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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
20925f6c65
commit
500806f001
@@ -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()
|
||||
|
||||
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user