2026-01-28 11:19:23 +08:00
|
|
|
|
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);
|
2026-01-01 14:43:43 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-01-05 15:21:13 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 构建分类信息
|
2026-01-05 15:51:47 +08:00
|
|
|
|
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 对象,末尾以换行符分隔),不要输出数组。
|
2026-01-11 11:21:13 +08:00
|
|
|
|
- 每行的JSON格式严格为:
|
|
|
|
|
|
{
|
|
|
|
|
|
"reason": "交易摘要",
|
|
|
|
|
|
"type": Number, // 交易类型,0=支出,1=收入,2=不计入收支
|
|
|
|
|
|
"classify": "分类名称"
|
|
|
|
|
|
}
|
2026-01-01 12:32:08 +08:00
|
|
|
|
- 不要输出任何解释性文字、编号、标点或多余的文本
|
2026-01-11 11:21:13 +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
|
|
|
|
|
|
{
|
2026-01-05 15:51:47 +08:00
|
|
|
|
// 构建分类信息
|
|
|
|
|
|
var categoryInfo = await GetCategoryInfoAsync();
|
|
|
|
|
|
|
2025-12-31 11:10:10 +08:00
|
|
|
|
// 第一步:使用AI生成聚合SQL查询
|
|
|
|
|
|
var now = DateTime.Now;
|
2026-01-05 15:21:13 +08:00
|
|
|
|
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不支持
|
|
|
|
|
|
|
2026-01-05 15:21:13 +08:00
|
|
|
|
【重要】最终的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;
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-01-05 17:13:35 +08:00
|
|
|
|
【重要】必须从以下分类列表中选择分类:
|
2026-01-05 15:51:47 +08:00
|
|
|
|
{{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);
|
|
|
|
|
|
|
2026-01-05 15:35:57 +08:00
|
|
|
|
chunkAction(
|
2026-01-05 15:57:22 +08:00
|
|
|
|
JsonSerializer.Serialize(new
|
|
|
|
|
|
{
|
|
|
|
|
|
content = $"""
|
2026-01-05 16:20:21 +08:00
|
|
|
|
<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)}
|
2026-01-05 15:57:22 +08:00
|
|
|
|
</pre>
|
|
|
|
|
|
"""
|
|
|
|
|
|
})
|
2026-01-05 15:35:57 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
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
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-05 15:21:13 +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}
|
|
|
|
|
|
|
2026-01-05 15:21:13 +08:00
|
|
|
|
【用户要求(重要)】
|
|
|
|
|
|
{userInput}
|
|
|
|
|
|
|
2026-01-01 12:32:08 +08:00
|
|
|
|
查询结果数据(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. 语言专业、清晰、简洁
|
2026-01-05 15:21:13 +08:00
|
|
|
|
|
|
|
|
|
|
【用户补充(重要)】
|
|
|
|
|
|
{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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-01 14:43:43 +08:00
|
|
|
|
public async Task<TransactionParseResult?> ParseOneLineBillAsync(string text)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 获取所有分类
|
|
|
|
|
|
var categories = await categoryRepository.GetAllAsync();
|
|
|
|
|
|
|
2026-01-01 21:22:58 +08:00
|
|
|
|
// 构建分类信息
|
|
|
|
|
|
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 = $$"""
|
2026-01-01 14:43:43 +08:00
|
|
|
|
你是一个智能账单解析助手。请从用户提供的文本中提取交易信息,包括日期、金额、摘要、类型和分类。
|
|
|
|
|
|
|
2026-01-01 21:22:58 +08:00
|
|
|
|
可用的分类列表:
|
|
|
|
|
|
{{categoryInfo}}
|
|
|
|
|
|
|
2026-01-01 14:43:43 +08:00
|
|
|
|
请返回 JSON 格式,包含以下字段:
|
2026-01-01 21:22:58 +08:00
|
|
|
|
- OccurredAt: 日期时间,格式 yyyy-MM-dd HH:mm:ss。当前系统时间为{{DateTime.Now:yyyy-MM-dd HH:mm:ss}}。
|
2026-01-01 14:43:43 +08:00
|
|
|
|
- Amount: 金额,数字。
|
|
|
|
|
|
- Reason: 备注/摘要,原文或其他补充信息。
|
|
|
|
|
|
- Type: 交易类型,0=支出,1=收入,2=不计入收支。根据语义判断。
|
2026-01-01 21:22:58 +08:00
|
|
|
|
- Classify: 分类,请从以下现有分类中选择最匹配的一个:如果无法匹配,请留空。
|
|
|
|
|
|
|
|
|
|
|
|
返回示例
|
|
|
|
|
|
{
|
|
|
|
|
|
"OccurredAt": "2024-06-15 14:30:00",
|
|
|
|
|
|
"Amount": 150.75,
|
|
|
|
|
|
"Reason": "午餐消费",
|
|
|
|
|
|
"Type": 0,
|
|
|
|
|
|
"Classify": "餐饮"
|
|
|
|
|
|
}
|
2026-01-01 14:43:43 +08:00
|
|
|
|
|
|
|
|
|
|
只返回 JSON,不要包含 markdown 标记。
|
|
|
|
|
|
""";
|
|
|
|
|
|
var json = await openAiService.ChatAsync(sysPrompt, text);
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(json)) return null;
|
|
|
|
|
|
|
2026-01-01 21:22:58 +08:00
|
|
|
|
try
|
2026-01-01 14:43:43 +08:00
|
|
|
|
{
|
|
|
|
|
|
// 清理可能的 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-05 15:51:47 +08:00
|
|
|
|
|
|
|
|
|
|
/// <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++)
|
2026-01-05 15:51:47 +08:00
|
|
|
|
{
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-05 15:51:47 +08:00
|
|
|
|
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
|
|
|
|
}
|
2026-01-01 14:43:43 +08:00
|
|
|
|
|
|
|
|
|
|
public record TransactionParseResult(string OccurredAt, string Classify, decimal Amount, string Reason, TransactionType Type);
|