Files
mini-openclaw-v2/backend/api/prescreen.py
T

599 lines
24 KiB
Python
Raw Normal View History

"""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)