From fc7099449506141c42c404dbedd2d9712c6a8b53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 14:42:31 +0000 Subject: [PATCH 01/21] Initial plan From 34efe91e3f28748f275003b1560cd0a6bd9f374c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 14:58:07 +0000 Subject: [PATCH 02/21] feat: add argument leaderboard page Agent-Logs-Url: https://github.com/dev-chat/mocker/sessions/92100499-66e6-4c96-b4c3-e1e87c3ebf96 --- packages/backend/src/ai/ai.constants.ts | 23 ++ packages/backend/src/ai/ai.service.spec.ts | 69 ++++++ packages/backend/src/ai/ai.service.ts | 160 ++++++++++++++ .../backend/src/argument/argument.model.ts | 36 +++ .../argument.persistence.service.spec.ts | 132 +++++++++++ .../argument/argument.persistence.service.ts | 186 ++++++++++++++++ .../src/dashboard/dashboard.controller.ts | 19 ++ .../shared/db/models/ArgumentLeaderboard.ts | 35 +++ .../backend/src/shared/db/models/SlackUser.ts | 4 + packages/frontend/src/app.model.ts | 30 +++ .../frontend/src/components/AppShell.model.ts | 2 +- .../frontend/src/components/AppShell.spec.tsx | 7 + packages/frontend/src/components/AppShell.tsx | 10 +- .../src/hooks/useArgumentLeaderboard.spec.ts | 54 +++++ .../src/hooks/useArgumentLeaderboard.ts | 65 ++++++ .../pages/ArgumentLeaderboardPage.model.ts | 3 + .../pages/ArgumentLeaderboardPage.spec.tsx | 79 +++++++ .../src/pages/ArgumentLeaderboardPage.tsx | 209 ++++++++++++++++++ 18 files changed, 1121 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/argument/argument.model.ts create mode 100644 packages/backend/src/argument/argument.persistence.service.spec.ts create mode 100644 packages/backend/src/argument/argument.persistence.service.ts create mode 100644 packages/backend/src/shared/db/models/ArgumentLeaderboard.ts create mode 100644 packages/frontend/src/hooks/useArgumentLeaderboard.spec.ts create mode 100644 packages/frontend/src/hooks/useArgumentLeaderboard.ts create mode 100644 packages/frontend/src/pages/ArgumentLeaderboardPage.model.ts create mode 100644 packages/frontend/src/pages/ArgumentLeaderboardPage.spec.tsx create mode 100644 packages/frontend/src/pages/ArgumentLeaderboardPage.tsx diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index d930fdcf..86716b59 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -207,3 +207,26 @@ Output format: - If no strong traits are present, return []`; export const DAILY_MEMORY_JOB_CONCURRENCY = 50; + +export const ARGUMENT_WINNER_EXTRACTION_PROMPT = `You are analyzing a Slack conversation after someone asked Moonbeam who won an argument. + +Return ONLY one of these: +- the exact string NONE when there is no clear argument with a winner in the provided context +- a single JSON object in this format: + { + "summary": "short summary of the argument", + "participants": [ + { "slackId": "U123", "name": "Alice", "viewpoint": "their side of the argument" } + ], + "winnerSlackId": "U123", + "pointValue": 4 + } + +Rules: +- use the conversation history to identify the argument topic, each participant, and each participant's viewpoint +- include at least 2 participants +- the winnerSlackId must match one of the listed participants +- pointValue must be an integer from 0 to 5 based on the substance of the argument; reward longer, more detailed, more back-and-forth arguments with higher values +- keep summary and viewpoints concise but specific +- do not include Markdown, explanations, or extra keys +- if the winner or participants are ambiguous, return NONE`; diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index c1a0aeb8..24e28a92 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -71,6 +71,10 @@ const buildAiService = (): AIService => { getCustomPrompt: vi.fn().mockResolvedValue(null), } as unknown as AIService['slackPersistenceService']; + ai.argumentPersistenceService = { + saveArgumentOutcome: vi.fn().mockResolvedValue(null), + } as unknown as AIService['argumentPersistenceService']; + ai.aiServiceLogger = { error: vi.fn(), warn: vi.fn(), @@ -615,6 +619,71 @@ describe('AIService', () => { expect(callArgs.instructions).toContain('traits_context'); expect(callArgs.instructions).toContain('dislikes donald trump'); }); + + it('extracts, saves, and answers argument winner requests', async () => { + (aiService.historyService.getHistoryWithOptions as Mock).mockResolvedValue([ + { slackId: 'U1', name: 'Alice', message: 'Tabs are better than spaces.' }, + { slackId: 'U2', name: 'Bob', message: 'Spaces are easier to read.' }, + ]); + (aiService.openAi.responses.create as Mock).mockResolvedValue({ + output: [ + { + type: 'message', + content: [ + { + type: 'output_text', + text: JSON.stringify({ + summary: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are more readable' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }), + }, + ], + }, + ], + }); + (aiService.argumentPersistenceService.saveArgumentOutcome as Mock).mockResolvedValue({ + id: 1, + argument: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are more readable' }, + ], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }); + + await aiService.participate('T1', 'C1', '<@moonbeam> who won the argument about tabs versus spaces?'); + + expect(aiService.argumentPersistenceService.saveArgumentOutcome).toHaveBeenCalledWith({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are more readable' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }); + expect(aiService.webService.sendMessage).toHaveBeenCalledWith( + 'C1', + 'Bob won. The argument was about tabs vs spaces. Alice argued tabs are faster; Bob argued spaces are more readable. Substance score: 4/5.', + [ + { + type: 'markdown', + text: 'Bob won. The argument was about tabs vs spaces. Alice argued tabs are faster; Bob argued spaces are more readable. Substance score: 4/5.', + }, + ], + ); + expect(aiService.redis.setHasParticipated).toHaveBeenCalledWith('T1', 'C1'); + expect(aiService.redis.removeParticipationInFlight).toHaveBeenCalledWith('C1', 'T1'); + }); }); describe('participate with custom prompt', () => { diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 837a4ce1..ffa499be 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -10,6 +10,7 @@ import { AIPersistenceService } from './ai.persistence'; import type { KnownBlock } from '@slack/web-api'; import { WebService } from '../shared/services/web/web.service'; import { + ARGUMENT_WINNER_EXTRACTION_PROMPT, CORPO_SPEAK_INSTRUCTIONS, GENERAL_TEXT_INSTRUCTIONS, MOONBEAM_SYSTEM_INSTRUCTIONS, @@ -37,6 +38,8 @@ import { GoogleGenAI } from '@google/genai'; import sharp from 'sharp'; import { extractParticipantSlackIds } from './helpers/extractParticipantSlackIds'; import { TraitService } from '../trait/trait.service'; +import { ArgumentPersistenceService } from '../argument/argument.persistence.service'; +import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; interface ReleaseCommit { sha: string; @@ -49,6 +52,13 @@ interface ReleaseMetadata { commits: ReleaseCommit[]; } +interface ArgumentWinnerExtraction { + summary: string; + participants: ArgumentParticipant[]; + winnerSlackId: string; + pointValue: number; +} + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const isResponseOutputMessage = (block: ResponseOutputItem): block is ResponseOutputMessage => block.type === 'message'; @@ -102,6 +112,11 @@ const normalizeReleaseSha = (value?: string): string | null => { return trimmed; }; +const isArgumentWinnerRequest = (taggedMessage: string): boolean => { + const normalized = taggedMessage.toLowerCase(); + return normalized.includes('who won') && (normalized.includes('argument') || normalized.includes('debate')); +}; + export class AIService { redis = new AIPersistenceService(); openAi = new OpenAI({ @@ -115,6 +130,7 @@ export class AIService { slackService = new SlackService(); slackPersistenceService = new SlackPersistenceService(); traitService = new TraitService(); + argumentPersistenceService = new ArgumentPersistenceService(); aiServiceLogger = logger.child({ module: 'AIService' }); public decrementDaiyRequests(userId: string, teamId: string): Promise { @@ -513,6 +529,19 @@ export class AIService { hasCustomPrompt: normalizedCustomPrompt !== null, }); + if (isArgumentWinnerRequest(taggedMessage)) { + const handled = await this.handleArgumentWinnerRequest(teamId, channelId, taggedMessage, historyMessages); + if (handled) { + await this.redis.removeParticipationInFlight(channelId, teamId); + this.aiServiceLogger.info('Finished Moonbeam participation flow', { + teamId, + channelId, + mode: 'argument-winner', + }); + return; + } + } + const baseInstructions = normalizedCustomPrompt ?? MOONBEAM_SYSTEM_INSTRUCTIONS; const systemInstructions = this.traitService.appendTraitContext(baseInstructions, traitContext); @@ -572,6 +601,137 @@ export class AIService { }); } + private async handleArgumentWinnerRequest( + teamId: string, + channelId: string, + taggedMessage: string, + historyMessages: MessageWithName[], + ): Promise { + const extractedArgument = await this.extractArgumentWinner(historyMessages, taggedMessage, teamId, channelId); + if (!extractedArgument) { + return false; + } + + const savedOutcome = await this.argumentPersistenceService.saveArgumentOutcome({ + teamId, + channelId, + argumentSummary: extractedArgument.summary, + participants: extractedArgument.participants, + winnerSlackId: extractedArgument.winnerSlackId, + pointValue: extractedArgument.pointValue, + }); + + if (!savedOutcome) { + return false; + } + + const responseText = this.buildArgumentWinnerResponse(savedOutcome); + await this.webService.sendMessage(channelId, responseText, [{ type: 'markdown', text: responseText }]); + await this.redis.setHasParticipated(teamId, channelId); + return true; + } + + private async extractArgumentWinner( + historyMessages: MessageWithName[], + taggedMessage: string, + teamId: string, + channelId: string, + ): Promise { + const history = this.formatHistory(historyMessages); + + return this.openAi.responses + .create({ + model: GPT_MODEL, + instructions: ARGUMENT_WINNER_EXTRACTION_PROMPT, + input: `${history}\n\n---\n[Request to evaluate the argument]:\n${taggedMessage}`, + user: `argument-winner-${channelId}-${teamId}-DaBros2016`, + }) + .then((response) => extractAndParseOpenAiResponse(response)) + .then((result) => this.parseArgumentWinnerExtraction(result)) + .catch((error) => { + logError(this.aiServiceLogger, 'Failed to extract argument winner', error, { + teamId, + channelId, + taggedMessage, + }); + return null; + }); + } + + private parseArgumentWinnerExtraction(result: string | undefined): ArgumentWinnerExtraction | null { + if (!result) { + return null; + } + + const trimmed = result.trim(); + if (trimmed === 'NONE' || trimmed === '"NONE"') { + return null; + } + + try { + const parsed: unknown = JSON.parse(trimmed); + if (!isRecord(parsed)) { + return null; + } + + const participants = Array.isArray(parsed.participants) + ? parsed.participants + .map((participant) => (isRecord(participant) ? participant : null)) + .map((participant) => + participant && + typeof participant.slackId === 'string' && + typeof participant.name === 'string' && + typeof participant.viewpoint === 'string' + ? { + slackId: participant.slackId.trim(), + name: participant.name.trim(), + viewpoint: participant.viewpoint.trim(), + } + : null, + ) + .filter((participant): participant is ArgumentParticipant => participant !== null) + : []; + + const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : ''; + const winnerSlackId = typeof parsed.winnerSlackId === 'string' ? parsed.winnerSlackId.trim() : ''; + const pointValue = typeof parsed.pointValue === 'number' ? parsed.pointValue : Number(parsed.pointValue); + + if ( + !summary || + participants.length < 2 || + !winnerSlackId || + !participants.some((x) => x.slackId === winnerSlackId) + ) { + return null; + } + + return { + summary, + participants, + winnerSlackId, + pointValue: Math.min(5, Math.max(0, Number.isFinite(pointValue) ? Math.round(pointValue) : 0)), + }; + } catch { + this.aiServiceLogger.warn('Argument winner extraction returned malformed JSON', { result: trimmed }); + return null; + } + } + + private buildArgumentWinnerResponse(outcome: { + argument: string; + participants: ArgumentParticipant[]; + winner: { name: string; slackId: string }; + pointValue: number; + }): string { + const participantSummary = outcome.participants + .map((participant) => `${participant.name} argued ${participant.viewpoint}`) + .join('; '); + + return ensureSentenceCaseAndPunctuation( + `${outcome.winner.name} won. The argument was about ${outcome.argument}. ${participantSummary}. Substance score: ${outcome.pointValue}/5`, + ); + } + private async updateMoonbeamProfilePhoto(imageBytes: Buffer): Promise { const profileImage = await sharp(imageBytes) .resize(512, 512, { fit: 'cover', position: 'centre' }) diff --git a/packages/backend/src/argument/argument.model.ts b/packages/backend/src/argument/argument.model.ts new file mode 100644 index 00000000..05ce280e --- /dev/null +++ b/packages/backend/src/argument/argument.model.ts @@ -0,0 +1,36 @@ +import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; + +export interface ArgumentLeaderboardStanding { + name: string; + slackId: string; + wins: number; + points: number; +} + +export interface ArgumentOutcomeWinner { + name: string; + slackId: string; +} + +export interface ArgumentOutcomeEntry { + id: number; + argument: string; + participants: ArgumentParticipant[]; + winner: ArgumentOutcomeWinner; + pointValue: number; + createdAt: string; +} + +export interface ArgumentLeaderboardResponse { + leaderboard: ArgumentLeaderboardStanding[]; + arguments: ArgumentOutcomeEntry[]; +} + +export interface SaveArgumentOutcomeInput { + teamId: string; + channelId: string; + argumentSummary: string; + participants: ArgumentParticipant[]; + winnerSlackId: string; + pointValue: number; +} diff --git a/packages/backend/src/argument/argument.persistence.service.spec.ts b/packages/backend/src/argument/argument.persistence.service.spec.ts new file mode 100644 index 00000000..8773b342 --- /dev/null +++ b/packages/backend/src/argument/argument.persistence.service.spec.ts @@ -0,0 +1,132 @@ +import { vi } from 'vitest'; +import { getRepository } from 'typeorm'; +import { ArgumentPersistenceService } from './argument.persistence.service'; + +vi.mock('typeorm', async () => { + const actual = await vi.importActual('typeorm'); + return { + ...actual, + getRepository: vi.fn(), + }; +}); + +describe('ArgumentPersistenceService', () => { + const findOne = vi.fn(); + const save = vi.fn(); + const query = vi.fn(); + let service: ArgumentPersistenceService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ArgumentPersistenceService(); + (getRepository as Mock).mockImplementation((entity: { name?: string }) => { + if (entity.name === 'SlackUser') { + return { findOne }; + } + + return { save, query }; + }); + }); + + it('saves an argument outcome with a resolved winner', async () => { + findOne.mockResolvedValue({ id: 7, slackId: 'U2', name: 'Bob' }); + save.mockImplementation(async (entry: { createdAt?: Date }) => ({ + ...entry, + id: 11, + createdAt: entry.createdAt ?? new Date('2026-05-21T00:00:00.000Z'), + })); + + const result = await service.saveArgumentOutcome({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 6, + }); + + expect(findOne).toHaveBeenCalledWith({ where: { slackId: 'U2', teamId: 'T1' } }); + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + pointValue: 5, + }), + ); + expect(result).toMatchObject({ + id: 11, + argument: 'Tabs versus spaces', + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 5, + }); + }); + + it('returns null when the winner cannot be mapped to a slack user', async () => { + findOne.mockResolvedValue(null); + + await expect( + service.saveArgumentOutcome({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }), + ).resolves.toBeNull(); + expect(save).not.toHaveBeenCalled(); + }); + + it('loads leaderboard standings and detailed argument outcomes', async () => { + query + .mockResolvedValueOnce([ + { name: 'Bob', slackId: 'U2', wins: '3', points: '12' }, + { name: 'Alice', slackId: 'U1', wins: '1', points: '4' }, + ]) + .mockResolvedValueOnce([ + { + id: 1, + argumentSummary: 'tabs vs spaces', + participants: JSON.stringify([ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ]), + winnerName: 'Bob', + winnerSlackId: 'U2', + pointValue: '4', + createdAt: '2026-05-21T00:00:00.000Z', + }, + ]); + + const result = await service.getArgumentLeaderboard('T1'); + + expect(query).toHaveBeenNthCalledWith(1, expect.stringContaining('CAST(COUNT(*) AS SIGNED) AS wins'), ['T1']); + expect(query).toHaveBeenNthCalledWith(2, expect.stringContaining('a.argumentSummary AS argumentSummary'), ['T1']); + expect(result).toEqual({ + leaderboard: [ + { name: 'Bob', slackId: 'U2', wins: 3, points: 12 }, + { name: 'Alice', slackId: 'U1', wins: 1, points: 4 }, + ], + arguments: [ + { + id: 1, + argument: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }, + ], + }); + }); +}); diff --git a/packages/backend/src/argument/argument.persistence.service.ts b/packages/backend/src/argument/argument.persistence.service.ts new file mode 100644 index 00000000..7ca448e4 --- /dev/null +++ b/packages/backend/src/argument/argument.persistence.service.ts @@ -0,0 +1,186 @@ +import { getRepository } from 'typeorm'; +import { ArgumentLeaderboard } from '../shared/db/models/ArgumentLeaderboard'; +import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; +import { SlackUser } from '../shared/db/models/SlackUser'; +import { logger } from '../shared/logger/logger'; +import { logError } from '../shared/logger/error-logging'; +import type { ArgumentLeaderboardResponse, ArgumentOutcomeEntry, SaveArgumentOutcomeInput } from './argument.model'; + +interface LeaderboardRow { + name: string; + slackId: string; + wins: string; + points: string; +} + +interface ArgumentRow { + id: number; + argumentSummary: string; + participants: string; + winnerName: string; + winnerSlackId: string; + pointValue: string; + createdAt: string | Date; +} + +const clampPointValue = (value: number): number => Math.min(5, Math.max(0, Math.round(value))); + +const normalizeParticipant = (participant: Partial): ArgumentParticipant | null => { + const slackId = participant.slackId?.trim(); + const name = participant.name?.trim(); + const viewpoint = participant.viewpoint?.trim(); + if (!slackId || !name || !viewpoint) { + return null; + } + + return { slackId, name, viewpoint }; +}; + +const parseParticipants = (raw: string): ArgumentParticipant[] => { + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((participant) => + typeof participant === 'object' && participant !== null ? normalizeParticipant(participant) : null, + ) + .filter((participant): participant is ArgumentParticipant => participant !== null); + } catch { + return []; + } +}; + +export class ArgumentPersistenceService { + private logger = logger.child({ module: 'ArgumentPersistenceService' }); + + async saveArgumentOutcome(input: SaveArgumentOutcomeInput): Promise { + const winner = await getRepository(SlackUser).findOne({ + where: { slackId: input.winnerSlackId, teamId: input.teamId }, + }); + + if (!winner) { + this.logger.warn('Skipping argument outcome save because winner was not found', { + teamId: input.teamId, + winnerSlackId: input.winnerSlackId, + }); + return null; + } + + const participants = Array.from( + new Map( + input.participants + .map(normalizeParticipant) + .filter((participant): participant is ArgumentParticipant => participant !== null) + .map((participant) => [participant.slackId, participant]), + ).values(), + ); + + if (participants.length < 2) { + this.logger.warn('Skipping argument outcome save because fewer than two participants were extracted', { + teamId: input.teamId, + channelId: input.channelId, + }); + return null; + } + + const argumentSummary = input.argumentSummary.trim(); + if (!argumentSummary) { + this.logger.warn('Skipping argument outcome save because summary was empty', { + teamId: input.teamId, + channelId: input.channelId, + }); + return null; + } + + const entry = new ArgumentLeaderboard(); + entry.teamId = input.teamId; + entry.channelId = input.channelId; + entry.argumentSummary = argumentSummary; + entry.participants = participants; + entry.winner = winner; + entry.pointValue = clampPointValue(input.pointValue); + + return getRepository(ArgumentLeaderboard) + .save(entry) + .then((saved) => ({ + id: saved.id, + argument: saved.argumentSummary, + participants: saved.participants, + winner: { + name: winner.name, + slackId: winner.slackId, + }, + pointValue: saved.pointValue, + createdAt: saved.createdAt.toISOString(), + })) + .catch((error) => { + logError(this.logger, 'Failed to save argument outcome', error, { + teamId: input.teamId, + channelId: input.channelId, + winnerSlackId: input.winnerSlackId, + }); + throw error; + }); + } + + async getArgumentLeaderboard(teamId: string): Promise { + const repo = getRepository(ArgumentLeaderboard); + + try { + const [leaderboardRows, argumentRows] = await Promise.all([ + repo.query( + `SELECT u.name AS name, + u.slackId AS slackId, + CAST(COUNT(*) AS SIGNED) AS wins, + CAST(COALESCE(SUM(a.pointValue), 0) AS SIGNED) AS points + FROM argument_leaderboard a + INNER JOIN slack_user u ON u.id = a.winnerId + WHERE a.teamId = ? + GROUP BY u.id, u.name, u.slackId + ORDER BY wins DESC, points DESC, u.name ASC`, + [teamId], + ), + repo.query( + `SELECT a.id AS id, + a.argumentSummary AS argumentSummary, + a.participants AS participants, + u.name AS winnerName, + u.slackId AS winnerSlackId, + CAST(a.pointValue AS SIGNED) AS pointValue, + a.createdAt AS createdAt + FROM argument_leaderboard a + INNER JOIN slack_user u ON u.id = a.winnerId + WHERE a.teamId = ? + ORDER BY a.createdAt DESC`, + [teamId], + ), + ]); + + return { + leaderboard: leaderboardRows.map((row) => ({ + name: row.name, + slackId: row.slackId, + wins: Number(row.wins), + points: Number(row.points), + })), + arguments: argumentRows.map((row) => ({ + id: Number(row.id), + argument: row.argumentSummary, + participants: parseParticipants(row.participants), + winner: { + name: row.winnerName, + slackId: row.winnerSlackId, + }, + pointValue: Number(row.pointValue), + createdAt: new Date(row.createdAt).toISOString(), + })), + }; + } catch (error) { + logError(this.logger, 'Failed to load argument leaderboard', error, { teamId }); + throw error; + } + } +} diff --git a/packages/backend/src/dashboard/dashboard.controller.ts b/packages/backend/src/dashboard/dashboard.controller.ts index a3ded677..da4bf49f 100644 --- a/packages/backend/src/dashboard/dashboard.controller.ts +++ b/packages/backend/src/dashboard/dashboard.controller.ts @@ -8,12 +8,14 @@ import { DEFAULT_PERIOD, VALID_PERIODS } from './dashboard.const'; import type { TimePeriod } from './dashboard.model'; import { MemoryPersistenceService } from '../ai/memory/memory.persistence.service'; import { TraitPersistenceService } from '../trait/trait.persistence.service'; +import { ArgumentPersistenceService } from '../argument/argument.persistence.service'; export const dashboardController: Router = express.Router(); const dashboardPersistenceService = new DashboardPersistenceService(); const memoryPersistenceService = new MemoryPersistenceService(); const traitPersistenceService = new TraitPersistenceService(); +const argumentPersistenceService = new ArgumentPersistenceService(); const dashboardLogger = logger.child({ module: 'DashboardController' }); function parsePeriod(value: unknown): TimePeriod { @@ -73,3 +75,20 @@ dashboardController.get('/personal-context', (req: RequestWithAuthSession, res) res.status(500).send(); }); }); + +dashboardController.get('/arguments', (req: RequestWithAuthSession, res) => { + const { teamId, userId } = req.authSession || {}; + + if (!teamId || !userId) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + argumentPersistenceService + .getArgumentLeaderboard(teamId) + .then((data) => res.status(200).json(data)) + .catch((e: unknown) => { + logError(dashboardLogger, 'Failed to load argument leaderboard data', e, { userId, teamId }); + res.status(500).send(); + }); +}); diff --git a/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts new file mode 100644 index 00000000..53977aeb --- /dev/null +++ b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts @@ -0,0 +1,35 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { SlackUser } from './SlackUser'; + +export interface ArgumentParticipant { + slackId: string; + name: string; + viewpoint: string; +} + +@Entity() +export class ArgumentLeaderboard { + @PrimaryGeneratedColumn() + public id!: number; + + @Column({ default: 'NOT_AVAILABLE' }) + public teamId!: string; + + @Column({ default: 'NOT_AVAILABLE' }) + public channelId!: string; + + @Column('text') + public argumentSummary!: string; + + @Column('simple-json') + public participants!: ArgumentParticipant[]; + + @ManyToOne(() => SlackUser, (user) => user.argumentWins) + public winner!: SlackUser; + + @Column({ type: 'int' }) + public pointValue!: number; + + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + public createdAt!: Date; +} diff --git a/packages/backend/src/shared/db/models/SlackUser.ts b/packages/backend/src/shared/db/models/SlackUser.ts index e23ad367..4a3e3f7f 100644 --- a/packages/backend/src/shared/db/models/SlackUser.ts +++ b/packages/backend/src/shared/db/models/SlackUser.ts @@ -1,5 +1,6 @@ import { Column, Entity, PrimaryGeneratedColumn, Unique, OneToMany, OneToOne } from 'typeorm'; import { Activity } from './Activity'; +import { ArgumentLeaderboard } from './ArgumentLeaderboard'; import { Memory } from './Memory'; import { Message } from './Message'; import { Portfolio } from './Portfolio'; @@ -39,6 +40,9 @@ export class SlackUser { @OneToMany(() => Message, (message) => message.userId) public messages?: Message[]; + @OneToMany(() => ArgumentLeaderboard, (argument) => argument.winner) + public argumentWins?: ArgumentLeaderboard[]; + @OneToMany(() => CalendarEvent, (calendarEvent) => calendarEvent.createdByUser) public createdCalendarEvents?: CalendarEvent[]; diff --git a/packages/frontend/src/app.model.ts b/packages/frontend/src/app.model.ts index 9fc9c174..de150716 100644 --- a/packages/frontend/src/app.model.ts +++ b/packages/frontend/src/app.model.ts @@ -76,6 +76,36 @@ export interface PersonalContextResponse { traits: PersonalContextEntry[]; } +export interface ArgumentParticipant { + slackId: string; + name: string; + viewpoint: string; +} + +export interface ArgumentLeaderboardStanding { + name: string; + slackId: string; + wins: number; + points: number; +} + +export interface ArgumentOutcomeEntry { + id: number; + argument: string; + participants: ArgumentParticipant[]; + winner: { + name: string; + slackId: string; + }; + pointValue: number; + createdAt: string; +} + +export interface ArgumentLeaderboardResponse { + leaderboard: ArgumentLeaderboardStanding[]; + arguments: ArgumentOutcomeEntry[]; +} + export type FrontendRecurrenceFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly'; export interface FrontendRecurrenceRule { diff --git a/packages/frontend/src/components/AppShell.model.ts b/packages/frontend/src/components/AppShell.model.ts index 54088aee..7545a114 100644 --- a/packages/frontend/src/components/AppShell.model.ts +++ b/packages/frontend/src/components/AppShell.model.ts @@ -1,6 +1,6 @@ import type { ElementType } from 'react'; -export type Page = 'home' | 'message-search' | 'personal-context' | 'calendar'; +export type Page = 'home' | 'message-search' | 'personal-context' | 'calendar' | 'arguments'; export interface NavItemProps { icon: ElementType; diff --git a/packages/frontend/src/components/AppShell.spec.tsx b/packages/frontend/src/components/AppShell.spec.tsx index f779d870..c806fc5b 100644 --- a/packages/frontend/src/components/AppShell.spec.tsx +++ b/packages/frontend/src/components/AppShell.spec.tsx @@ -36,6 +36,13 @@ describe('AppShell', () => { expect(screen.queryByRole('heading', { name: /^home$/i })).not.toBeInTheDocument(); }); + it('switches to the Argument Leaderboard page when its nav button is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /argument leaderboard/i })); + expect(screen.getByRole('heading', { name: /argument leaderboard/i })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /^home$/i })).not.toBeInTheDocument(); + }); + it('calls onLogout when the Sign out button is clicked', () => { const onLogout = vi.fn(); render(); diff --git a/packages/frontend/src/components/AppShell.tsx b/packages/frontend/src/components/AppShell.tsx index cf439b6c..f7249211 100644 --- a/packages/frontend/src/components/AppShell.tsx +++ b/packages/frontend/src/components/AppShell.tsx @@ -1,11 +1,12 @@ import { useState } from 'react'; -import { Home, Search, Brain, CalendarDays, LogOut } from 'lucide-react'; +import { Home, Search, Brain, CalendarDays, LogOut, Scale } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { HomePage } from '@/pages/HomePage'; import { MessageSearchPage } from '@/pages/MessageSearchPage'; import { PersonalContextPage } from '@/pages/PersonalContextPage'; import { CalendarPage } from '@/pages/CalendarPage'; +import { ArgumentLeaderboardPage } from '@/pages/ArgumentLeaderboardPage'; import type { Page, NavItemProps, AppShellProps } from '@/components/AppShell.model'; function NavItem({ icon: Icon, label, active, onClick }: NavItemProps) { @@ -50,6 +51,12 @@ export function AppShell({ onLogout }: AppShellProps) { active={currentPage === 'personal-context'} onClick={() => setCurrentPage('personal-context')} /> + setCurrentPage('arguments')} + /> } {currentPage === 'message-search' && } {currentPage === 'personal-context' && } + {currentPage === 'arguments' && } {currentPage === 'calendar' && } diff --git a/packages/frontend/src/hooks/useArgumentLeaderboard.spec.ts b/packages/frontend/src/hooks/useArgumentLeaderboard.spec.ts new file mode 100644 index 00000000..6f5fd727 --- /dev/null +++ b/packages/frontend/src/hooks/useArgumentLeaderboard.spec.ts @@ -0,0 +1,54 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { useArgumentLeaderboard } from '@/hooks/useArgumentLeaderboard'; +import { AUTH_TOKEN_KEY } from '@/app.const'; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const mockData = { + leaderboard: [{ name: 'Bob', slackId: 'U2', wins: 3, points: 12 }], + arguments: [ + { + id: 1, + argument: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }, + ], +}; + +beforeEach(() => { + localStorage.setItem(AUTH_TOKEN_KEY, 'test-token'); + mockFetch.mockReset(); +}); + +describe('useArgumentLeaderboard', () => { + it('returns data and clears loading state on success', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => mockData }); + const { result } = renderHook(() => useArgumentLeaderboard(vi.fn())); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual(mockData); + expect(result.current.error).toBeNull(); + }); + + it('calls onLogout when the response status is 401', async () => { + const onLogout = vi.fn(); + mockFetch.mockResolvedValue({ ok: false, status: 401 }); + renderHook(() => useArgumentLeaderboard(onLogout)); + await waitFor(() => expect(onLogout).toHaveBeenCalledOnce()); + }); + + it('includes the auth token in the request headers', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => mockData }); + renderHook(() => useArgumentLeaderboard(vi.fn())); + await waitFor(() => expect(mockFetch).toHaveBeenCalledOnce()); + const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/dashboard/arguments'); + expect((options.headers as Record)['Authorization']).toBe('Bearer test-token'); + }); +}); diff --git a/packages/frontend/src/hooks/useArgumentLeaderboard.ts b/packages/frontend/src/hooks/useArgumentLeaderboard.ts new file mode 100644 index 00000000..264b708c --- /dev/null +++ b/packages/frontend/src/hooks/useArgumentLeaderboard.ts @@ -0,0 +1,65 @@ +import { useEffect, useRef, useState } from 'react'; +import { AUTH_TOKEN_KEY } from '@/app.const'; +import { API_BASE_URL } from '@/config'; +import type { ArgumentLeaderboardResponse } from '@/app.model'; + +export interface UseArgumentLeaderboardReturn { + data: ArgumentLeaderboardResponse | null; + isLoading: boolean; + error: string | null; +} + +export function useArgumentLeaderboard(onLogout: () => void): UseArgumentLeaderboardReturn { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const onLogoutRef = useRef(onLogout); + onLogoutRef.current = onLogout; + + useEffect(() => { + const abortController = new AbortController(); + + const fetchArgumentLeaderboard = async () => { + setIsLoading(true); + setError(null); + + try { + const token = localStorage.getItem(AUTH_TOKEN_KEY) ?? ''; + const response = await fetch(`${API_BASE_URL}/dashboard/arguments`, { + headers: { Authorization: `Bearer ${token}` }, + signal: abortController.signal, + }); + + if (response.status === 401) { + onLogoutRef.current(); + return; + } + + if (!response.ok) { + throw new Error(`Failed to load argument leaderboard: ${response.statusText}`); + } + + const json = (await response.json()) as ArgumentLeaderboardResponse; + setData(json); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + return; + } + + setError(err instanceof Error ? err.message : 'Failed to load argument leaderboard'); + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }; + + void fetchArgumentLeaderboard(); + + return () => { + abortController.abort(); + }; + }, []); + + return { data, isLoading, error }; +} diff --git a/packages/frontend/src/pages/ArgumentLeaderboardPage.model.ts b/packages/frontend/src/pages/ArgumentLeaderboardPage.model.ts new file mode 100644 index 00000000..278989d0 --- /dev/null +++ b/packages/frontend/src/pages/ArgumentLeaderboardPage.model.ts @@ -0,0 +1,3 @@ +export interface ArgumentLeaderboardPageProps { + onLogout: () => void; +} diff --git a/packages/frontend/src/pages/ArgumentLeaderboardPage.spec.tsx b/packages/frontend/src/pages/ArgumentLeaderboardPage.spec.tsx new file mode 100644 index 00000000..220f8dbf --- /dev/null +++ b/packages/frontend/src/pages/ArgumentLeaderboardPage.spec.tsx @@ -0,0 +1,79 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { ArgumentLeaderboardPage } from '@/pages/ArgumentLeaderboardPage'; + +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +const fullData = { + leaderboard: [ + { name: 'Bob', slackId: 'U2', wins: 3, points: 12 }, + { name: 'Alice', slackId: 'U1', wins: 1, points: 4 }, + ], + arguments: [ + { + id: 1, + argument: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }, + { + id: 2, + argument: 'vim vs emacs', + participants: [ + { slackId: 'U3', name: 'Carol', viewpoint: 'vim is faster' }, + { slackId: 'U4', name: 'Dave', viewpoint: 'emacs is more flexible' }, + ], + winner: { name: 'Carol', slackId: 'U3' }, + pointValue: 5, + createdAt: '2026-05-20T00:00:00.000Z', + }, + ], +}; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('ArgumentLeaderboardPage', () => { + it('renders leaderboard standings and the default selected argument', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => fullData }); + render(); + + expect(screen.getByRole('heading', { name: /argument leaderboard/i })).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText('Top debaters')).toBeInTheDocument()); + expect(screen.getByText('#1')).toBeInTheDocument(); + expect(screen.getAllByText('Bob').length).toBeGreaterThan(0); + expect(screen.getAllByText('tabs vs spaces').length).toBeGreaterThan(0); + expect(screen.getByText('spaces are clearer')).toBeInTheDocument(); + }); + + it('switches the detailed outcome when a different argument is selected', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => fullData }); + render(); + + await waitFor(() => expect(screen.getByText('vim vs emacs')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /vim vs emacs/i })); + expect(screen.getByText('emacs is more flexible')).toBeInTheDocument(); + expect(screen.getAllByText('Carol').length).toBeGreaterThan(0); + }); + + it('shows an empty state when there are no saved arguments', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ leaderboard: [], arguments: [] }) }); + render(); + + await waitFor(() => expect(screen.getByText(/no arguments have been judged yet/i)).toBeInTheDocument()); + expect(screen.getByText(/no argument outcomes are available yet/i)).toBeInTheDocument(); + }); + + it('shows an error banner when the fetch fails', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: 'Internal Server Error' }); + render(); + + await waitFor(() => expect(screen.getByText(/failed to load argument leaderboard/i)).toBeInTheDocument()); + }); +}); diff --git a/packages/frontend/src/pages/ArgumentLeaderboardPage.tsx b/packages/frontend/src/pages/ArgumentLeaderboardPage.tsx new file mode 100644 index 00000000..a83b6725 --- /dev/null +++ b/packages/frontend/src/pages/ArgumentLeaderboardPage.tsx @@ -0,0 +1,209 @@ +import { useMemo, useState } from 'react'; +import { AlertCircle, MessageSquareQuote, Scale, Trophy } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useArgumentLeaderboard } from '@/hooks/useArgumentLeaderboard'; +import type { ArgumentOutcomeEntry, ArgumentParticipant } from '@/app.model'; +import type { ArgumentLeaderboardPageProps } from '@/pages/ArgumentLeaderboardPage.model'; + +function formatDateLabel(isoDate: string): string { + return new Date(isoDate).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +function WinnerBadge({ participant, isWinner }: { participant: ArgumentParticipant; isWinner: boolean }) { + return ( +
+
+

