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
+128
View File
@@ -170,10 +170,138 @@ def _project(body: dict) -> dict:
return KnowledgeCard(**card).model_dump(mode="json")
def registry_conditions(mapping: dict) -> list[dict]:
"""{标准字段: 触发值} → 注册表既有的等值条件形状。
注册表条件只能是 `{code, field, operator, threshold}` 且算子限于数值阈值,
因此这里固定用 `eq`——**不试图表达"字段存在/缺失"**,那种语义在注册表条件语言里
没有对应算子(`present` 被 test_knowledge_cards 的既有断言锁定拒绝)。
历史提炼与人工修订共用本函数,避免两处各写一份形状。
"""
return [{"code": "history.condition." + str(index + 1), "field": str(field),
"operator": "eq", "threshold": value}
for index, (field, value) in enumerate(sorted((mapping or {}).items()))]
def draft_card(db, draft_id: str) -> dict:
return _project(store.get(db, "component_draft", draft_id))
# ---------------------------------------------------------------- 候选读取与修订(AC-51)
def current_draft(db, card_ref: str) -> dict:
"""按草稿 id(`组件id@版本`)或组件 id 找当前候选草稿。
判据与方案草稿完全相同,因此直接复用注册表实现,不在这里再写一份排序规则。
"""
return registry.current_draft(db, card_ref)
def list_cards(db, project_id: str, domain: str | None = None) -> list[dict]:
"""项目下的知识卡片(草稿与已发布)。内置全局组件不是项目卡片,按 `_project` 的
既有判据排除,不在这里另写一套过滤。
"""
cards = []
for kind in ("component_draft", "component_version"):
for row in store.listing(db, kind, project_id):
if row.get("project_id") != project_id:
continue
if domain and row.get("scope") != domain:
continue
try:
cards.append(_project(row))
except ValueError:
continue
cards.sort(key=lambda card: (card["id"], card["status"] != "published", card["revision"]))
return cards
# 可改字段:适用范围、例外说明,以及**独立测试样本**。
# test_cases 在这里不是可选项——`component_evaluation` 对自定义 Skill 的发布门禁就是
# "必须有独立测试样本与期望输出"(14 §4 的"专家修改 → 测试"步骤)。不开放它,
# 提炼出来的候选就永远卡在评测门外,闭环无法走通。
EDITABLE_CHANGES = ("conditions", "exception_note", "test_cases")
def _bump(version: str) -> str:
parts = str(version).split(".")
if len(parts) == 3 and all(part.isdigit() for part in parts):
return f"{parts[0]}.{parts[1]}.{int(parts[2]) + 1}"
return f"{version}.1"
def _redraft_body(row: dict) -> dict:
"""把已存在的草稿行还原成可再次提交的注册表体。
必须去掉 `registry.draft()` 自己维护或校验用的键:`revision`(否则会被当成
expected 与 `id` 拼接重复计版本)、`status`/`edited_by`/`approved_by` 等由服务端
决定的字段、以及 `content_hash`(会重算)。
"""
body = {key: value for key, value in row.items()
if key not in ("revision", "content_hash", "status", "edited_by", "approved_by",
"published_at", "evaluation_id", "schema_version", "dependencies")}
body["id"] = row.get("component_id") or row["id"]
return body
def revise(db, card_ref: str, changes: dict, actor: str, note: str, expected_revision=None):
"""按人工修改形成**草稿修订**(AC-51 / BR-42)。
三条不变量,全部由既有治理保证而不是本函数自己承诺:
- **只产草稿**:走 `propose()` → `registry.draft()`,发布仍需独立审核与隔离评测;
- **来源不可洗**:origin 与来源事件原样继承,历史卡片修订后仍是 `history_derived`;
- **不自批**:本函数不发布;发布时 `registry.release()` 才检查 `edited_by == actor`。
已发布的当前版本会被**升版本**并记 `parent_version`;未发布时原地产生新 revision
(与既有"草稿可反复修订"一致)。
"""
if not str(note or "").strip():
raise ValueError("修订必须填写变更说明,否则无法追溯改了什么")
unknown = sorted(set(changes or ()) - set(EDITABLE_CHANGES))
if unknown:
raise ValueError(f"不支持的修订字段:{unknown};本版只允许改适用范围、例外说明与独立测试样本")
if not changes:
raise ValueError("修订必须至少改动一个字段")
draft = current_draft(db, card_ref)
component_id = draft["component_id"]
version_id = f"{component_id}@{draft['version']}"
try:
pointer = store.get(db, "release", component_id)
except KeyError:
pointer = None
if pointer and pointer.get("version_id") == version_id:
version = _bump(draft["version"])
parent = {"id": component_id, "version": draft["version"]}
else:
version = draft["version"]
parent = draft.get("parent_version")
body = _redraft_body(draft)
body["version"] = version
body["change_note"] = str(note)
if "conditions" in changes:
body["conditions"] = registry_conditions(changes["conditions"])
if "exception_note" in changes:
body["exception_note"] = str(changes["exception_note"])
if "test_cases" in changes:
cases = changes["test_cases"]
if not isinstance(cases, list) or any(
not isinstance(case, dict) or not str(case.get("name") or "").strip()
for case in cases):
raise ValueError("测试样本必须是带 name 的对象列表")
body["test_cases"] = cases
if expected_revision is not None:
body["revision"] = expected_revision # registry.draft 用它做乐观并发校验
return propose(db, body, actor, draft["project_id"], origin=draft["origin"],
source_event_ids=draft.get("source_event_ids"),
source_evidence_ids=draft.get("source_evidence_ids"),
parent_version=parent,
card_scope=draft.get("card_scope", "project"),
team_id=draft.get("team_id"))
def published_cards(db, domain: str) -> list[dict]:
return [_project(row) for row in store.listing(db, "component_version")
if row.get("status") == "published" and row.get("scope") == domain]