207 lines
7.6 KiB
Python
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,
|
|
}
|