Compare commits

..

No commits in common. "cd2ec0731448ac991e98209180a034d0d630e726" and "ac506795c68f2bb89322831d854058babc9724d7" have entirely different histories.

10 changed files with 121 additions and 567 deletions

View file

@ -261,53 +261,6 @@ 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" },
@ -542,40 +495,13 @@ 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"),
@ -650,91 +576,4 @@ 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");
});
});

View file

@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { 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),
@ -23,24 +22,11 @@ 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",
@ -64,7 +50,9 @@ describe("generatePartyQuestion", () => {
});
it("attaches a fallback song to generated questions", async () => {
mockResolvedQuestion(audioQuestionGenerator.buildAudioMetadataQuestion, {
vi.mocked(
audioQuestionGenerator.buildAudioMetadataQuestion,
).mockResolvedValueOnce({
type: "choice",
text: "Which genre appears most in the party analytics?",
correct: 0,
@ -115,61 +103,4 @@ 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();
}
});
});

View file

@ -17,8 +17,6 @@ import {
resolveQuestionSong,
} from "./question-utils";
const METADATA_ENTITY_POOL_SIZE = 8;
type TrackDetails = {
id: string;
name: string | null;
@ -100,18 +98,16 @@ export async function buildAudioMetadataQuestion(
const genreNames = buildOrderedOptions(getMostSharedGenreNames(analytics), 4);
if (genreNames) {
for (const genre of genreNames.slice(0, METADATA_ENTITY_POOL_SIZE)) {
const genreOptions = buildOptionsWithCorrect(genre, genreNames, 4);
const topGenre = genreNames[0];
if (topGenre) {
const genreOptions = buildOptionsWithCorrect(topGenre, genreNames, 4);
if (genreOptions) {
questions.push({
key: `audio:genre:${genre}`,
subjectKey: `genre:${genre}`,
key: `audio:genre:${topGenre}`,
subjectKey: `genre:${topGenre}`,
question: {
type: "choice",
text:
genre === genreNames[0]
? "Which genre appears most in the party analytics?"
: "Which of these genres appears in the party analytics?",
text: "Which genre appears most in the party analytics?",
options: genreOptions.options,
correct: genreOptions.correct,
points: 10,
@ -124,10 +120,8 @@ export async function buildAudioMetadataQuestion(
const topArtistEntities = getFairQuestionArtists(analytics, members, history);
const topArtists = topArtistEntities.map((artist) => artist.name);
for (const topArtist of topArtistEntities.slice(
0,
METADATA_ENTITY_POOL_SIZE,
)) {
const topArtist = topArtistEntities[0];
if (topArtist) {
const artistOptions = buildOptionsWithCorrect(
topArtist.name,
topArtists,
@ -140,10 +134,7 @@ export async function buildAudioMetadataQuestion(
fairness: getArtistFairness(topArtist, members, history),
question: {
type: "choice",
text:
topArtist === topArtistEntities[0]
? "Which artist shows up most often in the shared audio data?"
: "Which artist shows up in the shared audio data?",
text: "Which artist shows up most often in the shared audio data?",
options: artistOptions.options,
correct: artistOptions.correct,
points: 10,
@ -153,7 +144,8 @@ export async function buildAudioMetadataQuestion(
}
}
for (const topTrack of topTracks.slice(0, METADATA_ENTITY_POOL_SIZE)) {
if (topTracks.length > 0) {
const topTrack = topTracks[0];
const topTrackName = topTrack?.name;
const trackOptions = topTrackName
? buildOptionsWithCorrect(topTrackName, topTrackNames, 4)
@ -166,10 +158,9 @@ 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 appears in the party analytics?",
: "Which track stands out in the party analytics?",
options: trackOptions.options,
correct: trackOptions.correct,
points: 10,

View file

@ -16,7 +16,6 @@ import {
type PartyAnalytics,
type PartyQuestionMember,
pickQuestionCandidate,
pickRandomTop,
type QuestionCandidate,
resolveQuestionSong,
} from "./question-utils";
@ -85,9 +84,7 @@ async function getAlbumReleaseYear({
members,
history,
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
const topTrack = pickRandomTop(
getFairQuestionTracks(analytics, members, history),
);
const topTrack = getFairQuestionTracks(analytics, members, history)[0];
const trackName = topTrack?.name;
const track = trackName
? await db.query.track.findFirst({
@ -120,9 +117,7 @@ async function getTrackReleaseYear(
input: BuildNumericQuestionInput,
): Promise<NumericQuestion | null> {
const tracks = await getDetailedTopTracks(input);
const track = pickRandomTop(
tracks.filter((track) => track.album?.release_date && track.name),
);
const track = tracks.find((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,
@ -158,10 +153,8 @@ async function getArtistFirstTrackReleaseYear(
}
}
const artistEntry = pickRandomTop(
Array.from(tracksByArtist.entries()).filter(
([, artistTracks]) => artistTracks.length >= 2,
),
const artistEntry = Array.from(tracksByArtist.entries()).find(
([, artistTracks]) => artistTracks.length >= 2,
);
if (!artistEntry) return null;
const [artistName, artistTracks] = artistEntry;
@ -196,9 +189,7 @@ async function countTopTrackListeners({
members,
history,
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
const topTrack = pickRandomTop(
getFairQuestionTracks(analytics, members, history),
);
const topTrack = getFairQuestionTracks(analytics, members, history)[0];
const trackName = topTrack?.name;
if (!trackName || members.length === 0) return null;
const dbTrack = await db.query.track.findFirst({
@ -236,9 +227,7 @@ async function countFavouriteArtistListeners({
members,
history,
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
const topArtist = pickRandomTop(
getFairQuestionArtists(analytics, members, history),
);
const topArtist = getFairQuestionArtists(analytics, members, history)[0];
const artistName = topArtist?.name;
if (!artistName || members.length === 0) return null;
const dbArtist = await db.query.artist.findFirst({

View file

@ -1,5 +1,5 @@
import type { db } from "../db";
import type { Question, QuizRound, QuizState } from "../party-types";
import type { Question, QuizState } from "../party-types";
import { buildAudioMetadataQuestion } from "./audio-question-generator";
import { buildNumericQuestion } from "./numeric-question-generator";
import {
@ -27,10 +27,16 @@ export async function generatePartyQuestion({
index,
}: GenerateQuestionInput): Promise<Question | null> {
const members = await fetchPartyMembers(dbClient, partyId);
const typeOrder = getRandomQuestionTypeOrder(
["audio-metadata", "social", "numeric"],
quizState.history,
);
const preferredOrder: PartyQuestionType[] = [
"audio-metadata",
"social",
"numeric",
];
const rotation = index % preferredOrder.length;
const typeOrder = [
...preferredOrder.slice(rotation),
...preferredOrder.slice(0, rotation),
];
for (const type of typeOrder) {
let question: Question | null = null;
@ -76,38 +82,3 @@ 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;
}

View file

@ -66,9 +66,6 @@ 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;
@ -181,12 +178,23 @@ export function pickQuestionCandidate<T extends QuestionLike>(
});
if (fresh.length === 0) return null;
const candidate = pickWeightedRandom(
fresh.map((candidate) => ({
item: candidate,
weight: getQuestionCandidateWeight(candidate),
})),
const bestMemberCount = Math.max(
...fresh.map((candidate) => candidate.fairness?.memberCount ?? 0),
);
const bestScore = Math.max(
...fresh
.filter(
(candidate) =>
(candidate.fairness?.memberCount ?? 0) === bestMemberCount,
)
.map((candidate) => candidate.fairness?.score ?? 0),
);
const pool = fresh.filter(
(candidate) =>
(candidate.fairness?.memberCount ?? 0) === bestMemberCount &&
(candidate.fairness?.score ?? 0) === bestScore,
);
const candidate = pickRandom(pool);
if (!candidate) return null;
return {
...candidate.question,
@ -333,7 +341,6 @@ type SongSelectionInput = {
type SongCandidate = {
song: QuestionSong;
fairness?: QuestionCandidateFairness;
source?: "question" | "subject";
};
export async function selectQuestionSong({
@ -343,7 +350,7 @@ export async function selectQuestionSong({
history,
question,
}: SongSelectionInput): Promise<QuestionSong | null> {
const keepSpecificSong = shouldUseQuestionSubjectSong(question);
const keepSpecificSong = isSongTargetQuestion(question);
const usedPlatformIds = new Set(
history
.map((round) => round.question.song?.platform_id)
@ -359,49 +366,15 @@ export async function selectQuestionSong({
});
if (candidates.length === 0) return 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
);
}
if (keepSpecificSong) return candidates[0]?.song ?? question.song ?? null;
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(
const freshCandidates = candidates.filter(
(candidate) =>
isUsableText(candidate.song.platform_id) &&
!usedPlatformIds.has(candidate.song.platform_id),
);
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));
const selected =
pickFairSongCandidate(freshCandidates) ?? pickFairSongCandidate(candidates);
return selected?.song ?? question.song ?? null;
}
@ -423,15 +396,14 @@ 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, source });
candidates.push({ song, fairness });
};
push(question.song, undefined, "question");
push(question.song);
const subjectSong = await resolveSongFromQuestionSubject(
db,
@ -441,7 +413,6 @@ async function collectSongCandidates({
push(
subjectSong,
getQuestionSubjectFairness(analytics, members, history, question),
"subject",
);
const peopleSong = await resolveSongFromMentionedPeople(
@ -591,29 +562,23 @@ function pickFairSongCandidate(
candidates: SongCandidate[],
): SongCandidate | null {
if (candidates.length === 0) return null;
return pickWeightedRandom(
candidates.map((candidate) => ({
item: candidate,
weight: getQuestionCandidateFairnessWeight(candidate.fairness),
})),
const bestMemberCount = Math.max(
...candidates.map((candidate) => candidate.fairness?.memberCount ?? 0),
);
}
function pickRelevantSongCandidate(
question: Question,
candidates: SongCandidate[],
): SongCandidate | null {
if (!shouldPreferQuestionSubjectSong(question)) {
return pickFairSongCandidate(candidates);
}
return (
pickFairSongCandidate(
candidates.filter(
const bestScore = Math.max(
...candidates
.filter(
(candidate) =>
candidate.source === "subject" || candidate.source === "question",
),
) ?? pickFairSongCandidate(candidates)
(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,
),
);
}
@ -643,26 +608,17 @@ function getQuestionSubjectFairness(
return undefined;
}
function shouldUseQuestionSubjectSong(question: Question): boolean {
function isSongTargetQuestion(question: Question): boolean {
const key = question.questionKey?.toLowerCase() ?? "";
const text = question.text.toLowerCase();
return (
question.hideSongTitle === true ||
key.startsWith("audio:current-song:") ||
key.startsWith("audio:title:") ||
key.startsWith("audio:album:") ||
key.startsWith("audio:performer:") ||
key.startsWith("numeric:album-year:") ||
key.startsWith("numeric:track-year:")
text.includes("what song") ||
text.includes("which song")
);
}
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[],
@ -819,45 +775,6 @@ 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,

View file

@ -26,13 +26,10 @@ export const partyAnalysisApp = new Elysia()
return { error: "Only the host can trigger analysis." };
}
await partyAnalysisWorkflow.analyzeParty(membership.partyId);
const updatedParty = await db.query.party.findFirst({
where: {
id: membership.partyId,
},
});
return updatedParty?.analysisData ?? null;
const result = await partyAnalysisWorkflow.analyzeParty(
membership.partyId,
);
return result;
},
{
auth: true,

View file

@ -1,7 +1,6 @@
/** 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,
@ -22,46 +21,6 @@ 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 () => {
@ -69,7 +28,7 @@ describe("PartyAnalysisWorkflow", () => {
const party = await createParty(user.id);
await joinParty(party.partyId, user.id);
const result = await analyzeParty(party.partyId);
const result = await partyAnalysisWorkflow.analyzeParty(party.partyId);
expect(result.storyClusters).toHaveLength(0);
expect(result.pairwise).toHaveLength(0);
@ -84,7 +43,7 @@ describe("PartyAnalysisWorkflow", () => {
const party = await createParty(user.id);
// Don't add any members
const result = await analyzeParty(party.partyId);
const result = await partyAnalysisWorkflow.analyzeParty(party.partyId);
expect(result.storyClusters).toHaveLength(0);
expect(result.groupSummary.totalMembers).toBe(0);
@ -95,7 +54,7 @@ describe("PartyAnalysisWorkflow", () => {
const party = await createParty(user.id);
await joinParty(party.partyId, user.id);
const result = await analyzeParty(party.partyId);
const result = await partyAnalysisWorkflow.analyzeParty(party.partyId);
expect(result.storyClusters).toHaveLength(0);
expect(result.groupSummary.totalMembers).toBe(1);
@ -108,7 +67,7 @@ describe("PartyAnalysisWorkflow", () => {
const { partyId, userIdA, userIdB, sharedTrackId, sharedArtistId } =
await seedPartyWithTwoSimilarUsers();
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.storyClusters).toHaveLength(1);
const cluster = result.storyClusters[0]!;
@ -135,9 +94,8 @@ describe("PartyAnalysisWorkflow", () => {
// Should have exactly 1 pairwise comparison
expect(result.pairwise).toHaveLength(1);
const comparison = result.pairwise[0]!;
expect([comparison.userIdA, comparison.userIdB].sort()).toEqual(
[userIdA, userIdB].sort(),
);
expect(comparison.userIdA).toBe(userIdA);
expect(comparison.userIdB).toBe(userIdB);
expect(comparison.sharedTracks).toBeGreaterThan(0);
expect(comparison.sharedArtists).toBeGreaterThan(0);
expect(comparison.similarity).toBeGreaterThan(0);
@ -158,20 +116,14 @@ describe("PartyAnalysisWorkflow", () => {
});
it("correctly identifies group summary", async () => {
const { partyId, userIdA, userIdB } =
await seedPartyWithTwoSimilarUsers();
const { partyId, userIdA } = await seedPartyWithTwoSimilarUsers();
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.groupSummary.totalMembers).toBe(2);
expect(result.groupSummary.mostAlignedPair).toBeDefined();
if (result.groupSummary.mostAlignedPair) {
expect(
[
result.groupSummary.mostAlignedPair.userIdA,
result.groupSummary.mostAlignedPair.userIdB,
].sort(),
).toEqual([userIdA, userIdB].sort());
expect(result.groupSummary.mostAlignedPair.userIdA).toBe(userIdA);
}
expect(result.groupSummary.mostSharedGenres).toHaveLength(1);
});
@ -181,7 +133,7 @@ describe("PartyAnalysisWorkflow", () => {
it("does not find shared tracks across all members", async () => {
const { partyId } = await seedPartyWithThreeDiverseUsers();
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.storyClusters).toHaveLength(3);
expect(result.pairwise).toHaveLength(3); // C(3,2) = 3 pairs
@ -196,7 +148,7 @@ describe("PartyAnalysisWorkflow", () => {
it("identifies pairwise comparisons for all member pairs", async () => {
const { partyId } = await seedPartyWithThreeDiverseUsers();
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.pairwise).toHaveLength(3);
result.pairwise.forEach((comparison) => {
@ -208,7 +160,7 @@ describe("PartyAnalysisWorkflow", () => {
it("correctly identifies genre diversity for each member", async () => {
const { partyId } = await seedPartyWithThreeDiverseUsers();
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
expect(result.memberProfiles).toHaveLength(3);
expect(result.groupSummary.mostDiverseMember).toBeDefined();
@ -238,7 +190,7 @@ describe("PartyAnalysisWorkflow", () => {
await addPlaybackHistory(userIdA, trackC, oldDate);
await addPlaybackHistory(userIdB, trackC, oldDate);
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const comparison = result.pairwise[0]!;
expect(comparison.sharedTracks).toBeGreaterThan(1); // sharedTrack + trackC
@ -260,7 +212,7 @@ describe("PartyAnalysisWorkflow", () => {
]);
await addSavedTrack(userIdA, extraTrack.id);
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const profileA = result.memberProfiles.find((p) => p.userId === userIdA);
expect(profileA).toBeDefined();
@ -276,7 +228,7 @@ describe("PartyAnalysisWorkflow", () => {
await addTopArtist(userIdA, followedArtist.id, 5);
await addFollowedArtist(userIdA, followedArtist.id);
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const profileA = result.memberProfiles.find((p) => p.userId === userIdA);
expect(profileA).toBeDefined();
@ -299,7 +251,7 @@ describe("PartyAnalysisWorkflow", () => {
await addTopTrack(userIdB, uniqueTrack.id, 2);
await addTopArtist(userIdB, uniqueArtist.id, 2);
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const allTracks = result.storyClusters.flatMap(
(cluster) => cluster.tracks,
);
@ -324,7 +276,7 @@ describe("PartyAnalysisWorkflow", () => {
it("sorts clusters with all-member cluster first", async () => {
const { partyId, sharedTrackId } = await seedPartyWithTwoSimilarUsers();
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
// The cluster with both members should be first
expect(result.storyClusters[0]?.memberCount).toBe(2);
@ -350,7 +302,7 @@ describe("PartyAnalysisWorkflow", () => {
await addTopTrack(userIdA, extraTrack.id, 50);
await addTopTrack(userIdB, extraTrack.id, 50);
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const cluster = result.storyClusters[0]!;
expect(cluster.tracks.length).toBeGreaterThan(1);
@ -369,7 +321,7 @@ describe("PartyAnalysisWorkflow", () => {
it("calculates Jaccard-like similarity using min/max scoring", async () => {
const { partyId } = await seedPartyWithTwoSimilarUsers();
const result = await analyzeParty(partyId);
const result = await partyAnalysisWorkflow.analyzeParty(partyId);
const comparison = result.pairwise[0]!;
expect(comparison.sharedTracks).toBeGreaterThanOrEqual(1);
@ -386,6 +338,8 @@ describe("PartyAnalysisWorkflow", () => {
await partyAnalysisWorkflow.analyzeParty(partyId);
const { db } = await import("../../db");
const savedParty = await db.query.party.findFirst({
where: { id: partyId },
});

View file

@ -91,30 +91,12 @@ 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<PartyAnalysisWorkflowResult> {
return this.analyzeAndSaveParty(partyId);
}
@DBOS.step()
private async analyzeAndSaveParty(
partyId: string,
): Promise<PartyAnalysisWorkflowResult> {
async analyzeParty(partyId: string): Promise<PartyAnalysisResult> {
const members = await this.fetchPartyMembers(partyId);
if (members.length < 2) {
await this.saveAnalysis(partyId, {
return {
storyClusters: [],
pairwise: [],
groupSummary: {
@ -124,8 +106,7 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
mostAlignedPair: null,
},
memberProfiles: [],
});
return { partyId, totalMembers: members.length, analyzed: false };
};
}
const memberInfos = members.map((m) => ({
@ -179,17 +160,22 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
mostAlignedPair,
};
const analysis = this.compactAnalysis({
await this.saveAnalysis(partyId, {
storyClusters,
pairwise,
groupSummary,
memberProfiles,
});
await this.saveAnalysis(partyId, analysis);
return { partyId, totalMembers: members.length, analyzed: true };
return {
storyClusters,
pairwise,
groupSummary,
memberProfiles,
};
}
@DBOS.step()
private async fetchPartyMembers(partyId: string): Promise<PartyMemberRow[]> {
const result = await db
.select()
@ -199,6 +185,7 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
return result as PartyMemberRow[];
}
@DBOS.step()
private async fetchAllMemberData(
members: { userId: string }[],
): Promise<Map<string, MemberScores>> {
@ -212,6 +199,7 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
return result;
}
@DBOS.step()
private async fetchMemberScores(userId: string): Promise<MemberScores> {
const scores: MemberScores = {
tracks: new Map(),
@ -891,6 +879,7 @@ export class PartyAnalysisWorkflow extends ConfiguredInstance {
return genres.filter((g) => g.memberCount >= 2).slice(0, 10);
}
@DBOS.step()
private async saveAnalysis(
partyId: string,
analysis: PartyAnalysisResult,
@ -903,29 +892,6 @@ 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 {

View file

@ -1,7 +1,7 @@
import { ConfiguredInstance, DBOS, WorkflowQueue } from "@dbos-inc/dbos-sdk";
import { eq } from "drizzle-orm";
import { db } from "../db";
import { party, partyMember } from "../db/schema";
import { partyMember } from "../db/schema";
import { generatePartyQuestion } from "../party/question-generator";
import type { PartyAnalytics } from "../party/question-utils";
import { updatePartyData } from "../party/state";
@ -174,12 +174,11 @@ export class QuizWorkflow extends ConfiguredInstance {
quizState: QuizState,
index: number,
): Promise<Question | null> {
const partyRecord = await db
.select({ analysisData: party.analysisData })
.from(party)
.where(eq(party.id, partyId))
.limit(1)
.then((rows) => rows[0]);
const partyRecord = await db.query.party.findFirst({
where: {
id: partyId,
},
});
const analytics = (partyRecord?.analysisData ?? null) as PartyAnalytics;
const question = await generatePartyQuestion({
db,