improve random
This commit is contained in:
parent
1cd55a6d52
commit
cd2ec07314
6 changed files with 341 additions and 69 deletions
|
|
@ -261,6 +261,53 @@ describe("question generation", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("randomizes among top-tier candidates instead of only the highest score", () => {
|
||||||
|
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.99);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const question = pickQuestionCandidate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
key: "audio:track:highest",
|
||||||
|
subjectKey: "track:highest",
|
||||||
|
fairness: { memberIds: ["a", "b"], memberCount: 2, score: 100 },
|
||||||
|
question: makeChoiceQuestion(
|
||||||
|
"Highest question",
|
||||||
|
"audio:track:highest",
|
||||||
|
"track:highest",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "audio:track:middle",
|
||||||
|
subjectKey: "track:middle",
|
||||||
|
fairness: { memberIds: ["a", "b"], memberCount: 2, score: 50 },
|
||||||
|
question: makeChoiceQuestion(
|
||||||
|
"Middle question",
|
||||||
|
"audio:track:middle",
|
||||||
|
"track:middle",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "audio:track:lower",
|
||||||
|
subjectKey: "track:lower",
|
||||||
|
fairness: { memberIds: ["a", "b"], memberCount: 2, score: 25 },
|
||||||
|
question: makeChoiceQuestion(
|
||||||
|
"Lower question",
|
||||||
|
"audio:track:lower",
|
||||||
|
"track:lower",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(question?.subjectKey).toBe("track:lower");
|
||||||
|
} finally {
|
||||||
|
randomSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("orders fair tracks by party coverage before score", () => {
|
it("orders fair tracks by party coverage before score", () => {
|
||||||
const members: PartyQuestionMember[] = [
|
const members: PartyQuestionMember[] = [
|
||||||
{ userId: "a", name: "A" },
|
{ userId: "a", name: "A" },
|
||||||
|
|
@ -495,13 +542,40 @@ describe("question generation", () => {
|
||||||
expect(question?.type).toBe("choice");
|
expect(question?.type).toBe("choice");
|
||||||
if (question?.type === "choice") {
|
if (question?.type === "choice") {
|
||||||
expect(question.options).toHaveLength(2);
|
expect(question.options).toHaveLength(2);
|
||||||
expect(question.text).toContain("Shared Track Two");
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
randomSpy.mockRestore();
|
randomSpy.mockRestore();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds metadata questions for non-top genres", async () => {
|
||||||
|
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.99);
|
||||||
|
const db = createFakeDb(null);
|
||||||
|
const analytics = {
|
||||||
|
storyClusters: [],
|
||||||
|
groupSummary: {
|
||||||
|
mostSharedGenres: [{ name: "pop" }, { name: "rock" }, { name: "jazz" }],
|
||||||
|
},
|
||||||
|
} as PartyAnalytics;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const question = await buildAudioMetadataQuestion(
|
||||||
|
db,
|
||||||
|
analytics,
|
||||||
|
[],
|
||||||
|
0,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(question?.questionKey).toBe("audio:genre:jazz");
|
||||||
|
expect(question?.text).toBe(
|
||||||
|
"Which of these genres appears in the party analytics?",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
randomSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("selects a fresh party song when the current one was already used", async () => {
|
it("selects a fresh party song when the current one was already used", async () => {
|
||||||
const db = createSongFallbackDb([
|
const db = createSongFallbackDb([
|
||||||
makeSong("track-1", "spotify:track:one", "One"),
|
makeSong("track-1", "spotify:track:one", "One"),
|
||||||
|
|
@ -606,6 +680,35 @@ describe("question generation", () => {
|
||||||
expect(song?.platform_id).toBe("spotify:track:two");
|
expect(song?.platform_id).toBe("spotify:track:two");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers the referenced song for non-social subject questions", async () => {
|
||||||
|
const db = createSongFallbackDb([
|
||||||
|
makeSong("track-1", "spotify:track:one", "One"),
|
||||||
|
makeSong("track-2", "spotify:track:two", "Two"),
|
||||||
|
]);
|
||||||
|
const question = {
|
||||||
|
type: "choice" as const,
|
||||||
|
text: 'Who performs "One"?',
|
||||||
|
correct: 0,
|
||||||
|
startTimestamp: 1,
|
||||||
|
endTimestamp: 2,
|
||||||
|
points: 10,
|
||||||
|
options: ["A", "B"],
|
||||||
|
questionKey: "audio:performer:One",
|
||||||
|
subjectKey: "track:One",
|
||||||
|
song: makeSong("track-1", "spotify:track:one", "One"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const song = await selectQuestionSong({
|
||||||
|
db,
|
||||||
|
analytics: null,
|
||||||
|
members: [{ userId: "a", name: "A" }],
|
||||||
|
history: [],
|
||||||
|
question,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(song?.platform_id).toBe("spotify:track:one");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps album questions on the referenced track", async () => {
|
it("keeps album questions on the referenced track", async () => {
|
||||||
const db = createSongFallbackDb([
|
const db = createSongFallbackDb([
|
||||||
makeSong("track-1", "spotify:track:one", "One"),
|
makeSong("track-1", "spotify:track:one", "One"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { QuizState } from "../../party-types";
|
import type { QuizState } from "../../party-types";
|
||||||
import * as audioQuestionGenerator from "../audio-question-generator";
|
import * as audioQuestionGenerator from "../audio-question-generator";
|
||||||
|
import * as socialQuestionGenerator from "../social-question-generator";
|
||||||
|
|
||||||
vi.mock("../audio-question-generator", () => ({
|
vi.mock("../audio-question-generator", () => ({
|
||||||
buildAudioMetadataQuestion: vi.fn(async () => null),
|
buildAudioMetadataQuestion: vi.fn(async () => null),
|
||||||
|
|
@ -22,11 +23,24 @@ function createFakeDb() {
|
||||||
partyMember: {
|
partyMember: {
|
||||||
findMany: vi.fn(async () => [{ userId: "a", user: { name: "A" } }]),
|
findMany: vi.fn(async () => [{ userId: "a", user: { name: "A" } }]),
|
||||||
},
|
},
|
||||||
|
topTrack: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockResolvedQuestion(fn: unknown, question: unknown) {
|
||||||
|
(
|
||||||
|
fn as { mockResolvedValueOnce: (value: unknown) => void }
|
||||||
|
).mockResolvedValueOnce(question);
|
||||||
|
}
|
||||||
|
|
||||||
describe("generatePartyQuestion", () => {
|
describe("generatePartyQuestion", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns null when all real question sources are exhausted", async () => {
|
it("returns null when all real question sources are exhausted", async () => {
|
||||||
const quizState = {
|
const quizState = {
|
||||||
status: "running",
|
status: "running",
|
||||||
|
|
@ -50,9 +64,7 @@ describe("generatePartyQuestion", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("attaches a fallback song to generated questions", async () => {
|
it("attaches a fallback song to generated questions", async () => {
|
||||||
vi.mocked(
|
mockResolvedQuestion(audioQuestionGenerator.buildAudioMetadataQuestion, {
|
||||||
audioQuestionGenerator.buildAudioMetadataQuestion,
|
|
||||||
).mockResolvedValueOnce({
|
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which genre appears most in the party analytics?",
|
text: "Which genre appears most in the party analytics?",
|
||||||
correct: 0,
|
correct: 0,
|
||||||
|
|
@ -103,4 +115,61 @@ describe("generatePartyQuestion", () => {
|
||||||
|
|
||||||
expect(question?.song?.platform_id).toBe("spotify:track:one");
|
expect(question?.song?.platform_id).toBe("spotify:track:one");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers metadata questions over social questions when available", async () => {
|
||||||
|
mockResolvedQuestion(audioQuestionGenerator.buildAudioMetadataQuestion, {
|
||||||
|
type: "choice",
|
||||||
|
text: "Which track appears in the party analytics?",
|
||||||
|
correct: 0,
|
||||||
|
startTimestamp: 1,
|
||||||
|
endTimestamp: 2,
|
||||||
|
points: 10,
|
||||||
|
options: ["A", "B"],
|
||||||
|
questionKey: "audio:track:A",
|
||||||
|
subjectKey: "track:A",
|
||||||
|
});
|
||||||
|
mockResolvedQuestion(socialQuestionGenerator.buildSocialQuestion, {
|
||||||
|
type: "choice",
|
||||||
|
text: "Who is leading the quiz right now?",
|
||||||
|
correct: 0,
|
||||||
|
startTimestamp: 1,
|
||||||
|
endTimestamp: 2,
|
||||||
|
points: 10,
|
||||||
|
options: ["A", "B"],
|
||||||
|
questionKey: "social:leader",
|
||||||
|
subjectKey: "member:a",
|
||||||
|
});
|
||||||
|
const randomSpy = vi
|
||||||
|
.spyOn(Math, "random")
|
||||||
|
.mockReturnValueOnce(0.1)
|
||||||
|
.mockReturnValueOnce(0.9)
|
||||||
|
.mockReturnValueOnce(0.2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quizState = {
|
||||||
|
status: "running",
|
||||||
|
workflowId: null,
|
||||||
|
questionIndex: 0,
|
||||||
|
currentQuestion: null,
|
||||||
|
answers: {},
|
||||||
|
scores: {},
|
||||||
|
history: [],
|
||||||
|
} as QuizState;
|
||||||
|
|
||||||
|
const question = await generatePartyQuestion({
|
||||||
|
db: createFakeDb() as never,
|
||||||
|
partyId: "party-1",
|
||||||
|
quizState,
|
||||||
|
analytics: null,
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(question?.questionKey).toBe("audio:track:A");
|
||||||
|
expect(
|
||||||
|
socialQuestionGenerator.buildSocialQuestion,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
randomSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
resolveQuestionSong,
|
resolveQuestionSong,
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
|
const METADATA_ENTITY_POOL_SIZE = 8;
|
||||||
|
|
||||||
type TrackDetails = {
|
type TrackDetails = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
|
@ -98,16 +100,18 @@ export async function buildAudioMetadataQuestion(
|
||||||
|
|
||||||
const genreNames = buildOrderedOptions(getMostSharedGenreNames(analytics), 4);
|
const genreNames = buildOrderedOptions(getMostSharedGenreNames(analytics), 4);
|
||||||
if (genreNames) {
|
if (genreNames) {
|
||||||
const topGenre = genreNames[0];
|
for (const genre of genreNames.slice(0, METADATA_ENTITY_POOL_SIZE)) {
|
||||||
if (topGenre) {
|
const genreOptions = buildOptionsWithCorrect(genre, genreNames, 4);
|
||||||
const genreOptions = buildOptionsWithCorrect(topGenre, genreNames, 4);
|
|
||||||
if (genreOptions) {
|
if (genreOptions) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:genre:${topGenre}`,
|
key: `audio:genre:${genre}`,
|
||||||
subjectKey: `genre:${topGenre}`,
|
subjectKey: `genre:${genre}`,
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which genre appears most in the party analytics?",
|
text:
|
||||||
|
genre === genreNames[0]
|
||||||
|
? "Which genre appears most in the party analytics?"
|
||||||
|
: "Which of these genres appears in the party analytics?",
|
||||||
options: genreOptions.options,
|
options: genreOptions.options,
|
||||||
correct: genreOptions.correct,
|
correct: genreOptions.correct,
|
||||||
points: 10,
|
points: 10,
|
||||||
|
|
@ -120,8 +124,10 @@ export async function buildAudioMetadataQuestion(
|
||||||
|
|
||||||
const topArtistEntities = getFairQuestionArtists(analytics, members, history);
|
const topArtistEntities = getFairQuestionArtists(analytics, members, history);
|
||||||
const topArtists = topArtistEntities.map((artist) => artist.name);
|
const topArtists = topArtistEntities.map((artist) => artist.name);
|
||||||
const topArtist = topArtistEntities[0];
|
for (const topArtist of topArtistEntities.slice(
|
||||||
if (topArtist) {
|
0,
|
||||||
|
METADATA_ENTITY_POOL_SIZE,
|
||||||
|
)) {
|
||||||
const artistOptions = buildOptionsWithCorrect(
|
const artistOptions = buildOptionsWithCorrect(
|
||||||
topArtist.name,
|
topArtist.name,
|
||||||
topArtists,
|
topArtists,
|
||||||
|
|
@ -134,7 +140,10 @@ export async function buildAudioMetadataQuestion(
|
||||||
fairness: getArtistFairness(topArtist, members, history),
|
fairness: getArtistFairness(topArtist, members, history),
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which artist shows up most often in the shared audio data?",
|
text:
|
||||||
|
topArtist === topArtistEntities[0]
|
||||||
|
? "Which artist shows up most often in the shared audio data?"
|
||||||
|
: "Which artist shows up in the shared audio data?",
|
||||||
options: artistOptions.options,
|
options: artistOptions.options,
|
||||||
correct: artistOptions.correct,
|
correct: artistOptions.correct,
|
||||||
points: 10,
|
points: 10,
|
||||||
|
|
@ -144,8 +153,7 @@ export async function buildAudioMetadataQuestion(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (topTracks.length > 0) {
|
for (const topTrack of topTracks.slice(0, METADATA_ENTITY_POOL_SIZE)) {
|
||||||
const topTrack = topTracks[0];
|
|
||||||
const topTrackName = topTrack?.name;
|
const topTrackName = topTrack?.name;
|
||||||
const trackOptions = topTrackName
|
const trackOptions = topTrackName
|
||||||
? buildOptionsWithCorrect(topTrackName, topTrackNames, 4)
|
? buildOptionsWithCorrect(topTrackName, topTrackNames, 4)
|
||||||
|
|
@ -158,9 +166,10 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text:
|
text:
|
||||||
|
topTrack === topTracks[0] &&
|
||||||
getTrackFairness(topTrack, members, history).memberCount > 1
|
getTrackFairness(topTrack, members, history).memberCount > 1
|
||||||
? "Which track looks most shared across the party?"
|
? "Which track looks most shared across the party?"
|
||||||
: "Which track stands out in the party analytics?",
|
: "Which track appears in the party analytics?",
|
||||||
options: trackOptions.options,
|
options: trackOptions.options,
|
||||||
correct: trackOptions.correct,
|
correct: trackOptions.correct,
|
||||||
points: 10,
|
points: 10,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
pickQuestionCandidate,
|
pickQuestionCandidate,
|
||||||
|
pickRandomTop,
|
||||||
type QuestionCandidate,
|
type QuestionCandidate,
|
||||||
resolveQuestionSong,
|
resolveQuestionSong,
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
@ -84,7 +85,9 @@ async function getAlbumReleaseYear({
|
||||||
members,
|
members,
|
||||||
history,
|
history,
|
||||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||||
const topTrack = getFairQuestionTracks(analytics, members, history)[0];
|
const topTrack = pickRandomTop(
|
||||||
|
getFairQuestionTracks(analytics, members, history),
|
||||||
|
);
|
||||||
const trackName = topTrack?.name;
|
const trackName = topTrack?.name;
|
||||||
const track = trackName
|
const track = trackName
|
||||||
? await db.query.track.findFirst({
|
? await db.query.track.findFirst({
|
||||||
|
|
@ -117,7 +120,9 @@ async function getTrackReleaseYear(
|
||||||
input: BuildNumericQuestionInput,
|
input: BuildNumericQuestionInput,
|
||||||
): Promise<NumericQuestion | null> {
|
): Promise<NumericQuestion | null> {
|
||||||
const tracks = await getDetailedTopTracks(input);
|
const tracks = await getDetailedTopTracks(input);
|
||||||
const track = tracks.find((track) => track.album?.release_date && track.name);
|
const track = pickRandomTop(
|
||||||
|
tracks.filter((track) => track.album?.release_date && track.name),
|
||||||
|
);
|
||||||
if (!track?.name || !track.album?.release_date) return null;
|
if (!track?.name || !track.album?.release_date) return null;
|
||||||
const song = await resolveQuestionSong(input.db, input.analytics, {
|
const song = await resolveQuestionSong(input.db, input.analytics, {
|
||||||
trackName: track.name,
|
trackName: track.name,
|
||||||
|
|
@ -153,8 +158,10 @@ async function getArtistFirstTrackReleaseYear(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const artistEntry = Array.from(tracksByArtist.entries()).find(
|
const artistEntry = pickRandomTop(
|
||||||
([, artistTracks]) => artistTracks.length >= 2,
|
Array.from(tracksByArtist.entries()).filter(
|
||||||
|
([, artistTracks]) => artistTracks.length >= 2,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (!artistEntry) return null;
|
if (!artistEntry) return null;
|
||||||
const [artistName, artistTracks] = artistEntry;
|
const [artistName, artistTracks] = artistEntry;
|
||||||
|
|
@ -189,7 +196,9 @@ async function countTopTrackListeners({
|
||||||
members,
|
members,
|
||||||
history,
|
history,
|
||||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||||
const topTrack = getFairQuestionTracks(analytics, members, history)[0];
|
const topTrack = pickRandomTop(
|
||||||
|
getFairQuestionTracks(analytics, members, history),
|
||||||
|
);
|
||||||
const trackName = topTrack?.name;
|
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({
|
||||||
|
|
@ -227,7 +236,9 @@ async function countFavouriteArtistListeners({
|
||||||
members,
|
members,
|
||||||
history,
|
history,
|
||||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||||
const topArtist = getFairQuestionArtists(analytics, members, history)[0];
|
const topArtist = pickRandomTop(
|
||||||
|
getFairQuestionArtists(analytics, members, history),
|
||||||
|
);
|
||||||
const artistName = topArtist?.name;
|
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({
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { db } from "../db";
|
import type { db } from "../db";
|
||||||
import type { Question, QuizState } from "../party-types";
|
import type { Question, QuizRound, QuizState } from "../party-types";
|
||||||
import { buildAudioMetadataQuestion } from "./audio-question-generator";
|
import { buildAudioMetadataQuestion } from "./audio-question-generator";
|
||||||
import { buildNumericQuestion } from "./numeric-question-generator";
|
import { buildNumericQuestion } from "./numeric-question-generator";
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,16 +27,10 @@ export async function generatePartyQuestion({
|
||||||
index,
|
index,
|
||||||
}: GenerateQuestionInput): Promise<Question | null> {
|
}: GenerateQuestionInput): Promise<Question | null> {
|
||||||
const members = await fetchPartyMembers(dbClient, partyId);
|
const members = await fetchPartyMembers(dbClient, partyId);
|
||||||
const preferredOrder: PartyQuestionType[] = [
|
const typeOrder = getRandomQuestionTypeOrder(
|
||||||
"audio-metadata",
|
["audio-metadata", "social", "numeric"],
|
||||||
"social",
|
quizState.history,
|
||||||
"numeric",
|
);
|
||||||
];
|
|
||||||
const rotation = index % preferredOrder.length;
|
|
||||||
const typeOrder = [
|
|
||||||
...preferredOrder.slice(rotation),
|
|
||||||
...preferredOrder.slice(0, rotation),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const type of typeOrder) {
|
for (const type of typeOrder) {
|
||||||
let question: Question | null = null;
|
let question: Question | null = null;
|
||||||
|
|
@ -82,3 +76,38 @@ export async function generatePartyQuestion({
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRandomQuestionTypeOrder(
|
||||||
|
types: PartyQuestionType[],
|
||||||
|
history: QuizRound[],
|
||||||
|
): PartyQuestionType[] {
|
||||||
|
const recentTypes = history
|
||||||
|
.slice(-3)
|
||||||
|
.map((round) => getQuestionTypeFromKey(round.question.questionKey));
|
||||||
|
|
||||||
|
return types
|
||||||
|
.map((type) => ({
|
||||||
|
type,
|
||||||
|
score:
|
||||||
|
getQuestionTypeBaseWeight(type) +
|
||||||
|
Math.random() * 0.35 -
|
||||||
|
recentTypes.filter((recent) => recent === type).length * 0.45,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map((entry) => entry.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionTypeBaseWeight(type: PartyQuestionType): number {
|
||||||
|
if (type === "audio-metadata") return 1;
|
||||||
|
if (type === "numeric") return 0.55;
|
||||||
|
return 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionTypeFromKey(
|
||||||
|
questionKey: string | undefined,
|
||||||
|
): PartyQuestionType | null {
|
||||||
|
if (questionKey?.startsWith("audio:")) return "audio-metadata";
|
||||||
|
if (questionKey?.startsWith("social:")) return "social";
|
||||||
|
if (questionKey?.startsWith("numeric:")) return "numeric";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,9 @@ export type QuestionCandidateFairness = {
|
||||||
};
|
};
|
||||||
export type QuestionSong = InferSelectModel<typeof trackTable>;
|
export type QuestionSong = InferSelectModel<typeof trackTable>;
|
||||||
|
|
||||||
|
const RANDOM_TOP_TIER_SIZE = 5;
|
||||||
|
const MAX_RANDOM_WEIGHT = 100;
|
||||||
|
|
||||||
export const QUESTION_DURATION_MS = 60_000;
|
export const QUESTION_DURATION_MS = 60_000;
|
||||||
export const MIN_PARTY_SIZE = 2;
|
export const MIN_PARTY_SIZE = 2;
|
||||||
export const MAX_PARTY_SIZE = 4;
|
export const MAX_PARTY_SIZE = 4;
|
||||||
|
|
@ -178,23 +181,12 @@ export function pickQuestionCandidate<T extends QuestionLike>(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fresh.length === 0) return null;
|
if (fresh.length === 0) return null;
|
||||||
const bestMemberCount = Math.max(
|
const candidate = pickWeightedRandom(
|
||||||
...fresh.map((candidate) => candidate.fairness?.memberCount ?? 0),
|
fresh.map((candidate) => ({
|
||||||
|
item: candidate,
|
||||||
|
weight: getQuestionCandidateWeight(candidate),
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
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;
|
if (!candidate) return null;
|
||||||
return {
|
return {
|
||||||
...candidate.question,
|
...candidate.question,
|
||||||
|
|
@ -394,15 +386,22 @@ export async function selectQuestionSong({
|
||||||
)
|
)
|
||||||
: candidates;
|
: candidates;
|
||||||
|
|
||||||
|
const allFreshCandidates = candidates.filter(
|
||||||
|
(candidate) =>
|
||||||
|
isUsableText(candidate.song.platform_id) &&
|
||||||
|
!usedPlatformIds.has(candidate.song.platform_id),
|
||||||
|
);
|
||||||
const freshCandidates = adjacentCandidates.filter(
|
const freshCandidates = adjacentCandidates.filter(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
isUsableText(candidate.song.platform_id) &&
|
isUsableText(candidate.song.platform_id) &&
|
||||||
!usedPlatformIds.has(candidate.song.platform_id),
|
!usedPlatformIds.has(candidate.song.platform_id),
|
||||||
);
|
);
|
||||||
const selected =
|
const selected = shouldPreferQuestionSubjectSong(question)
|
||||||
pickFairSongCandidate(freshCandidates) ??
|
? (pickRelevantSongCandidate(question, allFreshCandidates) ??
|
||||||
pickFairSongCandidate(adjacentCandidates) ??
|
pickRelevantSongCandidate(question, candidates))
|
||||||
pickFairSongCandidate(candidates);
|
: (pickFairSongCandidate(freshCandidates) ??
|
||||||
|
pickFairSongCandidate(adjacentCandidates) ??
|
||||||
|
pickFairSongCandidate(candidates));
|
||||||
return selected?.song ?? question.song ?? null;
|
return selected?.song ?? question.song ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,23 +591,29 @@ function pickFairSongCandidate(
|
||||||
candidates: SongCandidate[],
|
candidates: SongCandidate[],
|
||||||
): SongCandidate | null {
|
): SongCandidate | null {
|
||||||
if (candidates.length === 0) return null;
|
if (candidates.length === 0) return null;
|
||||||
const bestMemberCount = Math.max(
|
return pickWeightedRandom(
|
||||||
...candidates.map((candidate) => candidate.fairness?.memberCount ?? 0),
|
candidates.map((candidate) => ({
|
||||||
|
item: candidate,
|
||||||
|
weight: getQuestionCandidateFairnessWeight(candidate.fairness),
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
const bestScore = Math.max(
|
}
|
||||||
...candidates
|
|
||||||
.filter(
|
function pickRelevantSongCandidate(
|
||||||
|
question: Question,
|
||||||
|
candidates: SongCandidate[],
|
||||||
|
): SongCandidate | null {
|
||||||
|
if (!shouldPreferQuestionSubjectSong(question)) {
|
||||||
|
return pickFairSongCandidate(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
pickFairSongCandidate(
|
||||||
|
candidates.filter(
|
||||||
(candidate) =>
|
(candidate) =>
|
||||||
(candidate.fairness?.memberCount ?? 0) === bestMemberCount,
|
candidate.source === "subject" || candidate.source === "question",
|
||||||
)
|
),
|
||||||
.map((candidate) => candidate.fairness?.score ?? 0),
|
) ?? pickFairSongCandidate(candidates)
|
||||||
);
|
|
||||||
return pickRandom(
|
|
||||||
candidates.filter(
|
|
||||||
(candidate) =>
|
|
||||||
(candidate.fairness?.memberCount ?? 0) === bestMemberCount &&
|
|
||||||
(candidate.fairness?.score ?? 0) === bestScore,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -651,6 +656,13 @@ function shouldUseQuestionSubjectSong(question: Question): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldPreferQuestionSubjectSong(question: Question): boolean {
|
||||||
|
const key = question.questionKey?.toLowerCase() ?? "";
|
||||||
|
const subjectKey = question.subjectKey?.toLowerCase() ?? "";
|
||||||
|
if (key.startsWith("social:")) return false;
|
||||||
|
return subjectKey.startsWith("track:") || subjectKey.startsWith("artist:");
|
||||||
|
}
|
||||||
|
|
||||||
function getMemberTrackScore(
|
function getMemberTrackScore(
|
||||||
track: { memberScores?: { userId: string; score: number }[] },
|
track: { memberScores?: { userId: string; score: number }[] },
|
||||||
userIds: string[],
|
userIds: string[],
|
||||||
|
|
@ -807,6 +819,45 @@ export function pickRandom<T>(items: T[]): T | null {
|
||||||
return items[index] ?? null;
|
return items[index] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pickRandomTop<T>(
|
||||||
|
items: T[],
|
||||||
|
limit = RANDOM_TOP_TIER_SIZE,
|
||||||
|
): T | null {
|
||||||
|
return pickRandom(items.slice(0, Math.max(1, limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickWeightedRandom<T>(
|
||||||
|
items: Array<{ item: T; weight: number }>,
|
||||||
|
): T | null {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
const weightedItems = items
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Math.max(1, b.weight) - Math.max(1, a.weight));
|
||||||
|
const totalWeight = weightedItems.reduce(
|
||||||
|
(total, entry) => total + Math.max(1, entry.weight),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
let target = Math.random() * totalWeight;
|
||||||
|
for (const entry of weightedItems) {
|
||||||
|
target -= Math.max(1, entry.weight);
|
||||||
|
if (target <= 0) return entry.item;
|
||||||
|
}
|
||||||
|
return weightedItems.at(-1)?.item ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionCandidateWeight(candidate: QuestionCandidate): number {
|
||||||
|
return getQuestionCandidateFairnessWeight(candidate.fairness);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQuestionCandidateFairnessWeight(
|
||||||
|
fairness: QuestionCandidateFairness | undefined,
|
||||||
|
): number {
|
||||||
|
if (!fairness) return 8;
|
||||||
|
const memberCoverageWeight = 8 + fairness.memberCount * 20;
|
||||||
|
const scoreWeight = Math.max(0, Math.min(MAX_RANDOM_WEIGHT, fairness.score));
|
||||||
|
return memberCoverageWeight + scoreWeight / 20;
|
||||||
|
}
|
||||||
|
|
||||||
function getAvailableOptionCount(
|
function getAvailableOptionCount(
|
||||||
availableCount: number,
|
availableCount: number,
|
||||||
desiredCount: number,
|
desiredCount: number,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue