song fallback
This commit is contained in:
parent
032e656297
commit
49e3299224
4 changed files with 412 additions and 14 deletions
|
|
@ -6,10 +6,12 @@ import {
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
pickQuestionCandidate,
|
pickQuestionCandidate,
|
||||||
|
selectQuestionSong,
|
||||||
} from "../question-utils";
|
} from "../question-utils";
|
||||||
import { buildSocialQuestion } from "../social-question-generator";
|
import { buildSocialQuestion } from "../social-question-generator";
|
||||||
|
|
||||||
type Db = typeof import("../../db").db;
|
type Db = typeof import("../../db").db;
|
||||||
|
type Song = NonNullable<Question["song"]>;
|
||||||
|
|
||||||
function makeChoiceQuestion(
|
function makeChoiceQuestion(
|
||||||
text: string,
|
text: string,
|
||||||
|
|
@ -61,6 +63,39 @@ function createFakeDb(trackReleaseDate: Date | null) {
|
||||||
} as unknown as Db;
|
} as unknown as Db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeSong(id: string, platformId: string, name: string): Song {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
albumId: "album-1",
|
||||||
|
platform: "spotify",
|
||||||
|
platform_id: platformId,
|
||||||
|
name,
|
||||||
|
popularity: 1,
|
||||||
|
duration: 1,
|
||||||
|
explicit: false,
|
||||||
|
disc_number: 1,
|
||||||
|
track_number: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSongFallbackDb(rows: Song[]) {
|
||||||
|
return {
|
||||||
|
query: {
|
||||||
|
topTrack: {
|
||||||
|
findMany: vi.fn(async () =>
|
||||||
|
rows.map((row, index) => ({
|
||||||
|
position: index + 1,
|
||||||
|
track: row,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Db;
|
||||||
|
}
|
||||||
|
|
||||||
describe("question generation", () => {
|
describe("question generation", () => {
|
||||||
it("skips repeated question keys, subjects, and text", () => {
|
it("skips repeated question keys, subjects, and text", () => {
|
||||||
const history: QuizRound[] = [
|
const history: QuizRound[] = [
|
||||||
|
|
@ -230,4 +265,79 @@ describe("question generation", () => {
|
||||||
|
|
||||||
expect(question).toBeNull();
|
expect(question).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("selects a fresh party song when the current one was already used", async () => {
|
||||||
|
const db = createSongFallbackDb([
|
||||||
|
makeSong("track-1", "spotify:track:one", "One"),
|
||||||
|
makeSong("track-2", "spotify:track:two", "Two"),
|
||||||
|
]);
|
||||||
|
const question = {
|
||||||
|
type: "choice" as const,
|
||||||
|
text: "Which genre appears most in the party analytics?",
|
||||||
|
correct: 0,
|
||||||
|
startTimestamp: 1,
|
||||||
|
endTimestamp: 2,
|
||||||
|
points: 10,
|
||||||
|
options: ["A", "B"],
|
||||||
|
questionKey: "audio:genre:pop",
|
||||||
|
subjectKey: "genre:pop",
|
||||||
|
};
|
||||||
|
|
||||||
|
const song = await selectQuestionSong({
|
||||||
|
db,
|
||||||
|
analytics: null,
|
||||||
|
members: [{ userId: "a", name: "A" }],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
questionIndex: 0,
|
||||||
|
question: {
|
||||||
|
...question,
|
||||||
|
song: makeSong("track-1", "spotify:track:one", "One"),
|
||||||
|
},
|
||||||
|
responses: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
question,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(song?.platform_id).toBe("spotify:track:two");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a song-target question on the same track", async () => {
|
||||||
|
const db = createSongFallbackDb([
|
||||||
|
makeSong("track-1", "spotify:track:one", "One"),
|
||||||
|
makeSong("track-2", "spotify:track:two", "Two"),
|
||||||
|
]);
|
||||||
|
const question = {
|
||||||
|
type: "choice" as const,
|
||||||
|
text: "What song is currently playing?",
|
||||||
|
correct: 0,
|
||||||
|
startTimestamp: 1,
|
||||||
|
endTimestamp: 2,
|
||||||
|
points: 10,
|
||||||
|
options: ["A", "B"],
|
||||||
|
questionKey: "audio:current-song:One",
|
||||||
|
subjectKey: "track:One",
|
||||||
|
hideSongTitle: true,
|
||||||
|
song: {
|
||||||
|
...makeSong("track-1", "spotify:track:one", "One"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const song = await selectQuestionSong({
|
||||||
|
db,
|
||||||
|
analytics: null,
|
||||||
|
members: [{ userId: "a", name: "A" }],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
questionIndex: 0,
|
||||||
|
question,
|
||||||
|
responses: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
question,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(song?.platform_id).toBe("spotify:track:one");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { QuizState } from "../../party-types";
|
import type { QuizState } from "../../party-types";
|
||||||
|
import * as audioQuestionGenerator from "../audio-question-generator";
|
||||||
|
|
||||||
vi.mock("../audio-question-generator", () => ({
|
vi.mock("../audio-question-generator", () => ({
|
||||||
buildAudioMetadataQuestion: vi.fn(async () => null),
|
buildAudioMetadataQuestion: vi.fn(async () => null),
|
||||||
|
|
@ -47,4 +48,59 @@ describe("generatePartyQuestion", () => {
|
||||||
|
|
||||||
expect(question).toBeNull();
|
expect(question).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches a fallback song to generated questions", async () => {
|
||||||
|
vi.mocked(
|
||||||
|
audioQuestionGenerator.buildAudioMetadataQuestion,
|
||||||
|
).mockResolvedValueOnce({
|
||||||
|
type: "choice",
|
||||||
|
text: "Which genre appears most in the party analytics?",
|
||||||
|
correct: 0,
|
||||||
|
startTimestamp: 1,
|
||||||
|
endTimestamp: 2,
|
||||||
|
points: 10,
|
||||||
|
options: ["A", "B"],
|
||||||
|
questionKey: "audio:genre:pop",
|
||||||
|
subjectKey: "genre:pop",
|
||||||
|
});
|
||||||
|
|
||||||
|
const quizState = {
|
||||||
|
status: "running",
|
||||||
|
workflowId: null,
|
||||||
|
questionIndex: 0,
|
||||||
|
currentQuestion: null,
|
||||||
|
answers: {},
|
||||||
|
scores: {},
|
||||||
|
history: [],
|
||||||
|
} as QuizState;
|
||||||
|
|
||||||
|
const question = await generatePartyQuestion({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
partyMember: {
|
||||||
|
findMany: vi.fn(async () => [{ userId: "a", user: { name: "A" } }]),
|
||||||
|
},
|
||||||
|
topTrack: {
|
||||||
|
findMany: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
position: 1,
|
||||||
|
track: {
|
||||||
|
id: "track-1",
|
||||||
|
platform: "spotify",
|
||||||
|
platform_id: "spotify:track:one",
|
||||||
|
name: "One",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
partyId: "party-1",
|
||||||
|
quizState,
|
||||||
|
analytics: null,
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(question?.song?.platform_id).toBe("spotify:track:one");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@ import type { db } from "../db";
|
||||||
import type { Question, QuizState } from "../party-types";
|
import type { Question, QuizState } from "../party-types";
|
||||||
import { buildAudioMetadataQuestion } from "./audio-question-generator";
|
import { buildAudioMetadataQuestion } from "./audio-question-generator";
|
||||||
import { buildNumericQuestion } from "./numeric-question-generator";
|
import { buildNumericQuestion } from "./numeric-question-generator";
|
||||||
import type { PartyAnalytics } from "./question-utils";
|
import {
|
||||||
import { fetchPartyMembers } from "./question-utils";
|
fetchPartyMembers,
|
||||||
|
type PartyAnalytics,
|
||||||
|
selectQuestionSong,
|
||||||
|
} from "./question-utils";
|
||||||
import { buildSocialQuestion } from "./social-question-generator";
|
import { buildSocialQuestion } from "./social-question-generator";
|
||||||
|
|
||||||
export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
|
export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
|
||||||
|
|
@ -36,37 +39,44 @@ export async function generatePartyQuestion({
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const type of typeOrder) {
|
for (const type of typeOrder) {
|
||||||
|
let question: Question | null = null;
|
||||||
if (type === "audio-metadata") {
|
if (type === "audio-metadata") {
|
||||||
const q = await buildAudioMetadataQuestion(
|
question = await buildAudioMetadataQuestion(
|
||||||
dbClient,
|
dbClient,
|
||||||
analytics,
|
analytics,
|
||||||
index,
|
index,
|
||||||
quizState.history,
|
quizState.history,
|
||||||
);
|
);
|
||||||
if (q) return q;
|
} else if (type === "social") {
|
||||||
continue;
|
question = await buildSocialQuestion(
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "social") {
|
|
||||||
const q = await buildSocialQuestion(
|
|
||||||
dbClient,
|
dbClient,
|
||||||
quizState,
|
quizState,
|
||||||
analytics,
|
analytics,
|
||||||
members,
|
members,
|
||||||
index,
|
index,
|
||||||
);
|
);
|
||||||
if (q) return q;
|
} else {
|
||||||
|
question = await buildNumericQuestion({
|
||||||
|
db: dbClient,
|
||||||
|
analytics,
|
||||||
|
index,
|
||||||
|
members,
|
||||||
|
history: quizState.history,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!question) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = await buildNumericQuestion({
|
const song = await selectQuestionSong({
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
analytics,
|
analytics,
|
||||||
index,
|
|
||||||
members,
|
members,
|
||||||
history: quizState.history,
|
history: quizState.history,
|
||||||
|
question,
|
||||||
});
|
});
|
||||||
if (q) return q;
|
return song ? { ...question, song } : question;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import type { db as Db } from "../db";
|
import type { db as Db } from "../db";
|
||||||
import type { track as trackTable } from "../db/schema";
|
import type { track as trackTable } from "../db/schema";
|
||||||
import type { QuizRound } from "../party-types";
|
import type { Question, QuizRound } from "../party-types";
|
||||||
|
|
||||||
export type PartyQuestionMember = {
|
export type PartyQuestionMember = {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -262,6 +262,228 @@ export async function resolveQuestionSong(
|
||||||
return song;
|
return song;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SongSelectionInput = {
|
||||||
|
db: typeof Db;
|
||||||
|
analytics: PartyAnalytics;
|
||||||
|
members: PartyQuestionMember[];
|
||||||
|
history: QuizRound[];
|
||||||
|
question: Question;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function selectQuestionSong({
|
||||||
|
db,
|
||||||
|
analytics,
|
||||||
|
members,
|
||||||
|
history,
|
||||||
|
question,
|
||||||
|
}: SongSelectionInput): Promise<QuestionSong | null> {
|
||||||
|
const keepSpecificSong = isSongTargetQuestion(question);
|
||||||
|
const usedPlatformIds = new Set(
|
||||||
|
history
|
||||||
|
.map((round) => round.question.song?.platform_id)
|
||||||
|
.filter((value): value is string => isUsableText(value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidates = await collectSongCandidates({
|
||||||
|
db,
|
||||||
|
analytics,
|
||||||
|
members,
|
||||||
|
question,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (candidates.length === 0) return question.song ?? null;
|
||||||
|
if (keepSpecificSong) return candidates[0] ?? question.song ?? null;
|
||||||
|
|
||||||
|
const freshCandidate = candidates.find(
|
||||||
|
(candidate) =>
|
||||||
|
isUsableText(candidate.platform_id) &&
|
||||||
|
!usedPlatformIds.has(candidate.platform_id),
|
||||||
|
);
|
||||||
|
return freshCandidate ?? candidates[0] ?? question.song ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectSongCandidates({
|
||||||
|
db,
|
||||||
|
analytics,
|
||||||
|
members,
|
||||||
|
question,
|
||||||
|
}: {
|
||||||
|
db: typeof Db;
|
||||||
|
analytics: PartyAnalytics;
|
||||||
|
members: PartyQuestionMember[];
|
||||||
|
question: Question;
|
||||||
|
}): Promise<QuestionSong[]> {
|
||||||
|
const candidates: QuestionSong[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const push = (song: QuestionSong | null | undefined) => {
|
||||||
|
if (!song || !isUsableText(song.platform_id)) return;
|
||||||
|
if (seen.has(song.platform_id)) return;
|
||||||
|
seen.add(song.platform_id);
|
||||||
|
candidates.push(song);
|
||||||
|
};
|
||||||
|
|
||||||
|
push(question.song);
|
||||||
|
|
||||||
|
const subjectSong = await resolveSongFromQuestionSubject(
|
||||||
|
db,
|
||||||
|
analytics,
|
||||||
|
question,
|
||||||
|
);
|
||||||
|
push(subjectSong);
|
||||||
|
|
||||||
|
const peopleSong = await resolveSongFromMentionedPeople(
|
||||||
|
db,
|
||||||
|
analytics,
|
||||||
|
question,
|
||||||
|
);
|
||||||
|
push(peopleSong);
|
||||||
|
|
||||||
|
const topClusterTracks = [...(analytics?.storyClusters?.[0]?.tracks ?? [])]
|
||||||
|
.filter((track) => isUsableText(track.name))
|
||||||
|
.sort((a, b) => getTrackScore(b) - getTrackScore(a));
|
||||||
|
|
||||||
|
for (const track of topClusterTracks) {
|
||||||
|
const song = await resolveQuestionSong(db, analytics, {
|
||||||
|
trackName: track.name,
|
||||||
|
artistNames: track.artists?.map((artist) => artist.name),
|
||||||
|
albumName: track.albumName,
|
||||||
|
});
|
||||||
|
push(song);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (members.length > 0) {
|
||||||
|
const topPartySongs = await fetchPartyTopSongs(db, members);
|
||||||
|
for (const song of topPartySongs) {
|
||||||
|
push(song);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSongFromQuestionSubject(
|
||||||
|
db: typeof Db,
|
||||||
|
analytics: PartyAnalytics,
|
||||||
|
question: Question,
|
||||||
|
): Promise<QuestionSong | null> {
|
||||||
|
const subjectKey = question.subjectKey ?? "";
|
||||||
|
if (subjectKey.startsWith("track:")) {
|
||||||
|
const trackName = subjectKey.slice("track:".length).trim();
|
||||||
|
if (!trackName) return null;
|
||||||
|
return resolveQuestionSong(db, analytics, { trackName });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subjectKey.startsWith("artist:")) {
|
||||||
|
const artistName = subjectKey.slice("artist:".length).trim();
|
||||||
|
if (!artistName) return null;
|
||||||
|
return resolveQuestionSong(db, analytics, { artistNames: [artistName] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSongFromMentionedPeople(
|
||||||
|
db: typeof Db,
|
||||||
|
analytics: PartyAnalytics,
|
||||||
|
question: Question,
|
||||||
|
): Promise<QuestionSong | null> {
|
||||||
|
const subjectKey = question.subjectKey ?? "";
|
||||||
|
const userIds = subjectKey.startsWith("member:")
|
||||||
|
? [subjectKey.slice("member:".length).trim()].filter(Boolean)
|
||||||
|
: subjectKey.startsWith("pair:")
|
||||||
|
? subjectKey
|
||||||
|
.slice("pair:".length)
|
||||||
|
.split("|")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (userIds.length === 0) return null;
|
||||||
|
|
||||||
|
const tracks = [...(analytics?.storyClusters?.[0]?.tracks ?? [])]
|
||||||
|
.filter((track) => isUsableText(track.name))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
getMemberTrackScore(b, userIds) - getMemberTrackScore(a, userIds),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const track of tracks) {
|
||||||
|
const song = await resolveQuestionSong(db, analytics, {
|
||||||
|
trackName: track.name,
|
||||||
|
artistNames: track.artists?.map((artist) => artist.name),
|
||||||
|
albumName: track.albumName,
|
||||||
|
});
|
||||||
|
if (song) return song;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPartyTopSongs(
|
||||||
|
db: typeof Db,
|
||||||
|
members: PartyQuestionMember[],
|
||||||
|
): Promise<QuestionSong[]> {
|
||||||
|
const songs: QuestionSong[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const member of members) {
|
||||||
|
const rows = await db.query.topTrack.findMany({
|
||||||
|
where: {
|
||||||
|
userId: member.userId,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
track: {
|
||||||
|
with: {
|
||||||
|
album: true,
|
||||||
|
artists: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
position: "asc",
|
||||||
|
},
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const song = row.track;
|
||||||
|
if (!song || !isUsableText(song.platform_id)) continue;
|
||||||
|
if (seen.has(song.platform_id)) continue;
|
||||||
|
seen.add(song.platform_id);
|
||||||
|
songs.push(song);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return songs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSongTargetQuestion(question: Question): boolean {
|
||||||
|
const key = question.questionKey?.toLowerCase() ?? "";
|
||||||
|
const text = question.text.toLowerCase();
|
||||||
|
return (
|
||||||
|
question.hideSongTitle === true ||
|
||||||
|
key.startsWith("audio:current-song:") ||
|
||||||
|
text.includes("what song") ||
|
||||||
|
text.includes("which song")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackScore(track: { memberScores?: { score: number }[] }): number {
|
||||||
|
return (track.memberScores ?? []).reduce(
|
||||||
|
(total, entry) => total + entry.score,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberTrackScore(
|
||||||
|
track: { memberScores?: { userId: string; score: number }[] },
|
||||||
|
userIds: string[],
|
||||||
|
): number {
|
||||||
|
return (track.memberScores ?? []).reduce((total, entry) => {
|
||||||
|
return userIds.includes(entry.userId) ? total + entry.score : total;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function isUsableText(
|
export function isUsableText(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
): value is string {
|
): value is string {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue