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>
138 lines
6.1 KiB
TypeScript
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')
|
|
})
|
|
})
|