attempt to prefer shared things
This commit is contained in:
parent
f055fc7c0f
commit
e4459833f4
7 changed files with 454 additions and 58 deletions
|
|
@ -38,6 +38,7 @@ type BaseQuestion = {
|
|||
hideSongTitle?: boolean;
|
||||
questionKey?: string;
|
||||
subjectKey?: string;
|
||||
subjectMemberIds?: string[];
|
||||
};
|
||||
|
||||
export type Question =
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export async function generatePartyQuestion({
|
|||
question = await buildAudioMetadataQuestion(
|
||||
dbClient,
|
||||
analytics,
|
||||
members,
|
||||
index,
|
||||
quizState.history,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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}"?`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue