Compare commits

..

16 Commits

Author SHA1 Message Date
aj 299ea3d4fe Cleanup: Remove unused Privacy page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 21:41:01 -05:00
aj b5f46a7646 Cleanup: Remove redundant AI categorization pages
ReviewAISuggestions and ReviewAISuggestionsWithProposals were a two-page
workflow superseded by AICategorizePreview, which handles batch approval,
tabs, and TempData storage in a single page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 20:02:50 -05:00
aj 4be5658d32 Improve: Overhaul navigation with grouped dropdowns, breadcrumbs, and quick-actions
Restructure the flat 7-item navbar into logical dropdown groups (Transactions,
Receipts, Accounts), add a prominent Upload button, settings gear icon, breadcrumb
navigation on 11 deep pages, and dashboard quick-action cards with hover effects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:41:56 -05:00
aj 324ab2c627 Fix: ViewReceipt parse button now uses parse queue instead of direct parsing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:26:40 -05:00
aj e6512f9b7f Docs: Update ARCHITECTURE.md for tool-use, parse queue, and bulk upload
Document AI tool-use framework, receipt parse queue system, background
worker, bulk upload, ParseStatus enum, and updated AIReceiptParser flow
with tool-aware vs enriched-prompt paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:35 -05:00
aj 516546b345 Config: Update storage path, port, and AI model defaults
Change receipt storage to \TRUENAS\receipts, Kestrel port to 5005,
and add AI section with ModelsEndpoint and default ReceiptParsingModel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:31 -05:00
aj 2be9990dbc Improve: Default transaction date filter and LLM response viewer
Transactions page now defaults to last 30 days when no date filters are
set. ViewReceipt page adds collapsible raw LLM response payload on
parse logs for debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:27 -05:00
aj c3e88df43c Improve: Recategorize page error display and provider detection
Add error message alert for failed proposal applications. Derive AI
provider name from model prefix instead of separate config key.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:23 -05:00
aj 444035fd72 Refactor: AICategorizePreview with tabbed proposals and rule status
Split proposals into High Confidence / Needs Review tabs. Extract
proposal table into _ProposalTable partial view. Show rule status
(Create/Update/Exists) based on existing category mappings. Persist
proposals in hidden form field to survive app restarts. Add per-tab
select-all and improved error reporting on apply.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:19 -05:00
aj 6e3589f7da Improve: AI categorizer with rule matching and unified model routing
Categorizer now pre-fetches existing rules and includes matching rules
in prompts so the AI respects established mappings. Unified model
routing via CallModelAsync replaces separate provider branching.
Improved pattern instructions require exact transaction name substrings.
Add rule update support (RuleUpdated) when a pattern exists with a
different category.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:14 -05:00
aj 5eb27319e1 Feature: Receipt queue dashboard and multi-file upload UI
Add ReceiptQueue page with tabbed dashboard (queued/completed/failed),
AJAX polling for live status updates, and per-receipt retry. Update
Receipts page with multi-file upload modal, file preview, upload
spinner, and bulk retry for failed parses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:09 -05:00
aj 705f4ea201 Feature: Receipt parse queue with background worker
Add ReceiptParseQueue (Channel-based singleton) and
ReceiptParseWorkerService (BackgroundService) for sequential receipt
parsing. Replaces fire-and-forget Task.Run with a proper queue.
ReceiptManager now enqueues uploaded receipts and supports bulk upload
via UploadManyUnmappedReceiptsAsync. Worker recovers pending items on
startup. Register IAIToolExecutor and IAIVisionClientResolver in DI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:05 -05:00
aj 16c8d121d4 Feature: Add ReceiptParseStatus enum and migration
Add ParseStatus field to Receipt model with states: NotRequested,
Queued, Parsing, Completed, Failed. Includes indexed column and EF Core
migration for tracking receipt parse queue progress.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:14:00 -05:00
aj 396d5cfc1d Feature: Database-aware receipt parsing with tool-use and enriched prompts
AIReceiptParser now routes to tool-aware or standard vision clients.
Tool-capable models (OpenAI, Claude, LlamaCpp) call search_categories,
search_transactions, and search_merchants during parsing. Ollama gets
pre-fetched DB context injected into the prompt. Adds suggestedCategory
and suggestedTransactionId fields with AI-driven transaction mapping.
Includes NullableLongConverter for resilient JSON deserialization and
restructured receipt prompt with strict field types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:13:56 -05:00
aj 167b7c2ec1 Feature: Add tool-use support to AI vision clients
Add IAIToolAwareVisionClient interface and OpenAIToolUseHelper for
function-calling via /v1/chat/completions. OpenAI and LlamaCpp clients
now support multi-round tool calls, letting the AI query the database
during receipt image analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:13:51 -05:00
aj 5c0f0f3fca Feature: Add AI tool-use framework for database-aware receipt parsing
Introduce provider-agnostic tool definitions (AIToolRegistry) and an
executor (AIToolExecutor) that lets AI models query MoneyMap's database
during receipt parsing via search_categories, search_transactions, and
search_merchants tools. Includes an enriched-context fallback for
providers that don't support function calling (Ollama).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:13:47 -05:00
45 changed files with 3742 additions and 956 deletions
+189 -24
View File
@@ -41,6 +41,11 @@ MoneyMap follows a clean, service-oriented architecture:
│ - ReceiptMatchingService (NEW) │ │ - ReceiptMatchingService (NEW) │
│ - ReceiptManager │ │ - ReceiptManager │
│ - AIReceiptParser │ │ - AIReceiptParser │
│ - ReceiptParseQueue (singleton) │
│ - ReceiptParseWorkerService (hosted) │
│ AI Tool Use: │
│ - AIToolExecutor (DB query tools) │
│ - AIToolRegistry (tool definitions) │
│ Reference & Dashboard: │ │ Reference & Dashboard: │
│ - ReferenceDataService (NEW) │ │ - ReferenceDataService (NEW) │
│ - DashboardService │ │ - DashboardService │
@@ -134,6 +139,9 @@ Stores uploaded receipt files (images/PDFs) linked to transactions.
- `FileHashSha256` (string, 64) - SHA256 hash for deduplication - `FileHashSha256` (string, 64) - SHA256 hash for deduplication
- `UploadedAtUtc` (DateTime) - Upload timestamp - `UploadedAtUtc` (DateTime) - Upload timestamp
**Parse Queue Status:**
- `ParseStatus` (ReceiptParseStatus enum) - NotRequested(0), Queued(1), Parsing(2), Completed(3), Failed(4)
**Parsed Fields (populated by AI parser):** **Parsed Fields (populated by AI parser):**
- `Merchant` (string, 200) - Merchant name extracted from receipt - `Merchant` (string, 200) - Merchant name extracted from receipt
- `ReceiptDate` (DateTime?) - Date on receipt - `ReceiptDate` (DateTime?) - Date on receipt
@@ -629,6 +637,11 @@ Represents a spending budget for a category or total spending.
- Same as UploadReceiptAsync but for receipts without initial transaction mapping - Same as UploadReceiptAsync but for receipts without initial transaction mapping
- TransactionId is null until later mapped - TransactionId is null until later mapped
- `UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files)`
- Bulk upload multiple receipt files
- Calls UploadReceiptInternalAsync for each file, collecting results
- Returns `BulkUploadResult` with lists of uploaded items and failures
- `MapReceiptToTransactionAsync(long receiptId, long transactionId)` - `MapReceiptToTransactionAsync(long receiptId, long transactionId)`
- Links an unmapped receipt to a transaction - Links an unmapped receipt to a transaction
- Returns success boolean - Returns success boolean
@@ -654,35 +667,62 @@ Represents a spending budget for a category or total spending.
**Location:** Services/ReceiptManager.cs:23-199 **Location:** Services/ReceiptManager.cs:23-199
### OpenAIReceiptParser (Services/OpenAIReceiptParser.cs) ### AIReceiptParser (Services/AIReceiptParser.cs)
**Interface:** `IReceiptParser` **Interface:** `IReceiptParser`
**Responsibility:** Parse receipts using OpenAI GPT-4o-mini Vision API. **Responsibility:** Parse receipts using AI vision APIs with tool-use support for database-aware parsing.
**Key Methods:** **Key Methods:**
- `ParseReceiptAsync(long receiptId)` - `ParseReceiptAsync(long receiptId, string? model, string? notes)`
- Loads receipt file from disk - Loads receipt file from disk
- Converts PDFs to PNG images using ImageMagick (220 DPI) - Converts PDFs to PNG images using ImageMagick (220 DPI)
- Calls OpenAI Vision API with structured prompt - Resolves vision client based on model prefix (openai, claude-, llamacpp:, ollama:)
- Parses JSON response (merchant, date, due date, amounts, line items) - **Tool-aware clients (OpenAI, Claude, LlamaCpp):** Uses function calling to let the AI query the database during parsing
- **Non-tool clients (Ollama):** Pre-fetches database context and injects it into the prompt
- Parses JSON response (merchant, date, due date, amounts, line items, suggestedCategory, suggestedTransactionId)
- Updates Receipt entity with extracted data - Updates Receipt entity with extracted data
- Replaces existing line items - Populates `ReceiptLineItem.Category` from AI response
- If AI suggested a transaction ID, attempts direct mapping before falling back to ReceiptAutoMapper
- Logs parse attempt in ReceiptParseLog - Logs parse attempt in ReceiptParseLog
- Attempts auto-mapping if receipt is unmapped
- Returns `ReceiptParseResult` - Returns `ReceiptParseResult`
**API Configuration:** **Tool-Use Flow (OpenAI, Claude, LlamaCpp):**
- Model: `gpt-4o-mini` ```
- Temperature: 0.1 (deterministic) AI sees receipt image + prompt with tool instructions
- Max tokens: 2000
- API key: Environment variable `OPENAI_API_KEY` or config `OpenAI:ApiKey` AI calls search_categories → gets existing categories from DB
AI calls search_transactions → finds matching bank transactions
AI calls search_merchants → normalizes merchant name
↓ (up to 5 tool rounds)
AI returns final JSON with:
- Standard fields (merchant, date, total, lineItems)
- suggestedCategory (from existing categories)
- suggestedTransactionId (matched transaction)
- Per-line-item category
```
**Prompt Strategy:** **Ollama Fallback (enriched prompt):**
- Structured JSON request with schema example ```
- Extracts: merchant, date, dueDate (for bills), subtotal, tax, total, confidence Pre-fetch all categories, matching merchants, candidate transactions
- Line items with: description, quantity, unitPrice, lineTotal
- Special handling: Services/fees have null quantity (not products) Inject as text block in prompt
- Due date extraction: For bills (utility, credit card, etc.), extracts payment due date
AI returns JSON using the provided context
```
**Supported Providers:**
| Provider | Model Prefix | Tool Use | Wire Format |
|----------|-------------|----------|-------------|
| OpenAI | (default) | Native | OpenAI /v1/chat/completions |
| Anthropic | claude- | Native | Anthropic /v1/messages |
| LlamaCpp | llamacpp: | Native | OpenAI-compatible /v1/chat/completions |
| Ollama | ollama: | Enriched prompt fallback | /api/generate |
**Response Fields (ParsedReceiptData):**
- `Merchant`, `ReceiptDate`, `DueDate`, `Subtotal`, `Tax`, `Total`, `Confidence` - Standard fields
- `SuggestedCategory` (NEW) - AI's best category for the overall receipt
- `SuggestedTransactionId` (NEW) - Transaction ID the AI thinks matches this receipt
- `LineItems[].Category` (NEW) - Per-line-item category
**PDF Handling:** **PDF Handling:**
- ImageMagick converts first page to PNG at 220 DPI - ImageMagick converts first page to PNG at 220 DPI
@@ -690,11 +730,47 @@ Represents a spending budget for a category or total spending.
- TrueColor 8-bit RGB output - TrueColor 8-bit RGB output
**Auto-Mapping Integration:** **Auto-Mapping Integration:**
- After successful parse of unmapped receipts, triggers ReceiptAutoMapper - If AI suggests a specific transaction, attempts direct mapping first
- Attempts to automatically link receipt to matching transaction - Falls back to ReceiptAutoMapper if AI mapping fails or no suggestion
- Silently fails if auto-mapping unsuccessful (parsing still successful) - Silently fails if auto-mapping unsuccessful (parsing still successful)
**Location:** Services/OpenAIReceiptParser.cs:23-342 **Location:** Services/AIReceiptParser.cs
### AIToolExecutor (Services/AITools/AIToolExecutor.cs)
**Interface:** `IAIToolExecutor`
**Responsibility:** Execute AI tool calls against the database during receipt parsing.
**Tools Available:**
| Tool | Purpose | Parameters |
|------|---------|------------|
| `search_categories` | Find existing categories with patterns and merchants | `query?` (optional filter) |
| `search_transactions` | Find unmapped bank transactions | `merchant?`, `minDate?`, `maxDate?`, `minAmount?`, `maxAmount?`, `limit?` |
| `search_merchants` | Look up known merchants | `query` (required) |
**Key Methods:**
- `ExecuteAsync(AIToolCall)` - Dispatches to the correct handler, runs EF Core query, returns JSON result
- `GetEnrichedContextAsync(receiptDate?, total?, merchantHint?)` - Pre-fetches all data as text block for Ollama fallback
**Design Constraints:**
- All tools are **read-only** database queries
- Results capped at 20 items
- Tool rounds capped at 5 per parse request
- Transactions already mapped to receipts are excluded from search results
**Location:** Services/AITools/AIToolExecutor.cs
### AIToolRegistry (Services/AITools/AIToolDefinitions.cs)
**Responsibility:** Define tool schemas in a provider-agnostic format.
**Key Types:**
- `AIToolDefinition` - Tool name, description, parameters
- `AIToolParameter` - Parameter name, type, description, required flag
- `AIToolCall` - Incoming tool call with arguments
- `AIToolResult` - Tool execution result returned to the AI
**Location:** Services/AITools/AIToolDefinitions.cs
### ReceiptAutoMapper (Services/ReceiptAutoMapper.cs) ### ReceiptAutoMapper (Services/ReceiptAutoMapper.cs)
**Interface:** `IReceiptAutoMapper` **Interface:** `IReceiptAutoMapper`
@@ -743,6 +819,30 @@ Represents a spending budget for a category or total spending.
**Location:** Services/ReceiptAutoMapper.cs **Location:** Services/ReceiptAutoMapper.cs
### ReceiptParseQueue (Services/ReceiptParseQueue.cs)
**Interface:** `IReceiptParseQueue`
**Lifetime:** Singleton
**Responsibility:** Thread-safe queue for receipt parsing jobs using `System.Threading.Channels`.
**Key Members:**
- `EnqueueAsync(long receiptId)` - Add a receipt to the parse queue
- `EnqueueManyAsync(IEnumerable<long> receiptIds)` - Bulk enqueue
- `DequeueAsync(CancellationToken ct)` - Wait for and dequeue the next receipt
- `QueueLength` - Current number of items waiting
- `CurrentlyProcessingId` - Receipt ID currently being parsed (thread-safe via `Interlocked`)
### ReceiptParseWorkerService (Services/ReceiptParseWorkerService.cs)
**Type:** `BackgroundService` (hosted service)
**Responsibility:** Continuously process receipts from the parse queue using the AI parser.
**Behavior:**
1. **Startup Recovery:** Queries DB for receipts with `ParseStatus == Queued || Parsing`, re-enqueues them in upload order
2. **Processing Loop:** Dequeues one receipt at a time, sets status to `Parsing`, calls `IReceiptParser.ParseReceiptAsync`, updates status to `Completed` or `Failed`
3. **Status Updates:** Uses separate DB scopes for status writes to guarantee persistence even if parsing throws
4. **Graceful Shutdown:** Respects `CancellationToken` from host
### FinancialAuditService (Services/FinancialAuditService.cs) ### FinancialAuditService (Services/FinancialAuditService.cs)
**Interface:** `IFinancialAuditService` **Interface:** `IFinancialAuditService`
@@ -952,6 +1052,29 @@ EF Core DbContext managing all database entities.
**Location:** Pages/Receipts.cshtml.cs **Location:** Pages/Receipts.cshtml.cs
### BulkReceiptUpload.cshtml / BulkReceiptUploadModel
**Route:** `/BulkReceiptUpload`
**Purpose:** Upload multiple receipt files at once with a queue dashboard showing parse progress.
**Features:**
- Multi-file upload form with file list preview and upload spinner
- Queue dashboard with tabs: Queued (with position), Completed (merchant/total/confidence/line items), Failed (error + retry button)
- Currently-processing indicator with spinner
- AJAX polling every 3 seconds while items are active (auto-stops when idle)
- Each receipt links to `/ViewReceipt/{id}`
- Retry button for failed receipts re-queues them
**Handlers:**
- `OnGetAsync()` - Loads queue dashboard data
- `OnPostUploadAsync(List<IFormFile> files)` - Calls `UploadManyUnmappedReceiptsAsync`
- `OnGetQueueStatusAsync()` - Returns JSON for AJAX polling
- `OnPostRetryAsync(long receiptId)` - Re-queues a failed receipt
**Dependencies:** `IReceiptManager`, `IReceiptParseQueue`
**Location:** Pages/BulkReceiptUpload.cshtml.cs
### EditTransaction.cshtml / EditTransactionModel ### EditTransaction.cshtml / EditTransactionModel
**Route:** `/EditTransaction/{id}` **Route:** `/EditTransaction/{id}`
@@ -1170,8 +1293,16 @@ builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvid
// Receipt Services // Receipt Services
builder.Services.AddScoped<IReceiptManager, ReceiptManager>(); builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>(); builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
// AI Vision Clients and Tool Use
builder.Services.AddHttpClient<OpenAIVisionClient>();
builder.Services.AddHttpClient<ClaudeVisionClient>();
builder.Services.AddHttpClient<OllamaVisionClient>();
builder.Services.AddHttpClient<LlamaCppVisionClient>();
builder.Services.AddScoped<IAIVisionClientResolver, AIVisionClientResolver>();
builder.Services.AddScoped<IAIToolExecutor, AIToolExecutor>();
builder.Services.AddScoped<IReceiptParser, AIReceiptParser>();
builder.Services.AddScoped<IMerchantService, MerchantService>(); builder.Services.AddScoped<IMerchantService, MerchantService>();
``` ```
@@ -1240,7 +1371,9 @@ Subtotal (decimal(18,2))
Tax (decimal(18,2)) Tax (decimal(18,2))
Total (decimal(18,2)) Total (decimal(18,2))
Currency (nvarchar(8)) Currency (nvarchar(8))
ParseStatus (int, NOT NULL, DEFAULT 0) -- 0=NotRequested, 1=Queued, 2=Parsing, 3=Completed, 4=Failed
UNIQUE INDEX: (TransactionId, FileHashSha256) WHERE TransactionId IS NOT NULL UNIQUE INDEX: (TransactionId, FileHashSha256) WHERE TransactionId IS NOT NULL
INDEX: ParseStatus
``` ```
### ReceiptParseLogs Table ### ReceiptParseLogs Table
@@ -1683,10 +1816,31 @@ MoneyMap demonstrates a well-architected ASP.NET Core application with clear sep
--- ---
**Last Updated:** 2025-12-15 **Last Updated:** 2026-02-11
**Version:** 1.4 **Version:** 1.5
**Framework:** ASP.NET Core 8.0 / EF Core 9.0 **Framework:** ASP.NET Core 8.0 / EF Core 9.0
## Recent Changes (v1.5)
### AI Tool Use for Receipt Parsing
- **Tool-Use Support**: AI can now query the database during receipt parsing via function calling
- **Three Tools**: `search_categories`, `search_transactions`, `search_merchants` - all read-only DB queries
- **Multi-Provider**: OpenAI, Claude, and LlamaCpp all support native tool calling; Ollama uses enriched prompt fallback
- **New Response Fields**: `suggestedCategory` (overall receipt), `suggestedTransactionId` (matched transaction), per-line-item `category`
- **AI-Suggested Mapping**: If the AI identifies a matching transaction, it's mapped directly before falling back to the scoring-based ReceiptAutoMapper
- **Category Population**: `ReceiptLineItem.Category` is now populated from AI responses (was previously unused)
- **Shared Tool-Use Helper**: `OpenAIToolUseHelper` implements the OpenAI-compatible tool-use loop shared by OpenAI and LlamaCpp clients
- **Anthropic Tool Use**: `ClaudeVisionClient` implements Anthropic-specific `tool_use`/`tool_result` content block format
### New Files
- `Services/AITools/AIToolDefinitions.cs` - Provider-agnostic tool schema models and registry
- `Services/AITools/AIToolExecutor.cs` - Tool executor with database query handlers
### Modified Files
- `Services/AIVisionClient.cs` - Added `IAIToolAwareVisionClient` interface, `OpenAIToolUseHelper`, tool-use implementations
- `Services/AIReceiptParser.cs` - Integrated tool executor, new response fields, enriched prompt fallback
- `Program.cs` - Registered `IAIToolExecutor` and `IAIVisionClientResolver`
## Recent Changes (v1.4) ## Recent Changes (v1.4)
### Financial Audit API ### Financial Audit API
@@ -1744,3 +1898,14 @@ MoneyMap demonstrates a well-architected ASP.NET Core application with clear sep
- Implemented `ReceiptAutoMapper` service with intelligent matching algorithm - Implemented `ReceiptAutoMapper` service with intelligent matching algorithm
- Updated `ReceiptManager` with unmapped receipt support and duplicate detection - Updated `ReceiptManager` with unmapped receipt support and duplicate detection
- Added `MerchantService` for merchant management - Added `MerchantService` for merchant management
## Recent Changes (v1.3)
### Bulk Receipt Upload & Parse Queue
- **ReceiptParseStatus**: New enum on `Receipt` model tracking parse lifecycle (NotRequested → Queued → Parsing → Completed/Failed)
- **ReceiptParseQueue**: Singleton `Channel<long>`-based queue service replacing fire-and-forget `Task.Run` parsing
- **ReceiptParseWorkerService**: `BackgroundService` that processes parse queue sequentially, with startup recovery for interrupted items
- **Bulk Upload Page**: New `/BulkReceiptUpload` page with multi-file upload, queue dashboard (tabbed: Queued/Completed/Failed), AJAX polling, and retry for failed items
- **ReceiptManager.UploadManyUnmappedReceiptsAsync**: New bulk upload method with per-file error handling
- **ViewReceipt LLM Response**: Collapsible raw LLM response payload in parse log history
- **Unified Queue**: Both single and bulk receipt uploads now go through the same parse queue
+3
View File
@@ -129,6 +129,9 @@ namespace MoneyMap.Data
e.Property(x => x.Total).HasColumnType("decimal(18,2)"); e.Property(x => x.Total).HasColumnType("decimal(18,2)");
e.Property(x => x.Currency).HasMaxLength(8); e.Property(x => x.Currency).HasMaxLength(8);
e.Property(x => x.ParseStatus).HasDefaultValue(ReceiptParseStatus.NotRequested);
e.HasIndex(x => x.ParseStatus);
// Receipt can optionally belong to a Transaction. If txn is deleted, cascade remove receipts. // Receipt can optionally belong to a Transaction. If txn is deleted, cascade remove receipts.
e.HasOne(x => x.Transaction) e.HasOne(x => x.Transaction)
.WithMany(t => t.Receipts) .WithMany(t => t.Receipts)
@@ -0,0 +1,668 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MoneyMap.Data;
#nullable disable
namespace MoneyMap.Migrations
{
[DbContext(typeof(MoneyMapContext))]
[Migration("20260215030558_AddReceiptParseStatus")]
partial class AddReceiptParseStatus
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("MoneyMap.Models.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("AccountType")
.HasColumnType("int");
b.Property<string>("Institution")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Nickname")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Institution", "Last4", "Owner");
b.ToTable("Accounts");
});
modelBuilder.Entity("MoneyMap.Models.Budget", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Period")
.HasColumnType("int");
b.Property<DateTime>("StartDate")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("Category", "Period")
.IsUnique()
.HasFilter("[IsActive] = 1");
b.ToTable("Budgets");
});
modelBuilder.Entity("MoneyMap.Models.Card", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Last4")
.IsRequired()
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Nickname")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("AccountId");
b.HasIndex("Issuer", "Last4", "Owner");
b.ToTable("Cards");
});
modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int?>("MerchantId")
.HasColumnType("int");
b.Property<string>("Pattern")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Priority")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("MerchantId");
b.ToTable("CategoryMappings");
});
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Merchants");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasDefaultValue("application/octet-stream");
b.Property<string>("Currency")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<DateTime?>("DueDate")
.HasColumnType("datetime2");
b.Property<string>("FileHashSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<long>("FileSizeBytes")
.HasColumnType("bigint");
b.Property<string>("Merchant")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("ParseStatus")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<string>("ParsingNotes")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTime?>("ReceiptDate")
.HasColumnType("datetime2");
b.Property<string>("StoragePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<decimal?>("Subtotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Tax")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Total")
.HasColumnType("decimal(18,2)");
b.Property<long?>("TransactionId")
.HasColumnType("bigint");
b.Property<DateTime>("UploadedAtUtc")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("FileHashSha256");
b.HasIndex("ParseStatus");
b.HasIndex("TransactionId", "FileHashSha256")
.IsUnique()
.HasFilter("[TransactionId] IS NOT NULL");
b.HasIndex("TransactionId", "ReceiptDate");
b.ToTable("Receipts");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("Category")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<int>("LineNumber")
.HasColumnType("int");
b.Property<decimal?>("LineTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal?>("Quantity")
.HasColumnType("decimal(18,4)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<string>("Sku")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Unit")
.HasMaxLength(16)
.HasColumnType("nvarchar(16)");
b.Property<decimal?>("UnitPrice")
.HasColumnType("decimal(18,4)");
b.Property<bool>("Voided")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ReceiptId", "LineNumber");
b.ToTable("ReceiptLineItems");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime?>("CompletedAtUtc")
.HasColumnType("datetime2");
b.Property<decimal?>("Confidence")
.HasColumnType("decimal(5,4)");
b.Property<string>("Error")
.HasColumnType("nvarchar(max)");
b.Property<string>("ExtractedTextPath")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ProviderJobId")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("RawProviderPayloadJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long>("ReceiptId")
.HasColumnType("bigint");
b.Property<DateTime>("StartedAtUtc")
.HasColumnType("datetime2");
b.Property<bool>("Success")
.HasColumnType("bit");
b.HasKey("Id");
b.HasIndex("ReceiptId", "StartedAtUtc");
b.ToTable("ReceiptParseLogs");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<int>("AccountId")
.HasColumnType("int");
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<int?>("CardId")
.HasColumnType("int");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Last4")
.HasMaxLength(4)
.HasColumnType("nvarchar(4)");
b.Property<string>("Memo")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<int?>("MerchantId")
.HasColumnType("int");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("TransactionType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int?>("TransferToAccountId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Amount");
b.HasIndex("Category");
b.HasIndex("Date");
b.HasIndex("MerchantId");
b.HasIndex("TransferToAccountId");
b.HasIndex("AccountId", "Category");
b.HasIndex("AccountId", "Date");
b.HasIndex("CardId", "Date");
b.HasIndex("MerchantId", "Date");
b.HasIndex("Date", "Amount", "Name", "Memo", "AccountId", "CardId")
.IsUnique()
.HasFilter("[CardId] IS NOT NULL");
b.ToTable("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("Amount")
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("Date")
.HasColumnType("datetime2");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int?>("DestinationAccountId")
.HasColumnType("int");
b.Property<string>("Notes")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<long?>("OriginalTransactionId")
.HasColumnType("bigint");
b.Property<int?>("SourceAccountId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("Date");
b.HasIndex("DestinationAccountId");
b.HasIndex("OriginalTransactionId");
b.HasIndex("SourceAccountId");
b.ToTable("Transfers");
});
modelBuilder.Entity("MoneyMap.Models.Card", b =>
{
b.HasOne("MoneyMap.Models.Account", "Account")
.WithMany("Cards")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("MoneyMap.Models.CategoryMapping", b =>
{
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
.WithMany("CategoryMappings")
.HasForeignKey("MerchantId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Merchant");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.HasOne("MoneyMap.Models.Transaction", "Transaction")
.WithMany("Receipts")
.HasForeignKey("TransactionId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Transaction");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptLineItem", b =>
{
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
.WithMany("LineItems")
.HasForeignKey("ReceiptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Receipt");
});
modelBuilder.Entity("MoneyMap.Models.ReceiptParseLog", b =>
{
b.HasOne("MoneyMap.Models.Receipt", "Receipt")
.WithMany("ParseLogs")
.HasForeignKey("ReceiptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Receipt");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.HasOne("MoneyMap.Models.Account", "Account")
.WithMany("Transactions")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MoneyMap.Models.Card", "Card")
.WithMany("Transactions")
.HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MoneyMap.Models.Merchant", "Merchant")
.WithMany("Transactions")
.HasForeignKey("MerchantId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MoneyMap.Models.Account", "TransferToAccount")
.WithMany()
.HasForeignKey("TransferToAccountId");
b.Navigation("Account");
b.Navigation("Card");
b.Navigation("Merchant");
b.Navigation("TransferToAccount");
});
modelBuilder.Entity("MoneyMap.Models.Transfer", b =>
{
b.HasOne("MoneyMap.Models.Account", "DestinationAccount")
.WithMany("DestinationTransfers")
.HasForeignKey("DestinationAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("MoneyMap.Models.Transaction", "OriginalTransaction")
.WithMany()
.HasForeignKey("OriginalTransactionId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("MoneyMap.Models.Account", "SourceAccount")
.WithMany("SourceTransfers")
.HasForeignKey("SourceAccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("DestinationAccount");
b.Navigation("OriginalTransaction");
b.Navigation("SourceAccount");
});
modelBuilder.Entity("MoneyMap.Models.Account", b =>
{
b.Navigation("Cards");
b.Navigation("DestinationTransfers");
b.Navigation("SourceTransfers");
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Card", b =>
{
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Merchant", b =>
{
b.Navigation("CategoryMappings");
b.Navigation("Transactions");
});
modelBuilder.Entity("MoneyMap.Models.Receipt", b =>
{
b.Navigation("LineItems");
b.Navigation("ParseLogs");
});
modelBuilder.Entity("MoneyMap.Models.Transaction", b =>
{
b.Navigation("Receipts");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MoneyMap.Migrations
{
/// <inheritdoc />
public partial class AddReceiptParseStatus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ParseStatus",
table: "Receipts",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateIndex(
name: "IX_Receipts_ParseStatus",
table: "Receipts",
column: "ParseStatus");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Receipts_ParseStatus",
table: "Receipts");
migrationBuilder.DropColumn(
name: "ParseStatus",
table: "Receipts");
}
}
}
@@ -236,6 +236,11 @@ namespace MoneyMap.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("nvarchar(200)"); .HasColumnType("nvarchar(200)");
b.Property<int>("ParseStatus")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<string>("ParsingNotes") b.Property<string>("ParsingNotes")
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("nvarchar(2000)"); .HasColumnType("nvarchar(2000)");
@@ -267,6 +272,8 @@ namespace MoneyMap.Migrations
b.HasIndex("FileHashSha256"); b.HasIndex("FileHashSha256");
b.HasIndex("ParseStatus");
b.HasIndex("TransactionId", "FileHashSha256") b.HasIndex("TransactionId", "FileHashSha256")
.IsUnique() .IsUnique()
.HasFilter("[TransactionId] IS NOT NULL"); .HasFilter("[TransactionId] IS NOT NULL");
+12
View File
@@ -4,6 +4,15 @@ using System.ComponentModel.DataAnnotations.Schema;
namespace MoneyMap.Models; namespace MoneyMap.Models;
public enum ReceiptParseStatus
{
NotRequested = 0,
Queued = 1,
Parsing = 2,
Completed = 3,
Failed = 4
}
[Index(nameof(TransactionId), nameof(FileHashSha256), IsUnique = true)] [Index(nameof(TransactionId), nameof(FileHashSha256), IsUnique = true)]
public class Receipt public class Receipt
{ {
@@ -55,6 +64,9 @@ public class Receipt
[MaxLength(2000)] [MaxLength(2000)]
public string? ParsingNotes { get; set; } public string? ParsingNotes { get; set; }
// Parse queue status
public ReceiptParseStatus ParseStatus { get; set; } = ReceiptParseStatus.NotRequested;
// One receipt -> many parse attempts + many line items // One receipt -> many parse attempts + many line items
public ICollection<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>(); public ICollection<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>();
public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>(); public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>();
+71 -77
View File
@@ -2,6 +2,12 @@
@model MoneyMap.Pages.AICategorizePreviewModel @model MoneyMap.Pages.AICategorizePreviewModel
@{ @{
ViewData["Title"] = "AI Categorization Preview"; ViewData["Title"] = "AI Categorization Preview";
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Transactions", Url.Page("/Transactions")),
("Recategorize", Url.Page("/Recategorize")),
("AI Preview", null)
};
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -65,7 +71,11 @@
} }
else else
{ {
var highConfidence = Model.Proposals.Where(p => p.Confidence >= 0.8m).ToList();
var needsReview = Model.Proposals.Where(p => p.Confidence < 0.8m).ToList();
<form method="post" asp-page-handler="Apply"> <form method="post" asp-page-handler="Apply">
<input type="hidden" name="proposalsData" value="@Model.ProposalsJson" />
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong>Review Proposals (@Model.Proposals.Count suggestions)</strong> <strong>Review Proposals (@Model.Proposals.Count suggestions)</strong>
@@ -75,89 +85,45 @@ else
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive"> <ul class="nav nav-tabs px-3 pt-3" id="proposalTabs" role="tablist">
<table class="table table-hover mb-0"> <li class="nav-item" role="presentation">
<thead class="table-light"> <button class="nav-link active" id="high-confidence-tab" data-bs-toggle="tab"
<tr> data-bs-target="#high-confidence" type="button" role="tab">
<th style="width: 40px;"> High Confidence <span class="badge bg-success ms-1">@highConfidence.Count</span>
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="selectAll(this.checked)" checked> </button>
</th> </li>
<th>Transaction</th> <li class="nav-item" role="presentation">
<th>Current</th> <button class="nav-link" id="needs-review-tab" data-bs-toggle="tab"
<th>Proposed</th> data-bs-target="#needs-review" type="button" role="tab">
<th>Merchant</th> Needs Review <span class="badge bg-warning text-dark ms-1">@needsReview.Count</span>
<th style="width: 100px;">Confidence</th> </button>
<th style="width: 100px;">Create Rule</th> </li>
</tr> </ul>
</thead> <div class="tab-content" id="proposalTabContent">
<tbody> <div class="tab-pane fade show active" id="high-confidence" role="tabpanel">
@foreach (var proposal in Model.Proposals) @if (highConfidence.Any())
{ {
var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" : @await Html.PartialAsync("_ProposalTable", highConfidence)
proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary";
<tr>
<td>
<input type="checkbox" class="form-check-input proposal-checkbox"
name="selectedIds" value="@proposal.TransactionId" checked>
</td>
<td>
<div class="fw-bold">@proposal.TransactionName</div>
@if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo))
{
<small class="text-muted">@proposal.TransactionMemo</small>
}
<div class="small text-muted">
@proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C")
</div>
</td>
<td>
@if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory))
{
<span class="badge bg-secondary">@proposal.CurrentCategory</span>
} }
else else
{ {
<span class="text-muted small">(none)</span> <div class="p-4 text-center text-muted">No high confidence proposals.</div>
} }
</td>
<td>
<span class="badge bg-primary">@proposal.ProposedCategory</span>
@if (!string.IsNullOrWhiteSpace(proposal.Reasoning))
{
<div class="small text-muted mt-1" title="@proposal.Reasoning">
@(proposal.Reasoning.Length > 60 ? proposal.Reasoning.Substring(0, 60) + "..." : proposal.Reasoning)
</div> </div>
} <div class="tab-pane fade" id="needs-review" role="tabpanel">
</td> @if (needsReview.Any())
<td>
@if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant))
{ {
<div>@proposal.ProposedMerchant</div> @await Html.PartialAsync("_ProposalTable", needsReview)
} }
@if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern)) else
{ {
<code class="small">@proposal.ProposedPattern</code> <div class="p-4 text-center text-muted">No proposals needing review.</div>
} }
</td> </div>
<td>
<span class="badge @confidenceClass">@proposal.Confidence.ToString("P0")</span>
</td>
<td>
<input type="checkbox" class="form-check-input"
name="createRules" value="@proposal.TransactionId"
@(proposal.CreateRule ? "checked" : "")
title="Create a mapping rule for this pattern">
</td>
</tr>
}
</tbody>
</table>
</div> </div>
</div> </div>
<div class="card-footer d-flex justify-content-between"> <div class="card-footer d-flex justify-content-between">
<form method="post" asp-page-handler="Cancel" class="d-inline"> <a asp-page="/Recategorize" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-outline-secondary">Cancel</button>
</form>
<button type="submit" class="btn btn-success"> <button type="submit" class="btn btn-success">
Apply Selected Categorizations Apply Selected Categorizations
</button> </button>
@@ -180,11 +146,12 @@ else
</ul> </ul>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<h6>Create Rule</h6> <h6>Rule Status</h6>
<p class="small text-muted mb-0"> <ul class="list-unstyled mb-0">
When checked, a category mapping rule will be created using the proposed pattern. <li><span class="small text-muted">Create</span> - No existing rule; check to create a new mapping rule</li>
Future transactions matching this pattern will be automatically categorized. <li><span class="badge bg-warning text-dark">Update</span> - Pattern exists with a different category; check to update it</li>
</p> <li><span class="badge bg-info text-dark">Exists</span> - Rule already exists with the same category</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
@@ -193,9 +160,36 @@ else
@section Scripts { @section Scripts {
<script> <script>
// Select/deselect all proposals across both tabs
function selectAll(checked) { function selectAll(checked) {
document.querySelectorAll('.proposal-checkbox').forEach(cb => cb.checked = checked); document.querySelectorAll('.proposal-checkbox').forEach(cb => {
document.getElementById('selectAllCheckbox').checked = checked; cb.checked = checked;
if (!checked) {
var ruleCheckbox = cb.closest('tr').querySelector('.create-rule-checkbox');
if (ruleCheckbox) ruleCheckbox.checked = false;
} }
});
document.querySelectorAll('.select-all-tab').forEach(cb => cb.checked = checked);
}
// Select/deselect all within a single tab
function selectAllInTab(headerCheckbox) {
var table = headerCheckbox.closest('table');
table.querySelectorAll('.proposal-checkbox').forEach(cb => {
cb.checked = headerCheckbox.checked;
if (!headerCheckbox.checked) {
var ruleCheckbox = cb.closest('tr').querySelector('.create-rule-checkbox');
if (ruleCheckbox) ruleCheckbox.checked = false;
}
});
}
// When a proposal checkbox is unchecked, also uncheck its create-rule checkbox
document.addEventListener('change', function (e) {
if (e.target.classList.contains('proposal-checkbox') && !e.target.checked) {
var ruleCheckbox = e.target.closest('tr').querySelector('.create-rule-checkbox');
if (ruleCheckbox) ruleCheckbox.checked = false;
}
});
</script> </script>
} }
+69 -14
View File
@@ -26,7 +26,17 @@ namespace MoneyMap.Pages
public List<ProposalViewModel> Proposals { get; set; } = new(); public List<ProposalViewModel> Proposals { get; set; } = new();
public string ModelUsed { get; set; } = ""; public string ModelUsed { get; set; } = "";
public string AIProvider => _config["AI:CategorizationProvider"] ?? "OpenAI"; public string AIProvider
{
get
{
var model = SelectedModel;
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase)) return "LlamaCpp";
if (model.StartsWith("ollama:", StringComparison.OrdinalIgnoreCase)) return "Ollama";
if (model.StartsWith("claude-", StringComparison.OrdinalIgnoreCase)) return "Anthropic";
return "OpenAI";
}
}
public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
[TempData] [TempData]
@@ -188,26 +198,37 @@ namespace MoneyMap.Pages
return RedirectToPage(); return RedirectToPage();
} }
public async Task<IActionResult> OnPostApplyAsync(long[] selectedIds, long[] createRules) public async Task<IActionResult> OnPostApplyAsync(long[] selectedIds, long[] createRules, string? proposalsData)
{ {
if (string.IsNullOrEmpty(ProposalsJson)) // Read proposals from the hidden form field (not TempData, which can be lost on app restart)
var json = proposalsData ?? ProposalsJson;
if (string.IsNullOrEmpty(json))
{ {
ErrorMessage = "No proposals to apply. Please generate new suggestions."; ErrorMessage = "No proposals to apply. Please generate new suggestions.";
return RedirectToPage("/Recategorize"); return RedirectToPage("/Recategorize");
} }
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(ProposalsJson); var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(json);
if (storedProposals == null || storedProposals.Count == 0) if (storedProposals == null || storedProposals.Count == 0)
{ {
ErrorMessage = "No proposals to apply."; ErrorMessage = "No proposals to apply (deserialization returned empty).";
return RedirectToPage("/Recategorize"); return RedirectToPage("/Recategorize");
} }
var selectedSet = selectedIds?.ToHashSet() ?? new HashSet<long>(); var selectedSet = selectedIds?.ToHashSet() ?? new HashSet<long>();
var createRulesSet = createRules?.ToHashSet() ?? new HashSet<long>(); var createRulesSet = createRules?.ToHashSet() ?? new HashSet<long>();
if (selectedSet.Count == 0)
{
ErrorMessage = $"No transactions were selected. ({storedProposals.Count} proposals available but 0 selectedIds received from form)";
return RedirectToPage("/Recategorize");
}
int applied = 0; int applied = 0;
int rulesCreated = 0; int rulesCreated = 0;
int rulesUpdated = 0;
var errors = new List<string>();
foreach (var stored in storedProposals) foreach (var stored in storedProposals)
{ {
@@ -226,7 +247,7 @@ namespace MoneyMap.Pages
CreateRule = stored.CreateRule CreateRule = stored.CreateRule
}; };
// Check if user wants to create rule for this one // Check if user wants to create/update rule for this one
var shouldCreateRule = createRulesSet.Contains(stored.TransactionId); var shouldCreateRule = createRulesSet.Contains(stored.TransactionId);
var result = await _aiCategorizer.ApplyProposalAsync(stored.TransactionId, proposal, shouldCreateRule); var result = await _aiCategorizer.ApplyProposalAsync(stored.TransactionId, proposal, shouldCreateRule);
@@ -235,15 +256,25 @@ namespace MoneyMap.Pages
applied++; applied++;
if (result.RuleCreated) if (result.RuleCreated)
rulesCreated++; rulesCreated++;
if (result.RuleUpdated)
rulesUpdated++;
} }
} else
SuccessMessage = $"Applied {applied} categorizations. Created {rulesCreated} new mapping rules.";
return RedirectToPage("/Recategorize");
}
public IActionResult OnPostCancel()
{ {
errors.Add($"ID {stored.TransactionId}: {result.ErrorMessage}");
}
}
var parts = new List<string> { $"Applied {applied} of {selectedSet.Count} selected categorizations" };
if (rulesCreated > 0) parts.Add($"created {rulesCreated} new rules");
if (rulesUpdated > 0) parts.Add($"updated {rulesUpdated} existing rules");
SuccessMessage = string.Join(". ", parts) + ".";
if (errors.Count > 0)
{
ErrorMessage = "Some proposals failed: " + string.Join("; ", errors);
}
return RedirectToPage("/Recategorize"); return RedirectToPage("/Recategorize");
} }
@@ -254,10 +285,25 @@ namespace MoneyMap.Pages
.Where(t => transactionIds.Contains(t.Id)) .Where(t => transactionIds.Contains(t.Id))
.ToDictionaryAsync(t => t.Id); .ToDictionaryAsync(t => t.Id);
// Look up existing rules for all proposed patterns
var proposedPatterns = storedProposals
.Where(p => !string.IsNullOrWhiteSpace(p.Pattern))
.Select(p => p.Pattern!)
.Distinct()
.ToList();
var existingRules = await _db.CategoryMappings
.Where(m => proposedPatterns.Contains(m.Pattern))
.ToDictionaryAsync(m => m.Pattern, m => m.Category);
foreach (var stored in storedProposals) foreach (var stored in storedProposals)
{ {
if (transactions.TryGetValue(stored.TransactionId, out var txn)) if (transactions.TryGetValue(stored.TransactionId, out var txn))
{ {
string? existingCategory = null;
var hasExisting = !string.IsNullOrWhiteSpace(stored.Pattern)
&& existingRules.TryGetValue(stored.Pattern!, out existingCategory);
Proposals.Add(new ProposalViewModel Proposals.Add(new ProposalViewModel
{ {
TransactionId = stored.TransactionId, TransactionId = stored.TransactionId,
@@ -271,7 +317,9 @@ namespace MoneyMap.Pages
ProposedPattern = stored.Pattern, ProposedPattern = stored.Pattern,
Confidence = stored.Confidence, Confidence = stored.Confidence,
Reasoning = stored.Reasoning, Reasoning = stored.Reasoning,
CreateRule = stored.CreateRule CreateRule = stored.CreateRule,
HasExistingRule = hasExisting,
ExistingRuleCategory = existingCategory
}); });
} }
} }
@@ -294,6 +342,13 @@ namespace MoneyMap.Pages
public decimal Confidence { get; set; } public decimal Confidence { get; set; }
public string? Reasoning { get; set; } public string? Reasoning { get; set; }
public bool CreateRule { get; set; } public bool CreateRule { get; set; }
public bool HasExistingRule { get; set; }
public string? ExistingRuleCategory { get; set; }
/// <summary>
/// True when the pattern exists but is mapped to a different category than proposed.
/// </summary>
public bool NeedsRuleUpdate => HasExistingRule && ExistingRuleCategory != ProposedCategory;
} }
public class StoredProposal public class StoredProposal
+5
View File
@@ -2,6 +2,11 @@
@model MoneyMap.Pages.AccountDetailsModel @model MoneyMap.Pages.AccountDetailsModel
@{ @{
ViewData["Title"] = $"Account - {Model.Account.DisplayLabel}"; ViewData["Title"] = $"Account - {Model.Account.DisplayLabel}";
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Accounts", Url.Page("/Accounts")),
(Model.Account.DisplayLabel, null)
};
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
+5
View File
@@ -3,6 +3,11 @@
@model MoneyMap.Pages.EditAccountModel @model MoneyMap.Pages.EditAccountModel
@{ @{
ViewData["Title"] = Model.IsNew ? "Add Account" : "Edit Account"; ViewData["Title"] = Model.IsNew ? "Add Account" : "Edit Account";
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Accounts", Url.Page("/Accounts")),
(Model.IsNew ? "Add Account" : "Edit Account", null)
};
} }
<h2>@ViewData["Title"]</h2> <h2>@ViewData["Title"]</h2>
+6
View File
@@ -2,6 +2,12 @@
@model MoneyMap.Pages.EditCardModel @model MoneyMap.Pages.EditCardModel
@{ @{
ViewData["Title"] = Model.IsNewCard ? "Add Card" : "Edit Card"; ViewData["Title"] = Model.IsNewCard ? "Add Card" : "Edit Card";
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Accounts", Url.Page("/Accounts")),
("Cards", Url.Page("/Cards")),
(Model.IsNewCard ? "Add Card" : "Edit Card", null)
};
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
+5
View File
@@ -2,6 +2,11 @@
@model MoneyMap.Pages.EditTransactionModel @model MoneyMap.Pages.EditTransactionModel
@{ @{
ViewData["Title"] = "Edit Transaction"; ViewData["Title"] = "Edit Transaction";
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Transactions", Url.Page("/Transactions")),
($"#{Model.Transaction.Id}", null)
};
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
+63 -5
View File
@@ -52,11 +52,69 @@
</div> </div>
</div> </div>
<div class="my-3 d-flex gap-2"> <div class="row g-3 my-3">
<a class="btn btn-primary" asp-page="/Upload">Upload CSV</a> <div class="col-sm-6 col-lg-3">
<a class="btn btn-outline-secondary" asp-page="/Transactions">View All Transactions</a> <a asp-page="/Upload" class="card shadow-sm quick-action-card d-block h-100 text-decoration-none">
<a class="btn btn-outline-secondary" asp-page="/CategoryMappings">Categories</a> <div class="card-body d-flex align-items-center gap-3">
<a class="btn btn-outline-secondary" asp-page="/Budgets">Budgets</a> <div class="quick-action-icon bg-primary bg-opacity-25 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>
</svg>
</div>
<div>
<div class="fw-semibold text-body">Upload Transactions</div>
<small class="text-muted">Import CSV files</small>
</div>
</div>
</a>
</div>
<div class="col-sm-6 col-lg-3">
<a asp-page="/Transactions" asp-route-category="(blank)" class="card shadow-sm quick-action-card d-block h-100 text-decoration-none">
<div class="card-body d-flex align-items-center gap-3">
<div class="quick-action-icon bg-warning bg-opacity-25 text-warning">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M7.005 3.1a1 1 0 1 1 1.99 0l-.388 6.35a.61.61 0 0 1-1.214 0zM7 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0"/>
</svg>
</div>
<div>
<div class="fw-semibold text-body">Review Uncategorized</div>
<small class="text-muted">@Model.Stats.Uncategorized transactions</small>
</div>
</div>
</a>
</div>
<div class="col-sm-6 col-lg-3">
<a asp-page="/ReceiptQueue" class="card shadow-sm quick-action-card d-block h-100 text-decoration-none">
<div class="card-body d-flex align-items-center gap-3">
<div class="quick-action-icon bg-info bg-opacity-25 text-info">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5z"/>
<path d="M4.5 12.5A.5.5 0 0 1 5 12h3a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5m0-2A.5.5 0 0 1 5 10h6a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5m1.639-3.708 1.33.886 1.854-1.855a.25.25 0 0 1 .289-.047l1.888.974V8.5a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5V8z"/>
</svg>
</div>
<div>
<div class="fw-semibold text-body">Receipt Parse Queue</div>
<small class="text-muted">Process pending receipts</small>
</div>
</div>
</a>
</div>
<div class="col-sm-6 col-lg-3">
<a asp-page="/Budgets" class="card shadow-sm quick-action-card d-block h-100 text-decoration-none">
<div class="card-body d-flex align-items-center gap-3">
<div class="quick-action-icon bg-success bg-opacity-25 text-success">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16">
<path d="M4 10.781c.148 1.667 1.513 2.85 3.591 3.003V15h1.043v-1.216c2.27-.179 3.678-1.438 3.678-3.3 0-1.59-.947-2.51-2.956-3.028l-.722-.187V3.467c1.122.11 1.879.714 2.07 1.616h1.47c-.166-1.6-1.54-2.748-3.54-2.875V1H7.591v1.233c-1.939.23-3.27 1.472-3.27 3.156 0 1.454.966 2.483 2.661 2.917l.61.162v4.031c-1.149-.17-1.94-.8-2.131-1.718zm3.391-3.836c-1.043-.263-1.6-.825-1.6-1.616 0-.944.704-1.641 1.8-1.828v3.495l-.2-.05zm1.591 1.872c1.287.323 1.852.859 1.852 1.769 0 1.097-.826 1.828-2.2 1.939V8.73z"/>
</svg>
</div>
<div>
<div class="fw-semibold text-body">Budgets</div>
<small class="text-muted">@Model.BudgetStatuses.Count active budgets</small>
</div>
</div>
</a>
</div>
</div> </div>
<div class="row g-3 my-2"> <div class="row g-3 my-2">
<div class="col-lg-6"> <div class="col-lg-6">
-8
View File
@@ -1,8 +0,0 @@
@page
@model PrivacyModel
@{
ViewData["Title"] = "Privacy Policy";
}
<h1>@ViewData["Title"]</h1>
<p>Use this page to detail your site's privacy policy.</p>
-19
View File
@@ -1,19 +0,0 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace MoneyMap.Pages
{
public class PrivacyModel : PageModel
{
private readonly ILogger<PrivacyModel> _logger;
public PrivacyModel(ILogger<PrivacyModel> logger)
{
_logger = logger;
}
public void OnGet()
{
}
}
}
+7
View File
@@ -18,6 +18,13 @@
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
} }
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-4"> <div class="col-md-4">
+14 -1
View File
@@ -26,11 +26,24 @@ namespace MoneyMap.Pages
} }
public RecategorizeStats Stats { get; set; } = new(); public RecategorizeStats Stats { get; set; } = new();
public string AIProvider => _config["AI:CategorizationProvider"] ?? "OpenAI"; public string AIProvider
{
get
{
var model = _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase)) return "LlamaCpp";
if (model.StartsWith("ollama:", StringComparison.OrdinalIgnoreCase)) return "Ollama";
if (model.StartsWith("claude-", StringComparison.OrdinalIgnoreCase)) return "Anthropic";
return "OpenAI";
}
}
[TempData] [TempData]
public string? SuccessMessage { get; set; } public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
await LoadStatsAsync(); await LoadStatsAsync();
+398
View File
@@ -0,0 +1,398 @@
@page
@model MoneyMap.Pages.ReceiptQueueModel
@{
ViewData["Title"] = "Receipt Queue";
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Receipts", Url.Page("/Receipts")),
("Parse Queue", null)
};
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Receipt Queue</h2>
<div>
<a asp-page="/Receipts" class="btn btn-outline-secondary">Back to Receipts</a>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(Model.Message))
{
<div class="alert @(Model.IsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
@Model.Message
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<!-- Upload Form -->
<div class="card shadow-sm mb-4">
<div class="card-header">
<strong>Upload Receipts</strong>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload" id="uploadForm">
<div class="mb-3">
<label for="files" class="form-label">Select Receipt Files</label>
<input type="file" name="files" id="fileInput" class="form-control" multiple
accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
<div class="form-text">
Supported: JPG, PNG, PDF, GIF, HEIC (Max 10MB each). Select multiple files at once.
</div>
</div>
<div id="filePreview" class="mb-3" style="display:none;">
<h6>Selected Files:</h6>
<ul id="fileList" class="list-group list-group-flush small"></ul>
</div>
<button type="submit" class="btn btn-primary" id="uploadBtn" disabled>
<span id="uploadBtnText">Upload</span>
<span id="uploadSpinner" class="spinner-border spinner-border-sm ms-1" role="status" style="display:none;"></span>
</button>
</form>
</div>
</div>
<!-- Currently Processing -->
<div id="processingCard" class="card shadow-sm mb-4 border-info" style="display:@(Model.CurrentlyProcessing != null ? "block" : "none");">
<div class="card-header bg-info text-white d-flex align-items-center">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
<strong>Currently Processing</strong>
</div>
<div class="card-body" id="processingBody">
@if (Model.CurrentlyProcessing != null)
{
<div>
<a asp-page="/ViewReceipt" asp-route-id="@Model.CurrentlyProcessing.ReceiptId">
@Model.CurrentlyProcessing.FileName
</a>
<span class="text-muted ms-2">
(uploaded @Model.CurrentlyProcessing.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))
</span>
</div>
}
</div>
</div>
<!-- Queue Dashboard Tabs -->
<div class="card shadow-sm">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="queueTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="queued-tab" data-bs-toggle="tab" data-bs-target="#queuedPane"
type="button" role="tab">
Queued <span class="badge bg-warning text-dark ms-1" id="queuedBadge">@Model.QueuedItems.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="completed-tab" data-bs-toggle="tab" data-bs-target="#completedPane"
type="button" role="tab">
Completed <span class="badge bg-success ms-1" id="completedBadge">@Model.CompletedItems.Count</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="failed-tab" data-bs-toggle="tab" data-bs-target="#failedPane"
type="button" role="tab">
Failed <span class="badge bg-danger ms-1" id="failedBadge">@Model.FailedItems.Count</span>
</button>
</li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content" id="queueTabContent">
<!-- Queued Tab -->
<div class="tab-pane fade show active" id="queuedPane" role="tabpanel">
@if (Model.QueuedItems.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th style="width:60px;">#</th>
<th>File Name</th>
<th style="width:160px;">Uploaded</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="queuedBody">
@foreach (var item in Model.QueuedItems)
{
<tr>
<td><span class="badge bg-warning text-dark">@item.QueuePosition</span></td>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId">@item.FileName</a>
</td>
<td class="small text-muted">@item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</td>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId" class="btn btn-sm btn-outline-primary">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="p-3 text-center text-muted" id="queuedEmpty">No items in queue.</div>
}
</div>
<!-- Completed Tab -->
<div class="tab-pane fade" id="completedPane" role="tabpanel">
@if (Model.CompletedItems.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>File Name</th>
<th>Merchant</th>
<th class="text-end">Total</th>
<th class="text-center">Confidence</th>
<th class="text-center">Items</th>
<th style="width:80px;">Action</th>
</tr>
</thead>
<tbody id="completedBody">
@foreach (var item in Model.CompletedItems)
{
<tr>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId">@item.FileName</a>
</td>
<td>@(item.Merchant ?? "-")</td>
<td class="text-end">@(item.Total?.ToString("C") ?? "-")</td>
<td class="text-center">
@if (item.Confidence.HasValue)
{
var pct = item.Confidence.Value * 100;
var cls = pct >= 80 ? "success" : pct >= 50 ? "warning" : "danger";
<span class="badge bg-@cls">@pct.ToString("F0")%</span>
}
else
{
<span class="text-muted">-</span>
}
</td>
<td class="text-center">@item.LineItemCount</td>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId" class="btn btn-sm btn-outline-primary">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="p-3 text-center text-muted" id="completedEmpty">No completed items.</div>
}
</div>
<!-- Failed Tab -->
<div class="tab-pane fade" id="failedPane" role="tabpanel">
@if (Model.FailedItems.Any())
{
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>File Name</th>
<th>Error</th>
<th style="width:160px;">Uploaded</th>
<th style="width:140px;">Actions</th>
</tr>
</thead>
<tbody id="failedBody">
@foreach (var item in Model.FailedItems)
{
<tr>
<td>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId">@item.FileName</a>
</td>
<td class="small text-danger">@(item.ErrorMessage ?? "Unknown error")</td>
<td class="small text-muted">@item.UploadedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm")</td>
<td>
<form method="post" asp-page-handler="Retry" asp-route-receiptId="@item.ReceiptId" style="display:inline;">
<button type="submit" class="btn btn-sm btn-outline-warning">Retry</button>
</form>
<a asp-page="/ViewReceipt" asp-route-id="@item.ReceiptId" class="btn btn-sm btn-outline-primary">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
else
{
<div class="p-3 text-center text-muted" id="failedEmpty">No failed items.</div>
}
</div>
</div>
</div>
</div>
@section Scripts {
<script>
// File input preview
document.getElementById('fileInput').addEventListener('change', function () {
var preview = document.getElementById('filePreview');
var list = document.getElementById('fileList');
var btn = document.getElementById('uploadBtn');
list.innerHTML = '';
if (this.files.length > 0) {
preview.style.display = 'block';
btn.disabled = false;
for (var i = 0; i < this.files.length; i++) {
var li = document.createElement('li');
li.className = 'list-group-item py-1';
var sizeKB = (this.files[i].size / 1024).toFixed(1);
li.textContent = this.files[i].name + ' (' + sizeKB + ' KB)';
list.appendChild(li);
}
} else {
preview.style.display = 'none';
btn.disabled = true;
}
});
// Upload spinner
document.getElementById('uploadForm').addEventListener('submit', function () {
document.getElementById('uploadBtn').disabled = true;
document.getElementById('uploadBtnText').textContent = 'Uploading...';
document.getElementById('uploadSpinner').style.display = 'inline-block';
});
// AJAX polling for queue status
var pollInterval = null;
var hasActiveItems = @((Model.CurrentlyProcessing != null || Model.QueuedItems.Any()) ? "true" : "false");
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(fetchQueueStatus, 3000);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
function fetchQueueStatus() {
fetch('?handler=QueueStatus', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.json())
.then(data => {
updateDashboard(data);
if (!data.currentlyProcessing && data.queued.length === 0) {
stopPolling();
}
})
.catch(err => console.error('Poll error:', err));
}
function updateDashboard(data) {
// Processing card
var procCard = document.getElementById('processingCard');
var procBody = document.getElementById('processingBody');
if (data.currentlyProcessing) {
procCard.style.display = 'block';
procBody.innerHTML = '<div><a href="/ViewReceipt/' + data.currentlyProcessing.receiptId + '">' +
escapeHtml(data.currentlyProcessing.fileName) + '</a></div>';
} else {
procCard.style.display = 'none';
}
// Badges
document.getElementById('queuedBadge').textContent = data.queued.length;
document.getElementById('completedBadge').textContent = data.completed.length;
document.getElementById('failedBadge').textContent = data.failed.length;
// Queued table
var queuedPane = document.getElementById('queuedPane');
if (data.queued.length > 0) {
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0">' +
'<thead><tr><th style="width:60px;">#</th><th>File Name</th><th style="width:160px;">Uploaded</th><th style="width:80px;">Action</th></tr></thead><tbody>';
data.queued.forEach(function(item) {
html += '<tr><td><span class="badge bg-warning text-dark">' + item.queuePosition + '</span></td>' +
'<td><a href="/ViewReceipt/' + item.receiptId + '">' + escapeHtml(item.fileName) + '</a></td>' +
'<td class="small text-muted">' + formatDate(item.uploadedAtUtc) + '</td>' +
'<td><a href="/ViewReceipt/' + item.receiptId + '" class="btn btn-sm btn-outline-primary">View</a></td></tr>';
});
html += '</tbody></table></div>';
queuedPane.innerHTML = html;
} else {
queuedPane.innerHTML = '<div class="p-3 text-center text-muted">No items in queue.</div>';
}
// Completed table
var completedPane = document.getElementById('completedPane');
if (data.completed.length > 0) {
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0">' +
'<thead><tr><th>File Name</th><th>Merchant</th><th class="text-end">Total</th><th class="text-center">Confidence</th><th class="text-center">Items</th><th style="width:80px;">Action</th></tr></thead><tbody>';
data.completed.forEach(function(item) {
var confHtml = '-';
if (item.confidence != null) {
var pct = item.confidence * 100;
var cls = pct >= 80 ? 'success' : pct >= 50 ? 'warning' : 'danger';
confHtml = '<span class="badge bg-' + cls + '">' + pct.toFixed(0) + '%</span>';
}
html += '<tr><td><a href="/ViewReceipt/' + item.receiptId + '">' + escapeHtml(item.fileName) + '</a></td>' +
'<td>' + escapeHtml(item.merchant || '-') + '</td>' +
'<td class="text-end">' + (item.total != null ? '$' + item.total.toFixed(2) : '-') + '</td>' +
'<td class="text-center">' + confHtml + '</td>' +
'<td class="text-center">' + item.lineItemCount + '</td>' +
'<td><a href="/ViewReceipt/' + item.receiptId + '" class="btn btn-sm btn-outline-primary">View</a></td></tr>';
});
html += '</tbody></table></div>';
completedPane.innerHTML = html;
} else {
completedPane.innerHTML = '<div class="p-3 text-center text-muted">No completed items.</div>';
}
// Failed table
var failedPane = document.getElementById('failedPane');
if (data.failed.length > 0) {
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0">' +
'<thead><tr><th>File Name</th><th>Error</th><th style="width:160px;">Uploaded</th><th style="width:140px;">Actions</th></tr></thead><tbody>';
data.failed.forEach(function(item) {
html += '<tr><td><a href="/ViewReceipt/' + item.receiptId + '">' + escapeHtml(item.fileName) + '</a></td>' +
'<td class="small text-danger">' + escapeHtml(item.errorMessage || 'Unknown error') + '</td>' +
'<td class="small text-muted">' + formatDate(item.uploadedAtUtc) + '</td>' +
'<td><form method="post" action="?handler=Retry&receiptId=' + item.receiptId + '" style="display:inline;">' +
'<input type="hidden" name="__RequestVerificationToken" value="' + getAntiForgeryToken() + '" />' +
'<button type="submit" class="btn btn-sm btn-outline-warning">Retry</button></form> ' +
'<a href="/ViewReceipt/' + item.receiptId + '" class="btn btn-sm btn-outline-primary">View</a></td></tr>';
});
html += '</tbody></table></div>';
failedPane.innerHTML = html;
} else {
failedPane.innerHTML = '<div class="p-3 text-center text-muted">No failed items.</div>';
}
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDate(utcStr) {
var d = new Date(utcStr);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function getAntiForgeryToken() {
var el = document.querySelector('input[name="__RequestVerificationToken"]');
return el ? el.value : '';
}
if (hasActiveItems) {
startPolling();
}
</script>
}
+220
View File
@@ -0,0 +1,220 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages
{
public class ReceiptQueueModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly IReceiptManager _receiptManager;
private readonly IReceiptParseQueue _parseQueue;
public ReceiptQueueModel(
MoneyMapContext db,
IReceiptManager receiptManager,
IReceiptParseQueue parseQueue)
{
_db = db;
_receiptManager = receiptManager;
_parseQueue = parseQueue;
}
public List<QueueItemViewModel> QueuedItems { get; set; } = new();
public List<QueueItemViewModel> CompletedItems { get; set; } = new();
public List<QueueItemViewModel> FailedItems { get; set; } = new();
public QueueItemViewModel? CurrentlyProcessing { get; set; }
[TempData]
public string? Message { get; set; }
[TempData]
public bool IsSuccess { get; set; }
public async Task OnGetAsync()
{
await LoadQueueDashboardAsync();
}
public async Task<IActionResult> OnPostUploadAsync(List<IFormFile> files)
{
if (files == null || files.Count == 0)
{
Message = "Please select files to upload.";
IsSuccess = false;
return RedirectToPage();
}
var result = await _receiptManager.UploadManyUnmappedReceiptsAsync(files);
var messages = new List<string>();
if (result.Uploaded.Count > 0)
messages.Add($"{result.Uploaded.Count} receipt(s) uploaded and queued for parsing.");
if (result.Failed.Count > 0)
messages.Add($"{result.Failed.Count} failed: " +
string.Join("; ", result.Failed.Select(f => $"{f.FileName}: {f.ErrorMessage}")));
Message = string.Join(" ", messages);
IsSuccess = result.Failed.Count == 0;
return RedirectToPage();
}
public async Task<IActionResult> OnGetQueueStatusAsync()
{
await LoadQueueDashboardAsync();
var data = new
{
currentlyProcessing = CurrentlyProcessing,
queued = QueuedItems,
completed = CompletedItems,
failed = FailedItems
};
return new JsonResult(data);
}
public async Task<IActionResult> OnPostRetryAsync(long receiptId)
{
var receipt = await _db.Receipts.FindAsync(receiptId);
if (receipt == null)
{
Message = "Receipt not found.";
IsSuccess = false;
return RedirectToPage();
}
receipt.ParseStatus = ReceiptParseStatus.Queued;
await _db.SaveChangesAsync();
await _parseQueue.EnqueueAsync(receiptId);
Message = $"Receipt \"{receipt.FileName}\" re-queued for parsing.";
IsSuccess = true;
return RedirectToPage();
}
private async Task LoadQueueDashboardAsync()
{
var currentId = _parseQueue.CurrentlyProcessingId;
// Load all non-NotRequested receipts (recent first, limit to keep things manageable)
var recentReceipts = await _db.Receipts
.Where(r => r.ParseStatus != ReceiptParseStatus.NotRequested)
.OrderByDescending(r => r.UploadedAtUtc)
.Take(200)
.Select(r => new QueueItemViewModel
{
ReceiptId = r.Id,
FileName = r.FileName,
UploadedAtUtc = r.UploadedAtUtc,
ParseStatus = r.ParseStatus,
Merchant = r.Merchant,
Total = r.Total,
LineItemCount = r.LineItems.Count
})
.ToListAsync();
// Get error messages from latest parse log for failed items
var failedIds = recentReceipts
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
.Select(r => r.ReceiptId)
.ToList();
if (failedIds.Count > 0)
{
var errorLogs = await _db.ReceiptParseLogs
.Where(l => failedIds.Contains(l.ReceiptId) && !l.Success)
.GroupBy(l => l.ReceiptId)
.Select(g => new { ReceiptId = g.Key, Error = g.OrderByDescending(l => l.StartedAtUtc).First().Error })
.ToListAsync();
var errorMap = errorLogs.ToDictionary(e => e.ReceiptId, e => e.Error);
foreach (var item in recentReceipts.Where(r => r.ParseStatus == ReceiptParseStatus.Failed))
{
item.ErrorMessage = errorMap.GetValueOrDefault(item.ReceiptId);
}
}
// Get confidence from latest successful parse log
var completedIds = recentReceipts
.Where(r => r.ParseStatus == ReceiptParseStatus.Completed)
.Select(r => r.ReceiptId)
.ToList();
if (completedIds.Count > 0)
{
var confidenceLogs = await _db.ReceiptParseLogs
.Where(l => completedIds.Contains(l.ReceiptId) && l.Success)
.GroupBy(l => l.ReceiptId)
.Select(g => new { ReceiptId = g.Key, Confidence = g.OrderByDescending(l => l.StartedAtUtc).First().Confidence })
.ToListAsync();
var confidenceMap = confidenceLogs.ToDictionary(c => c.ReceiptId, c => c.Confidence);
foreach (var item in recentReceipts.Where(r => r.ParseStatus == ReceiptParseStatus.Completed))
{
item.Confidence = confidenceMap.GetValueOrDefault(item.ReceiptId);
}
}
// Assign queue positions for queued items
var queuedList = recentReceipts
.Where(r => r.ParseStatus == ReceiptParseStatus.Queued)
.OrderBy(r => r.UploadedAtUtc)
.ToList();
for (int i = 0; i < queuedList.Count; i++)
queuedList[i].QueuePosition = i + 1;
QueuedItems = queuedList;
CompletedItems = recentReceipts
.Where(r => r.ParseStatus == ReceiptParseStatus.Completed)
.OrderByDescending(r => r.UploadedAtUtc)
.ToList();
FailedItems = recentReceipts
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
.OrderByDescending(r => r.UploadedAtUtc)
.ToList();
if (currentId.HasValue)
{
CurrentlyProcessing = recentReceipts
.FirstOrDefault(r => r.ReceiptId == currentId.Value);
// If currently processing item isn't in our recent list, load it
if (CurrentlyProcessing == null)
{
CurrentlyProcessing = await _db.Receipts
.Where(r => r.Id == currentId.Value)
.Select(r => new QueueItemViewModel
{
ReceiptId = r.Id,
FileName = r.FileName,
UploadedAtUtc = r.UploadedAtUtc,
ParseStatus = r.ParseStatus
})
.FirstOrDefaultAsync();
}
}
}
public class QueueItemViewModel
{
public long ReceiptId { get; set; }
public string FileName { get; set; } = "";
public DateTime UploadedAtUtc { get; set; }
public ReceiptParseStatus ParseStatus { get; set; }
public int QueuePosition { get; set; }
public string? Merchant { get; set; }
public decimal? Total { get; set; }
public decimal? Confidence { get; set; }
public string? ErrorMessage { get; set; }
public int LineItemCount { get; set; }
}
}
}
+61 -9
View File
@@ -10,8 +10,11 @@
<a asp-page="/ReviewReceipts" class="btn btn-warning me-2"> <a asp-page="/ReviewReceipts" class="btn btn-warning me-2">
Review Mappings Review Mappings
</a> </a>
<a asp-page="/ReceiptQueue" class="btn btn-info me-2">
Parse Queue
</a>
<button type="button" class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#uploadReceiptModal"> <button type="button" class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#uploadReceiptModal">
Upload Receipt Upload Receipts
</button> </button>
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a> <a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
</div> </div>
@@ -115,25 +118,33 @@
</script> </script>
} }
<!-- Upload Receipt Modal --> <!-- Upload Receipts Modal -->
<div class="modal fade" id="uploadReceiptModal" tabindex="-1" aria-labelledby="uploadReceiptModalLabel" aria-hidden="true"> <div class="modal fade" id="uploadReceiptModal" tabindex="-1" aria-labelledby="uploadReceiptModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="uploadReceiptModalLabel">Upload Receipt</h5> <h5 class="modal-title" id="uploadReceiptModalLabel">Upload Receipts</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<form method="post" enctype="multipart/form-data" asp-page-handler="Upload"> <form method="post" enctype="multipart/form-data" asp-page-handler="UploadToQueue" id="uploadForm">
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label for="UploadFile" class="form-label">Select Receipt File</label> <label for="uploadFiles" class="form-label">Select Receipt Files</label>
<input type="file" asp-for="UploadFile" class="form-control" accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" /> <input type="file" name="files" id="uploadFiles" class="form-control" multiple
<div class="form-text">Supported formats: JPG, PNG, PDF, GIF, HEIC (Max 10MB)</div> accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
<div class="form-text">Supported: JPG, PNG, PDF, GIF, HEIC (Max 10MB each). Select multiple files at once.</div>
</div>
<div id="filePreview" class="mb-3" style="display:none;">
<h6>Selected Files:</h6>
<ul id="fileList" class="list-group list-group-flush small"></ul>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button> <button type="submit" class="btn btn-primary" id="uploadBtn" disabled>
<span id="uploadBtnText">Upload & Parse</span>
<span id="uploadSpinner" class="spinner-border spinner-border-sm ms-1" role="status" style="display:none;"></span>
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -154,15 +165,25 @@
<span class="text-muted">- 0 total</span> <span class="text-muted">- 0 total</span>
} }
</div> </div>
<div class="d-flex gap-2">
@if (Model.FailedParseCount > 0)
{
<form method="post" asp-page-handler="RetryFailedParses" style="display: inline;">
<button type="submit" class="btn btn-sm btn-danger" title="Re-queue all failed receipts for AI parsing">
Retry @Model.FailedParseCount Failed Parse(s)
</button>
</form>
}
@if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue))) @if (Model.Receipts.Any(r => !r.TransactionId.HasValue && (!string.IsNullOrWhiteSpace(r.Merchant) || r.ReceiptDate.HasValue || r.Total.HasValue)))
{ {
<form method="post" asp-page-handler="AutoMapUnmapped" style="display: inline;"> <form method="post" asp-page-handler="AutoMapUnmapped" style="display: inline;">
<button type="submit" class="btn btn-sm btn-success" title="Automatically map unmapped receipts to matching transactions"> <button type="submit" class="btn btn-sm btn-success" title="Automatically map unmapped receipts to matching transactions">
🔗 Auto-Map Unmapped Receipts Auto-Map Unmapped Receipts
</button> </button>
</form> </form>
} }
</div> </div>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
@if (Model.Receipts.Any()) @if (Model.Receipts.Any())
{ {
@@ -487,6 +508,7 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Map form validation
document.querySelectorAll('form[data-mapform="1"]').forEach(function(form){ document.querySelectorAll('form[data-mapform="1"]').forEach(function(form){
form.addEventListener('submit', function(e){ form.addEventListener('submit', function(e){
var hidden = form.querySelector('input[type="hidden"][name="transactionId"]'); var hidden = form.querySelector('input[type="hidden"][name="transactionId"]');
@@ -498,6 +520,36 @@
} }
}); });
}); });
// Upload file preview
document.getElementById('uploadFiles').addEventListener('change', function () {
var preview = document.getElementById('filePreview');
var list = document.getElementById('fileList');
var btn = document.getElementById('uploadBtn');
list.innerHTML = '';
if (this.files.length > 0) {
preview.style.display = 'block';
btn.disabled = false;
for (var i = 0; i < this.files.length; i++) {
var li = document.createElement('li');
li.className = 'list-group-item py-1';
var sizeKB = (this.files[i].size / 1024).toFixed(1);
li.textContent = this.files[i].name + ' (' + sizeKB + ' KB)';
list.appendChild(li);
}
} else {
preview.style.display = 'none';
btn.disabled = true;
}
});
// Upload spinner
document.getElementById('uploadForm').addEventListener('submit', function () {
document.getElementById('uploadBtn').disabled = true;
document.getElementById('uploadBtnText').textContent = 'Uploading...';
document.getElementById('uploadSpinner').style.display = 'inline-block';
});
}); });
</script> </script>
+54 -1
View File
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services; using MoneyMap.Services;
namespace MoneyMap.Pages namespace MoneyMap.Pages
@@ -13,12 +14,15 @@ namespace MoneyMap.Pages
private readonly IReceiptAutoMapper _autoMapper; private readonly IReceiptAutoMapper _autoMapper;
private readonly IReceiptMatchingService _receiptMatchingService; private readonly IReceiptMatchingService _receiptMatchingService;
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper, IReceiptMatchingService receiptMatchingService) private readonly IReceiptParseQueue _parseQueue;
public ReceiptsModel(MoneyMapContext db, IReceiptManager receiptManager, IReceiptAutoMapper autoMapper, IReceiptMatchingService receiptMatchingService, IReceiptParseQueue parseQueue)
{ {
_db = db; _db = db;
_receiptManager = receiptManager; _receiptManager = receiptManager;
_autoMapper = autoMapper; _autoMapper = autoMapper;
_receiptMatchingService = receiptMatchingService; _receiptMatchingService = receiptMatchingService;
_parseQueue = parseQueue;
} }
public List<ReceiptRow> Receipts { get; set; } = new(); public List<ReceiptRow> Receipts { get; set; } = new();
@@ -53,10 +57,12 @@ namespace MoneyMap.Pages
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new(); public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
public bool ShowDuplicateModal { get; set; } = false; public bool ShowDuplicateModal { get; set; } = false;
public int FailedParseCount { get; set; }
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
await LoadReceiptsAsync(); await LoadReceiptsAsync();
FailedParseCount = await _db.Receipts.CountAsync(r => r.ParseStatus == ReceiptParseStatus.Failed);
// Show duplicate modal if warnings present // Show duplicate modal if warnings present
if (!string.IsNullOrWhiteSpace(DuplicateWarningsJson)) if (!string.IsNullOrWhiteSpace(DuplicateWarningsJson))
@@ -66,6 +72,29 @@ namespace MoneyMap.Pages
} }
} }
public async Task<IActionResult> OnPostUploadToQueueAsync(List<IFormFile> files)
{
if (files == null || files.Count == 0)
{
Message = "Please select files to upload.";
IsSuccess = false;
return RedirectToPage();
}
var result = await _receiptManager.UploadManyUnmappedReceiptsAsync(files);
var messages = new List<string>();
if (result.Uploaded.Count > 0)
messages.Add($"{result.Uploaded.Count} receipt(s) uploaded and queued for parsing.");
if (result.Failed.Count > 0)
messages.Add($"{result.Failed.Count} failed: " +
string.Join("; ", result.Failed.Select(f => $"{f.FileName}: {f.ErrorMessage}")));
Message = string.Join(" ", messages);
IsSuccess = result.Failed.Count == 0;
return RedirectToPage();
}
public async Task<IActionResult> OnPostUploadAsync() public async Task<IActionResult> OnPostUploadAsync()
{ {
if (UploadFile == null) if (UploadFile == null)
@@ -227,6 +256,30 @@ namespace MoneyMap.Pages
return RedirectToPage(); return RedirectToPage();
} }
public async Task<IActionResult> OnPostRetryFailedParsesAsync()
{
var failedReceipts = await _db.Receipts
.Where(r => r.ParseStatus == ReceiptParseStatus.Failed)
.ToListAsync();
if (failedReceipts.Count == 0)
{
Message = "No failed receipts to retry.";
IsSuccess = false;
return RedirectToPage();
}
foreach (var receipt in failedReceipts)
receipt.ParseStatus = ReceiptParseStatus.Queued;
await _db.SaveChangesAsync();
await _parseQueue.EnqueueManyAsync(failedReceipts.Select(r => r.Id));
Message = $"Re-queued {failedReceipts.Count} failed receipt(s) for parsing.";
IsSuccess = true;
return RedirectToPage();
}
public async Task<IActionResult> OnPostUnmapAsync(long receiptId) public async Task<IActionResult> OnPostUnmapAsync(long receiptId)
{ {
var success = await _receiptManager.UnmapReceiptAsync(receiptId); var success = await _receiptManager.UnmapReceiptAsync(receiptId);
-121
View File
@@ -1,121 +0,0 @@
@page
@model MoneyMap.Pages.ReviewAISuggestionsModel
@{
ViewData["Title"] = "AI Categorization Suggestions";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>AI Categorization Suggestions</h2>
<div class="d-flex gap-2">
<a asp-page="/Transactions" asp-route-category="(blank)" class="btn btn-outline-secondary">
View Uncategorized
</a>
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="card shadow-sm mb-3">
<div class="card-body">
<h5 class="card-title">How AI Categorization Works</h5>
<p class="card-text">
This tool uses AI to analyze your uncategorized transactions and suggest:
</p>
<ul>
<li><strong>Category</strong> - The most appropriate expense category</li>
<li><strong>Merchant Name</strong> - A normalized merchant name (e.g., "Walmart" from "WAL-MART #1234")</li>
<li><strong>Pattern Rule</strong> - An optional rule to auto-categorize similar transactions in the future</li>
</ul>
<p class="card-text">
<strong>Cost:</strong> Approximately $0.00015 per transaction (~1.5 cents per 100 transactions)
</p>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Uncategorized Transactions (@Model.TotalUncategorized)</strong>
<form method="post" asp-page-handler="GenerateSuggestions" class="d-inline">
<button type="submit" class="btn btn-primary btn-sm">
Generate AI Suggestions (up to 20)
</button>
</form>
</div>
<div class="card-body">
@if (!Model.Transactions.Any())
{
<p class="text-muted">No uncategorized transactions found. Great job!</p>
}
else
{
<p class="text-muted mb-3">
Showing the @Model.Transactions.Count most recent uncategorized transactions.
Click "Generate AI Suggestions" to analyze up to 20 transactions.
</p>
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Date</th>
<th>Name</th>
<th>Memo</th>
<th class="text-end">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Transactions)
{
<tr>
<td>@item.Transaction.Date.ToString("yyyy-MM-dd")</td>
<td>@item.Transaction.Name</td>
<td class="text-truncate" style="max-width: 300px;">@item.Transaction.Memo</td>
<td class="text-end">
<span class="@(item.Transaction.Amount < 0 ? "text-danger" : "text-success")">
@item.Transaction.Amount.ToString("C")
</span>
</td>
<td>
<a asp-page="/EditTransaction" asp-route-id="@item.Transaction.Id" class="btn btn-sm btn-outline-primary">
Edit
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
<div class="card shadow-sm">
<div class="card-header">
<strong>Quick Tips</strong>
</div>
<div class="card-body">
<ul class="small mb-0">
<li>AI suggestions are based on transaction name, memo, amount, and date</li>
<li>You can accept, reject, or modify each suggestion</li>
<li>Creating rules helps auto-categorize future transactions</li>
<li>High confidence suggestions (>80%) are more reliable</li>
<li>You can manually edit any transaction from the Transactions page</li>
</ul>
</div>
</div>
@@ -1,114 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
namespace MoneyMap.Pages;
public class ReviewAISuggestionsModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly ITransactionAICategorizer _aiCategorizer;
public ReviewAISuggestionsModel(MoneyMapContext db, ITransactionAICategorizer aiCategorizer)
{
_db = db;
_aiCategorizer = aiCategorizer;
}
public List<TransactionWithProposal> Transactions { get; set; } = new();
public bool IsGenerating { get; set; }
public int TotalUncategorized { get; set; }
[TempData]
public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task OnGetAsync()
{
// Get uncategorized transactions
var uncategorized = await _db.Transactions
.Include(t => t.Merchant)
.Where(t => string.IsNullOrEmpty(t.Category))
.OrderByDescending(t => t.Date)
.Take(50) // Limit to 50 most recent
.ToListAsync();
TotalUncategorized = uncategorized.Count;
Transactions = uncategorized.Select(t => new TransactionWithProposal
{
Transaction = t,
Proposal = null // Will be populated via AJAX or on generate
}).ToList();
}
public async Task<IActionResult> OnPostGenerateSuggestionsAsync()
{
// Get uncategorized transactions
var uncategorized = await _db.Transactions
.Where(t => string.IsNullOrEmpty(t.Category))
.OrderByDescending(t => t.Date)
.Take(20) // Limit to 20 for cost control
.ToListAsync();
if (!uncategorized.Any())
{
ErrorMessage = "No uncategorized transactions found.";
return RedirectToPage();
}
// Generate proposals
var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(uncategorized);
// Store proposals in session for review
HttpContext.Session.SetString("AIProposals", System.Text.Json.JsonSerializer.Serialize(proposals));
SuccessMessage = $"Generated {proposals.Count} AI suggestions. Review them below.";
return RedirectToPage("ReviewAISuggestionsWithProposals");
}
public async Task<IActionResult> OnPostApplyProposalAsync(long transactionId, string category, string? merchant, string? pattern, decimal confidence, bool createRule)
{
var proposal = new AICategoryProposal
{
TransactionId = transactionId,
Category = category,
CanonicalMerchant = merchant,
Pattern = pattern,
Confidence = confidence,
CreateRule = createRule
};
var result = await _aiCategorizer.ApplyProposalAsync(transactionId, proposal, createRule);
if (result.Success)
{
SuccessMessage = result.RuleCreated
? "Transaction categorized and rule created!"
: "Transaction categorized!";
}
else
{
ErrorMessage = result.ErrorMessage ?? "Failed to apply suggestion.";
}
return RedirectToPage();
}
public IActionResult OnPostRejectProposalAsync(long transactionId)
{
// Just refresh the page, removing this transaction from view
SuccessMessage = "Suggestion rejected.";
return RedirectToPage();
}
public class TransactionWithProposal
{
public Transaction Transaction { get; set; } = null!;
public AICategoryProposal? Proposal { get; set; }
}
}
@@ -1,162 +0,0 @@
@page
@model MoneyMap.Pages.ReviewAISuggestionsWithProposalsModel
@{
ViewData["Title"] = "Review AI Suggestions";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Review AI Suggestions</h2>
<div class="d-flex gap-2">
<form method="post" asp-page-handler="ApplyAll" class="d-inline" onsubmit="return confirm('Apply all high-confidence suggestions (≥80%)?');">
<button type="submit" class="btn btn-success">
Apply All High Confidence
</button>
</form>
<a asp-page="/ReviewAISuggestions" class="btn btn-outline-secondary">Back</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (!Model.Proposals.Any())
{
<div class="alert alert-info">
<h5>No suggestions remaining</h5>
<p class="mb-0">
All AI suggestions have been processed.
<a asp-page="/ReviewAISuggestions" class="alert-link">Generate more suggestions</a>
</p>
</div>
}
else
{
<div class="alert alert-info mb-3">
<strong>Review each suggestion below.</strong> You can accept the AI's proposal, reject it, or modify it before applying.
High confidence suggestions (≥80%) are generally very reliable.
</div>
@foreach (var item in Model.Proposals)
{
var confidenceClass = item.Proposal.Confidence >= 0.8m ? "success" :
item.Proposal.Confidence >= 0.6m ? "warning" : "danger";
var confidencePercent = (item.Proposal.Confidence * 100).ToString("F0");
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>@item.Transaction.Name</strong>
<span class="text-muted ms-2">@item.Transaction.Date.ToString("yyyy-MM-dd")</span>
<span class="badge bg-@confidenceClass ms-2">@confidencePercent% Confidence</span>
</div>
<span class="@(item.Transaction.Amount < 0 ? "text-danger" : "text-success") fw-bold">
@item.Transaction.Amount.ToString("C")
</span>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Transaction Details</h6>
<dl class="row small">
<dt class="col-sm-3">Memo:</dt>
<dd class="col-sm-9">@item.Transaction.Memo</dd>
<dt class="col-sm-3">Amount:</dt>
<dd class="col-sm-9">@item.Transaction.Amount.ToString("C")</dd>
</dl>
</div>
<div class="col-md-6">
<h6>AI Suggestion</h6>
<dl class="row small">
<dt class="col-sm-4">Category:</dt>
<dd class="col-sm-8"><span class="badge bg-primary">@item.Proposal.Category</span></dd>
@if (!string.IsNullOrWhiteSpace(item.Proposal.CanonicalMerchant))
{
<dt class="col-sm-4">Merchant:</dt>
<dd class="col-sm-8">@item.Proposal.CanonicalMerchant</dd>
}
@if (!string.IsNullOrWhiteSpace(item.Proposal.Pattern))
{
<dt class="col-sm-4">Pattern:</dt>
<dd class="col-sm-8"><code>@item.Proposal.Pattern</code></dd>
}
@if (!string.IsNullOrWhiteSpace(item.Proposal.Reasoning))
{
<dt class="col-sm-4">Reasoning:</dt>
<dd class="col-sm-8"><em>@item.Proposal.Reasoning</em></dd>
}
</dl>
</div>
</div>
<hr />
<div class="d-flex gap-2 justify-content-end">
<form method="post" asp-page-handler="RejectProposal" class="d-inline">
<input type="hidden" name="transactionId" value="@item.Transaction.Id" />
<button type="submit" class="btn btn-outline-danger btn-sm">
Reject
</button>
</form>
<form method="post" asp-page-handler="ApplyProposal" class="d-inline">
<input type="hidden" name="transactionId" value="@item.Transaction.Id" />
<input type="hidden" name="category" value="@item.Proposal.Category" />
<input type="hidden" name="merchant" value="@item.Proposal.CanonicalMerchant" />
<input type="hidden" name="pattern" value="@item.Proposal.Pattern" />
<input type="hidden" name="confidence" value="@item.Proposal.Confidence" />
<input type="hidden" name="createRule" value="false" />
<button type="submit" class="btn btn-outline-primary btn-sm">
Apply (No Rule)
</button>
</form>
<form method="post" asp-page-handler="ApplyProposal" class="d-inline">
<input type="hidden" name="transactionId" value="@item.Transaction.Id" />
<input type="hidden" name="category" value="@item.Proposal.Category" />
<input type="hidden" name="merchant" value="@item.Proposal.CanonicalMerchant" />
<input type="hidden" name="pattern" value="@item.Proposal.Pattern" />
<input type="hidden" name="confidence" value="@item.Proposal.Confidence" />
<input type="hidden" name="createRule" value="true" />
<button type="submit" class="btn btn-primary btn-sm">
Apply + Create Rule
</button>
</form>
<a asp-page="/EditTransaction" asp-route-id="@item.Transaction.Id" class="btn btn-outline-secondary btn-sm">
Edit Manually
</a>
</div>
</div>
</div>
}
}
<div class="card shadow-sm mt-3">
<div class="card-header">
<strong>Understanding Confidence Scores</strong>
</div>
<div class="card-body">
<ul class="small mb-0">
<li><span class="badge bg-success">≥80%</span> - High confidence, very reliable</li>
<li><span class="badge bg-warning text-dark">60-79%</span> - Medium confidence, review recommended</li>
<li><span class="badge bg-danger">&lt;60%</span> - Low confidence, manual review strongly recommended</li>
</ul>
</div>
</div>
@@ -1,157 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
using MoneyMap.Services;
using System.Text.Json;
namespace MoneyMap.Pages;
public class ReviewAISuggestionsWithProposalsModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly ITransactionAICategorizer _aiCategorizer;
public ReviewAISuggestionsWithProposalsModel(MoneyMapContext db, ITransactionAICategorizer aiCategorizer)
{
_db = db;
_aiCategorizer = aiCategorizer;
}
public List<TransactionWithProposal> Proposals { get; set; } = new();
[TempData]
public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task<IActionResult> OnGetAsync()
{
// Load proposals from session
var proposalsJson = HttpContext.Session.GetString("AIProposals");
if (string.IsNullOrWhiteSpace(proposalsJson))
{
ErrorMessage = "No AI suggestions found. Please generate suggestions first.";
return RedirectToPage("ReviewAISuggestions");
}
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(proposalsJson);
if (proposals == null || !proposals.Any())
{
ErrorMessage = "Failed to load AI suggestions.";
return RedirectToPage("ReviewAISuggestions");
}
// Load transactions for these proposals
var transactionIds = proposals.Select(p => p.TransactionId).ToList();
var transactions = await _db.Transactions
.Include(t => t.Merchant)
.Where(t => transactionIds.Contains(t.Id))
.ToListAsync();
Proposals = proposals.Select(p => new TransactionWithProposal
{
Transaction = transactions.FirstOrDefault(t => t.Id == p.TransactionId)!,
Proposal = p
}).Where(x => x.Transaction != null).ToList();
return Page();
}
public async Task<IActionResult> OnPostApplyProposalAsync(long transactionId, string category, string? merchant, string? pattern, decimal confidence, bool createRule)
{
var proposal = new AICategoryProposal
{
TransactionId = transactionId,
Category = category,
CanonicalMerchant = merchant,
Pattern = pattern,
Confidence = confidence,
CreateRule = createRule
};
var result = await _aiCategorizer.ApplyProposalAsync(transactionId, proposal, createRule);
if (result.Success)
{
// Remove this proposal from session
var proposalsJson = HttpContext.Session.GetString("AIProposals");
if (!string.IsNullOrWhiteSpace(proposalsJson))
{
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(proposalsJson);
if (proposals != null)
{
proposals.RemoveAll(p => p.TransactionId == transactionId);
HttpContext.Session.SetString("AIProposals", JsonSerializer.Serialize(proposals));
}
}
SuccessMessage = result.RuleCreated
? "Transaction categorized and rule created!"
: "Transaction categorized!";
}
else
{
ErrorMessage = result.ErrorMessage ?? "Failed to apply suggestion.";
}
return RedirectToPage();
}
public IActionResult OnPostRejectProposalAsync(long transactionId)
{
// Remove this proposal from session
var proposalsJson = HttpContext.Session.GetString("AIProposals");
if (!string.IsNullOrWhiteSpace(proposalsJson))
{
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(proposalsJson);
if (proposals != null)
{
proposals.RemoveAll(p => p.TransactionId == transactionId);
HttpContext.Session.SetString("AIProposals", JsonSerializer.Serialize(proposals));
}
}
SuccessMessage = "Suggestion rejected.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostApplyAllAsync()
{
var proposalsJson = HttpContext.Session.GetString("AIProposals");
if (string.IsNullOrWhiteSpace(proposalsJson))
{
ErrorMessage = "No AI suggestions found.";
return RedirectToPage("ReviewAISuggestions");
}
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(proposalsJson);
if (proposals == null || !proposals.Any())
{
ErrorMessage = "Failed to load AI suggestions.";
return RedirectToPage("ReviewAISuggestions");
}
int applied = 0;
foreach (var proposal in proposals.Where(p => p.Confidence >= 0.8m))
{
var result = await _aiCategorizer.ApplyProposalAsync(proposal.TransactionId, proposal, proposal.CreateRule);
if (result.Success)
applied++;
}
// Clear session
HttpContext.Session.Remove("AIProposals");
SuccessMessage = $"Applied {applied} high-confidence suggestions (≥80%).";
return RedirectToPage("ReviewAISuggestions");
}
public class TransactionWithProposal
{
public Transaction Transaction { get; set; } = null!;
public AICategoryProposal Proposal { get; set; } = null!;
}
}
+5
View File
@@ -2,6 +2,11 @@
@model MoneyMap.Pages.ReviewReceiptsModel @model MoneyMap.Pages.ReviewReceiptsModel
@{ @{
ViewData["Title"] = "Review Receipts"; ViewData["Title"] = "Review Receipts";
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Receipts", Url.Page("/Receipts")),
("Review Mappings", null)
};
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
+72 -14
View File
@@ -22,23 +22,62 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" asp-page="/Index">Dashboard</a> <a class="nav-link" asp-page="/Index">Dashboard</a>
</li> </li>
<li class="nav-item"> <li class="nav-item dropdown">
<a class="nav-link" asp-page="/Transactions">Transactions</a> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Transactions
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" asp-page="/Transactions">All Transactions</a></li>
<li><a class="dropdown-item" asp-page="/Recategorize">Recategorize</a></li>
<li><a class="dropdown-item" asp-page="/AICategorizePreview">AI Review</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Receipts
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" asp-page="/Receipts">All Receipts</a></li>
<li><a class="dropdown-item" asp-page="/ReceiptQueue">Parse Queue</a></li>
<li><a class="dropdown-item" asp-page="/ReviewReceipts">Review Mappings</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Accounts
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" asp-page="/Accounts">Bank Accounts</a></li>
<li><a class="dropdown-item" asp-page="/Cards">Cards</a></li>
</ul>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" asp-page="/Receipts">Receipts</a> <a class="nav-link" asp-page="/Budgets">Budgets</a>
</li> </li>
<li class="nav-item"> </ul>
<a class="nav-link" asp-page="/Accounts">Accounts</a> <ul class="navbar-nav ms-auto align-items-center">
<li class="nav-item me-2">
<a class="btn btn-sm btn-primary" asp-page="/Upload">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload me-1" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>
</svg>
Upload
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item dropdown">
<a class="nav-link" asp-page="/CategoryMappings">Categories</a> <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Settings">
</li> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-gear" viewBox="0 0 16 16">
<li class="nav-item"> <path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492M5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0"/>
<a class="nav-link" asp-page="/Merchants">Merchants</a> <path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.892 3.434-.901 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.892-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52zm-2.658.06a1.873 1.873 0 0 1 3.724 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16a1.873 1.873 0 0 1 2.693 2.693l-.16.291a1.873 1.873 0 0 0 1.116 2.693l.318.094a1.873 1.873 0 0 1 0 3.724l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291a1.873 1.873 0 0 1-2.693 2.693l-.292-.16a1.873 1.873 0 0 0-2.693 1.116l-.094.318a1.873 1.873 0 0 1-3.724 0l-.094-.319a1.873 1.873 0 0 0-2.693-1.115l-.291.16a1.873 1.873 0 0 1-2.693-2.693l.16-.291a1.873 1.873 0 0 0-1.116-2.693l-.318-.094a1.873 1.873 0 0 1 0-3.724l.319-.094a1.873 1.873 0 0 0 1.115-2.693l-.16-.291a1.873 1.873 0 0 1 2.693-2.693l.292.16a1.873 1.873 0 0 0 2.693-1.116z"/>
</li> </svg>
<li class="nav-item"> </a>
<a class="nav-link" asp-page="/Recategorize">Recategorize</a> <ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" asp-page="/CategoryMappings">Category Rules</a></li>
<li><a class="dropdown-item" asp-page="/Merchants">Merchants</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" asp-page="/Settings">AI Settings</a></li>
</ul>
</li> </li>
</ul> </ul>
</div> </div>
@@ -47,6 +86,25 @@
</header> </header>
<div class="@(ViewData["FullWidth"] is true ? "container-fluid px-3" : "container")"> <div class="@(ViewData["FullWidth"] is true ? "container-fluid px-3" : "container")">
<main role="main" class="pb-3"> <main role="main" class="pb-3">
@if (ViewData["Breadcrumbs"] is List<(string Label, string? Url)> breadcrumbs && breadcrumbs.Count > 0)
{
<nav aria-label="breadcrumb" class="breadcrumb-nav">
<ol class="breadcrumb">
@for (var i = 0; i < breadcrumbs.Count; i++)
{
var crumb = breadcrumbs[i];
if (i == breadcrumbs.Count - 1)
{
<li class="breadcrumb-item active" aria-current="page">@crumb.Label</li>
}
else
{
<li class="breadcrumb-item"><a href="@crumb.Url" class="text-decoration-none">@crumb.Label</a></li>
}
}
</ol>
</nav>
}
@RenderBody() @RenderBody()
</main> </main>
</div> </div>
@@ -58,7 +116,7 @@
</footer> </footer>
<script src="~/lib/jquery/jquery.min.js"></script> <script src="~/lib/jquery/jquery.min.js"></script>
<script src="~/lib/bootstrap/js/bootstrap.min.js"></script> <script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script> <script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false) @await RenderSectionAsync("Scripts", required: false)
+7
View File
@@ -54,6 +54,13 @@ namespace MoneyMap.Pages
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
// Default to last 30 days if no date filters provided
if (!StartDate.HasValue && !EndDate.HasValue)
{
StartDate = DateTime.Today.AddDays(-30);
EndDate = DateTime.Today;
}
var query = _db.Transactions var query = _db.Transactions
.Include(t => t.Card) .Include(t => t.Card)
.ThenInclude(c => c!.Account) .ThenInclude(c => c!.Account)
+5
View File
@@ -3,6 +3,11 @@
@{ @{
ViewData["Title"] = "Upload Transactions"; ViewData["Title"] = "Upload Transactions";
ViewData["FullWidth"] = Model.PreviewTransactions.Any(); ViewData["FullWidth"] = Model.PreviewTransactions.Any();
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Transactions", Url.Page("/Transactions")),
("Upload", null)
};
} }
<h2>Upload Transactions</h2> <h2>Upload Transactions</h2>
+15 -8
View File
@@ -2,6 +2,12 @@
@model MoneyMap.Pages.ViewReceiptModel @model MoneyMap.Pages.ViewReceiptModel
@{ @{
ViewData["Title"] = "View Receipt"; ViewData["Title"] = "View Receipt";
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
{
("Transactions", Url.Page("/Transactions")),
($"Transaction #{Model.Receipt.TransactionId}", Url.Page("/EditTransaction", new { id = Model.Receipt.TransactionId })),
("Receipt", null)
};
} }
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@@ -156,10 +162,7 @@
<strong>Parse Receipt</strong> <strong>Parse Receipt</strong>
</div> </div>
<div class="card-body"> <div class="card-body">
@if (Model.AvailableParsers.Any())
{
<form method="post" asp-page-handler="Parse" asp-route-id="@Model.Receipt.Id"> <form method="post" asp-page-handler="Parse" asp-route-id="@Model.Receipt.Id">
<input type="hidden" name="parser" value="@Model.AvailableParsers.First().FullName" />
<p class="text-muted small mb-2"> <p class="text-muted small mb-2">
Using: <strong>@Model.SelectedModel</strong> Using: <strong>@Model.SelectedModel</strong>
<a href="/Settings" class="ms-2 small">Change</a> <a href="/Settings" class="ms-2 small">Change</a>
@@ -173,11 +176,6 @@
Parse Receipt Parse Receipt
</button> </button>
</form> </form>
}
else
{
<p class="text-muted small mb-0">No parsers available</p>
}
</div> </div>
</div> </div>
@@ -214,6 +212,15 @@
{ {
<div class="small text-danger mt-1">@log.Error</div> <div class="small text-danger mt-1">@log.Error</div>
} }
@if (!string.IsNullOrWhiteSpace(log.RawProviderPayloadJson) && log.RawProviderPayloadJson != "{}")
{
<a class="small" data-bs-toggle="collapse" href="#rawPayload@(log.Id)" role="button" aria-expanded="false">
Show LLM Response
</a>
<div class="collapse mt-1" id="rawPayload@(log.Id)">
<pre class="bg-body-secondary p-2 rounded small" style="max-height: 400px; overflow: auto; white-space: pre-wrap; word-break: break-word;">@log.RawProviderPayloadJson</pre>
</div>
}
</div> </div>
} }
</div> </div>
+14 -31
View File
@@ -11,25 +11,24 @@ namespace MoneyMap.Pages
{ {
private readonly MoneyMapContext _db; private readonly MoneyMapContext _db;
private readonly IReceiptManager _receiptManager; private readonly IReceiptManager _receiptManager;
private readonly IEnumerable<IReceiptParser> _parsers; private readonly IReceiptParseQueue _parseQueue;
private readonly IConfiguration _config; private readonly IConfiguration _config;
public ViewReceiptModel( public ViewReceiptModel(
MoneyMapContext db, MoneyMapContext db,
IReceiptManager receiptManager, IReceiptManager receiptManager,
IEnumerable<IReceiptParser> parsers, IReceiptParseQueue parseQueue,
IConfiguration config) IConfiguration config)
{ {
_db = db; _db = db;
_receiptManager = receiptManager; _receiptManager = receiptManager;
_parsers = parsers; _parseQueue = parseQueue;
_config = config; _config = config;
} }
public Receipt? Receipt { get; set; } public Receipt? Receipt { get; set; }
public List<ReceiptLineItem> LineItems { get; set; } = new(); public List<ReceiptLineItem> LineItems { get; set; } = new();
public List<ReceiptParseLog> ParseLogs { get; set; } = new(); public List<ReceiptParseLog> ParseLogs { get; set; } = new();
public List<ParserOption> AvailableParsers { get; set; } = new();
public string ReceiptUrl { get; set; } = ""; public string ReceiptUrl { get; set; } = "";
public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
@@ -62,13 +61,6 @@ namespace MoneyMap.Pages
// Get receipt URL for display - use handler parameter // Get receipt URL for display - use handler parameter
ReceiptUrl = $"/ViewReceipt/{id}?handler=file"; ReceiptUrl = $"/ViewReceipt/{id}?handler=file";
// Get available parsers
AvailableParsers = _parsers.Select(p => new ParserOption
{
Name = p.GetType().Name.Replace("ReceiptParser", ""),
FullName = p.GetType().Name
}).ToList();
return Page(); return Page();
} }
@@ -94,35 +86,26 @@ namespace MoneyMap.Pages
return File(fileBytes, receipt.ContentType); return File(fileBytes, receipt.ContentType);
} }
public async Task<IActionResult> OnPostParseAsync(long id, string parser) public async Task<IActionResult> OnPostParseAsync(long id)
{ {
var selectedParser = _parsers.FirstOrDefault(p => p.GetType().Name == parser); var receipt = await _db.Receipts.FindAsync(id);
if (selectedParser == null) if (receipt == null)
{ {
ErrorMessage = "Parser not found."; ErrorMessage = "Receipt not found.";
return RedirectToPage(new { id }); return RedirectToPage(new { id });
} }
// Use the configured model from settings, pass user notes // Save parsing notes to the receipt entity so the worker can use them
var result = await selectedParser.ParseReceiptAsync(id, SelectedModel, ParsingNotes); receipt.ParsingNotes = ParsingNotes;
receipt.ParseStatus = ReceiptParseStatus.Queued;
await _db.SaveChangesAsync();
if (result.IsSuccess) // Enqueue the receipt for parsing
{ await _parseQueue.EnqueueAsync(id);
SuccessMessage = result.Message;
}
else
{
ErrorMessage = result.Message;
}
SuccessMessage = "Receipt queued for parsing.";
return RedirectToPage(new { id }); return RedirectToPage(new { id });
} }
public class ParserOption
{
public string Name { get; set; } = "";
public string FullName { get; set; } = "";
}
} }
} }
+101
View File
@@ -0,0 +1,101 @@
@model List<MoneyMap.Pages.AICategorizePreviewModel.ProposalViewModel>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input select-all-tab" onchange="selectAllInTab(this)" checked>
</th>
<th>Transaction</th>
<th>Current</th>
<th>Proposed</th>
<th>Merchant</th>
<th style="width: 100px;">Confidence</th>
<th style="width: 140px;">Rule</th>
</tr>
</thead>
<tbody>
@foreach (var proposal in Model)
{
var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" :
proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary";
<tr>
<td>
<input type="checkbox" class="form-check-input proposal-checkbox"
name="selectedIds" value="@proposal.TransactionId" checked>
</td>
<td>
<div class="fw-bold">@proposal.TransactionName</div>
@if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo))
{
<small class="text-muted">@proposal.TransactionMemo</small>
}
<div class="small text-muted">
@proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C")
</div>
</td>
<td>
@if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory))
{
<span class="badge bg-secondary">@proposal.CurrentCategory</span>
}
else
{
<span class="text-muted small">(none)</span>
}
</td>
<td>
<span class="badge bg-primary">@proposal.ProposedCategory</span>
@if (!string.IsNullOrWhiteSpace(proposal.Reasoning))
{
<div class="small text-muted mt-1">@proposal.Reasoning</div>
}
</td>
<td>
@if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant))
{
<div>@proposal.ProposedMerchant</div>
}
@if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern))
{
<div class="small text-muted mt-1">Pattern: <code>@proposal.ProposedPattern</code></div>
}
</td>
<td>
<span class="badge @confidenceClass">@proposal.Confidence.ToString("P0")</span>
</td>
<td>
@if (proposal.HasExistingRule && !proposal.NeedsRuleUpdate)
{
<span class="badge bg-info text-dark" title="Rule already exists for pattern '@proposal.ProposedPattern' with same category">
Exists
</span>
}
else if (proposal.NeedsRuleUpdate)
{
<div>
<input type="checkbox" class="form-check-input create-rule-checkbox"
name="createRules" value="@proposal.TransactionId"
@(proposal.CreateRule ? "checked" : "")
title="Update existing rule from '@proposal.ExistingRuleCategory' to '@proposal.ProposedCategory'">
<span class="badge bg-warning text-dark">Update</span>
</div>
<div class="small text-muted mt-1">
Currently: <code>@proposal.ExistingRuleCategory</code>
</div>
}
else
{
<input type="checkbox" class="form-check-input create-rule-checkbox"
name="createRules" value="@proposal.TransactionId"
@(proposal.CreateRule ? "checked" : "")
title="Create a new mapping rule for this pattern">
<span class="small text-muted">Create</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
+8 -1
View File
@@ -2,6 +2,7 @@ using System.Globalization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Services; using MoneyMap.Services;
using MoneyMap.Services.AITools;
// Set default culture to en-US for currency formatting ($) // Set default culture to en-US for currency formatting ($)
var culture = new CultureInfo("en-US"); var culture = new CultureInfo("en-US");
@@ -61,11 +62,17 @@ builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>(); builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
builder.Services.AddScoped<IPdfToImageConverter, PdfToImageConverter>(); builder.Services.AddScoped<IPdfToImageConverter, PdfToImageConverter>();
// AI vision clients // Receipt parse queue and background worker
builder.Services.AddSingleton<IReceiptParseQueue, ReceiptParseQueue>();
builder.Services.AddHostedService<ReceiptParseWorkerService>();
// AI vision clients and tool-use support
builder.Services.AddHttpClient<OpenAIVisionClient>(); builder.Services.AddHttpClient<OpenAIVisionClient>();
builder.Services.AddHttpClient<ClaudeVisionClient>(); builder.Services.AddHttpClient<ClaudeVisionClient>();
builder.Services.AddHttpClient<OllamaVisionClient>(); builder.Services.AddHttpClient<OllamaVisionClient>();
builder.Services.AddHttpClient<LlamaCppVisionClient>(); builder.Services.AddHttpClient<LlamaCppVisionClient>();
builder.Services.AddScoped<IAIVisionClientResolver, AIVisionClientResolver>();
builder.Services.AddScoped<IAIToolExecutor, AIToolExecutor>();
builder.Services.AddScoped<IReceiptParser, AIReceiptParser>(); builder.Services.AddScoped<IReceiptParser, AIReceiptParser>();
// AI categorization service // AI categorization service
+44 -29
View File
@@ -1,47 +1,62 @@
Analyze this receipt image and extract the following information as JSON: Analyze this receipt image and extract structured data. Respond with a single JSON object matching this exact schema. Use JSON null (not the string "null") for missing values. Do not include comments in the JSON.
{ {
"merchant": "store name", "merchant": "store name",
"receiptDate": "YYYY-MM-DD" (or null if not found), "receiptDate": "YYYY-MM-DD",
"dueDate": "YYYY-MM-DD" (or null if not found - for bills only), "dueDate": null,
"subtotal": 0.00 (or null if not found), "subtotal": 0.00,
"tax": 0.00 (or null if not found), "tax": 0.00,
"total": 0.00, "total": 0.00,
"confidence": 0.95, "confidence": 0.95,
"suggestedCategory": null,
"suggestedTransactionId": null,
"lineItems": [ "lineItems": [
{ {
"description": "item name", "description": "item name",
"upc": "1234567890123" (or null if not found), "upc": null,
"quantity": 1.0, "quantity": 1.0,
"unitPrice": 0.00 (or null), "unitPrice": 0.00,
"lineTotal": 0.00, "lineTotal": 0.00,
"category": null,
"voided": false "voided": false
} }
] ]
} }
Extract all line items you can see on the receipt. For each item: FIELD TYPES (you must follow these exactly):
- description: The item or service name (include any count/size info in the description itself, like "4CT" or "12 OZ") - merchant: string
- upc: The UPC/barcode number if visible (usually a 12-13 digit number near the item). This helps track price changes over time. Set to null if not found. - receiptDate: string "YYYY-MM-DD" or null
- quantity: ALWAYS set to 1.0 for ALL retail products (groceries, goods, merchandise, etc.) - this is the default. ONLY use null for utility bills, service fees, or taxes (non-product items). If it's a physical item on a retail receipt, use 1.0. - dueDate: string "YYYY-MM-DD" or null (only for bills with a payment deadline)
- unitPrice: Calculate as lineTotal divided by quantity (so usually equals lineTotal for retail items). Set to null only if quantity is null. - subtotal: number or null
- lineTotal: The total amount for this line (the price shown on the receipt, or 0.00 if voided) - tax: number or null
- voided: Set to true if this item appears immediately after a "** VOIDED ENTRY **" marker or similar void indicator. Set to false for all other items. - total: number
- confidence: number between 0 and 1
- suggestedCategory: string or null
- suggestedTransactionId: integer or null (MUST be a JSON number like 123, NEVER a string like "123")
- lineItems: array of objects
CRITICAL - HANDLING VOIDED ITEMS: LINE ITEM FIELDS:
- NEVER skip or ignore ANY line items on the receipt - description: string (the item or service name, include count/size info like "4CT" or "12 OZ")
- When you see "** VOIDED ENTRY **" or similar void markers, the item immediately after it is voided - upc: string or null (UPC/barcode number if visible, usually 12-13 digits)
- quantity: number (default 1.0 for all retail products; null only for service fees or taxes)
- unitPrice: number or null (lineTotal divided by quantity; null only if quantity is null)
- lineTotal: number (the price shown on the receipt; 0.00 if voided)
- category: string or null
- voided: boolean
RULES FOR LINE ITEMS:
- Extract ALL line items from top to bottom - never stop early
- quantity is 1.0 for ALL physical retail items unless you see "2 @" or "QTY 3" etc.
- Do not confuse product descriptions (like "4CT BLUE MUF" = 4-count muffin package) with quantity
- UPC/barcode numbers are long numeric codes (12-13 digits) near the item
VOIDED ITEMS:
- When you see "** VOIDED ENTRY **" or similar, the item immediately after it is voided
- For voided items: set "voided": true and "lineTotal": 0.00 - For voided items: set "voided": true and "lineTotal": 0.00
- For all other items: set "voided": false - For all other items: set "voided": false
- CONTINUE reading and extracting ALL items that appear after void markers - do NOT stop parsing - NEVER skip voided items - include them in the lineItems array
- The receipt may have many items listed after a void marker - you MUST include every single one - CONTINUE reading ALL items after void markers
- Include EVERY line item you can see, whether voided or not
OTHER IMPORTANT RULES: DUE DATE:
- Quantity MUST be 1.0 for ALL physical retail items (groceries, food, household goods, etc.) - do NOT leave it null - Only for bills (utility, credit card, etc.) - extract the payment due date
- Every item on a grocery/retail receipt gets quantity: 1.0 unless you see explicit indicators like "2 @" or "QTY 3" - For regular store receipts, dueDate must be null
- Only utility bills, service charges, fees, or taxes (non-product line items) should have null quantity
- Don't confuse product descriptions (like "4CT BLUE MUF" meaning 4-count muffin package) with quantity fields (like "2 @ $3.99")
- Extract UPC/barcode numbers when visible - they're usually long numeric codes (12-13 digits)
- Read through the ENTIRE receipt from top to bottom - don't stop early
If this is a bill (utility, credit card, etc.), look for a due date, payment due date, or deadline and extract it as dueDate. For regular receipts, dueDate should be null.
+163 -6
View File
@@ -1,7 +1,9 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using MoneyMap.Data; using MoneyMap.Data;
using MoneyMap.Models; using MoneyMap.Models;
using MoneyMap.Services.AITools;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization;
namespace MoneyMap.Services namespace MoneyMap.Services
{ {
@@ -17,6 +19,7 @@ namespace MoneyMap.Services
private readonly IPdfToImageConverter _pdfConverter; private readonly IPdfToImageConverter _pdfConverter;
private readonly IAIVisionClientResolver _clientResolver; private readonly IAIVisionClientResolver _clientResolver;
private readonly IMerchantService _merchantService; private readonly IMerchantService _merchantService;
private readonly IAIToolExecutor _toolExecutor;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<AIReceiptParser> _logger; private readonly ILogger<AIReceiptParser> _logger;
@@ -28,6 +31,7 @@ namespace MoneyMap.Services
IPdfToImageConverter pdfConverter, IPdfToImageConverter pdfConverter,
IAIVisionClientResolver clientResolver, IAIVisionClientResolver clientResolver,
IMerchantService merchantService, IMerchantService merchantService,
IAIToolExecutor toolExecutor,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
IConfiguration configuration, IConfiguration configuration,
ILogger<AIReceiptParser> logger) ILogger<AIReceiptParser> logger)
@@ -37,6 +41,7 @@ namespace MoneyMap.Services
_pdfConverter = pdfConverter; _pdfConverter = pdfConverter;
_clientResolver = clientResolver; _clientResolver = clientResolver;
_merchantService = merchantService; _merchantService = merchantService;
_toolExecutor = toolExecutor;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
@@ -55,9 +60,16 @@ namespace MoneyMap.Services
if (!File.Exists(filePath)) if (!File.Exists(filePath))
return ReceiptParseResult.Failure("Receipt file not found on disk."); return ReceiptParseResult.Failure("Receipt file not found on disk.");
// Fall back to receipt.ParsingNotes if notes parameter is null
var effectiveNotes = notes ?? receipt.ParsingNotes;
var selectedModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; var selectedModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
var (client, provider) = _clientResolver.Resolve(selectedModel); var (client, provider) = _clientResolver.Resolve(selectedModel);
// Let model-aware clients evaluate tool support for the specific model
if (client is LlamaCppVisionClient llamaCpp)
llamaCpp.SetCurrentModel(selectedModel);
var parseLog = new ReceiptParseLog var parseLog = new ReceiptParseLog
{ {
ReceiptId = receiptId, ReceiptId = receiptId,
@@ -70,8 +82,8 @@ namespace MoneyMap.Services
try try
{ {
var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath); var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath);
var promptText = await BuildPromptAsync(receipt, notes); var promptText = await BuildPromptAsync(receipt, effectiveNotes, client);
var visionResult = await client.AnalyzeImageAsync(base64Data, mediaType, promptText, selectedModel); var visionResult = await CallVisionClientAsync(client, base64Data, mediaType, promptText, selectedModel);
if (!visionResult.IsSuccess) if (!visionResult.IsSuccess)
{ {
@@ -80,14 +92,14 @@ namespace MoneyMap.Services
} }
var parseData = ParseResponse(visionResult.Content); var parseData = ParseResponse(visionResult.Content);
await ApplyParseResultAsync(receipt, receiptId, parseData, notes); await ApplyParseResultAsync(receipt, receiptId, parseData, effectiveNotes);
parseLog.Success = true; parseLog.Success = true;
parseLog.Confidence = parseData.Confidence; parseLog.Confidence = parseData.Confidence;
parseLog.RawProviderPayloadJson = JsonSerializer.Serialize(parseData); parseLog.RawProviderPayloadJson = JsonSerializer.Serialize(parseData);
await SaveParseLogAsync(parseLog); await SaveParseLogAsync(parseLog);
await TryAutoMapReceiptAsync(receipt, receiptId); await TryAutoMapReceiptAsync(receipt, receiptId, parseData.SuggestedTransactionId);
var lineCount = parseData.LineItems.Count; var lineCount = parseData.LineItems.Count;
return ReceiptParseResult.Success($"Parsed {lineCount} line items from receipt."); return ReceiptParseResult.Success($"Parsed {lineCount} line items from receipt.");
@@ -100,6 +112,29 @@ namespace MoneyMap.Services
} }
} }
/// <summary>
/// Call the vision client, using tool-use if the client supports it, or enriched prompt fallback for Ollama.
/// </summary>
private async Task<VisionApiResult> CallVisionClientAsync(
IAIVisionClient client, string base64Data, string mediaType, string prompt, string model)
{
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
{
_logger.LogInformation("Using tool-aware vision client for model {Model}", model);
var tools = AIToolRegistry.GetAllTools();
return await toolAwareClient.AnalyzeImageWithToolsAsync(
base64Data, mediaType, prompt, model,
tools,
toolCall => _toolExecutor.ExecuteAsync(toolCall),
maxToolRounds: 5);
}
// Fallback: standard call (Ollama gets enriched prompt via BuildPromptAsync)
_logger.LogInformation("Using standard vision client for model {Model} (no tool use)", model);
return await client.AnalyzeImageAsync(base64Data, mediaType, prompt, model);
}
private async Task<(string Base64Data, string MediaType)> PrepareImageDataAsync(Receipt receipt, string filePath) private async Task<(string Base64Data, string MediaType)> PrepareImageDataAsync(Receipt receipt, string filePath)
{ {
if (receipt.ContentType == "application/pdf") if (receipt.ContentType == "application/pdf")
@@ -112,7 +147,7 @@ namespace MoneyMap.Services
return (Convert.ToBase64String(fileBytes), receipt.ContentType); return (Convert.ToBase64String(fileBytes), receipt.ContentType);
} }
private async Task<string> BuildPromptAsync(Receipt receipt, string? userNotes = null) private async Task<string> BuildPromptAsync(Receipt receipt, string? userNotes, IAIVisionClient client)
{ {
var promptText = await LoadPromptTemplateAsync(); var promptText = await LoadPromptTemplateAsync();
@@ -133,6 +168,43 @@ namespace MoneyMap.Services
promptText += $"\n\nUser notes for this receipt: {userNotes}"; promptText += $"\n\nUser notes for this receipt: {userNotes}";
} }
// Add tool-use or enriched context instructions based on client capability
if (client is IAIToolAwareVisionClient toolAwareClient && toolAwareClient.SupportsToolUse)
{
// Tool-aware client: instruct to use tools for lookups
promptText += @"
TOOL USE INSTRUCTIONS:
You have access to tools that can query the application's database. You MUST call them before generating your JSON response:
1. Call search_categories to find existing category names. Use ONLY categories returned by this tool for suggestedCategory and line item category fields. Do not invent new category names.
2. Call search_transactions to find a matching bank transaction for this receipt (search by date, amount, merchant name). Set suggestedTransactionId to the numeric ID of the best match, or null if no good match. Remember: suggestedTransactionId must be a JSON integer or null, never a string.
3. Call search_merchants to look up the correct merchant name.";
}
else
{
// Non-tool client (Ollama): inject pre-fetched database context
try
{
var merchantHint = receipt.Transaction?.Name ?? receipt.Merchant;
var enrichedContext = await _toolExecutor.GetEnrichedContextAsync(
receipt.ReceiptDate,
receipt.Total,
merchantHint);
promptText += $"\n\n{enrichedContext}";
promptText += @"
Using the database context above, populate these fields in your JSON response:
- suggestedCategory: Use the best matching category name from the EXISTING CATEGORIES list. Do not invent new categories.
- suggestedTransactionId: Use the numeric transaction ID from CANDIDATE TRANSACTIONS that best matches this receipt, or null if none match. Must be a JSON integer or null, never a string.
- For each line item, set category to the best matching category from the EXISTING CATEGORIES list.";
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get enriched context for Ollama, proceeding without it");
}
}
promptText += "\n\nRespond ONLY with valid JSON, no other text."; promptText += "\n\nRespond ONLY with valid JSON, no other text.";
return promptText; return promptText;
} }
@@ -168,6 +240,16 @@ namespace MoneyMap.Services
receipt.Transaction.MerchantId = merchantId; receipt.Transaction.MerchantId = merchantId;
} }
// Update transaction category if AI suggested one and the transaction has no category
if (receipt.Transaction != null &&
!string.IsNullOrWhiteSpace(parseData.SuggestedCategory) &&
string.IsNullOrWhiteSpace(receipt.Transaction.Category))
{
receipt.Transaction.Category = parseData.SuggestedCategory;
_logger.LogInformation("Set transaction {TransactionId} category to '{Category}' from AI suggestion",
receipt.Transaction.Id, parseData.SuggestedCategory);
}
// Replace line items // Replace line items
var existingItems = await _db.ReceiptLineItems var existingItems = await _db.ReceiptLineItems
.Where(li => li.ReceiptId == receiptId) .Where(li => li.ReceiptId == receiptId)
@@ -183,6 +265,7 @@ namespace MoneyMap.Services
Quantity = item.Quantity, Quantity = item.Quantity,
UnitPrice = item.UnitPrice, UnitPrice = item.UnitPrice,
LineTotal = item.LineTotal, LineTotal = item.LineTotal,
Category = item.Category,
Voided = item.Voided Voided = item.Voided
}).ToList(); }).ToList();
@@ -198,8 +281,41 @@ namespace MoneyMap.Services
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
private async Task TryAutoMapReceiptAsync(Receipt receipt, long receiptId) private async Task TryAutoMapReceiptAsync(Receipt receipt, long receiptId, long? suggestedTransactionId)
{ {
// If AI suggested a specific transaction, try mapping directly
if (!receipt.TransactionId.HasValue && suggestedTransactionId.HasValue)
{
try
{
var transaction = await _db.Transactions.FindAsync(suggestedTransactionId.Value);
if (transaction != null)
{
// Verify the transaction isn't already mapped to another receipt
var alreadyMapped = await _db.Receipts
.AnyAsync(r => r.TransactionId == suggestedTransactionId.Value && r.Id != receiptId);
if (!alreadyMapped)
{
var success = await _receiptManager.MapReceiptToTransactionAsync(receiptId, suggestedTransactionId.Value);
if (success)
{
_logger.LogInformation(
"AI-suggested mapping: receipt {ReceiptId} → transaction {TransactionId}",
receiptId, suggestedTransactionId.Value);
return;
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "AI-suggested mapping failed for receipt {ReceiptId} → transaction {TransactionId}",
receiptId, suggestedTransactionId.Value);
}
}
// Fall back to the existing auto-mapper
if (receipt.TransactionId.HasValue) if (receipt.TransactionId.HasValue)
return; return;
@@ -282,6 +398,9 @@ namespace MoneyMap.Services
public decimal? Tax { get; set; } public decimal? Tax { get; set; }
public decimal? Total { get; set; } public decimal? Total { get; set; }
public decimal Confidence { get; set; } = 0.5m; public decimal Confidence { get; set; } = 0.5m;
public string? SuggestedCategory { get; set; }
[JsonConverter(typeof(NullableLongConverter))]
public long? SuggestedTransactionId { get; set; }
public List<ParsedLineItem> LineItems { get; set; } = new(); public List<ParsedLineItem> LineItems { get; set; } = new();
} }
@@ -292,6 +411,7 @@ namespace MoneyMap.Services
public decimal? Quantity { get; set; } public decimal? Quantity { get; set; }
public decimal? UnitPrice { get; set; } public decimal? UnitPrice { get; set; }
public decimal LineTotal { get; set; } public decimal LineTotal { get; set; }
public string? Category { get; set; }
public bool Voided { get; set; } public bool Voided { get; set; }
} }
@@ -306,4 +426,41 @@ namespace MoneyMap.Services
public static ReceiptParseResult Failure(string message) => public static ReceiptParseResult Failure(string message) =>
new() { IsSuccess = false, Message = message }; new() { IsSuccess = false, Message = message };
} }
/// <summary>
/// Handles AI responses that return suggestedTransactionId as a string ("null", "N/A", "123")
/// instead of as a JSON number or null.
/// </summary>
public class NullableLongConverter : JsonConverter<long?>
{
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
switch (reader.TokenType)
{
case JsonTokenType.Number:
return reader.GetInt64();
case JsonTokenType.String:
var str = reader.GetString();
if (string.IsNullOrWhiteSpace(str) ||
str.Equals("null", StringComparison.OrdinalIgnoreCase) ||
str.Equals("N/A", StringComparison.OrdinalIgnoreCase) ||
str.Equals("none", StringComparison.OrdinalIgnoreCase))
return null;
return long.TryParse(str, out var val) ? val : null;
case JsonTokenType.Null:
return null;
default:
reader.Skip();
return null;
}
}
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteNumberValue(value.Value);
else
writer.WriteNullValue();
}
}
} }
@@ -0,0 +1,159 @@
using System.Text.Json.Serialization;
namespace MoneyMap.Services.AITools
{
/// <summary>
/// Provider-agnostic tool definition for AI function calling.
/// </summary>
public class AIToolDefinition
{
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public List<AIToolParameter> Parameters { get; set; } = new();
}
public class AIToolParameter
{
public string Name { get; set; } = "";
public string Type { get; set; } = "string"; // string, number, integer
public string Description { get; set; } = "";
public bool Required { get; set; }
}
/// <summary>
/// Represents a tool call from the AI model.
/// </summary>
public class AIToolCall
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public Dictionary<string, object?> Arguments { get; set; } = new();
public string? GetString(string key)
{
if (Arguments.TryGetValue(key, out var val) && val != null)
return val.ToString();
return null;
}
public decimal? GetDecimal(string key)
{
if (Arguments.TryGetValue(key, out var val) && val != null)
{
if (decimal.TryParse(val.ToString(), out var d))
return d;
}
return null;
}
public int? GetInt(string key)
{
if (Arguments.TryGetValue(key, out var val) && val != null)
{
if (int.TryParse(val.ToString(), out var i))
return i;
}
return null;
}
}
/// <summary>
/// Result of executing a tool, returned to the AI.
/// </summary>
public class AIToolResult
{
public string ToolCallId { get; set; } = "";
public string Content { get; set; } = "";
public bool IsError { get; set; }
}
/// <summary>
/// Static registry of all tools available to the receipt parsing AI.
/// </summary>
public static class AIToolRegistry
{
public static List<AIToolDefinition> GetAllTools() => new()
{
new AIToolDefinition
{
Name = "search_categories",
Description = "Search existing expense categories in the system. Returns category names with their matching patterns and associated merchants. Use this to find the correct category name for line items and the overall receipt instead of inventing new ones.",
Parameters = new()
{
new AIToolParameter
{
Name = "query",
Type = "string",
Description = "Optional filter text to search category names (e.g., 'grocery', 'utility'). Omit to get all categories.",
Required = false
}
}
},
new AIToolDefinition
{
Name = "search_transactions",
Description = "Search bank transactions to find one that matches this receipt. Returns transaction ID, date, amount, name, merchant, and category. Use this to suggest which transaction this receipt belongs to.",
Parameters = new()
{
new AIToolParameter
{
Name = "merchant",
Type = "string",
Description = "Merchant or store name to search for (partial match)",
Required = false
},
new AIToolParameter
{
Name = "minDate",
Type = "string",
Description = "Earliest transaction date (YYYY-MM-DD format)",
Required = false
},
new AIToolParameter
{
Name = "maxDate",
Type = "string",
Description = "Latest transaction date (YYYY-MM-DD format)",
Required = false
},
new AIToolParameter
{
Name = "minAmount",
Type = "number",
Description = "Minimum absolute transaction amount",
Required = false
},
new AIToolParameter
{
Name = "maxAmount",
Type = "number",
Description = "Maximum absolute transaction amount",
Required = false
},
new AIToolParameter
{
Name = "limit",
Type = "integer",
Description = "Maximum results to return (default 10, max 20)",
Required = false
}
}
},
new AIToolDefinition
{
Name = "search_merchants",
Description = "Search known merchants by name. Returns merchant name, transaction count, and most common category. Use this to find the correct merchant name and see what category is typically used for them.",
Parameters = new()
{
new AIToolParameter
{
Name = "query",
Type = "string",
Description = "Merchant name to search for (partial match)",
Required = true
}
}
}
};
}
}
+280
View File
@@ -0,0 +1,280 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using System.Text;
using System.Text.Json;
namespace MoneyMap.Services.AITools
{
public interface IAIToolExecutor
{
/// <summary>
/// Execute a single tool call and return the result as JSON.
/// </summary>
Task<AIToolResult> ExecuteAsync(AIToolCall toolCall);
/// <summary>
/// Pre-fetch all relevant context as a text block for providers that don't support tool use (Ollama).
/// </summary>
Task<string> GetEnrichedContextAsync(DateTime? receiptDate = null, decimal? total = null, string? merchantHint = null);
}
public class AIToolExecutor : IAIToolExecutor
{
private readonly MoneyMapContext _db;
private readonly ILogger<AIToolExecutor> _logger;
private const int MaxResults = 20;
public AIToolExecutor(MoneyMapContext db, ILogger<AIToolExecutor> logger)
{
_db = db;
_logger = logger;
}
public async Task<AIToolResult> ExecuteAsync(AIToolCall toolCall)
{
_logger.LogInformation("Executing AI tool: {ToolName} with args: {Args}",
toolCall.Name, JsonSerializer.Serialize(toolCall.Arguments));
try
{
var result = toolCall.Name switch
{
"search_categories" => await SearchCategoriesAsync(toolCall),
"search_transactions" => await SearchTransactionsAsync(toolCall),
"search_merchants" => await SearchMerchantsAsync(toolCall),
_ => $"{{\"error\": \"Unknown tool: {toolCall.Name}\"}}"
};
_logger.LogInformation("Tool {ToolName} returned {Length} chars", toolCall.Name, result.Length);
return new AIToolResult
{
ToolCallId = toolCall.Id,
Content = result
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error executing tool {ToolName}", toolCall.Name);
return new AIToolResult
{
ToolCallId = toolCall.Id,
Content = JsonSerializer.Serialize(new { error = ex.Message }),
IsError = true
};
}
}
public async Task<string> GetEnrichedContextAsync(DateTime? receiptDate, decimal? total, string? merchantHint)
{
var sb = new StringBuilder();
sb.AppendLine("=== DATABASE CONTEXT (use this to match categories and transactions) ===");
sb.AppendLine();
// Categories
var categories = await _db.CategoryMappings
.Include(cm => cm.Merchant)
.OrderBy(cm => cm.Category)
.ToListAsync();
var grouped = categories.GroupBy(c => c.Category).ToList();
sb.AppendLine($"EXISTING CATEGORIES ({grouped.Count} total):");
foreach (var group in grouped)
{
var patterns = group.Select(c => c.Pattern).Take(5);
var merchants = group.Where(c => c.Merchant != null).Select(c => c.Merchant!.Name).Distinct().Take(3);
sb.Append($" - {group.Key}: patterns=[{string.Join(", ", patterns)}]");
if (merchants.Any())
sb.Append($", merchants=[{string.Join(", ", merchants)}]");
sb.AppendLine();
}
sb.AppendLine();
// Merchants matching hint
if (!string.IsNullOrWhiteSpace(merchantHint))
{
var matchingMerchants = await _db.Merchants
.Where(m => m.Name.Contains(merchantHint))
.Select(m => new
{
m.Name,
TransactionCount = m.Transactions.Count,
TopCategory = m.Transactions
.Where(t => t.Category != "")
.GroupBy(t => t.Category)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault()
})
.Take(10)
.ToListAsync();
if (matchingMerchants.Count > 0)
{
sb.AppendLine($"MATCHING MERCHANTS for \"{merchantHint}\":");
foreach (var m in matchingMerchants)
sb.AppendLine($" - {m.Name} ({m.TransactionCount} transactions, typical category: {m.TopCategory ?? "none"})");
sb.AppendLine();
}
}
// Matching transactions
if (receiptDate.HasValue || total.HasValue)
{
var txQuery = _db.Transactions
.Include(t => t.Merchant)
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
.AsQueryable();
if (receiptDate.HasValue)
{
var minDate = receiptDate.Value.AddDays(-1);
var maxDate = receiptDate.Value.AddDays(7);
txQuery = txQuery.Where(t => t.Date >= minDate && t.Date <= maxDate);
}
if (total.HasValue)
{
var absTotal = Math.Abs(total.Value);
var minAmt = absTotal * 0.9m;
var maxAmt = absTotal * 1.1m;
txQuery = txQuery.Where(t =>
(t.Amount >= -maxAmt && t.Amount <= -minAmt) ||
(t.Amount >= minAmt && t.Amount <= maxAmt));
}
var transactions = await txQuery
.OrderBy(t => t.Date)
.Take(10)
.ToListAsync();
if (transactions.Count > 0)
{
sb.AppendLine("CANDIDATE TRANSACTIONS (unmapped, matching date/amount):");
foreach (var t in transactions)
{
sb.AppendLine($" - ID={t.Id}, Date={t.Date:yyyy-MM-dd}, Amount={t.Amount:C}, Name=\"{t.Name}\", " +
$"Merchant={t.Merchant?.Name ?? "none"}, Category={t.Category}");
}
sb.AppendLine();
}
}
sb.AppendLine("=== END DATABASE CONTEXT ===");
return sb.ToString();
}
private async Task<string> SearchCategoriesAsync(AIToolCall toolCall)
{
var query = toolCall.GetString("query");
var mappings = _db.CategoryMappings
.Include(cm => cm.Merchant)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(query))
mappings = mappings.Where(cm => cm.Category.Contains(query));
var results = await mappings
.OrderBy(cm => cm.Category)
.ToListAsync();
var grouped = results
.GroupBy(c => c.Category)
.Take(MaxResults)
.Select(g => new
{
category = g.Key,
patterns = g.Select(c => c.Pattern).Take(5).ToList(),
merchants = g.Where(c => c.Merchant != null)
.Select(c => c.Merchant!.Name)
.Distinct()
.Take(5)
.ToList()
})
.ToList();
return JsonSerializer.Serialize(new { categories = grouped });
}
private async Task<string> SearchTransactionsAsync(AIToolCall toolCall)
{
var merchant = toolCall.GetString("merchant");
var minDateStr = toolCall.GetString("minDate");
var maxDateStr = toolCall.GetString("maxDate");
var minAmount = toolCall.GetDecimal("minAmount");
var maxAmount = toolCall.GetDecimal("maxAmount");
var limit = toolCall.GetInt("limit") ?? 10;
limit = Math.Min(limit, MaxResults);
var txQuery = _db.Transactions
.Include(t => t.Merchant)
.Where(t => !_db.Receipts.Any(r => r.TransactionId == t.Id))
.AsQueryable();
if (!string.IsNullOrWhiteSpace(merchant))
{
txQuery = txQuery.Where(t =>
t.Name.Contains(merchant) ||
(t.Merchant != null && t.Merchant.Name.Contains(merchant)));
}
if (DateTime.TryParse(minDateStr, out var minDate))
txQuery = txQuery.Where(t => t.Date >= minDate);
if (DateTime.TryParse(maxDateStr, out var maxDate))
txQuery = txQuery.Where(t => t.Date <= maxDate);
if (minAmount.HasValue)
{
var min = minAmount.Value;
txQuery = txQuery.Where(t => t.Amount <= -min || t.Amount >= min);
}
if (maxAmount.HasValue)
{
var max = maxAmount.Value;
txQuery = txQuery.Where(t => t.Amount >= -max && t.Amount <= max);
}
var transactions = await txQuery
.OrderByDescending(t => t.Date)
.Take(limit)
.Select(t => new
{
id = t.Id,
date = t.Date.ToString("yyyy-MM-dd"),
amount = t.Amount,
name = t.Name,
merchant = t.Merchant != null ? t.Merchant.Name : null,
category = t.Category
})
.ToListAsync();
return JsonSerializer.Serialize(new { transactions });
}
private async Task<string> SearchMerchantsAsync(AIToolCall toolCall)
{
var query = toolCall.GetString("query") ?? "";
var merchants = await _db.Merchants
.Where(m => m.Name.Contains(query))
.Select(m => new
{
name = m.Name,
transactionCount = m.Transactions.Count,
topCategory = m.Transactions
.Where(t => t.Category != "")
.GroupBy(t => t.Category)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault()
})
.Take(MaxResults)
.ToListAsync();
return JsonSerializer.Serialize(new { merchants });
}
}
}
+538 -56
View File
@@ -1,3 +1,4 @@
using MoneyMap.Services.AITools;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@@ -29,9 +30,226 @@ namespace MoneyMap.Services
} }
/// <summary> /// <summary>
/// OpenAI Vision API client. /// Extended interface for vision clients that support tool use / function calling.
/// </summary> /// </summary>
public class OpenAIVisionClient : IAIVisionClient public interface IAIToolAwareVisionClient : IAIVisionClient
{
bool SupportsToolUse { get; }
Task<VisionApiResult> AnalyzeImageWithToolsAsync(
string base64Image,
string mediaType,
string prompt,
string model,
List<AIToolDefinition> tools,
Func<AIToolCall, Task<AIToolResult>> toolExecutor,
int maxToolRounds = 5);
}
/// <summary>
/// Shared helper for the OpenAI-compatible tool-use wire format.
/// Used by both OpenAIVisionClient and LlamaCppVisionClient since they share /v1/chat/completions.
/// </summary>
public static class OpenAIToolUseHelper
{
/// <summary>
/// Convert AIToolDefinitions to the OpenAI tools array format.
/// </summary>
public static List<object> BuildToolsArray(List<AIToolDefinition> tools)
{
return tools.Select(t => (object)new
{
type = "function",
function = new
{
name = t.Name,
description = t.Description,
parameters = new
{
type = "object",
properties = t.Parameters.ToDictionary(
p => p.Name,
p => (object)new { type = p.Type, description = p.Description }
),
required = t.Parameters.Where(p => p.Required).Select(p => p.Name).ToArray()
}
}
}).ToList();
}
/// <summary>
/// Execute the tool-use loop for OpenAI-compatible /v1/chat/completions endpoints.
/// </summary>
public static async Task<VisionApiResult> ExecuteWithToolsAsync(
HttpClient httpClient,
string apiUrl,
Action<HttpClient> configureHeaders,
string model,
List<object> initialMessages,
List<object> toolsArray,
Func<AIToolCall, Task<AIToolResult>> toolExecutor,
int maxToolRounds,
int maxTokens,
ILogger logger)
{
// Build mutable message list
var messages = new List<object>(initialMessages);
for (int round = 0; round <= maxToolRounds; round++)
{
var requestBody = new Dictionary<string, object>
{
["model"] = model,
["messages"] = messages,
["max_tokens"] = maxTokens,
["temperature"] = 0.1
};
// Only include tools if we haven't exhausted rounds
if (round < maxToolRounds && toolsArray.Count > 0)
{
requestBody["tools"] = toolsArray;
requestBody["tool_choice"] = "auto";
}
configureHeaders(httpClient);
var json = JsonSerializer.Serialize(requestBody);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await httpClient.PostAsync(apiUrl, content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
logger.LogError("API error ({StatusCode}): {Error}", response.StatusCode, errorContent);
return VisionApiResult.Failure($"API error ({response.StatusCode}): {errorContent}");
}
var responseJson = await response.Content.ReadAsStringAsync();
var responseObj = JsonSerializer.Deserialize<JsonElement>(responseJson);
var choice = responseObj.GetProperty("choices")[0];
var message = choice.GetProperty("message");
var finishReason = choice.GetProperty("finish_reason").GetString();
// Check for tool calls
var hasToolCalls = message.TryGetProperty("tool_calls", out var toolCallsElement) &&
toolCallsElement.ValueKind == JsonValueKind.Array &&
toolCallsElement.GetArrayLength() > 0;
if (hasToolCalls || finishReason == "tool_calls")
{
if (!hasToolCalls)
{
// finish_reason says tool_calls but no tool_calls array - treat as final response
var fallbackContent = message.TryGetProperty("content", out var fc) ? fc.GetString() : null;
return VisionApiResult.Success(CleanJsonResponse(fallbackContent));
}
logger.LogInformation("Tool-use round {Round}: model requested {Count} tool calls",
round + 1, toolCallsElement.GetArrayLength());
// Add the assistant message (with tool_calls) to conversation
messages.Add(JsonSerializer.Deserialize<object>(message.GetRawText())!);
// Execute each tool call and add results
foreach (var tc in toolCallsElement.EnumerateArray())
{
var toolCall = new AIToolCall
{
Id = tc.GetProperty("id").GetString() ?? "",
Name = tc.GetProperty("function").GetProperty("name").GetString() ?? "",
Arguments = ParseArguments(tc.GetProperty("function").GetProperty("arguments").GetString())
};
logger.LogInformation("Executing tool: {ToolName}", toolCall.Name);
var result = await toolExecutor(toolCall);
messages.Add(new
{
role = "tool",
tool_call_id = toolCall.Id,
content = result.Content
});
}
continue; // Send another request with tool results
}
// No tool calls - extract final content
var messageContent = message.TryGetProperty("content", out var contentElement)
? contentElement.GetString()
: null;
return VisionApiResult.Success(CleanJsonResponse(messageContent));
}
return VisionApiResult.Failure("Exceeded maximum tool-use rounds without getting a final response.");
}
private static Dictionary<string, object?> ParseArguments(string? argsJson)
{
if (string.IsNullOrWhiteSpace(argsJson))
return new();
try
{
var element = JsonSerializer.Deserialize<JsonElement>(argsJson);
var dict = new Dictionary<string, object?>();
foreach (var prop in element.EnumerateObject())
{
dict[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.Number => prop.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => null,
_ => prop.Value.GetRawText()
};
}
return dict;
}
catch
{
return new();
}
}
public static string CleanJsonResponse(string? content)
{
var trimmed = content?.Trim() ?? "";
// Strip markdown code fences
if (trimmed.StartsWith("```json"))
{
trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim();
}
else if (trimmed.StartsWith("```"))
{
trimmed = trimmed.Replace("```", "").Trim();
}
// If the response doesn't start with '{', try to extract the JSON object.
// This handles HTML error pages, XML-wrapped responses, or other non-JSON wrapping.
if (!trimmed.StartsWith("{"))
{
var firstBrace = trimmed.IndexOf('{');
var lastBrace = trimmed.LastIndexOf('}');
if (firstBrace >= 0 && lastBrace > firstBrace)
{
trimmed = trimmed[firstBrace..(lastBrace + 1)];
}
}
return trimmed;
}
}
/// <summary>
/// OpenAI Vision API client with tool-use support.
/// </summary>
public class OpenAIVisionClient : IAIToolAwareVisionClient
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
@@ -44,12 +262,12 @@ namespace MoneyMap.Services
_logger = logger; _logger = logger;
} }
public bool SupportsToolUse => true;
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
{ {
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") var apiKey = GetApiKey();
?? _configuration["OpenAI:ApiKey"]; if (apiKey == null)
if (string.IsNullOrWhiteSpace(apiKey))
return VisionApiResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json"); return VisionApiResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json");
var requestBody = new var requestBody = new
@@ -101,7 +319,7 @@ namespace MoneyMap.Services
.GetProperty("content") .GetProperty("content")
.GetString(); .GetString();
return VisionApiResult.Success(CleanJsonResponse(messageContent)); return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -110,21 +328,66 @@ namespace MoneyMap.Services
} }
} }
private static string CleanJsonResponse(string? content) public async Task<VisionApiResult> AnalyzeImageWithToolsAsync(
string base64Image, string mediaType, string prompt, string model,
List<AIToolDefinition> tools, Func<AIToolCall, Task<AIToolResult>> toolExecutor,
int maxToolRounds = 5)
{ {
var trimmed = content?.Trim() ?? ""; var apiKey = GetApiKey();
if (trimmed.StartsWith("```json")) if (apiKey == null)
return VisionApiResult.Failure("OpenAI API key not configured.");
var initialMessages = new List<object>
{ {
trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); new
{
role = "user",
content = new object[]
{
new { type = "text", text = prompt },
new
{
type = "image_url",
image_url = new { url = $"data:{mediaType};base64,{base64Image}" }
} }
return trimmed; }
}
};
try
{
return await OpenAIToolUseHelper.ExecuteWithToolsAsync(
_httpClient,
"https://api.openai.com/v1/chat/completions",
client =>
{
client.DefaultRequestHeaders.Clear();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
},
model,
initialMessages,
OpenAIToolUseHelper.BuildToolsArray(tools),
toolExecutor,
maxToolRounds,
maxTokens: 4096,
_logger);
}
catch (Exception ex)
{
_logger.LogError(ex, "OpenAI tool-use call failed: {Message}", ex.Message);
return VisionApiResult.Failure($"OpenAI API error: {ex.Message}");
} }
} }
private string? GetApiKey() =>
Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? _configuration["OpenAI:ApiKey"];
}
/// <summary> /// <summary>
/// Anthropic Claude Vision API client. /// Anthropic Claude Vision API client with tool-use support.
/// </summary> /// </summary>
public class ClaudeVisionClient : IAIVisionClient public class ClaudeVisionClient : IAIToolAwareVisionClient
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
@@ -137,12 +400,12 @@ namespace MoneyMap.Services
_logger = logger; _logger = logger;
} }
public bool SupportsToolUse => true;
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
{ {
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") var apiKey = GetApiKey();
?? _configuration["Anthropic:ApiKey"]; if (apiKey == null)
if (string.IsNullOrWhiteSpace(apiKey))
return VisionApiResult.Failure("Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable or Anthropic:ApiKey in appsettings.json"); return VisionApiResult.Failure("Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable or Anthropic:ApiKey in appsettings.json");
var requestBody = new var requestBody = new
@@ -174,10 +437,7 @@ namespace MoneyMap.Services
try try
{ {
_httpClient.DefaultRequestHeaders.Clear(); ConfigureHeaders();
_httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey);
_httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
var json = JsonSerializer.Serialize(requestBody); var json = JsonSerializer.Serialize(requestBody);
var content = new StringContent(json, Encoding.UTF8, "application/json"); var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -198,7 +458,7 @@ namespace MoneyMap.Services
.GetProperty("text") .GetProperty("text")
.GetString(); .GetString();
return VisionApiResult.Success(CleanJsonResponse(messageContent)); return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -207,34 +467,225 @@ namespace MoneyMap.Services
} }
} }
private static string CleanJsonResponse(string? content) public async Task<VisionApiResult> AnalyzeImageWithToolsAsync(
string base64Image, string mediaType, string prompt, string model,
List<AIToolDefinition> tools, Func<AIToolCall, Task<AIToolResult>> toolExecutor,
int maxToolRounds = 5)
{ {
var trimmed = content?.Trim() ?? ""; var apiKey = GetApiKey();
if (trimmed.StartsWith("```json")) if (apiKey == null)
return VisionApiResult.Failure("Anthropic API key not configured.");
// Build Anthropic-format tools array
var anthropicTools = tools.Select(t => new
{ {
trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); name = t.Name,
description = t.Description,
input_schema = new
{
type = "object",
properties = t.Parameters.ToDictionary(
p => p.Name,
p => (object)new { type = p.Type, description = p.Description }
),
required = t.Parameters.Where(p => p.Required).Select(p => p.Name).ToArray()
} }
return trimmed; }).ToList();
// Initial message with image
var messages = new List<object>
{
new
{
role = "user",
content = new object[]
{
new
{
type = "image",
source = new
{
type = "base64",
media_type = mediaType,
data = base64Image
}
},
new { type = "text", text = prompt }
}
}
};
try
{
for (int round = 0; round <= maxToolRounds; round++)
{
var requestBody = new Dictionary<string, object>
{
["model"] = model,
["max_tokens"] = 4096,
["messages"] = messages
};
if (round < maxToolRounds && anthropicTools.Count > 0)
requestBody["tools"] = anthropicTools;
ConfigureHeaders();
var json = JsonSerializer.Serialize(requestBody);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogError("Anthropic API error ({StatusCode}): {Error}", response.StatusCode, errorContent);
return VisionApiResult.Failure($"Anthropic API error ({response.StatusCode}): {errorContent}");
}
var responseJson = await response.Content.ReadAsStringAsync();
var responseObj = JsonSerializer.Deserialize<JsonElement>(responseJson);
var stopReason = responseObj.GetProperty("stop_reason").GetString();
var contentBlocks = responseObj.GetProperty("content");
// Check for tool_use blocks
var toolUseBlocks = contentBlocks.EnumerateArray()
.Where(b => b.GetProperty("type").GetString() == "tool_use")
.ToList();
if (stopReason == "tool_use" && toolUseBlocks.Count > 0)
{
_logger.LogInformation("Claude tool-use round {Round}: {Count} tool calls",
round + 1, toolUseBlocks.Count);
// Add assistant response to messages (contains tool_use blocks)
var assistantContent = JsonSerializer.Deserialize<object>(contentBlocks.GetRawText())!;
messages.Add(new { role = "assistant", content = assistantContent });
// Build tool_result blocks
var toolResults = new List<object>();
foreach (var block in toolUseBlocks)
{
var toolCall = new AIToolCall
{
Id = block.GetProperty("id").GetString() ?? "",
Name = block.GetProperty("name").GetString() ?? "",
Arguments = ParseAnthropicInput(block.GetProperty("input"))
};
_logger.LogInformation("Executing tool: {ToolName}", toolCall.Name);
var result = await toolExecutor(toolCall);
toolResults.Add(new
{
type = "tool_result",
tool_use_id = toolCall.Id,
content = result.Content,
is_error = result.IsError
});
}
messages.Add(new { role = "user", content = toolResults });
continue;
}
// Extract final text content
var textBlock = contentBlocks.EnumerateArray()
.FirstOrDefault(b => b.GetProperty("type").GetString() == "text");
var text = textBlock.ValueKind != JsonValueKind.Undefined
? textBlock.GetProperty("text").GetString()
: null;
return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(text));
}
return VisionApiResult.Failure("Exceeded maximum tool-use rounds.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Claude tool-use call failed: {Message}", ex.Message);
return VisionApiResult.Failure($"Anthropic API error: {ex.Message}");
} }
} }
private static Dictionary<string, object?> ParseAnthropicInput(JsonElement input)
{
var dict = new Dictionary<string, object?>();
if (input.ValueKind == JsonValueKind.Object)
{
foreach (var prop in input.EnumerateObject())
{
dict[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.String => prop.Value.GetString(),
JsonValueKind.Number => prop.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Null => null,
_ => prop.Value.GetRawText()
};
}
}
return dict;
}
private void ConfigureHeaders()
{
var apiKey = GetApiKey()!;
_httpClient.DefaultRequestHeaders.Clear();
_httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey);
_httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
}
private string? GetApiKey() =>
Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
?? _configuration["Anthropic:ApiKey"];
}
/// <summary> /// <summary>
/// llama.cpp server client using OpenAI-compatible vision API for local LLM inference. /// llama.cpp server client using OpenAI-compatible vision API with tool-use support.
/// </summary> /// </summary>
public class LlamaCppVisionClient : IAIVisionClient public class LlamaCppVisionClient : IAIToolAwareVisionClient
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ILogger<LlamaCppVisionClient> _logger; private readonly ILogger<LlamaCppVisionClient> _logger;
// Model families whose Jinja chat templates support the OpenAI tool role format.
private static readonly string[] _toolCapableModelPrefixes = new[]
{
"qwen3", "qwen2.5", "hermes", "functionary", "mistral"
};
private string? _currentModel;
public LlamaCppVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger<LlamaCppVisionClient> logger) public LlamaCppVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger<LlamaCppVisionClient> logger)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_httpClient.Timeout = TimeSpan.FromMinutes(5); // Local models can be slow _httpClient.Timeout = TimeSpan.FromMinutes(5);
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
} }
/// <summary>
/// Whether the current model supports OpenAI-style tool/function calling.
/// Only certain model families (Qwen3, Hermes, etc.) have chat templates that handle the tool role.
/// </summary>
public bool SupportsToolUse =>
_currentModel != null &&
_toolCapableModelPrefixes.Any(p =>
_currentModel.StartsWith(p, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// Set the model name so SupportsToolUse can be evaluated per-model.
/// Called by AIReceiptParser before the tool-use check.
/// </summary>
public void SetCurrentModel(string model)
{
_currentModel = model.StartsWith("llamacpp:") ? model[9..] : model;
}
/// <summary> /// <summary>
/// Get available models from the llama.cpp server. /// Get available models from the llama.cpp server.
/// </summary> /// </summary>
@@ -256,7 +707,7 @@ namespace MoneyMap.Services
var modelsResponse = JsonSerializer.Deserialize<LlamaCppModelsResponse>(json); var modelsResponse = JsonSerializer.Deserialize<LlamaCppModelsResponse>(json);
return modelsResponse?.Data? return modelsResponse?.Data?
.Where(m => !m.Id.StartsWith("mmproj-")) // Filter out multimodal projectors .Where(m => !m.Id.StartsWith("mmproj-"))
.Select(m => new LlamaCppModel .Select(m => new LlamaCppModel
{ {
Id = m.Id, Id = m.Id,
@@ -279,7 +730,7 @@ namespace MoneyMap.Services
public async Task<VisionApiResult> SendTextPromptAsync(string prompt, string? model = null) public async Task<VisionApiResult> SendTextPromptAsync(string prompt, string? model = null)
{ {
var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434";
var llamaModel = model ?? "GLM-4.6V-UD-Q4_K_XL-00001-of-00002"; var llamaModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "Qwen3-8B-Q6_K";
if (llamaModel.StartsWith("llamacpp:")) if (llamaModel.StartsWith("llamacpp:"))
llamaModel = llamaModel[9..]; llamaModel = llamaModel[9..];
@@ -324,7 +775,7 @@ namespace MoneyMap.Services
.GetString(); .GetString();
_logger.LogInformation("LlamaCpp: Text prompt completed successfully"); _logger.LogInformation("LlamaCpp: Text prompt completed successfully");
return VisionApiResult.Success(CleanJsonResponse(messageContent)); return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent));
} }
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{ {
@@ -341,8 +792,6 @@ namespace MoneyMap.Services
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
{ {
var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434";
// Strip "llamacpp:" prefix if present
var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model; var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model;
_logger.LogInformation("LlamaCpp: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes", _logger.LogInformation("LlamaCpp: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes",
@@ -397,7 +846,7 @@ namespace MoneyMap.Services
.GetString(); .GetString();
_logger.LogInformation("LlamaCpp: Successfully parsed response"); _logger.LogInformation("LlamaCpp: Successfully parsed response");
return VisionApiResult.Success(CleanJsonResponse(messageContent)); return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent));
} }
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{ {
@@ -411,19 +860,64 @@ namespace MoneyMap.Services
} }
} }
private static string CleanJsonResponse(string? content) public async Task<VisionApiResult> AnalyzeImageWithToolsAsync(
string base64Image, string mediaType, string prompt, string model,
List<AIToolDefinition> tools, Func<AIToolCall, Task<AIToolResult>> toolExecutor,
int maxToolRounds = 5)
{ {
var trimmed = content?.Trim() ?? ""; var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434";
if (trimmed.StartsWith("```json")) var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model;
_logger.LogInformation("LlamaCpp: Starting tool-use request with model {Model}", llamaModel);
var initialMessages = new List<object>
{ {
trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); new
{
role = "user",
content = new object[]
{
new
{
type = "image_url",
image_url = new { url = $"data:{mediaType};base64,{base64Image}" }
},
new { type = "text", text = prompt }
}
}
};
try
{
return await OpenAIToolUseHelper.ExecuteWithToolsAsync(
_httpClient,
$"{baseUrl.TrimEnd('/')}/v1/chat/completions",
_ => { }, // No auth headers needed for local llama.cpp
llamaModel,
initialMessages,
OpenAIToolUseHelper.BuildToolsArray(tools),
toolExecutor,
maxToolRounds,
maxTokens: 4096,
_logger);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
_logger.LogError("llama.cpp tool-use request timed out");
return VisionApiResult.Failure("llama.cpp request timed out.");
}
catch (Exception ex)
{
_logger.LogError(ex, "llama.cpp tool-use call failed: {Message}", ex.Message);
return VisionApiResult.Failure($"llama.cpp API error: {ex.Message}");
} }
return trimmed;
} }
} }
/// <summary> /// <summary>
/// Ollama Vision API client for local LLM inference. /// Ollama Vision API client for local LLM inference.
/// Does NOT support tool use (uses /api/generate endpoint).
/// Falls back to enriched prompt with pre-fetched context.
/// </summary> /// </summary>
public class OllamaVisionClient : IAIVisionClient public class OllamaVisionClient : IAIVisionClient
{ {
@@ -434,7 +928,7 @@ namespace MoneyMap.Services
public OllamaVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger<OllamaVisionClient> logger) public OllamaVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger<OllamaVisionClient> logger)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_httpClient.Timeout = TimeSpan.FromMinutes(5); // Local models can be slow _httpClient.Timeout = TimeSpan.FromMinutes(5);
_configuration = configuration; _configuration = configuration;
_logger = logger; _logger = logger;
} }
@@ -442,8 +936,6 @@ namespace MoneyMap.Services
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
{ {
var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434";
// Strip "ollama:" prefix if present
var ollamaModel = model.StartsWith("ollama:") ? model[7..] : model; var ollamaModel = model.StartsWith("ollama:") ? model[7..] : model;
_logger.LogInformation("Ollama: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes", _logger.LogInformation("Ollama: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes",
@@ -483,7 +975,7 @@ namespace MoneyMap.Services
var messageContent = responseObj.GetProperty("response").GetString(); var messageContent = responseObj.GetProperty("response").GetString();
_logger.LogInformation("Ollama: Successfully parsed response"); _logger.LogInformation("Ollama: Successfully parsed response");
return VisionApiResult.Success(CleanJsonResponse(messageContent)); return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent));
} }
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{ {
@@ -496,16 +988,6 @@ namespace MoneyMap.Services
return VisionApiResult.Failure($"Ollama API error: {ex.Message}"); return VisionApiResult.Failure($"Ollama API error: {ex.Message}");
} }
} }
private static string CleanJsonResponse(string? content)
{
var trimmed = content?.Trim() ?? "";
if (trimmed.StartsWith("```json"))
{
trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim();
}
return trimmed;
}
} }
// Models for llama.cpp /v1/models endpoint // Models for llama.cpp /v1/models endpoint
+61 -15
View File
@@ -10,6 +10,7 @@ namespace MoneyMap.Services
{ {
Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file); Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file);
Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file); Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file);
Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files);
Task<bool> DeleteReceiptAsync(long receiptId); Task<bool> DeleteReceiptAsync(long receiptId);
Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId); Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId);
Task<bool> UnmapReceiptAsync(long receiptId); Task<bool> UnmapReceiptAsync(long receiptId);
@@ -23,6 +24,7 @@ namespace MoneyMap.Services
private readonly IWebHostEnvironment _environment; private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly IReceiptParseQueue _parseQueue;
private readonly ILogger<ReceiptManager> _logger; private readonly ILogger<ReceiptManager> _logger;
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" }; private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" };
@@ -47,12 +49,14 @@ namespace MoneyMap.Services
IWebHostEnvironment environment, IWebHostEnvironment environment,
IConfiguration configuration, IConfiguration configuration,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
IReceiptParseQueue parseQueue,
ILogger<ReceiptManager> logger) ILogger<ReceiptManager> logger)
{ {
_db = db; _db = db;
_environment = environment; _environment = environment;
_configuration = configuration; _configuration = configuration;
_serviceProvider = serviceProvider; _serviceProvider = serviceProvider;
_parseQueue = parseQueue;
_logger = logger; _logger = logger;
} }
@@ -147,28 +151,50 @@ namespace MoneyMap.Services
UploadedAtUtc = DateTime.UtcNow UploadedAtUtc = DateTime.UtcNow
}; };
receipt.ParseStatus = ReceiptParseStatus.Queued;
_db.Receipts.Add(receipt); _db.Receipts.Add(receipt);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
// Automatically parse the receipt after upload (in background, don't wait for result) await _parseQueue.EnqueueAsync(receipt.Id);
_ = Task.Run(async () => _logger.LogInformation("Receipt {ReceiptId} enqueued for parsing", receipt.Id);
{
try
{
using var scope = _serviceProvider.CreateScope();
var parser = scope.ServiceProvider.GetRequiredService<IReceiptParser>();
await parser.ParseReceiptAsync(receipt.Id);
_logger.LogInformation("Background parsing completed for receipt {ReceiptId}", receipt.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Background parsing failed for receipt {ReceiptId}: {Message}", receipt.Id, ex.Message);
}
});
return ReceiptUploadResult.Success(receipt, duplicateWarnings); return ReceiptUploadResult.Success(receipt, duplicateWarnings);
} }
public async Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files)
{
var uploaded = new List<BulkUploadItem>();
var failed = new List<BulkUploadFailure>();
foreach (var file in files)
{
var result = await UploadReceiptInternalAsync(file, null);
if (result.IsSuccess)
{
uploaded.Add(new BulkUploadItem
{
ReceiptId = result.Receipt!.Id,
FileName = result.Receipt.FileName,
DuplicateWarnings = result.DuplicateWarnings
});
}
else
{
failed.Add(new BulkUploadFailure
{
FileName = file.FileName,
ErrorMessage = result.ErrorMessage ?? "Unknown error"
});
}
}
return new BulkUploadResult
{
Uploaded = uploaded,
Failed = failed
};
}
private async Task<List<DuplicateWarning>> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize) private async Task<List<DuplicateWarning>> CheckForDuplicatesAsync(string fileHash, string fileName, long fileSize)
{ {
var warnings = new List<DuplicateWarning>(); var warnings = new List<DuplicateWarning>();
@@ -361,4 +387,24 @@ namespace MoneyMap.Services
public string? TransactionName { get; set; } public string? TransactionName { get; set; }
public string Reason { get; set; } = ""; public string Reason { get; set; } = "";
} }
public class BulkUploadResult
{
public List<BulkUploadItem> Uploaded { get; init; } = new();
public List<BulkUploadFailure> Failed { get; init; } = new();
public int TotalCount => Uploaded.Count + Failed.Count;
}
public class BulkUploadItem
{
public long ReceiptId { get; set; }
public string FileName { get; set; } = "";
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
}
public class BulkUploadFailure
{
public string FileName { get; set; } = "";
public string ErrorMessage { get; set; } = "";
}
} }
+56
View File
@@ -0,0 +1,56 @@
using System.Threading.Channels;
namespace MoneyMap.Services
{
public interface IReceiptParseQueue
{
ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default);
ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default);
ValueTask<long> DequeueAsync(CancellationToken ct);
int QueueLength { get; }
long? CurrentlyProcessingId { get; }
void SetCurrentlyProcessing(long? receiptId);
}
public class ReceiptParseQueue : IReceiptParseQueue
{
private readonly Channel<long> _channel = Channel.CreateUnbounded<long>(
new UnboundedChannelOptions { SingleReader = true });
private long _currentlyProcessingId;
public int QueueLength => _channel.Reader.Count;
public long? CurrentlyProcessingId
{
get
{
var val = Interlocked.Read(ref _currentlyProcessingId);
return val == 0 ? null : val;
}
}
public void SetCurrentlyProcessing(long? receiptId)
{
Interlocked.Exchange(ref _currentlyProcessingId, receiptId ?? 0);
}
public async ValueTask EnqueueAsync(long receiptId, CancellationToken ct = default)
{
await _channel.Writer.WriteAsync(receiptId, ct);
}
public async ValueTask EnqueueManyAsync(IEnumerable<long> receiptIds, CancellationToken ct = default)
{
foreach (var id in receiptIds)
{
await _channel.Writer.WriteAsync(id, ct);
}
}
public async ValueTask<long> DequeueAsync(CancellationToken ct)
{
return await _channel.Reader.ReadAsync(ct);
}
}
}
@@ -0,0 +1,129 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Services
{
public class ReceiptParseWorkerService : BackgroundService
{
private readonly IReceiptParseQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ReceiptParseWorkerService> _logger;
public ReceiptParseWorkerService(
IReceiptParseQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<ReceiptParseWorkerService> logger)
{
_queue = queue;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("ReceiptParseWorkerService starting");
await RecoverPendingItemsAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
long receiptId = 0;
try
{
receiptId = await _queue.DequeueAsync(stoppingToken);
_queue.SetCurrentlyProcessing(receiptId);
await SetParseStatusAsync(receiptId, ReceiptParseStatus.Parsing);
_logger.LogInformation("Processing receipt {ReceiptId} from parse queue", receiptId);
using var scope = _scopeFactory.CreateScope();
var parser = scope.ServiceProvider.GetRequiredService<IReceiptParser>();
var result = await parser.ParseReceiptAsync(receiptId);
var finalStatus = result.IsSuccess
? ReceiptParseStatus.Completed
: ReceiptParseStatus.Failed;
await SetParseStatusAsync(receiptId, finalStatus);
_logger.LogInformation(
"Receipt {ReceiptId} parse {Status}: {Message}",
receiptId, finalStatus, result.Message);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing receipt {ReceiptId} from parse queue", receiptId);
if (receiptId > 0)
{
try
{
await SetParseStatusAsync(receiptId, ReceiptParseStatus.Failed);
}
catch (Exception statusEx)
{
_logger.LogError(statusEx, "Failed to update ParseStatus to Failed for receipt {ReceiptId}", receiptId);
}
}
}
finally
{
_queue.SetCurrentlyProcessing(null);
}
}
_logger.LogInformation("ReceiptParseWorkerService stopping");
}
private async Task RecoverPendingItemsAsync(CancellationToken ct)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MoneyMapContext>();
var pendingIds = await db.Receipts
.Where(r => r.ParseStatus == ReceiptParseStatus.Queued
|| r.ParseStatus == ReceiptParseStatus.Parsing)
.OrderBy(r => r.UploadedAtUtc)
.Select(r => r.Id)
.ToListAsync(ct);
if (pendingIds.Count > 0)
{
_logger.LogInformation(
"Recovering {Count} receipts with pending parse status", pendingIds.Count);
foreach (var id in pendingIds)
{
await db.Receipts
.Where(r => r.Id == id)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.ParseStatus, ReceiptParseStatus.Queued), ct);
}
await _queue.EnqueueManyAsync(pendingIds, ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error recovering pending parse items on startup");
}
}
private async Task SetParseStatusAsync(long receiptId, ReceiptParseStatus status)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MoneyMapContext>();
await db.Receipts
.Where(r => r.Id == receiptId)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.ParseStatus, status));
}
}
}
+97 -48
View File
@@ -38,26 +38,10 @@ public class TransactionAICategorizer : ITransactionAICategorizer
public async Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null) public async Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null)
{ {
var provider = _config["AI:CategorizationProvider"] ?? "OpenAI"; var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
var prompt = await BuildPromptAsync(transaction); var prompt = await BuildPromptAsync(transaction);
AICategorizationResponse? response; var response = await CallModelAsync(prompt, selectedModel);
if (provider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model ?? "default");
response = await CallLlamaCppAsync(prompt, model);
}
else
{
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
_logger.LogWarning("OpenAI API key not configured");
return null;
}
response = await CallOpenAIAsync(apiKey, prompt);
}
if (response == null) if (response == null)
return null; return null;
@@ -79,17 +63,21 @@ public class TransactionAICategorizer : ITransactionAICategorizer
{ {
var proposals = new List<AICategoryProposal>(); var proposals = new List<AICategoryProposal>();
// Pre-fetch existing categories once to avoid concurrent DbContext access // Pre-fetch existing categories and all rules once to avoid concurrent DbContext access
var existingCategories = await _db.CategoryMappings var existingCategories = await _db.CategoryMappings
.Select(m => m.Category) .Select(m => m.Category)
.Distinct() .Distinct()
.OrderBy(c => c) .OrderBy(c => c)
.ToListAsync(); .ToListAsync();
var allRules = await _db.CategoryMappings
.Include(m => m.Merchant)
.ToListAsync();
// Process transactions sequentially to avoid DbContext concurrency issues // Process transactions sequentially to avoid DbContext concurrency issues
foreach (var transaction in transactions) foreach (var transaction in transactions)
{ {
var result = await ProposeCategorizationWithCategoriesAsync(transaction, existingCategories, model); var result = await ProposeCategorizationWithCategoriesAsync(transaction, existingCategories, allRules, model);
if (result != null) if (result != null)
proposals.Add(result); proposals.Add(result);
} }
@@ -100,28 +88,21 @@ public class TransactionAICategorizer : ITransactionAICategorizer
private async Task<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync( private async Task<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync(
Transaction transaction, Transaction transaction,
List<string> existingCategories, List<string> existingCategories,
List<CategoryMapping> allRules,
string? model = null) string? model = null)
{ {
var provider = _config["AI:CategorizationProvider"] ?? "OpenAI"; var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
var prompt = BuildPromptWithCategories(transaction, existingCategories);
AICategorizationResponse? response; // Find rules whose pattern matches this transaction name
var matchingRules = allRules
.Where(r => transaction.Name.Contains(r.Pattern, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(r => r.Priority)
.ThenByDescending(r => r.Pattern.Length) // Prefer more specific patterns
.ToList();
if (provider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase)) var prompt = BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
{
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model ?? "default"); var response = await CallModelAsync(prompt, selectedModel);
response = await CallLlamaCppAsync(prompt, model);
}
else
{
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
_logger.LogWarning("OpenAI API key not configured");
return null;
}
response = await CallOpenAIAsync(apiKey, prompt);
}
if (response == null) if (response == null)
return null; return null;
@@ -161,27 +142,39 @@ public class TransactionAICategorizer : ITransactionAICategorizer
transaction.MerchantId = merchant.Id; transaction.MerchantId = merchant.Id;
} }
// Create category mapping rule if requested bool ruleCreated = false;
bool ruleUpdated = false;
// Create or update category mapping rule if requested
if (createRule && !string.IsNullOrWhiteSpace(proposal.Pattern)) if (createRule && !string.IsNullOrWhiteSpace(proposal.Pattern))
{ {
// Check if rule already exists
var existingRule = await _db.CategoryMappings var existingRule = await _db.CategoryMappings
.FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern); .FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern);
if (existingRule == null) if (existingRule == null)
{ {
var merchantId = transaction.MerchantId;
var newMapping = new CategoryMapping var newMapping = new CategoryMapping
{ {
Category = proposal.Category, Category = proposal.Category,
Pattern = proposal.Pattern, Pattern = proposal.Pattern,
MerchantId = merchantId, MerchantId = transaction.MerchantId,
Priority = proposal.Priority, Priority = proposal.Priority,
Confidence = proposal.Confidence, Confidence = proposal.Confidence,
CreatedBy = "AI", CreatedBy = "AI",
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
_db.CategoryMappings.Add(newMapping); _db.CategoryMappings.Add(newMapping);
ruleCreated = true;
}
else if (existingRule.Category != proposal.Category)
{
existingRule.Category = proposal.Category;
existingRule.MerchantId = transaction.MerchantId;
existingRule.Priority = proposal.Priority;
existingRule.Confidence = proposal.Confidence;
existingRule.CreatedBy = "AI";
existingRule.CreatedAt = DateTime.UtcNow;
ruleUpdated = true;
} }
} }
@@ -190,7 +183,8 @@ public class TransactionAICategorizer : ITransactionAICategorizer
return new ApplyProposalResult return new ApplyProposalResult
{ {
Success = true, Success = true,
RuleCreated = createRule && !string.IsNullOrWhiteSpace(proposal.Pattern) RuleCreated = ruleCreated,
RuleUpdated = ruleUpdated
}; };
} }
@@ -203,10 +197,26 @@ public class TransactionAICategorizer : ITransactionAICategorizer
.OrderBy(c => c) .OrderBy(c => c)
.ToListAsync(); .ToListAsync();
return BuildPromptWithCategories(transaction, existingCategories); // Load all rules and find matches in memory (pattern-in-name is hard to express in SQL)
var allRules = await _db.CategoryMappings
.Include(m => m.Merchant)
.ToListAsync();
var matchingRules = allRules
.Where(r => transaction.Name.Contains(r.Pattern, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(r => r.Priority)
.ThenByDescending(r => r.Pattern.Length)
.ToList();
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
} }
private string BuildPromptWithCategories(Transaction transaction, List<string> existingCategories) private string BuildPromptWithCategories(Transaction transaction, List<string> existingCategories)
{
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, new List<CategoryMapping>());
}
private string BuildPromptWithCategoriesAndRules(Transaction transaction, List<string> existingCategories, List<CategoryMapping> matchingRules)
{ {
var categoryList = existingCategories.Any() var categoryList = existingCategories.Any()
? string.Join(", ", existingCategories) ? string.Join(", ", existingCategories)
@@ -243,6 +253,22 @@ public class TransactionAICategorizer : ITransactionAICategorizer
if (transaction.IsTransfer) if (transaction.IsTransfer)
sb.AppendLine($"- Transfer to: {transaction.TransferToAccount?.DisplayLabel ?? "Unknown"}"); sb.AppendLine($"- Transfer to: {transaction.TransferToAccount?.DisplayLabel ?? "Unknown"}");
// Include matching rules so the AI respects existing mappings
if (matchingRules.Any())
{
sb.AppendLine();
sb.AppendLine("EXISTING RULES that match this transaction (you MUST use these categories unless clearly wrong):");
foreach (var rule in matchingRules)
{
var createdBy = rule.CreatedBy ?? "Unknown";
var merchantName = rule.Merchant?.Name;
sb.Append($" - Pattern \"{rule.Pattern}\" → Category \"{rule.Category}\"");
if (!string.IsNullOrWhiteSpace(merchantName))
sb.Append($", Merchant \"{merchantName}\"");
sb.AppendLine($" (created by {createdBy})");
}
}
sb.AppendLine(); sb.AppendLine();
sb.AppendLine($"Existing categories in this system: {categoryList}"); sb.AppendLine($"Existing categories in this system: {categoryList}");
sb.AppendLine(); sb.AppendLine();
@@ -250,27 +276,49 @@ public class TransactionAICategorizer : ITransactionAICategorizer
sb.AppendLine("{"); sb.AppendLine("{");
sb.AppendLine(" \"category\": \"Category name\","); sb.AppendLine(" \"category\": \"Category name\",");
sb.AppendLine(" \"canonical_merchant\": \"Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')\","); sb.AppendLine(" \"canonical_merchant\": \"Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')\",");
sb.AppendLine(" \"pattern\": \"Pattern to match future transactions (e.g., 'WALMART' or 'SUBWAY')\","); sb.AppendLine(" \"pattern\": \"EXACT substring from the transaction Name that identifies this merchant\",");
sb.AppendLine(" \"priority\": 0,"); sb.AppendLine(" \"priority\": 0,");
sb.AppendLine(" \"confidence\": 0.85,"); sb.AppendLine(" \"confidence\": 0.85,");
sb.AppendLine(" \"reasoning\": \"Brief explanation\""); sb.AppendLine(" \"reasoning\": \"Brief explanation\"");
sb.AppendLine("}"); sb.AppendLine("}");
sb.AppendLine(); sb.AppendLine();
sb.AppendLine("Guidelines:"); sb.AppendLine("Guidelines:");
sb.AppendLine("- If an existing rule matches this transaction, you MUST use that rule's category and merchant. Only deviate if the existing rule is clearly incorrect.");
sb.AppendLine("- Prefer using existing categories when appropriate"); sb.AppendLine("- Prefer using existing categories when appropriate");
sb.AppendLine("- CRITICAL: The pattern MUST be a substring that actually appears in the transaction Name field above. It is used for case-insensitive contains matching. Do NOT invent or clean up the pattern. Extract the shortest distinctive substring from the Name that would identify this merchant. For example, if the Name is 'DEBIT PURCHASE -VISA Kindle Unltd*0M6888', use 'Kindle Unltd' NOT 'Kindle Unlimited'. If the Name is 'WAL-MART #1234 SPRINGFIELD', use 'WAL-MART' NOT 'WALMART'.");
sb.AppendLine("- confidence: Your certainty in this categorization (0.0-1.0). Use ~0.9+ for obvious matches like 'WALMART' -> Groceries. Use ~0.7-0.8 for likely matches. Use ~0.5-0.6 for uncertain/ambiguous transactions."); sb.AppendLine("- confidence: Your certainty in this categorization (0.0-1.0). Use ~0.9+ for obvious matches like 'WALMART' -> Groceries. Use ~0.7-0.8 for likely matches. Use ~0.5-0.6 for uncertain/ambiguous transactions.");
sb.AppendLine("- Return ONLY valid JSON, no additional text."); sb.AppendLine("- Return ONLY valid JSON, no additional text.");
return sb.ToString(); return sb.ToString();
} }
private async Task<AICategorizationResponse?> CallOpenAIAsync(string apiKey, string prompt) private async Task<AICategorizationResponse?> CallModelAsync(string prompt, string model)
{
if (model.StartsWith("llamacpp:", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model);
return await CallLlamaCppAsync(prompt, model);
}
// Default to OpenAI
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
_logger.LogWarning("OpenAI API key not configured");
return null;
}
_logger.LogInformation("Using OpenAI for transaction categorization with model {Model}", model);
return await CallOpenAIAsync(apiKey, prompt, model);
}
private async Task<AICategorizationResponse?> CallOpenAIAsync(string apiKey, string prompt, string model = "gpt-4o-mini")
{ {
try try
{ {
var requestBody = new var requestBody = new
{ {
model = "gpt-4o-mini", model = model,
messages = new[] messages = new[]
{ {
new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." }, new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." },
@@ -298,7 +346,7 @@ public class TransactionAICategorizer : ITransactionAICategorizer
if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0) if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0)
return null; return null;
var content = apiResponse.Choices[0].Message?.Content; var content = OpenAIToolUseHelper.CleanJsonResponse(apiResponse.Choices[0].Message?.Content);
if (string.IsNullOrWhiteSpace(content)) if (string.IsNullOrWhiteSpace(content))
return null; return null;
@@ -414,5 +462,6 @@ public class ApplyProposalResult
{ {
public bool Success { get; set; } public bool Success { get; set; }
public bool RuleCreated { get; set; } public bool RuleCreated { get; set; }
public bool RuleUpdated { get; set; }
public string? ErrorMessage { get; set; } public string? ErrorMessage { get; set; }
} }
+9 -3
View File
@@ -3,7 +3,7 @@
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;" "MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
}, },
"Receipts": { "Receipts": {
"StoragePath": "\\\\TRUENAS\\aj\\Documents\\Receipts" "StoragePath": "\\\\TRUENAS\\receipts"
}, },
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
@@ -13,8 +13,14 @@
}, },
"Kestrel": { "Kestrel": {
"Endpoints": { "Endpoints": {
"Http": { "Url": "http://0.0.0.0:5001" } "Http": {
"Url": "http://0.0.0.0:5005"
}
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"AI": {
"ModelsEndpoint": "http://athena.lan:11434",
"ReceiptParsingModel": "llamacpp:Qwen3-VL-32B-Instruct-Q8_0"
}
} }
+36
View File
@@ -20,3 +20,39 @@ html {
body { body {
margin-bottom: 60px; margin-bottom: 60px;
} }
/* Active dropdown parent highlighting */
.navbar .nav-item.dropdown .nav-link.dropdown-toggle.active-parent {
color: rgba(255, 255, 255, 0.9);
}
/* Breadcrumb styling */
.breadcrumb-nav {
margin-bottom: 1rem;
}
.breadcrumb-nav .breadcrumb {
background: transparent;
padding: 0;
margin-bottom: 0;
font-size: 0.875rem;
}
/* Quick-action cards on dashboard */
.quick-action-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
text-decoration: none;
color: inherit;
}
.quick-action-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.3) !important;
}
.quick-action-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
+19 -4
View File
@@ -1,4 +1,19 @@
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification // Highlight active dropdown parent when a child page is active
// for details on configuring this project to bundle and minify static web assets. (function () {
var path = window.location.pathname.toLowerCase().replace(/\/+$/, '') || '/';
// Write your JavaScript code. document.querySelectorAll('.navbar .dropdown-menu .dropdown-item').forEach(function (item) {
var href = (item.getAttribute('href') || '').toLowerCase().replace(/\/+$/, '');
if (href && (path === href || path.startsWith(href + '/'))) {
item.classList.add('active');
var toggle = item.closest('.dropdown').querySelector('.dropdown-toggle');
if (toggle) toggle.classList.add('active-parent');
}
});
// Direct nav-links (non-dropdown)
document.querySelectorAll('.navbar .nav-link:not(.dropdown-toggle)').forEach(function (link) {
var href = (link.getAttribute('href') || '').toLowerCase().replace(/\/+$/, '');
if (href && path === href) {
link.classList.add('active');
}
});
})();