feat: Narrator conversationnel — boucle libre + commandes /slash
This commit is contained in:
@@ -0,0 +1,888 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user