Files
EmailBill/Service/AI/SmartHandleService.cs

960 lines
39 KiB
C#
Raw Normal View History

using Service.Transaction;
namespace Service.AI;
2025-12-30 18:49:46 +08:00
public interface ISmartHandleService
{
2025-12-31 11:10:10 +08:00
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction);
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
Task<TransactionParseResult?> ParseOneLineBillAsync(string text);
2026-02-10 17:49:19 +08:00
/// <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);
2025-12-30 18:49:46 +08:00
}
public class SmartHandleService(
ITransactionRecordRepository transactionRepository,
2026-01-28 10:58:15 +08:00
ITransactionStatisticsService transactionStatisticsService,
2025-12-30 18:49:46 +08:00
ITextSegmentService textSegmentService,
ILogger<SmartHandleService> logger,
ITransactionCategoryRepository categoryRepository,
IOpenAiService openAiService,
2026-02-15 10:10:28 +08:00
IConfigService configService,
IClassificationIconPromptProvider iconPromptProvider
2025-12-30 18:49:46 +08:00
) : ISmartHandleService
{
2025-12-31 11:10:10 +08:00
public async Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> chunkAction)
2025-12-30 18:49:46 +08:00
{
try
{
// 获取指定ID的账单作为样本
var sampleRecords = await transactionRepository.GetByIdsAsync(transactionIds);
2026-01-02 18:51:28 +08:00
sampleRecords = sampleRecords
.Where(x => string.IsNullOrEmpty(x.Classify))
.ToArray();
2025-12-30 18:49:46 +08:00
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%的关键词才被认为是相似的
2026-01-28 10:58:15 +08:00
var similarClassifiedWithScore = await transactionStatisticsService.GetClassifiedByKeywordsWithScoreAsync(keywords, minMatchRate: 0.4, limit: 10);
2025-12-30 18:49:46 +08:00
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();
2025-12-30 18:49:46 +08:00
// 构建账单分组信息
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 = $$"""
2026-01-01 12:32:08 +08:00
2025-12-30 18:49:46 +08:00
2026-01-01 12:32:08 +08:00
{{categoryInfo}}
2025-12-30 18:49:46 +08:00
2026-01-01 12:32:08 +08:00
1.
2.
3. "其他"
4.
2025-12-30 18:49:46 +08:00
2026-01-01 12:32:08 +08:00
- 使 NDJSON JSON
- JSON格式严格为
{
"reason": "交易摘要",
"type": Number, // 交易类型0=支出1=收入2=不计入收支
"classify": "分类名称"
}
2026-01-01 12:32:08 +08:00
-
- JSON对象
2025-12-30 18:49:46 +08:00
2026-01-01 12:32:08 +08:00
JSON对象NDJSON
""";
2025-12-30 18:49:46 +08:00
var userPrompt = $$"""
2026-01-01 12:32:08 +08:00
2025-12-30 18:49:46 +08:00
2026-01-01 12:32:08 +08:00
{{billsInfo}}
2025-12-30 18:49:46 +08:00
2026-01-01 12:32:08 +08:00
""";
2025-12-30 18:49:46 +08:00
// 流式调用AI
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>();
2026-01-18 22:04:56 +08:00
var sentIds = new HashSet<long>();
2025-12-30 18:49:46 +08:00
2026-01-01 11:58:21 +08:00
// 将流解析逻辑提取为本地函数以减少嵌套
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)
2025-12-30 18:49:46 +08:00
{
2026-01-18 22:04:56 +08:00
if (!sentIds.Add(id))
2026-01-01 11:58:21 +08:00
{
2026-01-18 22:04:56 +08:00
continue;
2026-01-01 11:58:21 +08:00
}
2026-01-18 22:04:56 +08:00
var resultJson = JsonSerializer.Serialize(new
{
id,
result.Classify,
result.Type
});
chunkAction(("data", resultJson));
2026-01-01 11:58:21 +08:00
}
}
2025-12-30 18:49:46 +08:00
2026-01-01 11:58:21 +08:00
// 解析缓冲区中的所有完整 JSON 对象或数组
void FlushBuffer(StringBuilder buffer)
{
var buf = buffer.ToString();
if (string.IsNullOrWhiteSpace(buf)) return;
2025-12-30 18:49:46 +08:00
2026-01-01 11:58:21 +08:00
// 优先尝试解析完整数组
var trimmed = buf.TrimStart();
if (trimmed.Length > 0 && trimmed[0] == '[')
{
var lastArrEnd = buf.LastIndexOf(']');
if (lastArrEnd > -1)
2025-12-30 18:49:46 +08:00
{
2026-01-01 11:58:21 +08:00
var arrJson = buf.Substring(0, lastArrEnd + 1);
try
2025-12-30 18:49:46 +08:00
{
2026-01-01 11:58:21 +08:00
var results = JsonSerializer.Deserialize<GroupClassifyResult[]>(arrJson);
if (results != null)
2025-12-30 18:49:46 +08:00
{
2026-01-01 11:58:21 +08:00
foreach (var r in results) HandleResult(r);
2025-12-30 18:49:46 +08:00
}
2026-01-01 11:58:21 +08:00
buffer.Remove(0, lastArrEnd + 1);
buf = buffer.ToString();
2025-12-30 18:49:46 +08:00
}
2026-01-01 11:58:21 +08:00
catch (Exception exArr)
{
2026-01-18 22:04:56 +08:00
logger.LogDebug(exArr, "按数组解析AI返回失败回退到逐对象解析。预览: {Preview}", arrJson.Length > 200 ? arrJson.Substring(0, 200) + "..." : arrJson);
2026-01-01 11:58:21 +08:00
}
}
}
// 逐对象解析
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);
2025-12-30 18:49:46 +08:00
}
catch (Exception ex)
{
2026-01-01 11:58:21 +08:00
logger.LogWarning(ex, "解析AI分类结果失败: {JsonStr}", jsonStr.Length > 200 ? jsonStr.Substring(0, 200) + "..." : jsonStr);
2025-12-30 18:49:46 +08:00
}
startIdx = closeBrace + 1;
}
2026-01-01 11:58:21 +08:00
if (startIdx > 0)
{
buffer.Remove(0, startIdx);
}
2025-12-30 18:49:46 +08:00
}
2026-01-01 11:58:21 +08:00
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", "分类完成"));
}
2025-12-30 18:49:46 +08:00
}
catch (Exception ex)
{
logger.LogError(ex, "智能分类失败");
chunkAction(("error", $"智能分类失败: {ex.Message}"));
}
}
2025-12-31 11:10:10 +08:00
public async Task AnalyzeBillAsync(string userInput, Action<string> chunkAction)
{
try
{
// 构建分类信息
var categoryInfo = await GetCategoryInfoAsync();
2025-12-31 11:10:10 +08:00
// 第一步使用AI生成聚合SQL查询
var now = DateTime.Now;
var sqlPrompt = $$"""
{{now:yyyy年M月d日}}{{now:yyyy-MM-dd}}
{{userInput}}
2026-01-01 12:32:08 +08:00
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}}
2026-01-01 12:32:08 +08:00
SQL语句
""";
2025-12-31 11:10:10 +08:00
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">
2026-01-18 22:04:56 +08:00
{WebUtility.HtmlEncode(sqlText)}
</pre>
"""
})
);
2025-12-31 11:10:10 +08:00
// 第二步执行动态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,
2026-01-18 22:04:56 +08:00
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
2025-12-31 11:10:10 +08:00
});
var userPromptExtra = await configService.GetConfigByKeyAsync<string>("BillAnalysisPrompt");
2025-12-31 11:10:10 +08:00
var dataPrompt = $"""
2026-01-01 12:32:08 +08:00
{DateTime.Now:yyyy年M月d日}
{userInput}
{userInput}
2026-01-01 12:32:08 +08:00
JSON格式
{dataJson}
1. 使HTML格式H5页面风格
2.
3. 使table > thead/tbody > tr > th/td
4. 使HTML标签h2h3ptableul/listrong
5. <span class='expense-value'></span>
6. <span class='income-value'></span>
7. <span class='highlight'></span>
8. htmlbodyhead
9. 使 style <style>
10. backgroundbackground-colorcolor
11. 使 div
12. JSON数据转换为易读的表格和文字说明
13.
14.
15.
{userPromptExtra}
2026-01-01 12:32:08 +08:00
HTML内容markdown代码块标记
""";
2025-12-31 11:10:10 +08:00
// 第四步流式输出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)
{
2026-01-28 17:00:58 +08:00
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 => "不计入收支",
_ => "未知"
};
}
2026-02-15 10:10:28 +08:00
/// <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();
}
2026-02-10 17:49:19 +08:00
/// <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);
2026-02-15 10:10:28 +08:00
var systemPrompt = iconPromptProvider.GetPrompt(categoryName, categoryType);
2026-02-10 17:49:19 +08:00
2026-02-15 10:10:28 +08:00
var response = await openAiService.ChatAsync(systemPrompt, "", timeoutSeconds: 60 * 10);
2026-02-10 17:49:19 +08:00
2026-02-15 10:10:28 +08:00
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", categoryName);
return null;
}
2026-02-10 17:49:19 +08:00
2026-02-15 10:10:28 +08:00
// 清理可能的 markdown 代码块标记
response = CleanMarkdownCodeBlock(response);
2026-02-10 17:49:19 +08:00
if (string.IsNullOrWhiteSpace(response))
{
2026-02-15 10:10:28 +08:00
logger.LogWarning("清理后的 AI 响应为空,分类: {CategoryName}", categoryName);
2026-02-10 17:49:19 +08:00
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);
2026-02-15 10:10:28 +08:00
// 使用单个图标生成的 Prompt只生成 1 个图标,加快速度)
var systemPrompt = iconPromptProvider.GetSingleIconPrompt(categoryName, categoryType);
2026-02-10 17:49:19 +08:00
2026-02-15 10:10:28 +08:00
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
try
{
// 增加超时时间到 180 秒3 分钟)
var response = await openAiService.ChatAsync(systemPrompt, "", timeoutSeconds: 180);
2026-02-10 17:49:19 +08:00
2026-02-15 10:10:28 +08:00
stopwatch.Stop();
logger.LogInformation("AI 响应耗时: {ElapsedMs}ms分类: {CategoryName}", stopwatch.ElapsedMilliseconds, categoryName);
2026-02-10 17:49:19 +08:00
2026-02-15 10:10:28 +08:00
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("AI 未返回有效的图标数据,分类: {CategoryName}", categoryName);
return null;
}
2026-02-10 17:49:19 +08:00
2026-02-15 10:10:28 +08:00
// 清理可能的 markdown 代码块标记
response = CleanMarkdownCodeBlock(response);
if (string.IsNullOrWhiteSpace(response))
{
logger.LogWarning("清理后的 AI 响应为空,分类: {CategoryName}", categoryName);
return null;
}
2026-02-10 17:49:19 +08:00
2026-02-15 10:10:28 +08:00
// 解析返回的 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)
2026-02-10 17:49:19 +08:00
{
2026-02-15 10:10:28 +08:00
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;
2026-02-10 17:49:19 +08:00
}
}
/// <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;
}
2025-12-30 18:49:46 +08:00
}
/// <summary>
/// 分组分类结果DTO用于AI返回结果解析
/// </summary>
public record GroupClassifyResult
{
[JsonPropertyName("reason")]
2026-01-18 22:04:56 +08:00
public string Reason { get; init; } = string.Empty;
2025-12-30 18:49:46 +08:00
[JsonPropertyName("classify")]
2026-01-18 22:04:56 +08:00
public string? Classify { get; init; }
2025-12-30 18:49:46 +08:00
[JsonPropertyName("type")]
2026-01-18 22:04:56 +08:00
public TransactionType Type { get; init; }
2025-12-30 18:49:46 +08:00
}
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);