attempt to prefer shared things

This commit is contained in:
Daniel Bulant 2026-05-26 19:53:28 +02:00
parent f055fc7c0f
commit e4459833f4
No known key found for this signature in database
7 changed files with 454 additions and 58 deletions

View file

@ -38,6 +38,7 @@ type BaseQuestion = {
hideSongTitle?: boolean;
questionKey?: string;
subjectKey?: string;
subjectMemberIds?: string[];
};
export type Question =

View file

@ -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");

View file

@ -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<typeof getFairQuestionTracks>,
): Promise<TrackDetails[]> {
const tracks: TrackDetails[] = [];
const seen = new Set<string>();
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<Question | null> {
@ -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<typeof getFairQuestionTracks>,
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<typeof getFairQuestionArtists>,
name: string,
members: PartyQuestionMember[],
history: QuizRound[],
) {
const artist = artists.find((artist) => artist.name === name);
return artist ? getArtistFairness(artist, members, history) : undefined;
}

View file

@ -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<TrackDetails[]> {
const tracks: TrackDetails[] = [];
const seen = new Set<string>();
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<NumericQuestion | null> {
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<NumericQuestion | null> {
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<NumericQuestion | null> {
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;
}

View file

@ -44,6 +44,7 @@ export async function generatePartyQuestion({
question = await buildAudioMetadataQuestion(
dbClient,
analytics,
members,
index,
quizState.history,
);

View file

@ -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<T extends QuestionLike = QuestionLike> = {
key: string;
subjectKey?: string;
fairness?: QuestionCandidateFairness;
question: T;
};
export type QuestionCandidateFairness = {
memberIds: string[];
memberCount: number;
score: number;
};
export type QuestionSong = InferSelectModel<typeof trackTable>;
export const QUESTION_DURATION_MS = 60_000;
@ -165,7 +178,22 @@ export function pickQuestionCandidate<T extends QuestionLike>(
});
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<T extends QuestionLike>(
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<QuestionSong[]> {
const candidates: QuestionSong[] = [];
}): Promise<SongCandidate[]> {
const candidates: SongCandidate[] = [];
const seen = new Set<string>();
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<QuestionSong[]> {
const songs: QuestionSong[] = [];
history: QuizRound[],
): Promise<SongCandidate[]> {
const songsByMember: SongCandidate[][] = [];
const seen = new Set<string>();
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<string>();

View file

@ -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}"?`,