Files

889 lines
36 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
facilitator_v4.py — Sliding Pipeline v6 · 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 = "6.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) -> Optional[str]:
section("ÉTAPE 2 — THE DESIGNER")
info("Génération du plan de présentation...")
output = session.start(markdown)
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):
section("ÉTAPE 3 — THE ENCODER")
info("Encodage du plan en YAML...")
digest = layouts_digest(layouts) if layouts else ""
initial = f"{plan}\n\n---\n{digest}" if digest else plan
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
# ─────────────────────────────────────────────────────────────────────────────
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)
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 v4 — Narrator conversationnel")
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()