Files
pf1e-simulator/docs/architecture/phase-2-llm-architecture.md
ctan f61fc5c61f docs: reorganize documentation structure
- Clean README.md with installation, usage, CLI, examples
- Move data formats to docs/data-formats/ (map.md, monster.md, pc-sheets.md, index.md)
- Move architecture to docs/architecture/ (index.md, technical-architecture.md, phase-2-llm-architecture.md)
- Add contributing guidelines in docs/contributing.md
- Clean README.md: install, usage, CLI, data-formats/rules/architecture links, contributing link
2026-09-26 20:12:05 +02:00

183 lines
7.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` + `<name>.md` + `<name>.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)