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
@@ -8,3 +8,53 @@
export interface HealthStatus {
status: string;
}
export interface SubmitIdeaBody {
/**
* The political idea text
* @minLength 10
* @maxLength 1000
*/
content: string;
/**
* Optional pseudonym
* @maxLength 100
*/
author?: string;
}
export interface Idea {
id: number;
content: string;
author?: string | null;
accepted: boolean;
rejectionReason?: string | null;
createdAt: string;
}
export interface IdeaResult {
id: number;
accepted: boolean;
/** Reason if rejected */
reason?: string;
idea?: Idea;
}
export interface IdeaStats {
total: number;
accepted: number;
rejected: number;
}
export interface Synthesis {
/** The synthesized voice of the people */
text: string;
/** Number of ideas included in the synthesis */
ideaCount: number;
updatedAt?: string | null;
}
export interface ErrorResponse {
error: string;
message: string;
}
+319 -3
View File
@@ -5,18 +5,29 @@
* API specification
* OpenAPI spec version: 0.1.0
*/
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
MutationFunction,
QueryFunction,
QueryKey,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query";
import type { HealthStatus } from "./api.schemas";
import type {
ErrorResponse,
HealthStatus,
Idea,
IdeaResult,
IdeaStats,
SubmitIdeaBody,
Synthesis,
} from "./api.schemas";
import { customFetch } from "../custom-fetch";
import type { ErrorType } from "../custom-fetch";
import type { ErrorType, BodyType } from "../custom-fetch";
type AwaitedInput<T> = PromiseLike<T> | T;
@@ -99,3 +110,308 @@ export function useHealthCheck<
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Submit a civic idea to be filtered and included in the synthesis
* @summary Submit a political idea
*/
export const getSubmitIdeaUrl = () => {
return `/api/ideas`;
};
export const submitIdea = async (
submitIdeaBody: SubmitIdeaBody,
options?: RequestInit,
): Promise<IdeaResult> => {
return customFetch<IdeaResult>(getSubmitIdeaUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(submitIdeaBody),
});
};
export const getSubmitIdeaMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof submitIdea>>,
TError,
{ data: BodyType<SubmitIdeaBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof submitIdea>>,
TError,
{ data: BodyType<SubmitIdeaBody> },
TContext
> => {
const mutationKey = ["submitIdea"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof submitIdea>>,
{ data: BodyType<SubmitIdeaBody> }
> = (props) => {
const { data } = props ?? {};
return submitIdea(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type SubmitIdeaMutationResult = NonNullable<
Awaited<ReturnType<typeof submitIdea>>
>;
export type SubmitIdeaMutationBody = BodyType<SubmitIdeaBody>;
export type SubmitIdeaMutationError = ErrorType<ErrorResponse>;
/**
* @summary Submit a political idea
*/
export const useSubmitIdea = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof submitIdea>>,
TError,
{ data: BodyType<SubmitIdeaBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof submitIdea>>,
TError,
{ data: BodyType<SubmitIdeaBody> },
TContext
> => {
return useMutation(getSubmitIdeaMutationOptions(options));
};
/**
* Returns all ideas that passed the democratic filter
* @summary List all accepted ideas
*/
export const getListIdeasUrl = () => {
return `/api/ideas`;
};
export const listIdeas = async (options?: RequestInit): Promise<Idea[]> => {
return customFetch<Idea[]>(getListIdeasUrl(), {
...options,
method: "GET",
});
};
export const getListIdeasQueryKey = () => {
return [`/api/ideas`] as const;
};
export const getListIdeasQueryOptions = <
TData = Awaited<ReturnType<typeof listIdeas>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listIdeas>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListIdeasQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof listIdeas>>> = ({
signal,
}) => listIdeas({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listIdeas>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListIdeasQueryResult = NonNullable<
Awaited<ReturnType<typeof listIdeas>>
>;
export type ListIdeasQueryError = ErrorType<unknown>;
/**
* @summary List all accepted ideas
*/
export function useListIdeas<
TData = Awaited<ReturnType<typeof listIdeas>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<Awaited<ReturnType<typeof listIdeas>>, TError, TData>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListIdeasQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Returns counts of total, accepted and rejected ideas
* @summary Get idea statistics
*/
export const getGetIdeaStatsUrl = () => {
return `/api/ideas/stats`;
};
export const getIdeaStats = async (
options?: RequestInit,
): Promise<IdeaStats> => {
return customFetch<IdeaStats>(getGetIdeaStatsUrl(), {
...options,
method: "GET",
});
};
export const getGetIdeaStatsQueryKey = () => {
return [`/api/ideas/stats`] as const;
};
export const getGetIdeaStatsQueryOptions = <
TData = Awaited<ReturnType<typeof getIdeaStats>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIdeaStats>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetIdeaStatsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getIdeaStats>>> = ({
signal,
}) => getIdeaStats({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getIdeaStats>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetIdeaStatsQueryResult = NonNullable<
Awaited<ReturnType<typeof getIdeaStats>>
>;
export type GetIdeaStatsQueryError = ErrorType<unknown>;
/**
* @summary Get idea statistics
*/
export function useGetIdeaStats<
TData = Awaited<ReturnType<typeof getIdeaStats>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIdeaStats>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetIdeaStatsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* Returns the latest synthesized text of the people's voice
* @summary Get current synthesis
*/
export const getGetSynthesisUrl = () => {
return `/api/synthesis`;
};
export const getSynthesis = async (
options?: RequestInit,
): Promise<Synthesis> => {
return customFetch<Synthesis>(getGetSynthesisUrl(), {
...options,
method: "GET",
});
};
export const getGetSynthesisQueryKey = () => {
return [`/api/synthesis`] as const;
};
export const getGetSynthesisQueryOptions = <
TData = Awaited<ReturnType<typeof getSynthesis>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSynthesis>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetSynthesisQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSynthesis>>> = ({
signal,
}) => getSynthesis({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getSynthesis>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetSynthesisQueryResult = NonNullable<
Awaited<ReturnType<typeof getSynthesis>>
>;
export type GetSynthesisQueryError = ErrorType<unknown>;
/**
* @summary Get current synthesis
*/
export function useGetSynthesis<
TData = Awaited<ReturnType<typeof getSynthesis>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getSynthesis>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetSynthesisQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
+169
View File
@@ -10,6 +10,10 @@ servers:
tags:
- name: health
description: Health operations
- name: ideas
description: Civic idea submission and management
- name: synthesis
description: AI synthesis of the people's voice
paths:
/healthz:
get:
@@ -24,6 +28,75 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/HealthStatus"
/ideas:
post:
operationId: submitIdea
tags: [ideas]
summary: Submit a political idea
description: Submit a civic idea to be filtered and included in the synthesis
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SubmitIdeaBody"
responses:
"201":
description: Idea submitted
content:
application/json:
schema:
$ref: "#/components/schemas/IdeaResult"
"400":
description: Bad request
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
get:
operationId: listIdeas
tags: [ideas]
summary: List all accepted ideas
description: Returns all ideas that passed the democratic filter
responses:
"200":
description: List of accepted ideas
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Idea"
/ideas/stats:
get:
operationId: getIdeaStats
tags: [ideas]
summary: Get idea statistics
description: Returns counts of total, accepted and rejected ideas
responses:
"200":
description: Stats
content:
application/json:
schema:
$ref: "#/components/schemas/IdeaStats"
/synthesis:
get:
operationId: getSynthesis
tags: [synthesis]
summary: Get current synthesis
description: Returns the latest synthesized text of the people's voice
responses:
"200":
description: Current synthesis
content:
application/json:
schema:
$ref: "#/components/schemas/Synthesis"
components:
schemas:
HealthStatus:
@@ -34,3 +107,99 @@ components:
required:
- status
SubmitIdeaBody:
type: object
properties:
content:
type: string
minLength: 10
maxLength: 1000
description: The political idea text
author:
type: string
maxLength: 100
description: Optional pseudonym
required:
- content
IdeaResult:
type: object
properties:
id:
type: integer
accepted:
type: boolean
reason:
type: string
description: Reason if rejected
idea:
$ref: "#/components/schemas/Idea"
required:
- id
- accepted
Idea:
type: object
properties:
id:
type: integer
content:
type: string
author:
type: string
nullable: true
accepted:
type: boolean
rejectionReason:
type: string
nullable: true
createdAt:
type: string
format: date-time
required:
- id
- content
- accepted
- createdAt
IdeaStats:
type: object
properties:
total:
type: integer
accepted:
type: integer
rejected:
type: integer
required:
- total
- accepted
- rejected
Synthesis:
type: object
properties:
text:
type: string
description: The synthesized voice of the people
ideaCount:
type: integer
description: Number of ideas included in the synthesis
updatedAt:
type: string
format: date-time
nullable: true
required:
- text
- ideaCount
ErrorResponse:
type: object
properties:
error:
type: string
message:
type: string
required:
- error
- message
+56
View File
@@ -14,3 +14,59 @@ import * as zod from "zod";
export const HealthCheckResponse = zod.object({
status: zod.string(),
});
/**
* Submit a civic idea to be filtered and included in the synthesis
* @summary Submit a political idea
*/
export const submitIdeaBodyContentMin = 10;
export const submitIdeaBodyContentMax = 1000;
export const submitIdeaBodyAuthorMax = 100;
export const SubmitIdeaBody = zod.object({
content: zod
.string()
.min(submitIdeaBodyContentMin)
.max(submitIdeaBodyContentMax)
.describe("The political idea text"),
author: zod
.string()
.max(submitIdeaBodyAuthorMax)
.optional()
.describe("Optional pseudonym"),
});
/**
* Returns all ideas that passed the democratic filter
* @summary List all accepted ideas
*/
export const ListIdeasResponseItem = zod.object({
id: zod.number(),
content: zod.string(),
author: zod.string().nullish(),
accepted: zod.boolean(),
rejectionReason: zod.string().nullish(),
createdAt: zod.coerce.date(),
});
export const ListIdeasResponse = zod.array(ListIdeasResponseItem);
/**
* Returns counts of total, accepted and rejected ideas
* @summary Get idea statistics
*/
export const GetIdeaStatsResponse = zod.object({
total: zod.number(),
accepted: zod.number(),
rejected: zod.number(),
});
/**
* Returns the latest synthesized text of the people's voice
* @summary Get current synthesis
*/
export const GetSynthesisResponse = zod.object({
text: zod.string().describe("The synthesized voice of the people"),
ideaCount: zod.number().describe("Number of ideas included in the synthesis"),
updatedAt: zod.coerce.date().nullish(),
});
@@ -0,0 +1,12 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface ErrorResponse {
error: string;
message: string;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface Idea {
id: number;
content: string;
author?: string | null;
accepted: boolean;
rejectionReason?: string | null;
createdAt: Date;
}
@@ -0,0 +1,16 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
import type { Idea } from "./idea";
export interface IdeaResult {
id: number;
accepted: boolean;
/** Reason if rejected */
reason?: string;
idea?: Idea;
}
@@ -0,0 +1,13 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface IdeaStats {
total: number;
accepted: number;
rejected: number;
}
+6
View File
@@ -6,4 +6,10 @@
* OpenAPI spec version: 0.1.0
*/
export * from "./errorResponse";
export * from "./healthStatus";
export * from "./idea";
export * from "./ideaResult";
export * from "./ideaStats";
export * from "./submitIdeaBody";
export * from "./synthesis";
@@ -0,0 +1,21 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface SubmitIdeaBody {
/**
* The political idea text
* @minLength 10
* @maxLength 1000
*/
content: string;
/**
* Optional pseudonym
* @maxLength 100
*/
author?: string;
}
@@ -0,0 +1,15 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* Api
* API specification
* OpenAPI spec version: 0.1.0
*/
export interface Synthesis {
/** The synthesized voice of the people */
text: string;
/** Number of ideas included in the synthesis */
ideaCount: number;
updatedAt?: Date | null;
}
+16
View File
@@ -0,0 +1,16 @@
import { pgTable, text, serial, timestamp, boolean } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
export const ideasTable = pgTable("ideas", {
id: serial("id").primaryKey(),
content: text("content").notNull(),
author: text("author"),
accepted: boolean("accepted").notNull().default(false),
rejectionReason: text("rejection_reason"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const insertIdeaSchema = createInsertSchema(ideasTable).omit({ id: true, createdAt: true });
export type InsertIdea = z.infer<typeof insertIdeaSchema>;
export type Idea = typeof ideasTable.$inferSelect;
+2 -20
View File
@@ -1,20 +1,2 @@
// Export your models here. Add one export per file
// export * from "./posts";
//
// Each model/table should ideally be split into different files.
// Each model/table should define a Drizzle table, insert schema, and types:
//
// import { pgTable, text, serial } from "drizzle-orm/pg-core";
// import { createInsertSchema } from "drizzle-zod";
// import { z } from "zod/v4";
//
// export const postsTable = pgTable("posts", {
// id: serial("id").primaryKey(),
// title: text("title").notNull(),
// });
//
// export const insertPostSchema = createInsertSchema(postsTable).omit({ id: true });
// export type InsertPost = z.infer<typeof insertPostSchema>;
// export type Post = typeof postsTable.$inferSelect;
export {}
export * from "./ideas";
export * from "./synthesis";
+14
View File
@@ -0,0 +1,14 @@
import { pgTable, text, serial, timestamp, integer } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
export const synthesisTable = pgTable("synthesis", {
id: serial("id").primaryKey(),
text: text("text").notNull(),
ideaCount: integer("idea_count").notNull().default(0),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
});
export const insertSynthesisSchema = createInsertSchema(synthesisTable).omit({ id: true, updatedAt: true });
export type InsertSynthesis = z.infer<typeof insertSynthesisSchema>;
export type Synthesis = typeof synthesisTable.$inferSelect;