代码优化
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s

This commit is contained in:
2025-12-31 11:10:10 +08:00
parent 4b322494ba
commit e7d5c076d4
7 changed files with 259 additions and 406 deletions

View File

@@ -0,0 +1,392 @@
namespace Service;
public interface ISmartHandleService
{
Task SmartClassifyAsync(long[] transactionIds, Action<(string type, string data)> chunkAction);
Task AnalyzeBillAsync(string userInput, Action<string> chunkAction);
}
public class SmartHandleService(
ITransactionRecordRepository transactionRepository,
ITextSegmentService textSegmentService,
ILogger<SmartHandleService> logger,
ITransactionCategoryRepository categoryRepository,
IOpenAiService openAiService
) : ISmartHandleService
{
public async Task SmartClassifyAsync(long[] transactionIds, Action<(string, string)> chunkAction)
{
try
{
// 获取指定ID的账单作为样本
var sampleRecords = await transactionRepository.GetByIdsAsync(transactionIds);
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 transactionRepository.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 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 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.
{"reason": "交易摘要", "type": 0:/1:/2:(Type为Number枚举值) ,"classify": "分类名称"}
JSON
""";
var userPrompt = $$"""
请为以下账单分组进行分类:
{{billsInfo}}
请逐个输出分类结果。
""";
// 流式调用AI
chunkAction(("start", $"开始分类,共 {sampleRecords.Length} 条账单"));
// 用于存储AI返回的分组分类结果
var classifyResults = new List<(string Reason, string Classify, TransactionType Type)>();
var buffer = new StringBuilder();
var sendedIds = new HashSet<long>();
await foreach (var chunk in openAiService.ChatStreamAsync(systemPrompt, userPrompt))
{
buffer.Append(chunk);
// 尝试解析完整的JSON对象
var bufferStr = buffer.ToString();
var startIdx = 0;
while (startIdx < bufferStr.Length)
{
var openBrace = bufferStr.IndexOf('{', startIdx);
if (openBrace == -1) break;
var closeBrace = FindMatchingBrace(bufferStr, openBrace);
if (closeBrace == -1) break;
var jsonStr = bufferStr.Substring(openBrace, closeBrace - openBrace + 1);
try
{
var result = JsonSerializer.Deserialize<GroupClassifyResult>(jsonStr);
if (result != null && !string.IsNullOrEmpty(result.Reason))
{
classifyResults.Add((result.Reason, result.Classify ?? "", result.Type));
// 每一条结果单独通知
var group = groupedRecords.FirstOrDefault(g => g.Reason == result.Reason);
if (group != null)
{
// 为该分组的所有账单ID返回分类结果
foreach (var id in group.Ids)
{
if (!sendedIds.Contains(id))
{
sendedIds.Add(id);
var resultJson = JsonSerializer.Serialize(new { id, result.Classify, result.Type });
chunkAction(("data", resultJson));
}
}
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "解析AI分类结果失败: {JsonStr}", jsonStr);
}
startIdx = closeBrace + 1;
}
}
chunkAction(("end", "分类完成"));
}
catch (Exception ex)
{
logger.LogError(ex, "智能分类失败");
chunkAction(("error", $"智能分类失败: {ex.Message}"));
}
}
public async Task AnalyzeBillAsync(string userInput, Action<string> chunkAction)
{
try
{
// 第一步使用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不支持
1
SELECT Classify, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-10-01' AND OccurredAt < '2026-01-01' AND (Classify LIKE '%%' OR Reason LIKE '%%' OR Reason LIKE '%%' OR Reason LIKE '%%') GROUP BY Classify ORDER BY TotalAmount DESC
2
SELECT strftime('%Y', OccurredAt) as Year, strftime('%m', OccurredAt) as Month, COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-06-01' GROUP BY strftime('%Y', OccurredAt), strftime('%m', OccurredAt) ORDER BY Year, Month
3
SELECT COUNT(*) as TransactionCount, SUM(ABS(Amount)) as TotalAmount, AVG(ABS(Amount)) as AvgAmount, MAX(ABS(Amount)) as MaxAmount FROM TransactionRecord WHERE Type = 0 AND OccurredAt >= '2025-12-01' AND OccurredAt < '2026-01-01'
4 - 使
1000
SELECT OccurredAt, Classify, Reason, ABS(Amount) as Amount FROM TransactionRecord WHERE Type = 0 AND ABS(Amount) > 1000 ORDER BY Amount DESC LIMIT 50
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);
// 第二步执行动态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 = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
var dataPrompt = $"""
当前日期:{DateTime.Now:yyyy年M月d日}
用户问题:{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. 语言专业、清晰、简洁
直接输出纯净的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);
}
}
/// <summary>
/// 查找匹配的右括号
/// </summary>
private static int FindMatchingBrace(string str, int startPos)
{
int braceCount = 0;
for (int 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>
/// 分组分类结果DTO用于AI返回结果解析
/// </summary>
public record GroupClassifyResult
{
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
[JsonPropertyName("classify")]
public string? Classify { get; set; }
[JsonPropertyName("type")]
public TransactionType Type { get; set; }
}