{participant.name}

+ {isWinner && ( + + + )} +
+

{participant.viewpoint}

+
+ ); +} + +export function ArgumentLeaderboardPage({ onLogout }: ArgumentLeaderboardPageProps) { + const { data, isLoading, error } = useArgumentLeaderboard(onLogout); + const [selectedArgumentId, setSelectedArgumentId] = useState(null); + + const selectedArgument = useMemo(() => { + if (!data?.arguments.length) { + return null; + } + + if (selectedArgumentId === null) { + return data.arguments[0]; + } + + return data.arguments.find((argument) => argument.id === selectedArgumentId) ?? data.arguments[0]; + }, [data, selectedArgumentId]); + + return ( +
+
+

Argument Leaderboard

+

+ See who wins the most debates, how many substance points they have earned, and review past verdicts. +

+
+ + {error && ( + + + + + )} + +
+ + + + + Ranked by argument wins, with substance points as the tiebreaker. + + + {isLoading ? ( +

Loading leaderboard...

+ ) : !data?.leaderboard.length ? ( +

No arguments have been judged yet.

+ ) : ( +
+ + + + + + + + + + + {data.leaderboard.map((entry, index) => ( + + + + + + + ))} + +
RankUserWinsPoints
#{index + 1}{entry.name}{entry.wins}{entry.points}
+
+ )} +
+
+
+ +
+
+ + + + + Pick a verdict to review the argument, sides, winner, and point value. + + + {isLoading ? ( +

Loading argument history...

+ ) : !data?.arguments.length ? ( +

No argument outcomes are available yet.

+ ) : ( +
+ {data.arguments.map((argument) => ( + + ))} +
+ )} +
+
+
+ +
+ + + + + Review how Moonbeam scored the selected debate. + + + {!selectedArgument ? ( +

Select an argument to inspect the full outcome.

+ ) : ( +
+
+

Argument

+

{selectedArgument.argument}

+
+ +
+

Participants

+
+ {selectedArgument.participants.map((participant) => ( + + ))} +
+
+ +
+
+
+

Winner

+

{selectedArgument.winner.name}

+
+ +
+

+ Logged on {formatDateLabel(selectedArgument.createdAt)} +

+
+
+ )} +
+
+
+
+
+ ); +} From a9a22853fb840c5728ba7b7f39a28d3dd685d52d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 15:19:24 +0000 Subject: [PATCH 03/21] refactor: move argument extraction to nightly job --- README.md | 14 +- packages/backend/src/ai/ai.constants.ts | 64 ++++-- packages/backend/src/ai/ai.service.spec.ts | 69 ------ packages/backend/src/ai/ai.service.ts | 160 ------------- .../backend/src/argument/argument.job.spec.ts | 162 +++++++++++++ packages/backend/src/argument/argument.job.ts | 213 ++++++++++++++++++ packages/backend/src/job.service.spec.ts | 62 +++-- packages/backend/src/job.service.ts | 47 ++-- 8 files changed, 502 insertions(+), 289 deletions(-) create mode 100644 packages/backend/src/argument/argument.job.spec.ts create mode 100644 packages/backend/src/argument/argument.job.ts diff --git a/README.md b/README.md index 8efe855b..80bb0902 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ docker logs | jq . ### AI Features (Optional) -- **Daily Memory Job** - Summarizes conversations daily at 3 AM (requires OpenAI API key) +- **Daily Memory + Argument Jobs** - Analyze conversations daily at 3 AM (requires OpenAI API key) - **Sentiment Analysis** - Analyzes message tone - **AI Summaries** - Generates summaries of message threads @@ -262,12 +262,12 @@ docker logs | jq . Most scheduled jobs run inside the backend Node.js process using `node-cron`. They are started automatically when the server connects to the database. -| Job | Schedule | Location | Description | -| ---------------- | -------------------------------- | ----------- | ---------------------------------------------------------------------------------- | -| **Daily Memory** | `0 3 * * *` (3 AM ET) | In-process | Extracts AI memories from all Slack channels | -| **Fun Fact** | `0 9 * * *` (9 AM ET) | In-process | Posts daily facts, joke, quote, and on-this-day event to Slack | -| **Pricing** | `10 * * * *` (every hour at :10) | In-process | Recalculates item prices based on median reputation | -| **Health Check** | `*/5 * * * *` (every 5 min) | Bash script | Checks the `/health` endpoint from outside the process and alerts Slack on failure | +| Job | Schedule | Location | Description | +| ----------------------------- | -------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- | +| **Nightly Analysis Sequence** | `0 3 * * *` (3 AM ET) | In-process | Runs memory extraction, selective argument leaderboard extraction, and trait synthesis in order | +| **Fun Fact** | `0 9 * * *` (9 AM ET) | In-process | Posts daily facts, joke, quote, and on-this-day event to Slack | +| **Pricing** | `10 * * * *` (every hour at :10) | In-process | Recalculates item prices based on median reputation | +| **Health Check** | `*/5 * * * *` (every 5 min) | Bash script | Checks the `/health` endpoint from outside the process and alerts Slack on failure | #### Fun Fact Job environment variables diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index 86716b59..53e6c2c3 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -207,26 +207,48 @@ Output format: - If no strong traits are present, return []`; export const DAILY_MEMORY_JOB_CONCURRENCY = 50; - -export const ARGUMENT_WINNER_EXTRACTION_PROMPT = `You are analyzing a Slack conversation after someone asked Moonbeam who won an argument. - -Return ONLY one of these: -- the exact string NONE when there is no clear argument with a winner in the provided context -- a single JSON object in this format: - { - "summary": "short summary of the argument", - "participants": [ - { "slackId": "U123", "name": "Alice", "viewpoint": "their side of the argument" } - ], - "winnerSlackId": "U123", - "pointValue": 4 - } - -Rules: -- use the conversation history to identify the argument topic, each participant, and each participant's viewpoint +export const DAILY_ARGUMENT_JOB_CONCURRENCY = 50; + +export const ARGUMENT_EXTRACTION_PROMPT = `You are a highly selective argument detection tool reviewing one Slack channel's last 24 hours of conversation. + +Your job is to identify the single strongest real argument between humans in this channel, if one clearly happened, and determine who won. + +You must be INTOLERANT OF FALSE POSITIVES. +Prefer false negatives over false positives. +If there is any meaningful doubt that the conversation was a real argument, return NONE. + +ONLY treat something as an argument when ALL of the following are true: +- at least two identifiable human participants directly disagreed +- the disagreement involved competing viewpoints, not just different preferences stated once +- there was sustained back-and-forth with rebuttals or direct challenges +- the winner can be identified from the substance of the exchange + +Return NONE for: +- jokes, banter, teasing, sarcasm, or friendly ribbing +- brief disagreements without sustained rebuttals +- brainstorming, clarification, or neutral discussion +- factual Q&A where one person is simply correct +- bot interactions or people reacting to Moonbeam +- any conversation where the winner is ambiguous +- weak or low-substance disagreements that do not merit leaderboard tracking + +If you find a qualifying argument, return ONLY a single JSON object in this exact shape: +{ + "summary": "short summary of the argument", + "participants": [ + { "slackId": "U123", "name": "Alice", "viewpoint": "their side of the argument" } + ], + "winnerSlackId": "U123", + "pointValue": 4 +} + +Rules for valid JSON output: - include at least 2 participants +- every participant must be a human from the conversation - the winnerSlackId must match one of the listed participants -- pointValue must be an integer from 0 to 5 based on the substance of the argument; reward longer, more detailed, more back-and-forth arguments with higher values -- keep summary and viewpoints concise but specific -- do not include Markdown, explanations, or extra keys -- if the winner or participants are ambiguous, return NONE`; +- pointValue must be an integer from 0 to 5 based on substance/depth +- reserve 4-5 for long, detailed, high-signal arguments +- if the argument is too weak to feel leaderboard-worthy, return NONE instead of a low-confidence object +- do not include markdown, prose, or extra keys + +If no conversation clearly qualifies, return the exact string NONE.`; diff --git a/packages/backend/src/ai/ai.service.spec.ts b/packages/backend/src/ai/ai.service.spec.ts index 24e28a92..c1a0aeb8 100644 --- a/packages/backend/src/ai/ai.service.spec.ts +++ b/packages/backend/src/ai/ai.service.spec.ts @@ -71,10 +71,6 @@ const buildAiService = (): AIService => { getCustomPrompt: vi.fn().mockResolvedValue(null), } as unknown as AIService['slackPersistenceService']; - ai.argumentPersistenceService = { - saveArgumentOutcome: vi.fn().mockResolvedValue(null), - } as unknown as AIService['argumentPersistenceService']; - ai.aiServiceLogger = { error: vi.fn(), warn: vi.fn(), @@ -619,71 +615,6 @@ describe('AIService', () => { expect(callArgs.instructions).toContain('traits_context'); expect(callArgs.instructions).toContain('dislikes donald trump'); }); - - it('extracts, saves, and answers argument winner requests', async () => { - (aiService.historyService.getHistoryWithOptions as Mock).mockResolvedValue([ - { slackId: 'U1', name: 'Alice', message: 'Tabs are better than spaces.' }, - { slackId: 'U2', name: 'Bob', message: 'Spaces are easier to read.' }, - ]); - (aiService.openAi.responses.create as Mock).mockResolvedValue({ - output: [ - { - type: 'message', - content: [ - { - type: 'output_text', - text: JSON.stringify({ - summary: 'tabs vs spaces', - participants: [ - { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, - { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are more readable' }, - ], - winnerSlackId: 'U2', - pointValue: 4, - }), - }, - ], - }, - ], - }); - (aiService.argumentPersistenceService.saveArgumentOutcome as Mock).mockResolvedValue({ - id: 1, - argument: 'tabs vs spaces', - participants: [ - { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, - { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are more readable' }, - ], - winner: { name: 'Bob', slackId: 'U2' }, - pointValue: 4, - createdAt: '2026-05-21T00:00:00.000Z', - }); - - await aiService.participate('T1', 'C1', '<@moonbeam> who won the argument about tabs versus spaces?'); - - expect(aiService.argumentPersistenceService.saveArgumentOutcome).toHaveBeenCalledWith({ - teamId: 'T1', - channelId: 'C1', - argumentSummary: 'tabs vs spaces', - participants: [ - { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, - { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are more readable' }, - ], - winnerSlackId: 'U2', - pointValue: 4, - }); - expect(aiService.webService.sendMessage).toHaveBeenCalledWith( - 'C1', - 'Bob won. The argument was about tabs vs spaces. Alice argued tabs are faster; Bob argued spaces are more readable. Substance score: 4/5.', - [ - { - type: 'markdown', - text: 'Bob won. The argument was about tabs vs spaces. Alice argued tabs are faster; Bob argued spaces are more readable. Substance score: 4/5.', - }, - ], - ); - expect(aiService.redis.setHasParticipated).toHaveBeenCalledWith('T1', 'C1'); - expect(aiService.redis.removeParticipationInFlight).toHaveBeenCalledWith('C1', 'T1'); - }); }); describe('participate with custom prompt', () => { diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index ffa499be..837a4ce1 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -10,7 +10,6 @@ import { AIPersistenceService } from './ai.persistence'; import type { KnownBlock } from '@slack/web-api'; import { WebService } from '../shared/services/web/web.service'; import { - ARGUMENT_WINNER_EXTRACTION_PROMPT, CORPO_SPEAK_INSTRUCTIONS, GENERAL_TEXT_INSTRUCTIONS, MOONBEAM_SYSTEM_INSTRUCTIONS, @@ -38,8 +37,6 @@ import { GoogleGenAI } from '@google/genai'; import sharp from 'sharp'; import { extractParticipantSlackIds } from './helpers/extractParticipantSlackIds'; import { TraitService } from '../trait/trait.service'; -import { ArgumentPersistenceService } from '../argument/argument.persistence.service'; -import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; interface ReleaseCommit { sha: string; @@ -52,13 +49,6 @@ interface ReleaseMetadata { commits: ReleaseCommit[]; } -interface ArgumentWinnerExtraction { - summary: string; - participants: ArgumentParticipant[]; - winnerSlackId: string; - pointValue: number; -} - const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; const isResponseOutputMessage = (block: ResponseOutputItem): block is ResponseOutputMessage => block.type === 'message'; @@ -112,11 +102,6 @@ const normalizeReleaseSha = (value?: string): string | null => { return trimmed; }; -const isArgumentWinnerRequest = (taggedMessage: string): boolean => { - const normalized = taggedMessage.toLowerCase(); - return normalized.includes('who won') && (normalized.includes('argument') || normalized.includes('debate')); -}; - export class AIService { redis = new AIPersistenceService(); openAi = new OpenAI({ @@ -130,7 +115,6 @@ export class AIService { slackService = new SlackService(); slackPersistenceService = new SlackPersistenceService(); traitService = new TraitService(); - argumentPersistenceService = new ArgumentPersistenceService(); aiServiceLogger = logger.child({ module: 'AIService' }); public decrementDaiyRequests(userId: string, teamId: string): Promise { @@ -529,19 +513,6 @@ export class AIService { hasCustomPrompt: normalizedCustomPrompt !== null, }); - if (isArgumentWinnerRequest(taggedMessage)) { - const handled = await this.handleArgumentWinnerRequest(teamId, channelId, taggedMessage, historyMessages); - if (handled) { - await this.redis.removeParticipationInFlight(channelId, teamId); - this.aiServiceLogger.info('Finished Moonbeam participation flow', { - teamId, - channelId, - mode: 'argument-winner', - }); - return; - } - } - const baseInstructions = normalizedCustomPrompt ?? MOONBEAM_SYSTEM_INSTRUCTIONS; const systemInstructions = this.traitService.appendTraitContext(baseInstructions, traitContext); @@ -601,137 +572,6 @@ export class AIService { }); } - private async handleArgumentWinnerRequest( - teamId: string, - channelId: string, - taggedMessage: string, - historyMessages: MessageWithName[], - ): Promise { - const extractedArgument = await this.extractArgumentWinner(historyMessages, taggedMessage, teamId, channelId); - if (!extractedArgument) { - return false; - } - - const savedOutcome = await this.argumentPersistenceService.saveArgumentOutcome({ - teamId, - channelId, - argumentSummary: extractedArgument.summary, - participants: extractedArgument.participants, - winnerSlackId: extractedArgument.winnerSlackId, - pointValue: extractedArgument.pointValue, - }); - - if (!savedOutcome) { - return false; - } - - const responseText = this.buildArgumentWinnerResponse(savedOutcome); - await this.webService.sendMessage(channelId, responseText, [{ type: 'markdown', text: responseText }]); - await this.redis.setHasParticipated(teamId, channelId); - return true; - } - - private async extractArgumentWinner( - historyMessages: MessageWithName[], - taggedMessage: string, - teamId: string, - channelId: string, - ): Promise { - const history = this.formatHistory(historyMessages); - - return this.openAi.responses - .create({ - model: GPT_MODEL, - instructions: ARGUMENT_WINNER_EXTRACTION_PROMPT, - input: `${history}\n\n---\n[Request to evaluate the argument]:\n${taggedMessage}`, - user: `argument-winner-${channelId}-${teamId}-DaBros2016`, - }) - .then((response) => extractAndParseOpenAiResponse(response)) - .then((result) => this.parseArgumentWinnerExtraction(result)) - .catch((error) => { - logError(this.aiServiceLogger, 'Failed to extract argument winner', error, { - teamId, - channelId, - taggedMessage, - }); - return null; - }); - } - - private parseArgumentWinnerExtraction(result: string | undefined): ArgumentWinnerExtraction | null { - if (!result) { - return null; - } - - const trimmed = result.trim(); - if (trimmed === 'NONE' || trimmed === '"NONE"') { - return null; - } - - try { - const parsed: unknown = JSON.parse(trimmed); - if (!isRecord(parsed)) { - return null; - } - - const participants = Array.isArray(parsed.participants) - ? parsed.participants - .map((participant) => (isRecord(participant) ? participant : null)) - .map((participant) => - participant && - typeof participant.slackId === 'string' && - typeof participant.name === 'string' && - typeof participant.viewpoint === 'string' - ? { - slackId: participant.slackId.trim(), - name: participant.name.trim(), - viewpoint: participant.viewpoint.trim(), - } - : null, - ) - .filter((participant): participant is ArgumentParticipant => participant !== null) - : []; - - const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : ''; - const winnerSlackId = typeof parsed.winnerSlackId === 'string' ? parsed.winnerSlackId.trim() : ''; - const pointValue = typeof parsed.pointValue === 'number' ? parsed.pointValue : Number(parsed.pointValue); - - if ( - !summary || - participants.length < 2 || - !winnerSlackId || - !participants.some((x) => x.slackId === winnerSlackId) - ) { - return null; - } - - return { - summary, - participants, - winnerSlackId, - pointValue: Math.min(5, Math.max(0, Number.isFinite(pointValue) ? Math.round(pointValue) : 0)), - }; - } catch { - this.aiServiceLogger.warn('Argument winner extraction returned malformed JSON', { result: trimmed }); - return null; - } - } - - private buildArgumentWinnerResponse(outcome: { - argument: string; - participants: ArgumentParticipant[]; - winner: { name: string; slackId: string }; - pointValue: number; - }): string { - const participantSummary = outcome.participants - .map((participant) => `${participant.name} argued ${participant.viewpoint}`) - .join('; '); - - return ensureSentenceCaseAndPunctuation( - `${outcome.winner.name} won. The argument was about ${outcome.argument}. ${participantSummary}. Substance score: ${outcome.pointValue}/5`, - ); - } - private async updateMoonbeamProfilePhoto(imageBytes: Buffer): Promise { const profileImage = await sharp(imageBytes) .resize(512, 512, { fit: 'cover', position: 'centre' }) diff --git a/packages/backend/src/argument/argument.job.spec.ts b/packages/backend/src/argument/argument.job.spec.ts new file mode 100644 index 00000000..1fd2f1e5 --- /dev/null +++ b/packages/backend/src/argument/argument.job.spec.ts @@ -0,0 +1,162 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ArgumentJob } from './argument.job'; + +describe('ArgumentJob', () => { + let job: ArgumentJob; + let argumentPersistenceService: { + saveArgumentOutcome: ReturnType; + }; + let redis: { + getValue: ReturnType; + setValueWithExpire: ReturnType; + }; + let jobLogger: { + info: ReturnType; + warn: ReturnType; + }; + let aiService: { + formatHistory: ReturnType; + openAi: { + responses: { + create: ReturnType; + }; + }; + }; + + beforeEach(() => { + job = new ArgumentJob({ formatHistory: vi.fn() } as never); + argumentPersistenceService = { + saveArgumentOutcome: vi.fn().mockResolvedValue(null), + }; + redis = { + getValue: vi.fn().mockResolvedValue(null), + setValueWithExpire: vi.fn().mockResolvedValue('OK'), + }; + jobLogger = { + info: vi.fn(), + warn: vi.fn(), + }; + aiService = { + formatHistory: vi.fn().mockReturnValue('formatted history'), + openAi: { + responses: { + create: vi.fn(), + }, + }, + }; + + (job as never as { argumentPersistenceService: unknown }).argumentPersistenceService = argumentPersistenceService; + (job as never as { redis: unknown }).redis = redis; + (job as never as { jobLogger: unknown }).jobLogger = jobLogger; + (job as never as { aiService: unknown }).aiService = aiService; + }); + + it('returns early when extraction lock exists', async () => { + redis.getValue.mockResolvedValue('1'); + + await ( + job as never as { + extractArgument: ( + teamId: string, + channelId: string, + historyMessages: Array<{ message: string }>, + ) => Promise; + } + ).extractArgument('T1', 'C1', [{ message: 'history' }]); + + expect(jobLogger.info).toHaveBeenCalled(); + expect(aiService.openAi.responses.create).not.toHaveBeenCalled(); + }); + + it('does nothing when extractor returns NONE', async () => { + aiService.openAi.responses.create.mockResolvedValue({ + output: [{ type: 'message', content: [{ type: 'output_text', text: 'NONE' }] }], + }); + + await ( + job as never as { + extractArgument: ( + teamId: string, + channelId: string, + historyMessages: Array<{ message: string }>, + ) => Promise; + } + ).extractArgument('T1', 'C1', [{ message: 'history' }]); + + expect(argumentPersistenceService.saveArgumentOutcome).not.toHaveBeenCalled(); + }); + + it('saves a valid extracted argument outcome', async () => { + aiService.openAi.responses.create.mockResolvedValue({ + output: [ + { + type: 'message', + content: [ + { + type: 'output_text', + text: JSON.stringify({ + summary: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }), + }, + ], + }, + ], + }); + argumentPersistenceService.saveArgumentOutcome.mockResolvedValue({ + id: 1, + argument: 'tabs vs spaces', + participants: [], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }); + + await ( + job as never as { + extractArgument: ( + teamId: string, + channelId: string, + historyMessages: Array<{ message: string; slackId: string; name: string }>, + ) => Promise; + } + ).extractArgument('T1', 'C1', [{ slackId: 'U1', name: 'Alice', message: 'history' }]); + + expect(argumentPersistenceService.saveArgumentOutcome).toHaveBeenCalledWith({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }); + expect(jobLogger.info).toHaveBeenCalledWith('Argument extracted for C1: "tabs vs spaces"'); + }); + + it('skips malformed extraction payloads and logs warnings', async () => { + aiService.openAi.responses.create.mockResolvedValue({ + output: [{ type: 'message', content: [{ type: 'output_text', text: '{"summary":"oops"}' }] }], + }); + + await ( + job as never as { + extractArgument: ( + teamId: string, + channelId: string, + historyMessages: Array<{ message: string }>, + ) => Promise; + } + ).extractArgument('T1', 'C1', [{ message: 'history' }]); + + expect(argumentPersistenceService.saveArgumentOutcome).not.toHaveBeenCalled(); + expect(jobLogger.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/argument/argument.job.ts b/packages/backend/src/argument/argument.job.ts new file mode 100644 index 00000000..9e7b2d03 --- /dev/null +++ b/packages/backend/src/argument/argument.job.ts @@ -0,0 +1,213 @@ +import { getRepository } from 'typeorm'; +import type OpenAI from 'openai'; +import { SlackChannel } from '../shared/db/models/SlackChannel'; +import type { MessageWithName } from '../shared/models/message/message-with-name'; +import { HistoryPersistenceService } from '../shared/services/history.persistence.service'; +import { RedisPersistenceService } from '../shared/services/redis.persistence.service'; +import { AIService } from '../ai/ai.service'; +import { logger } from '../shared/logger/logger'; +import { logError } from '../shared/logger/error-logging'; +import { + DAILY_ARGUMENT_JOB_CONCURRENCY, + ARGUMENT_EXTRACTION_PROMPT, + GATE_MODEL, + MOONBEAM_SLACK_ID, +} from '../ai/ai.constants'; +import { extractParticipantSlackIds } from '../ai/helpers/extractParticipantSlackIds'; +import { ArgumentPersistenceService } from './argument.persistence.service'; +import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; + +interface ArgumentExtractionResult { + summary: string; + participants: ArgumentParticipant[]; + winnerSlackId: string; + pointValue: number; +} + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => { + const textBlock = response.output.find((item) => item.type === 'message'); + if (textBlock && 'content' in textBlock) { + const outputText = textBlock.content.find((item) => item.type === 'output_text'); + return outputText?.text.trim(); + } + return undefined; +}; + +export class ArgumentJob { + private historyService = new HistoryPersistenceService(); + private argumentPersistenceService = new ArgumentPersistenceService(); + private redis = RedisPersistenceService.getInstance(); + private aiService: AIService; + private jobLogger = logger.child({ module: 'ArgumentJob' }); + + constructor(aiService?: AIService) { + this.aiService = aiService ?? new AIService(); + } + + async run(): Promise { + this.jobLogger.info('Starting argument extraction job'); + + const channels = await getRepository(SlackChannel).find(); + const results = await this.runWithConcurrencyLimit( + channels.map((channel) => () => this.extractArgumentForChannel(channel.teamId, channel.channelId)), + DAILY_ARGUMENT_JOB_CONCURRENCY, + ); + + const failed = results + .map((result, index) => ({ result, index })) + .filter((item): item is { result: PromiseRejectedResult; index: number } => item.result.status === 'rejected'); + + failed.forEach(({ result, index }) => { + const channel = channels[index]; + this.jobLogger.warn( + `Failed to extract argument for channel ${channel.channelId} (team ${channel.teamId}):`, + result.reason, + ); + }); + + const processed = results.length - failed.length; + this.jobLogger.info(`Argument extraction job complete: processed ${processed}/${channels.length} channels`); + } + + private async extractArgumentForChannel(teamId: string, channelId: string): Promise { + const historyMessages = await this.historyService.getLast24HoursForChannel(teamId, channelId); + if (historyMessages.length === 0) return; + + const participantSlackIds = extractParticipantSlackIds(historyMessages, { + excludeSlackIds: [MOONBEAM_SLACK_ID], + }); + if (participantSlackIds.length < 2) return; + + await this.extractArgument(teamId, channelId, historyMessages); + } + + private async extractArgument(teamId: string, channelId: string, historyMessages: MessageWithName[]): Promise { + const lockKey = `argument_extraction_lock:${teamId}:${channelId}`; + const locked = await this.redis.getValue(lockKey); + if (locked) { + this.jobLogger.info(`Argument extraction lock active for ${channelId}-${teamId}, skipping`); + return; + } + await this.redis.setValueWithExpire(lockKey, 1, 'EX', 300000); + + try { + const history = this.aiService.formatHistory(historyMessages); + const result = await this.aiService.openAi.responses + .create({ + model: GATE_MODEL, + instructions: ARGUMENT_EXTRACTION_PROMPT, + input: history, + user: `nightly-argument-${channelId}-${teamId}`, + }) + .then((response) => extractAndParseOpenAiResponse(response)); + + const parsedResult = this.parseExtractionResult(result); + if (!parsedResult) { + return; + } + + const saved = await this.argumentPersistenceService.saveArgumentOutcome({ + teamId, + channelId, + argumentSummary: parsedResult.summary, + participants: parsedResult.participants, + winnerSlackId: parsedResult.winnerSlackId, + pointValue: parsedResult.pointValue, + }); + + if (saved) { + this.jobLogger.info(`Argument extracted for ${channelId}: "${saved.argument}"`); + } + } catch (error) { + logError(this.jobLogger, 'Argument extraction failed', error, { teamId, channelId }); + } + } + + private parseExtractionResult(result: string | undefined): ArgumentExtractionResult | null { + if (!result) { + this.jobLogger.warn('Argument extraction returned no result'); + return null; + } + + const trimmed = result.trim(); + if (trimmed === 'NONE' || trimmed === '"NONE"') { + return null; + } + + try { + const parsed: unknown = JSON.parse(trimmed); + if (!isRecord(parsed)) { + this.jobLogger.warn(`Argument extraction returned non-object JSON: ${trimmed}`); + return null; + } + + const participants = Array.isArray(parsed.participants) + ? parsed.participants + .map((participant) => { + if (!isRecord(participant)) { + return null; + } + + return typeof participant.slackId === 'string' && + typeof participant.name === 'string' && + typeof participant.viewpoint === 'string' + ? { + slackId: participant.slackId.trim(), + name: participant.name.trim(), + viewpoint: participant.viewpoint.trim(), + } + : null; + }) + .filter((participant): participant is ArgumentParticipant => participant !== null) + : []; + + const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : ''; + const winnerSlackId = typeof parsed.winnerSlackId === 'string' ? parsed.winnerSlackId.trim() : ''; + const pointValue = typeof parsed.pointValue === 'number' ? parsed.pointValue : Number(parsed.pointValue); + + if ( + !summary || + participants.length < 2 || + !winnerSlackId || + !participants.some((participant) => participant.slackId === winnerSlackId) + ) { + this.jobLogger.warn(`Argument extraction returned incomplete payload: ${trimmed}`); + return null; + } + + return { + summary, + participants, + winnerSlackId, + pointValue: Math.min(5, Math.max(0, Number.isFinite(pointValue) ? Math.round(pointValue) : 0)), + }; + } catch { + this.jobLogger.warn(`Argument extraction returned malformed JSON: ${trimmed}`); + return null; + } + } + + private async runWithConcurrencyLimit( + tasks: Array<() => Promise>, + concurrency: number, + ): Promise[]> { + const results: PromiseSettledResult[] = new Array(tasks.length); + let nextIndex = 0; + + const runNext = async (): Promise => { + while (nextIndex < tasks.length) { + const index = nextIndex++; + try { + results[index] = { status: 'fulfilled', value: await tasks[index]() }; + } catch (reason) { + results[index] = { status: 'rejected', reason }; + } + } + }; + + await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => runNext())); + return results; + } +} diff --git a/packages/backend/src/job.service.spec.ts b/packages/backend/src/job.service.spec.ts index 6c201f12..a7bc245d 100644 --- a/packages/backend/src/job.service.spec.ts +++ b/packages/backend/src/job.service.spec.ts @@ -1,16 +1,23 @@ import { vi } from 'vitest'; import { loggerMock } from './test/mocks/logger.mock'; -const { memoryRunMock, traitRunMock, funFactRunMock, pricingRunMock, eventAlertRunMock, scheduleMock } = vi.hoisted( - () => ({ - memoryRunMock: vi.fn(), - traitRunMock: vi.fn(), - funFactRunMock: vi.fn(), - pricingRunMock: vi.fn(), - eventAlertRunMock: vi.fn(), - scheduleMock: vi.fn(), - }), -); +const { + memoryRunMock, + argumentRunMock, + traitRunMock, + funFactRunMock, + pricingRunMock, + eventAlertRunMock, + scheduleMock, +} = vi.hoisted(() => ({ + memoryRunMock: vi.fn(), + argumentRunMock: vi.fn(), + traitRunMock: vi.fn(), + funFactRunMock: vi.fn(), + pricingRunMock: vi.fn(), + eventAlertRunMock: vi.fn(), + scheduleMock: vi.fn(), +})); vi.mock('node-cron', async () => ({ default: { @@ -24,6 +31,12 @@ vi.mock('./ai/memory/memory.job', async () => ({ })), })); +vi.mock('./argument/argument.job', async () => ({ + ArgumentJob: classMock(() => ({ + run: argumentRunMock, + })), +})); + vi.mock('./trait/trait.job', async () => ({ TraitJob: classMock(() => ({ run: traitRunMock, @@ -54,26 +67,37 @@ describe('JobService', () => { beforeEach(() => { vi.clearAllMocks(); memoryRunMock.mockResolvedValue(undefined); + argumentRunMock.mockResolvedValue(undefined); traitRunMock.mockResolvedValue(undefined); funFactRunMock.mockResolvedValue(undefined); pricingRunMock.mockResolvedValue(undefined); eventAlertRunMock.mockResolvedValue(undefined); }); - it('runs memory and trait jobs sequentially', async () => { + it('runs nightly analysis jobs sequentially', async () => { const service = new JobService(); - await service.runMemoryAndTraitJobs(); + await service.runNightlyAnalysisJobs(); expect(memoryRunMock).toHaveBeenCalledOnce(); + expect(argumentRunMock).toHaveBeenCalledOnce(); expect(traitRunMock).toHaveBeenCalledOnce(); }); - it('throws when memory and trait sequence fails', async () => { + it('throws when nightly analysis sequence fails', async () => { const service = new JobService(); memoryRunMock.mockRejectedValueOnce(new Error('memory-fail')); - await expect(service.runMemoryAndTraitJobs()).rejects.toThrow('memory-fail'); + await expect(service.runNightlyAnalysisJobs()).rejects.toThrow('memory-fail'); + expect(argumentRunMock).not.toHaveBeenCalled(); + expect(traitRunMock).not.toHaveBeenCalled(); + }); + + it('does not run trait job when argument job fails', async () => { + const service = new JobService(); + argumentRunMock.mockRejectedValueOnce(new Error('argument-fail')); + + await expect(service.runNightlyAnalysisJobs()).rejects.toThrow('argument-fail'); expect(traitRunMock).not.toHaveBeenCalled(); }); @@ -81,12 +105,14 @@ describe('JobService', () => { const service = new JobService(); await service.runMemoryJob(); + await service.runArgumentJob(); await service.runTraitJob(); await service.runFunFactJob(); await service.runPricingJob(); await service.runEventAlertJob(); expect(memoryRunMock).toHaveBeenCalled(); + expect(argumentRunMock).toHaveBeenCalled(); expect(traitRunMock).toHaveBeenCalled(); expect(funFactRunMock).toHaveBeenCalled(); expect(pricingRunMock).toHaveBeenCalled(); @@ -118,8 +144,8 @@ describe('JobService', () => { it('logs errors from scheduled callbacks instead of throwing', async () => { const service = new JobService(); - const runMemoryAndTraitJobsSpy = vi - .spyOn(service, 'runMemoryAndTraitJobs') + const runNightlyAnalysisJobsSpy = vi + .spyOn(service, 'runNightlyAnalysisJobs') .mockRejectedValueOnce(new Error('scheduled-failure')); service.scheduleCronJobs(); @@ -130,7 +156,7 @@ describe('JobService', () => { memoryCallback?.(); await Promise.resolve(); - expect(runMemoryAndTraitJobsSpy).toHaveBeenCalledOnce(); - expect(loggerMock.error).toHaveBeenCalledWith('Memory and trait job sequence failed:', expect.any(Error)); + expect(runNightlyAnalysisJobsSpy).toHaveBeenCalledOnce(); + expect(loggerMock.error).toHaveBeenCalledWith('Nightly analysis job sequence failed:', expect.any(Error)); }); }); diff --git a/packages/backend/src/job.service.ts b/packages/backend/src/job.service.ts index a80a8562..afaae036 100644 --- a/packages/backend/src/job.service.ts +++ b/packages/backend/src/job.service.ts @@ -5,9 +5,11 @@ import { PricingJob } from './jobs/pricing.job'; import { EventAlertJob } from './jobs/event-alert.job'; import { logger } from './shared/logger/logger'; import { TraitJob } from './trait/trait.job'; +import { ArgumentJob } from './argument/argument.job'; export class JobService { private memoryJob: MemoryJob; + private argumentJob: ArgumentJob; private traitJob: TraitJob; private funFactJob: FunFactJob; private pricingJob: PricingJob; @@ -16,6 +18,7 @@ export class JobService { constructor() { this.memoryJob = new MemoryJob(); + this.argumentJob = new ArgumentJob(); this.traitJob = new TraitJob(); this.funFactJob = new FunFactJob(); this.pricingJob = new PricingJob(); @@ -23,26 +26,28 @@ export class JobService { } /** - * Run the memory and trait jobs in sequence. - * Memory job runs first, then trait job runs only if memory job succeeds. + * Run the nightly analysis jobs in sequence. + * Memory job runs first, then argument job, then trait job if the earlier jobs succeed. */ - async runMemoryAndTraitJobs(): Promise { - this.jobServiceLogger.info('Starting memory and trait job sequence'); + async runNightlyAnalysisJobs(): Promise { + this.jobServiceLogger.info('Starting nightly analysis job sequence'); try { - // Run memory job first this.jobServiceLogger.info('Running memory job...'); await this.memoryJob.run(); - this.jobServiceLogger.info('Memory job succeeded, proceeding with trait job'); + this.jobServiceLogger.info('Memory job succeeded, proceeding with argument job'); + + this.jobServiceLogger.info('Running argument job...'); + await this.argumentJob.run(); + this.jobServiceLogger.info('Argument job succeeded, proceeding with trait job'); - // Run trait job only if memory job succeeds this.jobServiceLogger.info('Running trait job...'); await this.traitJob.run(); this.jobServiceLogger.info('Trait job succeeded'); - this.jobServiceLogger.info('Memory and trait job sequence completed successfully'); + this.jobServiceLogger.info('Nightly analysis job sequence completed successfully'); } catch (error) { - this.jobServiceLogger.error('Memory and trait job sequence failed:', error); + this.jobServiceLogger.error('Nightly analysis job sequence failed:', error); throw error; } } @@ -103,6 +108,20 @@ export class JobService { } } + /** + * Run the argument job in isolation + */ + async runArgumentJob(): Promise { + this.jobServiceLogger.info('Running argument job in isolation'); + try { + await this.argumentJob.run(); + this.jobServiceLogger.info('Argument job completed successfully'); + } catch (error) { + this.jobServiceLogger.error('Argument job failed:', error); + throw error; + } + } + /** * Run the trait job in isolation */ @@ -119,24 +138,24 @@ export class JobService { /** * Schedule all cron jobs on startup. - * Memory and trait jobs run daily at 3AM. + * Nightly analysis jobs run daily at 3AM. * Fun fact job runs daily at 9AM. * Pricing job runs every hour at minute 10. */ scheduleCronJobs(): void { this.jobServiceLogger.info('Scheduling cron jobs'); - // Memory and trait jobs: daily at 3AM America/New_York + // Nightly analysis jobs: daily at 3AM America/New_York cron.schedule( '0 3 * * *', () => { - this.runMemoryAndTraitJobs().catch((error) => { - this.jobServiceLogger.error('Memory and trait job sequence failed:', error); + this.runNightlyAnalysisJobs().catch((error) => { + this.jobServiceLogger.error('Nightly analysis job sequence failed:', error); }); }, { timezone: 'America/New_York' }, ); - this.jobServiceLogger.info('Memory and trait job sequence scheduled daily at 3AM America/New_York time.'); + this.jobServiceLogger.info('Nightly analysis job sequence scheduled daily at 3AM America/New_York time.'); // Fun fact job: daily at 9AM America/New_York cron.schedule( From 6d22437817d49932832fbf279c7e203907477d5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:22:46 +0000 Subject: [PATCH 04/21] fix: support multiple nightly argument extractions --- packages/backend/src/ai/ai.constants.ts | 14 +- .../backend/src/argument/argument.job.spec.ts | 74 +++++++--- packages/backend/src/argument/argument.job.ts | 129 ++++++++++-------- 3 files changed, 135 insertions(+), 82 deletions(-) diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index 53e6c2c3..db6a1194 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -211,7 +211,7 @@ export const DAILY_ARGUMENT_JOB_CONCURRENCY = 50; export const ARGUMENT_EXTRACTION_PROMPT = `You are a highly selective argument detection tool reviewing one Slack channel's last 24 hours of conversation. -Your job is to identify the single strongest real argument between humans in this channel, if one clearly happened, and determine who won. +Your job is to identify every clearly real argument between humans in this channel, if any clearly happened, and determine who won each one. You must be INTOLERANT OF FALSE POSITIVES. Prefer false negatives over false positives. @@ -232,7 +232,9 @@ Return NONE for: - any conversation where the winner is ambiguous - weak or low-substance disagreements that do not merit leaderboard tracking -If you find a qualifying argument, return ONLY a single JSON object in this exact shape: +Return ONLY a JSON array. + +Each array item must be a JSON object in this exact shape: { "summary": "short summary of the argument", "participants": [ @@ -243,12 +245,14 @@ If you find a qualifying argument, return ONLY a single JSON object in this exac } Rules for valid JSON output: -- include at least 2 participants +- return [] if no conversation clearly qualifies +- include 0 or more argument objects; do not invent arguments just to fill the array +- each argument object must include at least 2 participants - every participant must be a human from the conversation - the winnerSlackId must match one of the listed participants - pointValue must be an integer from 0 to 5 based on substance/depth - reserve 4-5 for long, detailed, high-signal arguments -- if the argument is too weak to feel leaderboard-worthy, return NONE instead of a low-confidence object +- omit any weak or ambiguous disagreement instead of returning a low-confidence object - do not include markdown, prose, or extra keys -If no conversation clearly qualifies, return the exact string NONE.`; +If no conversation clearly qualifies, return the exact JSON array [] and nothing else.`; diff --git a/packages/backend/src/argument/argument.job.spec.ts b/packages/backend/src/argument/argument.job.spec.ts index 1fd2f1e5..69dd1cb8 100644 --- a/packages/backend/src/argument/argument.job.spec.ts +++ b/packages/backend/src/argument/argument.job.spec.ts @@ -68,9 +68,9 @@ describe('ArgumentJob', () => { expect(aiService.openAi.responses.create).not.toHaveBeenCalled(); }); - it('does nothing when extractor returns NONE', async () => { + it('does nothing when extractor returns an empty array', async () => { aiService.openAi.responses.create.mockResolvedValue({ - output: [{ type: 'message', content: [{ type: 'output_text', text: 'NONE' }] }], + output: [{ type: 'message', content: [{ type: 'output_text', text: '[]' }] }], }); await ( @@ -86,7 +86,7 @@ describe('ArgumentJob', () => { expect(argumentPersistenceService.saveArgumentOutcome).not.toHaveBeenCalled(); }); - it('saves a valid extracted argument outcome', async () => { + it('saves each valid extracted argument outcome', async () => { aiService.openAi.responses.create.mockResolvedValue({ output: [ { @@ -94,28 +94,48 @@ describe('ArgumentJob', () => { content: [ { type: 'output_text', - text: JSON.stringify({ - summary: 'tabs vs spaces', - participants: [ - { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, - { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, - ], - winnerSlackId: 'U2', - pointValue: 4, - }), + text: JSON.stringify([ + { + summary: 'tabs vs spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }, + { + summary: 'vim vs emacs', + participants: [ + { slackId: 'U3', name: 'Carol', viewpoint: 'vim is faster' }, + { slackId: 'U4', name: 'Dan', viewpoint: 'emacs is more powerful' }, + ], + winnerSlackId: 'U3', + pointValue: 3, + }, + ]), }, ], }, ], }); - argumentPersistenceService.saveArgumentOutcome.mockResolvedValue({ - id: 1, - argument: 'tabs vs spaces', - participants: [], - winner: { name: 'Bob', slackId: 'U2' }, - pointValue: 4, - createdAt: '2026-05-21T00:00:00.000Z', - }); + argumentPersistenceService.saveArgumentOutcome + .mockResolvedValueOnce({ + id: 1, + argument: 'tabs vs spaces', + participants: [], + winner: { name: 'Bob', slackId: 'U2' }, + pointValue: 4, + createdAt: '2026-05-21T00:00:00.000Z', + }) + .mockResolvedValueOnce({ + id: 2, + argument: 'vim vs emacs', + participants: [], + winner: { name: 'Carol', slackId: 'U3' }, + pointValue: 3, + createdAt: '2026-05-21T00:10:00.000Z', + }); await ( job as never as { @@ -127,7 +147,7 @@ describe('ArgumentJob', () => { } ).extractArgument('T1', 'C1', [{ slackId: 'U1', name: 'Alice', message: 'history' }]); - expect(argumentPersistenceService.saveArgumentOutcome).toHaveBeenCalledWith({ + expect(argumentPersistenceService.saveArgumentOutcome).toHaveBeenNthCalledWith(1, { teamId: 'T1', channelId: 'C1', argumentSummary: 'tabs vs spaces', @@ -138,7 +158,19 @@ describe('ArgumentJob', () => { winnerSlackId: 'U2', pointValue: 4, }); + expect(argumentPersistenceService.saveArgumentOutcome).toHaveBeenNthCalledWith(2, { + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'vim vs emacs', + participants: [ + { slackId: 'U3', name: 'Carol', viewpoint: 'vim is faster' }, + { slackId: 'U4', name: 'Dan', viewpoint: 'emacs is more powerful' }, + ], + winnerSlackId: 'U3', + pointValue: 3, + }); expect(jobLogger.info).toHaveBeenCalledWith('Argument extracted for C1: "tabs vs spaces"'); + expect(jobLogger.info).toHaveBeenCalledWith('Argument extracted for C1: "vim vs emacs"'); }); it('skips malformed extraction payloads and logs warnings', async () => { diff --git a/packages/backend/src/argument/argument.job.ts b/packages/backend/src/argument/argument.job.ts index 9e7b2d03..65e5fade 100644 --- a/packages/backend/src/argument/argument.job.ts +++ b/packages/backend/src/argument/argument.job.ts @@ -103,89 +103,106 @@ export class ArgumentJob { }) .then((response) => extractAndParseOpenAiResponse(response)); - const parsedResult = this.parseExtractionResult(result); - if (!parsedResult) { + const parsedResults = this.parseExtractionResults(result); + if (parsedResults.length === 0) { return; } - const saved = await this.argumentPersistenceService.saveArgumentOutcome({ - teamId, - channelId, - argumentSummary: parsedResult.summary, - participants: parsedResult.participants, - winnerSlackId: parsedResult.winnerSlackId, - pointValue: parsedResult.pointValue, - }); - - if (saved) { - this.jobLogger.info(`Argument extracted for ${channelId}: "${saved.argument}"`); + for (const parsedResult of parsedResults) { + const saved = await this.argumentPersistenceService.saveArgumentOutcome({ + teamId, + channelId, + argumentSummary: parsedResult.summary, + participants: parsedResult.participants, + winnerSlackId: parsedResult.winnerSlackId, + pointValue: parsedResult.pointValue, + }); + + if (saved) { + this.jobLogger.info(`Argument extracted for ${channelId}: "${saved.argument}"`); + } } } catch (error) { logError(this.jobLogger, 'Argument extraction failed', error, { teamId, channelId }); } } - private parseExtractionResult(result: string | undefined): ArgumentExtractionResult | null { + private parseExtractionResults(result: string | undefined): ArgumentExtractionResult[] { if (!result) { this.jobLogger.warn('Argument extraction returned no result'); - return null; + return []; } const trimmed = result.trim(); if (trimmed === 'NONE' || trimmed === '"NONE"') { - return null; + return []; } try { const parsed: unknown = JSON.parse(trimmed); - if (!isRecord(parsed)) { - this.jobLogger.warn(`Argument extraction returned non-object JSON: ${trimmed}`); - return null; + const rawArguments = Array.isArray(parsed) ? parsed : isRecord(parsed) ? [parsed] : null; + if (!rawArguments) { + this.jobLogger.warn(`Argument extraction returned non-array JSON: ${trimmed}`); + return []; } - const participants = Array.isArray(parsed.participants) - ? parsed.participants - .map((participant) => { - if (!isRecord(participant)) { - return null; - } - - return typeof participant.slackId === 'string' && - typeof participant.name === 'string' && - typeof participant.viewpoint === 'string' - ? { - slackId: participant.slackId.trim(), - name: participant.name.trim(), - viewpoint: participant.viewpoint.trim(), + const parsedArguments = rawArguments + .map((argument) => { + if (!isRecord(argument)) { + return null; + } + + const participants = Array.isArray(argument.participants) + ? argument.participants + .map((participant) => { + if (!isRecord(participant)) { + return null; } - : null; - }) - .filter((participant): participant is ArgumentParticipant => participant !== null) - : []; - - const summary = typeof parsed.summary === 'string' ? parsed.summary.trim() : ''; - const winnerSlackId = typeof parsed.winnerSlackId === 'string' ? parsed.winnerSlackId.trim() : ''; - const pointValue = typeof parsed.pointValue === 'number' ? parsed.pointValue : Number(parsed.pointValue); - - if ( - !summary || - participants.length < 2 || - !winnerSlackId || - !participants.some((participant) => participant.slackId === winnerSlackId) - ) { + + return typeof participant.slackId === 'string' && + typeof participant.name === 'string' && + typeof participant.viewpoint === 'string' + ? { + slackId: participant.slackId.trim(), + name: participant.name.trim(), + viewpoint: participant.viewpoint.trim(), + } + : null; + }) + .filter((participant): participant is ArgumentParticipant => participant !== null) + : []; + + const summary = typeof argument.summary === 'string' ? argument.summary.trim() : ''; + const winnerSlackId = typeof argument.winnerSlackId === 'string' ? argument.winnerSlackId.trim() : ''; + const pointValue = + typeof argument.pointValue === 'number' ? argument.pointValue : Number(argument.pointValue); + + if ( + !summary || + participants.length < 2 || + !winnerSlackId || + !participants.some((participant) => participant.slackId === winnerSlackId) + ) { + return null; + } + + return { + summary, + participants, + winnerSlackId, + pointValue: Math.min(5, Math.max(0, Number.isFinite(pointValue) ? Math.round(pointValue) : 0)), + }; + }) + .filter((argument): argument is ArgumentExtractionResult => argument !== null); + + if (parsedArguments.length === 0 && rawArguments.length > 0) { this.jobLogger.warn(`Argument extraction returned incomplete payload: ${trimmed}`); - return null; } - return { - summary, - participants, - winnerSlackId, - pointValue: Math.min(5, Math.max(0, Number.isFinite(pointValue) ? Math.round(pointValue) : 0)), - }; + return parsedArguments; } catch { this.jobLogger.warn(`Argument extraction returned malformed JSON: ${trimmed}`); - return null; + return []; } } From 2ad28e54f97164364456d0e94cd0ec7e20847dce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:32:09 +0000 Subject: [PATCH 05/21] fix: store argument participants as slack users --- .../argument.persistence.service.spec.ts | 66 ++++++++----- .../argument/argument.persistence.service.ts | 93 ++++++++++--------- .../shared/db/models/ArgumentLeaderboard.ts | 12 ++- 3 files changed, 104 insertions(+), 67 deletions(-) diff --git a/packages/backend/src/argument/argument.persistence.service.spec.ts b/packages/backend/src/argument/argument.persistence.service.spec.ts index 8773b342..8046d84a 100644 --- a/packages/backend/src/argument/argument.persistence.service.spec.ts +++ b/packages/backend/src/argument/argument.persistence.service.spec.ts @@ -12,8 +12,10 @@ vi.mock('typeorm', async () => { describe('ArgumentPersistenceService', () => { const findOne = vi.fn(); + const findUsers = vi.fn(); const save = vi.fn(); const query = vi.fn(); + const findArguments = vi.fn(); let service: ArgumentPersistenceService; beforeEach(() => { @@ -21,15 +23,19 @@ describe('ArgumentPersistenceService', () => { service = new ArgumentPersistenceService(); (getRepository as Mock).mockImplementation((entity: { name?: string }) => { if (entity.name === 'SlackUser') { - return { findOne }; + return { findOne, find: findUsers }; } - return { save, query }; + return { save, query, find: findArguments }; }); }); it('saves an argument outcome with a resolved winner', async () => { findOne.mockResolvedValue({ id: 7, slackId: 'U2', name: 'Bob' }); + findUsers.mockResolvedValue([ + { id: 6, slackId: 'U1', name: 'Alice' }, + { id: 7, slackId: 'U2', name: 'Bob' }, + ]); save.mockImplementation(async (entry: { createdAt?: Date }) => ({ ...entry, id: 11, @@ -54,12 +60,24 @@ describe('ArgumentPersistenceService', () => { teamId: 'T1', channelId: 'C1', argumentSummary: 'Tabs versus spaces', + participants: [ + { id: 6, slackId: 'U1', name: 'Alice' }, + { id: 7, slackId: 'U2', name: 'Bob' }, + ], + participantViewpoints: { + U1: 'tabs are faster', + U2: 'spaces are clearer', + }, pointValue: 5, }), ); expect(result).toMatchObject({ id: 11, argument: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ], winner: { name: 'Bob', slackId: 'U2' }, pointValue: 5, }); @@ -85,30 +103,36 @@ describe('ArgumentPersistenceService', () => { }); it('loads leaderboard standings and detailed argument outcomes', async () => { - query - .mockResolvedValueOnce([ - { name: 'Bob', slackId: 'U2', wins: '3', points: '12' }, - { name: 'Alice', slackId: 'U1', wins: '1', points: '4' }, - ]) - .mockResolvedValueOnce([ - { - id: 1, - argumentSummary: 'tabs vs spaces', - participants: JSON.stringify([ - { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, - { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, - ]), - winnerName: 'Bob', - winnerSlackId: 'U2', - pointValue: '4', - createdAt: '2026-05-21T00:00:00.000Z', + query.mockResolvedValueOnce([ + { name: 'Bob', slackId: 'U2', wins: '3', points: '12' }, + { name: 'Alice', slackId: 'U1', wins: '1', points: '4' }, + ]); + findArguments.mockResolvedValue([ + { + id: 1, + argumentSummary: 'tabs vs spaces', + participants: [ + { id: 2, slackId: 'U2', name: 'Bob' }, + { id: 1, slackId: 'U1', name: 'Alice' }, + ], + participantViewpoints: { + U1: 'tabs are faster', + U2: 'spaces are clearer', }, - ]); + winner: { id: 2, slackId: 'U2', name: 'Bob' }, + pointValue: '4', + createdAt: '2026-05-21T00:00:00.000Z', + }, + ]); const result = await service.getArgumentLeaderboard('T1'); expect(query).toHaveBeenNthCalledWith(1, expect.stringContaining('CAST(COUNT(*) AS SIGNED) AS wins'), ['T1']); - expect(query).toHaveBeenNthCalledWith(2, expect.stringContaining('a.argumentSummary AS argumentSummary'), ['T1']); + expect(findArguments).toHaveBeenCalledWith({ + where: { teamId: 'T1' }, + relations: ['participants', 'winner'], + order: { createdAt: 'DESC' }, + }); expect(result).toEqual({ leaderboard: [ { name: 'Bob', slackId: 'U2', wins: 3, points: 12 }, diff --git a/packages/backend/src/argument/argument.persistence.service.ts b/packages/backend/src/argument/argument.persistence.service.ts index 7ca448e4..b4d5faf6 100644 --- a/packages/backend/src/argument/argument.persistence.service.ts +++ b/packages/backend/src/argument/argument.persistence.service.ts @@ -1,6 +1,7 @@ import { getRepository } from 'typeorm'; import { ArgumentLeaderboard } from '../shared/db/models/ArgumentLeaderboard'; import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; +import type { ArgumentParticipantViewpoints } from '../shared/db/models/ArgumentLeaderboard'; import { SlackUser } from '../shared/db/models/SlackUser'; import { logger } from '../shared/logger/logger'; import { logError } from '../shared/logger/error-logging'; @@ -13,16 +14,6 @@ interface LeaderboardRow { points: string; } -interface ArgumentRow { - id: number; - argumentSummary: string; - participants: string; - winnerName: string; - winnerSlackId: string; - pointValue: string; - createdAt: string | Date; -} - const clampPointValue = (value: number): number => Math.min(5, Math.max(0, Math.round(value))); const normalizeParticipant = (participant: Partial): ArgumentParticipant | null => { @@ -36,28 +27,44 @@ const normalizeParticipant = (participant: Partial): Argume return { slackId, name, viewpoint }; }; -const parseParticipants = (raw: string): ArgumentParticipant[] => { - try { - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } +const buildParticipantViewpoints = (participants: ArgumentParticipant[]): ArgumentParticipantViewpoints => + Object.fromEntries(participants.map((participant) => [participant.slackId, participant.viewpoint])); - return parsed - .map((participant) => - typeof participant === 'object' && participant !== null ? normalizeParticipant(participant) : null, - ) - .filter((participant): participant is ArgumentParticipant => participant !== null); - } catch { +const buildArgumentParticipants = ( + participants: SlackUser[] | undefined, + participantViewpoints: ArgumentParticipantViewpoints | null | undefined, +): ArgumentParticipant[] => { + if (!participants || participants.length === 0 || !participantViewpoints) { return []; } + + const participantUserBySlackId = new Map(participants.map((participant) => [participant.slackId, participant])); + + return Object.entries(participantViewpoints) + .flatMap(([slackId, viewpoint]) => { + const participant = participantUserBySlackId.get(slackId); + const normalizedViewpoint = viewpoint.trim(); + if (!participant || !normalizedViewpoint) { + return []; + } + + return [ + { + slackId: participant.slackId, + name: participant.name, + viewpoint: normalizedViewpoint, + }, + ]; + }) + .filter((participant) => participant.name.trim()); }; export class ArgumentPersistenceService { private logger = logger.child({ module: 'ArgumentPersistenceService' }); async saveArgumentOutcome(input: SaveArgumentOutcomeInput): Promise { - const winner = await getRepository(SlackUser).findOne({ + const slackUserRepo = getRepository(SlackUser); + const winner = await slackUserRepo.findOne({ where: { slackId: input.winnerSlackId, teamId: input.teamId }, }); @@ -78,7 +85,13 @@ export class ArgumentPersistenceService { ).values(), ); - if (participants.length < 2) { + const participantUsers = await slackUserRepo.find({ + where: participants.map((participant) => ({ slackId: participant.slackId, teamId: input.teamId })), + }); + const participantViewpoints = buildParticipantViewpoints(participants); + const hydratedParticipants = buildArgumentParticipants(participantUsers, participantViewpoints); + + if (hydratedParticipants.length < 2) { this.logger.warn('Skipping argument outcome save because fewer than two participants were extracted', { teamId: input.teamId, channelId: input.channelId, @@ -99,7 +112,8 @@ export class ArgumentPersistenceService { entry.teamId = input.teamId; entry.channelId = input.channelId; entry.argumentSummary = argumentSummary; - entry.participants = participants; + entry.participants = participantUsers; + entry.participantViewpoints = participantViewpoints; entry.winner = winner; entry.pointValue = clampPointValue(input.pointValue); @@ -108,7 +122,7 @@ export class ArgumentPersistenceService { .then((saved) => ({ id: saved.id, argument: saved.argumentSummary, - participants: saved.participants, + participants: buildArgumentParticipants(participantUsers, participantViewpoints), winner: { name: winner.name, slackId: winner.slackId, @@ -140,23 +154,14 @@ export class ArgumentPersistenceService { INNER JOIN slack_user u ON u.id = a.winnerId WHERE a.teamId = ? GROUP BY u.id, u.name, u.slackId - ORDER BY wins DESC, points DESC, u.name ASC`, - [teamId], - ), - repo.query( - `SELECT a.id AS id, - a.argumentSummary AS argumentSummary, - a.participants AS participants, - u.name AS winnerName, - u.slackId AS winnerSlackId, - CAST(a.pointValue AS SIGNED) AS pointValue, - a.createdAt AS createdAt - FROM argument_leaderboard a - INNER JOIN slack_user u ON u.id = a.winnerId - WHERE a.teamId = ? - ORDER BY a.createdAt DESC`, + ORDER BY wins DESC, points DESC, u.name ASC`, [teamId], ), + repo.find({ + where: { teamId }, + relations: ['participants', 'winner'], + order: { createdAt: 'DESC' }, + }), ]); return { @@ -169,10 +174,10 @@ export class ArgumentPersistenceService { arguments: argumentRows.map((row) => ({ id: Number(row.id), argument: row.argumentSummary, - participants: parseParticipants(row.participants), + participants: buildArgumentParticipants(row.participants, row.participantViewpoints), winner: { - name: row.winnerName, - slackId: row.winnerSlackId, + name: row.winner.name, + slackId: row.winner.slackId, }, pointValue: Number(row.pointValue), createdAt: new Date(row.createdAt).toISOString(), diff --git a/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts index 53977aeb..696ec348 100644 --- a/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts +++ b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts @@ -1,4 +1,6 @@ import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { JoinTable } from 'typeorm/decorator/relations/JoinTable'; +import { ManyToMany } from 'typeorm/decorator/relations/ManyToMany'; import { SlackUser } from './SlackUser'; export interface ArgumentParticipant { @@ -7,6 +9,8 @@ export interface ArgumentParticipant { viewpoint: string; } +export type ArgumentParticipantViewpoints = Record; + @Entity() export class ArgumentLeaderboard { @PrimaryGeneratedColumn() @@ -21,8 +25,12 @@ export class ArgumentLeaderboard { @Column('text') public argumentSummary!: string; - @Column('simple-json') - public participants!: ArgumentParticipant[]; + @ManyToMany(() => SlackUser) + @JoinTable() + public participants!: SlackUser[]; + + @Column('simple-json', { default: '{}' }) + public participantViewpoints!: ArgumentParticipantViewpoints; @ManyToOne(() => SlackUser, (user) => user.argumentWins) public winner!: SlackUser; From b11a8f2613a000fe8cf9b48a05d942ebbe197e4a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:35:46 +0000 Subject: [PATCH 06/21] fix: clean up argument participant persistence --- .../argument.persistence.service.spec.ts | 4 ++ .../argument/argument.persistence.service.ts | 40 +++++++++---------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/backend/src/argument/argument.persistence.service.spec.ts b/packages/backend/src/argument/argument.persistence.service.spec.ts index 8046d84a..ce95d9be 100644 --- a/packages/backend/src/argument/argument.persistence.service.spec.ts +++ b/packages/backend/src/argument/argument.persistence.service.spec.ts @@ -4,9 +4,13 @@ import { ArgumentPersistenceService } from './argument.persistence.service'; vi.mock('typeorm', async () => { const actual = await vi.importActual('typeorm'); + const { JoinTable } = await import('typeorm/decorator/relations/JoinTable'); + const { ManyToMany } = await import('typeorm/decorator/relations/ManyToMany'); return { ...actual, getRepository: vi.fn(), + JoinTable, + ManyToMany, }; }); diff --git a/packages/backend/src/argument/argument.persistence.service.ts b/packages/backend/src/argument/argument.persistence.service.ts index b4d5faf6..142b15cd 100644 --- a/packages/backend/src/argument/argument.persistence.service.ts +++ b/packages/backend/src/argument/argument.persistence.service.ts @@ -40,23 +40,21 @@ const buildArgumentParticipants = ( const participantUserBySlackId = new Map(participants.map((participant) => [participant.slackId, participant])); - return Object.entries(participantViewpoints) - .flatMap(([slackId, viewpoint]) => { - const participant = participantUserBySlackId.get(slackId); - const normalizedViewpoint = viewpoint.trim(); - if (!participant || !normalizedViewpoint) { - return []; - } - - return [ - { - slackId: participant.slackId, - name: participant.name, - viewpoint: normalizedViewpoint, - }, - ]; - }) - .filter((participant) => participant.name.trim()); + return Object.entries(participantViewpoints).flatMap(([slackId, viewpoint]) => { + const participant = participantUserBySlackId.get(slackId); + const normalizedViewpoint = viewpoint.trim(); + if (!participant || !normalizedViewpoint) { + return []; + } + + return [ + { + slackId: participant.slackId, + name: participant.name, + viewpoint: normalizedViewpoint, + }, + ]; + }); }; export class ArgumentPersistenceService { @@ -150,10 +148,10 @@ export class ArgumentPersistenceService { u.slackId AS slackId, CAST(COUNT(*) AS SIGNED) AS wins, CAST(COALESCE(SUM(a.pointValue), 0) AS SIGNED) AS points - FROM argument_leaderboard a - INNER JOIN slack_user u ON u.id = a.winnerId - WHERE a.teamId = ? - GROUP BY u.id, u.name, u.slackId + FROM argument_leaderboard a + INNER JOIN slack_user u ON u.id = a.winnerId + WHERE a.teamId = ? + GROUP BY u.id, u.name, u.slackId ORDER BY wins DESC, points DESC, u.name ASC`, [teamId], ), From 1f29b370ac8573177f373076114b46b1bb1f8fa2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:39:27 +0000 Subject: [PATCH 07/21] fix: tidy argument participant validation --- .../backend/src/argument/argument.persistence.service.spec.ts | 4 ---- packages/backend/src/argument/argument.persistence.service.ts | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/argument/argument.persistence.service.spec.ts b/packages/backend/src/argument/argument.persistence.service.spec.ts index ce95d9be..8046d84a 100644 --- a/packages/backend/src/argument/argument.persistence.service.spec.ts +++ b/packages/backend/src/argument/argument.persistence.service.spec.ts @@ -4,13 +4,9 @@ import { ArgumentPersistenceService } from './argument.persistence.service'; vi.mock('typeorm', async () => { const actual = await vi.importActual('typeorm'); - const { JoinTable } = await import('typeorm/decorator/relations/JoinTable'); - const { ManyToMany } = await import('typeorm/decorator/relations/ManyToMany'); return { ...actual, getRepository: vi.fn(), - JoinTable, - ManyToMany, }; }); diff --git a/packages/backend/src/argument/argument.persistence.service.ts b/packages/backend/src/argument/argument.persistence.service.ts index 142b15cd..523b46f0 100644 --- a/packages/backend/src/argument/argument.persistence.service.ts +++ b/packages/backend/src/argument/argument.persistence.service.ts @@ -87,9 +87,9 @@ export class ArgumentPersistenceService { where: participants.map((participant) => ({ slackId: participant.slackId, teamId: input.teamId })), }); const participantViewpoints = buildParticipantViewpoints(participants); - const hydratedParticipants = buildArgumentParticipants(participantUsers, participantViewpoints); + const resolvedParticipants = buildArgumentParticipants(participantUsers, participantViewpoints); - if (hydratedParticipants.length < 2) { + if (resolvedParticipants.length < 2) { this.logger.warn('Skipping argument outcome save because fewer than two participants were extracted', { teamId: input.teamId, channelId: input.channelId, From f91b23648ef359dd263a00e4374aef33acb8ae81 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 17:55:37 +0000 Subject: [PATCH 08/21] Add dashboard controller coverage --- .../dashboard/dashboard.controller.spec.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 packages/backend/src/dashboard/dashboard.controller.spec.ts diff --git a/packages/backend/src/dashboard/dashboard.controller.spec.ts b/packages/backend/src/dashboard/dashboard.controller.spec.ts new file mode 100644 index 00000000..d1da31cf --- /dev/null +++ b/packages/backend/src/dashboard/dashboard.controller.spec.ts @@ -0,0 +1,107 @@ +import { vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; + +const getDashboardData = vi.fn(); +const getAllMemoriesForUser = vi.fn(); +const getAllTraitsForUser = vi.fn(); +const getArgumentLeaderboard = vi.fn(); + +vi.mock('./dashboard.persistence.service', async () => ({ + DashboardPersistenceService: classMock(() => ({ + getDashboardData, + })), +})); + +vi.mock('../ai/memory/memory.persistence.service', async () => ({ + MemoryPersistenceService: classMock(() => ({ + getAllMemoriesForUser, + })), +})); + +vi.mock('../trait/trait.persistence.service', async () => ({ + TraitPersistenceService: classMock(() => ({ + getAllTraitsForUser, + })), +})); + +vi.mock('../argument/argument.persistence.service', async () => ({ + ArgumentPersistenceService: classMock(() => ({ + getArgumentLeaderboard, + })), +})); + +vi.mock('../shared/logger/logger', async () => ({ + logger: { child: () => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() }) }, +})); + +import { dashboardController } from './dashboard.controller'; + +describe('dashboardController', () => { + const app = express(); + app.use((req, _res, next) => { + const teamId = req.header('x-team-id'); + const userId = req.header('x-user-id'); + if (teamId || userId) { + req.authSession = { teamId: teamId ?? undefined, userId: userId ?? undefined }; + } + next(); + }); + app.use('/', dashboardController); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects unauthorized dashboard requests', async () => { + await request(app).get('/').expect(401, { error: 'Unauthorized' }); + }); + + it('loads dashboard data with a valid period', async () => { + getDashboardData.mockResolvedValue({ leaderboard: [], myStats: {}, repLeaderboard: [] }); + + const res = await request(app).get('/?period=weekly').set('x-team-id', 'T1').set('x-user-id', 'U1').expect(200); + + expect(getDashboardData).toHaveBeenCalledWith('U1', 'T1', 'weekly'); + expect(res.body).toEqual({ leaderboard: [], myStats: {}, repLeaderboard: [] }); + }); + + it('falls back to the default period for unknown values', async () => { + getDashboardData.mockResolvedValue({ leaderboard: [], myStats: {}, repLeaderboard: [] }); + + await request(app).get('/?period=nope').set('x-team-id', 'T1').set('x-user-id', 'U1').expect(200); + + expect(getDashboardData).toHaveBeenCalledWith('U1', 'T1', 'weekly'); + }); + + it('loads personal context data', async () => { + getAllMemoriesForUser.mockResolvedValue([ + { id: 1, content: 'likes coffee', updatedAt: '2026-05-20T00:00:00.000Z' }, + ]); + getAllTraitsForUser.mockResolvedValue([{ id: 2, content: 'curious', updatedAt: '2026-05-21T00:00:00.000Z' }]); + + const res = await request(app).get('/personal-context').set('x-team-id', 'T1').set('x-user-id', 'U1').expect(200); + + expect(getAllMemoriesForUser).toHaveBeenCalledWith('U1', 'T1'); + expect(getAllTraitsForUser).toHaveBeenCalledWith('U1', 'T1'); + expect(res.body).toEqual({ + memories: [{ id: 1, content: 'likes coffee', updatedAt: '2026-05-20T00:00:00.000Z' }], + traits: [{ id: 2, content: 'curious', updatedAt: '2026-05-21T00:00:00.000Z' }], + }); + }); + + it('loads argument leaderboard data', async () => { + getArgumentLeaderboard.mockResolvedValue({ + leaderboard: [{ name: 'Alice', slackId: 'U1', wins: 2, points: 7 }], + arguments: [], + }); + + const res = await request(app).get('/arguments').set('x-team-id', 'T1').set('x-user-id', 'U1').expect(200); + + expect(getArgumentLeaderboard).toHaveBeenCalledWith('T1'); + expect(res.body).toEqual({ + leaderboard: [{ name: 'Alice', slackId: 'U1', wins: 2, points: 7 }], + arguments: [], + }); + }); +}); From 23b96e4d12a940ed5a05ca496462d7787ff24d63 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Thu, 21 May 2026 16:51:38 -0400 Subject: [PATCH 09/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/backend/src/argument/argument.job.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/argument/argument.job.ts b/packages/backend/src/argument/argument.job.ts index 65e5fade..c5d8f3c3 100644 --- a/packages/backend/src/argument/argument.job.ts +++ b/packages/backend/src/argument/argument.job.ts @@ -90,7 +90,7 @@ export class ArgumentJob { this.jobLogger.info(`Argument extraction lock active for ${channelId}-${teamId}, skipping`); return; } - await this.redis.setValueWithExpire(lockKey, 1, 'EX', 300000); + await this.redis.setValueWithExpire(lockKey, 1, 'EX', 300); try { const history = this.aiService.formatHistory(historyMessages); From 28334523f08db0b99a3ea11c107a00be1eb475c5 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Thu, 21 May 2026 16:52:03 -0400 Subject: [PATCH 10/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/backend/src/shared/db/models/ArgumentLeaderboard.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts index 696ec348..4da2978e 100644 --- a/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts +++ b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts @@ -1,6 +1,4 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; -import { JoinTable } from 'typeorm/decorator/relations/JoinTable'; -import { ManyToMany } from 'typeorm/decorator/relations/ManyToMany'; +import { Column, Entity, JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { SlackUser } from './SlackUser'; export interface ArgumentParticipant { From 0e1a81c6319be0e46865329826b505e54a81daf8 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Thu, 21 May 2026 16:52:28 -0400 Subject: [PATCH 11/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/backend/src/dashboard/dashboard.controller.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/dashboard/dashboard.controller.spec.ts b/packages/backend/src/dashboard/dashboard.controller.spec.ts index d1da31cf..e21f5f9c 100644 --- a/packages/backend/src/dashboard/dashboard.controller.spec.ts +++ b/packages/backend/src/dashboard/dashboard.controller.spec.ts @@ -40,10 +40,13 @@ import { dashboardController } from './dashboard.controller'; describe('dashboardController', () => { const app = express(); app.use((req, _res, next) => { + const requestWithAuthSession = req as typeof req & { + authSession?: { teamId?: string; userId?: string }; + }; const teamId = req.header('x-team-id'); const userId = req.header('x-user-id'); if (teamId || userId) { - req.authSession = { teamId: teamId ?? undefined, userId: userId ?? undefined }; + requestWithAuthSession.authSession = { teamId: teamId ?? undefined, userId: userId ?? undefined }; } next(); }); From 0277d9e724a71c6bbf6b7db42fbae49e0a038b93 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 21:12:40 +0000 Subject: [PATCH 12/21] Fix backend TypeORM test mock decorators --- packages/backend/src/test/mocks/typeorm.mock.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/backend/src/test/mocks/typeorm.mock.ts b/packages/backend/src/test/mocks/typeorm.mock.ts index 6343aa94..d2bda5bc 100644 --- a/packages/backend/src/test/mocks/typeorm.mock.ts +++ b/packages/backend/src/test/mocks/typeorm.mock.ts @@ -10,7 +10,9 @@ export const PrimaryGeneratedColumn = noopDecoratorFactory; export const PrimaryColumn = noopDecoratorFactory; export const OneToMany = noopDecoratorFactory; export const OneToOne = noopDecoratorFactory; +export const ManyToMany = noopDecoratorFactory; export const ManyToOne = noopDecoratorFactory; +export const JoinTable = noopDecoratorFactory; export const Unique = noopDecoratorFactory; export const getRepository = vi.fn(); From b404d5e8aa54eb783c585dd4328f85e12eee83dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 15:00:14 +0000 Subject: [PATCH 13/21] fix: replace 'return NONE' with '[]' in argument extraction prompt for consistency --- packages/backend/src/ai/ai.constants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/ai/ai.constants.ts b/packages/backend/src/ai/ai.constants.ts index db6a1194..8e58e12c 100644 --- a/packages/backend/src/ai/ai.constants.ts +++ b/packages/backend/src/ai/ai.constants.ts @@ -215,7 +215,7 @@ Your job is to identify every clearly real argument between humans in this chann You must be INTOLERANT OF FALSE POSITIVES. Prefer false negatives over false positives. -If there is any meaningful doubt that the conversation was a real argument, return NONE. +If there is any meaningful doubt that the conversation was a real argument, return []. ONLY treat something as an argument when ALL of the following are true: - at least two identifiable human participants directly disagreed @@ -223,7 +223,7 @@ ONLY treat something as an argument when ALL of the following are true: - there was sustained back-and-forth with rebuttals or direct challenges - the winner can be identified from the substance of the exchange -Return NONE for: +Return [] for: - jokes, banter, teasing, sarcasm, or friendly ribbing - brief disagreements without sustained rebuttals - brainstorming, clarification, or neutral discussion From e45c7aeb0548f4f95aeedc6c1075e7fe1b9ff9d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 15:03:20 +0000 Subject: [PATCH 14/21] refactor: centralize OpenAI response text extraction helper --- packages/backend/src/ai/ai.service.ts | 28 ++++--------------- .../ai/helpers/extractOpenAiResponseText.ts | 10 +++++++ packages/backend/src/ai/memory/memory.job.ts | 13 ++------- packages/backend/src/argument/argument.job.ts | 13 ++------- 4 files changed, 20 insertions(+), 44 deletions(-) create mode 100644 packages/backend/src/ai/helpers/extractOpenAiResponseText.ts diff --git a/packages/backend/src/ai/ai.service.ts b/packages/backend/src/ai/ai.service.ts index 837a4ce1..a72e53d8 100644 --- a/packages/backend/src/ai/ai.service.ts +++ b/packages/backend/src/ai/ai.service.ts @@ -26,16 +26,11 @@ import { SlackService } from '../shared/services/slack/slack.service'; import { SlackPersistenceService } from '../shared/services/slack/slack.persistence.service'; import { MuzzlePersistenceService } from '../muzzle/muzzle.persistence.service'; import OpenAI from 'openai'; -import type { - ResponseOutputMessage, - ResponseOutputItem, - ResponseOutputText, - ResponseOutputRefusal, -} from 'openai/resources/responses/responses'; import type { Part } from '@google/genai'; import { GoogleGenAI } from '@google/genai'; import sharp from 'sharp'; import { extractParticipantSlackIds } from './helpers/extractParticipantSlackIds'; +import { extractOpenAiResponseText } from './helpers/extractOpenAiResponseText'; import { TraitService } from '../trait/trait.service'; interface ReleaseCommit { @@ -51,17 +46,6 @@ interface ReleaseMetadata { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; -const isResponseOutputMessage = (block: ResponseOutputItem): block is ResponseOutputMessage => block.type === 'message'; - -const isResponseOutputText = (block: ResponseOutputText | ResponseOutputRefusal): block is ResponseOutputText => - block.type === 'output_text'; - -const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => { - const textBlock = response.output.find(isResponseOutputMessage); - const outputText = textBlock?.content.find(isResponseOutputText)?.text; - return outputText?.trim(); -}; - const ensureSentenceCaseAndPunctuation = (text: string): string => { const trimmed = text.trim(); if (!trimmed) { @@ -151,7 +135,7 @@ export class AIService { user: `${userId}-DaBros2016`, }) .then((x) => { - return extractAndParseOpenAiResponse(x); + return extractOpenAiResponseText(x); }) .then(async (result) => { await this.redis.removeInflight(userId, teamId); @@ -205,7 +189,7 @@ export class AIService { input: REDPLOY_MOONBEAM_TEXT_PROMPT, user: 'Moonbeam', }) - .then((x) => extractAndParseOpenAiResponse(x)); + .then((x) => extractOpenAiResponseText(x)); const aiImage = this.gemini.models .generateContent({ @@ -370,7 +354,7 @@ export class AIService { return this.openAi.responses .create({ model: GPT_MODEL, input: text, user: 'Moonbeam', instructions: CORPO_SPEAK_INSTRUCTIONS }) .then((x) => { - return extractAndParseOpenAiResponse(x); + return extractOpenAiResponseText(x); }) .catch(async (e) => { logError(this.aiServiceLogger, 'Failed to generate corpo-speak response', e, { @@ -427,7 +411,7 @@ export class AIService { input: prompt, user: `${user_id}-DaBros2016`, }) - .then((x) => extractAndParseOpenAiResponse(x)) + .then((x) => extractOpenAiResponseText(x)) .then(async (result) => { await this.redis.removeInflight(user_id, team_id); if (!result) { @@ -527,7 +511,7 @@ export class AIService { input, user: `participation-${channelId}-${teamId}-DaBros2016`, }) - .then((x) => extractAndParseOpenAiResponse(x)) + .then((x) => extractOpenAiResponseText(x)) .then((result) => { this.aiServiceLogger.info('Received participation model response', { teamId, diff --git a/packages/backend/src/ai/helpers/extractOpenAiResponseText.ts b/packages/backend/src/ai/helpers/extractOpenAiResponseText.ts new file mode 100644 index 00000000..927479f6 --- /dev/null +++ b/packages/backend/src/ai/helpers/extractOpenAiResponseText.ts @@ -0,0 +1,10 @@ +import type OpenAI from 'openai'; + +export const extractOpenAiResponseText = (response: OpenAI.Responses.Response): string | undefined => { + const textBlock = response.output.find((item) => item.type === 'message'); + if (textBlock && 'content' in textBlock) { + const outputText = textBlock.content.find((item) => item.type === 'output_text'); + return outputText?.text.trim(); + } + return undefined; +}; diff --git a/packages/backend/src/ai/memory/memory.job.ts b/packages/backend/src/ai/memory/memory.job.ts index 1fbb739f..54912216 100644 --- a/packages/backend/src/ai/memory/memory.job.ts +++ b/packages/backend/src/ai/memory/memory.job.ts @@ -7,8 +7,8 @@ import { AIService } from '../ai.service'; import { logger } from '../../shared/logger/logger'; import { DAILY_MEMORY_JOB_CONCURRENCY, GATE_MODEL, MEMORY_EXTRACTION_PROMPT } from '../ai.constants'; import { MOONBEAM_SLACK_ID } from '../ai.constants'; -import type OpenAI from 'openai'; import { extractParticipantSlackIds } from '../helpers/extractParticipantSlackIds'; +import { extractOpenAiResponseText } from '../helpers/extractOpenAiResponseText'; interface ExtractionResult { slackId: string; @@ -17,15 +17,6 @@ interface ExtractionResult { existingMemoryId: number | null; } -const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => { - const textBlock = response.output.find((item) => item.type === 'message'); - if (textBlock && 'content' in textBlock) { - const outputText = textBlock.content.find((item) => item.type === 'output_text'); - return outputText?.text.trim(); - } - return undefined; -}; - export class MemoryJob { private historyService = new HistoryPersistenceService(); private memoryPersistenceService = new MemoryPersistenceService(); @@ -111,7 +102,7 @@ export class MemoryJob { instructions: prompt, input: conversationHistory, }) - .then((response) => extractAndParseOpenAiResponse(response)); + .then((response) => extractOpenAiResponseText(response)); if (!result) { this.jobLogger.warn('Extraction returned no result'); diff --git a/packages/backend/src/argument/argument.job.ts b/packages/backend/src/argument/argument.job.ts index c5d8f3c3..87ea699b 100644 --- a/packages/backend/src/argument/argument.job.ts +++ b/packages/backend/src/argument/argument.job.ts @@ -1,5 +1,4 @@ import { getRepository } from 'typeorm'; -import type OpenAI from 'openai'; import { SlackChannel } from '../shared/db/models/SlackChannel'; import type { MessageWithName } from '../shared/models/message/message-with-name'; import { HistoryPersistenceService } from '../shared/services/history.persistence.service'; @@ -14,6 +13,7 @@ import { MOONBEAM_SLACK_ID, } from '../ai/ai.constants'; import { extractParticipantSlackIds } from '../ai/helpers/extractParticipantSlackIds'; +import { extractOpenAiResponseText } from '../ai/helpers/extractOpenAiResponseText'; import { ArgumentPersistenceService } from './argument.persistence.service'; import type { ArgumentParticipant } from '../shared/db/models/ArgumentLeaderboard'; @@ -26,15 +26,6 @@ interface ArgumentExtractionResult { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; -const extractAndParseOpenAiResponse = (response: OpenAI.Responses.Response): string | undefined => { - const textBlock = response.output.find((item) => item.type === 'message'); - if (textBlock && 'content' in textBlock) { - const outputText = textBlock.content.find((item) => item.type === 'output_text'); - return outputText?.text.trim(); - } - return undefined; -}; - export class ArgumentJob { private historyService = new HistoryPersistenceService(); private argumentPersistenceService = new ArgumentPersistenceService(); @@ -101,7 +92,7 @@ export class ArgumentJob { input: history, user: `nightly-argument-${channelId}-${teamId}`, }) - .then((response) => extractAndParseOpenAiResponse(response)); + .then((response) => extractOpenAiResponseText(response)); const parsedResults = this.parseExtractionResults(result); if (parsedResults.length === 0) { From 58cadab6f144397ed0494a53ffcfc41fadcf1f48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 15:03:26 +0000 Subject: [PATCH 15/21] fix: keep argument participant viewpoints in sync --- .../argument.persistence.service.spec.ts | 43 +++++++++++++++++++ .../argument/argument.persistence.service.ts | 7 +-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/argument/argument.persistence.service.spec.ts b/packages/backend/src/argument/argument.persistence.service.spec.ts index 8046d84a..f330afae 100644 --- a/packages/backend/src/argument/argument.persistence.service.spec.ts +++ b/packages/backend/src/argument/argument.persistence.service.spec.ts @@ -102,6 +102,49 @@ describe('ArgumentPersistenceService', () => { expect(save).not.toHaveBeenCalled(); }); + it('filters participant viewpoints to resolved participants before saving', async () => { + findOne.mockResolvedValue({ id: 7, slackId: 'U2', name: 'Bob' }); + findUsers.mockResolvedValue([ + { id: 6, slackId: 'U1', name: 'Alice' }, + { id: 7, slackId: 'U2', name: 'Bob' }, + ]); + save.mockImplementation(async (entry: { createdAt?: Date }) => ({ + ...entry, + id: 12, + createdAt: entry.createdAt ?? new Date('2026-05-21T00:00:00.000Z'), + })); + + const result = await service.saveArgumentOutcome({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + { slackId: 'U3', name: 'Cara', viewpoint: 'tabs are clearer' }, + ], + winnerSlackId: 'U2', + pointValue: 4, + }); + + expect(save).toHaveBeenCalledWith( + expect.objectContaining({ + participants: [ + { id: 6, slackId: 'U1', name: 'Alice' }, + { id: 7, slackId: 'U2', name: 'Bob' }, + ], + participantViewpoints: { + U1: 'tabs are faster', + U2: 'spaces are clearer', + }, + }), + ); + expect(result?.participants).toEqual([ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'U2', name: 'Bob', viewpoint: 'spaces are clearer' }, + ]); + }); + it('loads leaderboard standings and detailed argument outcomes', async () => { query.mockResolvedValueOnce([ { name: 'Bob', slackId: 'U2', wins: '3', points: '12' }, diff --git a/packages/backend/src/argument/argument.persistence.service.ts b/packages/backend/src/argument/argument.persistence.service.ts index 523b46f0..6713d68b 100644 --- a/packages/backend/src/argument/argument.persistence.service.ts +++ b/packages/backend/src/argument/argument.persistence.service.ts @@ -86,8 +86,9 @@ export class ArgumentPersistenceService { const participantUsers = await slackUserRepo.find({ where: participants.map((participant) => ({ slackId: participant.slackId, teamId: input.teamId })), }); - const participantViewpoints = buildParticipantViewpoints(participants); - const resolvedParticipants = buildArgumentParticipants(participantUsers, participantViewpoints); + const extractedParticipantViewpoints = buildParticipantViewpoints(participants); + const resolvedParticipants = buildArgumentParticipants(participantUsers, extractedParticipantViewpoints); + const participantViewpoints = buildParticipantViewpoints(resolvedParticipants); if (resolvedParticipants.length < 2) { this.logger.warn('Skipping argument outcome save because fewer than two participants were extracted', { @@ -120,7 +121,7 @@ export class ArgumentPersistenceService { .then((saved) => ({ id: saved.id, argument: saved.argumentSummary, - participants: buildArgumentParticipants(participantUsers, participantViewpoints), + participants: resolvedParticipants, winner: { name: winner.name, slackId: winner.slackId, From 97b5a7b48e444d0c167c72d3317ad9254559dca5 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Fri, 22 May 2026 12:37:03 -0400 Subject: [PATCH 16/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 80bb0902..0cba9893 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ docker logs | jq . ### AI Features (Optional) -- **Daily Memory + Argument Jobs** - Analyze conversations daily at 3 AM (requires OpenAI API key) +- **Nightly Analysis Sequence** - Runs memory extraction, argument analysis, and trait synthesis daily at 3 AM (requires OpenAI API key) - **Sentiment Analysis** - Analyzes message tone - **AI Summaries** - Generates summaries of message threads From ce9c4b419199a1909532d9bbf8fa772c4d7e7996 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:40:30 +0000 Subject: [PATCH 17/21] fix: skip argument extraction when bots leave fewer than two humans --- .../backend/src/argument/argument.job.spec.ts | 52 +++++++++++++++++++ packages/backend/src/argument/argument.job.ts | 6 +++ 2 files changed, 58 insertions(+) diff --git a/packages/backend/src/argument/argument.job.spec.ts b/packages/backend/src/argument/argument.job.spec.ts index 69dd1cb8..ff529ff7 100644 --- a/packages/backend/src/argument/argument.job.spec.ts +++ b/packages/backend/src/argument/argument.job.spec.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getRepository } from 'typeorm'; import { ArgumentJob } from './argument.job'; describe('ArgumentJob', () => { @@ -14,6 +15,10 @@ describe('ArgumentJob', () => { info: ReturnType; warn: ReturnType; }; + let historyService: { + getLast24HoursForChannel: ReturnType; + }; + let findUsers: ReturnType; let aiService: { formatHistory: ReturnType; openAi: { @@ -36,6 +41,10 @@ describe('ArgumentJob', () => { info: vi.fn(), warn: vi.fn(), }; + historyService = { + getLast24HoursForChannel: vi.fn(), + }; + findUsers = vi.fn(); aiService = { formatHistory: vi.fn().mockReturnValue('formatted history'), openAi: { @@ -46,9 +55,11 @@ describe('ArgumentJob', () => { }; (job as never as { argumentPersistenceService: unknown }).argumentPersistenceService = argumentPersistenceService; + (job as never as { historyService: unknown }).historyService = historyService; (job as never as { redis: unknown }).redis = redis; (job as never as { jobLogger: unknown }).jobLogger = jobLogger; (job as never as { aiService: unknown }).aiService = aiService; + (getRepository as Mock).mockReturnValue({ find: findUsers }); }); it('returns early when extraction lock exists', async () => { @@ -173,6 +184,47 @@ describe('ArgumentJob', () => { expect(jobLogger.info).toHaveBeenCalledWith('Argument extracted for C1: "vim vs emacs"'); }); + it('skips extraction when fewer than two known human participants remain after filtering bots', async () => { + historyService.getLast24HoursForChannel.mockResolvedValue([ + { slackId: 'U1', name: 'Alice', message: 'hello' }, + { slackId: 'B1', name: 'Build Bot', message: 'beep boop' }, + ]); + findUsers.mockResolvedValue([{ slackId: 'U1', name: 'Alice', isBot: false }]); + + await ( + job as never as { extractArgumentForChannel: (teamId: string, channelId: string) => Promise } + ).extractArgumentForChannel('T1', 'C1'); + + expect(findUsers).toHaveBeenCalledWith({ + where: [ + { slackId: 'U1', teamId: 'T1', isBot: false }, + { slackId: 'B1', teamId: 'T1', isBot: false }, + ], + }); + expect(aiService.openAi.responses.create).not.toHaveBeenCalled(); + }); + + it('continues extraction when at least two known human participants remain after filtering bots', async () => { + historyService.getLast24HoursForChannel.mockResolvedValue([ + { slackId: 'U1', name: 'Alice', message: 'hello' }, + { slackId: 'B1', name: 'Build Bot', message: 'beep boop' }, + { slackId: 'U2', name: 'Bob', message: 'hi' }, + ]); + findUsers.mockResolvedValue([ + { slackId: 'U1', name: 'Alice', isBot: false }, + { slackId: 'U2', name: 'Bob', isBot: false }, + ]); + aiService.openAi.responses.create.mockResolvedValue({ + output: [{ type: 'message', content: [{ type: 'output_text', text: '[]' }] }], + }); + + await ( + job as never as { extractArgumentForChannel: (teamId: string, channelId: string) => Promise } + ).extractArgumentForChannel('T1', 'C1'); + + expect(aiService.openAi.responses.create).toHaveBeenCalledOnce(); + }); + it('skips malformed extraction payloads and logs warnings', async () => { aiService.openAi.responses.create.mockResolvedValue({ output: [{ type: 'message', content: [{ type: 'output_text', text: '{"summary":"oops"}' }] }], diff --git a/packages/backend/src/argument/argument.job.ts b/packages/backend/src/argument/argument.job.ts index 87ea699b..6bd301b1 100644 --- a/packages/backend/src/argument/argument.job.ts +++ b/packages/backend/src/argument/argument.job.ts @@ -6,6 +6,7 @@ import { RedisPersistenceService } from '../shared/services/redis.persistence.se import { AIService } from '../ai/ai.service'; import { logger } from '../shared/logger/logger'; import { logError } from '../shared/logger/error-logging'; +import { SlackUser } from '../shared/db/models/SlackUser'; import { DAILY_ARGUMENT_JOB_CONCURRENCY, ARGUMENT_EXTRACTION_PROMPT, @@ -71,6 +72,11 @@ export class ArgumentJob { }); if (participantSlackIds.length < 2) return; + const humanParticipants = await getRepository(SlackUser).find({ + where: participantSlackIds.map((slackId) => ({ slackId, teamId, isBot: false })), + }); + if (humanParticipants.length < 2) return; + await this.extractArgument(teamId, channelId, historyMessages); } From 40a5c516a7070bc51018743cb51ec8271d7fb42f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:40:32 +0000 Subject: [PATCH 18/21] fix: exclude bot users from argument winners --- .../src/argument/argument.persistence.service.spec.ts | 9 ++++++++- .../backend/src/argument/argument.persistence.service.ts | 8 ++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/argument/argument.persistence.service.spec.ts b/packages/backend/src/argument/argument.persistence.service.spec.ts index f330afae..14316302 100644 --- a/packages/backend/src/argument/argument.persistence.service.spec.ts +++ b/packages/backend/src/argument/argument.persistence.service.spec.ts @@ -54,7 +54,13 @@ describe('ArgumentPersistenceService', () => { pointValue: 6, }); - expect(findOne).toHaveBeenCalledWith({ where: { slackId: 'U2', teamId: 'T1' } }); + expect(findOne).toHaveBeenCalledWith({ where: { slackId: 'U2', teamId: 'T1', isBot: false } }); + expect(findUsers).toHaveBeenCalledWith({ + where: [ + { slackId: 'U1', teamId: 'T1', isBot: false }, + { slackId: 'U2', teamId: 'T1', isBot: false }, + ], + }); expect(save).toHaveBeenCalledWith( expect.objectContaining({ teamId: 'T1', @@ -171,6 +177,7 @@ describe('ArgumentPersistenceService', () => { const result = await service.getArgumentLeaderboard('T1'); expect(query).toHaveBeenNthCalledWith(1, expect.stringContaining('CAST(COUNT(*) AS SIGNED) AS wins'), ['T1']); + expect(query).toHaveBeenNthCalledWith(1, expect.stringContaining('u.isBot = 0'), ['T1']); expect(findArguments).toHaveBeenCalledWith({ where: { teamId: 'T1' }, relations: ['participants', 'winner'], diff --git a/packages/backend/src/argument/argument.persistence.service.ts b/packages/backend/src/argument/argument.persistence.service.ts index 6713d68b..eb1c1daa 100644 --- a/packages/backend/src/argument/argument.persistence.service.ts +++ b/packages/backend/src/argument/argument.persistence.service.ts @@ -63,7 +63,7 @@ export class ArgumentPersistenceService { async saveArgumentOutcome(input: SaveArgumentOutcomeInput): Promise { const slackUserRepo = getRepository(SlackUser); const winner = await slackUserRepo.findOne({ - where: { slackId: input.winnerSlackId, teamId: input.teamId }, + where: { slackId: input.winnerSlackId, teamId: input.teamId, isBot: false }, }); if (!winner) { @@ -84,7 +84,7 @@ export class ArgumentPersistenceService { ); const participantUsers = await slackUserRepo.find({ - where: participants.map((participant) => ({ slackId: participant.slackId, teamId: input.teamId })), + where: participants.map((participant) => ({ slackId: participant.slackId, teamId: input.teamId, isBot: false })), }); const extractedParticipantViewpoints = buildParticipantViewpoints(participants); const resolvedParticipants = buildArgumentParticipants(participantUsers, extractedParticipantViewpoints); @@ -151,8 +151,8 @@ export class ArgumentPersistenceService { CAST(COALESCE(SUM(a.pointValue), 0) AS SIGNED) AS points FROM argument_leaderboard a INNER JOIN slack_user u ON u.id = a.winnerId - WHERE a.teamId = ? - GROUP BY u.id, u.name, u.slackId + WHERE a.teamId = ? AND u.isBot = 0 + GROUP BY u.id, u.name, u.slackId ORDER BY wins DESC, points DESC, u.name ASC`, [teamId], ), From cb3bc57b4e81b0aee5b6150cf444df0f4b4004a8 Mon Sep 17 00:00:00 2001 From: sfreeman422 Date: Fri, 22 May 2026 12:55:25 -0400 Subject: [PATCH 19/21] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/backend/src/shared/db/models/ArgumentLeaderboard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts index 4da2978e..a2eecedf 100644 --- a/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts +++ b/packages/backend/src/shared/db/models/ArgumentLeaderboard.ts @@ -27,8 +27,8 @@ export class ArgumentLeaderboard { @JoinTable() public participants!: SlackUser[]; - @Column('simple-json', { default: '{}' }) - public participantViewpoints!: ArgumentParticipantViewpoints; + @Column('simple-json') + public participantViewpoints: ArgumentParticipantViewpoints = {}; @ManyToOne(() => SlackUser, (user) => user.argumentWins) public winner!: SlackUser; From 17c7c525101d36b8d2524c803c955b5ea9726b99 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:58:02 +0000 Subject: [PATCH 20/21] test: cover bot exclusion in argument persistence specs --- .../argument.persistence.service.spec.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/argument/argument.persistence.service.spec.ts b/packages/backend/src/argument/argument.persistence.service.spec.ts index 14316302..1e0cbb0c 100644 --- a/packages/backend/src/argument/argument.persistence.service.spec.ts +++ b/packages/backend/src/argument/argument.persistence.service.spec.ts @@ -89,7 +89,7 @@ describe('ArgumentPersistenceService', () => { }); }); - it('returns null when the winner cannot be mapped to a slack user', async () => { + it('returns null when the winner cannot be mapped to a non-bot slack user', async () => { findOne.mockResolvedValue(null); await expect( @@ -105,6 +105,34 @@ describe('ArgumentPersistenceService', () => { pointValue: 4, }), ).resolves.toBeNull(); + expect(findOne).toHaveBeenCalledWith({ where: { slackId: 'U2', teamId: 'T1', isBot: false } }); + expect(findUsers).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + }); + + it('returns null when fewer than two non-bot participants are resolved', async () => { + findOne.mockResolvedValue({ id: 7, slackId: 'U1', name: 'Alice' }); + findUsers.mockResolvedValue([{ id: 7, slackId: 'U1', name: 'Alice' }]); + + await expect( + service.saveArgumentOutcome({ + teamId: 'T1', + channelId: 'C1', + argumentSummary: 'Tabs versus spaces', + participants: [ + { slackId: 'U1', name: 'Alice', viewpoint: 'tabs are faster' }, + { slackId: 'UBOT', name: 'HelperBot', viewpoint: 'bots should win' }, + ], + winnerSlackId: 'U1', + pointValue: 4, + }), + ).resolves.toBeNull(); + expect(findUsers).toHaveBeenCalledWith({ + where: [ + { slackId: 'U1', teamId: 'T1', isBot: false }, + { slackId: 'UBOT', teamId: 'T1', isBot: false }, + ], + }); expect(save).not.toHaveBeenCalled(); }); From a04b28c670fcbf0d600ab1d6632689e90ddfbf31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 May 2026 16:58:18 +0000 Subject: [PATCH 21/21] fix: cap argument history query --- .../backend/src/argument/argument.persistence.service.spec.ts | 1 + packages/backend/src/argument/argument.persistence.service.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/backend/src/argument/argument.persistence.service.spec.ts b/packages/backend/src/argument/argument.persistence.service.spec.ts index 1e0cbb0c..05e0e607 100644 --- a/packages/backend/src/argument/argument.persistence.service.spec.ts +++ b/packages/backend/src/argument/argument.persistence.service.spec.ts @@ -210,6 +210,7 @@ describe('ArgumentPersistenceService', () => { where: { teamId: 'T1' }, relations: ['participants', 'winner'], order: { createdAt: 'DESC' }, + take: 100, }); expect(result).toEqual({ leaderboard: [ diff --git a/packages/backend/src/argument/argument.persistence.service.ts b/packages/backend/src/argument/argument.persistence.service.ts index eb1c1daa..1f03d7e7 100644 --- a/packages/backend/src/argument/argument.persistence.service.ts +++ b/packages/backend/src/argument/argument.persistence.service.ts @@ -14,6 +14,8 @@ interface LeaderboardRow { points: string; } +const ARGUMENT_HISTORY_LIMIT = 100; + const clampPointValue = (value: number): number => Math.min(5, Math.max(0, Math.round(value))); const normalizeParticipant = (participant: Partial): ArgumentParticipant | null => { @@ -160,6 +162,7 @@ export class ArgumentPersistenceService { where: { teamId }, relations: ['participants', 'winner'], order: { createdAt: 'DESC' }, + take: ARGUMENT_HISTORY_LIMIT, }), ]);