Files
mini-openclaw-v2/backend/services/review_plan.py
T
qiaoqichaoandClaude Code 1bc17bb9bc feat(v2): T06-c 方案治理入口与 Tools 卡守卫,T06 收尾
T06 第三块,收尾。方案提案复用既有组件治理(registry.draft/release),
不新建发布路径;自批拒绝等既有保证有测试锁定。

- propose_plan():维度在提案时即去重与冲突拦截(T06-a 的解析前移),
  保证草稿阶段就挡住"同 ID 不同定义"
- **不得出现业务 Tools 卡**(ADR-11):方案自带 tools/tool_cards/
  allowed_tools/permissions 一律拒绝,否则业务侧可绕过工具白名单;
  工具只能由后台治理
- validate_critical_checks():关键核查项必须真由本方案的 Skill/规则产生,
  凭空声明的"关键项"会让 BR-06 的硬性阻断语义失去可追溯性

T06 三块完成,卡片验收对照:AC-28(装配与依赖拒绝)· AC-29(维度去重与刻度
冲突)· AC-44(0/2/6/9 维与异刻度的呈现判定)均有测试;"没有 Tools 卡" 有守卫。

测试:test_review_plan.py 增至 34 项。全量 349 项 OK(340 → 349);
verify.py 10/10 PASS。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 20:24:24 +08:00

