root_cause_trace.py 6.1 KB
"""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: <one sentence, cite the specific SDD section or code location>"
    ).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