feat(runtime): add orchestration, tracking, and ops tooling

This commit is contained in:
L'électron rare
2026-04-06 11:49:28 +02:00
parent a1a4fe92a0
commit 5b052a8f70
114 changed files with 9809 additions and 703 deletions
+12
View File
@@ -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)"
]
}
}
+19
View File
@@ -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.
+76
View File
@@ -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
+41 -5
View File
@@ -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
<!-- AUTO-SYNC:ANE-TODO-ACTIVE:START -->
- 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`
+208 -1
View File
@@ -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
<!-- AUTO-SYNC:ANE-TODO-DONE:END -->
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ai-novel-engine.mascarade.kxkm.tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/ssh</string>
<string>-N</string>
<string>-o</string>
<string>ExitOnForwardFailure=yes</string>
<string>-o</string>
<string>ServerAliveInterval=30</string>
<string>-o</string>
<string>ServerAliveCountMax=3</string>
<string>-L</string>
<string>127.0.0.1:8111:127.0.0.1:8100</string>
<string>kxkm@kxkm-ai</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.ai-novel-engine.mascarade.tower.tunnel</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/ssh</string>
<string>-N</string>
<string>-o</string>
<string>ExitOnForwardFailure=yes</string>
<string>-o</string>
<string>ServerAliveInterval=30</string>
<string>-o</string>
<string>ServerAliveCountMax=3</string>
<string>-L</string>
<string>127.0.0.1:8110:127.0.0.1:8100</string>
<string>clems@192.168.120</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
+15
View File
@@ -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
@@ -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"
}
+8
View File
@@ -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
+9
View File
@@ -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",
]
+118
View File
@@ -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
+93
View File
@@ -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)
+107 -133
View File
@@ -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
+24 -193
View File
@@ -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,
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,
)
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.")
)
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):
+147
View File
@@ -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
+49 -30
View File
@@ -73,12 +73,7 @@ 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(
return [
{
"chapter": chapter.slug,
"status": str(meta.get("status", "")),
@@ -87,23 +82,22 @@ class ProjectState:
"retry_stages": self._retry_stages(meta),
"last_status_message": str(meta.get("last_status_message", "")).strip(),
}
)
return failures
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")
+142
View File
@@ -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+", "<n>", 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)
+77
View File
@@ -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",
]
+91
View File
@@ -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
+166
View File
@@ -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.")
+163
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
class ProviderError(RuntimeError):
"""Raised when a text generation provider fails."""
class ProviderConfigurationError(ProviderError):
"""Raised when the provider environment is incomplete."""
+153
View File
@@ -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]
+77
View File
@@ -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
+149
View File
@@ -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,
)
+50
View File
@@ -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
+87
View File
@@ -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,
)
+48
View File
@@ -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
+93
View File
@@ -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
+581
View File
@@ -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"<!-- AUTO-SYNC:{name}:START -->",
f"<!-- AUTO-SYNC:{name}:END -->",
)
def replace_auto_section(path: Path, marker_name: str, heading: str, body: str) -> None:
start_marker, end_marker = _auto_markers(marker_name)
text = path.read_text(encoding="utf-8") if path.exists() else ""
section = f"{heading}\n{start_marker}\n{body.rstrip()}\n{end_marker}\n"
if start_marker in text and end_marker in text:
start = text.index(start_marker)
end = text.index(end_marker) + len(end_marker)
replacement_start = text.rfind("\n", 0, start)
if replacement_start == -1:
replacement_start = 0
else:
replacement_start += 1
new_text = f"{text[:replacement_start]}{section}{text[end:].lstrip()}"
else:
suffix = "\n" if text.endswith("\n") else "\n\n"
new_text = f"{text}{suffix}{section}"
repeated_heading_pattern = rf"(?:{re.escape(heading)}\n){{2,}}"
new_text = re.sub(repeated_heading_pattern, f"{heading}\n", new_text)
path.write_text(new_text, encoding="utf-8")
def sync_tracking(context: TrackingSyncContext, state: Any, *, dry_run: bool, project_state: dict[str, Any] | None = None) -> None:
if dry_run:
write_report_summary(state)
return
typed_results = _consolidated_tracking_results(state, context.repo_root / "automation" / "reports")
accepted_counts = _accepted_history_counts(state, context.repo_root / "automation" / "reports")
project_state = project_state or ProjectState(context.repo_root).summary()
summary = _build_summary(state, typed_results)
comparison = _render_comparison_markdown(state, typed_results)
active_next = _compute_next_lot_recommendation(
typed_results,
context.next_code_lot,
accepted_counts=accepted_counts,
)
replace_auto_section(
context.ane_todo_active,
AUTO_SYNC_TODO_ACTIVE,
"## Auto-sync",
_render_todo_active_sync(summary, active_next),
)
replace_auto_section(
context.ane_todo_done,
AUTO_SYNC_TODO_DONE,
"## Auto-sync",
_render_todo_done_sync(summary),
)
replace_auto_section(
context.ane_plan,
AUTO_SYNC_PLAN,
"## Auto-sync",
_render_plan_sync(summary, active_next),
)
replace_auto_section(
context.ane_comparison,
AUTO_SYNC_COMPARISON,
"## Auto-sync",
comparison,
)
replace_auto_section(
context.ane_readme,
AUTO_SYNC_README,
"## Etat auto-synchronise",
_render_readme_sync(summary, active_next),
)
replace_auto_section(
context.ane_runbook,
AUTO_SYNC_RUNBOOK,
"## Etat auto-synchronise",
_render_runbook_sync(summary, project_state, active_next),
)
replace_auto_section(
context.mascarade_todo,
AUTO_SYNC_MASCARADE_TODO,
"## Auto-sync",
_render_mascarade_todo_sync(summary, active_next),
)
replace_auto_section(
context.mascarade_plan,
AUTO_SYNC_MASCARADE_PLAN,
"## Auto-sync",
_render_mascarade_plan_sync(summary, active_next),
)
replace_auto_section(
context.mascarade_readme,
AUTO_SYNC_MASCARADE_README,
"## Etat auto-synchronise",
_render_mascarade_readme_sync(summary, active_next),
)
replace_auto_section(
context.mascarade_runbook,
AUTO_SYNC_MASCARADE_RUNBOOK,
"## Etat auto-synchronise",
_render_mascarade_runbook_sync(summary, active_next),
)
write_report_summary(state)
def write_report_summary(state: Any) -> None:
report_dir = Path(getattr(state, "report_dir"))
report_dir.mkdir(parents=True, exist_ok=True)
run_path = report_dir / "run.json"
summary_path = report_dir / "SUMMARY.md"
state_payload = dict(vars(state))
run_path.write_text(json.dumps(state_payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
typed_results = list(_state_typed_results(state))
summary_path.write_text(_render_summary_markdown(state, typed_results), encoding="utf-8")
def _safe_timestamp(value: str) -> datetime:
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return datetime.fromtimestamp(0, tz=timezone.utc)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _load_report_history(reports_root: Path) -> list[dict[str, Any]]:
history: list[dict[str, Any]] = []
if not reports_root.exists():
return history
for run_path, payload in iter_run_payloads(reports_root):
payload = dict(payload)
payload.setdefault("report_dir", str(run_path.parent))
history.append(payload)
history.sort(key=lambda item: (_safe_timestamp(str(item.get("updated_at", ""))), str(item.get("report_dir", ""))))
return history
def _string_list(value: object) -> list[str]:
if not isinstance(value, list):
return []
return [str(item).strip() for item in value if str(item).strip()]
def _optional_string(value: object) -> str | None:
text = str(value).strip() if value is not None else ""
return text or None
def _tracking_result_from_payload(payload: Any) -> TrackingResult | None:
if isinstance(payload, TrackingResult):
return payload
if not isinstance(payload, dict):
payload = {field: getattr(payload, field, None) for field in (
"model",
"category",
"classification",
"preflight_ok",
"smoke_attempted",
"status",
"accepted",
"failed_stage",
"quality_blockers",
"retry_stages",
"repair_attempts",
"notes",
"completed_stages",
"repair_models",
)}
model = str(payload.get("model", "")).strip()
category = str(payload.get("category", "")).strip()
if not model or not category:
return None
return TrackingResult(
model=model,
category=category,
classification=str(payload.get("classification", "pending")),
preflight_ok=payload.get("preflight_ok"),
smoke_attempted=bool(payload.get("smoke_attempted", False)),
status=_optional_string(payload.get("status")),
accepted=bool(payload.get("accepted", False)),
failed_stage=_optional_string(payload.get("failed_stage")),
quality_blockers=_string_list(payload.get("quality_blockers")),
retry_stages=_string_list(payload.get("retry_stages")),
repair_attempts=int(payload.get("repair_attempts", 0) or 0),
notes=_string_list(payload.get("notes")),
completed_stages=_string_list(payload.get("completed_stages")),
repair_models=_string_list(payload.get("repair_models")),
)
def _state_typed_results(state: Any) -> list[TrackingResult]:
results: list[TrackingResult] = []
typed_results = getattr(state, "typed_results", None)
raw_results = typed_results() if callable(typed_results) else getattr(state, "results", [])
for item in raw_results or []:
result = _tracking_result_from_payload(item)
if result is not None:
results.append(result)
return results
def _consolidated_tracking_results(state: Any, reports_root: Path) -> list[TrackingResult]:
latest_by_model: dict[str, tuple[tuple[datetime, int], TrackingResult]] = {}
sequence = 0
for snapshot in [*_load_report_history(reports_root), _state_payload(state)]:
stamp = _safe_timestamp(str(snapshot.get("updated_at", "")))
for result in _snapshot_results(snapshot):
candidate_key = (stamp, sequence)
current = latest_by_model.get(result.model)
if current is None or candidate_key >= current[0]:
latest_by_model[result.model] = (candidate_key, result)
sequence += 1
return sorted((payload[1] for payload in latest_by_model.values()), key=_result_sort_key)
def _accepted_history_counts(state: Any, reports_root: Path) -> dict[str, int]:
counts: dict[str, int] = {}
for snapshot in [*_load_report_history(reports_root), _state_payload(state)]:
for result in _snapshot_results(snapshot):
if result.classification != "accepted":
continue
counts[result.model] = counts.get(result.model, 0) + 1
return counts
def _snapshot_results(snapshot: dict[str, Any]) -> list[TrackingResult]:
results: list[TrackingResult] = []
for item in snapshot.get("results") or []:
result = _tracking_result_from_payload(item)
if result is not None:
results.append(result)
return results
def _state_payload(state: Any) -> dict[str, Any]:
payload = dict(vars(state))
if "report_dir" in payload:
payload["report_dir"] = str(payload["report_dir"])
return payload
def _result_sort_key(result: TrackingResult) -> tuple[int, str, str]:
category_order = {
"priority_models": 0,
"baselines": 1,
"preflight_only": 2,
"runtime_preflight": 3,
}
provider = result.model.split(":", 1)[0]
return (category_order.get(result.category, 9), provider, result.model)
def _build_summary(state: Any, results: list[TrackingResult]) -> dict[str, Any]:
accepted = [item for item in results if item.classification == "accepted"]
reached_gate = [item for item in results if item.reached_gate()]
quality_blocked = [item for item in results if item.classification == "quality_blocked"]
provider_failed = [item for item in results if item.classification == "provider_failed"]
return {
"started_at": getattr(state, "started_at"),
"updated_at": getattr(state, "updated_at"),
"pending_manual_action": getattr(state, "pending_manual_action", None),
"accepted_models": [item.model for item in accepted],
"reached_gate_models": [item.model for item in reached_gate],
"quality_blocked_models": [item.model for item in quality_blocked],
"provider_failed_models": [item.model for item in provider_failed],
"results": results,
}
def _compute_next_lot_recommendation(
results: list[TrackingResult],
fallback: str,
*,
accepted_counts: dict[str, int] | None = None,
) -> str:
accepted_counts = accepted_counts or {}
provider_failed_models = [item.model for item in results if item.classification == "provider_failed"]
has_quality_blocked = any(item.classification == "quality_blocked" for item in results)
if accepted_counts.get("apple-coreml:qwen3.5-4b-onnx-q4f16", 0) >= 2:
if provider_failed_models:
if has_quality_blocked:
return "Reference locale reconfirmee; retablir le runtime des modeles provider_failed puis reprendre rewrite/repair sur les modeles bloques a gate."
return "Reference locale reconfirmee; retablir le runtime des modeles provider_failed avant de poursuivre."
if any(item.classification == "quality_blocked" for item in results):
return "Reference locale reconfirmee; resserrer rewrite/repair sur les modeles deja bloques a gate."
return "Reference locale reconfirmee; garder les autres modeles en regression."
if any(item.classification == "accepted" for item in results):
if provider_failed_models:
return "Confirmer la reference accepted puis retablir le runtime des modeles provider_failed."
if any(item.classification == "quality_blocked" for item in results):
return "Confirmer la reference accepted puis resserrer rewrite/repair sur les modeles deja bloques a gate."
return "Figer la reference locale dans les README/runbooks et garder les autres modeles en regression."
if any(item.reached_gate() for item in results):
return "Analyser les runs ayant atteint gate/repair puis resserrer la reference locale autour des meilleurs candidats."
return fallback
def _render_todo_active_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle automatique: {summary['updated_at']}",
f"- modeles accepted: {_comma_or_none(summary['accepted_models'])}",
f"- modeles ayant atteint gate: {_comma_or_none(summary['reached_gate_models'])}",
f"- quality_blocked: {_comma_or_none(summary['quality_blocked_models'])}",
f"- provider_failed: {_comma_or_none(summary['provider_failed_models'])}",
f"- prochain lot recommande: {next_lot}",
]
if summary["pending_manual_action"]:
pending = summary["pending_manual_action"]
lines.extend(
[
f"- checkpoint manuel en attente: {pending['reason']}",
f"- commande preparee: `{pending['command']}`",
f"- reprise: `python3 scripts/run_next_lots.py --resume {pending['resume_state']}`",
]
)
return "\n".join(lines)
def _render_todo_done_sync(summary: dict[str, Any]) -> str:
lines = [
"- orchestrateur `scripts/run_next_lots.py` disponible",
"- manifeste `automation/next_lots.toml` charge",
"- derniers fichiers de suivi synchronisables via marqueurs `AUTO-SYNC`",
f"- dernier cycle automatise observe: {summary['updated_at']}",
]
return "\n".join(lines)
def _render_plan_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier verdict automatise: {summary['updated_at']}",
f"- accepted: {_comma_or_none(summary['accepted_models'])}",
f"- gate atteint: {_comma_or_none(summary['reached_gate_models'])}",
f"- prochain lot calcule: {next_lot}",
]
if summary["pending_manual_action"]:
lines.append(f"- checkpoint manuel requis: {summary['pending_manual_action']['reason']}")
return "\n".join(lines)
def _render_readme_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle automatise: {summary['updated_at']}",
f"- reference locale actuelle: {_reference_label(summary)}",
f"- prochain lot utile: {next_lot}",
"- lancer un cycle: `python3 scripts/run_next_lots.py --lot full`",
]
if summary["pending_manual_action"]:
lines.append(f"- checkpoint manuel en attente: {summary['pending_manual_action']['reason']}")
return "\n".join(lines)
def _render_runbook_sync(summary: dict[str, Any], project_state: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle automatise: {summary['updated_at']}",
f"- chapitre courant detecte: {project_state.get('current_chapter') or 'aucun'}",
f"- reference locale actuelle: {_reference_label(summary)}",
f"- prochain lot utile: {next_lot}",
]
if summary["pending_manual_action"]:
lines.append(f"- reprise attendue apres action manuelle: {summary['pending_manual_action']['resume_state']}")
return "\n".join(lines)
def _render_mascarade_todo_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle ANE automatise: {summary['updated_at']}",
f"- accepted via runtime local: {_comma_or_none(summary['accepted_models'])}",
f"- gate atteint via runtime local: {_comma_or_none(summary['reached_gate_models'])}",
f"- blocage runtime principal: {next_lot}",
]
if summary["pending_manual_action"]:
lines.append(f"- checkpoint runtime manuel: {summary['pending_manual_action']['reason']}")
return "\n".join(lines)
def _render_mascarade_plan_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle ANE automatise: {summary['updated_at']}",
f"- reference locale ANE: {_reference_label(summary)}",
f"- prochain lot ANE a servir: {next_lot}",
]
return "\n".join(lines)
def _render_mascarade_readme_sync(summary: dict[str, Any], next_lot: str) -> str:
return "\n".join(
[
f"- dernier cycle ANE automatise: {summary['updated_at']}",
f"- etat de reference ANE: {_reference_label(summary)}",
f"- prochain lot utile cote pipeline: {next_lot}",
]
)
def _render_mascarade_runbook_sync(summary: dict[str, Any], next_lot: str) -> str:
lines = [
f"- dernier cycle ANE automatise: {summary['updated_at']}",
f"- meilleurs candidats actuels: {_top_candidates(summary['results'])}",
f"- prochain lot utile cote ANE: {next_lot}",
]
if summary["pending_manual_action"]:
lines.append(f"- checkpoint runtime manuel: {summary['pending_manual_action']['reason']}")
return "\n".join(lines)
def _reference_label(summary: dict[str, Any]) -> str:
if summary["accepted_models"]:
return summary["accepted_models"][0]
if summary["reached_gate_models"]:
return f"aucun accepted, meilleur diagnostic: {summary['reached_gate_models'][0]}"
return "aucune reference accepted"
def _top_candidates(results: Iterable[TrackingResult]) -> str:
candidates: list[str] = []
for item in results:
if item.model in candidates:
continue
if item.model.startswith("apple-coreml:qwen3.5-4b") or item.model.startswith("ollama:qwen2.5:7b"):
candidates.append(item.model)
return ", ".join(candidates) if candidates else "aucun"
def _comma_or_none(items: list[str]) -> str:
return ", ".join(items) if items else "aucun"
def _render_comparison_markdown(state: Any, results: list[TrackingResult]) -> str:
lines = [
f"- dernier cycle automatise: {getattr(state, 'updated_at')}",
"",
"| Modele | Categorie | Preflight | Smoke | Classification | Failed stage | Gate | Repairs | Notes |",
"|---|---|---|---|---|---|---|---:|---|",
]
for item in results:
lines.append(
"| {model} | {category} | {preflight} | {smoke} | {classification} | {failed_stage} | {gate} | {repairs} | {notes} |".format(
model=item.model,
category=item.category,
preflight="OK" if item.preflight_ok else ("KO" if item.preflight_ok is False else "n/a"),
smoke="oui" if item.smoke_attempted else "non",
classification=item.classification,
failed_stage=item.failed_stage or "",
gate="oui" if item.reached_gate() else "non",
repairs=item.repair_attempts,
notes="; ".join(item.notes) if item.notes else "",
)
)
return "\n".join(lines)
def _render_summary_markdown(state: Any, results: list[TrackingResult]) -> str:
summary = _build_summary(state, results)
lines = [
"# Résumé du cycle automatique",
"",
f"- lot: `{getattr(state, 'lot')}`",
f"- démarré: `{getattr(state, 'started_at')}`",
f"- mis à jour: `{getattr(state, 'updated_at')}`",
f"- accepted: {_comma_or_none(summary['accepted_models'])}",
f"- gate atteint: {_comma_or_none(summary['reached_gate_models'])}",
f"- quality_blocked: {_comma_or_none(summary['quality_blocked_models'])}",
f"- provider_failed: {_comma_or_none(summary['provider_failed_models'])}",
]
if getattr(state, "pending_manual_action", None):
lines.extend(
[
"",
"## Checkpoint manuel",
f"- raison: {state.pending_manual_action['reason']}",
f"- commande: `{state.pending_manual_action['command']}`",
f"- reprise: `python3 scripts/run_next_lots.py --resume {state.pending_manual_action['resume_state']}`",
]
)
if results:
lines.extend(["", "## Résultats", ""])
lines.append(_render_comparison_markdown(state, results))
return "\n".join(lines) + "\n"
+166
View File
@@ -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
+98
View File
@@ -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
+226
View File
@@ -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`
+61
View File
@@ -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)
+52
View File
@@ -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
+56
View File
@@ -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
+155
View File
@@ -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
<!-- AUTO-SYNC:ANE-PLAN:START -->
- 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`.
<!-- AUTO-SYNC:ANE-PLAN:END -->
+127
View File
@@ -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.
+133
View File
@@ -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
<!-- AUTO-SYNC:ANE-PLAN:START -->
- 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`.
<!-- AUTO-SYNC:ANE-PLAN:END -->
+71
View File
@@ -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 |
+52
View File
@@ -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`.
+46
View File
@@ -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.
+75
View File
@@ -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.
+44
View File
@@ -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
<!-- AUTO-SYNC:ANE-COMPARISON:START -->
- 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 | |
<!-- AUTO-SYNC:ANE-COMPARISON:END -->
+39
View File
@@ -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
<!-- AUTO-SYNC:ANE-COMPARISON:START -->
- 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é. |
<!-- AUTO-SYNC:ANE-COMPARISON:END -->
+177
View File
@@ -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
+58
View File
@@ -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.
+340
View File
@@ -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 (Q1Q8) : character depth, plot structure, world building, story impact, originality, thematic cohesion, voice/POV, line-level prose quality.
- Element Integration (Q9AQ9J) : 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.70.9 pour la prose littéraire, 0.40.6 pour la critique/évaluation.
4. **Top-p** : 0.90.95 pour la prose (évite les répétitions à basse température).
5. **Repeat penalty** : 1.11.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)
+91
View File
@@ -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.
+85
View File
@@ -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
+139
View File
@@ -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
+75
View File
@@ -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
+27 -1
View File
@@ -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
+33 -1
View File
@@ -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
+109
View File
@@ -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`
+39
View File
@@ -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
```
+30
View File
@@ -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`
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Fiche Personnages</title>
</head>
<body>
<h1>Template fiches personnages AI Novel Engine</h1>
<h2>Consigne</h2>
<p>Remplir surtout ce qui produit des scenes : role, desir, contradiction, secret, levier de pression, evolution.</p>
<h2>Personnage 1</h2>
<p><strong>Nom :</strong> ...</p>
<p><strong>Role :</strong> ...</p>
<p><strong>Desir :</strong> ...</p>
<p><strong>Contradiction :</strong> ...</p>
<p><strong>Blessure ou manque :</strong> ...</p>
<p><strong>Mensonge personnel :</strong> ...</p>
<p><strong>Secret :</strong> ...</p>
<p><strong>Ce qui le met sous pression :</strong> ...</p>
<p><strong>Voix / presence :</strong> ...</p>
<p><strong>Evolution attendue :</strong> ...</p>
<h2>Personnage 2</h2>
<p><strong>Nom :</strong> ...</p>
<p><strong>Role :</strong> ...</p>
<p><strong>Desir :</strong> ...</p>
<p><strong>Contradiction :</strong> ...</p>
<p><strong>Blessure ou manque :</strong> ...</p>
<p><strong>Mensonge personnel :</strong> ...</p>
<p><strong>Secret :</strong> ...</p>
<p><strong>Ce qui le met sous pression :</strong> ...</p>
<p><strong>Voix / presence :</strong> ...</p>
<p><strong>Evolution attendue :</strong> ...</p>
<h2>Personnage 3</h2>
<p><strong>Nom :</strong> ...</p>
<p><strong>Role :</strong> ...</p>
<p><strong>Desir :</strong> ...</p>
<p><strong>Contradiction :</strong> ...</p>
<p><strong>Blessure ou manque :</strong> ...</p>
<p><strong>Mensonge personnel :</strong> ...</p>
<p><strong>Secret :</strong> ...</p>
<p><strong>Ce qui le met sous pression :</strong> ...</p>
<p><strong>Voix / presence :</strong> ...</p>
<p><strong>Evolution attendue :</strong> ...</p>
<h2>Matrice relationnelle</h2>
<p><strong>Personnage A / Personnage B :</strong> alliance, dette, attraction, rivalite, domination, dette ancienne.</p>
<p><strong>Personnage A / Personnage C :</strong> ...</p>
<p><strong>Personnage B / Personnage C :</strong> ...</p>
<h2>Traduction ANE</h2>
<p><strong>Role :</strong> une phrase nette.</p>
<p><strong>Desire :</strong> quelque chose de concret et actionnable.</p>
<p><strong>Contradiction :</strong> ce qui rend les choix couteux.</p>
</body>
</html>
Binary file not shown.
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Pack Fantasy</title>
</head>
<body>
<h1>Pack fantasy AI Novel Engine</h1>
<h2>1. Base du projet</h2>
<p><strong>Titre :</strong> ...</p>
<p><strong>Sous-genre :</strong> epic, dark, sword and sorcery, romantasy, low fantasy.</p>
<p><strong>Logline :</strong> ...</p>
<p><strong>Promesse d'univers :</strong> ...</p>
<h2>2. Fondations du monde</h2>
<p><strong>Cadre :</strong> royaumes, cites, frontieres, lieux sacrifies.</p>
<p><strong>Regle de magie :</strong> source, cout, interdits, limites.</p>
<p><strong>Ordre politique :</strong> cour, guildes, clerge, empires, factions.</p>
<p><strong>Ressource ou relique decisive :</strong> ...</p>
<p><strong>Catastrophe latente :</strong> ...</p>
<h2>3. Axe dramatique</h2>
<p><strong>Hero ou heroine :</strong> ...</p>
<p><strong>Desir :</strong> ...</p>
<p><strong>Contradiction :</strong> ...</p>
<p><strong>Adversaire :</strong> ...</p>
<p><strong>Prix de la quete :</strong> ...</p>
<h2>4. Factions et personnages</h2>
<p><strong>Protagoniste :</strong> role, desir, contradiction.</p>
<p><strong>Allie majeur :</strong> role, desir, contradiction.</p>
<p><strong>Antagoniste :</strong> role, desir, contradiction.</p>
<p><strong>Faction 1 :</strong> interet, force, faille.</p>
<p><strong>Faction 2 :</strong> interet, force, faille.</p>
<h2>5. Arc du roman</h2>
<p><strong>Appel :</strong> ...</p>
<p><strong>Franchissement :</strong> ...</p>
<p><strong>Epreuves :</strong> ...</p>
<p><strong>Revelation :</strong> ...</p>
<p><strong>Sacrifice :</strong> ...</p>
<p><strong>Climax :</strong> ...</p>
<h2>6. Plan 12 chapitres</h2>
<p><strong>Ch. 01-03 :</strong> monde, faille, premier engagement.</p>
<p><strong>Ch. 04-06 :</strong> voyage, alliances, cout de la magie.</p>
<p><strong>Ch. 07-09 :</strong> revelation, trahison, perte.</p>
<p><strong>Ch. 10-12 :</strong> siege, duel, sacrifice, nouvel ordre.</p>
<h2>7. Garde-fous ANE</h2>
<ul>
<li>Ne pas transformer le worldbuilding en exposition statique.</li>
<li>Faire passer les regles du monde par des choix et des couts visibles.</li>
<li>Chaque chapitre doit modifier une relation, une position ou un risque.</li>
</ul>
</body>
</html>
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Pack Huis Clos</title>
</head>
<body>
<h1>Pack huis clos AI Novel Engine</h1>
<h2>1. Base du projet</h2>
<p><strong>Titre :</strong> ...</p>
<p><strong>Lieu clos :</strong> appartement, train, station, chalet, navire, salle de garde.</p>
<p><strong>Cause du confinement :</strong> tempete, panne, menace, quarantaine, crime, siege.</p>
<p><strong>Logline :</strong> ...</p>
<h2>2. Regles du clos</h2>
<p><strong>Pourquoi personne ne peut partir :</strong> ...</p>
<p><strong>Ce qui manque :</strong> air, temps, confiance, nourriture, courant, preuve.</p>
<p><strong>Ce qui augmente la pression :</strong> ...</p>
<p><strong>Ce qui peut casser le groupe :</strong> ...</p>
<h2>3. Distribution</h2>
<p><strong>Pivot :</strong> role, desir, contradiction.</p>
<p><strong>Source de menace :</strong> role, desir, contradiction.</p>
<p><strong>Allie instable :</strong> role, desir, contradiction.</p>
<p><strong>Temoin ou poids mort :</strong> role, desir, contradiction.</p>
<h2>4. Trajectoire</h2>
<p><strong>Incident :</strong> ...</p>
<p><strong>Premiere fracture du groupe :</strong> ...</p>
<p><strong>Revelation centrale :</strong> ...</p>
<p><strong>Point de non-retour :</strong> ...</p>
<p><strong>Resolution :</strong> ...</p>
<h2>5. Garde-fous ANE</h2>
<ul>
<li>Exploiter l'espace ferme comme machine dramatique.</li>
<li>Chaque scene doit reduire les options ou detruire une confiance.</li>
<li>Ne pas compenser le lieu unique par trop d'explication hors-scene.</li>
</ul>
</body>
</html>
Binary file not shown.
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Pack Idee Roman</title>
</head>
<body>
<h1>Pack personnalisation a partir d'une idee de roman</h1>
<h2>1. Idee brute</h2>
<p><strong>Votre idee en une phrase :</strong> ...</p>
<p><strong>L'image ou la scene d'origine :</strong> ...</p>
<p><strong>Ce qui vous obsede dans cette idee :</strong> ...</p>
<h2>2. Clarification</h2>
<p><strong>Qui porte le recit ?</strong> ...</p>
<p><strong>Que veut ce personnage ?</strong> ...</p>
<p><strong>Pourquoi ne l'obtient-il pas facilement ?</strong> ...</p>
<p><strong>Qu'est-ce qui pourrait etre perdu ?</strong> ...</p>
<p><strong>Quelle promesse de lecture voulez-vous tenir ?</strong> ...</p>
<h2>3. Premiere traduction romanesque</h2>
<p><strong>Logline :</strong> ...</p>
<p><strong>Synopsis court :</strong> ...</p>
<p><strong>Genre ou melange de genres :</strong> ...</p>
<p><strong>Ton :</strong> ...</p>
<h2>4. Personnages minimaux</h2>
<p><strong>Protagoniste :</strong> role, desir, contradiction.</p>
<p><strong>Force adverse :</strong> role, desir, contradiction.</p>
<p><strong>Allie ou miroir :</strong> role, desir, contradiction.</p>
<h2>5. Premiere structure</h2>
<p><strong>Depart :</strong> ...</p>
<p><strong>Fracture :</strong> ...</p>
<p><strong>Escalade :</strong> ...</p>
<p><strong>Point de non-retour :</strong> ...</p>
<p><strong>Fin probable :</strong> ...</p>
<h2>6. Conversion ANE</h2>
<p><strong>Intention chapitre 01 :</strong> 3 a 5 lignes courtes.</p>
<p><strong>Memoire personnages :</strong> role, desir, contradiction.</p>
<p><strong>Premier risque de prose :</strong> ou votre idee risque de rester abstraite.</p>
<p><strong>Premier levier de scene :</strong> quel acte, cout ou consequence montrer d'abord.</p>
</body>
</html>
Binary file not shown.
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Pack Litteraire</title>
</head>
<body>
<h1>Pack roman litteraire AI Novel Engine</h1>
<h2>1. Base du projet</h2>
<p><strong>Titre :</strong> ...</p>
<p><strong>Logline :</strong> ...</p>
<p><strong>Voix :</strong> premiere personne, troisieme proche, chorus, adresse.</p>
<p><strong>Promesse sensible :</strong> ...</p>
<h2>2. Noyau intime</h2>
<p><strong>Protagoniste :</strong> ...</p>
<p><strong>Manque ou fente intime :</strong> ...</p>
<p><strong>Desir visible :</strong> ...</p>
<p><strong>Contradiction :</strong> ...</p>
<p><strong>Transformation visee :</strong> ...</p>
<h2>3. Monde proche</h2>
<p><strong>Lieu principal :</strong> ...</p>
<p><strong>Milieu social :</strong> ...</p>
<p><strong>Objets, gestes, routines :</strong> ...</p>
<p><strong>Friction sourde :</strong> ...</p>
<h2>4. Personnages</h2>
<p><strong>Protagoniste :</strong> role, desir, contradiction.</p>
<p><strong>Autre central :</strong> role, desir, contradiction.</p>
<p><strong>Figure d'ecart :</strong> role, desir, contradiction.</p>
<h2>5. Mouvement du roman</h2>
<p><strong>Etat initial :</strong> ...</p>
<p><strong>Premier deplacement :</strong> ...</p>
<p><strong>Sequence de frottement :</strong> ...</p>
<p><strong>Craquement majeur :</strong> ...</p>
<p><strong>Point de verite :</strong> ...</p>
<p><strong>Retombee ou ouverture finale :</strong> ...</p>
<h2>6. Plan 12 chapitres</h2>
<p><strong>Ch. 01-03 :</strong> installation de la voix, manque, premiere dissonance.</p>
<p><strong>Ch. 04-06 :</strong> rapprochements, deplacements, premiers couts.</p>
<p><strong>Ch. 07-09 :</strong> aveu, retrait, faille visible.</p>
<p><strong>Ch. 10-12 :</strong> point de verite, geste irreparable, nouvel etat.</p>
<h2>7. Garde-fous ANE</h2>
<ul>
<li>Garder une tension concrete meme dans l'infra-ordinaire.</li>
<li>Eviter les chapitres purement meditatives sans bascule observable.</li>
<li>Chaque fin de chapitre doit laisser une trace emotionnelle nette.</li>
</ul>
</body>
</html>
BIN
View File
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Pack Polar</title>
</head>
<body>
<h1>Pack polar / thriller AI Novel Engine</h1>
<h2>1. Base du projet</h2>
<p><strong>Titre :</strong> ...</p>
<p><strong>Sous-genre :</strong> polar procedural, noir, thriller psychologique, huis clos, judiciaire.</p>
<p><strong>Logline :</strong> ...</p>
<p><strong>Crime, disparition ou menace :</strong> ...</p>
<h2>2. Axe dramatique</h2>
<p><strong>Enqueteur ou protagoniste :</strong> ...</p>
<p><strong>Faille intime :</strong> ...</p>
<p><strong>Desir :</strong> resoudre, proteger, prouver, survivre.</p>
<p><strong>Contradiction :</strong> ce qui le rend mauvais au bon moment ou inversement.</p>
<p><strong>Antagonisme :</strong> tueur, systeme, corruption, famille, manipulation.</p>
<h2>3. Dossier affaire</h2>
<p><strong>Fait initial :</strong> ...</p>
<p><strong>Version apparente :</strong> ...</p>
<p><strong>Verite cachee :</strong> ...</p>
<p><strong>Mobile reel :</strong> ...</p>
<p><strong>Point de bascule de l'enquete :</strong> ...</p>
<h2>4. Personnages cle</h2>
<p><strong>Enqueteur :</strong> role, desir, contradiction.</p>
<p><strong>Suspect principal :</strong> role, desir, contradiction.</p>
<p><strong>Allie fragile :</strong> role, desir, contradiction.</p>
<p><strong>Temoin dangereux :</strong> role, desir, contradiction.</p>
<h2>5. Outils polar</h2>
<p><strong>Indices vrais :</strong> ...</p>
<p><strong>Fausses pistes :</strong> ...</p>
<p><strong>Secrets et mensonges :</strong> ...</p>
<p><strong>Revelation finale :</strong> ...</p>
<h2>6. Plan 12 chapitres</h2>
<p><strong>Ch. 01-03 :</strong> crime, premiere hypothese, erreur initiale.</p>
<p><strong>Ch. 04-06 :</strong> escalation, soupcons, revelation partielle.</p>
<p><strong>Ch. 07-09 :</strong> fausse resolution, cout intime, point de non-retour.</p>
<p><strong>Ch. 10-12 :</strong> verite, confrontation, prix final.</p>
<h2>7. Garde-fous ANE</h2>
<ul>
<li>Chaque chapitre doit contenir une action d'enquete ou de pression visible.</li>
<li>Chaque fin de chapitre doit requalifier un soupcon ou aggraver un risque.</li>
<li>Ne pas expliquer le crime trop tot : faire porter la verite par les scenes.</li>
</ul>
</body>
</html>
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Pack Romantasy</title>
</head>
<body>
<h1>Pack romantasy AI Novel Engine</h1>
<h2>1. Base du projet</h2>
<p><strong>Titre :</strong> ...</p>
<p><strong>Promesse romance :</strong> enemies to lovers, forbidden love, arranged bond, rivals, slow burn.</p>
<p><strong>Promesse fantasy :</strong> magie, cour, guerre, quete, malediction.</p>
<p><strong>Logline :</strong> ...</p>
<h2>2. Couple central</h2>
<p><strong>Personnage A :</strong> desir, peur, faille.</p>
<p><strong>Personnage B :</strong> desir, peur, faille.</p>
<p><strong>Tension relationnelle :</strong> attraction, dette, mensonge, statut, clan, devoir.</p>
<p><strong>Interdit :</strong> ce qui rend l'union couteuse ou impossible.</p>
<h2>3. Monde et pouvoir</h2>
<p><strong>Cadre :</strong> royaume, ordre magique, maison, frontiere.</p>
<p><strong>Systeme de pouvoir :</strong> ...</p>
<p><strong>Factions :</strong> ...</p>
<p><strong>Menace externe :</strong> ...</p>
<h2>4. Progression</h2>
<p><strong>Rencontre / collision :</strong> ...</p>
<p><strong>Alliance contrainte :</strong> ...</p>
<p><strong>Rapprochement :</strong> ...</p>
<p><strong>Trahison ou revelation :</strong> ...</p>
<p><strong>Choix final :</strong> amour, pouvoir, sacrifice, renoncement.</p>
<h2>5. Garde-fous ANE</h2>
<ul>
<li>Faire avancer en meme temps la relation et l'intrigue externe.</li>
<li>Chaque chapitre doit produire une intensification emotionnelle ou politique.</li>
<li>Ne pas laisser la tension relationnelle se reduire a des declarations abstraites.</li>
</ul>
</body>
</html>
Binary file not shown.
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Pack Roman FR</title>
</head>
<body>
<h1>Pack roman FR AI Novel Engine</h1>
<h2>1. Pitch</h2>
<p><strong>Titre :</strong> ...</p>
<p><strong>Genre :</strong> ...</p>
<p><strong>Logline :</strong> ...</p>
<p><strong>Synopsis court :</strong> ...</p>
<p><strong>Promesse de lecture :</strong> ...</p>
<h2>2. Fondations</h2>
<p><strong>Protagoniste :</strong> ...</p>
<p><strong>Desir :</strong> ...</p>
<p><strong>Contradiction :</strong> ...</p>
<p><strong>Force adverse :</strong> ...</p>
<p><strong>Enjeu :</strong> ...</p>
<p><strong>Theme :</strong> ...</p>
<h2>3. Personnages</h2>
<p><strong>Personnage 1 :</strong> role, desir, contradiction.</p>
<p><strong>Personnage 2 :</strong> role, desir, contradiction.</p>
<p><strong>Personnage 3 :</strong> role, desir, contradiction.</p>
<p><strong>Relations cle :</strong> ...</p>
<h2>4. Monde et lieux</h2>
<p><strong>Cadre :</strong> ...</p>
<p><strong>Lieux recurrents :</strong> ...</p>
<p><strong>Regles du monde :</strong> ...</p>
<p><strong>Anomalie ou faille :</strong> ...</p>
<h2>5. Trajectoire du roman</h2>
<p><strong>Depart :</strong> ...</p>
<p><strong>Fracture initiale :</strong> ...</p>
<p><strong>Escalade :</strong> ...</p>
<p><strong>Milieu :</strong> ...</p>
<p><strong>Point de non-retour :</strong> ...</p>
<p><strong>Fin :</strong> ...</p>
<h2>6. Plan de 12 chapitres</h2>
<p><strong>Chapitre 01 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 02 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 03 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 04 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 05 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 06 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 07 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 08 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 09 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 10 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 11 :</strong> intention, tension, consequence.</p>
<p><strong>Chapitre 12 :</strong> intention, tension, consequence.</p>
<h2>7. Scene type</h2>
<p><strong>Objectif :</strong> ...</p>
<p><strong>Conflit :</strong> ...</p>
<p><strong>Sortie :</strong> ...</p>
<p><strong>Image finale :</strong> ...</p>
<h2>8. Conversion AI Novel Engine</h2>
<p><strong>Intention de chapitre :</strong> 3 a 5 lignes courtes.</p>
<p><strong>Memoire personnages :</strong> role, desir, contradiction.</p>
<p><strong>Rappel :</strong> ANE prefere les scenes jouees aux resumes de plan dans la prose.</p>
</body>
</html>
BIN
View File
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Pack SF</title>
</head>
<body>
<h1>Pack science-fiction AI Novel Engine</h1>
<h2>1. Base du projet</h2>
<p><strong>Titre :</strong> ...</p>
<p><strong>Sous-genre :</strong> hard SF, space opera, cyberpunk, cli-fi, post-apo, first contact.</p>
<p><strong>Hypothese centrale :</strong> quelle idee speculative change le monde ?</p>
<p><strong>Logline :</strong> ...</p>
<h2>2. Systeme du monde</h2>
<p><strong>Cadre :</strong> station, colonie, megalopole, vaisseau, Terre transformee.</p>
<p><strong>Technologie ou science decisive :</strong> ...</p>
<p><strong>Limites et couts :</strong> ...</p>
<p><strong>Ordre social ou politique :</strong> ...</p>
<p><strong>Ressource rare :</strong> ...</p>
<h2>3. Axe dramatique</h2>
<p><strong>Protagoniste :</strong> ...</p>
<p><strong>Desir :</strong> ...</p>
<p><strong>Contradiction :</strong> ...</p>
<p><strong>Force adverse :</strong> IA, corporation, Etat, ecosysteme, temps, mission.</p>
<p><strong>Prix du progres :</strong> ...</p>
<h2>4. Personnages et camps</h2>
<p><strong>Protagoniste :</strong> role, desir, contradiction.</p>
<p><strong>Allie technique :</strong> role, desir, contradiction.</p>
<p><strong>Contrepoids humain :</strong> role, desir, contradiction.</p>
<p><strong>Adversaire :</strong> role, desir, contradiction.</p>
<h2>5. Mouvement du roman</h2>
<p><strong>Incident :</strong> ...</p>
<p><strong>Escalade :</strong> ...</p>
<p><strong>Revelation scientifique ou politique :</strong> ...</p>
<p><strong>Point de non-retour :</strong> ...</p>
<p><strong>Climax :</strong> ...</p>
<p><strong>Nouvel equilibre :</strong> ...</p>
<h2>6. Garde-fous ANE</h2>
<ul>
<li>Ne pas laisser le concept manger la scene.</li>
<li>Faire porter les regles technologiques par des decisions et des pertes visibles.</li>
<li>Chaque chapitre doit changer soit le savoir, soit la position, soit le risque.</li>
</ul>
</body>
</html>
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Plan 3 Actes</title>
</head>
<body>
<h1>Template plan 3 actes AI Novel Engine</h1>
<h2>1. Base du projet</h2>
<p><strong>Titre :</strong> ...</p>
<p><strong>Logline :</strong> ...</p>
<p><strong>Question dramatique :</strong> ...</p>
<h2>2. Acte I - Installation et premiere fracture</h2>
<p><strong>Monde initial :</strong> ...</p>
<p><strong>Fracture declenchante :</strong> ...</p>
<p><strong>Refus ou hesitation :</strong> ...</p>
<p><strong>Premier seuil :</strong> la decision qui empeche le retour au statu quo.</p>
<h2>3. Acte II - Escalade</h2>
<p><strong>Promesse de l'intrigue :</strong> ...</p>
<p><strong>Complications majeures :</strong> ...</p>
<p><strong>Milieu :</strong> revelation, victoire amere ou renversement.</p>
<p><strong>Nouvelle pression :</strong> ...</p>
<p><strong>Point de non-retour :</strong> ...</p>
<h2>4. Acte III - Resolution</h2>
<p><strong>Crisis :</strong> pire moment, perte ou contradiction maximale.</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Climax :</strong> ...</p>
<p><strong>Consequence finale :</strong> ...</p>
<p><strong>Nouvel equilibre :</strong> ...</p>
<h2>5. Conversion en chapitres</h2>
<p><strong>Acte I :</strong> chapitres 1 a 3</p>
<p><strong>Acte II :</strong> chapitres 4 a 9</p>
<p><strong>Acte III :</strong> chapitres 10 a 12</p>
<h2>6. Garde-fous ANE</h2>
<ul>
<li>Chaque fin de chapitre doit produire une consequence immediate.</li>
<li>Eviter les bascules purement expliquees sans action visible.</li>
<li>Le protagoniste doit payer un prix croissant a chaque acte.</li>
</ul>
</body>
</html>
Binary file not shown.
+209
View File
@@ -0,0 +1,209 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Plan Roman Complet</title>
</head>
<body>
<h1>Template de plan roman complet AI Novel Engine</h1>
<h2>1. Identite du projet</h2>
<p><strong>Titre de travail :</strong> ...</p>
<p><strong>Genre :</strong> ...</p>
<p><strong>Logline :</strong> ...</p>
<p><strong>Promesse de lecture :</strong> ...</p>
<p><strong>Point de vue / personne :</strong> ...</p>
<p><strong>Ton / registre :</strong> ...</p>
<p><strong>Objectif mots :</strong> ...</p>
<h2>2. Noyau dramatique</h2>
<p><strong>Protagoniste :</strong> ...</p>
<p><strong>Desir central :</strong> ...</p>
<p><strong>Besoin profond :</strong> ...</p>
<p><strong>Contradiction :</strong> ...</p>
<p><strong>Force adverse :</strong> ...</p>
<p><strong>Enjeu externe :</strong> ...</p>
<p><strong>Enjeu intime :</strong> ...</p>
<p><strong>Question dramatique :</strong> ...</p>
<h2>3. Monde, regles, contraintes</h2>
<p><strong>Cadre :</strong> ...</p>
<p><strong>Regles du monde :</strong> ...</p>
<p><strong>Secret ou anomalie structurante :</strong> ...</p>
<p><strong>Interdits et risques visibles :</strong> ...</p>
<h2>4. Fiches personnages</h2>
<h3>Protagoniste</h3>
<ul>
<li><strong>Nom :</strong> ...</li>
<li><strong>Role :</strong> ...</li>
<li><strong>Desire :</strong> ...</li>
<li><strong>Contradiction :</strong> ...</li>
<li><strong>Blessure :</strong> ...</li>
<li><strong>Mensonge personnel :</strong> ...</li>
<li><strong>Evolution attendue :</strong> ...</li>
</ul>
<h3>Antagoniste ou force adverse</h3>
<ul>
<li><strong>Nom :</strong> ...</li>
<li><strong>Role :</strong> ...</li>
<li><strong>Desire :</strong> ...</li>
<li><strong>Contradiction :</strong> ...</li>
<li><strong>Moyens de pression :</strong> ...</li>
</ul>
<h3>Allie majeur</h3>
<ul>
<li><strong>Nom :</strong> ...</li>
<li><strong>Role :</strong> ...</li>
<li><strong>Desire :</strong> ...</li>
<li><strong>Contradiction :</strong> ...</li>
<li><strong>Ce qu'il cache :</strong> ...</li>
</ul>
<h3>Personnage de pression</h3>
<ul>
<li><strong>Nom :</strong> ...</li>
<li><strong>Role :</strong> ...</li>
<li><strong>Desire :</strong> ...</li>
<li><strong>Contradiction :</strong> ...</li>
<li><strong>Comment il complique les scenes :</strong> ...</li>
</ul>
<h2>5. Arc global du roman</h2>
<p><strong>Depart :</strong> ...</p>
<p><strong>Fracture initiale :</strong> ...</p>
<p><strong>Premiere bascule :</strong> ...</p>
<p><strong>Milieu / revelation :</strong> ...</p>
<p><strong>Point de non-retour :</strong> ...</p>
<p><strong>Crisis :</strong> ...</p>
<p><strong>Climax :</strong> ...</p>
<p><strong>Nouvel equilibre :</strong> ...</p>
<h2>6. Plan par actes</h2>
<h3>Acte I - Mise en tension</h3>
<p><strong>Fonction :</strong> installer le monde, la fracture et la premiere decision.</p>
<p><strong>Chapitres couverts :</strong> 1 a 3</p>
<h3>Acte II - Escalade</h3>
<p><strong>Fonction :</strong> multiplier les couts, erreurs, revelations, alliances et ruptures.</p>
<p><strong>Chapitres couverts :</strong> 4 a 9</p>
<h3>Acte III - Resolution</h3>
<p><strong>Fonction :</strong> mener au point de non-retour, au climax et au prix final.</p>
<p><strong>Chapitres couverts :</strong> 10 a 12</p>
<h2>7. Plan chapitre par chapitre</h2>
<h3>Chapitre 01</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 02</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 03</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 04</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 05</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 06</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 07</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 08</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 09</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 10</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 11</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h3>Chapitre 12</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<p><strong>Decision finale :</strong> ...</p>
<p><strong>Consequence immediate :</strong> ...</p>
<h2>8. Carte de scene reutilisable</h2>
<h3>Scene type</h3>
<ul>
<li><strong>Objectif :</strong> ...</li>
<li><strong>Conflit :</strong> ...</li>
<li><strong>Sortie :</strong> ...</li>
<li><strong>Lieu :</strong> ...</li>
<li><strong>Personnages presents :</strong> ...</li>
<li><strong>Image dominante :</strong> ...</li>
<li><strong>Cout ou risque visible :</strong> ...</li>
</ul>
<h2>9. Traduction vers AI Novel Engine</h2>
<p><strong>Intention ANE type :</strong> 3 a 5 lignes courtes, concretes, une par impulsion narrative.</p>
<p><strong>Memoire personnages :</strong> conserver pour chacun un role, un desir et une contradiction exploitables.</p>
<p><strong>Garde-fous :</strong> chaque chapitre doit finir sur un effet observable, pas sur une annonce abstraite.</p>
<h2>10. Checklist avant generation</h2>
<ul>
<li>Chaque personnage majeur a un desir et une contradiction utilisables en scene.</li>
<li>Chaque chapitre a une intention, une tension et une consequence finale.</li>
<li>Le plan montre des actions et des couts, pas seulement des idees.</li>
<li>Le protagoniste prend des decisions de plus en plus irreversibles.</li>
<li>Le climax regle la question dramatique posee au depart.</li>
</ul>
</body>
</html>
Binary file not shown.
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Plan Template</title>
</head>
<body>
<h1>Template de plan AI Novel Engine</h1>
<h2>1. Identite du projet</h2>
<p><strong>Titre de travail :</strong> ...</p>
<p><strong>Genre :</strong> ...</p>
<p><strong>Logline :</strong> ...</p>
<p><strong>Promesse de lecture :</strong> ...</p>
<h2>2. Noyau dramatique</h2>
<p><strong>Protagoniste :</strong> nom + position dans le monde.</p>
<p><strong>Desir :</strong> ce qu'il veut concretement.</p>
<p><strong>Contradiction :</strong> ce qui rend ses choix couteux.</p>
<p><strong>Force adverse :</strong> personne, systeme, secret, dette, menace.</p>
<p><strong>Prix du conflit :</strong> ce qui peut etre perdu si le personnage echoue.</p>
<h2>3. Personnages cle</h2>
<h3>Personnage 1</h3>
<ul>
<li><strong>Role :</strong> ...</li>
<li><strong>Desire :</strong> ...</li>
<li><strong>Contradiction :</strong> ...</li>
<li><strong>Lien au protagoniste :</strong> ...</li>
</ul>
<h3>Personnage 2</h3>
<ul>
<li><strong>Role :</strong> ...</li>
<li><strong>Desire :</strong> ...</li>
<li><strong>Contradiction :</strong> ...</li>
<li><strong>Lien au protagoniste :</strong> ...</li>
</ul>
<h2>4. Arc global</h2>
<p><strong>Depart :</strong> etat initial du personnage et du monde.</p>
<p><strong>Fracture :</strong> evenement qui rend l'ancien equilibre impossible.</p>
<p><strong>Escalade :</strong> serie de couts, revelations, erreurs ou retours de force.</p>
<p><strong>Point de non-retour :</strong> acte qui empeche le retour en arriere.</p>
<p><strong>Fin visee :</strong> resolution et transformation attendue.</p>
<h2>5. Plan de chapitre</h2>
<h3>Chapitre 01</h3>
<p><strong>Intention :</strong> ...</p>
<p><strong>Objectif dramatique :</strong> ...</p>
<p><strong>Tension :</strong> ...</p>
<h4>Scene 1</h4>
<ul>
<li><strong>Objectif :</strong> ...</li>
<li><strong>Conflit :</strong> ...</li>
<li><strong>Sortie :</strong> ...</li>
</ul>
<h4>Scene 2</h4>
<ul>
<li><strong>Objectif :</strong> ...</li>
<li><strong>Conflit :</strong> ...</li>
<li><strong>Sortie :</strong> ...</li>
</ul>
<h4>Scene 3</h4>
<ul>
<li><strong>Objectif :</strong> ...</li>
<li><strong>Conflit :</strong> ...</li>
<li><strong>Sortie :</strong> decision finale + consequence immediate.</li>
</ul>
<h2>6. Garde-fous ANE</h2>
<ul>
<li>Eviter les plans purement abstraits et les scenes sans action observable.</li>
<li>Finir les scenes sur une consequence nette, pas sur une annonce vague.</li>
<li>Donner a chaque personnage un desir et une contradiction utilisables en scene.</li>
<li>Ne pas ecrire la prose finale ici : garder ce document au niveau planifiable.</li>
</ul>
</body>
</html>
+90
View File
@@ -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
```
+43
View File
@@ -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._
@@ -0,0 +1,7 @@
[
{
"event": "Premier signe de fracture observe par Elise.",
"order_hint": "aube",
"chapter": "chapitre_01"
}
]
@@ -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"
]
}
}
@@ -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"
]
}
}
@@ -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.
+12
View File
@@ -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:
+24 -1
View File
@@ -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
+19 -1
View File
@@ -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:
+8 -2
View File
@@ -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
+46
View File
@@ -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"]
}
+44
View File
@@ -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"]
}
+28 -2
View File
@@ -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:
+27 -1
View File
@@ -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:
+123
View File
@@ -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
+160
View File
@@ -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())
+143
View File
@@ -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())
+314
View File
@@ -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())

Some files were not shown because too many files have changed in this diff Show More