feat: freeform palier 4 - derives de tokens, shapes, alpha, rotation, full_bleed (C6)

This commit is contained in:
2026-07-12 08:14:57 +02:00
parent 6e8167a91c
commit 3929d14e9f
6 changed files with 1160 additions and 70 deletions
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
patch_facilitator_c6.py — Chantier C6 (validateur freeform palier 4)
====================================================================
Patch strict de facilitator_v9.py :
P1. VALID_BLOCK_TYPES accepte le type « shape ».
P2. validate_freeform v2 — deux niveaux :
ERREURS (bloquantes) : type de bloc inconnu, shape inconnue,
couleur hors charte (hex, token inconnu, dérivé mal formé),
bloc hors slide, plus de 15 blocs, mode invalide, background
hors charte.
AVERTISSEMENTS (non bloquants, affichés) : plus de 10 blocs
(densité), alpha hors 0-100 (sera clampé), rotation non
multiple de 15° (sera arrondie), radius hors 0-0.5.
Le vocabulaire validé : tokens + dérivés token@NN, les 12 shapes
curées, full_bleed, border, dash — aligné sur le moteur C6.
Usage (dossier du pipeline, single-line) :
python3 patch_facilitator_c6.py
Vérifie chaque ancre, écrit .bak-fc6, compile, idempotent.
"""
import py_compile
import shutil
import sys
from pathlib import Path
TARGET = Path("facilitator_v9.py")
OLD_TYPES = ('VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", '
'"circle",\n'
' "badge", "card", "rect", "line", '
'"image"}\n')
NEW_TYPES = ('VALID_BLOCK_TYPES = {"title", "heading", "text", "stat", '
'"circle",\n'
' "badge", "card", "rect", "line", '
'"image", "shape"}\n'
'VALID_FREE_SHAPES = {"chevron", "arrow", "triangle", '
'"pill", "donut",\n'
' "bracket_left", "bracket_right", '
'"oval", "diamond",\n'
' "hexagon", "parallelogram", "moon"}\n'
'FREE_TOKEN_RE = re.compile(\n'
' r"^(navy|navy_light|coral|glacier|slate|card|card_alt'
'|white|body|muted)"\n'
' r"(@\\d{1,3})?$")\n')
NEW_VALIDATE = '''def validate_freeform(raw: str):
"""Valide un YAML freeform — palier 4 (C6), deux niveaux.
Retourne (ok, message, data). Les avertissements n'empêchent pas
le rendu ; ils sont intégrés au message."""
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) or "slides" not in data:
return False, "Clé 'slides' manquante.", None
slides = data["slides"]
if not slides:
return False, "Aucune slide.", None
def color_ok(c):
return bool(FREE_TOKEN_RE.match(str(c).strip().lower()))
errors, warns = [], []
for i, s in enumerate(slides, 1):
if s.get("layout") != "freeform":
errors.append(f"Slide {i} : layout doit être 'freeform'")
continue
if s.get("mode") not in ("light", "dark", None):
errors.append(f"Slide {i} : mode doit être 'light' ou "
f"'dark'")
bg = s.get("background")
if bg and not color_ok(bg):
errors.append(f"Slide {i} : background '{bg}' hors charte "
f"(token ou token@NN)")
blocks = s.get("blocks", [])
if not blocks:
errors.append(f"Slide {i} : aucun bloc")
if len(blocks) > 15:
errors.append(f"Slide {i} : {len(blocks)} blocs (max 15)")
elif len(blocks) > 10:
warns.append(f"Slide {i} : {len(blocks)} blocs — pense "
f"respiration (10 max conseillé)")
for j, b in enumerate(blocks, 1):
loc = f"Slide {i} bloc {j}"
bt = (b.get("type") or "text").lower()
if bt not in VALID_BLOCK_TYPES:
errors.append(f"{loc} : type '{bt}' invalide")
if bt == "shape":
sk = str(b.get("shape") or "").lower()
if sk and sk not in VALID_FREE_SHAPES:
errors.append(
f"{loc} : shape '{sk}' inconnue "
f"({', '.join(sorted(VALID_FREE_SHAPES))})")
col = float(b.get("col", 0)); w = float(b.get("w", 4))
row = float(b.get("row", 0)); h = float(b.get("h", 1))
if col + w > 12.01:
errors.append(f"{loc} : déborde "
f"(col+w={col + w:.1f}>12)")
if row + h > 12.01:
errors.append(f"{loc} : déborde "
f"(row+h={row + h:.1f}>12)")
for key in ("color", "text_color"):
c = b.get(key)
if c and not color_ok(c):
errors.append(f"{loc} : {key} '{c}' hors charte "
f"(token ou token@NN, jamais de hex)")
border = b.get("border")
if border is not None:
if not isinstance(border, dict):
errors.append(f"{loc} : border doit être "
f"{{color, weight}}")
elif border.get("color") and \
not color_ok(border["color"]):
errors.append(f"{loc} : border.color "
f"'{border['color']}' hors charte")
alpha = b.get("alpha")
if alpha is not None:
try:
if not 0 <= float(alpha) <= 100:
warns.append(f"{loc} : alpha {alpha} hors "
f"0-100 — sera clampé")
except (TypeError, ValueError):
errors.append(f"{loc} : alpha '{alpha}' non "
f"numérique")
rot = b.get("rotation")
if rot is not None:
try:
if float(rot) % 15 != 0:
warns.append(f"{loc} : rotation {rot}° — sera "
f"arrondie au pas de 15°")
except (TypeError, ValueError):
errors.append(f"{loc} : rotation '{rot}' non "
f"numérique")
radius = b.get("radius")
if radius is not None:
try:
if not 0 <= float(radius) <= 0.5:
warns.append(f"{loc} : radius {radius} hors "
f"0-0.5 — sera clampé")
except (TypeError, ValueError):
errors.append(f"{loc} : radius '{radius}' non "
f"numérique")
if errors:
return False, "\\n ".join(errors), data
msg = f"{len(slides)} slides freeform valides."
if warns:
msg += "\\n ⚠ " + "\\n ⚠ ".join(warns)
return True, msg, data
'''
MARKER = "VALID_FREE_SHAPES"
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é (VALID_FREE_SHAPES présent) — rien à faire.")
if content.count(OLD_TYPES) != 1:
fail("Ancre VALID_BLOCK_TYPES introuvable ou non unique — "
"appliquer d'abord le patch C4 du facilitator.")
# Localiser la fonction validate_freeform complète
start = content.find("def validate_freeform(raw: str):")
if start == -1:
fail("validate_freeform introuvable.")
end = content.find("\ndef ", start + 10)
if end == -1:
fail("Fin de validate_freeform introuvable.")
end += 1 # conserver le \\n final avant def suivant
shutil.copy2(TARGET, str(TARGET) + ".bak-fc6")
print(" + Sauvegarde : %s.bak-fc6" % TARGET)
content = content.replace(OLD_TYPES, NEW_TYPES)
# recalcul après P1
start = content.find("def validate_freeform(raw: str):")
end = content.find("\ndef ", start + 10) + 1
content = content[:start] + NEW_VALIDATE + "\n" + content[end:]
TARGET.write_text(content, encoding="utf-8")
print(" + Types + shapes + validateur 2 niveaux appliqués.")
try:
py_compile.compile(str(TARGET), doraise=True)
print(" + Compilation OK.")
except py_compile.PyCompileError as e:
shutil.copy2(str(TARGET) + ".bak-fc6", TARGET)
fail("Erreur de compilation — fichier restauré :\n%s" % e)
print("\n Prochaine étape : prompt_the_free_designer_v2.md dans "
"Mistral Studio (temp 0.7).")
if __name__ == "__main__":
main()