From e4459833f42b343cca27f1b8de1eada061c03605 Mon Sep 17 00:00:00 2001 From: Daniel Bulant Date: Tue, 26 May 2026 19:53:28 +0200 Subject: [PATCH] attempt to prefer shared things --- api/src/party-types.ts | 1 + .../__tests__/question-generation.test.ts | 78 +++++- api/src/party/audio-question-generator.ts | 85 +++++- api/src/party/numeric-question-generator.ts | 62 +++- api/src/party/question-generator.ts | 1 + api/src/party/question-utils.ts | 264 +++++++++++++++--- api/src/party/social-question-generator.ts | 21 +- 7 files changed, 454 insertions(+), 58 deletions(-) diff --git a/api/src/party-types.ts b/api/src/party-types.ts index 8648d56..77e4cde 100644 --- a/api/src/party-types.ts +++ b/api/src/party-types.ts @@ -38,6 +38,7 @@ type BaseQuestion = { hideSongTitle?: boolean; questionKey?: string; subjectKey?: string; + subjectMemberIds?: string[]; }; export type Question = diff --git a/api/src/party/__tests__/question-generation.test.ts b/api/src/party/__tests__/question-generation.test.ts index b2662bd..b6dc36d 100644 --- a/api/src/party/__tests__/question-generation.test.ts +++ b/api/src/party/__tests__/question-generation.test.ts @@ -4,6 +4,7 @@ import { buildAudioMetadataQuestion } from "../audio-question-generator"; import { buildNumericQuestion } from "../numeric-question-generator"; import { buildMemberOptions, + getFairQuestionTracks, type PartyAnalytics, type PartyQuestionMember, pickQuestionCandidate, @@ -223,6 +224,75 @@ describe("question generation", () => { expect(question?.subjectKey).toBe("member:a"); }); + it("prioritizes shared question candidates over single-member candidates", () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0); + + try { + const question = pickQuestionCandidate( + [ + { + key: "audio:track:solo", + subjectKey: "track:solo", + fairness: { memberIds: ["a"], memberCount: 1, score: 100 }, + question: makeChoiceQuestion( + "Solo question", + "audio:track:solo", + "track:solo", + ), + }, + { + key: "audio:track:shared", + subjectKey: "track:shared", + fairness: { memberIds: ["a", "b"], memberCount: 2, score: 10 }, + question: makeChoiceQuestion( + "Shared question", + "audio:track:shared", + "track:shared", + ), + }, + ], + [], + 0, + ); + + expect(question?.subjectKey).toBe("track:shared"); + } finally { + randomSpy.mockRestore(); + } + }); + + it("orders fair tracks by party coverage before score", () => { + const members: PartyQuestionMember[] = [ + { userId: "a", name: "A" }, + { userId: "b", name: "B" }, + ]; + const analytics = { + storyClusters: [ + { + tracks: [ + { + name: "Solo Track", + memberScores: [{ userId: "a", score: 100 }], + }, + { + name: "Shared Track", + memberScores: [ + { userId: "a", score: 10 }, + { userId: "b", score: 10 }, + ], + }, + ], + artists: [], + genres: [], + }, + ], + } as PartyAnalytics; + + expect(getFairQuestionTracks(analytics, members, [])[0]?.name).toBe( + "Shared Track", + ); + }); + it("returns null when member options would require fake placeholders", () => { const members: PartyQuestionMember[] = [ { userId: "a", name: "Sam" }, @@ -413,7 +483,13 @@ describe("question generation", () => { } as PartyAnalytics; try { - const question = await buildAudioMetadataQuestion(db, analytics, 0, []); + const question = await buildAudioMetadataQuestion( + db, + analytics, + [], + 0, + [], + ); expect(question).not.toBeNull(); expect(question?.type).toBe("choice"); diff --git a/api/src/party/audio-question-generator.ts b/api/src/party/audio-question-generator.ts index e14c43b..8f36dab 100644 --- a/api/src/party/audio-question-generator.ts +++ b/api/src/party/audio-question-generator.ts @@ -4,11 +4,14 @@ import { buildOptionsWithCorrect, buildOrderedOptions, buildQuestionWindow, + getArtistFairness, + getFairQuestionArtists, + getFairQuestionTracks, getMostSharedGenreNames, - getTopClusterArtists, - getTopClusterTracks, + getTrackFairness, isUsableText, type PartyAnalytics, + type PartyQuestionMember, pickQuestionCandidate, type QuestionCandidate, resolveQuestionSong, @@ -25,12 +28,12 @@ type TrackDetails = { async function getDetailedTopTracks( dbClient: typeof Db, - analytics: PartyAnalytics, + topTracks: ReturnType, ): Promise { const tracks: TrackDetails[] = []; const seen = new Set(); - for (const topTrack of getTopClusterTracks(analytics)) { + for (const topTrack of topTracks) { const dbTracks = (await dbClient.query.track.findMany({ where: { name: topTrack.name }, with: { album: true, artists: true }, @@ -58,6 +61,7 @@ async function getDetailedTopTracks( export async function buildAudioMetadataQuestion( dbClient: typeof Db, analytics: PartyAnalytics, + members: PartyQuestionMember[], index: number, history: QuizRound[], ): Promise { @@ -67,7 +71,7 @@ export async function buildAudioMetadataQuestion( > = []; const topSong = await resolveQuestionSong(dbClient, analytics); const topSongName = topSong?.name; - const topTracks = getTopClusterTracks(analytics); + const topTracks = getFairQuestionTracks(analytics, members, history); const topTrackNames = topTracks.map((track) => track.name); if (isUsableText(topSongName)) { const currentSongOptions = buildOptionsWithCorrect( @@ -114,14 +118,20 @@ export async function buildAudioMetadataQuestion( } } - const topArtists = getTopClusterArtists(analytics); - const topArtist = topArtists[0]; + const topArtistEntities = getFairQuestionArtists(analytics, members, history); + const topArtists = topArtistEntities.map((artist) => artist.name); + const topArtist = topArtistEntities[0]; if (topArtist) { - const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4); + const artistOptions = buildOptionsWithCorrect( + topArtist.name, + topArtists, + 4, + ); if (artistOptions) { questions.push({ - key: `audio:artist:${topArtist}`, - subjectKey: `artist:${topArtist}`, + key: `audio:artist:${topArtist.name}`, + subjectKey: `artist:${topArtist.name}`, + fairness: getArtistFairness(topArtist, members, history), question: { type: "choice", text: "Which artist shows up most often in the shared audio data?", @@ -135,17 +145,22 @@ export async function buildAudioMetadataQuestion( } if (topTracks.length > 0) { - const topTrackName = topTrackNames[0]; + const topTrack = topTracks[0]; + const topTrackName = topTrack?.name; const trackOptions = topTrackName ? buildOptionsWithCorrect(topTrackName, topTrackNames, 4) : null; - if (trackOptions) { + if (topTrack && trackOptions) { questions.push({ key: `audio:track:${topTrackName}`, subjectKey: `track:${topTrackName}`, + fairness: getTrackFairness(topTrack, members, history), question: { type: "choice", - text: "Which track looks most shared across the party?", + text: + getTrackFairness(topTrack, members, history).memberCount > 1 + ? "Which track looks most shared across the party?" + : "Which track stands out in the party analytics?", options: trackOptions, correct: 0, points: 10, @@ -165,6 +180,7 @@ export async function buildAudioMetadataQuestion( questions.push({ key: `audio:album-artist:${topTrack.albumName}:${correctArtist}`, subjectKey: `album:${topTrack.albumName}`, + fairness: getTrackFairness(topTrack, members, history), question: { type: "choice", text: `Which artist appears on "${topTrack.albumName}"?`, @@ -177,7 +193,7 @@ export async function buildAudioMetadataQuestion( } } - const detailedTracks = await getDetailedTopTracks(dbClient, analytics); + const detailedTracks = await getDetailedTopTracks(dbClient, topTracks); const datedTracks = detailedTracks.filter( (track) => track.album?.release_date, ); @@ -201,6 +217,12 @@ export async function buildAudioMetadataQuestion( questions.push({ key: `audio:release-first:${earliest.name}`, subjectKey: `track:${earliest.name}`, + fairness: getTrackFairnessForName( + topTracks, + earliest.name, + members, + history, + ), question: { type: "choice", text: "Which of these tracks came out first?", @@ -219,6 +241,12 @@ export async function buildAudioMetadataQuestion( questions.push({ key: `audio:release-last:${latest.name}`, subjectKey: `track:${latest.name}`, + fairness: getTrackFairnessForName( + topTracks, + latest.name, + members, + history, + ), question: { type: "choice", text: "Which of these tracks came out most recently?", @@ -257,6 +285,12 @@ export async function buildAudioMetadataQuestion( questions.push({ key: `audio:artist-longest-track:${artistName}:${longest.name}`, subjectKey: `artist:${artistName}`, + fairness: getArtistFairnessForName( + topArtistEntities, + artistName, + members, + history, + ), question: { type: "choice", text: `What's the longest track by ${artistName}?`, @@ -288,6 +322,7 @@ export async function buildAudioMetadataQuestion( questions.push({ key: `audio:performer:${topTrack.name}`, subjectKey: `track:${topTrack.name}`, + fairness: getTrackFairness(topTrack, members, history), question: { type: "choice", text: `Who performs "${topTrack.name}"?`, @@ -309,6 +344,7 @@ export async function buildAudioMetadataQuestion( questions.push({ key: `audio:title:${topTrack.name}`, subjectKey: `track:${topTrack.name}`, + fairness: getTrackFairness(topTrack, members, history), question: { type: "choice", text: `What is the name of this track by ${correctArtist}?`, @@ -357,6 +393,7 @@ export async function buildAudioMetadataQuestion( questions.push({ key: `audio:album:${topTrack.albumName}`, subjectKey: `track:${topTrack.name}`, + fairness: getTrackFairness(topTrack, members, history), question: { type: "choice", text: `"${topTrack.name}" appears on which album?`, @@ -378,3 +415,23 @@ export async function buildAudioMetadataQuestion( if (!question) return null; return buildQuestionWindow(question); } + +function getTrackFairnessForName( + tracks: ReturnType, + name: string, + members: PartyQuestionMember[], + history: QuizRound[], +) { + const track = tracks.find((track) => track.name === name); + return track ? getTrackFairness(track, members, history) : undefined; +} + +function getArtistFairnessForName( + artists: ReturnType, + name: string, + members: PartyQuestionMember[], + history: QuizRound[], +) { + const artist = artists.find((artist) => artist.name === name); + return artist ? getArtistFairness(artist, members, history) : undefined; +} diff --git a/api/src/party/numeric-question-generator.ts b/api/src/party/numeric-question-generator.ts index 886a1f0..12ae907 100644 --- a/api/src/party/numeric-question-generator.ts +++ b/api/src/party/numeric-question-generator.ts @@ -7,7 +7,11 @@ import { import type { Question, QuizRound } from "../party-types"; import { buildQuestionWindow, + getArtistFairness, + getFairQuestionArtists, + getFairQuestionTracks, getReleaseYearRange, + getTrackFairness, isUsableText, type PartyAnalytics, type PartyQuestionMember, @@ -40,13 +44,13 @@ type BuildNumericQuestionInput = { async function getDetailedTopTracks({ db, analytics, + members, + history, }: BuildNumericQuestionInput): Promise { const tracks: TrackDetails[] = []; const seen = new Set(); - for (const topTrack of analytics?.storyClusters?.flatMap( - (cluster) => cluster.tracks ?? [], - ) ?? []) { + for (const topTrack of getFairQuestionTracks(analytics, members, history)) { if (!isUsableText(topTrack.name)) continue; const dbTracks = (await db.query.track.findMany({ where: { name: topTrack.name }, @@ -77,8 +81,11 @@ async function getDetailedTopTracks({ async function getAlbumReleaseYear({ db, analytics, + members, + history, }: BuildNumericQuestionInput): Promise { - const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name; + const topTrack = getFairQuestionTracks(analytics, members, history)[0]; + const trackName = topTrack?.name; const track = trackName ? await db.query.track.findFirst({ where: { name: trackName }, @@ -102,7 +109,7 @@ async function getAlbumReleaseYear({ points: 10, song: song ?? undefined, questionKey: `numeric:album-year:${subject}`, - subjectKey: `album:${subject}`, + subjectKey: track.name ? `track:${track.name}` : `album:${subject}`, }; } @@ -180,8 +187,10 @@ async function countTopTrackListeners({ db, analytics, members, + history, }: BuildNumericQuestionInput): Promise { - const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name; + const topTrack = getFairQuestionTracks(analytics, members, history)[0]; + const trackName = topTrack?.name; if (!trackName || members.length === 0) return null; const dbTrack = await db.query.track.findFirst({ where: { name: trackName }, @@ -216,8 +225,10 @@ async function countFavouriteArtistListeners({ db, analytics, members, + history, }: BuildNumericQuestionInput): Promise { - const artistName = analytics?.storyClusters?.[0]?.artists?.[0]?.name; + const topArtist = getFairQuestionArtists(analytics, members, history)[0]; + const artistName = topArtist?.name; if (!artistName || members.length === 0) return null; const dbArtist = await db.query.artist.findFirst({ where: { name: artistName }, @@ -260,6 +271,7 @@ export async function buildNumericQuestion( questions.push({ key: albumYearQ.questionKey ?? `numeric:album-year:${albumYearQ.text}`, subjectKey: albumYearQ.subjectKey, + fairness: getNumericQuestionFairness(input, albumYearQ), question: albumYearQ, }); } @@ -269,6 +281,7 @@ export async function buildNumericQuestion( questions.push({ key: trackYearQ.questionKey ?? `numeric:track-year:${trackYearQ.text}`, subjectKey: trackYearQ.subjectKey, + fairness: getNumericQuestionFairness(input, trackYearQ), question: trackYearQ, }); } @@ -280,6 +293,7 @@ export async function buildNumericQuestion( artistFirstTrackYearQ.questionKey ?? `numeric:artist-first-track-year:${artistFirstTrackYearQ.text}`, subjectKey: artistFirstTrackYearQ.subjectKey, + fairness: getNumericQuestionFairness(input, artistFirstTrackYearQ), question: artistFirstTrackYearQ, }); } @@ -289,6 +303,7 @@ export async function buildNumericQuestion( questions.push({ key: topTrackQ.questionKey ?? `numeric:top-track-count:${topTrackQ.text}`, subjectKey: topTrackQ.subjectKey, + fairness: getNumericQuestionFairness(input, topTrackQ), question: topTrackQ, }); } @@ -298,6 +313,7 @@ export async function buildNumericQuestion( questions.push({ key: artistQ.questionKey ?? `numeric:artist-count:${artistQ.text}`, subjectKey: artistQ.subjectKey, + fairness: getNumericQuestionFairness(input, artistQ), question: artistQ, }); } @@ -306,3 +322,35 @@ export async function buildNumericQuestion( if (!question) return null; return buildQuestionWindow(question); } + +function getNumericQuestionFairness( + input: BuildNumericQuestionInput, + question: NumericQuestion, +) { + const subjectKey = question.subjectKey ?? ""; + if (subjectKey.startsWith("track:")) { + const trackName = subjectKey.slice("track:".length); + const track = getFairQuestionTracks( + input.analytics, + input.members, + input.history, + ).find((track) => track.name === trackName); + return track + ? getTrackFairness(track, input.members, input.history) + : undefined; + } + + if (subjectKey.startsWith("artist:")) { + const artistName = subjectKey.slice("artist:".length); + const artist = getFairQuestionArtists( + input.analytics, + input.members, + input.history, + ).find((artist) => artist.name === artistName); + return artist + ? getArtistFairness(artist, input.members, input.history) + : undefined; + } + + return undefined; +} diff --git a/api/src/party/question-generator.ts b/api/src/party/question-generator.ts index f6eec91..e6dce51 100644 --- a/api/src/party/question-generator.ts +++ b/api/src/party/question-generator.ts @@ -44,6 +44,7 @@ export async function generatePartyQuestion({ question = await buildAudioMetadataQuestion( dbClient, analytics, + members, index, quizState.history, ); diff --git a/api/src/party/question-utils.ts b/api/src/party/question-utils.ts index 98b6282..e8e354b 100644 --- a/api/src/party/question-utils.ts +++ b/api/src/party/question-utils.ts @@ -26,7 +26,10 @@ export type PartyAnalytics = { albumName?: string; memberScores?: { userId: string; score: number }[]; }[]; - artists?: { name: string }[]; + artists?: { + name: string; + memberScores?: { userId: string; score: number }[]; + }[]; genres?: { name: string }[]; }[]; memberProfiles?: { userId: string }[]; @@ -39,7 +42,10 @@ export type AnalyticsTrack = { albumName?: string; memberScores?: { userId: string; score: number }[]; }; -type AnalyticsArtist = { name: string }; +export type AnalyticsArtist = { + name: string; + memberScores?: { userId: string; score: number }[]; +}; type QuestionLike = { text: string; questionKey?: string; @@ -49,8 +55,15 @@ type QuestionLike = { export type QuestionCandidate = { key: string; subjectKey?: string; + fairness?: QuestionCandidateFairness; question: T; }; + +export type QuestionCandidateFairness = { + memberIds: string[]; + memberCount: number; + score: number; +}; export type QuestionSong = InferSelectModel; export const QUESTION_DURATION_MS = 60_000; @@ -165,7 +178,22 @@ export function pickQuestionCandidate( }); if (fresh.length === 0) return null; - const pool = fresh; + const bestMemberCount = Math.max( + ...fresh.map((candidate) => candidate.fairness?.memberCount ?? 0), + ); + const bestScore = Math.max( + ...fresh + .filter( + (candidate) => + (candidate.fairness?.memberCount ?? 0) === bestMemberCount, + ) + .map((candidate) => candidate.fairness?.score ?? 0), + ); + const pool = fresh.filter( + (candidate) => + (candidate.fairness?.memberCount ?? 0) === bestMemberCount && + (candidate.fairness?.score ?? 0) === bestScore, + ); const candidate = pickRandom(pool); if (!candidate) return null; return { @@ -173,6 +201,7 @@ export function pickQuestionCandidate( questionKey: candidate.question.questionKey ?? candidate.key, subjectKey: candidate.question.subjectKey ?? candidate.subjectKey ?? undefined, + subjectMemberIds: candidate.fairness?.memberIds, } as T; } @@ -181,13 +210,55 @@ function normalizeQuestionKey(value: string): string { } export function getTopClusterArtists(analytics: PartyAnalytics): string[] { - return getAllClusterArtists(analytics).map((artist) => artist.name); + return getFairQuestionArtists(analytics).map((artist) => artist.name); } export function getTopClusterTracks( analytics: PartyAnalytics, ): AnalyticsTrack[] { - return getAllClusterTracks(analytics); + return getFairQuestionTracks(analytics); +} + +export function getFairQuestionTracks( + analytics: PartyAnalytics, + members: PartyQuestionMember[] = [], + history: QuizRound[] = [], +): AnalyticsTrack[] { + return getAllClusterTracks(analytics).sort((a, b) => + compareFairEntities( + getTrackFairness(b, members, history), + getTrackFairness(a, members, history), + ), + ); +} + +export function getFairQuestionArtists( + analytics: PartyAnalytics, + members: PartyQuestionMember[] = [], + history: QuizRound[] = [], +): AnalyticsArtist[] { + return getAllClusterArtists(analytics).sort((a, b) => + compareFairEntities( + getArtistFairness(b, members, history), + getArtistFairness(a, members, history), + ), + ); +} + +export function getTrackFairness( + track: AnalyticsTrack, + members: PartyQuestionMember[] = [], + history: QuizRound[] = [], +): QuestionCandidateFairness { + return buildFairness(track.memberScores, members, history); +} + +export function getArtistFairness( + artist: AnalyticsArtist, + members: PartyQuestionMember[] = [], + history: QuizRound[] = [], +): QuestionCandidateFairness { + return buildFairness(artist.memberScores, members, history); } export function pickRelevantTrack( @@ -267,6 +338,11 @@ type SongSelectionInput = { question: Question; }; +type SongCandidate = { + song: QuestionSong; + fairness?: QuestionCandidateFairness; +}; + export async function selectQuestionSong({ db, analytics, @@ -285,38 +361,46 @@ export async function selectQuestionSong({ db, analytics, members, + history, question, }); if (candidates.length === 0) return question.song ?? null; - if (keepSpecificSong) return candidates[0] ?? question.song ?? null; + if (keepSpecificSong) return candidates[0]?.song ?? question.song ?? null; - const freshCandidate = candidates.find( + const freshCandidates = candidates.filter( (candidate) => - isUsableText(candidate.platform_id) && - !usedPlatformIds.has(candidate.platform_id), + isUsableText(candidate.song.platform_id) && + !usedPlatformIds.has(candidate.song.platform_id), ); - return freshCandidate ?? candidates[0] ?? question.song ?? null; + const selected = + pickFairSongCandidate(freshCandidates) ?? pickFairSongCandidate(candidates); + return selected?.song ?? question.song ?? null; } async function collectSongCandidates({ db, analytics, members, + history, question, }: { db: typeof Db; analytics: PartyAnalytics; members: PartyQuestionMember[]; + history: QuizRound[]; question: Question; -}): Promise { - const candidates: QuestionSong[] = []; +}): Promise { + const candidates: SongCandidate[] = []; const seen = new Set(); - const push = (song: QuestionSong | null | undefined) => { + const push = ( + song: QuestionSong | null | undefined, + fairness?: QuestionCandidateFairness, + ) => { if (!song || !isUsableText(song.platform_id)) return; if (seen.has(song.platform_id)) return; seen.add(song.platform_id); - candidates.push(song); + candidates.push({ song, fairness }); }; push(question.song); @@ -326,32 +410,36 @@ async function collectSongCandidates({ analytics, question, ); - push(subjectSong); + push( + subjectSong, + getQuestionSubjectFairness(analytics, members, history, question), + ); const peopleSong = await resolveSongFromMentionedPeople( db, analytics, question, ); - push(peopleSong); - - const topClusterTracks = getAllClusterTracks(analytics).sort( - (a, b) => getTrackScore(b) - getTrackScore(a), + push( + peopleSong, + getQuestionSubjectFairness(analytics, members, history, question), ); + const topClusterTracks = getFairQuestionTracks(analytics, members, history); + 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); + push(song, getTrackFairness(track, members, history)); } if (members.length > 0) { - const topPartySongs = await fetchPartyTopSongs(db, members); - for (const song of topPartySongs) { - push(song); + const topPartySongs = await fetchPartyTopSongs(db, members, history); + for (const candidate of topPartySongs) { + push(candidate.song, candidate.fairness); } } @@ -416,11 +504,13 @@ async function resolveSongFromMentionedPeople( async function fetchPartyTopSongs( db: typeof Db, members: PartyQuestionMember[], -): Promise { - const songs: QuestionSong[] = []; + history: QuizRound[], +): Promise { + const songsByMember: SongCandidate[][] = []; const seen = new Set(); for (const member of members) { + const memberSongs: SongCandidate[] = []; const rows = await db.query.topTrack.findMany({ where: { userId: member.userId, @@ -444,11 +534,78 @@ async function fetchPartyTopSongs( if (!song || !isUsableText(song.platform_id)) continue; if (seen.has(song.platform_id)) continue; seen.add(song.platform_id); - songs.push(song); + memberSongs.push({ + song, + fairness: { + memberIds: [member.userId], + memberCount: 1, + score: -getHistoryMemberUseCount(member.userId, history), + }, + }); + } + songsByMember.push(memberSongs); + } + + const interleaved: SongCandidate[] = []; + const maxLength = Math.max(0, ...songsByMember.map((songs) => songs.length)); + for (let i = 0; i < maxLength; i++) { + for (const memberSongs of songsByMember) { + const candidate = memberSongs[i]; + if (candidate) interleaved.push(candidate); } } - return songs; + return interleaved; +} + +function pickFairSongCandidate( + candidates: SongCandidate[], +): SongCandidate | null { + if (candidates.length === 0) return null; + const bestMemberCount = Math.max( + ...candidates.map((candidate) => candidate.fairness?.memberCount ?? 0), + ); + const bestScore = Math.max( + ...candidates + .filter( + (candidate) => + (candidate.fairness?.memberCount ?? 0) === bestMemberCount, + ) + .map((candidate) => candidate.fairness?.score ?? 0), + ); + return pickRandom( + candidates.filter( + (candidate) => + (candidate.fairness?.memberCount ?? 0) === bestMemberCount && + (candidate.fairness?.score ?? 0) === bestScore, + ), + ); +} + +function getQuestionSubjectFairness( + analytics: PartyAnalytics, + members: PartyQuestionMember[], + history: QuizRound[], + question: Question, +): QuestionCandidateFairness | undefined { + const subjectKey = question.subjectKey ?? ""; + if (subjectKey.startsWith("track:")) { + const trackName = subjectKey.slice("track:".length); + const track = getFairQuestionTracks(analytics, members, history).find( + (track) => track.name === trackName, + ); + return track ? getTrackFairness(track, members, history) : undefined; + } + + if (subjectKey.startsWith("artist:")) { + const artistName = subjectKey.slice("artist:".length); + const artist = getFairQuestionArtists(analytics, members, history).find( + (artist) => artist.name === artistName, + ); + return artist ? getArtistFairness(artist, members, history) : undefined; + } + + return undefined; } function isSongTargetQuestion(question: Question): boolean { @@ -462,13 +619,6 @@ function isSongTargetQuestion(question: Question): boolean { ); } -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[], @@ -478,6 +628,52 @@ function getMemberTrackScore( }, 0); } +function buildFairness( + memberScores: { userId: string; score: number }[] | undefined, + members: PartyQuestionMember[], + history: QuizRound[], +): QuestionCandidateFairness { + const partyMemberIds = new Set(members.map((member) => member.userId)); + const memberIds = uniqueStrings( + (memberScores ?? []) + .map((entry) => entry.userId) + .filter( + (userId) => partyMemberIds.size === 0 || partyMemberIds.has(userId), + ), + ); + const totalScore = (memberScores ?? []).reduce((total, entry) => { + return memberIds.includes(entry.userId) ? total + entry.score : total; + }, 0); + const score = + memberIds.length === 1 + ? -getHistoryMemberUseCount(memberIds[0], history) + : totalScore; + return { + memberIds, + memberCount: memberIds.length, + score, + }; +} + +function compareFairEntities( + left: QuestionCandidateFairness, + right: QuestionCandidateFairness, +): number { + return left.memberCount - right.memberCount || left.score - right.score; +} + +function getHistoryMemberUseCount( + memberId: string | undefined, + history: QuizRound[], +): number { + if (!memberId) return 0; + return history.reduce((total, round) => { + return round.question.subjectMemberIds?.includes(memberId) + ? total + 1 + : total; + }, 0); +} + function getAllClusterTracks(analytics: PartyAnalytics): AnalyticsTrack[] { const tracks: AnalyticsTrack[] = []; const seen = new Set(); diff --git a/api/src/party/social-question-generator.ts b/api/src/party/social-question-generator.ts index e6da155..016b635 100644 --- a/api/src/party/social-question-generator.ts +++ b/api/src/party/social-question-generator.ts @@ -5,9 +5,10 @@ import { buildMemberPairOptions, buildQuestionWindow, getCurrentLeader, + getFairQuestionTracks, getMostDiverseMember, - getTopClusterTracks, getTopTrackListener, + getTrackFairness, hasClearLeader, type PartyAnalytics, type PartyQuestionMember, @@ -70,9 +71,24 @@ export async function buildSocialQuestion( } } - const topTracks = getTopClusterTracks(analytics); + const topTracks = getFairQuestionTracks( + analytics, + members, + quizState.history, + ); if (hasMultipleMembers) { for (const topTrack of topTracks) { + const fairness = getTrackFairness(topTrack, members, quizState.history); + if ( + fairness.memberCount < 2 && + topTracks.some((track) => { + return ( + getTrackFairness(track, members, quizState.history).memberCount > 1 + ); + }) + ) { + continue; + } const topListener = getTopTrackListener(topTrack, members); if (!topListener) continue; @@ -86,6 +102,7 @@ export async function buildSocialQuestion( questions.push({ key: `social:track-listener:${topTrack.name}`, subjectKey: `track:${topTrack.name}`, + fairness, question: { type: "choice", text: `Who listens the most to "${topTrack.name}"?`,