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