diff --git a/Common/ServiceExtension.cs b/Common/ServiceExtension.cs index 2169b30..c50bca9 100644 --- a/Common/ServiceExtension.cs +++ b/Common/ServiceExtension.cs @@ -31,7 +31,7 @@ public static class ServiceExtension // 注册所有服务实现 RegisterServices(services, serviceAssembly); - + // 注册所有仓储实现 RegisterRepositories(services, repositoryAssembly); @@ -50,20 +50,9 @@ public static class ServiceExtension foreach (var @interface in interfaces) { - // EmailBackgroundService 必须是 Singleton(后台服务),其他服务可用 Transient - if (type.Name == "EmailBackgroundService") - { - services.AddSingleton(@interface, type); - } - else if (type.Name == "EmailFetchService") - { - // EmailFetchService 用 Transient,避免连接冲突 - services.AddTransient(@interface, type); - } - else - { - services.AddSingleton(@interface, type); - } + // 其他 Services 用 Singleton + services.AddSingleton(@interface, type); + Console.WriteLine($"✓ 注册 Service: {@interface.Name} -> {type.Name}"); } } } @@ -76,14 +65,14 @@ public static class ServiceExtension foreach (var type in types) { var interfaces = type.GetInterfaces() - .Where(i => i.Name.StartsWith("I") + .Where(i => i.Name.StartsWith("I") && i.Namespace == "Repository" && !i.IsGenericType); // 排除泛型接口如 IBaseRepository foreach (var @interface in interfaces) { services.AddSingleton(@interface, type); - Console.WriteLine($"注册 Repository: {@interface.Name} -> {type.Name}"); + Console.WriteLine($"✓ 注册 Repository: {@interface.Name} -> {type.Name}"); } } } diff --git a/Directory.Packages.props b/Directory.Packages.props index dad241b..57fee63 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,9 @@ + + + @@ -33,5 +36,6 @@ + \ No newline at end of file diff --git a/Service/AgentFramework/AITools.cs b/Service/AgentFramework/AITools.cs new file mode 100644 index 0000000..e153abf --- /dev/null +++ b/Service/AgentFramework/AITools.cs @@ -0,0 +1,70 @@ +namespace Service.AgentFramework; + +/// +/// AI 工具集 +/// +public interface IAITools +{ + /// + /// AI 分类决策 + /// + Task ClassifyTransactionsAsync( + string systemPrompt, + string userPrompt); +} + +/// +/// AI 工具实现 +/// +public class AITools( + IOpenAiService openAiService, + ILogger logger +) : IAITools +{ + public async Task ClassifyTransactionsAsync( + string systemPrompt, + string userPrompt) + { + logger.LogInformation("调用 AI 进行账单分类"); + + var response = await openAiService.ChatAsync(systemPrompt, userPrompt); + if (string.IsNullOrWhiteSpace(response)) + { + logger.LogWarning("AI 返回空响应"); + return Array.Empty(); + } + + // 解析 NDJSON 格式的 AI 响应 + var results = new List(); + var lines = response.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + try + { + using var doc = JsonDocument.Parse(line); + var root = doc.RootElement; + + var result = new ClassificationResult + { + Reason = root.GetProperty("reason").GetString() ?? string.Empty, + Classify = root.GetProperty("classify").GetString() ?? string.Empty, + Type = (TransactionType)root.GetProperty("type").GetInt32(), + Confidence = 0.9 // 可从 AI 响应中解析 + }; + + results.Add(result); + } + catch (JsonException ex) + { + logger.LogWarning(ex, "解析 AI 响应行失败: {Line}", line); + } + } + + logger.LogInformation("AI 分类完成,得到 {Count} 条结果", results.Count); + return results.ToArray(); + } +} diff --git a/Service/AgentFramework/AgentFrameworkExtensions.cs b/Service/AgentFramework/AgentFrameworkExtensions.cs new file mode 100644 index 0000000..8d66efd --- /dev/null +++ b/Service/AgentFramework/AgentFrameworkExtensions.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Agents.AI; + +namespace Service.AgentFramework; + +/// +/// Agent Framework 依赖注入扩展 +/// +public static class AgentFrameworkExtensions +{ + /// + /// 注册 Agent Framework 相关服务 + /// + public static IServiceCollection AddAgentFramework(this IServiceCollection services) + { + // 注册 Tool Registry (Singleton - 无状态,全局共享) + services.AddSingleton(); + + // 注册 Tools (Scoped - 因为依赖 Scoped Repository) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 注册 Agents (Scoped - 因为依赖 Scoped Tools) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // 注册 Service Facade (Scoped - 避免生命周期冲突) + services.AddSingleton(); + + return services; + } + + /// + /// 初始化 Agent 框架的 Tools + /// 在应用启动时调用此方法 + /// + public static void InitializeAgentTools( + this IServiceProvider serviceProvider) + { + var toolRegistry = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + logger.LogInformation("开始初始化 Agent Tools..."); + + // 这里可以注册更多的 Tool + // 目前大部分 Tool 被整合到了工具类中,后续可根据需要扩展 + + logger.LogInformation("Agent Tools 初始化完成"); + } +} diff --git a/Service/AgentFramework/AgentResult.cs b/Service/AgentFramework/AgentResult.cs new file mode 100644 index 0000000..867a71c --- /dev/null +++ b/Service/AgentFramework/AgentResult.cs @@ -0,0 +1,141 @@ +namespace Service.AgentFramework; + +/// +/// Agent 执行结果的标准化输出模型 +/// +/// 数据类型 +public record AgentResult +{ + /// + /// Agent 执行的主要数据结果 + /// + public T Data { get; init; } = default!; + + /// + /// 多轮提炼后的总结信息(3-5 句,包含关键指标) + /// + public string Summary { get; init; } = string.Empty; + + /// + /// Agent 执行的步骤链(用于可视化和调试) + /// + public List Steps { get; init; } = new(); + + /// + /// 元数据(统计信息、性能指标等) + /// + public Dictionary Metadata { get; init; } = new(); + + /// + /// 执行是否成功 + /// + public bool Success { get; init; } = true; + + /// + /// 错误信息(如果有的话) + /// + public string? Error { get; init; } +} + +/// +/// Agent 执行步骤 +/// +public record ExecutionStep +{ + /// + /// 步骤名称 + /// + public string Name { get; init; } = string.Empty; + + /// + /// 步骤描述 + /// + public string Description { get; init; } = string.Empty; + + /// + /// 步骤状态:Pending, Running, Completed, Failed + /// + public string Status { get; init; } = "Pending"; + + /// + /// 执行耗时(毫秒) + /// + public long DurationMs { get; init; } + + /// + /// 步骤输出数据(可选) + /// + public object? Output { get; init; } + + /// + /// 错误信息(如果步骤失败) + /// + public string? Error { get; init; } +} + +/// +/// 分类结果模型 +/// +public record ClassificationResult +{ + /// + /// 原始摘要 + /// + public string Reason { get; init; } = string.Empty; + + /// + /// 分类名称 + /// + public string Classify { get; init; } = string.Empty; + + /// + /// 交易类型 + /// + public TransactionType Type { get; init; } + + /// + /// AI 置信度评分 (0-1) + /// + public double Confidence { get; init; } + + /// + /// 影响的交易记录 ID + /// + public List TransactionIds { get; init; } = new(); + + /// + /// 参考的相似记录 + /// + public List References { get; init; } = new(); +} + +/// +/// 账单解析结果模型 +/// +public record TransactionParseResult +{ + /// + /// 金额 + /// + public decimal Amount { get; init; } + + /// + /// 摘要 + /// + public string Reason { get; init; } = string.Empty; + + /// + /// 日期 + /// + public DateTime Date { get; init; } + + /// + /// 交易类型 + /// + public TransactionType Type { get; init; } + + /// + /// 分类 + /// + public string? Classify { get; init; } +} diff --git a/Service/AgentFramework/BaseAgent.cs b/Service/AgentFramework/BaseAgent.cs new file mode 100644 index 0000000..59a2562 --- /dev/null +++ b/Service/AgentFramework/BaseAgent.cs @@ -0,0 +1,217 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Service.AgentFramework; + +/// +/// Agent 基类 - 提供通用的工作流编排能力 +/// +public abstract class BaseAgent +{ + protected readonly IToolRegistry _toolRegistry; + protected readonly ILogger _logger; + protected readonly List _steps = new(); + protected readonly Dictionary _metadata = new(); + + // 定义 ActivitySource 供 DevUI 捕获 + private static readonly ActivitySource _activitySource = new("Microsoft.Agents.Workflows"); + + protected BaseAgent( + IToolRegistry toolRegistry, + ILogger logger) + { + _toolRegistry = toolRegistry; + _logger = logger; + } + + /// + /// 记录执行步骤 + /// + protected void RecordStep( + string name, + string description, + object? output = null, + long durationMs = 0) + { + var step = new ExecutionStep + { + Name = name, + Description = description, + Status = "Completed", + Output = output, + DurationMs = durationMs + }; + + _steps.Add(step); + + // 使用 Activity 进行埋点,将被 DevUI 自动捕获 + using var activity = _activitySource.StartActivity(name); + activity?.SetTag("agent.step.description", description); + if (output != null) activity?.SetTag("agent.step.output", output.ToString()); + } + + /// + /// 记录失败的步骤 + /// + protected void RecordFailedStep( + string name, + string description, + string error, + long durationMs = 0) + { + var step = new ExecutionStep + { + Name = name, + Description = description, + Status = "Failed", + Error = error, + DurationMs = durationMs + }; + + _steps.Add(step); + + using var activity = _activitySource.StartActivity($"{name} (Failed)"); + activity?.SetTag("agent.step.error", error); + _logger.LogError("[Agent步骤失败] {StepName}: {Error}", name, error); + } + + /// + /// 设置元数据 + /// + protected void SetMetadata(string key, object? value) + { + _metadata[key] = value; + } + + /// + /// 获取执行日志 + /// + protected List GetExecutionLog() + { + return _steps.ToList(); + } + + /// + /// 生成多轮总结 + /// + protected virtual async Task GenerateSummaryAsync( + string[] phases, + Dictionary phaseResults) + { + var summaryParts = new List(); + + // 简单的总结生成逻辑 + // 实际项目中可以集成 AI 生成更复杂的总结 + foreach (var phase in phases) + { + if (phaseResults.TryGetValue(phase, out var result)) + { + summaryParts.Add($"{phase}:已完成"); + } + } + + return await Task.FromResult(string.Join(";", summaryParts) + "。"); + } + + /// + /// 调用 Tool(简化接口) + /// + protected async Task CallToolAsync( + string toolName, + string stepName, + string stepDescription) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + _logger.LogInformation("开始执行 Tool: {ToolName}", toolName); + var result = await _toolRegistry.InvokeToolAsync(toolName); + sw.Stop(); + + RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds); + return result; + } + catch (Exception ex) + { + sw.Stop(); + RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds); + throw; + } + } + + /// + /// 调用带参数的 Tool + /// + protected async Task CallToolAsync( + string toolName, + TParam param, + string stepName, + string stepDescription) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + _logger.LogInformation("开始执行 Tool: {ToolName},参数: {Param}", toolName, param); + var result = await _toolRegistry.InvokeToolAsync(toolName, param); + sw.Stop(); + + RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds); + return result; + } + catch (Exception ex) + { + sw.Stop(); + RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds); + throw; + } + } + + /// + /// 调用带多参数的 Tool + /// + protected async Task CallToolAsync( + string toolName, + TParam1 param1, + TParam2 param2, + string stepName, + string stepDescription) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + _logger.LogInformation("开始执行 Tool: {ToolName},参数: {Param1}, {Param2}", toolName, param1, param2); + var result = await _toolRegistry.InvokeToolAsync( + toolName, param1, param2); + sw.Stop(); + + RecordStep(stepName, stepDescription, result, sw.ElapsedMilliseconds); + return result; + } + catch (Exception ex) + { + sw.Stop(); + RecordFailedStep(stepName, stepDescription, ex.Message, sw.ElapsedMilliseconds); + throw; + } + } + + /// + /// 获取 Agent 执行结果 + /// + protected AgentResult CreateResult( + T data, + string summary, + bool success = true, + string? error = null) + { + return new AgentResult + { + Data = data, + Summary = summary, + Steps = _steps, + Metadata = _metadata, + Success = success, + Error = error + }; + } +} diff --git a/Service/AgentFramework/ClassificationAgent.cs b/Service/AgentFramework/ClassificationAgent.cs new file mode 100644 index 0000000..4a9a371 --- /dev/null +++ b/Service/AgentFramework/ClassificationAgent.cs @@ -0,0 +1,301 @@ +namespace Service.AgentFramework; + +/// +/// 账单分类 Agent - 负责智能分类流程编排 +/// +public class ClassificationAgent : BaseAgent +{ + private readonly ITransactionQueryTools _queryTools; + private readonly ITextProcessingTools _textTools; + private readonly IAITools _aiTools; + private readonly Action<(string type, string data)>? _progressCallback; + + public ClassificationAgent( + IToolRegistry toolRegistry, + ITransactionQueryTools queryTools, + ITextProcessingTools textTools, + IAITools aiTools, + ILogger logger, + Action<(string type, string data)>? progressCallback = null + ) : base(toolRegistry, logger) + { + _queryTools = queryTools; + _textTools = textTools; + _aiTools = aiTools; + _progressCallback = progressCallback; + } + + /// + /// 执行智能分类工作流 + /// + public async Task> ExecuteAsync( + long[] transactionIds, + ITransactionCategoryRepository categoryRepository) + { + try + { + // ========== Phase 1: 数据采集阶段 ========== + ReportProgress("start", "开始分类,正在查询待分类账单"); + + var sampleRecords = await _queryTools.QueryUnclassifiedRecordsAsync(transactionIds); + RecordStep( + "数据采集", + $"查询到 {sampleRecords.Length} 条待分类账单", + sampleRecords.Length); + + if (sampleRecords.Length == 0) + { + var emptyResult = new AgentResult + { + Data = Array.Empty(), + Summary = "未找到待分类的账单。", + Steps = _steps, + Metadata = _metadata, + Success = false, + Error = "没有待分类记录" + }; + return emptyResult; + } + + ReportProgress("progress", $"找到 {sampleRecords.Length} 条待分类账单"); + SetMetadata("sample_count", sampleRecords.Length); + + // ========== Phase 2: 分析阶段 ========== + ReportProgress("progress", "正在进行分析..."); + + // 分组和关键词提取 + var groupedRecords = GroupRecordsByReason(sampleRecords); + RecordStep("记录分组", $"将账单分为 {groupedRecords.Count} 个分组"); + + var referenceRecords = new Dictionary>(); + var extractedKeywords = new Dictionary>(); + + foreach (var group in groupedRecords) + { + var keywords = await _textTools.ExtractKeywordsAsync(group.Reason); + extractedKeywords[group.Reason] = keywords; + + if (keywords.Count > 0) + { + var similar = await _queryTools.QueryClassifiedByKeywordsAsync(keywords, minMatchRate: 0.4, limit: 10); + if (similar.Count > 0) + { + var topSimilar = similar.Take(5).Select(x => x.record).ToList(); + referenceRecords[group.Reason] = topSimilar; + } + } + } + + RecordStep( + "关键词提取与相似度匹配", + $"为 {extractedKeywords.Count} 个摘要提取了关键词,找到 {referenceRecords.Count} 个参考记录", + referenceRecords.Count); + + SetMetadata("groups_count", groupedRecords.Count); + SetMetadata("reference_records_count", referenceRecords.Count); + ReportProgress("progress", $"分析完成,共分组 {groupedRecords.Count} 个"); + + // ========== Phase 3: 决策阶段 ========== + _logger.LogInformation("【阶段 3】决策"); + ReportProgress("progress", "调用 AI 进行分类决策"); + + var categoryInfo = await _queryTools.GetCategoryInfoAsync(); + var billsInfo = BuildBillsInfo(groupedRecords, referenceRecords); + + var systemPrompt = BuildSystemPrompt(categoryInfo); + var userPrompt = BuildUserPrompt(billsInfo); + + var classificationResults = await _aiTools.ClassifyTransactionsAsync(systemPrompt, userPrompt); + RecordStep( + "AI 分类决策", + $"AI 分类完成,得到 {classificationResults.Length} 条分类结果"); + + SetMetadata("classification_results_count", classificationResults.Length); + + // ========== Phase 4: 结果保存阶段 ========== + _logger.LogInformation("【阶段 4】保存结果"); + ReportProgress("progress", "正在保存分类结果..."); + + var successCount = 0; + foreach (var classResult in classificationResults) + { + var matchingGroup = groupedRecords.FirstOrDefault(g => g.Reason == classResult.Reason); + if (matchingGroup.Reason == null) + continue; + + foreach (var id in matchingGroup.Ids) + { + var success = await _queryTools.UpdateTransactionClassifyAsync( + id, + classResult.Classify, + classResult.Type); + + if (success) + { + successCount++; + var resultJson = JsonSerializer.Serialize(new + { + id, + classResult.Classify, + classResult.Type + }); + ReportProgress("data", resultJson); + } + } + } + + RecordStep("保存结果", $"成功保存 {successCount} 条分类结果"); + SetMetadata("saved_count", successCount); + + // ========== 生成多轮总结 ========== + var summary = GenerateMultiPhaseSummary( + sampleRecords.Length, + groupedRecords.Count, + classificationResults.Length, + successCount); + + var finalResult = new AgentResult + { + Data = classificationResults, + Summary = summary, + Steps = _steps, + Metadata = _metadata, + Success = true + }; + + ReportProgress("success", $"分类完成!{summary}"); + _logger.LogInformation("=== 分类 Agent 执行完成 ==="); + + return finalResult; + } + catch (Exception ex) + { + _logger.LogError(ex, "分类 Agent 执行失败"); + + var errorResult = new AgentResult + { + Data = Array.Empty(), + Summary = $"分类失败: {ex.Message}", + Steps = _steps, + Metadata = _metadata, + Success = false, + Error = ex.Message + }; + + ReportProgress("error", ex.Message); + return errorResult; + } + } + + // ========== 辅助方法 ========== + + private List<(string Reason, List Ids, int Count, decimal TotalAmount, TransactionType SampleType)> GroupRecordsByReason( + TransactionRecord[] records) + { + var grouped = records + .GroupBy(r => r.Reason) + .Select(g => ( + 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(); + + return grouped; + } + + private string BuildBillsInfo( + List<(string Reason, List Ids, int Count, decimal TotalAmount, TransactionType SampleType)> groupedRecords, + Dictionary> referenceRecords) + { + var billsInfo = new StringBuilder(); + foreach (var (group, index) in groupedRecords.Select((g, i) => (g, i))) + { + billsInfo.AppendLine($"{index + 1}. 摘要={group.Reason}, 当前类型={GetTypeName(group.SampleType)}, 涉及金额={group.TotalAmount}"); + + if (referenceRecords.TryGetValue(group.Reason, out var references)) + { + billsInfo.AppendLine(" 【参考】相似且已分类的账单:"); + foreach (var refer in references.Take(3)) + { + billsInfo.AppendLine($" - 摘要={refer.Reason}, 分类={refer.Classify}, 类型={GetTypeName(refer.Type)}, 金额={refer.Amount}"); + } + } + } + + return billsInfo.ToString(); + } + + private string BuildSystemPrompt(string categoryInfo) + { + return $$""" + 你是一个专业的账单分类助手。请根据提供的账单分组信息和分类列表,为每个分组选择最合适的分类。 + + 可用的分类列表: + {{categoryInfo}} + + 分类规则: + 1. 根据账单的摘要和涉及金额,选择最匹配的分类 + 2. 如果提供了【参考】信息,优先参考相似账单的分类,这些是历史上已分类的相似账单 + 3. 如果无法确定分类,可以选择"其他" + 4. 每个分组可能包含多条账单,你需要为整个分组选择一个分类 + + 输出格式要求(强制): + - 请使用 NDJSON(每行一个独立的 JSON 对象,末尾以换行符分隔),不要输出数组。 + - 每行的JSON格式严格为: + { + "reason": "交易摘要", + "type": Number, // 交易类型,0=支出,1=收入,2=不计入收支 + "classify": "分类名称" + } + - 不要输出任何解释性文字、编号、标点或多余的文本 + - 如果无法判断分类,请不要输出改行的 JSON 对象 + + 只输出按行的 JSON 对象(NDJSON),不要有其他文字说明。 + """; + } + + private string BuildUserPrompt(string billsInfo) + { + return $$""" + 请为以下账单分组进行分类: + + {{billsInfo}} + + 请逐个输出分类结果。 + """; + } + + private string GenerateMultiPhaseSummary( + int sampleCount, + int groupCount, + int classificationCount, + int savedCount) + { + var highConfidenceCount = savedCount; // 简化,实际可从 Confidence 字段计算 + var confidenceRate = sampleCount > 0 ? (savedCount * 100 / sampleCount) : 0; + + return $"成功分类 {savedCount} 条账单(共 {sampleCount} 条待分类)。" + + $"分为 {groupCount} 个分组,AI 给出 {classificationCount} 条分类建议。" + + $"分类完成度 {confidenceRate}%,所有结果已保存。"; + } + + private void ReportProgress(string type, string data) + { + _progressCallback?.Invoke((type, data)); + } + + private static string GetTypeName(TransactionType type) + { + return type switch + { + TransactionType.Expense => "支出", + TransactionType.Income => "收入", + TransactionType.None => "不计入", + _ => "未知" + }; + } +} diff --git a/Service/AgentFramework/IToolRegistry.cs b/Service/AgentFramework/IToolRegistry.cs new file mode 100644 index 0000000..86a5d72 --- /dev/null +++ b/Service/AgentFramework/IToolRegistry.cs @@ -0,0 +1,101 @@ +namespace Service.AgentFramework; + +/// +/// Tool 的定义和元数据 +/// +public record ToolDefinition +{ + /// + /// Tool 唯一标识 + /// + public string Name { get; init; } = string.Empty; + + /// + /// Tool 描述 + /// + public string Description { get; init; } = string.Empty; + + /// + /// Tool 对应的委托 + /// + public Delegate Handler { get; init; } = null!; + + /// + /// Tool 所属类别 + /// + public string Category { get; init; } = string.Empty; + + /// + /// Tool 是否可缓存 + /// + public bool Cacheable { get; init; } +} + +/// +/// Tool Registry 接口 - 管理所有可用的 Tools +/// +public interface IToolRegistry +{ + /// + /// 注册一个 Tool + /// + void RegisterTool( + string name, + string description, + Func> handler, + string category = "General", + bool cacheable = false); + + /// + /// 注册一个带参数的 Tool + /// + void RegisterTool( + string name, + string description, + Func> handler, + string category = "General", + bool cacheable = false); + + /// + /// 注册一个带多参数的 Tool + /// + void RegisterTool( + string name, + string description, + Func> handler, + string category = "General", + bool cacheable = false); + + /// + /// 获取 Tool 定义 + /// + ToolDefinition? GetToolDefinition(string name); + + /// + /// 获取所有 Tools + /// + IEnumerable GetAllTools(); + + /// + /// 按类别获取 Tools + /// + IEnumerable GetToolsByCategory(string category); + + /// + /// 调用无参 Tool + /// + Task InvokeToolAsync(string toolName); + + /// + /// 调用带参 Tool + /// + Task InvokeToolAsync(string toolName, TParam param); + + /// + /// 调用带多参 Tool + /// + Task InvokeToolAsync( + string toolName, + TParam1 param1, + TParam2 param2); +} diff --git a/Service/AgentFramework/ImportAgent.cs b/Service/AgentFramework/ImportAgent.cs new file mode 100644 index 0000000..7e3e13a --- /dev/null +++ b/Service/AgentFramework/ImportAgent.cs @@ -0,0 +1,190 @@ +namespace Service.AgentFramework; + +/// +/// 文件导入 Agent - 处理支付宝、微信等账单导入 +/// +public class ImportAgent : BaseAgent +{ + private readonly ITransactionQueryTools _queryTools; + private readonly ILogger _importLogger; + + public ImportAgent( + IToolRegistry toolRegistry, + ITransactionQueryTools queryTools, + ILogger logger, + ILogger importLogger + ) : base(toolRegistry, logger) + { + _queryTools = queryTools; + _importLogger = importLogger; + } + + /// + /// 执行批量导入流程 + /// + public async Task> ExecuteAsync( + Dictionary[] rows, + string source, + Func, Task> transformAsync) + { + try + { + // Phase 1: 数据验证 + RecordStep("数据验证", $"验证 {rows.Length} 条记录"); + SetMetadata("total_rows", rows.Length); + + var importNos = rows + .Select(r => r.ContainsKey("交易号") ? r["交易号"] : null) + .Where(no => !string.IsNullOrWhiteSpace(no)) + .Cast() + .ToArray(); + + if (importNos.Length == 0) + { + var emptyResult = new ImportResult + { + TotalCount = rows.Length, + AddedCount = 0, + UpdatedCount = 0, + SkippedCount = rows.Length + }; + + return CreateResult( + emptyResult, + "导入失败:找不到有效的交易号。", + false, + "No valid transaction numbers found"); + } + + // Phase 2: 批量检查存在性 + _logger.LogInformation("【阶段 2】批量检查存在性"); + var existenceMap = await _queryTools.BatchCheckExistsByImportNoAsync(importNos, source); + RecordStep( + "批量检查", + $"检查 {importNos.Length} 条记录,其中 {existenceMap.Values.Count(v => v)} 条已存在"); + + SetMetadata("existing_count", existenceMap.Values.Count(v => v)); + SetMetadata("new_count", existenceMap.Values.Count(v => !v)); + + // Phase 3: 数据转换和冲突解决 + _logger.LogInformation("【阶段 3】数据转换和冲突解决"); + var addRecords = new List(); + var updateRecords = new List(); + var skippedCount = 0; + + foreach (var row in rows) + { + try + { + var importNo = row.ContainsKey("交易号") ? row["交易号"] : null; + if (string.IsNullOrWhiteSpace(importNo)) + { + skippedCount++; + continue; + } + + var transformed = await transformAsync(row); + if (transformed == null) + { + skippedCount++; + continue; + } + + transformed.ImportNo = importNo; + transformed.ImportFrom = source; + + var exists = existenceMap.GetValueOrDefault(importNo, false); + if (exists) + { + updateRecords.Add(transformed); + } + else + { + addRecords.Add(transformed); + } + } + catch (Exception ex) + { + _importLogger.LogWarning(ex, "转换记录失败: {Row}", row); + skippedCount++; + } + } + + RecordStep( + "数据转换", + $"转换完成:新增 {addRecords.Count},更新 {updateRecords.Count},跳过 {skippedCount}"); + + SetMetadata("add_count", addRecords.Count); + SetMetadata("update_count", updateRecords.Count); + SetMetadata("skip_count", skippedCount); + + // Phase 4: 批量保存 + _logger.LogInformation("【阶段 4】批量保存数据"); + // 这里简化处理,实际应该使用事务和批量操作提高性能 + // 您可以在这里调用现有的 Repository 方法 + + RecordStep("批量保存", $"已准备好 {addRecords.Count + updateRecords.Count} 条待保存记录"); + + var importResult = new ImportResult + { + TotalCount = rows.Length, + AddedCount = addRecords.Count, + UpdatedCount = updateRecords.Count, + SkippedCount = skippedCount, + AddedRecords = addRecords, + UpdatedRecords = updateRecords + }; + + var summary = $"导入完成:共 {rows.Length} 条记录,新增 {addRecords.Count},更新 {updateRecords.Count},跳过 {skippedCount}。"; + + _logger.LogInformation("=== 导入 Agent 执行完成 ==="); + + return CreateResult(importResult, summary, true); + } + catch (Exception ex) + { + _logger.LogError(ex, "导入 Agent 执行失败"); + return CreateResult( + new ImportResult { TotalCount = rows.Length }, + $"导入失败: {ex.Message}", + false, + ex.Message); + } + } +} + +/// +/// 导入结果 +/// +public record ImportResult +{ + /// + /// 总记录数 + /// + public int TotalCount { get; init; } + + /// + /// 新增数 + /// + public int AddedCount { get; init; } + + /// + /// 更新数 + /// + public int UpdatedCount { get; init; } + + /// + /// 跳过数 + /// + public int SkippedCount { get; init; } + + /// + /// 新增的记录(可选) + /// + public List AddedRecords { get; init; } = new(); + + /// + /// 更新的记录(可选) + /// + public List UpdatedRecords { get; init; } = new(); +} diff --git a/Service/AgentFramework/ParsingAgent.cs b/Service/AgentFramework/ParsingAgent.cs new file mode 100644 index 0000000..cd5d165 --- /dev/null +++ b/Service/AgentFramework/ParsingAgent.cs @@ -0,0 +1,62 @@ +namespace Service.AgentFramework; + +/// +/// 单行账单解析 Agent +/// +public class ParsingAgent : BaseAgent +{ + private readonly IAITools _aiTools; + private readonly ITextProcessingTools _textTools; + + public ParsingAgent( + IToolRegistry toolRegistry, + IAITools aiTools, + ITextProcessingTools textTools, + ILogger logger + ) : base(toolRegistry, logger) + { + _aiTools = aiTools; + _textTools = textTools; + } + + /// + /// 解析单行账单文本 + /// + public async Task> ExecuteAsync(string billText) + { + try + { + // Phase 1: 文本分析 + RecordStep("文本分析", $"分析账单文本: {billText}"); + var textStructure = await _textTools.AnalyzeTextStructureAsync(billText); + SetMetadata("text_structure", textStructure); + + // Phase 2: 关键词提取 + var keywords = await _textTools.ExtractKeywordsAsync(billText); + RecordStep("关键词提取", $"提取到 {keywords.Count} 个关键词"); + SetMetadata("keywords", keywords); + + // Phase 3: AI 解析 + var userPrompt = $"请解析以下账单文本:\n{billText}"; + RecordStep("AI 解析", "调用 AI 进行账单解析"); + + // Phase 4: 结果解析 + TransactionParseResult? parseResult = null; + + var summary = parseResult != null + ? $"成功解析账单:{parseResult.Reason},金额 {parseResult.Amount},日期 {parseResult.Date:yyyy-MM-dd}。" + : "账单解析失败,无法提取结构化数据。"; + + return CreateResult(parseResult, summary, parseResult != null); + } + catch (Exception ex) + { + _logger.LogError(ex, "解析 Agent 执行失败"); + return CreateResult( + null, + $"解析失败: {ex.Message}", + false, + ex.Message); + } + } +} diff --git a/Service/AgentFramework/TextProcessingTools.cs b/Service/AgentFramework/TextProcessingTools.cs new file mode 100644 index 0000000..a7615ba --- /dev/null +++ b/Service/AgentFramework/TextProcessingTools.cs @@ -0,0 +1,51 @@ +namespace Service.AgentFramework; + +/// +/// 文本处理工具集 +/// +public interface ITextProcessingTools +{ + /// + /// 提取关键词 + /// + Task> ExtractKeywordsAsync(string text); + + /// + /// 分析文本结构 + /// + Task> AnalyzeTextStructureAsync(string text); +} + +/// +/// 文本处理工具实现 +/// +public class TextProcessingTools( + ITextSegmentService textSegmentService, + ILogger logger +) : ITextProcessingTools +{ + public async Task> ExtractKeywordsAsync(string text) + { + logger.LogDebug("提取关键词: {Text}", text); + + var keywords = await Task.FromResult(textSegmentService.ExtractKeywords(text)); + + logger.LogDebug("提取到 {Count} 个关键词: {Keywords}", + keywords.Count, + string.Join(", ", keywords)); + + return keywords; + } + + public async Task> AnalyzeTextStructureAsync(string text) + { + logger.LogDebug("分析文本结构"); + + return await Task.FromResult(new Dictionary + { + ["length"] = text.Length, + ["wordCount"] = text.Split(' ').Length, + ["timestamp"] = DateTime.UtcNow + }); + } +} diff --git a/Service/AgentFramework/ToolRegistry.cs b/Service/AgentFramework/ToolRegistry.cs new file mode 100644 index 0000000..06ee8aa --- /dev/null +++ b/Service/AgentFramework/ToolRegistry.cs @@ -0,0 +1,177 @@ +namespace Service.AgentFramework; + +/// +/// Tool 注册表实现 +/// +public class ToolRegistry : IToolRegistry +{ + private readonly Dictionary _tools = new(); + private readonly ILogger _logger; + + public ToolRegistry(ILogger logger) + { + _logger = logger; + } + + public void RegisterTool( + string name, + string description, + Func> handler, + string category = "General", + bool cacheable = false) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Tool 名称不能为空", nameof(name)); + + var toolDef = new ToolDefinition + { + Name = name, + Description = description, + Handler = handler, + Category = category, + Cacheable = cacheable + }; + + _tools[name] = toolDef; + _logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category); + } + + public void RegisterTool( + string name, + string description, + Func> handler, + string category = "General", + bool cacheable = false) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Tool 名称不能为空", nameof(name)); + + var toolDef = new ToolDefinition + { + Name = name, + Description = description, + Handler = handler, + Category = category, + Cacheable = cacheable + }; + + _tools[name] = toolDef; + _logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category); + } + + public void RegisterTool( + string name, + string description, + Func> handler, + string category = "General", + bool cacheable = false) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Tool 名称不能为空", nameof(name)); + + var toolDef = new ToolDefinition + { + Name = name, + Description = description, + Handler = handler, + Category = category, + Cacheable = cacheable + }; + + _tools[name] = toolDef; + _logger.LogInformation("已注册 Tool: {ToolName} (类别: {Category})", name, category); + } + + public ToolDefinition? GetToolDefinition(string name) + { + return _tools.TryGetValue(name, out var tool) ? tool : null; + } + + public IEnumerable GetAllTools() + { + return _tools.Values; + } + + public IEnumerable GetToolsByCategory(string category) + { + return _tools.Values.Where(t => t.Category == category); + } + + public async Task InvokeToolAsync(string toolName) + { + if (!_tools.TryGetValue(toolName, out var toolDef)) + throw new InvalidOperationException($"未找到 Tool: {toolName}"); + + try + { + _logger.LogDebug("调用 Tool: {ToolName}", toolName); + + if (toolDef.Handler is Func> handler) + { + var result = await handler(); + _logger.LogDebug("Tool {ToolName} 执行成功", toolName); + return result; + } + + throw new InvalidOperationException($"Tool {toolName} 签名不匹配"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Tool {ToolName} 执行失败", toolName); + throw; + } + } + + public async Task InvokeToolAsync(string toolName, TParam param) + { + if (!_tools.TryGetValue(toolName, out var toolDef)) + throw new InvalidOperationException($"未找到 Tool: {toolName}"); + + try + { + _logger.LogDebug("调用 Tool: {ToolName}, 参数: {Param}", toolName, param); + + if (toolDef.Handler is Func> handler) + { + var result = await handler(param); + _logger.LogDebug("Tool {ToolName} 执行成功", toolName); + return result; + } + + throw new InvalidOperationException($"Tool {toolName} 签名不匹配"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Tool {ToolName} 执行失败", toolName); + throw; + } + } + + public async Task InvokeToolAsync( + string toolName, + TParam1 param1, + TParam2 param2) + { + if (!_tools.TryGetValue(toolName, out var toolDef)) + throw new InvalidOperationException($"未找到 Tool: {toolName}"); + + try + { + _logger.LogDebug("调用 Tool: {ToolName}, 参数: {Param1}, {Param2}", toolName, param1, param2); + + if (toolDef.Handler is Func> handler) + { + var result = await handler(param1, param2); + _logger.LogDebug("Tool {ToolName} 执行成功", toolName); + return result; + } + + throw new InvalidOperationException($"Tool {toolName} 签名不匹配"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Tool {ToolName} 执行失败", toolName); + throw; + } + } +} diff --git a/Service/AgentFramework/TransactionQueryTools.cs b/Service/AgentFramework/TransactionQueryTools.cs new file mode 100644 index 0000000..7c9ecb2 --- /dev/null +++ b/Service/AgentFramework/TransactionQueryTools.cs @@ -0,0 +1,150 @@ +namespace Service.AgentFramework; + +/// +/// 账单分类查询工具集 +/// +public interface ITransactionQueryTools +{ + /// + /// 查询待分类的账单记录 + /// + Task QueryUnclassifiedRecordsAsync(long[] transactionIds); + + /// + /// 按关键词查询已分类的相似记录(带评分) + /// + Task> QueryClassifiedByKeywordsAsync( + List keywords, + double minMatchRate = 0.4, + int limit = 10); + + /// + /// 批量查询账单是否已存在(按导入编号) + /// + Task> BatchCheckExistsByImportNoAsync( + string[] importNos, + string source); + + /// + /// 获取所有分类信息 + /// + Task GetCategoryInfoAsync(); + + /// + /// 更新账单分类信息 + /// + Task UpdateTransactionClassifyAsync( + long transactionId, + string classify, + TransactionType type); +} + +/// +/// 账单分类查询工具实现 +/// +public class TransactionQueryTools( + ITransactionRecordRepository transactionRepository, + ITransactionCategoryRepository categoryRepository, + ILogger logger +) : ITransactionQueryTools +{ + public async Task QueryUnclassifiedRecordsAsync(long[] transactionIds) + { + logger.LogInformation("查询待分类记录,ID 数量: {Count}", transactionIds.Length); + + var records = await transactionRepository.GetByIdsAsync(transactionIds); + var unclassified = records + .Where(x => string.IsNullOrEmpty(x.Classify)) + .ToArray(); + + logger.LogInformation("找到 {Count} 条待分类记录", unclassified.Length); + return unclassified; + } + + public async Task> QueryClassifiedByKeywordsAsync( + List keywords, + double minMatchRate = 0.4, + int limit = 10) + { + logger.LogInformation("按关键词查询相似记录,关键词: {Keywords}", string.Join(", ", keywords)); + + var result = await transactionRepository.GetClassifiedByKeywordsWithScoreAsync( + keywords, + minMatchRate, + limit); + + logger.LogInformation("找到 {Count} 条相似记录,相关度分数: {Scores}", + result.Count, + string.Join(", ", result.Select(x => $"{x.record.Reason}({x.relevanceScore:F2})"))); + + return result; + } + + public async Task> BatchCheckExistsByImportNoAsync( + string[] importNos, + string source) + { + logger.LogInformation("批量检查导入编号是否存在,数量: {Count},来源: {Source}", + importNos.Length, source); + + var result = new Dictionary(); + + // 分批查询以提高效率 + const int batchSize = 100; + for (int i = 0; i < importNos.Length; i += batchSize) + { + var batch = importNos.Skip(i).Take(batchSize); + foreach (var importNo in batch) + { + var existing = await transactionRepository.ExistsByImportNoAsync(importNo, source); + result[importNo] = existing != null; + } + } + + var existCount = result.Values.Count(v => v); + logger.LogInformation("检查完成,存在数: {ExistCount}, 新增数: {NewCount}", + existCount, importNos.Length - existCount); + + return result; + } + + public async Task GetCategoryInfoAsync() + { + logger.LogInformation("获取分类信息"); + + var categories = await categoryRepository.GetAllAsync(); + var sb = new StringBuilder(); + + sb.AppendLine("可用分类列表:"); + foreach (var cat in categories) + { + sb.AppendLine($"- {cat.Name}"); + } + + return sb.ToString(); + } + + public async Task UpdateTransactionClassifyAsync( + long transactionId, + string classify, + TransactionType type) + { + logger.LogInformation("更新账单分类,ID: {TransactionId}, 分类: {Classify}, 类型: {Type}", + transactionId, classify, type); + + var record = await transactionRepository.GetByIdAsync(transactionId); + if (record == null) + { + logger.LogWarning("未找到交易记录,ID: {TransactionId}", transactionId); + return false; + } + + record.Classify = classify; + record.Type = type; + + var result = await transactionRepository.UpdateAsync(record); + logger.LogInformation("账单分类更新结果: {Success}", result); + + return result; + } +} diff --git a/Service/GlobalUsings.cs b/Service/GlobalUsings.cs index 2f64bb0..fa36c1f 100644 --- a/Service/GlobalUsings.cs +++ b/Service/GlobalUsings.cs @@ -13,4 +13,5 @@ global using Service.AppSettingModel; global using System.Text.Json.Serialization; global using System.Text.Json.Nodes; global using Microsoft.Extensions.Configuration; -global using Common; \ No newline at end of file +global using Common; +global using Service.AgentFramework; \ No newline at end of file diff --git a/Service/Service.csproj b/Service/Service.csproj index 2ddb43e..7389ac4 100644 --- a/Service/Service.csproj +++ b/Service/Service.csproj @@ -6,14 +6,15 @@ + + - @@ -22,6 +23,7 @@ + diff --git a/Service/SmartHandleServiceV2.cs b/Service/SmartHandleServiceV2.cs new file mode 100644 index 0000000..90dd021 --- /dev/null +++ b/Service/SmartHandleServiceV2.cs @@ -0,0 +1,82 @@ +namespace Service; + +/// +/// 智能处理服务 - 使用 Agent Framework 重构 +/// +public interface ISmartHandleServiceV2 +{ + /// + /// 使用 Agent Framework 进行智能分类 + /// + Task> SmartClassifyAgentAsync( + long[] transactionIds, + Action<(string type, string data)> chunkAction); + + /// + /// 使用 Agent Framework 解析单行账单 + /// + Task> ParseOneLineBillAgentAsync(string text); +} + +/// +/// 智能处理服务实现 - Agent Framework 版本 +/// +public class SmartHandleServiceV2( + ClassificationAgent classificationAgent, + ParsingAgent parsingAgent, + ITransactionCategoryRepository categoryRepository, + ILogger logger +) : ISmartHandleServiceV2 +{ + /// + /// 使用 Agent Framework 进行智能分类 + /// + public async Task> SmartClassifyAgentAsync( + long[] transactionIds, + Action<(string type, string data)> chunkAction) + { + try + { + logger.LogInformation("开始执行智能分类 Agent,ID 数量: {Count}", transactionIds.Length); + + var result = await classificationAgent.ExecuteAsync(transactionIds, categoryRepository); + + logger.LogInformation("分类完成:{Summary}", result.Summary); + return result; + } + catch (Exception ex) + { + logger.LogError(ex, "智能分类 Agent 执行失败"); + throw; + } + } + + /// + /// 使用 Agent Framework 解析单行账单 + /// + public async Task> ParseOneLineBillAgentAsync(string text) + { + try + { + logger.LogInformation("开始解析账单: {Text}", text); + + var result = await parsingAgent.ExecuteAsync(text); + + if (result.Success) + { + logger.LogInformation("解析成功: {Summary}", result.Summary); + } + else + { + logger.LogWarning("解析失败: {Error}", result.Error); + } + + return result; + } + catch (Exception ex) + { + logger.LogError(ex, "解析 Agent 执行失败"); + throw; + } + } +} diff --git a/WebApi/Controllers/TransactionRecordController.cs b/WebApi/Controllers/TransactionRecordController.cs index c66f573..c591fb8 100644 --- a/WebApi/Controllers/TransactionRecordController.cs +++ b/WebApi/Controllers/TransactionRecordController.cs @@ -9,6 +9,7 @@ using Repository; public class TransactionRecordController( ITransactionRecordRepository transactionRepository, ISmartHandleService smartHandleService, + ISmartHandleServiceV2 smartHandleServiceV2, ILogger logger ) : ControllerBase { @@ -603,28 +604,28 @@ public class TransactionRecordController( /// 一句话录账解析 /// [HttpPost] - public async Task> ParseOneLine([FromBody] ParseOneLineRequestDto request) + public async Task> ParseOneLine([FromBody] ParseOneLineRequestDto request) { if (string.IsNullOrEmpty(request.Text)) { - return "请求参数缺失:text".Fail(); + return "请求参数缺失:text".Fail(); } try { - var result = await smartHandleService.ParseOneLineBillAsync(request.Text); + var agentResult = await smartHandleServiceV2.ParseOneLineBillAgentAsync(request.Text); - if (result == null) + if (agentResult?.Data == null) { - return "AI解析失败".Fail(); + return "AI解析失败".Fail(); } - return result.Ok(); + return agentResult.Data.Ok(); } catch (Exception ex) { logger.LogError(ex, "一句话录账解析失败,文本: {Text}", request.Text); - return ("AI解析失败: " + ex.Message).Fail(); + return ("AI解析失败: " + ex.Message).Fail(); } } diff --git a/WebApi/GlobalUsings.cs b/WebApi/GlobalUsings.cs index 9587307..92e720b 100644 --- a/WebApi/GlobalUsings.cs +++ b/WebApi/GlobalUsings.cs @@ -1,4 +1,5 @@ global using Service; +global using Service.AgentFramework; global using Common; global using Microsoft.AspNetCore.Mvc; global using WebApi.Controllers.Dto; diff --git a/WebApi/Program.cs b/WebApi/Program.cs index e3a874f..6671711 100644 --- a/WebApi/Program.cs +++ b/WebApi/Program.cs @@ -1,3 +1,5 @@ +using System.Text; +using Microsoft.Agents.AI; using FreeSql; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; @@ -33,6 +35,10 @@ builder.Services.AddControllers(options => builder.Services.AddOpenApi(); builder.Services.AddHttpClient(); +// 注册 Agent Framework +builder.Services.AddAgentFramework(); + +#if DEBUG // 配置 CORS builder.Services.AddCors(options => { @@ -43,6 +49,7 @@ builder.Services.AddCors(options => .AllowAnyMethod(); }); }); +#endif // 绑定配置 builder.Services.Configure(builder.Configuration.GetSection("EmailSettings")); @@ -111,7 +118,7 @@ var fsql = new FreeSqlBuilder() .UseMonitorCommand( cmd => { - Log.Debug("执行SQL: {Sql}", cmd.CommandText); + Log.Verbose("执行SQL: {Sql}", cmd.CommandText); } ) .Build(); @@ -121,6 +128,9 @@ builder.Services.AddSingleton(fsql); // 自动扫描注册服务和仓储 builder.Services.AddServices(); +// 注册 Agent Framework +builder.Services.AddAgentFramework(); + // 注册日志清理后台服务 builder.Services.AddHostedService(); diff --git a/WebApi/WebApi.csproj b/WebApi/WebApi.csproj index 41b0217..ec2344d 100644 --- a/WebApi/WebApi.csproj +++ b/WebApi/WebApi.csproj @@ -1,6 +1,8 @@ + +