diff --git a/encoder_schema.py b/encoder_schema.py index d2b7050..c476aed 100644 --- a/encoder_schema.py +++ b/encoder_schema.py @@ -47,7 +47,11 @@ SYSTEM_PROMPT = ( "d'important ; condenser légèrement si nécessaire.\n" "- Texte brut uniquement : aucun balisage Markdown (pas de **, __, #, `, " "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" "- Les nombres des champs numériques (start, end, x, y, numero) sont des " "entiers, pas des chaînes.\n" @@ -58,7 +62,22 @@ SYSTEM_PROMPT = ( "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: ... +# +# 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( r"^\s*SLIDE\s+(\d+)\s*[—–\-]+\s*([a-z][a-z0-9_]*)\s*$", re.MULTILINE) @@ -72,22 +91,60 @@ MD_PATTERNS = [ # ── 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): - """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}.""" - matches = list(SLIDE_RE.finditer(plan_md)) segments, errors = [], [] - if not matches: - return [], ["Aucune ligne 'SLIDE N — layout' détectée dans le plan."] 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): - 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) - seg = { - "position": int(m.group(1)), - "layout": layout, - "content": plan_md[m.start():end].strip(), - } + seg = {"position": int(m.group(1)), "layout": layout, + "content": plan_md[m.start():end].strip()} if layout not in valid: errors.append("Slide %d : layout '%s' sans schéma (connus : %s)" % (seg["position"], layout, @@ -121,7 +178,7 @@ def _post_with_retry(payload, api_key): % (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). Lève RuntimeError/ValueError en cas d'échec (isolé par l'appelant).""" schema = get_schema(layout) @@ -131,7 +188,9 @@ def encode_slide(content, layout, api_key, model=None): "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"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)}, ], "response_format": { @@ -181,6 +240,41 @@ def _accumulate(total, usage): 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 ──────────────────────────────────────────────────────────── 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). Retourne (data, usage, errors) : 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"} 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) 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: wanted = set(int(p) for p in only_positions) 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"])) try: content, u = encode_slide(seg["content"], seg["layout"], - api_key, model) + api_key, model, lang=lang) except (RuntimeError, ValueError, KeyError, json.JSONDecodeError) as e: errors.append("Slide %d (%s) : %s" @@ -215,6 +314,7 @@ def encode_plan(plan_md, api_key, model=None, only_positions=None, continue _accumulate(usage, u) content = strip_md(drop_nulls(content)) + content = strip_title_number(content) if seg["layout"] == "cover_split" and titre_presentation is None: titre_presentation = content.get("titre") slide = {"position": seg["position"], "layout": seg["layout"]} diff --git a/patches/patch_prompt_designer_meta.py b/patches/patch_prompt_designer_meta.py new file mode 100644 index 0000000..e48a4b6 --- /dev/null +++ b/patches/patch_prompt_designer_meta.py @@ -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") diff --git a/patches/patch_render_engine_c5fin.py b/patches/patch_render_engine_c5fin.py new file mode 100644 index 0000000..acadb1f --- /dev/null +++ b/patches/patch_render_engine_c5fin.py @@ -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() diff --git a/prompt_the_designer_injected_v3.md b/prompt_the_designer_injected_v3.md index f9035b0..d6c94ca 100644 --- a/prompt_the_designer_injected_v3.md +++ b/prompt_the_designer_injected_v3.md @@ -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 - Tu utilises UNIQUEMENT les 21 layouts du catalogue. - Tu reprends le Markdown INTÉGRALEMENT — aucune perte de contenu. - 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. - `cover_split` en premier, `end_slide` en dernier, toujours. diff --git a/prompt_the_designer_v3.md b/prompt_the_designer_v3.md index 7dc0c06..8f8b9b2 100644 --- a/prompt_the_designer_v3.md +++ b/prompt_the_designer_v3.md @@ -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 - Tu utilises UNIQUEMENT les 21 layouts du catalogue. - Tu reprends le Markdown INTÉGRALEMENT — aucune perte de contenu. - 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. - `cover_split` en premier, `end_slide` en dernier, toujours. diff --git a/prompt_the_encoder_injected_v2.md b/prompt_the_encoder_injected_v2.md index 38f3365..72ee333 100644 --- a/prompt_the_encoder_injected_v2.md +++ b/prompt_the_encoder_injected_v2.md @@ -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 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 - Tu utilises UNIQUEMENT les 21 layouts du catalogue — aucun autre nom n'existe diff --git a/prompt_the_encoder_v2.md b/prompt_the_encoder_v2.md index e62d808..9e1950a 100644 --- a/prompt_the_encoder_v2.md +++ b/prompt_the_encoder_v2.md @@ -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 - Tu utilises UNIQUEMENT les 21 layouts du catalogue — aucun autre nom n'existe diff --git a/render_engine_v2.py b/render_engine_v2.py index b880bc3..d1f19b7 100644 --- a/render_engine_v2.py +++ b/render_engine_v2.py @@ -1711,14 +1711,20 @@ class RenderEngineV2: if len(series) > 3: print(f" ~ bar_chart : {len(series)} séries → 3 (borne)") 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.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) - 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) @@ -1929,8 +1935,9 @@ class RenderEngineV2: align=PP_ALIGN.CENTER, fit=False) 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=10, + slot - 0.1, 1.05, label, size=sz, color=self.C["slate"], align=PP_ALIGN.CENTER) def connector(x1, x2, level):