feat(parties): phase 0 prerequisites, pg_trgm and golden-set draw (#2157)
* feat(parties): phase 0 prerequisites, pg_trgm and golden-set draw Phase 0 of the Kontakter plan: make the counterparty resolver measurable before building it. - Migration 20260902120000 enables pg_trgm (trigram blocking of counterparty keys) and drops the two context-graph tables from 20260706193007 whose feature code was never merged and which prod no longer has, so fresh replays agree with prod. - tests/pg/parties-phase0.pg.test.ts pins the extension, a sanity check on trigram ranking, and the absence of the graph tables. - scripts/parties/draw-golden-set.sql is the reproducible, read-only draw of the 200-key labelling sample (three strata, md5-ordered) and the payee-identity base rate. The drawn rows contain customer voucher text and are kept in gitignored dev_docs, never in this public repo. - scripts/parties/README.md records the label vocabulary and the numbers measured on prod on 2026-09-02. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(archive): drop the two context-graph tables from the archive contract The migration in this PR removes graph_counterparties and graph_transaction_counterparties, so the full-archive contract must stop classifying them: tests/schema/no-phantom-columns.test.ts asserts that every classified table exists in the migration replay, and the live-DB twin in tests/pg/full-archive-coverage.pg.test.ts asserts the same against information_schema. 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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
f266c386f3
commit
61a76b1669
@@ -0,0 +1,58 @@
|
||||
# Parties, phase 0: measure before building
|
||||
|
||||
Phase 0 of the Kontakter plan (design doc: the "Vem du gör affärer med"
|
||||
artifact, 2026-09). Nothing in this phase changes product behaviour. It makes
|
||||
the resolver measurable so the model steps in later phases can be judged
|
||||
against labelled data instead of opinions.
|
||||
|
||||
## What ships in this phase
|
||||
|
||||
- Migration `20260902120000`: enables `pg_trgm` (trigram blocking of
|
||||
counterparty keys) and drops the two context-graph tables whose feature code
|
||||
was never merged and which prod no longer has. Test:
|
||||
`tests/pg/parties-phase0.pg.test.ts`.
|
||||
- `draw-golden-set.sql`: the reproducible draw of the labelling sample and the
|
||||
payee-change base rate. Read-only.
|
||||
|
||||
## The golden set (never in git)
|
||||
|
||||
The draw returns customer voucher text, which can contain person names. The
|
||||
repository is public, so the drawn rows and the labels live in
|
||||
`dev_docs/parties/golden/` (gitignored). Only aggregates and this vocabulary
|
||||
are versioned.
|
||||
|
||||
Label vocabulary for the pre-classifier, one per key:
|
||||
|
||||
| label | meaning | example |
|
||||
|---|---|---|
|
||||
| `party` | a real counterpart the company pays or invoices | `Levfakt Beijer Byggmaterial AB (2089)`, `Loopia` |
|
||||
| `category` | an expense description with no counterpart in it | `Inköp av varor`, `Banktjänster`, `Fika` |
|
||||
| `payroll` | salary, benefits, expense claims to a person | `Löneutbetalning: 2024.M.01 - anställd: 5` |
|
||||
| `adjustment` | periodisering, kostnadsföring, omföring, lagerförändring | `Periodisering av leverantörsfaktura 1311` |
|
||||
| `authority` | Skatteverket, Bolagsverket, Transportstyrelsen, kommun | `Bolagsverket ändra bolagsordning` |
|
||||
| `bank` | bank fees and bank products | `Baspaket Bank`, `Avgift Amex` |
|
||||
| `intermediary` | payment rail or marketplace, not the counterpart | `Klarna`, `Stripe`, `Zettle`, `AmazonMktplc` |
|
||||
| `unsure` | cannot tell from the text alone | |
|
||||
|
||||
A key whose text names a vendor but is booked as a category still gets
|
||||
`party`: the resolver decides identity, the account comes from the ledger.
|
||||
|
||||
## Measured on prod, 2026-09-02
|
||||
|
||||
- 113,032 imported expense vouchers across 385 real companies; 99.5% yield a
|
||||
key; 71% belong to a key that repeats within the company.
|
||||
- Payee-identity base rate: 365 suppliers have an established bankgiro on two
|
||||
or more documents; 13 of them (3.6%) carry two established bankgiro numbers,
|
||||
with an average span of 29 days between them, which reads as parallel
|
||||
accounts or OCR variance rather than changes over time. Any distinct value
|
||||
at all: 8.4%. Consequence for the signal: a new bankgiro is rare enough to
|
||||
warrant a "verify" sentence, never a block, and a value must be seen on two
|
||||
documents before it counts as known.
|
||||
|
||||
## Still to confirm in this phase
|
||||
|
||||
- Which registration flags the free SCB Företagsregistret API exposes
|
||||
(moms, arbetsgivare, F-skatt). This decides how much of rung three can skip
|
||||
TIC.
|
||||
- The pre-classifier and selection agreement bars (0.85 on the labelled set)
|
||||
are enforced by the shadow harness in phase 1, not here.
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Parties, phase 0: draw the golden set for the counterparty resolver.
|
||||
--
|
||||
-- Read-only. Run against prod through the Supabase MCP (execute_sql) or psql.
|
||||
-- The OUTPUT contains customer voucher text, which can include person names
|
||||
-- (salary vouchers, expense claims). It must never be committed to this
|
||||
-- public repository: write it to dev_docs/parties/golden/ (gitignored) and
|
||||
-- label it there. Only aggregate results and the labelling vocabulary belong
|
||||
-- in the repo.
|
||||
--
|
||||
-- Scope: posted SIE-imported vouchers (source_type = 'import') with at least
|
||||
-- one expense debit line (4xxx-7xxx), sandbox tenants excluded. Keys are the
|
||||
-- shipped normalize_counterparty_key() so the sample matches what the
|
||||
-- resolver will see. Three strata so the set is not dominated by one tenant:
|
||||
-- fleet = key seen in 5+ companies (60 rows)
|
||||
-- heavy = 20+ vouchers in fewer companies (60 rows)
|
||||
-- tail = everything else with 3+ vouchers (80 rows)
|
||||
-- Ordering by md5(key) makes the draw deterministic and re-runnable.
|
||||
|
||||
-- 1. Keys for the pre-classifier (party / category / payroll / adjustment /
|
||||
-- authority / bank / intermediary / unsure).
|
||||
WITH real_co AS (SELECT id FROM companies WHERE name NOT ILIKE '%sandl%'),
|
||||
scope AS (
|
||||
SELECT je.id, je.company_id, je.description, 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,
|
||||
(SELECT sum(l.debit_amount) FROM journal_entry_lines l
|
||||
WHERE l.journal_entry_id = je.id AND l.account_number ~ '^[4-7][0-9]{3}$') AS sek
|
||||
FROM journal_entries je JOIN real_co c ON c.id = je.company_id
|
||||
WHERE je.status = 'posted' AND je.source_type = 'import'
|
||||
AND EXISTS (SELECT 1 FROM journal_entry_lines l
|
||||
WHERE l.journal_entry_id = je.id AND l.account_number ~ '^[4-7][0-9]{3}$' AND l.debit_amount > 0)
|
||||
),
|
||||
keys AS (
|
||||
SELECT k, count(*) AS n, count(DISTINCT company_id) AS cos,
|
||||
mode() WITHIN GROUP (ORDER BY description) AS example,
|
||||
mode() WITHIN GROUP (ORDER BY acct) AS acct, round(sum(sek)) AS sek,
|
||||
(array_agg(DISTINCT left(description, 60)))[1:3] AS variants
|
||||
FROM scope WHERE k <> '' GROUP BY k HAVING count(*) >= 3
|
||||
),
|
||||
strata AS (
|
||||
SELECT *,
|
||||
CASE WHEN cos >= 5 THEN 'fleet' WHEN n >= 20 THEN 'heavy' ELSE 'tail' END AS stratum,
|
||||
row_number() OVER (PARTITION BY (CASE WHEN cos >= 5 THEN 'fleet' WHEN n >= 20 THEN 'heavy' ELSE 'tail' END)
|
||||
ORDER BY md5(k)) AS rn
|
||||
FROM keys
|
||||
)
|
||||
SELECT stratum, k, example, n, cos, acct, sek, variants
|
||||
FROM strata
|
||||
WHERE (stratum = 'fleet' AND rn <= 60) OR (stratum = 'heavy' AND rn <= 60) OR (stratum = 'tail' AND rn <= 80)
|
||||
ORDER BY stratum, rn;
|
||||
|
||||
-- 2. Base rate of supplier bankgiro changes in our own documents, to set the
|
||||
-- severity of the payee-identity signal. "Established" = a bankgiro seen on
|
||||
-- at least two documents for the same org number; a single differing value
|
||||
-- is treated as OCR noise or a one-off account.
|
||||
WITH real_co AS (SELECT id FROM companies WHERE name NOT ILIKE '%sandl%'),
|
||||
docs AS (
|
||||
SELECT d.company_id,
|
||||
regexp_replace(coalesce(d.extracted_data->'supplier'->>'orgNumber',''), '[^0-9]', '', 'g') AS org,
|
||||
regexp_replace(coalesce(d.extracted_data->'supplier'->>'bankgiro',''), '[^0-9]', '', 'g') AS bg,
|
||||
d.created_at::date AS seen
|
||||
FROM document_attachments d JOIN real_co c ON c.id = d.company_id
|
||||
WHERE length(regexp_replace(coalesce(d.extracted_data->'supplier'->>'bankgiro',''), '[^0-9]', '', 'g')) BETWEEN 7 AND 8
|
||||
AND length(regexp_replace(coalesce(d.extracted_data->'supplier'->>'orgNumber',''), '[^0-9]', '', 'g')) = 10
|
||||
),
|
||||
bg_counts AS (SELECT company_id, org, bg, count(*) AS n, min(seen) AS first_seen, max(seen) AS last_seen FROM docs GROUP BY 1,2,3),
|
||||
established AS (SELECT * FROM bg_counts WHERE n >= 2),
|
||||
per_supplier AS (
|
||||
SELECT company_id, org, count(*) AS established_bgs, sum(n) AS docs, max(last_seen) - min(first_seen) AS span_days
|
||||
FROM established GROUP BY 1,2
|
||||
)
|
||||
SELECT count(*) AS suppliers_with_established_bg,
|
||||
count(*) FILTER (WHERE established_bgs >= 2) AS suppliers_with_2_established_bgs,
|
||||
round(100.0 * count(*) FILTER (WHERE established_bgs >= 2) / nullif(count(*),0), 1) AS pct,
|
||||
round(avg(span_days) FILTER (WHERE established_bgs >= 2)) AS avg_span_days_when_changed
|
||||
FROM per_supplier;
|
||||
Reference in New Issue
Block a user