"""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, role 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 (component_registry as registry, experience_extraction, fact_conflict, history_cases, improvements, knowledge_cards, material_import, prescreen_sandbox, prescreen_store, review_plan, 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 ExtractionInput(Input): """历史提炼请求:只选来源事件并给出提炼要求。 刻意**不接受**字段名、引擎、工具或模型参数——提炼输出什么由模型按标准字段表提出, 调用方不能借此指定"要提炼成哪条规则"(03 §9.4:不开放任意 prompt/路径/SQL 执行 API)。 """ event_ids: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX) requirement: str = Field(default="", max_length=DESC_MAX) class CandidateInput(Input): """人工新增知识候选(AC-43 的"人工新增"入口)。 `content` 直接使用注册表既有的执行体形状(skill 的 engine/inputs/code… 或 rule_set 的 conditions/effect),**不在接口层另定义一套卡片语义**; 合法性仍由 `knowledge_cards.propose()` → `component_registry.validate()` 判定。 """ kind: str = Field(min_length=1, max_length=40) title: str = Field(min_length=1, max_length=200) content: dict origin: str = Field(default="manual", max_length=40) version: str = Field(default="1.0.0", max_length=70) source_event_ids: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX) source_evidence_ids: list[str] = Field(default_factory=list, max_length=EVIDENCE_MAX) card_scope: str = Field(default="project", max_length=40) team_id: str | None = Field(default=None, max_length=ID_MAX) expires_at: str | None = None class ReviseInput(Input): """候选修订(AC-51):给自然语言要求,或直接给结构化修改;两者都没有即拒绝。""" requirement: str = Field(default="", max_length=DESC_MAX) changes: dict | None = None note: str = Field(default="", max_length=DESC_MAX) expected_revision: int | None = Field(default=None, ge=1) class ReviewPlanInput(Input): """方案装配请求:只引用**已发布**组件 id,不接受工具配置面(ADR-11)。""" name: str = Field(min_length=1, max_length=200) skills: list[str] = Field(min_length=1, max_length=100) rules: list[str] = Field(default_factory=list, max_length=100) cards: list[str] = Field(default_factory=list, max_length=100) template: str = Field(min_length=1, max_length=ID_MAX) budget: dict | None = None audience: list[str] = Field(default_factory=list, max_length=10) run_kind: str = Field(default="pre_review", max_length=40) dimensions: list[dict] = Field(default_factory=list, max_length=64) version: str = Field(default="1.0.0", max_length=70) 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) def _revision_draft(db, ref: dict) -> dict: """把候选声明的 `proposed_revision_ref` 解析成**那一份**草稿。 坚持按 `组件id@版本` 精确定位:只按组件 id 取"当前草稿"会解析到另一个修订 (`revise()` 升版本后旧草稿仍在),于是出现"审核的是 A、发布的是 B"。 解析不到就报未完成,**不退回猜一个草稿**。 """ ref = ref or {} component_id = str(ref.get("id") or "").strip() version = str(ref.get("version") or "").strip() if not component_id or not version: raise improvements.NotValidated("候选必须声明要发布的组件版本(proposed_revision_ref)") draft_id = component_id if "@" in component_id else f"{component_id}@{version}" try: return knowledge_cards.current_draft(db, draft_id) except KeyError as exc: raise improvements.NotValidated( f"候选指向的草稿 {draft_id} 不存在;请先形成草稿修订再发布") from exc def _knowledge_publisher(db, actor: str, preview: bool): """把改进候选的发布接到**真实知识发布入口**(BR-51 / AC-57)。 走 `knowledge_cards.publish` → `component_registry.release`,因此"自批拒绝、 隔离评测门禁、依赖可解析"三道既有保证原样生效,发布成功才覆盖 `release` 指针、 后续运行由 `freeze() → resolve()` 取到新版本。候选只是**指向**这次真实发布。 `db` 必须是调用方同一个连接:另开连接会让"写组件版本"与"写候选状态"分裂成 两个事务,出现"版本已发布、候选没标记"的裂口。 publisher 抛异常时 `improvements.transition` 不写候选状态(发布失败保留旧版)。 """ def publish(candidate: dict) -> dict: if candidate.get("target") != "knowledge": raise improvements.NotValidated( f"target={candidate.get('target')} 尚无真实发布入口;不得用改状态冒充发布") draft = _revision_draft(db, candidate.get("proposed_revision_ref")) try: published = knowledge_cards.publish(db, draft["id"], actor, preview) except store.Conflict as exc: # 评测/依赖门禁未过:统一按"未完成门禁"回报,界面只需处理一个码(E-24) raise improvements.NotValidated(str(exc)) from exc return {"id": published["component_id"], "version": published["version"]} return publish @router.post("/improvements/{candidate_id}/transitions") def transition_improvement(candidate_id: str, payload: TransitionInput, request: Request): """候选状态机。泄漏按 E-11 拒绝,未通过对照/审核按 E-24 拒绝。 `release` **不接受调用方自报的版本号**:版本由服务端调用真实知识发布入口产生 (BR-51 / AC-57)。只改 `released` 状态或返回一个版本号都不算发布,因此带了 `released_ref` 的请求一律 422 拒绝,而不是静默忽略它。 """ def action(): if payload.released_ref is not None: raise ValueError("发布版本由服务端实际发布产生,不接受客户端提交的 released_ref") 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: return improvements.transition( db, candidate_id, payload.action, _actor(request), payload.expected_revision, evaluation=payload.evaluation, contrast=payload.contrast, reason=payload.reason, publisher=_knowledge_publisher( db, _actor(request), request.state.identity.get("preview", False))) 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) # ---------------------------------------------------------------- 历史经验加工(纠偏第二批) @router.get("/projects/{project_id}/case-timeline") def get_case_timeline(project_id: str, request: Request): """历史过程时间线。尚未提炼过就是 404,不返回空壳时间线充数。""" blocked = scope(request, project_id) if blocked is not None: return blocked def action(): with _db() as db: return history_cases.load(db, project_id) return run(request, action) @router.post("/projects/{project_id}/case-timeline", status_code=201) def rebuild_case_timeline(project_id: str, request: Request): """从项目材料重新提炼时间线(确定性、不调用模型)。 重复提炼是同一对象的新 revision(03 §4),不是新建时间线。 """ blocked = scope(request, project_id) if blocked is not None: return blocked def action(): with _db() as db: project = _project(db, project_id) return history_cases.build(db, project_id, project["data_domain"]) return run(request, action) @router.get("/projects/{project_id}/knowledge-extractions") def list_knowledge_extractions(project_id: str, request: Request): """提炼记录列表。失败的记录照常返回——未完成必须可见,不隐藏。""" blocked = scope(request, project_id) if blocked is not None: return blocked def action(): with _db() as db: return {"items": experience_extraction.listing(db, project_id)} return run(request, action) @router.post("/projects/{project_id}/knowledge-extractions", status_code=201) def create_knowledge_extraction(project_id: str, payload: ExtractionInput, request: Request): """发起一次真实提炼(BR-48 / AC-50)。 **模型不可用不是 HTTP 错误**:仍返回 201 与一条 `status=failed` 的记录, 其 degradations 带 E-28,且 `candidate_id` 为空("不产生候选"由契约强制)。 这与 E-27 的口径一致——"HTTP 2xx 只表示记录可读取,不表示提炼成功"。 """ blocked = scope(request, project_id) if blocked is not None: return blocked def action(): with _db() as db: timeline = history_cases.load(db, project_id) return experience_extraction.extract(db, project_id, timeline, payload.model_dump(), _actor(request)) return run(request, action) @router.get("/projects/{project_id}/knowledge-extractions/{extraction_id}") def get_knowledge_extraction(project_id: str, extraction_id: str, request: Request): blocked = scope(request, project_id) if blocked is not None: return blocked def action(): with _db() as db: return experience_extraction.load(db, project_id, extraction_id) return run(request, action) # ---------------------------------------------------------------- 候选治理(纠偏第二批 B-b) # 注意:按卡片/方案 id 的路由**必须**在 run() 内查归属再判权限——放到路由函数体里, # 对象不存在时的 KeyError 会绕过 run() 的映射变成 500,而不是既有的 404 口径。 @router.get("/projects/{project_id}/knowledge-candidates") def list_knowledge_candidates(project_id: str, request: Request): """项目知识卡片(草稿与已发布)。来源与状态原样返回,供界面区分历史提炼与人工创建。""" blocked = scope(request, project_id) if blocked is not None: return blocked def action(): with _db() as db: return {"items": knowledge_cards.list_cards(db, project_id)} return run(request, action) @router.post("/projects/{project_id}/knowledge-candidates", status_code=201) def propose_knowledge_candidate(project_id: str, payload: CandidateInput, 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) # 组件 id 由服务端生成:调用方不该靠自报 id 影响治理(03 §9.4 同口径) body = dict(payload.content, kind=payload.kind, name=payload.title, id=store.uid(payload.kind + ".card"), version=payload.version, scope=project["data_domain"]) if payload.expires_at: body["expires_at"] = payload.expires_at return knowledge_cards.propose( db, body, _actor(request), project_id, origin=payload.origin, source_event_ids=payload.source_event_ids, source_evidence_ids=payload.source_evidence_ids, card_scope=payload.card_scope, team_id=payload.team_id) return run(request, action) @router.patch("/knowledge-candidates/{card_id}") def revise_knowledge_candidate(card_id: str, payload: ReviseInput, request: Request): """形成草稿修订。**不发布、不自批**——发布走 transitions 且需独立审核人。 模型调用在数据库连接之外进行:一次自然语言解释可能耗时数十秒, 不该占着 SQLite 连接。 """ def action(): with _db() as db: draft = knowledge_cards.current_draft(db, card_id) card = knowledge_cards.draft_card(db, draft["id"]) blocked = scope(request, draft["project_id"]) if blocked is not None: return blocked # 模型调用放在数据库连接之外:自然语言解释可能耗时数十秒,不该占着 SQLite 连接 changes, note = experience_extraction.edit_changes(card, payload.model_dump()) with _db() as db: return knowledge_cards.revise(db, card_id, changes, _actor(request), note, expected_revision=payload.expected_revision) return run(request, action) @router.post("/knowledge-candidates/{card_id}/transitions") def transition_knowledge_candidate(card_id: str, payload: TransitionInput, request: Request): """候选状态机。评测门禁与"不自批"由 `registry.release()` 保证(AC-03 / AC-43)。 本版只实现 evaluate 与 publish:注册表只产出 draft/published 两种状态, `in_review`/`rejected`/`withdrawn` 尚无落地表达——这是既有缺口, 不在这里用一个假状态掩盖。 """ def action(): if not flags()["evaluation"]: raise HTTPException(503, "候选评测与发布已暂停") with _db() as db: draft = knowledge_cards.current_draft(db, card_id) blocked = scope(request, draft["project_id"]) if blocked is not None: return blocked if payload.action == "evaluate": # 角色门禁与既有 /components/{id}/actions 一致:新路由不得比既有入口宽松 role(request, ("admin", "reviewer")) from services.component_evaluation import evaluate with _db() as db: return evaluate(db, draft["id"], _actor(request)) if payload.action == "publish": role(request, ("admin", "reviewer")) with _db() as db: return knowledge_cards.publish(db, draft["id"], _actor(request), request.state.identity.get("preview", False)) raise ValueError(f"不支持的候选动作:{payload.action};本版支持 evaluate 与 publish") return run(request, action) # ---------------------------------------------------------------- 方案装配(纠偏第二批 B-c) @router.get("/projects/{project_id}/review-plans") def list_review_plans(project_id: str, 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 {"items": review_plan.list_plans(db, project_id, project["data_domain"])} return run(request, action) @router.post("/projects/{project_id}/review-plans", status_code=201) def propose_review_plan(project_id: str, payload: ReviewPlanInput, request: Request): """装配一份方案草稿。 这是"已发布卡片被选入方案"的入口:`skills`/`rules` 里填卡片组件 id 即可, 但草稿或已撤回的组件会在装配与发布两处被拒(BR-27 / AC-28)。 """ blocked = scope(request, project_id) if blocked is not None: return blocked def action(): with _db() as db: project = _project(db, project_id) body = dict(payload.model_dump(), kind="plan", scope=project["data_domain"], id=store.uid("plan.card")) return review_plan.propose_plan(db, body, _actor(request), project_id) return run(request, action) @router.post("/review-plans/{plan_ref}/transitions") def transition_review_plan(plan_ref: str, payload: TransitionInput, request: Request): """方案评测与发布。角色门禁与既有 `/components` 入口一致。""" def action(): if not flags()["evaluation"]: raise HTTPException(503, "方案评测与发布已暂停") with _db() as db: draft = registry.current_draft(db, plan_ref) blocked = scope(request, draft["project_id"]) if blocked is not None: return blocked if payload.action == "evaluate": role(request, ("admin", "reviewer")) from services.component_evaluation import evaluate with _db() as db: return evaluate(db, draft["id"], _actor(request)) if payload.action == "publish": role(request, ("admin", "reviewer")) with _db() as db: return registry.release(db, draft["id"], _actor(request), request.state.identity.get("preview", False)) raise ValueError(f"不支持的方案动作:{payload.action};本版支持 evaluate 与 publish") return run(request, action) @router.get('/projects/{project_id}/run-comparison') def compare_runs(project_id: str, left: str, right: str, request: Request): blocked=scope(request, project_id) if blocked is not None: return blocked def action(): from services.trajectory_details import compare with _db() as db: return compare(db, project_id, left, right) return run(request, action)