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>
This commit is contained in:
2026-06-24 08:01:58 +02:00
parent bae1335dd5
commit b45e9fa0d2
2 changed files with 184 additions and 59 deletions
+183 -58
View File
@@ -1,14 +1,14 @@
# Architecture Technique — La Voix du Peuple
**Version :** 1.0
**Date :** Avril 2026
**Statut :** Production
**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 d'idées politiques, leur filtrage automatique par un agent IA ancré dans le droit international des droits humains, et leur synthèse en un texte collectif vivant.
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.
---
@@ -49,12 +49,15 @@ La Voix du Peuple est une plateforme démocratique citoyenne permettant la soumi
┌─────────┴──────────┐
│ │
▼ ▼
┌──────────┐ ┌───────────────┐
│PostgreSQL│ │ OpenAI API │
│(local) │ │ (externe) │
│- ideas │ │ gpt-4o-mini
│- synthesis│ │ gpt-4o
└──────────┘ └───────────────┘
┌──────────┐ ┌───────────────────
│PostgreSQL│ │ Mistral AI API │
│(local) │ │ (externe, UE) │
│- ideas │ │ mistral-small
│- synth. mistral-large
│- consult.│ │ (ou OpenAI-compat│
│- consents│ │ si configuré) │
│- ip_abuse│ └───────────────────┘
└──────────┘
```
---
@@ -129,16 +132,44 @@ real_ip_recursive on;
**Routes exposées :**
| Méthode | Route | Description | Rate limit |
|---------|-------|-------------|------------|
| GET | `/api/healthz` | Santé du service | Aucun |
| GET | `/api/ideas` | Liste des idées acceptées | 120/min |
| POST | `/api/ideas` | Soumettre une idée | 5/min, 20/h par IP |
| GET | `/api/ideas/stats` | Statistiques | 120/min |
| GET | `/api/synthesis` | Texte synthétisé collectif | 120/min |
| 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 (via `flask-limiter`)
- 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
@@ -146,21 +177,20 @@ real_ip_recursive on;
### 3.4 Agent IA
Deux agents distincts, chacun configuré avec son propre modèle :
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 | `gpt-4o-mini` |
| Variable d'env | `OPENAI_FILTER_MODEL` |
| 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 |
| Temps de réponse | ~3-6 secondes |
Logique de décision :
1. Appel OpenAI avec le prompt légal complet
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
@@ -169,24 +199,39 @@ Logique de décision :
| Propriété | Valeur |
|-----------|--------|
| Modèle par défaut | `gpt-4o` |
| Variable d'env | `OPENAI_SYNTHESIS_MODEL` |
| 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 |
| Temps de réponse | ~8-15 secondes |
| 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.
#### Base légale du filtre
#### Agent de vérification légale
Le prompt de filtrage intègre textuellement les articles pertinents de :
- Déclaration universelle des droits de l'homme (DUDH, ONU 1948)
- Pacte international relatif aux droits civils et politiques (PIDCP, ONU 1966)
- Convention européenne des droits de l'homme (CEDH, 1950)
- Charte des droits fondamentaux de l'UE (2000/2009)
- Convention pour la prévention du génocide (ONU 1948)
- Statut de Rome / CPI (1998)
- Convention sur la discrimination raciale (CERD, ONU 1965)
| 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](/transparence) du site.
### 3.5 PostgreSQL
@@ -195,26 +240,68 @@ Le prompt de filtrage intègre textuellement les articles pertinents de :
| Version | 15+ |
| Accès | Localhost uniquement (127.0.0.1) |
| Connexion Flask | Via `DATABASE_URL` (psycopg2-binary) |
| Authentification | md5 / scram-sha-256 |
| Authentification | scram-sha-256 |
| Migrations | Incrémentales et idempotentes — appliquées à chaque démarrage |
**Schéma de la base de données :**
```sql
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,
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
author VARCHAR(100),
accepted BOOLEAN NOT NULL DEFAULT FALSE,
rejection_reason TEXT,
legal_basis TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
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,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
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
);
```
@@ -222,18 +309,31 @@ CREATE TABLE synthesis (
| Propriété | Valeur |
|-----------|--------|
| Framework | React 18 + Vite 6 |
| 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 :**
- Hooks générés depuis la spécification OpenAPI (`lib/api-spec/openapi.yaml`)
- Codegen via Orval → `lib/api-client-react/src/generated/`
- 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
@@ -247,19 +347,27 @@ Citoyen
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 (5/min par IP)
├─ Rate limiting (fingerprint ou IP)
├─ Flood detection
├─ Agent filtrage ──► OpenAI API
├─ 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 ──► OpenAI
│ Thread daemon ──► Agent synthèse ──► Mistral AI
│ │
│ UPDATE synthesis
│ UPSERT synthesis
◄── HTTP 201 {"accepted": bool, "reason": "...", "idea": {...}} ──────────┘
```
@@ -274,6 +382,17 @@ Citoyen (polling 15s)
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é
@@ -285,7 +404,8 @@ HAProxy ──► Nginx ──► Flask ──► SELECT FROM synthesis ──
| 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é |
| IA (double filtre) | Filtre de contenu Azure/OpenAI + filtre légal interne |
| 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 |
@@ -308,8 +428,11 @@ 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
@@ -322,6 +445,8 @@ Cache-Control: no-store
| 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) |
---
@@ -330,6 +455,6 @@ Cache-Control: no-store
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 (`RATELIMIT_STORAGE_URL=redis://...`)
- **Base de données** : ajouter des index sur `ideas.accepted` et `ideas.created_at`
- **Cache synthèse** : la synthèse est unique en base, naturellement partagée entre workers
- **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
+1 -1
View File
@@ -11,7 +11,7 @@ Internet
[Nginx] ─── /api/* ──► [Gunicorn + Flask] ──► [PostgreSQL]
│ │
└─── /* ──► [React SPA] └──► [OpenAI API]
└─── /* ──► [React SPA] └──► [Mistral AI API]
(fichiers statiques)
```