From f0a7d71357d822fe695c16808261f01de26fe5dc Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Thu, 30 Jul 2026 01:37:47 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker]=20Sprint=202=20=C2=B7=20Publiciste?= =?UTF-8?q?=20scaffold=20(parser=20faisabilit=C3=A9=20=E2=86=92=20JSON=20+?= =?UTF-8?q?=20generator=20+=20gate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Livrable Publiciste · Sprint 2 · Semaine 2 (seul module net-neuf · chemin critique · GAP_ANALYSIS §3.13). Cible portage VPS : otoia/capabilities/publiciste.py. - lib/parser.py : data_room/PXX/ (template v1.0) → projet dict conforme au contrat projets_master.schema.json (livré S1). Mapping colonnes par en-tête, parsing montants USD/DOP robuste. Anti-invention #6 : rétrogradation défensive « en_developpement » si prix USD manquant ; absent → null (jamais 0/inventé). - lib/validator.py : validateur JSON-Schema draft-07 (sous-ensemble) ZÉRO dépendance pip (runner Gitea sans pip). Oracle jsonschema en test si présent. - lib/generator.py + template + branding : rendu HTML luxury #4 (dark+doré, Fraunces + Cormorant Garamond) ; sans prix → « Prochainement · Détails à venir ». - publiciste.py : CLI parse/validate/generate/run. - fixtures/ : données SYNTHÉTIQUES de test (jamais publiées) P01 complète + P02 incomplète. - tests/ : 23 tests unittest (stdlib) verts. - CI : job publiciste-tests ajouté au gate (.gitea/workflows/ci.yml · Gitea #2). Vérifs (en-repo, sans VPS) : 23/23 tests verts · gate CI local vert (guard/JSON/ docs) · pipeline CLI produit un master conforme au schéma. Auto-score 4Big 95/100. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yml | 18 +- .../daily_reports/2026-07-30.md | 48 ++ 05_deliverables_mvp/publiciste/.gitignore | 4 + 05_deliverables_mvp/publiciste/README.md | 83 ++++ .../publiciste/fixtures/README.md | 16 + .../fixtures/data_room/P01/00_brief/brief.md | 7 + .../P01/20_architecture/architecture.md | 13 + .../paysage_experience.md | 12 + .../P01/40_llm_outputs/commercial.md | 8 + .../data_room/P01/60_photos_site/README.md | 10 + .../fixtures/data_room/P01/_META/version.json | 9 + .../fixtures/data_room/P02/00_brief/brief.md | 6 + .../P02/20_architecture/architecture.md | 10 + .../paysage_experience.md | 5 + .../fixtures/data_room/P02/_META/version.json | 9 + .../publiciste/lib/__init__.py | 11 + .../publiciste/lib/branding.py | 40 ++ .../publiciste/lib/generator.py | 162 +++++++ 05_deliverables_mvp/publiciste/lib/parser.py | 442 ++++++++++++++++++ .../publiciste/lib/validator.py | 159 +++++++ 05_deliverables_mvp/publiciste/publiciste.py | 160 +++++++ .../templates/site_public.html.tmpl | 85 ++++ .../publiciste/tests/test_publiciste.py | 219 +++++++++ 23 files changed, 1535 insertions(+), 1 deletion(-) create mode 100644 05_deliverables_mvp/publiciste/.gitignore create mode 100644 05_deliverables_mvp/publiciste/README.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/README.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P01/00_brief/brief.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P01/20_architecture/architecture.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P01/30_paysage_experience/paysage_experience.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P01/40_llm_outputs/commercial.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P01/60_photos_site/README.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P01/_META/version.json create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P02/00_brief/brief.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P02/20_architecture/architecture.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P02/30_paysage_experience/paysage_experience.md create mode 100644 05_deliverables_mvp/publiciste/fixtures/data_room/P02/_META/version.json create mode 100644 05_deliverables_mvp/publiciste/lib/__init__.py create mode 100644 05_deliverables_mvp/publiciste/lib/branding.py create mode 100644 05_deliverables_mvp/publiciste/lib/generator.py create mode 100644 05_deliverables_mvp/publiciste/lib/parser.py create mode 100644 05_deliverables_mvp/publiciste/lib/validator.py create mode 100644 05_deliverables_mvp/publiciste/publiciste.py create mode 100644 05_deliverables_mvp/publiciste/templates/site_public.html.tmpl create mode 100644 05_deliverables_mvp/publiciste/tests/test_publiciste.py 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' + " \n" + + "\n".join(rows) + + "\n
TypologieSurfaceÀ partir de
" + ) + + +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)