fix: remove gnubok_categorize_with_receipt tool (#88)
* fix: remove gnubok_categorize_with_receipt tool The tool requires Claude to generate the entire base64-encoded file as tool arguments token-by-token, which is extremely slow for large PDFs. Receipt attachment should happen via the widget (where JavaScript handles encoding) or through the web app. The receipt matcher widget and gnubok_receipt_matcher tool remain — they provide the intended UX for matching receipts to transactions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: update widget to call gnubok_categorize_transaction The widget was still calling the removed gnubok_categorize_with_receipt tool. Updated to call gnubok_categorize_transaction instead (text-only, no file upload). Receipt attachment via the widget is deferred to a future iteration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
140dda0c2c
commit
fdcc94102a
@@ -5,7 +5,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
makeDocumentAttachment,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
// ── Mocks ──
|
||||
@@ -40,11 +39,6 @@ vi.mock('@/lib/events/bus', () => ({
|
||||
eventBus: { emit: vi.fn().mockResolvedValue(undefined), clear: vi.fn() },
|
||||
}))
|
||||
|
||||
const mockUploadDocument = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoices/vat-rules', () => ({
|
||||
getVatRules: vi.fn(),
|
||||
getAvailableVatRates: vi.fn(),
|
||||
@@ -135,17 +129,6 @@ describe('MCP Receipt Matcher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('includes gnubok_categorize_with_receipt', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('tools/list'))
|
||||
const result = await parseResult(res)
|
||||
|
||||
const tool = result.tools.find((t: { name: string }) => t.name === 'gnubok_categorize_with_receipt')
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool.inputSchema.required).toContain('file_data')
|
||||
expect(tool.inputSchema.required).toContain('filename')
|
||||
expect(tool.inputSchema.required).toContain('mime_type')
|
||||
})
|
||||
|
||||
it('does not include _meta for tools without it', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('tools/list'))
|
||||
const result = await parseResult(res)
|
||||
@@ -241,149 +224,6 @@ describe('MCP Receipt Matcher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── gnubok_categorize_with_receipt tool ──
|
||||
|
||||
describe('gnubok_categorize_with_receipt', () => {
|
||||
const validDataUri = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='
|
||||
const tx = makeTransaction({
|
||||
id: 'tx-1',
|
||||
description: 'Kontorsmaterial',
|
||||
amount: -299,
|
||||
currency: 'SEK',
|
||||
})
|
||||
|
||||
it('categorizes and uploads receipt document', async () => {
|
||||
const doc = makeDocumentAttachment({ id: 'doc-1', journal_entry_id: 'je-123' })
|
||||
mockUploadDocument.mockResolvedValue(doc)
|
||||
|
||||
enqueueMany([
|
||||
{ data: tx, error: null }, // fetch transaction
|
||||
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, // company_settings
|
||||
{ data: null, error: null }, // fiscal_periods upsert
|
||||
{ data: null, error: null }, // transaction update
|
||||
])
|
||||
|
||||
const res = await handleMcpRequest(
|
||||
mcpRequest('tools/call', {
|
||||
name: 'gnubok_categorize_with_receipt',
|
||||
arguments: {
|
||||
transaction_id: 'tx-1',
|
||||
category: 'expense_office',
|
||||
vat_treatment: 'standard_25',
|
||||
file_data: validDataUri,
|
||||
filename: 'receipt.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
},
|
||||
})
|
||||
)
|
||||
const result = await parseResult(res)
|
||||
const parsed = JSON.parse(result.content[0].text)
|
||||
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.journal_entry_created).toBe(true)
|
||||
expect(parsed.journal_entry_id).toBe('je-123')
|
||||
expect(parsed.document_id).toBe('doc-1')
|
||||
expect(parsed.debit_account).toBe('6110')
|
||||
expect(parsed.credit_account).toBe('1930')
|
||||
|
||||
// Verify uploadDocument was called with correct args
|
||||
expect(mockUploadDocument).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
name: 'receipt.jpg',
|
||||
type: 'image/jpeg',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
upload_source: 'api',
|
||||
journal_entry_id: 'je-123',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid data URI', async () => {
|
||||
const res = await handleMcpRequest(
|
||||
mcpRequest('tools/call', {
|
||||
name: 'gnubok_categorize_with_receipt',
|
||||
arguments: {
|
||||
transaction_id: 'tx-1',
|
||||
category: 'expense_office',
|
||||
file_data: 'not-a-data-uri',
|
||||
filename: 'receipt.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
},
|
||||
})
|
||||
)
|
||||
const result = await parseResult(res)
|
||||
const parsed = JSON.parse(result.content[0].text)
|
||||
|
||||
expect(parsed.error).toContain('Invalid file_data')
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('returns document_error when upload fails', async () => {
|
||||
mockUploadDocument.mockRejectedValue(new Error('Storage full'))
|
||||
|
||||
enqueueMany([
|
||||
{ data: tx, error: null }, // fetch transaction
|
||||
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
|
||||
{ data: null, error: null }, // fiscal_periods upsert
|
||||
{ data: null, error: null }, // transaction update
|
||||
])
|
||||
|
||||
const res = await handleMcpRequest(
|
||||
mcpRequest('tools/call', {
|
||||
name: 'gnubok_categorize_with_receipt',
|
||||
arguments: {
|
||||
transaction_id: 'tx-1',
|
||||
category: 'expense_office',
|
||||
file_data: validDataUri,
|
||||
filename: 'receipt.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
},
|
||||
})
|
||||
)
|
||||
const result = await parseResult(res)
|
||||
const parsed = JSON.parse(result.content[0].text)
|
||||
|
||||
expect(parsed.success).toBe(true)
|
||||
expect(parsed.journal_entry_created).toBe(true)
|
||||
expect(parsed.document_id).toBeNull()
|
||||
expect(parsed.document_error).toBe('Storage full')
|
||||
})
|
||||
|
||||
it('skips document upload when transaction already has journal entry', async () => {
|
||||
const bookedTx = makeTransaction({
|
||||
id: 'tx-2',
|
||||
journal_entry_id: 'je-existing',
|
||||
amount: -100,
|
||||
})
|
||||
|
||||
enqueueMany([
|
||||
{ data: bookedTx, error: null }, // fetch transaction (already booked)
|
||||
])
|
||||
|
||||
const res = await handleMcpRequest(
|
||||
mcpRequest('tools/call', {
|
||||
name: 'gnubok_categorize_with_receipt',
|
||||
arguments: {
|
||||
transaction_id: 'tx-2',
|
||||
category: 'expense_office',
|
||||
file_data: validDataUri,
|
||||
filename: 'receipt.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
},
|
||||
})
|
||||
)
|
||||
const result = await parseResult(res)
|
||||
const parsed = JSON.parse(result.content[0].text)
|
||||
|
||||
// Should not have uploaded — already booked
|
||||
expect(mockUploadDocument).not.toHaveBeenCalled()
|
||||
expect(parsed.document_id).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ── gnubok_categorize_transaction still works after refactor ──
|
||||
|
||||
describe('gnubok_categorize_transaction (refactored)', () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import {
|
||||
calculateGrossMargin,
|
||||
@@ -24,7 +23,7 @@ import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||
import { RECEIPT_MATCHER_HTML } from './widget-html'
|
||||
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
|
||||
// which dispatches to this handler — no duplicate call needed here.
|
||||
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, DocumentUploadSource } from '@/types'
|
||||
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency } from '@/types'
|
||||
|
||||
// ── JSON-RPC types ───────────────────────────────────────────
|
||||
|
||||
@@ -443,91 +442,6 @@ const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_categorize_with_receipt',
|
||||
description:
|
||||
'Categorize a transaction and attach a receipt document in one operation. ' +
|
||||
'Called by the receipt matcher widget — not typically used directly.\n\n' +
|
||||
'Args:\n' +
|
||||
' - transaction_id (string, required): UUID of the transaction\n' +
|
||||
' - category (string, required): One of: ' + VALID_CATEGORIES.join(', ') + '\n' +
|
||||
' - vat_treatment (string, optional): One of: ' + VALID_VAT_TREATMENTS.join(', ') + '\n' +
|
||||
' - file_data (string, required): Data URI of the receipt file\n' +
|
||||
' - filename (string, required): Original filename\n' +
|
||||
' - mime_type (string, required): MIME type (image/jpeg, image/png, application/pdf)\n\n' +
|
||||
'Returns JSON:\n' +
|
||||
' { success: boolean, journal_entry_created: boolean, journal_entry_id?: string,\n' +
|
||||
' document_id?: string, category: string, debit_account: string, credit_account: string }',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
transaction_id: { type: 'string', description: 'UUID of the transaction' },
|
||||
category: { type: 'string', description: 'Transaction category', enum: [...VALID_CATEGORIES] },
|
||||
vat_treatment: { type: 'string', description: 'VAT treatment override', enum: [...VALID_VAT_TREATMENTS] },
|
||||
file_data: { type: 'string', description: 'Data URI of the receipt (e.g. data:image/jpeg;base64,...)' },
|
||||
filename: { type: 'string', description: 'Original filename' },
|
||||
mime_type: { type: 'string', description: 'MIME type' },
|
||||
},
|
||||
required: ['transaction_id', 'category', 'file_data', 'filename', 'mime_type'],
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, userId, supabase) {
|
||||
const fileData = args.file_data as string
|
||||
const filename = args.filename as string
|
||||
const mimeType = args.mime_type as string
|
||||
|
||||
// Validate data URI
|
||||
const commaIdx = fileData.indexOf(',')
|
||||
if (!fileData.startsWith('data:') || commaIdx === -1) {
|
||||
throw new Error('Invalid file_data: expected a data URI (data:<mime>;base64,...)')
|
||||
}
|
||||
|
||||
const base64 = fileData.slice(commaIdx + 1)
|
||||
const buffer = Buffer.from(base64, 'base64')
|
||||
|
||||
// Categorize transaction
|
||||
const result = await categorizeTransactionCore(
|
||||
args.transaction_id as string,
|
||||
args.category as TransactionCategory,
|
||||
args.vat_treatment as VatTreatment | undefined,
|
||||
userId,
|
||||
supabase
|
||||
)
|
||||
|
||||
// Upload document and link to journal entry
|
||||
let documentId: string | null = null
|
||||
let documentError: string | null = null
|
||||
|
||||
if (result.journal_entry_created && result.journal_entry_id) {
|
||||
try {
|
||||
const doc = await uploadDocument(supabase, userId, {
|
||||
name: filename,
|
||||
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer,
|
||||
type: mimeType,
|
||||
}, {
|
||||
upload_source: 'api' as DocumentUploadSource,
|
||||
journal_entry_id: result.journal_entry_id,
|
||||
})
|
||||
documentId = doc.id
|
||||
} catch (err) {
|
||||
documentError = err instanceof Error ? err.message : 'Document upload failed'
|
||||
}
|
||||
}
|
||||
|
||||
const { transaction: _tx, ...publicResult } = result
|
||||
return {
|
||||
...publicResult,
|
||||
document_id: documentId,
|
||||
document_error: documentError,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── Customer tools ───────────────────────────────────────────
|
||||
|
||||
{
|
||||
|
||||
@@ -345,13 +345,10 @@ export const RECEIPT_MATCHER_HTML = `<!DOCTYPE html>
|
||||
tx._error = null;
|
||||
render();
|
||||
|
||||
callTool('gnubok_categorize_with_receipt', {
|
||||
callTool('gnubok_categorize_transaction', {
|
||||
transaction_id: tx.id,
|
||||
category: catEl.value,
|
||||
vat_treatment: vatEl.value,
|
||||
file_data: tx._dataUri,
|
||||
filename: tx._file,
|
||||
mime_type: tx._mimeType
|
||||
vat_treatment: vatEl.value
|
||||
}).then(function(res) {
|
||||
tx._booking = false;
|
||||
// The tool result comes back as content[0].text (JSON string)
|
||||
@@ -369,7 +366,7 @@ export const RECEIPT_MATCHER_HTML = `<!DOCTYPE html>
|
||||
}
|
||||
render();
|
||||
sendNotification('ui/updateContext', {
|
||||
content: [{ type: 'text', text: 'Bokf\\u00f6rt ' + booked + ' av ' + transactions.length + ' transaktioner med kvitton.' }]
|
||||
content: [{ type: 'text', text: 'Bokf\\u00f6rt ' + booked + ' av ' + transactions.length + ' transaktioner.' }]
|
||||
});
|
||||
}).catch(function(err) {
|
||||
tx._booking = false;
|
||||
|
||||
Reference in New Issue
Block a user