Files
500806f001 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>
2026-09-08 21:32:38 +02:00

138 lines
6.1 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { AgentIntent } from '@/lib/agent/intents/types'
import { buildIdentityBlock } from '../system-prompt'
// buildIdentityBlock is the always-on Block 2 of the chat system prompt. Unlike
// the per-intent ground rules (which sit only in the first user message and
// fall out of salience deep in a conversation), this block is re-sent on every
// turn. These guards lock in the epistemics rules added after the agent
// confidently answered "matvaror är 12 %" from stale training memory: it
// dropped to 6 % in April 2026, and invented a "ränteintäkter från ALMI"
// concern by inferring a lending business from an SNI code.
type VatStatus = Parameters<typeof buildIdentityBlock>[0]['vatStatus']
// Minimal base-typed intent: buildIdentityBlock only reads id, sheetTitle and
// atoms.mode. (The concrete intents have narrow capture/template generics that
// don't unify with the base AgentIntent the builder expects; the real call site
// resolves intents through the registry as base-typed.)
const intent: AgentIntent = {
id: 'general.help',
buttonLabel: 'x',
sheetTitle: 'Fråga din assistent',
atoms: { mode: 'progressive', horizontal: [], includeCompanyVertical: false, includeCompanyModifiers: false },
tools: [],
model: 'claude-sonnet-5',
capture: async () => ({}),
promptTemplate: () => '',
}
type FiscalYears = Parameters<typeof buildIdentityBlock>[0]['fiscalYears']
function block(vatStatus: VatStatus, fiscalYears?: FiscalYears): string {
return buildIdentityBlock({
intent,
companyId: 'c1',
companyName: 'Testbolaget AB',
firstName: 'Jakob',
profileSummary: null,
rankedMemory: [],
vatStatus,
today: '2026-01-01 (torsdag)',
fiscalYears,
// buildIdentityBlock never touches supabase; it's a pure render of args.
supabase: {} as unknown as SupabaseClient,
})
}
const VAT_STATES: VatStatus[] = [
null,
{ vat_registered: true, vat_number: 'SE556677889901' },
{ vat_registered: false, vat_number: null },
]
describe('chat system prompt: always-on epistemics rules', () => {
it('forces load-before-answer for regulatory figures, on every VAT status', () => {
for (const vs of VAT_STATES) {
const out = block(vs)
expect(out).toContain('# Säkerhet i sak: ladda reglerna, gissa aldrig från minnet')
// Must point at the load tool and demand reading before answering.
expect(out).toContain('gnubok_load_skill')
// The canonical staleness trap must be named so the rule is concrete,
// not abstract: a model answering food VAT "12 %" from memory is wrong.
expect(out).toContain('12 %→6 %')
}
})
it('kills the "I am sure" escape hatch and turns "are you sure?" into a verify signal', () => {
const out = block(null)
expect(out).toContain('ja, jag är säker')
expect(out).toContain('är du säker?')
// The instruction must be to load/verify, not to repeat the prior answer.
expect(out.toLowerCase()).toContain('upprepa')
})
it('forbids inferring the business from weak signals like SNI codes', () => {
const out = block(null)
expect(out).toContain('# Påstå inget om bolaget du inte grundat i data')
expect(out).toContain('SNI-kod')
// Resolve real uncertainty by reading data or asking: not by speculating.
expect(out).toMatch(/läsverktyg|fråga/i)
})
it('anchors relative-time reasoning to the supplied current date', () => {
// Without an explicit "today" the model dates "förra månaden" / overdue
// invoices / the current VAT period off its training cutoff. The date the
// caller passes must land verbatim in the always-on block.
const out = block(null)
expect(out).toContain('# Dagens datum')
expect(out).toContain('Idag är 2026-01-01 (torsdag).')
// Must tell the model to trust this over its own sense of "now".
expect(out).toContain('träningsdata')
})
it('addresses the user by their own tilltalsnamn, not owner/signatory names from the profile', () => {
// Regression: the agent answered "vad heter jag" with the registered
// firmatecknare's legal name from "Företagets profil" instead of the
// user's own chosen name. The role block must name the user (firstName)
// and explicitly demote company owner/signatory names.
const out = block(null)
expect(out).toContain('Jakob')
expect(out).toMatch(/tilltalsnamn/i)
expect(out).toContain('firmatecknare')
})
it('lets the agent read a pre-loaded atom directly instead of re-loading it', () => {
// Declarative intents pre-load swedish-vat etc. into Block 1, so the rule
// must not force a redundant gnubok_load_skill when the owning atom is
// already present. This nuance used to live only in the per-intent KÄLLOR
// line; it now lives here, in the single canonical epistemics home.
const out = block(null)
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')
})
})