From 5b052a8f70384eb24a8108cfab38e80f3a6ef487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:49:28 +0200 Subject: [PATCH] feat(runtime): add orchestration, tracking, and ops tooling --- .claude/settings.json | 12 + AGENTS.md | 19 + Makefile | 76 ++ TODO_ACTIVE.md | 46 +- TODO_IMPLEMENTE.md | 209 ++++- ...i-novel-engine.mascarade.kxkm.tunnel.plist | 31 + ...-novel-engine.mascarade.tower.tunnel.plist | 31 + automation/mascarade_hosts.toml | 15 + brouillons/chapitres/chapitre_01/meta.json | 35 + cli/main.py | 8 + core/evaluation/__init__.py | 9 + core/evaluation/models.py | 118 +++ core/evaluation/provider.py | 93 +++ core/generation/models.py | 240 +++--- core/generation/provider.py | 219 +----- core/json_payload.py | 147 ++++ core/project/loader.py | 95 ++- core/reporting.py | 142 ++++ core/runtime/__init__.py | 77 ++ core/runtime/checkpoints.py | 91 +++ core/runtime/client.py | 166 ++++ core/runtime/config.py | 163 ++++ core/runtime/errors.py | 6 + core/runtime/health.py | 153 ++++ core/runtime/models.py | 77 ++ core/runtime/orchestration.py | 149 ++++ core/runtime/policies.py | 50 ++ core/runtime/preflight.py | 87 ++ core/runtime/profiles.py | 48 ++ core/runtime/remote_hosts.py | 93 +++ core/tracking_sync.py | 581 ++++++++++++++ docs/AGENTS_2026-03-16.md | 166 ++++ docs/AGENTS_2026-03-21.md | 98 +++ docs/ANE_RUNNER_CONTRACT_V1_2026-03-23.md | 226 ++++++ docs/CONTEXTE_PROJET_2026-03-16.md | 61 ++ docs/CONTEXTE_PROJET_2026-03-21.md | 52 ++ docs/CONTEXTE_PROJET_2026-03-22.md | 56 ++ docs/EXECUTION_PLAN_2026-03-16.md | 155 ++++ docs/EXECUTION_PLAN_2026-03-21.md | 127 +++ docs/EXECUTION_PLAN_2026-03-22.md | 133 ++++ docs/FEATURE_MAP_2026-03-16.md | 71 ++ docs/MEMOIRE_REPRISE_2026-03-16.md | 52 ++ docs/MEMOIRE_REPRISE_2026-03-21.md | 46 ++ docs/MEMOIRE_REPRISE_2026-03-22.md | 75 ++ docs/MODEL_COMPARISON_2026-03-16.md | 44 ++ docs/MODEL_COMPARISON_2026-03-22.md | 39 + docs/OSS_LANDSCAPE_2026-03-16.md | 177 +++++ docs/OSS_LANDSCAPE_2026-03-21.md | 58 ++ docs/OSS_LANDSCAPE_DEEP_2026-03-16.md | 340 ++++++++ docs/OSS_RUNTIME_EVAL_2026-03-21.md | 91 +++ docs/REPOS_ANNEXES_2026-03-23.md | 85 ++ docs/SYSTEM_SPEC_2026-03-16.md | 139 ++++ docs/SYSTEM_SPEC_2026-03-21.md | 75 ++ docs/governance.md | 28 +- docs/principes.md | 34 +- docs/runbooks/AUTOMATION.md | 109 +++ docs/runbooks/RECOVERY_PROCEDURES.md | 39 + docs/templates/ANE_STRUCTURE_TEMPLATE.md | 30 + docs/templates/FICHE_PERSONNAGES.docx | Bin 0 -> 4069 bytes docs/templates/FICHE_PERSONNAGES_SOURCE.html | 60 ++ docs/templates/PACK_FANTASY.docx | Bin 0 -> 4417 bytes docs/templates/PACK_FANTASY_SOURCE.html | 59 ++ docs/templates/PACK_HUIS_CLOS.docx | Bin 0 -> 4201 bytes docs/templates/PACK_HUIS_CLOS_SOURCE.html | 43 + docs/templates/PACK_IDEE_ROMAN.docx | Bin 0 -> 4175 bytes docs/templates/PACK_IDEE_ROMAN_SOURCE.html | 46 ++ docs/templates/PACK_LITTERAIRE.docx | Bin 0 -> 4349 bytes docs/templates/PACK_LITTERAIRE_SOURCE.html | 55 ++ docs/templates/PACK_POLAR.docx | Bin 0 -> 4458 bytes docs/templates/PACK_POLAR_SOURCE.html | 56 ++ docs/templates/PACK_ROMANTASY.docx | Bin 0 -> 4252 bytes docs/templates/PACK_ROMANTASY_SOURCE.html | 43 + docs/templates/PACK_ROMAN_FR.docx | Bin 0 -> 4220 bytes docs/templates/PACK_ROMAN_FR_SOURCE.html | 70 ++ docs/templates/PACK_SF.docx | Bin 0 -> 4335 bytes docs/templates/PACK_SF_SOURCE.html | 52 ++ docs/templates/PLAN_3_ACTES.docx | Bin 0 -> 4227 bytes docs/templates/PLAN_3_ACTES_SOURCE.html | 48 ++ docs/templates/PLAN_ROMAN_COMPLET.docx | Bin 0 -> 5584 bytes docs/templates/PLAN_ROMAN_COMPLET_SOURCE.html | 209 +++++ docs/templates/PLAN_TEMPLATE.docx | Bin 0 -> 4571 bytes docs/templates/PLAN_TEMPLATE_SOURCE.html | 82 ++ docs/templates/README.md | 90 +++ docs/templates/STUDIO_PROJECT_TEMPLATE.md | 43 + .../memoire/index/chronologie.json | 7 + .../memoire/index/lieux.json | 10 + .../memoire/index/personnages.json | 21 + .../notes/intentions/chapitre_01.md | 7 + docs/vision.md | 12 + docs/workflow.md | 25 +- prompts/draft_v1.txt | 20 +- prompts/gate_v1.txt | 10 +- prompts/judge_narrative_retry_v1.txt | 46 ++ prompts/judge_narrative_v1.txt | 44 ++ prompts/repair_v1.txt | 30 +- prompts/rewrite_v1.txt | 28 +- scripts/healthcheck.sh | 123 +++ scripts/mascarade_remote_tui.py | 160 ++++ scripts/next_lots_tui.py | 143 ++++ scripts/ops_tui.py | 314 ++++++++ scripts/prepare_llama_cpp_runtime.sh | 185 +++++ scripts/reports_ops.py | 181 +++++ scripts/setup_mascarade_launchd.py | 140 ++++ scripts/smoke_local_generation.sh | 8 +- tests/test_generation_pipeline.py | 744 +++++++++++++++++- tests/test_mascarade_remote_tui.py | 111 +++ tests/test_next_lots.py | 373 ++------- tests/test_reporting.py | 177 +++++ tests/test_runtime_execution_plan.py | 154 ++++ tests/test_runtime_layer.py | 163 ++++ tests/test_runtime_orchestration.py | 116 +++ tests/test_runtime_profiles.py | 137 ++++ tests/test_setup_mascarade_launchd.py | 49 ++ tests/test_tracking_sync.py | 126 +++ 114 files changed, 9809 insertions(+), 703 deletions(-) create mode 100644 .claude/settings.json create mode 100644 AGENTS.md create mode 100644 automation/launchd/com.ai-novel-engine.mascarade.kxkm.tunnel.plist create mode 100644 automation/launchd/com.ai-novel-engine.mascarade.tower.tunnel.plist create mode 100644 automation/mascarade_hosts.toml create mode 100644 brouillons/chapitres/chapitre_01/meta.json create mode 100644 core/evaluation/__init__.py create mode 100644 core/evaluation/models.py create mode 100644 core/evaluation/provider.py create mode 100644 core/json_payload.py create mode 100644 core/reporting.py create mode 100644 core/runtime/__init__.py create mode 100644 core/runtime/checkpoints.py create mode 100644 core/runtime/client.py create mode 100644 core/runtime/config.py create mode 100644 core/runtime/errors.py create mode 100644 core/runtime/health.py create mode 100644 core/runtime/models.py create mode 100644 core/runtime/orchestration.py create mode 100644 core/runtime/policies.py create mode 100644 core/runtime/preflight.py create mode 100644 core/runtime/profiles.py create mode 100644 core/runtime/remote_hosts.py create mode 100644 core/tracking_sync.py create mode 100644 docs/AGENTS_2026-03-16.md create mode 100644 docs/AGENTS_2026-03-21.md create mode 100644 docs/ANE_RUNNER_CONTRACT_V1_2026-03-23.md create mode 100644 docs/CONTEXTE_PROJET_2026-03-16.md create mode 100644 docs/CONTEXTE_PROJET_2026-03-21.md create mode 100644 docs/CONTEXTE_PROJET_2026-03-22.md create mode 100644 docs/EXECUTION_PLAN_2026-03-16.md create mode 100644 docs/EXECUTION_PLAN_2026-03-21.md create mode 100644 docs/EXECUTION_PLAN_2026-03-22.md create mode 100644 docs/FEATURE_MAP_2026-03-16.md create mode 100644 docs/MEMOIRE_REPRISE_2026-03-16.md create mode 100644 docs/MEMOIRE_REPRISE_2026-03-21.md create mode 100644 docs/MEMOIRE_REPRISE_2026-03-22.md create mode 100644 docs/MODEL_COMPARISON_2026-03-16.md create mode 100644 docs/MODEL_COMPARISON_2026-03-22.md create mode 100644 docs/OSS_LANDSCAPE_2026-03-16.md create mode 100644 docs/OSS_LANDSCAPE_2026-03-21.md create mode 100644 docs/OSS_LANDSCAPE_DEEP_2026-03-16.md create mode 100644 docs/OSS_RUNTIME_EVAL_2026-03-21.md create mode 100644 docs/REPOS_ANNEXES_2026-03-23.md create mode 100644 docs/SYSTEM_SPEC_2026-03-16.md create mode 100644 docs/SYSTEM_SPEC_2026-03-21.md create mode 100644 docs/runbooks/AUTOMATION.md create mode 100644 docs/runbooks/RECOVERY_PROCEDURES.md create mode 100644 docs/templates/ANE_STRUCTURE_TEMPLATE.md create mode 100644 docs/templates/FICHE_PERSONNAGES.docx create mode 100644 docs/templates/FICHE_PERSONNAGES_SOURCE.html create mode 100644 docs/templates/PACK_FANTASY.docx create mode 100644 docs/templates/PACK_FANTASY_SOURCE.html create mode 100644 docs/templates/PACK_HUIS_CLOS.docx create mode 100644 docs/templates/PACK_HUIS_CLOS_SOURCE.html create mode 100644 docs/templates/PACK_IDEE_ROMAN.docx create mode 100644 docs/templates/PACK_IDEE_ROMAN_SOURCE.html create mode 100644 docs/templates/PACK_LITTERAIRE.docx create mode 100644 docs/templates/PACK_LITTERAIRE_SOURCE.html create mode 100644 docs/templates/PACK_POLAR.docx create mode 100644 docs/templates/PACK_POLAR_SOURCE.html create mode 100644 docs/templates/PACK_ROMANTASY.docx create mode 100644 docs/templates/PACK_ROMANTASY_SOURCE.html create mode 100644 docs/templates/PACK_ROMAN_FR.docx create mode 100644 docs/templates/PACK_ROMAN_FR_SOURCE.html create mode 100644 docs/templates/PACK_SF.docx create mode 100644 docs/templates/PACK_SF_SOURCE.html create mode 100644 docs/templates/PLAN_3_ACTES.docx create mode 100644 docs/templates/PLAN_3_ACTES_SOURCE.html create mode 100644 docs/templates/PLAN_ROMAN_COMPLET.docx create mode 100644 docs/templates/PLAN_ROMAN_COMPLET_SOURCE.html create mode 100644 docs/templates/PLAN_TEMPLATE.docx create mode 100644 docs/templates/PLAN_TEMPLATE_SOURCE.html create mode 100644 docs/templates/README.md create mode 100644 docs/templates/STUDIO_PROJECT_TEMPLATE.md create mode 100644 docs/templates/ane-workspace-template/memoire/index/chronologie.json create mode 100644 docs/templates/ane-workspace-template/memoire/index/lieux.json create mode 100644 docs/templates/ane-workspace-template/memoire/index/personnages.json create mode 100644 docs/templates/ane-workspace-template/notes/intentions/chapitre_01.md create mode 100644 prompts/judge_narrative_retry_v1.txt create mode 100644 prompts/judge_narrative_v1.txt create mode 100644 scripts/healthcheck.sh create mode 100644 scripts/mascarade_remote_tui.py create mode 100755 scripts/next_lots_tui.py create mode 100755 scripts/ops_tui.py create mode 100755 scripts/prepare_llama_cpp_runtime.sh create mode 100755 scripts/reports_ops.py create mode 100644 scripts/setup_mascarade_launchd.py create mode 100644 tests/test_mascarade_remote_tui.py create mode 100644 tests/test_reporting.py create mode 100644 tests/test_runtime_execution_plan.py create mode 100644 tests/test_runtime_layer.py create mode 100644 tests/test_runtime_orchestration.py create mode 100644 tests/test_runtime_profiles.py create mode 100644 tests/test_setup_mascarade_launchd.py create mode 100644 tests/test_tracking_sync.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..4650609 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(curl -s http://127.0.0.1:8091/health)", + "Bash(curl -s http://127.0.0.1:8091/v1/models)", + "Bash(make test:*)", + "Bash(python3 scripts/run_next_lots.py --lot french_models)", + "Bash(tee /tmp/lot_french_models.log)", + "Bash(tee /tmp/lot_french_models2.log)" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..99c004f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,19 @@ +# Repository Guidelines + +## Project Structure & Module Organization +The root repository is the Python narrative engine. Use `cli/` for the public CLI, `core/` for pipeline, runtime, reporting, and project logic, and `scripts/` for smoke tests, TUIs, and automation entrypoints. Unit tests live in `tests/`. Prompt templates are versioned in `prompts/`. Automation state, reports, and manifests live under `automation/`. Long-form documentation and runbooks live in `docs/` and `docs/runbooks/`. The macOS Studio app is a separate Swift package in `app_AI-novel-engine/` with its own `Sources/` and `Tests/`. + +## Build, Test, and Development Commands +Run the main Python suite with `make test` or `python3 -m unittest discover -s tests -v`. Use `make healthcheck` before runtime-dependent work to verify local services on `:8100`, `:8201`, `:8091`, and `:11434`. Launch a representative generation smoke test with `bash scripts/smoke_local_generation.sh --base-url http://127.0.0.1:8100 --model "apple-coreml:qwen3.5-4b-onnx-q4f16" --approve`. Drive automation with `python3 scripts/run_next_lots.py --lot full` or `make lot-full`. For the Studio app, run `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test` inside `app_AI-novel-engine/`. + +## Coding Style & Naming Conventions +Follow existing style rather than introducing a new formatter. Python uses 4-space indentation, type hints, dataclasses where appropriate, `snake_case` for functions/modules, and `PascalCase` for classes. Keep CLI and runtime constants explicit and environment-variable names uppercase, for example `ANE_BASE_URL`. Swift code in `app_AI-novel-engine/` follows standard Swift naming: `PascalCase` types, `camelCase` members. + +## Testing Guidelines +Add Python tests in `tests/test_*.py` and group cases in `unittest.TestCase` classes named `*Tests`. Prefer deterministic unit tests with mocks over live runtime calls. When touching automation or tracking, cover both file outputs and state transitions. If you change the Swift app, add or update SwiftPM tests in `app_AI-novel-engine/Tests/`. + +## Commit & Pull Request Guidelines +Recent history favors concise Conventional Commit subjects such as `feat(pipeline): ...`, `fix(lots): ...`, and `chore(gen): ...`. Keep commits scoped to one concern. PRs should explain the affected workflow, list verification commands, link any relevant issue or dated doc, and include screenshots when `app_AI-novel-engine/` UI changes. + +## Configuration & Generated Content +Do not commit secrets. Local runtime setup relies on `ANE_PROVIDER`, `ANE_BASE_URL`, `ANE_MODEL`, `ANE_API_KEY`, and optional `OPENAI_API_KEY`. Do not hand-edit sections wrapped in `AUTO-SYNC` markers; regenerate them through the supported scripts. diff --git a/Makefile b/Makefile index 130501e..848f308 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,78 @@ +# AI Novel Engine — Makefile + +# ── Tests ────────────────────────────────────────────────────────────────── +test: + python3 -m unittest discover -s tests -q + +test-v: + python3 -m unittest discover -s tests -v + +# ── TUI / Supervision ────────────────────────────────────────────────────── +tui: + python3 scripts/next_lots_tui.py --watch --interval 2 + +ops-tui: + python3 scripts/ops_tui.py --watch --interval 3 + +# ── Reports ──────────────────────────────────────────────────────────────── +reports-summary: + python3 scripts/reports_ops.py summary + +reports-errors: + python3 scripts/reports_ops.py analyze-logs --top 15 + +reports-prune-dry: + python3 scripts/reports_ops.py prune --days 14 + +reports-prune: + python3 scripts/reports_ops.py prune --days 14 --delete-workspaces --apply + +# ── Healthcheck services locaux ──────────────────────────────────────────── +healthcheck: + @echo "==> Services locaux" + @curl -sf http://127.0.0.1:8100/health && echo " :8100 mascarade OK" || echo " :8100 mascarade DOWN" + @curl -sf http://127.0.0.1:8201/health && echo " :8201 apple OK" || echo " :8201 apple DOWN" + @curl -sf http://127.0.0.1:8091/health && echo " :8091 llama-srv OK" || echo " :8091 llama-srv DOWN" + @curl -sf http://127.0.0.1:11434/api/tags > /dev/null && echo " :11434 ollama OK" || echo " :11434 ollama DOWN" + +# ── Smoke chapter ────────────────────────────────────────────────────────── +smoke-apple: + bash scripts/smoke_local_generation.sh \ + --base-url http://127.0.0.1:8100 \ + --model "apple-coreml:qwen3.5-4b-onnx-q4f16" \ + --approve + +smoke-ollama: + bash scripts/smoke_local_generation.sh \ + --base-url http://127.0.0.1:8091 \ + --model "ollama:qwen2.5:7b" \ + --approve + +smoke-mistral: + bash scripts/smoke_local_generation.sh \ + --base-url http://127.0.0.1:8091 \ + --model "ollama:mistral-nemo:latest" \ + --approve + +# ── Lots automation ──────────────────────────────────────────────────────── +lot-priority: + python3 scripts/run_next_lots.py --lot priority_models + +lot-baselines: + python3 scripts/run_next_lots.py --lot baselines + +lot-french: + python3 scripts/run_next_lots.py --lot french_models + +lot-full: + python3 scripts/run_next_lots.py --lot full + +lot-sync: + python3 scripts/run_next_lots.py --lot tracking_sync --report-only + +resume: + python3 scripts/run_next_lots.py --resume automation/state/next_lots_state.json + +# ── Git ──────────────────────────────────────────────────────────────────── status: git status diff --git a/TODO_ACTIVE.md b/TODO_ACTIVE.md index 6e29bba..e5a4e52 100644 --- a/TODO_ACTIVE.md +++ b/TODO_ACTIVE.md @@ -40,6 +40,38 @@ References: - `:8110` repond a `/health` mais reste inutilisable pour `chat/completions` - `automation/next_lots.toml` repointe maintenant vers `/Users/electron/Documents/Projets/mascarade` - `scripts/smoke_local_generation.sh` aligne maintenant ses budgets non-Apple sur le manifeste (`rewrite=1024`, `repair=1536`) +- cote app (2026-03-25): + - `app_AI-novel-engine` integre maintenant `Mascarade Core` et `Mascarade API` pour `agents`, `models` et `chat/completions` + - le panneau `Generation` affiche `provider_status` (`actif`, `configure`, `unauthorized`, erreur runtime) et le preset recommande suit maintenant le premier modele reellement actif + - un `Wizard Agents` dedie guide maintenant l'usage des agents Mascarade (`source -> catalogue -> brief -> routage -> run -> apply`) et persiste cet etat par projet + - le `Wizard Agents` garde maintenant un historique recent des runs agent et affiche une comparaison `brouillon / sortie agent` avant application + - le `Copilot / Writing Tools` sait lancer l'agent Mascarade selectionne, reinjecter sa reponse dans le fil Copilot, puis comparer inline `brouillon courant` / `derniere reponse Copilot` / `dernier manuscrit pipeline` + - le lot `Copilot hybride` a demarre: les requetes Copilot passent maintenant par une couche `CopilotService` avec fallback automatique `Apple Intelligence -> backend` + - le `Copilot` consomme maintenant un contexte ANE outille (`scene`, `brouillon`, `prompt`, `gate`, `manuscrit`, `agent`) prepare hors de l'UI pour les futures integrations `App Intents` + - le `Copilot` ajoute maintenant un RAG local-first sur le graphe projet et les artefacts pipeline, avec visualisation des extraits recuperes dans l'app + - les premieres actions outillees du `Copilot` sont maintenant reexecutables depuis le panneau de contexte (`scene`, `gate`, `manuscrit`, `agent`, `pipeline`) + - les ecritures destructives du brouillon depuis le `Copilot` ou un agent Mascarade demandent maintenant confirmation + - l'app expose maintenant un preset `MLX local` `:8092` avec `mlx-community/Ministral-3-3B-Instruct-2512-4bit` et un script `scripts/mlx_runtime.sh` pour diagnostiquer / demarrer le runtime + - l'app expose maintenant une bibliotheque locale de modeles HF/MLX (cache HF dedie, conversion MLX, nettoyage source optionnel, activation) et un export dataset JSONL compatible `KIKI-models-tuning` + - la refonte UI du 2 avril recentre maintenant l'app sur trois axes (`Ecrire`, `IA`, `Analyser & Ops`), avec un header a signaux et un inspecteur tabule `Contexte / Runtime / Pipeline` + - un nouvel ecran `Runtime & modeles` centralise maintenant la configuration IA, les providers Mascarade, le runtime MLX, la bibliotheque locale HF/MLX et l'export dataset KIKI + - les telechargements HF affichent maintenant une progression reelle, une annulation propre et un token HF optionnel cote app + - l'app expose maintenant un preflight `compatibilite MLX`; `croissantllm/CroissantLLMChat-v0.1` est telecharge localement et classe `Convertible MLX` + - `croissantllm/CroissantLLMChat-v0.1` a maintenant aussi un smoke reel MLX local valide (`~812M`, footprint stable `~3.7G`, pic `~6.7G`, `~1.0-1.8 s`), mais le replay ANE reel reste insuffisant (`repair_v1` corrompu, `gate/judge` fragiles), donc pas encore de promotion `Qualite FR` + - le 2 avril, `mlx-community/Ministral-3-3B-Instruct-2512-4bit` a ete telecharge et valide en reel sur `:8092` (`health`, `v1/models`, `v1/chat/completions`) + - le 2 avril, `mlx-community/Qwen2.5-7B-Instruct-4bit` a ete telecharge et rejoue en reel sur `:8092`; il tient le JSON parseable sur `gate/judge` sous `~4.9G` de footprint et `~5.7G` de pic memoire, mais pas encore le contrat ANE complet sans post-traitement schema/consistance + - l'app expose maintenant un preset `Mistral local` pointant vers `http://127.0.0.1:8091` avec `ollama:mistral-nemo:latest` pour un chemin local type TurboQuant / llama.cpp + - l'app expose aussi deux profils FR explicites: `croissantllm/CroissantLLMChat-v0.1` (`FR local experimental`) et `croissantllm/CroissantLLMBase` (`R&D ANE`) + - reverification ANE du 2 avril: `mlx-community/Ministral-3-3B-Instruct-2512-4bit` sert bien un vrai `repair_v1` apres completion du cache, mais ni lui ni `CroissantLLMChat` ne respectent encore assez bien les contrats JSON `gate/judge` pour servir de moteur ANE strict local + - le lot `App Intents` expose maintenant l'ouverture de l'app, le resume de scene, l'ouverture de scene ciblee, une aide de rewrite locale, et le lancement du pipeline ANE + - la provenance exacte d'ecriture est maintenant persistee dans `DraftSnapshot.source` pour `Copilot`, agents, generation directe et pipeline + - verification bundle app du 29 mars: `validate-app-intents` confirme que les symboles intents sont bien dans le binaire, mais que l'exposition systeme reste bloquee par l'absence de metadonnees `App Intents` et un Xcode local casse +- reverification runtime du 25 mars: + - `Mascarade Core` `:8100` repond a `health` et `v1/models` + - `Mascarade API` `:3100` repond a `health`, `v1/api/models` et `v1/api/agents/catalog` + - `local_server` expose maintenant `provider_status` / `v1/providers/status` et ne publie plus les providers non autorises comme disponibles + - `claude:claude-sonnet-4-6` passe maintenant en bout-en-bout sur `:8100/v1/chat/completions` et `:3100/api/v1/chat/completions` + - `mistral` et `openai` restent configures localement, mais sont correctement marques `unauthorized` - etat live reverifie (2026-03-16 session 3) : - `:8201` UP — `qwen3.5-4b-onnx-q4f16` actif - `:11434` UP @@ -52,6 +84,7 @@ References: - fix `dense_bullet_list` : 4+ bullet lines = `outline_like` sans 2e marqueur - lot runtime extrait : `core/runtime/{config,models,client,health,policies}.py` - suite unitaire : 156 tests verts +- le 2 avril, le pipeline ANE normalise maintenant les verdicts `gate/judge` avant persistence: aliases historiques (`incomplete`, `lacks_narrative_continuity`) unifies, labels inconnus filtres, doublons supprimes et `ready_for_manuscript` recalcule a partir des blockers effectifs ## Refonte runtime (P0) @@ -85,6 +118,8 @@ References: - [x] P0 Rejouer `qwen2.5:7b` et `mistral-nemo` apres cette retouche ciblee "consequence immediate" - [x] P0 Isoler une variante prompt/repair pour `mistral-nemo` — `rewrite_v2_nemo.txt` + `repair_v2_nemo.txt` + `prompt_profile` dans PromptStore - [x] P0 Garder `qwen2.5:7b` comme baseline Ollama — passe dans priority_models via Mascarade P2P (2026-03-23) +- [x] P1 Durcir la normalisation schema/consistance des verdicts `gate/judge` pour les petits modeles locaux avant persistence ANE +- [ ] P1 Ajouter un post-traitement semantique optionnel pour les contradictions restantes (`summary` vs blockers) si on veut promouvoir `Qwen2.5-7B` comme evaluateur local ANE ### Qualite code (P1, lot refonte) - [x] P0 Extraire une couche `core/runtime/*` claire (profil, contraintes, healthcheck) sans casser la facade `core/generation/provider.py` @@ -139,6 +174,7 @@ References: ## Bloque - [ ] P0 `ollama` natif 0.17.7 sur macOS 26.3.1 / Apple M5 echoue encore en generation sur `qwen2.5:7b` et `qwen2.5:1.5b` avec une erreur Metal / `HTTP 500` — contourne via `llama-server` sur `:8091` +- [ ] P0 Les secrets locaux `Mistral` et `OpenAI` sont invalides/expirés sur cette machine; les corriger pour re-activer ces providers dans `Mascarade local_server` - [ ] P1 Le runtime Apple local ne sert qu'un seul `model_id` a la fois; tout switch Apple reste semi-manuel (`prepare_runtime_step.sh`) - [ ] P1 Le runtime remote `:8110` repond a `/health` mais reste inutilisable pour `POST /v1/chat/completions` (`Temporary failure in name resolution`) - [x] P1 `:8100` ne repond pas — CORRIGE (mascarade relance, UP) @@ -159,12 +195,12 @@ References: ## Auto-sync -- dernier cycle automatique: 2026-03-23T15:52:31+00:00 +- dernier cycle automatique: 2026-03-23T21:34:05+00:00 - modeles accepted: mistral:mistral-large-latest -- modeles ayant atteint gate: mistral:mistral-large-latest, ollama:mistral-nemo:latest -- quality_blocked: ollama:mistral-nemo:latest -- provider_failed: ollama:qwen2.5:7b -- prochain lot recommande: Reference locale reconfirmee; retablir le runtime des modeles provider_failed puis reprendre rewrite/repair sur les modeles bloques a gate. +- modeles ayant atteint gate: mistral:mistral-large-latest +- quality_blocked: aucun +- provider_failed: ollama:qwen2.5:7b, ollama:mistral-nemo:latest +- prochain lot recommande: Reference locale reconfirmee; retablir le runtime des modeles provider_failed avant de poursuivre. - checkpoint manuel en attente: Le runtime Apple sert `aucun modèle` au lieu de `qwen3-4b-instruct-2507-q4f16`. - commande preparee: `bash scripts/prepare_runtime_step.sh --apple-model qwen3-4b-instruct-2507-q4f16 --resume-state /Users/electron/Documents/Lelectron_rare/ai-novel-engine/automation/state/next_lots_state.json --ane-script /Users/electron/Documents/Lelectron_rare/ai-novel-engine/scripts/run_next_lots.py` - reprise: `python3 scripts/run_next_lots.py --resume /Users/electron/Documents/Lelectron_rare/ai-novel-engine/automation/state/next_lots_state.json` diff --git a/TODO_IMPLEMENTE.md b/TODO_IMPLEMENTE.md index 33e8963..243f8a8 100644 --- a/TODO_IMPLEMENTE.md +++ b/TODO_IMPLEMENTE.md @@ -100,6 +100,213 @@ Regle: - [x] Etat automatise `baselines` clos proprement; `stateful-mistral7b-instruct-int4-coreml` sort du chemin critique - [x] Suite unitaire etendue a 43 tests verts +### Lot livre - 16 mars 2026 (ops cockpit + deep docs) +- [x] Module partage `core/reporting.py` pour centraliser lecture `run.json`, tri des reports et aggregation stderr +- [x] `scripts/reports_ops.py analyze-logs` corrige pour rattacher les erreurs aux vrais modeles +- [x] `scripts/next_lots_tui.py` et `scripts/reports_ops.py` executes directement depuis `scripts/` sans casser les imports repo +- [x] Nouveau cockpit TUI `scripts/ops_tui.py` +- [x] Fix de deduplication des evenements de chronologie lors d'un rerun du meme chapitre accepte +- [x] Nouveaux documents: contexte, memoire, plan, spec systeme, feature map, carte d'agents, veille OSS +- [x] `docs/runbooks/AUTOMATION.md`, `docs/workflow.md`, `docs/dev/README.md` et `README.md` rebranches sur l'etat reel +- [x] Suite unitaire verte a `55` tests + +### Lot livre - 16 mars 2026 (refonte globale — phase 1) +- [x] Memoire projet initialisee : `MEMORY.md` + 5 fichiers de memoire (user, feedback, project x3) +- [x] `docs/OSS_LANDSCAPE_2026-03-16.md` enrichi : GOAT-Storytelling-Agent, prometheus-eval, story-evaluation-llm, dottxt/outlines, DeepEval +- [x] `docs/AGENTS_2026-03-16.md` mis a jour : Agent 6 Qualite code, todos actifs par agent +- [x] `docs/FEATURE_MAP_2026-03-16.md` mis a jour : Carte 7 Qualite code, lot `french_models` +- [x] Fix 4 bare `except Exception` dans `core/next_lots.py` → `(OSError, json.JSONDecodeError, ValueError)` +- [x] Prompts `draft_v1`, `rewrite_v1`, `repair_v1` : output primer + few-shot BAD/GOOD + cible 600-800 mots +- [x] Nouveaux tests `IntentionGate` (11), `PromptStore` (7), CLI intention (3) + +### Lot livre - 2 avril 2026 (normalisation des verdicts gate/judge) +- [x] `NarrativeJudgeReport` normalise maintenant ses blockers avant persistence: aliases historiques unifies, labels inconnus filtres, doublons supprimes et `ready_for_manuscript` recalcule +- [x] `ManuscriptGateReport` applique la meme normalisation sur `blockers`, `heuristic_blockers`, `judge_blockers` et recommandations +- [x] Les sorties parseables mais contradictoires des petits modeles locaux sont maintenant rendues plus coherentes pour ANE avant `_sanitize_gate_report()` +- [x] Couverture unitaire ajoutee pour les aliases historiques et la revalidation de `ready_for_manuscript` +- [x] Suite unitaire a 77 tests verts + +### Lot livre - 16 mars 2026 (refonte globale — phase 2) +- [x] `_finish_stage()` extrait dans `core/generation/pipeline.py` : `generate_chapter()` allege (-12 lignes de boilerplate) +- [x] `_iter_chapters_with_status()` extrait dans `core/project/loader.py` : 3 methodes `failed/quality_blocked/awaiting_acceptance` factorisees +- [x] `Makefile` enrichi : `healthcheck`, `smoke-apple/ollama/mistral`, `lot-priority/baselines/french/full/sync`, `resume`, `test-v` +- [x] `README.md` nettoye : bloc CHANTIER stale supprime +- [x] Suite unitaire a 77 tests verts (stable apres refactors) + +### Lot livre - 16 mars 2026 (refonte globale — phase 3) +- [x] `_close_json_delimiters()` robustifie dans `core/generation/models.py` : rebuild caractere par caractere, closers mal assortis repares, stray closers droppes +- [x] 3 nouveaux tests `JsonRepairTests` : mismatched closer, stray closer, truncated string in array +- [x] `docs/OSS_LANDSCAPE_2026-03-16.md` enrichi : section contraintes decodage (lm-format-enforcer, outlines-core, guidance, IterGen, FMBench), section continuite narrative (SCORE pattern, KazKozDev/NovelGenerator, AIStoryWriter), section FR (CroissantLLM, FrenchBench, leaderboard FR, CamemBERT perplexite), benchmarks creatifs EQ-bench (longform, creative, Judgemark-v2), distilabel+PrometheusEval +- [x] Recommandations P0/P1/P2/P3 structurees avec recettes concretes (lm-format-enforcer regex, Prometheus 2 rubrique FR, SCORE pattern, CamemBERT gate) +- [x] Suite unitaire a 80 tests verts + +### Lot livre - 16 mars 2026 (fix outline_like — normalisation headings) +- [x] Root cause identifiee depuis lot `baselines` rejoue : `qwen2.5:1.5b` genere `### Scene N — titre` comme headings de scene, non stripes par la normalisation +- [x] `_normalize_generated_prose()` : strip ALL `#{1,6}` headings (au lieu du seul `# Chapitre`) — prevention directe de `outline_like` avant gate +- [x] `_is_outline_like()` : tightening du check `scene_heading` — ne se declenche plus sur le mot "scene" dans la prose courante, seulement sur les labels structurants (`### Scene N`, `Scene 1:`) +- [x] `NormalizeProseTests` (5 tests) : strips H1/H2/H3, prose contenant "scene" non flagee, labels structurants correctement detects +- [x] Suite unitaire a 85 tests verts + +### Lot livre - 16 mars 2026 (tests CLI + reporting) +- [x] 6 nouveaux tests `CLIIntentionTests` : chapitre invalide, intention en doublon, contenu vide, `main([])` → status, `ProviderError` via generate, `ProviderError` via write +- [x] 18 nouveaux tests `ReportingHelpersTests` : safe_read_json (3), safe_stamp (3), extract_stderr (2), classification_count (2), folder_timestamp (2), latest_report_run (2), log_label (4) +- [x] Suite unitaire a 109 tests verts + +### Lot livre - 16 mars 2026 (fix dense_bullet_list + validation baselines) +- [x] `_is_outline_like()` : ajout compteur `bullet_line_count`; 4+ lignes bullet = `dense_bullet_list` (marqueur solo suffisant) — models 0.5b generant des listes pures maintenant bloques correctement +- [x] 2 nouveaux tests `NormalizeProseTests` : `dense_bullet_list` (4 bullets flagges), prose avec 1 bullet non flaggee +- [x] Validation end-to-end lot `baselines` : `ollama:qwen2.5:1.5b` → `quality_blocked ['truncated_ending']` uniquement — plus d'`outline_like` (fix normalisation headings confirme) +- [x] `apple-coreml:qwen2.5-0.5b-instruct-onnx` → `quality_blocked ['truncated_ending', 'outline_like']` (dense_bullet_list detecte correctement apres repair) +- [x] Suite unitaire a 111 tests verts + +### Lot livre - 16 mars 2026 (fix budgets rewrite/repair + rerun priority_models) +- [x] Diagnostic : `ANE_MAX_TOKENS_REWRITE=768` et `ANE_MAX_TOKENS_REPAIR=512` trop courts — prose refusee pour `truncated_ending` budget, pas qualite +- [x] `automation/next_lots.toml` : `ANE_MAX_TOKENS_REWRITE` 768 → 1024, `ANE_MAX_TOKENS_REPAIR` 512 → 1024 +- [x] Rerun `priority_models` avec budgets 1024 : `apple-coreml:qwen3.5-4b-onnx-q4f16` → `accepted` (531 mots, gate vert) +- [x] `ollama:qwen2.5:7b` → `quality_blocked ['truncated_ending']` par LLM gate (fin narrative insuffisante, pas de decision risquee) +- [x] Lot `french_models` lance : `ollama:mistral-nemo:latest` via llama-server `:8091` (rapport `20260316T220423Z`) + +### Lot livre - 16 mars 2026 (french_models — mistral-nemo) +- [x] Premier run `ollama:mistral-nemo:latest` via `llama-server` `:8091` avec budgets 1024 +- [x] Gate LLM : `quality_blocked ['outline_like', 'incomplete', 'lacks_narrative_continuity']` +- [x] Prose sans headings ni bullets (fix normalisation tient) mais scene trop condensee (316 mots rewrite) +- [x] Comparatif mis a jour : `docs/MODEL_COMPARISON_2026-03-16.md` +- [x] Blockers narratifs mistral-nemo documentes — requalification apres ajustements prompts + +### Lot livre - 21 mars 2026 (refonte runtime — phase 1) +- [x] Couche runtime minimale extraite sous `core/runtime/` +- [x] `core/runtime/config.py` ajoute comme premier point d'entree runtime partage +- [x] `core/runtime/policies.py` ajoute pour sortir du pipeline les regles de fallback et la contrainte Apple +- [x] `core/runtime/models.py` enrichi avec contraintes runtime explicites +- [x] `core/runtime/health.py` sait maintenant remonter la sante runtime et un catalogue de modeles OpenAI-compatible +- [x] `OpenAICompatibleProvider` delegue maintenant le transport OpenAI-compatible a `OpenAIChatRuntimeClient` +- [x] `core/generation/provider.py` utilise `OpenAICompatibleRuntimeConfig` comme source de verite de config +- [x] `core/generation/pipeline.py` delegue le fallback `repair` a `core/runtime/policies.py` +- [x] `core/next_lots.py` reutilise `runtime_probe_profile` et `runtime_model_ids` +- [x] `core/next_lots.py` recompilable a nouveau (`IndentationError` corrige) +- [x] Tests dedies ajoutes pour config / policies / health dans `tests/test_runtime_layer.py` +- [x] Suite unitaire a `128 tests` verts + +### Lot livre - 21 mars 2026 (refonte runtime — phase 2) +- [x] `core/generation/provider.py` repose maintenant sur `core/runtime/config.py` pour la configuration OpenAI-compatible +- [x] `core/runtime/config.py` expose `runtime_probe_profile()` +- [x] `core/runtime/health.py` expose `runtime_model_ids()` pour mutualiser la lecture du catalogue runtime +- [x] `core/next_lots.py` consomme ces helpers partages au lieu de reconstruire localement une partie des profils runtime +- [x] `scripts/ops_tui.py` utilise la meme sonde runtime partagee pour les endpoints OpenAI-compatibles +- [x] Tests runtime enrichis dans `tests/test_runtime_layer.py` +- [x] Suite unitaire a `131 tests` verts + +### Lot livre - 21 mars 2026 (refonte runtime — phase 3) +- [x] `core/runtime/checkpoints.py` extrait la decision de checkpoint manuel runtime +- [x] `core/runtime/preflight.py` extrait le preflight Ollama natif +- [x] `core/runtime/health.py` expose `current_apple_model()` et `wait_for_expected_apple_model()` +- [x] `core/next_lots.py` ne porte plus directement la logique de checkpoint Apple ni le preflight HTTP Ollama natif +- [x] Tests dedies ajoutes dans `tests/test_runtime_orchestration.py` +- [x] Suite unitaire a `138 tests` verts + +### Lot livre - 21 mars 2026 (refonte runtime — phase 4) +- [x] `core/runtime/profiles.py` formalise les noms de profils runtime et de probes (`mascarade_local`, `mascarade_remote_*`, `apple_coreml_single_model`, `ollama_openai_compatible`, `llama_cpp_local`) +- [x] `core/runtime/models.py` encode explicitement le mode `response_format` dans `RuntimeCapabilities` +- [x] `core/runtime/config.py` nomme les profils a partir du registre runtime partage +- [x] `core/next_lots.py` et `scripts/ops_tui.py` consomment maintenant ce registre au lieu de coder les noms en dur +- [x] `tests/test_runtime_profiles.py` couvre le mapping des profils et des probes runtime +- [x] Suite unitaire a `140 tests` verts + +### Lot livre - 21 mars 2026 (refonte runtime — phase 5) +- [x] `core/runtime/remote_hosts.py` factorise la configuration des hosts remote Mascarade +- [x] `scripts/mascarade_remote_tui.py` consomme le registre de profils runtime et affiche maintenant le profil probe + le modele actif +- [x] `scripts/setup_mascarade_launchd.py` reutilise la meme source de verite pour les hosts remote +- [x] `core/runtime/orchestration.py` extrait le plan d'execution runtime, les signaux checkpoint et le catalogue Ollama hors de `core/next_lots.py` +- [x] `core/next_lots.py` consomme maintenant ce module d'orchestration pour la strategie runtime restante +- [x] Tests dedies ajoutes dans `tests/test_mascarade_remote_tui.py` et `tests/test_runtime_execution_plan.py` +- [x] Suite unitaire a `152 tests` verts + +### Lot livre - 22 mars 2026 (control plane — extraction tracking_sync) +- [x] `core/tracking_sync.py` extrait la synchronisation documentaire auto-sync hors de `core/next_lots.py` +- [x] `core/next_lots.py` est recentre sur orchestration, etat et commandes +- [x] Les tests de sync documentaire sont isoles dans `tests/test_tracking_sync.py` +- [x] Les tests d'orchestration visibles restent dans `tests/test_next_lots.py` +- [x] Suite unitaire Python ciblee relancee apres extraction + +### Lot livre - 22 mars 2026 (qualite narrative — juge secondaire) +- [x] `core/evaluation/` ajoute une interface `NarrativeJudge` et son implementation `ProviderNarrativeJudge` +- [x] Activation optionnelle du juge via `ANE_JUDGE_MODEL` +- [x] Nouveau prompt `prompts/judge_narrative_v1.txt` + retry JSON strict +- [x] `ManuscriptGateReport`, `gate_v1.json` et `meta.json` exposent maintenant `judge_report` et `judge_blockers` +- [x] `_repair_focus()` traite maintenant explicitement `missing_risky_decision`, `missing_immediate_consequence`, `incomplete_scene`, `weak_narrative_continuity` +- [x] Les prompts `gate_v1`, `rewrite_v1` et `repair_v1` ont ete resserres sur decision risquee + consequence immediate +- [x] Suite unitaire Python complete relancee a `156 tests` verts + +### Lot livre - 22 mars 2026 (requalification narrative + hygiene manifeste) +- [x] `llama-server` local `:8091` revalide en chargeant et servant `ollama:qwen2.5:7b` +- [x] `llama-server` local `:8091` revalide en chargeant et servant `ollama:mistral-nemo:latest` +- [x] Rerun cible `ollama:qwen2.5:7b` avec `ANE_JUDGE_MODEL=ollama:qwen2.5:7b` : + - verdict final `quality_blocked` + - blockers finaux `truncated_ending`, `missing_risky_decision` + - workspace `automation/reports/20260322_qwen2_5_7b_judge` +- [x] Rerun cible `ollama:mistral-nemo:latest` avec `ANE_JUDGE_MODEL=ollama:mistral-nemo:latest` : + - verdict final `quality_blocked` + - blockers finaux `truncated_ending`, `missing_immediate_consequence` + - workspace `automation/reports/20260322_mistral_nemo_judge` +- [x] `automation/next_lots.toml` repointe vers le vrai repo local Mascarade (`/Users/electron/Documents/Projets/mascarade`) +- [x] `automation/next_lots.toml` repointe le tracking ANE vers les snapshots docs du 22 mars 2026 +- [x] `docs/runbooks/LOCAL_GENERATION.md` corrige les chemins d'exemple vers le repo Mascarade reel +- [x] Nouvelle continuite projet 22 mars ajoutee : + - `docs/CONTEXTE_PROJET_2026-03-22.md` + - `docs/MEMOIRE_REPRISE_2026-03-22.md` + - `docs/EXECUTION_PLAN_2026-03-22.md` + - `docs/MODEL_COMPARISON_2026-03-22.md` + +### Lot livre - 22 mars 2026 (budgets comparables + prompt retouche) +- [x] `scripts/smoke_local_generation.sh` aligne ses budgets non-Apple par defaut sur le manifeste (`rewrite=1024`, `repair=1536`) +- [x] `README.md` aligne les budgets d'exemple sur le manifeste courant +- [x] Rerun comparable `ollama:qwen2.5:7b` avec budgets manifeste : + - workspace `automation/reports/20260322_qwen2_5_7b_judge_budgeted` + - verdict `quality_blocked ['truncated_ending', 'missing_risky_decision', 'incomplete_scene']` +- [x] Rerun comparable `ollama:mistral-nemo:latest` avec budgets manifeste : + - workspace `automation/reports/20260322_mistral_nemo_judge_budgeted` + - verdict `quality_blocked ['truncated_ending']` +- [x] Retouche courte des prompts `rewrite_v1` / `repair_v1` sur fermeture de scene, non-repetition et structure de fin +- [x] Rerun prompté `ollama:qwen2.5:7b` : + - workspace `automation/reports/20260322_qwen2_5_7b_judge_prompted` + - gain partiel: disparition de `incomplete_scene`, reste `quality_blocked ['truncated_ending', 'missing_risky_decision']` +- [x] Rerun prompté `ollama:mistral-nemo:latest` : + - workspace `automation/reports/20260322_mistral_nemo_judge_prompted` + - regression : `quality_blocked ['outline_like', 'missing_immediate_consequence']` + +### Lot livre - 22 mars 2026 (gate sanitize + micro-decision qwen) +- [x] `pipeline._sanitize_gate_report()` retire maintenant `outline_like` du gate principal quand le brouillon ne declenche aucun marqueur visuel local de plan +- [x] `prompts/gate_v1.txt` force explicitement le gate LLM a preferer un diagnostic de prose faible plutot qu'un faux `outline_like` sans titres, puces ou labels visibles +- [x] `tests/test_generation_pipeline.py` couvre la sanitization `outline_like` et le nouveau guidage `_repair_focus()` sur le cout observable de la decision +- [x] `prompts/rewrite_v1.txt` et `prompts/repair_v1.txt` imposent maintenant que la decision finale soit prise et executee tout de suite, pas seulement annoncee +- [x] Rerun gatefix `ollama:mistral-nemo:latest` : + - workspace `automation/reports/20260322_mistral_nemo_judge_gatefix` + - faux `outline_like` supprime + - verdict final `quality_blocked ['missing_immediate_consequence']` +- [x] Rerun gatefix `ollama:qwen2.5:7b` : + - workspace `automation/reports/20260322_qwen2_5_7b_judge_gatefix` + - `missing_risky_decision` supprime + - verdict final `quality_blocked ['missing_immediate_consequence']` + +### Lot livre - 22 mars 2026 (consequence immediate observable) +- [x] `prompts/rewrite_v1.txt` et `prompts/repair_v1.txt` imposent maintenant une consequence immediate dans le meme lieu et la meme minute, avec effet visible +- [x] `_repair_focus()` ajoute des exemples concrets de consequence observable et interdit explicitement de finir sur un simple depart vers la suite +- [x] Tests pipeline cibles relances pour la nouvelle guidance `missing_immediate_consequence` +- [x] Rerun consequencefix `ollama:qwen2.5:7b` : + - workspace `automation/reports/20260322_qwen2_5_7b_judge_consequencefix` + - verdict final `accepted` + - aucun repair necessaire +- [x] Rerun consequencefix `ollama:mistral-nemo:latest` : + - workspace `automation/reports/20260322_mistral_nemo_judge_consequencefix` + - regression : `quality_blocked ['truncated_ending', 'missing_risky_decision', 'missing_immediate_consequence']` + +### Lot livre - 25 mars 2026 (revalidation Mascarade local + diagnostics runtime app) +- [x] `Mascarade local_server` expose maintenant un `provider_status` honnete sur `health`, `providers/status` et `v1/providers/status` +- [x] Les providers non autorises (`Mistral`, `OpenAI` sur cette machine) ne sont plus annonces comme disponibles; ils remontent maintenant `unauthorized` +- [x] `Claude` est revalide en bout-en-bout sur `:8100/v1/chat/completions` et `:3100/api/v1/chat/completions` avec `claude:claude-sonnet-4-6` +- [x] `Mascarade API` `:3100` est revalidee sur `health`, `v1/api/models` et `v1/api/agents/catalog` contre le core local +- [x] `app_AI-novel-engine` affiche maintenant `provider_status` dans `Generation` et choisit dynamiquement le premier modele reellement actif pour son preset recommande Mascarade + ## Actif - [x] Aucun suivi actif ici. Voir `TODO_ACTIVE.md`. @@ -114,5 +321,5 @@ Regle: - 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-14T14:03:06+00:00 +- dernier cycle automatise observe: 2026-03-23T21:34:05+00:00 diff --git a/automation/launchd/com.ai-novel-engine.mascarade.kxkm.tunnel.plist b/automation/launchd/com.ai-novel-engine.mascarade.kxkm.tunnel.plist new file mode 100644 index 0000000..b8ee4da --- /dev/null +++ b/automation/launchd/com.ai-novel-engine.mascarade.kxkm.tunnel.plist @@ -0,0 +1,31 @@ + + + + + Label + com.ai-novel-engine.mascarade.kxkm.tunnel + ProgramArguments + + /usr/bin/ssh + -N + -o + ExitOnForwardFailure=yes + -o + ServerAliveInterval=30 + -o + ServerAliveCountMax=3 + -L + 127.0.0.1:8111:127.0.0.1:8100 + kxkm@kxkm-ai + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ProcessType + Background + + diff --git a/automation/launchd/com.ai-novel-engine.mascarade.tower.tunnel.plist b/automation/launchd/com.ai-novel-engine.mascarade.tower.tunnel.plist new file mode 100644 index 0000000..3e39ab7 --- /dev/null +++ b/automation/launchd/com.ai-novel-engine.mascarade.tower.tunnel.plist @@ -0,0 +1,31 @@ + + + + + Label + com.ai-novel-engine.mascarade.tower.tunnel + ProgramArguments + + /usr/bin/ssh + -N + -o + ExitOnForwardFailure=yes + -o + ServerAliveInterval=30 + -o + ServerAliveCountMax=3 + -L + 127.0.0.1:8110:127.0.0.1:8100 + clems@192.168.120 + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + ProcessType + Background + + diff --git a/automation/mascarade_hosts.toml b/automation/mascarade_hosts.toml new file mode 100644 index 0000000..0c553bb --- /dev/null +++ b/automation/mascarade_hosts.toml @@ -0,0 +1,15 @@ +[defaults] +remote_health_path = "/health" +remote_core_port = 8100 +local_bind_host = "127.0.0.1" +ssh_connect_timeout_seconds = 4 + +[[hosts]] +name = "tower" +ssh_target = "clems@192.168.120" +local_tunnel_port = 8110 + +[[hosts]] +name = "kxkm" +ssh_target = "kxkm@kxkm-ai" +local_tunnel_port = 8111 diff --git a/brouillons/chapitres/chapitre_01/meta.json b/brouillons/chapitres/chapitre_01/meta.json new file mode 100644 index 0000000..3f05963 --- /dev/null +++ b/brouillons/chapitres/chapitre_01/meta.json @@ -0,0 +1,35 @@ +{ + "chapter": "chapitre_01", + "started_at": "2026-03-16T17:21:18+00:00", + "status": "failed", + "last_status_message": "Échec à l'étape structure: Timeout du provider pendant l'étape 'structure' après 60s.", + "completed_stages": [], + "accepted": false, + "repair_attempts": 0, + "repair_models": [], + "stage_attempts": { + "structure": 1 + }, + "retry_stages": [], + "quality_blockers": [], + "provider": { + "kind": "OpenAICompatibleProvider", + "base_url": "http://127.0.0.1:8100", + "model": "apple-coreml:qwen3.5-4b-onnx-q4f16" + }, + "artifacts": { + "intention": "/Users/electron/Documents/Lelectron_rare/ai-novel-engine/notes/intentions/chapitre_1.md", + "structure": "/Users/electron/Documents/Lelectron_rare/ai-novel-engine/structure/chapitres/chapitre_01.md", + "draft_v1": "/Users/electron/Documents/Lelectron_rare/ai-novel-engine/brouillons/chapitres/chapitre_01/draft_v1.md", + "critique_v1": "/Users/electron/Documents/Lelectron_rare/ai-novel-engine/brouillons/chapitres/chapitre_01/critique_v1.md", + "draft_v2": "/Users/electron/Documents/Lelectron_rare/ai-novel-engine/brouillons/chapitres/chapitre_01/draft_v2.md", + "repair_latest": null, + "repairs": [], + "gate_v1": "/Users/electron/Documents/Lelectron_rare/ai-novel-engine/brouillons/chapitres/chapitre_01/gate_v1.json", + "manuscript": "/Users/electron/Documents/Lelectron_rare/ai-novel-engine/manuscrit/chapitre_01.md", + "memory_summary": "/Users/electron/Documents/Lelectron_rare/ai-novel-engine/memoire/chapitres/chapitre_01.md" + }, + "failed_stage": "structure", + "error": "Timeout du provider pendant l'étape 'structure' après 60s.", + "finished_at": "2026-03-16T17:24:21+00:00" +} diff --git a/cli/main.py b/cli/main.py index 8856dd9..ea2d9e6 100644 --- a/cli/main.py +++ b/cli/main.py @@ -102,6 +102,14 @@ def cmd_status(root: Path): else: print("\nEn attente de validation: aucun") + corrupted_meta = state.get("corrupted_meta", []) + if corrupted_meta: + print("\nMétadonnées corrompues:") + for item in corrupted_meta: + print(f"- {item['chapter']}: meta={item['meta_path']} | erreur: {item['error']}") + else: + print("\nMétadonnées corrompues: aucune") + print("") return 0 diff --git a/core/evaluation/__init__.py b/core/evaluation/__init__.py new file mode 100644 index 0000000..c844c72 --- /dev/null +++ b/core/evaluation/__init__.py @@ -0,0 +1,9 @@ +from core.evaluation.models import NarrativeJudge, NarrativeJudgeReport +from core.evaluation.provider import ProviderNarrativeJudge, build_narrative_judge_from_env + +__all__ = [ + "NarrativeJudge", + "NarrativeJudgeReport", + "ProviderNarrativeJudge", + "build_narrative_judge_from_env", +] diff --git a/core/evaluation/models.py b/core/evaluation/models.py new file mode 100644 index 0000000..7957732 --- /dev/null +++ b/core/evaluation/models.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + +from core.json_payload import parse_json_object, string_list + +_JUDGE_BLOCKER_ALIASES = { + "incomplete": "incomplete_scene", + "lacks_narrative_continuity": "weak_narrative_continuity", +} +_ALLOWED_JUDGE_BLOCKERS = { + "weak_narrative_continuity", + "incomplete_scene", + "missing_risky_decision", + "missing_immediate_consequence", +} + + +@dataclass(frozen=True) +class NarrativeJudgeReport: + ready_for_manuscript: bool + summary: str + blockers: list[str] + recommendations: list[str] + error: str | None = None + raw: dict[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + blockers = _normalize_blockers(self.blockers) + recommendations = _normalize_recommendations(self.recommendations) + summary = self.summary.strip() or "Diagnostic narratif indisponible." + error = _normalize_error(self.error) + ready = bool(self.ready_for_manuscript) and not blockers + + object.__setattr__(self, "blockers", blockers) + object.__setattr__(self, "recommendations", recommendations) + object.__setattr__(self, "summary", summary) + object.__setattr__(self, "error", error) + object.__setattr__(self, "ready_for_manuscript", ready) + + @classmethod + def from_response_text(cls, text: str) -> "NarrativeJudgeReport": + raw = parse_json_object(text) + blockers = _normalize_blockers(string_list(raw.get("blockers"))) + recommendations = _normalize_recommendations(string_list(raw.get("recommendations"))) + ready_default = not blockers + ready_for_manuscript = bool(raw.get("ready_for_manuscript", ready_default)) + summary = str(raw.get("summary", "")).strip() or "Diagnostic narratif indisponible." + return cls( + ready_for_manuscript=ready_for_manuscript, + summary=summary, + blockers=blockers, + recommendations=recommendations, + error=_normalize_error(raw.get("error")), + raw=raw, + ) + + @classmethod + def unavailable(cls, error: str) -> "NarrativeJudgeReport": + return cls( + ready_for_manuscript=True, + summary="Le juge narratif secondaire est indisponible; le gate principal reste seul applicable.", + blockers=[], + recommendations=[], + error=error.strip() or "Erreur de juge inconnue.", + raw={"error": error.strip() or "Erreur de juge inconnue."}, + ) + + 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), + "error": self.error, + } + + +class NarrativeJudge(ABC): + @abstractmethod + def evaluate( + self, + *, + chapter_slug: str, + intention: str, + structure_markdown: str, + draft_markdown: str, + story_context: str, + ) -> NarrativeJudgeReport: + raise NotImplementedError + + +def _normalize_error(value: object) -> str | None: + text = str(value or "").strip() + return text or None + + +def _normalize_blockers(values: list[str]) -> list[str]: + normalized: list[str] = [] + for value in values: + label = _JUDGE_BLOCKER_ALIASES.get(value.strip(), value.strip()) + # Preserve unknown blocker labels so secondary-judge drift stays visible + # instead of being treated as an implicit "no blocker". + if not label or label in normalized: + continue + normalized.append(label) + return normalized + + +def _normalize_recommendations(values: list[str]) -> list[str]: + normalized: list[str] = [] + for value in values: + text = value.strip() + if not text or text in normalized: + continue + normalized.append(text) + return normalized diff --git a/core/evaluation/provider.py b/core/evaluation/provider.py new file mode 100644 index 0000000..ec8f015 --- /dev/null +++ b/core/evaluation/provider.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import os + +from core.evaluation.models import NarrativeJudge, NarrativeJudgeReport +from core.generation.provider import ( + clone_provider_with_model, + GenerationProvider, + GenerationRequest, + ProviderError, +) +from core.prompts import PromptStore + + +class ProviderNarrativeJudge(NarrativeJudge): + def __init__( + self, + *, + provider: GenerationProvider, + prompt_store: PromptStore, + max_tokens: int = 384, + ): + self.provider = provider + self.prompt_store = prompt_store + self.max_tokens = max_tokens + + def evaluate( + self, + *, + chapter_slug: str, + intention: str, + structure_markdown: str, + draft_markdown: str, + story_context: str, + ) -> NarrativeJudgeReport: + prompt = self.prompt_store.render( + "judge_narrative", + chapter_slug=chapter_slug, + intention=intention, + structure_markdown=structure_markdown, + draft_markdown=draft_markdown, + story_context=story_context, + ) + try: + first = self.provider.generate( + GenerationRequest( + stage="judge", + prompt=prompt, + response_format="json", + temperature=0.1, + max_tokens=self.max_tokens, + ) + ) + return NarrativeJudgeReport.from_response_text(first.content) + except (ProviderError, ValueError) as first_error: + retry_prompt = self.prompt_store.render( + "judge_narrative_retry", + chapter_slug=chapter_slug, + intention=intention, + structure_markdown=structure_markdown, + draft_markdown=draft_markdown, + story_context=story_context, + parse_error=str(first_error), + invalid_response=getattr(locals().get("first"), "content", ""), + ) + try: + second = self.provider.generate( + GenerationRequest( + stage="judge", + prompt=retry_prompt, + response_format="json", + temperature=0.1, + max_tokens=self.max_tokens, + ) + ) + return NarrativeJudgeReport.from_response_text(second.content) + except (ProviderError, ValueError) as second_error: + return NarrativeJudgeReport.unavailable( + f"Le juge narratif a echoue apres deux tentatives: {second_error}" + ) + + +def build_narrative_judge_from_env( + *, + provider: GenerationProvider, + prompt_store: PromptStore, +) -> NarrativeJudge | None: + judge_model = os.environ.get("ANE_JUDGE_MODEL", "").strip() + if not judge_model: + return None + + judge_provider = clone_provider_with_model(provider, judge_model) + return ProviderNarrativeJudge(provider=judge_provider, prompt_store=prompt_store) diff --git a/core/generation/models.py b/core/generation/models.py index 2e637d2..bb6449c 100644 --- a/core/generation/models.py +++ b/core/generation/models.py @@ -3,136 +3,33 @@ from __future__ import annotations from dataclasses import dataclass, field import json from pathlib import Path -import re from core.chapters import ChapterId +from core.evaluation.models import NarrativeJudgeReport +from core.json_payload import ( + close_json_delimiters as _close_json_delimiters, + extract_json_object as _extract_json_object, + json_candidates as _json_candidates, + parse_json_object as _parse_json_object, + record_list as _record_list, + remove_trailing_commas as _remove_trailing_commas, + string_list as _string_list, + strip_code_fence as _strip_code_fence, +) - -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 +_GATE_BLOCKER_ALIASES = { + "incomplete": "incomplete_scene", + "lacks_narrative_continuity": "weak_narrative_continuity", +} +_ALLOWED_GATE_BLOCKERS = { + "too_short", + "truncated_ending", + "outline_like", + "weak_narrative_continuity", + "incomplete_scene", + "missing_risky_decision", + "missing_immediate_consequence", +} @dataclass(frozen=True) @@ -224,23 +121,49 @@ class ManuscriptGateReport: blockers: list[str] recommendations: list[str] heuristic_blockers: list[str] + judge_blockers: list[str] = field(default_factory=list) + judge_report: NarrativeJudgeReport | None = None raw: dict[str, object] = field(default_factory=dict) + def __post_init__(self) -> None: + blockers = _normalize_gate_blockers(self.blockers) + heuristic_blockers = _normalize_gate_blockers(self.heuristic_blockers) + judge_blockers = _normalize_gate_blockers(self.judge_blockers) + recommendations = _normalize_recommendations(self.recommendations) + summary = self.summary.strip() or "Diagnostic manuscrit indisponible." + ready = bool(self.ready_for_manuscript) and not blockers and not heuristic_blockers and not judge_blockers + + object.__setattr__(self, "blockers", blockers) + object.__setattr__(self, "heuristic_blockers", heuristic_blockers) + object.__setattr__(self, "judge_blockers", judge_blockers) + object.__setattr__(self, "recommendations", recommendations) + object.__setattr__(self, "summary", summary) + object.__setattr__(self, "ready_for_manuscript", ready) + @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 + blockers = _normalize_gate_blockers(_string_list(raw.get("blockers"))) + heuristic_blockers = _normalize_gate_blockers(_string_list(raw.get("heuristic_blockers"))) + judge_blockers = _normalize_gate_blockers(_string_list(raw.get("judge_blockers"))) + recommendations = _normalize_recommendations(_string_list(raw.get("recommendations"))) + judge_report_payload = raw.get("judge_report") + judge_report = None + if isinstance(judge_report_payload, dict): + judge_report = NarrativeJudgeReport.from_response_text(json.dumps(judge_report_payload, ensure_ascii=False)) + if not judge_blockers: + judge_blockers = list(judge_report.blockers) + ready_default = not blockers and not heuristic_blockers and not judge_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, + ready_for_manuscript=ready_for_manuscript, summary=summary, blockers=blockers, recommendations=recommendations, heuristic_blockers=heuristic_blockers, + judge_blockers=judge_blockers, + judge_report=judge_report, raw=raw, ) @@ -258,16 +181,43 @@ class ManuscriptGateReport: blockers=list(blockers), recommendations=list(recommendations), heuristic_blockers=list(blockers), + judge_blockers=[], + judge_report=None, raw={}, ) def all_blockers(self) -> list[str]: ordered: list[str] = [] - for value in [*self.heuristic_blockers, *self.blockers]: + for value in [*self.heuristic_blockers, *self.blockers, *self.judge_blockers]: if value not in ordered: ordered.append(value) return ordered + def with_judge_report(self, judge_report: NarrativeJudgeReport) -> "ManuscriptGateReport": + recommendations = list(self.recommendations) + for item in judge_report.recommendations: + if item not in recommendations: + recommendations.append(item) + + summary = self.summary + if judge_report.summary and judge_report.summary not in summary: + summary = f"{self.summary} | Juge narratif: {judge_report.summary}" + + return ManuscriptGateReport( + ready_for_manuscript=self.ready_for_manuscript and judge_report.ready_for_manuscript and not judge_report.blockers, + summary=summary, + blockers=list(self.blockers), + recommendations=recommendations, + heuristic_blockers=list(self.heuristic_blockers), + judge_blockers=list(judge_report.blockers), + judge_report=judge_report, + raw={ + **self.raw, + "judge_report": judge_report.to_dict(), + "judge_blockers": list(judge_report.blockers), + }, + ) + def to_dict(self) -> dict[str, object]: return { "ready_for_manuscript": self.ready_for_manuscript, @@ -275,9 +225,33 @@ class ManuscriptGateReport: "blockers": list(self.blockers), "recommendations": list(self.recommendations), "heuristic_blockers": list(self.heuristic_blockers), + "judge_blockers": list(self.judge_blockers), + "judge_report": self.judge_report.to_dict() if self.judge_report is not None else None, } +def _normalize_gate_blockers(values: list[str]) -> list[str]: + normalized: list[str] = [] + for value in values: + label = _GATE_BLOCKER_ALIASES.get(value.strip(), value.strip()) + # Preserve unknown blocker labels so the gate cannot silently flip to ready + # when prompts or local models start emitting a new diagnostic code. + if not label or label in normalized: + continue + normalized.append(label) + return normalized + + +def _normalize_recommendations(values: list[str]) -> list[str]: + normalized: list[str] = [] + for value in values: + text = value.strip() + if not text or text in normalized: + continue + normalized.append(text) + return normalized + + @dataclass(frozen=True) class GenerationContext: root: Path diff --git a/core/generation/provider.py b/core/generation/provider.py index 3935bc8..f2f5fbf 100644 --- a/core/generation/provider.py +++ b/core/generation/provider.py @@ -1,95 +1,17 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, replace +from dataclasses import dataclass import json -import os -import random -import socket -import time from typing import Mapping -from urllib import error, request +from urllib import request + +from core.runtime.client import ChatRequest, OpenAIChatRuntimeClient, RuntimeClientError +from core.runtime.config import OpenAICompatibleRuntimeConfig, STAGE_MAX_TOKENS_ENV +from core.runtime.errors import ProviderConfigurationError, ProviderError -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) +ProviderConfig = OpenAICompatibleRuntimeConfig @dataclass(frozen=True) @@ -122,118 +44,27 @@ class OpenAICompatibleProvider(GenerationProvider): if not config.model: raise ProviderConfigurationError("ANE_MODEL est requis pour le provider openai_compatible.") self.config = config + self.client = OpenAIChatRuntimeClient( + config.to_runtime_profile(), + opener=request.urlopen, + ) 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", - ) - - _RETRYABLE_HTTP_CODES = {429, 500, 502, 503} - _MAX_RETRIES = 3 - _BASE_DELAY = 1.0 - _MAX_DELAY = 10.0 - - last_exc: Exception | None = None - for attempt in range(_MAX_RETRIES): - try: - with request.urlopen(http_request, timeout=self.config.timeout) as response: - raw_payload = json.loads(response.read().decode("utf-8")) - break - except error.HTTPError as exc: - if exc.code in _RETRYABLE_HTTP_CODES and attempt < _MAX_RETRIES - 1: - last_exc = exc - delay = min(_BASE_DELAY * (2 ** attempt), _MAX_DELAY) - delay += random.uniform(0, delay * 0.25) - time.sleep(delay) - continue - 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, TimeoutError, socket.timeout) as exc: - if attempt < _MAX_RETRIES - 1: - last_exc = exc - delay = min(_BASE_DELAY * (2 ** attempt), _MAX_DELAY) - delay += random.uniform(0, delay * 0.25) - time.sleep(delay) - continue - if isinstance(exc, error.URLError): - raise ProviderError( - f"Impossible de joindre le provider pendant l'étape '{prompt_request.stage}': {exc.reason}" - ) from 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 - else: - raise ProviderError( - f"Le provider a échoué après {_MAX_RETRIES} tentatives pendant l'étape '{prompt_request.stage}'." - ) from last_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.") + response = self.client.generate( + ChatRequest( + stage=prompt_request.stage, + prompt=prompt_request.prompt, + response_format=prompt_request.response_format, + temperature=prompt_request.temperature, + system_prompt=prompt_request.system_prompt, + max_tokens=prompt_request.max_tokens, + ) + ) + except RuntimeClientError as exc: + message = str(exc).replace("runtime", "provider") + raise ProviderError(message) from exc + return GenerationResponse(content=response.content, model=response.model, raw=response.raw) class MockGenerationProvider(GenerationProvider): diff --git a/core/json_payload.py b/core/json_payload.py new file mode 100644 index 0000000..d3381f2 --- /dev/null +++ b/core/json_payload.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json +import re + + +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 + output: list[str] = [] + + for char in payload: + if in_string: + output.append(char) + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + continue + if char == '"': + in_string = True + output.append(char) + elif char == "{": + stack.append("}") + output.append(char) + elif char == "[": + stack.append("]") + output.append(char) + elif char in {"}", "]"}: + if stack and stack[-1] == char: + stack.pop() + output.append(char) + elif char in stack: + while stack and stack[-1] != char: + output.append(stack.pop()) + if stack: + stack.pop() + output.append(char) + else: + output.append(char) + + repaired = "".join(output).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 diff --git a/core/project/loader.py b/core/project/loader.py index c974ef5..c17e61e 100644 --- a/core/project/loader.py +++ b/core/project/loader.py @@ -73,37 +73,31 @@ class ProjectState: 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 + return [ + { + "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(), + } + for chapter, draft_dir, meta in self._iter_chapters_with_status("failed") + ] 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 + result: list[dict[str, object]] = [] + for chapter, draft_dir, meta in self._iter_chapters_with_status("quality_blocked"): 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( + quality_blockers = ( + [str(item).strip() for item in raw_blockers if str(item).strip()] + if isinstance(raw_blockers, list) + else [] + ) + result.append( { "chapter": chapter.slug, "status": str(meta.get("status", "")), @@ -118,18 +112,15 @@ class ProjectState: "last_status_message": str(meta.get("last_status_message", "")).strip(), } ) - return blocked + return result 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 + result: list[dict[str, object]] = [] + for chapter, draft_dir, meta in self._iter_chapters_with_status("awaiting_acceptance"): artifacts = meta.get("artifacts", {}) if not isinstance(artifacts, dict): artifacts = {} - pending.append( + result.append( { "chapter": chapter.slug, "status": str(meta.get("status", "")), @@ -143,7 +134,7 @@ class ProjectState: "last_status_message": str(meta.get("last_status_message", "")).strip(), } ) - return pending + return result def retry_stages(self) -> dict[str, list[str]]: retries: dict[str, list[str]] = {} @@ -156,6 +147,21 @@ class ProjectState: retries[chapter.slug] = stages return retries + def corrupted_meta(self) -> list[dict[str, str]]: + corrupted: list[dict[str, str]] = [] + for chapter, draft_dir in discover_chapter_dirs(self.drafts): + _payload, error = self._read_meta(draft_dir) + if error is None: + continue + corrupted.append( + { + "chapter": chapter.slug, + "meta_path": str(draft_dir / "meta.json"), + "error": error, + } + ) + return corrupted + def summary(self) -> dict[str, object]: return { "project_root": str(self.root), @@ -175,19 +181,32 @@ class ProjectState: "quality_blocked_chapters": self.quality_blocked_chapters(), "awaiting_acceptance": self.awaiting_acceptance(), "retry_stages": self.retry_stages(), + "corrupted_meta": self.corrupted_meta(), } + def _iter_chapters_with_status(self, status: str): + for chapter, draft_dir in discover_chapter_dirs(self.drafts): + meta = self._load_meta(draft_dir) + if meta and meta.get("status") == status: + yield chapter, draft_dir, meta + def _load_meta(self, draft_dir: Path) -> dict[str, object] | None: + payload, _error = self._read_meta(draft_dir) + return payload + + def _read_meta(self, draft_dir: Path) -> tuple[dict[str, object] | None, str | None]: meta_path = draft_dir / "meta.json" if not meta_path.exists(): - return None + return None, None try: payload = json.loads(meta_path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - return None + except OSError as exc: + return None, f"{exc.__class__.__name__}: {exc}" + except json.JSONDecodeError as exc: + return None, f"JSONDecodeError: {exc.msg} (line {exc.lineno}, column {exc.colno})" if not isinstance(payload, dict): - return None - return payload + return None, "Le meta.json ne contient pas un objet JSON." + return payload, None def _retry_stages(self, meta: dict[str, object]) -> list[str]: raw = meta.get("retry_stages") diff --git a/core/reporting.py b/core/reporting.py new file mode 100644 index 0000000..0cf4c40 --- /dev/null +++ b/core/reporting.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +from collections import Counter, defaultdict +from datetime import datetime, timezone +import json +from pathlib import Path +import re +from typing import Any, Iterable + + +LOG_SUFFIXES = ( + "_ollama_native_preflight.log", + "_preflight.log", + "_smoke.log", +) + + +def safe_read_json(path: Path) -> dict[str, Any] | None: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def safe_stamp(value: str | None) -> datetime: + if not value: + return datetime.fromtimestamp(0, tz=timezone.utc) + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return datetime.fromtimestamp(0, tz=timezone.utc) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def iter_run_payloads(reports_root: Path) -> list[tuple[Path, dict[str, Any]]]: + items: list[tuple[Path, dict[str, Any]]] = [] + for run_path in sorted(reports_root.glob("*/run.json")): + payload = safe_read_json(run_path) + if isinstance(payload, dict): + items.append((run_path, payload)) + return items + + +def latest_report_run(reports_root: Path) -> dict[str, Any] | None: + latest: tuple[datetime, dict[str, Any]] | None = None + for _, payload in iter_run_payloads(reports_root): + stamp = safe_stamp(str(payload.get("updated_at", ""))) + if latest is None or stamp >= latest[0]: + latest = (stamp, payload) + return latest[1] if latest else None + + +def recent_report_runs(reports_root: Path, limit: int = 5) -> list[tuple[Path, dict[str, Any]]]: + ranked = sorted( + iter_run_payloads(reports_root), + key=lambda item: safe_stamp(str(item[1].get("updated_at", ""))), + reverse=True, + ) + return ranked[: max(limit, 0)] + + +def classification_count(results: Iterable[dict[str, Any]]) -> Counter[str]: + counts: Counter[str] = Counter() + for item in results: + counts[str(item.get("classification", "pending"))] += 1 + return counts + + +def folder_timestamp(report_dir: Path) -> datetime: + try: + return datetime.strptime(report_dir.name, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc) + except ValueError: + return datetime.fromtimestamp(0, tz=timezone.utc) + + +def extract_stderr(text: str) -> str: + marker = "\n\nSTDERR\n" + if marker not in text: + return "" + return text.split(marker, 1)[1].strip() + + +def build_log_model_lookup(reports_root: Path) -> dict[str, str]: + lookup: dict[str, str] = {} + for _, payload in iter_run_payloads(reports_root): + for raw in payload.get("results") or []: + if not isinstance(raw, dict): + continue + model = str(raw.get("model", "")).strip() + if not model: + continue + for key in ("preflight_log", "smoke_log"): + value = raw.get(key) + if not isinstance(value, str) or not value.strip(): + continue + lookup[Path(value).name] = model + return lookup + + +def log_label_from_path(log_path: Path, *, model_lookup: dict[str, str] | None = None) -> str: + base = log_path.name + if model_lookup and base in model_lookup: + return model_lookup[base] + if base.startswith("manual_action_"): + return "manual_action" + for suffix in LOG_SUFFIXES: + if base.endswith(suffix): + return base[: -len(suffix)] + return log_path.stem + + +def collect_log_error_counts(reports_root: Path) -> tuple[Counter[str], dict[str, Counter[str]]]: + error_counts: Counter[str] = Counter() + model_errors: defaultdict[str, Counter[str]] = defaultdict(Counter) + model_lookup = build_log_model_lookup(reports_root) + + for log_path in sorted(reports_root.glob("**/*.log")): + try: + raw = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + stderr = extract_stderr(raw) + if not stderr: + continue + + first = "" + for line in stderr.splitlines(): + clean = line.strip() + if clean: + first = clean + break + if not first: + continue + + normalized = re.sub(r"\d+", "", first) + error_counts[normalized] += 1 + model = log_label_from_path(log_path, model_lookup=model_lookup) + model_errors[model][normalized] += 1 + + return error_counts, dict(model_errors) diff --git a/core/runtime/__init__.py b/core/runtime/__init__.py new file mode 100644 index 0000000..7f6da1a --- /dev/null +++ b/core/runtime/__init__.py @@ -0,0 +1,77 @@ +from core.runtime.client import ChatRequest, ChatResponse, OpenAIChatRuntimeClient +from core.runtime.checkpoints import RuntimeManualAction, checkpoint_manual_action_for_model, host_port_from_base_url +from core.runtime.config import ( + OpenAICompatibleRuntimeConfig, + STAGE_MAX_TOKENS_ENV, + openai_base_url_for_model, + runtime_probe_profile, +) +from core.runtime.errors import ProviderConfigurationError, ProviderError +from core.runtime.health import ( + current_apple_model, + probe_runtime_health, + runtime_model_ids, + wait_for_expected_apple_model, +) +from core.runtime.models import RuntimeCapabilities, RuntimeConstraint, RuntimeHealth, RuntimeProfile +from core.runtime.orchestration import ( + RuntimeCheckpointSignals, + RuntimeExecutionPlan, + build_runtime_execution_plan, + collect_checkpoint_runtime_signals, + missing_ollama_models, + openai_runtime_model_ids, + read_current_apple_model, + runtime_timeout_for_model, +) +from core.runtime.policies import ( + default_repair_fallback_model, + is_cross_apple_runtime_switch, + model_provider_name, + resolve_repair_model, +) +from core.runtime.preflight import RuntimePreflightResult, ollama_base_url, run_ollama_native_preflight +from core.runtime.profiles import runtime_probe_name, runtime_profile_name_for_model +from core.runtime.remote_hosts import RemoteHostConfig, read_remote_hosts + +__all__ = [ + "ChatRequest", + "ChatResponse", + "checkpoint_manual_action_for_model", + "current_apple_model", + "default_repair_fallback_model", + "host_port_from_base_url", + "is_cross_apple_runtime_switch", + "OpenAIChatRuntimeClient", + "OpenAICompatibleRuntimeConfig", + "ollama_base_url", + "openai_runtime_model_ids", + "ProviderConfigurationError", + "ProviderError", + "read_current_apple_model", + "read_remote_hosts", + "run_ollama_native_preflight", + "RuntimeManualAction", + "RuntimeCapabilities", + "RuntimeCheckpointSignals", + "RuntimeConstraint", + "RuntimeExecutionPlan", + "RuntimeHealth", + "RemoteHostConfig", + "RuntimePreflightResult", + "RuntimeProfile", + "STAGE_MAX_TOKENS_ENV", + "build_runtime_execution_plan", + "collect_checkpoint_runtime_signals", + "model_provider_name", + "missing_ollama_models", + "openai_base_url_for_model", + "probe_runtime_health", + "runtime_model_ids", + "runtime_profile_name_for_model", + "runtime_probe_name", + "runtime_probe_profile", + "runtime_timeout_for_model", + "resolve_repair_model", + "wait_for_expected_apple_model", +] diff --git a/core/runtime/checkpoints.py b/core/runtime/checkpoints.py new file mode 100644 index 0000000..b994c65 --- /dev/null +++ b/core/runtime/checkpoints.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass +from urllib.parse import urlparse + + +@dataclass(frozen=True) +class RuntimeManualAction: + reason: str + args: list[str] + + +def checkpoint_manual_action_for_model( + *, + model: str, + core_health_ok: bool, + ollama_runtime: str, + ollama_openai_runtime_ready: bool, + ollama_openai_base_url: str, + apple_model_active: str | None, + repo_root: str, + state_path: str, + ane_script_path: str, +) -> RuntimeManualAction | None: + if not core_health_ok: + return RuntimeManualAction( + reason="Le core mascarade ne répond pas correctement.", + args=[ + "bash", + "scripts/prepare_runtime_step.sh", + "--restart", + "core", + "--resume-state", + state_path, + "--ane-script", + ane_script_path, + ], + ) + + if model.startswith("ollama:") and ollama_runtime == "openai_compatible" and not ollama_openai_runtime_ready: + host, port = host_port_from_base_url(ollama_openai_base_url) + return RuntimeManualAction( + reason=( + "Le runtime OpenAI-compatible attendu pour " + f"`{model}` ne publie pas encore ce modele sur `{ollama_openai_base_url}`." + ), + args=[ + "bash", + f"{repo_root}/scripts/prepare_llama_cpp_runtime.sh", + "--model", + model, + "--host", + host, + "--port", + str(port), + "--resume-state", + state_path, + "--ane-script", + ane_script_path, + ], + ) + + if model.startswith("apple-coreml:"): + target_model = model.split(":", 1)[1] + if apple_model_active != target_model: + return RuntimeManualAction( + reason=f"Le runtime Apple sert `{apple_model_active or 'aucun modèle'}` au lieu de `{target_model}`.", + args=[ + "bash", + "scripts/prepare_runtime_step.sh", + "--apple-model", + target_model, + "--resume-state", + state_path, + "--ane-script", + ane_script_path, + ], + ) + + return None + + +def host_port_from_base_url(base_url: str) -> tuple[str, int]: + candidate = base_url.strip() + if "://" not in candidate: + candidate = f"http://{candidate}" + parsed = urlparse(candidate) + host = parsed.hostname or "127.0.0.1" + if parsed.port is not None: + return host, parsed.port + return host, 443 if parsed.scheme == "https" else 80 diff --git a/core/runtime/client.py b/core/runtime/client.py new file mode 100644 index 0000000..6762735 --- /dev/null +++ b/core/runtime/client.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +import random +import socket +import time +from typing import Callable +from urllib import error, request + +from core.runtime.models import RuntimeProfile + + +class RuntimeClientError(RuntimeError): + """Raised when the runtime cannot complete a generation request.""" + + +@dataclass(frozen=True) +class ChatRequest: + 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 ChatResponse: + content: str + model: str | None = None + raw: dict[str, object] | None = None + + +class OpenAIChatRuntimeClient: + def __init__( + self, + profile: RuntimeProfile, + *, + opener: Callable[..., object] | None = None, + sleeper: Callable[[float], None] | None = None, + jitter: Callable[[float, float], float] | None = None, + ) -> None: + self.profile = profile + self._opener = opener or request.urlopen + self._sleeper = sleeper or time.sleep + self._jitter = jitter or random.uniform + + def generate(self, chat_request: ChatRequest) -> ChatResponse: + payload: dict[str, object] = { + "model": self.profile.model, + "messages": self._build_messages(chat_request), + "temperature": chat_request.temperature, + "max_tokens": self.profile.max_tokens_for_stage( + chat_request.stage, + chat_request.max_tokens, + ), + } + if chat_request.response_format == "json" and self.profile.capabilities.supports_response_format: + payload["response_format"] = {"type": "json_object"} + + http_request = request.Request( + self._chat_completions_url(), + data=json.dumps(payload).encode("utf-8"), + headers=self._headers(), + method="POST", + ) + raw_payload = self._request_json(http_request, stage=chat_request.stage) + + try: + choice = raw_payload["choices"][0] + message = choice["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeClientError( + f"Réponse OpenAI-compatible invalide pendant l'étape '{chat_request.stage}'." + ) from exc + + return ChatResponse( + content=self._normalize_message_content(message), + model=str(raw_payload.get("model", self.profile.model)), + raw=raw_payload, + ) + + def _request_json(self, http_request: request.Request, *, stage: str) -> dict[str, object]: + retryable_http_codes = {429, 500, 502, 503} + max_retries = 3 + base_delay = 1.0 + max_delay = 10.0 + last_exc: Exception | None = None + + for attempt in range(max_retries): + try: + with self._opener(http_request, timeout=self.profile.timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + if not isinstance(payload, dict): + raise RuntimeClientError( + f"Réponse JSON inattendue pendant l'étape '{stage}'." + ) + return payload + except error.HTTPError as exc: + if exc.code in retryable_http_codes and attempt < max_retries - 1: + last_exc = exc + self._sleep_backoff(base_delay, max_delay, attempt) + continue + details = exc.read().decode("utf-8", errors="replace") + raise RuntimeClientError( + f"Le runtime a répondu avec HTTP {exc.code} pendant l'étape '{stage}': {details}" + ) from exc + except (error.URLError, TimeoutError, socket.timeout) as exc: + if attempt < max_retries - 1: + last_exc = exc + self._sleep_backoff(base_delay, max_delay, attempt) + continue + if isinstance(exc, error.URLError): + raise RuntimeClientError( + f"Impossible de joindre le runtime pendant l'étape '{stage}': {exc.reason}" + ) from exc + raise RuntimeClientError( + f"Timeout du runtime pendant l'étape '{stage}' après {self.profile.timeout:.0f}s." + ) from exc + except json.JSONDecodeError as exc: + raise RuntimeClientError( + f"Réponse non JSON du runtime pendant l'étape '{stage}'." + ) from exc + + raise RuntimeClientError( + f"Le runtime a échoué après {max_retries} tentatives pendant l'étape '{stage}'." + ) from last_exc + + def _sleep_backoff(self, base_delay: float, max_delay: float, attempt: int) -> None: + delay = min(base_delay * (2 ** attempt), max_delay) + delay += self._jitter(0, delay * 0.25) + self._sleeper(delay) + + def _headers(self) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if self.profile.api_key: + headers["Authorization"] = f"Bearer {self.profile.api_key}" + return headers + + def _build_messages(self, chat_request: ChatRequest) -> list[dict[str, str]]: + messages: list[dict[str, str]] = [] + if chat_request.system_prompt: + messages.append({"role": "system", "content": chat_request.system_prompt}) + messages.append({"role": "user", "content": chat_request.prompt}) + return messages + + def _chat_completions_url(self) -> str: + base = self.profile.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 RuntimeClientError("Le runtime n'a pas renvoyé de contenu texte exploitable.") diff --git a/core/runtime/config.py b/core/runtime/config.py new file mode 100644 index 0000000..86a7a10 --- /dev/null +++ b/core/runtime/config.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +import os +from typing import Mapping + +from core.runtime.errors import ProviderConfigurationError +from core.runtime.models import RuntimeCapabilities, RuntimeConstraint, RuntimeProfile +from core.runtime.profiles import PROFILE_OPENAI_PROBE, runtime_profile_name_for_model + + +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 + + +def _runtime_capabilities(model: str) -> RuntimeCapabilities: + is_apple_runtime = model.startswith("apple-coreml:") + return RuntimeCapabilities( + supports_response_format=True, + response_format_mode="best_effort", + requires_manual_model_switch=is_apple_runtime, + single_model_per_runtime=is_apple_runtime, + supports_cross_provider_fallback=not is_apple_runtime, + ) + + +def _runtime_constraints(model: str) -> tuple[RuntimeConstraint, ...]: + constraints: list[RuntimeConstraint] = [ + RuntimeConstraint( + code="json-best-effort", + detail="Le contrat JSON reste best-effort; ANE doit garder ses retries applicatifs.", + ) + ] + if not model.startswith("apple-coreml:"): + return tuple(constraints) + constraints.append( + RuntimeConstraint( + code="manual-apple-switch", + detail="Le runtime Apple local ne sert qu'un model_id a la fois; un checkpoint manuel peut etre requis.", + ) + ) + constraints.append( + RuntimeConstraint( + code="apple_single_model_runtime", + detail="Le runtime Apple ne sert qu'un seul model_id a la fois.", + ) + ) + return tuple(constraints) + + +@dataclass(frozen=True) +class OpenAICompatibleRuntimeConfig: + 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) -> "OpenAICompatibleRuntimeConfig": + 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) -> "OpenAICompatibleRuntimeConfig": + return replace(self, model=model) + + def to_runtime_profile(self) -> RuntimeProfile: + return RuntimeProfile( + name=runtime_profile_name_for_model(self.model), + provider=self.provider, + base_url=self.base_url, + api_key=self.api_key, + model=self.model, + timeout=self.timeout, + max_tokens=self.max_tokens, + stage_max_tokens=self.stage_max_tokens, + capabilities=_runtime_capabilities(self.model), + constraints=_runtime_constraints(self.model), + ) + + +def runtime_probe_profile( + base_url: str, + *, + timeout: float = 10.0, + model: str = "runtime-probe", + provider: str = "openai_compatible", + name: str = PROFILE_OPENAI_PROBE, +) -> RuntimeProfile: + profile = OpenAICompatibleRuntimeConfig( + provider=provider, + base_url=base_url, + api_key="", + model=model, + timeout=timeout, + max_tokens=1, + stage_max_tokens={}, + ).to_runtime_profile() + return replace(profile, name=name) + + +def openai_base_url_for_model( + model: str, + *, + core_base_url: str, + ollama_runtime: str, + ollama_openai_base_url: str, +) -> str: + if model.startswith("ollama:") and ollama_runtime == "openai_compatible": + return ollama_openai_base_url + return core_base_url diff --git a/core/runtime/errors.py b/core/runtime/errors.py new file mode 100644 index 0000000..0888b7c --- /dev/null +++ b/core/runtime/errors.py @@ -0,0 +1,6 @@ +class ProviderError(RuntimeError): + """Raised when a text generation provider fails.""" + + +class ProviderConfigurationError(ProviderError): + """Raised when the provider environment is incomplete.""" diff --git a/core/runtime/health.py b/core/runtime/health.py new file mode 100644 index 0000000..e262289 --- /dev/null +++ b/core/runtime/health.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +import time +from urllib import error, request + +from core.runtime.models import RuntimeHealth, RuntimeProfile + + +def probe_runtime_health( + profile: RuntimeProfile, + *, + opener=request.urlopen, + json_fetcher=None, + health_fetcher=None, +) -> RuntimeHealth: + url = _health_url(profile.base_url) + try: + if health_fetcher is not None: + payload = health_fetcher(url, profile.timeout) + else: + with opener(url, timeout=profile.timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + except (OSError, error.URLError, error.HTTPError, json.JSONDecodeError) as exc: + return RuntimeHealth(ok=False, url=url, detail=str(exc)) + + if not isinstance(payload, dict): + return RuntimeHealth(ok=False, url=url, detail="Réponse health non exploitable.") + + active_model = None + for key in ("model", "active_model", "model_id"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + active_model = value.strip() + break + + status = payload.get("status") + available_models = None + detail = None + if json_fetcher is not None: + models_url = runtime_models_url(profile.base_url) + try: + models_payload = json_fetcher(models_url, profile.timeout) + except (OSError, error.URLError, error.HTTPError, json.JSONDecodeError): + detail = "Runtime joignable mais catalogue modeles indisponible." + else: + available_models = extract_runtime_model_ids(models_payload) + if active_model is None and len(available_models) == 1: + active_model = available_models[0] + + return RuntimeHealth( + ok=True, + url=url, + status=str(status).strip() if status is not None else None, + active_model=active_model, + available_models=available_models, + detail=detail, + ) + + +def runtime_model_ids( + profile: RuntimeProfile, + *, + json_fetcher, +) -> set[str]: + payload = json_fetcher(runtime_models_url(profile.base_url), profile.timeout) + return set(extract_runtime_model_ids(payload)) + + +def current_apple_model( + base_url: str, + *, + json_fetcher, + timeout: float = 10.0, +) -> str | None: + try: + payload = json_fetcher(f"{base_url.rstrip('/')}/models", timeout) + except (OSError, json.JSONDecodeError, ValueError): + return None + model_ids = extract_runtime_model_ids(payload) + return model_ids[0] if model_ids else None + + +def wait_for_expected_apple_model( + base_url: str, + target_model: str, + *, + json_fetcher, + timeout_seconds: float, + poll_interval_seconds: float, + sleeper=time.sleep, + monotonic=time.monotonic, +) -> str | None: + deadline = monotonic() + max(timeout_seconds, 0.0) + poll_interval = max(poll_interval_seconds, 0.1) + last_seen = current_apple_model(base_url, json_fetcher=json_fetcher) + if last_seen == target_model or timeout_seconds <= 0: + return last_seen + while monotonic() < deadline: + sleeper(poll_interval) + last_seen = current_apple_model(base_url, json_fetcher=json_fetcher) + if last_seen == target_model: + return last_seen + return last_seen + + +def _health_url(base_url: str) -> str: + base = base_url.rstrip("/") + if base.endswith("/health"): + return base + if base.endswith("/v1"): + base = base[:-3].rstrip("/") + return f"{base}/health" + + +def runtime_models_url(base_url: str) -> str: + base = base_url.rstrip("/") + if base.endswith("/v1/chat/completions"): + return f"{base[:-len('/chat/completions')]}/models" + if base.endswith("/chat/completions"): + return f"{base[:-len('/chat/completions')]}/models" + if base.endswith("/v1"): + return f"{base}/models" + return f"{base}/v1/models" + + +def extract_runtime_model_ids(payload: object) -> list[str]: + model_ids: list[str] = [] + if isinstance(payload, list): + model_ids.extend(str(item) for item in payload) + if isinstance(payload, dict): + if isinstance(payload.get("data"), list): + for item in payload["data"]: + if isinstance(item, dict): + for key in ("id", "name", "model"): + value = str(item.get(key, "")).strip() + if value: + model_ids.append(value) + aliases = item.get("aliases") + if isinstance(aliases, list): + model_ids.extend(str(alias).strip() for alias in aliases if str(alias).strip()) + else: + model_ids.append(str(item)) + if isinstance(payload.get("models"), list): + for item in payload["models"]: + if isinstance(item, dict): + for key in ("id", "name", "model"): + value = str(item.get(key, "")).strip() + if value: + model_ids.append(value) + else: + model_ids.append(str(item)) + return [item for item in model_ids if item] diff --git a/core/runtime/models.py b/core/runtime/models.py new file mode 100644 index 0000000..ef99a54 --- /dev/null +++ b/core/runtime/models.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from typing import Mapping + + +@dataclass(frozen=True) +class RuntimeCapabilities: + supports_response_format: bool = True + response_format_mode: str = "best_effort" + requires_manual_model_switch: bool = False + single_model_per_runtime: bool = False + supports_cross_provider_fallback: bool = True + + +@dataclass(frozen=True) +class RuntimeConstraint: + code: str + detail: str + + +@dataclass(frozen=True) +class RuntimeProfile: + provider: str + base_url: str + api_key: str + model: str + timeout: float + max_tokens: int + stage_max_tokens: Mapping[str, int] + name: str = "openai_compatible_generic" + capabilities: RuntimeCapabilities = field(default_factory=RuntimeCapabilities) + constraints: tuple[RuntimeConstraint, ...] = () + + 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) -> "RuntimeProfile": + next_manual_switch = self.capabilities.requires_manual_model_switch + if self.model.startswith("apple-coreml:") and model.startswith("apple-coreml:") and self.model != model: + next_manual_switch = True + constraints = list(self.constraints) + if model.startswith("apple-coreml:") and not any( + item.code == "manual-apple-switch" for item in constraints + ): + constraints.append( + RuntimeConstraint( + code="manual-apple-switch", + detail="Le runtime Apple local ne sert qu'un model_id a la fois; un checkpoint manuel peut etre requis.", + ) + ) + return replace( + self, + model=model, + capabilities=replace(self.capabilities, requires_manual_model_switch=next_manual_switch), + constraints=tuple(constraints), + ) + + @property + def provider_name(self) -> str | None: + if ":" not in self.model: + return None + provider, _ = self.model.split(":", 1) + provider = provider.strip() + return provider or None + + +@dataclass(frozen=True) +class RuntimeHealth: + ok: bool + url: str + status: str | None = None + active_model: str | None = None + available_models: list[str] | None = None + detail: str | None = None diff --git a/core/runtime/orchestration.py b/core/runtime/orchestration.py new file mode 100644 index 0000000..8aa58ce --- /dev/null +++ b/core/runtime/orchestration.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import Any, Callable + +from core.runtime.config import openai_base_url_for_model, runtime_probe_profile +from core.runtime.health import current_apple_model, probe_runtime_health, runtime_model_ids, wait_for_expected_apple_model +from core.runtime.profiles import runtime_probe_name + + +JsonFetcher = Callable[[str, float], Any] + + +@dataclass(frozen=True) +class RuntimeExecutionPlan: + model: str + openai_base_url: str + timeout_seconds: int + requires_native_ollama_preflight: bool + native_preflight_timeout_seconds: float + probe_profile_name: str + + +@dataclass(frozen=True) +class RuntimeCheckpointSignals: + core_health_ok: bool + apple_model_active: str | None + ollama_openai_runtime_ready: bool + + +def runtime_timeout_for_model(model: str, *, smoke_timeout_seconds: int) -> int: + if model.startswith("apple-coreml:"): + return max(600, smoke_timeout_seconds) + return max(120, smoke_timeout_seconds) + + +def build_runtime_execution_plan( + model: str, + *, + core_base_url: str, + ollama_runtime: str, + ollama_openai_base_url: str, + smoke_timeout_seconds: int, +) -> RuntimeExecutionPlan: + openai_base_url = openai_base_url_for_model( + model, + core_base_url=core_base_url, + ollama_runtime=ollama_runtime, + ollama_openai_base_url=ollama_openai_base_url, + ) + timeout_seconds = runtime_timeout_for_model(model, smoke_timeout_seconds=smoke_timeout_seconds) + requires_native_preflight = model.startswith("ollama:") and ollama_runtime == "native" + probe_kind = "ollama_openai" if openai_base_url == ollama_openai_base_url and model.startswith("ollama:") else "core" + return RuntimeExecutionPlan( + model=model, + openai_base_url=openai_base_url, + timeout_seconds=timeout_seconds, + requires_native_ollama_preflight=requires_native_preflight, + native_preflight_timeout_seconds=min(45.0, float(timeout_seconds)), + probe_profile_name=runtime_probe_name(probe_kind), + ) + + +def collect_checkpoint_runtime_signals( + model: str, + *, + core_base_url: str, + apple_runtime_url: str, + apple_model_ready_timeout_seconds: float, + apple_model_poll_interval_seconds: float, + ollama_runtime: str, + ollama_openai_base_url: str, + json_fetcher: JsonFetcher, +) -> RuntimeCheckpointSignals: + core_profile = runtime_probe_profile(core_base_url, name=runtime_probe_name("core")) + core_health = probe_runtime_health( + core_profile, + health_fetcher=json_fetcher, + json_fetcher=json_fetcher, + ) + + apple_model = None + if model.startswith("apple-coreml:"): + apple_model = wait_for_expected_apple_model( + apple_runtime_url, + model.split(":", 1)[1], + json_fetcher=json_fetcher, + timeout_seconds=apple_model_ready_timeout_seconds, + poll_interval_seconds=apple_model_poll_interval_seconds, + ) + + ollama_ready = False + if model.startswith("ollama:") and ollama_runtime == "openai_compatible": + ollama_ready = model in openai_runtime_model_ids( + ollama_openai_base_url, + json_fetcher=json_fetcher, + profile_name=runtime_probe_name("ollama_openai"), + ) + + return RuntimeCheckpointSignals( + core_health_ok=core_health.ok, + apple_model_active=apple_model, + ollama_openai_runtime_ready=ollama_ready, + ) + + +def openai_runtime_model_ids(base_url: str, *, json_fetcher: JsonFetcher, profile_name: str) -> set[str]: + profile = runtime_probe_profile(base_url, name=profile_name) + health = probe_runtime_health( + profile, + health_fetcher=json_fetcher, + json_fetcher=json_fetcher, + ) + if not health.ok or not health.available_models: + return set() + try: + model_ids = runtime_model_ids(profile, json_fetcher=json_fetcher) + except (OSError, json.JSONDecodeError, ValueError): + return set(health.available_models) + return model_ids or set(health.available_models) + + +def missing_ollama_models( + required_models: list[str], + *, + tags_url: str, + json_fetcher: JsonFetcher, +) -> list[str]: + try: + payload = json_fetcher(tags_url, 10.0) + except (OSError, json.JSONDecodeError, ValueError): + 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 required_models if model not in names] + + +def read_current_apple_model(apple_runtime_url: str, *, json_fetcher: JsonFetcher) -> str | None: + return current_apple_model( + apple_runtime_url, + json_fetcher=json_fetcher, + ) diff --git a/core/runtime/policies.py b/core/runtime/policies.py new file mode 100644 index 0000000..1fe791f --- /dev/null +++ b/core/runtime/policies.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from core.runtime.errors import ProviderError + + +def model_provider_name(model: str | None) -> str | None: + if not model or ":" not in model: + return None + provider, _ = model.split(":", 1) + provider = provider.strip() + return provider or None + + +def is_cross_apple_runtime_switch(base_model: str | None, candidate: str | None) -> bool: + if not base_model or not candidate or base_model == candidate: + return False + return base_model.startswith("apple-coreml:") and candidate.startswith("apple-coreml:") + + +def default_repair_fallback_model(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 resolve_repair_model( + *, + base_model: str | None, + attempt: int, + override_model: str = "", +) -> str | None: + if attempt <= 1: + return base_model + + candidate = override_model or default_repair_fallback_model(base_model) or base_model + if not override_model and model_provider_name(candidate) != model_provider_name(base_model): + candidate = base_model + if is_cross_apple_runtime_switch(base_model, candidate): + from core.generation.provider import ProviderError + + 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 diff --git a/core/runtime/preflight.py b/core/runtime/preflight.py new file mode 100644 index 0000000..65aacb9 --- /dev/null +++ b/core/runtime/preflight.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +import time +from urllib import error, request + + +@dataclass(frozen=True) +class RuntimePreflightResult: + args: list[str] + returncode: int + stdout: str + stderr: str + duration_seconds: float + + +def ollama_base_url(tags_url: str) -> str: + normalized = tags_url.rstrip("/") + suffix = "/api/tags" + if normalized.endswith(suffix): + return normalized[: -len(suffix)] + return normalized + + +def run_ollama_native_preflight( + *, + model: str, + tags_url: str, + timeout_seconds: float, + opener=request.urlopen, + monotonic=time.monotonic, +) -> RuntimePreflightResult: + payload = { + "model": model, + "messages": [{"role": "user", "content": "Respond with exactly: ollama native preflight ok"}], + "stream": False, + "options": { + "temperature": 0, + "num_predict": 16, + }, + } + body = json.dumps(payload).encode("utf-8") + started = monotonic() + try: + req = request.Request( + f"{ollama_base_url(tags_url)}/api/chat", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with opener(req, timeout=timeout_seconds) as response: + raw_payload = response.read().decode("utf-8") + except error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + return RuntimePreflightResult( + args=["ollama-native-preflight", model], + returncode=1, + stdout="", + stderr=f"HTTP {exc.code} {exc.reason}\n{detail}".strip(), + duration_seconds=monotonic() - started, + ) + except (OSError, error.URLError, TimeoutError) as exc: + return RuntimePreflightResult( + args=["ollama-native-preflight", model], + returncode=1, + stdout="", + stderr=f"{type(exc).__name__}: {exc}", + duration_seconds=monotonic() - started, + ) + + try: + parsed = json.loads(raw_payload) + except json.JSONDecodeError: + parsed = {"raw": raw_payload} + preview = { + "model": parsed.get("model"), + "content": (parsed.get("message") or {}).get("content", ""), + "done_reason": parsed.get("done_reason"), + } + return RuntimePreflightResult( + args=["ollama-native-preflight", model], + returncode=0, + stdout=json.dumps(preview, ensure_ascii=False, indent=2), + stderr="", + duration_seconds=monotonic() - started, + ) diff --git a/core/runtime/profiles.py b/core/runtime/profiles.py new file mode 100644 index 0000000..fecd6fd --- /dev/null +++ b/core/runtime/profiles.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import re +from urllib.parse import urlparse + + +PROFILE_OPENAI_COMPATIBLE_GENERIC = "openai_compatible_generic" +PROFILE_OPENAI_PROBE = "openai_probe" +PROFILE_MASCARADE_LOCAL = "mascarade_local" +PROFILE_MASCARADE_REMOTE_PREFIX = "mascarade_remote" +PROFILE_APPLE_COREML_SINGLE_MODEL = "apple_coreml_single_model" +PROFILE_OLLAMA_NATIVE = "ollama_native" +PROFILE_OLLAMA_OPENAI_COMPATIBLE = "ollama_openai_compatible" +PROFILE_LLAMA_CPP_LOCAL = "llama_cpp_local" + + +def runtime_profile_name_for_model(model: str, *, ollama_runtime: str = "openai_compatible") -> str: + if model.startswith("apple-coreml:"): + return PROFILE_APPLE_COREML_SINGLE_MODEL + if model.startswith("ollama:"): + if ollama_runtime == "native": + return PROFILE_OLLAMA_NATIVE + return PROFILE_OLLAMA_OPENAI_COMPATIBLE + return PROFILE_OPENAI_COMPATIBLE_GENERIC + + +def runtime_probe_name(kind: str, *, base_url: str | None = None, remote_name: str | None = None) -> str: + if kind == "core": + return PROFILE_MASCARADE_LOCAL + if kind == "apple": + return PROFILE_APPLE_COREML_SINGLE_MODEL + if kind == "ollama_openai": + return PROFILE_LLAMA_CPP_LOCAL + if kind == "remote": + suffix = remote_name or _remote_suffix_from_base_url(base_url or "") + if suffix: + return f"{PROFILE_MASCARADE_REMOTE_PREFIX}_{suffix}" + return PROFILE_MASCARADE_REMOTE_PREFIX + return PROFILE_OPENAI_PROBE + + +def _remote_suffix_from_base_url(base_url: str) -> str: + parsed = urlparse(base_url) + host = (parsed.hostname or "").strip().lower() + if not host: + return "" + host = re.sub(r"[^a-z0-9]+", "_", host).strip("_") + return host diff --git a/core/runtime/remote_hosts.py b/core/runtime/remote_hosts.py new file mode 100644 index 0000000..3e970f2 --- /dev/null +++ b/core/runtime/remote_hosts.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import sys +import tomllib +from typing import TextIO + +from core.runtime.profiles import runtime_probe_name + + +@dataclass(frozen=True) +class RemoteHostConfig: + name: str + ssh_target: str + local_tunnel_port: int + remote_core_port: int + remote_health_path: str + local_bind_host: str + ssh_connect_timeout_seconds: int + + def local_base_url(self) -> str: + return f"http://{self.local_bind_host}:{self.local_tunnel_port}" + + def local_health_url(self) -> str: + return f"{self.local_base_url()}{self.remote_health_path}" + + def tunnel_command(self) -> str: + return ( + "ssh -N " + "-o ExitOnForwardFailure=yes " + "-o ServerAliveInterval=30 " + "-o ServerAliveCountMax=3 " + f"-L {self.local_bind_host}:{self.local_tunnel_port}:127.0.0.1:{self.remote_core_port} " + f"{self.ssh_target}" + ) + + @property + def label(self) -> str: + return f"com.ai-novel-engine.mascarade.{self.name}.tunnel" + + @property + def plist_name(self) -> str: + return f"{self.label}.plist" + + def probe_profile_name(self) -> str: + return runtime_probe_name("remote", remote_name=self.name) + + +def read_remote_hosts(config_path: Path, *, stderr: TextIO | None = None) -> list[RemoteHostConfig]: + stderr = stderr or sys.stderr + try: + payload = tomllib.loads(config_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as exc: + print(f"Erreur lecture config {config_path}: {exc}", file=stderr) + return [] + + defaults = payload.get("defaults") if isinstance(payload, dict) else {} + if not isinstance(defaults, dict): + defaults = {} + + hosts = payload.get("hosts") if isinstance(payload, dict) else [] + if not isinstance(hosts, list): + return [] + + result: list[RemoteHostConfig] = [] + for raw in hosts: + if not isinstance(raw, dict): + continue + name = str(raw.get("name", "")).strip() + ssh_target = str(raw.get("ssh_target", "")).strip() + if not name or not ssh_target: + continue + + local_tunnel_port = int(raw.get("local_tunnel_port", 0) or 0) + if local_tunnel_port <= 0: + continue + + result.append( + RemoteHostConfig( + name=name, + ssh_target=ssh_target, + local_tunnel_port=local_tunnel_port, + remote_core_port=int(raw.get("remote_core_port", defaults.get("remote_core_port", 8100)) or 8100), + remote_health_path=str(raw.get("remote_health_path", defaults.get("remote_health_path", "/health")) or "/health"), + local_bind_host=str(raw.get("local_bind_host", defaults.get("local_bind_host", "127.0.0.1")) or "127.0.0.1"), + ssh_connect_timeout_seconds=int( + raw.get("ssh_connect_timeout_seconds", defaults.get("ssh_connect_timeout_seconds", 4)) or 4 + ), + ) + ) + + return result diff --git a/core/tracking_sync.py b/core/tracking_sync.py new file mode 100644 index 0000000..9ea4bfb --- /dev/null +++ b/core/tracking_sync.py @@ -0,0 +1,581 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +import json +import re +import tempfile +from pathlib import Path +from typing import Any, Iterable + +from core.project.loader import ProjectState +from core.reporting import iter_run_payloads, safe_stamp + + +AUTO_SYNC_TODO_ACTIVE = "ANE-TODO-ACTIVE" +AUTO_SYNC_TODO_DONE = "ANE-TODO-DONE" +AUTO_SYNC_PLAN = "ANE-PLAN" +AUTO_SYNC_COMPARISON = "ANE-COMPARISON" +AUTO_SYNC_README = "ANE-README" +AUTO_SYNC_RUNBOOK = "ANE-RUNBOOK" +AUTO_SYNC_MASCARADE_TODO = "MASCARADE-TODO" +AUTO_SYNC_MASCARADE_PLAN = "MASCARADE-PLAN" +AUTO_SYNC_MASCARADE_README = "MASCARADE-README" +AUTO_SYNC_MASCARADE_RUNBOOK = "MASCARADE-RUNBOOK" + + +@dataclass(frozen=True) +class TrackingPaths: + ane_todo_active: Path + ane_todo_done: Path + ane_plan: Path + ane_comparison: Path + ane_readme: Path + ane_runbook: Path + mascarade_repo: Path + mascarade_todo: Path + mascarade_plan: Path + mascarade_readme: Path + mascarade_runbook: Path + + +@dataclass(frozen=True) +class TrackingSyncContext: + repo_root: Path + next_code_lot: str + ane_todo_active: Path + ane_todo_done: Path + ane_plan: Path + ane_comparison: Path + ane_readme: Path + ane_runbook: Path + mascarade_todo: Path + mascarade_plan: Path + mascarade_readme: Path + mascarade_runbook: Path + + +def build_tracking_sync_context( + repo_root: Path, + *, + next_code_lot: str, + tracking: TrackingPaths, +) -> TrackingSyncContext: + return TrackingSyncContext( + repo_root=repo_root, + next_code_lot=next_code_lot, + ane_todo_active=tracking.ane_todo_active, + ane_todo_done=tracking.ane_todo_done, + ane_plan=tracking.ane_plan, + ane_comparison=tracking.ane_comparison, + ane_readme=tracking.ane_readme, + ane_runbook=tracking.ane_runbook, + mascarade_todo=tracking.mascarade_todo, + mascarade_plan=tracking.mascarade_plan, + mascarade_readme=tracking.mascarade_readme, + mascarade_runbook=tracking.mascarade_runbook, + ) + + +@dataclass(frozen=True) +class TrackingResult: + model: str + category: str + classification: str = "pending" + preflight_ok: bool | None = None + smoke_attempted: bool = False + status: str | None = None + accepted: bool = False + failed_stage: str | None = None + quality_blockers: list[str] = field(default_factory=list) + retry_stages: list[str] = field(default_factory=list) + repair_attempts: int = 0 + notes: list[str] = field(default_factory=list) + completed_stages: list[str] = field(default_factory=list) + repair_models: list[str] = field(default_factory=list) + + def reached_gate(self) -> bool: + return "gate" in self.completed_stages or self.failed_stage == "gate" + + +def _auto_markers(name: str) -> tuple[str, str]: + return ( + f"", + 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}" + repeated_heading_pattern = rf"(?:{re.escape(heading)}\n){{2,}}" + new_text = re.sub(repeated_heading_pattern, f"{heading}\n", new_text) + path.write_text(new_text, encoding="utf-8") + + +def sync_tracking(context: TrackingSyncContext, state: Any, *, dry_run: bool, project_state: dict[str, Any] | None = None) -> None: + if dry_run: + write_report_summary(state) + return + typed_results = _consolidated_tracking_results(state, context.repo_root / "automation" / "reports") + accepted_counts = _accepted_history_counts(state, context.repo_root / "automation" / "reports") + project_state = project_state or ProjectState(context.repo_root).summary() + summary = _build_summary(state, typed_results) + comparison = _render_comparison_markdown(state, typed_results) + active_next = _compute_next_lot_recommendation( + typed_results, + context.next_code_lot, + accepted_counts=accepted_counts, + ) + + replace_auto_section( + context.ane_todo_active, + AUTO_SYNC_TODO_ACTIVE, + "## Auto-sync", + _render_todo_active_sync(summary, active_next), + ) + replace_auto_section( + context.ane_todo_done, + AUTO_SYNC_TODO_DONE, + "## Auto-sync", + _render_todo_done_sync(summary), + ) + replace_auto_section( + context.ane_plan, + AUTO_SYNC_PLAN, + "## Auto-sync", + _render_plan_sync(summary, active_next), + ) + replace_auto_section( + context.ane_comparison, + AUTO_SYNC_COMPARISON, + "## Auto-sync", + comparison, + ) + replace_auto_section( + context.ane_readme, + AUTO_SYNC_README, + "## Etat auto-synchronise", + _render_readme_sync(summary, active_next), + ) + replace_auto_section( + context.ane_runbook, + AUTO_SYNC_RUNBOOK, + "## Etat auto-synchronise", + _render_runbook_sync(summary, project_state, active_next), + ) + replace_auto_section( + context.mascarade_todo, + AUTO_SYNC_MASCARADE_TODO, + "## Auto-sync", + _render_mascarade_todo_sync(summary, active_next), + ) + replace_auto_section( + context.mascarade_plan, + AUTO_SYNC_MASCARADE_PLAN, + "## Auto-sync", + _render_mascarade_plan_sync(summary, active_next), + ) + replace_auto_section( + context.mascarade_readme, + AUTO_SYNC_MASCARADE_README, + "## Etat auto-synchronise", + _render_mascarade_readme_sync(summary, active_next), + ) + replace_auto_section( + context.mascarade_runbook, + AUTO_SYNC_MASCARADE_RUNBOOK, + "## Etat auto-synchronise", + _render_mascarade_runbook_sync(summary, active_next), + ) + write_report_summary(state) + + +def write_report_summary(state: Any) -> None: + report_dir = Path(getattr(state, "report_dir")) + report_dir.mkdir(parents=True, exist_ok=True) + run_path = report_dir / "run.json" + summary_path = report_dir / "SUMMARY.md" + state_payload = dict(vars(state)) + run_path.write_text(json.dumps(state_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + typed_results = list(_state_typed_results(state)) + summary_path.write_text(_render_summary_markdown(state, typed_results), encoding="utf-8") + + +def _safe_timestamp(value: str) -> datetime: + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return datetime.fromtimestamp(0, tz=timezone.utc) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _load_report_history(reports_root: Path) -> list[dict[str, Any]]: + history: list[dict[str, Any]] = [] + if not reports_root.exists(): + return history + for run_path, payload in iter_run_payloads(reports_root): + payload = dict(payload) + payload.setdefault("report_dir", str(run_path.parent)) + history.append(payload) + history.sort(key=lambda item: (_safe_timestamp(str(item.get("updated_at", ""))), str(item.get("report_dir", "")))) + return history + + +def _string_list(value: object) -> list[str]: + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + +def _optional_string(value: object) -> str | None: + text = str(value).strip() if value is not None else "" + return text or None + + +def _tracking_result_from_payload(payload: Any) -> TrackingResult | None: + if isinstance(payload, TrackingResult): + return payload + if not isinstance(payload, dict): + payload = {field: getattr(payload, field, None) for field in ( + "model", + "category", + "classification", + "preflight_ok", + "smoke_attempted", + "status", + "accepted", + "failed_stage", + "quality_blockers", + "retry_stages", + "repair_attempts", + "notes", + "completed_stages", + "repair_models", + )} + model = str(payload.get("model", "")).strip() + category = str(payload.get("category", "")).strip() + if not model or not category: + return None + return TrackingResult( + model=model, + category=category, + classification=str(payload.get("classification", "pending")), + preflight_ok=payload.get("preflight_ok"), + smoke_attempted=bool(payload.get("smoke_attempted", False)), + status=_optional_string(payload.get("status")), + accepted=bool(payload.get("accepted", False)), + failed_stage=_optional_string(payload.get("failed_stage")), + quality_blockers=_string_list(payload.get("quality_blockers")), + retry_stages=_string_list(payload.get("retry_stages")), + repair_attempts=int(payload.get("repair_attempts", 0) or 0), + notes=_string_list(payload.get("notes")), + completed_stages=_string_list(payload.get("completed_stages")), + repair_models=_string_list(payload.get("repair_models")), + ) + + +def _state_typed_results(state: Any) -> list[TrackingResult]: + results: list[TrackingResult] = [] + typed_results = getattr(state, "typed_results", None) + raw_results = typed_results() if callable(typed_results) else getattr(state, "results", []) + for item in raw_results or []: + result = _tracking_result_from_payload(item) + if result is not None: + results.append(result) + return results + + +def _consolidated_tracking_results(state: Any, reports_root: Path) -> list[TrackingResult]: + latest_by_model: dict[str, tuple[tuple[datetime, int], TrackingResult]] = {} + sequence = 0 + for snapshot in [*_load_report_history(reports_root), _state_payload(state)]: + stamp = _safe_timestamp(str(snapshot.get("updated_at", ""))) + for result in _snapshot_results(snapshot): + candidate_key = (stamp, sequence) + current = latest_by_model.get(result.model) + if current is None or candidate_key >= current[0]: + latest_by_model[result.model] = (candidate_key, result) + sequence += 1 + return sorted((payload[1] for payload in latest_by_model.values()), key=_result_sort_key) + + +def _accepted_history_counts(state: Any, reports_root: Path) -> dict[str, int]: + counts: dict[str, int] = {} + for snapshot in [*_load_report_history(reports_root), _state_payload(state)]: + for result in _snapshot_results(snapshot): + if result.classification != "accepted": + continue + counts[result.model] = counts.get(result.model, 0) + 1 + return counts + + +def _snapshot_results(snapshot: dict[str, Any]) -> list[TrackingResult]: + results: list[TrackingResult] = [] + for item in snapshot.get("results") or []: + result = _tracking_result_from_payload(item) + if result is not None: + results.append(result) + return results + + +def _state_payload(state: Any) -> dict[str, Any]: + payload = dict(vars(state)) + if "report_dir" in payload: + payload["report_dir"] = str(payload["report_dir"]) + return payload + + +def _result_sort_key(result: TrackingResult) -> tuple[int, str, str]: + category_order = { + "priority_models": 0, + "baselines": 1, + "preflight_only": 2, + "runtime_preflight": 3, + } + provider = result.model.split(":", 1)[0] + return (category_order.get(result.category, 9), provider, result.model) + + +def _build_summary(state: Any, results: list[TrackingResult]) -> dict[str, Any]: + accepted = [item for item in results if item.classification == "accepted"] + reached_gate = [item for item in results if item.reached_gate()] + quality_blocked = [item for item in results if item.classification == "quality_blocked"] + provider_failed = [item for item in results if item.classification == "provider_failed"] + return { + "started_at": getattr(state, "started_at"), + "updated_at": getattr(state, "updated_at"), + "pending_manual_action": getattr(state, "pending_manual_action", None), + "accepted_models": [item.model for item in accepted], + "reached_gate_models": [item.model for item in reached_gate], + "quality_blocked_models": [item.model for item in quality_blocked], + "provider_failed_models": [item.model for item in provider_failed], + "results": results, + } + + +def _compute_next_lot_recommendation( + results: list[TrackingResult], + fallback: str, + *, + accepted_counts: dict[str, int] | None = None, +) -> str: + accepted_counts = accepted_counts or {} + provider_failed_models = [item.model for item in results if item.classification == "provider_failed"] + has_quality_blocked = any(item.classification == "quality_blocked" for item in results) + if accepted_counts.get("apple-coreml:qwen3.5-4b-onnx-q4f16", 0) >= 2: + if provider_failed_models: + if has_quality_blocked: + return "Reference locale reconfirmee; retablir le runtime des modeles provider_failed puis reprendre rewrite/repair sur les modeles bloques a gate." + return "Reference locale reconfirmee; retablir le runtime des modeles provider_failed avant de poursuivre." + if any(item.classification == "quality_blocked" for item in results): + return "Reference locale reconfirmee; resserrer rewrite/repair sur les modeles deja bloques a gate." + return "Reference locale reconfirmee; garder les autres modeles en regression." + if any(item.classification == "accepted" for item in results): + if provider_failed_models: + return "Confirmer la reference accepted puis retablir le runtime des modeles provider_failed." + if any(item.classification == "quality_blocked" for item in results): + return "Confirmer la reference accepted puis resserrer rewrite/repair sur les modeles deja bloques a gate." + return "Figer la reference locale dans les README/runbooks et garder les autres modeles en regression." + if any(item.reached_gate() for item in results): + return "Analyser les runs ayant atteint gate/repair puis resserrer la reference locale autour des meilleurs candidats." + return fallback + + +def _render_todo_active_sync(summary: dict[str, Any], next_lot: str) -> str: + lines = [ + f"- dernier cycle automatique: {summary['updated_at']}", + f"- modeles accepted: {_comma_or_none(summary['accepted_models'])}", + f"- modeles ayant atteint gate: {_comma_or_none(summary['reached_gate_models'])}", + f"- quality_blocked: {_comma_or_none(summary['quality_blocked_models'])}", + f"- provider_failed: {_comma_or_none(summary['provider_failed_models'])}", + f"- prochain lot recommande: {next_lot}", + ] + if summary["pending_manual_action"]: + pending = summary["pending_manual_action"] + lines.extend( + [ + f"- checkpoint manuel en attente: {pending['reason']}", + f"- commande preparee: `{pending['command']}`", + f"- reprise: `python3 scripts/run_next_lots.py --resume {pending['resume_state']}`", + ] + ) + return "\n".join(lines) + + +def _render_todo_done_sync(summary: dict[str, Any]) -> str: + lines = [ + "- orchestrateur `scripts/run_next_lots.py` disponible", + "- manifeste `automation/next_lots.toml` charge", + "- derniers fichiers de suivi synchronisables via marqueurs `AUTO-SYNC`", + f"- dernier cycle automatise observe: {summary['updated_at']}", + ] + return "\n".join(lines) + + +def _render_plan_sync(summary: dict[str, Any], next_lot: str) -> str: + lines = [ + f"- dernier verdict automatise: {summary['updated_at']}", + f"- accepted: {_comma_or_none(summary['accepted_models'])}", + f"- gate atteint: {_comma_or_none(summary['reached_gate_models'])}", + f"- prochain lot calcule: {next_lot}", + ] + if summary["pending_manual_action"]: + lines.append(f"- checkpoint manuel requis: {summary['pending_manual_action']['reason']}") + return "\n".join(lines) + + +def _render_readme_sync(summary: dict[str, Any], next_lot: str) -> str: + lines = [ + f"- dernier cycle automatise: {summary['updated_at']}", + f"- reference locale actuelle: {_reference_label(summary)}", + f"- prochain lot utile: {next_lot}", + "- lancer un cycle: `python3 scripts/run_next_lots.py --lot full`", + ] + if summary["pending_manual_action"]: + lines.append(f"- checkpoint manuel en attente: {summary['pending_manual_action']['reason']}") + return "\n".join(lines) + + +def _render_runbook_sync(summary: dict[str, Any], project_state: dict[str, Any], next_lot: str) -> str: + lines = [ + f"- dernier cycle automatise: {summary['updated_at']}", + f"- chapitre courant detecte: {project_state.get('current_chapter') or 'aucun'}", + f"- reference locale actuelle: {_reference_label(summary)}", + f"- prochain lot utile: {next_lot}", + ] + if summary["pending_manual_action"]: + lines.append(f"- reprise attendue apres action manuelle: {summary['pending_manual_action']['resume_state']}") + return "\n".join(lines) + + +def _render_mascarade_todo_sync(summary: dict[str, Any], next_lot: str) -> str: + lines = [ + f"- dernier cycle ANE automatise: {summary['updated_at']}", + f"- accepted via runtime local: {_comma_or_none(summary['accepted_models'])}", + f"- gate atteint via runtime local: {_comma_or_none(summary['reached_gate_models'])}", + f"- blocage runtime principal: {next_lot}", + ] + if summary["pending_manual_action"]: + lines.append(f"- checkpoint runtime manuel: {summary['pending_manual_action']['reason']}") + return "\n".join(lines) + + +def _render_mascarade_plan_sync(summary: dict[str, Any], next_lot: str) -> str: + lines = [ + f"- dernier cycle ANE automatise: {summary['updated_at']}", + f"- reference locale ANE: {_reference_label(summary)}", + f"- prochain lot ANE a servir: {next_lot}", + ] + return "\n".join(lines) + + +def _render_mascarade_readme_sync(summary: dict[str, Any], next_lot: str) -> str: + return "\n".join( + [ + f"- dernier cycle ANE automatise: {summary['updated_at']}", + f"- etat de reference ANE: {_reference_label(summary)}", + f"- prochain lot utile cote pipeline: {next_lot}", + ] + ) + + +def _render_mascarade_runbook_sync(summary: dict[str, Any], next_lot: str) -> str: + lines = [ + f"- dernier cycle ANE automatise: {summary['updated_at']}", + f"- meilleurs candidats actuels: {_top_candidates(summary['results'])}", + f"- prochain lot utile cote ANE: {next_lot}", + ] + if summary["pending_manual_action"]: + lines.append(f"- checkpoint runtime manuel: {summary['pending_manual_action']['reason']}") + return "\n".join(lines) + + +def _reference_label(summary: dict[str, Any]) -> str: + if summary["accepted_models"]: + return summary["accepted_models"][0] + if summary["reached_gate_models"]: + return f"aucun accepted, meilleur diagnostic: {summary['reached_gate_models'][0]}" + return "aucune reference accepted" + + +def _top_candidates(results: Iterable[TrackingResult]) -> str: + candidates: list[str] = [] + for item in results: + if item.model in candidates: + continue + if item.model.startswith("apple-coreml:qwen3.5-4b") or item.model.startswith("ollama:qwen2.5:7b"): + candidates.append(item.model) + return ", ".join(candidates) if candidates else "aucun" + + +def _comma_or_none(items: list[str]) -> str: + return ", ".join(items) if items else "aucun" + + +def _render_comparison_markdown(state: Any, results: list[TrackingResult]) -> str: + lines = [ + f"- dernier cycle automatise: {getattr(state, 'updated_at')}", + "", + "| Modele | Categorie | Preflight | Smoke | Classification | Failed stage | Gate | Repairs | Notes |", + "|---|---|---|---|---|---|---|---:|---|", + ] + for item in results: + lines.append( + "| {model} | {category} | {preflight} | {smoke} | {classification} | {failed_stage} | {gate} | {repairs} | {notes} |".format( + model=item.model, + category=item.category, + preflight="OK" if item.preflight_ok else ("KO" if item.preflight_ok is False else "n/a"), + smoke="oui" if item.smoke_attempted else "non", + classification=item.classification, + failed_stage=item.failed_stage or "", + gate="oui" if item.reached_gate() else "non", + repairs=item.repair_attempts, + notes="; ".join(item.notes) if item.notes else "", + ) + ) + return "\n".join(lines) + + +def _render_summary_markdown(state: Any, results: list[TrackingResult]) -> str: + summary = _build_summary(state, results) + lines = [ + "# Résumé du cycle automatique", + "", + f"- lot: `{getattr(state, 'lot')}`", + f"- démarré: `{getattr(state, 'started_at')}`", + f"- mis à jour: `{getattr(state, 'updated_at')}`", + f"- accepted: {_comma_or_none(summary['accepted_models'])}", + f"- gate atteint: {_comma_or_none(summary['reached_gate_models'])}", + f"- quality_blocked: {_comma_or_none(summary['quality_blocked_models'])}", + f"- provider_failed: {_comma_or_none(summary['provider_failed_models'])}", + ] + if getattr(state, "pending_manual_action", None): + lines.extend( + [ + "", + "## Checkpoint manuel", + f"- raison: {state.pending_manual_action['reason']}", + f"- commande: `{state.pending_manual_action['command']}`", + f"- reprise: `python3 scripts/run_next_lots.py --resume {state.pending_manual_action['resume_state']}`", + ] + ) + if results: + lines.extend(["", "## Résultats", ""]) + lines.append(_render_comparison_markdown(state, results)) + return "\n".join(lines) + "\n" diff --git a/docs/AGENTS_2026-03-16.md b/docs/AGENTS_2026-03-16.md new file mode 100644 index 0000000..6e56a34 --- /dev/null +++ b/docs/AGENTS_2026-03-16.md @@ -0,0 +1,166 @@ +# Agents et sous-agents - 16 mars 2026 + +Carte operative des agents de travail pour `ai-novel-engine`. + +Mise a jour : 16 mars 2026 (lot refonte — deep analyse + bugs + outline_like + tests). + +Hypothese volontaire: ces agents sont des boucles de travail et de verification, pas une architecture de production multi-agents. + +## Carte d'ensemble + +```mermaid +flowchart TD + C["Agent 0 - Coordination"] --> R["Agent 1 - Runtime"] + C --> Q["Agent 2 - Qualite narrative"] + C --> O["Agent 3 - Ops / TUI / Logs"] + C --> D["Agent 4 - Documentation / Sync"] + C --> V["Agent 5 - Veille OSS"] + C --> E["Agent 6 - Qualite code"] + R --> Q + O --> D + V --> Q + V --> D + E --> Q +``` + +## Agent 0 - Coordination + +- mission: garder le repo coherent, prioriser, synchroniser plans/TODOs/README +- competences: lecture repo, triage, verification de fin de lot +- sous-agent `tracking-sync`: surveille les sections `AUTO-SYNC` +- sous-agent `release-memory`: tient les docs `CONTEXTE`, `MEMOIRE_REPRISE`, `EXECUTION_PLAN` + +todo actif: +- [x] mascarade :8100 remonte +- [x] lot `baselines` valide : `qwen2.5:1.5b` → `quality_blocked ['truncated_ending']` (plus d'`outline_like`) +- [x] lot `priority_models` termine : `qwen3.5-4b` → `accepted`, `qwen2.5:7b` → `quality_blocked ['truncated_ending']` +- [x] lot `french_models` termine : `mistral-nemo` → `quality_blocked ['outline_like', 'incomplete', 'lacks_narrative_continuity']` +- [ ] resserrer prompts pour `qwen2.5:7b` et `mistral-nemo` (fin de scene + decision risquee) + +## Agent 1 - Runtime + +- mission: garder un backend local stable pour `provider:model` +- competences: `llama.cpp`, `llama-server`, healthchecks, preflight, shim OpenAI-compatible +- sous-agent `apple-runtime`: surveille `:8201` et le modele Apple actif +- sous-agent `llama-runtime`: prepare `:8091` et les alias `ollama:*` + +todo actif: +- [x] mascarade :8100 remis en route (fix `supported_provider_names` → `available_providers`) +- [x] llama-server :8091 valide pour qwen2.5:7b +- [x] mistral-nemo:latest pull + llama-server valide +- [ ] automatiser le demarrage mascarade (script systemd ou launchd) + +## Agent 2 - Qualite narrative + +- mission: faire tomber les blockers prose une fois le runtime stabilise +- competences: prompts, `gate`, `repair`, lecture des artefacts de chapitre, patterns few-shot +- sous-agent `gate-reader`: regroupe `quality_blockers` depuis `gate_v1.json` +- sous-agent `prompt-surgeon`: n'agit que sur `draft_v1`, `rewrite_v1` et `repair_v1` + +todo actif: +- [x] **output primer** dans `draft_v1`, `rewrite_v1`, `repair_v1` — livre (session 2) +- [x] **few-shot BAD/GOOD** dans `draft_v1`, `rewrite_v1`, `repair_v1` — livre (session 2) +- [x] relancer `ollama:qwen2.5:7b` — `quality_blocked ['truncated_ending']` (LLM gate : fin de scene incomplete) +- [x] relancer `ollama:mistral-nemo:latest` — `quality_blocked ['outline_like', 'incomplete', 'lacks_narrative_continuity']` (316 mots, prose trop condensee) +- [ ] renforcer cible `"decision risquee"` dans `rewrite_v1` et `repair_v1` pour `qwen2.5:7b` +- [ ] comprendre pourquoi `mistral-nemo` reste a 316 mots malgre budget 1024 — verifier completion stop token ou longueur systeme +- [ ] convertir la `structure` JSON en resume narratif avant injection dans `draft_v1` (pattern GOAT) + +competences ajoutees (veille OSS): +- pattern GOAT-Storytelling-Agent : outline → narrative summary +- few-shot structure : exemple MAUVAIS / BIEN en 3-5 phrases +- output primer : injecter incipit partiel en fin de prompt + +## Agent 3 - Ops / TUI / Logs + +- mission: donner une lecture operateur simple et verifiable +- competences: scripts shell/python, TUI texte, retention des reports, analyse stderr +- sous-agent `report-analyst`: agrège `run.json` et logs +- sous-agent `cleanup-surgeon`: propose les purges dry-run avant suppression + +todo actif: +- [ ] analyser et supprimer les logs de runs froids (`automation/reports/` anciens) +- garder `scripts/ops_tui.py` comme cockpit court terme +- ne purger que les workspaces ou logs clairement froids + +## Agent 4 - Documentation / Sync + +- mission: faire raconter la meme histoire aux README, plans, runbooks et TODOs +- competences: synthese, spec, mermaid, cartes de fonctionnalite +- sous-agent `spec-writer`: maintient la spec systeme et les feature maps +- sous-agent `manifest-keeper`: garde les chemins du manifeste coherents + +todo actif: +- [x] OSS_LANDSCAPE mis a jour avec nouvelles trouvailles (GOAT, prometheus-eval, story-eval, outlines, DeepEval) +- [x] AGENTS mis a jour +- [x] FEATURE_MAP mis a jour +- [ ] mise a jour README.md apres fix outline_like +- garder les dates 16 mars comme point de reprise + +## Agent 5 - Veille OSS + +- mission: reperer les briques open source utiles sans deguiser ANE en framework generique +- competences: veille sourcee, comparaison produit, tri de dependances +- sous-agent `evals-scout`: regarde les briques eval/guardrails narratifs +- sous-agent `ui-scout`: regarde les cockpits locaux/TUI/web + +suivi actif: +- `GOAT-Storytelling-Agent` — pattern outline→narrative a adapter dans `draft_v1` +- `prometheus-eval` — juge LLM avec rubrique personnalisable +- `story-evaluation-llm` — 15 criteres narratifs LLM-as-judge +- `dottxt/outlines` — contraintes logits anti-Markdown +- `DeepEval` — evals narratifs compatibles pytest +- `llama.cpp`, `Open WebUI`, `SillyTavern`, `koboldcpp` — references runtime/cockpit + +## Agent 6 - Qualite code (nouveau) + +- mission: corriger les bugs critiques et refactorer pour la maintenabilite +- competences: Python, analyse statique, refactoring, couverture de tests +- sous-agent `bug-hunter`: cible les bare excepts, les niveaux d'imbrication et les repetitions +- sous-agent `test-writer`: ajoute les tests manquants sur `cli/main.py`, `intention/gate.py`, `prompts.py` + +todo actif: +- [x] fix 4 bare `except Exception` dans `next_lots.py` +- [x] refactorer `pipeline.generate_chapter()` : `_finish_stage()` extrait (pattern × 4 factorise) +- [x] robustifier `_close_json_delimiters()` dans `models.py` — rebuild car-par-car, mismatched + stray closers, 3 tests +- [x] refactorer 3 fonctions near-identiques dans `project/loader.py` — `_iter_chapters_with_status()` +- [ ] typer `metadata` avec `ChapterMetadata(TypedDict)` dans `pipeline.py` — reporte : 20+ signatures, P3 +- [x] tests `cli/main.py` : 6 nouveaux tests CLI (invalid chapter, duplicate, empty content, no args, ProviderError x2) — 91 tests verts + +## Agent 7 - Robustesse fichiers / reprise (nouveau) + +- mission: eliminer les corruptions d'etat sur crash et fiabiliser la reprise +- competences: ecritures atomiques, tolerance JSON corrompu, runbooks de recovery +- sous-agent `atomic-writer`: applique `temp file + replace` sur les JSON critiques +- sous-agent `recovery-ops`: documente les procedures de reprise lot/meta/state + +todo actif: +- [x] ecritures JSON atomiques sur `pipeline._write_json()` et `RunState.dump()` +- [x] lectures tolerantes aux JSON corrompus dans `pipeline` et `loader` +- [x] runbook `docs/runbooks/RECOVERY_PROCEDURES.md` cree +- [ ] ajouter tests unitaires dedies aux corruptions JSON (meta/state) + +## Agent 8 - Remote runtime Mascarade (tower/kxkm) + +- mission: piloter les cibles SSH distantes sans casser la boucle locale ANE +- competences: SSH tunnel, healthchecks distants, TUI ops, hygiene logs +- sous-agent `tunnel-keeper`: maintient les forwards `8110` et `8111` +- sous-agent `remote-observer`: lit la sante tunnel + pousse les actions de reprise + +todo actif: +- [x] `automation/mascarade_hosts.toml` ajoute +- [x] `scripts/mascarade_remote_tui.py` ajoute +- [x] `scripts/setup_mascarade_launchd.py` ajoute + templates `automation/launchd/*.plist` +- [x] test `tests/test_setup_mascarade_launchd.py` ajoute +- [ ] valider en reel `tower` et `kxkm` avec cle SSH et tunnels actifs +- [ ] activer launchd en reel (`install`) et verifier `status` +- [ ] checkpoint runtime Apple: executer `prepare_runtime_step.sh` puis `run_next_lots.py --resume` + +## Regles de handoff + +- runtime avant prompts +- evidence avant opinion +- dry-run avant purge +- docs a jour avant changement de cap +- fix bugs avant refactor +- tests avant merge diff --git a/docs/AGENTS_2026-03-21.md b/docs/AGENTS_2026-03-21.md new file mode 100644 index 0000000..7fe02ec --- /dev/null +++ b/docs/AGENTS_2026-03-21.md @@ -0,0 +1,98 @@ +# Agents et sous-agents - 21 mars 2026 + +Carte operative mise a jour pour la refonte ANE. + +## Carte d'ensemble + +```mermaid +flowchart TD + C["Agent 0 - Coordination"] --> R["Agent 1 - Runtime layer"] + C --> P["Agent 2 - Pipeline narratif"] + C --> N["Agent 3 - next_lots / control plane"] + C --> O["Agent 4 - Ops / logs / retention"] + C --> D["Agent 5 - Docs / sync"] + C --> V["Agent 6 - Veille OSS"] + C --> A["Agent 7 - App SwiftUI"] + C --> T["Agent 8 - Tests / qualite code"] + R --> N + R --> A + V --> P + T --> R + T --> N +``` + +## Agent 0 - Coordination + +- mission: tenir le point de reprise officiel, arbitrer les lots, garder le repo coherent +- livrables: `TODO_ACTIVE.md`, plans dates, memoires, arbitrages de purge + +## Agent 1 - Runtime layer + +- mission: rendre le runtime explicite et testable +- zones: `core/runtime/*`, `core/generation/provider.py` +- actif: + - [x] extraire profil + client + health + - [x] ajouter profils runtime nommes + - [x] ajouter capacites explicites pour `response_format` et switch manuel + - [x] extraire `remote_hosts.py` et `orchestration.py` + +## Agent 2 - Pipeline narratif + +- mission: garder ANE strictement auteurial +- zones: `core/generation/pipeline.py`, `prompts/*` +- actif: + - [ ] ne pas laisser les contraintes runtime reinfiltrer le pipeline + - [ ] reprendre `rewrite_v1` / `repair_v1` pour `qwen2.5:7b` + - [ ] comprendre `mistral-nemo` + +## Agent 3 - next_lots / control plane + +- mission: decouper orchestration, preflight runtime et checkpoint manuel +- zones: `core/next_lots.py`, `automation/next_lots.toml`, scripts de reprise +- actif: + - [x] sortir les checkpoints runtime du monolithe + - [x] brancher les profils runtime + - [x] sortir le plan runtime et les signaux checkpoint hors du runner + - [ ] proteger les reports references des purges + +## Agent 4 - Ops / logs / retention + +- mission: garder des TUIs et logs lisibles, puis purger sans perdre de references +- zones: `scripts/ops_tui.py`, `scripts/reports_ops.py`, `automation/reports/` +- actif: + - [x] analyser les top erreurs stderr + - [x] converger le TUI remote Mascarade sur la couche runtime partagee + - [ ] durcir la retention des reports documentes + +## Agent 5 - Docs / sync + +- mission: resynchroniser l'histoire du repo avec l'etat reel +- zones: docs racine + docs app +- actif: + - [x] ouvrir un nouveau point de reprise au 21 mars + - [ ] realigner toutes les references stale du 16 mars qui sont devenues fausses + +## Agent 6 - Veille OSS + +- mission: reperer les briques reutilisables sans transformer ANE en framework generique +- actif: + - [x] sourcer `llama.cpp`, `lm-format-enforcer`, `Outlines`, `DeepEval`, `LiteLLM`, `Open WebUI`, `WritingBench` + - [ ] traduire la veille en decisions d'implementation concretes + +## Agent 7 - App SwiftUI + +- mission: aligner l'app avec l'architecture ANE + Mascarade +- zones: `app_AI-novel-engine/Sources/AINovelEngineStudio/*` +- actif: + - [x] backend selector OpenAI / Mascarade + - [x] test connection + - [x] lancement pipeline ANE + - [ ] resynchroniser plans/TODOs/docs de l'app + +## Agent 8 - Tests / qualite code + +- mission: garder chaque lot couvert et relancable +- actif: + - [x] valider `152` tests Python verts apres extraction runtime + - [x] ajouter tests dedies a la logique de profils runtime, TUI remote et execution runtime + - [ ] clarifier la strategie de tests Swift de l'app diff --git a/docs/ANE_RUNNER_CONTRACT_V1_2026-03-23.md b/docs/ANE_RUNNER_CONTRACT_V1_2026-03-23.md new file mode 100644 index 0000000..6614006 --- /dev/null +++ b/docs/ANE_RUNNER_CONTRACT_V1_2026-03-23.md @@ -0,0 +1,226 @@ +# ANE Runner Contract v1 - 23 mars 2026 + +Contrat canonique du pont entre `ai-novel-engine` et ses clients applicatifs, en priorite `app_AI-novel-engine`. + +## But + +Sortir d'un couplage implicite ou un client: + +- prepare lui-meme l'arborescence interne d'ANE +- lance `python3 -m cli.main` avec des hypotheses de layout +- parse directement `meta.json` et les cles `artifacts.*` + +Le contrat `v1` introduit une frontiere stable: + +- une commande runner stable +- une requete JSON stable +- un resultat JSON stable + +## Perimetre + +Le contrat couvre: + +- lancement d'un run de chapitre depuis un client +- passage du contexte auteurial utile au moteur +- restitution d'un resultat lisible sans parser les artefacts internes + +Le contrat ne couvre pas: + +- le protocole runtime OpenAI-compatible +- les details internes de `meta.json` +- le layout complet du workspace ANE +- le contenu exact des prompts narratifs + +## Acteurs et responsabilites + +### Client ANE + +Le client possede: + +- l'UI +- le modele de projet local +- le Keychain et les secrets locaux +- la decision de lancer un run + +Le client ne possede pas: + +- le layout interne du workspace ANE +- la selection de l'artefact final candidat via `repair_latest` ou `draft_v2` +- la lecture directe de `meta.json` comme contrat public + +### ANE Runner + +Le runner possede: + +- la traduction de la requete en workspace ANE +- l'appel au pipeline auteurial +- la selection du brouillon candidat +- la production du resultat public versionne + +### Runtime + +Le runtime reste hors contrat runner. Il est deja borne par le contrat OpenAI-compatible documente ailleurs. + +## Surface stable + +Commande cible: + +```bash +python3 -m cli.main runner execute \ + --request /path/to/ane_runner_request_v1.json \ + --result /path/to/ane_runner_result_v1.json +``` + +Compatibilite transitoire: + +- tant que cette commande n'est pas implementee, le pont historique `generate chapter --chapter XX` reste un mode legacy +- ce mode legacy ne doit plus etre etendu comme contrat public + +## Requete v1 + +Nom logique: `ane_runner_request_v1.json` + +Champs obligatoires: + +| Champ | Type | Role | +|------|------|------| +| `contract_version` | `string` | doit valoir `ane-runner-v1` | +| `chapter` | `string` | identifiant normalisable du chapitre, ex. `01` | +| `project` | `object` | metadonnees auteuriales minimales | +| `scene` | `object` | scene cible ou intention locale | +| `runtime` | `object` | provider, base URL, modele, budgets utiles | +| `execution` | `object` | politique de validation et mode workspace | + +Champs optionnels: + +| Champ | Type | Role | +|------|------|------| +| `request_id` | `string` | correlation client | +| `locked_history` | `array` | scenes/chapitres precedents verrouilles utiles au contexte | +| `characters` | `array` | personnages structures | +| `world_state` | `object` | lieux, chronologie, index annexes | +| `workspace_override` | `string` | racine explicite si le client force un workspace | + +Exemple minimal: + +```json +{ + "contract_version": "ane-runner-v1", + "request_id": "studio-7B5A4D6E", + "chapter": "01", + "project": { + "title": "Projet local", + "genre": "roman", + "logline": "Une arrivee de nuit force une decision risquee.", + "synopsis": "Projet de fiction longue.", + "writer_note": "Style direct, phrases courtes." + }, + "scene": { + "title": "Arrivee de nuit", + "objective": "Trouver l'indice puis agir", + "beat": "Monter la tension jusqu'a la decision", + "mood": "sobre", + "target_words": 900 + }, + "runtime": { + "provider": "openai_compatible", + "base_url": "http://127.0.0.1:8100", + "model": "mistral:mistral-large-latest" + }, + "execution": { + "approval_mode": "approve", + "workspace_mode": "temporary" + } +} +``` + +## Resultat v1 + +Nom logique: `ane_runner_result_v1.json` + +Le resultat est le seul contrat public de sortie pour un client. + +Champs obligatoires: + +| Champ | Type | Role | +|------|------|------| +| `contract_version` | `string` | doit valoir `ane-runner-v1` | +| `status` | `string` | `accepted`, `rejected`, `awaiting_acceptance`, `quality_blocked` ou `failed` | +| `chapter` | `string` | chapitre normalise | +| `workspace_path` | `string` | workspace du run | +| `draft_path` | `string` | brouillon candidat retenu par ANE | +| `gate_path` | `string` | rapport de gate public pour le run | +| `meta_path` | `string` | artefact legacy encore expose pendant la transition | +| `model_used` | `string` | modele reellement utilise si connu | + +Champs optionnels: + +| Champ | Type | Role | +|------|------|------| +| `manuscript_path` | `string|null` | manuscrit promu si `accepted` | +| `quality_blockers` | `array` | blockers de garde-fou si presents | +| `error` | `object` | detail machine lisible si echec | +| `artifacts` | `object` | manifeste public restreint des chemins utiles | + +Exemple: + +```json +{ + "contract_version": "ane-runner-v1", + "status": "accepted", + "chapter": "chapitre_01", + "workspace_path": "/tmp/ane-run-123", + "draft_path": "/tmp/ane-run-123/brouillons/chapitres/chapitre_01/repair_v1.md", + "gate_path": "/tmp/ane-run-123/brouillons/chapitres/chapitre_01/gate_v1.json", + "meta_path": "/tmp/ane-run-123/brouillons/chapitres/chapitre_01/meta.json", + "manuscript_path": "/tmp/ane-run-123/manuscrit/chapitre_01.md", + "model_used": "mistral:mistral-large-latest", + "quality_blockers": [] +} +``` + +## Regles de compatibilite + +- `draft_path` est choisi par ANE; le client ne decide pas entre `draft_v2` et `repair_latest` +- `meta_path` reste expose seulement comme artefact legacy de transition +- un client ne doit pas parser `meta.json` pour reconstruire le contrat public +- un client ne doit pas supposer l'existence de `brouillons/chapitres/...` ni de `memoire/index/...` +- l'ajout de nouveaux champs est autorise en `v1` tant qu'ils sont optionnels +- toute suppression ou reinterpretation d'un champ obligatoire exige `v2` + +## Migration depuis le pont legacy + +Etat actuel: + +- le Studio prepare un workspace ANE lui-meme +- le Studio lance `python3 -m cli.main generate chapter --chapter XX --approve` +- le Studio parse `meta.json` + +Migration cible: + +1. ANE ajoute `runner execute` et ecrit `ane_runner_result_v1.json` +2. le Studio construit `ane_runner_request_v1.json` au lieu d'ecrire l'arborescence ANE +3. le Studio lit uniquement `ane_runner_result_v1.json` +4. `meta.json` redevient un artefact interne et de debug + +## Tests minimaux a exiger + +### Cote ANE + +- test de normalisation du `chapter` +- test d'emission du resultat `v1` +- test de stabilite des champs obligatoires +- test d'echec avec `status=failed` et `error` renseigne + +### Cote client + +- test de serialisation de la requete `v1` +- test de decodage du resultat `v1` +- test de non-regression: aucun parse direct de `meta.json` + +## Decision de gouvernance + +Le contrat canonique vit dans `ai-novel-engine`. + +- un client peut l'importer, le citer ou le dupliquer +- le texte source de verite reste cote `ai-novel-engine` diff --git a/docs/CONTEXTE_PROJET_2026-03-16.md b/docs/CONTEXTE_PROJET_2026-03-16.md new file mode 100644 index 0000000..249e595 --- /dev/null +++ b/docs/CONTEXTE_PROJET_2026-03-16.md @@ -0,0 +1,61 @@ +# Contexte projet - 16 mars 2026 + +Document court pour reprendre `ai-novel-engine` sans relire tout l'historique. + +## Ce que fait le projet + +`ai-novel-engine` est un moteur de redaction longue local-first, structure autour d'un pipeline strict: + +`intention -> structure -> draft -> critique -> rewrite -> gate -> validation -> memoire` + +Ce n'est ni un chat libre ni un studio collaboratif. Le produit utile est: + +- une chaine de production narrative lisible +- des artefacts markdown/json inspectables +- un garde-fou dur avant promotion manuscrit +- une memoire externe rejouable +- un runtime local interchangeable derriere un contrat OpenAI-compatible + +## Etat confirme + +- la reference locale reste `apple-coreml:qwen3.5-4b-onnx-q4f16` +- `apple-coreml:qwen2.5-0.5b-instruct-onnx` reste une baseline `quality_blocked` +- le chemin alternatif `llama.cpp` / `llama-server` pour `ollama:*` est maintenant branche cote orchestration ANE +- un smoke reel via ce chemin a deja fait passer `ollama:qwen2.5:1.5b` jusqu'au garde-fou, avec verdict `quality_blocked` sur `truncated_ending` et `outline_like` +- le blocage principal n'est donc plus "est-ce que le modele repond", mais "comment rendre ce chemin runtime stable et rejouable pour les lots automatisés" + +## Etat live reverifie le 16 mars 2026 + +- `http://127.0.0.1:8100/health`: indisponible au moment du controle +- `http://127.0.0.1:8201/models`: OK, sert `qwen3.5-4b-onnx-q4f16` +- `http://127.0.0.1:8091/health`: indisponible au moment du controle +- `http://127.0.0.1:11434/api/tags`: OK, expose bien `qwen2.5:7b` et `qwen2.5:1.5b` + +Lecture utile: + +- le runtime Apple est en ligne +- le host Ollama expose toujours les tags et les blobs +- le core OpenAI-compatible `:8100` et le runtime alternatif `:8091` sont actuellement a remonter avant de relancer les lots utiles + +## Ou on en est cote produit + +- le pipeline narratif est stable et teste +- la boucle `repair` et le `gate` sont en production +- l'orchestrateur `next_lots` sait maintenant checkpoint-er proprement un runtime `llama-server` +- un cockpit TUI d'exploitation existe maintenant pour suivre projet, automation, reports et erreurs stderr + +## Ce qui est vraiment bloque + +- remettre `:8100` en service pour le flux nominal +- remettre `:8091` en service quand on veut valider `ollama:*` via `llama.cpp` +- rejouer `priority_models` puis `baselines` sur le nouveau chemin runtime +- reprendre `rewrite` / `repair` uniquement apres ces reruns + +## Fichiers a ouvrir en premier + +- [`docs/MEMOIRE_REPRISE_2026-03-16.md`](./MEMOIRE_REPRISE_2026-03-16.md) +- [`docs/EXECUTION_PLAN_2026-03-16.md`](./EXECUTION_PLAN_2026-03-16.md) +- [`docs/SYSTEM_SPEC_2026-03-16.md`](./SYSTEM_SPEC_2026-03-16.md) +- [`docs/FEATURE_MAP_2026-03-16.md`](./FEATURE_MAP_2026-03-16.md) +- [`docs/AGENTS_2026-03-16.md`](./AGENTS_2026-03-16.md) +- [`docs/OSS_LANDSCAPE_2026-03-16.md`](./OSS_LANDSCAPE_2026-03-16.md) diff --git a/docs/CONTEXTE_PROJET_2026-03-21.md b/docs/CONTEXTE_PROJET_2026-03-21.md new file mode 100644 index 0000000..77c486f --- /dev/null +++ b/docs/CONTEXTE_PROJET_2026-03-21.md @@ -0,0 +1,52 @@ +# Contexte projet - 21 mars 2026 + +Photographie courte du repo `ai-novel-engine` apres integration Mascarade dans l'app, extraction runtime cote Python et ajout d'un juge narratif secondaire. + +## Intention + +Conserver ANE comme moteur narratif strict, relancable et inspectable, tout en traitant enfin le runtime comme une couche dediee et non comme un detail enfoui dans le provider. + +## Etat reel + +- le moteur Python reste la source de verite pour le pipeline `intention -> structure -> draft -> critique -> rewrite -> gate -> repair -> memory` +- une premiere couche runtime existe desormais sous `core/runtime/` : + - `models.py` + - `client.py` + - `health.py` + - `config.py` + - `policies.py` +- `core/generation/provider.py` delegue maintenant le transport OpenAI-compatible a `OpenAIChatRuntimeClient` +- `core/generation/pipeline.py` delegue maintenant le fallback `repair` a `core/runtime/policies.py` +- `core/evaluation/` ajoute un juge narratif secondaire optionnel, branche dans le gate via `ANE_JUDGE_MODEL` +- `core/next_lots.py` et `scripts/ops_tui.py` utilisent maintenant des helpers runtime partages pour les probes OpenAI-compatibles +- `core/runtime/profiles.py` formalise maintenant les noms de profils et de probes runtime utilises par l'orchestration +- `core/runtime/remote_hosts.py` factorise maintenant les hosts remote Mascarade pour le TUI remote et `launchd` +- `core/runtime/orchestration.py` porte maintenant le plan d'execution runtime et les signaux de checkpoint utilises par `next_lots` +- `core/next_lots.py` ne porte plus directement la logique de checkpoint Apple ni le preflight Ollama natif +- la suite Python passe a `155` tests verts +- les reports confirment toujours `apple-coreml:qwen3.5-4b-onnx-q4f16` comme seule reference `accepted` +- `ollama:qwen2.5:7b`, `ollama:qwen2.5:1.5b`, `ollama:mistral-nemo:latest` et `apple-coreml:qwen2.5-0.5b-instruct-onnx` atteignent bien `gate`, mais restent `quality_blocked` + +## Etat ops utile + +- `scripts/reports_ops.py summary` : + - `reports=27` + - `accepted=4` + - `quality_blocked=15` + - `provider_failed=7` +- `scripts/reports_ops.py analyze-logs --top 10` : + - bruit dominant `HTTP 500` + - un timeout client +- `scripts/reports_ops.py prune --days 14` propose 2 suppressions en dry-run, mais l'une est une reference explicitement conservee + +## Etat app utile + +- l'app SwiftUI sait maintenant choisir `OpenAI direct` ou `Mascarade` +- elle sait tester la connexion, appliquer un preset recommande, et lancer le pipeline ANE via `ANEPipelineService` +- elle expose maintenant un panneau workspace ANE et ses docs de pilotage ont ete resynchronisees + +## Ce qui n'est toujours pas resolu + +- les reruns `qwen2.5:7b` et `mistral-nemo` doivent etre rejoues avec le juge narratif secondaire pour confirmer le gain reel +- le contrat `response_format` n'est pas fiable de bout en bout si le shim Mascarade ne le propage pas +- les contraintes runtime Apple restent semi-manuelles et doivent devenir des capacites explicites dans ANE diff --git a/docs/CONTEXTE_PROJET_2026-03-22.md b/docs/CONTEXTE_PROJET_2026-03-22.md new file mode 100644 index 0000000..cdccc69 --- /dev/null +++ b/docs/CONTEXTE_PROJET_2026-03-22.md @@ -0,0 +1,56 @@ +# Contexte projet - 22 mars 2026 + +Photographie courte du repo `ai-novel-engine` apres reruns narratifs cibles avec le juge secondaire et revalidation locale `llama.cpp`. + +## Intention + +Conserver ANE comme moteur narratif strict, inspectable et relancable, tout en rendant les diagnostics de qualite plus actionnables que le simple mix heuristique precedent. + +## Etat reel + +- le moteur Python reste la source de verite pour le pipeline `intention -> structure -> draft -> critique -> rewrite -> gate -> repair -> memory` +- `core/runtime/` porte maintenant la configuration, la sante, les profils et l'orchestration runtime utiles a ANE +- `core/evaluation/` ajoute un juge narratif secondaire optionnel active par `ANE_JUDGE_MODEL` +- le gate fusionne maintenant heuristiques locales et verdict narratif secondaire dans `gate_v1.json` et `meta.json` +- l'app SwiftUI reste alignee sur cette architecture et sait lancer le pipeline ANE complet +- la suite Python est revalidee a `156` tests verts + +## Etat runtime utile + +- `:8091` a ete revalide localement le 22 mars 2026 via `llama-server` pour : + - `ollama:qwen2.5:7b` + - `ollama:mistral-nemo:latest` +- `:8110` repond a `/health` via tunnel remote, mais le routage chat utile renvoie encore `Temporary failure in name resolution`; ce n'est pas un runtime exploitable pour les reruns ANE +- `:8201` n'a pas ete redemarre dans cette session, mais la procedure operative a ete reconfirmee dans le repo Mascarade reel via `scripts/run_apple_llm_service.sh` +- `automation/next_lots.toml` pointe maintenant vers le vrai repo local Mascarade : `/Users/electron/Documents/Projets/mascarade` + +## Resultats narratifs utiles + +- `ollama:qwen2.5:7b` rerun avec `ANE_JUDGE_MODEL=ollama:qwen2.5:7b` : + - verdict final : `quality_blocked` + - blockers finaux : `truncated_ending`, `missing_risky_decision` + - lecture utile : le juge conserve la tension et l'indice, mais isole encore l'absence de decision finale suffisamment couteuse +- `ollama:mistral-nemo:latest` rerun avec `ANE_JUDGE_MODEL=ollama:mistral-nemo:latest` : + - verdict final : `quality_blocked` + - blockers finaux : `truncated_ending`, `missing_immediate_consequence` + - lecture utile : la decision risquee est finalement presente, mais la consequence observable immediate manque encore +- reruns comparables avec budgets manifeste : + - `qwen2.5:7b` reste `quality_blocked` et confirme que le budget seul ne resout pas la fin de scene + - `mistral-nemo` descend a `quality_blocked ['truncated_ending']`, ce qui montre que le budget etait bien une partie du probleme +- retouche courte des prompts : + - effet partiel utile sur `qwen2.5:7b` : disparition de `incomplete_scene`, mais `truncated_ending` et `missing_risky_decision` restent + - regression sur `mistral-nemo` : retour d'un faux `outline_like` cote gate LLM malgre une prose narrative correcte +- correctif gate + retouche micro-decision : + - `mistral-nemo` rerun `automation/reports/20260322_mistral_nemo_judge_gatefix` : le faux `outline_like` disparait; le seul blocage final restant est `missing_immediate_consequence` + - `qwen2.5:7b` rerun `automation/reports/20260322_qwen2_5_7b_judge_gatefix` : `missing_risky_decision` disparait; le seul blocage final restant est `missing_immediate_consequence` + - lecture utile : les deux modeles convergent maintenant vers le meme manque narratif, ce qui resserre fortement le prochain chantier prompt/repair +- retouche "consequence immediate observable" : + - `qwen2.5:7b` rerun `automation/reports/20260322_qwen2_5_7b_judge_consequencefix` : `accepted` des la passe `rewrite`, sans `repair` + - `mistral-nemo` rerun `automation/reports/20260322_mistral_nemo_judge_consequencefix` : regression vers `truncated_ending`, `missing_risky_decision`, `missing_immediate_consequence` + - lecture utile : la contrainte commune debloque clairement la famille Qwen, mais ne generalise pas a `mistral-nemo` + +## Ce qui n'est toujours pas resolu + +- `qwen2.5:7b` a maintenant un chemin local `accepted`, mais `mistral-nemo` regresse si on lui applique la meme rigidite de fin de scene +- le prochain chantier n'est plus un prompt commun: il faut probablement une variante ou un profil narratif separe pour `mistral-nemo` +- le runtime remote `:8110` donne un faux sentiment de disponibilite tant que le routage utile echoue encore diff --git a/docs/EXECUTION_PLAN_2026-03-16.md b/docs/EXECUTION_PLAN_2026-03-16.md new file mode 100644 index 0000000..6a33936 --- /dev/null +++ b/docs/EXECUTION_PLAN_2026-03-16.md @@ -0,0 +1,155 @@ +# Plan d'execution - 16 mars 2026 + +Plan de reprise reel base sur l'etat code + runtime constate le 16 mars 2026. + +References: + +- contexte: [`CONTEXTE_PROJET_2026-03-16.md`](./CONTEXTE_PROJET_2026-03-16.md) +- memoire: [`MEMOIRE_REPRISE_2026-03-16.md`](./MEMOIRE_REPRISE_2026-03-16.md) +- spec systeme: [`SYSTEM_SPEC_2026-03-16.md`](./SYSTEM_SPEC_2026-03-16.md) +- carte agents: [`AGENTS_2026-03-16.md`](./AGENTS_2026-03-16.md) +- backlog actif: [`../TODO_ACTIVE.md`](../TODO_ACTIVE.md) + +## Lot 1 - Restaurer le control plane local [LIVRE] + +- `:8100` mascarade UP (`apple-coreml` + `ollama`) +- `:8201` Apple LLM UP — `qwen3.5-4b-onnx-q4f16` actif +- `:8091` llama-server UP — `ollama:qwen2.5:7b` actif + +## Lot 2 - Requalifier le chemin `llama.cpp` [LIVRE] + +- `llama-server` expose `ollama:qwen2.5:7b` sur `:8091` +- preflight ANE OpenAI-compatible passe +- lot `priority_models` lance avec qwen2.5:7b en cours + +## Lot 3 - Rejoyer les lots utiles [EN COURS] + +### Ordre + +1. `baselines` — TERMINE (rapport `20260316T195716Z`) +2. `priority_models` — EN COURS (rapport `20260316T204232Z`) +3. `french_models` — A FAIRE (mistral-nemo via `:8091`) +4. `tracking_sync` — apres french_models + +### Done quand + +- `ollama:qwen2.5:7b` et `ollama:qwen2.5:1.5b` ne sont plus `provider_failed` par defaut +- `ollama:mistral-nemo:latest` a un verdict qualite (pas runtime) + +## Lot 4 - Revenir aux blockers narratifs + +### Objectif + +- reprendre `rewrite` / `repair` une fois le runtime stabilise, sous les nouveaux prompts + +### Livres (lot refonte 16 mars — phases 1 a 5) + +- prompts `draft_v1`, `rewrite_v1`, `repair_v1` reinforces : output primer + few-shot BAD/GOOD + cible 600-800 mots +- 4 bare `except Exception` corriges dans `core/next_lots.py` +- tests `IntentionGate`, `PromptStore`, CLI intention : suite a 111 tests verts +- `_normalize_generated_prose()` : strip ALL `#{1,6}` headings avant gate +- `_is_outline_like()` : fix false positive "scene", ajout `dense_bullet_list` (4+ bullets) +- `_close_json_delimiters()` : rebuild car-par-car, mismatched + stray closers +- `_finish_stage()` extrait, `_iter_chapters_with_status()` extrait +- fix `outline_like` valide sur baselines : `qwen2.5:1.5b` → `quality_blocked ['truncated_ending']` seulement + +### Cibles restantes + +- valider `qwen2.5:7b` sous prompts nouveaux via lot `priority_models` en cours +- verifier `ollama:mistral-nemo:latest` via lot `french_models` a faire +- `truncated_ending` persistant sur `qwen2.5-0.5b` et `qwen2.5:1.5b` — modeles trop petits, attendu + +### A moyen terme + +- evaluer `prometheus-eval` ou `story-evaluation-llm` comme remplacement gate heuristique +- regarder `dottxt/outlines` si grammar sampling disponible via llama-server + +## Lot 5 - Consolider l'exploitation + +### Objectif + +- garder une boucle d'observabilite legere et fiable + +### Done quand + +- `scripts/ops_tui.py` devient le point d'entree court terme pour lire projet + lots + logs +- `docs/runbooks/AUTOMATION.md` couvre l'usage TUI, l'analyse des logs et la purge dry-run + +## Lot 6 - Robustesse reprise (nouveau) + +### Objectif + +- rendre les ecritures d'etat resilientes aux interruptions +- eviter qu'un JSON partiellement ecrit bloque la reprise + +### Livres (17 mars) + +- ecritures JSON atomiques dans `core/generation/pipeline.py` et `core/next_lots.py` +- lectures metadata/index tolerantes aux JSON corrompus dans `pipeline` et `loader` +- runbook de recovery ajoute: `docs/runbooks/RECOVERY_PROCEDURES.md` +- suite unitaire: 111 tests verts + +### Cibles restantes + +- ajouter des tests de corruption JSON dedies (meta chapitre + state automation) +- ajouter une commande de verification JSON en preflight ops + +## Lot 7 - Mascarade multi-host (tower/kxkm) + +### Objectif + +- operer ANE sur deux cibles SSH avec un cockpit unique +- garder un mode TUI-first pour supervision et relance + +### Livres (17 mars) + +- `automation/mascarade_hosts.toml` ajoute (tower + kxkm) +- `scripts/mascarade_remote_tui.py` ajoute (probe SSH + sante tunnel) +- `scripts/setup_mascarade_launchd.py` ajoute (render/install/uninstall/status) +- plists de reference ajoutes sous `automation/launchd/` +- test unitaire ajoute: `tests/test_setup_mascarade_launchd.py` +- README + runbook automation synchronises + +### Cibles restantes + +- valider les deux sessions tunnel en conditions reelles +- activer launchd en reel et verifier `status` +- fallback autossh si l'environnement reseau rend launchd insuffisant + +### Etat rerun qualite (17 mars) + +- rerun `priority_models` relance, stoppe sur checkpoint runtime Apple (`aucun modele` expose au lieu de `qwen3.5-4b-onnx-q4f16`) +- prochaine action immediate: `prepare_runtime_step.sh` puis reprise `--resume` + +## Lot 8 - Refonte runtime ANE (phase 1) [EN COURS] + +### Objectif + +- sortir les concepts runtime hors du pipeline narratif +- garder `core/generation/provider.py` comme facade de compatibilite pendant la migration + +### Livres (21 mars) + +- package `core/runtime/` explicite dans le repo +- contraintes runtime explicites (`json-best-effort`, switch Apple semi-manuel) +- healthcheck runtime capable de lire un catalogue de modeles OpenAI-compatible +- tests dedies runtime ajoutes + +### Cibles restantes + +- faire consommer `core/runtime/*` par `core/next_lots.py` +- brancher `scripts/ops_tui.py` et les preflights sur une sonde runtime unique +- introduire des profils runtime nommes (local, remote, `llama.cpp`, Apple) + +## Risque a eviter + +Ne pas retourner dans une boucle de tuning prompts avant d'avoir remonte `:8100` et valide `qwen2.5:7b` sur le chemin `llama.cpp`. + +## Auto-sync + +- dernier verdict automatise: 2026-03-17T09:44:06+00:00 +- accepted: apple-coreml:qwen3.5-4b-onnx-q4f16 +- gate atteint: apple-coreml:qwen3.5-4b-onnx-q4f16, ollama:qwen2.5:7b, apple-coreml:qwen2.5-0.5b-instruct-onnx, ollama:qwen2.5:1.5b, ollama:mistral-nemo:latest +- prochain lot calcule: Reference locale reconfirmee; resserrer rewrite/repair sur les modeles deja bloques a gate. +- checkpoint manuel requis: Le runtime Apple sert `aucun modèle` au lieu de `qwen3.5-4b-onnx-q4f16`. + diff --git a/docs/EXECUTION_PLAN_2026-03-21.md b/docs/EXECUTION_PLAN_2026-03-21.md new file mode 100644 index 0000000..98b016d --- /dev/null +++ b/docs/EXECUTION_PLAN_2026-03-21.md @@ -0,0 +1,127 @@ +# Plan d'execution - 21 mars 2026 + +Plan de reprise reel base sur l'etat code, docs et reports constate le 21 mars 2026. + +References: + +- contexte: [`CONTEXTE_PROJET_2026-03-21.md`](./CONTEXTE_PROJET_2026-03-21.md) +- memoire: [`MEMOIRE_REPRISE_2026-03-21.md`](./MEMOIRE_REPRISE_2026-03-21.md) +- spec systeme: [`SYSTEM_SPEC_2026-03-21.md`](./SYSTEM_SPEC_2026-03-21.md) +- carte agents: [`AGENTS_2026-03-21.md`](./AGENTS_2026-03-21.md) +- backlog actif: [`../TODO_ACTIVE.md`](../TODO_ACTIVE.md) + +## Lot 1 - Extraire la couche runtime minimale [LIVRE] + +- `core/runtime/config.py` : configuration runtime partagee et budgets par etape +- `core/runtime/models.py` : `RuntimeProfile`, `RuntimeCapabilities`, `RuntimeHealth` +- `core/runtime/policies.py` : fallback `repair`, detection provider, contrainte Apple +- `core/runtime/client.py` : client OpenAI-compatible avec retries et normalisation du texte +- `core/runtime/health.py` : health probe simple +- `core/generation/provider.py` garde l'API publique mais s'appuie sur `OpenAICompatibleRuntimeConfig` + le client runtime +- `core/generation/pipeline.py` delegue maintenant la politique de fallback runtime a `core/runtime/policies.py` +- `core/next_lots.py` reutilise deja `runtime_probe_profile` et `runtime_model_ids` +- `core/next_lots.py` recompilable a nouveau +- suite Python a `128` tests verts + +## Lot 2 - Formaliser profils et capacites runtime [LIVRE] + +### Objectif + +- sortir les decisions runtime des details implicites disperses dans le pipeline, puis finir de les sortir de `next_lots` + +### Done quand + +- `provider.py` lit la config runtime partagee +- `next_lots.py` et `ops_tui.py` consomment `runtime_probe_profile()` / `runtime_model_ids()` +- profils explicites `mascarade_local`, `mascarade_remote_*`, `llama_cpp_local` +- metadata et preflight lisent ces capacites au lieu de les redeviner + +Etat: + +- `provider.py` lit la config runtime partagee +- `next_lots.py` et `ops_tui.py` consomment les probes runtime partagees +- `core/runtime/profiles.py` formalise les noms de profils/probes +- `response_format` est encode comme capacite runtime explicite +- suite Python a `152` tests verts + +## Lot 3 - Refaire `next_lots`, `ops_tui` et les preflights autour de la couche runtime [LIVRE] + +### Objectif + +- reduire le couplage entre orchestration, checkpoints manuels et synchronisation documentaire + +### Done quand + +- preflight runtime isole +- checkpoint Apple / `llama.cpp` isole +- auto-sync docs garde, mais hors logique de sante runtime + +Etat: + +- checkpoint Apple et preflight Ollama natif deja extraits vers `core/runtime/*` +- `scripts/mascarade_remote_tui.py` et `scripts/setup_mascarade_launchd.py` consomment maintenant `core/runtime/remote_hosts.py` +- `core/runtime/orchestration.py` extrait le plan runtime, les signaux checkpoint et le catalogue Ollama hors de `next_lots` +- `core/tracking_sync.py` extrait la synchronisation documentaire et les rendus auto-sync hors de `next_lots` +- `core/next_lots.py` est recentre sur orchestration, etat et commandes +- prochaine extraction utile: consolider les tests orchestration vs sync et garder le reste du control plane mince + +## Lot 4 - Ajouter un juge narratif secondaire [LIVRE] + +### Objectif + +- ajouter une seconde lecture narrative compatible avec une future integration Prometheus, sans dependance externe + +### Etat + +- `core/evaluation/` ajoute `NarrativeJudge` et `ProviderNarrativeJudge` +- le juge est optionnel et active via `ANE_JUDGE_MODEL` +- `gate_v1.json` / `meta.json` exposent `judge_report` et `judge_blockers` +- `_repair_focus()` et les prompts `gate`, `rewrite`, `repair` sont resserres sur decision risquee et consequence immediate +- suite Python a `155` tests verts + +## Lot 5 - Revenir aux blockers narratifs utiles [EN COURS] + +### Objectif + +- retoucher prompts et gate seulement apres stabilisation de la lecture runtime + +### Cibles + +- `qwen2.5:7b` : fermer la scene sur la decision risquee attendue +- `mistral-nemo` : comprendre la condensation a 316 mots +- ne pas surcorriger les petits modeles 0.5b / 1.5b au-dela de leur envelope reelle + +## Lot 6 - Realigner l'app SwiftUI [LIVRE] + +### Objectif + +- faire raconter a l'app la meme architecture que le moteur Python + +### Done quand + +- docs app mises a jour sur Mascarade + pipeline ANE +- backlog app aligne sur l'etat reel +- strategie de tests Swift clarifiee + +Etat: + +- l'app sait deja parler a `OpenAI` direct ou `Mascarade` +- le pipeline ANE complet est deja lancable depuis l'app +- le panneau workspace ANE expose maintenant les artefacts utiles et les erreurs pipeline/runtime +- `Package.swift` expose un `testTarget` +- `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test` passe +- README, TODOs et docs app ont ete resynchronises +## Lot 7 - Hygiene reports / logs + +### Objectif + +- garder une retention utile sans perdre les evidence packs cites comme references + +### Done quand + +- les reports historiques a conserver sont marques comme tels +- les purges automatiques ne proposent plus de supprimer une reference documentaire + +## Risque a eviter + +Ne pas rebasculer vers du tuning prompt tant que les capacites runtime ne sont pas encodees explicitement dans ANE. diff --git a/docs/EXECUTION_PLAN_2026-03-22.md b/docs/EXECUTION_PLAN_2026-03-22.md new file mode 100644 index 0000000..61ab936 --- /dev/null +++ b/docs/EXECUTION_PLAN_2026-03-22.md @@ -0,0 +1,133 @@ +# Plan d'execution - 22 mars 2026 + +Plan de reprise reel base sur les reruns narratifs du 22 mars 2026 et sur l'etat runtime constate localement. + +References: + +- contexte: [`CONTEXTE_PROJET_2026-03-22.md`](./CONTEXTE_PROJET_2026-03-22.md) +- memoire: [`MEMOIRE_REPRISE_2026-03-22.md`](./MEMOIRE_REPRISE_2026-03-22.md) +- spec systeme: [`SYSTEM_SPEC_2026-03-21.md`](./SYSTEM_SPEC_2026-03-21.md) +- carte agents: [`AGENTS_2026-03-21.md`](./AGENTS_2026-03-21.md) +- backlog actif: [`../TODO_ACTIVE.md`](../TODO_ACTIVE.md) + +## Lot 1 - Couche runtime minimale [LIVRE] + +- `core/runtime/config.py` : configuration runtime partagee et budgets par etape +- `core/runtime/models.py` : `RuntimeProfile`, `RuntimeCapabilities`, `RuntimeHealth` +- `core/runtime/policies.py` : fallback `repair`, detection provider, contrainte Apple +- `core/runtime/client.py` : client OpenAI-compatible avec retries et normalisation du texte +- `core/runtime/health.py` : probes runtime +- `core/generation/provider.py` garde l'API publique mais s'appuie sur la config runtime partagee + +## Lot 2 - Recentrage orchestration / sync [LIVRE] + +- `core/runtime/orchestration.py` porte le plan runtime et les signaux de checkpoint +- `core/tracking_sync.py` porte la synchronisation documentaire +- `core/next_lots.py` est recentre sur orchestration, etat et commandes + +## Lot 3 - Juge narratif secondaire [LIVRE] + +- `core/evaluation/` ajoute `NarrativeJudge` et `ProviderNarrativeJudge` +- le juge est optionnel via `ANE_JUDGE_MODEL` +- `gate_v1.json` et `meta.json` exposent `judge_report` et `judge_blockers` +- `_repair_focus()` sait maintenant pousser `missing_risky_decision` et `missing_immediate_consequence` + +## Lot 4 - Requalification narrative ciblee [LIVRE] + +### Rerun `qwen2.5:7b` + +- runtime : `llama.cpp` local sur `:8091` +- juge : `ANE_JUDGE_MODEL=ollama:qwen2.5:7b` +- workspace : `automation/reports/20260322_qwen2_5_7b_judge` +- verdict : `quality_blocked` +- blockers finaux : `truncated_ending`, `missing_risky_decision` + +### Rerun `mistral-nemo` + +- runtime : `llama.cpp` local sur `:8091` +- juge : `ANE_JUDGE_MODEL=ollama:mistral-nemo:latest` +- workspace : `automation/reports/20260322_mistral_nemo_judge` +- verdict : `quality_blocked` +- blockers finaux : `truncated_ending`, `missing_immediate_consequence` + +### Lecture + +- le juge secondaire produit bien un diagnostic plus fin que le gate heuristique seul +- `qwen2.5:7b` reste faible sur la cout/irreversibilite de la decision finale +- `mistral-nemo` garde la decision, mais echoue encore a montrer une consequence immediate + +## Lot 5 - Gate sanitize + micro-decision [LIVRE] + +### Objectif + +- transformer les nouveaux diagnostics du juge en corrections de prompts et de repairs, pas en nouvelle complexite runtime + +### Priorites + +1. faire une retouche de prompt plus fine, ciblee sur `qwen2.5:7b`, pour fermer la scene sans boucle ni repetition +2. renforcer `gate_v1` contre les faux `outline_like` LLM sur prose narrative correcte +3. revalider ensuite `mistral-nemo` seulement apres cette correction gate/prompt plus fine +4. corriger le faux positif ops du runtime remote `:8110` + +### Etat courant des reruns + +- `qwen2.5:7b` : + - rerun juge simple : `truncated_ending`, `missing_risky_decision` + - rerun budgets manifeste : `truncated_ending`, `missing_risky_decision`, `incomplete_scene` + - rerun prompté : `truncated_ending`, `missing_risky_decision` avec meilleur signal de structure + - rerun gatefix : `missing_immediate_consequence` uniquement +- `mistral-nemo` : + - rerun juge simple : `truncated_ending`, `missing_immediate_consequence` + - rerun budgets manifeste : `truncated_ending` + - rerun prompté : regression `outline_like`, `missing_immediate_consequence` + - rerun gatefix : `missing_immediate_consequence` uniquement + +### Lecture mise a jour + +- le correctif `outline_like` est valide : `mistral-nemo` n'est plus bloque sur un faux positif de forme +- la retouche micro-decision est valide : `qwen2.5:7b` n'est plus bloque sur `missing_risky_decision` +- le prochain chantier utile est maintenant unique et partage : forcer une consequence immediate observable apres l'acte final, sans rouvrir une nouvelle scene + +## Lot 6 - Consequence immediate observable [LIVRE] + +### Objectif + +- faire converger `qwen2.5:7b` et `mistral-nemo` vers un premier verdict `accepted` en fermant la consequence de l'acte final au lieu de simplement l'annoncer + +### Priorites + +1. retoucher `rewrite_v1` et `repair_v1` pour exiger une consequence immediate visible dans les 1-3 phrases suivant l'acte final +2. durcir `_repair_focus()` sur `missing_immediate_consequence` avec exemples de consequence observable et interdiction de reouvrir une nouvelle piste +3. rejouer `qwen2.5:7b` sur `:8091` +4. rejouer `mistral-nemo` sur `:8091` + +### Resultat + +- `qwen2.5:7b` : `accepted` dans `automation/reports/20260322_qwen2_5_7b_judge_consequencefix` +- `mistral-nemo` : regression dans `automation/reports/20260322_mistral_nemo_judge_consequencefix` vers `truncated_ending`, `missing_risky_decision`, `missing_immediate_consequence` + +## Lot 7 - Divergence par famille de modele [A LANCER] + +### Objectif + +- conserver le gain `qwen2.5:7b` sans imposer a `mistral-nemo` une rigidite de fin de scene qu'il degrade + +### Priorites + +1. garder `qwen2.5:7b` comme baseline Ollama locale `accepted` +2. isoler une variante prompt/repair plus legere pour `mistral-nemo` +3. rejouer `mistral-nemo` apres cette separation +4. seulement ensuite, relancer `priority_models` complet avec `qwen2.5:7b` comme reference Ollama + +## Risque a eviter + +Ne pas confondre "runtime joignable" et "runtime exploitable" : `:8110` repond a `/health`, mais pas aux runs utiles ANE. + +## Auto-sync + +- dernier verdict automatise: 2026-03-23T21:34:05+00:00 +- accepted: mistral:mistral-large-latest +- gate atteint: mistral:mistral-large-latest +- prochain lot calcule: Reference locale reconfirmee; retablir le runtime des modeles provider_failed avant de poursuivre. +- checkpoint manuel requis: Le runtime Apple sert `aucun modèle` au lieu de `qwen3-4b-instruct-2507-q4f16`. + diff --git a/docs/FEATURE_MAP_2026-03-16.md b/docs/FEATURE_MAP_2026-03-16.md new file mode 100644 index 0000000..c3bc1f0 --- /dev/null +++ b/docs/FEATURE_MAP_2026-03-16.md @@ -0,0 +1,71 @@ +# Cartes de fonctionnalite - 16 mars 2026 + +Cartographie courte des fonctionnalites, de leur valeur et de leur etat reel. + +Mise a jour : 16 mars 2026 (lot refonte). + +## Carte 1 - Ecriture sous contrainte + +| Fonction | Valeur auteur | Surface | Etat | Suite | +|---|---|---|---|---| +| Intention obligatoire | empeche la generation sans cadre | `IntentionGate`, CLI | livre | garder la collision `chapitre_1` / `chapitre_01` visible | +| Pipeline narratif strict | garde un processus lisible | `GenerationPipeline` | livre | continuer a le garder runtime-agnostic | +| Validation auteur | pas de promotion automatique | CLI, callbacks | livre | un cockpit futur peut juste aider, pas decider | + +## Carte 2 - Qualite narrative + +| Fonction | Valeur | Surface | Etat | Suite | +|---|---|---|---|---| +| Critique JSON | diagnostic compacte | `ControlReport` | livre | ne pas laisser gonfler le schema | +| Gate manuscrit | bloque prose insuffisante | `ManuscriptGateReport` | livre | ajuster seulement apres reruns stables | +| Repair automatique | tente de sauver un brouillon | `repair_vN` | livre | cibler `outline_like` et `truncated_ending` | +| Output primer (a faire) | force la prose en debut de sortie | `prompts/draft_v1.txt` | **planifie** | terminer le prompt par un incipit partiel | +| Few-shot BAD/GOOD (a faire) | montre ce qui est interdit | `draft_v1`, `rewrite_v1`, `repair_v1` | **planifie** | 1 exemple mauvais + 1 bon en 3-5 lignes | +| Structure → narrative summary (a faire) | empeche outline_like a la source | `GenerationPipeline._build_draft_prompt()` | **planifie** | pattern GOAT-Storytelling-Agent | + +## Carte 3 - Memoire externe + +| Fonction | Valeur | Surface | Etat | Suite | +|---|---|---|---|---| +| Resume chapitre | relance rapide | `memoire/chapitres` | livre | garder le format court | +| Index personnages/lieux | continuité lisible | `memoire/index/*.json` | livre | deja dedupes | +| Chronologie | garde les evenements | `chronologie.json` | corrigee | surveiller les reruns de meme chapitre | + +## Carte 4 - Runtime interchangeable + +| Fonction | Valeur | Surface | Etat | Suite | +|---|---|---|---|---| +| Provider OpenAI-compatible | decouple ANE du runtime | `OpenAICompatibleProvider` | livre | garder le contrat minimal | +| Routage `provider:model` | change de backend sans rewriter le pipeline | `ANE_MODEL` | livre | continuer sur `llama.cpp` | +| Contournement `llama.cpp` | sort des crashes Ollama natifs | `ollama_runtime=openai_compatible` | stable | :8091 valide pour qwen2.5:7b + mistral-nemo | +| Lot `french_models` | test des modeles francophones | `automation/next_lots.toml` | **nouveau** | mistral-nemo:latest, requalification en cours | + +## Carte 5 - Exploitation + +| Fonction | Valeur operateur | Surface | Etat | Suite | +|---|---|---|---|---| +| Reports machine | evidence pack par lot | `automation/reports/` | livre | garder les workspaces utiles | +| Analyse stderr | lit les causes frequentes | `scripts/reports_ops.py` | amelioree | ajouter si besoin des regroupements par etape | +| TUI lots | supervision lot courant | `scripts/next_lots_tui.py` | livre | garder focalise | +| TUI ops | vue projet + lots + logs | `scripts/ops_tui.py` | livre | enrichir seulement si le terrain le demande | + +## Carte 6 - Documentation + +| Fonction | Valeur | Etat | Suite | +|---|---|---|---| +| Contextes / memoires / plans dates | reprise rapide | remis a niveau | garder les dates coherentes | +| Spec systeme | base commune | livree | servir de reference courte | +| Veille OSS | eviter de reinventer l'ecosysteme | enrichie | GOAT, prometheus-eval, story-eval, outlines, DeepEval, lm-format-enforcer, SCORE, KazKozDev/NovelGenerator, CroissantLLM, EQ-bench suite, FrenchBench, CamemBERT perplexite | + +## Carte 7 - Qualite code (nouvelle) + +| Probleme | Impact | Fichier | Priorite | Fix prevu | +|---|---|---|---|---| +| 4 bare `except Exception` | masque erreurs de prog | `core/next_lots.py` | P1 | restreindre a `(OSError, json.JSONDecodeError, ValueError)` | +| `generate_chapter()` 150 LOC | maintenance difficile | `core/generation/pipeline.py` | **livre** | `_finish_stage()` extrait (phase 2) | +| `metadata` dict non-type | erreurs silencieuses | `core/generation/pipeline.py` | P3 | `ChapterMetadata(TypedDict)` — reporte 20+ signatures | +| `_close_json_delimiters()` fragile | echecs JSON silencieux | `core/generation/models.py` | **livre** | rebuild car-par-car, mismatched + stray closers (phase 3) | +| 3 fonctions near-identiques | duplication de logique | `core/project/loader.py` | **livre** | `_iter_chapters_with_status()` (phase 2) | +| couverture CLI 35% | regressions non detectees | `cli/main.py` | **livre** | 6 nouveaux tests : invalid chapter, duplicate, empty, no args, ProviderError x2 (phase 4) | +| couverture `core/reporting.py` 3 tests | fonctions utilitaires non testees | `core/reporting.py` | **livre** | 21 tests : safe_read_json, safe_stamp, extract_stderr, classification_count, folder_timestamp, latest_report_run, log_label (phase 4) | +| `_is_outline_like()` manque bullet-only | prose 0.5b en listes pures non bloquee | `core/generation/pipeline.py` | **livre** | `dense_bullet_list`: 4+ lignes bullet = outline_like solo; 2 nouveaux tests; 111 tests verts | diff --git a/docs/MEMOIRE_REPRISE_2026-03-16.md b/docs/MEMOIRE_REPRISE_2026-03-16.md new file mode 100644 index 0000000..cf5a478 --- /dev/null +++ b/docs/MEMOIRE_REPRISE_2026-03-16.md @@ -0,0 +1,52 @@ +# Memoire de reprise - 16 mars 2026 + +Memoire operationnelle pour reprendre `ai-novel-engine` sans refaire l'enquete runtime ni l'audit repo. + +## Etat code + +- suite unitaire verte: `55` tests +- derniere passe: ajout d'un module partage de reporting, d'un TUI ops, et d'un fix de deduplication chronologie +- scripts d'exploitation Python executes directement depuis `scripts/` corriges pour resoudre le repo root avant import + +## Etat runtime utile + +- `:8201` repond et sert `qwen3.5-4b-onnx-q4f16` +- `:11434/api/tags` repond et les blobs `qwen2.5` sont bien presents +- `:8100` ne repond pas actuellement +- `:8091` ne repond pas actuellement + +## Etat produit utile + +- reference accepted: `apple-coreml:qwen3.5-4b-onnx-q4f16` +- baseline Apple regression: `apple-coreml:qwen2.5-0.5b-instruct-onnx` -> `quality_blocked` +- meilleur candidat alternatif: `ollama:qwen2.5:7b` +- preuve importante de reprise: `ollama:qwen2.5:1.5b` a deja atteint `gate` via `llama-server`; il n'est donc plus seulement a lire comme un echec provider + +## Nouveaux points d'appui + +- `scripts/ops_tui.py` pour suivre projet, automation, reports, erreurs stderr et empreinte disque +- `scripts/reports_ops.py analyze-logs` corrige pour afficher les vrais modeles au lieu de noms deformes +- `core/reporting.py` centralise la lecture des `run.json`, le comptage des classifications et l'agregation d'erreurs logs + +## Risques a ne pas oublier + +- ne pas relancer des lots entiers tant que `:8100` n'est pas revenu +- ne pas considerer `qwen2.5:7b` comme "qualite bloquee" tant qu'on n'a pas un rerun complet via le nouveau chemin runtime +- ne pas laisser les reruns du meme chapitre dupliquer la chronologie; le correctif est maintenant pose mais doit etre garde + +## Commandes utiles + +```bash +python3 scripts/ops_tui.py --watch --interval 3 +python3 scripts/next_lots_tui.py --watch --interval 2 +python3 scripts/reports_ops.py summary +python3 scripts/reports_ops.py analyze-logs --top 10 +python3 scripts/run_next_lots.py --resume automation/state/next_lots_state.json +``` + +## Priorite immediate + +1. Remonter `:8100`. +2. Remonter `:8091` si un lot `ollama:*` est a rejouer. +3. Valider `qwen2.5:7b` via le meme chemin que `qwen2.5:1.5b`. +4. Rejouer `priority_models`, puis `baselines`. diff --git a/docs/MEMOIRE_REPRISE_2026-03-21.md b/docs/MEMOIRE_REPRISE_2026-03-21.md new file mode 100644 index 0000000..7e26b7c --- /dev/null +++ b/docs/MEMOIRE_REPRISE_2026-03-21.md @@ -0,0 +1,46 @@ +# Memoire de reprise - 21 mars 2026 + +Memoire operationnelle pour reprendre `ai-novel-engine` sans refaire l'enquete docs/runtime/app. + +## Etat code + +- extraction lot 1/2/3 realisee : `core/runtime/` contient config, policies, client, health, checkpoints et preflight +- `core/evaluation/` ajoute un juge narratif secondaire activable par `ANE_JUDGE_MODEL` +- `core/generation/provider.py` garde le contrat public actuel mais repose maintenant sur la config runtime partagee +- `core/next_lots.py` et `scripts/ops_tui.py` consomment deja une partie des helpers runtime partages +- `core/tracking_sync.py` porte maintenant la synchronisation documentaire; `core/next_lots.py` est recentre sur orchestration + commandes +- `core/next_lots.py` ne porte plus directement la logique de checkpoint Apple ni le preflight HTTP Ollama natif +- le gate narratif peut maintenant fusionner heuristiques locales + verdict du juge secondaire +- suite Python verte : `155` tests + +## Etat runtime utile + +- reference actuelle : `apple-coreml:qwen3.5-4b-onnx-q4f16` +- candidat alternatif le plus credible : `ollama:qwen2.5:7b` +- les modeles 0.5b / 1.5b / mistral-nemo atteignent `gate` mais restent bloques sur la qualite narrative +- le point fragile reste la frontiere entre ANE et le shim OpenAI-compatible lorsque la sortie JSON doit etre structuree + +## Etat docs utile + +- nouveau point de reprise officiel : + - `docs/CONTEXTE_PROJET_2026-03-21.md` + - `docs/MEMOIRE_REPRISE_2026-03-21.md` + - `docs/EXECUTION_PLAN_2026-03-21.md` + - `docs/AGENTS_2026-03-21.md` + - `docs/SYSTEM_SPEC_2026-03-21.md` + - `docs/OSS_LANDSCAPE_2026-03-21.md` +- les docs app du 16 mars etaient stale par rapport au code; un rattrapage est requis +- les docs app ont ete rattrapees; la commande de test utile est `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test` + +## Logs / reports + +- synthese reports : `python3 scripts/reports_ops.py summary` +- top erreurs : `python3 scripts/reports_ops.py analyze-logs --top 10` +- purge a garder en dry-run tant que les reports historiques cites dans la doc ne sont pas declasses + +## Priorite immediate + +1. Rejouer `priority_models` avec `ANE_JUDGE_MODEL` pour requalifier `qwen2.5:7b`. +2. Rejouer `french_models` avec `ANE_JUDGE_MODEL` pour requalifier `mistral-nemo`. +3. Continuer a garder `next_lots` concentre sur l'orchestration pure sans rouvrir la couche runtime. +4. Requalifier les TUIs/logs restants pour qu'ils lisent tous la meme couche runtime partagee. diff --git a/docs/MEMOIRE_REPRISE_2026-03-22.md b/docs/MEMOIRE_REPRISE_2026-03-22.md new file mode 100644 index 0000000..909d807 --- /dev/null +++ b/docs/MEMOIRE_REPRISE_2026-03-22.md @@ -0,0 +1,75 @@ +# Memoire de reprise - 22 mars 2026 + +Memoire operationnelle pour reprendre `ai-novel-engine` apres la vague de reruns cibles avec juge narratif. + +## Etat code + +- `core/runtime/` reste la couche runtime partagee unique pour config, profils, probes, checkpoints et orchestration +- `core/evaluation/` porte le juge narratif secondaire active par `ANE_JUDGE_MODEL` +- la refonte code reste stable; la session a ajoute une sanitization `outline_like` cote gate, une retouche micro-decision dans les prompts et une requalification narrative complete +- suite Python revalidee a `156` tests verts + +## Etat runtime utile + +- `llama-server` local sur `:8091` a ete prouve en chargeant puis servant effectivement : + - `ollama:qwen2.5:7b` + - `ollama:mistral-nemo:latest` +- `:8110` repond a `/health` mais reste inutilisable pour ANE tant que `POST /v1/chat/completions` echoue avec `Temporary failure in name resolution` +- le repo Mascarade local reel est `/Users/electron/Documents/Projets/mascarade` +- le chemin de demarrage Apple confirme cote Mascarade est : + 1. stage/export du modele + 2. exports `APPLE_LLM_*` + 3. `bash scripts/run_apple_llm_service.sh` + +## Etat narratif utile + +- qwen rerun juge : + - workspace : `automation/reports/20260322_qwen2_5_7b_judge` + - verdict : `quality_blocked` + - blockers finaux : `truncated_ending`, `missing_risky_decision` +- mistral rerun juge : + - workspace : `automation/reports/20260322_mistral_nemo_judge` + - verdict : `quality_blocked` + - blockers finaux : `truncated_ending`, `missing_immediate_consequence` +- qwen rerun budgete : + - workspace : `automation/reports/20260322_qwen2_5_7b_judge_budgeted` + - verdict : `quality_blocked` + - blockers finaux : `truncated_ending`, `missing_risky_decision`, `incomplete_scene` +- mistral rerun budgete : + - workspace : `automation/reports/20260322_mistral_nemo_judge_budgeted` + - verdict : `quality_blocked` + - blockers finaux : `truncated_ending` +- qwen rerun apres retouche prompt : + - workspace : `automation/reports/20260322_qwen2_5_7b_judge_prompted` + - gain partiel : `incomplete_scene` disparait + - blockers finaux : `truncated_ending`, `missing_risky_decision` +- mistral rerun apres retouche prompt : + - workspace : `automation/reports/20260322_mistral_nemo_judge_prompted` + - regression : `outline_like`, `missing_immediate_consequence` +- mistral rerun apres correctif gate : + - workspace : `automation/reports/20260322_mistral_nemo_judge_gatefix` + - verdict : `quality_blocked` + - blockers finaux : `missing_immediate_consequence` +- qwen rerun apres retouche micro-decision + gate stable : + - workspace : `automation/reports/20260322_qwen2_5_7b_judge_gatefix` + - verdict : `quality_blocked` + - blockers finaux : `missing_immediate_consequence` +- qwen rerun apres retouche consequence immediate : + - workspace : `automation/reports/20260322_qwen2_5_7b_judge_consequencefix` + - verdict : `accepted` + - aucun repair necessaire +- mistral rerun apres retouche consequence immediate : + - workspace : `automation/reports/20260322_mistral_nemo_judge_consequencefix` + - verdict : `quality_blocked` + - blockers finaux : `truncated_ending`, `missing_risky_decision`, `missing_immediate_consequence` +- implication : + - le juge secondaire est utile; il remplace un diagnostic narratif flou par des manques localises et exploitables + - `qwen2.5:7b` a maintenant un chemin local stable jusqu'a `accepted` + - la meme retouche ne generalise pas a `mistral-nemo`; il faut vraisemblablement separer le guidage de fin de scene par famille de modele + +## Priorite immediate + +1. Garder `automation/reports/20260322_qwen2_5_7b_judge_consequencefix` comme premiere reference Ollama `accepted`. +2. Isoler une variante prompt/repair moins directive pour `mistral-nemo`, puis rejouer `automation/reports/20260322_mistral_nemo_judge_consequencefix`. +3. Corriger ou contourner le faux positif ops du runtime remote `:8110`. +4. Ne reouvrir Apple `:8201` que pour un lot dedie, pas pour les reruns Ollama courants. diff --git a/docs/MODEL_COMPARISON_2026-03-16.md b/docs/MODEL_COMPARISON_2026-03-16.md new file mode 100644 index 0000000..f5b0e38 --- /dev/null +++ b/docs/MODEL_COMPARISON_2026-03-16.md @@ -0,0 +1,44 @@ +# Comparatif local ANE - 16 mars 2026 + +Comparatif de reprise aligne sur le protocole courant et sur les faits confirmes les plus utiles. + +## Lecture rapide + +- `apple-coreml:qwen3.5-4b-onnx-q4f16` reste la reference `accepted` +- `apple-coreml:qwen2.5-0.5b-instruct-onnx` reste une baseline `quality_blocked` +- `ollama:qwen2.5:1.5b` a deux lectures utiles: + - dernier etat automatise historise: `provider_failed` + - preuve de reprise manuelle via `llama-server`: `quality_blocked` a `gate` +- `ollama:qwen2.5:7b` reste le meilleur candidat alternatif, mais sa requalification via `llama.cpp` reste a faire sur un rerun comparable recent + +## Resultats utiles (mis a jour 16 mars 2026 session 2) + +| Modele | Backend cible | Verdict 16 mars session 2 | Blocker principal | Lecture operative | +|---|---|---|---|---| +| `apple-coreml:qwen3.5-4b-onnx-q4f16` | `apple-coreml` | `accepted` (reconfirme) | — | reference locale actuelle; rerun avec budgets 1024 confirme | +| `apple-coreml:qwen2.5-0.5b-instruct-onnx` | `apple-coreml` | `quality_blocked` | `truncated_ending` | fix `dense_bullet_list` catch listes pures en repair | +| `ollama:qwen2.5:7b` | `llama.cpp` / `llama-server` | `quality_blocked` | `truncated_ending` (LLM gate) | prose coherente mais fin narrative insuffisante pour gate LLM | +| `ollama:qwen2.5:1.5b` | `llama.cpp` / `llama-server` | `quality_blocked` | `truncated_ending` | plus d'`outline_like` (fix normalisation); manque de longueur | +| `ollama:mistral-nemo:latest` | `llama.cpp` / `llama-server` | en cours | — | lot french_models en cours (rapport 20260316T220423Z) | + +## Implication + +Le comparatif utile n'oppose plus "Apple vs Ollama" mais "prose complete vs incomplete" : + +- reference Apple `qwen3.5-4b` : `accepted` stable avec budgets 1024 +- `qwen2.5:7b` : runtime stable, blocker narratif — gate LLM stricter que heuristique sur fin de scene +- `qwen2.5:1.5b` : fix `outline_like` valide; reste `truncated_ending` (modele trop petit) +- mistral-nemo : premier run francophone, verdict en cours + +## Auto-sync + +- dernier cycle automatise: 2026-03-17T09:44:06+00:00 + +| Modele | Categorie | Preflight | Smoke | Classification | Failed stage | Gate | Repairs | Notes | +|---|---|---|---|---|---|---|---:|---| +| apple-coreml:qwen3.5-4b-onnx-q4f16 | priority_models | OK | oui | accepted | | oui | 0 | | +| ollama:qwen2.5:7b | priority_models | OK | oui | quality_blocked | gate | oui | 2 | | +| 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 | | +| ollama:mistral-nemo:latest | french_models | OK | oui | quality_blocked | gate | oui | 2 | | + diff --git a/docs/MODEL_COMPARISON_2026-03-22.md b/docs/MODEL_COMPARISON_2026-03-22.md new file mode 100644 index 0000000..24b8161 --- /dev/null +++ b/docs/MODEL_COMPARISON_2026-03-22.md @@ -0,0 +1,39 @@ +# Comparatif local ANE - 22 mars 2026 + +Comparatif court apres reruns cibles avec juge narratif secondaire. + +## Lecture rapide + +- `apple-coreml:qwen3.5-4b-onnx-q4f16` reste la seule reference `accepted` +- `ollama:qwen2.5:7b` devient le premier chemin Ollama local `accepted` +- `ollama:mistral-nemo:latest` n'a plus de faux `outline_like`, mais ne suit pas la meme retouche de fin de scene + +## Resultats utiles + +| Modele | Backend cible | Verdict 22 mars | Blockers finaux | Lecture operative | +|---|---|---|---|---| +| `apple-coreml:qwen3.5-4b-onnx-q4f16` | `apple-coreml` | `accepted` (historique de reference) | — | reference locale actuelle | +| `ollama:qwen2.5:7b` | `llama.cpp` / `llama-server` | `accepted` sur rerun consequencefix | — | premiere baseline Ollama locale promue; la contrainte "meme lieu, meme minute" ferme correctement la scene | +| `ollama:qwen2.5:1.5b` | `llama.cpp` / `llama-server` | `quality_blocked` (historique) | `truncated_ending` | petit modele encore utile comme baseline technique | +| `ollama:mistral-nemo:latest` | `llama.cpp` / `llama-server` | `quality_blocked` | `truncated_ending` sur rerun budgete; `outline_like`, `missing_immediate_consequence` sur rerun prompté; `missing_immediate_consequence` sur rerun gatefix; `truncated_ending`, `missing_risky_decision`, `missing_immediate_consequence` sur rerun consequencefix | le budget aide et le gate corrige supprime le faux positif de forme, mais la retouche commune de fin de scene degrade ce modele | + +## Implication + +Le prochain lot ne doit pas rouvrir le runtime. Il doit : + +- garder `qwen2.5:7b` comme reference Ollama locale `accepted` +- ouvrir un chantier prompt/repair specifique a `mistral-nemo`; la retouche commune n'est plus une hypothese tenable + +## Auto-sync + +- dernier cycle automatise: 2026-03-23T21:34:05+00:00 + +| Modele | Categorie | Preflight | Smoke | Classification | Failed stage | Gate | Repairs | Notes | +|---|---|---|---|---|---|---|---:|---| +| ollama:qwen2.5:7b | priority_models | KO | non | provider_failed | | non | 0 | Le preflight OpenAI-compatible a échoué. | +| apple-coreml:qwen2.5-0.5b-instruct-onnx | runtime_preflight | n/a | non | dry_run | | non | 0 | Dry-run: aucun preflight ni smoke exécuté. | +| apple-coreml:qwen3.5-4b-onnx-q4f16 | runtime_preflight | n/a | non | dry_run | | non | 0 | Dry-run: aucun preflight ni smoke exécuté. | +| ollama:qwen2.5:1.5b | runtime_preflight | n/a | non | dry_run | | non | 0 | Dry-run: aucun preflight ni smoke exécuté. | +| mistral:mistral-large-latest | french_models | OK | oui | accepted | | oui | 0 | | +| ollama:mistral-nemo:latest | french_models | KO | non | provider_failed | | non | 0 | Le preflight OpenAI-compatible a échoué. | + diff --git a/docs/OSS_LANDSCAPE_2026-03-16.md b/docs/OSS_LANDSCAPE_2026-03-16.md new file mode 100644 index 0000000..7f38605 --- /dev/null +++ b/docs/OSS_LANDSCAPE_2026-03-16.md @@ -0,0 +1,177 @@ +# Veille OSS - 16 mars 2026 + +Veille sur des projets et librairies open source proches ou reutilisables pour `ai-novel-engine`. + +Derniere mise a jour : 16 mars 2026 (session 3 — lots baselines/priority_models/french_models termines + veille agent complementaire). + +## Lecture rapide + +Le besoin ANE n'est pas "adopter une plateforme agentique complete". Le besoin est: + +- garder un contrat OpenAI-compatible local +- mieux piloter les runtimes et les logs +- garder une boucle d'eval/garde-fou sobre +- **faire passer `outline_like` et `truncated_ending`** + +## Addendum web verifie — 17 mars 2026 + +Synthese issue d'une verification web directe sur les repos: + +- `promptfoo/promptfoo`: MIT, ~17k stars, evals + red teaming en local et CI/CD; bon candidat pour industrialiser les campagnes de regression prompts +- `confident-ai/deepeval`: Apache-2.0, ~14k stars, metriques G-Eval et integration pytest; bon candidat pour formaliser le gate narratif en tests automatises +- `noamgat/lm-format-enforcer`: MIT, ~2k stars, contraintes regex/JSON au niveau token; pertinent pour bloquer les sorties de type outline/markdown +- `GOAT-AI-lab/GOAT-Storytelling-Agent`: MIT, ~136 stars, pattern plan -> scenes avec contexte `previous_scene`; utile pour la conversion structure -> resume narratif avant draft + +Decision court terme: + +1. Prioriser un POC `deepeval` sur 10 chapitres de reference +2. Tester `lm-format-enforcer` sur le chemin `llama.cpp` pour prevenir `outline_like` +3. Garder `promptfoo` pour orchestration de campagnes, pas pour remplacer le pipeline ANE + +## Addendum web verifie - remote ops Mascarade (17 mars 2026) + +Projets verifies pour l'idee tower/kxkm: + +- `BerriAI/litellm`: gateway OpenAI-compatible multi-provider, utile comme reference de routage/observabilite centralisee +- `Autossh/autossh`: maintien automatique des tunnels SSH, bon candidat pour stabiliser `8110`/`8111` +- `fatedier/frp`: reverse proxy/NAT traversal, option si SSH direct devient fragile +- `tmux-python/tmuxp`: sessions tmux declaratives, utile pour standardiser cockpit ops +- `Textualize/textual`: framework TUI Python moderne, utile si on evolue au-dela des TUIs texte actuelles +- `getsops/sops`: gestion secrete des credentials/env en fichiers chiffres, utile pour durcir les presets + +Decision court terme: + +1. Prioriser `autossh` pour tenir les tunnels tower/kxkm sans intervention manuelle +2. Garder `mascarade_remote_tui.py` comme cockpit minimal immediat +3. Evaluer `litellm` seulement comme reference architecture, pas comme remplacement du pipeline ANE + +## Projets a surveiller — Runtime et cockpit + +| Projet | Type | Pourquoi c'est utile a ANE | Ce qu'il faut emprunter | Lien | +|---|---|---|---|---| +| `llama.cpp` | runtime local | brique la plus directe pour servir les blobs GGUF `qwen2.5` hors Ollama natif | `llama-server`, alias de modeles, healthchecks simples | [ggml-org/llama.cpp](https://github.com/ggml-org/llama.cpp) | +| `Open WebUI` | cockpit local LLM | reference produit pour visualiser runtimes, modeles et interactions locales | idees d'observabilite locale, pas son stack complet | [open-webui/open-webui](https://github.com/open-webui/open-webui) | +| `Promptfoo` | evals / guardrails | proche du besoin ANE de comparer des sorties et de verrouiller des regressions | patterns d'evaluations declaratives et de matrices de tests | [promptfoo/promptfoo](https://github.com/promptfoo/promptfoo) | +| `Flowise` | orchestration visuelle | utile comme source d'idees pour un futur cockpit, pas comme coeur narratif | inspiration UI / debugging de flux | [FlowiseAI/Flowise](https://github.com/FlowiseAI/Flowise) | +| `SillyTavern` | interface locale narrative | interessant pour la partie auteur/runtime local et les usages fictionnels | idees UX pour mode auteur, presets, gestion contextuelle | [SillyTavern/SillyTavern](https://github.com/SillyTavern/SillyTavern) | +| `koboldcpp` | runtime local fiction | tres proche des usages d'ecriture locale et des modeles GGUF pour narration | inspiration sur le serving local fiction-first | [LostRuins/koboldcpp](https://github.com/LostRuins/koboldcpp) | + +## Projets a surveiller — Contraintes decodage (prevention outline_like) + +| Projet | Facilite | Pourquoi c'est utile a ANE | Ce qu'il faut emprunter | Lien | +|---|---|---|---|---| +| `lm-format-enforcer` | **Facile** | filtre logit regex + JSON schema ; integration `llama-cpp-python` documentee ; bloque `#`, `*`, `-` au niveau token | regex `^[^#*\-]{20,}` appliquee au sampling — zero Markdown sans fine-tuning | [noamgat/lm-format-enforcer](https://github.com/noamgat/lm-format-enforcer) | +| `dottxt/outlines` | Moyen | logit masking via automates finis ; mode regex grammar ; `outlines-core` Rust pour C-bindings llama.cpp | `RegexGuide` pour bannir tokens structurants dans draft/rewrite ; si outlines-core deja installe | [dottxt-ai/outlines](https://github.com/dottxt-ai/outlines) | +| `microsoft/guidance` | Moyen | entrelace Python et generation ; `gen()` avec regex ; backend `llama_cpp` disponible | forcer des paragraphes de prose continue via grammaire | [microsoft/guidance](https://github.com/microsoft/guidance) | +| `IterGen` (ICLR 2025) | Difficile | generation liee a grammaire avec reutilisation KV-cache ; backtracking clause a clause sans regenration complete | pattern applicable a la detection prose vs liste | [arxiv:2410.07295](https://arxiv.org/abs/2410.07295) | +| `Awesome-LLM-Constrained-Decoding` | Reference | liste curatee de papiers+code sur le decodage contraint (2022-2025) | carte de navigation OSS | [Saibo-creator/Awesome-LLM-Constrained-Decoding](https://github.com/Saibo-creator/Awesome-LLM-Constrained-Decoding) | +| `FMBench` (arxiv, fev 2025) | Reference | benchmark sur la conformite de formatage de sortie (Markdown vs prose) ; SFT+RLFT reduit le Markdown sans contrainte dure | reference pour valider l'impact des prompts sur le format de sortie | [arxiv:2602.06384](https://arxiv.org/abs/2602.06384) | + +## Projets a surveiller — Qualite narrative et evals + +| Projet | Type | Pourquoi c'est utile a ANE | Ce qu'il faut emprunter | Lien | +|---|---|---|---|---| +| `GOAT-Storytelling-Agent` | pipeline narratif | convertit un plan structurel en resume narratif avant injection dans draft | pattern outline→summary pour eviter `outline_like` dans le modele | [malik-kb/GOAT-Storytelling-Agent](https://github.com/malik-kb/GOAT-Storytelling-Agent) | +| `story-evaluation-llm` (lars76) | evaluateur narratif | 15 criteres ; 5 categories de faiblesses dont "list-like structure" et "heading use" | remplacement potentiel du garde-fou `gate` heuristique ; taxonomie de faiblesses directement reutilisable | [lars76/story-evaluation-llm](https://github.com/lars76/story-evaluation-llm) | +| `prometheus-eval` (Prometheus 2) | LLM-as-judge | modele 7B/14B open-weight ; rubrique 1-5 personnalisable ; absolu + pairwise ; fully local | rubrique "absence de listes et titres" en francais — remplace les heuristiques fragiles | [prometheus-eval/prometheus-eval](https://github.com/prometheus-eval/prometheus-eval) | +| `EQ-bench / longform-writing-bench` | benchmark long | evalue planification, coherence, developpement personnage, qualite prose sur roman court | criteres de coherence narrative ; juge swappable vers modele local | [EQ-bench/longform-writing-bench](https://github.com/EQ-bench/longform-writing-bench) | +| `EQ-bench / creative-writing-bench` | benchmark creatif | rubrique 18 questions ; agregation multi-juge ; criteres style/voix/tension | reference pour calibrer un juge local ANE | [EQ-bench/creative-writing-bench](https://github.com/EQ-bench/creative-writing-bench) | +| `EQ-bench / Judgemark-v2` | meta-eval | evalue le juge LLM lui-meme ; fiabilite inter-evaluateurs vs baseline humain pour la fiction | valider un Prometheus local comme juge ANE | [EQ-bench/Judgemark-v2](https://github.com/EQ-bench/Judgemark-v2) | +| `dottxt/outlines` | contraintes logits | interdit des patterns Markdown au niveau du sampling — zero Markdown structurant garanti | `RegexGuide` pour bannir `#`, `*`, `-` en debut de ligne dans draft/rewrite | [dottxt-ai/outlines](https://github.com/dottxt-ai/outlines) | +| `DeepEval` | eval LLM pytest-compatible | G-Eval avec criteres personnalises, s'integre dans une suite pytest | evals de prose comme tests unitaires narratifs | [confident-ai/deepeval](https://github.com/confident-ai/deepeval) | +| `distilabel` (Argilla) | pipeline eval | orchestrateur pipeline eval + `PrometheusEval` task integree | orchestrer des runs Prometheus locaux sur les lots ANE | [argilla-io/distilabel](https://github.com/argilla-io/distilabel) | +| `WritingBench` (X-PLUG) | benchmark ecriture | 6 domaines, 100 sous-domaines ; pipeline de juge automatise ; agnostique a la langue | grille d'evaluation etendue pour les lots ANE | [X-PLUG/WritingBench](https://github.com/X-PLUG/WritingBench) | +| `story-bench` (clchinkc) | benchmark narratif | Story Theory Benchmark ; criteres narratifs theoriques ; leaderboard ; flag des sorties trop structurantes | grille theorie narrative adaptable aux lots ANE | [clchinkc/story-bench](https://github.com/clchinkc/story-bench) | +| `lechmazur/writing` | benchmark creatif | 10 elements obligatoires a incorporer ; rubrique 18 questions ; agregation multi-juge | reference pour valider la completude narrative (elements d'intention injected vs produits) | [lechmazur/writing](https://github.com/lechmazur/writing) | + +## Projets a surveiller — Continuite narrative et architectures pipeline + +| Projet | Type | Pourquoi c'est utile a ANE | Ce qu'il faut emprunter | Lien | +|---|---|---|---|---| +| `SCORE` (arxiv, mars 2025) | pattern RAG narratif | tracker symbolique + episodes hierarchiques + recuperation TF-IDF/cosine injectes avant generation ; +23.6% coherence, -41.8% hallucinations | **pattern direct** pour un gate de continuite avant chaque lot | [arxiv:2503.23512](https://arxiv.org/abs/2503.23512) | +| `KazKozDev/NovelGenerator` | pipeline roman Ollama-natif | multi-agents ; suivi de la connaissance personnage par timeline ; threads narratifs paralleles | structures de donnees d'etat personnage + scene ; compatible Ollama | [KazKozDev/NovelGenerator](https://github.com/KazKozDev/NovelGenerator) | +| `datacrystals/AIStoryWriter` | ecrivain roman local | Ollama-compatible ; maintient traits personnage coherents ; code Python inspectable | coherence des traits personnage chapitre a chapitre | [datacrystals/AIStoryWriter](https://github.com/datacrystals/AIStoryWriter) | +| `Awesome-Story-Generation` | reference papiers | liste curatee generation narrative LLM era (2023-2025) : planning, coherence, consistance | carte de navigation pour les prochaines iterations narratives ANE | [yingpengma/Awesome-Story-Generation](https://github.com/yingpengma/Awesome-Story-Generation) | + +## Projets a surveiller — Francais et qualite de prose + +| Projet | Type | Pourquoi c'est utile a ANE | Ce qu'il faut emprunter | Lien | +|---|---|---|---|---| +| `CroissantLLM` | modele bilingue FR/EN | entraine sur corpus FR/EN equilibre ; meilleur candidat comme juge de prose FR local | modele juge natif francais pour Prometheus — evite un juge anglophone | [HuggingFace blog](https://huggingface.co/blog/manu/croissant-llm-blog) | +| `FrenchBench` (Quantmetry) | benchmark FR NLP | evalutation multi-taches FR dont generation ; taches de generation de qualite | base de reference pour calibrer les evaluations FR dans ANE | [Quantmetry/FrenchBench](https://github.com/Quantmetry/FrenchBench) | +| Leaderboard Open LLM Francais | leaderboard | classe les modeles sur benchmarks FR specifiques dont generation | reference pour choisir les prochains modeles FR a tester dans ANE | [HuggingFace Leaderboard FR](https://huggingface.co/spaces/fr-gouv-coordination-ia/Leaderboard_Open_LLM_en_francais) | +| CamemBERT / CamemBERT-bio | scorer de perplexite FR | masked-LM ; perplexite comme proxy de fluidite grammaticale FR | gate de fluidite FR leger avant un juge lourd — rejette les lots avec perplexite anormale | [HuggingFace camembert-base](https://huggingface.co/almanach/camembert-base) | +| `COLE` (arxiv:2510.05046) | benchmark NLU FR | 23 taches NLU FR ; 94 modeles evalues ; variation regionale ; zero-shot QA | reference pour classer les prochains modeles candidats `french_models` avant de les tester dans ANE | [arxiv:2510.05046](https://arxiv.org/abs/2510.05046) | + +## Recommandations concretes + +### P0 — outline_like (immediate, effort faible) + +Outil recommande : **`lm-format-enforcer` + `llama-cpp-python`** + +```python +from lmformatenforcer import RegexParser +from lmformatenforcer.integrations.llamacpp import build_llamacpp_logits_processor + +# Regex qui autorise uniquement des caracteres de prose +# Bloque #, *, -, > au niveau logit +parser = RegexParser(r"[A-Za-zÀ-ÿ ,\.;:!\?\"\'\(\)\n]{50,}") +logits_processor = build_llamacpp_logits_processor(llm, parser) +``` + +Fallback si `outlines` deja installe : utiliser `RegexGuide` avec le meme pattern. + +Le papier `FMBench` (fev 2025) confirme que SFT+RLFT reduit le Markdown sans contraintes dures — voie de fine-tuning future quand les baselines sont stables. + +### P1 — Gate LLM-as-judge (effort moyen) + +Deployer **Prometheus 2 (7B)** localement via `llama-cpp-python` ou `ollama`. Ecrire une rubrique 5 criteres en francais : + +1. Absence de listes, titres, et labels structurants +2. Qualite narrative (scene jouee, pas decrite) +3. Continuite de personnage +4. Coherence de ton et de registre +5. Densite prose (cible 600-800 mots de recit continu) + +Utiliser la taxonomie de faiblesses `lars76/story-evaluation-llm` comme base de formulation. Orchestrer avec `distilabel` + tache `PrometheusEval`. + +### P2 — Continuite narrative (effort moyen) + +Implémenter le **pattern SCORE** (arxiv:2503.23512) : + +1. Avant chaque lot, recuperer les k passages les plus pertinents des chapitres precedents via TF-IDF + cosine +2. Injecter un bloc d'etat symbolique (personnages actifs, lieux, fils narratifs ouverts) +3. Passer les deux dans le contexte de `draft_v1` + +Reference d'implementation : `KazKozDev/NovelGenerator` pour les structures de donnees d'etat par timeline. + +### P3 — Qualite prose FR (effort faible a proto) + +Pas d'outil cle en main. Approche recommandee : + +- **Court terme** : perplexite via CamemBERT comme gate leger de fluidite FR (rejette les lots >seuil) +- **Moyen terme** : Prometheus 2 + rubrique FR + CroissantLLM comme juge natif bilingue +- **Long terme** : fine-tuner un petit CroissantLLM comme scorer FR dedie, une fois assez de donnees de lots + +### A moyen terme (general) + +- evaluer `prometheus-eval` ou `story-evaluation-llm` comme remplacement LLM-as-judge du gate heuristique actuel +- regarder `dottxt/outlines` si les contraintes logits deviennent accessibles via llama-server (grammar-based sampling) +- regarder `Open WebUI` et `SillyTavern` si un vrai poste auteur local devient prioritaire +- tester les modeles du Leaderboard Open LLM Francais comme prochains candidats `french_models` + +### A ne pas faire maintenant + +- remplacer le pipeline ANE par une plateforme agentique generaliste +- noyer la logique auteuriale dans un builder de flows +- multiplier les dependances UI avant d'avoir stabilise les reruns runtime + +## Fix outline_like — recette directe + +D'apres l'analyse des projets GOAT, lm-format-enforcer et des patterns few-shot : + +1. **Output primer** : terminer le prompt `draft_v1` par `---\n\n` suivi des premiers mots de la premiere scene (force le modele a continuer en prose, pas en titre) +2. **Few-shot BAD/GOOD** : ajouter 1 exemple court de MAUVAISE sortie (`# Scene\n- bullet`) et 1 BONNE sortie (prose continue) dans chaque prompt +3. **Word count explicite** : mentionner une cible de mots (`environ 800 mots`) plutot qu'un budget tokens +4. **Structure → narrative** : convertir la `structure` JSON en 2-3 phrases narratives (type GOAT) avant de l'injecter dans `draft_v1` +5. **Contrainte logit** : si llama-server avec `lm-format-enforcer`, appliquer regex prose en complement des prompts pour garantie hard diff --git a/docs/OSS_LANDSCAPE_2026-03-21.md b/docs/OSS_LANDSCAPE_2026-03-21.md new file mode 100644 index 0000000..d29e61e --- /dev/null +++ b/docs/OSS_LANDSCAPE_2026-03-21.md @@ -0,0 +1,58 @@ +# OSS Landscape - 21 mars 2026 + +Veille sourcee orientee refonte d'ANE. Le but est de reperer ce qui peut etre repris comme idee, pas d'importer ces projets tels quels. + +## Recommandations directes + +### P0 - Contraindre les sorties structurees cote runtime + +- [`lm-format-enforcer`](https://github.com/noamgat/lm-format-enforcer) + - utile pour imposer du JSON ou des regex sans rewriter tout le pipeline + - bon candidat si ANE veut durcir `critique` / `gate` / `memory` sur des runtimes compatibles + - a traiter comme experience ciblee, pas comme dependance centrale + +### P1 - Evaluer la prose avec un cadre de tests + +- [`DeepEval`](https://github.com/confident-ai/deepeval) + - utile comme harness d'evals Python/pytest pour comparer prompts, budgets et modeles + - peut completer les heuristiques ANE sans les remplacer d'un coup + +### P1 - Utiliser un benchmark d'ecriture comme source de criteres + +- [`WritingBench`](https://github.com/X-PLUG/WritingBench) + - benchmark recent de generation d'ecriture + - utile pour deriver une grille d'evaluation et des criteres plus stables que les seuls heuristiques maison + +## Recommandations architecture / ops + +### P1 - Consolider le backend local de secours + +- [`llama.cpp`](https://github.com/ggerganov/llama.cpp) + - ANE l'utilise deja indirectement via `llama-server` + - confirme comme substrate local robuste pour le fallback `ollama:*` quand Ollama natif casse + +### P2 - Reference de gateway / abstraction provider + +- [`LiteLLM`](https://github.com/BerriAI/litellm) + - interessant comme reference de design pour les profils runtime et l'abstraction provider + - a etudier pour les concepts, pas a adopter tel quel dans ANE + +### P2 - Reference de cockpit runtime + +- [Open WebUI](https://docs.openwebui.com/) + - utile comme reference produit pour ce qui releve du cockpit et de l'operabilite locale + - non cible comme dependance directe: ANE a besoin d'un control plane plus petit et plus TUI-first + +## Recommandations plus lourdes ou a garder sous surveillance + +- [`Outlines`](https://github.com/dottxt-ai/outlines) + - tres interessant pour la generation structuree, mais integration plus structurante et plus lourde + - a garder en lot ulterieur si `lm-format-enforcer` ne suffit pas + +## Traduction concrete pour ANE + +1. Garder `llama.cpp` comme backend de secours de premier rang. +2. Introduire des capacites runtime explicites avant toute nouvelle salve de tuning prompt. +3. Tester `lm-format-enforcer` sur les etapes JSON seulement. +4. Ajouter un harness d'evals type `DeepEval` ou une grille inspiree de `WritingBench`. +5. Ne pas absorber `LiteLLM` ni `Open WebUI`; ne reprendre que leurs idees de surface. diff --git a/docs/OSS_LANDSCAPE_DEEP_2026-03-16.md b/docs/OSS_LANDSCAPE_DEEP_2026-03-16.md new file mode 100644 index 0000000..2a0e965 --- /dev/null +++ b/docs/OSS_LANDSCAPE_DEEP_2026-03-16.md @@ -0,0 +1,340 @@ +# Veille OSS approfondie — ai-novel-engine — 16 mars 2026 + +Recherche effectuée le 16 mars 2026. Sources : GitHub, arXiv, documentation officielle des projets. + +--- + +## 1. Pipelines de génération de romans / fiction avec LLM + +### 1.1 GOAT-Storytelling-Agent +- **URL** : https://github.com/GOAT-AI-lab/GOAT-Storytelling-Agent +- **Étoiles** : 136 +- **Licence** : MIT +- **Pipeline** : 5 étapes de planification (init spec → enrich spec → plot structure → refine chapter outlines → scene breakdown), puis génération scène par scène. Chaque appel `write_a_scene` reçoit la scène précédente en contexte. +- **Format de scène structuré** : Characters, Place, Time, Event, Conflict, Story value, Story value charge, Mood, Outcome. Ce séparateur planning/prose évite la fuite de structure dans l'output. +- **Backend local** : supporte `llama.cpp` via `backend_uri = 'http://localhost:8080'` et HuggingFace TGI. Extensible à tout moteur OpenAI-compatible. +- **Réutilisable pour ANE** : + - Pattern de passage `previous_scene` pour maintenir la continuité sans rechargement d'un long contexte. + - Structure de scène normalisée utilisable comme contexte d'injection avant generation (comparable aux lorebooks). + - Mécanisme de séparation explicite entre outline interne (non sorti) et prose générée. + +### 1.2 AIStoryWriter +- **URL** : https://github.com/datacrystals/AIStoryWriter +- **Étoiles** : 220 +- **Licence** : AGPL-3.0 +- **Pipeline** : outline global → outline chapitres → rédaction chapitre → révision. Supporte Ollama, Google, OpenRouter avec mélange de modèles par étape. +- **Fichier `Evaluate.py`** : évaluation structurée des sorties (critères non publiés en détail dans le README). +- **Réutilisable pour ANE** : + - Couche d'abstraction multi-provider (`Writer/Prompts.py`) avec prompts séparés par étape. + - Inspiration pour mixer modèles locaux selon l'étape (draft léger, critique plus puissant). + - Attention : licence AGPL-3.0 impose de publier les modifications si distribué en service réseau. + +### 1.3 NovelGenerator (KazKozDev) +- **URL** : https://github.com/KazKozDev/NovelGenerator +- **Étoiles** : ~100 +- **Licence** : non spécifiée dans le README (à vérifier dans le fichier LICENSE) +- **Pipeline** : génération multi-thread narrative, suivi de perspectives personnage par timeline, synchronisation des arcs parallèles. +- **Codebase** : TypeScript / React (pas Python). Composants modulaires : Components, Constants, Hooks, Services, Utilities. +- **Réutilisable pour ANE** : architecture conceptuelle de tracking d'état narratif multi-fil. Pas directement réutilisable en Python mais les patterns de cohérence sont transposables. + +### 1.4 Novel-OS (mrigankad) +- **URL** : https://github.com/mrigankad/Novel-OS +- **Étoiles** : 1 +- **Licence** : MIT +- **Pipeline 5 agents** : + 1. The Architect (planification 3 actes, arcs chapitre) + 2. The Scribe (prose en deep POV) + 3. The Editor (5 modes : line, developmental, pacing, dialogue, tension) + 4. The Continuity Guardian (analyse forensique : personnage / timeline / monde / plot) + 5. The Style Curator (maintien de la voix sur 300+ pages) +- **Mémoire** : `StoryState` JSON persistant (`outputs/state/story_state.json`) comme source de vérité unique. +- **Continuity Guardian output** : `CONTINUITY_REPORT` avec statuts PASS / WARNING / FAIL. +- **Réutilisable pour ANE** : structure de rapport de continuité directement applicable comme pattern de gate qualité. La décomposition en 5 rôles éditoriaux distincts est proche de l'architecture ANE (pipeline → critique → gate → repair). + +### 1.5 Novel-Writer (curvedinf) +- **URL** : https://github.com/curvedinf/novel-writer +- **Étoiles** : 46 +- **Licence** : MIT +- **Pipeline** : `python outline.py` pour démarrer, approche tiered (modèle gratuit pour init, modèle fort pour prose finale). +- **Réutilisable pour ANE** : peu. Sert de référence conceptuelle sur le choix de modèle par étape. + +### 1.6 Book-OS / Novel-OS (forsonny) +- **URL** : https://github.com/forsonny/book-os +- **Étoiles** : non communiqué +- **Licence** : non précisée +- **Description** : workflow system avec 3 couches de contexte (Standards, Novel, Manuscripts) pour maintenir la voix de l'auteur. Compatible Claude Code, Cursor, et tout outil IA. +- **Réutilisable pour ANE** : idée des couches de contexte hiérarchiques (style sheet globale → paramètres du roman → manuscrit en cours). Applicable à la mémoire ANE. + +### 1.7 RecurrentGPT +- **URL** : https://github.com/aiwaves-cn/RecurrentGPT +- **Étoiles** : 1 003 +- **Licence** : non précisée (à vérifier) +- **Concept** : simulacre LLM du mécanisme LSTM. À chaque timestep : génération d'un paragraphe + mise à jour d'une mémoire court terme (prompt) et long terme (disque). Génération de longueur arbitraire. +- **Réutilisable pour ANE** : + - Pattern de mémoire double (court terme in-prompt / long terme sur disque) directement transposable au système de mémoire ANE. + - Gestion interactive : propose plusieurs continuations possibles pour validation auteur. + +--- + +## 2. Benchmarks d'évaluation qualité prose / fiction + +### 2.1 EQ-Bench Creative Writing Benchmark +- **URL** : https://github.com/EQ-bench/creative-writing-bench +- **Site** : https://eqbench.com/creative_writing.html +- **Étoiles** : 97 +- **Licence** : non précisée +- **Philosophie** : exposer les faiblesses des modèles, pas aider à produire le meilleur output. 32 prompts difficiles × 3 itérations, scoring Elo. +- **Failure modes ciblés** : verbosité excessive, incohérence poétique, biais de longueur, biais de position dans le jugement comparatif. +- **Réutilisable pour ANE** : les 32 prompts et la grille de scoring par rubric constituent une base pour définir les critères du gate ANE. Le mécanisme d'Elo normalisé sur des modèles de référence est applicable à une évaluation comparative draft/rewrite. + +### 2.2 LLM Creative Story-Writing Benchmark (lechmazur/writing) +- **URL** : https://github.com/lechmazur/writing +- **Étoiles** : 354 +- **Licence** : non précisée +- **Rubrique 18 questions** : + - Craft & Coherence (Q1–Q8) : character depth, plot structure, world building, story impact, originality, thematic cohesion, voice/POV, line-level prose quality. + - Element Integration (Q9A–Q9J) : 10 éléments obligatoires à intégrer organiquement. +- **Scoring** : power mean (Hölder p=0.5), pondération 60/40 craft vs intégration, 7 LLMs juges indépendants. +- **Réutilisable pour ANE** : la rubrique 8 questions de craft est directement applicable comme grille de scoring du gate. Critère `outline_like` absent explicitement → peut être ajouté comme Q8bis (structure non narrative dans le texte). + +### 2.3 WritingBench (X-PLUG/Alibaba) +- **URL** : https://github.com/X-PLUG/WritingBench +- **Étoiles** : 165 +- **Licence** : Apache-2.0 +- **Approche** : 1 000 queries réelles sur 6 domaines (créatif, persuasif, informatif, technique) × 100 sous-domaines (dont Novel Outline, Prose, Screenplay, Fan Fiction). Chaque query génère 5 critères d'évaluation spécifiques dynamiquement. +- **Modèle critique** : Qwen-7B fine-tuné disponible sur HuggingFace, déployable localement. +- **Réutilisable pour ANE** : le modèle critique Qwen-7B est une alternative locale à un LLM-as-judge cloud. La génération dynamique de critères par query est un pattern applicable à la critique adaptative (critères différents selon le type de scène). + +### 2.4 story-evaluation-llm (lars76) +- **URL** : https://github.com/lars76/story-evaluation-llm +- **Étoiles** : 3 +- **Licence** : MIT +- **Dataset** : 8 520 histoires × 15 modèles × 4 températures, scores moyennés. +- **15 critères narratifs** (directement réutilisables) : + 1. Grammar, spelling, punctuation quality + 2. Clarity and understandability + 3. Logical connection between events and ideas + 4. Scene construction and purpose + 5. Internal consistency + 6. Character consistency + 7. Character motivation coherence + 8. Sentence pattern variety + 9. Avoidance of clichés + 10. Natural dialogue + 11. Avoidance of predictable narrative tropes + 12. Character depth and dimensionality + 13. Realistic character interactions + 14. Ability to hold reader interest + 15. Satisfying plot resolution +- **Réutilisable pour ANE** : cette liste de 15 critères est la grille la plus directement applicable pour remplacer l'heuristique actuelle du gate. Critère 3 (logical connection) couvre outline_like partiel. À compléter avec "absence de structure non narrative dans le texte" (critère custom). + +--- + +## 3. Frameworks LLM-as-Judge pour la prose narrative + +### 3.1 Prometheus-Eval +- **URL** : https://github.com/prometheus-eval/prometheus-eval +- **Étoiles** : 1 100 +- **Licence** : Apache-2.0 +- **Modèles** : prometheus-7b-v2.0 et prometheus-8x7b-v2.0, fine-tunés pour l'évaluation sur rubrique personnalisée. +- **API Python** : + ```python + from prometheus_eval.vllm import VLLM + from prometheus_eval import PrometheusEval + model = VLLM(model="prometheus-eval/prometheus-7b-v2.0") + judge = PrometheusEval(model=model) + # absolute_grade() retourne feedback + score 1-5 + ``` +- **Format rubrique** : 5 niveaux (score1_description … score5_description) sur critère custom. +- **Déploiement local** : via llamafile (quantifié 5-bit, ~12 GB RAM) ou vLLM. Serveur Flask OpenAI-compatible sur port 8081. +- **Réutilisable pour ANE** : remplacement direct de l'heuristique gate par un judge local. Définir une rubrique 1-5 sur "absence de structure non narrative" + "cohérence narrative" + "qualité prose". Batch evaluation 10× plus rapide que single. + +### 3.2 DeepEval (confident-ai) +- **URL** : https://github.com/confident-ai/deepeval +- **Étoiles** : 14 100 +- **Licence** : open source (fichier LICENSE.md dans le repo) +- **G-Eval** : LLM-as-judge sur critères personnalisés avec chain-of-thought, précision humaine déclarée. +- **Local LLM support** : métriques tournent localement, pas de dépendance API externe obligatoire. +- **Réutilisable pour ANE** : G-Eval + critères custom prose = gate qualité configurable en quelques lignes. Intégration dans les tests existants (`tests/test_generation_pipeline.py`). + +### 3.3 JudgeLM (BAAI) +- **URL** : https://github.com/baaivision/JudgeLM +- **Étoiles** : non relevé précisément (ICLR 2025 Spotlight) +- **Licence** : open source +- **Performance** : accord > 90% avec jugement humain-humain en open-ended scenarios. +- **Réutilisable pour ANE** : alternative à Prometheus pour le jugement comparatif (draft vs rewrite). + +### 3.4 quotient-ai/judges +- **URL** : https://github.com/quotient-ai/judges +- **Étoiles** : non relevé +- **Licence** : open source +- **Description** : bibliothèque légère de juges LLM basés sur la recherche, utilisables off-the-shelf. +- **Réutilisable pour ANE** : surface d'API minimale, bonne option si DeepEval est trop lourd. + +--- + +## 4. Frameworks de cohérence narrative (mémoire / état) + +### 4.1 SCORE (Story Coherence and Retrieval Enhancement) +- **Référence** : https://arxiv.org/html/2503.23512v1 +- **Pas de repo GitHub autonome** (article de mars 2026) +- **Architecture** : + - Dynamic State Tracking : suivi symbolic des états actifs/perdus/détruits des objets et personnages. + - Context-Aware Summarization : résumés hiérarchiques par épisode (plot points, actions, progression émotionnelle). + - Hybrid Retrieval : TF-IDF + cosine similarity FAISS + sentiment scoring. + - Formule kernel : `K(ec,ep) = exp((S − γ|σ_diff|) / τ)`. +- **Réutilisable pour ANE** : le schéma de représentation d'état épisodique (active/lost/destroyed) est directement applicable à la mémoire ANE. Le RAG hybride TF-IDF + semantic est un upgrade du système mémoire actuel. + +### 4.2 SillyTavern Memory Stack +- **SillyTavern-MemoryBooks** : https://github.com/aikohanasaki/SillyTavern-MemoryBooks — génération JSON de souvenirs scènes → lorebook vectorisé. +- **Timeline Memory** : https://github.com/unkarelian/timeline-memory — timeline de chapitres résumés, injection context intelligente sans édition manuelle. +- **TunnelVision** : https://github.com/Coneja-Chibi/TunnelVision — memory management toolkit déclaré comme tool calls (le LLM décide quand l'utiliser). +- **SillyTavern-LorebookOrdering** : https://github.com/aikohanasaki/SillyTavern-LorebookOrdering — priorité et budget d'activation des lorebooks. +- **Réutilisable pour ANE** : les patterns d'injection dynamique de contexte narratif (keyword-triggered, budget-controlled) sont transposables à la mémoire ANE sans adopter SillyTavern. Le concept de "lorebook budgeté" = injection mémoire avec limite de tokens. + +### 4.3 Awesome-Story-Generation (yingpengma) +- **URL** : https://github.com/yingpengma/Awesome-Story-Generation +- **Étoiles** : 592 +- **Papiers clés** : + - RecurrentGPT (2023) : mémoire double court/long terme. + - FACTTRACK NAACL-2025 : time-aware world state tracking dans les outlines. + - MLD-EA (2024) : validation de cohérence émotionnelle et d'action. + - Weaver (2024) : foundation models optimisés pour l'écriture. + - Small LMs can outperform humans (COLING-2025) : viabilité des petits modèles pour la fiction. + +--- + +## 5. Solutions techniques au problème outline_like / structure dans les outputs + +### 5.1 Diagnostic du problème + +Le problème `outline_like` / `truncated_ending` / structure dans le texte généré est un **failure mode documenté** dans plusieurs projets. Il survient quand : +1. Le modèle confond la phase de planification avec la phase de rédaction. +2. Le contexte inclut encore des éléments de l'outline au moment du draft. +3. Le modèle coupe sa génération avant la fin naturelle (budget token sous-estimé). + +### 5.2 Solutions de prompting (classées par efficacité documentée) + +**Technique 1 — Séparation stricte des phases (pattern GOAT)** +Ne jamais inclure l'outline brut dans le prompt de génération prose. Convertir l'outline en un bloc de contexte narratif neutre (description de la scène en prose condensée, pas en liste). + +**Technique 2 — Output primer** +Terminer le prompt utilisateur avec le début de la prose souhaitée : +``` +[fin du prompt système] +[début de la réponse attendue] : "Le soleil se couchait sur..." +``` +Le modèle continue dans le registre amorcé. Efficace pour éviter l'émission de headers Markdown. + +**Technique 3 — Few-shot avec contre-exemple explicite** +Fournir un exemple de mauvaise sortie (avec headers/liste) labelisé "MAUVAIS" et un exemple de bonne sortie labelisé "BON". Les modèles locaux répondent mieux à la démonstration qu'à l'instruction négative seule ("ne pas utiliser de headers"). + +**Technique 4 — Chained prompting séquentiel** +Diviser la génération d'un chapitre en N appels (une scène par appel), chacun recevant uniquement : résumé de la scène + fin de la scène précédente. Empêche la confusion planning/prose sur les contextes longs. + +**Technique 5 — dottxt/outlines pour contraintes de format** +- **URL** : https://github.com/dottxt-ai/outlines +- **Étoiles** : non relevé précisément (projet .txt, très actif) +- **Licence** : Apache-2.0 +- **Principe** : génération contrainte par grammaire EBNF ou regex au niveau logits. Peut forcer l'absence de certains patterns (headers Markdown `##`, listes `- `) pendant la génération. +- **Réutilisable pour ANE** : post-processing ou contrainte de génération pour interdire `\n##`, `\n-`, `\n*`, `\nCHAPITRE`, etc. Compatible Ollama/vLLM/llama.cpp via serveur. + +**Technique 6 — Token budget planning** +Estimer le nombre de tokens attendus pour la scène, injecter l'instruction dans le prompt (`"Cette scène doit faire environ 800 mots. Écris la scène complète jusqu'à sa conclusion naturelle."`). Réduit les `truncated_ending` par prise de conscience du modèle sur sa progression. + +### 5.3 Post-processing détection outline_like + +Heuristiques robustes à implémenter : +- Ratio lignes débutant par `#`, `-`, `*`, chiffre + `.` > seuil → flag `outline_like`. +- Présence de tokens structurants : `CHAPITRE`, `SCÈNE`, `ACTE`, `---`, `===` en début de ligne. +- Longueur de la dernière phrase < 10 tokens → flag `truncated_ending`. +- Entropie des longueurs de phrases : faible entropie (toutes les phrases courtes identiques) → flag `listing_pattern`. + +--- + +## 6. Mistral NeMo 12B — Best practices pour le français littéraire + +### 6.1 Modèle + +- **Modèle officiel** : `mistralai/Mistral-Nemo-Instruct-2407` (Mistral AI) +- **Variante NVIDIA** : `nvidia/Mistral-NeMo-12B-Instruct` +- **Documentation** : https://docs.mistral.ai/models/mistral-nemo-12b-24-07 +- **Format prompt** : `[INST]...[/INST]` (Mistral Instruct) ou ChatML standard. +- **Contexte** : 128k tokens (tokenizer Tekken, meilleure gestion du français que SentencePiece standard). +- **Points forts** : français natif de premier rang parmi les 12B locaux, créatif, contexte long. + +### 6.2 Pratiques documentées pour la fiction en français + +1. **System prompt en français** : le modèle répond mieux quand la langue de la consigne correspond à la langue de génération. +2. **Éviter les instructions en anglais dans un prompt français** : crée un biais de basculement de langue partiel. +3. **Température** : 0.7–0.9 pour la prose littéraire, 0.4–0.6 pour la critique/évaluation. +4. **Top-p** : 0.9–0.95 pour la prose (évite les répétitions à basse température). +5. **Repeat penalty** : 1.1–1.15 pour éviter les boucles lexicales sur les textes longs. +6. **Format instruct ChatML** : préférer le format natif Mistral `[INST]` pour les modèles GGUF via llama.cpp. ChatML peut causer des dérives sur certaines quant. +7. **NemoMix-Unleashed-12B** : fine-tune communautaire orienté roleplay/créatif (`marinaraspaghetti/NemoMix-Unleashed-12B`) — à évaluer si le modèle de base montre des limites de registre. + +### 6.3 Ressources + +- Prompting guide officiel : https://docs.mistral.ai/guides/prompting_capabilities +- Modèle GGUF sur Ollama : `ollama pull mistral-nemo:12b-instruct-2407-q8_0` +- Leaderboard créatif 2026 : https://eqbench.com/creative_writing.html (Mistral NeMo se positionne dans le tier intermédiaire sur l'EQ-Bench) + +--- + +## 7. Tableau de synthèse — Réutilisabilité immédiate pour ANE + +| Priorité | Projet / Outil | Action recommandée | Impact sur ANE | +|---|---|---|---| +| P0 | `lars76/story-evaluation-llm` (15 critères) | Adopter la grille de 15 critères comme base du gate, remplacer l'heuristique actuelle | Résout outline_like, truncated_ending de façon structurée | +| P0 | `prometheus-eval/prometheus-eval` | Intégrer Prometheus-7B local comme LLM-as-judge dans le gate | Gate qualitatif reproductible, rubrique customisable | +| P1 | Pattern GOAT (séparation planning/prose) | Ne jamais passer l'outline brut au prompt draft. Convertir en résumé narratif neutre | Réduit outline_like à la source | +| P1 | Output primer technique | Amorcer le prompt de génération avec le début de la phrase | Réduit truncated_ending et structure Markdown | +| P1 | `confident-ai/deepeval` G-Eval | Wrapper les critères narratifs dans des G-Eval metrics, intégration pytest | CI/CD qualité narrative dans tests/test_generation_pipeline.py | +| P2 | SCORE état épisodique | Implémenter un state tracker (active/lost/destroyed) dans la mémoire ANE | Cohérence narrative long terme | +| P2 | RecurrentGPT mémoire double | Pattern mémoire court terme (prompt) + long terme (fichier) dans core/memory | Génération arbitrairement longue sans drift | +| P2 | Novel-OS Continuity Guardian | Ajouter un agent "guardian" post-draft qui produit un CONTINUITY_REPORT | Gate enrichi au-delà de la qualité stylistique | +| P3 | `dottxt/outlines` contraintes logits | Interdire les patterns Markdown en génération si le runtime le supporte | Correction outline_like au niveau token | +| P3 | `X-PLUG/WritingBench` critère model | Déployer le modèle critique Qwen-7B local pour scoring multi-critères | Alternative au LLM-as-judge full-size | +| P3 | Token budget planning | Injecter estimation de longueur dans le prompt draft | Réduit truncated_ending | + +--- + +## 8. Projets à ne pas adopter (raisons) + +| Projet | Raison d'exclusion | +|---|---| +| `KazKozDev/NovelGenerator` | TypeScript/React, pas Python. Architecture non portable directement. | +| `datacrystals/AIStoryWriter` | AGPL-3.0 : contrainte de publication si distribué en service. Architecture moins modulaire qu'ANE. | +| `SillyTavern` complet | UI orientée chat roleplay, pas pipeline batch. Adopter les patterns, pas le système. | +| LangChain / LangGraph complets | Overhead architectural disproportionné. ANE n'a pas besoin d'orchestration générale. | + +--- + +## Sources + +- [GOAT-Storytelling-Agent](https://github.com/GOAT-AI-lab/GOAT-Storytelling-Agent) +- [AIStoryWriter](https://github.com/datacrystals/AIStoryWriter) +- [NovelGenerator](https://github.com/KazKozDev/NovelGenerator) +- [Novel-OS (mrigankad)](https://github.com/mrigankad/Novel-OS) +- [Novel-Writer (curvedinf)](https://github.com/curvedinf/novel-writer) +- [Book-OS (forsonny)](https://github.com/forsonny/book-os) +- [RecurrentGPT](https://github.com/aiwaves-cn/RecurrentGPT) +- [EQ-Bench Creative Writing](https://github.com/EQ-bench/creative-writing-bench) +- [lechmazur/writing benchmark](https://github.com/lechmazur/writing) +- [WritingBench (X-PLUG)](https://github.com/X-PLUG/WritingBench) +- [story-evaluation-llm (lars76)](https://github.com/lars76/story-evaluation-llm) +- [prometheus-eval](https://github.com/prometheus-eval/prometheus-eval) +- [DeepEval (confident-ai)](https://github.com/confident-ai/deepeval) +- [JudgeLM (BAAI)](https://github.com/baaivision/JudgeLM) +- [quotient-ai/judges](https://github.com/quotient-ai/judges) +- [dottxt-ai/outlines](https://github.com/dottxt-ai/outlines) +- [Awesome-Story-Generation](https://github.com/yingpengma/Awesome-Story-Generation) +- [SillyTavern-MemoryBooks](https://github.com/aikohanasaki/SillyTavern-MemoryBooks) +- [Timeline Memory](https://github.com/unkarelian/timeline-memory) +- [TunnelVision](https://github.com/Coneja-Chibi/TunnelVision) +- [KoboldCpp](https://github.com/LostRuins/koboldcpp) +- [SCORE framework (arXiv 2503.23512)](https://arxiv.org/html/2503.23512v1) +- [Mozilla AI — Local LLM-as-judge avec Prometheus](https://blog.mozilla.ai/local-llm-as-judge-evaluation-with-lm-buddy-prometheus-and-llamafile/) +- [Mistral Nemo documentation](https://docs.mistral.ai/models/mistral-nemo-12b-24-07) +- [EQ-Bench leaderboard créatif](https://eqbench.com/creative_writing.html) +- [Awesome-LLM-as-a-judge](https://github.com/llm-as-a-judge/Awesome-LLM-as-a-judge) diff --git a/docs/OSS_RUNTIME_EVAL_2026-03-21.md b/docs/OSS_RUNTIME_EVAL_2026-03-21.md new file mode 100644 index 0000000..aeffa9b --- /dev/null +++ b/docs/OSS_RUNTIME_EVAL_2026-03-21.md @@ -0,0 +1,91 @@ +# Veille runtime + eval - 21 mars 2026 + +Veille sourcee orientee refonte ANE. Objectif: identifier les briques reutilisables sans diluer le pipeline narratif dans un framework generique. + +## Sources principales + +- GOAT-Storytelling-Agent: https://github.com/GOAT-AI-lab/GOAT-Storytelling-Agent +- Prometheus-Eval: https://github.com/prometheus-eval/prometheus-eval +- DeepEval: https://github.com/confident-ai/deepeval +- LM Format Enforcer: https://github.com/noamgat/lm-format-enforcer +- Outlines: https://github.com/dottxt-ai/outlines +- llama.cpp: https://github.com/ggml-org/llama.cpp +- SCORE: https://arxiv.org/abs/2503.23512 + +## Ce qui ressort pour ANE + +### 1. GOAT valide le pattern "plan -> scene summary -> prose" + +- GOAT garde plusieurs etapes de planification avant l'ecriture scene par scene. +- Le pipeline injecte `previous_scene` dans `write_a_scene`, ce qui confirme le pattern de continuite locale deja recherche dans ANE. +- Point d'application ANE: + - convertir la `structure` JSON en resume narratif compact avant `draft` + - injecter le dernier scene/chapitre accepte de facon plus explicite que l'outline brut + +## 2. Prometheus est le meilleur candidat court terme pour remplacer une partie du gate heuristique + +- Le projet expose `absolute_grade` et `relative_grade`. +- Les rubrics sont completement pilotables par l'appelant. +- Le mode batch est annonce comme >10x plus rapide que les variantes single. +- Point d'application ANE: + - garder les heuristiques locales comme garde-fou rapide + - ajouter un juge secondaire `absolute_grade` pour `outline_like`, continuite, resolution, densite dramatique + - reserver `relative_grade` au comparatif `draft_v1` vs `draft_v2` + +## 3. DeepEval est pertinent pour industrialiser les tests d'eval + +- DeepEval peut tourner sans integration pytest, mais s'integre aussi tres bien dans un pipeline de tests. +- Le projet montre un pattern dataset + parametrisation pytest, directement compatible avec la structure `tests/` d'ANE. +- Point d'application ANE: + - ne pas l'utiliser comme moteur principal du gate + - l'utiliser pour des suites d'evaluation regressives sur un corpus fixe de chapitres/smokes + +## 4. LM Format Enforcer est la meilleure piste "faible friction" pour les sorties structurees + +- Support explicite JSON Schema, regex, `llama.cpp`, vLLM, transformers. +- Le projet insiste sur une integration dans les pipelines existants, sans rewriter toute la boucle d'inference. +- Point d'application ANE: + - priorite si on introduit un runtime vLLM ou un server OpenAI-compatible pilotable + - utile pour `critique`, `gate`, `memory` + - moins utile si ANE reste exclusivement derriere le shim Mascarade actuel qui ne propage pas encore toutes les contraintes de format + +## 5. Outlines est plus ambitieux mais plus intrusif + +- Outlines promet des sorties structurees garanties, directement "during generation". +- Le modele d'API est tres elegant, mais plus proche d'un substrate de generation que d'un simple add-on. +- Point d'application ANE: + - interessant si ANE controle un runtime Python local de bout en bout + - moins prioritaire que LM Format Enforcer pour une integration rapide sur le chemin actuel `OpenAI-compatible -> runtime` + +## 6. llama.cpp confirme une contrainte strategique deja observee sur le terrain + +- `llama-server` supporte les grammaires GBNF et un fichier JSON de grammaire. +- Cela confirme que le chemin `llama.cpp` doit etre traite comme un runtime de premier rang, pas juste comme contournement de secours. +- Point d'application ANE: + - elevage de `llama.cpp` au rang de profil runtime officiel + - exploration ulterieure de grammaires pour les etapes JSON + +## 7. SCORE reste une reference conceptuelle forte pour la memoire + +- Le papier formalise state tracking + episode summaries + retrieval hybride. +- Point d'application ANE: + - ne pas copier l'architecture telle quelle + - reutiliser ses idees pour un futur lot "memoire episode/state" sans abandonner le stockage fichier + +## Priorites recommandees + +1. Phase immediate + - finir l'extraction `core/runtime/*` + - brancher `next_lots` et les probes dessus +2. Phase eval + - ajouter un spike Prometheus sur quelques chapitres reels + - garder DeepEval pour les regressions offline +3. Phase structured output + - prioriser LM Format Enforcer avant Outlines + - explorer GBNF/grammaires via `llama.cpp` seulement si le runtime le permet proprement + +## Decision de refonte + +- ANE garde le pipeline narratif, le gate local et les artefacts. +- Mascarade / `llama.cpp` restent des substrates runtime. +- Les frameworks d'eval et de generation structuree doivent rester optionnels et branchables, jamais absorbés au coeur du pipeline. diff --git a/docs/REPOS_ANNEXES_2026-03-23.md b/docs/REPOS_ANNEXES_2026-03-23.md new file mode 100644 index 0000000..27d0b44 --- /dev/null +++ b/docs/REPOS_ANNEXES_2026-03-23.md @@ -0,0 +1,85 @@ +# Manifeste des repos annexes - 23 mars 2026 + +Cartographie courte des repos lies a `ai-novel-engine`, avec statut canonique, frontieres et decisions de gouvernance. + +## Objectif + +Eviter trois derives: + +- confondre le moteur ANE avec le runtime local +- confondre un client ANE avec le moteur lui-meme +- laisser des forks, copies partielles ou projets narratifs parler au nom d'ANE sans statut explicite + +## Doctrine + +- `ai-novel-engine` reste la source de verite du pipeline auteurial, des garde-fous, des prompts narratifs et des reports ANE +- `mascarade` reste la source de verite du runtime local OpenAI-compatible, du serving et des checkpoints runtime +- un client ANE ne doit pas reconstruire librement l'arborescence interne d'ANE sans contrat stable +- un projet narratif consommateur ne doit pas etre traite comme spec du moteur +- un fork partiel ou snapshot incomplet ne doit pas rester ambigu: soit archive, soit reconstruit, soit supprime des points d'entree + +## Carte + +```mermaid +flowchart LR + ANE["ai-novel-engine\npipeline auteurial canonique"] --> M["mascarade\nruntime compagnon canonique"] + ANE --> ST["app_AI-novel-engine\nclient macOS direct"] + ANE --> HV["histoire-de-vie\nprojet consommateur legacy"] + ANE --> FL["FULL_LIFE\ncopie/fork partiel"] + M --> MA["mascarade_app\nclient runtime adjacent"] +``` + +## Statut canonique + +| Repo | Role vis-a-vis d'ANE | Couplage | Statut canonique | Source de verite | Decision | +|------|----------------------|----------|------------------|------------------|----------| +| `mascarade` | Runtime local, serving OpenAI-compatible, checkpoints, outillage remote | direct | canonique | `automation/next_lots.toml` cote ANE + `TODO_AI_NOVEL_ENGINE.md` cote Mascarade | garder comme unique repo runtime compagnon | +| `app_AI-novel-engine` | Client macOS ANE, UI auteur, Keychain, Git, pilotage du pipeline | direct | canonique mais frontiere a formaliser | docs du repo app + contrat ANE a expliciter | garder comme client direct, avec contrat runner stable | +| `histoire-de-vie` | Projet narratif historique avec prompts/chapitres annuels | indirect | legacy, non canonique pour le moteur | le projet lui-meme; pour le chantier courant preferer `histoire-de-vie-work` | ne plus l'utiliser comme reference du pipeline ANE | +| `FULL_LIFE` | Snapshot/fork partiel autour d'une biographie et d'une copie minimale d'ANE | confus | non canonique | aucune source stable exploitable | archiver, fusionner ou reconstruire; ne pas laisser ambigu | +| `mascarade_app` | Client macOS du runtime Mascarade, oriente ops/kanban | indirect | adjacent, hors chemin critique ANE | docs propres du repo | garder hors perimetre ANE tant qu'aucun contrat ANE explicite n'est consomme | + +## Frontieres attendues + +### 1. Ce qu'ANE possede + +- pipeline auteurial +- logique `intention -> structure -> draft -> critique -> rewrite -> gate -> validation -> memoire` +- prompts narratifs +- artefacts ANE +- reports et suivi ANE + +### 2. Ce que Mascarade possede + +- compatibilite OpenAI +- routage provider +- runtime local/remote +- healthchecks runtime +- checkpoints de switch runtime + +### 3. Ce qu'un client ANE peut posseder + +- interface utilisateur +- edition de projet +- Keychain, persistance locale, Git utilisateur +- lancement du pipeline ANE via un contrat stable + +### 4. Ce qu'un projet narratif possede + +- contenu +- contexte +- chronologie +- docs editoriales et trous documentaires + +## Anti-patterns a eviter + +- un repo projet qui se presente comme `AI Novel Engine` alors qu'il n'embarque pas le moteur complet +- un client qui parse directement `meta.json` et suppose la forme interne des artefacts sans contrat versionne +- un repo runtime qui devient spec implicite du moteur +- plusieurs points d'entree README qui racontent des perimetres differents sans statut explicite + +## Suites recommandees + +1. Implementer la commande `runner execute` conforme au contrat [`ANE_RUNNER_CONTRACT_V1_2026-03-23.md`](./ANE_RUNNER_CONTRACT_V1_2026-03-23.md) +2. Marquer `histoire-de-vie` comme consommateur legacy et sortir `FULL_LIFE` du flou +3. Garder `mascarade` comme unique repo runtime de reference pour ANE diff --git a/docs/SYSTEM_SPEC_2026-03-16.md b/docs/SYSTEM_SPEC_2026-03-16.md new file mode 100644 index 0000000..28274ef --- /dev/null +++ b/docs/SYSTEM_SPEC_2026-03-16.md @@ -0,0 +1,139 @@ +# System Spec - 16 mars 2026 + +Spec operative du systeme `ai-novel-engine`. + +## Intention produit + +Donner a un auteur un moteur de redaction longue: + +- explicite +- relancable +- inspectable sur disque +- decouple du runtime LLM + +## Invariants + +- pas de generation sans intention +- pas de promotion manuscrit sans `gate` +- la memoire reste en fichiers, pas en base opaque +- le runtime local doit rester interchangeable derriere un contrat OpenAI-compatible minimal + +## Topologie + +```mermaid +flowchart LR + Auteur["Auteur"] --> CLI["CLI publique"] + CLI --> Pipeline["GenerationPipeline"] + Pipeline --> Gate["IntentionGate + ManuscriptGate"] + Pipeline --> Prompts["PromptStore"] + Pipeline --> Provider["OpenAICompatibleProvider"] + Provider --> Runtime["Mascarade / llama-server / Apple runtime"] + Pipeline --> FS["Artefacts markdown + json"] + FS --> Status["ProjectState / status / TUI ops"] + FS --> Automation["next_lots + reports"] +``` + +## Workflow narratif + +```mermaid +flowchart TD + A["Intention presente"] --> B["structure"] + B --> C["draft_v1"] + C --> D["critique_v1 (json)"] + D --> E["draft_v2"] + E --> F{"gate OK ?"} + F -- "non" --> G["repair_vN"] + G --> F + F -- "oui" --> H{"validation auteur"} + H -- "non" --> I["status=rejected"] + H -- "oui" --> J["manuscrit"] + J --> K["memory update"] + K --> L["status=accepted"] +``` + +## Contrat runtime + +Le contrat stable cote ANE est volontairement petit: + +- `POST /v1/chat/completions` +- `model=provider:model` +- reponse non-streaming +- JSON best-effort pour `critique`, `gate`, `memory` + +Refonte en cours: + +- `core/runtime/*` porte les profils runtime, contraintes explicites et checks de sante +- `core/generation/*` garde le pipeline narratif, le gate et les retries applicatifs + +## Surfaces utilisateur + +- CLI auteur: `python3 -m cli.main` +- smoke: `scripts/smoke_local_generation.sh` +- orchestration lots: `scripts/run_next_lots.py` +- supervision lots: `scripts/next_lots_tui.py` +- supervision ops globale: `scripts/ops_tui.py` +- synthese/purge reports: `scripts/reports_ops.py` + +## Artefacts + +| Zone | Raison d'etre | Format | +|---|---|---| +| `notes/intentions/` | entree auteur obligatoire | Markdown | +| `structure/chapitres/` | plan de chapitre | Markdown | +| `brouillons/chapitres/` | drafts, critique, gate, repair, meta | Markdown + JSON | +| `manuscrit/` | chapitre accepte | Markdown | +| `memoire/chapitres/` | resume memoire par chapitre | Markdown | +| `memoire/index/` | personnages, lieux, chronologie | JSON | +| `automation/reports/` | evidence packs d'orchestration | JSON + logs + workspaces | + +## Etats principaux + +- `started` +- `structure_ready` +- `draft_ready` +- `critique_ready` +- `rewrite_ready` +- `repair_ready` +- `awaiting_acceptance` +- `accepted` +- `rejected` +- `quality_blocked` +- `failed` + +## Failure model + +```mermaid +sequenceDiagram + participant O as Operateur + participant A as ai-novel-engine + participant R as Runtime local + participant F as Filesystem + + O->>A: lancer chapter ou lot + A->>R: requete OpenAI-compatible + alt runtime indisponible + R-->>A: erreur reseau / HTTP 500 + A->>F: meta.json status=failed + else prose invalide + R-->>A: texte ou JSON defectueux + A->>A: retry JSON ou repair + A->>F: meta.json + gate/report + else garde-fou bloque + A->>F: status=quality_blocked + else succes complet + A->>F: manuscrit + memoire + accepted + end +``` + +## Hotspots techniques + +- `core/generation/pipeline.py`: logique centrale et heuristiques qualite +- `core/next_lots.py`: orchestration, checkpoints, synchronisation docs +- `scripts/reports_ops.py` et `scripts/ops_tui.py`: observabilite operateur + +## Ce que le systeme ne fait pas encore + +- studio collaboratif riche +- edition visuelle des artefacts +- persistence multi-utilisateur +- orchestration autonome idee -> roman final diff --git a/docs/SYSTEM_SPEC_2026-03-21.md b/docs/SYSTEM_SPEC_2026-03-21.md new file mode 100644 index 0000000..6ad866a --- /dev/null +++ b/docs/SYSTEM_SPEC_2026-03-21.md @@ -0,0 +1,75 @@ +# System Spec - 21 mars 2026 + +Spec operative du systeme `ai-novel-engine` apres extraction de la couche runtime minimale. + +## Intention produit + +Donner a un auteur un moteur de redaction longue : + +- explicite +- relancable +- inspectable sur disque +- decouple du runtime LLM + +## Invariants + +- pas de generation sans intention +- pas de promotion manuscrit sans `gate` +- la memoire reste en fichiers, pas en base opaque +- le runtime local doit rester interchangeable derriere un contrat OpenAI-compatible minimal +- les contraintes runtime doivent etre encodees dans une couche dediee, pas dispersees dans le pipeline + +## Topologie + +```mermaid +flowchart LR + Auteur["Auteur"] --> CLI["CLI publique"] + CLI --> Pipeline["GenerationPipeline"] + Pipeline --> Gate["IntentionGate + ManuscriptGate"] + Pipeline --> Prompts["PromptStore"] + Pipeline --> Provider["OpenAICompatibleProvider"] + Provider --> RuntimeLayer["core/runtime/*"] + RuntimeLayer --> Runtime["Mascarade / llama-server / Apple runtime"] + Pipeline --> FS["Artefacts markdown + json"] + FS --> Status["ProjectState / status / TUI ops"] + FS --> Automation["next_lots + reports"] +``` + +## Contrat runtime + +Le contrat stable cote ANE reste volontairement petit : + +- `POST /v1/chat/completions` +- `model=provider:model` +- reponse non-streaming +- JSON best-effort, avec reessai applicatif cote ANE + +## Contrat runner app + +Le pont entre un client applicatif et ANE ne doit plus reposer sur le parse direct de `meta.json`. + +Le contrat canonique cible est documente dans: + +- [`ANE_RUNNER_CONTRACT_V1_2026-03-23.md`](./ANE_RUNNER_CONTRACT_V1_2026-03-23.md) + +Ce contrat borne: + +- la commande runner stable cote ANE +- la requete JSON envoyee par un client +- le resultat JSON public restitue au client + +Le mode historique `python3 -m cli.main generate chapter --chapter XX` reste un mode legacy de transition, pas l'interface applicative de long terme. + +## Capacites runtime a expliciter + +- support reel de `response_format` +- besoin de switch manuel de modele +- provider actif derive de `provider:model` +- sante runtime lisible via `/health` + +## Hotspots techniques + +- `core/runtime/client.py` : transport OpenAI-compatible +- `core/generation/pipeline.py` : logique narrative et garde-fous +- `core/next_lots.py` : orchestration a decoupler +- `app_AI-novel-engine/.../ANEPipelineService.swift` : pont app -> CLI ANE diff --git a/docs/governance.md b/docs/governance.md index ccdda97..78f3875 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -1 +1,27 @@ -Gouvernance légère du projet. +# Gouvernance legere + +Regles de gouvernance adaptees a un repo local-first, pilote par evidence. + +## Source de verite + +- le code et les tests priment +- les documents dates (`CONTEXTE`, `MEMOIRE_REPRISE`, `EXECUTION_PLAN`) servent de point de reprise +- les blocs `AUTO-SYNC` reflètent le dernier etat automatise, pas l'etat live instantane + +## Priorisation + +- runtime stable avant tuning prompts +- blockers produits avant optimisation cosmetique +- corrections chirurgicales avant refontes larges + +## Changements + +- un changement doit idealement livrer: code, verification, doc courte +- les suppressions de logs ou de reports se font d'abord en dry-run +- les docs ne doivent pas masquer un service down ou un lot incomplet + +## Definition du done + +- comportement verifie +- impact doc minimal mis a jour +- prochain pas explicite dans `TODO_ACTIVE.md` ou le plan courant diff --git a/docs/principes.md b/docs/principes.md index d08920b..d750857 100644 --- a/docs/principes.md +++ b/docs/principes.md @@ -1 +1,33 @@ -Principes fondateurs du système. +# Principes + +Principes simples qui doivent survivre aux changements de runtime, de prompts et d'outillage. + +## Auteur d'abord + +- l'auteur decide de l'intention +- l'auteur decide de la promotion manuscrit +- le systeme aide a ecrire, il ne remplace pas la direction du projet + +## Lisibilite avant magie + +- chaque etape doit laisser un artefact lisible +- chaque echec doit produire une trace compréhensible +- chaque lot automatique doit pouvoir etre repris + +## Runtime decouple + +- le pipeline narratif ne doit pas dependre d'un backend unique +- le contrat stable reste OpenAI-compatible +- les changements runtime ne doivent pas forcer une recriture auteuriale + +## Guardrails reels + +- pas de generation sans intention +- pas de manuscrit sans `gate` +- `repair` sert a sauver un brouillon, pas a contourner les regles + +## Simplicite operationnelle + +- TUI et scripts avant plateformes lourdes +- dry-run avant purge +- evidence avant intuition diff --git a/docs/runbooks/AUTOMATION.md b/docs/runbooks/AUTOMATION.md new file mode 100644 index 0000000..4419430 --- /dev/null +++ b/docs/runbooks/AUTOMATION.md @@ -0,0 +1,109 @@ +# Runbook Automation + +Runbook court pour piloter `next_lots`, lire les reports et nettoyer sans casser l'historique utile. + +## Vue d'ensemble + +- orchestrateur: `python3 scripts/run_next_lots.py` +- TUI lot courant: `python3 scripts/next_lots_tui.py --watch --interval 2` +- TUI ops global: `python3 scripts/ops_tui.py --watch --interval 3` +- TUI remote Mascarade: `python3 scripts/mascarade_remote_tui.py --watch --interval 4` +- synthese reports: `python3 scripts/reports_ops.py summary` +- analyse logs: `python3 scripts/reports_ops.py analyze-logs --top 10` + +## Boucle recommandee + +```bash +python3 scripts/ops_tui.py --watch --interval 3 +python3 scripts/next_lots_tui.py --watch --interval 2 +``` + +Quand un checkpoint apparait: + +1. lire la raison dans le TUI +2. executer la commande preparee +3. reprendre avec `python3 scripts/run_next_lots.py --resume automation/state/next_lots_state.json` + +## Lots utiles + +```bash +python3 scripts/run_next_lots.py --lot priority_models +python3 scripts/run_next_lots.py --lot baselines +python3 scripts/run_next_lots.py --lot tracking_sync +python3 scripts/run_next_lots.py --lot full +``` + +## Logs + +Lecture rapide: + +```bash +python3 scripts/reports_ops.py analyze-logs --top 10 +``` + +Ce que l'outil fait maintenant: + +- agrege les erreurs `STDERR` +- rattache les logs aux vrais modeles via `run.json` +- evite les pseudo-noms deformes du style `ollama:qwen2:5:7b` + +## Purge chirurgicale + +Toujours commencer par un dry-run: + +```bash +python3 scripts/reports_ops.py prune --days 14 +python3 scripts/reports_ops.py prune --days 14 --delete-workspaces +``` + +Suppression effective seulement si la retention est claire: + +```bash +python3 scripts/reports_ops.py prune --days 14 --delete-workspaces --apply +``` + +Regle: + +- ne pas supprimer les reports encore utiles au comparatif courant +- preferer supprimer d'abord les `workspaces/` anciens si l'on veut alleger sans perdre `run.json` et les logs + +## Commandes de reprise runtime + +- runtime Apple seulement: verifier `http://127.0.0.1:8201/models` +- runtime `llama.cpp`: utiliser `bash ./scripts/prepare_llama_cpp_runtime.sh --model qwen2.5:7b --port 8091` +- reprendre ensuite le lot ANE avec `--resume` + +## Multi-host Mascarade (tower / kxkm) + +Config centralisee: + +```bash +cat automation/mascarade_hosts.toml +``` + +Cockpit distant: + +```bash +python3 scripts/mascarade_remote_tui.py --watch --interval 4 +``` + +Tunnel manuel (exemples): + +```bash +ssh -N -L 127.0.0.1:8110:127.0.0.1:8100 clems@192.168.120 +ssh -N -L 127.0.0.1:8111:127.0.0.1:8100 kxkm@kxkm-ai +``` + +## Persistance launchd (macOS) + +```bash +python3 scripts/setup_mascarade_launchd.py render +python3 scripts/setup_mascarade_launchd.py install --dry-run +python3 scripts/setup_mascarade_launchd.py install +python3 scripts/setup_mascarade_launchd.py status +``` + +Plists de reference versionnes: + +- `automation/launchd/com.ai-novel-engine.mascarade.tower.tunnel.plist` +- `automation/launchd/com.ai-novel-engine.mascarade.kxkm.tunnel.plist` diff --git a/docs/runbooks/RECOVERY_PROCEDURES.md b/docs/runbooks/RECOVERY_PROCEDURES.md new file mode 100644 index 0000000..ff16014 --- /dev/null +++ b/docs/runbooks/RECOVERY_PROCEDURES.md @@ -0,0 +1,39 @@ +# Runbook Recovery - ANE + +Procedures courtes de recuperation quand un lot est interrompu ou quand des artefacts JSON sont corrompus. + +## 1) Reprendre un lot interrompu + +```bash +python3 scripts/run_next_lots.py --resume automation/state/next_lots_state.json +``` + +## 2) Detecter un JSON corrompu + +```bash +python3 -m json.tool brouillons/chapitres/chapitre_01/meta.json > /dev/null +``` + +## 3) Recovery metadata chapitre + +```bash +cp brouillons/chapitres/chapitre_01/meta.json /tmp/meta_chapitre_01.backup.json +rm brouillons/chapitres/chapitre_01/meta.json +python3 -m cli.main generate chapter --chapter 01 +``` + +## 4) Recovery automation state + +```bash +cp automation/state/next_lots_state.json /tmp/next_lots_state.backup.json +rm automation/state/next_lots_state.json +python3 scripts/run_next_lots.py --lot priority_models +``` + +## 5) Logs et purge chirurgicale + +```bash +python3 scripts/reports_ops.py analyze-logs --top 15 +python3 scripts/reports_ops.py prune --days 14 --delete-workspaces +python3 scripts/reports_ops.py prune --days 14 --delete-workspaces --apply +``` diff --git a/docs/templates/ANE_STRUCTURE_TEMPLATE.md b/docs/templates/ANE_STRUCTURE_TEMPLATE.md new file mode 100644 index 0000000..a7f92c6 --- /dev/null +++ b/docs/templates/ANE_STRUCTURE_TEMPLATE.md @@ -0,0 +1,30 @@ +# Structure - chapitre_01 + +## Objectif dramatique +Poser ce que ce chapitre doit faire avancer concretement dans le recit. + +## Tension +Nommer la pression immediate : risque, menace, manque, secret, delai, dette, poursuite. + +## Scenes +### Scene 1 - titre +- objectif: ce que la scene doit obtenir +- conflit: ce qui resiste ici et maintenant +- sortie: l'etat concret a la fin de la scene + +### Scene 2 - titre +- objectif: ce qui doit se compliquer ou se retourner +- conflit: opposition, cout, revelation ou erreur +- sortie: consequence immediate qui pousse vers la suite + +### Scene 3 - titre +- objectif: forcer une decision couteuse +- conflit: ce que le personnage risque de perdre +- sortie: acte final, consequence observable, fermeture nette + +Repere pratique : + +- rester en scenes jouables, pas en resume de chapitre +- chaque `sortie` doit modifier la situation +- eviter les listes de lore sans action, qui finissent en `outline_like` + diff --git a/docs/templates/FICHE_PERSONNAGES.docx b/docs/templates/FICHE_PERSONNAGES.docx new file mode 100644 index 0000000000000000000000000000000000000000..0598ce760cfa56c5aa5e0480310ed8430c1b4778 GIT binary patch literal 4069 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 + + + + Fiche Personnages + + +

Template fiches personnages AI Novel Engine

+ +

Consigne

+

Remplir surtout ce qui produit des scenes : role, desir, contradiction, secret, levier de pression, evolution.

+ +

Personnage 1

+

Nom : ...

+

Role : ...

+

Desir : ...

+

Contradiction : ...

+

Blessure ou manque : ...

+

Mensonge personnel : ...

+

Secret : ...

+

Ce qui le met sous pression : ...

+

Voix / presence : ...

+

Evolution attendue : ...

+ +

Personnage 2

+

Nom : ...

+

Role : ...

+

Desir : ...

+

Contradiction : ...

+

Blessure ou manque : ...

+

Mensonge personnel : ...

+

Secret : ...

+

Ce qui le met sous pression : ...

+

Voix / presence : ...

+

Evolution attendue : ...

+ +

Personnage 3

+

Nom : ...

+

Role : ...

+

Desir : ...

+

Contradiction : ...

+

Blessure ou manque : ...

+

Mensonge personnel : ...

+

Secret : ...

+

Ce qui le met sous pression : ...

+

Voix / presence : ...

+

Evolution attendue : ...

+ +

Matrice relationnelle

+

Personnage A / Personnage B : alliance, dette, attraction, rivalite, domination, dette ancienne.

+

Personnage A / Personnage C : ...

+

Personnage B / Personnage C : ...

+ +

Traduction ANE

+

Role : une phrase nette.

+

Desire : quelque chose de concret et actionnable.

+

Contradiction : ce qui rend les choix couteux.

+ + + diff --git a/docs/templates/PACK_FANTASY.docx b/docs/templates/PACK_FANTASY.docx new file mode 100644 index 0000000000000000000000000000000000000000..5d0bf5d04b25f7061503f0ba3c3a92d92c21a094 GIT binary patch literal 4417 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 + + + + Pack Fantasy + + +

Pack fantasy AI Novel Engine

+ +

1. Base du projet

+

Titre : ...

+

Sous-genre : epic, dark, sword and sorcery, romantasy, low fantasy.

+

Logline : ...

+

Promesse d'univers : ...

+ +

2. Fondations du monde

+

Cadre : royaumes, cites, frontieres, lieux sacrifies.

+

Regle de magie : source, cout, interdits, limites.

+

Ordre politique : cour, guildes, clerge, empires, factions.

+

Ressource ou relique decisive : ...

+

Catastrophe latente : ...

+ +

3. Axe dramatique

+

Hero ou heroine : ...

+

Desir : ...

+

Contradiction : ...

+

Adversaire : ...

+

Prix de la quete : ...

+ +

4. Factions et personnages

+

Protagoniste : role, desir, contradiction.

+

Allie majeur : role, desir, contradiction.

+

Antagoniste : role, desir, contradiction.

+

Faction 1 : interet, force, faille.

+

Faction 2 : interet, force, faille.

+ +

5. Arc du roman

+

Appel : ...

+

Franchissement : ...

+

Epreuves : ...

+

Revelation : ...

+

Sacrifice : ...

+

Climax : ...

+ +

6. Plan 12 chapitres

+

Ch. 01-03 : monde, faille, premier engagement.

+

Ch. 04-06 : voyage, alliances, cout de la magie.

+

Ch. 07-09 : revelation, trahison, perte.

+

Ch. 10-12 : siege, duel, sacrifice, nouvel ordre.

+ +

7. Garde-fous ANE

+
    +
  • Ne pas transformer le worldbuilding en exposition statique.
  • +
  • Faire passer les regles du monde par des choix et des couts visibles.
  • +
  • Chaque chapitre doit modifier une relation, une position ou un risque.
  • +
+ + + diff --git a/docs/templates/PACK_HUIS_CLOS.docx b/docs/templates/PACK_HUIS_CLOS.docx new file mode 100644 index 0000000000000000000000000000000000000000..b30838de70d8b2b486c87638a0068c0e7ea5becb GIT binary patch literal 4201 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 literal 0 HcmV?d00001 diff --git a/docs/templates/PACK_HUIS_CLOS_SOURCE.html b/docs/templates/PACK_HUIS_CLOS_SOURCE.html new file mode 100644 index 0000000..53c6dd2 --- /dev/null +++ b/docs/templates/PACK_HUIS_CLOS_SOURCE.html @@ -0,0 +1,43 @@ + + + + + Pack Huis Clos + + +

Pack huis clos AI Novel Engine

+ +

1. Base du projet

+

Titre : ...

+

Lieu clos : appartement, train, station, chalet, navire, salle de garde.

+

Cause du confinement : tempete, panne, menace, quarantaine, crime, siege.

+

Logline : ...

+ +

2. Regles du clos

+

Pourquoi personne ne peut partir : ...

+

Ce qui manque : air, temps, confiance, nourriture, courant, preuve.

+

Ce qui augmente la pression : ...

+

Ce qui peut casser le groupe : ...

+ +

3. Distribution

+

Pivot : role, desir, contradiction.

+

Source de menace : role, desir, contradiction.

+

Allie instable : role, desir, contradiction.

+

Temoin ou poids mort : role, desir, contradiction.

+ +

4. Trajectoire

+

Incident : ...

+

Premiere fracture du groupe : ...

+

Revelation centrale : ...

+

Point de non-retour : ...

+

Resolution : ...

+ +

5. Garde-fous ANE

+
    +
  • Exploiter l'espace ferme comme machine dramatique.
  • +
  • Chaque scene doit reduire les options ou detruire une confiance.
  • +
  • Ne pas compenser le lieu unique par trop d'explication hors-scene.
  • +
+ + + diff --git a/docs/templates/PACK_IDEE_ROMAN.docx b/docs/templates/PACK_IDEE_ROMAN.docx new file mode 100644 index 0000000000000000000000000000000000000000..6a301a70323e4b8ecb61b64a527aa1af700e33e5 GIT binary patch literal 4175 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 MW1^WBzIpZZ9}iwaYybcN literal 0 HcmV?d00001 diff --git a/docs/templates/PACK_IDEE_ROMAN_SOURCE.html b/docs/templates/PACK_IDEE_ROMAN_SOURCE.html new file mode 100644 index 0000000..977615d --- /dev/null +++ b/docs/templates/PACK_IDEE_ROMAN_SOURCE.html @@ -0,0 +1,46 @@ + + + + + Pack Idee Roman + + +

Pack personnalisation a partir d'une idee de roman

+ +

1. Idee brute

+

Votre idee en une phrase : ...

+

L'image ou la scene d'origine : ...

+

Ce qui vous obsede dans cette idee : ...

+ +

2. Clarification

+

Qui porte le recit ? ...

+

Que veut ce personnage ? ...

+

Pourquoi ne l'obtient-il pas facilement ? ...

+

Qu'est-ce qui pourrait etre perdu ? ...

+

Quelle promesse de lecture voulez-vous tenir ? ...

+ +

3. Premiere traduction romanesque

+

Logline : ...

+

Synopsis court : ...

+

Genre ou melange de genres : ...

+

Ton : ...

+ +

4. Personnages minimaux

+

Protagoniste : role, desir, contradiction.

+

Force adverse : role, desir, contradiction.

+

Allie ou miroir : role, desir, contradiction.

+ +

5. Premiere structure

+

Depart : ...

+

Fracture : ...

+

Escalade : ...

+

Point de non-retour : ...

+

Fin probable : ...

+ +

6. Conversion ANE

+

Intention chapitre 01 : 3 a 5 lignes courtes.

+

Memoire personnages : role, desir, contradiction.

+

Premier risque de prose : ou votre idee risque de rester abstraite.

+

Premier levier de scene : quel acte, cout ou consequence montrer d'abord.

+ + diff --git a/docs/templates/PACK_LITTERAIRE.docx b/docs/templates/PACK_LITTERAIRE.docx new file mode 100644 index 0000000000000000000000000000000000000000..906eb9f97f8e85f4a38aabbc8d3888c9cb5cd03b GIT binary patch literal 4349 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 + + + + Pack Litteraire + + +

Pack roman litteraire AI Novel Engine

+ +

1. Base du projet

+

Titre : ...

+

Logline : ...

+

Voix : premiere personne, troisieme proche, chorus, adresse.

+

Promesse sensible : ...

+ +

2. Noyau intime

+

Protagoniste : ...

+

Manque ou fente intime : ...

+

Desir visible : ...

+

Contradiction : ...

+

Transformation visee : ...

+ +

3. Monde proche

+

Lieu principal : ...

+

Milieu social : ...

+

Objets, gestes, routines : ...

+

Friction sourde : ...

+ +

4. Personnages

+

Protagoniste : role, desir, contradiction.

+

Autre central : role, desir, contradiction.

+

Figure d'ecart : role, desir, contradiction.

+ +

5. Mouvement du roman

+

Etat initial : ...

+

Premier deplacement : ...

+

Sequence de frottement : ...

+

Craquement majeur : ...

+

Point de verite : ...

+

Retombee ou ouverture finale : ...

+ +

6. Plan 12 chapitres

+

Ch. 01-03 : installation de la voix, manque, premiere dissonance.

+

Ch. 04-06 : rapprochements, deplacements, premiers couts.

+

Ch. 07-09 : aveu, retrait, faille visible.

+

Ch. 10-12 : point de verite, geste irreparable, nouvel etat.

+ +

7. Garde-fous ANE

+
    +
  • Garder une tension concrete meme dans l'infra-ordinaire.
  • +
  • Eviter les chapitres purement meditatives sans bascule observable.
  • +
  • Chaque fin de chapitre doit laisser une trace emotionnelle nette.
  • +
+ + diff --git a/docs/templates/PACK_POLAR.docx b/docs/templates/PACK_POLAR.docx new file mode 100644 index 0000000000000000000000000000000000000000..b6fa7b79f3b5f55d31b43e66d955e2dfa0331377 GIT binary patch literal 4458 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 hzV3m)&SD)acPfRq^IwV(j{{b)|&j|nk literal 0 HcmV?d00001 diff --git a/docs/templates/PACK_POLAR_SOURCE.html b/docs/templates/PACK_POLAR_SOURCE.html new file mode 100644 index 0000000..6e863b1 --- /dev/null +++ b/docs/templates/PACK_POLAR_SOURCE.html @@ -0,0 +1,56 @@ + + + + + Pack Polar + + +

Pack polar / thriller AI Novel Engine

+ +

1. Base du projet

+

Titre : ...

+

Sous-genre : polar procedural, noir, thriller psychologique, huis clos, judiciaire.

+

Logline : ...

+

Crime, disparition ou menace : ...

+ +

2. Axe dramatique

+

Enqueteur ou protagoniste : ...

+

Faille intime : ...

+

Desir : resoudre, proteger, prouver, survivre.

+

Contradiction : ce qui le rend mauvais au bon moment ou inversement.

+

Antagonisme : tueur, systeme, corruption, famille, manipulation.

+ +

3. Dossier affaire

+

Fait initial : ...

+

Version apparente : ...

+

Verite cachee : ...

+

Mobile reel : ...

+

Point de bascule de l'enquete : ...

+ +

4. Personnages cle

+

Enqueteur : role, desir, contradiction.

+

Suspect principal : role, desir, contradiction.

+

Allie fragile : role, desir, contradiction.

+

Temoin dangereux : role, desir, contradiction.

+ +

5. Outils polar

+

Indices vrais : ...

+

Fausses pistes : ...

+

Secrets et mensonges : ...

+

Revelation finale : ...

+ +

6. Plan 12 chapitres

+

Ch. 01-03 : crime, premiere hypothese, erreur initiale.

+

Ch. 04-06 : escalation, soupcons, revelation partielle.

+

Ch. 07-09 : fausse resolution, cout intime, point de non-retour.

+

Ch. 10-12 : verite, confrontation, prix final.

+ +

7. Garde-fous ANE

+
    +
  • Chaque chapitre doit contenir une action d'enquete ou de pression visible.
  • +
  • Chaque fin de chapitre doit requalifier un soupcon ou aggraver un risque.
  • +
  • Ne pas expliquer le crime trop tot : faire porter la verite par les scenes.
  • +
+ + + diff --git a/docs/templates/PACK_ROMANTASY.docx b/docs/templates/PACK_ROMANTASY.docx new file mode 100644 index 0000000000000000000000000000000000000000..443b3965883e2206dbccc5aaff97362309dbbe8d GIT binary patch literal 4252 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 m0{i82n@=Zx*I_k{v(|-V9h;3B> literal 0 HcmV?d00001 diff --git a/docs/templates/PACK_ROMANTASY_SOURCE.html b/docs/templates/PACK_ROMANTASY_SOURCE.html new file mode 100644 index 0000000..e591091 --- /dev/null +++ b/docs/templates/PACK_ROMANTASY_SOURCE.html @@ -0,0 +1,43 @@ + + + + + Pack Romantasy + + +

Pack romantasy AI Novel Engine

+ +

1. Base du projet

+

Titre : ...

+

Promesse romance : enemies to lovers, forbidden love, arranged bond, rivals, slow burn.

+

Promesse fantasy : magie, cour, guerre, quete, malediction.

+

Logline : ...

+ +

2. Couple central

+

Personnage A : desir, peur, faille.

+

Personnage B : desir, peur, faille.

+

Tension relationnelle : attraction, dette, mensonge, statut, clan, devoir.

+

Interdit : ce qui rend l'union couteuse ou impossible.

+ +

3. Monde et pouvoir

+

Cadre : royaume, ordre magique, maison, frontiere.

+

Systeme de pouvoir : ...

+

Factions : ...

+

Menace externe : ...

+ +

4. Progression

+

Rencontre / collision : ...

+

Alliance contrainte : ...

+

Rapprochement : ...

+

Trahison ou revelation : ...

+

Choix final : amour, pouvoir, sacrifice, renoncement.

+ +

5. Garde-fous ANE

+
    +
  • Faire avancer en meme temps la relation et l'intrigue externe.
  • +
  • Chaque chapitre doit produire une intensification emotionnelle ou politique.
  • +
  • Ne pas laisser la tension relationnelle se reduire a des declarations abstraites.
  • +
+ + + diff --git a/docs/templates/PACK_ROMAN_FR.docx b/docs/templates/PACK_ROMAN_FR.docx new file mode 100644 index 0000000000000000000000000000000000000000..a9960c4278492d459deb1870de6134a45a3cc006 GIT binary patch literal 4220 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 F{{V1@Or-z- literal 0 HcmV?d00001 diff --git a/docs/templates/PACK_ROMAN_FR_SOURCE.html b/docs/templates/PACK_ROMAN_FR_SOURCE.html new file mode 100644 index 0000000..f0e1714 --- /dev/null +++ b/docs/templates/PACK_ROMAN_FR_SOURCE.html @@ -0,0 +1,70 @@ + + + + + Pack Roman FR + + +

Pack roman FR AI Novel Engine

+ +

1. Pitch

+

Titre : ...

+

Genre : ...

+

Logline : ...

+

Synopsis court : ...

+

Promesse de lecture : ...

+ +

2. Fondations

+

Protagoniste : ...

+

Desir : ...

+

Contradiction : ...

+

Force adverse : ...

+

Enjeu : ...

+

Theme : ...

+ +

3. Personnages

+

Personnage 1 : role, desir, contradiction.

+

Personnage 2 : role, desir, contradiction.

+

Personnage 3 : role, desir, contradiction.

+

Relations cle : ...

+ +

4. Monde et lieux

+

Cadre : ...

+

Lieux recurrents : ...

+

Regles du monde : ...

+

Anomalie ou faille : ...

+ +

5. Trajectoire du roman

+

Depart : ...

+

Fracture initiale : ...

+

Escalade : ...

+

Milieu : ...

+

Point de non-retour : ...

+

Fin : ...

+ +

6. Plan de 12 chapitres

+

Chapitre 01 : intention, tension, consequence.

+

Chapitre 02 : intention, tension, consequence.

+

Chapitre 03 : intention, tension, consequence.

+

Chapitre 04 : intention, tension, consequence.

+

Chapitre 05 : intention, tension, consequence.

+

Chapitre 06 : intention, tension, consequence.

+

Chapitre 07 : intention, tension, consequence.

+

Chapitre 08 : intention, tension, consequence.

+

Chapitre 09 : intention, tension, consequence.

+

Chapitre 10 : intention, tension, consequence.

+

Chapitre 11 : intention, tension, consequence.

+

Chapitre 12 : intention, tension, consequence.

+ +

7. Scene type

+

Objectif : ...

+

Conflit : ...

+

Sortie : ...

+

Image finale : ...

+ +

8. Conversion AI Novel Engine

+

Intention de chapitre : 3 a 5 lignes courtes.

+

Memoire personnages : role, desir, contradiction.

+

Rappel : ANE prefere les scenes jouees aux resumes de plan dans la prose.

+ + diff --git a/docs/templates/PACK_SF.docx b/docs/templates/PACK_SF.docx new file mode 100644 index 0000000000000000000000000000000000000000..6388a47aebfcfd72d10709071c8d442a5e79a456 GIT binary patch literal 4335 zcmaJ^2{e>#`=7FBER9|EHQ7^)G_ph(WjFREO;pA()+~{A7)uyi;T2h$iR_UzBfFt& zA=y>dA_iH$@xAYRD(CzEpXc27ea>_4-*ukrcVE|aUq3@A1tlv00H6g}Wk%Sf4?H9Y zod5usC;$K!@>gq3q?;Gq&CA~OzB}C0R>lwEnxE8tgCNJOMGB0VN2Gw^51IAeQzeLc z8L2jxi$@P_3no~PH903?Wem^exCtqHu0dwG((qv`qkKZ#Yv&q!=!wk|FXQe>9yH$X zTvaBBWTiIv8h#fRupd&IU*H6AW$>EULnSWLKEzQ-%9Bp;m~rzL>wCo((;6e-^fT3G zSHst39zrd-&->Y`stfxD-Rz^;g;0KW1Wh+p;Y(}poi)Iyrm?cE>OomeyXHLcKgCxqfY zZtc|G%=f@d?&@6dNN?=M2XHyb2+)5nBG4@Vdg zJP%7>Q<;T_<||VVyU(4hTfO_URYA8{A{nY>rgN5CEYT~_SD|%TcTpiB zy`#Rrqvp=|nVu%De(ChP$cu9g4b(&YEcPw&gelPUhz5?4G zr{;k52Lb-%BaDce>>=~;f*fW%mD_+hU zb}dO_G<{J~OV)B`5>!^AWnnC^4-BwzaXVe>LL#)JK;76|4!@w%y67BlT72t3OlgJoJzL7Ve?89kqQN)G&FF2f4L}=g{D$C4@VzqCLuA|4*c-I!fXa=a|YYh zy4Y+5oZ-h*g3FT-hRxkFPY%7_4bSr@^nJfK8muxknjFRSCksn(OnD(C05AXs08anQ z!tegCSWlpqE-*KKmhcJ80^hOH>%6cMexT;eDC)`0riM#-PF*&k zOSYPp8q2{D`x?PP>0*yLl+Q6;2t^GfaVxC#dR;(WCS=xHoG~4&8CG=8j#1>D@A<@W zyTjWpokLPEVlq~2_PfU*Fwfb@aj25(W;(rF)xbS|yNtz6D|c77aiwLMjp3IziXt$D zcqzSHb;&fk+3LKMV5Q)UZbTDJMR#R1oT)OY_Jwm`zoNXkr&s)4*`>C^(@bqD-}oZ^ z{GB*J*?qP7BF-9G&fzn9;!oU_hTx8bUaq$1vX}Ko$ApX5rso8>v$Eo|{Rh()cB~}V zG@RQb(zT$8$+3pYPb5hm8FdIp)82K3p%i^$ZeB8?_fg#ySc!I@E{?mML79Or1!x|m zLmQ_RRo(i<_02Yf*+`@x{A`Hcfv`vI<{b?xF+Il`X%q-C@14S9hL_!IHV>fwEdO5(fN)(6DfJ+;YR*9cE{b=$>5YNB_jq89$)HQ z726ue7+f3}-XrGj3kku|=}=iK0vau!UW|BMcuhj*C$QP&b5zfl6E7mX`M~monl;9* zNkr@xS4v3o>1zFI<`pI?yNr zYm26}QzT)Z_8h51=NNNwwp+fKVmuQaYC6=|jd#IzedklOjh|P@8yiE+wz#TEUovH+B(%E6Tqx`2@_5_if<>0#mPDKZUcvZv=~x9e zlV{zqPAG5X>LMts6ms<^@LdFBfTV84#Dr#5yP@rpLxv@YIB8Y1?Y1BYuF@VCGj7Oz zQR}xsg;HVFS>k^g`#r7 z=IAqyr7p-)pTSt@lm^Cuk{N5RiLv9@U;S)H;UlO;4PFKk)nSeMgTJAfNgWn!a7bgZ6x?xVG~`F_1AH5C&ZVG-%vV;Vdz zVzn(M{wb86%2*^m<PJ7j z)=aLmRjw%Fte!vyc!$JmB->RET5H3w7cnX725t0Wc;W*$=0%;qWSq)i3yfarQ;0pA z5Cfj=1RYi}T%qFB)$-G~c=Hw1WE|b}@c^Y+Vy5RAqn6oyue|lS@q87$lQuPa&jyPn zR0Ij4avjcrB-!TxmC6;FqpoiABD0(xNfb-G6UpKQ{nazAucUuQzTtqCTcOka(3M>e z(bVk;KAXb!^N;C%=zjo(71V%TzhYX@r=L%~0rmZ$v3{jwF5WsA{z8yr(Li{FwlyI> zm8G+UrBV~=;XU$Q<>Dddt5MhCGj+2A0Fc%c4$|W9=D;tr1VUxVm z9QdIwd*C;>1d{f)gCeQ%5=H&U?0(rk{^inUUe(UevjF{69igF`3`= zSZWJe)dr!7m#xa<(HpUcBGQmDxIb>6SUB;?ITlg`(+*A^6SFLpVstbzs5IqRI3MAd zhYW#IzH<^ewdq^%J_?J?X&>eWjT(xBQX<0VW^~Aprk}5)&VJszWpHm}a_~0SyKw1v9kbq*S`)nXd7;=gvyb;e6{R^0 z67_ZUpYV)|^Oj905ZLSaFKUjr?59_ICf&kqhz;&=bi8-Pirx$4;o>LupLns(%b$nF zLhGYICkgeGs+}ycw9u)>1k>5Bx~QEG(*xu0v#(#eY-n`q`6^ofp2U7p1H*k}(`JaXKN~5uWc$z_hRcE~dpk|2 ztMLoa;^0A&Twf>)|_JJSD03LY4y3XJwABL%o>!EyGVb^S4>E|?!d?Tqvn>; zh>_3-^k1P1+uj(JQf0YJe9;59Ged;}=^Z4|CkDbFR>>1wBl_IV-N1gJp2Qd6%+&e9${s8ajUPM@G^N69X&byTc`4I+SLLHOr!_Fz&IFBZ z1#wu;)xQ0J57Tiy!O1jt0haMid5^a9PNx&M;N8oMiBpXw{LP6jd0GTBi?*Sn#uRE8 zd{2KsNRIYGqOdIR`cSj1B$j8=#vht}Qh2GGGpJ)7n?|+hh znL2Eb*D%(kIXuPPc&y>INOEaUC%e8u9?ZW};OOpt#6T7iLf<0C8ZkX#f4cnDb4H>3 zI~*Q9MXQ3f`v6a*PD%bgNt<$lmr3?=W`Xs-rWEsle$jm6i)Vn!x!XKd^_yukB3d`Y zh?i;AyBU&}$80_0SOwOBy0=awr?Pk!rRe#bL>gx(ESxv|5xEhYm+P1=o!O}h5x{Gj z@v^-DeraZ#rteCyklmAA>}Wj94`_;5u8P~8K-m9zkW?{*#dz}H7m>#w2Z?}tIUc#8 z)m?U~MUFXSdZLi_)ssqcBPPO2DV~qXT}tFbJnc*oK{qxGzuh@}FwwXtmCZWP`bJHH zo|%=0WWovjSW<|f5P}0CM4pc3C!k87JP4e1Qb~luiP)|Fd`^2kT|}l^|9u%Oo;ME{ zlWUG&lNYvt80Mfe@*pJ*OMGb?kQ*7fxW(JDzE=Cu2U%MbhR_oySpomIuqAr|Acy2X z&;O9Q#|<2paYx9%4VaLZ-$dPU;Bme43+O~vg#VxVId18=X82|44*AaguchA=#BuoX z%lQ}l3hh7e<5%@@6UVdpmkA@_|H$p*@Z*{A3vSBr8~mTVIF3FZfxpn)jK87(jYmT$ T74^{;TJn36%=8F(69DiZEozB# literal 0 HcmV?d00001 diff --git a/docs/templates/PACK_SF_SOURCE.html b/docs/templates/PACK_SF_SOURCE.html new file mode 100644 index 0000000..78555fc --- /dev/null +++ b/docs/templates/PACK_SF_SOURCE.html @@ -0,0 +1,52 @@ + + + + + Pack SF + + +

Pack science-fiction AI Novel Engine

+ +

1. Base du projet

+

Titre : ...

+

Sous-genre : hard SF, space opera, cyberpunk, cli-fi, post-apo, first contact.

+

Hypothese centrale : quelle idee speculative change le monde ?

+

Logline : ...

+ +

2. Systeme du monde

+

Cadre : station, colonie, megalopole, vaisseau, Terre transformee.

+

Technologie ou science decisive : ...

+

Limites et couts : ...

+

Ordre social ou politique : ...

+

Ressource rare : ...

+ +

3. Axe dramatique

+

Protagoniste : ...

+

Desir : ...

+

Contradiction : ...

+

Force adverse : IA, corporation, Etat, ecosysteme, temps, mission.

+

Prix du progres : ...

+ +

4. Personnages et camps

+

Protagoniste : role, desir, contradiction.

+

Allie technique : role, desir, contradiction.

+

Contrepoids humain : role, desir, contradiction.

+

Adversaire : role, desir, contradiction.

+ +

5. Mouvement du roman

+

Incident : ...

+

Escalade : ...

+

Revelation scientifique ou politique : ...

+

Point de non-retour : ...

+

Climax : ...

+

Nouvel equilibre : ...

+ +

6. Garde-fous ANE

+
    +
  • Ne pas laisser le concept manger la scene.
  • +
  • Faire porter les regles technologiques par des decisions et des pertes visibles.
  • +
  • Chaque chapitre doit changer soit le savoir, soit la position, soit le risque.
  • +
+ + + diff --git a/docs/templates/PLAN_3_ACTES.docx b/docs/templates/PLAN_3_ACTES.docx new file mode 100644 index 0000000000000000000000000000000000000000..132c3f1928d64bcf0fce9404f782bd0d537740c6 GIT binary patch literal 4227 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 O{=R@`+VRra(|-V + + + + Plan 3 Actes + + +

Template plan 3 actes AI Novel Engine

+ +

1. Base du projet

+

Titre : ...

+

Logline : ...

+

Question dramatique : ...

+ +

2. Acte I - Installation et premiere fracture

+

Monde initial : ...

+

Fracture declenchante : ...

+

Refus ou hesitation : ...

+

Premier seuil : la decision qui empeche le retour au statu quo.

+ +

3. Acte II - Escalade

+

Promesse de l'intrigue : ...

+

Complications majeures : ...

+

Milieu : revelation, victoire amere ou renversement.

+

Nouvelle pression : ...

+

Point de non-retour : ...

+ +

4. Acte III - Resolution

+

Crisis : pire moment, perte ou contradiction maximale.

+

Decision finale : ...

+

Climax : ...

+

Consequence finale : ...

+

Nouvel equilibre : ...

+ +

5. Conversion en chapitres

+

Acte I : chapitres 1 a 3

+

Acte II : chapitres 4 a 9

+

Acte III : chapitres 10 a 12

+ +

6. Garde-fous ANE

+
    +
  • Chaque fin de chapitre doit produire une consequence immediate.
  • +
  • Eviter les bascules purement expliquees sans action visible.
  • +
  • Le protagoniste doit payer un prix croissant a chaque acte.
  • +
+ + + diff --git a/docs/templates/PLAN_ROMAN_COMPLET.docx b/docs/templates/PLAN_ROMAN_COMPLET.docx new file mode 100644 index 0000000000000000000000000000000000000000..6aab15b501d43b93850acbb3fb7058fab214e111 GIT binary patch literal 5584 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 literal 0 HcmV?d00001 diff --git a/docs/templates/PLAN_ROMAN_COMPLET_SOURCE.html b/docs/templates/PLAN_ROMAN_COMPLET_SOURCE.html new file mode 100644 index 0000000..7d68e90 --- /dev/null +++ b/docs/templates/PLAN_ROMAN_COMPLET_SOURCE.html @@ -0,0 +1,209 @@ + + + + + Plan Roman Complet + + +

Template de plan roman complet AI Novel Engine

+ +

1. Identite du projet

+

Titre de travail : ...

+

Genre : ...

+

Logline : ...

+

Promesse de lecture : ...

+

Point de vue / personne : ...

+

Ton / registre : ...

+

Objectif mots : ...

+ +

2. Noyau dramatique

+

Protagoniste : ...

+

Desir central : ...

+

Besoin profond : ...

+

Contradiction : ...

+

Force adverse : ...

+

Enjeu externe : ...

+

Enjeu intime : ...

+

Question dramatique : ...

+ +

3. Monde, regles, contraintes

+

Cadre : ...

+

Regles du monde : ...

+

Secret ou anomalie structurante : ...

+

Interdits et risques visibles : ...

+ +

4. Fiches personnages

+

Protagoniste

+
    +
  • Nom : ...
  • +
  • Role : ...
  • +
  • Desire : ...
  • +
  • Contradiction : ...
  • +
  • Blessure : ...
  • +
  • Mensonge personnel : ...
  • +
  • Evolution attendue : ...
  • +
+ +

Antagoniste ou force adverse

+
    +
  • Nom : ...
  • +
  • Role : ...
  • +
  • Desire : ...
  • +
  • Contradiction : ...
  • +
  • Moyens de pression : ...
  • +
+ +

Allie majeur

+
    +
  • Nom : ...
  • +
  • Role : ...
  • +
  • Desire : ...
  • +
  • Contradiction : ...
  • +
  • Ce qu'il cache : ...
  • +
+ +

Personnage de pression

+
    +
  • Nom : ...
  • +
  • Role : ...
  • +
  • Desire : ...
  • +
  • Contradiction : ...
  • +
  • Comment il complique les scenes : ...
  • +
+ +

5. Arc global du roman

+

Depart : ...

+

Fracture initiale : ...

+

Premiere bascule : ...

+

Milieu / revelation : ...

+

Point de non-retour : ...

+

Crisis : ...

+

Climax : ...

+

Nouvel equilibre : ...

+ +

6. Plan par actes

+

Acte I - Mise en tension

+

Fonction : installer le monde, la fracture et la premiere decision.

+

Chapitres couverts : 1 a 3

+ +

Acte II - Escalade

+

Fonction : multiplier les couts, erreurs, revelations, alliances et ruptures.

+

Chapitres couverts : 4 a 9

+ +

Acte III - Resolution

+

Fonction : mener au point de non-retour, au climax et au prix final.

+

Chapitres couverts : 10 a 12

+ +

7. Plan chapitre par chapitre

+ +

Chapitre 01

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 02

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 03

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 04

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 05

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 06

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 07

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 08

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 09

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 10

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 11

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

Chapitre 12

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+

Decision finale : ...

+

Consequence immediate : ...

+ +

8. Carte de scene reutilisable

+

Scene type

+
    +
  • Objectif : ...
  • +
  • Conflit : ...
  • +
  • Sortie : ...
  • +
  • Lieu : ...
  • +
  • Personnages presents : ...
  • +
  • Image dominante : ...
  • +
  • Cout ou risque visible : ...
  • +
+ +

9. Traduction vers AI Novel Engine

+

Intention ANE type : 3 a 5 lignes courtes, concretes, une par impulsion narrative.

+

Memoire personnages : conserver pour chacun un role, un desir et une contradiction exploitables.

+

Garde-fous : chaque chapitre doit finir sur un effet observable, pas sur une annonce abstraite.

+ +

10. Checklist avant generation

+
    +
  • Chaque personnage majeur a un desir et une contradiction utilisables en scene.
  • +
  • Chaque chapitre a une intention, une tension et une consequence finale.
  • +
  • Le plan montre des actions et des couts, pas seulement des idees.
  • +
  • Le protagoniste prend des decisions de plus en plus irreversibles.
  • +
  • Le climax regle la question dramatique posee au depart.
  • +
+ + diff --git a/docs/templates/PLAN_TEMPLATE.docx b/docs/templates/PLAN_TEMPLATE.docx new file mode 100644 index 0000000000000000000000000000000000000000..ddd92c5a350691c055ca6ae1a6c7a853a7a5b5e9 GIT binary patch literal 4571 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 + + + + Plan Template + + +

Template de plan AI Novel Engine

+ +

1. Identite du projet

+

Titre de travail : ...

+

Genre : ...

+

Logline : ...

+

Promesse de lecture : ...

+ +

2. Noyau dramatique

+

Protagoniste : nom + position dans le monde.

+

Desir : ce qu'il veut concretement.

+

Contradiction : ce qui rend ses choix couteux.

+

Force adverse : personne, systeme, secret, dette, menace.

+

Prix du conflit : ce qui peut etre perdu si le personnage echoue.

+ +

3. Personnages cle

+

Personnage 1

+
    +
  • Role : ...
  • +
  • Desire : ...
  • +
  • Contradiction : ...
  • +
  • Lien au protagoniste : ...
  • +
+ +

Personnage 2

+
    +
  • Role : ...
  • +
  • Desire : ...
  • +
  • Contradiction : ...
  • +
  • Lien au protagoniste : ...
  • +
+ +

4. Arc global

+

Depart : etat initial du personnage et du monde.

+

Fracture : evenement qui rend l'ancien equilibre impossible.

+

Escalade : serie de couts, revelations, erreurs ou retours de force.

+

Point de non-retour : acte qui empeche le retour en arriere.

+

Fin visee : resolution et transformation attendue.

+ +

5. Plan de chapitre

+

Chapitre 01

+

Intention : ...

+

Objectif dramatique : ...

+

Tension : ...

+ +

Scene 1

+
    +
  • Objectif : ...
  • +
  • Conflit : ...
  • +
  • Sortie : ...
  • +
+ +

Scene 2

+
    +
  • Objectif : ...
  • +
  • Conflit : ...
  • +
  • Sortie : ...
  • +
+ +

Scene 3

+
    +
  • Objectif : ...
  • +
  • Conflit : ...
  • +
  • Sortie : decision finale + consequence immediate.
  • +
+ +

6. Garde-fous ANE

+
    +
  • Eviter les plans purement abstraits et les scenes sans action observable.
  • +
  • Finir les scenes sur une consequence nette, pas sur une annonce vague.
  • +
  • Donner a chaque personnage un desir et une contradiction utilisables en scene.
  • +
  • Ne pas ecrire la prose finale ici : garder ce document au niveau planifiable.
  • +
+ + diff --git a/docs/templates/README.md b/docs/templates/README.md new file mode 100644 index 0000000..ebffcde --- /dev/null +++ b/docs/templates/README.md @@ -0,0 +1,90 @@ +# Templates utiles + +Deux points d'entree selon le mode de travail : + +- `STUDIO_PROJECT_TEMPLATE.md` : format Markdown importable par `app_AI-novel-engine`. +- `PLAN_TEMPLATE.docx` : document Word editable pour preparer un plan de roman ou de chapitre hors app. +- `PLAN_ROMAN_COMPLET.docx` : version longue avec actes, 12 chapitres et fiches personnages. +- `FICHE_PERSONNAGES.docx` : fiche de cast orientee desir, contradiction, pression et arcs. +- `PLAN_3_ACTES.docx` : squelette resserre autour des bascules majeures du roman. +- `PACK_ROMAN_FR.docx` : pack complet francise pour preparer un roman avant import ou generation. +- `PACK_POLAR.docx` : pack polar / thriller avec crime, soupcons, fausses pistes et revelation. +- `PACK_FANTASY.docx` : pack fantasy avec monde, magie, factions et quete. +- `PACK_LITTERAIRE.docx` : pack roman litteraire centre voix, tension fine et transformation intime. +- `PACK_SF.docx` : pack science-fiction avec hypothesis, systemes, contraintes et cout technologique. +- `PACK_ROMANTASY.docx` : pack romantasy avec tension relationnelle, pouvoir, dettes et factions. +- `PACK_HUIS_CLOS.docx` : pack huis clos avec espace contraint, pression continue et revelation par confinement. +- `PACK_IDEE_ROMAN.docx` : pack personnalisable a partir d'une idee de roman encore brute. +- `ane-workspace-template/` : squelette minimal pour le moteur CLI `ai-novel-engine`. + +Repere important : + +- le moteur CLI exige surtout `notes/intentions/chapitre_01.md` +- `memoire/index/personnages.json` peut etre pre-rempli pour injecter un cast de depart +- le plan de chapitre genere par ANE suit le contrat documente dans `ANE_STRUCTURE_TEMPLATE.md` +- la source regenerable du document Word est `PLAN_TEMPLATE_SOURCE.html` +- la source regenerable du document long est `PLAN_ROMAN_COMPLET_SOURCE.html` +- les autres sources regenerables sont `FICHE_PERSONNAGES_SOURCE.html`, `PLAN_3_ACTES_SOURCE.html` et `PACK_ROMAN_FR_SOURCE.html` +- les packs genre sont sources dans `PACK_POLAR_SOURCE.html`, `PACK_FANTASY_SOURCE.html` et `PACK_LITTERAIRE_SOURCE.html` +- les packs additionnels sont sources dans `PACK_SF_SOURCE.html`, `PACK_ROMANTASY_SOURCE.html`, `PACK_HUIS_CLOS_SOURCE.html` et `PACK_IDEE_ROMAN_SOURCE.html` + +Exemples : + +```bash +cp docs/templates/STUDIO_PROJECT_TEMPLATE.md /tmp/mon-projet.md +cp docs/templates/PLAN_TEMPLATE.docx ~/Desktop/ +cp docs/templates/PLAN_ROMAN_COMPLET.docx ~/Desktop/ +cp docs/templates/FICHE_PERSONNAGES.docx ~/Desktop/ +cp docs/templates/PLAN_3_ACTES.docx ~/Desktop/ +cp docs/templates/PACK_ROMAN_FR.docx ~/Desktop/ +cp docs/templates/PACK_POLAR.docx ~/Desktop/ +cp docs/templates/PACK_FANTASY.docx ~/Desktop/ +cp docs/templates/PACK_LITTERAIRE.docx ~/Desktop/ +cp docs/templates/PACK_SF.docx ~/Desktop/ +cp docs/templates/PACK_ROMANTASY.docx ~/Desktop/ +cp docs/templates/PACK_HUIS_CLOS.docx ~/Desktop/ +cp docs/templates/PACK_IDEE_ROMAN.docx ~/Desktop/ +cp -R docs/templates/ane-workspace-template /tmp/mon-workspace-ane +cd /tmp/mon-workspace-ane +python3 -m cli.main generate chapter --chapter 01 +``` + +Regeneration du DOCX : + +```bash +textutil -convert docx docs/templates/PLAN_TEMPLATE_SOURCE.html \ + -output docs/templates/PLAN_TEMPLATE.docx + +textutil -convert docx docs/templates/PLAN_ROMAN_COMPLET_SOURCE.html \ + -output docs/templates/PLAN_ROMAN_COMPLET.docx + +textutil -convert docx docs/templates/FICHE_PERSONNAGES_SOURCE.html \ + -output docs/templates/FICHE_PERSONNAGES.docx + +textutil -convert docx docs/templates/PLAN_3_ACTES_SOURCE.html \ + -output docs/templates/PLAN_3_ACTES.docx + +textutil -convert docx docs/templates/PACK_ROMAN_FR_SOURCE.html \ + -output docs/templates/PACK_ROMAN_FR.docx + +textutil -convert docx docs/templates/PACK_POLAR_SOURCE.html \ + -output docs/templates/PACK_POLAR.docx + +textutil -convert docx docs/templates/PACK_FANTASY_SOURCE.html \ + -output docs/templates/PACK_FANTASY.docx + +textutil -convert docx docs/templates/PACK_LITTERAIRE_SOURCE.html \ + -output docs/templates/PACK_LITTERAIRE.docx + +textutil -convert docx docs/templates/PACK_SF_SOURCE.html \ + -output docs/templates/PACK_SF.docx + +textutil -convert docx docs/templates/PACK_ROMANTASY_SOURCE.html \ + -output docs/templates/PACK_ROMANTASY.docx + +textutil -convert docx docs/templates/PACK_HUIS_CLOS_SOURCE.html \ + -output docs/templates/PACK_HUIS_CLOS.docx + +textutil -convert docx docs/templates/PACK_IDEE_ROMAN_SOURCE.html \ + -output docs/templates/PACK_IDEE_ROMAN.docx +``` diff --git a/docs/templates/STUDIO_PROJECT_TEMPLATE.md b/docs/templates/STUDIO_PROJECT_TEMPLATE.md new file mode 100644 index 0000000..21a3630 --- /dev/null +++ b/docs/templates/STUDIO_PROJECT_TEMPLATE.md @@ -0,0 +1,43 @@ +# Titre du roman + +## Logline +Une phrase courte qui pose le protagoniste, la fracture initiale et l'enjeu. + +## Synopsis +Resume continu du projet. + +Paragraphe 1 : situation initiale, monde, promesse narrative. +Paragraphe 2 : rupture, escalade, conflit central. +Paragraphe 3 : trajectoire globale, prix a payer, fin visee. + +## Writer's Note +Ton, personne grammaticale, contraintes de style, interdits, niveau de violence, promesse emotionnelle. + +## Characters +### Elise Varenne +**Role:** Protagoniste. Celle qui voit la fracture avant les autres. +**Desire:** Comprendre ce qui se dechire et proteger ce qui reste. +**Contradiction:** Veut la verite mais vit deja sur un compromis qu'elle refuse d'examiner. + +### Abel Sorn +**Role:** Allie ambigu ou faux soutien. +**Desire:** Garder le controle de la situation. +**Contradiction:** Pretend proteger Elise, mais a besoin qu'elle ignore une piece cle. + +## Scenes +### Scene 1: La premiere fracture +**Objective:** Introduire le protagoniste, le monde et l'evenement impossible. +**Beat:** Elise voit une anomalie concrete et choisit de ne pas detourner le regard. +**Mood:** Tendu, feutre, etrange +**Draft:** +_No draft yet._ + +--- + +### Scene 2: Le temoin ment +**Objective:** Faire monter le doute et isoler le protagoniste. +**Beat:** Un tiers nie l'evidence ou requalifie l'evenement, ce qui force Elise a agir seule. +**Mood:** Instable, paranoiaque, froid +**Draft:** +_No draft yet._ + diff --git a/docs/templates/ane-workspace-template/memoire/index/chronologie.json b/docs/templates/ane-workspace-template/memoire/index/chronologie.json new file mode 100644 index 0000000..6b81803 --- /dev/null +++ b/docs/templates/ane-workspace-template/memoire/index/chronologie.json @@ -0,0 +1,7 @@ +[ + { + "event": "Premier signe de fracture observe par Elise.", + "order_hint": "aube", + "chapter": "chapitre_01" + } +] diff --git a/docs/templates/ane-workspace-template/memoire/index/lieux.json b/docs/templates/ane-workspace-template/memoire/index/lieux.json new file mode 100644 index 0000000..8864ed7 --- /dev/null +++ b/docs/templates/ane-workspace-template/memoire/index/lieux.json @@ -0,0 +1,10 @@ +{ + "Le quai nord": { + "name": "Le quai nord", + "description": "Zone portuaire vide, bruyante, balayee par le vent. Bon lieu de premiere fracture.", + "chapters": [ + "chapitre_01" + ] + } +} + diff --git a/docs/templates/ane-workspace-template/memoire/index/personnages.json b/docs/templates/ane-workspace-template/memoire/index/personnages.json new file mode 100644 index 0000000..4b530db --- /dev/null +++ b/docs/templates/ane-workspace-template/memoire/index/personnages.json @@ -0,0 +1,21 @@ +{ + "Elise Varenne": { + "name": "Elise Varenne", + "role": "Protagoniste. Observe la fracture avant les autres.", + "desire": "Comprendre ce qui se deforme et proteger ce qui compte encore.", + "contradiction": "Cherche la verite, mais vit deja sur un mensonge qu'elle entretient.", + "chapters": [ + "chapitre_01" + ] + }, + "Abel Sorn": { + "name": "Abel Sorn", + "role": "Allie ambigu. Oriente Elise sans tout lui dire.", + "desire": "Garder le controle du rythme et des informations.", + "contradiction": "Veut sauver Elise, mais seulement a condition qu'elle reste dependante de lui.", + "chapters": [ + "chapitre_01" + ] + } +} + diff --git a/docs/templates/ane-workspace-template/notes/intentions/chapitre_01.md b/docs/templates/ane-workspace-template/notes/intentions/chapitre_01.md new file mode 100644 index 0000000..57bc9a2 --- /dev/null +++ b/docs/templates/ane-workspace-template/notes/intentions/chapitre_01.md @@ -0,0 +1,7 @@ +# Intention - Chapitre 01 + +Installer la voix du projet. +Montrer une fracture nette dans l'ordinaire. +Forcer le protagoniste a voir quelque chose qu'il ne pourra pas oublier. +Finir sur une decision simple, couteuse, irreversible. + diff --git a/docs/vision.md b/docs/vision.md index d4aa180..ac91b35 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -35,6 +35,18 @@ de garder un pipeline lisible, reproductible et contrôlable par l'auteur: - autonomie complete "idee -> manuscrit final" - base de donnees opaque pour la mémoire +## En-scope operationnel + +Le produit peut en revanche assumer un cockpit local simple si cela aide l'auteur +ou l'operateur a: + +- relire l'etat du projet +- suivre les lots automatiques +- comprendre les blockers runtime et qualite +- piloter les reports et la reprise + +Le TUI local est donc en-scope. Le "studio web riche" ne l'est toujours pas. + ## Critere de valeur Le systeme est utile si un auteur peut: diff --git a/docs/workflow.md b/docs/workflow.md index 1b24725..596392b 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -1 +1,24 @@ -Intention → Structure → Production → Contrôle → Mémoire. +# Workflow ANE + +Vue courte du workflow utile. + +```mermaid +flowchart LR + I["Intention"] --> S["Structure"] + S --> D["Draft"] + D --> C["Critique"] + C --> R["Rewrite"] + R --> G["Gate"] + G -->|bloque| P["Repair"] + P --> G + G -->|ok| V["Validation auteur"] + V -->|refus| X["Rejet"] + V -->|accord| M["Memoire"] +``` + +Regles: + +- pas d'intention, pas de generation +- pas de `gate` vert, pas de promotion manuscrit +- `repair` existe pour sauver un brouillon, pas pour contourner le garde-fou +- la memoire n'est mise a jour qu'apres validation auteur diff --git a/prompts/draft_v1.txt b/prompts/draft_v1.txt index fb28d49..efb9f90 100644 --- a/prompts/draft_v1.txt +++ b/prompts/draft_v1.txt @@ -23,5 +23,23 @@ Consignes: - transformer chaque beat de structure en action, perception, decision, consequence et, si utile, dialogue - ne pas commenter la structure ni annoncer les scenes; les jouer directement - finir sur une vraie phrase complete avec une ponctuation finale -- viser un chapitre bref mais complet, d'au moins 3 paragraphes substantiels +- viser environ 600 a 800 mots, repartis en au moins 4 paragraphes substantiels - ne pas ajouter d'explication hors texte narratif + +Exemple de sortie INTERDITE (outline_like): +``` +# Scène 1 — Arrivée +Objectif: observer la ville +- Elle entre dans la rue +- Elle repère un indice +Tension: hésitation +Sortie: elle décide d'agir +``` + +Exemple de sortie CORRECTE: +``` +Elle entra dans la rue par le côté obscur, là où les réverbères s'espacent et où les passants pressent le pas sans se retourner. Les pavés mouillés réfléchissaient les enseignes au loin. Elle s'arrêta devant une porte entrouverte, retint son souffle, puis aperçut le ticket froissé coincé dans le battant — exactement ce qu'on lui avait dit de chercher. +``` + +Commence le chapitre maintenant, directement en prose, sans titre ni label: + diff --git a/prompts/gate_v1.txt b/prompts/gate_v1.txt index 39c52e0..53675d7 100644 --- a/prompts/gate_v1.txt +++ b/prompts/gate_v1.txt @@ -4,8 +4,14 @@ 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. -Ne bloque pas `outline_like` si le texte reste en prose continue, sans titres, sans puces et sans labels visibles, meme s'il est simple ou sobre. -Utilise `outline_like` seulement s'il reste des traces visibles de plan, de checklist, de resume de scene, d'intitules structurants ou d'ecriture meta. +Ce garde-fou principal juge surtout la forme manuscrite exploitable; il ne doit pas surdiagnostiquer un simple style faible comme une scene incomplete. +Ne bloque pas `outline_like` si le texte est composé uniquement de paragraphes de prose narrative, sans aucun titre (`#`), sans aucune puce (`-`, `*`), sans numérotation, sans labels (`Scène:`, `Objectif:`, etc.) et sans résumé méta. +Si le texte contient uniquement des phrases narratives en prose, meme courtes ou répétitives, il n'est PAS `outline_like`. +Si tu hesites entre `outline_like` et prose narrative faible, choisis prose narrative faible: un mot comme `objectif`, `tension` ou `scene` a l'interieur d'une phrase normale ne suffit jamais a bloquer en `outline_like`. +Utilise `outline_like` seulement si le texte contient encore des marqueurs visuels de plan: titres Markdown, puces, listes numérotées, labels structurants ou descriptions extérieures de scène. +Utilise `blockers` seulement si le texte n'est pas promouvable tel quel. +Si la prose est faible mais qu'une vraie scene complete existe deja, prefere une recommandation a un blocage. +Verifie aussi qu'une decision risquee apparait bien dans le dernier tiers et qu'une consequence immediate est visible avant la fin; si c'est deja present, n'ajoute aucun blocker a ce sujet. Limite-toi a 1 phrase de resume, 4 blockers max et 4 recommandations max. Chapitre cible: $chapter_slug diff --git a/prompts/judge_narrative_retry_v1.txt b/prompts/judge_narrative_retry_v1.txt new file mode 100644 index 0000000..afac417 --- /dev/null +++ b/prompts/judge_narrative_retry_v1.txt @@ -0,0 +1,46 @@ +Tu es le rôle Juge narratif secondaire 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, regenere le diagnostic narratif a partir du contexte. + +Critères a verifier: +- continuite narrative +- completude de scene +- presence d'une decision risquee concrete dans le dernier tiers +- consequence immediate observable apres cette decision + +Bloque uniquement avec ces labels si necessaire: +- `weak_narrative_continuity` +- `incomplete_scene` +- `missing_risky_decision` +- `missing_immediate_consequence` + +Erreur de parsing observee: +$parse_error + +Tentative precedente a corriger si possible: +$invalid_response + +Chapitre cible: $chapter_slug + +Intention: +$intention + +Contexte projet: +$story_context + +Structure attendue: +$structure_markdown + +Brouillon final: +$draft_markdown + +Format JSON strict: +{ + "ready_for_manuscript": true, + "summary": "diagnostic bref", + "blockers": ["missing_immediate_consequence"], + "recommendations": ["aller jusqu'a l'effet observable de la decision finale"] +} diff --git a/prompts/judge_narrative_v1.txt b/prompts/judge_narrative_v1.txt new file mode 100644 index 0000000..1859278 --- /dev/null +++ b/prompts/judge_narrative_v1.txt @@ -0,0 +1,44 @@ +Tu es le rôle Juge narratif secondaire du moteur AI Novel Engine. +Tu effectues une evaluation compatible avec une future integration de type Prometheus, mais tu dois repondre uniquement avec un objet JSON valide. +Ne mets aucun texte avant ou apres le JSON. +Ne mets aucun bloc Markdown. +Tu n'es pas le garde-fou principal: tu fournis un second verdict sur la qualite narrative. + +Critères a verifier: +- continuite narrative: le texte doit enchainer perceptions, decisions, actions et consequences sans saut resume majeur +- completude de scene: la scene doit aller jusqu'a une fermeture dramatique exploitable, pas s'arreter juste avant l'effet utile +- decision risquee: dans le dernier tiers, le personnage principal doit prendre une decision concrete, couteuse ou irreversible +- consequence immediate: dans les 2 a 4 phrases qui suivent cette decision, une consequence observable doit se produire + +Bloque uniquement avec ces labels si necessaire: +- `weak_narrative_continuity` +- `incomplete_scene` +- `missing_risky_decision` +- `missing_immediate_consequence` + +N'utilise pas `missing_risky_decision` si la decision finale existe clairement, meme si elle reste sobre. +N'utilise pas `missing_immediate_consequence` si la consequence est visible juste apres la decision, meme courte. +Ne bloque pas pour un simple style faible si la scene est complete; dans ce cas, utilise seulement `recommendations`. +Limite-toi a 1 phrase de resume, 4 blockers max et 4 recommandations max. + +Chapitre cible: $chapter_slug + +Intention: +$intention + +Contexte projet: +$story_context + +Structure attendue: +$structure_markdown + +Brouillon final: +$draft_markdown + +Format JSON strict: +{ + "ready_for_manuscript": true, + "summary": "diagnostic bref", + "blockers": ["missing_risky_decision"], + "recommendations": ["rendre la decision finale plus couteuse", "montrer son effet immediat"] +} diff --git a/prompts/repair_v1.txt b/prompts/repair_v1.txt index 8fd3b9c..cedb86b 100644 --- a/prompts/repair_v1.txt +++ b/prompts/repair_v1.txt @@ -27,10 +27,36 @@ Consignes impératives: - 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 +- viser en priorite une scene complete et non repetitive; la plupart des sorties utiles feront entre 500 et 700 mots, mais n'etire jamais le texte par repetition mecanique pour atteindre un quota - montrer la scene au lieu de la resumer depuis l'exterieur - chaque paragraphe doit produire une action, une reaction ou une consequence concrete -- finir le dernier paragraphe sur une decision nette suivie d'une consequence immediate +- dans le dernier tiers, forcer une action irreversible ou couteuse que le personnage principal execute tout de suite; pas une simple intention ou promesse d'agir plus tard +- la consequence immediate doit se produire dans le meme lieu et la meme minute: reaction adverse, blessure, cri, poursuite, alarme, preuve detruite, porte claquee ou point de non-retour visible +- dans les 4 dernieres phrases, respecter cette progression: decision nette, action immediate, consequence perceptible, phrase de cloture complete +- finir le dernier paragraphe sur une decision nette suivie d'une consequence immediate (observable, pas abstraite) +- interdit en fin de scene: repartir vers une prochaine etape, annoncer une cible future ou rouvrir une nouvelle piste avant d'avoir montre l'effet immediat de l'acte final +- si une action, un lieu, un indice ou une decouverte a deja eu lieu, ne la rejoue pas une seconde fois pour remplir le texte; compacter la repetition et avancer vers la consequence +- si le brouillon contient deja les bons evenements mais les resume trop vite, decompresser la scene au lieu d'inventer un autre plan +- distinguer prose faible et scene incomplete: si la scene n'est pas encore fermee, aller jusqu'a la consequence concrete au lieu d'ajouter des commentaires explicatifs $repair_focus - finir obligatoirement sur une vraie phrase complete avec une ponctuation finale +- ne jamais finir sur une suspension (pas de "...", pas de phrase coupee, pas d'annonce d'action future sans action executee) - ne rien ajouter avant ou apres le chapitre + +Exemple de sortie INTERDITE (outline_like): +``` +# Réparation +Scène 1: arrivée +- action 1 +- action 2 +Résultat: elle décide +``` + +Exemple de sortie CORRECTE (style attendu — ne reproduis pas ce texte, écris ta propre scène): +``` +Il poussa la grille du jardin sans la regarder. Les herbes hautes lui arrivaient aux genoux et le chemin de gravier avait disparu sous les années. Il s'arrêta devant la fenêtre condamnée, posa la main à plat sur le bois gonflé d'humidité, et comprit qu'il était le dernier à se souvenir de ce que la maison avait été. +``` + +IMPORTANT: n'utilise pas le texte de l'exemple ci-dessus. Génère une réparation originale basée sur le brouillon et l'intention fournis plus haut. + +Commence la réparation maintenant, directement en prose, sans titre ni label: diff --git a/prompts/rewrite_v1.txt b/prompts/rewrite_v1.txt index 77c8adc..abec2c1 100644 --- a/prompts/rewrite_v1.txt +++ b/prompts/rewrite_v1.txt @@ -22,7 +22,33 @@ Consignes de réécriture: - 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`, `Chapitre` 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 +- materialiser les decisions et leurs consequences; ne pas les commenter depuis l'exterieur +- inclure explicitement, dans le dernier tiers, une decision risquee concrete prise et executee tout de suite par le personnage principal; pas une simple intention ou promesse d'agir plus tard +- montrer une consequence immediate de cette decision dans les 2-4 phrases qui suivent +- cette consequence doit se produire dans le meme lieu et la meme minute: reaction adverse, blessure, cri, poursuite, alarme, preuve detruite, porte claquee ou point de non-retour visible; pas une simple marche vers la suite +- dans les 4 dernieres phrases, respecter cette progression: decision nette, action immediate, consequence perceptible, phrase de cloture complete +- interdit en fin de scene: annoncer qu'elle repart, qu'elle continuera plus tard ou qu'elle vise une prochaine cible sans montrer d'abord l'effet immediat de son acte +- si un lieu, un indice ou une action a deja ete etabli, ne rejoue pas une deuxieme fois la meme sequence pour gagner des mots; avance vers la consequence au lieu de boucler +- si la prose reste faible mais que la scene avance, continuer jusqu'a une scene complete au lieu de condenser en resume - ne pas resumer la scene depuis l'exterieur; montrer les gestes, les perceptions, les hesitations et les consequences dans le fil du texte - finir sur une vraie phrase complete avec une ponctuation finale +- ne jamais finir sur une suspension (pas de phrase inachevee, pas de "...", pas de transition annoncee sans action) +- viser en priorite une scene complete et non repetitive; la plupart des sorties utiles feront entre 500 et 700 mots, mais n'etire jamais le texte par repetition mecanique pour atteindre un quota - ne rien ajouter avant ou apres le chapitre + +Exemple de sortie INTERDITE (outline_like): +``` +## Scène 1 +- Elle arrive dans la rue +- Elle voit un indice +Tension: elle hésite +``` + +Exemple de sortie CORRECTE (style attendu — ne reproduis pas ce texte, écris ta propre scène): +``` +Il poussa la grille du jardin sans la regarder. Les herbes hautes lui arrivaient aux genoux et le chemin de gravier avait disparu sous les années. Il s'arrêta devant la fenêtre condamnée, posa la main à plat sur le bois gonflé d'humidité, et comprit qu'il était le dernier à se souvenir de ce que la maison avait été. +``` + +IMPORTANT: n'utilise pas le texte de l'exemple ci-dessus. Génère une réécriture originale basée sur le brouillon et l'intention fournis plus haut. + +Commence la réécriture maintenant, directement en prose: diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100644 index 0000000..a6a8cab --- /dev/null +++ b/scripts/healthcheck.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Vérifie l'état des ports runtime ANE et guide vers les actions correctives. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +TOML="${REPO_DIR}/automation/next_lots.toml" + +# --- lecture TOML robuste via Python/tomllib ------------------------------- +_toml_get() { + local key="$1" + python3 - "$TOML" "$key" <<'PY' +from __future__ import annotations + +import sys +import tomllib +from pathlib import Path + +path = Path(sys.argv[1]) +key = sys.argv[2] + +try: + payload = tomllib.loads(path.read_text(encoding="utf-8")) +except (OSError, tomllib.TOMLDecodeError): + print("") + raise SystemExit(0) + +paths = payload.get("paths") if isinstance(payload, dict) else {} +if not isinstance(paths, dict): + print("") + raise SystemExit(0) + +value = paths.get(key, "") +print(str(value).strip()) +PY +} + +CORE_URL="$(_toml_get core_base_url)" +APPLE_URL="$(_toml_get apple_runtime_url)" +OLLAMA_TAGS="$(_toml_get ollama_tags_url)" +OPENAI_URL="$(_toml_get ollama_openai_base_url)" +OLLAMA_RUNTIME="$(_toml_get ollama_runtime)" + +CORE_URL="${CORE_URL:-http://127.0.0.1:8100}" +APPLE_URL="${APPLE_URL:-http://127.0.0.1:8201}" +OLLAMA_TAGS="${OLLAMA_TAGS:-http://127.0.0.1:11434/api/tags}" +OPENAI_URL="${OPENAI_URL:-http://127.0.0.1:8091}" +OLLAMA_RUNTIME="${OLLAMA_RUNTIME:-native}" + +# --- probe ----------------------------------------------------------------- +probe() { + local url="$1" + local code + if code=$(curl -s --max-time 2 "$url" -o /dev/null -w "%{http_code}" 2>/dev/null); then + echo "UP(${code})" + else + echo "DOWN" + fi +} + +# --- affichage ------------------------------------------------------------- +echo "=== ANE Runtime Healthcheck ===" +echo "" + +CORE_STATUS=$(probe "${CORE_URL%/}/health") +APPLE_STATUS=$(probe "${APPLE_URL%/}/health") +OLLAMA_STATUS=$(probe "$OLLAMA_TAGS") +OPENAI_STATUS=$(probe "${OPENAI_URL%/}/health") + +printf " core (mascarade %-25s %s\n" "${CORE_URL}):" "$CORE_STATUS" +printf " apple (ANE runtime %-24s %s\n" "${APPLE_URL}):" "$APPLE_STATUS" +printf " ollama daemon %-30s %s\n" "(${OLLAMA_TAGS}):" "$OLLAMA_STATUS" +printf " openai-compat (llama-server) %-14s %s\n" "(${OPENAI_URL}):" "$OPENAI_STATUS" +printf " ollama_runtime setting: %s\n" "$OLLAMA_RUNTIME" +echo "" + +# --- guidance corrective --------------------------------------------------- +PROBLEMS=0 + +if [[ "$CORE_STATUS" == "DOWN" ]]; then + echo "[!] core (:$(echo "$CORE_URL" | grep -oE '[0-9]{4,5}$' || echo '8100')) DOWN" + echo " -> Démarrer Mascarade (core Python OpenAI-compatible)" + echo " cd /path/to/mascarade && python3 -m mascarade.server" + echo "" + PROBLEMS=$((PROBLEMS + 1)) +fi + +if [[ "$APPLE_STATUS" == "DOWN" ]]; then + echo "[!] apple (:$(echo "$APPLE_URL" | grep -oE '[0-9]{4,5}$' || echo '8201')) DOWN" + echo " -> Démarrer l'Apple ANE server (voir scripts/smoke_local_generation.sh)" + echo "" + PROBLEMS=$((PROBLEMS + 1)) +fi + +if [[ "$OPENAI_STATUS" == "DOWN" && "$OLLAMA_RUNTIME" == "openai_compatible" ]]; then + echo "[!] llama-server (:$(echo "$OPENAI_URL" | grep -oE '[0-9]{4,5}$' || echo '8091')) DOWN" + echo " ollama_runtime = openai_compatible → les lots ollama:* vont déclencher un checkpoint." + echo "" + echo " Pour démarrer llama-server sur qwen2.5:7b :" + echo " bash scripts/prepare_llama_cpp_runtime.sh --model qwen2.5:7b --port 8091" + echo "" + echo " Pour démarrer llama-server sur qwen2.5:1.5b :" + echo " bash scripts/prepare_llama_cpp_runtime.sh --model qwen2.5:1.5b --port 8091" + echo "" + PROBLEMS=$((PROBLEMS + 1)) +fi + +if [[ "$OLLAMA_STATUS" == "DOWN" ]]; then + echo "[!] Ollama daemon (:11434) ne répond pas" + echo " -> ollama serve" + echo "" + PROBLEMS=$((PROBLEMS + 1)) +fi + +if [[ $PROBLEMS -eq 0 ]]; then + echo "[ok] Tous les services configurés répondent." +fi + +echo "---" +echo "Cockpit ops : python3 scripts/ops_tui.py" +echo "Relancer un lot : python3 scripts/run_next_lots.py --lot priority_models" +echo "Reprendre : python3 scripts/run_next_lots.py --resume automation/state/next_lots_state.json" +exit 0 diff --git a/scripts/mascarade_remote_tui.py b/scripts/mascarade_remote_tui.py new file mode 100644 index 0000000..365b74c --- /dev/null +++ b/scripts/mascarade_remote_tui.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +from pathlib import Path +import subprocess +import sys +import time +from urllib import error as urllib_error, request as urllib_request + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from core.runtime.config import runtime_probe_profile +from core.runtime.health import probe_runtime_health +from core.runtime.remote_hosts import RemoteHostConfig, read_remote_hosts + + +def _short(value: str, width: int) -> str: + if len(value) <= width: + return value + if width <= 3: + return value[:width] + return value[: width - 3] + "..." + + +def _http_probe(url: str, timeout: float = 1.5) -> str: + try: + with urllib_request.urlopen(url, timeout=timeout) as response: + return f"UP ({response.status})" + except urllib_error.HTTPError as error: + return f"UP ({error.code})" + except (urllib_error.URLError, OSError, ValueError): + return "DOWN" + + +def _run_ssh_probe(target: str, timeout_seconds: int) -> str: + cmd = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + f"ConnectTimeout={max(1, timeout_seconds)}", + target, + "echo", + "ok", + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=max(2, timeout_seconds + 1), check=False) + except (subprocess.SubprocessError, OSError, ValueError): + return "DOWN" + + if result.returncode == 0 and result.stdout.strip() == "ok": + return "UP" + return "DOWN" + + +def _probe_remote_runtime(host: RemoteHostConfig, timeout: float = 1.5) -> tuple[bool, str | None]: + profile = runtime_probe_profile( + host.local_base_url(), + timeout=timeout, + name=host.probe_profile_name(), + ) + health = probe_runtime_health(profile) + return health.ok, health.active_model + + +def _render(config_path: Path, hosts: list[RemoteHostConfig]) -> str: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") + lines: list[str] = [] + + lines.append("Mascarade Remote TUI") + lines.append(f"utc now: {now}") + lines.append(f"config: {config_path}") + lines.append("") + + if not hosts: + lines.append("Aucun host valide dans la config.") + lines.append("Attendu: [[hosts]] avec name, ssh_target, local_tunnel_port.") + return "\n".join(lines) + + lines.append("etat remote:") + for host in hosts: + ssh_status = _run_ssh_probe(host.ssh_target, host.ssh_connect_timeout_seconds) + local_http_status = _http_probe(host.local_health_url()) + runtime_ok, active_model = _probe_remote_runtime(host) + runtime_status = "UP" if runtime_ok else "DOWN" + if active_model: + runtime_status += f" model={active_model}" + + lines.append( + "- {name}: ssh={ssh} | tunnel={tunnel} | runtime={runtime} | target={target}".format( + name=host.name, + ssh=ssh_status, + tunnel=local_http_status, + runtime=runtime_status, + target=_short(host.ssh_target, 40), + ) + ) + lines.append(f" local url: {host.local_health_url()}") + lines.append(f" profile: {host.probe_profile_name()}") + lines.append(f" tunnel cmd: {host.tunnel_command()}") + lines.append(f" launchd: {host.label}") + + if ssh_status != "UP": + lines.append(f" next: verifier cle/VPN puis tester `ssh {host.ssh_target} echo ok`") + elif local_http_status == "DOWN": + lines.append(f" next: lancer `{host.tunnel_command()}`") + lines.append( + " next: ou relancer launchd `launchctl kickstart -k " + f"gui/$(id -u)/{host.label}`" + ) + elif not runtime_ok: + lines.append(" next: tunnel OK mais runtime distant indisponible; verifier Mascarade sur l'hote remote") + else: + lines.append(" next: OK - tunnel utilisable") + + lines.append("") + lines.append("actions utiles:") + lines.append("- python3 scripts/reports_ops.py analyze-logs --top 12") + lines.append("- python3 scripts/reports_ops.py prune --days 14 --delete-workspaces") + lines.append("- python3 scripts/reports_ops.py prune --days 14 --delete-workspaces --apply") + return "\n".join(lines) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python3 scripts/mascarade_remote_tui.py") + parser.add_argument("--config", default="automation/mascarade_hosts.toml") + parser.add_argument("--watch", action="store_true", help="Rafraichit en boucle la vue TUI.") + parser.add_argument("--interval", type=float, default=4.0, help="Intervalle de rafraichissement en secondes.") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + config_path = Path(args.config) + + def draw_once() -> None: + hosts = read_remote_hosts(config_path) + print(_render(config_path, hosts)) + + if args.watch: + interval = max(0.5, float(args.interval)) + try: + while True: + print("\033[2J\033[H", end="") + draw_once() + time.sleep(interval) + except KeyboardInterrupt: + return 0 + else: + draw_once() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/next_lots_tui.py b/scripts/next_lots_tui.py new file mode 100755 index 0000000..6a6ca37 --- /dev/null +++ b/scripts/next_lots_tui.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +from pathlib import Path +import sys +import time + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from core.reporting import classification_count, latest_report_run, safe_read_json + + +def _short(text: str, width: int) -> str: + if len(text) <= width: + return text + if width <= 3: + return text[:width] + return text[: width - 3] + "..." + + +def _render(state: dict[str, object] | None, latest_run: dict[str, object] | None, reports_root: Path) -> str: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") + lines: list[str] = [] + lines.append("AI Novel Engine - Next Lots TUI") + lines.append(f"UTC now: {now}") + lines.append(f"reports root: {reports_root}") + lines.append("") + + if not state: + lines.append("state: absent ou illisible") + else: + step_index = int(state.get("step_index", 0)) + model_index = int(state.get("model_index", 0)) + steps = state.get("steps") or [] + lot = str(state.get("lot", "")) + updated = str(state.get("updated_at", "")) + pending = state.get("pending_manual_action") + results = state.get("results") or [] + counts = classification_count([item for item in results if isinstance(item, dict)]) + + lines.append(f"lot: {lot}") + lines.append(f"updated_at: {updated}") + lines.append(f"progress: step {step_index + 1}/{max(len(steps), 1)} model_index={model_index}") + lines.append( + "results: " + + ", ".join( + [ + f"accepted={counts.get('accepted', 0)}", + f"quality_blocked={counts.get('quality_blocked', 0)}", + f"provider_failed={counts.get('provider_failed', 0)}", + f"preflight_only={counts.get('preflight_only', 0)}", + f"pending={counts.get('pending', 0)}", + ] + ) + ) + + if pending: + reason = str(pending.get("reason", "")) + resume = str(pending.get("resume_state", "")) + lines.append("checkpoint: OUI") + lines.append(f"reason: {_short(reason, 120)}") + lines.append(f"resume: {_short(resume, 120)}") + else: + lines.append("checkpoint: non") + + lines.append("") + lines.append("dernieres classifications:") + recent = [item for item in results if isinstance(item, dict)][-8:] + if not recent: + lines.append("- aucune") + for item in recent: + model = str(item.get("model", "")) + cls = str(item.get("classification", "pending")) + status = str(item.get("status", "")) + lines.append(f"- {model} -> {cls} ({status})") + + lines.append("") + lines.append("latest report snapshot:") + if not latest_run: + lines.append("- aucun run.json historise") + else: + lines.append(f"- lot: {latest_run.get('lot', '')}") + lines.append(f"- updated_at: {latest_run.get('updated_at', '')}") + results = latest_run.get("results") or [] + counts = classification_count([item for item in results if isinstance(item, dict)]) + lines.append( + "- counts: " + + ", ".join( + [ + f"accepted={counts.get('accepted', 0)}", + f"quality_blocked={counts.get('quality_blocked', 0)}", + f"provider_failed={counts.get('provider_failed', 0)}", + ] + ) + ) + + lines.append("") + lines.append("tips:") + lines.append("- resume: python3 scripts/run_next_lots.py --resume automation/state/next_lots_state.json") + lines.append("- sync docs only: python3 scripts/run_next_lots.py --lot tracking_sync --report-only") + return "\n".join(lines) + + +def _draw_once(state_path: Path, reports_root: Path) -> str: + state = safe_read_json(state_path) + latest = latest_report_run(reports_root) + return _render(state, latest, reports_root) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python3 scripts/next_lots_tui.py") + parser.add_argument("--state", default="automation/state/next_lots_state.json") + parser.add_argument("--reports-root", default="automation/reports") + parser.add_argument("--watch", action="store_true", help="Rafraichit en boucle la vue TUI.") + parser.add_argument("--interval", type=float, default=2.0, help="Intervalle de rafraichissement en secondes.") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + state_path = Path(args.state) + reports_root = Path(args.reports_root) + + if args.watch: + interval = max(0.2, float(args.interval)) + try: + while True: + print("\033[2J\033[H", end="") + print(_draw_once(state_path, reports_root)) + time.sleep(interval) + except KeyboardInterrupt: + return 0 + else: + print(_draw_once(state_path, reports_root)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ops_tui.py b/scripts/ops_tui.py new file mode 100755 index 0000000..3573c52 --- /dev/null +++ b/scripts/ops_tui.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +from pathlib import Path +import sys +import time +import tomllib +from typing import Any +from urllib import request as urllib_request, error as urllib_error + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from core.project.loader import ProjectState +from core.reporting import ( + classification_count, + collect_log_error_counts, + recent_report_runs, + safe_read_json, +) +from core.runtime.config import runtime_probe_profile +from core.runtime.health import probe_runtime_health +from core.runtime.profiles import runtime_probe_name + + +def _short(text: str, width: int) -> str: + if len(text) <= width: + return text + if width <= 3: + return text[:width] + return text[: width - 3] + "..." + + +def _human_bytes(value: int) -> str: + size = float(max(value, 0)) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{size:.1f}{unit}" + size /= 1024 + return f"{size:.1f}GB" + + +def _safe_manifest(path: Path) -> dict[str, Any]: + try: + return tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return {} + + + +def _probe_url(url: str, timeout: float = 1.5) -> bool: + """Retourne True si l'URL répond avec un code HTTP, quelle que soit la valeur.""" + try: + urllib_request.urlopen(url, timeout=timeout) + return True + except urllib_error.HTTPError: + return True # le serveur répond (ex: 404 Ollama /health) + except (OSError, ValueError, urllib_error.URLError): + return False + + +def _probe_openai_runtime(base_url: str, timeout: float = 1.5, *, probe_kind: str = "core") -> tuple[bool, str | None]: + profile = runtime_probe_profile(base_url, timeout=timeout, name=runtime_probe_name(probe_kind)) + health = probe_runtime_health(profile) + return health.ok, health.active_model + + +def _reports_size_bytes(reports_root: Path) -> int: + return sum(path.stat().st_size for path in reports_root.glob("**/*") if path.is_file()) + + +def _render_project(project: ProjectState) -> list[str]: + state = project.summary() + lines = [ + "projet:", + f"- chapitre courant: {state['current_chapter'] or 'aucun'}", + f"- chapitres connus: {len(state['known_chapters'])}", + f"- en attente de validation: {len(state['awaiting_acceptance'])}", + f"- quality_blocked: {len(state['quality_blocked_chapters'])}", + f"- failed: {len(state['failed_chapters'])}", + ] + + if state["awaiting_acceptance"]: + lines.append("- attente la plus recente: " + str(state["awaiting_acceptance"][0]["chapter"])) + if state["quality_blocked_chapters"]: + blocked = state["quality_blocked_chapters"][0] + blockers = ", ".join(blocked["quality_blockers"]) if blocked["quality_blockers"] else "inconnu" + lines.append(f"- premier blocage qualite: {blocked['chapter']} ({blockers})") + if state["failed_chapters"]: + failed = state["failed_chapters"][0] + lines.append(f"- premier echec runtime: {failed['chapter']} ({failed['failed_stage'] or 'n/a'})") + return lines + + +def _render_automation(state: dict[str, Any] | None) -> list[str]: + lines = ["automation:"] + if not state: + lines.append("- etat: absent ou illisible") + return lines + + step_index = int(state.get("step_index", 0)) + steps = state.get("steps") or [] + results = [item for item in (state.get("results") or []) if isinstance(item, dict)] + counts = classification_count(results) + lines.extend( + [ + f"- lot: {state.get('lot', '')}", + f"- updated_at: {state.get('updated_at', '')}", + f"- progression: step {step_index + 1}/{max(len(steps), 1)}", + ( + "- resultats: " + + ", ".join( + [ + f"accepted={counts.get('accepted', 0)}", + f"quality_blocked={counts.get('quality_blocked', 0)}", + f"provider_failed={counts.get('provider_failed', 0)}", + f"preflight_only={counts.get('preflight_only', 0)}", + ] + ) + ), + ] + ) + + pending = state.get("pending_manual_action") + if isinstance(pending, dict): + lines.append("- checkpoint: OUI") + lines.append(f"- raison: {_short(str(pending.get('reason', '')), 110)}") + lines.append(f"- reprise: {_short(str(pending.get('resume_state', '')), 110)}") + else: + lines.append("- checkpoint: non") + + recommendation = str(state.get("next_recommended_lot", "")).strip() + if recommendation: + lines.append(f"- suite: {_short(recommendation, 110)}") + return lines + + +def _render_manifest(manifest: dict[str, Any]) -> list[str]: + paths = manifest.get("paths") if isinstance(manifest, dict) else {} + if not isinstance(paths, dict): + paths = {} + + def _tag(url: str, probe_url: str | None = None) -> str: + target = probe_url or url + if not target or target == "n/a": + return "n/a" + up = _probe_url(target) + return f"{url} [{'UP' if up else 'DOWN'}]" + + def _runtime_tag(url: str) -> str: + if not url or url == "n/a": + return "n/a" + probe_kind = "core" + if url == apple_url: + probe_kind = "apple" + elif url == ollama_openai_url: + probe_kind = "ollama_openai" + up, active_model = _probe_openai_runtime(url, probe_kind=probe_kind) + suffix = f" model={active_model}" if active_model else "" + return f"{url} [{'UP' if up else 'DOWN'}{suffix}]" + + core_url = str(paths.get("core_base_url", "n/a")) + apple_url = str(paths.get("apple_runtime_url", "n/a")) + ollama_tags_url = str(paths.get("ollama_tags_url", "n/a")) + ollama_runtime = str(paths.get("ollama_runtime", "n/a")) + ollama_openai_url = str(paths.get("ollama_openai_base_url", "n/a")) + + # Ollama daemon répond sur /api/tags, pas /health + ollama_health_url = ollama_tags_url if "api/tags" in ollama_tags_url else ollama_tags_url + + return [ + "runtime:", + f"- core: {_runtime_tag(core_url)}", + f"- apple: {_runtime_tag(apple_url)}", + f"- ollama daemon: {_tag(ollama_tags_url, ollama_health_url)}", + f"- ollama runtime: {ollama_runtime}", + f"- ollama openai: {_runtime_tag(ollama_openai_url)}", + ] + + +def _render_recent_reports(reports_root: Path, limit: int) -> list[str]: + lines = ["reports recents:"] + recent = recent_report_runs(reports_root, limit=limit) + if not recent: + lines.append("- aucun run.json historise") + return lines + + for run_path, payload in recent: + results = [item for item in payload.get("results") or [] if isinstance(item, dict)] + counts = classification_count(results) + lines.append( + "- {name} | lot={lot} | accepted={accepted} qb={qb} pf={pf}".format( + name=run_path.parent.name, + lot=payload.get("lot", ""), + accepted=counts.get("accepted", 0), + qb=counts.get("quality_blocked", 0), + pf=counts.get("provider_failed", 0), + ) + ) + return lines + + +def _render_log_section(reports_root: Path, top: int) -> list[str]: + lines = ["logs:"] + error_counts, model_errors = collect_log_error_counts(reports_root) + if not error_counts: + lines.append("- aucune erreur stderr agregee") + return lines + + lines.append("- top erreurs:") + for message, count in error_counts.most_common(max(top, 1)): + lines.append(f" {count}x {message}") + + lines.append("- principaux modeles:") + shown = 0 + for model in sorted(model_errors): + head = model_errors[model].most_common(1) + if not head: + continue + message, count = head[0] + lines.append(f" {model}: {count}x {message}") + shown += 1 + if shown >= top: + break + return lines + + +def _render_footer(reports_root: Path) -> list[str]: + size_bytes = _reports_size_bytes(reports_root) + return [ + "actions utiles:", + "- python3 scripts/next_lots_tui.py --watch --interval 2", + "- python3 scripts/reports_ops.py summary", + "- python3 scripts/reports_ops.py analyze-logs --top 10", + "- python3 scripts/reports_ops.py prune --days 14", + f"- empreinte reports: {_human_bytes(size_bytes)}", + ] + + +def _render(root: Path, state_path: Path, reports_root: Path, manifest_path: Path, recent: int, top: int) -> str: + now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") + project = ProjectState(root) + state = safe_read_json(state_path) + manifest = _safe_manifest(manifest_path) + + sections = [ + [ + "AI Novel Engine - Ops TUI", + f"utc now: {now}", + f"repo root: {root}", + f"manifest: {manifest_path}", + "", + ], + _render_manifest(manifest), + _render_project(project), + _render_automation(state), + _render_recent_reports(reports_root, recent), + _render_log_section(reports_root, top), + _render_footer(reports_root), + ] + return "\n\n".join("\n".join(section) for section in sections) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python3 scripts/ops_tui.py") + parser.add_argument("--root", default=str(REPO_ROOT)) + parser.add_argument("--manifest", default="automation/next_lots.toml") + parser.add_argument("--state", default="automation/state/next_lots_state.json") + parser.add_argument("--reports-root", default="automation/reports") + parser.add_argument("--recent", type=int, default=5, help="Nombre de reports recents affiches.") + parser.add_argument("--top", type=int, default=5, help="Nombre d'erreurs stderr agregees.") + parser.add_argument("--watch", action="store_true", help="Rafraichit en boucle la vue TUI.") + parser.add_argument("--interval", type=float, default=3.0, help="Intervalle de rafraichissement en secondes.") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + root = Path(args.root) + manifest_path = root / args.manifest if not Path(args.manifest).is_absolute() else Path(args.manifest) + state_path = root / args.state if not Path(args.state).is_absolute() else Path(args.state) + reports_root = root / args.reports_root if not Path(args.reports_root).is_absolute() else Path(args.reports_root) + + def draw() -> None: + print( + _render( + root=root, + state_path=state_path, + reports_root=reports_root, + manifest_path=manifest_path, + recent=max(args.recent, 1), + top=max(args.top, 1), + ) + ) + + if args.watch: + interval = max(0.5, float(args.interval)) + try: + while True: + print("\033[2J\033[H", end="") + draw() + time.sleep(interval) + except KeyboardInterrupt: + return 0 + else: + draw() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prepare_llama_cpp_runtime.sh b/scripts/prepare_llama_cpp_runtime.sh new file mode 100755 index 0000000..526e8ae --- /dev/null +++ b/scripts/prepare_llama_cpp_runtime.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +MODEL="" +HOST="127.0.0.1" +PORT="8091" +CTX_SIZE="${LLAMA_CPP_CTX_SIZE:-8192}" +GPU_LAYERS="${LLAMA_CPP_GPU_LAYERS:-999}" +THREADS="${LLAMA_CPP_THREADS:-}" +ALIASES="" +LOG_FILE="" +RESUME_STATE="" +ANE_SCRIPT="" + +usage() { + cat <<'EOF' +Usage: scripts/prepare_llama_cpp_runtime.sh [options] + +Prepare the manual command needed to serve an Ollama model through llama-server +with an OpenAI-compatible alias usable by ai-novel-engine. + +Options: + --model Ollama model name, with or without the `ollama:` prefix + --host Host to bind llama-server on (default: 127.0.0.1) + --port Port to bind llama-server on (default: 8091) + --ctx-size Context size to pass to llama-server (default: 8192) + --gpu-layers GPU layers setting for llama-server (default: 999) + --threads CPU threads for llama-server (default: unset) + --aliases Extra aliases to expose in addition to the ANE defaults + --log-file Log file path for llama-server + --resume-state State file to resume in ai-novel-engine + --ane-script Path to ai-novel-engine/scripts/run_next_lots.py + -h, --help Show this help +EOF +} + +die() { + echo "$*" >&2 + exit 2 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1" +} + +normalize_model_name() { + local raw="$1" + if [[ "$raw" == ollama:* ]]; then + printf '%s\n' "${raw#ollama:}" + return + fi + printf '%s\n' "$raw" +} + +slugify() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '_' +} + +join_aliases() { + local raw_model="$1" + local extra_aliases="$2" + local joined="ollama:${raw_model},${raw_model}" + if [[ -n "$extra_aliases" ]]; then + joined="${joined},${extra_aliases}" + fi + printf '%s\n' "$joined" +} + +resolve_blob_path() { + local raw_model="$1" + local modelfile + local from_line + + modelfile="$(ollama show --modelfile "$raw_model" 2>/dev/null || true)" + from_line="$(printf '%s\n' "$modelfile" | awk '/^FROM / { print substr($0, 6); exit }')" + + [[ -n "$from_line" ]] || die "Unable to resolve a GGUF blob for Ollama model: ${raw_model}" + [[ -e "$from_line" ]] || die "Resolved GGUF blob does not exist: ${from_line}" + printf '%s\n' "$from_line" +} + +print_resume_hint() { + if [[ -n "${RESUME_STATE}" && -n "${ANE_SCRIPT}" ]]; then + printf '\n# resume ANE lot\npython3 %q --resume %q\n' "${ANE_SCRIPT}" "${RESUME_STATE}" + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --model) + MODEL="${2:-}" + shift 2 + ;; + --host) + HOST="${2:-}" + shift 2 + ;; + --port) + PORT="${2:-}" + shift 2 + ;; + --ctx-size) + CTX_SIZE="${2:-}" + shift 2 + ;; + --gpu-layers) + GPU_LAYERS="${2:-}" + shift 2 + ;; + --threads) + THREADS="${2:-}" + shift 2 + ;; + --aliases) + ALIASES="${2:-}" + shift 2 + ;; + --log-file) + LOG_FILE="${2:-}" + shift 2 + ;; + --resume-state) + RESUME_STATE="${2:-}" + shift 2 + ;; + --ane-script) + ANE_SCRIPT="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "Unknown argument: $1" + ;; + esac +done + +[[ -n "$MODEL" ]] || die "--model is required" +[[ -n "$HOST" ]] || die "--host cannot be empty" +[[ "$PORT" =~ ^[0-9]+$ ]] || die "--port must be an integer" +[[ "$CTX_SIZE" =~ ^[0-9]+$ ]] || die "--ctx-size must be an integer" +[[ "$GPU_LAYERS" =~ ^-?[0-9]+$ ]] || die "--gpu-layers must be an integer" +if [[ -n "$THREADS" && ! "$THREADS" =~ ^[0-9]+$ ]]; then + die "--threads must be an integer" +fi + +require_cmd ollama +require_cmd llama-server + +RAW_MODEL="$(normalize_model_name "$MODEL")" +BLOB_PATH="$(resolve_blob_path "$RAW_MODEL")" +MODEL_ALIASES="$(join_aliases "$RAW_MODEL" "$ALIASES")" + +if [[ -z "$LOG_FILE" ]]; then + LOG_FILE="${TMPDIR:-/tmp}/ane_llama_cpp_$(slugify "$RAW_MODEL").log" +fi + +cat < $(printf '%q' "${LOG_FILE}") 2>&1 & + +sleep 2 +curl -fsS http://${HOST}:${PORT}/health +curl -fsS http://${HOST}:${PORT}/v1/models +tail -n 40 $(printf '%q' "${LOG_FILE}") || true +EOF + +print_resume_hint diff --git a/scripts/reports_ops.py b/scripts/reports_ops.py new file mode 100755 index 0000000..7c3a998 --- /dev/null +++ b/scripts/reports_ops.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from collections import Counter +from datetime import datetime, timedelta, timezone +from pathlib import Path +import shutil +import sys +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from core.reporting import ( + classification_count, + collect_log_error_counts, + folder_timestamp, + iter_run_payloads, + safe_stamp, +) + + +def cmd_summary(reports_root: Path) -> int: + payloads = iter_run_payloads(reports_root) + if not payloads: + print("Aucun report trouve.") + return 0 + + latest_by_model: dict[str, tuple[datetime, dict[str, Any]]] = {} + total_counts: Counter[str] = Counter() + + for _, run in payloads: + updated = safe_stamp(str(run.get("updated_at", ""))) + for raw in run.get("results") or []: + if not isinstance(raw, dict): + continue + total_counts[str(raw.get("classification", "pending"))] += 1 + model = str(raw.get("model", "")).strip() + if not model: + continue + current = latest_by_model.get(model) + if current is None or updated >= current[0]: + latest_by_model[model] = (updated, raw) + + print(f"reports: {len(payloads)}") + print( + "global counts: " + + ", ".join( + [ + f"accepted={total_counts.get('accepted', 0)}", + f"quality_blocked={total_counts.get('quality_blocked', 0)}", + f"provider_failed={total_counts.get('provider_failed', 0)}", + f"preflight_only={total_counts.get('preflight_only', 0)}", + ] + ) + ) + print("") + print("latest by model:") + for model in sorted(latest_by_model): + _, row = latest_by_model[model] + cls = str(row.get("classification", "pending")) + status = str(row.get("status", "")) + failed_stage = str(row.get("failed_stage", "")) + print(f"- {model}: {cls}, status={status}, failed_stage={failed_stage}") + return 0 + + +def cmd_analyze_logs(reports_root: Path, top: int) -> int: + if not reports_root.exists(): + print("Aucun report root trouve.") + return 0 + + error_counts, model_errors = collect_log_error_counts(reports_root) + + print("top erreurs stderr:") + for msg, count in error_counts.most_common(max(top, 1)): + print(f"- {count}x {msg}") + + print("") + print("erreurs par pseudo-modele:") + for model in sorted(model_errors): + head = model_errors[model].most_common(1) + if not head: + continue + msg, count = head[0] + print(f"- {model}: {count}x {msg}") + return 0 + + +def _folder_timestamp(report_dir: Path) -> datetime: + return folder_timestamp(report_dir) + + +def cmd_prune(reports_root: Path, days: int, delete_workspaces: bool, apply: bool) -> int: + if not reports_root.exists(): + print("Aucun report root trouve.") + return 0 + + keep_after = datetime.now(timezone.utc) - timedelta(days=max(days, 0)) + report_dirs = [p for p in sorted(reports_root.iterdir()) if p.is_dir()] + + deleted = 0 + reclaimed = 0 + + for report_dir in report_dirs: + stamp = _folder_timestamp(report_dir) + if stamp >= keep_after: + continue + + if delete_workspaces: + target = report_dir / "workspaces" + if target.exists(): + size = sum(f.stat().st_size for f in target.glob("**/*") if f.is_file()) + if apply: + shutil.rmtree(target, ignore_errors=True) + reclaimed += size + if not apply: + print(f"[dry-run] remove workspace {target}") + else: + print(f"removed workspace {target}") + continue + + size = sum(f.stat().st_size for f in report_dir.glob("**/*") if f.is_file()) + if apply: + shutil.rmtree(report_dir, ignore_errors=True) + print(f"removed report {report_dir}") + else: + print(f"[dry-run] remove report {report_dir}") + reclaimed += size + deleted += 1 + + print("") + print(f"deleted_report_dirs={deleted}") + print(f"reclaimed_bytes={reclaimed}") + if not apply: + print("Aucune suppression effective (mode dry-run). Utiliser --apply pour supprimer.") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python3 scripts/reports_ops.py") + parser.add_argument("--reports-root", default="automation/reports") + + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("summary") + + analyze = sub.add_parser("analyze-logs") + analyze.add_argument("--top", type=int, default=10) + + prune = sub.add_parser("prune") + prune.add_argument("--days", type=int, default=14) + prune.add_argument("--delete-workspaces", action="store_true") + prune.add_argument("--apply", action="store_true") + + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + reports_root = Path(args.reports_root) + + if args.command == "summary": + return cmd_summary(reports_root) + if args.command == "analyze-logs": + return cmd_analyze_logs(reports_root, top=args.top) + if args.command == "prune": + return cmd_prune( + reports_root, + days=args.days, + delete_workspaces=bool(args.delete_workspaces), + apply=bool(args.apply), + ) + + raise RuntimeError(f"Commande inconnue: {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/setup_mascarade_launchd.py b/scripts/setup_mascarade_launchd.py new file mode 100644 index 0000000..94b8a58 --- /dev/null +++ b/scripts/setup_mascarade_launchd.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import plistlib +import subprocess +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.runtime.remote_hosts import RemoteHostConfig, read_remote_hosts + +HostConfig = RemoteHostConfig + + +def _read_hosts(config_path: Path) -> list[HostConfig]: + return read_remote_hosts(config_path, stderr=sys.stderr) + + +def _plist_payload(host: HostConfig) -> dict: + return { + "Label": host.label, + "ProgramArguments": [ + "/usr/bin/ssh", + "-N", + "-o", + "ExitOnForwardFailure=yes", + "-o", + "ServerAliveInterval=30", + "-o", + "ServerAliveCountMax=3", + "-L", + f"{host.local_bind_host}:{host.local_tunnel_port}:127.0.0.1:{host.remote_core_port}", + host.ssh_target, + ], + "RunAtLoad": True, + "KeepAlive": {"SuccessfulExit": False}, + "ProcessType": "Background", + } + + +def _write_plists(hosts: list[HostConfig], launch_agents_dir: Path, dry_run: bool) -> list[Path]: + paths: list[Path] = [] + for host in hosts: + plist_path = launch_agents_dir / host.plist_name + paths.append(plist_path) + if dry_run: + continue + payload = _plist_payload(host) + plist_path.parent.mkdir(parents=True, exist_ok=True) + plist_path.write_bytes(plistlib.dumps(payload, fmt=plistlib.FMT_XML)) + return paths + + +def _run_launchctl(command: list[str], dry_run: bool) -> int: + if dry_run: + return 0 + result = subprocess.run(command, check=False, capture_output=True, text=True) + if result.returncode != 0: + print(f"launchctl failed ({result.returncode}): {' '.join(command)}", file=sys.stderr) + stderr = (result.stderr or "").strip() + if stderr: + print(stderr, file=sys.stderr) + return result.returncode + + +def install(hosts: list[HostConfig], launch_agents_dir: Path, dry_run: bool) -> int: + paths = _write_plists(hosts, launch_agents_dir, dry_run) + failed = False + for plist_path in paths: + print(f"plist: {plist_path}") + _run_launchctl(["launchctl", "unload", str(plist_path)], dry_run) + if _run_launchctl(["launchctl", "load", str(plist_path)], dry_run) != 0: + failed = True + if dry_run: + print("dry-run: aucun fichier ecrit et aucun launchctl execute") + return 1 if failed else 0 + + +def uninstall(hosts: list[HostConfig], launch_agents_dir: Path, dry_run: bool) -> int: + for host in hosts: + plist_path = launch_agents_dir / host.plist_name + print(f"remove: {plist_path}") + _run_launchctl(["launchctl", "unload", str(plist_path)], dry_run) + if not dry_run and plist_path.exists(): + plist_path.unlink() + return 0 + + +def status(hosts: list[HostConfig]) -> int: + uid = os.getuid() + failed = False + for host in hosts: + print(host.label) + result = subprocess.run(["launchctl", "print", f"gui/{uid}/{host.label}"], check=False) + if result.returncode != 0: + failed = True + return 1 if failed else 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python3 scripts/setup_mascarade_launchd.py") + parser.add_argument("command", choices=["install", "uninstall", "render", "status"], help="Action a executer") + parser.add_argument("--config", default="automation/mascarade_hosts.toml") + parser.add_argument("--launch-agents-dir", default=str(Path.home() / "Library/LaunchAgents")) + parser.add_argument("--dry-run", action="store_true") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + config_path = Path(args.config) + launch_agents_dir = Path(args.launch_agents_dir).expanduser() + + hosts = _read_hosts(config_path) + if not hosts: + print("Aucun host valide trouve dans la config.") + return 1 + + if args.command == "install": + return install(hosts, launch_agents_dir, args.dry_run) + if args.command == "uninstall": + return uninstall(hosts, launch_agents_dir, args.dry_run) + if args.command == "status": + return status(hosts) + + # render + paths = _write_plists(hosts, launch_agents_dir, dry_run=True) + for host, plist_path in zip(hosts, paths): + print(f"[{host.name}] {plist_path}") + print(plistlib.dumps(_plist_payload(host), fmt=plistlib.FMT_XML).decode("utf-8")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_local_generation.sh b/scripts/smoke_local_generation.sh index fd10e57..4e4c264 100755 --- a/scripts/smoke_local_generation.sh +++ b/scripts/smoke_local_generation.sh @@ -37,9 +37,9 @@ Environment: 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_REWRITE Default: 192 for apple-coreml, 1024 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_REPAIR Default: 160 for apple-coreml, 1536 otherwise ANE_MAX_TOKENS_MEMORY Default: 128 for apple-coreml, 320 otherwise EOF } @@ -123,9 +123,9 @@ else 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_REWRITE="${ANE_MAX_TOKENS_REWRITE:-1024}" export ANE_MAX_TOKENS_GATE="${ANE_MAX_TOKENS_GATE:-384}" - export ANE_MAX_TOKENS_REPAIR="${ANE_MAX_TOKENS_REPAIR:-512}" + export ANE_MAX_TOKENS_REPAIR="${ANE_MAX_TOKENS_REPAIR:-1536}" export ANE_MAX_TOKENS_MEMORY="${ANE_MAX_TOKENS_MEMORY:-320}" fi diff --git a/tests/test_generation_pipeline.py b/tests/test_generation_pipeline.py index af03f01..d522a18 100644 --- a/tests/test_generation_pipeline.py +++ b/tests/test_generation_pipeline.py @@ -11,7 +11,8 @@ 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.evaluation.models import NarrativeJudgeReport +from core.generation.models import ControlReport, ManuscriptGateReport, MemoryUpdate from core.generation.pipeline import GenerationPipeline from core.generation.provider import ( GenerationRequest, @@ -21,6 +22,8 @@ from core.generation.provider import ( ProviderConfigurationError, ProviderError, ) +from core.runtime.health import probe_runtime_health +from core.runtime.models import RuntimeProfile from core.project.loader import ProjectState @@ -165,6 +168,202 @@ class GenerationPipelineTests(unittest.TestCase): self.assertEqual(meta["stage_attempts"]["memory"], 1) self.assertEqual(meta["provider"]["kind"], "MockGenerationProvider") + def test_judge_is_disabled_by_default(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) + self.assertNotIn("judge", [request.stage for request in provider.requests]) + gate_payload = json.loads(outcome.gate_path.read_text(encoding="utf-8")) + self.assertIsNone(gate_payload["judge_report"]) + self.assertEqual(gate_payload["judge_blockers"], []) + + def test_judge_report_is_merged_into_gate_metadata_when_enabled(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": { + "ready_for_manuscript": True, + "summary": "Le chapitre est narratif et peut etre promu.", + "blockers": [], + "recommendations": ["resserrer la chute"], + "heuristic_blockers": [], + }, + "judge": { + "ready_for_manuscript": True, + "summary": "La decision finale est nette et sa consequence immediate est visible.", + "blockers": [], + "recommendations": ["rendre la tension du milieu plus nette"], + }, + "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"}], + }, + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + with mock.patch.dict("os.environ", {"ANE_JUDGE_MODEL": "ollama:qwen2.5:7b"}, clear=False): + 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", "rewrite", "gate", "judge", "memory"], + ) + gate_payload = json.loads(outcome.gate_path.read_text(encoding="utf-8")) + self.assertEqual(gate_payload["judge_blockers"], []) + self.assertEqual( + gate_payload["judge_report"]["summary"], + "La decision finale est nette et sa consequence immediate est visible.", + ) + self.assertIn("Juge narratif", gate_payload["summary"]) + self.assertIn("rendre la tension du milieu plus nette", gate_payload["recommendations"]) + + meta = json.loads(outcome.meta_path.read_text(encoding="utf-8")) + self.assertEqual(meta["gate_report"]["judge_report"]["blockers"], []) + + def test_judge_blockers_trigger_repair(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 reste prudent.", + "rewrite_required": True, + "deviations": ["La scene n'ose pas aller jusqu'au point de bascule."], + "recommendations": ["Rendre l'acte final plus couteux."], + }, + "rewrite": self._narrative_text(), + "gate": [ + { + "ready_for_manuscript": True, + "summary": "Le chapitre est formellement exploitable.", + "blockers": [], + "recommendations": [], + "heuristic_blockers": [], + }, + { + "ready_for_manuscript": True, + "summary": "La version reparée peut etre promue.", + "blockers": [], + "recommendations": [], + "heuristic_blockers": [], + }, + ], + "judge": [ + { + "ready_for_manuscript": False, + "summary": "La scene ne va pas jusqu'a une decision risquee suivie d'un effet immediat.", + "blockers": ["missing_risky_decision", "missing_immediate_consequence"], + "recommendations": ["forcer une decision couteuse", "montrer aussitot son effet"], + }, + { + "ready_for_manuscript": True, + "summary": "La scene va jusqu'a une decision risquee et son effet.", + "blockers": [], + "recommendations": [], + }, + ], + "repair": repaired_text, + "memory": { + "summary": "Le chapitre se clot enfin sur un acte couteux et son effet.", + "characters": [{"name": "Ariane", "description": "Va au bout de sa decision."}], + "locations": [{"name": "Port-Vieux", "description": "Le lieu absorbe l'onde de choc."}], + "timeline_events": [{"event": "Ariane agit et en paie le prix tout de suite.", "order_hint": "nuit"}], + }, + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + with mock.patch.dict("os.environ", {"ANE_JUDGE_MODEL": "ollama:qwen2.5:7b"}, clear=False): + 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.assertEqual( + [request.stage for request in provider.requests], + ["structure", "draft", "critique", "rewrite", "gate", "judge", "repair", "gate", "judge", "memory"], + ) + first_gate_meta = json.loads(outcome.meta_path.read_text(encoding="utf-8")) + self.assertEqual(first_gate_meta["stage_attempts"]["gate"], 2) + + def test_judge_failure_is_non_blocking(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": { + "ready_for_manuscript": True, + "summary": "Le chapitre est narratif et peut etre promu.", + "blockers": [], + "recommendations": [], + "heuristic_blockers": [], + }, + "judge": [ + "Pas de JSON du tout.", + "Toujours pas de JSON exploitable.", + ], + "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"}], + }, + } + ) + pipeline = GenerationPipeline(self.root, provider=provider) + + with mock.patch.dict("os.environ", {"ANE_JUDGE_MODEL": "ollama:qwen2.5:7b"}, clear=False): + 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", "rewrite", "gate", "judge", "judge", "memory"], + ) + gate_payload = json.loads(outcome.gate_path.read_text(encoding="utf-8")) + self.assertEqual(gate_payload["judge_blockers"], []) + self.assertIn("indisponible", gate_payload["judge_report"]["summary"]) + self.assertIn("deux tentatives", gate_payload["judge_report"]["error"]) + + def test_rerunning_accepted_chapter_does_not_duplicate_timeline_events(self): + GenerationPipeline(self.root, provider=self._provider()).generate_chapter( + "01", + approval_callback=lambda _report, _path: True, + ) + GenerationPipeline(self.root, provider=self._provider()).generate_chapter( + "01", + approval_callback=lambda _report, _path: True, + ) + + timeline_index = self.root / "memoire" / "index" / "chronologie.json" + timeline = json.loads(timeline_index.read_text(encoding="utf-8")) + + self.assertEqual(len(timeline), 1) + self.assertEqual(timeline[0]["chapter"], "chapitre_01") + self.assertEqual(timeline[0]["event"], "Ariane perçoit le premier signe du basculement.") + def test_generation_retries_invalid_json_for_critique_and_memory(self): provider = MockGenerationProvider( { @@ -740,6 +939,24 @@ class GenerationPipelineTests(unittest.TestCase): 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_project_status_reports_corrupted_meta(self): + broken_dir = self.root / "brouillons" / "chapitres" / "chapitre_04" + broken_dir.mkdir(parents=True, exist_ok=True) + (broken_dir / "meta.json").write_text('{"status": "failed"', encoding="utf-8") + + state = ProjectState(self.root).summary() + + self.assertEqual( + state["corrupted_meta"], + [ + { + "chapter": "chapitre_04", + "meta_path": str(broken_dir / "meta.json"), + "error": "JSONDecodeError: Expecting ',' delimiter (line 1, column 20)", + } + ], + ) + def test_cli_write_alias_runs_pipeline(self): output = io.StringIO() @@ -872,6 +1089,10 @@ class GenerationPipelineTests(unittest.TestCase): encoding="utf-8", ) + broken_dir = self.root / "brouillons" / "chapitres" / "chapitre_04" + broken_dir.mkdir(parents=True, exist_ok=True) + (broken_dir / "meta.json").write_text('{"status": "failed"', encoding="utf-8") + output = io.StringIO() with redirect_stdout(output): exit_code = main(["status"], root=self.root) @@ -890,6 +1111,9 @@ class GenerationPipelineTests(unittest.TestCase): self.assertIn("En attente de validation:", rendered) self.assertIn("status=awaiting_acceptance", rendered) self.assertIn("chapitre_02", rendered) + self.assertIn("Métadonnées corrompues:", rendered) + self.assertIn("chapitre_04", rendered) + self.assertIn("JSONDecodeError", rendered) class ProviderConfigTests(unittest.TestCase): @@ -1050,7 +1274,18 @@ class ProviderConfigTests(unittest.TestCase): } ).encode("utf-8") - with mock.patch("core.generation.provider.request.urlopen", return_value=FakeResponse()) as urlopen_mock: + with mock.patch("core.runtime.client.request.urlopen", return_value=FakeResponse()) as urlopen_mock: + 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}, + ) + ) provider.generate(GenerationRequest(stage="critique", prompt="hello")) http_request = urlopen_mock.call_args.args[0] @@ -1058,18 +1293,6 @@ class ProviderConfigTests(unittest.TestCase): 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 @@ -1087,7 +1310,18 @@ class ProviderConfigTests(unittest.TestCase): } ).encode("utf-8") - with mock.patch("core.generation.provider.request.urlopen", return_value=FakeResponse()) as urlopen_mock: + with mock.patch("core.runtime.client.request.urlopen", return_value=FakeResponse()) as urlopen_mock: + 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}, + ) + ) provider.generate( GenerationRequest( stage="critique", @@ -1101,25 +1335,55 @@ class ProviderConfigTests(unittest.TestCase): 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.runtime.client.request.urlopen", side_effect=TimeoutError("timed out")): + 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)) + def test_runtime_health_probe_reads_health_payload(self): + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps( + { + "status": "ok", + "model": "apple-coreml:qwen3.5-4b-onnx-q4f16", + } + ).encode("utf-8") + + profile = RuntimeProfile( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="apple-coreml:qwen3.5-4b-onnx-q4f16", + timeout=12.0, + max_tokens=321, + stage_max_tokens={}, + ) + health = probe_runtime_health(profile, opener=lambda *_args, **_kwargs: FakeResponse()) + + self.assertTrue(health.ok) + self.assertEqual(health.status, "ok") + self.assertEqual(health.active_model, "apple-coreml:qwen3.5-4b-onnx-q4f16") + class JsonRepairTests(unittest.TestCase): def test_control_report_recovers_json_with_trailing_text(self): @@ -1154,6 +1418,432 @@ class JsonRepairTests(unittest.TestCase): self.assertEqual(memory.characters[0]["name"], "Ariane") self.assertEqual(memory.timeline_events[0]["event"], "Décision") + def test_narrative_judge_report_recovers_json(self): + report = NarrativeJudgeReport.from_response_text( + 'Diagnostic a ignorer {"ready_for_manuscript": false,' + '"summary":"La scene reste trop prudente.",' + '"blockers":["missing_risky_decision"],' + '"recommendations":["forcer une decision couteuse"]}' + ) + + self.assertFalse(report.ready_for_manuscript) + self.assertEqual(report.blockers, ["missing_risky_decision"]) + self.assertEqual(report.recommendations, ["forcer une decision couteuse"]) + + def test_narrative_judge_report_normalizes_aliases_and_preserves_unknown_blockers(self): + report = NarrativeJudgeReport.from_response_text( + '{"ready_for_manuscript": true,' + '"summary":"Diagnostic.",' + '"blockers":["incomplete", "lacks_narrative_continuity", "unknown_label", "incomplete"],' + '"recommendations":["forcer la fermeture", "forcer la fermeture"]}' + ) + + self.assertFalse(report.ready_for_manuscript) + self.assertEqual(report.blockers, ["incomplete_scene", "weak_narrative_continuity", "unknown_label"]) + self.assertEqual(report.recommendations, ["forcer la fermeture"]) + + def test_gate_report_normalizes_aliases_and_recomputes_ready(self): + report = ManuscriptGateReport.from_response_text( + '{"ready_for_manuscript": true,' + '"summary":"Diagnostic.",' + '"blockers":["incomplete", "outline_like", "unknown_label"],' + '"heuristic_blockers":["lacks_narrative_continuity"],' + '"judge_blockers":["missing_immediate_consequence", "unknown_label"],' + '"recommendations":["terminer la scene", "terminer la scene"]}' + ) + + self.assertFalse(report.ready_for_manuscript) + self.assertEqual(report.blockers, ["incomplete_scene", "outline_like", "unknown_label"]) + self.assertEqual(report.heuristic_blockers, ["weak_narrative_continuity"]) + self.assertEqual(report.judge_blockers, ["missing_immediate_consequence", "unknown_label"]) + self.assertEqual(report.recommendations, ["terminer la scene"]) + + def test_close_json_delimiters_mismatched_closer_repaired(self): + from core.generation.models import _close_json_delimiters + # Array closed with } instead of ] — should produce valid JSON + result = _close_json_delimiters('{"key": [1, 2}') + import json as _json + data = _json.loads(result) + self.assertEqual(data["key"], [1, 2]) + + def test_close_json_delimiters_stray_closer_dropped(self): + from core.generation.models import _close_json_delimiters + # Closer with no matching opener at top level — dropped + result = _close_json_delimiters(']{"key": "val"}') + import json as _json + data = _json.loads(result) + self.assertEqual(data["key"], "val") + + def test_close_json_delimiters_truncated_string_in_array(self): + from core.generation.models import _close_json_delimiters + # Array truncated mid-string + result = _close_json_delimiters('{"items": ["first", "truncated') + import json as _json + data = _json.loads(result) + self.assertEqual(data["items"][0], "first") + self.assertIn("truncated", data["items"][1]) + + +class NormalizeProseTests(unittest.TestCase): + """Tests for _normalize_generated_prose: heading stripping and outline cleanup.""" + + def _pipeline(self): + import tempfile + td = tempfile.mkdtemp() + return GenerationPipeline(Path(td)) + + def test_strips_all_markdown_headings(self): + p = self._pipeline() + raw = "### Scène 1 — La femme arrive\nElle avança.\n## Objectif dramatique\nReste neutre.\n" + result = p._normalize_generated_prose(raw) + self.assertNotIn("###", result) + self.assertNotIn("## Objectif", result) + self.assertIn("Elle avança.", result) + self.assertIn("Reste neutre.", result) + + def test_strips_h1_chapter_heading(self): + p = self._pipeline() + raw = "# Chapitre 01\n\nAriane longe le quai.\n" + result = p._normalize_generated_prose(raw) + self.assertNotIn("# Chapitre", result) + self.assertIn("Ariane longe le quai.", result) + + def test_prose_containing_word_scene_not_flagged(self): + p = self._pipeline() + prose = ("Elle entra dans la scène avec une retenue calculee. " * 20).strip() + ".\n" + result = p._is_outline_like(prose) + self.assertFalse(result, "Le mot 'scène' dans la prose courante ne doit pas déclencher outline_like") + + def test_scene_heading_label_is_flagged(self): + p = self._pipeline() + text = "### Scène 1 — La femme arrive\nElle avança.\n### Scène 2 — La fuite\nElle courut.\n" + self.assertTrue(p._is_outline_like(text)) + + def test_scene_number_label_is_flagged(self): + p = self._pipeline() + text = "Scène 1:\nElle avança.\nScène 2:\nElle courut.\n" + self.assertTrue(p._is_outline_like(text)) + + def test_dense_bullet_list_flagged_as_outline_like(self): + p = self._pipeline() + text = "- Elle s'arrêta net.\n- La bruine tombait.\n- Une porte s'ouvrit.\n- Elle avança quand même.\n" + self.assertTrue(p._is_outline_like(text), "4+ bullet lines alone doivent déclencher outline_like") + + def test_few_bullets_in_prose_not_flagged(self): + p = self._pipeline() + prose = "Ariane longe le quai vide et ecoute les pas. Elle serre un billet humide.\n" + prose += "- Un indice ici.\n" + prose += "Elle decida de repartir sans se retourner.\n" + self.assertFalse(p._is_outline_like(prose), "1-3 bullet lines dans de la prose ne doit pas déclencher outline_like") + + def test_repair_focus_mentions_judge_blockers(self): + p = self._pipeline() + gate_report = ManuscriptGateReport.from_heuristics( + blockers=["too_short"], + recommendations=["allonger la scene"], + summary="Heuristique.", + ).with_judge_report( + NarrativeJudgeReport( + ready_for_manuscript=False, + summary="La decision risquee manque encore.", + blockers=["missing_risky_decision", "missing_immediate_consequence"], + recommendations=["aller jusqu'a l'acte final"], + ) + ) + + focus = p._repair_focus(gate_report) + self.assertIn("decision risquee concrete", focus) + self.assertIn("consequence immediate, observable", focus) + self.assertIn("couter quelque chose d'observable", focus) + self.assertIn("meme lieu et la meme minute", focus) + self.assertIn("ne pas finir sur un depart vers la suite", focus) + + def test_sanitize_gate_report_drops_outline_like_without_visual_markers(self): + p = self._pipeline() + prose = ( + "La femme marchait vite dans la rue humide. Elle tenait un livre noir contre elle.\n\n" + "Elle prit une decision risquee et entra dans la ruelle, le souffle court.\n" + ) + gate_report = ManuscriptGateReport( + ready_for_manuscript=False, + summary="Le texte contient encore des marqueurs visuels de plan.", + blockers=["outline_like"], + recommendations=["Retirer les titres et les puces."], + heuristic_blockers=[], + judge_blockers=[], + judge_report=None, + raw={}, + ) + + sanitized = p._sanitize_gate_report(prose, gate_report) + + self.assertEqual(sanitized.blockers, []) + self.assertTrue(sanitized.ready_for_manuscript) + self.assertIn("aucun marqueur visuel de plan", sanitized.summary) + + +class IntentionGateTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + + def tearDown(self): + self.temp_dir.cleanup() + + def _gate(self): + from core.intention.gate import IntentionGate + return IntentionGate(self.root) + + def _make_intention(self, slug: str, content: str = "Intention test.\n") -> None: + d = self.root / "notes" / "intentions" + d.mkdir(parents=True, exist_ok=True) + (d / f"{slug}.md").write_text(content, encoding="utf-8") + + def test_has_intention_returns_false_when_dir_missing(self): + gate = self._gate() + self.assertFalse(gate.has_intention()) + self.assertFalse(gate.has_intention("1")) + + def test_has_intention_returns_false_when_no_matching_file(self): + d = self.root / "notes" / "intentions" + d.mkdir(parents=True, exist_ok=True) + gate = self._gate() + self.assertFalse(gate.has_intention("1")) + self.assertFalse(gate.has_intention()) + + def test_has_intention_returns_true_for_matching_chapter(self): + self._make_intention("chapitre_01") + gate = self._gate() + self.assertTrue(gate.has_intention("1")) + self.assertTrue(gate.has_intention("01")) + + def test_has_intention_any_returns_true_when_any_intention_exists(self): + self._make_intention("chapitre_03") + gate = self._gate() + self.assertTrue(gate.has_intention()) + + def test_resolve_intention_path_returns_none_when_missing(self): + d = self.root / "notes" / "intentions" + d.mkdir(parents=True, exist_ok=True) + gate = self._gate() + self.assertIsNone(gate.resolve_intention_path("2")) + + def test_resolve_intention_path_returns_path_when_exists(self): + self._make_intention("chapitre_02") + gate = self._gate() + path = gate.resolve_intention_path("2") + self.assertIsNotNone(path) + self.assertEqual(path.name, "chapitre_02.md") + + def test_resolve_intention_path_raises_on_conflict(self): + from core.chapters import ChapterConflictError + d = self.root / "notes" / "intentions" + d.mkdir(parents=True, exist_ok=True) + (d / "chapitre_01.md").write_text("A\n", encoding="utf-8") + (d / "chapitre_1.md").write_text("B\n", encoding="utf-8") + gate = self._gate() + with self.assertRaises(ChapterConflictError): + gate.resolve_intention_path("1") + + def test_load_intention_returns_stripped_content(self): + self._make_intention("chapitre_01", " Tension sourde. \n\n") + gate = self._gate() + text = gate.load_intention("1") + self.assertEqual(text, "Tension sourde.") + + def test_load_intention_raises_when_missing(self): + d = self.root / "notes" / "intentions" + d.mkdir(parents=True, exist_ok=True) + gate = self._gate() + with self.assertRaises(RuntimeError): + gate.load_intention("99") + + def test_assert_intention_raises_when_no_file_for_chapter(self): + gate = self._gate() + with self.assertRaises(RuntimeError) as ctx: + gate.assert_intention("5") + self.assertIn("bloquée", str(ctx.exception)) + + def test_assert_intention_raises_when_no_intention_at_all(self): + gate = self._gate() + with self.assertRaises(RuntimeError) as ctx: + gate.assert_intention() + self.assertIn("bloquée", str(ctx.exception)) + + def test_assert_intention_passes_when_intention_exists(self): + self._make_intention("chapitre_01") + gate = self._gate() + gate.assert_intention("1") # should not raise + + +class PromptStoreTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + + def tearDown(self): + self.temp_dir.cleanup() + + def _store(self): + from core.prompts import PromptStore + return PromptStore(self.root) + + def _write_prompt(self, name: str, content: str) -> None: + d = self.root / "prompts" + d.mkdir(parents=True, exist_ok=True) + (d / f"{name}_v1.txt").write_text(content, encoding="utf-8") + + def test_render_substitutes_simple_variable(self): + self._write_prompt("hello", "Bonjour $hero.") + store = self._store() + result = store.render("hello", hero="Monde") + self.assertEqual(result, "Bonjour Monde.") + + def test_render_normalizes_dict_to_json(self): + self._write_prompt("meta", "Données: $payload") + store = self._store() + result = store.render("meta", payload={"key": "val"}) + self.assertIn('"key"', result) + self.assertIn('"val"', result) + + def test_render_normalizes_none_to_empty_string(self): + self._write_prompt("nullable", "Contexte: $ctx.") + store = self._store() + result = store.render("nullable", ctx=None) + self.assertEqual(result, "Contexte: .") + + def test_render_falls_back_to_builtin_prompt(self): + store = self._store() + result = store.render("draft", chapter_slug="chapitre_01", + intention="test", structure_markdown="# s", + story_context="ctx") + self.assertIn("chapitre_01", result) + + def test_render_raises_when_prompt_not_found(self): + from core.prompts import PromptNotFoundError + store = self._store() + with self.assertRaises(PromptNotFoundError): + store.render("nonexistent_prompt_xyz", x="y") + + def test_project_prompt_overrides_builtin(self): + self._write_prompt("draft", "OVERRIDE: $chapter_slug") + store = self._store() + result = store.render("draft", chapter_slug="chapitre_02", + intention="i", structure_markdown="s", + story_context="c") + self.assertTrue(result.startswith("OVERRIDE:")) + self.assertIn("chapitre_02", result) + + def test_render_normalizes_list_to_json(self): + self._write_prompt("list_tpl", "Items: $items") + store = self._store() + result = store.render("list_tpl", items=["a", "b"]) + self.assertIn('"a"', result) + self.assertIn('"b"', result) + + +class CLIIntentionTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + + def tearDown(self): + self.temp_dir.cleanup() + + def test_cli_intention_create_writes_file(self): + from cli.main import cmd_intention_create + inputs = iter(["Une tension sourde.", EOFError()]) + + def fake_input(prompt=""): + val = next(inputs) + if isinstance(val, BaseException): + raise val + return val + + output = io.StringIO() + with redirect_stdout(output): + exit_code = cmd_intention_create(self.root, chapter_value="1", input_func=fake_input) + self.assertEqual(exit_code, 0) + intention_path = self.root / "notes" / "intentions" / "chapitre_01.md" + self.assertTrue(intention_path.exists()) + content = intention_path.read_text(encoding="utf-8") + self.assertIn("Une tension sourde.", content) + + def test_cli_status_shows_no_chapter_for_empty_project(self): + output = io.StringIO() + with redirect_stdout(output): + exit_code = main(["status"], root=self.root) + self.assertEqual(exit_code, 0) + self.assertIn("aucun", output.getvalue()) + + def test_cli_generate_without_intention_exits_nonzero(self): + with mock.patch("cli.main.GenerationPipeline") as pipeline_cls: + pipeline_cls.return_value.generate_chapter.side_effect = RuntimeError( + "Aucune intention trouvée pour chapitre_01." + ) + exit_code = main(["generate", "chapter", "--chapter", "1"], root=self.root) + self.assertNotEqual(exit_code, 0) + + def test_cli_intention_create_rejects_invalid_chapter_format(self): + from cli.main import cmd_intention_create + output = io.StringIO() + with redirect_stdout(output): + exit_code = cmd_intention_create(self.root, chapter_value="@@@") + self.assertEqual(exit_code, 1) + self.assertIn("invalide", output.getvalue()) + + def test_cli_intention_create_rejects_duplicate_intention(self): + from cli.main import cmd_intention_create + # Create once + inputs = iter(["Tension initiale.", EOFError()]) + def fake_input(prompt=""): + val = next(inputs) + if isinstance(val, BaseException): + raise val + return val + cmd_intention_create(self.root, chapter_value="3", input_func=fake_input) + # Try to create again — should fail + output = io.StringIO() + with redirect_stdout(output): + exit_code = cmd_intention_create(self.root, chapter_value="3") + self.assertEqual(exit_code, 1) + self.assertIn("déjà", output.getvalue()) + + def test_cli_intention_create_rejects_empty_content(self): + from cli.main import cmd_intention_create + inputs = iter([EOFError()]) + def fake_input(prompt=""): + raise next(inputs) + output = io.StringIO() + with redirect_stdout(output): + exit_code = cmd_intention_create(self.root, chapter_value="4", input_func=fake_input) + self.assertEqual(exit_code, 1) + self.assertIn("vide", output.getvalue()) + + def test_cli_no_args_calls_status(self): + output = io.StringIO() + with redirect_stdout(output): + exit_code = main([], root=self.root) + self.assertEqual(exit_code, 0) + self.assertIn("AI Novel Engine", output.getvalue()) + + def test_cli_provider_error_exits_nonzero(self): + with mock.patch("cli.main.GenerationPipeline") as pipeline_cls: + pipeline_cls.return_value.generate_chapter.side_effect = ProviderError("timeout") + output = io.StringIO() + with redirect_stdout(output): + exit_code = main(["generate", "chapter", "--chapter", "1"], root=self.root) + self.assertEqual(exit_code, 1) + self.assertIn("Erreur", output.getvalue()) + + def test_cli_write_alias_provider_error_exits_nonzero(self): + with mock.patch("cli.main.GenerationPipeline") as pipeline_cls: + pipeline_cls.return_value.generate_chapter.side_effect = ProviderError("timeout") + output = io.StringIO() + with redirect_stdout(output): + exit_code = main(["write", "--chapter", "1"], root=self.root) + self.assertEqual(exit_code, 1) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_mascarade_remote_tui.py b/tests/test_mascarade_remote_tui.py new file mode 100644 index 0000000..901ff43 --- /dev/null +++ b/tests/test_mascarade_remote_tui.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + +from core.runtime.models import RuntimeHealth +from core.runtime.remote_hosts import RemoteHostConfig, read_remote_hosts + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "mascarade_remote_tui.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("mascarade_remote_tui", SCRIPT_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("Impossible de charger mascarade_remote_tui.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class RemoteHostsTests(unittest.TestCase): + def test_read_remote_hosts_parses_defaults_and_remote_profile_name(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "hosts.toml" + path.write_text( + "[defaults]\nremote_core_port = 8100\nlocal_bind_host = \"127.0.0.1\"\n" + "[[hosts]]\nname = \"tower\"\nssh_target = \"clems@192.168.120\"\nlocal_tunnel_port = 8110\n", + encoding="utf-8", + ) + hosts = read_remote_hosts(path) + + self.assertEqual(len(hosts), 1) + self.assertEqual(hosts[0].local_base_url(), "http://127.0.0.1:8110") + self.assertEqual(hosts[0].probe_profile_name(), "mascarade_remote_tower") + + +class MascaradeRemoteTuiTests(unittest.TestCase): + def test_probe_remote_runtime_builds_named_profile(self) -> None: + module = _load_module() + host = RemoteHostConfig( + name="tower", + ssh_target="clems@192.168.120", + local_tunnel_port=8110, + remote_core_port=8100, + remote_health_path="/health", + local_bind_host="127.0.0.1", + ssh_connect_timeout_seconds=4, + ) + captured: dict[str, str] = {} + + def fake_probe(profile): + captured["name"] = profile.name + captured["base_url"] = profile.base_url + return RuntimeHealth(ok=True, url=profile.base_url, active_model="apple-coreml:qwen3.5-4b-onnx-q4f16") + + module.probe_runtime_health = fake_probe + ok, active_model = module._probe_remote_runtime(host) + + self.assertTrue(ok) + self.assertEqual(active_model, "apple-coreml:qwen3.5-4b-onnx-q4f16") + self.assertEqual(captured["name"], "mascarade_remote_tower") + self.assertEqual(captured["base_url"], "http://127.0.0.1:8110") + + def test_render_shows_profile_and_active_model_when_runtime_is_up(self) -> None: + module = _load_module() + host = RemoteHostConfig( + name="tower", + ssh_target="clems@192.168.120", + local_tunnel_port=8110, + remote_core_port=8100, + remote_health_path="/health", + local_bind_host="127.0.0.1", + ssh_connect_timeout_seconds=4, + ) + module._run_ssh_probe = lambda *_args, **_kwargs: "UP" + module._http_probe = lambda *_args, **_kwargs: "UP (200)" + module._probe_remote_runtime = lambda *_args, **_kwargs: (True, "ollama:qwen2.5:7b") + + rendered = module._render(Path("automation/mascarade_hosts.toml"), [host]) + + self.assertIn("profile: mascarade_remote_tower", rendered) + self.assertIn("runtime=UP model=ollama:qwen2.5:7b", rendered) + + def test_render_keeps_tunnel_guidance_when_tunnel_is_down(self) -> None: + module = _load_module() + host = RemoteHostConfig( + name="tower", + ssh_target="clems@192.168.120", + local_tunnel_port=8110, + remote_core_port=8100, + remote_health_path="/health", + local_bind_host="127.0.0.1", + ssh_connect_timeout_seconds=4, + ) + module._run_ssh_probe = lambda *_args, **_kwargs: "UP" + module._http_probe = lambda *_args, **_kwargs: "DOWN" + module._probe_remote_runtime = lambda *_args, **_kwargs: (False, None) + + rendered = module._render(Path("automation/mascarade_hosts.toml"), [host]) + + self.assertIn("next: lancer `ssh -N", rendered) + self.assertIn("launchctl kickstart -k", rendered) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_next_lots.py b/tests/test_next_lots.py index e51622d..4d54776 100644 --- a/tests/test_next_lots.py +++ b/tests/test_next_lots.py @@ -7,14 +7,12 @@ import unittest from core.chapters import ChapterId from core.next_lots import ( - AUTO_SYNC_TODO_ACTIVE, CommandResult, Manifest, ModelRunResult, NextLotsRunner, RunState, _default_command_runner, - replace_auto_section, ) @@ -101,44 +99,6 @@ class NextLotsTests(unittest.TestCase): self.assertEqual(manifest.ollama_runtime, "native") self.assertEqual(manifest.ollama_openai_base_url, "http://127.0.0.1:8100") - 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_replace_auto_section_deduplicates_repeated_heading(self) -> None: - path = self.root / "README.md" - path.write_text( - "## Etat auto-synchronise\n" - "## Etat auto-synchronise\n" - "\n" - "ancien\n" - "\n", - encoding="utf-8", - ) - - replace_auto_section(path, "ANE-README", "## Etat auto-synchronise", "- propre") - rendered = path.read_text(encoding="utf-8") - - self.assertEqual(rendered.count("## Etat auto-synchronise\n"), 1) - self.assertIn("- propre", rendered) - def test_default_command_runner_returns_timeout_result(self) -> None: result = _default_command_runner( ["python3", "-c", "import time; time.sleep(1)"], @@ -218,6 +178,87 @@ class NextLotsTests(unittest.TestCase): self.assertEqual(prepare_calls, []) self.assertGreaterEqual(model_calls["count"], 2) + def test_runner_creates_checkpoint_when_openai_compatible_ollama_runtime_is_missing(self) -> None: + manifest = Manifest.load(self.root, self.manifest_path) + manifest = Manifest( + **{ + **manifest.__dict__, + "ollama_runtime": "openai_compatible", + "ollama_openai_base_url": "http://127.0.0.1:8091", + } + ) + prepare_calls: list[list[str]] = [] + + def command_runner(args: list[str], cwd: Path, env=None) -> CommandResult: + if "prepare_llama_cpp_runtime.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("/v1/models"): + return {"data": []} + 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}], + ), + "ollama:qwen2.5:7b", + ) + + self.assertIsNotNone(checkpoint) + self.assertEqual(len(prepare_calls), 1) + self.assertIn("--model", prepare_calls[0]) + self.assertIn("ollama:qwen2.5:7b", prepare_calls[0]) + self.assertIn("--port", prepare_calls[0]) + self.assertIn("8091", prepare_calls[0]) + self.assertIn("runtime OpenAI-compatible", checkpoint["reason"]) + + def test_runner_skips_checkpoint_when_openai_compatible_ollama_runtime_is_ready(self) -> None: + manifest = Manifest.load(self.root, self.manifest_path) + manifest = Manifest( + **{ + **manifest.__dict__, + "ollama_runtime": "openai_compatible", + "ollama_openai_base_url": "http://127.0.0.1:8091", + } + ) + prepare_calls: list[list[str]] = [] + + def command_runner(args: list[str], cwd: Path, env=None) -> CommandResult: + if "prepare_llama_cpp_runtime.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("/v1/models"): + return {"data": [{"id": "sha256-demo", "aliases": ["ollama:qwen2.5:7b"]}]} + 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}], + ), + "ollama:qwen2.5:7b", + ) + + self.assertIsNone(checkpoint) + self.assertEqual(prepare_calls, []) + def test_run_model_classifies_accepted_from_meta(self) -> None: manifest = Manifest.load(self.root, self.manifest_path) chapter = ChapterId.parse("02") @@ -366,258 +407,6 @@ class NextLotsTests(unittest.TestCase): self.assertEqual(result.classification, "accepted") self.assertEqual(len(command_calls), 2) - 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 test_tracking_sync_consolidates_latest_results_across_reports(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"}, - ) - previous_state = RunState.new( - manifest, - lot="priority_models", - report_dir=self.root / "automation" / "reports" / "20260309T055457Z", - state_path=self.root / "automation" / "state" / "next_lots_state.json", - steps=[{"type": "models", "name": "priority_models", "models": manifest.priority_models, "preflight_only": False}], - ) - previous_state.updated_at = "2026-03-09T06:20:33+00:00" - previous_state.results = [ - asdict( - ModelRunResult( - model="apple-coreml:qwen3.5-4b-onnx-q4f16", - category="priority_models", - classification="accepted", - preflight_ok=True, - smoke_attempted=True, - status="accepted", - accepted=True, - completed_stages=["structure", "draft", "critique", "rewrite", "gate", "memory"], - ) - ) - ] - previous_report_dir = Path(previous_state.report_dir) - previous_report_dir.mkdir(parents=True, exist_ok=True) - (previous_report_dir / "run.json").write_text(json.dumps(previous_state.__dict__, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - current_state = RunState.new( - manifest, - lot="tracking_sync", - report_dir=self.root / "automation" / "reports" / "20260309T063512Z", - state_path=self.root / "automation" / "state" / "next_lots_state.json", - steps=[{"type": "tracking_sync"}], - ) - current_state.updated_at = "2026-03-09T06:53:02+00:00" - current_state.results = [ - asdict( - ModelRunResult( - model="apple-coreml:qwen2.5-0.5b-instruct-onnx", - category="baselines", - classification="quality_blocked", - preflight_ok=True, - smoke_attempted=True, - status="quality_blocked", - failed_stage="gate", - quality_blockers=["truncated_ending"], - completed_stages=["structure", "draft", "critique", "rewrite", "gate", "repair"], - ) - ) - ] - - runner._sync_tracking(current_state, dry_run=False) - - readme = (self.root / "README.md").read_text(encoding="utf-8") - comparison = (self.root / "docs" / "MODEL_COMPARISON_2026-03-08.md").read_text(encoding="utf-8") - todo_active = (self.root / "TODO_ACTIVE.md").read_text(encoding="utf-8") - - self.assertIn("reference locale actuelle: apple-coreml:qwen3.5-4b-onnx-q4f16", readme) - self.assertIn("apple-coreml:qwen3.5-4b-onnx-q4f16", comparison) - self.assertIn("apple-coreml:qwen2.5-0.5b-instruct-onnx", comparison) - self.assertIn("Confirmer la reference accepted puis resserrer rewrite/repair", todo_active) - - def test_tracking_sync_marks_reference_reconfirmed_after_two_accepted_runs(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"}, - ) - - first_state = RunState.new( - manifest, - lot="priority_models", - report_dir=self.root / "automation" / "reports" / "20260309T055457Z", - state_path=self.root / "automation" / "state" / "next_lots_state.json", - steps=[{"type": "models", "name": "priority_models", "models": manifest.priority_models, "preflight_only": False}], - ) - first_state.updated_at = "2026-03-09T06:20:33+00:00" - first_state.results = [ - asdict( - ModelRunResult( - model="apple-coreml:qwen3.5-4b-onnx-q4f16", - category="priority_models", - classification="accepted", - preflight_ok=True, - smoke_attempted=True, - status="accepted", - accepted=True, - completed_stages=["structure", "draft", "critique", "rewrite", "gate", "memory"], - ) - ) - ] - first_report_dir = Path(first_state.report_dir) - first_report_dir.mkdir(parents=True, exist_ok=True) - (first_report_dir / "run.json").write_text(json.dumps(first_state.__dict__, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - second_state = RunState.new( - manifest, - lot="priority_models", - report_dir=self.root / "automation" / "reports" / "20260313T225017Z", - state_path=self.root / "automation" / "state" / "next_lots_state.json", - steps=[{"type": "models", "name": "priority_models", "models": ["apple-coreml:qwen3.5-4b-onnx-q4f16"], "preflight_only": False}], - ) - second_state.updated_at = "2026-03-13T22:50:17+00:00" - second_state.results = [ - asdict( - ModelRunResult( - model="apple-coreml:qwen3.5-4b-onnx-q4f16", - category="priority_models", - classification="accepted", - preflight_ok=True, - smoke_attempted=True, - status="accepted", - accepted=True, - completed_stages=["structure", "draft", "critique", "rewrite", "gate", "memory"], - ) - ) - ] - second_report_dir = Path(second_state.report_dir) - second_report_dir.mkdir(parents=True, exist_ok=True) - (second_report_dir / "run.json").write_text(json.dumps(second_state.__dict__, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - current_state = RunState.new( - manifest, - lot="tracking_sync", - report_dir=self.root / "automation" / "reports" / "20260313T230000Z", - state_path=self.root / "automation" / "state" / "next_lots_state.json", - steps=[{"type": "tracking_sync"}], - ) - current_state.updated_at = "2026-03-13T23:00:00+00:00" - current_state.results = [ - asdict( - ModelRunResult( - model="ollama:qwen2.5:7b", - category="priority_models", - classification="quality_blocked", - preflight_ok=True, - smoke_attempted=True, - status="quality_blocked", - failed_stage="gate", - quality_blockers=["outline_like"], - completed_stages=["structure", "draft", "critique", "rewrite", "gate", "repair"], - ) - ) - ] - - runner._sync_tracking(current_state, dry_run=False) - - todo_active = (self.root / "TODO_ACTIVE.md").read_text(encoding="utf-8") - self.assertIn("Reference locale reconfirmee; resserrer rewrite/repair", todo_active) - - def test_tracking_sync_prioritizes_runtime_fix_when_reference_reconfirmed_but_provider_failed(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"}, - ) - - for stamp in ("20260309T055457Z", "20260313T225017Z"): - accepted_state = RunState.new( - manifest, - lot="priority_models", - report_dir=self.root / "automation" / "reports" / stamp, - state_path=self.root / "automation" / "state" / "next_lots_state.json", - steps=[{"type": "models", "name": "priority_models", "models": ["apple-coreml:qwen3.5-4b-onnx-q4f16"], "preflight_only": False}], - ) - accepted_state.updated_at = "2026-03-13T22:50:17+00:00" - accepted_state.results = [ - asdict( - ModelRunResult( - model="apple-coreml:qwen3.5-4b-onnx-q4f16", - category="priority_models", - classification="accepted", - preflight_ok=True, - smoke_attempted=True, - status="accepted", - accepted=True, - completed_stages=["structure", "draft", "critique", "rewrite", "gate", "memory"], - ) - ) - ] - report_dir = Path(accepted_state.report_dir) - report_dir.mkdir(parents=True, exist_ok=True) - (report_dir / "run.json").write_text(json.dumps(accepted_state.__dict__, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - current_state = RunState.new( - manifest, - lot="tracking_sync", - report_dir=self.root / "automation" / "reports" / "20260314T000000Z", - state_path=self.root / "automation" / "state" / "next_lots_state.json", - steps=[{"type": "tracking_sync"}], - ) - current_state.updated_at = "2026-03-14T00:00:00+00:00" - current_state.results = [ - asdict( - ModelRunResult( - model="ollama:qwen2.5:7b", - category="priority_models", - classification="provider_failed", - preflight_ok=False, - status="ollama_runtime_unhealthy", - notes=["Le preflight Ollama natif a échoué."], - ) - ) - ] - - runner._sync_tracking(current_state, dry_run=False) - - todo_active = (self.root / "TODO_ACTIVE.md").read_text(encoding="utf-8") - self.assertIn("retablir le runtime des modeles provider_failed", todo_active) - def asdict(result: ModelRunResult) -> dict[str, object]: return { diff --git a/tests/test_reporting.py b/tests/test_reporting.py new file mode 100644 index 0000000..8a2c7be --- /dev/null +++ b/tests/test_reporting.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest + +from core.reporting import ( + classification_count, + collect_log_error_counts, + extract_stderr, + folder_timestamp, + latest_report_run, + log_label_from_path, + recent_report_runs, + safe_read_json, + safe_stamp, +) + + +class ReportingHelpersTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) + self.reports_root = self.root / "automation" / "reports" + self.reports_root.mkdir(parents=True, exist_ok=True) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def _write_run(self, name: str, *, updated_at: str, results: list[dict[str, object]]) -> None: + report_dir = self.reports_root / name + report_dir.mkdir(parents=True, exist_ok=True) + payload = { + "lot": "priority_models", + "updated_at": updated_at, + "results": results, + } + (report_dir / "run.json").write_text(json.dumps(payload), encoding="utf-8") + + def test_recent_report_runs_sort_by_updated_at_desc(self) -> None: + self._write_run("20260309T000000Z", updated_at="2026-03-09T00:00:00+00:00", results=[]) + self._write_run("20260310T000000Z", updated_at="2026-03-10T00:00:00+00:00", results=[]) + self._write_run("20260311T000000Z", updated_at="2026-03-11T00:00:00+00:00", results=[]) + + recent = recent_report_runs(self.reports_root, limit=2) + + self.assertEqual([path.parent.name for path, _payload in recent], ["20260311T000000Z", "20260310T000000Z"]) + + def test_collect_log_error_counts_uses_model_names_from_run_payload(self) -> None: + report_dir = self.reports_root / "20260314T085946Z" + report_dir.mkdir(parents=True, exist_ok=True) + log_path = report_dir / "ollama_qwen2_5_7b_ollama_native_preflight.log" + log_path.write_text( + "COMMAND\nollama ...\n\nSTDERR\nHTTP 500 Internal Server Error\n", + encoding="utf-8", + ) + self._write_run( + "20260314T085946Z", + updated_at="2026-03-14T08:59:46+00:00", + results=[ + { + "model": "ollama:qwen2.5:7b", + "preflight_log": str(log_path), + } + ], + ) + + error_counts, model_errors = collect_log_error_counts(self.reports_root) + + self.assertEqual(error_counts["HTTP Internal Server Error"], 1) + self.assertIn("ollama:qwen2.5:7b", model_errors) + self.assertEqual(model_errors["ollama:qwen2.5:7b"]["HTTP Internal Server Error"], 1) + + def test_log_label_from_path_keeps_manual_action_readable(self) -> None: + label = log_label_from_path(Path("/tmp/manual_action_00.log")) + self.assertEqual(label, "manual_action") + + def test_log_label_strips_smoke_suffix(self) -> None: + label = log_label_from_path(Path("/tmp/ollama_qwen2_5_7b_smoke.log")) + self.assertEqual(label, "ollama_qwen2_5_7b") + + def test_log_label_strips_preflight_suffix(self) -> None: + label = log_label_from_path(Path("/tmp/ollama_qwen2_5_1_5b_preflight.log")) + self.assertEqual(label, "ollama_qwen2_5_1_5b") + + def test_log_label_uses_model_lookup_when_available(self) -> None: + lookup = {"model_smoke.log": "ollama:qwen2.5:7b"} + label = log_label_from_path(Path("/tmp/model_smoke.log"), model_lookup=lookup) + self.assertEqual(label, "ollama:qwen2.5:7b") + + def test_log_label_falls_back_to_stem(self) -> None: + label = log_label_from_path(Path("/tmp/unknown_log_file.log")) + self.assertEqual(label, "unknown_log_file") + + def test_safe_read_json_returns_dict(self) -> None: + path = self.root / "test.json" + path.write_text('{"key": "value"}', encoding="utf-8") + result = safe_read_json(path) + self.assertEqual(result, {"key": "value"}) + + def test_safe_read_json_returns_none_on_missing_file(self) -> None: + result = safe_read_json(self.root / "nonexistent.json") + self.assertIsNone(result) + + def test_safe_read_json_returns_none_on_invalid_json(self) -> None: + path = self.root / "bad.json" + path.write_text("not json", encoding="utf-8") + result = safe_read_json(path) + self.assertIsNone(result) + + def test_safe_stamp_parses_iso_with_tz(self) -> None: + from datetime import timezone + stamp = safe_stamp("2026-03-16T12:00:00+00:00") + self.assertEqual(stamp.tzinfo, timezone.utc) + self.assertEqual(stamp.year, 2026) + + def test_safe_stamp_returns_epoch_on_empty(self) -> None: + stamp = safe_stamp("") + self.assertEqual(stamp.year, 1970) + + def test_safe_stamp_returns_epoch_on_invalid(self) -> None: + stamp = safe_stamp("not-a-date") + self.assertEqual(stamp.year, 1970) + + def test_extract_stderr_returns_empty_when_no_marker(self) -> None: + result = extract_stderr("just some output\nmore lines") + self.assertEqual(result, "") + + def test_extract_stderr_returns_content_after_marker(self) -> None: + text = "stdout stuff\n\nSTDERR\nError: timeout\nmore error" + result = extract_stderr(text) + self.assertEqual(result, "Error: timeout\nmore error") + + def test_classification_count_counts_correctly(self) -> None: + results = [ + {"classification": "accepted"}, + {"classification": "quality_blocked"}, + {"classification": "quality_blocked"}, + {"classification": "provider_failed"}, + ] + counts = classification_count(results) + self.assertEqual(counts["accepted"], 1) + self.assertEqual(counts["quality_blocked"], 2) + self.assertEqual(counts["provider_failed"], 1) + + def test_classification_count_defaults_to_pending(self) -> None: + results = [{"status": "running"}] + counts = classification_count(results) + self.assertEqual(counts["pending"], 1) + + def test_folder_timestamp_parses_valid_name(self) -> None: + from datetime import timezone + ts = folder_timestamp(Path("automation/reports/20260316T193455Z")) + self.assertEqual(ts.year, 2026) + self.assertEqual(ts.month, 3) + self.assertEqual(ts.day, 16) + self.assertEqual(ts.tzinfo, timezone.utc) + + def test_folder_timestamp_returns_epoch_on_invalid_name(self) -> None: + ts = folder_timestamp(Path("automation/reports/invalid-name")) + self.assertEqual(ts.year, 1970) + + def test_latest_report_run_returns_most_recent(self) -> None: + self._write_run("20260309T000000Z", updated_at="2026-03-09T00:00:00+00:00", results=[]) + self._write_run("20260316T000000Z", updated_at="2026-03-16T00:00:00+00:00", results=[]) + result = latest_report_run(self.reports_root) + self.assertIsNotNone(result) + self.assertEqual(result["updated_at"], "2026-03-16T00:00:00+00:00") + + def test_latest_report_run_returns_none_on_empty_dir(self) -> None: + result = latest_report_run(self.reports_root) + self.assertIsNone(result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_execution_plan.py b/tests/test_runtime_execution_plan.py new file mode 100644 index 0000000..c697a25 --- /dev/null +++ b/tests/test_runtime_execution_plan.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import unittest + +from core.runtime.orchestration import ( + RuntimeCheckpointSignals, + build_runtime_execution_plan, + collect_checkpoint_runtime_signals, + missing_ollama_models, + openai_runtime_model_ids, + runtime_timeout_for_model, +) + + +class RuntimeExecutionPlanTests(unittest.TestCase): + def test_build_runtime_execution_plan_for_apple_uses_core_runtime_and_long_timeout(self) -> None: + plan = build_runtime_execution_plan( + "apple-coreml:qwen3.5-4b-onnx-q4f16", + core_base_url="http://127.0.0.1:8100", + ollama_runtime="native", + ollama_openai_base_url="http://127.0.0.1:8091", + smoke_timeout_seconds=300, + ) + + self.assertEqual(plan.openai_base_url, "http://127.0.0.1:8100") + self.assertEqual(plan.timeout_seconds, 600) + self.assertFalse(plan.requires_native_ollama_preflight) + self.assertEqual(plan.probe_profile_name, "mascarade_local") + + def test_build_runtime_execution_plan_for_native_ollama_requires_native_preflight(self) -> None: + plan = build_runtime_execution_plan( + "ollama:qwen2.5:7b", + core_base_url="http://127.0.0.1:8100", + ollama_runtime="native", + ollama_openai_base_url="http://127.0.0.1:8091", + smoke_timeout_seconds=300, + ) + + self.assertTrue(plan.requires_native_ollama_preflight) + self.assertEqual(plan.openai_base_url, "http://127.0.0.1:8100") + + def test_build_runtime_execution_plan_for_openai_compatible_ollama_uses_llama_cpp_url(self) -> None: + plan = build_runtime_execution_plan( + "ollama:qwen2.5:7b", + core_base_url="http://127.0.0.1:8100", + ollama_runtime="openai_compatible", + ollama_openai_base_url="http://127.0.0.1:8091", + smoke_timeout_seconds=300, + ) + + self.assertFalse(plan.requires_native_ollama_preflight) + self.assertEqual(plan.openai_base_url, "http://127.0.0.1:8091") + self.assertEqual(plan.probe_profile_name, "llama_cpp_local") + + def test_runtime_timeout_for_model_keeps_apple_high_and_others_medium(self) -> None: + self.assertEqual(runtime_timeout_for_model("apple-coreml:model", smoke_timeout_seconds=300), 600) + self.assertEqual(runtime_timeout_for_model("ollama:model", smoke_timeout_seconds=90), 120) + + +class RuntimeSignalsTests(unittest.TestCase): + def test_collect_checkpoint_runtime_signals_for_apple_waits_and_reports_core_health(self) -> None: + calls: list[str] = [] + + def fetch(url: str, timeout: float): + calls.append(url) + if url == "http://127.0.0.1:8100/health": + return {"status": "ok"} + if url == "http://127.0.0.1:8100/v1/models": + return {"data": [{"id": "apple-coreml:qwen3.5-4b-onnx-q4f16"}]} + if url == "http://127.0.0.1:8201/models": + return {"models": ["qwen3.5-4b-onnx-q4f16"]} + raise AssertionError(url) + + signals = collect_checkpoint_runtime_signals( + "apple-coreml:qwen3.5-4b-onnx-q4f16", + core_base_url="http://127.0.0.1:8100", + apple_runtime_url="http://127.0.0.1:8201", + apple_model_ready_timeout_seconds=0.0, + apple_model_poll_interval_seconds=0.0, + ollama_runtime="native", + ollama_openai_base_url="http://127.0.0.1:8091", + json_fetcher=fetch, + ) + + self.assertEqual( + signals, + RuntimeCheckpointSignals( + core_health_ok=True, + apple_model_active="qwen3.5-4b-onnx-q4f16", + ollama_openai_runtime_ready=False, + ), + ) + self.assertIn("http://127.0.0.1:8201/models", calls) + + def test_collect_checkpoint_runtime_signals_marks_openai_compatible_ollama_ready(self) -> None: + def fetch(url: str, timeout: float): + if url == "http://127.0.0.1:8100/health": + return {"status": "ok"} + if url == "http://127.0.0.1:8100/v1/models": + return {"data": [{"id": "apple-coreml:qwen3.5-4b-onnx-q4f16"}]} + if url == "http://127.0.0.1:8091/health": + return {"status": "ok"} + if url == "http://127.0.0.1:8091/v1/models": + return {"data": [{"id": "sha256-demo", "aliases": ["ollama:qwen2.5:7b"]}]} + raise AssertionError(url) + + signals = collect_checkpoint_runtime_signals( + "ollama:qwen2.5:7b", + core_base_url="http://127.0.0.1:8100", + apple_runtime_url="http://127.0.0.1:8201", + apple_model_ready_timeout_seconds=0.0, + apple_model_poll_interval_seconds=0.0, + ollama_runtime="openai_compatible", + ollama_openai_base_url="http://127.0.0.1:8091", + json_fetcher=fetch, + ) + + self.assertEqual( + signals, + RuntimeCheckpointSignals( + core_health_ok=True, + apple_model_active=None, + ollama_openai_runtime_ready=True, + ), + ) + + def test_openai_runtime_model_ids_can_fall_back_to_health_available_models(self) -> None: + import core.runtime.orchestration as module + + original = module.runtime_model_ids + module.runtime_model_ids = lambda _profile, json_fetcher: (_ for _ in ()).throw(ValueError("boom")) + try: + model_ids = openai_runtime_model_ids( + "http://127.0.0.1:8091", + json_fetcher=lambda url, timeout: {"status": "ok"} if url.endswith("/health") else {"data": [{"id": "ollama:qwen2.5:7b"}]}, + profile_name="llama_cpp_local", + ) + finally: + module.runtime_model_ids = original + + self.assertEqual(model_ids, {"ollama:qwen2.5:7b"}) + + def test_missing_ollama_models_reads_tag_catalog(self) -> None: + missing = missing_ollama_models( + ["qwen2.5:7b", "qwen2.5:1.5b"], + tags_url="http://127.0.0.1:11434/api/tags", + json_fetcher=lambda _url, _timeout: {"models": [{"name": "qwen2.5:7b"}]}, + ) + + self.assertEqual(missing, ["qwen2.5:1.5b"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_layer.py b/tests/test_runtime_layer.py new file mode 100644 index 0000000..b1eb30f --- /dev/null +++ b/tests/test_runtime_layer.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +import unittest + +from core.runtime.config import OpenAICompatibleRuntimeConfig, runtime_probe_profile +from core.runtime.health import probe_runtime_health, runtime_model_ids +from core.runtime.models import RuntimeProfile +from core.runtime.policies import ( + default_repair_fallback_model, + is_cross_apple_runtime_switch, + model_provider_name, + resolve_repair_model, +) + + +class RuntimeConfigTests(unittest.TestCase): + def test_runtime_profile_marks_apple_runtime_constraints(self): + config = OpenAICompatibleRuntimeConfig.from_env( + { + "ANE_BASE_URL": "http://127.0.0.1:8100", + "ANE_MODEL": "apple-coreml:qwen3.5-4b-onnx-q4f16", + } + ) + + profile = config.to_runtime_profile() + + self.assertTrue(profile.capabilities.requires_manual_model_switch) + self.assertTrue(profile.capabilities.single_model_per_runtime) + codes = {item.code for item in profile.constraints} + self.assertIn("apple_single_model_runtime", codes) + + def test_runtime_probe_profile_uses_shared_probe_defaults(self): + profile = runtime_probe_profile("http://127.0.0.1:8100") + + self.assertEqual(profile.model, "runtime-probe") + self.assertEqual(profile.max_tokens, 1) + self.assertEqual(profile.timeout, 10.0) + + +class RuntimePoliciesTests(unittest.TestCase): + def test_model_provider_name_extracts_prefix(self): + self.assertEqual(model_provider_name("ollama:qwen2.5:7b"), "ollama") + self.assertEqual(model_provider_name("apple-coreml:qwen3.5-4b-onnx-q4f16"), "apple-coreml") + + def test_default_repair_fallback_model_matches_known_runtime_defaults(self): + self.assertEqual(default_repair_fallback_model("ollama:qwen2.5:1.5b"), "ollama:qwen2.5:7b") + self.assertEqual( + default_repair_fallback_model("apple-coreml:qwen3.5-4b-onnx-q4f16"), + "ollama:qwen2.5:7b", + ) + + def test_cross_apple_runtime_switch_is_detected(self): + self.assertTrue( + is_cross_apple_runtime_switch( + "apple-coreml:qwen2.5-0.5b-instruct-onnx", + "apple-coreml:qwen3.5-4b-onnx-q4f16", + ) + ) + + def test_resolve_repair_model_keeps_same_provider_without_override(self): + self.assertEqual( + resolve_repair_model( + base_model="apple-coreml:qwen3.5-4b-onnx-q4f16", + attempt=2, + ), + "apple-coreml:qwen3.5-4b-onnx-q4f16", + ) + + def test_resolve_repair_model_rejects_cross_apple_override(self): + with self.assertRaisesRegex(RuntimeError, "apple-coreml"): + resolve_repair_model( + base_model="apple-coreml:qwen2.5-0.5b-instruct-onnx", + attempt=2, + override_model="apple-coreml:qwen3.5-4b-onnx-q4f16", + ) + + +class RuntimeHealthTests(unittest.TestCase): + def test_probe_runtime_health_reads_health_endpoint(self): + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps({"status": "ok", "model": "apple-coreml:qwen3.5-4b-onnx-q4f16"}).encode("utf-8") + + profile = RuntimeProfile( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="apple-coreml:qwen3.5-4b-onnx-q4f16", + timeout=5.0, + max_tokens=256, + stage_max_tokens={}, + ) + + health = probe_runtime_health(profile, opener=lambda url, timeout: FakeResponse()) + + self.assertTrue(health.ok) + self.assertEqual(health.active_model, "apple-coreml:qwen3.5-4b-onnx-q4f16") + self.assertEqual(health.status, "ok") + + def test_probe_runtime_health_reads_models_catalog_when_available(self): + profile = RuntimeProfile( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="ollama:qwen2.5:7b", + timeout=5.0, + max_tokens=256, + stage_max_tokens={}, + ) + + def json_fetcher(url, timeout): + self.assertTrue(url.endswith("/v1/models")) + return {"data": [{"id": "ollama:qwen2.5:7b"}]} + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps({"status": "ok"}).encode("utf-8") + + health = probe_runtime_health( + profile, + opener=lambda url, timeout: FakeResponse(), + json_fetcher=json_fetcher, + ) + + self.assertTrue(health.ok) + self.assertEqual(health.available_models, ["ollama:qwen2.5:7b"]) + + def test_runtime_model_ids_flattens_ids_names_and_aliases(self): + profile = runtime_probe_profile("http://127.0.0.1:8100") + + model_ids = runtime_model_ids( + profile, + json_fetcher=lambda _url, _timeout: { + "data": [ + {"id": "ollama:qwen2.5:7b", "aliases": ["qwen2.5:7b"]}, + {"name": "apple-coreml:qwen3.5-4b-onnx-q4f16"}, + ], + "models": ["fallback:model"], + }, + ) + + self.assertEqual( + model_ids, + { + "ollama:qwen2.5:7b", + "qwen2.5:7b", + "apple-coreml:qwen3.5-4b-onnx-q4f16", + "fallback:model", + }, + ) diff --git a/tests/test_runtime_orchestration.py b/tests/test_runtime_orchestration.py new file mode 100644 index 0000000..19136f9 --- /dev/null +++ b/tests/test_runtime_orchestration.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import io +import json +import unittest +from urllib import error + +from core.runtime.checkpoints import checkpoint_manual_action_for_model, host_port_from_base_url +from core.runtime.health import current_apple_model, wait_for_expected_apple_model +from core.runtime.preflight import ollama_base_url, run_ollama_native_preflight + + +class RuntimeCheckpointTests(unittest.TestCase): + def test_checkpoint_manual_action_builds_apple_switch(self) -> None: + action = checkpoint_manual_action_for_model( + model="apple-coreml:qwen3.5-4b-onnx-q4f16", + core_health_ok=True, + ollama_runtime="openai_compatible", + ollama_openai_runtime_ready=True, + ollama_openai_base_url="http://127.0.0.1:8091", + apple_model_active="qwen2.5-0.5b-instruct-onnx", + repo_root="/repo", + state_path="/repo/automation/state.json", + ane_script_path="/repo/scripts/run_next_lots.py", + ) + + self.assertIsNotNone(action) + assert action is not None + self.assertIn("--apple-model", action.args) + self.assertIn("qwen3.5-4b-onnx-q4f16", action.reason) + + def test_host_port_from_base_url_handles_https_default_port(self) -> None: + self.assertEqual(host_port_from_base_url("https://example.test"), ("example.test", 443)) + + +class AppleRuntimeHealthTests(unittest.TestCase): + def test_current_apple_model_reads_first_model(self) -> None: + model = current_apple_model( + "http://127.0.0.1:8201", + json_fetcher=lambda url, timeout: {"models": ["qwen3.5-4b-onnx-q4f16"]}, + ) + self.assertEqual(model, "qwen3.5-4b-onnx-q4f16") + + def test_wait_for_expected_apple_model_polls_until_match(self) -> None: + payloads = iter( + [ + {"models": ["qwen2.5-0.5b-instruct-onnx"]}, + {"models": ["qwen3.5-4b-onnx-q4f16"]}, + ] + ) + ticks = iter([0.0, 0.0, 0.2, 0.4]) + + model = wait_for_expected_apple_model( + "http://127.0.0.1:8201", + "qwen3.5-4b-onnx-q4f16", + json_fetcher=lambda url, timeout: next(payloads), + timeout_seconds=1.0, + poll_interval_seconds=0.1, + sleeper=lambda seconds: None, + monotonic=lambda: next(ticks), + ) + self.assertEqual(model, "qwen3.5-4b-onnx-q4f16") + + +class OllamaPreflightTests(unittest.TestCase): + def test_ollama_base_url_removes_api_tags_suffix(self) -> None: + self.assertEqual(ollama_base_url("http://127.0.0.1:11434/api/tags"), "http://127.0.0.1:11434") + + def test_run_ollama_native_preflight_returns_success_preview(self) -> None: + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps( + { + "model": "qwen2.5:7b", + "message": {"content": "ollama native preflight ok"}, + "done_reason": "stop", + } + ).encode("utf-8") + + result = run_ollama_native_preflight( + model="qwen2.5:7b", + tags_url="http://127.0.0.1:11434/api/tags", + timeout_seconds=5.0, + opener=lambda req, timeout: FakeResponse(), + monotonic=lambda: 0.0, + ) + + self.assertEqual(result.returncode, 0) + self.assertIn("qwen2.5:7b", result.stdout) + + def test_run_ollama_native_preflight_reports_http_error(self) -> None: + http_error = error.HTTPError( + url="http://127.0.0.1:11434/api/chat", + code=500, + msg="boom", + hdrs=None, + fp=io.BytesIO(b"detail"), + ) + + result = run_ollama_native_preflight( + model="qwen2.5:7b", + tags_url="http://127.0.0.1:11434/api/tags", + timeout_seconds=5.0, + opener=lambda req, timeout: (_ for _ in ()).throw(http_error), + monotonic=lambda: 0.0, + ) + http_error.close() + + self.assertEqual(result.returncode, 1) + self.assertIn("HTTP 500", result.stderr) diff --git a/tests/test_runtime_profiles.py b/tests/test_runtime_profiles.py new file mode 100644 index 0000000..d0088fa --- /dev/null +++ b/tests/test_runtime_profiles.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import unittest + +from core.generation.provider import ProviderConfig +from core.runtime.config import openai_base_url_for_model, runtime_probe_profile +from core.runtime.health import probe_runtime_health +from core.runtime.profiles import runtime_probe_name, runtime_profile_name_for_model + + +class RuntimeProfileTests(unittest.TestCase): + def test_provider_config_exposes_runtime_constraints_for_apple(self) -> None: + profile = 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=512, + stage_max_tokens={}, + ).to_runtime_profile() + + self.assertTrue(profile.capabilities.requires_manual_model_switch) + self.assertEqual(profile.capabilities.response_format_mode, "best_effort") + self.assertEqual(profile.name, "apple_coreml_single_model") + self.assertTrue(any(item.code == "manual-apple-switch" for item in profile.constraints)) + self.assertTrue(any(item.code == "json-best-effort" for item in profile.constraints)) + + def test_runtime_profile_with_model_keeps_manual_switch_flag_for_cross_apple_switch(self) -> None: + profile = 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=512, + stage_max_tokens={}, + ).to_runtime_profile() + + updated = profile.with_model("apple-coreml:qwen3.5-4b-onnx-q4f16") + self.assertTrue(updated.capabilities.requires_manual_model_switch) + + +class RuntimeHealthTests(unittest.TestCase): + def test_probe_runtime_health_can_read_model_catalog(self) -> None: + profile = ProviderConfig( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="ollama:qwen2.5:7b", + timeout=30.0, + max_tokens=512, + stage_max_tokens={}, + ).to_runtime_profile() + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return b'{\"status\": \"ok\"}' + + def fetch(url: str, timeout: float): + if url.endswith("/v1/models"): + return {"data": [{"id": "ollama:qwen2.5:7b"}]} + raise AssertionError(url) + + health = probe_runtime_health(profile, opener=lambda *args, **kwargs: FakeResponse(), json_fetcher=fetch) + self.assertTrue(health.ok) + self.assertEqual(health.available_models, ["ollama:qwen2.5:7b"]) + + def test_probe_runtime_health_reports_failure(self) -> None: + profile = ProviderConfig( + provider="openai_compatible", + base_url="http://127.0.0.1:8100", + api_key="", + model="ollama:qwen2.5:7b", + timeout=30.0, + max_tokens=512, + stage_max_tokens={}, + ).to_runtime_profile() + + health = probe_runtime_health(profile, opener=lambda *args, **kwargs: (_ for _ in ()).throw(OSError("down"))) + self.assertFalse(health.ok) + self.assertIn("down", health.detail or "") + + +class RuntimeConfigHelpersTests(unittest.TestCase): + def test_runtime_probe_profile_uses_runtime_probe_defaults(self) -> None: + profile = runtime_probe_profile("http://127.0.0.1:8100", timeout=7.0) + + self.assertEqual(profile.base_url, "http://127.0.0.1:8100") + self.assertEqual(profile.model, "runtime-probe") + self.assertEqual(profile.timeout, 7.0) + self.assertEqual(profile.name, "openai_probe") + + def test_openai_base_url_for_model_prefers_ollama_openai_runtime_when_enabled(self) -> None: + self.assertEqual( + openai_base_url_for_model( + "ollama:qwen2.5:7b", + core_base_url="http://127.0.0.1:8100", + ollama_runtime="openai_compatible", + ollama_openai_base_url="http://127.0.0.1:8091", + ), + "http://127.0.0.1:8091", + ) + + def test_openai_base_url_for_model_defaults_to_core_runtime(self) -> None: + self.assertEqual( + openai_base_url_for_model( + "apple-coreml:qwen3.5-4b-onnx-q4f16", + core_base_url="http://127.0.0.1:8100", + ollama_runtime="openai_compatible", + ollama_openai_base_url="http://127.0.0.1:8091", + ), + "http://127.0.0.1:8100", + ) + + def test_runtime_profile_name_for_model_maps_known_runtimes(self) -> None: + self.assertEqual(runtime_profile_name_for_model("apple-coreml:qwen3.5-4b-onnx-q4f16"), "apple_coreml_single_model") + self.assertEqual(runtime_profile_name_for_model("ollama:qwen2.5:7b"), "ollama_openai_compatible") + self.assertEqual( + runtime_profile_name_for_model("ollama:qwen2.5:7b", ollama_runtime="native"), + "ollama_native", + ) + + def test_runtime_probe_name_maps_named_probe_profiles(self) -> None: + self.assertEqual(runtime_probe_name("core"), "mascarade_local") + self.assertEqual(runtime_probe_name("apple"), "apple_coreml_single_model") + self.assertEqual(runtime_probe_name("ollama_openai"), "llama_cpp_local") + self.assertEqual( + runtime_probe_name("remote", base_url="http://tower.example.test:8100"), + "mascarade_remote_tower_example_test", + ) diff --git a/tests/test_setup_mascarade_launchd.py b/tests/test_setup_mascarade_launchd.py new file mode 100644 index 0000000..1fab7b2 --- /dev/null +++ b/tests/test_setup_mascarade_launchd.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "setup_mascarade_launchd.py" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("setup_mascarade_launchd", SCRIPT_PATH) + if spec is None or spec.loader is None: + raise RuntimeError("Impossible de charger setup_mascarade_launchd.py") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +class SetupMascaradeLaunchdTests(unittest.TestCase): + def test_read_hosts_returns_empty_on_invalid_toml(self) -> None: + module = _load_module() + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "bad.toml" + path.write_text("[[hosts]\nname='oops'", encoding="utf-8") + hosts = module._read_hosts(path) + self.assertEqual(hosts, []) + + def test_read_hosts_parses_valid_config(self) -> None: + module = _load_module() + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "hosts.toml" + path.write_text( + "[defaults]\nremote_core_port = 8100\nlocal_bind_host = \"127.0.0.1\"\n" + "[[hosts]]\nname = \"tower\"\nssh_target = \"clems@192.168.120\"\nlocal_tunnel_port = 8110\n", + encoding="utf-8", + ) + hosts = module._read_hosts(path) + self.assertEqual(len(hosts), 1) + self.assertEqual(hosts[0].name, "tower") + self.assertEqual(hosts[0].label, "com.ai-novel-engine.mascarade.tower.tunnel") + self.assertEqual(hosts[0].plist_name, "com.ai-novel-engine.mascarade.tower.tunnel.plist") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tracking_sync.py b/tests/test_tracking_sync.py new file mode 100644 index 0000000..be51ccd --- /dev/null +++ b/tests/test_tracking_sync.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from types import SimpleNamespace +from pathlib import Path +import tempfile +import unittest + +from core.tracking_sync import ( + TrackingPaths, + build_tracking_sync_context, + sync_tracking, + write_report_summary, +) + + +class TrackingSyncTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "repo" + self.root.mkdir(parents=True, exist_ok=True) + self.mascarade = Path(self.temp_dir.name) / "mascarade" + self.mascarade.mkdir(parents=True, exist_ok=True) + self.report_dir = self.root / "automation" / "reports" / "20260322T120000Z" + self.report_dir.mkdir(parents=True, exist_ok=True) + + self.tracking = TrackingPaths( + ane_todo_active=self._write_doc(self.root / "TODO_ACTIVE.md", "## Auto-sync", "ANE-TODO-ACTIVE"), + ane_todo_done=self._write_doc(self.root / "TODO_IMPLEMENTE.md", "## Auto-sync", "ANE-TODO-DONE"), + ane_plan=self._write_doc(self.root / "docs" / "EXECUTION_PLAN_2026-03-21.md", "## Auto-sync", "ANE-PLAN"), + ane_comparison=self._write_doc(self.root / "docs" / "MODEL_COMPARISON_2026-03-21.md", "## Auto-sync", "ANE-COMPARISON"), + ane_readme=self._write_doc(self.root / "README.md", "## Etat auto-synchronise", "ANE-README"), + ane_runbook=self._write_doc(self.root / "docs" / "runbooks" / "LOCAL_GENERATION.md", "## Etat auto-synchronise", "ANE-RUNBOOK"), + mascarade_repo=self.mascarade, + mascarade_todo=self._write_doc(self.mascarade / "TODO_AI_NOVEL_ENGINE.md", "## Auto-sync", "MASCARADE-TODO"), + mascarade_plan=self._write_doc(self.mascarade / "docs" / "EXECUTION_PLAN_2026-03-21.md", "## Auto-sync", "MASCARADE-PLAN"), + mascarade_readme=self._write_doc(self.mascarade / "README.md", "## Etat auto-synchronise", "MASCARADE-README"), + mascarade_runbook=self._write_doc(self.mascarade / "docs" / "RUNBOOK_APPLE_LLM_LOCAL.md", "## Etat auto-synchronise", "MASCARADE-RUNBOOK"), + ) + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def _write_doc(self, path: Path, heading: str, marker: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join( + [ + f"# {path.name}", + "", + heading, + f"", + "- old", + f"", + "", + ] + ), + encoding="utf-8", + ) + return path + + def _state(self) -> SimpleNamespace: + return SimpleNamespace( + lot="priority_models", + started_at="2026-03-22T12:00:00+00:00", + updated_at="2026-03-22T12:05:00+00:00", + report_dir=str(self.report_dir), + state_path=str(self.root / "automation" / "state" / "next_lots_state.json"), + pending_manual_action=None, + results=[ + { + "model": "apple-coreml:qwen3.5-4b-onnx-q4f16", + "category": "priority_models", + "classification": "accepted", + "preflight_ok": True, + "smoke_attempted": True, + "status": "accepted", + "accepted": True, + "completed_stages": ["gate", "repair"], + "repair_attempts": 0, + "notes": ["ok"], + } + ], + ) + + def test_build_tracking_sync_context_maps_all_paths(self) -> None: + context = build_tracking_sync_context( + self.root, + next_code_lot="rewrite_compaction", + tracking=self.tracking, + ) + + self.assertEqual(context.repo_root, self.root) + self.assertEqual(context.next_code_lot, "rewrite_compaction") + self.assertEqual(context.ane_todo_active, self.tracking.ane_todo_active) + self.assertEqual(context.mascarade_runbook, self.tracking.mascarade_runbook) + + def test_sync_tracking_updates_docs_and_writes_summary(self) -> None: + context = build_tracking_sync_context( + self.root, + next_code_lot="rewrite_compaction", + tracking=self.tracking, + ) + + sync_tracking(context, self._state(), dry_run=False, project_state={"current_chapter": "05"}) + + todo_active = self.tracking.ane_todo_active.read_text(encoding="utf-8") + summary = (self.report_dir / "SUMMARY.md").read_text(encoding="utf-8") + self.assertIn("apple-coreml:qwen3.5-4b-onnx-q4f16", todo_active) + self.assertIn("prochain lot recommande", todo_active) + self.assertIn("| Modele | Categorie | Preflight | Smoke | Classification | Failed stage | Gate | Repairs | Notes |", summary) + self.assertIn("accepted", summary) + + def test_write_report_summary_keeps_report_directory_atomic(self) -> None: + state = self._state() + + write_report_summary(state) + + run_path = self.report_dir / "run.json" + summary_path = self.report_dir / "SUMMARY.md" + self.assertTrue(run_path.exists()) + self.assertTrue(summary_path.exists()) + self.assertIn("priority_models", summary_path.read_text(encoding="utf-8")) + + +if __name__ == "__main__": + unittest.main()