911f70b9ab
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>
311 lines
13 KiB
Python
311 lines
13 KiB
Python
"""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
|