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>
244 lines
9.7 KiB
Python
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())
|