Files
sliding-automation/sliding_api.py
T

287 lines
11 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
sliding_api.py — Sliding Pipeline · Chantier C8 (tronc commun)
==============================================================
Façade API NON-INTERACTIVE du pipeline : enveloppe facilitator_v9 sans
le modifier (aucun ask(), aucune boucle terminal) et expose des
fonctions propres, appelables par le serveur MCP (mcp_sliding.py) —
et demain par toute autre enveloppe (web app, CLI batch).
Choix d'architecture : façade plutôt que découpage big-bang du
facilitator — zéro risque de régression sur le flux terminal existant,
réversible, le vrai découpage (R12) reste possible plus tard.
Rôles : le NARRATOR n'est PAS ici — c'est le client MCP (Claude/Le
Chat) qui joue ce rôle et pilote le plan. La façade couvre :
projets, documents/assets, plan compact, formalisation (Designer
Mistral → Encoder structuré → rendu), révision ciblée fusionnée,
preview PNG.
Python 3.9. S'exécute depuis le dossier du pipeline (imports locaux).
"""
import base64
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Optional
import facilitator_v9 as fac
import encoder_schema as enc
# ── Projets ──────────────────────────────────────────────────────────────────
def list_projects():
"""Projets existants avec leur état sommaire."""
root = Path(fac.PROJECTS_DIR)
out = []
if not root.is_dir():
return out
for p in sorted(root.iterdir()):
if not p.is_dir() or not (p / "project_state.json").exists():
continue
try:
state = json.loads((p / "project_state.json")
.read_text(encoding="utf-8"))
except Exception:
state = {}
out.append({
"slug": p.name,
"nom": state.get("nom", p.name),
"sessions": state.get("nb_sessions", 0),
"a_un_plan": bool(state.get("dernier_plan_compact")),
"dernier_pptx": state.get("dernier_pptx") or None,
"maj": state.get("derniere_session") or None,
})
return out
def _project(slug_ou_nom: str) -> "fac.Project":
proj = fac.Project(slug_ou_nom)
if not proj.exists():
raise ValueError(f"Projet introuvable : {slug_ou_nom} "
f"(slug essayé : {proj.slug})")
return proj
def create_project(nom: str):
proj = fac.Project(nom)
existed = proj.exists()
proj.ensure()
(proj.root / "assets").mkdir(exist_ok=True)
if not existed:
proj.log(f"Projet créé via API le "
f"{datetime.now().strftime('%d/%m/%Y %H:%M')}")
return {"slug": proj.slug, "nom": nom,
"status": "existant" if existed else "cree"}
def get_project(slug: str):
"""État complet : plan compact, derniers artefacts, journal."""
proj = _project(slug)
state = proj.load_state()
journal = ""
if proj.journal_file.exists():
lines = proj.journal_file.read_text(
encoding="utf-8").splitlines()
journal = "\n".join(lines[-15:])
docs = sorted(f.name for f in proj.inputs.iterdir()
if f.is_file()) if proj.inputs.is_dir() else []
return {
"slug": proj.slug,
"plan_compact": state.get("dernier_plan_compact", ""),
"dernier_markdown": bool(state.get("dernier_markdown")),
"dernier_yaml": state.get("dernier_yaml") or None,
"dernier_pptx": state.get("dernier_pptx") or None,
"sessions": state.get("nb_sessions", 0),
"documents": docs,
"assets": _asset_names(proj),
"journal_recent": journal,
}
# ── Plan compact ─────────────────────────────────────────────────────────────
def get_plan(slug: str):
proj = _project(slug)
return {"plan_compact": proj.load_state().get(
"dernier_plan_compact", "")}
def set_plan(slug: str, plan_compact: str):
"""Persiste le plan compact — même mécanisme que le facilitator
(source de vérité, rechargée à l'identique en session terminal)."""
proj = _project(slug)
proj.save_plan_compact(plan_compact)
proj.log("Plan compact mis à jour via MCP.")
return {"slug": proj.slug, "status": "plan sauvegardé",
"longueur": len(plan_compact)}
# ── Documents & assets ───────────────────────────────────────────────────────
def add_document(slug: str, filename: str, content: str,
encoding: str = "text"):
"""Dépose un document dans inputs/ (brief, notes…).
encoding='text' (défaut) ou 'base64' pour les binaires."""
proj = _project(slug)
safe = re.sub(r"[^A-Za-z0-9._-]", "_", filename)[:120]
path = proj.inputs / safe
if encoding == "base64":
path.write_bytes(base64.b64decode(content))
else:
path.write_text(content, encoding="utf-8")
return {"slug": proj.slug, "fichier": safe,
"octets": path.stat().st_size}
def _asset_names(proj):
assets = proj.root / "assets"
if not assets.is_dir():
return []
exts = {".png", ".jpg", ".jpeg", ".webp"}
return sorted(p.name for p in assets.iterdir()
if p.suffix.lower() in exts)
def list_project_assets(slug: str):
proj = _project(slug)
info = fac.list_assets(proj) # C4 : noms + dimensions
return {"slug": proj.slug, "assets": info or "aucune image"}
# ── Formalisation : Designer → Encoder → rendu ───────────────────────────────
def formalise(slug: str, plan_markdown: str):
"""Chaîne complète non-interactive :
Designer Mistral (annotation layouts) → Encoder structuré (C1) →
validation → rendu → persistance d'état. Retourne les chemins et
les compteurs. Le plan_markdown = la formalisation produite par le
Narrator-client (Claude/Le Chat)."""
proj = _project(slug)
layouts = fac.load_layouts()
designer = fac.AgentSession(fac.DESIGNER_ID)
annotated = designer.start(plan_markdown)
fac.save_text(annotated, proj.out("designer_annote", "md"))
data, usage, errors = enc.encode_plan(annotated, fac.API_KEY)
if not data.get("slides"):
return {"error": "Aucune slide encodée",
"encoder_errors": errors}
yaml_str = enc.to_yaml(data)
is_valid, message, _ = fac.validate_yaml(yaml_str, layouts)
yaml_path = fac.save_text(yaml_str, proj.out("input", "yaml"))
pptx_path = fac.run_render(yaml_path, proj.root / "assets")
fac.persist_generation(
proj, markdown=plan_markdown, yaml_path=yaml_path,
pptx_path=pptx_path,
journal_entry="Deck formalisé via MCP (Designer + Encoder "
"structuré).")
return {
"slug": proj.slug,
"slides": len(data["slides"]),
"validation": message if is_valid else f"{message}",
"encoder_errors": errors,
"tokens_encoder": usage.get("total_tokens", 0),
"yaml": str(yaml_path),
"pptx": str(pptx_path) if pptx_path else None,
}
def revise(slug: str, positions, instruction: str):
"""Révision ciblée fusionnée (C3) : le Designer retravaille les
slides indiquées à partir du dernier markdown, l'Encoder les
transcrit, merge_revision reconstruit et re-rend le deck COMPLET."""
proj = _project(slug)
state = proj.load_state()
last_md = state.get("dernier_markdown", "")
if not last_md:
return {"error": "Pas de markdown précédent — formalise "
"d'abord."}
positions = sorted({int(p) for p in positions})
pos_txt = ", ".join(str(p) for p in positions)
scope = (f"RÉVISION CIBLÉE — Ne traite QUE les slides {pos_txt}. "
f"Conserve leur numérotation d'origine (SLIDE N — layout)."
f" Instruction : {instruction}")
designer = fac.AgentSession(fac.DESIGNER_ID)
annotated = designer.start(f"{last_md}\n\n---\n{scope}")
fac.save_text(annotated, proj.out("designer_revision", "md"))
data, usage, errors = enc.encode_plan(
annotated, fac.API_KEY, only_positions=positions)
if not data.get("slides"):
return {"error": "Aucune slide encodée",
"encoder_errors": errors}
partial_path = fac.save_text(enc.to_yaml(data),
proj.out("revision_ciblee", "yaml"))
fused = fac.merge_revision(proj, partial_path)
target = fused or partial_path
pptx_path = fac.run_render(target, proj.root / "assets")
if fused:
fac.persist_generation(
proj, yaml_path=fused, pptx_path=pptx_path,
journal_entry=f"Révision MCP des slides {pos_txt} — deck "
f"complet régénéré.")
return {
"slug": proj.slug,
"slides_revisees": positions,
"fusion": bool(fused),
"encoder_errors": errors,
"pptx": str(pptx_path) if pptx_path else None,
}
# ── Rendu direct & preview ───────────────────────────────────────────────────
def render_yaml(slug: str, yaml_str: str):
"""Rendu direct d'un YAML fourni (équivalent --render)."""
proj = _project(slug)
layouts = fac.load_layouts()
is_valid, message, _ = fac.validate_yaml(yaml_str, layouts)
yaml_path = fac.save_text(yaml_str, proj.out("input", "yaml"))
pptx_path = fac.run_render(yaml_path, proj.root / "assets")
return {"slug": proj.slug,
"validation": message if is_valid else f"{message}",
"pptx": str(pptx_path) if pptx_path else None}
def preview(slug: str, pptx: Optional[str] = None):
"""Aperçus PNG (bloquant, quelques minutes sur le NAS).
pptx=None → dernier PPTX du projet."""
proj = _project(slug)
target = Path(pptx) if pptx else Path(
proj.load_state().get("dernier_pptx") or "")
if not target or not target.exists():
return {"error": "PPTX introuvable — formalise ou précise le "
"chemin."}
ok = fac.run_preview(target, wait=True)
if not ok:
return {"error": "La génération des aperçus a échoué (voir "
"logs NAS)."}
prev_dir = target.parent / f"{target.stem}_previews"
pngs = sorted(prev_dir.glob("slide-*.png"))
return {"slug": proj.slug, "dossier": str(prev_dir),
"slides": [p.name for p in pngs]}
def get_slide_image(slug: str, position: int,
pptx: Optional[str] = None):
"""Retourne le PNG (base64) d'une slide du dernier preview —
consommé par le tool MCP qui le renvoie en content type image."""
proj = _project(slug)
target = Path(pptx) if pptx else Path(
proj.load_state().get("dernier_pptx") or "")
prev_dir = target.parent / f"{target.stem}_previews"
png = prev_dir / f"slide-{int(position):02d}.png"
if not png.exists():
return {"error": f"{png.name} introuvable — lance preview "
f"d'abord."}
return {"filename": png.name, "mime": "image/png",
"b64": base64.b64encode(png.read_bytes()).decode("ascii")}