using Service.Transaction; namespace Service.AI; public interface ISmartHandleService { Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction); Task AnalyzeBillAsync(string userInput, Action chunkAction); Task ParseOneLineBillAsync(string text); /// /// 从邮件正文中使用AI提取交易记录(AI兜底方案) /// Task<(string card, string reason, decimal amount, decimal balance, TransactionType type, DateTime? occurredAt)[]?> ParseEmailByAiAsync(string emailBody); /// /// 为分类生成多个SVG图标(定时任务使用) /// Task?> GenerateCategoryIconsAsync(string categoryName, TransactionType categoryType, int iconCount = 5); /// /// 为分类生成单个SVG图标(手动触发使用) /// Task GenerateSingleCategoryIconAsync(string categoryName, TransactionType categoryType); /// /// 生成预算执行报告(HTML格式) /// /// 完整的Prompt(包含数据和格式要求) /// 年份 /// 月份 Task GenerateBudgetReportAsync(string promptWithData, int year, int month); } public class SmartHandleService( ITransactionRecordRepository transactionRepository, ITransactionStatisticsService transactionStatisticsService, ITextSegmentService textSegmentService, ILogger 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>(); 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(); // 将流解析逻辑提取为本地函数以减少嵌套 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(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(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 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> ExecuteDynamicSqlAsync(string completeSql) { var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql); var result = new List(); foreach (System.Data.DataRow row in dt.Rows) { var expando = new System.Dynamic.ExpandoObject() as IDictionary; 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 = $"""
                    {WebUtility.HtmlEncode(sqlText)}
                    
""" }) ); // 第二步:执行动态SQL查询 List queryResults; try { queryResults = await transactionRepository.ExecuteDynamicSqlAsync(sqlText); } catch (Exception ex) { logger.LogError(ex, "执行AI生成的SQL失败: {Sql}", sqlText); // 如果SQL执行失败,返回错误 var errorData = JsonSerializer.Serialize(new { content = "
SQL执行失败,请重新描述您的问题
" }); chunkAction(errorData); return; } // 第三步:将查询结果序列化为JSON,直接传递给AI生成分析报告 var dataJson = JsonSerializer.Serialize(queryResults, new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); var userPromptExtra = await configService.GetConfigByKeyAsync("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. 支出金额用 金额 包裹 6. 收入金额用 金额 包裹 7. 重要结论用 内容 高亮 【样式限制(重要)】 8. 不要包含 html、body、head 标签 9. 不要使用任何 style 属性或