Files
accounted/lib/agent/ask/snapshot.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

131 lines
5.0 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { getDeadlinesNeedingAttention } from '@/lib/deadlines/status-engine'
import {
loadActiveEmployeeCount,
resolveEmployeeFacts,
type EmployeeVerdict,
} from '@/lib/agent/composer/employee-facts'
import { loadFiscalYearInventory, renderFiscalYearInventory } from '@/lib/agent/fiscal-years'
/**
* 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
}
/**
* The salary part of the status line, or null when nothing is known.
*
* company_settings.pays_salaries is NOT NULL DEFAULT false and its only
* writer is the Skatt settings form, so `false` is what every company that
* never opened that form reads, whatever its ledger says. Stating "betalar
* inte löner" from that default told a payroll-running aktiebolag in every
* answer that it had no salaries (support case 2026-09-04). The same doctrine
* as the composer (lib/agent/composer/employee-facts.ts): positive evidence
* (active employees, the flag, an attested employer registration) yields the
* fact, only an attested negative yields the negative, and the default
* yields nothing.
*/
function salaryPart(verdict: EmployeeVerdict): string | null {
switch (verdict.kind) {
case 'count':
return `betalar löner (${verdict.count} ${verdict.count === 1 ? 'anställd' : 'anställda'})`
case 'employer':
return verdict.isEmployer ? 'betalar löner' : 'betalar inte löner'
default:
return null
}
}
export async function buildAssistantSnapshot(
supabase: SupabaseClient,
companyId: string,
): Promise<string> {
const lines: string[] = []
// Kicked off with the first round so it costs no extra latency; never rejects.
const fiscalYears = loadFiscalYearInventory(supabase, companyId)
try {
const [{ data }, activeEmployees] = await Promise.all([
supabase
.from('company_settings')
.select('vat_registered, moms_period, accounting_method, pays_salaries, employer_registered')
.eq('company_id', companyId)
.maybeSingle(),
loadActiveEmployeeCount(supabase, companyId),
])
const row = data as {
vat_registered?: boolean | null
moms_period?: string | null
accounting_method?: string | null
pays_salaries?: boolean | null
employer_registered?: 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}`)
const salary = salaryPart(
resolveEmployeeFacts({
activeEmployees,
ticEmployeeRange: null,
employerRegistered: row.employer_registered ?? null,
paysSalaries: row.pays_salaries ?? null,
}),
)
if (salary) parts.push(salary)
lines.push(`Status: ${parts.join(', ')}.`)
// So the model can point the user at the right page instead of
// "inställningarna" in general when a value here is wrong.
lines.push(
'Grunduppgifterna ovan ändras under Inställningar > Skatt (moms, löner) och Inställningar > Bokföring (bokföringsmetod).',
)
}
} 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
}
// Which years the ledger holds and how to address them: a company with
// several imported years otherwise reads as single-year (#2185).
const fiscalYearLine = renderFiscalYearInventory(await fiscalYears)
if (fiscalYearLine) lines.push(fiscalYearLine)
return lines.join('\n')
}