Files
jizhi/backend/MiaoJiZhang.Api/Services/OpenAiVisionClient.cs
T
2026-07-24 23:11:20 +08:00

812 lines
33 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\"," +
"\"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","amount":0,"categoryName":"","paymentMethod":null,"note":"","occurredAt":"2026-07-18T14:30:00+08:00"}]}
type 只能是 expense income
支出分类只能是:餐饮/饮品/购物/交通/住房/娱乐/医疗/学习/服饰/人情/旅行/其他。
收入分类只能是:工资/奖金/理财/兼职/红包/报销/其他。
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);
}
}
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",
_ => 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);
results.Add(new ImageParseResult(
type,
amount,
string.IsNullOrWhiteSpace(category) ? "其他" : category.Trim(),
string.IsNullOrWhiteSpace(payment) ? null : payment.Trim(),
string.IsNullOrWhiteSpace(note) ? "" : note.Trim(),
occurredAt));
}
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"))
return new IntentResult("chat", null);
var type = typeText == "income"
? TransactionType.Income
: TransactionType.Expense;
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));
}
catch
{
return null;
}
}
}