v7 mech-7: scorecard user_side_items + three-table rule (code unchanged, production forced not_pass on user-side pending/unknown/invalid)

(cherry picked from commit 59b4c49a896972501aeeb8472a9fc2918ec226fa)
This commit is contained in:
host committed 2026-11-03 02:49:27 +00:00
1 parent f8a6a9d258
commit f406398ce2
2 files changed
+495 -8

No files matched your search

+231 -8
View File
@@ -59,11 +59,13 @@
- 文档与技能并入架构与契约一致性
(技能文本、附录溯源、移植规则最终都是契约一致性的一部分)。
输入格式:build_scorecard(evidence, thresholds=None, rubric_version=None)
输入格式:build_scorecard(evidence, thresholds=None, rubric_version=None,
baseline_head=None, evidence_rows=None, user_side_items=None,
product_evidence=None)
rubric_version 只是卡片上的版本锚点(版本化持久存储见
cloud/scorecard_rubric.py:标尺改动写新版本行、旧行只读,
本函数不查库、不改旧分)。
evidence: dict,键为上面 7 个维 id,值为该维的证据 dict。
输入格式:build_scorecard(evidence, thresholds=None, user_side_items=None) evidence: dict,键为上面 7 个维 id,值为该维的证据 dict。
每个维的证据字段见各 _score_* 函数的 docstring。
调用方必须带来源、时间、检查名;本模块不自动从测试文件、
摘要文档、PR 描述里拼凑分数。
@@ -76,8 +78,26 @@
产品轮证据时恒 not_pass;这只是评分卡输入结构拆分,
不代表任何产品功能已落地。build_scorecard 的返回 dict
同时带 code/production/product 三个子对象,GET /audit-scorecard
端点返回的示例卡因此也带这三个子对象(端点已接线)。
"""
端点返回的示例卡因此也带这三个子对象(端点已接线)。v7 机制第 7 项 user_side_items(本模块内实现,不依赖第 2 项的三张表函数):
- evidence 可带可选键 "user_side_items",或由 build_scorecard 的
user_side_items 关键字参数显式传入(关键字参数优先于 evidence 键)。
每项至少含 id、status(pending/done/blocked)、blocked_by
(pending/blocked 时必填,例如 user/host_env/contract_decision)。
- 非法项绝不静默丢弃、绝不编造状态:记入
user_side_items["invalid_items"](含下标与原因),不进 items。
- 输出增加 user_side_items 字段:原样透传有效项(多余字段保留),
并附汇总 pending_ids、done_ids、blocked_ids、blocked_by_summary。
- 三表规则见 apply_user_side_three_table_rule():代码表结论
(code_overall)绝不因用户侧 pending 被拉低;用户侧有未完成项
(pending/blocked)或非法项时,生产表结论强制为 not_pass,
原因字段明确指向用户侧(如 "user_side_pending: B1,B2,...");
user_side_items 为空/缺失时,输出 user_side_unknown=True,
生产表原因写 user_side_unknown,绝不把“无用户侧项”写成
“用户侧已完成”。
- 本项不做 B/C 拍板、不改阈值:阈值仍为 test_default_unapproved;
B/C 清单只是 id 与标题常量(见 USER_SIDE_ITEM_IDS),未知 id
不因此判非法(清单可能扩展),非法只按缺字段/未知状态/缺
blocked_by/重复 id 判定。"""
from __future__ import annotations
@@ -124,6 +144,190 @@ DEFAULT_THRESHOLDS = {
UNTESTED_KINDS = {"fixture", "viewport_substitute", "code_review_only",
"fixture_simulated", "dry_run", "estimated"}
# v7 审计 B/C 用户侧清单(id 与标题;仅作常量与测试证据,不作拍板)。
USER_SIDE_ITEM_IDS = [
"B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B9",
"C1", "C2", "C3", "C4",
]
USER_SIDE_ITEM_TITLES = {
"B1": "分支保护",
"B2": "合并部署窗口",
"B3": "阈值拍板",
"B4": "金任务人工审定",
"B5": "真实 PG 环境",
"B6": "真实 App 安装",
"B7": "第二台主机与账单",
"B8": "v1.1 确认",
"B9": "回滚演练窗口",
"C1": "证据绑定强制化与签名",
"C2": "live 验证接线",
"C3": "锚点策略",
"C4": "actual-only 语义",
}
VALID_USER_SIDE_STATUSES = {"pending", "done", "blocked"}
def normalize_user_side_items(raw):
"""归一化用户侧项:有效项原样透传,非法项记入 invalid_items。
返回 dict,键:items、pending_ids、done_ids、blocked_ids、
unfinished_ids、blocked_by_summary、invalid_items、count、
all_pending、all_done、user_side_unknown。
user_side_unknown 仅在 raw 为 None 或空列表时为 True;
非列表、非法项等情形不算 unknown,而是 invalid(生产表
同样不能因此通过,原因指向 invalid,见三表规则)。
本函数不修改调用方传入的原始项(逐项浅拷贝)。
"""
summary = {
"items": [],
"pending_ids": [],
"done_ids": [],
"blocked_ids": [],
"unfinished_ids": [],
"blocked_by_summary": {},
"invalid_items": [],
"count": 0,
"all_pending": False,
"all_done": False,
"user_side_unknown": False,
}
if raw is None:
summary["user_side_unknown"] = True
return summary
if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)):
summary["invalid_items"].append({
"index": None,
"reason": "user_side_items_not_a_list",
"raw_type": type(raw).__name__,
})
return summary
if len(raw) == 0:
summary["user_side_unknown"] = True
return summary
seen_ids = set()
for index, item in enumerate(raw):
if not isinstance(item, dict):
summary["invalid_items"].append({
"index": index,
"reason": "item_not_a_dict",
"raw_type": type(item).__name__,
})
continue
item_id = item.get("id")
if not isinstance(item_id, str) or not item_id:
summary["invalid_items"].append({
"index": index,
"reason": "id_missing",
})
continue
if item_id in seen_ids:
summary["invalid_items"].append({
"index": index,
"id": item_id,
"reason": "duplicate_id",
})
continue
status = item.get("status")
if status not in VALID_USER_SIDE_STATUSES:
summary["invalid_items"].append({
"index": index,
"id": item_id,
"reason": "status_missing_or_unknown",
"status": status,
})
continue
blocked_by = item.get("blocked_by")
if status in ("pending", "blocked"):
if not isinstance(blocked_by, str) or not blocked_by:
summary["invalid_items"].append({
"index": index,
"id": item_id,
"reason": "blocked_by_missing",
})
continue
seen_ids.add(item_id)
copied = dict(item)
summary["items"].append(copied)
if status == "pending":
summary["pending_ids"].append(item_id)
summary["unfinished_ids"].append(item_id)
elif status == "blocked":
summary["blocked_ids"].append(item_id)
summary["unfinished_ids"].append(item_id)
else:
summary["done_ids"].append(item_id)
if isinstance(blocked_by, str) and blocked_by:
summary["blocked_by_summary"].setdefault(blocked_by, []).append(item_id)
summary["count"] = len(summary["items"])
if summary["items"]:
summary["all_pending"] = all(
it["status"] == "pending" for it in summary["items"])
summary["all_done"] = all(
it["status"] == "done" for it in summary["items"])
return summary
def _extract_overall(value):
"""从字符串或评分卡 dict 里取出 overall(字符串原样返回)。"""
if isinstance(value, dict):
for key in ("overall", "code_overall", "production_overall"):
if isinstance(value.get(key), str):
return value[key]
return None
if isinstance(value, str):
return value
return None
def apply_user_side_three_table_rule(code_overall, production_overall,
user_side_items):
"""三表规则(v7 机制第 7 项;位置:本模块)。
输入:代码表结论、生产表结论(各为 "pass"/"not_pass" 字符串,
或含 overall 键的评分卡 dict)、user_side_items 原始列表。
输出:代码表结论原样不动;生产表结论在用户侧未完成/非法/
未知时强制为 not_pass,原因明确指向用户侧;用户侧全部 done
且无非法项时,生产表结论跟随其自身输入结论,不被本规则改写。
本函数是纯函数,不读库、不改阈值、不做 B/C 拍板。
"""
normalized = normalize_user_side_items(user_side_items)
code = _extract_overall(code_overall)
production = _extract_overall(production_overall)
forced = False
reason = None
if normalized["user_side_unknown"]:
production = "not_pass"
reason = ("user_side_unknown: no user_side_items supplied; "
"not equivalent to user_side_done")
forced = True
elif normalized["invalid_items"]:
production = "not_pass"
reasons = "; ".join(
"%s(%s)" % (inv.get("reason", "invalid"),
inv.get("id", inv.get("index")))
for inv in normalized["invalid_items"])
reason = "user_side_invalid: %s" % reasons
if normalized["unfinished_ids"]:
reason += "; user_side_pending: %s" % ",".join(
normalized["unfinished_ids"])
forced = True
elif normalized["unfinished_ids"]:
production = "not_pass"
reason = "user_side_pending: %s" % ",".join(
normalized["unfinished_ids"])
forced = True
return {
"code_overall": code,
"production_overall": production,
"production_reason": reason,
"user_side_forced_production_not_pass": forced,
"code_unchanged_by_user_side": True,
"user_side_unknown": normalized["user_side_unknown"],
"user_side_items": normalized,
}
def thresholds_source(thresholds):
"""阈值来源:只有调用方显式传入 user_approved 才算已拍板。"""
@@ -690,18 +894,18 @@ def _build_table(dim_ids, evidence, th):
# 钉死:平均分绝不能作为达标依据(各表各自独立判断)。
overall_pass = all(d["score"] is not None and d["score"] >= PASS_SCORE
for d in dimensions)
overall = "pass" if overall_pass else "not_pass"
return {
"dimensions": dimensions,
"average_score": average,
"average_is_not_pass_criterion": True,
"overall": "pass" if overall_pass else "not_pass",
"overall": overall,
"overall_pass": overall_pass,
"pass_score": PASS_SCORE,
"missing_items": missing_all,
"untested_items": untested_all,
"thresholds_source": thresholds_source(th),
"thresholds": _effective_thresholds(th),
}
"thresholds": _effective_thresholds(th), }
def check_baseline_stale(scorecard, current_main_head):
@@ -881,10 +1085,29 @@ def build_scorecard(evidence, thresholds=None, rubric_version=None,
report["evidence_row_ids"] = ledger["row_ids"]
report["thresholds"] = _effective_thresholds(th)
report["code"] = build_code_scorecard(evidence, thresholds)
raw_user_side = (user_side_items if user_side_items is not None
else evidence.get("user_side_items"))
report["production"] = build_production_scorecard(
evidence, thresholds, user_side_items)
evidence, thresholds, raw_user_side)
report["product"] = build_product_scorecard(
evidence, thresholds, product_evidence)
# v7 机制第 7 项三表规则(合一接线):代码表结论绝不因用户侧
# pending 被拉低;用户侧未完成/非法/未知时顶层
# production_overall 强制 not_pass 且原因指向用户侧。注意:
# 顶层 production_overall 是规则结论,report["production"]
# 子对象仍是第 2 项的证据表(维分数照常展示),两者分工不同,
# 顶层字段不回写子对象 overall,以免第 2 项的证据表语义被改。
rule = apply_user_side_three_table_rule(
report["code"]["overall"], report["production"]["overall"],
raw_user_side)
report["user_side_items"] = rule["user_side_items"]
report["code_overall"] = rule["code_overall"]
report["production_overall"] = rule["production_overall"]
report["production_reason"] = rule["production_reason"]
report["user_side_unknown"] = rule["user_side_unknown"]
report["code_unchanged_by_user_side"] = True
report["user_side_forced_production_not_pass"] = (
rule["user_side_forced_production_not_pass"])
return report