fix(agent): the assistant reaches earlier räkenskapsår: period resolved from dates, years listed in the grounding (#2436)

Part 3 of #2185. A user with several imported years concluded the assistant
"only reads the period I am standing in". Nothing restricted it: the report
tools defaulted to the most recent fiscal period when no period_id was given
and then rejected any from_date/to_date/as_of_date outside it, and neither
the snapshot nor the chat identity block told the model which years existed
or how to address them.

- extensions/general/mcp-server/server.ts: resolveReportPeriod takes a date
  hint; without period_id, a date in the call resolves the fiscal period that
  contains it (findFiscalPeriodContaining, company-scoped), and a date no
  period covers fails with the company's span instead of the latest year's
  bounds. Income statement (from_date or to_date), balance sheet (as_of_date)
  and dimension P&L (to_date) use it. The range guard is unchanged. No schema
  change: tools/list sits at its token ceiling.
- lib/agent/fiscal-years.ts: one query and one line, "Räkenskapsår (senaste
  först): ... period_id=<uuid> (senaste|avslutat)", plus the rule on
  addressing an earlier year, shared by the single-call snapshot and the
  streaming chat's always-on identity block.
- lib/agent/intents/shared-rules.ts and TOOL_RULES: pass that year's
  period_id, one call per year, and say which räkenskapsår the answer covers.


