覆盖 AC-50/51/52(BR-48/49、E-28)。审计确认的 P1-3 缺口"历史提炼与卡片 不在实时 API 调用图上"由此关闭:提炼、候选治理、卡片进方案全部有真实调用方。 后端: - contracts:新增 E-28 EXTRACTION_UNAVAILABLE(运行级,不进 HTTP 信封)与 KnowledgeExtraction 记录类型。契约层强制"失败不得带候选", AC-50「模型不可用⇒不产生候选」是类型约束而不是文档约定。 - services/experience_extraction.py(新):真实提炼入口。模型不可用或调用失败 → 记 E-28 且 candidate_id 为空;不产半成品候选。传输纪律同 assistant_model (禁进程内代理、零重试),且 tools=[] 使"越权读材料"在结构上不可能。 自然语言修订同样必须真实调用模型,没有"把中文猜成条件"的回退路径。 - services/knowledge_cards.py:新增 current_draft/revise/list_cards 与 registry_conditions。修订只产草稿、保留 origin 与来源、升版本记 parent_version; test_cases 纳入可改字段——它是 component_evaluation 对自定义 Skill 的发布门禁, 不开放则提炼出的候选永远卡在评测门外。 - services/project_assistant.py:enqueue 改用 review_plan.assemble(), 让 T06-b 的依赖预检(E-19 可定位文案)与维度解析不再被绕过。 - api/prescreen.py:case-timeline、knowledge-extractions、knowledge-candidates (创建/修订/状态机)、review-plans(装配/状态机)。角色门禁与既有 /components 入口一致;id 与数据域由服务端生成;按 id 的路由一律在 run() 内 查归属,否则 KeyError 会变成 500 而不是 404。 - component_registry/review_plan:下沉 current_draft/version_key 与 list_plans。 前端: - 新增 /experience-center 独立入口(BR-50)与 ExperienceCenter 组件; AgentNav 增加「历史经验加工」。 - originBadge 此前零调用方、.origin-* 无任何 CSS——守卫只 grep 库文件字符串, 于是"测试通过但功能不存在"。本批补齐消费方与样式,并用左边框样式区分四种来源, 保证去色仍可区分(AC-42/BR-48)。 - 守卫语义按 AC-55 调整:由"禁止新建路由"改为"禁止重复页面" (面板不得搬到独立路由、已合并页必须保持重定向),并新增 experience-center-ui 守卫(已登记 verify.py,共 12 项检查)。 测试:新增 36 项(提炼 15 / 候选治理 15 / 卡片到运行 6),全量 unittest 无回归; 两次同项目运行对照证明卡片确实改变了核查结果,且 basis_ids 指向 卡片id@版本。 未闭合(记录在 17 的剩余缺口):真实模型提炼尚未实测(需端点); 界面未经浏览器点击验证;例外边界未进入可求值语言;规则类候选不在本批。 Co-Authored-By: Claude Code <noreply@anthropic.com>
898 lines
37 KiB
Python
898 lines
37 KiB
Python
"""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)
|
||
|
||
|
||
@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)
|
||
|
||
|
||
# ---------------------------------------------------------------- 历史经验加工(纠偏第二批)
|
||
@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)
|