feat(parties): one suggestion per legal person, rename on rebuild, review list for SCB matches, model reading for memos (#2274)

* fix(parties): one suggestion per legal person, and a later run may rename an untouched one

Found while walking the queue end to end: two voucher keys naming the same
company ("TIC identity · … The Intelligence Company AB (publ)" and
"Utbetalning leverantörsfaktura …, The Intelligence Company AB (publ)")
became two suggestions and, after Lägg upp, two suppliers; and a suggestion
made before the legal-form anchoring kept its sentence-long name for good,
because apply_party_suggestions never touched a name.

- Suggestions whose display name is anchored on a legal form read out of
  the voucher text (name_anchored) are grouped: one item, both keys as
  aliases, stats summed. Such a name also attaches to an existing party
  called exactly that, legal form included, unless an org number on either
  side says otherwise. Registered company names are unique in Sweden; a
  bank memo never groups or attaches by name.
- Migration 20260904030000: apply_party_suggestions renames a suggestion
  nobody has touched (no decision, no user or registry fact) to an anchored
  name from a later run, and reports 'renamed'. Confirmed and decided
  parties keep their names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): read legal_name for exact-name attach; say a row is foreign instead of offering SCB

next build: ExistingParty had no legal_name, so the exact-legal-name index
did not compile. The query now selects it.

Queue rows whose voucher text places the company abroad show
"Utländskt bolag (Nederländerna), finns inte i SCB" instead of a search
that cannot succeed; the promote dialog counts them separately from rows
that merely lack an org number; the dossier shows the country.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): carry country on the dossier row

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(parties): one review list for SCB matches, a model reading for bank memos, refresh demoted

- Review list ("Hitta org.nr (n)" in the queue toolbar): every suggestion
  SCB could hold but that lacks an org number is asked for, one row at a
  time under SCB's rate limit; rows with exactly one active match are
  shown ticked and approved in one click, the rest keep the per-row
  picker. Nothing is written before the click.
- Model reading (lib/parties/ai-name.ts, through getAiService): when the
  rules find no legal form or country in the texts, one call reads the
  counterpart out of the bank memo; kept as a 'model' fact, shown as
  "Läst ur verifikatet", used as the query, never as a hard key. On
  demand only, never when the queue builds.
- "Uppdatera förslag" moves from the page header to a ghost button in the
  toolbar: the queue builds itself now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): review list passes the dialog overflow guard; plural for match counts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): gate the model reading on the company's AI capability

