""" Stage 3: Deterministic Merge & Completeness Audit. - Merges IR rule fragments, deduplicating by trigger+actions similarity. - Reassigns stable rule_ids. - Generates an audit report covering: 1. Logic tree node coverage 2. Table enumeration coverage 3. Global switch state coverage Outputs: - ir_final.json (in doc_parser output per spec) - ir_audit_report.md (in doc_parser output) """ import json import hashlib import sys from collections import defaultdict from pathlib import Path import config PASS = "[PASS]" WARN = "[WARN]" FAIL = "[FAIL]" def load_fragments() -> list[dict]: """Load IR fragments from Stage 2.""" return config.load_json(config.IR_FRAGMENTS_JSON) def load_semantic_index() -> dict: """Load semantic index from Stage 1.""" return config.load_json(config.SEMANTIC_INDEX_JSON) def rule_signature(rule: dict) -> str: """Generate a dedup signature from trigger + actions. Two rules with identical trigger conditions and actions produce the same signature and should be merged. """ trigger = rule.get("trigger", {}) actions = rule.get("actions", []) # Normalize: sort conditions by signal name for stability conditions = sorted(trigger.get("conditions", []), key=lambda c: c.get("signal", "")) # Sort actions by description sorted_actions = sorted(actions, key=lambda a: a.get("description", "")) sig_data = { "conditions": conditions, "actions": sorted_actions, } sig_json = json.dumps(sig_data, ensure_ascii=False, sort_keys=True) return hashlib.sha256(sig_json.encode()).hexdigest()[:16] def merge_rules(fragments: list[dict]) -> list[dict]: """Merge rules across all fragments, deduplicating by trigger+actions.""" signature_map: dict[str, dict] = {} order = [] for fragment in fragments: for rule in fragment.get("rules", []): sig = rule_signature(rule) if sig in signature_map: # Merge sources existing = signature_map[sig] existing_sources = existing.setdefault("sources", []) for src in rule.get("sources", []): if src not in existing_sources: existing_sources.append(src) # Use the more detailed description if len(rule.get("description", "")) > len(existing.get("description", "")): existing["description"] = rule["description"] else: signature_map[sig] = dict(rule) order.append(sig) merged = [signature_map[sig] for sig in order] print(f" 合并前: {sum(len(f.get('rules', [])) for f in fragments)} 条规则") print(f" 合并后: {len(merged)} 条规则") return merged def assign_rule_ids(rules: list[dict], feature_id: str = "DRL-001") -> list[dict]: """Reassign stable rule_ids based on type and sequence.""" type_counters = defaultdict(int) for rule in rules: # Determine type from the first action's type actions = rule.get("actions", []) if any(a.get("type") == "user_interaction" for a in actions) and \ not any(a.get("type") == "system" for a in actions): rtype = "UI" elif any("SDK" in str(a) for a in actions): rtype = "SDK" else: rtype = "SYS" type_counters[rtype] += 1 seq = type_counters[rtype] rule["rule_id"] = f"{feature_id}-{rtype}-FG-{seq:02d}" # Also generate top-level feature metadata return rules def find_all_logic_tree_nodes(doc: dict) -> dict[str, list[dict]]: """Return {image_id: [all nodes]} for all logic trees.""" result = {} for img in doc.get("image_analysis", []): lt = img.get("logic_tree") rid = img.get("rid", "") if lt and rid: result[rid] = lt.get("nodes", []) return result def find_referenced_nodes(rules: list[dict]) -> dict[str, set[str]]: """Return {image_id: {referenced node ids}} across all rules.""" referenced = defaultdict(set) for rule in rules: for src in rule.get("sources", []): if src.get("type") == "logic_tree": image_id = src.get("image_id", "") for nid in src.get("node_ids", []): referenced[image_id].add(nid) return dict(referenced) def audit_logic_tree_coverage( doc: dict, rules: list[dict] ) -> list[dict]: """Generate coverage statistics for logic tree nodes.""" all_nodes = find_all_logic_tree_nodes(doc) referenced = find_referenced_nodes(rules) results = [] for image_id, nodes in all_nodes.items(): ref_set = referenced.get(image_id, set()) decision_nodes = [n for n in nodes if n["type"] == "decision"] action_nodes = [n for n in nodes if n["type"] == "action"] state_nodes = [n for n in nodes if n["type"] == "state"] decisions_covered = [n for n in decision_nodes if n["id"] in ref_set] actions_covered = [n for n in action_nodes if n["id"] in ref_set] decisions_uncovered = [n for n in decision_nodes if n["id"] not in ref_set] actions_uncovered = [n for n in action_nodes if n["id"] not in ref_set] total_checkable = len(decision_nodes) + len(action_nodes) total_covered = len(decisions_covered) + len(actions_covered) coverage = (total_covered / total_checkable * 100) if total_checkable > 0 else 100 status = PASS if coverage >= 95 else (WARN if coverage >= 70 else FAIL) detail_parts = [f"{total_covered}/{total_checkable} decision+action 节点被引用"] if decisions_uncovered: detail_parts.append( f"未覆盖的 decision: {[n['id'] + ': ' + n.get('condition','')[:40] for n in decisions_uncovered]}" ) if actions_uncovered: detail_parts.append( f"未覆盖的 action: {[n['id'] + ': ' + n.get('description','')[:40] for n in actions_uncovered]}" ) results.append({ "check": f"逻辑树 {image_id} 节点覆盖率", "status": status, "coverage_pct": round(coverage, 1), "detail": "; ".join(detail_parts), "image_id": image_id, "uncovered_decisions": decisions_uncovered, "uncovered_actions": actions_uncovered, }) return results def find_table_enums(doc: dict) -> list[dict]: """Find enumerated values in tables (e.g., app types, limit methods).""" enums = [] for section in doc.get("sections", []): for block in section.get("blocks", []): if block["type"] != "table": continue headers = block.get("headers", []) if not headers: continue # Look for the "功能" / "功能详细说明" table pattern (key-value pairs) if "功能" in headers and "功能详细说明" in headers: for row in block.get("rows", []): cols = row.get("columns", []) key_col = next((c for c in cols if c.get("name") == "功能"), None) val_col = next( (c for c in cols if c.get("name") == "功能详细说明"), None ) if key_col and val_col: enums.append({ "section": section.get("source", ""), "row": key_col.get("row"), "key": key_col.get("text", ""), "value": val_col.get("text", ""), }) else: # Generic table: record first column values as potential enum first_col_name = headers[0] if headers else "" values = [] for row in block.get("rows", []): for col in row.get("columns", []): if col.get("name") == first_col_name: values.append(col.get("text", "")) if values: enums.append({ "section": section.get("source", ""), "column": first_col_name, "values": values, }) return enums def audit_table_enums(rules: list[dict], doc: dict) -> list[dict]: """Check if key enumerated values appear in rule preconditions.""" results = [] table_enums = find_table_enums(doc) # Collect all rule precondition fields and their values rule_preconditions = [] for rule in rules: precond = rule.get("precondition", {}) rule_preconditions.append(precond) # Check specific enum categories app_types = {"系统限制", "SDK限制", "其他应用"} switch_states = {"开启", "关闭"} app_states = {"前台", "后台"} # App type coverage found_app_types = set() for precond in rule_preconditions: at = precond.get("app_type", "") if at: found_app_types.add(at) missing_types = app_types - found_app_types results.append({ "check": "应用类型枚举覆盖", "status": PASS if not missing_types else WARN, "detail": f"已覆盖: {found_app_types or '无'}" + (f"; 未覆盖: {missing_types}" if missing_types else ""), }) # App state coverage found_states = set() for precond in rule_preconditions: st = precond.get("app_state", "") if st: found_states.add(st) missing_states = app_states - found_states results.append({ "check": "应用前后台状态覆盖", "status": PASS if not missing_states else WARN, "detail": f"已覆盖: {found_states or '无'}" + (f"; 未覆盖: {missing_states}" if missing_states else ""), }) # Trigger signal coverage (check each table enum key appears) trigger_signals = set() for rule in rules: for cond in rule.get("trigger", {}).get("conditions", []): signal = cond.get("signal", "") if signal: trigger_signals.add(signal) # Check if key concepts from the doc appear in signals key_signals = {"车速", "档位", "车速_持续时间", "应用请求启动"} missing_signals = key_signals - trigger_signals results.append({ "check": "触发信号覆盖(车速/档位/持续时间/启动请求)", "status": PASS if not missing_signals else WARN, "detail": f"已覆盖信号: {sorted(trigger_signals)}" + (f"; 未覆盖: {missing_signals}" if missing_signals else ""), }) return results def audit_switch_coverage(rules: list[dict]) -> list[dict]: """Check that rules cover both switch ON and OFF states.""" switch_on = False switch_off = False switch_rules = [] for rule in rules: precond = rule.get("precondition", {}) sw = precond.get("switch", "") if sw == "开启": switch_on = True switch_rules.append(rule.get("rule_id", "?")) elif sw == "关闭": switch_off = True switch_rules.append(rule.get("rule_id", "?")) status = PASS detail_parts = [] if switch_on: detail_parts.append(f"开关=开启: 有规则覆盖") else: detail_parts.append(f"开关=开启: 未找到规则") status = FAIL if switch_off: detail_parts.append(f"开关=关闭: 有规则覆盖") else: detail_parts.append(f"开关=关闭: 未找到规则") status = FAIL return [{ "check": "开关状态完整性(开启/关闭)", "status": status, "detail": "; ".join(detail_parts), }] def generate_audit_report( rules: list[dict], doc: dict, feature_name: str, lt_results: list[dict], enum_results: list[dict], switch_results: list[dict], ) -> str: """Generate ir_audit_report.md in Markdown format.""" lines = [] lines.append(f"# IR 完整性审计报告") lines.append(f"") lines.append(f"**功能**: {feature_name}") lines.append(f"**规则总数**: {len(rules)}") lines.append(f"**生成时间**: {__import__('datetime').datetime.now().isoformat()}") lines.append(f"") # Human review notice lines.append(f"> ⚠️ **重要**: 请人工审查以下 ⚠️ 和 ❌ 项,确认是文档遗漏还是 IR 提取遗漏。") lines.append(f'> 如无需修改,在对应项后标注 **"已确认"**。') lines.append(f"") # ---- Logic Tree Coverage ---- lines.append(f"## 1. 逻辑树节点覆盖率") lines.append(f"") lines.append(f"| 图片 ID | 覆盖率 | 状态 | 详情 |") lines.append(f"|---------|--------|------|------|") for r in lt_results: lines.append( f"| {r['image_id']} | {r['coverage_pct']}% | {r['status']} | {r['detail']} |" ) lines.append(f"") # Uncovered node details for r in lt_results: if r["uncovered_decisions"] or r["uncovered_actions"]: lines.append(f"### {r['image_id']} 未覆盖节点详情") lines.append(f"") for n in r.get("uncovered_decisions", []): lines.append(f"- **Decision** `{n['id']}`: {n.get('condition', '?')}") for n in r.get("uncovered_actions", []): lines.append(f"- **Action** `{n['id']}`: {n.get('description', '?')}") lines.append(f"") # ---- Table Enumeration Coverage ---- lines.append(f"## 2. 表格枚举覆盖") lines.append(f"") lines.append(f"| 检查项 | 状态 | 详情 |") lines.append(f"|--------|------|------|") for r in enum_results: lines.append(f"| {r['check']} | {r['status']} | {r['detail']} |") lines.append(f"") # ---- Switch Coverage ---- lines.append(f"## 3. 全局开关状态覆盖") lines.append(f"") lines.append(f"| 检查项 | 状态 | 详情 |") lines.append(f"|--------|------|------|") for r in switch_results: lines.append(f"| {r['check']} | {r['status']} | {r['detail']} |") lines.append(f"") # ---- Rule Summary ---- lines.append(f"## 4. 规则清单") lines.append(f"") lines.append(f"| rule_id | Priority | 简述 |") lines.append(f"|---------|----------|------|") for rule in rules: desc = rule.get("description", "")[:80] lines.append(f"| {rule.get('rule_id', '?')} | {rule.get('priority', '?')} | {desc} |") lines.append(f"") return "\n".join(lines) def main(): print("=" * 60) print("阶段三:确定性合并与完整性校验") print("=" * 60) # 1. Load inputs print(f"\n[1/5] 加载输入...") fragments = load_fragments() doc = config.load_input_document() semantic_index = load_semantic_index() feature_name = semantic_index.get("feature_name", "行车娱乐限制") feature_id = "DRL-001" print(f" 功能: {feature_name} ({feature_id})") print(f" 片段数: {len(fragments)}") # 2. Merge rules print(f"\n[2/5] 合并去重...") merged_rules = merge_rules(fragments) # 3. Reassign rule IDs print(f"\n[3/5] 重分配 rule_id...") final_rules = assign_rule_ids(merged_rules, feature_id) print(f" 已分配 {len(final_rules)} 个稳定 ID") # Collect top-level metadata ir_final = { "feature": feature_name, "feature_id": feature_id, "rules": final_rules, } # Save ir_final.json print(f"\n[4/5] 生成审计报告...") lt_results = audit_logic_tree_coverage(doc, final_rules) enum_results = audit_table_enums(final_rules, doc) switch_results = audit_switch_coverage(final_rules) report = generate_audit_report( final_rules, doc, feature_name, lt_results, enum_results, switch_results ) # 5. Save outputs print(f"\n[5/5] 保存输出...") config.save_json(ir_final, config.IR_FINAL_JSON) print(f" IR: {config.IR_FINAL_JSON}") with open(config.IR_AUDIT_REPORT_MD, "w", encoding="utf-8") as f: f.write(report) print(f" 审计报告: {config.IR_AUDIT_REPORT_MD}") # Print quick summary print(f"\n完成!") issue_count = sum( 1 for r in lt_results + enum_results + switch_results if r["status"] in (WARN, FAIL) ) print(f" 规则: {len(final_rules)} 条") print(f" 审计问题: {issue_count} 个需要人工审查") if issue_count > 0: print(f"\n 请查看 {config.IR_AUDIT_REPORT_MD} 并审查标记项。") if __name__ == "__main__": main()