Compare commits
3 Commits
42b0bf0713
...
bcb5e6d191
| Author | SHA1 | Date | |
|---|---|---|---|
| bcb5e6d191 | |||
| f17b1696d5 | |||
| dc92171f82 |
@@ -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
@@ -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"]}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user