From 55c44934599874ba96b055fe1bfcf1690cd7e67d Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Sun, 3 May 2026 22:38:49 +0200 Subject: [PATCH] split up generation --- api/biome.json | 2 +- api/src/party/audio-question-generator.ts | 43 +++++++ api/src/party/question-generator.ts | 30 +++++ api/src/party/question-utils.ts | 141 +++++++++++++++++++++ api/src/party/social-question-generator.ts | 46 +++++++ api/src/test/factories.ts | 4 +- api/src/workflows/quiz.ts | 73 ++++------- 7 files changed, 287 insertions(+), 52 deletions(-) create mode 100644 api/src/party/audio-question-generator.ts create mode 100644 api/src/party/question-generator.ts create mode 100644 api/src/party/question-utils.ts create mode 100644 api/src/party/social-question-generator.ts diff --git a/api/biome.json b/api/biome.json index 6e37aea..4104bb0 100644 --- a/api/biome.json +++ b/api/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", "vcs": { "enabled": false, "clientKind": "git", diff --git a/api/src/party/audio-question-generator.ts b/api/src/party/audio-question-generator.ts new file mode 100644 index 0000000..45bfcb5 --- /dev/null +++ b/api/src/party/audio-question-generator.ts @@ -0,0 +1,43 @@ +import type { Question } from "../party-types"; +import { + buildQuestionWindow, + getTopArtistName, + getTopGenreName, + getTopTrackName, + type PartyAnalytics, +} from "./question-utils"; + +export function buildAudioMetadataQuestion( + analytics: PartyAnalytics, + index: number, +): Question { + const questions: Array> = [ + { + text: "Which genre appears most in the party analytics?", + options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"], + correct: 0, + points: 10, + }, + { + text: "Which artist shows up most often in the shared audio data?", + options: [ + getTopArtistName(analytics), + "Artist B", + "Artist C", + "Artist D", + ], + correct: 0, + points: 10, + }, + { + text: "Which track looks most shared across the party?", + options: [getTopTrackName(analytics), "Track B", "Track C", "Track D"], + correct: 0, + points: 10, + }, + ]; + + const question = questions[index % questions.length]; + if (!question) throw new Error("Question not found"); + return buildQuestionWindow(question); +} diff --git a/api/src/party/question-generator.ts b/api/src/party/question-generator.ts new file mode 100644 index 0000000..062fbf0 --- /dev/null +++ b/api/src/party/question-generator.ts @@ -0,0 +1,30 @@ +import type { db } from "../db"; +import type { Question, QuizState } from "../party-types"; +import { buildAudioMetadataQuestion } from "./audio-question-generator"; +import type { PartyAnalytics } from "./question-utils"; +import { fetchPartyMembers } from "./question-utils"; +import { buildSocialQuestion } from "./social-question-generator"; + +export type PartyQuestionType = "audio-metadata" | "social"; + +type GenerateQuestionInput = { + db: typeof db; + partyId: string; + quizState: QuizState; + analytics: PartyAnalytics; + index: number; +}; + +export async function generatePartyQuestion({ + db: dbClient, + partyId, + quizState, + analytics, + index, +}: GenerateQuestionInput): Promise { + const members = await fetchPartyMembers(dbClient, partyId); + const type: PartyQuestionType = index % 2 === 0 ? "audio-metadata" : "social"; + return type === "audio-metadata" + ? buildAudioMetadataQuestion(analytics, index) + : buildSocialQuestion(quizState, analytics, members, index); +} diff --git a/api/src/party/question-utils.ts b/api/src/party/question-utils.ts new file mode 100644 index 0000000..5dd900d --- /dev/null +++ b/api/src/party/question-utils.ts @@ -0,0 +1,141 @@ +import type { db as Db } from "../db"; + +export type PartyQuestionMember = { + userId: string; + name: string; +}; + +export type PartyAnalytics = { + groupSummary?: { + mostSharedGenres?: { name: string }[]; + }; + storyClusters?: { + tracks?: { name: string }[]; + artists?: { name: string }[]; + }[]; + memberProfiles?: { userId: string }[]; + pairwise?: { userIdA: string; userIdB: string }[]; +} | null; + +export const QUESTION_DURATION_MS = 60_000; +export const MIN_PARTY_SIZE = 2; +export const MAX_PARTY_SIZE = 4; + +export async function fetchPartyMembers( + db: typeof Db, + partyId: string, +): Promise { + const members = await db.query.partyMember.findMany({ + where: { + partyId, + }, + with: { + user: true, + }, + }); + + return members.map((member) => ({ + userId: member.userId, + name: member.user?.name ?? member.userId, + })); +} + +export function getPartySize(memberCount: number): number { + return Math.max(MIN_PARTY_SIZE, Math.min(MAX_PARTY_SIZE, memberCount)); +} + +export function buildQuestionWindow( + question: T, + timestamp = Date.now(), +): T & { startTimestamp: number; endTimestamp: number } { + return { + ...question, + startTimestamp: timestamp, + endTimestamp: timestamp + QUESTION_DURATION_MS, + }; +} + +export function getTopGenreName(analytics: PartyAnalytics): string { + return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop"; +} + +export function getTopArtistName(analytics: PartyAnalytics): string { + return analytics?.storyClusters?.[0]?.artists?.[0]?.name ?? "Artist A"; +} + +export function getTopTrackName(analytics: PartyAnalytics): string { + return analytics?.storyClusters?.[0]?.tracks?.[0]?.name ?? "Track A"; +} + +export function getCurrentLeader( + quizState: { scores: Record }, + members: PartyQuestionMember[], +): PartyQuestionMember { + const leaderId = Object.entries(quizState.scores) + .sort(([, a], [, b]) => b - a) + .at(0)?.[0]; + + return ( + members.find((member) => member.userId === leaderId) ?? + members[0] ?? { userId: "", name: "Player A" } + ); +} + +export function getMostDiverseMember( + analytics: PartyAnalytics, + members: PartyQuestionMember[], +): PartyQuestionMember { + const userId = analytics?.memberProfiles?.[0]?.userId; + return ( + members.find((member) => member.userId === userId) ?? + members[1] ?? + members[0] ?? { userId: "", name: "Player B" } + ); +} + +export function getMostAlignedMember( + analytics: PartyAnalytics, + members: PartyQuestionMember[], +): PartyQuestionMember { + const userId = analytics?.pairwise?.[0]?.userIdA; + return ( + members.find((member) => member.userId === userId) ?? + members[2] ?? + members[0] ?? { userId: "", name: "Player C" } + ); +} + +export function buildMemberOptions( + correctMember: PartyQuestionMember, + members: PartyQuestionMember[], +): string[] { + const desiredCount = getPartySize(members.length); + const options = uniqueStrings([ + correctMember.name, + ...members.map((member) => member.name), + ]); + + if (options.length < desiredCount) { + for (const fallback of fallbackPlayerNames(desiredCount)) { + if (options.length >= desiredCount) break; + if (!options.includes(fallback)) options.push(fallback); + } + } + + const ordered = [ + correctMember.name, + ...options.filter((name) => name !== correctMember.name), + ]; + return ordered.slice(0, desiredCount); +} + +function uniqueStrings(values: string[]): string[] { + return values.filter((value, index, list) => list.indexOf(value) === index); +} + +function fallbackPlayerNames(count: number): string[] { + return Array.from( + { length: count }, + (_, index) => `Player ${String.fromCharCode(65 + index)}`, + ); +} diff --git a/api/src/party/social-question-generator.ts b/api/src/party/social-question-generator.ts new file mode 100644 index 0000000..6eeeacf --- /dev/null +++ b/api/src/party/social-question-generator.ts @@ -0,0 +1,46 @@ +import type { Question, QuizState } from "../party-types"; +import { + buildMemberOptions, + buildQuestionWindow, + getCurrentLeader, + getMostAlignedMember, + getMostDiverseMember, + type PartyAnalytics, + type PartyQuestionMember, +} from "./question-utils"; + +export function buildSocialQuestion( + quizState: QuizState, + analytics: PartyAnalytics, + members: PartyQuestionMember[], + index: number, +): Question { + const leader = getCurrentLeader(quizState, members); + const diverse = getMostDiverseMember(analytics, members); + const aligned = getMostAlignedMember(analytics, members); + + const questions: Array> = [ + { + text: "Who is leading the quiz right now?", + options: buildMemberOptions(leader, members), + correct: 0, + points: 10, + }, + { + text: "Who looks like the most diverse listener in the party?", + options: buildMemberOptions(diverse, members), + correct: 0, + points: 10, + }, + { + text: "Which member seems most aligned with the rest of the party?", + options: buildMemberOptions(aligned, members), + correct: 0, + points: 10, + }, + ]; + + const question = questions[index % questions.length]; + if (!question) throw new Error("Question not found"); + return buildQuestionWindow(question); +} diff --git a/api/src/test/factories.ts b/api/src/test/factories.ts index 77ec59a..93f343a 100644 --- a/api/src/test/factories.ts +++ b/api/src/test/factories.ts @@ -358,8 +358,8 @@ export async function seedPartyWithThreeDiverseUsers(): Promise<{ // Give each user their unique track for (let i = 0; i < 3; i++) { - await addTopTrack(userIds[i]!, tracks[i]?.id, 1); - await addTopArtist(userIds[i]!, artists[i]?.id, 1); + await addTopTrack(userIds[i]!, tracks[i]!.id, 1); + await addTopArtist(userIds[i]!, artists[i]!.id, 1); } return { diff --git a/api/src/workflows/quiz.ts b/api/src/workflows/quiz.ts index fabe1a7..611819b 100644 --- a/api/src/workflows/quiz.ts +++ b/api/src/workflows/quiz.ts @@ -2,6 +2,8 @@ import { ConfiguredInstance, DBOS, WorkflowQueue } from "@dbos-inc/dbos-sdk"; import { eq } from "drizzle-orm"; import { db } from "../db"; import { partyMember } from "../db/schema"; +import { generatePartyQuestion } from "../party/question-generator"; +import type { PartyAnalytics } from "../party/question-utils"; import { updatePartyData } from "../party/state"; import type { QuizResponse, QuizRound, QuizState } from "../party-types"; @@ -46,7 +48,11 @@ export class QuizWorkflow extends ConfiguredInstance { for (let i = 0; i < TOTAL_QUESTIONS; i++) { quizState.questionIndex = i; - const question = await QuizWorkflow.generateQuestion(i); + const question = await QuizWorkflow.generateQuestion( + partyId, + quizState, + i, + ); quizState.currentQuestion = question; quizState.answers = {}; const round: QuizRound = { @@ -124,7 +130,11 @@ export class QuizWorkflow extends ConfiguredInstance { } @DBOS.step() - static async generateQuestion(index: number): Promise<{ + static async generateQuestion( + partyId: string, + quizState: QuizState, + index: number, + ): Promise<{ text: string; options: string[]; correct: number; @@ -132,54 +142,19 @@ export class QuizWorkflow extends ConfiguredInstance { endTimestamp: number; points: number; }> { - // Placeholder - returns same question for now, question generation comes later - const questions: { - text: string; - options: string[]; - correct: number; - points: number; - }[] = [ - { - text: "What is the most common genre in your party's shared taste?", - options: ["Hip-Hop", "Rock", "Electronic", "Jazz"], - correct: 0, - points: 10, + const partyRecord = await db.query.party.findFirst({ + where: { + id: partyId, }, - { - text: "Which artist do most party members follow?", - options: ["Artist A", "Artist B", "Artist C", "Artist D"], - correct: 1, - points: 10, - }, - { - text: "What percentage of the party shares at least 1 album?", - options: ["0-25%", "25-50%", "50-75%", "75-100%"], - correct: 2, - points: 10, - }, - { - text: "Who has the most diverse taste in the party?", - options: ["Player A", "Player B", "Player C", "Player D"], - correct: 0, - points: 10, - }, - { - text: "Which track appears most in everyone's top 50?", - options: ["Track A", "Track B", "Track C", "Track D"], - correct: 3, - points: 10, - }, - ]; - - const question = questions[index % questions.length]; - if (!question) { - throw new Error("Question not found"); - } - return { - ...question, - startTimestamp: Date.now(), - endTimestamp: Date.now() + 60_000, - }; + }); + const analytics = (partyRecord?.analysisData ?? null) as PartyAnalytics; + return generatePartyQuestion({ + db, + partyId, + quizState, + analytics, + index, + }); } @DBOS.step()