Compare commits

...

3 Commits

13 changed files with 481 additions and 56 deletions
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
check_goldens.py — C0 · goldens de non-régression du moteur
===========================================================
Rend chaque golden_*.yaml présent et compare sa STRUCTURE (nb de
slides, nb de shapes par slide) au manifeste goldens_manifest.json.
Le PPTX contient des timestamps : un diff binaire est impossible — la
structure, elle, est déterministe et attrape les régressions de rendu
(shape manquante, layout cassé, exception).
python3 check_goldens.py → vérifie (code retour 1 si écart)
python3 check_goldens.py --update → (re)génère le manifeste
À lancer avant tout push moteur (ou en tâche DSM hebdo).
"""
import glob
import json
import subprocess
import sys
from pathlib import Path
MANIFEST = Path("goldens_manifest.json")
def structure(pptx_path):
from pptx import Presentation
prs = Presentation(pptx_path)
return [len(list(s.shapes)) for s in prs.slides]
def render_all():
out = {}
for y in sorted(glob.glob("golden_*.yaml")):
pptx = "_check_%s.pptx" % Path(y).stem
r = subprocess.run(
["python3", "render_engine_v2.py", y, pptx,
"--assets", "assets"],
capture_output=True, text=True)
if r.returncode != 0:
print(" ! %s : le rendu ÉCHOUE\n%s" % (y, r.stdout[-400:]))
sys.exit(1)
out[y] = structure(pptx)
Path(pptx).unlink(missing_ok=True)
return out
def main():
got = render_all()
if "--update" in sys.argv:
MANIFEST.write_text(json.dumps(got, indent=2))
print(" + Manifeste écrit : %d goldens, %d slides au total."
% (len(got), sum(len(v) for v in got.values())))
return
if not MANIFEST.exists():
print(" ! Pas de manifeste — lancer d'abord : "
"python3 check_goldens.py --update")
sys.exit(1)
want = json.loads(MANIFEST.read_text())
ok = True
for y, shapes in got.items():
ref = want.get(y)
if ref is None:
print(" ~ %s : nouveau golden (absent du manifeste)" % y)
continue
if shapes != ref:
ok = False
print(" ! %s : ÉCART — slides/shapes %s ≠ attendu %s"
% (y, shapes, ref))
for y in want:
if y not in got:
ok = False
print(" ! %s : golden du manifeste INTROUVABLE" % y)
print(" %s" % ("✓ goldens conformes (%d fichiers)" % len(got)
if ok else "✗ régression détectée"))
sys.exit(0 if ok else 1)
if __name__ == "__main__":
main()
+115 -15
View File
@@ -47,7 +47,11 @@ SYSTEM_PROMPT = (
"d'important ; condenser légèrement si nécessaire.\n" "d'important ; condenser légèrement si nécessaire.\n"
"- Texte brut uniquement : aucun balisage Markdown (pas de **, __, #, `, " "- Texte brut uniquement : aucun balisage Markdown (pas de **, __, #, `, "
"ni tirets de liste dans les valeurs).\n" "ni tirets de liste dans les valeurs).\n"
"- Français, ton affirmatif, guillemets typographiques évités.\n" "- LANGUE : conserve strictement la langue du contenu fourni (un plan "
"anglais donne des champs 100 % anglais). Ces instructions sont en "
"français mais ce n'est JAMAIS la langue du livrable. Ne traduis "
"rien.\n"
"- Ton affirmatif, guillemets typographiques évités.\n"
"- Champs optionnels sans contenu correspondant : null.\n" "- Champs optionnels sans contenu correspondant : null.\n"
"- Les nombres des champs numériques (start, end, x, y, numero) sont des " "- Les nombres des champs numériques (start, end, x, y, numero) sont des "
"entiers, pas des chaînes.\n" "entiers, pas des chaînes.\n"
@@ -58,7 +62,22 @@ SYSTEM_PROMPT = (
"ignore-les." "ignore-les."
) )
# "SLIDE 12 — layout_name" (tiret cadratin, demi-cadratin ou simple) # FORMAT RÉEL du Designer (prompt_the_designer_v3) : le Markdown du
# Narrator repris intégralement, avec une ligne "@layout: nom" ajoutée
# sous chaque titre de slide, et des "@note:" facultatives.
# ### 3. The CEO's 3 priorities
# @layout: two_cols_text
# @note: ...
# <contenu>
# On découpe donc sur les lignes @layout, et on déduit la position du
# titre qui précède (### 3. …) ou, à défaut, de l'ordre d'apparition.
LAYOUT_RE = re.compile(r"^[ \t]*@layout[ \t]*:[ \t]*([a-z][a-z0-9_]*)[ \t]*$",
re.MULTILINE)
# Titre de slide : "### 12. Titre" ou "### [OPT-EXEC] 5. Titre"
TITLE_NUM_RE = re.compile(
r"^[ \t]*#{1,6}[ \t]*(?:\[[^\]]+\][ \t]*)?(\d+)[.)][ \t]*",
re.MULTILINE)
# Compat ascendante : ancien format "SLIDE 12 — layout_name"
SLIDE_RE = re.compile( SLIDE_RE = re.compile(
r"^\s*SLIDE\s+(\d+)\s*[—–\-]+\s*([a-z][a-z0-9_]*)\s*$", r"^\s*SLIDE\s+(\d+)\s*[—–\-]+\s*([a-z][a-z0-9_]*)\s*$",
re.MULTILINE) re.MULTILINE)
@@ -72,22 +91,60 @@ MD_PATTERNS = [
# ── Découpage du plan ──────────────────────────────────────────────────────── # ── Découpage du plan ────────────────────────────────────────────────────────
def _position_before(plan_md, idx, fallback):
"""Numéro de la dernière ligne de titre '### N.' avant idx."""
last = None
for m in TITLE_NUM_RE.finditer(plan_md, 0, idx):
last = m
if last:
try:
return int(last.group(1))
except (TypeError, ValueError):
pass
return fallback
def split_plan(plan_md): def split_plan(plan_md):
"""Découpe le plan Designer en slides. """Découpe le plan annoté par le Designer en slides.
Format principal : lignes '@layout: nom'. Compat : 'SLIDE N — nom'.
Retourne (segments, errors) ; segment = {position, layout, content}.""" Retourne (segments, errors) ; segment = {position, layout, content}."""
matches = list(SLIDE_RE.finditer(plan_md))
segments, errors = [], [] segments, errors = [], []
if not matches:
return [], ["Aucune ligne 'SLIDE N — layout' détectée dans le plan."]
valid = set(known_layouts()) valid = set(known_layouts())
matches = list(LAYOUT_RE.finditer(plan_md))
if matches:
for i, m in enumerate(matches):
start = m.start()
end = (matches[i + 1].start() if i + 1 < len(matches)
else len(plan_md))
layout = m.group(1)
pos = _position_before(plan_md, start, i + 1)
seg = {"position": pos, "layout": layout,
"content": plan_md[start:end].strip()}
if layout not in valid:
errors.append("Slide %d : layout '%s' sans schéma (connus"
" : %s)" % (pos, layout,
", ".join(sorted(valid))))
continue
segments.append(seg)
# Positions dupliquées (titre absent) → renumérotation d'ordre
seen = [s["position"] for s in segments]
if len(set(seen)) != len(seen):
for i, s in enumerate(segments, start=1):
s["position"] = i
return segments, errors
# Fallback : ancien format explicite
matches = list(SLIDE_RE.finditer(plan_md))
if not matches:
return [], ["Aucune annotation '@layout: nom' (ni 'SLIDE N — "
"layout') détectée dans le plan du Designer."]
for i, m in enumerate(matches): for i, m in enumerate(matches):
end = matches[i + 1].start() if i + 1 < len(matches) else len(plan_md) end = (matches[i + 1].start() if i + 1 < len(matches)
else len(plan_md))
layout = m.group(2) layout = m.group(2)
seg = { seg = {"position": int(m.group(1)), "layout": layout,
"position": int(m.group(1)), "content": plan_md[m.start():end].strip()}
"layout": layout,
"content": plan_md[m.start():end].strip(),
}
if layout not in valid: if layout not in valid:
errors.append("Slide %d : layout '%s' sans schéma (connus : %s)" errors.append("Slide %d : layout '%s' sans schéma (connus : %s)"
% (seg["position"], layout, % (seg["position"], layout,
@@ -121,7 +178,7 @@ def _post_with_retry(payload, api_key):
% (MAX_RETRY, last_err)) % (MAX_RETRY, last_err))
def encode_slide(content, layout, api_key, model=None): def encode_slide(content, layout, api_key, model=None, lang=None):
"""Encode UNE slide. Retourne (dict_contenu, usage_dict). """Encode UNE slide. Retourne (dict_contenu, usage_dict).
Lève RuntimeError/ValueError en cas d'échec (isolé par l'appelant).""" Lève RuntimeError/ValueError en cas d'échec (isolé par l'appelant)."""
schema = get_schema(layout) schema = get_schema(layout)
@@ -131,7 +188,9 @@ def encode_slide(content, layout, api_key, model=None):
"messages": [ "messages": [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": {"role": "user", "content":
"Layout : %s\n\nContenu de la slide :\n%s" ("LANGUE DU DECK : %s — tous les champs texte restent "
"dans cette langue.\n" % lang if lang else "")
+ "Layout : %s\n\nContenu de la slide :\n%s"
% (layout, content)}, % (layout, content)},
], ],
"response_format": { "response_format": {
@@ -181,6 +240,41 @@ def _accumulate(total, usage):
return total return total
def detect_lang(text):
"""Heuristique stopwords : 'anglais' / 'français' / None (mixte)."""
t = " %s " % re.sub(r"\s+", " ", text.lower())
en = sum(t.count(w) for w in (" the ", " and ", " of ", " to ",
" is ", " for ", " with "))
fr = sum(t.count(w) for w in (" le ", " la ", " les ", " des ",
" est ", " pour ", " avec ", " une "))
if en > fr * 1.5:
return "anglais"
if fr > en * 1.5:
return "français"
return None
TITLE_PREFIX_RE = re.compile(r"^\s*(?:\[[^\]]+\]\s*)?\d+[.)]\s*")
def strip_title_number(content):
"""Retire la numérotation Markdown résiduelle du titre ('13. X''X')."""
t = content.get("titre")
if isinstance(t, str):
content["titre"] = TITLE_PREFIX_RE.sub("", t)
return content
def trim_trailing_meta(segments):
"""Le DERNIER segment court jusqu'à la fin du texte : il peut embarquer
les commentaires de conclusion du Designer (après un '---'). On coupe."""
if segments:
parts = segments[-1]["content"].split("\n---")
if parts[0].count("\n") >= 1:
segments[-1]["content"] = parts[0].rstrip()
return segments
# ── Orchestration ──────────────────────────────────────────────────────────── # ── Orchestration ────────────────────────────────────────────────────────────
def encode_plan(plan_md, api_key, model=None, only_positions=None, def encode_plan(plan_md, api_key, model=None, only_positions=None,
@@ -190,6 +284,7 @@ def encode_plan(plan_md, api_key, model=None, only_positions=None,
progress : callable(str) pour l'affichage (info du facilitator). progress : callable(str) pour l'affichage (info du facilitator).
Retourne (data, usage, errors) : Retourne (data, usage, errors) :
data = {"titre_presentation": str|None, "slides": [...]} data = {"titre_presentation": str|None, "slides": [...]}
La langue du plan est détectée et imposée à chaque slide (anti-dérive).
usage = tokens cumulés {"prompt_tokens","completion_tokens","total_tokens"} usage = tokens cumulés {"prompt_tokens","completion_tokens","total_tokens"}
errors = liste de messages (slides en échec — absentes de data). errors = liste de messages (slides en échec — absentes de data).
""" """
@@ -198,6 +293,10 @@ def encode_plan(plan_md, api_key, model=None, only_positions=None,
progress(msg) progress(msg)
segments, errors = split_plan(plan_md) segments, errors = split_plan(plan_md)
segments = trim_trailing_meta(segments)
lang = detect_lang(plan_md)
if lang:
say("Langue du deck : %s (imposée à chaque slide)" % lang)
if only_positions is not None: if only_positions is not None:
wanted = set(int(p) for p in only_positions) wanted = set(int(p) for p in only_positions)
segments = [s for s in segments if s["position"] in wanted] segments = [s for s in segments if s["position"] in wanted]
@@ -207,7 +306,7 @@ def encode_plan(plan_md, api_key, model=None, only_positions=None,
say("Slide %d (%s)..." % (seg["position"], seg["layout"])) say("Slide %d (%s)..." % (seg["position"], seg["layout"]))
try: try:
content, u = encode_slide(seg["content"], seg["layout"], content, u = encode_slide(seg["content"], seg["layout"],
api_key, model) api_key, model, lang=lang)
except (RuntimeError, ValueError, KeyError, except (RuntimeError, ValueError, KeyError,
json.JSONDecodeError) as e: json.JSONDecodeError) as e:
errors.append("Slide %d (%s) : %s" errors.append("Slide %d (%s) : %s"
@@ -215,6 +314,7 @@ def encode_plan(plan_md, api_key, model=None, only_positions=None,
continue continue
_accumulate(usage, u) _accumulate(usage, u)
content = strip_md(drop_nulls(content)) content = strip_md(drop_nulls(content))
content = strip_title_number(content)
if seg["layout"] == "cover_split" and titre_presentation is None: if seg["layout"] == "cover_split" and titre_presentation is None:
titre_presentation = content.get("titre") titre_presentation = content.get("titre")
slide = {"position": seg["position"], "layout": seg["layout"]} slide = {"position": seg["position"], "layout": seg["layout"]}
+1
View File
@@ -0,0 +1 @@
{}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
patch_prompt_designer_meta.py — Le Designer ne conclut plus.
Bug : ses commentaires de synthèse (« Points clés de la proposition… »)
après la dernière slide fuyaient dans le deck (slide 27 du deck Sisley).
Défense en profondeur : encoder_schema v3 tronque déjà après '---' ;
cette règle prompt tarit la source.
Usage : python3 patch_prompt_designer_meta.py
puis prompt_injection_v2.py + recoller le Designer dans Mistral Studio.
"""
import shutil, sys
from pathlib import Path
BLOC = """- Ta réponse se termine à la DERNIÈRE ligne de contenu de la dernière
slide. AUCUN commentaire, bilan, synthèse ou justification après —
tout texte postérieur serait transcrit dans le deck comme du contenu.
"""
p = Path("prompt_the_designer_v3.md")
if not p.exists():
print(" ! prompt_the_designer_v3.md introuvable"); sys.exit(1)
c = p.read_text(encoding="utf-8")
if "se termine à la DERNIÈRE ligne" in c:
print(" = déjà à jour"); sys.exit(0)
anchor = "- Tu ajoutes UNIQUEMENT les lignes `@layout:` et `@note:`."
if c.count(anchor) != 1:
print(" ! ancre introuvable/non unique"); sys.exit(1)
shutil.copy2(p, str(p) + ".bak-meta")
p.write_text(c.replace(anchor, anchor + "\n" + BLOC), encoding="utf-8")
print(" + règle anti-conclusion insérée — propager avec prompt_injection_v2.py")
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
patch_render_engine_c5fin.py — Finitions C5 (2 micro-fixes visuels)
===================================================================
F1. bar_chart horizontal : PowerPoint affiche les catégories de BAS
en HAUT en BAR_CLUSTERED — la première catégorie du YAML se
retrouvait en bas. On inverse catégories ET valeurs quand
horizontal, pour un ordre de lecture naturel (haut → bas).
F2. waterfall : les libellés de catégories longs (Décommissionnements…)
wrappaient en orphelin. Réduction automatique à 9 pt au-delà de
14 caractères.
Usage (dossier du pipeline) : python3 patches/patch_render_engine_c5fin.py
Vérifie les ancres, écrit .bak-c5fin, compile, idempotent.
"""
import py_compile
import shutil
import sys
from pathlib import Path
TARGET = Path("render_engine_v2.py")
P1_OLD = """ 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"))
"""
P1_NEW = """ horiz = bool(d.get("horizontal"))
if horiz:
# BAR_CLUSTERED trace de bas en haut : on inverse pour un
# ordre de lecture naturel (1re catégorie du YAML en haut).
cats = cats[::-1]
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))
if horiz:
vals = vals[::-1]
cd.add_series(pick(s, "label", default="Série"), tuple(vals))
x, y, w, h = self._chart_zone(d)
"""
P2_OLD = """ 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)
"""
P2_NEW = """ def cat_label(i, label):
sz = 9 if len(str(label)) > 14 else 10
self._text(slide, zx + i * slot + 0.05, chart_b + 0.15,
slot - 0.1, 1.05, label, size=sz,
color=self.C["slate"], align=PP_ALIGN.CENTER)
"""
def fail(m):
print(" ! %s" % m)
sys.exit(1)
def main():
if not TARGET.exists():
fail("%s introuvable." % TARGET)
c = TARGET.read_text(encoding="utf-8")
if "cats = cats[::-1]" in c:
fail("Déjà patché — rien à faire.")
for i, old in enumerate((P1_OLD, P2_OLD), 1):
if c.count(old) != 1:
fail("Ancre F%d introuvable ou non unique." % i)
shutil.copy2(TARGET, str(TARGET) + ".bak-c5fin")
c = c.replace(P1_OLD, P1_NEW).replace(P2_OLD, P2_NEW)
TARGET.write_text(c, encoding="utf-8")
try:
py_compile.compile(str(TARGET), doraise=True)
except py_compile.PyCompileError as e:
shutil.copy2(str(TARGET) + ".bak-c5fin", TARGET)
fail("Compilation : %s" % e)
print(" + F1 (ordre bar horizontal) + F2 (labels waterfall) OK.")
if __name__ == "__main__":
main()
+16
View File
@@ -338,11 +338,27 @@ annoté complet mis à jour, pas seulement la partie modifiée.
--- ---
## LANGUE DU DECK (RÈGLE ABSOLUE)
Le contenu que tu reçois peut être dans n'importe quelle langue
(français, anglais, autre). **Tu conserves TOUJOURS la langue du plan
reçu**, mot pour mot, y compris pour les titres, les bullets, les
labels, les légendes et les notes.
Ces instructions sont en français — c'est la langue de TON pilotage,
jamais celle du livrable. Un plan en anglais produit un deck 100 %
anglais. Ne traduis JAMAIS, ne mélange JAMAIS deux langues dans un
même deck.
## RÈGLES ABSOLUES ## RÈGLES ABSOLUES
- Tu utilises UNIQUEMENT les 21 layouts du catalogue. - Tu utilises UNIQUEMENT les 21 layouts du catalogue.
- Tu reprends le Markdown INTÉGRALEMENT — aucune perte de contenu. - Tu reprends le Markdown INTÉGRALEMENT — aucune perte de contenu.
- Tu ajoutes UNIQUEMENT les lignes `@layout:` et `@note:`. - Tu ajoutes UNIQUEMENT les lignes `@layout:` et `@note:`.
- Ta réponse se termine à la DERNIÈRE ligne de contenu de la dernière
slide. AUCUN commentaire, bilan, synthèse ou justification après —
tout texte postérieur serait transcrit dans le deck comme du contenu.
- Tu ne produis ni YAML, ni plan séparé, ni JSON. - Tu ne produis ni YAML, ni plan séparé, ni JSON.
- `cover_split` en premier, `end_slide` en dernier, toujours. - `cover_split` en premier, `end_slide` en dernier, toujours.
+16
View File
@@ -101,11 +101,27 @@ annoté complet mis à jour, pas seulement la partie modifiée.
--- ---
## LANGUE DU DECK (RÈGLE ABSOLUE)
Le contenu que tu reçois peut être dans n'importe quelle langue
(français, anglais, autre). **Tu conserves TOUJOURS la langue du plan
reçu**, mot pour mot, y compris pour les titres, les bullets, les
labels, les légendes et les notes.
Ces instructions sont en français — c'est la langue de TON pilotage,
jamais celle du livrable. Un plan en anglais produit un deck 100 %
anglais. Ne traduis JAMAIS, ne mélange JAMAIS deux langues dans un
même deck.
## RÈGLES ABSOLUES ## RÈGLES ABSOLUES
- Tu utilises UNIQUEMENT les 21 layouts du catalogue. - Tu utilises UNIQUEMENT les 21 layouts du catalogue.
- Tu reprends le Markdown INTÉGRALEMENT — aucune perte de contenu. - Tu reprends le Markdown INTÉGRALEMENT — aucune perte de contenu.
- Tu ajoutes UNIQUEMENT les lignes `@layout:` et `@note:`. - Tu ajoutes UNIQUEMENT les lignes `@layout:` et `@note:`.
- Ta réponse se termine à la DERNIÈRE ligne de contenu de la dernière
slide. AUCUN commentaire, bilan, synthèse ou justification après —
tout texte postérieur serait transcrit dans le deck comme du contenu.
- Tu ne produis ni YAML, ni plan séparé, ni JSON. - Tu ne produis ni YAML, ni plan séparé, ni JSON.
- `cover_split` en premier, `end_slide` en dernier, toujours. - `cover_split` en premier, `end_slide` en dernier, toujours.
+12 -7
View File
@@ -451,13 +451,6 @@ bullets:
--- ---
### Notes du présentateur
Si une slide du plan contient une ligne « Notes : ... », transcris son
contenu dans un champ `notes` de la slide YAML (chaîne simple). Les
lignes de justification du Designer (commençant par →) ne sont NI du
contenu NI des notes : ignore-les.
## GESTION DE LA LONGUEUR ## GESTION DE LA LONGUEUR
Si la présentation dépasse 8 slides, tu travailles en blocs : Si la présentation dépasse 8 slides, tu travailles en blocs :
@@ -469,6 +462,18 @@ Le YAML de chaque bloc doit être **syntaxiquement valide indépendamment** —
--- ---
## LANGUE DU DECK (RÈGLE ABSOLUE)
Le contenu que tu reçois peut être dans n'importe quelle langue
(français, anglais, autre). **Tu conserves TOUJOURS la langue du plan
reçu**, mot pour mot, y compris pour les titres, les bullets, les
labels, les légendes et les notes.
Ces instructions sont en français — c'est la langue de TON pilotage,
jamais celle du livrable. Un plan en anglais produit un deck 100 %
anglais. Ne traduis JAMAIS, ne mélange JAMAIS deux langues dans un
même deck.
## RÈGLES ABSOLUES ## RÈGLES ABSOLUES
- Tu utilises UNIQUEMENT les 21 layouts du catalogue — aucun autre nom n'existe - Tu utilises UNIQUEMENT les 21 layouts du catalogue — aucun autre nom n'existe
+12
View File
@@ -81,6 +81,18 @@ Le YAML de chaque bloc doit être **syntaxiquement valide indépendamment** —
--- ---
## LANGUE DU DECK (RÈGLE ABSOLUE)
Le contenu que tu reçois peut être dans n'importe quelle langue
(français, anglais, autre). **Tu conserves TOUJOURS la langue du plan
reçu**, mot pour mot, y compris pour les titres, les bullets, les
labels, les légendes et les notes.
Ces instructions sont en français — c'est la langue de TON pilotage,
jamais celle du livrable. Un plan en anglais produit un deck 100 %
anglais. Ne traduis JAMAIS, ne mélange JAMAIS deux langues dans un
même deck.
## RÈGLES ABSOLUES ## RÈGLES ABSOLUES
- Tu utilises UNIQUEMENT les 21 layouts du catalogue — aucun autre nom n'existe - Tu utilises UNIQUEMENT les 21 layouts du catalogue — aucun autre nom n'existe
+9 -2
View File
@@ -1711,14 +1711,20 @@ class RenderEngineV2:
if len(series) > 3: if len(series) > 3:
print(f" ~ bar_chart : {len(series)} séries → 3 (borne)") print(f" ~ bar_chart : {len(series)} séries → 3 (borne)")
series = series[:3] series = series[:3]
horiz = bool(d.get("horizontal"))
if horiz:
# BAR_CLUSTERED trace de bas en haut : on inverse pour un
# ordre de lecture naturel (1re catégorie du YAML en haut).
cats = cats[::-1]
cd = CategoryChartData() cd = CategoryChartData()
cd.categories = cats cd.categories = cats
for s in series: for s in series:
vals = [self._num(v) for v in (s.get("values") or [])][:len(cats)] vals = [self._num(v) for v in (s.get("values") or [])][:len(cats)]
vals += [0.0] * (len(cats) - len(vals)) vals += [0.0] * (len(cats) - len(vals))
if horiz:
vals = vals[::-1]
cd.add_series(pick(s, "label", default="Série"), tuple(vals)) cd.add_series(pick(s, "label", default="Série"), tuple(vals))
x, y, w, h = self._chart_zone(d) x, y, w, h = self._chart_zone(d)
horiz = bool(d.get("horizontal"))
ctype = (XL_CHART_TYPE.BAR_CLUSTERED if horiz ctype = (XL_CHART_TYPE.BAR_CLUSTERED if horiz
else XL_CHART_TYPE.COLUMN_CLUSTERED) else XL_CHART_TYPE.COLUMN_CLUSTERED)
ch = self._chart_base(slide, x, y, w, h, ctype, cd) ch = self._chart_base(slide, x, y, w, h, ctype, cd)
@@ -1929,8 +1935,9 @@ class RenderEngineV2:
align=PP_ALIGN.CENTER, fit=False) align=PP_ALIGN.CENTER, fit=False)
def cat_label(i, label): def cat_label(i, label):
sz = 9 if len(str(label)) > 14 else 10
self._text(slide, zx + i * slot + 0.05, chart_b + 0.15, self._text(slide, zx + i * slot + 0.05, chart_b + 0.15,
slot - 0.1, 1.05, label, size=10, slot - 0.1, 1.05, label, size=sz,
color=self.C["slate"], align=PP_ALIGN.CENTER) color=self.C["slate"], align=PP_ALIGN.CENTER)
def connector(x1, x2, level): def connector(x1, x2, level):
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# resilience_boot.sh — Résilience C2 · à planifier en tâche DSM
# « Au démarrage », utilisateur ROOT (recrée la règle sudoers).
# Rejouable sans risque (idempotent). Journalise dans resilience.log.
BASE=/volume1/homes/Master/App/Sliding/python-pptx
LOG="$BASE/resilience.log"
TS=$(date '+%Y-%m-%d %H:%M:%S')
# 1. Règle sudoers de la preview (sautée par les MAJ DSM majeures)
SUDOERS=/etc/sudoers.d/sliding-preview
if [ ! -f "$SUDOERS" ]; then
printf 'Master ALL=(root) NOPASSWD: %s/preview.sh\n' "$BASE" > "$SUDOERS"
chmod 440 "$SUDOERS"
echo "$TS [sudoers] regle sliding-preview RECREEE" >> "$LOG"
fi
# 2. Purge des previews de plus de 30 jours (le disque du DS218 est petit)
N=$(find "$BASE/projets" -type d -name "*_previews" -mtime +30 2>/dev/null | wc -l)
if [ "$N" -gt 0 ]; then
find "$BASE/projets" -type d -name "*_previews" -mtime +30 -exec rm -rf {} + 2>/dev/null
echo "$TS [purge] $N dossier(s) de previews > 30j supprimes" >> "$LOG"
fi
# 3. L'image docker de la preview a-t-elle survécu ?
DOCKER=/volume1/@appstore/ContainerManager/usr/bin/docker
if [ -x "$DOCKER" ]; then
if ! "$DOCKER" image inspect sliding-preview >/dev/null 2>&1; then
echo "$TS [docker] IMAGE sliding-preview ABSENTE — rebuild requis (voir fiche systeme Trilium)" >> "$LOG"
fi
else
echo "$TS [docker] demon/binaire docker indisponible" >> "$LOG"
fi
+1 -1
View File
@@ -60,7 +60,7 @@
{% if previews %} {% if previews %}
<div class="gallery"> <div class="gallery">
{% for n in previews %} {% for n in previews %}
<figure><img src="/p/{{ p.slug }}/previews/{{ n }}" loading="lazy"> <figure><img src="/p/{{ p.slug }}/previews/{{ n }}?v={{ cache_v }}" loading="lazy">
<figcaption>{{ n }}</figcaption></figure> <figcaption>{{ n }}</figcaption></figure>
{% endfor %} {% endfor %}
</div> </div>
+68 -31
View File
@@ -191,15 +191,16 @@ async def project_page(request):
entry = NARRATORS.get(slug) entry = NARRATORS.get(slug)
chat = entry["chat"] if entry else [] chat = entry["chat"] if entry else []
# previews existantes ? # previews existantes ?
previews = [] previews, cache_v = [], 0
pptx = info.get("dernier_pptx") pptx = _pptx_path(slug)
if pptx: if pptx:
prev_dir = Path(pptx).parent / f"{Path(pptx).stem}_previews" prev_dir = pptx.parent / f"{pptx.stem}_previews"
cache_v = int(pptx.stat().st_mtime)
if prev_dir.is_dir(): if prev_dir.is_dir():
previews = sorted(p.name for p in previews = sorted(p.name for p in
prev_dir.glob("slide-*.png")) prev_dir.glob("slide-*.png"))
return render("project.html", p=info, chat_html=_chat_html(chat), return render("project.html", p=info, chat_html=_chat_html(chat),
previews=previews) previews=previews, cache_v=cache_v)
# ── routes : chat Narrator ─────────────────────────────────────────────────── # ── routes : chat Narrator ───────────────────────────────────────────────────
@@ -285,28 +286,45 @@ async def formalise(request):
entry = _narrator(slug) entry = _narrator(slug)
if not entry["started"]: if not entry["started"]:
return HTMLResponse('<p class="err">Aucune conversation ' return HTMLResponse('<p class="err">Aucune conversation '
'Narrator — travaille le plan d\'abord.' 'Narrator — travaille le plan d\'abord, '
'</p>') 'puis sauvegarde-le.</p>')
proj = fac.Project(slug)
try: try:
md = entry["agent"].send( # Message IDENTIQUE à /formalise du facilitator (le Narrator
"formalise — produis la formalisation complète slide par " # produit un Markdown structuré #/##/### — PAS des "SLIDE N —
"slide (SLIDE N — titre, contenus, Notes éventuelles), " # layout" : ce sont le Designer puis l'Encoder qui les créent).
"sans commentaire autour.") md = entry["agent"].feedback(
entry["chat"].append(("system", "Formalisation demandée…")) "Formalise maintenant le plan compact validé en Markdown "
"structuré complet pour le Designer (format #, ##, ###), "
"en développant le contenu de chaque slide. Inclus les "
"slides tiroir en les marquant [OPT-XXX].")
entry["chat"].append(("system", "Formalisation demandée."))
entry["chat"].append(("narrator", md))
# Garde : le Narrator a-t-il vraiment produit un plan ?
if md.count("#") < 3 or len(md) < 300:
return HTMLResponse(
'<p class="err">Le Narrator n\'a pas renvoyé un plan '
'formalisé exploitable (réponse trop courte ou non '
'structurée). Vérifie le plan dans le chat, ajuste, '
'puis relance.</p>')
proj.mark_formalised(md)
r = api.formalise(slug, md) r = api.formalise(slug, md)
except Exception as e: except Exception as e:
return HTMLResponse(f'<p class="err">Échec : ' return HTMLResponse(f'<p class="err">Échec : '
f'{html.escape(str(e))}</p>') f'{html.escape(str(e))}</p>')
if r.get("error"): if r.get("error"):
return HTMLResponse(f'<p class="err">' return HTMLResponse(
f'{html.escape(str(r))}</p>') f'<p class="err">{html.escape(str(r.get("error")))}'
f'<br><span class="hint">Le Designer/Encoder n\'a pas su '
f'transcrire ce plan. Regarde la formalisation dans le '
f'chat : elle doit lister les slides une à une.</span>'
f'</p>')
return HTMLResponse( return HTMLResponse(
f'<p class="ok">Deck généré : {r["slides"]} slides — ' f'<p class="ok">Deck généré : {r["slides"]} slides — '
f'{html.escape(r["validation"])}<br>' f'{html.escape(r["validation"])}<br>'
f'<a href="/p/{slug}/download">Télécharger le PPTX</a> · ' f'<a class="btn" href="/p/{slug}/download">Télécharger le '
f'<button hx-post="/p/{slug}/preview" ' f'PPTX</a> <span class="hint">puis « Générer les aperçus » '
f'hx-target="#gallery" hx-indicator="#busy">Générer les ' f'ci-dessus pour les voir.</span></p>')
f'aperçus</button></p>')
async def preview(request): async def preview(request):
@@ -325,6 +343,8 @@ async def preview(request):
f'</figcaption></figure>' f'</figcaption></figure>'
for i, n in enumerate(r["slides"])) for i, n in enumerate(r["slides"]))
return HTMLResponse( return HTMLResponse(
f'<p class="ok">{len(r["slides"])} aperçus générés. Coche des '
f'slides pour les réviser.</p>'
f'<form hx-post="/p/{slug}/revise" hx-target="#pipeline-out" ' f'<form hx-post="/p/{slug}/revise" hx-target="#pipeline-out" '
f'hx-indicator="#busy"><div class="gallery">{cells}</div>' f'hx-indicator="#busy"><div class="gallery">{cells}</div>'
f'<input name="instruction" placeholder="Instruction de ' f'<input name="instruction" placeholder="Instruction de '
@@ -349,33 +369,45 @@ async def revise(request):
f'{html.escape(str(r))}</p>') f'{html.escape(str(r))}</p>')
return HTMLResponse( return HTMLResponse(
f'<p class="ok">Slides {r["slides_revisees"]} révisées, deck ' f'<p class="ok">Slides {r["slides_revisees"]} révisées, deck '
f'complet régénéré.<br><a href="/p/{slug}/download">' f'complet régénéré.<br><a class="btn" href="/p/{slug}/'
f'Télécharger</a> · <button hx-post="/p/{slug}/preview" ' f'download">Télécharger</a> <span class="hint">puis '
f'hx-target="#gallery" hx-indicator="#busy">Re-générer les ' f'« Générer les aperçus » pour revoir le deck.</span></p>')
f'aperçus</button></p>')
# ── routes : fichiers ──────────────────────────────────────────────────────── # ── routes : fichiers ────────────────────────────────────────────────────────
def _pptx_path(slug):
"""Chemin ABSOLU du dernier PPTX. project_state.json stocke un
chemin relatif à la racine du pipeline la webapp peut avoir un
cwd différent, on résout donc depuis BASE."""
proj = fac.Project(slug)
raw = (proj.load_state().get("dernier_pptx") or "").strip()
if not raw:
return None
p = Path(raw)
if not p.is_absolute():
p = BASE / p
return p if p.exists() else None
async def preview_png(request): async def preview_png(request):
guard = require_login(request) guard = require_login(request)
if guard: if guard:
return guard return guard
slug = request.path_params["slug"] slug = request.path_params["slug"]
name = request.path_params["name"] name = request.path_params["name"]
proj = fac.Project(slug) pptx = _pptx_path(slug)
state = proj.load_state()
pptx = state.get("dernier_pptx")
if not pptx: if not pptx:
return Response(status_code=404) return Response(status_code=404)
prev_dir = Path(pptx).parent / f"{Path(pptx).stem}_previews" prev_dir = pptx.parent / f"{pptx.stem}_previews"
try: try:
png = _safe_child(prev_dir, name) png = _safe_child(prev_dir, name)
except ValueError: except ValueError:
return Response(status_code=403) return Response(status_code=403)
if not png.exists(): if not png.exists():
return Response(status_code=404) return Response(status_code=404)
return FileResponse(str(png), media_type="image/png") return FileResponse(str(png), media_type="image/png",
headers={"Cache-Control": "no-store"})
async def download(request): async def download(request):
@@ -383,11 +415,16 @@ async def download(request):
if guard: if guard:
return guard return guard
slug = request.path_params["slug"] slug = request.path_params["slug"]
proj = fac.Project(slug) pptx = _pptx_path(slug)
pptx = proj.load_state().get("dernier_pptx") if not pptx:
if not pptx or not Path(pptx).exists(): return HTMLResponse(
return Response(status_code=404) '<p class="err">Aucun PPTX disponible pour ce projet — '
return FileResponse(pptx, filename=Path(pptx).name) 'lance « Formaliser le deck » d\'abord.</p>',
status_code=404)
return FileResponse(
str(pptx), filename=pptx.name,
media_type="application/vnd.openxmlformats-officedocument."
"presentationml.presentation")
async def health(request): async def health(request):