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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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.'
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user