# Architecture Phase 2 — Couche tactique LLM > Document consolidé depuis `phase-2-llm-architecture.md` (historique complet conservé dans l'original à la racine de `docs/`) --- ## 1. Vision générale La Phase 2 ajoute une **couche tactique LLM** au simulateur déterministe Phase 0/1 : - **P5.1** ✅ Fournisseur réel (`ProviderClient`) — client OpenAI-compatible HTTP, cache, budget, observabilité - **P5.2** ✅ Few-shot + réparation bornée — prompts externes versionnés, boucle `max_repairs` tracée par `NLPolicyStats` - **P5.3** ✅ Rapporteur LLM local — agrégation stats + rédaction FR (`report.py`, `--llm-report`) Le contrat d'observation (`Observation` + wrapper `LLMPolicy`) est en place ; la couche LLM elle-même est désormais implémentée. --- ## 2. Contraintes & décisions arbitrées (D-table) | ID | Domaine | Décision | |----|---------|----------| | D1 | Économie d'action | Résolue par deux étages (budget général + filtres spécialisés) | | D2 | Cartographie danger | Non exhaustive : région d'échec + frontière + sensibilité | | D3 | Priorité v1 | Cartographie d'abord, puis optimiseur (mêmes briques) | | D4 | Fournisseur/modèle | Client agnostique ; implémentation distante d'abord, locale ensuite ; **mode JSON/schéma requis** | | D5 | Budget | **Plafonds petits** (≤ 50 appels de compilation/campagne, ≤ 20 combats en vitrine) + coupe-circuit dans le client | | D6 | Reproductibilité | **Cache obligatoire** ; clé composite incluant le modèle ; replay `call_seq` | | D7 | Prompts | **Externes / dépôt séparé** (voir §14) | --- ## 3. P5.1 — ProviderClient (fournisseur réel) **Fichier** : `src/pf1e_simulator/provider.py` — `OpenAILLMClient` - Speaks OpenAI chat-completions wire format over HTTPS (stdlib `urllib.request`) - Base URL, model, API key résolus depuis l'env (`config.py` : `PF1E_LLM_API_KEY`, `PF1E_LLM_BASE_URL`, `PF1E_LLM_MODEL`) - Aucune dépendance HTTP externe - Error taxonomy : `ProviderError` (transient 429/5xx), `ProviderRejectedError` (4xx permanent), `ProviderResponseError` (2xx body invalide) ### Safeguards intégrés (dans `LLMClient` base class) - **Cache** : `ResponseCache` (clé composite = modèle|params|prompt_version|schema|prompt_hash), sharded, thread-safe - **Budget** : `CostBudget` (max_calls, max_total_tokens, max_cost_usd) — appliqué avant chaque appel - **Retry/Backoff** : `retries` (défaut 2), `backoff_base_s` (0.25s), exponentiel - **Circuit Breaker** : `CircuitBreaker` (seuil échecs consécutifs, probes après refus) ### Interface ```python class LLMClient(ABC): def __init__(self, *, model: str, prompt_version: str, ...) def complete(self, prompt: str, params: ClientParams | None = None) -> str ``` `prompt_version` = `version:name:hash12` (pack version + nom prompt + hash contenu) — requis pour validation cache D6. --- ## 4. P5.2 — Few-shot + réparation bornée ### PromptPack externe (§14) - Répertoire pointé par `PF1E_PROMPT_PACK_DIR` (hors repo) - Layout : `version.txt` + `.md` + `.examples.json` (optionnel) - `PromptPack.load(root)` : strict, pas de fallback, lève `PromptPackError` si invalide - `prompt_version = version:name:hash12` (hash couvre prompt + exemples) ### NLPolicy ```python @dataclass(frozen=True) class NLPolicy: client: LLMClient builder: PromptBuilder prompt_name: str = "nl_default" fallback: Fallback = fallback_default max_repairs: int = 1 stats: NLPolicyStats = field(default_factory=NLPolicyStats) def __call__(self, observation: Observation) -> tuple[Action, ...]: # 1. build prompt via PromptBuilder # 2. client.complete(prompt) # 3. parse_actions() → tuple[Action] # 4. si échec → retry jusqu'à max_repairs (injecte erreur + instruction) # 5. fallback scripté si épuisé ``` - `NLPolicyStats` : `calls`, `repairs_attempted`, `repairs_succeeded`, `fallbacks` — « matière des rapports de P5.3 » - `FakeLLMClient` pour tests 100% offline (script de réponses, zéro réseau) --- ## 5. P5.3 — Rapporteur LLM local (Phase 3) **Module** : `src/pf1e_simulator/report.py` ### Structures - `ReportContext` : agrège `EncounterReport`, `DangerMetrics`, `NLPolicyStats`, `ClientStats`, `MatchupBenchmark`, `DangerCell`, `SensitivityEntry` - `build_report_context()` : fonction pure, calcule `DangerMetrics` depuis `RunOutcome` si fourni - `build_report_prompt(ctx, template)` : rend rapport factuel + danger + robustesse + observabilité LLM - `LLMReportPolicy` : distinct de `NLPolicy` (sortie texte libre, retry simple, `ReportStats`) - `ReportBudget` : plafonds vitrine (`max_calls=1`, ajusté pour retries via `for_retries()`) - `format_llm_report(ctx, llm_text)` : préfixe factuel + `=== Récit d'équilibrage (LLM) ===` + narratif ### CLI - `--llm-report` : active mode LLM (exclusif avec `--transcript`) - `--output FILE.md` : écrit rapport complet (factuel + LLM) dans fichier - `--llm-model`, `--llm-base-url`, `--llm-api-key` : overrides CLI - Exige `PF1E_LLM_API_KEY` + `PF1E_PROMPT_PACK_DIR` env ### Pack FR - `tests/fixtures/prompt_pack_fs/report.md` : narrateur FR, structure 4 sections (Bilan, Danger, Robustesse, Observabilité) - `report.examples.json` : 2 exemples few-shot - Découvert via `PromptPack` comme `nl_default` --- ## 6. Risques & mitigations | Risque | Impact | Mitigation | |--------|--------|------------| | Bandes 3σ trompeuses à petit n | Mauvaises conclusions quantitatives | Étage A pour le quantitatif (formule fermée) | | Événements rares (TPK) | Monte Carlo naïf inefficace | Ciblage par scénarios, cartographie danger | | Explosion combinatoire | Énumération impossible | Coarse-to-fine (danger_map, frontier, sensitivity) | | Prompt trop long | Coût/tokens élevés | Vue compacte ~4k tokens, budget ≤ 50 appels | | Hallucination chiffres | Rapport non fiable | Injection stats verbatim, pas d'invention | | Tests flaky (texte libre) | CI instable | `FakeLLMClient` scripté + assertions structure | --- ## 7. Intégration CLI (P5.3) ```bash # Variables d'env requises export PF1E_LLM_API_KEY=sk-... export PF1E_PROMPT_PACK_DIR=/chemin/vers/prompt-pack # Rapport LLM + sortie fichier uv run pf1e-sim \ --map data/maps/sample_arena.yaml \ --side players data/monsters/goblin.json \ --side monsters data/monsters/orc.json \ --runs 100 --seed 1 \ --llm-report --output rapport.md ``` Options ajoutées : - `--llm-report` : active LLM (exclusif `--transcript`) - `--output FILE.md` : écrit rapport complet (factuel + LLM) - `--llm-model`, `--llm-base-url`, `--llm-api-key` : overrides --- ## 8. Tests & Gate ### Nouveaux tests (27) - `test_report_context.py` (4) — `ReportContext` + `build_report_context` - `test_report_prompt_pack.py` (3) — prompt pack version/render - `test_llm_report_policy.py` (4) — `LLMReportPolicy` + retry/fallback - `test_format_llm_report.py` (4) — `format_llm_report` empty/with narrative - `test_cli_llm_report.py` (4) — flags mutuellement exclusifs, env req - `test_report_budget.py` (8) — `ReportBudget`, `CostBudget`, retries ### Gate complète ```bash uv run pytest -q uv run ruff check src tests uv run basedpyright src ``` --- ## 9. Liens transverses - [Architecture technique](../architecture/technical-architecture.md) - [Conformité SRD](../conformance-srd.md) - [Formats de données](../data-formats/index.md) - [Règles Phase 0](../rules/phase-0-implemented.md)