#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ encoder_schema.py — Sliding Pipeline v11 · Chantier C1 (Encoder structuré) ========================================================================== Remplace l'agent Encoder par des appels chat/completions en structured outputs stricts (response_format: json_schema, strict: true), slide par slide. La sortie ne peut structurellement pas être invalide ; plus de pagination PAUSE, plus d'extract_yaml par regex, échecs isolés par slide. Entrée : le plan Markdown annoté produit par le Designer (blocs "SLIDE N — layout_name" suivis du contenu). Sortie : dict {"titre_presentation": ..., "slides": [...]} + YAML. Usage module (depuis le facilitator) : from encoder_schema import encode_plan, to_yaml data, usage, errors = encode_plan(plan, api_key=API_KEY) yaml_str = to_yaml(data) Usage CLI (test autonome sur le NAS, single-line) : python3 encoder_schema.py plan_designer.txt > input.yaml Config .env : ENCODER_MODEL (défaut mistral-small-latest). Python 3.9. Dépendances : requests, pyyaml (déjà dans le venv pipeline). """ import json import os import re import sys import time import requests from schemas import get_schema, known_layouts API_URL = "https://api.mistral.ai/v1/chat/completions" DEFAULT_MODEL = os.getenv("ENCODER_MODEL", "mistral-small-latest") MAX_RETRY = 4 TIMEOUT = 120 SYSTEM_PROMPT = ( "Tu es un transcripteur de contenu de slide. On te donne la description " "d'UNE slide et tu produis le JSON de son contenu, conforme au schéma " "imposé. Règles absolues :\n" "- Fidélité totale au contenu fourni : ne rien inventer, ne rien omettre " "d'important ; condenser légèrement si nécessaire.\n" "- Texte brut uniquement : aucun balisage Markdown (pas de **, __, #, `, " "ni tirets de liste dans les valeurs).\n" "- Français, ton affirmatif, guillemets typographiques évités.\n" "- Champs optionnels sans contenu correspondant : null.\n" "- Les nombres des champs numériques (start, end, x, y, numero) sont des " "entiers, pas des chaînes.\n" "- Le champ notes est réservé aux notes du présentateur explicitement " "marquées (« Notes : ... ») ; sinon notes = null.\n" "- Les lignes de justification du Designer (commençant par → ou " "expliquant le choix du layout) ne sont NI du contenu NI des notes : " "ignore-les." ) # "SLIDE 12 — layout_name" (tiret cadratin, demi-cadratin ou simple) SLIDE_RE = re.compile( r"^\s*SLIDE\s+(\d+)\s*[—–\-]+\s*([a-z][a-z0-9_]*)\s*$", re.MULTILINE) MD_PATTERNS = [ (re.compile(r"\*\*(.+?)\*\*"), r"\1"), (re.compile(r"__(.+?)__"), r"\1"), (re.compile(r"`([^`]*)`"), r"\1"), ] # ── Découpage du plan ──────────────────────────────────────────────────────── def split_plan(plan_md): """Découpe le plan Designer en slides. Retourne (segments, errors) ; segment = {position, layout, content}.""" matches = list(SLIDE_RE.finditer(plan_md)) segments, errors = [], [] if not matches: return [], ["Aucune ligne 'SLIDE N — layout' détectée dans le plan."] valid = set(known_layouts()) for i, m in enumerate(matches): end = matches[i + 1].start() if i + 1 < len(matches) else len(plan_md) layout = m.group(2) seg = { "position": int(m.group(1)), "layout": layout, "content": plan_md[m.start():end].strip(), } if layout not in valid: errors.append("Slide %d : layout '%s' sans schéma (connus : %s)" % (seg["position"], layout, ", ".join(sorted(valid)))) continue segments.append(seg) return segments, errors # ── Appel API ──────────────────────────────────────────────────────────────── def _post_with_retry(payload, api_key): headers = {"Authorization": "Bearer %s" % api_key, "Content-Type": "application/json"} last_err = None for attempt in range(1, MAX_RETRY + 1): try: resp = requests.post(API_URL, headers=headers, json=payload, timeout=TIMEOUT) if resp.status_code == 429 or resp.status_code >= 500: wait = 2 ** attempt * 3 time.sleep(wait) last_err = "HTTP %d" % resp.status_code continue resp.raise_for_status() return resp.json() except requests.RequestException as e: last_err = e time.sleep(2 ** attempt * 2) raise RuntimeError("API Mistral injoignable après %d tentatives : %s" % (MAX_RETRY, last_err)) def encode_slide(content, layout, api_key, model=None): """Encode UNE slide. Retourne (dict_contenu, usage_dict). Lève RuntimeError/ValueError en cas d'échec (isolé par l'appelant).""" schema = get_schema(layout) payload = { "model": model or DEFAULT_MODEL, "temperature": 0, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": "Layout : %s\n\nContenu de la slide :\n%s" % (layout, content)}, ], "response_format": { "type": "json_schema", "json_schema": { "name": "slide_%s" % layout, "strict": True, "schema": schema, }, }, } data = _post_with_retry(payload, api_key) raw = data["choices"][0]["message"]["content"] usage = data.get("usage", {}) or {} return json.loads(raw), usage # ── Post-traitement ────────────────────────────────────────────────────────── def drop_nulls(node): """Retire récursivement les clés à None (les optionnels non remplis).""" if isinstance(node, dict): return {k: drop_nulls(v) for k, v in node.items() if v is not None} if isinstance(node, list): return [drop_nulls(v) for v in node] return node def strip_md(node): """Défense en profondeur : retire le gras/italique/backticks résiduels. (La règle définitive côté moteur arrive au chantier C3.)""" if isinstance(node, dict): return {k: strip_md(v) for k, v in node.items()} if isinstance(node, list): return [strip_md(v) for v in node] if isinstance(node, str): out = node for pat, rep in MD_PATTERNS: out = pat.sub(rep, out) return out.strip() return node def _accumulate(total, usage): for k in ("prompt_tokens", "completion_tokens", "total_tokens"): total[k] = total.get(k, 0) + int(usage.get(k, 0) or 0) return total # ── Orchestration ──────────────────────────────────────────────────────────── def encode_plan(plan_md, api_key, model=None, only_positions=None, progress=None): """Encode tout le plan slide par slide. only_positions : iterable de positions à encoder (mode ciblé), None = tout. progress : callable(str) pour l'affichage (info du facilitator). Retourne (data, usage, errors) : data = {"titre_presentation": str|None, "slides": [...]} usage = tokens cumulés {"prompt_tokens","completion_tokens","total_tokens"} errors = liste de messages (slides en échec — absentes de data). """ def say(msg): if progress: progress(msg) segments, errors = split_plan(plan_md) if only_positions is not None: wanted = set(int(p) for p in only_positions) segments = [s for s in segments if s["position"] in wanted] slides, usage = [], {} titre_presentation = None for seg in segments: say("Slide %d (%s)..." % (seg["position"], seg["layout"])) try: content, u = encode_slide(seg["content"], seg["layout"], api_key, model) except (RuntimeError, ValueError, KeyError, json.JSONDecodeError) as e: errors.append("Slide %d (%s) : %s" % (seg["position"], seg["layout"], e)) continue _accumulate(usage, u) content = strip_md(drop_nulls(content)) if seg["layout"] == "cover_split" and titre_presentation is None: titre_presentation = content.get("titre") slide = {"position": seg["position"], "layout": seg["layout"]} slide.update(content) slides.append(slide) slides.sort(key=lambda s: s["position"]) data = {"slides": slides} if titre_presentation: data = {"titre_presentation": titre_presentation, "slides": slides} return data, usage, errors def to_yaml(data): import yaml return yaml.safe_dump(data, allow_unicode=True, sort_keys=False, default_flow_style=False, width=100) # ── CLI de test autonome ───────────────────────────────────────────────────── def _main(): from pathlib import Path try: from dotenv import load_dotenv load_dotenv() except ImportError: pass api_key = os.getenv("MISTRAL_API_KEY") if not api_key: sys.stderr.write("MISTRAL_API_KEY manquant (.env)\n") sys.exit(1) if len(sys.argv) < 2: sys.stderr.write("Usage : python3 encoder_schema.py " "[positions ex 3,5,7]\n") sys.exit(1) plan = Path(sys.argv[1]).read_text(encoding="utf-8") only = None if len(sys.argv) > 2: only = [int(p) for p in sys.argv[2].split(",") if p.strip()] data, usage, errors = encode_plan( plan, api_key, only_positions=only, progress=lambda m: sys.stderr.write(" %s\n" % m)) sys.stderr.write("Tokens : %s\n" % json.dumps(usage)) for e in errors: sys.stderr.write(" ! %s\n" % e) sys.stdout.write(to_yaml(data)) sys.exit(2 if errors else 0) if __name__ == "__main__": _main()