Add AI batch reconciliation for accessibility bills

This commit is contained in:
2026-07-25 18:41:09 +08:00
parent eb8909a192
commit 7cca34b331
25 changed files with 2771 additions and 230 deletions
@@ -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)
{