69d3bba587
* feat(parties): selection-step evaluation against document-anchored truth Scores which similar keys are the same party without human labels: every key in the set carries an org number OCR-read from a linked invoice, so two keys are the same party exactly when the org numbers agree. Rules and the Bedrock model both land at 0.91 pair precision; the residual false merges are different legal entities sharing a trade name, which text cannot and should not separate. Results and caveats in the README. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): make the model selector opt-in in the selection eval Sending voucher key text to the AI provider now requires --llm; the default run scores the rules selector only and makes no network call. Documents that the opt-in path uses the same configured provider the production categorizer already sends the same text to. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
227 lines
9.7 KiB
TypeScript
227 lines
9.7 KiB
TypeScript
/**
|
|
* Parties, phase 0/1: shadow evaluation of the SELECTION step against
|
|
* document-anchored ground truth.
|
|
*
|
|
* WHAT: after blocking, the resolver must decide which of at most six
|
|
* similar keys in the same company are the same party as an anchor key, or
|
|
* none. Ground truth here needs no human: every key in the set carries an
|
|
* org number read by OCR from an invoice linked to its voucher, so two keys
|
|
* are the same party exactly when their org numbers agree. The draw is
|
|
* `draw-selection-gold.sql`; keys that map to several org numbers are
|
|
* excluded because they are ambiguous by construction.
|
|
*
|
|
* Two selectors are scored on the same anchors:
|
|
* 1. rules: a candidate is "same" when its core (AP prefix, supplier number
|
|
* and digit runs stripped) equals the anchor's core;
|
|
* 2. the model behind getAiService().generateStructured, asked to return the
|
|
* indices of candidates that are the same organisation, or none.
|
|
* Metrics are pair-level (same / different) precision and recall, plus the
|
|
* anchor-level "none" decision, because a resolver that merges where it
|
|
* should not is the failure mode the July design warned about.
|
|
*
|
|
* SAFETY: read-only. Reads a gitignored JSONL, writes a report next to the
|
|
* input. Never opens a database connection.
|
|
*
|
|
* DATA PROCESSING: the model selector is opt-in (--llm). It sends the voucher
|
|
* key text and account numbers in the gold file to getAiService(), which is
|
|
* the same configured provider the production categorizer already sends the
|
|
* same voucher text to (Claude on AWS Bedrock in the EU on hosted). Run it
|
|
* only with an env file whose AI configuration you have checked; without
|
|
* --llm the script scores the rules selector alone and makes no network call.
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/parties/eval-selection.ts \
|
|
* --gold dev_docs/parties/golden/selection-2026-09-02.jsonl --env .env.local \
|
|
* [--out <report.json>] [--llm] [--limit 300]
|
|
*/
|
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { config as dotenv } from 'dotenv'
|
|
import { z } from 'zod'
|
|
|
|
interface Cand {
|
|
k: string
|
|
example: string
|
|
n: number
|
|
acct: string | null
|
|
org: string
|
|
sim: number
|
|
}
|
|
interface Anchor {
|
|
rn: number
|
|
anchor_k: string
|
|
anchor_example: string
|
|
anchor_n: number
|
|
anchor_acct: string | null
|
|
anchor_org: string
|
|
candidates: Cand[]
|
|
}
|
|
|
|
function arg(name: string): string | undefined {
|
|
const i = process.argv.indexOf(`--${name}`)
|
|
return i >= 0 ? process.argv[i + 1] : undefined
|
|
}
|
|
const flag = (name: string) => process.argv.includes(`--${name}`)
|
|
|
|
const goldPath = resolve(arg('gold') ?? 'dev_docs/parties/golden/selection-2026-09-02.jsonl')
|
|
const envPath = resolve(arg('env') ?? '.env.local')
|
|
const outPath = resolve(arg('out') ?? goldPath.replace(/\.jsonl$/, '') + '.eval.json')
|
|
const limit = Number(arg('limit') ?? 300)
|
|
const runLlm = flag('llm')
|
|
dotenv({ path: envPath })
|
|
|
|
const anchors: Anchor[] = readFileSync(goldPath, 'utf8')
|
|
.split('\n')
|
|
.filter(Boolean)
|
|
.map((l) => JSON.parse(l) as Anchor)
|
|
.slice(0, limit)
|
|
|
|
// ── Rules selector ──────────────────────────────────────────────────────────
|
|
const AP = /^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura från|leverantörsfaktura|levbet\.?|kvitto|faktura|utgift|inköp)\s+/
|
|
const SUFFIX = /\b(ab|aktiebolag|hb|kb|sverige|sweden|ltd|limited|oy|gmbh|inc|sarl|publ|filial)\b/g
|
|
export function core(k: string): string {
|
|
return k
|
|
.toLowerCase()
|
|
.replace(AP, '')
|
|
.replace(/\b\d+\b/g, '')
|
|
.replace(SUFFIX, '')
|
|
.replace(/[^a-zåäöé ]+/g, ' ')
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
}
|
|
function rulesSelect(a: Anchor): number[] {
|
|
const ac = core(a.anchor_k)
|
|
if (!ac) return []
|
|
return a.candidates.map((c, i) => (core(c.k) === ac ? i : -1)).filter((i) => i >= 0)
|
|
}
|
|
|
|
// ── Model selector ──────────────────────────────────────────────────────────
|
|
const SYSTEM = `Du avgör identitet mellan motparter i svensk bokföring.
|
|
Du får ett ankare (en normaliserad verifikationstext från ett bolags bokföring) och upp till sex kandidater ur samma bolag som liknar det.
|
|
Svara med index för varje kandidat som är SAMMA organisation som ankaret, alltså samma juridiska person som skulle ha samma organisationsnummer. Olika bolag i samma koncern, franchisetagare med samma kedjenamn, eller ett företag och dess inkassobolag är INTE samma.
|
|
Leverantörsnummer inom parentes, fakturanummer och prefix som "levfakt" eller "leverantörsfaktura från" är brus, inte identitet.
|
|
Om ingen kandidat är samma organisation, svara med en tom lista. Gissa inte: hellre tom lista än en felaktig sammanslagning.`
|
|
|
|
const Out = z.object({ same: z.array(z.number().int().min(1).max(6)) })
|
|
const jsonSchema = {
|
|
type: 'object',
|
|
properties: { same: { type: 'array', items: { type: 'integer', minimum: 1, maximum: 6 } } },
|
|
required: ['same'],
|
|
additionalProperties: false,
|
|
}
|
|
|
|
async function modelSelect(a: Anchor): Promise<{ picks: number[]; usage: unknown; model: string }> {
|
|
const { getAiService } = await import('@/lib/ai')
|
|
const service = getAiService()
|
|
const prompt =
|
|
`Ankare: "${a.anchor_k}" (exempel "${a.anchor_example}", konto ${a.anchor_acct ?? '?'}, ${a.anchor_n} verifikat)\n` +
|
|
`Kandidater:\n` +
|
|
a.candidates
|
|
.map((c, i) => `[${i + 1}] "${c.k}" (exempel "${c.example}", konto ${c.acct ?? '?'}, ${c.n} verifikat, textlikhet ${c.sim})`)
|
|
.join('\n') +
|
|
`\nVilka kandidater är samma organisation som ankaret? Svara med index, eller tom lista.`
|
|
let lastErr: unknown
|
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
try {
|
|
const r = await service.generateStructured({
|
|
tier: 'assistant',
|
|
system: SYSTEM,
|
|
prompt,
|
|
maxTokens: 512,
|
|
schema: { name: 'same_party', description: 'Indices of candidates that are the same organisation', jsonSchema },
|
|
})
|
|
const parsed = Out.parse(r.value)
|
|
const picks = [...new Set(parsed.same.map((i) => i - 1).filter((i) => i >= 0 && i < a.candidates.length))]
|
|
return { picks, usage: r.usage, model: r.model }
|
|
} catch (e) {
|
|
lastErr = e
|
|
}
|
|
}
|
|
throw lastErr
|
|
}
|
|
|
|
// ── Scoring ─────────────────────────────────────────────────────────────────
|
|
interface Score {
|
|
anchors: number
|
|
pairs: number
|
|
pair_precision: number
|
|
pair_recall: number
|
|
pair_tnr: number
|
|
anchor_exact: number
|
|
none_precision: number
|
|
none_recall: number
|
|
false_merges: { anchor: string; candidate: string; sim: number }[]
|
|
missed: { anchor: string; candidate: string; sim: number }[]
|
|
}
|
|
function score(picksByAnchor: Map<number, number[]>): Score {
|
|
let tp = 0, fp = 0, fn = 0, tn = 0, exact = 0, pairs = 0
|
|
let noneTp = 0, noneFp = 0, noneFn = 0
|
|
const falseMerges: Score['false_merges'] = []
|
|
const missed: Score['missed'] = []
|
|
for (const a of anchors) {
|
|
const picks = new Set(picksByAnchor.get(a.rn) ?? [])
|
|
const truth = new Set(a.candidates.map((c, i) => (c.org === a.anchor_org ? i : -1)).filter((i) => i >= 0))
|
|
let ok = true
|
|
a.candidates.forEach((c, i) => {
|
|
pairs++
|
|
const p = picks.has(i), t = truth.has(i)
|
|
if (p && t) tp++
|
|
else if (p && !t) { fp++; falseMerges.push({ anchor: a.anchor_k, candidate: c.k, sim: c.sim }) }
|
|
else if (!p && t) { fn++; missed.push({ anchor: a.anchor_k, candidate: c.k, sim: c.sim }) }
|
|
else tn++
|
|
if (p !== t) ok = false
|
|
})
|
|
if (ok) exact++
|
|
const predNone = picks.size === 0, truthNone = truth.size === 0
|
|
if (predNone && truthNone) noneTp++
|
|
else if (predNone && !truthNone) noneFp++
|
|
else if (!predNone && truthNone) noneFn++
|
|
}
|
|
const r4 = (x: number) => Math.round(x * 10000) / 10000
|
|
return {
|
|
anchors: anchors.length,
|
|
pairs,
|
|
pair_precision: r4(tp / Math.max(1, tp + fp)),
|
|
pair_recall: r4(tp / Math.max(1, tp + fn)),
|
|
pair_tnr: r4(tn / Math.max(1, tn + fp)),
|
|
anchor_exact: r4(exact / anchors.length),
|
|
none_precision: r4(noneTp / Math.max(1, noneTp + noneFp)),
|
|
none_recall: r4(noneTp / Math.max(1, noneTp + noneFn)),
|
|
false_merges: falseMerges.slice(0, 40),
|
|
missed: missed.slice(0, 40),
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const truthNone = anchors.filter((a) => !a.candidates.some((c) => c.org === a.anchor_org)).length
|
|
console.log(`anchors ${anchors.length}, pairs ${anchors.reduce((s, a) => s + a.candidates.length, 0)}, anchors with no true match ${truthNone}`)
|
|
const rules = new Map<number, number[]>(anchors.map((a) => [a.rn, rulesSelect(a)]))
|
|
const report: Record<string, unknown> = { gold: goldPath, rules_v0: score(rules) }
|
|
if (runLlm) {
|
|
const picks = new Map<number, number[]>()
|
|
const usages: unknown[] = []
|
|
let model = ''
|
|
let i = 0
|
|
for (const a of anchors) {
|
|
const r = await modelSelect(a)
|
|
picks.set(a.rn, r.picks)
|
|
usages.push(r.usage)
|
|
model = r.model
|
|
if (++i % 25 === 0) console.log(`model: ${i}/${anchors.length}`)
|
|
}
|
|
report.model_zero_shot = { model, usages, ...score(picks) }
|
|
}
|
|
writeFileSync(outPath, JSON.stringify(report, null, 2))
|
|
const line = (name: string, s: Score) =>
|
|
`${name.padEnd(16)} pair P ${s.pair_precision} R ${s.pair_recall} TNR ${s.pair_tnr} | anchor exact ${s.anchor_exact} | none P ${s.none_precision} R ${s.none_recall} (anchors ${s.anchors}, pairs ${s.pairs})`
|
|
console.log(line('rules_v0', report.rules_v0 as Score))
|
|
if (runLlm) console.log(line('model_zero_shot', report.model_zero_shot as Score))
|
|
console.log(`report: ${outPath}`)
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e)
|
|
process.exit(1)
|
|
})
|