Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fc70994
Initial plan
Copilot May 21, 2026
34efe91
feat: add argument leaderboard page
Copilot May 21, 2026
a9a2285
refactor: move argument extraction to nightly job
Copilot May 21, 2026
6d22437
fix: support multiple nightly argument extractions
Copilot May 21, 2026
2ad28e5
fix: store argument participants as slack users
Copilot May 21, 2026
b11a8f2
fix: clean up argument participant persistence
Copilot May 21, 2026
1f29b37
fix: tidy argument participant validation
Copilot May 21, 2026
f91b236
Add dashboard controller coverage
Copilot May 21, 2026
23b96e4
Potential fix for pull request finding
sfreeman422 May 21, 2026
2833452
Potential fix for pull request finding
sfreeman422 May 21, 2026
0e1a81c
Potential fix for pull request finding
sfreeman422 May 21, 2026
0277d9e
Fix backend TypeORM test mock decorators
Copilot May 21, 2026
b404d5e
fix: replace 'return NONE' with '[]' in argument extraction prompt fo…
Copilot May 22, 2026
e45c7ae
refactor: centralize OpenAI response text extraction helper
Copilot May 22, 2026
58cadab
fix: keep argument participant viewpoints in sync
Copilot May 22, 2026
97b5a7b
Potential fix for pull request finding
sfreeman422 May 22, 2026
ce9c4b4
fix: skip argument extraction when bots leave fewer than two humans
Copilot May 22, 2026
40a5c51
fix: exclude bot users from argument winners
Copilot May 22, 2026
cb3bc57
Potential fix for pull request finding
sfreeman422 May 22, 2026
17c7c52
test: cover bot exclusion in argument persistence specs
Copilot May 22, 2026
a04b28c
fix: cap argument history query
Copilot May 22, 2026
646c676
Merge branch 'master' into copilot/add-argument-leaderboard-table
sfreeman422 Jun 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,20 +254,20 @@ docker logs <container-id> | jq .

### AI Features (Optional)

- **Daily Memory Job** - Summarizes 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

### Scheduled Jobs

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

Expand Down
49 changes: 49 additions & 0 deletions packages/backend/src/ai/ai.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,52 @@ Output format:
- If no strong traits are present, return []`;

export const DAILY_MEMORY_JOB_CONCURRENCY = 50;
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 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.
If there is any meaningful doubt that the conversation was a real argument, return [].

Comment on lines +214 to +219

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the latest commit — both "return NONE" occurrences (the inline sentence and the Return NONE for: section header) have been replaced with [] so the prompt consistently uses the JSON array contract throughout.

ONLY treat something as an argument when ALL of the following are true:
Comment on lines +212 to +220
- 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 [] 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

Return ONLY a JSON array.

Each array item must be a 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:
- 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
- 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 JSON array [] and nothing else.`;
28 changes: 6 additions & 22 deletions packages/backend/src/ai/ai.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -51,17 +46,6 @@ interface ReleaseMetadata {

const isRecord = (value: unknown): value is Record<string, unknown> => 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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/backend/src/ai/helpers/extractOpenAiResponseText.ts
Original file line number Diff line number Diff line change
@@ -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;
};
13 changes: 2 additions & 11 deletions packages/backend/src/ai/memory/memory.job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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');
Expand Down
Loading
Loading