Files
accounted/dev_docs/base_architecture/AI_CATEGORIZATION_EXTENSION.md
T
2026-02-19 09:48:02 +01:00

382 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<CategorizationSuggestion[]>
}
```
### `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=<jsonb>`.
- `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