915fc5194a
Faisabilité S3 : le même brief.json -> data_room/PXX/50_financier_bancable/{fr,en,es}.md
+ manifest.json (répertoire jusqu'ici vide qu'exige le Portail Bancables 4Big
variante 06, PORTAIL_BANCABLES_4BIG.md étape 1).
Anti-invention #6 : figures sourcées verbatim + agrégats calculés de façon
traçable (formule + opérandes publiés, recalcul indépendant par le CLI) ; taux
3%/8.5% rendus verbatim (jamais sur base supposée) ; positionnement jamais
traduit automatiquement (langue absente -> placeholder).
- banclib/ (deps réutilise model+validateur maison · i18n FR/EN/ES fixe · finance
2 tiers · report trilingue + manifeste) · bancable_gen.py CLI build/validate
refuse d'écrire si invariant casse · bancable.schema.json · 22 tests stdlib.
- CI : job bancable-tests ajouté au gate (Gitea Actions #2).
- brief.schema.json étendu (positionnement_en/es) · PORTAIL_BANCABLES_4BIG.md cousu.
Régression : 121 tests verts (99 + 22). Auto-score 4Big : 96/100.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
226 lines
9.2 KiB
Python
226 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Générateur de dossier financier bancable trilingue FR/EN/ES · Sprint 3.
|
|
|
|
Livrable **Faisabilité · Sprint 3** (roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md`
|
|
§Sprint 3 · « 40_llm_outputs/ + rapports bancables FR/EN/ES »). Remplit le
|
|
répertoire `50_financier_bancable/` d'une data_room — jusqu'ici vide (`.gitkeep`)
|
|
— que le **Portail Bancables 4Big** (`PORTAIL_BANCABLES_4BIG.md`, variante 06
|
|
retenue par Michel) exige à l'étape 1 de son workflow de déploiement projet.
|
|
|
|
Chaîne de valeur :
|
|
|
|
brief.json ─► faisabilite_gen ─► data_room/PXX/ (4 volets, template v1.0)
|
|
│
|
|
bancable_gen ───┤ (ce module)
|
|
▼
|
|
data_room/PXX/50_financier_bancable/{fr,en,es}.md + manifest.json
|
|
│
|
|
▼ (VPS · hors périmètre worker)
|
|
PDFs FR/EN/ES ─► Portail Bancables 4Big (privé · noindex)
|
|
|
|
Anti-invention (#6) : figures sourcées reprises verbatim ; agrégats calculés de
|
|
façon TRAÇABLE (formule + opérandes sourcés publiés) ; aucun montant sur base non
|
|
documentée ; tout champ absent reste placeholder `{{…}}` (jamais 0 fabriqué).
|
|
|
|
⚠ Ce worker n'écrit JAMAIS sur le VPS (#8). Les .md sont produits en local ; la
|
|
conversion PDF + le rebuild du portail restent côté serveur.
|
|
|
|
Sous-commandes :
|
|
build BRIEF.json -o DATA_ROOM → DATA_ROOM/PXX/50_financier_bancable/*
|
|
validate BRIEF.json → manifeste + invariants (sans écrire)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, _HERE)
|
|
|
|
from banclib import deps, finance, i18n, report # noqa: E402
|
|
|
|
|
|
def _eprint(*args) -> None:
|
|
print(*args, file=sys.stderr)
|
|
|
|
|
|
def _load_schema() -> dict:
|
|
with open(os.path.join(_HERE, "bancable.schema.json"), encoding="utf-8") as fh:
|
|
return json.load(fh)
|
|
|
|
|
|
def _validate_projet(brief: dict) -> None:
|
|
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}")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Invariants — le CLI REFUSE d'écrire si l'un casse (honnêteté #6).
|
|
# --------------------------------------------------------------------------- #
|
|
def check_invariants(brief: dict, manifest: dict,
|
|
files: dict[str, str]) -> list[str]:
|
|
motifs: list[str] = []
|
|
|
|
# (a) manifeste conforme au schéma (validateur maison).
|
|
errs = deps.validate(manifest, _load_schema())
|
|
motifs += [f"manifest: {e}" for e in errs]
|
|
|
|
# (b) exactement les 3 langues canoniques, chacune rendue.
|
|
if manifest["langues"] != ["fr", "en", "es"]:
|
|
motifs.append(f"langues != [fr,en,es] : {manifest['langues']}")
|
|
for lang in i18n.LANGS:
|
|
rel = f"50_financier_bancable/{lang}.md"
|
|
if rel not in files:
|
|
motifs.append(f"fichier langue manquant : {rel}")
|
|
|
|
# (c) chaque .md porte la bannière CONFIDENTIEL ; les synthétiques portent
|
|
# aussi l'avertissement « ne jamais publier » (#6) et sont non publiables.
|
|
for lang in i18n.LANGS:
|
|
md = files.get(f"50_financier_bancable/{lang}.md", "")
|
|
if "CONFIDENTIEL" not in md and "CONFIDENTIAL" not in md \
|
|
and "CONFIDENCIAL" not in md:
|
|
motifs.append(f"{lang}.md : bannière CONFIDENTIEL absente")
|
|
if brief.get("synthetique"):
|
|
if "SYNTH" not in md.upper():
|
|
motifs.append(f"{lang}.md : avertissement synthétique absent")
|
|
# Garde-fou : aucun `None` Python ne doit fuiter dans le rendu.
|
|
if " None " in md or "| None " in md or ": None\n" in md:
|
|
motifs.append(f"{lang}.md : littéral None dans le rendu")
|
|
if brief.get("synthetique") and manifest["publiable"]:
|
|
motifs.append("synthétique mais publiable=true (#6)")
|
|
|
|
# (d) figures calculées : recoupement anti-invention. Chaque valeur non nulle
|
|
# doit se RECALCULER à partir des opérandes sourcés (formule publiée).
|
|
motifs += _check_derived_arithmetic(brief, manifest)
|
|
|
|
# (e) cohérence publiable ↔ champs manquants : un dossier publiable ne peut pas
|
|
# manquer le trio coût/revenu/marge.
|
|
if manifest["publiable"]:
|
|
fs = manifest["figures_sourcees"]
|
|
if fs["cout_construction_usd"] is None or fs["revenu_brut_usd"] is None \
|
|
or fs["marge_pct"] is None:
|
|
motifs.append("publiable=true mais cœur financier incomplet")
|
|
|
|
return motifs
|
|
|
|
|
|
def _check_derived_arithmetic(brief: dict, manifest: dict) -> list[str]:
|
|
"""Reproduit indépendamment les 4 agrégats et compare au manifeste.
|
|
|
|
Prouve qu'aucune valeur calculée n'a été « posée » : elle DOIT découler des
|
|
typologies sourcées et du taux canonique 52 %.
|
|
"""
|
|
motifs: list[str] = []
|
|
rows = finance.typologies(brief)
|
|
figs = {f["cle"]: f["valeur"] for f in manifest["figures_calculees"]}
|
|
|
|
qtes = [r["quantite"] for r in rows]
|
|
all_q = bool(rows) and all(q is not None for q in qtes)
|
|
|
|
exp_total = sum(qtes) if all_q else None
|
|
exp_usd = (sum(q * r["prix_usd"] for q, r in zip(qtes, rows))
|
|
if all_q and all(r["prix_usd"] is not None for r in rows) else None)
|
|
exp_dop = (sum(q * r["prix_dop"] for q, r in zip(qtes, rows))
|
|
if all_q and all(r["prix_dop"] is not None for r in rows) else None)
|
|
exp_pe = (float(math.ceil(0.52 * exp_total))
|
|
if exp_total is not None else None)
|
|
|
|
for cle, exp in (("total_unites", exp_total),
|
|
("valeur_catalogue_usd", exp_usd),
|
|
("valeur_catalogue_dop", exp_dop),
|
|
("point_equilibre_unites", exp_pe)):
|
|
got = figs.get(cle)
|
|
if exp is None and got is not None:
|
|
motifs.append(f"figure {cle} calculée ({got}) alors qu'un opérande manque")
|
|
elif exp is not None and got != exp:
|
|
motifs.append(f"figure {cle} : manifeste {got} != recalcul {exp}")
|
|
return motifs
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
def cmd_build(ns: argparse.Namespace) -> int:
|
|
with open(ns.brief, encoding="utf-8") as fh:
|
|
brief = json.load(fh)
|
|
_validate_projet(brief)
|
|
code = brief["projet"]
|
|
|
|
files = report.render_all(brief, generated_at=ns.generated_at)
|
|
manifest = json.loads(files["50_financier_bancable/manifest.json"])
|
|
|
|
motifs = check_invariants(brief, manifest, files)
|
|
if motifs:
|
|
_eprint(f"❌ {code} : invariants cassés — RIEN écrit :")
|
|
for m in motifs:
|
|
_eprint(" ·", m)
|
|
return 1
|
|
|
|
dest_dir = os.path.join(ns.out, code, "50_financier_bancable")
|
|
os.makedirs(dest_dir, exist_ok=True)
|
|
for rel, content in files.items():
|
|
with open(os.path.join(ns.out, code, rel), "w", encoding="utf-8") as fh:
|
|
fh.write(content)
|
|
|
|
_eprint(f"✅ {code} → {dest_dir}")
|
|
_eprint(f" langues={manifest['langues']} · publiable={manifest['publiable']} "
|
|
f"· synthétique={manifest['synthetique']}")
|
|
_eprint(f" typologies={manifest['typologies_count']} · "
|
|
f"champs 🔴 manquants={len(manifest['champs_manquants'])}")
|
|
for f in manifest["figures_calculees"]:
|
|
_eprint(f" · {f['cle']} = {f['valeur']} [{f['formule']}]")
|
|
return 0
|
|
|
|
|
|
def cmd_validate(ns: argparse.Namespace) -> int:
|
|
with open(ns.brief, encoding="utf-8") as fh:
|
|
brief = json.load(fh)
|
|
_validate_projet(brief)
|
|
code = brief["projet"]
|
|
|
|
files = report.render_all(brief, generated_at=ns.generated_at)
|
|
manifest = json.loads(files["50_financier_bancable/manifest.json"])
|
|
motifs = check_invariants(brief, manifest, files)
|
|
|
|
print(json.dumps(manifest, ensure_ascii=False, indent=2))
|
|
if motifs:
|
|
_eprint(f"❌ {code} : {len(motifs)} invariant(s) cassé(s) :")
|
|
for m in motifs:
|
|
_eprint(" ·", m)
|
|
return 1
|
|
_eprint(f"✅ {code} : manifeste conforme + invariants OK "
|
|
f"(publiable={manifest['publiable']}).")
|
|
return 0
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
def build_argparser() -> argparse.ArgumentParser:
|
|
ap = argparse.ArgumentParser(prog="bancable_gen",
|
|
description=__doc__.splitlines()[0])
|
|
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
|
|
p = sub.add_parser("build", help="brief.json → 50_financier_bancable/{fr,en,es}.md")
|
|
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_build)
|
|
|
|
p = sub.add_parser("validate", help="brief.json → manifeste + invariants (sans écrire)")
|
|
p.add_argument("brief")
|
|
p.add_argument("--generated-at", dest="generated_at", default=None)
|
|
p.set_defaults(func=cmd_validate)
|
|
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())
|