feat(mcp): structured supplier-resolution failure with candidates in create_supplier_invoice_from_inbox (#873)

* feat(mcp): structured supplier-resolution failure with candidates in create_supplier_invoice_from_inbox

When supplier resolution failed (no match on id, org_number, or exact
name), the tool threw an opaque error — a dead end for the inbox
pipeline on small ad hoc vendors, which are most of the backlog
(agent.feedback). The error prose did mention supplier_id_override, but
gave the agent no candidate ids and no machine-readable next step.

Resolution failure now returns the staged-operation envelope with
staged:false: preview carries the extracted supplier identity and
near-miss candidates (normalized-name + org-digit matching — catches
punctuation/legal-suffix OCR variants like "Polarn o Pyret" vs
"Polarn O. Pyret AB", and formatted org numbers '556677-8899' vs
'5566778899'); next hints either retry-with-override on the best
candidate or a prefilled gnubok_create_supplier. Fuzzy scores never
auto-resolve — the agent confirms against the underlag.

Part of dev_docs/mcp_optimization_plan.md (P1-4).

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

* fix(mcp): review fixes — EF org-number forms, candidate-pool truncation flag, override tenancy validation

Addresses the three substantive review-bot findings on #873:

- orgNumberKey(): canonical 10-digit key. Swedish orgnr is exactly 10
  digits; enskild firma org numbers are personnummer that appear in both
  10- and 12-digit forms — exact digit-equality missed that legitimate
  match, and >= 10 accepted non-orgnr garbage lengths.
- No silent caps: preview.candidate_pool_truncated + message note when
  the 500-supplier candidate pool is hit.
- The defaults fetch now validates supplier existence in THIS company on
  every resolution path and rejects a bad supplier_id_override with a
  clear error — the unresolved next-hint actively promotes overrides, so
  a bogus id must fail at staging, not opaquely at commit.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 10:14:16 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 28827c2613
commit 001de3c844
4 changed files with 409 additions and 9 deletions
@@ -57,6 +57,10 @@ function makeMock(opts: {
}
/** When provided, every pending_operations .insert(payload) is recorded here. */
inserts?: Array<Record<string, unknown>>
/** Served by the awaited (non-maybeSingle) suppliers list query — candidate search. */
supplierList?: Array<Record<string, unknown>>
/** Served by the .single() defaults/tenancy fetch. Pass explicit null to simulate a supplier missing from the company. */
supplierRecord?: Record<string, unknown> | null
}) {
const inboxResult = { data: opts.inbox ?? null, error: opts.inbox ? null : { message: 'not found' } }
const supplierByOrgResult = { data: opts.supplierByOrg ?? null, error: null }
@@ -79,8 +83,17 @@ function makeMock(opts: {
return Promise.resolve(supplierLookupCall === 1 ? supplierByOrgResult : supplierByNameResult)
}
}
if (prop === 'single') {
return () =>
Promise.resolve(
'supplierRecord' in opts
? { data: opts.supplierRecord, error: opts.supplierRecord ? null : { message: 'not found' } }
: { data: { id: 'resolved-supplier', default_expense_account: null }, error: null },
)
}
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(supplierByOrgResult)
return (resolve: (v: unknown) => void) =>
resolve(opts.supplierList ? { data: opts.supplierList, error: null } : supplierByOrgResult)
}
return () => supplierChain()
},
@@ -252,7 +265,7 @@ describe('gnubok_create_supplier_invoice_from_inbox — execute', () => {
).rejects.toThrow(/already converted/)
})
it('throws when supplier cannot be resolved', async () => {
it('unresolved supplier with no similar suppliers returns staged:false + create-supplier next hint', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-4',
@@ -266,9 +279,90 @@ describe('gnubok_create_supplier_invoice_from_inbox — execute', () => {
supplierByName: null,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-4' },
'company-1', 'user-1', supabase,
)) as {
staged: boolean
risk_level: string
preview: { supplier_resolution: string; candidates: unknown[]; unresolved_supplier: Record<string, unknown> }
next: { tool: string; args: Record<string, unknown> }
}
expect(result.staged).toBe(false)
expect(result.risk_level).toBe('medium')
expect(result.preview.supplier_resolution).toBe('unresolved')
expect(result.preview.candidates).toEqual([])
expect(result.preview.unresolved_supplier).toEqual({
extracted_name: 'Acme AB',
extracted_org_number: '5566778899',
})
// Next hint prefills gnubok_create_supplier from the extraction.
expect(result.next.tool).toBe('gnubok_create_supplier')
expect(result.next.args).toEqual({ name: 'Acme AB', org_number: '5566778899' })
})
it('unresolved supplier with a near-miss candidate returns it with a retry-with-override next hint', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-8',
status: 'received',
extracted_data: {
...baseExtracted,
supplier: { name: 'Polarn o Pyret' }, // OCR variant: no punctuation, no AB, no org number
},
matched_supplier_id: null,
created_supplier_invoice_id: null,
document_id: 'doc-8',
},
supplierByOrg: null,
supplierByName: null, // exact ilike on the full name misses the punctuation/suffix variant
supplierList: [
{ id: 'sup-dnb', name: 'DNB Bank AB', org_number: '5169077454' },
{ id: 'sup-polarn', name: 'Polarn O. Pyret AB', org_number: '556235-8797' },
],
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-8' },
'company-1', 'user-1', supabase,
)) as {
staged: boolean
preview: { candidates: Array<{ supplier_id: string; score: number; matched_on: string }> }
next: { tool: string; args: Record<string, unknown> }
}
expect(result.staged).toBe(false)
expect(result.preview.candidates[0]).toMatchObject({
supplier_id: 'sup-polarn',
score: 1,
matched_on: 'name',
})
// Next hint is the retry with the best candidate as override — the agent
// confirms against the underlag; fuzzy never auto-resolves.
expect(result.next.tool).toBe('gnubok_create_supplier_invoice_from_inbox')
expect(result.next.args).toEqual({ inbox_item_id: 'inbox-8', supplier_id_override: 'sup-polarn' })
})
it('rejects a supplier_id_override that does not exist in the company', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-9',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: null,
created_supplier_invoice_id: null,
document_id: 'doc-9',
},
supplierRecord: null, // tenancy fetch finds nothing for the override id
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
await expect(
tool.execute({ inbox_item_id: 'inbox-4', dry_run: true }, 'company-1', 'user-1', supabase),
).rejects.toThrow(/Cannot resolve supplier/)
tool.execute(
{ inbox_item_id: 'inbox-9', supplier_id_override: 'sup-foreign' },
'company-1', 'user-1', supabase,
),
).rejects.toThrow(/supplier_id_override sup-foreign does not match any supplier in this company/)
})
it('applies line_overrides — overridden account wins over extracted accountSuggestion', async () => {
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import {
findSupplierCandidates,
normalizeSupplierName,
orgNumberKey,
scoreSupplierName,
} from '../supplier-candidates'
describe('orgNumberKey', () => {
it('canonicalizes 10- and 12-digit forms to the same key', () => {
// Enskild firma: org number IS the personnummer; extraction often yields
// the 12-digit form while the register stores the 10-digit form.
expect(orgNumberKey('19660101-1234')).toBe('6601011234')
expect(orgNumberKey('660101-1234')).toBe('6601011234')
expect(orgNumberKey('556677-8899')).toBe('5566778899')
})
it('rejects lengths that are not Swedish org numbers', () => {
expect(orgNumberKey('12345')).toBeNull()
expect(orgNumberKey('12345678901')).toBeNull() // 11 digits
expect(orgNumberKey('')).toBeNull()
})
})
describe('normalizeSupplierName', () => {
it('lowercases, strips punctuation and legal-form suffixes', () => {
expect(normalizeSupplierName('Polarn O. Pyret AB')).toBe('polarn o pyret')
expect(normalizeSupplierName('polarn o pyret')).toBe('polarn o pyret')
expect(normalizeSupplierName('ECAB Bygg & VVS AB')).toBe('ecab bygg & vvs')
expect(normalizeSupplierName('Städarna i Uppsala Aktiebolag')).toBe('städarna i uppsala')
})
it('only strips the suffix at the end, not inside the name', () => {
expect(normalizeSupplierName('AB Volvo')).toBe('ab volvo')
expect(normalizeSupplierName('Habo Snickeri')).toBe('habo snickeri')
})
})
describe('scoreSupplierName', () => {
it('scores the Polarn OCR-variant case as an exact normalized match', () => {
expect(scoreSupplierName('Polarn o Pyret', 'Polarn O. Pyret AB')).toBe(1)
})
it('scores containment at 0.9', () => {
expect(scoreSupplierName('Polarn o Pyret', 'Polarn O Pyret Sverige AB')).toBe(0.9)
})
it('scores partial token overlap below containment', () => {
const s = scoreSupplierName('Bygg & VVS Norr', 'ECAB Bygg & VVS AB')
expect(s).toBeGreaterThan(0)
expect(s).toBeLessThan(0.9)
})
it('scores unrelated names near zero', () => {
expect(scoreSupplierName('Polarn o Pyret', 'DNB Bank AB')).toBeLessThan(0.4)
})
})
describe('findSupplierCandidates', () => {
const suppliers = [
{ id: 'sup-polarn', name: 'Polarn O. Pyret AB', org_number: '556235-8797' },
{ id: 'sup-dnb', name: 'DNB Bank AB', org_number: '5169077454' },
{ id: 'sup-ecab', name: 'ECAB Bygg & VVS AB', org_number: null },
]
it('matches org numbers across formatting variants at score 1', () => {
const c = findSupplierCandidates(suppliers, null, '5562358797')
expect(c).toHaveLength(1)
expect(c[0]).toMatchObject({ supplier_id: 'sup-polarn', score: 1, matched_on: 'org_number' })
})
it('matches a 12-digit personnummer extraction against a 10-digit stored EF org number', () => {
const withEf = [...suppliers, { id: 'sup-ef', name: 'Eriks Snickeri', org_number: '660101-1234' }]
const c = findSupplierCandidates(withEf, null, '19660101-1234')
expect(c).toHaveLength(1)
expect(c[0]).toMatchObject({ supplier_id: 'sup-ef', score: 1, matched_on: 'org_number' })
})
it('surfaces the near-miss name candidate for the Polarn case', () => {
const c = findSupplierCandidates(suppliers, 'Polarn o Pyret', null)
expect(c[0]).toMatchObject({ supplier_id: 'sup-polarn', score: 1, matched_on: 'name' })
})
it('applies the minScore threshold', () => {
const c = findSupplierCandidates(suppliers, 'Helt Annat Företagsnamn', null)
expect(c).toEqual([])
})
it('caps the candidate list and sorts by score descending', () => {
const many = Array.from({ length: 10 }, (_, i) => ({
id: `sup-${i}`,
name: `Polarn o Pyret ${i}`,
org_number: null,
}))
const c = findSupplierCandidates(many, 'Polarn o Pyret', null)
expect(c).toHaveLength(5)
expect(c.every((x, i, arr) => i === 0 || arr[i - 1].score >= x.score)).toBe(true)
})
it('returns empty for no extracted signal', () => {
expect(findSupplierCandidates(suppliers, null, null)).toEqual([])
})
})
+73 -5
View File
@@ -56,6 +56,7 @@ import {
IdempotencyKeyReuseError,
} from '@/lib/api/idempotency'
import { toToolError, type NextActionHint } from './tool-result'
import { findSupplierCandidates } from './supplier-candidates'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
@@ -7463,7 +7464,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_supplier_invoice_from_inbox',
title: 'Create Supplier Invoice from Inbox',
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX + dimension tags, attaches the document. Stages for human review; honors dry_run.",
description: "Atomic: turn an OCR'd inbox item into a staged supplier invoice. Resolves supplier, builds lines from extracted_data, applies VAT + FX + dimension tags, attaches the document. Stages for human review; honors dry_run. Unresolved supplier → staged:false + candidates + next.",
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -7571,20 +7572,87 @@ export const tools: McpTool[] = [
}
if (!supplierId) {
throw new Error(
`Cannot resolve supplier from extracted data. Pass supplier_id_override, or create the supplier first (extracted name: ${supplierExt?.name ?? 'unknown'}, org: ${supplierExt?.organizationNumber ?? 'unknown'}).`
// Structured resolution failure instead of a dead end (P1-4,
// dev_docs/mcp_optimization_plan.md): a thrown error here stops the
// whole inbox pipeline for small ad hoc vendors. Return staged:false
// with near-miss candidates the agent can pass as supplier_id_override,
// or a create-supplier next hint when nothing is close. Fuzzy scores
// never auto-resolve — the agent/human confirms against the underlag.
const extractedName = (supplierExt?.name as string | undefined) ?? null
const extractedOrg = (supplierExt?.organizationNumber as string | undefined) ?? null
const CANDIDATE_POOL_CAP = 500
const { data: companySuppliers } = await supabase
.from('suppliers')
.select('id, name, org_number')
.eq('company_id', companyId)
.limit(CANDIDATE_POOL_CAP)
const candidates = findSupplierCandidates(
(companySuppliers ?? []) as { id: string; name: string; org_number: string | null }[],
extractedName,
extractedOrg,
)
const best = candidates[0]
// No silent caps: past the pool cap the right supplier may exist yet
// be absent from candidates — say so instead of implying full coverage.
const poolTruncated = (companySuppliers?.length ?? 0) >= CANDIDATE_POOL_CAP
return {
staged: false,
risk_level: getRiskLevel('create_supplier_invoice_from_inbox'),
actor: actor ?? { type: 'user' },
message: (best
? `Could not resolve supplier "${extractedName ?? 'unknown'}" exactly — ${candidates.length} near-miss candidate(s) in preview.candidates. Verify against the underlag, then retry with supplier_id_override; or create the supplier first.`
: `Could not resolve supplier "${extractedName ?? 'unknown'}" (org: ${extractedOrg ?? 'unknown'}) and no similar supplier exists. Create it with gnubok_create_supplier, then retry with supplier_id_override.`)
+ (poolTruncated ? ` Note: candidate search covered only the first ${CANDIDATE_POOL_CAP} suppliers — the pool was truncated.` : ''),
preview: {
supplier_resolution: 'unresolved',
unresolved_supplier: {
extracted_name: extractedName,
extracted_org_number: extractedOrg,
},
candidates,
candidate_pool_truncated: poolTruncated,
},
next: best
? {
description: `Closest existing supplier: "${best.name}" (score ${best.score}). If it matches the underlag, retry with this supplier_id_override.`,
tool: 'gnubok_create_supplier_invoice_from_inbox',
args: { inbox_item_id: inboxItemId, supplier_id_override: best.supplier_id },
}
: {
description:
'Create the supplier, approve it, then retry this tool with supplier_id_override set to the new supplier id.',
tool: 'gnubok_create_supplier',
args: {
...(extractedName ? { name: extractedName } : {}),
...(extractedOrg ? { org_number: extractedOrg } : {}),
},
},
}
}
// Fetch supplier defaults so line items can inherit default_expense_account
// when neither the extraction nor the agent provided an accountSuggestion.
// Doubles as existence/tenancy validation: every resolution path — and
// especially supplier_id_override, which the unresolved next-hint now
// actively promotes — must point at a supplier in THIS company, or the
// staged operation would fail opaquely at commit time instead.
const { data: resolvedSupplier } = await supabase
.from('suppliers')
.select('default_expense_account')
.select('id, default_expense_account')
.eq('id', supplierId)
.eq('company_id', companyId)
.single()
const supplierDefaultExpenseAccount = resolvedSupplier?.default_expense_account ?? null
if (!resolvedSupplier) {
throw new Error(
supplierResolution === 'override'
? `supplier_id_override ${supplierId} does not match any supplier in this company. Use a supplier_id from preview.candidates or gnubok_list_suppliers.`
: `Resolved supplier ${supplierId} no longer exists in this company — re-run extraction or pass supplier_id_override.`,
)
}
const supplierDefaultExpenseAccount = resolvedSupplier.default_expense_account ?? null
// Assemble core invoice fields
const currency = (invoiceExt?.currency as string) || 'SEK'
@@ -0,0 +1,135 @@
/**
* Fuzzy supplier-candidate matching for gnubok_create_supplier_invoice_from_inbox.
*
* Exact resolution (matched id → org_number → full-name ilike) misses the
* common OCR variants: punctuation ("Polarn O. Pyret" vs "Polarn o Pyret"),
* legal-form suffixes ("… AB"), and formatted org numbers ("556677-8899" vs
* "5566778899"). When resolution fails, the tool surfaces near-miss candidates
* from these matchers so the agent can retry with supplier_id_override instead
* of dead-ending — the agent (or the approving human) makes the final call,
* fuzzy scores never auto-resolve.
*/
export type SupplierRow = {
id: string
name: string
org_number: string | null
}
export type SupplierCandidate = {
supplier_id: string
name: string
org_number: string | null
score: number
matched_on: 'org_number' | 'name'
}
// Legal-form suffixes carry no identity signal and OCR/extraction includes
// them inconsistently. Longest-first so 'aktiebolag' is stripped before 'ab'.
const LEGAL_SUFFIXES = [
'ekonomisk förening',
'kommanditbolag',
'handelsbolag',
'aktiebolag',
'ek för',
'filial',
'ekf',
'ab',
'hb',
'kb',
]
export function normalizeSupplierName(raw: string): string {
let s = raw
.toLowerCase()
// Punctuation → space; keep letters (incl. åäöé), digits, ampersand.
.replace(/[^a-z0-9åäöéü&]+/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
for (const suffix of LEGAL_SUFFIXES) {
if (s.endsWith(` ${suffix}`)) {
s = s.slice(0, -suffix.length - 1).trim()
break
}
}
return s
}
function digitsOnly(s: string): string {
return s.replace(/\D/g, '')
}
/**
* Canonical 10-digit key for a Swedish org number. Orgnr is exactly 10
* significant digits; enskild firma uses the owner's personnummer, which
* appears in both 10-digit (YYMMDDXXXX) and 12-digit (YYYYMMDDXXXX) forms —
* the last 10 digits are the same identifier. Anything else is not a Swedish
* org number and must not fuzzy-match.
*/
export function orgNumberKey(raw: string): string | null {
const d = digitsOnly(raw)
if (d.length === 10) return d
if (d.length === 12) return d.slice(-10)
return null
}
/**
* Similarity in [0, 1]. Exact normalized match = 1; containment
* ("polarn o pyret" ⊂ "polarn o pyret sverige") = 0.9; otherwise token
* Jaccard scaled to max 0.8 so partial overlaps never outrank containment.
*/
export function scoreSupplierName(a: string, b: string): number {
const na = normalizeSupplierName(a)
const nb = normalizeSupplierName(b)
if (!na || !nb) return 0
if (na === nb) return 1
if (na.includes(nb) || nb.includes(na)) return 0.9
const ta = new Set(na.split(' '))
const tb = new Set(nb.split(' '))
let intersection = 0
for (const t of ta) if (tb.has(t)) intersection++
const union = new Set([...ta, ...tb]).size
return union === 0 ? 0 : Math.round((intersection / union) * 0.8 * 100) / 100
}
export function findSupplierCandidates(
suppliers: SupplierRow[],
extractedName: string | null,
extractedOrgNumber: string | null,
options: { limit?: number; minScore?: number } = {},
): SupplierCandidate[] {
const limit = options.limit ?? 5
const minScore = options.minScore ?? 0.4
const extractedOrgKey = extractedOrgNumber ? orgNumberKey(extractedOrgNumber) : null
const scored: SupplierCandidate[] = []
for (const s of suppliers) {
// Canonical-key equality catches formatting variants ('556677-8899' vs
// '5566778899') and the 10- vs 12-digit personnummer forms of enskild
// firma org numbers — none of which the exact .eq() lookup upstream can.
if (extractedOrgKey && s.org_number && orgNumberKey(s.org_number) === extractedOrgKey) {
scored.push({
supplier_id: s.id,
name: s.name,
org_number: s.org_number,
score: 1,
matched_on: 'org_number',
})
continue
}
if (extractedName) {
const score = scoreSupplierName(extractedName, s.name)
if (score >= minScore) {
scored.push({
supplier_id: s.id,
name: s.name,
org_number: s.org_number,
score,
matched_on: 'name',
})
}
}
}
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
}