# AI Categorization Extension — Implementation Summary This document describes the ai-categorization extension: the second first-party extension built on the Part 3 event bus and extension registry. It uses Claude Haiku to suggest BAS account categorizations for bank transactions, following the same canonical pattern established by receipt-ocr. --- ## Problem Transaction categorization is the most frequent daily task. Every downstream report (momsdeklaration, income statement, balance sheet, NE-bilaga, SRU export) depends on transactions being mapped to the correct BAS accounts. Before this extension, suggestions came only from: - **Mapping rules** — user-defined merchant/description patterns (confidence 0.8) - **Pattern matching** — built-in regex heuristics from `expense-warnings.ts` (confidence 0.6) - **User history** — most frequently used categories (confidence 0.1–0.5) These sources cover common recurring transactions but fail on novel descriptions, edge cases, and new users with no history. ## Solution An `ai-categorization` extension that: 1. Listens to `transaction.synced` events and auto-generates AI-powered category suggestions for uncategorized transactions 2. Stores suggestions in `extension_data` (pre-computed, ready when the user opens the transaction list) 3. Exposes an on-demand API for manual "AI suggest" triggers 4. Merges AI suggestions into the existing suggestion pipeline alongside rule/pattern/history sources **Key constraint:** Suggestions only, never auto-commit. The extension stores suggestions in `extension_data` but never creates journal entries. The user confirms via the existing categorization UI, preserving audit trail integrity. --- ## Files Changed ### New files | File | Purpose | |------|---------| | `extensions/ai-categorization/categorizer.ts` | AI provider interface + Anthropic implementation | | `extensions/ai-categorization/index.ts` | Extension: settings, event handler, public API, extension object | | `app/api/extensions/ai-categorization/settings/route.ts` | GET/PATCH API for per-user extension settings | | `app/api/extensions/ai-categorization/suggestions/route.ts` | GET (pre-computed) / POST (on-demand) suggestions API | ### Modified files | File | Change | |------|--------| | `lib/extensions/loader.ts` | Imported and registered `aiCategorizationExtension` | | `lib/transactions/category-suggestions.ts` | Added `'ai'` to `SuggestedCategory.source` union; added `mergeAiSuggestions()` | | `app/api/transactions/suggest-categories/route.ts` | Reads pre-computed AI suggestions from `extension_data` and merges into results | --- ## Provider Abstraction The architecture doc requires "no hard dependency on any specific AI provider". The categorizer implements this via a `CategorizationProvider` interface. ### `CategorizationProvider` interface ```typescript interface CategorizationProvider { categorize( transactions: TransactionForCategorization[], context: CategorizationContext ): Promise } ``` ### `TransactionForCategorization` Minimal transaction data sent to the AI: ```typescript interface TransactionForCategorization { id: string description: string amount: number // negative = expense, positive = income date: string merchant_name: string | null mcc_code: number | null currency: string } ``` ### `CategorizationContext` Contextual data that improves accuracy: ```typescript interface CategorizationContext { entityType: EntityType // 'enskild_firma' | 'aktiebolag' recentHistory: { description: string; category: string }[] // last 50 categorized } ``` ### `CategorizationSuggestion` The result per transaction: ```typescript interface CategorizationSuggestion { transactionId: string category: TransactionCategory basAccount: string // BAS account number (e.g. '5420') taxCode: string | null // 'MPI', 'MP1', or null confidence: number // 0.0–1.0 reasoning: string // Swedish-language explanation isPrivate: boolean // true = likely private expense } ``` ### `AnthropicCategorizationProvider` The default implementation using `@anthropic-ai/sdk` (already a project dependency): - Model: `claude-haiku-4-5-20251001` (same as receipt-analyzer, chosen for cost efficiency) - Batch size: max 20 transactions per API call (cross-transaction pattern recognition) - Retry logic: 3 attempts with exponential backoff, no retry on JSON parse errors - Response validation: filters to valid transaction IDs and valid `TransactionCategory` values The system prompt includes: 1. Full `TransactionCategory` → BAS account mapping table 2. Entity type (EF uses 2013 for private, AB uses 2893) 3. Swedish non-deductible expense rules (kläder, gym, kosmetika, etc. with legal references) 4. VAT treatment rules (bank fees exempt, standard 25% otherwise) 5. User's recent categorization history (up to 30 entries) for learning patterns --- ## Extension: `extensions/ai-categorization/index.ts` ### Settings ```typescript interface AiCategorizationSettings { autoSuggestEnabled: boolean // default: true confidenceThreshold: number // default: 0.7 providerModel: string // default: 'claude-haiku-4-5-20251001' } ``` Stored as an `extension_data` row with `extension_id='ai-categorization'`, `key='settings'`, `value=`. - `getSettings(userId)` reads from DB and merges with defaults (forward-compatible) - `saveSettings(userId, partial)` merges partial update with current, upserts on `(user_id, extension_id, key)` ### Event Handler: `transaction.synced` When new transactions arrive from banking sync: 1. **Gate:** Is `autoSuggestEnabled` in user's settings? — if not, return 2. **Gate:** Filter to uncategorized transactions only (`is_business === null`) — if none, return 3. Fetch entity type from `company_settings` 4. Fetch user's last 50 categorized transactions (for learning patterns) 5. Call `provider.categorize(batch, context)` 6. Filter suggestions to those above `confidenceThreshold` 7. Store each qualified suggestion to `extension_data` as `key: "suggestion:{transactionId}"` 8. Log summary with `[ai-categorization]` prefix ### Public API: `categorizeTransactions(userId, transactionIds)` Exported function for on-demand categorization (used by the suggestions POST endpoint): 1. Fetch transactions by IDs 2. Build context (entity type + history) 3. Call provider 4. Store all suggestions (no threshold filtering — user explicitly requested) 5. Return suggestions ### Extension Object ```typescript export const aiCategorizationExtension: Extension = { id: 'ai-categorization', name: 'AI Kategorisering', version: '1.0.0', eventHandlers: [ { eventType: 'transaction.synced', handler: handleTransactionSynced }, ], settingsPanel: { label: 'AI Kategorisering', path: '/settings/extensions/ai-categorization', }, async onInstall(ctx) { await saveSettings(ctx.userId, DEFAULT_SETTINGS) }, } ``` --- ## Suggestion Storage Suggestions are stored as individual rows in `extension_data`: | Column | Value | |--------|-------| | `user_id` | The user who owns the transaction | | `extension_id` | `'ai-categorization'` | | `key` | `'suggestion:{transactionId}'` | | `value` | The full `CategorizationSuggestion` object as JSONB | This per-transaction key scheme allows: - Fast lookup by transaction ID (used by the suggest-categories route) - Batch lookup via `IN` clause on keys - Natural overwrite on re-categorization (upsert on unique constraint) --- ## Suggestions API: `app/api/extensions/ai-categorization/suggestions/route.ts` ### `GET ?transaction_ids=id1,id2,...` Reads pre-computed suggestions from `extension_data`. Returns only what's already stored — no AI call. Response: `{ suggestions: { [txId]: CategorizationSuggestion } }` ### `POST { transaction_ids: [...] }` Triggers on-demand AI categorization via `categorizeTransactions()`. Stores results and returns them. Response: `{ suggestions: { [txId]: CategorizationSuggestion } }` Both endpoints limit to 50 transaction IDs per request. --- ## Settings API: `app/api/extensions/ai-categorization/settings/route.ts` Mirrors the receipt-ocr settings route exactly: - **GET** — Returns the current user's merged settings (DB value + defaults) - **PATCH** — Accepts a partial settings object, validates keys against allowlist (`autoSuggestEnabled`, `confidenceThreshold`, `providerModel`), saves via `saveSettings()` --- ## Integration with Existing Suggestion Pipeline ### `lib/transactions/category-suggestions.ts` Two changes: 1. **Source type extended:** `SuggestedCategory.source` union widened from `'mapping_rule' | 'pattern' | 'history'` to `'mapping_rule' | 'pattern' | 'history' | 'ai'` 2. **New merge function:** ```typescript function mergeAiSuggestions( existing: SuggestedCategory[], aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[] ): SuggestedCategory[] ``` Inserts AI suggestions into the list, deduplicating by category (skips categories already present from higher-priority sources). Returns top 5 sorted by confidence. ### `app/api/transactions/suggest-categories/route.ts` After computing rule/pattern/history suggestions for each transaction, the route now: 1. Fetches pre-computed AI suggestions from `extension_data` for all requested transaction IDs (single batch query) 2. For each transaction with an AI suggestion, calls `mergeAiSuggestions()` to blend it in 3. Returns the merged result This means AI suggestions appear alongside existing sources with no latency — they were pre-computed during bank sync. --- ## Suggestion Priority The existing pipeline already sorts by confidence. With AI added, the effective priority becomes: | Source | Typical Confidence | When | |--------|--------------------|------| | Mapping rules | 0.8 | User-defined patterns match | | AI | 0.7–0.95 | Pre-computed from sync | | Pattern matching | 0.6 | Built-in regex matches | | User history | 0.1–0.5 | Most frequently used categories | AI suggestions naturally slot between mapping rules and pattern matching. For novel transactions where no mapping rule or pattern exists, AI becomes the top suggestion. --- ## Event Flow ``` Bank Sync | POST /banking/sync | emit transaction.synced | +---> receipt-ocr extension (auto-match receipts) | +---> ai-categorization extension | Gate: autoSuggestEnabled? Gate: has uncategorized transactions? | Fetch entity type + history Call AnthropicCategorizationProvider.categorize() Filter by confidenceThreshold Store to extension_data (suggestion:{txId}) | [suggestions pre-computed and waiting] User opens transaction list | POST /api/transactions/suggest-categories | +---> getSuggestedCategories() [mapping rules + patterns + history] +---> Read extension_data [pre-computed AI suggestions] +---> mergeAiSuggestions() | v Response: merged suggestions with source labels | User sees: "AI: Programvara (5420) — confidence 0.9" User clicks "AI suggest" button (on-demand) | POST /api/extensions/ai-categorization/suggestions | +---> categorizeTransactions() | Call AI provider | Store results | v Response: fresh AI suggestions ``` --- ## Existing Code Reused | Import | From | Used in | |--------|------|---------| | `Anthropic` | `@anthropic-ai/sdk` | `AnthropicCategorizationProvider` | | `getSettings()`/`saveSettings()` pattern | `extensions/receipt-ocr/index.ts` | Settings management (same pattern) | | `getSuggestedCategories()` | `lib/transactions/category-suggestions.ts` | Existing pipeline (unchanged) | | `createClient()` | `lib/supabase/server.ts` | DB access throughout | No existing service logic was duplicated. The extension adds a new AI-powered source to the existing suggestion pipeline. --- ## Architectural Patterns Followed 1. **Suggestions only, never auto-commit.** AI writes to `extension_data`, never to `journal_entries`. The user confirms via existing categorization UI. 2. **Provider abstraction from day one.** `CategorizationProvider` interface means the AI model is swappable without changing extension logic. 3. **Cost-efficient model.** Claude Haiku (same as receipt-analyzer) keeps per-sync costs low. 4. **Batch processing.** One AI call per sync handles up to 20 transactions. Cross-transaction context (e.g., "all ICA transactions = groceries") improves accuracy. 5. **Pre-computed suggestions.** AI runs on sync, results are stored. No user-facing latency when opening the transaction list. 6. **Graceful degradation.** If the AI call fails, the handler catches and logs. Existing rule/pattern/history suggestions still work. No user-facing error. 7. **Gate-guarded.** Every handler checks user settings before doing work. 8. **One-way dependency.** Base never imports from `extensions/`. Only `loader.ts` imports the extension object. 9. **`[ai-categorization]` prefix.** Console logging convention for grep-ability. --- ## No New Migrations No database schema changes were needed. The existing `extension_data` table (created in Part 3, migration `20240101000020_extension_data.sql`) handles all storage: - Settings: `key='settings'` - Per-transaction suggestions: `key='suggestion:{transactionId}'` The unique constraint `(user_id, extension_id, key)` ensures upsert semantics. --- ## Verification - `npx tsc --noEmit` — zero TypeScript errors - `npx vitest run` — all 78 existing tests pass (11 test files) - Manual: trigger bank sync → check console for `[ai-categorization]` logs - Manual: open transactions page → uncategorized transactions show AI suggestions (source: `'ai'`) alongside existing pattern/history suggestions - Manual: disable `autoSuggestEnabled` in settings → sync does not trigger AI - Manual: `POST /api/extensions/ai-categorization/suggestions` with transaction IDs → returns on-demand suggestions - Manual: `GET /api/extensions/ai-categorization/settings` → returns default settings - Manual: `PATCH /api/extensions/ai-categorization/settings` → updates settings