From 2de1b1105270960db0f17b8eedca1f84b35c04ae Mon Sep 17 00:00:00 2001 From: Master Date: Thu, 9 Jul 2026 21:50:40 +0200 Subject: [PATCH] feat: catalogue data vague 1 - charts, waterfall, heatmap, funnel, agenda, pyramid (C5) --- patch_layouts_c5a.py | 120 +++++++++++++ patch_layouts_c5b.py | 123 +++++++++++++ patch_layouts_c5c.py | 104 +++++++++++ patch_render_engine_c5a.py | 327 ++++++++++++++++++++++++++++++++++ patch_render_engine_c5b.py | 349 +++++++++++++++++++++++++++++++++++++ patch_render_engine_c5c.py | 197 +++++++++++++++++++++ 6 files changed, 1220 insertions(+) create mode 100644 patch_layouts_c5a.py create mode 100644 patch_layouts_c5b.py create mode 100644 patch_layouts_c5c.py create mode 100644 patch_render_engine_c5a.py create mode 100644 patch_render_engine_c5b.py create mode 100644 patch_render_engine_c5c.py diff --git a/patch_layouts_c5a.py b/patch_layouts_c5a.py new file mode 100644 index 0000000..3cdc6e1 --- /dev/null +++ b/patch_layouts_c5a.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_layouts_c5a.py — Chantier C5 · lot 1 (catalogue : charts natifs) +====================================================================== +Ajoute bar_chart (L42), line_chart (L43) et donut_split (L44) à +layouts_v2.yaml par APPEND sécurisé (même mécanique que le patch C4 : +parse avant, contrôle de fin de bloc, backup, re-parse, restauration +automatique en cas d'échec — le fichier n'est jamais reformaté). + +Après ce patch, propager : python3 prompt_injection_v2.py && python3 +build_gallery.py + +Usage (dossier du pipeline, single-line) : + python3 patch_layouts_c5a.py +""" + +import shutil +import sys +from pathlib import Path + +import yaml + +TARGET = Path("layouts_v2.yaml") + +FRAGMENT = """ + # ── Chantier C5 lot 1 — charts natifs (éditables dans PowerPoint) ── + bar_chart: + id: L42 + famille: Données + mode: light + champs: [titre, categories, series, unite, source, horizontal] + champs_requis: [titre, categories, series] + agent_hint: >- + Comparaison de valeurs par catégories — graphique NATIF éditable. + Max 8 catégories × 3 séries. series = liste de {label, values} ; + values = nombres SANS guillemets, alignés sur categories. + horizontal: true pour des barres (libellés longs). unite (ex M€) + et source optionnels. Couleurs imposées : navy, coral, glacier. + + line_chart: + id: L43 + famille: Données + mode: light + champs: [titre, points_x, series, unite, source] + champs_requis: [titre, points_x, series] + agent_hint: >- + Évolution temporelle — graphique NATIF éditable. Max 12 points × + 3 séries. points_x = libellés d'axe (mois, années...) ; series = + {label, values}, nombres sans guillemets. Le dernier point de la + première série est automatiquement mis en valeur (corail). + + donut_split: + id: L44 + famille: Données + mode: light + champs: [titre, segments, valeur_centrale, source] + champs_requis: [titre, segments] + agent_hint: >- + Répartition d'un tout : anneau à gauche + légende détaillée à + droite. 2 à 6 segments = {label, valeur} (nombres sans + guillemets). valeur_centrale (optionnelle) s'affiche au centre de + l'anneau (ex : total « 120 M€ »). Couleurs = cycle PR imposé. +""" + + +def fail(msg): + print(" ! %s" % msg) + sys.exit(1) + + +def main(): + if not TARGET.exists(): + fail("%s introuvable — lancer depuis le dossier du pipeline." + % TARGET) + raw = TARGET.read_text(encoding="utf-8") + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as e: + fail("layouts_v2.yaml actuel illisible : %s" % e) + layouts = (data or {}).get("layouts") + if not isinstance(layouts, dict): + fail("Bloc layouts: introuvable dans le fichier.") + for key in ("bar_chart", "line_chart", "donut_split"): + if key in layouts: + fail("'%s' déjà présent — rien à faire." % key) + + last = next((l for l in reversed(raw.splitlines()) + if l.strip() and not l.strip().startswith("#")), "") + if not last.startswith(" "): + fail("Le fichier ne se termine pas dans le bloc layouts: " + "(dernière ligne non indentée : %r) — fusion manuelle " + "requise." % last[:40]) + + shutil.copy2(TARGET, str(TARGET) + ".bak-c5a") + print(" + Sauvegarde : %s.bak-c5a" % TARGET) + + with open(TARGET, "a", encoding="utf-8") as f: + if not raw.endswith("\n"): + f.write("\n") + f.write(FRAGMENT) + + try: + check = yaml.safe_load(TARGET.read_text(encoding="utf-8")) + nl = check["layouts"] + assert all(k in nl for k in ("bar_chart", "line_chart", + "donut_split")) + assert len(nl) == len(layouts) + 3 + except Exception as e: + shutil.copy2(str(TARGET) + ".bak-c5a", TARGET) + fail("Contrôle post-append échoué (%s) — fichier restauré." % e) + + print(" + bar_chart (L42), line_chart (L43), donut_split (L44) " + "ajoutés (%d layouts au total)." % (len(layouts) + 3)) + print("\n Propager : python3 prompt_injection_v2.py && python3 " + "build_gallery.py") + + +if __name__ == "__main__": + main() diff --git a/patch_layouts_c5b.py b/patch_layouts_c5b.py new file mode 100644 index 0000000..2927746 --- /dev/null +++ b/patch_layouts_c5b.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_layouts_c5b.py — Chantier C5 lot 2 (catalogue : waterfall, +heatmap_table, funnel) +================================================================ +Append sécurisé à layouts_v2.yaml (même mécanique verrouillée que C4 : +parse avant, contrôle du bloc layouts:, .bak, re-parse, restauration +auto). Après ce patch, propager : python3 prompt_injection_v2.py && +python3 build_gallery.py. + +Usage (dossier du pipeline, single-line) : + python3 patch_layouts_c5b.py +""" + +import shutil +import sys +from pathlib import Path + +import yaml + +TARGET = Path("layouts_v2.yaml") +NEW_KEYS = ("waterfall", "heatmap_table", "funnel") + +FRAGMENT = """ + # ── Chantier C5 lot 2 — data & structure ────────────────────────── + waterfall: + id: L45 + famille: Données + mode: light + champs: [titre, depart, marches, arrivee, unite, source] + champs_requis: [titre, depart, marches, arrivee] + agent_hint: >- + Pont de valeur (waterfall) : expliquer un écart entre deux + montants par des variations successives. depart et arrivee = + {label, valeur} ; marches = liste de {label, delta} SIGNÉ + (positif ou négatif, max 8). Le moteur calcule les cumuls — + ne jamais fournir de cumul. Idéal pour : évolution de budget, + pont d'effectifs, décomposition d'un résultat. + + heatmap_table: + id: L46 + famille: Comparaison + mode: light + champs: [titre, headers, rows, legende] + champs_requis: [titre, headers, rows] + agent_hint: >- + Tableau à intensité : évaluer plusieurs items sur plusieurs + critères. headers = colonnes (max 6) ; rows = {label, + scores} avec score ENTIER de 0 (faible) à 4 (fort), max + 8 lignes. Le moteur traduit chaque score en teinte de navy — + aucune autre donnée. Idéal pour : maturité, couverture + fonctionnelle, cartographie de risques. + + funnel: + id: L47 + famille: Process + mode: light + champs: [titre, etapes, source] + champs_requis: [titre, etapes] + agent_hint: >- + Entonnoir de conversion : volumes décroissants d'étape en + étape. etapes = {label, valeur, description?} du haut vers le + bas, 3 à 5 étages. Largeurs proportionnelles aux valeurs + (plancher de lisibilité), dernier étage corail. Idéal pour : + pipeline commercial, adoption, qualification progressive. +""" + + +def fail(msg): + print(" ! %s" % msg) + sys.exit(1) + + +def main(): + if not TARGET.exists(): + fail("%s introuvable — lancer depuis le dossier du pipeline." + % TARGET) + raw = TARGET.read_text(encoding="utf-8") + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as e: + fail("layouts_v2.yaml actuel illisible : %s" % e) + layouts = (data or {}).get("layouts") + if not isinstance(layouts, dict): + fail("Bloc layouts: introuvable dans le fichier.") + for key in NEW_KEYS: + if key in layouts: + fail("'%s' déjà présent — rien à faire." % key) + + last = next((l for l in reversed(raw.splitlines()) + if l.strip() and not l.strip().startswith("#")), "") + if not last.startswith(" "): + fail("Le fichier ne se termine pas dans le bloc layouts: " + "(dernière ligne non indentée : %r) — fusion manuelle " + "requise." % last[:40]) + + shutil.copy2(TARGET, str(TARGET) + ".bak-c5b") + print(" + Sauvegarde : %s.bak-c5b" % TARGET) + + with open(TARGET, "a", encoding="utf-8") as f: + if not raw.endswith("\n"): + f.write("\n") + f.write(FRAGMENT) + + try: + check = yaml.safe_load(TARGET.read_text(encoding="utf-8")) + nl = check["layouts"] + assert all(k in nl for k in NEW_KEYS) + assert len(nl) == len(layouts) + len(NEW_KEYS) + except Exception as e: + shutil.copy2(str(TARGET) + ".bak-c5b", TARGET) + fail("Contrôle post-append échoué (%s) — fichier restauré." % e) + + print(" + waterfall (L45), heatmap_table (L46), funnel (L47) " + "ajoutés (%d layouts au total)." % (len(layouts) + + len(NEW_KEYS))) + print("\n Propager : python3 prompt_injection_v2.py && python3 " + "build_gallery.py") + + +if __name__ == "__main__": + main() diff --git a/patch_layouts_c5c.py b/patch_layouts_c5c.py new file mode 100644 index 0000000..ce9f36a --- /dev/null +++ b/patch_layouts_c5c.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_layouts_c5c.py — Chantier C5 lot 3 (catalogue : agenda, pyramid) +====================================================================== +Append sécurisé à layouts_v2.yaml (mécanique verrouillée C4/C5b). +Après ce patch, propager : python3 prompt_injection_v2.py && python3 +build_gallery.py. + +Usage (dossier du pipeline, single-line) : + python3 patch_layouts_c5c.py +""" + +import shutil +import sys +from pathlib import Path + +import yaml + +TARGET = Path("layouts_v2.yaml") +NEW_KEYS = ("agenda", "pyramid") + +FRAGMENT = """ + # ── Chantier C5 lot 3 — retours v1 restylés ─────────────────────── + agenda: + id: L40 + famille: Structure + mode: light + champs: [titre, sections] + champs_requis: [titre, sections] + agent_hint: >- + Sommaire du deck : à placer en slide 2 pour toute présentation + de plus de 15 minutes. sections = liste de {label, numero?, + duree?, actif?}, 2 à 8 entrées. actif: true met la section en + corail (utile pour les rappels d'agenda en cours de deck). + duree (ex "10 min") s'affiche à droite en discret. + + pyramid: + id: L41 + famille: Concept + mode: light + champs: [titre, niveaux] + champs_requis: [titre, niveaux] + agent_hint: >- + Argumentation pyramidale : la conclusion au sommet, les + fondations à la base. niveaux = liste de {label, description?}, + EXACTEMENT 3 ou 4 niveaux, du sommet vers la base. Largeurs + d'étages fixes gérées par le moteur. Idéal pour : message clé + et ses appuis, hiérarchie stratégie/tactiques/moyens. +""" + + +def fail(msg): + print(" ! %s" % msg) + sys.exit(1) + + +def main(): + if not TARGET.exists(): + fail("%s introuvable — lancer depuis le dossier du pipeline." + % TARGET) + raw = TARGET.read_text(encoding="utf-8") + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as e: + fail("layouts_v2.yaml actuel illisible : %s" % e) + layouts = (data or {}).get("layouts") + if not isinstance(layouts, dict): + fail("Bloc layouts: introuvable dans le fichier.") + for key in NEW_KEYS: + if key in layouts: + fail("'%s' déjà présent — rien à faire." % key) + + last = next((l for l in reversed(raw.splitlines()) + if l.strip() and not l.strip().startswith("#")), "") + if not last.startswith(" "): + fail("Le fichier ne se termine pas dans le bloc layouts: — " + "fusion manuelle requise.") + + shutil.copy2(TARGET, str(TARGET) + ".bak-c5c") + print(" + Sauvegarde : %s.bak-c5c" % TARGET) + + with open(TARGET, "a", encoding="utf-8") as f: + if not raw.endswith("\n"): + f.write("\n") + f.write(FRAGMENT) + + try: + check = yaml.safe_load(TARGET.read_text(encoding="utf-8")) + nl = check["layouts"] + assert all(k in nl for k in NEW_KEYS) + assert len(nl) == len(layouts) + len(NEW_KEYS) + except Exception as e: + shutil.copy2(str(TARGET) + ".bak-c5c", TARGET) + fail("Contrôle post-append échoué (%s) — fichier restauré." % e) + + print(" + agenda (L40) et pyramid (L41) ajoutés (%d layouts au " + "total)." % (len(layouts) + len(NEW_KEYS))) + print("\n Propager : python3 prompt_injection_v2.py && python3 " + "build_gallery.py") + + +if __name__ == "__main__": + main() diff --git a/patch_render_engine_c5a.py b/patch_render_engine_c5a.py new file mode 100644 index 0000000..20e403e --- /dev/null +++ b/patch_render_engine_c5a.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_render_engine_c5a.py — Chantier C5 · lot 1 (charts natifs) +================================================================ +Patch strict de render_engine_v2.py — trois layouts de données en +graphiques NATIFS python-pptx (éditables dans PowerPoint, données +modifiables par le lecteur — pas des images) : + + bar_chart (L42) — colonnes ou barres (horizontal: true), max + 8 catégories × 3 séries, axe des valeurs masqué, + étiquettes de valeurs affichées, style consulting. + line_chart (L43) — courbes à marqueurs, max 12 points × 3 séries, + gridlines très claires, dernier point de la + série 1 étiqueté en corail gras (règle fixe). + donut_split (L44) — anneau (60 % gauche, trou 65, segments au cycle + PR) + valeur centrale + légende détaillée custom + à droite (pastille, label, valeur). + +Style PR en dur : séries navy → coral → glacier, Calibri, aucune +couleur libre. Bornes appliquées par troncature tracée (warning +console) — les schémas C1 et les prompts bornent en amont. + + P1. Imports chart python-pptx. + P2. Composants _num/_chart_base + 3 renderers (avant orchestration). + P3. REGISTRY : les 3 entrées. + +Usage (dossier du pipeline, single-line) : + python3 patch_render_engine_c5a.py +Indépendant des patchs C3/C4 (tout ordre). Vérifie chaque ancre, +écrit .bak-c5a, compile, idempotent. +""" + +import py_compile +import shutil +import sys +from pathlib import Path + +TARGET = Path("render_engine_v2.py") + +COMPONENTS = ''' # ---------------- charts natifs (C5 lot 1) ---------------- + + def _num(self, v, default=0.0): + """Nombre robuste : int/float directs, chaînes '12,5' ou '12.5'.""" + if isinstance(v, (int, float)): + return float(v) + try: + return float(str(v).replace(",", ".").replace(" ", "")) + except (TypeError, ValueError): + return float(default) + + def _chart_series_colors(self): + return [self.C["navy"], self.C["coral"], self.C["glacier"]] + + def _chart_base(self, slide, x, y, w, h, chart_type, chart_data): + """Insertion + style de base commun (police, taille, couleur).""" + gf = slide.shapes.add_chart(chart_type, Cm(x), Cm(y), + Cm(w), Cm(h), chart_data) + ch = gf.chart + ch.font.name = self.F_BODY + ch.font.size = Pt(11) + ch.font.color.rgb = hex_to_rgb(self.C["body"]) + return ch + + def _chart_caption(self, slide, d): + """Unité (droite de la zone titre) et source (bas de page).""" + unite = pick(d, "unite", "unit") + if unite: + self._text(slide, self.SLIDE_W - self.MX - 8.0, + self.TITLE_Y + 0.35, 8.0, 0.8, f"en {unite}", + size=12, italic=True, color=self.C["muted"], + align=PP_ALIGN.RIGHT) + source = pick(d, "source") + if source: + self._text(slide, self.MX, self.CONT_B - 0.55, + self.SLIDE_W - 2 * self.MX, 0.55, + f"Source : {source}", size=10, + color=self.C["muted"]) + + def _chart_zone(self, d): + """Zone du graphique (réserve la ligne source si présente).""" + h = self.CONT_H - (0.7 if pick(d, "source") else 0.0) + return self.MX, self.CONT_Y, self.SLIDE_W - 2 * self.MX, h + + def _render_bar_chart(self, slide, d): + from pptx.chart.data import CategoryChartData + from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION, + XL_LABEL_POSITION) + self._title(slide, pick(d, "titre", "title")) + self._chart_caption(slide, d) + cats = [as_label(c) for c in (d.get("categories") or [])] + series = [s for s in (d.get("series") or []) if isinstance(s, dict)] + if len(cats) > 8: + print(f" ~ bar_chart : {len(cats)} catégories → 8 (borne)") + cats = cats[:8] + if len(series) > 3: + print(f" ~ bar_chart : {len(series)} séries → 3 (borne)") + series = series[:3] + cd = CategoryChartData() + cd.categories = cats + for s in series: + vals = [self._num(v) for v in (s.get("values") or [])][:len(cats)] + vals += [0.0] * (len(cats) - len(vals)) + cd.add_series(pick(s, "label", default="Série"), tuple(vals)) + x, y, w, h = self._chart_zone(d) + horiz = bool(d.get("horizontal")) + ctype = (XL_CHART_TYPE.BAR_CLUSTERED if horiz + else XL_CHART_TYPE.COLUMN_CLUSTERED) + ch = self._chart_base(slide, x, y, w, h, ctype, cd) + plot = ch.plots[0] + plot.gap_width = 60 + if len(series) > 1: + plot.overlap = -10 + for i, ser in enumerate(ch.series): + ser.format.fill.solid() + ser.format.fill.fore_color.rgb = hex_to_rgb( + self._chart_series_colors()[i % 3]) + ch.value_axis.visible = False + ch.value_axis.has_major_gridlines = False + cat_ax = ch.category_axis + cat_ax.has_major_gridlines = False + cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"]) + cat_ax.tick_labels.font.size = Pt(11) + plot.has_data_labels = True + dls = plot.data_labels + dls.font.size = Pt(10) + dls.font.bold = True + dls.font.color.rgb = hex_to_rgb(self.C["body"]) + dls.position = (XL_LABEL_POSITION.OUTSIDE_END) + ch.has_legend = len(series) > 1 + if ch.has_legend: + ch.legend.position = XL_LEGEND_POSITION.BOTTOM + ch.legend.include_in_layout = False + ch.legend.font.size = Pt(11) + + def _render_line_chart(self, slide, d): + from pptx.chart.data import CategoryChartData + from pptx.enum.chart import (XL_CHART_TYPE, XL_LEGEND_POSITION, + XL_LABEL_POSITION, XL_MARKER_STYLE) + self._title(slide, pick(d, "titre", "title")) + self._chart_caption(slide, d) + pts = [as_label(p) for p in (d.get("points_x") or [])] + series = [s for s in (d.get("series") or []) if isinstance(s, dict)] + if len(pts) > 12: + print(f" ~ line_chart : {len(pts)} points → 12 (borne)") + pts = pts[:12] + if len(series) > 3: + print(f" ~ line_chart : {len(series)} séries → 3 (borne)") + series = series[:3] + cd = CategoryChartData() + cd.categories = pts + for s in series: + vals = [self._num(v) for v in (s.get("values") or [])][:len(pts)] + vals += [0.0] * (len(pts) - len(vals)) + cd.add_series(pick(s, "label", default="Série"), tuple(vals)) + x, y, w, h = self._chart_zone(d) + ch = self._chart_base(slide, x, y, w, h, + XL_CHART_TYPE.LINE_MARKERS, cd) + for i, ser in enumerate(ch.series): + col = hex_to_rgb(self._chart_series_colors()[i % 3]) + ser.format.line.color.rgb = col + ser.format.line.width = Pt(2.25) + ser.smooth = False + ser.marker.style = XL_MARKER_STYLE.CIRCLE + ser.marker.size = 6 + ser.marker.format.fill.solid() + ser.marker.format.fill.fore_color.rgb = col + ser.marker.format.line.fill.background() + va = ch.value_axis + va.has_major_gridlines = True + va.major_gridlines.format.line.color.rgb = hex_to_rgb( + self.C["card_alt"]) + va.tick_labels.font.size = Pt(10) + va.tick_labels.font.color.rgb = hex_to_rgb(self.C["muted"]) + va.format.line.fill.background() + cat_ax = ch.category_axis + cat_ax.has_major_gridlines = False + cat_ax.format.line.color.rgb = hex_to_rgb(self.C["slate"]) + cat_ax.tick_labels.font.size = Pt(11) + ch.has_legend = len(series) > 1 + if ch.has_legend: + ch.legend.position = XL_LEGEND_POSITION.BOTTOM + ch.legend.include_in_layout = False + ch.legend.font.size = Pt(11) + # Règle fixe : dernier point de la série 1 étiqueté corail gras + if series and pts: + try: + last = ch.series[0].points[len(pts) - 1] + dl = last.data_label + dl.position = XL_LABEL_POSITION.ABOVE + v = self._num((series[0].get("values") or [0])[ + min(len(pts), len(series[0].get("values") or [])) - 1]) + txt = ("%g" % v) + dl.text_frame.text = txt + run = dl.text_frame.paragraphs[0].runs[0] + run.font.size = Pt(12) + run.font.bold = True + run.font.color.rgb = hex_to_rgb(self.C["coral"]) + run.font.name = self.F_BODY + except Exception as e: + print(f" ~ line_chart : étiquette dernier point sautée " + f"({e})") + + def _render_donut_split(self, slide, d): + from pptx.chart.data import CategoryChartData + from pptx.enum.chart import XL_CHART_TYPE + self._title(slide, pick(d, "titre", "title")) + self._chart_caption(slide, d) + segs = [s for s in (d.get("segments") or []) if isinstance(s, dict)] + if len(segs) > 6: + print(f" ~ donut_split : {len(segs)} segments → 6 (borne)") + segs = segs[:6] + labels = [pick(s, "label", default="—") for s in segs] + vals = [self._num(pick(s, "valeur", "value")) for s in segs] + cd = CategoryChartData() + cd.categories = labels + cd.add_series("Répartition", tuple(vals)) + x, y, w, h = self._chart_zone(d) + cw = w * 0.55 + side = min(cw, h) + cx0 = x + (cw - side) / 2 + cy0 = y + (h - side) / 2 + ch = self._chart_base(slide, cx0, cy0, side, side, + XL_CHART_TYPE.DOUGHNUT, cd) + ch.has_legend = False + for i, pt in enumerate(ch.series[0].points): + pt.format.fill.solid() + pt.format.fill.fore_color.rgb = hex_to_rgb( + self.cycle[i % len(self.cycle)]) + pt.format.line.color.rgb = hex_to_rgb(self.C["white"]) + pt.format.line.width = Pt(1.5) + plot_el = ch.plots[0]._element # + hs = plot_el.find(qn("c:holeSize")) + if hs is None: + hs = plot_el.makeelement(qn("c:holeSize"), {}) + plot_el.append(hs) + hs.set("val", "65") + centre = pick(d, "valeur_centrale", "centre") + if centre: + self._text(slide, cx0, cy0 + side / 2 - 1.1, side, 2.2, + centre, font=self.F_DISPLAY, size=24, bold=True, + color=self.C["navy"], align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.MIDDLE, fit=False) + # Légende détaillée custom à droite + lx = x + cw + 1.2 + lw = w - cw - 1.2 + ih, gap = 1.35, 0.45 + tot = len(segs) * (ih + gap) - gap if segs else 0 + ly = self._cy(tot) + for i, s in enumerate(segs): + self._oval(slide, lx, ly + (ih - 0.5) / 2, 0.5, + self.cycle[i % len(self.cycle)]) + self._text(slide, lx + 1.0, ly, lw - 4.2, ih, + pick(s, "label", default="—"), + size=14, bold=True, color=self.C["body"], + anchor=MSO_ANCHOR.MIDDLE) + self._text(slide, lx + lw - 3.2, ly, 3.2, ih, + "%g" % self._num(pick(s, "valeur", "value")), + size=14, color=self.C["muted"], + align=PP_ALIGN.RIGHT, anchor=MSO_ANCHOR.MIDDLE) + ly += ih + gap + +''' + +PATCHES = [ + # P3 — REGISTRY + ( + " \"matrix_2x2\": \"_render_matrix_2x2\",\n", + " \"matrix_2x2\": \"_render_matrix_2x2\",\n" + " \"bar_chart\": \"_render_bar_chart\",\n" + " \"line_chart\": \"_render_line_chart\",\n" + " \"donut_split\": \"_render_donut_split\",\n", + ), +] + +MARKER = "_render_bar_chart" +ANCHOR_ORCH = " # ---------------- orchestration ----------------" + + +def fail(msg): + print(" ! %s" % msg) + sys.exit(1) + + +def main(): + if not TARGET.exists(): + fail("%s introuvable — lancer depuis le dossier du pipeline." + % TARGET) + content = TARGET.read_text(encoding="utf-8") + if MARKER in content: + fail("Déjà patché (_render_bar_chart présent) — rien à faire.") + + for i, (old, _) in enumerate(PATCHES, 1): + n = content.count(old) + if n == 0: + fail("Ancre du patch %d introuvable — moteur inattendu." % i) + if n > 1: + fail("Ancre du patch %d non unique (%d occurrences)." % (i, n)) + if content.count(ANCHOR_ORCH) != 1: + fail("Ancre de la section orchestration introuvable ou non " + "unique.") + + shutil.copy2(TARGET, str(TARGET) + ".bak-c5a") + print(" + Sauvegarde : %s.bak-c5a" % TARGET) + + for old, new in PATCHES: + content = content.replace(old, new) + + lines = content.split("\n") + idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l) + lines[idx:idx] = COMPONENTS.split("\n") + content = "\n".join(lines) + + TARGET.write_text(content, encoding="utf-8") + print(" + Composants charts + 3 renderers + REGISTRY appliqués.") + try: + py_compile.compile(str(TARGET), doraise=True) + print(" + Compilation OK.") + except py_compile.PyCompileError as e: + shutil.copy2(str(TARGET) + ".bak-c5a", TARGET) + fail("Erreur de compilation — fichier restauré :\n%s" % e) + print("\n Suite lot 1 : python3 patch_layouts_c5a.py puis " + "propagation (prompt_injection_v2 + build_gallery).") + + +if __name__ == "__main__": + main() diff --git a/patch_render_engine_c5b.py b/patch_render_engine_c5b.py new file mode 100644 index 0000000..3bdfd2e --- /dev/null +++ b/patch_render_engine_c5b.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_render_engine_c5b.py — Chantier C5 · lot 2 (waterfall, heatmap, +funnel) +===================================================================== +Patch strict de render_engine_v2.py. PRÉREQUIS : lot 1 appliqué +(patch_render_engine_c5a.py) — les renderers du lot 2 réutilisent +_chart_header/_chart_source ; le patch refuse de s'appliquer sinon. + + P1. Import MSO_LINE (connecteurs pointillés) + _blend() module-level + (mélange déterministe de deux couleurs hex). + P2. Trois renderers : + waterfall (L45) — pont de valeur en rectangles : cumuls + CALCULÉS par le renderer (l'agent ne positionne + rien), delta+ corail / delta− slate, départ et + arrivée navy pleins, connecteurs pointillés, + étiquettes signées. Si la valeur d'arrivée + déclarée diverge du cumul calculé : warning et le + cumul fait foi (cohérence visuelle). + heatmap_table (L46) — grille d'intensité : score entier 0-4 → + 5 teintes précalculées du navy vers blanc, texte + du score en contraste automatique, aucune couleur + libre. Scores hors bornes : clamp + warning. + funnel (L47) — entonnoir en vrais trapèzes (freeform + builder) : largeurs proportionnelles aux valeurs, + plancher 30 %, raccord exact entre étages, + dernier étage corail (règle fixe), descriptions + alignées à droite. + P3. REGISTRY : waterfall, heatmap_table, funnel. + +Usage (dossier du pipeline, single-line) : + python3 patch_render_engine_c5b.py +Vérifie chaque ancre, écrit .bak-c5b, compile, idempotent. +""" + +import py_compile +import shutil +import sys +from pathlib import Path + +TARGET = Path("render_engine_v2.py") + +BLEND_FUNC = ''' + +def _blend(hex_a: str, hex_b: str, t: float) -> str: + """Mélange linéaire déterministe de deux couleurs hex (t: 0→a, 1→b).""" + a = (hex_a or "#000000").lstrip("#") + b = (hex_b or "#FFFFFF").lstrip("#") + t = max(0.0, min(1.0, float(t))) + out = "".join( + "%02X" % round(int(a[i:i + 2], 16) + + (int(b[i:i + 2], 16) - int(a[i:i + 2], 16)) * t) + for i in (0, 2, 4)) + return "#" + out +''' + +COMPONENTS = ''' + # ---------------- data & structure (C5 lot 2) ---------------- + WF_MAX_STEPS = 8 + HM_MAX_COLS = 6 + HM_MAX_ROWS = 8 + FUNNEL_MAX = 5 + + def _render_waterfall(self, slide, d): + """Pont de valeur : cumuls calculés, jamais fournis.""" + self._title(slide, pick(d, "titre", "title")) + self._chart_caption(slide, d) + zx, zy, zw, zh = self._chart_zone(d) + dep = d.get("depart") or {} + arr = d.get("arrivee") or {} + marches = (d.get("marches") or [])[:self.WF_MAX_STEPS] + if len(d.get("marches") or []) > self.WF_MAX_STEPS: + print(f" ~ waterfall : marches tronquées à " + f"{self.WF_MAX_STEPS}") + v0 = self._num(pick(dep, "valeur", "value", default=0)) + cums = [v0] + for m in marches: + cums.append(cums[-1] + self._num(pick(m, "delta", default=0))) + v_end = cums[-1] + declared = self._num(pick(arr, "valeur", "value", + default=v_end), default=v_end) + if abs(declared - v_end) > 1e-9: + print(f" ~ waterfall : arrivée déclarée {declared:g} ≠ " + f"cumul {v_end:g} — le cumul fait foi") + chart_top = zy + 0.7 # réserve étiquettes hautes + chart_b = zy + zh - 1.3 # réserve labels bas + chart_h = chart_b - chart_top + vmin = min(0.0, min(cums)) + vmax = max(max(cums), v0, 0.0) + span = (vmax - vmin) or 1.0 + hpu = chart_h / span + base_y = chart_top + vmax * hpu # y de la valeur 0 + n = len(marches) + 2 + slot = zw / n + bar_w = slot * 0.62 + + def bar(i, v_from, v_to, color): + x = zx + i * slot + (slot - bar_w) / 2 + y1 = base_y - max(v_from, v_to) * hpu + h = max(0.06, abs(v_to - v_from) * hpu) + self._rect(slide, x, y1, bar_w, h, color) + return x, y1 + + def val_label(i, y_ref, text, color): + self._text(slide, zx + i * slot, y_ref - 0.62, slot, 0.55, + text, size=11, bold=True, color=color, + align=PP_ALIGN.CENTER, fit=False) + + def cat_label(i, label): + self._text(slide, zx + i * slot + 0.05, chart_b + 0.15, + slot - 0.1, 1.05, label, size=10, + color=self.C["slate"], align=PP_ALIGN.CENTER) + + def connector(x1, x2, level): + conn = slide.shapes.add_connector( + 1, Cm(x1), Cm(base_y - level * hpu), + Cm(x2), Cm(base_y - level * hpu)) + conn.line.color.rgb = hex_to_rgb(self.C["muted"]) + conn.line.width = Pt(1.0) + conn.line.dash_style = MSO_LINE.DASH + conn.shadow.inherit = False + + ln = slide.shapes.add_connector( + 1, Cm(zx), Cm(base_y), Cm(zx + zw), Cm(base_y)) + ln.line.color.rgb = hex_to_rgb(self.C["muted"]) + ln.line.width = Pt(0.75) + ln.shadow.inherit = False + x, y1 = bar(0, 0.0, v0, self.C["navy"]) + val_label(0, y1, f"{v0:g}", self.C["navy"]) + cat_label(0, as_label(dep, "label", default="Départ")) + prev_x_end = x + bar_w + for i, m in enumerate(marches, start=1): + c_prev, c_cur = cums[i - 1], cums[i] + delta = c_cur - c_prev + color = self.C["coral"] if delta >= 0 else self.C["slate"] + x, y1 = bar(i, c_prev, c_cur, color) + sign = "+" if delta >= 0 else "\\u2212" + val_label(i, y1, f"{sign}{abs(delta):g}", color) + cat_label(i, as_label(m, "label")) + connector(prev_x_end, x, c_prev) + prev_x_end = x + bar_w + i = n - 1 + x, y1 = bar(i, 0.0, v_end, self.C["navy"]) + val_label(i, y1 if v_end >= 0 else base_y, f"{v_end:g}", + self.C["navy"]) + cat_label(i, as_label(arr, "label", default="Arrivée")) + connector(prev_x_end, x, v_end) + + def _render_heatmap_table(self, slide, d): + """Grille d'intensité : score 0-4 → 5 teintes du navy.""" + self._title(slide, pick(d, "titre", "title")) + headers = [as_label(h) for h in (d.get("headers") or [])][ + :self.HM_MAX_COLS] + rows = (d.get("rows") or [])[:self.HM_MAX_ROWS] + if not headers or not rows: + self._text(slide, self.MX, self.CONT_Y, 10, 1.0, + "[données manquantes]", size=14, + color=self.C["muted"]) + return + tints = [_blend("#FFFFFF", self.C["navy"], t) + for t in (0.10, 0.28, 0.50, 0.74, 1.0)] + legende = pick(d, "legende", "legend") + label_w = 6.5 + gap = 0.12 + w_total = self.SLIDE_W - 2 * self.MX + col_w = (w_total - label_w - gap * len(headers)) / len(headers) + head_h = 0.9 + leg_h = 0.8 if legende else 0.0 + row_h = min(1.55, (self.CONT_H - head_h - leg_h + - gap * (len(rows) + 1)) / len(rows)) + total_h = head_h + gap + len(rows) * (row_h + gap) + leg_h + y = self._cy(total_h) + for j, h in enumerate(headers): + x = self.MX + label_w + gap + j * (col_w + gap) + self._text(slide, x, y, col_w, head_h, h, size=12, + bold=True, color=self.C["slate"], + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + cy = y + head_h + gap + for r in rows: + self._text(slide, self.MX, cy, label_w, row_h, + as_label(r, "label"), size=13, bold=True, + color=self.C["body"], anchor=MSO_ANCHOR.MIDDLE) + scores = (r.get("scores") if isinstance(r, dict) else []) \ + or [] + for j in range(len(headers)): + s = int(self._num(scores[j] if j < len(scores) else 0)) + if not 0 <= s <= 4: + print(f" ~ heatmap : score {s} hors 0-4 — clampé") + s = max(0, min(4, s)) + x = self.MX + label_w + gap + j * (col_w + gap) + self._rect(slide, x, cy, col_w, row_h, tints[s]) + self._text(slide, x, cy, col_w, row_h, str(s), size=12, + bold=True, + color=self.C["white"] if s >= 2 + else self.C["navy"], + align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.MIDDLE, fit=False) + cy += row_h + gap + if legende: + self._text(slide, self.MX, cy + 0.05, w_total, 0.6, legende, + size=10, italic=True, color=self.C["muted"]) + + def _render_funnel(self, slide, d): + """Entonnoir : trapèzes proportionnels, plancher 30 %, + dernier étage corail.""" + self._title(slide, pick(d, "titre", "title")) + self._chart_caption(slide, d) + zx, zy, zw, zh = self._chart_zone(d) + etapes = (d.get("etapes") or [])[:self.FUNNEL_MAX] + if len(etapes) < 2: + self._text(slide, zx, zy, 12, 1.0, + "[funnel : 2 étapes minimum]", size=14, + color=self.C["muted"]) + return + vals = [max(0.0, self._num(pick(e, "valeur", "value", + default=0))) + for e in etapes] + vmax = max(vals) or 1.0 + has_desc = any(pick(e, "description") for e in etapes) + fun_w = zw * (0.58 if has_desc else 0.80) + cx = zx + fun_w / 2 + gap = 0.18 + n = len(etapes) + stage_h = (zh - 0.4 - gap * (n - 1)) / n + widths = [max(0.30, v / vmax) * fun_w for v in vals] + y = zy + 0.2 + for i, e in enumerate(etapes): + wt = widths[i] + wb = widths[i + 1] if i + 1 < n else widths[i] * 0.72 + color = self.C["coral"] if i == n - 1 else \ + _blend(self.C["navy"], self.C["glacier"], + 0.5 * i / max(1, n - 1)) + fb = slide.shapes.build_freeform( + cx - wt / 2, y, scale=360000) + fb.add_line_segments( + [(cx + wt / 2, y), + (cx + wb / 2, y + stage_h), + (cx - wb / 2, y + stage_h)], close=True) + sh = fb.convert_to_shape() + sh.fill.solid() + sh.fill.fore_color.rgb = hex_to_rgb(color) + sh.line.fill.background() + sh.shadow.inherit = False + label = as_label(e, "label") + val = self._num(pick(e, "valeur", "value", default=0)) + self._text(slide, cx - wt / 2, y + stage_h / 2 - 0.78, + wt, 0.8, label, size=14, bold=True, + color=self.C["white"], align=PP_ALIGN.CENTER, + anchor=MSO_ANCHOR.BOTTOM) + self._text(slide, cx - wt / 2, y + stage_h / 2 + 0.06, + wt, 0.6, f"{val:g}", size=12, + color=self.C["white"], align=PP_ALIGN.CENTER, + fit=False) + desc = pick(e, "description") + if desc: + dx = zx + fun_w + 1.2 + self._text(slide, dx, y, zx + zw - dx, stage_h, desc, + size=13, color=self.C["body"], + anchor=MSO_ANCHOR.MIDDLE) + y += stage_h + gap + +''' + +PATCHES = [ + # P1 — import MSO_LINE + _blend + ( + "def hex_to_rgb(h: str) -> RGBColor:\n" + " h = (h or \"#000000\").lstrip(\"#\")\n" + " return RGBColor(int(h[0:2], 16), int(h[2:4], 16), " + "int(h[4:6], 16))\n", + "def hex_to_rgb(h: str) -> RGBColor:\n" + " h = (h or \"#000000\").lstrip(\"#\")\n" + " return RGBColor(int(h[0:2], 16), int(h[2:4], 16), " + "int(h[4:6], 16))\n" + + BLEND_FUNC, + ), + # P1b — import MSO_LINE + ( + "from pptx.enum.shapes import MSO_SHAPE\n", + "from pptx.enum.shapes import MSO_SHAPE\n" + "from pptx.enum.dml import MSO_LINE_DASH_STYLE as MSO_LINE\n", + ), + # P3 — REGISTRY + ( + " \"matrix_2x2\": \"_render_matrix_2x2\",\n", + " \"matrix_2x2\": \"_render_matrix_2x2\",\n" + " \"waterfall\": \"_render_waterfall\",\n" + " \"heatmap_table\": \"_render_heatmap_table\",\n" + " \"funnel\": \"_render_funnel\",\n", + ), +] + +MARKER = "_render_waterfall" +PREREQ = "_render_bar_chart" +ANCHOR_ORCH = " # ---------------- orchestration ----------------" + + +def fail(msg): + print(" ! %s" % msg) + sys.exit(1) + + +def main(): + if not TARGET.exists(): + fail("%s introuvable — lancer depuis le dossier du pipeline." + % TARGET) + content = TARGET.read_text(encoding="utf-8") + if MARKER in content: + fail("Déjà patché (_render_waterfall présent) — rien à faire.") + if PREREQ not in content: + fail("Lot 1 non appliqué (_render_bar_chart absent) — lancer " + "d'abord patch_render_engine_c5a.py.") + + for i, (old, _) in enumerate(PATCHES, 1): + n = content.count(old) + if n == 0: + fail("Ancre du patch %d introuvable — moteur inattendu." % i) + if n > 1: + fail("Ancre du patch %d non unique (%d occurrences)." % (i, n)) + if content.count(ANCHOR_ORCH) != 1: + fail("Ancre de la section orchestration introuvable ou non " + "unique.") + + shutil.copy2(TARGET, str(TARGET) + ".bak-c5b") + print(" + Sauvegarde : %s.bak-c5b" % TARGET) + + for old, new in PATCHES: + content = content.replace(old, new) + + lines = content.split("\n") + idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l) + lines[idx:idx] = COMPONENTS.split("\n") + content = "\n".join(lines) + + TARGET.write_text(content, encoding="utf-8") + print(" + 3 patchs + renderers lot 2 appliqués.") + try: + py_compile.compile(str(TARGET), doraise=True) + print(" + Compilation OK.") + except py_compile.PyCompileError as e: + shutil.copy2(str(TARGET) + ".bak-c5b", TARGET) + fail("Erreur de compilation — fichier restauré :\n%s" % e) + print("\n Suite : python3 patch_layouts_c5b.py puis propagation " + "(prompt_injection_v2 + build_gallery).") + + +if __name__ == "__main__": + main() diff --git a/patch_render_engine_c5c.py b/patch_render_engine_c5c.py new file mode 100644 index 0000000..287cb69 --- /dev/null +++ b/patch_render_engine_c5c.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +patch_render_engine_c5c.py — Chantier C5 · lot 3 (agenda, pyramid) +================================================================== +Patch strict de render_engine_v2.py. Réintroduit les deux layouts v1 +disparus à la refonte, restylés PR Editorial. Aucun prérequis sur les +lots 1/2 (aucun helper chart utilisé). + + agenda (L40) — sommaire typographique aéré : badge numéroté (navy ; + corail si actif: true), label (bold navy si actif), durée + alignée à droite en muted, filet séparateur card_alt. + Bornes : 2-8 sections (troncature tracée au-delà). + pyramid (L41) — pyramide à degrés : rectangles empilés centrés, + largeurs FIXES par étage (jamais recalculées) : + 3 niveaux : 44 / 72 / 100 % + 4 niveaux : 40 / 60 / 80 / 100 % + Couleurs fixes du sommet à la base : navy, navy_light, + slate, glacier (texte blanc, navy sur glacier). Descriptions + optionnelles alignées à droite de chaque étage (la pyramide + passe alors à 58 % de largeur) ; sans description, pyramide + centrée sur 80 %. + +Usage (dossier du pipeline, single-line) : + python3 patch_render_engine_c5c.py +Vérifie chaque ancre, écrit .bak-c5c, compile, idempotent. +""" + +import py_compile +import shutil +import sys +from pathlib import Path + +TARGET = Path("render_engine_v2.py") + +COMPONENTS = ''' # ---------------- structure & concept (C5 lot 3) ---------------- + AGENDA_MAX = 8 + PYRAMID_WIDTHS = {3: (0.44, 0.72, 1.0), + 4: (0.40, 0.60, 0.80, 1.0)} + + def _render_agenda(self, slide, d): + """Sommaire : badges numérotés, section active en corail.""" + self._title(slide, pick(d, "titre", "title")) + sections = (d.get("sections") or [])[:self.AGENDA_MAX] + if len(d.get("sections") or []) > self.AGENDA_MAX: + print(f" ~ agenda : sections tronquées à {self.AGENDA_MAX}") + if len(sections) < 2: + self._text(slide, self.MX, self.CONT_Y, 12, 1.0, + "[agenda : 2 sections minimum]", size=14, + color=self.C["muted"]) + return + n = len(sections) + gap = 0.5 + row_h = min(1.9, (self.CONT_H - gap * (n - 1)) / n) + tot = n * row_h + (n - 1) * gap + y = self._cy(tot) + bd = self.components["badge"]["sizes"]["m"] + x0 = self.MX + 1.5 + w = self.SLIDE_W - 2 * self.MX - 3.0 + for i, s in enumerate(sections): + if not isinstance(s, dict): + s = {"label": str(s)} + actif = bool(s.get("actif")) + num = s.get("numero", i + 1) + fill = self.C["coral"] if actif else self.C["navy"] + self._badge(slide, x0 + bd / 2, y + row_h / 2, bd, num, + fill=fill, font_size=20) + self._text(slide, x0 + bd + 1.0, y, w - bd - 6.0, row_h, + as_label(s, "label", "titre"), + size=20 if actif else 19, bold=actif, + color=self.C["navy"] if actif + else self.C["body"], + anchor=MSO_ANCHOR.MIDDLE) + duree = pick(s, "duree", "duration") + if duree: + self._text(slide, x0 + w - 4.5, y, 4.5, row_h, duree, + size=13, italic=True, color=self.C["muted"], + align=PP_ALIGN.RIGHT, + anchor=MSO_ANCHOR.MIDDLE, fit=False) + if i < n - 1: + self._rect(slide, x0 + bd + 1.0, + y + row_h + gap / 2 - 0.015, + w - bd - 1.0, 0.03, self.C["card"]) + y += row_h + gap + + def _render_pyramid(self, slide, d): + """Pyramide à degrés : largeurs fixes, couleurs fixes.""" + self._title(slide, pick(d, "titre", "title")) + niveaux = (d.get("niveaux") or d.get("levels") or [])[:4] + if len(niveaux) < 3: + self._text(slide, self.MX, self.CONT_Y, 12, 1.0, + "[pyramid : 3 ou 4 niveaux]", size=14, + color=self.C["muted"]) + return + n = len(niveaux) + widths = self.PYRAMID_WIDTHS[n] + colors = [self.C["navy"], self.C["navy2"], + self.C["slate"], self.C["glacier"]] + if n == 3: + colors = [self.C["navy"], self.C["navy2"], + self.C["glacier"]] + has_desc = any(isinstance(nv, dict) and pick(nv, "description") + for nv in niveaux) + zone_w = self.SLIDE_W - 2 * self.MX + pyr_w = zone_w * (0.58 if has_desc else 0.80) + pyr_x = self.MX if has_desc else \ + self.MX + (zone_w - pyr_w) / 2 + cx = pyr_x + pyr_w / 2 + gap = 0.15 + avail = self.CONT_H - 0.4 + stage_h = (avail - gap * (n - 1)) / n + y = self._cy(avail) + 0.2 + for i, nv in enumerate(niveaux): + if not isinstance(nv, dict): + nv = {"label": str(nv)} + wt = widths[i] * pyr_w + color = colors[i] + txt_color = self.C["navy"] if color == self.C["glacier"] \ + else self.C["white"] + self._rect(slide, cx - wt / 2, y, wt, stage_h, color) + self._text(slide, cx - wt / 2 + 0.3, y, wt - 0.6, stage_h, + as_label(nv, "label", "titre"), + size=16, bold=True, color=txt_color, + align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE) + desc = pick(nv, "description", "detail") + if desc: + dx = pyr_x + pyr_w + 1.2 + self._text(slide, dx, y, + self.SLIDE_W - self.MX - dx, stage_h, desc, + size=13, color=self.C["body"], + anchor=MSO_ANCHOR.MIDDLE) + y += stage_h + gap + +''' + +PATCHES = [ + # REGISTRY + ( + " \"matrix_2x2\": \"_render_matrix_2x2\",\n", + " \"matrix_2x2\": \"_render_matrix_2x2\",\n" + " \"agenda\": \"_render_agenda\",\n" + " \"pyramid\": \"_render_pyramid\",\n", + ), +] + +MARKER = "_render_agenda" +ANCHOR_ORCH = " # ---------------- orchestration ----------------" + + +def fail(msg): + print(" ! %s" % msg) + sys.exit(1) + + +def main(): + if not TARGET.exists(): + fail("%s introuvable — lancer depuis le dossier du pipeline." + % TARGET) + content = TARGET.read_text(encoding="utf-8") + if MARKER in content: + fail("Déjà patché (_render_agenda présent) — rien à faire.") + + for i, (old, _) in enumerate(PATCHES, 1): + n = content.count(old) + if n == 0: + fail("Ancre du patch %d introuvable — moteur inattendu." % i) + if n > 1: + fail("Ancre du patch %d non unique (%d occurrences)." % (i, n)) + if content.count(ANCHOR_ORCH) != 1: + fail("Ancre de la section orchestration introuvable ou non " + "unique.") + + shutil.copy2(TARGET, str(TARGET) + ".bak-c5c") + print(" + Sauvegarde : %s.bak-c5c" % TARGET) + + for old, new in PATCHES: + content = content.replace(old, new) + + lines = content.split("\n") + idx = next(i for i, l in enumerate(lines) if ANCHOR_ORCH in l) + lines[idx:idx] = COMPONENTS.split("\n") + content = "\n".join(lines) + + TARGET.write_text(content, encoding="utf-8") + print(" + Renderers agenda + pyramid + REGISTRY appliqués.") + try: + py_compile.compile(str(TARGET), doraise=True) + print(" + Compilation OK.") + except py_compile.PyCompileError as e: + shutil.copy2(str(TARGET) + ".bak-c5c", TARGET) + fail("Erreur de compilation — fichier restauré :\n%s" % e) + print("\n Suite : python3 patch_layouts_c5c.py puis propagation " + "(prompt_injection_v2 + build_gallery).") + + +if __name__ == "__main__": + main()