From 911f70b9ab38083c8c5659a64a37f46c7f29fa0f Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Thu, 30 Jul 2026 02:37:47 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker]=20Sprint=202=20=C2=B7=20Faisabilit?= =?UTF-8?q?=C3=A9=20g=C3=A9n=C3=A9rateur=204=20volets=20(brief=E2=86=92dat?= =?UTF-8?q?a=5Froom=20+=20round-trip=20Publiciste=20+=20gate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Générateur brief JSON → data_room/PXX/ (template canonique v1.0) : - genlib/{model,scorer,renderer}.py : params canoniques imposés (#9/#10), barème 4Big §7, rendu aligné sur le parser Publiciste (round-trip garanti) - faisabilite_gen.py : CLI score/scaffold/generate/batch ; axe machine-lisible PROUVÉ (re-parse Publiciste + validation version.json), jamais auto-décerné - Anti-invention #6 : champ 🔴 absent → placeholder + champs_manquants + rétrogradation statut ; prix manquant → {{…}} (jamais 0) - 16 tests unittest (stdlib, réutilise parser+validateur Publiciste · #5) + oracle jsonschema ; job CI faisabilite-gen-tests dans le gate (Gitea only #2) - brief.schema.json + fixtures synthétiques ; README ; GAP §2 mis à jour Boucle de valeur fermée : generator → data_room → Publiciste → projets_master.json Auto-score 4Big : 96/100. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yml | 18 +- 05_deliverables_mvp/GAP_ANALYSIS_SPRINT1.md | 4 +- .../daily_reports/2026-07-30.md | 50 +++ .../faisabilite/generator/.gitignore | 5 + .../faisabilite/generator/README.md | 112 +++++++ .../faisabilite/generator/brief.schema.json | 105 ++++++ .../faisabilite/generator/faisabilite_gen.py | 243 ++++++++++++++ .../generator/fixtures/brief_complete.json | 95 ++++++ .../generator/fixtures/brief_incomplete.json | 53 +++ .../faisabilite/generator/genlib/__init__.py | 18 + .../faisabilite/generator/genlib/model.py | 113 +++++++ .../faisabilite/generator/genlib/renderer.py | 310 ++++++++++++++++++ .../faisabilite/generator/genlib/scorer.py | 176 ++++++++++ .../generator/tests/test_generator.py | 243 ++++++++++++++ 14 files changed, 1542 insertions(+), 3 deletions(-) create mode 100644 05_deliverables_mvp/faisabilite/generator/.gitignore create mode 100644 05_deliverables_mvp/faisabilite/generator/README.md create mode 100644 05_deliverables_mvp/faisabilite/generator/brief.schema.json create mode 100644 05_deliverables_mvp/faisabilite/generator/faisabilite_gen.py create mode 100644 05_deliverables_mvp/faisabilite/generator/fixtures/brief_complete.json create mode 100644 05_deliverables_mvp/faisabilite/generator/fixtures/brief_incomplete.json create mode 100644 05_deliverables_mvp/faisabilite/generator/genlib/__init__.py create mode 100644 05_deliverables_mvp/faisabilite/generator/genlib/model.py create mode 100644 05_deliverables_mvp/faisabilite/generator/genlib/renderer.py create mode 100644 05_deliverables_mvp/faisabilite/generator/genlib/scorer.py create mode 100644 05_deliverables_mvp/faisabilite/generator/tests/test_generator.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 3d8ef1c..64b1e9f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -74,10 +74,26 @@ jobs: - name: Tests contrat RBAC run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Générateur Faisabilité 4 volets (Sprint 2 · Faisabilité) : brief.json → + # data_room/PXX/ (template v1.0). Le round-trip via le parser Publiciste est + # testé ici (générateur → parser → schéma). stdlib pur, aucune install pip. + # -------------------------------------------------------------------------- + faisabilite-gen-tests: + name: Faisabilité · générateur 4 volets + round-trip (unittest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/faisabilite/generator + steps: + - uses: actions/checkout@v4 + - name: Tests générateur Faisabilité + 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, publiciste-tests, rbac-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_deliverables_mvp/GAP_ANALYSIS_SPRINT1.md b/05_deliverables_mvp/GAP_ANALYSIS_SPRINT1.md index 3886750..51bca1d 100644 --- a/05_deliverables_mvp/GAP_ANALYSIS_SPRINT1.md +++ b/05_deliverables_mvp/GAP_ANALYSIS_SPRINT1.md @@ -71,8 +71,8 @@ et la cible plancher **4-5 semaines**. - ✅ P01, P08, P09 : prix documentés dans `commercial.md`. - ⚠ **P02, P03, P05, P07 : « prix non défini » / « typologie non fournie »** — faisabilités hétérogènes, templates différents. - **Cause** : générations avec templates non versionnés / anciens. -- **À faire** : template canonique v1.0 → régénération des 7 faisabilités au même standard → versioning `_META/version.json` → archives auto → trigger sur update template. -- **Sprint** : S1 (template + scaffold) → S4 (régénération batch) → S5 (validation 4Big). +- **À faire** : ~~template canonique v1.0~~ ✅ **livré S1** → ~~générateur 4 volets~~ ✅ **livré S2** (`faisabilite/generator/` : brief JSON → `data_room/PXX/` template v1.0 + scoring §7 + `version.json` ; **round-trip prouvé** vers le parser Publiciste ; 16 tests · job CI `faisabilite-gen-tests`) → régénération des 7 faisabilités réelles au même standard (S4 · VPS) → archives auto (S3) → trigger sur update template (S6 · VPS). +- **Sprint** : ✅ S1 (template + scaffold) · ✅ S2 (générateur 4 volets) → S4 (régénération batch données réelles) → S5 (validation 4Big). ### 3 · Rendu Agent — 🟡 Refactor (gain ~40 %) - **Existe** : `scripts/gen_renders_flux.py`, `executive_hero_flux.py`, modèle Flux local, archives `data_room/*/60_photos_site/`. diff --git a/05_deliverables_mvp/daily_reports/2026-07-30.md b/05_deliverables_mvp/daily_reports/2026-07-30.md index 918f00e..72a0a78 100644 --- a/05_deliverables_mvp/daily_reports/2026-07-30.md +++ b/05_deliverables_mvp/daily_reports/2026-07-30.md @@ -185,3 +185,53 @@ Rapport écrit dans `05_deliverables_mvp/daily_reports/` (chemin canonique fixé --- **Auto-score 4Big du livrable RBAC 50 rôles : 95/100.** Réserve −5 : application fixtures + DocTypes `custom` + `User Permission` = côté VPS (agent ERPNext, hors périmètre) ; plafonds monétaires non chiffrés (anti-invention #6). Validé statiquement en-repo (10 tests verts + schéma conforme + gate CI vert). + +--- + +# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 5) + +**Session** : `20260730_022639` + +## Tâche exécutée +**Sprint 2 · Livrable Faisabilité « Générateur 4 volets automatique »** +(roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 2/3 · `03_agents/faisabilite/AGENT.md` §Livrable S2 · `GAP_ANALYSIS_SPRINT1` §2 · suggérée par sessions 3-4). + +## Contexte / analyse +- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, `GAP_ANALYSIS_SPRINT1.md`, daily reports sessions 1-4, `AGENT.md` Faisabilité, template canonique v1.0 + `version.schema.json`, **parser Publiciste** (`lib/parser.py`) + `publiciste.py`. +- **Sprint 1 bouclé côté repo** ; Sprint 2 : RBAC (session 4) + Publiciste parser (session 3) livrés. Frontend/CRM dépendent des layouts LIVE (VPS · hors périmètre). +- Priorité évidente : **Générateur Faisabilité 4 volets** — sur le **chemin critique**, produit les `data_room/PXX/` que le parser Publiciste (déjà livré) consomme. **Ferme la boucle de valeur** et 100 % autorable en-repo (zéro API, zéro VPS). Vérifiable end-to-end : générateur → parser → schéma. + +## Réalisé — module `05_deliverables_mvp/faisabilite/generator/` +- `genlib/model.py` — constantes **canoniques** (3 %/8.5 %/52 % · USD+DOP · Cardnet · Letter US · #9/#10, **imposées, jamais issues du brief**) + tables des champs 🔴 par volet + `is_filled()` (détecte placeholders/absences). +- `genlib/scorer.py` — **barème 4Big §7** (5 axes × 20) : complétude, anti-gap prix (§3.2), traçabilité (`sources[]` + taux daté/sourcé), paramètres canoniques, machine-lisible. Dérive `statut_faisabilite` / `volets_complets` / `champs_manquants` cohérents avec la contrainte `allOf` de `version.schema.json`. +- `genlib/renderer.py` — rend les 4 volets + brief + commercial + manifeste photos + `_META/version.json`, avec **en-têtes/colonnes exactement alignés sur le parser Publiciste** (garantit le round-trip). Champ absent → placeholder `{{…}}` (**jamais de chiffre inventé · #6**). +- `faisabilite_gen.py` — CLI `score` / `scaffold` / `generate` / `batch`. L'axe machine-lisible n'est **pas auto-décerné** : après génération, le CLI **re-parse** le projet avec le parser Publiciste + **valide** `version.json` → en cas d'échec, retire les 20 pts, réécrit `version.json`, sort en erreur. +- `brief.schema.json` (contrat d'entrée) + `fixtures/` **synthétiques** (`brief_complete.json` → complete 100/100 ; `brief_incomplete.json` → prix absents → rétrogradation `en_developpement`, prix restés placeholders). +- `tests/test_generator.py` — **16 tests `unittest` (stdlib pur, zéro pip)** : scoring, rétrogradation défensive, paramètres canoniques, et **round-trip end-to-end** generator → parser Publiciste → `projets_master.json` validé contre le schéma. **Réutilise** parser + validateur Publiciste (aucune duplication · workflow #5) + oracle `jsonschema` si présent. +- **CI** : job `faisabilite-gen-tests` ajouté au **gate** de `.gitea/workflows/ci.yml` (Gitea Actions uniquement · #2). +- `README.md` + `.gitignore` ; `GAP_ANALYSIS_SPRINT1.md §2` mis à jour (générateur livré S2). + +## Anti-invention (#6) appliqué +- Paramètres canoniques émis par le générateur (jamais du brief) → impossibles à fausser projet par projet. +- Tout 🔴 absent → placeholder + `champs_manquants` + rétrogradation ; prix manquant → `{{…}}` (jamais `0`). +- Fixtures **clairement synthétiques** (`"synthetique": true` + bannière « ne jamais publier »). + +## Vérifications effectuées (en-repo, sans toucher au VPS) +- **16/16 tests verts** (schéma maison **+** oracle `jsonschema` → concordance draft-07 confirmée). +- **Pipeline complet démontré en CLI** : `batch fixtures` → `data_room/{P01,P02}/` → **Publiciste `run`** → `projets_master.json` (2 projets : P01 `disponible`/3 typo, P02 `en_developpement`/2 typo) + `index.html`. Round-trip cohérent de bout en bout. +- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh` (inclut `brief.schema.json` + fixtures), `check_docs.sh` (0 lien cassé), YAML `ci.yml` valide. +- **Régression** : 23/23 tests Publiciste + 10/10 tests RBAC toujours verts (réutilisation sans effet de bord). + +## Non fait (hors périmètre worker · VPS ou calendrier ultérieur) +- Génération contre les **briefs réels** P01-P09 (VPS · Faisabilité) — régénération batch des 7 faisabilités = **S4**. +- `40_llm_outputs/` marché/directeur/fiscal/juridique + rapports bancables FR/EN/ES + archives auto `_ARCHIVES/` (S3+). +- Trigger `otoia-faisabilite.timer` + notification Publiciste/WhatsApp (S6 · VPS). + +## Prochaine tâche suggérée +- Faisabilité S3 : générer les `40_llm_outputs/` + rapports bancables FR/EN/ES depuis le brief (étend le renderer). +- Ou ERPNext S2 : générateur de fixtures `rbac_50_roles.json → fixtures/` (même patron, autorable en-repo). +- Ou Publiciste S3 : brancher le generator HTML sur un layout proche de `/waf-home` (dépend layout LIVE · VPS). + +--- + +**Auto-score 4Big du livrable Générateur Faisabilité : 96/100.** Réserve −4 : exécution contre les briefs réels P01-P09 différée (VPS · S4) ; `40_llm_outputs`/bancables/archives = livrables S3+. Validé statiquement en-repo (16 tests verts + round-trip end-to-end démontré + gate CI vert). diff --git a/05_deliverables_mvp/faisabilite/generator/.gitignore b/05_deliverables_mvp/faisabilite/generator/.gitignore new file mode 100644 index 0000000..68f1134 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/.gitignore @@ -0,0 +1,5 @@ +# Artefacts de génération locale (jamais commités — produits à la demande). +__pycache__/ +*.pyc +build/ +out/ diff --git a/05_deliverables_mvp/faisabilite/generator/README.md b/05_deliverables_mvp/faisabilite/generator/README.md new file mode 100644 index 0000000..0e19b4e --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/README.md @@ -0,0 +1,112 @@ +# Générateur Faisabilité 4 volets · Sprint 2 + +> Livrable **Faisabilité · Sprint 2** — `03_agents/faisabilite/AGENT.md` §Livrable S2 +> (« Générateur 4 volets automatique »). +> Cible de portage VPS : `otoia/capabilities/faisabilite_agent.py`. + +Produit une faisabilité canonique `data_room/PXX/` (conforme au +[`TEMPLATE_FAISABILITE_CANONIQUE_v1.0.md`](../TEMPLATE_FAISABILITE_CANONIQUE_v1.0.md)) +à partir d'un **brief JSON sourcé**, puis **prouve** que le résultat est +consommable par le Publiciste. + +## Place dans la chaîne de valeur + +``` +brief.json ──► faisabilite_gen ──► data_room/PXX/ (template v1.0) + │ + ▼ (module Publiciste, déjà livré) + publiciste.parser ──► projets_master.json ──► site +``` + +Le générateur ferme la boucle amont du Publiciste : ce dernier ne fait que +**publier** ; c'est ici que les 4 volets sont **produits** et **notés**. + +## Zéro invention de chiffres (CLAUDE.md #6) + +Principe défensif de bout en bout : + +- Les **paramètres canoniques** (3 % édition · 8.5 % marketing · 52 % point + d'équilibre · USD+DOP · Cardnet · Letter US, CLAUDE.md #9/#10) sont **imposés + par le générateur** — ils ne proviennent jamais du brief, donc ne peuvent être + faussés projet par projet. +- Tout **champ 🔴 absent** du brief est rendu en placeholder `{{…}}`, listé dans + `champs_manquants`, et **rétrograde** le statut (jamais « complete » douteux). +- Un prix manquant reste `{{…}}` (jamais `0`, jamais une valeur inventée) → le + Publiciste publie le projet « En développement ». + +## Scoring 4Big (§7 du template · 5 axes × 20) + +| Axe | Source | Calcul | +|---|---|---| +| Complétude | brief | fraction des champs 🔴 des 4 volets remplis | +| Anti-gap prix | brief §3.2 | fraction des cellules typologies renseignées | +| Traçabilité | brief | `sources[]` + `taux_conversion{date,source}` | +| Paramètres canoniques | générateur | 20 (garanti · vérifié dans le rendu) | +| Machine-lisible | **preuve** | `version.json` valide **+** parser Publiciste OK | + +L'axe machine-lisible n'est **pas auto-décerné** : le CLI re-parse le projet +généré avec le parser Publiciste et valide `version.json` contre +[`version.schema.json`](../version.schema.json). En cas d'échec, les 20 points +sont retirés, `version.json` réécrit, et la commande sort en erreur. + +Statut « complete » ⇔ score ≥ 95 **et** aucun champ 🔴 manquant **et** bloc prix +intégral **et** 4 volets — cohérent avec la contrainte `allOf` de +`version.schema.json`. + +## Utilisation + +```bash +# Bilan 4Big d'un brief (sans rien écrire) +python3 faisabilite_gen.py score fixtures/brief_complete.json + +# Générer une faisabilité (+ preuve machine-lisible) +python3 faisabilite_gen.py generate fixtures/brief_complete.json -o /tmp/data_room + +# Amorcer un nouveau projet (arborescence vide à placeholders) +python3 faisabilite_gen.py scaffold P07 -o /tmp/data_room + +# Générer tous les briefs d'un dossier +python3 faisabilite_gen.py batch fixtures -o /tmp/data_room +``` + +Puis, via le module Publiciste, publier : + +```bash +cd ../../publiciste +python3 publiciste.py run /tmp/data_room -o /tmp/site +``` + +`--generated-at` fige l'horodatage (sorties déterministes / tests). + +## Contrat d'entrée + +Voir [`brief.schema.json`](./brief.schema.json). Les fixtures sont **synthétiques** +(`"synthetique": true`, bannière « ne jamais publier ») : + +- `fixtures/brief_complete.json` → statut `complete`, score 100/100. +- `fixtures/brief_incomplete.json` → prix absents → rétrogradation + `en_developpement`, prix restés en placeholders (démontre l'anti-invention). + +## Tests + +```bash +python3 -m unittest discover -s tests -v +``` + +16 tests `unittest` (stdlib pur, **zéro pip**) : scoring, rétrogradation, +paramètres canoniques, et **round-trip end-to-end** generator → parser Publiciste +→ `projets_master.json` validé contre le schéma. Réutilise le parser + le +validateur du Publiciste (aucune duplication · workflow #5). Un oracle +`jsonschema` est activé en plus s'il est installé. + +Job CI : `faisabilite-gen-tests` (gate `.gitea/workflows/ci.yml` · Gitea Actions +uniquement · contrainte #2). + +## Hors périmètre worker (VPS / calendrier ultérieur) + +- Application sur les **données réelles** `data_room/` (VPS · Faisabilité/ERPNext). +- Archives auto `_ARCHIVES/PXX_v{N}_YYYYMMDD/` sur bump MAJOR du template (S3/S4). +- Génération des `40_llm_outputs/` marché/directeur/fiscal/juridique + rapports + bancables FR/EN/ES (S3+ ; seuls brief/masterplan/architecture/paysage/ + ingénierie/commercial sont produits ici). +- Trigger `otoia-faisabilite.timer` + notification Publiciste/WhatsApp (S6 · VPS). diff --git a/05_deliverables_mvp/faisabilite/generator/brief.schema.json b/05_deliverables_mvp/faisabilite/generator/brief.schema.json new file mode 100644 index 0000000..d16f520 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/brief.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oto.dtp/schemas/faisabilite/brief.schema.json", + "title": "Brief d'entrée du générateur Faisabilité 4 volets", + "description": "Données projet SOURCÉES fournies au générateur. Le générateur ne fabrique aucun chiffre (#6) : tout champ absent devient un placeholder et rétrograde le statut. Les paramètres canoniques (3%/8.5%/52% · USD+DOP · Cardnet · Letter US) ne figurent PAS ici — ils sont imposés par le générateur.", + "type": "object", + "required": ["projet"], + "additionalProperties": true, + "properties": { + "projet": { "type": "string", "pattern": "^P0[1-9]$" }, + "nom": { "type": "string" }, + "synthetique": { "type": "boolean", "description": "true = fixture de test, jamais publiable." }, + "sources": { + "type": "array", + "items": { "type": "string" }, + "description": "Références archives justifiant les chiffres (axe Traçabilité §7)." + }, + "masterplan": { + "type": "object", + "properties": { + "localisation": { "type": "string" }, + "terrain_m2": { "type": ["number", "null"] }, + "terrain_tareas": { "type": ["number", "null"] }, + "zonage": { "type": "string" }, + "nb_unites": { "type": ["integer", "null"] }, + "nb_phases": { "type": ["integer", "null"] }, + "cos": { "type": "string" }, + "acces": { "type": "string" }, + "ref_plan_masse": { "type": "string" }, + "phasage": { "type": "string" } + } + }, + "architecture": { + "type": "object", + "properties": { + "parti_archi": { "type": "string" }, + "nb_batiments": { "type": ["integer", "null"] }, + "nb_niveaux": { "type": ["integer", "null"] }, + "ref_rendus": { "type": "string" }, + "taux_conversion": { + "type": "object", + "properties": { + "taux": { "type": ["number", "null"] }, + "date": { "type": "string" }, + "source": { "type": "string" } + } + }, + "typologies": { + "type": "array", + "items": { + "type": "object", + "properties": { + "nom": { "type": "string" }, + "quantite": { "type": ["integer", "null"] }, + "surface_interieure_m2": { "type": ["number", "null"] }, + "surface_terrasse_m2": { "type": ["number", "null"] }, + "surface_totale_m2": { "type": ["number", "null"] }, + "prix_usd": { "type": ["number", "null"] }, + "prix_dop": { "type": ["number", "null"] } + } + } + } + } + }, + "paysage": { + "type": "object", + "properties": { + "concept_paysage": { "type": "string" }, + "amenities": { "type": "array", "items": { "type": "string" } }, + "services_inclus": { "type": "array", "items": { "type": "string" } }, + "experience": { "type": "string" }, + "durabilite": { "type": "string" } + } + }, + "ingenierie": { + "type": "object", + "properties": { + "structure": { "type": "string" }, + "reseaux": { "type": "string" }, + "contraintes_site": { "type": "string" }, + "cout_construction_usd": { "type": ["number", "null"] }, + "revenu_brut_usd": { "type": ["number", "null"] }, + "marge_pct": { "type": ["number", "null"] }, + "regime_fiscal": { "type": "string" } + } + }, + "commercial": { + "type": "object", + "properties": { + "positionnement_fr": { "type": "string" } + } + }, + "rendus": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fichier": { "type": "string" }, + "vue": { "type": "string" }, + "hero": { "type": "boolean" } + } + } + } + } +} diff --git a/05_deliverables_mvp/faisabilite/generator/faisabilite_gen.py b/05_deliverables_mvp/faisabilite/generator/faisabilite_gen.py new file mode 100644 index 0000000..7f9532d --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/faisabilite_gen.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Générateur Faisabilité 4 volets · orchestrateur CLI (Sprint 2). + +Cible de portage VPS : `otoia/capabilities/faisabilite_agent.py` +(cf. 03_agents/faisabilite/AGENT.md §Livrable S2). + +Produit une faisabilité canonique `data_room/PXX/` (template v1.0) à partir d'un +brief JSON, puis PROUVE que le résultat est consommable par le Publiciste +(re-parse + validation `version.json`) — sinon échoue et retire le score de l'axe +machine-lisible (jamais de score auto-décerné à l'aveugle). + +Sous-commandes : + score BRIEF.json → bilan 4Big (§7) sur stdout + scaffold PXX -o OUT → arborescence vide (placeholders) d'un projet + generate BRIEF.json -o OUT → data_room/PXX/ + preuve machine-lisible + batch BRIEFS_DIR -o DATA_ROOM → génère tous les briefs d'un dossier + +⚠ Ce worker n'écrit JAMAIS sur le VPS. Les artefacts sont produits en local ; +l'application réelle (copie data_room/ VPS + archives + notif) est un livrable +ultérieur côté serveur (AGENT.md §Workflow, Semaines 3-4). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from datetime import datetime, timezone + +_HERE = os.path.dirname(os.path.abspath(__file__)) +# Ordre voulu sur sys.path : d'abord le générateur (paquet `genlib`), puis le +# Publiciste (paquet `lib`). Les deux noms sont distincts → aucune collision. +sys.path.insert(0, os.path.normpath(os.path.join(_HERE, "..", "..", "publiciste"))) +sys.path.insert(0, _HERE) + +from genlib import renderer, scorer # noqa: E402 + +# Réutilisation (workflow #5 : zéro duplication) du parser + validateur Publiciste. +from lib import parser as pub_parser # type: ignore # noqa: E402 +from lib import validator as pub_validator # type: ignore # noqa: E402 + +_SCHEMA_DIR = os.path.normpath(os.path.join(_HERE, "..")) + + +def _eprint(*args) -> None: + print(*args, file=sys.stderr) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _load_version_schema() -> dict: + with open(os.path.join(_SCHEMA_DIR, "version.schema.json"), encoding="utf-8") as fh: + return json.load(fh) + + +def _validate_brief(brief: dict) -> None: + """Garde-fou minimal : le brief DOIT porter un code projet canonique.""" + code = brief.get("projet") + if not (isinstance(code, str) and len(code) == 3 and code[:2] == "P0" + and code[2] in "123456789"): + raise ValueError(f"brief.projet invalide (attendu P01..P09) : {code!r}") + + +# --------------------------------------------------------------------------- # +def _generate_tree(brief: dict, out_dir: str, generated_at: str) -> tuple[str, dict]: + """Écrit l'arborescence PXX/ et retourne (chemin_projet, bilan) avec l'axe + machine-lisible PROUVÉ (re-parse Publiciste + validation version.json).""" + _validate_brief(brief) + code = brief["projet"] + + # 1er passage : bilan optimiste (axe 5 = 20 par construction). + bilan = scorer.score(brief, machine_lisible_pts=20) + + projet_dir = os.path.join(out_dir, code) + files = renderer.render_files(brief, bilan, generated_at) + for rel, content in files.items(): + dest = os.path.join(projet_dir, rel) + os.makedirs(os.path.dirname(dest), exist_ok=True) + with open(dest, "w", encoding="utf-8") as fh: + fh.write(content) + + # PREUVE axe 5 — machine-lisible : + # (a) version.json conforme à version.schema.json + # (b) commodité canonique présente dans le volet ingénierie (#9/#10) + # (c) parser Publiciste consomme le projet sans erreur et retrouve le code + machine_ok, motifs = _prove_machine_lisible(projet_dir, code, files) + + if not machine_ok: + # Honnêteté (#6) : on retire les 20 pts et on réécrit version.json. + bilan = scorer.score(brief, machine_lisible_pts=0) + version = renderer.render_version_json(brief, bilan, generated_at) + with open(os.path.join(projet_dir, "_META", "version.json"), + "w", encoding="utf-8") as fh: + fh.write(json.dumps(version, ensure_ascii=False, indent=2) + "\n") + bilan["_machine_lisible_motifs"] = motifs + + return projet_dir, bilan + + +def _prove_machine_lisible(projet_dir: str, code: str, + files: dict) -> tuple[bool, list[str]]: + motifs: list[str] = [] + + # (a) version.json valide. + with open(os.path.join(projet_dir, "_META", "version.json"), encoding="utf-8") as fh: + version = json.load(fh) + errs = pub_validator.validate(version, _load_version_schema()) + if errs: + motifs += [f"version.json: {e}" for e in errs] + + # (b) paramètres canoniques présents dans le volet ingénierie. + ing_md = files[os.path.join("40_ingenierie_faisabilite", + "ingenierie_faisabilite.md")] + manquants = renderer.check_canonical(ing_md) + if manquants: + motifs.append("paramètres canoniques absents : " + ", ".join(manquants)) + + # (c) le parser Publiciste consomme le projet sans erreur. + try: + projet = pub_parser.parse_projet(projet_dir) + if projet.get("code") != code: + motifs.append(f"parser: code {projet.get('code')!r} != {code!r}") + except Exception as exc: # noqa: BLE001 — on veut la raison exacte + motifs.append(f"parser a levé : {exc!r}") + + return (not motifs), motifs + + +# --------------------------------------------------------------------------- # +def cmd_score(ns: argparse.Namespace) -> int: + with open(ns.brief, encoding="utf-8") as fh: + brief = json.load(fh) + _validate_brief(brief) + bilan = scorer.score(brief) + print(json.dumps(bilan, ensure_ascii=False, indent=2)) + return 0 + + +def cmd_scaffold(ns: argparse.Namespace) -> int: + """Arborescence vide (placeholders) — utile pour amorcer un nouveau projet.""" + brief = {"projet": ns.projet, "synthetique": False} + generated_at = ns.generated_at or _now_iso() + projet_dir, bilan = _generate_tree(brief, ns.out, generated_at) + _eprint(f"✅ scaffold {ns.projet} → {projet_dir} " + f"(statut={bilan['statut_faisabilite']}, score={bilan['score_4big']})") + _eprint(f" {len(bilan['champs_manquants'])} champ(s) 🔴 à renseigner.") + return 0 + + +def cmd_generate(ns: argparse.Namespace) -> int: + with open(ns.brief, encoding="utf-8") as fh: + brief = json.load(fh) + generated_at = ns.generated_at or _now_iso() + projet_dir, bilan = _generate_tree(brief, ns.out, generated_at) + + _eprint(f"✅ {brief['projet']} → {projet_dir}") + _eprint(f" statut={bilan['statut_faisabilite']} · score 4Big={bilan['score_4big']}/100") + _eprint(f" axes={bilan['axes']}") + if bilan["champs_manquants"]: + _eprint(f" champs 🔴 manquants ({len(bilan['champs_manquants'])}) :") + for c in bilan["champs_manquants"]: + _eprint(" ·", c) + motifs = bilan.get("_machine_lisible_motifs") + if motifs: + _eprint("❌ preuve machine-lisible ÉCHOUÉE (axe 5 = 0) :") + for m in motifs: + _eprint(" ·", m) + return 1 + + # Un statut « complete » DOIT être publiable (#5) — sinon on alerte. + if bilan["statut_faisabilite"] == "complete" and bilan["score_4big"] < 95: + _eprint("❌ incohérence : complete avec score < 95.") + return 1 + return 0 + + +def cmd_batch(ns: argparse.Namespace) -> int: + generated_at = ns.generated_at or _now_iso() + briefs = sorted( + f for f in os.listdir(ns.briefs_dir) + if f.endswith(".json") and not f.startswith(".") + ) + if not briefs: + _eprint(f"⚠ aucun brief *.json dans {ns.briefs_dir}") + return 1 + rc = 0 + for fn in briefs: + with open(os.path.join(ns.briefs_dir, fn), encoding="utf-8") as fh: + brief = json.load(fh) + try: + projet_dir, bilan = _generate_tree(brief, ns.out, generated_at) + except Exception as exc: # noqa: BLE001 + _eprint(f"❌ {fn} : {exc}") + rc = 1 + continue + flag = "⚠" if bilan.get("_machine_lisible_motifs") else "✅" + _eprint(f"{flag} {brief.get('projet')} · {bilan['statut_faisabilite']} · " + f"{bilan['score_4big']}/100 · {len(bilan['champs_manquants'])} 🔴") + if bilan.get("_machine_lisible_motifs"): + rc = 1 + return rc + + +# --------------------------------------------------------------------------- # +def build_argparser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser(prog="faisabilite_gen", + description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + + p = sub.add_parser("score", help="brief.json → bilan 4Big") + p.add_argument("brief") + p.set_defaults(func=cmd_score) + + p = sub.add_parser("scaffold", help="PXX → arborescence vide (placeholders)") + p.add_argument("projet") + p.add_argument("-o", "--out", required=True, help="dossier data_room de sortie") + p.add_argument("--generated-at", dest="generated_at", default=None) + p.set_defaults(func=cmd_scaffold) + + p = sub.add_parser("generate", help="brief.json → data_room/PXX/ (+ preuve)") + p.add_argument("brief") + p.add_argument("-o", "--out", required=True, help="dossier data_room de sortie") + p.add_argument("--generated-at", dest="generated_at", default=None) + p.set_defaults(func=cmd_generate) + + p = sub.add_parser("batch", help="dossier de briefs → data_room/") + p.add_argument("briefs_dir") + p.add_argument("-o", "--out", required=True) + p.add_argument("--generated-at", dest="generated_at", default=None) + p.set_defaults(func=cmd_batch) + 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/faisabilite/generator/fixtures/brief_complete.json b/05_deliverables_mvp/faisabilite/generator/fixtures/brief_complete.json new file mode 100644 index 0000000..3d99a46 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/fixtures/brief_complete.json @@ -0,0 +1,95 @@ +{ + "projet": "P01", + "nom": "Résidence Fixture Alpha", + "synthetique": true, + "sources": [ + "SYNTHÉTIQUE — archive fictive brief_alpha_2026.pdf", + "SYNTHÉTIQUE — taux BCRD fictif 2026-07-01" + ], + "masterplan": { + "localisation": "Province Fixture · Municipalité Alpha · 19.0000, -70.0000", + "terrain_m2": 12000, + "terrain_tareas": 190, + "zonage": "Résidentiel touristique", + "nb_unites": 40, + "nb_phases": 2, + "cos": "0.35", + "acces": "Route principale à 5 min · aéroport 40 min · plage 3 min", + "ref_plan_masse": "plan_masse_alpha_v3.dwg", + "phasage": "Phase 1 (24 unités) puis Phase 2 (16 unités) au seuil 52 % pré-vente" + }, + "architecture": { + "parti_archi": "Contemporain tropical, matérialité pierre + bois local", + "nb_batiments": 3, + "nb_niveaux": 4, + "ref_rendus": "60_photos_site/", + "taux_conversion": { + "taux": 59.0, + "date": "2026-07-01", + "source": "SYNTHÉTIQUE — BCRD fictif" + }, + "typologies": [ + { + "nom": "Studio", + "quantite": 12, + "surface_interieure_m2": 45, + "surface_terrasse_m2": 10, + "surface_totale_m2": 55, + "prix_usd": 150000, + "prix_dop": 8850000 + }, + { + "nom": "1 Chambre", + "quantite": 20, + "surface_interieure_m2": 68, + "surface_terrasse_m2": 15, + "surface_totale_m2": 83, + "prix_usd": 210000, + "prix_dop": 12390000 + }, + { + "nom": "2 Chambres", + "quantite": 8, + "surface_interieure_m2": 95, + "surface_terrasse_m2": 25, + "surface_totale_m2": 120, + "prix_usd": 320000, + "prix_dop": 18880000 + } + ] + }, + "paysage": { + "concept_paysage": "Jardins tropicaux étagés, plans d'eau et circulations douces piétonnes", + "amenities": [ + "Piscine à débordement", + "Beach club", + "Spa & gym" + ], + "services_inclus": [ + "Conciergerie 24/7", + "Sécurité périmétrique", + "Gestion locative optionnelle", + "Régime CONFOTUR (exonération fiscale)", + "Structure Fideicomiso" + ], + "experience": "Parcours investisseur clé-en-main, de la visite à la mise en location", + "durabilite": "Panneaux solaires, récupération d'eau de pluie, éclairage LED" + }, + "ingenierie": { + "structure": "Béton armé, fondations superficielles sur bon sol", + "reseaux": "Raccordement réseau + forage d'appoint + station eaux usées", + "contraintes_site": "Topographie douce, aucun risque d'inondation recensé", + "cout_construction_usd": 9000000, + "revenu_brut_usd": 14200000, + "marge_pct": 28, + "regime_fiscal": "CONFOTUR + Fideicomiso" + }, + "commercial": { + "positionnement_fr": "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." + }, + "rendus": [ + { "fichier": "hero_aerien.jpg", "vue": "Vue aérienne", "hero": true }, + { "fichier": "piscine.jpg", "vue": "Piscine à débordement", "hero": false }, + { "fichier": "lobby.jpg", "vue": "Lobby", "hero": false } + ] +} diff --git a/05_deliverables_mvp/faisabilite/generator/fixtures/brief_incomplete.json b/05_deliverables_mvp/faisabilite/generator/fixtures/brief_incomplete.json new file mode 100644 index 0000000..2bddf9f --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/fixtures/brief_incomplete.json @@ -0,0 +1,53 @@ +{ + "projet": "P02", + "nom": "Résidence Fixture Beta", + "synthetique": true, + "sources": [], + "masterplan": { + "localisation": "Province Fixture · Municipalité Beta", + "terrain_m2": 8000, + "terrain_tareas": 127, + "zonage": "Résidentiel touristique", + "nb_unites": 30, + "nb_phases": 1, + "phasage": "Phase unique après seuil 52 % pré-vente" + }, + "architecture": { + "parti_archi": "Contemporain minimaliste", + "nb_batiments": 2, + "nb_niveaux": 5, + "typologies": [ + { + "nom": "1 Chambre", + "quantite": 18, + "surface_interieure_m2": 62, + "surface_terrasse_m2": 12, + "surface_totale_m2": 74, + "prix_usd": null, + "prix_dop": null + }, + { + "nom": "2 Chambres", + "quantite": 12, + "surface_interieure_m2": 90, + "surface_terrasse_m2": 20, + "surface_totale_m2": 110, + "prix_usd": null, + "prix_dop": null + } + ] + }, + "paysage": { + "concept_paysage": "Patios végétalisés", + "amenities": ["Piscine"], + "services_inclus": ["Sécurité 24/7"] + }, + "ingenierie": { + "cout_construction_usd": null, + "revenu_brut_usd": null, + "marge_pct": null + }, + "commercial": { + "positionnement_fr": "Un projet résidentiel en cours de faisabilité — les prix seront confirmés après validation des sources archives." + } +} diff --git a/05_deliverables_mvp/faisabilite/generator/genlib/__init__.py b/05_deliverables_mvp/faisabilite/generator/genlib/__init__.py new file mode 100644 index 0000000..b30ac65 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/genlib/__init__.py @@ -0,0 +1,18 @@ +"""Générateur Faisabilité 4 volets — bibliothèque. + +Livrable Faisabilité · Sprint 2 (`03_agents/faisabilite/AGENT.md` §Livrable S2 : +« Générateur 4 volets automatique »). Cible de portage VPS : +`otoia/capabilities/faisabilite_agent.py`. + +Chaîne de valeur (ferme la boucle avec le Publiciste déjà livré) : + + brief.json ──► renderer ──► data_room/PXX/ (template v1.0) + │ + ▼ + publiciste.parser ──► projets_master.json ──► site + +Le module NE FABRIQUE JAMAIS de chiffre (CLAUDE.md #6) : un champ 🔴 absent du +brief reste un placeholder `{{…}}`, est listé dans `champs_manquants`, et +rétrograde le statut de la faisabilité (jamais « complete » avec une donnée +douteuse). +""" diff --git a/05_deliverables_mvp/faisabilite/generator/genlib/model.py b/05_deliverables_mvp/faisabilite/generator/genlib/model.py new file mode 100644 index 0000000..8f9e993 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/genlib/model.py @@ -0,0 +1,113 @@ +"""Modèle de données du générateur : constantes canoniques + champs requis. + +Sépare les VALEURS CANONIQUES (imposées par CLAUDE.md, jamais issues du brief — +donc jamais « inventées » projet par projet) de la structure des champs 🔴/🟡 du +template v1.0 (§2-§5). +""" + +from __future__ import annotations + +from typing import Any, Optional + +# --------------------------------------------------------------------------- # +# Version du template canonique consommé (doit rester alignée sur +# TEMPLATE_FAISABILITE_CANONIQUE_v1.0.md §8 · version.schema.json pattern). +# --------------------------------------------------------------------------- # +TEMPLATE_VERSION = "1.0.0" + +# --------------------------------------------------------------------------- # +# Paramètres CANONIQUES CLAUDE.md #9/#10. Émis TELS QUELS par le générateur pour +# TOUS les projets. Ne proviennent JAMAIS du brief → aucune invention possible. +# --------------------------------------------------------------------------- # +CANONICAL = { + "frais_edition_pct": "3 %", + "marketing_pct": "8.5 %", + "point_equilibre_pct": "52 %", + "devises": "USD + DOP", + "paiement": "Cardnet", + "format_doc": "Letter US", +} + +# Ordre canonique des 4 volets (version.schema.json enum + parser). +VOLETS = ["masterplan", "architecture", "paysage_experience", "ingenierie"] + +# --------------------------------------------------------------------------- # +# Champs OBLIGATOIRES (🔴) par volet — cf. tableaux §2-§5 du template. +# Un 🔴 absent ⇒ champs_manquants ⇒ rétrogradation (jamais « complete »). +# Les typologies (§3.2, bloc anti-gap) sont traitées à part par le scorer. +# --------------------------------------------------------------------------- # +REQUIRED = { + "masterplan": [ + "localisation", # 1.1 + "terrain_m2", # 1.2 + "terrain_tareas", # 1.2 + "zonage", # 1.3 + "nb_unites", # 1.4 + "nb_phases", # 1.4 + "phasage", # 1.8 + ], + "architecture": [ + "parti_archi", # 2.1 + "nb_batiments", # 2.2 + "nb_niveaux", # 2.3 + ], + "paysage_experience": [ + "concept_paysage", # 3.1 + "amenities", # 3.2 + "services_inclus", # 3.3 + ], + "ingenierie": [ + "cout_construction_usd", # 4.4 + "revenu_brut_usd", # 4.8 + "marge_pct", # 4.9 + ], +} + +# Colonnes 🔴 d'une typologie (§3.2). Toutes requises pour lever le blocage prix. +TYPO_REQUIRED = [ + "nom", + "quantite", + "surface_interieure_m2", + "surface_terrasse_m2", + "surface_totale_m2", + "prix_usd", + "prix_dop", +] + +# Placeholders signalant l'absence (alignés sur publiciste.parser._ABSENT). +_PLACEHOLDER_ABSENT = { + "", "—", "-", "n/d", "nd", "non défini", "non defini", "à définir", + "a definir", "tbd", "todo", "…", "...", +} + + +def is_filled(value: Any) -> bool: + """True si `value` est une donnée réelle exploitable (pas un placeholder). + + - None / cellule vide / « non défini » / `{{…}}` → False + - liste vide → False, liste non vide → True + - 0 numérique → True (un vrai zéro sourcé est une donnée valide) + """ + if value is None: + return False + if isinstance(value, bool): + return True + if isinstance(value, (int, float)): + return True + if isinstance(value, (list, tuple)): + return len(value) > 0 + if isinstance(value, dict): + return len(value) > 0 + text = str(value).strip().lower() + if not text: + return False + if "{{" in text and "}}" in text: + return False + return text not in _PLACEHOLDER_ABSENT + + +def get(section: Optional[dict], key: str) -> Any: + """Accès défensif à une clé de section de brief (None si absente).""" + if not isinstance(section, dict): + return None + return section.get(key) diff --git a/05_deliverables_mvp/faisabilite/generator/genlib/renderer.py b/05_deliverables_mvp/faisabilite/generator/genlib/renderer.py new file mode 100644 index 0000000..71ea7f4 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/genlib/renderer.py @@ -0,0 +1,310 @@ +"""Rendu Markdown des 4 volets + version.json à partir d'un brief. + +Les en-têtes de sections et les colonnes de tableaux reproduisent EXACTEMENT +ceux attendus par `publiciste.parser` (mapping par en-tête) — c'est ce qui +garantit le round-trip generator → parser sans perte. Toute donnée absente du +brief est rendue en placeholder `{{…}}` (jamais un chiffre inventé — #6). +""" + +from __future__ import annotations + +import json +import os +from typing import Any, Optional + +from . import model + + +# --------------------------------------------------------------------------- # +# Helpers de rendu de valeurs (placeholder si absent). +# --------------------------------------------------------------------------- # +def _v(value: Any, placeholder: str) -> str: + """Valeur réelle ou placeholder `{{placeholder}}` si absente (#6).""" + if not model.is_filled(value): + return "{{" + placeholder + "}}" + return str(value) + + +def _money(value: Any, devise: str, placeholder: str) -> str: + """« USD 250,000 » / « DOP 14,750,000 ». Placeholder si absent (jamais 0).""" + if not model.is_filled(value): + return "{{" + placeholder + "}}" + try: + n = float(value) + except (TypeError, ValueError): + return "{{" + placeholder + "}}" + entier = f"{n:,.0f}" if n == int(n) else f"{n:,.2f}" + return f"{devise} {entier}" + + +def _num(value: Any, placeholder: str) -> str: + if not model.is_filled(value): + return "{{" + placeholder + "}}" + return str(value) + + +def _synth_banner(brief: dict) -> str: + if brief.get("synthetique"): + return ("> ⚠️ FIXTURE SYNTHÉTIQUE — données fictives de test. Ne jamais " + "publier (CLAUDE.md #6).\n\n") + return "" + + +# --------------------------------------------------------------------------- # +# 00_brief/brief.md (parser : H1 = nom · ligne « Localisation : … ») +# --------------------------------------------------------------------------- # +def render_brief(brief: dict) -> str: + mp = brief.get("masterplan") or {} + nom = _v(brief.get("nom"), "nom") + return ( + f"# {nom}\n\n" + f"{_synth_banner(brief)}" + f"- **Localisation** : {_v(mp.get('localisation'), 'localisation')}\n" + f"- Superficie terrain : {_num(mp.get('terrain_m2'), 'terrain_m2')} m² / " + f"{_num(mp.get('terrain_tareas'), 'terrain_tareas')} tareas\n" + f"- Zonage : {_v(mp.get('zonage'), 'zonage')}\n" + f"- Accès : {_v(mp.get('acces'), 'acces')}\n" + ) + + +# --------------------------------------------------------------------------- # +# 10_masterplan/masterplan.md (§2) +# --------------------------------------------------------------------------- # +def render_masterplan(brief: dict) -> str: + mp = brief.get("masterplan") or {} + return ( + "# Volet 1 · Masterplan\n\n" + f"{_synth_banner(brief)}" + f"- 1.1 Localisation : {_v(mp.get('localisation'), 'localisation')}\n" + f"- 1.2 Superficie terrain : {_num(mp.get('terrain_m2'), 'terrain_m2')} m² / " + f"{_num(mp.get('terrain_tareas'), 'terrain_tareas')} tareas\n" + f"- 1.3 Zonage / usage : {_v(mp.get('zonage'), 'zonage')}\n" + f"- 1.4 Densité programme : {_num(mp.get('nb_unites'), 'nb_unites')} unités · " + f"{_num(mp.get('nb_phases'), 'nb_phases')} phase(s)\n" + f"- 1.5 Emprise au sol / COS : {_v(mp.get('cos'), 'cos')}\n" + f"- 1.6 Accès & mobilité : {_v(mp.get('acces'), 'acces')}\n" + f"- 1.7 Plan de masse : {_v(mp.get('ref_plan_masse'), 'ref_plan_masse')}\n" + f"- 1.8 Phasage développement : {_v(mp.get('phasage'), 'phasage')}\n" + ) + + +# --------------------------------------------------------------------------- # +# 20_architecture/architecture.md (§3 · bloc anti-gap §3.2) +# --------------------------------------------------------------------------- # +_ARCHI_HEADER = ( + "| Typologie | Nb unités | Surface intérieure (m²) | Surface terrasse (m²) | " + "Surface totale (m²) | Prix « à partir de » (USD) | Prix (DOP) |\n" + "|---|---|---|---|---|---|---|\n" +) + + +def render_architecture(brief: dict) -> str: + archi = brief.get("architecture") or {} + typos = archi.get("typologies") or [] + + rows = "" + if typos: + for i, t in enumerate(typos): + rows += ( + f"| {_v(t.get('nom'), f'typo_{i+1}_nom')} " + f"| {_num(t.get('quantite'), f'typo_{i+1}_qte')} " + f"| {_num(t.get('surface_interieure_m2'), f'typo_{i+1}_int')} " + f"| {_num(t.get('surface_terrasse_m2'), f'typo_{i+1}_terrasse')} " + f"| {_num(t.get('surface_totale_m2'), f'typo_{i+1}_total')} " + f"| {_money(t.get('prix_usd'), 'USD', f'typo_{i+1}_prix_usd')} " + f"| {_money(t.get('prix_dop'), 'DOP', f'typo_{i+1}_prix_dop')} |\n" + ) + else: + # Ligne-placeholder : le parser la saute, le bloc anti-gap reste visiblement vide. + rows = ("| {{typo_1_nom}} | {{typo_1_qte}} | {{typo_1_int}} | " + "{{typo_1_terrasse}} | {{typo_1_total}} | {{typo_1_prix_usd}} | " + "{{typo_1_prix_dop}} |\n") + + taux = archi.get("taux_conversion") or {} + taux_line = ( + f"\n_Taux de conversion USD↔DOP : {_v(taux.get('taux'), 'taux')} " + f"(source {_v(taux.get('source'), 'taux_source')}, " + f"{_v(taux.get('date'), 'taux_date')})._\n" + ) + + return ( + "# Volet 2 · Architecture\n\n" + f"{_synth_banner(brief)}" + "## 3.1 Champs généraux\n" + f"- Parti architectural : {_v(archi.get('parti_archi'), 'parti_archi')}\n" + f"- Nb de bâtiments / blocs : {_num(archi.get('nb_batiments'), 'nb_batiments')}\n" + f"- Niveaux (étages) : {_num(archi.get('nb_niveaux'), 'nb_niveaux')}\n" + f"- Rendus référence : {_v(archi.get('ref_rendus'), 'ref_rendus')}\n\n" + "## 3.2 Tableau des typologies\n\n" + f"{_ARCHI_HEADER}{rows}{taux_line}" + ) + + +# --------------------------------------------------------------------------- # +# 30_paysage_experience/paysage_experience.md (§4) +# --------------------------------------------------------------------------- # +def _bullets(items: Optional[list], placeholder: str) -> str: + if not model.is_filled(items): + return f"- {{{{{placeholder}}}}}\n" + return "".join(f"- {str(it).strip()}\n" for it in items) + + +def render_paysage(brief: dict) -> str: + p = brief.get("paysage") or {} + return ( + "# Volet 3 · Paysage & Expérience\n\n" + f"{_synth_banner(brief)}" + "## 3.1 Concept paysager\n" + f"{_v(p.get('concept_paysage'), 'concept_paysage')}\n\n" + "## 3.2 Amenities / équipements\n" + f"{_bullets(p.get('amenities'), 'amenities')}\n" + "## 3.3 Services inclus\n" + f"{_bullets(p.get('services_inclus'), 'services_inclus')}\n" + "## 3.4 Expérience résident/investisseur\n" + f"{_v(p.get('experience'), 'experience')}\n\n" + "## 3.5 Durabilité / environnement\n" + f"{_v(p.get('durabilite'), 'durabilite')}\n" + ) + + +# --------------------------------------------------------------------------- # +# 40_ingenierie_faisabilite/ingenierie_faisabilite.md (§5 · params canoniques #9/#10) +# --------------------------------------------------------------------------- # +def render_ingenierie(brief: dict) -> str: + ing = brief.get("ingenierie") or {} + c = model.CANONICAL + return ( + "# Volet 4 · Ingénierie & Faisabilité\n\n" + f"{_synth_banner(brief)}" + "## 5.1 Technique\n" + f"- 4.1 Systèmes structurels : {_v(ing.get('structure'), 'structure')}\n" + f"- 4.2 Réseaux (eau/élec/eaux usées) : {_v(ing.get('reseaux'), 'reseaux')}\n" + f"- 4.3 Contraintes site : {_v(ing.get('contraintes_site'), 'contraintes_site')}\n\n" + "## 5.2 Financier bancable (paramètres canoniques)\n" + f"- 4.4 Coût de construction total : " + f"{_money(ing.get('cout_construction_usd'), 'USD', 'cout_construction')}\n" + f"- 4.5 Frais d'édition documentaire : {c['frais_edition_pct']} (fixe · #9)\n" + f"- 4.6 Budget marketing : {c['marketing_pct']} (fixe · #9)\n" + f"- 4.7 Point d'équilibre pré-vente : {c['point_equilibre_pct']} (fixe · #9)\n" + f"- 4.8 Revenu brut de vente projeté : " + f"{_money(ing.get('revenu_brut_usd'), 'USD', 'revenu_brut')}\n" + f"- 4.9 Marge projetée : {_num(ing.get('marge_pct'), 'marge')} %\n" + f"- 4.10 Devises : {c['devises']} (#10)\n" + f"- 4.11 Moyen de paiement : {c['paiement']} (#10)\n" + f"- 4.12 Format documents : {c['format_doc']} (#10)\n" + f"- 4.13 Régime fiscal : {_v(ing.get('regime_fiscal'), 'regime_fiscal')}\n" + ) + + +# --------------------------------------------------------------------------- # +# 40_llm_outputs/commercial.md (parser : 1er paragraphe = positionnement FR) +# --------------------------------------------------------------------------- # +def render_commercial(brief: dict) -> str: + com = brief.get("commercial") or {} + positionnement = com.get("positionnement_fr") + body = ( + str(positionnement).strip() + if model.is_filled(positionnement) + else "{{positionnement_fr}}" + ) + typos = (brief.get("architecture") or {}).get("typologies") or [] + prix0 = None + for t in typos: + if model.is_filled(t.get("prix_usd")): + prix0 = t.get("prix_usd") + break + prix_line = ( + f"prix « à partir de » {_money(prix0, 'USD', 'prix')}." + if prix0 is not None + else "prix « à partir de » à confirmer (faisabilité en cours)." + ) + return ( + "# Commercial\n\n" + f"{_synth_banner(brief)}" + f"{body}\n\n" + "## Typologies (miroir §3.2)\n" + f"Voir le tableau de l'architecture — {prix_line}\n" + ) + + +# --------------------------------------------------------------------------- # +# 60_photos_site/README.md (parser : table Fichier | Vue | Hero) +# --------------------------------------------------------------------------- # +def render_photos_manifest(brief: dict) -> str: + rendus = brief.get("rendus") or [] + head = ( + "# Manifeste rendus\n\n" + f"{_synth_banner(brief)}" + "> Rendus liés au projet réel obligatoires en production (❌ IA générique).\n\n" + "| Fichier | Vue | Hero |\n|---|---|---|\n" + ) + if not rendus: + return head + "| {{rendu_1_fichier}} | {{rendu_1_vue}} | hero |\n" + rows = "" + for i, r in enumerate(rendus): + hero = "hero" if r.get("hero") else "non" + rows += ( + f"| {_v(r.get('fichier'), f'rendu_{i+1}_fichier')} " + f"| {_v(r.get('vue'), f'rendu_{i+1}_vue')} | {hero} |\n" + ) + return head + rows + + +# --------------------------------------------------------------------------- # +# _META/version.json +# --------------------------------------------------------------------------- # +def render_version_json(brief: dict, bilan: dict, generated_at: str, + archive_precedente: Optional[str] = None) -> dict: + version = { + "projet": brief["projet"], + "template_version": model.TEMPLATE_VERSION, + "generated_at": generated_at, + "score_4big": bilan["score_4big"], + "statut_faisabilite": bilan["statut_faisabilite"], + "volets_complets": bilan["volets_complets"], + "champs_manquants": bilan["champs_manquants"], + } + if archive_precedente is not None: + version["archive_precedente"] = archive_precedente + return version + + +# --------------------------------------------------------------------------- # +# Vérification des paramètres canoniques dans le rendu (axe 4, honnêteté). +# --------------------------------------------------------------------------- # +_CANONICAL_MARKERS = ["3 %", "8.5 %", "52 %", "USD + DOP", "Cardnet", "Letter US"] + + +def check_canonical(ingenierie_md: str) -> list[str]: + """Retourne la liste des marqueurs canoniques ABSENTS du volet ingénierie.""" + return [m for m in _CANONICAL_MARKERS if m not in ingenierie_md] + + +# --------------------------------------------------------------------------- # +# Arborescence complète (§1 du template). +# --------------------------------------------------------------------------- # +# Répertoires exigés vides → .gitkeep (règle d'homogénéité §1). +_EMPTY_DIRS = ["50_financier_bancable"] + + +def render_files(brief: dict, bilan: dict, generated_at: str) -> dict[str, str]: + """Retourne {chemin_relatif: contenu} pour toute l'arborescence PXX/.""" + files = { + os.path.join("00_brief", "brief.md"): render_brief(brief), + os.path.join("10_masterplan", "masterplan.md"): render_masterplan(brief), + os.path.join("20_architecture", "architecture.md"): render_architecture(brief), + os.path.join("30_paysage_experience", "paysage_experience.md"): render_paysage(brief), + os.path.join("40_ingenierie_faisabilite", "ingenierie_faisabilite.md"): + render_ingenierie(brief), + os.path.join("40_llm_outputs", "commercial.md"): render_commercial(brief), + os.path.join("60_photos_site", "README.md"): render_photos_manifest(brief), + os.path.join("_META", "version.json"): + json.dumps( + render_version_json(brief, bilan, generated_at), + ensure_ascii=False, + indent=2, + ) + "\n", + } + for d in _EMPTY_DIRS: + files[os.path.join(d, ".gitkeep")] = "" + return files diff --git a/05_deliverables_mvp/faisabilite/generator/genlib/scorer.py b/05_deliverables_mvp/faisabilite/generator/genlib/scorer.py new file mode 100644 index 0000000..d2e8552 --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/genlib/scorer.py @@ -0,0 +1,176 @@ +"""Scoring 4Big (§7 du template canonique) + dérivation du statut faisabilité. + +Barème (20 pts × 5 axes = 100) — reproduit fidèlement §7 : + 1. Complétude — 4 volets présents + champs 🔴 remplis à 100 % + 2. Anti-gap prix — tableau typologies §3.2 complet (aucun prix manquant) + 3. Traçabilité — chiffres sourcés (sources[] + taux de conversion daté) + 4. Paramètres canon. — 3 % / 8.5 % / 52 % · USD+DOP · Letter US · Cardnet + 5. Machine-lisible — version.json valide + extraction Publiciste OK + +Axes 1-4 : calculés à partir du brief (déterministe, sans invention #6). +Axe 5 : GARANTI PAR CONSTRUCTION (le renderer produit une arborescence + conforme au parser + un version.json conforme au schéma). Le CLI le + PROUVE après génération (re-parse + validation) et échoue bruyamment + sinon — le score n'est donc jamais auto-décerné à l'aveugle. +""" + +from __future__ import annotations + +from typing import Any + +from . import model + +_SEUIL_PUBLIABLE = 95 # CLAUDE.md #5 · version.schema.json (complete ⇒ score>=95) + + +def _score_completude(brief: dict) -> tuple[int, list[str], list[str]]: + """Axe 1 → (points/20, volets_complets, champs_manquants). + + Un volet est « complet » si TOUS ses champs 🔴 sont remplis. Pour + l'architecture, la présence d'au moins une typologie est aussi exigée. + """ + total = 0 + filled = 0 + champs_manquants: list[str] = [] + volets_complets: list[str] = [] + + for volet in model.VOLETS: + section = brief.get(_brief_key(volet)) + volet_ok = True + for champ in model.REQUIRED[volet]: + total += 1 + if model.is_filled(model.get(section, champ)): + filled += 1 + else: + volet_ok = False + champs_manquants.append(f"{volet}.{champ}") + # L'architecture exige aussi au moins une typologie renseignée (§3.2). + if volet == "architecture": + typos = model.get(section, "typologies") or [] + if not typos: + volet_ok = False + if "architecture.typologies" not in champs_manquants: + champs_manquants.append("architecture.typologies") + if volet_ok: + volets_complets.append(volet) + + pts = round(20 * filled / total) if total else 0 + return pts, volets_complets, champs_manquants + + +def _score_anti_gap_prix(brief: dict) -> tuple[int, list[str]]: + """Axe 2 → (points/20, champs_manquants typologies). + + Chaque typologie doit renseigner ses 7 cellules 🔴 (§3.2). Le score est la + fraction de cellules 🔴 réellement remplies ; 0 s'il n'existe aucune typologie. + """ + archi = brief.get("architecture") or {} + typos = archi.get("typologies") or [] + if not typos: + return 0, ["architecture.typologies (aucune typologie — bloc anti-gap vide)"] + + total = len(typos) * len(model.TYPO_REQUIRED) + filled = 0 + manquants: list[str] = [] + for i, typo in enumerate(typos): + for champ in model.TYPO_REQUIRED: + if model.is_filled(model.get(typo, champ)): + filled += 1 + else: + manquants.append(f"architecture.typologies[{i}].{champ}") + pts = round(20 * filled / total) if total else 0 + return pts, manquants + + +def _score_tracabilite(brief: dict) -> tuple[int, list[str]]: + """Axe 3 → (points/20, remarques). + + 10 pts : `sources[]` non vide (chaque chiffre rattachable à une archive). + 10 pts : `architecture.taux_conversion` avec `date` ET `source` (règle §3.2 : + le taux USD↔DOP doit être daté et sourcé — jamais inventé). + """ + pts = 0 + remarques: list[str] = [] + if model.is_filled(brief.get("sources")): + pts += 10 + else: + remarques.append("sources[] absent — traçabilité des chiffres non établie") + + taux = (brief.get("architecture") or {}).get("taux_conversion") or {} + if model.is_filled(taux.get("date")) and model.is_filled(taux.get("source")): + pts += 10 + else: + remarques.append("architecture.taux_conversion.{date,source} incomplet (§3.2)") + return pts, remarques + + +def _score_parametres_canoniques() -> int: + """Axe 4 → 20/20. Garanti : le renderer émet toujours les 6 paramètres + canoniques (3 % / 8.5 % / 52 % · USD+DOP · Letter US · Cardnet). + Le CLI vérifie leur présence dans le rendu (`renderer.check_canonical`).""" + return 20 + + +def score(brief: dict, machine_lisible_pts: int = 20) -> dict[str, Any]: + """Calcule le bilan 4Big complet + le statut faisabilité. + + `machine_lisible_pts` (axe 5) vaut 20 par construction ; le CLI le remplace + par le résultat réel de la re-validation après génération. + """ + pts1, volets_complets, champs1 = _score_completude(brief) + pts2, champs2 = _score_anti_gap_prix(brief) + pts3, remarques3 = _score_tracabilite(brief) + pts4 = _score_parametres_canoniques() + + champs_manquants = _dedupe(champs1 + champs2) + total = pts1 + pts2 + pts3 + pts4 + machine_lisible_pts + + statut = _statut(total, champs_manquants, volets_complets, pts2) + + return { + "score_4big": total, + "statut_faisabilite": statut, + "volets_complets": volets_complets, + "champs_manquants": champs_manquants, + "axes": { + "completude": pts1, + "anti_gap_prix": pts2, + "tracabilite": pts3, + "parametres_canoniques": pts4, + "machine_lisible": machine_lisible_pts, + }, + "remarques": remarques3, + } + + +def _statut(total: int, champs_manquants: list[str], volets_complets: list[str], + pts_prix: int) -> str: + """« complete » exige le seuil publiable (#5) ET aucun champ 🔴 manquant ET + le bloc prix intégral ET les 4 volets — cohérent avec version.schema.json. + Sinon « en_developpement », ou « incomplete » si aucun volet abouti.""" + prix_complet = pts_prix >= 20 + if ( + total >= _SEUIL_PUBLIABLE + and not champs_manquants + and prix_complet + and len(volets_complets) >= 4 + ): + return "complete" + if not volets_complets: + return "incomplete" + return "en_developpement" + + +def _brief_key(volet: str) -> str: + """Le brief utilise « paysage » pour le volet « paysage_experience ».""" + return "paysage" if volet == "paysage_experience" else volet + + +def _dedupe(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for it in items: + if it not in seen: + seen.add(it) + out.append(it) + return out diff --git a/05_deliverables_mvp/faisabilite/generator/tests/test_generator.py b/05_deliverables_mvp/faisabilite/generator/tests/test_generator.py new file mode 100644 index 0000000..5f5bedf --- /dev/null +++ b/05_deliverables_mvp/faisabilite/generator/tests/test_generator.py @@ -0,0 +1,243 @@ +"""Tests du générateur Faisabilité 4 volets (stdlib `unittest`, zéro pip). + +Couvre : scoring 4Big, rétrogradation défensive (#6), rendu conforme au template, +paramètres canoniques (#9/#10), et surtout le ROUND-TRIP end-to-end +generator → parser Publiciste → projets_master.json (validé contre le schéma). + +Un oracle `jsonschema` est utilisé EN PLUS s'il est installé. +""" + +import importlib.util +import json +import os +import shutil +import sys +import tempfile +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_GEN = os.path.normpath(os.path.join(_HERE, "..")) # generator/ +_PUB = os.path.normpath(os.path.join(_GEN, "..", "..", "publiciste")) +_FAIS = os.path.normpath(os.path.join(_GEN, "..")) # faisabilite/ (schémas) + +sys.path.insert(0, _PUB) +sys.path.insert(0, _GEN) + +from genlib import model, renderer, scorer # noqa: E402,F401 (generator/genlib) + + +def _load_gen_cli(): + """Charge faisabilite_gen.py comme module isolé (évite collision `lib`).""" + spec = importlib.util.spec_from_file_location( + "faisabilite_gen", os.path.join(_GEN, "faisabilite_gen.py") + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _pub(): + """(parser, validator) du Publiciste, importés depuis publiciste/lib.""" + import lib.parser as pub_parser # noqa: PLC0415 + import lib.validator as pub_validator # noqa: PLC0415 + return pub_parser, pub_validator + + +def _brief(name): + with open(os.path.join(_GEN, "fixtures", name), encoding="utf-8") as fh: + return json.load(fh) + + +def _schema(name): + with open(os.path.join(_FAIS, name), encoding="utf-8") as fh: + return json.load(fh) + + +_FIXED_TS = "2026-07-30T00:00:00Z" + + +class ScorerTest(unittest.TestCase): + def test_complete_brief_scores_publiable(self): + bilan = scorer.score(_brief("brief_complete.json")) + self.assertEqual(bilan["statut_faisabilite"], "complete") + self.assertGreaterEqual(bilan["score_4big"], 95) + self.assertEqual(bilan["champs_manquants"], []) + self.assertEqual(len(bilan["volets_complets"]), 4) + self.assertEqual(bilan["axes"]["anti_gap_prix"], 20) + + def test_incomplete_brief_is_downgraded(self): + bilan = scorer.score(_brief("brief_incomplete.json")) + self.assertNotEqual(bilan["statut_faisabilite"], "complete") + self.assertLess(bilan["score_4big"], 95) + # Les prix manquants doivent figurer explicitement (anti-gap). + joined = " ".join(bilan["champs_manquants"]) + self.assertIn("prix_usd", joined) + self.assertIn("prix_dop", joined) + + def test_missing_required_field_listed(self): + b = _brief("brief_complete.json") + b["masterplan"]["phasage"] = None + bilan = scorer.score(b) + self.assertIn("masterplan.phasage", bilan["champs_manquants"]) + self.assertNotIn("masterplan", bilan["volets_complets"]) + self.assertNotEqual(bilan["statut_faisabilite"], "complete") + + def test_no_typologies_zeroes_anti_gap(self): + b = _brief("brief_complete.json") + b["architecture"]["typologies"] = [] + bilan = scorer.score(b) + self.assertEqual(bilan["axes"]["anti_gap_prix"], 0) + self.assertNotIn("architecture", bilan["volets_complets"]) + + def test_empty_brief_is_incomplete(self): + bilan = scorer.score({"projet": "P03"}) + self.assertEqual(bilan["statut_faisabilite"], "incomplete") + self.assertEqual(bilan["volets_complets"], []) + + +class RendererTest(unittest.TestCase): + def test_canonical_params_always_present(self): + # Même un brief quasi vide émet les 6 paramètres canoniques (#9/#10). + md = renderer.render_ingenierie({"projet": "P03"}) + self.assertEqual(renderer.check_canonical(md), []) + for marker in ["3 %", "8.5 %", "52 %", "USD + DOP", "Cardnet", "Letter US"]: + self.assertIn(marker, md) + + def test_missing_price_is_placeholder_not_invented(self): + md = renderer.render_architecture(_brief("brief_incomplete.json")) + # Aucun prix inventé : les cellules USD/DOP restent des placeholders. + self.assertIn("{{typo_1_prix_usd}}", md) + self.assertIn("{{typo_2_prix_dop}}", md) + + def test_money_formatting(self): + self.assertEqual(renderer._money(150000, "USD", "x"), "USD 150,000") + self.assertEqual(renderer._money(None, "USD", "x"), "{{x}}") + self.assertEqual(renderer._money(0, "DOP", "x"), "DOP 0") + + def test_synthetic_banner(self): + md = renderer.render_brief(_brief("brief_complete.json")) + self.assertIn("SYNTHÉTIQUE", md) + + +class RoundTripTest(unittest.TestCase): + """generator → data_room/PXX/ → parser Publiciste → schéma.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="fais_gen_") + self.cli = _load_gen_cli() + + def tearDown(self): + shutil.rmtree(self.tmp, ignore_errors=True) + + def _gen(self, brief_name): + brief = _brief(brief_name) + projet_dir, bilan = self.cli._generate_tree(brief, self.tmp, _FIXED_TS) + return brief, projet_dir, bilan + + def test_complete_roundtrips_to_disponible(self): + brief, projet_dir, bilan = self._gen("brief_complete.json") + self.assertNotIn("_machine_lisible_motifs", bilan) # preuve axe 5 OK + + pub_parser, pub_validator = _pub() + projet = pub_parser.parse_projet(projet_dir) + # complete → le parser NE rétrograde PAS (prix tous présents). + self.assertEqual(projet["statut"], "disponible") + self.assertEqual(projet["code"], "P01") + self.assertEqual(len(projet["typologies"]), 3) + self.assertTrue(all(isinstance(t["prix_depuis_usd"], (int, float)) + for t in projet["typologies"])) + self.assertEqual(projet["typologies"][0]["prix_depuis_usd"], 150000.0) + # Services + localisation + positionnement remontent bien. + self.assertIn("Conciergerie 24/7", projet["inclus"]) + self.assertTrue(projet["localisation"]) + self.assertIn("fr", projet.get("positionnement", {})) + # Rendus (manifeste) lus. + self.assertTrue(projet["rendus"][0]["hero"]) + + def test_incomplete_roundtrips_to_en_developpement(self): + brief, projet_dir, bilan = self._gen("brief_incomplete.json") + self.assertNotIn("_machine_lisible_motifs", bilan) + + pub_parser, _ = _pub() + projet = pub_parser.parse_projet(projet_dir) + # Prix absents → parser publie « en développement » (jamais de prix inventé). + self.assertEqual(projet["statut"], "en_developpement") + self.assertEqual(len(projet["typologies"]), 2) + self.assertTrue(all(t["prix_depuis_usd"] is None + for t in projet["typologies"])) + + def test_version_json_validates_against_schema(self): + _, projet_dir, _ = self._gen("brief_complete.json") + with open(os.path.join(projet_dir, "_META", "version.json"), + encoding="utf-8") as fh: + version = json.load(fh) + _, pub_validator = _pub() + errs = pub_validator.validate(version, _schema("version.schema.json")) + self.assertEqual(errs, [], f"version.json non conforme : {errs}") + + def test_full_pipeline_master_validates(self): + # Génère 2 projets → parcours build_master → projets_master.json conforme. + for name in ("brief_complete.json", "brief_incomplete.json"): + self._gen(name) + pub_parser, pub_validator = _pub() + master = pub_parser.build_master(self.tmp, generated_at=_FIXED_TS) + self.assertEqual(len(master["projets"]), 2) + errs = pub_validator.validate( + master, _schema("projets_master.schema.json") + ) + self.assertEqual(errs, [], f"projets_master non conforme : {errs}") + + def test_generation_is_deterministic(self): + self._gen("brief_complete.json") + snap1 = _read_tree(os.path.join(self.tmp, "P01")) + shutil.rmtree(os.path.join(self.tmp, "P01")) + self._gen("brief_complete.json") + snap2 = _read_tree(os.path.join(self.tmp, "P01")) + self.assertEqual(snap1, snap2) + + def test_scaffold_is_not_publiable(self): + # Un scaffold vide ne doit jamais être « complete ». + projet_dir, bilan = self.cli._generate_tree( + {"projet": "P09"}, self.tmp, _FIXED_TS + ) + self.assertNotEqual(bilan["statut_faisabilite"], "complete") + self.assertNotIn("_machine_lisible_motifs", bilan) # structure quand même valide + pub_parser, _ = _pub() + projet = pub_parser.parse_projet(projet_dir) + self.assertEqual(projet["statut"], "en_developpement") + + +class OracleTest(unittest.TestCase): + """Concordance validateur maison ↔ `jsonschema` (si installé).""" + + def test_version_json_matches_jsonschema_oracle(self): + try: + import jsonschema # noqa: PLC0415 + except ImportError: + self.skipTest("jsonschema non installé") + tmp = tempfile.mkdtemp(prefix="fais_oracle_") + try: + cli = _load_gen_cli() + projet_dir, _ = cli._generate_tree( + _brief("brief_complete.json"), tmp, _FIXED_TS + ) + with open(os.path.join(projet_dir, "_META", "version.json"), + encoding="utf-8") as fh: + version = json.load(fh) + jsonschema.validate(version, _schema("version.schema.json")) + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def _read_tree(root): + out = {} + for dirpath, _dirs, files in os.walk(root): + for f in sorted(files): + p = os.path.join(dirpath, f) + with open(p, encoding="utf-8") as fh: + out[os.path.relpath(p, root)] = fh.read() + return out + + +if __name__ == "__main__": + unittest.main(verbosity=2)