ff4425d10e
The /chat assistant (audit Option A / rip) shipped in #1759 reading only the company name + entity type, so it answered "jag har ingen bokföringsdata" to every figures question ("vad är min största utgiftspost?"). It now behaves like an MCP client: it answers over a bounded, READ-only tool loop across the same MCP read tools the old streaming assistant had, plus an always-on company snapshot as the backstop. Provider-agnostic by construction, so it still runs on a local model: - lib/ai generateText gains optional `tools` + `maxSteps`. The OpenAI-compatible service forwards them to the Vercel AI SDK (stopWhen: stepCountIs), which runs the loop; the Anthropic-family service hand-rolls a small loop against messages.create. Kept on the raw Anthropic SDK: no new deps, and the no-tools path is byte-identical, so hosted extraction/composer/etc. are unchanged. - lib/agent/ask/ledger-tools.ts: the read slice of general.help's whitelist (income statement, VAT, ledgers, query_journal, reskontror, lists…) from agentToolRegistry, dispatched with the agent_chat actor run-turn uses. Write/ staging + memory-write tools are excluded; readOnlyHint/destructiveHint are re-checked. Empty in a core-only build → snapshot-only, graceful. - lib/agent/ask/snapshot.ts: a compact company_settings + deadlines block so a model that can't/won't call tools still answers status questions. Never carries figures (those come from the live tools). - ask-service attaches tools + snapshot when a userId is present and uses a tool-aware system prompt; the route calls ensureInitialized() so the registry is populated and threads userId/conversationId through. Works on Bedrock and on any local model with function-calling (Qwen). Tests: the anthropic hand-rolled loop (tool call → result → answer, is_error handling, step-budget forced answer), openai tool forwarding, the read-only adapter filter, the snapshot format, and the ask-service wiring. 457 agent+ai tests green, lint/guards clean. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
77 lines
2.7 KiB
TypeScript
77 lines
2.7 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { getDeadlinesNeedingAttention } from '@/lib/deadlines/status-engine'
|
|
|
|
/**
|
|
* A compact, always-on grounding block for the single-call assistant.
|
|
*
|
|
* This is the reliability backstop for the "MCP tools + snapshot" design: it
|
|
* lets a model that does NOT call tools (a weaker local model, or one whose
|
|
* function-calling is off) still answer the standing-status questions from
|
|
* this block alone. It carries the company's standing profile and the
|
|
* deadlines that need attention: it deliberately does NOT carry figures
|
|
* (result, expenses, VAT amounts, transactions), which come from the read
|
|
* tools so they are always live and never stale in a prompt.
|
|
*
|
|
* Company-scoped: reads only this company's own rows. Every part is
|
|
* best-effort: a slow or failing query drops that line, never the answer.
|
|
*/
|
|
|
|
function accountingMethodLabel(method: string | null | undefined): string | null {
|
|
if (!method) return null
|
|
const m = method.toLowerCase()
|
|
if (m.includes('cash') || m.includes('kontant')) return 'kontantmetod'
|
|
if (m.includes('accrual') || m.includes('faktura')) return 'fakturametod'
|
|
return method
|
|
}
|
|
|
|
export async function buildAssistantSnapshot(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
): Promise<string> {
|
|
const lines: string[] = []
|
|
|
|
try {
|
|
const { data } = await supabase
|
|
.from('company_settings')
|
|
.select('vat_registered, moms_period, accounting_method, pays_salaries')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
const row = data as {
|
|
vat_registered?: boolean | null
|
|
moms_period?: string | null
|
|
accounting_method?: string | null
|
|
pays_salaries?: boolean | null
|
|
} | null
|
|
if (row) {
|
|
const parts: string[] = []
|
|
parts.push(
|
|
row.vat_registered
|
|
? `momsregistrerad${row.moms_period ? ` (momsperiod: ${row.moms_period})` : ''}`
|
|
: 'ej momsregistrerad',
|
|
)
|
|
const method = accountingMethodLabel(row.accounting_method)
|
|
if (method) parts.push(`bokföringsmetod: ${method}`)
|
|
parts.push(row.pays_salaries ? 'betalar löner' : 'betalar inte löner')
|
|
lines.push(`Status: ${parts.join(', ')}.`)
|
|
}
|
|
} catch {
|
|
// best-effort: skip the status line
|
|
}
|
|
|
|
try {
|
|
const { overdue, actionNeeded } = await getDeadlinesNeedingAttention(supabase, companyId)
|
|
const soon = [...overdue, ...actionNeeded].slice(0, 6)
|
|
if (soon.length > 0) {
|
|
lines.push(
|
|
`Deadlines som behöver åtgärd: ${soon
|
|
.map((d) => `${d.title} (${d.due_date})`)
|
|
.join('; ')}.`,
|
|
)
|
|
}
|
|
} catch {
|
|
// best-effort: skip the deadlines line
|
|
}
|
|
|
|
return lines.join('\n')
|
|
}
|