[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:
Claude Code DTP Worker
2026-07-30 02:37:47 +00:00
parent cc3e5aef0e
commit 911f70b9ab
14 changed files with 1542 additions and 3 deletions
@@ -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