Провязать detector results в stage repair handoff

This commit is contained in:
dctouch 2026-06-03 10:55:26 +03:00
parent d24f8476b4
commit 92e075157f
3 changed files with 145 additions and 2 deletions

View File

@ -171,7 +171,7 @@ The status payload also exposes `effective_stage_status`, `effective_stage_statu
Use `python scripts/stage_agent_loop.py continue --manifest docs/orchestration/<stage_loop>.json` as the safe one-command continuation layer. From a cold start it materializes `domain_pack_loop.command.txt` without launching the long live loop; after a GUI review it can prepare a repair iteration and materialize `run-repair --dry-run` automatically; it will not run the real coder pass unless `--execute-repair` is passed, and it waits for a `--run-id assistant-stage1-<id>` when the next required step is post-repair rerun/ingest validation.
It also writes `stage_repair_handoff.md/json` next to the stage summary. That handoff is the preferred input for the next coder pass: it lists primary repair targets and sample user-facing failures without forcing the coder to reread the entire GUI conversation first.
It also writes `stage_repair_handoff.md/json` next to the stage summary. That handoff is the preferred input for the next coder pass: it lists primary repair targets, detector result signals, and sample user-facing failures without forcing the coder to reread the entire GUI conversation first.
For live stage-pack failures, prefer `lead_coder_handoff.md` over immediately preparing a coder pass. The intent is: strong business audit first, Lead Codex code repair second, same replay/GUI validation third.

View File

@ -1354,6 +1354,52 @@ def load_gui_review_detector_gate(review_dir: Path) -> dict[str, Any]:
}
def load_detector_results_for_stage_handoff(latest_gui_review: dict[str, Any]) -> dict[str, Any]:
raw_path = str(latest_gui_review.get("detector_results_json") or "").strip()
if not raw_path:
return {}
path = repo_path(raw_path)
if not path.exists():
return {}
try:
return load_json_object(path, "Stage GUI detector_results.json")
except (OSError, json.JSONDecodeError, RuntimeError):
return {}
def build_detector_result_signals(
detector_results: dict[str, Any],
*,
issue_codes: list[str],
limit: int = 8,
) -> list[dict[str, Any]]:
results = detector_results.get("results") if isinstance(detector_results.get("results"), list) else []
wanted_issue_codes = set(issue_codes)
signals: list[dict[str, Any]] = []
for item in results:
if not isinstance(item, dict):
continue
status = str(item.get("status") or "").strip()
if status not in {"fail", "review"}:
continue
item_issue_codes = dcl.normalize_string_list(item.get("issue_codes"))
if wanted_issue_codes and not wanted_issue_codes.intersection(item_issue_codes):
continue
evidence = item.get("evidence") if isinstance(item.get("evidence"), list) else []
signals.append(
{
"detector": item.get("detector"),
"status": status,
"issue_codes": item_issue_codes,
"message": item.get("message"),
"evidence_preview": evidence[:3],
}
)
if len(signals) >= limit:
break
return signals
def unique_strings(values: list[str]) -> list[str]:
result: list[str] = []
for value in values:
@ -1682,7 +1728,10 @@ def build_stage_repair_handoff(summary: dict[str, Any], review: dict[str, Any])
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": []}
detector_results = load_detector_results_for_stage_handoff(latest)
if detector_results:
detector_results_summary = dcl.summarize_detector_results(detector_results)
detector_results_for_gate = detector_results if detector_results else {"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")
@ -1714,6 +1763,10 @@ def build_stage_repair_handoff(summary: dict[str, Any], review: dict[str, Any])
"candidate_files": repair_contract.get("candidate_files"),
"repair_contract": repair_contract,
"auto_propose_gate": auto_propose_gate,
"detector_result_signals": build_detector_result_signals(
detector_results_for_gate,
issue_codes=dcl.normalize_string_list(repair_contract.get("issue_codes")),
),
"primary_repair_targets": primary_targets,
"sample_findings": sample_findings,
}
@ -1748,6 +1801,22 @@ def build_stage_repair_handoff_markdown(handoff: dict[str, Any]) -> str:
"",
]
)
detector_signals = (
handoff.get("detector_result_signals") if isinstance(handoff.get("detector_result_signals"), list) else []
)
if detector_signals:
lines.extend(["## Detector Result Signals"])
for signal in detector_signals:
if not isinstance(signal, dict):
continue
lines.extend(
[
f"- `{signal.get('status')}` `{signal.get('detector')}` "
f"`{', '.join(dcl.normalize_string_list(signal.get('issue_codes'))) or 'n/a'}`",
f" message: {str(signal.get('message') or '').strip()}",
]
)
lines.append("")
auto_gate = handoff.get("auto_propose_gate") if isinstance(handoff.get("auto_propose_gate"), dict) else {}
if auto_gate:
lines.extend(

View File

@ -761,6 +761,80 @@ class StageAgentLoopTests(unittest.TestCase):
self.assertTrue(handoff["auto_propose_gate"]["allowed"])
self.assertEqual(handoff["auto_propose_gate"]["detector_results_summary"]["status"], "fail")
def test_stage_repair_handoff_loads_detector_result_signals_from_json(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
detector_results_path = Path(tmp) / "detector_results.json"
write_json(
detector_results_path,
{
"schema_version": "agent_detector_results_v1",
"created_at": "2026-06-03T00:00:00+00:00",
"artifact_dir": str(Path(tmp)),
"registry_path": "docs/orchestration/detector_registry.json",
"selected_detectors": ["business_direct_answer_missing_signal"],
"artifact_counts": {},
"summary": {
"status": "fail",
"detector_count": 1,
"pass": 0,
"fail": 1,
"review": 0,
"skipped": 0,
},
"results": [
{
"detector": "business_direct_answer_missing_signal",
"kind": "semantic",
"automation_level": "deterministic",
"status": "fail",
"issue_codes": ["business_direct_answer_missing"],
"message": "Direct answer is missing on the first line.",
"evidence": [{"turn_index": 19, "first_line": "Коротко: общий обзор"}],
}
],
},
)
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": str(detector_results_path),
"detector_results_status": "fail",
"detector_results_summary": {"status": "fail", "detector_count": 1, "fail": 1},
},
}
review = {
"run_id": "assistant-stage1-test",
"repair_targets": [
{
"problem_layer": "answer_shape_mismatch",
"issue_code": "business_direct_answer_missing",
"severity": "P0",
"occurrences": 1,
}
],
"findings": [],
}
handoff = stage_loop.build_stage_repair_handoff(summary, review)
self.assertTrue(handoff["auto_propose_gate"]["allowed"])
self.assertEqual(
handoff["auto_propose_gate"]["detector_results_summary"]["failed_detectors"],
["business_direct_answer_missing_signal"],
)
self.assertEqual(handoff["detector_result_signals"][0]["detector"], "business_direct_answer_missing_signal")
self.assertIn("Direct answer is missing", handoff["detector_result_signals"][0]["message"])
def test_stage_repair_handoff_blocks_auto_propose_for_non_allowlisted_gui_issue(self) -> None:
summary = {
"stage_id": "agent_loop",