feat(v2): 纠偏第二批——接通历史经验加工闭环

覆盖 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>
This commit is contained in:
2026-10-01 00:31:42 +08:00
co-authored by Claude Code
parent 350146d9bc
commit 2e7e19471c
20 changed files with 2195 additions and 14 deletions
+302 -3
View File
@@ -30,7 +30,7 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from api.project_assistant import enabled
from api.project_assistant import enabled, role
from api.risk_agent_access import authorize, check_project
from contracts.prescreen import (
DESC_MAX,
@@ -43,8 +43,9 @@ from contracts.prescreen import (
S1_ERROR_RETRYABLE,
)
from services import assistant_store as store
from services import (fact_conflict, improvements, material_import, prescreen_sandbox,
prescreen_store, trajectory)
from services 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
@@ -116,6 +117,60 @@ class TransitionInput(Input):
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 会议结果。原始结果码必填;未提供时不得推断成通过或否决。"""
@@ -596,3 +651,247 @@ def transition_improvement(candidate_id: str, payload: TransitionInput, request:
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)