Same gate as every other model call on company data: the capability the
company holds by plan and can switch off. No call, no fact, no reading
without it.

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:
Jakob Wennberg
2026-09-04 17:01:10 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent be0478c219
commit c6ca119e73
18 changed files with 1014 additions and 41 deletions
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const generateStructured = vi.fn()
const status = { configured: true }
vi.mock('@/lib/ai', () => ({
getAiService: () => ({ generateStructured }),
getAiStatus: () => ({ configured: status.configured }),
}))
import { readCounterpartName, aiNameAvailable } from '../ai-name'
beforeEach(() => {
vi.clearAllMocks()
status.configured = true
})
describe('readCounterpartName', () => {
it('reads the counterpart out of a card memo and normalises country and VAT', async () => {
generateStructured.mockResolvedValue({
value: { name: 'Booking.com', country: 'nl', vat_number: 'NL 805734958 B01', confidence: 'high' },
model: 'test-model',
usage: {},
})
const r = await readCounterpartName(['Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'])
expect(r).toEqual({ name: 'Booking.com', country: 'NL', vatNumber: 'NL805734958B01', confidence: 'high', model: 'test-model' })
const req = generateStructured.mock.calls[0]![0] as { tier: string; prompt: string; schema: { name: string } }
expect(req.tier).toBe('extraction')
expect(req.prompt).toContain('1. Hotel at Booking.com')
expect(req.schema.name).toBe('counterpart_reading')
})
it('keeps a null name, drops malformed country and VAT values, and sends at most three distinct texts', async () => {
generateStructured.mockResolvedValue({ value: { name: null, country: 'Sweden', vat_number: '123', confidence: 'weird' }, model: 'm', usage: {} })
const r = await readCounterpartName(['a', 'a', 'b', 'c', 'd'])
expect(r).toEqual({ name: null, country: null, vatNumber: null, confidence: 'low', model: 'm' })
const req = generateStructured.mock.calls[0]![0] as { prompt: string }
expect(req.prompt).toContain('3. c')
expect(req.prompt).not.toContain('4. d')
})
it('answers null without a call when the deployment has no model, on empty input, on a bad answer, and on an error', async () => {
status.configured = false
expect(aiNameAvailable()).toBe(false)
expect(await readCounterpartName(['x'])).toBeNull()
expect(generateStructured).not.toHaveBeenCalled()
status.configured = true
expect(await readCounterpartName(['', ' '])).toBeNull()
generateStructured.mockResolvedValueOnce({ value: 'not an object', model: 'm', usage: {} })
expect(await readCounterpartName(['x'])).toBeNull()
generateStructured.mockRejectedValueOnce(new Error('boom'))
expect(await readCounterpartName(['x'])).toBeNull()
})
})
+39
View File
@@ -118,6 +118,45 @@ describe('buildSuggestions', () => {
expect(revenue.facts.some((f) => f.field === 'vat_number')).toBe(false)
})
it('groups keys that name the same legal person into one suggestion, and attaches to an existing party by exact legal name', () => {
const tic1 = 'TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'
const tic2 = 'Utbetalning leverantörsfaktura 20250928, The Intelligence Company AB (publ)'
const r = buildSuggestions({
observed: [
observed({ key: 'tic identity', name: tic1, expense_sek: 2385, occurrences: 1, first_seen: '2026-02-01', last_seen: '2026-02-01' }),
observed({ key: 'utbetalning leverantörsfaktura the intelligence company publ', name: tic2, expense_sek: 2385, occurrences: 1, first_seen: '2026-03-01', last_seen: '2026-03-01' }),
],
evidence: [],
existing: [],
})
expect(r.items).toHaveLength(1)
const item = r.items[0]!
expect(item.display_name).toBe('The Intelligence Company AB (publ)')
expect(item.name_anchored).toBe(true)
expect(item.alias_keys).toEqual(['tic identity', 'utbetalning leverantörsfaktura the intelligence company publ'])
expect(item.reason.occurrences).toBe(2)
expect(item.reason.expense_sek).toBe(4770)
expect(item.reason.first_seen).toBe('2026-02-01')
expect(item.reason.last_seen).toBe('2026-03-01')
const confirmed: ExistingParty = { id: 'p-tic', display_name: 'The Intelligence Company AB (publ)', org_number: '5594871682', alias_keys: [], status: 'confirmed' }
const attached = buildSuggestions({ observed: [observed({ key: 'tic identity', name: tic1 })], evidence: [], existing: [confirmed] }).items[0]!
expect(attached.party_id).toBe('p-tic')
expect(attached.reason.attach).toBe('legal_name')
// A different org number on the key side is a different company with a confusable name.
const other = buildSuggestions({
observed: [observed({ key: 'tic identity', name: tic1 })],
evidence: [{ key: 'tic identity', docs: 1, self_docs: 0, orgs: [{ org: '5560125790', n: 1 }], vat_numbers: [], names: [], bankgiro: [], plusgiro: [] }],
existing: [confirmed],
}).items[0]!
expect(other.party_id).toBeUndefined()
// A bank memo head never groups or attaches by name.
const memo = buildSuggestions({ observed: [observed({ key: 'beijer byggmaterial', name: 'BEIJER BYGGMATERIAL 2089' })], evidence: [], existing: [{ id: 'p-b', display_name: 'BEIJER BYGGMATERIAL', org_number: null, alias_keys: [], status: 'confirmed' }] }).items[0]!
expect(memo.party_id).toBeUndefined()
expect(memo.name_anchored).toBeUndefined()
})
it('withholds the hard key and identities when a key mixes two org numbers', () => {
const r = buildSuggestions({
observed: [observed({ key: 'vattenfall' })],