Files
accounted/extensions/general/ai-chat/chatbot/chain.ts
T
Jakob Wennberg 13725ffc16 feat: production readiness — 3-extension deploy with security hardening and observability
- Strip extensions to enable-banking, ai-categorization, ai-chat only
- Remove push-notifications cron from vercel.json
- Add security headers (HSTS, CSP, X-Frame-Options, Permissions-Policy)
- Add /api/health endpoint for uptime monitoring
- Add env var validation in ensureInitialized()
- Fix SIE4 #IB opening balance records from year-end closing entry
- Replace in-memory ai-chat rate limiter with Supabase-backed distributed rate limiting
- Add Sentry error tracking scaffolding (@sentry/nextjs, instrumentation hook)
- Add AI token usage tracking (migration 047, usage-tracker, wired into both AI extensions)
- Include pending enable-banking and dashboard improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:32:43 +01:00

252 lines
7.3 KiB
TypeScript

import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { CHATBOT_CONFIG } from './config'
import {
SYSTEM_PROMPT,
formatContextFromSources,
formatConversationHistory,
} from './prompts'
import {
retrieveRelevantDocuments,
documentsToSources,
type RetrievedDocument,
} from './retriever'
import { routeMessage, type RouteType } from './router'
import { createAccountingTools } from './tools'
import { streamAgentResponse, type ToolResultEntry } from './agent'
import { generateArtifact, type ArtifactSpec } from './artifacts'
import type { ChatMessage, SourceReference } from '@/types/chat'
import type { SupabaseClient } from '@supabase/supabase-js'
import { trackTokenUsage } from '@/lib/ai/usage-tracker'
// Initialize the LLM
function getChatModel() {
return new ChatAnthropic({
modelName: CHATBOT_CONFIG.model,
maxTokens: CHATBOT_CONFIG.maxTokens,
temperature: CHATBOT_CONFIG.temperature,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
}
export interface ChatResult {
content: string
sources: SourceReference[]
}
export async function generateChatResponse(
userMessage: string,
conversationHistory: ChatMessage[],
tracking?: { supabase: SupabaseClient; userId: string }
): Promise<ChatResult> {
// 1. Retrieve relevant documents
const relevantDocs = await retrieveRelevantDocuments(userMessage)
// 2. Format context from retrieved documents
const context = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
// 3. Format conversation history (last N messages)
const recentHistory = conversationHistory.slice(
-CHATBOT_CONFIG.maxHistoryMessages
)
const historyText = formatConversationHistory(
recentHistory.map((msg) => ({
role: msg.role,
content: msg.content,
}))
)
// 4. Build the system prompt with context
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
'{history}',
historyText
)
// 5. Generate response
const model = getChatModel()
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userMessage),
])
// 6. Track token usage
if (tracking && response.usage_metadata) {
trackTokenUsage(tracking.supabase, tracking.userId, 'ai-chat', {
inputTokens: response.usage_metadata.input_tokens ?? 0,
outputTokens: response.usage_metadata.output_tokens ?? 0,
model: CHATBOT_CONFIG.model,
})
}
// 7. Extract content and sources
const content =
typeof response.content === 'string'
? response.content
: JSON.stringify(response.content)
return {
content,
sources: documentsToSources(relevantDocs),
}
}
export async function* streamChatResponse(
userMessage: string,
conversationHistory: ChatMessage[]
): AsyncGenerator<{ type: 'content' | 'sources'; data: string | SourceReference[] }> {
// 1. Retrieve relevant documents first
const relevantDocs = await retrieveRelevantDocuments(userMessage)
// 2. Format context from retrieved documents
const context = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
// 3. Format conversation history
const recentHistory = conversationHistory.slice(
-CHATBOT_CONFIG.maxHistoryMessages
)
const historyText = formatConversationHistory(
recentHistory.map((msg) => ({
role: msg.role,
content: msg.content,
}))
)
// 4. Build the system prompt
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
'{history}',
historyText
)
// 5. Stream the response
const model = getChatModel()
const stream = await model.stream([
new SystemMessage(systemPrompt),
new HumanMessage(userMessage),
])
for await (const chunk of stream) {
const content =
typeof chunk.content === 'string'
? chunk.content
: JSON.stringify(chunk.content)
if (content) {
yield { type: 'content', data: content }
}
}
// 6. Yield sources at the end
yield { type: 'sources', data: documentsToSources(relevantDocs) }
}
// ── Routed response (data / hybrid / knowledge) ────────────────
export type RoutedStreamEvent =
| { type: 'content'; content: string }
| { type: 'sources'; sources: SourceReference[] }
| { type: 'tool_start'; toolName: string }
| { type: 'artifact'; artifact: ArtifactSpec }
| { type: 'route'; route: RouteType }
/**
* High-level streaming function: routes the message, then either uses
* the existing RAG chain (knowledge) or the LangGraph agent (data/hybrid).
* Generates artifact post-hoc on data/hybrid routes.
*/
export async function* streamRoutedResponse(
userMessage: string,
conversationHistory: ChatMessage[],
supabase: SupabaseClient,
userId: string,
sessionId?: string
): AsyncGenerator<RoutedStreamEvent> {
// 1. Route the message
const { route, rewrittenQuery } = await routeMessage(userMessage, conversationHistory)
yield { type: 'route', route }
// 2. Knowledge-only: use existing RAG chain
if (route === 'knowledge') {
for await (const chunk of streamChatResponse(rewrittenQuery, conversationHistory)) {
if (chunk.type === 'content') {
yield { type: 'content', content: chunk.data as string }
} else if (chunk.type === 'sources') {
yield { type: 'sources', sources: chunk.data as SourceReference[] }
}
}
return
}
// 3. Data or hybrid: use LangGraph agent with tools
const tools = createAccountingTools(supabase, userId)
// For hybrid, get RAG context
let ragContext: string | undefined
let sources: SourceReference[] = []
if (route === 'hybrid') {
try {
const relevantDocs = await retrieveRelevantDocuments(rewrittenQuery)
ragContext = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
sources = documentsToSources(relevantDocs)
} catch {
// RAG failure is non-critical for hybrid route
}
}
let fullContent = ''
let toolResults: ToolResultEntry[] = []
for await (const event of streamAgentResponse({
query: rewrittenQuery,
route,
tools,
conversationHistory,
ragContext,
})) {
if (event.type === 'tool_start') {
yield { type: 'tool_start', toolName: event.toolName! }
} else if (event.type === 'content') {
fullContent += event.content!
yield { type: 'content', content: event.content! }
} else if (event.type === 'done') {
toolResults = event.toolResults || []
}
}
// 4. Yield sources if hybrid
if (sources.length > 0) {
yield { type: 'sources', sources }
}
// 5. Generate artifact (post-processing)
if (toolResults.length > 0 && fullContent.length > 0) {
try {
const artifact = await generateArtifact(toolResults, fullContent)
if (artifact) {
yield { type: 'artifact', artifact }
}
} catch (e) {
console.warn('Artifact generation failed:', e)
}
}
}