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
+28
View File
@@ -211,6 +211,34 @@ def validate_critical_checks(frozen: dict, critical_check_ids) -> None:
raise ValueError(f"关键核查项不属于本方案的 Skill 或规则:{unknown}")
def list_plans(db, project_id: str, domain: str | None = None) -> list[dict]:
"""项目自建的方案草稿与已发布版本。
只列**项目自建**的方案(带 project_id 的组件);内置种子方案是全局配置,
不属于任何项目,不在这里冒充项目方案。
"""
items = []
for kind, status in (("component_draft", "draft"), ("component_version", "published")):
for row in store.listing(db, kind, project_id):
if row.get("kind") != "plan" or row.get("project_id") != project_id:
continue
if domain and row.get("scope") != domain:
continue
items.append({
"id": row.get("component_id") or row["id"],
"version": row.get("version", "1.0.0"),
"status": row.get("status", status),
"name": row.get("name", ""),
"revision": row.get("revision", 1),
"skills": list(row.get("skills") or ()),
"rules": list(row.get("rules") or ()),
"template": row.get("template", ""),
"dimensions": [item.get("id") for item in (row.get("dimensions") or ())],
})
items.sort(key=lambda item: (item["id"], item["version"], item["status"] != "published"))
return items
def propose_plan(db, body: dict, actor: str, project_id: str):
"""提议一份方案草稿。复用既有组件治理,不新建发布路径。"""
forbidden = sorted(key for key in FORBIDDEN_PLAN_KEYS if key in body)