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:
@@ -34,6 +34,7 @@ from api.project_assistant import enabled
|
|||||||
from api.risk_agent_access import authorize, check_project
|
from api.risk_agent_access import authorize, check_project
|
||||||
from contracts.prescreen import (
|
from contracts.prescreen import (
|
||||||
EVIDENCE_MAX,
|
EVIDENCE_MAX,
|
||||||
|
ID_MAX,
|
||||||
MAX_BATCH_FILES,
|
MAX_BATCH_FILES,
|
||||||
NOTE_MAX,
|
NOTE_MAX,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
@@ -41,7 +42,7 @@ from contracts.prescreen import (
|
|||||||
S1_ERROR_RETRYABLE,
|
S1_ERROR_RETRYABLE,
|
||||||
)
|
)
|
||||||
from services import assistant_store as store
|
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.assistant_settings import flags
|
||||||
from services.fact_conflict import FactConflictUnresolved
|
from services.fact_conflict import FactConflictUnresolved
|
||||||
from services.project_assistant import ProjectAssistantService
|
from services.project_assistant import ProjectAssistantService
|
||||||
@@ -91,6 +92,20 @@ class SimulationInput(Input):
|
|||||||
expected_revision: int | None = Field(default=None, ge=1)
|
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:
|
def _envelope(request: Request, code: ErrorCode, message: str) -> JSONResponse:
|
||||||
request_id = request.headers.get("x-request-id") or store.uid("req")
|
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 result
|
||||||
|
|
||||||
return run(request, action)
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""T10 轨迹与会议结果契约。
|
||||||
|
|
||||||
|
Owner:本文件(docs/v2/03-detailed-design.md §9.1 的 TrajectoryStep / ReviewOutcome)。
|
||||||
|
`contracts/prescreen.py` 头部已明确把这两个对象排除在该文件之外,这里补上。
|
||||||
|
|
||||||
|
两条不可让渡的规则:
|
||||||
|
1. **只记录可观察动作**:步骤只保存计划、组件版本、输入输出引用、理由摘要与
|
||||||
|
错误码;**不存在**隐藏思维链字段,也不采集模型内部推理(BR-31 / AC-32)。
|
||||||
|
2. **结果追加,不覆盖**:会议结果只能新增并指向 `supersedes_id`,绝不改写既有
|
||||||
|
记录或历史预测(BR-13 / AC-33)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field, model_validator
|
||||||
|
|
||||||
|
from contracts.prescreen import (
|
||||||
|
DESC_MAX,
|
||||||
|
EVIDENCE_MAX,
|
||||||
|
ID_MAX,
|
||||||
|
AwareDatetime,
|
||||||
|
Contract,
|
||||||
|
EventOutcome,
|
||||||
|
Ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
STEP_KINDS = ("plan", "skill", "tool", "rule", "judgment", "synthesis")
|
||||||
|
STEP_STATUSES = ("queued", "running", "completed", "skipped", "failed", "cancelled")
|
||||||
|
|
||||||
|
|
||||||
|
class TrajectoryStep(Contract):
|
||||||
|
"""一次运行里的一个可观察步骤。`failed` 步骤保留而不是被过滤掉。"""
|
||||||
|
|
||||||
|
id: str = Field(min_length=1, max_length=ID_MAX)
|
||||||
|
run_id: str = Field(min_length=1, max_length=ID_MAX)
|
||||||
|
parent_step_id: str | None = Field(default=None, max_length=ID_MAX)
|
||||||
|
sequence: int = Field(ge=0)
|
||||||
|
kind: Literal["plan", "skill", "tool", "rule", "judgment", "synthesis"]
|
||||||
|
component_ref: Ref | None = None
|
||||||
|
input_refs: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
|
||||||
|
output_refs: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
|
||||||
|
evidence_ids: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
|
||||||
|
status: Literal["queued", "running", "completed", "skipped", "failed", "cancelled"]
|
||||||
|
started_at: AwareDatetime | None = None
|
||||||
|
finished_at: AwareDatetime | None = None
|
||||||
|
reason_summary: str | None = Field(default=None, max_length=DESC_MAX)
|
||||||
|
error_code: str | None = Field(default=None, max_length=ID_MAX)
|
||||||
|
latency_ms: int | None = Field(default=None, ge=0)
|
||||||
|
cost: float | None = Field(default=None, ge=0)
|
||||||
|
cost_currency: str | None = Field(default=None, max_length=16)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_step(self) -> TrajectoryStep:
|
||||||
|
if self.status == "failed" and not self.error_code:
|
||||||
|
raise ValueError("失败的步骤必须带错误码,不能只留一条看不出原因的记录")
|
||||||
|
if self.cost is None and self.cost_currency is not None:
|
||||||
|
raise ValueError("成本未知时币种也必须留空,不能只写币种")
|
||||||
|
if self.parent_step_id == self.id:
|
||||||
|
raise ValueError("步骤不能以自己为父步骤")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def observable(self) -> dict:
|
||||||
|
"""对外可读字段白名单:排除任何推理过程字段(AC-32 的机器可读断言)。"""
|
||||||
|
body = self.model_dump(mode="json")
|
||||||
|
return {key: body[key] for key in (
|
||||||
|
"id", "run_id", "parent_step_id", "sequence", "kind", "component_ref",
|
||||||
|
"input_refs", "output_refs", "evidence_ids", "status", "started_at",
|
||||||
|
"finished_at", "reason_summary", "error_code", "latency_ms", "cost",
|
||||||
|
"cost_currency")}
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewOutcome(Contract):
|
||||||
|
"""评审会结果记录。`label` 只在口径明确时才有值,未确认的一律为空。"""
|
||||||
|
|
||||||
|
id: str = Field(min_length=1, max_length=ID_MAX)
|
||||||
|
project_id: str = Field(min_length=1, max_length=ID_MAX)
|
||||||
|
meeting_id: str = Field(min_length=1, max_length=ID_MAX)
|
||||||
|
revision: int = Field(ge=1)
|
||||||
|
occurred_at: AwareDatetime
|
||||||
|
recorded_at: AwareDatetime
|
||||||
|
reviewed_snapshot_id: str | None = Field(default=None, max_length=ID_MAX)
|
||||||
|
raw_code: str = Field(min_length=1, max_length=100)
|
||||||
|
normalized: EventOutcome
|
||||||
|
mapping_ref: Ref | None = None
|
||||||
|
label: bool | None = None
|
||||||
|
conditions: list[str] = Field(default_factory=list, max_length=50)
|
||||||
|
evidence_ids: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX)
|
||||||
|
status: Literal["matched", "unmatched"]
|
||||||
|
supersedes_id: str | None = Field(default=None, max_length=ID_MAX)
|
||||||
|
recorded_by: str = Field(min_length=1, max_length=ID_MAX)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_outcome(self) -> ReviewOutcome:
|
||||||
|
if self.status == "unmatched" and self.reviewed_snapshot_id is not None:
|
||||||
|
raise ValueError("找不到对应材料版本时应记为 unmatched,不得挂上任何快照")
|
||||||
|
if self.status == "matched" and self.reviewed_snapshot_id is None:
|
||||||
|
raise ValueError("matched 必须指向被评审的材料版本")
|
||||||
|
if self.label is not None:
|
||||||
|
if self.mapping_ref is None or self.status != "matched":
|
||||||
|
raise ValueError("没有明确映射口径或材料版本未匹配时不得给出二元标签")
|
||||||
|
if self.normalized not in (EventOutcome.approved, EventOutcome.rejected):
|
||||||
|
raise ValueError("只有通过/否决才有二元标签;附条件、撤回、未送审一律留空")
|
||||||
|
if self.supersedes_id == self.id:
|
||||||
|
raise ValueError("更正不能指向自己")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def counts_toward_denominator(self) -> bool:
|
||||||
|
"""未送审、撤回、附条件、未知都不进入已观察通过率的分母(AC-22/37)。"""
|
||||||
|
return self.status == "matched" and self.normalized in (
|
||||||
|
EventOutcome.approved, EventOutcome.rejected)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""T10 轨迹回放与会议结果复盘。
|
||||||
|
|
||||||
|
依据:docs/v2/03-detailed-design.md §9.1/§9.4、14-experience-loop.md §7/§8;
|
||||||
|
BR-13/31/32、E-05/23/26、AC-22/32/33。
|
||||||
|
|
||||||
|
三条不可让渡的规则:
|
||||||
|
1. **只读回放**:轨迹从既有 run 的步骤投影出来,本模块不写 run,也不改历史结果。
|
||||||
|
2. **失败保留**:失败的步骤照样出现并带错误码,不因为"看起来不好"被过滤。
|
||||||
|
3. **结果追加**:会议结果只能新增;更正生成新记录并指向 `supersedes_id`,
|
||||||
|
找不到材料版本的一律记 unmatched 并单列,不当成否决、也不进通过率分母。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contracts.prescreen import EventOutcome
|
||||||
|
from contracts.trajectory import ReviewOutcome, TrajectoryStep
|
||||||
|
from services import assistant_store as store
|
||||||
|
|
||||||
|
KIND = "review_outcome"
|
||||||
|
DEFAULT_PAGE = 50
|
||||||
|
|
||||||
|
# 既有步骤状态 → 轨迹状态。partial 不是完成,保留原样说明,避免看起来"全过"。
|
||||||
|
_STATUS_MAP = {"running": "running", "completed": "completed", "partial": "completed",
|
||||||
|
"failed": "failed", "skipped": "skipped", "cancelled": "cancelled"}
|
||||||
|
_PARTIAL_NOTE = "部分完成(原始状态 partial);未完成的部分不计入已核查"
|
||||||
|
_FAILURE_CODE_FALLBACK = "RUN_STEP_FAILED"
|
||||||
|
|
||||||
|
RAW_ALIASES = {
|
||||||
|
"通过": EventOutcome.approved, "同意": EventOutcome.approved,
|
||||||
|
"否决": EventOutcome.rejected, "未通过": EventOutcome.rejected,
|
||||||
|
"附条件": EventOutcome.conditional, "附条件通过": EventOutcome.conditional,
|
||||||
|
"暂缓": EventOutcome.deferred, "撤回": EventOutcome.withdrawn,
|
||||||
|
"未送审": EventOutcome.not_submitted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _step(run_body: dict, raw: dict, index: int) -> TrajectoryStep:
|
||||||
|
status = _STATUS_MAP.get(str(raw.get("status")), "skipped")
|
||||||
|
errors = raw.get("errors") or ()
|
||||||
|
notes = []
|
||||||
|
if raw.get("status") == "partial":
|
||||||
|
notes.append(_PARTIAL_NOTE)
|
||||||
|
if raw.get("missing_inputs"):
|
||||||
|
notes.append("缺证:" + "、".join(str(item) for item in raw["missing_inputs"]))
|
||||||
|
skill = raw.get("skill") or {}
|
||||||
|
component = {"id": skill["id"], "version": str(skill.get("version", ""))} if skill.get("id") else None
|
||||||
|
return TrajectoryStep(
|
||||||
|
id=str(raw.get("step_id")), run_id=str(raw.get("run_id") or run_body.get("id")),
|
||||||
|
parent_step_id=None, sequence=index,
|
||||||
|
kind="skill" if raw.get("skill") else "tool" if raw.get("tool") else "plan",
|
||||||
|
component_ref=component,
|
||||||
|
input_refs=[str(raw.get("input_snapshot_id"))] if raw.get("input_snapshot_id") else [],
|
||||||
|
output_refs=sorted(str(key) for key in (raw.get("outputs") or {})),
|
||||||
|
evidence_ids=sorted({str(item) for finding in raw.get("findings") or ()
|
||||||
|
for item in (finding.get("evidence_ids") or ())}),
|
||||||
|
status=status,
|
||||||
|
started_at=raw.get("started_at"), finished_at=raw.get("finished_at"),
|
||||||
|
reason_summary=";".join(notes) or None,
|
||||||
|
error_code=(str(errors[0].get("code")) if errors and errors[0].get("code")
|
||||||
|
else _FAILURE_CODE_FALLBACK) if status == "failed" else None,
|
||||||
|
latency_ms=raw.get("latency_ms"), cost=None, cost_currency=None)
|
||||||
|
# 成本缺省一律留空:既有运行没有成本口径时不得填 0 或估计值(06 §5)。
|
||||||
|
|
||||||
|
|
||||||
|
def project_steps(run_body: dict) -> list[dict]:
|
||||||
|
"""把既有 run 的步骤投影成轨迹步骤,顺序即 sequence,不改写原 run。"""
|
||||||
|
return [_step(run_body, raw, index).model_dump(mode="json")
|
||||||
|
for index, raw in enumerate(run_body.get("steps") or ())]
|
||||||
|
|
||||||
|
|
||||||
|
def replay(db, project_id: str, run_id: str, cursor: str | None = None,
|
||||||
|
limit: int = DEFAULT_PAGE) -> dict:
|
||||||
|
"""只读回放:按 sequence 稳定分页。不存在或跨项目 → KeyError(接口层转 404)。"""
|
||||||
|
run = store.get(db, "run", run_id)
|
||||||
|
if run.get("project_id") != project_id:
|
||||||
|
raise KeyError("运行不存在或不属于该项目")
|
||||||
|
steps = project_steps(run)
|
||||||
|
start = max(0, int(cursor or 0))
|
||||||
|
window = steps[start:start + max(1, min(limit, 200))]
|
||||||
|
end = start + len(window)
|
||||||
|
return {
|
||||||
|
"run_id": run_id, "project_id": project_id, "status": run.get("status"),
|
||||||
|
"snapshot_id": run.get("input_snapshot_id"),
|
||||||
|
"steps": window,
|
||||||
|
"next_cursor": str(end) if end < len(steps) else None,
|
||||||
|
"read_only": True,
|
||||||
|
"note": "轨迹只记录可观察动作与版本;不包含模型内部推理过程",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _outcomes(db, project_id: str) -> list[dict]:
|
||||||
|
return list(store.listing(db, KIND, project_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _chain_has_cycle(rows: list[dict], start: str) -> bool:
|
||||||
|
"""防御性检查:追加写入本身造不出环,但迁移/导入的旧行可能有(03 §9.1)。"""
|
||||||
|
seen, current = set(), start
|
||||||
|
while current:
|
||||||
|
if current in seen:
|
||||||
|
return True
|
||||||
|
seen.add(current)
|
||||||
|
current = next((item.get("supersedes_id") for item in rows
|
||||||
|
if item["id"] == current), None)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def record(db, project_id: str, body: dict, actor: str,
|
||||||
|
known_snapshots=()) -> dict:
|
||||||
|
"""追加一条会议结果。更正只能新增,且必须指向同项目的旧记录。"""
|
||||||
|
rows = _outcomes(db, project_id)
|
||||||
|
raw_code = str(body.get("raw_code") or "").strip()
|
||||||
|
if not raw_code:
|
||||||
|
raise ValueError("会议结果必须给出原始结果码,未提供时不得推断")
|
||||||
|
|
||||||
|
normalized = body.get("normalized") or RAW_ALIASES.get(raw_code, EventOutcome.unknown)
|
||||||
|
if isinstance(normalized, str):
|
||||||
|
normalized = EventOutcome(normalized)
|
||||||
|
snapshot = body.get("reviewed_snapshot_id")
|
||||||
|
matched = bool(snapshot) and (not known_snapshots or snapshot in set(known_snapshots))
|
||||||
|
|
||||||
|
supersedes = body.get("supersedes_id")
|
||||||
|
if supersedes:
|
||||||
|
previous = next((item for item in rows if item["id"] == supersedes), None)
|
||||||
|
if previous is None:
|
||||||
|
raise ValueError("更正要指向本项目既有的会议结果记录")
|
||||||
|
if _chain_has_cycle(rows, supersedes):
|
||||||
|
raise ValueError("更正链出现循环,已拒绝")
|
||||||
|
mapping_ref = body.get("mapping_ref")
|
||||||
|
label = None
|
||||||
|
if matched and mapping_ref and normalized in (EventOutcome.approved, EventOutcome.rejected):
|
||||||
|
label = normalized == EventOutcome.approved
|
||||||
|
|
||||||
|
revision = 1 + max([item.get("revision", 0) for item in rows
|
||||||
|
if item.get("meeting_id") == body.get("meeting_id")] or [0])
|
||||||
|
outcome = ReviewOutcome(
|
||||||
|
id=store.uid("outcome"), project_id=project_id,
|
||||||
|
meeting_id=str(body.get("meeting_id") or ""), revision=revision,
|
||||||
|
occurred_at=body.get("occurred_at") or store.now(), recorded_at=store.now(),
|
||||||
|
reviewed_snapshot_id=snapshot if matched else None,
|
||||||
|
raw_code=raw_code, normalized=normalized, mapping_ref=mapping_ref, label=label,
|
||||||
|
conditions=list(body.get("conditions") or ()),
|
||||||
|
evidence_ids=list(body.get("evidence_ids") or ()),
|
||||||
|
status="matched" if matched else "unmatched",
|
||||||
|
supersedes_id=supersedes, recorded_by=actor)
|
||||||
|
saved = outcome.model_dump(mode="json")
|
||||||
|
store.put(db, KIND, saved)
|
||||||
|
store.event(db, project_id, "review_outcome.recorded", actor,
|
||||||
|
{"outcome_id": saved["id"], "status": saved["status"],
|
||||||
|
"raw_code": saved["raw_code"]}, None)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def listing(db, project_id: str, status: str | None = None) -> dict:
|
||||||
|
"""按匹配状态分组返回。unmatched 单列,不混进通过率分母。"""
|
||||||
|
rows = sorted(_outcomes(db, project_id),
|
||||||
|
key=lambda item: item.get("recorded_at") or "")
|
||||||
|
observed = [item for item in rows if item["status"] == "matched"]
|
||||||
|
unmatched = [item for item in rows if item["status"] == "unmatched"]
|
||||||
|
if status == "matched":
|
||||||
|
return {"items": observed}
|
||||||
|
if status == "unmatched":
|
||||||
|
return {"items": unmatched}
|
||||||
|
return {"items": rows, "matched": observed, "unmatched": unmatched,
|
||||||
|
"counts_toward_denominator": denominator(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
def denominator(rows) -> dict:
|
||||||
|
"""已观察分母只数"匹配到材料版本且有通过/否决口径"的记录(AC-22/37)。"""
|
||||||
|
labelled = [item for item in rows if item.get("status") == "matched"
|
||||||
|
and item.get("normalized") in ("approved", "rejected")]
|
||||||
|
excluded = [(item["id"], item.get("raw_code"), item.get("normalized"))
|
||||||
|
for item in rows if item not in labelled]
|
||||||
|
return {"observed": len(labelled), "excluded": excluded,
|
||||||
|
"note": "未送审、撤回、附条件与未知结果不计入已观察通过率;不把未知当否决"}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""T10 轨迹回放与会议结果复盘测试。
|
||||||
|
|
||||||
|
覆盖 AC-32(一个 run 多 step、失败保留、可追溯、不伪造隐藏推理)
|
||||||
|
与 AC-33(unmatched 单列、更正追加并引用旧版、不改原预测),
|
||||||
|
以及 06 §5 要求的"成本未知不得伪造"。
|
||||||
|
|
||||||
|
同时验证只读性:回放与追加会议结果都不改动既有 run。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from api import prescreen as api
|
||||||
|
from contracts.trajectory import ReviewOutcome, TrajectoryStep
|
||||||
|
from services import assistant_store as store
|
||||||
|
from services import trajectory
|
||||||
|
from services.project_assistant import ProjectAssistantService
|
||||||
|
|
||||||
|
HEADERS = {"X-Xingbang-Demo-Role": "manager"}
|
||||||
|
PROJECT = "DEMO-B"
|
||||||
|
|
||||||
|
|
||||||
|
def step(step_id, status="completed", **over):
|
||||||
|
body = {"run_id": "run-1", "step_id": step_id, "name": f"步骤 {step_id}",
|
||||||
|
"skill": {"id": step_id, "version": "1.0.0"}, "status": status,
|
||||||
|
"input_snapshot_id": "snap-1", "started_at": "2026-09-30T01:00:00+00:00",
|
||||||
|
"finished_at": "2026-09-30T01:00:03+00:00", "attempt": 1,
|
||||||
|
"outputs": {"requirement_response": "已核查"},
|
||||||
|
"findings": [{"code": f"{step_id}.check", "result": "issue",
|
||||||
|
"evidence_ids": ["EV-1"]}],
|
||||||
|
"errors": [], "missing_inputs": [], "output_sources": {}}
|
||||||
|
body.update(over)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def run_body(steps=None, **over):
|
||||||
|
body = {"id": "run-1", "project_id": PROJECT, "status": "completed",
|
||||||
|
"input_snapshot_id": "snap-1", "revision": 1,
|
||||||
|
"created_at": "2026-09-30T01:00:00+00:00",
|
||||||
|
"steps": steps if steps is not None else [step("corporate.cashflow")]}
|
||||||
|
body.update(over)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class TrajectoryProjectionTests(unittest.TestCase):
|
||||||
|
def test_one_run_becomes_one_trajectory_with_many_steps(self):
|
||||||
|
rows = trajectory.project_steps(run_body(steps=[
|
||||||
|
step("corporate.cashflow"), step("corporate.title"),
|
||||||
|
step("corporate.deal", status="failed",
|
||||||
|
errors=[{"code": "SKILL_TIMEOUT", "message": "模型超时"}])]))
|
||||||
|
self.assertEqual([item["sequence"] for item in rows], [0, 1, 2])
|
||||||
|
self.assertEqual({item["run_id"] for item in rows}, {"run-1"})
|
||||||
|
self.assertEqual(len({item["id"] for item in rows}), 3)
|
||||||
|
|
||||||
|
def test_failed_step_is_preserved_with_a_code(self):
|
||||||
|
rows = trajectory.project_steps(run_body(steps=[
|
||||||
|
step("a"), step("b", status="failed",
|
||||||
|
errors=[{"code": "SKILL_TIMEOUT", "message": "超时"}])]))
|
||||||
|
failed = [item for item in rows if item["status"] == "failed"]
|
||||||
|
self.assertEqual(len(failed), 1, "失败的步骤不得被过滤掉")
|
||||||
|
self.assertEqual(failed[0]["error_code"], "SKILL_TIMEOUT")
|
||||||
|
|
||||||
|
def test_failed_step_without_error_detail_still_carries_a_code(self):
|
||||||
|
rows = trajectory.project_steps(run_body(steps=[step("b", status="failed")]))
|
||||||
|
self.assertTrue(rows[0]["error_code"], "失败必须留痕,不能只有一条看不出原因的记录")
|
||||||
|
|
||||||
|
def test_partial_is_not_reported_as_fully_checked(self):
|
||||||
|
rows = trajectory.project_steps(run_body(steps=[step("a", status="partial",
|
||||||
|
missing_inputs=["银行流水"])]))
|
||||||
|
self.assertIn("部分完成", rows[0]["reason_summary"])
|
||||||
|
self.assertIn("银行流水", rows[0]["reason_summary"])
|
||||||
|
|
||||||
|
def test_steps_expose_only_observable_fields(self):
|
||||||
|
rows = trajectory.project_steps(run_body())
|
||||||
|
allowed = set(TrajectoryStep.model_fields)
|
||||||
|
for row in rows:
|
||||||
|
self.assertTrue(set(row) <= allowed)
|
||||||
|
for banned in ("thought", "reasoning", "chain_of_thought", "scratchpad",
|
||||||
|
"hidden_state", "prompt"):
|
||||||
|
self.assertNotIn(banned, rows[0], "轨迹不得包含模型内部推理字段")
|
||||||
|
|
||||||
|
def test_cost_is_unknown_not_zero(self):
|
||||||
|
rows = trajectory.project_steps(run_body())
|
||||||
|
self.assertIsNone(rows[0]["cost"], "没有成本口径时应留空,不得填 0 或估计值")
|
||||||
|
self.assertIsNone(rows[0]["cost_currency"])
|
||||||
|
|
||||||
|
def test_evidence_and_versions_are_traceable(self):
|
||||||
|
rows = trajectory.project_steps(run_body())
|
||||||
|
self.assertEqual(rows[0]["evidence_ids"], ["EV-1"])
|
||||||
|
self.assertEqual(rows[0]["component_ref"], {"id": "corporate.cashflow",
|
||||||
|
"version": "1.0.0"})
|
||||||
|
self.assertEqual(rows[0]["input_refs"], ["snap-1"])
|
||||||
|
|
||||||
|
|
||||||
|
class TrajectoryStoreTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
|
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
store.put(db, "run", run_body(steps=[step("a"), step("b", status="failed")]))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_replay_is_read_only_and_paginated(self):
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
before = store.get(db, "run", "run-1")
|
||||||
|
page = trajectory.replay(db, PROJECT, "run-1", limit=1)
|
||||||
|
after = store.get(db, "run", "run-1")
|
||||||
|
self.assertEqual(len(page["steps"]), 1)
|
||||||
|
self.assertEqual(page["next_cursor"], "1")
|
||||||
|
self.assertTrue(page["read_only"])
|
||||||
|
self.assertEqual(before, after, "回放不得改动运行")
|
||||||
|
|
||||||
|
def test_replay_rejects_foreign_project(self):
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
with self.assertRaises(KeyError):
|
||||||
|
trajectory.replay(db, "OTHER-9", "run-1")
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewOutcomeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
|
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def record(self, **over):
|
||||||
|
body = {"raw_code": "通过", "meeting_id": "M-1",
|
||||||
|
"occurred_at": "2026-09-29T02:00:00+00:00",
|
||||||
|
"reviewed_snapshot_id": "snap-1", "evidence_ids": ["EV-1"]}
|
||||||
|
body.update(over)
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
return trajectory.record(db, PROJECT, body, "reviewer-1",
|
||||||
|
known_snapshots=["snap-1"])
|
||||||
|
|
||||||
|
def test_unknown_snapshot_is_unmatched_and_has_no_label(self):
|
||||||
|
saved = self.record(reviewed_snapshot_id=None)
|
||||||
|
self.assertEqual(saved["status"], "unmatched")
|
||||||
|
self.assertIsNone(saved["label"], "未匹配材料版本时不得给出二元标签")
|
||||||
|
self.assertIsNone(saved["reviewed_snapshot_id"])
|
||||||
|
|
||||||
|
def test_conditional_is_never_labeled_as_approved(self):
|
||||||
|
saved = self.record(raw_code="附条件通过")
|
||||||
|
self.assertEqual(saved["normalized"], "conditional")
|
||||||
|
self.assertIsNone(saved["label"], "附条件不等于通过(BR-21)")
|
||||||
|
|
||||||
|
def test_withdrawn_is_recorded_but_excluded_from_denominator(self):
|
||||||
|
self.record(raw_code="撤回")
|
||||||
|
self.record(raw_code="通过")
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
listed = trajectory.listing(db, PROJECT)
|
||||||
|
self.assertEqual(listed["counts_toward_denominator"]["observed"], 1)
|
||||||
|
excluded = listed["counts_toward_denominator"]["excluded"]
|
||||||
|
self.assertEqual([item[1] for item in excluded], ["撤回"])
|
||||||
|
|
||||||
|
def test_correction_appends_and_references_the_old_record(self):
|
||||||
|
first = self.record(raw_code="通过")
|
||||||
|
corrected = self.record(raw_code="否决", supersedes_id=first["id"])
|
||||||
|
self.assertEqual(corrected["supersedes_id"], first["id"])
|
||||||
|
self.assertGreater(corrected["revision"], first["revision"])
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
rows = trajectory.listing(db, PROJECT)["items"]
|
||||||
|
self.assertEqual(len(rows), 2, "更正必须追加,不得覆盖旧记录")
|
||||||
|
self.assertEqual(rows[0]["raw_code"], "通过")
|
||||||
|
|
||||||
|
def test_correction_must_point_to_an_existing_record_of_the_project(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.record(supersedes_id="outcome-missing")
|
||||||
|
|
||||||
|
def test_supersede_chains_always_terminate(self):
|
||||||
|
first = self.record(raw_code="通过")
|
||||||
|
second = self.record(raw_code="否决", supersedes_id=first["id"])
|
||||||
|
self.record(raw_code="附条件", supersedes_id=second["id"])
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
rows = trajectory.listing(db, PROJECT)["items"]
|
||||||
|
by_id = {item["id"]: item for item in rows}
|
||||||
|
for item in rows:
|
||||||
|
seen, current = set(), item["id"]
|
||||||
|
while current:
|
||||||
|
self.assertNotIn(current, seen, "更正链不得出现循环")
|
||||||
|
seen.add(current)
|
||||||
|
current = (by_id.get(current) or {}).get("supersedes_id")
|
||||||
|
self.assertEqual(len(rows), 3)
|
||||||
|
|
||||||
|
def test_missing_raw_code_is_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.record(raw_code="")
|
||||||
|
|
||||||
|
def test_recording_never_touches_runs(self):
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
store.put(db, "run", run_body())
|
||||||
|
before = None
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
before = store.get(db, "run", "run-1")
|
||||||
|
self.record(raw_code="否决")
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
after = store.get(db, "run", "run-1")
|
||||||
|
self.assertEqual(before, after, "会议结果不得改写历史预测")
|
||||||
|
|
||||||
|
def test_contract_refuses_label_without_mapping(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
ReviewOutcome(id="o-1", project_id=PROJECT, meeting_id="M-1", revision=1,
|
||||||
|
occurred_at="2026-09-29T02:00:00+00:00",
|
||||||
|
recorded_at="2026-09-29T02:00:00+00:00",
|
||||||
|
reviewed_snapshot_id="snap-1", raw_code="通过",
|
||||||
|
normalized="approved", label=True, status="matched",
|
||||||
|
recorded_by="reviewer-1")
|
||||||
|
|
||||||
|
|
||||||
|
class TrajectoryApiTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||||
|
self.service = ProjectAssistantService(Path(self.temp.name) / "assistant.sqlite3")
|
||||||
|
self.patches = [
|
||||||
|
patch.object(api, "_service", lambda: self.service),
|
||||||
|
patch.dict(os.environ, {"XINGBANG_AUTH_USERS": "", "XINGBANG_REQUIRE_AUTH": "0",
|
||||||
|
"XINGBANG_LOCAL_MODEL_URL": ""}),
|
||||||
|
]
|
||||||
|
for item in self.patches:
|
||||||
|
item.start()
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_exception_handler(RequestValidationError, api.validation_handler)
|
||||||
|
app.include_router(api.router)
|
||||||
|
self.client = TestClient(app)
|
||||||
|
self.base = "/api/xingbang/risk-agent/project-assistant/prescreen"
|
||||||
|
with store.transaction(self.service.db_path) as db:
|
||||||
|
store.put(db, "run", run_body(steps=[step("a"), step("b", status="failed",
|
||||||
|
errors=[{"code": "SKILL_TIMEOUT"}])]))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.client.close()
|
||||||
|
for item in reversed(self.patches):
|
||||||
|
item.stop()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_trajectory_replay_over_http(self):
|
||||||
|
response = self.client.get(f"{self.base}/projects/{PROJECT}/runs/run-1/trajectory",
|
||||||
|
headers=HEADERS)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertEqual([item["status"] for item in body["steps"]],
|
||||||
|
["completed", "failed"])
|
||||||
|
self.assertTrue(body["read_only"])
|
||||||
|
|
||||||
|
def test_trajectory_of_missing_run_is_404(self):
|
||||||
|
response = self.client.get(f"{self.base}/projects/{PROJECT}/runs/run-x/trajectory",
|
||||||
|
headers=HEADERS)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_outcome_round_trip(self):
|
||||||
|
created = self.client.post(
|
||||||
|
f"{self.base}/projects/{PROJECT}/review-outcomes",
|
||||||
|
json={"raw_code": "附条件通过", "meeting_id": "M-1"},
|
||||||
|
headers=HEADERS)
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
self.assertEqual(created.json()["status"], "unmatched")
|
||||||
|
self.assertIsNone(created.json()["label"])
|
||||||
|
|
||||||
|
listed = self.client.get(f"{self.base}/projects/{PROJECT}/review-outcomes",
|
||||||
|
headers=HEADERS)
|
||||||
|
self.assertEqual(len(listed.json()["unmatched"]), 1)
|
||||||
|
self.assertEqual(listed.json()["counts_toward_denominator"]["observed"], 0)
|
||||||
|
|
||||||
|
def test_outcome_without_raw_code_is_rejected(self):
|
||||||
|
response = self.client.post(
|
||||||
|
f"{self.base}/projects/{PROJECT}/review-outcomes",
|
||||||
|
json={"meeting_id": "M-1"}, headers=HEADERS)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
self.assertEqual(response.json()["error"]["code"], "INVALID_REQUEST")
|
||||||
|
|
||||||
|
def test_unknown_outcome_status_filter_is_rejected(self):
|
||||||
|
response = self.client.get(
|
||||||
|
f"{self.base}/projects/{PROJECT}/review-outcomes?status=weird",
|
||||||
|
headers=HEADERS)
|
||||||
|
self.assertEqual(response.status_code, 422)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user