* feat(agent): 'Vad din agent vet' page rendering the ledger context (P2) The human-facing surface for the openwiki ledger-context: a read-only page that renders the exact payload the AI agent reads (Accounted://ledger/context + the briefing digest) as a legible profile of how this company books. - Route app/(dashboard)/agent-knowledge (server component) calls the shared buildLedgerContext(supabase, companyId) directly: one payload, two renderers, no new API or data path. - Sections mirror the payload 1:1: coverage/freshness strip, counterparty patterns (monochrome confidence bars + seen/agree evidence), supplier patterns, explicit rules shown as authoritative instructions distinct from observed patterns, account usage, VAT profile, conventions. - Nav entry in the Analys group (icon Brain), ungated so it doubles as an upsell; flip requiredCapability to paywall. - Design per .claude/rules/design.md (PageHeader, Card, Table, Badge, AccountNumber BAS tooltips); sv + en strings (agentKnowledge namespace). VAT/BAS labels stay Swedish in both locales per i18n rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): deep entity-resolved analysis + radial graph for the knowledge page Reworks the 'Vad din agent vet' page from tables into a radial-hub graph driven by a new full-history deep analysis, per founder feedback. - fix(rpc): median_booking_lag_days now measures real posting promptness via committed_at, not entry_date (which the bank flow sets to the transaction date, giving a ~0 tautology: 151/152 on prod). migration 20260708120000. - feat(rpc): get_ledger_deep_context (migration 20260708130000): full-history, deterministic, read-side. Merges counterparties by normalize_counterparty_key (e.g. Claude = 14 bookings across 12 name variants, weekly, 9 710 kr, always 5420), mines booked verifikat for SEK spend (coalesce amount_sek), detects recurrence cadence, dominant account + share, plus supplier entities. Storno excluded, corrections kept; 19xx/26xx excluded from the dominant contra. - LedgerGraph: radial SVG (company center, accounts inner ring, payees outer ring), hover/focus reveals variants + spend + cadence + account. Keyboard focusable nodes with per-node accessible names + a screen-reader data table. - Page fetches the deep context alongside the light context; coverage strip gains tracked-payee / recurring / tracked-spend stats. sv + en strings. - 14 pg tests (light + deep) green; both RPCs applied to prod + version-matched. Reviewed by an adversarial multi-lens pass (accounting/SQL, frontend/a11y, prod-fact verification); all four verified findings fixed (SEK currency, storno-lag guard, keyboard a11y, spacing tokens). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): gentle mount fade-in for the radial map (reduced-motion safe) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent): render the page for a rules-only company (empty-state edge case) isEmpty ignored explicit_rules, so a company with configured mapping rules but no posted transactions hit the 'hasn't learned anything' empty state and lost its rules section. Rules are independent of bookings. (CodeRabbit) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): show Kompetens (skills) + Fakta (memory) on the knowledge page The 'Vad din agent vet' page now shows the full picture of what the agent knows: alongside the booking map, a compact read-only view of its Kompetens (the Swedish accounting/tax knowledge atoms it ships with, grouped by tier as chips with active/dormant state) and the Fakta it remembers (top learned facts with kind + source), each linking to /settings/assistant for full management. Server-rendered via a new buildAgentCompetence() that mirrors GET /api/agent/skills + /api/agent/memory. Also renders in the no-bookings case so a new company still sees its agent's competence. sv + en strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(agent): restructure knowledge page - hero graph + tabbed detail Declutters the page per feedback: the booking map is the always-visible hero, and the supporting detail (Kompetens · Minne · Regler & profil) moves into tabs so only one view shows at a time instead of a long card stack. Split AgentCompetenceSections into standalone CompetenceCard + FactsCard for the tabs; removed the top stat row on request. sv + en. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(agent): Reconciliation Aurora rewrite of the ledger knowledge graph Full rewrite of LedgerGraph: node area = sqrt(spend), colour = cadence, shape = supplier/counterparty, confidence = depth-of-field; on-mount descriptor-collapse animation with xN badge; cadence pulse veins; deterministic seeded layout; framer-motion only (no new deps); keyboard navigation, reduced-motion and sr-only support. Build-verified; 3-lens adversarial review findings fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent): sample-size-honest confidence in the ledger knowledge graph dominant_account_share was raw cnt/total, so a counterparty with a single booking rendered as '100% säkerhet': fake certainty by construction (the data_quality_master Item-C / P3 finding). New migration replaces get_ledger_deep_context with a Laplace-smoothed share (cnt+1)/(total+2) (1/1 -> 0.67, 3/3 -> 0.80) and exposes the raw evidence as dominant_account_count / dominant_account_total. The detail card now shows 'Bokförd hit i k av n fall' under the confidence bar; the existing focus buckets, stroke widths and percent labels inherit the honest value unchanged. pg-real test updated to guard the n=1 case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
189 lines
7.7 KiB
TypeScript
189 lines
7.7 KiB
TypeScript
/**
|
|
* pg-real test for get_ledger_deep_context.
|
|
*
|
|
* The deep, full-history analysis behind the "Vad din agent vet" page: merges
|
|
* counterparties across name variants, mines booked verifikat for spend, and
|
|
* detects recurrence. Verifies variant-merging, occurrence/spend rollup,
|
|
* dominant-account + share, recurrence cadence, and supplier entities.
|
|
*/
|
|
import { describe, it, expect, beforeAll } from 'vitest'
|
|
import { randomUUID } from 'node:crypto'
|
|
import { getPool } from './setup'
|
|
import { seedCompany, insertDraftJournalEntry } from './fixtures'
|
|
|
|
async function insertLines(
|
|
journalEntryId: string,
|
|
lines: Array<{ account: string; debit: number; credit: number }>,
|
|
): Promise<void> {
|
|
for (const line of lines) {
|
|
await getPool().query(
|
|
`INSERT INTO public.journal_entry_lines
|
|
(journal_entry_id, account_number, debit_amount, credit_amount)
|
|
VALUES ($1, $2, $3, $4)`,
|
|
[journalEntryId, line.account, line.debit, line.credit],
|
|
)
|
|
}
|
|
}
|
|
|
|
async function bookMerchant(params: {
|
|
userId: string
|
|
companyId: string
|
|
fiscalPeriodId: string
|
|
merchantName: string
|
|
date: string
|
|
expenseAccount: string
|
|
amount: number
|
|
voucherNumber: number
|
|
}): Promise<void> {
|
|
const entryId = await insertDraftJournalEntry({
|
|
userId: params.userId,
|
|
companyId: params.companyId,
|
|
fiscalPeriodId: params.fiscalPeriodId,
|
|
entryDate: params.date,
|
|
status: 'posted',
|
|
voucherNumber: params.voucherNumber,
|
|
sourceType: 'bank_transaction',
|
|
})
|
|
await insertLines(entryId, [
|
|
{ account: params.expenseAccount, debit: params.amount, credit: 0 },
|
|
{ account: '1930', debit: 0, credit: params.amount },
|
|
])
|
|
await getPool().query(
|
|
`INSERT INTO public.transactions
|
|
(id, company_id, user_id, currency, amount, date, description, journal_entry_id, merchant_name, category)
|
|
VALUES ($1,$2,$3,'SEK',$4,$5,$6,$7,$8,'expense_software')`,
|
|
[randomUUID(), params.companyId, params.userId, -params.amount, params.date,
|
|
`Payment ${params.merchantName}`, entryId, params.merchantName],
|
|
)
|
|
}
|
|
|
|
type DeepEntity = {
|
|
name: string
|
|
key: string
|
|
variants: string[]
|
|
variant_count: number
|
|
occurrences: number
|
|
total_amount: number
|
|
first_seen: string
|
|
last_seen: string
|
|
cadence_days: number | null
|
|
dominant_account_number: string | null
|
|
dominant_account_share: number | null
|
|
dominant_account_count: number | null
|
|
dominant_account_total: number | null
|
|
dominant_vat?: string | null
|
|
}
|
|
type Deep = { counterparty_entities: DeepEntity[]; supplier_entities: DeepEntity[] }
|
|
|
|
async function callRpc(companyId: string, fromDate: string | null): Promise<Deep> {
|
|
const res = await getPool().query(
|
|
`SELECT public.get_ledger_deep_context($1, $2) AS d`,
|
|
[companyId, fromDate],
|
|
)
|
|
return res.rows[0].d as Deep
|
|
}
|
|
|
|
describe('get_ledger_deep_context', () => {
|
|
let userId: string
|
|
let companyId: string
|
|
let fiscalPeriodId: string
|
|
|
|
beforeAll(async () => {
|
|
const seeded = await seedCompany()
|
|
userId = seeded.userId
|
|
companyId = seeded.companyId
|
|
fiscalPeriodId = seeded.fiscalPeriodId
|
|
|
|
// Klarna under 3 name variants, monthly, all to 5420, 100 kr each.
|
|
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'KLARNA AB', date: '2026-04-01', expenseAccount: '5420', amount: 100, voucherNumber: 1 })
|
|
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'SWISH KLARNA AB', date: '2026-05-01', expenseAccount: '5420', amount: 100, voucherNumber: 2 })
|
|
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'KORTKÖP KLARNA AB 2026-06-01', date: '2026-06-01', expenseAccount: '5420', amount: 100, voucherNumber: 3 })
|
|
|
|
// A one-off different merchant.
|
|
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'SL', date: '2026-05-10', expenseAccount: '5810', amount: 50, voucherNumber: 4 })
|
|
|
|
// A supplier with 2 invoices.
|
|
const supplierId = randomUUID()
|
|
await getPool().query(`INSERT INTO public.suppliers (id, user_id, company_id, name) VALUES ($1,$2,$3,'Telia Sverige AB')`,
|
|
[supplierId, userId, companyId])
|
|
let arr = 1
|
|
for (const [d, total] of [['2026-04-15', 500], ['2026-05-15', 500]] as const) {
|
|
const invId = randomUUID()
|
|
await getPool().query(
|
|
`INSERT INTO public.supplier_invoices
|
|
(id,user_id,company_id,supplier_id,arrival_number,supplier_invoice_number,invoice_date,due_date,status,vat_treatment,is_credit_note,subtotal,vat_amount,total,total_sek)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$7,'registered','standard_25',false,400,100,500,$8)`,
|
|
[invId, userId, companyId, supplierId, arr++, `SI-${arr}`, d, total],
|
|
)
|
|
await getPool().query(
|
|
`INSERT INTO public.supplier_invoice_items (supplier_invoice_id,description,quantity,unit_price,line_total,account_number,vat_rate,vat_amount)
|
|
VALUES ($1,'Line',1,400,400,'6212',0.25,100)`,
|
|
[invId],
|
|
)
|
|
}
|
|
})
|
|
|
|
it('merges counterparty name variants into one entity with spend + cadence', async () => {
|
|
const deep = await callRpc(companyId, null)
|
|
const klarna = deep.counterparty_entities.find((e) => e.key === 'klarna')
|
|
expect(klarna).toBeDefined()
|
|
expect(klarna!.occurrences).toBe(3)
|
|
expect(klarna!.variant_count).toBe(3)
|
|
expect(klarna!.variants.length).toBeGreaterThanOrEqual(3)
|
|
expect(klarna!.dominant_account_number).toBe('5420')
|
|
// Laplace-smoothed (3+1)/(3+2): consistent history, but n=3 is not certainty.
|
|
expect(klarna!.dominant_account_share).toBe(0.8)
|
|
expect(klarna!.dominant_account_count).toBe(3)
|
|
expect(klarna!.dominant_account_total).toBe(3)
|
|
expect(klarna!.total_amount).toBe(300)
|
|
expect(klarna!.first_seen).toBe('2026-04-01')
|
|
expect(klarna!.last_seen).toBe('2026-06-01')
|
|
// Monthly cadence: gaps of 30 and 31 days -> median ~30.
|
|
expect(klarna!.cadence_days).toBeGreaterThanOrEqual(30)
|
|
expect(klarna!.cadence_days).toBeLessThanOrEqual(31)
|
|
})
|
|
|
|
it('keeps a one-off merchant as a single-occurrence entity', async () => {
|
|
const deep = await callRpc(companyId, null)
|
|
const sl = deep.counterparty_entities.find((e) => e.key === 'sl')
|
|
expect(sl!.occurrences).toBe(1)
|
|
expect(sl!.variant_count).toBe(1)
|
|
expect(sl!.cadence_days).toBeNull()
|
|
expect(sl!.dominant_account_number).toBe('5810')
|
|
// The P3 bug this guards: a single booking must NOT read as 100%.
|
|
// Laplace (1+1)/(1+2) = 0.67, with the raw 1-of-1 evidence exposed.
|
|
expect(sl!.dominant_account_share).toBe(0.67)
|
|
expect(sl!.dominant_account_count).toBe(1)
|
|
expect(sl!.dominant_account_total).toBe(1)
|
|
})
|
|
|
|
it('aggregates supplier entities with spend and dominant account', async () => {
|
|
const deep = await callRpc(companyId, null)
|
|
const telia = deep.supplier_entities.find((e) => e.name === 'Telia Sverige AB')
|
|
expect(telia).toBeDefined()
|
|
expect(telia!.occurrences).toBe(2)
|
|
expect(telia!.total_amount).toBe(1000)
|
|
expect(telia!.dominant_account_number).toBe('6212')
|
|
// Laplace-smoothed (2+1)/(2+2).
|
|
expect(telia!.dominant_account_share).toBe(0.75)
|
|
expect(telia!.dominant_account_count).toBe(2)
|
|
expect(telia!.dominant_account_total).toBe(2)
|
|
expect(telia!.dominant_vat).toBe('standard_25')
|
|
expect(telia!.cadence_days).toBeGreaterThanOrEqual(30)
|
|
})
|
|
|
|
it('respects the from_date bound', async () => {
|
|
const deep = await callRpc(companyId, '2026-05-15')
|
|
const klarna = deep.counterparty_entities.find((e) => e.key === 'klarna')
|
|
// Only the 2026-06-01 Klarna booking is on/after the bound.
|
|
expect(klarna!.occurrences).toBe(1)
|
|
})
|
|
|
|
it('isolates by company', async () => {
|
|
const other = await seedCompany()
|
|
const deep = await callRpc(other.companyId, null)
|
|
expect(deep.counterparty_entities).toEqual([])
|
|
expect(deep.supplier_entities).toEqual([])
|
|
})
|
|
})
|