Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 299ea3d4fe | |||
| b5f46a7646 | |||
| 4be5658d32 | |||
| 324ab2c627 | |||
| e6512f9b7f | |||
| 516546b345 | |||
| 2be9990dbc | |||
| c3e88df43c | |||
| 444035fd72 | |||
| 6e3589f7da | |||
| 5eb27319e1 | |||
| 705f4ea201 | |||
| 16c8d121d4 | |||
| 396d5cfc1d | |||
| 167b7c2ec1 | |||
| 5c0f0f3fca |
+189
-24
@@ -41,6 +41,11 @@ MoneyMap follows a clean, service-oriented architecture:
|
||||
│ - ReceiptMatchingService (NEW) │
|
||||
│ - ReceiptManager │
|
||||
│ - AIReceiptParser │
|
||||
│ - ReceiptParseQueue (singleton) │
|
||||
│ - ReceiptParseWorkerService (hosted) │
|
||||
│ AI Tool Use: │
|
||||
│ - AIToolExecutor (DB query tools) │
|
||||
│ - AIToolRegistry (tool definitions) │
|
||||
│ Reference & Dashboard: │
|
||||
│ - ReferenceDataService (NEW) │
|
||||
│ - DashboardService │
|
||||
@@ -134,6 +139,9 @@ Stores uploaded receipt files (images/PDFs) linked to transactions.
|
||||
- `FileHashSha256` (string, 64) - SHA256 hash for deduplication
|
||||
- `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):**
|
||||
- `Merchant` (string, 200) - Merchant name extracted from 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
|
||||
- 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)`
|
||||
- Links an unmapped receipt to a transaction
|
||||
- Returns success boolean
|
||||
@@ -654,35 +667,62 @@ Represents a spending budget for a category or total spending.
|
||||
|
||||
**Location:** Services/ReceiptManager.cs:23-199
|
||||
|
||||
### OpenAIReceiptParser (Services/OpenAIReceiptParser.cs)
|
||||
### AIReceiptParser (Services/AIReceiptParser.cs)
|
||||
**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:**
|
||||
- `ParseReceiptAsync(long receiptId)`
|
||||
- `ParseReceiptAsync(long receiptId, string? model, string? notes)`
|
||||
- Loads receipt file from disk
|
||||
- Converts PDFs to PNG images using ImageMagick (220 DPI)
|
||||
- Calls OpenAI Vision API with structured prompt
|
||||
- Parses JSON response (merchant, date, due date, amounts, line items)
|
||||
- Resolves vision client based on model prefix (openai, claude-, llamacpp:, ollama:)
|
||||
- **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
|
||||
- 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
|
||||
- Attempts auto-mapping if receipt is unmapped
|
||||
- Returns `ReceiptParseResult`
|
||||
|
||||
**API Configuration:**
|
||||
- Model: `gpt-4o-mini`
|
||||
- Temperature: 0.1 (deterministic)
|
||||
- Max tokens: 2000
|
||||
- API key: Environment variable `OPENAI_API_KEY` or config `OpenAI:ApiKey`
|
||||
**Tool-Use Flow (OpenAI, Claude, LlamaCpp):**
|
||||
```
|
||||
AI sees receipt image + prompt with tool instructions
|
||||
↓
|
||||
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:**
|
||||
- Structured JSON request with schema example
|
||||
- Extracts: merchant, date, dueDate (for bills), subtotal, tax, total, confidence
|
||||
- Line items with: description, quantity, unitPrice, lineTotal
|
||||
- Special handling: Services/fees have null quantity (not products)
|
||||
- Due date extraction: For bills (utility, credit card, etc.), extracts payment due date
|
||||
**Ollama Fallback (enriched prompt):**
|
||||
```
|
||||
Pre-fetch all categories, matching merchants, candidate transactions
|
||||
↓
|
||||
Inject as text block in prompt
|
||||
↓
|
||||
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:**
|
||||
- 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
|
||||
|
||||
**Auto-Mapping Integration:**
|
||||
- After successful parse of unmapped receipts, triggers ReceiptAutoMapper
|
||||
- Attempts to automatically link receipt to matching transaction
|
||||
- If AI suggests a specific transaction, attempts direct mapping first
|
||||
- Falls back to ReceiptAutoMapper if AI mapping fails or no suggestion
|
||||
- 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)
|
||||
**Interface:** `IReceiptAutoMapper`
|
||||
@@ -743,6 +819,30 @@ Represents a spending budget for a category or total spending.
|
||||
|
||||
**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)
|
||||
**Interface:** `IFinancialAuditService`
|
||||
|
||||
@@ -952,6 +1052,29 @@ EF Core DbContext managing all database entities.
|
||||
|
||||
**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
|
||||
**Route:** `/EditTransaction/{id}`
|
||||
|
||||
@@ -1170,8 +1293,16 @@ builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvid
|
||||
|
||||
// Receipt Services
|
||||
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
|
||||
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>();
|
||||
```
|
||||
|
||||
@@ -1240,7 +1371,9 @@ Subtotal (decimal(18,2))
|
||||
Tax (decimal(18,2))
|
||||
Total (decimal(18,2))
|
||||
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
|
||||
INDEX: ParseStatus
|
||||
```
|
||||
|
||||
### ReceiptParseLogs Table
|
||||
@@ -1683,10 +1816,31 @@ MoneyMap demonstrates a well-architected ASP.NET Core application with clear sep
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** 2025-12-15
|
||||
**Version:** 1.4
|
||||
**Last Updated:** 2026-02-11
|
||||
**Version:** 1.5
|
||||
**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)
|
||||
|
||||
### 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
|
||||
- Updated `ReceiptManager` with unmapped receipt support and duplicate detection
|
||||
- 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
|
||||
|
||||
@@ -129,6 +129,9 @@ namespace MoneyMap.Data
|
||||
e.Property(x => x.Total).HasColumnType("decimal(18,2)");
|
||||
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.
|
||||
e.HasOne(x => x.Transaction)
|
||||
.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)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<int>("ParseStatus")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("ParsingNotes")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("nvarchar(2000)");
|
||||
@@ -267,6 +272,8 @@ namespace MoneyMap.Migrations
|
||||
|
||||
b.HasIndex("FileHashSha256");
|
||||
|
||||
b.HasIndex("ParseStatus");
|
||||
|
||||
b.HasIndex("TransactionId", "FileHashSha256")
|
||||
.IsUnique()
|
||||
.HasFilter("[TransactionId] IS NOT NULL");
|
||||
|
||||
@@ -4,6 +4,15 @@ using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MoneyMap.Models;
|
||||
|
||||
public enum ReceiptParseStatus
|
||||
{
|
||||
NotRequested = 0,
|
||||
Queued = 1,
|
||||
Parsing = 2,
|
||||
Completed = 3,
|
||||
Failed = 4
|
||||
}
|
||||
|
||||
[Index(nameof(TransactionId), nameof(FileHashSha256), IsUnique = true)]
|
||||
public class Receipt
|
||||
{
|
||||
@@ -55,6 +64,9 @@ public class Receipt
|
||||
[MaxLength(2000)]
|
||||
public string? ParsingNotes { get; set; }
|
||||
|
||||
// Parse queue status
|
||||
public ReceiptParseStatus ParseStatus { get; set; } = ReceiptParseStatus.NotRequested;
|
||||
|
||||
// One receipt -> many parse attempts + many line items
|
||||
public ICollection<ReceiptParseLog> ParseLogs { get; set; } = new List<ReceiptParseLog>();
|
||||
public ICollection<ReceiptLineItem> LineItems { get; set; } = new List<ReceiptLineItem>();
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
@model MoneyMap.Pages.AICategorizePreviewModel
|
||||
@{
|
||||
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">
|
||||
@@ -65,7 +71,11 @@
|
||||
}
|
||||
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">
|
||||
<input type="hidden" name="proposalsData" value="@Model.ProposalsJson" />
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Review Proposals (@Model.Proposals.Count suggestions)</strong>
|
||||
@@ -75,89 +85,45 @@ else
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<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" id="selectAllCheckbox" onchange="selectAll(this.checked)" checked>
|
||||
</th>
|
||||
<th>Transaction</th>
|
||||
<th>Current</th>
|
||||
<th>Proposed</th>
|
||||
<th>Merchant</th>
|
||||
<th style="width: 100px;">Confidence</th>
|
||||
<th style="width: 100px;">Create Rule</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var proposal in Model.Proposals)
|
||||
{
|
||||
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" title="@proposal.Reasoning">
|
||||
@(proposal.Reasoning.Length > 60 ? proposal.Reasoning.Substring(0, 60) + "..." : proposal.Reasoning)
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant))
|
||||
{
|
||||
<div>@proposal.ProposedMerchant</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern))
|
||||
{
|
||||
<code class="small">@proposal.ProposedPattern</code>
|
||||
}
|
||||
</td>
|
||||
<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>
|
||||
<ul class="nav nav-tabs px-3 pt-3" id="proposalTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="high-confidence-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#high-confidence" type="button" role="tab">
|
||||
High Confidence <span class="badge bg-success ms-1">@highConfidence.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="needs-review-tab" data-bs-toggle="tab"
|
||||
data-bs-target="#needs-review" type="button" role="tab">
|
||||
Needs Review <span class="badge bg-warning text-dark ms-1">@needsReview.Count</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content" id="proposalTabContent">
|
||||
<div class="tab-pane fade show active" id="high-confidence" role="tabpanel">
|
||||
@if (highConfidence.Any())
|
||||
{
|
||||
@await Html.PartialAsync("_ProposalTable", highConfidence)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-4 text-center text-muted">No high confidence proposals.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="tab-pane fade" id="needs-review" role="tabpanel">
|
||||
@if (needsReview.Any())
|
||||
{
|
||||
@await Html.PartialAsync("_ProposalTable", needsReview)
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="p-4 text-center text-muted">No proposals needing review.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between">
|
||||
<form method="post" asp-page-handler="Cancel" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-secondary">Cancel</button>
|
||||
</form>
|
||||
<a asp-page="/Recategorize" class="btn btn-outline-secondary">Cancel</a>
|
||||
<button type="submit" class="btn btn-success">
|
||||
Apply Selected Categorizations
|
||||
</button>
|
||||
@@ -180,11 +146,12 @@ else
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Create Rule</h6>
|
||||
<p class="small text-muted mb-0">
|
||||
When checked, a category mapping rule will be created using the proposed pattern.
|
||||
Future transactions matching this pattern will be automatically categorized.
|
||||
</p>
|
||||
<h6>Rule Status</h6>
|
||||
<ul class="list-unstyled mb-0">
|
||||
<li><span class="small text-muted">Create</span> - No existing rule; check to create a new mapping rule</li>
|
||||
<li><span class="badge bg-warning text-dark">Update</span> - Pattern exists with a different category; check to update it</li>
|
||||
<li><span class="badge bg-info text-dark">Exists</span> - Rule already exists with the same category</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -193,9 +160,36 @@ else
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Select/deselect all proposals across both tabs
|
||||
function selectAll(checked) {
|
||||
document.querySelectorAll('.proposal-checkbox').forEach(cb => cb.checked = checked);
|
||||
document.getElementById('selectAllCheckbox').checked = checked;
|
||||
document.querySelectorAll('.proposal-checkbox').forEach(cb => {
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -26,7 +26,17 @@ namespace MoneyMap.Pages
|
||||
|
||||
public List<ProposalViewModel> Proposals { get; set; } = new();
|
||||
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";
|
||||
|
||||
[TempData]
|
||||
@@ -188,26 +198,37 @@ namespace MoneyMap.Pages
|
||||
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.";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(ProposalsJson);
|
||||
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(json);
|
||||
if (storedProposals == null || storedProposals.Count == 0)
|
||||
{
|
||||
ErrorMessage = "No proposals to apply.";
|
||||
ErrorMessage = "No proposals to apply (deserialization returned empty).";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
var selectedSet = selectedIds?.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 rulesCreated = 0;
|
||||
int rulesUpdated = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var stored in storedProposals)
|
||||
{
|
||||
@@ -226,7 +247,7 @@ namespace MoneyMap.Pages
|
||||
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 result = await _aiCategorizer.ApplyProposalAsync(stored.TransactionId, proposal, shouldCreateRule);
|
||||
@@ -235,15 +256,25 @@ namespace MoneyMap.Pages
|
||||
applied++;
|
||||
if (result.RuleCreated)
|
||||
rulesCreated++;
|
||||
if (result.RuleUpdated)
|
||||
rulesUpdated++;
|
||||
}
|
||||
else
|
||||
{
|
||||
errors.Add($"ID {stored.TransactionId}: {result.ErrorMessage}");
|
||||
}
|
||||
}
|
||||
|
||||
SuccessMessage = $"Applied {applied} categorizations. Created {rulesCreated} new mapping rules.";
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
public IActionResult OnPostCancel()
|
||||
{
|
||||
return RedirectToPage("/Recategorize");
|
||||
}
|
||||
|
||||
@@ -254,10 +285,25 @@ namespace MoneyMap.Pages
|
||||
.Where(t => transactionIds.Contains(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)
|
||||
{
|
||||
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
|
||||
{
|
||||
TransactionId = stored.TransactionId,
|
||||
@@ -271,7 +317,9 @@ namespace MoneyMap.Pages
|
||||
ProposedPattern = stored.Pattern,
|
||||
Confidence = stored.Confidence,
|
||||
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 string? Reasoning { 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
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
@model MoneyMap.Pages.AccountDetailsModel
|
||||
@{
|
||||
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">
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
@model MoneyMap.Pages.EditAccountModel
|
||||
@{
|
||||
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>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
@model MoneyMap.Pages.EditCardModel
|
||||
@{
|
||||
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">
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
@model MoneyMap.Pages.EditTransactionModel
|
||||
@{
|
||||
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">
|
||||
|
||||
@@ -52,11 +52,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-3 d-flex gap-2">
|
||||
<a class="btn btn-primary" asp-page="/Upload">Upload CSV</a>
|
||||
<a class="btn btn-outline-secondary" asp-page="/Transactions">View All Transactions</a>
|
||||
<a class="btn btn-outline-secondary" asp-page="/CategoryMappings">Categories</a>
|
||||
<a class="btn btn-outline-secondary" asp-page="/Budgets">Budgets</a>
|
||||
<div class="row g-3 my-3">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<a asp-page="/Upload" 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-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 class="row g-3 my-2">
|
||||
<div class="col-lg-6">
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -18,6 +18,13 @@
|
||||
<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="row mb-4">
|
||||
<div class="col-md-4">
|
||||
|
||||
@@ -26,11 +26,24 @@ namespace MoneyMap.Pages
|
||||
}
|
||||
|
||||
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]
|
||||
public string? SuccessMessage { get; set; }
|
||||
|
||||
[TempData]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
await LoadStatsAsync();
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,11 @@
|
||||
<a asp-page="/ReviewReceipts" class="btn btn-warning me-2">
|
||||
Review Mappings
|
||||
</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">
|
||||
Upload Receipt
|
||||
Upload Receipts
|
||||
</button>
|
||||
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
|
||||
</div>
|
||||
@@ -115,25 +118,33 @@
|
||||
</script>
|
||||
}
|
||||
|
||||
<!-- Upload Receipt Modal -->
|
||||
<!-- Upload Receipts Modal -->
|
||||
<div class="modal fade" id="uploadReceiptModal" tabindex="-1" aria-labelledby="uploadReceiptModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<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>
|
||||
</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="mb-3">
|
||||
<label for="UploadFile" class="form-label">Select Receipt File</label>
|
||||
<input type="file" asp-for="UploadFile" class="form-control" accept=".jpg,.jpeg,.png,.pdf,.gif,.heic" />
|
||||
<div class="form-text">Supported formats: JPG, PNG, PDF, GIF, HEIC (Max 10MB)</div>
|
||||
<label for="uploadFiles" class="form-label">Select Receipt Files</label>
|
||||
<input type="file" name="files" id="uploadFiles" 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>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
@@ -154,14 +165,24 @@
|
||||
<span class="text-muted">- 0 total</span>
|
||||
}
|
||||
</div>
|
||||
@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;">
|
||||
<button type="submit" class="btn btn-sm btn-success" title="Automatically map unmapped receipts to matching transactions">
|
||||
🔗 Auto-Map Unmapped Receipts
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
<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)))
|
||||
{
|
||||
<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">
|
||||
Auto-Map Unmapped Receipts
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (Model.Receipts.Any())
|
||||
@@ -487,6 +508,7 @@
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Map form validation
|
||||
document.querySelectorAll('form[data-mapform="1"]').forEach(function(form){
|
||||
form.addEventListener('submit', function(e){
|
||||
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>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services;
|
||||
|
||||
namespace MoneyMap.Pages
|
||||
@@ -13,12 +14,15 @@ namespace MoneyMap.Pages
|
||||
private readonly IReceiptAutoMapper _autoMapper;
|
||||
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;
|
||||
_receiptManager = receiptManager;
|
||||
_autoMapper = autoMapper;
|
||||
_receiptMatchingService = receiptMatchingService;
|
||||
_parseQueue = parseQueue;
|
||||
}
|
||||
|
||||
public List<ReceiptRow> Receipts { get; set; } = new();
|
||||
@@ -53,10 +57,12 @@ namespace MoneyMap.Pages
|
||||
|
||||
public List<DuplicateWarning> DuplicateWarnings { get; set; } = new();
|
||||
public bool ShowDuplicateModal { get; set; } = false;
|
||||
public int FailedParseCount { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
await LoadReceiptsAsync();
|
||||
FailedParseCount = await _db.Receipts.CountAsync(r => r.ParseStatus == ReceiptParseStatus.Failed);
|
||||
|
||||
// Show duplicate modal if warnings present
|
||||
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()
|
||||
{
|
||||
if (UploadFile == null)
|
||||
@@ -227,6 +256,30 @@ namespace MoneyMap.Pages
|
||||
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)
|
||||
{
|
||||
var success = await _receiptManager.UnmapReceiptAsync(receiptId);
|
||||
|
||||
@@ -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"><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!;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
@model MoneyMap.Pages.ReviewReceiptsModel
|
||||
@{
|
||||
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">
|
||||
|
||||
@@ -22,23 +22,62 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Index">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Transactions">Transactions</a>
|
||||
<li class="nav-item dropdown">
|
||||
<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 class="nav-item">
|
||||
<a class="nav-link" asp-page="/Receipts">Receipts</a>
|
||||
<a class="nav-link" asp-page="/Budgets">Budgets</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Accounts">Accounts</a>
|
||||
</ul>
|
||||
<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 class="nav-item">
|
||||
<a class="nav-link" asp-page="/CategoryMappings">Categories</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Merchants">Merchants</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" asp-page="/Recategorize">Recategorize</a>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false" title="Settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-gear" viewBox="0 0 16 16">
|
||||
<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"/>
|
||||
<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"/>
|
||||
</svg>
|
||||
</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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -47,6 +86,25 @@
|
||||
</header>
|
||||
<div class="@(ViewData["FullWidth"] is true ? "container-fluid px-3" : "container")">
|
||||
<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()
|
||||
</main>
|
||||
</div>
|
||||
@@ -58,7 +116,7 @@
|
||||
</footer>
|
||||
|
||||
<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>
|
||||
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
|
||||
@@ -54,6 +54,13 @@ namespace MoneyMap.Pages
|
||||
|
||||
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
|
||||
.Include(t => t.Card)
|
||||
.ThenInclude(c => c!.Account)
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
@{
|
||||
ViewData["Title"] = "Upload Transactions";
|
||||
ViewData["FullWidth"] = Model.PreviewTransactions.Any();
|
||||
ViewData["Breadcrumbs"] = new List<(string Label, string? Url)>
|
||||
{
|
||||
("Transactions", Url.Page("/Transactions")),
|
||||
("Upload", null)
|
||||
};
|
||||
}
|
||||
|
||||
<h2>Upload Transactions</h2>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
@model MoneyMap.Pages.ViewReceiptModel
|
||||
@{
|
||||
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">
|
||||
@@ -156,28 +162,20 @@
|
||||
<strong>Parse Receipt</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (Model.AvailableParsers.Any())
|
||||
{
|
||||
<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">
|
||||
Using: <strong>@Model.SelectedModel</strong>
|
||||
<a href="/Settings" class="ms-2 small">Change</a>
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<label for="ParsingNotes" class="form-label small text-muted mb-1">Notes for AI</label>
|
||||
<textarea asp-for="ParsingNotes" class="form-control form-control-sm" rows="3"
|
||||
placeholder="Optional hints for parsing (e.g., 'This is a restaurant receipt', 'Ignore the voided items')"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
Parse Receipt
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small mb-0">No parsers available</p>
|
||||
}
|
||||
<form method="post" asp-page-handler="Parse" asp-route-id="@Model.Receipt.Id">
|
||||
<p class="text-muted small mb-2">
|
||||
Using: <strong>@Model.SelectedModel</strong>
|
||||
<a href="/Settings" class="ms-2 small">Change</a>
|
||||
</p>
|
||||
<div class="mb-2">
|
||||
<label for="ParsingNotes" class="form-label small text-muted mb-1">Notes for AI</label>
|
||||
<textarea asp-for="ParsingNotes" class="form-control form-control-sm" rows="3"
|
||||
placeholder="Optional hints for parsing (e.g., 'This is a restaurant receipt', 'Ignore the voided items')"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
Parse Receipt
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -214,6 +212,15 @@
|
||||
{
|
||||
<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>
|
||||
|
||||
@@ -11,25 +11,24 @@ namespace MoneyMap.Pages
|
||||
{
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IReceiptManager _receiptManager;
|
||||
private readonly IEnumerable<IReceiptParser> _parsers;
|
||||
private readonly IReceiptParseQueue _parseQueue;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public ViewReceiptModel(
|
||||
MoneyMapContext db,
|
||||
IReceiptManager receiptManager,
|
||||
IEnumerable<IReceiptParser> parsers,
|
||||
IReceiptParseQueue parseQueue,
|
||||
IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_receiptManager = receiptManager;
|
||||
_parsers = parsers;
|
||||
_parseQueue = parseQueue;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public Receipt? Receipt { get; set; }
|
||||
public List<ReceiptLineItem> LineItems { get; set; } = new();
|
||||
public List<ReceiptParseLog> ParseLogs { get; set; } = new();
|
||||
public List<ParserOption> AvailableParsers { get; set; } = new();
|
||||
public string ReceiptUrl { get; set; } = "";
|
||||
public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
|
||||
@@ -62,13 +61,6 @@ namespace MoneyMap.Pages
|
||||
// Get receipt URL for display - use handler parameter
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -94,35 +86,26 @@ namespace MoneyMap.Pages
|
||||
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 });
|
||||
}
|
||||
|
||||
// Use the configured model from settings, pass user notes
|
||||
var result = await selectedParser.ParseReceiptAsync(id, SelectedModel, ParsingNotes);
|
||||
// Save parsing notes to the receipt entity so the worker can use them
|
||||
receipt.ParsingNotes = ParsingNotes;
|
||||
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
SuccessMessage = result.Message;
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = result.Message;
|
||||
}
|
||||
// Enqueue the receipt for parsing
|
||||
await _parseQueue.EnqueueAsync(id);
|
||||
|
||||
SuccessMessage = "Receipt queued for parsing.";
|
||||
return RedirectToPage(new { id });
|
||||
}
|
||||
|
||||
public class ParserOption
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string FullName { get; set; } = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Services;
|
||||
using MoneyMap.Services.AITools;
|
||||
|
||||
// Set default culture to en-US for currency formatting ($)
|
||||
var culture = new CultureInfo("en-US");
|
||||
@@ -61,11 +62,17 @@ builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||
builder.Services.AddScoped<IReceiptAutoMapper, ReceiptAutoMapper>();
|
||||
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<ClaudeVisionClient>();
|
||||
builder.Services.AddHttpClient<OllamaVisionClient>();
|
||||
builder.Services.AddHttpClient<LlamaCppVisionClient>();
|
||||
builder.Services.AddScoped<IAIVisionClientResolver, AIVisionClientResolver>();
|
||||
builder.Services.AddScoped<IAIToolExecutor, AIToolExecutor>();
|
||||
builder.Services.AddScoped<IReceiptParser, AIReceiptParser>();
|
||||
|
||||
// AI categorization service
|
||||
|
||||
@@ -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",
|
||||
"receiptDate": "YYYY-MM-DD" (or null if not found),
|
||||
"dueDate": "YYYY-MM-DD" (or null if not found - for bills only),
|
||||
"subtotal": 0.00 (or null if not found),
|
||||
"tax": 0.00 (or null if not found),
|
||||
"receiptDate": "YYYY-MM-DD",
|
||||
"dueDate": null,
|
||||
"subtotal": 0.00,
|
||||
"tax": 0.00,
|
||||
"total": 0.00,
|
||||
"confidence": 0.95,
|
||||
"suggestedCategory": null,
|
||||
"suggestedTransactionId": null,
|
||||
"lineItems": [
|
||||
{
|
||||
"description": "item name",
|
||||
"upc": "1234567890123" (or null if not found),
|
||||
"upc": null,
|
||||
"quantity": 1.0,
|
||||
"unitPrice": 0.00 (or null),
|
||||
"unitPrice": 0.00,
|
||||
"lineTotal": 0.00,
|
||||
"category": null,
|
||||
"voided": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Extract all line items you can see on the receipt. For each item:
|
||||
- description: The item or service name (include any count/size info in the description itself, like "4CT" or "12 OZ")
|
||||
- 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.
|
||||
- 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.
|
||||
- unitPrice: Calculate as lineTotal divided by quantity (so usually equals lineTotal for retail items). Set to null only if quantity is null.
|
||||
- lineTotal: The total amount for this line (the price shown on the receipt, or 0.00 if voided)
|
||||
- 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.
|
||||
FIELD TYPES (you must follow these exactly):
|
||||
- merchant: string
|
||||
- receiptDate: string "YYYY-MM-DD" or null
|
||||
- dueDate: string "YYYY-MM-DD" or null (only for bills with a payment deadline)
|
||||
- subtotal: number or null
|
||||
- tax: number or null
|
||||
- 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:
|
||||
- NEVER skip or ignore ANY line items on the receipt
|
||||
- When you see "** VOIDED ENTRY **" or similar void markers, the item immediately after it is voided
|
||||
LINE ITEM FIELDS:
|
||||
- description: string (the item or service name, include count/size info like "4CT" or "12 OZ")
|
||||
- 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 all other items: set "voided": false
|
||||
- CONTINUE reading and extracting ALL items that appear after void markers - do NOT stop parsing
|
||||
- The receipt may have many items listed after a void marker - you MUST include every single one
|
||||
- Include EVERY line item you can see, whether voided or not
|
||||
- NEVER skip voided items - include them in the lineItems array
|
||||
- CONTINUE reading ALL items after void markers
|
||||
|
||||
OTHER IMPORTANT RULES:
|
||||
- Quantity MUST be 1.0 for ALL physical retail items (groceries, food, household goods, etc.) - do NOT leave it null
|
||||
- Every item on a grocery/retail receipt gets quantity: 1.0 unless you see explicit indicators like "2 @" or "QTY 3"
|
||||
- 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.
|
||||
DUE DATE:
|
||||
- Only for bills (utility, credit card, etc.) - extract the payment due date
|
||||
- For regular store receipts, dueDate must be null
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MoneyMap.Data;
|
||||
using MoneyMap.Models;
|
||||
using MoneyMap.Services.AITools;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoneyMap.Services
|
||||
{
|
||||
@@ -17,6 +19,7 @@ namespace MoneyMap.Services
|
||||
private readonly IPdfToImageConverter _pdfConverter;
|
||||
private readonly IAIVisionClientResolver _clientResolver;
|
||||
private readonly IMerchantService _merchantService;
|
||||
private readonly IAIToolExecutor _toolExecutor;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<AIReceiptParser> _logger;
|
||||
@@ -28,6 +31,7 @@ namespace MoneyMap.Services
|
||||
IPdfToImageConverter pdfConverter,
|
||||
IAIVisionClientResolver clientResolver,
|
||||
IMerchantService merchantService,
|
||||
IAIToolExecutor toolExecutor,
|
||||
IServiceProvider serviceProvider,
|
||||
IConfiguration configuration,
|
||||
ILogger<AIReceiptParser> logger)
|
||||
@@ -37,6 +41,7 @@ namespace MoneyMap.Services
|
||||
_pdfConverter = pdfConverter;
|
||||
_clientResolver = clientResolver;
|
||||
_merchantService = merchantService;
|
||||
_toolExecutor = toolExecutor;
|
||||
_serviceProvider = serviceProvider;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
@@ -55,9 +60,16 @@ namespace MoneyMap.Services
|
||||
if (!File.Exists(filePath))
|
||||
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 (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
|
||||
{
|
||||
ReceiptId = receiptId,
|
||||
@@ -70,8 +82,8 @@ namespace MoneyMap.Services
|
||||
try
|
||||
{
|
||||
var (base64Data, mediaType) = await PrepareImageDataAsync(receipt, filePath);
|
||||
var promptText = await BuildPromptAsync(receipt, notes);
|
||||
var visionResult = await client.AnalyzeImageAsync(base64Data, mediaType, promptText, selectedModel);
|
||||
var promptText = await BuildPromptAsync(receipt, effectiveNotes, client);
|
||||
var visionResult = await CallVisionClientAsync(client, base64Data, mediaType, promptText, selectedModel);
|
||||
|
||||
if (!visionResult.IsSuccess)
|
||||
{
|
||||
@@ -80,14 +92,14 @@ namespace MoneyMap.Services
|
||||
}
|
||||
|
||||
var parseData = ParseResponse(visionResult.Content);
|
||||
await ApplyParseResultAsync(receipt, receiptId, parseData, notes);
|
||||
await ApplyParseResultAsync(receipt, receiptId, parseData, effectiveNotes);
|
||||
|
||||
parseLog.Success = true;
|
||||
parseLog.Confidence = parseData.Confidence;
|
||||
parseLog.RawProviderPayloadJson = JsonSerializer.Serialize(parseData);
|
||||
await SaveParseLogAsync(parseLog);
|
||||
|
||||
await TryAutoMapReceiptAsync(receipt, receiptId);
|
||||
await TryAutoMapReceiptAsync(receipt, receiptId, parseData.SuggestedTransactionId);
|
||||
|
||||
var lineCount = parseData.LineItems.Count;
|
||||
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)
|
||||
{
|
||||
if (receipt.ContentType == "application/pdf")
|
||||
@@ -112,7 +147,7 @@ namespace MoneyMap.Services
|
||||
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();
|
||||
|
||||
@@ -133,6 +168,43 @@ namespace MoneyMap.Services
|
||||
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.";
|
||||
return promptText;
|
||||
}
|
||||
@@ -168,6 +240,16 @@ namespace MoneyMap.Services
|
||||
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
|
||||
var existingItems = await _db.ReceiptLineItems
|
||||
.Where(li => li.ReceiptId == receiptId)
|
||||
@@ -183,6 +265,7 @@ namespace MoneyMap.Services
|
||||
Quantity = item.Quantity,
|
||||
UnitPrice = item.UnitPrice,
|
||||
LineTotal = item.LineTotal,
|
||||
Category = item.Category,
|
||||
Voided = item.Voided
|
||||
}).ToList();
|
||||
|
||||
@@ -198,8 +281,41 @@ namespace MoneyMap.Services
|
||||
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)
|
||||
return;
|
||||
|
||||
@@ -282,6 +398,9 @@ namespace MoneyMap.Services
|
||||
public decimal? Tax { get; set; }
|
||||
public decimal? Total { get; set; }
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -292,6 +411,7 @@ namespace MoneyMap.Services
|
||||
public decimal? Quantity { get; set; }
|
||||
public decimal? UnitPrice { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public bool Voided { get; set; }
|
||||
}
|
||||
|
||||
@@ -306,4 +426,41 @@ namespace MoneyMap.Services
|
||||
public static ReceiptParseResult Failure(string 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
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using MoneyMap.Services.AITools;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -29,9 +30,226 @@ namespace MoneyMap.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI Vision API client.
|
||||
/// Extended interface for vision clients that support tool use / function calling.
|
||||
/// </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 IConfiguration _configuration;
|
||||
@@ -44,12 +262,12 @@ namespace MoneyMap.Services
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool SupportsToolUse => true;
|
||||
|
||||
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
|
||||
?? _configuration["OpenAI:ApiKey"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return VisionApiResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json");
|
||||
|
||||
var requestBody = new
|
||||
@@ -101,7 +319,7 @@ namespace MoneyMap.Services
|
||||
.GetProperty("content")
|
||||
.GetString();
|
||||
|
||||
return VisionApiResult.Success(CleanJsonResponse(messageContent));
|
||||
return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent));
|
||||
}
|
||||
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() ?? "";
|
||||
if (trimmed.StartsWith("```json"))
|
||||
var apiKey = GetApiKey();
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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}");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private string? GetApiKey() =>
|
||||
Environment.GetEnvironmentVariable("OPENAI_API_KEY")
|
||||
?? _configuration["OpenAI:ApiKey"];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anthropic Claude Vision API client.
|
||||
/// Anthropic Claude Vision API client with tool-use support.
|
||||
/// </summary>
|
||||
public class ClaudeVisionClient : IAIVisionClient
|
||||
public class ClaudeVisionClient : IAIToolAwareVisionClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
@@ -137,12 +400,12 @@ namespace MoneyMap.Services
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool SupportsToolUse => true;
|
||||
|
||||
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
|
||||
{
|
||||
var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
|
||||
?? _configuration["Anthropic:ApiKey"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
var apiKey = GetApiKey();
|
||||
if (apiKey == null)
|
||||
return VisionApiResult.Failure("Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable or Anthropic:ApiKey in appsettings.json");
|
||||
|
||||
var requestBody = new
|
||||
@@ -174,10 +437,7 @@ namespace MoneyMap.Services
|
||||
|
||||
try
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Clear();
|
||||
_httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey);
|
||||
_httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
||||
|
||||
ConfigureHeaders();
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
@@ -198,7 +458,7 @@ namespace MoneyMap.Services
|
||||
.GetProperty("text")
|
||||
.GetString();
|
||||
|
||||
return VisionApiResult.Success(CleanJsonResponse(messageContent));
|
||||
return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent));
|
||||
}
|
||||
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() ?? "";
|
||||
if (trimmed.StartsWith("```json"))
|
||||
var apiKey = GetApiKey();
|
||||
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()
|
||||
}
|
||||
}).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}");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
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>
|
||||
/// 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>
|
||||
public class LlamaCppVisionClient : IAIVisionClient
|
||||
public class LlamaCppVisionClient : IAIToolAwareVisionClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
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)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(5); // Local models can be slow
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(5);
|
||||
_configuration = configuration;
|
||||
_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>
|
||||
/// Get available models from the llama.cpp server.
|
||||
/// </summary>
|
||||
@@ -256,7 +707,7 @@ namespace MoneyMap.Services
|
||||
var modelsResponse = JsonSerializer.Deserialize<LlamaCppModelsResponse>(json);
|
||||
|
||||
return modelsResponse?.Data?
|
||||
.Where(m => !m.Id.StartsWith("mmproj-")) // Filter out multimodal projectors
|
||||
.Where(m => !m.Id.StartsWith("mmproj-"))
|
||||
.Select(m => new LlamaCppModel
|
||||
{
|
||||
Id = m.Id,
|
||||
@@ -279,7 +730,7 @@ namespace MoneyMap.Services
|
||||
public async Task<VisionApiResult> SendTextPromptAsync(string prompt, string? model = null)
|
||||
{
|
||||
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:"))
|
||||
llamaModel = llamaModel[9..];
|
||||
|
||||
@@ -324,7 +775,7 @@ namespace MoneyMap.Services
|
||||
.GetString();
|
||||
|
||||
_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)
|
||||
{
|
||||
@@ -341,8 +792,6 @@ namespace MoneyMap.Services
|
||||
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
|
||||
{
|
||||
var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434";
|
||||
|
||||
// Strip "llamacpp:" prefix if present
|
||||
var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model;
|
||||
|
||||
_logger.LogInformation("LlamaCpp: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes",
|
||||
@@ -397,7 +846,7 @@ namespace MoneyMap.Services
|
||||
.GetString();
|
||||
|
||||
_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)
|
||||
{
|
||||
@@ -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() ?? "";
|
||||
if (trimmed.StartsWith("```json"))
|
||||
var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434";
|
||||
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>
|
||||
/// 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>
|
||||
public class OllamaVisionClient : IAIVisionClient
|
||||
{
|
||||
@@ -434,7 +928,7 @@ namespace MoneyMap.Services
|
||||
public OllamaVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger<OllamaVisionClient> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(5); // Local models can be slow
|
||||
_httpClient.Timeout = TimeSpan.FromMinutes(5);
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -442,8 +936,6 @@ namespace MoneyMap.Services
|
||||
public async Task<VisionApiResult> AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model)
|
||||
{
|
||||
var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434";
|
||||
|
||||
// Strip "ollama:" prefix if present
|
||||
var ollamaModel = model.StartsWith("ollama:") ? model[7..] : model;
|
||||
|
||||
_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();
|
||||
|
||||
_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)
|
||||
{
|
||||
@@ -496,16 +988,6 @@ namespace MoneyMap.Services
|
||||
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
|
||||
|
||||
@@ -10,6 +10,7 @@ namespace MoneyMap.Services
|
||||
{
|
||||
Task<ReceiptUploadResult> UploadReceiptAsync(long transactionId, IFormFile file);
|
||||
Task<ReceiptUploadResult> UploadUnmappedReceiptAsync(IFormFile file);
|
||||
Task<BulkUploadResult> UploadManyUnmappedReceiptsAsync(IReadOnlyList<IFormFile> files);
|
||||
Task<bool> DeleteReceiptAsync(long receiptId);
|
||||
Task<bool> MapReceiptToTransactionAsync(long receiptId, long transactionId);
|
||||
Task<bool> UnmapReceiptAsync(long receiptId);
|
||||
@@ -23,6 +24,7 @@ namespace MoneyMap.Services
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly IReceiptParseQueue _parseQueue;
|
||||
private readonly ILogger<ReceiptManager> _logger;
|
||||
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
|
||||
private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".pdf", ".gif", ".heic" };
|
||||
@@ -47,12 +49,14 @@ namespace MoneyMap.Services
|
||||
IWebHostEnvironment environment,
|
||||
IConfiguration configuration,
|
||||
IServiceProvider serviceProvider,
|
||||
IReceiptParseQueue parseQueue,
|
||||
ILogger<ReceiptManager> logger)
|
||||
{
|
||||
_db = db;
|
||||
_environment = environment;
|
||||
_configuration = configuration;
|
||||
_serviceProvider = serviceProvider;
|
||||
_parseQueue = parseQueue;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -147,28 +151,50 @@ namespace MoneyMap.Services
|
||||
UploadedAtUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
receipt.ParseStatus = ReceiptParseStatus.Queued;
|
||||
_db.Receipts.Add(receipt);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Automatically parse the receipt after upload (in background, don't wait for result)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
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);
|
||||
}
|
||||
});
|
||||
await _parseQueue.EnqueueAsync(receipt.Id);
|
||||
_logger.LogInformation("Receipt {ReceiptId} enqueued for parsing", receipt.Id);
|
||||
|
||||
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)
|
||||
{
|
||||
var warnings = new List<DuplicateWarning>();
|
||||
@@ -361,4 +387,24 @@ namespace MoneyMap.Services
|
||||
public string? TransactionName { 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; } = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,26 +38,10 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
|
||||
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);
|
||||
|
||||
AICategorizationResponse? response;
|
||||
|
||||
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);
|
||||
}
|
||||
var response = await CallModelAsync(prompt, selectedModel);
|
||||
|
||||
if (response == null)
|
||||
return null;
|
||||
@@ -79,17 +63,21 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
{
|
||||
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
|
||||
.Select(m => m.Category)
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
|
||||
var allRules = await _db.CategoryMappings
|
||||
.Include(m => m.Merchant)
|
||||
.ToListAsync();
|
||||
|
||||
// Process transactions sequentially to avoid DbContext concurrency issues
|
||||
foreach (var transaction in transactions)
|
||||
{
|
||||
var result = await ProposeCategorizationWithCategoriesAsync(transaction, existingCategories, model);
|
||||
var result = await ProposeCategorizationWithCategoriesAsync(transaction, existingCategories, allRules, model);
|
||||
if (result != null)
|
||||
proposals.Add(result);
|
||||
}
|
||||
@@ -100,28 +88,21 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
private async Task<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync(
|
||||
Transaction transaction,
|
||||
List<string> existingCategories,
|
||||
List<CategoryMapping> allRules,
|
||||
string? model = null)
|
||||
{
|
||||
var provider = _config["AI:CategorizationProvider"] ?? "OpenAI";
|
||||
var prompt = BuildPromptWithCategories(transaction, existingCategories);
|
||||
var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini";
|
||||
|
||||
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))
|
||||
{
|
||||
_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);
|
||||
}
|
||||
var prompt = BuildPromptWithCategoriesAndRules(transaction, existingCategories, matchingRules);
|
||||
|
||||
var response = await CallModelAsync(prompt, selectedModel);
|
||||
|
||||
if (response == null)
|
||||
return null;
|
||||
@@ -161,27 +142,39 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
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))
|
||||
{
|
||||
// Check if rule already exists
|
||||
var existingRule = await _db.CategoryMappings
|
||||
.FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern);
|
||||
|
||||
if (existingRule == null)
|
||||
{
|
||||
var merchantId = transaction.MerchantId;
|
||||
var newMapping = new CategoryMapping
|
||||
{
|
||||
Category = proposal.Category,
|
||||
Pattern = proposal.Pattern,
|
||||
MerchantId = merchantId,
|
||||
MerchantId = transaction.MerchantId,
|
||||
Priority = proposal.Priority,
|
||||
Confidence = proposal.Confidence,
|
||||
CreatedBy = "AI",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_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
|
||||
{
|
||||
Success = true,
|
||||
RuleCreated = createRule && !string.IsNullOrWhiteSpace(proposal.Pattern)
|
||||
RuleCreated = ruleCreated,
|
||||
RuleUpdated = ruleUpdated
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,10 +197,26 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
.OrderBy(c => c)
|
||||
.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)
|
||||
{
|
||||
return BuildPromptWithCategoriesAndRules(transaction, existingCategories, new List<CategoryMapping>());
|
||||
}
|
||||
|
||||
private string BuildPromptWithCategoriesAndRules(Transaction transaction, List<string> existingCategories, List<CategoryMapping> matchingRules)
|
||||
{
|
||||
var categoryList = existingCategories.Any()
|
||||
? string.Join(", ", existingCategories)
|
||||
@@ -243,6 +253,22 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
if (transaction.IsTransfer)
|
||||
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($"Existing categories in this system: {categoryList}");
|
||||
sb.AppendLine();
|
||||
@@ -250,27 +276,49 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" \"category\": \"Category name\",");
|
||||
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(" \"confidence\": 0.85,");
|
||||
sb.AppendLine(" \"reasoning\": \"Brief explanation\"");
|
||||
sb.AppendLine("}");
|
||||
sb.AppendLine();
|
||||
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("- 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("- Return ONLY valid JSON, no additional text.");
|
||||
|
||||
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
|
||||
{
|
||||
var requestBody = new
|
||||
{
|
||||
model = "gpt-4o-mini",
|
||||
model = model,
|
||||
messages = new[]
|
||||
{
|
||||
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)
|
||||
return null;
|
||||
|
||||
var content = apiResponse.Choices[0].Message?.Content;
|
||||
var content = OpenAIToolUseHelper.CleanJsonResponse(apiResponse.Choices[0].Message?.Content);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return null;
|
||||
|
||||
@@ -414,5 +462,6 @@ public class ApplyProposalResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public bool RuleCreated { get; set; }
|
||||
public bool RuleUpdated { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"MoneyMapDb": "Server=barge.lan;Database=MoneyMap;User Id=moneymap;Password=Cn87oXQPj7EEkx;TrustServerCertificate=True;"
|
||||
},
|
||||
"Receipts": {
|
||||
"StoragePath": "\\\\TRUENAS\\aj\\Documents\\Receipts"
|
||||
"StoragePath": "\\\\TRUENAS\\receipts"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
@@ -13,8 +13,14 @@
|
||||
},
|
||||
"Kestrel": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,40 @@ html {
|
||||
|
||||
body {
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,19 @@
|
||||
// Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
||||
// for details on configuring this project to bundle and minify static web assets.
|
||||
|
||||
// Write your JavaScript code.
|
||||
// Highlight active dropdown parent when a child page is active
|
||||
(function () {
|
||||
var path = window.location.pathname.toLowerCase().replace(/\/+$/, '') || '/';
|
||||
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');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user