Files
EmailBill/Service/AI/SmartHandleService.cs
SunCheng a88556c784 fix
2026-02-15 10:10:28 +08:00

960 lines
39 KiB
C#
Raw Permalink 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 Service.Transaction;
namespace Service.AI;
public interface ISmartHandleService
{
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction);
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
Task<TransactionParseResult?> ParseOneLineBillAsync(string text);
/// <summary>
/// 从邮件正文中使用AI提取交易记录AI兜底方案
/// </summary>
Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailByAiAsync(string emailBody);
/// <summary>
/// 为分类生成多个SVG图标定时任务使用
/// </summary>
Task<List<string>?> GenerateCategoryIconsAsync(string categoryName, TransactionType categoryType, int iconCount = 5);
/// <summary>
/// 为分类生成单个SVG图标手动触发使用
/// </summary>
Task<string?> GenerateSingleCategoryIconAsync(string categoryName, TransactionType categoryType);
/// <summary>
/// 生成预算执行报告HTML格式
/// </summary>
/// <param name="promptWithData">完整的Prompt包含数据和格式要求</param>
/// <param name="year">年份</param>
/// <param name="month">月份</param>
Task<string?> GenerateBudgetReportAsync(string promptWithData, int year, int month);
}
public class SmartHandleService(
ITransactionRecordRepository transactionRepository,
ITransactionStatisticsService transactionStatisticsService,
ITextSegmentService textSegmentService,
ILogger<SmartHandleService> logger,
ITransactionCategoryRepository categoryRepository,
IOpenAiService openAiService,
IConfigService configService,
IClassificationIconPromptProvider iconPromptProvider
) : ISmartHandleService
{
public async Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> chunkAction)
{
try
{
// 获取指定ID的账单作为样本
var sampleRecords = await transactionRepository.GetByIdsAsync(transactionIds);
sampleRecords = sampleRecords
.Where(x => string.IsNullOrEmpty(x.Classify))
.ToArray();
if (sampleRecords.Length == 0)
{
// await WriteEventAsync("error", "找不到指定的账单");
chunkAction(("error", "找不到指定的账单"));
return;
}
// 重新按Reason分组所有待分类账单
var groupedRecords = sampleRecords
.GroupBy(r => r.Reason)
.Select(g => new
{
Reason = g.Key,
Ids = g.Select(r => r.Id).ToList(),
Count = g.Count(),
TotalAmount = g.Sum(r => r.Amount),
SampleType = g.First().Type
})
.OrderByDescending(g => Math.Abs(g.TotalAmount))
.ToList();
// 【增强功能】对每个分组的摘要进行分词,查询已分类的相似账单
var referenceRecords = new Dictionary<string, List<TransactionRecord>>();
foreach (var group in groupedRecords)
{
// 使用专业分词库提取关键词
var keywords = textSegmentService.ExtractKeywords(group.Reason);
if (keywords.Count > 0)
{
// 查询包含这些关键词且已分类的账单(带相关度评分)
// minMatchRate=0.4 表示至少匹配40%的关键词才被认为是相似的
var similarClassifiedWithScore = await transactionStatisticsService.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.4, limit: 10);
if (similarClassifiedWithScore.Count > 0)
{
// 只取前5个最相关的
var topSimilar = similarClassifiedWithScore.Take(5).Select(x => x.record).ToList();
referenceRecords[group.Reason] = topSimilar;
// 记录调试信息
logger.LogDebug("摘要 '{Reason}' 提取关键词: {Keywords}, 找到 {Count} 个相似账单,相关度分数: {Scores}",
group.Reason,
string.Join(", ", keywords),
similarClassifiedWithScore.Count,
string.Join(", ", similarClassifiedWithScore.Select(x => $"{x.record.Reason}({x.relevanceScore:F2})")));
}
else
{
logger.LogDebug("摘要 '{Reason}' 提取关键词: {Keywords}, 未找到高相关度的相似账单",
group.Reason,
string.Join(", ", keywords));
}
}
}
// 构建分类信息
var categoryInfo = await GetCategoryInfoAsync();
// 构建账单分组信息
var billsInfo = new StringBuilder();
foreach (var (group, index) in groupedRecords.Select((g, i) => (g, i)))
{
billsInfo.AppendLine($"{index + 1}. 摘要={group.Reason}, 当前类型={GetTypeName(group.SampleType)}, 当前分类={(string.IsNullOrEmpty(group.SampleType.ToString()) ? "" : group.SampleType.ToString())}, 涉及金额={group.TotalAmount}");
// 如果有相似的已分类账单,添加参考信息
if (referenceRecords.TryGetValue(group.Reason, out var references))
{
billsInfo.AppendLine(" 【参考】相似且已分类的账单:");
foreach (var refer in references.Take(3)) // 最多显示3个参考
{
billsInfo.AppendLine($" - 摘要={refer.Reason}, 分类={refer.Classify}, 类型={GetTypeName(refer.Type)}, 金额={refer.Amount}");
}
}
}
var systemPrompt = $$"""
你是一个专业的账单分类助手。请根据提供的账单分组信息和分类列表,为每个分组选择最合适的分类。
可用的分类列表:
{{categoryInfo}}
分类规则:
1. 根据账单的摘要和涉及金额,选择最匹配的分类
2. 如果提供了【参考】信息,优先参考相似账单的分类,这些是历史上已分类的相似账单
3. 如果无法确定分类,可以选择""
4.
- 使 NDJSON JSON
- JSON格式严格为
{
"reason": "交易摘要",
"type": Number, // 交易类型0=支出1=收入2=不计入收支
"classify": "分类名称"
}
-
- JSON对象
JSON对象NDJSON
""";
var userPrompt = $$"""
请为以下账单分组进行分类:
{{billsInfo}}
请逐个输出分类结果。
""";
// 流式调用AI
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>();
var sentIds = new HashSet<long>();
// 将流解析逻辑提取为本地函数以减少嵌套
void HandleResult(GroupClassifyResult? result)
{
if (result is null || string.IsNullOrEmpty(result.Reason)) return;
classifyResults.Add((result.Reason, result.Classify ?? string.Empty, result.Type));
var group = groupedRecords.FirstOrDefault(g => g.Reason == result.Reason);
if (group == null) return;
foreach (var id in group.Ids)
{
if (!sentIds.Add(id))
{
continue;
}
var resultJson = JsonSerializer.Serialize(new
{
id,
result.Classify,
result.Type
});
chunkAction(("data", resultJson));
}
}
// 解析缓冲区中的所有完整 JSON 对象或数组
void FlushBuffer(StringBuilder buffer)
{
var buf = buffer.ToString();
if (string.IsNullOrWhiteSpace(buf)) return;
// 优先尝试解析完整数组
var trimmed = buf.TrimStart();
if (trimmed.Length > 0 && trimmed[0] == '[')
{
var lastArrEnd = buf.LastIndexOf(']');
if (lastArrEnd > -1)
{
var arrJson = buf.Substring(0, lastArrEnd + 1);
try
{
var results = JsonSerializer.Deserialize<GroupClassifyResult[]>(arrJson);
if (results != null)
{
foreach (var r in results) HandleResult(r);
}
buffer.Remove(0, lastArrEnd + 1);
buf = buffer.ToString();
}
catch (Exception exArr)
{
logger.LogDebug(exArr, "按数组解析AI返回失败回退到逐对象解析。预览: {Preview}", arrJson.Length > 200 ? arrJson.Substring(0, 200) + "..." : arrJson);
}
}
}
// 逐对象解析
var startIdx = 0;
while (startIdx < buf.Length)
{
var openBrace = buf.IndexOf('{', startIdx);
if (openBrace == -1) break;
var closeBrace = FindMatchingBrace(buf, openBrace);
if (closeBrace == -1) break;
var jsonStr = buf.Substring(openBrace, closeBrace - openBrace + 1);
try
{
var result = JsonSerializer.Deserialize<GroupClassifyResult>(jsonStr);
HandleResult(result);
}
catch (Exception ex)
{
logger.LogWarning(ex, "解析AI分类结果失败: {JsonStr}", jsonStr.Length > 200 ? jsonStr.Substring(0, 200) + "..." : jsonStr);
}
startIdx = closeBrace + 1;
}
if (startIdx > 0)
{
buffer.Remove(0, startIdx);
}
}
var buffer = new StringBuilder();
await foreach (var chunk in openAiService.ChatStreamAsync(systemPrompt, userPrompt))
{
buffer.Append(chunk);
FlushBuffer(buffer);
}
// 如果AI流结束但没有任何分类结果发出错误提示
if (classifyResults.Count == 0)
{
logger.LogWarning("AI未返回任何分类结果buffer最终内容: {BufferPreview}", buffer.ToString().Length > 500 ? buffer.ToString().Substring(0, 500) + "..." : buffer.ToString());
chunkAction(("error", "智能分类未返回任何结果,请重试或手动分类"));
}
else
{
chunkAction(("end", "分类完成"));
}
}
catch (Exception ex)
{
logger.LogError(ex, "智能分类失败");
chunkAction(("error", $"智能分类失败: {ex.Message}"));
}
}
public async Task AnalyzeBillAsync(string userInput, Action<string> chunkAction)
{
try
{
// 构建分类信息
var categoryInfo = await GetCategoryInfoAsync();
// 第一步使用AI生成聚合SQL查询
var now = DateTime.Now;
var sqlPrompt = $$"""
当前日期:{{now:yyyy年M月d日}}{{now:yyyy-MM-dd}}
用户问题:{{userInput}}
数据库类型SQLite
数据库表名TransactionRecord
字段说明:
- Id: bigint 主键
- Card: nvarchar 卡号
- Reason: nvarchar 交易原因/摘要
- Amount: decimal 交易金额(支出为负数,收入为正数)
- OccurredAt: datetime 交易发生时间TEXT类型格式'2025-12-26 10:30:00'
- Type: int 交易类型0=支出, 1=收入, 2=不计入收支)
- Classify: nvarchar 交易分类(如:交通、餐饮、购物等)
【核心原则】直接生成用户所需的聚合统计SQL而不是查询原始记录后再统计
要求:
1. 根据用户问题判断需要什么维度的聚合数据
2. 使用 GROUP BY 按分类、时间等维度分组
3. 使用聚合函数SUM(ABS(Amount)) 计算金额总和、COUNT(*) 计数、AVG()平均、MAX()最大、MIN()最小
4. 时间范围使用 OccurredAt 字段,"X个月/"基于当前日期计算
5. Type = 0 Type = 1
6. TotalAmount, TransactionCount, AvgAmount
7. 使 ORDER BY
8. SQL语句
SQLite日期函数
- strftime('%Y', OccurredAt)
- strftime('%m', OccurredAt)
- strftime('%Y-%m-%d', OccurredAt)
- 使 YEAR()MONTH()DAY() SQLite不支持
SQL会被一下DOTNET代码执行,
```C#
public async Task<List<dynamic>> ExecuteDynamicSqlAsync(string completeSql)
{
var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql);
var result = new List<dynamic>();
foreach (System.Data.DataRow row in dt.Rows)
{
var expando = new System.Dynamic.ExpandoObject() as IDictionary<string, object>;
foreach (System.Data.DataColumn column in dt.Columns)
{
expando[column.ColumnName] = row[column];
}
result.Add(expando);
}
return result;
}
```
{{categoryInfo}}
SQL语句
""";
var sqlText = await openAiService.ChatAsync(sqlPrompt);
// 清理SQL文本
sqlText = sqlText?.Trim() ?? "";
sqlText = sqlText.TrimStart('`').TrimEnd('`');
if (sqlText.StartsWith("sql", StringComparison.OrdinalIgnoreCase))
{
sqlText = sqlText.Substring(3).Trim();
}
logger.LogInformation("AI生成的SQL: {Sql}", sqlText);
chunkAction(
JsonSerializer.Serialize(new
{
content = $"""
<pre style="max-height: 80px; font-size: 8px; overflow-y: auto; padding: 8px; border: 1px solid #3c3c3c">
{WebUtility.HtmlEncode(sqlText)}
</pre>
"""
})
);
// 第二步执行动态SQL查询
List<dynamic> queryResults;
try
{
queryResults = await transactionRepository.ExecuteDynamicSqlAsync(sqlText);
}
catch (Exception ex)
{
logger.LogError(ex, "执行AI生成的SQL失败: {Sql}", sqlText);
// 如果SQL执行失败返回错误
var errorData = JsonSerializer.Serialize(new { content = "<div class='error-message'>SQL执行失败请重新描述您的问题</div>" });
chunkAction(errorData);
return;
}
// 第三步将查询结果序列化为JSON直接传递给AI生成分析报告
var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
var dataPrompt = $"""
当前日期:{DateTime.Now:yyyy年M月d日}
用户问题:{userInput}
【用户要求(重要)】
{userInput}
查询结果数据JSON格式
{dataJson}
说明:以上数据是根据用户问题查询出的聚合统计结果,请基于这些数据生成分析报告。
请生成一份专业的数据分析报告,严格遵守以下要求:
【格式要求】
1. 使用HTML格式移动端H5页面风格
2. 生成清晰的报告标题(基于用户问题)
3. 使用表格展示统计数据table > thead/tbody > tr > th/td
4. 使用合适的HTML标签h2标题、h3小节、p段落、table表格、ul/li列表、strong强调
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
6. 收入金额用 <span class='income-value'>金额</span> 包裹
7. 重要结论用 <span class='highlight'>内容</span> 高亮
【样式限制(重要)】
8. 不要包含 html、body、head 标签
9. 不要使用任何 style 属性或 <style> 标签
10. 不要设置 background、background-color、color 等样式属性
11. 不要使用 div 包裹大段内容
【内容要求】
12. 准确解读数据将JSON数据转换为易读的表格和文字说明
13. 提供洞察分析:根据数据给出有价值的发现和趋势分析
14. 给出实用建议:基于数据提供合理的财务建议
15. 语言专业、清晰、简洁
【用户补充(重要)】
{userPromptExtra}
直接输出纯净的HTML内容不要markdown代码块标记。
""";
// 第四步流式输出AI分析结果
await foreach (var chunk in openAiService.ChatStreamAsync(dataPrompt))
{
var sseData = JsonSerializer.Serialize(new { content = chunk });
chunkAction(sseData);
}
// 发送完成标记
chunkAction("[DONE]");
}
catch (Exception ex)
{
logger.LogError(ex, "智能分析账单失败");
var errorData = JsonSerializer.Serialize(new { content = $"<div class='error-message'>分析失败:{ex.Message}</div>" });
chunkAction(errorData);
}
}
public async Task<TransactionParseResult?> ParseOneLineBillAsync(string text)
{
// 获取所有分类
var categories = await categoryRepository.GetAllAsync();
// 构建分类信息
var categoryInfo = new StringBuilder();
foreach (var type in new[] { 0, 1, 2 })
{
var typeName = GetTypeName((TransactionType)type);
categoryInfo.AppendLine($"{typeName}: ");
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
foreach (var category in categoriesOfType)
{
categoryInfo.AppendLine($"- {category.Name}");
}
}
var sysPrompt = $$"""
你是一个智能账单解析助手。请从用户提供的文本中提取交易信息,包括日期、金额、摘要、类型和分类。
可用的分类列表:
{{categoryInfo}}
请返回 JSON 格式,包含以下字段:
- OccurredAt: 日期时间,格式 yyyy-MM-dd HH:mm:ss。当前系统时间为{{DateTime.Now:yyyy-MM-dd HH:mm:ss}}。
- Amount: 金额,数字。
- Reason: 备注/摘要,原文或其他补充信息。
- Type: 交易类型0=支出1=收入2=不计入收支。根据语义判断。
- Classify: 分类,请从以下现有分类中选择最匹配的一个:如果无法匹配,请留空。
返回示例
{
"OccurredAt": "2024-06-15 14:30:00",
"Amount": 150.75,
"Reason": "午餐消费",
"Type": 0,
"Classify": "餐饮"
}
JSON markdown
""";
var json = await openAiService.ChatAsync(sysPrompt, text);
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
// 清理可能的 markdown 标记
json = json.Replace("```json", "").Replace("```", "").Trim();
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
return JsonSerializer.Deserialize<TransactionParseResult>(json, options);
}
catch (Exception ex)
{
logger.LogError(ex, "解析账单失败");
return null;
}
}
/// <summary>
/// 查找匹配的右括号
/// </summary>
private static int FindMatchingBrace(string str, int startPos)
{
var braceCount = 0;
for (var i = startPos; i < str.Length; i++)
{
if (str[i] == '{') braceCount++;
else if (str[i] == '}')
{
braceCount--;
if (braceCount == 0) return i;
}
}
return -1;
}
private static string GetTypeName(TransactionType type)
{
return type switch
{
TransactionType.Expense => "支出",
TransactionType.Income => "收入",
TransactionType.None => "不计入收支",
_ => "未知"
};
}
/// <summary>
/// 清理 AI 响应中的 markdown 代码块标记
/// </summary>
private static string CleanMarkdownCodeBlock(string response)
{
var cleaned = response?.Trim() ?? string.Empty;
if (cleaned.StartsWith("```"))
{
// 移除开头的 ```json 或 ```
var firstNewLine = cleaned.IndexOf('\n');
if (firstNewLine > 0)
{
cleaned = cleaned.Substring(firstNewLine + 1);
}
// 移除结尾的 ```
if (cleaned.EndsWith("```"))
{
cleaned = cleaned.Substring(0, cleaned.Length - 3);
}
cleaned = cleaned.Trim();
}
return cleaned;
}
private async Task<string> GetCategoryInfoAsync()
{
// 获取所有分类
var categories = await categoryRepository.GetAllAsync();
// 构建分类信息
var categoryInfo = new StringBuilder();
foreach (var type in new[] { 0, 1, 2 })
{
var typeName = GetTypeName((TransactionType)type);
categoryInfo.AppendLine($"{typeName}: ");
var categoriesOfType = categories.Where(c => (int)c.Type == type).ToList();
foreach (var category in categoriesOfType)
{
categoryInfo.AppendLine($"- {category.Name}");
}
}
return categoryInfo.ToString();
}
/// <summary>
/// 从邮件正文中使用AI提取交易记录AI兜底方案
/// </summary>
public async Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailByAiAsync(string emailBody)
{
var systemPrompt = $"""
你是一个信息抽取助手。
仅输出严格的JSON数组不要包含任何多余文本。
每个交易记录包含字段: card(字符串), reason(字符串), amount(数字), balance(数字), type(字符串,值为'收入'或'支出'), occurredAt(字符串yyyy-MM-dd HH:mm:ss格式日期时间)。
如果缺失,请推断或置空。
[重要] 当前时间为{DateTime.Now:yyyy-MM-dd HH:mm:ss},请根据当前时间推断交易发生的时间。
""";
var userPrompt = $"""
从下面这封银行账单相关邮件正文中提取所有交易记录返回JSON数组格式
每个元素包含: card, reason, amount, balance, type(收入或支出), occurredAt(非必要)。
正文如下:\n\n{emailBody}
""";
var contentText = await openAiService.ChatAsync(systemPrompt, userPrompt);
if (string.IsNullOrWhiteSpace(contentText))
{
logger.LogWarning("AI未返回任何内容无法解析邮件");
return null;
}
logger.LogDebug("AI返回的内容: {Content}", contentText);
// 清理可能的 markdown 代码块标记
contentText = contentText.Trim();
if (contentText.StartsWith("```"))
{
// 移除开头的 ```json 或 ```
var firstNewLine = contentText.IndexOf('\n');
if (firstNewLine > 0)
{
contentText = contentText.Substring(firstNewLine + 1);
}
// 移除结尾的 ```
if (contentText.EndsWith("```"))
{
contentText = contentText.Substring(0, contentText.Length - 3);
}
contentText = contentText.Trim();
}
// contentText 期望是 JSON 数组
using var jsonDoc = JsonDocument.Parse(contentText);
var arrayElement = jsonDoc.RootElement;
// 如果返回的是单个对象而不是数组,尝试兼容处理
if (arrayElement.ValueKind == JsonValueKind.Object)
{
logger.LogWarning("AI返回的内容是单个对象而非数组尝试兼容处理");
var result = ParseEmailSingleRecord(arrayElement);
return result != null ? [result.Value] : null;
}
if (arrayElement.ValueKind != JsonValueKind.Array)
{
logger.LogWarning("AI返回的内容不是JSON数组无法解析邮件");
return null;
}
var results = new List<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)>();
foreach (var obj in arrayElement.EnumerateArray())
{
var record = ParseEmailSingleRecord(obj);
if (record != null)
{
logger.LogInformation("解析到一条交易记录: {@Record}", record.Value);
results.Add(record.Value);
}
}
logger.LogInformation("使用AI成功解析邮件内容提取到 {Count} 条交易记录", results.Count);
return results.Count > 0 ? results.ToArray() : null;
}
/// <summary>
/// 为分类生成多个SVG图标定时任务使用
/// </summary>
public async Task<List<string>?> GenerateCategoryIconsAsync(string categoryName, TransactionType categoryType, int iconCount = 5)
{
logger.LogInformation("正在为分类 {CategoryName} 生成 {IconCount} 个图标", categoryName, iconCount);
var systemPrompt = iconPromptProvider.GetPrompt(categoryName, categoryType);
var response = await openAiService.ChatAsync(systemPrompt, "", timeoutSeconds: 60 * 10);
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", categoryName);
return null;
}
// 清理可能的 markdown 代码块标记
response = CleanMarkdownCodeBlock(response);
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("清理后的 AI 响应为空,分类: {CategoryName}", categoryName);
return null;
}
// 验证返回的是有效的 JSON 数组
try
{
var icons = JsonSerializer.Deserialize<List<string>>(response);
if (icons == null || icons.Count != iconCount)
{
logger.LogWarning("AI 返回的图标数量不正确(期望{IconCount}个),分类: {CategoryName}", iconCount, categoryName);
return null;
}
logger.LogInformation("成功为分类 {CategoryName} 生成了 {IconCount} 个图标", categoryName, iconCount);
return icons;
}
catch (JsonException ex)
{
logger.LogError(ex, "解析 AI 返回的图标数据失败,分类: {CategoryName},响应内容: {Response}",
categoryName, response);
return null;
}
}
/// <summary>
/// 为分类生成单个SVG图标手动触发使用
/// </summary>
public async Task<string?> GenerateSingleCategoryIconAsync(string categoryName, TransactionType categoryType)
{
logger.LogInformation("正在为分类 {CategoryName} 生成单个图标", categoryName);
// 使用单个图标生成的 Prompt只生成 1 个图标,加快速度)
var systemPrompt = iconPromptProvider.GetSingleIconPrompt(categoryName, categoryType);
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// 增加超时时间到 180 秒3 分钟)
var response = await openAiService.ChatAsync(systemPrompt, "", timeoutSeconds: 180);
stopwatch.Stop();
logger.LogInformation("AI 响应耗时: {ElapsedMs}ms分类: {CategoryName}", stopwatch.ElapsedMilliseconds, categoryName);
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", categoryName);
return null;
}
// 清理可能的 markdown 代码块标记
response = CleanMarkdownCodeBlock(response);
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("清理后的 AI 响应为空,分类: {CategoryName}", categoryName);
return null;
}
// 解析返回的 JSON 数组,取第一个图标
try
{
var icons = JsonSerializer.Deserialize<List<string>>(response);
if (icons == null || icons.Count == 0)
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", categoryName);
return null;
}
var svg = icons[0];
logger.LogInformation("成功为分类 {CategoryName} 生成单个图标,总耗时: {ElapsedMs}ms", categoryName, stopwatch.ElapsedMilliseconds);
return svg;
}
catch (JsonException ex)
{
logger.LogError(ex, "解析 AI 返回的图标数据失败,分类: {CategoryName},响应内容: {Response}",
categoryName, response.Length > 500 ? response.Substring(0, 500) + "..." : response);
return null;
}
}
catch (TimeoutException)
{
stopwatch.Stop();
logger.LogError("AI 请求超时(>180秒分类: {CategoryName},已等待: {ElapsedMs}ms", categoryName, stopwatch.ElapsedMilliseconds);
throw;
}
catch (Exception ex)
{
stopwatch.Stop();
logger.LogError(ex, "AI 调用失败,分类: {CategoryName},耗时: {ElapsedMs}ms", categoryName, stopwatch.ElapsedMilliseconds);
throw;
}
}
/// <summary>
/// 生成预算执行报告HTML格式
/// </summary>
public async Task<string?> GenerateBudgetReportAsync(string promptWithData, int year, int month)
{
logger.LogInformation("正在生成预算执行报告: {Year}年{Month}月", year, month);
// 直接使用传入的完整prompt包含数据和格式要求
var htmlReport = await openAiService.ChatAsync(promptWithData);
if (string.IsNullOrWhiteSpace(htmlReport))
{
logger.LogWarning("AI 未返回有效的报告内容");
return null;
}
logger.LogInformation("成功生成预算执行报告: {Year}年{Month}月", year, month);
return htmlReport;
}
private (string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)? ParseEmailSingleRecord(JsonElement obj)
{
var card = obj.TryGetProperty("card", out var pCard) ? pCard.GetString() ?? string.Empty : string.Empty;
var reason = obj.TryGetProperty("reason", out var pReason) ? pReason.GetString() ?? string.Empty : string.Empty;
var typeStr = obj.TryGetProperty("type", out var pType) ? pType.GetString() ?? string.Empty : string.Empty;
var occurredAtStr = obj.TryGetProperty("occurredAt", out var pOccurredAt) ? pOccurredAt.GetString() ?? string.Empty : string.Empty;
var amount = 0m;
if (obj.TryGetProperty("amount", out var pAmount))
{
if (pAmount.ValueKind == JsonValueKind.Number && pAmount.TryGetDecimal(out var d)) amount = d;
else if (pAmount.ValueKind == JsonValueKind.String && decimal.TryParse(pAmount.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds)) amount = ds;
}
var balance = 0m;
if (obj.TryGetProperty("balance", out var pBalance))
{
if (pBalance.ValueKind == JsonValueKind.Number && pBalance.TryGetDecimal(out var d2)) balance = d2;
else if (pBalance.ValueKind == JsonValueKind.String && decimal.TryParse(pBalance.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var ds2)) balance = ds2;
}
if (string.IsNullOrWhiteSpace(card) || string.IsNullOrWhiteSpace(reason))
{
return null;
}
var occurredAt = (DateTime?)null;
if (DateTime.TryParse(occurredAtStr, out var occurredAtValue))
{
occurredAt = occurredAtValue;
}
var type = DetermineTransactionType(typeStr, reason, amount);
return (card, reason, amount, balance, type, occurredAt);
}
/// <summary>
/// 判断交易类型
/// </summary>
private TransactionType DetermineTransactionType(string typeStr, string reason, decimal amount)
{
// 优先使用明确的类型字符串
if (!string.IsNullOrWhiteSpace(typeStr))
{
if (typeStr.Contains("收入") || typeStr.Contains("income") || typeStr.Equals("收", StringComparison.OrdinalIgnoreCase))
return TransactionType.Income;
if (typeStr.Contains("支出") || typeStr.Contains("expense") || typeStr.Equals("支", StringComparison.OrdinalIgnoreCase))
return TransactionType.Expense;
}
// 根据交易原因中的关键词判断
var lowerReason = reason.ToLower();
// 收入关键词
string[] incomeKeywords =
[
"工资", "奖金", "退款",
"返现", "收入", "转入",
"存入", "利息", "分红",
"入账", "收款",
// 常见扩展
"实发工资", "薪资", "薪水", "薪酬",
"提成", "佣金", "劳务费",
"报销入账", "报销款", "补贴", "补助",
"退款成功", "退回", "退货退款",
"返现入账", "返利", "返佣",
"到账", "已到账", "入账成功",
"收款成功", "收到款项", "到账金额",
"资金转入", "资金收入",
"转账收入", "转账入账", "他行来账",
"工资代发", "代发工资", "单位打款",
"利息收入", "收益", "收益发放", "理财收益",
"分红收入", "股息", "红利",
// 平台常用词
"红包", "红包收入", "红包入账",
"奖励金", "活动奖励", "补贴金",
"现金奖励", "推广奖励", "返现奖励",
// 存取类
"现金存入", "柜台存入", "ATM存入",
"他人转入", "他人汇入"
];
if (incomeKeywords.Any(k => lowerReason.Contains(k)))
return TransactionType.Income;
// 支出关键词
string[] expenseKeywords =
[
"消费", "支付", "购买",
"转出", "取款", "支出",
"扣款", "缴费", "付款",
"刷卡",
// 常见扩展
"支出金额", "支出人民币", "已支出",
"已消费", "消费支出", "消费人民币",
"已支付", "成功支付", "支付成功", "交易支付",
"已扣款", "扣款成功", "扣费", "扣费成功",
"转账", "转账支出", "向外转账", "已转出",
"提现", "现金支出", "现金取款",
"扣除", "扣除金额", "记账支出",
// 账单/通知类用语
"本期应还", "本期应还金额", "本期账单金额",
"本期应还人民币", "最低还款额",
"本期欠款", "欠款金额",
// 线上平台常见用语
"订单支付", "订单扣款", "订单消费",
"交易支出", "交易扣款", "交易成功支出",
"话费充值", "流量充值", "水费", "电费", "燃气费",
"物业费", "服务费", "手续费", "年费", "会费",
"利息支出", "还款支出", "代扣", "代缴",
// 信用卡/花呗等场景
"信用卡还款", "花呗还款", "白条还款",
"分期还款", "账单还款", "自动还款"
];
if (expenseKeywords.Any(k => lowerReason.Contains(k)))
return TransactionType.Expense;
// 根据金额正负判断(如果金额为负数,可能是支出)
if (amount < 0)
return TransactionType.Expense;
if (amount > 0)
return TransactionType.Income;
// 默认为支出
return TransactionType.Expense;
}
}
/// <summary>
/// 分组分类结果DTO用于AI返回结果解析
/// </summary>
public record GroupClassifyResult
{
[JsonPropertyName("reason")]
public string Reason { get; init; } = string.Empty;
[JsonPropertyName("classify")]
public string? Classify { get; init; }
[JsonPropertyName("type")]
public TransactionType Type { get; init; }
}
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);