diff --git a/facilitator_v5.py b/facilitator_v5.py new file mode 100644 index 0000000..01ebc13 --- /dev/null +++ b/facilitator_v5.py @@ -0,0 +1,1063 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +facilitator_v5.py — Sliding Pipeline v7 · 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") +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 = "7.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" + + 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}" + + @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 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 + + +# ───────────────────────────────────────────────────────────────────────────── +# 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 run_designer(session: AgentSession, markdown: str, + proj: Project, scope_instruction: str = "") -> Optional[str]: + section("ÉTAPE 2 — THE DESIGNER") + info("Génération du plan de présentation...") + # En révision ciblée, scope_instruction restreint le périmètre tout en + # fournissant le Markdown complet pour le contexte. + 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"Plan généré ({len(output)} caractères)") + while True: + print() + display_plan(output) + print("\n [1] Valider → passer à l'Encoder") + print(" [2] Donner du feedback au Designer") + print(" [3] Afficher le plan complet") + print(" [4] Sauvegarder le plan") + print(" [0] Revenir au Narrator") + choix = ask("Votre choix :") + if choix == "1": + ok("Plan approuvé.") + save_text(output, proj.out("designer", "txt")) + return output + elif choix == "2": + fb = ask("Votre feedback :") + if fb: + info("Envoi au Designer...") + output = session.feedback(fb) + ok(f"Nouveau plan ({len(output)} caractères)") + elif choix == "3": + print("\n" + textwrap.indent(output, " ")) + elif choix == "4": + save_text(output, proj.out("designer_draft", "txt")) + 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 + + +# ───────────────────────────────────────────────────────────────────────────── +# É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)) + 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 + + +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") + + section("PROJET") + proj = select_project() + if proj is None: + print(" Au revoir.") + return + + while True: + manifest = Manifest(proj) + narrator = AgentSession(NARRATOR_ID) + + # Étape 1 — Narrator conversationnel + markdown = run_narrator(narrator, proj) + if not markdown: + rep = ask("Changer de projet ou quitter ? (projet/quitter) :").lower() + if rep in ("projet", "p"): + section("PROJET") + np = select_project() + if np: + proj = np + else: + print(" Au revoir.") + break + continue + + save_text(markdown, proj.out("narrator", "md")) + manifest.step("narrator", caracteres=len(markdown), + docs_charges=bool(narrator.messages)) + + # Mode express ? + 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: + # Retour au Narrator — reprend la conversation existante + section("RETOUR AU NARRATOR") + info("La session Narrator est toujours active.") + info("Continuez la discussion ou /formalise pour re-valider.") + markdown = run_narrator(narrator, proj) + if not markdown: + break + 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.") + break + 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}") + 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 ────────────────────────────────────────── + while True: + print() + print(" [1] Réviser la présentation (repasse par le Narrator)") + print(" [2] Terminer cette présentation") + rev = ask("Votre choix :") + if rev == "1": + markdown = run_revision( + narrator, markdown, layouts, proj, manifest) + # le manifest de révision est ré-enregistré + manifest.save( + proj.outputs / + f"{proj.slug}_{datetime.now():%Y%m%d_%H%M%S}_manifest.json") + continue + break + + print() + suite = ask("Nouvelle présentation dans ce projet ? (o/n) :").lower() + if suite not in ("o", "oui", "y", "yes"): + rep2 = ask("Changer de projet ? (o/n) :").lower() + if rep2 in ("o", "oui", "y", "yes"): + section("PROJET") + np = select_project() + if np: + proj = np + else: + print(" Au revoir.") + break + + +# ───────────────────────────────────────────────────────────────────────────── +# POINT D'ENTRÉE +# ───────────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Sliding facilitator v5 — Narrator conversationnel + révision") + 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_designer_injected_v2.md b/prompt_the_designer_injected_v2.md index 870053c..7820094 100644 --- a/prompt_the_designer_injected_v2.md +++ b/prompt_the_designer_injected_v2.md @@ -388,3 +388,20 @@ SLIDE 10 — end_slide FIN — 10 slides au total. ``` + +--- + +## MODE RÉVISION CIBLÉE + +Il se peut qu'on te fournisse, après le Markdown complet, une consigne du type : + +> RÉVISION CIBLÉE. Le Markdown ci-dessus est le deck COMPLET, fourni pour +> que tu comprennes le contexte. Tu ne dois produire le plan QUE pour les +> slides suivantes : 4, 7. Numérote-les avec leur position réelle. + +Dans ce cas : +- Tu lis le deck complet pour comprendre la cohérence d'ensemble (enchaînement, ton, ce que disent les slides voisines). +- Tu ne produis le plan QUE pour les slides demandées, en gardant leur numéro de position réel dans le deck (ex : `SLIDE 4`, `SLIDE 7`). +- Tu choisis les layouts de ces slides en cohérence avec le reste du deck. +- Tu n'émets AUCune ligne pour les autres slides. +- Tu termines par `FIN — slides révisées : 4, 7.` diff --git a/prompt_the_designer_v2.md b/prompt_the_designer_v2.md index 300a7f5..4d6bcda 100644 --- a/prompt_the_designer_v2.md +++ b/prompt_the_designer_v2.md @@ -193,3 +193,20 @@ SLIDE 10 — end_slide FIN — 10 slides au total. ``` + +--- + +## MODE RÉVISION CIBLÉE + +Il se peut qu'on te fournisse, après le Markdown complet, une consigne du type : + +> RÉVISION CIBLÉE. Le Markdown ci-dessus est le deck COMPLET, fourni pour +> que tu comprennes le contexte. Tu ne dois produire le plan QUE pour les +> slides suivantes : 4, 7. Numérote-les avec leur position réelle. + +Dans ce cas : +- Tu lis le deck complet pour comprendre la cohérence d'ensemble (enchaînement, ton, ce que disent les slides voisines). +- Tu ne produis le plan QUE pour les slides demandées, en gardant leur numéro de position réel dans le deck (ex : `SLIDE 4`, `SLIDE 7`). +- Tu choisis les layouts de ces slides en cohérence avec le reste du deck. +- Tu n'émets AUCune ligne pour les autres slides. +- Tu termines par `FIN — slides révisées : 4, 7.` diff --git a/prompt_the_encoder_injected_v2.md b/prompt_the_encoder_injected_v2.md index 2a1434a..a4aff7f 100644 --- a/prompt_the_encoder_injected_v2.md +++ b/prompt_the_encoder_injected_v2.md @@ -438,3 +438,18 @@ SLIDE 7 — numbered_steps titre: "Comité de gouvernance trimestriel" description: "Arbitre les conflits de définition et reporte au CODIR" ``` + +--- + +## MODE RÉVISION CIBLÉE + +On peut te fournir, après le plan, une consigne du type : + +> RÉVISION CIBLÉE. Encode UNIQUEMENT les slides 4, 7. Conserve leur position +> réelle dans le champ 'position'. Le YAML produit ne contiendra donc que 2 slides. + +Dans ce cas : +- Tu encodes UNIQUEMENT les slides listées dans le plan fourni. +- Tu conserves leur `position` réelle (ex : `position: 4`, `position: 7`) — c'est essentiel pour que Bastien sache où les recoller. +- Le YAML produit contient donc uniquement ces slides, dans l'ordre croissant de position. +- Le reste des règles de transcription est inchangé. diff --git a/prompt_the_encoder_v2.md b/prompt_the_encoder_v2.md index 08b3ce1..622ad1b 100644 --- a/prompt_the_encoder_v2.md +++ b/prompt_the_encoder_v2.md @@ -135,3 +135,18 @@ SLIDE 7 — numbered_steps titre: "Comité de gouvernance trimestriel" description: "Arbitre les conflits de définition et reporte au CODIR" ``` + +--- + +## MODE RÉVISION CIBLÉE + +On peut te fournir, après le plan, une consigne du type : + +> RÉVISION CIBLÉE. Encode UNIQUEMENT les slides 4, 7. Conserve leur position +> réelle dans le champ 'position'. Le YAML produit ne contiendra donc que 2 slides. + +Dans ce cas : +- Tu encodes UNIQUEMENT les slides listées dans le plan fourni. +- Tu conserves leur `position` réelle (ex : `position: 4`, `position: 7`) — c'est essentiel pour que Bastien sache où les recoller. +- Le YAML produit contient donc uniquement ces slides, dans l'ordre croissant de position. +- Le reste des règles de transcription est inchangé.