Files
la-voix-du-peuple/ARCHITECTURE.md
T
billisdead b45e9fa0d2 Update ARCHITECTURE.md and DEPLOIEMENT.md to match actual codebase
- Replace all OpenAI/GPT references with Mistral AI (mistral-small-latest,
  mistral-large-latest) and correct env var names (FILTER_MODEL, SYNTHESIS_MODEL)
- Expand DB schema: add missing columns on ideas/synthesis, add tables
  consultations, consents, ip_abuse with full column list
- Expand API routes table from 5 to 33 routes (admin, consultations,
  contributions, check-law, consent, exports, ip-blacklist)
- Add third AI agent: law-check (LAW_CHECK_PROMPT / POST /api/check-law)
- Expand legal basis from 7 to 16 sources
- Update Vite version: 6 → 7
- Add public pages routing table
- Add autoclose consultation flow diagram (section 4.3)
- Fix security section: replace Azure/OpenAI with Mistral AI
- Update date (April → June 2026), status (Production → Recette)
- Fix DEPLOIEMENT.md diagram: OpenAI API → Mistral AI API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 08:01:58 +02:00

19 KiB
Raw Blame History

Architecture Technique — La Voix du Peuple

Version : 1.1
Date : Juin 2026
Statut : Recette (homelab, pas encore de domaine public)


1. Vue d'ensemble

La Voix du Peuple est une plateforme démocratique citoyenne permettant la soumission de propositions politiques en texte libre, leur filtrage automatique par un agent IA ancré dans le droit international des droits humains et le droit pénal français, et leur synthèse en un texte collectif vivant.


