ОРРКЕСТРАЦИЯ - Оркестрация домена: ужесточить автофикс loop и назначать primary repair focus

This commit is contained in:
2026-04-15 08:09:42 +03:00
parent 5934f5f3fc
commit bc381c012e
16 changed files with 956 additions and 38 deletions
+238 -5
View File
@@ -61,6 +61,19 @@ DEFAULT_INVARIANT_SEVERITY: dict[str, str] = {
}
REPAIR_TARGET_SEVERITY_ORDER = {"P0": 0, "P1": 1, "P2": 2}
REPAIR_TARGET_PROBLEM_ORDER = {
"temporal_honesty_gap": 0,
"edge_carryover_gap": 1,
"followup_action_resolution_gap": 2,
"object_memory_gap": 3,
"route_gap": 4,
"answer_shape_mismatch": 5,
"presentation_gap": 6,
"domain_anchor_gap": 7,
"capability_gap": 8,
"evidence_gap": 9,
"other": 10,
}
REPAIR_TARGET_FILE_HINTS: dict[str, list[str]] = {
"followup_action_resolution_gap": [
@@ -132,6 +145,65 @@ def read_text_file(file_path: Path) -> str:
return file_path.read_text(encoding="utf-8-sig")
def resolve_repo_relative_path(raw_path: str | None) -> Path | None:
candidate = str(raw_path or "").strip().replace("\\", "/")
if not candidate:
return None
path = Path(candidate)
if path.is_absolute() or any(part == ".." for part in path.parts):
return None
return REPO_ROOT / path
def build_coder_snapshot_paths(repair_targets: dict[str, Any]) -> list[Path]:
collected: list[Path] = []
seen: set[Path] = set()
groups = []
if isinstance(repair_targets, dict):
groups.extend(repair_targets.get("priority_foci") or [])
groups.extend(repair_targets.get("targets") or [])
for item in groups:
if not isinstance(item, dict):
continue
for raw_path in normalize_string_list(item.get("candidate_files")):
resolved = resolve_repo_relative_path(raw_path)
if resolved is None or not resolved.exists() or resolved in seen:
continue
seen.add(resolved)
collected.append(resolved)
return collected
def snapshot_coder_candidate_files(paths: list[Path]) -> dict[str, bytes]:
snapshots: dict[str, bytes] = {}
for path in paths:
if not path.exists() or not path.is_file():
continue
snapshots[str(path)] = path.read_bytes()
return snapshots
def restore_line_collapsed_files_from_snapshot(snapshots: dict[str, bytes]) -> list[str]:
restored: list[str] = []
for raw_path, before_bytes in snapshots.items():
path = Path(raw_path)
if not path.exists() or not path.is_file():
continue
after_bytes = path.read_bytes()
before_line_count = before_bytes.count(b"\n")
after_line_count = after_bytes.count(b"\n")
if before_line_count < 1 or after_line_count != 0:
continue
if before_bytes.replace(b"\r", b"").replace(b"\n", b"") != after_bytes.replace(b"\r", b"").replace(b"\n", b""):
continue
path.write_bytes(before_bytes)
try:
restored.append(str(path.relative_to(REPO_ROOT)).replace("\\", "/"))
except ValueError:
restored.append(str(path))
return restored
def sanitize_export_text(value: str) -> str:
raw = str(value or "")
debug_heading = re.search(
@@ -1551,6 +1623,7 @@ def build_scenario_step_state(
step: dict[str, Any],
step_index: int,
question_resolved: str,
analysis_context: dict[str, Any],
turn_artifact: dict[str, Any],
entries: list[dict[str, Any]],
) -> dict[str, Any]:
@@ -1570,7 +1643,12 @@ def build_scenario_step_state(
reply_type = assistant_item.get("reply_type")
assistant_text = str(assistant_item.get("text") or "")
top_non_empty = first_non_empty_lines(assistant_text, limit=3)
analysis_context = step.get("analysis_context") if isinstance(step.get("analysis_context"), dict) else {}
turn_scenario = turn_artifact.get("scenario") if isinstance(turn_artifact.get("scenario"), dict) else {}
effective_analysis_context = normalize_analysis_context(turn_scenario.get("analysis_context"))
if not effective_analysis_context:
effective_analysis_context = normalize_analysis_context(analysis_context)
if not effective_analysis_context:
effective_analysis_context = step.get("analysis_context") if isinstance(step.get("analysis_context"), dict) else {}
step_state = {
"schema_version": SCENARIO_STEP_STATE_SCHEMA_VERSION,
@@ -1582,7 +1660,7 @@ def build_scenario_step_state(
"depends_on": step["depends_on"],
"question_template": step["question_template"],
"question_resolved": question_resolved,
"analysis_context": analysis_context,
"analysis_context": effective_analysis_context,
"expected_intents": step.get("expected_intents") or [],
"expected_capability": step.get("expected_capability"),
"expected_recipe": step.get("expected_recipe"),
@@ -1789,6 +1867,7 @@ def run_assistant_step(
step=step,
step_index=step_index,
question_resolved=question_resolved,
analysis_context=analysis_context,
turn_artifact=turn_artifact,
entries=entries,
)
@@ -2764,6 +2843,63 @@ def build_step_repair_target(
}
def build_repair_focus_signature(target: dict[str, Any]) -> str:
problem_type = str(target.get("problem_type") or "other").strip() or "other"
candidate_files = normalize_string_list(target.get("candidate_files"))
primary_file = candidate_files[0] if candidate_files else "no_file_hint"
return f"{problem_type}|{primary_file}"
def build_priority_repair_foci(targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
grouped: dict[str, dict[str, Any]] = {}
for target in targets:
focus_id = build_repair_focus_signature(target)
focus = grouped.setdefault(
focus_id,
{
"focus_id": focus_id,
"severity": str(target.get("severity") or "P2"),
"problem_type": str(target.get("problem_type") or "other"),
"root_cause_layers": normalize_string_list(target.get("root_cause_layers")),
"candidate_files": normalize_string_list(target.get("candidate_files")),
"target_ids": [],
"scenario_ids": set(),
},
)
focus["target_ids"].append(str(target.get("target_id") or ""))
scenario_id = str(target.get("scenario_id") or "").strip()
if scenario_id:
focus["scenario_ids"].add(scenario_id)
priority_foci: list[dict[str, Any]] = []
for focus in grouped.values():
scenario_ids = sorted(focus.pop("scenario_ids"))
target_ids = [target_id for target_id in focus.get("target_ids", []) if target_id]
focus["target_count"] = len(target_ids)
focus["scenario_count"] = len(scenario_ids)
focus["target_ids"] = target_ids
focus["scenario_ids"] = scenario_ids
priority_foci.append(focus)
priority_foci.sort(
key=lambda item: (
REPAIR_TARGET_SEVERITY_ORDER.get(str(item.get("severity") or "P2"), 99),
-int(item.get("target_count") or 0),
-int(item.get("scenario_count") or 0),
REPAIR_TARGET_PROBLEM_ORDER.get(str(item.get("problem_type") or "other"), 99),
str(item.get("focus_id") or ""),
)
)
for index, focus in enumerate(priority_foci, start=1):
primary_file = normalize_string_list(focus.get("candidate_files"))[:1]
focus["focus_rank"] = index
focus["rank_reason"] = (
f"severity={focus.get('severity')} targets={focus.get('target_count')} "
f"scenarios={focus.get('scenario_count')} primary_file={primary_file[0] if primary_file else 'n/a'}"
)
return priority_foci
def build_deterministic_repair_targets(
pack_state: dict[str, Any],
scenario_artifacts: list[dict[str, Any]],
@@ -2792,9 +2928,36 @@ def build_deterministic_repair_targets(
if target:
targets.append(target)
priority_foci = build_priority_repair_foci(targets)
focus_rank_by_id = {
str(focus.get("focus_id") or ""): int(focus.get("focus_rank") or 999)
for focus in priority_foci
if isinstance(focus, dict)
}
focus_target_count_by_id = {
str(focus.get("focus_id") or ""): int(focus.get("target_count") or 0)
for focus in priority_foci
if isinstance(focus, dict)
}
focus_scenario_count_by_id = {
str(focus.get("focus_id") or ""): int(focus.get("scenario_count") or 0)
for focus in priority_foci
if isinstance(focus, dict)
}
for target in targets:
focus_id = build_repair_focus_signature(target)
target["repair_focus_id"] = focus_id
target["repair_focus_rank"] = focus_rank_by_id.get(focus_id, 999)
target["repair_focus_target_count"] = focus_target_count_by_id.get(focus_id, 0)
target["repair_focus_scenario_count"] = focus_scenario_count_by_id.get(focus_id, 0)
targets.sort(
key=lambda item: (
REPAIR_TARGET_SEVERITY_ORDER.get(str(item.get("severity") or "P2"), 99),
int(item.get("repair_focus_rank") or 999),
-int(item.get("repair_focus_target_count") or 0),
-int(item.get("repair_focus_scenario_count") or 0),
REPAIR_TARGET_PROBLEM_ORDER.get(str(item.get("problem_type") or "other"), 99),
str(item.get("scenario_id") or ""),
str(item.get("step_id") or ""),
)
@@ -2811,10 +2974,21 @@ def build_deterministic_repair_targets(
"final_status": pack_state.get("final_status"),
"target_count": len(targets),
"severity_counts": severity_counts,
"priority_foci": priority_foci,
"targets": targets,
}
def select_primary_repair_focus(repair_targets: dict[str, Any]) -> dict[str, Any] | None:
if not isinstance(repair_targets, dict):
return None
priority_foci = repair_targets.get("priority_foci")
if not isinstance(priority_foci, list) or not priority_foci:
return None
primary_focus = priority_foci[0]
return primary_focus if isinstance(primary_focus, dict) else None
def build_repair_targets_summary(repair_targets: dict[str, Any]) -> str:
lines = [
"# Repair targets",
@@ -2823,9 +2997,36 @@ def build_repair_targets_summary(repair_targets: dict[str, Any]) -> str:
f"- domain: `{repair_targets.get('domain') or 'n/a'}`",
f"- target_count: `{repair_targets.get('target_count') or 0}`",
f"- severity_counts: `{dump_json(repair_targets.get('severity_counts') or {})}`",
"",
"## Targets",
]
priority_foci = repair_targets.get("priority_foci") or []
if isinstance(priority_foci, list) and priority_foci:
lines.extend(
[
"",
"## Priority foci",
]
)
for focus in priority_foci:
if not isinstance(focus, dict):
continue
lines.extend(
[
f"- `{focus.get('focus_id')}`",
f" focus_rank: `{focus.get('focus_rank')}`",
f" severity: `{focus.get('severity')}`",
f" problem_type: `{focus.get('problem_type')}`",
f" target_count: `{focus.get('target_count')}`",
f" scenario_count: `{focus.get('scenario_count')}`",
f" candidate_files: {', '.join(focus.get('candidate_files') or []) or 'none'}",
f" rank_reason: {focus.get('rank_reason') or 'n/a'}",
]
)
lines.extend(
[
"",
"## Targets",
]
)
for target in repair_targets.get("targets") or []:
if not isinstance(target, dict):
continue
@@ -2834,6 +3035,8 @@ def build_repair_targets_summary(repair_targets: dict[str, Any]) -> str:
f"- `{target.get('target_id')}`",
f" severity: `{target.get('severity')}`",
f" problem_type: `{target.get('problem_type')}`",
f" repair_focus_rank: `{target.get('repair_focus_rank')}`",
f" repair_focus_target_count: `{target.get('repair_focus_target_count')}`",
f" root_cause_layers: {', '.join(target.get('root_cause_layers') or []) or 'none'}",
f" fix_goal: {target.get('fix_goal') or 'n/a'}",
f" candidate_files: {', '.join(target.get('candidate_files') or []) or 'none'}",
@@ -2989,6 +3192,7 @@ def build_analyst_loop_prompt(
- `accepted` is allowed only if quality_score >= {target_score}, unresolved_p0_count = 0, and regression_detected = false;
- `accepted` is forbidden if the evidence bundle shows `pack_state.final_status != accepted` or the deterministic repair targets still contain any `P0` or `P1` items;
- `accepted` also requires `direct_answer_ok = true`, `business_usefulness_ok = true`, `temporal_honesty_ok = true`, and `field_truth_ok = true`;
- when several failing steps share one deterministic repair focus, call out the highest-leverage shared focus first instead of only the lexicographically first failing step;
- `partial` means the pack is usable but exactness, routing, or coverage is still insufficient;
- `needs_exact_capability` means the primary blocker is a missing exact route or capability, but the loop should still continue autonomously unless a user decision is required;
- `continue` means there is a clear next patch cycle;
@@ -3035,9 +3239,22 @@ def build_coder_loop_prompt(
pack_dir: Path,
repair_targets_path: Path,
repair_targets_json: str,
assigned_focus: dict[str, Any] | None,
analyst_verdict_path: Path,
analyst_verdict_json: str,
) -> str:
assigned_focus_block = (
textwrap.dedent(
f"""\
Assigned deterministic repair focus for this iteration:
```json
{dump_json(assigned_focus)}
```
"""
).strip()
if assigned_focus
else "Assigned deterministic repair focus for this iteration: none"
)
return textwrap.dedent(
f"""\
You are the `domain_coder` for NDC_1C.
@@ -3061,8 +3278,11 @@ def build_coder_loop_prompt(
- do not present heuristic answers as confirmed;
- do not touch unrelated files;
- preserve already successful baseline flows.
- preserve UTF-8 without BOM and the existing line structure of edited files; do not leave whole-file normalization-only rewrites or single-line collapses in the worktree;
- use minimal local edits; if a tool rewrites a file into normalization noise, restore the original file first and then apply only the intended semantic patch;
- use `root_cause_layers`, `broken_edge_ids`, `violated_invariants`, and business-utility scores from the analyst verdict to choose the smallest fix;
- use the deterministic repair targets to choose the narrowest failing edge before touching broader scenarios;
- use the deterministic repair targets to choose the highest-leverage repair focus first; within that focus, patch the narrowest shared layer that can clear the most `P0`/`P1` targets without architecture drift;
- the assigned deterministic repair focus below is mandatory for this iteration; do not switch to a lower-priority focus unless you are blocked from making a safe patch for the assigned focus;
- if the analyst verdict is optimistic but deterministic repair targets still contain `P0` or `P1`, trust the deterministic repair targets and keep fixing the pack;
- prioritize state continuity, selected-object persistence, stable `focus_object`, stable `answer_object`, reusable `provenance_bundle` / `sale_trace_bundle`, action-first answer behavior, compact micro-action answers, answer layering, temporal honesty, and field-truth mapping when those are the blocking layers;
- do not broaden scope when the analyst says the defect is mainly `object_memory_gap`, `followup_action_resolution_gap`, `bundle_reuse_gap`, `field_mapping_gap`, `temporal_honesty_gap`, `answer_shape_mismatch`, or `business_utility_gap`;
@@ -3082,6 +3302,8 @@ def build_coder_loop_prompt(
{repair_targets_json}
```
{assigned_focus_block}
- then return JSON only and follow the schema exactly.
"""
).strip()
@@ -3218,6 +3440,8 @@ def build_loop_summary(loop_state: dict[str, Any]) -> str:
f" requires_user_decision: `{item.get('requires_user_decision')}`",
f" user_decision_type: `{item.get('user_decision_type') or 'none'}`",
f" coder_status: `{item.get('coder_status') or 'n/a'}`",
f" assigned_repair_focus_id: `{item.get('assigned_repair_focus_id') or 'none'}`",
f" coder_workspace_hygiene_restored_files: `{', '.join(item.get('coder_workspace_hygiene_restored_files') or []) or 'none'}`",
f" analyst_verdict: `{item.get('analyst_verdict_path') or 'n/a'}`",
f" repair_targets: `{item.get('repair_targets_path') or 'n/a'}`",
f" repair_target_count: `{item.get('repair_target_count')}`",
@@ -3396,16 +3620,20 @@ def handle_run_pack_loop(args: argparse.Namespace) -> int:
break
coder_result_path = iteration_dir / "coder_result.json"
assigned_focus = select_primary_repair_focus(repair_targets)
coder_prompt = build_coder_loop_prompt(
loop_dir=loop_dir,
iteration_dir=iteration_dir,
pack_dir=pack_dir,
repair_targets_path=repair_targets_path,
repair_targets_json=repair_targets_json,
assigned_focus=assigned_focus,
analyst_verdict_path=analyst_verdict_path,
analyst_verdict_json=dump_json(analyst_verdict),
)
write_text(iteration_dir / "coder_prompt.md", coder_prompt + "\n")
coder_snapshot_paths = build_coder_snapshot_paths(repair_targets)
coder_snapshots = snapshot_coder_candidate_files(coder_snapshot_paths)
coder_command = build_codex_exec_command(
args,
output_file=coder_result_path,
@@ -3422,10 +3650,15 @@ def handle_run_pack_loop(args: argparse.Namespace) -> int:
stdout_path=iteration_dir / "coder_exec.stdout.log",
stderr_path=iteration_dir / "coder_exec.stderr.log",
)
restored_files = restore_line_collapsed_files_from_snapshot(coder_snapshots)
coder_result = read_json_output(coder_result_path)
coder_status = str(coder_result.get("status") or "").strip() or "unknown"
iteration_record["coder_status"] = coder_status
iteration_record["coder_result_path"] = str(coder_result_path)
if assigned_focus:
iteration_record["assigned_repair_focus_id"] = str(assigned_focus.get("focus_id") or "")
if restored_files:
iteration_record["coder_workspace_hygiene_restored_files"] = restored_files
loop_state["iterations"].append(iteration_record)
loop_state["updated_at"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
write_json(loop_dir / "loop_state.json", loop_state)