1034 lines
44 KiB
C#
1034 lines
44 KiB
C#
using System.Globalization;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Text.RegularExpressions;
|
||
using MiaoJiZhang.Domain.Enums;
|
||
using MiaoJiZhang.Infrastructure.Persistence;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace MiaoJiZhang.Api.Services;
|
||
|
||
public partial class OpenAiVisionClient : ILlmClient
|
||
{
|
||
private readonly IServiceScopeFactory _sf;
|
||
private readonly HttpClient _http;
|
||
private readonly ILogger<OpenAiVisionClient> _logger;
|
||
private string? _baseUrl, _apiKey, _model, _protocol;
|
||
private int _maxTokens = 1024;
|
||
private DateTime _last = DateTime.MinValue;
|
||
private static readonly object _lk = new();
|
||
|
||
public OpenAiVisionClient(
|
||
IServiceScopeFactory sf,
|
||
IHttpClientFactory hf,
|
||
ILogger<OpenAiVisionClient> logger)
|
||
{
|
||
_sf = sf;
|
||
_http = hf.CreateClient("LlmClient");
|
||
_http.Timeout = TimeSpan.FromSeconds(120);
|
||
_logger = logger;
|
||
}
|
||
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
|
||
|
||
public async Task<IntentResult?> TryParseIntentAsync(
|
||
string userText,
|
||
CancellationToken ct = default)
|
||
{
|
||
if (!IsEnabled) return null;
|
||
const string prompt =
|
||
"你是记账意图识别器。只返回 JSON:" +
|
||
"{\"kind\":\"bill\"|\"query\"|\"chat\",\"type\":\"expense\"|\"income\"|\"transfer\"," +
|
||
"\"transferDirection\":\"in\"|\"out\"|null,\"counterparty\":null," +
|
||
"\"amount\":0,\"categoryName\":\"\",\"note\":\"\"}。" +
|
||
"收入信号包括赚了、工资到账、奖金、兼职、稿费、红包、报销、退款、理财收益、收款;" +
|
||
"支出分类:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他;" +
|
||
"收入分类:工资/奖金/理财/兼职/红包/报销/其他。" +
|
||
"查询句不得返回 bill,未明确表示收入的金额默认 expense。";
|
||
var response = await L(prompt, userText, ct);
|
||
return response is null ? null : PI(response);
|
||
}
|
||
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(
|
||
byte[] img,
|
||
string mime,
|
||
CancellationToken ct = default)
|
||
{
|
||
if (!IsEnabled) return null;
|
||
|
||
var b64 = Convert.ToBase64String(img);
|
||
var shanghaiNow = ChinaClock.Now;
|
||
var systemPrompt = $$"""
|
||
你是账单截图识别器。逐条提取图片中所有独立、真实发生的交易,只返回 JSON:
|
||
{"bills":[{"type":"expense","transferDirection":null,"counterparty":null,"amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
|
||
type 只能是 expense、income 或 transfer;transfer 必须同时返回 transferDirection=in|out,counterparty 尽量填写转账对方。
|
||
支出分类只能是:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他。
|
||
收入分类只能是:工资/奖金/理财/兼职/红包/报销/其他。
|
||
note 只写简短商户、商品或交易对象,不要抄整行原文。
|
||
当前上海时间是 {{shanghaiNow:yyyy-MM-dd HH:mm:ss}}。截图明确显示完整日期和时间时,occurredAt 返回带 +08:00 的 ISO 8601 时间;
|
||
“今天、昨天、前天”等相对日期按当前上海时间换算。缺少日期或缺少具体时分时 occurredAt 必须返回 null,不得猜测。
|
||
不要把总计、余额、优惠、原价、待支付、统计数字或重复展示的同一笔交易当成独立账单。
|
||
无法确认真实交易时返回 {"bills":[]},最多返回 20 笔。
|
||
""";
|
||
var messages = new List<object>();
|
||
|
||
if (_protocol == "messages")
|
||
{
|
||
messages.Add(new
|
||
{
|
||
role = "user",
|
||
content = new object[]
|
||
{
|
||
new
|
||
{
|
||
type = "image",
|
||
source = new { type = "base64", media_type = mime, data = b64 },
|
||
},
|
||
new { type = "text", text = systemPrompt },
|
||
},
|
||
});
|
||
}
|
||
else if (_protocol == "responses")
|
||
{
|
||
messages.Add(new { role = "system", content = systemPrompt });
|
||
messages.Add(new
|
||
{
|
||
role = "user",
|
||
content = new object[]
|
||
{
|
||
new { type = "input_image", image_url = "data:" + mime + ";base64," + b64 },
|
||
new { type = "input_text", text = "提取截图中的全部独立交易" },
|
||
},
|
||
});
|
||
}
|
||
else
|
||
{
|
||
messages.Add(new { role = "system", content = systemPrompt });
|
||
messages.Add(new
|
||
{
|
||
role = "user",
|
||
content = new object[]
|
||
{
|
||
new
|
||
{
|
||
type = "image_url",
|
||
image_url = new { url = "data:" + mime + ";base64," + b64 },
|
||
},
|
||
new { type = "text", text = "提取截图中的全部独立交易" },
|
||
},
|
||
});
|
||
}
|
||
|
||
var diagnosticId = Guid.NewGuid().ToString("N")[..12];
|
||
var (json, error) = await CA(BuildBody(messages, 1536, 0.1), ct);
|
||
if (json is null)
|
||
{
|
||
_logger.LogWarning(
|
||
"Image model request failed. model={Model} protocol={Protocol} requestId={RequestId} error={Error}",
|
||
_model,
|
||
_protocol,
|
||
diagnosticId,
|
||
error);
|
||
throw new InvalidOperationException(error ?? "图片模型未返回内容");
|
||
}
|
||
|
||
var providerRequestId = ReadProviderRequestId(json) ?? diagnosticId;
|
||
var output = EX(json);
|
||
try
|
||
{
|
||
var results = ParseImageResults(output);
|
||
_logger.LogInformation(
|
||
"Image model parse completed. model={Model} protocol={Protocol} requestId={RequestId} items={Count}",
|
||
_model,
|
||
_protocol,
|
||
providerRequestId,
|
||
results.Count);
|
||
return results;
|
||
}
|
||
catch (Exception ex) when (ex is JsonException or InvalidOperationException)
|
||
{
|
||
_logger.LogWarning(
|
||
ex,
|
||
"Image model parse failed. model={Model} protocol={Protocol} requestId={RequestId} output={Output}",
|
||
_model,
|
||
_protocol,
|
||
providerRequestId,
|
||
SanitizeOutputSnippet(output));
|
||
throw new InvalidOperationException("图片模型返回格式无法解析,请重试", ex);
|
||
}
|
||
}
|
||
|
||
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,
|
||
transferDirection = candidate.TransferDirection,
|
||
counterparty = candidate.Counterparty,
|
||
providerTransactionId = candidate.ProviderTransactionId,
|
||
recognitionOccurrenceId = candidate.RecognitionOccurrenceId,
|
||
identityConfidence = candidate.IdentityConfidence,
|
||
}),
|
||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||
var systemPrompt = $$"""
|
||
你是支付结果批次对账器。只返回 JSON:
|
||
{"actions":[{"action":"keep|update|create|drop","actionId":"a1","candidateId":null,"evidenceId":null,"type":null,"transferDirection":null,"counterparty":null,"amount":null,"categoryName":null,"paymentMethod":null,"note":null,"occurredAt":null,"confidence":0.0,"reason":""}]}
|
||
当前批次候选:{{candidateJson}}
|
||
支出分类只能是:{{string.Join('/', input.ExpenseCategories)}}。
|
||
收入分类只能是:{{string.Join('/', input.IncomeCategories)}}。
|
||
每个候选必须且只能返回一个 keep、update 或 drop;缺失字段用候选原值。
|
||
不同 flowSessionId 代表不同支付流程。即使收款人、金额和时间相同,也绝不能据此合并或删除。
|
||
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop。
|
||
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId。
|
||
update/create 的 type 只能是 expense、income 或 transfer,amount 必须大于 0;transfer 必须返回 transferDirection=in|out。
|
||
截图未明确显示时间时沿用候选时间,禁止猜测时间。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,
|
||
ReadText(item, "transferDirection", "transfer_direction")?.Trim().ToLowerInvariant(),
|
||
ReadText(item, "counterparty")?.Trim()));
|
||
}
|
||
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("模型没有返回可解析内容");
|
||
|
||
InvalidOperationException? shapeError = null;
|
||
foreach (var candidate in EnumerateJsonCandidates(raw))
|
||
{
|
||
try
|
||
{
|
||
using var document = JsonDocument.Parse(
|
||
candidate,
|
||
new JsonDocumentOptions
|
||
{
|
||
AllowTrailingCommas = true,
|
||
CommentHandling = JsonCommentHandling.Skip,
|
||
});
|
||
var results = new List<ImageParseResult>();
|
||
if (TryReadImageRoot(document.RootElement, results))
|
||
return results;
|
||
shapeError = new InvalidOperationException("JSON 中没有账单字段");
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
// Keep scanning: models often add prose or multiple fenced blocks.
|
||
}
|
||
}
|
||
|
||
if (LooksLikeNoBillReply(raw)) return [];
|
||
throw shapeError ?? new InvalidOperationException("未找到完整 JSON");
|
||
}
|
||
|
||
private static IEnumerable<string> EnumerateJsonCandidates(string raw)
|
||
{
|
||
for (var start = 0; start < raw.Length; start++)
|
||
{
|
||
var opening = raw[start];
|
||
if (opening is not ('{' or '[')) continue;
|
||
|
||
var stack = new Stack<char>();
|
||
var inString = false;
|
||
var escaped = false;
|
||
for (var index = start; index < raw.Length; index++)
|
||
{
|
||
var current = raw[index];
|
||
if (inString)
|
||
{
|
||
if (escaped)
|
||
{
|
||
escaped = false;
|
||
}
|
||
else if (current == '\\')
|
||
{
|
||
escaped = true;
|
||
}
|
||
else if (current == '"')
|
||
{
|
||
inString = false;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (current == '"')
|
||
{
|
||
inString = true;
|
||
continue;
|
||
}
|
||
if (current is '{' or '[')
|
||
{
|
||
stack.Push(current);
|
||
continue;
|
||
}
|
||
if (current is not ('}' or ']')) continue;
|
||
if (
|
||
stack.Count == 0 ||
|
||
(current == '}' && stack.Peek() != '{') ||
|
||
(current == ']' && stack.Peek() != '[')
|
||
) {
|
||
break;
|
||
}
|
||
|
||
stack.Pop();
|
||
if (stack.Count == 0)
|
||
{
|
||
yield return raw[start..(index + 1)];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private static bool TryReadImageRoot(
|
||
JsonElement root,
|
||
List<ImageParseResult> results)
|
||
{
|
||
if (root.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in root.EnumerateArray()) AddBill(item, results);
|
||
return true;
|
||
}
|
||
if (root.ValueKind != JsonValueKind.Object) return false;
|
||
|
||
foreach (var name in new[] { "bills", "items", "transactions", "data" })
|
||
{
|
||
if (!TryGetProperty(root, name, out var items)) continue;
|
||
if (items.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in items.EnumerateArray()) AddBill(item, results);
|
||
return true;
|
||
}
|
||
if (items.ValueKind == JsonValueKind.Object)
|
||
return TryReadImageRoot(items, results);
|
||
if (items.ValueKind == JsonValueKind.Null) return true;
|
||
}
|
||
|
||
if (TryGetProperty(root, "result", out var nested))
|
||
return TryReadImageRoot(nested, results);
|
||
if (
|
||
TryGetProperty(root, "matched", out var matched) &&
|
||
matched.ValueKind == JsonValueKind.False
|
||
) {
|
||
return true;
|
||
}
|
||
if (TryGetProperty(root, "amount", out _))
|
||
{
|
||
AddBill(root, results);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private static void AddBill(
|
||
JsonElement item,
|
||
List<ImageParseResult> results)
|
||
{
|
||
if (results.Count >= 20 || item.ValueKind != JsonValueKind.Object) return;
|
||
var amount = ReadAmount(item);
|
||
if (amount <= 0) return;
|
||
|
||
var rawType = ReadText(item, "type", "transactionType", "direction");
|
||
var type = rawType?.Trim().ToLowerInvariant() switch
|
||
{
|
||
"income" or "收入" or "入账" => "income",
|
||
"expense" or "支出" or "出账" => "expense",
|
||
"transfer" or "转账" => "transfer",
|
||
_ => null,
|
||
};
|
||
if (type is null) return;
|
||
var category = ReadText(
|
||
item,
|
||
"categoryName",
|
||
"category_name",
|
||
"category");
|
||
var payment = ReadText(
|
||
item,
|
||
"paymentMethod",
|
||
"payment_method",
|
||
"payment",
|
||
"payMethod");
|
||
var note = ReadText(
|
||
item,
|
||
"note",
|
||
"merchant",
|
||
"description",
|
||
"title",
|
||
"counterparty");
|
||
var occurredAt = ReadOccurredAt(item);
|
||
var transferDirection = type == "transfer"
|
||
? ReadText(item, "transferDirection", "transfer_direction", "direction")
|
||
?.Trim().ToLowerInvariant() switch
|
||
{
|
||
"in" or "转入" => "in",
|
||
"out" or "转出" => "out",
|
||
_ => null,
|
||
}
|
||
: null;
|
||
if (type == "transfer" && transferDirection is null) return;
|
||
var counterparty = ReadText(item, "counterparty", "merchant")?.Trim();
|
||
|
||
results.Add(new ImageParseResult(
|
||
type,
|
||
amount,
|
||
string.IsNullOrWhiteSpace(category) ? "其他" : category.Trim(),
|
||
string.IsNullOrWhiteSpace(payment) ? null : payment.Trim(),
|
||
string.IsNullOrWhiteSpace(note) ? "" : note.Trim(),
|
||
occurredAt,
|
||
transferDirection,
|
||
string.IsNullOrWhiteSpace(counterparty) ? null : counterparty));
|
||
}
|
||
|
||
private static DateTime? ReadOccurredAt(JsonElement item)
|
||
{
|
||
var text = ReadText(
|
||
item,
|
||
"occurredAt",
|
||
"occurred_at",
|
||
"transactionTime",
|
||
"transaction_time",
|
||
"dateTime");
|
||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||
|
||
// Reject date-only values so the client can deliberately fall back to now.
|
||
if (!Regex.IsMatch(text, @"\b\d{4}[-/.年]\d{1,2}[-/.月]\d{1,2}") ||
|
||
!Regex.IsMatch(text, @"\b\d{1,2}:\d{2}\b"))
|
||
return null;
|
||
|
||
if (DateTimeOffset.TryParse(
|
||
text,
|
||
CultureInfo.InvariantCulture,
|
||
DateTimeStyles.AllowWhiteSpaces,
|
||
out var offsetValue))
|
||
{
|
||
var utc = offsetValue.UtcDateTime;
|
||
return utc <= DateTime.UtcNow.AddMinutes(10) ? utc : null;
|
||
}
|
||
|
||
if (!DateTime.TryParse(
|
||
text,
|
||
CultureInfo.GetCultureInfo("zh-CN"),
|
||
DateTimeStyles.AllowWhiteSpaces,
|
||
out var localValue))
|
||
return null;
|
||
var localUtc = ChinaClock.ToUtc(localValue);
|
||
return localUtc <= DateTime.UtcNow.AddMinutes(10) ? localUtc : null;
|
||
}
|
||
|
||
private static decimal ReadAmount(JsonElement item)
|
||
{
|
||
JsonElement amount = default;
|
||
var found =
|
||
TryGetProperty(item, "amount", out amount) ||
|
||
TryGetProperty(item, "money", out amount) ||
|
||
TryGetProperty(item, "price", out amount);
|
||
if (!found) return 0;
|
||
if (
|
||
amount.ValueKind == JsonValueKind.Number &&
|
||
amount.TryGetDecimal(out var number)
|
||
) {
|
||
return number;
|
||
}
|
||
if (amount.ValueKind != JsonValueKind.String) return 0;
|
||
|
||
var text = amount.GetString()?.Replace(",", "");
|
||
if (string.IsNullOrWhiteSpace(text)) return 0;
|
||
var match = Regex.Match(text, @"-?\d+(?:\.\d+)?");
|
||
return match.Success &&
|
||
decimal.TryParse(
|
||
match.Value,
|
||
NumberStyles.Number,
|
||
CultureInfo.InvariantCulture,
|
||
out var parsed)
|
||
? parsed
|
||
: 0;
|
||
}
|
||
|
||
private static string? ReadText(JsonElement item, params string[] names)
|
||
{
|
||
foreach (var name in names)
|
||
{
|
||
if (!TryGetProperty(item, name, out var value)) continue;
|
||
if (value.ValueKind == JsonValueKind.String) return value.GetString();
|
||
if (value.ValueKind is JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False)
|
||
return value.ToString();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static bool TryGetProperty(
|
||
JsonElement element,
|
||
string name,
|
||
out JsonElement value)
|
||
{
|
||
foreach (var property in element.EnumerateObject())
|
||
{
|
||
if (!property.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||
continue;
|
||
value = property.Value;
|
||
return true;
|
||
}
|
||
value = default;
|
||
return false;
|
||
}
|
||
|
||
private static bool LooksLikeNoBillReply(string raw)
|
||
{
|
||
return raw.Contains("没有账单", StringComparison.OrdinalIgnoreCase) ||
|
||
raw.Contains("未发现交易", StringComparison.OrdinalIgnoreCase) ||
|
||
raw.Contains("无法确认", StringComparison.OrdinalIgnoreCase) ||
|
||
raw.Contains("no transaction", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private static string? ReadProviderRequestId(string json)
|
||
{
|
||
try
|
||
{
|
||
using var document = JsonDocument.Parse(json);
|
||
var root = document.RootElement;
|
||
foreach (var name in new[] { "id", "request_id", "requestId" })
|
||
{
|
||
if (
|
||
TryGetProperty(root, name, out var value) &&
|
||
value.ValueKind == JsonValueKind.String
|
||
) {
|
||
return value.GetString();
|
||
}
|
||
}
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
// The normal response parser will report the malformed payload.
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static string SanitizeOutputSnippet(string raw)
|
||
{
|
||
var sanitized = Regex.Replace(
|
||
raw,
|
||
@"(?i)""(note|merchant|description|counterparty|paymentMethod|payment_method)""\s*:\s*""(?:\\.|[^""])*""",
|
||
@"""$1"":""***""");
|
||
sanitized = Regex.Replace(sanitized, @"\b\d{4,}\b", "***");
|
||
sanitized = Regex.Replace(sanitized, @"\s+", " ").Trim();
|
||
return sanitized.Length <= 500 ? sanitized : sanitized[..500] + "…";
|
||
}
|
||
|
||
public async Task<(bool, string?)> TestConnectionAsync(CancellationToken ct = default)
|
||
{
|
||
Load();
|
||
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
||
if (_protocol != "responses")
|
||
return (false, "AI Agent 记账要求使用 Responses 协议");
|
||
|
||
try
|
||
{
|
||
var tool = new AgentToolDefinition(
|
||
"diagnostic_echo",
|
||
"连接测试时必须调用的无副作用工具",
|
||
JsonSerializer.Deserialize<JsonElement>(
|
||
"""{"type":"object","properties":{"value":{"type":"string"}},"required":["value"]}"""));
|
||
var response = await RunAgentAsync(
|
||
"你正在执行连接测试。必须调用 diagnostic_echo,参数 value 填 ok。",
|
||
"执行工具调用测试",
|
||
[tool],
|
||
(_, _) => Task.FromResult("{\"ok\":true}"),
|
||
ct: ct);
|
||
return response.ToolCallCount > 0
|
||
? (true, null)
|
||
: (false, "模型返回了文本,但没有触发 Function Calling");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return (false, ex.Message);
|
||
}
|
||
}
|
||
|
||
public async Task StreamReplyAsync(string s, string u, Func<string, CancellationToken, Task> onToken, CancellationToken ct = default)
|
||
{
|
||
if (!IsEnabled) return;
|
||
Load();
|
||
|
||
|
||
var msgs = new List<object>();
|
||
if (_protocol == "messages") msgs.Add(new { role = "user", content = s + "\n\n" + u });
|
||
else { msgs.Add(new { role = "system", content = s }); msgs.Add(new { role = "user", content = u }); }
|
||
await SA(BuildBody(msgs, _maxTokens, 0.7), onToken, ct);
|
||
}
|
||
|
||
void Load()
|
||
{
|
||
if ((DateTime.UtcNow - _last).TotalMinutes < 5) return;
|
||
lock (_lk)
|
||
{
|
||
if ((DateTime.UtcNow - _last).TotalMinutes < 5) return;
|
||
try
|
||
{
|
||
using var scope = _sf.CreateScope();
|
||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
||
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||
_baseUrl = (
|
||
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
||
config.GetValueOrDefault("llm.base_url", "https://api.openai.com/v1") ??
|
||
"").TrimEnd('/');
|
||
_model = Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
||
config.GetValueOrDefault("llm.model", "gpt-4o-mini");
|
||
_protocol = Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
||
config.GetValueOrDefault("llm.protocol", "chat_completions");
|
||
_maxTokens = int.TryParse(
|
||
config.GetValueOrDefault("llm.max_tokens"),
|
||
out var maxTokens)
|
||
? Math.Clamp(maxTokens, 64, 4096)
|
||
: 1024;
|
||
_last = DateTime.UtcNow;
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
_logger.LogError(exception, "Failed to load LLM configuration");
|
||
}
|
||
}
|
||
}
|
||
|
||
async Task<string?> L(string sys, string user, CancellationToken ct)
|
||
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, 0.7), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
|
||
|
||
object BuildBody(List<object> msgs, int maxT, double temp) => _protocol switch
|
||
{ "messages" => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp }, "responses" => new { model = _model, input = msgs, max_output_tokens = maxT, temperature = temp, thinking = new { type = "disabled" } }, _ => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp } };
|
||
|
||
async Task<(string?, string?)> CA(object body, CancellationToken ct)
|
||
{
|
||
Load();
|
||
try
|
||
{
|
||
var ep = _protocol switch { "messages" => "/messages", "responses" => "/responses", _ => "/chat/completions" };
|
||
var json = JsonSerializer.Serialize(body);
|
||
var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + ep) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||
if (_protocol == "messages") { req.Headers.Add("x-api-key", _apiKey); req.Headers.Add("anthropic-version", "2023-06-01"); }
|
||
else req.Headers.Add("Authorization", "Bearer " + _apiKey);
|
||
var r = await _http.SendAsync(req, ct); var t = await r.Content.ReadAsStringAsync(ct);
|
||
if (r.IsSuccessStatusCode) return (t, null);
|
||
var detail = "";
|
||
try { var e = JsonDocument.Parse(t).RootElement; if (e.TryGetProperty("error", out var er)) { string? em = null; if (er.TryGetProperty("message", out var m)) em = m.GetString(); detail = ": " + (em ?? er.ToString()); } } catch { }
|
||
var msg = ((int)r.StatusCode) switch { 401 => "401 认证失败", 403 => "403 禁止访问", 404 => "404 接口不存在", 429 => "429 频率过高", 500 => "500 服务异常", _ => ((int)r.StatusCode).ToString() };
|
||
return (null, msg + detail);
|
||
}
|
||
catch (TaskCanceledException) { return (null, "请求超时"); }
|
||
catch (HttpRequestException ex) { return (null, "网络错误: " + (ex.InnerException?.Message ?? ex.Message)); }
|
||
catch (Exception ex) { return (null, ex.Message); }
|
||
}
|
||
|
||
async Task SA(object body, Func<string, CancellationToken, Task> onToken, CancellationToken ct)
|
||
{
|
||
Load();
|
||
var ep = _protocol switch { "messages" => "/messages", "responses" => "/responses", _ => "/chat/completions" };
|
||
var serialized = JsonSerializer.Serialize(body);
|
||
var json = serialized[..^1] + ",\"stream\":true}";
|
||
var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + ep) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
|
||
if (_protocol == "messages") { req.Headers.Add("x-api-key", _apiKey); req.Headers.Add("anthropic-version", "2023-06-01"); }
|
||
else req.Headers.Add("Authorization", "Bearer " + _apiKey);
|
||
var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
|
||
if (!resp.IsSuccessStatusCode)
|
||
throw new HttpRequestException($"LLM 流式请求失败 ({(int)resp.StatusCode})");
|
||
using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
||
using var reader = new StreamReader(stream);
|
||
var emitted = false;
|
||
while (!reader.EndOfStream && !ct.IsCancellationRequested)
|
||
{
|
||
var line = await reader.ReadLineAsync(ct);
|
||
if (string.IsNullOrWhiteSpace(line) || line == "data: [DONE]") continue;
|
||
var failure = StreamFailure(line);
|
||
if (failure is not null) throw new InvalidOperationException(failure);
|
||
var tok = ET(line);
|
||
if (!string.IsNullOrWhiteSpace(tok)) { emitted = true; await onToken(tok, ct); }
|
||
}
|
||
if (!emitted) throw new InvalidOperationException("AI 未返回内容");
|
||
}
|
||
|
||
static string? StreamFailure(string line)
|
||
{
|
||
var i = line.IndexOf('{');
|
||
if (i < 0) return null;
|
||
try
|
||
{
|
||
var d = JsonDocument.Parse(line[i..]).RootElement;
|
||
if (!d.TryGetProperty("type", out var type)) return null;
|
||
var value = type.GetString();
|
||
if (value == "response.incomplete")
|
||
{
|
||
var reason = d.TryGetProperty("response", out var response)
|
||
&& response.TryGetProperty("incomplete_details", out var details)
|
||
&& details.TryGetProperty("reason", out var reasonElement)
|
||
? reasonElement.GetString() : null;
|
||
return reason == "length" ? "AI 输出长度不足,请稍后重试" : "AI 回复未完成";
|
||
}
|
||
if (value is "response.failed" or "error") return "AI 服务返回失败";
|
||
return null;
|
||
}
|
||
catch { return null; }
|
||
}
|
||
static string? ET(string line)
|
||
{
|
||
if (!line.StartsWith("data: ") && !line.StartsWith("data:")) return null;
|
||
var i = line.IndexOf('{'); if (i < 0) return null;
|
||
try
|
||
{
|
||
var d = JsonDocument.Parse(line[i..]).RootElement;
|
||
if (d.TryGetProperty("choices", out var ch) && ch.ValueKind == JsonValueKind.Array && ch.GetArrayLength() > 0 && ch[0].TryGetProperty("delta", out var dt) && dt.TryGetProperty("content", out var ct)) return ct.GetString();
|
||
if (d.TryGetProperty("type", out var t))
|
||
{ var ty = t.GetString(); if ((ty == "response.output_text.delta" || ty == "content_block_delta") && d.TryGetProperty("delta", out var dl)) { if (dl.ValueKind == JsonValueKind.String) return dl.GetString(); if (dl.TryGetProperty("text", out var tx)) return tx.GetString(); } }
|
||
return null;
|
||
}
|
||
catch { return null; }
|
||
}
|
||
|
||
string EX(string json)
|
||
{
|
||
try
|
||
{
|
||
using var document = JsonDocument.Parse(json);
|
||
return ExtractResponseText(document.RootElement);
|
||
}
|
||
catch (JsonException)
|
||
{
|
||
return "";
|
||
}
|
||
}
|
||
|
||
private static string ExtractResponseText(JsonElement root)
|
||
{
|
||
if (
|
||
TryGetProperty(root, "output_text", out var directOutput) &&
|
||
directOutput.ValueKind == JsonValueKind.String
|
||
) {
|
||
return directOutput.GetString() ?? "";
|
||
}
|
||
|
||
if (TryGetProperty(root, "choices", out var choices) &&
|
||
choices.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var choice in choices.EnumerateArray())
|
||
{
|
||
if (!TryGetProperty(choice, "message", out var message)) continue;
|
||
if (!TryGetProperty(message, "content", out var content)) continue;
|
||
var value = ExtractTextParts(content);
|
||
if (value.Length > 0) return value;
|
||
}
|
||
}
|
||
|
||
if (TryGetProperty(root, "content", out var anthropicContent))
|
||
{
|
||
var value = ExtractTextParts(anthropicContent);
|
||
if (value.Length > 0) return value;
|
||
}
|
||
|
||
if (TryGetProperty(root, "output", out var output) &&
|
||
output.ValueKind == JsonValueKind.Array)
|
||
{
|
||
var messageParts = new List<string>();
|
||
var fallbackParts = new List<string>();
|
||
foreach (var item in output.EnumerateArray())
|
||
{
|
||
var type = TryGetProperty(item, "type", out var typeElement)
|
||
? typeElement.GetString()
|
||
: null;
|
||
if (type == "message" &&
|
||
TryGetProperty(item, "content", out var messageContent))
|
||
{
|
||
var value = ExtractTextParts(messageContent);
|
||
if (value.Length > 0) messageParts.Add(value);
|
||
}
|
||
if (TryGetProperty(item, "summary", out var summary))
|
||
{
|
||
var value = ExtractTextParts(summary);
|
||
if (value.Length > 0) fallbackParts.Add(value);
|
||
}
|
||
}
|
||
if (messageParts.Count > 0) return string.Join("", messageParts);
|
||
if (fallbackParts.Count > 0) return string.Join("", fallbackParts);
|
||
}
|
||
|
||
if (
|
||
TryGetProperty(root, "response", out var response) &&
|
||
response.ValueKind == JsonValueKind.Object
|
||
) {
|
||
return ExtractResponseText(response);
|
||
}
|
||
return "";
|
||
}
|
||
|
||
private static string ExtractTextParts(JsonElement element)
|
||
{
|
||
if (element.ValueKind == JsonValueKind.String)
|
||
return element.GetString() ?? "";
|
||
if (element.ValueKind == JsonValueKind.Array)
|
||
{
|
||
var parts = new List<string>();
|
||
foreach (var item in element.EnumerateArray())
|
||
{
|
||
var value = ExtractTextParts(item);
|
||
if (value.Length > 0) parts.Add(value);
|
||
}
|
||
return string.Join("", parts);
|
||
}
|
||
if (element.ValueKind != JsonValueKind.Object) return "";
|
||
|
||
if (TryGetProperty(element, "text", out var text))
|
||
{
|
||
if (text.ValueKind == JsonValueKind.String)
|
||
return text.GetString() ?? "";
|
||
if (
|
||
text.ValueKind == JsonValueKind.Object &&
|
||
TryGetProperty(text, "value", out var textValue) &&
|
||
textValue.ValueKind == JsonValueKind.String
|
||
) {
|
||
return textValue.GetString() ?? "";
|
||
}
|
||
}
|
||
if (
|
||
TryGetProperty(element, "output_text", out var outputText) &&
|
||
outputText.ValueKind == JsonValueKind.String
|
||
) {
|
||
return outputText.GetString() ?? "";
|
||
}
|
||
if (TryGetProperty(element, "content", out var content))
|
||
return ExtractTextParts(content);
|
||
return "";
|
||
}
|
||
|
||
static IntentResult? PI(string response)
|
||
{
|
||
try
|
||
{
|
||
var json = response.Trim();
|
||
var fence = new string((char)96, 3);
|
||
if (json.StartsWith(fence))
|
||
{
|
||
json = json.Replace(fence, "").Trim();
|
||
if (json.StartsWith("json", StringComparison.OrdinalIgnoreCase))
|
||
json = json[4..].Trim();
|
||
}
|
||
|
||
var document = JsonDocument.Parse(json).RootElement;
|
||
var kind = document.TryGetProperty("kind", out var kindNode)
|
||
? kindNode.GetString() ?? "chat"
|
||
: "chat";
|
||
if (kind != "bill") return new IntentResult(kind, null);
|
||
|
||
var typeText = document.TryGetProperty("type", out var typeNode)
|
||
? typeNode.GetString()
|
||
: null;
|
||
typeText = typeText?.Trim().ToLowerInvariant();
|
||
if (typeText is null ||
|
||
typeText is not ("income" or "expense" or "transfer"))
|
||
return new IntentResult("chat", null);
|
||
var type = typeText switch
|
||
{
|
||
"income" => TransactionType.Income,
|
||
"transfer" => TransactionType.Transfer,
|
||
_ => TransactionType.Expense,
|
||
};
|
||
var transferDirection = type == TransactionType.Transfer
|
||
? ReadText(document, "transferDirection", "transfer_direction") switch
|
||
{
|
||
"in" => TransferDirection.In,
|
||
"out" => TransferDirection.Out,
|
||
_ => (TransferDirection?)null,
|
||
}
|
||
: null;
|
||
if (type == TransactionType.Transfer && transferDirection is null)
|
||
return new IntentResult("chat", null);
|
||
var category = document.TryGetProperty(
|
||
"categoryName",
|
||
out var categoryNode)
|
||
? categoryNode.GetString() ?? "其他"
|
||
: "其他";
|
||
var note = document.TryGetProperty("note", out var noteNode)
|
||
? noteNode.GetString() ?? category
|
||
: category;
|
||
var amount = document.TryGetProperty("amount", out var amountNode) &&
|
||
amountNode.TryGetDecimal(out var value)
|
||
? value
|
||
: 0;
|
||
return new IntentResult(
|
||
"bill",
|
||
new ParsedBill(
|
||
type,
|
||
0,
|
||
category,
|
||
note,
|
||
amount,
|
||
null,
|
||
null,
|
||
transferDirection,
|
||
ReadText(document, "counterparty")?.Trim()));
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
}
|