#!/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/ / 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//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()