Dé-Replit-isation complète du projet

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 22:41:06 +02:00
parent a7b7684e87
commit bc6bd3f9d7
28 changed files with 31 additions and 919 deletions
+6 -9
View File
@@ -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
+2 -11
View File
@@ -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) |
---
@@ -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"
-126
View File
@@ -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);
});
-33
View File
@@ -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"
}
}
-34
View File
@@ -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;
-25
View File
@@ -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");
});
-93
View File
@@ -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<FilterResult> {
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<string> {
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.";
}
}
-20
View File
@@ -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 },
},
}),
});
-11
View File
@@ -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;
-100
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> {
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;
-12
View File
@@ -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;
@@ -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<void> => {
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;
-17
View File
@@ -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"
}
]
}
+7 -14
View File
@@ -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
-3
View File
@@ -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:",
@@ -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)]",
},
},
@@ -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",
@@ -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,
},
});
+2 -36
View File
@@ -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: {
+5 -6
View File
@@ -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`)
---
+5 -26
View File
@@ -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
-172
View File
@@ -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
```
+1 -1
View File
@@ -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
+2 -12
View File
@@ -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": "-"
-69
View File
@@ -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 `/`)