889 lines
36 KiB
Python
889 lines
36 KiB
Python
#!/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()
|