chore: regroupement des scripts de patch dans patches/
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_facilitator_c1.py — Chantier C1 (Encoder structuré)
|
||||
=========================================================
|
||||
Patch strict de facilitator_v9.py :
|
||||
P1. Config : ajoute ENCODER_MODE (.env, défaut 'agent' — rien ne change
|
||||
tant que ENCODER_MODE=schema n'est pas posé).
|
||||
P2. Insère run_encoder_schema() avant la section FREE DESIGNER
|
||||
(même contrat de retour que run_encoder, fallback agent intégré).
|
||||
P3. Dispatch dans run_full_pipeline_pass (pipeline + révisions).
|
||||
P4. Dispatch dans le flux standard (fallback agent en mode express :
|
||||
le plan Narrator n'y est pas annoté SLIDE N — layout).
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_facilitator_c1.py
|
||||
Vérifie chaque ancre (présence + unicité), écrit facilitator_v9.py.bak,
|
||||
compile le résultat. Idempotent : refuse de patcher deux fois.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("facilitator_v9.py")
|
||||
|
||||
FUNC = '''
|
||||
def run_encoder_schema(plan: str, layouts: dict, proj: "Project"):
|
||||
"""Encoder structuré (chantier C1) : chat/completions + json_schema
|
||||
strict Mistral, slide par slide. Même contrat de retour que
|
||||
run_encoder : (yaml_str, data, path, attempts) — attempts = nb de
|
||||
slides en échec. Fallback automatique sur l'Encoder agent si le
|
||||
module manque ou si aucune slide n'est encodée."""
|
||||
section("ÉTAPE 3 — THE ENCODER (structured outputs)")
|
||||
try:
|
||||
import encoder_schema as enc
|
||||
except ImportError as e:
|
||||
warn(f"encoder_schema.py indisponible ({e}) — bascule mode agent.")
|
||||
return run_encoder(AgentSession(ENCODER_ID), plan, layouts, proj)
|
||||
try:
|
||||
import schemas as sch
|
||||
for issue in (sch.verify_against_layouts(layouts) if layouts else []):
|
||||
warn(f"Schéma vs layouts_v2 : {issue}")
|
||||
except ImportError:
|
||||
warn("schemas.py absent — vérification de cohérence sautée.")
|
||||
info(f"Encodage slide par slide ({enc.DEFAULT_MODEL}, temp 0)...")
|
||||
data, usage, errors = enc.encode_plan(plan, API_KEY, progress=info)
|
||||
nb = len(data.get("slides", []))
|
||||
ok(f"{nb} slides encodées — tokens : {usage.get('total_tokens', 0)} "
|
||||
f"(prompt {usage.get('prompt_tokens', 0)} / "
|
||||
f"completion {usage.get('completion_tokens', 0)})")
|
||||
for e in errors:
|
||||
warn(e)
|
||||
if not nb:
|
||||
warn("Aucune slide encodée — bascule mode agent.")
|
||||
return run_encoder(AgentSession(ENCODER_ID), plan, layouts, proj)
|
||||
yaml_str = enc.to_yaml(data)
|
||||
is_valid, message, _ = validate_yaml(yaml_str, layouts)
|
||||
if is_valid:
|
||||
ok(f"YAML valide : {message}")
|
||||
else:
|
||||
warn(f"Validation : {message}")
|
||||
if errors:
|
||||
print("\\n [1] Continuer sans les slides en échec")
|
||||
print(" [2] Basculer sur l'Encoder agent (deck complet)")
|
||||
print(" [0] Abandonner")
|
||||
choix = ask("Votre choix :")
|
||||
if choix == "2":
|
||||
return run_encoder(AgentSession(ENCODER_ID), plan, layouts, proj)
|
||||
if choix == "0":
|
||||
return None, None, None, len(errors)
|
||||
path = save_text(yaml_str, proj.out("input", "yaml"))
|
||||
return yaml_str, data, path, len(errors)
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P1 — config
|
||||
(
|
||||
'ENCODER_ID = os.getenv("ENCODER_AGENT_ID")\n',
|
||||
'ENCODER_ID = os.getenv("ENCODER_AGENT_ID")\n'
|
||||
'ENCODER_MODE = os.getenv("ENCODER_MODE", "agent")'
|
||||
' # agent | schema (C1)\n',
|
||||
),
|
||||
# P3 — dispatch pipeline pass (révisions comprises)
|
||||
(
|
||||
' encoder = AgentSession(ENCODER_ID)\n'
|
||||
' yaml_str, yaml_data, yaml_path, tries = run_encoder(\n'
|
||||
' encoder, plan, layouts, proj, scope_encoder)\n',
|
||||
' if ENCODER_MODE == "schema":\n'
|
||||
' yaml_str, yaml_data, yaml_path, tries = '
|
||||
'run_encoder_schema(\n'
|
||||
' plan, layouts, proj)\n'
|
||||
' else:\n'
|
||||
' encoder = AgentSession(ENCODER_ID)\n'
|
||||
' yaml_str, yaml_data, yaml_path, tries = run_encoder(\n'
|
||||
' encoder, plan, layouts, proj, scope_encoder)\n',
|
||||
),
|
||||
# P4 — dispatch flux standard (express → agent)
|
||||
(
|
||||
' encoder = AgentSession(ENCODER_ID)\n'
|
||||
' yaml_str, yaml_data, yaml_path, tries = run_encoder(\n'
|
||||
' encoder, plan, layouts, proj)\n'
|
||||
' manifest.step("encoder", tentatives_correction=tries)\n'
|
||||
' if yaml_str is None:\n'
|
||||
' if express:\n',
|
||||
' if ENCODER_MODE == "schema" and not express:\n'
|
||||
' yaml_str, yaml_data, yaml_path, tries = '
|
||||
'run_encoder_schema(\n'
|
||||
' plan, layouts, proj)\n'
|
||||
' else:\n'
|
||||
' if ENCODER_MODE == "schema" and express:\n'
|
||||
' info("Mode express → Encoder agent '
|
||||
'(plan non annoté).")\n'
|
||||
' encoder = AgentSession(ENCODER_ID)\n'
|
||||
' yaml_str, yaml_data, yaml_path, tries = run_encoder(\n'
|
||||
' encoder, plan, layouts, proj)\n'
|
||||
' manifest.step("encoder", tentatives_correction=tries)\n'
|
||||
' if yaml_str is None:\n'
|
||||
' if express:\n',
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "run_encoder_schema"
|
||||
ANCHOR_SECTION = "# FLUX LIBRE — THE FREE DESIGNER"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
for dep in ("schemas.py", "encoder_schema.py"):
|
||||
if not Path(dep).exists():
|
||||
fail("%s manquant à côté du facilitator." % dep)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (run_encoder_schema présent) — rien à faire.")
|
||||
|
||||
# Vérification de toutes les ancres AVANT toute écriture
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — facilitator inattendu."
|
||||
% i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_SECTION) != 1:
|
||||
fail("Ancre de section FREE DESIGNER introuvable ou non unique.")
|
||||
|
||||
shutil.copy2(TARGET, TARGET.with_suffix(".py.bak"))
|
||||
print(" + Sauvegarde : %s.bak" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
# P2 — insertion de la fonction avant le séparateur de la section
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_SECTION in l)
|
||||
ins = idx - 1 if lines[idx - 1].startswith("# ───") else idx
|
||||
lines[ins:ins] = FUNC.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 4 patchs appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(TARGET.with_suffix(".py.bak"), TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Activer : ajouter ENCODER_MODE=schema dans .env")
|
||||
print(" Retour arrière : ENCODER_MODE=agent (ou supprimer la ligne).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_facilitator_c2.py — Chantier C2 (Preview PNG)
|
||||
===================================================
|
||||
Patch strict de facilitator_v9.py :
|
||||
P1. Config : PREVIEW_SCRIPT (.env, défaut ./preview.sh).
|
||||
P2. Insère run_preview() + maybe_preview() avant la section
|
||||
ARCHIVAGE TRILIUM (asynchrone par défaut, log dédié).
|
||||
P3-P6. Propose les aperçus aux 4 sorties PPTX : flux standard,
|
||||
flux libre, révision ciblée, révision complète.
|
||||
P7. CLI : --preview <pptx> (mode bloquant, pour usage direct).
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_facilitator_c2.py
|
||||
Compatible avant/après le patch C1 (ancres indépendantes). Vérifie
|
||||
chaque ancre, écrit .bak, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("facilitator_v9.py")
|
||||
|
||||
FUNC = '''
|
||||
def run_preview(pptx_path: Path, wait: bool = False) -> bool:
|
||||
"""Aperçus PNG par slide via preview.sh (chantier C2).
|
||||
wait=False : lancement en arrière-plan (le DS218 est lent), sortie
|
||||
consignée dans <pptx>_preview.log. wait=True : bloquant (CLI)."""
|
||||
script = Path(PREVIEW_SCRIPT)
|
||||
if not script.exists():
|
||||
warn(f"{PREVIEW_SCRIPT} introuvable — aperçus indisponibles.")
|
||||
return False
|
||||
out_dir = pptx_path.parent / f"{pptx_path.stem}_previews"
|
||||
if wait:
|
||||
info("Génération des aperçus (quelques minutes sur le NAS)...")
|
||||
r = subprocess.run([str(script), str(pptx_path)],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode == 0:
|
||||
ok(f"Aperçus : {out_dir}")
|
||||
ok(f"Galerie : {out_dir / 'index.html'}")
|
||||
return True
|
||||
warn("Échec de la génération des aperçus :")
|
||||
print(textwrap.indent((r.stderr or r.stdout or "?").strip(),
|
||||
" "))
|
||||
return False
|
||||
log = pptx_path.parent / f"{pptx_path.stem}_preview.log"
|
||||
with open(log, "w", encoding="utf-8") as lf:
|
||||
subprocess.Popen([str(script), str(pptx_path)],
|
||||
stdout=lf, stderr=subprocess.STDOUT)
|
||||
info(f"Aperçus en arrière-plan → {out_dir}")
|
||||
info(f"Suivi : {log}")
|
||||
return True
|
||||
|
||||
|
||||
def maybe_preview(pptx_path) -> None:
|
||||
"""Propose la génération des aperçus après une sortie PPTX."""
|
||||
if not pptx_path or not Path(PREVIEW_SCRIPT).exists():
|
||||
return
|
||||
if ask("Générer les aperçus PNG ? (o/N) :").lower() in (
|
||||
"o", "oui", "y", "yes"):
|
||||
run_preview(pptx_path, wait=False)
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P1 — config
|
||||
(
|
||||
'LAYOUTS_PATH = os.getenv("LAYOUTS_PATH", "layouts_v2.yaml")\n',
|
||||
'LAYOUTS_PATH = os.getenv("LAYOUTS_PATH", "layouts_v2.yaml")\n'
|
||||
'PREVIEW_SCRIPT = os.getenv("PREVIEW_SCRIPT", "./preview.sh")'
|
||||
' # C2\n',
|
||||
),
|
||||
# P3 — flux standard (indentation 12)
|
||||
(
|
||||
' ok(f"Fichier PPTX : {pptx_path}")\n'
|
||||
' ok(f"Taille : {pptx_path.stat().st_size/1024:.1f}'
|
||||
' Ko")\n'
|
||||
' else:\n'
|
||||
' warn("Le PPTX n\'a pas pu être généré.")\n'
|
||||
' info(f"Le YAML est dans : {proj.outputs}")\n',
|
||||
' ok(f"Fichier PPTX : {pptx_path}")\n'
|
||||
' ok(f"Taille : {pptx_path.stat().st_size/1024:.1f}'
|
||||
' Ko")\n'
|
||||
' maybe_preview(pptx_path)\n'
|
||||
' else:\n'
|
||||
' warn("Le PPTX n\'a pas pu être généré.")\n'
|
||||
' info(f"Le YAML est dans : {proj.outputs}")\n',
|
||||
),
|
||||
# P4 — flux libre (indentation 20)
|
||||
(
|
||||
' ok(f"Fichier PPTX : {pptx_path}")\n'
|
||||
' ok(f"Taille : '
|
||||
'{pptx_path.stat().st_size/1024:.1f} Ko")\n',
|
||||
' ok(f"Fichier PPTX : {pptx_path}")\n'
|
||||
' ok(f"Taille : '
|
||||
'{pptx_path.stat().st_size/1024:.1f} Ko")\n'
|
||||
' maybe_preview(pptx_path)\n',
|
||||
),
|
||||
# P5 — révision ciblée
|
||||
(
|
||||
' ok(f"PPTX des slides révisées : {pptx_path}")\n'
|
||||
' info("Ouvre ce fichier et copie-colle les slides dans '
|
||||
'ton deck maître.")\n',
|
||||
' ok(f"PPTX des slides révisées : {pptx_path}")\n'
|
||||
' info("Ouvre ce fichier et copie-colle les slides dans '
|
||||
'ton deck maître.")\n'
|
||||
' maybe_preview(pptx_path)\n',
|
||||
),
|
||||
# P6 — révision complète
|
||||
(
|
||||
' section("DECK COMPLET RÉGÉNÉRÉ")\n'
|
||||
' ok(f"Nouveau PPTX complet : {pptx_path}")\n',
|
||||
' section("DECK COMPLET RÉGÉNÉRÉ")\n'
|
||||
' ok(f"Nouveau PPTX complet : {pptx_path}")\n'
|
||||
' maybe_preview(pptx_path)\n',
|
||||
),
|
||||
# P7a — argument CLI
|
||||
(
|
||||
' parser.add_argument("--render", metavar="YAML",\n'
|
||||
' help="Rendu direct d\'un YAML existant, '
|
||||
'sans agents")\n',
|
||||
' parser.add_argument("--render", metavar="YAML",\n'
|
||||
' help="Rendu direct d\'un YAML existant, '
|
||||
'sans agents")\n'
|
||||
' parser.add_argument("--preview", metavar="PPTX",\n'
|
||||
' help="Aperçus PNG d\'un PPTX existant '
|
||||
'(C2)")\n',
|
||||
),
|
||||
# P7b — traitement CLI
|
||||
(
|
||||
' if args.render:\n',
|
||||
' if args.preview:\n'
|
||||
' p = Path(args.preview)\n'
|
||||
' if not p.exists():\n'
|
||||
' warn(f"Fichier introuvable : {p}")\n'
|
||||
' sys.exit(1)\n'
|
||||
' sys.exit(0 if run_preview(p, wait=True) else 1)\n'
|
||||
'\n'
|
||||
' if args.render:\n',
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "def run_preview"
|
||||
ANCHOR_SECTION = "# ARCHIVAGE TRILIUM"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (run_preview présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — facilitator inattendu."
|
||||
% i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_SECTION) != 1:
|
||||
fail("Ancre de section ARCHIVAGE TRILIUM introuvable ou non unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c2")
|
||||
print(" + Sauvegarde : %s.bak-c2" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_SECTION in l)
|
||||
ins = idx - 1 if lines[idx - 1].startswith("# ───") else idx
|
||||
lines[ins:ins] = FUNC.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 7 patchs appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c2", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Test direct : python3 facilitator_v9.py --preview "
|
||||
"<deck.pptx>")
|
||||
print(" Config .env optionnelle : PREVIEW_SCRIPT=./preview.sh")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_facilitator_c3.py — Chantier C3 (fusion des révisions ciblées)
|
||||
====================================================================
|
||||
Patch strict de facilitator_v9.py :
|
||||
P1. Insère merge_revision() : remplace, dans le dernier YAML complet
|
||||
(project_state.json → dernier_yaml), les slides régénérées par la
|
||||
révision ciblée (appariement par position), puis re-rend le deck
|
||||
ENTIER. Le PPTX partiel « à recoller » disparaît du flux.
|
||||
P2. Branche la fusion dans run_full_pipeline_pass juste avant le
|
||||
rendu (suffix revision_ciblee uniquement).
|
||||
P3. En cas de fusion réussie, l'état du projet est persisté comme un
|
||||
deck complet (dernier_yaml/dernier_pptx à jour).
|
||||
P4. Message utilisateur complété au site révision ciblée.
|
||||
|
||||
Échec de fusion (pas de dernier_yaml, YAML illisible…) : comportement
|
||||
actuel conservé à l'identique (PPTX partiel + message de recollage).
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_facilitator_c3.py
|
||||
Compatible avant/après les patchs C1 et C2 (ancres indépendantes).
|
||||
Vérifie chaque ancre, écrit .bak-c3, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("facilitator_v9.py")
|
||||
|
||||
FUNC = '''
|
||||
def merge_revision(proj: "Project", partial_yaml_path: Path):
|
||||
"""Fusion YAML des révisions ciblées (chantier C3).
|
||||
Remplace dans le dernier YAML complet les slides régénérées
|
||||
(appariement par position ; positions inconnues ajoutées en fin).
|
||||
Retourne le chemin du YAML complet fusionné, ou None si fusion
|
||||
impossible (l'appelant conserve alors le flux partiel actuel)."""
|
||||
state = proj.load_state()
|
||||
last = state.get("dernier_yaml") or ""
|
||||
if not last or not Path(last).exists():
|
||||
warn("Fusion : pas de YAML complet précédent — PPTX partiel "
|
||||
"conservé.")
|
||||
return None
|
||||
try:
|
||||
full = yaml.safe_load(Path(last).read_text(encoding="utf-8"))
|
||||
part = yaml.safe_load(
|
||||
partial_yaml_path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as e:
|
||||
warn(f"Fusion : YAML illisible ({e}).")
|
||||
return None
|
||||
if not isinstance(full, dict) or not full.get("slides"):
|
||||
warn("Fusion : le YAML précédent ne contient pas de slides.")
|
||||
return None
|
||||
news = {}
|
||||
for s in (part or {}).get("slides", []):
|
||||
if isinstance(s, dict) and s.get("position"):
|
||||
news[int(s["position"])] = s
|
||||
if not news:
|
||||
warn("Fusion : aucune slide positionnée dans la révision.")
|
||||
return None
|
||||
merged, replaced = [], 0
|
||||
for i, s in enumerate(full["slides"]):
|
||||
pos = int(s.get("position", i + 1)) if isinstance(s, dict) \
|
||||
else i + 1
|
||||
if pos in news:
|
||||
merged.append(news.pop(pos))
|
||||
replaced += 1
|
||||
else:
|
||||
merged.append(s)
|
||||
for pos in sorted(news):
|
||||
merged.append(news[pos])
|
||||
full["slides"] = merged
|
||||
out = proj.out("revision_fusion", "yaml")
|
||||
out.write_text(
|
||||
yaml.safe_dump(full, allow_unicode=True, sort_keys=False,
|
||||
default_flow_style=False, width=100),
|
||||
encoding="utf-8")
|
||||
ok(f"Fusion : {replaced} slide(s) remplacée(s), "
|
||||
f"{len(merged)} au total → {out.name}")
|
||||
return out
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P2 — fusion avant le rendu (revision_ciblee)
|
||||
(
|
||||
' # Renommer la sortie selon le suffixe demandé\n'
|
||||
' if suffix != "input" and yaml_path:\n'
|
||||
' new_path = proj.out(suffix, "yaml")\n'
|
||||
' yaml_path.rename(new_path)\n'
|
||||
' yaml_path = new_path\n'
|
||||
'\n'
|
||||
' manifest.file(yaml_path)\n',
|
||||
' # Renommer la sortie selon le suffixe demandé\n'
|
||||
' if suffix != "input" and yaml_path:\n'
|
||||
' new_path = proj.out(suffix, "yaml")\n'
|
||||
' yaml_path.rename(new_path)\n'
|
||||
' yaml_path = new_path\n'
|
||||
'\n'
|
||||
' merged = False\n'
|
||||
' if suffix == "revision_ciblee" and yaml_path:\n'
|
||||
' fused = merge_revision(proj, yaml_path)\n'
|
||||
' if fused:\n'
|
||||
' yaml_path, merged = fused, True\n'
|
||||
' info("Rendu du deck COMPLET fusionné.")\n'
|
||||
'\n'
|
||||
' manifest.file(yaml_path)\n',
|
||||
),
|
||||
# P3 — persistance d'état si fusion
|
||||
(
|
||||
' elif suffix == "revision_ciblee":\n'
|
||||
' persist_generation(\n'
|
||||
' proj, markdown=markdown,\n'
|
||||
' journal_entry="Révision ciblée — slides '
|
||||
'régénérées séparément.")\n',
|
||||
' elif suffix == "revision_ciblee":\n'
|
||||
' if merged:\n'
|
||||
' persist_generation(\n'
|
||||
' proj, markdown=markdown, yaml_path=yaml_path,\n'
|
||||
' pptx_path=pptx_path,\n'
|
||||
' journal_entry="Révision ciblée fusionnée — '
|
||||
'deck complet régénéré.")\n'
|
||||
' else:\n'
|
||||
' persist_generation(\n'
|
||||
' proj, markdown=markdown,\n'
|
||||
' journal_entry="Révision ciblée — slides '
|
||||
'régénérées séparément.")\n',
|
||||
),
|
||||
# P4 — message utilisateur
|
||||
(
|
||||
' info("Ouvre ce fichier et copie-colle les slides dans '
|
||||
'ton deck maître.")\n',
|
||||
' info("Ouvre ce fichier et copie-colle les slides dans '
|
||||
'ton deck maître.")\n'
|
||||
' info("(Si la fusion YAML a réussi — voir ci-dessus — '
|
||||
'le PPTX est déjà le deck complet.)")\n',
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "def merge_revision"
|
||||
ANCHOR_SECTION = "# FLUX LIBRE — THE FREE DESIGNER"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (merge_revision présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — facilitator inattendu."
|
||||
% i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_SECTION) != 1:
|
||||
fail("Ancre de section FREE DESIGNER introuvable ou non unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-fc3")
|
||||
print(" + Sauvegarde : %s.bak-fc3" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_SECTION in l)
|
||||
ins = idx - 1 if lines[idx - 1].startswith("# ───") else idx
|
||||
lines[ins:ins] = FUNC.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 3 patchs + merge_revision appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-fc3", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n La prochaine révision ciblée régénérera le deck complet "
|
||||
"(revision_fusion.yaml).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_facilitator_c4.py — Chantier C4 (Assets & images)
|
||||
=======================================================
|
||||
Patch strict de facilitator_v9.py :
|
||||
P1. validate_freeform accepte le nouveau type de bloc « image ».
|
||||
P2. /lire inventorie aussi projets/<slug>/assets/ (images + dimensions)
|
||||
et injecte la liste dans le contexte Narrator — le Narrator peut
|
||||
alors référencer les fichiers par nom dans les layouts image_*.
|
||||
Le dossier assets/ est créé au premier /lire s'il n'existe pas.
|
||||
P3. Insère list_assets() (inventaire, dimensions via Pillow si
|
||||
disponible, sinon noms seuls).
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_facilitator_c4.py
|
||||
Compatible tout ordre avec C1/C2/C3. Vérifie chaque ancre, écrit
|
||||
.bak-fc4, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("facilitator_v9.py")
|
||||
|
||||
FUNC = '''
|
||||
def list_assets(proj: "Project"):
|
||||
"""Inventaire des images de projets/<slug>/assets/ (chantier C4).
|
||||
Crée le dossier au premier appel. Retourne un bloc texte destiné au
|
||||
contexte Narrator, ou une chaîne vide si aucune image."""
|
||||
assets = proj.root / "assets"
|
||||
assets.mkdir(exist_ok=True)
|
||||
exts = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
files = sorted(p for p in assets.iterdir()
|
||||
if p.suffix.lower() in exts and p.is_file())
|
||||
if not files:
|
||||
return ""
|
||||
lines = ["IMAGES DISPONIBLES DANS assets/ (utilisables dans les "
|
||||
"layouts image_split / image_full et le bloc freeform "
|
||||
"image, par leur nom de fichier) :"]
|
||||
for p in files:
|
||||
dims = ""
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(p) as im:
|
||||
dims = f" ({im.size[0]}×{im.size[1]})"
|
||||
except Exception:
|
||||
pass
|
||||
lines.append(f"- {p.name}{dims}")
|
||||
return "\\n".join(lines)
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P1 — type de bloc freeform « image »
|
||||
(
|
||||
'VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", '
|
||||
'"circle",\n'
|
||||
' "badge", "card", "rect", "line"}\n',
|
||||
'VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", '
|
||||
'"circle",\n'
|
||||
' "badge", "card", "rect", "line", "image"}\n',
|
||||
),
|
||||
# P2 — /lire : inventaire des assets
|
||||
(
|
||||
' elif cmd == "/lire":\n'
|
||||
' context, new_files = load_documents(proj, '
|
||||
'loaded_docs)\n'
|
||||
' if not new_files:\n'
|
||||
' info("Aucun nouveau document dans inputs/.")\n'
|
||||
' continue\n',
|
||||
' elif cmd == "/lire":\n'
|
||||
' context, new_files = load_documents(proj, '
|
||||
'loaded_docs)\n'
|
||||
' assets_info = list_assets(proj)\n'
|
||||
' if assets_info:\n'
|
||||
' context = ((context or "").strip()\n'
|
||||
' + "\\n\\n" + assets_info).strip()\n'
|
||||
' ok(f"{len(assets_info.splitlines()) - 1} '
|
||||
'image(s) dans assets/.")\n'
|
||||
' if not new_files and not assets_info:\n'
|
||||
' info("Aucun nouveau document dans inputs/ ni '
|
||||
'image dans assets/.")\n'
|
||||
' continue\n',
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "def list_assets"
|
||||
ANCHOR_SECTION = "# FLUX LIBRE — THE FREE DESIGNER"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (list_assets présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — facilitator inattendu."
|
||||
% i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_SECTION) != 1:
|
||||
fail("Ancre de section FREE DESIGNER introuvable ou non unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-fc4")
|
||||
print(" + Sauvegarde : %s.bak-fc4" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_SECTION in l)
|
||||
ins = idx - 1 if lines[idx - 1].startswith("# ───") else idx
|
||||
lines[ins:ins] = FUNC.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 2 patchs + list_assets appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-fc4", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Dépose tes images dans projets/<slug>/assets/ puis /lire "
|
||||
"dans la session.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_facilitator_c4b.py — Correctif C4 (résolution des assets)
|
||||
================================================================
|
||||
run_render() ne transmettait jamais --assets à render_engine_v2.py,
|
||||
qui retombait sur son défaut <yaml>/../assets. Ce défaut n'est correct
|
||||
que si le YAML est dans projets/<slug>/outputs/ (alors ../assets =
|
||||
projets/<slug>/assets/) — ce qui couvre le flux normal du pipeline,
|
||||
mais PAS un YAML isolé à la racine (golden, test manuel).
|
||||
|
||||
Patch : run_render accepte un paramètre assets_dir optionnel. Résolu
|
||||
dans l'ordre :
|
||||
1. assets_dir explicitement fourni par l'appelant (le pipeline
|
||||
normal le fournira désormais : projets/<slug>/assets/).
|
||||
2. Sinon, si le YAML est sous PROJECTS_DIR, le dossier assets/ du
|
||||
projet correspondant est déduit automatiquement.
|
||||
3. Sinon (golden, test manuel hors projet), fallback sur le défaut
|
||||
du moteur (<yaml>/../assets) — comportement inchangé pour ces cas.
|
||||
|
||||
Tous les appelants internes de run_render (pipeline standard, flux
|
||||
libre, révisions, merge C3, --render CLI) sont mis à jour pour passer
|
||||
proj.root / "assets" quand un Project est en contexte.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_facilitator_c4b.py
|
||||
Compatible C1/C2/C3/C4 déjà appliqués (ancres indépendantes). Vérifie
|
||||
chaque ancre, écrit .bak-c4b, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("facilitator_v9.py")
|
||||
|
||||
PATCHES = [
|
||||
# P1 — signature + résolution + transmission --assets
|
||||
(
|
||||
'def run_render(yaml_path: Path) -> Optional[Path]:\n'
|
||||
' section("ÉTAPE 4 — RENDER ENGINE V2")\n'
|
||||
' if not Path(RENDER_ENGINE_PATH).exists():\n'
|
||||
' warn(f"{RENDER_ENGINE_PATH} introuvable.")\n'
|
||||
' return None\n'
|
||||
' for p in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]:\n'
|
||||
' if not os.path.exists(p):\n'
|
||||
' warn(f"Config manquante : {p}")\n'
|
||||
' return None\n'
|
||||
' pptx_out = yaml_path.with_suffix(".pptx")\n'
|
||||
' info("Lancement de render_engine_v2.py...")\n'
|
||||
' info(f"Sortie : {pptx_out}")\n'
|
||||
' result = subprocess.run(\n'
|
||||
' [sys.executable, RENDER_ENGINE_PATH, str(yaml_path), '
|
||||
'str(pptx_out),\n'
|
||||
' "--theme", THEME_PATH, "--components", '
|
||||
'COMPONENTS_PATH,\n'
|
||||
' "--layouts", LAYOUTS_PATH],\n'
|
||||
' capture_output=True, text=True)\n',
|
||||
|
||||
'def run_render(yaml_path: Path,\n'
|
||||
' assets_dir: Optional[Path] = None) -> '
|
||||
'Optional[Path]:\n'
|
||||
' section("ÉTAPE 4 — RENDER ENGINE V2")\n'
|
||||
' if not Path(RENDER_ENGINE_PATH).exists():\n'
|
||||
' warn(f"{RENDER_ENGINE_PATH} introuvable.")\n'
|
||||
' return None\n'
|
||||
' for p in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]:\n'
|
||||
' if not os.path.exists(p):\n'
|
||||
' warn(f"Config manquante : {p}")\n'
|
||||
' return None\n'
|
||||
' pptx_out = yaml_path.with_suffix(".pptx")\n'
|
||||
' info("Lancement de render_engine_v2.py...")\n'
|
||||
' info(f"Sortie : {pptx_out}")\n'
|
||||
' if assets_dir is None:\n'
|
||||
' # Déduction depuis PROJECTS_DIR si le YAML y vit '
|
||||
'(C4b)\n'
|
||||
' try:\n'
|
||||
' rel = yaml_path.resolve().relative_to(\n'
|
||||
' Path(PROJECTS_DIR).resolve())\n'
|
||||
' assets_dir = Path(PROJECTS_DIR) / rel.parts[0] '
|
||||
'/ "assets"\n'
|
||||
' except ValueError:\n'
|
||||
' assets_dir = None # hors projet : défaut du '
|
||||
'moteur\n'
|
||||
' cmd = [sys.executable, RENDER_ENGINE_PATH, str(yaml_path),'
|
||||
'\n'
|
||||
' str(pptx_out),\n'
|
||||
' "--theme", THEME_PATH, "--components", '
|
||||
'COMPONENTS_PATH,\n'
|
||||
' "--layouts", LAYOUTS_PATH]\n'
|
||||
' if assets_dir is not None:\n'
|
||||
' cmd += ["--assets", str(assets_dir)]\n'
|
||||
' info(f"Assets : {assets_dir}")\n'
|
||||
' result = subprocess.run(cmd, capture_output=True, '
|
||||
'text=True)\n',
|
||||
),
|
||||
# P2 — CLI --render : passe le dossier assets du projet si déductible
|
||||
(
|
||||
' if args.render:\n'
|
||||
' yaml_path = Path(args.render)\n'
|
||||
' if not yaml_path.exists():\n'
|
||||
' warn(f"Fichier introuvable : {yaml_path}")\n'
|
||||
' sys.exit(1)\n'
|
||||
' layouts = load_layouts()\n'
|
||||
' is_valid, message, _ = validate_yaml(\n'
|
||||
' yaml_path.read_text(encoding="utf-8"), layouts)\n'
|
||||
' (ok if is_valid else warn)(f"Validation : {message}")\n'
|
||||
' sys.exit(0 if run_render(yaml_path) else 1)\n',
|
||||
|
||||
' if args.render:\n'
|
||||
' yaml_path = Path(args.render)\n'
|
||||
' if not yaml_path.exists():\n'
|
||||
' warn(f"Fichier introuvable : {yaml_path}")\n'
|
||||
' sys.exit(1)\n'
|
||||
' layouts = load_layouts()\n'
|
||||
' is_valid, message, _ = validate_yaml(\n'
|
||||
' yaml_path.read_text(encoding="utf-8"), layouts)\n'
|
||||
' (ok if is_valid else warn)(f"Validation : {message}")\n'
|
||||
' local_assets = yaml_path.parent / "assets"\n'
|
||||
' assets_dir = local_assets if local_assets.is_dir() '
|
||||
'else None\n'
|
||||
' sys.exit(0 if run_render(yaml_path, assets_dir) '
|
||||
'else 1)\n',
|
||||
),
|
||||
]
|
||||
|
||||
# Les 3 appels internes de run_render(yaml_path) — même nom de
|
||||
# variable partout, différenciés par leur contexte immédiat.
|
||||
CALL_PATCHES = [
|
||||
(
|
||||
' info("Rendu du deck COMPLET fusionné.")\n'
|
||||
'\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path)\n',
|
||||
' info("Rendu du deck COMPLET fusionné.")\n'
|
||||
'\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path, proj.root / '
|
||||
'"assets")\n',
|
||||
),
|
||||
(
|
||||
' if yaml_path and yaml_data:\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path)\n',
|
||||
' if yaml_path and yaml_data:\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path, proj.root '
|
||||
'/ "assets")\n',
|
||||
),
|
||||
(
|
||||
' if yaml_path and yaml_data:\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path)\n',
|
||||
' if yaml_path and yaml_data:\n'
|
||||
' manifest.file(yaml_path)\n'
|
||||
' pptx_path = run_render(yaml_path, proj.root / '
|
||||
'"assets")\n',
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "assets_dir: Optional[Path] = None"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (assets_dir présent) — rien à faire.")
|
||||
if "def merge_revision" not in content:
|
||||
fail("Prérequis manquant : patch_facilitator_c3.py doit être "
|
||||
"appliqué avant celui-ci (C4b s'ancre sur le message de "
|
||||
"fusion introduit par C3).")
|
||||
|
||||
all_patches = PATCHES + CALL_PATCHES
|
||||
for i, (old, _) in enumerate(all_patches, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — facilitator inattendu."
|
||||
" (Le fichier a peut-être déjà été modifié depuis les "
|
||||
"patchs C1/C2/C3.)" % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c4b")
|
||||
print(" + Sauvegarde : %s.bak-c4b" % TARGET)
|
||||
|
||||
for old, new in all_patches:
|
||||
content = content.replace(old, new)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + %d patchs appliqués (2 structurels + %d appels internes)."
|
||||
% (len(all_patches), len(CALL_PATCHES)))
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c4b", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Pipeline normal : assets résolus automatiquement "
|
||||
"(projets/<slug>/assets/).")
|
||||
print(" --render CLI : assets résolus si un dossier assets/ existe"
|
||||
" à côté du YAML (sinon défaut moteur <yaml>/../assets).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_layouts_c4.py — Chantier C4 (catalogue : image_split, image_full)
|
||||
=======================================================================
|
||||
Ajoute les deux layouts images à layouts_v2.yaml par APPEND sécurisé
|
||||
(le fichier n'est jamais réécrit ni reformaté — protection des
|
||||
commentaires et de la mise en forme, leçon de l'incident Le Chat) :
|
||||
1. Parse le fichier actuel, vérifie l'absence des deux clés.
|
||||
2. Vérifie que la dernière ligne significative appartient bien au
|
||||
bloc layouts: (ligne indentée) — sinon refus.
|
||||
3. Sauvegarde .bak-c4, append du fragment, re-parse de contrôle.
|
||||
4. En cas d'échec du re-parse : restauration automatique.
|
||||
|
||||
Après ce patch, propager : python3 prompt_injection_v2.py && python3
|
||||
build_gallery.py (le Designer voit les nouveaux layouts, la galerie et
|
||||
le bloc skill sont régénérés).
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_layouts_c4.py
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
TARGET = Path("layouts_v2.yaml")
|
||||
|
||||
FRAGMENT = """
|
||||
# ── Chantier C4 — layouts images ──────────────────────────────────
|
||||
image_split:
|
||||
id: L50
|
||||
famille: Visuel
|
||||
mode: light
|
||||
champs: [titre, image, bullets, side, legende]
|
||||
champs_requis: [titre, image, bullets]
|
||||
agent_hint: >-
|
||||
Image d'appui sur 40 % de la slide (side: left par défaut, right
|
||||
possible) + titre et points clés (max 4 bullets). À utiliser quand
|
||||
une image du dossier assets/ du projet illustre le propos ;
|
||||
image = nom de fichier exact tel que listé par /lire.
|
||||
legende (optionnelle) s'affiche sur un bandeau navy sous l'image.
|
||||
|
||||
image_full:
|
||||
id: L51
|
||||
famille: Visuel
|
||||
mode: dark
|
||||
champs: [titre, image, sous_titre]
|
||||
champs_requis: [titre, image]
|
||||
agent_hint: >-
|
||||
Ouverture de chapitre visuelle : image plein cadre + voile navy +
|
||||
titre display blanc. Alternative à section_divider quand un asset
|
||||
du projet s'y prête. image = nom de fichier exact de assets/.
|
||||
"""
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
raw = TARGET.read_text(encoding="utf-8")
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
fail("layouts_v2.yaml actuel illisible : %s" % e)
|
||||
layouts = (data or {}).get("layouts")
|
||||
if not isinstance(layouts, dict):
|
||||
fail("Bloc layouts: introuvable dans le fichier.")
|
||||
for key in ("image_split", "image_full"):
|
||||
if key in layouts:
|
||||
fail("'%s' déjà présent — rien à faire." % key)
|
||||
|
||||
# La dernière ligne significative doit être DANS le bloc layouts:
|
||||
last = next((l for l in reversed(raw.splitlines())
|
||||
if l.strip() and not l.strip().startswith("#")), "")
|
||||
if not last.startswith(" "):
|
||||
fail("Le fichier ne se termine pas dans le bloc layouts: "
|
||||
"(dernière ligne non indentée : %r) — fusion manuelle "
|
||||
"requise." % last[:40])
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c4")
|
||||
print(" + Sauvegarde : %s.bak-c4" % TARGET)
|
||||
|
||||
with open(TARGET, "a", encoding="utf-8") as f:
|
||||
if not raw.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(FRAGMENT)
|
||||
|
||||
try:
|
||||
check = yaml.safe_load(TARGET.read_text(encoding="utf-8"))
|
||||
nl = check["layouts"]
|
||||
assert "image_split" in nl and "image_full" in nl
|
||||
assert len(nl) == len(layouts) + 2
|
||||
except Exception as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c4", TARGET)
|
||||
fail("Contrôle post-append échoué (%s) — fichier restauré." % e)
|
||||
|
||||
print(" + image_split (L50) et image_full (L51) ajoutés "
|
||||
"(%d layouts au total)." % (len(layouts) + 2))
|
||||
print("\n Propager : python3 prompt_injection_v2.py && python3 "
|
||||
"build_gallery.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_layouts_c5a.py — Chantier C5 · lot 1 (catalogue : charts natifs)
|
||||
======================================================================
|
||||
Ajoute bar_chart (L42), line_chart (L43) et donut_split (L44) à
|
||||
layouts_v2.yaml par APPEND sécurisé (même mécanique que le patch C4 :
|
||||
parse avant, contrôle de fin de bloc, backup, re-parse, restauration
|
||||
automatique en cas d'échec — le fichier n'est jamais reformaté).
|
||||
|
||||
Après ce patch, propager : python3 prompt_injection_v2.py && python3
|
||||
build_gallery.py
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_layouts_c5a.py
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
TARGET = Path("layouts_v2.yaml")
|
||||
|
||||
FRAGMENT = """
|
||||
# ── Chantier C5 lot 1 — charts natifs (éditables dans PowerPoint) ──
|
||||
bar_chart:
|
||||
id: L42
|
||||
famille: Données
|
||||
mode: light
|
||||
champs: [titre, categories, series, unite, source, horizontal]
|
||||
champs_requis: [titre, categories, series]
|
||||
agent_hint: >-
|
||||
Comparaison de valeurs par catégories — graphique NATIF éditable.
|
||||
Max 8 catégories × 3 séries. series = liste de {label, values} ;
|
||||
values = nombres SANS guillemets, alignés sur categories.
|
||||
horizontal: true pour des barres (libellés longs). unite (ex M€)
|
||||
et source optionnels. Couleurs imposées : navy, coral, glacier.
|
||||
|
||||
line_chart:
|
||||
id: L43
|
||||
famille: Données
|
||||
mode: light
|
||||
champs: [titre, points_x, series, unite, source]
|
||||
champs_requis: [titre, points_x, series]
|
||||
agent_hint: >-
|
||||
Évolution temporelle — graphique NATIF éditable. Max 12 points ×
|
||||
3 séries. points_x = libellés d'axe (mois, années...) ; series =
|
||||
{label, values}, nombres sans guillemets. Le dernier point de la
|
||||
première série est automatiquement mis en valeur (corail).
|
||||
|
||||
donut_split:
|
||||
id: L44
|
||||
famille: Données
|
||||
mode: light
|
||||
champs: [titre, segments, valeur_centrale, source]
|
||||
champs_requis: [titre, segments]
|
||||
agent_hint: >-
|
||||
Répartition d'un tout : anneau à gauche + légende détaillée à
|
||||
droite. 2 à 6 segments = {label, valeur} (nombres sans
|
||||
guillemets). valeur_centrale (optionnelle) s'affiche au centre de
|
||||
l'anneau (ex : total « 120 M€ »). Couleurs = cycle PR imposé.
|
||||
"""
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
raw = TARGET.read_text(encoding="utf-8")
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
fail("layouts_v2.yaml actuel illisible : %s" % e)
|
||||
layouts = (data or {}).get("layouts")
|
||||
if not isinstance(layouts, dict):
|
||||
fail("Bloc layouts: introuvable dans le fichier.")
|
||||
for key in ("bar_chart", "line_chart", "donut_split"):
|
||||
if key in layouts:
|
||||
fail("'%s' déjà présent — rien à faire." % key)
|
||||
|
||||
last = next((l for l in reversed(raw.splitlines())
|
||||
if l.strip() and not l.strip().startswith("#")), "")
|
||||
if not last.startswith(" "):
|
||||
fail("Le fichier ne se termine pas dans le bloc layouts: "
|
||||
"(dernière ligne non indentée : %r) — fusion manuelle "
|
||||
"requise." % last[:40])
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c5a")
|
||||
print(" + Sauvegarde : %s.bak-c5a" % TARGET)
|
||||
|
||||
with open(TARGET, "a", encoding="utf-8") as f:
|
||||
if not raw.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(FRAGMENT)
|
||||
|
||||
try:
|
||||
check = yaml.safe_load(TARGET.read_text(encoding="utf-8"))
|
||||
nl = check["layouts"]
|
||||
assert all(k in nl for k in ("bar_chart", "line_chart",
|
||||
"donut_split"))
|
||||
assert len(nl) == len(layouts) + 3
|
||||
except Exception as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c5a", TARGET)
|
||||
fail("Contrôle post-append échoué (%s) — fichier restauré." % e)
|
||||
|
||||
print(" + bar_chart (L42), line_chart (L43), donut_split (L44) "
|
||||
"ajoutés (%d layouts au total)." % (len(layouts) + 3))
|
||||
print("\n Propager : python3 prompt_injection_v2.py && python3 "
|
||||
"build_gallery.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_layouts_c5b.py — Chantier C5 lot 2 (catalogue : waterfall,
|
||||
heatmap_table, funnel)
|
||||
================================================================
|
||||
Append sécurisé à layouts_v2.yaml (même mécanique verrouillée que C4 :
|
||||
parse avant, contrôle du bloc layouts:, .bak, re-parse, restauration
|
||||
auto). Après ce patch, propager : python3 prompt_injection_v2.py &&
|
||||
python3 build_gallery.py.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_layouts_c5b.py
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
TARGET = Path("layouts_v2.yaml")
|
||||
NEW_KEYS = ("waterfall", "heatmap_table", "funnel")
|
||||
|
||||
FRAGMENT = """
|
||||
# ── Chantier C5 lot 2 — data & structure ──────────────────────────
|
||||
waterfall:
|
||||
id: L45
|
||||
famille: Données
|
||||
mode: light
|
||||
champs: [titre, depart, marches, arrivee, unite, source]
|
||||
champs_requis: [titre, depart, marches, arrivee]
|
||||
agent_hint: >-
|
||||
Pont de valeur (waterfall) : expliquer un écart entre deux
|
||||
montants par des variations successives. depart et arrivee =
|
||||
{label, valeur} ; marches = liste de {label, delta} SIGNÉ
|
||||
(positif ou négatif, max 8). Le moteur calcule les cumuls —
|
||||
ne jamais fournir de cumul. Idéal pour : évolution de budget,
|
||||
pont d'effectifs, décomposition d'un résultat.
|
||||
|
||||
heatmap_table:
|
||||
id: L46
|
||||
famille: Comparaison
|
||||
mode: light
|
||||
champs: [titre, headers, rows, legende]
|
||||
champs_requis: [titre, headers, rows]
|
||||
agent_hint: >-
|
||||
Tableau à intensité : évaluer plusieurs items sur plusieurs
|
||||
critères. headers = colonnes (max 6) ; rows = {label,
|
||||
scores} avec score ENTIER de 0 (faible) à 4 (fort), max
|
||||
8 lignes. Le moteur traduit chaque score en teinte de navy —
|
||||
aucune autre donnée. Idéal pour : maturité, couverture
|
||||
fonctionnelle, cartographie de risques.
|
||||
|
||||
funnel:
|
||||
id: L47
|
||||
famille: Process
|
||||
mode: light
|
||||
champs: [titre, etapes, source]
|
||||
champs_requis: [titre, etapes]
|
||||
agent_hint: >-
|
||||
Entonnoir de conversion : volumes décroissants d'étape en
|
||||
étape. etapes = {label, valeur, description?} du haut vers le
|
||||
bas, 3 à 5 étages. Largeurs proportionnelles aux valeurs
|
||||
(plancher de lisibilité), dernier étage corail. Idéal pour :
|
||||
pipeline commercial, adoption, qualification progressive.
|
||||
"""
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
raw = TARGET.read_text(encoding="utf-8")
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
fail("layouts_v2.yaml actuel illisible : %s" % e)
|
||||
layouts = (data or {}).get("layouts")
|
||||
if not isinstance(layouts, dict):
|
||||
fail("Bloc layouts: introuvable dans le fichier.")
|
||||
for key in NEW_KEYS:
|
||||
if key in layouts:
|
||||
fail("'%s' déjà présent — rien à faire." % key)
|
||||
|
||||
last = next((l for l in reversed(raw.splitlines())
|
||||
if l.strip() and not l.strip().startswith("#")), "")
|
||||
if not last.startswith(" "):
|
||||
fail("Le fichier ne se termine pas dans le bloc layouts: "
|
||||
"(dernière ligne non indentée : %r) — fusion manuelle "
|
||||
"requise." % last[:40])
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c5b")
|
||||
print(" + Sauvegarde : %s.bak-c5b" % TARGET)
|
||||
|
||||
with open(TARGET, "a", encoding="utf-8") as f:
|
||||
if not raw.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(FRAGMENT)
|
||||
|
||||
try:
|
||||
check = yaml.safe_load(TARGET.read_text(encoding="utf-8"))
|
||||
nl = check["layouts"]
|
||||
assert all(k in nl for k in NEW_KEYS)
|
||||
assert len(nl) == len(layouts) + len(NEW_KEYS)
|
||||
except Exception as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c5b", TARGET)
|
||||
fail("Contrôle post-append échoué (%s) — fichier restauré." % e)
|
||||
|
||||
print(" + waterfall (L45), heatmap_table (L46), funnel (L47) "
|
||||
"ajoutés (%d layouts au total)." % (len(layouts)
|
||||
+ len(NEW_KEYS)))
|
||||
print("\n Propager : python3 prompt_injection_v2.py && python3 "
|
||||
"build_gallery.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_layouts_c5c.py — Chantier C5 lot 3 (catalogue : agenda, pyramid)
|
||||
======================================================================
|
||||
Append sécurisé à layouts_v2.yaml (mécanique verrouillée C4/C5b).
|
||||
Après ce patch, propager : python3 prompt_injection_v2.py && python3
|
||||
build_gallery.py.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_layouts_c5c.py
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
TARGET = Path("layouts_v2.yaml")
|
||||
NEW_KEYS = ("agenda", "pyramid")
|
||||
|
||||
FRAGMENT = """
|
||||
# ── Chantier C5 lot 3 — retours v1 restylés ───────────────────────
|
||||
agenda:
|
||||
id: L40
|
||||
famille: Structure
|
||||
mode: light
|
||||
champs: [titre, sections]
|
||||
champs_requis: [titre, sections]
|
||||
agent_hint: >-
|
||||
Sommaire du deck : à placer en slide 2 pour toute présentation
|
||||
de plus de 15 minutes. sections = liste de {label, numero?,
|
||||
duree?, actif?}, 2 à 8 entrées. actif: true met la section en
|
||||
corail (utile pour les rappels d'agenda en cours de deck).
|
||||
duree (ex "10 min") s'affiche à droite en discret.
|
||||
|
||||
pyramid:
|
||||
id: L41
|
||||
famille: Concept
|
||||
mode: light
|
||||
champs: [titre, niveaux]
|
||||
champs_requis: [titre, niveaux]
|
||||
agent_hint: >-
|
||||
Argumentation pyramidale : la conclusion au sommet, les
|
||||
fondations à la base. niveaux = liste de {label, description?},
|
||||
EXACTEMENT 3 ou 4 niveaux, du sommet vers la base. Largeurs
|
||||
d'étages fixes gérées par le moteur. Idéal pour : message clé
|
||||
et ses appuis, hiérarchie stratégie/tactiques/moyens.
|
||||
"""
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
raw = TARGET.read_text(encoding="utf-8")
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
fail("layouts_v2.yaml actuel illisible : %s" % e)
|
||||
layouts = (data or {}).get("layouts")
|
||||
if not isinstance(layouts, dict):
|
||||
fail("Bloc layouts: introuvable dans le fichier.")
|
||||
for key in NEW_KEYS:
|
||||
if key in layouts:
|
||||
fail("'%s' déjà présent — rien à faire." % key)
|
||||
|
||||
last = next((l for l in reversed(raw.splitlines())
|
||||
if l.strip() and not l.strip().startswith("#")), "")
|
||||
if not last.startswith(" "):
|
||||
fail("Le fichier ne se termine pas dans le bloc layouts: — "
|
||||
"fusion manuelle requise.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c5c")
|
||||
print(" + Sauvegarde : %s.bak-c5c" % TARGET)
|
||||
|
||||
with open(TARGET, "a", encoding="utf-8") as f:
|
||||
if not raw.endswith("\n"):
|
||||
f.write("\n")
|
||||
f.write(FRAGMENT)
|
||||
|
||||
try:
|
||||
check = yaml.safe_load(TARGET.read_text(encoding="utf-8"))
|
||||
nl = check["layouts"]
|
||||
assert all(k in nl for k in NEW_KEYS)
|
||||
assert len(nl) == len(layouts) + len(NEW_KEYS)
|
||||
except Exception as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c5c", TARGET)
|
||||
fail("Contrôle post-append échoué (%s) — fichier restauré." % e)
|
||||
|
||||
print(" + agenda (L40) et pyramid (L41) ajoutés (%d layouts au "
|
||||
"total)." % (len(layouts) + len(NEW_KEYS)))
|
||||
print("\n Propager : python3 prompt_injection_v2.py && python3 "
|
||||
"build_gallery.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_prompts_c1_c4.py — Mises à jour des prompts sources (C3 + C4)
|
||||
===================================================================
|
||||
Insère dans les prompts SOURCES les règles hors-catalogue accumulées
|
||||
depuis C1 (le catalogue, lui, se propage tout seul via
|
||||
prompt_injection_v2.py — ne jamais l'éditer à la main) :
|
||||
|
||||
1. prompt_the_narrator_v4.md
|
||||
- Notes du présentateur (C3) : ligne facultative « Notes : »
|
||||
par slide à la formalisation.
|
||||
- Images du projet (C4) : référencer les fichiers listés par
|
||||
/lire dans les slides visuelles.
|
||||
2. prompt_the_free_designer.md
|
||||
- Nouveau bloc « image » (C4) dans le vocabulaire freeform.
|
||||
3. prompt_the_encoder_v2.md (fallback agent — mode express
|
||||
uniquement ; en mode schema le prompt est embarqué dans
|
||||
encoder_schema.py)
|
||||
- Transcription du champ notes.
|
||||
|
||||
Ancres = titres de section Markdown (stables). Refuse de patcher deux
|
||||
fois. Sauvegardes .bak-prompts.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_prompts_c1_c4.py
|
||||
|
||||
APRÈS ce patch, la séquence complète de propagation est :
|
||||
python3 prompt_injection_v2.py && python3 build_gallery.py
|
||||
puis recoller dans Mistral Studio :
|
||||
- Agent Narrator ← prompt_the_narrator_v4.md (source)
|
||||
- Agent Designer ← prompt_the_designer_injected_v3.md
|
||||
- Agent Encoder ← prompt_the_encoder_injected_v2.md
|
||||
- Agent Free Designer ← prompt_the_free_designer.md (source)
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
JOBS = [
|
||||
{
|
||||
"file": "prompt_the_narrator_v4.md",
|
||||
"marker": "## IMAGES DU PROJET",
|
||||
"anchor": "## RÈGLES ABSOLUES",
|
||||
"block": """## NOTES DU PRÉSENTATEUR (formalisation)
|
||||
|
||||
À la formalisation, tu peux ajouter sous chaque slide une ligne
|
||||
facultative :
|
||||
|
||||
Notes : [2-3 phrases de narration orale pour le présentateur — le ton
|
||||
parlé, les transitions, l'exemple à raconter. Pas une répétition du
|
||||
contenu affiché.]
|
||||
|
||||
Ces notes seront placées dans la zone commentaires de PowerPoint,
|
||||
invisibles à l'audience. N'en mets que là où elles apportent quelque
|
||||
chose.
|
||||
|
||||
## IMAGES DU PROJET
|
||||
|
||||
Quand la commande /lire t'a fourni une liste « IMAGES DISPONIBLES DANS
|
||||
assets/ », tu peux construire des slides visuelles autour de ces
|
||||
fichiers : ouverture de chapitre sur une image forte, slide
|
||||
image + points clés. Référence toujours le nom de fichier EXACT tel
|
||||
que listé. Ne référence JAMAIS une image absente de la liste.
|
||||
|
||||
""",
|
||||
},
|
||||
{
|
||||
"file": "prompt_the_free_designer.md",
|
||||
"marker": "- type: image",
|
||||
"anchor": "## FORMAT DE SORTIE",
|
||||
"block": """### Bloc image (nouveau)
|
||||
|
||||
- type: image # image du dossier assets/ du projet
|
||||
image: photo.png # nom de fichier EXACT (liste fournie en contexte)
|
||||
fit: cover # cover (remplit, recadrage centré) | contain
|
||||
# x/y/w/h : grille habituelle. Jamais de déformation.
|
||||
# Ne référence JAMAIS un fichier absent de la liste fournie.
|
||||
|
||||
""",
|
||||
},
|
||||
{
|
||||
"file": "prompt_the_encoder_v2.md",
|
||||
"marker": "champ `notes`",
|
||||
"anchor": "## GESTION DE LA LONGUEUR",
|
||||
"block": """### Notes du présentateur
|
||||
|
||||
Si une slide du plan contient une ligne « Notes : ... », transcris son
|
||||
contenu dans un champ `notes` de la slide YAML (chaîne simple). Les
|
||||
lignes de justification du Designer (commençant par →) ne sont NI du
|
||||
contenu NI des notes : ignore-les.
|
||||
|
||||
""",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
patched = 0
|
||||
for job in JOBS:
|
||||
path = Path(job["file"])
|
||||
if not path.exists():
|
||||
print(" ~ %s introuvable — sauté." % path)
|
||||
continue
|
||||
content = path.read_text(encoding="utf-8")
|
||||
if job["marker"] in content:
|
||||
print(" = %s : déjà à jour." % path)
|
||||
continue
|
||||
n = content.count(job["anchor"])
|
||||
if n != 1:
|
||||
fail("%s : ancre %r trouvée %d fois — insertion manuelle "
|
||||
"requise (bloc dans ce script)." % (path, job["anchor"],
|
||||
n))
|
||||
shutil.copy2(path, str(path) + ".bak-prompts")
|
||||
content = content.replace(job["anchor"],
|
||||
job["block"] + job["anchor"])
|
||||
path.write_text(content, encoding="utf-8")
|
||||
print(" + %s : bloc inséré avant %r." % (path, job["anchor"]))
|
||||
patched += 1
|
||||
if patched == 0:
|
||||
print("\n Rien à faire.")
|
||||
return
|
||||
print("\n Propager : python3 prompt_injection_v2.py && "
|
||||
"python3 build_gallery.py")
|
||||
print(" Puis recoller dans Mistral Studio :")
|
||||
print(" Narrator ← prompt_the_narrator_v4.md (source)")
|
||||
print(" Designer ← prompt_the_designer_injected_v3.md")
|
||||
print(" Encoder ← prompt_the_encoder_injected_v2.md")
|
||||
print(" Free Designer ← prompt_the_free_designer.md (source)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_render_engine_c3.py — Chantier C3 (Moteur durci)
|
||||
======================================================
|
||||
Patch strict de render_engine_v2.py :
|
||||
P1. Import optionnel de measure.py (mesure PIL réelle).
|
||||
P2. estimate_text_height() délègue à la mesure réelle quand elle est
|
||||
disponible (fallback : heuristique v2 inchangée) + ajoute
|
||||
strip_markdown_tree() (nettoyage récursif du Markdown résiduel).
|
||||
P3. Fitter anti-débordement dans _text() : si le texte mesuré dépasse
|
||||
la zone, réduction par pas de 1 pt (plancher 60 % du nominal) puis
|
||||
troncature avec « … » — chaque ajustement est tracé en console.
|
||||
Désactivable par appel (fit=False) ou globalement (FIT_TEXT=0).
|
||||
P4. Nettoyage Markdown appliqué à TOUTE donnée entrante de render().
|
||||
P5. Speaker notes : champ notes: par slide → zone notes PowerPoint.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_render_engine_c3.py
|
||||
Prérequis : measure.py à côté (sinon P1/P3 restent inertes, sans casse).
|
||||
Vérifie chaque ancre, écrit render_engine_v2.py.bak-c3, compile,
|
||||
idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("render_engine_v2.py")
|
||||
|
||||
PATCHES = [
|
||||
# P1 — import measure (optionnel)
|
||||
(
|
||||
"from pptx.util import Cm, Emu, Pt\n",
|
||||
"from pptx.util import Cm, Emu, Pt\n"
|
||||
"\n"
|
||||
"try:\n"
|
||||
" import measure\n"
|
||||
" HAS_MEASURE = True\n"
|
||||
"except ImportError:\n"
|
||||
" HAS_MEASURE = False\n"
|
||||
"FIT_TEXT = os.getenv(\"FIT_TEXT\", \"1\") != \"0\" # C3\n",
|
||||
),
|
||||
# P2 — mesure réelle + strip_markdown_tree
|
||||
(
|
||||
"def estimate_text_height(text: str, size_pt: int, width_cm: float)"
|
||||
" -> float:\n"
|
||||
" \"\"\"Hauteur estimée d'un texte (cm) pour une largeur donnée."
|
||||
"\"\"\"\n"
|
||||
" if not text:\n"
|
||||
" return 0.0\n"
|
||||
" char_w_cm = size_pt * 0.0185 # largeur moyenne d'un "
|
||||
"caractère\n"
|
||||
" chars_per_line = max(1, int(width_cm / char_w_cm))\n"
|
||||
" lines = 0\n"
|
||||
" for para in str(text).split(\"\\n\"):\n"
|
||||
" lines += max(1, -(-len(para) // chars_per_line))\n"
|
||||
" return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3"
|
||||
" em\n",
|
||||
"def estimate_text_height(text: str, size_pt: int, width_cm: float,"
|
||||
"\n"
|
||||
" font_name: str = \"Calibri\",\n"
|
||||
" bold: bool = False) -> float:\n"
|
||||
" \"\"\"Hauteur d'un texte (cm). Mesure réelle PIL si disponible"
|
||||
" (C3),\n"
|
||||
" sinon heuristique v2 inchangée.\"\"\"\n"
|
||||
" if not text:\n"
|
||||
" return 0.0\n"
|
||||
" if HAS_MEASURE:\n"
|
||||
" try:\n"
|
||||
" return measure.text_height_cm(str(text), font_name,\n"
|
||||
" size_pt, width_cm, bold)"
|
||||
"\n"
|
||||
" except Exception:\n"
|
||||
" pass\n"
|
||||
" char_w_cm = size_pt * 0.0185 # largeur moyenne d'un "
|
||||
"caractère\n"
|
||||
" chars_per_line = max(1, int(width_cm / char_w_cm))\n"
|
||||
" lines = 0\n"
|
||||
" for para in str(text).split(\"\\n\"):\n"
|
||||
" lines += max(1, -(-len(para) // chars_per_line))\n"
|
||||
" return lines * size_pt * 0.0455 # hauteur de ligne ≈ 1.3"
|
||||
" em\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"_MD_RES = [\n"
|
||||
" (re.compile(r\"\\*\\*(.+?)\\*\\*\"), r\"\\1\"),\n"
|
||||
" (re.compile(r\"__(.+?)__\"), r\"\\1\"),\n"
|
||||
" (re.compile(r\"`([^`]+)`\"), r\"\\1\"),\n"
|
||||
" (re.compile(r\"^#{1,4}\\s+\"), \"\"),\n"
|
||||
" (re.compile(r\"^[-•]\\s+\"), \"\"),\n"
|
||||
"]\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"def strip_markdown_tree(node):\n"
|
||||
" \"\"\"Nettoyage récursif du Markdown résiduel (C3) : gras,\n"
|
||||
" italique, code inline, titres et puces en tête de valeur.\"\"\""
|
||||
"\n"
|
||||
" if isinstance(node, dict):\n"
|
||||
" return {k: strip_markdown_tree(v) for k, v in node.items()}"
|
||||
"\n"
|
||||
" if isinstance(node, list):\n"
|
||||
" return [strip_markdown_tree(v) for v in node]\n"
|
||||
" if isinstance(node, str):\n"
|
||||
" s = node\n"
|
||||
" for rx, rep in _MD_RES:\n"
|
||||
" s = rx.sub(rep, s)\n"
|
||||
" return s\n"
|
||||
" return node\n",
|
||||
),
|
||||
# P3 — fitter dans _text
|
||||
(
|
||||
" def _text(self, slide, x, y, w, h, txt, *, font=None, size=14,"
|
||||
"\n"
|
||||
" bold=False, italic=False, color=None, align=PP_ALIGN."
|
||||
"LEFT,\n"
|
||||
" anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing="
|
||||
"None):\n"
|
||||
" tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))\n",
|
||||
" def _text(self, slide, x, y, w, h, txt, *, font=None, size=14,"
|
||||
"\n"
|
||||
" bold=False, italic=False, color=None, align=PP_ALIGN."
|
||||
"LEFT,\n"
|
||||
" anchor=MSO_ANCHOR.TOP, spacing=None, char_spacing="
|
||||
"None,\n"
|
||||
" fit=True):\n"
|
||||
" if (fit and FIT_TEXT and HAS_MEASURE and txt\n"
|
||||
" and h and h > 0.3 and w and w > 0.5):\n"
|
||||
" _ratio = (spacing / size) if spacing else None\n"
|
||||
" _s, _t, _tr = measure.fit_text(\n"
|
||||
" str(txt), font or self.F_BODY, size, w, h,\n"
|
||||
" bold=bold, line_ratio=_ratio)\n"
|
||||
" if _s != size or _tr:\n"
|
||||
" print(f\" ~ fit slide \"\n"
|
||||
" f\"{getattr(self, '_slide_num', '?')} : \"\n"
|
||||
" f\"{size}→{_s} pt\"\n"
|
||||
" + (\" +troncature\" if _tr else \"\"))\n"
|
||||
" size, txt = _s, _t\n"
|
||||
" tb = slide.shapes.add_textbox(Cm(x), Cm(y), Cm(w), Cm(h))\n",
|
||||
),
|
||||
# P4 — strip à l'entrée de render()
|
||||
(
|
||||
" if isinstance(data, str):\n"
|
||||
" data = yaml.safe_load(data)\n"
|
||||
" slides = data.get(\"slides\", data) if isinstance(data, "
|
||||
"dict) else data\n",
|
||||
" if isinstance(data, str):\n"
|
||||
" data = yaml.safe_load(data)\n"
|
||||
" data = strip_markdown_tree(data)\n"
|
||||
" slides = data.get(\"slides\", data) if isinstance(data, "
|
||||
"dict) else data\n",
|
||||
),
|
||||
# P5 — speaker notes
|
||||
(
|
||||
" if layout not in excluded and layout != "
|
||||
"\"recommendation_card\":\n"
|
||||
" self._footer(slide, i + 1)\n"
|
||||
"\n"
|
||||
" prs.save(output_path)\n",
|
||||
" if layout not in excluded and layout != "
|
||||
"\"recommendation_card\":\n"
|
||||
" self._footer(slide, i + 1)\n"
|
||||
" notes = sd.get(\"notes\") if isinstance(sd, dict) else"
|
||||
" None\n"
|
||||
" if notes:\n"
|
||||
" slide.notes_slide.notes_text_frame.text = "
|
||||
"str(notes)\n"
|
||||
"\n"
|
||||
" prs.save(output_path)\n",
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "strip_markdown_tree"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
if not Path("measure.py").exists():
|
||||
print(" ~ measure.py absent : le moteur restera sur l'heuristique"
|
||||
" tant qu'il n'est pas déposé (patch appliqué quand même).")
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (strip_markdown_tree présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — moteur inattendu." % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c3")
|
||||
print(" + Sauvegarde : %s.bak-c3" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 5 patchs appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c3", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Prérequis mesure réelle : pip3 install Pillow (venv) et TTF"
|
||||
" dans assets/fonts/")
|
||||
print(" Diagnostic : python3 measure.py")
|
||||
print(" Désactivation d'urgence du fitter : FIT_TEXT=0 dans .env")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_render_engine_c3b.py — Chantier C3b (centrage vertical)
|
||||
=============================================================
|
||||
Corrige deux défauts de centrage vertical révélés par les previews C2,
|
||||
SANS toucher au helper _cy() (le centrage du contenu dans la zone est
|
||||
un choix de design assumé — le contenu respire).
|
||||
|
||||
P1. big_stat : la hauteur de bloc était codée en dur (block = 8.13),
|
||||
sous-estimée par rapport au placement réel (chiffre à y, desc à
|
||||
y+5.33, source à y+7.37 ≈ 8.3+). Résultat : _cy() centrait un
|
||||
bloc faussé, l'ensemble penchait vers le bas. Correctif : hauteur
|
||||
réelle calculée (avec/sans source) → centrage exact. Les offsets
|
||||
internes deviennent relatifs à cette hauteur.
|
||||
|
||||
P2. executive_summary : dans chaque carte (hauteur ch=3.68), le label
|
||||
et le texte étaient posés à des offsets FIXES (y+0.56, y+1.57),
|
||||
collés en haut, laissant du vide en bas de carte. Correctif : le
|
||||
bloc label+texte est centré verticalement dans la carte (offsets
|
||||
dérivés de ch), à côté du badge déjà centré.
|
||||
|
||||
Aucune dépendance nouvelle ; réutilise measure via estimate_text_height
|
||||
déjà présent. Vérifie chaque ancre, écrit .bak-c3b, compile, idempotent.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_render_engine_c3b.py
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("render_engine_v2.py")
|
||||
|
||||
PATCHES = [
|
||||
# P1 — big_stat : hauteur réelle
|
||||
(
|
||||
' def _render_big_stat(self, slide, d):\n'
|
||||
' self._title(slide, pick(d, "titre", "title"))\n'
|
||||
' val = pick(d, "valeur", "stat", "chiffre", "value")\n'
|
||||
' desc = pick(d, "description", "texte", "label")\n'
|
||||
' src = pick(d, "source", "reference")\n'
|
||||
' block = 8.13\n'
|
||||
' y = self._cy(block)\n'
|
||||
' self._text(slide, 0, y, self.SLIDE_W, 5.1, val,\n'
|
||||
' font=self.F_DISPLAY, size=self.T["stat_hero"], '
|
||||
'bold=True,\n'
|
||||
' color=self.C["coral"], align=PP_ALIGN.CENTER,\n'
|
||||
' anchor=MSO_ANCHOR.MIDDLE)\n'
|
||||
' self._text(slide, 6.86, y + 5.33, self.SLIDE_W - 13.72, '
|
||||
'2.0, desc,\n'
|
||||
' size=18, color=self.C["body"], '
|
||||
'align=PP_ALIGN.CENTER)\n'
|
||||
' if src:\n'
|
||||
' self._text(slide, 6.86, y + 7.37, self.SLIDE_W - '
|
||||
'13.72, 0.9, src,\n'
|
||||
' size=11, italic=True, '
|
||||
'color=self.C["muted"],\n'
|
||||
' align=PP_ALIGN.CENTER)\n',
|
||||
|
||||
' def _render_big_stat(self, slide, d):\n'
|
||||
' self._title(slide, pick(d, "titre", "title"))\n'
|
||||
' val = pick(d, "valeur", "stat", "chiffre", "value")\n'
|
||||
' desc = pick(d, "description", "texte", "label")\n'
|
||||
' src = pick(d, "source", "reference")\n'
|
||||
' # Hauteur réelle du bloc (C3b) : chiffre + desc '
|
||||
'(+ source)\n'
|
||||
' H_VAL, GAP_D, H_DESC, GAP_S, H_SRC = 5.1, 0.23, 2.0, '
|
||||
'0.14, 0.9\n'
|
||||
' block = H_VAL + GAP_D + H_DESC + (\n'
|
||||
' GAP_S + H_SRC if src else 0.0)\n'
|
||||
' y = self._cy(block)\n'
|
||||
' self._text(slide, 0, y, self.SLIDE_W, H_VAL, val,\n'
|
||||
' font=self.F_DISPLAY, size=self.T["stat_hero"], '
|
||||
'bold=True,\n'
|
||||
' color=self.C["coral"], align=PP_ALIGN.CENTER,\n'
|
||||
' anchor=MSO_ANCHOR.MIDDLE)\n'
|
||||
' y_desc = y + H_VAL + GAP_D\n'
|
||||
' self._text(slide, 6.86, y_desc, self.SLIDE_W - 13.72, '
|
||||
'H_DESC, desc,\n'
|
||||
' size=18, color=self.C["body"], '
|
||||
'align=PP_ALIGN.CENTER)\n'
|
||||
' if src:\n'
|
||||
' y_src = y_desc + H_DESC + GAP_S\n'
|
||||
' self._text(slide, 6.86, y_src, self.SLIDE_W - 13.72, '
|
||||
'H_SRC, src,\n'
|
||||
' size=11, italic=True, '
|
||||
'color=self.C["muted"],\n'
|
||||
' align=PP_ALIGN.CENTER)\n',
|
||||
),
|
||||
# P2 — executive_summary : bloc label+texte centré dans la carte
|
||||
(
|
||||
' self._text(slide, self.MX + 3.81, y + 0.56, 8.1, 1.0, '
|
||||
'label,\n'
|
||||
' size=14, bold=True, color=col, '
|
||||
'char_spacing=3)\n'
|
||||
' self._text(slide, self.MX + 3.81, y + 1.57,\n'
|
||||
' self.SLIDE_W - 2 * self.MX - 5.33, 1.8, '
|
||||
'txt,\n'
|
||||
' size=self.T["body"], '
|
||||
'color=self.C["body"])\n',
|
||||
|
||||
' # Bloc label+texte centré verticalement dans la carte '
|
||||
'(C3b)\n'
|
||||
' H_LBL, GAP_LT, H_TXT = 0.85, 0.18, 1.8\n'
|
||||
' blk = H_LBL + GAP_LT + H_TXT\n'
|
||||
' y_lbl = y + max(0.0, (ch - blk) / 2)\n'
|
||||
' self._text(slide, self.MX + 3.81, y_lbl, 8.1, H_LBL, '
|
||||
'label,\n'
|
||||
' size=14, bold=True, color=col, '
|
||||
'char_spacing=3,\n'
|
||||
' anchor=MSO_ANCHOR.MIDDLE)\n'
|
||||
' self._text(slide, self.MX + 3.81, y_lbl + H_LBL + '
|
||||
'GAP_LT,\n'
|
||||
' self.SLIDE_W - 2 * self.MX - 5.33, H_TXT, '
|
||||
'txt,\n'
|
||||
' size=self.T["body"], color=self.C["body"],'
|
||||
'\n'
|
||||
' anchor=MSO_ANCHOR.MIDDLE)\n',
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "Hauteur réelle du bloc (C3b)"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (C3b présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — moteur inattendu "
|
||||
"(version différente ?)." % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c3b")
|
||||
print(" + Sauvegarde : %s.bak-c3b" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 2 patchs de centrage appliqués (big_stat, "
|
||||
"executive_summary).")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c3b", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Re-rends et compare les previews : big_stat (chiffre "
|
||||
"mieux centré),")
|
||||
print(" executive_summary (texte centré dans les cartes). Vérifie "
|
||||
"aussi from_to_pairs")
|
||||
print(" (le rognage des labels a pu disparaître avec le fix polices "
|
||||
"C3).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_render_engine_c4.py — Chantier C4 (Assets & images)
|
||||
=========================================================
|
||||
Patch strict de render_engine_v2.py :
|
||||
P1. _image_size() module-level : dimensions via Pillow, sinon
|
||||
mini-parseur d'en-têtes PNG/JPEG (aucune dépendance).
|
||||
P2. Composants : _asset() (résolution dans assets/ du projet),
|
||||
_image() (insertion cover = recadrage centré symétrique, jamais de
|
||||
déformation ; contain = letterbox centré), _rect_alpha() (voile de
|
||||
couleur avec transparence OOXML), et les renderers
|
||||
_render_image_split (image 40 %, side left/right, légende sur
|
||||
bandeau navy) et _render_image_full (plein cadre + voile navy
|
||||
60 % + titre display blanc).
|
||||
Image introuvable → placeholder card_alt + nom du fichier
|
||||
(warning console), jamais de crash.
|
||||
P3. Freeform : nouveau bloc {type: image, image: fichier, fit:}.
|
||||
P4. REGISTRY : image_split, image_full.
|
||||
P5. CLI : --assets (défaut : <dossier du yaml>/../assets, soit
|
||||
projets/<slug>/assets/ dans le flux facilitator — aucun patch
|
||||
facilitator nécessaire pour le rendu).
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_render_engine_c4.py
|
||||
Indépendant du patch C3 (ancres distinctes, tout ordre). Vérifie chaque
|
||||
ancre, écrit .bak-c4, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("render_engine_v2.py")
|
||||
|
||||
IMAGE_SIZE_FUNC = '''
|
||||
|
||||
def _image_size(path):
|
||||
"""Dimensions (w, h) d'une image : Pillow si présent, sinon lecture
|
||||
directe des en-têtes PNG/JPEG. None si indéterminable."""
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(path) as im:
|
||||
return im.size
|
||||
except Exception:
|
||||
pass
|
||||
import struct
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(26)
|
||||
if head[:8] == b"\\x89PNG\\r\\n\\x1a\\n":
|
||||
w, h = struct.unpack(">II", head[16:24])
|
||||
return int(w), int(h)
|
||||
if head[:2] == b"\\xff\\xd8":
|
||||
f.seek(2)
|
||||
while True:
|
||||
b2 = f.read(2)
|
||||
if len(b2) < 2 or b2[0] != 0xFF:
|
||||
return None
|
||||
marker = b2[1]
|
||||
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6,
|
||||
0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE,
|
||||
0xCF):
|
||||
f.read(3)
|
||||
h, w = struct.unpack(">HH", f.read(4))
|
||||
return int(w), int(h)
|
||||
ln = struct.unpack(">H", f.read(2))[0]
|
||||
f.seek(ln - 2, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
'''
|
||||
|
||||
COMPONENTS = ''' # ---------------- assets & images (C4) ----------------
|
||||
assets_dir = "assets" # surchargé par main() ou l'appelant
|
||||
|
||||
def _asset(self, name):
|
||||
"""Résout un nom de fichier image vers un chemin existant :
|
||||
chemin direct, puis assets_dir/name. None si introuvable."""
|
||||
name = str(name or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
if os.path.isfile(name):
|
||||
return name
|
||||
cand = os.path.join(str(self.assets_dir or "assets"), name)
|
||||
if os.path.isfile(cand):
|
||||
return cand
|
||||
return None
|
||||
|
||||
def _image(self, slide, x, y, w, h, name, fit="cover"):
|
||||
"""Insère une image dans la zone (x, y, w, h) SANS déformation.
|
||||
cover : remplit la zone, recadrage centré symétrique (crop_*).
|
||||
contain : letterbox centré dans la zone.
|
||||
Image introuvable ou illisible → placeholder déterministe."""
|
||||
path = self._asset(name)
|
||||
size = _image_size(path) if path else None
|
||||
if not path or not size or size[0] <= 0 or size[1] <= 0:
|
||||
print(f" ~ image absente ou illisible : {name} → placeholder")
|
||||
self._rect(slide, x, y, w, h, self.C["card_alt"])
|
||||
self._text(slide, x, y, w, h, f"[image : {name}]",
|
||||
size=12, color=self.C["muted"],
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
|
||||
fit=False)
|
||||
return None
|
||||
iw, ih = size
|
||||
target_ratio = w / h
|
||||
img_ratio = iw / ih
|
||||
if fit == "contain":
|
||||
if img_ratio > target_ratio:
|
||||
w2, h2 = w, w / img_ratio
|
||||
x2, y2 = x, y + (h - h2) / 2
|
||||
else:
|
||||
h2, w2 = h, h * img_ratio
|
||||
x2, y2 = x + (w - w2) / 2, y
|
||||
return slide.shapes.add_picture(path, Cm(x2), Cm(y2),
|
||||
Cm(w2), Cm(h2))
|
||||
pic = slide.shapes.add_picture(path, Cm(x), Cm(y), Cm(w), Cm(h))
|
||||
if img_ratio > target_ratio: # trop large → crop lat.
|
||||
c = (1 - target_ratio / img_ratio) / 2
|
||||
pic.crop_left = c
|
||||
pic.crop_right = c
|
||||
elif img_ratio < target_ratio: # trop haut → crop vert.
|
||||
c = (1 - img_ratio / target_ratio) / 2
|
||||
pic.crop_top = c
|
||||
pic.crop_bottom = c
|
||||
return pic
|
||||
|
||||
def _rect_alpha(self, slide, x, y, w, h, color, alpha_pct):
|
||||
"""Rectangle de couleur avec transparence (voile) — OOXML."""
|
||||
sh = self._rect(slide, x, y, w, h, color)
|
||||
srgb = sh._element.spPr.find(qn("a:solidFill")).find(
|
||||
qn("a:srgbClr"))
|
||||
alpha = srgb.makeelement(
|
||||
qn("a:alpha"), {"val": str(int(alpha_pct * 1000))})
|
||||
srgb.append(alpha)
|
||||
return sh
|
||||
|
||||
# ---------------- renderers images (C4) ----------------
|
||||
def _render_image_split(self, slide, d):
|
||||
"""Image 40 % d'un côté (side: left|right) + titre et bullets.
|
||||
L'image s'arrête au-dessus du footer (numéro de page lisible)."""
|
||||
side = str(d.get("side") or "left").lower()
|
||||
img_w = self.SLIDE_W * 0.40
|
||||
img_h = self.FOOTER_Y - 0.2
|
||||
ix = 0.0 if side != "right" else self.SLIDE_W - img_w
|
||||
self._image(slide, ix, 0.0, img_w, img_h,
|
||||
pick(d, "image", "img"), fit="cover")
|
||||
legende = pick(d, "legende", "caption")
|
||||
if legende:
|
||||
bh = 1.15
|
||||
self._rect(slide, ix, img_h - bh, img_w, bh, self.C["navy"])
|
||||
self._text(slide, ix + 0.5, img_h - bh, img_w - 1.0, bh,
|
||||
legende, size=11, color=self.C["white"],
|
||||
anchor=MSO_ANCHOR.MIDDLE)
|
||||
cx = self.MX if side == "right" else img_w + 1.5
|
||||
cw = self.SLIDE_W - img_w - 1.5 - self.MX
|
||||
self._text(slide, cx, self.TITLE_Y, cw, self.TITLE_H,
|
||||
pick(d, "titre", "title"),
|
||||
font=self.F_DISPLAY, size=self.T["slide_title"],
|
||||
bold=True, color=self.C["navy"])
|
||||
bullets = [b for b in (d.get("bullets") or [])][:6]
|
||||
bh_i, gap = 1.9, 0.5
|
||||
tot = len(bullets) * (bh_i + gap) - gap if bullets else 0
|
||||
y = self._cy(tot)
|
||||
for b in bullets:
|
||||
self._oval(slide, cx, y + 0.28, 0.28, self.C["coral"])
|
||||
self._text(slide, cx + 0.8, y, cw - 0.8, bh_i,
|
||||
as_label(b, "texte", "text"),
|
||||
size=self.T["body"], color=self.C["body"])
|
||||
y += bh_i + gap
|
||||
|
||||
def _render_image_full(self, slide, d):
|
||||
"""Image plein cadre + voile navy 60 % + titre display blanc.
|
||||
Ouverture de chapitre visuelle (alternative à section_divider)."""
|
||||
self._bg(slide, self.C["navy"])
|
||||
self._image(slide, 0.0, 0.0, self.SLIDE_W, self.SLIDE_H,
|
||||
pick(d, "image", "img"), fit="cover")
|
||||
self._rect_alpha(slide, 0.0, 0.0, self.SLIDE_W, self.SLIDE_H,
|
||||
self.C["navy"], 60)
|
||||
self._oval(slide, self.MX, 6.48, 0.66, self.C["coral"])
|
||||
self._text(slide, self.MX, 7.6, 26.0, 4.5,
|
||||
pick(d, "titre", "title"),
|
||||
font=self.F_DISPLAY, size=44, bold=True,
|
||||
color=self.C["white"], spacing=48)
|
||||
st = pick(d, "sous_titre", "subtitle")
|
||||
if st:
|
||||
self._text(slide, self.MX, 12.3, 22.0, 1.5, st,
|
||||
size=16, italic=True, color=self.C["glacier"])
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P1 — _image_size après hex_to_rgb
|
||||
(
|
||||
"def hex_to_rgb(h: str) -> RGBColor:\n"
|
||||
" h = (h or \"#000000\").lstrip(\"#\")\n"
|
||||
" return RGBColor(int(h[0:2], 16), int(h[2:4], 16), "
|
||||
"int(h[4:6], 16))\n",
|
||||
"def hex_to_rgb(h: str) -> RGBColor:\n"
|
||||
" h = (h or \"#000000\").lstrip(\"#\")\n"
|
||||
" return RGBColor(int(h[0:2], 16), int(h[2:4], 16), "
|
||||
"int(h[4:6], 16))\n"
|
||||
+ IMAGE_SIZE_FUNC,
|
||||
),
|
||||
# P3 — bloc freeform image (après le bloc line)
|
||||
(
|
||||
" elif btype == \"line\":\n"
|
||||
" ln = slide.shapes.add_connector(\n"
|
||||
" 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))\n"
|
||||
" ln.line.color.rgb = hex_to_rgb(\n"
|
||||
" self._token_color(blk.get(\"color\"), "
|
||||
"self.C[\"muted\"]))\n"
|
||||
" ln.line.width = Pt(float(blk.get(\"weight\", "
|
||||
"1.25)))\n",
|
||||
" elif btype == \"line\":\n"
|
||||
" ln = slide.shapes.add_connector(\n"
|
||||
" 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h))\n"
|
||||
" ln.line.color.rgb = hex_to_rgb(\n"
|
||||
" self._token_color(blk.get(\"color\"), "
|
||||
"self.C[\"muted\"]))\n"
|
||||
" ln.line.width = Pt(float(blk.get(\"weight\", "
|
||||
"1.25)))\n"
|
||||
"\n"
|
||||
" elif btype == \"image\":\n"
|
||||
" self._image(slide, x, y, w, h,\n"
|
||||
" blk.get(\"image\") or "
|
||||
"blk.get(\"src\", \"\"),\n"
|
||||
" fit=str(blk.get(\"fit\") or "
|
||||
"\"cover\"))\n",
|
||||
),
|
||||
# P4 — REGISTRY
|
||||
(
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n",
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n"
|
||||
" \"image_split\": \"_render_image_split\",\n"
|
||||
" \"image_full\": \"_render_image_full\",\n",
|
||||
),
|
||||
# P5a — CLI --assets
|
||||
(
|
||||
" parser.add_argument(\"--layouts\", default=\"layouts_v2.yaml"
|
||||
"\")\n"
|
||||
" args = parser.parse_args()\n",
|
||||
" parser.add_argument(\"--layouts\", default=\"layouts_v2.yaml"
|
||||
"\")\n"
|
||||
" parser.add_argument(\"--assets\", default=None,\n"
|
||||
" help=\"Dossier des images (défaut : "
|
||||
"<input>/../assets)\")\n"
|
||||
" args = parser.parse_args()\n",
|
||||
),
|
||||
# P5b — résolution du dossier assets
|
||||
(
|
||||
" engine = RenderEngineV2(args.theme, args.layouts, "
|
||||
"args.components)\n"
|
||||
" engine.render(data, args.output)\n",
|
||||
" engine = RenderEngineV2(args.theme, args.layouts, "
|
||||
"args.components)\n"
|
||||
" engine.assets_dir = args.assets or os.path.join(\n"
|
||||
" os.path.dirname(os.path.abspath(args.input_file)),\n"
|
||||
" \"..\", \"assets\")\n"
|
||||
" engine.render(data, args.output)\n",
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "_render_image_split"
|
||||
ANCHOR_ORCH = " # ---------------- orchestration ----------------"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (_render_image_split présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — moteur inattendu." % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_ORCH) != 1:
|
||||
fail("Ancre de la section orchestration introuvable ou non "
|
||||
"unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c4")
|
||||
print(" + Sauvegarde : %s.bak-c4" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
# P2 — insertion des composants/renderers avant l'orchestration
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l)
|
||||
lines[idx:idx] = COMPONENTS.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 5 patchs + composants images appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c4", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Suite C4 : python3 patch_layouts_c4.py (catalogue), "
|
||||
"python3 patch_facilitator_c4.py (/lire + freeform),")
|
||||
print(" puis python3 prompt_injection_v2.py && python3 "
|
||||
"build_gallery.py (propagation).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_render_engine_c5a.py — Chantier C5 · lot 1 (charts natifs)
|
||||
================================================================
|
||||
Patch strict de render_engine_v2.py — trois layouts de données en
|
||||
graphiques NATIFS python-pptx (éditables dans PowerPoint, données
|
||||
modifiables par le lecteur — pas des images) :
|
||||
|
||||
bar_chart (L42) — colonnes ou barres (horizontal: true), max
|
||||
8 catégories × 3 séries, axe des valeurs masqué,
|
||||
étiquettes de valeurs affichées, style consulting.
|
||||
line_chart (L43) — courbes à marqueurs, max 12 points × 3 séries,
|
||||
gridlines très claires, dernier point de la
|
||||
série 1 étiqueté en corail gras (règle fixe).
|
||||
donut_split (L44) — anneau (60 % gauche, trou 65, segments au cycle
|
||||
PR) + valeur centrale + légende détaillée custom
|
||||
à droite (pastille, label, valeur).
|
||||
|
||||
Style PR en dur : séries navy → coral → glacier, Calibri, aucune
|
||||
couleur libre. Bornes appliquées par troncature tracée (warning
|
||||
console) — les schémas C1 et les prompts bornent en amont.
|
||||
|
||||
P1. Imports chart python-pptx.
|
||||
P2. Composants _num/_chart_base + 3 renderers (avant orchestration).
|
||||
P3. REGISTRY : les 3 entrées.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_render_engine_c5a.py
|
||||
Indépendant des patchs C3/C4 (tout ordre). Vérifie chaque ancre,
|
||||
écrit .bak-c5a, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("render_engine_v2.py")
|
||||
|
||||
COMPONENTS = ''' # ---------------- charts natifs (C5 lot 1) ----------------
|
||||
|
||||
def _num(self, v, default=0.0):
|
||||
"""Nombre robuste : int/float directs, chaînes '12,5' ou '12.5'."""
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
try:
|
||||
return float(str(v).replace(",", ".").replace(" ", ""))
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
|
||||
def _chart_series_colors(self):
|
||||
return [self.C["navy"], self.C["coral"], self.C["glacier"]]
|
||||
|
||||
def _chart_base(self, slide, x, y, w, h, chart_type, chart_data):
|
||||
"""Insertion + style de base commun (police, taille, couleur)."""
|
||||
gf = slide.shapes.add_chart(chart_type, Cm(x), Cm(y),
|
||||
Cm(w), Cm(h), chart_data)
|
||||
ch = gf.chart
|
||||
ch.font.name = self.F_BODY
|
||||
ch.font.size = Pt(11)
|
||||
ch.font.color.rgb = hex_to_rgb(self.C["body"])
|
||||
return ch
|
||||
|
||||
def _chart_caption(self, slide, d):
|
||||
"""Unité (droite de la zone titre) et source (bas de page)."""
|
||||
unite = pick(d, "unite", "unit")
|
||||
if unite:
|
||||
self._text(slide, self.SLIDE_W - self.MX - 8.0,
|
||||
self.TITLE_Y + 0.35, 8.0, 0.8, f"en {unite}",
|
||||
size=12, italic=True, color=self.C["muted"],
|
||||
align=PP_ALIGN.RIGHT)
|
||||
source = pick(d, "source")
|
||||
if source:
|
||||
self._text(slide, self.MX, self.CONT_B - 0.55,
|
||||
self.SLIDE_W - 2 * self.MX, 0.55,
|
||||
f"Source : {source}", size=10,
|
||||
color=self.C["muted"])
|
||||
|
||||
def _chart_zone(self, d):
|
||||
"""Zone du graphique (réserve la ligne source si présente)."""
|
||||
h = self.CONT_H - (0.7 if pick(d, "source") else 0.0)
|
||||
return self.MX, self.CONT_Y, self.SLIDE_W - 2 * self.MX, h
|
||||
|
||||
def _render_bar_chart(self, slide, d):
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION,
|
||||
XL_LABEL_POSITION)
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
self._chart_caption(slide, d)
|
||||
cats = [as_label(c) for c in (d.get("categories") or [])]
|
||||
series = [s for s in (d.get("series") or []) if isinstance(s, dict)]
|
||||
if len(cats) > 8:
|
||||
print(f" ~ bar_chart : {len(cats)} catégories → 8 (borne)")
|
||||
cats = cats[:8]
|
||||
if len(series) > 3:
|
||||
print(f" ~ bar_chart : {len(series)} séries → 3 (borne)")
|
||||
series = series[:3]
|
||||
cd = CategoryChartData()
|
||||
cd.categories = cats
|
||||
for s in series:
|
||||
vals = [self._num(v) for v in (s.get("values") or [])][:len(cats)]
|
||||
vals += [0.0] * (len(cats) - len(vals))
|
||||
cd.add_series(pick(s, "label", default="Série"), tuple(vals))
|
||||
x, y, w, h = self._chart_zone(d)
|
||||
horiz = bool(d.get("horizontal"))
|
||||
ctype = (XL_CHART_TYPE.BAR_CLUSTERED if horiz
|
||||
else XL_CHART_TYPE.COLUMN_CLUSTERED)
|
||||
ch = self._chart_base(slide, x, y, w, h, ctype, cd)
|
||||
plot = ch.plots[0]
|
||||
plot.gap_width = 60
|
||||
if len(series) > 1:
|
||||
plot.overlap = -10
|
||||
for i, ser in enumerate(ch.series):
|
||||
ser.format.fill.solid()
|
||||
ser.format.fill.fore_color.rgb = hex_to_rgb(
|
||||
self._chart_series_colors()[i % 3])
|
||||
ch.value_axis.visible = False
|
||||
ch.value_axis.has_major_gridlines = False
|
||||
cat_ax = ch.category_axis
|
||||
cat_ax.has_major_gridlines = False
|
||||
cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"])
|
||||
cat_ax.tick_labels.font.size = Pt(11)
|
||||
plot.has_data_labels = True
|
||||
dls = plot.data_labels
|
||||
dls.font.size = Pt(10)
|
||||
dls.font.bold = True
|
||||
dls.font.color.rgb = hex_to_rgb(self.C["body"])
|
||||
dls.position = (XL_LABEL_POSITION.OUTSIDE_END)
|
||||
ch.has_legend = len(series) > 1
|
||||
if ch.has_legend:
|
||||
ch.legend.position = XL_LEGEND_POSITION.BOTTOM
|
||||
ch.legend.include_in_layout = False
|
||||
ch.legend.font.size = Pt(11)
|
||||
|
||||
def _render_line_chart(self, slide, d):
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION,
|
||||
XL_LABEL_POSITION, XL_MARKER_STYLE)
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
self._chart_caption(slide, d)
|
||||
pts = [as_label(p) for p in (d.get("points_x") or [])]
|
||||
series = [s for s in (d.get("series") or []) if isinstance(s, dict)]
|
||||
if len(pts) > 12:
|
||||
print(f" ~ line_chart : {len(pts)} points → 12 (borne)")
|
||||
pts = pts[:12]
|
||||
if len(series) > 3:
|
||||
print(f" ~ line_chart : {len(series)} séries → 3 (borne)")
|
||||
series = series[:3]
|
||||
cd = CategoryChartData()
|
||||
cd.categories = pts
|
||||
for s in series:
|
||||
vals = [self._num(v) for v in (s.get("values") or [])][:len(pts)]
|
||||
vals += [0.0] * (len(pts) - len(vals))
|
||||
cd.add_series(pick(s, "label", default="Série"), tuple(vals))
|
||||
x, y, w, h = self._chart_zone(d)
|
||||
ch = self._chart_base(slide, x, y, w, h,
|
||||
XL_CHART_TYPE.LINE_MARKERS, cd)
|
||||
for i, ser in enumerate(ch.series):
|
||||
col = hex_to_rgb(self._chart_series_colors()[i % 3])
|
||||
ser.format.line.color.rgb = col
|
||||
ser.format.line.width = Pt(2.25)
|
||||
ser.smooth = False
|
||||
ser.marker.style = XL_MARKER_STYLE.CIRCLE
|
||||
ser.marker.size = 6
|
||||
ser.marker.format.fill.solid()
|
||||
ser.marker.format.fill.fore_color.rgb = col
|
||||
ser.marker.format.line.fill.background()
|
||||
va = ch.value_axis
|
||||
va.has_major_gridlines = True
|
||||
va.major_gridlines.format.line.color.rgb = hex_to_rgb(
|
||||
self.C["card_alt"])
|
||||
va.tick_labels.font.size = Pt(10)
|
||||
va.tick_labels.font.color.rgb = hex_to_rgb(self.C["muted"])
|
||||
va.format.line.fill.background()
|
||||
cat_ax = ch.category_axis
|
||||
cat_ax.has_major_gridlines = False
|
||||
cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"])
|
||||
cat_ax.tick_labels.font.size = Pt(11)
|
||||
ch.has_legend = len(series) > 1
|
||||
if ch.has_legend:
|
||||
ch.legend.position = XL_LEGEND_POSITION.BOTTOM
|
||||
ch.legend.include_in_layout = False
|
||||
ch.legend.font.size = Pt(11)
|
||||
# Règle fixe : dernier point de la série 1 étiqueté corail gras
|
||||
if series and pts:
|
||||
try:
|
||||
last = ch.series[0].points[len(pts) - 1]
|
||||
dl = last.data_label
|
||||
dl.position = XL_LABEL_POSITION.ABOVE
|
||||
v = self._num((series[0].get("values") or [0])[
|
||||
min(len(pts), len(series[0].get("values") or [])) - 1])
|
||||
txt = ("%g" % v)
|
||||
dl.text_frame.text = txt
|
||||
run = dl.text_frame.paragraphs[0].runs[0]
|
||||
run.font.size = Pt(12)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = hex_to_rgb(self.C["coral"])
|
||||
run.font.name = self.F_BODY
|
||||
except Exception as e:
|
||||
print(f" ~ line_chart : étiquette dernier point sautée "
|
||||
f"({e})")
|
||||
|
||||
def _render_donut_split(self, slide, d):
|
||||
from pptx.chart.data import CategoryChartData
|
||||
from pptx.enum.chart import XL_CHART_TYPE
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
self._chart_caption(slide, d)
|
||||
segs = [s for s in (d.get("segments") or []) if isinstance(s, dict)]
|
||||
if len(segs) > 6:
|
||||
print(f" ~ donut_split : {len(segs)} segments → 6 (borne)")
|
||||
segs = segs[:6]
|
||||
labels = [pick(s, "label", default="—") for s in segs]
|
||||
vals = [self._num(pick(s, "valeur", "value")) for s in segs]
|
||||
cd = CategoryChartData()
|
||||
cd.categories = labels
|
||||
cd.add_series("Répartition", tuple(vals))
|
||||
x, y, w, h = self._chart_zone(d)
|
||||
cw = w * 0.55
|
||||
side = min(cw, h)
|
||||
cx0 = x + (cw - side) / 2
|
||||
cy0 = y + (h - side) / 2
|
||||
ch = self._chart_base(slide, cx0, cy0, side, side,
|
||||
XL_CHART_TYPE.DOUGHNUT, cd)
|
||||
ch.has_legend = False
|
||||
for i, pt in enumerate(ch.series[0].points):
|
||||
pt.format.fill.solid()
|
||||
pt.format.fill.fore_color.rgb = hex_to_rgb(
|
||||
self.cycle[i % len(self.cycle)])
|
||||
pt.format.line.color.rgb = hex_to_rgb(self.C["white"])
|
||||
pt.format.line.width = Pt(1.5)
|
||||
plot_el = ch.plots[0]._element # <c:doughnutChart>
|
||||
hs = plot_el.find(qn("c:holeSize"))
|
||||
if hs is None:
|
||||
hs = plot_el.makeelement(qn("c:holeSize"), {})
|
||||
plot_el.append(hs)
|
||||
hs.set("val", "65")
|
||||
centre = pick(d, "valeur_centrale", "centre")
|
||||
if centre:
|
||||
self._text(slide, cx0, cy0 + side / 2 - 1.1, side, 2.2,
|
||||
centre, font=self.F_DISPLAY, size=24, bold=True,
|
||||
color=self.C["navy"], align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, fit=False)
|
||||
# Légende détaillée custom à droite
|
||||
lx = x + cw + 1.2
|
||||
lw = w - cw - 1.2
|
||||
ih, gap = 1.35, 0.45
|
||||
tot = len(segs) * (ih + gap) - gap if segs else 0
|
||||
ly = self._cy(tot)
|
||||
for i, s in enumerate(segs):
|
||||
self._oval(slide, lx, ly + (ih - 0.5) / 2, 0.5,
|
||||
self.cycle[i % len(self.cycle)])
|
||||
self._text(slide, lx + 1.0, ly, lw - 4.2, ih,
|
||||
pick(s, "label", default="—"),
|
||||
size=14, bold=True, color=self.C["body"],
|
||||
anchor=MSO_ANCHOR.MIDDLE)
|
||||
self._text(slide, lx + lw - 3.2, ly, 3.2, ih,
|
||||
"%g" % self._num(pick(s, "valeur", "value")),
|
||||
size=14, color=self.C["muted"],
|
||||
align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE)
|
||||
ly += ih + gap
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P3 — REGISTRY
|
||||
(
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n",
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n"
|
||||
" \"bar_chart\": \"_render_bar_chart\",\n"
|
||||
" \"line_chart\": \"_render_line_chart\",\n"
|
||||
" \"donut_split\": \"_render_donut_split\",\n",
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "_render_bar_chart"
|
||||
ANCHOR_ORCH = " # ---------------- orchestration ----------------"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (_render_bar_chart présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — moteur inattendu." % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_ORCH) != 1:
|
||||
fail("Ancre de la section orchestration introuvable ou non "
|
||||
"unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c5a")
|
||||
print(" + Sauvegarde : %s.bak-c5a" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l)
|
||||
lines[idx:idx] = COMPONENTS.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + Composants charts + 3 renderers + REGISTRY appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c5a", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Suite lot 1 : python3 patch_layouts_c5a.py puis "
|
||||
"propagation (prompt_injection_v2 + build_gallery).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_render_engine_c5b.py — Chantier C5 · lot 2 (waterfall, heatmap,
|
||||
funnel)
|
||||
=====================================================================
|
||||
Patch strict de render_engine_v2.py. PRÉREQUIS : lot 1 appliqué
|
||||
(patch_render_engine_c5a.py) — les renderers du lot 2 réutilisent
|
||||
_chart_header/_chart_source ; le patch refuse de s'appliquer sinon.
|
||||
|
||||
P1. Import MSO_LINE (connecteurs pointillés) + _blend() module-level
|
||||
(mélange déterministe de deux couleurs hex).
|
||||
P2. Trois renderers :
|
||||
waterfall (L45) — pont de valeur en rectangles : cumuls
|
||||
CALCULÉS par le renderer (l'agent ne positionne
|
||||
rien), delta+ corail / delta− slate, départ et
|
||||
arrivée navy pleins, connecteurs pointillés,
|
||||
étiquettes signées. Si la valeur d'arrivée
|
||||
déclarée diverge du cumul calculé : warning et le
|
||||
cumul fait foi (cohérence visuelle).
|
||||
heatmap_table (L46) — grille d'intensité : score entier 0-4 →
|
||||
5 teintes précalculées du navy vers blanc, texte
|
||||
du score en contraste automatique, aucune couleur
|
||||
libre. Scores hors bornes : clamp + warning.
|
||||
funnel (L47) — entonnoir en vrais trapèzes (freeform
|
||||
builder) : largeurs proportionnelles aux valeurs,
|
||||
plancher 30 %, raccord exact entre étages,
|
||||
dernier étage corail (règle fixe), descriptions
|
||||
alignées à droite.
|
||||
P3. REGISTRY : waterfall, heatmap_table, funnel.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_render_engine_c5b.py
|
||||
Vérifie chaque ancre, écrit .bak-c5b, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("render_engine_v2.py")
|
||||
|
||||
BLEND_FUNC = '''
|
||||
|
||||
def _blend(hex_a: str, hex_b: str, t: float) -> str:
|
||||
"""Mélange linéaire déterministe de deux couleurs hex (t: 0→a, 1→b)."""
|
||||
a = (hex_a or "#000000").lstrip("#")
|
||||
b = (hex_b or "#FFFFFF").lstrip("#")
|
||||
t = max(0.0, min(1.0, float(t)))
|
||||
out = "".join(
|
||||
"%02X" % round(int(a[i:i + 2], 16)
|
||||
+ (int(b[i:i + 2], 16) - int(a[i:i + 2], 16)) * t)
|
||||
for i in (0, 2, 4))
|
||||
return "#" + out
|
||||
'''
|
||||
|
||||
COMPONENTS = '''
|
||||
# ---------------- data & structure (C5 lot 2) ----------------
|
||||
WF_MAX_STEPS = 8
|
||||
HM_MAX_COLS = 6
|
||||
HM_MAX_ROWS = 8
|
||||
FUNNEL_MAX = 5
|
||||
|
||||
def _render_waterfall(self, slide, d):
|
||||
"""Pont de valeur : cumuls calculés, jamais fournis."""
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
self._chart_caption(slide, d)
|
||||
zx, zy, zw, zh = self._chart_zone(d)
|
||||
dep = d.get("depart") or {}
|
||||
arr = d.get("arrivee") or {}
|
||||
marches = (d.get("marches") or [])[:self.WF_MAX_STEPS]
|
||||
if len(d.get("marches") or []) > self.WF_MAX_STEPS:
|
||||
print(f" ~ waterfall : marches tronquées à "
|
||||
f"{self.WF_MAX_STEPS}")
|
||||
v0 = self._num(pick(dep, "valeur", "value", default=0))
|
||||
cums = [v0]
|
||||
for m in marches:
|
||||
cums.append(cums[-1] + self._num(pick(m, "delta", default=0)))
|
||||
v_end = cums[-1]
|
||||
declared = self._num(pick(arr, "valeur", "value",
|
||||
default=v_end), default=v_end)
|
||||
if abs(declared - v_end) > 1e-9:
|
||||
print(f" ~ waterfall : arrivée déclarée {declared:g} ≠ "
|
||||
f"cumul {v_end:g} — le cumul fait foi")
|
||||
chart_top = zy + 0.7 # réserve étiquettes hautes
|
||||
chart_b = zy + zh - 1.3 # réserve labels bas
|
||||
chart_h = chart_b - chart_top
|
||||
vmin = min(0.0, min(cums))
|
||||
vmax = max(max(cums), v0, 0.0)
|
||||
span = (vmax - vmin) or 1.0
|
||||
hpu = chart_h / span
|
||||
base_y = chart_top + vmax * hpu # y de la valeur 0
|
||||
n = len(marches) + 2
|
||||
slot = zw / n
|
||||
bar_w = slot * 0.62
|
||||
|
||||
def bar(i, v_from, v_to, color):
|
||||
x = zx + i * slot + (slot - bar_w) / 2
|
||||
y1 = base_y - max(v_from, v_to) * hpu
|
||||
h = max(0.06, abs(v_to - v_from) * hpu)
|
||||
self._rect(slide, x, y1, bar_w, h, color)
|
||||
return x, y1
|
||||
|
||||
def val_label(i, y_ref, text, color):
|
||||
self._text(slide, zx + i * slot, y_ref - 0.62, slot, 0.55,
|
||||
text, size=11, bold=True, color=color,
|
||||
align=PP_ALIGN.CENTER, fit=False)
|
||||
|
||||
def cat_label(i, label):
|
||||
self._text(slide, zx + i * slot + 0.05, chart_b + 0.15,
|
||||
slot - 0.1, 1.05, label, size=10,
|
||||
color=self.C["slate"], align=PP_ALIGN.CENTER)
|
||||
|
||||
def connector(x1, x2, level):
|
||||
conn = slide.shapes.add_connector(
|
||||
1, Cm(x1), Cm(base_y - level * hpu),
|
||||
Cm(x2), Cm(base_y - level * hpu))
|
||||
conn.line.color.rgb = hex_to_rgb(self.C["muted"])
|
||||
conn.line.width = Pt(1.0)
|
||||
conn.line.dash_style = MSO_LINE.DASH
|
||||
conn.shadow.inherit = False
|
||||
|
||||
ln = slide.shapes.add_connector(
|
||||
1, Cm(zx), Cm(base_y), Cm(zx + zw), Cm(base_y))
|
||||
ln.line.color.rgb = hex_to_rgb(self.C["muted"])
|
||||
ln.line.width = Pt(0.75)
|
||||
ln.shadow.inherit = False
|
||||
x, y1 = bar(0, 0.0, v0, self.C["navy"])
|
||||
val_label(0, y1, f"{v0:g}", self.C["navy"])
|
||||
cat_label(0, as_label(dep, "label", default="Départ"))
|
||||
prev_x_end = x + bar_w
|
||||
for i, m in enumerate(marches, start=1):
|
||||
c_prev, c_cur = cums[i - 1], cums[i]
|
||||
delta = c_cur - c_prev
|
||||
color = self.C["coral"] if delta >= 0 else self.C["slate"]
|
||||
x, y1 = bar(i, c_prev, c_cur, color)
|
||||
sign = "+" if delta >= 0 else "\\u2212"
|
||||
val_label(i, y1, f"{sign}{abs(delta):g}", color)
|
||||
cat_label(i, as_label(m, "label"))
|
||||
connector(prev_x_end, x, c_prev)
|
||||
prev_x_end = x + bar_w
|
||||
i = n - 1
|
||||
x, y1 = bar(i, 0.0, v_end, self.C["navy"])
|
||||
val_label(i, y1 if v_end >= 0 else base_y, f"{v_end:g}",
|
||||
self.C["navy"])
|
||||
cat_label(i, as_label(arr, "label", default="Arrivée"))
|
||||
connector(prev_x_end, x, v_end)
|
||||
|
||||
def _render_heatmap_table(self, slide, d):
|
||||
"""Grille d'intensité : score 0-4 → 5 teintes du navy."""
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
headers = [as_label(h) for h in (d.get("headers") or [])][
|
||||
:self.HM_MAX_COLS]
|
||||
rows = (d.get("rows") or [])[:self.HM_MAX_ROWS]
|
||||
if not headers or not rows:
|
||||
self._text(slide, self.MX, self.CONT_Y, 10, 1.0,
|
||||
"[données manquantes]", size=14,
|
||||
color=self.C["muted"])
|
||||
return
|
||||
tints = [_blend("#FFFFFF", self.C["navy"], t)
|
||||
for t in (0.10, 0.28, 0.50, 0.74, 1.0)]
|
||||
legende = pick(d, "legende", "legend")
|
||||
label_w = 6.5
|
||||
gap = 0.12
|
||||
w_total = self.SLIDE_W - 2 * self.MX
|
||||
col_w = (w_total - label_w - gap * len(headers)) / len(headers)
|
||||
head_h = 0.9
|
||||
leg_h = 0.8 if legende else 0.0
|
||||
row_h = min(1.55, (self.CONT_H - head_h - leg_h
|
||||
- gap * (len(rows) + 1)) / len(rows))
|
||||
total_h = head_h + gap + len(rows) * (row_h + gap) + leg_h
|
||||
y = self._cy(total_h)
|
||||
for j, h in enumerate(headers):
|
||||
x = self.MX + label_w + gap + j * (col_w + gap)
|
||||
self._text(slide, x, y, col_w, head_h, h, size=12,
|
||||
bold=True, color=self.C["slate"],
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||
cy = y + head_h + gap
|
||||
for r in rows:
|
||||
self._text(slide, self.MX, cy, label_w, row_h,
|
||||
as_label(r, "label"), size=13, bold=True,
|
||||
color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE)
|
||||
scores = (r.get("scores") if isinstance(r, dict) else []) \
|
||||
or []
|
||||
for j in range(len(headers)):
|
||||
s = int(self._num(scores[j] if j < len(scores) else 0))
|
||||
if not 0 <= s <= 4:
|
||||
print(f" ~ heatmap : score {s} hors 0-4 — clampé")
|
||||
s = max(0, min(4, s))
|
||||
x = self.MX + label_w + gap + j * (col_w + gap)
|
||||
self._rect(slide, x, cy, col_w, row_h, tints[s])
|
||||
self._text(slide, x, cy, col_w, row_h, str(s), size=12,
|
||||
bold=True,
|
||||
color=self.C["white"] if s >= 2
|
||||
else self.C["navy"],
|
||||
align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.MIDDLE, fit=False)
|
||||
cy += row_h + gap
|
||||
if legende:
|
||||
self._text(slide, self.MX, cy + 0.05, w_total, 0.6, legende,
|
||||
size=10, italic=True, color=self.C["muted"])
|
||||
|
||||
def _render_funnel(self, slide, d):
|
||||
"""Entonnoir : trapèzes proportionnels, plancher 30 %,
|
||||
dernier étage corail."""
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
self._chart_caption(slide, d)
|
||||
zx, zy, zw, zh = self._chart_zone(d)
|
||||
etapes = (d.get("etapes") or [])[:self.FUNNEL_MAX]
|
||||
if len(etapes) < 2:
|
||||
self._text(slide, zx, zy, 12, 1.0,
|
||||
"[funnel : 2 étapes minimum]", size=14,
|
||||
color=self.C["muted"])
|
||||
return
|
||||
vals = [max(0.0, self._num(pick(e, "valeur", "value",
|
||||
default=0)))
|
||||
for e in etapes]
|
||||
vmax = max(vals) or 1.0
|
||||
has_desc = any(pick(e, "description") for e in etapes)
|
||||
fun_w = zw * (0.58 if has_desc else 0.80)
|
||||
cx = zx + fun_w / 2
|
||||
gap = 0.18
|
||||
n = len(etapes)
|
||||
stage_h = (zh - 0.4 - gap * (n - 1)) / n
|
||||
widths = [max(0.30, v / vmax) * fun_w for v in vals]
|
||||
y = zy + 0.2
|
||||
for i, e in enumerate(etapes):
|
||||
wt = widths[i]
|
||||
wb = widths[i + 1] if i + 1 < n else widths[i] * 0.72
|
||||
color = self.C["coral"] if i == n - 1 else \
|
||||
_blend(self.C["navy"], self.C["glacier"],
|
||||
0.5 * i / max(1, n - 1))
|
||||
fb = slide.shapes.build_freeform(
|
||||
cx - wt / 2, y, scale=360000)
|
||||
fb.add_line_segments(
|
||||
[(cx + wt / 2, y),
|
||||
(cx + wb / 2, y + stage_h),
|
||||
(cx - wb / 2, y + stage_h)], close=True)
|
||||
sh = fb.convert_to_shape()
|
||||
sh.fill.solid()
|
||||
sh.fill.fore_color.rgb = hex_to_rgb(color)
|
||||
sh.line.fill.background()
|
||||
sh.shadow.inherit = False
|
||||
label = as_label(e, "label")
|
||||
val = self._num(pick(e, "valeur", "value", default=0))
|
||||
self._text(slide, cx - wt / 2, y + stage_h / 2 - 0.78,
|
||||
wt, 0.8, label, size=14, bold=True,
|
||||
color=self.C["white"], align=PP_ALIGN.CENTER,
|
||||
anchor=MSO_ANCHOR.BOTTOM)
|
||||
self._text(slide, cx - wt / 2, y + stage_h / 2 + 0.06,
|
||||
wt, 0.6, f"{val:g}", size=12,
|
||||
color=self.C["white"], align=PP_ALIGN.CENTER,
|
||||
fit=False)
|
||||
desc = pick(e, "description")
|
||||
if desc:
|
||||
dx = zx + fun_w + 1.2
|
||||
self._text(slide, dx, y, zx + zw - dx, stage_h, desc,
|
||||
size=13, color=self.C["body"],
|
||||
anchor=MSO_ANCHOR.MIDDLE)
|
||||
y += stage_h + gap
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# P1 — import MSO_LINE + _blend
|
||||
(
|
||||
"def hex_to_rgb(h: str) -> RGBColor:\n"
|
||||
" h = (h or \"#000000\").lstrip(\"#\")\n"
|
||||
" return RGBColor(int(h[0:2], 16), int(h[2:4], 16), "
|
||||
"int(h[4:6], 16))\n",
|
||||
"def hex_to_rgb(h: str) -> RGBColor:\n"
|
||||
" h = (h or \"#000000\").lstrip(\"#\")\n"
|
||||
" return RGBColor(int(h[0:2], 16), int(h[2:4], 16), "
|
||||
"int(h[4:6], 16))\n"
|
||||
+ BLEND_FUNC,
|
||||
),
|
||||
# P1b — import MSO_LINE
|
||||
(
|
||||
"from pptx.enum.shapes import MSO_SHAPE\n",
|
||||
"from pptx.enum.shapes import MSO_SHAPE\n"
|
||||
"from pptx.enum.dml import MSO_LINE_DASH_STYLE as MSO_LINE\n",
|
||||
),
|
||||
# P3 — REGISTRY
|
||||
(
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n",
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n"
|
||||
" \"waterfall\": \"_render_waterfall\",\n"
|
||||
" \"heatmap_table\": \"_render_heatmap_table\",\n"
|
||||
" \"funnel\": \"_render_funnel\",\n",
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "_render_waterfall"
|
||||
PREREQ = "_render_bar_chart"
|
||||
ANCHOR_ORCH = " # ---------------- orchestration ----------------"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (_render_waterfall présent) — rien à faire.")
|
||||
if PREREQ not in content:
|
||||
fail("Lot 1 non appliqué (_render_bar_chart absent) — lancer "
|
||||
"d'abord patch_render_engine_c5a.py.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — moteur inattendu." % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_ORCH) != 1:
|
||||
fail("Ancre de la section orchestration introuvable ou non "
|
||||
"unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c5b")
|
||||
print(" + Sauvegarde : %s.bak-c5b" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l)
|
||||
lines[idx:idx] = COMPONENTS.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + 3 patchs + renderers lot 2 appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c5b", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Suite : python3 patch_layouts_c5b.py puis propagation "
|
||||
"(prompt_injection_v2 + build_gallery).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
patch_render_engine_c5c.py — Chantier C5 · lot 3 (agenda, pyramid)
|
||||
==================================================================
|
||||
Patch strict de render_engine_v2.py. Réintroduit les deux layouts v1
|
||||
disparus à la refonte, restylés PR Editorial. Aucun prérequis sur les
|
||||
lots 1/2 (aucun helper chart utilisé).
|
||||
|
||||
agenda (L40) — sommaire typographique aéré : badge numéroté (navy ;
|
||||
corail si actif: true), label (bold navy si actif), durée
|
||||
alignée à droite en muted, filet séparateur card_alt.
|
||||
Bornes : 2-8 sections (troncature tracée au-delà).
|
||||
pyramid (L41) — pyramide à degrés : rectangles empilés centrés,
|
||||
largeurs FIXES par étage (jamais recalculées) :
|
||||
3 niveaux : 44 / 72 / 100 %
|
||||
4 niveaux : 40 / 60 / 80 / 100 %
|
||||
Couleurs fixes du sommet à la base : navy, navy_light,
|
||||
slate, glacier (texte blanc, navy sur glacier). Descriptions
|
||||
optionnelles alignées à droite de chaque étage (la pyramide
|
||||
passe alors à 58 % de largeur) ; sans description, pyramide
|
||||
centrée sur 80 %.
|
||||
|
||||
Usage (dossier du pipeline, single-line) :
|
||||
python3 patch_render_engine_c5c.py
|
||||
Vérifie chaque ancre, écrit .bak-c5c, compile, idempotent.
|
||||
"""
|
||||
|
||||
import py_compile
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TARGET = Path("render_engine_v2.py")
|
||||
|
||||
COMPONENTS = ''' # ---------------- structure & concept (C5 lot 3) ----------------
|
||||
AGENDA_MAX = 8
|
||||
PYRAMID_WIDTHS = {3: (0.44, 0.72, 1.0),
|
||||
4: (0.40, 0.60, 0.80, 1.0)}
|
||||
|
||||
def _render_agenda(self, slide, d):
|
||||
"""Sommaire : badges numérotés, section active en corail."""
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
sections = (d.get("sections") or [])[:self.AGENDA_MAX]
|
||||
if len(d.get("sections") or []) > self.AGENDA_MAX:
|
||||
print(f" ~ agenda : sections tronquées à {self.AGENDA_MAX}")
|
||||
if len(sections) < 2:
|
||||
self._text(slide, self.MX, self.CONT_Y, 12, 1.0,
|
||||
"[agenda : 2 sections minimum]", size=14,
|
||||
color=self.C["muted"])
|
||||
return
|
||||
n = len(sections)
|
||||
gap = 0.5
|
||||
row_h = min(1.9, (self.CONT_H - gap * (n - 1)) / n)
|
||||
tot = n * row_h + (n - 1) * gap
|
||||
y = self._cy(tot)
|
||||
bd = self.components["badge"]["sizes"]["m"]
|
||||
x0 = self.MX + 1.5
|
||||
w = self.SLIDE_W - 2 * self.MX - 3.0
|
||||
for i, s in enumerate(sections):
|
||||
if not isinstance(s, dict):
|
||||
s = {"label": str(s)}
|
||||
actif = bool(s.get("actif"))
|
||||
num = s.get("numero", i + 1)
|
||||
fill = self.C["coral"] if actif else self.C["navy"]
|
||||
self._badge(slide, x0 + bd / 2, y + row_h / 2, bd, num,
|
||||
fill=fill, font_size=20)
|
||||
self._text(slide, x0 + bd + 1.0, y, w - bd - 6.0, row_h,
|
||||
as_label(s, "label", "titre"),
|
||||
size=20 if actif else 19, bold=actif,
|
||||
color=self.C["navy"] if actif
|
||||
else self.C["body"],
|
||||
anchor=MSO_ANCHOR.MIDDLE)
|
||||
duree = pick(s, "duree", "duration")
|
||||
if duree:
|
||||
self._text(slide, x0 + w - 4.5, y, 4.5, row_h, duree,
|
||||
size=13, italic=True, color=self.C["muted"],
|
||||
align=PP_ALIGN.RIGHT,
|
||||
anchor=MSO_ANCHOR.MIDDLE, fit=False)
|
||||
if i < n - 1:
|
||||
self._rect(slide, x0 + bd + 1.0,
|
||||
y + row_h + gap / 2 - 0.015,
|
||||
w - bd - 1.0, 0.03, self.C["card"])
|
||||
y += row_h + gap
|
||||
|
||||
def _render_pyramid(self, slide, d):
|
||||
"""Pyramide à degrés : largeurs fixes, couleurs fixes."""
|
||||
self._title(slide, pick(d, "titre", "title"))
|
||||
niveaux = (d.get("niveaux") or d.get("levels") or [])[:4]
|
||||
if len(niveaux) < 3:
|
||||
self._text(slide, self.MX, self.CONT_Y, 12, 1.0,
|
||||
"[pyramid : 3 ou 4 niveaux]", size=14,
|
||||
color=self.C["muted"])
|
||||
return
|
||||
n = len(niveaux)
|
||||
widths = self.PYRAMID_WIDTHS[n]
|
||||
colors = [self.C["navy"], self.C["navy2"],
|
||||
self.C["slate"], self.C["glacier"]]
|
||||
if n == 3:
|
||||
colors = [self.C["navy"], self.C["navy2"],
|
||||
self.C["glacier"]]
|
||||
has_desc = any(isinstance(nv, dict) and pick(nv, "description")
|
||||
for nv in niveaux)
|
||||
zone_w = self.SLIDE_W - 2 * self.MX
|
||||
pyr_w = zone_w * (0.58 if has_desc else 0.80)
|
||||
pyr_x = self.MX if has_desc else \
|
||||
self.MX + (zone_w - pyr_w) / 2
|
||||
cx = pyr_x + pyr_w / 2
|
||||
gap = 0.15
|
||||
avail = self.CONT_H - 0.4
|
||||
stage_h = (avail - gap * (n - 1)) / n
|
||||
y = self._cy(avail) + 0.2
|
||||
for i, nv in enumerate(niveaux):
|
||||
if not isinstance(nv, dict):
|
||||
nv = {"label": str(nv)}
|
||||
wt = widths[i] * pyr_w
|
||||
color = colors[i]
|
||||
txt_color = self.C["navy"] if color == self.C["glacier"] \
|
||||
else self.C["white"]
|
||||
self._rect(slide, cx - wt / 2, y, wt, stage_h, color)
|
||||
self._text(slide, cx - wt / 2 + 0.3, y, wt - 0.6, stage_h,
|
||||
as_label(nv, "label", "titre"),
|
||||
size=16, bold=True, color=txt_color,
|
||||
align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
|
||||
desc = pick(nv, "description", "detail")
|
||||
if desc:
|
||||
dx = pyr_x + pyr_w + 1.2
|
||||
self._text(slide, dx, y,
|
||||
self.SLIDE_W - self.MX - dx, stage_h, desc,
|
||||
size=13, color=self.C["body"],
|
||||
anchor=MSO_ANCHOR.MIDDLE)
|
||||
y += stage_h + gap
|
||||
|
||||
'''
|
||||
|
||||
PATCHES = [
|
||||
# REGISTRY
|
||||
(
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n",
|
||||
" \"matrix_2x2\": \"_render_matrix_2x2\",\n"
|
||||
" \"agenda\": \"_render_agenda\",\n"
|
||||
" \"pyramid\": \"_render_pyramid\",\n",
|
||||
),
|
||||
]
|
||||
|
||||
MARKER = "_render_agenda"
|
||||
ANCHOR_ORCH = " # ---------------- orchestration ----------------"
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print(" ! %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if not TARGET.exists():
|
||||
fail("%s introuvable — lancer depuis le dossier du pipeline."
|
||||
% TARGET)
|
||||
content = TARGET.read_text(encoding="utf-8")
|
||||
if MARKER in content:
|
||||
fail("Déjà patché (_render_agenda présent) — rien à faire.")
|
||||
|
||||
for i, (old, _) in enumerate(PATCHES, 1):
|
||||
n = content.count(old)
|
||||
if n == 0:
|
||||
fail("Ancre du patch %d introuvable — moteur inattendu." % i)
|
||||
if n > 1:
|
||||
fail("Ancre du patch %d non unique (%d occurrences)." % (i, n))
|
||||
if content.count(ANCHOR_ORCH) != 1:
|
||||
fail("Ancre de la section orchestration introuvable ou non "
|
||||
"unique.")
|
||||
|
||||
shutil.copy2(TARGET, str(TARGET) + ".bak-c5c")
|
||||
print(" + Sauvegarde : %s.bak-c5c" % TARGET)
|
||||
|
||||
for old, new in PATCHES:
|
||||
content = content.replace(old, new)
|
||||
|
||||
lines = content.split("\n")
|
||||
idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l)
|
||||
lines[idx:idx] = COMPONENTS.split("\n")
|
||||
content = "\n".join(lines)
|
||||
|
||||
TARGET.write_text(content, encoding="utf-8")
|
||||
print(" + Renderers agenda + pyramid + REGISTRY appliqués.")
|
||||
try:
|
||||
py_compile.compile(str(TARGET), doraise=True)
|
||||
print(" + Compilation OK.")
|
||||
except py_compile.PyCompileError as e:
|
||||
shutil.copy2(str(TARGET) + ".bak-c5c", TARGET)
|
||||
fail("Erreur de compilation — fichier restauré :\n%s" % e)
|
||||
print("\n Suite : python3 patch_layouts_c5c.py puis propagation "
|
||||
"(prompt_injection_v2 + build_gallery).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user