Files

207 lines
7.6 KiB
Python

"""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,
}