split up generation

This commit is contained in:
Daniel Bulant 2026-05-03 22:38:49 +02:00
parent f945f12d81
commit 55c4493459
No known key found for this signature in database
7 changed files with 287 additions and 52 deletions

View file

@ -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",

View file

@ -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<Omit<Question, "startTimestamp" | "endTimestamp">> = [
{
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);
}

View file

@ -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<Question> {
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);
}

View file

@ -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<PartyQuestionMember[]> {
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<T extends object>(
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<string, number> },
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)}`,
);
}

View file

@ -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<Omit<Question, "startTimestamp" | "endTimestamp">> = [
{
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);
}

View file

@ -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 {

View file

@ -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()