Files
sliding-automation/patches/patch_render_engine_c5b.py

350 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()