[DTP-Worker] Sprint 2 · Faisabilité générateur 4 volets (brief→data_room + round-trip Publiciste + gate)
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) <noreply@anthropic.com>
This commit is contained in:
+17
-1
@@ -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."
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Artefacts de génération locale (jamais commités — produits à la demande).
|
||||
__pycache__/
|
||||
*.pyc
|
||||
build/
|
||||
out/
|
||||
@@ -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).
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user