feat(v2): T10 轨迹回放与会议结果复盘——只读轨迹、unmatched 单列、更正追加

覆盖 AC-22/32/33(BR-13/21/31/32、E-05/23/26)。

后端:
- contracts/trajectory.py:TrajectoryStep 与 ReviewOutcome。
  TrajectoryStep 失败必有错误码、成本未知时币种留空、observable() 给出
  可读字段白名单(机器可读地排除任何推理过程字段);
  ReviewOutcome 只在 matched 且有明确映射口径时才有二元标签,附条件/撤回/
  未送审一律留空,unmatched 不得挂快照。
- services/trajectory.py:
  · project_steps 把既有 run 的步骤投影成轨迹(顺序即 sequence,部分完成
    如实标注,失败保留,成本缺省留空不填 0);
  · replay 只读回放并按 sequence 稳定分页,不存在或跨项目抛 KeyError;
  · record 追加会议结果:未知快照记 unmatched,更正要指向本项目既有记录,
    链必须能终止;denominator 只数 matched 且有通过/否决口径的记录。
- api/prescreen.py:新增轨迹回放、会议结果列表与追加三个端点。

未做:轨迹写入仍由既有 DurableWorker.save_step 负责(本批不重做运行执行),
因此目前没有 judgment/rule 类步骤的产出方;会议标签映射(E-26)仍属 T11。

验证:backend 全量 unittest 494 项 OK(T09 时 469 项,无回归)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:19:33 +08:00
co-authored by Claude Code
parent 22f8a37799
commit f2bbac03e7
4 changed files with 641 additions and 1 deletions
+67 -1
View File
@@ -34,6 +34,7 @@ from api.project_assistant import enabled
from api.risk_agent_access import authorize, check_project
from contracts.prescreen import (
EVIDENCE_MAX,
ID_MAX,
MAX_BATCH_FILES,
NOTE_MAX,
ErrorCode,
@@ -41,7 +42,7 @@ from contracts.prescreen import (
S1_ERROR_RETRYABLE,
)
from services import assistant_store as store
from services import fact_conflict, material_import, prescreen_sandbox, prescreen_store
from services import fact_conflict, material_import, prescreen_sandbox, prescreen_store, trajectory
from services.assistant_settings import flags
from services.fact_conflict import FactConflictUnresolved
from services.project_assistant import ProjectAssistantService
@@ -91,6 +92,20 @@ class SimulationInput(Input):
expected_revision: int | None = Field(default=None, ge=1)
class OutcomeInput(Input):
"""T10 会议结果。原始结果码必填;未提供时不得推断成通过或否决。"""
raw_code: str = Field(min_length=1, max_length=100)
meeting_id: str = Field(min_length=1, max_length=ID_MAX)
normalized: str | None = Field(default=None, max_length=40)
occurred_at: str | None = None
reviewed_snapshot_id: str | None = Field(default=None, max_length=ID_MAX)
supersedes_id: str | None = Field(default=None, max_length=ID_MAX)
mapping_ref: dict | None = None
conditions: list[str] = Field(default_factory=list, max_length=50)
evidence_ids: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
# ---------------------------------------------------------------- 错误信封
def _envelope(request: Request, code: ErrorCode, message: str) -> JSONResponse:
request_id = request.headers.get("x-request-id") or store.uid("req")
@@ -452,3 +467,54 @@ def create_simulation(project_id: str, run_id: str, payload: SimulationInput,
return result
return run(request, action)
# ---------------------------------------------------------------- 轨迹与会议结果(T10)
@router.get("/projects/{project_id}/runs/{run_id}/trajectory")
def get_trajectory(project_id: str, run_id: str, request: Request,
cursor: str | None = None, limit: int = 50):
"""只读轨迹回放。失败的步骤照常返回,不做美化过滤。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
return trajectory.replay(db, project_id, run_id, cursor, _limit(limit))
return run(request, action)
@router.get("/projects/{project_id}/review-outcomes")
def list_review_outcomes(project_id: str, request: Request, status: str | None = None):
"""会议结果列表。unmatched 单列,不混进已观察通过率的分母。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
if status not in (None, "matched", "unmatched"):
return _envelope(request, ErrorCode.INVALID_REQUEST,
"状态取值须为 matched 或 unmatched")
def action():
with _db() as db:
return trajectory.listing(db, project_id, status)
return run(request, action)
@router.post("/projects/{project_id}/review-outcomes", status_code=201)
def record_review_outcome(project_id: str, payload: OutcomeInput, request: Request):
"""追加会议结果。找不到材料版本的一律记 unmatched;更正只能新增。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
runs = store.listing(db, "run", project_id)
snapshots = [item.get("input_snapshot_id") for item in runs
if item.get("input_snapshot_id")]
return trajectory.record(db, project_id, payload.model_dump(), _actor(request),
known_snapshots=snapshots)
return run(request, action)