Files
Claude Code DTP Worker 911f70b9ab [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>
2026-07-30 02:37:47 +00:00

244 lines
9.7 KiB
Python

#!/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())