diff --git a/scripts/stage_agent_loop.py b/scripts/stage_agent_loop.py index 7009f79..578d324 100644 --- a/scripts/stage_agent_loop.py +++ b/scripts/stage_agent_loop.py @@ -1230,6 +1230,168 @@ def load_gui_review_detector_gate(review_dir: Path) -> dict[str, Any]: } +def unique_strings(values: list[str]) -> list[str]: + result: list[str] = [] + for value in values: + text = str(value or "").strip() + if text and text not in result: + result.append(text) + return result + + +def stage_patch_target_looks_like_file(value: str) -> bool: + normalized = str(value or "").replace("\\", "/").strip() + if not normalized or " " in normalized: + return False + prefixes = ( + "llm_normalizer/", + "scripts/", + "docs/", + ".codex/", + "graphify-out/", + ) + return normalized.startswith(prefixes) + + +def issue_catalog_contract_for_stage(issue_code: str, catalog: dict[str, Any]) -> dict[str, Any]: + entry = dcl.issue_catalog_entry(issue_code, catalog) + return { + "issue_code": issue_code, + "severity": entry.get("severity"), + "root_layers": dcl.normalize_string_list(entry.get("root_layers")), + "expected_answer_contract": dcl.issue_acceptance_contract_name(issue_code, entry), + "detectors": dcl.normalize_string_list(entry.get("detectors")), + "allowed_patch_targets": dcl.normalize_string_list(entry.get("allowed_patch_targets")), + "forbidden_patch_targets": dcl.normalize_string_list(entry.get("forbidden_patch_targets")), + "rerun_matrix": dcl.normalize_string_list(entry.get("rerun_matrix")), + } + + +def enrich_stage_gui_repair_target( + target: dict[str, Any], + *, + catalog: dict[str, Any], + fallback_evidence_paths: list[str], +) -> dict[str, Any]: + issue_code = str(target.get("issue_code") or "").strip() + problem_layer = str(target.get("problem_layer") or "other").strip() or "other" + catalog_contract = issue_catalog_contract_for_stage(issue_code, catalog) if issue_code else {} + root_layers = unique_strings( + dcl.normalize_string_list(target.get("root_cause_layers")) + + dcl.normalize_string_list(target.get("suggested_root_cause_layers")) + + dcl.normalize_string_list(catalog_contract.get("root_layers")) + + [problem_layer] + ) + allowed_patch_targets = unique_strings( + dcl.normalize_string_list(target.get("allowed_patch_targets")) + + dcl.normalize_string_list(catalog_contract.get("allowed_patch_targets")) + ) + forbidden_patch_targets = unique_strings( + dcl.normalize_string_list(target.get("forbidden_patch_targets")) + + dcl.normalize_string_list(catalog_contract.get("forbidden_patch_targets")) + ) + rerun_matrix = unique_strings( + dcl.normalize_string_list(target.get("rerun_matrix")) + + dcl.normalize_string_list(catalog_contract.get("rerun_matrix")) + ) + candidate_files = unique_strings( + dcl.normalize_string_list(target.get("candidate_files")) + + [ + path + for path in allowed_patch_targets + if stage_patch_target_looks_like_file(path) + ] + + list(dcl.REPAIR_TARGET_FILE_HINTS.get(problem_layer) or dcl.REPAIR_TARGET_FILE_HINTS["other"]) + ) + evidence_paths = unique_strings(dcl.normalize_string_list(target.get("evidence_paths")) + fallback_evidence_paths) + expected_contract = ( + str(target.get("expected_business_answer_contract") or "").strip() + or str(catalog_contract.get("expected_answer_contract") or "").strip() + or None + ) + target_id = str(target.get("target_id") or "").strip() + if not target_id: + target_id = f"gui:{issue_code or 'unknown'}:{problem_layer}" + return { + **target, + "target_id": target_id, + "problem_type": target.get("problem_type") or problem_layer, + "root_cause_layers": root_layers, + "expected_business_answer_contract": expected_contract, + "detectors": dcl.normalize_string_list(catalog_contract.get("detectors")), + "allowed_patch_targets": allowed_patch_targets, + "forbidden_patch_targets": forbidden_patch_targets, + "rerun_matrix": rerun_matrix, + "candidate_files": candidate_files, + "evidence_paths": evidence_paths, + } + + +def build_stage_gui_repair_contract( + *, + summary: dict[str, Any], + review: dict[str, Any], +) -> dict[str, Any]: + latest = summary.get("latest_gui_review") if isinstance(summary.get("latest_gui_review"), dict) else {} + catalog = dcl.load_issue_catalog() + raw_targets = review.get("repair_targets") if isinstance(review.get("repair_targets"), list) else [] + fallback_evidence_paths = unique_strings( + [ + latest.get("review_json"), + latest.get("review_markdown"), + latest.get("repair_targets_json"), + latest.get("conversation_pairs_json"), + latest.get("detector_results_json"), + ] + ) + targets = [ + enrich_stage_gui_repair_target(target, catalog=catalog, fallback_evidence_paths=fallback_evidence_paths) + for target in raw_targets + if isinstance(target, dict) + ] + issue_codes = unique_strings([str(target.get("issue_code") or "") for target in targets]) + issue_catalog_contracts = { + issue_code: issue_catalog_contract_for_stage(issue_code, catalog) + for issue_code in issue_codes + } + severity_counts = Counter(str(target.get("severity") or "unknown") for target in targets) + rerun_matrix = unique_strings([item for target in targets for item in dcl.normalize_string_list(target.get("rerun_matrix"))]) + candidate_files = unique_strings([item for target in targets for item in dcl.normalize_string_list(target.get("candidate_files"))]) + focus = None + if targets: + focus = { + "focus_id": f"gui:{latest.get('run_id') or review.get('run_id') or 'review'}:{issue_codes[0] if issue_codes else 'unknown'}", + "issue_codes": issue_codes, + "root_cause_layers": unique_strings( + [item for target in targets for item in dcl.normalize_string_list(target.get("root_cause_layers"))] + ), + "allowed_patch_targets": unique_strings( + [item for target in targets for item in dcl.normalize_string_list(target.get("allowed_patch_targets"))] + ), + "forbidden_patch_targets": unique_strings( + [item for target in targets for item in dcl.normalize_string_list(target.get("forbidden_patch_targets"))] + ), + "rerun_matrix": rerun_matrix, + "candidate_files": candidate_files, + "target_ids": unique_strings([str(target.get("target_id") or "") for target in targets]), + "severity": targets[0].get("severity"), + "problem_type": targets[0].get("problem_type"), + "target_count": len(targets), + "rank_reason": "gui_review_primary_repair_targets", + } + return { + "schema_version": "stage_gui_repair_contract_v1", + "target_count": len(targets), + "severity_counts": dict(sorted(severity_counts.items())), + "issue_codes": issue_codes, + "issue_catalog_contracts": issue_catalog_contracts, + "rerun_matrix": rerun_matrix, + "candidate_files": candidate_files, + "targets": targets, + "priority_foci": [focus] if focus else [], + } + + def build_gui_review_stage_summary( *, stage_manifest: dict[str, Any], @@ -1331,13 +1493,9 @@ def build_gui_review_stage_summary( def build_stage_repair_handoff(summary: dict[str, Any], review: dict[str, Any]) -> dict[str, Any]: latest = summary.get("latest_gui_review") if isinstance(summary.get("latest_gui_review"), dict) else {} - repair_targets = review.get("repair_targets") if isinstance(review.get("repair_targets"), list) else [] findings = review.get("findings") if isinstance(review.get("findings"), list) else [] - ordered_targets = [ - target - for target in repair_targets - if isinstance(target, dict) - ] + repair_contract = build_stage_gui_repair_contract(summary=summary, review=review) + ordered_targets = repair_contract.get("targets") if isinstance(repair_contract.get("targets"), list) else [] primary_targets = ordered_targets[:5] target_issue_codes = {str(target.get("issue_code") or "") for target in primary_targets} sample_findings = [ @@ -1349,6 +1507,20 @@ def build_stage_repair_handoff(summary: dict[str, Any], review: dict[str, Any]) or any(str(code) in target_issue_codes for code in finding.get("issue_codes", [])) ) ][:8] + detector_results_summary = ( + latest.get("detector_results_summary") if isinstance(latest.get("detector_results_summary"), dict) else {} + ) + detector_results_for_gate = {"summary": detector_results_summary, "results": []} + assigned_focus = ( + repair_contract.get("priority_foci", [None])[0] + if isinstance(repair_contract.get("priority_foci"), list) and repair_contract.get("priority_foci") + else None + ) + auto_propose_gate = dcl.evaluate_auto_coder_gate( + repair_contract, + assigned_focus if isinstance(assigned_focus, dict) else None, + detector_results=detector_results_for_gate if detector_results_summary else None, + ) return { "schema_version": "stage_gui_repair_handoff_v1", "stage_id": summary.get("stage_id"), @@ -1363,6 +1535,13 @@ def build_stage_repair_handoff(summary: dict[str, Any], review: dict[str, Any]) "detector_candidates_json": latest.get("detector_candidates_json"), "detector_results_json": latest.get("detector_results_json"), "detector_results_status": latest.get("detector_results_status"), + "detector_results_summary": detector_results_summary, + "issue_codes": repair_contract.get("issue_codes"), + "issue_catalog_contracts": repair_contract.get("issue_catalog_contracts"), + "rerun_matrix": repair_contract.get("rerun_matrix"), + "candidate_files": repair_contract.get("candidate_files"), + "repair_contract": repair_contract, + "auto_propose_gate": auto_propose_gate, "primary_repair_targets": primary_targets, "sample_findings": sample_findings, } @@ -1383,9 +1562,34 @@ def build_stage_repair_handoff_markdown(handoff: dict[str, Any]) -> str: f"- repair_targets_json: `{handoff.get('repair_targets_json')}`", f"- detector_results_status: `{handoff.get('detector_results_status') or 'n/a'}`", f"- detector_results_json: `{handoff.get('detector_results_json') or 'n/a'}`", + f"- issue_codes: `{', '.join(dcl.normalize_string_list(handoff.get('issue_codes'))) or 'n/a'}`", + f"- rerun_matrix: `{', '.join(dcl.normalize_string_list(handoff.get('rerun_matrix'))) or 'n/a'}`", "", - "## Primary Repair Targets", ] + detector_summary = handoff.get("detector_results_summary") if isinstance(handoff.get("detector_results_summary"), dict) else {} + if detector_summary: + lines.extend( + [ + "## Detector Gate", + f"- status: `{detector_summary.get('status') or 'n/a'}`", + f"- counts: `pass={detector_summary.get('pass') or 0}, fail={detector_summary.get('fail') or 0}, review={detector_summary.get('review') or 0}, skipped={detector_summary.get('skipped') or 0}`", + "", + ] + ) + auto_gate = handoff.get("auto_propose_gate") if isinstance(handoff.get("auto_propose_gate"), dict) else {} + if auto_gate: + lines.extend( + [ + "## Auto-Propose Gate", + f"- allowed: `{bool(auto_gate.get('allowed'))}`", + f"- reason: `{auto_gate.get('reason') or 'n/a'}`", + f"- focus_id: `{auto_gate.get('focus_id') or 'n/a'}`", + ] + ) + blocking = dcl.normalize_string_list(auto_gate.get("blocking_reasons")) + lines.extend([f"- blocking_reason: `{item}`" for item in blocking[:12]] or ["- blocking_reason: `none`"]) + lines.append("") + lines.append("## Primary Repair Targets") targets = handoff.get("primary_repair_targets") if isinstance(handoff.get("primary_repair_targets"), list) else [] if not targets: lines.append("- no repair targets") @@ -1393,9 +1597,16 @@ def build_stage_repair_handoff_markdown(handoff: dict[str, Any]) -> str: for target in targets: if not isinstance(target, dict): continue - lines.append( - f"- `{target.get('severity')}` `{target.get('problem_layer')}` / `{target.get('issue_code')}`: " - f"{target.get('occurrences')} occurrence(s)" + lines.extend( + [ + f"- `{target.get('severity')}` `{target.get('problem_layer')}` / `{target.get('issue_code')}`: " + f"{target.get('occurrences')} occurrence(s)", + f" expected_contract: `{target.get('expected_business_answer_contract') or 'n/a'}`", + f" allowed_patch_targets: `{', '.join(dcl.normalize_string_list(target.get('allowed_patch_targets'))) or 'n/a'}`", + f" forbidden_patch_targets: `{', '.join(dcl.normalize_string_list(target.get('forbidden_patch_targets'))) or 'n/a'}`", + f" rerun_matrix: `{', '.join(dcl.normalize_string_list(target.get('rerun_matrix'))) or 'n/a'}`", + f" candidate_files: `{', '.join(dcl.normalize_string_list(target.get('candidate_files'))) or 'n/a'}`", + ] ) lines.extend(["", "## Sample Findings"]) findings = handoff.get("sample_findings") if isinstance(handoff.get("sample_findings"), list) else [] @@ -1423,7 +1634,10 @@ def save_stage_repair_handoff(stage_dir: Path, handoff: dict[str, Any]) -> None: def enrich_repair_target_for_coder(target: dict[str, Any]) -> dict[str, Any]: problem_layer = str(target.get("problem_layer") or "other").strip() or "other" - candidate_files = list(dcl.REPAIR_TARGET_FILE_HINTS.get(problem_layer) or dcl.REPAIR_TARGET_FILE_HINTS["other"]) + candidate_files = unique_strings( + dcl.normalize_string_list(target.get("candidate_files")) + + list(dcl.REPAIR_TARGET_FILE_HINTS.get(problem_layer) or dcl.REPAIR_TARGET_FILE_HINTS["other"]) + ) return { **target, "candidate_files": candidate_files, @@ -1463,12 +1677,25 @@ def build_stage_repair_iteration_plan( "source_handoff": repo_relative(stage_dir / "stage_repair_handoff.json"), "source_review_markdown": handoff.get("review_markdown"), "source_repair_targets_json": handoff.get("repair_targets_json"), + "source_detector_results_json": handoff.get("detector_results_json"), "run_id": handoff.get("run_id"), "next_action": handoff.get("next_action"), "overall_business_status": handoff.get("overall_business_status"), "p0_findings": handoff.get("p0_findings"), "p1_findings": handoff.get("p1_findings"), "question_quality_score": handoff.get("question_quality_score"), + "detector_results_status": handoff.get("detector_results_status"), + "detector_results_summary": ( + handoff.get("detector_results_summary") if isinstance(handoff.get("detector_results_summary"), dict) else {} + ), + "issue_codes": dcl.normalize_string_list(handoff.get("issue_codes")), + "issue_catalog_contracts": ( + handoff.get("issue_catalog_contracts") if isinstance(handoff.get("issue_catalog_contracts"), dict) else {} + ), + "rerun_matrix": dcl.normalize_string_list(handoff.get("rerun_matrix")), + "auto_propose_gate": ( + handoff.get("auto_propose_gate") if isinstance(handoff.get("auto_propose_gate"), dict) else {} + ), "primary_repair_targets": enriched_targets, "candidate_files": candidate_files, "sample_findings": [finding for finding in sample_findings if isinstance(finding, dict)][:8], @@ -1480,6 +1707,7 @@ def build_stage_repair_iteration_plan( "ingest the new assistant-stage1 run id with stage_agent_loop.py ingest-gui-run", "accept only when P0 is zero and P1 noise is either cleared or explicitly bounded", ], + "rerun_matrix": dcl.normalize_string_list(handoff.get("rerun_matrix")), "ingest_command_template": ( "python scripts/stage_agent_loop.py ingest-gui-run " "--manifest --run-id assistant-stage1-" diff --git a/scripts/test_stage_agent_loop.py b/scripts/test_stage_agent_loop.py index aa99944..54733c2 100644 --- a/scripts/test_stage_agent_loop.py +++ b/scripts/test_stage_agent_loop.py @@ -519,6 +519,110 @@ class StageAgentLoopTests(unittest.TestCase): self.assertEqual(handoff["primary_repair_targets"][0]["issue_code"], "business_direct_answer_missing") self.assertEqual(handoff["sample_findings"][0]["turn_index"], 19) + def test_stage_repair_handoff_enriches_gui_targets_with_catalog_contract(self) -> None: + summary = { + "stage_id": "agent_loop", + "next_action": "continue_repair_from_gui_review_p0", + "latest_gui_review": { + "run_id": "assistant-stage1-test", + "overall_business_status": "fail", + "p0_findings": 1, + "p1_findings": 0, + "question_quality_score": 100, + "review_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-test/run_review.json", + "review_markdown": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-test/run_review.md", + "repair_targets_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-test/repair_targets.json", + "conversation_pairs_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-test/conversation_pairs.json", + "detector_results_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-test/detector_results.json", + "detector_results_status": "fail", + "detector_results_summary": { + "status": "fail", + "detector_count": 1, + "pass": 0, + "fail": 1, + "review": 0, + "skipped": 0, + }, + }, + } + review = { + "run_id": "assistant-stage1-test", + "repair_targets": [ + { + "problem_layer": "answer_shape_mismatch", + "issue_code": "business_direct_answer_missing", + "severity": "P0", + "occurrences": 1, + } + ], + "findings": [ + { + "turn_index": 19, + "severity": "P0", + "issue_codes": ["business_direct_answer_missing"], + "question": "какой у нас самый доходный год", + "assistant_first_line": "Коротко: ограниченный бизнес-обзор...", + } + ], + } + + handoff = stage_loop.build_stage_repair_handoff(summary, review) + target = handoff["primary_repair_targets"][0] + + self.assertIn("business_direct_answer_missing", handoff["issue_catalog_contracts"]) + self.assertIn("direct_answer_surface_pack", handoff["rerun_matrix"]) + self.assertIn("llm_normalizer/backend/src/services/assistantService.ts", target["allowed_patch_targets"]) + self.assertIn("llm_normalizer/backend/src/services/address_runtime/composeStage.ts", target["candidate_files"]) + self.assertTrue(handoff["auto_propose_gate"]["allowed"]) + self.assertEqual(handoff["auto_propose_gate"]["detector_results_summary"]["status"], "fail") + + def test_stage_repair_handoff_blocks_auto_propose_for_non_allowlisted_gui_issue(self) -> None: + summary = { + "stage_id": "agent_loop", + "next_action": "continue_repair_from_gui_review_p0", + "latest_gui_review": { + "run_id": "assistant-stage1-failing", + "overall_business_status": "fail", + "p0_findings": 1, + "p1_findings": 1, + "question_quality_score": 98, + "review_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-failing/run_review.json", + "review_markdown": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-failing/run_review.md", + "repair_targets_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-failing/repair_targets.json", + "detector_results_json": "artifacts/domain_runs/gui_run_reviews/assistant-stage1-failing/detector_results.json", + "detector_results_status": "fail", + "detector_results_summary": { + "status": "fail", + "detector_count": 1, + "pass": 0, + "fail": 1, + "review": 0, + "skipped": 0, + }, + }, + } + review = { + "run_id": "assistant-stage1-failing", + "repair_targets": [ + { + "problem_layer": "runtime_error", + "issue_code": "backend_error_response", + "severity": "P0", + "occurrences": 1, + } + ], + "findings": [], + } + + handoff = stage_loop.build_stage_repair_handoff(summary, review) + + self.assertFalse(handoff["auto_propose_gate"]["allowed"]) + self.assertIn( + "issue_code_not_allowlisted:backend_error_response", + handoff["auto_propose_gate"]["blocking_reasons"], + ) + self.assertIn("failed_gui_turn", handoff["rerun_matrix"]) + def test_repair_iteration_plan_adds_candidate_files(self) -> None: with tempfile.TemporaryDirectory() as tmp: stage_dir = Path(tmp) / "stage" @@ -531,6 +635,12 @@ class StageAgentLoopTests(unittest.TestCase): "question_quality_score": 100, "review_markdown": "review.md", "repair_targets_json": "repair_targets.json", + "detector_results_json": "detector_results.json", + "detector_results_status": "fail", + "detector_results_summary": {"status": "fail", "detector_count": 1, "fail": 1}, + "issue_codes": ["business_direct_answer_missing"], + "rerun_matrix": ["failed_scenario", "direct_answer_surface_pack", "accepted_smoke_pack"], + "auto_propose_gate": {"allowed": True, "reason": "auto_coder_gate_passed"}, "primary_repair_targets": [ { "problem_layer": "answer_shape_mismatch", @@ -564,6 +674,10 @@ class StageAgentLoopTests(unittest.TestCase): self.assertEqual(plan["iteration_id"], "repair_001") self.assertIn("llm_normalizer/backend/src/services/address_runtime/composeStage.ts", plan["candidate_files"]) self.assertEqual(plan["primary_repair_targets"][0]["candidate_files"][0], "llm_normalizer/backend/src/services/address_runtime/composeStage.ts") + self.assertEqual(plan["detector_results_status"], "fail") + self.assertIn("business_direct_answer_missing", plan["issue_codes"]) + self.assertIn("direct_answer_surface_pack", plan["rerun_matrix"]) + self.assertTrue(plan["auto_propose_gate"]["allowed"]) self.assertIn("ingest-gui-run", plan["acceptance_rerun"]["ingest_command_template"]) def test_handle_prepare_repair_materializes_prompt_and_checklist(self) -> None: