[DTP-Worker] Sprint 7 · Générateur Scénarios démo (run-sheet P07 banquier / P05 client) (CRM+Faisabilité · roadmap L68)

Orchestrateur méta-niveau : compose les hand-offs out/ des 10 modules déjà
livrés en un run-sheet de démo jouable, via pointeurs RFC 6901 résolus à la
construction (zéro chiffre en dur · #6). Réutilise le validateur Publiciste et
la preuve de couverture CI de l'auditeur 4Big (#5) — une démo ne s'appuie que
sur des modules gated. Enregistre demo/scenarios dans l'audit 4Big (17→18,
verdict PASS 18/18 à 100). 32 tests (9 injections négatives) · régression 474
tests verts · guards CI verts · builds déterministes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 10:39:24 +00:00
parent a517619432
commit cac65386ab
19 changed files with 1691 additions and 11 deletions
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
@@ -0,0 +1,87 @@
# Scénarios démo · run-sheet de pitch — Sprint 7 (CRM + Faisabilité)
> Roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` · Sprint 7 l.68 —
> « **CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready)** ».
Générateur **de méta-niveau** qui **compose** les livrables déjà produits en un
**run-sheet de démo** prêt à jouer, sans écrire une seule donnée métier lui-même.
Deux scénarios sont assemblés :
| Scénario | Projet | Audience | Angle |
|---|---|---|---|
| `S-P07-BANQUIER` | **P07 Aqua Terra Las Terrenas** | banquier | bancabilité, conformité fiscale, CONFOTUR, gouvernance, gate qualité |
| `S-P05-CLIENT` | **P05 Las Colinas Najayo Arriba** | client | portail, OTOIA embarqué, pipeline lisible, dossier transparent, SEO |
## Le principe : zéro invention (CLAUDE.md #6)
Un scénario est une suite de **beats** (scènes). Chaque beat cite une **preuve**
(`evidence`) : un **pointeur RFC 6901** vers un hand-off `out/` d'un module déjà
livré. Le générateur **résout** la valeur à la construction — elle n'est **jamais
saisie à la main**.
```
scenario_spec.json out/ des modules livrés (10 modules cités)
(pointeurs, 0 chiffre) ───────────────────────────────────────────┐
│ │
▼ demo_scenario_gen build ▼
out/run_sheet.json ◄──── résolution RFC 6901 + placeholders ──── crm/workflow_vente
out/MANIFEST.json fiscal/ecf_dgii
legal/confotur
rbac/apply_plan
qa/audit_4big …
```
Une valeur introuvable devient un **placeholder** `{{module:file#pointer}}`
jamais un `0` fabriqué, jamais un `null` amont promu en chiffre de pitch.
## Garanties (le CLI REFUSE d'écrire si l'une casse)
1. **Anti-invention** — chaque citation est **re-résolue indépendamment** depuis
le disque et comparée au run-sheet (`_check_anti_invention`).
2. **Couverture CI** — tout module cité doit être **gated** par
`.gitea/workflows/ci.yml`. On **réutilise** la preuve de couverture de
l'auditeur 4Big (`qa/audit_4big/q4lib/registry.py`) — une démo ne repose que
sur du validé. Zéro duplication (#5).
3. **Orchestration réelle** — chaque scénario tisse **≥ 2 modules distincts** et
**≥ 3 beats**, et ne **s'auto-cite jamais** (séparation des pouvoirs).
4. **Prêt à jouer** — l'artefact livré est `pret=true` : 0 citation non résolue,
0 module non gated. Sinon, rien n'est écrit.
5. **Contrat de sortie**`MANIFEST.json` est validé contre `scenario.schema.json`
par le **validateur maison** du Publiciste (draft-07, sans pip · #2).
## Utilisation
```bash
# Assemble le run-sheet (défaut: ./out)
python3 demo_scenario_gen.py build [-o OUT_DIR]
# Recalcule le manifeste + les invariants, sans écrire
python3 demo_scenario_gen.py validate
# Tests (32 méthodes, dont injections négatives)
python3 -m unittest discover -s tests -v
```
## Fichiers
| Fichier | Rôle |
|---|---|
| `scenario_spec.json` | Les 2 scénarios · beats · pointeurs de preuve (**0 chiffre en dur**) |
| `scenlib/deps.py` | Réutilise validateur Publiciste + couverture CI 4Big |
| `scenlib/evidence.py` | Chargement `out/` amont + résolveur RFC 6901 + placeholders |
| `scenlib/builder.py` | Assemblage déterministe run-sheet + manifeste |
| `demo_scenario_gen.py` | CLI `build`/`validate` + invariants |
| `scenario.schema.json` | Contrat de sortie draft-07 du manifeste |
| `out/run_sheet.json` | **Hand-off principal** — run-sheet jouable |
| `out/MANIFEST.json` | Diagnostic + preuve de couverture + drapeau `pret` |
## Hors périmètre worker (VPS · #8)
Le rendu final (deck / prompteur / page démo `otov7.com`) et le branchement au
`data_room` réel des projets restent **côté serveur** — ce worker n'écrit jamais
sur le VPS. Le run-sheet est le **contrat** que ce rendu consommera.
## Provenance des libellés projets
`P05 Las Colinas Najayo Arriba` et `P07 Aqua Terra Las Terrenas` proviennent
**verbatim** de `CLAUDE.md · §Projets` — aucun projet inventé.
@@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""Générateur de scénarios démo (run-sheet de pitch) · Sprint 7 · CRM + Faisabilité.
Livrable **Sprint 7** (roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` l.68 :
« CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready) »).
C'est un **orchestrateur de méta-niveau** : il ne crée aucune donnée métier, il
COMPOSE les hand-offs `out/` déjà produits par les modules livrés en un run-sheet
de démo prêt à jouer (P07 banquier · P05 client). Toute valeur chiffrée du
run-sheet est RÉSOLUE depuis un artefact amont via un pointeur RFC 6901 — jamais
saisie à la main (anti-invention CLAUDE.md #6). Une valeur introuvable devient un
placeholder `{{…}}`, jamais un chiffre fabriqué.
Chaîne de valeur :
out/ des modules livrés (workflow, e-CF, CONFOTUR, RBAC, 4Big, portails, …)
│ pointeurs RFC 6901 (scenario_spec.json)
demo_scenario_gen ──► out/run_sheet.json + out/MANIFEST.json
▼ (VPS · hors périmètre worker · #8)
rendu deck / prompteur ─► démo publique otov7.com
Garanties (le CLI REFUSE d'écrire si l'une casse) :
· chaque module cité est GATED par le CI (`.gitea/workflows/ci.yml`) — la démo
ne s'appuie que sur du validé (réutilise la preuve de l'auditeur 4Big) ;
· chaque valeur est re-résolue indépendamment et comparée (anti-invention) ;
· l'artefact livré est `pret=true` (0 citation non résolue · 0 module non gated).
Sous-commandes :
build [-o OUT_DIR] → OUT_DIR/{run_sheet,MANIFEST}.json
validate → manifeste + invariants (sans écrire)
"""
from __future__ import annotations
import argparse
import json
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, _HERE)
from scenlib import builder, deps, evidence # noqa: E402
def _eprint(*args) -> None:
print(*args, file=sys.stderr)
def _load_spec() -> dict:
with open(os.path.join(_HERE, "scenario_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def _load_schema() -> dict:
with open(os.path.join(_HERE, "scenario.schema.json"), encoding="utf-8") as fh:
return json.load(fh)
# --------------------------------------------------------------------------- #
# Invariants — honnêteté #6 : le CLI n'écrit RIEN si l'un casse.
# --------------------------------------------------------------------------- #
def check_invariants(spec: dict, run_sheet: dict, manifest: dict) -> list[str]:
motifs: list[str] = []
# (a) manifeste conforme au schéma de sortie (validateur maison Publiciste).
motifs += [f"manifest: {e}" for e in deps.validate(manifest, _load_schema())]
# (b) identité des scénarios : ids uniques, projet/audience déclarés au spec.
ids = [sc["id"] for sc in run_sheet["scenarios"]]
if len(ids) != len(set(ids)):
motifs.append(f"ids de scénario non uniques : {ids}")
for sc in run_sheet["scenarios"]:
if sc["projet"] not in spec["projets"]:
motifs.append(f"{sc['id']} : projet {sc['projet']} absent du spec.projets")
if sc["audience"] not in spec["audiences"]:
motifs.append(f"{sc['id']} : audience {sc['audience']} hors spec.audiences")
if sc["projet_libelle"] != spec["projets"].get(sc["projet"]):
motifs.append(f"{sc['id']} : libellé projet incohérent avec le spec")
# (c) structure de chaque scénario : beats ordonnés 1..n, ≥3 beats,
# ≥2 modules distincts (un orchestrateur DOIT tisser plusieurs livrables),
# et jamais s'auto-citer (pas de preuve circulaire · SoD).
for sc in run_sheet["scenarios"]:
ordres = [b["ordre"] for b in sc["beats"]]
if ordres != list(range(1, len(sc["beats"]) + 1)):
motifs.append(f"{sc['id']} : ordres de beats non contigus {ordres}")
if len(sc["beats"]) < 3:
motifs.append(f"{sc['id']} : {len(sc['beats'])} beat(s) (min 3)")
mods = {c["module"] for b in sc["beats"] for c in b["evidence"]}
if len(mods) < 2:
motifs.append(f"{sc['id']} : {len(mods)} module cité (min 2 · orchestration)")
if builder.SELF_MODULE in mods:
motifs.append(f"{sc['id']} : auto-citation de {builder.SELF_MODULE} (SoD)")
# (d) ANTI-INVENTION : re-résolution indépendante de CHAQUE citation.
motifs += _check_anti_invention(spec, run_sheet)
# (e) placeholder ⟺ non résolu (aucune fuite de None ni de faux chiffre).
for sc in run_sheet["scenarios"]:
for b in sc["beats"]:
for c in b["evidence"]:
ph = evidence.is_placeholder(c["valeur"])
if c["resolu"] and (ph or c["valeur"] is None):
motifs.append(f"{c['module']}#{c['pointer']} : resolu mais valeur placeholder/None")
if not c["resolu"] and not ph:
motifs.append(f"{c['module']}#{c['pointer']} : non résolu sans placeholder")
# (f) comptes recalculés == manifeste (aucun compteur « posé »).
motifs += _check_counts(run_sheet, manifest)
# (g) couverture CI : tout module cité doit être gated + drapeau pret.
if not manifest["couverture_ci"]["ok"] or manifest["couverture_ci"]["modules_cites_non_gated"]:
motifs.append("module cité NON gated par le CI : "
f"{manifest['couverture_ci']['modules_cites_non_gated']}")
if not manifest["pret"]:
motifs.append("run-sheet non prêt (citations non résolues ou modules non gated) — "
"un livrable démo doit être prêt à jouer (#5)")
return motifs
def _check_anti_invention(spec: dict, run_sheet: dict) -> list[str]:
"""Re-résout chaque pointeur avec un loader neuf et compare au run-sheet.
Prouve qu'aucune valeur n'a été « posée » : elle DOIT redécouler de l'artefact
amont sur disque. Recoupe aussi spec ↔ run-sheet (mêmes pointeurs, même ordre).
"""
motifs: list[str] = []
loader = evidence.Loader()
spec_by_id = {sc["id"]: sc for sc in spec["scenarios"]}
for sc in run_sheet["scenarios"]:
spec_sc = spec_by_id.get(sc["id"])
if spec_sc is None:
motifs.append(f"{sc['id']} : scénario absent du spec")
continue
if len(spec_sc["beats"]) != len(sc["beats"]):
motifs.append(f"{sc['id']} : nb de beats spec≠run-sheet")
continue
for spec_b, b in zip(spec_sc["beats"], sc["beats"]):
spec_ev = spec_b["evidence"]
if len(spec_ev) != len(b["evidence"]):
motifs.append(f"{sc['id']}/{b['id']} : nb de citations spec≠run-sheet")
continue
for se, c in zip(spec_ev, b["evidence"]):
if (se["module"], se["file"], se["pointer"]) != \
(c["module"], c["file"], c["pointer"]):
motifs.append(f"{sc['id']}/{b['id']} : citation dérivée du spec")
continue
exp_val, exp_res = evidence.resolve(
loader, se["module"], se["file"], se["pointer"])
if exp_res != c["resolu"] or exp_val != c["valeur"]:
motifs.append(
f"{se['module']}#{se['pointer']} : run-sheet {c['valeur']!r} "
f"!= re-résolution {exp_val!r} (invention/dérive)")
return motifs
def _check_counts(run_sheet: dict, manifest: dict) -> list[str]:
motifs: list[str] = []
cites = [c for sc in run_sheet["scenarios"] for b in sc["beats"] for c in b["evidence"]]
exp = {
"scenarios": len(run_sheet["scenarios"]),
"beats_total": sum(len(sc["beats"]) for sc in run_sheet["scenarios"]),
"citations_total": len(cites),
"citations_non_resolues": sum(1 for c in cites if not c["resolu"]),
"modules_cites_uniques": len({c["module"] for c in cites}),
}
for cle, val in exp.items():
if manifest["counts"].get(cle) != val:
motifs.append(f"counts.{cle} : manifeste {manifest['counts'].get(cle)} != recalcul {val}")
return motifs
# --------------------------------------------------------------------------- #
def _assemble(generated_at: str | None) -> tuple[dict, dict, dict]:
spec = _load_spec()
run_sheet = builder.build_run_sheet(spec, generated_at=generated_at)
manifest = builder.build_manifest(spec, run_sheet)
return spec, run_sheet, manifest
def cmd_build(ns: argparse.Namespace) -> int:
spec, run_sheet, manifest = _assemble(ns.generated_at)
motifs = check_invariants(spec, run_sheet, manifest)
if motifs:
_eprint("❌ invariants cassés — RIEN écrit :")
for m in motifs:
_eprint(" ·", m)
return 1
os.makedirs(ns.out, exist_ok=True)
for name, obj in (("run_sheet.json", run_sheet), ("MANIFEST.json", manifest)):
with open(os.path.join(ns.out, name), "w", encoding="utf-8") as fh:
json.dump(obj, fh, ensure_ascii=False, indent=2)
fh.write("\n")
c = manifest["counts"]
_eprint(f"✅ run-sheet → {ns.out}")
_eprint(f" scénarios={c['scenarios']} · beats={c['beats_total']} · "
f"citations={c['citations_total']} (non résolues={c['citations_non_resolues']}) · "
f"modules cités={c['modules_cites_uniques']}")
for sc in manifest["scenarios"]:
_eprint(f" · {sc['id']} [{sc['audience']}] {sc['projet']}"
f"{sc['beats']} beats · {sc['duree_min']} min · {sc['citations_resolues']}/{sc['citations']} résolues")
_eprint(f" pret={manifest['pret']} · couverture CI ok={manifest['couverture_ci']['ok']}")
return 0
def cmd_validate(ns: argparse.Namespace) -> int:
spec, run_sheet, manifest = _assemble(ns.generated_at)
motifs = check_invariants(spec, run_sheet, manifest)
print(json.dumps(manifest, ensure_ascii=False, indent=2))
if motifs:
_eprint(f"{len(motifs)} invariant(s) cassé(s) :")
for m in motifs:
_eprint(" ·", m)
return 1
_eprint(f"✅ manifeste conforme + invariants OK (pret={manifest['pret']}).")
return 0
# --------------------------------------------------------------------------- #
def build_argparser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(prog="demo_scenario_gen",
description=__doc__.splitlines()[0])
sub = ap.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("build", help="scenario_spec.json → out/{run_sheet,MANIFEST}.json")
p.add_argument("-o", "--out", default=os.path.join(_HERE, "out"),
help="dossier de sortie (défaut: ./out)")
p.add_argument("--generated-at", dest="generated_at", default=None)
p.set_defaults(func=cmd_build)
p = sub.add_parser("validate", help="manifeste + invariants (sans écrire)")
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())
@@ -0,0 +1,69 @@
{
"deliverable": "Scénarios démo · run-sheet de pitch",
"sprint": "Sprint 7 · CRM + Faisabilité",
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 7 l.68 « CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready) »",
"generated_from": "scenario_spec.json",
"deliverables_root": "05_deliverables_mvp",
"self_module": "demo/scenarios",
"generated_at": null,
"scenarios": [
{
"id": "S-P07-BANQUIER",
"projet": "P07",
"audience": "banquier",
"beats": 6,
"citations": 13,
"citations_resolues": 13,
"duree_min": 12,
"modules_cites": [
"crm/dossier_vente",
"crm/workflow_vente",
"fiscal/ecf_dgii",
"legal/confotur",
"qa/audit_4big",
"rbac/apply_plan"
]
},
{
"id": "S-P05-CLIENT",
"projet": "P05",
"audience": "client",
"beats": 6,
"citations": 10,
"citations_resolues": 10,
"duree_min": 12,
"modules_cites": [
"crm/commissions",
"crm/dossier_vente",
"crm/workflow_vente",
"frontend/chat_otoia",
"frontend/portails",
"seo"
]
}
],
"counts": {
"scenarios": 2,
"beats_total": 12,
"citations_total": 23,
"citations_non_resolues": 0,
"modules_cites_uniques": 10
},
"modules_cites": [
"crm/commissions",
"crm/dossier_vente",
"crm/workflow_vente",
"fiscal/ecf_dgii",
"frontend/chat_otoia",
"frontend/portails",
"legal/confotur",
"qa/audit_4big",
"rbac/apply_plan",
"seo"
],
"couverture_ci": {
"ok": true,
"modules_cites_non_gated": []
},
"pret": true
}
@@ -0,0 +1,342 @@
{
"doc": "OTO · Run-sheet démo (méta-livrable)",
"spec_version": "1.0.0",
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 7 l.68 « CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready) »",
"generated_at": null,
"scenarios": [
{
"id": "S-P07-BANQUIER",
"projet": "P07",
"projet_libelle": "Aqua Terra Las Terrenas",
"audience": "banquier",
"titre": "P07 Aqua Terra — Pitch banquier (bancabilité & gouvernance)",
"objectif": "Démontrer qu'un dossier de vente OTO est bancable : pipeline structuré, conformité fiscale e-CF DGII, incitation CONFOTUR, gouvernance RBAC et gate qualité 4Big.",
"duree_min": 12,
"modules_cites": [
"crm/dossier_vente",
"crm/workflow_vente",
"fiscal/ecf_dgii",
"legal/confotur",
"qa/audit_4big",
"rbac/apply_plan"
],
"beats": [
{
"ordre": 1,
"id": "b1-pipeline",
"titre": "Un pipeline de vente verrouillé de bout en bout",
"role_narratif": "Ouvrir sur la traçabilité : la vente n'est pas un tableur, c'est un workflow ERPNext soumis (docstatus) piloté par rôle.",
"duree_min": 2,
"evidence": [
{
"module": "crm/workflow_vente",
"file": "out/workflow.json",
"pointer": "/0/workflow_name",
"label": "Nom du pipeline",
"valeur": "OTO Vente Pipeline",
"resolu": true
},
{
"module": "crm/workflow_vente",
"file": "out/workflow.json",
"pointer": "/0/document_type",
"label": "DocType piloté",
"valeur": "OTO Dossier Vente",
"resolu": true
}
]
},
{
"ordre": 2,
"id": "b2-ecf",
"titre": "Facturation fiscale conforme DGII (e-CF · Compupar)",
"role_narratif": "Rassurer sur le risque fiscal : chaque encaissement est un e-CF certifié, pas une facture maison.",
"duree_min": 2,
"evidence": [
{
"module": "fiscal/ecf_dgii",
"file": "out/MANIFEST.json",
"pointer": "/provider",
"label": "Fournisseur e-CF",
"valeur": "Compupar",
"resolu": true
},
{
"module": "fiscal/ecf_dgii",
"file": "out/MANIFEST.json",
"pointer": "/counts/tipos_en_scope",
"label": "Types e-CF en périmètre",
"valeur": 5,
"resolu": true
}
]
},
{
"ordre": 3,
"id": "b3-confotur",
"titre": "Incitation CONFOTUR dé-risquée et outillée",
"role_narratif": "Montrer l'avantage fiscal touristique comme un actif documenté : DocType soumis, dépôt outillé.",
"duree_min": 2,
"evidence": [
{
"module": "legal/confotur",
"file": "out/MANIFEST.json",
"pointer": "/doctype_name",
"label": "DocType CONFOTUR",
"valeur": "CONFOTUR Application",
"resolu": true
},
{
"module": "legal/confotur",
"file": "out/MANIFEST.json",
"pointer": "/is_submittable",
"label": "Soumissible (verrou légal)",
"valeur": true,
"resolu": true
},
{
"module": "legal/confotur",
"file": "out/MANIFEST.json",
"pointer": "/counts/depot_events",
"label": "Étapes de dépôt outillées",
"valeur": 2,
"resolu": true
}
]
},
{
"ordre": 4,
"id": "b4-rbac",
"titre": "Séparation des pouvoirs (RBAC 50 rôles)",
"role_narratif": "Répondre à la question d'audit du banquier : qui peut faire quoi ? Permissions dérivées, pas ad hoc.",
"duree_min": 2,
"evidence": [
{
"module": "rbac/apply_plan",
"file": "out/MANIFEST.json",
"pointer": "/counts/roles",
"label": "Rôles RBAC",
"valeur": 50,
"resolu": true
},
{
"module": "rbac/apply_plan",
"file": "out/MANIFEST.json",
"pointer": "/counts/custom_docperm",
"label": "Permissions DocType (Custom DocPerm)",
"valeur": 116,
"resolu": true
}
]
},
{
"ordre": 5,
"id": "b5-qualite",
"titre": "Un gate qualité 4Big, pas une promesse",
"role_narratif": "Clore la confiance : la plateforme s'auto-note et bloque sous 95/100 sur 100 % des livrables.",
"duree_min": 2,
"evidence": [
{
"module": "qa/audit_4big",
"file": "out/quality_report.json",
"pointer": "/pass_score",
"label": "Seuil bloquant 4Big",
"valeur": 95,
"resolu": true
},
{
"module": "qa/audit_4big",
"file": "out/quality_report.json",
"pointer": "/coverage/ci_modules_count",
"label": "Modules sous gate qualité",
"valeur": 18,
"resolu": true
}
]
},
{
"ordre": 6,
"id": "b6-dossier",
"titre": "Le dossier de vente, pièce bancable finale",
"role_narratif": "Refermer sur l'objet concret que le banquier instruira : un DocType riche et soumis.",
"duree_min": 2,
"evidence": [
{
"module": "crm/dossier_vente",
"file": "out/MANIFEST.json",
"pointer": "/counts/fields",
"label": "Champs du dossier de vente",
"valeur": 30,
"resolu": true
},
{
"module": "crm/dossier_vente",
"file": "out/MANIFEST.json",
"pointer": "/counts/pipeline_states",
"label": "États de pipeline couverts",
"valeur": 9,
"resolu": true
}
]
}
]
},
{
"id": "S-P05-CLIENT",
"projet": "P05",
"projet_libelle": "Las Colinas Najayo Arriba",
"audience": "client",
"titre": "P05 Las Colinas Najayo Arriba — Parcours client (réservation prête)",
"objectif": "Faire vivre le parcours d'un acheteur : découverte du portail, assistance OTOIA, pipeline lisible, dossier transparent, et comment le client nous a trouvés (SEO trilingue).",
"duree_min": 12,
"modules_cites": [
"crm/commissions",
"crm/dossier_vente",
"crm/workflow_vente",
"frontend/chat_otoia",
"frontend/portails",
"seo"
],
"beats": [
{
"ordre": 1,
"id": "b1-portail",
"titre": "« Choisir mon unité » — 5 portails luxury",
"role_narratif": "Poser l'expérience d'entrée : un portail par rôle, cohérent et couvert.",
"duree_min": 2,
"evidence": [
{
"module": "frontend/portails",
"file": "out/MANIFEST.json",
"pointer": "/counts/workspaces",
"label": "Portails (workspaces)",
"valeur": 5,
"resolu": true
},
{
"module": "frontend/portails",
"file": "out/MANIFEST.json",
"pointer": "/counts/roles_couverts",
"label": "Rôles couverts par les portails",
"valeur": 44,
"resolu": true
}
]
},
{
"ordre": 2,
"id": "b2-otoia",
"titre": "OTOIA répond dans le portail",
"role_narratif": "Montrer l'assistance embarquée : le client n'est jamais seul face à l'écran.",
"duree_min": 2,
"evidence": [
{
"module": "frontend/chat_otoia",
"file": "out/MANIFEST.json",
"pointer": "/deliverable",
"label": "Assistant embarqué",
"valeur": "Chat OTOIA embarqué par portail",
"resolu": true
}
]
},
{
"ordre": 3,
"id": "b3-parcours",
"titre": "Du premier contact à la réservation",
"role_narratif": "Dérouler le pipeline côté client : Lead → … → Réservation confirmée, lisible et rassurant.",
"duree_min": 2,
"evidence": [
{
"module": "crm/workflow_vente",
"file": "out/workflow.json",
"pointer": "/0/states/0/state",
"label": "Première étape",
"valeur": "Lead",
"resolu": true
},
{
"module": "crm/workflow_vente",
"file": "out/workflow.json",
"pointer": "/0/states/3/state",
"label": "Étape réservation",
"valeur": "Réservation confirmée",
"resolu": true
}
]
},
{
"ordre": 4,
"id": "b4-dossier",
"titre": "Un dossier de vente transparent",
"role_narratif": "Donner confiance : tout est consigné dans un dossier structuré que le client peut suivre.",
"duree_min": 2,
"evidence": [
{
"module": "crm/dossier_vente",
"file": "out/MANIFEST.json",
"pointer": "/counts/fields",
"label": "Champs du dossier de vente",
"valeur": 30,
"resolu": true
}
]
},
{
"ordre": 5,
"id": "b5-commissions",
"titre": "Un conseiller rémunéré à la transparence",
"role_narratif": "Aligner l'intérêt du conseiller et du client : barème de commissions traçable par événement.",
"duree_min": 2,
"evidence": [
{
"module": "crm/commissions",
"file": "out/MANIFEST.json",
"pointer": "/bareme_name",
"label": "Barème de commissions",
"valeur": "OTO Barème Commissions Ventes",
"resolu": true
},
{
"module": "crm/commissions",
"file": "out/MANIFEST.json",
"pointer": "/counts/evenements",
"label": "Événements déclencheurs",
"valeur": 5,
"resolu": true
}
]
},
{
"ordre": 6,
"id": "b6-seo",
"titre": "Comment le client nous a trouvés (SEO trilingue)",
"role_narratif": "Refermer la boucle acquisition : indexation FR/EN/ES, le parcours commence bien avant le portail.",
"duree_min": 2,
"evidence": [
{
"module": "seo",
"file": "out/MANIFEST.json",
"pointer": "/counts/keywords_total",
"label": "Mots-clés indexés",
"valeur": 258,
"resolu": true
},
{
"module": "seo",
"file": "out/MANIFEST.json",
"pointer": "/langs",
"label": "Langues",
"valeur": [
"fr",
"en",
"es"
],
"resolu": true
}
]
}
]
}
]
}
@@ -0,0 +1,73 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://otov7.com/schemas/demo-scenario-manifest-1.0.0.json",
"title": "OTO · Manifeste Scénarios démo (run-sheet)",
"type": "object",
"additionalProperties": false,
"required": [
"deliverable", "sprint", "roadmap_ref", "generated_from",
"deliverables_root", "self_module", "generated_at", "scenarios",
"counts", "modules_cites", "couverture_ci", "pret"
],
"properties": {
"deliverable": {"type": "string", "minLength": 1},
"sprint": {"type": "string", "minLength": 1},
"roadmap_ref": {"type": "string", "minLength": 1},
"generated_from": {"type": "string", "const": "scenario_spec.json"},
"deliverables_root": {"type": "string", "const": "05_deliverables_mvp"},
"self_module": {"type": "string", "const": "demo/scenarios"},
"generated_at": {"type": ["string", "null"]},
"scenarios": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "projet", "audience", "beats", "citations",
"citations_resolues", "duree_min", "modules_cites"],
"properties": {
"id": {"type": "string", "pattern": "^S-P0[1-9]-[A-Z]+$"},
"projet": {"type": "string", "pattern": "^P0[1-9]$"},
"audience": {"type": "string", "enum": ["banquier", "client"]},
"beats": {"type": "integer", "minimum": 3},
"citations": {"type": "integer", "minimum": 1},
"citations_resolues": {"type": "integer", "minimum": 0},
"duree_min": {"type": "integer", "minimum": 1},
"modules_cites": {
"type": "array", "minItems": 2, "uniqueItems": true,
"items": {"type": "string", "minLength": 1}
}
}
}
},
"counts": {
"type": "object",
"additionalProperties": false,
"required": ["scenarios", "beats_total", "citations_total",
"citations_non_resolues", "modules_cites_uniques"],
"properties": {
"scenarios": {"type": "integer", "minimum": 1},
"beats_total": {"type": "integer", "minimum": 1},
"citations_total": {"type": "integer", "minimum": 1},
"citations_non_resolues": {"type": "integer", "minimum": 0},
"modules_cites_uniques": {"type": "integer", "minimum": 1}
}
},
"modules_cites": {
"type": "array", "minItems": 1, "uniqueItems": true,
"items": {"type": "string", "minLength": 1}
},
"couverture_ci": {
"type": "object",
"additionalProperties": false,
"required": ["ok", "modules_cites_non_gated"],
"properties": {
"ok": {"type": "boolean"},
"modules_cites_non_gated": {
"type": "array", "items": {"type": "string", "minLength": 1}
}
}
},
"pret": {"type": "boolean"}
}
}
@@ -0,0 +1,151 @@
{
"spec_version": "1.0.0",
"deliverable": "Scénarios démo · run-sheet de pitch",
"roadmap_ref": "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint 7 l.68 « CRM + Faisabilité : Scénarios démo (P07 pitch banquier · P05 client ready) »",
"self_module": "demo/scenarios",
"audiences": ["banquier", "client"],
"projets": {
"P05": "Las Colinas Najayo Arriba",
"P07": "Aqua Terra Las Terrenas"
},
"note_anti_invention": "Aucune valeur chiffrée n'est écrite dans ce spec. Chaque `evidence` est un POINTEUR (RFC 6901) vers un hand-off `out/` d'un module déjà livré et gated par le CI ; le générateur résout la valeur à la construction. Une valeur non résolue devient un placeholder `{{module:file#pointer}}` — jamais un chiffre fabriqué (CLAUDE.md #6).",
"scenarios": [
{
"id": "S-P07-BANQUIER",
"projet": "P07",
"audience": "banquier",
"titre": "P07 Aqua Terra — Pitch banquier (bancabilité & gouvernance)",
"objectif": "Démontrer qu'un dossier de vente OTO est bancable : pipeline structuré, conformité fiscale e-CF DGII, incitation CONFOTUR, gouvernance RBAC et gate qualité 4Big.",
"beats": [
{
"id": "b1-pipeline",
"titre": "Un pipeline de vente verrouillé de bout en bout",
"role_narratif": "Ouvrir sur la traçabilité : la vente n'est pas un tableur, c'est un workflow ERPNext soumis (docstatus) piloté par rôle.",
"duree_min": 2,
"evidence": [
{"module": "crm/workflow_vente", "file": "out/workflow.json", "pointer": "/0/workflow_name", "label": "Nom du pipeline"},
{"module": "crm/workflow_vente", "file": "out/workflow.json", "pointer": "/0/document_type", "label": "DocType piloté"}
]
},
{
"id": "b2-ecf",
"titre": "Facturation fiscale conforme DGII (e-CF · Compupar)",
"role_narratif": "Rassurer sur le risque fiscal : chaque encaissement est un e-CF certifié, pas une facture maison.",
"duree_min": 2,
"evidence": [
{"module": "fiscal/ecf_dgii", "file": "out/MANIFEST.json", "pointer": "/provider", "label": "Fournisseur e-CF"},
{"module": "fiscal/ecf_dgii", "file": "out/MANIFEST.json", "pointer": "/counts/tipos_en_scope", "label": "Types e-CF en périmètre"}
]
},
{
"id": "b3-confotur",
"titre": "Incitation CONFOTUR dé-risquée et outillée",
"role_narratif": "Montrer l'avantage fiscal touristique comme un actif documenté : DocType soumis, dépôt outillé.",
"duree_min": 2,
"evidence": [
{"module": "legal/confotur", "file": "out/MANIFEST.json", "pointer": "/doctype_name", "label": "DocType CONFOTUR"},
{"module": "legal/confotur", "file": "out/MANIFEST.json", "pointer": "/is_submittable", "label": "Soumissible (verrou légal)"},
{"module": "legal/confotur", "file": "out/MANIFEST.json", "pointer": "/counts/depot_events", "label": "Étapes de dépôt outillées"}
]
},
{
"id": "b4-rbac",
"titre": "Séparation des pouvoirs (RBAC 50 rôles)",
"role_narratif": "Répondre à la question d'audit du banquier : qui peut faire quoi ? Permissions dérivées, pas ad hoc.",
"duree_min": 2,
"evidence": [
{"module": "rbac/apply_plan", "file": "out/MANIFEST.json", "pointer": "/counts/roles", "label": "Rôles RBAC"},
{"module": "rbac/apply_plan", "file": "out/MANIFEST.json", "pointer": "/counts/custom_docperm", "label": "Permissions DocType (Custom DocPerm)"}
]
},
{
"id": "b5-qualite",
"titre": "Un gate qualité 4Big, pas une promesse",
"role_narratif": "Clore la confiance : la plateforme s'auto-note et bloque sous 95/100 sur 100 % des livrables.",
"duree_min": 2,
"evidence": [
{"module": "qa/audit_4big", "file": "out/quality_report.json", "pointer": "/pass_score", "label": "Seuil bloquant 4Big"},
{"module": "qa/audit_4big", "file": "out/quality_report.json", "pointer": "/coverage/ci_modules_count", "label": "Modules sous gate qualité"}
]
},
{
"id": "b6-dossier",
"titre": "Le dossier de vente, pièce bancable finale",
"role_narratif": "Refermer sur l'objet concret que le banquier instruira : un DocType riche et soumis.",
"duree_min": 2,
"evidence": [
{"module": "crm/dossier_vente", "file": "out/MANIFEST.json", "pointer": "/counts/fields", "label": "Champs du dossier de vente"},
{"module": "crm/dossier_vente", "file": "out/MANIFEST.json", "pointer": "/counts/pipeline_states", "label": "États de pipeline couverts"}
]
}
]
},
{
"id": "S-P05-CLIENT",
"projet": "P05",
"audience": "client",
"titre": "P05 Las Colinas Najayo Arriba — Parcours client (réservation prête)",
"objectif": "Faire vivre le parcours d'un acheteur : découverte du portail, assistance OTOIA, pipeline lisible, dossier transparent, et comment le client nous a trouvés (SEO trilingue).",
"beats": [
{
"id": "b1-portail",
"titre": "« Choisir mon unité » — 5 portails luxury",
"role_narratif": "Poser l'expérience d'entrée : un portail par rôle, cohérent et couvert.",
"duree_min": 2,
"evidence": [
{"module": "frontend/portails", "file": "out/MANIFEST.json", "pointer": "/counts/workspaces", "label": "Portails (workspaces)"},
{"module": "frontend/portails", "file": "out/MANIFEST.json", "pointer": "/counts/roles_couverts", "label": "Rôles couverts par les portails"}
]
},
{
"id": "b2-otoia",
"titre": "OTOIA répond dans le portail",
"role_narratif": "Montrer l'assistance embarquée : le client n'est jamais seul face à l'écran.",
"duree_min": 2,
"evidence": [
{"module": "frontend/chat_otoia", "file": "out/MANIFEST.json", "pointer": "/deliverable", "label": "Assistant embarqué"}
]
},
{
"id": "b3-parcours",
"titre": "Du premier contact à la réservation",
"role_narratif": "Dérouler le pipeline côté client : Lead → … → Réservation confirmée, lisible et rassurant.",
"duree_min": 2,
"evidence": [
{"module": "crm/workflow_vente", "file": "out/workflow.json", "pointer": "/0/states/0/state", "label": "Première étape"},
{"module": "crm/workflow_vente", "file": "out/workflow.json", "pointer": "/0/states/3/state", "label": "Étape réservation"}
]
},
{
"id": "b4-dossier",
"titre": "Un dossier de vente transparent",
"role_narratif": "Donner confiance : tout est consigné dans un dossier structuré que le client peut suivre.",
"duree_min": 2,
"evidence": [
{"module": "crm/dossier_vente", "file": "out/MANIFEST.json", "pointer": "/counts/fields", "label": "Champs du dossier de vente"}
]
},
{
"id": "b5-commissions",
"titre": "Un conseiller rémunéré à la transparence",
"role_narratif": "Aligner l'intérêt du conseiller et du client : barème de commissions traçable par événement.",
"duree_min": 2,
"evidence": [
{"module": "crm/commissions", "file": "out/MANIFEST.json", "pointer": "/bareme_name", "label": "Barème de commissions"},
{"module": "crm/commissions", "file": "out/MANIFEST.json", "pointer": "/counts/evenements", "label": "Événements déclencheurs"}
]
},
{
"id": "b6-seo",
"titre": "Comment le client nous a trouvés (SEO trilingue)",
"role_narratif": "Refermer la boucle acquisition : indexation FR/EN/ES, le parcours commence bien avant le portail.",
"duree_min": 2,
"evidence": [
{"module": "seo", "file": "out/MANIFEST.json", "pointer": "/counts/keywords_total", "label": "Mots-clés indexés"},
{"module": "seo", "file": "out/MANIFEST.json", "pointer": "/langs", "label": "Langues"}
]
}
]
}
]
}
@@ -0,0 +1,9 @@
"""Bibliothèque du générateur de scénarios démo (run-sheet de pitch).
Découpage :
- deps : réutilisation des briques déjà livrées (validateur maison +
preuve de couverture CI) · zéro duplication (#5).
- evidence : chargement des hand-offs `out/` amont + résolution de pointeurs
RFC 6901 · cœur anti-invention (#6).
- builder : assemblage déterministe du run-sheet + du manifeste.
"""
@@ -0,0 +1,123 @@
"""Assemblage déterministe du run-sheet démo + du manifeste (hand-off).
Le builder est PUR : mêmes entrées (spec + artefacts `out/` sur disque) ⇒ même
sortie. Aucun horodatage implicite, aucun ordre non déterministe. L'ordre des
beats est dérivé de la position dans le spec (jamais d'un champ `ordre` en dur,
pour éviter toute dérive). Les modules cités sont recoupés avec l'ensemble des
modules gated par le CI (via `deps.ci_module_paths`) — une démo ne s'appuie que
sur du validé.
"""
from __future__ import annotations
from . import deps, evidence
SELF_MODULE = "demo/scenarios"
def _build_beat(loader: evidence.Loader, beat: dict, ordre: int) -> dict:
cites = []
for ev in beat["evidence"]:
module, file, pointer = ev["module"], ev["file"], ev["pointer"]
valeur, resolu = evidence.resolve(loader, module, file, pointer)
cites.append({
"module": module,
"file": file,
"pointer": pointer,
"label": ev["label"],
"valeur": valeur,
"resolu": resolu,
})
return {
"ordre": ordre,
"id": beat["id"],
"titre": beat["titre"],
"role_narratif": beat["role_narratif"],
"duree_min": beat["duree_min"],
"evidence": cites,
}
def _build_scenario(loader: evidence.Loader, spec: dict, sc: dict) -> dict:
beats = [_build_beat(loader, b, i + 1) for i, b in enumerate(sc["beats"])]
modules = sorted({c["module"] for b in beats for c in b["evidence"]})
duree = sum(b["duree_min"] for b in beats)
return {
"id": sc["id"],
"projet": sc["projet"],
"projet_libelle": spec["projets"][sc["projet"]],
"audience": sc["audience"],
"titre": sc["titre"],
"objectif": sc["objectif"],
"duree_min": duree,
"modules_cites": modules,
"beats": beats,
}
def build_run_sheet(spec: dict, generated_at: str | None = None) -> dict:
"""Construit le run-sheet complet (le hand-off principal)."""
loader = evidence.Loader()
scenarios = [_build_scenario(loader, spec, sc) for sc in spec["scenarios"]]
return {
"doc": "OTO · Run-sheet démo (méta-livrable)",
"spec_version": spec["spec_version"],
"roadmap_ref": spec["roadmap_ref"],
"generated_at": generated_at,
"scenarios": scenarios,
}
def _all_citations(run_sheet: dict) -> list[dict]:
return [c for sc in run_sheet["scenarios"] for b in sc["beats"] for c in b["evidence"]]
def build_manifest(spec: dict, run_sheet: dict) -> dict:
"""Manifeste diagnostic + preuve de couverture CI + drapeau `pret`."""
cites = _all_citations(run_sheet)
non_resolues = [c for c in cites if not c["resolu"]]
modules_cites = sorted({c["module"] for c in cites})
gated = deps.ci_module_paths() # modules réellement gated par le CI
non_gated = sorted(m for m in modules_cites if m not in gated)
scen_diag = []
for sc in run_sheet["scenarios"]:
sc_cites = [c for b in sc["beats"] for c in b["evidence"]]
scen_diag.append({
"id": sc["id"],
"projet": sc["projet"],
"audience": sc["audience"],
"beats": len(sc["beats"]),
"citations": len(sc_cites),
"citations_resolues": sum(1 for c in sc_cites if c["resolu"]),
"duree_min": sc["duree_min"],
"modules_cites": sc["modules_cites"],
})
couverture_ok = not non_gated
pret = (not non_resolues) and couverture_ok
return {
"deliverable": "Scénarios démo · run-sheet de pitch",
"sprint": "Sprint 7 · CRM + Faisabilité",
"roadmap_ref": spec["roadmap_ref"],
"generated_from": "scenario_spec.json",
"deliverables_root": "05_deliverables_mvp",
"self_module": SELF_MODULE,
"generated_at": run_sheet["generated_at"],
"scenarios": scen_diag,
"counts": {
"scenarios": len(run_sheet["scenarios"]),
"beats_total": sum(len(sc["beats"]) for sc in run_sheet["scenarios"]),
"citations_total": len(cites),
"citations_non_resolues": len(non_resolues),
"modules_cites_uniques": len(modules_cites),
},
"modules_cites": modules_cites,
"couverture_ci": {
"ok": couverture_ok,
"modules_cites_non_gated": non_gated,
},
"pret": pret,
}
@@ -0,0 +1,44 @@
"""Réutilisation des briques déjà livrées (workflow #5 · zéro duplication).
L'orchestrateur démo ne redéfinit RIEN qui existe ailleurs :
- `validate` : le validateur JSON-Schema maison du Publiciste (draft-07,
sous-ensemble) — le run-sheet est validé SANS pip, car le
gate CI Gitea Actions tourne sans réseau (CLAUDE.md #2).
- `ci_module_paths` : la PREUVE de couverture de l'auditeur 4Big
(`qa/audit_4big/q4lib/registry.py`), qui lit
`.gitea/workflows/ci.yml` pour connaître les modules
réellement gated. On la réutilise pour garantir qu'une
démo ne s'appuie QUE sur des livrables validés par le CI —
jamais sur du code non gated.
Import par manipulation de `sys.path` (idiome des modules voisins :
`banclib/deps.py`, `q4lib/deps.py`) plutôt que duplication — une seule source de
vérité pour tout le mandat.
"""
from __future__ import annotations
import os
import sys
_HERE = os.path.dirname(os.path.abspath(__file__))
# demo/scenarios/scenlib → 05_deliverables_mvp
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
_PUB = os.path.join(_DELIVERABLES, "publiciste")
_AUDIT = os.path.join(_DELIVERABLES, "qa", "audit_4big")
for _p in (_PUB, _AUDIT):
if _p not in sys.path:
sys.path.insert(0, _p)
from lib import validator # type: ignore # noqa: E402
from q4lib import registry # type: ignore # noqa: E402
validate = validator.validate
ci_module_paths = registry.ci_module_paths
# Racine des livrables, exposée aux autres modules scenlib.
DELIVERABLES_ROOT = _DELIVERABLES
__all__ = ["validate", "ci_module_paths", "DELIVERABLES_ROOT"]
@@ -0,0 +1,112 @@
"""Cœur anti-invention (#6) : résolution des preuves depuis les hand-offs amont.
Un scénario démo ne contient AUCUN chiffre en dur. Chaque « evidence » du spec est
un pointeur `(module, file, pointer)` vers un artefact `out/` déjà produit par un
module livré. Ce fichier :
1. charge l'artefact JSON (avec cache) et signale toute absence / JSON invalide ;
2. résout un pointeur RFC 6901 (`/0/counts/fields`) vers sa valeur verbatim ;
3. quand la valeur n'existe pas, renvoie un PLACEHOLDER `{{module:file#pointer}}`
— jamais un chiffre fabriqué, jamais un `0` posé.
La résolution est PURE et rejouable : l'invariant `INV_ANTI_INVENTION` du CLI
re-résout indépendamment chaque pointeur et compare à ce qui figure dans le
run-sheet — impossible de « poser » une valeur à la main.
"""
from __future__ import annotations
import json
import os
from typing import Any
from .deps import DELIVERABLES_ROOT
# Sentinelle « non résolu » — un placeholder lisible, jamais confondable avec une
# vraie valeur (ni un nombre, ni une chaîne métier).
_MISSING = object()
def placeholder(module: str, file: str, pointer: str) -> str:
return f"{{{{{module}:{file}#{pointer}}}}}"
def is_placeholder(value: Any) -> bool:
return isinstance(value, str) and value.startswith("{{") and value.endswith("}}")
def artifact_path(module: str, file: str) -> str:
"""Chemin absolu de l'artefact `out/` (jamais hors des livrables)."""
return os.path.join(DELIVERABLES_ROOT, module, file)
class Loader:
"""Charge et met en cache les artefacts amont pour une passe de build."""
def __init__(self) -> None:
self._cache: dict[str, Any] = {}
self._errors: dict[str, str] = {}
def load(self, module: str, file: str) -> Any:
key = f"{module}/{file}"
if key in self._cache or key in self._errors:
return self._cache.get(key, _MISSING)
path = artifact_path(module, file)
if not os.path.isfile(path):
self._errors[key] = "artefact absent"
return _MISSING
try:
with open(path, encoding="utf-8") as fh:
obj = json.load(fh)
except (OSError, ValueError) as exc:
self._errors[key] = f"JSON invalide : {exc}"
return _MISSING
self._cache[key] = obj
return obj
def errors(self) -> dict[str, str]:
return dict(self._errors)
def _split_pointer(pointer: str) -> list[str]:
"""Découpe un pointeur RFC 6901 (`/a/0/b`) en tokens déséchappés."""
if pointer == "":
return []
if not pointer.startswith("/"):
raise ValueError(f"pointeur RFC 6901 invalide (doit commencer par '/') : {pointer!r}")
tokens = pointer.split("/")[1:]
return [t.replace("~1", "/").replace("~0", "~") for t in tokens]
def _descend(obj: Any, token: str) -> Any:
"""Un pas de descente : clé d'objet ou index de liste (jamais d'erreur)."""
if isinstance(obj, dict):
return obj.get(token, _MISSING)
if isinstance(obj, list):
if token.lstrip("-").isdigit():
idx = int(token)
if -len(obj) <= idx < len(obj):
return obj[idx]
return _MISSING
return _MISSING
def resolve(loader: Loader, module: str, file: str, pointer: str) -> tuple[Any, bool]:
"""Résout un pointeur en `(valeur, resolu)`.
`resolu=False` ⇒ `valeur` est un placeholder `{{…}}` (artefact absent / JSON
invalide / chemin inexistant). `resolu=True` ⇒ valeur verbatim non `None`.
Une valeur littérale `null` dans l'artefact est traitée comme NON résolue :
un chiffre « à confirmer » ne doit jamais alimenter un pitch (#6).
"""
root = loader.load(module, file)
if root is _MISSING:
return placeholder(module, file, pointer), False
node: Any = root
for token in _split_pointer(pointer):
node = _descend(node, token)
if node is _MISSING:
return placeholder(module, file, pointer), False
if node is None:
return placeholder(module, file, pointer), False
return node, True
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Tests du générateur de scénarios démo (run-sheet).
Couvre : assemblage réel (les artefacts `out/` amont doivent exister et être
gated), résolution de pointeurs RFC 6901, cœur anti-invention (#6), invariants,
et un lot d'INJECTIONS NÉGATIVES prouvant que chaque garde-fou rougit vraiment.
"""
from __future__ import annotations
import copy
import json
import os
import sys
import unittest
_HERE = os.path.dirname(os.path.abspath(__file__))
_MOD = os.path.normpath(os.path.join(_HERE, ".."))
sys.path.insert(0, _MOD)
import demo_scenario_gen as gen # noqa: E402
from scenlib import builder, evidence # noqa: E402
def _fresh():
"""(spec, run_sheet, manifest) construits depuis le dépôt réel."""
return gen._assemble(generated_at=None)
class TestBuildReel(unittest.TestCase):
def setUp(self):
self.spec, self.run_sheet, self.manifest = _fresh()
def test_build_sans_invariant_casse(self):
self.assertEqual(gen.check_invariants(self.spec, self.run_sheet, self.manifest), [])
def test_deux_scenarios_p07_p05(self):
ids = {sc["id"] for sc in self.run_sheet["scenarios"]}
self.assertEqual(ids, {"S-P07-BANQUIER", "S-P05-CLIENT"})
projets = {sc["projet"] for sc in self.run_sheet["scenarios"]}
self.assertEqual(projets, {"P05", "P07"})
def test_audiences_declarees(self):
for sc in self.run_sheet["scenarios"]:
self.assertIn(sc["audience"], self.spec["audiences"])
def test_libelles_projets_sources_du_spec(self):
for sc in self.run_sheet["scenarios"]:
self.assertEqual(sc["projet_libelle"], self.spec["projets"][sc["projet"]])
def test_beats_ordonnes_et_min_trois(self):
for sc in self.run_sheet["scenarios"]:
self.assertGreaterEqual(len(sc["beats"]), 3)
self.assertEqual([b["ordre"] for b in sc["beats"]],
list(range(1, len(sc["beats"]) + 1)))
def test_orchestration_min_deux_modules(self):
for sc in self.run_sheet["scenarios"]:
self.assertGreaterEqual(len(set(sc["modules_cites"])), 2)
def test_toutes_citations_resolues(self):
self.assertEqual(self.manifest["counts"]["citations_non_resolues"], 0)
for sc in self.run_sheet["scenarios"]:
for b in sc["beats"]:
for c in b["evidence"]:
self.assertTrue(c["resolu"])
self.assertFalse(evidence.is_placeholder(c["valeur"]))
self.assertIsNotNone(c["valeur"])
def test_pret_et_couverture_ci(self):
self.assertTrue(self.manifest["pret"])
self.assertTrue(self.manifest["couverture_ci"]["ok"])
self.assertEqual(self.manifest["couverture_ci"]["modules_cites_non_gated"], [])
def test_pas_auto_citation(self):
mods = {c["module"] for sc in self.run_sheet["scenarios"]
for b in sc["beats"] for c in b["evidence"]}
self.assertNotIn(builder.SELF_MODULE, mods)
def test_manifest_conforme_schema(self):
self.assertEqual(gen.deps.validate(self.manifest, gen._load_schema()), [])
def test_duree_est_somme_des_beats(self):
for sc in self.run_sheet["scenarios"]:
self.assertEqual(sc["duree_min"], sum(b["duree_min"] for b in sc["beats"]))
def test_determinisme(self):
_, rs2, mf2 = _fresh()
self.assertEqual(self.run_sheet, rs2)
self.assertEqual(self.manifest, mf2)
def test_valeurs_verbatim_depuis_artefacts(self):
# Une valeur du run-sheet doit être EXACTEMENT celle du fichier amont.
wf = json.load(open(os.path.join(
builder.deps.DELIVERABLES_ROOT, "crm/workflow_vente/out/workflow.json"),
encoding="utf-8"))
beat1 = self.run_sheet["scenarios"][0]["beats"][0]["evidence"][0]
self.assertEqual(beat1["valeur"], wf[0]["workflow_name"])
class TestPointeurRFC6901(unittest.TestCase):
def setUp(self):
self.loader = evidence.Loader()
def test_split_racine_et_echappement(self):
self.assertEqual(evidence._split_pointer(""), [])
self.assertEqual(evidence._split_pointer("/a/b"), ["a", "b"])
self.assertEqual(evidence._split_pointer("/a~1b/c~0d"), ["a/b", "c~d"])
def test_split_refuse_pointeur_sans_slash(self):
with self.assertRaises(ValueError):
evidence._split_pointer("a/b")
def test_descend_cle_index_negatif(self):
self.assertEqual(evidence._descend({"x": 1}, "x"), 1)
self.assertEqual(evidence._descend([10, 20, 30], "0"), 10)
self.assertEqual(evidence._descend([10, 20, 30], "-1"), 30)
self.assertIs(evidence._descend([1], "5"), evidence._MISSING)
self.assertIs(evidence._descend(42, "x"), evidence._MISSING)
def test_resolve_valeur_existante(self):
val, ok = evidence.resolve(self.loader, "crm/workflow_vente",
"out/workflow.json", "/0/document_type")
self.assertTrue(ok)
self.assertEqual(val, "OTO Dossier Vente")
def test_resolve_null_est_non_resolu(self):
# `condition: null` dans une transition → placeholder, pas résolu (#6).
val, ok = evidence.resolve(self.loader, "crm/workflow_vente",
"out/workflow.json", "/0/transitions/0/condition")
self.assertFalse(ok)
self.assertTrue(evidence.is_placeholder(val))
def test_resolve_artefact_absent(self):
val, ok = evidence.resolve(self.loader, "module/inexistant",
"out/rien.json", "/x")
self.assertFalse(ok)
self.assertEqual(val, "{{module/inexistant:out/rien.json#/x}}")
def test_resolve_pointeur_manquant(self):
val, ok = evidence.resolve(self.loader, "crm/workflow_vente",
"out/workflow.json", "/0/champ_absent")
self.assertFalse(ok)
self.assertTrue(evidence.is_placeholder(val))
def test_is_placeholder(self):
self.assertTrue(evidence.is_placeholder("{{a:b#/c}}"))
self.assertFalse(evidence.is_placeholder("valeur"))
self.assertFalse(evidence.is_placeholder(258))
class TestInjectionsNegatives(unittest.TestCase):
"""Chaque garde-fou DOIT rougir sur une dégradation ciblée."""
def setUp(self):
self.spec, self.run_sheet, self.manifest = _fresh()
def _motifs(self, rs, mf):
return gen.check_invariants(self.spec, rs, mf)
def test_anti_invention_valeur_falsifiee(self):
rs = copy.deepcopy(self.run_sheet)
rs["scenarios"][0]["beats"][0]["evidence"][0]["valeur"] = "CHIFFRE INVENTÉ"
mf = builder.build_manifest(self.spec, rs)
self.assertTrue(any("invention/dérive" in m for m in self._motifs(rs, mf)))
def test_compteur_falsifie(self):
mf = copy.deepcopy(self.manifest)
mf["counts"]["citations_total"] += 1
motifs = self._motifs(self.run_sheet, mf)
self.assertTrue(any("counts.citations_total" in m for m in motifs))
def test_placeholder_incoherent(self):
rs = copy.deepcopy(self.run_sheet)
c = rs["scenarios"][0]["beats"][0]["evidence"][0]
c["resolu"] = False # résolu marqué faux mais valeur reste réelle
mf = builder.build_manifest(self.spec, rs)
self.assertTrue(any("non résolu sans placeholder" in m
or "invention/dérive" in m for m in self._motifs(rs, mf)))
def test_module_non_gated_detecte(self):
# On force couverture_ci en KO → invariant (g) doit rougir.
mf = copy.deepcopy(self.manifest)
mf["couverture_ci"]["ok"] = False
mf["couverture_ci"]["modules_cites_non_gated"] = ["module/hors-ci"]
self.assertTrue(any("NON gated" in m for m in self._motifs(self.run_sheet, mf)))
def test_non_pret_bloque(self):
mf = copy.deepcopy(self.manifest)
mf["pret"] = False
self.assertTrue(any("non prêt" in m for m in self._motifs(self.run_sheet, mf)))
def test_auto_citation_detectee(self):
rs = copy.deepcopy(self.run_sheet)
rs["scenarios"][0]["beats"][0]["evidence"][0]["module"] = builder.SELF_MODULE
mf = builder.build_manifest(self.spec, rs)
self.assertTrue(any("auto-citation" in m or "invention/dérive" in m
for m in self._motifs(rs, mf)))
def test_ordres_non_contigus_detectes(self):
rs = copy.deepcopy(self.run_sheet)
rs["scenarios"][0]["beats"][0]["ordre"] = 99
mf = builder.build_manifest(self.spec, rs)
self.assertTrue(any("non contigus" in m for m in self._motifs(rs, mf)))
def test_schema_rejette_beats_insuffisants(self):
mf = copy.deepcopy(self.manifest)
mf["scenarios"][0]["beats"] = 1
self.assertTrue(gen.deps.validate(mf, gen._load_schema()))
def test_schema_rejette_id_hors_pattern(self):
mf = copy.deepcopy(self.manifest)
mf["scenarios"][0]["id"] = "mauvais-id"
self.assertTrue(gen.deps.validate(mf, gen._load_schema()))
class TestCLI(unittest.TestCase):
def test_validate_retourne_zero(self):
self.assertEqual(gen.main(["validate"]), 0)
def test_build_ecrit_out(self):
import tempfile
with tempfile.TemporaryDirectory() as d:
self.assertEqual(gen.main(["build", "-o", d]), 0)
for name in ("run_sheet.json", "MANIFEST.json"):
self.assertTrue(os.path.isfile(os.path.join(d, name)))
if __name__ == "__main__":
unittest.main(verbosity=2)