feat: application des chantiers C1-C5 au moteur, facilitator et catalogue

This commit is contained in:
2026-07-09 21:53:52 +02:00
parent 05bed9985e
commit b938b1119a
4 changed files with 1192 additions and 37 deletions
+264 -22
View File
@@ -63,12 +63,14 @@ API_KEY = os.getenv("MISTRAL_API_KEY")
NARRATOR_ID = os.getenv("NARRATOR_AGENT_ID")
DESIGNER_ID = os.getenv("DESIGNER_AGENT_ID")
ENCODER_ID = os.getenv("ENCODER_AGENT_ID")
ENCODER_MODE = os.getenv("ENCODER_MODE", "agent") # agent | schema (C1)
FREE_DESIGNER_ID = os.getenv("FREE_DESIGNER_AGENT_ID")
PROJECTS_DIR = Path(os.getenv("PROJECTS_DIR", "./projets"))
RENDER_ENGINE_PATH = os.getenv("RENDER_ENGINE_PATH", "render_engine_v2.py")
THEME_PATH = os.getenv("THEME_PATH", "theme_v2.yaml")
COMPONENTS_PATH = os.getenv("COMPONENTS_PATH", "components_v2.yaml")
LAYOUTS_PATH = os.getenv("LAYOUTS_PATH", "layouts_v2.yaml")
PREVIEW_SCRIPT = os.getenv("PREVIEW_SCRIPT", "./preview.sh") # C2
CONTEXT_MAX_CHARS = int(os.getenv("CONTEXT_MAX_CHARS", "12000"))
TRILIUM_API_URL = os.getenv("TRILIUM_API_URL", "")
TRILIUM_API_KEY = os.getenv("TRILIUM_API_KEY", "")
@@ -92,6 +94,7 @@ SLASH_CMDS = {
"/formalise": "Transformer le plan compact validé en Markdown pour le Designer",
"/valider": "Valider le Markdown formalisé → passer au Designer",
"/plan": "Réafficher le dernier plan compact en entier",
"/afficher": "Réafficher la dernière réponse du Narrator en entier (sans troncature)",
"/aide": "Afficher cette aide",
"/quitter": "Abandonner et revenir au menu projet",
}
@@ -678,8 +681,13 @@ def run_narrator(session: AgentSession, proj: Project) -> Optional[str]:
elif cmd == "/lire":
context, new_files = load_documents(proj, loaded_docs)
if not new_files:
info("Aucun nouveau document dans inputs/.")
assets_info = list_assets(proj)
if assets_info:
context = ((context or "").strip()
+ "\n\n" + assets_info).strip()
ok(f"{len(assets_info.splitlines()) - 1} image(s) dans assets/.")
if not new_files and not assets_info:
info("Aucun nouveau document dans inputs/ ni image dans assets/.")
continue
loaded_docs |= new_files
if not session.started:
@@ -718,6 +726,13 @@ def run_narrator(session: AgentSession, proj: Project) -> Optional[str]:
warn("Aucun plan compact pour l'instant.")
continue
elif cmd == "/afficher":
if last_response:
print("\n" + textwrap.indent(last_response, " "))
else:
warn("Pas encore de réponse à afficher.")
continue
elif cmd == "/formalise":
if not session.started:
warn("Commence par établir le plan compact."); continue
@@ -994,6 +1009,133 @@ def run_encoder(session: AgentSession, plan: str, layouts: dict,
return None, None, None, attempts
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)
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
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)
# ─────────────────────────────────────────────────────────────────────────────
# FLUX LIBRE — THE FREE DESIGNER
# ─────────────────────────────────────────────────────────────────────────────
@@ -1001,7 +1143,7 @@ def run_encoder(session: AgentSession, plan: str, layouts: dict,
VALID_TOKENS = {"navy", "navy_light", "coral", "glacier", "slate",
"card", "white", "body", "muted"}
VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", "circle",
"badge", "card", "rect", "line"}
"badge", "card", "rect", "line", "image"}
def validate_freeform(raw: str):
@@ -1112,7 +1254,8 @@ def run_free_designer(session: AgentSession, markdown: str, proj: Project):
# ÉTAPE 4 — RENDER ENGINE
# ─────────────────────────────────────────────────────────────────────────────
def run_render(yaml_path: Path) -> Optional[Path]:
def run_render(yaml_path: Path,
assets_dir: Optional[Path] = None) -> Optional[Path]:
section("ÉTAPE 4 — RENDER ENGINE V2")
if not Path(RENDER_ENGINE_PATH).exists():
warn(f"{RENDER_ENGINE_PATH} introuvable.")
@@ -1124,11 +1267,22 @@ def run_render(yaml_path: Path) -> Optional[Path]:
pptx_out = yaml_path.with_suffix(".pptx")
info("Lancement de render_engine_v2.py...")
info(f"Sortie : {pptx_out}")
result = subprocess.run(
[sys.executable, RENDER_ENGINE_PATH, str(yaml_path), str(pptx_out),
"--theme", THEME_PATH, "--components", COMPONENTS_PATH,
"--layouts", LAYOUTS_PATH],
capture_output=True, text=True)
if assets_dir is None:
# Déduction depuis PROJECTS_DIR si le YAML y vit (C4b)
try:
rel = yaml_path.resolve().relative_to(
Path(PROJECTS_DIR).resolve())
assets_dir = Path(PROJECTS_DIR) / rel.parts[0] / "assets"
except ValueError:
assets_dir = None # hors projet : défaut du moteur
cmd = [sys.executable, RENDER_ENGINE_PATH, str(yaml_path),
str(pptx_out),
"--theme", THEME_PATH, "--components", COMPONENTS_PATH,
"--layouts", LAYOUTS_PATH]
if assets_dir is not None:
cmd += ["--assets", str(assets_dir)]
info(f"Assets : {assets_dir}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
ok(result.stdout.strip() or f"PPTX généré : {pptx_out}")
return pptx_out
@@ -1137,6 +1291,46 @@ def run_render(yaml_path: Path) -> Optional[Path]:
return None
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).resolve()
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)
# ─────────────────────────────────────────────────────────────────────────────
# ARCHIVAGE TRILIUM
# ─────────────────────────────────────────────────────────────────────────────
@@ -1212,9 +1406,13 @@ def run_full_pipeline_pass(narrator, markdown, layouts, proj, manifest,
continue
manifest.step("designer", caracteres=len(plan))
encoder = AgentSession(ENCODER_ID)
yaml_str, yaml_data, yaml_path, tries = run_encoder(
encoder, plan, layouts, proj, scope_encoder)
if ENCODER_MODE == "schema":
yaml_str, yaml_data, yaml_path, tries = run_encoder_schema(
plan, layouts, proj)
else:
encoder = AgentSession(ENCODER_ID)
yaml_str, yaml_data, yaml_path, tries = run_encoder(
encoder, plan, layouts, proj, scope_encoder)
manifest.step("encoder", tentatives_correction=tries)
if yaml_str is None:
info("Retour au Designer...")
@@ -1226,8 +1424,15 @@ def run_full_pipeline_pass(narrator, markdown, layouts, proj, manifest,
yaml_path.rename(new_path)
yaml_path = new_path
merged = False
if suffix == "revision_ciblee" and yaml_path:
fused = merge_revision(proj, yaml_path)
if fused:
yaml_path, merged = fused, True
info("Rendu du deck COMPLET fusionné.")
manifest.file(yaml_path)
pptx_path = run_render(yaml_path)
pptx_path = run_render(yaml_path, proj.root / "assets")
manifest.step("render", succes=bool(pptx_path))
# Persister l'état du projet (seulement en révision complète :
# le ciblé ne représente pas l'état complet du deck)
@@ -1237,9 +1442,15 @@ def run_full_pipeline_pass(narrator, markdown, layouts, proj, manifest,
pptx_path=pptx_path,
journal_entry="Révision structurante — deck complet régénéré.")
elif suffix == "revision_ciblee":
persist_generation(
proj, markdown=markdown,
journal_entry="Révision ciblée — slides régénérées séparément.")
if merged:
persist_generation(
proj, markdown=markdown, yaml_path=yaml_path,
pptx_path=pptx_path,
journal_entry="Révision ciblée fusionnée — deck complet régénéré.")
else:
persist_generation(
proj, markdown=markdown,
journal_entry="Révision ciblée — slides régénérées séparément.")
return pptx_path, markdown
@@ -1304,6 +1515,8 @@ def run_revision(narrator, last_markdown, layouts, proj, manifest):
section("SLIDES RÉVISÉES GÉNÉRÉES")
ok(f"PPTX des slides révisées : {pptx_path}")
info("Ouvre ce fichier et copie-colle les slides dans ton deck maître.")
info("(Si la fusion YAML a réussi — voir ci-dessus — le PPTX est déjà le deck complet.)")
maybe_preview(pptx_path)
else:
warn("Les slides révisées n'ont pas pu être générées.")
return new_markdown
@@ -1316,6 +1529,7 @@ def run_revision(narrator, last_markdown, layouts, proj, manifest):
if pptx_path and pptx_path.exists():
section("DECK COMPLET RÉGÉNÉRÉ")
ok(f"Nouveau PPTX complet : {pptx_path}")
maybe_preview(pptx_path)
else:
warn("Le deck n'a pas pu être régénéré.")
return new_markdown
@@ -1375,13 +1589,14 @@ def run_creation(proj, layouts):
if yaml_path and yaml_data:
manifest.file(yaml_path)
pptx_path = run_render(yaml_path)
pptx_path = run_render(yaml_path, proj.root / "assets")
manifest.step("render", succes=bool(pptx_path))
if pptx_path and pptx_path.exists():
manifest.file(pptx_path)
section("PRÉSENTATION GÉNÉRÉE (flux libre)")
ok(f"Fichier PPTX : {pptx_path}")
ok(f"Taille : {pptx_path.stat().st_size/1024:.1f} Ko")
maybe_preview(pptx_path)
else:
warn("Le PPTX n'a pas pu être généré.")
persist_generation(
@@ -1420,9 +1635,15 @@ def run_creation(proj, layouts):
continue
manifest.step("designer", caracteres=len(plan))
encoder = AgentSession(ENCODER_ID)
yaml_str, yaml_data, yaml_path, tries = run_encoder(
encoder, plan, layouts, proj)
if ENCODER_MODE == "schema" and not express:
yaml_str, yaml_data, yaml_path, tries = run_encoder_schema(
plan, layouts, proj)
else:
if ENCODER_MODE == "schema" and express:
info("Mode express → Encoder agent (plan non annoté).")
encoder = AgentSession(ENCODER_ID)
yaml_str, yaml_data, yaml_path, tries = run_encoder(
encoder, plan, layouts, proj)
manifest.step("encoder", tentatives_correction=tries)
if yaml_str is None:
if express:
@@ -1434,13 +1655,14 @@ def run_creation(proj, layouts):
if yaml_path and yaml_data:
manifest.file(yaml_path)
pptx_path = run_render(yaml_path)
pptx_path = run_render(yaml_path, proj.root / "assets")
manifest.step("render", succes=bool(pptx_path))
if pptx_path and pptx_path.exists():
manifest.file(pptx_path)
section("PRÉSENTATION GÉNÉRÉE")
ok(f"Fichier PPTX : {pptx_path}")
ok(f"Taille : {pptx_path.stat().st_size/1024:.1f} Ko")
maybe_preview(pptx_path)
else:
warn("Le PPTX n'a pas pu être généré.")
info(f"Le YAML est dans : {proj.outputs}")
@@ -1499,6 +1721,15 @@ def run_reprise(proj, layouts):
reprime_narrator(narrator, proj) # réamorçage avec contexte
proj.log("Reprise du projet pour révision.")
markdown = state.get("dernier_markdown", "")
# On est déjà en révision (choix explicite ci-dessus) : on lance
# directement la session de travail, sans repasser par un menu
# "veux-tu réviser ?" redondant.
markdown = run_revision(narrator, markdown, layouts, proj, manifest)
manifest.save(
proj.outputs /
f"{proj.slug}_{datetime.now():%Y%m%d_%H%M%S}_manifest.json")
# Boucle de suite : une fois cette révision faite, proposer d'itérer
# encore ou de terminer — ce menu-ci n'est pas redondant.
revision_loop(narrator, markdown, layouts, proj, manifest)
elif choix == "2":
@@ -1570,8 +1801,17 @@ def main():
description="Sliding facilitator v8 — flux libre (Free Designer)")
parser.add_argument("--render", metavar="YAML",
help="Rendu direct d'un YAML existant, sans agents")
parser.add_argument("--preview", metavar="PPTX",
help="Aperçus PNG d'un PPTX existant (C2)")
args = parser.parse_args()
if args.preview:
p = Path(args.preview)
if not p.exists():
warn(f"Fichier introuvable : {p}")
sys.exit(1)
sys.exit(0 if run_preview(p, wait=True) else 1)
if args.render:
yaml_path = Path(args.render)
if not yaml_path.exists():
@@ -1581,7 +1821,9 @@ def main():
is_valid, message, _ = validate_yaml(
yaml_path.read_text(encoding="utf-8"), layouts)
(ok if is_valid else warn)(f"Validation : {message}")
sys.exit(0 if run_render(yaml_path) else 1)
local_assets = yaml_path.parent / "assets"
assets_dir = local_assets if local_assets.is_dir() else None
sys.exit(0 if run_render(yaml_path, assets_dir) else 1)
run_pipeline()