Files
mini-openclaw-v2/backend/api/prescreen.py
T
qiaoqichaoandClaude Code d6bc8485f5 feat(v2): T11 改进治理与演示交付——对照门禁、泄漏作废、三模块演示脚本
覆盖 AC-15/16/34/37/41(BR-15/16/40、E-11/24)。

后端:
- contracts/improvement.py:ImprovementCandidate + ContrastReport/ContrastMetrics。
  零分母不得给比率或覆盖率(样本不足不是 0%);非独立评测不得给改进/退化结论;
  已发布必须引用既有不可变版本;没有对照证据不得进入待审核或已发布。
- services/improvements.py:
  · evaluate_contrast 在服务端判定泄漏——预测锁定前读到标签即整次评测作废(E-11),
    保留集来源进开发集的样本作废并单列,指标按分子/可观察分母/覆盖率/未观察分开报;
  · transition 状态机:draft→evaluating→ready→in_review→approved→released,
    自批拒绝、退化与结论不明拒绝发布、发布幂等且只引用既有版本、撤回只影响新运行。
- api/prescreen.py:候选列表、提出与状态机三个端点;评测请求体提交样本,
  泄漏与指标一律由服务端计算,不接受客户端交来的"成品报告"。
- contracts/prescreen.py:补 E-11/E-24 错误码与 409 映射。

前端一致性修正:
- src/lib/prescreen-run.ts 的呈现判定改用后端既有词汇
  (empty/list/grouped/radar/faceted,与 services/review_plan.presentation 一致),
  避免前后端各一套判定导致界面画出后端判定为不该画雷达的图。

演示交付:
- scripts/xingbang_prescreen_demo.py:按 08 §7 八步演示,只驱动已实现入口,
  未实现的步骤(批量清单、历史提炼、卡片治理)如实标注原因;
  工程演示与模型效果分列,本地专项/Jev/真实概率一律标未验证或未接通。
- backend/test_demo_script.py:冒烟断言八步状态、未实现原因与分列口径。

验证:backend 全量 unittest 519 项 OK;前端 npm run build 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-30 09:25:25 +08:00

