From bc6bd3f9d779e7bd203f4d701ea5684e058a23a5 Mon Sep 17 00:00:00 2001 From: billisdead Date: Sat, 23 May 2026 22:41:06 +0200 Subject: [PATCH] =?UTF-8?q?D=C3=A9-Replit-isation=20compl=C3=A8te=20du=20p?= =?UTF-8?q?rojet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprimés : - replit.md — doc Replit obsolète - docs/GITEA_TUTO.md — tuto push Replit → Gitea (obsolète) - artifacts/api-server/ — serveur TypeScript mort, remplacé par Flask - artifacts/voix-du-peuple/vite.config.selfhost.ts — fusionné dans vite.config.ts Nettoyés : - ai_agent.py — fallback Replit AI supprimé (Mistral + OpenAI-compatible suffisent) - vite.config.ts — plugins @replit/* retirés, PORT optionnel (défaut 5173) - package.json + pnpm-workspace.yaml — @replit/* retirés du catalog et des deps - badge.tsx + button.tsx — commentaires // @replit supprimés - README.md, DEPLOIEMENT.md, DAT.md, DEX.md, WIKI.md — références Replit remplacées Co-Authored-By: Claude Sonnet 4.6 --- DEPLOIEMENT.md | 15 +- README.md | 13 +- .../api-server/.replit-artifact/artifact.toml | 27 --- artifacts/api-server/build.mjs | 126 ------------- artifacts/api-server/package.json | 33 ---- artifacts/api-server/src/app.ts | 34 ---- artifacts/api-server/src/index.ts | 25 --- artifacts/api-server/src/lib/.gitkeep | 0 artifacts/api-server/src/lib/ai-agent.ts | 93 ---------- artifacts/api-server/src/lib/logger.ts | 20 -- artifacts/api-server/src/middlewares/.gitkeep | 0 artifacts/api-server/src/routes/health.ts | 11 -- artifacts/api-server/src/routes/ideas.ts | 100 ---------- artifacts/api-server/src/routes/index.ts | 12 -- artifacts/api-server/src/routes/synthesis.ts | 25 --- artifacts/api-server/tsconfig.json | 17 -- artifacts/flask-api/ai_agent.py | 21 +-- artifacts/voix-du-peuple/package.json | 3 - .../src/components/ui/badge.tsx | 6 - .../src/components/ui/button.tsx | 9 +- .../voix-du-peuple/vite.config.selfhost.ts | 23 --- artifacts/voix-du-peuple/vite.config.ts | 38 +--- docs/DAT.md | 11 +- docs/DEX.md | 31 +--- docs/GITEA_TUTO.md | 172 ------------------ docs/WIKI.md | 2 +- pnpm-workspace.yaml | 14 +- replit.md | 69 ------- 28 files changed, 31 insertions(+), 919 deletions(-) delete mode 100644 artifacts/api-server/.replit-artifact/artifact.toml delete mode 100644 artifacts/api-server/build.mjs delete mode 100644 artifacts/api-server/package.json delete mode 100644 artifacts/api-server/src/app.ts delete mode 100644 artifacts/api-server/src/index.ts delete mode 100644 artifacts/api-server/src/lib/.gitkeep delete mode 100644 artifacts/api-server/src/lib/ai-agent.ts delete mode 100644 artifacts/api-server/src/lib/logger.ts delete mode 100644 artifacts/api-server/src/middlewares/.gitkeep delete mode 100644 artifacts/api-server/src/routes/health.ts delete mode 100644 artifacts/api-server/src/routes/ideas.ts delete mode 100644 artifacts/api-server/src/routes/index.ts delete mode 100644 artifacts/api-server/src/routes/synthesis.ts delete mode 100644 artifacts/api-server/tsconfig.json delete mode 100644 artifacts/voix-du-peuple/vite.config.selfhost.ts delete mode 100644 docs/GITEA_TUTO.md delete mode 100644 replit.md diff --git a/DEPLOIEMENT.md b/DEPLOIEMENT.md index 976b9b4..69e99e2 100644 --- a/DEPLOIEMENT.md +++ b/DEPLOIEMENT.md @@ -18,7 +18,7 @@ Internet - **Frontend** : React + Vite, servi comme fichiers statiques par Nginx - **Backend** : Flask + Gunicorn (4 workers), accessible uniquement via Nginx - **Base de données** : PostgreSQL 15+ -- **IA** : API OpenAI (clé standard, pas de proxy Replit) +- **IA** : Mistral AI (MISTRAL_API_KEY requis) --- @@ -295,8 +295,7 @@ voix-du-peuple/ │ │ ├── pages/ # home.tsx, about.tsx │ │ ├── components/ # Composants UI (shadcn/ui + radix) │ │ └── App.tsx # Routing principal -│ ├── vite.config.ts # Config Replit (développement) -│ └── vite.config.selfhost.ts # Config auto-hébergement (production) +│ └── vite.config.ts # Config Vite (développement + production) ├── lib/ │ ├── api-spec/ # Spécification OpenAPI │ ├── api-client-react/ # Hooks React Query générés @@ -327,12 +326,10 @@ voix-du-peuple/ ## Modèles IA utilisés -| Fonction | Modèle par défaut | Configurable dans | -|----------|-------------------|-------------------| -| Filtrage des idées | `gpt-4o-mini` | `ai_agent.py` ligne 56 | -| Synthèse collective | `gpt-4o` | `ai_agent.py` ligne 104 | - -> **Note :** Les modèles `gpt-5-mini` et `gpt-5.2` sont les noms utilisés sur la plateforme Replit. En auto-hébergement avec l'API OpenAI standard, utilisez `gpt-4o-mini` et `gpt-4o`. +| Fonction | Modèle par défaut | Variable d'environnement | +|----------|-------------------|--------------------------| +| Filtrage des idées | `mistral-small-latest` | `FILTER_MODEL` | +| Synthèse collective | `mistral-large-latest` | `SYNTHESIS_MODEL` | Pour changer les modèles, éditez `artifacts/flask-api/ai_agent.py` : ```python diff --git a/README.md b/README.md index 2f2ce83..0c653b1 100644 --- a/README.md +++ b/README.md @@ -129,16 +129,6 @@ systemctl reload nginx --- -## Synchroniser avec ce dépôt (depuis Replit) - -```bash -bash scripts/push-gitea.sh -``` - -Prérequis : secret `GITEA_TOKEN` configuré dans Replit → Secrets. - ---- - ## Documentation | Document | Contenu | @@ -147,8 +137,9 @@ Prérequis : secret `GITEA_TOKEN` configuré dans Replit → Secrets. | [`docs/DEX.md`](docs/DEX.md) | Guide d'exploitation et maintenance | | [`docs/WIKI.md`](docs/WIKI.md) | Page wiki — présentation générale | | [`docs/INSTALL_ROCKY.md`](docs/INSTALL_ROCKY.md) | Installation sur RockyLinux 9 | -| [`docs/GITEA_TUTO.md`](docs/GITEA_TUTO.md) | Synchronisation Replit → Gitea | | [`docs/SECURITE_ANTI_ABUS.md`](docs/SECURITE_ANTI_ABUS.md) | Protections anti-bot, flood, rate limiting, hCaptcha | +| [`docs/RGPD.md`](docs/RGPD.md) | Conformité RGPD — registre des traitements | +| [`docs/PROMPTS_IA.md`](docs/PROMPTS_IA.md) | Prompts IA intégraux (transparence algorithmique) | --- diff --git a/artifacts/api-server/.replit-artifact/artifact.toml b/artifacts/api-server/.replit-artifact/artifact.toml deleted file mode 100644 index 1c229d2..0000000 --- a/artifacts/api-server/.replit-artifact/artifact.toml +++ /dev/null @@ -1,27 +0,0 @@ -kind = "api" -previewPath = "/api" -title = "API Server (Flask)" -version = "1.0.0" -id = "3B4_FFSkEVBkAeYMFRJ2e" - -[[services]] -localPort = 8080 -name = "API Server" -paths = ["/api"] - -[services.development] -run = "PORT=8080 sh /home/runner/workspace/artifacts/flask-api/start.sh" - -[services.production] - -[services.production.build] -args = ["echo", "no build step for Flask"] - -[services.production.run] -args = ["sh", "/home/runner/workspace/artifacts/flask-api/start.sh"] - -[services.production.run.env] -PORT = "8080" - -[services.production.health.startup] -path = "/api/healthz" diff --git a/artifacts/api-server/build.mjs b/artifacts/api-server/build.mjs deleted file mode 100644 index 86ebf7f..0000000 --- a/artifacts/api-server/build.mjs +++ /dev/null @@ -1,126 +0,0 @@ -import { createRequire } from "node:module"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { build as esbuild } from "esbuild"; -import esbuildPluginPino from "esbuild-plugin-pino"; -import { rm } from "node:fs/promises"; - -// Plugins (e.g. 'esbuild-plugin-pino') may use `require` to resolve dependencies -globalThis.require = createRequire(import.meta.url); - -const artifactDir = path.dirname(fileURLToPath(import.meta.url)); - -async function buildAll() { - const distDir = path.resolve(artifactDir, "dist"); - await rm(distDir, { recursive: true, force: true }); - - await esbuild({ - entryPoints: [path.resolve(artifactDir, "src/index.ts")], - platform: "node", - bundle: true, - format: "esm", - outdir: distDir, - outExtension: { ".js": ".mjs" }, - logLevel: "info", - // Some packages may not be bundleable, so we externalize them, we can add more here as needed. - // Some of the packages below may not be imported or installed, but we're adding them in case they are in the future. - // Examples of unbundleable packages: - // - uses native modules and loads them dynamically (e.g. sharp) - // - use path traversal to read files (e.g. @google-cloud/secret-manager loads sibling .proto files) - external: [ - "*.node", - "sharp", - "better-sqlite3", - "sqlite3", - "canvas", - "bcrypt", - "argon2", - "fsevents", - "re2", - "farmhash", - "xxhash-addon", - "bufferutil", - "utf-8-validate", - "ssh2", - "cpu-features", - "dtrace-provider", - "isolated-vm", - "lightningcss", - "pg-native", - "oracledb", - "mongodb-client-encryption", - "nodemailer", - "handlebars", - "knex", - "typeorm", - "protobufjs", - "onnxruntime-node", - "@tensorflow/*", - "@prisma/client", - "@mikro-orm/*", - "@grpc/*", - "@swc/*", - "@aws-sdk/*", - "@azure/*", - "@opentelemetry/*", - "@google-cloud/*", - "@google/*", - "googleapis", - "firebase-admin", - "@parcel/watcher", - "@sentry/profiling-node", - "@tree-sitter/*", - "aws-sdk", - "classic-level", - "dd-trace", - "ffi-napi", - "grpc", - "hiredis", - "kerberos", - "leveldown", - "miniflare", - "mysql2", - "newrelic", - "odbc", - "piscina", - "realm", - "ref-napi", - "rocksdb", - "sass-embedded", - "sequelize", - "serialport", - "snappy", - "tinypool", - "usb", - "workerd", - "wrangler", - "zeromq", - "zeromq-prebuilt", - "playwright", - "puppeteer", - "puppeteer-core", - "electron", - ], - sourcemap: "linked", - plugins: [ - // pino relies on workers to handle logging, instead of externalizing it we use a plugin to handle it - esbuildPluginPino({ transports: ["pino-pretty"] }) - ], - // Make sure packages that are cjs only (e.g. express) but are bundled continue to work in our esm output file - banner: { - js: `import { createRequire as __bannerCrReq } from 'node:module'; -import __bannerPath from 'node:path'; -import __bannerUrl from 'node:url'; - -globalThis.require = __bannerCrReq(import.meta.url); -globalThis.__filename = __bannerUrl.fileURLToPath(import.meta.url); -globalThis.__dirname = __bannerPath.dirname(globalThis.__filename); - `, - }, - }); -} - -buildAll().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json deleted file mode 100644 index 1aa59ea..0000000 --- a/artifacts/api-server/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@workspace/api-server", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "export NODE_ENV=development && pnpm run build && pnpm run start", - "build": "node ./build.mjs", - "start": "node --enable-source-maps ./dist/index.mjs", - "typecheck": "tsc -p tsconfig.json --noEmit" - }, - "dependencies": { - "@workspace/api-zod": "workspace:*", - "@workspace/db": "workspace:*", - "cookie-parser": "^1.4.7", - "cors": "^2", - "drizzle-orm": "catalog:", - "express": "^5", - "openai": "^6.33.0", - "pino": "^9", - "pino-http": "^10" - }, - "devDependencies": { - "@types/cookie-parser": "^1.4.10", - "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", - "@types/node": "catalog:", - "esbuild": "^0.27.3", - "esbuild-plugin-pino": "^2.3.3", - "pino-pretty": "^13", - "thread-stream": "3.1.0" - } -} diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts deleted file mode 100644 index f32f71e..0000000 --- a/artifacts/api-server/src/app.ts +++ /dev/null @@ -1,34 +0,0 @@ -import express, { type Express } from "express"; -import cors from "cors"; -import pinoHttp from "pino-http"; -import router from "./routes"; -import { logger } from "./lib/logger"; - -const app: Express = express(); - -app.use( - pinoHttp({ - logger, - serializers: { - req(req) { - return { - id: req.id, - method: req.method, - url: req.url?.split("?")[0], - }; - }, - res(res) { - return { - statusCode: res.statusCode, - }; - }, - }, - }), -); -app.use(cors()); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); - -app.use("/api", router); - -export default app; diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts deleted file mode 100644 index b1f024d..0000000 --- a/artifacts/api-server/src/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import app from "./app"; -import { logger } from "./lib/logger"; - -const rawPort = process.env["PORT"]; - -if (!rawPort) { - throw new Error( - "PORT environment variable is required but was not provided.", - ); -} - -const port = Number(rawPort); - -if (Number.isNaN(port) || port <= 0) { - throw new Error(`Invalid PORT value: "${rawPort}"`); -} - -app.listen(port, (err) => { - if (err) { - logger.error({ err }, "Error listening on port"); - process.exit(1); - } - - logger.info({ port }, "Server listening"); -}); diff --git a/artifacts/api-server/src/lib/.gitkeep b/artifacts/api-server/src/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/artifacts/api-server/src/lib/ai-agent.ts b/artifacts/api-server/src/lib/ai-agent.ts deleted file mode 100644 index 980fcf1..0000000 --- a/artifacts/api-server/src/lib/ai-agent.ts +++ /dev/null @@ -1,93 +0,0 @@ -import OpenAI from "openai"; -import { logger } from "./logger"; - -const openai = new OpenAI({ - baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL, - apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY, -}); - -export interface FilterResult { - accepted: boolean; - reason?: string; -} - -export async function filterIdea(content: string): Promise { - const systemPrompt = `Tu es un agent de filtrage éthique pour une plateforme démocratique citoyenne. -Ta mission : analyser les idées politiques soumises et décider si elles respectent les valeurs démocratiques. - -Critères d'ACCEPTATION : -- L'idée promeut les droits fondamentaux, la liberté, l'égalité, la justice sociale -- L'idée propose des améliorations concrètes pour la société -- L'idée est constructive, même si critique du gouvernement ou des institutions -- L'idée débat de politiques publiques de manière civile - -Critères de REJET : -- Contenu fasciste, totalitaire ou autoritaire -- Appels à la haine, discrimination ou violence -- Négation de droits fondamentaux pour des groupes de personnes -- Propagande pour des idéologies qui détruisent la démocratie -- Contenu raciste, sexiste, homophobe ou xénophobe -- Appels au renversement violent de la démocratie - -Réponds UNIQUEMENT avec un JSON valide, sans markdown, dans ce format exact : -{"accepted": true} ou {"accepted": false, "reason": "explication courte en français"}`; - - try { - const response = await openai.chat.completions.create({ - model: "gpt-5-mini", - max_completion_tokens: 200, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: `Idée à analyser : "${content}"` }, - ], - }); - - const raw = response.choices[0]?.message?.content ?? '{"accepted": false, "reason": "Erreur d\'analyse"}'; - const result = JSON.parse(raw) as FilterResult; - return result; - } catch (err) { - logger.error({ err }, "Error filtering idea"); - return { accepted: false, reason: "Erreur interne de filtrage" }; - } -} - -export async function synthesizeIdeas(ideas: string[]): Promise { - if (ideas.length === 0) { - return "Aucune idée n'a encore été soumise. Soyez le premier à partager votre vision pour une société meilleure."; - } - - const systemPrompt = `Tu es un synthétiseur démocratique. Tu reçois une liste d'idées politiques citoyennes filtrées et validées. -Ta mission : créer UN texte synthétique, éloquent et inspirant qui capture l'essence collective de ces idées. - -Ce texte est "La Voix du Peuple" — il doit : -- Être écrit à la première personne du pluriel (nous, notre, nos) -- Capturer les thèmes communs et les aspirations partagées -- Être poétique mais concret, inspirant mais ancré dans la réalité -- Faire environ 3-5 paragraphes -- Commencer par "Nous, le peuple, ..." -- Respecter la diversité des idées sans en ignorer aucune -- Être rédigé en français - -NE PAS mentionner les idées individuellement, mais les fondre dans une vision collective cohérente.`; - - const ideasText = ideas.map((idea, i) => `${i + 1}. ${idea}`).join("\n"); - - try { - const response = await openai.chat.completions.create({ - model: "gpt-5.2", - max_completion_tokens: 1000, - messages: [ - { role: "system", content: systemPrompt }, - { - role: "user", - content: `Voici les idées citoyennes à synthétiser :\n\n${ideasText}\n\nRédige "La Voix du Peuple".`, - }, - ], - }); - - return response.choices[0]?.message?.content ?? "Synthèse en cours..."; - } catch (err) { - logger.error({ err }, "Error synthesizing ideas"); - return "La synthèse est temporairement indisponible. Vos idées ont été enregistrées."; - } -} diff --git a/artifacts/api-server/src/lib/logger.ts b/artifacts/api-server/src/lib/logger.ts deleted file mode 100644 index d9c67f7..0000000 --- a/artifacts/api-server/src/lib/logger.ts +++ /dev/null @@ -1,20 +0,0 @@ -import pino from "pino"; - -const isProduction = process.env.NODE_ENV === "production"; - -export const logger = pino({ - level: process.env.LOG_LEVEL ?? "info", - redact: [ - "req.headers.authorization", - "req.headers.cookie", - "res.headers['set-cookie']", - ], - ...(isProduction - ? {} - : { - transport: { - target: "pino-pretty", - options: { colorize: true }, - }, - }), -}); diff --git a/artifacts/api-server/src/middlewares/.gitkeep b/artifacts/api-server/src/middlewares/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/artifacts/api-server/src/routes/health.ts b/artifacts/api-server/src/routes/health.ts deleted file mode 100644 index c0a1446..0000000 --- a/artifacts/api-server/src/routes/health.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router, type IRouter } from "express"; -import { HealthCheckResponse } from "@workspace/api-zod"; - -const router: IRouter = Router(); - -router.get("/healthz", (_req, res) => { - const data = HealthCheckResponse.parse({ status: "ok" }); - res.json(data); -}); - -export default router; diff --git a/artifacts/api-server/src/routes/ideas.ts b/artifacts/api-server/src/routes/ideas.ts deleted file mode 100644 index 0607f42..0000000 --- a/artifacts/api-server/src/routes/ideas.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Router, type IRouter } from "express"; -import { eq, count, and } from "drizzle-orm"; -import { db, ideasTable, synthesisTable } from "@workspace/db"; -import { SubmitIdeaBody } from "@workspace/api-zod"; -import { filterIdea, synthesizeIdeas } from "../lib/ai-agent"; -import { logger } from "../lib/logger"; - -const router: IRouter = Router(); - -router.get("/ideas", async (_req, res): Promise => { - const ideas = await db - .select() - .from(ideasTable) - .where(eq(ideasTable.accepted, true)) - .orderBy(ideasTable.createdAt); - res.json(ideas); -}); - -router.get("/ideas/stats", async (_req, res): Promise => { - const [totalRow] = await db.select({ value: count() }).from(ideasTable); - const [acceptedRow] = await db - .select({ value: count() }) - .from(ideasTable) - .where(eq(ideasTable.accepted, true)); - const [rejectedRow] = await db - .select({ value: count() }) - .from(ideasTable) - .where(eq(ideasTable.accepted, false)); - - res.json({ - total: Number(totalRow?.value ?? 0), - accepted: Number(acceptedRow?.value ?? 0), - rejected: Number(rejectedRow?.value ?? 0), - }); -}); - -router.post("/ideas", async (req, res): Promise => { - const parsed = SubmitIdeaBody.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: "validation_error", message: parsed.error.message }); - return; - } - - const { content, author } = parsed.data; - - req.log.info({ contentLength: content.length }, "Filtering new idea"); - - const filterResult = await filterIdea(content); - - const [idea] = await db - .insert(ideasTable) - .values({ - content, - author: author ?? null, - accepted: filterResult.accepted, - rejectionReason: filterResult.reason ?? null, - }) - .returning(); - - if (filterResult.accepted) { - triggerSynthesisUpdate().catch((err) => { - logger.error({ err }, "Background synthesis update failed"); - }); - } - - res.status(201).json({ - id: idea!.id, - accepted: filterResult.accepted, - reason: filterResult.reason, - idea: idea, - }); -}); - -async function triggerSynthesisUpdate(): Promise { - const ideas = await db - .select({ content: ideasTable.content }) - .from(ideasTable) - .where(eq(ideasTable.accepted, true)) - .orderBy(ideasTable.createdAt); - - const ideaTexts = ideas.map((i) => i.content); - const synthesizedText = await synthesizeIdeas(ideaTexts); - - const existing = await db.select().from(synthesisTable).limit(1); - - if (existing.length > 0) { - await db - .update(synthesisTable) - .set({ text: synthesizedText, ideaCount: ideaTexts.length }); - } else { - await db.insert(synthesisTable).values({ - text: synthesizedText, - ideaCount: ideaTexts.length, - }); - } - - logger.info({ ideaCount: ideaTexts.length }, "Synthesis updated"); -} - -export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts deleted file mode 100644 index c7cc355..0000000 --- a/artifacts/api-server/src/routes/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Router, type IRouter } from "express"; -import healthRouter from "./health"; -import ideasRouter from "./ideas"; -import synthesisRouter from "./synthesis"; - -const router: IRouter = Router(); - -router.use(healthRouter); -router.use(ideasRouter); -router.use(synthesisRouter); - -export default router; diff --git a/artifacts/api-server/src/routes/synthesis.ts b/artifacts/api-server/src/routes/synthesis.ts deleted file mode 100644 index 369ac5a..0000000 --- a/artifacts/api-server/src/routes/synthesis.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Router, type IRouter } from "express"; -import { db, synthesisTable } from "@workspace/db"; - -const router: IRouter = Router(); - -router.get("/synthesis", async (_req, res): Promise => { - const [synthesis] = await db.select().from(synthesisTable).limit(1); - - if (!synthesis) { - res.json({ - text: "Aucune idée n'a encore été soumise. Soyez le premier à partager votre vision pour une société meilleure.", - ideaCount: 0, - updatedAt: null, - }); - return; - } - - res.json({ - text: synthesis.text, - ideaCount: synthesis.ideaCount, - updatedAt: synthesis.updatedAt, - }); -}); - -export default router; diff --git a/artifacts/api-server/tsconfig.json b/artifacts/api-server/tsconfig.json deleted file mode 100644 index b60e718..0000000 --- a/artifacts/api-server/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "rootDir": "src", - "types": ["node"] - }, - "include": ["src"], - "references": [ - { - "path": "../../lib/db" - }, - { - "path": "../../lib/api-zod" - } - ] -} diff --git a/artifacts/flask-api/ai_agent.py b/artifacts/flask-api/ai_agent.py index 2608d22..c1efa17 100644 --- a/artifacts/flask-api/ai_agent.py +++ b/artifacts/flask-api/ai_agent.py @@ -3,7 +3,7 @@ La Voix du Peuple — Agent IA Copyright (C) 2026 billisdead — Licence EUPL-1.2 Agent IA pour le filtrage éthique et la synthèse démocratique. -Supporte Mistral AI, OpenAI, et les intégrations Replit AI. +Supporte Mistral AI (par défaut) et tout fournisseur compatible OpenAI. """ import json import os @@ -20,10 +20,9 @@ _client: OpenAI | None = None def get_client() -> OpenAI: """ - Supporte trois modes (par ordre de priorité) : - 1. Mistral AI : MISTRAL_API_KEY (+ MISTRAL_BASE_URL optionnel) - 2. OpenAI standard : OPENAI_API_KEY (+ OPENAI_BASE_URL optionnel) - 3. Replit AI Integration : AI_INTEGRATIONS_OPENAI_BASE_URL + AI_INTEGRATIONS_OPENAI_API_KEY + Supporte deux modes (par ordre de priorité) : + 1. Mistral AI : MISTRAL_API_KEY (+ MISTRAL_BASE_URL optionnel) + 2. OpenAI-compatible : OPENAI_API_KEY (+ OPENAI_BASE_URL optionnel) """ global _client if _client is None: @@ -31,26 +30,20 @@ def get_client() -> OpenAI: mistral_base = os.environ.get("MISTRAL_BASE_URL", MISTRAL_BASE_URL) std_key = os.environ.get("OPENAI_API_KEY") std_base = os.environ.get("OPENAI_BASE_URL") - replit_base = os.environ.get("AI_INTEGRATIONS_OPENAI_BASE_URL") - replit_key = os.environ.get("AI_INTEGRATIONS_OPENAI_API_KEY") if mistral_key: logger.info("Utilisation de l'API Mistral AI (%s)", mistral_base) _client = OpenAI(base_url=mistral_base, api_key=mistral_key) elif std_key: - logger.info("Utilisation de l'API OpenAI") - kwargs = {"api_key": std_key} + logger.info("Utilisation d'une API compatible OpenAI") + kwargs: dict = {"api_key": std_key} if std_base: kwargs["base_url"] = std_base _client = OpenAI(**kwargs) - elif replit_base and replit_key: - logger.info("Utilisation de l'intégration Replit AI") - _client = OpenAI(base_url=replit_base, api_key=replit_key) else: raise RuntimeError( "Aucune clé IA configurée. " - "Définissez MISTRAL_API_KEY ou OPENAI_API_KEY dans le fichier .env, " - "ou configurez les intégrations Replit AI." + "Définissez MISTRAL_API_KEY (recommandé) ou OPENAI_API_KEY dans le fichier .env." ) return _client diff --git a/artifacts/voix-du-peuple/package.json b/artifacts/voix-du-peuple/package.json index bb07ba5..4fa8ad0 100644 --- a/artifacts/voix-du-peuple/package.json +++ b/artifacts/voix-du-peuple/package.json @@ -38,9 +38,6 @@ "@radix-ui/react-toggle": "^1.1.3", "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", - "@replit/vite-plugin-cartographer": "catalog:", - "@replit/vite-plugin-dev-banner": "catalog:", - "@replit/vite-plugin-runtime-error-modal": "catalog:", "@tailwindcss/typography": "^0.5.15", "@tailwindcss/vite": "catalog:", "@tanstack/react-query": "catalog:", diff --git a/artifacts/voix-du-peuple/src/components/ui/badge.tsx b/artifacts/voix-du-peuple/src/components/ui/badge.tsx index 3f03665..cfd176a 100644 --- a/artifacts/voix-du-peuple/src/components/ui/badge.tsx +++ b/artifacts/voix-du-peuple/src/components/ui/badge.tsx @@ -4,23 +4,17 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const badgeVariants = cva( - // @replit - // Whitespace-nowrap: Badges should never wrap. "whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + " hover-elevate ", { variants: { variant: { default: - // @replit shadow-xs instead of shadow, no hover because we use hover-elevate "border-transparent bg-primary text-primary-foreground shadow-xs", secondary: - // @replit no hover because we use hover-elevate "border-transparent bg-secondary text-secondary-foreground", destructive: - // @replit shadow-xs instead of shadow, no hover because we use hover-elevate "border-transparent bg-destructive text-destructive-foreground shadow-xs", - // @replit shadow-xs" - use badge outline variable outline: "text-foreground border [border-color:var(--badge-outline)]", }, }, diff --git a/artifacts/voix-du-peuple/src/components/ui/button.tsx b/artifacts/voix-du-peuple/src/components/ui/button.tsx index 16eb95d..d67e258 100644 --- a/artifacts/voix-du-peuple/src/components/ui/button.tsx +++ b/artifacts/voix-du-peuple/src/components/ui/button.tsx @@ -11,24 +11,17 @@ const buttonVariants = cva( variants: { variant: { default: - // @replit: no hover, and add primary border - "bg-primary text-primary-foreground border border-primary-border", + "bg-primary text-primary-foreground border border-primary-border", destructive: "bg-destructive text-destructive-foreground shadow-sm border-destructive-border", outline: - // @replit Shows the background color of whatever card / sidebar / accent background it is inside of. - // Inherits the current text color. Uses shadow-xs. no shadow on active - // No hover state " border [border-color:var(--button-outline)] shadow-xs active:shadow-none ", secondary: - // @replit border, no hover, no shadow, secondary border. "border bg-secondary text-secondary-foreground border border-secondary-border ", - // @replit no hover, transparent border ghost: "border border-transparent", link: "text-primary underline-offset-4 hover:underline", }, size: { - // @replit changed sizes default: "min-h-9 px-4 py-2", sm: "min-h-8 rounded-md px-3 text-xs", lg: "min-h-10 rounded-md px-8", diff --git a/artifacts/voix-du-peuple/vite.config.selfhost.ts b/artifacts/voix-du-peuple/vite.config.selfhost.ts deleted file mode 100644 index 3ad06db..0000000 --- a/artifacts/voix-du-peuple/vite.config.selfhost.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; -import path from "path"; - -export default defineConfig({ - base: "/", - plugins: [ - react(), - tailwindcss(), - ], - resolve: { - alias: { - "@": path.resolve(import.meta.dirname, "src"), - }, - dedupe: ["react", "react-dom"], - }, - root: path.resolve(import.meta.dirname), - build: { - outDir: path.resolve(import.meta.dirname, "dist/public"), - emptyOutDir: true, - }, -}); diff --git a/artifacts/voix-du-peuple/vite.config.ts b/artifacts/voix-du-peuple/vite.config.ts index 6676c40..550a7ee 100644 --- a/artifacts/voix-du-peuple/vite.config.ts +++ b/artifacts/voix-du-peuple/vite.config.ts @@ -2,49 +2,15 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import path from "path"; -import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal"; -const rawPort = process.env.PORT; - -if (!rawPort) { - throw new Error( - "PORT environment variable is required but was not provided.", - ); -} - -const port = Number(rawPort); - -if (Number.isNaN(port) || port <= 0) { - throw new Error(`Invalid PORT value: "${rawPort}"`); -} - -const basePath = process.env.BASE_PATH; - -if (!basePath) { - throw new Error( - "BASE_PATH environment variable is required but was not provided.", - ); -} +const port = process.env.PORT ? Number(process.env.PORT) : 5173; +const basePath = process.env.BASE_PATH ?? "/"; export default defineConfig({ base: basePath, plugins: [ react(), tailwindcss(), - runtimeErrorOverlay(), - ...(process.env.NODE_ENV !== "production" && - process.env.REPL_ID !== undefined - ? [ - await import("@replit/vite-plugin-cartographer").then((m) => - m.cartographer({ - root: path.resolve(import.meta.dirname, ".."), - }), - ), - await import("@replit/vite-plugin-dev-banner").then((m) => - m.devBanner(), - ), - ] - : []), ], resolve: { alias: { diff --git a/docs/DAT.md b/docs/DAT.md index 52cf87c..3e90706 100644 --- a/docs/DAT.md +++ b/docs/DAT.md @@ -2,7 +2,7 @@ **Version** : 1.4 **Date** : Avril 2026 -**Statut** : En production (Replit), prêt pour auto-hébergement +**Statut** : Prêt pour auto-hébergement --- @@ -123,7 +123,7 @@ **Variables d'environnement frontend** : - `BASE_URL` — Préfixe de chemin (injecté par Vite) -- `PORT` — Port du serveur de développement (assigné par Replit) +- `PORT` — Port du serveur de développement (défaut : 5173) --- @@ -172,9 +172,8 @@ Deux appels distincts à l'API Mistral (compatible OpenAI SDK) : - **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) +1. `MISTRAL_API_KEY` → `https://api.mistral.ai/v1` (recommandé) +2. `OPENAI_API_KEY` → tout fournisseur compatible OpenAI (`OPENAI_BASE_URL` optionnel) --- @@ -232,7 +231,7 @@ Internet ──▶ HAProxy (TLS, load balancing) **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 +- `artifacts/voix-du-peuple/vite.config.ts` — Config Vite (développement + production) - `scripts/push-gitea.sh` — Push sécurisé vers Gitea (compatible Git 2.50+, lit `GITEA_TOKEN`) --- diff --git a/docs/DEX.md b/docs/DEX.md index 6f1e0f6..08959ba 100644 --- a/docs/DEX.md +++ b/docs/DEX.md @@ -21,19 +21,6 @@ ## 1. Démarrage et arrêt des services -### Sur Replit - -Les services sont gérés par les **Workflows** Replit. Ils démarrent automatiquement. - -| Workflow | Commande | Port | -|----------|----------|------| -| API Server | `PORT=8080 sh artifacts/flask-api/start.sh` | 8080 | -| Frontend | `pnpm --filter @workspace/voix-du-peuple run dev` | auto | - -Pour redémarrer manuellement : onglet **Workflows** → bouton restart. - ---- - ### En auto-hébergement (RockyLinux / Debian) ```bash @@ -111,10 +98,6 @@ systemctl restart voix-du-peuple-api ## 4. Consultation des logs -### Sur Replit - -Voir les logs dans l'onglet **Workflows** → cliquer sur le workflow concerné. - ### En auto-hébergement ```bash @@ -352,19 +335,15 @@ Toutes les occurrences de `--primary` dans le fichier CSS s'appliquent automatiq --- -## 15. Synchronisation avec Gitea - -Pour pousser le code depuis Replit vers Gitea (après chaque session de travail) : +## 15. Push vers Gitea ```bash -bash scripts/push-gitea.sh +git push ``` -**Prérequis** : le secret `GITEA_TOKEN` doit être configuré dans Replit → Secrets. +> **Note** : le script `scripts/push-gitea.sh` permet un push avec token HTTP si besoin (`GITEA_TOKEN`). Pour Git 2.50+, préférer `git remote set-url` avec un token en variable d'environnement. -> **Contexte** : Git 2.50+ ignore les tokens embarqués dans les URLs. Le script contourne ce comportement en transmettant les identifiants via l'en-tête HTTP `Authorization: Basic` (encodage Base64). Voir `docs/GITEA_TUTO.md` pour le détail complet. - -Pour régénérer un token Gitea : **Paramètres du compte Gitea → Applications → Générer un token** +Pour régénérer un token Gitea : **Paramètres du compte → Applications → Générer un token** Permissions requises : `repository` (lecture + écriture). --- @@ -385,7 +364,7 @@ https://votredomaine.fr/admin | Variable | Où la définir | |----------|---------------| -| `ADMIN_SECRET` | Replit → Secrets · ou `.env` en auto-hébergement | +| `ADMIN_SECRET` | `.env` ou variable d'environnement système | ### Fonctionnalités diff --git a/docs/GITEA_TUTO.md b/docs/GITEA_TUTO.md deleted file mode 100644 index 1a90372..0000000 --- a/docs/GITEA_TUTO.md +++ /dev/null @@ -1,172 +0,0 @@ -# Récupérer le projet sur Gitea - -Ce tutoriel explique comment pousser le code de **La Voix du Peuple** depuis Replit vers votre instance Gitea, et comment vous synchroniser ensuite à votre rythme. - -> **État du dépôt** : branche `main`, synchronisée. Remote actif : `origin` (Gitea). - ---- - -## Prérequis - -- Une instance Gitea accessible (`https://homegit.gyozamancave.fr`) -- Un token d'accès Gitea avec droits `repository:write` -- Le secret `GITEA_TOKEN` configuré dans Replit (voir Étape 1) - ---- - -## Étape 1 — Stocker le token dans Replit Secrets - -Votre token Gitea doit être stocké comme secret Replit, jamais dans un fichier versionné. - -1. Dans Replit → **Secrets** (cadenas dans le panneau latéral) -2. Ajouter un secret : - - **Clé** : `GITEA_TOKEN` - - **Valeur** : votre token Gitea - -Pour générer ou renouveler un token sur Gitea : -**Paramètres du compte → Applications → Générer un token** -Permissions requises : `repository` (lecture + écriture) - ---- - -## Étape 2 — Pousser vers Gitea - -Le script `scripts/push-gitea.sh` gère l'authentification automatiquement. - -Dans le shell Replit : - -```bash -bash scripts/push-gitea.sh -``` - -Ce script : -- Lit `GITEA_TOKEN` depuis l'environnement -- Encode les identifiants en Base64 (compatibilité Git 2.50+) -- Pousse la branche `main` vers `origin` (Gitea) - ---- - -## Pourquoi ce script ? (contexte technique) - -Git 2.50+ ignore les tokens embarqués dans les URLs (`https://user:token@host/...`) pour des raisons de sécurité. La solution est de passer l'en-tête `Authorization: Basic` explicitement via `-c http.extraHeader`. - -```bash -# Ce que fait le script, en détail : -B64=$(printf '%s' "billisdead:${GITEA_TOKEN}" | base64 -w0) -git -c "http.extraHeader=Authorization: Basic ${B64}" push origin main -``` - ---- - -## Étape 3 — Récupérer les mises à jour depuis Replit - -Chaque fois que vous voulez synchroniser votre Gitea avec l'état actuel du projet : - -```bash -bash scripts/push-gitea.sh -``` - -Si vous avez modifié des fichiers directement sur Gitea (peu recommandé) : - -```bash -# Récupérer les changements Gitea d'abord -B64=$(printf '%s' "billisdead:${GITEA_TOKEN}" | base64 -w0) -git -c "http.extraHeader=Authorization: Basic ${B64}" pull origin main --rebase -bash scripts/push-gitea.sh -``` - ---- - -## Étape 4 — Cloner depuis Gitea sur votre serveur de production - -Sur votre serveur RockyLinux / Debian : - -```bash -git clone https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple.git -cd la-voix-du-peuple - -# Copier et adapter les variables d'environnement -cp .env.example .env -nano .env # Renseignez DATABASE_URL, MISTRAL_API_KEY, SESSION_SECRET - -# Installer les dépendances Python -pip install -r artifacts/flask-api/requirements.txt - -# Installer les dépendances Node -pnpm install - -# Construire le frontend -pnpm --filter @workspace/voix-du-peuple run build --config vite.config.selfhost.ts -``` - ---- - -## Résumé des commandes utiles - -```bash -# Pousser vers Gitea (commande principale) -bash scripts/push-gitea.sh - -# Voir l'état du dépôt -git status -git log --oneline -10 - -# Voir les remotes configurés -git remote -v -``` - ---- - -## Structure des branches - -| Branche | Usage | -|---------|-------| -| `main` | Code de production, stable — **seule branche à pousser vers Gitea** | -| `replit-agent` | Branche de travail interne de l'agent Replit — ne pas pousser | - ---- - -## Remotes configurés - -| Nom | URL | Usage | -|-----|-----|-------| -| `origin` | `https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple.git` | Gitea principal | -| `gitsafe-backup` | `git://gitsafe:5418/backup.git` | Sauvegarde interne Replit | - -> **Important** : ne jamais inclure de token dans une URL de remote. Le token doit rester dans `GITEA_TOKEN` (Replit Secrets) et être transmis via le script. - ---- - -## Contenu du dépôt (structure principale) - -``` -artifacts/ - flask-api/ ← Backend Python Flask - app.py ← Routes API - ai_agent.py ← Intégration Mistral / OpenAI - database.py ← Accès PostgreSQL - requirements.txt ← Dépendances Python - voix-du-peuple/ ← Frontend React + Vite - src/ - pages/ - home.tsx ← Page principale (synthèse, partage, PDF) - about.tsx ← À propos - transparence.tsx ← Fonctionnement & données - flyer.tsx ← Flyer QR code imprimable - components/ - accessibility-panel.tsx ← Panneau d'accessibilité - hooks/ - use-accessibility.tsx ← Contexte et persistance - App.tsx ← Routing et navbar - index.css ← Styles globaux, dark mode, accessibilité -scripts/ - push-gitea.sh ← Script de push sécurisé vers Gitea -deploy/ - nginx.conf ← Config Nginx production - voix-du-peuple-api.service ← Unité systemd Gunicorn -docs/ - DAT.md ← Architecture technique - DEX.md ← Exploitation - WIKI.md ← Page wiki - GITEA_TUTO.md ← Ce fichier -``` diff --git a/docs/WIKI.md b/docs/WIKI.md index b78615f..9a7e30a 100644 --- a/docs/WIKI.md +++ b/docs/WIKI.md @@ -2,7 +2,7 @@ **Type** : Plateforme civique numérique **Stack** : Python / Flask · React / Vite · PostgreSQL · Mistral AI -**Hébergement** : Replit (dev) / Auto-hébergeable (RockyLinux, Debian) +**Hébergement** : Auto-hébergeable (RockyLinux, Debian, VPS UE) **Dépôt** : `voix-du-peuple` (Gitea) **Statut** : Actif — avril 2026 **Version doc** : 1.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 593c430..9ae0e72 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,7 +16,7 @@ # passed (e.g. an urgent security bugfix), you can add it to the # `minimumReleaseAgeExclude` allowlist below. Only consider doing this for # packages released by trusted organizations with an impeccable security -# posture (e.g. Replit packsges, react from Meta, typescript from Microsoft). Even then, +# posture (e.g. react from Meta, typescript from Microsoft). Even then, # remove the exclusion once the 1-day window has passed. # # Example: @@ -27,13 +27,6 @@ # ============================================================================ minimumReleaseAge: 1440 -minimumReleaseAgeExclude: - # Exclude @replit scoped packages from the minimum release age check. - # These are published by Replit and trusted — the supply-chain attack vector - # this setting guards against does not apply to our own packages. - - '@replit/*' - - stripe-replit-sync - packages: - artifacts/* - lib/* @@ -41,9 +34,6 @@ packages: - scripts catalog: - '@replit/vite-plugin-cartographer': ^0.5.1 - '@replit/vite-plugin-dev-banner': ^0.1.1 - '@replit/vite-plugin-runtime-error-modal': ^0.0.6 '@tailwindcss/vite': ^4.1.14 '@tanstack/react-query': ^5.90.21 '@types/node': ^25.3.3 @@ -74,7 +64,7 @@ onlyBuiltDependencies: - unrs-resolver overrides: - # replit uses linux-x64 only, we can exclude all other platforms + # Déploiement Linux x64 uniquement — on exclut les binaires inutiles "esbuild>@esbuild/darwin-arm64": "-" "esbuild>@esbuild/darwin-x64": "-" "esbuild>@esbuild/freebsd-arm64": "-" diff --git a/replit.md b/replit.md deleted file mode 100644 index 2f3cf41..0000000 --- a/replit.md +++ /dev/null @@ -1,69 +0,0 @@ -# La Voix du Peuple - -## Vue d'ensemble - -Plateforme démocratique citoyenne où les citoyens soumettent leurs idées politiques, filtrées par IA selon le droit international des droits humains, puis synthétisées en un texte collectif vivant. - -## Architecture - -### Frontend -- **React + Vite** (`artifacts/voix-du-peuple/`) -- Interface bicolonne : formulaire de soumission + synthèse en direct -- Auto-refresh de la synthèse toutes les 15 secondes -- Hooks générés par Orval depuis l'OpenAPI spec - -### Backend -- **Python Flask** (`artifacts/flask-api/`) — remplace le serveur TypeScript/Express -- Sert l'API sur `/api/*` -- Démarre via `artifacts/flask-api/start.sh` - -### IA Agentique (deux agents) -1. **Agent filtrage** (`ai_agent.py::filter_idea`) — gpt-5-mini - - Filtre selon DUDH, PIDCP, CEDH, Charte UE, etc. - - Double protection : filtre du proxy Azure + filtre légal IA -2. **Agent synthèse** (`ai_agent.py::synthesize_ideas`) — gpt-5.2 - - Synthèse collective en français, "Nous, le peuple..." - - Déclenché en arrière-plan à chaque nouvelle idée acceptée - -### Base légale du filtre (`legal_framework.py`) -- 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) -- CERD — Convention sur la discrimination raciale (ONU 1965) - -### Base de données -- **PostgreSQL** via `psycopg2` directement (pas d'ORM Flask) -- Tables : `ideas`, `synthesis` -- Drizzle ORM maintenu côté TypeScript pour compatibilité (schema dans `lib/db/`) - -## Stack - -- **Monorepo** : pnpm workspaces -- **Node.js** : 24 (pour le frontend React) -- **Python** : 3.11 (pour le backend Flask) -- **TypeScript** : 5.9 -- **API** : OpenAPI spec → Orval codegen → React Query hooks -- **IA** : Replit AI Integrations (OpenAI proxy, pas de clé API requise) - -## Sécurité Flask -- Rate limiting : 5 soumissions/minute, 20/heure par IP (`flask-limiter`) -- Assainissement XSS : `bleach.clean()` sur toutes les entrées -- En-têtes HTTP : CSP, X-Frame-Options DENY, X-Content-Type-Options, etc. -- Requêtes paramétrées : `psycopg2` avec `%s` — protection injection SQL -- CORS configuré -- Aucun secret exposé dans les réponses d'erreur - -## Commandes clés - -- `pnpm --filter @workspace/api-spec run codegen` — régénérer les hooks React -- `pnpm --filter @workspace/db run push` — migrer le schéma DB -- `pnpm --filter @workspace/voix-du-peuple run dev` — frontend dev -- `sh artifacts/flask-api/start.sh` — backend Flask - -## Workflows - -- `artifacts/api-server: API Server` → Flask (port 8080, chemin `/api`) -- `artifacts/voix-du-peuple: web` → React/Vite (port 20108, chemin `/`)