feat(v2): T06-b 方案装配与依赖预检(可定位的 E-19 文案)

T06 第二块。核对后确认 component_registry.freeze() 已实现大部分 AC-28:
按 release 指针解析依赖、原生方法文本装配时固化、循环依赖与输出冲突检查、
数据域一致性("演示与正式组件不能混用")、content_hash。因此本批**不重复实现冻结**,
只补真正的缺口。

新增 contracts:JudgmentBinding、ReviewPlan(含维度、关键核查项、专项绑定)。

新增 review_plan.check_dependencies() / assemble():
- **缺口**:未发布/已撤回的依赖原先抛裸 KeyError,被 translate() 映射成 404
  "对象不存在",用户无法定位是哪一项。现改为逐个检查并**指明依赖名与原因**
  ("尚未发布或已撤回"/"版本状态为 X"/"发布指针指向的版本不存在")
- 只用 release 指针判定可用性——草稿与已撤回天然没有指针,这正是 BR-27
  "草稿或已撤回组件不得进入新运行"的机械表达
- 装配时接入 T06-a 的维度解析与呈现判定,并做不丢失断言

测试:test_review_plan.py 增至 25 项(新增 7 项装配)。其中版本换版后
content_hash 必须变化,用于证明版本确实被冻结进结果而不是每次重解析。
全量 340 项 OK(333 → 340);verify.py 10/10 PASS。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:20:58 +08:00
co-authored by Claude Code
parent 019d5081df
commit 8a9005036c
3 changed files with 223 additions and 0 deletions
+47
View File
@@ -569,3 +569,50 @@ class DimensionSpec(Contract):
def scale_key(self) -> tuple[str, str]:
"""跨维度可比性的唯一判据:刻度标识 + 版本。"""
return (self.scale_id, self.scale_version)
# ---------------------------------------------------------------- 评审方案(T06-b)
class JudgmentBinding(Contract):
"""把某个业务任务绑定到具体的专项判断配置修订(03 §9.3)。"""
task: str = Field(min_length=1, max_length=ID_MAX)
profile_id: Identifier
profile_revision: int = Field(ge=1)
class ReviewPlan(Contract):
"""评审方案:数据卡 / Skills / Rules / 经验与核查维度的版本化装配。
方案固定引用与维度定义,发起运行时验证依赖在当前项目/域获准并**冻结版本**
(BR-27 / E-19 / AC-28)。草稿或已撤回组件不得进入新运行。
"""
id: Identifier
project_id: Identifier
data_domain: DataDomain
version: str = Field(min_length=1, max_length=70)
revision: int = Field(ge=1)
title: str = Field(min_length=1, max_length=200)
status: GovernanceStatus
data_card_refs: list[Ref] = Field(default_factory=list, max_length=64)
skill_refs: list[Ref] = Field(default_factory=list, max_length=64)
rule_refs: list[Ref] = Field(default_factory=list, max_length=64)
experience_refs: list[Ref] = Field(default_factory=list, max_length=64)
dimensions: list[DimensionSpec] = Field(default_factory=list, max_length=32)
critical_check_ids: list[Identifier] = Field(default_factory=list, max_length=64)
judgment_bindings: list[JudgmentBinding] = Field(default_factory=list, max_length=16)
created_by: Identifier
reviewer_id: Identifier | None = None
@model_validator(mode="after")
def _check_plan(self) -> ReviewPlan:
# 维度同 ID 不得定义两次(真正的一致性由 review_plan.resolve_dimensions 把关)
ids = [spec.id for spec in self.dimensions]
if len(set(ids)) != len(ids):
raise ValueError("同一方案内维度 ID 不得重复")
tasks = [binding.task for binding in self.judgment_bindings]
if len(set(tasks)) != len(tasks):
raise ValueError("同一业务任务只能绑定一个专项判断配置")
if self.status == GovernanceStatus.published and self.reviewer_id is None:
raise ValueError("已发布方案必须有审核人")
return self
+70
View File
@@ -14,6 +14,8 @@ BR-28 / E-20 / AC-29 / AC-44。
from __future__ import annotations
from contracts.prescreen import DimensionSpec
from services import assistant_store as store
from services import component_registry as registry
RADAR_MIN, RADAR_MAX = 3, 8
@@ -111,3 +113,71 @@ def assert_nothing_dropped(result: dict, dimensions) -> None:
raise ValueError(f"呈现判定丢失了维度:{sorted(missing)}")
if shown - expected:
raise ValueError(f"呈现判定出现了未知维度:{sorted(shown - expected)}")
# ---------------------------------------------------------------- 方案装配(T06-b)
# 依赖不可用时的统一文案(对应 E-19 PLAN_DEPENDENCY_INVALID)。
DEPENDENCY_INVALID = "评审方案存在不可用组件,请更新后重新发起;不暴露无权组件"
REF_GROUPS = ("skills", "rules", "template", "cards")
def _unusable(db, component_id: str) -> str | None:
"""返回不可用原因;可用则 None。
只用**已发布指针**(release)判定:草稿、已撤回、从未发布的组件都没有 release 行,
因此天然被排除——这正是"草稿或已撤回组件不得进入新运行"(BR-27)的机械表达。
"""
try:
pointer = store.get(db, "release", component_id)
except KeyError:
return "尚未发布或已撤回"
try:
version = store.get(db, "component_version", pointer["version_id"])
except KeyError:
return "发布指针指向的版本不存在"
if version.get("status") != "published":
return f'版本状态为 {version.get("status")}'
return None
def check_dependencies(db, plan: dict) -> None:
"""装配前预检:逐个说明**哪个**依赖不可用,而不是抛裸 KeyError。
裸 KeyError 会被 translate() 映射成 404,用户看到"对象不存在",无法定位是哪一项。
"""
problems = []
for group in REF_GROUPS:
keys = plan.get(group) if group != "template" else [plan.get("template")]
for key in [item for item in (keys or []) if item]:
reason = _unusable(db, key)
if reason:
problems.append(f"{group}:{key}({reason})")
if problems:
raise ValueError(f"{DEPENDENCY_INVALID};不可用依赖:{'、'.join(sorted(problems))}")
def assemble(db, plan_id: str, legacy_catalog=None) -> dict:
"""装配并冻结一个评审方案。
不重复实现冻结:解析、原生方法文本冻结、循环依赖与输出冲突检查、数据域一致性、
content_hash 全部委托 `component_registry.freeze()`。本函数只加两件事:
1. 依赖预检,给出可定位的 E-19 文案(而非 404 的"对象不存在")
2. 维度解析与呈现判定(T06-a),并保证维度不丢失
"""
reason = _unusable(db, plan_id)
if reason:
raise ValueError(f"评审方案不可用({reason});{DEPENDENCY_INVALID}")
published_plan = registry.resolve(db, plan_id)
if published_plan.get("kind") != "plan":
raise ValueError("所选组件不是业务评审方案")
check_dependencies(db, published_plan)
frozen = registry.freeze(db, plan_id, legacy_catalog)
dimensions = resolve_dimensions(published_plan.get("dimensions", []))
result = presentation(dimensions)
assert_nothing_dropped(result, dimensions)
frozen["dimensions"] = dimensions
frozen["presentation"] = result
return frozen
+106
View File
@@ -6,8 +6,12 @@
- 呈现判定覆盖 0 / 2 / 6 / 9 维与异刻度,且**不丢失任何维度**
"""
import tempfile
import unittest
from pathlib import Path
from services import assistant_store as store
from services import component_registry as registry
from services import review_plan as plan
SCALE = "evidence-stage"
@@ -145,5 +149,107 @@ class PresentationTests(unittest.TestCase):
self.assertEqual(self.check(dimensions)["layout"], "faceted")
class PlanAssemblyTests(unittest.TestCase):
"""AC-28 / E-19:只有全部依赖有效且同域才装配;否则拒绝并指明是哪一项。"""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.db_path = Path(self._tmp.name) / "plan.sqlite3"
store.initialize(self.db_path)
with store.transaction(self.db_path) as db:
registry.seed(db) # 内置演示组件,含 corporate.default 方案
def tearDown(self):
self._tmp.cleanup()
def assemble(self, plan_id="corporate.default"):
with store.transaction(self.db_path) as db:
return plan.assemble(db, plan_id)
def test_published_plan_assembles_and_freezes(self):
frozen = self.assemble()
self.assertEqual(frozen["plan"]["id"], "corporate.default")
self.assertEqual({s["kind"] for s in frozen["skills"]}, {"skill"})
self.assertEqual({r["kind"] for r in frozen["rules"]}, {"rule_set"})
self.assertTrue(frozen["content_hash"])
# 原生方法文本在装配时即固化,运行中改动无法影响本次
model_skills = [s for s in frozen["skills"] if s.get("native_skills")]
self.assertTrue(model_skills)
self.assertTrue(all(s["methods"] for s in model_skills))
def test_unpublished_plan_is_rejected_with_a_clear_reason(self):
with self.assertRaises(ValueError) as ctx:
self.assemble("corporate.does-not-exist")
self.assertIn("评审方案不可用", str(ctx.exception))
def test_draft_dependency_is_named_in_the_error(self):
# 造一个未发布的 skill 并被方案引用 → 必须指出是哪一项
with store.transaction(self.db_path) as db:
plan_body = registry.resolve(db, "corporate.default")
skill_id = plan_body["skills"][0]
# 移除该 skill 的发布指针,模拟"草稿/已撤回"
db.execute("DELETE FROM assistant_objects WHERE kind='release' AND id=?", (skill_id,))
with self.assertRaises(ValueError) as ctx:
self.assemble()
message = str(ctx.exception)
self.assertIn("不可用依赖", message)
self.assertIn(skill_id, message, "必须指明是哪个依赖不可用")
self.assertIn("尚未发布或已撤回", message)
def test_cross_domain_dependency_is_rejected(self):
# 演示与正式组件不能混用(freeze 的既有保证)
with store.transaction(self.db_path) as db:
production = dict(registry.resolve(db, "corporate.analysis"), scope="production")
production.pop("revision", None)
store.put(db, "component_version", production, immutable=True)
store.put(db, "release", {"id": "corporate.analysis", "version_id": production["id"],
"scope": "production"})
body = registry.resolve(db, "corporate.default")
body["skills"] = ["corporate.analysis"] + [s for s in body["skills"] if s != "corporate.analysis"]
with self.assertRaises(ValueError):
with store.transaction(self.db_path) as db:
plan.check_dependencies(db, body)
registry.freeze(db, "corporate.default")
def test_frozen_hash_changes_when_a_dependency_version_changes(self):
first = self.assemble()
with store.transaction(self.db_path) as db:
upgraded = dict(registry.resolve(db, "corporate.cashflow"), version="9.9.9",
name="现金流完整性(改版)")
upgraded.pop("revision", None)
store.put(db, "component_version", upgraded, immutable=True)
store.put(db, "release", {"id": "corporate.cashflow", "version_id": upgraded["id"],
"scope": upgraded["scope"]})
second = self.assemble()
self.assertNotEqual(first["content_hash"], second["content_hash"],
"换版本后装配结果必须变化,证明版本确实被冻结进结果")
def test_assembled_dimensions_are_resolved_and_presented(self):
with store.transaction(self.db_path) as db:
body = dict(registry.resolve(db, "corporate.default"),
dimensions=[spec("d1"), spec("d2", "负债率", 1), spec("d3", "设备", 2)])
body.pop("revision", None)
store.put(db, "component_version", body, immutable=True)
store.put(db, "release", {"id": "corporate.default", "version_id": body["id"],
"scope": body["scope"]})
frozen = plan.assemble(db, "corporate.default")
self.assertEqual(len(frozen["dimensions"]), 3)
self.assertEqual(frozen["presentation"]["layout"], "radar")
def test_conflicting_dimensions_are_rejected_at_assembly(self):
with store.transaction(self.db_path) as db:
body = dict(registry.resolve(db, "corporate.default"),
dimensions=[spec("d1"), spec("d1", label="同名不同义")])
body.pop("revision", None)
store.put(db, "component_version", body, immutable=True)
store.put(db, "release", {"id": "corporate.default", "version_id": body["id"],
"scope": body["scope"]})
with self.assertRaises(ValueError) as ctx:
plan.assemble(db, "corporate.default")
self.assertIn("不一致定义", str(ctx.exception))
if __name__ == "__main__":
unittest.main()