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;
|
hideSongTitle?: boolean;
|
||||||
questionKey?: string;
|
questionKey?: string;
|
||||||
subjectKey?: string;
|
subjectKey?: string;
|
||||||
|
subjectMemberIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Question =
|
export type Question =
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { buildAudioMetadataQuestion } from "../audio-question-generator";
|
||||||
import { buildNumericQuestion } from "../numeric-question-generator";
|
import { buildNumericQuestion } from "../numeric-question-generator";
|
||||||
import {
|
import {
|
||||||
buildMemberOptions,
|
buildMemberOptions,
|
||||||
|
getFairQuestionTracks,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
pickQuestionCandidate,
|
pickQuestionCandidate,
|
||||||
|
|
@ -223,6 +224,75 @@ describe("question generation", () => {
|
||||||
expect(question?.subjectKey).toBe("member:a");
|
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", () => {
|
it("returns null when member options would require fake placeholders", () => {
|
||||||
const members: PartyQuestionMember[] = [
|
const members: PartyQuestionMember[] = [
|
||||||
{ userId: "a", name: "Sam" },
|
{ userId: "a", name: "Sam" },
|
||||||
|
|
@ -413,7 +483,13 @@ describe("question generation", () => {
|
||||||
} as PartyAnalytics;
|
} as PartyAnalytics;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const question = await buildAudioMetadataQuestion(db, analytics, 0, []);
|
const question = await buildAudioMetadataQuestion(
|
||||||
|
db,
|
||||||
|
analytics,
|
||||||
|
[],
|
||||||
|
0,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
expect(question).not.toBeNull();
|
expect(question).not.toBeNull();
|
||||||
expect(question?.type).toBe("choice");
|
expect(question?.type).toBe("choice");
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,14 @@ import {
|
||||||
buildOptionsWithCorrect,
|
buildOptionsWithCorrect,
|
||||||
buildOrderedOptions,
|
buildOrderedOptions,
|
||||||
buildQuestionWindow,
|
buildQuestionWindow,
|
||||||
|
getArtistFairness,
|
||||||
|
getFairQuestionArtists,
|
||||||
|
getFairQuestionTracks,
|
||||||
getMostSharedGenreNames,
|
getMostSharedGenreNames,
|
||||||
getTopClusterArtists,
|
getTrackFairness,
|
||||||
getTopClusterTracks,
|
|
||||||
isUsableText,
|
isUsableText,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
|
type PartyQuestionMember,
|
||||||
pickQuestionCandidate,
|
pickQuestionCandidate,
|
||||||
type QuestionCandidate,
|
type QuestionCandidate,
|
||||||
resolveQuestionSong,
|
resolveQuestionSong,
|
||||||
|
|
@ -25,12 +28,12 @@ type TrackDetails = {
|
||||||
|
|
||||||
async function getDetailedTopTracks(
|
async function getDetailedTopTracks(
|
||||||
dbClient: typeof Db,
|
dbClient: typeof Db,
|
||||||
analytics: PartyAnalytics,
|
topTracks: ReturnType<typeof getFairQuestionTracks>,
|
||||||
): Promise<TrackDetails[]> {
|
): Promise<TrackDetails[]> {
|
||||||
const tracks: TrackDetails[] = [];
|
const tracks: TrackDetails[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const topTrack of getTopClusterTracks(analytics)) {
|
for (const topTrack of topTracks) {
|
||||||
const dbTracks = (await dbClient.query.track.findMany({
|
const dbTracks = (await dbClient.query.track.findMany({
|
||||||
where: { name: topTrack.name },
|
where: { name: topTrack.name },
|
||||||
with: { album: true, artists: true },
|
with: { album: true, artists: true },
|
||||||
|
|
@ -58,6 +61,7 @@ async function getDetailedTopTracks(
|
||||||
export async function buildAudioMetadataQuestion(
|
export async function buildAudioMetadataQuestion(
|
||||||
dbClient: typeof Db,
|
dbClient: typeof Db,
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
|
members: PartyQuestionMember[],
|
||||||
index: number,
|
index: number,
|
||||||
history: QuizRound[],
|
history: QuizRound[],
|
||||||
): Promise<Question | null> {
|
): Promise<Question | null> {
|
||||||
|
|
@ -67,7 +71,7 @@ export async function buildAudioMetadataQuestion(
|
||||||
> = [];
|
> = [];
|
||||||
const topSong = await resolveQuestionSong(dbClient, analytics);
|
const topSong = await resolveQuestionSong(dbClient, analytics);
|
||||||
const topSongName = topSong?.name;
|
const topSongName = topSong?.name;
|
||||||
const topTracks = getTopClusterTracks(analytics);
|
const topTracks = getFairQuestionTracks(analytics, members, history);
|
||||||
const topTrackNames = topTracks.map((track) => track.name);
|
const topTrackNames = topTracks.map((track) => track.name);
|
||||||
if (isUsableText(topSongName)) {
|
if (isUsableText(topSongName)) {
|
||||||
const currentSongOptions = buildOptionsWithCorrect(
|
const currentSongOptions = buildOptionsWithCorrect(
|
||||||
|
|
@ -114,14 +118,20 @@ export async function buildAudioMetadataQuestion(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const topArtists = getTopClusterArtists(analytics);
|
const topArtistEntities = getFairQuestionArtists(analytics, members, history);
|
||||||
const topArtist = topArtists[0];
|
const topArtists = topArtistEntities.map((artist) => artist.name);
|
||||||
|
const topArtist = topArtistEntities[0];
|
||||||
if (topArtist) {
|
if (topArtist) {
|
||||||
const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4);
|
const artistOptions = buildOptionsWithCorrect(
|
||||||
|
topArtist.name,
|
||||||
|
topArtists,
|
||||||
|
4,
|
||||||
|
);
|
||||||
if (artistOptions) {
|
if (artistOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:artist:${topArtist}`,
|
key: `audio:artist:${topArtist.name}`,
|
||||||
subjectKey: `artist:${topArtist}`,
|
subjectKey: `artist:${topArtist.name}`,
|
||||||
|
fairness: getArtistFairness(topArtist, members, history),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which artist shows up most often in the shared audio data?",
|
text: "Which artist shows up most often in the shared audio data?",
|
||||||
|
|
@ -135,17 +145,22 @@ export async function buildAudioMetadataQuestion(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topTracks.length > 0) {
|
if (topTracks.length > 0) {
|
||||||
const topTrackName = topTrackNames[0];
|
const topTrack = topTracks[0];
|
||||||
|
const topTrackName = topTrack?.name;
|
||||||
const trackOptions = topTrackName
|
const trackOptions = topTrackName
|
||||||
? buildOptionsWithCorrect(topTrackName, topTrackNames, 4)
|
? buildOptionsWithCorrect(topTrackName, topTrackNames, 4)
|
||||||
: null;
|
: null;
|
||||||
if (trackOptions) {
|
if (topTrack && trackOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:track:${topTrackName}`,
|
key: `audio:track:${topTrackName}`,
|
||||||
subjectKey: `track:${topTrackName}`,
|
subjectKey: `track:${topTrackName}`,
|
||||||
|
fairness: getTrackFairness(topTrack, members, history),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
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,
|
options: trackOptions,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
|
|
@ -165,6 +180,7 @@ export async function buildAudioMetadataQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:album-artist:${topTrack.albumName}:${correctArtist}`,
|
key: `audio:album-artist:${topTrack.albumName}:${correctArtist}`,
|
||||||
subjectKey: `album:${topTrack.albumName}`,
|
subjectKey: `album:${topTrack.albumName}`,
|
||||||
|
fairness: getTrackFairness(topTrack, members, history),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `Which artist appears on "${topTrack.albumName}"?`,
|
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(
|
const datedTracks = detailedTracks.filter(
|
||||||
(track) => track.album?.release_date,
|
(track) => track.album?.release_date,
|
||||||
);
|
);
|
||||||
|
|
@ -201,6 +217,12 @@ export async function buildAudioMetadataQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:release-first:${earliest.name}`,
|
key: `audio:release-first:${earliest.name}`,
|
||||||
subjectKey: `track:${earliest.name}`,
|
subjectKey: `track:${earliest.name}`,
|
||||||
|
fairness: getTrackFairnessForName(
|
||||||
|
topTracks,
|
||||||
|
earliest.name,
|
||||||
|
members,
|
||||||
|
history,
|
||||||
|
),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which of these tracks came out first?",
|
text: "Which of these tracks came out first?",
|
||||||
|
|
@ -219,6 +241,12 @@ export async function buildAudioMetadataQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:release-last:${latest.name}`,
|
key: `audio:release-last:${latest.name}`,
|
||||||
subjectKey: `track:${latest.name}`,
|
subjectKey: `track:${latest.name}`,
|
||||||
|
fairness: getTrackFairnessForName(
|
||||||
|
topTracks,
|
||||||
|
latest.name,
|
||||||
|
members,
|
||||||
|
history,
|
||||||
|
),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which of these tracks came out most recently?",
|
text: "Which of these tracks came out most recently?",
|
||||||
|
|
@ -257,6 +285,12 @@ export async function buildAudioMetadataQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:artist-longest-track:${artistName}:${longest.name}`,
|
key: `audio:artist-longest-track:${artistName}:${longest.name}`,
|
||||||
subjectKey: `artist:${artistName}`,
|
subjectKey: `artist:${artistName}`,
|
||||||
|
fairness: getArtistFairnessForName(
|
||||||
|
topArtistEntities,
|
||||||
|
artistName,
|
||||||
|
members,
|
||||||
|
history,
|
||||||
|
),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `What's the longest track by ${artistName}?`,
|
text: `What's the longest track by ${artistName}?`,
|
||||||
|
|
@ -288,6 +322,7 @@ export async function buildAudioMetadataQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:performer:${topTrack.name}`,
|
key: `audio:performer:${topTrack.name}`,
|
||||||
subjectKey: `track:${topTrack.name}`,
|
subjectKey: `track:${topTrack.name}`,
|
||||||
|
fairness: getTrackFairness(topTrack, members, history),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `Who performs "${topTrack.name}"?`,
|
text: `Who performs "${topTrack.name}"?`,
|
||||||
|
|
@ -309,6 +344,7 @@ export async function buildAudioMetadataQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:title:${topTrack.name}`,
|
key: `audio:title:${topTrack.name}`,
|
||||||
subjectKey: `track:${topTrack.name}`,
|
subjectKey: `track:${topTrack.name}`,
|
||||||
|
fairness: getTrackFairness(topTrack, members, history),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `What is the name of this track by ${correctArtist}?`,
|
text: `What is the name of this track by ${correctArtist}?`,
|
||||||
|
|
@ -357,6 +393,7 @@ export async function buildAudioMetadataQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:album:${topTrack.albumName}`,
|
key: `audio:album:${topTrack.albumName}`,
|
||||||
subjectKey: `track:${topTrack.name}`,
|
subjectKey: `track:${topTrack.name}`,
|
||||||
|
fairness: getTrackFairness(topTrack, members, history),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `"${topTrack.name}" appears on which album?`,
|
text: `"${topTrack.name}" appears on which album?`,
|
||||||
|
|
@ -378,3 +415,23 @@ export async function buildAudioMetadataQuestion(
|
||||||
if (!question) return null;
|
if (!question) return null;
|
||||||
return buildQuestionWindow(question);
|
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 type { Question, QuizRound } from "../party-types";
|
||||||
import {
|
import {
|
||||||
buildQuestionWindow,
|
buildQuestionWindow,
|
||||||
|
getArtistFairness,
|
||||||
|
getFairQuestionArtists,
|
||||||
|
getFairQuestionTracks,
|
||||||
getReleaseYearRange,
|
getReleaseYearRange,
|
||||||
|
getTrackFairness,
|
||||||
isUsableText,
|
isUsableText,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
|
|
@ -40,13 +44,13 @@ type BuildNumericQuestionInput = {
|
||||||
async function getDetailedTopTracks({
|
async function getDetailedTopTracks({
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
|
members,
|
||||||
|
history,
|
||||||
}: BuildNumericQuestionInput): Promise<TrackDetails[]> {
|
}: BuildNumericQuestionInput): Promise<TrackDetails[]> {
|
||||||
const tracks: TrackDetails[] = [];
|
const tracks: TrackDetails[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const topTrack of analytics?.storyClusters?.flatMap(
|
for (const topTrack of getFairQuestionTracks(analytics, members, history)) {
|
||||||
(cluster) => cluster.tracks ?? [],
|
|
||||||
) ?? []) {
|
|
||||||
if (!isUsableText(topTrack.name)) continue;
|
if (!isUsableText(topTrack.name)) continue;
|
||||||
const dbTracks = (await db.query.track.findMany({
|
const dbTracks = (await db.query.track.findMany({
|
||||||
where: { name: topTrack.name },
|
where: { name: topTrack.name },
|
||||||
|
|
@ -77,8 +81,11 @@ async function getDetailedTopTracks({
|
||||||
async function getAlbumReleaseYear({
|
async function getAlbumReleaseYear({
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
|
members,
|
||||||
|
history,
|
||||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
}: 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
|
const track = trackName
|
||||||
? await db.query.track.findFirst({
|
? await db.query.track.findFirst({
|
||||||
where: { name: trackName },
|
where: { name: trackName },
|
||||||
|
|
@ -102,7 +109,7 @@ async function getAlbumReleaseYear({
|
||||||
points: 10,
|
points: 10,
|
||||||
song: song ?? undefined,
|
song: song ?? undefined,
|
||||||
questionKey: `numeric:album-year:${subject}`,
|
questionKey: `numeric:album-year:${subject}`,
|
||||||
subjectKey: `album:${subject}`,
|
subjectKey: track.name ? `track:${track.name}` : `album:${subject}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,8 +187,10 @@ async function countTopTrackListeners({
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
members,
|
members,
|
||||||
|
history,
|
||||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
}: 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;
|
if (!trackName || members.length === 0) return null;
|
||||||
const dbTrack = await db.query.track.findFirst({
|
const dbTrack = await db.query.track.findFirst({
|
||||||
where: { name: trackName },
|
where: { name: trackName },
|
||||||
|
|
@ -216,8 +225,10 @@ async function countFavouriteArtistListeners({
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
members,
|
members,
|
||||||
|
history,
|
||||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
}: 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;
|
if (!artistName || members.length === 0) return null;
|
||||||
const dbArtist = await db.query.artist.findFirst({
|
const dbArtist = await db.query.artist.findFirst({
|
||||||
where: { name: artistName },
|
where: { name: artistName },
|
||||||
|
|
@ -260,6 +271,7 @@ export async function buildNumericQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: albumYearQ.questionKey ?? `numeric:album-year:${albumYearQ.text}`,
|
key: albumYearQ.questionKey ?? `numeric:album-year:${albumYearQ.text}`,
|
||||||
subjectKey: albumYearQ.subjectKey,
|
subjectKey: albumYearQ.subjectKey,
|
||||||
|
fairness: getNumericQuestionFairness(input, albumYearQ),
|
||||||
question: albumYearQ,
|
question: albumYearQ,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +281,7 @@ export async function buildNumericQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: trackYearQ.questionKey ?? `numeric:track-year:${trackYearQ.text}`,
|
key: trackYearQ.questionKey ?? `numeric:track-year:${trackYearQ.text}`,
|
||||||
subjectKey: trackYearQ.subjectKey,
|
subjectKey: trackYearQ.subjectKey,
|
||||||
|
fairness: getNumericQuestionFairness(input, trackYearQ),
|
||||||
question: trackYearQ,
|
question: trackYearQ,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -280,6 +293,7 @@ export async function buildNumericQuestion(
|
||||||
artistFirstTrackYearQ.questionKey ??
|
artistFirstTrackYearQ.questionKey ??
|
||||||
`numeric:artist-first-track-year:${artistFirstTrackYearQ.text}`,
|
`numeric:artist-first-track-year:${artistFirstTrackYearQ.text}`,
|
||||||
subjectKey: artistFirstTrackYearQ.subjectKey,
|
subjectKey: artistFirstTrackYearQ.subjectKey,
|
||||||
|
fairness: getNumericQuestionFairness(input, artistFirstTrackYearQ),
|
||||||
question: artistFirstTrackYearQ,
|
question: artistFirstTrackYearQ,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -289,6 +303,7 @@ export async function buildNumericQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: topTrackQ.questionKey ?? `numeric:top-track-count:${topTrackQ.text}`,
|
key: topTrackQ.questionKey ?? `numeric:top-track-count:${topTrackQ.text}`,
|
||||||
subjectKey: topTrackQ.subjectKey,
|
subjectKey: topTrackQ.subjectKey,
|
||||||
|
fairness: getNumericQuestionFairness(input, topTrackQ),
|
||||||
question: topTrackQ,
|
question: topTrackQ,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -298,6 +313,7 @@ export async function buildNumericQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: artistQ.questionKey ?? `numeric:artist-count:${artistQ.text}`,
|
key: artistQ.questionKey ?? `numeric:artist-count:${artistQ.text}`,
|
||||||
subjectKey: artistQ.subjectKey,
|
subjectKey: artistQ.subjectKey,
|
||||||
|
fairness: getNumericQuestionFairness(input, artistQ),
|
||||||
question: artistQ,
|
question: artistQ,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -306,3 +322,35 @@ export async function buildNumericQuestion(
|
||||||
if (!question) return null;
|
if (!question) return null;
|
||||||
return buildQuestionWindow(question);
|
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(
|
question = await buildAudioMetadataQuestion(
|
||||||
dbClient,
|
dbClient,
|
||||||
analytics,
|
analytics,
|
||||||
|
members,
|
||||||
index,
|
index,
|
||||||
quizState.history,
|
quizState.history,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ export type PartyAnalytics = {
|
||||||
albumName?: string;
|
albumName?: string;
|
||||||
memberScores?: { userId: string; score: number }[];
|
memberScores?: { userId: string; score: number }[];
|
||||||
}[];
|
}[];
|
||||||
artists?: { name: string }[];
|
artists?: {
|
||||||
|
name: string;
|
||||||
|
memberScores?: { userId: string; score: number }[];
|
||||||
|
}[];
|
||||||
genres?: { name: string }[];
|
genres?: { name: string }[];
|
||||||
}[];
|
}[];
|
||||||
memberProfiles?: { userId: string }[];
|
memberProfiles?: { userId: string }[];
|
||||||
|
|
@ -39,7 +42,10 @@ export type AnalyticsTrack = {
|
||||||
albumName?: string;
|
albumName?: string;
|
||||||
memberScores?: { userId: string; score: number }[];
|
memberScores?: { userId: string; score: number }[];
|
||||||
};
|
};
|
||||||
type AnalyticsArtist = { name: string };
|
export type AnalyticsArtist = {
|
||||||
|
name: string;
|
||||||
|
memberScores?: { userId: string; score: number }[];
|
||||||
|
};
|
||||||
type QuestionLike = {
|
type QuestionLike = {
|
||||||
text: string;
|
text: string;
|
||||||
questionKey?: string;
|
questionKey?: string;
|
||||||
|
|
@ -49,8 +55,15 @@ type QuestionLike = {
|
||||||
export type QuestionCandidate<T extends QuestionLike = QuestionLike> = {
|
export type QuestionCandidate<T extends QuestionLike = QuestionLike> = {
|
||||||
key: string;
|
key: string;
|
||||||
subjectKey?: string;
|
subjectKey?: string;
|
||||||
|
fairness?: QuestionCandidateFairness;
|
||||||
question: T;
|
question: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QuestionCandidateFairness = {
|
||||||
|
memberIds: string[];
|
||||||
|
memberCount: number;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
export type QuestionSong = InferSelectModel<typeof trackTable>;
|
export type QuestionSong = InferSelectModel<typeof trackTable>;
|
||||||
|
|
||||||
export const QUESTION_DURATION_MS = 60_000;
|
export const QUESTION_DURATION_MS = 60_000;
|
||||||
|
|
@ -165,7 +178,22 @@ export function pickQuestionCandidate<T extends QuestionLike>(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fresh.length === 0) return null;
|
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);
|
const candidate = pickRandom(pool);
|
||||||
if (!candidate) return null;
|
if (!candidate) return null;
|
||||||
return {
|
return {
|
||||||
|
|
@ -173,6 +201,7 @@ export function pickQuestionCandidate<T extends QuestionLike>(
|
||||||
questionKey: candidate.question.questionKey ?? candidate.key,
|
questionKey: candidate.question.questionKey ?? candidate.key,
|
||||||
subjectKey:
|
subjectKey:
|
||||||
candidate.question.subjectKey ?? candidate.subjectKey ?? undefined,
|
candidate.question.subjectKey ?? candidate.subjectKey ?? undefined,
|
||||||
|
subjectMemberIds: candidate.fairness?.memberIds,
|
||||||
} as T;
|
} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,13 +210,55 @@ function normalizeQuestionKey(value: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
||||||
return getAllClusterArtists(analytics).map((artist) => artist.name);
|
return getFairQuestionArtists(analytics).map((artist) => artist.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopClusterTracks(
|
export function getTopClusterTracks(
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
): AnalyticsTrack[] {
|
): 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(
|
export function pickRelevantTrack(
|
||||||
|
|
@ -267,6 +338,11 @@ type SongSelectionInput = {
|
||||||
question: Question;
|
question: Question;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SongCandidate = {
|
||||||
|
song: QuestionSong;
|
||||||
|
fairness?: QuestionCandidateFairness;
|
||||||
|
};
|
||||||
|
|
||||||
export async function selectQuestionSong({
|
export async function selectQuestionSong({
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
|
|
@ -285,38 +361,46 @@ export async function selectQuestionSong({
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
members,
|
members,
|
||||||
|
history,
|
||||||
question,
|
question,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (candidates.length === 0) return question.song ?? null;
|
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) =>
|
(candidate) =>
|
||||||
isUsableText(candidate.platform_id) &&
|
isUsableText(candidate.song.platform_id) &&
|
||||||
!usedPlatformIds.has(candidate.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({
|
async function collectSongCandidates({
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
members,
|
members,
|
||||||
|
history,
|
||||||
question,
|
question,
|
||||||
}: {
|
}: {
|
||||||
db: typeof Db;
|
db: typeof Db;
|
||||||
analytics: PartyAnalytics;
|
analytics: PartyAnalytics;
|
||||||
members: PartyQuestionMember[];
|
members: PartyQuestionMember[];
|
||||||
|
history: QuizRound[];
|
||||||
question: Question;
|
question: Question;
|
||||||
}): Promise<QuestionSong[]> {
|
}): Promise<SongCandidate[]> {
|
||||||
const candidates: QuestionSong[] = [];
|
const candidates: SongCandidate[] = [];
|
||||||
const seen = new Set<string>();
|
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 (!song || !isUsableText(song.platform_id)) return;
|
||||||
if (seen.has(song.platform_id)) return;
|
if (seen.has(song.platform_id)) return;
|
||||||
seen.add(song.platform_id);
|
seen.add(song.platform_id);
|
||||||
candidates.push(song);
|
candidates.push({ song, fairness });
|
||||||
};
|
};
|
||||||
|
|
||||||
push(question.song);
|
push(question.song);
|
||||||
|
|
@ -326,32 +410,36 @@ async function collectSongCandidates({
|
||||||
analytics,
|
analytics,
|
||||||
question,
|
question,
|
||||||
);
|
);
|
||||||
push(subjectSong);
|
push(
|
||||||
|
subjectSong,
|
||||||
|
getQuestionSubjectFairness(analytics, members, history, question),
|
||||||
|
);
|
||||||
|
|
||||||
const peopleSong = await resolveSongFromMentionedPeople(
|
const peopleSong = await resolveSongFromMentionedPeople(
|
||||||
db,
|
db,
|
||||||
analytics,
|
analytics,
|
||||||
question,
|
question,
|
||||||
);
|
);
|
||||||
push(peopleSong);
|
push(
|
||||||
|
peopleSong,
|
||||||
const topClusterTracks = getAllClusterTracks(analytics).sort(
|
getQuestionSubjectFairness(analytics, members, history, question),
|
||||||
(a, b) => getTrackScore(b) - getTrackScore(a),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const topClusterTracks = getFairQuestionTracks(analytics, members, history);
|
||||||
|
|
||||||
for (const track of topClusterTracks) {
|
for (const track of topClusterTracks) {
|
||||||
const song = await resolveQuestionSong(db, analytics, {
|
const song = await resolveQuestionSong(db, analytics, {
|
||||||
trackName: track.name,
|
trackName: track.name,
|
||||||
artistNames: track.artists?.map((artist) => artist.name),
|
artistNames: track.artists?.map((artist) => artist.name),
|
||||||
albumName: track.albumName,
|
albumName: track.albumName,
|
||||||
});
|
});
|
||||||
push(song);
|
push(song, getTrackFairness(track, members, history));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (members.length > 0) {
|
if (members.length > 0) {
|
||||||
const topPartySongs = await fetchPartyTopSongs(db, members);
|
const topPartySongs = await fetchPartyTopSongs(db, members, history);
|
||||||
for (const song of topPartySongs) {
|
for (const candidate of topPartySongs) {
|
||||||
push(song);
|
push(candidate.song, candidate.fairness);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -416,11 +504,13 @@ async function resolveSongFromMentionedPeople(
|
||||||
async function fetchPartyTopSongs(
|
async function fetchPartyTopSongs(
|
||||||
db: typeof Db,
|
db: typeof Db,
|
||||||
members: PartyQuestionMember[],
|
members: PartyQuestionMember[],
|
||||||
): Promise<QuestionSong[]> {
|
history: QuizRound[],
|
||||||
const songs: QuestionSong[] = [];
|
): Promise<SongCandidate[]> {
|
||||||
|
const songsByMember: SongCandidate[][] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
|
const memberSongs: SongCandidate[] = [];
|
||||||
const rows = await db.query.topTrack.findMany({
|
const rows = await db.query.topTrack.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: member.userId,
|
userId: member.userId,
|
||||||
|
|
@ -444,11 +534,78 @@ async function fetchPartyTopSongs(
|
||||||
if (!song || !isUsableText(song.platform_id)) continue;
|
if (!song || !isUsableText(song.platform_id)) continue;
|
||||||
if (seen.has(song.platform_id)) continue;
|
if (seen.has(song.platform_id)) continue;
|
||||||
seen.add(song.platform_id);
|
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 {
|
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(
|
function getMemberTrackScore(
|
||||||
track: { memberScores?: { userId: string; score: number }[] },
|
track: { memberScores?: { userId: string; score: number }[] },
|
||||||
userIds: string[],
|
userIds: string[],
|
||||||
|
|
@ -478,6 +628,52 @@ function getMemberTrackScore(
|
||||||
}, 0);
|
}, 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[] {
|
function getAllClusterTracks(analytics: PartyAnalytics): AnalyticsTrack[] {
|
||||||
const tracks: AnalyticsTrack[] = [];
|
const tracks: AnalyticsTrack[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@ import {
|
||||||
buildMemberPairOptions,
|
buildMemberPairOptions,
|
||||||
buildQuestionWindow,
|
buildQuestionWindow,
|
||||||
getCurrentLeader,
|
getCurrentLeader,
|
||||||
|
getFairQuestionTracks,
|
||||||
getMostDiverseMember,
|
getMostDiverseMember,
|
||||||
getTopClusterTracks,
|
|
||||||
getTopTrackListener,
|
getTopTrackListener,
|
||||||
|
getTrackFairness,
|
||||||
hasClearLeader,
|
hasClearLeader,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
|
|
@ -70,9 +71,24 @@ export async function buildSocialQuestion(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const topTracks = getTopClusterTracks(analytics);
|
const topTracks = getFairQuestionTracks(
|
||||||
|
analytics,
|
||||||
|
members,
|
||||||
|
quizState.history,
|
||||||
|
);
|
||||||
if (hasMultipleMembers) {
|
if (hasMultipleMembers) {
|
||||||
for (const topTrack of topTracks) {
|
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);
|
const topListener = getTopTrackListener(topTrack, members);
|
||||||
if (!topListener) continue;
|
if (!topListener) continue;
|
||||||
|
|
||||||
|
|
@ -86,6 +102,7 @@ export async function buildSocialQuestion(
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `social:track-listener:${topTrack.name}`,
|
key: `social:track-listener:${topTrack.name}`,
|
||||||
subjectKey: `track:${topTrack.name}`,
|
subjectKey: `track:${topTrack.name}`,
|
||||||
|
fairness,
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `Who listens the most to "${topTrack.name}"?`,
|
text: `Who listens the most to "${topTrack.name}"?`,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue