Files
2026-04-06 11:49:28 +02:00

582 lines
22 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
import json
import re
import tempfile
from pathlib import Path
from typing import Any, Iterable
from core.project.loader import ProjectState
from core.reporting import iter_run_payloads, safe_stamp
AUTO_SYNC_TODO_ACTIVE = "ANE-TODO-ACTIVE"
AUTO_SYNC_TODO_DONE = "ANE-TODO-DONE"
AUTO_SYNC_PLAN = "ANE-PLAN"
AUTO_SYNC_COMPARISON = "ANE-COMPARISON"
AUTO_SYNC_README = "ANE-README"
AUTO_SYNC_RUNBOOK = "ANE-RUNBOOK"
AUTO_SYNC_MASCARADE_TODO = "MASCARADE-TODO"
AUTO_SYNC_MASCARADE_PLAN = "MASCARADE-PLAN"
AUTO_SYNC_MASCARADE_README = "MASCARADE-README"
AUTO_SYNC_MASCARADE_RUNBOOK = "MASCARADE-RUNBOOK"
@dataclass(frozen=True)
class TrackingPaths:
ane_todo_active: Path
ane_todo_done: Path
ane_plan: Path
ane_comparison: Path
ane_readme: Path
ane_runbook: Path
mascarade_repo: Path
mascarade_todo: Path
mascarade_plan: Path
mascarade_readme: Path
mascarade_runbook: Path
@dataclass(frozen=True)
class TrackingSyncContext:
repo_root: Path
next_code_lot: str
ane_todo_active: Path
ane_todo_done: Path
ane_plan: Path
ane_comparison: Path
ane_readme: Path
ane_runbook: Path
mascarade_todo: Path
mascarade_plan: Path
mascarade_readme: Path
mascarade_runbook: Path
def build_tracking_sync_context(
repo_root: Path,
*,
next_code_lot: str,
tracking: TrackingPaths,
) -> TrackingSyncContext:
return TrackingSyncContext(
repo_root=repo_root,
next_code_lot=next_code_lot,
ane_todo_active=tracking.ane_todo_active,
ane_todo_done=tracking.ane_todo_done,
ane_plan=tracking.ane_plan,
ane_comparison=tracking.ane_comparison,
ane_readme=tracking.ane_readme,
ane_runbook=tracking.ane_runbook,
mascarade_todo=tracking.mascarade_todo,
mascarade_plan=tracking.mascarade_plan,
mascarade_readme=tracking.mascarade_readme,
mascarade_runbook=tracking.mascarade_runbook,
)
@dataclass(frozen=True)
class TrackingResult:
model: str
category: str
classification: str = "pending"
preflight_ok: bool | None = None
smoke_attempted: bool = False
status: str | None = None
accepted: bool = False
failed_stage: str | None = None
quality_blockers: list[str] = field(default_factory=list)
retry_stages: list[str] = field(default_factory=list)
repair_attempts: int = 0
notes: list[str] = field(default_factory=list)
completed_stages: list[str] = field(default_factory=list)
repair_models: list[str] = field(default_factory=list)
def reached_gate(self) -> bool:
return "gate" in self.completed_stages or self.failed_stage == "gate"
def _auto_markers(name: str) -> tuple[str, str]:
return (
f"<!-- AUTO-SYNC:{name}:START -->",
f"<!-- AUTO-SYNC:{name}:END -->",
)
def replace_auto_section(path: Path, marker_name: str, heading: str, body: str) -> None:
start_marker, end_marker = _auto_markers(marker_name)
text = path.read_text(encoding="utf-8") if path.exists() else ""
section = f"{heading}\n{start_marker}\n{body.rstrip()}\n{end_marker}\n"
if start_marker in text and end_marker in text:
start = text.index(start_marker)
end = text.index(end_marker) + len(end_marker)
replacement_start = text.rfind("\n", 0, start)
if replacement_start == -1:
replacement_start = 0
else:
replacement_start += 1
new_text = f"{text[:replacement_start]}{section}{text[end:].lstrip()}"
else:
suffix = "\n" if text.endswith("\n") else "\n\n"
new_text = f"{text}{suffix}{section}"
repeated_heading_pattern = rf"(?:{re.escape(heading)}\n){{2,}}"
new_text = re.sub(repeated_heading_pattern, f"{heading}\n", new_text)
path.write_text(new_text, encoding="utf-8")
def sync_tracking(context: TrackingSyncContext, state: Any, *, dry_run: bool, project_state: dict[str, Any] | None = None) -> None:
if dry_run:
write_report_summary(state)
return
typed_results = _consolidated_tracking_results(state, context.repo_root / "automation" / "reports")
accepted_counts = _accepted_history_counts(state, context.repo_root / "automation" / "reports")
project_state = project_state or ProjectState(context.repo_root).summary()
summary = _build_summary(state, typed_results)
comparison = _render_comparison_markdown(state, typed_results)
active_next = _compute_next_lot_recommendation(
typed_results,
context.next_code_lot,
accepted_counts=accepted_counts,
)
replace_auto_section(
context.ane_todo_active,
AUTO_SYNC_TODO_ACTIVE,
"## Auto-sync",
_render_todo_active_sync(summary, active_next),
)
replace_auto_section(
context.ane_todo_done,
AUTO_SYNC_TODO_DONE,
"## Auto-sync",
_render_todo_done_sync(summary),
)
replace_auto_section(
context.ane_plan,
AUTO_SYNC_PLAN,
"## Auto-sync",
_render_plan_sync(summary, active_next),
)
replace_auto_section(
context.ane_comparison,
AUTO_SYNC_COMPARISON,
"## Auto-sync",
comparison,
)
replace_auto_section(
context.ane_readme,
AUTO_SYNC_README,
"## Etat auto-synchronise",
_render_readme_sync(summary, active_next),
)
replace_auto_section(
context.ane_runbook,
AUTO_SYNC_RUNBOOK,
"## Etat auto-synchronise",
_render_runbook_sync(summary, project_state, active_next),
)
replace_auto_section(
context.mascarade_todo,
AUTO_SYNC_MASCARADE_TODO,
"## Auto-sync",
_render_mascarade_todo_sync(summary, active_next),
)
replace_auto_section(
context.mascarade_plan,
AUTO_SYNC_MASCARADE_PLAN,
"## Auto-sync",
_render_mascarade_plan_sync(summary, active_next),
)
replace_auto_section(
context.mascarade_readme,
AUTO_SYNC_MASCARADE_README,
"## Etat auto-synchronise",
_render_mascarade_readme_sync(summary, active_next),
)
replace_auto_section(
context.mascarade_runbook,
AUTO_SYNC_MASCARADE_RUNBOOK,
"## Etat auto-synchronise",
_render_mascarade_runbook_sync(summary, active_next),
)
write_report_summary(state)
def write_report_summary(state: Any) -> None:
report_dir = Path(getattr(state, "report_dir"))
report_dir.mkdir(parents=True, exist_ok=True)
run_path = report_dir / "run.json"
summary_path = report_dir / "SUMMARY.md"
state_payload = dict(vars(state))
run_path.write_text(json.dumps(state_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
typed_results = list(_state_typed_results(state))
summary_path.write_text(_render_summary_markdown(state, typed_results), encoding="utf-8")
def _safe_timestamp(value: str) -> datetime:
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return datetime.fromtimestamp(0, tz=timezone.utc)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _load_report_history(reports_root: Path) -> list[dict[str, Any]]:
history: list[dict[str, Any]] = []
if not reports_root.exists():
return history
for run_path, payload in iter_run_payloads(reports_root):
payload = dict(payload)
payload.setdefault("report_dir", str(run_path.parent))
history.append(payload)
history.sort(key=lambda item: (_safe_timestamp(str(item.get("updated_at", ""))), str(item.get("report_dir", ""))))
return history
def _string_list(value: object) -> list[str]:
if not isinstance(value, list):
return []
return [str(item).strip() for item in value if str(item).strip()]
def _optional_string(value: object) -> str | None:
text = str(value).strip() if value is not None else ""
return text or None
def _tracking_result_from_payload(payload: Any) -> TrackingResult | None:
if isinstance(payload, TrackingResult):
return payload
if not isinstance(payload, dict):
payload = {field: getattr(payload, field, None) for field in (
"model",
"category",
"classification",
"preflight_ok",
"smoke_attempted",
"status",
"accepted",
"failed_stage",
"quality_blockers",
"retry_stages",
"repair_attempts",
"notes",
"completed_stages",
"repair_models",
)}
model = str(payload.get("model", "")).strip()
category = str(payload.get("category", "")).strip()
if not model or not category:
return None
return TrackingResult(
model=model,
category=category,
classification=str(payload.get("classification", "pending")),
preflight_ok=payload.get("preflight_ok"),
smoke_attempted=bool(payload.get("smoke_attempted", False)),
status=_optional_string(payload.get("status")),
accepted=bool(payload.get("accepted", False)),
failed_stage=_optional_string(payload.get("failed_stage")),
quality_blockers=_string_list(payload.get("quality_blockers")),
retry_stages=_string_list(payload.get("retry_stages")),
repair_attempts=int(payload.get("repair_attempts", 0) or 0),
notes=_string_list(payload.get("notes")),
completed_stages=_string_list(payload.get("completed_stages")),
repair_models=_string_list(payload.get("repair_models")),
)
def _state_typed_results(state: Any) -> list[TrackingResult]:
results: list[TrackingResult] = []
typed_results = getattr(state, "typed_results", None)
raw_results = typed_results() if callable(typed_results) else getattr(state, "results", [])
for item in raw_results or []:
result = _tracking_result_from_payload(item)
if result is not None:
results.append(result)
return results
def _consolidated_tracking_results(state: Any, reports_root: Path) -> list[TrackingResult]:
latest_by_model: dict[str, tuple[tuple[datetime, int], TrackingResult]] = {}
sequence = 0
for snapshot in [*_load_report_history(reports_root), _state_payload(state)]:
stamp = _safe_timestamp(str(snapshot.get("updated_at", "")))
for result in _snapshot_results(snapshot):
candidate_key = (stamp, sequence)
current = latest_by_model.get(result.model)
if current is None or candidate_key >= current[0]:
latest_by_model[result.model] = (candidate_key, result)
sequence += 1
return sorted((payload[1] for payload in latest_by_model.values()), key=_result_sort_key)
def _accepted_history_counts(state: Any, reports_root: Path) -> dict[str, int]:
counts: dict[str, int] = {}
for snapshot in [*_load_report_history(reports_root), _state_payload(state)]:
for result in _snapshot_results(snapshot):
if result.classification != "accepted":
continue
counts[result.model] = counts.get(result.model, 0) + 1
return counts
def _snapshot_results(snapshot: dict[str, Any]) -> list[TrackingResult]:
results: list[TrackingResult] = []
for item in snapshot.get("results") or []:
result = _tracking_result_from_payload(item)
if result is not None:
results.append(result)
return results
def _state_payload(state: Any) -> dict[str, Any]:
payload = dict(vars(state))
if "report_dir" in payload:
payload["report_dir"] = str(payload["report_dir"])
return payload
def _result_sort_key(result: TrackingResult) -> tuple[int, str, str]:
category_order = {
"priority_models": 0,
"baselines": 1,
"preflight_only": 2,
"runtime_preflight": 3,
}
provider = result.model.split(":", 1)[0]
return (category_order.get(result.category, 9), provider, result.model)
def _build_summary(state: Any, results: list[TrackingResult]) -> dict[str, Any]:
accepted = [item for item in results if item.classification == "accepted"]
reached_gate = [item for item in results if item.reached_gate()]
quality_blocked = [item for item in results if item.classification == "quality_blocked"]
provider_failed = [item for item in results if item.classification == "provider_failed"]
return {
"started_at": getattr(state, "started_at"),
"updated_at": getattr(state, "updated_at"),
"pending_manual_action": getattr(state, "pending_manual_action", None),
"accepted_models": [item.model for item in accepted],
"reached_gate_models": [item.model for item in reached_gate],
"quality_blocked_models": [item.model for item in quality_blocked],
"provider_failed_models": [item.model for item in provider_failed],
"results": results,
}
def _compute_next_lot_recommendation(
results: list[TrackingResult],
fallback: str,
*,
accepted_counts: dict[str, int] | None = None,
) -> str:
accepted_counts = accepted_counts or {}
provider_failed_models = [item.model for item in results if item.classification == "provider_failed"]
has_quality_blocked = any(item.classification == "quality_blocked" for item in results)
if accepted_counts.get("apple-coreml:qwen3.5-4b-onnx-q4f16", 0) >= 2:
if provider_failed_models:
if has_quality_blocked:
return "Reference locale reconfirmee; retablir le runtime des modeles provider_failed puis reprendre rewrite/repair sur les modeles bloques a gate."
return "Reference locale reconfirmee; retablir le runtime des modeles provider_failed avant de poursuivre."
if any(item.classification == "quality_blocked" for item in results):
return "Reference locale reconfirmee; resserrer rewrite/repair sur les modeles deja bloques a gate."
return "Reference locale reconfirmee; garder les autres modeles en regression."
if any(item.classification == "accepted" for item in results):
if provider_failed_models:
return "Confirmer la reference accepted puis retablir le runtime des modeles provider_failed."
if any(item.classification == "quality_blocked" for item in results):
return "Confirmer la reference accepted puis resserrer rewrite/repair sur les modeles deja bloques a gate."
return "Figer la reference locale dans les README/runbooks et garder les autres modeles en regression."
if any(item.reached_gate() for item in results):
return "Analyser les runs ayant atteint gate/repair puis resserrer la reference locale autour des meilleurs candidats."
return fallback
def _render_todo_active_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle automatique: {summary['updated_at']}",
f"- modeles accepted: {_comma_or_none(summary['accepted_models'])}",
f"- modeles ayant atteint gate: {_comma_or_none(summary['reached_gate_models'])}",
f"- quality_blocked: {_comma_or_none(summary['quality_blocked_models'])}",
f"- provider_failed: {_comma_or_none(summary['provider_failed_models'])}",
f"- prochain lot recommande: {next_lot}",
]
if summary["pending_manual_action"]:
pending = summary["pending_manual_action"]
lines.extend(
[
f"- checkpoint manuel en attente: {pending['reason']}",
f"- commande preparee: `{pending['command']}`",
f"- reprise: `python3 scripts/run_next_lots.py --resume {pending['resume_state']}`",
]
)
return "\n".join(lines)
def _render_todo_done_sync(summary: dict[str, Any]) -> str:
lines = [
"- orchestrateur `scripts/run_next_lots.py` disponible",
"- manifeste `automation/next_lots.toml` charge",
"- derniers fichiers de suivi synchronisables via marqueurs `AUTO-SYNC`",
f"- dernier cycle automatise observe: {summary['updated_at']}",
]
return "\n".join(lines)
def _render_plan_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier verdict automatise: {summary['updated_at']}",
f"- accepted: {_comma_or_none(summary['accepted_models'])}",
f"- gate atteint: {_comma_or_none(summary['reached_gate_models'])}",
f"- prochain lot calcule: {next_lot}",
]
if summary["pending_manual_action"]:
lines.append(f"- checkpoint manuel requis: {summary['pending_manual_action']['reason']}")
return "\n".join(lines)
def _render_readme_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle automatise: {summary['updated_at']}",
f"- reference locale actuelle: {_reference_label(summary)}",
f"- prochain lot utile: {next_lot}",
"- lancer un cycle: `python3 scripts/run_next_lots.py --lot full`",
]
if summary["pending_manual_action"]:
lines.append(f"- checkpoint manuel en attente: {summary['pending_manual_action']['reason']}")
return "\n".join(lines)
def _render_runbook_sync(summary: dict[str, Any], project_state: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle automatise: {summary['updated_at']}",
f"- chapitre courant detecte: {project_state.get('current_chapter') or 'aucun'}",
f"- reference locale actuelle: {_reference_label(summary)}",
f"- prochain lot utile: {next_lot}",
]
if summary["pending_manual_action"]:
lines.append(f"- reprise attendue apres action manuelle: {summary['pending_manual_action']['resume_state']}")
return "\n".join(lines)
def _render_mascarade_todo_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle ANE automatise: {summary['updated_at']}",
f"- accepted via runtime local: {_comma_or_none(summary['accepted_models'])}",
f"- gate atteint via runtime local: {_comma_or_none(summary['reached_gate_models'])}",
f"- blocage runtime principal: {next_lot}",
]
if summary["pending_manual_action"]:
lines.append(f"- checkpoint runtime manuel: {summary['pending_manual_action']['reason']}")
return "\n".join(lines)
def _render_mascarade_plan_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle ANE automatise: {summary['updated_at']}",
f"- reference locale ANE: {_reference_label(summary)}",
f"- prochain lot ANE a servir: {next_lot}",
]
return "\n".join(lines)
def _render_mascarade_readme_sync(summary: dict[str, Any], next_lot: str) -> str:
return "\n".join(
[
f"- dernier cycle ANE automatise: {summary['updated_at']}",
f"- etat de reference ANE: {_reference_label(summary)}",
f"- prochain lot utile cote pipeline: {next_lot}",
]
)
def _render_mascarade_runbook_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle ANE automatise: {summary['updated_at']}",
f"- meilleurs candidats actuels: {_top_candidates(summary['results'])}",
f"- prochain lot utile cote ANE: {next_lot}",
]
if summary["pending_manual_action"]:
lines.append(f"- checkpoint runtime manuel: {summary['pending_manual_action']['reason']}")
return "\n".join(lines)
def _reference_label(summary: dict[str, Any]) -> str:
if summary["accepted_models"]:
return summary["accepted_models"][0]
if summary["reached_gate_models"]:
return f"aucun accepted, meilleur diagnostic: {summary['reached_gate_models'][0]}"
return "aucune reference accepted"
def _top_candidates(results: Iterable[TrackingResult]) -> str:
candidates: list[str] = []
for item in results:
if item.model in candidates:
continue
if item.model.startswith("apple-coreml:qwen3.5-4b") or item.model.startswith("ollama:qwen2.5:7b"):
candidates.append(item.model)
return ", ".join(candidates) if candidates else "aucun"
def _comma_or_none(items: list[str]) -> str:
return ", ".join(items) if items else "aucun"
def _render_comparison_markdown(state: Any, results: list[TrackingResult]) -> str:
lines = [
f"- dernier cycle automatise: {getattr(state, 'updated_at')}",
"",
"| Modele | Categorie | Preflight | Smoke | Classification | Failed stage | Gate | Repairs | Notes |",
"|---|---|---|---|---|---|---|---:|---|",
]
for item in results:
lines.append(
"| {model} | {category} | {preflight} | {smoke} | {classification} | {failed_stage} | {gate} | {repairs} | {notes} |".format(
model=item.model,
category=item.category,
preflight="OK" if item.preflight_ok else ("KO" if item.preflight_ok is False else "n/a"),
smoke="oui" if item.smoke_attempted else "non",
classification=item.classification,
failed_stage=item.failed_stage or "",
gate="oui" if item.reached_gate() else "non",
repairs=item.repair_attempts,
notes="; ".join(item.notes) if item.notes else "",
)
)
return "\n".join(lines)
def _render_summary_markdown(state: Any, results: list[TrackingResult]) -> str:
summary = _build_summary(state, results)
lines = [
"# Résumé du cycle automatique",
"",
f"- lot: `{getattr(state, 'lot')}`",
f"- démarré: `{getattr(state, 'started_at')}`",
f"- mis à jour: `{getattr(state, 'updated_at')}`",
f"- accepted: {_comma_or_none(summary['accepted_models'])}",
f"- gate atteint: {_comma_or_none(summary['reached_gate_models'])}",
f"- quality_blocked: {_comma_or_none(summary['quality_blocked_models'])}",
f"- provider_failed: {_comma_or_none(summary['provider_failed_models'])}",
]
if getattr(state, "pending_manual_action", None):
lines.extend(
[
"",
"## Checkpoint manuel",
f"- raison: {state.pending_manual_action['reason']}",
f"- commande: `{state.pending_manual_action['command']}`",
f"- reprise: `python3 scripts/run_next_lots.py --resume {state.pending_manual_action['resume_state']}`",
]
)
if results:
lines.extend(["", "## Résultats", ""])
lines.append(_render_comparison_markdown(state, results))
return "\n".join(lines) + "\n"