Files
mini-openclaw-v2/backend/services/review_plan.py
T

265 lines
12 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)的机械表达。
"""
from services.catalog_configuration import hidden
if hidden(db, component_id):
return "COMPONENT_HIDDEN:评审方案存在隐藏组件,请更新配置后重试"
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 list_plans(db, project_id: str, domain: str | None = None) -> list[dict]:
"""项目自建的方案草稿与已发布版本。
只列**项目自建**的方案(带 project_id 的组件);内置种子方案是全局配置,
不属于任何项目,不在这里冒充项目方案。
"""
items = []
for kind, status in (("component_draft", "draft"), ("component_version", "published")):
for row in store.listing(db, kind, project_id):
if row.get("kind") != "plan" or row.get("project_id") != project_id:
continue
if domain and row.get("scope") != domain:
continue
items.append({
"id": row.get("component_id") or row["id"],
"version": row.get("version", "1.0.0"),
"status": row.get("status", status),
"name": row.get("name", ""),
"revision": row.get("revision", 1),
"skills": list(row.get("skills") or ()),
"rules": list(row.get("rules") or ()),
"template": row.get("template", ""),
"dimensions": [item.get("id") for item in (row.get("dimensions") or ())],
})
items.sort(key=lambda item: (item["id"], item["version"], item["status"] != "published"))
return items
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)
from services.catalog_configuration import available_plan
if not available_plan(db, body):
raise ValueError("COMPONENT_HIDDEN:评审方案存在隐藏组件,请更新配置后重试")
return registry.draft(db, dict(body, project_id=project_id, dimensions=resolved), actor)