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"
"- 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: ...
# <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(
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"]}