231 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""T06-a 方案维度解析:去重、刻度冲突与呈现判定。
依据:docs/v2/14-experience-loop.md §5、03-detailed-design.md §9.2;
BR-28 / E-20 / AC-29 / AC-44。
三条不可妥协的规则:
1. **同 ID 必须语义一致**:标签、量纲(scale_id/version)、刻度层级任一不同即
**拒绝合并**并报冲突,绝不让模型临时猜一个(BR-28)。相同定义只保留一份。
2. **异刻度不强行归一化**:不同刻度的维度不得共用雷达,改为分面展示(14 §5)。
3. **不默默丢失关键项**:呈现判定返回的 radar / listed / hard_block 三组并集
必须等于全部维度;任何维度都要出现在某一组里(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
EMPTY_REASON = "当前方案尚未配置核查维度"
FACETED_REASON = "维度刻度不一致,分面展示;不强行归一化,避免误导读者比较不可比的量"
RADAR_REASON = "同一刻度且 3–8 维,可用雷达并附等价文字列表"
FEW_REASON = "少于 3 维,用状态条/列表比雷达更清楚"
MANY_REASON = "多于 8 维,雷达会不可读,改为分组/列表且全部维度仍可查看"
def _definition(spec: dict) -> tuple:
"""判据:标签 + 刻度标识/版本 + 层级内容。任一不同即视为不同定义。"""
return (
str(spec.get("label", "")),
str(spec.get("scale_id", "")),
str(spec.get("scale_version", "")),
tuple((level.get("value"), level.get("label"), level.get("criterion"))
for level in spec.get("levels", [])),
)
def _validate(spec: dict) -> dict:
try:
return DimensionSpec(**spec).model_dump(mode="json")
except Exception as exc:
raise ValueError(f"维度不合法:{exc}") from exc
def resolve_dimensions(specs) -> list[dict]:
"""按 dimension_id 合并;同 ID 不同定义 → 冲突拒绝(AC-29 / E-20)。
返回按 order 稳定排序后的维度列表,**同一定义只出现一次**。
"""
merged: dict[str, dict] = {}
for raw in specs or ():
spec = _validate(raw)
existing = merged.get(spec["id"])
if existing is None:
merged[spec["id"]] = spec
continue
if _definition(existing) != _definition(spec):
raise ValueError(
f"维度 {spec['id']} 存在不一致定义(标签/刻度/层级不同),请统一后保存方案;"
"不得由程序或模型临时选择其一"
)
# 完全相同的定义:合并引用,避免重复计数(14 §5)
references = {ref["id"]: ref for ref in existing["component_refs"]}
for ref in spec["component_refs"]:
references.setdefault(ref["id"], ref)
existing["component_refs"] = list(references.values())
existing["required"] = existing["required"] or spec["required"]
return sorted(merged.values(), key=lambda item: (item["order"], item["id"]))
def is_single_scale(dimensions) -> bool:
"""全部维度是否共用同一可解释刻度(可比性前提)。"""
return len({(d["scale_id"], d["scale_version"]) for d in dimensions}) <= 1
def presentation(dimensions, hard_block_ids=()) -> dict:
"""AC-44 的呈现判定:雷达 / 列表 / 分组 / 分面 / 空。
硬阻断维度**单列**,不参与雷达面积(14 §5:不被平均数或面积覆盖)。
"""
ids = [item["id"] for item in dimensions]
if not dimensions:
return {"layout": "empty", "reason": EMPTY_REASON,
"radar_ids": [], "listed_ids": [], "hard_block_ids": []}
blocks = [item_id for item_id in ids if item_id in set(hard_block_ids)]
remaining = [item_id for item_id in ids if item_id not in set(blocks)]
if not is_single_scale(dimensions):
return {"layout": "faceted", "reason": FACETED_REASON,
"radar_ids": [], "listed_ids": remaining, "hard_block_ids": blocks}
count = len(remaining)
if count < RADAR_MIN:
layout, reason, radar = "list", FEW_REASON, []
elif count > RADAR_MAX:
layout, reason, radar = "grouped", MANY_REASON, []
else:
layout, reason, radar = "radar", RADAR_REASON, remaining
listed = [item_id for item_id in remaining if item_id not in set(radar)]
return {"layout": layout, "reason": reason,
"radar_ids": radar, "listed_ids": listed, "hard_block_ids": blocks}
def assert_nothing_dropped(result: dict, dimensions) -> None:
"""呈现判定不得丢失任何维度(AC-44:不默默丢失关键项)。"""
shown = set(result["radar_ids"]) | set(result["listed_ids"]) | set(result["hard_block_ids"])
expected = {item["id"] for item in dimensions}
missing = expected - shown
if missing:
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
# ---------------------------------------------------------------- 方案治理(T06-c)
# ADR-11:Tools 由后台批准,**不开放业务 Tools 卡**。方案只能引用已登记工具,
# 不得自带工具配置面——否则业务侧就能绕过工具白名单。
FORBIDDEN_PLAN_KEYS = ("tools", "tool_cards", "tool_config", "allowed_tools", "permissions")
def _plan_codes(frozen: dict) -> set[str]:
"""方案内产生的核查编码:Skill 的 code 与规则条件的 code。"""
codes = {skill.get("code") for skill in frozen.get("skills", []) if skill.get("code")}
for rule in frozen.get("rules", []):
codes |= {item.get("code") for item in rule.get("conditions", []) if item.get("code")}
return codes
def validate_critical_checks(frozen: dict, critical_check_ids) -> None:
"""关键核查项必须真由本方案的 Skill/规则产生,不能凭空声明。
否则"关键项"就成了无法追溯的标签,会绕过 BR-06 的硬性阻断语义。
"""
declared = list(critical_check_ids or ())
if not declared:
return
available = _plan_codes(frozen)
unknown = sorted(set(declared) - available)
if unknown:
raise ValueError(f"关键核查项不属于本方案的 Skill 或规则:{unknown}")
def propose_plan(db, body: dict, actor: str, project_id: str):
"""提议一份方案草稿。复用既有组件治理,不新建发布路径。"""
forbidden = sorted(key for key in FORBIDDEN_PLAN_KEYS if key in body)
if forbidden:
raise ValueError(
"方案不得自带工具配置面(不开放业务 Tools 卡);"
f"工具由后台治理,请移除:{forbidden}"
)
if body.get("kind") != "plan":
raise ValueError("提案类型必须是 plan")
spec_inputs = body.get("dimensions", [])
resolved = resolve_dimensions(spec_inputs) # 去重与刻度冲突在此拦截
result = presentation(resolved)
assert_nothing_dropped(result, resolved)
return registry.draft(db, dict(body, project_id=project_id, dimensions=resolved), actor)