Improve synthesis display, PDF exports, and priority documentation

- home.tsx: add SynthesisText component — detects "Sur X : / Concernant X :"
  thematic prefixes and renders them in serif/teal, replaces raw whitespace-pre-line
- home.tsx: redesign client-side PDF — site color palette (#F9F7F1, #1b5f6a),
  Georgia serif typography, thematic prefix highlighting, tricolor stripe
- app.py: redesign consultation print export — same visual identity, server-side
  paragraph parsing with theme span injection before HTML-escaping
- transparence.tsx: rewrite priority explanation to be explicit about what
  "critique/haute/moyenne" means in practice (injection vs monitoring, why)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 10:05:28 +02:00
parent 8f904f4cf0
commit 41556d3ea2
3 changed files with 138 additions and 45 deletions
+45 -23
View File
@@ -907,41 +907,63 @@ def export_consultation_print(slug: str):
title_esc = html_module.escape(consultation["title"]) title_esc = html_module.escape(consultation["title"])
subject_esc = html_module.escape(consultation["subject"]) subject_esc = html_module.escape(consultation["subject"])
organizer_esc = html_module.escape(consultation.get("organizer_name") or "") organizer_esc = html_module.escape(consultation.get("organizer_name") or "")
synthesis_esc = html_module.escape(synthesis_text).replace("\n", "<br>")
now_str = dt.datetime.now(dt.timezone.utc).strftime("%d/%m/%Y %H:%M") now_str = dt.datetime.now(dt.timezone.utc).strftime("%d/%m/%Y %H:%M")
# Mise en forme du texte : "Sur X :" / "Concernant X :" en gras teal
import re as _re
_THEME_RE = _re.compile(
r'^((?:Sur|Concernant|À propos d[eu]?|En ce qui concerne|Quant à)[^:]{0,100}:\s?)([\s\S]*)$',
_re.IGNORECASE,
)
paragraphs = [p.strip() for p in synthesis_text.split("\n\n") if p.strip()]
body_html = ""
for para in paragraphs:
m = _THEME_RE.match(para)
if m:
prefix = html_module.escape(m.group(1))
rest = html_module.escape(m.group(2))
body_html += f'<p><span class="theme">{prefix}</span>{rest}</p>\n'
else:
body_html += f'<p>{html_module.escape(para)}</p>\n'
html_content = f"""<!DOCTYPE html> html_content = f"""<!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>{title_esc}</title> <title>{title_esc}</title>
<style> <style>
body {{ font-family: Georgia, serif; max-width: 800px; margin: 40px auto; padding: 0 20px; color: #1a1a1a; }} * {{ box-sizing: border-box; margin: 0; padding: 0; }}
.header {{ border-bottom: 3px solid #1b5f6a; padding-bottom: 16px; margin-bottom: 32px; }} body {{ font-family: Georgia, 'Times New Roman', serif; background: #F9F7F1; color: #1a1a1a; }}
.tricolore {{ display: flex; height: 6px; width: 60px; margin-bottom: 12px; }} .page {{ max-width: 700px; margin: 0 auto; background: #fff; min-height: 100vh; padding: 52px 56px; }}
.tricolore span {{ flex: 1; }} .tricolor {{ display: flex; height: 3px; margin-bottom: 40px; }}
h1 {{ font-size: 24px; margin: 0 0 6px; color: #1b5f6a; }} .tricolor span:nth-child(1) {{ flex: 1; background: #002395; }}
.meta {{ font-size: 13px; color: #666; margin: 4px 0; }} .tricolor span:nth-child(2) {{ flex: 1; background: #E5E5E5; }}
.synthesis {{ font-size: 15px; line-height: 1.8; }} .tricolor span:nth-child(3) {{ flex: 1; background: #ED2939; }}
.footer {{ margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 11px; color: #999; }} .label {{ font-family: 'Courier New', monospace; font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.14em; color: #1b5f6a; margin-bottom: 10px; }}
h1 {{ font-size: 1.5rem; font-weight: 700; color: #111; line-height: 1.25; margin-bottom: 4px; }}
.organizer {{ font-size: 0.85rem; font-weight: 600; color: #1b5f6a; margin-top: 6px; }}
.subject {{ font-size: 0.85rem; color: #555; margin-top: 4px; font-style: italic; }}
.meta {{ font-family: 'Courier New', monospace; font-size: 0.68rem; color: #999; margin-top: 16px; padding-top: 16px; border-top: 1px solid #E8E4DC; margin-bottom: 36px; }}
.body {{ font-size: 0.95rem; line-height: 1.85; color: #1a1a1a; }}
.body p {{ margin-bottom: 1.1em; }}
.theme {{ font-family: Georgia, serif; font-weight: 600; color: #1b5f6a; }}
.footer {{ margin-top: 52px; padding-top: 14px; border-top: 1px solid #E8E4DC; font-family: 'Courier New', monospace; font-size: 0.6rem; color: #bbb; display: flex; justify-content: space-between; gap: 16px; }}
@media print {{ body {{ background: white; }} .page {{ box-shadow: none; padding: 32px 40px; }} }}
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="page">
<div class="tricolore"> <div class="tricolor"><span></span><span></span><span></span></div>
<span style="background:#002395"></span> <p class="label">La Voix du Peuple &middot; Consultation citoyenne</p>
<span style="background:#ededed"></span>
<span style="background:#ed2939"></span>
</div>
<h1>{title_esc}</h1> <h1>{title_esc}</h1>
{f'<p class="meta"><strong>{organizer_esc}</strong></p>' if organizer_esc else ""} {f'<p class="organizer">{organizer_esc}</p>' if organizer_esc else ""}
<p class="meta">{subject_esc}</p> <p class="subject">{subject_esc}</p>
<p class="meta">Exporté le {now_str} {idea_count} contribution(s) intégrée(s)</p> <div class="meta">Exporté le {now_str} &nbsp;&middot;&nbsp; {idea_count} contribution(s) intégrée(s)</div>
</div> <div class="body">{body_html}</div>
<div class="synthesis">{synthesis_esc}</div> <div class="footer">
<div class="footer"> <span>lavoixdupeuple.fr</span>
La Voix du Peuple — Document généré automatiquement à partir de contributions citoyennes. <span>Modération DUDH &middot; PIDCP &middot; CEDH &middot; droit pénal français</span>
Ce contenu reflète l'expression des participants, pas une vérité établie. </div>
</div> </div>
<script>window.print();</script> <script>window.print();</script>
</body> </body>
+62 -17
View File
@@ -82,6 +82,34 @@ const VALEURS = [
// Clé hCaptcha — activée si la variable d'environnement est définie // Clé hCaptcha — activée si la variable d'environnement est définie
const HCAPTCHA_SITE_KEY = import.meta.env.VITE_HCAPTCHA_SITE_KEY as string | undefined; const HCAPTCHA_SITE_KEY = import.meta.env.VITE_HCAPTCHA_SITE_KEY as string | undefined;
// Détecte "Sur X :" / "Concernant Y :" en début de bloc et met le préfixe en valeur
function SynthesisText({ text }: { text: string }) {
const THEME_RE = /^((?:Sur|Concernant|À propos d[eu]?|En ce qui concerne|Quant à)[^:]{0,100}:\s?)([\s\S]*)$/i;
const blocks = text.split(/\n{2,}/);
return (
<div className="space-y-5">
{blocks.map((block, i) => {
const trimmed = block.trim();
if (!trimmed) return null;
const m = trimmed.match(THEME_RE);
if (m) {
return (
<p key={i} className="text-sm md:text-base leading-[1.85] text-foreground">
<span className="font-serif font-semibold text-primary">{m[1]}</span>
{m[2]}
</p>
);
}
return (
<p key={i} className="text-sm md:text-base leading-[1.85] text-foreground">
{trimmed}
</p>
);
})}
</div>
);
}
const API_BASE = import.meta.env.VITE_API_URL ?? ""; const API_BASE = import.meta.env.VITE_API_URL ?? "";
export default function Home() { export default function Home() {
@@ -246,43 +274,60 @@ export default function Home() {
if (!synthesis?.text) return; if (!synthesis?.text) return;
const date = format(new Date(), "d MMMM yyyy 'à' HH:mm", { locale: fr }); const date = format(new Date(), "d MMMM yyyy 'à' HH:mm", { locale: fr });
const nb = synthesis.ideaCount ?? 0; const nb = synthesis.ideaCount ?? 0;
const safe = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const THEME_RE = /^((?:Sur|Concernant|À propos d[eu]?|En ce qui concerne|Quant à)[^:]{0,100}:\s?)([\s\S]*)$/i;
const formattedText = synthesis.text
.split(/\n{2,}/)
.filter(Boolean)
.map((block) => {
const t = block.trim();
const m = t.match(THEME_RE);
if (m) return `<p><span class="theme">${esc(m[1])}</span>${esc(m[2])}</p>`;
return `<p>${esc(t)}</p>`;
})
.join("\n");
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>La Voix du Peuple — Synthèse</title> <title>La Voix du Peuple — Synthèse</title>
<style> <style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', 'Segoe UI', sans-serif; max-width: 680px; margin: 48px auto; color: #1a1a2e; line-height: 1.75; padding: 0 24px; } body { font-family: Georgia, 'Times New Roman', serif; background: #F9F7F1; color: #1a1a1a; }
.tricolor { display: flex; height: 4px; width: 80px; margin-bottom: 32px; border-radius: 2px; overflow: hidden; } .page { max-width: 680px; margin: 0 auto; background: #fff; min-height: 100vh; padding: 52px 56px; }
.tricolor { display: flex; height: 3px; margin-bottom: 40px; }
.tricolor span:nth-child(1) { flex: 1; background: #002395; } .tricolor span:nth-child(1) { flex: 1; background: #002395; }
.tricolor span:nth-child(2) { flex: 1; background: #EDEDED; } .tricolor span:nth-child(2) { flex: 1; background: #E5E5E5; }
.tricolor span:nth-child(3) { flex: 1; background: #ED2939; } .tricolor span:nth-child(3) { flex: 1; background: #ED2939; }
h1 { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 4px; color: #1a1a2e; } .label { font-family: 'Courier New', monospace; font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.14em; color: #1b5f6a; margin-bottom: 10px; }
.meta { font-size: 0.72rem; color: #888; font-family: monospace; margin-bottom: 36px; } h1 { font-size: 1.55rem; font-weight: 700; color: #111; line-height: 1.2; margin-bottom: 6px; }
.text { font-size: 1rem; white-space: pre-line; color: #1a1a2e; } .meta { font-family: 'Courier New', monospace; font-size: 0.68rem; color: #999; margin-bottom: 36px; padding-bottom: 24px; border-bottom: 1px solid #E8E4DC; }
.footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid #eee; font-size: 0.7rem; color: #bbb; font-family: monospace; display: flex; justify-content: space-between; } .body { font-size: 0.95rem; line-height: 1.85; color: #1a1a1a; }
@media print { body { margin: 32px auto; } } .body p { margin-bottom: 1.1em; }
.theme { font-family: Georgia, serif; font-weight: 600; color: #1b5f6a; }
.footer { margin-top: 52px; padding-top: 14px; border-top: 1px solid #E8E4DC; font-family: 'Courier New', monospace; font-size: 0.62rem; color: #bbb; display: flex; justify-content: space-between; gap: 16px; }
@media print { body { background: white; } .page { box-shadow: none; padding: 32px 40px; } }
</style> </style>
</head> </head>
<body> <body>
<div class="page">
<div class="tricolor"><span></span><span></span><span></span></div> <div class="tricolor"><span></span><span></span><span></span></div>
<h1>La Voix du Peuple Synthèse citoyenne</h1> <p class="label">La Voix du Peuple &middot; Synthèse citoyenne</p>
<div class="meta">Générée le ${safe(date)} &nbsp;·&nbsp; ${nb} contribution${nb !== 1 ? "s" : ""} intégrée${nb !== 1 ? "s" : ""}</div> <h1>Synthèse des contributions</h1>
<div class="text">${safe(synthesis.text)}</div> <div class="meta">Générée le ${esc(date)} &nbsp;&middot;&nbsp; ${nb} contribution${nb !== 1 ? "s" : ""} intégrée${nb !== 1 ? "s" : ""}</div>
<div class="body">${formattedText}</div>
<div class="footer"> <div class="footer">
<span>lavoixdupeuple.fr</span> <span>lavoixdupeuple.fr</span>
<span>Document généré automatiquement — modération selon DUDH / PIDCP / CEDH</span> <span>Modération DUDH · PIDCP · CEDH · droit pénal français</span>
</div> </div>
</div>
<script>window.print();</script>
</body> </body>
</html>`; </html>`;
const w = window.open("", "_blank"); const w = window.open("", "_blank");
if (w) { if (w) {
w.document.write(html); w.document.write(html);
w.document.close(); w.document.close();
w.onload = () => w.print();
} }
}; };
@@ -682,10 +727,10 @@ export default function Home() {
</div> </div>
) : synthesis?.text ? ( ) : synthesis?.text ? (
<div <div
className="animate-in fade-in slide-in-from-bottom-2 duration-700 ease-out text-sm md:text-base leading-relaxed text-foreground space-y-4 whitespace-pre-line" className="animate-in fade-in slide-in-from-bottom-2 duration-700 ease-out"
data-testid="text-synthesis-content" data-testid="text-synthesis-content"
> >
{synthesis.text} <SynthesisText text={synthesis.text} />
</div> </div>
) : synthesis ? ( ) : synthesis ? (
<p className="text-muted-foreground italic text-sm py-16 text-center"> <p className="text-muted-foreground italic text-sm py-16 text-center">
@@ -97,11 +97,37 @@ export default function Transparence() {
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-foreground/90 text-sm font-semibold">Articles surveillés</p> <p className="text-foreground/90 text-sm font-semibold">Articles surveillés</p>
<p className="text-foreground/80 text-xs leading-relaxed"> <div className="text-foreground/80 text-xs leading-relaxed space-y-2">
Les articles de priorité <strong>critique</strong> et <strong>haute</strong> sont injectés dans chaque prompt. <p>
Les articles <strong>moyenne</strong> sont suivis pour détecter les changements législatifs mais ne sont pas injectés La priorité détermine deux choses indépendantes : <em>est-ce que le texte est injecté dans le prompt ?</em>
(le cadre légal statique les couvre déjà). et <em>à quelle fréquence le surveille-t-on ?</em>
</p> </p>
<ul className="space-y-1.5 ml-3">
<li>
<span className="font-semibold text-red-600 dark:text-red-400">Critique</span>
texte injecté en temps réel <strong>et</strong> suivi en priorité.
Ces articles sont amendés fréquemment (ex. : Art. 225-1 sur la discrimination a é élargi plusieurs fois
depuis 2021). Sans injection live, le modèle risque de travailler sur une liste de critères obsolète
et de manquer une infraction récemment ajoutée.
</li>
<li>
<span className="font-semibold text-amber-600 dark:text-amber-400">Haute</span>
texte injecté en temps réel. Le libellé exact compte pour une décision correcte
(ex. : les seuils de peines, les conditions d'aggravation, la définition précise de l'acte incriminé).
Ces articles sont plus stables, mais leur formulation spécifique ne peut pas être laissée à la mémoire d'entraînement.
</li>
<li>
<span className="font-semibold text-muted-foreground">Moyenne</span>
<strong>non injecté</strong>. Le cadre légal statique du prompt couvre déjà ces articles
(textes stables depuis des décennies : diffamation depuis 1944, crimes contre l'humanité depuis 1994).
Ils sont quand même surveillés pour détecter toute modification législative surprise.
</li>
</ul>
<p>
Dans tous les cas, si l'API Légifrance est indisponible au moment de l'analyse, le filtrage continue
sans interruption sur le cadre statique sans dégradation visible pour l'utilisateur.
</p>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs border-collapse mt-2"> <table className="w-full text-xs border-collapse mt-2">
<thead> <thead>