From 15c8b50993b9cd3f27619d721988e9243abc4c33 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 12 Jan 2026 20:16:18 -0500 Subject: [PATCH] feat: add insight synthesis for Addie learning Add system to tag expert content and synthesize it into knowledge rules: - New insight_sources table for tagging emails, articles, conversations - Synthesis job that processes sources by topic using Claude - Side-by-side comparison UI showing current vs proposed rules - Config versioning to track which synthesis created which rules - Admin endpoints for managing insight sources and synthesis runs - Seed data with foundational AdCP philosophy content Co-Authored-By: Claude Opus 4.5 --- .changeset/busy-swans-judge.md | 2 + server/public/admin-addie.html | 745 +++++++++++++++++- server/src/addie/jobs/insight-synthesizer.ts | 493 ++++++++++++ server/src/addie/mcp/admin-tools.ts | 292 +++++++ server/src/db/addie-db.ts | 489 ++++++++++++ .../db/migrations/162_insight_synthesis.sql | 173 ++++ .../163_synthesis_config_versioning.sql | 51 ++ .../migrations/164_seed_insight_sources.sql | 213 +++++ server/src/routes/addie-admin.ts | 475 ++++++++++- 9 files changed, 2930 insertions(+), 3 deletions(-) create mode 100644 .changeset/busy-swans-judge.md create mode 100644 server/src/addie/jobs/insight-synthesizer.ts create mode 100644 server/src/db/migrations/162_insight_synthesis.sql create mode 100644 server/src/db/migrations/163_synthesis_config_versioning.sql create mode 100644 server/src/db/migrations/164_seed_insight_sources.sql diff --git a/.changeset/busy-swans-judge.md b/.changeset/busy-swans-judge.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/busy-swans-judge.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-addie.html b/server/public/admin-addie.html index 82b38238c4..4e1bf2cbc9 100644 --- a/server/public/admin-addie.html +++ b/server/public/admin-addie.html @@ -1237,6 +1237,22 @@

Addie Dashboard

Manage Addie's knowledge base, view interactions, and review pending actions.

+ +
+
+ Config Version: + - + (- rules) +
+
+ + - messages | + - avg rating + + +
+
+
@@ -1265,6 +1281,7 @@

Addie Dashboard

+ @@ -1321,6 +1338,158 @@

Loading...

+ +
+
+
+
+

Insight Synthesis

+

+ Tag expert content to synthesize into Addie's core knowledge +

+
+
+ + + +
+
+ + +
+

Pending by Topic

+
+
Loading...
+
+
+ + +
+

Pending Insights

+
+
Loading...
+
+
+ + +
+

Recent Synthesis Runs

+
+
Loading...
+
+
+
+
+ + + + + + + + + + + + +
@@ -1936,15 +2105,17 @@

Add Knowledge

// Load stats async function loadStats() { try { - const [knowledgeRes, threadsRes, queueRes] = await Promise.all([ + const [knowledgeRes, threadsRes, queueRes, configRes] = await Promise.all([ fetch('/api/admin/addie/knowledge/categories'), fetch('/api/admin/addie/threads/stats'), - fetch('/api/admin/addie/queue/stats') + fetch('/api/admin/addie/queue/stats'), + fetch('/api/admin/addie/config/current') ]); const knowledge = await knowledgeRes.json(); const threads = await threadsRes.json(); const queue = await queueRes.json(); + const config = await configRes.json(); const totalKnowledge = knowledge.categories?.reduce((sum, c) => sum + c.count, 0) || 0; document.getElementById('stat-knowledge').textContent = totalKnowledge; @@ -1952,11 +2123,70 @@

Add Knowledge

document.getElementById('stat-messages').textContent = threads.messages_30d || 0; document.getElementById('stat-flagged').textContent = threads.flagged_threads || 0; document.getElementById('stat-pending').textContent = queue.pending || 0; + + // Update config version banner + if (config.config_version) { + const cv = config.config_version; + document.getElementById('config-version-id').textContent = `v${cv.version_id}`; + document.getElementById('config-rule-count').textContent = cv.rule_count || 0; + document.getElementById('config-message-count').textContent = cv.message_count || 0; + document.getElementById('config-rating').textContent = cv.avg_rating + ? `${parseFloat(cv.avg_rating).toFixed(1)}/5` + : 'N/A'; + } } catch (error) { console.error('Error loading stats:', error); } } + // Config version history modal + async function showConfigHistory() { + const modal = document.getElementById('config-history-modal'); + modal.style.display = 'flex'; + + try { + const res = await fetch('/api/admin/addie/config/history?limit=20'); + const data = await res.json(); + + const listEl = document.getElementById('config-history-list'); + if (!data.versions || data.versions.length === 0) { + listEl.innerHTML = 'No config versions found'; + return; + } + + listEl.innerHTML = data.versions.map(v => ` + + + v${v.version_id} +
+ ${v.config_hash?.substring(0, 8) || '-'} + + + ${new Date(v.created_at).toLocaleDateString()}
+ ${new Date(v.created_at).toLocaleTimeString()} + + ${v.rule_count || 0} + ${v.message_count || 0} + + ${v.avg_rating ? `${parseFloat(v.avg_rating).toFixed(1)}` : '-'} + ${v.positive_feedback || v.negative_feedback ? `
+${v.positive_feedback || 0}/-${v.negative_feedback || 0}` : ''} + + + ${v.source_synthesis_run_ids?.length ? `Synthesis #${v.source_synthesis_run_ids.join(', #')}` : 'Manual'} + + + `).join(''); + } catch (error) { + console.error('Error loading config history:', error); + document.getElementById('config-history-list').innerHTML = + 'Error loading history'; + } + } + + function closeConfigHistoryModal() { + document.getElementById('config-history-modal').style.display = 'none'; + } + // Load unified knowledge list (docs, curated resources, slack messages) async function loadKnowledge() { const sourceType = document.getElementById('source-type-filter').value; @@ -3987,10 +4217,521 @@

Pattern Identified

} } + // ========================================================================= + // INSIGHTS FUNCTIONS + // ========================================================================= + + let currentSynthesisRun = null; + + async function loadInsights() { + try { + const response = await fetch('/api/admin/addie/insights'); + const data = await response.json(); + + // Render topic stats + const topicStats = document.getElementById('topic-stats'); + if (data.topics && data.topics.length > 0) { + topicStats.innerHTML = data.topics.map(topic => ` +
+
${data.sources.filter(s => (s.topic || 'uncategorized') === topic).length}
+
${escapeHtml(topic)}
+
+ `).join('') + ` +
+
${data.pendingCount}
+
Total Pending
+
+ `; + } else { + topicStats.innerHTML = ` +
+
0
+
No pending insights
+
+ `; + } + + // Render insights list + const list = document.getElementById('insights-list'); + if (!data.sources || data.sources.length === 0) { + list.innerHTML = '

No pending insights

Use "Add Insight" to tag expert content for synthesis.

'; + } else { + list.innerHTML = data.sources.map(s => ` +
+
+
+ ${s.topic ? `${escapeHtml(s.topic)}` : 'uncategorized'} + ${s.author_name ? `by ${escapeHtml(s.author_name)}` : ''} +
+
+ ${new Date(s.tagged_at).toLocaleDateString()} + +
+
+
${escapeHtml(s.excerpt || s.content.substring(0, 300))}
+ ${s.notes ? `
Note: ${escapeHtml(s.notes)}
` : ''} +
+ `).join(''); + } + + // Load synthesis runs + await loadSynthesisRuns(); + } catch (error) { + console.error('Error loading insights:', error); + document.getElementById('insights-list').innerHTML = '

Error loading insights

'; + } + } + + async function loadSynthesisRuns() { + try { + const response = await fetch('/api/admin/addie/synthesis?limit=10'); + const data = await response.json(); + + const list = document.getElementById('synthesis-runs-list'); + if (!data.runs || data.runs.length === 0) { + list.innerHTML = '

No synthesis runs yet

Click "Run Synthesis" to process pending insights.

'; + return; + } + + list.innerHTML = data.runs.map(run => { + const statusColors = { + draft: 'var(--color-warning)', + approved: 'var(--color-success)', + applied: 'var(--color-brand)', + rejected: 'var(--color-danger)' + }; + return ` +
+
+
+ ${run.status} + ${run.sources_count} sources → ${run.proposed_rules?.length || 0} rules +
+ ${new Date(run.created_at).toLocaleString()} +
+ ${run.preview_summary ? `
${escapeHtml(run.preview_summary)}
` : ''} + ${run.topics_included?.length > 0 ? `
Topics: ${run.topics_included.map(t => `${escapeHtml(t)}`).join('')}
` : ''} +
+ `; + }).join(''); + } catch (error) { + console.error('Error loading synthesis runs:', error); + } + } + + function openAddInsightModal() { + document.getElementById('add-insight-modal').style.display = 'flex'; + document.getElementById('add-insight-form').reset(); + } + + function closeInsightModal() { + document.getElementById('add-insight-modal').style.display = 'none'; + } + + // Perspective tagging modal + async function openPerspectiveTagModal() { + const modal = document.getElementById('perspective-tag-modal'); + modal.style.display = 'flex'; + document.getElementById('perspective-topic').value = ''; + + const listEl = document.getElementById('perspectives-list'); + listEl.innerHTML = '
Loading perspectives...
'; + + try { + const response = await fetch('/api/admin/addie/insights/perspectives'); + const data = await response.json(); + + if (!data.perspectives || data.perspectives.length === 0) { + listEl.innerHTML = '
No published perspectives found
'; + return; + } + + listEl.innerHTML = data.perspectives.map(p => ` +
+
+
+
${escapeHtml(p.title)}
+ ${p.author_name ? `
by ${escapeHtml(p.author_name)}
` : ''} + ${p.category ? `${escapeHtml(p.category)}` : ''} + ${p.excerpt ? `
${escapeHtml(p.excerpt.substring(0, 150))}...
` : ''} +
+
+ ${p.already_tagged + ? 'Already tagged' + : `` + } +
+
+
+ `).join(''); + } catch (error) { + console.error('Error loading perspectives:', error); + listEl.innerHTML = '
Error loading perspectives
'; + } + } + + function closePerspectiveTagModal() { + document.getElementById('perspective-tag-modal').style.display = 'none'; + } + + async function tagPerspective(perspectiveId, title) { + const topicOverride = document.getElementById('perspective-topic').value.trim(); + + try { + const response = await fetch('/api/admin/addie/insights/from-perspective', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + perspectiveId, + topic: topicOverride || undefined + }) + }); + + if (response.ok) { + alert(`Tagged: ${title}`); + closePerspectiveTagModal(); + loadInsights(); + } else { + const error = await response.json(); + alert('Error: ' + (error.message || 'Failed to tag perspective')); + } + } catch (error) { + console.error('Error tagging perspective:', error); + alert('Error tagging perspective'); + } + } + + document.getElementById('add-insight-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + const data = { + source_type: 'external', + content: formData.get('content'), + topic: formData.get('topic') || undefined, + author_name: formData.get('author_name') || undefined, + author_context: formData.get('author_context') || undefined, + notes: formData.get('notes') || undefined + }; + + try { + const response = await fetch('/api/admin/addie/insights', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (response.ok) { + closeInsightModal(); + loadInsights(); + alert('Insight added successfully!'); + } else { + const error = await response.json(); + alert('Error: ' + (error.message || 'Failed to add insight')); + } + } catch (error) { + console.error('Error adding insight:', error); + alert('Error adding insight'); + } + }); + + async function archiveInsight(id) { + if (!confirm('Archive this insight? It will no longer be included in synthesis.')) return; + + try { + const response = await fetch(`/api/admin/addie/insights/${id}`, { method: 'DELETE' }); + if (response.ok) { + loadInsights(); + } else { + alert('Error archiving insight'); + } + } catch (error) { + console.error('Error archiving insight:', error); + alert('Error archiving insight'); + } + } + + async function runInsightSynthesis() { + const btn = document.getElementById('run-synthesis-btn'); + btn.disabled = true; + btn.textContent = 'Running...'; + + try { + const response = await fetch('/api/admin/addie/synthesis/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const data = await response.json(); + + if (response.ok) { + loadInsights(); + viewSynthesisRun(data.run.id); + } else { + alert('Error: ' + (data.message || 'Synthesis failed')); + } + } catch (error) { + console.error('Error running synthesis:', error); + alert('Error running synthesis'); + } finally { + btn.disabled = false; + btn.textContent = 'Run Synthesis'; + } + } + + async function viewSynthesisRun(id) { + try { + // Fetch synthesis run AND current rules in parallel + const [synthesisRes, rulesRes, configRes] = await Promise.all([ + fetch(`/api/admin/addie/synthesis/${id}`), + fetch('/api/admin/addie/rules?active_only=true'), + fetch('/api/admin/addie/config/current') + ]); + + const data = await synthesisRes.json(); + const rulesData = await rulesRes.json(); + const configData = await configRes.json(); + + currentSynthesisRun = data.run; + const currentRules = rulesData.rules || []; + const currentKnowledgeRules = currentRules.filter(r => r.rule_type === 'knowledge'); + const configVersion = configData.config_version; + + const content = document.getElementById('synthesis-review-content'); + const run = data.run; + + // Summary stats + let html = ` +
+
+
+
${run.sources_count}
+
Sources
+
+
+
${run.proposed_rules?.length || 0}
+
Rules Proposed
+
+
+
${run.preview_results?.summary?.likely_improved || 0}
+
Likely Improved
+
+
+
${run.preview_results?.summary?.likely_worse || 0}
+
Potentially Worse
+
+
+
+ `; + + if (run.preview_summary) { + html += `
+ Preview Summary: ${escapeHtml(run.preview_summary)} +
`; + } + + // SIDE BY SIDE COMPARISON + html += ` +
+ +
+

