"""Seat turn structured-output schema + one-shot format repair (Phase 3).""" from __future__ import annotations import json import re from collections.abc import Awaitable, Callable from typing import Any from pydantic import BaseModel, Field, ValidationError RepairRunner = Callable[[str], Awaitable[str]] class SeatTurnOutput(BaseModel): """Validated structured output produced by an expert seat turn.""" summary: str = Field(..., min_length=1, description="Short natural-language summary") findings: list[str] = Field(default_factory=list) evidence_refs: list[str | dict[str, Any]] = Field(default_factory=list) proposed_actions: list[str | dict[str, Any]] = Field(default_factory=list) # Model-claimed executed actions are informational only — the fact ledger # never trusts them; ActionCoordinator is the sole source of truth. executed_actions: list[str | dict[str, Any]] = Field(default_factory=list) open_questions: list[str] = Field(default_factory=list) conflicts: list[str] = Field(default_factory=list) class ConfirmedCandidate(BaseModel): """A candidate fact the cross-review confirms, WITH required evidence. The server only promotes a candidate to confirmed_facts when: the candidate_id exists in candidate_facts AND at least one evidence_ref is supplied AND every evidence_ref resolves to a known evidence/tool result in the ledger. This prevents a weak model from promoting a bare seat claim to an "established fact" with no backing. """ candidate_id: str = Field(..., min_length=1) evidence_refs: list[str] = Field(..., min_length=1) class CrossReviewOutput(BaseModel): """Validated output of the cross-review stage.""" summary: str = Field(..., min_length=1) # Conservative default: a review must positively assert evidence is OK; we # never assume it. (Previously defaulted True, which let a model that # omitted the field count as "evidence sufficient".) evidence_ok: bool = False gaps: list[str] = Field(default_factory=list) conflicts: list[str] = Field(default_factory=list) tool_failures: list[str] = Field(default_factory=list) write_status_notes: list[str] = Field(default_factory=list) recommendations: list[str] = Field(default_factory=list) # Phase-3 P1-1: confirmed candidates now carry mandatory evidence. The # legacy bare-id list is still accepted (for back-compat with old prompts) # but is treated as "no evidence" and therefore NOT promoted. confirmed_candidates: list[ConfirmedCandidate] = Field(default_factory=list) confirmed_candidate_ids: list[str] = Field(default_factory=list) _JSON_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE) def _extract_json_object(text: str) -> str | None: """Best-effort extract of a JSON object from model text.""" raw = (text or "").strip() if not raw: return None m = _JSON_FENCE_RE.search(raw) if m: raw = m.group(1).strip() if raw.startswith("{") and raw.endswith("}"): return raw start = raw.find("{") end = raw.rfind("}") if start >= 0 and end > start: return raw[start : end + 1] return None def parse_seat_output(text: str) -> tuple[SeatTurnOutput | None, list[str]]: """Parse seat text into SeatTurnOutput. Returns (model, readable_errors).""" blob = _extract_json_object(text) if blob is None: return None, ["output is not a JSON object; emit a single JSON object matching SeatTurnOutput"] try: data = json.loads(blob) except json.JSONDecodeError as exc: return None, [f"invalid JSON: {exc.msg}"] if not isinstance(data, dict): return None, ["JSON root must be an object"] try: return SeatTurnOutput.model_validate(data), [] except ValidationError as exc: errors = [] for err in exc.errors(): loc = ".".join(str(p) for p in err.get("loc", ())) errors.append(f"{loc}: {err.get('msg', 'invalid')}") return None, errors or ["schema validation failed"] def parse_cross_review_output(text: str) -> tuple[CrossReviewOutput | None, list[str]]: blob = _extract_json_object(text) if blob is None: return None, ["output is not a JSON object; emit CrossReviewOutput JSON"] try: data = json.loads(blob) except json.JSONDecodeError as exc: return None, [f"invalid JSON: {exc.msg}"] if not isinstance(data, dict): return None, ["JSON root must be an object"] try: return CrossReviewOutput.model_validate(data), [] except ValidationError as exc: errors = [ f"{'.'.join(str(p) for p in e.get('loc', ()))}: {e.get('msg', 'invalid')}" for e in exc.errors() ] return None, errors or ["schema validation failed"] def build_repair_prompt(raw: str, errors: list[str], *, schema_name: str = "SeatTurnOutput") -> str: """Build a one-shot repair instruction for the seat.""" err_lines = "\n".join(f"- {e}" for e in errors[:12]) return ( f"你上一次输出未通过 {schema_name} 校验。请只输出一个 JSON 对象(不要 Markdown 说明)," f"修正下列问题后重试一次:\n{err_lines}\n\n" f"原始输出(供参考,请重写为合法 JSON):\n{raw[:4000]}" ) def seat_output_instruction() -> str: """Stage instruction fragment telling the model to emit SeatTurnOutput JSON.""" return ( "请以 JSON 对象回复,字段:summary(string,必填)、findings(string[])、" "evidence_refs、proposed_actions、executed_actions、open_questions、conflicts。" "proposed_actions 只表示建议;不要把未真正由系统执行的外部写入标为已执行。" ) async def validate_or_repair_seat_output( *, raw_content: str, repair_runner: RepairRunner | None, ) -> dict[str, Any]: """Validate seat output; optionally run one repair turn via ``repair_runner``. ``repair_runner`` is an async callable ``(repair_prompt: str) -> str``. Returns:: { "schema_status": "ok" | "schema_invalid" | "repaired", "output": dict | None, "errors": [...], "raw_content": str, "repair_attempted": bool, } """ model, errors = parse_seat_output(raw_content) if model is not None: return { "schema_status": "ok", "output": model.model_dump(), "errors": [], "raw_content": raw_content, "repair_attempted": False, } if repair_runner is None: return { "schema_status": "schema_invalid", "output": None, "errors": errors, "raw_content": raw_content, "repair_attempted": False, } repair_prompt = build_repair_prompt(raw_content, errors) try: repaired_text = await repair_runner(repair_prompt) except Exception as exc: # noqa: BLE001 return { "schema_status": "schema_invalid", "output": None, "errors": errors + [f"repair failed: {type(exc).__name__}"], "raw_content": raw_content, "repair_attempted": True, } model2, errors2 = parse_seat_output(str(repaired_text or "")) if model2 is not None: return { "schema_status": "repaired", "output": model2.model_dump(), "errors": [], "raw_content": str(repaired_text), "repair_attempted": True, } return { "schema_status": "schema_invalid", "output": None, "errors": errors2 or errors, "raw_content": raw_content, "repair_attempted": True, }