diff --git a/.changeset/polite-paws-heal.md b/.changeset/polite-paws-heal.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/polite-paws-heal.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/src/addie/jobs/committee-document-indexer.ts b/server/src/addie/jobs/committee-document-indexer.ts index e6d416ade6..bd01faf5cc 100644 --- a/server/src/addie/jobs/committee-document-indexer.ts +++ b/server/src/addie/jobs/committee-document-indexer.ts @@ -13,7 +13,6 @@ */ import * as crypto from 'crypto'; -import Anthropic from '@anthropic-ai/sdk'; import { logger } from '../../logger.js'; import { WorkingGroupDatabase } from '../../db/working-group-db.js'; import { @@ -22,13 +21,11 @@ import { GOOGLE_DOCS_ERROR_PREFIX, GOOGLE_DOCS_ACCESS_DENIED_PREFIX, } from '../mcp/google-docs.js'; +import { isLLMConfigured, complete } from '../../utils/llm.js'; import type { CommitteeDocument, DocumentIndexStatus } from '../../types.js'; const workingGroupDb = new WorkingGroupDatabase(); -// Use same model as main Addie assistant -const SUMMARIZER_MODEL = process.env.ADDIE_MODEL || 'claude-sonnet-4-20250514'; - export interface DocumentIndexResult { documentsChecked: number; documentsChanged: number; @@ -107,8 +104,7 @@ async function generateDocumentSummary( content: string, committeeContext?: string ): Promise { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) { + if (!isLLMConfigured()) { throw new Error('ANTHROPIC_API_KEY not configured'); } @@ -118,8 +114,6 @@ async function generateDocumentSummary( ? content.substring(0, maxContentLength) + '\n\n[Content truncated...]' : content; - const client = new Anthropic({ apiKey }); - const systemPrompt = `You are summarizing a document for a working group at AgenticAdvertising.org. Generate a brief, informative summary (2-4 sentences) that captures the key points. Focus on what the document covers and any important updates or decisions. @@ -132,15 +126,15 @@ ${truncatedContent} Write a brief summary (2-4 sentences) of this document.`; - const response = await client.messages.create({ - model: SUMMARIZER_MODEL, - max_tokens: 300, + const result = await complete({ system: systemPrompt, - messages: [{ role: 'user', content: userPrompt }], + prompt: userPrompt, + maxTokens: 300, + model: 'primary', + operationName: 'document-summary', }); - const textContent = response.content.find(block => block.type === 'text'); - return textContent?.text || 'Unable to generate summary.'; + return result.text || 'Unable to generate summary.'; } /** @@ -151,8 +145,7 @@ async function generateChangeSummary( oldContent: string, newContent: string ): Promise { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) { + if (!isLLMConfigured()) { return 'Document content was updated.'; } @@ -165,20 +158,15 @@ async function generateChangeSummary( ? newContent.substring(0, maxLength) + '...' : newContent; - const client = new Anthropic({ apiKey }); - - const response = await client.messages.create({ - model: SUMMARIZER_MODEL, - max_tokens: 200, + const result = await complete({ system: 'Summarize the key changes between the old and new versions of this document in 1-2 sentences. Focus on substantive changes, not formatting.', - messages: [{ - role: 'user', - content: `Document: "${title}"\n\nOLD VERSION:\n${truncatedOld}\n\nNEW VERSION:\n${truncatedNew}\n\nWhat changed?`, - }], + prompt: `Document: "${title}"\n\nOLD VERSION:\n${truncatedOld}\n\nNEW VERSION:\n${truncatedNew}\n\nWhat changed?`, + maxTokens: 200, + model: 'primary', + operationName: 'document-change-summary', }); - const textContent = response.content.find(block => block.type === 'text'); - return textContent?.text || 'Document was updated.'; + return result.text || 'Document was updated.'; } /** diff --git a/server/src/addie/jobs/committee-summary-generator.ts b/server/src/addie/jobs/committee-summary-generator.ts index 7eff937e69..b970ecc422 100644 --- a/server/src/addie/jobs/committee-summary-generator.ts +++ b/server/src/addie/jobs/committee-summary-generator.ts @@ -10,17 +10,14 @@ * - 'changes': Summary of what changed since last update */ -import Anthropic from '@anthropic-ai/sdk'; import { logger } from '../../logger.js'; import { getPool } from '../../db/client.js'; import { WorkingGroupDatabase } from '../../db/working-group-db.js'; +import { isLLMConfigured, complete } from '../../utils/llm.js'; import type { CommitteeSummaryType } from '../../types.js'; const workingGroupDb = new WorkingGroupDatabase(); -// Use same model as main Addie assistant -const SUMMARIZER_MODEL = process.env.ADDIE_MODEL || 'claude-sonnet-4-20250514'; - export interface SummaryGeneratorResult { committeesProcessed: number; summariesGenerated: number; @@ -64,13 +61,10 @@ async function generateActivitySummary( posts: Array<{ title: string; excerpt?: string; published_at?: Date }>, activity: Array<{ activity_type: string; change_summary?: string; detected_at: Date; document_title?: string }> ): Promise { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) { + if (!isLLMConfigured()) { throw new Error('ANTHROPIC_API_KEY not configured'); } - const client = new Anthropic({ apiKey }); - // Build context about the committee const documentsSection = documents.length > 0 ? `\nTracked Documents:\n${documents.map(d => @@ -105,15 +99,15 @@ ${documentsSection}${postsSection}${changesSection} Generate a brief activity summary for this committee.`; - const response = await client.messages.create({ - model: SUMMARIZER_MODEL, - max_tokens: 400, + const result = await complete({ + prompt: userPrompt, system: systemPrompt, - messages: [{ role: 'user', content: userPrompt }], + maxTokens: 400, + model: 'primary', + operationName: 'committee-summary', }); - const textContent = response.content.find(block => block.type === 'text'); - return textContent?.text || 'No activity summary available.'; + return result.text || 'No activity summary available.'; } /** diff --git a/server/src/addie/jobs/insight-synthesizer.ts b/server/src/addie/jobs/insight-synthesizer.ts index 5259d4b0bc..7fcfd0131a 100644 --- a/server/src/addie/jobs/insight-synthesizer.ts +++ b/server/src/addie/jobs/insight-synthesizer.ts @@ -6,8 +6,9 @@ * focused knowledge rules. */ -import Anthropic from '@anthropic-ai/sdk'; import { createLogger } from '../../logger.js'; +import { complete } from '../../utils/llm.js'; +import { ModelConfig } from '../../config/models.js'; import { AddieDatabase, type InsightSource, @@ -18,7 +19,6 @@ import { type AddieRule, type RuleType, } from '../../db/addie-db.js'; -import { ModelConfig } from '../../config/models.js'; import { getOrCreateConfigVersion, invalidateConfigCache, @@ -102,7 +102,6 @@ Prefer fewer, higher-quality rules over many mediocre ones.`; */ export async function synthesizeInsights( db: AddieDatabase, - anthropicApiKey: string, options: SynthesisOptions = {} ): Promise { const { @@ -128,14 +127,12 @@ export async function synthesizeInsights( const topicsIncluded = Object.keys(byTopic); // 3. Synthesize each topic group - const anthropic = new Anthropic({ apiKey: anthropicApiKey }); const allProposedRules: ProposedRule[] = []; const allGaps: string[] = []; let totalTokens = 0; for (const [topicName, topicSources] of Object.entries(byTopic)) { const { rules, gaps, tokensUsed } = await synthesizeTopic( - anthropic, topicName, topicSources ); @@ -165,7 +162,6 @@ export async function synthesizeInsights( try { preview = await previewSynthesizedRules( db, - anthropic, allProposedRules, previewSampleSize ); @@ -301,21 +297,21 @@ function groupByTopic(sources: InsightSource[]): Record * Synthesize a single topic's sources into rules */ async function synthesizeTopic( - anthropic: Anthropic, topic: string, sources: InsightSource[] ): Promise<{ rules: ProposedRule[]; gaps: string[]; tokensUsed: number }> { const prompt = buildSynthesisPrompt(topic, sources); - const response = await anthropic.messages.create({ - model: ModelConfig.primary, - max_tokens: 4096, + const result = await complete({ + prompt, system: SYNTHESIS_SYSTEM_PROMPT, - messages: [{ role: 'user', content: prompt }], + maxTokens: 4096, + model: 'primary', + operationName: 'insight-synthesis', }); - const tokensUsed = response.usage.input_tokens + response.usage.output_tokens; - const responseText = response.content[0].type === 'text' ? response.content[0].text : ''; + const tokensUsed = (result.inputTokens || 0) + (result.outputTokens || 0); + const responseText = result.text; // Parse response let parsed: ClaudeSynthesisResponse; @@ -377,7 +373,6 @@ Now synthesize these sources into 1-3 focused knowledge rules. Remember: */ async function previewSynthesizedRules( db: AddieDatabase, - anthropic: Anthropic, proposedRules: ProposedRule[], sampleSize: number ): Promise { @@ -411,12 +406,8 @@ async function previewSynthesizedRules( for (const interaction of sampled) { try { - const response = await anthropic.messages.create({ - model: ModelConfig.fast, - max_tokens: 512, - messages: [{ - role: 'user', - content: `You are evaluating how new knowledge rules would affect an AI assistant's response. + const result = await complete({ + prompt: `You are evaluating how new knowledge rules would affect an AI assistant's response. ## New Rules Being Added @@ -437,12 +428,13 @@ Would the new rules improve this response? Output JSON: "predicted_change": "Brief description of how response would change (or 'No significant change')", "improvement_score": 0.5, // -1 (worse) to 1 (better), 0 = no change "confidence": 0.8 -}` - }], +}`, + maxTokens: 512, + model: 'fast', + operationName: 'synthesis-preview', }); - const responseText = response.content[0].type === 'text' ? response.content[0].text : ''; - const jsonMatch = responseText.match(/\{[\s\S]*\}/); + const jsonMatch = result.text.match(/\{[\s\S]*\}/); if (jsonMatch) { const parsed = JSON.parse(jsonMatch[0]); predictions.push({ diff --git a/server/src/addie/jobs/moltbook-engagement.ts b/server/src/addie/jobs/moltbook-engagement.ts index 8844c8217a..e18e0d6c40 100644 --- a/server/src/addie/jobs/moltbook-engagement.ts +++ b/server/src/addie/jobs/moltbook-engagement.ts @@ -7,7 +7,6 @@ * Runs every 4 hours, respecting Moltbook's comment rate limits. */ -import Anthropic from '@anthropic-ai/sdk'; import { randomUUID } from 'crypto'; import { logger as baseLogger } from '../../logger.js'; import { @@ -34,15 +33,13 @@ import { } from '../../db/moltbook-db.js'; import { sendChannelMessage } from '../../slack/client.js'; import { getChannelByName } from '../../db/notification-channels-db.js'; +import { isLLMConfigured, classify, complete } from '../../utils/llm.js'; const logger = baseLogger.child({ module: 'moltbook-engagement' }); // Channel name in notification_channels table const MOLTBOOK_CHANNEL_NAME = 'addie_moltbook'; -// Claude model for generating comments -const ENGAGEMENT_MODEL = process.env.ADDIE_MODEL || 'claude-sonnet-4-20250514'; - // Search terms for finding advertising discussions const SEARCH_TERMS = [ 'advertising', @@ -70,23 +67,12 @@ interface EngagementResult { // Daily limit for upvotes (be generous but not spammy) const MAX_DAILY_UPVOTES = 20; -interface ThreadContext { - post: MoltbookPost; - comments: MoltbookComment[]; - isRelevant: boolean; - engagementOpportunity?: string; -} - /** * Check if a post is relevant to advertising topics * Uses Claude to evaluate - much more accurate than keyword matching */ async function isAdvertisingRelevant(post: MoltbookPost, jobRunId: string): Promise { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) return false; - - const client = new Anthropic({ apiKey }); - const startTime = Date.now(); + if (!isLLMConfigured()) return false; const prompt = `Is this Moltbook post relevant to ADVERTISING, AD TECH, or MARKETING? @@ -109,37 +95,30 @@ Answer NO if the post is about: Respond with only YES or NO.`; try { - const response = await client.messages.create({ - model: 'claude-haiku-4-20250514', - max_tokens: 10, - messages: [{ role: 'user', content: prompt }], + const result = await classify({ + prompt, + operationName: 'moltbook-relevance', }); - const content = response.content[0]; - if (content.type !== 'text') return false; - - const isRelevant = content.text.trim().toUpperCase() === 'YES'; - const latencyMs = Date.now() - startTime; - // Record the decision await recordDecision({ moltbookPostId: post.id, postTitle: post.title, postAuthor: post.author?.name, decisionType: 'relevance', - outcome: isRelevant ? 'engaged' : 'skipped', - reason: isRelevant + outcome: result.result ? 'engaged' : 'skipped', + reason: result.result ? 'Post is relevant to advertising/ad tech/marketing topics' : 'Post not related to advertising topics', decisionMethod: 'llm', - model: 'claude-haiku-4-20250514', - tokensInput: response.usage?.input_tokens, - tokensOutput: response.usage?.output_tokens, - latencyMs, + model: result.model, + tokensInput: result.inputTokens, + tokensOutput: result.outputTokens, + latencyMs: result.latencyMs, jobRunId, }); - return isRelevant; + return result.result; } catch (err) { logger.debug({ err, postId: post.id }, 'Failed to evaluate post relevance'); @@ -152,7 +131,7 @@ Respond with only YES or NO.`; outcome: 'skipped', reason: `Error evaluating relevance: ${err instanceof Error ? err.message : 'Unknown error'}`, decisionMethod: 'llm', - latencyMs: Date.now() - startTime, + latencyMs: 0, jobRunId, }); @@ -168,15 +147,11 @@ async function generateComment( comments: MoltbookComment[], jobRunId: string ): Promise { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) { + if (!isLLMConfigured()) { logger.error('ANTHROPIC_API_KEY not configured'); return null; } - const client = new Anthropic({ apiKey }); - const startTime = Date.now(); - // Build context from existing comments const existingDiscussion = comments .slice(0, 5) // Limit to recent comments @@ -216,17 +191,14 @@ If this discussion isn't relevant to advertising/agents/tech, respond with just Your comment:`; try { - const response = await client.messages.create({ - model: ENGAGEMENT_MODEL, - max_tokens: 300, - messages: [{ role: 'user', content: prompt }], + const result = await complete({ + prompt, + maxTokens: 300, + model: 'primary', + operationName: 'moltbook-comment', }); - const content = response.content[0]; - if (content.type !== 'text') return null; - - const comment = content.text.trim(); - const latencyMs = Date.now() - startTime; + const comment = result.text; const isSkip = comment === 'SKIP' || comment.toLowerCase().includes('skip'); // Record the decision @@ -242,10 +214,10 @@ Your comment:`; decisionMethod: 'llm', generatedContent: isSkip ? undefined : comment, contentPosted: false, // Will be updated after posting - model: ENGAGEMENT_MODEL, - tokensInput: response.usage?.input_tokens, - tokensOutput: response.usage?.output_tokens, - latencyMs, + model: result.model, + tokensInput: result.inputTokens, + tokensOutput: result.outputTokens, + latencyMs: result.latencyMs, jobRunId, }); @@ -265,7 +237,7 @@ Your comment:`; outcome: 'skipped', reason: `Error generating comment: ${err instanceof Error ? err.message : 'Unknown error'}`, decisionMethod: 'llm', - latencyMs: Date.now() - startTime, + latencyMs: 0, jobRunId, }); @@ -282,10 +254,7 @@ async function generateReply( replyAuthor: string, replyContent: string ): Promise { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) return null; - - const client = new Anthropic({ apiKey }); + if (!isLLMConfigured()) return null; const prompt = `You are Addie, the AI assistant for AgenticAdvertising.org. You have a STRONG OPINION: agents should take over advertising from humans. @@ -309,16 +278,14 @@ If they're being hostile or trolling, respond with just "SKIP". Your reply:`; try { - const response = await client.messages.create({ - model: ENGAGEMENT_MODEL, - max_tokens: 200, - messages: [{ role: 'user', content: prompt }], + const result = await complete({ + prompt, + maxTokens: 200, + model: 'primary', + operationName: 'moltbook-reply', }); - const content = response.content[0]; - if (content.type !== 'text') return null; - - const reply = content.text.trim(); + const reply = result.text; if (reply === 'SKIP' || reply.toLowerCase().includes('skip')) { return null; } @@ -339,15 +306,11 @@ async function shouldUpvoteComment( comment: MoltbookComment, jobRunId: string ): Promise { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) return false; + if (!isLLMConfigured()) return false; // Don't upvote our own comments if (comment.author.name.toLowerCase() === 'addie') return false; - const client = new Anthropic({ apiKey }); - const startTime = Date.now(); - const prompt = `You are Addie, evaluating if a comment deserves karma (upvote). **Your beliefs:** @@ -376,38 +339,31 @@ Answer NO if the comment: Respond with only YES or NO.`; try { - const response = await client.messages.create({ - model: 'claude-haiku-4-20250514', // Fast model for evaluation - max_tokens: 10, - messages: [{ role: 'user', content: prompt }], + const result = await classify({ + prompt, + operationName: 'moltbook-upvote', }); - const content = response.content[0]; - if (content.type !== 'text') return false; - - const shouldUpvote = content.text.trim().toUpperCase() === 'YES'; - const latencyMs = Date.now() - startTime; - // Record the decision await recordDecision({ moltbookPostId: post.id, postTitle: post.title, postAuthor: comment.author.name, decisionType: 'upvote', - outcome: shouldUpvote ? 'engaged' : 'skipped', - reason: shouldUpvote + outcome: result.result ? 'engaged' : 'skipped', + reason: result.result ? `Comment by ${comment.author.name} aligns with agentic advertising worldview` : `Comment by ${comment.author.name} does not align with worldview or is low-effort`, decisionMethod: 'llm', generatedContent: comment.content.substring(0, 200), - model: 'claude-haiku-4-20250514', - tokensInput: response.usage?.input_tokens, - tokensOutput: response.usage?.output_tokens, - latencyMs, + model: result.model, + tokensInput: result.inputTokens, + tokensOutput: result.outputTokens, + latencyMs: result.latencyMs, jobRunId, }); - return shouldUpvote; + return result.result; } catch (err) { logger.debug({ err }, 'Failed to evaluate comment for karma'); return false; diff --git a/server/src/addie/jobs/moltbook-poster.ts b/server/src/addie/jobs/moltbook-poster.ts index 8c3e3bafe6..7b54dea6f0 100644 --- a/server/src/addie/jobs/moltbook-poster.ts +++ b/server/src/addie/jobs/moltbook-poster.ts @@ -7,7 +7,6 @@ * Runs every 2 hours, respecting Moltbook's 1 post per 30 minutes rate limit. */ -import Anthropic from '@anthropic-ai/sdk'; import { logger as baseLogger } from '../../logger.js'; import { isMoltbookEnabled, @@ -24,15 +23,13 @@ import { } from '../../db/moltbook-db.js'; import { sendChannelMessage } from '../../slack/client.js'; import { getChannelByName } from '../../db/notification-channels-db.js'; +import { isLLMConfigured, complete } from '../../utils/llm.js'; const logger = baseLogger.child({ module: 'moltbook-poster' }); // Channel name in notification_channels table const MOLTBOOK_CHANNEL_NAME = 'addie_moltbook'; -// Model for submolt selection -const SUBMOLT_SELECTION_MODEL = 'claude-haiku-4-20250514'; - // Default submolt if selection fails const DEFAULT_SUBMOLT = 'technology'; @@ -51,8 +48,7 @@ async function selectSubmolt( content: string, submolts: MoltbookSubmolt[] ): Promise { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) { + if (!isLLMConfigured()) { logger.warn('ANTHROPIC_API_KEY not configured, using default submolt'); return DEFAULT_SUBMOLT; } @@ -84,19 +80,14 @@ Select the single most appropriate submolt for this article. Consider: Respond with ONLY the submolt name (e.g., "technology"), nothing else.`; try { - const client = new Anthropic({ apiKey }); - const response = await client.messages.create({ - model: SUBMOLT_SELECTION_MODEL, - max_tokens: 50, - messages: [{ role: 'user', content: prompt }], + const result = await complete({ + prompt, + maxTokens: 50, + model: 'fast', + operationName: 'moltbook-submolt', }); - const textContent = response.content[0]; - if (textContent.type !== 'text') { - return DEFAULT_SUBMOLT; - } - - const selected = textContent.text.trim().toLowerCase(); + const selected = result.text.toLowerCase(); // Verify the selected submolt exists const validSubmolt = submolts.find(s => s.name.toLowerCase() === selected); diff --git a/server/src/addie/jobs/rule-analyzer.ts b/server/src/addie/jobs/rule-analyzer.ts index 45ad154049..3e1dd72809 100644 --- a/server/src/addie/jobs/rule-analyzer.ts +++ b/server/src/addie/jobs/rule-analyzer.ts @@ -5,7 +5,6 @@ * Can be run on a schedule or triggered manually. */ -import Anthropic from '@anthropic-ai/sdk'; import { createLogger } from '../../logger.js'; import { AddieDatabase, @@ -15,6 +14,7 @@ import { type AnalysisType, } from '../../db/addie-db.js'; import { randomUUID } from 'crypto'; +import { complete } from '../../utils/llm.js'; import { ModelConfig } from '../../config/models.js'; const logger = createLogger('addie-rule-analyzer'); @@ -60,7 +60,6 @@ interface ClaudeAnalysisResponse { */ export async function analyzeInteractions(options: { db: AddieDatabase; - anthropicApiKey: string; analysisType?: AnalysisType; days?: number; focusOnNegative?: boolean; @@ -68,7 +67,6 @@ export async function analyzeInteractions(options: { }): Promise { const { db, - anthropicApiKey, analysisType = 'manual', days = 7, focusOnNegative = false, @@ -109,11 +107,8 @@ export async function analyzeInteractions(options: { const analysisPrompt = buildAnalysisPrompt(interactions, rules); // Call Claude for analysis - const anthropic = new Anthropic({ apiKey: anthropicApiKey }); - - const response = await anthropic.messages.create({ - model: ModelConfig.primary, - max_tokens: 4096, + const response = await complete({ + prompt: analysisPrompt, system: `You are an expert at analyzing AI agent interactions and improving agent behavior. Your task is to analyze interactions between Addie (an AI assistant for the AAO community) and users, then suggest improvements. @@ -141,17 +136,14 @@ Focus on: 5. External sources that users found helpful (like bokonads.com, IAB Tech Lab, etc.) Output your analysis as JSON matching the specified schema.`, - messages: [ - { - role: 'user', - content: analysisPrompt, - }, - ], + maxTokens: 4096, + model: 'primary', + operationName: 'rule-analyzer-analyze', }); // Parse Claude's response - const responseText = response.content[0].type === 'text' ? response.content[0].text : ''; - const tokensUsed = response.usage.input_tokens + response.usage.output_tokens; + const responseText = response.text; + const tokensUsed = (response.inputTokens || 0) + (response.outputTokens || 0); let analysis: ClaudeAnalysisResponse; try { @@ -320,7 +312,6 @@ Focus on actionable, evidence-based suggestions. For content gaps, prefer linkin */ export async function previewRuleChange(options: { db: AddieDatabase; - anthropicApiKey: string; proposedRules: AddieRule[]; sampleSize?: number; }): Promise<{ @@ -333,7 +324,7 @@ export async function previewRuleChange(options: { }>; overall_assessment: string; }> { - const { db, anthropicApiKey, proposedRules, sampleSize = 10 } = options; + const { db, proposedRules, sampleSize = 10 } = options; // Get sample of recent interactions const interactions = await db.getInteractionsForAnalysis({ @@ -351,27 +342,12 @@ export async function previewRuleChange(options: { // Build system prompt from proposed rules const proposedPrompt = buildSystemPromptFromRules(proposedRules); - const anthropic = new Anthropic({ apiKey: anthropicApiKey }); - // For each interaction, predict how the new rules would change the response const predictions = []; for (const interaction of interactions.slice(0, 5)) { - const response = await anthropic.messages.create({ - model: ModelConfig.primary, - max_tokens: 1024, - system: `You are evaluating how a change in operating rules would affect an AI assistant's response. - -Given: -1. The user's original input -2. The assistant's original response -3. The proposed new operating rules - -Predict how the response would differ under the new rules. Be specific about improvements or potential issues.`, - messages: [ - { - role: 'user', - content: `## Proposed Rules + const response = await complete({ + prompt: `## Proposed Rules ${proposedPrompt} @@ -392,11 +368,20 @@ Predict how the response would change under these rules. Output JSON: "confidence": 0.8 } \`\`\``, - }, - ], + system: `You are evaluating how a change in operating rules would affect an AI assistant's response. + +Given: +1. The user's original input +2. The assistant's original response +3. The proposed new operating rules + +Predict how the response would differ under the new rules. Be specific about improvements or potential issues.`, + maxTokens: 1024, + model: 'primary', + operationName: 'rule-analyzer-preview-prediction', }); - const responseText = response.content[0].type === 'text' ? response.content[0].text : ''; + const responseText = response.text; try { const jsonMatch = responseText.match(/```json\n?([\s\S]*?)\n?```/) || responseText.match(/\{[\s\S]*\}/); @@ -416,24 +401,18 @@ Predict how the response would change under these rules. Output JSON: } // Generate overall assessment - const assessmentResponse = await anthropic.messages.create({ - model: ModelConfig.primary, - max_tokens: 512, - messages: [ - { - role: 'user', - content: `Based on these predictions of how rule changes would affect responses, provide a brief overall assessment: + const assessmentResponse = await complete({ + prompt: `Based on these predictions of how rule changes would affect responses, provide a brief overall assessment: ${JSON.stringify(predictions, null, 2)} In 2-3 sentences, summarize whether these rule changes seem beneficial and any risks.`, - }, - ], + maxTokens: 512, + model: 'primary', + operationName: 'rule-analyzer-preview-assessment', }); - const overallAssessment = assessmentResponse.content[0].type === 'text' - ? assessmentResponse.content[0].text - : 'Unable to generate assessment.'; + const overallAssessment = assessmentResponse.text; return { predictions, diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index c8395928f4..dabf25f585 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -6048,11 +6048,6 @@ Use add_committee_leader to assign a leader.`; try { const topic = (input.topic as string) || undefined; - const apiKey = process.env.ANTHROPIC_API_KEY; - - if (!apiKey) { - return '❌ ANTHROPIC_API_KEY not configured. Cannot run synthesis.'; - } const { AddieDatabase } = await import('../../db/addie-db.js'); const { synthesizeInsights } = await import('../jobs/insight-synthesizer.js'); @@ -6072,7 +6067,7 @@ Use add_committee_leader to assign a leader.`; createdBy, }, 'Starting insight synthesis via Addie tool'); - const result = await synthesizeInsights(addieDb, apiKey, { + const result = await synthesizeInsights(addieDb, { topic, maxSources: 50, previewSampleSize: 20, diff --git a/server/src/addie/services/content-curator.ts b/server/src/addie/services/content-curator.ts index 9cda3e7a23..4a5b3f787c 100644 --- a/server/src/addie/services/content-curator.ts +++ b/server/src/addie/services/content-curator.ts @@ -11,8 +11,8 @@ * 4. Content is indexed for full-text search */ -import Anthropic from '@anthropic-ai/sdk'; import { Readability } from '@mozilla/readability'; +import { isLLMConfigured, complete } from '../../utils/llm.js'; import { parseHTML } from 'linkedom'; import { logger } from '../../logger.js'; import { AddieDatabase, type KeyInsight } from '../../db/addie-db.js'; @@ -28,9 +28,6 @@ import { const addieDb = new AddieDatabase(); -// Use same model as main Addie assistant -const CURATOR_MODEL = process.env.ADDIE_MODEL || 'claude-sonnet-4-20250514'; - /** * Fetch URL content and extract article text using Mozilla Readability * This extracts just the main article content, removing navigation, ads, footers, etc. @@ -147,13 +144,10 @@ async function generateAnalysis( quality_score: number | null; notification_channels: string[]; }> { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) { + if (!isLLMConfigured()) { throw new Error('ANTHROPIC_API_KEY not configured'); } - const client = new Anthropic({ apiKey }); - // Build channel routing section if channels are provided const channelRoutingSection = channels && channels.length > 0 ? ` @@ -168,13 +162,7 @@ ${channels.map(ch => `- "${ch.slack_channel_id}": ${ch.name} - ${ch.description} Add "notification_channels": ["channel_id", ...] to your JSON response with the IDs of channels that should receive this article.` : ''; - const response = await client.messages.create({ - model: CURATOR_MODEL, - max_tokens: 2000, - messages: [ - { - role: 'user', - content: `You are Addie, the AI assistant for AgenticAdvertising.org. Analyze this article and provide structured insights for our knowledge base. + const prompt = `You are Addie, the AI assistant for AgenticAdvertising.org. Analyze this article and provide structured insights for our knowledge base. **Article Title:** ${title} **URL:** ${url} @@ -220,14 +208,17 @@ Provide your analysis as JSON with this structure: - 1: Not useful for our community ${channelRoutingSection} -Return ONLY the JSON, no markdown formatting.`, - }, - ], +Return ONLY the JSON, no markdown formatting.`; + + const response = await complete({ + prompt, + model: 'primary', + maxTokens: 2000, + operationName: 'content-curator-analysis', }); // Extract JSON from response - const responseText = - response.content[0].type === 'text' ? response.content[0].text : ''; + const responseText = response.text; try { // Try to parse as JSON directly diff --git a/server/src/addie/services/insight-extractor.ts b/server/src/addie/services/insight-extractor.ts index 43a2233f2f..cf9363e423 100644 --- a/server/src/addie/services/insight-extractor.ts +++ b/server/src/addie/services/insight-extractor.ts @@ -11,7 +11,6 @@ * 4. Stores extracted insights with source tracking */ -import Anthropic from '@anthropic-ai/sdk'; import { logger } from '../../logger.js'; import { InsightsDatabase, @@ -19,15 +18,12 @@ import { type InsightGoal, type InsightConfidence, } from '../../db/insights-db.js'; -import { ModelConfig } from '../../config/models.js'; import { invalidateInsightsCache } from '../insights-cache.js'; import { trackApiCall, ApiPurpose } from './api-tracker.js'; +import { isLLMConfigured, complete } from '../../utils/llm.js'; const insightsDb = new InsightsDatabase(); -// Use fast model for insight extraction (it's a classification task) -const EXTRACTOR_MODEL = ModelConfig.fast; - /** * Extracted insight from Claude analysis */ @@ -185,8 +181,7 @@ export async function extractInsights( }; } - const apiKey = process.env.ADDIE_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; - if (!apiKey) { + if (!isLLMConfigured()) { logger.warn('Insight extractor: No API key configured'); return { insights: [], @@ -216,32 +211,25 @@ export async function extractInsights( // Build prompt and call Claude const prompt = buildExtractionPrompt(message, insightTypes, activeGoals); - const client = new Anthropic({ apiKey }); - const startTime = Date.now(); - const response = await client.messages.create({ - model: EXTRACTOR_MODEL, - max_tokens: 1000, - messages: [ - { - role: 'user', - content: prompt, - }, - ], + const result = await complete({ + prompt, + maxTokens: 1000, + model: 'fast', + operationName: 'insight-extraction', }); - const latencyMs = Date.now() - startTime; // Track for performance metrics (fire-and-forget, errors handled internally) void trackApiCall({ - model: EXTRACTOR_MODEL, + model: result.model, purpose: ApiPurpose.INSIGHT_EXTRACTION, - tokens_input: response.usage?.input_tokens, - tokens_output: response.usage?.output_tokens, - latency_ms: latencyMs, + tokens_input: result.inputTokens, + tokens_output: result.outputTokens, + latency_ms: result.latencyMs, thread_id: context.threadId, }); // Parse response - const responseText = response.content[0].type === 'text' ? response.content[0].text : ''; + const responseText = result.text; let parsed: { insights: ExtractedInsight[]; goal_responses: GoalResponse[] }; try { diff --git a/server/src/addie/services/interaction-analyzer.ts b/server/src/addie/services/interaction-analyzer.ts index 96b2f5164c..ab63076725 100644 --- a/server/src/addie/services/interaction-analyzer.ts +++ b/server/src/addie/services/interaction-analyzer.ts @@ -9,17 +9,12 @@ * and task system in sync with actual communications. */ -import Anthropic from '@anthropic-ai/sdk'; import { createLogger } from '../../logger.js'; import { getPool } from '../../db/client.js'; -import { ModelConfig } from '../../config/models.js'; +import { isLLMConfigured, complete } from '../../utils/llm.js'; const logger = createLogger('interaction-analyzer'); -// Initialize Anthropic client -const ANTHROPIC_API_KEY = process.env.ADDIE_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; -const anthropic = ANTHROPIC_API_KEY ? new Anthropic({ apiKey: ANTHROPIC_API_KEY }) : null; - /** * Context about the interaction being analyzed */ @@ -231,7 +226,7 @@ Important: export async function analyzeInteraction( context: InteractionContext ): Promise { - if (!anthropic) { + if (!isLLMConfigured()) { logger.warn('Anthropic not configured, skipping interaction analysis'); return null; } @@ -288,28 +283,17 @@ export async function analyzeInteraction( prompt += `\n## Pending Tasks\nNo pending tasks for this contact/organization.\n`; } - const startTime = Date.now(); - try { - const response = await anthropic.messages.create({ - model: ModelConfig.fast, - max_tokens: 1000, - messages: [ - { role: 'user', content: prompt }, - ], + const result = await complete({ + prompt, system: ANALYSIS_PROMPT, + maxTokens: 1000, + model: 'fast', + operationName: 'interaction-analysis', }); - const durationMs = Date.now() - startTime; - const textBlock = response.content.find(block => block.type === 'text'); - - if (!textBlock || textBlock.type !== 'text') { - logger.warn({ durationMs }, 'No text response from Claude for interaction analysis'); - return null; - } - // Parse the JSON response - const rawAnalysis = textBlock.text; + const rawAnalysis = result.text; let parsed: { learnings?: InteractionAnalysis['learnings']; taskActions?: Array<{ @@ -346,13 +330,13 @@ export async function analyzeInteraction( })); logger.info({ - durationMs, + durationMs: result.latencyMs, orgId, orgName, pendingTaskCount: pendingTasks.length, taskActionCount: taskActions.length, hasLearnings: !!parsed.learnings, - tokensUsed: response.usage.input_tokens + response.usage.output_tokens, + tokensUsed: (result.inputTokens || 0) + (result.outputTokens || 0), }, 'Interaction analysis completed'); return { @@ -361,8 +345,7 @@ export async function analyzeInteraction( rawAnalysis, }; } catch (error) { - const durationMs = Date.now() - startTime; - logger.error({ error, durationMs }, 'Error analyzing interaction'); + logger.error({ error }, 'Error analyzing interaction'); return null; } } diff --git a/server/src/addie/services/passive-note-extractor.ts b/server/src/addie/services/passive-note-extractor.ts index 9471032e88..e52fc6b70e 100644 --- a/server/src/addie/services/passive-note-extractor.ts +++ b/server/src/addie/services/passive-note-extractor.ts @@ -11,10 +11,9 @@ * - Conservative: only extracts notable/interesting statements */ -import Anthropic from '@anthropic-ai/sdk'; import { logger } from '../../logger.js'; import { InsightsDatabase, type InsightConfidence } from '../../db/insights-db.js'; -import { ModelConfig } from '../../config/models.js'; +import { isLLMConfigured, complete } from '../../utils/llm.js'; import { invalidateInsightsCache } from '../insights-cache.js'; const insightsDb = new InsightsDatabase(); @@ -96,27 +95,23 @@ Return ONLY valid JSON: * Extract a note from a message using Claude */ async function extractNote(item: QueueItem): Promise { - const apiKey = process.env.ADDIE_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; - if (!apiKey) { + if (!isLLMConfigured()) { logger.warn('Passive note extractor: No API key configured'); return null; } try { - const client = new Anthropic({ apiKey }); const prompt = buildNoteExtractionPrompt(item.messageText, item.channelName); - const response = await client.messages.create({ - model: ModelConfig.fast, - max_tokens: 150, - messages: [{ role: 'user', content: prompt }], + const result = await complete({ + prompt, + model: 'fast', + maxTokens: 150, + operationName: 'passive-note-extraction', }); - const content = response.content[0]; - if (content.type !== 'text') return null; - // Parse JSON response - const cleaned = content.text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); + const cleaned = result.text.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim(); const parsed = JSON.parse(cleaned); return parsed.note || null; diff --git a/server/src/routes/addie-admin.ts b/server/src/routes/addie-admin.ts index ff1a35b17b..504fd02aa7 100644 --- a/server/src/routes/addie-admin.ts +++ b/server/src/routes/addie-admin.ts @@ -1767,15 +1767,9 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be try { const { days, focus_on_negative, max_interactions } = req.body; - const anthropicApiKey = process.env.ANTHROPIC_API_KEY; - if (!anthropicApiKey) { - return res.status(500).json({ error: "ANTHROPIC_API_KEY not configured" }); - } - // Run analysis (this may take a while) const result = await analyzeInteractions({ db: addieDb, - anthropicApiKey, analysisType: "manual", days: days || 7, focusOnNegative: focus_on_negative || false, @@ -1833,11 +1827,6 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be return res.status(400).json({ error: "rule_ids array is required" }); } - const anthropicApiKey = process.env.ANTHROPIC_API_KEY; - if (!anthropicApiKey) { - return res.status(500).json({ error: "ANTHROPIC_API_KEY not configured" }); - } - // Get the proposed rules const proposedRules = await Promise.all( rule_ids.map((id: number) => addieDb.getRuleById(id)) @@ -1851,7 +1840,6 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be const result = await previewRuleChange({ db: addieDb, - anthropicApiKey, proposedRules: validRules, sampleSize: sample_size || 5, }); @@ -2715,18 +2703,10 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be // POST /api/admin/addie/synthesis/run - Trigger a new synthesis apiRouter.post("/synthesis/run", requireAuth, requireAdmin, async (req, res) => { try { - const apiKey = process.env.ANTHROPIC_API_KEY; - if (!apiKey) { - return res.status(500).json({ - error: "Configuration error", - message: "ANTHROPIC_API_KEY not configured", - }); - } - const { topic, maxSources, previewSampleSize } = req.body; const userEmail = req.user?.email || 'admin'; - const result = await synthesizeInsights(addieDb, apiKey, { + const result = await synthesizeInsights(addieDb, { topic, maxSources: maxSources || 50, previewSampleSize: previewSampleSize || 20, diff --git a/server/src/utils/llm.ts b/server/src/utils/llm.ts new file mode 100644 index 0000000000..d85de66f45 --- /dev/null +++ b/server/src/utils/llm.ts @@ -0,0 +1,195 @@ +/** + * Shared LLM utilities for simple completions and classifications + * + * Uses centralized model configuration and retry logic. + * For conversational AI with tools, use AddieClaudeClient instead. + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { ModelConfig } from '../config/models.js'; +import { withRetry } from './anthropic-retry.js'; +import { logger } from '../logger.js'; + +// Singleton client instance +let client: Anthropic | null = null; + +/** + * Get or create the shared Anthropic client + */ +function getClient(): Anthropic { + if (!client) { + const apiKey = process.env.ADDIE_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error('ANTHROPIC_API_KEY or ADDIE_ANTHROPIC_API_KEY not configured'); + } + client = new Anthropic({ apiKey }); + } + return client; +} + +/** + * Check if the API key is configured + */ +export function isLLMConfigured(): boolean { + return !!(process.env.ADDIE_ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY); +} + +/** + * Result from an LLM completion with metadata for tracking + */ +export interface LLMResult { + text: string; + model: string; + inputTokens?: number; + outputTokens?: number; + latencyMs: number; +} + +/** + * Model tier for selecting appropriate model + */ +export type ModelTier = 'fast' | 'primary' | 'precision'; + +/** + * Options for LLM completion + */ +export interface CompleteOptions { + /** The prompt to send (user message) */ + prompt: string; + /** Optional system prompt */ + system?: string; + /** Maximum tokens in response (default: 100) */ + maxTokens?: number; + /** Model tier to use (default: 'fast') */ + model?: ModelTier; + /** Operation name for logging (default: 'llm-complete') */ + operationName?: string; +} + +/** + * Simple LLM completion for short responses + * + * Returns full result with metadata for tracking/logging. + * Uses retry logic for transient errors. + * + * @example + * const result = await complete({ + * prompt: 'Select the best category: tech, sports, news', + * maxTokens: 20, + * model: 'fast', + * }); + * console.log(result.text); // "tech" + * + * @example + * // With system prompt + * const result = await complete({ + * system: 'You are a helpful assistant.', + * prompt: 'Summarize this document...', + * maxTokens: 500, + * model: 'primary', + * }); + */ +export async function complete(options: CompleteOptions): Promise { + const { + prompt, + system, + maxTokens = 100, + model = 'fast', + operationName = 'llm-complete', + } = options; + + const modelId = ModelConfig[model]; + const startTime = Date.now(); + + const response = await withRetry( + async () => { + return getClient().messages.create({ + model: modelId, + max_tokens: maxTokens, + ...(system && { system }), + messages: [{ role: 'user', content: prompt }], + }); + }, + { maxRetries: 3, initialDelayMs: 1000 }, + operationName + ); + + const latencyMs = Date.now() - startTime; + + if (!response.content || response.content.length === 0) { + throw new Error('Empty response from LLM'); + } + const content = response.content[0]; + + if (content.type !== 'text') { + throw new Error('Unexpected response type from LLM'); + } + + return { + text: content.text.trim(), + model: modelId, + inputTokens: response.usage?.input_tokens, + outputTokens: response.usage?.output_tokens, + latencyMs, + }; +} + +/** + * Options for yes/no classification + */ +export interface ClassifyOptions { + /** The prompt asking a yes/no question */ + prompt: string; + /** Operation name for logging (default: 'llm-classify') */ + operationName?: string; +} + +/** + * Result from a classification with metadata + */ +export interface ClassifyResult { + result: boolean; + model: string; + inputTokens?: number; + outputTokens?: number; + latencyMs: number; +} + +/** + * Quick yes/no classification using fast model + * + * Returns boolean result with metadata for tracking. + * Prompt should ask a yes/no question ending with "Respond with only YES or NO." + * + * @example + * const result = await classify({ + * prompt: 'Is this about advertising? ... Respond with only YES or NO.', + * operationName: 'relevance-check', + * }); + * if (result.result) { ... } + */ +export async function classify(options: ClassifyOptions): Promise { + const { prompt, operationName = 'llm-classify' } = options; + + const llmResult = await complete({ + prompt, + maxTokens: 10, + model: 'fast', + operationName, + }); + + return { + result: llmResult.text.toUpperCase() === 'YES', + model: llmResult.model, + inputTokens: llmResult.inputTokens, + outputTokens: llmResult.outputTokens, + latencyMs: llmResult.latencyMs, + }; +} + +/** + * Reset the client (for testing) + */ +export function resetClient(): void { + client = null; +}