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
@@ -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' })