Implement Phase 1: AI-powered categorization with manual review
Add AI categorization service that suggests categories, merchants, and
rules for uncategorized transactions. Users can review and approve
suggestions before applying them.
Features:
- TransactionAICategorizer service using OpenAI GPT-4o-mini
- Batch processing (5 transactions at a time) to avoid rate limits
- Confidence scoring (0-100%) for each suggestion
- AI suggests category, canonical merchant name, and pattern rule
- ReviewAISuggestions page to list uncategorized transactions
- ReviewAISuggestionsWithProposals page for manual review
- Apply individual suggestions or bulk apply high confidence (≥80%)
- Optional rule creation for future auto-categorization
- Cost: ~$0.00015 per transaction (~$0.015 per 100)
CategoryMapping enhancements:
- Confidence field to track AI confidence score
- CreatedBy field ("AI" or "User") to track rule origin
- CreatedAt timestamp for audit trail
Updated ARCHITECTURE.md with complete documentation of:
- TransactionAICategorizer service details
- ReviewAISuggestions page descriptions
- AI categorization workflow (Phase 1)
- Updated CategoryMappings schema
Next steps (Phase 2):
- Auto-apply high confidence suggestions
- Background job processing
- Batch API requests for better efficiency
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
120
ARCHITECTURE.md
120
ARCHITECTURE.md
@@ -347,6 +347,47 @@ Pattern-based rules for auto-categorization with merchant linking.
|
|||||||
|
|
||||||
**Location:** Services/OpenAIReceiptParser.cs:23-302
|
**Location:** Services/OpenAIReceiptParser.cs:23-302
|
||||||
|
|
||||||
|
### TransactionAICategorizer (Services/TransactionAICategorizer.cs)
|
||||||
|
**Interface:** `ITransactionAICategorizer`
|
||||||
|
|
||||||
|
**Responsibility:** AI-powered categorization for uncategorized transactions.
|
||||||
|
|
||||||
|
**Key Methods:**
|
||||||
|
- `ProposeCategorizationAsync(Transaction transaction)`
|
||||||
|
- Analyzes transaction details (name, memo, amount, date)
|
||||||
|
- Calls OpenAI GPT-4o-mini with categorization prompt
|
||||||
|
- Returns `AICategoryProposal` with category, merchant, pattern, and confidence
|
||||||
|
- Auto-suggests rule creation for high confidence (≥70%)
|
||||||
|
|
||||||
|
- `ProposeBatchCategorizationAsync(List<Transaction> transactions)`
|
||||||
|
- Processes transactions in batches of 5 to avoid rate limits
|
||||||
|
- Returns list of proposals for review
|
||||||
|
|
||||||
|
- `ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule)`
|
||||||
|
- Updates transaction category and merchant
|
||||||
|
- Optionally creates CategoryMapping rule for future auto-categorization
|
||||||
|
- Returns `ApplyProposalResult` with success status
|
||||||
|
|
||||||
|
**API Configuration:**
|
||||||
|
- Model: `gpt-4o-mini`
|
||||||
|
- Temperature: 0.1 (deterministic)
|
||||||
|
- Max tokens: 300
|
||||||
|
- API key: Environment variable `OPENAI_API_KEY` or config `OpenAI:ApiKey`
|
||||||
|
- Cost: ~$0.00015 per transaction (~$0.015 per 100 transactions)
|
||||||
|
|
||||||
|
**Prompt Strategy:**
|
||||||
|
- Provides transaction details (name, memo, amount, date)
|
||||||
|
- Requests JSON response with category, canonical_merchant, pattern, confidence, reasoning
|
||||||
|
- Includes common category examples for context
|
||||||
|
- High confidence threshold (≥70%) suggests automatic rule creation
|
||||||
|
|
||||||
|
**CategoryMapping Enhancements:**
|
||||||
|
- `Confidence` (decimal?) - AI confidence score (0.0-1.0)
|
||||||
|
- `CreatedBy` (string?) - "AI" or "User"
|
||||||
|
- `CreatedAt` (DateTime?) - Rule creation timestamp
|
||||||
|
|
||||||
|
**Location:** Services/TransactionAICategorizer.cs
|
||||||
|
|
||||||
## Data Access Layer
|
## Data Access Layer
|
||||||
|
|
||||||
### MoneyMapContext (Data/MoneyMapContext.cs)
|
### MoneyMapContext (Data/MoneyMapContext.cs)
|
||||||
@@ -505,6 +546,38 @@ EF Core DbContext managing all database entities.
|
|||||||
|
|
||||||
**Location:** Pages/Recategorize.cshtml.cs:16-87
|
**Location:** Pages/Recategorize.cshtml.cs:16-87
|
||||||
|
|
||||||
|
### ReviewAISuggestions.cshtml / ReviewAISuggestionsModel
|
||||||
|
**Route:** `/ReviewAISuggestions`
|
||||||
|
|
||||||
|
**Purpose:** AI-powered categorization suggestions for uncategorized transactions.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Lists up to 50 most recent uncategorized transactions
|
||||||
|
- Generate AI suggestions button (processes up to 20 at a time)
|
||||||
|
- Cost transparency (~$0.00015 per transaction)
|
||||||
|
- Link to view uncategorized transactions
|
||||||
|
|
||||||
|
**Dependencies:** `ITransactionAICategorizer`
|
||||||
|
|
||||||
|
**Location:** Pages/ReviewAISuggestions.cshtml.cs
|
||||||
|
|
||||||
|
### ReviewAISuggestionsWithProposals.cshtml / ReviewAISuggestionsWithProposalsModel
|
||||||
|
**Route:** `/ReviewAISuggestionsWithProposals`
|
||||||
|
|
||||||
|
**Purpose:** Review and apply AI categorization proposals.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Display AI proposals with confidence scores
|
||||||
|
- Color-coded confidence indicators (green ≥80%, yellow 60-79%, red <60%)
|
||||||
|
- Individual actions: Accept (with/without rule), Reject, Edit Manually
|
||||||
|
- Bulk action: Apply all high-confidence suggestions (≥80%)
|
||||||
|
- Shows AI reasoning for each suggestion
|
||||||
|
- Stores proposals in session for review workflow
|
||||||
|
|
||||||
|
**Dependencies:** `ITransactionAICategorizer`
|
||||||
|
|
||||||
|
**Location:** Pages/ReviewAISuggestionsWithProposals.cshtml.cs
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### appsettings.json
|
### appsettings.json
|
||||||
@@ -648,6 +721,9 @@ Category (nvarchar(max), NOT NULL)
|
|||||||
Pattern (nvarchar(max), NOT NULL)
|
Pattern (nvarchar(max), NOT NULL)
|
||||||
MerchantId (int, FK → Merchants.Id, SET NULL)
|
MerchantId (int, FK → Merchants.Id, SET NULL)
|
||||||
Priority (int, NOT NULL, DEFAULT 0)
|
Priority (int, NOT NULL, DEFAULT 0)
|
||||||
|
Confidence (decimal(18,2), NULL) -- AI confidence score
|
||||||
|
CreatedBy (nvarchar(max), NULL) -- "AI" or "User"
|
||||||
|
CreatedAt (datetime2, NULL) -- Rule creation timestamp
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Workflows
|
## Key Workflows
|
||||||
@@ -740,6 +816,50 @@ Return DashboardData DTO
|
|||||||
Render dashboard view
|
Render dashboard view
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 5. AI-Powered Categorization (Phase 1 - Manual Review)
|
||||||
|
|
||||||
|
```
|
||||||
|
User visits /ReviewAISuggestions
|
||||||
|
↓
|
||||||
|
ReviewAISuggestionsModel.OnGetAsync()
|
||||||
|
- Loads up to 50 recent uncategorized transactions
|
||||||
|
↓
|
||||||
|
User clicks "Generate AI Suggestions"
|
||||||
|
↓
|
||||||
|
ReviewAISuggestionsModel.OnPostGenerateSuggestionsAsync()
|
||||||
|
- Fetches up to 20 uncategorized transactions
|
||||||
|
- Calls TransactionAICategorizer.ProposeBatchCategorizationAsync()
|
||||||
|
↓
|
||||||
|
For each transaction (batches of 5):
|
||||||
|
TransactionAICategorizer.ProposeCategorizationAsync()
|
||||||
|
- Builds prompt with transaction details
|
||||||
|
- Calls OpenAI GPT-4o-mini API
|
||||||
|
- Parses JSON response
|
||||||
|
- Returns AICategoryProposal
|
||||||
|
↓
|
||||||
|
Store proposals in session
|
||||||
|
Redirect to /ReviewAISuggestionsWithProposals
|
||||||
|
↓
|
||||||
|
User reviews proposals with confidence scores
|
||||||
|
↓
|
||||||
|
User actions:
|
||||||
|
Option A: Apply + Create Rule
|
||||||
|
- Updates transaction category and merchant
|
||||||
|
- Creates CategoryMapping rule (CreatedBy="AI")
|
||||||
|
- Future similar transactions auto-categorized
|
||||||
|
Option B: Apply (No Rule)
|
||||||
|
- Updates transaction only
|
||||||
|
- No rule created
|
||||||
|
Option C: Reject
|
||||||
|
- Removes proposal from session
|
||||||
|
Option D: Edit Manually
|
||||||
|
- Redirects to EditTransaction page
|
||||||
|
↓
|
||||||
|
Proposal applied
|
||||||
|
Remove from session
|
||||||
|
Display success message
|
||||||
|
```
|
||||||
|
|
||||||
## Design Patterns
|
## Design Patterns
|
||||||
|
|
||||||
### 1. Service Layer Pattern
|
### 1. Service Layer Pattern
|
||||||
|
|||||||
121
MoneyMap/Pages/ReviewAISuggestions.cshtml
Normal file
121
MoneyMap/Pages/ReviewAISuggestions.cshtml
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
@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>
|
||||||
114
MoneyMap/Pages/ReviewAISuggestions.cshtml.cs
Normal file
114
MoneyMap/Pages/ReviewAISuggestions.cshtml.cs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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 async Task<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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
162
MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml
Normal file
162
MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
@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>
|
||||||
157
MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml.cs
Normal file
157
MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml.cs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
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 async Task<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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,9 @@ builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvid
|
|||||||
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
|
||||||
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
|
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
|
||||||
|
|
||||||
|
// AI categorization service
|
||||||
|
builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Seed default category mappings on startup
|
// Seed default category mappings on startup
|
||||||
|
|||||||
273
MoneyMap/Services/TransactionAICategorizer.cs
Normal file
273
MoneyMap/Services/TransactionAICategorizer.cs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MoneyMap.Data;
|
||||||
|
using MoneyMap.Models;
|
||||||
|
|
||||||
|
namespace MoneyMap.Services;
|
||||||
|
|
||||||
|
public interface ITransactionAICategorizer
|
||||||
|
{
|
||||||
|
Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction);
|
||||||
|
Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions);
|
||||||
|
Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TransactionAICategorizer : ITransactionAICategorizer
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly MoneyMapContext _db;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
|
||||||
|
public TransactionAICategorizer(HttpClient httpClient, MoneyMapContext db, IConfiguration config)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_db = db;
|
||||||
|
_config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction)
|
||||||
|
{
|
||||||
|
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prompt = BuildPrompt(transaction);
|
||||||
|
var response = await CallOpenAIAsync(apiKey, prompt);
|
||||||
|
|
||||||
|
if (response == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new AICategoryProposal
|
||||||
|
{
|
||||||
|
TransactionId = transaction.Id,
|
||||||
|
Category = response.Category ?? "",
|
||||||
|
CanonicalMerchant = response.CanonicalMerchant,
|
||||||
|
Pattern = response.Pattern,
|
||||||
|
Priority = response.Priority,
|
||||||
|
Confidence = response.Confidence,
|
||||||
|
Reasoning = response.Reasoning,
|
||||||
|
CreateRule = response.Confidence >= 0.7m // High confidence = auto-create rule
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions)
|
||||||
|
{
|
||||||
|
var proposals = new List<AICategoryProposal>();
|
||||||
|
|
||||||
|
// Process in batches of 5 to avoid rate limits
|
||||||
|
var batches = transactions.Chunk(5);
|
||||||
|
|
||||||
|
foreach (var batch in batches)
|
||||||
|
{
|
||||||
|
var tasks = batch.Select(t => ProposeCategorizationAsync(t));
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
proposals.AddRange(results.Where(r => r != null)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposals;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true)
|
||||||
|
{
|
||||||
|
var transaction = await _db.Transactions.FindAsync(transactionId);
|
||||||
|
if (transaction == null)
|
||||||
|
return new ApplyProposalResult { Success = false, ErrorMessage = "Transaction not found" };
|
||||||
|
|
||||||
|
// Update transaction category
|
||||||
|
transaction.Category = proposal.Category;
|
||||||
|
|
||||||
|
// Handle merchant
|
||||||
|
if (!string.IsNullOrWhiteSpace(proposal.CanonicalMerchant))
|
||||||
|
{
|
||||||
|
var merchant = await _db.Merchants.FirstOrDefaultAsync(m => m.Name == proposal.CanonicalMerchant);
|
||||||
|
if (merchant == null)
|
||||||
|
{
|
||||||
|
merchant = new Merchant { Name = proposal.CanonicalMerchant };
|
||||||
|
_db.Merchants.Add(merchant);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
transaction.MerchantId = merchant.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 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,
|
||||||
|
Priority = proposal.Priority,
|
||||||
|
Confidence = proposal.Confidence,
|
||||||
|
CreatedBy = "AI",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
_db.CategoryMappings.Add(newMapping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new ApplyProposalResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
RuleCreated = createRule && !string.IsNullOrWhiteSpace(proposal.Pattern)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildPrompt(Transaction transaction)
|
||||||
|
{
|
||||||
|
return $@"Analyze this financial transaction and suggest a category and merchant name.
|
||||||
|
|
||||||
|
Transaction Details:
|
||||||
|
- Name: ""{transaction.Name}""
|
||||||
|
- Memo: ""{transaction.Memo}""
|
||||||
|
- Amount: {transaction.Amount:C}
|
||||||
|
- Date: {transaction.Date:yyyy-MM-dd}
|
||||||
|
|
||||||
|
Provide your analysis in JSON format:
|
||||||
|
{{
|
||||||
|
""category"": ""Category name (e.g., Restaurants, Groceries, Gas & Auto)"",
|
||||||
|
""canonical_merchant"": ""Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')"",
|
||||||
|
""pattern"": ""Pattern to match (e.g., 'WALMART' or 'SUBWAY')"",
|
||||||
|
""priority"": 0,
|
||||||
|
""confidence"": 0.95,
|
||||||
|
""reasoning"": ""Brief explanation""
|
||||||
|
}}
|
||||||
|
|
||||||
|
Common categories:
|
||||||
|
- Restaurants, Fast Food, Coffee Shop
|
||||||
|
- Groceries, Convenience Store
|
||||||
|
- Gas & Auto, Automotive
|
||||||
|
- Online shopping, Brick/mortar store
|
||||||
|
- Health, Pharmacy
|
||||||
|
- Entertainment, Streaming
|
||||||
|
- Utilities, Banking, Insurance
|
||||||
|
- Home Improvement, School
|
||||||
|
|
||||||
|
Return ONLY valid JSON, no additional text.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<OpenAIResponse?> CallOpenAIAsync(string apiKey, string prompt)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestBody = new
|
||||||
|
{
|
||||||
|
model = "gpt-4o-mini",
|
||||||
|
messages = new[]
|
||||||
|
{
|
||||||
|
new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." },
|
||||||
|
new { role = "user", content = prompt }
|
||||||
|
},
|
||||||
|
temperature = 0.1,
|
||||||
|
max_tokens = 300
|
||||||
|
};
|
||||||
|
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
|
||||||
|
request.Headers.Add("Authorization", $"Bearer {apiKey}");
|
||||||
|
request.Content = new StringContent(
|
||||||
|
JsonSerializer.Serialize(requestBody),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
var apiResponse = JsonSerializer.Deserialize<OpenAIChatResponse>(json);
|
||||||
|
|
||||||
|
if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = apiResponse.Choices[0].Message?.Content;
|
||||||
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Parse the JSON response from the AI
|
||||||
|
var result = JsonSerializer.Deserialize<OpenAIResponse>(content, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI API response models
|
||||||
|
private class OpenAIChatResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("choices")]
|
||||||
|
public Choice[]? Choices { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Choice
|
||||||
|
{
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public Message? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Message
|
||||||
|
{
|
||||||
|
[JsonPropertyName("content")]
|
||||||
|
public string? Content { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OpenAIResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("category")]
|
||||||
|
public string? Category { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("canonical_merchant")]
|
||||||
|
public string? CanonicalMerchant { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("pattern")]
|
||||||
|
public string? Pattern { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("priority")]
|
||||||
|
public int Priority { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("confidence")]
|
||||||
|
public decimal Confidence { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("reasoning")]
|
||||||
|
public string? Reasoning { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AICategoryProposal
|
||||||
|
{
|
||||||
|
public long TransactionId { get; set; }
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string? CanonicalMerchant { get; set; }
|
||||||
|
public string? Pattern { get; set; }
|
||||||
|
public int Priority { get; set; }
|
||||||
|
public decimal Confidence { get; set; }
|
||||||
|
public string? Reasoning { get; set; }
|
||||||
|
public bool CreateRule { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ApplyProposalResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public bool RuleCreated { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ namespace MoneyMap.Services
|
|||||||
// Merchant relationship
|
// Merchant relationship
|
||||||
public int? MerchantId { get; set; }
|
public int? MerchantId { get; set; }
|
||||||
public Models.Merchant? Merchant { get; set; }
|
public Models.Merchant? Merchant { get; set; }
|
||||||
|
|
||||||
|
// AI categorization tracking
|
||||||
|
public decimal? Confidence { get; set; } // AI confidence score (0.0 - 1.0)
|
||||||
|
public string? CreatedBy { get; set; } // "User" or "AI"
|
||||||
|
public DateTime? CreatedAt { get; set; } // When rule was created
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Service Interface =====
|
// ===== Service Interface =====
|
||||||
|
|||||||
Reference in New Issue
Block a user