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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user