diff --git a/facilitator_v8.py b/facilitator_v8.py new file mode 100644 index 0000000..238bcba --- /dev/null +++ b/facilitator_v8.py @@ -0,0 +1,1427 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +facilitator_v8.py — Sliding Pipeline v10 · Pernod Ricard +======================================================== +Orchestre les agents Mistral + render_engine_v2, avec une +organisation par projet et un Narrator conversationnel. + +Changements v6 vs v5 : + - Narrator en mode conversation libre (plus de menu numéroté) + - Commandes /slash dans la boucle Narrator : + /lire → rescanner inputs/ et injecter les nouveaux docs + /formalise → demander au Narrator de structurer le Markdown + /valider → valider le Markdown formalisé et passer au Designer + /sauvegarder → sauvegarder l'état actuel de la conversation + /afficher → afficher le dernier message du Narrator en entier + /aide → revoir les commandes disponibles + /quitter → abandonner et revenir au menu projet + - Chargement incrémental des docs : /lire charge uniquement + les nouveaux fichiers pas encore injectés + - Transition naturelle : le Markdown formalisé est auto-détecté + ou demandé explicitement via /formalise + +Tout le reste (Designer, Encoder, Render, projet, manifest) est +identique au facilitator_v3. + +Variables .env : identiques à v3. NARRATOR_AGENT_ID pointe vers +le nouvel agent configuré avec prompt_the_narrator_v3.md. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import textwrap +import time +from datetime import datetime +from pathlib import Path +from typing import Optional + +import requests +import yaml +from dotenv import load_dotenv + +load_dotenv() + +# ───────────────────────────────────────────────────────────────────────────── +# CONFIGURATION (identique v3) +# ───────────────────────────────────────────────────────────────────────────── + +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") +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") +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", "") +TRILIUM_PROJET = os.getenv("TRILIUM_PROJET", "SlidingAutomation") + +HEADERS = { + "Authorization": f"Bearer {API_KEY}", + "Content-Type": "application/json", +} + +VERSION = "10.0" +MAX_PAGES = 10 +MAX_RETRY = 3 + +PROJECTS_DIR.mkdir(parents=True, exist_ok=True) + +# Commandes slash reconnues +SLASH_CMDS = { + "/lire": "Charger / recharger les documents de inputs/", + "/contexte": "(alias /lire)", + "/docs": "(alias /lire)", + "/formalise": "Demander au Narrator de structurer le Markdown", + "/structure": "(alias /formalise)", + "/markdown": "(alias /formalise)", + "/valider": "Valider le Markdown formalisé → passer au Designer", + "/sauvegarder": "Sauvegarder le dernier message du Narrator", + "/afficher": "Afficher le dernier message du Narrator en entier", + "/aide": "Afficher cette aide", + "/quitter": "Abandonner et revenir au menu projet", +} + + +# ───────────────────────────────────────────────────────────────────────────── +# AFFICHAGE +# ───────────────────────────────────────────────────────────────────────────── + +def banner(): + print("\n" + "=" * 62) + print(f" SLIDING PIPELINE v{VERSION} — Pernod Ricard (design system v2)") + print(f" {datetime.now().strftime('%d/%m/%Y %H:%M')}") + print("=" * 62 + "\n") + + +def section(title): print(f"\n{'-'*62}\n {title}\n{'-'*62}\n") +def info(msg): print(f" i {msg}") +def ok(msg): print(f" + {msg}") +def warn(msg): print(f" ! {msg}") + + +def ask(prompt: str) -> str: + try: + return input(f"\n {prompt} ").strip() + except (EOFError, KeyboardInterrupt): + print("\n Session interrompue.") + sys.exit(0) + + +def display_narrator(text: str, max_lines: int = 80): + """Affiche la réponse du Narrator avec indentation et troncature douce.""" + lines = text.splitlines() + if len(lines) <= max_lines: + print("\n" + textwrap.indent(text, " ")) + else: + print("\n" + textwrap.indent("\n".join(lines[:max_lines]), " ")) + print(f"\n ... [{len(lines) - max_lines} lignes supplémentaires" + f" — /afficher pour tout voir]") + + +def display_help(): + print("\n Commandes disponibles :\n") + for cmd, desc in SLASH_CMDS.items(): + if not desc.startswith("(alias"): + print(f" {cmd:<16} {desc}") + print() + + +def display_plan(text: str): + slides = [l for l in text.splitlines() if l.strip().startswith("SLIDE")] + if slides: + print(" Résumé du plan :\n") + for s in slides: + print(f" {s.strip()}") + print(f"\n ({len(slides)} slides au total)") + else: + display_narrator(text) + + +def slugify(name: str) -> str: + s = re.sub(r"[^a-zA-Z0-9]+", "-", name.lower()).strip("-") + return s[:40] + + +# ───────────────────────────────────────────────────────────────────────────── +# GESTION DE PROJET +# ───────────────────────────────────────────────────────────────────────────── + +class Project: + def __init__(self, name: str): + self.name = name + self.slug = slugify(name) + self.root = PROJECTS_DIR / self.slug + self.inputs = self.root / "inputs" + self.outputs = self.root / "outputs" + self.state_file = self.root / "project_state.json" + self.journal_file = self.root / "journal.md" + + def ensure(self): + self.inputs.mkdir(parents=True, exist_ok=True) + self.outputs.mkdir(parents=True, exist_ok=True) + + def exists(self) -> bool: + return self.root.is_dir() + + def out(self, suffix: str, ext: str) -> Path: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + return self.outputs / f"{self.slug}_{ts}_{suffix}.{ext}" + + # ── État persistant (Solution A) ──────────────────────────────────────── + def load_state(self) -> dict: + """Charge project_state.json, ou un état vide si absent.""" + if self.state_file.is_file(): + try: + return json.loads(self.state_file.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + warn("project_state.json illisible — état réinitialisé.") + return { + "nom": self.name, + "cree_le": datetime.now().isoformat(timespec="seconds"), + "dernier_markdown": "", + "dernier_yaml": "", + "dernier_pptx": "", + "nb_sessions": 0, + "derniere_session": "", + } + + def save_state(self, state: dict): + state["derniere_session"] = datetime.now().isoformat(timespec="seconds") + self.state_file.write_text( + json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + + def has_state(self) -> bool: + """Vrai si le projet a déjà produit au moins un narratif formalisé.""" + st = self.load_state() + return bool(st.get("dernier_markdown")) + + # ── Journal de décisions (Solution D) ─────────────────────────────────── + def log(self, entry: str): + """Ajoute une entrée horodatée au journal.md du projet.""" + ts = datetime.now().strftime("%d/%m/%Y %H:%M") + line = f"- **{ts}** — {entry}\n" + if not self.journal_file.is_file(): + header = f"# Journal — {self.name}\n\n" + self.journal_file.write_text(header + line, encoding="utf-8") + else: + with self.journal_file.open("a", encoding="utf-8") as f: + f.write(line) + + def journal_summary(self, max_entries: int = 15) -> str: + """Retourne les dernières entrées du journal pour réamorçage.""" + if not self.journal_file.is_file(): + return "" + lines = [l for l in self.journal_file.read_text( + encoding="utf-8").splitlines() if l.startswith("- ")] + return "\n".join(lines[-max_entries:]) + + @staticmethod + def list_existing() -> list: + if not PROJECTS_DIR.is_dir(): + return [] + return sorted([p.name for p in PROJECTS_DIR.iterdir() if p.is_dir()]) + + +def select_project() -> Optional[Project]: + existing = Project.list_existing() + print(" [1] Nouveau projet") + if existing: + print(" [2] Projet existant") + print(" [0] Quitter") + choix = ask("Votre choix :") + if choix == "0": + return None + if choix == "1": + name = ask("Nom du nouveau projet :") + if not name: + warn("Nom vide — annulé.") + return None + proj = Project(name) + if proj.exists(): + warn(f"Le projet '{proj.slug}' existe déjà — il sera réutilisé.") + proj.ensure() + ok(f"Projet créé : {proj.root}") + info(f"Déposez vos documents de contexte dans : {proj.inputs}") + return proj + if choix == "2" and existing: + print() + for i, name in enumerate(existing, 1): + print(f" [{i}] {name}") + sel = ask("Numéro du projet :") + try: + name = existing[int(sel) - 1] + except (ValueError, IndexError): + warn("Sélection invalide.") + return None + proj = Project(name) + proj.ensure() + ok(f"Projet ouvert : {proj.root}") + return proj + warn("Choix invalide.") + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# LECTURE DE DOCUMENTS — chargement incrémental +# ───────────────────────────────────────────────────────────────────────────── + +def _read_pdf(path: Path) -> str: + try: + from pypdf import PdfReader + return "\n".join((pg.extract_text() or "") + for pg in PdfReader(str(path)).pages) + except Exception: + try: + import pdfplumber + with pdfplumber.open(str(path)) as pdf: + return "\n".join((p.extract_text() or "") for p in pdf.pages) + except Exception as e: + warn(f"PDF illisible ({path.name}) : {e}") + return "" + + +def _read_docx(path: Path) -> str: + try: + import docx + return "\n".join(p.text for p in docx.Document(str(path)).paragraphs) + except Exception as e: + warn(f"DOCX illisible ({path.name}) : {e}") + return "" + + +def load_documents(proj: Project, already_loaded: set) -> tuple[str, set]: + """ + Charge les fichiers de inputs/ non encore injectés. + Retourne (bloc_contexte, nouveaux_fichiers_chargés). + already_loaded = ensemble des noms de fichiers déjà injectés. + """ + if not proj.inputs.is_dir(): + return "", set() + + files = [f for f in sorted(proj.inputs.iterdir()) + if f.is_file() + and not f.name.startswith(".") + and f.name not in already_loaded] + + if not files: + return "", set() + + info(f"{len(files)} nouveau(x) document(s) détecté(s) :") + for f in files: + print(f" - {f.name}") + + chunks = [] + loaded = set() + for f in files: + ext = f.suffix.lower() + if ext == ".pdf": + txt = _read_pdf(f) + elif ext == ".docx": + txt = _read_docx(f) + elif ext in (".txt", ".md", ".markdown", ".csv"): + txt = f.read_text(encoding="utf-8", errors="replace") + else: + warn(f"Format non supporté, ignoré : {f.name}") + continue + txt = txt.strip() + if txt: + chunks.append(f"### Document : {f.name}\n{txt}") + ok(f"Lu : {f.name} ({len(txt)} caractères)") + loaded.add(f.name) + + if not chunks: + return "", set() + + context = "\n\n".join(chunks) + if len(context) > CONTEXT_MAX_CHARS: + warn(f"Contexte tronqué à {CONTEXT_MAX_CHARS} caractères.") + context = context[:CONTEXT_MAX_CHARS] + "\n[...contexte tronqué...]" + + return context, loaded + + +# ───────────────────────────────────────────────────────────────────────────── +# MANIFEST +# ───────────────────────────────────────────────────────────────────────────── + +class Manifest: + def __init__(self, proj: Project): + self.data = { + "version": VERSION, + "demarrage": datetime.now().isoformat(timespec="seconds"), + "projet": proj.name, + "etapes": [], + "fichiers": [], + } + self._t0 = time.time() + + def step(self, nom, **extra): + self.data["etapes"].append( + {"nom": nom, "duree_s": round(time.time() - self._t0, 1), **extra}) + self._t0 = time.time() + + def file(self, path: Path): + self.data["fichiers"].append(str(path)) + + def save(self, path: Path): + self.data["fin"] = datetime.now().isoformat(timespec="seconds") + path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), + encoding="utf-8") + ok(f"Manifest : {path}") + return path + + +# ───────────────────────────────────────────────────────────────────────────── +# APPEL AGENT +# ───────────────────────────────────────────────────────────────────────────── + +def _post_with_retry(payload: dict) -> dict: + last_err = None + for attempt in range(1, MAX_RETRY + 1): + try: + resp = requests.post( + "https://api.mistral.ai/v1/agents/completions", + headers=HEADERS, json=payload, timeout=120) + if resp.status_code == 429: + wait = 2 ** attempt * 5 + warn(f"Rate limit (429) — attente {wait}s " + f"(tentative {attempt}/{MAX_RETRY})") + time.sleep(wait) + continue + resp.raise_for_status() + return resp.json() + except requests.RequestException as e: + last_err = e + wait = 2 ** attempt * 2 + warn(f"Erreur réseau : {e} — retry dans {wait}s " + f"(tentative {attempt}/{MAX_RETRY})") + time.sleep(wait) + raise RuntimeError(f"API Mistral injoignable après {MAX_RETRY} " + f"tentatives : {last_err}") + + +def call_agent(agent_id: str, messages: list) -> str: + full, current = "", messages.copy() + for page in range(MAX_PAGES): + data = _post_with_retry({"agent_id": agent_id, "messages": current}) + content = data["choices"][0]["message"]["content"] + full += content + if "PAUSE" in content and "FIN —" not in content: + info(f"Pagination (page {page + 1}) → continuation...") + current.append({"role": "assistant", "content": content}) + current.append({"role": "user", "content": "continue"}) + else: + return full + warn(f"Garde pagination atteinte ({MAX_PAGES} pages).") + return full + + +class AgentSession: + def __init__(self, agent_id: str): + self.agent_id = agent_id + self.messages: list = [] + self.started = False + + def start(self, msg: str) -> str: + self.messages = [{"role": "user", "content": msg}] + out = call_agent(self.agent_id, self.messages) + self.messages.append({"role": "assistant", "content": out}) + self.started = True + return out + + def send(self, msg: str) -> str: + """Premier message ou suite — gère automatiquement start vs feedback.""" + if not self.started: + return self.start(msg) + return self.feedback(msg) + + def feedback(self, msg: str) -> str: + self.messages.append({"role": "user", "content": msg}) + out = call_agent(self.agent_id, self.messages) + self.messages.append({"role": "assistant", "content": out}) + return out + + def reset(self) -> None: + self.messages = [] + self.started = False + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 1 — NARRATOR CONVERSATIONNEL +# ───────────────────────────────────────────────────────────────────────────── + +def reprime_narrator(session: AgentSession, proj: Project) -> None: + """ + Réamorce une session Narrator neuve avec le contexte d'un projet + déjà travaillé : dernier Markdown formalisé + journal des décisions. + (Solution C — on ne restaure pas la conversation exacte, on réinforme.) + """ + state = proj.load_state() + last_md = state.get("dernier_markdown", "") + if not last_md: + return + summary = proj.journal_summary() + reprime = ( + "Nous reprenons un projet de présentation déjà travaillé. " + "Voici l'état actuel pour te remettre en contexte.\n\n" + "=== DERNIER NARRATIF FORMALISÉ (état actuel du deck) ===\n\n" + f"{last_md}\n\n" + ) + if summary: + reprime += ( + "=== JOURNAL DES DÉCISIONS (historique du projet) ===\n\n" + f"{summary}\n\n" + ) + reprime += ( + "Prends connaissance de ce contexte. Ne reformule rien pour l'instant. " + "Réponds simplement par un court récapitulatif de ce que contient la " + "présentation actuelle, puis demande-moi ce que je souhaite réviser." + ) + info("Réamorçage du Narrator avec le contexte du projet...") + out = session.start(reprime) + display_narrator(out) + + +def run_narrator(session: AgentSession, proj: Project) -> Optional[str]: + """ + Boucle conversationnelle avec le Narrator. + Retourne le Markdown validé, ou None si abandonné. + """ + section("ÉTAPE 1 — THE NARRATOR") + + # Vérifier si des docs sont déjà dans inputs/ + has_docs = any( + f.is_file() and not f.name.startswith(".") + for f in proj.inputs.iterdir() + ) if proj.inputs.is_dir() else False + + print(" Mode conversation. Votre premier message lance la discussion.") + print(" Tapez /aide pour voir les commandes disponibles.") + if has_docs: + print(f"\n Documents disponibles dans inputs/") + print(f" Tapez /lire pour les injecter dans la conversation.") + print() + + loaded_docs: set = set() # noms des docs déjà injectés + last_response: str = "" # dernier message du Narrator + formalized_md: Optional[str] = None # Markdown structuré (après /formalise) + pending_context: str = "" # docs chargés avant le 1er message + + while True: + user_input = ask("Vous :") + if not user_input: + continue + + # ── Commandes slash ──────────────────────────────────────────────── + cmd = user_input.strip().lower().split()[0] if user_input.startswith("/") else "" + + if cmd == "/aide": + display_help() + continue + + elif cmd in ("/lire", "/contexte", "/docs"): + context, new_files = load_documents(proj, loaded_docs) + if not new_files: + info("Aucun nouveau document dans inputs/.") + info(f"Dossier : {proj.inputs}") + continue + loaded_docs |= new_files + inject_msg = ( + f"[Documents chargés depuis inputs/]\n\n{context}\n\n" + f"Prends en compte ces informations dans notre discussion." + ) + if not session.started: + # Stocke pour fusion avec le 1er vrai message + pending_context = (pending_context + "\n\n" + context).strip() + ok(f"{len(new_files)} doc(s) en attente d'injection" + " — ils seront envoyés avec votre prochain message.") + else: + info("Injection des documents dans la conversation...") + last_response = session.feedback(inject_msg) + display_narrator(last_response) + continue + + elif cmd in ("/formalise", "/structure", "/markdown"): + if not session.started: + warn("Commencez d'abord la conversation avec le Narrator.") + continue + info("Demande de formalisation au Narrator...") + formalise_msg = ( + "Sur la base de tout ce que nous avons discuté jusqu'ici, " + "produis maintenant le Markdown narratif structuré complet, " + "prêt à être mis en présentation. " + "Utilise le format standard avec #, ## et ###." + ) + last_response = session.feedback(formalise_msg) + formalized_md = last_response + display_narrator(last_response) + print() + ok("Narratif formalisé. Tape /valider pour passer au Designer,") + info("ou continue la conversation pour affiner.") + continue + + elif cmd == "/valider": + if formalized_md: + ok("Narratif validé. Passage au Designer.") + return formalized_md + else: + warn("Le narratif n'est pas encore formalisé.") + info("Utilise /formalise pour demander au Narrator de structurer.") + continue + + elif cmd == "/sauvegarder": + if last_response: + path = save_text(last_response, proj.out("narrator_draft", "md")) + ok(f"Sauvegardé : {path}") + else: + warn("Rien à sauvegarder pour l'instant.") + continue + + elif cmd == "/afficher": + if last_response: + print("\n" + textwrap.indent(last_response, " ")) + else: + warn("Pas encore de réponse du Narrator.") + continue + + elif cmd in ("/quitter", "/exit", "/q"): + rep = ask("Abandonner la session Narrator ? (o/N) :").lower() + if rep in ("o", "oui", "y", "yes"): + return None + continue + + elif cmd and cmd.startswith("/"): + warn(f"Commande inconnue : {cmd}") + info("Tapez /aide pour voir les commandes disponibles.") + continue + + # ── Message conversationnel normal ───────────────────────────────── + if not session.started and pending_context: + # Fusionner le contexte en attente avec le premier message + combined = ( + f"{user_input}\n\n" + f"---\nCONTEXTE DOCUMENTAIRE DISPONIBLE :\n\n{pending_context}" + ) + pending_context = "" + info("Envoi du message avec le contexte documentaire...") + last_response = session.start(combined) + else: + last_response = session.send(user_input) + + display_narrator(last_response) + + +def save_text(content: str, path: Path) -> Path: + path.write_text(content, encoding="utf-8") + ok(f"Sauvegardé : {path}") + return path + + +def persist_generation(proj: Project, markdown: str = None, + yaml_path: Path = None, pptx_path: Path = None, + journal_entry: str = None): + """Met à jour project_state.json et journal.md après une génération.""" + state = proj.load_state() + if markdown is not None: + state["dernier_markdown"] = markdown + if yaml_path is not None: + state["dernier_yaml"] = str(yaml_path) + if pptx_path is not None: + state["dernier_pptx"] = str(pptx_path) + state["nb_sessions"] = state.get("nb_sessions", 0) + 1 + proj.save_state(state) + if journal_entry: + proj.log(journal_entry) + + +# ───────────────────────────────────────────────────────────────────────────── +# VALIDATION YAML +# ───────────────────────────────────────────────────────────────────────────── + +def extract_yaml(raw: str) -> str: + m = re.search(r"```ya?ml\s*(.*?)```", raw, re.DOTALL | re.IGNORECASE) + return re.sub(r"[─]+", "-", (m.group(1).strip() if m else raw.strip())) + + +def validate_yaml(raw: str, layouts: dict): + try: + data = yaml.safe_load(extract_yaml(raw)) + except yaml.YAMLError as e: + return False, f"Erreur de syntaxe YAML : {e}", None + if not isinstance(data, dict): + return False, "Doit être un dictionnaire à la racine.", None + slides = data.get("slides") + if not slides: + return False, "Clé 'slides' manquante ou vide.", None + errors, valid = [], set(layouts.keys()) + for i, slide in enumerate(slides): + pos = slide.get("position", i + 1) + layout = slide.get("layout", "") + if not layout: + errors.append(f"Slide {pos} : layout manquant") + continue + if layout not in valid: + errors.append(f"Slide {pos} : layout '{layout}' inconnu") + continue + for field in layouts[layout].get("champs_requis", []): + if field not in slide or slide.get(field) in (None, "", []): + errors.append(f"Slide {pos} ({layout}) : '{field}' requis") + if errors: + return False, "\n ".join(errors), data + return True, f"{len(slides)} slides valides.", data + + +def layouts_digest(layouts: dict) -> str: + lines = ["Layouts valides et champs requis (design system v2) :"] + for name, cfg in layouts.items(): + req = ", ".join(cfg.get("champs_requis", [])) + lines.append(f"- {name} : {req}") + return "\n".join(lines) + + +def load_layouts() -> dict: + if not os.path.exists(LAYOUTS_PATH): + warn(f"{LAYOUTS_PATH} introuvable — validation désactivée") + return {} + with open(LAYOUTS_PATH, encoding="utf-8") as f: + return yaml.safe_load(f).get("layouts", {}) + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 2 — DESIGNER +# ───────────────────────────────────────────────────────────────────────────── + +def display_annotated(md: str, max_lines: int = 60): + """Affiche le Markdown annoté en mettant en évidence les @layout.""" + lines = md.splitlines() + shown = lines[:max_lines] + for l in shown: + if l.strip().startswith("@layout:"): + print(f" \033[1m{l.strip()}\033[0m" if sys.stdout.isatty() + else f" {l.strip()}") + elif l.startswith("#"): + print(f" {l}") + else: + print(f" {l}") + if len(lines) > max_lines: + print(f"\n ... [{len(lines)-max_lines} lignes — option 3 pour tout voir]") + + +def layout_summary(md: str) -> str: + """Extrait la liste des layouts annotés pour un résumé rapide.""" + out = [] + n = 0 + for l in md.splitlines(): + s = l.strip() + if s.startswith("@layout:"): + n += 1 + out.append(f" {n}. {s.replace('@layout:', '').strip()}") + return "\n".join(out) if out else " (aucune annotation @layout détectée)" + + +def run_designer(session: AgentSession, markdown: str, + proj: Project, scope_instruction: str = "") -> Optional[str]: + section("ÉTAPE 2 — THE DESIGNER (annotation des layouts)") + info("Annotation du Markdown avec les layouts proposés...") + initial = markdown + if scope_instruction: + initial = f"{markdown}\n\n---\n{scope_instruction}" + info("Mode ciblé : le Designer ne traitera que les slides indiquées.") + output = session.start(initial) + ok(f"Markdown annoté ({len(output)} caractères)") + while True: + print() + print(" Layouts proposés :") + print(layout_summary(output)) + print("\n [1] Valider → passer à l'Encoder") + print(" [2] Donner du feedback au Designer") + print(" [3] Afficher le Markdown annoté complet") + print(" [4] Sauvegarder le Markdown annoté") + print(" [0] Revenir au Narrator") + choix = ask("Votre choix :") + if choix == "1": + ok("Annotation validée.") + save_text(output, proj.out("designer_annote", "md")) + return output + elif choix == "2": + fb = ask("Votre feedback (ex : 'slide 4 en big_stat') :") + if fb: + info("Envoi au Designer...") + output = session.feedback(fb) + ok(f"Markdown annoté mis à jour ({len(output)} caractères)") + elif choix == "3": + print() + display_annotated(output, max_lines=200) + elif choix == "4": + save_text(output, proj.out("designer_annote_draft", "md")) + elif choix == "0": + return None + else: + warn("Choix invalide.") + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 3 — ENCODER +# ───────────────────────────────────────────────────────────────────────────── + +def run_encoder(session: AgentSession, plan: str, layouts: dict, + proj: Project, scope_instruction: str = ""): + section("ÉTAPE 3 — THE ENCODER") + info("Encodage du plan en YAML...") + digest = layouts_digest(layouts) if layouts else "" + parts = [plan] + if scope_instruction: + parts.append(scope_instruction) + info("Mode ciblé : l'Encoder ne produira que les slides indiquées.") + if digest: + parts.append(digest) + initial = "\n\n---\n".join(parts) + output = session.start(initial) + ok(f"YAML brut reçu ({len(output)} caractères)") + + attempts, max_attempts = 0, 3 + while attempts < max_attempts: + is_valid, message, data = validate_yaml(output, layouts) + if is_valid: + ok(f"YAML valide : {message}") + path = save_text(extract_yaml(output), proj.out("input", "yaml")) + return extract_yaml(output), data, path, attempts + attempts += 1 + warn(f"YAML invalide (tentative {attempts}/{max_attempts}) :") + print(f" {message}\n") + if attempts >= max_attempts: + break + info("Correction automatique...") + output = session.feedback( + f"Erreurs dans le YAML :\n\n{message}\n\n" + f"Renvoie le YAML COMPLET corrigé, sans texte avant ou après.") + ok(f"YAML corrigé ({len(output)} caractères)") + + print("\n [1] Accepter le YAML tel quel") + print(" [2] Feedback manuel à l'Encoder") + print(" [0] Revenir au Designer") + choix = ask("Votre choix :") + if choix == "1": + path = save_text(extract_yaml(output), + proj.out("input_unvalidated", "yaml")) + try: + data = yaml.safe_load(extract_yaml(output)) or {} + except Exception: + data = {} + return extract_yaml(output), data, path, attempts + elif choix == "2": + fb = ask("Votre feedback :") + output = session.feedback(fb) + path = save_text(extract_yaml(output), proj.out("input_manual", "yaml")) + try: + data = yaml.safe_load(extract_yaml(output)) or {} + except Exception: + data = {} + return extract_yaml(output), data, path, attempts + return None, None, None, attempts + + +# ───────────────────────────────────────────────────────────────────────────── +# FLUX LIBRE — THE FREE DESIGNER +# ───────────────────────────────────────────────────────────────────────────── + +VALID_TOKENS = {"navy", "navy_light", "coral", "glacier", "slate", + "card", "white", "body", "muted"} +VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", "circle", + "badge", "card", "rect", "line"} + + +def validate_freeform(raw: str): + """Valide la structure d'un YAML freeform. Retourne (ok, message, data).""" + try: + data = yaml.safe_load(extract_yaml(raw)) + except yaml.YAMLError as e: + return False, f"Erreur de syntaxe YAML : {e}", None + if not isinstance(data, dict) or "slides" not in data: + return False, "Clé 'slides' manquante.", None + slides = data["slides"] + if not slides: + return False, "Aucune slide.", None + errors = [] + for i, s in enumerate(slides, 1): + if s.get("layout") != "freeform": + errors.append(f"Slide {i} : layout doit être 'freeform'") + continue + if s.get("mode") not in ("light", "dark"): + errors.append(f"Slide {i} : mode doit être 'light' ou 'dark'") + blocks = s.get("blocks", []) + if not blocks: + errors.append(f"Slide {i} : aucun bloc") + if len(blocks) > 8: + errors.append(f"Slide {i} : {len(blocks)} blocs (max 8)") + for j, b in enumerate(blocks, 1): + bt = (b.get("type") or "text").lower() + if bt not in VALID_BLOCK_TYPES: + errors.append(f"Slide {i} bloc {j} : type '{bt}' invalide") + col = float(b.get("col", 0)); w = float(b.get("w", 4)) + row = float(b.get("row", 0)); h = float(b.get("h", 1)) + if col + w > 12.01: + errors.append(f"Slide {i} bloc {j} : déborde (col+w={col+w:.1f}>12)") + if row + h > 12.01: + errors.append(f"Slide {i} bloc {j} : déborde (row+h={row+h:.1f}>12)") + color = b.get("color") + if color and color not in VALID_TOKENS: + errors.append(f"Slide {i} bloc {j} : couleur '{color}' hors charte") + if errors: + return False, "\n ".join(errors), data + return True, f"{len(slides)} slides freeform valides.", data + + +def run_free_designer(session: AgentSession, markdown: str, proj: Project): + """Flux libre : le Free Designer produit directement le YAML freeform.""" + section("FLUX LIBRE — THE FREE DESIGNER") + info("Composition libre des slides (charte PR imposée)...") + output = session.start(markdown) + ok(f"YAML freeform reçu ({len(output)} caractères)") + + attempts, max_attempts = 0, 3 + while attempts < max_attempts: + is_valid, message, data = validate_freeform(output) + if is_valid: + ok(f"Freeform valide : {message}") + break + attempts += 1 + warn(f"Freeform invalide (tentative {attempts}/{max_attempts}) :") + print(f" {message}\n") + if attempts >= max_attempts: + break + info("Correction automatique...") + output = session.feedback( + f"Le YAML freeform contient des erreurs :\n\n{message}\n\n" + f"Corrige et renvoie le YAML freeform COMPLET, sans texte autour. " + f"Rappel : couleurs = tokens de charte uniquement, " + f"col+w ≤ 12, row+h ≤ 12, max 8 blocs par slide.") + ok(f"Freeform corrigé ({len(output)} caractères)") + + # Boucle de validation utilisateur + while True: + is_valid, message, data = validate_freeform(output) + print() + n = len(data["slides"]) if data and "slides" in data else 0 + info(f"Composition libre : {n} slide(s).") + status = "valide" if is_valid else "INVALIDE — " + message.split(chr(10))[0] + print(f" Statut : {status}") + print("\n [1] Valider → rendu") + print(" [2] Donner du feedback au Free Designer") + print(" [3] Afficher le YAML complet") + print(" [4] Sauvegarder le YAML") + print(" [0] Revenir au Narrator") + choix = ask("Votre choix :") + if choix == "1": + if not is_valid: + warn("Le YAML a des erreurs — le rendu peut échouer.") + if ask("Forcer le rendu ? (o/N) :").lower() not in ("o", "oui", "y"): + continue + path = save_text(extract_yaml(output), proj.out("freeform", "yaml")) + return extract_yaml(output), data, path + elif choix == "2": + fb = ask("Votre feedback :") + if fb: + info("Envoi au Free Designer...") + output = session.feedback(fb) + ok(f"Composition mise à jour ({len(output)} caractères)") + elif choix == "3": + print("\n" + textwrap.indent(extract_yaml(output), " ")) + elif choix == "4": + save_text(extract_yaml(output), proj.out("freeform_draft", "yaml")) + elif choix == "0": + return None, None, None + else: + warn("Choix invalide.") + + +# ───────────────────────────────────────────────────────────────────────────── +# ÉTAPE 4 — RENDER ENGINE +# ───────────────────────────────────────────────────────────────────────────── + +def run_render(yaml_path: Path) -> Optional[Path]: + section("ÉTAPE 4 — RENDER ENGINE V2") + if not Path(RENDER_ENGINE_PATH).exists(): + warn(f"{RENDER_ENGINE_PATH} introuvable.") + return None + for p in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]: + if not os.path.exists(p): + warn(f"Config manquante : {p}") + return None + 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 result.returncode == 0: + ok(result.stdout.strip() or f"PPTX généré : {pptx_out}") + return pptx_out + warn("Erreur render_engine_v2.py :") + print(textwrap.indent(result.stderr or result.stdout, " ")) + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# ARCHIVAGE TRILIUM +# ───────────────────────────────────────────────────────────────────────────── + +def archive_to_trilium(pptx_path, manifest_path, proj): + if not (TRILIUM_API_URL and TRILIUM_API_KEY): + return + try: + detail = f"Projet : {proj.name}" + if pptx_path: + detail += f" | PPTX : {pptx_path}" + resp = requests.post( + f"{TRILIUM_API_URL.rstrip('/')}/api/historique", + headers={"Authorization": TRILIUM_API_KEY, + "Content-Type": "application/json"}, + json={"projet": TRILIUM_PROJET, "type": "Fait etabli", + "enonce": f"Génération PPTX — projet {proj.name}", + "detail": detail}, + timeout=15) + ok("Archivé dans Trilium.") if resp.ok \ + else warn(f"Trilium : {resp.status_code} (non bloquant)") + except requests.RequestException as e: + warn(f"Trilium injoignable ({e}).") + + +# ───────────────────────────────────────────────────────────────────────────── +# BOUCLE PRINCIPALE +# ───────────────────────────────────────────────────────────────────────────── + +# ───────────────────────────────────────────────────────────────────────────── +# MODE RÉVISION (ciblé / structurant) +# ───────────────────────────────────────────────────────────────────────────── + +def parse_slide_numbers(raw: str) -> list: + """'4, 7, 9' ou '4-6' → [4,7,9] ou [4,5,6]. Retourne liste triée unique.""" + nums = set() + for token in re.split(r"[,\s]+", raw.strip()): + if not token: + continue + if "-" in token: + try: + a, b = token.split("-") + nums.update(range(int(a), int(b) + 1)) + except ValueError: + pass + else: + try: + nums.add(int(token)) + except ValueError: + pass + return sorted(nums) + + +def run_full_pipeline_pass(narrator, markdown, layouts, proj, manifest, + scope_designer="", scope_encoder="", + suffix="input"): + """ + Exécute Designer → Encoder → Render sur le Markdown fourni. + scope_* restreignent le périmètre (révision ciblée). + suffix nomme les fichiers de sortie (ex 'revision'). + Retourne le chemin du PPTX généré, ou None. + """ + while True: + designer = AgentSession(DESIGNER_ID) + plan = run_designer(designer, markdown, proj, scope_designer) + if plan is None: + section("RETOUR AU NARRATOR") + info("La session Narrator est toujours active.") + markdown = run_narrator(narrator, proj) + if not markdown: + return None, markdown + save_text(markdown, proj.out("narrator_rev", "md")) + 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) + manifest.step("encoder", tentatives_correction=tries) + if yaml_str is None: + info("Retour au Designer...") + continue + + # Renommer la sortie selon le suffixe demandé + if suffix != "input" and yaml_path: + new_path = proj.out(suffix, "yaml") + yaml_path.rename(new_path) + yaml_path = new_path + + manifest.file(yaml_path) + pptx_path = run_render(yaml_path) + 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) + if suffix == "revision_complete": + persist_generation( + proj, markdown=markdown, yaml_path=yaml_path, + 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.") + return pptx_path, markdown + + +def run_revision(narrator, last_markdown, layouts, proj, manifest): + """ + Boucle de révision. Repasse TOUJOURS par le Narrator (contexte complet), + puis propose le mode ciblé ou structurant. + Retourne le dernier markdown (pour chaîner d'autres révisions). + """ + section("MODE RÉVISION") + info("La session Narrator a conservé tout le contexte de la présentation.") + info("Retravaillez une ou plusieurs slides, puis /formalise pour") + info("régénérer le Markdown complet du deck.") + + # 1. Narrator — conversation reprise, formalisation complète + new_markdown = run_narrator(narrator, proj) + if not new_markdown: + info("Révision annulée.") + return last_markdown + save_text(new_markdown, proj.out("narrator_rev", "md")) + manifest.step("narrator_revision", caracteres=len(new_markdown)) + + # 2. Portée de la révision + section("PORTÉE DE LA RÉVISION") + print(" [1] Ciblé — quelques slides modifiées, le reste est inchangé") + print(" → seules ces slides sont régénérées (à coller dans ton deck)") + print(" [2] Structurant — la logique d'ensemble a changé") + print(" → tout le deck est régénéré") + print(" [0] Annuler") + choix = ask("Votre choix :") + + if choix == "0": + return new_markdown + + if choix == "1": + nums_raw = ask("Numéros des slides à régénérer (ex : 4, 7 ou 4-6) :") + slides = parse_slide_numbers(nums_raw) + if not slides: + warn("Aucun numéro valide — révision annulée.") + return new_markdown + slist = ", ".join(str(n) for n in slides) + ok(f"Régénération ciblée des slides : {slist}") + + scope_designer = ( + f"RÉVISION CIBLÉE. Le Markdown ci-dessus est le deck COMPLET, " + f"fourni pour que tu comprennes le contexte et la cohérence. " + f"Tu ne dois produire le plan QUE pour les slides suivantes : " + f"{slist}. Numérote-les avec leur position réelle dans le deck " + f"complet (ex : SLIDE {slides[0]}). Ignore toutes les autres slides." + ) + scope_encoder = ( + f"RÉVISION CIBLÉE. Encode UNIQUEMENT les slides {slist}. " + f"Conserve leur position réelle dans le champ 'position'. " + f"Le YAML produit ne contiendra donc que {len(slides)} slide(s)." + ) + pptx_path, new_markdown = run_full_pipeline_pass( + narrator, new_markdown, layouts, proj, manifest, + scope_designer=scope_designer, scope_encoder=scope_encoder, + suffix="revision_ciblee") + + if pptx_path and pptx_path.exists(): + 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.") + else: + warn("Les slides révisées n'ont pas pu être générées.") + return new_markdown + + if choix == "2": + ok("Régénération complète du deck.") + pptx_path, new_markdown = run_full_pipeline_pass( + narrator, new_markdown, layouts, proj, manifest, + suffix="revision_complete") + if pptx_path and pptx_path.exists(): + section("DECK COMPLET RÉGÉNÉRÉ") + ok(f"Nouveau PPTX complet : {pptx_path}") + else: + warn("Le deck n'a pas pu être régénéré.") + return new_markdown + + warn("Choix invalide — révision annulée.") + return new_markdown + + +# ───────────────────────────────────────────────────────────────────────────── +# FLUX DE CRÉATION (nouvelle présentation) +# ───────────────────────────────────────────────────────────────────────────── + +def run_creation(proj, layouts): + """Crée une nouvelle présentation : Narrator → Designer → Encoder → Render. + Persiste l'état du projet à la génération. Gère ensuite la boucle révision.""" + manifest = Manifest(proj) + narrator = AgentSession(NARRATOR_ID) + + markdown = run_narrator(narrator, proj) + if not markdown: + return + + save_text(markdown, proj.out("narrator", "md")) + manifest.step("narrator", caracteres=len(markdown)) + proj.log(f"Narratif formalisé ({len(markdown)} car.).") + + # ── Choix du flux de mise en forme ────────────────────────────────────── + section("FLUX DE MISE EN FORME") + print(" [1] Standard — layouts prédéfinis (Designer annote, Encoder, rendu)") + print(" → présentations régulières, structure cadrée et reproductible") + print(" [2] Libre — composition sur mesure (Free Designer)") + print(" → présentations stratégiques, charte PR imposée") + flux = ask("Votre choix :") + + # ── FLUX LIBRE ────────────────────────────────────────────────────────── + if flux == "2": + if not FREE_DESIGNER_ID: + warn("FREE_DESIGNER_AGENT_ID absent du .env — flux libre indisponible.") + info("Bascule sur le flux standard.") + else: + manifest.step("choix_flux", flux="libre") + proj.log("Flux libre sélectionné.") + yaml_path = yaml_data = None + while True: + free = AgentSession(FREE_DESIGNER_ID) + yaml_str, yaml_data, yaml_path = run_free_designer( + free, markdown, proj) + if yaml_str is None: + section("RETOUR AU NARRATOR") + markdown = run_narrator(narrator, proj) + if not markdown: + return + save_text(markdown, proj.out("narrator_v2", "md")) + continue + manifest.step("free_designer", caracteres=len(yaml_str)) + break + + if yaml_path and yaml_data: + manifest.file(yaml_path) + pptx_path = run_render(yaml_path) + 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") + else: + warn("Le PPTX n'a pas pu être généré.") + persist_generation( + proj, markdown=markdown, yaml_path=yaml_path, + pptx_path=pptx_path, + journal_entry="Présentation générée (flux libre).") + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + manifest_path = manifest.save( + proj.outputs / f"{proj.slug}_{ts}_manifest.json") + archive_to_trilium(pptx_path, manifest_path, proj) + revision_loop(narrator, markdown, layouts, proj, manifest) + return + + # ── FLUX STANDARD ─────────────────────────────────────────────────────── + manifest.step("choix_flux", flux="standard") + + express = ask("Mode express — sans Designer ? (o/N) :").lower() \ + in ("o", "oui", "y", "yes") + if express: + info("Mode express : Narrator → Encoder direct.") + + yaml_path = yaml_data = None + while True: + if express: + plan = markdown + else: + designer = AgentSession(DESIGNER_ID) + plan = run_designer(designer, markdown, proj) + if plan is None: + section("RETOUR AU NARRATOR") + info("La session Narrator est toujours active.") + markdown = run_narrator(narrator, proj) + if not markdown: + return + save_text(markdown, proj.out("narrator_v2", "md")) + continue + manifest.step("designer", caracteres=len(plan)) + + 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: + warn("Échec Encoder en mode express.") + return + info("Retour au Designer...") + continue + break + + if yaml_path and yaml_data: + manifest.file(yaml_path) + pptx_path = run_render(yaml_path) + 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") + else: + warn("Le PPTX n'a pas pu être généré.") + info(f"Le YAML est dans : {proj.outputs}") + # Persister l'état complet du projet + persist_generation(proj, markdown=markdown, yaml_path=yaml_path, + pptx_path=pptx_path, + journal_entry="Présentation générée (deck complet).") + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + manifest_path = manifest.save( + proj.outputs / f"{proj.slug}_{ts}_manifest.json") + archive_to_trilium(pptx_path, manifest_path, proj) + + # Boucle de révision immédiate + revision_loop(narrator, markdown, layouts, proj, manifest) + + +def revision_loop(narrator, markdown, layouts, proj, manifest): + """Menu de révision réutilisable (après création ou à la reprise).""" + while True: + print() + print(" [1] Réviser la présentation (repasse par le Narrator)") + print(" [2] Terminer") + rev = ask("Votre choix :") + if rev == "1": + 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") + continue + break + + +# ───────────────────────────────────────────────────────────────────────────── +# REPRISE D'UN PROJET EXISTANT (Solution B + C) +# ───────────────────────────────────────────────────────────────────────────── + +def run_reprise(proj, layouts): + """Ouvre un projet déjà travaillé : révision par défaut, ou questions.""" + state = proj.load_state() + section(f"REPRISE — {proj.name}") + ok(f"Dernière session : {state.get('derniere_session', 'inconnue')}") + ok(f"Sessions précédentes : {state.get('nb_sessions', 0)}") + last_pptx = state.get("dernier_pptx", "") + if last_pptx: + info(f"Dernier PPTX : {last_pptx}") + + print("\n [1] Réviser la présentation existante") + print(" [2] Nouvelle présentation dans ce projet") + print(" [3] Poser des questions (sans rien régénérer)") + print(" [0] Retour au menu projet") + choix = ask("Votre choix :") + + if choix == "1": + manifest = Manifest(proj) + narrator = AgentSession(NARRATOR_ID) + reprime_narrator(narrator, proj) # réamorçage avec contexte + proj.log("Reprise du projet pour révision.") + markdown = state.get("dernier_markdown", "") + revision_loop(narrator, markdown, layouts, proj, manifest) + + elif choix == "2": + proj.log("Nouvelle présentation dans un projet existant.") + run_creation(proj, layouts) + + elif choix == "3": + manifest = Manifest(proj) + narrator = AgentSession(NARRATOR_ID) + reprime_narrator(narrator, proj) + section("MODE QUESTIONS") + info("Pose tes questions sur la présentation. Tape /quitter pour sortir.") + while True: + q = ask("Question :") + if q.strip().lower() in ("/quitter", "/q", "quit", "/exit"): + break + if not q: + continue + ans = narrator.send(q) + display_narrator(ans) + + # choix 0 ou autre : retour + + +# ───────────────────────────────────────────────────────────────────────────── +# BOUCLE PRINCIPALE +# ───────────────────────────────────────────────────────────────────────────── + +def run_pipeline(): + banner() + missing = [k for k, v in { + "MISTRAL_API_KEY": API_KEY, "NARRATOR_AGENT_ID": NARRATOR_ID, + "DESIGNER_AGENT_ID": DESIGNER_ID, "ENCODER_AGENT_ID": ENCODER_ID, + }.items() if not v] + if missing: + for m in missing: warn(f"Variable .env manquante : {m}") + sys.exit(1) + + layouts = load_layouts() + if layouts: + ok(f"{LAYOUTS_PATH} chargé — {len(layouts)} layouts") + + while True: + section("PROJET") + proj = select_project() + if proj is None: + print(" Au revoir.") + break + + # Reprise intelligente : projet avec état → menu révision par défaut + if proj.has_state(): + run_reprise(proj, layouts) + else: + run_creation(proj, layouts) + + # Après création ou reprise : continuer ou quitter + cont = ask("Revenir au menu projet ? (o/n) :").lower() + if cont not in ("o", "oui", "y", "yes"): + print(" Au revoir.") + break + + +# ───────────────────────────────────────────────────────────────────────────── +# POINT D'ENTRÉE +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Sliding facilitator v8 — flux libre (Free Designer)") + parser.add_argument("--render", metavar="YAML", + help="Rendu direct d'un YAML existant, sans agents") + args = parser.parse_args() + + if args.render: + yaml_path = Path(args.render) + if not yaml_path.exists(): + warn(f"Fichier introuvable : {yaml_path}") + sys.exit(1) + layouts = load_layouts() + 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) + + run_pipeline() + + +if __name__ == "__main__": + main() diff --git a/prompt_the_free_designer.md b/prompt_the_free_designer.md new file mode 100644 index 0000000..afad660 --- /dev/null +++ b/prompt_the_free_designer.md @@ -0,0 +1,198 @@ +# THE FREE DESIGNER +# Sliding Pipeline v3 — Pernod Ricard · Flux libre +# Mistral Large · Temperature 0.5 · Format : YAML freeform + +## RÔLE + +Tu es The Free Designer. Tu interviens dans le **flux libre**, pour les +présentations stratégiques qui ont besoin de compositions sur mesure plutôt +que de layouts prédéfinis. + +Tu reçois un Markdown narratif (issu du Narrator) et tu produis directement +un **YAML freeform** : chaque slide est composée librement de blocs +positionnés sur une grille, dans le respect ABSOLU de la charte Pernod Ricard. + +Tu n'utilises pas les layouts nommés. Tu composes. + +--- + +## LA CHARTE PERNOD RICARD (IMPOSÉE — non négociable) + +**Couleurs** — tu ne peux utiliser QUE ces tokens (jamais de code hex) : +- `navy` : bleu nuit dominant (fonds sombres, titres) +- `navy_light` : navy plus clair (cercles décoratifs sur fond sombre) +- `coral` : accent unique (chiffres, badges, lignes d'accent) +- `glacier` : bleu clair (sous-titres sur fond sombre, 3e couleur) +- `slate` : gris ardoise (2e couleur de cycle) +- `card` : gris chaud (fonds de cartes) +- `white` : blanc +- `body` : gris foncé (texte courant sur fond clair) +- `muted` : gris clair (légendes, sources) + +**Règle d'accent** : le corail est rare et précieux. Un seul élément corail +dominant par slide (un chiffre, un badge, une ligne). Le navy domine. + +**Polices** (gérées automatiquement) : +- Les blocs `title`, `heading`, `stat` utilisent la police serif (Cambria). +- Les blocs `text` utilisent la police sans-serif (Calibri), sauf si tu mets `serif: true`. + +**Rythme sandwich** : les slides d'ouverture, de transition et de conclusion +sont en `mode: dark` (fond navy). Les slides de contenu en `mode: light`. + +--- + +## LA GRILLE + +Chaque slide a une grille de **12 colonnes × 12 lignes**. Tu positionnes +chaque bloc avec : +- `col` : colonne de départ (0 à 12) +- `row` : ligne de départ (0 à 12) +- `w` : largeur en colonnes +- `h` : hauteur en lignes + +La grille couvre la zone utile (hors marges et footer). `col: 0` = marge +gauche, `col: 12` = marge droite. Reste dans ces bornes : un bloc ne doit +jamais avoir `col + w > 12` ni `row + h > 12`. + +**Garde-fous de composition :** +- Laisse de l'air. N'occupe pas toute la grille. Le vide est un outil. +- Aligne les blocs entre eux (mêmes `col` ou mêmes `row`). +- Centre verticalement les compositions courtes (commence vers `row: 3-4`). +- Maximum 8 blocs par slide. Au-delà, tu surcharges. + +--- + +## LES TYPES DE BLOCS + +```yaml +- type: title # titre serif, navy (light) ou white (dark) + text: "..." + col: 0 + row: 1 + w: 12 + h: 1.5 + size: 28 # optionnel (défaut 28) + align: left # left | center | right + color: navy # optionnel (défaut auto selon mode) + +- type: stat # grand chiffre serif corail + text: "78%" + col: 0 + row: 3 + w: 4 + h: 3 + size: 100 # optionnel (défaut 72) + color: coral + align: center + +- type: text # texte courant (Calibri) + text: "..." + col: 0 + row: 5 + w: 6 + h: 2 + size: 16 + bold: false + italic: false + serif: false # true pour passer en Cambria + color: body + align: left + +- type: circle # cercle plein (motif récurrent de la charte) + col: 1 + row: 3 + w: 2.5 # le diamètre = min(w, h) converti + h: 2.5 + color: navy + +- type: badge # cercle numéroté/lettré (texte centré blanc) + text: "1" + col: 1.4 + row: 3.4 + w: 1.6 + h: 1.6 + color: coral + +- type: card # carte arrondie avec ombre (fond card par défaut) + col: 0 + row: 4 + w: 5 + h: 3 + color: card + +- type: rect # rectangle plein + col: 0 + row: 0 + w: 4 + h: 12 + color: navy + rounded: false + +- type: line # ligne (séparateur, connecteur) + col: 4 + row: 4.5 + w: 7 # longueur horizontale + h: 0 # 0 = ligne horizontale + color: coral + weight: 2 +``` + +--- + +## FORMAT DE SORTIE + +```yaml +slides: + - layout: freeform + mode: dark + blocks: + - type: stat + text: "3" + col: 0.5 + row: 1 + w: 3 + h: 4 + size: 150 + color: coral + align: center + - type: title + text: "trois convictions structurantes" + col: 4 + row: 1.5 + w: 7.5 + h: 3 + color: white + - layout: freeform + mode: light + blocks: + - type: title + text: "..." + col: 0 + row: 0 + w: 12 + h: 1.5 +``` + +--- + +## RÈGLES ABSOLUES + +- Tu produis UNIQUEMENT du YAML freeform valide. Aucun texte avant ou après. +- Tu n'utilises QUE les tokens de couleur de la charte. Jamais de hex. +- Tu n'inventes pas de contenu : tu mets en forme le narratif fourni. +- Tu respectes les bornes de grille (col+w ≤ 12, row+h ≤ 12). +- Maximum 8 blocs par slide. +- Première slide en `mode: dark` (ouverture), dernière en `mode: dark` (clôture). +- Le corail reste rare : un accent dominant par slide. +- Si une présentation est longue, travaille en blocs de 6 slides puis + `PAUSE — [N] slides restants.` en attendant "continue". +- Tu termines par `FIN — [N] slides au total.` + +--- + +## PHILOSOPHIE + +Tu n'es pas là pour remplir des cases. Tu composes des slides qui respirent, +qui hiérarchisent l'information par la taille et l'espace, et qui gardent la +signature visuelle Pernod Ricard : sobre, éditoriale, un seul accent chaud. +Pense comme un directeur artistique qui connaît la charte par cœur. diff --git a/render_engine_v2.py b/render_engine_v2.py index 2330a8a..f8467c4 100644 --- a/render_engine_v2.py +++ b/render_engine_v2.py @@ -1102,6 +1102,132 @@ class RenderEngineV2: item.get("label", ""), size=10, color=self.C["body"]) + + # ── FLUX LIBRE — freeform (palier 3, charte imposée) ────────────────────── + # + # Une slide freeform : { layout: freeform, mode: light|dark, blocks: [...] } + # Chaque bloc est positionné sur une grille 12x12 (col 0-12, row 0-12), + # ce qui borne le positionnement et évite les débordements. + # Les couleurs ne peuvent être que des TOKENS de charte (imposé). + + # Grille libre : 12 colonnes, 12 lignes, sur la zone utile (hors marges) + FREE_COLS = 12 + FREE_ROWS = 12 + + # Tokens de couleur autorisés (charte imposée — aucune couleur arbitraire) + def _token_color(self, token: str, default: str = None) -> str: + mapping = { + "navy": self.C["navy"], "navy_light": self.C["navy2"], + "coral": self.C["coral"], "glacier": self.C["glacier"], + "slate": self.C["slate"], "card": self.C["card"], + "white": self.C["white"], "body": self.C["body"], + "muted": self.C["muted"], + } + return mapping.get((token or "").strip().lower(), + default or self.C["navy"]) + + def _free_x(self, col: float) -> float: + """Colonne de grille (0-12) → position x en cm (dans la zone utile).""" + usable = self.SLIDE_W - 2 * self.MX + return self.MX + (col / self.FREE_COLS) * usable + + def _free_y(self, row: float) -> float: + """Ligne de grille (0-12) → position y en cm (zone titre→footer).""" + top = self.TITLE_Y + usable = self.FOOTER_Y - 0.4 - top + return top + (row / self.FREE_ROWS) * usable + + def _free_w(self, cols: float) -> float: + usable = self.SLIDE_W - 2 * self.MX + return (cols / self.FREE_COLS) * usable + + def _free_h(self, rows: float) -> float: + usable = self.FOOTER_Y - 0.4 - self.TITLE_Y + return (rows / self.FREE_ROWS) * usable + + def _render_freeform(self, slide, d): + mode = d.get("mode", "light") + on_dark = (mode == "dark") + if on_dark: + self._bg(slide, self.C["navy"]) + default_text = self.C["white"] if on_dark else self.C["body"] + + for blk in d.get("blocks", []): + btype = (blk.get("type") or "text").lower() + col = float(blk.get("col", 0)) + row = float(blk.get("row", 0)) + w_cols = float(blk.get("w", 4)) + h_rows = float(blk.get("h", 1)) + x, y = self._free_x(col), self._free_y(row) + w, h = self._free_w(w_cols), self._free_h(h_rows) + + if btype == "rect": + self._rect(slide, x, y, w, h, + self._token_color(blk.get("color"), self.C["card"]), + rounded=blk.get("rounded", False)) + + elif btype == "card": + self._card(slide, x, y, w, h, + self._token_color(blk.get("color"), self.C["card"])) + + elif btype == "circle": + d_cm = min(w, h) + self._oval(slide, x, y, d_cm, + self._token_color(blk.get("color"), self.C["coral"])) + + elif btype == "line": + ln = slide.shapes.add_connector( + 1, Cm(x), Cm(y), Cm(x + w), Cm(y + h)) + ln.line.color.rgb = hex_to_rgb( + self._token_color(blk.get("color"), self.C["muted"])) + ln.line.width = Pt(float(blk.get("weight", 1.25))) + + elif btype == "badge": + d_cm = min(w, h) + self._badge(slide, x + d_cm / 2, y + d_cm / 2, d_cm, + blk.get("text", ""), + fill=self._token_color(blk.get("color"), + self.C["navy"])) + + elif btype == "stat": + # Grand chiffre — display, corail par défaut + self._text(slide, x, y, w, h, blk.get("text", ""), + font=self.F_DISPLAY, + size=int(blk.get("size", 72)), bold=True, + color=self._token_color(blk.get("color"), + self.C["coral"]), + align=self._free_align(blk.get("align", "left")), + anchor=MSO_ANCHOR.MIDDLE) + + elif btype in ("title", "heading"): + self._text(slide, x, y, w, h, blk.get("text", ""), + font=self.F_DISPLAY, + size=int(blk.get("size", 28)), bold=True, + color=self._token_color( + blk.get("color"), + self.C["white"] if on_dark else self.C["navy"]), + align=self._free_align(blk.get("align", "left")), + anchor=MSO_ANCHOR.MIDDLE) + + else: # text + # Police body, ou display si explicitement demandé + font = self.F_DISPLAY if blk.get("serif") else self.F_BODY + self._text(slide, x, y, w, h, blk.get("text", ""), + font=font, + size=int(blk.get("size", 16)), + bold=blk.get("bold", False), + italic=blk.get("italic", False), + color=self._token_color(blk.get("color"), + default_text), + align=self._free_align(blk.get("align", "left")), + anchor=MSO_ANCHOR.TOP) + + def _free_align(self, a: str): + return {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, + "right": PP_ALIGN.RIGHT}.get((a or "left").lower(), + PP_ALIGN.LEFT) + + # ---------------- orchestration ---------------- REGISTRY = { "cover_split": "_render_cover_split", @@ -1117,6 +1243,7 @@ class RenderEngineV2: "phases_timeline": "_render_phases_timeline", "recommendation_card": "_render_recommendation_card", "end_slide": "_render_end_slide", + "freeform": "_render_freeform", "from_to_pairs": "_render_from_to_pairs", "gantt_timeline": "_render_gantt_timeline", "yearly_timeline": "_render_yearly_timeline", @@ -1148,12 +1275,20 @@ class RenderEngineV2: print(f" ⚠ Layout inconnu '{layout}' → default_bullets") method = "_render_default_bullets" layout = "default_bullets" - mode = self.layouts.get(layout, {}).get("mode", "light") - if mode == "light": - self._bg(slide, self.C["white"]) - getattr(self, method)(slide, sd) - if layout not in excluded and layout != "recommendation_card": - self._footer(slide, i + 1) + if layout == "freeform": + # Le freeform gère son fond lui-même selon sd["mode"] + if sd.get("mode", "light") == "light": + self._bg(slide, self.C["white"]) + getattr(self, method)(slide, sd) + if sd.get("footer", True): + self._footer(slide, i + 1) + else: + mode = self.layouts.get(layout, {}).get("mode", "light") + if mode == "light": + self._bg(slide, self.C["white"]) + getattr(self, method)(slide, sd) + if layout not in excluded and layout != "recommendation_card": + self._footer(slide, i + 1) prs.save(output_path) print(f"✓ PPTX généré : {output_path} ({len(slides)} slides)")