Claude-Session: https://claude.ai/code/session_0179bdetHyofL6ATfQxB5wP5

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-08 21:32:38 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 20925f6c65
commit 500806f001
13 changed files with 471 additions and 10 deletions
+1
View File
@@ -1675,6 +1675,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-08] #2425 ROT/RUT: the picker's begäran rows keep single-click handoff and gain a checkbox per row for a bundled pick, instead of a new multi-select mode or an ambiguity-tolerant matcher. Why: the server (request_ids, settleRotRutPayoutRequestSet) and the confirm dialog already take N begäran since #2360; only the manual fallback was 1:1, and a firm whose jobs share a price hits the matcher's ambiguity refusal often. Guessing between two same-amount begäran would clear the wrong 1513 fordran, so the choice stays with the user.
[2026-09-08] Issue #2426 ROT/RUT begäran state in the invoice list: normal states (Att begära, Skapad, Uppladdad, Beviljad) render as muted text and only Delvis beviljad and Avslagen get a chip (design convention 5: a fönsterputsfirma where every invoice is ROT/RUT would otherwise carry the same chip on every row); the filter is a third ContextPicker (?rotrut=) gated on rot_rut_enabled or an invoiced deduction, not extra entries in the status view, so a ROT/RUT state can be combined with any status view and companies without ROT/RUT never see it. One predicate (lib/invoices/rot-rut-list-status.ts) feeds the column, the filter and its counts; the state is read from a rot_rut_payout_request_items embed on the existing list query, no new API and no migration.
[2026-09-08] Receipt purchase date: fixed the extraction prompt's description of invoice.invoiceDate (schema comment plus an explicit "invoiceDate on receipts" rule) instead of adding a receipt-specific purchaseDate field. Why: prod (last 30 days) showed receipts losing the date on 46% of items (75% via WhatsApp) while supplier invoices lost it on 0.4%, and purchaseTime was filled on nearly every affected receipt: the date was described as a bare ISO field under the invoice block beside a purchaseTime rule marked "receipts only", and the model read that as "invoice-only". A new field would need a schema change, a UI column and every consumer (WhatsApp M4 ack date, the Bokför entry-date default that falls back to today, the worklist) taught a second date; the prompt fix stops the class with no data-shape change. Existing undated items are not re-extracted by this; a backfill is a separate call.
[2026-09-08] Assistant and earlier räkenskapsår (#2185 part 3): the MCP report tools resolve the fiscal period from a date argument (from_date, to_date, as_of_date) when no period_id is given, instead of defaulting to the most recent period and then rejecting the date; the in-app assistant's snapshot and the chat identity block list the company's räkenskapsår with period_id (lib/agent/fiscal-years.ts) plus one rule on addressing them. No fiscal_year parameter was added to the six report schemas because tools/list sits at its 60 000 token ceiling, and the range guard was kept because it was a deliberate fix for silently dropped ranges.
[2026-09-08] #2187 (a) Att göra row "Betala in till Skatteverket" shows the shortfall (next charge minus the last synced saldo, the full charge only when no snapshot exists) and appears only when something has to be paid in, instead of a deadline-style row for every upcoming charge: a company whose saldo covers the dragning has nothing to do, and the /skattekonto page's Nästa dragning math already defines the number, now computed once in lib/worklist for Hem and /api/worklist/counts. Part (b), a betalfil for the skattekonto payment, stays a follow-up: the existing payment-file route (app/api/skatteverket/tax-payments/[period]/payment-file) is AGI-scoped (amount from agi_declarations, reachable only from a salary run) and needs an amount source before it can pay a general dragning.
[2026-09-08] Issue #2224 offert -> kundorder: the quote source was added to the existing proforma -> order service (lib/sales-orders/convert-to-sales-order.ts) and to gnubok_convert_invoice as a target parameter staged under the existing convert_invoice operation type, instead of a second service, a separate MCP tool and a new pending-operation type. Why: one conversion path and one guard pair (a quote with a live order cannot become an invoice, a quote with a live invoice cannot become an order) keep the proforma and quote flows identical, and reusing the operation type keeps the pending-operations type list untouched. The quote flips to accepted like quote -> invoice does; cancelling it like the proforma path would hide the customer's accepted agreement. Skeptic refutation: for an already-accepted quote that accepted -> accepted compare-and-set cannot detect a concurrent conversion, so the "never twice" guard moved into the database (migration 20260908165000: one live kundorder per source, and two triggers that lock the quote row and refuse a live order beside a live converted invoice and vice versa); the service pre-checks stay for the friendly error. Second refutation: order -> invoice booked a foreign-currency invoice 1:1 when Riksbanken had no rate (pre-existing, but the quote now depends on that path and the fail-closed quote -> invoice route is refused while an order lives), so createInvoiceFromSalesOrder now fails closed with SALES_ORDER_INVOICE_FX_RATE_UNAVAILABLE. Locking the quote decision (open/declined) while a live order exists was left out: the DB trigger only knows about converted invoices and changing it is a migration; a declined quote with a live order is harmless since the order carries the sale.
[2026-09-08] Issue #2224 follow-up from the correctness skeptic: the quote decision (open/declined) is now locked in the database while a live kundorder exists (migration 20260908165100 extends invoices_quote_decision_guard), reversing the earlier call to leave it open; a declined offert behind a confirmed, invoiced order was a contradictory agreement trail and the dashboard hid the re-accept button, so the quote was stuck. The three source and decision guards run as SECURITY DEFINER: a SELECT FOR UPDATE under RLS admits only the caller's active company, so a multi-company member writing for another company through raw PostgREST got no row, no lock and no guard. Both landed as a second migration rather than an edit of 20260908165000, which was already applied to staging under that version.
@@ -111,6 +111,22 @@ describe('gnubok_get_dimension_pnl: execute', () => {
})
})
it('resolves the period that contains to_date when period_id is omitted (#2185)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: { id: 'fp-2023', name: '2023', period_start: '2023-01-01', period_end: '2023-12-31' },
error: null,
})
mockGenerate.mockResolvedValueOnce(makeReport() as never)
await tool.execute({ sie_dim_no: '6', to_date: '2023-06-30' }, 'company-1', 'user-1', supabase as never)
expect(findCalls('fiscal_periods', 'lte')).toContainEqual(['period_start', '2023-06-30'])
expect(mockGenerate).toHaveBeenCalledWith(supabase, 'company-1', 'fp-2023', '6', {
toDate: '2023-06-30',
})
})
it('errors when no fiscal period exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
@@ -115,6 +115,142 @@ describe('gnubok_get_income_statement: from_date/to_date', () => {
})
})
// #2185: a date in the call names the year. Before, the tools defaulted to
// the most recent period and then rejected the date, so every question about
// an earlier year failed unless the model had first looked up that year's
// UUID. Now a from_date / as_of_date without period_id resolves the period
// that contains it.
describe('report period resolved from a date when period_id is absent (#2185)', () => {
const PERIOD_2023 = {
id: 'fp-2023',
name: '2023',
period_start: '2023-01-01',
period_end: '2023-12-31',
}
it('income statement: from_date/to_date in an earlier year run against that year', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: PERIOD_2023, error: null }) // the period containing from_date
mockIncomeStatement.mockResolvedValueOnce({ net_result: 7 } as never)
const result = (await incomeStatement.execute(
{ from_date: '2023-01-01', to_date: '2023-12-31' },
'company-1',
'user-1',
supabase as never,
)) as { period_name: string; period: { start: string; end: string } }
expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'fp-2023', {
fromDate: '2023-01-01',
toDate: '2023-12-31',
})
expect(result.period).toEqual({ start: '2023-01-01', end: '2023-12-31' })
// Scoped to the company and bounded by the date, not "most recent".
expect(findCalls('fiscal_periods', 'eq')).toContainEqual(['company_id', 'company-1'])
expect(findCalls('fiscal_periods', 'lte')).toContainEqual(['period_start', '2023-01-01'])
expect(findCalls('fiscal_periods', 'gte')).toContainEqual(['period_end', '2023-01-01'])
})
it('income statement: to_date alone is enough to name the year', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_2023, error: null })
mockIncomeStatement.mockResolvedValueOnce({ net_result: 7 } as never)
await incomeStatement.execute({ to_date: '2023-06-30' }, 'company-1', 'user-1', supabase as never)
expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'fp-2023', {
fromDate: undefined,
toDate: '2023-06-30',
})
})
it('income statement: a date no period covers names the span the company has', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null }) // no containing period
enqueue({
data: [
{ period_start: '2023-01-01', period_end: '2023-12-31' },
{ period_start: '2026-01-01', period_end: '2026-12-31' },
],
error: null,
}) // the span
await expect(
incomeStatement.execute({ from_date: '2019-06-30' }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(
/No fiscal period contains 2019-06-30: the company's fiscal periods span 2023-01-01 to 2026-12-31/,
)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
it('income statement: a range that spans two years is still refused, with the resolved year named', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_2023, error: null })
await expect(
incomeStatement.execute(
{ from_date: '2023-07-01', to_date: '2024-06-30' },
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/to_date must be within the fiscal period \(2023-01-01 to 2023-12-31\)/)
expect(mockIncomeStatement).not.toHaveBeenCalled()
})
it('income statement: without any date the most recent period is still the default', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'fp-1' }, error: null }) // most recent lookup
enqueue({ data: PERIOD_ROW, error: null }) // scoped re-read
mockIncomeStatement.mockResolvedValueOnce({ net_result: 1 } as never)
await incomeStatement.execute({}, 'company-1', 'user-1', supabase as never)
expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
fromDate: undefined,
toDate: undefined,
})
})
it('income statement: an explicit period_id wins over the date', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: PERIOD_ROW, error: null })
mockIncomeStatement.mockResolvedValueOnce({ net_result: 1 } as never)
await incomeStatement.execute(
{ period_id: 'fp-1', to_date: '2026-03-31' },
'company-1',
'user-1',
supabase as never,
)
expect(mockIncomeStatement).toHaveBeenCalledWith(supabase, 'company-1', 'fp-1', {
fromDate: undefined,
toDate: '2026-03-31',
})
expect(findCalls('fiscal_periods', 'lte')).toEqual([])
})
it('balance sheet: as_of_date in an earlier year runs against that year', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: PERIOD_2023, error: null })
mockBalanceSheet.mockResolvedValueOnce({ total_assets: 0, total_equity_liabilities: 0 } as never)
const result = (await balanceSheet.execute(
{ as_of_date: '2023-12-31' },
'company-1',
'user-1',
supabase as never,
)) as { period_name: string; period: { start: string; end: string } }
expect(mockBalanceSheet).toHaveBeenCalledWith(supabase, 'company-1', 'fp-2023', {
toDate: '2023-12-31',
})
expect(result.period_name).toBe('2023')
expect(result.period).toEqual({ start: '2023-01-01', end: '2023-12-31' })
})
})
describe('gnubok_get_balance_sheet: as_of_date', () => {
it('maps as_of_date to the generator toDate and echoes the effective window', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
+82 -6
View File
@@ -2649,19 +2649,71 @@ async function countMissingUnderlagInPeriod(
return Math.max(0, fromStart - afterEnd)
}
/**
* The company's fiscal period that contains an ISO date, or null. A date in a
* report call names the year the caller wants: "resultatrapporten för 2023"
* arrives as from_date/to_date, not as a period_id the model would first
* have to look up (#2185).
*/
async function findFiscalPeriodContaining(
supabase: SupabaseClient,
companyId: string,
date: string,
): Promise<{ id: string; name: string; period_start: string; period_end: string } | null> {
const { data } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.lte('period_start', date)
.gte('period_end', date)
.order('period_start', { ascending: false })
.limit(1)
.maybeSingle()
return data ?? null
}
/** "the company's fiscal periods span A to B", for an error that names a date no period covers. */
async function describeFiscalPeriodSpan(supabase: SupabaseClient, companyId: string): Promise<string> {
const { data } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: true })
const rows = (data ?? []) as { period_start: string; period_end: string }[]
if (rows.length === 0) return 'the company has no fiscal periods'
return `the company's fiscal periods span ${rows[0].period_start} to ${rows[rows.length - 1].period_end}`
}
/**
* Resolve the fiscal period a report tool runs against: the caller's
* `period_id` when given, else the company's most recent period. The period
* is then re-read scoped to the company, so a foreign id never resolves.
* `period_id` when given; else, when the call carries a date (from_date,
* as_of_date, ...), the period that contains that date; else the company's
* most recent period. An explicit id is re-read scoped to the company, so a
* foreign id never resolves.
*
* The date fallback exists because the previous default (most recent period,
* then a loud range check) made every question about an earlier year fail
* unless the model had first looked up that year's UUID (#2185).
*/
async function resolveReportPeriod(
supabase: SupabaseClient,
companyId: string,
periodIdArg: unknown,
noPeriodsMessage: string,
dateHint?: unknown,
) {
let periodId = periodIdArg as string | undefined
if (!periodId && typeof dateHint === 'string' && ISO_DATE_RE.test(dateHint)) {
const containing = await findFiscalPeriodContaining(supabase, companyId, dateHint)
if (containing) return containing
const span = await describeFiscalPeriodSpan(supabase, companyId)
throw new Error(
`No fiscal period contains ${dateHint}: ${span}. ` +
`Pass a date inside one of them, or that period's period_id (gnubok_list_fiscal_periods).`,
)
}
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
@@ -8197,7 +8249,13 @@ export const tools: McpTool[] = [
},
annotations: ANNOTATIONS_READ_ONLY,
async execute(args, companyId, userId, supabase) {
const period = await resolveReportPeriod(supabase, companyId, args.period_id, 'No fiscal periods found. Categorize some transactions first.')
const period = await resolveReportPeriod(
supabase,
companyId,
args.period_id,
'No fiscal periods found. Categorize some transactions first.',
args.from_date ?? args.to_date,
)
rejectUnknownArgs(args, ['period_id', 'from_date', 'to_date', 'dimensions'])
const range = parseReportRangeArgs(args, period, { from: 'from_date', to: 'to_date' })
@@ -10123,6 +10181,20 @@ export const tools: McpTool[] = [
}
let periodId = args.period_id as string | undefined
const toDate = args.to_date as string | undefined
// No period but a date: the period that contains the date (#2185).
if (!periodId && typeof toDate === 'string' && ISO_DATE_RE.test(toDate)) {
periodId = (
await resolveReportPeriod(
supabase,
companyId,
undefined,
'No fiscal periods found. Categorize some transactions first to auto-create a period.',
toDate,
)
).id
}
// If no period specified, find the most recent one (same default as
// gnubok_get_trial_balance).
@@ -10141,8 +10213,6 @@ export const tools: McpTool[] = [
periodId = periods.id
}
const toDate = args.to_date as string | undefined
return await generateDimensionPnl(supabase, companyId, periodId!, sieDimNo, { toDate })
},
},
@@ -10165,7 +10235,13 @@ export const tools: McpTool[] = [
outputSchema: { type: 'object' },
annotations: ANNOTATIONS_READ_ONLY,
async execute(args, companyId, userId, supabase) {
const period = await resolveReportPeriod(supabase, companyId, args.period_id, 'No fiscal periods found. Create one first.')
const period = await resolveReportPeriod(
supabase,
companyId,
args.period_id,
'No fiscal periods found. Create one first.',
args.as_of_date,
)
rejectUnknownArgs(args, ['period_id', 'as_of_date'])
const range = parseReportRangeArgs(args, period, { to: 'as_of_date' })
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
FISCAL_YEAR_INVENTORY_CAP,
loadFiscalYearInventory,
renderFiscalYearInventory,
} from '../fiscal-years'
function supabaseReturning(result: { data?: unknown; error?: unknown } | Error): {
supabase: SupabaseClient
calls: { method: string; args: unknown[] }[]
} {
const calls: { method: string; args: unknown[] }[] = []
const chain: Record<string, unknown> = {}
for (const method of ['select', 'eq', 'order', 'limit']) {
chain[method] = (...args: unknown[]) => {
calls.push({ method, args })
return chain
}
}
chain.then = (onFulfilled: (v: unknown) => unknown, onRejected?: (e: unknown) => unknown) =>
(result instanceof Error ? Promise.reject(result) : Promise.resolve(result)).then(
onFulfilled,
onRejected,
)
return { supabase: { from: () => chain } as unknown as SupabaseClient, calls }
}
describe('loadFiscalYearInventory', () => {
it('reads the company periods newest first, capped', async () => {
const rows = [
{ id: 'a', name: '2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
]
const { supabase, calls } = supabaseReturning({ data: rows, error: null })
expect(await loadFiscalYearInventory(supabase, 'c1')).toEqual(rows)
expect(calls).toContainEqual({ method: 'eq', args: ['company_id', 'c1'] })
expect(calls).toContainEqual({ method: 'order', args: ['period_start', { ascending: false }] })
expect(calls).toContainEqual({ method: 'limit', args: [FISCAL_YEAR_INVENTORY_CAP] })
})
it('is best-effort: a failing query yields an empty list', async () => {
const { supabase } = supabaseReturning(new Error('db down'))
expect(await loadFiscalYearInventory(supabase, 'c1')).toEqual([])
const nulled = supabaseReturning({ data: null, error: { message: 'x' } })
expect(await loadFiscalYearInventory(nulled.supabase, 'c1')).toEqual([])
})
})
describe('renderFiscalYearInventory', () => {
it('marks the newest and the closed years and carries the period_id', () => {
expect(
renderFiscalYearInventory([
{ id: 'fp-2026', name: '2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
{ id: 'fp-2025', name: '2025', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true },
{ id: 'fp-2024', name: '2024', period_start: '2024-05-01', period_end: '2025-04-30', is_closed: false },
]),
).toBe(
'Räkenskapsår (senaste först): 2026-01-01..2026-12-31 period_id=fp-2026 (senaste); ' +
'2025-01-01..2025-12-31 period_id=fp-2025 (avslutat); ' +
'2024-05-01..2025-04-30 period_id=fp-2024.',
)
})
it('is null for a company without periods, so the caller drops the line', () => {
expect(renderFiscalYearInventory([])).toBeNull()
})
})
+29
View File
@@ -141,6 +141,35 @@ describe('buildAssistantSnapshot', () => {
expect(snap).toContain('Moms (2026-09-12)')
})
it('lists the räkenskapsår newest first with their period_id (#2185)', async () => {
const periods = [
{ id: 'fp-2026', name: '2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
{ id: 'fp-2025', name: '2025', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true },
]
const supabase = {
from: (table: string) => {
const chain = {
select: () => chain,
eq: () => chain,
order: () => chain,
limit: () => chain,
maybeSingle: async () => ({ data: null, error: null }),
then: (onFulfilled: (v: unknown) => unknown) =>
Promise.resolve(
table === 'fiscal_periods'
? { data: periods, error: null }
: { count: null, error: null },
).then(onFulfilled),
}
return chain
},
} as unknown as SupabaseClient
const snap = await buildAssistantSnapshot(supabase, 'c1')
expect(snap).toContain(
'Räkenskapsår (senaste först): 2026-01-01..2026-12-31 period_id=fp-2026 (senaste); 2025-01-01..2025-12-31 period_id=fp-2025 (avslutat).',
)
})
it('returns an empty string when there is nothing to say', async () => {
const snap = await buildAssistantSnapshot(supabaseWith(null), 'c1')
expect(snap).toBe('')
+3 -1
View File
@@ -4,6 +4,7 @@ import { createLogger } from '@/lib/logger'
import { EmptyModelAnswerError } from './errors'
import { buildLedgerTools } from './ledger-tools'
import { buildAssistantSnapshot } from './snapshot'
import { FISCAL_YEAR_RULE } from '@/lib/agent/fiscal-years'
const log = createLogger('agent.ask')
@@ -89,7 +90,8 @@ Regler:
// With tools: the model can and should fetch the real figures itself.
const TOOL_RULES = `
Du har läsverktyg för bolagets faktiska bokföring: resultatrapport, balansrapport, momsrapport, huvudbok, transaktioner (query_journal), kund- och leverantörsreskontra, lönejournal, kontoplan, fakturor, dokumentinkorg med mera. När användaren frågar om siffror, belopp, poster, kategorier eller en period: ANROPA rätt verktyg och svara med de faktiska siffrorna, inte uppskattningar. Verktygen är skrivskyddade; för att bokföra eller ändra något hänvisar du användaren till rätt sida i appen.
"Nuläge"-blocket nedan är bara grunddata (moms, deadlines), inte hela bokföringen: använd verktygen för siffror.`
"Nuläge"-blocket nedan är bara grunddata (moms, deadlines, räkenskapsår), inte hela bokföringen: använd verktygen för siffror.
${FISCAL_YEAR_RULE}`
// Without tools (core-only build, or a text-only model): answer from what is
// in the prompt and be honest about the rest.
+8
View File
@@ -5,6 +5,7 @@ import {
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.
@@ -58,6 +59,8 @@ export async function buildAssistantSnapshot(
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([
@@ -118,5 +121,10 @@ export async function buildAssistantSnapshot(
// 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')
}
+27 -1
View File
@@ -28,7 +28,9 @@ const intent: AgentIntent = {
promptTemplate: () => '',
}
function block(vatStatus: VatStatus): string {
type FiscalYears = Parameters<typeof buildIdentityBlock>[0]['fiscalYears']
function block(vatStatus: VatStatus, fiscalYears?: FiscalYears): string {
return buildIdentityBlock({
intent,
companyId: 'c1',
@@ -38,6 +40,7 @@ function block(vatStatus: VatStatus): string {
rankedMemory: [],
vatStatus,
today: '2026-01-01 (torsdag)',
fiscalYears,
// buildIdentityBlock never touches supabase; it's a pure render of args.
supabase: {} as unknown as SupabaseClient,
})
@@ -109,3 +112,26 @@ describe('chat system prompt: always-on epistemics rules', () => {
expect(out).toContain('redan laddad')
})
})
// #2185: a multi-year ledger read as single-year to the model because the
// report tools default to the most recent period and nothing told it other
// years existed. The inventory and the rule are always-on (Block 2), since
// the question about an earlier year tends to come many turns in.
describe('chat system prompt: räkenskapsår inventory', () => {
it('lists the years with their period_id and the rule for addressing them', () => {
const out = block(null, [
{ id: 'fp-2026', name: '2026', period_start: '2026-01-01', period_end: '2026-12-31', is_closed: false },
{ id: 'fp-2024', name: '2024', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: true },
])
expect(out).toContain('# Räkenskapsår')
expect(out).toContain('2026-01-01..2026-12-31 period_id=fp-2026 (senaste)')
expect(out).toContain('2024-01-01..2024-12-31 period_id=fp-2024 (avslutat)')
expect(out).toContain('skicka det årets period_id')
expect(out).toContain('Säg alltid vilket räkenskapsår svaret gäller')
})
it('renders no section when the company has no periods', () => {
expect(block(null, [])).not.toContain('# Räkenskapsår')
expect(block(null)).not.toContain('# Räkenskapsår')
})
})
+4 -1
View File
@@ -11,6 +11,7 @@ import { agentToolRegistry } from '@/lib/agent/tools/registry'
import type { AgentTool, AgentActorContext, StagedOperationResult } from '@/lib/agent/tools/types'
import { isStagedOperation } from '@/lib/agent/tools/types'
import { buildSystemPrompt } from './system-prompt'
import { loadFiscalYearInventory } from '@/lib/agent/fiscal-years'
import { createLogger } from '@/lib/logger'
import { swedishToday } from '@/lib/utils'
@@ -258,12 +259,13 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
// On a first turn the caller already read the profile summary to build the
// intent's prompt template, so it hands it over rather than making the same
// round trip again for the system prompt.
const [profile, memory, vatStatus] = await Promise.all([
const [profile, memory, vatStatus, fiscalYears] = await Promise.all([
args.preloadedProfileSummary !== undefined
? Promise.resolve(args.preloadedProfileSummary)
: loadProfileSummary(supabase, companyId),
loadRankedMemory(supabase, companyId, 30),
loadVatStatus(supabase, companyId),
loadFiscalYearInventory(supabase, companyId),
])
const systemPrompt = await buildSystemPrompt({
@@ -275,6 +277,7 @@ export async function runChatTurn(args: RunTurnArgs): Promise<void> {
rankedMemory: memory,
vatStatus,
today: swedishToday(),
fiscalYears,
supabase,
})
+23 -1
View File
@@ -3,6 +3,11 @@ import { join } from 'node:path'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createHash } from 'node:crypto'
import type { AgentIntent } from '../intents/types'
import {
FISCAL_YEAR_RULE,
renderFiscalYearInventory,
type FiscalYearInventoryRow,
} from '@/lib/agent/fiscal-years'
// Builds the system prompt the chat loop sends to Anthropic.
//
@@ -41,6 +46,9 @@ interface BuildArgs {
// relative-time reasoning ("förra månaden", "förfallen", current VAT period)
// to the real date instead of the model's training cutoff. See swedishToday().
today: string
// The company's räkenskapsår, newest first (lib/agent/fiscal-years.ts).
// Optional so a caller that has nothing to say renders no section.
fiscalYears?: FiscalYearInventoryRow[]
supabase: SupabaseClient
}
@@ -213,7 +221,7 @@ async function resolveBodies(
}
export function buildIdentityBlock(args: BuildArgs): string {
const { intent, companyName, firstName, profileSummary, rankedMemory, vatStatus, today } = args
const { intent, companyName, firstName, profileSummary, rankedMemory, vatStatus, today, fiscalYears } = args
const lines: string[] = []
lines.push('# Din roll')
@@ -392,6 +400,20 @@ export function buildIdentityBlock(args: BuildArgs): string {
lines.push('4. **Inga hypoteser om motsatt status.** Spekulera ALDRIG "om du *varit* momsregistrerad hade det blivit X" eller "om du *inte varit* momsregistrerad…": det är källan till hallucinationer mellan turns. Svara för det faktiska tillståndet enligt blocket "Företagets momsstatus" ovan. Om användaren vill ha en hypotetisk genomgång: säg att de kan ändra status i /settings/company och prova om.')
lines.push('')
// Which years the ledger holds. The report tools default to the most recent
// period, so without this inventory a multi-year ledger read as single-year
// to the model and the user concluded the assistant "only sees the period I
// am standing in" (#2185). Always-on rather than first-message: the question
// about an earlier year tends to come many turns in.
const fiscalYearLine = renderFiscalYearInventory(fiscalYears ?? [])
if (fiscalYearLine) {
lines.push('# Räkenskapsår')
lines.push('')
lines.push(fiscalYearLine)
lines.push(FISCAL_YEAR_RULE)
lines.push('')
}
if (profileSummary) {
lines.push('# Företagets profil')
lines.push('')
+68
View File
@@ -0,0 +1,68 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* The company's räkenskapsår as one prompt line, so the assistant knows which
* years exist and how to address them. Without it the model only ever saw
* the report tools' "default: most recent" and read a multi-year ledger as a
* single-year one (#2185): a question about 2023 had no period_id to pass
* and no hint that one existed.
*
* Shared by the single-call assistant snapshot (lib/agent/ask/snapshot.ts)
* and the streaming chat's identity block (lib/agent/chat/system-prompt.ts),
* so both surfaces carry the same inventory from one query. Company-scoped
* and best-effort: a failing query yields an empty list, never an error.
*/
export interface FiscalYearInventoryRow {
id: string
name: string
period_start: string
period_end: string
is_closed: boolean
}
/** Newest first. Enough for a decade of imports without bloating the prompt. */
export const FISCAL_YEAR_INVENTORY_CAP = 8
export async function loadFiscalYearInventory(
supabase: SupabaseClient,
companyId: string,
): Promise<FiscalYearInventoryRow[]> {
try {
const { data } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(FISCAL_YEAR_INVENTORY_CAP)
return ((data ?? []) as FiscalYearInventoryRow[]).filter(
(row) => typeof row?.id === 'string' && typeof row.period_start === 'string',
)
} catch {
return []
}
}
/**
* "Räkenskapsår (senaste först): 2026-01-01..2026-12-31 period_id=<uuid>
* (senaste); 2025-01-01..2025-12-31 period_id=<uuid> (avslutat)." Null when
* the company has no periods, so the caller can drop the line.
*/
export function renderFiscalYearInventory(rows: FiscalYearInventoryRow[]): string | null {
if (rows.length === 0) return null
const items = rows.map((row, index) => {
const marks: string[] = []
if (index === 0) marks.push('senaste')
if (row.is_closed) marks.push('avslutat')
const suffix = marks.length > 0 ? ` (${marks.join(', ')})` : ''
return `${row.period_start}..${row.period_end} period_id=${row.id}${suffix}`
})
return `Räkenskapsår (senaste först): ${items.join('; ')}.`
}
/**
* The rule that goes with the inventory: how to ask a report tool about an
* earlier year, and to say which year an answer covers.
*/
export const FISCAL_YEAR_RULE =
'Frågor om ett tidigare räkenskapsår: skicka det årets period_id till rapportverktygen (resultatrapport, balansrapport, KPI, huvudbok, saldobalans); utan period_id läser de det senaste året. Säg alltid vilket räkenskapsår svaret gäller.'
+7
View File
@@ -35,6 +35,13 @@ export const AGENT_GROUND_RULES: string[] = [
// -- Anchor in user's own history --
'- KOLLA HISTORIK FÖRST: innan du föreslår "så här gör du" på en återkommande motpart, anropa gnubok_query_journal med motpartens namn. Om de bokfört Vercel/Spotify/SJ förut: följ samma mönster. "Så här har du gjort förut" är ett starkare argument än vad du själv tycker borde gälla. Bryt bara mönstret om underlaget tydligt säger något annat.',
'',
// -- Earlier years: the ledger is not single-year --
// Production report (#2185): a user with several imported years concluded
// the assistant "only sees the period I am standing in". The report tools
// default to the most recent period when no period_id is given; the
// inventory of years sits in the Räkenskapsår line of the context.
'- TIDIGARE ÅR: rapportverktygen (resultatrapport, balansrapport, KPI, huvudbok, saldobalans) läser det senaste räkenskapsåret om inget period_id anges. Frågar användaren om ett tidigare år ("hur gick 2023?", "jämför med förra året"): skicka det årets period_id (från raden Räkenskapsår i din kontext, annars gnubok_list_fiscal_periods), ett anrop per år, och skicka aldrig bara datum utanför senaste året. Säg alltid vilket räkenskapsår svaret gäller.',
'',
// -- Storno / rättelse: how the product actually works --
// Production feedback: the assistant described correction flows that don't
// exist in Accounted (or implied the user must register accounts before