"""p_validator_root_cause_trace (04-P §4.3): U-015 scope. "血缘读取"(tracing_lineage: reading SDD/PRD/BRD versions + Coder implementation records) is the caller's job -- perspective_fns are expected to already have whatever lineage context they need baked in (e.g. closures over artifact content read beforehand) by the time judge_root_cause calls them. This module only drives judging_root_cause's multi-perspective vote and the bug_recorded/inconclusive_escalate branch, matching 04-P's actual state-table scope for that transition. Each perspective_fn takes no arguments and returns {"category": "planning"| "coder"|"skill_defect", "detail": str} -- one judgment from one of the three perspectives 04-P names (正确性视角/需求完整性视角/Skill 系统性缺陷视角). Which concrete question each perspective asks is a prompt-engineering concern for the caller (see real_validator_perspective below for the real-call version), not this module's. """ from collections import Counter from src import bug_record class InconclusiveEscalateError(Exception): """04-P: judging_root_cause -> inconclusive_escalate (<2/3 perspectives agree). Carries the raw per-perspective judgments for human review ("附带三视角原始判断供人工裁决").""" def __init__(self, judgments): self.judgments = judgments super().__init__("perspectives did not reach 2/3 agreement: " + repr(judgments)) def judge_root_cause(perspective_fns): """Runs all perspective_fns, requires >=2/3 to agree on the same category (04-P guard: "至少 2/3 视角一致才定案"). Returns {"root_cause_category", "root_cause_detail", "judgments"} on agreement, raises InconclusiveEscalateError otherwise. """ judgments = [fn() for fn in perspective_fns] counts = Counter(j["category"] for j in judgments) winner, count = counts.most_common(1)[0] if count * 2 < len(judgments): # strictly "at least 2/3", not just plurality raise InconclusiveEscalateError(judgments) detail = " | ".join(j["detail"] for j in judgments if j["category"] == winner) return {"root_cause_category": winner, "root_cause_detail": detail, "judgments": judgments} def trace_root_cause_test_failed(conn, task_id, description, perspective_fns, suggested_route, related_artifact_ids=None): """04-P: test_failed -> tracing_lineage -> judging_root_cause -> bug_recorded. Creates a brand-new bug_record (test_failed origin never updates an existing one, unlike human_reported -- see 04-P's own note on this distinction). Re-raises InconclusiveEscalateError unchanged if judge_root_cause can't reach agreement. """ result = judge_root_cause(perspective_fns) created = bug_record.create_bug_record( conn, task_id=task_id, description=description, root_cause_category=result["root_cause_category"], root_cause_detail=result["root_cause_detail"], suggested_route=suggested_route, related_artifact_ids=related_artifact_ids, ) return {**created, **result} def trace_root_cause_human_reported(conn, bug_id, perspective_fns, suggested_route): """04-P: human_reported (already claimed via bug_record.claim_human_reported_bug, status=triaging) -> tracing_lineage -> judging_root_cause -> bug_recorded. Field-completes the existing bug_record instead of creating a new one. """ result = judge_root_cause(perspective_fns) bug_record.complete_human_reported_triage( conn, bug_id, result["root_cause_category"], result["root_cause_detail"], suggested_route ) return result _PERSPECTIVE_FRAMINGS = { "correctness": ( "CORRECTNESS PERSPECTIVE: does the Coder's implementation match what the SDD/PRD actually " "specify? If the spec was followed correctly and the bug is still there, this is not your " "category to claim." ), "requirement_completeness": ( "REQUIREMENT-COMPLETENESS PERSPECTIVE: does the PRD/SDD actually define the behavior the bug " "report is about at all? If the requirement was never specified (a genuine planning gap, not " "an implementation mistake), that's your category to claim." ), "skill_defect": ( "SKILL-DEFECT PERSPECTIVE: is this a one-off mistake in this specific task, or does it look " "like the underlying Skill (the reusable methodology/prompt an Org Agent follows) is " "systematically wrong in a way that would recur on other tasks?" ), } def real_validator_perspective(perspective_name, bug_description, context, target_folder_path, trigger_fn, timeout=300): """Builds a real perspective_fn (for use with judge_root_cause) that calls the deployed Validator agent, framed from one of the three named perspectives, and parses its 'CATEGORY: ...' / 'DETAIL: ...' response. `trigger_fn` is org_node_trigger.trigger_org_node, passed in explicitly (not imported/called directly) so tests can inject a fake without mocking a module-global. """ framing = _PERSPECTIVE_FRAMINGS[perspective_name] prompt = ( "{framing}\n\nBug: {bug_description}\n\nContext:\n{context}\n\n" "Reply with exactly two lines:\n" "CATEGORY: planning|coder|skill_defect\n" "DETAIL: " ).format(framing=framing, bug_description=bug_description, context=context) def perspective_fn(): response = trigger_fn("validator", prompt, target_folder_path, timeout=timeout) category = None detail = "" for line in response.splitlines(): stripped = line.strip() if stripped.upper().startswith("CATEGORY:"): value = stripped.split(":", 1)[1].strip().lower() if value in ("planning", "coder", "skill_defect"): category = value elif stripped.upper().startswith("DETAIL:"): detail = stripped.split(":", 1)[1].strip() if category is None: raise ValueError("could not parse a CATEGORY line from validator response: " + response) return {"category": category, "detail": detail or response} return perspective_fn