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
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Architecture & Plan d'implémentation
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
- [Architecture technique](technical-architecture.md) — modules, flux de données, interfaces
|
||||
- [Plan Phase 2 (LLM)](phase-2-llm-architecture.md) — étapes P5.1–P5.3, décisions, risques
|
||||
- [Conformité SRD](conformance-srd.md) — écarts documentés, corrigés, hors périmètre
|
||||
- [Feuille de route](roadmap.md) — Phases 0, 1, 2, 3 et au-delà
|
||||
|
||||
## Structure du code
|
||||
|
||||
```
|
||||
src/pf1e_simulator/
|
||||
├── combat.py # CombatEngine, résolution attaques, actions, states
|
||||
├── dice.py # parseur notation dés (2d4+1)
|
||||
├── rng.py # SeededRng, ScriptedRng
|
||||
├── models.py # schémas Pydantic v2 (Combatant, AttackSpec, etc.)
|
||||
├── map.py # MapSpec, chargement YAML, validation
|
||||
├── grid.py # grille 5-10-5, Dijkstra, airborne_view
|
||||
├── los.py # ligne d'effet, couvert (règle des coins)
|
||||
├── areas.py # cône, ligne, burst (géométrie zones)
|
||||
├── effects.py # StatModifier, resolve_modifiers (empilement bonus)
|
||||
├── conditions.py # 29 conditions PF1e + flags comportementaux
|
||||
├── abilities.py # dons (Weapon Focus, Power Attack, etc.)
|
||||
├── spells.py # SpellSpec, effets, scalage, métamagie
|
||||
├── metrics.py # win_rate, win_rate_sigma, win_rate_band
|
||||
├── runner.py # EncounterSpec, run_encounter, EncounterReport
|
||||
├── objectives.py # primitives Phase 2 (P(survie), P(TPK), etc.)
|
||||
├── phase2_spike.py # politique "focus fire", danger cartography
|
||||
├── cli.py # argparse, rapport FR, flags LLM
|
||||
├── loaders/
|
||||
│ ├── combatant.py # chargeur PJ + monstres + dons/sorts
|
||||
│ └── foundry.py # migration Foundry (one-shot, incomplet)
|
||||
└── report.py # LLM report writer (Phase 3)
|
||||
```
|
||||
|
||||
## Décisions clés (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/campagne, ≤ 20 combats vitrine) + coupe-circuit |
|
||||
| D6 | Reproductibilité | Cache obligatoire ; clé composite incluant le modèle ; replay `call_seq` |
|
||||
| D7 | Prompts | Externes / dépôt séparé (voir §14) |
|
||||
|
||||
## Gate de validation
|
||||
|
||||
```bash
|
||||
uv run pytest -q
|
||||
uv run ruff check src tests
|
||||
uv run basedpyright src
|
||||
```
|
||||
|
||||
- Ruff : `select = ["ALL"]`, longueur ligne 100
|
||||
- Basedpyright : `typeCheckingMode = "strict"` sur `src` et `tests`
|
||||
- Transcripts épinglés + assertions statistiques (matchup symétrique dans bande 3σ)
|
||||
|
||||
## Feuille de route
|
||||
|
||||
- **Phase 0** ✅ — socle combat (initiative, attaques, critiques, RD, états, déplacement, mêlée/distance)
|
||||
- **Phase 1** ✅ — magie/états (108 sorts, DD/sauvegardes, concentration, invocation, conditions, manœuvres, dons, capacités de classe)
|
||||
- **Phase 2** ✅ — couche tactique LLM (few-shot, réparation bornée, contrat Observation/LLMPolicy)
|
||||
- **Phase 3** ✅ — rapporteur LLM local (agrégation stats + rédaction FR, `report.py`, `--llm-report`)
|
||||
- **Phase 4** (futur) — optimiseur de scénarios, campagnes multi-rencontres
|
||||
@@ -0,0 +1,183 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,64 @@
|
||||
# Architecture technique
|
||||
|
||||
## Modules principaux
|
||||
|
||||
| Module | Responsabilité |
|
||||
|--------|----------------|
|
||||
| `combat.py` | `CombatEngine` : initiative, actions, résolution attaques, états de vie, incantation, invocation, manœuvres, transcript, politique par défaut, `Observation`/`LLMPolicy` |
|
||||
| `dice.py` | Parseur notation dés (`2d4+1`) + lancer |
|
||||
| `rng.py` | `SeededRng` (flux reproductible par graine), `ScriptedRng` (tests) |
|
||||
| `models.py` | Schémas Pydantic v2 : `Combatant`, `AbilityScores`, `ACProfile`, `AttackSpec`, `DamageComponent`, `Saves`, `DamageReduction` |
|
||||
| `map.py` | `MapSpec`/`TerrainType`, chargement YAML, validation (dimensions, chars, zones, déploiement, markers) |
|
||||
| `grid.py` | Grille 5-10-5 : `distance`, `step_cost`, `diagonal_allowed`, Dijkstra `reachable`, `airborne_view` |
|
||||
| `los.py` | Ligne d'effet, couvert par règle des coins : gate dans `weapon_for`, bonus dans `resolve_attack` |
|
||||
| `areas.py` | Géométrie gabarits zone : `cone_cells` (quart cercle 90°), `line_cells` (Bresenham 5ft), bursts via `_area_creatures` |
|
||||
| `combat.py` | `CombatEngine` déterministe : initiative, actions, résolution attaques, tailles/allonges, high ground, actions immédiates opt-in, états de vie, incantation, invocation, manœuvres, capacités de classe, transcripts, politique par défaut, `Observation`/`LLMPolicy` |
|
||||
| `effects.py` | `StatModifier` + `resolve_modifiers` (empilement PF1e : dodge/racial/trait/sans-type s'additionnent ; moral/sacré/profane/enhancement gardent le max) |
|
||||
| `conditions.py` | 29 conditions PF1e : `Condition` (modificateurs + flags comportementaux + `miss_chance`) |
|
||||
| `abilities.py` | Dons : `AbilitySpec` + fabriques (Weapon Focus, Toughness, Power Attack actif −X/+2X, X=BAB//4+1) |
|
||||
| `spells.py` | `SpellSpec` (niveau, école, portée, save, DC, `requires_ranged_touch`, `harmless`, `cast_time`, `components`) + effets (`DamageEffect`, `ConditionEffect`, `HealEffect`, `BuffEffect`, `AreaEffect`, `OneShotEffect`) + scalage (`scaling` dés, `scaling_flat` bonus plat) |
|
||||
| `metrics.py` | `win_rate`, `win_rate_sigma`, `win_rate_band` (bande 3σ bornée [0,1]) |
|
||||
| `runner.py` | `EncounterSpec`/`Side`, `build_states` (placement + désambiguïsation ids), `run_encounter` (1 `SeededRng`/run), `EncounterReport` + attrition |
|
||||
| `objectives.py` | Primitives Phase 2 : P(survie), P(TPK), P(≥1 mort), PV perdus, rounds, dcile PV restants |
|
||||
| `phase2_spike.py` | Politique "focus fire" + cartographie danger |
|
||||
| `cli.py` | `argparse` `pf1e-sim`, rapport FR, codes sortie, flags `--llm-report`/`--output` |
|
||||
| `loaders/combatant.py` | Chargeur unifié PJ + monstres : résolution dons → `AbilitySpec`, sorts → `Combatant.spells` |
|
||||
| `loaders/foundry.py` | Migration Foundry one-shot (incomplet — ne pas régénérer `data/pcs/*.json`) |
|
||||
| `report.py` | LLM report writer P5.3 : `ReportContext`, `LLMReportPolicy`, `ReportBudget`, `format_llm_report` |
|
||||
|
||||
## Flux de données principal
|
||||
|
||||
```
|
||||
CLI (args) → _build_spec() → EncounterSpec
|
||||
↓
|
||||
load_spell_registry() → spell_registry
|
||||
↓
|
||||
run_encounter(spec, runs, seed, spell_registry, ...)
|
||||
↓
|
||||
Pour chaque run: SeededRng(seed+i) → CombatEngine → CombatResult
|
||||
↓
|
||||
Agrégation → EncounterReport (wins, draws, avg_rounds, attrition)
|
||||
↓
|
||||
format_report() → sortie FR
|
||||
```
|
||||
|
||||
## Frontière LLM (Phase 2/3)
|
||||
|
||||
```
|
||||
CombatEngine(llm_policy=LLMPolicy) → avant chaque appel politique:
|
||||
Observation snapshot (to_dict() → JSON isolé)
|
||||
LLMPolicy(observation) → tuple[Action, ...]
|
||||
```
|
||||
|
||||
- LLM ne reçoit **que** le snapshot, jamais `engine.*`
|
||||
- `PromptPack` externe (`PF1E_PROMPT_PACK_DIR`) : `version.txt` + `<name>.md` + `<name>.examples.json`
|
||||
- `prompt_version = version:name:hash12` (clé cache composite D6)
|
||||
- Cache `ResponseCache` (clé = modèle|params|prompt_version|schema|prompt_hash)
|
||||
- Budget `CostBudget` + `CircuitBreaker` + retry/backoff
|
||||
|
||||
## Tests & qualité
|
||||
|
||||
- Transcripts épinglés (combats 1v1/2v2 scriptés via `ScriptedRng`)
|
||||
- Assertions statistiques forme fermée (matchup symétrique dans bande 3σ)
|
||||
- Gate : `uv run pytest -q && uv run ruff check src tests && uv run basedpyright src`
|
||||
- Ruff `select = ["ALL"]`, ligne 100 ; basedpyright strict sur `src` + `tests`
|
||||
Reference in New Issue
Block a user