# Document d'Architecture Technique — La Voix du Peuple **Version** : 1.4 **Date** : Avril 2026 **Statut** : En production (Replit), prêt pour auto-hébergement --- ## Historique des versions | Version | Date | Modifications | |---------|------|---------------| | 1.0 | Avril 2026 | Version initiale | | 1.1 | Avril 2026 | Ajout page Flyer QR, boutons Partager / PDF, `qrcode.react` | | 1.2 | Avril 2026 | Palette pétrol neutre, textes de posture (expression vs. vérité) | | 1.3 | Avril 2026 | Dark mode pétrol, panneau d'accessibilité (dyslexie, contraste, zoom) | | 1.4 | Avril 2026 | Synchronisation Gitea sécurisée — `GITEA_TOKEN` + `scripts/push-gitea.sh` | --- ## 1. Présentation générale **La Voix du Peuple** est une plateforme civique permettant à des citoyens de soumettre des propositions politiques. Ces contributions sont : 1. Filtrées par un agent IA selon le droit international des droits humains 2. Synthétisées automatiquement en un résumé clair par thème 3. Affichées en temps réel à destination d'élus ou de décideurs 4. Exportables (PDF, partage natif) et diffusables via un flyer imprimable avec QR code --- ## 2. Architecture globale ``` ┌─────────────────────────────────────────────────────────┐ │ Navigateur client │ │ React + Vite (TypeScript) │ └────────────────────────┬────────────────────────────────┘ │ HTTP/REST (JSON) ▼ ┌─────────────────────────────────────────────────────────┐ │ Reverse Proxy — Nginx / HAProxy │ │ (rate limiting, TLS, X-Forwarded-For) │ └────────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ Backend API — Flask (Python) │ │ Port 8080 │ │ │ │ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐ │ │ │ app.py │ │ ai_agent.py │ │ database.py │ │ │ │ (routes) │──▶│ (filtre + │ │ (PostgreSQL) │ │ │ │ │ │ synthèse) │ │ │ │ │ └──────────────┘ └──────┬──────┘ └──────┬───────┘ │ └─────────────────────────────┼───────────────┼──────────┘ │ │ ▼ ▼ ┌──────────────────┐ ┌──────────────┐ │ Mistral AI API │ │ PostgreSQL │ │ api.mistral.ai │ │ (base DB) │ └──────────────────┘ └──────────────┘ ``` --- ## 3. Composants ### 3.1 Frontend — React + Vite | Élément | Détail | |---------|--------| | Framework | React 18 + TypeScript | | Build | Vite 7 | | Styles | Tailwind CSS + shadcn/ui | | Routing | Wouter | | État serveur | TanStack Query | | Client API | `@workspace/api-client-react` (généré depuis OpenAPI) | | QR code | `qrcode.react` (page Flyer) | | Police | Bahnschrift (titres), Inter (corps) | | Couleur principale | Pétrol foncé `hsl(185 42% 28%)` — neutre, sans connotation partisane | **Pages** : | URL | Description | |-----|-------------| | `/` | Page principale : formulaire de soumission, fil des contributions, colonne de synthèse | | `/about` | À propos, fondements juridiques, posture de la démarche | | `/transparence` | Fonctionnement de l'IA, données collectées, limites, posture éditoriale | | `/flyer` | Flyer imprimable avec QR code configurable pour diffusion physique | **Accessibilité** : | Fonctionnalité | Mécanisme | |----------------|-----------| | Dark mode | Classes CSS `.dark` sur ``, variables pétrol clair (`hsl(185 55% 58%)`) | | Police dyslexie | Classe `.dyslexia` : Arial/Verdana, `letter-spacing 0.06em`, `line-height 2` | | Contraste élevé | Classe `.high-contrast` : fond blanc/noir pur, ratio WCAG AA+ | | Texte agrandi | Classe `.large-text` : `font-size 120%` global | | Navigation clavier | `skip-link` "Aller au contenu principal" + anneau `:focus-visible` 3 px | | ARIA | `aria-pressed` sur les toggles, `aria-label` sur tous les boutons d'action | | Persistance | Préférences stockées dans `localStorage`, relues au chargement | | Panneau | Composant `AccessibilityPanel` — icône dans la barre de navigation | | Fournisseur d'état | `AccessibilityProvider` + `useAccessibility` hook (React Context) | **Textes de posture** (infusés dans plusieurs sections) : - Bandeau d'intro : "espace d'expression citoyenne, pas un sondage ni une vérité établie" - Pied de synthèse : note italique discrète rappelant l'ancrage dans l'expertise de l'auteur - Page À propos — section "Expression, pas vérité" : distinction expression/consensus, posture d'auteur - Page Fonctionnement — encart "Ce que cette plateforme n'est pas" : limites éditoriales et posture **Fonctionnalités de la colonne de synthèse** : - Bouton **Partager / Copier** : compose un texte horodaté (texte + nombre de contributions + date) et l'envoie via l'API Web Share (mobile) ou le presse-papier (bureau) ; un toast confirme la copie - Bouton **PDF** : ouvre une fenêtre dédiée avec un rendu mise en page (tricolore, titre, métadonnées, texte, pied de page `lavoixdupeuple.fr`) et déclenche l'impression navigateur **Page Flyer (`/flyer`)** : - QR code SVG haute résolution (niveau de correction H) pointant vers l'URL de destination - URL modifiable en temps réel via un champ texte, sans rechargement - URL par défaut définie par la constante `DEFAULT_QR_URL` en ligne 10 de `src/pages/flyer.tsx` - Passage d'une URL via paramètre GET : `/flyer?url=https://monsite.fr` - Barre de contrôle masquée à l'impression (`@media print`) — seul le flyer A4 est imprimé - Bouton "Imprimer / Exporter PDF" sur la page **Variables d'environnement frontend** : - `BASE_URL` — Préfixe de chemin (injecté par Vite) - `PORT` — Port du serveur de développement (assigné par Replit) --- ### 3.2 Backend — Flask | Élément | Détail | |---------|--------| | Framework | Flask 3 (Python 3.11+) | | Serveur WSGI | Gunicorn (production) | | CORS | flask-cors | | Rate limiting | flask-limiter (5 req/min par IP sur POST /api/ideas) | | ORM | psycopg2-binary (requêtes SQL directes) | **Endpoints** : | Méthode | Route | Description | |---------|-------|-------------| | `GET` | `/api/ideas` | Liste des contributions acceptées | | `POST` | `/api/ideas` | Soumet une nouvelle contribution | | `GET` | `/api/ideas/stats` | Statistiques (acceptées/refusées) | | `GET` | `/api/synthesis` | Texte de synthèse actuel | | `GET` | `/health` | Health check | **Réponses** : JSON camelCase, statuts HTTP standard. --- ### 3.3 Agent IA Deux appels distincts à l'API Mistral (compatible OpenAI SDK) : #### Filtre de modération - **Modèle** : `mistral-small-latest` (configurable via `FILTER_MODEL`) - **Entrée** : Texte brut de la contribution - **Sortie** : JSON `{"accepted": bool, "reason"?: string, "legal_basis"?: string}` - **Référentiel** : DUDH, PIDCP, CEDH, Charte UE, CERD, Statut de Rome - **Max tokens** : 300 #### Synthèse collective - **Modèle** : `mistral-large-latest` (configurable via `SYNTHESIS_MODEL`) - **Entrée** : Liste de toutes les contributions acceptées - **Sortie** : Texte libre structuré par thèmes, destiné aux élus - **Max tokens** : 1200 - **Déclencheur** : À chaque nouvelle contribution acceptée (asynchrone) **Priorité de configuration du client IA** : 1. `MISTRAL_API_KEY` → `https://api.mistral.ai/v1` 2. `OPENAI_API_KEY` → API OpenAI standard 3. `AI_INTEGRATIONS_OPENAI_*` → Proxy Replit (intégration native) --- ### 3.4 Base de données — PostgreSQL **Table `ideas`** : | Colonne | Type | Description | |---------|------|-------------| | `id` | SERIAL PK | Identifiant | | `content` | TEXT | Texte de la contribution | | `author` | VARCHAR(100) | Pseudonyme (nullable) | | `accepted` | BOOLEAN | Résultat du filtre | | `rejection_reason` | TEXT | Motif de refus (nullable) | | `legal_basis` | TEXT | Base légale du refus (nullable) | | `created_at` | TIMESTAMPTZ | Horodatage de soumission | **Table `synthesis`** : | Colonne | Type | Description | |---------|------|-------------| | `id` | SERIAL PK | (toujours 1 — ligne unique) | | `text` | TEXT | Dernier texte de synthèse | | `idea_count` | INTEGER | Nombre de contributions intégrées | | `updated_at` | TIMESTAMPTZ | Dernière mise à jour | --- ## 4. Variables d'environnement | Variable | Obligatoire | Description | |----------|-------------|-------------| | `DATABASE_URL` | ✅ | URL PostgreSQL complète | | `MISTRAL_API_KEY` | ✅ (ou OpenAI) | Clé API Mistral | | `SESSION_SECRET` | ✅ | Secret Flask (sessions) | | `GITEA_TOKEN` | ✅ (pour push) | Token d'accès Gitea — utilisé par `scripts/push-gitea.sh` | | `FILTER_MODEL` | — | Modèle de filtrage (défaut : `mistral-small-latest`) | | `SYNTHESIS_MODEL` | — | Modèle de synthèse (défaut : `mistral-large-latest`) | | `OPENAI_API_KEY` | — | Alternative à Mistral | | `MISTRAL_BASE_URL` | — | URL custom Mistral (défaut : `https://api.mistral.ai/v1`) | --- ## 5. Infrastructure de déploiement (auto-hébergement) ``` Internet ──▶ HAProxy (TLS, load balancing) └──▶ Nginx (reverse proxy, rate limiting IP) └──▶ Gunicorn × N workers (Flask) └──▶ Fichiers statiques Vite (build) │ PostgreSQL (local ou RDS) ``` **Fichiers fournis** : - `deploy/nginx.conf` — Configuration Nginx avec HAProxy support - `deploy/voix-du-peuple-api.service` — Unité systemd pour Gunicorn - `artifacts/voix-du-peuple/vite.config.selfhost.ts` — Build sans plugins Replit - `scripts/push-gitea.sh` — Push sécurisé vers Gitea (compatible Git 2.50+, lit `GITEA_TOKEN`) --- ## 6. Sécurité | Mesure | Implémentation | |--------|----------------| | Rate limiting | 5 POST/min par IP (flask-limiter) | | Sanitisation | bleach sur le contenu avant stockage | | CORS | Origines configurées explicitement | | SQL injection | Requêtes paramétrées (psycopg2) | | Secrets | Variables d'environnement uniquement, jamais dans le code | | Données personnelles | Aucune IP stockée, pseudonyme facultatif | | Export PDF | Généré côté client uniquement, aucune donnée transmise au serveur | --- ## 7. Flux de traitement d'une contribution ``` POST /api/ideas │ ├─ Validation (longueur 10–1000 chars) ├─ Sanitisation (bleach) ├─ INSERT en base (statut pending) │ ├─ Appel Mistral (filtre) │ ├─ accepted=true → UPDATE idea, déclenche synthèse async │ └─ accepted=false → UPDATE idea avec motif │ ├─ [async] Appel Mistral (synthèse) sur toutes contributions acceptées │ └─ UPSERT table synthesis │ └─ Réponse JSON 201 ``` ## 8. Flux d'export / partage (côté client) ``` Clic "Partager / Copier" │ ├─ Composition du texte horodaté (date locale, nb contributions, texte synthèse) ├─ navigator.share disponible ? → Partage natif (mobile) └─ Sinon → navigator.clipboard.writeText → toast de confirmation Clic "PDF" │ ├─ Génération HTML en mémoire (tricolore, titre, méta, texte, pied de page) ├─ window.open("", "_blank") → document.write(html) └─ window.print() → dialogue d'impression / export PDF navigateur Page /flyer — Clic "Imprimer / Exporter PDF" │ ├─ CSS @media print masque .no-print (barre de contrôle, navbar) └─ window.print() → impression du flyer A4 seul ```