2. Architecture générale

                         RÉSEAU EXTERNE (Internet)
                                   │
                                   ▼
                        ┌─────────────────────┐
                        │       HAProxy        │
                        │  (réseau séparé)     │
                        │  - Terminaison TLS   │
                        │  - Load balancing    │
                        │  - ACL / filtrage IP │
                        └──────────┬──────────┘
                                   │  HTTP (port 80, réseau interne)
                                   ▼
                        ┌─────────────────────┐
                        │        Nginx         │
                        │  (reverse proxy)     │
                        │  - Routage /api/*    │
                        │  - Serve SPA React   │
                        │  - Cache statiques   │
                        └────────┬────────────┘
                                 │
               ┌─────────────────┴──────────────────┐
               │                                     │
               ▼                                     ▼
   ┌───────────────────────┐           ┌───────────────────────┐
   │   Gunicorn + Flask    │           │   Fichiers statiques   │
   │   (127.0.0.1:8000)   │           │   React SPA (dist/)    │
   │   - API REST /api/*   │           │   (servis par Nginx)   │
   │   - Filtrage IA       │           └───────────────────────┘
   │   - Rate limiting     │
   └──────────┬────────────┘
              │
    ┌─────────┴──────────┐
    │                    │
    ▼                    ▼
┌──────────┐     ┌───────────────────┐
│PostgreSQL│     │  Mistral AI API   │
│(local)   │     │  (externe, UE)    │
│- ideas   │     │  mistral-small    │
│- synth.  │     │  mistral-large    │
│- consult.│     │  (ou OpenAI-compat│
│- consents│     │   si configuré)   │
│- ip_abuse│     └───────────────────┘
└──────────┘

3. Composants

3.1 HAProxy (réseau séparé)

Propriété Valeur
Rôle Point d'entrée unique, terminaison TLS, load balancing
Réseau DMZ / réseau séparé dédié
Protocoles entrants HTTPS/443, HTTP/80 (redirect)
Protocoles sortants HTTP/80 vers Nginx (réseau interne)
TLS Terminaison SSL/TLS sur HAProxy, communication interne en HTTP clair

Configuration recommandée dans HAProxy :

frontend voix_du_peuple_https
    bind *:443 ssl crt /etc/ssl/certs/voix-du-peuple.pem
    mode http
    option forwardfor
    http-request set-header X-Forwarded-Proto https
    default_backend voix_du_peuple_backend

frontend voix_du_peuple_http
    bind *:80
    mode http
    redirect scheme https code 301 if !{ ssl_fc }

backend voix_du_peuple_backend
    mode http
    balance roundrobin
    option httpchk GET /api/healthz
    http-check expect status 200
    server app01 <IP_NGINX>:80 check inter 10s

3.2 Nginx

Propriété Valeur
Rôle Reverse proxy applicatif, service des fichiers statiques
Écoute 0.0.0.0:80 (réseau interne uniquement)
Vers Flask 127.0.0.1:8000 pour toute requête /api/*
Vers SPA dist/public/ pour tout le reste (try_files)

Responsabilités :

  • Routage : /api/* → Gunicorn, /* → SPA React
  • Cache : assets JS/CSS/images avec Cache-Control: immutable
  • Headers de sécurité : X-Frame-Options, X-Content-Type-Options, Referrer-Policy
  • Logging : access.log et error.log structurés

Note HAProxy : Nginx doit faire confiance à l'en-tête X-Forwarded-For positionné par HAProxy pour que Flask voie la vraie IP cliente (rate limiting par IP).

Configuration Nginx requise :

set_real_ip_from  <IP_HAPROXY>;
real_ip_header    X-Forwarded-For;
real_ip_recursive on;

3.3 Gunicorn + Flask (Backend API)

Propriété Valeur
Serveur WSGI Gunicorn 23+
Workers 4 (synchrones, ajustable)
Bind 127.0.0.1:8000 (non exposé directement)
Framework Flask 3.1+
Python 3.11+

Routes exposées :

Méthode Route Description Auth
GET /api/healthz Santé du service
GET /api/ideas Idées globales acceptées
POST /api/ideas Soumettre une idée globale
GET /api/ideas/stats Statistiques globales (admin)
GET /api/synthesis Synthèse collective globale
POST /api/consent Enregistrer le consentement RGPD
GET /api/stats/public Statistiques publiques globales
GET /api/contributions Contributions paginées (vue publique)
GET /api/contributions/export/json Export JSON contributions globales
GET /api/contributions/export/csv Export CSV contributions globales
POST /api/ideas/:id/flag Signaler une contribution
POST /api/check-law Vérifier si une proposition est couverte par la loi
GET /api/consultations Liste des consultations
GET /api/consultations/:slug Détails d'une consultation
GET /api/consultations/:slug/synthesis Synthèse d'une consultation
GET /api/consultations/:slug/contributions Contributions paginées d'une consultation
POST /api/consultations/:slug/ideas Soumettre une idée dans une consultation
GET /api/consultations/:slug/export/print Rendu HTML pour impression
POST /api/admin/login Authentification admin
GET /api/admin/stats Statistiques détaillées Admin
GET /api/admin/ideas Liste toutes les contributions (filtres, pagination) Admin
DELETE /api/admin/ideas/:id Supprimer une contribution Admin
POST /api/admin/ideas/bulk-delete Suppression en masse Admin
POST /api/admin/ideas/:id/override Modifier le statut d'une contribution Admin
POST /api/admin/ideas/:id/unflag Retirer un signalement Admin
POST /api/admin/synthesis/regenerate Régénérer la synthèse globale Admin
GET /api/admin/export/csv Export CSV complet (tous champs) Admin
GET /api/admin/consultations Liste toutes les consultations Admin
POST /api/admin/consultations Créer une consultation Admin
POST /api/admin/consultations/:slug/close Fermer une consultation Admin
DELETE /api/admin/consultations/:slug Supprimer une consultation Admin
GET /api/admin/ip-blacklist Liste des IPs blacklistées Admin
DELETE /api/admin/ip-blacklist/:hash Lever un blacklist IP Admin

Sécurité applicative :

  • Rate limiting par IP réelle ou fingerprint FingerprintJS (via flask-limiter)
  • Assainissement XSS : bleach.clean() sur toutes les entrées
  • Requêtes SQL paramétrées (psycopg2) — pas de concaténation de chaînes
  • En-têtes HTTP de sécurité sur toutes les réponses
  • Aucun secret exposé dans les messages d'erreur

3.4 Agent IA

Trois agents distincts, tous via le client OpenAI-compatible (Mistral AI par défaut) :

Agent de filtrage

Propriété Valeur
Modèle par défaut mistral-small-latest
Variable d'env FILTER_MODEL (fallback : OPENAI_FILTER_MODEL)
Tokens max 300
Format de sortie JSON strict (response_format: json_object)
Déclenchement À chaque soumission d'idée

Logique de décision :

  1. Appel API avec le prompt légal complet (16 sources, ~350 lignes)
  2. Parse JSON {"accepted": bool, "reason"?: str, "legal_basis"?: str}
  3. Si l'API retourne une erreur de filtre de contenu → rejet automatique avec citation DUDH/PIDCP/CEDH
  4. Résultat persisté en base avec l'idée

Agent de synthèse

Propriété Valeur
Modèle par défaut mistral-large-latest
Variable d'env SYNTHESIS_MODEL (fallback : OPENAI_SYNTHESIS_MODEL)
Tokens max 1200
Déclenchement En arrière-plan après chaque acceptation, ou à la suppression

La synthèse est non-bloquante : l'API répond immédiatement à l'utilisateur, la synthèse s'effectue dans un thread daemon.

Agent de vérification légale

Propriété Valeur
Modèle même que FILTER_MODEL
Tokens max 200
Déclenchement À la demande via POST /api/check-law
Rôle Informer le contributeur si sa proposition est déjà couverte par une loi — non bloquant

Base légale du filtre (16 sources)

# Texte
1 Déclaration universelle des droits de l'homme (DUDH, ONU 1948)
2 Pacte international relatif aux droits civils et politiques (PIDCP, ONU 1966)
3 Convention européenne des droits de l'homme (CEDH, 1950)
4 Charte des droits fondamentaux de l'UE (2000/2009)
5 Convention pour la prévention du génocide (ONU 1948)
6 Statut de Rome / CPI (1998)
7 Convention sur la discrimination raciale (CERD, ONU 1965)
8-13 Code pénal français — Livres I à VI
14 Loi sur la liberté de la presse du 29 juillet 1881
15 LCEN (2004) · Loi SREN (2024) · RGPD · Code de la santé publique
16 Principes constitutionnels (CC 94-343/344 DC) · Code civil Art. 9, 1240

Le texte intégral des prompts est consultable sur la page Fonctionnement du site.

3.5 PostgreSQL

Propriété Valeur
Version 15+
Accès Localhost uniquement (127.0.0.1)
Connexion Flask Via DATABASE_URL (psycopg2-binary)
Authentification scram-sha-256
Migrations Incrémentales et idempotentes — appliquées à chaque démarrage

Schéma de la base de données :

CREATE TABLE consultations (
    id                  SERIAL PRIMARY KEY,
    slug                VARCHAR(100) UNIQUE NOT NULL,
    title               VARCHAR(200) NOT NULL,
    subject             TEXT NOT NULL,
    intro_message       TEXT,
    organizer_name      VARCHAR(200),
    organizer_logo_url  TEXT,
    starts_at           TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    ends_at             TIMESTAMPTZ,
    closed_at           TIMESTAMPTZ,
    webhook_url         TEXT,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE ideas (
    id               SERIAL PRIMARY KEY,
    content          TEXT        NOT NULL,
    author           VARCHAR(100),
    accepted         BOOLEAN     NOT NULL DEFAULT FALSE,
    rejection_reason TEXT,
    legal_basis      TEXT,
    flagged          BOOLEAN     NOT NULL DEFAULT FALSE,
    flag_count       INTEGER     NOT NULL DEFAULT 0,
    admin_note       TEXT,
    fingerprint_hash VARCHAR(64),
    consultation_id  INTEGER REFERENCES consultations(id) ON DELETE SET NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT NOW()
    -- consultation_id IS NULL → contribution globale (page d'accueil)
);

CREATE TABLE synthesis (
    id              SERIAL PRIMARY KEY,
    text            TEXT    NOT NULL,
    idea_count      INTEGER NOT NULL DEFAULT 0,
    consultation_id INTEGER REFERENCES consultations(id) ON DELETE CASCADE,
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
    -- consultation_id IS NULL → synthèse globale
);

CREATE TABLE consents (
    id               SERIAL PRIMARY KEY,
    fingerprint_hash VARCHAR(64) NOT NULL,
    consent_version  VARCHAR(20) NOT NULL,
    consented_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE ip_abuse (
    ip_hash          TEXT PRIMARY KEY,  -- SHA-256 de l'IP (RGPD)
    rejection_count  INTEGER     NOT NULL DEFAULT 1,
    first_rejected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_rejected_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_idea_id     INTEGER,
    blacklisted_at   TIMESTAMPTZ,
    expires_at       TIMESTAMPTZ
    -- blacklist déclenché à rejection_count >= 2
);

3.6 Frontend React

Propriété Valeur
Framework React 18 + Vite 7
Styles Tailwind CSS v4 + shadcn/ui + Radix UI
Routing Wouter (léger, côté client)
Requêtes API TanStack React Query
Build Sortie statique dans dist/public/

Communication avec le backend :

  • Toutes les requêtes passent par /api/ (même origine, pas de CORS cross-origin en production)
  • Rafraîchissement automatique de la synthèse toutes les 15 secondes

Pages publiques :

Route Composant Description
/ Home Formulaire de soumission + synthèse (onglets mobile)
/about About Présentation du projet
/transparence Transparence Fonctionnement, critères IA, prompts verbatim
/contributions-brutes ContributionsBrutes Fil paginé des contributions acceptées
/consultations ConsultationsList Index des consultations actives et clôturées
/consultation/:slug ConsultationPage Page d'une consultation ciblée
/flyer Flyer Flyer QR code imprimable
/mentions-legales LegalNotice Mentions légales (LCEN)
/politique-confidentialite PrivacyPolicy Politique de confidentialité RGPD
/admin Admin Panel d'administration (protégé)

4. Flux de données

4.1 Soumission d'une idée

Citoyen
  │
  │  POST /api/ideas {"content": "...", "author": "..."}
  ▼
HAProxy ──[TLS]──► Nginx ──► Gunicorn/Flask
                               │
                               ├─ Vérification blacklist IP
                               ├─ Honeypot / hCaptcha
                               ├─ Consentement RGPD (cookie _ct)
                               ├─ Cooldown session (cookie _cv)
                               ├─ Validation entrées (longueur, XSS)
                               ├─ Rate limiting (fingerprint ou IP)
                               ├─ Flood detection
                               │
                               ├─ Agent filtrage ──► Mistral AI API
                               │                        │
                               │                   JSON {"accepted": bool, ...}
                               │
                               ├─ INSERT INTO ideas (...)
                               │
                               ├─ Si refusé :
                               │    record_ip_rejection() → blacklist si >= 2 rejets
                               │
                               ├─ Si accepté :
                               │    Thread daemon ──► Agent synthèse ──► Mistral AI
                               │                                             │
                               │                                        UPSERT synthesis
                               │
  ◄── HTTP 201 {"accepted": bool, "reason": "...", "idea": {...}} ──────────┘

4.2 Lecture de la synthèse

Citoyen (polling 15s)
  │
  │  GET /api/synthesis
  ▼
HAProxy ──► Nginx ──► Flask ──► SELECT FROM synthesis ──► {"text": "...", "ideaCount": N}

4.3 Fermeture automatique des consultations

Thread daemon (toutes les 60s, par worker)
  │
  ├─ pg_try_advisory_lock() — un seul worker exécute le check
  ├─ SELECT consultations WHERE ends_at < NOW() AND closed_at IS NULL
  ├─ UPDATE consultations SET closed_at = NOW()
  └─ Thread ──► webhook (non-bloquant)

5. Sécurité

5.1 Couches de défense

Couche Mécanisme
Réseau HAProxy sur réseau séparé, Nginx non exposé directement
Transport TLS 1.2/1.3 terminé sur HAProxy
Applicative Flask Rate limiting, validation, assainissement XSS, headers sécurité
Anti-abus Honeypot · hCaptcha (optionnel) · cooldown cookie · flood detection · blacklist IP (SHA-256, 30 jours)
IA Filtre de contenu Mistral AI + filtre légal interne (16 sources)
Base de données Requêtes paramétrées, accès localhost uniquement
Système Utilisateur dédié non-root, systemd sandboxing

5.2 Périmètre des ports réseau

Port Interface Exposé à Rôle
443 HAProxy Internet HTTPS public
80 HAProxy Internet Redirect HTTPS
80 Nginx Réseau interne HTTP depuis HAProxy
8000 Gunicorn 127.0.0.1 API Flask
5432 PostgreSQL 127.0.0.1 Base de données

5.3 En-têtes HTTP de sécurité (Flask)

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'
Cache-Control: no-store
Pragma: no-cache

Exception : /api/consultations/:slug/export/print autorise script-src 'unsafe-inline' pour window.print().


6. Décisions d'architecture

Décision Choix Justification
ORM vs SQL direct psycopg2 direct Transparence, simplicité, facilité d'audit
Sync vs Async Flask Synchrone (Gunicorn) Complexité réduite, suffisant pour la charge attendue
Synthèse bloquante vs thread Thread daemon Latence utilisateur réduite, synthèse non-critique au retour
TLS sur HAProxy vs Nginx HAProxy TLS centralisé sur le composant réseau dédié à cet usage
SPA vs SSR SPA statique Déploiement simple, aucune dépendance Node en production
Rate limiting en mémoire vs Redis Mémoire (memory://) Installation simple ; Redis optionnel pour cluster multi-instances
Fournisseur IA Mistral AI (défaut) Hébergement UE, conformité RGPD, API compatible OpenAI
Stockage IP SHA-256 (32 car.) Anti-abus sans stocker l'IP brute — conformité RGPD art. 6(1)(f)

7. Scalabilité

L'architecture actuelle est dimensionnée pour une instance unique. En cas de montée en charge :

  • Gunicorn workers : augmenter via --workers (règle : 2 × CPU + 1)
  • Multi-instances : ajouter des backends dans HAProxy + basculer le rate limiting sur Redis (REDIS_URL=redis://...)
  • Base de données : index existants sur ideas.consultation_id, synthesis.consultation_id, consents.fingerprint_hash, ip_abuse.expires_at ; ajouter un index sur ideas.accepted si la table grossit
  • Cache synthèse : la synthèse est unique en base par contexte, naturellement partagée entre workers