Files
accounted/scripts/backtest-categorize.ts
T
Jakob Wennberg ad8566f1ae feat(settings): per-company data-analysis opt-in gating the calibration corpus (#1346) (#2007)
* feat(settings): per-company opt-in for data analysis of bookkeeping outcomes (#1346)

Adds company_settings.data_analysis_opt_in (default false, no grandfathering)
and gates every path that reads bookkeeping outcomes across companies on it:
POST /api/agent/categorize/outcome stops writing calibration samples for
companies that have not opted in, and the backtest / calibration-fit scripts
filter to opted-in company ids. One helper (lib/company/data-analysis.ts)
is the single gate for future analysis paths. A toggle on Inställningar >
Företag states plainly what is analysed (proposed vs booked account, amount,
confidence; no free text, no personal data) in sv and en. The flag is UI-only
by design: consent is a human action, so it is absent from the v1 REST / MCP
settings pick lists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(settings): make data-analysis consent copy true for the backtest path (#1346)

Addresses adversarial review findings on PR #2007:

- Findings 1-3 (consent narrower than the gated processing): the flag also
  gates scripts/backtest-categorize.ts, which re-runs transaction
  descriptions, merchant names and matched underlag through the model. The
  sv/en toggle help and disclosure now state that explicitly as "evaluation
  runs" and no longer claim that free text or underlag are excluded. The
  migration header and COMMENT, the lib/company/data-analysis.ts docstring,
  the backtest script header and the DECISIONS line say the same. Kept the
  gate (un-gating would put the script back to reading every company with
  no consent at all). A test pins that both locales name those inputs and
  contain no "no free text / no underlag" denial.
- Finding 4 (member sees an active switch that RLS rejects): the toggle is
  now enabled only for owner/admin, matching the company_settings update
  policy; the disclosure says only administrators can change the choice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(scripts): address round-2 review findings (#1346)

1. [minor] Opted-in company filter was an unbounded PostgREST `in` list in
   the URL (scripts/fit-categorize-calibration.ts, scripts/backtest-categorize.ts).
   Both scripts now read the opted-in ids through a shared, paginated helper
   (listDataAnalysisOptedInCompanyIds, fetchAllRows so the pre-fetch no longer
   caps at 1000) and query per chunk of 100 ids (chunkCompanyIds). The fit
   script pages each chunk on the id PK; the backtest merges per-chunk
   results and re-cuts to the N most recent overall. Early exit on zero
   opt-ins is kept. Pinned with tests in lib/company/__tests__/data-analysis.test.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(scripts): coerce a null transaction description in the backtest (#1346)

The typed row from the chunked consent query made description nullable,
which TransactionForSelect does not accept; fall back to the original
description or an empty string, as the untyped row did implicitly before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 17:38:36 +02:00

200 lines
8.9 KiB
TypeScript

/**
* Backtest the auto-booking cascade against REAL, already-booked transactions.
*
* READ-ONLY. For each recent booked expense transaction it: reconstructs the
* candidate slate + underlag from prod, runs the real selector (against the
* configured AI backend), and compares the model's proposed account to the
* account the human actually booked (the expense debit line). Prints per-row
* detail + an aggregate: overall accuracy, and — the honest signal — accuracy
* on the cases where the top deterministic candidate was NOT the answer, i.e.
* where the model had to add value.
*
* cp ~/erp-base/.env.local . # prod DB + Bedrock, read-only
* npx tsx scripts/backtest-categorize.ts [N]
* rm .env.local
*
* Consent: only companies with company_settings.data_analysis_opt_in = true
* are read (#1346). This script goes beyond booking outcomes: it reads each
* transaction's description, merchant name and matched underlag (via
* gatherUnderlag) and sends them to the model again, so the consent copy in
* messages/*.json (data_analysis.settings_toggle_help) explicitly names
* "evaluation runs" with exactly those inputs. Do not add inputs here that
* the copy does not name. Nobody is opted in by default, so an empty run is
* the expected state until an admin flips the toggle in Inställningar > Företag.
*
* Leakage caveat: a known vendor's counterparty template may already reflect
* the very booking under test, inflating the "deterministic nailed it" segment.
* The "model had to decide" segment below is the leakage-free measure.
*/
import { config } from 'dotenv'
config({ path: '.env.local' })
const N = Number(process.argv[2] ?? 50)
const CONCURRENCY = 4
async function main() {
const { createClient } = await import('@supabase/supabase-js')
// Import after dotenv so lib/ai resolves the provider/model from .env.local.
const { gatherCandidates } = await import('../lib/agent/categorize/candidates')
const { gatherUnderlag } = await import('../lib/agent/categorize/underlag')
const { selectAccount } = await import('../lib/agent/categorize/select-account')
const { chunkCompanyIds, listDataAnalysisOptedInCompanyIds } = await import('../lib/company/data-analysis')
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!
const key = process.env.SUPABASE_SERVICE_ROLE_KEY!
const supabase = createClient(url, key)
// Consent gate (#1346): only companies that opted in to data analysis.
const optedInIds = await listDataAnalysisOptedInCompanyIds(supabase)
if (optedInIds.length === 0) {
console.log('\nNo company has opted in to data analysis (company_settings.data_analysis_opt_in). Nothing to backtest.')
return
}
// Recent booked expense transactions with a counterparty. Queried per chunk
// of company ids (`.in()` lives in the GET query string), then merged and
// re-cut to the N most recent overall.
type Tx = {
id: string
company_id: string
merchant_name: string | null
description: string | null
original_description: string | null
amount: number
date: string
currency: string | null
document_id: string | null
journal_entry_id: string | null
created_at: string
}
const candidatesByChunk: Tx[] = []
for (const chunk of chunkCompanyIds(optedInIds)) {
const { data: txs, error } = await supabase
.from('transactions')
.select('id, company_id, merchant_name, description, original_description, amount, date, currency, document_id, journal_entry_id, created_at')
.in('company_id', chunk)
.not('journal_entry_id', 'is', null)
.lt('amount', 0)
.eq('is_business', true)
.not('merchant_name', 'is', null)
.order('created_at', { ascending: false })
.limit(N)
if (error) throw error
candidatesByChunk.push(...((txs ?? []) as Tx[]))
}
const rows = candidatesByChunk
.sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0))
.slice(0, N)
console.log(`\nBacktesting ${rows.length} booked transactions on ${process.env.BEDROCK_MODEL_ID ?? process.env.AI_MODEL ?? 'the configured model'}\n`)
// Ground-truth debit account per journal entry (expense line, not cash/VAT).
const jeIds = rows.map((r) => r.journal_entry_id).filter(Boolean) as string[]
const truth = new Map<string, string>()
for (let i = 0; i < jeIds.length; i += 100) {
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('journal_entry_id, account_number, debit_amount')
.in('journal_entry_id', jeIds.slice(i, i + 100))
for (const l of (lines ?? []) as { journal_entry_id: string; account_number: string; debit_amount: number | null }[]) {
const acct = l.account_number ?? ''
if (!(Number(l.debit_amount) > 0)) continue
if (acct.startsWith('19') || acct.startsWith('26') || acct.startsWith('264')) continue // cash + VAT
const cur = truth.get(l.journal_entry_id)
if (!cur) truth.set(l.journal_entry_id, acct) // first expense debit line
}
}
const companyCtx = new Map<string, { entityType: string; vatRegistered: boolean }>()
async function ctxFor(companyId: string) {
const hit = companyCtx.get(companyId)
if (hit) return hit
const [{ data: c }, { data: s }] = await Promise.all([
supabase.from('companies').select('entity_type').eq('id', companyId).maybeSingle(),
supabase.from('company_settings').select('vat_registered').eq('company_id', companyId).maybeSingle(),
])
const ctx = { entityType: (c?.entity_type as string) ?? 'enskild_firma', vatRegistered: !!s?.vat_registered }
companyCtx.set(companyId, ctx)
return ctx
}
interface Result {
merchant: string
truth: string | null
proposed: string | null
conf: number
fromCandidate: boolean
topCandidate: string | null
hadUnderlag: boolean
correct: boolean | null
}
const results: Result[] = []
async function run(r: (typeof rows)[number]) {
const gt = r.journal_entry_id ? truth.get(r.journal_entry_id) ?? null : null
if (!gt) return
const ctx = await ctxFor(r.company_id)
const [candidates, underlag] = await Promise.all([
gatherCandidates(supabase as never, r.company_id, r as never),
gatherUnderlag(supabase as never, r.company_id, r.id, r.document_id),
])
const sel = await selectAccount({
transaction: {
merchantName: r.merchant_name,
description: r.description ?? r.original_description ?? '',
amount: r.amount,
date: r.date,
currency: r.currency,
},
underlag,
candidates,
entityType: ctx.entityType as never,
vatRegistered: ctx.vatRegistered,
samples: 1,
})
results.push({
merchant: (r.merchant_name ?? '').slice(0, 22),
truth: gt,
proposed: sel.account,
conf: sel.confidence,
fromCandidate: sel.fromCandidate,
topCandidate: candidates[0]?.account ?? null,
hadUnderlag: underlag.length > 0,
correct: sel.account ? sel.account === gt : null,
})
}
for (let i = 0; i < rows.length; i += CONCURRENCY) {
await Promise.all(rows.slice(i, i + CONCURRENCY).map((r) => run(r).catch((e) => console.error('row failed', e?.message))))
process.stdout.write('.')
}
console.log('\n')
// Per-row.
for (const r of results) {
const mark = r.correct === null ? '·' : r.correct ? '✓' : '✗'
console.log(
`${mark} ${r.merchant.padEnd(22)} truth=${(r.truth ?? '—').padEnd(6)} pick=${(r.proposed ?? 'review').padEnd(6)} ` +
`conf=${r.conf.toFixed(2)} ${r.fromCandidate ? 'cand' : 'cat '} ${r.hadUnderlag ? 'underlag' : ' '} topcand=${r.topCandidate ?? '—'}`,
)
}
const scored = results.filter((r) => r.correct !== null)
const acc = (xs: Result[]) => (xs.length ? (xs.filter((r) => r.correct).length / xs.length) : 0)
const detWrong = scored.filter((r) => r.topCandidate !== r.truth) // deterministic top candidate was NOT the answer
const withU = scored.filter((r) => r.hadUnderlag)
console.log('\n──────── summary ────────')
console.log(`scored: ${scored.length} / ${results.length} (rest = needs_review)`)
console.log(`overall accuracy: ${(acc(scored) * 100).toFixed(1)}%`)
console.log(` model-decided (top candidate ≠ truth): ${(acc(detWrong) * 100).toFixed(1)}% (n=${detWrong.length}) ← leakage-free`)
console.log(` with underlag: ${(acc(withU) * 100).toFixed(1)}% (n=${withU.length})`)
console.log(`needs_review rate: ${(((results.length - scored.length) / Math.max(1, results.length)) * 100).toFixed(1)}%`)
console.log(`reliability (conf ≥0.8): ${(acc(scored.filter((r) => r.conf >= 0.8)) * 100).toFixed(1)}% (n=${scored.filter((r) => r.conf >= 0.8).length})`)
console.log(`reliability (conf <0.5): ${(acc(scored.filter((r) => r.conf < 0.5)) * 100).toFixed(1)}% (n=${scored.filter((r) => r.conf < 0.5).length})`)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})