Files
mini-openclaw-v2/backend/api/prescreen.py
T
qiaoqichaoandClaude Code f775c6cbe4 feat(v2): 纠偏第四批——会议结果录入与改进发布接真实知识发布入口
- api/prescreen.py:transition_improvement 注入 _knowledge_publisher,
  release 委托 knowledge_cards.publish → component_registry.release;
  按 proposed_revision_ref 以「组件id@版本」精确解析草稿(不取"最新草稿",
  否则会出现"审核的是 A、发布的是 B");客户端自报 released_ref 一律 422;
  非 knowledge 目标 409 诚实拒绝;发布与候选状态同一事务,失败保留旧版。
- TrajectoryPanel:新增会议结果录入(追加式更正走 supersedes_id;
  未匹配单列且不计入分母)。
- ImprovementPanel(新增):偏差→候选→两道评测门→独立审核→发布;
  请求体不含版本号,无自动上线,不自批。
- 界面并入「轨迹复盘」,不新增第四个导航入口(AC-53)。
- 守卫:新增 scripts/test_improvement_panel.cjs(verify.py 13→14 项);
  test_module_shell_ui.cjs 的「只读」断言改为分别锁死「回放只读」与「结果只可追加」。
- 测试:新增 backend/test_improvement_release.py(AC-57 端到端 + 三条反例);
  test_improvements.py 补「自报版本号被拒」。
- 文档:17 第四批交付记录、15 §7 交接、03 §9.4 发布事务边界。

验证:scripts/verify.py 14/14 PASS(backend-unit 572 项),证据
test-evidence/20261001-013517-3e15bda/;scripts/xingbang_batch4_smoke.py
对真实服务器实跑通过(发布指针实测移到新版本)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-01 02:21:11 +08:00

952 lines
40 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, 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)
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, ("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, ("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)