diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index 6077c3b..2b551ca 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -43,10 +43,26 @@ jobs:
- name: Contrôle docs
run: bash ci/check_docs.sh
+ # --------------------------------------------------------------------------
+ # Publiciste (Sprint 2) : parser faisabilité → projets_master.json + generator.
+ # Tests unitaires stdlib pur (unittest) → aucune installation pip requise sur
+ # le runner. L'oracle jsonschema est utilisé s'il est présent, sinon ignoré.
+ # --------------------------------------------------------------------------
+ publiciste-tests:
+ name: Publiciste · parser + schéma + generator (unittest)
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: 05_deliverables_mvp/publiciste
+ steps:
+ - uses: actions/checkout@v4
+ - name: Tests unitaires Publiciste
+ run: python3 -m unittest discover -s tests -v
+
gate:
name: Gate qualité (agrégat)
runs-on: ubuntu-latest
- needs: [constraints-guard, validate-json, check-docs]
+ needs: [constraints-guard, validate-json, check-docs, publiciste-tests]
steps:
- name: Résultat
run: echo "✅ Gate qualité 4Big franchi — tous les checks verts."
diff --git a/05_deliverables_mvp/daily_reports/2026-07-30.md b/05_deliverables_mvp/daily_reports/2026-07-30.md
index 348fe95..5f2ede3 100644
--- a/05_deliverables_mvp/daily_reports/2026-07-30.md
+++ b/05_deliverables_mvp/daily_reports/2026-07-30.md
@@ -91,3 +91,51 @@ Rapport écrit dans `05_deliverables_mvp/daily_reports/` (chemin canonique fixé
---
**Auto-score 4Big du livrable Baseline QA : 95/100.** Réserve −5 : exécution serveur différée (runner + `DTP_BASE_URL` VPS, hors périmètre) ; specs validées statiquement en-repo (JSON + YAML + gate vert).
+
+---
+
+# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 3)
+
+**Session** : `20260730_012632`
+
+## Tâche exécutée
+**Sprint 2 · Livrable Publiciste « Setup base + parser faisabilité → JSON »**
+(roadmap §Sprint 2 · AGENT.md Publiciste §Livrable Sprint · Semaine 2 · suggérée par session 2).
+
+## Contexte / analyse
+- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, `GAP_ANALYSIS_SPRINT1.md`, daily reports sessions 1-2, `AGENT.md` Publiciste, template canonique v1.0 + les 2 schémas (`projets_master.schema.json`, `version.schema.json`), `ci.yml`, `guard_constraints.sh`.
+- **Sprint 1 bouclé côté repo** ; les 2 critères restants (cartographie DocTypes, runner/endpoints) touchent le VPS → hors périmètre worker.
+- Priorité évidente : **Publiciste** — seul module net-neuf (`GAP_ANALYSIS §3.13`), chemin critique, démarre S2, contrat de données déjà défini au S1. 100 % autorable en-repo (parser + validateur + generator, zéro API externe, zéro VPS).
+
+## Réalisé — module `05_deliverables_mvp/publiciste/` (cible portage : `otoia/capabilities/publiciste.py`)
+- `lib/parser.py` — **cœur du sprint** : `data_room/PXX/` (template v1.0) → dict projet conforme au schéma. Mapping colonnes **par en-tête** (résilient), parsing robuste des montants (USD/DOP, séparateurs FR/US), extraction localisation/services/positionnement FR/rendus.
+- **Anti-invention #6 (défensif)** : statut dérivé de `_META/version.json` ; **rétrogradation** en « en_developpement » si un prix USD manque (jamais publier de prix douteux) ; cellule vide/`{{…}}`/« non défini » → `null` (jamais `0`).
+- `lib/validator.py` — validateur **JSON-Schema draft-07 (sous-ensemble)** **zéro dépendance pip** (le runner Gitea n'a pas `pip`). Couvre type/enum/pattern/required/additionalProperties/items/allOf/if-then/const/min-max… Oracle `jsonschema` utilisé en test **s'il est présent**.
+- `lib/generator.py` + `templates/site_public.html.tmpl` + `lib/branding.py` — rendu HTML **luxury #4** (dark `#0a0a12` + doré `#f0b429`, Fraunces + Cormorant Garamond). Projet sans prix → « Prochainement · Détails à venir » (aucun prix inventé).
+- `publiciste.py` — orchestrateur CLI : `parse` / `validate` / `generate` / `run`.
+- `fixtures/` — données **synthétiques** de test (P01 complète, P02 incomplète) clairement marquées « ne jamais publier » (respect #6).
+- `tests/test_publiciste.py` — **23 tests `unittest`** (stdlib pur) : parsing, extraction, rétrogradation défensive, conformité schéma (maison + oracle), rendu marque + zéro prix inventé.
+- **CI** : job `publiciste-tests` ajouté au **gate** de `.gitea/workflows/ci.yml` (Gitea Actions uniquement · #2) → `unittest` sans installation pip.
+- `README.md` module + `.gitignore` (artefacts `build/`).
+
+## Vérifications effectuées (en-repo, sans toucher au VPS)
+- **23/23 tests `unittest` verts.**
+- Pipeline CLI complet sur fixtures : `run` → `projets_master.json` **conforme au schéma** + `index.html` (2 projets ; P01 « disponible » avec prix, P02 « en développement » sans prix).
+- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh` (inclut les 2 `version.json` fixtures), `check_docs.sh` (0 lien cassé ; ⚠ 4Big = SOFT sur fixtures uniquement).
+- Aucun terme interdit introduit (guard #2/#3/#10 vert).
+
+## Non fait (hors périmètre worker · touche au VPS ou calendrier ultérieur)
+- Exécution contre les **données réelles** `data_room/` (VPS · ERPNext/Faisabilité).
+- Triggers systemd `otoia-publiciste.timer` + watch inotify (Semaine 4 · VPS).
+- Notifications WhatsApp Michel (Semaine 5 · VPS).
+- Génération copy **EN/ES** automatique (Semaine 6 ; seul le FR est extrait aujourd'hui).
+- DocType Frappe `Publiciste Log` (VPS · ERPNext).
+
+## Prochaine tâche suggérée
+- Publiciste S3 : brancher le generator sur un layout proche de `/waf-home` (Frontend) + intégrer les 6 vues/projet (dépend Rendu S3).
+- Ou ERPNext S2 : spec **RBAC 50 rôles** (autorable en-repo comme document de conception).
+- Ou Faisabilité S2 : générateur 4 volets auto consommant le template v1.0 (produit les `data_room/PXX/` que le parser Publiciste consomme).
+
+---
+
+**Auto-score 4Big du livrable Publiciste (parser) : 95/100.** Réserve −5 : exécution contre `data_room/` réel différée (VPS) ; triggers/notifications/EN-ES = livrables Semaines 4-6 ; validé statiquement en-repo (23 tests verts + gate CI vert + schéma conforme).
diff --git a/05_deliverables_mvp/publiciste/.gitignore b/05_deliverables_mvp/publiciste/.gitignore
new file mode 100644
index 0000000..7c1692b
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/.gitignore
@@ -0,0 +1,4 @@
+# Artefacts générés par le pipeline (jamais commit — reproductibles).
+build/
+__pycache__/
+*.pyc
diff --git a/05_deliverables_mvp/publiciste/README.md b/05_deliverables_mvp/publiciste/README.md
new file mode 100644
index 0000000..0e3bc0f
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/README.md
@@ -0,0 +1,83 @@
+# Publiciste Agent — scaffold (Sprint 2)
+
+Maintient automatiquement le site public `vente.otov7.com` **à partir des
+faisabilités canoniques** (`data_room/PXX/`, template v1.0), sans édition manuelle.
+Cible de portage sur le VPS : `otoia/capabilities/publiciste.py`
+(voir [`../../03_agents/publiciste/AGENT.md`](../../03_agents/publiciste/AGENT.md)
+et [`../GAP_ANALYSIS_SPRINT1.md`](../GAP_ANALYSIS_SPRINT1.md) §3.13 — seul module
+net-neuf, chemin critique).
+
+## Pipeline
+
+```
+data_room/PXX/ ──► lib/parser.py ──► projets_master.json ──► lib/generator.py ──► index.html
+ (faisabilité) (extraction) (contrat schéma) (rendu luxury) (site public)
+```
+
+Le contrat `projets_master.json` est défini par
+[`../faisabilite/projets_master.schema.json`](../faisabilite/projets_master.schema.json)
+(livré au Sprint 1). Le versioning faisabilité par
+[`../faisabilite/version.schema.json`](../faisabilite/version.schema.json).
+
+## État par rapport au calendrier AGENT.md
+
+| Semaine | Livrable | Statut ici |
+|---|---|---|
+| **S2** | Setup base + **parser faisabilité → JSON** | ✅ `lib/parser.py` + validation schéma |
+| S3 | Générateur HTML → site | ✅ aperçu `lib/generator.py` (rendu luxury #4) |
+| S4 | Triggers systemd + inotify | ☐ (exécution VPS — hors périmètre worker) |
+| S5 | Notifications WhatsApp Michel | ☐ (VPS) |
+| S6 | Multilingue FR/EN/ES auto | ☐ (FR extrait ; EN/ES à venir) |
+| S7 | Tests + validation P01-P09 | 🟡 tests sur fixtures synthétiques (données réelles VPS) |
+| S8 | Production | ☐ (VPS) |
+
+## Composants
+
+| Fichier | Rôle |
+|---|---|
+| `publiciste.py` | Orchestrateur CLI (`parse` / `validate` / `generate` / `run`) |
+| `lib/parser.py` | `data_room/PXX/` → dict projet (contrat schéma) · **anti-invention #6** |
+| `lib/validator.py` | Validateur JSON-Schema draft-07 (sous-ensemble) · **zéro dépendance pip** |
+| `lib/generator.py` | `projets_master.json` → HTML (marque luxury #4) |
+| `lib/branding.py` | Tokens de marque (source unique · CLAUDE.md #4) |
+| `templates/site_public.html.tmpl` | Gabarit dark+doré (Fraunces + Cormorant Garamond) |
+| `fixtures/` | Données **synthétiques** de test (⚠️ jamais publiées — cf. `fixtures/README.md`) |
+| `tests/test_publiciste.py` | Suite `unittest` (parser, schéma, generator) |
+
+## Règles anti-invention appliquées (CLAUDE.md #6)
+
+- Le statut publié dérive de `_META/version.json` : `complete → disponible`, sinon
+ `en_developpement`.
+- **Rétrogradation défensive** : un projet n'est « disponible » que si TOUTES ses
+ typologies ont un prix USD numérique ; sinon → « En développement · Prochainement ».
+- Cellule vide / « non défini » / placeholder `{{…}}` → `null` (jamais `0`, jamais
+ une valeur inventée).
+- Rendus : uniquement ceux référencés dans la faisabilité (❌ IA générique).
+
+## Utilisation
+
+```bash
+# depuis 05_deliverables_mvp/publiciste/
+
+# Parser une data_room → projets_master.json (validé contre le schéma)
+python3 publiciste.py parse fixtures/data_room -o build/projets_master.json
+
+# Générer le site depuis un master validé
+python3 publiciste.py generate build/projets_master.json -o build/index.html
+
+# Pipeline complet
+python3 publiciste.py run fixtures/data_room -o build/
+
+# Tests
+python3 -m unittest discover -s tests -v
+```
+
+> ⚠️ Ce worker n'écrit jamais sur le VPS. Le déploiement réel (copie vers
+> `/opt/oto/sites/vente/`, cache-bust Cloudflare, notification WhatsApp) est un
+> livrable ultérieur exécuté côté serveur par l'agent (Semaines 4-5).
+
+## Auto-score 4Big du livrable : **95/100**
+
+_Réserve −5_ : exécution contre les **données réelles** `data_room/` (VPS, hors
+périmètre) différée ; triggers systemd/inotify + notifications WhatsApp = livrables
+Semaines 4-5 côté serveur ; génération EN/ES = Semaine 6.
diff --git a/05_deliverables_mvp/publiciste/fixtures/README.md b/05_deliverables_mvp/publiciste/fixtures/README.md
new file mode 100644
index 0000000..1bbdf8e
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/README.md
@@ -0,0 +1,16 @@
+# Fixtures Publiciste — ⚠️ DONNÉES SYNTHÉTIQUES DE TEST
+
+**Ces fichiers ne proviennent d'AUCUNE archive réelle `data_room/` et ne doivent
+JAMAIS être publiés.** Ils existent uniquement pour exercer le parser + le
+générateur + le validateur de schéma en CI, sans dépendre du VPS.
+
+- Les chiffres (prix, surfaces) sont **volontairement ronds et fictifs** — ils ne
+ décrivent aucun projet OTO. Aucun risque d'« invention » publiée (CLAUDE.md #6) :
+ rien ici n'alimente `vente.otov7.com`.
+- `data_room/P01/` — faisabilité **complète** (statut `complete`, score ≥ 95) :
+ exerce le chemin « disponible » avec grille de prix.
+- `data_room/P02/` — faisabilité **incomplète** (prix manquant) : exerce la
+ rétrogradation défensive → « En développement · Prochainement · Détails à venir ».
+
+Codes projet réels réutilisés (P01/P02) car le schéma impose le motif `^P0[1-9]$` ;
+la nature synthétique est portée par ce README + les valeurs manifestement fictives.
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P01/00_brief/brief.md b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/00_brief/brief.md
new file mode 100644
index 0000000..018740b
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/00_brief/brief.md
@@ -0,0 +1,7 @@
+# Résidence Fixture Uno
+
+> ⚠️ FIXTURE SYNTHÉTIQUE — données fictives pour test parser. Ne pas publier.
+
+- **Localisation** : Test Province · Municipalité Fixture · 19.0000, -70.0000
+- Superficie terrain : 10 000 m² / 159 tareas
+- Zonage : Résidentiel touristique
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P01/20_architecture/architecture.md b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/20_architecture/architecture.md
new file mode 100644
index 0000000..3ee6224
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/20_architecture/architecture.md
@@ -0,0 +1,13 @@
+# Architecture — Résidence Fixture Uno (SYNTHÉTIQUE)
+
+## 3.1 Champs généraux
+- Parti architectural : contemporain tropical (fixture)
+- Nb de bâtiments : 3
+
+## 3.2 Tableau des typologies
+
+| Typologie | Nb unités | Surface intérieure (m²) | Surface terrasse (m²) | Surface totale (m²) | Prix « à partir de » (USD) | Prix (DOP) |
+|---|---|---|---|---|---|---|
+| Studio | 12 | 45 | 10 | 55 | USD 150,000 | DOP 8,850,000 |
+| 1 Chambre | 20 | 68 | 15 | 83 | USD 210,000 | DOP 12,390,000 |
+| 2 Chambres | 8 | 95 | 25 | 120 | USD 320,000 | DOP 18,880,000 |
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P01/30_paysage_experience/paysage_experience.md b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/30_paysage_experience/paysage_experience.md
new file mode 100644
index 0000000..3c261e5
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/30_paysage_experience/paysage_experience.md
@@ -0,0 +1,12 @@
+# Paysage & Expérience — Fixture Uno (SYNTHÉTIQUE)
+
+## 3.2 Amenities / équipements
+- Piscine à débordement
+- Beach club
+
+## 3.3 Services inclus
+- Conciergerie 24/7
+- Sécurité périmétrique
+- Gestion locative optionnelle
+- Régime CONFOTUR (exonération fiscale)
+- Structure Fideicomiso
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P01/40_llm_outputs/commercial.md b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/40_llm_outputs/commercial.md
new file mode 100644
index 0000000..99070ac
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/40_llm_outputs/commercial.md
@@ -0,0 +1,8 @@
+# Commercial — Fixture Uno (SYNTHÉTIQUE)
+
+Une adresse balnéaire confidentielle où l'architecture contemporaine dialogue avec
+la nature tropicale. Des résidences pensées pour l'investissement locatif comme pour
+l'art de vivre, sous régime CONFOTUR.
+
+## Typologies (miroir §3.2)
+Voir le tableau de l'architecture — prix « à partir de » USD 150,000.
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P01/60_photos_site/README.md b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/60_photos_site/README.md
new file mode 100644
index 0000000..93e4e9a
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/60_photos_site/README.md
@@ -0,0 +1,10 @@
+# Manifeste rendus — Fixture Uno (SYNTHÉTIQUE)
+
+> ⚠️ Fichiers non fournis (fixture) — seul le manifeste est testé.
+> Rendus liés au projet réel obligatoires en production (❌ IA générique).
+
+| Fichier | Vue | Hero |
+|---|---|---|
+| hero_aerien.jpg | Vue aérienne | hero |
+| piscine.jpg | Piscine à débordement | non |
+| lobby.jpg | Lobby | non |
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P01/_META/version.json b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/_META/version.json
new file mode 100644
index 0000000..7089af3
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P01/_META/version.json
@@ -0,0 +1,9 @@
+{
+ "projet": "P01",
+ "template_version": "1.0.0",
+ "generated_at": "2026-07-30T00:00:00Z",
+ "score_4big": 96,
+ "statut_faisabilite": "complete",
+ "volets_complets": ["masterplan", "architecture", "paysage_experience", "ingenierie"],
+ "champs_manquants": []
+}
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P02/00_brief/brief.md b/05_deliverables_mvp/publiciste/fixtures/data_room/P02/00_brief/brief.md
new file mode 100644
index 0000000..47e5e44
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P02/00_brief/brief.md
@@ -0,0 +1,6 @@
+# Résidence Fixture Dos
+
+> ⚠️ FIXTURE SYNTHÉTIQUE — faisabilité volontairement INCOMPLÈTE (prix manquants).
+
+- **Localisation** : Test Province Dos · Municipalité Fixture
+- Zonage : Résidentiel touristique
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P02/20_architecture/architecture.md b/05_deliverables_mvp/publiciste/fixtures/data_room/P02/20_architecture/architecture.md
new file mode 100644
index 0000000..a6c6854
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P02/20_architecture/architecture.md
@@ -0,0 +1,10 @@
+# Architecture — Résidence Fixture Dos (SYNTHÉTIQUE · INCOMPLÈTE)
+
+## 3.2 Tableau des typologies
+
+> Prix « à partir de » NON défini → faisabilité incomplète → « En développement ».
+
+| Typologie | Nb unités | Surface intérieure (m²) | Surface terrasse (m²) | Surface totale (m²) | Prix « à partir de » (USD) | Prix (DOP) |
+|---|---|---|---|---|---|---|
+| Villa A | 6 | 180 | 60 | 240 | non défini | non défini |
+| Villa B | 4 | 220 | 80 | 300 | — | — |
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P02/30_paysage_experience/paysage_experience.md b/05_deliverables_mvp/publiciste/fixtures/data_room/P02/30_paysage_experience/paysage_experience.md
new file mode 100644
index 0000000..13778cb
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P02/30_paysage_experience/paysage_experience.md
@@ -0,0 +1,5 @@
+# Paysage & Expérience — Fixture Dos (SYNTHÉTIQUE)
+
+## 3.3 Services inclus
+- Sécurité 24/7
+- Espaces verts paysagers
diff --git a/05_deliverables_mvp/publiciste/fixtures/data_room/P02/_META/version.json b/05_deliverables_mvp/publiciste/fixtures/data_room/P02/_META/version.json
new file mode 100644
index 0000000..26881f7
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/fixtures/data_room/P02/_META/version.json
@@ -0,0 +1,9 @@
+{
+ "projet": "P02",
+ "template_version": "1.0.0",
+ "generated_at": "2026-07-30T00:00:00Z",
+ "score_4big": 71,
+ "statut_faisabilite": "incomplete",
+ "volets_complets": ["masterplan", "architecture"],
+ "champs_manquants": ["3.2.typo_1_prix_usd", "3.2.typo_2_prix_usd"]
+}
diff --git a/05_deliverables_mvp/publiciste/lib/__init__.py b/05_deliverables_mvp/publiciste/lib/__init__.py
new file mode 100644
index 0000000..e0870ef
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/lib/__init__.py
@@ -0,0 +1,11 @@
+"""Publiciste Agent · bibliothèque interne.
+
+Modules :
+ - parser : data_room/PXX/ → dict projet (contrat projets_master.schema.json)
+ - validator : validation JSON-Schema draft-07 (sous-ensemble, zéro dépendance pip)
+ - generator : projets_master.json → HTML public (vente.otov7.com)
+ - branding : tokens de marque luxury (CLAUDE.md #4)
+
+Cible de portage sur le VPS : otoia/capabilities/publiciste.py
+(voir 03_agents/publiciste/AGENT.md · GAP_ANALYSIS_SPRINT1.md §3.13).
+"""
diff --git a/05_deliverables_mvp/publiciste/lib/branding.py b/05_deliverables_mvp/publiciste/lib/branding.py
new file mode 100644
index 0000000..91083c5
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/lib/branding.py
@@ -0,0 +1,40 @@
+"""Tokens de marque luxury · CLAUDE.md contrainte #4 (source unique de vérité).
+
+Dark + doré : `#0a0a12` fond · `#f0b429` accent doré.
+Typographies : Fraunces (titres) + Cormorant Garamond (corps éditorial).
+
+Ces constantes sont importées par le générateur ET vérifiées par la baseline QA
+Playwright (tests/e2e/_shared/contract.ts) — garder les valeurs synchronisées.
+"""
+
+from __future__ import annotations
+
+# Couleurs canoniques (contrainte #4).
+COLOR_BG = "#0a0a12" # fond dark
+COLOR_ACCENT = "#f0b429" # doré
+COLOR_INK = "#f5f3ee" # texte clair sur fond dark
+COLOR_MUTED = "#8b8778" # texte secondaire
+
+# Typographies canoniques (contrainte #4).
+FONT_DISPLAY = "Fraunces"
+FONT_BODY = "Cormorant Garamond"
+
+# Devises canoniques (contrainte #10 · USD + DOP).
+DEVISE_PRIMAIRE = "USD"
+DEVISE_SECONDAIRE = "DOP"
+
+# Libellés de statut affichés sur le site public (FR par défaut).
+STATUT_LABELS = {
+ "disponible": "Disponible",
+ "en_developpement": "En développement",
+ "bientot": "Bientôt",
+ "en_processus": "En processus",
+}
+
+# Statuts pour lesquels on affiche « Prochainement · Détails à venir »
+# au lieu d'une grille de prix (jamais de prix inventé — contrainte #6).
+STATUTS_SANS_PRIX = {"en_developpement", "bientot", "en_processus"}
+
+
+def statut_label(statut: str) -> str:
+ return STATUT_LABELS.get(statut, statut)
diff --git a/05_deliverables_mvp/publiciste/lib/generator.py b/05_deliverables_mvp/publiciste/lib/generator.py
new file mode 100644
index 0000000..7b04062
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/lib/generator.py
@@ -0,0 +1,162 @@
+"""Générateur HTML · projets_master.json → site public (vente.otov7.com).
+
+Livrable Publiciste · Sprint 2 · Semaine 3 (aperçu ; le parser Semaine 2 est le
+cœur du sprint courant). Rendu server-side pur stdlib, marque luxury (#4).
+
+Interdits appliqués (AGENT.md §Règles absolues) :
+ - ❌ Jamais éditer directement l'index publié : on passe TOUJOURS par ce
+ générateur, qui écrit une sortie complète et horodatée.
+ - ❌ Zéro prix inventé : un projet sans prix numérique n'affiche PAS de grille,
+ mais « Prochainement · Détails à venir » (contrainte #6).
+ - ❌ Rendus IA génériques interdits : on n'affiche que les rendus référencés
+ dans la faisabilité (liés au projet réel).
+"""
+
+from __future__ import annotations
+
+import html
+import os
+from typing import Any
+
+from . import branding
+
+_TEMPLATE = os.path.join(os.path.dirname(__file__), "..", "templates", "site_public.html.tmpl")
+
+_TAGLINE = "Résidences d'exception en République dominicaine · faisabilités 4 volets."
+_A_VENIR = "Prochainement · Détails à venir"
+
+
+def _esc(text: Any) -> str:
+ return html.escape(str(text), quote=True)
+
+
+def _fmt_usd(value: float) -> str:
+ return f"USD {value:,.0f}".replace(",", " ")
+
+
+def _fmt_dop(value: float) -> str:
+ return f"DOP {value:,.0f}".replace(",", " ")
+
+
+def _fmt_m2(value: float) -> str:
+ txt = f"{value:,.0f}".replace(",", " ") if float(value).is_integer() else f"{value:,.1f}".replace(",", " ")
+ return f"{txt} m²"
+
+
+def _prix_depuis(typologies: list[dict[str, Any]]) -> float | None:
+ """Prix « à partir de » du projet = min des prix USD numériques présents."""
+ prices = [t["prix_depuis_usd"] for t in typologies if isinstance(t.get("prix_depuis_usd"), (int, float))]
+ return min(prices) if prices else None
+
+
+def _render_typologies(typologies: list[dict[str, Any]]) -> str:
+ rows = []
+ for t in typologies:
+ surface = t.get("surface_totale_m2") or t.get("surface_interieure_m2")
+ surface_txt = _fmt_m2(surface) if isinstance(surface, (int, float)) else "—"
+ prix = t.get("prix_depuis_usd")
+ prix_txt = (
+ f'{_esc(_fmt_usd(prix))}'
+ if isinstance(prix, (int, float))
+ else "—"
+ )
+ rows.append(
+ "
"
+ f"| {_esc(t.get('nom', ''))} | "
+ f"{surface_txt} | "
+ f"{prix_txt} | "
+ "
"
+ )
+ return (
+ ' \n'
+ " | Typologie | Surface | À partir de |
\n"
+ + "\n".join(rows)
+ + "\n
"
+ )
+
+
+def _render_inclus(inclus: list[str]) -> str:
+ if not inclus:
+ return ""
+ items = "\n".join(f" {_esc(x)}" for x in inclus)
+ return f' '
+
+
+def _hero_media(projet: dict[str, Any]) -> str:
+ rendus = projet.get("rendus", [])
+ hero = next((r for r in rendus if r.get("hero")), rendus[0] if rendus else None)
+ if not hero:
+ return ' '
+ # Convention de chemin public (miroir de /opt/oto/sites/static/projets/pXX/).
+ code = projet["code"].lower()
+ src = f"/static/projets/{code}/{hero['fichier']}"
+ style = f"background-image:url('{_esc(src)}')"
+ return f' '
+
+
+def render_projet(projet: dict[str, Any]) -> str:
+ statut = projet.get("statut", "en_developpement")
+ statut_label = branding.statut_label(statut)
+ nom = projet.get("nom", projet.get("code", ""))
+ loc = projet.get("localisation", "")
+
+ parts = [f' ']
+ parts.append(_hero_media(projet))
+ parts.append(' ')
+ parts.append(f'
{_esc(statut_label)}')
+ parts.append(f"
{_esc(nom)}
")
+ if loc:
+ parts.append(f'
{_esc(loc)}
')
+
+ positionnement = (projet.get("positionnement") or {}).get("fr")
+ if positionnement:
+ parts.append(f'
{_esc(positionnement)}
')
+
+ typologies = projet.get("typologies", [])
+ show_prices = statut not in branding.STATUTS_SANS_PRIX and _prix_depuis(typologies) is not None
+ if show_prices:
+ parts.append(_render_typologies(typologies))
+ else:
+ # Aucun prix inventé — message d'attente (contrainte #6).
+ parts.append(f'
{_esc(_A_VENIR)}
')
+
+ inclus_html = _render_inclus(projet.get("inclus", []))
+ if inclus_html:
+ parts.append(inclus_html)
+
+ parts.append("
")
+ parts.append(" ")
+ return "\n".join(parts)
+
+
+def render_site(master: dict[str, Any]) -> str:
+ """projets_master.json (dict) → page HTML complète (str)."""
+ with open(_TEMPLATE, encoding="utf-8") as fh:
+ tmpl = fh.read()
+
+ projets_html = "\n".join(render_projet(p) for p in master.get("projets", []))
+ generated_at = master.get("generated_at", "")
+ template_version = master.get("template_version", "")
+ footer = (
+ f"Contenu généré automatiquement depuis les faisabilités canoniques · "
+ f"template {_esc(template_version)} · {_esc(generated_at)}. "
+ "Prix « à partir de » en USD + DOP (Cardnet). Aucune donnée inventée — "
+ "chaque valeur provient de la faisabilité du projet."
+ )
+
+ replacements = {
+ "{{TITRE}}": "Helios RD · Résidences d'exception",
+ "{{META_DESCRIPTION}}": _TAGLINE,
+ "{{TAGLINE}}": _TAGLINE,
+ "{{COLOR_BG}}": branding.COLOR_BG,
+ "{{COLOR_ACCENT}}": branding.COLOR_ACCENT,
+ "{{COLOR_INK}}": branding.COLOR_INK,
+ "{{COLOR_MUTED}}": branding.COLOR_MUTED,
+ "{{FONT_DISPLAY}}": branding.FONT_DISPLAY,
+ "{{FONT_BODY}}": branding.FONT_BODY,
+ "{{PROJETS}}": projets_html,
+ "{{FOOTER}}": footer,
+ }
+ for key, val in replacements.items():
+ tmpl = tmpl.replace(key, val)
+ return tmpl
diff --git a/05_deliverables_mvp/publiciste/lib/parser.py b/05_deliverables_mvp/publiciste/lib/parser.py
new file mode 100644
index 0000000..0e1db67
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/lib/parser.py
@@ -0,0 +1,442 @@
+"""Parser Faisabilité → contrat Publiciste (`projets_master.json`).
+
+Livrable Publiciste · Sprint 2 · Semaine 2 (AGENT.md §Livrable Sprint).
+Lit une faisabilité canonique `data_room/PXX/` (template v1.0) et produit le dict
+`projet` conforme à `projets_master.schema.json`, consommé ensuite par le
+générateur HTML.
+
+Règles ANTI-INVENTION (CLAUDE.md #6) appliquées ici — le parser est volontairement
+défensif : il n'invente JAMAIS un chiffre absent, et il **rétrograde** un projet
+en « en_developpement » plutôt que de publier une donnée douteuse.
+
+ 1. Le statut publié dérive de `_META/version.json.statut_faisabilite` :
+ complete → disponible
+ en_developpement→ en_developpement
+ incomplete → en_developpement
+ 2. Un projet n'est « disponible » QUE si version.json le dit complete ET que
+ chaque typologie a un prix USD numérique (sinon rétrogradation défensive).
+ 3. Une cellule vide / « non défini » / placeholder `{{...}}` → None (jamais 0,
+ jamais une valeur inventée).
+
+Sources lues (ordre du template canonique v1.0) :
+ _META/version.json → statut, score, traçabilité
+ 00_brief/brief.md → localisation (§1.1)
+ 20_architecture/architecture.md → typologies + prix (§3.2, bloc anti-gap)
+ 30_paysage_experience/paysage_experience.md→ services inclus (§3.3)
+ 40_llm_outputs/commercial.md → positionnement FR (fallback prix §5.3)
+ 60_photos_site/ → rendus (liés au projet réel)
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from datetime import datetime, timezone
+from typing import Any, Optional
+
+# Marqueurs signalant une donnée ABSENTE (jamais inventer — contrainte #6).
+_ABSENT = {
+ "", "—", "-", "–", "n/d", "nd", "na", "n.a.", "s/o", "so",
+ "non défini", "non defini", "non renseigné", "non renseigne",
+ "à définir", "a definir", "tbd", "todo", "…", "...",
+}
+
+
+# --------------------------------------------------------------------------- #
+# Helpers de bas niveau : nettoyage de cellules Markdown → valeurs typées.
+# --------------------------------------------------------------------------- #
+def _clean(cell: str) -> str:
+ """Retire gras/italique/backticks et espaces d'une cellule Markdown."""
+ return cell.replace("**", "").replace("`", "").replace("*", "").strip()
+
+
+def _is_absent(text: str) -> bool:
+ t = _clean(text).lower()
+ if not t:
+ return True
+ if "{{" in t and "}}" in t: # placeholder de template non rempli
+ return True
+ return t in _ABSENT
+
+
+def parse_number(text: str) -> Optional[float]:
+ """« USD 250,000 » / « 1 250,50 » / « 3.5 » → float ; absent → None.
+
+ Détecte le séparateur décimal (dernier « . » ou « , » suivi de 1-2 chiffres)
+ et traite les autres séparateurs comme des milliers. Ignore tout habillage
+ (devises, unités m², %, texte). Retourne None dès qu'aucun chiffre exploitable.
+ """
+ if _is_absent(text):
+ return None
+ raw = _clean(text)
+ # Isole le premier bloc numérique (chiffres, points, virgules, espaces).
+ m = re.search(r"[0-9][0-9\s., ]*", raw)
+ if not m:
+ return None
+ token = m.group(0).strip().replace(" ", " ")
+ token = token.rstrip(" .,")
+
+ # Décide du séparateur décimal : le dernier '.' ou ',' suivi de 1-2 chiffres
+ # de fin de chaîne est décimal ; le reste = séparateurs de milliers.
+ dec = re.search(r"[.,](\d{1,2})$", token)
+ if dec:
+ int_part = token[: dec.start()]
+ int_part = re.sub(r"[^\d]", "", int_part)
+ value_str = f"{int_part}.{dec.group(1)}"
+ else:
+ value_str = re.sub(r"[^\d]", "", token)
+ if not value_str or value_str == ".":
+ return None
+ try:
+ return float(value_str)
+ except ValueError:
+ return None
+
+
+def parse_int(text: str) -> Optional[int]:
+ n = parse_number(text)
+ if n is None:
+ return None
+ return int(round(n))
+
+
+def parse_price(text: str) -> Optional[float]:
+ """Prix « à partir de ». Alias sémantique de parse_number (garde None si absent)."""
+ return parse_number(text)
+
+
+# --------------------------------------------------------------------------- #
+# Tables Markdown.
+# --------------------------------------------------------------------------- #
+def _split_row(line: str) -> list[str]:
+ line = line.strip()
+ if line.startswith("|"):
+ line = line[1:]
+ if line.endswith("|"):
+ line = line[:-1]
+ return [c.strip() for c in line.split("|")]
+
+
+def _is_separator_row(cells: list[str]) -> bool:
+ return all(re.fullmatch(r":?-{2,}:?", c.strip()) for c in cells if c.strip()) and any(cells)
+
+
+def find_table_with_header(md: str, header_keywords: list[str]) -> tuple[list[str], list[list[str]]]:
+ """(header, rows) de la première table Markdown dont l'en-tête contient TOUS
+ les mots-clés (insensible casse). ([], []) si aucune table correspondante.
+ """
+ lines = md.splitlines()
+ keys = [k.lower() for k in header_keywords]
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ if line.count("|") >= 2:
+ header = _split_row(line)
+ header_txt = " ".join(header).lower()
+ nxt = lines[i + 1] if i + 1 < len(lines) else ""
+ if all(k in header_txt for k in keys) and _is_separator_row(_split_row(nxt)):
+ rows: list[list[str]] = []
+ j = i + 2
+ while j < len(lines) and lines[j].count("|") >= 2:
+ cells = _split_row(lines[j])
+ if not _is_separator_row(cells):
+ rows.append(cells)
+ j += 1
+ return header, rows
+ i += 1
+ return [], []
+
+
+def find_table(md: str, header_keywords: list[str]) -> list[list[str]]:
+ """Lignes de données seules (compat) — voir find_table_with_header."""
+ return find_table_with_header(md, header_keywords)[1]
+
+
+def _col(header: list[str], *keywords: str) -> Optional[int]:
+ """Index de la 1re colonne dont l'en-tête contient TOUS les mots-clés (casse ignorée)."""
+ keys = [k.lower() for k in keywords]
+ for idx, cell in enumerate(header):
+ low = _clean(cell).lower()
+ if all(k in low for k in keys):
+ return idx
+ return None
+
+
+# --------------------------------------------------------------------------- #
+# Extractions par volet.
+# --------------------------------------------------------------------------- #
+def parse_typologies(architecture_md: str) -> list[dict[str, Any]]:
+ """Tableau §3.2 → liste de typologies. Ignore les lignes-placeholder.
+
+ Colonnes attendues (template v1.0) :
+ Typologie | Nb unités | Surface intérieure | Surface terrasse |
+ Surface totale | Prix « à partir de » (USD) | Prix (DOP)
+ """
+ header, rows = find_table_with_header(architecture_md, ["typologie", "prix"])
+ if not header:
+ return []
+
+ # Mapping des colonnes PAR EN-TÊTE (résilient au ré-ordonnancement / ajout de
+ # colonnes). Une colonne absente de l'en-tête → valeur None (jamais deviner une
+ # position et risquer d'assigner la mauvaise donnée — contrainte #6).
+ i_nom = _col(header, "typologie")
+ i_qte = _col(header, "unit") # « Nb unités »
+ i_int = _col(header, "intérieure")
+ if i_int is None:
+ i_int = _col(header, "interieure")
+ i_terr = _col(header, "terrasse")
+ i_tot = _col(header, "totale")
+ i_usd = _col(header, "usd")
+ i_dop = _col(header, "dop")
+ if i_nom is None: # en-tête inexploitable → on ne parse pas de prix au hasard
+ return []
+
+ def cell(cells: list[str], idx: Optional[int]) -> str:
+ if idx is None or not (0 <= idx < len(cells)):
+ return ""
+ return cells[idx]
+
+ typologies: list[dict[str, Any]] = []
+ for cells in rows:
+ if _is_absent(cell(cells, i_nom)): # ligne entièrement placeholder → on saute
+ continue
+ typologies.append(
+ {
+ "nom": _clean(cell(cells, i_nom)),
+ "quantite": parse_int(cell(cells, i_qte)),
+ "surface_interieure_m2": parse_number(cell(cells, i_int)),
+ "surface_terrasse_m2": parse_number(cell(cells, i_terr)),
+ "surface_totale_m2": parse_number(cell(cells, i_tot)),
+ "prix_depuis_usd": parse_price(cell(cells, i_usd)),
+ "prix_depuis_dop": parse_price(cell(cells, i_dop)),
+ }
+ )
+ return typologies
+
+
+def _parse_bullets_after(md: str, heading_keywords: list[str]) -> list[str]:
+ """Puces (- / * / •) sous le premier titre/ligne contenant tous les mots-clés."""
+ lines = md.splitlines()
+ keys = [k.lower() for k in heading_keywords]
+ items: list[str] = []
+ capturing = False
+ for line in lines:
+ low = line.lower()
+ if not capturing:
+ if line.lstrip().startswith("#") and all(k in low for k in keys):
+ capturing = True
+ continue
+ if line.lstrip().startswith("#"): # section suivante → stop
+ break
+ m = re.match(r"\s*[-*•]\s+(.*)", line)
+ if m:
+ val = _clean(m.group(1))
+ if val and not _is_absent(val):
+ items.append(val)
+ return items
+
+
+def parse_services_inclus(paysage_md: str) -> list[str]:
+ """§3.3 « Services inclus » (+ §3.2 amenities en complément)."""
+ services = _parse_bullets_after(paysage_md, ["services", "inclus"])
+ amenities = _parse_bullets_after(paysage_md, ["amenities"])
+ # Dédoublonnage en conservant l'ordre.
+ seen: set[str] = set()
+ out: list[str] = []
+ for item in services + amenities:
+ key = item.lower()
+ if key not in seen:
+ seen.add(key)
+ out.append(item)
+ return out
+
+
+def parse_localisation(brief_md: str) -> Optional[str]:
+ """Ligne « Localisation : … » du brief (§1.1)."""
+ for line in brief_md.splitlines():
+ m = re.match(r"\s*[-*]?\s*\**localisation\**\s*[::]\s*(.+)", line, re.IGNORECASE)
+ if m:
+ val = _clean(m.group(1))
+ if val and not _is_absent(val):
+ return val
+ return None
+
+
+def parse_nom(brief_md: str, code: str) -> str:
+ """Nom commercial : 1er titre H1 du brief, sinon le code projet."""
+ for line in brief_md.splitlines():
+ m = re.match(r"\s*#\s+(.+)", line)
+ if m:
+ return _clean(m.group(1))
+ return code
+
+
+def parse_positionnement_fr(commercial_md: str) -> Optional[str]:
+ """Positionnement FR = 1er paragraphe non-titre de commercial.md.
+
+ NOTE : la génération EN/ES multilingue est le livrable Semaine 6 ; ici on ne
+ remplit que le FR à partir de la source documentée (jamais inventé — #6).
+ """
+ for block in re.split(r"\n\s*\n", commercial_md):
+ block = block.strip()
+ if not block or block.startswith("#") or block.startswith("|"):
+ continue
+ text = _clean(block.replace("\n", " "))
+ if len(text) >= 20:
+ return text
+ return None
+
+
+def parse_rendus(photos_dir: str) -> list[dict[str, Any]]:
+ """Rendus liés au projet réel (jamais IA générique — contrainte CLAUDE.md).
+
+ Deux sources possibles :
+ 1. Un manifeste `60_photos_site/README.md` (table : Fichier | Vue | Hero).
+ 2. À défaut, la liste des fichiers image du dossier.
+ """
+ rendus: list[dict[str, Any]] = []
+ if not os.path.isdir(photos_dir):
+ return rendus
+
+ manifest = os.path.join(photos_dir, "README.md")
+ if os.path.isfile(manifest):
+ with open(manifest, encoding="utf-8") as fh:
+ rows = find_table(fh.read(), ["fichier"])
+ for cells in rows:
+ cells = cells + [""] * (3 - len(cells))
+ fichier = _clean(cells[0])
+ if _is_absent(cells[0]):
+ continue
+ entry: dict[str, Any] = {"fichier": fichier}
+ vue = _clean(cells[1])
+ if vue and not _is_absent(cells[1]):
+ entry["vue"] = vue
+ entry["hero"] = "hero" in _clean(cells[2]).lower() or "oui" in _clean(cells[2]).lower()
+ rendus.append(entry)
+ if rendus:
+ return rendus
+
+ # Fallback : fichiers image présents.
+ exts = (".jpg", ".jpeg", ".png", ".webp", ".avif")
+ for fn in sorted(os.listdir(photos_dir)):
+ if fn.lower().endswith(exts):
+ rendus.append({"fichier": fn, "hero": False})
+ if rendus:
+ rendus[0]["hero"] = True # 1er rendu = hero par défaut
+ return rendus
+
+
+# --------------------------------------------------------------------------- #
+# Mapping de statut + assemblage projet.
+# --------------------------------------------------------------------------- #
+_STATUT_MAP = {
+ "complete": "disponible",
+ "en_developpement": "en_developpement",
+ "incomplete": "en_developpement",
+}
+
+
+def map_statut(version_statut: str) -> str:
+ return _STATUT_MAP.get(version_statut, "en_developpement")
+
+
+def _read(path: str) -> str:
+ if os.path.isfile(path):
+ with open(path, encoding="utf-8") as fh:
+ return fh.read()
+ return ""
+
+
+def parse_projet(projet_dir: str) -> dict[str, Any]:
+ """Lit un dossier `data_room/PXX/` → dict `projet` (contrat schéma).
+
+ Lève FileNotFoundError si `_META/version.json` absent (donnée de vérité
+ obligatoire : sans elle on ne connaît ni le statut ni le score, on refuse
+ d'inventer).
+ """
+ meta_path = os.path.join(projet_dir, "_META", "version.json")
+ if not os.path.isfile(meta_path):
+ raise FileNotFoundError(f"version.json manquant : {meta_path}")
+ with open(meta_path, encoding="utf-8") as fh:
+ version = json.load(fh)
+
+ code = version["projet"]
+ brief_md = _read(os.path.join(projet_dir, "00_brief", "brief.md"))
+ architecture_md = _read(os.path.join(projet_dir, "20_architecture", "architecture.md"))
+ paysage_md = _read(os.path.join(projet_dir, "30_paysage_experience", "paysage_experience.md"))
+ commercial_md = _read(os.path.join(projet_dir, "40_llm_outputs", "commercial.md"))
+
+ typologies = parse_typologies(architecture_md)
+ statut = map_statut(version.get("statut_faisabilite", "incomplete"))
+
+ # Rétrogradation défensive (contrainte #6) : « disponible » exige des prix USD
+ # numériques sur toutes les typologies, sinon on ne publie pas de prix douteux.
+ if statut == "disponible":
+ prix_ok = bool(typologies) and all(
+ isinstance(t["prix_depuis_usd"], (int, float)) for t in typologies
+ )
+ if not prix_ok:
+ statut = "en_developpement"
+
+ projet: dict[str, Any] = {
+ "code": code,
+ "nom": parse_nom(brief_md, code),
+ "statut": statut,
+ "localisation": parse_localisation(brief_md) or "",
+ # Les typologies (avec prix null tolérés) restent exposées même en
+ # « en_developpement » : le générateur choisit d'afficher ou non la grille.
+ "typologies": typologies,
+ "inclus": parse_services_inclus(paysage_md),
+ "rendus": parse_rendus(os.path.join(projet_dir, "60_photos_site")),
+ "source": {
+ "template_version": version.get("template_version", ""),
+ "score_4big": version.get("score_4big", 0),
+ "fichiers": _sources_presentes(projet_dir),
+ },
+ }
+ positionnement_fr = parse_positionnement_fr(commercial_md)
+ if positionnement_fr:
+ projet["positionnement"] = {"fr": positionnement_fr}
+ return projet
+
+
+def _sources_presentes(projet_dir: str) -> list[str]:
+ """Chemins relatifs des sources effectivement lues (traçabilité #6)."""
+ candidates = [
+ os.path.join("_META", "version.json"),
+ os.path.join("00_brief", "brief.md"),
+ os.path.join("20_architecture", "architecture.md"),
+ os.path.join("30_paysage_experience", "paysage_experience.md"),
+ os.path.join("40_llm_outputs", "commercial.md"),
+ os.path.join("60_photos_site", "README.md"),
+ ]
+ return [c for c in candidates if os.path.isfile(os.path.join(projet_dir, c))]
+
+
+def build_master(
+ data_room_dir: str,
+ generated_at: Optional[str] = None,
+ template_version: str = "1.0.0",
+) -> dict[str, Any]:
+ """Parcourt `data_room/` → objet `projets_master.json` complet et trié par code.
+
+ `generated_at` : horodatage ISO-8601 UTC ; par défaut « maintenant ».
+ Injectable pour des sorties déterministes (tests).
+ """
+ if generated_at is None:
+ generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ projets: list[dict[str, Any]] = []
+ for name in sorted(os.listdir(data_room_dir)):
+ pdir = os.path.join(data_room_dir, name)
+ if re.fullmatch(r"P0[1-9]", name) and os.path.isfile(
+ os.path.join(pdir, "_META", "version.json")
+ ):
+ projets.append(parse_projet(pdir))
+
+ return {
+ "generated_at": generated_at,
+ "template_version": template_version,
+ "projets": projets,
+ }
diff --git a/05_deliverables_mvp/publiciste/lib/validator.py b/05_deliverables_mvp/publiciste/lib/validator.py
new file mode 100644
index 0000000..f0feb9b
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/lib/validator.py
@@ -0,0 +1,159 @@
+"""Validateur JSON-Schema draft-07 (sous-ensemble) · zéro dépendance pip.
+
+Pourquoi un validateur maison plutôt que `jsonschema` ?
+-----------------------------------------------------
+Le gate CI (Gitea Actions, cf. .gitea/workflows/ci.yml) tourne sur un runner
+`act_runner` sans installation `pip` (les guards Sprint 1 sont du bash pur).
+Ce validateur couvre EXACTEMENT les constructions utilisées par les deux schémas
+du contrat Faisabilité↔Publiciste (`version.schema.json`,
+`projets_master.schema.json`) : type, required, additionalProperties, enum,
+pattern, minLength, minItems, maxItems, uniqueItems, minimum, maximum, items,
+properties, $ref (interne « #/definitions/... »), allOf, if/then, const, format.
+
+Les tests (`tests/test_publiciste.py`) utilisent en plus la bibliothèque
+`jsonschema` comme oracle *quand elle est disponible*, pour se prémunir d'un
+écart entre ce validateur et la spec draft-07. En production le validateur
+maison suffit et reste autonome.
+
+API : validate(instance, schema) -> list[str] (liste d'erreurs, vide si OK).
+"""
+
+from __future__ import annotations
+
+import re
+from typing import Any
+
+
+class SchemaError(Exception):
+ """Schéma mal formé (bug du schéma, pas de la donnée)."""
+
+
+def _resolve_ref(ref: str, root: dict) -> dict:
+ if not ref.startswith("#/"):
+ raise SchemaError(f"$ref non supporté (interne uniquement) : {ref}")
+ node: Any = root
+ for part in ref[2:].split("/"):
+ part = part.replace("~1", "/").replace("~0", "~")
+ node = node[part]
+ return node
+
+
+def _type_ok(value: Any, expected: str) -> bool:
+ if expected == "object":
+ return isinstance(value, dict)
+ if expected == "array":
+ return isinstance(value, list)
+ if expected == "string":
+ return isinstance(value, str)
+ if expected == "integer":
+ # bool est un int en Python — on l'exclut explicitement.
+ return isinstance(value, int) and not isinstance(value, bool)
+ if expected == "number":
+ return isinstance(value, (int, float)) and not isinstance(value, bool)
+ if expected == "boolean":
+ return isinstance(value, bool)
+ if expected == "null":
+ return value is None
+ raise SchemaError(f"type inconnu dans le schéma : {expected}")
+
+
+# Formats vérifiés (les autres sont acceptés sans contrôle, comme le veut draft-07).
+_DATE_TIME = re.compile(
+ r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$"
+)
+
+
+def _check_format(value: str, fmt: str, path: str, errors: list[str]) -> None:
+ if fmt == "date-time" and not _DATE_TIME.match(value):
+ errors.append(f"{path}: format date-time invalide ({value!r})")
+
+
+def _validate(value: Any, schema: dict, root: dict, path: str, errors: list[str]) -> None:
+ if "$ref" in schema:
+ schema = _resolve_ref(schema["$ref"], root)
+
+ # const
+ if "const" in schema and value != schema["const"]:
+ errors.append(f"{path}: attendu const={schema['const']!r}, reçu {value!r}")
+
+ # enum
+ if "enum" in schema and value not in schema["enum"]:
+ errors.append(f"{path}: {value!r} hors enum {schema['enum']}")
+
+ # type (peut être une liste de types alternatifs)
+ if "type" in schema:
+ types = schema["type"]
+ types = [types] if isinstance(types, str) else types
+ if not any(_type_ok(value, t) for t in types):
+ errors.append(f"{path}: type {type(value).__name__} ∉ {types}")
+ # Inutile d'aller plus loin si le type de base est faux.
+ return
+
+ if isinstance(value, str):
+ if "minLength" in schema and len(value) < schema["minLength"]:
+ errors.append(f"{path}: chaîne trop courte (min {schema['minLength']})")
+ if "pattern" in schema and not re.search(schema["pattern"], value):
+ errors.append(f"{path}: {value!r} ne matche pas /{schema['pattern']}/")
+ if "format" in schema:
+ _check_format(value, schema["format"], path, errors)
+
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ if "minimum" in schema and value < schema["minimum"]:
+ errors.append(f"{path}: {value} < minimum {schema['minimum']}")
+ if "maximum" in schema and value > schema["maximum"]:
+ errors.append(f"{path}: {value} > maximum {schema['maximum']}")
+
+ if isinstance(value, list):
+ if "minItems" in schema and len(value) < schema["minItems"]:
+ errors.append(f"{path}: {len(value)} items < minItems {schema['minItems']}")
+ if "maxItems" in schema and len(value) > schema["maxItems"]:
+ errors.append(f"{path}: {len(value)} items > maxItems {schema['maxItems']}")
+ if schema.get("uniqueItems") and _has_duplicates(value):
+ errors.append(f"{path}: items non uniques")
+ if "items" in schema:
+ for i, item in enumerate(value):
+ _validate(item, schema["items"], root, f"{path}[{i}]", errors)
+
+ if isinstance(value, dict):
+ props = schema.get("properties", {})
+ for req in schema.get("required", []):
+ if req not in value:
+ errors.append(f"{path}: propriété requise absente « {req} »")
+ if schema.get("additionalProperties") is False:
+ extra = set(value) - set(props)
+ if extra:
+ errors.append(f"{path}: propriétés interdites {sorted(extra)}")
+ for key, sub in props.items():
+ if key in value:
+ _validate(value[key], sub, root, f"{path}.{key}", errors)
+
+ # Combinateurs
+ for sub in schema.get("allOf", []):
+ _validate(value, sub, root, path, errors)
+
+ if "if" in schema:
+ cond_errors: list[str] = []
+ _validate(value, schema["if"], root, path, cond_errors)
+ branch = "then" if not cond_errors else "else"
+ if branch in schema:
+ _validate(value, schema[branch], root, path, errors)
+
+
+def _has_duplicates(items: list) -> bool:
+ seen: list = []
+ for it in items:
+ if it in seen:
+ return True
+ seen.append(it)
+ return False
+
+
+def validate(instance: Any, schema: dict) -> list[str]:
+ """Valide `instance` contre `schema`. Retourne la liste des erreurs (vide = OK)."""
+ errors: list[str] = []
+ _validate(instance, schema, schema, "$", errors)
+ return errors
+
+
+def is_valid(instance: Any, schema: dict) -> bool:
+ return not validate(instance, schema)
diff --git a/05_deliverables_mvp/publiciste/publiciste.py b/05_deliverables_mvp/publiciste/publiciste.py
new file mode 100644
index 0000000..761fe71
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/publiciste.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+"""Publiciste Agent · orchestrateur CLI (scaffold Sprint 2).
+
+Cible de portage sur le VPS : `otoia/capabilities/publiciste.py`
+(cf. 03_agents/publiciste/AGENT.md · GAP_ANALYSIS_SPRINT1.md §3.13).
+
+Maintient le site public `vente.otov7.com` À PARTIR DES FAISABILITÉS CANONIQUES
+(data_room/PXX/), sans édition manuelle. Zéro invention de chiffre (CLAUDE.md #6).
+
+Sous-commandes :
+ parse data_room/ -> projets_master.json (validé contre le schéma)
+ validate projets_master.json
+ generate projets_master.json -> index.html
+ run data_room/ -> projets_master.json + index.html (pipeline complet)
+
+Pipeline (AGENT.md §Architecture technique) :
+ data_room/PXX/ ──► parser ──► projets_master.json ──► generator ──► index.html
+
+⚠ Ce worker n'écrit JAMAIS sur le VPS. La commande `run` produit les artefacts
+dans un dossier de sortie local ; le déploiement réel (copie vers
+/opt/oto/sites/vente/ + cache-bust + WhatsApp) est un livrable ultérieur exécuté
+côté serveur par l'agent (Semaines 4-5).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from lib import generator, parser, validator # noqa: E402
+
+_SCHEMA_DIR = os.path.normpath(
+ os.path.join(os.path.dirname(__file__), "..", "faisabilite")
+)
+
+
+def _load_schema(name: str) -> dict:
+ with open(os.path.join(_SCHEMA_DIR, name), encoding="utf-8") as fh:
+ return json.load(fh)
+
+
+def _eprint(*args) -> None:
+ print(*args, file=sys.stderr)
+
+
+# --------------------------------------------------------------------------- #
+def cmd_parse(ns: argparse.Namespace) -> int:
+ master = parser.build_master(ns.data_room, generated_at=ns.generated_at)
+ errors = validator.validate(master, _load_schema("projets_master.schema.json"))
+ if errors:
+ _eprint("❌ projets_master.json NON conforme au schéma :")
+ for e in errors:
+ _eprint(" ·", e)
+ # On écrit quand même la sortie pour diagnostic si --force.
+ if not ns.force:
+ return 1
+ payload = json.dumps(master, ensure_ascii=False, indent=2)
+ if ns.out:
+ with open(ns.out, "w", encoding="utf-8") as fh:
+ fh.write(payload + "\n")
+ _eprint(f"✅ {len(master['projets'])} projet(s) → {ns.out}")
+ else:
+ print(payload)
+ return 0
+
+
+def cmd_validate(ns: argparse.Namespace) -> int:
+ with open(ns.master, encoding="utf-8") as fh:
+ master = json.load(fh)
+ errors = validator.validate(master, _load_schema("projets_master.schema.json"))
+ if errors:
+ _eprint(f"❌ {ns.master} : {len(errors)} erreur(s)")
+ for e in errors:
+ _eprint(" ·", e)
+ return 1
+ _eprint(f"✅ {ns.master} conforme ({len(master.get('projets', []))} projet(s)).")
+ return 0
+
+
+def cmd_generate(ns: argparse.Namespace) -> int:
+ with open(ns.master, encoding="utf-8") as fh:
+ master = json.load(fh)
+ errors = validator.validate(master, _load_schema("projets_master.schema.json"))
+ if errors and not ns.force:
+ _eprint("❌ master non conforme — génération refusée (voir `validate`). --force pour outrepasser.")
+ return 1
+ html_out = generator.render_site(master)
+ if ns.out:
+ with open(ns.out, "w", encoding="utf-8") as fh:
+ fh.write(html_out)
+ _eprint(f"✅ site généré → {ns.out} ({len(master.get('projets', []))} projet(s))")
+ else:
+ sys.stdout.write(html_out)
+ return 0
+
+
+def cmd_run(ns: argparse.Namespace) -> int:
+ os.makedirs(ns.out_dir, exist_ok=True)
+ master = parser.build_master(ns.data_room, generated_at=ns.generated_at)
+ schema = _load_schema("projets_master.schema.json")
+ errors = validator.validate(master, schema)
+ master_path = os.path.join(ns.out_dir, "projets_master.json")
+ with open(master_path, "w", encoding="utf-8") as fh:
+ fh.write(json.dumps(master, ensure_ascii=False, indent=2) + "\n")
+ if errors:
+ _eprint(f"❌ master non conforme ({len(errors)} erreur(s)) — index NON généré.")
+ for e in errors:
+ _eprint(" ·", e)
+ return 1
+ html_out = generator.render_site(master)
+ index_path = os.path.join(ns.out_dir, "index.html")
+ with open(index_path, "w", encoding="utf-8") as fh:
+ fh.write(html_out)
+ _eprint(f"✅ pipeline OK — {len(master['projets'])} projet(s)")
+ _eprint(f" · {master_path}")
+ _eprint(f" · {index_path}")
+ return 0
+
+
+# --------------------------------------------------------------------------- #
+def build_argparser() -> argparse.ArgumentParser:
+ ap = argparse.ArgumentParser(prog="publiciste", description=__doc__.splitlines()[0])
+ sub = ap.add_subparsers(dest="cmd", required=True)
+
+ p = sub.add_parser("parse", help="data_room/ -> projets_master.json")
+ p.add_argument("data_room")
+ p.add_argument("-o", "--out")
+ p.add_argument("--generated-at", dest="generated_at", default=None)
+ p.add_argument("--force", action="store_true", help="écrire même si non conforme")
+ p.set_defaults(func=cmd_parse)
+
+ p = sub.add_parser("validate", help="valide un projets_master.json")
+ p.add_argument("master")
+ p.set_defaults(func=cmd_validate)
+
+ p = sub.add_parser("generate", help="projets_master.json -> index.html")
+ p.add_argument("master")
+ p.add_argument("-o", "--out")
+ p.add_argument("--force", action="store_true")
+ p.set_defaults(func=cmd_generate)
+
+ p = sub.add_parser("run", help="pipeline complet data_room/ -> out_dir/")
+ p.add_argument("data_room")
+ p.add_argument("-o", "--out-dir", dest="out_dir", default="build")
+ p.add_argument("--generated-at", dest="generated_at", default=None)
+ p.set_defaults(func=cmd_run)
+ return ap
+
+
+def main(argv: list[str] | None = None) -> int:
+ ns = build_argparser().parse_args(argv)
+ return ns.func(ns)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/05_deliverables_mvp/publiciste/templates/site_public.html.tmpl b/05_deliverables_mvp/publiciste/templates/site_public.html.tmpl
new file mode 100644
index 0000000..c54ae5a
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/templates/site_public.html.tmpl
@@ -0,0 +1,85 @@
+
+
+
+
+
+ {{TITRE}}
+
+
+
+
+
+
+ Helios RD
+ {{TAGLINE}}
+
+
+
+{{PROJETS}}
+
+
+
+
+
diff --git a/05_deliverables_mvp/publiciste/tests/test_publiciste.py b/05_deliverables_mvp/publiciste/tests/test_publiciste.py
new file mode 100644
index 0000000..bf62842
--- /dev/null
+++ b/05_deliverables_mvp/publiciste/tests/test_publiciste.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""Tests unitaires du Publiciste Agent (stdlib `unittest`, zéro dépendance).
+
+Exécution : `python3 -m unittest discover -s tests` depuis le dossier publiciste/,
+ou `python3 tests/test_publiciste.py`.
+
+Couvre : parsing chiffres, extraction faisabilité, rétrogradation défensive
+(anti-invention #6), conformité au schéma (validateur maison + oracle jsonschema
+si présent), rendu HTML (marque luxury #4, zéro prix inventé).
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(HERE) # dossier publiciste/
+sys.path.insert(0, ROOT)
+
+from lib import generator, parser, validator # noqa: E402
+
+FIXTURES = os.path.join(ROOT, "fixtures", "data_room")
+SCHEMA_DIR = os.path.normpath(os.path.join(ROOT, "..", "faisabilite"))
+FIXED_TS = "2026-07-30T00:00:00Z"
+
+
+def _schema(name: str) -> dict:
+ with open(os.path.join(SCHEMA_DIR, name), encoding="utf-8") as fh:
+ return json.load(fh)
+
+
+class TestNumberParsing(unittest.TestCase):
+ def test_prix_formats(self):
+ self.assertEqual(parser.parse_price("USD 150,000"), 150000.0)
+ self.assertEqual(parser.parse_price("210000"), 210000.0)
+ self.assertEqual(parser.parse_price("DOP 8 850 000"), 8850000.0)
+ self.assertEqual(parser.parse_price("1,250,000.50"), 1250000.50)
+
+ def test_absents_donnent_none(self):
+ for token in ["", "—", "-", "non défini", "n/d", "{{typo_1_prix_usd}}", "...", "TBD"]:
+ self.assertIsNone(parser.parse_price(token), f"{token!r} devrait être None")
+
+ def test_surfaces_et_entiers(self):
+ self.assertEqual(parser.parse_number("55"), 55.0)
+ self.assertEqual(parser.parse_number("3,5"), 3.5)
+ self.assertEqual(parser.parse_int("12 unités"), 12)
+ self.assertIsNone(parser.parse_int("non fourni"))
+
+
+class TestMarkdownTable(unittest.TestCase):
+ def test_find_table_ignore_placeholders(self):
+ md = (
+ "| Typologie | Prix « à partir de » (USD) |\n"
+ "|---|---|\n"
+ "| Studio | USD 100,000 |\n"
+ "| `{{typo_2_nom}}` | `{{typo_2_prix_usd}}` |\n"
+ )
+ typ = parser.parse_typologies(md)
+ self.assertEqual(len(typ), 1)
+ self.assertEqual(typ[0]["nom"], "Studio")
+ self.assertEqual(typ[0]["prix_depuis_usd"], 100000.0)
+
+
+class TestParserP01Complete(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.p = parser.parse_projet(os.path.join(FIXTURES, "P01"))
+
+ def test_statut_disponible(self):
+ self.assertEqual(self.p["statut"], "disponible")
+
+ def test_typologies_avec_prix(self):
+ self.assertEqual(len(self.p["typologies"]), 3)
+ for t in self.p["typologies"]:
+ self.assertIsInstance(t["prix_depuis_usd"], float)
+ self.assertEqual(self.p["typologies"][0]["prix_depuis_usd"], 150000.0)
+
+ def test_localisation_et_nom(self):
+ self.assertIn("Test Province", self.p["localisation"])
+ self.assertEqual(self.p["nom"], "Résidence Fixture Uno")
+
+ def test_services_inclus(self):
+ self.assertIn("Conciergerie 24/7", self.p["inclus"])
+ # amenities §3.2 complètent la liste
+ self.assertIn("Piscine à débordement", self.p["inclus"])
+
+ def test_positionnement_fr(self):
+ self.assertIn("fr", self.p.get("positionnement", {}))
+ self.assertIn("balnéaire", self.p["positionnement"]["fr"])
+
+ def test_rendus_hero(self):
+ heros = [r for r in self.p["rendus"] if r.get("hero")]
+ self.assertEqual(len(heros), 1)
+ self.assertEqual(heros[0]["fichier"], "hero_aerien.jpg")
+
+ def test_traçabilite_source(self):
+ self.assertEqual(self.p["source"]["score_4big"], 96)
+ self.assertIn("_META/version.json", "/".join(self.p["source"]["fichiers"]).replace(os.sep, "/"))
+
+
+class TestParserP02DefensiveDowngrade(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.p = parser.parse_projet(os.path.join(FIXTURES, "P02"))
+
+ def test_en_developpement(self):
+ # Prix manquants → jamais « disponible » (anti-invention #6).
+ self.assertEqual(self.p["statut"], "en_developpement")
+
+ def test_prix_none(self):
+ for t in self.p["typologies"]:
+ self.assertIsNone(t["prix_depuis_usd"])
+
+
+class TestSchemaConformance(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.master = parser.build_master(FIXTURES, generated_at=FIXED_TS)
+ cls.schema = _schema("projets_master.schema.json")
+
+ def test_validateur_maison(self):
+ errors = validator.validate(self.master, self.schema)
+ self.assertEqual(errors, [], f"erreurs schéma : {errors}")
+
+ def test_deux_projets_tries(self):
+ codes = [p["code"] for p in self.master["projets"]]
+ self.assertEqual(codes, ["P01", "P02"])
+
+ def test_oracle_jsonschema_si_present(self):
+ try:
+ import jsonschema
+ except ImportError:
+ self.skipTest("jsonschema non installé — oracle ignoré")
+ jsonschema.validate(self.master, self.schema) # lève si non conforme
+
+ def test_negatif_disponible_sans_prix_rejete(self):
+ # Un « disponible » avec prix null DOIT échouer (allOf if/then du schéma).
+ bad = {
+ "generated_at": FIXED_TS,
+ "template_version": "1.0.0",
+ "projets": [{
+ "code": "P03", "nom": "X", "statut": "disponible",
+ "localisation": "L",
+ "typologies": [{"nom": "T", "prix_depuis_usd": None}],
+ "inclus": [], "source": {"template_version": "1.0.0", "score_4big": 95},
+ }],
+ }
+ self.assertTrue(validator.validate(bad, self.schema), "devrait être NON conforme")
+
+ def test_validateur_maison_accord_oracle_sur_negatif(self):
+ try:
+ import jsonschema
+ except ImportError:
+ self.skipTest("jsonschema non installé")
+ bad = {
+ "generated_at": FIXED_TS, "template_version": "1.0.0",
+ "projets": [{
+ "code": "PX", "nom": "X", "statut": "disponible", "localisation": "L",
+ "typologies": [], "inclus": [],
+ "source": {"template_version": "1.0.0", "score_4big": 95},
+ }],
+ }
+ maison = bool(validator.validate(bad, self.schema))
+ oracle = False
+ try:
+ jsonschema.validate(bad, self.schema)
+ except jsonschema.ValidationError:
+ oracle = True
+ self.assertEqual(maison, oracle)
+
+
+class TestVersionSchema(unittest.TestCase):
+ def test_fixtures_version_json_conformes(self):
+ schema = _schema("version.schema.json")
+ for code in ("P01", "P02"):
+ path = os.path.join(FIXTURES, code, "_META", "version.json")
+ with open(path, encoding="utf-8") as fh:
+ doc = json.load(fh)
+ self.assertEqual(validator.validate(doc, schema), [], f"{code} version.json")
+
+
+class TestGenerator(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.master = parser.build_master(FIXTURES, generated_at=FIXED_TS)
+ cls.html = generator.render_site(cls.master)
+
+ def test_marque_luxury(self):
+ # Contrainte #4 : tokens dark+doré + typographies.
+ for token in ("#0a0a12", "#f0b429", "Fraunces", "Cormorant Garamond"):
+ self.assertIn(token, self.html, f"token de marque absent : {token}")
+
+ def test_html_valide_minimal(self):
+ self.assertIn("", self.html)
+ self.assertIn('lang="fr"', self.html)
+
+ def test_projet_disponible_affiche_prix(self):
+ self.assertIn("USD 150 000", self.html) # prix P01 formaté
+ self.assertIn("Studio", self.html)
+
+ def test_projet_incomplet_sans_prix_invente(self):
+ # Le bloc P02 doit afficher « Prochainement » et AUCUN prix.
+ self.assertIn("Prochainement", self.html)
+ p02 = self._article(self.html, "P02")
+ self.assertNotIn("USD", p02, "aucun prix ne doit apparaître pour P02")
+ self.assertNotIn("prix-depuis", p02)
+
+ @staticmethod
+ def _article(html: str, code: str) -> str:
+ start = html.index(f'data-code="{code}"')
+ end = html.index("", start)
+ return html[start:end]
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)