Files
accounted/scripts/parties/draw-selection-gold.sql
Jakob Wennberg 69d3bba587 feat(parties): selection-step evaluation against document-anchored truth (#2169)
* 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>
2026-09-02 17:38:20 +02:00

45 lines
3.2 KiB
SQL

-- Parties, phase 0/1: draw the document-anchored ground truth for the
-- SELECTION step. Read-only. Output contains customer voucher text: keep it
-- in dev_docs/parties/golden/, never in this public repository.
--
-- Truth without a human: a key whose voucher is linked to a document with an
-- OCR-read supplier org number is a known party; two keys in the same company
-- are the same party exactly when their org numbers agree. Keys that map to
-- several org numbers are dropped as ambiguous; documents whose "supplier"
-- org number is the company's own are dropped as the company's sales side.
WITH real_co AS (SELECT id, regexp_replace(coalesce(org_number,''), '[^0-9]', '', 'g') AS own_org FROM companies WHERE name NOT ILIKE '%sandl%'),
linked AS (
SELECT d.company_id, je.description,
regexp_replace(coalesce(d.extracted_data->'supplier'->>'orgNumber',''), '[^0-9]', '', 'g') AS org,
public.normalize_counterparty_key(je.description) AS k,
(SELECT l.account_number FROM journal_entry_lines l WHERE l.journal_entry_id = je.id AND l.account_number ~ '^[4-7][0-9]{3}$' ORDER BY l.debit_amount DESC LIMIT 1) AS acct
FROM document_attachments d
JOIN real_co c ON c.id = d.company_id
JOIN journal_entries je ON je.id = d.journal_entry_id AND je.status = 'posted'
WHERE d.extracted_data->'supplier'->>'orgNumber' IS NOT NULL
),
keys AS (
SELECT l.company_id, l.org, l.k, count(*) AS n, mode() WITHIN GROUP (ORDER BY l.description) AS example, mode() WITHIN GROUP (ORDER BY l.acct) AS acct,
btrim(regexp_replace(regexp_replace(l.k, '^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura från|leverantörsfaktura|levbet\.?|kvitto|faktura|utgift|inköp)\s+', '', 'g'), '\m\d+\M', '', 'g')) AS core
FROM linked l JOIN real_co c ON c.id = l.company_id
WHERE length(l.org) = 10 AND l.k <> '' AND l.org <> c.own_org
GROUP BY l.company_id, l.org, l.k
),
uniq AS (SELECT company_id, k, min(org) AS org, sum(n) AS n, min(example) AS example, min(acct) AS acct, min(core) AS core FROM keys GROUP BY company_id, k HAVING count(DISTINCT org) = 1),
co_ok AS (SELECT company_id FROM uniq GROUP BY 1 HAVING count(DISTINCT org) >= 2 AND count(*) >= 6),
anchors AS (SELECT u.*, row_number() OVER (ORDER BY md5(u.company_id::text || u.k)) AS rn FROM uniq u JOIN co_ok USING (company_id) WHERE length(u.core) >= 4),
sample AS (SELECT * FROM anchors WHERE rn <= 320),
cands AS (
SELECT s.rn AS anchor_rn, o.k, o.example, o.n, o.acct, o.org, extensions.similarity(s.core, o.core) AS sim,
row_number() OVER (PARTITION BY s.rn ORDER BY extensions.similarity(s.core, o.core) DESC, o.n DESC) AS cr
FROM sample s JOIN uniq o ON o.company_id = s.company_id AND o.k <> s.k
WHERE extensions.similarity(s.core, o.core) >= 0.25
),
agg AS (
SELECT s.rn, s.k AS anchor_k, s.example AS anchor_example, s.n AS anchor_n, s.acct AS anchor_acct, s.org AS anchor_org,
(SELECT json_agg(json_build_object('k', c.k, 'example', c.example, 'n', c.n, 'acct', c.acct, 'org', c.org, 'sim', round(c.sim::numeric, 2)) ORDER BY c.sim DESC)
FROM cands c WHERE c.anchor_rn = s.rn AND c.cr <= 6) AS candidates
FROM sample s
)
SELECT * FROM agg WHERE candidates IS NOT NULL ORDER BY rn LIMIT 300;