feat: serveur MCP Sliding avec OAuth 2.1 partage (C8)
This commit is contained in:
+286
@@ -0,0 +1,286 @@
|
||||
#!/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")}
|
||||
Reference in New Issue
Block a user