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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
be0478c219
commit
c6ca119e73
@@ -16,6 +16,13 @@ vi.mock('@/lib/parties/scb/client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/lib/parties/scb/client')>()),
|
||||
createScbClient: () => ({ lookupByOrgNumber, searchByName }),
|
||||
}))
|
||||
const readCounterpartName = vi.fn()
|
||||
const ai = { available: false, capability: true }
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({ hasCapability: () => Promise.resolve(ai.capability) }))
|
||||
vi.mock('@/lib/parties/ai-name', () => ({
|
||||
readCounterpartName: (texts: string[]) => readCounterpartName(texts),
|
||||
aiNameAvailable: () => ai.available,
|
||||
}))
|
||||
const configured = { value: true }
|
||||
vi.mock('@/lib/parties/scb/config', () => ({
|
||||
isScbConfigured: () => configured.value,
|
||||
@@ -40,6 +47,8 @@ beforeEach(() => {
|
||||
reset()
|
||||
eventBus.clear()
|
||||
configured.value = true
|
||||
ai.available = false
|
||||
ai.capability = true
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user } })
|
||||
})
|
||||
|
||||
@@ -153,7 +162,7 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
|
||||
searchByName.mockResolvedValue(result)
|
||||
const a = await parseJsonResponse<{ data: typeof result }>(await candidates())
|
||||
expect(a.status).toBe(200)
|
||||
expect(a.body.data).toEqual({ ...result, queries: ['Adobe Systems Software'], foreign: null })
|
||||
expect(a.body.data).toEqual({ ...result, queries: ['Adobe Systems Software'], foreign: null, aiRead: null })
|
||||
expect(searchByName).toHaveBeenLastCalledWith('Adobe Systems Software')
|
||||
enqueue({ data: { id: PARTY, display_name: 'Adobe Systems Software', legal_name: null } })
|
||||
await candidates('Adobe Nordic')
|
||||
@@ -165,7 +174,7 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
|
||||
const hit = { query: 'TIC identity', mode: 'starts_with', total: 1, truncated: false, candidates: [{ orgNumber: '5567890123', name: 'TIC Identity AB', active: true }] }
|
||||
enqueue({ data: { id: PARTY, display_name: 'TIC identity', legal_name: null } })
|
||||
enqueue({
|
||||
data: [{ value: ['TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'] }],
|
||||
data: [{ field: 'voucher_text', value: ['TIC identity BG 0000005786439 Bg-bet. via internet · Faktura 20250746, The Intelligence Company AB (publ). TIC Identity-abonnemang.'] }],
|
||||
})
|
||||
searchByName.mockResolvedValueOnce(miss).mockResolvedValueOnce(hit)
|
||||
const { status, body } = await parseJsonResponse<{ data: { query: string; queries: string[]; candidates: unknown[] } }>(await candidates())
|
||||
@@ -175,9 +184,56 @@ describe('GET /api/parties/[id]/enrich/candidates', () => {
|
||||
expect(body.data.candidates).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('lets the model read a bank memo once, keeps the reading as a fact, and searches on it', async () => {
|
||||
ai.available = true
|
||||
readCounterpartName.mockResolvedValue({ name: 'Booking.com', country: 'NL', vatNumber: null, confidence: 'high', model: 'm' })
|
||||
enqueue({ data: { id: PARTY, display_name: 'Hotel at Booking.com', legal_name: null } })
|
||||
enqueue({ data: [{ field: 'voucher_text', value: ['Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'] }] })
|
||||
enqueue({ data: { recorded: 1 } }) // record_party_facts
|
||||
const { status, body } = await parseJsonResponse<{ data: { queries: string[]; foreign: unknown; aiRead: unknown; candidates: unknown[] } }>(await candidates())
|
||||
expect(status).toBe(200)
|
||||
expect(readCounterpartName).toHaveBeenCalledWith(['Hotel at Booking.com', 'Hotel at Booking.com K3667 Kortköp/uttag · Hotell, svenskt boende, 12% moms'])
|
||||
// A Dutch reading: no SCB call, the picker explains, the reading is shown.
|
||||
expect(searchByName).not.toHaveBeenCalled()
|
||||
expect(body.data.foreign).toEqual({ name: 'Booking.com', country: 'NL' })
|
||||
expect(body.data.aiRead).toEqual({ name: 'Booking.com', country: 'NL' })
|
||||
const rpc = mockSupabase.rpc.mock.calls.find((c) => c[0] === 'record_party_facts')
|
||||
expect(rpc?.[1]).toMatchObject({ p_source: 'model', p_party_id: PARTY, p_facts: [{ field: 'ai_name' }] })
|
||||
|
||||
// Cached: no second model call, and a Swedish reading is searched for.
|
||||
readCounterpartName.mockClear()
|
||||
enqueue({ data: { id: PARTY, display_name: 'UBER *TRIP HELP.UBER.COM', legal_name: null } })
|
||||
enqueue({ data: [{ field: 'ai_name', value: { name: 'Uber Sweden AB', country: 'SE', vatNumber: null, confidence: 'high', model: 'm' } }] })
|
||||
searchByName.mockResolvedValue({ query: 'Uber Sweden', mode: 'starts_with', total: 1, truncated: false, candidates: [{ orgNumber: '5567890123', name: 'Uber Sweden AB', active: true }] })
|
||||
const second = await parseJsonResponse<{ data: { queries: string[]; aiRead: unknown } }>(await candidates())
|
||||
expect(readCounterpartName).not.toHaveBeenCalled()
|
||||
expect(searchByName).toHaveBeenLastCalledWith('Uber Sweden')
|
||||
expect(second.body.data.aiRead).toEqual({ name: 'Uber Sweden AB', country: 'SE' })
|
||||
})
|
||||
|
||||
it('does not call the model when the rules already anchored a name, when the company lacks the AI capability, or when no model is configured', async () => {
|
||||
ai.available = true
|
||||
enqueue({ data: { id: PARTY, display_name: 'Visma Spcs AB', legal_name: null } })
|
||||
enqueue({ data: [] })
|
||||
searchByName.mockResolvedValue({ query: 'Visma Spcs', mode: 'starts_with', total: 1, truncated: false, candidates: [] })
|
||||
await candidates()
|
||||
expect(readCounterpartName).not.toHaveBeenCalled()
|
||||
ai.capability = false
|
||||
enqueue({ data: { id: PARTY, display_name: 'Hotel at Booking.com', legal_name: null } })
|
||||
enqueue({ data: [] })
|
||||
await candidates()
|
||||
expect(readCounterpartName).not.toHaveBeenCalled()
|
||||
ai.capability = true
|
||||
ai.available = false
|
||||
enqueue({ data: { id: PARTY, display_name: 'Hotel at Booking.com', legal_name: null } })
|
||||
enqueue({ data: [] })
|
||||
await candidates()
|
||||
expect(readCounterpartName).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never asks SCB about a foreign company, and says which one it read', async () => {
|
||||
enqueue({ data: { id: PARTY, display_name: 'Framer B.V.', legal_name: null } })
|
||||
enqueue({ data: [{ value: ['Utlägg Framer · Framer B.V. (NL), webbdesignverktyg.'] }] })
|
||||
enqueue({ data: [{ field: 'voucher_text', value: ['Utlägg Framer · Framer B.V. (NL), webbdesignverktyg.'] }] })
|
||||
const { status, body } = await parseJsonResponse<{ data: { queries: string[]; candidates: unknown[]; foreign: unknown } }>(await candidates())
|
||||
expect(status).toBe(200)
|
||||
expect(searchByName).not.toHaveBeenCalled()
|
||||
|
||||
@@ -6,7 +6,10 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { createScbClient, type ScbSearchResult } from '@/lib/parties/scb/client'
|
||||
import { isScbConfigured, scbConfigFromEnv } from '@/lib/parties/scb/config'
|
||||
import { ScbApiError } from '@/lib/parties/scb/transport'
|
||||
import { planRegistryQueries, type RegistryCandidatesResult } from '@/lib/parties/registry-search'
|
||||
import { needsModelReading, planRegistryQueries, type RegistryCandidatesResult } from '@/lib/parties/registry-search'
|
||||
import { readCounterpartName, aiNameAvailable, type AiNameReading } from '@/lib/parties/ai-name'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
/**
|
||||
* GET /api/parties/[id]/enrich/candidates?q=: SCB companies whose name
|
||||
@@ -19,7 +22,7 @@ import { planRegistryQueries, type RegistryCandidatesResult } from '@/lib/partie
|
||||
*/
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'parties.enrich.candidates',
|
||||
async (request, { supabase, companyId, log, requestId }, { params }) => {
|
||||
async (request, { supabase, companyId, user, log, requestId }, { params }) => {
|
||||
const { id } = await params
|
||||
if (!/^[0-9a-f-]{36}$/i.test(id)) return errorResponseFromCode('NOT_FOUND', log, { requestId })
|
||||
const validated = validateQuery(request, PartySearchRegistryQuerySchema, { log, operation: 'parties.enrich.candidates' })
|
||||
@@ -40,21 +43,58 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
const explicit = validated.data.q?.trim()
|
||||
let queries: string[]
|
||||
let foreign: RegistryCandidatesResult['foreign'] = null
|
||||
let aiRead: RegistryCandidatesResult['aiRead'] = null
|
||||
if (explicit) {
|
||||
queries = [explicit]
|
||||
} else {
|
||||
const { data: textFacts, error: factsError } = await supabase
|
||||
const { data: facts, error: factsError } = await supabase
|
||||
.from('party_facts')
|
||||
.select('value')
|
||||
.select('field, value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('party_id', id)
|
||||
.eq('field', 'voucher_text')
|
||||
.in('field', ['voucher_text', 'ai_name'])
|
||||
.is('superseded_at', null)
|
||||
if (factsError) throw new Error(`party_facts lookup failed: ${factsError.message}`)
|
||||
const voucherTexts = ((textFacts ?? []) as Array<{ value: unknown }>).flatMap((f) =>
|
||||
Array.isArray(f.value) ? f.value.filter((v): v is string => typeof v === 'string') : [],
|
||||
)
|
||||
const plan = planRegistryQueries({ legalName: p.legal_name, displayName: p.display_name, voucherTexts })
|
||||
const rows = (facts ?? []) as Array<{ field: string; value: unknown }>
|
||||
const voucherTexts = rows
|
||||
.filter((f) => f.field === 'voucher_text')
|
||||
.flatMap((f) => (Array.isArray(f.value) ? f.value.filter((v): v is string => typeof v === 'string') : []))
|
||||
let plan = planRegistryQueries({ legalName: p.legal_name, displayName: p.display_name, voucherTexts })
|
||||
|
||||
// A bank memo the rules could not anchor: the model reads it once,
|
||||
// the reading is kept as a fact with source 'model', and the search
|
||||
// runs on the reading. A rebuilt queue does not repeat the call. Same
|
||||
// gate as every other model call on the company's data: the AI
|
||||
// capability, which the company holds by plan and can switch off.
|
||||
if (needsModelReading(plan) && aiNameAvailable() && (await hasCapability(supabase, companyId, CAPABILITY.ai))) {
|
||||
const cached = rows.find((f) => f.field === 'ai_name')?.value as Partial<AiNameReading> | undefined
|
||||
let reading: AiNameReading | null =
|
||||
cached && typeof cached === 'object' && 'name' in cached
|
||||
? { name: cached.name ?? null, country: cached.country ?? null, vatNumber: cached.vatNumber ?? null, confidence: cached.confidence ?? 'low', model: cached.model ?? '' }
|
||||
: null
|
||||
if (!reading) {
|
||||
reading = await readCounterpartName([p.display_name, ...voucherTexts])
|
||||
if (reading) {
|
||||
const { error: recordError } = await supabase.rpc('record_party_facts', {
|
||||
p_company_id: companyId,
|
||||
p_user_id: user.id,
|
||||
p_party_id: id,
|
||||
p_source: 'model',
|
||||
p_facts: [{ field: 'ai_name', value: reading, reference: { model: reading.model, texts: voucherTexts.length } }],
|
||||
p_fetched_at: new Date().toISOString(),
|
||||
})
|
||||
if (recordError) log.warn('record_party_facts (model reading) failed', { partyId: id, message: recordError.message })
|
||||
}
|
||||
}
|
||||
if (reading?.name) {
|
||||
aiRead = { name: reading.name, country: reading.country }
|
||||
const readPlan = planRegistryQueries({ legalName: reading.name, displayName: p.display_name, voucherTexts: [] })
|
||||
plan =
|
||||
reading.country && reading.country !== 'SE' && !readPlan.candidates.some((c) => c.source === 'legal_form' && !c.foreign)
|
||||
? { queries: [], foreign: { name: reading.name, country: reading.country }, candidates: readPlan.candidates }
|
||||
: readPlan
|
||||
}
|
||||
}
|
||||
queries = plan.queries
|
||||
foreign = plan.foreign
|
||||
}
|
||||
@@ -68,6 +108,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
candidates: [],
|
||||
queries: [],
|
||||
foreign,
|
||||
aiRead,
|
||||
}
|
||||
return NextResponse.json({ data: empty })
|
||||
}
|
||||
@@ -79,7 +120,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
last = await client.searchByName(q)
|
||||
if (last.candidates.length > 0 || last.truncated) break
|
||||
}
|
||||
const result: RegistryCandidatesResult = { ...(last as ScbSearchResult), queries, foreign }
|
||||
const result: RegistryCandidatesResult = { ...(last as ScbSearchResult), queries, foreign, aiRead }
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
log.warn('scb search failed', { partyId: id, status: err instanceof ScbApiError ? err.status : undefined, message: err instanceof Error ? err.message : String(err) })
|
||||
|
||||
Reference in New Issue
Block a user