feat: AI chat now more accurate, has access to data from user, and can generate graphs, charts etc
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
|
||||
import { generateChatResponse, streamChatResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
||||
import { generateChatResponse, streamChatResponse, streamRoutedResponse } from '@/extensions/general/ai-chat/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
|
||||
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||
@@ -258,6 +258,7 @@ async function handlePostStream(
|
||||
const encoder = new TextEncoder()
|
||||
let fullContent = ''
|
||||
let sources: SourceReference[] = []
|
||||
let artifact: ArtifactSpec | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
@@ -267,22 +268,37 @@ async function handlePostStream(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
||||
)
|
||||
|
||||
// Stream the response
|
||||
for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) {
|
||||
if (chunk.type === 'content') {
|
||||
fullContent += chunk.data as string
|
||||
// Stream the routed response (handles knowledge, data, and hybrid)
|
||||
for await (const event of streamRoutedResponse(
|
||||
message.trim(),
|
||||
conversationHistory,
|
||||
supabase,
|
||||
userId,
|
||||
sessionId
|
||||
)) {
|
||||
if (event.type === 'content') {
|
||||
fullContent += event.content
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: chunk.data })}\n\n`)
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: event.content })}\n\n`)
|
||||
)
|
||||
} else if (chunk.type === 'sources') {
|
||||
sources = chunk.data as SourceReference[]
|
||||
} else if (event.type === 'sources') {
|
||||
sources = event.sources
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: chunk.data })}\n\n`)
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: event.sources })}\n\n`)
|
||||
)
|
||||
} else if (event.type === 'tool_start') {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'tool_start', toolName: event.toolName })}\n\n`)
|
||||
)
|
||||
} else if (event.type === 'artifact') {
|
||||
artifact = event.artifact
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'artifact', artifact: event.artifact })}\n\n`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the complete assistant message
|
||||
// Save the complete assistant message (including artifact)
|
||||
const { data: savedMessage } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
@@ -291,6 +307,7 @@ async function handlePostStream(
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
sources,
|
||||
...(artifact ? { artifact } : {}),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt'
|
||||
import { HumanMessage, AIMessage } from '@langchain/core/messages'
|
||||
import type { StructuredToolInterface } from '@langchain/core/tools'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import {
|
||||
SYSTEM_PROMPT_DATA,
|
||||
SYSTEM_PROMPT_HYBRID,
|
||||
formatConversationHistory,
|
||||
} from './prompts'
|
||||
import type { ChatMessage } from '@/types/chat'
|
||||
import type { RouteType } from './router'
|
||||
|
||||
export interface AgentStreamEvent {
|
||||
type: 'tool_start' | 'content' | 'done'
|
||||
toolName?: string
|
||||
content?: string
|
||||
toolResults?: ToolResultEntry[]
|
||||
}
|
||||
|
||||
export interface ToolResultEntry {
|
||||
toolName: string
|
||||
result: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the LangGraph agent with tool calling and stream events.
|
||||
*/
|
||||
export async function* streamAgentResponse(options: {
|
||||
query: string
|
||||
route: RouteType
|
||||
tools: StructuredToolInterface[]
|
||||
conversationHistory: ChatMessage[]
|
||||
ragContext?: string
|
||||
}): AsyncGenerator<AgentStreamEvent> {
|
||||
const { query, route, tools, conversationHistory, ragContext } = options
|
||||
|
||||
// Build system prompt based on route
|
||||
const historyText = formatConversationHistory(
|
||||
conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages).map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}))
|
||||
)
|
||||
|
||||
let systemPrompt: string
|
||||
if (route === 'data') {
|
||||
systemPrompt = SYSTEM_PROMPT_DATA.replace('{history}', historyText)
|
||||
} else {
|
||||
const context = ragContext || 'Ingen specifik kontext hittades i kunskapsbasen.'
|
||||
systemPrompt = SYSTEM_PROMPT_HYBRID
|
||||
.replace('{context}', context)
|
||||
.replace('{history}', historyText)
|
||||
}
|
||||
|
||||
// Create the model
|
||||
const model = new ChatAnthropic({
|
||||
modelName: CHATBOT_CONFIG.agentModel,
|
||||
maxTokens: CHATBOT_CONFIG.agentMaxTokens,
|
||||
temperature: CHATBOT_CONFIG.temperature,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
|
||||
// Create the agent
|
||||
const agent = createReactAgent({
|
||||
llm: model,
|
||||
tools,
|
||||
prompt: systemPrompt,
|
||||
})
|
||||
|
||||
// Build input messages
|
||||
const messages: (HumanMessage | AIMessage)[] = []
|
||||
|
||||
// Add recent history as messages for the agent
|
||||
const recent = conversationHistory.slice(-CHATBOT_CONFIG.maxHistoryMessages)
|
||||
for (const msg of recent) {
|
||||
if (msg.role === 'user') {
|
||||
messages.push(new HumanMessage(msg.content))
|
||||
} else {
|
||||
messages.push(new AIMessage(msg.content))
|
||||
}
|
||||
}
|
||||
messages.push(new HumanMessage(query))
|
||||
|
||||
// Track tool results for artifact generation
|
||||
const toolResults: ToolResultEntry[] = []
|
||||
|
||||
// Stream the agent execution using streamEvents for fine-grained control
|
||||
const eventStream = agent.streamEvents(
|
||||
{ messages },
|
||||
{
|
||||
version: 'v2',
|
||||
recursionLimit: CHATBOT_CONFIG.maxAgentIterations * 2 + 1,
|
||||
}
|
||||
)
|
||||
|
||||
for await (const event of eventStream) {
|
||||
// Tool start events
|
||||
if (event.event === 'on_tool_start') {
|
||||
yield { type: 'tool_start', toolName: event.name }
|
||||
}
|
||||
|
||||
// Tool end events — capture results
|
||||
if (event.event === 'on_tool_end') {
|
||||
const output = event.data?.output
|
||||
const result = typeof output === 'string' ? output : JSON.stringify(output ?? '')
|
||||
toolResults.push({
|
||||
toolName: event.name,
|
||||
result,
|
||||
})
|
||||
}
|
||||
|
||||
// LLM streaming tokens (final response text)
|
||||
if (event.event === 'on_chat_model_stream') {
|
||||
const chunk = event.data?.chunk
|
||||
if (chunk) {
|
||||
const content = typeof chunk.content === 'string'
|
||||
? chunk.content
|
||||
: Array.isArray(chunk.content)
|
||||
? chunk.content
|
||||
.filter((c: { type: string }) => c.type === 'text')
|
||||
.map((c: { text: string }) => c.text)
|
||||
.join('')
|
||||
: ''
|
||||
if (content) {
|
||||
yield { type: 'content', content }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: 'done', toolResults }
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { z } from 'zod'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import type { ToolResultEntry } from './agent'
|
||||
import type { ArtifactSpec } from '@/types/chat'
|
||||
|
||||
// ── Artifact Zod Schemas ────────────────────────────────────────
|
||||
|
||||
const ChartDataPoint = z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
color: z.string().optional(),
|
||||
})
|
||||
|
||||
const ChartArtifact = z.object({
|
||||
type: z.enum(['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']),
|
||||
title: z.string(),
|
||||
data: z.array(ChartDataPoint),
|
||||
unit: z.string().optional(),
|
||||
subtitle: z.string().optional(),
|
||||
})
|
||||
|
||||
const TableColumn = z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
align: z.enum(['left', 'right']).optional(),
|
||||
})
|
||||
|
||||
const TableArtifact = z.object({
|
||||
type: z.literal('table'),
|
||||
title: z.string(),
|
||||
columns: z.array(TableColumn),
|
||||
rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
|
||||
summary_row: z.record(z.string(), z.union([z.string(), z.number()])).optional(),
|
||||
})
|
||||
|
||||
const KpiCard = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
trend: z.enum(['up', 'down', 'flat']).optional(),
|
||||
change: z.string().optional(),
|
||||
})
|
||||
|
||||
const KpiCardsArtifact = z.object({
|
||||
type: z.literal('kpi_cards'),
|
||||
title: z.string().optional(),
|
||||
cards: z.array(KpiCard),
|
||||
})
|
||||
|
||||
const AgingBucket = z.object({
|
||||
label: z.string(),
|
||||
amount: z.number(),
|
||||
count: z.number(),
|
||||
})
|
||||
|
||||
const AgingBucketsArtifact = z.object({
|
||||
type: z.literal('aging_buckets'),
|
||||
title: z.string(),
|
||||
buckets: z.array(AgingBucket),
|
||||
total: z.number(),
|
||||
})
|
||||
|
||||
export const ArtifactSpecSchema = z.discriminatedUnion('type', [
|
||||
ChartArtifact,
|
||||
TableArtifact,
|
||||
KpiCardsArtifact,
|
||||
AgingBucketsArtifact,
|
||||
])
|
||||
|
||||
export type { ArtifactSpec } from '@/types/chat'
|
||||
|
||||
// ── Artifact System Prompt ──────────────────────────────────────
|
||||
|
||||
const ARTIFACT_SYSTEM_PROMPT = `You are a data visualization expert. Given tool results and an AI response about accounting data, generate a structured artifact spec for visual display.
|
||||
|
||||
## EXACT schemas (follow field names precisely):
|
||||
|
||||
### Chart (bar_chart, line_chart, pie_chart, stacked_bar):
|
||||
{"type":"bar_chart","title":"...","data":[{"label":"Category name","value":1234}],"unit":"kr"}
|
||||
IMPORTANT: Each item in "data" MUST have "label" (string) and "value" (number). NOT "name", NOT "amount" — use exactly "label" and "value".
|
||||
|
||||
### Table:
|
||||
{"type":"table","title":"...","columns":[{"key":"col1","label":"Header","align":"right"}],"rows":[{"col1":"value"}],"summary_row":{"col1":"Total"}}
|
||||
|
||||
### KPI cards:
|
||||
{"type":"kpi_cards","title":"...","cards":[{"label":"Metric","value":"1 234 kr","trend":"up","change":"+12%"}]}
|
||||
IMPORTANT: "trend" MUST be exactly "up", "down", or "flat". No other values allowed.
|
||||
|
||||
### Aging buckets:
|
||||
{"type":"aging_buckets","title":"...","buckets":[{"label":"0 dagar","amount":1000,"count":2}],"total":5000}
|
||||
|
||||
## Rules:
|
||||
1. Return ONLY a single JSON object (not an array!) or the word "null". The top-level must be an object with a "type" field.
|
||||
2. Choose chart type based on data:
|
||||
- Income/balance sheet sections → "bar_chart"
|
||||
- Distribution (VAT, account classes) → "pie_chart"
|
||||
- Company overview → "kpi_cards"
|
||||
- AR/AP aging → "aging_buckets"
|
||||
- Lists with >3 items + amounts → "table"
|
||||
- Simple answers, few items, yes/no → null
|
||||
3. Use Swedish labels. Use "kr" as unit for monetary charts.
|
||||
4. Max 12 chart data points. Aggregate small items as "Övrigt".
|
||||
5. For tables, include summary_row with totals where appropriate.`
|
||||
|
||||
// ── Normalizer ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fix common LLM field name mistakes before Zod validation.
|
||||
* Mutates the object in place.
|
||||
*/
|
||||
function normalizeArtifact(obj: Record<string, unknown>): void {
|
||||
if (!obj || typeof obj !== 'object') return
|
||||
|
||||
// Chart types: normalize data[].name→label, data[].amount→value
|
||||
const chartTypes = ['bar_chart', 'line_chart', 'pie_chart', 'stacked_bar']
|
||||
if (chartTypes.includes(obj.type as string) && Array.isArray(obj.data)) {
|
||||
for (const item of obj.data) {
|
||||
if (item && typeof item === 'object') {
|
||||
if ('name' in item && !('label' in item)) {
|
||||
item.label = item.name
|
||||
delete item.name
|
||||
}
|
||||
if ('amount' in item && !('value' in item)) {
|
||||
item.value = item.amount
|
||||
delete item.amount
|
||||
}
|
||||
if ('total' in item && !('value' in item)) {
|
||||
item.value = item.total
|
||||
delete item.total
|
||||
}
|
||||
if ('value' in item && typeof item.value === 'string') {
|
||||
const num = parseFloat(String(item.value).replace(/\s/g, '').replace(',', '.'))
|
||||
if (!isNaN(num)) item.value = num
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KPI cards: normalize trend values
|
||||
if (obj.type === 'kpi_cards' && Array.isArray(obj.cards)) {
|
||||
const trendMap: Record<string, string> = {
|
||||
neutral: 'flat', stable: 'flat', none: 'flat', '-': 'flat',
|
||||
negative: 'down', decrease: 'down', declining: 'down',
|
||||
positive: 'up', increase: 'up', increasing: 'up', growing: 'up',
|
||||
}
|
||||
for (const card of obj.cards) {
|
||||
if (card && typeof card === 'object' && 'trend' in card) {
|
||||
const t = String(card.trend).toLowerCase()
|
||||
if (trendMap[t]) {
|
||||
card.trend = trendMap[t]
|
||||
} else if (t !== 'up' && t !== 'down' && t !== 'flat') {
|
||||
// Unknown trend value — remove it so optional field passes
|
||||
delete card.trend
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generator ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate an artifact spec from tool results using a post-processing LLM call.
|
||||
* Returns null if no visualization is appropriate.
|
||||
*/
|
||||
export async function generateArtifact(
|
||||
toolResults: ToolResultEntry[],
|
||||
assistantResponse: string
|
||||
): Promise<ArtifactSpec | null> {
|
||||
if (toolResults.length === 0) return null
|
||||
|
||||
const model = new ChatAnthropic({
|
||||
modelName: CHATBOT_CONFIG.artifactModel,
|
||||
maxTokens: 1024,
|
||||
temperature: 0,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
|
||||
const toolSummary = toolResults
|
||||
.map((r) => `Tool: ${r.toolName}\nResult: ${r.result.slice(0, 2000)}`)
|
||||
.join('\n\n---\n\n')
|
||||
|
||||
const prompt = `${ARTIFACT_SYSTEM_PROMPT}
|
||||
|
||||
## Tool results:
|
||||
${toolSummary}
|
||||
|
||||
## AI response:
|
||||
${assistantResponse.slice(0, 1000)}
|
||||
|
||||
Generate the artifact JSON or "null":`
|
||||
|
||||
try {
|
||||
const response = await model.invoke(prompt)
|
||||
const text = typeof response.content === 'string'
|
||||
? response.content
|
||||
: JSON.stringify(response.content)
|
||||
|
||||
const trimmed = text.trim()
|
||||
if (trimmed === 'null' || trimmed === '"null"') return null
|
||||
|
||||
// Extract JSON from response (handle markdown code blocks)
|
||||
let jsonStr = trimmed
|
||||
const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||||
if (codeBlockMatch) {
|
||||
jsonStr = codeBlockMatch[1].trim()
|
||||
}
|
||||
|
||||
let parsed = JSON.parse(jsonStr)
|
||||
|
||||
// If LLM returned an array, try to wrap it as kpi_cards
|
||||
if (Array.isArray(parsed)) {
|
||||
// Array of cards → wrap as kpi_cards
|
||||
if (parsed.length > 0 && parsed[0] && typeof parsed[0] === 'object' && 'label' in parsed[0]) {
|
||||
parsed = { type: 'kpi_cards', title: 'Översikt', cards: parsed }
|
||||
} else {
|
||||
console.warn('Artifact returned unexpected array')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize common LLM field name mistakes before validation
|
||||
normalizeArtifact(parsed)
|
||||
|
||||
const validated = ArtifactSpecSchema.safeParse(parsed)
|
||||
|
||||
if (validated.success) {
|
||||
return validated.data as ArtifactSpec
|
||||
}
|
||||
|
||||
console.warn('Artifact validation failed:', validated.error.issues)
|
||||
return null
|
||||
} catch (e) {
|
||||
console.warn('Artifact generation failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
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'
|
||||
|
||||
// Initialize the LLM
|
||||
function getChatModel() {
|
||||
@@ -135,3 +140,101 @@ export async function* streamChatResponse(
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,17 @@ export const CHATBOT_CONFIG = {
|
||||
maxTokens: 2048,
|
||||
temperature: 0.3,
|
||||
|
||||
// Agent settings
|
||||
agentModel: 'claude-sonnet-4-6',
|
||||
agentMaxTokens: 4096,
|
||||
maxAgentIterations: 5,
|
||||
|
||||
// Router settings
|
||||
routerModel: 'claude-haiku-4-5-20251001',
|
||||
|
||||
// Artifact generation
|
||||
artifactModel: 'claude-haiku-4-5-20251001',
|
||||
|
||||
// Retrieval settings
|
||||
retrievalK: 5,
|
||||
similarityThreshold: 0.7,
|
||||
|
||||
@@ -42,6 +42,60 @@ Fråga: {question}
|
||||
|
||||
Sök efter information som hjälper att besvara frågan korrekt och fullständigt.`
|
||||
|
||||
/**
|
||||
* System prompt for tool-calling agent (data route).
|
||||
* No RAG context — relies entirely on tools.
|
||||
*/
|
||||
export const SYSTEM_PROMPT_DATA = `Du är en AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata i realtid.
|
||||
|
||||
## Instruktioner:
|
||||
1. Svara alltid på svenska
|
||||
2. Använd verktygen för att hämta data innan du svarar — gissa aldrig siffror
|
||||
3. Presentera data tydligt med belopp i SEK om inget annat anges
|
||||
4. Om ett verktyg returnerar tom data, berätta det vänligt (t.ex. "Du har inga obetalda fakturor just nu")
|
||||
5. Avrunda belopp till hela kronor i text, men behåll decimaler i tabeller
|
||||
6. Använd svenska bokföringstermer (verifikation, kontering, resultaträkning, etc.)
|
||||
7. Förklara kort vad siffrorna betyder i kontext — var pedagogisk
|
||||
|
||||
## Formatering:
|
||||
- Använd markdown: **fetstil** för belopp, punktlistor för detaljer
|
||||
- ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller. Data visas automatiskt i en visuell komponent nedanför ditt svar
|
||||
- Använd punktlistor eller fetstil istället för tabeller
|
||||
- Sammanfatta huvudinsikten först, detaljer sedan
|
||||
- Max 3-4 meningar för enkla frågor, mer för rapporter
|
||||
- Använd inte emojis
|
||||
|
||||
## Tidigare konversation:
|
||||
{history}`
|
||||
|
||||
/**
|
||||
* System prompt for hybrid route: RAG context + tools.
|
||||
*/
|
||||
export const SYSTEM_PROMPT_HYBRID = `Du är en expert AI-assistent i en svensk ekonomiplattform. Du har tillgång till verktyg som hämtar användarens bokföringsdata, samt kunskap om svenska skatteregler.
|
||||
|
||||
## Kunskapsområden:
|
||||
- Svensk skattlagstiftning, moms, bokföring (BAS-kontoplanen)
|
||||
- Avdrag, egenavgifter, socialförsäkring
|
||||
- Fakturering, NE-bilaga, inkomstdeklaration
|
||||
|
||||
## Viktiga tröskelvärden:
|
||||
- Momsregistrering: 120 000 kr/12 mån
|
||||
- Direktavdrag: 26 250 kr
|
||||
- Friskvårdsbidrag: 6 000 kr/år
|
||||
|
||||
## Instruktioner:
|
||||
1. Svara alltid på svenska med korrekt terminologi
|
||||
2. Använd verktygen för att hämta data — gissa aldrig siffror
|
||||
3. Kombinera data med regelkunskap för att ge kontextuella råd
|
||||
4. Om du är osäker, rekommendera att konsultera en revisor
|
||||
5. Formatera tydligt med markdown, men ABSOLUT FÖRBJUDET att använda markdown-tabeller (|---|). Använd ALDRIG pipe-tecken för tabeller — data visas automatiskt i en visuell komponent. Använd punktlistor istället. Använd inte emojis
|
||||
|
||||
## Kontext från kunskapsbasen:
|
||||
{context}
|
||||
|
||||
## Tidigare konversation:
|
||||
{history}`
|
||||
|
||||
export function formatContextFromSources(
|
||||
sources: Array<{
|
||||
content: string
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ChatAnthropic } from '@langchain/anthropic'
|
||||
import { CHATBOT_CONFIG } from './config'
|
||||
import type { ChatMessage } from '@/types/chat'
|
||||
|
||||
export type RouteType = 'knowledge' | 'data' | 'hybrid'
|
||||
|
||||
export interface RouterResult {
|
||||
route: RouteType
|
||||
rewrittenQuery: string
|
||||
}
|
||||
|
||||
// Swedish data-related keywords for fast-path heuristic
|
||||
const DATA_NOUNS = [
|
||||
'faktura', 'fakturor', 'fakturorna',
|
||||
'leverantörsfaktura', 'leverantörsfakturor',
|
||||
'transaktion', 'transaktioner', 'transaktionerna',
|
||||
'verifikation', 'verifikationer', 'verifikationerna',
|
||||
'resultaträkning', 'balansräkning',
|
||||
'moms', 'momsdeklaration', 'momssammanställning',
|
||||
'saldo', 'saldon', 'kontosaldo',
|
||||
'konto', 'konton', 'kontona',
|
||||
'kunder', 'kundfordringar',
|
||||
'leverantörsskulder',
|
||||
'intäkter', 'kostnader', 'utgifter',
|
||||
'resultat', 'årsresultat',
|
||||
'bokföring', 'bokförda', 'obokförda',
|
||||
'obetalda', 'förfallna',
|
||||
'nyckeltal', 'företaget', 'företagsinfo',
|
||||
]
|
||||
|
||||
const POSSESSIVE_PRONOUNS = ['mina', 'min', 'mitt', 'mig', 'våra', 'vår', 'vårt']
|
||||
|
||||
const KNOWLEDGE_TERMS = [
|
||||
'momsgransen', 'momsgränsen', 'avdrag', 'skatteregler',
|
||||
'bokföringslag', 'bokföringslagen', 'regler', 'lag',
|
||||
'hur fungerar', 'vad innebär', 'vad betyder', 'vad är',
|
||||
'när måste', 'hur räknar', 'hur beräknar',
|
||||
'enskild firma', 'aktiebolag', 'egenavgifter',
|
||||
'prisbasbelopp', 'schablonavdrag', 'representation',
|
||||
'friskvårdsbidrag', 'traktamente',
|
||||
]
|
||||
|
||||
/**
|
||||
* Fast-path keyword heuristic. Returns a route if confident, null otherwise.
|
||||
*/
|
||||
function heuristicClassify(query: string): RouteType | null {
|
||||
const lower = query.toLowerCase()
|
||||
const words = lower.split(/\s+/)
|
||||
|
||||
const hasPossessive = POSSESSIVE_PRONOUNS.some((p) => words.includes(p))
|
||||
const hasDataNoun = DATA_NOUNS.some((n) => lower.includes(n))
|
||||
const hasKnowledgeTerm = KNOWLEDGE_TERMS.some((t) => lower.includes(t))
|
||||
|
||||
// "Visa mina fakturor" — clearly data
|
||||
if (hasPossessive && hasDataNoun && !hasKnowledgeTerm) return 'data'
|
||||
|
||||
// Action verbs with data nouns
|
||||
const actionVerbs = ['visa', 'hämta', 'lista', 'sök', 'hitta', 'hur går', 'hur ser', 'hur mycket', 'hur många', 'vilka']
|
||||
const hasAction = actionVerbs.some((v) => lower.includes(v))
|
||||
if (hasAction && hasDataNoun && !hasKnowledgeTerm) return 'data'
|
||||
|
||||
// Pure knowledge question with no data references
|
||||
if (hasKnowledgeTerm && !hasPossessive && !hasDataNoun) return 'knowledge'
|
||||
|
||||
// "Hur ser min resultaträkning ut?" — data (has possessive + data noun)
|
||||
if (hasPossessive && hasDataNoun && hasKnowledgeTerm) return 'hybrid'
|
||||
|
||||
return null // ambiguous → fall through to LLM
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-based classification + query rewrite for multi-turn context.
|
||||
*/
|
||||
async function llmClassify(
|
||||
query: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
): Promise<RouterResult> {
|
||||
const model = new ChatAnthropic({
|
||||
modelName: CHATBOT_CONFIG.routerModel,
|
||||
maxTokens: 256,
|
||||
temperature: 0,
|
||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
|
||||
})
|
||||
|
||||
const historyContext = conversationHistory
|
||||
.slice(-4)
|
||||
.map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content.slice(0, 200)}`)
|
||||
.join('\n')
|
||||
|
||||
const prompt = `Classify the user's question and rewrite it for a data query system.
|
||||
|
||||
Conversation history:
|
||||
${historyContext || '(none)'}
|
||||
|
||||
User question: "${query}"
|
||||
|
||||
Classification rules:
|
||||
- "knowledge": General questions about Swedish tax law, accounting rules, regulations (no user-specific data needed)
|
||||
- "data": Questions about the user's own accounting data (invoices, transactions, balances, reports)
|
||||
- "hybrid": Questions that need both user data AND knowledge context
|
||||
|
||||
Rewriting rules:
|
||||
- Resolve pronouns ("dem", "de", "den") using conversation history
|
||||
- Make the query self-contained (no context needed to understand it)
|
||||
- If it's a knowledge question, keep the original query
|
||||
|
||||
Respond ONLY with valid JSON:
|
||||
{"route": "knowledge"|"data"|"hybrid", "rewrittenQuery": "..."}
|
||||
`
|
||||
|
||||
try {
|
||||
const response = await model.invoke(prompt)
|
||||
const text = typeof response.content === 'string'
|
||||
? response.content
|
||||
: JSON.stringify(response.content)
|
||||
|
||||
// Extract JSON from response
|
||||
const jsonMatch = text.match(/\{[^}]+\}/)
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
const route = ['knowledge', 'data', 'hybrid'].includes(parsed.route)
|
||||
? (parsed.route as RouteType)
|
||||
: 'hybrid'
|
||||
return {
|
||||
route,
|
||||
rewrittenQuery: parsed.rewrittenQuery || query,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Router LLM classification failed, defaulting to hybrid:', e)
|
||||
}
|
||||
|
||||
return { route: 'hybrid', rewrittenQuery: query }
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a user message: fast-path heuristic first, LLM fallback for ambiguous cases.
|
||||
*/
|
||||
export async function routeMessage(
|
||||
query: string,
|
||||
conversationHistory: ChatMessage[]
|
||||
): Promise<RouterResult> {
|
||||
const heuristicResult = heuristicClassify(query)
|
||||
|
||||
if (heuristicResult) {
|
||||
// For data/hybrid with conversation history, still rewrite the query for context
|
||||
if (heuristicResult !== 'knowledge' && conversationHistory.length > 0) {
|
||||
const { rewrittenQuery } = await llmClassify(query, conversationHistory)
|
||||
return { route: heuristicResult, rewrittenQuery }
|
||||
}
|
||||
return { route: heuristicResult, rewrittenQuery: query }
|
||||
}
|
||||
|
||||
// Ambiguous — use LLM
|
||||
return llmClassify(query, conversationHistory)
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import { tool } from '@langchain/core/tools'
|
||||
import { z } from 'zod'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Extract name from a Supabase join result (could be object or array).
|
||||
*/
|
||||
function extractName(joined: unknown): string | null {
|
||||
if (!joined) return null
|
||||
if (Array.isArray(joined)) {
|
||||
return joined[0]?.name ?? null
|
||||
}
|
||||
if (typeof joined === 'object' && 'name' in joined) {
|
||||
return (joined as { name: string }).name
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current fiscal period for a user. Falls back to latest period.
|
||||
*/
|
||||
async function resolveCurrentPeriod(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
fiscalPeriodId?: string
|
||||
): Promise<{ id: string; start: string; end: string } | null> {
|
||||
if (fiscalPeriodId) {
|
||||
const { data } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
|
||||
}
|
||||
|
||||
// Default: latest open period, or just the latest period
|
||||
const { data } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, period_start, period_end, is_closed')
|
||||
.eq('user_id', userId)
|
||||
.order('period_start', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (data) return { id: data.id, start: data.period_start, end: data.period_end }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all 10 accounting tools bound to a specific Supabase client and user.
|
||||
*/
|
||||
export function createAccountingTools(supabase: SupabaseClient, userId: string) {
|
||||
const getInvoices = tool(
|
||||
async ({ status, customer_name, date_from, date_to, limit }) => {
|
||||
let query = supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, total, paid_amount, currency, vat_amount, customer:customers(name)')
|
||||
.eq('user_id', userId)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (status) query = query.eq('status', status)
|
||||
if (customer_name) query = query.ilike('customers.name', `%${customer_name}%`)
|
||||
if (date_from) query = query.gte('invoice_date', date_from)
|
||||
if (date_to) query = query.lte('invoice_date', date_to)
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('invoices')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
|
||||
const { data: invoices, error: fetchError } = await query
|
||||
|
||||
if (fetchError) return `Fel vid hämtning av fakturor: ${fetchError.message}`
|
||||
if (!invoices || invoices.length === 0) return 'Inga fakturor hittades.'
|
||||
|
||||
const result = invoices.map((inv) => ({
|
||||
invoice_number: inv.invoice_number,
|
||||
date: inv.invoice_date,
|
||||
due_date: inv.due_date,
|
||||
status: inv.status,
|
||||
total: inv.total,
|
||||
paid: inv.paid_amount || 0,
|
||||
currency: inv.currency || 'SEK',
|
||||
vat: inv.vat_amount || 0,
|
||||
customer: extractName(inv.customer) || 'Okänd',
|
||||
}))
|
||||
|
||||
const summary: Record<string, unknown> = { invoices: result }
|
||||
if (count && count > limit) {
|
||||
summary.note = `Visar ${result.length} av totalt ${count} fakturor.`
|
||||
}
|
||||
return JSON.stringify(summary)
|
||||
},
|
||||
{
|
||||
name: 'get_invoices',
|
||||
description: 'Hämtar användarens försäljningsfakturor (kundfakturor). Kan filtrera på status, kundnamn och datumintervall.',
|
||||
schema: z.object({
|
||||
status: z.enum(['draft', 'sent', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på fakturastatus'),
|
||||
customer_name: z.string().optional().describe('Sök på kundnamn (delmatchning)'),
|
||||
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
|
||||
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
|
||||
limit: z.number().max(20).default(10).describe('Max antal fakturor att returnera'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getSupplierInvoices = tool(
|
||||
async ({ status, supplier_name, overdue_only, limit }) => {
|
||||
let query = supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, remaining_amount, currency, vat_amount, supplier:suppliers(name)')
|
||||
.eq('user_id', userId)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (status) query = query.eq('status', status)
|
||||
if (overdue_only) query = query.eq('status', 'overdue')
|
||||
if (supplier_name) query = query.ilike('suppliers.name', `%${supplier_name}%`)
|
||||
|
||||
const { data: invoices, error } = await query
|
||||
|
||||
if (error) return `Fel vid hämtning av leverantörsfakturor: ${error.message}`
|
||||
if (!invoices || invoices.length === 0) return 'Inga leverantörsfakturor hittades.'
|
||||
|
||||
const result = invoices.map((inv) => ({
|
||||
number: inv.supplier_invoice_number,
|
||||
date: inv.invoice_date,
|
||||
due_date: inv.due_date,
|
||||
status: inv.status,
|
||||
total: inv.total,
|
||||
remaining: inv.remaining_amount || 0,
|
||||
currency: inv.currency || 'SEK',
|
||||
vat: inv.vat_amount || 0,
|
||||
supplier: extractName(inv.supplier) || 'Okänd',
|
||||
}))
|
||||
|
||||
return JSON.stringify({ supplier_invoices: result })
|
||||
},
|
||||
{
|
||||
name: 'get_supplier_invoices',
|
||||
description: 'Hämtar användarens leverantörsfakturor (inköpsfakturor). Kan filtrera på status, leverantörsnamn och förfallodag.',
|
||||
schema: z.object({
|
||||
status: z.enum(['registered', 'approved', 'partially_paid', 'paid', 'overdue', 'cancelled']).optional().describe('Filtrera på status'),
|
||||
supplier_name: z.string().optional().describe('Sök på leverantörsnamn (delmatchning)'),
|
||||
overdue_only: z.boolean().optional().describe('Visa bara förfallna fakturor'),
|
||||
limit: z.number().max(20).default(10).describe('Max antal fakturor'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getAccountBalances = tool(
|
||||
async ({ account_numbers, account_class, fiscal_period_id }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||
|
||||
const { generateTrialBalance } = await import('@/lib/reports/trial-balance')
|
||||
const { rows } = await generateTrialBalance(supabase, userId, period.id)
|
||||
|
||||
let filtered = rows
|
||||
if (account_numbers && account_numbers.length > 0) {
|
||||
filtered = rows.filter((r) => account_numbers.includes(r.account_number))
|
||||
} else if (account_class) {
|
||||
filtered = rows.filter((r) => r.account_class === account_class)
|
||||
}
|
||||
|
||||
if (filtered.length === 0) return 'Inga konton med saldo hittades.'
|
||||
|
||||
const result = filtered.map((r) => ({
|
||||
account: r.account_number,
|
||||
name: r.account_name,
|
||||
debit: r.closing_debit,
|
||||
credit: r.closing_credit,
|
||||
balance: r.closing_debit - r.closing_credit,
|
||||
}))
|
||||
|
||||
return JSON.stringify({
|
||||
period: `${period.start} – ${period.end}`,
|
||||
accounts: result,
|
||||
total_debit: Math.round(result.reduce((s, r) => s + r.debit, 0) * 100) / 100,
|
||||
total_credit: Math.round(result.reduce((s, r) => s + r.credit, 0) * 100) / 100,
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_account_balances',
|
||||
description: 'Hämtar saldon för BAS-konton. Kan filtrera på kontonummer eller kontoklass (1=tillgångar, 2=skulder, 3=intäkter, 4-7=kostnader, 8=finansiella).',
|
||||
schema: z.object({
|
||||
account_numbers: z.array(z.string()).optional().describe('Specifika kontonummer att hämta'),
|
||||
account_class: z.number().min(1).max(8).optional().describe('Kontoklass 1-8'),
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getTransactions = tool(
|
||||
async ({ uncategorized_only, description, date_from, date_to, limit }) => {
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency, category, is_business, merchant_name, journal_entry_id')
|
||||
.eq('user_id', userId)
|
||||
.order('date', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (uncategorized_only) query = query.is('journal_entry_id', null)
|
||||
if (description) query = query.ilike('description', `%${description}%`)
|
||||
if (date_from) query = query.gte('date', date_from)
|
||||
if (date_to) query = query.lte('date', date_to)
|
||||
|
||||
const { data: transactions, error } = await query
|
||||
|
||||
if (error) return `Fel vid hämtning av transaktioner: ${error.message}`
|
||||
if (!transactions || transactions.length === 0) return 'Inga transaktioner hittades.'
|
||||
|
||||
const result = transactions.map((tx) => ({
|
||||
date: tx.date,
|
||||
description: tx.description,
|
||||
amount: tx.amount,
|
||||
currency: tx.currency || 'SEK',
|
||||
category: tx.category,
|
||||
is_business: tx.is_business,
|
||||
merchant: tx.merchant_name,
|
||||
booked: !!tx.journal_entry_id,
|
||||
}))
|
||||
|
||||
return JSON.stringify({ transactions: result })
|
||||
},
|
||||
{
|
||||
name: 'get_transactions',
|
||||
description: 'Hämtar användarens banktransaktioner. Kan filtrera på obokförda, beskrivning (textsökning) och datumintervall.',
|
||||
schema: z.object({
|
||||
uncategorized_only: z.boolean().optional().describe('Visa bara obokförda transaktioner'),
|
||||
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
|
||||
date_from: z.string().optional().describe('Startdatum (YYYY-MM-DD)'),
|
||||
date_to: z.string().optional().describe('Slutdatum (YYYY-MM-DD)'),
|
||||
limit: z.number().max(20).default(10).describe('Max antal transaktioner'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getJournalEntries = tool(
|
||||
async ({ limit, fiscal_period_id, account_number, description }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
|
||||
let query = supabase
|
||||
.from('journal_entries')
|
||||
.select('id, voucher_number, entry_date, description, status, source_type')
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'posted')
|
||||
.order('voucher_number', { ascending: false })
|
||||
.limit(limit)
|
||||
|
||||
if (period) query = query.eq('fiscal_period_id', period.id)
|
||||
if (description) query = query.ilike('description', `%${description}%`)
|
||||
|
||||
const { data: entries, error } = await query
|
||||
|
||||
if (error) return `Fel vid hämtning av verifikationer: ${error.message}`
|
||||
if (!entries || entries.length === 0) return 'Inga verifikationer hittades.'
|
||||
|
||||
// Fetch lines for these entries
|
||||
const entryIds = entries.map((e) => e.id)
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('journal_entry_id, account_number, debit_amount, credit_amount, line_description')
|
||||
.in('journal_entry_id', entryIds)
|
||||
|
||||
// If filtering by account, only include entries with matching lines
|
||||
let filteredEntries = entries
|
||||
if (account_number && lines) {
|
||||
const matchingEntryIds = new Set(
|
||||
lines.filter((l) => l.account_number === account_number).map((l) => l.journal_entry_id)
|
||||
)
|
||||
filteredEntries = entries.filter((e) => matchingEntryIds.has(e.id))
|
||||
}
|
||||
|
||||
const linesByEntry = new Map<string, typeof lines>()
|
||||
for (const line of lines || []) {
|
||||
const group = linesByEntry.get(line.journal_entry_id) || []
|
||||
group.push(line)
|
||||
linesByEntry.set(line.journal_entry_id, group)
|
||||
}
|
||||
|
||||
const result = filteredEntries.map((e) => ({
|
||||
voucher: e.voucher_number,
|
||||
date: e.entry_date,
|
||||
description: e.description,
|
||||
source: e.source_type,
|
||||
lines: (linesByEntry.get(e.id) || []).map((l) => ({
|
||||
account: l.account_number,
|
||||
debit: l.debit_amount,
|
||||
credit: l.credit_amount,
|
||||
text: l.line_description,
|
||||
})),
|
||||
}))
|
||||
|
||||
return JSON.stringify({ journal_entries: result })
|
||||
},
|
||||
{
|
||||
name: 'get_journal_entries',
|
||||
description: 'Hämtar bokförda verifikationer med konteringsrader. Kan filtrera på kontonummer, beskrivning och räkenskapsperiod.',
|
||||
schema: z.object({
|
||||
limit: z.number().max(20).default(10).describe('Max antal verifikationer'),
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID'),
|
||||
account_number: z.string().optional().describe('Filtrera på kontonummer i rader'),
|
||||
description: z.string().optional().describe('Sök i beskrivning (delmatchning)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getIncomeStatement = tool(
|
||||
async ({ fiscal_period_id }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||
|
||||
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
|
||||
const report = await generateIncomeStatement(supabase, userId, period.id)
|
||||
|
||||
const sections = [
|
||||
...report.revenue_sections.map((s) => ({
|
||||
category: 'Intäkter',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
...report.expense_sections.map((s) => ({
|
||||
category: 'Kostnader',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
...report.financial_sections.map((s) => ({
|
||||
category: 'Finansiella poster',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
]
|
||||
|
||||
return JSON.stringify({
|
||||
period: `${period.start} – ${period.end}`,
|
||||
total_revenue: report.total_revenue,
|
||||
total_expenses: report.total_expenses,
|
||||
total_financial: report.total_financial,
|
||||
net_result: report.net_result,
|
||||
sections,
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_income_statement',
|
||||
description: 'Hämtar resultaträkning med intäkter, kostnader och årets resultat. Visar alla kontona grupperade i sektioner.',
|
||||
schema: z.object({
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getBalanceSheet = tool(
|
||||
async ({ fiscal_period_id }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||
|
||||
const { generateBalanceSheet } = await import('@/lib/reports/balance-sheet')
|
||||
const report = await generateBalanceSheet(supabase, userId, period.id)
|
||||
|
||||
const sections = [
|
||||
...report.asset_sections.map((s) => ({
|
||||
category: 'Tillgångar',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
...report.equity_liability_sections.map((s) => ({
|
||||
category: 'Eget kapital & skulder',
|
||||
title: s.title,
|
||||
amount: s.subtotal,
|
||||
accounts: s.rows.map((r) => ({ account: r.account_number, name: r.account_name, amount: r.amount })),
|
||||
})),
|
||||
]
|
||||
|
||||
return JSON.stringify({
|
||||
period: `${period.start} – ${period.end}`,
|
||||
total_assets: report.total_assets,
|
||||
total_equity_liabilities: report.total_equity_liabilities,
|
||||
balanced: Math.abs(report.total_assets - report.total_equity_liabilities) < 0.01,
|
||||
sections,
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_balance_sheet',
|
||||
description: 'Hämtar balansräkning med tillgångar, eget kapital och skulder.',
|
||||
schema: z.object({
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getVatSummary = tool(
|
||||
async ({ fiscal_period_id }) => {
|
||||
const period = await resolveCurrentPeriod(supabase, userId, fiscal_period_id)
|
||||
if (!period) return 'Ingen räkenskapsperiod hittades.'
|
||||
|
||||
// Get company settings for moms period type
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('moms_period')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const periodType = settings?.moms_period || 'quarterly'
|
||||
const startDate = new Date(period.start)
|
||||
const year = startDate.getFullYear()
|
||||
let periodNum = 1
|
||||
if (periodType === 'monthly') {
|
||||
periodNum = startDate.getMonth() + 1
|
||||
} else if (periodType === 'quarterly') {
|
||||
periodNum = Math.ceil((startDate.getMonth() + 1) / 3)
|
||||
}
|
||||
|
||||
const { calculateVatDeclaration, getVatDeclarationSummary } = await import('@/lib/reports/vat-declaration')
|
||||
const declaration = await calculateVatDeclaration(supabase, userId, periodType, year, periodNum)
|
||||
const summary = getVatDeclarationSummary(declaration)
|
||||
|
||||
return JSON.stringify({
|
||||
period: `${period.start} – ${period.end}`,
|
||||
output_vat_25: declaration.rutor.ruta05,
|
||||
output_vat_12: declaration.rutor.ruta06,
|
||||
output_vat_6: declaration.rutor.ruta07,
|
||||
total_output_vat: summary.totalOutputVat,
|
||||
input_vat: summary.totalInputVat,
|
||||
vat_to_pay: summary.vatToPay,
|
||||
is_refund: summary.isRefund,
|
||||
revenue_basis_25: declaration.rutor.ruta10,
|
||||
revenue_basis_12: declaration.rutor.ruta11,
|
||||
revenue_basis_6: declaration.rutor.ruta12,
|
||||
invoice_count: declaration.invoiceCount,
|
||||
transaction_count: declaration.transactionCount,
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_vat_summary',
|
||||
description: 'Hämtar momssammanställning med utgående moms, ingående moms och moms att betala/återfå.',
|
||||
schema: z.object({
|
||||
fiscal_period_id: z.string().optional().describe('Räkenskapsperiod-ID (standard: aktuell period)'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const getCompanyOverview = tool(
|
||||
async () => {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (!settings) return 'Inga företagsinställningar hittades.'
|
||||
|
||||
// Get quick KPIs
|
||||
const period = await resolveCurrentPeriod(supabase, userId)
|
||||
|
||||
const [
|
||||
{ count: invoiceCount },
|
||||
{ count: unpaidCount },
|
||||
{ count: txCount },
|
||||
{ count: unbookedCount },
|
||||
] = await Promise.all([
|
||||
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('user_id', userId),
|
||||
supabase.from('invoices').select('id', { count: 'exact', head: true }).eq('user_id', userId).in('status', ['sent', 'overdue']),
|
||||
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('user_id', userId),
|
||||
supabase.from('transactions').select('id', { count: 'exact', head: true }).eq('user_id', userId).is('journal_entry_id', null),
|
||||
])
|
||||
|
||||
let netResult: number | null = null
|
||||
if (period) {
|
||||
try {
|
||||
const { generateIncomeStatement } = await import('@/lib/reports/income-statement')
|
||||
const report = await generateIncomeStatement(supabase, userId, period.id)
|
||||
netResult = report.net_result
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
company: {
|
||||
name: settings.company_name,
|
||||
entity_type: settings.entity_type,
|
||||
org_number: settings.org_number,
|
||||
vat_registered: settings.vat_registered,
|
||||
accounting_method: settings.accounting_method,
|
||||
moms_period: settings.moms_period,
|
||||
},
|
||||
kpis: {
|
||||
total_invoices: invoiceCount || 0,
|
||||
unpaid_invoices: unpaidCount || 0,
|
||||
total_transactions: txCount || 0,
|
||||
unbooked_transactions: unbookedCount || 0,
|
||||
...(netResult !== null ? { net_result: netResult } : {}),
|
||||
...(period ? { current_period: `${period.start} – ${period.end}` } : {}),
|
||||
},
|
||||
})
|
||||
},
|
||||
{
|
||||
name: 'get_company_overview',
|
||||
description: 'Hämtar företagsinformation och nyckeltal (KPIs): antal fakturor, obetalda fakturor, transaktioner, obokförda transaktioner, årets resultat.',
|
||||
schema: z.object({}),
|
||||
}
|
||||
)
|
||||
|
||||
const getAgingReport = tool(
|
||||
async ({ type, limit }) => {
|
||||
if (type === 'receivable') {
|
||||
const { generateARLedger } = await import('@/lib/reports/ar-ledger')
|
||||
const report = await generateARLedger(supabase, userId)
|
||||
|
||||
if (report.entries.length === 0) return 'Inga utestående kundfordringar.'
|
||||
|
||||
const entries = report.entries.slice(0, limit).map((e) => ({
|
||||
name: e.customer_name,
|
||||
current: e.current,
|
||||
'1_30': e.days_1_30,
|
||||
'31_60': e.days_31_60,
|
||||
'61_90': e.days_61_90,
|
||||
'90_plus': e.days_90_plus,
|
||||
total: e.total_outstanding,
|
||||
}))
|
||||
|
||||
return JSON.stringify({
|
||||
type: 'receivable',
|
||||
total_outstanding: report.total_outstanding,
|
||||
total_current: report.total_current,
|
||||
total_overdue: report.total_overdue,
|
||||
unpaid_count: report.unpaid_count,
|
||||
entries,
|
||||
})
|
||||
} else {
|
||||
const { generateSupplierLedger } = await import('@/lib/reports/supplier-ledger')
|
||||
const report = await generateSupplierLedger(supabase, userId)
|
||||
|
||||
if (report.entries.length === 0) return 'Inga utestående leverantörsskulder.'
|
||||
|
||||
const entries = report.entries.slice(0, limit).map((e) => ({
|
||||
name: e.supplier_name,
|
||||
current: e.current,
|
||||
'1_30': e.days_1_30,
|
||||
'31_60': e.days_31_60,
|
||||
'61_90': e.days_61_90,
|
||||
'90_plus': e.days_90_plus,
|
||||
total: e.total_outstanding,
|
||||
}))
|
||||
|
||||
return JSON.stringify({
|
||||
type: 'payable',
|
||||
total_outstanding: report.total_outstanding,
|
||||
total_current: report.total_current,
|
||||
total_overdue: report.total_overdue,
|
||||
unpaid_count: report.unpaid_count,
|
||||
entries,
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_aging_report',
|
||||
description: 'Hämtar åldersanalys för kundfordringar (receivable) eller leverantörsskulder (payable). Visar utestående belopp uppdelat i ålderskategorier.',
|
||||
schema: z.object({
|
||||
type: z.enum(['receivable', 'payable']).describe("'receivable' för kundfordringar, 'payable' för leverantörsskulder"),
|
||||
limit: z.number().max(20).default(10).describe('Max antal poster'),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
getInvoices,
|
||||
getSupplierInvoices,
|
||||
getAccountBalances,
|
||||
getTransactions,
|
||||
getJournalEntries,
|
||||
getIncomeStatement,
|
||||
getBalanceSheet,
|
||||
getVatSummary,
|
||||
getCompanyOverview,
|
||||
getAgingReport,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { CallbackHandler } from '@langfuse/langchain'
|
||||
|
||||
let langfuseConfigured: boolean | null = null
|
||||
|
||||
function isLangfuseConfigured(): boolean {
|
||||
if (langfuseConfigured !== null) return langfuseConfigured
|
||||
langfuseConfigured = !!(
|
||||
process.env.LANGFUSE_SECRET_KEY &&
|
||||
process.env.LANGFUSE_PUBLIC_KEY
|
||||
)
|
||||
return langfuseConfigured
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Langfuse callback handler for LangChain tracing.
|
||||
* Returns null if Langfuse is not configured (graceful degradation).
|
||||
*/
|
||||
export function createTraceHandler(options: {
|
||||
sessionId?: string
|
||||
userId?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}): CallbackHandler | null {
|
||||
if (!isLangfuseConfigured()) return null
|
||||
|
||||
try {
|
||||
return new CallbackHandler({
|
||||
sessionId: options.sessionId,
|
||||
userId: options.userId,
|
||||
})
|
||||
} catch {
|
||||
console.warn('Failed to create Langfuse handler, tracing disabled')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush Langfuse handler. Safe to call with null.
|
||||
*/
|
||||
export async function flushTrace(handler: CallbackHandler | null): Promise<void> {
|
||||
if (!handler) return
|
||||
try {
|
||||
// Langfuse CallbackHandler may expose flush via different methods
|
||||
if ('shutdownAsync' in handler && typeof handler.shutdownAsync === 'function') {
|
||||
await handler.shutdownAsync()
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — tracing failure should never block response
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,17 @@
|
||||
"entryPoint": "@/extensions/general/ai-chat",
|
||||
"workspace": "@/components/extensions/general/AiChatWorkspace",
|
||||
"requiredEnvVars": ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
"optionalEnvVars": [],
|
||||
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai"],
|
||||
"optionalEnvVars": ["LANGFUSE_SECRET_KEY", "LANGFUSE_PUBLIC_KEY", "LANGFUSE_BASE_URL"],
|
||||
"npmDependencies": ["@langchain/anthropic", "@langchain/core", "langchain", "@langchain/openai", "@langchain/langgraph", "@langfuse/core", "@langfuse/langchain"],
|
||||
"definition": {
|
||||
"name": "AI-assistent",
|
||||
"category": "operations",
|
||||
"icon": "MessageSquare",
|
||||
"dataPattern": "manual",
|
||||
"dataPattern": "both",
|
||||
"readsCoreTables": ["invoices", "supplier_invoices", "transactions", "journal_entries", "journal_entry_lines", "fiscal_periods", "company_settings", "customers", "suppliers", "chart_of_accounts"],
|
||||
"hasOwnData": true,
|
||||
"description": "AI-assistent för skatte- och bokföringsfrågor",
|
||||
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.",
|
||||
"description": "AI-assistent för skatte- och bokföringsfrågor med tillgång till din data",
|
||||
"longDescription": "Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Kan hämta och visualisera din bokföringsdata — fakturor, transaktioner, resultaträkning, balansräkning och mer.",
|
||||
"quickAction": {
|
||||
"label": "AI-assistent",
|
||||
"description": "Fråga om bokföring",
|
||||
|
||||
Reference in New Issue
Block a user