2026-09-29 20:16:39 +08:00
|
|
|
|
"""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
|
2026-09-29 20:20:58 +08:00
|
|
|
|
from services import assistant_store as store
|
|
|
|
|
|
from services import component_registry as registry
|
2026-09-29 20:16:39 +08:00
|
|
|
|
|
|
|
|
|
|
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)}")
|
2026-09-29 20:20:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 方案装配(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
|
2026-09-29 20:24:24 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- 方案治理(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}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-01 00:30:41 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-29 20:24:24 +08:00
|
|
|
|
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)
|