Files
jizhi/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs
T

1065 lines
45 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 readonly LlmSecretProtector _secretProtector;
private string? _baseUrl, _apiKey, _model, _protocol;
private int _maxTokens = 1024;
private double _temperature = 0.7;
private DateTime _last = DateTime.MinValue;
private static readonly object _lk = new();
public OpenAiVisionClient(
IServiceScopeFactory sf,
IHttpClientFactory hf,
LlmSecretProtector secretProtector,
ILogger<OpenAiVisionClient> logger)
{
_sf = sf;
_http = hf.CreateClient("LlmClient");
_http.Timeout = TimeSpan.FromSeconds(120);
_secretProtector = secretProtector;
_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 只能是 expenseincome transfertransfer 必须同时返回 transferDirection=in|outcounterparty 尽量填写转账对方。
支出分类只能是:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他。
收入分类只能是:工资/奖金/理财/兼职/红包/报销/其他。
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)}}
每个候选必须且只能返回一个 keepupdate drop;缺失字段用候选原值。
不同 flowSessionId 代表不同支付流程。即使收款人、金额和时间相同,也绝不能据此合并或删除。
只有截图明确显示失败、取消、待支付,或明确是同一 flowSessionId 的重复结果页时才可 drop
只有存在没有对应候选的 evidenceId 且截图明确显示交易成功时才可 create,并必须引用该 evidenceId
update/create type 只能是 expenseincome transferamount 必须大于 0transfer 必须返回 transferDirection=in|out
update 修改 amount 时必须引用属于该 candidateId evidenceId,且 confidence 不低于 0.9;证据不足时保持候选金额。
截图未明确显示时间时沿用候选时间,禁止猜测时间。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 为空");
try
{
if (_protocol != "responses")
{
var reply = await L(
"你正在执行连接测试,只回复 OK。",
"测试连接",
ct);
return string.IsNullOrWhiteSpace(reply)
? (false, "模型没有返回内容")
: (true, null);
}
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, _temperature), onToken, ct);
}
public void InvalidateConfiguration()
{
lock (_lk) _last = DateTime.MinValue;
}
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 = _secretProtector.TryUnprotect(
config.GetValueOrDefault(LlmSecretProtector.ConfigKey),
out var protectedApiKey)
? protectedApiKey
: Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
_baseUrl = (
config.GetValueOrDefault("llm.base_url") ??
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
"https://api.openai.com/v1").TrimEnd('/');
_model = config.GetValueOrDefault("llm.model") ??
Environment.GetEnvironmentVariable("LLM_MODEL") ??
"gpt-4o-mini";
_protocol = config.GetValueOrDefault("llm.protocol") ??
Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
"responses";
_maxTokens = int.TryParse(
config.GetValueOrDefault("llm.max_tokens"),
out var maxTokens)
? Math.Clamp(maxTokens, 64, 4096)
: 1024;
_temperature = double.TryParse(
config.GetValueOrDefault("llm.temperature"),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var temperature)
? Math.Clamp(temperature, 0, 2)
: 0.7;
_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, _temperature), 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;
}
}
}