792 lines
32 KiB
Python
792 lines
32 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# -*- coding: utf-8 -*-
|
||
|
|
"""
|
||
|
|
facilitator_v3.py — Sliding Pipeline v5 · Pernod Ricard
|
||
|
|
========================================================
|
||
|
|
Orchestre les 3 agents Mistral + render_engine_v2, avec une
|
||
|
|
**organisation par projet**.
|
||
|
|
|
||
|
|
Arborescence :
|
||
|
|
projets/
|
||
|
|
<nom_du_projet>/
|
||
|
|
inputs/ ← documents de contexte (PDF, txt, md, docx) fournis par Bastien
|
||
|
|
outputs/ ← Markdown, plan, YAML, PPTX, manifest générés
|
||
|
|
|
||
|
|
Workflow :
|
||
|
|
Lancement → nouveau projet ou existant ?
|
||
|
|
→ documents dans inputs/ à analyser ?
|
||
|
|
→ brief
|
||
|
|
The Narrator (Agent 1) → Markdown [+ contexte documentaire injecté]
|
||
|
|
The Designer (Agent 2) → Plan [optionnel : mode express]
|
||
|
|
The Encoder (Agent 3) → YAML validé (contre layouts_v2)
|
||
|
|
render_engine_v2.py → PPTX
|
||
|
|
|
||
|
|
Nouveautés v5 :
|
||
|
|
- Organisation par projet (projets/<nom>/inputs|outputs)
|
||
|
|
- Sélection nouveau / existant au lancement
|
||
|
|
- Lecture des documents de contexte (inputs/) injectés au Narrator
|
||
|
|
- FIX : double 'output/output/' du manifest (séparation dir / nom de fichier)
|
||
|
|
|
||
|
|
Conserve de la v4 : retry réseau, garde pagination, historique cumulatif,
|
||
|
|
retour Narrator complet, yaml sort_keys=False, mode --render, mode express,
|
||
|
|
manifest JSON, archivage Trilium optionnel.
|
||
|
|
|
||
|
|
Variables .env :
|
||
|
|
MISTRAL_API_KEY (requis sauf mode --render)
|
||
|
|
NARRATOR_AGENT_ID / DESIGNER_AGENT_ID / ENCODER_AGENT_ID
|
||
|
|
PROJECTS_DIR (défaut : ./projets)
|
||
|
|
RENDER_ENGINE_PATH (défaut : render_engine_v2.py)
|
||
|
|
THEME_PATH / COMPONENTS_PATH / LAYOUTS_PATH
|
||
|
|
CONTEXT_MAX_CHARS (défaut : 12000 — plafond du contexte documentaire)
|
||
|
|
TRILIUM_API_URL / TRILIUM_API_KEY / TRILIUM_PROJET
|
||
|
|
"""
|
||
|
|
|
||
|
|
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
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
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 = "5.0"
|
||
|
|
MAX_PAGES = 10
|
||
|
|
MAX_RETRY = 3
|
||
|
|
|
||
|
|
PROJECTS_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# 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_markdown(text: str, max_lines: int = 60):
|
||
|
|
lines = text.splitlines()
|
||
|
|
if len(lines) <= max_lines:
|
||
|
|
print(textwrap.indent(text, " "))
|
||
|
|
else:
|
||
|
|
print(textwrap.indent("\n".join(lines[:max_lines]), " "))
|
||
|
|
print(f"\n ... [{len(lines) - max_lines} lignes supplémentaires]")
|
||
|
|
|
||
|
|
|
||
|
|
def display_plan(text: str, max_lines: int = 80):
|
||
|
|
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_markdown(text, max_lines)
|
||
|
|
|
||
|
|
|
||
|
|
def slugify(name: str) -> str:
|
||
|
|
s = re.sub(r"[^a-zA-Z0-9]+", "-", name.lower()).strip("-")
|
||
|
|
return s[:40]
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# GESTION DE PROJET (nouveau v5)
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
class Project:
|
||
|
|
"""Encapsule un projet : dossiers inputs/ et outputs/."""
|
||
|
|
|
||
|
|
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]:
|
||
|
|
"""Menu nouveau / existant au lancement."""
|
||
|
|
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 DES DOCUMENTS DE CONTEXTE (nouveau v5)
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def _read_pdf(path: Path) -> str:
|
||
|
|
try:
|
||
|
|
from pypdf import PdfReader
|
||
|
|
reader = PdfReader(str(path))
|
||
|
|
return "\n".join((pg.extract_text() or "") for pg in reader.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
|
||
|
|
doc = docx.Document(str(path))
|
||
|
|
return "\n".join(p.text for p in doc.paragraphs)
|
||
|
|
except Exception as e:
|
||
|
|
warn(f"DOCX illisible ({path.name}) : {e}")
|
||
|
|
return ""
|
||
|
|
|
||
|
|
|
||
|
|
def read_context_documents(proj: Project) -> str:
|
||
|
|
"""Lit tous les documents de inputs/ et retourne un bloc de contexte."""
|
||
|
|
if not proj.inputs.is_dir():
|
||
|
|
return ""
|
||
|
|
files = [f for f in sorted(proj.inputs.iterdir())
|
||
|
|
if f.is_file() and not f.name.startswith(".")]
|
||
|
|
if not files:
|
||
|
|
return ""
|
||
|
|
|
||
|
|
print()
|
||
|
|
info(f"{len(files)} document(s) trouvé(s) dans inputs/ :")
|
||
|
|
for f in files:
|
||
|
|
print(f" - {f.name}")
|
||
|
|
rep = ask("Analyser ces documents comme contexte ? (O/n) :").lower()
|
||
|
|
if rep in ("n", "non", "no"):
|
||
|
|
return ""
|
||
|
|
|
||
|
|
chunks = []
|
||
|
|
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)")
|
||
|
|
|
||
|
|
if not chunks:
|
||
|
|
return ""
|
||
|
|
context = "\n\n".join(chunks)
|
||
|
|
if len(context) > CONTEXT_MAX_CHARS:
|
||
|
|
warn(f"Contexte tronqué à {CONTEXT_MAX_CHARS} caractères "
|
||
|
|
f"(total {len(context)}).")
|
||
|
|
context = context[:CONTEXT_MAX_CHARS] + "\n[...contexte tronqué...]"
|
||
|
|
return context
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# MANIFEST DE SESSION
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
class Manifest:
|
||
|
|
def __init__(self, brief: str, proj: Project, has_context: bool):
|
||
|
|
self.data = {
|
||
|
|
"version": VERSION,
|
||
|
|
"demarrage": datetime.now().isoformat(timespec="seconds"),
|
||
|
|
"projet": proj.name,
|
||
|
|
"brief": brief,
|
||
|
|
"contexte_documentaire": has_context,
|
||
|
|
"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):
|
||
|
|
"""path = chemin COMPLET du fichier manifest (fix double-output)."""
|
||
|
|
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 — retry + pagination
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
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_response = ""
|
||
|
|
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_response += content
|
||
|
|
if "PAUSE" in content and "FIN —" not in content:
|
||
|
|
info(f"Pagination détectée (page {page + 1}) → continuation...")
|
||
|
|
current.append({"role": "assistant", "content": content})
|
||
|
|
current.append({"role": "user", "content": "continue"})
|
||
|
|
else:
|
||
|
|
return full_response
|
||
|
|
warn(f"Garde pagination atteinte ({MAX_PAGES} pages) — "
|
||
|
|
"sortie tronquée possible.")
|
||
|
|
return full_response
|
||
|
|
|
||
|
|
|
||
|
|
class AgentSession:
|
||
|
|
def __init__(self, agent_id: str):
|
||
|
|
self.agent_id = agent_id
|
||
|
|
self.messages: list = []
|
||
|
|
|
||
|
|
def start(self, initial: str) -> str:
|
||
|
|
self.messages = [{"role": "user", "content": initial}]
|
||
|
|
out = call_agent(self.agent_id, self.messages)
|
||
|
|
self.messages.append({"role": "assistant", "content": out})
|
||
|
|
return out
|
||
|
|
|
||
|
|
def feedback(self, fb: str) -> str:
|
||
|
|
self.messages.append({"role": "user", "content": fb})
|
||
|
|
out = call_agent(self.agent_id, self.messages)
|
||
|
|
self.messages.append({"role": "assistant", "content": out})
|
||
|
|
return out
|
||
|
|
|
||
|
|
def reset(self, initial: str) -> str:
|
||
|
|
return self.start(initial)
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# VALIDATION YAML
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def extract_yaml(raw: str) -> str:
|
||
|
|
match = re.search(r"```ya?ml\s*(.*?)```", raw, re.DOTALL | re.IGNORECASE)
|
||
|
|
content = match.group(1).strip() if match else raw.strip()
|
||
|
|
return re.sub(r"[─]+", "-", content)
|
||
|
|
|
||
|
|
|
||
|
|
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, "Le YAML 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} : clé 'layout' manquante")
|
||
|
|
continue
|
||
|
|
if layout not in valid:
|
||
|
|
errors.append(f"Slide {pos} : layout '{layout}' inconnu "
|
||
|
|
f"(valides : {', '.join(sorted(valid))})")
|
||
|
|
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}) : champ requis "
|
||
|
|
f"'{field}' manquant ou vide")
|
||
|
|
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 save_text(content: str, path: Path) -> Path:
|
||
|
|
path.write_text(content, encoding="utf-8")
|
||
|
|
ok(f"Sauvegardé : {path}")
|
||
|
|
return path
|
||
|
|
|
||
|
|
|
||
|
|
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", {})
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# ARCHIVAGE TRILIUM (non bloquant)
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def archive_to_trilium(brief, pptx_path, manifest_path, proj):
|
||
|
|
if not (TRILIUM_API_URL and TRILIUM_API_KEY):
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
detail = f"Projet : {proj.name} | Brief : {brief[:200]}"
|
||
|
|
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("Génération archivée dans Trilium.") if resp.ok else \
|
||
|
|
warn(f"Trilium : réponse {resp.status_code} (non bloquant)")
|
||
|
|
except requests.RequestException as e:
|
||
|
|
warn(f"Trilium injoignable ({e}) — archivage ignoré.")
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# ÉTAPE 1 — NARRATOR (avec contexte documentaire)
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def build_narrator_input(brief: str, context: str) -> str:
|
||
|
|
if context:
|
||
|
|
return (f"{brief}\n\n---\nCONTEXTE DOCUMENTAIRE FOURNI "
|
||
|
|
f"(à utiliser pour enrichir et cadrer la présentation) :\n\n"
|
||
|
|
f"{context}")
|
||
|
|
return brief
|
||
|
|
|
||
|
|
|
||
|
|
def run_narrator(session, brief, context, proj, first_output=None):
|
||
|
|
section("ÉTAPE 1 — THE NARRATOR")
|
||
|
|
if first_output is None:
|
||
|
|
if context:
|
||
|
|
info("Génération du Markdown narratif (avec contexte documentaire)...")
|
||
|
|
else:
|
||
|
|
info("Génération du Markdown narratif...")
|
||
|
|
output = session.start(build_narrator_input(brief, context))
|
||
|
|
ok(f"Markdown généré ({len(output)} caractères)")
|
||
|
|
else:
|
||
|
|
output = first_output
|
||
|
|
|
||
|
|
while True:
|
||
|
|
print()
|
||
|
|
display_markdown(output)
|
||
|
|
print("\n [1] Valider → étape suivante")
|
||
|
|
print(" [2] Donner du feedback au Narrator")
|
||
|
|
print(" [3] Régénérer complètement")
|
||
|
|
print(" [4] Afficher le Markdown complet")
|
||
|
|
print(" [5] Sauvegarder le Markdown")
|
||
|
|
choix = ask("Votre choix :")
|
||
|
|
if choix == "1":
|
||
|
|
ok("Markdown approuvé.")
|
||
|
|
return output
|
||
|
|
elif choix == "2":
|
||
|
|
fb = ask("Votre feedback :")
|
||
|
|
if fb:
|
||
|
|
info("Envoi du feedback au Narrator...")
|
||
|
|
output = session.feedback(fb)
|
||
|
|
ok(f"Nouveau Markdown ({len(output)} caractères)")
|
||
|
|
elif choix == "3":
|
||
|
|
info("Régénération depuis le brief...")
|
||
|
|
output = session.reset(build_narrator_input(brief, context))
|
||
|
|
ok(f"Nouveau Markdown ({len(output)} caractères)")
|
||
|
|
elif choix == "4":
|
||
|
|
print("\n" + textwrap.indent(output, " "))
|
||
|
|
elif choix == "5":
|
||
|
|
save_text(output, proj.out("narrator_draft", "md"))
|
||
|
|
else:
|
||
|
|
warn("Choix invalide. Entrez 1 à 5.")
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# ÉTAPE 2 — DESIGNER
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def run_designer(session, markdown, proj):
|
||
|
|
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 (modifier le Markdown)")
|
||
|
|
choix = ask("Votre choix :")
|
||
|
|
if choix == "1":
|
||
|
|
ok("Plan approuvé. Passage à l'Encoder...")
|
||
|
|
save_text(output, proj.out("designer", "txt"))
|
||
|
|
return output
|
||
|
|
elif choix == "2":
|
||
|
|
fb = ask("Votre feedback :")
|
||
|
|
if fb:
|
||
|
|
info("Envoi du feedback 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. Entrez 0 à 4.")
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# ÉTAPE 3 — ENCODER
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def run_encoder(session, plan, layouts, proj):
|
||
|
|
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"Le YAML que tu as généré contient des erreurs :\n\n{message}\n\n"
|
||
|
|
f"Corrige ces erreurs et renvoie le YAML COMPLET corrigé, "
|
||
|
|
f"sans aucun texte avant ou après.")
|
||
|
|
ok(f"YAML corrigé reçu ({len(output)} caractères)")
|
||
|
|
|
||
|
|
print("\n [1] Accepter le YAML tel quel (avec erreurs)")
|
||
|
|
print(" [2] Donner un feedback manuel à l'Encoder")
|
||
|
|
print(" [0] Abandonner et 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 path in [THEME_PATH, COMPONENTS_PATH, LAYOUTS_PATH]:
|
||
|
|
if not os.path.exists(path):
|
||
|
|
warn(f"Fichier de config manquant : {path}")
|
||
|
|
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 dans render_engine_v2.py :")
|
||
|
|
print(textwrap.indent(result.stderr or result.stdout, " "))
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# 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")
|
||
|
|
|
||
|
|
# Sélection du projet (une fois par lancement)
|
||
|
|
section("PROJET")
|
||
|
|
proj = select_project()
|
||
|
|
if proj is None:
|
||
|
|
print(" Au revoir.")
|
||
|
|
return
|
||
|
|
|
||
|
|
while True:
|
||
|
|
# Contexte documentaire (inputs/)
|
||
|
|
context = read_context_documents(proj)
|
||
|
|
|
||
|
|
brief = ask("Votre brief (ou 'projet' pour changer, 'quit') :")
|
||
|
|
if brief.lower() in ("quit", "exit", "q"):
|
||
|
|
print(" Au revoir.")
|
||
|
|
break
|
||
|
|
if brief.lower() == "projet":
|
||
|
|
section("PROJET")
|
||
|
|
np = select_project()
|
||
|
|
if np:
|
||
|
|
proj = np
|
||
|
|
continue
|
||
|
|
if not brief:
|
||
|
|
continue
|
||
|
|
|
||
|
|
express = ask("Mode express — sans Designer ? (o/N) :").lower() \
|
||
|
|
in ("o", "oui", "y", "yes")
|
||
|
|
if express:
|
||
|
|
info("Mode express : Narrator → Encoder direct.")
|
||
|
|
|
||
|
|
manifest = Manifest(brief, proj, bool(context))
|
||
|
|
narrator = AgentSession(NARRATOR_ID)
|
||
|
|
markdown = run_narrator(narrator, brief, context, proj)
|
||
|
|
if not markdown:
|
||
|
|
continue
|
||
|
|
save_text(markdown, proj.out("narrator", "md"))
|
||
|
|
manifest.step("narrator", caracteres=len(markdown),
|
||
|
|
contexte=bool(context))
|
||
|
|
|
||
|
|
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:
|
||
|
|
markdown = run_narrator(narrator, brief, context, proj,
|
||
|
|
first_output=markdown)
|
||
|
|
if not markdown:
|
||
|
|
break
|
||
|
|
save_text(markdown, proj.out("narrator", "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}")
|
||
|
|
# FIX : chemin complet du manifest (plus de double output/)
|
||
|
|
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(brief, pptx_path, manifest_path, proj)
|
||
|
|
|
||
|
|
print()
|
||
|
|
if ask("Nouvelle présentation ? (o/n) :").lower() \
|
||
|
|
not in ("o", "oui", "y", "yes"):
|
||
|
|
print(" Au revoir.")
|
||
|
|
break
|
||
|
|
|
||
|
|
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
# POINT D'ENTRÉE
|
||
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(
|
||
|
|
description="Sliding facilitator v3 — pipeline par projet + render v2")
|
||
|
|
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()
|