fix: langue imposee par slide, titres sans numerotation, meta Designer tronquee, finitions C5

This commit is contained in:
2026-07-14 16:09:53 +02:00
parent 42b0bf0713
commit dc92171f82
8 changed files with 298 additions and 24 deletions
+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): for i, m in enumerate(matches):
end = matches[i + 1].start() if i + 1 < len(matches) else len(plan_md) 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):
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"]}
+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):