feat(pipeline): per-model prompt profiles + mistral-nemo v2 variant wired end-to-end

- PromptStore.render() accepts prompt_profile with fallback to v1
- GenerationPipeline passes prompt_profile to rewrite + repair stages
- cli/main.py reads ANE_PROMPT_PROFILE from env
- next_lots.py reads [prompt_profiles] from TOML, injects into smoke env
- Config: "ollama:mistral-nemo:latest" = "v2_nemo"
- v2_nemo prompts: less directive on closure, natural scene endings
- 156 tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
L'électron rare
2026-03-23 17:01:30 +01:00
parent 3af51d39ed
commit 62fe2b0943
3 changed files with 286 additions and 658 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import os
import re
import argparse
from pathlib import Path
@@ -157,7 +158,8 @@ def cmd_generate_chapter(
*,
force_accept: bool | None = None,
):
pipeline = GenerationPipeline(root, provider=provider, input_func=input_func)
prompt_profile = os.environ.get("ANE_PROMPT_PROFILE") or None
pipeline = GenerationPipeline(root, provider=provider, input_func=input_func, prompt_profile=prompt_profile)
outcome = pipeline.generate_chapter(
chapter_value,
approval_callback=_approval_callback_from_flags(force_accept),
+155 -72
View File
@@ -5,9 +5,11 @@ import json
import os
from pathlib import Path
import re
import tempfile
from typing import Callable, TypeVar
from core.chapters import ChapterId, resolve_chapter_file
from core.evaluation import build_narrative_judge_from_env
from core.generation.models import (
ControlReport,
GenerationContext,
@@ -26,6 +28,12 @@ from core.generation.provider import (
)
from core.intention.gate import IntentionGate
from core.prompts import PromptStore
from core.runtime.policies import (
default_repair_fallback_model,
is_cross_apple_runtime_switch,
model_provider_name,
resolve_repair_model,
)
ApprovalCallback = Callable[[ControlReport, Path], bool]
@@ -41,6 +49,7 @@ class GenerationPipeline:
prompt_store: PromptStore | None = None,
input_func: Callable[[str], str] = input,
output_func: OutputCallback = print,
prompt_profile: str | None = None,
):
self.root = root
self.provider = provider
@@ -48,6 +57,7 @@ class GenerationPipeline:
self.input_func = input_func
self.output_func = output_func
self.intention_gate = IntentionGate(root)
self.prompt_profile = prompt_profile
def generate_chapter(
self,
@@ -74,32 +84,24 @@ class GenerationPipeline:
current_stage = "structure"
structure_plan = self._generate_structure(provider, context, metadata)
self._write_text(context.structure_path, structure_plan.markdown)
self._complete_stage(metadata, current_stage)
self._set_status(metadata, "structure_ready", "Structure générée.")
self._write_metadata(context.meta_path, metadata)
self._finish_stage(metadata, context.meta_path, "structure", "Structure générée.")
current_stage = "draft"
draft_v1 = self._generate_draft(provider, context, structure_plan, metadata)
self._write_text(context.draft_v1_path, draft_v1)
self._complete_stage(metadata, current_stage)
self._set_status(metadata, "draft_ready", "Brouillon initial généré.")
self._write_metadata(context.meta_path, metadata)
self._finish_stage(metadata, context.meta_path, "draft", "Brouillon initial généré.")
current_stage = "critique"
control_report = self._generate_control_report(provider, context, structure_plan, draft_v1, metadata)
self._write_text(context.critique_path, control_report.to_markdown(context.chapter_id))
self._complete_stage(metadata, current_stage)
self._set_status(metadata, "critique_ready", "Critique structurée générée.")
metadata["control_report"] = control_report.to_dict()
self._write_metadata(context.meta_path, metadata)
self._finish_stage(metadata, context.meta_path, "critique", "Critique structurée générée.")
current_stage = "rewrite"
draft_v2 = self._rewrite_draft(provider, context, structure_plan, draft_v1, control_report, metadata)
self._write_text(context.draft_v2_path, draft_v2)
self._complete_stage(metadata, current_stage)
self._set_status(metadata, "rewrite_ready", "Brouillon final généré, contrôle manuscrit en cours.")
metadata["draft_final"] = str(context.draft_v2_path)
self._write_metadata(context.meta_path, metadata)
self._finish_stage(metadata, context.meta_path, "rewrite", "Brouillon final généré, contrôle manuscrit en cours.")
current_candidate_text = draft_v2
current_candidate_path = context.draft_v2_path
@@ -307,7 +309,7 @@ class GenerationPipeline:
continue
if stripped == "---":
continue
if re.match(r"^#\s+(chapitre|chapter)\b", stripped, flags=re.IGNORECASE):
if re.match(r"^#{1,6}\s", stripped):
continue
cleaned_lines.append(raw_line)
@@ -328,10 +330,38 @@ class GenerationPipeline:
focus.append(
"- la derniere scene doit se fermer sur une decision nette et sa consequence immediate, dans une phrase pleinement terminee"
)
focus.append(
"- apres l'acte final, fermer la scene en 2-4 phrases sans rouvrir un nouveau trajet, un nouveau lieu ou une nouvelle decouverte"
)
if "too_short" in blockers:
focus.append(
"- viser au moins 4 paragraphes utiles pour obtenir une scene complete, pas un resume raccourci"
)
if "missing_risky_decision" in blockers:
focus.append(
"- dans le dernier tiers, ajouter une decision risquee concrete et couteuse prise par le personnage principal"
)
focus.append(
"- la decision finale doit etre executee tout de suite et couter quelque chose d'observable: exposition, perte, poursuite, argent sacrifie ou point de non-retour"
)
if "missing_immediate_consequence" in blockers:
focus.append(
"- montrer dans les phrases qui suivent une consequence immediate, observable et irreversible de la decision finale"
)
focus.append(
"- cette consequence doit arriver dans le meme lieu et la meme minute: cri, sang, poursuite, alarme, porte forcee, preuve detruite, argent perdu ou autre point de non-retour visible"
)
focus.append(
"- ne pas finir sur un depart vers la suite; montrer d'abord la reaction ou le degat cause par l'acte final, puis fermer la scene"
)
if "incomplete_scene" in blockers:
focus.append(
"- completer la scene jusqu'a une fermeture dramatique nette; ne pas s'arreter juste avant l'acte ou juste avant son effet"
)
if "weak_narrative_continuity" in blockers:
focus.append(
"- renforcer la continuite causale entre perceptions, decisions, actions et consequences; supprimer les sauts resumes ou abstraits"
)
if not focus:
focus.append("- conserver une prose continue, concrete et entierement narrative")
return "\n".join(focus)
@@ -428,6 +458,7 @@ class GenerationPipeline:
) -> str:
prompt = self.prompt_store.render(
"rewrite",
prompt_profile=self.prompt_profile,
chapter_slug=context.chapter_id.slug,
intention=context.intention_text,
structure_markdown=structure_plan.markdown,
@@ -513,6 +544,7 @@ class GenerationPipeline:
) -> str:
prompt = self.prompt_store.render(
"repair",
prompt_profile=self.prompt_profile,
chapter_slug=context.chapter_id.slug,
intention=context.intention_text,
structure_markdown=structure_plan.markdown,
@@ -550,33 +582,71 @@ class GenerationPipeline:
"gate",
"Contrôle manuscrit en cours.",
)
judge = build_narrative_judge_from_env(provider=provider, prompt_store=self.prompt_store)
heuristic_report = self._heuristic_gate_report(draft_v2)
if heuristic_report is not None:
metadata["last_status_message"] = heuristic_report.summary
return heuristic_report
if heuristic_report is None:
prompt = self.prompt_store.render(
"gate",
chapter_slug=context.chapter_id.slug,
intention=context.intention_text,
structure_markdown=structure_plan.markdown,
draft_markdown=draft_v2,
)
gate_report = self._generate_json_payload(
provider=provider,
stage="gate",
prompt=prompt,
retry_prompt_name="gate_retry",
parse_response=ManuscriptGateReport.from_response_text,
metadata=metadata,
meta_path=context.meta_path,
retry_context={
"chapter_slug": context.chapter_id.slug,
"intention": context.intention_text,
"structure_markdown": structure_plan.markdown,
"draft_markdown": draft_v2,
},
begin_stage=False,
)
gate_report = self._sanitize_gate_report(draft_v2, gate_report)
else:
gate_report = heuristic_report
prompt = self.prompt_store.render(
"gate",
chapter_slug=context.chapter_id.slug,
intention=context.intention_text,
structure_markdown=structure_plan.markdown,
draft_markdown=draft_v2,
)
return self._generate_json_payload(
provider=provider,
stage="gate",
prompt=prompt,
retry_prompt_name="gate_retry",
parse_response=ManuscriptGateReport.from_response_text,
metadata=metadata,
meta_path=context.meta_path,
retry_context={
"chapter_slug": context.chapter_id.slug,
"intention": context.intention_text,
"structure_markdown": structure_plan.markdown,
"draft_markdown": draft_v2,
},
begin_stage=False,
if judge is not None:
judge_report = judge.evaluate(
chapter_slug=context.chapter_id.slug,
intention=context.intention_text,
structure_markdown=structure_plan.markdown,
draft_markdown=draft_v2,
story_context=context.story_context,
)
gate_report = gate_report.with_judge_report(judge_report)
metadata["last_status_message"] = gate_report.summary
return gate_report
def _sanitize_gate_report(self, draft_markdown: str, gate_report: ManuscriptGateReport) -> ManuscriptGateReport:
if "outline_like" not in gate_report.blockers:
return gate_report
if self._is_outline_like(draft_markdown):
return gate_report
blockers = [item for item in gate_report.blockers if item != "outline_like"]
recommendations = [item for item in gate_report.recommendations if item]
if blockers:
summary = "Le garde-fou manuscrit a bloque la promotion: " + ", ".join(blockers) + "."
else:
summary = "Le texte reste en prose narrative continue; aucun marqueur visuel de plan n'a ete confirme."
return ManuscriptGateReport(
ready_for_manuscript=not blockers and not gate_report.heuristic_blockers and not gate_report.judge_blockers,
summary=summary,
blockers=blockers,
recommendations=recommendations,
heuristic_blockers=list(gate_report.heuristic_blockers),
judge_blockers=list(gate_report.judge_blockers),
judge_report=gate_report.judge_report,
raw={**gate_report.raw, "outline_like_sanitized": True},
)
def _persist_gate_report(
@@ -608,43 +678,24 @@ class GenerationPipeline:
def _repair_model_for_attempt(self, provider: GenerationProvider, attempt: int) -> str | None:
base_model = self._provider_model_name(provider)
if attempt <= 1:
return base_model
override = os.environ.get("ANE_REPAIR_FALLBACK_MODEL", "").strip()
candidate = override or self._default_repair_fallback_model(base_model) or base_model
if not override and self._model_provider_name(candidate) != self._model_provider_name(base_model):
candidate = base_model
if self._is_cross_apple_runtime_switch(base_model, candidate):
raise ProviderError(
"ANE_REPAIR_FALLBACK_MODEL ne peut pas viser un autre modèle apple-coreml pendant un même smoke. "
"Relancer le runtime Apple sur le modèle cible ou utiliser un fallback non-Apple."
try:
return resolve_repair_model(
base_model=base_model,
attempt=attempt,
override_model=override,
)
return candidate
except RuntimeError as exc:
raise ProviderError(str(exc)) from exc
def _default_repair_fallback_model(self, model: str | None) -> str | None:
mapping = {
"ollama:qwen2.5:1.5b": "ollama:qwen2.5:7b",
"apple-coreml:qwen2.5-0.5b-instruct-onnx": "ollama:qwen2.5:7b",
"apple-coreml:qwen3.5-4b-onnx-q4f16": "ollama:qwen2.5:7b",
}
if not model:
return None
return mapping.get(model)
return default_repair_fallback_model(model)
def _is_cross_apple_runtime_switch(self, base_model: str | None, candidate: str | None) -> bool:
if not base_model or not candidate:
return False
if base_model == candidate:
return False
return base_model.startswith("apple-coreml:") and candidate.startswith("apple-coreml:")
return is_cross_apple_runtime_switch(base_model, candidate)
def _model_provider_name(self, model: str | None) -> str | None:
if not model or ":" not in model:
return None
provider, _ = model.split(":", 1)
provider = provider.strip()
return provider or None
return model_provider_name(model)
def _heuristic_gate_report(self, draft_v2: str) -> ManuscriptGateReport | None:
blockers: list[str] = []
@@ -685,6 +736,7 @@ class GenerationPipeline:
def _is_outline_like(self, text: str) -> bool:
detected_markers: set[str] = set()
bullet_line_count = 0
for line in text.splitlines():
stripped = line.strip()
if not stripped:
@@ -698,6 +750,9 @@ class GenerationPipeline:
detected_markers.add("horizontal_rule")
if stripped.startswith(("- ", "* ")):
detected_markers.add("bullet_list")
bullet_line_count += 1
if bullet_line_count >= 4:
detected_markers.add("dense_bullet_list")
if re.match(r"^\d+[.)]\s", stripped):
detected_markers.add("numbered_list")
if (
@@ -713,7 +768,7 @@ class GenerationPipeline:
detected_markers.add("structure_label")
if re.match(r"^#{0,6}\s*chapitre\b", lowered):
detected_markers.add("chapter_title")
if "scène" in lowered or "scene" in lowered or "— titre" in lowered:
if re.match(r"^#{0,6}\s*(scène|scene)\b", lowered) or re.match(r"^(?:scène|scene)\s*\d", lowered):
detected_markers.add("scene_heading")
if len(detected_markers) >= 2:
return True
@@ -838,7 +893,10 @@ class GenerationPipeline:
) -> None:
existing: dict[str, dict[str, object]] = {}
if path.exists():
payload = json.loads(path.read_text(encoding="utf-8"))
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
payload = {}
if isinstance(payload, dict):
existing = payload
@@ -864,7 +922,10 @@ class GenerationPipeline:
) -> None:
existing: list[dict[str, str]] = []
if path.exists():
payload = json.loads(path.read_text(encoding="utf-8"))
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
payload = []
if isinstance(payload, list):
existing = [
{str(key): str(value) for key, value in item.items()}
@@ -875,7 +936,8 @@ class GenerationPipeline:
for record in records:
merged = dict(record)
merged["chapter"] = chapter_id.slug
existing.append(merged)
if merged not in existing:
existing.append(merged)
self._write_json(path, existing)
@@ -940,6 +1002,17 @@ class GenerationPipeline:
return None
return model.strip() or None
def _finish_stage(
self,
metadata: dict[str, object],
meta_path: Path,
stage: str,
message: str,
) -> None:
self._complete_stage(metadata, stage)
self._set_status(metadata, f"{stage}_ready", message)
self._write_metadata(meta_path, metadata)
def _complete_stage(self, metadata: dict[str, object], stage: str) -> None:
completed = metadata.setdefault("completed_stages", [])
if not isinstance(completed, list):
@@ -1017,7 +1090,17 @@ class GenerationPipeline:
def _write_json(self, path: Path, payload: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
rendered = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=path.parent,
delete=False,
suffix=".tmp",
) as handle:
handle.write(rendered)
temp_path = Path(handle.name)
temp_path.replace(path)
def _write_text(self, path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
+128 -585
View File
@@ -7,48 +7,29 @@ import inspect
import json
import os
from pathlib import Path
import re
import subprocess
import time
import tomllib
from typing import Any, Callable, Iterable
import tempfile
from typing import Any, Callable
from urllib import error, request
from core.chapters import ChapterId
from core.project.loader import ProjectState
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"
from core.runtime.checkpoints import checkpoint_manual_action_for_model, host_port_from_base_url
from core.runtime.orchestration import (
build_runtime_execution_plan,
collect_checkpoint_runtime_signals,
missing_ollama_models,
read_current_apple_model,
runtime_timeout_for_model,
)
from core.runtime.preflight import run_ollama_native_preflight
from core.tracking_sync import TrackingPaths, build_tracking_sync_context, sync_tracking, write_report_summary
class NextLotsError(RuntimeError):
"""Raised when the orchestration flow cannot continue automatically."""
@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 Manifest:
repo_root: Path
@@ -70,6 +51,8 @@ class Manifest:
priority_models: list[str]
baseline_models: list[str]
preflight_only_models: list[str]
french_models: list[str]
prompt_profiles: dict[str, str]
next_code_lot: str
@classmethod
@@ -122,6 +105,8 @@ class Manifest:
priority_models=[str(item) for item in lots["priority_models"]["models"]],
baseline_models=[str(item) for item in lots["baselines"]["models"]],
preflight_only_models=[str(item) for item in lots["preflight_only"]["models"]],
french_models=[str(item) for item in lots.get("french_models", {}).get("models", [])],
prompt_profiles={str(k): str(v) for k, v in payload.get("prompt_profiles", {}).items()},
next_code_lot=str(payload["next_actions"]["rewrite_compaction"]),
)
@@ -216,7 +201,17 @@ class RunState:
target = path or Path(self.state_path)
self.updated_at = _timestamp()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(json.dumps(asdict(self), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
rendered = json.dumps(asdict(self), ensure_ascii=False, indent=2) + "\n"
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=target.parent,
delete=False,
suffix=".tmp",
) as handle:
handle.write(rendered)
temp_path = Path(handle.name)
temp_path.replace(target)
def append_result(self, result: ModelRunResult) -> None:
self.results.append(asdict(result))
@@ -276,92 +271,6 @@ def _default_json_fetcher(url: str, timeout: float) -> Any:
return json.loads(response.read().decode("utf-8"))
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 _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[RunState]:
history: list[RunState] = []
if not reports_root.exists():
return history
for run_path in sorted(reports_root.glob("*/run.json")):
try:
history.append(RunState.load(run_path))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
continue
history.sort(key=lambda item: (_safe_timestamp(item.updated_at), item.report_dir))
return history
def _result_sort_key(result: ModelRunResult) -> 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 _consolidated_tracking_results(state: RunState, reports_root: Path) -> list[ModelRunResult]:
latest_by_model: dict[str, tuple[tuple[datetime, int], ModelRunResult]] = {}
sequence = 0
for snapshot in [*_load_report_history(reports_root), state]:
stamp = _safe_timestamp(snapshot.updated_at)
for result in snapshot.typed_results():
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: RunState, reports_root: Path) -> dict[str, int]:
counts: dict[str, int] = {}
for snapshot in [*_load_report_history(reports_root), state]:
for result in snapshot.typed_results():
if result.classification != "accepted":
continue
counts[result.model] = counts.get(result.model, 0) + 1
return counts
class NextLotsRunner:
def __init__(
self,
@@ -398,7 +307,15 @@ class NextLotsRunner:
state.dump(state_path)
if report_only:
self._sync_tracking(state, dry_run=dry_run)
sync_tracking(
build_tracking_sync_context(
self.manifest.repo_root,
next_code_lot=self.manifest.next_code_lot,
tracking=self.manifest.tracking,
),
state,
dry_run=dry_run,
)
return 0
while state.step_index < len(state.steps):
@@ -416,7 +333,15 @@ class NextLotsRunner:
exit_code = self._run_model_step(state, step, dry_run=dry_run)
state.dump()
if exit_code is not None:
self._sync_tracking(state, dry_run=dry_run)
sync_tracking(
build_tracking_sync_context(
self.manifest.repo_root,
next_code_lot=self.manifest.next_code_lot,
tracking=self.manifest.tracking,
),
state,
dry_run=dry_run,
)
return exit_code
state.step_index += 1
state.model_index = 0
@@ -424,14 +349,22 @@ class NextLotsRunner:
continue
if step_type == "tracking_sync":
print("==> lot tracking_sync")
self._sync_tracking(state, dry_run=dry_run)
sync_tracking(
build_tracking_sync_context(
self.manifest.repo_root,
next_code_lot=self.manifest.next_code_lot,
tracking=self.manifest.tracking,
),
state,
dry_run=dry_run,
)
state.step_index += 1
state.model_index = 0
state.dump()
continue
raise NextLotsError(f"Type de lot non supporté: {step_type}")
self._write_report_summary(state)
write_report_summary(state)
return 0
def _steps_for_lot(self, lot: str) -> list[dict[str, Any]]:
@@ -451,6 +384,11 @@ class NextLotsRunner:
{"type": "models", "name": "preflight_only", "models": self.manifest.preflight_only_models, "preflight_only": True},
{"type": "tracking_sync"},
]
if lot == "french_models":
return [
{"type": "models", "name": "french_models", "models": self.manifest.french_models, "preflight_only": False},
{"type": "tracking_sync"},
]
if lot == "tracking_sync":
return [{"type": "tracking_sync"}]
if lot == "full":
@@ -486,19 +424,11 @@ class NextLotsRunner:
)
def _missing_ollama_models(self) -> list[str]:
try:
payload = self.json_fetcher(self.manifest.ollama_tags_url, 10.0)
except Exception:
return []
models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(models, list):
return []
names = {
str(item.get("name", "")).strip()
for item in models
if isinstance(item, dict) and str(item.get("name", "")).strip()
}
return [model for model in self.manifest.required_ollama_models if model not in names]
return missing_ollama_models(
self.manifest.required_ollama_models,
tags_url=self.manifest.ollama_tags_url,
json_fetcher=self.json_fetcher,
)
def _run_model_step(self, state: RunState, step: dict[str, Any], *, dry_run: bool) -> int | None:
models = [str(item) for item in step["models"]]
@@ -526,7 +456,7 @@ class NextLotsRunner:
print(f"commande: {checkpoint['command']}")
state.pending_manual_action = checkpoint
state.notes = [f"Checkpoint manuel requis pour: {model}"]
self._write_report_summary(state)
write_report_summary(state)
return 3
state.pending_manual_action = None
result = self._run_model(model, category=category, preflight_only=preflight_only, report_dir=Path(state.report_dir))
@@ -535,37 +465,35 @@ class NextLotsRunner:
return None
def _checkpoint_if_runtime_manual_step_needed(self, state: RunState, model: str) -> dict[str, Any] | None:
if not self._core_health_ok():
return self._build_manual_action(
state,
args=["bash", "scripts/prepare_runtime_step.sh", "--restart", "core", "--resume-state", state.state_path, "--ane-script", str(self.manifest.repo_root / "scripts" / "run_next_lots.py")],
reason="Le core mascarade ne répond pas correctement.",
)
if not model.startswith("apple-coreml:"):
return None
target_model = model.split(":", 1)[1]
apple_model = self._wait_for_expected_apple_model(target_model)
if apple_model == target_model:
return None
args = [
"bash",
"scripts/prepare_runtime_step.sh",
"--apple-model",
target_model,
"--resume-state",
state.state_path,
"--ane-script",
str(self.manifest.repo_root / "scripts" / "run_next_lots.py"),
]
return self._build_manual_action(
state,
args=args,
reason=f"Le runtime Apple sert `{apple_model or 'aucun modèle'}` au lieu de `{target_model}`.",
signals = collect_checkpoint_runtime_signals(
model,
core_base_url=self.manifest.core_base_url,
apple_runtime_url=self.manifest.apple_runtime_url,
apple_model_ready_timeout_seconds=self.manifest.apple_model_ready_timeout_seconds,
apple_model_poll_interval_seconds=self.manifest.apple_model_poll_interval_seconds,
ollama_runtime=self.manifest.ollama_runtime,
ollama_openai_base_url=self.manifest.ollama_openai_base_url,
json_fetcher=self.json_fetcher,
)
action = checkpoint_manual_action_for_model(
model=model,
core_health_ok=signals.core_health_ok,
ollama_runtime=self.manifest.ollama_runtime,
ollama_openai_runtime_ready=signals.ollama_openai_runtime_ready,
ollama_openai_base_url=self.manifest.ollama_openai_base_url,
apple_model_active=signals.apple_model_active,
repo_root=str(self.manifest.repo_root),
state_path=state.state_path,
ane_script_path=str(self.manifest.repo_root / "scripts" / "run_next_lots.py"),
)
if action is None:
return None
return self._build_manual_action(state, args=action.args, reason=action.reason)
def _build_manual_action(self, state: RunState, *, args: list[str], reason: str) -> dict[str, Any]:
result = self._invoke_command(args, self.manifest.tracking.mascarade_repo, timeout_seconds=300)
log_path = Path(state.report_dir) / f"manual_action_{len(state.results):02d}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
log_path.write_text(_command_log(result), encoding="utf-8")
return {
"reason": reason,
@@ -574,38 +502,11 @@ class NextLotsRunner:
"resume_state": state.state_path,
}
def _core_health_ok(self) -> bool:
try:
payload = self.json_fetcher(f"{self.manifest.core_base_url}/health", 10.0)
except Exception:
return False
return isinstance(payload, dict)
def _current_apple_model(self) -> str | None:
try:
payload = self.json_fetcher(f"{self.manifest.apple_runtime_url}/models", 10.0)
except Exception:
return None
if isinstance(payload, list) and payload:
return str(payload[0]).strip() or None
if isinstance(payload, dict):
models = payload.get("models")
if isinstance(models, list) and models:
return str(models[0]).strip() or None
return None
def _wait_for_expected_apple_model(self, target_model: str) -> str | None:
deadline = time.monotonic() + max(self.manifest.apple_model_ready_timeout_seconds, 0.0)
poll_interval = max(self.manifest.apple_model_poll_interval_seconds, 0.1)
last_seen = self._current_apple_model()
if last_seen == target_model or self.manifest.apple_model_ready_timeout_seconds <= 0:
return last_seen
while time.monotonic() < deadline:
time.sleep(poll_interval)
last_seen = self._current_apple_model()
if last_seen == target_model:
return last_seen
return last_seen
return read_current_apple_model(
self.manifest.apple_runtime_url,
json_fetcher=self.json_fetcher,
)
def _ollama_base_url(self) -> str:
tags_url = self.manifest.ollama_tags_url.rstrip("/")
@@ -614,68 +515,23 @@ class NextLotsRunner:
return tags_url[: -len(suffix)]
return tags_url
def _openai_base_url_for_model(self, model: str) -> str:
if model.startswith("ollama:") and self.manifest.ollama_runtime == "openai_compatible":
return self.manifest.ollama_openai_base_url
return self.manifest.core_base_url
def _should_run_ollama_native_preflight(self, model: str) -> bool:
return model.startswith("ollama:") and self.manifest.ollama_runtime == "native"
def _host_port_from_base_url(self, base_url: str) -> tuple[str, int]:
return host_port_from_base_url(base_url)
def _run_ollama_native_preflight(self, model: str) -> CommandResult:
timeout_seconds = min(45.0, float(self._timeout_for_model(f"ollama:{model}")))
payload = {
"model": model,
"messages": [{"role": "user", "content": "Respond with exactly: ollama native preflight ok"}],
"stream": False,
"options": {
"temperature": 0,
"num_predict": 16,
},
}
body = json.dumps(payload).encode("utf-8")
started = time.monotonic()
try:
req = request.Request(
f"{self._ollama_base_url()}/api/chat",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with request.urlopen(req, timeout=timeout_seconds) as response:
raw_payload = response.read().decode("utf-8")
except error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
return CommandResult(
args=["ollama-native-preflight", model],
returncode=1,
stdout="",
stderr=f"HTTP {exc.code} {exc.reason}\n{detail}".strip(),
duration_seconds=time.monotonic() - started,
)
except Exception as exc:
return CommandResult(
args=["ollama-native-preflight", model],
returncode=1,
stdout="",
stderr=f"{type(exc).__name__}: {exc}",
duration_seconds=time.monotonic() - started,
)
try:
parsed = json.loads(raw_payload)
except json.JSONDecodeError:
parsed = {"raw": raw_payload}
preview = {
"model": parsed.get("model"),
"content": (parsed.get("message") or {}).get("content", ""),
"done_reason": parsed.get("done_reason"),
}
result = run_ollama_native_preflight(
model=model,
tags_url=self.manifest.ollama_tags_url,
timeout_seconds=timeout_seconds,
opener=request.urlopen,
)
return CommandResult(
args=["ollama-native-preflight", model],
returncode=0,
stdout=json.dumps(preview, ensure_ascii=False, indent=2),
stderr="",
duration_seconds=time.monotonic() - started,
args=result.args,
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
duration_seconds=result.duration_seconds,
)
def _invoke_command(
@@ -693,8 +549,14 @@ class NextLotsRunner:
def _run_model(self, model: str, *, category: str, preflight_only: bool, report_dir: Path) -> ModelRunResult:
result = ModelRunResult(model=model, category=category, apple_model_active=self._current_apple_model())
model_slug = _slugify(model)
openai_base_url = self._openai_base_url_for_model(model)
if self._should_run_ollama_native_preflight(model):
runtime_plan = build_runtime_execution_plan(
model,
core_base_url=self.manifest.core_base_url,
ollama_runtime=self.manifest.ollama_runtime,
ollama_openai_base_url=self.manifest.ollama_openai_base_url,
smoke_timeout_seconds=self.manifest.smoke_timeout_seconds,
)
if runtime_plan.requires_native_ollama_preflight:
native_preflight = self._run_ollama_native_preflight(model.split(":", 1)[1])
if native_preflight.returncode != 0:
result.preflight_duration_seconds = native_preflight.duration_seconds
@@ -713,16 +575,16 @@ class NextLotsRunner:
"bash",
"scripts/smoke_openai_compat_ane.sh",
"--url",
openai_base_url,
runtime_plan.openai_base_url,
"--model",
model,
"--timeout",
str(self._timeout_for_model(model)),
str(runtime_plan.timeout_seconds),
]
preflight = self._invoke_command(
preflight_args,
self.manifest.tracking.mascarade_repo,
timeout_seconds=float(self._timeout_for_model(model) + 30),
timeout_seconds=float(runtime_plan.timeout_seconds + 30),
)
result.preflight_duration_seconds = preflight.duration_seconds
preflight_log = report_dir / f"{model_slug}_preflight.log"
@@ -746,7 +608,7 @@ class NextLotsRunner:
"bash",
"scripts/smoke_local_generation.sh",
"--base-url",
openai_base_url,
runtime_plan.openai_base_url,
"--model",
model,
"--chapter",
@@ -754,16 +616,20 @@ class NextLotsRunner:
"--workspace",
str(workspace),
"--timeout",
str(self.manifest.smoke_timeout_seconds),
str(runtime_plan.timeout_seconds),
"--intention",
self.manifest.smoke_intention,
"--approve",
]
smoke_env = dict(self.manifest.preset_env)
profile = self.manifest.prompt_profiles.get(model)
if profile:
smoke_env["ANE_PROMPT_PROFILE"] = profile
smoke = self._invoke_command(
smoke_args,
self.manifest.repo_root,
env=self.manifest.preset_env,
timeout_seconds=float(self.manifest.smoke_timeout_seconds + 60),
env=smoke_env,
timeout_seconds=float(runtime_plan.timeout_seconds * 4),
)
result.smoke_attempted = True
result.smoke_duration_seconds = smoke.duration_seconds
@@ -807,101 +673,7 @@ class NextLotsRunner:
return result
def _timeout_for_model(self, model: str) -> int:
if model.startswith("apple-coreml:"):
return max(600, self.manifest.smoke_timeout_seconds)
return max(120, self.manifest.smoke_timeout_seconds)
def _sync_tracking(self, state: RunState, *, dry_run: bool) -> None:
if dry_run:
self._write_report_summary(state)
return
typed_results = _consolidated_tracking_results(
state,
self.manifest.repo_root / "automation" / "reports",
)
accepted_counts = _accepted_history_counts(
state,
self.manifest.repo_root / "automation" / "reports",
)
project_state = ProjectState(self.manifest.repo_root).summary()
summary = _build_summary(state, typed_results)
comparison = _render_comparison_markdown(state, typed_results)
active_next = _compute_next_lot_recommendation(
typed_results,
self.manifest.next_code_lot,
accepted_counts=accepted_counts,
)
replace_auto_section(
self.manifest.tracking.ane_todo_active,
AUTO_SYNC_TODO_ACTIVE,
"## Auto-sync",
_render_todo_active_sync(summary, active_next),
)
replace_auto_section(
self.manifest.tracking.ane_todo_done,
AUTO_SYNC_TODO_DONE,
"## Auto-sync",
_render_todo_done_sync(summary),
)
replace_auto_section(
self.manifest.tracking.ane_plan,
AUTO_SYNC_PLAN,
"## Auto-sync",
_render_plan_sync(summary, active_next),
)
replace_auto_section(
self.manifest.tracking.ane_comparison,
AUTO_SYNC_COMPARISON,
"## Auto-sync",
comparison,
)
replace_auto_section(
self.manifest.tracking.ane_readme,
AUTO_SYNC_README,
"## Etat auto-synchronise",
_render_readme_sync(summary, active_next),
)
replace_auto_section(
self.manifest.tracking.ane_runbook,
AUTO_SYNC_RUNBOOK,
"## Etat auto-synchronise",
_render_runbook_sync(summary, project_state, active_next),
)
replace_auto_section(
self.manifest.tracking.mascarade_todo,
AUTO_SYNC_MASCARADE_TODO,
"## Auto-sync",
_render_mascarade_todo_sync(summary, active_next),
)
replace_auto_section(
self.manifest.tracking.mascarade_plan,
AUTO_SYNC_MASCARADE_PLAN,
"## Auto-sync",
_render_mascarade_plan_sync(summary, active_next),
)
replace_auto_section(
self.manifest.tracking.mascarade_readme,
AUTO_SYNC_MASCARADE_README,
"## Etat auto-synchronise",
_render_mascarade_readme_sync(summary, active_next),
)
replace_auto_section(
self.manifest.tracking.mascarade_runbook,
AUTO_SYNC_MASCARADE_RUNBOOK,
"## Etat auto-synchronise",
_render_mascarade_runbook_sync(summary, active_next),
)
self._write_report_summary(state)
def _write_report_summary(self, state: RunState) -> None:
report_dir = Path(state.report_dir)
report_dir.mkdir(parents=True, exist_ok=True)
run_path = report_dir / "run.json"
summary_path = report_dir / "SUMMARY.md"
run_path.write_text(json.dumps(asdict(state), ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
summary_path.write_text(_render_summary_markdown(state, state.typed_results()), encoding="utf-8")
return runtime_timeout_for_model(model, smoke_timeout_seconds=self.manifest.smoke_timeout_seconds)
def _string_list(value: object) -> list[str]:
if not isinstance(value, list):
@@ -936,239 +708,10 @@ def _command_log(result: CommandResult) -> str:
)
def _build_summary(state: RunState, results: list[ModelRunResult]) -> 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": state.started_at,
"updated_at": state.updated_at,
"pending_manual_action": state.pending_manual_action,
"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[ModelRunResult],
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[ModelRunResult]) -> str:
candidates = []
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: RunState, results: list[ModelRunResult]) -> str:
lines = [
f"- dernier cycle automatise: {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: RunState, results: list[ModelRunResult]) -> str:
summary = _build_summary(state, results)
lines = [
"# Résumé du cycle automatique",
"",
f"- lot: `{state.lot}`",
f"- démarré: `{state.started_at}`",
f"- mis à jour: `{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 state.pending_manual_action:
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"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="python3 scripts/run_next_lots.py")
parser.add_argument("--manifest", default="automation/next_lots.toml")
parser.add_argument("--lot", default="full", choices=["full", "ensure_models", "runtime_preflight", "priority_models", "baselines", "tracking_sync"])
parser.add_argument("--lot", default="full", choices=["full", "ensure_models", "runtime_preflight", "priority_models", "baselines", "french_models", "tracking_sync"])
parser.add_argument("--resume", type=Path)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--report-only", action="store_true")