diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9791082 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +automation/reports/ +automation/state/ diff --git a/README.md b/README.md index 726c360..e4121d7 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,100 @@ sans perte de cohérence, de mémoire ou de contrôle. ## Statut v2 — développement en cours (open-source) + +## Suivi +- backlog actif: [`TODO_ACTIVE.md`](./TODO_ACTIVE.md) +- etat livre: [`TODO_IMPLEMENTE.md`](./TODO_IMPLEMENTE.md) +- ordre d'execution recommande: [`docs/EXECUTION_PLAN_2026-03-08.md`](./docs/EXECUTION_PLAN_2026-03-08.md) +- runbook local: [`docs/runbooks/LOCAL_GENERATION.md`](./docs/runbooks/LOCAL_GENERATION.md) +- comparatif modeles local: [`docs/MODEL_COMPARISON_2026-03-08.md`](./docs/MODEL_COMPARISON_2026-03-08.md) + +## Automation des lots utiles + +Le driver principal des prochains lots utiles est maintenant: + +```bash +python3 scripts/run_next_lots.py --lot full +``` + +Points clés: +- le manifeste versionné est `automation/next_lots.toml` +- le driver réutilise les smokes existants au lieu de dupliquer le pipeline +- les opérations sensibles restent semi-autos: en cas de switch Apple ou de restart runtime, le cycle prépare les commandes exactes puis s'arrête avec un état de reprise +- reprise: + +```bash +python3 scripts/run_next_lots.py --resume automation/state/next_lots_state.json +``` + +- synchronisation seule des plans/TODOs/readmes à partir du dernier état: + +```bash +python3 scripts/run_next_lots.py --lot tracking_sync --report-only +``` + +## Generation locale via Mascarade + +`ai-novel-engine` parle un provider OpenAI-compatible. Pour utiliser la +generation locale via `mascarade`, pointer simplement le moteur narratif vers le +core Python sur `:8100`. + +```bash +export ANE_PROVIDER=openai_compatible +export ANE_BASE_URL=http://127.0.0.1:8100 +export ANE_MODEL= +export ANE_MAX_TOKENS=512 +export ANE_MAX_TOKENS_STRUCTURE=256 +export ANE_MAX_TOKENS_DRAFT=512 +export ANE_MAX_TOKENS_CRITIQUE=384 +export ANE_MAX_TOKENS_REWRITE=512 +export ANE_MAX_TOKENS_GATE=320 +export ANE_MAX_TOKENS_REPAIR=384 +export ANE_MAX_TOKENS_MEMORY=256 +export ANE_REPAIR_MAX_PASSES=2 +# optionnel si tu veux forcer un fallback explicite pour la reparation +# export ANE_REPAIR_FALLBACK_MODEL=ollama:qwen2.5:7b + +# seulement si MASCARADE_API_KEY est active +export ANE_API_KEY=ton-token-mascarade + +python3 -m cli.main generate chapter --chapter 01 +``` + +Notes : +- `ANE_MODEL` est requis; le repo n'impose pas de modele par défaut +- `ANE_MODEL` selectionne le backend local par prefixe `apple-coreml:` ou `ollama:` +- `ANE_MAX_TOKENS` reste le plafond global par défaut +- les overrides `ANE_MAX_TOKENS_STRUCTURE`, `..._DRAFT`, `..._CRITIQUE`, `..._REWRITE`, `..._GATE`, `..._REPAIR`, `..._MEMORY` permettent d'ajuster chaque étape +- `ANE_REPAIR_MAX_PASSES` borne la boucle `repair` +- `ANE_REPAIR_FALLBACK_MODEL` permet de forcer le modele du second passage `repair` +- le pipeline narratif reste entierement dans `ai-novel-engine` +- `mascarade` sert uniquement de runtime local et de couche OpenAI-compatible +- dernier cycle complet termine au 9 mars 2026 : + - `apple-coreml:qwen3.5-4b-onnx-q4f16` est `accepted` de bout en bout sous garde-fou + - `ollama:qwen2.5:7b` atteint `gate`, exerce `repair` en live, puis finit `quality_blocked` sur `outline_like` + - `apple-coreml:stateful-mistral7b-instruct-int4-coreml` reste `preflight_only` +- les baselines `apple-coreml:qwen2.5-0.5b-instruct-onnx` et `ollama:qwen2.5:1.5b` sont en rerun automatise separe; ils ne sont plus la reference locale courante +- le smoke et `status` exposent maintenant `gate_v1.json`, `quality_blockers`, `failed_stage`, `repair_attempts` et `repair_models` +- le runtime Apple local ne sert qu'un `model_id` a la fois; un fallback `repair` vers un autre modele Apple exige donc un switch de service entre runs + +Smoke test local rapide : + +```bash +./scripts/smoke_local_generation.sh \ + --base-url http://127.0.0.1:8100 \ + --model "ollama:qwen2.5:1.5b" \ + --approve +``` + +Le script cree un workspace temporaire, ecrit une intention de test, lance la vraie CLI publique, fait un warm-up automatique pour `apple-coreml`, puis affiche un resume humain des artefacts et du `meta.json`. En mode `apple-coreml`, il applique par defaut un timeout plus large (`ANE_TIMEOUT=900`) et des budgets de smoke plus courts pour eviter de faire exploser la latence locale. Pour les reruns qualitatifs de reference, fixer explicitement `--timeout 300` et des budgets `ANE_MAX_TOKENS_*` communs. Utiliser `--workspace`, `--chapter`, `--intention`, `--timeout`, `--approve` ou `--reject` si besoin. + +## Etat auto-synchronise +## Etat auto-synchronise + +- dernier cycle automatise: 2026-03-09T06:53:02+00:00 +- reference locale actuelle: aucun accepted, meilleur diagnostic: apple-coreml:qwen2.5-0.5b-instruct-onnx +- prochain lot utile: Analyser les runs ayant atteint gate/repair puis resserrer la reference locale autour des meilleurs candidats. +- lancer un cycle: `python3 scripts/run_next_lots.py --lot full` +- checkpoint manuel en attente: Le runtime Apple sert `qwen2.5-0.5b-instruct-onnx` au lieu de `stateful-mistral7b-instruct-int4-coreml`. + diff --git a/TODO_ACTIVE.md b/TODO_ACTIVE.md new file mode 100644 index 0000000..2aa9c8e --- /dev/null +++ b/TODO_ACTIVE.md @@ -0,0 +1,74 @@ +# TODO actif - AI Novel Engine + +Source de verite des taches restantes pour `ai-novel-engine`. + +Regle: +- cocher ici ce qui est fait puis deplacer le lot livre vers `TODO_IMPLEMENTE.md` +- ne suivre ici que le travail restant ou les blocages encore ouverts +- garder les dependances `mascarade` explicites + +## Deja implemente +- [x] P0 Pipeline chapitre `intention -> structure -> draft -> critique -> rewrite -> validation -> memoire` +- [x] P0 Normalisation de chapitre avec identifiant canonique `chapitre_XX` et detection des collisions legacy +- [x] P0 Provider OpenAI-compatible et branchement local via `mascarade` +- [x] P0 Budgets par etape (`ANE_MAX_TOKENS_*`) +- [x] P0 Parsing JSON tolerant pour les sorties locales imparfaites +- [x] P0 Second passage de reessai pour `critique` et `memory` si le JSON reste invalide apres parsing tolerant +- [x] P0 Garde-fou manuscrit dur avant promotion (`gate_v1.json`, heuristiques locales, verdict `quality_blocked`) +- [x] P0 Boucle `repair` automatique entre `gate` et `quality_blocked`, avec artefacts `repair_vN.md`, fallback modele et preservation de `draft_v2.md` +- [x] P0 Smoke script local `scripts/smoke_local_generation.sh` +- [x] P0 Timeout provider remonte maintenant en `ProviderError` et marque correctement `failed_stage` dans `meta.json` +- [x] P0 Warm-up Apple du smoke remonte maintenant une erreur lisible au lieu d'une stacktrace brute +- [x] P0 Prompts `draft_v1` et `rewrite_v1` durcis pour imposer une prose continue sans titres ni puces +- [x] P1 Flags CLI non interactifs `--approve` et `--reject` +- [x] P1 `status` enrichi avec les chapitres en echec, en attente et bloques par garde-fou +- [x] P1 Resume de smoke humain a partir du `meta.json` +- [x] P1 Contrat cross-repo et recovery documentes via les runbooks +- [x] P2 `docs/vision.md`, `docs/roadmap.md` et le runbook local ne sont plus des placeholders +- [x] P0 Revalidation sous garde-fou de `ollama:qwen2.5:1.5b` -> `quality_blocked` +- [x] P0 Revalidation sous garde-fou de `apple-coreml:qwen2.5-0.5b-instruct-onnx` -> `quality_blocked` +- [x] P0 Revalidation sous garde-fou de `apple-coreml:qwen3.5-4b-onnx-q4f16` -> `provider_failed` en `rewrite` +- [x] P0 Revalidation sous garde-fou de `ollama:qwen2.5:7b` -> `provider_failed` par timeout en `draft` +- [x] P0 Comparatif local re-ecrit pour le protocole avec garde-fou dans `docs/MODEL_COMPARISON_2026-03-08.md` +- [x] P0 Revalidation reelle sous protocole `gate + repair` borne a `300s` par requete: + - `ollama:qwen2.5:1.5b` -> `failed` en `structure` + - `apple-coreml:qwen2.5-0.5b-instruct-onnx` -> `failed` en `rewrite` + - `apple-coreml:qwen3.5-4b-onnx-q4f16` -> `failed` en `rewrite` + - `ollama:qwen2.5:7b` -> `failed` en `rewrite` + +## Actif +- [ ] P0 Terminer le lot `baselines`, puis relancer `tracking_sync` sur un etat complet incluant `apple-coreml:qwen2.5-0.5b-instruct-onnx` et `ollama:qwen2.5:1.5b` +- [ ] P0 Faire passer `ollama:qwen2.5:7b` de `quality_blocked` a `accepted` en supprimant le residu `outline_like` apres `repair` +- [ ] P0 Faire terminer au moins un cycle `python3 scripts/run_next_lots.py --lot full` jusqu'a `tracking_sync` sans checkpoint manuel autre qu'un switch Apple explicite +- [ ] P1 Requalifier `apple-coreml:qwen3.5-4b-onnx-q4f16` comme reference Apple stable sur plusieurs cycles, pas sur un seul run accepte +- [ ] P1 Rendre la strategie de fallback `repair` consciente des modeles reellement servis: le runtime Apple n'expose qu'un `model_id` a la fois +- [ ] P1 Garder l'installation/staging Apple de `qwen2.5-0.5b-instruct-onnx`, `qwen3.5-4b-onnx-q4f16` et `stateful-mistral7b-instruct-int4-coreml` comme prerequis explicite + +## Bloque +- [ ] P1 `ollama:qwen2.5:7b` atteint maintenant `gate` et exerce `repair`, mais reste bloque sur `outline_like` apres deux passes +- [ ] P1 Le lot `baselines` exige encore un switch Apple explicite avant de pouvoir finir `apple-coreml:qwen2.5-0.5b-instruct-onnx` +- [ ] P1 `ollama:qwen2.5:1.5b` reste lent et reste a requalifier une fois `baselines` repris jusqu'au bout +- [ ] P1 `apple-coreml:stateful-mistral7b-instruct-int4-coreml` reste preflight-only sur cette machine: preflight froid `:8100` OK en 128 s, preflight chaud OK en 63 s, mais le smoke ANE est reste bloque a `structure` pendant plus de 8 minutes avec les budgets de smoke +- [ ] P1 Le host `ollama` natif 0.17.7 sur cette machine echoue sur `qwen2.5:1.5b` avec un crash Metal; la validation ANE reelle passe par un service Docker CPU expose sur `127.0.0.1:11435` et route via `mascarade` +- [ ] P1 Le runtime Apple local n'expose qu'un seul `model_id` a la fois; un fallback `repair` vers un autre modele Apple exige donc un switch de service entre deux runs, pas au milieu d'un smoke + +## Prochain ordre +- [ ] P0 Finir `python3 scripts/run_next_lots.py --lot baselines`, puis laisser `tracking_sync` recalculer les verdicts complets +- [ ] P0 Tuner `rewrite_v1` et la passe `repair` pour eliminer `outline_like` sur `ollama:qwen2.5:7b` +- [ ] P1 Rejouer ensuite `python3 scripts/run_next_lots.py --lot priority_models` pour verifier la stabilite de `apple-coreml:qwen3.5-4b-onnx-q4f16` et le sort de `ollama:qwen2.5:7b` +- [ ] P1 Garder `apple-coreml:qwen2.5-0.5b-instruct-onnx` et `ollama:qwen2.5:1.5b` comme baselines vitesse ou regressions tant qu'ils n'ont pas de verdict comparable au protocole courant +- [ ] P1 Verifier avant tout rerun Apple que `qwen2.5-0.5b-instruct-onnx`, `qwen3.5-4b-onnx-q4f16` et `stateful-mistral7b-instruct-int4-coreml` sont bien installes/stages et que le bon `model_id` est charge sur `:8201` + +## Auto-sync +## Auto-sync + +- dernier cycle automatique: 2026-03-09T06:53:02+00:00 +- modeles accepted: aucun +- modeles ayant atteint gate: apple-coreml:qwen2.5-0.5b-instruct-onnx, ollama:qwen2.5:1.5b +- quality_blocked: apple-coreml:qwen2.5-0.5b-instruct-onnx, ollama:qwen2.5:1.5b +- provider_failed: aucun +- prochain lot recommande: Analyser les runs ayant atteint gate/repair puis resserrer la reference locale autour des meilleurs candidats. +- checkpoint manuel en attente: Le runtime Apple sert `qwen2.5-0.5b-instruct-onnx` au lieu de `stateful-mistral7b-instruct-int4-coreml`. +- commande preparee: `bash scripts/prepare_runtime_step.sh --apple-model stateful-mistral7b-instruct-int4-coreml --resume-state /Users/electron/Documents/Projets_Creatifs/ai-novel-engine/automation/state/next_lots_state.json --ane-script /Users/electron/Documents/Projets_Creatifs/ai-novel-engine/scripts/run_next_lots.py` +- reprise: `python3 scripts/run_next_lots.py --resume /Users/electron/Documents/Projets_Creatifs/ai-novel-engine/automation/state/next_lots_state.json` + diff --git a/TODO_IMPLEMENTE.md b/TODO_IMPLEMENTE.md new file mode 100644 index 0000000..d4431b7 --- /dev/null +++ b/TODO_IMPLEMENTE.md @@ -0,0 +1,110 @@ +# TODO implemente - AI Novel Engine + +Snapshot append-only de ce qui est reellement livre. + +Regle: +- n'ajouter ici qu'un lot termine +- ne pas y laisser de travail restant +- renvoyer vers `TODO_ACTIVE.md` pour les suites et blocages + +## Deja implemente + +### Lot livre - 7 mars 2026 +- [x] Pipeline chapitre complet `intention -> structure -> draft -> critique -> rewrite -> validation -> memoire` +- [x] Artefacts standardises dans `structure/`, `brouillons/`, `manuscrit/`, `memoire/` +- [x] Normalisation des chapitres et detection explicite des collisions `chapitre_1` / `chapitre_01` +- [x] CLI `status`, `intention create`, `generate chapter --chapter XX` et alias `write --chapter XX` +- [x] Provider generique avec implementation OpenAI-compatible et configuration par variables d'environnement +- [x] Branchement local via `mascarade` en pointant `ANE_BASE_URL` vers `http://127.0.0.1:8100` +- [x] Budgets par etape avec `ANE_MAX_TOKENS_STRUCTURE`, `..._DRAFT`, `..._CRITIQUE`, `..._REWRITE`, `..._MEMORY` +- [x] Parsing JSON tolerant pour les etapes `critique` et `memory` +- [x] Second passage de reessai cible sur `critique` et `memory` quand la premiere reponse reste invalide +- [x] Prompts versionnes par etape dans `prompts/` +- [x] Metadonnees pipeline enrichies avec `stage_attempts`, `retry_stages`, `provider.*` et `last_status_message` +- [x] Ecriture immediate de l'etape en cours dans `meta.json` avant les appels provider pour rendre les hangs lisibles +- [x] CLI non interactive avec `--approve` et `--reject` +- [x] `status` enrichi pour les chapitres en echec et en attente +- [x] Smoke script local `scripts/smoke_local_generation.sh` branche sur la vraie CLI et avec warm-up Apple via `:8100` +- [x] Presets de smoke Apple plus conservateurs et timeout local plus large pour limiter les faux negatifs de warm-up +- [x] Runbook local ANE `docs/runbooks/LOCAL_GENERATION.md` +- [x] `docs/vision.md` et `docs/roadmap.md` remplaces par une doc exploitable +- [x] Suite unitaire `python3 -m unittest discover -s tests -v` verte sur l'etat livre + +### Lot livre - 8 mars 2026 +- [x] Validation locale `ollama` de bout en bout avec `ollama:qwen2.5:1.5b` via `mascarade` +- [x] Validation Apple locale de bout en bout avec `apple-coreml:qwen2.5-0.5b-instruct-onnx` +- [x] Validation Apple locale de bout en bout avec `apple-coreml:qwen3.5-4b-onnx-q4f16` +- [x] Comparatif local documente dans `docs/MODEL_COMPARISON_2026-03-08.md` +- [x] Runbook local ANE et `README` realignes sur les modeles reellement valides + +### Lot livre - 8 mars 2026 (garde-fou qualite) +- [x] Nouvelle etape `gate` entre `rewrite` et la validation auteur +- [x] Type `ManuscriptGateReport` et artefact `brouillons/chapitres/chapitre_XX/gate_v1.json` +- [x] Heuristiques bloquantes locales `too_short`, `truncated_ending`, `outline_like` +- [x] Budget provider `ANE_MAX_TOKENS_GATE` +- [x] `--approve` et la promotion manuscrit ne bypassent jamais le garde-fou +- [x] `meta.json`, `status` et le smoke exposent `quality_blockers`, `gate_report`, `gate_v1` et les chapitres `quality_blocked` +- [x] Revalidation du protocole qualite: + - `ollama:qwen2.5:1.5b` -> `quality_blocked` au garde-fou + - `apple-coreml:qwen2.5-0.5b-instruct-onnx` -> `quality_blocked` au garde-fou + - `apple-coreml:qwen3.5-4b-onnx-q4f16` -> `provider_failed` en `rewrite` + - `ollama:qwen2.5:7b` -> `provider_failed` par timeout client en `draft` +- [x] Suite unitaire `python3 -m unittest discover -s tests -v` verte avec 27 tests + +### Lot livre - 8 mars 2026 (durcissement prose) +- [x] Prompts `draft_v1` et `rewrite_v1` renforces pour interdire titres, puces et labels de plan visibles +- [x] Consignes explicites de prose continue, de scene jouee et de fin de phrase complete +- [x] Fix runtime cote `mascarade` avec `OLLAMA_TIMEOUT_SECONDS` configurable et timeout explicite +- [x] Rerun reel `ollama:qwen2.5:1.5b` complete a nouveau jusqu'au garde-fou (`499` mots), mais reste `quality_blocked` +- [x] Rerun reel `apple-coreml:qwen2.5-0.5b-instruct-onnx` complete jusqu'au garde-fou (`538` mots), mais reste `quality_blocked` + +### Lot livre - 8 mars 2026 (repair + reruns bornes) +- [x] Boucle `repair` automatique entre `gate` et `quality_blocked` +- [x] Preservation de `draft_v2.md` et ajout des artefacts `repair_vN.md` +- [x] Budget `ANE_MAX_TOKENS_REPAIR`, limite `ANE_REPAIR_MAX_PASSES` et override `ANE_REPAIR_FALLBACK_MODEL` +- [x] `meta.json`, `status` et le smoke exposent `repair_attempts`, `repair_models`, `repair_latest` et le brouillon final candidat +- [x] Timeout provider `urllib` remonte maintenant en `ProviderError`, ce qui marque correctement `failed_stage` +- [x] Le warm-up Apple du smoke remonte maintenant un message d'erreur lisible +- [x] Le fallback `repair` n'essaie plus automatiquement un autre modele `apple-coreml` au milieu d'un meme smoke; `qwen2.5-0.5b` bascule desormais vers un fallback non-Apple +- [x] Suite unitaire etendue a 34 tests verts +- [x] Reruns reels sous preset qualite borne a `300s` par requete: + - `ollama:qwen2.5:1.5b` -> `failed_stage=structure` + - `apple-coreml:qwen2.5-0.5b-instruct-onnx` -> `failed_stage=rewrite` + - `apple-coreml:qwen3.5-4b-onnx-q4f16` -> `failed_stage=rewrite` + - `ollama:qwen2.5:7b` -> `failed_stage=rewrite` +- [x] Conclusion du cycle: la boucle `repair` est livree et preparee; le goulot courant reste `rewrite` tant que les meilleurs candidats n'ont pas ete rejoues + +### Lot livre - 9 mars 2026 (automation des lots utiles) +- [x] Orchestrateur central `python3 scripts/run_next_lots.py` +- [x] Manifeste versionne `automation/next_lots.toml` +- [x] Etat de reprise local et rapports machines dans `automation/state/` et `automation/reports/` +- [x] Reutilisation des smokes existants `scripts/smoke_local_generation.sh` et `mascarade/scripts/smoke_openai_compat_ane.sh` +- [x] Synchronisation directe des plans/TODOs/readmes/runbooks dans des sections `AUTO-SYNC` +- [x] Helper `mascarade/scripts/ensure_apple_models.sh` pour verifier ou installer les trois modeles Apple requis +- [x] Helper `mascarade/scripts/prepare_runtime_step.sh` pour preparer les checkpoints semi-autos de restart/switch runtime +- [x] Attente courte sur `/models` apres un switch Apple pour eviter les faux checkpoints `aucun modele` +- [x] Couverture unitaire du manifeste, des checkpoints Apple, du rendu `AUTO-SYNC` et des helpers shell + +### Lot livre - 9 mars 2026 (priority_models automatise) +- [x] Cycle reel `python3 scripts/run_next_lots.py --lot priority_models` termine jusqu'a `tracking_sync` +- [x] `apple-coreml:qwen3.5-4b-onnx-q4f16` accepte de bout en bout sous protocole `gate + repair` +- [x] `ollama:qwen2.5:7b` atteint `gate`, exerce `repair` en live sur deux passes, puis finit `quality_blocked` avec `outline_like` +- [x] Le comparatif local, les TODOs, les README et les runbooks disposent maintenant d'un premier resultat `accepted` sous protocole courant + +## Actif +- [x] Aucun suivi actif ici. Voir `TODO_ACTIVE.md`. + +## Bloque +- [x] Aucun blocage suivi ici. Voir `TODO_ACTIVE.md`. + +## Prochain ordre +- [x] Mettre a jour ce fichier uniquement quand un nouveau lot est reellement termine. + +## Auto-sync +## Auto-sync + +- orchestrateur `scripts/run_next_lots.py` disponible +- manifeste `automation/next_lots.toml` charge +- derniers fichiers de suivi synchronisables via marqueurs `AUTO-SYNC` +- dernier cycle automatise observe: 2026-03-09T06:53:02+00:00 + diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/automation/next_lots.toml b/automation/next_lots.toml new file mode 100644 index 0000000..db66c64 --- /dev/null +++ b/automation/next_lots.toml @@ -0,0 +1,70 @@ +[paths] +mascarade_repo = "/Users/electron/mascarade" +core_base_url = "http://127.0.0.1:8100" +apple_runtime_url = "http://127.0.0.1:8201" +ollama_tags_url = "http://127.0.0.1:11435/api/tags" +apple_model_ready_timeout_seconds = 30 +apple_model_poll_interval_seconds = 2 + +[smoke] +chapter = "02" +intention = "Chapitre court. Une femme arrive dans une ville de nuit, trouve un indice simple, et finit sur une decision risquee. Style direct, phrases courtes, ton sobre." +timeout_seconds = 300 + +[preset] +ANE_PROVIDER = "openai_compatible" +ANE_BASE_URL = "http://127.0.0.1:8100" +ANE_TIMEOUT = "300" +ANE_MAX_TOKENS_STRUCTURE = "256" +ANE_MAX_TOKENS_DRAFT = "768" +ANE_MAX_TOKENS_CRITIQUE = "512" +ANE_MAX_TOKENS_REWRITE = "768" +ANE_MAX_TOKENS_GATE = "384" +ANE_MAX_TOKENS_REPAIR = "512" +ANE_MAX_TOKENS_MEMORY = "320" +ANE_REPAIR_MAX_PASSES = "2" + +[ensure_models] +apple_models = [ + "qwen2.5-0.5b-instruct-onnx", + "qwen3.5-4b-onnx-q4f16", + "stateful-mistral7b-instruct-int4-coreml", +] +ollama_models = [ + "qwen2.5:7b", + "qwen2.5:1.5b", +] + +[lots.priority_models] +models = [ + "apple-coreml:qwen3.5-4b-onnx-q4f16", + "ollama:qwen2.5:7b", +] + +[lots.baselines] +models = [ + "apple-coreml:qwen2.5-0.5b-instruct-onnx", + "ollama:qwen2.5:1.5b", +] + +[lots.preflight_only] +models = [ + "apple-coreml:stateful-mistral7b-instruct-int4-coreml", +] + +[tracking.ane] +todo_active = "TODO_ACTIVE.md" +todo_done = "TODO_IMPLEMENTE.md" +plan = "docs/EXECUTION_PLAN_2026-03-08.md" +comparison = "docs/MODEL_COMPARISON_2026-03-08.md" +readme = "README.md" +runbook = "docs/runbooks/LOCAL_GENERATION.md" + +[tracking.mascarade] +todo = "TODO_AI_NOVEL_ENGINE.md" +plan = "docs/EXECUTION_PLAN_2026-03-08.md" +readme = "README.md" +runbook = "docs/RUNBOOK_APPLE_LLM_LOCAL.md" + +[next_actions] +rewrite_compaction = "Compacter ou reduire `rewrite_v1` et ses budgets pour faire passer au moins `apple-coreml:qwen3.5-4b-onnx-q4f16` ou `ollama:qwen2.5:7b` jusqu'a `gate`." diff --git a/cli/main.py b/cli/main.py index 1c50fd7..cab20e6 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,8 +1,13 @@ -import sys -from pathlib import Path +from __future__ import annotations +import argparse +from pathlib import Path +import sys + +from core.chapters import ChapterConflictError, ChapterError, ChapterId, resolve_chapter_file +from core.generation.pipeline import GenerationPipeline +from core.generation.provider import ProviderConfigurationError, ProviderError from core.project.loader import ProjectState -from core.intention.gate import IntentionGate def cmd_status(root: Path): @@ -11,92 +16,237 @@ def cmd_status(root: Path): print("\nAI Novel Engine — Project Status\n") - if state["current_chapter"] is None: - print("📄 Aucun chapitre détecté.") + current = state["current_chapter"] + if current is None: + print("Chapitre courant : aucun") else: - print(f"📄 Chapitre courant : {state['current_chapter']}") + print(f"Chapitre courant : {current}") - print(f"📐 Structure présente : {state['has_structure']}") - print(f"🧠 Mémoire présente : {state['has_memory']}") - print("\n(Prochaine étape : définir une intention)\n") + print("\nDossiers:") + for label, key in ( + ("Structure", "structure"), + ("Brouillons", "drafts"), + ("Manuscrit", "manuscript"), + ("Memoire", "memory"), + ): + print(f"- {label:<10}: {state['directories'][key]}") + + latest_drafts = state["latest_drafts"] + if latest_drafts: + print("\nDerniers brouillons:") + for chapter_slug, draft_name in sorted(latest_drafts.items()): + print(f"- {chapter_slug}: {draft_name}") + else: + print("\nDerniers brouillons: aucun") + + latest_repairs = state["latest_repairs"] + if latest_repairs: + print("\nDernières réparations:") + for chapter_slug, repair_name in sorted(latest_repairs.items()): + print(f"- {chapter_slug}: {repair_name}") + else: + print("\nDernières réparations: aucune") + + failures = state["failed_chapters"] + if failures: + print("\nChapitres en échec:") + for item in failures: + retry_suffix = "" + if item["retry_stages"]: + retry_suffix = f" | réessais JSON: {', '.join(item['retry_stages'])}" + status_message = f" | message: {item['last_status_message']}" if item["last_status_message"] else "" + print( + f"- {item['chapter']}: status={item['status']} | failed_stage={item['failed_stage']} | meta={item['meta_path']}{retry_suffix}{status_message}" + ) + else: + print("\nChapitres en échec: aucun") + + quality_blocked = state["quality_blocked_chapters"] + if quality_blocked: + print("\nBloqués par garde-fou:") + for item in quality_blocked: + retry_suffix = "" + if item["retry_stages"]: + retry_suffix = f" | réessais JSON: {', '.join(item['retry_stages'])}" + blockers_suffix = "" + if item["quality_blockers"]: + blockers_suffix = f" | blockers: {', '.join(item['quality_blockers'])}" + repair_suffix = "" + if item["repair_attempts"]: + repair_models = ", ".join(item["repair_models"]) if item["repair_models"] else "provider_courant" + repair_suffix = f" | réparations: {item['repair_attempts']} ({repair_models})" + status_message = f" | message: {item['last_status_message']}" if item["last_status_message"] else "" + print( + f"- {item['chapter']}: status={item['status']} | failed_stage={item['failed_stage']} | brouillon={item['draft_path']} | gate={item['gate_path']} | meta={item['meta_path']}{blockers_suffix}{repair_suffix}{retry_suffix}{status_message}" + ) + else: + print("\nBloqués par garde-fou: aucun") + + awaiting_acceptance = state["awaiting_acceptance"] + if awaiting_acceptance: + print("\nEn attente de validation:") + for item in awaiting_acceptance: + retry_suffix = "" + if item["retry_stages"]: + retry_suffix = f" | réessais JSON: {', '.join(item['retry_stages'])}" + repair_suffix = "" + if item["repair_attempts"]: + repair_models = ", ".join(item["repair_models"]) if item["repair_models"] else "provider_courant" + repair_suffix = f" | réparations: {item['repair_attempts']} ({repair_models})" + status_message = f" | message: {item['last_status_message']}" if item["last_status_message"] else "" + print( + f"- {item['chapter']}: status={item['status']} | brouillon={item['draft_path']} | critique={item['critique_path']} | gate={item['gate_path']}{repair_suffix}{retry_suffix}{status_message}" + ) + else: + print("\nEn attente de validation: aucun") + + print("") + return 0 -def cmd_intention_create(root: Path): +def cmd_intention_create(root: Path, chapter_value: str | None = None, input_func=input): intentions_dir = root / "notes" / "intentions" intentions_dir.mkdir(parents=True, exist_ok=True) - chap = input("Numéro du chapitre (ex: 08) : ").strip() - if not chap: - print("❌ Numéro de chapitre requis.") - return + raw_chapter = chapter_value or input_func("Numéro du chapitre (ex: 08) : ").strip() + chapter = ChapterId.parse(raw_chapter) - path = intentions_dir / f"chapitre_{chap}.md" + path = resolve_chapter_file(intentions_dir, chapter) if path.exists(): - print(f"⚠️ Une intention existe déjà : {path}") - return + print(f"Une intention existe déjà : {path}") + return 1 print("\nDécris l’intention (finir par Ctrl+D / Ctrl+Z):\n") lines = [] try: while True: - lines.append(input()) + lines.append(input_func("")) except EOFError: pass - content = "\n".join(lines).strip() + content = "\n".join(line for line in lines if line is not None).strip() if not content: - print("❌ Intention vide. Annulé.") - return + print("Intention vide. Annulé.") + return 1 - path.write_text( - f"# Intention — Chapitre {chap}\n\n{content}\n", - encoding="utf-8" + canonical_path = intentions_dir / chapter.filename + canonical_path.write_text( + f"# Intention — Chapitre {chapter.label}\n\n{content}\n", + encoding="utf-8", ) - print(f"✅ Intention créée : {path}\n") + print(f"Intention créée : {canonical_path}\n") + return 0 -def cmd_write(root: Path): - gate = IntentionGate(root) +def _approval_callback_from_flags(force_accept: bool | None): + if force_accept is None: + return None + return lambda _report, _path: force_accept + + +def cmd_generate_chapter( + root: Path, + chapter_value: str, + provider=None, + input_func=input, + *, + force_accept: bool | None = None, +): + pipeline = GenerationPipeline(root, provider=provider, input_func=input_func) + outcome = pipeline.generate_chapter( + chapter_value, + approval_callback=_approval_callback_from_flags(force_accept), + ) + + print("") + print(f"Chapitre traité : {outcome.chapter_id.slug}") + print(f"Statut : {outcome.status}") + print(f"Brouillon final : {outcome.draft_path}") + print(f"Critique : {outcome.critique_path}") + print(f"Garde-fou : {outcome.gate_path}") + print(f"Métadonnées : {outcome.meta_path}") + if outcome.accepted and outcome.manuscript_path is not None: + print(f"Manuscrit : {outcome.manuscript_path}") + else: + print("Manuscrit : non promu") + if outcome.quality_blockers: + print(f"Blockers : {', '.join(outcome.quality_blockers)}") + print("") + return 0 + + +def _add_approval_flags(parser: argparse.ArgumentParser) -> None: + group = parser.add_mutually_exclusive_group() + group.add_argument("--approve", action="store_true", help="Promouvoir sans confirmation interactive.") + group.add_argument("--reject", action="store_true", help="Refuser sans confirmation interactive.") + + +def _force_accept_from_namespace(namespace: argparse.Namespace) -> bool | None: + if getattr(namespace, "approve", False): + return True + if getattr(namespace, "reject", False): + return False + return None + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python3 -m cli.main") + subparsers = parser.add_subparsers(dest="command") + + subparsers.add_parser("status") + + intention_parser = subparsers.add_parser("intention") + intention_subparsers = intention_parser.add_subparsers(dest="intention_command") + intention_create = intention_subparsers.add_parser("create") + intention_create.add_argument("--chapter") + + generate_parser = subparsers.add_parser("generate") + generate_subparsers = generate_parser.add_subparsers(dest="generate_target") + generate_chapter = generate_subparsers.add_parser("chapter") + generate_chapter.add_argument("--chapter", required=True) + _add_approval_flags(generate_chapter) + + write_parser = subparsers.add_parser("write") + write_parser.add_argument("--chapter", required=True) + _add_approval_flags(write_parser) + + return parser + + +def main(argv: list[str] | None = None, root: Path | None = None): + args = argv if argv is not None else sys.argv[1:] + project_root = root or Path.cwd() + parser = build_parser() + namespace = parser.parse_args(args) + + if not args or namespace.command == "status": + return cmd_status(project_root) try: - gate.assert_intention() - except RuntimeError as e: - print("\n⛔ ÉCRITURE BLOQUÉE\n") - print(str(e)) - print("\n➡️ Utilise : python3 -m cli.main intention create\n") - return + if namespace.command == "intention" and namespace.intention_command == "create": + return cmd_intention_create(project_root, chapter_value=namespace.chapter) - print("\n✅ Intention détectée.") - print("✍️ Écriture autorisée (génération non implémentée).\n") + if namespace.command == "generate" and namespace.generate_target == "chapter": + return cmd_generate_chapter( + project_root, + namespace.chapter, + force_accept=_force_accept_from_namespace(namespace), + ) + if namespace.command == "write": + return cmd_generate_chapter( + project_root, + namespace.chapter, + force_accept=_force_accept_from_namespace(namespace), + ) + except (ChapterError, ChapterConflictError, RuntimeError, ProviderConfigurationError, ProviderError) as exc: + print(f"\nErreur: {exc}\n") + return 1 -def main(): - root = Path.cwd() - - # Aucun argument → status - if len(sys.argv) == 1: - cmd_status(root) - return - - # intention create - if sys.argv[1] == "intention" and len(sys.argv) >= 3: - if sys.argv[2] == "create": - cmd_intention_create(root) - return - - # write - if sys.argv[1] == "write": - cmd_write(root) - return - - # aide - print("\nCommande inconnue.\n") - print("Commandes disponibles :") - print(" python3 -m cli.main → status") - print(" python3 -m cli.main intention create") - print(" python3 -m cli.main write\n") + parser.print_help() + return 1 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/core/chapters.py b/core/chapters.py new file mode 100644 index 0000000..692a3d3 --- /dev/null +++ b/core/chapters.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import re + + +_CHAPTER_PATTERN = re.compile(r"^chapitre_(\d+)$", re.IGNORECASE) +_DIGITS_PATTERN = re.compile(r"^\d+$") + + +class ChapterError(ValueError): + """Raised when a chapter identifier is invalid.""" + + +class ChapterConflictError(ChapterError): + """Raised when both canonical and legacy files exist for the same chapter.""" + + def __init__(self, chapter: "ChapterId", paths: list[Path]): + joined = ", ".join(str(path) for path in paths) + super().__init__( + f"Conflit de chapitre pour {chapter.slug}: plusieurs fichiers existent ({joined})." + ) + self.chapter = chapter + self.paths = paths + + +@dataclass(frozen=True, order=True) +class ChapterId: + number: int + + def __post_init__(self): + if self.number <= 0: + raise ChapterError("Le numéro de chapitre doit être strictement positif.") + + @classmethod + def parse(cls, value: object) -> "ChapterId": + return cls(parse_chapter_number(value)) + + @property + def label(self) -> str: + return f"{self.number:02d}" + + @property + def slug(self) -> str: + return f"chapitre_{self.label}" + + @property + def filename(self) -> str: + return f"{self.slug}.md" + + def __str__(self) -> str: + return self.slug + + +def parse_chapter_number(value: object) -> int: + if isinstance(value, ChapterId): + return value.number + + if isinstance(value, int) and not isinstance(value, bool): + return value + + if isinstance(value, Path): + text = value.stem + else: + text = str(value).strip() + + if not text: + raise ChapterError("Numéro de chapitre requis.") + + candidate = text + if "/" in candidate or "\\" in candidate or candidate.endswith(".md"): + candidate = Path(candidate).stem + + match = _CHAPTER_PATTERN.fullmatch(candidate) + if match: + return int(match.group(1)) + + if _DIGITS_PATTERN.fullmatch(candidate): + return int(candidate) + + raise ChapterError(f"Identifiant de chapitre invalide: {value!r}") + + +def discover_chapter_files(directory: Path) -> list[tuple[ChapterId, Path]]: + if not directory.exists(): + return [] + + discovered: list[tuple[ChapterId, Path]] = [] + for path in sorted(directory.glob("chapitre_*.md")): + try: + chapter = ChapterId.parse(path.stem) + except ChapterError: + continue + discovered.append((chapter, path)) + return discovered + + +def discover_chapter_dirs(directory: Path) -> list[tuple[ChapterId, Path]]: + if not directory.exists(): + return [] + + discovered: list[tuple[ChapterId, Path]] = [] + for path in sorted(directory.glob("chapitre_*")): + if not path.is_dir(): + continue + try: + chapter = ChapterId.parse(path.name) + except ChapterError: + continue + discovered.append((chapter, path)) + return discovered + + +def collect_matching_chapter_files(directory: Path, chapter: ChapterId) -> list[Path]: + paths = [path for candidate, path in discover_chapter_files(directory) if candidate == chapter] + unique_paths = sorted(set(paths)) + return unique_paths + + +def resolve_chapter_file(directory: Path, chapter: ChapterId) -> Path: + matches = collect_matching_chapter_files(directory, chapter) + if len(matches) > 1: + raise ChapterConflictError(chapter, matches) + if matches: + return matches[0] + return directory / chapter.filename diff --git a/core/generation/__init__.py b/core/generation/__init__.py new file mode 100644 index 0000000..d475a51 --- /dev/null +++ b/core/generation/__init__.py @@ -0,0 +1 @@ +"""Generation pipeline primitives.""" diff --git a/core/generation/models.py b/core/generation/models.py new file mode 100644 index 0000000..2e637d2 --- /dev/null +++ b/core/generation/models.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import json +from pathlib import Path +import re + +from core.chapters import ChapterId + + +def _strip_code_fence(text: str) -> str: + payload = text.strip() + if not payload.startswith("```"): + return payload + lines = payload.splitlines() + if len(lines) >= 3 and lines[-1].strip() == "```": + return "\n".join(lines[1:-1]).strip() + return payload + + +def _remove_trailing_commas(payload: str) -> str: + return re.sub(r",(\s*[}\]])", r"\1", payload) + + +def _extract_json_object(payload: str) -> str | None: + start = payload.find("{") + if start == -1: + return None + + depth = 0 + in_string = False + escaped = False + for index in range(start, len(payload)): + char = payload[index] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return payload[start : index + 1] + return payload[start:] + + +def _close_json_delimiters(payload: str) -> str: + stack: list[str] = [] + in_string = False + escaped = False + for char in payload: + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + elif char == "{": + stack.append("}") + elif char == "[": + stack.append("]") + elif char in {"}", "]"} and stack and char == stack[-1]: + stack.pop() + + repaired = payload.rstrip() + if repaired.endswith(","): + repaired = repaired[:-1].rstrip() + if in_string: + repaired += '"' + return repaired + "".join(reversed(stack)) + + +def _json_candidates(text: str) -> list[str]: + payload = _strip_code_fence(text) + candidates = [payload] + + extracted = _extract_json_object(payload) + if extracted and extracted not in candidates: + candidates.append(extracted) + + repaired: list[str] = [] + for candidate in list(candidates): + trimmed = _remove_trailing_commas(candidate) + if trimmed not in candidates and trimmed not in repaired: + repaired.append(trimmed) + closed = _close_json_delimiters(trimmed) + if closed not in candidates and closed not in repaired: + repaired.append(closed) + candidates.extend(repaired) + return candidates + + +def _parse_json_object(text: str) -> dict[str, object]: + last_error: Exception | None = None + for candidate in _json_candidates(text): + try: + data = json.loads(candidate) + except json.JSONDecodeError as exc: + last_error = exc + continue + if not isinstance(data, dict): + raise ValueError("La réponse JSON attendue doit être un objet.") + return data + raise ValueError(str(last_error) if last_error else "Réponse JSON illisible.") + + +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 _record_list(value: object, required_key: str) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + + normalized: list[dict[str, str]] = [] + for item in value: + if not isinstance(item, dict): + continue + record = {str(key): str(val).strip() for key, val in item.items() if str(val).strip()} + if record.get(required_key): + normalized.append(record) + return normalized + + +@dataclass(frozen=True) +class StructurePlan: + chapter_id: ChapterId + markdown: str + + +@dataclass(frozen=True) +class ControlReport: + summary: str + deviations: list[str] + recommendations: list[str] + rewrite_required: bool + raw: dict[str, object] = field(default_factory=dict) + + @classmethod + def from_response_text(cls, text: str) -> "ControlReport": + raw = _parse_json_object(text) + summary = str(raw.get("summary", "")).strip() or "Aucun résumé fourni." + deviations = _string_list(raw.get("deviations")) + recommendations = _string_list(raw.get("recommendations")) + rewrite_required = bool(raw.get("rewrite_required", deviations or recommendations)) + return cls( + summary=summary, + deviations=deviations, + recommendations=recommendations, + rewrite_required=rewrite_required, + raw=raw, + ) + + def to_dict(self) -> dict[str, object]: + return { + "summary": self.summary, + "deviations": list(self.deviations), + "recommendations": list(self.recommendations), + "rewrite_required": self.rewrite_required, + } + + def to_markdown(self, chapter_id: ChapterId) -> str: + verdict = "oui" if self.rewrite_required else "non" + deviations = "\n".join(f"- {item}" for item in self.deviations) or "- Aucun écart majeur." + recommendations = "\n".join(f"- {item}" for item in self.recommendations) or "- Aucune recommandation." + return ( + f"# Critique — {chapter_id.slug}\n\n" + f"## Résumé\n{self.summary}\n\n" + f"## Réécriture requise\n{verdict}\n\n" + f"## Écarts\n{deviations}\n\n" + f"## Recommandations\n{recommendations}\n" + ) + + +@dataclass(frozen=True) +class MemoryUpdate: + chapter_summary: str + characters: list[dict[str, str]] + locations: list[dict[str, str]] + timeline_events: list[dict[str, str]] + raw: dict[str, object] = field(default_factory=dict) + + @classmethod + def from_response_text(cls, text: str) -> "MemoryUpdate": + raw = _parse_json_object(text) + chapter_summary = str(raw.get("summary", "")).strip() or "Résumé indisponible." + characters = _record_list(raw.get("characters"), "name") + locations = _record_list(raw.get("locations"), "name") + timeline_events = _record_list(raw.get("timeline_events"), "event") + return cls( + chapter_summary=chapter_summary, + characters=characters, + locations=locations, + timeline_events=timeline_events, + raw=raw, + ) + + def to_dict(self) -> dict[str, object]: + return { + "summary": self.chapter_summary, + "characters": list(self.characters), + "locations": list(self.locations), + "timeline_events": list(self.timeline_events), + } + + +@dataclass(frozen=True) +class ManuscriptGateReport: + ready_for_manuscript: bool + summary: str + blockers: list[str] + recommendations: list[str] + heuristic_blockers: list[str] + raw: dict[str, object] = field(default_factory=dict) + + @classmethod + def from_response_text(cls, text: str) -> "ManuscriptGateReport": + raw = _parse_json_object(text) + blockers = _string_list(raw.get("blockers")) + heuristic_blockers = _string_list(raw.get("heuristic_blockers")) + recommendations = _string_list(raw.get("recommendations")) + ready_default = not blockers and not heuristic_blockers + ready_for_manuscript = bool(raw.get("ready_for_manuscript", ready_default)) + summary = str(raw.get("summary", "")).strip() or "Diagnostic manuscrit indisponible." + return cls( + ready_for_manuscript=ready_for_manuscript and not blockers and not heuristic_blockers, + summary=summary, + blockers=blockers, + recommendations=recommendations, + heuristic_blockers=heuristic_blockers, + raw=raw, + ) + + @classmethod + def from_heuristics( + cls, + *, + blockers: list[str], + recommendations: list[str], + summary: str, + ) -> "ManuscriptGateReport": + return cls( + ready_for_manuscript=False, + summary=summary, + blockers=list(blockers), + recommendations=list(recommendations), + heuristic_blockers=list(blockers), + raw={}, + ) + + def all_blockers(self) -> list[str]: + ordered: list[str] = [] + for value in [*self.heuristic_blockers, *self.blockers]: + if value not in ordered: + ordered.append(value) + return ordered + + def to_dict(self) -> dict[str, object]: + return { + "ready_for_manuscript": self.ready_for_manuscript, + "summary": self.summary, + "blockers": list(self.blockers), + "recommendations": list(self.recommendations), + "heuristic_blockers": list(self.heuristic_blockers), + } + + +@dataclass(frozen=True) +class GenerationContext: + root: Path + chapter_id: ChapterId + intention_path: Path + intention_text: str + structure_path: Path + draft_dir: Path + draft_v1_path: Path + critique_path: Path + draft_v2_path: Path + gate_path: Path + meta_path: Path + manuscript_path: Path + memory_summary_path: Path + memory_index_dir: Path + story_context: str + + def repair_path(self, attempt: int) -> Path: + return self.draft_dir / f"repair_v{attempt}.md" + + +@dataclass(frozen=True) +class GenerationOutcome: + chapter_id: ChapterId + accepted: bool + status: str + draft_path: Path + critique_path: Path + gate_path: Path + meta_path: Path + manuscript_path: Path | None + quality_blockers: list[str] = field(default_factory=list) diff --git a/core/generation/pipeline.py b/core/generation/pipeline.py new file mode 100644 index 0000000..5ad4dba --- /dev/null +++ b/core/generation/pipeline.py @@ -0,0 +1,953 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import re +from typing import Callable, TypeVar + +from core.chapters import ChapterId, resolve_chapter_file +from core.generation.models import ( + ControlReport, + GenerationContext, + GenerationOutcome, + ManuscriptGateReport, + MemoryUpdate, + StructurePlan, +) +from core.generation.provider import ( + clone_provider_with_model, + GenerationProvider, + GenerationRequest, + OpenAICompatibleProvider, + ProviderError, + build_provider_from_env, +) +from core.intention.gate import IntentionGate +from core.prompts import PromptStore + + +ApprovalCallback = Callable[[ControlReport, Path], bool] +OutputCallback = Callable[[str], None] +ParsedStagePayload = TypeVar("ParsedStagePayload") + + +class GenerationPipeline: + def __init__( + self, + root: Path, + provider: GenerationProvider | None = None, + prompt_store: PromptStore | None = None, + input_func: Callable[[str], str] = input, + output_func: OutputCallback = print, + ): + self.root = root + self.provider = provider + self.prompt_store = prompt_store or PromptStore(root) + self.input_func = input_func + self.output_func = output_func + self.intention_gate = IntentionGate(root) + + def generate_chapter( + self, + chapter: object, + approval_callback: ApprovalCallback | None = None, + ) -> GenerationOutcome: + chapter_id = ChapterId.parse(chapter) + self.intention_gate.assert_intention(chapter_id) + context = self._build_context(chapter_id) + provider = self.provider or build_provider_from_env() + metadata = self._initial_metadata(context, provider) + self._write_metadata(context.meta_path, metadata) + + structure_plan: StructurePlan | None = None + draft_v1: str | None = None + control_report: ControlReport | None = None + draft_v2: str | None = None + gate_report: ManuscriptGateReport | None = None + current_candidate_text: str | None = None + current_candidate_path: Path = context.draft_v2_path + current_stage = "setup" + + try: + 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) + + 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) + + 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) + + 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) + current_candidate_text = draft_v2 + current_candidate_path = context.draft_v2_path + + current_stage = "gate" + gate_report = self._generate_manuscript_gate_report( + provider, + context, + structure_plan, + current_candidate_text, + metadata, + ) + self._persist_gate_report(metadata, context, gate_report, current_candidate_path) + self._complete_stage(metadata, current_stage) + self._write_metadata(context.meta_path, metadata) + + if not gate_report.ready_for_manuscript: + current_candidate_text, current_candidate_path, gate_report = self._repair_until_ready( + provider=provider, + context=context, + structure_plan=structure_plan, + current_candidate_text=current_candidate_text, + current_candidate_path=current_candidate_path, + gate_report=gate_report, + metadata=metadata, + ) + if not gate_report.ready_for_manuscript: + self._set_status(metadata, "quality_blocked", "Promotion bloquée par le garde-fou manuscrit.") + metadata["failed_stage"] = current_stage + metadata["finished_at"] = self._timestamp() + self._write_metadata(context.meta_path, metadata) + return GenerationOutcome( + chapter_id=chapter_id, + accepted=False, + status="quality_blocked", + draft_path=current_candidate_path, + critique_path=context.critique_path, + gate_path=context.gate_path, + meta_path=context.meta_path, + manuscript_path=None, + quality_blockers=gate_report.all_blockers(), + ) + + self._set_status(metadata, "awaiting_acceptance", "Brouillon final prêt pour validation.") + self._write_metadata(context.meta_path, metadata) + + accepted = ( + approval_callback(control_report, current_candidate_path) + if approval_callback is not None + else self._prompt_for_acceptance(control_report, current_candidate_path) + ) + metadata["accepted"] = accepted + + if not accepted: + self._set_status(metadata, "rejected", "Promotion refusée par l'auteur.") + metadata["finished_at"] = self._timestamp() + self._write_metadata(context.meta_path, metadata) + return GenerationOutcome( + chapter_id=chapter_id, + accepted=False, + status="rejected", + draft_path=current_candidate_path, + critique_path=context.critique_path, + gate_path=context.gate_path, + meta_path=context.meta_path, + manuscript_path=None, + quality_blockers=gate_report.all_blockers() if gate_report is not None else [], + ) + + current_stage = "memory" + self._write_text(context.manuscript_path, current_candidate_text) + self._set_status( + metadata, + "manuscript_promoted", + "Brouillon promu dans le manuscrit, mise à jour mémoire en cours.", + ) + self._write_metadata(context.meta_path, metadata) + memory_update = self._generate_memory_update(provider, context, current_candidate_text, metadata) + self._persist_memory(context, memory_update) + self._complete_stage(metadata, current_stage) + self._set_status(metadata, "accepted", "Chapitre accepté et mémoire mise à jour.") + metadata["finished_at"] = self._timestamp() + metadata["memory_update"] = memory_update.to_dict() + self._write_metadata(context.meta_path, metadata) + + return GenerationOutcome( + chapter_id=chapter_id, + accepted=True, + status="accepted", + draft_path=current_candidate_path, + critique_path=context.critique_path, + gate_path=context.gate_path, + meta_path=context.meta_path, + manuscript_path=context.manuscript_path, + quality_blockers=[], + ) + except ProviderError as exc: + failed_stage = self._current_running_stage(metadata, current_stage) + self._set_status(metadata, "failed", f"Échec à l'étape {failed_stage}: {exc}") + metadata["failed_stage"] = failed_stage + metadata["error"] = str(exc) + metadata["finished_at"] = self._timestamp() + self._write_metadata(context.meta_path, metadata) + raise + except ValueError as exc: + failed_stage = self._current_running_stage(metadata, current_stage) + self._set_status(metadata, "failed", f"Échec à l'étape {failed_stage}: {exc}") + metadata["failed_stage"] = failed_stage + metadata["error"] = str(exc) + metadata["finished_at"] = self._timestamp() + self._write_metadata(context.meta_path, metadata) + raise ProviderError(str(exc)) from exc + + def _build_context(self, chapter_id: ChapterId) -> GenerationContext: + intention_path = self.intention_gate.resolve_intention_path(chapter_id) + if intention_path is None: + raise RuntimeError(f"Aucune intention trouvée pour {chapter_id.slug}.") + + structure_path = resolve_chapter_file(self.root / "structure" / "chapitres", chapter_id) + manuscript_path = resolve_chapter_file(self.root / "manuscrit", chapter_id) + memory_summary_path = resolve_chapter_file(self.root / "memoire" / "chapitres", chapter_id) + draft_dir = self.root / "brouillons" / "chapitres" / chapter_id.slug + + return GenerationContext( + root=self.root, + chapter_id=chapter_id, + intention_path=intention_path, + intention_text=intention_path.read_text(encoding="utf-8").strip(), + structure_path=structure_path, + draft_dir=draft_dir, + draft_v1_path=draft_dir / "draft_v1.md", + critique_path=draft_dir / "critique_v1.md", + draft_v2_path=draft_dir / "draft_v2.md", + gate_path=draft_dir / "gate_v1.json", + meta_path=draft_dir / "meta.json", + manuscript_path=manuscript_path, + memory_summary_path=memory_summary_path, + memory_index_dir=self.root / "memoire" / "index", + story_context=self._build_story_context(chapter_id), + ) + + def _build_story_context(self, chapter_id: ChapterId) -> str: + sections: list[str] = [] + + previous_manuscript = self._latest_existing_file(self.root / "manuscrit", chapter_id.number - 1) + if previous_manuscript is not None: + sections.append( + "## Dernier chapitre accepté\n" + f"Fichier: {previous_manuscript.name}\n" + f"{self._read_excerpt(previous_manuscript)}" + ) + + previous_memory = self._latest_existing_file(self.root / "memoire" / "chapitres", chapter_id.number - 1) + if previous_memory is not None: + sections.append( + "## Dernier résumé mémoire\n" + f"Fichier: {previous_memory.name}\n" + f"{self._read_excerpt(previous_memory)}" + ) + + for name in ("personnages.json", "lieux.json", "chronologie.json"): + path = self.root / "memoire" / "index" / name + if path.exists(): + sections.append(f"## {name}\n{self._read_excerpt(path, limit=2000)}") + + if not sections: + return "Aucun contexte projet disponible." + + return "\n\n".join(sections) + + def _latest_existing_file(self, directory: Path, max_number: int) -> Path | None: + if max_number <= 0 or not directory.exists(): + return None + + candidates: list[Path] = [] + for number in range(max_number, 0, -1): + path = resolve_chapter_file(directory, ChapterId(number)) + if path.exists(): + candidates.append(path) + break + return candidates[0] if candidates else None + + def _read_excerpt(self, path: Path, limit: int = 4000) -> str: + text = path.read_text(encoding="utf-8").strip() + if len(text) <= limit: + return text + return f"{text[:limit].rstrip()}\n[...]" + + def _generate_structure( + self, + provider: GenerationProvider, + context: GenerationContext, + metadata: dict[str, object], + ) -> StructurePlan: + prompt = self.prompt_store.render( + "structure", + chapter_slug=context.chapter_id.slug, + intention=context.intention_text, + story_context=context.story_context, + ) + self._begin_stage( + metadata, + context.meta_path, + "structure", + "Génération de la structure en cours.", + ) + response = provider.generate(GenerationRequest(stage="structure", prompt=prompt)) + markdown = response.content.strip() + if not markdown: + raise ProviderError("Le provider a renvoyé une structure vide.") + return StructurePlan(chapter_id=context.chapter_id, markdown=f"{markdown}\n") + + def _generate_draft( + self, + provider: GenerationProvider, + context: GenerationContext, + structure_plan: StructurePlan, + metadata: dict[str, object], + ) -> str: + prompt = self.prompt_store.render( + "draft", + chapter_slug=context.chapter_id.slug, + intention=context.intention_text, + structure_markdown=structure_plan.markdown, + story_context=context.story_context, + ) + self._begin_stage( + metadata, + context.meta_path, + "draft", + "Génération du brouillon initial en cours.", + ) + response = provider.generate(GenerationRequest(stage="draft", prompt=prompt, temperature=0.4)) + draft = response.content.strip() + if not draft: + raise ProviderError("Le provider a renvoyé un brouillon vide.") + return f"{draft}\n" + + def _generate_control_report( + self, + provider: GenerationProvider, + context: GenerationContext, + structure_plan: StructurePlan, + draft_v1: str, + metadata: dict[str, object], + ) -> ControlReport: + prompt = self.prompt_store.render( + "critique", + chapter_slug=context.chapter_id.slug, + intention=context.intention_text, + structure_markdown=structure_plan.markdown, + draft_markdown=draft_v1, + ) + return self._generate_json_payload( + provider=provider, + stage="critique", + prompt=prompt, + retry_prompt_name="critique_retry", + parse_response=ControlReport.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_v1, + }, + ) + + def _rewrite_draft( + self, + provider: GenerationProvider, + context: GenerationContext, + structure_plan: StructurePlan, + draft_v1: str, + control_report: ControlReport, + metadata: dict[str, object], + ) -> str: + prompt = self.prompt_store.render( + "rewrite", + chapter_slug=context.chapter_id.slug, + intention=context.intention_text, + structure_markdown=structure_plan.markdown, + draft_markdown=draft_v1, + critique_json=control_report.to_dict(), + ) + self._begin_stage( + metadata, + context.meta_path, + "rewrite", + "Réécriture guidée par la critique en cours.", + ) + response = provider.generate(GenerationRequest(stage="rewrite", prompt=prompt, temperature=0.3)) + draft = response.content.strip() + if not draft: + raise ProviderError("Le provider a renvoyé une réécriture vide.") + return f"{draft}\n" + + def _repair_until_ready( + self, + *, + provider: GenerationProvider, + context: GenerationContext, + structure_plan: StructurePlan, + current_candidate_text: str, + current_candidate_path: Path, + gate_report: ManuscriptGateReport, + metadata: dict[str, object], + ) -> tuple[str, Path, ManuscriptGateReport]: + for attempt in range(1, self._repair_max_passes() + 1): + repair_model = self._repair_model_for_attempt(provider, attempt) + repair_provider = self._provider_for_repair(provider, repair_model) + repaired_text = self._repair_draft( + provider=repair_provider, + context=context, + structure_plan=structure_plan, + current_candidate=current_candidate_text, + gate_report=gate_report, + metadata=metadata, + attempt=attempt, + repair_model=repair_model, + ) + repair_path = context.repair_path(attempt) + self._write_text(repair_path, repaired_text) + self._complete_stage(metadata, "repair") + self._record_repair_attempt(metadata, attempt=attempt, model=self._provider_model_name(repair_provider) or repair_model, path=repair_path) + self._set_status( + metadata, + "repair_ready", + f"Réparation prose v{attempt} générée, nouveau contrôle manuscrit en cours.", + ) + metadata["draft_final"] = str(repair_path) + self._write_metadata(context.meta_path, metadata) + + current_candidate_text = repaired_text + current_candidate_path = repair_path + gate_report = self._generate_manuscript_gate_report( + repair_provider, + context, + structure_plan, + current_candidate_text, + metadata, + ) + self._persist_gate_report(metadata, context, gate_report, current_candidate_path) + self._complete_stage(metadata, "gate") + self._write_metadata(context.meta_path, metadata) + if gate_report.ready_for_manuscript: + break + + return current_candidate_text, current_candidate_path, gate_report + + def _repair_draft( + self, + *, + provider: GenerationProvider, + context: GenerationContext, + structure_plan: StructurePlan, + current_candidate: str, + gate_report: ManuscriptGateReport, + metadata: dict[str, object], + attempt: int, + repair_model: str | None, + ) -> str: + prompt = self.prompt_store.render( + "repair", + chapter_slug=context.chapter_id.slug, + intention=context.intention_text, + structure_markdown=structure_plan.markdown, + draft_markdown=current_candidate, + gate_json=gate_report.to_dict(), + repair_attempt=attempt, + repair_model=repair_model or "", + story_context=context.story_context, + ) + model_label = repair_model or self._provider_model_name(provider) or "provider_courant" + self._begin_stage( + metadata, + context.meta_path, + "repair", + f"Réparation prose v{attempt} en cours avec {model_label}.", + ) + response = provider.generate(GenerationRequest(stage="repair", prompt=prompt, temperature=0.2)) + repaired = response.content.strip() + if not repaired: + raise ProviderError("Le provider a renvoyé une réparation vide.") + return f"{repaired}\n" + + def _generate_manuscript_gate_report( + self, + provider: GenerationProvider, + context: GenerationContext, + structure_plan: StructurePlan, + draft_v2: str, + metadata: dict[str, object], + ) -> ManuscriptGateReport: + self._begin_stage( + metadata, + context.meta_path, + "gate", + "Contrôle manuscrit en cours.", + ) + heuristic_report = self._heuristic_gate_report(draft_v2) + if heuristic_report is not None: + metadata["last_status_message"] = heuristic_report.summary + return 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, + ) + + def _persist_gate_report( + self, + metadata: dict[str, object], + context: GenerationContext, + gate_report: ManuscriptGateReport, + draft_path: Path, + ) -> None: + self._write_json(context.gate_path, gate_report.to_dict()) + metadata["gate_report"] = gate_report.to_dict() + metadata["quality_blockers"] = gate_report.all_blockers() + metadata["draft_final"] = str(draft_path) + + def _provider_for_repair(self, provider: GenerationProvider, model: str | None) -> GenerationProvider: + if not model: + return provider + return clone_provider_with_model(provider, model) + + def _repair_max_passes(self) -> int: + raw = os.environ.get("ANE_REPAIR_MAX_PASSES", "2").strip() or "2" + try: + value = int(raw) + except ValueError as exc: + raise ProviderError("ANE_REPAIR_MAX_PASSES doit être un entier positif.") from exc + if value <= 0: + raise ProviderError("ANE_REPAIR_MAX_PASSES doit être supérieur à zéro.") + return value + + 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 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." + ) + return candidate + + 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) + + 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:") + + def _heuristic_gate_report(self, draft_v2: str) -> ManuscriptGateReport | None: + blockers: list[str] = [] + recommendations: list[str] = [] + + if self._word_count(draft_v2) < 180: + blockers.append("too_short") + recommendations.append("Allonger le chapitre pour produire une scene complete et continue.") + + if self._has_truncated_ending(draft_v2): + blockers.append("truncated_ending") + recommendations.append("Terminer la scene sur une phrase complete avec une vraie fermeture.") + + if self._is_outline_like(draft_v2): + blockers.append("outline_like") + recommendations.append("Remplacer les titres et puces par une narration continue en prose.") + + if not blockers: + return None + + summary = "Le garde-fou manuscrit a bloque la promotion: " + ", ".join(blockers) + "." + return ManuscriptGateReport.from_heuristics( + blockers=blockers, + recommendations=recommendations, + summary=summary, + ) + + def _word_count(self, text: str) -> int: + return len(re.findall(r"[0-9A-Za-zÀ-ÖØ-öø-ÿ]+(?:[-'][0-9A-Za-zÀ-ÖØ-öø-ÿ]+)*", text)) + + def _has_truncated_ending(self, text: str) -> bool: + for line in reversed(text.splitlines()): + stripped = line.strip() + if not stripped: + continue + return not stripped.endswith((".", "!", "?", "…", "»", '"', "'")) + return True + + def _is_outline_like(self, text: str) -> bool: + detected_markers: set[str] = set() + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("## "): + detected_markers.add("heading_level_2") + if stripped.startswith("### "): + detected_markers.add("heading_level_3") + if stripped.startswith("- "): + detected_markers.add("bullet_list") + lowered = stripped.lower() + if "**objectif**" in lowered or "**conflit**" in lowered or "**sortie**" in lowered: + detected_markers.add("scene_fields") + if "scène" in lowered or "scene" in lowered: + detected_markers.add("scene_heading") + if len(detected_markers) >= 2: + return True + return False + + def _generate_memory_update( + self, + provider: GenerationProvider, + context: GenerationContext, + accepted_draft: str, + metadata: dict[str, object], + ) -> MemoryUpdate: + prompt = self.prompt_store.render( + "memory", + chapter_slug=context.chapter_id.slug, + accepted_draft=accepted_draft, + story_context=context.story_context, + ) + return self._generate_json_payload( + provider=provider, + stage="memory", + prompt=prompt, + retry_prompt_name="memory_retry", + parse_response=MemoryUpdate.from_response_text, + metadata=metadata, + meta_path=context.meta_path, + retry_context={ + "chapter_slug": context.chapter_id.slug, + "accepted_draft": accepted_draft, + "story_context": context.story_context, + }, + ) + + def _generate_json_payload( + self, + *, + provider: GenerationProvider, + stage: str, + prompt: str, + retry_prompt_name: str, + parse_response: Callable[[str], ParsedStagePayload], + metadata: dict[str, object], + meta_path: Path, + retry_context: dict[str, object], + begin_stage: bool = True, + ) -> ParsedStagePayload: + if begin_stage: + self._begin_stage( + metadata, + meta_path, + stage, + f"Génération de l'étape {stage} en cours.", + ) + first_response = provider.generate( + GenerationRequest(stage=stage, prompt=prompt, response_format="json", temperature=0.1) + ) + first_payload = first_response.content + try: + return parse_response(first_payload) + except ValueError as first_error: + self._mark_stage_attempt( + metadata, + stage, + retry=True, + message=f"Réessai JSON sur l'étape {stage} après une réponse invalide.", + ) + self._set_status(metadata, f"{stage}_retrying", f"Réessai JSON sur l'étape {stage} en cours.") + self._write_metadata(meta_path, metadata) + retry_prompt = self.prompt_store.render( + retry_prompt_name, + parse_error=str(first_error), + invalid_response=self._truncate_retry_payload(first_payload), + **retry_context, + ) + retry_response = provider.generate( + GenerationRequest(stage=stage, prompt=retry_prompt, response_format="json", temperature=0.0) + ) + try: + return parse_response(retry_response.content) + except ValueError as second_error: + raise ProviderError( + f"Le provider a renvoyé un JSON invalide pendant l'étape '{stage}' après deux tentatives: " + f"première erreur: {first_error}; seconde erreur: {second_error}" + ) from second_error + + def _truncate_retry_payload(self, payload: str, limit: int = 2000) -> str: + text = payload.strip() + if len(text) <= limit: + return text + return f"{text[:limit].rstrip()}\n[...]" + + def _persist_memory(self, context: GenerationContext, memory_update: MemoryUpdate) -> None: + self._write_text( + context.memory_summary_path, + f"# Mémoire — {context.chapter_id.slug}\n\n{memory_update.chapter_summary}\n", + ) + + self._merge_index_records( + context.memory_index_dir / "personnages.json", + context.chapter_id, + memory_update.characters, + label_key="name", + ) + self._merge_index_records( + context.memory_index_dir / "lieux.json", + context.chapter_id, + memory_update.locations, + label_key="name", + ) + self._merge_timeline_records( + context.memory_index_dir / "chronologie.json", + context.chapter_id, + memory_update.timeline_events, + ) + + def _merge_index_records( + self, + path: Path, + chapter_id: ChapterId, + records: list[dict[str, str]], + label_key: str, + ) -> None: + existing: dict[str, dict[str, object]] = {} + if path.exists(): + payload = json.loads(path.read_text(encoding="utf-8")) + if isinstance(payload, dict): + existing = payload + + for record in records: + label = record[label_key] + current = existing.get(label, {"chapters": []}) + chapters = list(current.get("chapters", [])) + if chapter_id.slug not in chapters: + chapters.append(chapter_id.slug) + + merged = dict(current) + merged.update(record) + merged["chapters"] = sorted(chapters) + existing[label] = merged + + self._write_json(path, existing) + + def _merge_timeline_records( + self, + path: Path, + chapter_id: ChapterId, + records: list[dict[str, str]], + ) -> None: + existing: list[dict[str, str]] = [] + if path.exists(): + payload = json.loads(path.read_text(encoding="utf-8")) + if isinstance(payload, list): + existing = [ + {str(key): str(value) for key, value in item.items()} + for item in payload + if isinstance(item, dict) + ] + + for record in records: + merged = dict(record) + merged["chapter"] = chapter_id.slug + existing.append(merged) + + self._write_json(path, existing) + + def _prompt_for_acceptance(self, control_report: ControlReport, draft_path: Path) -> bool: + self.output_func("") + self.output_func(f"Critique: {control_report.summary}") + self.output_func(f"Brouillon final: {draft_path}") + self.output_func(f"Écarts détectés: {len(control_report.deviations)}") + response = self.input_func("Promouvoir ce brouillon vers le manuscrit ? [y/N]: ").strip().lower() + return response in {"y", "yes", "o", "oui"} + + def _initial_metadata(self, context: GenerationContext, provider: GenerationProvider) -> dict[str, object]: + return { + "chapter": context.chapter_id.slug, + "started_at": self._timestamp(), + "status": "started", + "last_status_message": "Pipeline initialisé.", + "completed_stages": [], + "accepted": False, + "repair_attempts": 0, + "repair_models": [], + "stage_attempts": {}, + "retry_stages": [], + "quality_blockers": [], + "provider": self._provider_metadata(provider), + "artifacts": { + "intention": str(context.intention_path), + "structure": str(context.structure_path), + "draft_v1": str(context.draft_v1_path), + "critique_v1": str(context.critique_path), + "draft_v2": str(context.draft_v2_path), + "repair_latest": None, + "repairs": [], + "gate_v1": str(context.gate_path), + "manuscript": str(context.manuscript_path), + "memory_summary": str(context.memory_summary_path), + }, + } + + def _provider_metadata(self, provider: GenerationProvider) -> dict[str, object]: + snapshot = { + "kind": provider.__class__.__name__, + "base_url": None, + "model": None, + } + if isinstance(provider, OpenAICompatibleProvider): + snapshot["base_url"] = provider.config.base_url + snapshot["model"] = provider.config.model + return snapshot + + def _current_running_stage(self, metadata: dict[str, object], fallback: str) -> str: + status = str(metadata.get("status", "")).strip() + for suffix in ("_running", "_retrying"): + if status.endswith(suffix): + return status[: -len(suffix)] + return fallback + + def _provider_model_name(self, provider: GenerationProvider) -> str | None: + metadata = self._provider_metadata(provider) + model = metadata.get("model") + if not isinstance(model, str): + return None + return model.strip() or None + + def _complete_stage(self, metadata: dict[str, object], stage: str) -> None: + completed = metadata.setdefault("completed_stages", []) + if not isinstance(completed, list): + completed = [] + metadata["completed_stages"] = completed + if stage not in completed: + completed.append(stage) + + def _record_repair_attempt( + self, + metadata: dict[str, object], + *, + attempt: int, + model: str | None, + path: Path, + ) -> None: + metadata["repair_attempts"] = attempt + + repair_models = metadata.setdefault("repair_models", []) + if not isinstance(repair_models, list): + repair_models = [] + metadata["repair_models"] = repair_models + repair_models.append(model or "provider_courant") + + artifacts = metadata.setdefault("artifacts", {}) + if not isinstance(artifacts, dict): + artifacts = {} + metadata["artifacts"] = artifacts + repairs = artifacts.setdefault("repairs", []) + if not isinstance(repairs, list): + repairs = [] + artifacts["repairs"] = repairs + repairs.append(str(path)) + artifacts["repair_latest"] = str(path) + + def _set_status(self, metadata: dict[str, object], status: str, message: str) -> None: + metadata["status"] = status + metadata["last_status_message"] = message + + def _mark_stage_attempt( + self, + metadata: dict[str, object], + stage: str, + *, + retry: bool = False, + message: str | None = None, + ) -> None: + stage_attempts = metadata.setdefault("stage_attempts", {}) + if not isinstance(stage_attempts, dict): + stage_attempts = {} + metadata["stage_attempts"] = stage_attempts + stage_attempts[stage] = int(stage_attempts.get(stage, 0)) + 1 + + if retry: + retry_stages = metadata.setdefault("retry_stages", []) + if isinstance(retry_stages, list) and stage not in retry_stages: + retry_stages.append(stage) + + if message: + metadata["last_status_message"] = message + + def _begin_stage( + self, + metadata: dict[str, object], + meta_path: Path, + stage: str, + message: str, + ) -> None: + self._mark_stage_attempt(metadata, stage) + self._set_status(metadata, f"{stage}_running", message) + self._write_metadata(meta_path, metadata) + + def _write_metadata(self, path: Path, metadata: dict[str, object]) -> None: + self._write_json(path, metadata) + + 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") + + def _write_text(self, path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + def _timestamp(self) -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() diff --git a/core/generation/provider.py b/core/generation/provider.py new file mode 100644 index 0000000..40193be --- /dev/null +++ b/core/generation/provider.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, replace +import json +import os +import socket +from typing import Mapping +from urllib import error, request + + +class ProviderError(RuntimeError): + """Raised when a text generation provider fails.""" + + +class ProviderConfigurationError(ProviderError): + """Raised when the provider environment is incomplete.""" + + +STAGE_MAX_TOKENS_ENV = { + "structure": "ANE_MAX_TOKENS_STRUCTURE", + "draft": "ANE_MAX_TOKENS_DRAFT", + "critique": "ANE_MAX_TOKENS_CRITIQUE", + "rewrite": "ANE_MAX_TOKENS_REWRITE", + "gate": "ANE_MAX_TOKENS_GATE", + "repair": "ANE_MAX_TOKENS_REPAIR", + "memory": "ANE_MAX_TOKENS_MEMORY", +} + + +def _parse_positive_int(raw_value: str, *, env_name: str) -> int: + try: + value = int(raw_value) + except ValueError as exc: + raise ProviderConfigurationError(f"{env_name} doit être un entier.") from exc + if value <= 0: + raise ProviderConfigurationError(f"{env_name} doit être supérieur à zéro.") + return value + + +@dataclass(frozen=True) +class ProviderConfig: + provider: str + base_url: str + api_key: str + model: str + timeout: float + max_tokens: int + stage_max_tokens: Mapping[str, int] + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> "ProviderConfig": + source = env or os.environ + provider = source.get("ANE_PROVIDER", "openai_compatible").strip() or "openai_compatible" + base_url = source.get("ANE_BASE_URL", "").strip() + model = source.get("ANE_MODEL", "").strip() + api_key = source.get("ANE_API_KEY", "").strip() + timeout_value = source.get("ANE_TIMEOUT", "60").strip() or "60" + max_tokens_value = source.get("ANE_MAX_TOKENS", "4096").strip() or "4096" + + try: + timeout = float(timeout_value) + except ValueError as exc: + raise ProviderConfigurationError("ANE_TIMEOUT doit être un nombre.") from exc + + max_tokens = _parse_positive_int(max_tokens_value, env_name="ANE_MAX_TOKENS") + stage_max_tokens: dict[str, int] = {} + for stage_name, env_name in STAGE_MAX_TOKENS_ENV.items(): + raw_stage_value = source.get(env_name, "").strip() + if not raw_stage_value: + continue + stage_max_tokens[stage_name] = _parse_positive_int(raw_stage_value, env_name=env_name) + + return cls( + provider=provider, + base_url=base_url, + api_key=api_key, + model=model, + timeout=timeout, + max_tokens=max_tokens, + stage_max_tokens=stage_max_tokens, + ) + + def max_tokens_for_stage(self, stage: str, explicit: int | None = None) -> int: + if explicit is not None: + return explicit + return self.stage_max_tokens.get(stage, self.max_tokens) + + def with_model(self, model: str) -> "ProviderConfig": + return replace(self, model=model) + + +@dataclass(frozen=True) +class GenerationRequest: + stage: str + prompt: str + response_format: str = "text" + temperature: float = 0.2 + system_prompt: str | None = None + max_tokens: int | None = None + + +@dataclass(frozen=True) +class GenerationResponse: + content: str + model: str | None = None + raw: dict[str, object] | None = None + + +class GenerationProvider(ABC): + @abstractmethod + def generate(self, request: GenerationRequest) -> GenerationResponse: + raise NotImplementedError + + +class OpenAICompatibleProvider(GenerationProvider): + def __init__(self, config: ProviderConfig): + if not config.base_url: + raise ProviderConfigurationError("ANE_BASE_URL est requis pour le provider openai_compatible.") + if not config.model: + raise ProviderConfigurationError("ANE_MODEL est requis pour le provider openai_compatible.") + self.config = config + + def generate(self, prompt_request: GenerationRequest) -> GenerationResponse: + payload: dict[str, object] = { + "model": self.config.model, + "messages": self._build_messages(prompt_request), + "temperature": prompt_request.temperature, + "max_tokens": self.config.max_tokens_for_stage( + prompt_request.stage, + prompt_request.max_tokens, + ), + } + if prompt_request.response_format == "json": + payload["response_format"] = {"type": "json_object"} + + body = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + if self.config.api_key: + headers["Authorization"] = f"Bearer {self.config.api_key}" + + http_request = request.Request( + self._chat_completions_url(), + data=body, + headers=headers, + method="POST", + ) + + try: + with request.urlopen(http_request, timeout=self.config.timeout) as response: + raw_payload = json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace") + raise ProviderError( + f"Le provider a répondu avec HTTP {exc.code} pendant l'étape '{prompt_request.stage}': {details}" + ) from exc + except error.URLError as exc: + raise ProviderError( + f"Impossible de joindre le provider pendant l'étape '{prompt_request.stage}': {exc.reason}" + ) from exc + except (TimeoutError, socket.timeout) as exc: + raise ProviderError( + f"Timeout du provider pendant l'étape '{prompt_request.stage}' après {self.config.timeout:.0f}s." + ) from exc + except json.JSONDecodeError as exc: + raise ProviderError( + f"Réponse non JSON du provider pendant l'étape '{prompt_request.stage}'." + ) from exc + + try: + choice = raw_payload["choices"][0] + message = choice["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise ProviderError( + f"Réponse OpenAI-compatible invalide pendant l'étape '{prompt_request.stage}'." + ) from exc + + content = self._normalize_message_content(message) + return GenerationResponse( + content=content, + model=str(raw_payload.get("model", self.config.model)), + raw=raw_payload, + ) + + def _build_messages(self, prompt_request: GenerationRequest) -> list[dict[str, str]]: + messages: list[dict[str, str]] = [] + if prompt_request.system_prompt: + messages.append({"role": "system", "content": prompt_request.system_prompt}) + messages.append({"role": "user", "content": prompt_request.prompt}) + return messages + + def _chat_completions_url(self) -> str: + base = self.config.base_url.rstrip("/") + if base.endswith("/chat/completions"): + return base + if base.endswith("/v1"): + return f"{base}/chat/completions" + return f"{base}/v1/chat/completions" + + def _normalize_message_content(self, message: object) -> str: + if isinstance(message, str): + return message + if isinstance(message, list): + parts: list[str] = [] + for item in message: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text", ""))) + if parts: + return "\n".join(parts) + raise ProviderError("Le provider n'a pas renvoyé de contenu texte exploitable.") + + +class MockGenerationProvider(GenerationProvider): + def __init__(self, responses: Mapping[str, object]): + self._responses = { + stage: list(value) if isinstance(value, list) else [value] + for stage, value in responses.items() + } + self.requests: list[GenerationRequest] = [] + + def generate(self, prompt_request: GenerationRequest) -> GenerationResponse: + self.requests.append(prompt_request) + queue = self._responses.get(prompt_request.stage) + if not queue: + raise ProviderError(f"Aucune réponse mock configurée pour l'étape '{prompt_request.stage}'.") + + next_value = queue.pop(0) + if isinstance(next_value, Exception): + raise next_value + if isinstance(next_value, (dict, list)): + content = json.dumps(next_value, ensure_ascii=False) + else: + content = str(next_value) + + return GenerationResponse(content=content, model="mock") + + +def build_provider_from_env(env: Mapping[str, str] | None = None) -> GenerationProvider: + config = ProviderConfig.from_env(env) + if config.provider != "openai_compatible": + raise ProviderConfigurationError( + f"Provider non supporté: {config.provider}. Utilisez ANE_PROVIDER=openai_compatible." + ) + return OpenAICompatibleProvider(config) + + +def clone_provider_with_model(provider: GenerationProvider, model: str) -> GenerationProvider: + if not model: + return provider + if isinstance(provider, OpenAICompatibleProvider): + if provider.config.model == model: + return provider + return OpenAICompatibleProvider(provider.config.with_model(model)) + return provider diff --git a/core/intention/__pycache__/gate.cpython-314.pyc b/core/intention/__pycache__/gate.cpython-314.pyc deleted file mode 100644 index c21208d..0000000 Binary files a/core/intention/__pycache__/gate.cpython-314.pyc and /dev/null differ diff --git a/core/intention/gate.py b/core/intention/gate.py index b4ad16f..b4e6a91 100644 --- a/core/intention/gate.py +++ b/core/intention/gate.py @@ -1,5 +1,7 @@ from pathlib import Path +from core.chapters import ChapterId, collect_matching_chapter_files + class IntentionGate: """ Hard lock: blocks any generation if no explicit intention exists. @@ -8,17 +10,46 @@ class IntentionGate: def __init__(self, project_root: Path): self.intentions_dir = project_root / "notes" / "intentions" - def has_intention(self) -> bool: + def has_intention(self, chapter: object | None = None) -> bool: if not self.intentions_dir.exists(): return False - intentions = list(self.intentions_dir.glob("chapitre_*.md")) + if chapter is None: + intentions = list(self.intentions_dir.glob("chapitre_*.md")) + return len(intentions) > 0 + chapter_id = ChapterId.parse(chapter) + intentions = collect_matching_chapter_files(self.intentions_dir, chapter_id) return len(intentions) > 0 - def assert_intention(self): - if not self.has_intention(): + def resolve_intention_path(self, chapter: object) -> Path | None: + chapter_id = ChapterId.parse(chapter) + matches = collect_matching_chapter_files(self.intentions_dir, chapter_id) + if not matches: + return None + if len(matches) > 1: + from core.chapters import ChapterConflictError + + raise ChapterConflictError(chapter_id, matches) + return matches[0] + + def load_intention(self, chapter: object) -> str: + path = self.resolve_intention_path(chapter) + if path is None: + chapter_id = ChapterId.parse(chapter) + raise RuntimeError(f"Aucune intention trouvée pour {chapter_id.slug}.") + return path.read_text(encoding="utf-8").strip() + + def assert_intention(self, chapter: object | None = None): + if not self.has_intention(chapter): + if chapter is None: + raise RuntimeError( + "Aucune intention trouvée.\n" + "L'écriture est volontairement bloquée.\n" + "Créez d'abord une intention explicite (CLI: intention create)." + ) + + chapter_id = ChapterId.parse(chapter) raise RuntimeError( - "Aucune intention trouvée.\n" + f"Aucune intention trouvée pour {chapter_id.slug}.\n" "L'écriture est volontairement bloquée.\n" "Créez d'abord une intention explicite (CLI: intention create)." ) - diff --git a/core/next_lots.py b/core/next_lots.py new file mode 100644 index 0000000..4a97dd7 --- /dev/null +++ b/core/next_lots.py @@ -0,0 +1,952 @@ +from __future__ import annotations + +import argparse +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import subprocess +import time +import tomllib +from typing import Any, Callable, Iterable +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" + + +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 + manifest_path: Path + tracking: TrackingPaths + core_base_url: str + apple_runtime_url: str + ollama_tags_url: str + apple_model_ready_timeout_seconds: float + apple_model_poll_interval_seconds: float + smoke_chapter: str + smoke_intention: str + smoke_timeout_seconds: int + preset_env: dict[str, str] + required_apple_models: list[str] + required_ollama_models: list[str] + priority_models: list[str] + baseline_models: list[str] + preflight_only_models: list[str] + next_code_lot: str + + @classmethod + def load(cls, repo_root: Path, manifest_path: Path) -> "Manifest": + payload = tomllib.loads(manifest_path.read_text(encoding="utf-8")) + paths = payload["paths"] + smoke = payload["smoke"] + preset = payload["preset"] + tracking = payload["tracking"] + lots = payload["lots"] + ensure_models = payload["ensure_models"] + + mascarade_repo = Path(paths["mascarade_repo"]).expanduser() + return cls( + repo_root=repo_root, + manifest_path=manifest_path, + tracking=TrackingPaths( + ane_todo_active=repo_root / tracking["ane"]["todo_active"], + ane_todo_done=repo_root / tracking["ane"]["todo_done"], + ane_plan=repo_root / tracking["ane"]["plan"], + ane_comparison=repo_root / tracking["ane"]["comparison"], + ane_readme=repo_root / tracking["ane"]["readme"], + ane_runbook=repo_root / tracking["ane"]["runbook"], + mascarade_repo=mascarade_repo, + mascarade_todo=mascarade_repo / tracking["mascarade"]["todo"], + mascarade_plan=mascarade_repo / tracking["mascarade"]["plan"], + mascarade_readme=mascarade_repo / tracking["mascarade"]["readme"], + mascarade_runbook=mascarade_repo / tracking["mascarade"]["runbook"], + ), + core_base_url=str(paths["core_base_url"]).rstrip("/"), + apple_runtime_url=str(paths["apple_runtime_url"]).rstrip("/"), + ollama_tags_url=str(paths["ollama_tags_url"]).rstrip("/"), + apple_model_ready_timeout_seconds=float(paths.get("apple_model_ready_timeout_seconds", 30)), + apple_model_poll_interval_seconds=float(paths.get("apple_model_poll_interval_seconds", 2)), + smoke_chapter=str(smoke["chapter"]), + smoke_intention=str(smoke["intention"]), + smoke_timeout_seconds=int(smoke["timeout_seconds"]), + preset_env={str(key): str(value) for key, value in preset.items()}, + required_apple_models=[str(item) for item in ensure_models["apple_models"]], + required_ollama_models=[str(item) for item in ensure_models["ollama_models"]], + 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"]], + next_code_lot=str(payload["next_actions"]["rewrite_compaction"]), + ) + + +@dataclass +class CommandResult: + args: list[str] + returncode: int + stdout: str + stderr: str + duration_seconds: float + + +CommandRunner = Callable[[list[str], Path, dict[str, str] | None], CommandResult] +JsonFetcher = Callable[[str, float], Any] + + +@dataclass +class ModelRunResult: + model: str + category: str + classification: str = "pending" + preflight_ok: bool | None = None + preflight_duration_seconds: float | None = None + smoke_attempted: bool = False + smoke_duration_seconds: float | None = None + 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 + repair_models: list[str] = field(default_factory=list) + draft_path: str | None = None + gate_path: str | None = None + meta_path: str | None = None + manuscript_path: str | None = None + notes: list[str] = field(default_factory=list) + preflight_log: str | None = None + smoke_log: str | None = None + workspace: str | None = None + apple_model_active: str | None = None + completed_stages: list[str] = field(default_factory=list) + + def reached_gate(self) -> bool: + return "gate" in self.completed_stages or (self.failed_stage == "gate") + + +@dataclass +class RunState: + version: int + manifest_path: str + report_dir: str + state_path: str + lot: str + started_at: str + updated_at: str + step_index: int + model_index: int + steps: list[dict[str, Any]] + results: list[dict[str, Any]] + notes: list[str] + pending_manual_action: dict[str, Any] | None + next_recommended_lot: str + + @classmethod + def new(cls, manifest: Manifest, lot: str, report_dir: Path, state_path: Path, steps: list[dict[str, Any]]) -> "RunState": + now = _timestamp() + return cls( + version=1, + manifest_path=str(manifest.manifest_path), + report_dir=str(report_dir), + state_path=str(state_path), + lot=lot, + started_at=now, + updated_at=now, + step_index=0, + model_index=0, + steps=steps, + results=[], + notes=[], + pending_manual_action=None, + next_recommended_lot=manifest.next_code_lot, + ) + + @classmethod + def load(cls, path: Path) -> "RunState": + payload = json.loads(path.read_text(encoding="utf-8")) + return cls(**payload) + + def dump(self, path: Path | None = None) -> None: + 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") + + def append_result(self, result: ModelRunResult) -> None: + self.results.append(asdict(result)) + self.updated_at = _timestamp() + + def typed_results(self) -> list[ModelRunResult]: + return [ModelRunResult(**payload) for payload in self.results] + + +def _timestamp() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def _default_command_runner(args: list[str], cwd: Path, env: dict[str, str] | None = None) -> CommandResult: + merged_env = os.environ.copy() + if env: + merged_env.update(env) + started = time.monotonic() + completed = subprocess.run( + args, + cwd=str(cwd), + env=merged_env, + text=True, + capture_output=True, + check=False, + ) + return CommandResult( + args=args, + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + duration_seconds=time.monotonic() - started, + ) + + +def _default_json_fetcher(url: str, timeout: float) -> Any: + with request.urlopen(url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def _auto_markers(name: str) -> tuple[str, str]: + return ( + f"", + f"", + ) + + +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}" + path.write_text(new_text, encoding="utf-8") + + +class NextLotsRunner: + def __init__( + self, + manifest: Manifest, + *, + command_runner: CommandRunner = _default_command_runner, + json_fetcher: JsonFetcher = _default_json_fetcher, + ) -> None: + self.manifest = manifest + self.command_runner = command_runner + self.json_fetcher = json_fetcher + + def run( + self, + *, + lot: str, + resume_state: Path | None = None, + dry_run: bool = False, + report_only: bool = False, + ) -> int: + state_path = self.manifest.repo_root / "automation" / "state" / "next_lots_state.json" + if resume_state is not None: + state = RunState.load(resume_state) + else: + report_dir = self._new_report_dir() + state = RunState.new( + self.manifest, + lot=lot, + report_dir=report_dir, + state_path=state_path, + steps=self._steps_for_lot(lot), + ) + state.dump(state_path) + + if report_only: + self._sync_tracking(state, dry_run=dry_run) + return 0 + + while state.step_index < len(state.steps): + step = state.steps[state.step_index] + step_type = str(step["type"]) + if step_type == "ensure_models": + print("==> lot ensure_models") + self._run_ensure_models(state, dry_run=dry_run) + state.step_index += 1 + state.model_index = 0 + state.dump() + continue + if step_type == "models": + print(f"==> lot {step['name']}") + 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) + return exit_code + state.step_index += 1 + state.model_index = 0 + state.dump() + continue + if step_type == "tracking_sync": + print("==> lot tracking_sync") + self._sync_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) + return 0 + + def _steps_for_lot(self, lot: str) -> list[dict[str, Any]]: + if lot == "ensure_models": + return [{"type": "ensure_models"}] + if lot == "runtime_preflight": + queue = [*self.manifest.priority_models, *self.manifest.baseline_models] + return [{"type": "models", "name": "runtime_preflight", "models": queue, "preflight_only": True}] + if lot == "priority_models": + return [ + {"type": "models", "name": "priority_models", "models": self.manifest.priority_models, "preflight_only": False}, + {"type": "tracking_sync"}, + ] + if lot == "baselines": + return [ + {"type": "models", "name": "baselines", "models": self.manifest.baseline_models, "preflight_only": False}, + {"type": "models", "name": "preflight_only", "models": self.manifest.preflight_only_models, "preflight_only": True}, + {"type": "tracking_sync"}, + ] + if lot == "tracking_sync": + return [{"type": "tracking_sync"}] + if lot == "full": + return [ + {"type": "ensure_models"}, + {"type": "models", "name": "priority_models", "models": self.manifest.priority_models, "preflight_only": False}, + {"type": "models", "name": "baselines", "models": self.manifest.baseline_models, "preflight_only": False}, + {"type": "models", "name": "preflight_only", "models": self.manifest.preflight_only_models, "preflight_only": True}, + {"type": "tracking_sync"}, + ] + raise NextLotsError(f"Lot inconnu: {lot}") + + def _new_report_dir(self) -> Path: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + report_dir = self.manifest.repo_root / "automation" / "reports" / stamp + report_dir.mkdir(parents=True, exist_ok=True) + return report_dir + + def _run_ensure_models(self, state: RunState, *, dry_run: bool) -> None: + if dry_run: + state.notes.append("Dry-run: ensure_models non exécuté.") + return + args = ["bash", "scripts/ensure_apple_models.sh"] + result = self.command_runner(args, self.manifest.tracking.mascarade_repo) + log_path = Path(state.report_dir) / "ensure_models.log" + log_path.write_text(_command_log(result), encoding="utf-8") + if result.returncode != 0: + raise NextLotsError("ensure_models a échoué.") + missing = self._missing_ollama_models() + if missing: + state.notes.append( + "Modèles Ollama manquants: " + ", ".join(missing) + ". Lancer manuellement `ollama pull` sur ces modèles." + ) + + 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] + + def _run_model_step(self, state: RunState, step: dict[str, Any], *, dry_run: bool) -> int | None: + models = [str(item) for item in step["models"]] + preflight_only = bool(step.get("preflight_only", False)) + for index in range(state.model_index, len(models)): + state.model_index = index + model = models[index] + category = str(step["name"]) + state.notes = [f"Modele en cours: {model}"] + state.dump() + print(f"--> {model}") + if dry_run: + state.append_result( + ModelRunResult( + model=model, + category=category, + classification="dry_run", + notes=["Dry-run: aucun preflight ni smoke exécuté."], + ) + ) + continue + checkpoint = self._checkpoint_if_runtime_manual_step_needed(state, model) + if checkpoint is not None: + print(f"checkpoint manuel: {checkpoint['reason']}") + print(f"commande: {checkpoint['command']}") + state.pending_manual_action = checkpoint + state.notes = [f"Checkpoint manuel requis pour: {model}"] + self._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)) + state.notes = [f"Dernier modele traite: {model} -> {result.classification}"] + state.append_result(result) + 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}`.", + ) + + def _build_manual_action(self, state: RunState, *, args: list[str], reason: str) -> dict[str, Any]: + result = self.command_runner(args, self.manifest.tracking.mascarade_repo) + log_path = Path(state.report_dir) / f"manual_action_{len(state.results):02d}.log" + log_path.write_text(_command_log(result), encoding="utf-8") + return { + "reason": reason, + "command": " ".join(args), + "log_path": str(log_path), + "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 + + 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) + preflight_args = [ + "bash", + "scripts/smoke_openai_compat_ane.sh", + "--url", + self.manifest.core_base_url, + "--model", + model, + "--timeout", + str(self._timeout_for_model(model)), + ] + preflight = self.command_runner(preflight_args, self.manifest.tracking.mascarade_repo) + result.preflight_duration_seconds = preflight.duration_seconds + preflight_log = report_dir / f"{model_slug}_preflight.log" + preflight_log.write_text(_command_log(preflight), encoding="utf-8") + result.preflight_log = str(preflight_log) + result.preflight_ok = preflight.returncode == 0 + if not result.preflight_ok: + result.classification = "provider_failed" + result.status = "preflight_failed" + result.notes.append("Le preflight OpenAI-compatible a échoué.") + return result + if preflight_only: + result.classification = "preflight_only" + result.status = "preflight_only" + result.notes.append("Smoke complet volontairement sauté pour ce modèle.") + return result + + workspace = report_dir / "workspaces" / model_slug + workspace.parent.mkdir(parents=True, exist_ok=True) + smoke_args = [ + "bash", + "scripts/smoke_local_generation.sh", + "--base-url", + self.manifest.core_base_url, + "--model", + model, + "--chapter", + self.manifest.smoke_chapter, + "--workspace", + str(workspace), + "--timeout", + str(self.manifest.smoke_timeout_seconds), + "--intention", + self.manifest.smoke_intention, + "--approve", + ] + smoke = self.command_runner(smoke_args, self.manifest.repo_root, env=self.manifest.preset_env) + result.smoke_attempted = True + result.smoke_duration_seconds = smoke.duration_seconds + smoke_log = report_dir / f"{model_slug}_smoke.log" + smoke_log.write_text(_command_log(smoke), encoding="utf-8") + result.smoke_log = str(smoke_log) + result.workspace = str(workspace) + + chapter = ChapterId.parse(self.manifest.smoke_chapter) + meta_path = workspace / "brouillons" / "chapitres" / chapter.slug / "meta.json" + if not meta_path.exists(): + result.classification = "provider_failed" + result.status = "missing_meta" + result.notes.append("Le smoke n'a pas produit de meta.json exploitable.") + return result + + payload = json.loads(meta_path.read_text(encoding="utf-8")) + result.meta_path = str(meta_path) + result.status = str(payload.get("status", "")).strip() or None + result.accepted = bool(payload.get("accepted", False)) + result.failed_stage = str(payload.get("failed_stage", "")).strip() or None + result.quality_blockers = _string_list(payload.get("quality_blockers")) + result.retry_stages = _string_list(payload.get("retry_stages")) + result.repair_attempts = int(payload.get("repair_attempts", 0) or 0) + result.repair_models = _string_list(payload.get("repair_models")) + result.completed_stages = _string_list(payload.get("completed_stages")) + artifacts = payload.get("artifacts", {}) + if isinstance(artifacts, dict): + result.draft_path = _optional_string(artifacts.get("repair_latest")) or _optional_string(artifacts.get("draft_v2")) + result.gate_path = _optional_string(artifacts.get("gate_v1")) + result.manuscript_path = _optional_string(artifacts.get("manuscript")) + + if result.status == "accepted": + result.classification = "accepted" + elif result.status == "quality_blocked": + result.classification = "quality_blocked" + elif smoke.returncode == 0 and result.status == "rejected": + result.classification = "provider_failed" + else: + result.classification = "provider_failed" + 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 = state.typed_results() + 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) + + 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") + + +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 _slugify(value: str) -> str: + return "".join(char if char.isalnum() else "_" for char in value).strip("_").lower() + + +def _command_log(result: CommandResult) -> str: + return ( + f"$ {' '.join(result.args)}\n" + f"returncode={result.returncode}\n" + f"duration_seconds={result.duration_seconds:.2f}\n\n" + f"STDOUT\n{result.stdout}\n\nSTDERR\n{result.stderr}" + ) + + +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) -> str: + if any(item.classification == "accepted" for item in results): + return "Rejouer uniquement les baselines vitesse puis figer la référence locale dans les README/runbooks." + 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("--resume", type=Path) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--report-only", action="store_true") + return parser + + +def main(argv: list[str] | None = None, repo_root: Path | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + root = repo_root or Path.cwd() + manifest = Manifest.load(root, root / args.manifest) + runner = NextLotsRunner(manifest) + return runner.run( + lot=args.lot, + resume_state=args.resume, + dry_run=args.dry_run, + report_only=args.report_only, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/core/project/__init__.py b/core/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/project/loader.py b/core/project/loader.py new file mode 100644 index 0000000..c974ef5 --- /dev/null +++ b/core/project/loader.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from pathlib import Path +import json + +from core.chapters import ChapterId, discover_chapter_dirs, discover_chapter_files + + +class ProjectState: + """ + Detects and summarizes the current state of a writing project. + Read-only, file-based, human-readable. + """ + + def __init__(self, root: Path): + self.root = root + self.manuscript = root / "manuscrit" + self.structure = root / "structure" / "chapitres" + self.drafts = root / "brouillons" / "chapitres" + self.memory = root / "memoire" + self.memory_chapters = self.memory / "chapitres" + self.intentions = root / "notes" / "intentions" + + def detect_current_chapter(self) -> str | None: + chapters = self.known_chapters() + if not chapters: + return None + return chapters[-1].slug + + def known_chapters(self) -> list[ChapterId]: + chapters: set[ChapterId] = set() + for chapter, _path in discover_chapter_files(self.intentions): + chapters.add(chapter) + for chapter, _path in discover_chapter_files(self.structure): + chapters.add(chapter) + for chapter, _path in discover_chapter_files(self.manuscript): + chapters.add(chapter) + for chapter, _path in discover_chapter_files(self.memory_chapters): + chapters.add(chapter) + for chapter, _path in discover_chapter_dirs(self.drafts): + chapters.add(chapter) + return sorted(chapters) + + def latest_drafts(self) -> dict[str, str]: + latest: dict[str, str] = {} + for chapter, draft_dir in discover_chapter_dirs(self.drafts): + meta = self._load_meta(draft_dir) + if meta: + artifacts = meta.get("artifacts", {}) + if isinstance(artifacts, dict): + repair_latest = artifacts.get("repair_latest") + if isinstance(repair_latest, str) and repair_latest.strip(): + latest[chapter.slug] = Path(repair_latest).name + continue + + candidates = sorted(path.name for path in draft_dir.glob("draft_v*.md")) + if candidates: + latest[chapter.slug] = candidates[-1] + return latest + + def latest_repairs(self) -> dict[str, str]: + latest: dict[str, str] = {} + for chapter, draft_dir in discover_chapter_dirs(self.drafts): + meta = self._load_meta(draft_dir) + if not meta: + continue + artifacts = meta.get("artifacts", {}) + if not isinstance(artifacts, dict): + continue + repair_latest = artifacts.get("repair_latest") + if isinstance(repair_latest, str) and repair_latest.strip(): + latest[chapter.slug] = Path(repair_latest).name + return latest + + def failed_chapters(self) -> list[dict[str, object]]: + failures: list[dict[str, object]] = [] + for chapter, draft_dir in discover_chapter_dirs(self.drafts): + meta = self._load_meta(draft_dir) + if not meta or meta.get("status") != "failed": + continue + failures.append( + { + "chapter": chapter.slug, + "status": str(meta.get("status", "")), + "failed_stage": str(meta.get("failed_stage", "")), + "meta_path": str(draft_dir / "meta.json"), + "retry_stages": self._retry_stages(meta), + "last_status_message": str(meta.get("last_status_message", "")).strip(), + } + ) + return failures + + def quality_blocked_chapters(self) -> list[dict[str, object]]: + blocked: list[dict[str, object]] = [] + for chapter, draft_dir in discover_chapter_dirs(self.drafts): + meta = self._load_meta(draft_dir) + if not meta or meta.get("status") != "quality_blocked": + continue + artifacts = meta.get("artifacts", {}) + if not isinstance(artifacts, dict): + artifacts = {} + raw_blockers = meta.get("quality_blockers") + quality_blockers = [] + if isinstance(raw_blockers, list): + quality_blockers = [str(item).strip() for item in raw_blockers if str(item).strip()] + blocked.append( + { + "chapter": chapter.slug, + "status": str(meta.get("status", "")), + "failed_stage": str(meta.get("failed_stage", "")), + "meta_path": str(draft_dir / "meta.json"), + "draft_path": str(artifacts.get("repair_latest") or artifacts.get("draft_v2", draft_dir / "draft_v2.md")), + "gate_path": str(artifacts.get("gate_v1", draft_dir / "gate_v1.json")), + "quality_blockers": quality_blockers, + "retry_stages": self._retry_stages(meta), + "repair_attempts": int(meta.get("repair_attempts", 0) or 0), + "repair_models": self._repair_models(meta), + "last_status_message": str(meta.get("last_status_message", "")).strip(), + } + ) + return blocked + + def awaiting_acceptance(self) -> list[dict[str, object]]: + pending: list[dict[str, object]] = [] + for chapter, draft_dir in discover_chapter_dirs(self.drafts): + meta = self._load_meta(draft_dir) + if not meta or meta.get("status") != "awaiting_acceptance": + continue + artifacts = meta.get("artifacts", {}) + if not isinstance(artifacts, dict): + artifacts = {} + pending.append( + { + "chapter": chapter.slug, + "status": str(meta.get("status", "")), + "draft_path": str(artifacts.get("repair_latest") or artifacts.get("draft_v2", draft_dir / "draft_v2.md")), + "critique_path": str(artifacts.get("critique_v1", draft_dir / "critique_v1.md")), + "gate_path": str(artifacts.get("gate_v1", draft_dir / "gate_v1.json")), + "meta_path": str(draft_dir / "meta.json"), + "retry_stages": self._retry_stages(meta), + "repair_attempts": int(meta.get("repair_attempts", 0) or 0), + "repair_models": self._repair_models(meta), + "last_status_message": str(meta.get("last_status_message", "")).strip(), + } + ) + return pending + + def retry_stages(self) -> dict[str, list[str]]: + retries: dict[str, list[str]] = {} + for chapter, draft_dir in discover_chapter_dirs(self.drafts): + meta = self._load_meta(draft_dir) + if not meta: + continue + stages = self._retry_stages(meta) + if stages: + retries[chapter.slug] = stages + return retries + + def summary(self) -> dict[str, object]: + return { + "project_root": str(self.root), + "current_chapter": self.detect_current_chapter(), + "known_chapters": [chapter.slug for chapter in self.known_chapters()], + "directories": { + "structure": self.structure.exists(), + "drafts": self.drafts.exists(), + "manuscript": self.manuscript.exists(), + "memory": self.memory.exists(), + }, + "has_structure": self.structure.exists(), + "has_memory": self.memory.exists(), + "latest_drafts": self.latest_drafts(), + "latest_repairs": self.latest_repairs(), + "failed_chapters": self.failed_chapters(), + "quality_blocked_chapters": self.quality_blocked_chapters(), + "awaiting_acceptance": self.awaiting_acceptance(), + "retry_stages": self.retry_stages(), + } + + def _load_meta(self, draft_dir: Path) -> dict[str, object] | None: + meta_path = draft_dir / "meta.json" + if not meta_path.exists(): + return None + try: + payload = json.loads(meta_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + if not isinstance(payload, dict): + return None + return payload + + def _retry_stages(self, meta: dict[str, object]) -> list[str]: + raw = meta.get("retry_stages") + if not isinstance(raw, list): + return [] + return [str(item).strip() for item in raw if str(item).strip()] + + def _repair_models(self, meta: dict[str, object]) -> list[str]: + raw = meta.get("repair_models") + if not isinstance(raw, list): + return [] + return [str(item).strip() for item in raw if str(item).strip()] diff --git a/core/prompts.py b/core/prompts.py new file mode 100644 index 0000000..886b69f --- /dev/null +++ b/core/prompts.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pathlib import Path +from string import Template +import json + + +class PromptNotFoundError(FileNotFoundError): + """Raised when a prompt file is missing.""" + + +class PromptStore: + def __init__(self, root: Path): + self.root = root + self.prompts_dir = root / "prompts" + self.builtin_prompts_dir = Path(__file__).resolve().parents[1] / "prompts" + + def render(self, name: str, **context: object) -> str: + path = self.prompts_dir / f"{name}_v1.txt" + if not path.exists(): + path = self.builtin_prompts_dir / f"{name}_v1.txt" + if not path.exists(): + raise PromptNotFoundError(f"Prompt introuvable: {path}") + + template = Template(path.read_text(encoding="utf-8")) + normalized = {key: self._normalize(value) for key, value in context.items()} + return template.substitute(normalized) + + def _normalize(self, value: object) -> str: + if value is None: + return "" + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False, indent=2) + return str(value) diff --git a/docs/EXECUTION_PLAN_2026-03-07.md b/docs/EXECUTION_PLAN_2026-03-07.md new file mode 100644 index 0000000..d866ee0 --- /dev/null +++ b/docs/EXECUTION_PLAN_2026-03-07.md @@ -0,0 +1,59 @@ +# Plan d'execution - 7 mars 2026 + +Ordre recommande pour la suite de `ai-novel-engine`, base sur l'etat reel livre au 7 mars 2026. + +## Lot 1 - Stabilisation locale Apple / Ollama + +### Objectif +- verrouiller un run chapitre complet en local via `apple-coreml` +- rejouer le meme flux via `ollama` +- durcir la fin du pipeline sur les sorties JSON encore fragiles + +### Done quand +- un chapitre complet passe jusqu'a la validation interactive puis a la promotion dans `manuscrit/` avec `apple-coreml` +- le meme chapitre passe avec `ollama` sans changer le pipeline narratif +- `critique` et `memory` disposent d'un second passage de reparation ou de reessai si le JSON reste invalide + +### Risque principal +- le service Apple local `:8201` peut rester lent, bloquer une connexion ou degrader la validation sequentielle + +### Dependances +- `mascarade` doit garder le shim `/v1/chat/completions` stable +- un backend `ollama` local doit etre disponible pour le second passage +- les budgets par etape doivent rester ajustables sans changer le pipeline + +## Lot 2 - Workflow auteur et CLI non interactive + +### Objectif +- rendre le workflow auteur exploitable en interactif et en batch local +- exposer plus clairement l'etat d'echec des chapitres + +### Done quand +- `generate chapter` accepte `--approve` et `--reject` +- `status` expose les chapitres en echec, le dernier `failed_stage` et le dernier artefact utile +- le smoke local affiche un resume lisible sans ouvrir `meta.json` + +### Risque principal +- la CLI peut devenir ambigue si les modes interactif et non interactif divergent + +### Dependances +- les artefacts de pipeline doivent rester stables +- les metadonnees `meta.json` doivent contenir assez d'information pour alimenter `status` + +## Lot 3 - Docs produit et runbooks + +### Objectif +- remplacer les placeholders de doc produit +- figer les contrats cross-repo et les procedures de recuperation locales + +### Done quand +- `docs/vision.md` et `docs/roadmap.md` ne sont plus des placeholders +- un runbook court de recuperation Apple local existe +- le contrat `mascarade` utile a `ai-novel-engine` est documente une fois, de facon stable + +### Risque principal +- la doc peut diverger vite du runtime si elle est redigee avant la stabilisation locale + +### Dependances +- le Lot 1 doit etre suffisamment stable pour produire des runbooks fiables +- `mascarade` doit figer le perimetre ANE suivi dans `TODO_AI_NOVEL_ENGINE.md` diff --git a/docs/EXECUTION_PLAN_2026-03-08.md b/docs/EXECUTION_PLAN_2026-03-08.md new file mode 100644 index 0000000..b3996c2 --- /dev/null +++ b/docs/EXECUTION_PLAN_2026-03-08.md @@ -0,0 +1,104 @@ +# Plan d'execution - 8 mars 2026 + +Plan de reference apres livraison de la boucle `repair`. + +Le plan du 7 mars 2026 reste archive pour historique. L'ordre recommande a date +est celui-ci. + +Pilotage operationnel: +- lancer les lots avec `python3 scripts/run_next_lots.py --lot ` +- utiliser `automation/next_lots.toml` comme source de verite pour l'ordre des smokes, les budgets et les fichiers de suivi +- en cas de switch Apple ou de restart runtime, reprendre ensuite avec `python3 scripts/run_next_lots.py --resume automation/state/next_lots_state.json` + +## Lot 1 - Consolider la reference acceptee et finir les baselines + +### Etat constate +- la boucle `repair` est livree, testee et visible dans `status` / `meta.json` +- `apple-coreml:qwen3.5-4b-onnx-q4f16` a termine un cycle complet et est `accepted` +- `ollama:qwen2.5:7b` atteint `gate`, exerce `repair` en live, puis reste `quality_blocked` sur `outline_like` +- le lot `baselines` est en cours pour `apple-coreml:qwen2.5-0.5b-instruct-onnx` et `ollama:qwen2.5:1.5b` +- le runtime Apple local n'expose qu'un seul `model_id` a la fois, ce qui limite le fallback `repair` entre modeles Apple au sein d'un meme smoke + +### Objectif +- finir les baselines pour avoir un comparatif complet du protocole courant +- confirmer que la reference `apple-coreml:qwen3.5-4b-onnx-q4f16` est reproductible sur plus d'un cycle +- sortir `ollama:qwen2.5:7b` de `quality_blocked` sans degrader la prose utile + +### Done quand +- le lot `baselines` est termine et synchronise +- `apple-coreml:qwen3.5-4b-onnx-q4f16` reste `accepted` sur un rerun de confirmation +- `ollama:qwen2.5:7b` finit soit `accepted`, soit `quality_blocked` avec un diagnostic resserre qui ne soit plus `outline_like` + +### Risque principal +- la reference Apple 4B peut rester un succes isole si les switches runtime ou les budgets changent + +### Dependances +- garder le garde-fou comme blocage dur +- conserver le protocole de comparaison commun et le meme preset qualite +- installer ou restager explicitement avant les reruns Apple: + - `qwen2.5-0.5b-instruct-onnx` + - `qwen3.5-4b-onnx-q4f16` + - `stateful-mistral7b-instruct-int4-coreml` +- verifier avant chaque rerun Apple que le bon `model_id` est effectivement charge sur `:8201` + +## Lot 2 - Tuner `rewrite` et `repair` pour Ollama 7B + +### Objectif +- garder `apple-coreml:qwen3.5-4b-onnx-q4f16` comme reference +- faire passer `ollama:qwen2.5:7b` de `quality_blocked` a `accepted` +- ne garder les petits modeles que comme baselines vitesse ou regressions + +### Ordre recommande +1. finir `apple-coreml:qwen2.5-0.5b-instruct-onnx` +2. finir `ollama:qwen2.5:1.5b` +3. rejouer `apple-coreml:qwen3.5-4b-onnx-q4f16` +4. rejouer `ollama:qwen2.5:7b` +5. `ollama:qwen3.5:9b` seulement si `qwen2.5:7b` termine un smoke complet + +### Done quand +- le comparatif distingue clairement: + - le modele de reference ANE actuel + - le meilleur candidat Apple actuel + - le meilleur candidat Ollama actuel + - les baselines vitesse a conserver ou a sortir + - le meilleur compromis Apple + - le candidat vitesse encore insuffisant + - les modeles a sortir de la reference locale + +### Risque principal +- les meilleurs candidats peuvent rester meilleurs sur la qualite, mais encore hors reference tant que `rewrite` ne passe pas + +### Dependances +- chemin Ollama de reference: Docker CPU via `mascarade` +- service Apple `:8201` stable pendant tout le smoke +- les trois modeles Apple cibles doivent etre installes et visibles cote runtime avant comparaison: + - `qwen2.5-0.5b-instruct-onnx` + - `qwen3.5-4b-onnx-q4f16` + - `stateful-mistral7b-instruct-int4-coreml` +- temps borne par requete pour garder des verdicts comparables + +## Lot 3 - Docs et runbooks finaux + +### Objectif +- maintenir les README, TODOs, runbooks et le comparatif alignes sur l'etat reel + +### Done quand + - les docs distinguent clairement le modele `accepted`, les modeles `quality_blocked` et les baselines encore en rerun +- le comparatif et les runbooks renvoient tous vers ce plan du 8 mars 2026 +- les TODOs n'exposent plus d'items deja livres + +### Risque principal +- la doc redevient trop optimiste si elle est mise a jour avant la revalidation complete + +### Dependances +- les lots 1 et 2 doivent produire des resultats reels, pas des suppositions + +## Auto-sync +## Auto-sync + +- dernier verdict automatise: 2026-03-09T06:53:02+00:00 +- accepted: aucun +- gate atteint: apple-coreml:qwen2.5-0.5b-instruct-onnx, ollama:qwen2.5:1.5b +- prochain lot calcule: Analyser les runs ayant atteint gate/repair puis resserrer la reference locale autour des meilleurs candidats. +- checkpoint manuel requis: Le runtime Apple sert `qwen2.5-0.5b-instruct-onnx` au lieu de `stateful-mistral7b-instruct-int4-coreml`. + diff --git a/docs/MODEL_COMPARISON_2026-03-08.md b/docs/MODEL_COMPARISON_2026-03-08.md new file mode 100644 index 0000000..29bda29 --- /dev/null +++ b/docs/MODEL_COMPARISON_2026-03-08.md @@ -0,0 +1,102 @@ +# Comparatif local ANE - 8 mars 2026 + +Comparatif realise avec le protocole courant: + +- meme intention de smoke +- meme chapitre `02` +- meme CLI publique `generate chapter --chapter 02 --approve` +- meme preset qualite: + - `ANE_MAX_TOKENS_STRUCTURE=256` + - `ANE_MAX_TOKENS_DRAFT=768` + - `ANE_MAX_TOKENS_CRITIQUE=512` + - `ANE_MAX_TOKENS_REWRITE=768` + - `ANE_MAX_TOKENS_GATE=384` + - `ANE_MAX_TOKENS_REPAIR=512` + - `ANE_MAX_TOKENS_MEMORY=320` + - `ANE_REPAIR_MAX_PASSES=2` +- meme timeout borne par requete: + - `300s` +- meme garde-fou manuscrit dur et meme boucle `repair` + +Contexte machine: + +- `ai-novel-engine` pointe vers `mascarade` sur `http://127.0.0.1:8100` +- `ollama` est route vers un service Docker CPU expose sur `127.0.0.1:11435` +- le host `ollama` natif 0.17.7 reste bloque par un crash Metal sur cette machine +- le runtime Apple local n'expose qu'un seul `model_id` a la fois sur `:8201` +- dernier cycle complet termine au 9 mars 2026: + - `apple-coreml:qwen3.5-4b-onnx-q4f16` est `accepted` + - `ollama:qwen2.5:7b` atteint `gate`, exerce `repair` puis finit `quality_blocked` + - le lot `baselines` est relance separement pour les petits modeles + +## Resultats + +| Modele | Backend | Preflight | Smoke complet | Statut final | Derniere etape atteinte | Total observe | Prose / narration | JSON / controle | Verdict | +|---|---|---|---|---|---|---:|---|---|---| +| `apple-coreml:qwen3.5-4b-onnx-q4f16` | `apple-coreml` | OK | oui | `accepted` | `memory` | `711s` | meilleure nuance narrative du lot | critique exploitable, gate vert | reference ANE locale actuelle | +| `ollama:qwen2.5:7b` | `ollama` | OK | oui | `quality_blocked` | `gate` | `825s` | correcte, plus sobre que l'Apple 4B | critique exploitable, mais le texte reste trop proche d'un plan | meilleur candidat Ollama, encore bloque | +| `apple-coreml:qwen2.5-0.5b-instruct-onnx` | `apple-coreml` | OK | rerun en cours | n/a | n/a | n/a | baseline vitesse a requalifier | n/a | en attente de verdict courant | +| `ollama:qwen2.5:1.5b` | `ollama` | OK | rerun en cours | n/a | n/a | n/a | baseline vitesse a requalifier | n/a | en attente de verdict courant | + +Point legacy hors protocole courant: + +| Modele | Backend | Preflight | Smoke complet | Statut final | +|---|---|---|---|---| +| `apple-coreml:stateful-mistral7b-instruct-int4-coreml` | `apple-coreml` | OK | bloque > `8 min` a `structure` | `preflight_only` | + +## Lecture rapide + +### `apple-coreml:qwen3.5-4b-onnx-q4f16` +- passe `structure`, `draft`, `critique`, `rewrite`, `gate` puis `memory` +- fournit le premier run `accepted` sous protocole `gate + repair` +- devient la reference ANE locale actuelle +- doit encore etre confirme sur rerun de stabilite + +### `ollama:qwen2.5:7b` +- passe `structure`, `draft`, `critique`, `rewrite` puis `gate` +- exerce `repair` en live sur deux passes +- reste bloque sur `outline_like` +- c'est le meilleur candidat Ollama actuel, mais il lui manque encore une prose plus continue + +### `apple-coreml:qwen2.5-0.5b-instruct-onnx` +- rerun baseline en cours via le lot `baselines` +- reste utile comme candidat vitesse Apple, pas comme reference qualite tant qu'un verdict courant n'est pas resynchronise + +### `ollama:qwen2.5:1.5b` +- rerun baseline en cours via le lot `baselines` +- reste un temoin de regression plus qu'un candidat qualite + +## Verdicts + +- **Modele de reference ANE**: `apple-coreml:qwen3.5-4b-onnx-q4f16` +- **Meilleur compromis Apple**: `apple-coreml:qwen3.5-4b-onnx-q4f16` +- **Meilleur compromis Ollama**: `ollama:qwen2.5:7b` +- **Modele rapide mais insuffisant**: `apple-coreml:qwen2.5-0.5b-instruct-onnx` +- **Modeles a eviter pour la redaction longue sur cette machine**: `ollama:qwen2.5:1.5b` et `apple-coreml:stateful-mistral7b-instruct-int4-coreml` + +## Conclusion du cycle + +Le cycle `priority_models` atteint enfin un objectif produit minimal: + +- la boucle `repair` est implementée, testee et visible dans `status` / `meta.json` +- `repair` a maintenant une validation live sur `ollama:qwen2.5:7b` +- un premier modele est `accepted` sous protocole courant: `apple-coreml:qwen3.5-4b-onnx-q4f16` +- le prochain enjeu n'est plus de trouver un premier succes, mais de finir les baselines et de sortir `ollama:qwen2.5:7b` de `outline_like` + +Le prochain lot logique n'est plus "ajouter un garde-fou", mais: + +1. finir le lot `baselines` +2. confirmer `apple-coreml:qwen3.5-4b-onnx-q4f16` sur rerun +3. regler `rewrite` et `repair` pour faire tomber `outline_like` sur `ollama:qwen2.5:7b` +4. ne garder `qwen2.5-0.5b` et `qwen2.5:1.5b` que comme baselines vitesse + +## Auto-sync +## Auto-sync + +- dernier cycle automatise: 2026-03-09T06:53:02+00:00 + +| Modele | Categorie | Preflight | Smoke | Classification | Failed stage | Gate | Repairs | Notes | +|---|---|---|---|---|---|---|---:|---| +| apple-coreml:qwen2.5-0.5b-instruct-onnx | baselines | OK | oui | quality_blocked | gate | oui | 2 | | +| ollama:qwen2.5:1.5b | baselines | OK | oui | quality_blocked | gate | oui | 2 | | + diff --git a/docs/roadmap.md b/docs/roadmap.md index b7732ac..baab6fc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1 +1,28 @@ -Roadmap v2 du projet. +# Roadmap v2 + +Roadmap courte et concrete, alignee sur l'etat reel du repo. + +## Priorite 1 - Passer au moins un modele jusqu'a `gate` + +- compacter `rewrite` pour qu'au moins un modele atteigne `gate` +- conserver la boucle `repair` et le garde-fou comme blocages durs +- viser d'abord `apple-coreml:qwen3.5-4b-onnx-q4f16` et `ollama:qwen2.5:7b` + +## Priorite 2 - Requalifier les modeles plus lourds + +- garder `apple-coreml:qwen2.5-0.5b-instruct-onnx` et `ollama:qwen2.5:1.5b` comme baselines vitesse +- rejouer `qwen3.5:9b` seulement si `qwen2.5:7b` termine un smoke complet +- maintenir les modeles toujours explicites dans les smokes et la doc +- tenir compte du fait que le runtime Apple local ne sert qu'un `model_id` a la fois + +## Priorite 3 - Exploitation locale et docs + +- runbook local ANE centre sur `rewrite`, `gate_v1.json`, `repair_vN.md` et `quality_blocked` +- runbook Apple local cote `mascarade` aligne sur les statuts reels +- README et suivi croises pointent vers `EXECUTION_PLAN_2026-03-08.md` + +## Source de verite + +- backlog actif: [`../TODO_ACTIVE.md`](../TODO_ACTIVE.md) +- etat livre: [`../TODO_IMPLEMENTE.md`](../TODO_IMPLEMENTE.md) +- ordre d'execution: [`EXECUTION_PLAN_2026-03-08.md`](./EXECUTION_PLAN_2026-03-08.md) diff --git a/docs/runbooks/LOCAL_GENERATION.md b/docs/runbooks/LOCAL_GENERATION.md new file mode 100644 index 0000000..08e6fda --- /dev/null +++ b/docs/runbooks/LOCAL_GENERATION.md @@ -0,0 +1,142 @@ +# Runbook local - generation ANE + +Runbook court pour lancer et diagnostiquer la generation locale via `mascarade`. + +Comparatif de reference: [`docs/MODEL_COMPARISON_2026-03-08.md`](../MODEL_COMPARISON_2026-03-08.md) + +## Prerequis + +- `mascarade` repond sur `http://127.0.0.1:8100/health` +- le modele reste explicite via `ANE_MODEL` ou `--model` +- une intention existe pour le chapitre cible +- le garde-fou manuscrit et la boucle `repair` peuvent bloquer la promotion meme avec `--approve` + +## Cycle automatise + +Commande de reference: + +```bash +python3 scripts/run_next_lots.py --lot full +``` + +Commandes utiles: + +```bash +python3 scripts/run_next_lots.py --lot priority_models +python3 scripts/run_next_lots.py --resume automation/state/next_lots_state.json +python3 scripts/run_next_lots.py --lot tracking_sync --report-only +``` + +Le driver: +- lit `automation/next_lots.toml` +- rejoue preflights et smokes dans l'ordre utile du moment +- met a jour les sections `AUTO-SYNC` des TODOs, plans, README et runbooks +- attend brievement que `/models` reflète le bon `model_id` apres un switch Apple avant de recréer un checkpoint manuel +- s'arrete proprement si un restart runtime ou un switch Apple est requis, puis imprime la commande de reprise + +## Contrat local + +`ai-novel-engine` parle uniquement a `mascarade` sur `POST /v1/chat/completions`. + +- format modele: `provider:model` +- dernier cycle complet termine au 9 mars 2026: + - `apple-coreml:qwen3.5-4b-onnx-q4f16` est `accepted` de bout en bout sous garde-fou + - `ollama:qwen2.5:7b` atteint `gate`, exerce `repair` en live, puis finit `quality_blocked` sur `outline_like` + - `apple-coreml:stateful-mistral7b-instruct-int4-coreml` reste `preflight_only` +- le lot `baselines` pour `apple-coreml:qwen2.5-0.5b-instruct-onnx` et `ollama:qwen2.5:1.5b` est rejoue separement +- le runtime Apple local ne sert qu'un seul `model_id` a la fois +- le fallback `repair` n'essaie plus de changer de modele `apple-coreml` en plein smoke; tout switch Apple reste une action runtime explicite + +## Smoke Apple + +Preflight minimal cote runtime: + +```bash +bash /Users/electron/mascarade/scripts/smoke_openai_compat_ane.sh \ + --url http://127.0.0.1:8100 \ + --model "apple-coreml:qwen3.5-4b-onnx-q4f16" +``` + +Smoke chapitre complet: + +```bash +./scripts/smoke_local_generation.sh \ + --base-url http://127.0.0.1:8100 \ + --model "apple-coreml:qwen3.5-4b-onnx-q4f16" \ + --approve +``` + +Notes: +- le script fait un warm-up automatique via `:8100` quand le modele commence par `apple-coreml:` +- le premier chargement Core ML peut etre long +- le smoke Apple applique par defaut un timeout plus large et des budgets plus courts; utiliser `--timeout` ou `ANE_MAX_TOKENS_*` pour durcir ou assouplir +- `ANE_MAX_TOKENS_GATE` permet de regler le budget du garde-fou LLM +- `ANE_MAX_TOKENS_REPAIR` et `ANE_REPAIR_MAX_PASSES` reglent la boucle `repair` +- `apple-coreml:qwen2.5-0.5b-instruct-onnx` reste le candidat vitesse Apple a requalifier en baseline +- `apple-coreml:qwen3.5-4b-onnx-q4f16` est la reference Apple locale actuelle +- `apple-coreml:stateful-mistral7b-instruct-int4-coreml` reste preflight-only sur cette machine: il repond, mais le smoke ANE est reste bloque a `structure` pendant plus de 8 minutes + +## Smoke Ollama + +Preflight: + +```bash +bash /Users/electron/mascarade/scripts/smoke_openai_compat_ane.sh \ + --url http://127.0.0.1:8100 \ + --model "ollama:qwen2.5:1.5b" +``` + +Le provider `ollama` doit apparaitre dans `providers` et le modele cible doit etre deja installe. +Sur cette machine, le meilleur candidat Ollama courant est `ollama:qwen2.5:7b`; `qwen2.5:1.5b` reste une baseline a rerun. + +Smoke chapitre complet: + +```bash +./scripts/smoke_local_generation.sh \ + --base-url http://127.0.0.1:8100 \ + --model "ollama:qwen2.5:1.5b" \ + --approve +``` + +## Lire rapidement le resultat + +Le smoke affiche un resume humain: + +- `backend` +- `chapter` +- `status` +- `accepted` +- `failed_stage` si present +- `quality_blockers` si presents +- `retry_stages` si present +- `repair_attempts` et `repair_models` si presents +- chemins vers `draft_v2`, `repair_latest`, `critique_v1`, `gate_v1`, `manuscript`, `memory_summary`, `meta.json` + +Le fichier de reference reste: + +```bash +cat brouillons/chapitres/chapitre_XX/meta.json +``` + +Champs utiles: +- `status` +- `last_status_message` +- `stage_attempts` +- `retry_stages` +- `failed_stage` +- `quality_blockers` +- `repair_attempts` +- `repair_models` +- `gate_report` +- `provider.base_url` +- `provider.model` + +## Etat auto-synchronise +## Etat auto-synchronise + +- dernier cycle automatise: 2026-03-09T06:53:02+00:00 +- chapitre courant detecte: chapitre_01 +- reference locale actuelle: aucun accepted, meilleur diagnostic: apple-coreml:qwen2.5-0.5b-instruct-onnx +- prochain lot utile: Analyser les runs ayant atteint gate/repair puis resserrer la reference locale autour des meilleurs candidats. +- reprise attendue apres action manuelle: /Users/electron/Documents/Projets_Creatifs/ai-novel-engine/automation/state/next_lots_state.json + diff --git a/docs/vision.md b/docs/vision.md index 5b7f496..d4aa180 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -1 +1,45 @@ -Vision du projet AI Novel Engine. +# Vision AI Novel Engine + +## Positionnement + +AI Novel Engine est un moteur narratif strict, local-first, pour projets longs. + +Le but n'est pas de "discuter avec un chatbot qui écrit un roman". Le but est +de garder un pipeline lisible, reproductible et contrôlable par l'auteur: + +`intention -> structure -> draft -> critique -> rewrite -> gate -> validation -> memoire` + +## Ce que porte le produit + +- l'auteur reste decisionnaire a chaque promotion vers le manuscrit +- aucune generation sans intention explicite +- aucune promotion vers le manuscrit si le garde-fou qualite bloque +- la memoire reste externe, inspectable et persistée sur disque +- les artefacts intermediaires sont lisibles en Markdown et JSON +- le moteur narratif reste decouple du runtime local + +## Architecture cible + +- `ai-novel-engine` porte la logique auteuriale, le pipeline, les prompts et la mémoire +- `mascarade` porte le runtime local, le routage provider et le shim OpenAI-compatible +- le contrat entre les deux reste minimal: + - `POST /v1/chat/completions` + - `model=provider:model` + - non-streaming + - JSON best effort, avec reessai applicatif cote ANE + +## Non-objectifs v1 + +- chat libre comme interface principale +- studio web riche ou collaboratif +- autonomie complete "idee -> manuscrit final" +- base de donnees opaque pour la mémoire + +## Critere de valeur + +Le systeme est utile si un auteur peut: + +- relancer un chapitre sans perdre le contexte de travail +- comprendre pourquoi une etape a echoue +- changer de backend local sans rewriter le pipeline narratif +- relire les brouillons, critiques et mises a jour memoire hors de l'IA diff --git a/prompts/critique_retry_v1.txt b/prompts/critique_retry_v1.txt new file mode 100644 index 0000000..1dde2d6 --- /dev/null +++ b/prompts/critique_retry_v1.txt @@ -0,0 +1,32 @@ +Tu es le rôle Contrôle du moteur AI Novel Engine. +La tentative précédente n'a pas produit un JSON exploitable. +Réponds à nouveau avec un seul objet JSON valide. +Ne mets aucun texte avant ou après le JSON. +Ne mets aucun bloc Markdown. +Si la tentative précédente est inutilisable, regénère le diagnostic à partir du contexte. +Limite-toi à 1 phrase de résumé, 3 écarts max et 3 recommandations max. + +Chapitre cible: $chapter_slug + +Erreur de parsing observée: +$parse_error + +Tentative précédente à corriger si possible: +$invalid_response + +Intention: +$intention + +Structure attendue: +$structure_markdown + +Brouillon à critiquer: +$draft_markdown + +Format JSON strict: +{ + "summary": "résumé bref du diagnostic", + "rewrite_required": true, + "deviations": ["écart 1", "écart 2"], + "recommendations": ["recommandation 1", "recommandation 2"] +} diff --git a/prompts/critique_v1.txt b/prompts/critique_v1.txt new file mode 100644 index 0000000..b3a329f --- /dev/null +++ b/prompts/critique_v1.txt @@ -0,0 +1,25 @@ +Tu es le rôle Contrôle du moteur AI Novel Engine. +Analyse le brouillon et renvoie uniquement un objet JSON valide. +Réponse compacte obligatoire. +Ne mets aucun texte avant ou après le JSON. +Ne mets aucun bloc Markdown. +Limite-toi à 1 phrase de résumé, 3 écarts max et 3 recommandations max. + +Chapitre cible: $chapter_slug + +Intention: +$intention + +Structure attendue: +$structure_markdown + +Brouillon à critiquer: +$draft_markdown + +Format JSON strict: +{ + "summary": "résumé bref du diagnostic", + "rewrite_required": true, + "deviations": ["écart 1", "écart 2"], + "recommendations": ["recommandation 1", "recommandation 2"] +} diff --git a/prompts/draft_v1.txt b/prompts/draft_v1.txt new file mode 100644 index 0000000..ff8322d --- /dev/null +++ b/prompts/draft_v1.txt @@ -0,0 +1,26 @@ +Tu es le rôle Production du moteur AI Novel Engine. +Tu rédiges un brouillon de chapitre fidèle à l'intention et à la structure. + +Chapitre cible: $chapter_slug + +Intention: +$intention + +Structure validée: +$structure_markdown + +Contexte projet: +$story_context + +Consignes: +- répondre uniquement avec le chapitre en Markdown +- produire uniquement de la prose narrative continue, sous forme de paragraphes +- ne jamais recopier la structure sous forme de plan +- interdit: titres Markdown (`#`, `##`, `###`), listes a puces, numerotations, labels `objectif`, `conflit`, `sortie`, section `Tension`, section `Scènes`, code fences +- garder une voix cohérente +- matérialiser la tension annoncée +- ouvrir directement dans l'action ou dans la scene, sans preambule meta +- transformer chaque beat de structure en action, perception, decision, consequence et, si utile, dialogue +- finir sur une vraie phrase complete avec une ponctuation finale +- viser un chapitre bref mais complet, d'au moins 3 paragraphes substantiels +- ne pas ajouter d'explication hors texte narratif diff --git a/prompts/gate_retry_v1.txt b/prompts/gate_retry_v1.txt new file mode 100644 index 0000000..ac33c6f --- /dev/null +++ b/prompts/gate_retry_v1.txt @@ -0,0 +1,34 @@ +Tu es le rôle Garde-fou manuscrit du moteur AI Novel Engine. +La tentative precedente n'a pas produit un JSON exploitable. +Reponds a nouveau avec un seul objet JSON valide. +Ne mets aucun texte avant ou apres le JSON. +Ne mets aucun bloc Markdown. +Si la tentative precedente est inutilisable, regénere le diagnostic a partir du brouillon final. +Bloque si le texte ressemble encore a un plan, s'il semble coupe avant sa fin, ou s'il manque une vraie continuite narrative. +Limite-toi a 1 phrase de resume, 4 blockers max et 4 recommandations max. + +Chapitre cible: $chapter_slug + +Erreur de parsing observee: +$parse_error + +Tentative precedente a corriger si possible: +$invalid_response + +Intention: +$intention + +Structure attendue: +$structure_markdown + +Brouillon final: +$draft_markdown + +Format JSON strict: +{ + "ready_for_manuscript": true, + "summary": "diagnostic bref", + "blockers": ["outline_like"], + "recommendations": ["retirer les titres", "terminer la scene"], + "heuristic_blockers": [] +} diff --git a/prompts/gate_v1.txt b/prompts/gate_v1.txt new file mode 100644 index 0000000..66f5c96 --- /dev/null +++ b/prompts/gate_v1.txt @@ -0,0 +1,27 @@ +Tu es le rôle Garde-fou manuscrit du moteur AI Novel Engine. +Analyse le brouillon final et renvoie uniquement un objet JSON valide. +Ne mets aucun texte avant ou après le JSON. +Ne mets aucun bloc Markdown. +Le but est de decider si ce texte peut etre promu dans le manuscrit. +Bloque si le texte ressemble encore a un plan, s'il semble coupe avant sa fin, ou s'il manque une vraie continuite narrative. +Limite-toi a 1 phrase de resume, 4 blockers max et 4 recommandations max. + +Chapitre cible: $chapter_slug + +Intention: +$intention + +Structure attendue: +$structure_markdown + +Brouillon final: +$draft_markdown + +Format JSON strict: +{ + "ready_for_manuscript": true, + "summary": "diagnostic bref", + "blockers": ["outline_like"], + "recommendations": ["retirer les titres", "terminer la scene"], + "heuristic_blockers": [] +} diff --git a/prompts/memory_retry_v1.txt b/prompts/memory_retry_v1.txt new file mode 100644 index 0000000..7e59e01 --- /dev/null +++ b/prompts/memory_retry_v1.txt @@ -0,0 +1,35 @@ +Tu es le rôle Mémoire du moteur AI Novel Engine. +La tentative précédente n'a pas produit un JSON exploitable. +Réponds à nouveau avec un seul objet JSON valide. +Ne mets aucun texte avant ou après le JSON. +Ne mets aucun bloc Markdown. +Si la tentative précédente est inutilisable, regénère la mémoire à partir du chapitre accepté. +Limite-toi à 1 résumé, 3 personnages max, 3 lieux max et 5 événements max. + +Chapitre cible: $chapter_slug + +Erreur de parsing observée: +$parse_error + +Tentative précédente à corriger si possible: +$invalid_response + +Contexte projet: +$story_context + +Chapitre accepté: +$accepted_draft + +Format JSON strict: +{ + "summary": "résumé factuel du chapitre", + "characters": [ + {"name": "Nom", "description": "Rôle ou évolution"} + ], + "locations": [ + {"name": "Lieu", "description": "Ce qui y est établi"} + ], + "timeline_events": [ + {"event": "Fait important", "order_hint": "optionnel"} + ] +} diff --git a/prompts/memory_v1.txt b/prompts/memory_v1.txt new file mode 100644 index 0000000..8aab241 --- /dev/null +++ b/prompts/memory_v1.txt @@ -0,0 +1,29 @@ +Tu es le rôle Mémoire du moteur AI Novel Engine. +Tu extrais une mémoire exploitable à partir d'un chapitre accepté. +Réponds uniquement avec un objet JSON valide. +Réponse compacte obligatoire. +Ne mets aucun texte avant ou après le JSON. +Ne mets aucun bloc Markdown. +Limite-toi à 1 résumé, 3 personnages max, 3 lieux max et 5 événements max. + +Chapitre cible: $chapter_slug + +Contexte projet: +$story_context + +Chapitre accepté: +$accepted_draft + +Format JSON strict: +{ + "summary": "résumé factuel du chapitre", + "characters": [ + {"name": "Nom", "description": "Rôle ou évolution"} + ], + "locations": [ + {"name": "Lieu", "description": "Ce qui y est établi"} + ], + "timeline_events": [ + {"event": "Fait important", "order_hint": "optionnel"} + ] +} diff --git a/prompts/repair_v1.txt b/prompts/repair_v1.txt new file mode 100644 index 0000000..35ad198 --- /dev/null +++ b/prompts/repair_v1.txt @@ -0,0 +1,32 @@ +Tu es le rôle Réparation prose du moteur AI Novel Engine. +Tu dois convertir un brouillon candidat bloqué par le garde-fou en un vrai chapitre lisible. + +Chapitre cible: $chapter_slug +Tentative de réparation: $repair_attempt +Modèle de réparation: $repair_model + +Intention: +$intention + +Structure attendue: +$structure_markdown + +Contexte projet: +$story_context + +Diagnostic du garde-fou: +$gate_json + +Brouillon à réparer: +$draft_markdown + +Consignes impératives: +- répondre uniquement avec la nouvelle version du chapitre en Markdown +- produire uniquement de la prose narrative continue en paragraphes +- ne garder aucun titre, aucune puce, aucune numérotation, aucun label de plan visible +- supprimer completement les mots `objectif`, `conflit`, `sortie`, `Scène`, `scene`, `Tension` s'ils apparaissent comme labels ou sous-titres +- transformer toute structure, note ou checklist en scene(s) jouee(s) avec actions, perceptions et consequences +- conserver l'intention et les informations utiles deja presentes +- allonger si besoin pour obtenir une scene complete et continue +- finir obligatoirement sur une vraie phrase complete avec une ponctuation finale +- ne rien ajouter avant ou apres le chapitre diff --git a/prompts/rewrite_v1.txt b/prompts/rewrite_v1.txt new file mode 100644 index 0000000..46cdd50 --- /dev/null +++ b/prompts/rewrite_v1.txt @@ -0,0 +1,27 @@ +Tu es le rôle Réécriture du moteur AI Novel Engine. +Tu dois produire une seconde version du chapitre à partir du brouillon et de la critique. + +Chapitre cible: $chapter_slug + +Intention: +$intention + +Structure: +$structure_markdown + +Brouillon initial: +$draft_markdown + +Critique structurée: +$critique_json + +Consignes de réécriture: +- répondre uniquement avec la version réécrite du chapitre en Markdown +- produire uniquement de la prose narrative continue, sous forme de paragraphes +- supprimer completement tout titre, toute puce, toute numérotation et tout label de plan +- si le brouillon ressemble a une structure ou a des notes, le convertir integralement en scene(s) racontee(s) +- ne jamais garder les termes `objectif`, `conflit`, `sortie`, `Tension`, `Scène` comme titres ou labels visibles +- conserver l'intention, mais augmenter la continuite dramatique d'une scene a l'autre +- materialiser les decisions et leurs consequences +- finir sur une vraie phrase complete avec une ponctuation finale +- ne rien ajouter avant ou apres le chapitre diff --git a/prompts/structure_v1.txt b/prompts/structure_v1.txt new file mode 100644 index 0000000..f4a7a6e --- /dev/null +++ b/prompts/structure_v1.txt @@ -0,0 +1,22 @@ +Tu es le rôle Structure du moteur AI Novel Engine. +Tu dois transformer une intention d'auteur en plan de chapitre actionnable. + +Chapitre cible: $chapter_slug + +Intention: +$intention + +Contexte projet: +$story_context + +Réponds uniquement en Markdown avec cette structure: +# Structure — $chapter_slug +## Objectif dramatique +## Tension +## Scènes +### Scène 1 — titre +- objectif: +- conflit: +- sortie: + +Ajoute autant de scènes que nécessaire, mais reste concret et opérable pour une génération de chapitre. diff --git a/scripts/run_next_lots.py b/scripts/run_next_lots.py new file mode 100755 index 0000000..503d278 --- /dev/null +++ b/scripts/run_next_lots.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from core.next_lots import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_local_generation.sh b/scripts/smoke_local_generation.sh new file mode 100755 index 0000000..fd10e57 --- /dev/null +++ b/scripts/smoke_local_generation.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +chapter="02" +workspace="" +base_url="${ANE_BASE_URL:-http://127.0.0.1:8100}" +model="${ANE_MODEL:-}" +timeout="${ANE_TIMEOUT:-}" +decision="approve" +intention="Chapitre court. Une femme arrive dans une ville de nuit, trouve un indice simple, et finit sur une decision risquee. Style direct, phrases courtes, ton sobre." + +usage() { + cat <<'EOF' +Usage: + ./scripts/smoke_local_generation.sh [options] + +Options: + --chapter N Chapter number to generate (default: 02) + --workspace DIR Reuse or create the smoke workspace in DIR + --intention TEXT Override the default smoke intention + --base-url URL OpenAI-compatible base URL (default: ANE_BASE_URL or http://127.0.0.1:8100) + --model MODEL Explicit model in provider:model format (required unless ANE_MODEL is already set) + --timeout SEC Provider timeout in seconds (default: 900 for apple-coreml, 300 otherwise) + --approve Promote without interactive prompt (default) + --reject Reject without interactive prompt + --no-approve Deprecated alias for --reject + -h, --help Show this help + +Environment: + ANE_BASE_URL Used if --base-url is not provided + ANE_MODEL Used if --model is not provided + ANE_TIMEOUT Default: 900 for apple-coreml, 300 otherwise + ANE_MAX_TOKENS Default: 192 for apple-coreml, 384 otherwise + ANE_MAX_TOKENS_STRUCTURE Default: 96 for apple-coreml, 224 otherwise + ANE_MAX_TOKENS_DRAFT Default: 192 for apple-coreml, 384 otherwise + ANE_MAX_TOKENS_CRITIQUE Default: 160 for apple-coreml, 512 otherwise + ANE_MAX_TOKENS_REWRITE Default: 192 for apple-coreml, 448 otherwise + ANE_MAX_TOKENS_GATE Default: 128 for apple-coreml, 384 otherwise + ANE_MAX_TOKENS_REPAIR Default: 160 for apple-coreml, 512 otherwise + ANE_MAX_TOKENS_MEMORY Default: 128 for apple-coreml, 320 otherwise +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --chapter) + chapter="${2:-}" + shift 2 + ;; + --workspace) + workspace="${2:-}" + shift 2 + ;; + --intention) + intention="${2:-}" + shift 2 + ;; + --base-url) + base_url="${2:-}" + shift 2 + ;; + --model) + model="${2:-}" + shift 2 + ;; + --timeout) + timeout="${2:-}" + shift 2 + ;; + --approve) + decision="approve" + shift + ;; + --reject|--no-approve) + decision="reject" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "${model}" ]]; then + echo "ANE model required. Pass --model provider:model or export ANE_MODEL." >&2 + exit 2 +fi + +if [[ -z "${workspace}" ]]; then + workspace="$(mktemp -d "${TMPDIR:-/tmp}/ane-local-smoke.XXXXXX")" +fi + +mkdir -p "${workspace}" + +if [[ -z "${timeout}" ]]; then + if [[ "${model}" == apple-coreml:* ]]; then + timeout="900" + else + timeout="300" + fi +fi + +if [[ "${model}" == apple-coreml:* ]]; then + export ANE_MAX_TOKENS="${ANE_MAX_TOKENS:-192}" + export ANE_MAX_TOKENS_STRUCTURE="${ANE_MAX_TOKENS_STRUCTURE:-96}" + export ANE_MAX_TOKENS_DRAFT="${ANE_MAX_TOKENS_DRAFT:-192}" + export ANE_MAX_TOKENS_CRITIQUE="${ANE_MAX_TOKENS_CRITIQUE:-160}" + export ANE_MAX_TOKENS_REWRITE="${ANE_MAX_TOKENS_REWRITE:-192}" + export ANE_MAX_TOKENS_GATE="${ANE_MAX_TOKENS_GATE:-128}" + export ANE_MAX_TOKENS_REPAIR="${ANE_MAX_TOKENS_REPAIR:-160}" + export ANE_MAX_TOKENS_MEMORY="${ANE_MAX_TOKENS_MEMORY:-128}" +else + export ANE_MAX_TOKENS="${ANE_MAX_TOKENS:-384}" + export ANE_MAX_TOKENS_STRUCTURE="${ANE_MAX_TOKENS_STRUCTURE:-224}" + export ANE_MAX_TOKENS_DRAFT="${ANE_MAX_TOKENS_DRAFT:-384}" + export ANE_MAX_TOKENS_CRITIQUE="${ANE_MAX_TOKENS_CRITIQUE:-512}" + export ANE_MAX_TOKENS_REWRITE="${ANE_MAX_TOKENS_REWRITE:-448}" + export ANE_MAX_TOKENS_GATE="${ANE_MAX_TOKENS_GATE:-384}" + export ANE_MAX_TOKENS_REPAIR="${ANE_MAX_TOKENS_REPAIR:-512}" + export ANE_MAX_TOKENS_MEMORY="${ANE_MAX_TOKENS_MEMORY:-320}" +fi + +export ANE_PROVIDER="${ANE_PROVIDER:-openai_compatible}" +export ANE_BASE_URL="${base_url}" +export ANE_MODEL="${model}" +export ANE_TIMEOUT="${timeout}" + +warmup_openai_compatible() { + python3 - <<'PY' +from __future__ import annotations + +import json +import os +import socket +import sys +import urllib.request + +base_url = os.environ["ANE_BASE_URL"].rstrip("/") +if base_url.endswith("/v1"): + url = f"{base_url}/chat/completions" +elif base_url.endswith("/chat/completions"): + url = base_url +else: + url = f"{base_url}/v1/chat/completions" + +payload = { + "model": os.environ["ANE_MODEL"], + "messages": [{"role": "user", "content": "Respond with exactly: ok"}], + "temperature": 0, + "max_tokens": 8, +} +body = json.dumps(payload).encode("utf-8") +headers = {"Content-Type": "application/json"} +api_key = os.environ.get("ANE_API_KEY", "").strip() +if api_key: + headers["Authorization"] = f"Bearer {api_key}" + +request = urllib.request.Request(url, data=body, headers=headers, method="POST") +try: + with urllib.request.urlopen(request, timeout=float(os.environ.get("ANE_TIMEOUT", "300"))) as response: + payload = json.loads(response.read().decode("utf-8")) +except (TimeoutError, socket.timeout) as exc: + print(f"Warm-up OpenAI-compatible failed: timeout after {os.environ.get('ANE_TIMEOUT', '300')}s", file=sys.stderr) + raise SystemExit(1) from exc + +content = payload["choices"][0]["message"]["content"] +print(f"Warm-up OpenAI-compatible OK: {content}") +PY +} + +prepare_workspace() { + python3 - <<'PY' +from __future__ import annotations + +from pathlib import Path +import os + +from core.chapters import ChapterId + +root = Path(os.environ["ANE_SMOKE_ROOT"]) +chapter_id = ChapterId.parse(os.environ["ANE_SMOKE_CHAPTER"]) +intention = os.environ["ANE_SMOKE_INTENTION"].strip() + +intentions_dir = root / "notes" / "intentions" +intentions_dir.mkdir(parents=True, exist_ok=True) +intention_path = intentions_dir / chapter_id.filename +if not intention_path.exists(): + intention_path.write_text( + f"# Intention — Chapitre {chapter_id.label}\n\n{intention}\n", + encoding="utf-8", + ) +PY +} + +print_summary() { + python3 - <<'PY' +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +from core.chapters import ChapterId + +root = Path(os.environ["ANE_SMOKE_ROOT"]) +chapter_id = ChapterId.parse(os.environ["ANE_SMOKE_CHAPTER"]) +meta_path = root / "brouillons" / "chapitres" / chapter_id.slug / "meta.json" + +print("") +print("Smoke summary") +print(f"- workspace: {root}") +print(f"- model: {os.environ['ANE_MODEL']}") +print(f"- chapter: {chapter_id.slug}") + +if not meta_path.exists(): + print("- meta: absent") + sys.exit(0) + +meta = json.loads(meta_path.read_text(encoding="utf-8")) +print(f"- status: {meta.get('status')}") +print(f"- accepted: {meta.get('accepted')}") +if meta.get("failed_stage"): + print(f"- failed_stage: {meta.get('failed_stage')}") +quality_blockers = meta.get("quality_blockers") or [] +if quality_blockers: + print(f"- quality_blockers: {', '.join(str(item) for item in quality_blockers)}") +retry_stages = meta.get("retry_stages") or [] +if retry_stages: + print(f"- retry_stages: {', '.join(str(item) for item in retry_stages)}") +print(f"- repair_attempts: {meta.get('repair_attempts', 0)}") +repair_models = meta.get("repair_models") or [] +if repair_models: + print(f"- repair_models: {', '.join(str(item) for item in repair_models)}") +print(f"- last_status_message: {meta.get('last_status_message', '')}") +artifacts = meta.get("artifacts", {}) +if isinstance(artifacts, dict): + draft_path = artifacts.get("repair_latest") or artifacts.get("draft_v2") + if draft_path: + print(f"- draft_path: {draft_path}") + for label, key in ( + ("draft_v2", "draft_v2"), + ("critique_v1", "critique_v1"), + ("repair_latest", "repair_latest"), + ("gate_v1", "gate_v1"), + ("manuscript", "manuscript"), + ("memory_summary", "memory_summary"), + ): + value = artifacts.get(key) + if value: + print(f"- {label}: {value}") +print(f"- meta: {meta_path}") +PY +} + +export ANE_SMOKE_ROOT="${workspace}" +export ANE_SMOKE_CHAPTER="${chapter}" +export ANE_SMOKE_INTENTION="${intention}" + +cd "${REPO_DIR}" +prepare_workspace + +if [[ "${ANE_MODEL}" == apple-coreml:* ]]; then + echo "Warm-up Apple runtime via ${ANE_BASE_URL} ..." + warmup_openai_compatible +fi + +decision_flag="--approve" +if [[ "${decision}" == "reject" ]]; then + decision_flag="--reject" +fi + +set +e +cli_output="$( + cd "${workspace}" && \ + PYTHONPATH="${REPO_DIR}${PYTHONPATH:+:${PYTHONPATH}}" \ + python3 -m cli.main generate chapter --chapter "${chapter}" "${decision_flag}" 2>&1 +)" +cli_exit=$? +set -e + +printf '%s\n' "${cli_output}" +print_summary + +if [[ ${cli_exit} -ne 0 ]]; then + exit "${cli_exit}" +fi diff --git a/tests/test_generation_pipeline.py b/tests/test_generation_pipeline.py new file mode 100644 index 0000000..ed4d497 --- /dev/null +++ b/tests/test_generation_pipeline.py @@ -0,0 +1,1121 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +import io +import json +from pathlib import Path +from types import SimpleNamespace +import tempfile +import unittest +from unittest import mock + +from cli.main import main +from core.chapters import ChapterConflictError, ChapterId, resolve_chapter_file +from core.generation.models import ControlReport, MemoryUpdate +from core.generation.pipeline import GenerationPipeline +from core.generation.provider import ( + GenerationRequest, + MockGenerationProvider, + OpenAICompatibleProvider, + ProviderConfig, + ProviderConfigurationError, + ProviderError, +) +from core.project.loader import ProjectState + + +class GenerationPipelineTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + intentions_dir = self.root / "notes" / "intentions" + intentions_dir.mkdir(parents=True, exist_ok=True) + (intentions_dir / "chapitre_01.md").write_text( + "# Intention — Chapitre 01\n\nInstaller la voix.\nCréer une tension sourde.\n", + encoding="utf-8", + ) + + def tearDown(self): + self.temp_dir.cleanup() + + def _narrative_text(self, *, ending: str = ".") -> str: + sentence = ( + "Ariane longe le quai vide, compte les fenetres allumees, ecoute les pas derriere elle " + "et serre dans sa poche un billet humide qui pourrait la condamner." + ) + text = " ".join(sentence for _ in range(12)).strip() + if ending: + text = text.rstrip(".!?…\"' ") + text = f"{text}{ending}" + return f"{text}\n" + + def _provider(self) -> MockGenerationProvider: + return MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": { + "summary": "Le brouillon manque d'escalade au milieu.", + "rewrite_required": True, + "deviations": ["Le conflit tarde à apparaître."], + "recommendations": ["Accentuer la menace dans la seconde scène."], + }, + "rewrite": self._narrative_text(), + "gate": { + "ready_for_manuscript": True, + "summary": "Le chapitre est narratif et peut etre promu.", + "blockers": [], + "recommendations": [], + "heuristic_blockers": [], + }, + "memory": { + "summary": "Le chapitre installe une menace diffuse autour de l'héroïne.", + "characters": [{"name": "Ariane", "description": "Héroïne troublée par un signe avant-coureur."}], + "locations": [{"name": "Port-Vieux", "description": "Quartier bruissant où la tension s'installe."}], + "timeline_events": [{"event": "Ariane perçoit le premier signe du basculement.", "order_hint": "soir"}], + }, + } + ) + + def _make_outcome(self, *, accepted: bool) -> SimpleNamespace: + return SimpleNamespace( + accepted=accepted, + status="accepted" if accepted else "rejected", + chapter_id=ChapterId.parse("1"), + draft_path=self.root / "brouillons" / "chapitres" / "chapitre_01" / "draft_v2.md", + critique_path=self.root / "brouillons" / "chapitres" / "chapitre_01" / "critique_v1.md", + gate_path=self.root / "brouillons" / "chapitres" / "chapitre_01" / "gate_v1.json", + meta_path=self.root / "brouillons" / "chapitres" / "chapitre_01" / "meta.json", + manuscript_path=(self.root / "manuscrit" / "chapitre_01.md") if accepted else None, + quality_blockers=[], + ) + + def test_generation_without_intention_fails_before_provider_call(self): + project_root = Path(self.temp_dir.name) / "missing" + project_root.mkdir(parents=True, exist_ok=True) + provider = self._provider() + pipeline = GenerationPipeline(project_root, provider=provider) + + with self.assertRaises(RuntimeError): + pipeline.generate_chapter("1", approval_callback=lambda _report, _path: False) + + self.assertEqual(provider.requests, []) + self.assertFalse((project_root / "brouillons").exists()) + self.assertFalse((project_root / "structure").exists()) + + def test_chapter_normalization_and_conflict_detection(self): + chapter = ChapterId.parse("1") + self.assertEqual(chapter.slug, "chapitre_01") + + intentions_dir = self.root / "notes" / "intentions" + (intentions_dir / "chapitre_1.md").write_text("# Intention legacy\n", encoding="utf-8") + + with self.assertRaises(ChapterConflictError): + resolve_chapter_file(intentions_dir, chapter) + + def test_generation_rejection_keeps_intermediate_artifacts_only(self): + provider = self._provider() + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("1", approval_callback=lambda _report, _path: False) + + self.assertFalse(outcome.accepted) + structure_path = self.root / "structure" / "chapitres" / "chapitre_01.md" + draft_dir = self.root / "brouillons" / "chapitres" / "chapitre_01" + self.assertTrue(structure_path.exists()) + self.assertTrue((draft_dir / "draft_v1.md").exists()) + self.assertTrue((draft_dir / "critique_v1.md").exists()) + self.assertTrue((draft_dir / "draft_v2.md").exists()) + self.assertTrue((draft_dir / "gate_v1.json").exists()) + self.assertFalse((self.root / "manuscrit" / "chapitre_01.md").exists()) + self.assertFalse((self.root / "memoire").exists()) + + meta = json.loads((draft_dir / "meta.json").read_text(encoding="utf-8")) + self.assertEqual(meta["status"], "rejected") + self.assertEqual(meta["completed_stages"], ["structure", "draft", "critique", "rewrite", "gate"]) + self.assertEqual(meta["stage_attempts"], {"structure": 1, "draft": 1, "critique": 1, "rewrite": 1, "gate": 1}) + self.assertEqual(meta["provider"], {"kind": "MockGenerationProvider", "base_url": None, "model": None}) + + def test_generation_acceptance_promotes_manuscript_and_memory(self): + provider = self._provider() + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertTrue(outcome.accepted) + manuscript_path = self.root / "manuscrit" / "chapitre_01.md" + memory_summary = self.root / "memoire" / "chapitres" / "chapitre_01.md" + characters_index = self.root / "memoire" / "index" / "personnages.json" + locations_index = self.root / "memoire" / "index" / "lieux.json" + timeline_index = self.root / "memoire" / "index" / "chronologie.json" + + self.assertTrue(manuscript_path.exists()) + self.assertIn("Ariane longe le quai", manuscript_path.read_text(encoding="utf-8")) + self.assertTrue(memory_summary.exists()) + self.assertTrue(characters_index.exists()) + self.assertTrue(locations_index.exists()) + self.assertTrue(timeline_index.exists()) + + characters = json.loads(characters_index.read_text(encoding="utf-8")) + self.assertEqual(characters["Ariane"]["chapters"], ["chapitre_01"]) + + meta = json.loads((self.root / "brouillons" / "chapitres" / "chapitre_01" / "meta.json").read_text(encoding="utf-8")) + self.assertEqual(meta["status"], "accepted") + self.assertEqual(meta["stage_attempts"]["gate"], 1) + self.assertEqual(meta["stage_attempts"]["memory"], 1) + self.assertEqual(meta["provider"]["kind"], "MockGenerationProvider") + + def test_generation_retries_invalid_json_for_critique_and_memory(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": [ + "Résumé libre sans JSON exploitable.", + { + "summary": "Le brouillon manque d'escalade au milieu.", + "rewrite_required": True, + "deviations": ["Le conflit tarde à apparaître."], + "recommendations": ["Accentuer la menace dans la seconde scène."], + }, + ], + "rewrite": self._narrative_text(), + "gate": { + "ready_for_manuscript": True, + "summary": "Le chapitre est narratif et peut etre promu.", + "blockers": [], + "recommendations": [], + "heuristic_blockers": [], + }, + "memory": [ + "Bloc mémoire illisible et non structuré.", + { + "summary": "Le chapitre installe une menace diffuse autour de l'héroïne.", + "characters": [{"name": "Ariane", "description": "Héroïne troublée par un signe avant-coureur."}], + "locations": [{"name": "Port-Vieux", "description": "Quartier bruissant où la tension s'installe."}], + "timeline_events": [ + {"event": "Ariane perçoit le premier signe du basculement.", "order_hint": "soir"} + ], + }, + ], + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertTrue(outcome.accepted) + self.assertEqual( + [request.stage for request in provider.requests], + ["structure", "draft", "critique", "critique", "rewrite", "gate", "memory", "memory"], + ) + manuscript_path = self.root / "manuscrit" / "chapitre_01.md" + self.assertTrue(manuscript_path.exists()) + meta = json.loads((self.root / "brouillons" / "chapitres" / "chapitre_01" / "meta.json").read_text(encoding="utf-8")) + self.assertEqual(meta["retry_stages"], ["critique", "memory"]) + self.assertEqual(meta["stage_attempts"]["critique"], 2) + self.assertEqual(meta["stage_attempts"]["memory"], 2) + + def test_quality_gate_blocks_outline_like_manuscript(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": { + "summary": "Le brouillon manque d'escalade au milieu.", + "rewrite_required": True, + "deviations": ["Le conflit tarde à apparaître."], + "recommendations": ["Accentuer la menace dans la seconde scène."], + }, + "rewrite": ( + "## Objectif dramatique\n" + "- **objectif**: Trouver l'indice.\n" + "- **conflit**: Echouer avant l'aube.\n" + "- **sortie**: Partir.\n" + ), + "repair": [ + "## Scène\n- **objectif**: Observer.\n- **conflit**: Trembler.\n- **sortie**: Fuir.\n", + "## Scène\n- **objectif**: Observer.\n- **conflit**: Trembler.\n- **sortie**: Fuir.\n", + ], + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertFalse(outcome.accepted) + self.assertEqual(outcome.status, "quality_blocked") + self.assertIn("outline_like", outcome.quality_blockers) + self.assertEqual( + [request.stage for request in provider.requests], + ["structure", "draft", "critique", "rewrite", "repair", "repair"], + ) + self.assertFalse((self.root / "manuscrit" / "chapitre_01.md").exists()) + + meta = json.loads((self.root / "brouillons" / "chapitres" / "chapitre_01" / "meta.json").read_text(encoding="utf-8")) + self.assertEqual(meta["status"], "quality_blocked") + self.assertEqual(meta["failed_stage"], "gate") + self.assertIn("outline_like", meta["quality_blockers"]) + + def test_outline_like_triggers_repair_and_promotes_repaired_manuscript(self): + repaired_text = self._narrative_text() + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": { + "summary": "Le brouillon ressemble encore à un plan.", + "rewrite_required": True, + "deviations": ["Le texte reste structuré comme des notes."], + "recommendations": ["Le convertir en narration continue."], + }, + "rewrite": ( + "## Objectif dramatique\n" + "- **objectif**: Entrer.\n" + "- **conflit**: Etre vue.\n" + "- **sortie**: Partir.\n" + ), + "repair": repaired_text, + "gate": { + "ready_for_manuscript": True, + "summary": "Le chapitre reparé peut etre promu.", + "blockers": [], + "recommendations": [], + "heuristic_blockers": [], + }, + "memory": { + "summary": "Le chapitre installe une entrée risquée dans un lieu surveillé.", + "characters": [{"name": "Ariane", "description": "Observe et decide vite."}], + "locations": [{"name": "Port-Vieux", "description": "Quartier nocturne et tendu."}], + "timeline_events": [{"event": "Ariane franchit le seuil interdit.", "order_hint": "nuit"}], + }, + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertTrue(outcome.accepted) + self.assertEqual(outcome.status, "accepted") + self.assertEqual(outcome.draft_path.name, "repair_v1.md") + self.assertTrue(outcome.manuscript_path and outcome.manuscript_path.exists()) + self.assertEqual(outcome.manuscript_path.read_text(encoding="utf-8"), repaired_text) + self.assertEqual( + [request.stage for request in provider.requests], + ["structure", "draft", "critique", "rewrite", "repair", "gate", "memory"], + ) + + meta = json.loads((self.root / "brouillons" / "chapitres" / "chapitre_01" / "meta.json").read_text(encoding="utf-8")) + self.assertEqual(meta["status"], "accepted") + self.assertEqual(meta["repair_attempts"], 1) + self.assertEqual(meta["stage_attempts"]["repair"], 1) + self.assertEqual(meta["stage_attempts"]["gate"], 2) + self.assertEqual(meta["artifacts"]["repair_latest"], str(self.root / "brouillons" / "chapitres" / "chapitre_01" / "repair_v1.md")) + self.assertEqual(meta["draft_final"], str(self.root / "brouillons" / "chapitres" / "chapitre_01" / "repair_v1.md")) + + def test_truncated_ending_triggers_repair_before_promotion(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": { + "summary": "La fin reste ouverte de facon accidentelle.", + "rewrite_required": True, + "deviations": ["La derniere phrase est tronquee."], + "recommendations": ["Fermer la scene sur une vraie phrase."], + }, + "rewrite": self._narrative_text().rstrip(".\n") + "\n", + "repair": self._narrative_text(), + "gate": { + "ready_for_manuscript": True, + "summary": "La fin est maintenant complete.", + "blockers": [], + "recommendations": [], + "heuristic_blockers": [], + }, + "memory": { + "summary": "Le chapitre se clot sur une decision nette.", + "characters": [{"name": "Ariane", "description": "Choisit d'avancer malgré le risque."}], + "locations": [{"name": "Port-Vieux", "description": "Zone de transition menaçante."}], + "timeline_events": [{"event": "Ariane tranche et passe a l'acte.", "order_hint": "nuit"}], + }, + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertTrue(outcome.accepted) + self.assertEqual(outcome.draft_path.name, "repair_v1.md") + self.assertIn("repair", [request.stage for request in provider.requests]) + + def test_too_short_triggers_repair_before_promotion(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": { + "summary": "Le chapitre reste trop court.", + "rewrite_required": True, + "deviations": ["La scene ne va pas assez loin."], + "recommendations": ["Allonger la scene et la consequence."], + }, + "rewrite": "Ariane pousse la porte et comprend trop tard qu'elle est attendue.\n", + "repair": self._narrative_text(), + "gate": { + "ready_for_manuscript": True, + "summary": "La scene est complete.", + "blockers": [], + "recommendations": [], + "heuristic_blockers": [], + }, + "memory": { + "summary": "La scene s'etire enfin jusqu'a une vraie consequence.", + "characters": [{"name": "Ariane", "description": "Va au bout de sa decision."}], + "locations": [{"name": "Port-Vieux", "description": "Le lieu absorbe sa décision."}], + "timeline_events": [{"event": "Ariane entre malgré l'alerte.", "order_hint": "nuit"}], + }, + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertTrue(outcome.accepted) + self.assertEqual(outcome.draft_path.name, "repair_v1.md") + self.assertIn("repair", [request.stage for request in provider.requests]) + + def test_quality_gate_blocks_after_exhausting_repair_passes(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": { + "summary": "Le brouillon ressemble encore à un plan.", + "rewrite_required": True, + "deviations": ["Le texte reste structuré comme des notes."], + "recommendations": ["Le convertir en narration continue."], + }, + "rewrite": ( + "## Objectif dramatique\n" + "- **objectif**: Entrer.\n" + "- **conflit**: Etre vue.\n" + "- **sortie**: Partir.\n" + ), + "repair": [ + "## Scène\n- **objectif**: Observer.\n- **conflit**: Trembler.\n- **sortie**: Fuir.\n", + "## Scène\n- **objectif**: Observer.\n- **conflit**: Trembler.\n- **sortie**: Fuir.\n", + ], + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertFalse(outcome.accepted) + self.assertEqual(outcome.status, "quality_blocked") + self.assertEqual(outcome.draft_path.name, "repair_v2.md") + self.assertIn("outline_like", outcome.quality_blockers) + self.assertEqual( + [request.stage for request in provider.requests], + ["structure", "draft", "critique", "rewrite", "repair", "repair"], + ) + meta = json.loads((self.root / "brouillons" / "chapitres" / "chapitre_01" / "meta.json").read_text(encoding="utf-8")) + self.assertEqual(meta["repair_attempts"], 2) + self.assertEqual(meta["stage_attempts"]["repair"], 2) + self.assertEqual(meta["stage_attempts"]["gate"], 3) + self.assertFalse((self.root / "manuscrit" / "chapitre_01.md").exists()) + + def test_quality_gate_blocks_too_short_and_truncated_manuscript(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": { + "summary": "Le brouillon manque d'escalade au milieu.", + "rewrite_required": True, + "deviations": ["Le conflit tarde à apparaître."], + "recommendations": ["Accentuer la menace dans la seconde scène."], + }, + "rewrite": "Ariane s'arrete devant la porte, retient son souffle et comprend que le bruit revient\n", + "repair": [ + "Ariane avance encore mais la phrase reste suspendue\n", + "Ariane avance encore mais la phrase reste suspendue\n", + ], + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertFalse(outcome.accepted) + self.assertEqual(outcome.status, "quality_blocked") + self.assertIn("too_short", outcome.quality_blockers) + self.assertIn("truncated_ending", outcome.quality_blockers) + self.assertFalse((self.root / "manuscrit" / "chapitre_01.md").exists()) + + def test_force_accept_does_not_bypass_quality_gate(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": { + "summary": "Le brouillon manque d'escalade au milieu.", + "rewrite_required": True, + "deviations": ["Le conflit tarde à apparaître."], + "recommendations": ["Accentuer la menace dans la seconde scène."], + }, + "rewrite": "Ariane entend un pas et se retourne\n", + "repair": [ + "Ariane entend un pas et se retourne\n", + "Ariane entend un pas et se retourne\n", + ], + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + outcome = pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertFalse(outcome.accepted) + self.assertEqual(outcome.status, "quality_blocked") + self.assertFalse((self.root / "manuscrit" / "chapitre_01.md").exists()) + self.assertIn("repair", [request.stage for request in provider.requests]) + + def test_generation_retries_invalid_json_for_gate_and_then_fails(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": { + "summary": "Le brouillon manque d'escalade au milieu.", + "rewrite_required": True, + "deviations": ["Le conflit tarde à apparaître."], + "recommendations": ["Accentuer la menace dans la seconde scène."], + }, + "rewrite": self._narrative_text(), + "gate": ["Toujours pas du JSON.", "Encore une reponse inutilisable."], + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + with self.assertRaises(ProviderError) as context: + pipeline.generate_chapter("01", approval_callback=lambda _report, _path: True) + + self.assertIn("après deux tentatives", str(context.exception)) + self.assertEqual( + [request.stage for request in provider.requests], + ["structure", "draft", "critique", "rewrite", "gate", "gate"], + ) + meta = json.loads((self.root / "brouillons" / "chapitres" / "chapitre_01" / "meta.json").read_text(encoding="utf-8")) + self.assertEqual(meta["status"], "failed") + self.assertEqual(meta["failed_stage"], "gate") + self.assertEqual(meta["retry_stages"], ["gate"]) + self.assertEqual(meta["stage_attempts"]["gate"], 2) + + def test_generation_fails_if_json_is_invalid_after_two_attempts(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": "# Chapitre 01\n\nUn premier jet tendu.\n", + "critique": [ + "Toujours pas du JSON.", + "Encore une réponse non exploitable.", + ], + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + with self.assertRaises(ProviderError) as context: + pipeline.generate_chapter("01", approval_callback=lambda _report, _path: False) + + self.assertIn("après deux tentatives", str(context.exception)) + self.assertEqual( + [request.stage for request in provider.requests], + ["structure", "draft", "critique", "critique"], + ) + + meta = json.loads( + (self.root / "brouillons" / "chapitres" / "chapitre_01" / "meta.json").read_text(encoding="utf-8") + ) + self.assertEqual(meta["status"], "failed") + self.assertEqual(meta["failed_stage"], "critique") + self.assertEqual(meta["retry_stages"], ["critique"]) + self.assertEqual(meta["stage_attempts"]["critique"], 2) + + def test_provider_error_preserves_existing_artifacts_and_marks_failure(self): + provider = MockGenerationProvider( + { + "structure": "# Structure — chapitre_01\n\n## Objectif dramatique\nPoser une menace.\n", + "draft": ProviderError("Panne réseau pendant le brouillon."), + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + with self.assertRaises(ProviderError): + pipeline.generate_chapter("1", approval_callback=lambda _report, _path: False) + + structure_path = self.root / "structure" / "chapitres" / "chapitre_01.md" + meta_path = self.root / "brouillons" / "chapitres" / "chapitre_01" / "meta.json" + self.assertTrue(structure_path.exists()) + self.assertTrue(meta_path.exists()) + self.assertFalse((self.root / "brouillons" / "chapitres" / "chapitre_01" / "draft_v1.md").exists()) + + meta = json.loads(meta_path.read_text(encoding="utf-8")) + self.assertEqual(meta["status"], "failed") + self.assertEqual(meta["failed_stage"], "draft") + self.assertIn("Panne réseau", meta["error"]) + self.assertEqual(meta["stage_attempts"], {"structure": 1, "draft": 1}) + + def test_project_status_reports_latest_draft(self): + provider = self._provider() + pipeline = GenerationPipeline(self.root, provider=provider) + pipeline.generate_chapter("1", approval_callback=lambda _report, _path: False) + + state = ProjectState(self.root).summary() + self.assertEqual(state["current_chapter"], "chapitre_01") + self.assertEqual(state["latest_drafts"], {"chapitre_01": "draft_v2.md"}) + + def test_project_status_reports_failures_quality_blocked_and_waiting_acceptance(self): + failed_dir = self.root / "brouillons" / "chapitres" / "chapitre_01" + failed_dir.mkdir(parents=True, exist_ok=True) + (failed_dir / "meta.json").write_text( + json.dumps( + { + "chapter": "chapitre_01", + "status": "failed", + "failed_stage": "critique", + "retry_stages": ["critique"], + "last_status_message": "Échec à l'étape critique.", + "artifacts": { + "draft_v2": str(failed_dir / "draft_v2.md"), + "critique_v1": str(failed_dir / "critique_v1.md"), + }, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + blocked_dir = self.root / "brouillons" / "chapitres" / "chapitre_03" + blocked_dir.mkdir(parents=True, exist_ok=True) + (blocked_dir / "meta.json").write_text( + json.dumps( + { + "chapter": "chapitre_03", + "status": "quality_blocked", + "failed_stage": "gate", + "retry_stages": ["gate"], + "quality_blockers": ["too_short", "truncated_ending"], + "last_status_message": "Promotion bloquée par le garde-fou manuscrit.", + "repair_attempts": 2, + "repair_models": ["ollama:qwen2.5:1.5b", "ollama:qwen2.5:7b"], + "artifacts": { + "draft_v2": str(blocked_dir / "draft_v2.md"), + "repair_latest": str(blocked_dir / "repair_v2.md"), + "gate_v1": str(blocked_dir / "gate_v1.json"), + }, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + awaiting_dir = self.root / "brouillons" / "chapitres" / "chapitre_02" + awaiting_dir.mkdir(parents=True, exist_ok=True) + (awaiting_dir / "meta.json").write_text( + json.dumps( + { + "chapter": "chapitre_02", + "status": "awaiting_acceptance", + "retry_stages": ["memory"], + "last_status_message": "Brouillon final prêt pour validation.", + "repair_attempts": 1, + "repair_models": ["apple-coreml:qwen3.5-4b-onnx-q4f16"], + "artifacts": { + "draft_v2": str(awaiting_dir / "draft_v2.md"), + "repair_latest": str(awaiting_dir / "repair_v1.md"), + "critique_v1": str(awaiting_dir / "critique_v1.md"), + "gate_v1": str(awaiting_dir / "gate_v1.json"), + }, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + state = ProjectState(self.root).summary() + self.assertEqual( + state["failed_chapters"], + [ + { + "chapter": "chapitre_01", + "status": "failed", + "failed_stage": "critique", + "meta_path": str(failed_dir / "meta.json"), + "retry_stages": ["critique"], + "last_status_message": "Échec à l'étape critique.", + } + ], + ) + self.assertEqual( + state["awaiting_acceptance"], + [ + { + "chapter": "chapitre_02", + "status": "awaiting_acceptance", + "draft_path": str(awaiting_dir / "repair_v1.md"), + "critique_path": str(awaiting_dir / "critique_v1.md"), + "gate_path": str(awaiting_dir / "gate_v1.json"), + "meta_path": str(awaiting_dir / "meta.json"), + "retry_stages": ["memory"], + "repair_attempts": 1, + "repair_models": ["apple-coreml:qwen3.5-4b-onnx-q4f16"], + "last_status_message": "Brouillon final prêt pour validation.", + } + ], + ) + self.assertEqual( + state["quality_blocked_chapters"], + [ + { + "chapter": "chapitre_03", + "status": "quality_blocked", + "failed_stage": "gate", + "meta_path": str(blocked_dir / "meta.json"), + "draft_path": str(blocked_dir / "repair_v2.md"), + "gate_path": str(blocked_dir / "gate_v1.json"), + "quality_blockers": ["too_short", "truncated_ending"], + "retry_stages": ["gate"], + "repair_attempts": 2, + "repair_models": ["ollama:qwen2.5:1.5b", "ollama:qwen2.5:7b"], + "last_status_message": "Promotion bloquée par le garde-fou manuscrit.", + } + ], + ) + self.assertEqual(state["retry_stages"], {"chapitre_01": ["critique"], "chapitre_02": ["memory"], "chapitre_03": ["gate"]}) + self.assertEqual(state["latest_drafts"], {"chapitre_02": "repair_v1.md", "chapitre_03": "repair_v2.md"}) + self.assertEqual(state["latest_repairs"], {"chapitre_02": "repair_v1.md", "chapitre_03": "repair_v2.md"}) + + def test_cli_write_alias_runs_pipeline(self): + output = io.StringIO() + + with mock.patch("cli.main.GenerationPipeline") as pipeline_cls: + pipeline_instance = pipeline_cls.return_value + pipeline_instance.generate_chapter.return_value = self._make_outcome(accepted=False) + + with redirect_stdout(output): + exit_code = main(["write", "--chapter", "1"], root=self.root) + + self.assertEqual(exit_code, 0) + pipeline_cls.assert_called_once() + pipeline_instance.generate_chapter.assert_called_once_with("1", approval_callback=None) + self.assertIn("Chapitre traité : chapitre_01", output.getvalue()) + + def test_cli_generate_approve_uses_non_interactive_acceptance(self): + with mock.patch("cli.main.GenerationPipeline") as pipeline_cls: + pipeline_instance = pipeline_cls.return_value + + def generate(chapter, approval_callback=None): + self.assertEqual(chapter, "1") + self.assertIsNotNone(approval_callback) + self.assertTrue( + approval_callback( + ControlReport("ok", [], [], False), + self.root / "brouillons" / "chapitres" / "chapitre_01" / "draft_v2.md", + ) + ) + return self._make_outcome(accepted=True) + + pipeline_instance.generate_chapter.side_effect = generate + exit_code = main(["generate", "chapter", "--chapter", "1", "--approve"], root=self.root) + + self.assertEqual(exit_code, 0) + + def test_cli_generate_reject_uses_non_interactive_rejection(self): + with mock.patch("cli.main.GenerationPipeline") as pipeline_cls: + pipeline_instance = pipeline_cls.return_value + + def generate(chapter, approval_callback=None): + self.assertEqual(chapter, "1") + self.assertIsNotNone(approval_callback) + self.assertFalse( + approval_callback( + ControlReport("ok", [], [], False), + self.root / "brouillons" / "chapitres" / "chapitre_01" / "draft_v2.md", + ) + ) + return self._make_outcome(accepted=False) + + pipeline_instance.generate_chapter.side_effect = generate + exit_code = main(["generate", "chapter", "--chapter", "1", "--reject"], root=self.root) + + self.assertEqual(exit_code, 0) + + def test_cli_reject_and_approve_are_mutually_exclusive(self): + with self.assertRaises(SystemExit) as context: + main(["write", "--chapter", "1", "--approve", "--reject"], root=self.root) + + self.assertEqual(context.exception.code, 2) + + def test_status_output_includes_failures_quality_gate_and_waiting_acceptance(self): + failed_dir = self.root / "brouillons" / "chapitres" / "chapitre_01" + failed_dir.mkdir(parents=True, exist_ok=True) + (failed_dir / "meta.json").write_text( + json.dumps( + { + "chapter": "chapitre_01", + "status": "failed", + "failed_stage": "memory", + "retry_stages": ["critique"], + "last_status_message": "Échec à l'étape memory: timeout.", + "artifacts": {}, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + blocked_dir = self.root / "brouillons" / "chapitres" / "chapitre_03" + blocked_dir.mkdir(parents=True, exist_ok=True) + (blocked_dir / "meta.json").write_text( + json.dumps( + { + "chapter": "chapitre_03", + "status": "quality_blocked", + "failed_stage": "gate", + "retry_stages": ["gate"], + "quality_blockers": ["outline_like"], + "last_status_message": "Promotion bloquée par le garde-fou manuscrit.", + "repair_attempts": 2, + "repair_models": ["ollama:qwen2.5:1.5b", "ollama:qwen2.5:7b"], + "artifacts": { + "draft_v2": str(blocked_dir / "draft_v2.md"), + "repair_latest": str(blocked_dir / "repair_v2.md"), + "gate_v1": str(blocked_dir / "gate_v1.json"), + }, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + awaiting_dir = self.root / "brouillons" / "chapitres" / "chapitre_02" + awaiting_dir.mkdir(parents=True, exist_ok=True) + (awaiting_dir / "meta.json").write_text( + json.dumps( + { + "chapter": "chapitre_02", + "status": "awaiting_acceptance", + "retry_stages": ["memory"], + "last_status_message": "Brouillon final prêt pour validation.", + "repair_attempts": 1, + "repair_models": ["apple-coreml:qwen3.5-4b-onnx-q4f16"], + "artifacts": { + "draft_v2": str(awaiting_dir / "draft_v2.md"), + "repair_latest": str(awaiting_dir / "repair_v1.md"), + "critique_v1": str(awaiting_dir / "critique_v1.md"), + "gate_v1": str(awaiting_dir / "gate_v1.json"), + }, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + output = io.StringIO() + with redirect_stdout(output): + exit_code = main(["status"], root=self.root) + + self.assertEqual(exit_code, 0) + rendered = output.getvalue() + self.assertIn("Chapitres en échec:", rendered) + self.assertIn("status=failed", rendered) + self.assertIn("failed_stage=memory", rendered) + self.assertIn("timeout", rendered) + self.assertIn("Dernières réparations:", rendered) + self.assertIn("Bloqués par garde-fou:", rendered) + self.assertIn("status=quality_blocked", rendered) + self.assertIn("outline_like", rendered) + self.assertIn("réparations: 2", rendered) + self.assertIn("En attente de validation:", rendered) + self.assertIn("status=awaiting_acceptance", rendered) + self.assertIn("chapitre_02", rendered) + + +class ProviderConfigTests(unittest.TestCase): + def test_provider_config_reads_global_and_stage_token_budgets(self): + config = ProviderConfig.from_env( + { + "ANE_PROVIDER": "openai_compatible", + "ANE_BASE_URL": "http://127.0.0.1:8100", + "ANE_MODEL": "apple-coreml:qwen3.5-4b-onnx-q4f16", + "ANE_TIMEOUT": "45", + "ANE_MAX_TOKENS": "512", + "ANE_MAX_TOKENS_CRITIQUE": "384", + "ANE_MAX_TOKENS_MEMORY": "192", + } + ) + + self.assertEqual(config.max_tokens, 512) + self.assertEqual(config.timeout, 45.0) + self.assertEqual(config.stage_max_tokens, {"critique": 384, "memory": 192}) + self.assertEqual(config.max_tokens_for_stage("draft"), 512) + self.assertEqual(config.max_tokens_for_stage("critique"), 384) + + def test_provider_config_reads_gate_token_budget(self): + config = ProviderConfig.from_env( + { + "ANE_BASE_URL": "http://127.0.0.1:8100", + "ANE_MODEL": "ollama:qwen2.5:7b", + "ANE_MAX_TOKENS": "512", + "ANE_MAX_TOKENS_GATE": "333", + "ANE_MAX_TOKENS_REPAIR": "444", + } + ) + + self.assertEqual(config.stage_max_tokens, {"gate": 333, "repair": 444}) + self.assertEqual(config.max_tokens_for_stage("gate"), 333) + self.assertEqual(config.max_tokens_for_stage("repair"), 444) + + def test_repair_fallback_policy_uses_expected_models(self): + pipeline = GenerationPipeline(Path.cwd()) + provider = OpenAICompatibleProvider( + ProviderConfig( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="ollama:qwen2.5:1.5b", + timeout=30.0, + max_tokens=321, + stage_max_tokens={}, + ) + ) + + self.assertEqual(pipeline._repair_model_for_attempt(provider, 1), "ollama:qwen2.5:1.5b") + self.assertEqual(pipeline._repair_model_for_attempt(provider, 2), "ollama:qwen2.5:7b") + + apple_provider = OpenAICompatibleProvider( + ProviderConfig( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="apple-coreml:qwen2.5-0.5b-instruct-onnx", + timeout=30.0, + max_tokens=321, + stage_max_tokens={}, + ) + ) + self.assertEqual( + pipeline._repair_model_for_attempt(apple_provider, 2), + "ollama:qwen2.5:7b", + ) + + def test_repair_fallback_override_env_wins(self): + pipeline = GenerationPipeline(Path.cwd()) + provider = OpenAICompatibleProvider( + ProviderConfig( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="apple-coreml:qwen3.5-4b-onnx-q4f16", + timeout=30.0, + max_tokens=321, + stage_max_tokens={}, + ) + ) + + with mock.patch.dict("os.environ", {"ANE_REPAIR_FALLBACK_MODEL": "ollama:qwen2.5:7b"}, clear=False): + self.assertEqual(pipeline._repair_model_for_attempt(provider, 2), "ollama:qwen2.5:7b") + + def test_repair_fallback_override_rejects_cross_apple_switch(self): + pipeline = GenerationPipeline(Path.cwd()) + provider = OpenAICompatibleProvider( + ProviderConfig( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="apple-coreml:qwen2.5-0.5b-instruct-onnx", + timeout=30.0, + max_tokens=321, + stage_max_tokens={}, + ) + ) + + with mock.patch.dict( + "os.environ", + {"ANE_REPAIR_FALLBACK_MODEL": "apple-coreml:qwen3.5-4b-onnx-q4f16"}, + clear=False, + ): + with self.assertRaises(ProviderError): + pipeline._repair_model_for_attempt(provider, 2) + + def test_provider_config_rejects_invalid_ane_max_tokens(self): + with self.assertRaises(ProviderConfigurationError): + ProviderConfig.from_env( + { + "ANE_BASE_URL": "http://127.0.0.1:8100", + "ANE_MODEL": "apple-coreml:qwen3.5-4b-onnx-q4f16", + "ANE_MAX_TOKENS": "zero", + } + ) + + def test_provider_config_rejects_invalid_stage_token_budget(self): + with self.assertRaises(ProviderConfigurationError): + ProviderConfig.from_env( + { + "ANE_BASE_URL": "http://127.0.0.1:8100", + "ANE_MODEL": "apple-coreml:qwen3.5-4b-onnx-q4f16", + "ANE_MAX_TOKENS": "256", + "ANE_MAX_TOKENS_CRITIQUE": "zero", + } + ) + + def test_openai_provider_uses_stage_specific_token_budget(self): + provider = OpenAICompatibleProvider( + ProviderConfig( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="apple-coreml:qwen3.5-4b-onnx-q4f16", + timeout=30.0, + max_tokens=321, + stage_max_tokens={"critique": 654}, + ) + ) + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps( + { + "model": "apple-coreml:qwen3.5-4b-onnx-q4f16", + "choices": [ + {"message": {"content": "ok"}}, + ], + } + ).encode("utf-8") + + with mock.patch("core.generation.provider.request.urlopen", return_value=FakeResponse()) as urlopen_mock: + provider.generate(GenerationRequest(stage="critique", prompt="hello")) + + http_request = urlopen_mock.call_args.args[0] + payload = json.loads(http_request.data.decode("utf-8")) + self.assertEqual(payload["max_tokens"], 654) + + def test_explicit_request_budget_overrides_stage_budget(self): + provider = OpenAICompatibleProvider( + ProviderConfig( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="apple-coreml:qwen3.5-4b-onnx-q4f16", + timeout=30.0, + max_tokens=321, + stage_max_tokens={"critique": 654}, + ) + ) + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps( + { + "model": "apple-coreml:qwen3.5-4b-onnx-q4f16", + "choices": [ + {"message": {"content": "ok"}}, + ], + } + ).encode("utf-8") + + with mock.patch("core.generation.provider.request.urlopen", return_value=FakeResponse()) as urlopen_mock: + provider.generate( + GenerationRequest( + stage="critique", + prompt="hello", + max_tokens=111, + ) + ) + + http_request = urlopen_mock.call_args.args[0] + payload = json.loads(http_request.data.decode("utf-8")) + self.assertEqual(payload["max_tokens"], 111) + + def test_openai_provider_wraps_timeout_error(self): + provider = OpenAICompatibleProvider( + ProviderConfig( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="ollama:qwen2.5:1.5b", + timeout=12.0, + max_tokens=321, + stage_max_tokens={}, + ) + ) + + with mock.patch("core.generation.provider.request.urlopen", side_effect=TimeoutError("timed out")): + with self.assertRaises(ProviderError) as context: + provider.generate(GenerationRequest(stage="structure", prompt="hello")) + + self.assertIn("Timeout du provider", str(context.exception)) + self.assertIn("structure", str(context.exception)) + + +class JsonRepairTests(unittest.TestCase): + def test_control_report_recovers_json_with_trailing_text(self): + report = ControlReport.from_response_text( + 'Avant propos inutile\n' + '{"summary":"Diagnostic bref","rewrite_required":true,' + '"deviations":["écart 1"],"recommendations":["action 1"]}\n' + 'Texte à ignorer' + ) + + self.assertEqual(report.summary, "Diagnostic bref") + self.assertTrue(report.rewrite_required) + self.assertEqual(report.deviations, ["écart 1"]) + + def test_control_report_recovers_missing_closing_brace(self): + report = ControlReport.from_response_text( + '{"summary":"Diagnostic bref","rewrite_required":true,' + '"deviations":["écart 1"],"recommendations":["action 1"]' + ) + + self.assertEqual(report.recommendations, ["action 1"]) + + def test_memory_update_recovers_trailing_commas(self): + memory = MemoryUpdate.from_response_text( + '{"summary":"Résumé",' + '"characters":[{"name":"Ariane","description":"Heroine"},],' + '"locations":[{"name":"Port-Vieux","description":"Quartier"},],' + '"timeline_events":[{"event":"Décision","order_hint":"nuit"},],}' + ) + + self.assertEqual(memory.chapter_summary, "Résumé") + self.assertEqual(memory.characters[0]["name"], "Ariane") + self.assertEqual(memory.timeline_events[0]["event"], "Décision") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_next_lots.py b/tests/test_next_lots.py new file mode 100644 index 0000000..99d6023 --- /dev/null +++ b/tests/test_next_lots.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest + +from core.chapters import ChapterId +from core.next_lots import ( + AUTO_SYNC_TODO_ACTIVE, + CommandResult, + Manifest, + ModelRunResult, + NextLotsRunner, + RunState, + replace_auto_section, +) + + +class NextLotsTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "ane" + self.root.mkdir(parents=True, exist_ok=True) + self.mascarade = Path(self.temp_dir.name) / "mascarade" + self.mascarade.mkdir(parents=True, exist_ok=True) + + for path in ( + self.root / "README.md", + self.root / "TODO_ACTIVE.md", + self.root / "TODO_IMPLEMENTE.md", + self.root / "docs" / "EXECUTION_PLAN_2026-03-08.md", + self.root / "docs" / "MODEL_COMPARISON_2026-03-08.md", + self.root / "docs" / "runbooks" / "LOCAL_GENERATION.md", + self.mascarade / "README.md", + self.mascarade / "TODO_AI_NOVEL_ENGINE.md", + self.mascarade / "docs" / "EXECUTION_PLAN_2026-03-08.md", + self.mascarade / "docs" / "RUNBOOK_APPLE_LLM_LOCAL.md", + ): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"# {path.name}\n", encoding="utf-8") + + manifest_dir = self.root / "automation" + manifest_dir.mkdir(parents=True, exist_ok=True) + self.manifest_path = manifest_dir / "next_lots.toml" + self.manifest_path.write_text( + ( + "[paths]\n" + f"mascarade_repo = \"{self.mascarade}\"\n" + "core_base_url = \"http://127.0.0.1:8100\"\n" + "apple_runtime_url = \"http://127.0.0.1:8201\"\n" + "ollama_tags_url = \"http://127.0.0.1:11435/api/tags\"\n\n" + "apple_model_ready_timeout_seconds = 0\n" + "apple_model_poll_interval_seconds = 0.01\n\n" + "[smoke]\n" + "chapter = \"02\"\n" + "intention = \"Smoke intention\"\n" + "timeout_seconds = 300\n\n" + "[preset]\n" + "ANE_MAX_TOKENS_STRUCTURE = \"256\"\n" + "ANE_REPAIR_MAX_PASSES = \"2\"\n\n" + "[ensure_models]\n" + "apple_models = [\"qwen2.5-0.5b-instruct-onnx\", \"qwen3.5-4b-onnx-q4f16\", \"stateful-mistral7b-instruct-int4-coreml\"]\n" + "ollama_models = [\"qwen2.5:7b\", \"qwen2.5:1.5b\"]\n\n" + "[lots.priority_models]\n" + "models = [\"apple-coreml:qwen3.5-4b-onnx-q4f16\", \"ollama:qwen2.5:7b\"]\n\n" + "[lots.baselines]\n" + "models = [\"apple-coreml:qwen2.5-0.5b-instruct-onnx\", \"ollama:qwen2.5:1.5b\"]\n\n" + "[lots.preflight_only]\n" + "models = [\"apple-coreml:stateful-mistral7b-instruct-int4-coreml\"]\n\n" + "[tracking.ane]\n" + "todo_active = \"TODO_ACTIVE.md\"\n" + "todo_done = \"TODO_IMPLEMENTE.md\"\n" + "plan = \"docs/EXECUTION_PLAN_2026-03-08.md\"\n" + "comparison = \"docs/MODEL_COMPARISON_2026-03-08.md\"\n" + "readme = \"README.md\"\n" + "runbook = \"docs/runbooks/LOCAL_GENERATION.md\"\n\n" + "[tracking.mascarade]\n" + "todo = \"TODO_AI_NOVEL_ENGINE.md\"\n" + "plan = \"docs/EXECUTION_PLAN_2026-03-08.md\"\n" + "readme = \"README.md\"\n" + "runbook = \"docs/RUNBOOK_APPLE_LLM_LOCAL.md\"\n\n" + "[next_actions]\n" + "rewrite_compaction = \"Compacter rewrite\"\n" + ), + encoding="utf-8", + ) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_manifest_loads_tracking_and_models(self) -> None: + manifest = Manifest.load(self.root, self.manifest_path) + + self.assertEqual(manifest.priority_models, ["apple-coreml:qwen3.5-4b-onnx-q4f16", "ollama:qwen2.5:7b"]) + self.assertEqual(manifest.required_apple_models[0], "qwen2.5-0.5b-instruct-onnx") + self.assertEqual(manifest.tracking.mascarade_repo, self.mascarade) + self.assertEqual(manifest.tracking.ane_todo_active, self.root / "TODO_ACTIVE.md") + self.assertEqual(manifest.apple_model_ready_timeout_seconds, 0) + + def test_replace_auto_section_only_replaces_managed_block(self) -> None: + path = self.root / "TODO_ACTIVE.md" + path.write_text( + "# Manual\n\n" + "Avant.\n\n" + "## Auto-sync\n" + "\n" + "ancien\n" + "\n\n" + "Apres.\n", + encoding="utf-8", + ) + + replace_auto_section(path, AUTO_SYNC_TODO_ACTIVE, "## Auto-sync", "- nouveau") + rendered = path.read_text(encoding="utf-8") + + self.assertIn("Avant.", rendered) + self.assertIn("Apres.", rendered) + self.assertIn("- nouveau", rendered) + self.assertNotIn("ancien", rendered) + + def test_runner_creates_checkpoint_when_apple_model_differs(self) -> None: + manifest = Manifest.load(self.root, self.manifest_path) + prepare_calls: list[list[str]] = [] + + def command_runner(args: list[str], cwd: Path, env=None) -> CommandResult: + if "prepare_runtime_step.sh" in " ".join(args): + prepare_calls.append(args) + return CommandResult(args=args, returncode=0, stdout="prepared", stderr="", duration_seconds=0.1) + + def json_fetcher(url: str, timeout: float): + if url.endswith("/health"): + return {"status": "ok"} + if url.endswith("/models"): + return ["qwen2.5-0.5b-instruct-onnx"] + raise AssertionError(url) + + runner = NextLotsRunner(manifest, command_runner=command_runner, json_fetcher=json_fetcher) + exit_code = runner.run(lot="priority_models") + + self.assertEqual(exit_code, 3) + self.assertEqual(len(prepare_calls), 1) + self.assertIn("--apple-model", prepare_calls[0]) + state = RunState.load(self.root / "automation" / "state" / "next_lots_state.json") + self.assertIsNotNone(state.pending_manual_action) + self.assertIn("qwen3.5-4b-onnx-q4f16", state.pending_manual_action["reason"]) + + def test_runner_waits_for_apple_model_before_checkpointing(self) -> None: + manifest = Manifest.load(self.root, self.manifest_path) + manifest = Manifest( + **{ + **manifest.__dict__, + "apple_model_ready_timeout_seconds": 0.05, + "apple_model_poll_interval_seconds": 0.0, + } + ) + prepare_calls: list[list[str]] = [] + model_calls = {"count": 0} + + def command_runner(args: list[str], cwd: Path, env=None) -> CommandResult: + if "prepare_runtime_step.sh" in " ".join(args): + prepare_calls.append(args) + return CommandResult(args=args, returncode=0, stdout="prepared", stderr="", duration_seconds=0.1) + + def json_fetcher(url: str, timeout: float): + if url.endswith("/health"): + return {"status": "ok"} + if url.endswith("/models"): + model_calls["count"] += 1 + if model_calls["count"] == 1: + return {"models": []} + return ["qwen3.5-4b-onnx-q4f16"] + raise AssertionError(url) + + runner = NextLotsRunner(manifest, command_runner=command_runner, json_fetcher=json_fetcher) + checkpoint = runner._checkpoint_if_runtime_manual_step_needed( + RunState.new( + manifest, + lot="priority_models", + report_dir=self.root / "automation" / "reports" / "sync", + state_path=self.root / "automation" / "state" / "next_lots_state.json", + steps=[{"type": "models", "name": "priority_models", "models": manifest.priority_models, "preflight_only": False}], + ), + "apple-coreml:qwen3.5-4b-onnx-q4f16", + ) + + self.assertIsNone(checkpoint) + self.assertEqual(prepare_calls, []) + self.assertGreaterEqual(model_calls["count"], 2) + + def test_run_model_classifies_accepted_from_meta(self) -> None: + manifest = Manifest.load(self.root, self.manifest_path) + chapter = ChapterId.parse("02") + + def command_runner(args: list[str], cwd: Path, env=None) -> CommandResult: + if "smoke_openai_compat_ane.sh" in " ".join(args): + return CommandResult(args=args, returncode=0, stdout="ok", stderr="", duration_seconds=0.2) + if "smoke_local_generation.sh" in " ".join(args): + workspace = Path(args[args.index("--workspace") + 1]) + meta_path = workspace / "brouillons" / "chapitres" / chapter.slug / "meta.json" + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta_path.write_text( + json.dumps( + { + "status": "accepted", + "accepted": True, + "completed_stages": ["structure", "draft", "critique", "rewrite", "gate", "memory"], + "retry_stages": ["gate"], + "repair_attempts": 1, + "repair_models": ["ollama:qwen2.5:7b"], + "artifacts": { + "repair_latest": str(meta_path.parent / "repair_v1.md"), + "gate_v1": str(meta_path.parent / "gate_v1.json"), + "manuscript": str(workspace / "manuscrit" / chapter.filename), + }, + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + return CommandResult(args=args, returncode=0, stdout="smoke ok", stderr="", duration_seconds=1.5) + raise AssertionError(args) + + runner = NextLotsRunner( + manifest, + command_runner=command_runner, + json_fetcher=lambda url, timeout: {"status": "ok"} if url.endswith("/health") else ["qwen3.5-4b-onnx-q4f16"], + ) + report_dir = self.root / "automation" / "reports" / "test" + report_dir.mkdir(parents=True, exist_ok=True) + result = runner._run_model("ollama:qwen2.5:7b", category="priority_models", preflight_only=False, report_dir=report_dir) + + self.assertEqual(result.classification, "accepted") + self.assertEqual(result.repair_attempts, 1) + self.assertIn("gate", result.completed_stages) + + def test_tracking_sync_updates_docs_with_auto_sync_sections(self) -> None: + manifest = Manifest.load(self.root, self.manifest_path) + runner = NextLotsRunner( + manifest, + command_runner=lambda args, cwd, env=None: CommandResult(args=args, returncode=0, stdout="", stderr="", duration_seconds=0.0), + json_fetcher=lambda url, timeout: {"status": "ok"}, + ) + state = RunState.new( + manifest, + lot="tracking_sync", + report_dir=self.root / "automation" / "reports" / "sync", + state_path=self.root / "automation" / "state" / "next_lots_state.json", + steps=[{"type": "tracking_sync"}], + ) + state.results = [ + asdict( + ModelRunResult( + model="ollama:qwen2.5:7b", + category="priority_models", + classification="provider_failed", + preflight_ok=True, + smoke_attempted=True, + status="failed", + failed_stage="rewrite", + ) + ) + ] + + runner._sync_tracking(state, dry_run=False) + + self.assertIn("AUTO-SYNC:ANE-TODO-ACTIVE:START", (self.root / "TODO_ACTIVE.md").read_text(encoding="utf-8")) + self.assertIn("Compacter rewrite", (self.root / "docs" / "EXECUTION_PLAN_2026-03-08.md").read_text(encoding="utf-8")) + self.assertIn("ollama:qwen2.5:7b", (self.root / "docs" / "MODEL_COMPARISON_2026-03-08.md").read_text(encoding="utf-8")) + + +def asdict(result: ModelRunResult) -> dict[str, object]: + return { + "model": result.model, + "category": result.category, + "classification": result.classification, + "preflight_ok": result.preflight_ok, + "preflight_duration_seconds": result.preflight_duration_seconds, + "smoke_attempted": result.smoke_attempted, + "smoke_duration_seconds": result.smoke_duration_seconds, + "status": result.status, + "accepted": result.accepted, + "failed_stage": result.failed_stage, + "quality_blockers": result.quality_blockers, + "retry_stages": result.retry_stages, + "repair_attempts": result.repair_attempts, + "repair_models": result.repair_models, + "draft_path": result.draft_path, + "gate_path": result.gate_path, + "meta_path": result.meta_path, + "manuscript_path": result.manuscript_path, + "notes": result.notes, + "preflight_log": result.preflight_log, + "smoke_log": result.smoke_log, + "workspace": result.workspace, + "apple_model_active": result.apple_model_active, + "completed_stages": result.completed_stages, + }