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
@@ -51,7 +51,7 @@ public partial class OpenAiVisionClient : ILlmClient
public async Task<string?> TryGenerateReplyAsync(string s, string u, CancellationToken ct = default)
{ if (!IsEnabled) return null; return await L(s, u, ct); }
public async Task<IReadOnlyList<ImageParseResult>?> AnalyzeImageAsync(
public async Task<IReadOnlyList<ImageParseResult>?> AnalyzeImageAsync(
byte[] img,
string mime,
CancellationToken ct = default)
@@ -157,10 +157,193 @@ public partial class OpenAiVisionClient : ILlmClient
providerRequestId,
SanitizeOutputSnippet(output));
throw new InvalidOperationException("图片模型返回格式无法解析,请重试", ex);
}
}
private static IReadOnlyList<ImageParseResult> ParseImageResults(string raw)
}
}
public async Task<IReadOnlyList<RecognitionBatchModelAction>?> ReconcileRecognitionBatchAsync(
RecognitionBatchModelInput input,
CancellationToken ct = default)
{
if (!IsEnabled) return null;
var candidateJson = JsonSerializer.Serialize(
input.Candidates.Select(candidate => new
{
candidateId = candidate.CandidateId,
flowSessionId = candidate.FlowSessionId,
packageName = candidate.PackageName,
type = candidate.Type,
amount = candidate.Amount,
merchant = candidate.Merchant,
orderId = candidate.OrderId,
occurredAt = candidate.OccurredAt,
recognitionKind = candidate.RecognitionKind,
categoryHint = candidate.CategoryHint,
confidence = candidate.Confidence,
evidenceIds = candidate.EvidenceIds,
}),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
var systemPrompt = $$"""
你是支付结果批次对账器。只返回 JSON:
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
{{candidateJson}}
{{string.Join('/', input.ExpenseCategories)}}
{{string.Join('/', input.IncomeCategories)}}
keepupdate drop
flowSessionId 使
flowSessionId drop
evidenceId create evidenceId
update/create type expense incomeamount 0
沿reason 40
""";
var messages = new List<object>();
var content = new List<object>();
if (_protocol == "messages")
{
content.Add(new { type = "text", text = systemPrompt });
foreach (var evidence in input.Evidence)
{
content.Add(new { type = "text", text = $"证据 {evidence.EvidenceId}" });
content.Add(new
{
type = "image",
source = new
{
type = "base64",
media_type = evidence.MimeType,
data = Convert.ToBase64String(evidence.ImageBytes),
},
});
}
messages.Add(new { role = "user", content });
}
else if (_protocol == "responses")
{
messages.Add(new { role = "system", content = systemPrompt });
foreach (var evidence in input.Evidence)
{
content.Add(new { type = "input_text", text = $"证据 {evidence.EvidenceId}" });
content.Add(new
{
type = "input_image",
image_url = "data:" + evidence.MimeType + ";base64," +
Convert.ToBase64String(evidence.ImageBytes),
});
}
if (content.Count == 0)
content.Add(new { type = "input_text", text = "仅根据候选结构化信息完成对账" });
messages.Add(new { role = "user", content });
}
else
{
messages.Add(new { role = "system", content = systemPrompt });
foreach (var evidence in input.Evidence)
{
content.Add(new { type = "text", text = $"证据 {evidence.EvidenceId}" });
content.Add(new
{
type = "image_url",
image_url = new
{
url = "data:" + evidence.MimeType + ";base64," +
Convert.ToBase64String(evidence.ImageBytes),
},
});
}
if (content.Count == 0)
content.Add(new { type = "text", text = "仅根据候选结构化信息完成对账" });
messages.Add(new { role = "user", content });
}
var (json, error) = await CA(BuildBody(messages, 2048, 0.1), ct);
if (json is null)
{
_logger.LogWarning(
"Recognition batch model request failed. batchId={BatchId} candidates={Candidates} evidence={Evidence} error={Error}",
input.BatchId,
input.Candidates.Count,
input.Evidence.Count,
error);
throw new InvalidOperationException(error ?? "批次对账模型未返回内容");
}
try
{
return ParseRecognitionBatchActions(EX(json));
}
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
{
_logger.LogWarning(
ex,
"Recognition batch output was invalid. batchId={BatchId} candidates={Candidates} evidence={Evidence}",
input.BatchId,
input.Candidates.Count,
input.Evidence.Count);
throw new InvalidOperationException("批次对账模型返回格式无法解析", ex);
}
}
private static IReadOnlyList<RecognitionBatchModelAction> ParseRecognitionBatchActions(
string raw)
{
foreach (var candidate in EnumerateJsonCandidates(raw))
{
try
{
using var document = JsonDocument.Parse(candidate);
var root = document.RootElement;
JsonElement actions;
if (root.ValueKind == JsonValueKind.Array)
{
actions = root;
}
else if (!TryGetProperty(root, "actions", out actions) ||
actions.ValueKind != JsonValueKind.Array)
{
continue;
}
var results = new List<RecognitionBatchModelAction>();
foreach (var item in actions.EnumerateArray().Take(30))
{
if (item.ValueKind != JsonValueKind.Object) continue;
var action = ReadText(item, "action")?.Trim().ToLowerInvariant();
if (action is not ("keep" or "update" or "create" or "drop"))
continue;
decimal? amount = TryGetProperty(item, "amount", out var amountValue) &&
amountValue.ValueKind != JsonValueKind.Null
? ReadAmount(item)
: null;
var confidence = TryGetProperty(item, "confidence", out var confidenceValue) &&
confidenceValue.TryGetDouble(out var parsedConfidence)
? Math.Clamp(parsedConfidence, 0, 1)
: 0;
var reason = ReadText(item, "reason")?.Trim() ?? "AI 对账";
results.Add(new RecognitionBatchModelAction(
action,
ReadText(item, "actionId", "action_id")?.Trim() ?? $"a{results.Count + 1}",
ReadText(item, "candidateId", "candidate_id")?.Trim(),
ReadText(item, "evidenceId", "evidence_id")?.Trim(),
ReadText(item, "type")?.Trim().ToLowerInvariant(),
amount,
ReadText(item, "categoryName", "category_name")?.Trim(),
ReadText(item, "paymentMethod", "payment_method")?.Trim(),
ReadText(item, "note", "merchant")?.Trim(),
ReadOccurredAt(item),
confidence,
reason.Length > 80 ? reason[..80] : reason));
}
return results;
}
catch (JsonException)
{
// Models may wrap the JSON in prose; continue scanning candidates.
}
}
throw new InvalidOperationException("JSON 中没有 actions 数组");
}
private static IReadOnlyList<ImageParseResult> ParseImageResults(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("模型没有返回可解析内容");
@@ -809,4 +992,4 @@ public partial class OpenAiVisionClient : ILlmClient
return null;
}
}
}
}