Add AI batch reconciliation for accessibility bills
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using MiaoJiZhang.Api.Contracts;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
@@ -102,7 +103,7 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
[EnableRateLimiting("upload")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
|
||||
public async Task<ActionResult<ImageParseResponse>> ParseImage(
|
||||
public async Task<ActionResult<ImageParseResponse>> ParseImage(
|
||||
IFormFile file,
|
||||
[FromQuery] string source = "image",
|
||||
CancellationToken ct = default)
|
||||
@@ -195,7 +196,7 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
}
|
||||
|
||||
var first = items[0];
|
||||
return Ok(new ImageParseResponse(
|
||||
return Ok(new ImageParseResponse(
|
||||
true,
|
||||
first.CategoryId,
|
||||
first.CategoryName,
|
||||
@@ -205,8 +206,219 @@ public class ParseController(AppDbContext db, ILlmClient llm, AgentService agent
|
||||
first.Note,
|
||||
first.Type,
|
||||
items,
|
||||
first.OccurredAt));
|
||||
}
|
||||
first.OccurredAt));
|
||||
}
|
||||
|
||||
[HttpPost("recognition-batch")]
|
||||
[EnableRateLimiting("upload")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
|
||||
public async Task<ActionResult<RecognitionBatchResponse>> ReconcileRecognitionBatch(
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!await FeatureEnabled("feature.screenshot_bookkeeping_enabled"))
|
||||
return StatusCode(403, new ApiError("FEATURE_DISABLED", "AI 截图补全已由后台关闭"));
|
||||
if (!llm.IsEnabled)
|
||||
return StatusCode(503, new ApiError("LLM_NOT_CONFIGURED", "AI 图片解析未配置"));
|
||||
|
||||
var form = await Request.ReadFormAsync(ct);
|
||||
var manifestText = form["manifest"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(manifestText))
|
||||
return BadRequest(new ApiError("BATCH_MANIFEST_EMPTY", "批次清单不能为空"));
|
||||
|
||||
RecognitionBatchManifestRequest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<RecognitionBatchManifestRequest>(
|
||||
manifestText,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return BadRequest(new ApiError("BATCH_MANIFEST_INVALID", "批次清单格式无效"));
|
||||
}
|
||||
if (manifest is null || !Guid.TryParse(manifest.BatchId, out _) ||
|
||||
manifest.Candidates.Count > 10 || manifest.Evidence.Count > 10 ||
|
||||
manifest.Candidates.Count + manifest.Evidence.Count == 0)
|
||||
{
|
||||
return BadRequest(new ApiError("BATCH_MANIFEST_INVALID", "批次数量或标识无效"));
|
||||
}
|
||||
if (manifest.Candidates.Select(item => item.CandidateId).Distinct().Count() !=
|
||||
manifest.Candidates.Count ||
|
||||
manifest.Evidence.Select(item => item.EvidenceId).Distinct().Count() !=
|
||||
manifest.Evidence.Count)
|
||||
{
|
||||
return BadRequest(new ApiError("BATCH_ID_DUPLICATED", "批次中存在重复标识"));
|
||||
}
|
||||
|
||||
var evidenceById = manifest.Evidence.ToDictionary(item => item.EvidenceId);
|
||||
var candidateIds = manifest.Candidates.Select(item => item.CandidateId).ToHashSet();
|
||||
if (manifest.Evidence.Any(item => item.CandidateId is not null &&
|
||||
!candidateIds.Contains(item.CandidateId)) ||
|
||||
manifest.Candidates.Any(candidate =>
|
||||
(candidate.EvidenceIds ?? []).Any(evidenceId =>
|
||||
!evidenceById.TryGetValue(evidenceId, out var evidence) ||
|
||||
evidence.CandidateId != candidate.CandidateId)))
|
||||
{
|
||||
return BadRequest(new ApiError("BATCH_EVIDENCE_INVALID", "截图与候选关联无效"));
|
||||
}
|
||||
var imageInputs = new List<RecognitionBatchModelEvidence>();
|
||||
var uploadedEvidenceIds = new HashSet<string>();
|
||||
long totalBytes = 0;
|
||||
foreach (var file in form.Files)
|
||||
{
|
||||
var evidenceId = Path.GetFileNameWithoutExtension(file.FileName);
|
||||
if (!evidenceById.TryGetValue(evidenceId, out var evidence) ||
|
||||
!uploadedEvidenceIds.Add(evidenceId))
|
||||
return BadRequest(new ApiError("BATCH_EVIDENCE_UNKNOWN", "截图与批次清单不匹配"));
|
||||
if (!file.ContentType.StartsWith("image/") || file.Length <= 0 || file.Length > 1024 * 1024)
|
||||
return BadRequest(new ApiError("BATCH_EVIDENCE_INVALID", "单张截图必须是 1MB 以内的图片"));
|
||||
totalBytes += file.Length;
|
||||
if (totalBytes > 9 * 1024 * 1024)
|
||||
return BadRequest(new ApiError("BATCH_TOO_LARGE", "批次截图总大小不能超过 9MB"));
|
||||
using var stream = new MemoryStream();
|
||||
await file.CopyToAsync(stream, ct);
|
||||
imageInputs.Add(new RecognitionBatchModelEvidence(
|
||||
evidence.EvidenceId,
|
||||
evidence.CandidateId,
|
||||
evidence.FlowSessionId,
|
||||
evidence.PackageName,
|
||||
evidence.CapturedAt,
|
||||
stream.ToArray(),
|
||||
file.ContentType));
|
||||
}
|
||||
if (uploadedEvidenceIds.Count != evidenceById.Count)
|
||||
return BadRequest(new ApiError("BATCH_EVIDENCE_MISSING", "批次截图上传不完整"));
|
||||
|
||||
var categories = await db.Categories
|
||||
.Where(category => !category.IsDeleted &&
|
||||
(category.UserId == null || category.UserId == Uid))
|
||||
.ToListAsync(ct);
|
||||
var candidates = manifest.Candidates.Select(candidate =>
|
||||
new RecognitionBatchModelCandidate(
|
||||
candidate.CandidateId,
|
||||
candidate.FlowSessionId,
|
||||
candidate.PackageName,
|
||||
candidate.Type,
|
||||
candidate.Amount,
|
||||
candidate.Merchant,
|
||||
candidate.OrderId,
|
||||
candidate.OccurredAt,
|
||||
candidate.RecognitionKind,
|
||||
candidate.CategoryHint,
|
||||
candidate.Confidence,
|
||||
candidate.EvidenceIds ?? [])).ToList();
|
||||
|
||||
IReadOnlyList<RecognitionBatchModelAction>? modelActions;
|
||||
try
|
||||
{
|
||||
modelActions = await llm.ReconcileRecognitionBatchAsync(
|
||||
new RecognitionBatchModelInput(
|
||||
manifest.BatchId,
|
||||
candidates,
|
||||
imageInputs,
|
||||
categories.Where(category => category.Type == TransactionType.Expense)
|
||||
.Select(category => category.Name).Distinct().ToList(),
|
||||
categories.Where(category => category.Type == TransactionType.Income)
|
||||
.Select(category => category.Name).Distinct().ToList()),
|
||||
ct);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return StatusCode(502, new ApiError("BATCH_AI_FAILED", ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var evidence in imageInputs)
|
||||
Array.Clear(evidence.ImageBytes);
|
||||
}
|
||||
if (modelActions is null)
|
||||
return StatusCode(502, new ApiError("BATCH_AI_FAILED", "AI 未返回批次对账结果"));
|
||||
|
||||
var actions = ValidateBatchActions(manifest, modelActions, categories);
|
||||
return Ok(new RecognitionBatchResponse(manifest.BatchId, actions));
|
||||
}
|
||||
|
||||
private List<RecognitionBatchActionResponse> ValidateBatchActions(
|
||||
RecognitionBatchManifestRequest manifest,
|
||||
IReadOnlyList<RecognitionBatchModelAction> modelActions,
|
||||
List<Category> categories)
|
||||
{
|
||||
var candidateIds = manifest.Candidates.Select(item => item.CandidateId).ToHashSet();
|
||||
var evidenceById = manifest.Evidence.ToDictionary(item => item.EvidenceId);
|
||||
var selected = modelActions
|
||||
.Where(action => action.CandidateId != null && candidateIds.Contains(action.CandidateId))
|
||||
.GroupBy(action => action.CandidateId!)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
var result = new List<RecognitionBatchActionResponse>();
|
||||
|
||||
foreach (var candidate in manifest.Candidates)
|
||||
{
|
||||
selected.TryGetValue(candidate.CandidateId, out var model);
|
||||
var action = model?.Action is "update" or "drop" ? model.Action : "keep";
|
||||
var reason = model?.Reason ?? "保留本地识别结果";
|
||||
var confidence = model?.Confidence ?? 1;
|
||||
if (action == "drop" && (confidence < 0.9 ||
|
||||
string.IsNullOrWhiteSpace(model?.EvidenceId) ||
|
||||
!evidenceById.TryGetValue(model.EvidenceId, out var dropEvidence) ||
|
||||
dropEvidence.CandidateId != candidate.CandidateId))
|
||||
{
|
||||
action = "keep";
|
||||
reason = "撤销证据不足,已保留本地结果";
|
||||
}
|
||||
var type = model?.Type is "income" or "expense" ? model.Type : candidate.Type;
|
||||
var amount = model?.Amount is > 0 ? model.Amount.Value : candidate.Amount;
|
||||
var category = FindCategory(
|
||||
categories,
|
||||
type == "income" ? TransactionType.Income : TransactionType.Expense,
|
||||
model?.CategoryName ?? candidate.CategoryHint ?? "其他");
|
||||
result.Add(new RecognitionBatchActionResponse(
|
||||
action,
|
||||
model?.ActionId ?? $"keep-{candidate.CandidateId}",
|
||||
candidate.CandidateId,
|
||||
model?.EvidenceId,
|
||||
category.Id,
|
||||
category.Name,
|
||||
type,
|
||||
amount,
|
||||
model?.PaymentMethod,
|
||||
string.IsNullOrWhiteSpace(model?.Note) ? candidate.Merchant : model.Note,
|
||||
model?.OccurredAt ?? candidate.OccurredAt,
|
||||
confidence,
|
||||
reason));
|
||||
}
|
||||
|
||||
var usedEvidence = new HashSet<string>();
|
||||
foreach (var model in modelActions.Where(action => action.Action == "create"))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(model.EvidenceId) ||
|
||||
!evidenceById.TryGetValue(model.EvidenceId, out var evidence) ||
|
||||
!string.IsNullOrWhiteSpace(evidence.CandidateId) ||
|
||||
!usedEvidence.Add(model.EvidenceId) ||
|
||||
model.Type is not ("income" or "expense") ||
|
||||
model.Amount is not > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var type = model.Type == "income" ? TransactionType.Income : TransactionType.Expense;
|
||||
var category = FindCategory(categories, type, model.CategoryName ?? "其他");
|
||||
result.Add(new RecognitionBatchActionResponse(
|
||||
"create",
|
||||
model.ActionId,
|
||||
null,
|
||||
model.EvidenceId,
|
||||
category.Id,
|
||||
category.Name,
|
||||
model.Type,
|
||||
model.Amount,
|
||||
model.PaymentMethod,
|
||||
model.Note,
|
||||
model.OccurredAt ?? evidence.CapturedAt,
|
||||
model.Confidence,
|
||||
model.Reason));
|
||||
}
|
||||
return result.Take(20).ToList();
|
||||
}
|
||||
|
||||
private async Task<bool> FeatureEnabled(string key)
|
||||
{
|
||||
|
||||
@@ -61,17 +61,7 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
Amount = req.Amount,
|
||||
Note = req.Note,
|
||||
PaymentMethod = req.PaymentMethod,
|
||||
Source = req.Source switch
|
||||
{
|
||||
"voice" => TransactionSource.Voice,
|
||||
"ocr" => TransactionSource.ReceiptOcr,
|
||||
"screenshot" => TransactionSource.Screenshot,
|
||||
"accessibility" => TransactionSource.Accessibility,
|
||||
"notification" => TransactionSource.Notification,
|
||||
"recognition_ai" => TransactionSource.RecognitionAi,
|
||||
"local_ocr" => TransactionSource.LocalOcr,
|
||||
_ => TransactionSource.Manual,
|
||||
},
|
||||
Source = SourceFromWire(req.Source),
|
||||
SourceText = req.SourceText,
|
||||
ClientRequestId = clientRequestId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
@@ -98,6 +88,107 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("recognition-batch")]
|
||||
public async Task<ActionResult<List<RecognitionBatchTransactionDto>>> CreateRecognitionBatch(
|
||||
CreateRecognitionBatchRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!Guid.TryParse(req.BatchId, out _) || req.Items.Count is < 1 or > 20)
|
||||
return BadRequest(new ApiError("BATCH_INVALID", "批次标识或账单数量无效"));
|
||||
if (req.Items.Select(item => item.CandidateId).Distinct().Count() != req.Items.Count ||
|
||||
req.Items.Select(item => item.ClientRequestId).Distinct().Count() != req.Items.Count)
|
||||
{
|
||||
return BadRequest(new ApiError("BATCH_DUPLICATED", "批次中存在重复账单标识"));
|
||||
}
|
||||
if (req.Items.Any(item => item.Amount <= 0 ||
|
||||
item.ClientRequestId.Length is < 1 or > 64 ||
|
||||
ParseType(item.Type) is null))
|
||||
{
|
||||
return BadRequest(new ApiError("BATCH_ITEM_INVALID", "批次中存在无效账单"));
|
||||
}
|
||||
|
||||
var ledgerId = await ledgers.ResolveAsync(Uid, req.LedgerId);
|
||||
if (!ledgerId.HasValue)
|
||||
return BadRequest(new ApiError("LEDGER_NOT_FOUND", "账本不存在或无权访问"));
|
||||
var categoryIds = req.Items.Select(item => item.CategoryId).Distinct().ToList();
|
||||
var categories = await db.Categories
|
||||
.Where(category => categoryIds.Contains(category.Id) && !category.IsDeleted &&
|
||||
(category.UserId == null || category.UserId == Uid))
|
||||
.ToDictionaryAsync(category => category.Id, ct);
|
||||
foreach (var item in req.Items)
|
||||
{
|
||||
var type = ParseType(item.Type)!.Value;
|
||||
if (!categories.TryGetValue(item.CategoryId, out var category) || category.Type != type)
|
||||
return BadRequest(new ApiError("CATEGORY_TYPE_MISMATCH", "分类与收支类型不一致"));
|
||||
}
|
||||
|
||||
var requestIds = req.Items.Select(item => item.ClientRequestId).ToList();
|
||||
var existing = await db.Transactions
|
||||
.Include(transaction => transaction.Category)
|
||||
.Where(transaction => transaction.UserId == Uid &&
|
||||
transaction.ClientRequestId != null &&
|
||||
requestIds.Contains(transaction.ClientRequestId))
|
||||
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
|
||||
await using var transactionScope = await db.Database.BeginTransactionAsync(ct);
|
||||
var mapped = new List<(string CandidateId, Transaction Transaction)>();
|
||||
foreach (var item in req.Items)
|
||||
{
|
||||
if (existing.TryGetValue(item.ClientRequestId, out var found))
|
||||
{
|
||||
mapped.Add((item.CandidateId, found));
|
||||
continue;
|
||||
}
|
||||
var category = categories[item.CategoryId];
|
||||
var transaction = new Transaction
|
||||
{
|
||||
LedgerId = ledgerId.Value,
|
||||
UserId = Uid,
|
||||
CategoryId = category.Id,
|
||||
Category = category,
|
||||
Type = ParseType(item.Type)!.Value,
|
||||
Amount = item.Amount,
|
||||
Note = item.Note?.Trim(),
|
||||
PaymentMethod = item.PaymentMethod?.Trim(),
|
||||
OccurredAt = NormalizeOccurredAt(item.OccurredAt),
|
||||
Source = SourceFromWire(item.Source),
|
||||
SourceText = item.SourceText,
|
||||
ClientRequestId = item.ClientRequestId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.Transactions.Add(transaction);
|
||||
mapped.Add((item.CandidateId, transaction));
|
||||
}
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
await transactionScope.CommitAsync(ct);
|
||||
return Ok(mapped.Select(item => new RecognitionBatchTransactionDto(
|
||||
item.CandidateId,
|
||||
ToDto(item.Transaction, item.Transaction.Category))).ToList());
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
await transactionScope.RollbackAsync(ct);
|
||||
foreach (var entry in db.ChangeTracker.Entries<Transaction>()
|
||||
.Where(entry => entry.State == EntityState.Added))
|
||||
{
|
||||
entry.State = EntityState.Detached;
|
||||
}
|
||||
var raced = await db.Transactions
|
||||
.AsNoTracking()
|
||||
.Include(transaction => transaction.Category)
|
||||
.Where(transaction => transaction.UserId == Uid &&
|
||||
transaction.ClientRequestId != null &&
|
||||
requestIds.Contains(transaction.ClientRequestId))
|
||||
.ToDictionaryAsync(transaction => transaction.ClientRequestId!, ct);
|
||||
if (raced.Count != requestIds.Count) throw;
|
||||
return Ok(req.Items.Select(item => new RecognitionBatchTransactionDto(
|
||||
item.CandidateId,
|
||||
ToDto(raced[item.ClientRequestId], raced[item.ClientRequestId].Category))).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>账单详情(P11:AI 来源追溯、分类、备注、时间)</summary>
|
||||
[HttpGet("{id:long}")]
|
||||
public async Task<ActionResult<TransactionDto>> Detail(long id)
|
||||
@@ -512,6 +603,18 @@ public class TransactionsController(AppDbContext db, LedgerResolver ledgers) : C
|
||||
: baseUpdatedAt.Value.ToUniversalTime();
|
||||
return transaction.UpdatedAt > baseline.AddMilliseconds(1);
|
||||
}
|
||||
|
||||
private static TransactionSource SourceFromWire(string? source) => source switch
|
||||
{
|
||||
"voice" => TransactionSource.Voice,
|
||||
"ocr" => TransactionSource.ReceiptOcr,
|
||||
"screenshot" => TransactionSource.Screenshot,
|
||||
"accessibility" => TransactionSource.Accessibility,
|
||||
"notification" => TransactionSource.Notification,
|
||||
"recognition_ai" => TransactionSource.RecognitionAi,
|
||||
"local_ocr" => TransactionSource.LocalOcr,
|
||||
_ => TransactionSource.Manual,
|
||||
};
|
||||
private static DateTime NormalizeOccurredAt(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc ? value : ChinaClock.ToUtc(value);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user