599 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""S1 预审接口层:材料集合导入与事实确认。
命名空间(claude_use/T00/04-first-slice-contracts.md §1 定案 P1):
/api/xingbang/risk-agent/project-assistant/prescreen/...
挂在 project-assistant 前缀之下,因此**复用现有鉴权与代理,零改动**:
- `authorize()` 的角色→路径表已有 `path.startswith("project-assistant/")` → 客户经理放行;
reviewer 的写豁免同样覆盖该前缀(backend/api/risk_agent_access.py:49,53)。
- 同源代理 `/api/risk-agent/[...path]` 已转发 Authorization / X-Xingbang-Demo-Role /
Idempotency-Key(src/app/api/risk-agent/[...path]/route.ts:7)。
**不**使用设计草案原提的 `/api/prescreen` + `/api/core/prescreen`:那条路要求 admin
角色,且需改三处鉴权/代理代码。
错误信封(T00/04 §2 决策 D2):**只在本模块内**输出
`{error:{code,message,retryable,request_id}}`。不改 `api/project_assistant.translate()`,
也不注册 app 级处理器——既有端点的 `{"detail": ...}` 形状完全不变。
404 统一口径(03 §2):不存在与无访问权一律对外 404,不泄露对象是否存在。
既有 `check_project` 对无权项目抛 403 且被 5 个模块共用,故**不改它**,由本模块的
`scope()` 把访问失败包装成 404;演示域策略仍按 403(E-03 语义)。
"""
from __future__ import annotations
import hashlib
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.exception_handlers import request_validation_exception_handler
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from api.project_assistant import enabled
from api.risk_agent_access import authorize, check_project
from contracts.prescreen import (
DESC_MAX,
EVIDENCE_MAX,
ID_MAX,
MAX_BATCH_FILES,
NOTE_MAX,
ErrorCode,
S1_ERROR_HTTP,
S1_ERROR_RETRYABLE,
)
from services import assistant_store as store
from services import (fact_conflict, improvements, 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
router = APIRouter(
prefix="/api/xingbang/risk-agent/project-assistant/prescreen",
dependencies=[Depends(authorize), Depends(enabled)],
tags=["兴邦预审 S1"],
)
DEMO_PROJECTS = ("DEMO-A", "DEMO-B", "DEMO-C")
NOT_FOUND_MESSAGE = "对象不可访问或不存在"
# ---------------------------------------------------------------- 请求模型
class Input(BaseModel):
"""与既有 api/project_assistant.Input 同口径:禁止未知字段(E-07)。"""
model_config = ConfigDict(extra="forbid")
class ImportBatchInput(Input):
# 清单条目只有 4 个字段;id/status/received_at 由服务端生成(03 §9.4)。
# data_domain 不在请求里:从项目读取,避免客户端与项目数据域不一致。
manifest: list[dict] = Field(min_length=1, max_length=MAX_BATCH_FILES)
class RetryInput(Input):
item_ids: list[str] = Field(min_length=1, max_length=MAX_BATCH_FILES)
expected_revision: int = Field(ge=1)
class CancelInput(Input):
expected_revision: int = Field(ge=1)
class ResolveInput(Input):
selected_evidence_ids: list[str] = Field(min_length=1, max_length=EVIDENCE_MAX)
resolution_note: str = Field(min_length=1, max_length=NOTE_MAX)
expected_revision: int = Field(ge=1)
class SimulationInput(Input):
"""T09 沙盘请求:只传假设动作键,不传任何概率或维度值。"""
assumptions: list[str] = Field(default_factory=list, max_length=8)
expected_revision: int | None = Field(default=None, ge=1)
class ImprovementInput(Input):
"""T11 改进候选。必须说明改什么、依据哪些运行或结果。"""
target: str = Field(default="knowledge", max_length=40)
target_ref: dict
proposed_revision_ref: dict
change_summary: str = Field(min_length=1, max_length=DESC_MAX)
source_run_ids: list[str] = Field(default_factory=list, max_length=200)
outcome_ids: list[str] = Field(default_factory=list, max_length=200)
class TransitionInput(Input):
"""状态机动作。评测动作必须随请求提交对照结果,不能只改状态。"""
action: str = Field(min_length=1, max_length=40)
expected_revision: int | None = Field(default=None, ge=1)
evaluation: dict | None = None
contrast: dict | None = None
released_ref: dict | None = None
reason: str | None = Field(default=None, max_length=DESC_MAX)
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")
return JSONResponse(
status_code=S1_ERROR_HTTP[code],
content={"error": {"code": code.value, "message": message,
"retryable": S1_ERROR_RETRYABLE[code],
"request_id": request_id}},
)
def scope(request: Request, project_id: str):
"""项目范围检查。通过返回 None;不通过返回 404/403 信封。
无权访问的项目按 404 处理(03 §2 不泄露对象存在性);演示域被禁用是策略性
拒绝,按 403(E-03)。
"""
try:
check_project(request, project_id)
except HTTPException:
return _envelope(request, ErrorCode.NOT_FOUND, NOT_FOUND_MESSAGE)
if project_id in DEMO_PROJECTS and not flags()["demo"]:
return _envelope(request, ErrorCode.ACCESS_DENIED, "演示数据域已禁用")
return None
def run(request: Request, call, invalid_code: ErrorCode = ErrorCode.INVALID_REQUEST):
"""执行并把异常映射为统一信封。
顺序重要:HTTPException 必须原样抛出,否则依赖注入阶段的 401/403 会被吞掉。
"""
try:
return call()
except HTTPException:
raise
except FactConflictUnresolved as exc:
# E-17 的「确认动作被拒」面;运行级结果面是 HTTP 200 且不走此信封
return _envelope(request, ErrorCode.FACT_CONFLICT_UNRESOLVED, str(exc))
except store.IdempotencyConflict as exc:
return _envelope(request, ErrorCode.IDEMPOTENCY_CONFLICT, str(exc))
except store.Conflict as exc:
return _envelope(request, ErrorCode.REVISION_CONFLICT, str(exc))
except PermissionError as exc:
return _envelope(request, ErrorCode.ACCESS_DENIED, str(exc))
except KeyError:
return _envelope(request, ErrorCode.NOT_FOUND, NOT_FOUND_MESSAGE)
except ValueError as exc:
return _envelope(request, invalid_code, str(exc))
# ---------------------------------------------------------------- 内部工具
def _service() -> ProjectAssistantService:
return ProjectAssistantService()
def _db():
return _service().connect()
def _project(db, project_id: str) -> dict:
project = store.get(db, "project", project_id) # 不存在 → KeyError → 404
if project.get("data_domain") not in ("demo", "production"):
raise ValueError("项目数据域异常,无法导入材料")
return project
def _actor(request: Request) -> str:
return request.state.identity["id"]
def _key(request: Request) -> str:
key = request.headers.get("idempotency-key", "")
if len(key) > 160:
raise ValueError("幂等键过长")
return key
def _limit(value: int) -> int:
return max(1, min(value, 100))
# ------------------------------------------------- 既有上传入口的批量摄取
def _pick_item(batch: dict, filename: str, item_id: str | None) -> dict:
items = batch["items"]
if item_id:
item = next((i for i in items if i["id"] == item_id), None)
if item is None:
raise KeyError("导入条目不存在")
return item
base = filename.replace("\\", "/").rsplit("/", 1)[-1]
matches = [i for i in items
if i["relative_path"].replace("\\", "/").rsplit("/", 1)[-1] == base]
if not matches:
raise KeyError("该文件不在导入批次清单中")
if len(matches) > 1:
raise ValueError("批次中有多个同名文件,请指定 item_id")
return matches[0]
def ingest_into_batch(project_id: str, filename: str, content: bytes, actor: str,
batch_id: str, item_id: str | None = None):
"""把文件字节摄入**既有**上传链,并回写批次条目(T00/04 §4.2)。
不建立平行上传 API:仍走 `ProjectAssistantService.upload()`,因此解析、
去重(同哈希复用原件)、pending_candidates、data_revision、mark_stale 等
既有行为完全不变。本函数只做"清单核对 + 状态回写"。
服务端**重算**哈希与大小,不信任客户端清单(B-22 的来源可信度)。
"""
with _db() as db:
batch = material_import.load(db, batch_id, project_id)
if batch["status"] == "cancelled":
raise store.Conflict("批次已取消,不能继续摄入文件")
item = _pick_item(batch, filename, item_id)
def fail(code: ErrorCode, message: str):
with _db() as db:
db.execute("BEGIN IMMEDIATE")
material_import.mark_item(db, batch_id, item["id"], "failed", error_code=code)
raise ValueError(message)
if item["status"] == "parsed":
raise store.Conflict("该条目已解析完成,不能重复摄入")
digest = hashlib.sha256(content).hexdigest()
if digest != item["sha256"]:
fail(ErrorCode.INVALID_EVIDENCE, "文件内容与清单声明的 sha256 不一致,请重新选择该文件")
if len(content) != item["size_bytes"]:
fail(ErrorCode.INVALID_EVIDENCE, "文件大小与清单声明不一致,请重新选择该文件")
# 走既有上传链(可能因同哈希而复用既有 document,这是 14 §3 的期望行为)
document = _service().upload(project_id, filename, content, actor)
with _db() as db:
db.execute("BEGIN IMMEDIATE")
batch = material_import.mark_item(db, batch_id, item["id"], "parsed",
document_id=document["id"])
# 返回体保持**document 形状**(既有调用方读 id 与 candidates),
# 批次状态作为附加字段给出,不替换既有契约。
return dict(document, import_batch=batch)
async def validation_handler(request: Request, exc: RequestValidationError):
"""请求体/查询参数校验失败也走统一信封。
为什么需要它:`Input(extra="forbid")` 之类由 FastAPI 的校验层在**进入端点之前**
抛错,不经过本模块的 `run()`,若不处理就会在同一命名空间里出现两种错误形状
(信封 + FastAPI 默认的 `{"detail": ...}`)。这是 smoke_s1.py 实测发现的。
**非 prescreen 路径一律交回 FastAPI 默认处理器**,因此既有端点的错误形状
完全不变(有测试锁定这一隔离)。
"""
if not request.url.path.startswith(router.prefix):
return await request_validation_exception_handler(request, exc)
errors = exc.errors() if callable(exc.errors) else exc.errors
first = errors[0] if errors else {}
where = ".".join(str(part) for part in first.get("loc", ()) if part != "body") or "请求"
return _envelope(request, ErrorCode.INVALID_REQUEST,
f"{where}:{first.get('msg', '校验失败')}")
# ---------------------------------------------------------------- 导入批次
@router.post("/projects/{project_id}/import-batches", status_code=202)
def create_import_batch(project_id: str, payload: ImportBatchInput, request: Request):
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
db.execute("BEGIN IMMEDIATE")
project = _project(db, project_id)
batch = material_import.create(db, project_id, project["data_domain"],
payload.manifest, _actor(request), _key(request))
return {"batch": batch}
return run(request, action, ErrorCode.IMPORT_BATCH_INVALID)
@router.get("/projects/{project_id}/import-batches")
def list_import_batches(project_id: str, request: Request, limit: int = 20):
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
return material_import.listing(db, project_id, _limit(limit))
return run(request, action)
@router.get("/import-batches/{batch_id}")
def get_import_batch(batch_id: str, request: Request):
def action():
with _db() as db:
batch = material_import.load(db, batch_id) # KeyError → 404
blocked = scope(request, batch["project_id"])
return blocked if blocked is not None else batch
return run(request, action)
@router.post("/import-batches/{batch_id}/retry", status_code=202)
def retry_import_batch(batch_id: str, payload: RetryInput, request: Request):
def action():
with _db() as db:
probe = material_import.load(db, batch_id)
blocked = scope(request, probe["project_id"])
if blocked is not None:
return blocked
with _db() as db:
db.execute("BEGIN IMMEDIATE")
return material_import.retry(db, batch_id, payload.item_ids,
_actor(request), payload.expected_revision)
return run(request, action, ErrorCode.IMPORT_BATCH_INVALID)
@router.post("/import-batches/{batch_id}/cancel")
def cancel_import_batch(batch_id: str, payload: CancelInput, request: Request):
def action():
with _db() as db:
probe = material_import.load(db, batch_id)
blocked = scope(request, probe["project_id"])
if blocked is not None:
return blocked
with _db() as db:
db.execute("BEGIN IMMEDIATE")
return material_import.cancel(db, batch_id, _actor(request), payload.expected_revision)
return run(request, action, ErrorCode.IMPORT_BATCH_INVALID)
# ---------------------------------------------------------------- 事实冲突
@router.get("/projects/{project_id}/fact-conflicts")
def list_fact_conflicts(project_id: str, request: Request, status: str | None = None,
limit: int = 20):
blocked = scope(request, project_id)
if blocked is not None:
return blocked
if status not in (None, "unresolved", "resolved"):
return _envelope(request, ErrorCode.INVALID_REQUEST,
"状态取值须为 unresolved 或 resolved")
def action():
with _db() as db:
return fact_conflict.listing(db, project_id, status, _limit(limit))
return run(request, action)
@router.get("/fact-conflicts/{conflict_id}")
def get_fact_conflict(conflict_id: str, request: Request):
def action():
with _db() as db:
row = fact_conflict.load(db, conflict_id)
blocked = scope(request, row["project_id"])
return blocked if blocked is not None else row
return run(request, action)
@router.post("/fact-conflicts/{conflict_id}/resolve")
def resolve_fact_conflict(conflict_id: str, payload: ResolveInput, request: Request):
def action():
with _db() as db:
probe = fact_conflict.load(db, conflict_id)
blocked = scope(request, probe["project_id"])
if blocked is not None:
return blocked
with _db() as db:
db.execute("BEGIN IMMEDIATE")
project = _project(db, probe["project_id"])
# resolve 就地写 project 的 values/selected_sources/facts,因此必须保存它;
# 不要在此重复计算 facts 的列名——resolve 已按 FIELDS 反查过。
resolved = fact_conflict.resolve(db, conflict_id, project,
payload.selected_evidence_ids,
payload.resolution_note,
_actor(request), payload.expected_revision)
store.put(db, "project", project)
return resolved
return run(request, action)
# ---------------------------------------------------------------- 运行视图(T09)
@router.get("/projects/{project_id}/runs")
def list_runs(project_id: str, request: Request, limit: int = 20):
"""项目内已装配的预审运行视图列表;没有运行就返回空列表,不造空壳。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
items = prescreen_store.listing(db, project_id, _limit(limit))
return {"items": items}
return run(request, action)
@router.get("/projects/{project_id}/runs/{run_id}")
def get_run(project_id: str, run_id: str, request: Request):
"""只读运行视图。没有运行就是 404,不返回一个空壳运行充数。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
return prescreen_store.load(db, project_id, run_id)
return run(request, action)
@router.get("/projects/{project_id}/runs/{run_id}/assumptions")
def list_assumptions(project_id: str, run_id: str, request: Request):
"""本次方案可用的假设动作。只返回指向该运行真实存在维度的动作(E-22)。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
view = prescreen_store.load(db, project_id, run_id)
return {"items": prescreen_sandbox.available_assumptions(view),
"scenario_table_version": prescreen_sandbox.DEMO_TABLE_VERSION,
"label": prescreen_sandbox.DEMO_LABEL}
return run(request, action)
@router.post("/projects/{project_id}/runs/{run_id}/simulations", status_code=201)
def create_simulation(project_id: str, run_id: str, payload: SimulationInput,
request: Request):
"""按完整假设组合产出一条情景;真实运行只被读取(BR-11 / AC-11)。
演示值只在 demo 域返回(E-21);假设冲突或指向不存在的维度按 E-22 拒绝。
请求体里没有任何概率字段——数值一律由服务端查表给出。
"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
view = prescreen_store.load(db, project_id, run_id)
if payload.expected_revision is not None \
and view.get("revision") != payload.expected_revision:
raise store.Conflict("运行已有新版本,请刷新后重试")
try:
result = prescreen_sandbox.simulate(view, payload.assumptions)
except prescreen_sandbox.DemoEstimateForbidden as exc:
return _envelope(request, ErrorCode.DEMO_ESTIMATE_FORBIDDEN, str(exc))
except prescreen_sandbox.AssumptionConflict as exc:
return _envelope(request, ErrorCode.SCENARIO_ASSUMPTION_CONFLICT, str(exc))
prescreen_sandbox.assert_baseline_untouched(view, result)
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)
# ---------------------------------------------------------------- 改进治理(T11)
@router.get("/projects/{project_id}/improvements")
def list_improvements(project_id: str, request: Request, status: str | None = None):
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
return improvements.listing(db, project_id, status)
return run(request, action)
@router.post("/projects/{project_id}/improvements", status_code=201)
def propose_improvement(project_id: str, payload: ImprovementInput, request: Request):
"""提出改进候选(草稿)。发布仍须经过对照、独立审核两个门。"""
blocked = scope(request, project_id)
if blocked is not None:
return blocked
def action():
with _db() as db:
project = _project(db, project_id)
return improvements.propose(db, project_id, payload.model_dump(),
_actor(request), project["data_domain"])
return run(request, action)
@router.post("/improvements/{candidate_id}/transitions")
def transition_improvement(candidate_id: str, payload: TransitionInput, request: Request):
"""候选状态机。泄漏按 E-11 拒绝,未通过对照/审核按 E-24 拒绝。"""
def action():
with _db() as db:
probe = store.get(db, improvements.KIND, candidate_id)
blocked = scope(request, probe["project_id"])
if blocked is not None:
return blocked
try:
with _db() as db:
return improvements.transition(
db, candidate_id, payload.action, _actor(request),
payload.expected_revision, evaluation=payload.evaluation,
contrast=payload.contrast, released_ref=payload.released_ref,
reason=payload.reason)
except improvements.LeakageDetected as exc:
return _envelope(request, ErrorCode.EVALUATION_CONTAMINATED, str(exc))
except improvements.NotValidated as exc:
return _envelope(request, ErrorCode.IMPROVEMENT_NOT_VALIDATED, str(exc))
return run(request, action)