diff --git a/shatter-backend/docs/API_REFERENCE.md b/shatter-backend/docs/API_REFERENCE.md index cefde56..914841d 100644 --- a/shatter-backend/docs/API_REFERENCE.md +++ b/shatter-backend/docs/API_REFERENCE.md @@ -46,7 +46,8 @@ - [POST `/api/bingo/createBingo`](#post-apibingocreatebingo) - [GET `/api/bingo/getBingo/:eventId`](#get-apibingogetbingoeventid) - [PUT `/api/bingo/updateBingo`](#put-apibingoupdatebingo) - - [POST `/api/bingo/generate`](#post-apibingogenerate) + - [POST `/api/bingo/generateBingo`](#post-apibingogeneratebingo) + - [POST `/api/bingo/generateBingo/single`](#post-apibingogeneratebingosingle) - [Participant Connections (`/api/participantConnections`)](#participant-connections-apiparticipantconnections) - [POST `/api/participantConnections/`](#post-apiparticipantconnections) - [POST `/api/participantConnections/by-emails`](#post-apiparticipantconnectionsby-emails) @@ -1309,30 +1310,33 @@ Update a bingo game. ### POST `/api/bingo/generateBingo` -Generate an AI-powered bingo grid based on a given context. +Generate an AI-powered bingo grid based on a given event description and attendee tags. -- **Auth:** Protected +- **Auth:** Not Protected **Request Body:** -| Field | Type | Required | Notes | -|-----------|--------|----------|-------| -| `context` | string | Yes | Context used to generate bingo content | -| `n_rows` | number | Yes | Number of rows (1–5) | -| `n_cols` | number | Yes | Number of columns (1–5) | +| Field | Type | Required | Notes | +|---|---|---|---| +| `event_description` | string | Yes | General information about what the event is about. Cannot be empty | +| `tags` | string[] | Yes | List of professional types, roles, job titles, specializations, or departments attending the event. Can be an empty array | +| `n_rows` | number | Yes | Number of rows (1–5) | +| `n_cols` | number | Yes | Number of columns (1–5) | **Example Request:** ```json { - "context": "Software engineer networking event where developers meet, discuss tech stacks, exchange ideas, talk about startups, open source, AI, and career opportunities", + "event_description": "Software engineer networking event where developers meet, discuss tech stacks, exchange ideas, talk about startups, open source, AI, and career opportunities", + "tags": ["software engineers", "frontend developers", "backend developers", "startup founders", "product managers"], "n_rows": 2, "n_cols": 2 } ``` **Example Response:** -``` + +```json { "status": true, "bingo_grid": [ @@ -1360,6 +1364,120 @@ Generate an AI-powered bingo grid based on a given context. } ``` +**Validation Errors:** + +Missing or invalid `event_description`: + +```json +{ + "status": false, + "msg": "event_description is required and must be a non-empty string" +} +``` + +Missing or invalid `tags`: + +```json +{ + "status": false, + "msg": "tags is required and must be an array of strings" +} +``` + +Missing or invalid `n_rows` or `n_cols`: + +```json +{ + "status": false, + "msg": "n_rows and n_cols must be numbers where 0 < value <= 5" +} +``` + +### POST `/api/bingo/generateBingo/single` + +Generate one new AI-powered bingo question to replace a target question in an existing bingo grid. + +The New generated question should be different from the existing questions in the bingo grid while still matching the provided event context. + +- **Auth:** Not Protected + +**Request Body:** + +| Field | Type | Required | Notes | +|---|---|---|---| +| `event_description` | string | Yes | Event context used to generate the new bingo question. Cannot be empty | +| `tags` | string[] | Yes | Types or roles of people attending the event. Can be an empty array | +| `bingo_grid` | string[][] | Yes | Existing bingo grid containing the full question strings | +| `bingo_question_target` | string | Yes | The question intended to be regenerated/replaced | + +**Example Request:** + +```json +{ + "event_description": "Software engineer networking event where developers meet, discuss tech stacks, exchange ideas, talk about startups, open source, AI, and career opportunities", + "tags": ["software engineers", "startup founders", "product managers", "designers"], + "bingo_grid": [ + [ + "Sketches architecture on a napkin", + "Shows a product demo on phone" + ], + [ + "Explains their open-source contribution", + "Asks 'What's your current stack?'" + ] + ], + "bingo_question_target": "Explains their open-source contribution" +} +``` + +**Example Response:** + +```json +{ + "status": true, + "question": "Shows a side project they built over the weekend", + "shortQuestion": "Weekend side project" +} +``` + +**Validation Errors:** + +Missing or invalid `event_description`: + +```json +{ + "status": false, + "msg": "event_description is required and must be a non-empty string" +} +``` + +Missing or invalid `tags`: + +```json +{ + "status": false, + "msg": "tags is required and must be an array of strings" +} +``` + +Missing or invalid `bingo_grid`: + +```json +{ + "status": false, + "msg": "bingo_grid is required and must be a 2D array of strings" +} +``` + +Missing or invalid `bingo_question_target`: + +```json +{ + "status": false, + "msg": "bingo_question_target is required and must be a non-empty string" +} +``` + ## Participant Connections (`/api/participantConnections`) ### POST `/api/participantConnections/` diff --git a/shatter-backend/src/controllers/bingo_controller.ts b/shatter-backend/src/controllers/bingo_controller.ts index 0a96455..3be6f07 100644 --- a/shatter-backend/src/controllers/bingo_controller.ts +++ b/shatter-backend/src/controllers/bingo_controller.ts @@ -342,37 +342,43 @@ function buildShapeExample(rows: number, cols: number): string { async function generateBingoGrid( n_rows: number, n_cols: number, - context: string, + event_description: string, + tags: string[], ): Promise> { const schema = buildSchema(n_rows, n_cols); const schemaJson = z.toJSONSchema(schema); const example = buildShapeExample(n_rows, n_cols); - const { GoogleGenAI } = await import("@google/genai"); - const basePrompt_structure = `Generate a ${n_rows}x${n_cols} bingo board. Return JSON exactly matching this structure: ${example} + Rules: - Keys must be row1, row2, row3, etc. - Each row must contain ${n_cols} strings. - Return ONLY valid JSON. - - You will be provided with additional context to inspire the content of the bingo squares. Use that context to generate relevant bingo square phrases. + - Return ONLY valid JSON. + - Each entry should be a full bingo question or bingo square phrase. + - Every entry must fit the provided event description. + - Use the tags as guidance for the types of professionals, roles, departments, or specializations attending the event. + - Tags can be empty. If no tags are provided, rely only on the event description. + - Avoid duplicate or near-duplicate entries. + - Avoid generic networking items unless they are made specific to the event context. `.trim(); - const userContext = `Additional context for bingo content:\n${context}`; - // const promptPath = new URL( - // "../ai/prompts/bingo_short_questions.txt", - // import.meta.url, - // ); - // const aiInstruction = fs.readFileSync(promptPath, "utf-8"); + const eventContext = ` +Event description: +${event_description} + +Tags / attendee professional types: +${tags.length > 0 ? tags.join(", ") : "No tags provided"} +`.trim(); const aiPrompt = new Prompt([ basePrompt_structure, - userContext, + eventContext, aiShortVersionInstruction, ]); + aiPrompt.generatePrompt(); const prompt = aiPrompt.getPrompt(); @@ -492,7 +498,8 @@ function combine2DArrays( * * Generate an AI bingo grid. * - * @param req.body.context - Context for bingo content (required) + * @param req.body.event_description - General event context for bingo content (required) + * @param req.body.tags - Types of professionals, roles, departments, or specializations attending the event (required, can be empty) * @param req.body.n_rows - Number of grid rows (1-5) * @param req.body.n_cols - Number of grid columns (1-5) * @@ -501,12 +508,27 @@ function combine2DArrays( */ export async function generateBingo(req: Request, res: Response) { try { - const { context, n_rows, n_cols } = req.body; + const { event_description, tags, n_rows, n_cols } = req.body; - if (!context) { + if ( + !event_description || + typeof event_description !== "string" || + event_description.trim().length === 0 + ) { return res.status(400).json({ status: false, - msg: "context is required", + msg: "event_description is required and must be a non-empty string", + }); + } + + if ( + tags === undefined || + !Array.isArray(tags) || + !tags.every((tag: any) => typeof tag === "string") + ) { + return res.status(400).json({ + status: false, + msg: "tags is required and must be an array of strings", }); } @@ -524,15 +546,26 @@ export async function generateBingo(req: Request, res: Response) { }); } - const bingo_questions = await generateBingoGrid(n_rows, n_cols, context); + const cleanedTags = tags.map((tag: string) => tag.trim()).filter(Boolean); + + const bingo_questions = await generateBingoGrid( + n_rows, + n_cols, + event_description.trim(), + cleanedTags, + ); + const bingo_short_versions = await generateBingoGrid_shortVersions( n_rows, n_cols, JSON.stringify(bingo_questions), ); + const bingo_grid_questions: string[][] = process_ai_result(bingo_questions); + const bingo_grid_short_versions: string[][] = process_ai_result(bingo_short_versions); + const bingo_grid = combine2DArrays( bingo_grid_questions, bingo_grid_short_versions, @@ -549,3 +582,186 @@ export async function generateBingo(req: Request, res: Response) { }); } } + +function buildSingleBingoQuestionSchema() { + return z.object({ + question: z + .string() + .min(1) + .describe("The full generated bingo question"), + shortQuestion: z + .string() + .min(1) + .describe("A max 3 word short version of the question"), + }); +} + +async function generateSingleBingoQuestionWithGemini({ + event_description, + tags, + bingo_grid, + bingo_question_target, +}: { + event_description: string; + tags: string[]; + bingo_grid: string[][]; + bingo_question_target: string; +}): Promise<{ question: string; shortQuestion: string }> { + const schema = buildSingleBingoQuestionSchema(); + const schemaJson = z.toJSONSchema(schema); + + const basePrompt = ` +Generate one new bingo question for an event bingo game. + +Return JSON exactly matching this structure: +{ + "question": "Full bingo question here", + "shortQuestion": "Max 3 words" +} + +Rules: +- Return ONLY valid JSON. +- Generate exactly one new question. +- The new question must fit the event context. +- The new question must be different from every existing question in the bingo grid. +- The target question is being regenerated, but the replacement does NOT need to be about the same topic. +- The replacement should be entirely new while still fitting the event. +- The shortQuestion must be max 3 words. +- The shortQuestion should describe what the full question is about. +- Avoid generic networking questions. +- Prefer observable, realistic, event-specific bingo moments. +`.trim(); + + const eventContext = ` +Event description: +${event_description} + +Tags / attendee roles: +${tags.length > 0 ? tags.join(", ") : "No tags provided"} + +Existing bingo grid questions: +${JSON.stringify(bingo_grid, null, 2)} + +Target question to replace: +${bingo_question_target} +`.trim(); + + const aiPrompt = new Prompt([ + basePrompt, + eventContext, + aiShortVersionInstruction, + ]); + + aiPrompt.generatePrompt(); + const prompt = aiPrompt.getPrompt(); + + const response = await ai.models.generateContent({ + model: "gemini-2.5-flash", + contents: prompt, + config: { + responseMimeType: "application/json", + responseJsonSchema: schemaJson, + temperature: 0.8, + }, + }); + + if (!response.text) { + throw new Error("Gemini returned empty response"); + } + + const parsed = JSON.parse(response.text); + const validated = schema.parse(parsed); + + return validated; +} + + +/** + * POST /api/bingo/generate/single + * + * Generate one AI bingo question. + * + * @param req.body.event_description - Context for bingo content (required) - string + * @param req.body.tags - Tags for the type/roles of people attending the event - string[] can be empty + * @param req.body.bingo_grid - Existing bingo grid of full questions - string[][] + * @param req.body.bingo_question_target - Target question to regenerate - string + * + * @returns 200 with generated bingo question and short version + * @returns 400 if validation fails + */ +export async function generateSingleBingoQuestion(req: Request, res: Response) { + try { + const { + event_description, + tags, + bingo_grid, + bingo_question_target, + } = req.body; + + if ( + !event_description || + typeof event_description !== "string" || + event_description.trim().length === 0 + ) { + return res.status(400).json({ + status: false, + msg: "event_description is required and must be a non-empty string", + }); + } + + if ( + tags === undefined || + !Array.isArray(tags) || + !tags.every((tag: any) => typeof tag === "string") + ) { + return res.status(400).json({ + status: false, + msg: "tags is required and must be an array of strings", + }); + } + + if ( + !Array.isArray(bingo_grid) || + bingo_grid.length === 0 || + !bingo_grid.every( + (row: any) => + Array.isArray(row) && + row.every((cell: any) => typeof cell === "string"), + ) + ) { + return res.status(400).json({ + status: false, + msg: "bingo_grid is required and must be a 2D array of strings", + }); + } + + if ( + !bingo_question_target || + typeof bingo_question_target !== "string" || + bingo_question_target.trim().length === 0 + ) { + return res.status(400).json({ + status: false, + msg: "bingo_question_target is required and must be a non-empty string", + }); + } + + const generatedQuestion = await generateSingleBingoQuestionWithGemini({ + event_description: event_description.trim(), + tags, + bingo_grid, + bingo_question_target: bingo_question_target.trim(), + }); + + return res.status(200).json({ + status: true, + question: generatedQuestion.question, + shortQuestion: generatedQuestion.shortQuestion, + }); + } catch (err: any) { + return res.status(500).json({ + status: false, + error: err.message, + }); + } +} diff --git a/shatter-backend/src/routes/bingo_routes.ts b/shatter-backend/src/routes/bingo_routes.ts index a811f78..b5e9d0c 100644 --- a/shatter-backend/src/routes/bingo_routes.ts +++ b/shatter-backend/src/routes/bingo_routes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { createBingo, getBingo, updateBingo, generateBingo} from '../controllers/bingo_controller.js'; +import { createBingo, getBingo, updateBingo, generateBingo, generateSingleBingoQuestion} from '../controllers/bingo_controller.js'; import { authMiddleware } from '../middleware/auth_middleware.js'; const router = Router(); @@ -15,5 +15,8 @@ router.put("/updateBingo", authMiddleware, updateBingo); // POST /api/bingo/generateBingo - generate bingo using AI for an event router.post("/generateBingo", authMiddleware, generateBingo); +// POST /api/bingo/generate/single - generate a single AI bingo question +router.post("/generateBingo/singlequestion", authMiddleware, generateSingleBingoQuestion); + export default router;