* 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>
54 lines
2.5 KiB
TypeScript
54 lines
2.5 KiB
TypeScript
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()
|
|
})
|
|
})
|