improved question generation
This commit is contained in:
parent
bfeb44a625
commit
fca9608de7
7 changed files with 123 additions and 52 deletions
|
|
@ -107,6 +107,31 @@ describe("question generation", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("preserves candidate metadata on the generated question", () => {
|
||||
const question = pickQuestionCandidate(
|
||||
[
|
||||
{
|
||||
key: "social:leader",
|
||||
subjectKey: "member:a",
|
||||
question: {
|
||||
type: "choice",
|
||||
text: "Who is leading the quiz right now?",
|
||||
correct: 0,
|
||||
startTimestamp: 1,
|
||||
endTimestamp: 2,
|
||||
points: 10,
|
||||
options: ["A", "B"],
|
||||
} as Question,
|
||||
},
|
||||
],
|
||||
[],
|
||||
0,
|
||||
);
|
||||
|
||||
expect(question?.questionKey).toBe("social:leader");
|
||||
expect(question?.subjectKey).toBe("member:a");
|
||||
});
|
||||
|
||||
it("returns null when member options would require fake placeholders", () => {
|
||||
const members: PartyQuestionMember[] = [
|
||||
{ userId: "a", name: "Sam" },
|
||||
|
|
|
|||
50
api/src/party/__tests__/question-generator.test.ts
Normal file
50
api/src/party/__tests__/question-generator.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { QuizState } from "../../party-types";
|
||||
|
||||
vi.mock("../audio-question-generator", () => ({
|
||||
buildAudioMetadataQuestion: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("../social-question-generator", () => ({
|
||||
buildSocialQuestion: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("../numeric-question-generator", () => ({
|
||||
buildNumericQuestion: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
import { generatePartyQuestion } from "../question-generator";
|
||||
|
||||
function createFakeDb() {
|
||||
return {
|
||||
query: {
|
||||
partyMember: {
|
||||
findMany: vi.fn(async () => [{ userId: "a", user: { name: "A" } }]),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("generatePartyQuestion", () => {
|
||||
it("returns null when all real question sources are exhausted", async () => {
|
||||
const quizState = {
|
||||
status: "running",
|
||||
workflowId: null,
|
||||
questionIndex: 0,
|
||||
currentQuestion: null,
|
||||
answers: {},
|
||||
scores: {},
|
||||
history: [],
|
||||
} as QuizState;
|
||||
|
||||
const question = await generatePartyQuestion({
|
||||
db: createFakeDb() as never,
|
||||
partyId: "party-1",
|
||||
quizState,
|
||||
analytics: null,
|
||||
index: 0,
|
||||
});
|
||||
|
||||
expect(question).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -10,7 +10,6 @@ import {
|
|||
isUsableText,
|
||||
type PartyAnalytics,
|
||||
pickQuestionCandidate,
|
||||
pickRandom,
|
||||
type QuestionCandidate,
|
||||
resolveQuestionSong,
|
||||
} from "./question-utils";
|
||||
|
|
@ -115,15 +114,13 @@ export async function buildAudioMetadataQuestion(
|
|||
}
|
||||
}
|
||||
|
||||
const randomTopTrack = pickRandom(topTracks);
|
||||
if (randomTopTrack) {
|
||||
const randomTrackSong = await resolveQuestionSong(dbClient, analytics, {
|
||||
trackName: randomTopTrack.name,
|
||||
artistNames: randomTopTrack.artists?.map((artist) => artist.name),
|
||||
albumName: randomTopTrack.albumName,
|
||||
for (const topTrack of topTracks) {
|
||||
const trackSong = await resolveQuestionSong(dbClient, analytics, {
|
||||
trackName: topTrack.name,
|
||||
artistNames: topTrack.artists?.map((artist) => artist.name),
|
||||
albumName: topTrack.albumName,
|
||||
});
|
||||
const trackArtists =
|
||||
randomTopTrack.artists?.map((artist) => artist.name) ?? [];
|
||||
const trackArtists = topTrack.artists?.map((artist) => artist.name) ?? [];
|
||||
const allArtists = topArtists.length > 0 ? topArtists : trackArtists;
|
||||
const correctArtist = trackArtists[0] ?? allArtists[0];
|
||||
if (correctArtist) {
|
||||
|
|
@ -134,41 +131,41 @@ export async function buildAudioMetadataQuestion(
|
|||
);
|
||||
if (artistOptions) {
|
||||
questions.push({
|
||||
key: `audio:performer:${randomTopTrack.name}`,
|
||||
subjectKey: `track:${randomTopTrack.name}`,
|
||||
key: `audio:performer:${topTrack.name}`,
|
||||
subjectKey: `track:${topTrack.name}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: `Who performs "${randomTopTrack.name}"?`,
|
||||
text: `Who performs "${topTrack.name}"?`,
|
||||
options: artistOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
song: trackSong ?? topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const trackNames = topTracks.map((t) => t.name);
|
||||
const trackNameOptions = buildOptionsWithCorrect(
|
||||
randomTopTrack.name,
|
||||
topTrack.name,
|
||||
trackNames,
|
||||
4,
|
||||
);
|
||||
if (trackNameOptions) {
|
||||
questions.push({
|
||||
key: `audio:title:${randomTopTrack.name}`,
|
||||
subjectKey: `track:${randomTopTrack.name}`,
|
||||
key: `audio:title:${topTrack.name}`,
|
||||
subjectKey: `track:${topTrack.name}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: `What is the name of this track by ${correctArtist}?`,
|
||||
options: trackNameOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
song: trackSong ?? topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isUsableText(topSongName) && topSongName !== randomTopTrack.name) {
|
||||
if (isUsableText(topSongName) && topSongName !== topTrack.name) {
|
||||
const alternateSongOptions = buildOptionsWithCorrect(
|
||||
topSongName,
|
||||
trackNames,
|
||||
|
|
@ -192,26 +189,26 @@ export async function buildAudioMetadataQuestion(
|
|||
}
|
||||
}
|
||||
|
||||
if (randomTopTrack.albumName) {
|
||||
if (topTrack.albumName) {
|
||||
const albumNames = topTracks
|
||||
.map((track) => track.albumName)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
const albumOptions = buildOptionsWithCorrect(
|
||||
randomTopTrack.albumName,
|
||||
topTrack.albumName,
|
||||
albumNames,
|
||||
4,
|
||||
);
|
||||
if (albumOptions) {
|
||||
questions.push({
|
||||
key: `audio:album:${randomTopTrack.albumName}`,
|
||||
subjectKey: `track:${randomTopTrack.name}`,
|
||||
key: `audio:album:${topTrack.albumName}`,
|
||||
subjectKey: `track:${topTrack.name}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: `"${randomTopTrack.name}" appears on which album?`,
|
||||
text: `"${topTrack.name}" appears on which album?`,
|
||||
options: albumOptions,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
song: trackSong ?? topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Question, QuizState } from "../party-types";
|
|||
import { buildAudioMetadataQuestion } from "./audio-question-generator";
|
||||
import { buildNumericQuestion } from "./numeric-question-generator";
|
||||
import type { PartyAnalytics } from "./question-utils";
|
||||
import { buildQuestionWindow, fetchPartyMembers } from "./question-utils";
|
||||
import { fetchPartyMembers } from "./question-utils";
|
||||
import { buildSocialQuestion } from "./social-question-generator";
|
||||
|
||||
export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
|
||||
|
|
@ -69,13 +69,5 @@ export async function generatePartyQuestion({
|
|||
if (q) return q;
|
||||
}
|
||||
|
||||
return buildQuestionWindow({
|
||||
type: "numeric" as const,
|
||||
text: "How many players are in this party?",
|
||||
correct: members.length,
|
||||
range: { min: 0, max: members.length },
|
||||
points: 5,
|
||||
subjectKey: "party-size",
|
||||
questionKey: `fallback:party-size:${members.length}`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,7 +165,14 @@ export function pickQuestionCandidate<T extends QuestionLike>(
|
|||
|
||||
if (fresh.length === 0) return null;
|
||||
const pool = fresh;
|
||||
return pool[index % pool.length]?.question ?? null;
|
||||
const candidate = pool[index % pool.length];
|
||||
if (!candidate) return null;
|
||||
return {
|
||||
...candidate.question,
|
||||
questionKey: candidate.question.questionKey ?? candidate.key,
|
||||
subjectKey:
|
||||
candidate.question.subjectKey ?? candidate.subjectKey ?? undefined,
|
||||
} as T;
|
||||
}
|
||||
|
||||
function normalizeQuestionKey(value: string): string {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
type PartyAnalytics,
|
||||
type PartyQuestionMember,
|
||||
pickQuestionCandidate,
|
||||
pickRandom,
|
||||
type QuestionCandidate,
|
||||
resolveQuestionSong,
|
||||
} from "./question-utils";
|
||||
|
|
@ -72,27 +71,28 @@ export async function buildSocialQuestion(
|
|||
}
|
||||
|
||||
const topTracks = getTopClusterTracks(analytics);
|
||||
const randomTrack = pickRandom(topTracks);
|
||||
if (randomTrack && hasMultipleMembers) {
|
||||
const topListener = getTopTrackListener(randomTrack, members);
|
||||
if (topListener) {
|
||||
const randomTrackSong = await resolveQuestionSong(dbClient, analytics, {
|
||||
trackName: randomTrack.name,
|
||||
artistNames: randomTrack.artists?.map((artist) => artist.name),
|
||||
albumName: randomTrack.albumName,
|
||||
if (hasMultipleMembers) {
|
||||
for (const topTrack of topTracks) {
|
||||
const topListener = getTopTrackListener(topTrack, members);
|
||||
if (!topListener) continue;
|
||||
|
||||
const trackSong = await resolveQuestionSong(dbClient, analytics, {
|
||||
trackName: topTrack.name,
|
||||
artistNames: topTrack.artists?.map((artist) => artist.name),
|
||||
albumName: topTrack.albumName,
|
||||
});
|
||||
const options = buildMemberOptions(topListener, members);
|
||||
if (options) {
|
||||
questions.push({
|
||||
key: `social:track-listener:${randomTrack.name}`,
|
||||
subjectKey: `track:${randomTrack.name}`,
|
||||
key: `social:track-listener:${topTrack.name}`,
|
||||
subjectKey: `track:${topTrack.name}`,
|
||||
question: {
|
||||
type: "choice",
|
||||
text: `Who listens the most to "${randomTrack.name}"?`,
|
||||
text: `Who listens the most to "${topTrack.name}"?`,
|
||||
options,
|
||||
correct: 0,
|
||||
points: 10,
|
||||
song: randomTrackSong ?? topSong ?? undefined,
|
||||
song: trackSong ?? topSong ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
quizState,
|
||||
i,
|
||||
);
|
||||
if (!question) {
|
||||
break;
|
||||
}
|
||||
quizState.currentQuestion = question;
|
||||
quizState.answers = {};
|
||||
const round: QuizRound = {
|
||||
|
|
@ -148,7 +151,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
partyId: string,
|
||||
quizState: QuizState,
|
||||
index: number,
|
||||
): Promise<Question> {
|
||||
): Promise<Question | null> {
|
||||
const partyRecord = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: partyId,
|
||||
|
|
@ -162,9 +165,6 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
analytics,
|
||||
index,
|
||||
});
|
||||
if (!question) {
|
||||
throw new Error("Failed to generate quiz question");
|
||||
}
|
||||
return question;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue