Add a democratic idea submission and AI synthesis platform

Implement a full-stack application with a React frontend and a Python Flask backend. The backend integrates with an AI agent to filter political ideas for democratic values and synthesize accepted ideas into a collective voice. Includes API endpoints for idea submission, retrieval, and synthesis, along with database persistence.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 923ae0e3-a363-4db8-b04a-e8baca2a1330
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 31c5f770-9905-46af-a938-9d40ef3d4404
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8af7d2ec-2cc3-4ece-8af3-9f071488d072/923ae0e3-a363-4db8-b04a-e8baca2a1330/Xzzm5QH
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
pironantoine
2026-04-03 16:25:11 +00:00
parent 4d26b95657
commit f9c4073d21
92 changed files with 8199 additions and 23 deletions
+1
View File
@@ -16,6 +16,7 @@
"cors": "^2",
"drizzle-orm": "catalog:",
"express": "^5",
"openai": "^6.33.0",
"pino": "^9",
"pino-http": "^10"
},
+93
View File
@@ -0,0 +1,93 @@
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.";
}
}
+100
View File
@@ -0,0 +1,100 @@
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;
+4
View File
@@ -1,8 +1,12 @@
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;
@@ -0,0 +1,25 @@
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;