diff --git a/backend/contracts/prescreen.py b/backend/contracts/prescreen.py index cfaaa9b..430369b 100644 --- a/backend/contracts/prescreen.py +++ b/backend/contracts/prescreen.py @@ -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 diff --git a/backend/services/review_plan.py b/backend/services/review_plan.py index a5de7d8..6bbed0a 100644 --- a/backend/services/review_plan.py +++ b/backend/services/review_plan.py @@ -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 diff --git a/backend/test_review_plan.py b/backend/test_review_plan.py index 6a565b0..fcfab48 100644 --- a/backend/test_review_plan.py +++ b/backend/test_review_plan.py @@ -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()