[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,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)