Compare commits
2 commits
ac506795c6
...
cd2ec07314
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd2ec07314 | ||
|
|
1cd55a6d52 |
10 changed files with 567 additions and 121 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", () => {
|
||||
const members: PartyQuestionMember[] = [
|
||||
{ userId: "a", name: "A" },
|
||||
|
|
@ -495,13 +542,40 @@ describe("question generation", () => {
|
|||
expect(question?.type).toBe("choice");
|
||||
if (question?.type === "choice") {
|
||||
expect(question.options).toHaveLength(2);
|
||||
expect(question.text).toContain("Shared Track Two");
|
||||
}
|
||||
} finally {
|
||||
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 () => {
|
||||
const db = createSongFallbackDb([
|
||||
makeSong("track-1", "spotify:track:one", "One"),
|
||||
|
|
@ -576,4 +650,91 @@ describe("question generation", () => {
|
|||
|
||||
expect(song?.platform_id).toBe("spotify:track:one");
|
||||
});
|
||||
|
||||
it("uses an adjacent song for generic metadata 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: "Which genre appears most in the party analytics?",
|
||||
correct: 0,
|
||||
startTimestamp: 1,
|
||||
endTimestamp: 2,
|
||||
points: 10,
|
||||
options: ["A", "B"],
|
||||
questionKey: "audio:genre:pop",
|
||||
subjectKey: "genre:pop",
|
||||
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: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 () => {
|
||||
const db = createSongFallbackDb([
|
||||
makeSong("track-1", "spotify:track:one", "One"),
|
||||
makeSong("track-2", "spotify:track:two", "Two"),
|
||||
]);
|
||||
const question = {
|
||||
type: "choice" as const,
|
||||
text: '"One" appears on which album?',
|
||||
correct: 0,
|
||||
startTimestamp: 1,
|
||||
endTimestamp: 2,
|
||||
points: 10,
|
||||
options: ["A", "B"],
|
||||
questionKey: "audio:album:One Album",
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 * as audioQuestionGenerator from "../audio-question-generator";
|
||||
import * as socialQuestionGenerator from "../social-question-generator";
|
||||
|
||||
vi.mock("../audio-question-generator", () => ({
|
||||
buildAudioMetadataQuestion: vi.fn(async () => null),
|
||||
|
|
@ -22,11 +23,24 @@ function createFakeDb() {
|
|||
partyMember: {
|
||||
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", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when all real question sources are exhausted", async () => {
|
||||
const quizState = {
|
||||
status: "running",
|
||||
|
|
@ -50,9 +64,7 @@ describe("generatePartyQuestion", () => {
|
|||
});
|
||||
|
||||
it("attaches a fallback song to generated questions", async () => {
|
||||
vi.mocked(
|
||||
audioQuestionGenerator.buildAudioMetadataQuestion,
|
||||
).mockResolvedValueOnce({
|
||||
mockResolvedQuestion(audioQuestionGenerator.buildAudioMetadataQuestion, {
|
||||
type: "choice",
|
||||
text: "Which genre appears most in the party analytics?",
|
||||
correct: 0,
|
||||
|
|
@ -103,4 +115,61 @@ describe("generatePartyQuestion", () => {
|
|||
|
||||
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,
|
||||
} from "./question-utils";
|
||||
|
||||
const METADATA_ENTITY_POOL_SIZE = 8;
|
||||
|
||||
type TrackDetails = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
|
|
@ -98,16 +100,18 @@ export async function buildAudioMetadataQuestion(
|
|||
|
||||
const genreNames = buildOrderedOptions(getMostSharedGenreNames(analytics), 4);
|
||||
if (genreNames) {
|
||||
const topGenre = genreNames[0];
|
||||
if (topGenre) {
|
||||
const genreOptions = buildOptionsWithCorrect(topGenre, genreNames, 4);
|
||||
for (const genre of genreNames.slice(0, METADATA_ENTITY_POOL_SIZE)) {
|
||||
const genreOptions = buildOptionsWithCorrect(genre, genreNames, 4);
|
||||
if (genreOptions) {
|
||||
questions.push({
|
||||
key: `audio:genre:${topGenre}`,
|
||||
subjectKey: `genre:${topGenre}`,
|
||||
key: `audio:genre:${genre}`,
|
||||
subjectKey: `genre:${genre}`,
|
||||
question: {
|
||||
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,
|
||||
correct: genreOptions.correct,
|
||||
points: 10,
|
||||
|
|
@ -120,8 +124,10 @@ export async function buildAudioMetadataQuestion(
|
|||
|
||||
const topArtistEntities = getFairQuestionArtists(analytics, members, history);
|
||||
const topArtists = topArtistEntities.map((artist) => artist.name);
|
||||
const topArtist = topArtistEntities[0];
|
||||
if (topArtist) {
|
||||
for (const topArtist of topArtistEntities.slice(
|
||||
0,
|
||||
METADATA_ENTITY_POOL_SIZE,
|
||||
)) {
|
||||
const artistOptions = buildOptionsWithCorrect(
|
||||
topArtist.name,
|
||||
topArtists,
|
||||
|
|
@ -134,7 +140,10 @@ export async function buildAudioMetadataQuestion(
|
|||
fairness: getArtistFairness(topArtist, members, history),
|
||||
question: {
|
||||
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,
|
||||
correct: artistOptions.correct,
|
||||
points: 10,
|
||||
|
|
@ -144,8 +153,7 @@ export async function buildAudioMetadataQuestion(
|
|||
}
|
||||
}
|
||||
|
||||
if (topTracks.length > 0) {
|
||||
const topTrack = topTracks[0];
|
||||
for (const topTrack of topTracks.slice(0, METADATA_ENTITY_POOL_SIZE)) {
|
||||
const topTrackName = topTrack?.name;
|
||||
const trackOptions = topTrackName
|
||||
? buildOptionsWithCorrect(topTrackName, topTrackNames, 4)
|
||||
|
|
@ -158,9 +166,10 @@ export async function buildAudioMetadataQuestion(
|
|||
question: {
|
||||
type: "choice",
|
||||
text:
|
||||
topTrack === topTracks[0] &&
|
||||
getTrackFairness(topTrack, members, history).memberCount > 1
|
||||
? "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,
|
||||
correct: trackOptions.correct,
|
||||
points: 10,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type PartyAnalytics,
|
||||
type PartyQuestionMember,
|
||||
pickQuestionCandidate,
|
||||
pickRandomTop,
|
||||
type QuestionCandidate,
|
||||
resolveQuestionSong,
|
||||
} from "./question-utils";
|
||||
|
|
@ -84,7 +85,9 @@ async function getAlbumReleaseYear({
|
|||
members,
|
||||
history,
|
||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||
const topTrack = getFairQuestionTracks(analytics, members, history)[0];
|
||||
const topTrack = pickRandomTop(
|
||||
getFairQuestionTracks(analytics, members, history),
|
||||
);
|
||||
const trackName = topTrack?.name;
|
||||
const track = trackName
|
||||
? await db.query.track.findFirst({
|
||||
|
|
@ -117,7 +120,9 @@ async function getTrackReleaseYear(
|
|||
input: BuildNumericQuestionInput,
|
||||
): Promise<NumericQuestion | null> {
|
||||
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;
|
||||
const song = await resolveQuestionSong(input.db, input.analytics, {
|
||||
trackName: track.name,
|
||||
|
|
@ -153,8 +158,10 @@ async function getArtistFirstTrackReleaseYear(
|
|||
}
|
||||
}
|
||||
|
||||
const artistEntry = Array.from(tracksByArtist.entries()).find(
|
||||
([, artistTracks]) => artistTracks.length >= 2,
|
||||
const artistEntry = pickRandomTop(
|
||||
Array.from(tracksByArtist.entries()).filter(
|
||||
([, artistTracks]) => artistTracks.length >= 2,
|
||||
),
|
||||
);
|
||||
if (!artistEntry) return null;
|
||||
const [artistName, artistTracks] = artistEntry;
|
||||
|
|
@ -189,7 +196,9 @@ async function countTopTrackListeners({
|
|||
members,
|
||||
history,
|
||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||
const topTrack = getFairQuestionTracks(analytics, members, history)[0];
|
||||
const topTrack = pickRandomTop(
|
||||
getFairQuestionTracks(analytics, members, history),
|
||||
);
|
||||
const trackName = topTrack?.name;
|
||||
if (!trackName || members.length === 0) return null;
|
||||
const dbTrack = await db.query.track.findFirst({
|
||||
|
|
@ -227,7 +236,9 @@ async function countFavouriteArtistListeners({
|
|||
members,
|
||||
history,
|
||||
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
|
||||
const topArtist = getFairQuestionArtists(analytics, members, history)[0];
|
||||
const topArtist = pickRandomTop(
|
||||
getFairQuestionArtists(analytics, members, history),
|
||||
);
|
||||
const artistName = topArtist?.name;
|
||||
if (!artistName || members.length === 0) return null;
|
||||
const dbArtist = await db.query.artist.findFirst({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { buildNumericQuestion } from "./numeric-question-generator";
|
||||
import {
|
||||
|
|
@ -27,16 +27,10 @@ export async function generatePartyQuestion({
|
|||
index,
|
||||
}: GenerateQuestionInput): Promise<Question | null> {
|
||||
const members = await fetchPartyMembers(dbClient, partyId);
|
||||
const preferredOrder: PartyQuestionType[] = [
|
||||
"audio-metadata",
|
||||
"social",
|
||||
"numeric",
|
||||
];
|
||||
const rotation = index % preferredOrder.length;
|
||||
const typeOrder = [
|
||||
...preferredOrder.slice(rotation),
|
||||
...preferredOrder.slice(0, rotation),
|
||||
];
|
||||
const typeOrder = getRandomQuestionTypeOrder(
|
||||
["audio-metadata", "social", "numeric"],
|
||||
quizState.history,
|
||||
);
|
||||
|
||||
for (const type of typeOrder) {
|
||||
let question: Question | null = null;
|
||||
|
|
@ -82,3 +76,38 @@ export async function generatePartyQuestion({
|
|||
|
||||
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>;
|
||||
|
||||
const RANDOM_TOP_TIER_SIZE = 5;
|
||||
const MAX_RANDOM_WEIGHT = 100;
|
||||
|
||||
export const QUESTION_DURATION_MS = 60_000;
|
||||
export const MIN_PARTY_SIZE = 2;
|
||||
export const MAX_PARTY_SIZE = 4;
|
||||
|
|
@ -178,23 +181,12 @@ export function pickQuestionCandidate<T extends QuestionLike>(
|
|||
});
|
||||
|
||||
if (fresh.length === 0) return null;
|
||||
const bestMemberCount = Math.max(
|
||||
...fresh.map((candidate) => candidate.fairness?.memberCount ?? 0),
|
||||
const candidate = pickWeightedRandom(
|
||||
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;
|
||||
return {
|
||||
...candidate.question,
|
||||
|
|
@ -341,6 +333,7 @@ type SongSelectionInput = {
|
|||
type SongCandidate = {
|
||||
song: QuestionSong;
|
||||
fairness?: QuestionCandidateFairness;
|
||||
source?: "question" | "subject";
|
||||
};
|
||||
|
||||
export async function selectQuestionSong({
|
||||
|
|
@ -350,7 +343,7 @@ export async function selectQuestionSong({
|
|||
history,
|
||||
question,
|
||||
}: SongSelectionInput): Promise<QuestionSong | null> {
|
||||
const keepSpecificSong = isSongTargetQuestion(question);
|
||||
const keepSpecificSong = shouldUseQuestionSubjectSong(question);
|
||||
const usedPlatformIds = new Set(
|
||||
history
|
||||
.map((round) => round.question.song?.platform_id)
|
||||
|
|
@ -366,15 +359,49 @@ export async function selectQuestionSong({
|
|||
});
|
||||
|
||||
if (candidates.length === 0) return question.song ?? null;
|
||||
if (keepSpecificSong) return candidates[0]?.song ?? question.song ?? null;
|
||||
if (keepSpecificSong) {
|
||||
return (
|
||||
candidates.find((candidate) => candidate.source === "subject")?.song ??
|
||||
candidates.find((candidate) => candidate.source === "question")?.song ??
|
||||
candidates[0]?.song ??
|
||||
question.song ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
const freshCandidates = candidates.filter(
|
||||
const exactPlatformIds = new Set(
|
||||
candidates
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.source === "question" || candidate.source === "subject",
|
||||
)
|
||||
.map((candidate) => candidate.song.platform_id)
|
||||
.filter((value): value is string => isUsableText(value)),
|
||||
);
|
||||
const adjacentCandidates =
|
||||
exactPlatformIds.size > 0
|
||||
? candidates.filter(
|
||||
(candidate) =>
|
||||
!exactPlatformIds.has(candidate.song.platform_id ?? ""),
|
||||
)
|
||||
: candidates;
|
||||
|
||||
const allFreshCandidates = candidates.filter(
|
||||
(candidate) =>
|
||||
isUsableText(candidate.song.platform_id) &&
|
||||
!usedPlatformIds.has(candidate.song.platform_id),
|
||||
);
|
||||
const selected =
|
||||
pickFairSongCandidate(freshCandidates) ?? pickFairSongCandidate(candidates);
|
||||
const freshCandidates = adjacentCandidates.filter(
|
||||
(candidate) =>
|
||||
isUsableText(candidate.song.platform_id) &&
|
||||
!usedPlatformIds.has(candidate.song.platform_id),
|
||||
);
|
||||
const selected = shouldPreferQuestionSubjectSong(question)
|
||||
? (pickRelevantSongCandidate(question, allFreshCandidates) ??
|
||||
pickRelevantSongCandidate(question, candidates))
|
||||
: (pickFairSongCandidate(freshCandidates) ??
|
||||
pickFairSongCandidate(adjacentCandidates) ??
|
||||
pickFairSongCandidate(candidates));
|
||||
return selected?.song ?? question.song ?? null;
|
||||
}
|
||||
|
||||
|
|
@ -396,14 +423,15 @@ async function collectSongCandidates({
|
|||
const push = (
|
||||
song: QuestionSong | null | undefined,
|
||||
fairness?: QuestionCandidateFairness,
|
||||
source?: SongCandidate["source"],
|
||||
) => {
|
||||
if (!song || !isUsableText(song.platform_id)) return;
|
||||
if (seen.has(song.platform_id)) return;
|
||||
seen.add(song.platform_id);
|
||||
candidates.push({ song, fairness });
|
||||
candidates.push({ song, fairness, source });
|
||||
};
|
||||
|
||||
push(question.song);
|
||||
push(question.song, undefined, "question");
|
||||
|
||||
const subjectSong = await resolveSongFromQuestionSubject(
|
||||
db,
|
||||
|
|
@ -413,6 +441,7 @@ async function collectSongCandidates({
|
|||
push(
|
||||
subjectSong,
|
||||
getQuestionSubjectFairness(analytics, members, history, question),
|
||||
"subject",
|
||||
);
|
||||
|
||||
const peopleSong = await resolveSongFromMentionedPeople(
|
||||
|
|
@ -562,23 +591,29 @@ function pickFairSongCandidate(
|
|||
candidates: SongCandidate[],
|
||||
): SongCandidate | null {
|
||||
if (candidates.length === 0) return null;
|
||||
const bestMemberCount = Math.max(
|
||||
...candidates.map((candidate) => candidate.fairness?.memberCount ?? 0),
|
||||
return pickWeightedRandom(
|
||||
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.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,
|
||||
),
|
||||
candidate.source === "subject" || candidate.source === "question",
|
||||
),
|
||||
) ?? pickFairSongCandidate(candidates)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -608,17 +643,26 @@ function getQuestionSubjectFairness(
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function isSongTargetQuestion(question: Question): boolean {
|
||||
function shouldUseQuestionSubjectSong(question: Question): boolean {
|
||||
const key = question.questionKey?.toLowerCase() ?? "";
|
||||
const text = question.text.toLowerCase();
|
||||
return (
|
||||
question.hideSongTitle === true ||
|
||||
key.startsWith("audio:current-song:") ||
|
||||
text.includes("what song") ||
|
||||
text.includes("which song")
|
||||
key.startsWith("audio:title:") ||
|
||||
key.startsWith("audio:album:") ||
|
||||
key.startsWith("audio:performer:") ||
|
||||
key.startsWith("numeric:album-year:") ||
|
||||
key.startsWith("numeric:track-year:")
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
track: { memberScores?: { userId: string; score: number }[] },
|
||||
userIds: string[],
|
||||
|
|
@ -775,6 +819,45 @@ export function pickRandom<T>(items: T[]): T | 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(
|
||||
availableCount: number,
|
||||
desiredCount: number,
|
||||
|
|
|
|||
|
|
@ -26,10 +26,13 @@ export const partyAnalysisApp = new Elysia()
|
|||
return { error: "Only the host can trigger analysis." };
|
||||
}
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(
|
||||
membership.partyId,
|
||||
);
|
||||
return result;
|
||||
await partyAnalysisWorkflow.analyzeParty(membership.partyId);
|
||||
const updatedParty = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: membership.partyId,
|
||||
},
|
||||
});
|
||||
return updatedParty?.analysisData ?? null;
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/** biome-ignore-all lint/style/noNonNullAssertion: test setup uses controlled arrays */
|
||||
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { db } from "../../db";
|
||||
import {
|
||||
addFollowedArtist,
|
||||
addPlaybackHistory,
|
||||
|
|
@ -21,6 +22,46 @@ import "../../dbos";
|
|||
|
||||
await DBOS.launch();
|
||||
|
||||
async function analyzeParty(partyId: string) {
|
||||
await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const savedParty = await db.query.party.findFirst({
|
||||
where: { id: partyId },
|
||||
});
|
||||
return savedParty?.analysisData as {
|
||||
storyClusters: Array<{
|
||||
memberIds: string[];
|
||||
memberCount: number;
|
||||
tracks: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
memberScores: Array<{ userId: string; score: number }>;
|
||||
memberCount: number;
|
||||
}>;
|
||||
artists: Array<{ id: string; name: string; memberCount: number }>;
|
||||
genres: unknown[];
|
||||
}>;
|
||||
pairwise: Array<{
|
||||
userIdA: string;
|
||||
userIdB: string;
|
||||
sharedTracks: number;
|
||||
sharedArtists: number;
|
||||
sharedGenres: number;
|
||||
similarity: number;
|
||||
}>;
|
||||
groupSummary: {
|
||||
totalMembers: number;
|
||||
mostSharedGenres: unknown[];
|
||||
mostDiverseMember: { genreEntropy: number } | null;
|
||||
mostAlignedPair: { userIdA: string; userIdB: string } | null;
|
||||
};
|
||||
memberProfiles: Array<{
|
||||
userId: string;
|
||||
trackCount: number;
|
||||
artistCount: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("PartyAnalysisWorkflow", () => {
|
||||
describe("analyzeParty - basic behavior", () => {
|
||||
it("returns empty results for party with fewer than 2 members", async () => {
|
||||
|
|
@ -28,7 +69,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
const party = await createParty(user.id);
|
||||
await joinParty(party.partyId, user.id);
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(party.partyId);
|
||||
const result = await analyzeParty(party.partyId);
|
||||
|
||||
expect(result.storyClusters).toHaveLength(0);
|
||||
expect(result.pairwise).toHaveLength(0);
|
||||
|
|
@ -43,7 +84,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
const party = await createParty(user.id);
|
||||
// Don't add any members
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(party.partyId);
|
||||
const result = await analyzeParty(party.partyId);
|
||||
|
||||
expect(result.storyClusters).toHaveLength(0);
|
||||
expect(result.groupSummary.totalMembers).toBe(0);
|
||||
|
|
@ -54,7 +95,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
const party = await createParty(user.id);
|
||||
await joinParty(party.partyId, user.id);
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(party.partyId);
|
||||
const result = await analyzeParty(party.partyId);
|
||||
|
||||
expect(result.storyClusters).toHaveLength(0);
|
||||
expect(result.groupSummary.totalMembers).toBe(1);
|
||||
|
|
@ -67,7 +108,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
const { partyId, userIdA, userIdB, sharedTrackId, sharedArtistId } =
|
||||
await seedPartyWithTwoSimilarUsers();
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
expect(result.storyClusters).toHaveLength(1);
|
||||
const cluster = result.storyClusters[0]!;
|
||||
|
|
@ -94,8 +135,9 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
// Should have exactly 1 pairwise comparison
|
||||
expect(result.pairwise).toHaveLength(1);
|
||||
const comparison = result.pairwise[0]!;
|
||||
expect(comparison.userIdA).toBe(userIdA);
|
||||
expect(comparison.userIdB).toBe(userIdB);
|
||||
expect([comparison.userIdA, comparison.userIdB].sort()).toEqual(
|
||||
[userIdA, userIdB].sort(),
|
||||
);
|
||||
expect(comparison.sharedTracks).toBeGreaterThan(0);
|
||||
expect(comparison.sharedArtists).toBeGreaterThan(0);
|
||||
expect(comparison.similarity).toBeGreaterThan(0);
|
||||
|
|
@ -116,14 +158,20 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
});
|
||||
|
||||
it("correctly identifies group summary", async () => {
|
||||
const { partyId, userIdA } = await seedPartyWithTwoSimilarUsers();
|
||||
const { partyId, userIdA, userIdB } =
|
||||
await seedPartyWithTwoSimilarUsers();
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
expect(result.groupSummary.totalMembers).toBe(2);
|
||||
expect(result.groupSummary.mostAlignedPair).toBeDefined();
|
||||
if (result.groupSummary.mostAlignedPair) {
|
||||
expect(result.groupSummary.mostAlignedPair.userIdA).toBe(userIdA);
|
||||
expect(
|
||||
[
|
||||
result.groupSummary.mostAlignedPair.userIdA,
|
||||
result.groupSummary.mostAlignedPair.userIdB,
|
||||
].sort(),
|
||||
).toEqual([userIdA, userIdB].sort());
|
||||
}
|
||||
expect(result.groupSummary.mostSharedGenres).toHaveLength(1);
|
||||
});
|
||||
|
|
@ -133,7 +181,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
it("does not find shared tracks across all members", async () => {
|
||||
const { partyId } = await seedPartyWithThreeDiverseUsers();
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
expect(result.storyClusters).toHaveLength(3);
|
||||
expect(result.pairwise).toHaveLength(3); // C(3,2) = 3 pairs
|
||||
|
|
@ -148,7 +196,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
it("identifies pairwise comparisons for all member pairs", async () => {
|
||||
const { partyId } = await seedPartyWithThreeDiverseUsers();
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
expect(result.pairwise).toHaveLength(3);
|
||||
result.pairwise.forEach((comparison) => {
|
||||
|
|
@ -160,7 +208,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
it("correctly identifies genre diversity for each member", async () => {
|
||||
const { partyId } = await seedPartyWithThreeDiverseUsers();
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
expect(result.memberProfiles).toHaveLength(3);
|
||||
expect(result.groupSummary.mostDiverseMember).toBeDefined();
|
||||
|
|
@ -190,7 +238,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
await addPlaybackHistory(userIdA, trackC, oldDate);
|
||||
await addPlaybackHistory(userIdB, trackC, oldDate);
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
const comparison = result.pairwise[0]!;
|
||||
expect(comparison.sharedTracks).toBeGreaterThan(1); // sharedTrack + trackC
|
||||
|
|
@ -212,7 +260,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
]);
|
||||
await addSavedTrack(userIdA, extraTrack.id);
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
const profileA = result.memberProfiles.find((p) => p.userId === userIdA);
|
||||
expect(profileA).toBeDefined();
|
||||
|
|
@ -228,7 +276,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
await addTopArtist(userIdA, followedArtist.id, 5);
|
||||
await addFollowedArtist(userIdA, followedArtist.id);
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
const profileA = result.memberProfiles.find((p) => p.userId === userIdA);
|
||||
expect(profileA).toBeDefined();
|
||||
|
|
@ -251,7 +299,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
await addTopTrack(userIdB, uniqueTrack.id, 2);
|
||||
await addTopArtist(userIdB, uniqueArtist.id, 2);
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
const allTracks = result.storyClusters.flatMap(
|
||||
(cluster) => cluster.tracks,
|
||||
);
|
||||
|
|
@ -276,7 +324,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
it("sorts clusters with all-member cluster first", async () => {
|
||||
const { partyId, sharedTrackId } = await seedPartyWithTwoSimilarUsers();
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
// The cluster with both members should be first
|
||||
expect(result.storyClusters[0]?.memberCount).toBe(2);
|
||||
|
|
@ -302,7 +350,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
await addTopTrack(userIdA, extraTrack.id, 50);
|
||||
await addTopTrack(userIdB, extraTrack.id, 50);
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
const cluster = result.storyClusters[0]!;
|
||||
expect(cluster.tracks.length).toBeGreaterThan(1);
|
||||
|
|
@ -321,7 +369,7 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
it("calculates Jaccard-like similarity using min/max scoring", async () => {
|
||||
const { partyId } = await seedPartyWithTwoSimilarUsers();
|
||||
|
||||
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
const result = await analyzeParty(partyId);
|
||||
|
||||
const comparison = result.pairwise[0]!;
|
||||
expect(comparison.sharedTracks).toBeGreaterThanOrEqual(1);
|
||||
|
|
@ -338,8 +386,6 @@ describe("PartyAnalysisWorkflow", () => {
|
|||
|
||||
await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||
|
||||
const { db } = await import("../../db");
|
||||
|
||||
const savedParty = await db.query.party.findFirst({
|
||||
where: { id: partyId },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,12 +91,30 @@ type PartyAnalysisResult = {
|
|||
memberProfiles: MemberProfile[];
|
||||
};
|
||||
|
||||
type PartyAnalysisWorkflowResult = {
|
||||
partyId: string;
|
||||
totalMembers: number;
|
||||
analyzed: boolean;
|
||||
};
|
||||
|
||||
const MAX_STORY_CLUSTERS = 8;
|
||||
const MAX_CLUSTER_ENTITIES = 20;
|
||||
const MAX_PAIRWISE_COMPARISONS = 20;
|
||||
const MAX_PROFILE_GENRES = 20;
|
||||
|
||||
export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
||||
@DBOS.workflow()
|
||||
async analyzeParty(partyId: string): Promise<PartyAnalysisResult> {
|
||||
async analyzeParty(partyId: string): Promise<PartyAnalysisWorkflowResult> {
|
||||
return this.analyzeAndSaveParty(partyId);
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
private async analyzeAndSaveParty(
|
||||
partyId: string,
|
||||
): Promise<PartyAnalysisWorkflowResult> {
|
||||
const members = await this.fetchPartyMembers(partyId);
|
||||
if (members.length < 2) {
|
||||
return {
|
||||
await this.saveAnalysis(partyId, {
|
||||
storyClusters: [],
|
||||
pairwise: [],
|
||||
groupSummary: {
|
||||
|
|
@ -106,7 +124,8 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
|||
mostAlignedPair: null,
|
||||
},
|
||||
memberProfiles: [],
|
||||
};
|
||||
});
|
||||
return { partyId, totalMembers: members.length, analyzed: false };
|
||||
}
|
||||
|
||||
const memberInfos = members.map((m) => ({
|
||||
|
|
@ -160,22 +179,17 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
|||
mostAlignedPair,
|
||||
};
|
||||
|
||||
await this.saveAnalysis(partyId, {
|
||||
const analysis = this.compactAnalysis({
|
||||
storyClusters,
|
||||
pairwise,
|
||||
groupSummary,
|
||||
memberProfiles,
|
||||
});
|
||||
await this.saveAnalysis(partyId, analysis);
|
||||
|
||||
return {
|
||||
storyClusters,
|
||||
pairwise,
|
||||
groupSummary,
|
||||
memberProfiles,
|
||||
};
|
||||
return { partyId, totalMembers: members.length, analyzed: true };
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
private async fetchPartyMembers(partyId: string): Promise<PartyMemberRow[]> {
|
||||
const result = await db
|
||||
.select()
|
||||
|
|
@ -185,7 +199,6 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
|||
return result as PartyMemberRow[];
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
private async fetchAllMemberData(
|
||||
members: { userId: string }[],
|
||||
): Promise<Map<string, MemberScores>> {
|
||||
|
|
@ -199,7 +212,6 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
|||
return result;
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
private async fetchMemberScores(userId: string): Promise<MemberScores> {
|
||||
const scores: MemberScores = {
|
||||
tracks: new Map(),
|
||||
|
|
@ -879,7 +891,6 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
|||
return genres.filter((g) => g.memberCount >= 2).slice(0, 10);
|
||||
}
|
||||
|
||||
@DBOS.step()
|
||||
private async saveAnalysis(
|
||||
partyId: string,
|
||||
analysis: PartyAnalysisResult,
|
||||
|
|
@ -892,6 +903,29 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
|
|||
})
|
||||
.where(sql`${party.id} = ${partyId}`);
|
||||
}
|
||||
|
||||
private compactAnalysis(analysis: PartyAnalysisResult): PartyAnalysisResult {
|
||||
return {
|
||||
storyClusters: analysis.storyClusters
|
||||
.slice(0, MAX_STORY_CLUSTERS)
|
||||
.map((cluster) => ({
|
||||
...cluster,
|
||||
tracks: cluster.tracks.slice(0, MAX_CLUSTER_ENTITIES),
|
||||
artists: cluster.artists.slice(0, MAX_CLUSTER_ENTITIES),
|
||||
genres: cluster.genres.slice(0, MAX_CLUSTER_ENTITIES),
|
||||
})),
|
||||
pairwise: analysis.pairwise.slice(0, MAX_PAIRWISE_COMPARISONS),
|
||||
groupSummary: analysis.groupSummary,
|
||||
memberProfiles: analysis.memberProfiles.map((profile) => ({
|
||||
...profile,
|
||||
genreScores: Object.fromEntries(
|
||||
Object.entries(profile.genreScores)
|
||||
.sort(([, left], [, right]) => right - left)
|
||||
.slice(0, MAX_PROFILE_GENRES),
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface MemberScores {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ConfiguredInstance, DBOS, WorkflowQueue } from "@dbos-inc/dbos-sdk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db";
|
||||
import { partyMember } from "../db/schema";
|
||||
import { party, partyMember } from "../db/schema";
|
||||
import { generatePartyQuestion } from "../party/question-generator";
|
||||
import type { PartyAnalytics } from "../party/question-utils";
|
||||
import { updatePartyData } from "../party/state";
|
||||
|
|
@ -174,11 +174,12 @@ export class QuizWorkflow extends ConfiguredInstance {
|
|||
quizState: QuizState,
|
||||
index: number,
|
||||
): Promise<Question | null> {
|
||||
const partyRecord = await db.query.party.findFirst({
|
||||
where: {
|
||||
id: partyId,
|
||||
},
|
||||
});
|
||||
const partyRecord = await db
|
||||
.select({ analysisData: party.analysisData })
|
||||
.from(party)
|
||||
.where(eq(party.id, partyId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0]);
|
||||
const analytics = (partyRecord?.analysisData ?? null) as PartyAnalytics;
|
||||
const question = await generatePartyQuestion({
|
||||
db,
|
||||
|
|
|
|||
Loading…
Reference in a new issue