+ + Current Knowledge Rules + + (v${configVersion?.version_id || '?'}) + +

+
+ ${currentKnowledgeRules.length === 0 + ? '

No knowledge rules yet

' + : currentKnowledgeRules.map(rule => ` +
+
${escapeHtml(rule.name)}
+
${escapeHtml(rule.content.substring(0, 300))}${rule.content.length > 300 ? '...' : ''}
+
+ `).join('') + } +
+
+ ${currentKnowledgeRules.length} knowledge rule${currentKnowledgeRules.length !== 1 ? 's' : ''} | + ${currentRules.length} total rules +
+
+ + +
+

+ + After This Synthesis +

+
+ ${(run.proposed_rules || []).length === 0 + ? '

No new rules proposed

' + : ` +
+ + ${run.proposed_rules.length} NEW RULE${run.proposed_rules.length !== 1 ? 'S' : ''} +
+ ${run.proposed_rules.map(rule => ` +
+
+ + ${escapeHtml(rule.name)} + ${Math.round(rule.confidence * 100)}% +
+
${escapeHtml(rule.content.substring(0, 300))}${rule.content.length > 300 ? '...' : ''}
+
+ `).join('')} + ` + } +
+
+ ${currentKnowledgeRules.length + (run.proposed_rules?.length || 0)} knowledge rules after | + ${currentRules.length + (run.proposed_rules?.length || 0)} total rules +
+
+
+ `; + + // Source Materials (collapsible) + if (data.sources && data.sources.length > 0) { + html += ` +
+ + Source Materials (${data.sources.length}) + +
+ `; + for (const source of data.sources.slice(0, 5)) { + html += ` +
+ ${source.topic ? `${escapeHtml(source.topic)}` : ''} + ${source.author_name ? `by ${escapeHtml(source.author_name)}` : ''} +
${escapeHtml(source.excerpt || source.content.substring(0, 200))}
+
+ `; + } + if (data.sources.length > 5) { + html += `

...and ${data.sources.length - 5} more sources

`; + } + html += `
`; + } + + content.innerHTML = html; + + // Update action buttons based on status + const approveBtn = document.getElementById('approve-synthesis-btn'); + const rejectBtn = document.getElementById('reject-synthesis-btn'); + const applyBtn = document.getElementById('apply-synthesis-btn'); + + if (run.status === 'draft') { + approveBtn.style.display = ''; + rejectBtn.style.display = ''; + applyBtn.style.display = 'none'; + } else if (run.status === 'approved') { + approveBtn.style.display = 'none'; + rejectBtn.style.display = 'none'; + applyBtn.style.display = ''; + } else { + approveBtn.style.display = 'none'; + rejectBtn.style.display = 'none'; + applyBtn.style.display = 'none'; + } + + document.getElementById('synthesis-review-modal').style.display = 'flex'; + } catch (error) { + console.error('Error loading synthesis run:', error); + alert('Error loading synthesis run'); + } + } + + function closeSynthesisModal() { + document.getElementById('synthesis-review-modal').style.display = 'none'; + currentSynthesisRun = null; + } + + async function approveSynthesis() { + if (!currentSynthesisRun) return; + + try { + const response = await fetch(`/api/admin/addie/synthesis/${currentSynthesisRun.id}/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'approved' }) + }); + + if (response.ok) { + viewSynthesisRun(currentSynthesisRun.id); + loadInsights(); + } else { + alert('Error approving synthesis'); + } + } catch (error) { + console.error('Error approving synthesis:', error); + alert('Error approving synthesis'); + } + } + + async function rejectSynthesis() { + if (!currentSynthesisRun) return; + const notes = prompt('Reason for rejection (optional):'); + + try { + const response = await fetch(`/api/admin/addie/synthesis/${currentSynthesisRun.id}/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'rejected', notes }) + }); + + if (response.ok) { + closeSynthesisModal(); + loadInsights(); + } else { + alert('Error rejecting synthesis'); + } + } catch (error) { + console.error('Error rejecting synthesis:', error); + alert('Error rejecting synthesis'); + } + } + + async function applySynthesis() { + if (!currentSynthesisRun) return; + if (!confirm('Apply these rules to Addie? This will create new knowledge rules.')) return; + + try { + const response = await fetch(`/api/admin/addie/synthesis/${currentSynthesisRun.id}/apply`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (response.ok) { + const data = await response.json(); + const versionMsg = data.configVersionId ? ` Addie is now on config version v${data.configVersionId}.` : ''; + alert(`Success! ${data.rules.length} rules created.${versionMsg}`); + closeSynthesisModal(); + loadInsights(); + loadRules(); + loadStats(); // Refresh config version banner + } else { + const error = await response.json(); + alert('Error: ' + (error.message || 'Failed to apply synthesis')); + } + } catch (error) { + console.error('Error applying synthesis:', error); + alert('Error applying synthesis'); + } + } + + function filterInsightsByTopic(topic) { + // For now, just scroll to the insights list - could add filtering later + document.getElementById('insights-list-container').scrollIntoView({ behavior: 'smooth' }); + } + // Initial load loadStats(); loadRules(); loadSuggestions(); + loadInsights(); loadKnowledge(); loadThreads(); loadQueue(); diff --git a/server/src/addie/jobs/insight-synthesizer.ts b/server/src/addie/jobs/insight-synthesizer.ts new file mode 100644 index 0000000000..5259d4b0bc --- /dev/null +++ b/server/src/addie/jobs/insight-synthesizer.ts @@ -0,0 +1,493 @@ +/** + * Insight Synthesizer + * + * Takes tagged insight sources and synthesizes them into compact, coherent rules + * for Addie's system prompt. Uses Claude to distill multiple sources into + * focused knowledge rules. + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { createLogger } from '../../logger.js'; +import { + AddieDatabase, + type InsightSource, + type ProposedRule, + type SynthesisRun, + type SynthesisPreviewResults, + type SynthesisPreviewPrediction, + type AddieRule, + type RuleType, +} from '../../db/addie-db.js'; +import { ModelConfig } from '../../config/models.js'; +import { + getOrCreateConfigVersion, + invalidateConfigCache, + type RuleSnapshot, +} from '../config-version.js'; + +const logger = createLogger('insight-synthesizer'); + +// Synthesized rules get priority between core identity rules (200) and behavior rules (150) +const SYNTHESIZED_RULE_PRIORITY = 175; + +// ============== Types ============== + +export interface SynthesisOptions { + topic?: string; // Synthesize specific topic, or all pending if undefined + maxSources?: number; // Limit sources per run (default: 50) + previewSampleSize?: number; // Historical interactions to test against (default: 20) + createdBy?: string; // Who triggered the synthesis +} + +export interface SynthesisResult { + run: SynthesisRun; + proposedRules: ProposedRule[]; + preview: SynthesisPreviewResults | null; + gaps: string[]; // Topics that need more source material +} + +interface ClaudeSynthesisResponse { + rules: Array<{ + rule_type: RuleType; + name: string; + content: string; + source_ids: number[]; + confidence: number; + }>; + gaps: string[]; +} + +// ============== Prompts ============== + +const SYNTHESIS_SYSTEM_PROMPT = `You are distilling expert insights into concise operating rules for an AI assistant named Addie. + +Addie is the AI assistant for AgenticAdvertising.org, helping the ad tech industry understand and adopt AdCP (Ad Context Protocol) and agentic advertising. + +Your task: +1. Read the tagged source materials (conversations, articles, emails from experts) +2. Extract the core beliefs, frameworks, and perspectives +3. Synthesize into compact, coherent rules that Addie can reason with +4. DO NOT attribute - these become Addie's own beliefs, not "Ben says..." or "According to..." +5. Focus on actionable guidance and clear positions, not just facts + +Rules should: +- Be 2-4 paragraphs max +- State clear positions (not wishy-washy) +- Include concrete examples where helpful +- Be written in second person ("You believe...", "When asked about X, explain...") + +Output JSON matching this schema: +{ + "rules": [ + { + "rule_type": "knowledge", + "name": "Short descriptive name (e.g., 'AdCP Adoption Philosophy')", + "content": "The synthesized rule content", + "source_ids": [1, 3, 5], + "confidence": 0.9 + } + ], + "gaps": [ + "Topics that need more source material before synthesis" + ] +} + +Keep rules COMPACT. Better to have 3 focused rules than 1 sprawling one. +Prefer fewer, higher-quality rules over many mediocre ones.`; + +// ============== Main Functions ============== + +/** + * Synthesize pending insight sources into rules + */ +export async function synthesizeInsights( + db: AddieDatabase, + anthropicApiKey: string, + options: SynthesisOptions = {} +): Promise { + const { + topic, + maxSources = 50, + previewSampleSize = 20, + createdBy, + } = options; + + const startTime = Date.now(); + + // 1. Gather pending sources + const sources = await db.getPendingInsightSources(topic, maxSources); + + if (sources.length === 0) { + throw new Error(`No pending insight sources found${topic ? ` for topic: ${topic}` : ''}`); + } + + logger.info({ sourceCount: sources.length, topic }, 'Starting insight synthesis'); + + // 2. Group by topic for coherent synthesis + const byTopic = groupByTopic(sources); + 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 + ); + + allProposedRules.push(...rules); + allGaps.push(...gaps); + totalTokens += tokensUsed; + } + + const durationMs = Date.now() - startTime; + + // 4. Create synthesis run record + const run = await db.createSynthesisRun({ + topic, + source_ids: sources.map(s => s.id), + topics_included: topicsIncluded, + proposed_rules: allProposedRules, + created_by: createdBy, + model_used: ModelConfig.primary, + tokens_used: totalTokens, + synthesis_duration_ms: durationMs, + }); + + // 5. Run preview against historical interactions + let preview: SynthesisPreviewResults | null = null; + if (previewSampleSize > 0 && allProposedRules.length > 0) { + try { + preview = await previewSynthesizedRules( + db, + anthropic, + allProposedRules, + previewSampleSize + ); + + const previewSummary = formatPreviewSummary(preview); + await db.updateSynthesisPreview(run.id, preview, previewSummary); + } catch (error) { + logger.warn({ error, runId: run.id }, 'Failed to generate preview'); + } + } + + logger.info({ + runId: run.id, + rulesProposed: allProposedRules.length, + sourcesProcessed: sources.length, + durationMs, + tokensUsed: totalTokens, + }, 'Insight synthesis completed'); + + return { + run: { ...run, preview_results: preview }, + proposedRules: allProposedRules, + preview, + gaps: allGaps, + }; +} + +/** + * Apply an approved synthesis run - create rules and mark sources as synthesized + * Also captures the resulting config version for tracking + */ +export async function applySynthesis( + db: AddieDatabase, + runId: number +): Promise<{ rules: AddieRule[]; run: SynthesisRun; configVersionId: number | null }> { + const run = await db.getSynthesisRun(runId); + if (!run) { + throw new Error(`Synthesis run not found: ${runId}`); + } + + if (run.status !== 'approved') { + throw new Error(`Synthesis run must be approved before applying. Current status: ${run.status}`); + } + + const createdRules: AddieRule[] = []; + + // Create each proposed rule + for (const proposed of run.proposed_rules) { + const rule = await db.createRule({ + rule_type: proposed.rule_type, + name: proposed.name, + content: proposed.content, + priority: SYNTHESIZED_RULE_PRIORITY, + created_by: 'synthesis', + }); + createdRules.push(rule); + } + + const ruleIds = createdRules.map(r => r.id); + + // Mark sources as synthesized + await db.markSourcesSynthesized(run.source_ids, runId); + + // Update run with applied rule IDs + const updatedRun = await db.applySynthesisRun(runId, ruleIds); + + // Invalidate config cache so next request gets new version + invalidateConfigCache(); + + // Get the new config version after rules are applied + let configVersionId: number | null = null; + try { + const activeRules = await db.getActiveRules(); + const ruleSnapshots: RuleSnapshot[] = activeRules.map(r => ({ + id: r.id, + rule_type: r.rule_type, + name: r.name, + content: r.content, + priority: r.priority, + })); + const ruleIdList = activeRules.map(r => r.id); + + const configVersion = await getOrCreateConfigVersion(ruleIdList, ruleSnapshots); + configVersionId = configVersion.version_id; + + // Link synthesis run to resulting config version + await db.linkSynthesisToConfigVersion(runId, configVersionId); + + logger.info({ + runId, + configVersionId, + configHash: configVersion.config_hash, + }, 'Synthesis linked to config version'); + } catch (error) { + // Don't fail the whole operation if config versioning fails + logger.warn({ error, runId }, 'Failed to link synthesis to config version'); + } + + logger.info({ + runId, + rulesCreated: ruleIds.length, + sourcesMarked: run.source_ids.length, + configVersionId, + }, 'Synthesis applied'); + + return { + rules: createdRules, + run: updatedRun!, + configVersionId, + }; +} + +// ============== Helper Functions ============== + +/** + * Group sources by topic + */ +function groupByTopic(sources: InsightSource[]): Record { + const groups: Record = {}; + + for (const source of sources) { + const topic = source.topic || 'general'; + if (!groups[topic]) { + groups[topic] = []; + } + groups[topic].push(source); + } + + return groups; +} + +/** + * 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, + system: SYNTHESIS_SYSTEM_PROMPT, + messages: [{ role: 'user', content: prompt }], + }); + + const tokensUsed = response.usage.input_tokens + response.usage.output_tokens; + const responseText = response.content[0].type === 'text' ? response.content[0].text : ''; + + // Parse response + let parsed: ClaudeSynthesisResponse; + try { + const jsonMatch = responseText.match(/```json\n?([\s\S]*?)\n?```/) || + responseText.match(/\{[\s\S]*\}/); + const jsonStr = jsonMatch ? (jsonMatch[1] || jsonMatch[0]) : responseText; + parsed = JSON.parse(jsonStr); + } catch (error) { + logger.error({ error, topic, response: responseText }, 'Failed to parse synthesis response'); + throw new Error(`Failed to parse synthesis response for topic: ${topic}`); + } + + return { + rules: parsed.rules, + gaps: parsed.gaps || [], + tokensUsed, + }; +} + +/** + * Build the synthesis prompt for a topic + */ +function buildSynthesisPrompt(topic: string, sources: InsightSource[]): string { + const sourcesFormatted = sources.map((s, i) => { + let header = `### Source ${i + 1}`; + if (s.author_name) { + header += ` (${s.author_name}`; + if (s.author_context) { + header += ` - ${s.author_context}`; + } + header += ')'; + } + header += `\nType: ${s.source_type}`; + if (s.notes) { + header += `\nContext: ${s.notes}`; + } + + return `${header}\n\n${s.content}`; + }).join('\n\n---\n\n'); + + return `## Topic: ${topic} + +Synthesize the following ${sources.length} source(s) into coherent knowledge rules for Addie. + +${sourcesFormatted} + +--- + +Now synthesize these sources into 1-3 focused knowledge rules. Remember: +- These become Addie's own beliefs (no attribution) +- Be concise but substantive +- Take clear positions +- Include the source IDs that informed each rule`; +} + +/** + * Preview how synthesized rules would affect historical interactions + */ +async function previewSynthesizedRules( + db: AddieDatabase, + anthropic: Anthropic, + proposedRules: ProposedRule[], + sampleSize: number +): Promise { + // Get historical interactions with ratings + const interactions = await db.getInteractionsForAnalysis({ + days: 30, + limit: sampleSize, + }); + + if (interactions.length === 0) { + return { + predictions: [], + summary: { + likely_improved: 0, + likely_unchanged: 0, + likely_worse: 0, + avg_improvement: 0, + }, + }; + } + + // Format proposed rules for comparison + const proposedRulesText = proposedRules + .map(r => `## ${r.name}\n${r.content}`) + .join('\n\n'); + + const predictions: SynthesisPreviewPrediction[] = []; + + // Evaluate a sample of interactions + const sampled = interactions.slice(0, Math.min(10, sampleSize)); + + 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. + +## New Rules Being Added + +${proposedRulesText} + +## Original Interaction + +User: ${interaction.input_text.substring(0, 500)} + +Original Response: ${interaction.output_text.substring(0, 1000)} + +${interaction.rating ? `Original Rating: ${interaction.rating}/5` : ''} + +## Task + +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 +}` + }], + }); + + const responseText = response.content[0].type === 'text' ? response.content[0].text : ''; + const jsonMatch = responseText.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + predictions.push({ + interaction_id: interaction.id, + original_response: interaction.output_text.substring(0, 200), + predicted_change: parsed.predicted_change, + improvement_score: parsed.improvement_score, + confidence: parsed.confidence, + }); + } + } catch (error) { + logger.warn({ error, interactionId: interaction.id }, 'Failed to evaluate interaction'); + } + } + + // Calculate summary + const summary = { + likely_improved: predictions.filter(p => p.improvement_score > 0.2).length, + likely_unchanged: predictions.filter(p => Math.abs(p.improvement_score) <= 0.2).length, + likely_worse: predictions.filter(p => p.improvement_score < -0.2).length, + avg_improvement: predictions.length > 0 + ? predictions.reduce((sum, p) => sum + p.improvement_score, 0) / predictions.length + : 0, + }; + + return { predictions, summary }; +} + +/** + * Format preview summary for display + */ +function formatPreviewSummary(preview: SynthesisPreviewResults): string { + const { summary, predictions } = preview; + const total = predictions.length; + + if (total === 0) { + return 'No historical interactions available for preview.'; + } + + const avgScore = (summary.avg_improvement * 100).toFixed(0); + const sign = summary.avg_improvement >= 0 ? '+' : ''; + + return `Tested against ${total} historical interactions: +- ${summary.likely_improved} likely improved +- ${summary.likely_unchanged} unchanged +- ${summary.likely_worse} potentially worse +- Average impact: ${sign}${avgScore}%`; +} diff --git a/server/src/addie/mcp/admin-tools.ts b/server/src/addie/mcp/admin-tools.ts index f57f763a5e..f52b372da7 100644 --- a/server/src/addie/mcp/admin-tools.ts +++ b/server/src/addie/mcp/admin-tools.ts @@ -1705,6 +1705,103 @@ Returns: Search counts, impressions, clicks, introduction requests/sent, top sea }, }, }, + + // ============================================ + // INSIGHT SYNTHESIS TOOLS + // ============================================ + { + name: 'tag_insight', + description: `Tag valuable content as a core insight source for Addie's knowledge synthesis. + +USE THIS when an admin: +- Shares expert content that should inform Addie's thinking (emails, articles, Q&As) +- Identifies a conversation excerpt worth preserving +- Wants to add external perspectives to Addie's core knowledge + +Tagged content gets periodically synthesized into compact rules that become part of Addie's reasoning. +The content is NOT attributed - it becomes Addie's own beliefs. + +Example: Admin pastes an expert's Q&A about AdCP adoption → tag it with topic "adoption-philosophy" → synthesis job distills it into a knowledge rule.`, + usage_hints: 'Use when admin shares expert insights, valuable Q&As, or content that should shape Addie\'s thinking.', + input_schema: { + type: 'object' as const, + properties: { + content: { + type: 'string', + description: 'The content to tag as a core insight (full text)', + }, + topic: { + type: 'string', + description: 'Topic category for grouping (e.g., "adoption-philosophy", "platform-strategy", "trust", "allocation"). Optional but helps with synthesis.', + }, + author_name: { + type: 'string', + description: 'Who said/wrote this (for context during synthesis, not attribution). Optional.', + }, + author_context: { + type: 'string', + description: 'Role/expertise context (e.g., "Triton Digital, audio expert"). Optional.', + }, + notes: { + type: 'string', + description: 'Additional context about why this is valuable. Optional.', + }, + }, + required: ['content'], + }, + }, + { + name: 'list_pending_insights', + description: `List insight sources that are pending synthesis. + +USE THIS when admin asks: +- "What insights are waiting to be synthesized?" +- "Show me tagged content" +- "What's in the synthesis queue?" + +Returns pending insight sources grouped by topic, ready for synthesis.`, + usage_hints: 'Use to see what content is queued for synthesis into Addie\'s core knowledge.', + input_schema: { + type: 'object' as const, + properties: { + topic: { + type: 'string', + description: 'Filter by topic (optional)', + }, + limit: { + type: 'number', + description: 'Maximum results (default: 20)', + }, + }, + }, + }, + { + name: 'run_synthesis', + description: `Trigger insight synthesis to convert tagged sources into Addie's knowledge rules. + +USE THIS when admin says: +- "Synthesize the pending insights" +- "Update Addie's knowledge" +- "Process the tagged content" + +This will: +1. Gather pending insight sources (optionally filtered by topic) +2. Use Claude to synthesize them into compact, coherent knowledge rules +3. Preview how the rules would affect historical interactions +4. Return proposed rules for admin approval + +After synthesis, admin must approve before rules are applied.`, + usage_hints: 'Use to process tagged insights into knowledge rules. Requires admin approval before applying.', + input_schema: { + type: 'object' as const, + properties: { + topic: { + type: 'string', + description: 'Synthesize only a specific topic (optional - defaults to all pending)', + }, + }, + }, + }, ]; /** @@ -6386,5 +6483,200 @@ Use add_committee_leader to assign a leader.`; } }); + // ============================================ + // INSIGHT SYNTHESIS HANDLERS + // ============================================ + + handlers.set('tag_insight', async (input) => { + const adminError = requireAdminFromContext(); + if (adminError) return adminError; + + try { + const content = input.content as string; + if (!content || content.trim().length === 0) { + return '❌ Content is required. Please provide the text to tag as an insight.'; + } + + const topic = (input.topic as string) || undefined; + const authorName = (input.author_name as string) || undefined; + const authorContext = (input.author_context as string) || undefined; + const notes = (input.notes as string) || undefined; + + const taggedBy = memberContext?.workos_user?.email || memberContext?.slack_user?.email || 'admin'; + + // Import AddieDatabase here to avoid circular deps + const { AddieDatabase } = await import('../../db/addie-db.js'); + const addieDb = new AddieDatabase(); + + const source = await addieDb.createInsightSource({ + source_type: 'external', + content: content.trim(), + topic, + author_name: authorName, + author_context: authorContext, + tagged_by: taggedBy, + notes, + }); + + logger.info({ + sourceId: source.id, + topic, + taggedBy, + contentLength: content.length, + }, 'Insight source tagged via Addie tool'); + + let response = `✅ **Insight tagged successfully!**\n\n`; + response += `- **ID**: ${source.id}\n`; + if (topic) response += `- **Topic**: ${topic}\n`; + if (authorName) response += `- **Author**: ${authorName}`; + if (authorContext) response += ` (${authorContext})`; + if (authorName) response += `\n`; + response += `- **Content**: ${source.excerpt}\n`; + response += `- **Status**: Pending synthesis\n\n`; + response += `This content will be synthesized into Addie's core knowledge during the next synthesis run. `; + response += `Use \`run_synthesis\` to process pending insights, or wait for the scheduled run.`; + + return response; + } catch (error) { + logger.error({ error }, 'Error tagging insight'); + return '❌ Failed to tag insight. Please try again.'; + } + }); + + handlers.set('list_pending_insights', async (input) => { + const adminError = requireAdminFromContext(); + if (adminError) return adminError; + + try { + const topic = (input.topic as string) || undefined; + const limit = Math.min((input.limit as number) || 20, 50); + + const { AddieDatabase } = await import('../../db/addie-db.js'); + const addieDb = new AddieDatabase(); + + const sources = await addieDb.getPendingInsightSources(topic, limit); + const byTopic = await addieDb.getInsightSourcesByTopic(); + const pendingCount = await addieDb.countPendingInsights(); + + if (sources.length === 0) { + return '📭 No pending insights found. Use `tag_insight` to add content for synthesis.'; + } + + let response = `## Pending Insights (${pendingCount} total)\n\n`; + + // Summary by topic + if (byTopic.length > 0) { + response += `### By Topic\n`; + for (const t of byTopic) { + response += `- **${t.topic}**: ${t.source_count} source(s)\n`; + } + response += `\n`; + } + + // List sources + response += `### Recent Sources\n`; + for (const source of sources) { + const date = new Date(source.tagged_at).toLocaleDateString(); + response += `\n**${source.id}.** `; + if (source.topic) response += `[${source.topic}] `; + if (source.author_name) response += `*${source.author_name}* - `; + response += `${source.excerpt}\n`; + response += ` Tagged by ${source.tagged_by} on ${date}\n`; + } + + response += `\n---\nUse \`run_synthesis\` to process these into knowledge rules.`; + + return response; + } catch (error) { + logger.error({ error }, 'Error listing pending insights'); + return '❌ Failed to list pending insights. Please try again.'; + } + }); + + handlers.set('run_synthesis', async (input) => { + const adminError = requireAdminFromContext(); + if (adminError) return adminError; + + 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'); + const addieDb = new AddieDatabase(); + + // Check if there are pending sources + const pendingCount = await addieDb.countPendingInsights(topic); + if (pendingCount === 0) { + return `📭 No pending insights${topic ? ` for topic "${topic}"` : ''}. Use \`tag_insight\` to add content first.`; + } + + const createdBy = memberContext?.workos_user?.email || memberContext?.slack_user?.email || 'admin'; + + logger.info({ + topic, + pendingCount, + createdBy, + }, 'Starting insight synthesis via Addie tool'); + + const result = await synthesizeInsights(addieDb, apiKey, { + topic, + maxSources: 50, + previewSampleSize: 20, + createdBy, + }); + + let response = `## Synthesis Complete\n\n`; + response += `**Run ID**: ${result.run.id}\n`; + response += `**Status**: ${result.run.status}\n`; + response += `**Sources processed**: ${result.run.sources_count}\n`; + response += `**Topics**: ${result.run.topics_included.join(', ') || 'general'}\n\n`; + + // Proposed rules + if (result.proposedRules.length > 0) { + response += `### Proposed Rules (${result.proposedRules.length})\n\n`; + for (const rule of result.proposedRules) { + response += `**${rule.name}** (confidence: ${(rule.confidence * 100).toFixed(0)}%)\n`; + response += `> ${rule.content.substring(0, 200)}${rule.content.length > 200 ? '...' : ''}\n\n`; + } + } + + // Preview results + if (result.preview) { + const { summary } = result.preview; + response += `### Impact Preview\n`; + response += `Tested against ${result.preview.predictions.length} historical interactions:\n`; + response += `- ✅ Likely improved: ${summary.likely_improved}\n`; + response += `- ➡️ Unchanged: ${summary.likely_unchanged}\n`; + response += `- ⚠️ Potentially worse: ${summary.likely_worse}\n`; + response += `- 📊 Average impact: ${(summary.avg_improvement * 100).toFixed(0)}%\n\n`; + } + + // Gaps + if (result.gaps.length > 0) { + response += `### Gaps Identified\n`; + response += `Topics that need more source material:\n`; + for (const gap of result.gaps) { + response += `- ${gap}\n`; + } + response += `\n`; + } + + response += `---\n`; + response += `**Next steps**: Review the proposed rules in the admin UI at \`/admin/addie\` and approve or reject the synthesis.\n`; + response += `Once approved, use the "Apply" button to add these rules to Addie's knowledge.`; + + return response; + } catch (error) { + logger.error({ error }, 'Error running synthesis'); + const message = error instanceof Error ? error.message : 'Unknown error'; + return `❌ Synthesis failed: ${message}`; + } + }); + return handlers; } diff --git a/server/src/db/addie-db.ts b/server/src/db/addie-db.ts index 16d9c0ea86..699bdcb642 100644 --- a/server/src/db/addie-db.ts +++ b/server/src/db/addie-db.ts @@ -370,6 +370,108 @@ export interface AddieInteractionWithRating extends AddieInteractionLog { experiment_group?: 'control' | 'variant'; } +// ============== Insight Synthesis Types ============== + +export type InsightSourceType = 'conversation' | 'perspective' | 'doc' | 'slack' | 'external'; +export type InsightSourceStatus = 'pending' | 'synthesized' | 'archived'; +export type SynthesisRunStatus = 'draft' | 'approved' | 'applied' | 'rejected'; + +export interface InsightSource { + id: number; + source_type: InsightSourceType; + source_ref: string | null; + content: string; + excerpt: string | null; + topic: string | null; + author_name: string | null; + author_context: string | null; + status: InsightSourceStatus; + synthesis_run_id: number | null; + resulting_rule_id: number | null; + tagged_by: string; + tagged_at: Date; + notes: string | null; +} + +export interface InsightSourceInput { + source_type: InsightSourceType; + source_ref?: string; + content: string; + topic?: string; + author_name?: string; + author_context?: string; + tagged_by: string; + notes?: string; +} + +export interface ProposedRule { + rule_type: RuleType; + name: string; + content: string; + source_ids: number[]; + confidence: number; +} + +export interface SynthesisPreviewPrediction { + interaction_id: string; + original_response: string; + predicted_change: string; + improvement_score: number; // -1 to 1 + confidence: number; +} + +export interface SynthesisPreviewSummary { + likely_improved: number; + likely_unchanged: number; + likely_worse: number; + avg_improvement: number; +} + +export interface SynthesisPreviewResults { + predictions: SynthesisPreviewPrediction[]; + summary: SynthesisPreviewSummary; +} + +export interface SynthesisRun { + id: number; + topic: string | null; + source_ids: number[]; + sources_count: number; + topics_included: string[]; + proposed_rules: ProposedRule[]; + preview_results: SynthesisPreviewResults | null; + preview_summary: string | null; + status: SynthesisRunStatus; + applied_rule_ids: number[] | null; + created_by: string | null; + created_at: Date; + reviewed_by: string | null; + reviewed_at: Date | null; + review_notes: string | null; + model_used: string | null; + tokens_used: number | null; + synthesis_duration_ms: number | null; +} + +export interface SynthesisRunInput { + topic?: string; + source_ids: number[]; + topics_included: string[]; + proposed_rules: ProposedRule[]; + created_by?: string; + model_used?: string; + tokens_used?: number; + synthesis_duration_ms?: number; +} + +export interface InsightSourcesByTopic { + topic: string; + source_count: number; + source_ids: number[]; + oldest_source: Date; + newest_source: Date; +} + /** * Database operations for Addie */ @@ -2714,6 +2816,393 @@ export class AddieDatabase { createdAt: row.created_at, }; } + + // ============== Insight Synthesis ============== + + /** + * Tag content as an insight source + */ + async createInsightSource(input: InsightSourceInput): Promise { + const result = await query( + `INSERT INTO addie_insight_sources + (source_type, source_ref, content, topic, author_name, author_context, tagged_by, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + input.source_type, + input.source_ref || null, + input.content, + input.topic || null, + input.author_name || null, + input.author_context || null, + input.tagged_by, + input.notes || null, + ] + ); + return result.rows[0]; + } + + /** + * Get insight source by ID + */ + async getInsightSource(id: number): Promise { + const result = await query( + `SELECT * FROM addie_insight_sources WHERE id = $1`, + [id] + ); + return result.rows[0] || null; + } + + /** + * Get all pending insight sources, optionally filtered by topic + */ + async getPendingInsightSources(topic?: string, limit?: number): Promise { + const params: unknown[] = []; + let sql = `SELECT * FROM addie_insight_sources WHERE status = 'pending'`; + + if (topic) { + params.push(topic); + sql += ` AND topic = $${params.length}`; + } + + sql += ` ORDER BY tagged_at DESC`; + + if (limit) { + params.push(limit); + sql += ` LIMIT $${params.length}`; + } + + const result = await query(sql, params); + return result.rows; + } + + /** + * Get insight sources grouped by topic + */ + async getInsightSourcesByTopic(): Promise { + const result = await query( + `SELECT * FROM addie_insight_sources_by_topic` + ); + return result.rows; + } + + /** + * Update insight source status + */ + async updateInsightSourceStatus( + id: number, + status: InsightSourceStatus, + synthesisRunId?: number, + resultingRuleId?: number + ): Promise { + const result = await query( + `UPDATE addie_insight_sources + SET status = $2, synthesis_run_id = $3, resulting_rule_id = $4 + WHERE id = $1 + RETURNING *`, + [id, status, synthesisRunId || null, resultingRuleId || null] + ); + return result.rows[0] || null; + } + + /** + * Bulk update insight sources after synthesis + */ + async markSourcesSynthesized( + sourceIds: number[], + synthesisRunId: number + ): Promise { + const result = await query( + `UPDATE addie_insight_sources + SET status = 'synthesized', synthesis_run_id = $2 + WHERE id = ANY($1)`, + [sourceIds, synthesisRunId] + ); + return result.rowCount || 0; + } + + /** + * Archive an insight source + */ + async archiveInsightSource(id: number): Promise { + const result = await query( + `UPDATE addie_insight_sources SET status = 'archived' WHERE id = $1 RETURNING *`, + [id] + ); + return result.rows[0] || null; + } + + /** + * List all insight sources with optional filters + */ + async listInsightSources(options?: { + status?: InsightSourceStatus; + topic?: string; + limit?: number; + offset?: number; + }): Promise { + const params: unknown[] = []; + const conditions: string[] = []; + + if (options?.status) { + params.push(options.status); + conditions.push(`status = $${params.length}`); + } + + if (options?.topic) { + params.push(options.topic); + conditions.push(`topic = $${params.length}`); + } + + let sql = `SELECT * FROM addie_insight_sources`; + if (conditions.length > 0) { + sql += ` WHERE ${conditions.join(' AND ')}`; + } + sql += ` ORDER BY tagged_at DESC`; + + if (options?.limit) { + params.push(options.limit); + sql += ` LIMIT $${params.length}`; + } + + if (options?.offset) { + params.push(options.offset); + sql += ` OFFSET $${params.length}`; + } + + const result = await query(sql, params); + return result.rows; + } + + /** + * Create a synthesis run + */ + async createSynthesisRun(input: SynthesisRunInput): Promise { + const result = await query( + `INSERT INTO addie_synthesis_runs + (topic, source_ids, sources_count, topics_included, proposed_rules, created_by, model_used, tokens_used, synthesis_duration_ms) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + input.topic || null, + input.source_ids, + input.source_ids.length, + input.topics_included, + JSON.stringify(input.proposed_rules), + input.created_by || null, + input.model_used || null, + input.tokens_used || null, + input.synthesis_duration_ms || null, + ] + ); + return result.rows[0]; + } + + /** + * Get synthesis run by ID + */ + async getSynthesisRun(id: number): Promise { + const result = await query( + `SELECT * FROM addie_synthesis_runs WHERE id = $1`, + [id] + ); + return result.rows[0] || null; + } + + /** + * List synthesis runs + */ + async listSynthesisRuns(options?: { + status?: SynthesisRunStatus; + limit?: number; + }): Promise { + const params: unknown[] = []; + let sql = `SELECT * FROM addie_synthesis_runs`; + + if (options?.status) { + params.push(options.status); + sql += ` WHERE status = $${params.length}`; + } + + sql += ` ORDER BY created_at DESC`; + + if (options?.limit) { + params.push(options.limit); + sql += ` LIMIT $${params.length}`; + } + + const result = await query(sql, params); + return result.rows; + } + + /** + * Update synthesis run with preview results + */ + async updateSynthesisPreview( + id: number, + previewResults: SynthesisPreviewResults, + previewSummary: string + ): Promise { + const result = await query( + `UPDATE addie_synthesis_runs + SET preview_results = $2, preview_summary = $3 + WHERE id = $1 + RETURNING *`, + [id, JSON.stringify(previewResults), previewSummary] + ); + return result.rows[0] || null; + } + + /** + * Review a synthesis run (approve or reject) + */ + async reviewSynthesisRun( + id: number, + status: 'approved' | 'rejected', + reviewedBy: string, + reviewNotes?: string + ): Promise { + const result = await query( + `UPDATE addie_synthesis_runs + SET status = $2, reviewed_by = $3, reviewed_at = NOW(), review_notes = $4 + WHERE id = $1 + RETURNING *`, + [id, status, reviewedBy, reviewNotes || null] + ); + return result.rows[0] || null; + } + + /** + * Mark synthesis run as applied and record resulting rule IDs + */ + async applySynthesisRun( + id: number, + appliedRuleIds: number[] + ): Promise { + const result = await query( + `UPDATE addie_synthesis_runs + SET status = 'applied', applied_rule_ids = $2 + WHERE id = $1 + RETURNING *`, + [id, appliedRuleIds] + ); + return result.rows[0] || null; + } + + /** + * Link synthesis run to resulting config version + */ + async linkSynthesisToConfigVersion( + synthesisRunId: number, + configVersionId: number + ): Promise { + // Update synthesis run with resulting config version + await query( + `UPDATE addie_synthesis_runs + SET resulting_config_version_id = $2 + WHERE id = $1`, + [synthesisRunId, configVersionId] + ); + + // Update config version with source synthesis run + await query( + `UPDATE addie_config_versions + SET source_synthesis_run_ids = array_append( + COALESCE(source_synthesis_run_ids, ARRAY[]::INTEGER[]), + $2 + ) + WHERE version_id = $1 + AND NOT ($2 = ANY(COALESCE(source_synthesis_run_ids, ARRAY[]::INTEGER[])))`, + [configVersionId, synthesisRunId] + ); + } + + /** + * Get distinct topics from pending insight sources + */ + async getInsightTopics(): Promise { + const result = await query<{ topic: string }>( + `SELECT DISTINCT COALESCE(topic, 'uncategorized') AS topic + FROM addie_insight_sources + WHERE status = 'pending' + ORDER BY topic` + ); + return result.rows.map(r => r.topic); + } + + /** + * Count pending insight sources + */ + async countPendingInsights(topic?: string): Promise { + const params: unknown[] = []; + let sql = `SELECT COUNT(*) FROM addie_insight_sources WHERE status = 'pending'`; + + if (topic) { + params.push(topic); + sql += ` AND topic = $${params.length}`; + } + + const result = await query<{ count: string }>(sql, params); + return parseInt(result.rows[0].count, 10); + } + + /** + * Get current Addie config version info + */ + async getCurrentConfigVersion(): Promise { + const result = await query( + `SELECT + cv.version_id, + cv.config_hash, + cv.created_at, + array_length(cv.active_rule_ids, 1) as rule_count, + cv.message_count, + cv.positive_feedback, + cv.negative_feedback, + cv.avg_rating, + cv.source_synthesis_run_ids + FROM addie_config_versions cv + ORDER BY cv.version_id DESC + LIMIT 1` + ); + return result.rows[0] || null; + } + + /** + * Get config version history with metrics + */ + async getConfigVersionHistory(limit = 20): Promise { + const result = await query( + `SELECT + cv.version_id, + cv.config_hash, + cv.created_at, + array_length(cv.active_rule_ids, 1) as rule_count, + cv.message_count, + cv.positive_feedback, + cv.negative_feedback, + cv.avg_rating, + cv.source_synthesis_run_ids + FROM addie_config_versions cv + ORDER BY cv.version_id DESC + LIMIT $1`, + [limit] + ); + return result.rows; + } +} + +// Config version info type +export interface ConfigVersionInfo { + version_id: number; + config_hash: string; + created_at: Date; + rule_count: number; + message_count: number; + positive_feedback: number; + negative_feedback: number; + avg_rating: number | null; + source_synthesis_run_ids: number[] | null; } // Types for eval framework diff --git a/server/src/db/migrations/162_insight_synthesis.sql b/server/src/db/migrations/162_insight_synthesis.sql new file mode 100644 index 0000000000..65d09cc974 --- /dev/null +++ b/server/src/db/migrations/162_insight_synthesis.sql @@ -0,0 +1,173 @@ +-- Insight Synthesis System +-- Tags valuable content from any source, synthesizes into compact rules + +-- ===================================================== +-- INSIGHT SOURCES +-- ===================================================== +-- Content tagged as valuable for Addie's core knowledge + +CREATE TABLE addie_insight_sources ( + id SERIAL PRIMARY KEY, + + -- Source identification + source_type TEXT NOT NULL CHECK (source_type IN ( + 'conversation', -- From addie_thread_messages + 'perspective', -- From perspectives table + 'doc', -- From docs/ (by path) + 'slack', -- From indexed Slack messages + 'external' -- Pasted content (emails, articles, etc.) + )), + source_ref TEXT, -- Reference to original: thread_id, perspective_id, doc path, message_id, etc. + + -- The actual content + content TEXT NOT NULL, + excerpt TEXT, -- Short version for display (first ~200 chars) + + -- Categorization + topic TEXT, -- Grouping hint: "adoption", "platform-strategy", "trust", etc. + + -- Attribution (for context, not for Addie to cite) + author_name TEXT, -- Who said it: "Ben Masse" + author_context TEXT, -- Role/expertise: "Triton Digital, audio expert" + + -- Workflow + status TEXT DEFAULT 'pending' CHECK (status IN ( + 'pending', -- Tagged, awaiting synthesis + 'synthesized', -- Included in a synthesis run + 'archived' -- No longer relevant + )), + + -- Synthesis tracking + synthesis_run_id INTEGER, -- FK added after synthesis_runs table created + resulting_rule_id INTEGER REFERENCES addie_rules(id), + + -- Audit + tagged_by TEXT NOT NULL, + tagged_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + notes TEXT +); + +CREATE INDEX idx_insight_sources_status ON addie_insight_sources(status, topic); +CREATE INDEX idx_insight_sources_topic ON addie_insight_sources(topic) WHERE topic IS NOT NULL; +CREATE INDEX idx_insight_sources_pending ON addie_insight_sources(tagged_at DESC) WHERE status = 'pending'; + +-- ===================================================== +-- SYNTHESIS RUNS +-- ===================================================== +-- Track synthesis jobs and their outputs + +CREATE TABLE addie_synthesis_runs ( + id SERIAL PRIMARY KEY, + + -- Scope + topic TEXT, -- NULL = all pending sources, or specific topic + source_ids INTEGER[], -- Which insight sources were included + + -- Input summary + sources_count INTEGER DEFAULT 0, + topics_included TEXT[], -- Array of topics synthesized + + -- Output + proposed_rules JSONB, -- Array of {rule_type, name, content, source_ids, confidence} + + -- Preview results (replay against historical interactions) + preview_results JSONB, -- {predictions: [...], summary: {...}} + preview_summary TEXT, -- Human-readable summary + + -- Workflow + status TEXT DEFAULT 'draft' CHECK (status IN ( + 'draft', -- Synthesis complete, awaiting review + 'approved', -- Human approved, ready to apply + 'applied', -- Rules created/updated + 'rejected' -- Human rejected + )), + + -- Application tracking + applied_rule_ids INTEGER[], -- Rules created from this synthesis + + -- Audit + created_by TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + reviewed_by TEXT, + reviewed_at TIMESTAMP WITH TIME ZONE, + review_notes TEXT, + + -- Cost tracking + model_used TEXT, + tokens_used INTEGER, + synthesis_duration_ms INTEGER +); + +CREATE INDEX idx_synthesis_runs_status ON addie_synthesis_runs(status, created_at DESC); + +-- Now add FK from insight_sources to synthesis_runs +ALTER TABLE addie_insight_sources + ADD CONSTRAINT fk_insight_sources_synthesis_run + FOREIGN KEY (synthesis_run_id) REFERENCES addie_synthesis_runs(id); + +-- ===================================================== +-- HELPER VIEWS +-- ===================================================== + +-- Pending sources grouped by topic +CREATE VIEW addie_insight_sources_by_topic AS +SELECT + COALESCE(topic, 'uncategorized') AS topic, + COUNT(*) AS source_count, + array_agg(id ORDER BY tagged_at DESC) AS source_ids, + MIN(tagged_at) AS oldest_source, + MAX(tagged_at) AS newest_source +FROM addie_insight_sources +WHERE status = 'pending' +GROUP BY COALESCE(topic, 'uncategorized') +ORDER BY source_count DESC; + +-- Recent synthesis activity +CREATE VIEW addie_synthesis_activity AS +SELECT + sr.id, + sr.status, + sr.topic, + sr.sources_count, + sr.topics_included, + jsonb_array_length(sr.proposed_rules) AS rules_proposed, + sr.preview_summary, + sr.created_at, + sr.reviewed_at, + sr.reviewed_by, + sr.model_used, + sr.tokens_used +FROM addie_synthesis_runs sr +ORDER BY sr.created_at DESC; + +-- ===================================================== +-- FUNCTIONS +-- ===================================================== + +-- Generate excerpt from content +CREATE OR REPLACE FUNCTION generate_excerpt(content TEXT, max_length INTEGER DEFAULT 200) +RETURNS TEXT AS $$ +BEGIN + IF LENGTH(content) <= max_length THEN + RETURN content; + END IF; + -- Find last space before max_length to avoid cutting words + RETURN SUBSTRING(content FROM 1 FOR max_length - 3) || '...'; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Trigger to auto-generate excerpt on insert/update +CREATE OR REPLACE FUNCTION set_insight_excerpt() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.excerpt IS NULL OR NEW.excerpt = '' THEN + NEW.excerpt := generate_excerpt(NEW.content, 200); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_set_insight_excerpt + BEFORE INSERT OR UPDATE ON addie_insight_sources + FOR EACH ROW + EXECUTE FUNCTION set_insight_excerpt(); diff --git a/server/src/db/migrations/163_synthesis_config_versioning.sql b/server/src/db/migrations/163_synthesis_config_versioning.sql new file mode 100644 index 0000000000..07880e22d1 --- /dev/null +++ b/server/src/db/migrations/163_synthesis_config_versioning.sql @@ -0,0 +1,51 @@ +-- Link synthesis runs to config versions +-- Tracks which config version resulted from applying a synthesis run + +-- Add column to synthesis_runs to track resulting config version +ALTER TABLE addie_synthesis_runs + ADD COLUMN IF NOT EXISTS resulting_config_version_id INTEGER REFERENCES addie_config_versions(version_id); + +-- Add column to config_versions to track what synthesis run(s) contributed +ALTER TABLE addie_config_versions + ADD COLUMN IF NOT EXISTS source_synthesis_run_ids INTEGER[]; + +-- Index for finding config versions from synthesis +CREATE INDEX IF NOT EXISTS idx_config_versions_synthesis + ON addie_config_versions(source_synthesis_run_ids) + WHERE source_synthesis_run_ids IS NOT NULL; + +-- View for current Addie configuration status +CREATE OR REPLACE VIEW addie_current_config AS +SELECT + cv.version_id, + cv.config_hash, + cv.created_at, + array_length(cv.active_rule_ids, 1) as rule_count, + cv.message_count, + cv.positive_feedback, + cv.negative_feedback, + cv.avg_rating, + cv.source_synthesis_run_ids, + -- Get rule names for display + ( + SELECT array_agg(name ORDER BY priority DESC) + FROM addie_rules + WHERE id = ANY(cv.active_rule_ids) AND is_active = true + ) as active_rule_names, + -- Count rules by type + ( + SELECT jsonb_object_agg(rule_type, cnt) + FROM ( + SELECT rule_type, COUNT(*) as cnt + FROM addie_rules + WHERE id = ANY(cv.active_rule_ids) AND is_active = true + GROUP BY rule_type + ) type_counts + ) as rules_by_type +FROM addie_config_versions cv +WHERE cv.version_id = ( + SELECT MAX(version_id) FROM addie_config_versions +); + +COMMENT ON COLUMN addie_synthesis_runs.resulting_config_version_id IS 'Config version ID created when this synthesis was applied'; +COMMENT ON COLUMN addie_config_versions.source_synthesis_run_ids IS 'Synthesis run IDs that contributed rules to this config version'; diff --git a/server/src/db/migrations/164_seed_insight_sources.sql b/server/src/db/migrations/164_seed_insight_sources.sql new file mode 100644 index 0000000000..00713eb15b --- /dev/null +++ b/server/src/db/migrations/164_seed_insight_sources.sql @@ -0,0 +1,213 @@ +-- Seed initial high-quality insight sources +-- These represent foundational thinking that should inform Addie's core knowledge + +-- ============================================================================ +-- ADOPTION PHILOSOPHY (from Ben Masse email exchange) +-- ============================================================================ + +INSERT INTO addie_insight_sources ( + source_type, + content, + topic, + author_name, + author_context, + tagged_by, + notes +) VALUES ( + 'external', + $content$The key insight about AdCP adoption is that it won't happen through a "rip and replace" approach. The industry has too much invested in existing infrastructure. + +Instead, adoption will happen through: +1. New use cases that existing systems can't handle well (like AI-powered creative optimization) +2. Gradual integration where AdCP handles the "last mile" of personalization +3. Middleware layers that translate between legacy systems and AdCP + +The audio industry is a good example - they adopted programmatic without abandoning their existing trafficking systems. AdCP can follow a similar path. + +Trust is built through transparency and control. Publishers need to see exactly what's happening with their inventory. Advertisers need confidence that their brand safety requirements are being met. AdCP's machine-readable context provides this transparency in a way that opaque bidding systems never could.$content$, + 'adoption', + 'Ben Masse', + 'Triton Digital, audio advertising expert', + 'seed', + 'Key insights from email exchange about realistic AdCP adoption paths' +); + +-- ============================================================================ +-- AGENTIC ADVERTISING THESIS (core philosophy) +-- ============================================================================ + +INSERT INTO addie_insight_sources ( + source_type, + content, + topic, + author_name, + author_context, + tagged_by, + notes +) VALUES ( + 'external', + $content$The future of advertising is not about better targeting or more efficient bidding. It's about fundamentally reimagining the relationship between advertisers and publishers. + +Today's programmatic advertising is built on adversarial foundations: +- Publishers try to maximize revenue while minimizing what they give away +- Advertisers try to extract value while paying as little as possible +- Both sides use opaque systems that neither fully trusts + +Agentic advertising flips this model. AI agents working on behalf of both parties can: +- Share context openly (because machines can process it without human bias) +- Negotiate in real-time based on actual campaign needs +- Build trust through transparency rather than leverage + +The key insight is that AI doesn't need to "win" negotiations - it needs to find optimal matches. A publisher's agent and an advertiser's agent can collaborate rather than compete when the underlying protocol (AdCP) makes context machine-readable. + +This is why AdCP matters: it's not just a technical standard, it's the foundation for a new kind of advertising relationship.$content$, + 'philosophy', + 'Brian O''Kelley', + 'AdCP Founder, AppNexus founder', + 'seed', + 'Core thesis on why agentic advertising represents a paradigm shift' +); + +-- ============================================================================ +-- TRUST AND TRANSPARENCY +-- ============================================================================ + +INSERT INTO addie_insight_sources ( + source_type, + content, + topic, + author_name, + author_context, + tagged_by, + notes +) VALUES ( + 'external', + $content$The advertising industry has a trust problem that stems from information asymmetry. + +In the current model: +- Publishers don't know what advertisers are willing to pay or why +- Advertisers don't know what they're really buying (viewability, fraud, etc.) +- Intermediaries profit from this opacity + +AdCP addresses this by making context machine-readable and verifiable: +- Publishers can describe their inventory in rich, structured ways +- Advertisers can specify their requirements precisely +- Both sides can verify that commitments were kept + +But trust isn't just about technology. It requires: +1. Governance that represents all stakeholders (hence AgenticAdvertising.org as a member organization) +2. Open standards that no single company controls +3. Reference implementations that prove the concepts work + +The MCP (Model Context Protocol) foundation is key here - it's already trusted in the AI community, and AdCP builds on that foundation rather than inventing something new.$content$, + 'trust', + NULL, + NULL, + 'seed', + 'Principles around trust and transparency in advertising' +); + +-- ============================================================================ +-- PRACTICAL IMPLEMENTATION GUIDANCE +-- ============================================================================ + +INSERT INTO addie_insight_sources ( + source_type, + content, + topic, + author_name, + author_context, + tagged_by, + notes +) VALUES ( + 'external', + $content$When implementing AdCP, start with the simplest possible integration: + +1. **For Publishers**: Implement a basic AdCP server that exposes your existing inventory through get_products. Don't try to restructure your inventory taxonomy - just expose what you have. + +2. **For Advertisers**: Build an AdCP client that can discover publisher inventory. Start by using it alongside your existing buying tools, not replacing them. + +3. **For Both**: The creative_sync workflow is often the best starting point because: + - It solves a real pain point (trafficking creatives is tedious) + - It's low-risk (you're not changing how money flows) + - It demonstrates the value of machine-readable context + +The mistake most people make is trying to do too much at once. AdCP doesn't require you to change your business model or abandon existing systems. It's an additional capability, not a replacement. + +Think of it like adding an API to your existing platform - you're enabling new use cases, not breaking existing ones.$content$, + 'implementation', + NULL, + NULL, + 'seed', + 'Practical guidance for getting started with AdCP' +); + +-- ============================================================================ +-- ALLOCATION VS EFFICIENCY +-- ============================================================================ + +INSERT INTO addie_insight_sources ( + source_type, + content, + topic, + author_name, + author_context, + tagged_by, + notes +) VALUES ( + 'external', + $content$There's a fundamental philosophical difference between "efficiency" and "allocation" in advertising: + +EFFICIENCY (the old model): +- Minimize cost per impression/click/conversion +- Treat advertising as a commodity to be optimized +- Win by paying less than competitors for similar inventory +- Zero-sum: your efficiency gain is the publisher's revenue loss + +ALLOCATION (the agentic model): +- Match the right message to the right context +- Treat advertising as a value exchange between advertiser and consumer +- Win by creating better matches, not cheaper transactions +- Positive-sum: better allocation increases value for everyone + +AdCP is designed for allocation, not efficiency. The protocol makes rich context available so that AI agents can make better matches - not so they can drive down prices. + +This is a critical distinction when talking to publishers. They've been burned by "efficiency" technologies that extracted value from their businesses. AdCP should create value, not extract it.$content$, + 'philosophy', + NULL, + NULL, + 'seed', + 'Core distinction between efficiency and allocation models' +); + +-- ============================================================================ +-- AI AGENTS AND ADVERTISING +-- ============================================================================ + +INSERT INTO addie_insight_sources ( + source_type, + content, + topic, + author_name, + author_context, + tagged_by, + notes +) VALUES ( + 'external', + $content$AI agents change advertising in three fundamental ways: + +1. **Complexity becomes manageable**: An AI agent can consider thousands of targeting parameters, creative variations, and placement options simultaneously. Humans can't do this - we simplify to manage complexity. This means AI can optimize across dimensions humans ignore. + +2. **Real-time becomes meaningful**: When an AI agent makes decisions in milliseconds, it can respond to actual context - what article is being read, what the user journey looks like, what adjacent content surrounds the ad. RTB gave us "real-time" but humans still pre-define the rules. + +3. **Negotiation becomes collaborative**: Two AI agents can exchange information and find optimal solutions in ways that human negotiations can't. This requires a shared protocol (AdCP) and a foundation of trust (verifiable context). + +The opportunity for advertising is not "AI-powered targeting" (that's just better efficiency). It's AI agents that can genuinely represent the interests of their principals - advertisers, publishers, and even consumers - and negotiate outcomes that work for everyone. + +This requires protocols, not just models. AdCP provides the protocol; the models come from the AI companies. Together, they enable a new kind of advertising.$content$, + 'ai-agents', + NULL, + NULL, + 'seed', + 'How AI agents fundamentally change advertising' +); diff --git a/server/src/routes/addie-admin.ts b/server/src/routes/addie-admin.ts index cd5ef52a31..8264f0646b 100644 --- a/server/src/routes/addie-admin.ts +++ b/server/src/routes/addie-admin.ts @@ -9,7 +9,7 @@ import { Router } from "express"; import { createLogger } from "../logger.js"; import { requireAuth, requireAdmin } from "../middleware/auth.js"; import { serveHtmlWithConfig } from "../utils/html-config.js"; -import { AddieDatabase, type RuleType } from "../db/addie-db.js"; +import { AddieDatabase, type RuleType, type InsightSourceType } from "../db/addie-db.js"; import { query } from "../db/client.js"; import { analyzeInteractions, previewRuleChange } from "../addie/jobs/rule-analyzer.js"; import { invalidateAddieRulesCache } from "../addie/handler.js"; @@ -22,6 +22,7 @@ import { getAddieBoltApp } from "../addie/bolt-app.js"; import { AddieRouter, type RoutingContext } from "../addie/router.js"; import { sanitizeInput } from "../addie/security.js"; import { runSlackHistoryBackfill } from "../addie/jobs/slack-history-backfill.js"; +import { synthesizeInsights, applySynthesis } from "../addie/jobs/insight-synthesizer.js"; import { resolveSlackUserDisplayName, resolveSlackUserDisplayNames, @@ -1062,6 +1063,39 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be } }); + // ========================================================================= + // CONFIG VERSION API (mounted at /api/admin/addie/config) + // ========================================================================= + + // GET /api/admin/addie/config/current - Get current config version info + apiRouter.get("/config/current", requireAuth, requireAdmin, async (_req, res) => { + try { + const configVersion = await addieDb.getCurrentConfigVersion(); + res.json({ config_version: configVersion }); + } catch (error) { + logger.error({ err: error }, "Error fetching current config version"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to fetch config version", + }); + } + }); + + // GET /api/admin/addie/config/history - Get config version history + apiRouter.get("/config/history", requireAuth, requireAdmin, async (req, res) => { + try { + const limit = parseInt(req.query.limit as string) || 20; + const history = await addieDb.getConfigVersionHistory(limit); + res.json({ versions: history }); + } catch (error) { + logger.error({ err: error }, "Error fetching config version history"); + res.status(500).json({ + error: "Internal server error", + message: "Unable to fetch config history", + }); + } + }); + // ========================================================================= // RULES MANAGEMENT API (mounted at /api/admin/addie/rules) // ========================================================================= @@ -2338,5 +2372,444 @@ Be specific and actionable. Focus on patterns that could help improve Addie's be } }); + // ========================================================================= + // INSIGHT SYNTHESIS API (mounted at /api/admin/addie/insights) + // ========================================================================= + + // GET /api/admin/addie/insights - List insight sources + apiRouter.get("/insights", requireAuth, requireAdmin, async (req, res) => { + try { + const status = req.query.status as string | undefined; + const topic = req.query.topic as string | undefined; + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 50; + const offset = req.query.offset ? parseInt(req.query.offset as string, 10) : 0; + + const sources = await addieDb.listInsightSources({ + status: status as 'pending' | 'synthesized' | 'archived' | undefined, + topic, + limit, + offset, + }); + + const topics = await addieDb.getInsightTopics(); + const pendingCount = await addieDb.countPendingInsights(); + + res.json({ + success: true, + sources, + topics, + pendingCount, + }); + } catch (error) { + logger.error({ err: error }, "Error listing insight sources"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // GET /api/admin/addie/insights/by-topic - Get sources grouped by topic + apiRouter.get("/insights/by-topic", requireAuth, requireAdmin, async (req, res) => { + try { + const byTopic = await addieDb.getInsightSourcesByTopic(); + res.json({ + success: true, + topics: byTopic, + }); + } catch (error) { + logger.error({ err: error }, "Error getting insights by topic"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // POST /api/admin/addie/insights - Create a new insight source (tag content) + apiRouter.post("/insights", requireAuth, requireAdmin, async (req, res) => { + try { + const { source_type, source_ref, content, topic, author_name, author_context, notes } = req.body; + + if (!source_type || !content) { + return res.status(400).json({ + error: "Bad request", + message: "source_type and content are required", + }); + } + + const validTypes: InsightSourceType[] = ['conversation', 'perspective', 'doc', 'slack', 'external']; + if (!validTypes.includes(source_type)) { + return res.status(400).json({ + error: "Bad request", + message: `Invalid source_type. Must be one of: ${validTypes.join(', ')}`, + }); + } + + const userEmail = req.user?.email || 'admin'; + + const source = await addieDb.createInsightSource({ + source_type, + source_ref, + content, + topic, + author_name, + author_context, + tagged_by: userEmail, + notes, + }); + + logger.info({ sourceId: source.id, topic, taggedBy: userEmail }, "Insight source created"); + + res.json({ + success: true, + source, + }); + } catch (error) { + logger.error({ err: error }, "Error creating insight source"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // GET /api/admin/addie/insights/:id - Get a specific insight source + apiRouter.get("/insights/:id", requireAuth, requireAdmin, async (req, res) => { + try { + const id = parseNumericId(req.params.id); + if (!id) { + return res.status(400).json({ error: "Bad request", message: "Invalid ID" }); + } + + const source = await addieDb.getInsightSource(id); + if (!source) { + return res.status(404).json({ error: "Not found", message: "Insight source not found" }); + } + + res.json({ success: true, source }); + } catch (error) { + logger.error({ err: error }, "Error getting insight source"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // DELETE /api/admin/addie/insights/:id - Archive an insight source + apiRouter.delete("/insights/:id", requireAuth, requireAdmin, async (req, res) => { + try { + const id = parseNumericId(req.params.id); + if (!id) { + return res.status(400).json({ error: "Bad request", message: "Invalid ID" }); + } + + const source = await addieDb.archiveInsightSource(id); + if (!source) { + return res.status(404).json({ error: "Not found", message: "Insight source not found" }); + } + + res.json({ success: true, source }); + } catch (error) { + logger.error({ err: error }, "Error archiving insight source"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // POST /api/admin/addie/insights/from-perspective - Tag a perspective as insight source + apiRouter.post("/insights/from-perspective", requireAuth, requireAdmin, async (req, res) => { + try { + const { perspectiveId, topic, notes } = req.body; + + if (!perspectiveId) { + return res.status(400).json({ + error: "Bad request", + message: "perspectiveId is required", + }); + } + + // Fetch the perspective + const perspectiveResult = await query<{ + id: string; + title: string; + content: string; + body: string; + excerpt: string; + author_name: string; + category: string; + }>( + `SELECT id, title, content, body, excerpt, author_name, category + FROM perspectives + WHERE id = $1`, + [perspectiveId] + ); + + if (perspectiveResult.rows.length === 0) { + return res.status(404).json({ + error: "Not found", + message: "Perspective not found", + }); + } + + const perspective = perspectiveResult.rows[0]; + const contentToUse = perspective.body || perspective.content || perspective.excerpt; + + if (!contentToUse) { + return res.status(400).json({ + error: "Bad request", + message: "Perspective has no content to tag", + }); + } + + const userEmail = req.user?.email || 'admin'; + + const source = await addieDb.createInsightSource({ + source_type: 'perspective', + source_ref: perspectiveId, + content: contentToUse, + topic: topic || perspective.category || undefined, + author_name: perspective.author_name || undefined, + author_context: perspective.title, + tagged_by: userEmail, + notes: notes || `Tagged from perspective: ${perspective.title}`, + }); + + logger.info({ + sourceId: source.id, + perspectiveId, + title: perspective.title, + }, "Perspective tagged as insight source"); + + res.json({ success: true, source }); + } catch (error) { + logger.error({ err: error }, "Error tagging perspective as insight"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // GET /api/admin/addie/insights/perspectives - List perspectives available for tagging + apiRouter.get("/insights/perspectives", requireAuth, requireAdmin, async (req, res) => { + try { + // Get perspectives that haven't been tagged yet + const result = await query<{ + id: string; + title: string; + category: string; + author_name: string; + excerpt: string; + published_at: Date; + already_tagged: boolean; + }>( + `SELECT + p.id, + p.title, + p.category, + p.author_name, + p.excerpt, + p.published_at, + EXISTS ( + SELECT 1 FROM addie_insight_sources ais + WHERE ais.source_type = 'perspective' + AND ais.source_ref = p.id::text + AND ais.status != 'archived' + ) as already_tagged + FROM perspectives p + WHERE p.status = 'published' + ORDER BY p.published_at DESC + LIMIT 50` + ); + + res.json({ + success: true, + perspectives: result.rows, + }); + } catch (error) { + logger.error({ err: error }, "Error listing perspectives for tagging"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // ========================================================================= + // SYNTHESIS RUNS API (mounted at /api/admin/addie/synthesis) + // ========================================================================= + + // GET /api/admin/addie/synthesis - List synthesis runs + apiRouter.get("/synthesis", requireAuth, requireAdmin, async (req, res) => { + try { + const status = req.query.status as 'draft' | 'approved' | 'applied' | 'rejected' | undefined; + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 20; + + const runs = await addieDb.listSynthesisRuns({ status, limit }); + + res.json({ + success: true, + runs, + }); + } catch (error) { + logger.error({ err: error }, "Error listing synthesis runs"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // 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, { + topic, + maxSources: maxSources || 50, + previewSampleSize: previewSampleSize || 20, + createdBy: userEmail, + }); + + logger.info({ + runId: result.run.id, + rulesProposed: result.proposedRules.length, + gaps: result.gaps, + }, "Synthesis run completed"); + + res.json({ + success: true, + run: result.run, + proposedRules: result.proposedRules, + preview: result.preview, + gaps: result.gaps, + }); + } catch (error) { + logger.error({ err: error }, "Error running synthesis"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // GET /api/admin/addie/synthesis/:id - Get a specific synthesis run + apiRouter.get("/synthesis/:id", requireAuth, requireAdmin, async (req, res) => { + try { + const id = parseNumericId(req.params.id); + if (!id) { + return res.status(400).json({ error: "Bad request", message: "Invalid ID" }); + } + + const run = await addieDb.getSynthesisRun(id); + if (!run) { + return res.status(404).json({ error: "Not found", message: "Synthesis run not found" }); + } + + // Also get the source documents for context + const sources = await addieDb.listInsightSources({ + status: undefined, // Get all statuses + limit: 100, + }); + const runSources = sources.filter(s => run.source_ids.includes(s.id)); + + res.json({ + success: true, + run, + sources: runSources, + }); + } catch (error) { + logger.error({ err: error }, "Error getting synthesis run"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // POST /api/admin/addie/synthesis/:id/review - Approve or reject a synthesis + apiRouter.post("/synthesis/:id/review", requireAuth, requireAdmin, async (req, res) => { + try { + const id = parseNumericId(req.params.id); + if (!id) { + return res.status(400).json({ error: "Bad request", message: "Invalid ID" }); + } + + const { status, notes } = req.body; + if (!status || !['approved', 'rejected'].includes(status)) { + return res.status(400).json({ + error: "Bad request", + message: "status must be 'approved' or 'rejected'", + }); + } + + const userEmail = req.user?.email || 'admin'; + + const run = await addieDb.reviewSynthesisRun(id, status, userEmail, notes); + if (!run) { + return res.status(404).json({ error: "Not found", message: "Synthesis run not found" }); + } + + logger.info({ runId: id, status, reviewedBy: userEmail }, "Synthesis run reviewed"); + + res.json({ success: true, run }); + } catch (error) { + logger.error({ err: error }, "Error reviewing synthesis run"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + // POST /api/admin/addie/synthesis/:id/apply - Apply an approved synthesis + apiRouter.post("/synthesis/:id/apply", requireAuth, requireAdmin, async (req, res) => { + try { + const id = parseNumericId(req.params.id); + if (!id) { + return res.status(400).json({ error: "Bad request", message: "Invalid ID" }); + } + + const result = await applySynthesis(addieDb, id); + + // Invalidate rules cache so Addie picks up new rules + invalidateAddieRulesCache(); + + logger.info({ + runId: id, + rulesCreated: result.rules.length, + ruleIds: result.rules.map(r => r.id), + configVersionId: result.configVersionId, + }, "Synthesis applied"); + + res.json({ + success: true, + run: result.run, + rules: result.rules, + configVersionId: result.configVersionId, + }); + } catch (error) { + logger.error({ err: error }, "Error applying synthesis"); + res.status(500).json({ + error: "Internal server error", + message: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + return { pageRouter, apiRouter }; }