Conformité RGPD (P3) + transparence éditoriale (P4)

P3 — RGPD :
- Table `consents` + `POST /api/consent` (art. 7.1 — preuve du consentement)
- Dialogue de consentement explicite avant la première contribution (art. 9.2.a)
- Pages `/mentions-legales` et `/politique-confidentialite`
- `docs/RGPD.md` — registre des traitements, bases légales, sous-traitants
- `getVisitorId()` exporté depuis l'API client React

P4 — Transparence éditoriale :
- Page `/contributions-brutes` avec pagination et export JSON/CSV
- `GET /api/contributions`, `GET /api/contributions/export/{json,csv}`
- `GET /api/stats/public` — stats publiques sans données de rejet
- Label de transparence IA sur la colonne de synthèse
- Compteurs (acceptées / soumises) dans le bandeau d'intro
- `docs/PROMPTS_IA.md` — prompts intégraux publiés + analyse des biais
- Pied de page avec liens légaux et transparence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 22:30:30 +02:00
parent 45edc1fa77
commit a7b7684e87
12 changed files with 1354 additions and 50 deletions
+98
View File
@@ -44,6 +44,7 @@ from database import (
init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis, init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis,
get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas, get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas,
override_idea, flag_idea, unflag_idea, override_idea, flag_idea, unflag_idea,
create_consent, get_public_contributions, get_public_stats,
) )
from ai_agent import filter_idea, synthesize_ideas from ai_agent import filter_idea, synthesize_ideas
@@ -422,6 +423,103 @@ def get_synthesis_route():
}) })
# ─── Routes publiques : consentement, stats, contributions ───────────────────
@app.post("/api/consent")
@limiter.limit("5 per minute")
def record_consent():
"""Enregistre le consentement explicite d'un citoyen (art. 9.2.a RGPD)."""
if not request.is_json:
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
data = request.get_json(silent=True) or {}
consent_version = sanitize_text(str(data.get("consent_version", "1.0") or "1.0"))[:20]
raw_fp = request.headers.get("X-Visitor-Id", "").strip()
fingerprint_hash = (
hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else "anonymous"
)
create_consent(fingerprint_hash, consent_version)
logger.info("Consentement enregistré — fingerprint: %s... | version: %s", fingerprint_hash[:8], consent_version)
return jsonify({"ok": True}), 201
@app.get("/api/stats/public")
@limiter.limit("120 per minute")
def public_stats():
"""Statistiques publiques : total soumis et acceptées (sans données de rejet)."""
return jsonify(get_public_stats())
@app.get("/api/contributions")
@limiter.limit("60 per minute")
def public_contributions():
"""Liste paginée des contributions acceptées — vue publique anti-chronologique."""
try:
page = max(1, int(request.args.get("page", 1)))
per_page = min(50, max(5, int(request.args.get("per_page", 20))))
except (ValueError, TypeError):
page, per_page = 1, 20
contributions, total = get_public_contributions(page=page, per_page=per_page)
pages = max(1, -(-total // per_page))
return jsonify({
"contributions": [
{
"id": c["id"],
"content": c["content"],
"author": c.get("author"),
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
}
for c in contributions
],
"total": total,
"page": page,
"perPage": per_page,
"pages": pages,
})
@app.get("/api/contributions/export/json")
@limiter.limit("10 per minute")
def export_contributions_json():
"""Exporte l'intégralité des contributions acceptées en JSON (champs publics)."""
contributions, _ = get_public_contributions(page=1, per_page=10000)
payload = [
{
"id": c["id"],
"content": c["content"],
"author": c.get("author"),
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
}
for c in contributions
]
return Response(
json.dumps(payload, ensure_ascii=False, indent=2),
mimetype="application/json",
headers={"Content-Disposition": "attachment; filename=contributions.json"},
)
@app.get("/api/contributions/export/csv")
@limiter.limit("10 per minute")
def export_contributions_csv():
"""Exporte les contributions acceptées en CSV (champs publics uniquement — pas de fingerprint)."""
contributions, _ = get_public_contributions(page=1, per_page=10000)
output = io.StringIO()
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
writer.writerow(["id", "content", "author", "created_at"])
for c in contributions:
writer.writerow([
c.get("id"),
c.get("content", ""),
c.get("author", ""),
c["created_at"].isoformat() if c.get("created_at") else "",
])
return Response(
output.getvalue().encode("utf-8-sig"),
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
)
# ─── Route publique : signalement ──────────────────────────────────────────── # ─── Route publique : signalement ────────────────────────────────────────────
@app.post("/api/ideas/<int:idea_id>/flag") @app.post("/api/ideas/<int:idea_id>/flag")
+61
View File
@@ -62,6 +62,18 @@ def init_db() -> None:
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) )
""") """)
# Table de traçabilité des consentements RGPD (art. 7.1 — charge de la preuve)
cur.execute("""
CREATE TABLE IF NOT EXISTS consents (
id SERIAL PRIMARY KEY,
fingerprint_hash VARCHAR(64) NOT NULL,
consent_version VARCHAR(20) NOT NULL,
consented_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
cur.execute(
"CREATE INDEX IF NOT EXISTS idx_consents_fingerprint ON consents(fingerprint_hash)"
)
logger.info("Base de données initialisée.") logger.info("Base de données initialisée.")
@@ -85,6 +97,55 @@ def insert_idea(
return dict(cur.fetchone()) return dict(cur.fetchone())
def create_consent(fingerprint_hash: str, consent_version: str) -> dict:
"""Enregistre un consentement explicite (art. 7.1 RGPD — preuve)."""
with db_cursor() as cur:
cur.execute(
"""
INSERT INTO consents (fingerprint_hash, consent_version)
VALUES (%s, %s)
RETURNING *
""",
(fingerprint_hash, consent_version),
)
return dict(cur.fetchone())
def get_public_contributions(page: int = 1, per_page: int = 20) -> tuple[list[dict], int]:
"""Retourne les contributions acceptées paginées pour la vue publique."""
offset = (page - 1) * per_page
with db_cursor() as cur:
cur.execute(
"SELECT COUNT(*) as total FROM ideas WHERE accepted = TRUE AND flagged = FALSE"
)
total = cur.fetchone()["total"]
cur.execute(
"""
SELECT id, content, author, created_at
FROM ideas
WHERE accepted = TRUE AND flagged = FALSE
ORDER BY created_at DESC
LIMIT %s OFFSET %s
""",
(per_page, offset),
)
rows = [dict(row) for row in cur.fetchall()]
return rows, total
def get_public_stats() -> dict:
"""Statistiques publiques — ne révèle pas les chiffres de rejet."""
with db_cursor() as cur:
cur.execute("SELECT COUNT(*) as total FROM ideas")
total = cur.fetchone()["total"]
cur.execute("SELECT COUNT(*) as accepted FROM ideas WHERE accepted = TRUE")
accepted = cur.fetchone()["accepted"]
cur.execute("SELECT updated_at FROM synthesis LIMIT 1")
row = cur.fetchone()
last_updated = row["updated_at"].isoformat() if row and row.get("updated_at") else None
return {"total": total, "accepted": accepted, "lastUpdated": last_updated}
def get_accepted_ideas() -> list[dict]: def get_accepted_ideas() -> list[dict]:
with db_cursor() as cur: with db_cursor() as cur:
cur.execute( cur.execute(
+34 -1
View File
@@ -1,6 +1,6 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2 // Copyright (C) 2026 billisdead — Licence EUPL-1.2
import React from "react"; import React from "react";
import { Switch, Route, Router as WouterRouter, Link } from "wouter"; import { Switch, Route, Router as WouterRouter, Link, useLocation } from "wouter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
@@ -10,6 +10,9 @@ import About from "@/pages/about";
import Transparence from "@/pages/transparence"; import Transparence from "@/pages/transparence";
import Flyer from "@/pages/flyer"; import Flyer from "@/pages/flyer";
import Admin from "@/pages/admin"; import Admin from "@/pages/admin";
import LegalNotice from "@/pages/legal-notice";
import PrivacyPolicy from "@/pages/privacy-policy";
import ContributionsBrutes from "@/pages/contributions-brutes";
import { AccessibilityProvider } from "@/hooks/use-accessibility"; import { AccessibilityProvider } from "@/hooks/use-accessibility";
import { AccessibilityPanel } from "@/components/accessibility-panel"; import { AccessibilityPanel } from "@/components/accessibility-panel";
import { setVisitorId } from "@workspace/api-client-react"; import { setVisitorId } from "@workspace/api-client-react";
@@ -55,7 +58,32 @@ function Navbar() {
); );
} }
function Footer() {
return (
<footer className="border-t border-border/40 bg-background py-5">
<div className="container mx-auto max-w-7xl px-4 md:px-8">
<div className="flex flex-col sm:flex-row gap-3 sm:gap-6 items-start sm:items-center justify-between text-xs text-muted-foreground font-mono">
<span className="font-serif font-semibold text-foreground/70 text-sm">La Voix du Peuple</span>
<nav className="flex flex-wrap gap-4" aria-label="Liens légaux et transparence">
<Link href="/mentions-legales" className="hover:text-foreground transition-colors">
Mentions légales
</Link>
<Link href="/politique-confidentialite" className="hover:text-foreground transition-colors">
Confidentialité
</Link>
<Link href="/contributions-brutes" className="hover:text-foreground transition-colors">
Contributions brutes
</Link>
</nav>
<span className="opacity-50">EUPL-1.2 · billisdead</span>
</div>
</div>
</footer>
);
}
function Router() { function Router() {
const [location] = useLocation();
return ( return (
<div className="min-h-screen flex flex-col font-sans"> <div className="min-h-screen flex flex-col font-sans">
{/* Lien d'évitement pour lecteurs d'écran */} {/* Lien d'évitement pour lecteurs d'écran */}
@@ -73,9 +101,14 @@ function Router() {
<Route path="/transparence" component={Transparence} /> <Route path="/transparence" component={Transparence} />
<Route path="/flyer" component={Flyer} /> <Route path="/flyer" component={Flyer} />
<Route path="/admin" component={Admin} /> <Route path="/admin" component={Admin} />
<Route path="/mentions-legales" component={LegalNotice} />
<Route path="/politique-confidentialite" component={PrivacyPolicy} />
<Route path="/contributions-brutes" component={ContributionsBrutes} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
</main> </main>
{/* Pied de page masqué sur la page d'accueil (layout plein-écran) */}
{location !== "/" && <Footer />}
</div> </div>
); );
} }
@@ -0,0 +1,100 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
import React from "react";
import { Link } from "wouter";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { ShieldCheck } from "lucide-react";
interface ConsentDialogProps {
open: boolean;
onConsent: () => void;
onCancel: () => void;
}
export function ConsentDialog({ open, onConsent, onCancel }: ConsentDialogProps) {
const [checked, setChecked] = React.useState(false);
// Réinitialise la case à cocher à chaque ouverture
React.useEffect(() => {
if (open) setChecked(false);
}, [open]);
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel(); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 font-serif">
<ShieldCheck className="h-5 w-5 text-primary" />
Votre consentement
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3 text-sm text-foreground/80 leading-relaxed pt-1">
<p>
Avant de contribuer, nous avons besoin de votre accord explicite
sur le traitement de vos données.
</p>
<div className="bg-muted/50 border border-border/60 rounded-sm p-4 space-y-2 text-xs">
<p className="font-semibold text-foreground/90">Ce que nous collectons :</p>
<ul className="space-y-1 text-foreground/75">
<li> Le texte de votre contribution (opinion politique donnée sensible au sens de l'art. 9 RGPD)</li>
<li> Un pseudonyme facultatif librement choisi</li>
<li> La date et l'heure de la soumission</li>
<li> Un identifiant technique non-personnel pour la protection anti-abus (hash non réversible)</li>
</ul>
<p className="font-semibold text-foreground/90 pt-1">Durée de conservation :</p>
<p className="text-foreground/75">24 mois, puis suppression ou anonymisation.</p>
<p className="font-semibold text-foreground/90 pt-1">Sous-traitant IA :</p>
<p className="text-foreground/75">
Votre contribution est analysée par Mistral AI (France) pour modération et synthèse.
Aucun transfert hors Union européenne.
</p>
</div>
<p className="text-xs text-foreground/60">
Vous pouvez retirer votre consentement et demander la suppression de vos données
à tout moment via piron.antoine@gmail.com. Détails :{" "}
<Link href="/politique-confidentialite" className="underline text-primary" onClick={onCancel}>
politique de confidentialité
</Link>
.
</p>
</div>
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-3 py-2">
<Checkbox
id="consent-check"
checked={checked}
onCheckedChange={(val) => setChecked(val === true)}
/>
<Label
htmlFor="consent-check"
className="text-sm leading-relaxed cursor-pointer"
>
J'ai lu et j'accepte la politique de confidentialité. Je consens
au traitement de mes données, y compris mes opinions politiques,
pour la finalité décrite ci-dessus.
</Label>
</div>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={onCancel}>
Annuler
</Button>
<Button onClick={onConsent} disabled={!checked}>
Accepter et contribuer
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,193 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
import React from "react";
import { Link } from "wouter";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import {
ArrowLeft, Download, ChevronLeft, ChevronRight, Loader2, AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
const API_BASE = import.meta.env.VITE_API_URL ?? "";
const PER_PAGE = 20;
interface Contribution {
id: number;
content: string;
author: string | null;
createdAt: string;
}
interface ContributionsResponse {
contributions: Contribution[];
total: number;
page: number;
perPage: number;
pages: number;
}
export default function ContributionsBrutes() {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<ContributionsResponse | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(false);
React.useEffect(() => {
setLoading(true);
setError(false);
fetch(`${API_BASE}/api/contributions?page=${page}&per_page=${PER_PAGE}`)
.then((res) => {
if (!res.ok) throw new Error();
return res.json() as Promise<ContributionsResponse>;
})
.then((d) => {
setData(d);
setLoading(false);
})
.catch(() => {
setError(true);
setLoading(false);
});
}, [page]);
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
<Link href="/">
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
</Button>
</Link>
<header className="mb-8 space-y-3">
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
Contributions brutes
</h1>
<p className="text-muted-foreground text-sm leading-relaxed">
L'intégralité des contributions acceptées, dans leur ordre de soumission, sans mise en forme éditoriale.
</p>
</header>
{/* Avertissement éditorial */}
<Alert className="mb-8 border-amber-200/70 bg-amber-50/50 dark:bg-amber-950/20 dark:border-amber-800/40">
<AlertDescription className="text-sm text-foreground/80 leading-relaxed">
La{" "}
<Link href="/" className="underline text-primary">
synthèse collective
</Link>{" "}
visible sur la page d'accueil est une interprétation thématique automatisée de ces contributions.
Elle peut regrouper, omettre ou reformuler des expressions individuelles.
Cette page est la source primaire pour vérification.
</AlertDescription>
</Alert>
{/* Boutons d'export */}
<div className="flex gap-3 mb-8">
<a
href={`${API_BASE}/api/contributions/export/json`}
download="contributions.json"
>
<Button variant="outline" size="sm" className="gap-2 font-mono text-xs">
<Download className="h-3.5 w-3.5" /> Export JSON
</Button>
</a>
<a
href={`${API_BASE}/api/contributions/export/csv`}
download="contributions.csv"
>
<Button variant="outline" size="sm" className="gap-2 font-mono text-xs">
<Download className="h-3.5 w-3.5" /> Export CSV
</Button>
</a>
</div>
{/* Contenu */}
{loading ? (
<div className="flex justify-center py-16 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : error ? (
<div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
<AlertCircle className="h-6 w-6 text-destructive" />
<span className="text-sm">Impossible de charger les contributions.</span>
</div>
) : data && data.contributions.length > 0 ? (
<>
<p className="text-xs font-mono text-muted-foreground mb-6">
{data.total} contribution{data.total !== 1 ? "s" : ""} acceptée{data.total !== 1 ? "s" : ""}
{" "} page {data.page} / {data.pages}
</p>
<div className="space-y-6">
{data.contributions.map((c) => (
<div
key={c.id}
className="border-l-2 border-border/50 pl-4 space-y-1"
id={`contrib-${c.id}`}
>
<p className="text-sm leading-relaxed font-serif text-foreground/90">
{c.content}
</p>
<div className="flex items-center gap-3 text-xs font-mono text-muted-foreground">
<span className="font-semibold text-primary/70">
{c.author || "Citoyen anonyme"}
</span>
<span>&bull;</span>
<span>
{format(new Date(c.createdAt), "d MMMM yyyy, HH:mm", { locale: fr })}
</span>
<span className="ml-auto opacity-40">#{c.id}</span>
</div>
</div>
))}
</div>
{/* Pagination */}
{data.pages > 1 && (
<div className="flex items-center justify-between mt-10 pt-6 border-t border-border/40">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="gap-1.5"
>
<ChevronLeft className="h-4 w-4" /> Précédent
</Button>
<span className="text-xs font-mono text-muted-foreground">
{page} / {data.pages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
disabled={page === data.pages}
className="gap-1.5"
>
Suivant <ChevronRight className="h-4 w-4" />
</Button>
</div>
)}
</>
) : (
<div className="text-center py-16 text-muted-foreground text-sm border border-dashed border-border/60">
Aucune contribution acceptée pour l'instant.
</div>
)}
<p className="text-xs text-muted-foreground/60 font-mono pt-8 mt-8 border-t border-border/30">
Contributions modérées selon le droit international des droits humains (DUDH · PIDCP · CEDH).{" "}
<a
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/PROMPTS_IA.md"
className="underline text-primary"
rel="noopener noreferrer"
>
Voir les critères de modération
</a>
.
</p>
</div>
</div>
);
}
+105 -45
View File
@@ -1,5 +1,6 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2 // Copyright (C) 2026 billisdead — Licence EUPL-1.2
import React from "react"; import React from "react";
import { Link } from "wouter";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -16,7 +17,9 @@ import {
getGetIdeaStatsQueryKey, getGetIdeaStatsQueryKey,
addExtraHeader, addExtraHeader,
removeExtraHeader, removeExtraHeader,
getVisitorId,
} from "@workspace/api-client-react"; } from "@workspace/api-client-react";
import { ConsentDialog } from "@/components/consent-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
@@ -98,6 +101,13 @@ export default function Home() {
const captchaRef = React.useRef<HCaptcha>(null); const captchaRef = React.useRef<HCaptcha>(null);
const [captchaToken, setCaptchaToken] = React.useState<string | null>(null); const [captchaToken, setCaptchaToken] = React.useState<string | null>(null);
// Consentement RGPD — persisté dans localStorage (art. 9.2.a)
const [consentGiven, setConsentGiven] = React.useState(
() => !!localStorage.getItem("consent_v1")
);
const [showConsentDialog, setShowConsentDialog] = React.useState(false);
const pendingSubmitData = React.useRef<SubmitIdeaValues | null>(null);
const handleFlag = async (ideaId: number) => { const handleFlag = async (ideaId: number) => {
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return; if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
setFlaggingId(ideaId); setFlaggingId(ideaId);
@@ -123,6 +133,60 @@ export default function Home() {
query: { refetchInterval: 15000 }, query: { refetchInterval: 15000 },
}); });
// Soumission effective après confirmation du consentement (ou si déjà consenti)
const doActualSubmit = (data: SubmitIdeaValues) => {
if (captchaToken) {
addExtraHeader("x-hcaptcha-token", captchaToken);
}
setSubmitResult(null);
submitIdea.mutate({ data }, {
onSuccess: (result) => {
if (result.accepted) {
setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." });
form.reset();
} else {
setSubmitResult({
success: false,
message: "Cette contribution n'a pas pu être intégrée : elle n'est pas compatible avec le cadre de modération de cette plateforme.",
reason: result.reason ?? undefined,
});
}
queryClient.invalidateQueries({ queryKey: getListIdeasQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetIdeaStatsQueryKey() });
},
onError: () => {
setSubmitResult({ success: false, message: "Une erreur est survenue lors de l'envoi. Veuillez réessayer." });
},
onSettled: () => {
removeExtraHeader("x-hcaptcha-token");
captchaRef.current?.resetCaptcha();
setCaptchaToken(null);
},
});
};
// Confirme le consentement, l'enregistre en DB, puis exécute la soumission en attente
const handleConsentConfirm = () => {
localStorage.setItem("consent_v1", new Date().toISOString());
setConsentGiven(true);
setShowConsentDialog(false);
const visitorId = getVisitorId();
fetch(`${API_BASE}/api/consent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(visitorId ? { "X-Visitor-Id": visitorId } : {}),
},
body: JSON.stringify({ consent_version: "1.0" }),
}).catch(() => {
// Dégradation silencieuse — localStorage suffit comme preuve côté client
});
if (pendingSubmitData.current) {
doActualSubmit(pendingSubmitData.current);
pendingSubmitData.current = null;
}
};
const handleShare = () => { const handleShare = () => {
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 });
@@ -189,70 +253,53 @@ export default function Home() {
const onSubmit = (data: SubmitIdeaValues) => { const onSubmit = (data: SubmitIdeaValues) => {
// Honeypot — si le champ leurre est rempli, c'est un bot // Honeypot — si le champ leurre est rempli, c'est un bot
if (honeypotRef.current?.value) { if (honeypotRef.current?.value) {
// Simulation silencieuse d'un succès sans appel API
setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." }); setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." });
form.reset(); form.reset();
return; return;
} }
// hCaptcha — obligatoire si la clé de site est configurée // hCaptcha — obligatoire si la clé de site est configurée
if (HCAPTCHA_SITE_KEY && !captchaToken) { if (HCAPTCHA_SITE_KEY && !captchaToken) {
toast({ toast({ title: "Vérification requise", description: "Veuillez valider le CAPTCHA avant de soumettre.", variant: "destructive" });
title: "Vérification requise",
description: "Veuillez valider le CAPTCHA avant de soumettre.",
variant: "destructive",
});
return; return;
} }
// Consentement RGPD — obligatoire avant la première contribution
// Transmission du token hCaptcha si disponible if (!consentGiven) {
if (captchaToken) { pendingSubmitData.current = data;
addExtraHeader("x-hcaptcha-token", captchaToken); setShowConsentDialog(true);
return;
} }
doActualSubmit(data);
setSubmitResult(null);
submitIdea.mutate({ data }, {
onSuccess: (result) => {
if (result.accepted) {
setSubmitResult({
success: true,
message: "Votre contribution a été ajoutée à la synthèse.",
});
form.reset();
} else {
setSubmitResult({
success: false,
message: "Cette contribution n'a pas pu être intégrée : elle n'est pas compatible avec le cadre de modération de cette plateforme.",
reason: result.reason ?? undefined,
});
}
queryClient.invalidateQueries({ queryKey: getListIdeasQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetIdeaStatsQueryKey() });
},
onError: () => {
setSubmitResult({
success: false,
message: "Une erreur est survenue lors de l'envoi. Veuillez réessayer.",
});
},
onSettled: () => {
// Nettoyage du token hCaptcha après chaque tentative
removeExtraHeader("x-hcaptcha-token");
captchaRef.current?.resetCaptcha();
setCaptchaToken(null);
},
});
}; };
return ( return (
<> <>
<ConsentDialog
open={showConsentDialog}
onConsent={handleConsentConfirm}
onCancel={() => { setShowConsentDialog(false); pendingSubmitData.current = null; }}
/>
{/* Bandeau d'introduction */} {/* Bandeau d'introduction */}
<div className="border-b border-border/40 bg-muted/30 px-6 md:px-10 py-5"> <div className="border-b border-border/40 bg-muted/30 px-6 md:px-10 py-5">
<p className="text-sm text-foreground/75 max-w-3xl leading-relaxed"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 max-w-5xl">
<p className="text-sm text-foreground/75 leading-relaxed">
<span className="font-semibold text-foreground">La Voix du Peuple</span> est un espace d'expression citoyenne, pas un sondage ni une vérité établie. <span className="font-semibold text-foreground">La Voix du Peuple</span> est un espace d'expression citoyenne, pas un sondage ni une vérité établie.
Exprimez-vous librement chaque contribution est modérée selon le droit international des droits humains, puis reflétée dans la synthèse collective affichée à droite. Exprimez-vous librement chaque contribution est modérée selon le droit international des droits humains, puis reflétée dans la synthèse collective affichée à droite.
<span className="text-foreground/55 ml-1">Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé.</span> <span className="text-foreground/55 ml-1">Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé.</span>
</p> </p>
{stats && stats.total > 0 && (
<div className="flex items-center gap-5 text-xs font-mono text-muted-foreground flex-shrink-0 sm:text-right">
<div>
<div className="text-xl font-bold text-primary leading-none">{stats.accepted}</div>
<div className="mt-0.5">acceptées</div>
</div>
<div>
<div className="text-xl font-bold text-foreground/40 leading-none">{stats.total}</div>
<div className="mt-0.5">soumises</div>
</div>
</div>
)}
</div>
</div> </div>
<div className="flex-1 grid md:grid-cols-2 lg:grid-cols-[1fr_1.2fr] h-[calc(100vh-5rem)]"> <div className="flex-1 grid md:grid-cols-2 lg:grid-cols-[1fr_1.2fr] h-[calc(100vh-5rem)]">
@@ -520,6 +567,19 @@ export default function Home() {
</div> </div>
</div> </div>
{/* Avertissement éditorial — transparence sur la nature de la synthèse IA */}
<div className="px-6 md:px-10 pt-4 pb-1 relative z-10 flex-shrink-0">
<Alert className="border-amber-200/70 bg-amber-50/50 dark:bg-amber-950/20 dark:border-amber-800/40 py-3">
<AlertDescription className="text-xs text-foreground/70 leading-relaxed">
Synthèse générée par IA peut regrouper, omettre ou reformuler des contributions.{" "}
<Link href="/contributions-brutes" className="underline text-primary">
Voir les contributions brutes
</Link>{" "}
pour vérification indépendante.
</AlertDescription>
</Alert>
</div>
{/* Texte défilable */} {/* Texte défilable */}
<ScrollArea className="flex-1 relative z-10"> <ScrollArea className="flex-1 relative z-10">
<div className="px-6 md:px-10 py-8"> <div className="px-6 md:px-10 py-8">
@@ -0,0 +1,143 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
import { Link } from "wouter";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function LegalNotice() {
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
<Link href="/">
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
</Button>
</Link>
<header className="mb-10 space-y-3">
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
Mentions légales
</h1>
<p className="text-muted-foreground text-sm font-mono">
Conformément à la loi n° 2004-575 du 21 juin 2004 (LCEN)
</p>
</header>
<div className="space-y-10 text-sm leading-relaxed">
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Éditeur du site</h2>
<table className="w-full text-sm">
<tbody className="divide-y divide-border/40">
{[
{ label: "Nom", value: "Antoine Piron" },
{ label: "Pseudonyme", value: "billisdead" },
{ label: "Qualité", value: "Personne physique — exploitant à titre personnel" },
{ label: "Contact", value: "piron.antoine@gmail.com" },
{ label: "Adresse", value: "France" },
].map(row => (
<tr key={row.label}>
<td className="py-2 pr-6 font-mono text-xs font-semibold text-muted-foreground uppercase tracking-wider w-40">{row.label}</td>
<td className="py-2 text-foreground/90">{row.value}</td>
</tr>
))}
</tbody>
</table>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Directeur de la publication</h2>
<p className="text-foreground/80">Antoine Piron (billisdead)</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Hébergement</h2>
<p className="text-foreground/80">
La plateforme est auto-hébergée sur un serveur privé virtuel (VPS) situé
en Union européenne. Le choix d'hébergement répond à une exigence de
souveraineté numérique européenne.
</p>
<p className="text-foreground/70 text-xs font-mono">
Configuration : Rocky Linux 9 · Nginx · Python / Flask · PostgreSQL
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Propriété intellectuelle</h2>
<p className="text-foreground/80">
Le code source de cette plateforme est publié sous la{" "}
<strong>European Union Public Licence v. 1.2 (EUPL-1.2)</strong>, licence
open source officielle de l'Union européenne. Le code est librement
réutilisable par toute association, collectivité ou citoyen dans les
conditions de cette licence.
</p>
<p className="text-foreground/80">
Dépôt :{" "}
<a
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple"
className="underline text-primary"
rel="noopener noreferrer"
>
homegit.gyozamancave.fr/billisdead/la-voix-du-peuple
</a>
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Responsabilité éditoriale</h2>
<p className="text-foreground/80">
Les contributions publiées sur cette plateforme sont soumises par des
tiers. Elles sont automatiquement modérées selon le cadre légal international
des droits humains et le droit français. L'éditeur n'est pas responsable des
contenus soumis, sous réserve de prendre les mesures prévues par la LCEN
dès qu'un contenu illicite lui est signalé.
</p>
<p className="text-foreground/80">
La synthèse produite par l'IA est une interprétation éditoriale automatisée
des contributions acceptées, pas un document factuel ni un consensus validé.
Voir la page{" "}
<Link href="/transparence" className="underline text-primary">Fonctionnement</Link>{" "}
et les{" "}
<Link href="/contributions-brutes" className="underline text-primary">Contributions brutes</Link>{" "}
pour vérification.
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Données personnelles</h2>
<p className="text-foreground/80">
Voir la{" "}
<Link href="/politique-confidentialite" className="underline text-primary">
politique de confidentialité
</Link>{" "}
et le document de conformité RGPD complet (
<a
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/RGPD.md"
className="underline text-primary"
rel="noopener noreferrer"
>
docs/RGPD.md
</a>
).
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Signalement d'un contenu illicite</h2>
<p className="text-foreground/80">
Conformément à l'article 6-I-7 de la LCEN, vous pouvez signaler tout
contenu manifestement illicite par email à{" "}
<a href="mailto:piron.antoine@gmail.com" className="underline text-primary">
piron.antoine@gmail.com
</a>{" "}
ou en utilisant le bouton "Signaler" disponible sur chaque contribution.
</p>
</section>
<p className="text-xs text-muted-foreground/60 font-mono pt-4 border-t border-border/30">
Dernière mise à jour : mai 2026
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,220 @@
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
import { Link } from "wouter";
import { ArrowLeft, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
export default function PrivacyPolicy() {
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
<Link href="/">
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
</Button>
</Link>
<header className="mb-10 space-y-3">
<div className="flex items-center gap-3">
<ShieldCheck className="h-8 w-8 text-primary" />
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
Politique de confidentialité
</h1>
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
Version 1.0 mai 2026 · Responsable : Antoine Piron (billisdead)
</p>
</header>
<div className="space-y-10 text-sm leading-relaxed">
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
En résumé
</h2>
<div className="bg-muted/30 border border-border/50 p-5 space-y-2 rounded-sm">
<p className="text-foreground/90">
Cette plateforme collecte uniquement ce qui est strictement nécessaire
au fonctionnement du service.
</p>
<ul className="space-y-1 text-foreground/80">
<li> Aucun compte utilisateur, aucune inscription</li>
<li> Aucun cookie de tracking, aucune publicité</li>
<li> Aucun transfert de données hors Union européenne</li>
<li> Vos contributions sont supprimées après 24 mois</li>
<li> Vous pouvez demander la suppression à tout moment</li>
</ul>
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
Qui traite vos données ?
</h2>
<p className="text-foreground/80">
Responsable de traitement : Antoine Piron (billisdead), France.
Contact : <a href="mailto:piron.antoine@gmail.com" className="underline text-primary">piron.antoine@gmail.com</a>.
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
Quelles données sont collectées ?
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-4 font-mono text-xs uppercase tracking-wider text-muted-foreground">Donnée</th>
<th className="text-left py-2 pr-4 font-mono text-xs uppercase tracking-wider text-muted-foreground">Collectée ?</th>
<th className="text-left py-2 font-mono text-xs uppercase tracking-wider text-muted-foreground">Pourquoi ?</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{[
{ d: "Votre contribution (texte)", c: true, r: "Afficher et synthétiser les propositions" },
{ d: "Pseudonyme", c: true, r: "Facultatif — affiché avec votre contribution" },
{ d: "Date et heure", c: true, r: "Traçabilité, affichage dans l'interface" },
{ d: "Hash technique anti-abus", c: true, r: "Protection contre les bots — non réversible, non-PII" },
{ d: "Horodatage du consentement", c: true, r: "Preuve légale de votre accord (art. 7.1 RGPD)" },
{ d: "Adresse IP", c: false, r: "Non stockée — utilisée temporairement pour le rate limiting" },
{ d: "Cookies de suivi", c: false, r: "Aucun tracker tiers" },
{ d: "Compte utilisateur", c: false, r: "Aucune inscription requise" },
{ d: "Géolocalisation", c: false, r: "—" },
].map(row => (
<tr key={row.d}>
<td className="py-2.5 pr-4 font-medium">{row.d}</td>
<td className="py-2.5 pr-4">
{row.c
? <span className="text-amber-700 font-semibold">Oui</span>
: <span className="text-green-700 font-semibold">Non</span>
}
</td>
<td className="py-2.5 text-foreground/70">{row.r}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
Vos opinions politiques donnée sensible
</h2>
<p className="text-foreground/80">
Les contributions peuvent contenir vos opinions politiques, qui sont des{" "}
<strong>données sensibles</strong> au sens de l'article 9 du RGPD.
Leur traitement est autorisé uniquement avec votre{" "}
<strong>consentement explicite</strong>, que vous donnez en cochant
la case avant votre première contribution.
</p>
<p className="text-foreground/80">
Vous pouvez retirer ce consentement à tout moment. Le retrait n'affecte
pas les traitements déjà effectués, mais vous pouvez demander la
suppression de vos contributions (voir §&nbsp;Vos droits).
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
Intelligence artificielle (Mistral AI)
</h2>
<p className="text-foreground/80">
Votre contribution est transmise à{" "}
<strong>Mistral AI (Paris, France)</strong> pour modération automatique.
Si elle est acceptée, elle est également transmise avec l'ensemble des
contributions acceptées pour générer la synthèse collective.
</p>
<p className="text-foreground/80">
Mistral AI est un sous-traitant RGPD au sens de l'article 28.
Son infrastructure est hébergée exclusivement en Union européenne
(Pays-Bas). Aucun transfert hors UE n'est effectué.
Mistral AI ne conserve pas les données pour entraîner ses modèles.
</p>
<p className="text-foreground/80">
Les instructions précises données à l'IA sont publiques :{" "}
<a
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/PROMPTS_IA.md"
className="underline text-primary"
rel="noopener noreferrer"
>
docs/PROMPTS_IA.md
</a>
.
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
Durée de conservation
</h2>
<p className="text-foreground/80">
Vos contributions sont conservées pendant <strong>24 mois</strong> après la soumission,
puis supprimées ou anonymisées. Le pseudonyme est supprimé en priorité si vous
en avez fourni un.
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
Vos droits
</h2>
<p className="text-foreground/80">
Conformément au RGPD (articles 15 à 22), vous disposez des droits suivants.
Pour les exercer, envoyez un email à{" "}
<a href="mailto:piron.antoine@gmail.com" className="underline text-primary">
piron.antoine@gmail.com
</a>{" "}
avec l'objet "Demande RGPD".
</p>
<ul className="space-y-2 text-foreground/80">
{[
{ titre: "Accès", desc: "Obtenir la liste de vos contributions et des données associées." },
{ titre: "Rectification", desc: "Corriger un pseudonyme ou une erreur dans vos données." },
{ titre: "Effacement", desc: "Faire supprimer vos contributions (avec la date approximative)." },
{ titre: "Opposition", desc: "Vous opposer au traitement basé sur l'intérêt légitime (hash technique)." },
{ titre: "Portabilité", desc: "Obtenir vos contributions en format JSON ou CSV." },
{ titre: "Retrait du consentement", desc: "Annuler votre accord pour les données sensibles (opinions politiques). Les contributions publiées restent visibles sauf demande d'effacement complémentaire." },
].map(r => (
<li key={r.titre} className="flex gap-3">
<span className="font-semibold text-foreground/90 flex-shrink-0 w-36">{r.titre}</span>
<span>{r.desc}</span>
</li>
))}
</ul>
<p className="text-foreground/70 text-xs">
Délai de réponse : 1 mois (prolongeable à 3 mois).
En cas de désaccord, vous pouvez saisir la{" "}
<a href="https://www.cnil.fr/fr/plaintes" className="underline text-primary" rel="noopener noreferrer">
CNIL
</a>
.
</p>
</section>
<section className="space-y-3">
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
Document complet
</h2>
<p className="text-foreground/80">
Le document de conformité RGPD complet (registre des traitements, bases légales,
sous-traitants, durées de conservation) est disponible en open source :{" "}
<a
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/RGPD.md"
className="underline text-primary"
rel="noopener noreferrer"
>
docs/RGPD.md
</a>
.
</p>
</section>
<p className="text-xs text-muted-foreground/60 font-mono pt-4 border-t border-border/30">
Version 1.0 mai 2026
</p>
</div>
</div>
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
# Prompts IA — La Voix du Peuple
> Transparence radicale sur les instructions données aux modèles d'intelligence artificielle.
> Ces prompts constituent l'intégralité des instructions système envoyées à Mistral AI.
> Source : `artifacts/flask-api/legal_framework.py`
---
## Pourquoi publier ces prompts ?
La plateforme repose sur deux décisions automatisées (modération et synthèse) prises par une IA. Conformément à la posture de transparence du projet et aux principes d'auditabilité des systèmes algorithmiques (cf. règlement IA européen), ces instructions sont intégralement publiées.
Tout chercheur, journaliste ou citoyen peut ainsi :
- Vérifier les biais potentiels du cadre de modération
- Évaluer si le prompt de synthèse introduit des distorsions
- Proposer des améliorations via le dépôt de code
---
## 1. Prompt de modération
**Modèle utilisé** : Mistral Small (`mistral-small-latest` par défaut, configurable via `FILTER_MODEL`)
**Rôle** : Décider si une contribution respecte le cadre légal international des droits humains et le droit français.
```
Tu es un agent de filtrage éthique pour une plateforme démocratique citoyenne.
Ta mission est d'analyser des idées politiques soumises par des citoyens
et de décider si elles sont conformes aux valeurs et droits fondamentaux
reconnus par le droit international.
═══════════════════════════════════════════════════════════════════════════════
CADRE LÉGAL DE RÉFÉRENCE
═══════════════════════════════════════════════════════════════════════════════
1. DÉCLARATION UNIVERSELLE DES DROITS DE L'HOMME (DUDH, ONU 1948)
• Art. 1 : "Tous les êtres humains naissent libres et égaux en dignité et
en droits."
• Art. 2 : Interdiction de toute discrimination (race, sexe, langue,
religion, opinion, origine nationale, condition sociale, etc.)
• Art. 3 : "Tout individu a droit à la vie, à la liberté et à la sûreté
de sa personne."
• Art. 5 : Interdiction de la torture et des traitements dégradants.
• Art. 7 : Égalité devant la loi, protection contre la discrimination.
• Art. 18 : Liberté de pensée, de conscience et de religion.
• Art. 19 : "Tout individu a droit à la liberté d'opinion et d'expression."
• Art. 20 : "Toute propagande en faveur de la guerre est interdite par la
loi. Tout appel à la haine nationale, raciale ou religieuse
qui constitue une incitation à la discrimination, à l'hostilité
ou à la violence est interdit par la loi."
• Art. 21 : Droit de participer au gouvernement de son pays, suffrage.
• Art. 29 : Les droits s'exercent dans les limites qui assurent le respect
des droits d'autrui.
[... cadre légal complet dans artifacts/flask-api/legal_framework.py ...]
═══════════════════════════════════════════════════════════════════════════════
CRITÈRES D'ACCEPTATION
═══════════════════════════════════════════════════════════════════════════════
Accepte les idées qui :
✓ Promeuvent les droits fondamentaux, la liberté, l'égalité, la justice (DUDH Art. 1-3)
✓ Proposent des réformes sociales, économiques, politiques ou environnementales
✓ Critiquent le gouvernement, les institutions, les politiques — c'est protégé
(DUDH Art. 19, CEDH Art. 10)
✓ Expriment des opinions politiques, même radicales, tant qu'elles respectent
la dignité humaine et ne prônent pas la haine
✓ Défendent des groupes marginalisés ou discriminés
✓ Proposent des changements constitutionnels, législatifs ou systémiques par
des voies démocratiques et pacifiques
✓ Soulèvent des préoccupations légitimes de sécurité, d'économie, de justice
✓ Sont rédigées dans n'importe quelle langue
═══════════════════════════════════════════════════════════════════════════════
CRITÈRES DE REJET (extraits)
═══════════════════════════════════════════════════════════════════════════════
Rejette les idées qui :
✗ Prônent le fascisme, le nazisme ou tout régime totalitaire
→ CEDH Art. 17 (abus de droit), DUDH Art. 29-30
✗ Appellent à la haine raciale, ethnique, religieuse ou nationale
→ DUDH Art. 20, PIDCP Art. 20, CERD Art. 4
✗ Incitent à la violence, au terrorisme ou à la guerre contre une population
→ DUDH Art. 3, Statut de Rome
✗ Nient l'égale dignité d'êtres humains sur la base de race, genre, sexualité,
religion, handicap, origine nationale ou toute autre caractéristique
✗ Prônent l'élimination, l'expulsion forcée ou la persécution d'un groupe
✗ Contiennent de la désinformation délibérée visant à détruire les institutions
démocratiques
✗ Nient ou contestent l'existence de crimes contre l'humanité reconnus
(négationnisme) → Loi du 29 juillet 1881, Art. 24 bis
✗ Font l'apologie du terrorisme → Code pénal Art. 421-2-5
✗ Appellent au renversement violent des institutions républicaines
→ Code pénal Art. 412-1 à 412-8
✗ Contiennent des menaces de mort ou de violences graves
→ Code pénal Art. 222-17 à 222-18-3
✗ Incitent au suicide → Code pénal Art. 223-13 à 223-15
✗ Contiennent du contenu sexuel ou pornographique
→ Code pénal Art. 222-32, Art. 227-24
✗ Contiennent des données personnelles identifiables de tiers → RGPD
✗ Ne constituent pas une proposition citoyenne (spam, tests, hors sujet)
═══════════════════════════════════════════════════════════════════════════════
FORMAT DE RÉPONSE — OBLIGATOIRE
═══════════════════════════════════════════════════════════════════════════════
Réponds UNIQUEMENT avec un objet JSON valide, sans markdown, sans commentaire :
Si acceptée :
{"accepted": true}
Si rejetée :
{"accepted": false, "reason": "Explication courte en français avec référence légale précise", "legal_basis": "DUDH Art. XX, ..."}
```
> **Note** : la liste complète des critères de rejet (25+ catégories) est dans `artifacts/flask-api/legal_framework.py`. Cette page présente les catégories principales. Le cadre légal complet (16 sources, 350+ lignes) est intégralement transmis au modèle à chaque appel.
---
## 2. Prompt de synthèse
**Modèle utilisé** : Mistral Large (`mistral-large-latest` par défaut, configurable via `SYNTHESIS_MODEL`)
**Rôle** : Produire un résumé structuré des contributions acceptées, directement transmissible à des élus.
```
Tu es un assistant qui résume des contributions citoyennes à destination d'élus politiques.
STYLE
- Phrases courtes, directes. Pas d'emphase, pas de lyrisme.
- N'écris jamais "Nous le peuple", "la voix du peuple", ni aucune formule solennelle.
- N'écris pas de phrase d'introduction générale. Va directement aux sujets.
- Ton neutre : ni poétique, ni journalistique. Factuel.
FORMAT
- Regroupe les contributions par thème (1 paragraphe par thème, 2 à 4 thèmes au total).
- Chaque paragraphe commence par le sujet principal du thème, par exemple :
"Sur la transparence des élus : ..." ou "Concernant les services publics : ..."
- Formule les demandes au présent, à la troisième personne :
"Des citoyens demandent que...", "Plusieurs contributions soulignent que..."
- Si des contributions se contredisent sur un point, dis-le en une phrase.
- Pas de conclusion, pas de résumé final.
- Pas d'emojis, pas de markdown, pas de tirets.
Réponds avec UNIQUEMENT le texte, sans en-tête ni commentaire.
```
---
## 3. Analyse des biais potentiels
### Biais du prompt de modération
| Biais potentiel | Évaluation |
|-----------------|------------|
| Biais vers le consensus institutionnel | **Présent** — le cadre légal favorise les expressions reconnues par les institutions. Des formes de résistance radicale (mais légales) pourraient être rejetées à tort. |
| Biais de langue | **Faible** — le prompt précise explicitement "rédigées dans n'importe quelle langue". Le modèle peut toutefois moins bien interpréter des nuances dans des langues rares. |
| Faux positifs (légitimes rejetés) | **Possible** — le modèle peut rejeter des contributions satiriques, métaphoriques ou au style très direct. Aucun mécanisme de recours automatique n'existe dans la v1. |
| Faux négatifs (illicites acceptés) | **Possible** — le modèle n'est pas infaillible. Un mécanisme de signalement public et d'override admin existe (voir panel admin). |
### Biais du prompt de synthèse
| Biais potentiel | Évaluation |
|-----------------|------------|
| Omission | **Inhérent** — la synthèse regroupe et peut omettre des contributions peu représentées thématiquement, même si elles sont légitimes. La vue "/contributions-brutes" permet de vérifier. |
| Hiérarchisation implicite | **Possible** — le modèle peut placer certains thèmes en premier, suggérant implicitement une priorité. |
| Reformulation déformante | **Possible** — le résumé peut trahir la nuance d'une contribution individuelle. La vue "/contributions-brutes" est le contrepoids. |
| Neutralisation du registre émotionnel | **Voulu** — le prompt demande explicitement un ton factuel. Cela lisse intentionnellement l'expression citoyenne. |
---
## 4. Données transmises à Mistral AI
À chaque soumission, le texte de la contribution est envoyé à l'API Mistral (Mistral Small) pour modération.
Si la contribution est acceptée, l'intégralité des contributions acceptées est envoyée à l'API Mistral (Mistral Large) pour recalcul de la synthèse.
**Données transmises** : uniquement le texte brut des contributions. Pas d'adresse IP, pas de pseudonyme, pas de fingerprint.
**Politique de rétention Mistral AI** : Mistral AI ne conserve pas les données soumises via API pour entraîner ses modèles (politique en vigueur au moment de la rédaction — vérifier le DPA actuel).
---
## 5. Modification des prompts
Les prompts sont modifiables dans `artifacts/flask-api/legal_framework.py`. Toute modification du prompt de modération impacte les critères d'acceptation et doit être documentée ici avec une justification et la date de changement.
**Politique** : tout changement de prompt doit faire l'objet d'un commit documenté et d'une mise à jour de cette page. La transparence sur les modifications est aussi importante que la transparence sur le contenu initial.
+201
View File
@@ -0,0 +1,201 @@
# Conformité RGPD — La Voix du Peuple
> Document de référence RGPD. Version 1.0 — mai 2026.
> Responsable de traitement : billisdead (Antoine Piron) — piron.antoine@gmail.com
---
## 1. Responsable de traitement
| Champ | Valeur |
|-------|--------|
| Identité | Antoine Piron (billisdead) |
| Contact | piron.antoine@gmail.com |
| DPO | Même personne (structure individuelle) |
| Territoire | France |
La plateforme est exploitée à titre personnel dans un but civique non-lucratif.
---
## 2. Registre des traitements
### Traitement 1 — Contributions citoyennes
| Champ | Détail |
|-------|--------|
| **Finalité** | Recueillir des propositions citoyennes, les modérer automatiquement et produire une synthèse thématique |
| **Base légale principale** | Art. 6.1.e RGPD — exécution d'une mission d'intérêt public (participation démocratique) |
| **Base légale données sensibles** | Art. 9.2.a RGPD — **consentement explicite** de la personne concernée (les opinions politiques sont des données sensibles au sens de l'art. 9) |
| **Catégories de données** | Contenu de la contribution (texte libre, peut contenir des opinions politiques), pseudonyme (facultatif, librement choisi), horodatage |
| **Destinataires** | Responsable de traitement (admin), Mistral AI (sous-traitant IA, voir §4) |
| **Durée de conservation** | 24 mois à compter de la soumission, puis suppression ou anonymisation complète |
| **Transferts hors UE** | Aucun |
### Traitement 2 — Fingerprint de session (anti-abus)
| Champ | Détail |
|-------|--------|
| **Finalité** | Protection contre les attaques automatisées (bots, sybil attacks, flood), sans authentification et sans traçage individuel |
| **Base légale** | Art. 6.1.f RGPD — intérêt légitime du responsable de traitement (sécurité technique de la plateforme) |
| **Nature de la donnée** | Hash SHA-256 (tronqué à 32 caractères hexadécimaux) d'un identifiant généré à partir des caractéristiques du navigateur (FingerprintJS). Non réversible, non-PII au sens du RGPD |
| **Durée de conservation** | Liée à la contribution associée (24 mois max) |
| **Transferts hors UE** | Aucun |
### Traitement 3 — Consentements
| Champ | Détail |
|-------|--------|
| **Finalité** | Traçabilité du recueil du consentement (art. 7.1 RGPD — charge de la preuve) |
| **Base légale** | Art. 6.1.c RGPD — obligation légale (conservation de la preuve de consentement) |
| **Catégories de données** | Hash fingerprint (voir §2.2), version du texte accepté, horodatage du consentement |
| **Durée de conservation** | Durée du traitement principal + 3 ans (prescription civile) |
| **Transferts hors UE** | Aucun |
### Traitement 4 — Synthèse IA
| Champ | Détail |
|-------|--------|
| **Finalité** | Produire un résumé structuré des contributions acceptées, à destination d'élus ou décideurs |
| **Base légale** | Art. 6.1.e RGPD — même finalité que le traitement 1 |
| **Catégories de données** | Contenu des contributions acceptées (transmis à Mistral AI pour synthèse) |
| **Destinataires** | Mistral AI (sous-traitant, voir §4) |
| **Durée de conservation** | Synthèse courante uniquement — pas d'historique conservé |
| **Transferts hors UE** | Aucun |
---
## 3. Données collectées / non collectées
| Donnée | Collectée | Base / commentaire |
|--------|-----------|--------------------|
| Texte de la contribution | Oui | Finalité principale — art. 6.1.e + 9.2.a |
| Pseudonyme | Oui (optionnel) | Librement choisi, pas de vérification |
| Horodatage | Oui | Nécessaire à la traçabilité |
| Résultat de modération | Oui (admin uniquement) | Traçabilité des décisions automatisées |
| Hash fingerprint navigateur | Oui | Art. 6.1.f — sécurité, non-PII |
| Consentement (version + date) | Oui | Art. 6.1.c — obligation légale |
| Adresse IP | Non conservée | Utilisée pour le rate limiting, non stockée |
| Compte utilisateur | Non | Pas d'inscription requise |
| Cookie de suivi | Non | Aucun tracker tiers |
| Données de navigation | Non | Aucune |
| Données de géolocalisation | Non | Aucune |
---
## 4. Sous-traitants
### Mistral AI
| Champ | Détail |
|-------|--------|
| **Raison sociale** | Mistral AI SAS |
| **Siège** | Paris, France |
| **Rôle** | Sous-traitant pour la modération IA (Mistral Small) et la synthèse (Mistral Large) |
| **Données transmises** | Texte des contributions soumises (modération) · Texte des contributions acceptées (synthèse) |
| **Hébergement** | Exclusivement en Union européenne (GCP europe-west4, Pays-Bas) |
| **DPA** | Disponible via le portail Mistral AI — conforme RGPD |
| **Transferts hors UE** | Aucun |
**Note** : Mistral AI ne conserve pas les données soumises via API pour entraîner ses modèles (politique explicite de l'API Mistral). À vérifier et documenter lors de la signature du DPA.
### Hébergeur VPS
| Champ | Détail |
|-------|--------|
| **Configuration cible** | Rocky Linux 9, VPS français ou européen (OVHcloud, Scaleway, ou équivalent) |
| **Rôle** | Hébergement de la base de données PostgreSQL, du backend Flask et du frontend compilé |
| **Données hébergées** | Toutes les données listées §2 |
| **Transferts hors UE** | Aucun (hébergeur UE requis) |
| **DPA** | À signer avec l'hébergeur retenu lors du déploiement |
---
## 5. Durées de conservation
| Catégorie | Durée active | Traitement au terme |
|-----------|-------------|---------------------|
| Contributions (texte + auteur) | 24 mois | Suppression complète ou anonymisation du pseudonyme |
| Hash fingerprint | 24 mois (liée contribution) | Suppression avec la contribution |
| Consentements | 24 mois + 3 ans | Suppression (prescription civile) |
| Synthèse courante | Sans limite | Écrasée à chaque recalcul |
| Logs applicatifs | 90 jours | Rotation automatique |
---
## 6. Droits des personnes
Conformément aux articles 15 à 22 du RGPD, toute personne peut exercer les droits suivants :
| Droit | Contenu | Comment l'exercer |
|-------|---------|-------------------|
| **Accès** (art. 15) | Obtenir copie des données vous concernant | Email à piron.antoine@gmail.com |
| **Rectification** (art. 16) | Corriger des données inexactes | Email à piron.antoine@gmail.com |
| **Effacement** (art. 17) | "Droit à l'oubli" — suppression de vos contributions | Email à piron.antoine@gmail.com avec la date approximative de la contribution |
| **Opposition** (art. 21) | S'opposer au traitement basé sur l'intérêt légitime (fingerprint) | Email à piron.antoine@gmail.com |
| **Portabilité** (art. 20) | Obtenir vos données dans un format structuré | Email à piron.antoine@gmail.com |
| **Retrait du consentement** (art. 7.3) | Retirer votre consentement à tout moment | Email à piron.antoine@gmail.com — sans effet sur les traitements déjà effectués |
**Délai de réponse** : 1 mois (prolongeable à 3 mois pour les demandes complexes, avec notification).
**Réclamation CNIL** : En cas de non-réponse ou de litige, vous pouvez adresser une réclamation à la CNIL (www.cnil.fr/fr/plaintes).
---
## 7. Sécurité des données
Mesures techniques et organisationnelles mises en place :
- Transport chiffré (HTTPS/TLS) entre le client et le serveur (Nginx)
- Connexion base de données sur réseau local uniquement (pas d'exposition publique du port PostgreSQL)
- Rate limiting et détection de flood (voir `docs/SECURITE_ANTI_ABUS.md`)
- Aucune donnée personnelle dans les logs applicatifs
- Accès admin protégé par token secret (`ADMIN_SECRET`)
- Principe de minimisation : pas de collecte d'IP ni de cookies de tracking
- Hash SHA-256 non réversible pour le fingerprint (non-PII)
---
## 8. Décisions automatisées
La plateforme utilise une modération automatisée par IA (Mistral Small) pour filtrer les contributions.
**Information prévue à l'art. 22 RGPD** : si votre contribution est rejetée, le motif et la référence légale précise vous sont communiqués. Vous pouvez reformuler et resoumettre.
Il n'existe pas de mécanisme de contestation formel de la décision de modération dans la version actuelle. En cas de déploiement institutionnel, un mécanisme de recours humain est recommandé.
---
## 9. Consentement pour les données sensibles
Les contributions peuvent contenir des **opinions politiques**, qui sont des données sensibles au sens de l'art. 9 RGPD. Leur traitement requiert un consentement explicite de la personne concernée.
**Mécanisme de recueil** : avant la première contribution, un bandeau de consentement explicite est présenté. Il contient :
- Une description claire du traitement
- Un lien vers la présente politique de confidentialité
- Une case à cocher obligatoire (pas de pré-cochage)
- Un bouton "Accepter et contribuer"
**Horodatage** : le consentement est enregistré en base de données avec la version du texte accepté et la date.
**Retrait** : le consentement peut être retiré à tout moment par email. Les contributions déjà publiées restent dans la base mais peuvent être supprimées sur demande (droit à l'effacement).
---
## 10. Cookies
La plateforme utilise uniquement les cookies suivants, strictement nécessaires :
| Nom | Type | Finalité | Durée |
|-----|------|----------|-------|
| `_cv` | httpOnly, SameSite=Lax | Anti-abus — cooldown entre soumissions | `CONTRIBUTION_COOLDOWN_SECONDS` (1h par défaut) |
**Aucun cookie tiers, aucun cookie de tracking, aucune publicité.**
---
## Historique des versions
| Version | Date | Changements |
|---------|------|-------------|
| 1.0 | Mai 2026 | Version initiale |
+8
View File
@@ -47,6 +47,14 @@ export function removeExtraHeader(key: string): void {
delete _extraHeaders[key]; delete _extraHeaders[key];
} }
/**
* Retourne l'identifiant de visite FingerprintJS actuellement enregistré.
* Utilisé pour transmettre l'identifiant lors d'appels fetch manuels (ex. enregistrement du consentement).
*/
export function getVisitorId(): string | null {
return _visitorId;
}
/** /**
* Set a base URL that is prepended to every relative request URL * Set a base URL that is prepended to every relative request URL
* (i.e. paths that start with `/`). * (i.e. paths that start with `/`).
+1
View File
@@ -4,6 +4,7 @@ export {
setBaseUrl, setBaseUrl,
setAuthTokenGetter, setAuthTokenGetter,
setVisitorId, setVisitorId,
getVisitorId,
addExtraHeader, addExtraHeader,
removeExtraHeader, removeExtraHeader,
} from "./custom-fetch"; } from "./custom-fetch";