From 2e691b28f7aca4424a2072a1ea69dd43f641934a Mon Sep 17 00:00:00 2001 From: MayueCif Date: Mon, 3 Aug 2026 09:34:21 +0800 Subject: [PATCH 1/8] Add unit tests for market analysis, chat session behavior, and MCP service runtime - Implement tests for MarketAnalysisWorkflow to ensure concurrent runs are isolated and handle empty run IDs correctly. - Create tests for MarketChatSession to verify message streaming behavior and cancellation handling. - Add tests for McpServiceRuntime to validate configuration fingerprinting, connection management, and tool retrieval. - Enhance middleware tests for conversation compression and token tracking, ensuring proper functionality under various conditions. - Introduce structured output tests to validate JSON schema generation and criteria handling in investment selection strategies. - Update project file to include demo documents for testing purposes. - Refactor file path handling in vector tests to ensure test documents are correctly located. --- Directory.Packages.props | 4 + NuGet.config | 7 + ...06\346\236\220\346\212\245\345\221\212.md" | 996 ++++++++++++++++++ docs/code-review-report.md | 552 ++++++++++ .../Models/CoordinatorResult.cs | 5 + .../MarketAssistant.Agents.csproj | 4 + .../ConversationCompressionMiddleware.cs | 228 +--- .../Middleware/TokenTrackingMiddleware.cs | 80 +- .../TradingFunctionGuardMiddleware.cs | 185 +++- .../AnalystPromptConfig.cs | 14 + .../Analysts/CoordinatorAnalystAgent.cs | 11 +- .../Agents/Analysts/FinancialAnalystAgent.cs | 14 +- .../Analysts/FundamentalAnalystAgent.cs | 13 +- .../Analysts/MarketSentimentAnalystAgent.cs | 13 +- .../Agents/Analysts/NewsEventAnalystAgent.cs | 13 +- .../Agents/Analysts/TechnicalAnalystAgent.cs | 13 +- .../Executors/AnalyzeAssetsExecutor.cs | 102 +- .../Executors/GenerateCriteriaExecutor.cs | 16 +- .../InvestmentSelectionWorkflow.cs | 4 +- .../CryptoCriteriaGenerationStrategy.cs | 49 +- .../Strategies/ICriteriaGenerationStrategy.cs | 4 +- .../StockCriteriaGenerationStrategy.cs | 57 +- .../Executors/CoordinatorExecutor.cs | 20 +- .../MarketAnalysis/MarketAnalysisWorkflow.cs | 152 ++- .../Agents/MarketChatSession.cs | 248 +++-- .../Analysis/AnalysisOrchestrationService.cs | 19 +- .../InvestmentSelectionService.cs | 2 +- .../Applications/Settings/AppInfo.cs | 10 + .../Applications/Settings/MCPServerConfig.cs | 3 + .../Settings/MCPServerConfigService.cs | 100 +- .../Applications/Settings/UserSetting.cs | 42 +- .../Factories/AnalystAgentFactory.cs | 22 +- .../Factories/ChatClientFactory.cs | 263 +++-- .../Factories/EmbeddingFactory.cs | 26 +- .../Factories/MarketChatSessionFactory.cs | 21 +- .../Factories/TradingAgentFactory.cs | 25 +- .../Providers/IModelProviderAdapter.cs | 39 + .../Infrastructure/Providers/ModelProvider.cs | 85 ++ .../Providers/ModelProviderAdapterFactory.cs | 33 + .../Providers/ModelProviderCatalog.cs | 165 +++ .../Infrastructure/Providers/OllamaAdapter.cs | 84 ++ .../Providers/OpenAICompatibleAdapter.cs | 126 +++ .../Providers/StructuredOutputHelper.cs | 51 + .../Providers/StructuredOutputValidator.cs | 118 +++ .../MarketAssistant.App.Services.csproj | 8 +- .../Services/ChatSessionPersistenceService.cs | 80 +- .../Services/Mcp/McpService.cs | 420 +++++--- .../Services/Mcp/McpToolContextProvider.cs | 4 +- .../Services/ServiceCollectionExtensions.cs | 20 +- .../Services/Settings/SecureSettingsStore.cs | 106 ++ .../Services/Settings/UserSettingService.cs | 165 ++- .../Trading/MarketMonitor.cs | 9 +- src/MarketAssistant.App/AGENTS.md | 2 - .../MarketAssistant.App.csproj | 3 - src/MarketAssistant.App/Program.cs | 13 +- .../Resources/Styles/Spacing.axaml | 4 +- .../Notification/NotificationService.cs | 2 +- .../ViewModels/AgentAnalysisViewModel.cs | 20 +- .../ViewModels/ChatSidebarViewModel.cs | 37 +- .../ViewModels/MCPConfigPageViewModel.cs | 2 +- .../ViewModels/SettingsPageViewModel.cs | 223 +++- .../Views/Pages/SettingsPageView.axaml | 93 +- .../Views/Windows/NotificationWindow.axaml | 17 +- .../Views/Windows/NotificationWindow.axaml.cs | 18 +- src/MarketAssistant.App/config/models.yaml | 7 - .../Core/MarketAssistantDiagnostics.cs | 28 + .../MarketAssistant.Core.csproj | 1 + tests/Application/AssetScreenerServiceTest.cs | 3 +- tests/Application/FavoriteServiceTest.cs | 4 +- tests/BaseAgentTest.cs | 3 +- tests/Infrastructure/ChatClientFactoryTest.cs | 201 ++++ tests/Infrastructure/DiagnosticsTest.cs | 64 ++ .../ModelProviderCatalogTest.cs | 141 +++ .../Infrastructure/SecretSerializationTest.cs | 55 + .../SecureSettingsMigrationTest.cs | 218 ++++ tests/MarketAnalysisWorkflowIdentityTest.cs | 89 ++ tests/MarketChatSessionBehaviorTest.cs | 126 +++ tests/McpServiceRuntimeTest.cs | 198 ++++ .../ConversationCompressionMiddlewareTest.cs | 64 +- .../Middleware/TokenTrackingMiddlewareTest.cs | 25 + .../TradingFunctionGuardMiddlewareTest.cs | 258 ++++- tests/StructuredOutputTest.cs | 125 +++ tests/TestMarketAssistant.csproj | 5 + tests/Tools/BasicDataToolsTest.cs | 3 +- tests/Tools/NewsDataToolsTest.cs | 2 + tests/Vectors/DocxFileTest.cs | 24 +- tests/Vectors/PdfFileTest.cs | 24 +- 87 files changed, 5940 insertions(+), 1012 deletions(-) create mode 100644 NuGet.config create mode 100644 "docs/RAG\346\250\241\345\235\227\345\210\206\346\236\220\346\212\245\345\221\212.md" create mode 100644 docs/code-review-report.md create mode 100644 src/MarketAssistant.App.Services/Infrastructure/Providers/IModelProviderAdapter.cs create mode 100644 src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProvider.cs create mode 100644 src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderAdapterFactory.cs create mode 100644 src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderCatalog.cs create mode 100644 src/MarketAssistant.App.Services/Infrastructure/Providers/OllamaAdapter.cs create mode 100644 src/MarketAssistant.App.Services/Infrastructure/Providers/OpenAICompatibleAdapter.cs create mode 100644 src/MarketAssistant.App.Services/Infrastructure/Providers/StructuredOutputHelper.cs create mode 100644 src/MarketAssistant.App.Services/Infrastructure/Providers/StructuredOutputValidator.cs create mode 100644 src/MarketAssistant.App.Services/Services/Settings/SecureSettingsStore.cs delete mode 100644 src/MarketAssistant.App/config/models.yaml create mode 100644 src/MarketAssistant.Core/Infrastructure/Core/MarketAssistantDiagnostics.cs create mode 100644 tests/Infrastructure/ChatClientFactoryTest.cs create mode 100644 tests/Infrastructure/DiagnosticsTest.cs create mode 100644 tests/Infrastructure/ModelProviderCatalogTest.cs create mode 100644 tests/Infrastructure/SecretSerializationTest.cs create mode 100644 tests/Infrastructure/SecureSettingsMigrationTest.cs create mode 100644 tests/MarketAnalysisWorkflowIdentityTest.cs create mode 100644 tests/MarketChatSessionBehaviorTest.cs create mode 100644 tests/McpServiceRuntimeTest.cs create mode 100644 tests/StructuredOutputTest.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index b423cff..c4a2669 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,16 +13,20 @@ + + + + diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..4d736c1 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,7 @@ + + + + + + + diff --git "a/docs/RAG\346\250\241\345\235\227\345\210\206\346\236\220\346\212\245\345\221\212.md" "b/docs/RAG\346\250\241\345\235\227\345\210\206\346\236\220\346\212\245\345\221\212.md" new file mode 100644 index 0000000..ed4c44e --- /dev/null +++ "b/docs/RAG\346\250\241\345\235\227\345\210\206\346\236\220\346\212\245\345\221\212.md" @@ -0,0 +1,996 @@ +# RAG 模块分析与重构实施报告 + +> 核验日期:2026-07-30 +> 分析对象:`src/MarketAssistant.Rag/` 及其在 `MarketAssistant.App`、`MarketAssistant.App.Services`、`tests/Vectors` 中的调用与测试 +> 适用读者:负责实施重构的初级、中级开发者,以及负责方案评审的高级开发者 +> 文档状态:已按当前源码核验,可作为重构实施基线 + +--- + +## 1. 结论先行 + +当前 RAG 模块已经具备完整的基础链路:文档解析、文本清洗、语义分块、文本向量化、SQLiteVec 持久化、查询改写、向量召回和启发式重排。 + +但是,当前实现存在两个必须先修复的正确性问题: + +1. `TextParagraph.TextEmbedding` 使用 `CosineDistance`,检索结果应按“距离越小越相关”解释;当前去重和重排却按“分数越大越相关”处理,可能造成排序反向。 +2. 文本向量维度固定为 1024,但摄取链没有显式校验;块级异常被捕获后不向上报告,界面仍可能把部分失败的文件计为成功。 + +在这两个问题修复前,不应优先投入混合检索、LLM Query Rewrite 或解析器大改。否则新增能力会建立在不可靠的排序和摄取结果之上,难以判断改动是否真正提升质量。 + +推荐实施顺序: + +```text +P0 检索分数方向 + -> P0 向量维度与摄取结果 + -> P1 文档替换/删除与键稳定性 + -> P1 保留检索元数据并扩展上下文 + -> P1 建立最小离线评估基线 + -> P1 收敛伪多模态设计 + -> P2 收敛 PDF/DOCX 解析链 + -> P2 配置化查询词表 + -> P2 基于评估结果决定是否引入混合检索 +``` + +--- + +## 2. 核验范围与验证结果 + +### 2.1 核验依据 + +本报告已对照以下内容: + +- `src/MarketAssistant.Rag/` 的模型、接口、服务和依赖注入注册; +- `src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs` 的摄取调用; +- `src/MarketAssistant.App.Services/Agents/Tools/GroundingSearchTools.cs` 的检索调用; +- `Directory.Packages.props` 中的实际 NuGet 版本; +- `tests/Vectors/` 下的单元测试和集成测试; +- `docs/Todo.md` 中已有的 RAG 待办。 + +本文中的行号基于 2026-07-30 的源码快照。后续代码变化时,以文件路径、类型名和方法名为主要定位依据。 + +### 2.2 当前验证结果 + +| 验证项 | 结果 | 说明 | +|---|---:|---| +| 解决方案构建 | 通过 | 0 个错误,19 个警告 | +| RAG 核心单元测试 | 49/49 通过 | 覆盖 cleaning、chunking、mapper、rewrite、reranker、Markdown reader、CLIP 基础行为 | +| `tests/Vectors` 全套测试 | 69/72 通过 | 3 个集成测试因未配置 `JINA_API_KEY` 失败,不属于已确认的代码回归 | + +当前测试数量不能证明检索质量良好。现有测试主要验证功能路径,尚未验证: + +- `CosineDistance` 分数方向; +- 非 1024 维文本模型; +- 部分块失败时的文件级状态; +- 重复摄取后的旧记录清理; +- 邻接上下文扩展; +- 真实 CLIP ONNX 推理和真正的跨模态检索; +- Recall@K、MRR、nDCG 等离线质量指标。 + +--- + +## 3. 当前架构 + +### 3.1 摄取链路 + +```text +File + -> DocumentBlockReaderFactory + -> IDocumentBlockReader + -> DocumentBlock[] + -> DocumentBlockMapper + -> TextParagraph[] + -> IEmbeddingGenerator> + -> VectorStoreCollection.UpsertAsync + -> SQLiteVec +``` + +入口:`RagIngestionService.IngestFileAsync`。 + +PDF/DOCX 当前实际路径为: + +```text +PDF/DOCX + -> PdfMarkdownConverter / DocxMarkdownConverter + -> Markdown 文本 + -> Markdig 再解析 + -> DocumentBlock[] +``` + +证据: + +- `Services/PdfBlockReader.cs:21-26` +- `Services/DocxBlockReader.cs:20-23` +- `Services/MarkdownDocumentBlockReader.cs:37-65` + +### 3.2 检索链路 + +```text +User Query + -> QueryRewriteService 生成最多 3 个词表变体 + -> 原查询 + 变体批量生成文本向量 + -> 每个查询搜索 TextEmbedding + -> 合并候选 + -> 按 Link + Name + Value 去重 + -> RerankerService 启发式重排 + -> Top-K TextSearchResult +``` + +入口:`RetrievalOrchestrator.RetrieveAsync`。 + +当前只搜索文本向量: + +```csharp +var vectorSearchOptions = new VectorSearchOptions +{ + VectorProperty = r => r.TextEmbedding +}; +``` + +证据:`Services/RetrievalOrchestrator.cs:78-82`。 + +### 3.3 关键类型职责 + +| 类型 | 当前职责 | +|---|---| +| `TextParagraph` | SQLiteVec 记录模型,保存文本、两个向量字段及文档元数据 | +| `RagConstants` | 固定向量维度为 1024 | +| `RagIngestionService` | 文档级摄取编排 | +| `DocumentBlockMapper` | 将不同块类型转换为 `TextParagraph` | +| `TextCleaningService` | 文本清洗 | +| `TextChunkingService` | 400 token 分块,40 token overlap | +| `QueryRewriteService` | 基于硬编码词表生成查询变体,不调用 LLM | +| `RetrievalOrchestrator` | 批量生成查询向量、多查询召回、去重和重排 | +| `RerankerService` | 向量、关键词、时效、长度和多样性启发式评分 | +| `ClipImageEmbeddingService` | CLIP ONNX 图像向量;失败时返回哈希向量 | + +--- + +## 4. 已确认的合理设计 + +以下设计可以保留,不建议在第一阶段重写: + +1. **RAG 独立类库** + 模块通过接口被上层调用,分层方向符合项目约束。 + +2. **检索阶段不强制依赖 LLM** + Query Rewrite 和 Reranker 目前均为本地启发式逻辑,延迟和费用可控。后续即使增加 LLM 改写,也应保持可关闭和可降级。 + +3. **查询向量批量生成** + `RetrievalOrchestrator.cs:66-71` 一次生成原查询及变体的向量,减少 HTTP 往返。 + +4. **摄取时按块批量生成文本向量** + `RagIngestionService.cs:113-125` 对一个 `DocumentBlock` 产生的多个段落批量生成向量。注意:这是“每块一次”,不是“整篇文档一次”。 + +5. **结构化元数据模型** + `Order`、`Section`、`BlockKind`、`ContentHash`、`PublishedAt` 已存在,为上下文扩展和增量更新提供了基础。 + +6. **金融领域的 CJK n-gram 和关键词权重** + 作为轻量级相关性信号具有实用价值,但权重需要评估集校准,不能仅凭主观调整。 + +7. **图片按 SHA-256 做文档内精确去重** + 该逻辑适合识别字节完全相同的图片,但不具备感知相似去重能力。 + +--- + +## 5. 已确认问题与优先级 + +| ID | 优先级 | 问题 | 当前事实 | 主要影响 | +|---|---|---|---|---| +| RAG-001 | P0 | 余弦距离方向处理反了 | 配置为 `CosineDistance`,代码却保留最高分并按降序重排 | 最相关结果可能被降权 | +| RAG-002 | P0 | 文本向量维度无显式校验 | Schema 固定 1024,文本向量原样写入 | 写入/检索失败,问题定位困难 | +| RAG-003 | P0 | 块失败被吞掉,文件仍可能显示成功 | 块级 catch 只记 Warning;UI 随后 `successCount++` | 知识库处于不完整状态但用户不知情 | +| RAG-004 | P1 | 重复摄取不会删除旧块 | 只有 `UpsertAsync`,没有文档级替换/删除 | 文档更新后新旧内容同时被召回 | +| RAG-005 | P1 | 部分记录键可能覆盖合法块 | 标题、列表、表格、图片键未包含稳定顺序,同内容重复出现时可能相同 | 同一文档内记录被覆盖 | +| RAG-006 | P1 | 检索过早丢弃元数据 | 搜索后立即转换为 `TextSearchResult` | 无法可靠使用 `Order`、`Section`、`PublishedAt` | +| RAG-007 | P1 | 没有邻接上下文 | 只返回命中的独立段落 | 表格、标题后的正文缺少语境 | +| RAG-008 | P1 | “多模态检索”描述与实现不符 | 只搜索 `TextEmbedding`,`ImageEmbedding` 未进入查询链路 | 维护者可能基于错误假设继续扩展 | +| RAG-009 | P1 | 哈希向量被当作图像语义降级 | CLIP 失败后返回 SHA-256 派生向量 | 向量确定但无语义,可能污染未来图像检索 | +| RAG-010 | P2 | PDF/DOCX 双重解析 | 先转 Markdown,再由 Markdig 解析 | 结构信息丢失、启发式叠加 | +| RAG-011 | P2 | Query Rewrite 和评分词表硬编码 | 词表、停用词、权重均在代码内 | 维护成本高,调整必须发版 | +| RAG-012 | P2 | 只有向量召回 | 当前 SQLiteVec connector 不提供可直接使用的 Hybrid Search | 股票代码、数值、专有名词召回不足 | +| RAG-013 | P1 | 缺少离线质量评估 | 没有标准问题集和排序指标 | 无法证明重构提升或退化 | + +--- + +## 6. 关键问题详解 + +### 6.1 RAG-001:余弦距离方向错误 + +模型配置: + +```csharp +[VectorStoreVector( + RagConstants.EmbeddingDimension, + DistanceFunction = DistanceFunction.CosineDistance, + IndexKind = IndexKind.Hnsw)] +public Embedding TextEmbedding { get; set; } = default!; +``` + +位置:`TextParagraph.cs:28-30`。 + +在当前 `Microsoft.SemanticKernel.Connectors.SqliteVec 1.74.0-preview` 和 `CosineDistance` 配置下,应按距离语义处理:距离越小越相关。当前代码存在三处反向处理: + +- `RetrievalOrchestrator.cs:127-131`:去重时 `OrderByDescending`,保留最大值; +- `RerankerService.cs:90-105`:最大值被归一化为最高相关度; +- `RerankerService.cs:122-125`:异常回退按降序返回。 + +该问题必须由集成测试锁定。未来升级向量库连接器时,也必须重新验证 `VectorSearchResult.Score` 的具体语义,不能只根据属性名猜测。 + +### 6.2 RAG-002:文本向量不会被本模块静默截断 + +原实现中,截断/补零只发生在 CLIP 图像向量: + +- `ClipImageEmbeddingService.cs:419-440` + +文本向量在以下位置被原样写入: + +- `RagIngestionService.cs:118-124` + +因此,非 1024 维文本模型的真实风险不是“被本模块静默截断”,而是与 1024 维 Schema 不兼容,最终在写入或搜索时失败。 + +第一阶段不建议实现“动态向量维度”。`TextParagraph` 的向量维度通过特性固定,动态支持多个维度还涉及 Collection Schema、存量库迁移和模型切换策略。正确的短期方案是: + +- 明确系统当前只支持 1024 维文本 Embedding; +- 首次生成向量后立即校验维度; +- 不匹配时失败退出并给出可操作错误; +- 更换模型或维度时要求重建对应 Collection。 + +### 6.3 RAG-003:部分失败被报告为成功 + +块级异常在 `RagIngestionService.cs:128-132` 被捕获后仅记录警告,方法继续执行并最终正常返回。 + +调用方 `SettingsPageViewModel.cs:523-526` 只要方法没有抛出异常,就会增加成功计数。 + +结果是:文档可能只有部分块进入向量库,但界面显示“成功向量化”。这是数据完整性问题,不只是日志体验问题。 + +### 6.4 RAG-004:增量摄取不是“只插入新 ContentHash” + +简单地跳过已存在的 `ContentHash` 不能解决文档更新: + +- 被删除的旧段落仍会保留; +- 段落顺序变化后,旧的邻接关系仍可能存在; +- 摄取中途失败会留下新旧混合数据。 + +目标语义应定义为“按文档替换”: + +```text +解析并生成新版本 + -> 校验全部结果 + -> 写入新记录 + -> 删除该文档不再存在的旧记录 + -> 更新文档清单 +``` + +在没有事务能力时,至少需要文档清单和失败恢复策略,不能只比较单个块哈希。 + +### 6.5 RAG-005:同内容块可能产生相同 Key + +例如标题键: + +```csharp +Key = $"{fileHash}:hdg:{headingBlock.Level}:{hash[..8]}" +``` + +位置:`DocumentBlockMapper.cs:81-100`。 + +同一文件中若两次出现同级、同文本标题,Key 相同,后写入记录会覆盖先写入记录。列表、表格和图片键也存在相同风险。 + +键应同时包含稳定文档标识、块类型、顺序和内容哈希,例如: + +```text +{documentId}:{blockKind}:{order:D6}:{contentHashPrefix} +``` + +不要使用随机 GUID,否则无法稳定比较新旧版本。 + +### 6.6 RAG-006/RAG-007:元数据被过早丢弃 + +`RetrievalOrchestrator.cs:103-112` 将 `TextParagraph` 立即压缩成只有 `Name`、`Link`、`Value` 的 `TextSearchResult`。 + +这会丢失: + +- `Order`:无法定位前后块; +- `Section`:无法补充章节标题; +- `PublishedAt`:Reranker 只能从 URL/正文猜时间; +- `BlockKind`:无法针对表格、标题、图片使用不同策略; +- `ContentHash`:无法做候选级精确去重。 + +正确做法是内部全程保留 `TextParagraph`,只在公开 API 边界转换为 `TextSearchResult`。 + +### 6.7 RAG-008/RAG-009:当前不是跨模态检索 + +当前事实: + +- 图片块会生成 Caption; +- Caption 会继续生成 `TextEmbedding`,因此可以通过文本查询召回图片说明; +- 图片还会生成 `ImageEmbedding`; +- 检索只指定 `TextEmbedding`,没有查询或融合 `ImageEmbedding`。 + +所以当前具备的是“图片 Caption 的文本检索”,不是“文本与图片共享语义空间的跨模态检索”。 + +此外,仅把不同模型的输出都调整为 1024 维,并不能让它们进入同一语义空间。CLIP 失败后生成的哈希向量只具有确定性,不具有语义相关性,不应参与检索排序。 + +### 6.8 RAG-012:不能直接利用 `IsFullTextIndexed` 完成混合检索 + +虽然 `TextParagraph.Text` 标记了: + +```csharp +[VectorStoreData(IsFullTextIndexed = true)] +``` + +但项目当前使用 `Microsoft.SemanticKernel.Connectors.SqliteVec 1.74.0-preview`,该连接器不能据此提供现成的全文检索或 Hybrid Search API。 + +因此,不应让开发者直接在 `RetrievalOrchestrator` 中调用一个并不存在的 FTS 搜索能力。混合检索需要先做技术选型: + +1. 更换为明确支持关键词检索与向量检索的存储实现;或 +2. 引入独立、成熟的词法检索组件,再使用 RRF 融合。 + +本项目坚持避免手写基础设施,不建议在业务服务中直接拼接 FTS5 SQL。 + +--- + +## 7. 目标架构与边界 + +### 7.1 目标摄取链 + +```text +File + -> IDocumentBlockReader + -> DocumentBlock[] + -> DocumentBlockMapper + -> TextParagraph[] + -> 文本向量批量生成 + -> 维度与数量校验 + -> 文档级替换写入 + -> RagIngestionResult +``` + +核心约束: + +- 一个文件的摄取结果必须明确区分成功、部分失败和失败; +- 不允许块失败后静默计为文件成功; +- 同一文档重复摄取后,旧版本记录必须可清理; +- 取消令牌必须传播到解析、Embedding 和存储调用; +- 1024 维约束在模型调用后立即验证。 + +### 7.2 目标检索链 + +```text +User Query + -> 可配置 Query Rewrite + -> 批量文本 Embedding + -> 向量召回(保留 TextParagraph + Distance) + -> 候选去重 + -> 距离方向正确的 Reranker + -> Section/Neighbor Context 扩展 + -> 最终转换为 TextSearchResult +``` + +后续可选扩展: + +```text +向量召回 ----┐ + ├-> RRF 融合 -> Reranker -> Context Expansion +关键词召回 --┘ +``` + +### 7.3 本轮重构非目标 + +以下内容不要与 P0 修复放在同一个 PR: + +- 引入 LLM Query Rewrite; +- 同时支持任意 Embedding 维度; +- 更换向量数据库; +- 一次性重写 PDF、DOCX、Markdown 三套解析器; +- 实现文本查询到 CLIP 图片向量的跨模态搜索; +- 调整所有 Reranker 权重。 + +原因:这些改动会扩大验证面,使正确性修复难以单独评审和回归。 + +--- + +## 8. 分阶段重构实施手册 + +### 阶段 0:建立基线 + +难度:初级开发者可执行。 +目标:在修改前固定当前行为和验证命令。 + +#### 操作步骤 + +1. 运行解决方案构建: + +```bash +dotnet build MarketAssistant.slnx -c Debug +``` + +2. 运行 RAG 单元测试: + +```bash +dotnet test tests/TestMarketAssistant.csproj -c Debug --filter "FullyQualifiedName~TestMarketAssistant.Vectors" +``` + +3. 如果没有配置 `JINA_API_KEY`,记录相关集成测试为环境阻塞,不要为了“全绿”删除或弱化断言。 +4. 保存至少一个中文 Markdown、一个包含表格的 DOCX、一个 PDF 作为回归样本。测试样本不得包含密钥或私人数据。 + +#### 完成标准 + +- 构建结果已记录; +- 失败测试已区分代码失败和环境失败; +- 后续每个阶段均可重复执行同一组命令。 + +--- + +### 阶段 1:修复距离分数方向(RAG-001) + +难度:中级开发者主导,初级开发者补测试。 +目标:统一使用“距离越小越相关”的语义,禁止继续使用含糊的 `VectorScore` 命名。 + +#### 修改文件 + +- `src/MarketAssistant.Rag/Interfaces/IRerankerService.cs` +- `src/MarketAssistant.Rag/Services/RetrievalOrchestrator.cs` +- `src/MarketAssistant.Rag/Services/RerankerService.cs` +- `tests/Vectors/RerankerServiceTest.cs` +- `tests/Vectors/RetrievalOrchestratorIntegrationTest.cs` + +#### 操作步骤 + +1. 将 `ScoredSearchResult.VectorScore` 重命名为 `VectorDistance`。 +2. 去重时保留距离最小的结果: + +```csharp +.Select(group => group.OrderBy(item => item.VectorDistance).First()) +``` + +3. Reranker 的归一化改为距离反转: + +```csharp +var normalizedSimilarity = range < 1e-9 + ? 1.0 + : (maxDistance - item.VectorDistance) / range; +``` + +4. 异常回退改为按距离升序。 +5. 日志字段使用 `distance` 和 `similarity`,不要继续统称 `score`。 +6. 增加单元测试,至少覆盖: + - 距离 0.1 的结果必须排在 0.8 前; + - 同一个结果被多个查询召回时保留最小距离; + - 所有距离相同时行为稳定; + - Reranker 抛出异常时仍按距离升序。 +7. 增加 SQLiteVec 集成测试,用两个可控向量验证真实连接器返回顺序和距离含义。 + +#### 禁止做法 + +- 不要只把最终 `OrderByDescending` 改成 `OrderBy`,而保留错误的 min-max 归一化; +- 不要使用 `1 - distance` 后直接假设结果一定在 `[0,1]`; +- 不要同时调整 0.6/0.2/0.1/0.1 权重。 + +#### 完成标准 + +- 所有新增测试通过; +- 已有 Reranker 测试通过; +- 代码中不再出现把 `CosineDistance` 当作“越大越好”的路径; +- `VectorScore` 命名已从该链路移除。 + +--- + +### 阶段 2:修复维度校验和摄取结果(RAG-002/RAG-003) + +难度:中级开发者主导。 +目标:任何块失败都必须进入结构化结果,向量维度错误必须在 Upsert 前被明确拒绝。 + +#### 修改文件 + +- `src/MarketAssistant.Rag/Interfaces/IRagIngestionService.cs` +- `src/MarketAssistant.Rag/Services/RagIngestionService.cs` +- `src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs` +- `tests/Vectors/RagIngestionServiceIntegrationTest.cs` +- 新增对应的摄取结果单元测试文件 + +#### 建议类型 + +```csharp +public sealed record RagIngestionFailure( + int BlockOrder, + string ErrorCode, + string Message); + +public sealed record RagIngestionResult( + int BlockCount, + int ParagraphCount, + IReadOnlyList Failures) +{ + public bool IsSuccess => Failures.Count == 0; + public bool IsPartialSuccess => ParagraphCount > 0 && Failures.Count > 0; +} +``` + +接口建议增加取消令牌并返回结果: + +```csharp +Task IngestFileAsync( + VectorStoreCollection collection, + string filePath, + IEmbeddingGenerator> embeddingGenerator, + CancellationToken cancellationToken = default); +``` + +#### 操作步骤 + +1. 为每个块记录成功段落数和失败原因。 +2. 生成向量后先校验: + - `embeddings.Count == paragraphList.Count`; + - 每个 `embeddings[i].Vector.Length == RagConstants.EmbeddingDimension`。 +3. 校验失败时不要进入 `UpsertAsync`。 +4. `OperationCanceledException` 必须继续抛出,不得转换成普通失败。 +5. 其余块异常可以继续处理后续块,但必须加入 `Failures`。 +6. UI 根据结果分别统计: + - 成功; + - 部分成功; + - 失败。 +7. 完成提示中显示失败文件和失败块数量,不得把部分成功计入完全成功。 +8. 所有异步调用传递 `CancellationToken`。 + +#### 错误消息要求 + +维度错误至少包含: + +```text +Embedding dimension mismatch. Expected 1024, actual 1536. +Model: {modelName if available}. Rebuild the collection after selecting a supported model. +``` + +不得记录 API Key、完整用户文档内容或完整向量。 + +#### 测试清单 + +- 1024 维向量正常写入; +- 1536 维向量在第一次 Upsert 前失败; +- 返回向量数量少于文本数量时失败; +- 中间块失败时结果为部分成功; +- 所有块失败时结果为失败; +- 取消操作抛出 `OperationCanceledException`; +- UI 不再把部分成功累计到成功数。 + +#### 完成标准 + +- 用户能区分成功、部分成功、失败; +- 维度错误不会产生任何对应块记录; +- 日志和 UI 都能定位到文件及块序号; +- 构建与 RAG 测试通过。 + +--- + +### 阶段 3:实现文档级替换和稳定键(RAG-004/RAG-005) + +难度:中级开发者实施,高级开发者评审数据一致性方案。 +目标:重复摄取同一文档后,不保留旧版本孤儿记录,也不覆盖同文档内合法重复块。 + +#### 设计要求 + +引入文档清单抽象,例如: + +```csharp +public interface IRagDocumentCatalog +{ + Task GetAsync(string collectionName, string documentId, CancellationToken ct); + Task SaveAsync(RagDocumentManifest manifest, CancellationToken ct); + Task DeleteAsync(string collectionName, string documentId, CancellationToken ct); +} +``` + +`RagDocumentManifest` 至少保存: + +- `CollectionName`; +- `DocumentId`; +- `DocumentUri`; +- 文档内容哈希; +- 当前记录 Key 集合; +- Embedding 模型标识和维度; +- 更新时间。 + +具体持久化实现必须先评审。不要在业务服务中临时手写 SQL 或把清单只放进内存。 + +#### 操作步骤 + +1. 定义稳定的 `DocumentId`。本地文件建议先规范化绝对路径(调用 `Path.GetFullPath`,统一目录分隔符;Windows 下按不区分大小写处理),再计算哈希;不要直接把路径明文放进 Key,也不要使用文件内容作为文档身份。 +2. 修改所有块 Key,使其包含:文档 ID、块类型、全局顺序、内容哈希前缀。 +3. 完整解析并向量化新版本,得到 `newKeys`。 +4. 读取旧清单得到 `oldKeys`。 +5. 写入新记录。 +6. 删除 `oldKeys.Except(newKeys)`。 +7. 只有前述步骤成功后才更新清单。 +8. 删除文档时,根据清单删除全部记录并移除清单。 +9. 设计失败恢复:如果写入新记录成功但清单更新失败,下次摄取必须能重新收敛,而不是永久遗留脏数据。 + +#### 测试清单 + +- 同一文件重复摄取两次,记录数不增长; +- 删除原文中一个段落后再次摄取,旧段落不再能召回; +- 同一文件出现两个同名标题,两者都存在且 Key 不同; +- 文件内容不变时可安全重复执行; +- 删除文档后,其所有记录均不可召回; +- 中途失败时旧版本仍可用,或系统明确标记为失败待恢复。 + +#### 完成标准 + +- 文档更新后向量库中不存在旧块; +- 同内容、同类型但位于不同顺序的块不会互相覆盖; +- 文档删除有公开服务入口和测试; +- 一致性方案通过高级开发者评审。 + +--- + +### 阶段 4:保留候选元数据并扩展上下文(RAG-006/RAG-007) + +难度:中级开发者主导。 +目标:Reranker 和上下文扩展阶段都能使用完整记录。 + +#### 建议内部模型 + +```csharp +public sealed record RagSearchCandidate( + TextParagraph Record, + double VectorDistance, + string MatchedQuery); +``` + +`IRetrievalOrchestrator` 的外部返回类型可以暂时保持 `TextSearchResult`,但内部不要提前转换。 + +#### 操作步骤 + +1. 搜索结果先转换为 `RagSearchCandidate`,保留完整 `TextParagraph`。 +2. Reranker 输入改为 `RagSearchCandidate`。 +3. 时效评分优先使用 `PublishedAt`,只有缺失时才从 URL/正文推断。 +4. 去重优先使用 `Record.Key`;内容级去重可以使用 `ContentHash` 作为第二层策略。 +5. 新增 `IContextExpansionService`,按 `DocumentId + Order` 获取命中块前后记录。 +6. 首版窗口固定为前 1 块、后 1 块,并设置总字符或 token 上限。 +7. 同一 Section 内优先扩展;跨章节时停止,避免拼入无关段落。 +8. 表格块优先补充最近标题;标题块优先补充后一个正文块。 +9. 完成排序和扩展后,再转换为 `TextSearchResult` 供现有 Agent 工具使用。 + +#### 测试清单 + +- `Section`、`Order`、`PublishedAt` 在重排前后不丢失; +- 命中表格时能带上表头或章节; +- 不跨 `DocumentUri` 扩展; +- 不跨主要 Section 扩展; +- 多个命中窗口重叠时不会重复输出; +- 扩展后结果不超过配置的 token 上限。 + +#### 完成标准 + +- 检索内部链路不再以 `TextSearchResult` 作为核心数据模型; +- 时效评分可读取真实 `PublishedAt`; +- 上下文扩展行为可配置、可测试、有上限。 + +--- + +### 阶段 5:收敛图片与“多模态”设计(RAG-008/RAG-009) + +难度:中级开发者实施,高级开发者确定产品边界。 +目标:只保留真实可用的能力,避免哈希向量和同维度造成错误语义。 + +#### 推荐短期方案 + +保留“图片 -> Caption -> TextEmbedding -> 文本检索”,暂不宣称跨模态检索。 + +#### 操作步骤 + +1. 修改注释、日志和 UI 文案,将能力名称改为“图片说明文本检索”。 +2. CLIP 加载或推理失败时: + - 不生成可检索的哈希语义向量; + - 将图像向量标记为不可用; + - Caption 仍可成功时,允许通过文本向量召回。 +3. 评估 `ImageEmbedding` 是否还有实际消费者: + - 若没有,先停止写入,后续迁移时再移除字段; + - 若要保留,增加明确的可用状态,不用零向量伪装成功。 +4. 只有在文本查询也使用与图像编码器配套的 CLIP 文本塔,并完成融合检索测试后,才能恢复“跨模态检索”描述。 + +#### 测试清单 + +- CLIP 模型缺失时不会产生哈希语义向量; +- Caption 成功时仍可通过 `TextEmbedding` 检索图片块; +- Caption 和 CLIP 都失败时,摄取结果明确包含失败信息; +- 普通文本块不需要写入 1024 维零图像向量,前提是连接器允许可空字段。 + +#### 完成标准 + +- 文档、代码注释和产品文案对能力描述一致; +- 哈希向量不参与语义检索; +- “跨模态检索”只有在共享模型空间和真实查询链路存在时才使用该名称。 + +--- + +### 阶段 6:收敛 PDF/DOCX 解析链(RAG-010) + +难度:中级开发者实施,高级开发者评审解析策略。 +目标:减少 `PDF/DOCX -> Markdown -> Markdig` 的重复结构推断。 + +#### 实施原则 + +不要同时重写 PDF 和 DOCX。建议先选择结构更明确的 DOCX 作为试点。 + +#### DOCX 试点步骤 + +1. 为当前 `DocxMarkdownConverter` 建立回归样本:标题、列表、表格、图片、段落顺序。 +2. 让 `DocxBlockReader` 直接遍历 OpenXML 元素并产出 `DocumentBlock`。 +3. 保证全局 `Order` 单调递增。 +4. 标题样式映射为 `HeadingBlock`;表格映射为 `TableBlock`;图片保存后映射为 `ImageBlock`。 +5. 保留旧转换器作为短期 feature flag 回退路径。 +6. 对相同样本比较新旧块序列,不以 Markdown 文本完全一致为目标,而以结构和可检索内容不退化为目标。 + +#### PDF 后续步骤 + +PDF 缺乏稳定语义结构,不应简单照搬 DOCX 方案。需要先定义可接受的提取目标: + +- 页面正文顺序; +- 标题启发式; +- 表格是否要求结构化; +- 图片是否要求提取; +- 扫描 PDF 是否支持 OCR。 + +若现有 PdfPig 启发式无法达到目标,应评估成熟解析组件,而不是继续叠加正则和字体阈值。 + +#### 完成标准 + +- DOCX 直接输出 `DocumentBlock[]`; +- 新旧路径有同一批回归样本; +- 可通过开关回退; +- PDF 在完成选型前不做大规模复制式重构。 + +--- + +### 阶段 7:配置化查询词表(RAG-011) + +难度:初级开发者可在中级开发者指导下执行。 +目标:词表可维护、配置无效时安全失败或回退。 + +#### 操作步骤 + +1. 定义强类型配置: + - `QueryRewriteOptions`; + - `RerankerOptions`。 +2. 将同义词、停用词、金融关键词、时间关键词迁移到项目统一配置机制。 +3. 使用 `IOptions` 注入,不要在服务内直接读取文件。 +4. 对配置做启动校验: + - 同义词 key 不为空; + - 权重范围有效; + - Reranker 各主权重之和为 1; + - n-gram 最小值不大于最大值。 +5. 保留代码内默认值,配置缺失时行为与当前版本一致。 +6. 增加配置绑定和无效配置测试。 + +#### 注意事项 + +配置化只解决维护问题,不等于 Query Rewrite 质量提升。词表增删必须由离线评估结果支撑。 + +#### 完成标准 + +- 修改词表不需要改服务代码; +- 配置错误在启动或加载时给出明确消息; +- 缺省配置下原有测试继续通过。 + +--- + +### 阶段 8:建立离线检索评估(RAG-013) + +难度:初级开发者整理数据,中级开发者实现指标。 +目标:任何检索策略调整都能量化比较。该阶段必须先于混合检索实施,但最小数据集可以与前述正确性修复并行准备。 + +#### 最小数据集 + +首版建议 30~50 个问题,覆盖: + +- 公司财报指标; +- 股票代码和数值精确查询; +- 同义表达; +- 表格问答; +- 需要标题或前后文才能理解的问题; +- 图片 Caption 查询; +- 无答案问题。 + +每个问题至少标注: + +- 查询文本; +- 相关文档 ID; +- 相关段落 Key 或可接受 Key 集合; +- 是否必须命中 Top 1、Top 3 或 Top 5; +- 备注和数据来源。 + +#### 指标 + +首版实现: + +- Recall@1、Recall@3、Recall@5; +- MRR; +- 无答案查询的误召回率; +- P50/P95 检索耗时。 + +有多级相关性标注后再增加 nDCG,不要在只有二元标签时为了指标数量强行引入。 + +#### 基线要求 + +每个重构阶段记录: + +```text +代码版本 +Embedding 模型与维度 +向量库版本 +数据集版本 +Recall@K / MRR +P50 / P95 +失败查询列表 +``` + +#### 完成标准 + +- 评估可由单条命令重复执行; +- 结果包含版本和模型信息; +- 失败查询可直接定位到预期文档/段落; +- Reranker 权重或 Query Rewrite 词表变更必须附前后指标。 + +--- + +### 阶段 9:评估混合检索(RAG-012) + +难度:中级开发者调研,高级开发者做 ADR 决策。 +目标:为股票代码、精确数值和专有名词提供词法召回,不在业务层自建搜索基础设施。 + +#### 前置条件 + +必须先完成: + +- RAG-001 距离方向修复; +- RAG-013 最小评估集; +- 文档级替换与删除机制。 + +#### 技术选型要求 + +候选方案必须评估: + +- 中文文本和数字精确匹配; +- 本地部署能力; +- 与 .NET 10 的兼容性; +- 增量写入和删除; +- 索引一致性; +- 维护活跃度; +- 是否提供稳定 API,避免业务层手写 SQL。 + +#### 融合策略 + +首版优先使用 RRF(Reciprocal Rank Fusion),不要直接混合不可比较的原始分数: + +```text +RRF(d) = sum(1 / (k + rank_i(d))) +``` + +初始可使用 `k = 60`,但最终值必须通过评估集确认。 + +#### 测试样例 + +- 股票代码:`600519`; +- 精确指标:`PE 15.3`; +- 公司全称和简称; +- 中英文混合术语; +- 向量相关但关键数字不匹配的干扰项。 + +#### 完成标准 + +- 形成 ADR,明确采用或暂缓; +- 混合检索在评估集上优于纯向量基线; +- 文档更新和删除能同时更新两类索引; +- 不在 `RetrievalOrchestrator` 内直接堆叠存储实现细节。 + +--- + +## 9. PR 拆分建议 + +不要提交一个覆盖全部阶段的大 PR。建议按以下边界拆分: + +| PR | 内容 | 评审重点 | +|---|---|---| +| PR-1 | 距离语义重命名、排序修复、测试 | 所有升降序和归一化是否一致 | +| PR-2 | 摄取结果、维度校验、取消传播、UI 状态 | 是否仍存在静默成功 | +| PR-3 | 稳定 Key、文档清单、替换与删除 | 数据一致性和失败恢复 | +| PR-4 | 候选模型、元数据保留、上下文扩展 | 是否跨文档/跨章节误拼接 | +| PR-5 | 最小离线评估框架 | 数据集可重复性和指标正确性 | +| PR-6 | 图片能力收敛 | 哈希向量是否完全退出语义检索 | +| PR-7 | DOCX 直接块解析 | 结构回归和回退能力 | +| PR-8 | 强类型词表配置 | 默认兼容和配置校验 | +| PR-9 | 混合检索 ADR/实现 | 是否有量化收益和索引一致性 | + +每个代码 PR 必须执行: + +```bash +dotnet build MarketAssistant.slnx -c Debug +dotnet test tests/TestMarketAssistant.csproj -c Debug --filter "FullyQualifiedName~TestMarketAssistant.Vectors" +``` + +涉及数据结构或重大架构改动时,执行全量测试: + +```bash +dotnet test tests/TestMarketAssistant.csproj -c Debug +``` + +--- + +## 10. 开发者检查清单 + +### 编码前 + +- [ ] 已阅读根目录和 `src/MarketAssistant.Rag/AGENTS.md`; +- [ ] 已确认当前 PR 只处理一个阶段; +- [ ] 已为错误行为补充失败测试; +- [ ] 已确认 NuGet API 在当前版本真实存在,没有根据其他连接器文档猜测。 + +### 编码中 + +- [ ] 使用 `Distance`/`Similarity` 精确命名,不使用含糊的 `Score`; +- [ ] 异步 API 传播 `CancellationToken`; +- [ ] `OperationCanceledException` 不被吞掉; +- [ ] 不记录密钥、完整文档、完整向量; +- [ ] 不在 ViewModel 或业务服务中自建 retry、缓存、搜索框架; +- [ ] 不用随机 Key 代替稳定身份; +- [ ] 不把部分成功当作完全成功。 + +### 提交前 + +- [ ] 解决方案构建通过; +- [ ] 相关单元测试通过; +- [ ] 集成测试失败已区分环境原因和代码原因; +- [ ] 新增行为有验收测试; +- [ ] 日志和错误消息能定位文件、块序号和错误类型; +- [ ] 未提交 API Key、用户文档或本地数据库; +- [ ] 报告和代码中的能力描述保持一致。 + +--- + +## 11. 风险与回滚 + +| 风险 | 预防措施 | 回滚方式 | +|---|---|---| +| 排序修复改变线上结果顺序 | 集成测试加离线评估基线 | 保留旧排序 feature flag 仅用于短期对比,不长期双轨 | +| 摄取结果接口改动影响 UI | 先修改接口和测试,再修改唯一调用方 | 单 PR 内原子修改,不保留两个含义不同的入口 | +| Key 规则变化导致存量记录不可管理 | 版本化 Collection 或执行明确重建 | 删除新版本 Collection,切回旧 Collection | +| 文档替换中途失败 | 文档清单、幂等 Key、失败恢复测试 | 保留旧清单,重试同一文档摄取 | +| 解析器重构造成结构退化 | 固定样本、旧路径开关、块序列对比 | 切回 Markdown 转换路径 | +| 混合检索引入双索引不一致 | 统一写入编排和删除流程 | 暂停词法召回,保留向量索引为主链 | + +向量 Schema、Embedding 模型或 Key 规则发生不兼容变化时,优先创建新 Collection 并重建,不要原地修改存量数据库后寄希望于自动兼容。 + +--- + +## 12. 最终验收标准 + +完成 P0 和 P1 后,RAG 模块至少应满足: + +1. 余弦距离越小的候选不会在任何阶段被错误降权; +2. 非 1024 维文本向量在写入前失败,并给出清晰错误; +3. 块级失败不会被 UI 报告为文件完全成功; +4. 同一文档重复摄取不会持续累积旧块; +5. 文档删除后相关记录不再被召回; +6. 同一文件的重复标题、列表、表格和图片不会因 Key 相同被覆盖; +7. Reranker 能使用 `PublishedAt`,上下文扩展能使用 `Order` 和 `Section`; +8. 图片能力被准确描述为 Caption 文本检索,哈希向量不参与语义排序; +9. 所有关键行为均有自动化测试; +10. 每次检索策略调整都能通过统一评估集量化比较。 + +--- + +## 13. 总结 + +当前模块不是需要推倒重写,而是需要按正确顺序修复边界和数据语义。 + +最重要的工程原则是: + +- 先保证距离方向正确,再讨论排序优化; +- 先保证摄取结果可信,再讨论召回增强; +- 先保证文档更新可收敛,再增加第二套索引; +- 内部保留完整领域数据,最后一步才转换为通用搜索结果; +- 不把“维度相同”误认为“语义空间相同”; +- 不以测试数量代替检索质量指标。 + +初级开发者可以承担基线、配置化、测试样本和验收测试;中级开发者负责距离修复、摄取结果、候选模型、上下文扩展和解析器试点;文档级一致性、存储选型和真正的跨模态方案必须经过高级开发者评审。 \ No newline at end of file diff --git a/docs/code-review-report.md b/docs/code-review-report.md new file mode 100644 index 0000000..1ee2c0b --- /dev/null +++ b/docs/code-review-report.md @@ -0,0 +1,552 @@ +# MarketAssistant 项目代码审查与重构实施报告 + +> 初次审查日期:2026-07-29 +> 最新核验日期:2026-07-30 +> 核验基线:分支 `feature/multi-provider-support`,提交 `4bf670e`,并包含当时工作区未提交修改 +> 核验范围:当前工作区源码、依赖注入注册、主要调用方、测试源码;统计时排除 `bin/`、`obj/` +> 目标读者:中级开发者可独立实施;初级开发者在代码评审下按任务卡实施 +> 行号说明:行号对应上述工作区快照;实施前应重新搜索符号,不应仅按行号定位 + +--- + +## 1. 执行结论 + +原报告的 34 项结论经源码核验后,分布如下: + +| 核验结果 | 数量 | 处理方式 | +|---|---:|---| +| 确认问题 | 14 | 进入整改清单,采用本报告修订后的严重性和描述 | +| 部分成立 | 10 | 保留真实风险,删除错误原因、错误影响或不合理修复建议 | +| 不成立 / 误报 | 8 | 从整改清单删除 | +| 产品策略 | 2 | 由产品与发布策略决定,不作为代码缺陷 | +| **合计** | **34** | | + +最重要的技术纠正: + +1. .NET 内置 DI 会跟踪它创建的 disposable transient,因此原 #5 的运行时原理错误。当前应用另有独立问题:没有释放根 `ServiceProvider`,容器跟踪的资源在正常退出时仍可能得不到释放(A3)。 +2. `await foreach` 的循环体顺序执行;原 #7 没有证据表明局部 `HashSet` 被并发访问。 +3. ONNX Runtime 的同一 `InferenceSession` 支持并发 `Run()`;原 #8 只有懒初始化竞态成立。 +4. `Interlocked.Increment` 返回每次递增后的唯一值;原 #23 不存在所述 TOCTOU 绕过。 + +原 34 项之外,本次为保证重构可执行性继续检查调用链,发现 3 个必须纳入计划的相邻问题: + +| 编号 | 新发现 | 严重性 | 证据 | +|---|---|---|---| +| A1 | FIFO 平仓读取列顺序错误 | **Critical(启用真实交易时)/ High** | `TradingDataService.cs:419-437` 查询顺序为 `quantity, entry_price, closed_quantity`,读取却把索引 2 当 `closed`、索引 3 当 `entry`,会同时破坏可平数量、已实现盈亏和 `closed_quantity` 更新。 | +| A2 | 卖出风控在 symbol 锁外完成,锁内未复检 | **High** | `TradeExecutor.cs:90-122` 先风控,后获取 symbol 锁;两个并发卖出可基于同一持仓同时通过,随后依次下单。 | +| A3 | 根 DI 容器未在应用退出时释放 | **Medium** | `Program.cs:43` 构建根容器;`App.axaml.cs:73-87` 退出时只清理异常处理器和日志,没有释放 `ServiceProvider`。 | + +当前整改阻断顺序: + +1. **先修交易一致性:A1、A2、#25。** 在修复并通过并发测试前,不应启用真实交易。 +2. **修复根容器所有权:A3。** 后续 ChatClient、限流器和 Singleton 的释放依赖它。 +3. **修复凭据明文存储:#1,并与设置快照一致性 #11 一起实施。** +4. **修复 ChatClient 生命周期竞态:#2。** +5. 再处理取消传播、初始化、Token 估算和摘要质量等 P1 项。 + +--- + +## 2. 架构概览 + +```text +Core(无项目依赖) + ↑ +├── Trading → Core +├── DataProviders → Core +├── Rag → Core +├── Agents → Core, Trading +├── App.Services → Core, Agents, Trading, DataProviders, Rag +└── App → Core, Agents, Trading, DataProviders, App.Services, Rag +``` + +当前项目引用关系未发现循环依赖。分层方向总体合理,但“遵循洋葱架构”属于架构风格判断,不能仅凭项目引用图确认。 + +主要技术栈:.NET 10、C# 13、Avalonia 12.0.4、Microsoft Agent Framework 1.10.0、Microsoft.Extensions.AI 10.7.0、Semantic Kernel SQLiteVec、Serilog、CommunityToolkit.Mvvm。 + +--- + +## 3. 原 34 项逐条核验 + +### 3.1 原 Critical / High 项 + +| # | 原问题 | 核验结论 | 修订严重性 | 当前证据与准确描述 | +|---:|---|---|---|---| +| 1 | API 密钥明文存储 | **确认问题** | **High;真实交易可升为 Critical** | `UserSettingService.cs:81-82` 将整个设置对象写入 JSON;`UserSetting.cs:23,33,54,80,85,90,115` 包含 API Key、Token 和 Binance Secret Key。 | +| 2 | `ChatClientFactory` 使用已释放对象竞态 | **确认问题** | **High** | `ChatClientFactory.cs:96,137-140` 在配置切换时立即释放旧客户端;调用方持有裸 `IChatClient`,没有租约或在途请求跟踪。 | +| 3 | `MainWindowViewModel` 事件订阅泄漏 | **部分成立** | **Low / Medium** | `MainWindowViewModel.cs:52,55` 订阅长生命周期服务且未退订;但该 VM 当前只在 `App.axaml.cs:53` 创建一次。应明确为 Singleton 并在退出时退订,不应按“每次导航创建”处理。 | +| 4 | `SettingsPageViewModel` 构造函数 fire-and-forget | **部分成立** | **Medium** | `SettingsPageViewModel.cs:321` 在构造期间启动初始化,存在对象先可用、初始化后完成和无法随页面取消的问题;异常会被 `GlobalExceptionHandler` 记录,并非静默吞没。 | +| 5 | transient VM 的 `Dispose()` 不会由 DI 调用 | **不成立** | 删除;相邻问题见 A3 | .NET DI 会跟踪并释放它创建的 disposable transient,`NavigationService.cs:155,190-196,243-250` 也会主动释放出栈页面。原论断错误;`App.axaml.cs:73-87` 未释放根容器是独立的 A3,不能据此把原论断判为成立。 | +| 6 | WebSocket 事件必然导致 UI 跨线程异常 | **不成立** | 删除 | `BinanceWebSocketService.cs:163` 在接收线程发布事件合理;UI 订阅者在 `AssetPageViewModel.cs:276-286`、`FavoritesPageViewModel.cs:221-230` 主动切换到 UI 线程。 | +| 7 | Workflow 的局部 `HashSet` 存在线程竞争 | **不成立** | 删除 | `MarketAnalysisWorkflow.cs:181-295` 在单个 `await foreach` 中顺序消费,局部集合没有并发访问证据。 | +| 8 | `ClipImageEmbeddingService` 并发竞态 | **部分成立** | **Medium** | `ClipImageEmbeddingService.cs:285-297` 的懒初始化无同步;但同一 ONNX Session 可并发 `Run()`。只保护初始化,不应串行化所有推理。 | +| 9 | 后台记忆提取不可取消 | **确认问题** | **Medium** | `MarketChatSession.cs:410-420` 使用 `Task.Run` 和 `CancellationToken.None`;异常已捕获,真实问题是任务没有会话所有权和取消。 | +| 10 | RAG 摄取缺少 `CancellationToken` | **确认问题** | **Medium** | `IRagIngestionService.cs:18`、`RagIngestionService.cs:58-70,80-168` 以及 `IDocumentBlockReader.cs:10` 均缺少完整取消传播。 | + +### 3.2 原 Medium 项 + +| # | 原问题 | 核验结论 | 修订严重性 | 当前证据与准确描述 | +|---:|---|---|---|---| +| 11 | `UserSettingService` 暴露可变引用 | **确认问题** | **Medium** | `UserSettingService.cs:21` 返回内部可变对象;`95-98` 在锁外替换对象后再保存。并发读取、属性修改与持久化没有统一同步边界。 | +| 12 | 字典 setter 缺少 key 会抛异常 | **不成立** | 删除 | `SettingsPageViewModel.cs:91` 使用字典 indexer setter;key 不存在时会新增,不会抛 `KeyNotFoundException`。 | +| 13 | 大量服务注册为 Singleton | **部分成立** | **Medium(审计项)** | `ServiceCollectionExtensions.cs:240-350` 确有大量 Singleton,但 Singleton 本身不是缺陷。桌面应用没有天然请求 Scope,应逐个审计可变状态和线程模型,禁止批量改生命周期。 | +| 14 | 两个 `ConcurrencyLimiter` 未释放 | **部分成立** | **Low** | 当前只在 `ServiceCollectionExtensions.cs:221-230` 找到一个实例。它被长期闭包捕获,生命周期等同应用生命周期;不是持续增长型泄漏。 | +| 15 | WebSocket 同步 Dispose 可能阻塞 / 死锁 | **确认问题** | **Medium** | `BinanceWebSocketService.cs:207-223` 在同步 `Dispose()` 中等待异步关闭最多 3 秒。已有 `DisposeAsync()`;`Task.Run(...).Wait()` 不是正确修复。 | +| 16 | `Program.cs` 重复实例化 `UserSettingService` | **部分成立** | **Low** | `Program.cs:35` 为日志路径临时创建实例,正式 Singleton 另行注册。会重复读取设置;并不存在“临时修改丢失”的当前事实。 | +| 17 | DI 未启用构建验证 | **确认问题** | **Low / Medium** | `Program.cs:43` 直接 `BuildServiceProvider()`,未设置 `ValidateOnBuild` / `ValidateScopes`。 | +| 18 | `GlobalExceptionHandler` 双检锁缺 `volatile` | **部分成立** | **Low** | `GlobalExceptionHandler.cs:30-36` 在 `RegisterHandlers()` 前发布 `_instance`。真实风险是初始化事务发布顺序;只加 `volatile` 不能修复事务边界。 | +| 19 | `TaskCanceledException` 一律解释为网络超时 | **确认问题** | **Low / Medium** | `MarketAnalysisWorkflow.cs:297-309` 仅排除外部 token 取消,其他内部取消仍可能被错误映射为网络超时。 | +| 20 | 错误冷却阻止配置修正后重试 | **不成立** | 删除 | `ChatClientFactory.cs:78-92` 只在配置未变化时命中冷却;配置变化会重试。 | +| 21 | `MarketContext.CurrentMarketType` 缺少 `volatile` | **部分成立** | **Low** | `MarketContext.cs:74-80` 锁内写、静态属性无同步读。枚举读取原子,且没有已发生故障证据。自动属性不能标记 `volatile`;需要时用私有字段配合 `Volatile.Read/Write`。 | +| 22 | `TradingContext.AsyncLocal` 已发生上下文泄漏 | **不成立** | 删除 | `MarketMonitor.cs:346-358` 在 `finally` 中清空,没有当前残留路径证据。 | +| 23 | `Interlocked.Increment` 后比较存在 TOCTOU | **不成立** | 删除 | `TradingFunctionGuardMiddleware.cs:45-54` 使用原子递增返回值,每次调用获得唯一序号。 | +| 24 | 流式 Token 回退估算错误 | **确认问题** | **Medium** | `TokenTrackingMiddleware.cs:68-93` 只累计字符数,再构造等长空格字符串估算,无法代表中文、英文或代码。 | +| 25 | `RiskManager` 卖出数量校验错误 | **部分成立,影响方向写反** | **High** | `RiskManager.cs:111-115` 汇总 `Quantity` 而非 `RemainingQuantity`,会高估可卖数量并可能放行超额卖出。单改该行仍不足以解决 A1、A2。 | +| 26 | `ExtractBaseAsset` 使用固定报价资产列表 | **确认问题** | **Medium;卖出校验路径可升 High** | `RiskManager.cs:12,173-181` 依赖固定后缀。更严重的是 `106-119` 在解析失败时跳过卖出持仓校验;卖出校验本来只需要完整 symbol,不应依赖 base asset。 | +| 27 | 对话摘要逐消息截断 | **确认问题** | **Medium** | `ConversationCompressionMiddleware.cs:173-180` 在摘要前把每条消息截为 500 字符;fallback 在 `206-215` 截为 100 字符,后置结论可能不可逆丢失。 | +| 28 | 文本二分递归深度等于文本长度 | **不成立** | 删除 | `TextChunkingService.cs:78,102-120` 会依次尝试语义分隔符,最后以 `null` 表示空分隔符;`144-147` 随后强制按字符中点二分。递归深度约为 `O(log n)`,原报告所述线性递归和栈溢出模式均不成立。 | + +### 3.3 原 Low 项 + +| # | 原问题 | 核验结论 | 修订严重性 | 当前证据与准确描述 | +|---:|---|---|---|---| +| 29 | `AnalystPromptLoader` 路径固定 | **确认问题** | **Low** | `AnalystPromptLoader.cs:15-16` 固定读取应用目录 `config/prompts`。仅在需要环境覆盖时改为 Options。 | +| 30 | `TokenEstimator` 使用 `Debug.WriteLine` | **确认问题** | **Low** | `TokenEstimator.cs:24-28` 初始化失败后生产环境不可观测,并静默退化到启发式估算。 | +| 31 | `RiskConfig` 默认值硬编码 | **产品策略** | 非缺陷 | 配置模型提供默认值正常;是否允许用户修改属于产品需求。 | +| 32 | `ExchangeOrderResult.Status` 使用字符串 | **确认问题** | **Low** | `IExchangeClient.cs:72-80` 的状态、方向、类型均为字符串,跨交易所映射和大小写比较脆弱。 | +| 33 | 外部 API URL 写在 DI 注册中 | **部分成立** | **Low** | 稳定端点写在代码中不是安全问题,但降低代理、测试环境和故障切换能力。 | +| 34 | Debug-only 导航使用 `#if DEBUG` | **产品策略** | 非缺陷 | 若交易功能尚未发布,编译期隔离合理;需要灰度或动态启用时才引入 feature flag。 | + +--- + +## 4. 重构执行规则 + +实施者必须遵守以下规则,避免把局部修复变成新的竞态或架构债务: + +1. **一个任务卡一个 PR。** 交易一致性任务卡 T1 可作为单独紧急 PR;不要与 UI、提示词或格式化重构混合。 +2. **先写失败测试,再改实现。** 如果当前类不可测试,先做最小依赖抽取,不要为了测试引入新的服务层级。 +3. **不要按原编号机械修改。** “部分成立”必须按本报告的真实根因实施;“误报”不得进入代码整改。 +4. **禁止批量修改 DI 生命周期。** 每个 Singleton 必须先记录:可变字段、调用线程、释放方式和所有者。 +5. **取消必须向下传播,不能吞掉。** 捕获 `OperationCanceledException` 时,若调用方 token 已取消,必须重新抛出。 +6. **安全失败。** 交易持仓、symbol 元数据或密钥存储不可用时应 fail-closed,不能静默跳过校验或退回明文。 +7. **所有资源必须有唯一所有者。** DI 创建的服务由根容器释放;手工 `new` 的会话或客户端由创建者/租约释放。 +8. **禁止把异步问题改成同步阻塞。** 不使用 `.Wait()`、`.Result`、`Task.Run(...).Wait()` 处理异步释放或初始化。 +9. **不得记录密钥。** 日志、异常、测试快照和配置 diff 中不能出现 API Key、Secret 或 Token 值。 +10. **每个任务卡完成后执行:** + +```bash +dotnet build MarketAssistant.slnx -c Debug +dotnet test tests/TestMarketAssistant.csproj -c Debug +dotnet format --verify-no-changes +``` + +交易 T1、凭据 S1、ChatClient C1 属重大改动,必须运行全量测试;不能只依赖编译通过。 + +--- + +## 5. 阻断级任务卡 + +### R0:建立根容器所有权和退出释放 + +**对应:A3、#14,并解决 #5 附近真实存在的容器退出清理问题;同时是 C1 的前置条件。** + +**改动文件** + +- `src/MarketAssistant.App/Program.cs:28-43` +- `src/MarketAssistant.App/App.axaml.cs:18-20,28-87` +- `src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs:40-44` + +**实施步骤** + +1. 将 `App.ServiceProvider` 的实际类型改为可释放的 `ServiceProvider`,不要只保存 `IServiceProvider`。 +2. 在 `OnApplicationExit` 中按顺序执行:停止应用级后台任务、`GlobalExceptionHandler.Cleanup()`、释放根容器、将静态引用置空、最后 `Log.CloseAndFlush()`。 +3. 当前所有退出事件为同步事件,先使用 `ServiceProvider.Dispose()`;若以后引入仅实现 `IAsyncDisposable` 的 Singleton,再统一设计异步关闭协调器,不要在各服务内部自行阻塞。 +4. 把 `MainWindowViewModel` 注册改为 Singleton,因为当前只作为唯一主窗口根 VM 使用;同时实现幂等 `Dispose()` 退订事件。 +5. 保留导航 VM 的幂等 Dispose。导航层可能先释放页面,根容器退出时再次调用,所有实现都必须允许重复 Dispose。 + +**禁止方案** + +- 不要依赖进程退出替代 `ServiceProvider.Dispose()`。 +- 不要在每个 Singleton 上注册独立的 `ProcessExit`。 +- 不要为了释放 VM 把全部 ViewModel 改成 Singleton。 + +**测试与验收** + +- 新增 DI 生命周期测试:根容器释放后,测试用 disposable Singleton 和根解析 transient 各只执行一次有效清理。 +- 应用正常退出时日志中无 `ObjectDisposedException`,主窗口事件已退订。 +- R0 完成后,#5 的剩余风险才算关闭。 + +### T1:修复卖出风控与 FIFO 平仓一致性 + +**对应:A1、A2、#25、#26。真实交易启用前必须完成。** + +**改动文件** + +- `src/MarketAssistant.App.Services/Trading/TradingDataService.cs:403-473,478-553` +- `src/MarketAssistant.App.Services/Trading/RiskManager.cs:88-119,170-182` +- `src/MarketAssistant.App.Services/Trading/TradeExecutor.cs:84-133,201-225` +- `src/MarketAssistant.Trading/TradingModels.cs:228-241` +- 新增 `tests/Trading/TradingDataServiceTest.cs` +- 新增 `tests/Trading/RiskManagerTest.cs` +- 新增 `tests/Trading/TradeExecutorConcurrencyTest.cs` + +**实施步骤** + +1. **先修 A1。** `ClosePositionFifoAsync` 禁止按数字索引猜列含义;使用 `GetOrdinal("quantity")`、`GetOrdinal("entry_price")`、`GetOrdinal("closed_quantity")` 读取。 +2. 在同一事务内先计算 `totalAvailable = Sum(quantity - closed_quantity)`。若 `closeQty > totalAvailable`,在执行任何 UPDATE 前返回明确失败或抛出领域异常;禁止提交“只平掉一部分但上层认为全部成功”的事务。 +3. `RiskManager` 的卖出校验直接按完整 `instrumentSymbol` 查询持仓,并汇总 `RemainingQuantity`。删除卖出路径对 `ExtractBaseAsset()` 成功与否的依赖。 +4. `TradeExecutor` 保留锁外的初步风控和人工确认,但在用户确认后、获取 symbol 锁后,必须再次执行最终风控。第二次结果若拒绝则不得调用交易所 API;若仍要求确认且订单参数未改变,可复用本次确认结果。 +5. symbol 锁必须覆盖“最终风控 → 下单 → 保存交易记录 → FIFO 持仓更新”。当前 `ExecuteApprovedOrderAsync` 已在锁内,保持该边界。 +6. 单标的买入仓位计算需要 base asset 时,复用 `BinanceMarketDataService.GetExchangeInfoAsync()` 返回的 `BinanceSymbolInfo.BaseAsset/QuoteAsset`,不要继续扩充硬编码后缀。元数据不可用时,对会影响交易上限的检查 fail-closed。 +7. 本任务只修正现有持久化路径,不在同一 PR 中重写整个交易存储层。若后续迁移 EF Core,应单独采用 Code First 迁移,不再扩散手写 SQL。 + +**必须新增的测试** + +| 测试 | 场景 | 断言 | +|---|---|---| +| `ClosePositionFifoAsync_PartialClose_UsesCorrectColumns` | 数量 2、入场价 100、已平 0.5,再平 0.5,出场价 120 | 剩余 1;PnL 为 10;`closed_quantity` 为 1 | +| `ClosePositionFifoAsync_InsufficientPosition_RollsBack` | 可用 1,尝试平 2 | 无 UPDATE、无部分提交、返回明确失败 | +| `ValidateOrderAsync_Sell_UsesRemainingQuantity` | `Quantity=10`、`ClosedQuantity=8`、卖出 3 | 风控拒绝,可用量为 2 | +| `ValidateOrderAsync_UnknownQuote_DoesNotSkipSellValidation` | symbol 不在硬编码后缀列表 | 仍按完整 symbol 校验持仓 | +| `ExecuteOrderAsync_ConcurrentSells_OnlyOnePassesFinalRiskCheck` | 可用 1,并发发起两笔卖出 1 | 只调用一次交易所下单;另一笔在锁内复检被拒绝 | +| `ExecuteOrderAsync_ConfirmationWait_DoesNotHoldSymbolLock` | 第一笔等待人工确认 | 第二笔仍能进入初步流程,但不能绕过最终复检 | + +**完成定义** + +- 上述测试稳定重复运行 100 次无偶发失败。 +- 任何路径都不能令 `closed_quantity < 0` 或 `closed_quantity > quantity`。 +- 任何卖出下单前都在 symbol 锁内完成一次基于最新本地持仓的最终检查。 +- 交易所余额/持仓仍作为最终权威约束;本地检查不能替代交易所拒绝处理。 + +### S1:将敏感凭据移出明文设置 JSON + +**对应:#1、#11、#16。建议与设置快照一致性一起实施。** + +**改动文件** + +- `src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs:19-33,52-115` +- `src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs` +- `src/MarketAssistant.App.Services/Services/Settings/IUserSettingService.cs` +- `src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs:87-132,332-345,578-610` +- `src/MarketAssistant.App/Program.cs:32-35` +- 所有直接读取密钥的调用方:`ChatClientFactory`、`EmbeddingFactory`、`WebTextSearchFactory`、`BinanceAuthService`、`CoinGeckoApiKeyHandler`、A 股工具等 +- 新增 `ISecretStore` 及平台实现;新增 `InMemorySecretStore` 仅供测试 + +**推荐最小契约** + +```csharp +public interface ISecretStore +{ + string? Get(string key); + void Set(string key, string value); + void Remove(string key); +} +``` + +平台实现要求: + +- Windows:当前用户范围 DPAPI 或 Windows Credential Manager。 +- macOS:Keychain。 +- Linux:Secret Service。 +- 如果首个 PR 只交付 Windows 实现,非 Windows 平台必须明确禁用密钥保存并提示“不支持”,不能退回明文。 + +**实施步骤** + +1. 先定义稳定的密钥名,例如 `llm:{providerId}`、`embedding`、`binance:api-key`、`binance:secret-key`、`web-search:{provider}`。 +2. `usersettings.json` 只保存非敏感配置。序列化前使用持久化 DTO,或把敏感属性标记为不参与 JSON;不要依赖“保存前临时清空原对象”。 +3. `UserSettingService.LoadSettings()` 加一次性迁移:若旧 JSON 含明文密钥,则写入 `ISecretStore`,成功后原子重写无密钥 JSON。迁移日志只记录密钥类型和数量,不记录值。 +4. 如果写入安全存储失败,保留原文件并向用户报告迁移失败;不要先清空再尝试保存。 +5. `UserSettingService.CurrentSetting` 返回深拷贝快照。`UpdateSettings` 在一个锁内完成:克隆输入、写安全存储、原子写非敏感 JSON、最后发布新快照。 +6. 原子写文件:先写同目录临时文件,刷新成功后替换目标文件。异常时保留旧文件。 +7. `SettingsPageViewModel` 编辑独立快照;只有点击保存时调用 `UpdateSettings`。保存失败时 UI 保留用户输入并显示错误。 +8. `Program.cs` 不再 `new UserSettingService()`。新增只读取非敏感日志路径的 `StartupSettingsReader`,或使用固定默认日志目录完成启动。 +9. 禁止将加密主密钥硬编码在代码、JSON、环境变量默认值或仓库文件中。 + +**必须新增的测试** + +- `SaveSettings_DoesNotPersistAnySecretValue` +- `LoadSettings_MigratesLegacyPlaintextSecretsExactlyOnce` +- `Migration_WhenSecretStoreFails_KeepsOriginalFile` +- `CurrentSetting_ReturnsIndependentSnapshot` +- `ConcurrentReadsAndUpdate_DoNotObservePartialSnapshot` +- `UpdateSettings_WhenWriteFails_DoesNotPublishNewSnapshot` +- 测试使用临时目录和 `InMemorySecretStore`,不得读写真实用户密钥库。 + +**完成定义** + +- 在测试配置中填入唯一标记字符串后,递归搜索应用数据目录、日志目录和导出文件均找不到该明文。 +- 旧用户配置升级后功能可用,且 `usersettings.json` 不再包含任何密钥值。 +- 安全存储不可用时保存失败并明确提示,不静默降级。 + +### C1:修复 ChatClient 配置切换与在途请求竞态 + +**对应:#2。依赖 R0;建议在 S1 的设置快照完成后实施。** + +**改动文件** + +- `src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs` +- `src/MarketAssistant.App.Services/Infrastructure/Factories/ResilientChatClient.cs` +- `src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs:72-91` +- 所有 `IChatClientFactory.CreateClient()` 调用点 +- 新增 `tests/Infrastructure/ChatClientFactoryTest.cs` + +**推荐最小设计:版本化退役,应用退出统一释放。** + +当前配置切换频率低,不必第一步引入引用计数租约。安全且较小的方案: + +1. 定义不可变 `ChatClientConfiguration` record,包含 provider、model、endpoint 和密钥版本/值,用于完整比较。 +2. 配置变化时先成功创建新客户端,再原子替换 `_cachedClient`;旧客户端加入 `_retiredClients`,不立即 Dispose。 +3. 创建失败时保留当前客户端供既有在途请求完成,但新 `CreateClient()` 对新配置继续返回错误;错误冷却使用独立的 `_lastFailedConfiguration`,不能覆盖当前成功配置。 +4. `ChatClientFactory` 实现幂等 `IDisposable`:根容器退出时统一释放当前客户端和所有 retired 客户端。 +5. 若产品未来频繁切换模型或长期运行导致 retired 数量不可接受,再升级为 `IChatClientLease : IAsyncDisposable` 引用计数;不要在本次修复中同时改造所有 Agent 所有权。 +6. 为可测试性,把“根据配置创建原始客户端”的动作抽成 internal delegate 或 internal factory 接口;不要在测试中访问真实 LLM。 + +**禁止方案** + +- 把 `oldClient.Dispose()` 移到锁内:锁外调用方仍在使用,问题不变。 +- 用 `Thread.Sleep` 等待“可能完成”的请求。 +- 配置变化后永不释放所有客户端且没有根容器退出清理。 +- 在异常日志中输出 API Key 或完整配置对象。 + +**必须新增的测试** + +- `CreateClient_UnchangedConfiguration_ReturnsSameInstance` +- `CreateClient_ChangedConfiguration_ReturnsNewInstance` +- `CreateClient_ChangedConfiguration_DoesNotDisposeInFlightOldClient` +- `CreateClient_FailedNewConfiguration_KeepsOldClientAliveButDoesNotReturnItForNewCalls` +- `Dispose_DisposesCurrentAndRetiredClientsExactlyOnce` +- `CreateClient_ConcurrentConfigurationSwitch_CreatesAtMostOneClientPerSuccessfulVersion` + +**完成定义** + +- 旧客户端上的阻塞请求在设置切换后可正常完成。 +- 新请求只使用新配置;新配置创建失败时不偷偷使用旧配置。 +- 应用退出后所有成功创建的客户端恰好释放一次。 + +--- + +## 6. P1 任务卡 + +### P1-1:设置页显式初始化与页面级取消(#4) + +**改动:** `SettingsPageViewModel.cs:26,306-354,456-571,626-662,681-693`,`SettingsPageView.axaml.cs:8-20`。 + +1. 从构造函数删除 `_ = SafeExecuteAsync(InitializeAsync, ...)`。 +2. 将初始化改为公开、幂等的 `Task InitializeAsync(CancellationToken)`;使用缓存 Task 防止重复附加可视树时重复加载。 +3. View 在 `AttachedToVisualTree` 时调用初始化;VM 暴露 `IsInitializing`,初始化完成前禁用依赖配置的命令。 +4. VM 持有 `_lifetimeCts`,`Dispose()` 时取消;`FetchModels`、`VectorizeDocuments` 接收并传播 token。 +5. 初始化异常由 `SafeExecuteAsync` 统一记录和展示,不使用无观察的 async void 业务方法。 + +**验收:** 导航离开设置页后网络模型列表请求和文档向量化可取消;重复进入不重复订阅或重复初始化。 + +### P1-2:后台记忆提取归属会话生命周期(#9) + +**改动:** `MarketChatSession.cs:22-44,399-420,533-552`。 + +1. 增加会话级 `_lifetimeCts`;删除 `Task.Run`,直接启动异步方法并保存 Task 引用。 +2. 提取调用使用 `_lifetimeCts.Token`,Dispose 时先取消。 +3. 实现 `IAsyncDisposable` 以便可等待后台任务;同步 Dispose 只取消并做幂等内存清理,不同步等待。 +4. 创建 `MarketChatSession` 的 ViewModel/工厂明确负责释放会话。 + +**验收:** 释放会话后提取收到取消;没有未观察异常;不会在旧会话销毁后继续写记忆。 + +### P1-3:RAG 摄取全链路取消(#10) + +**改动范围:** `IRagIngestionService`、`RagIngestionService`、`IDocumentBlockReader` 及三个 reader、`IImageEmbeddingService` 调用、`SettingsPageViewModel.VectorizeDocuments`。 + +1. 所有异步接口末尾增加 `CancellationToken cancellationToken = default`。 +2. 传播到文档读取、embedding `GenerateAsync`、图片 Caption/Generate、向量 `UpsertAsync`。 +3. 在同步的长循环和每个 block 开始处调用 `ThrowIfCancellationRequested()`。 +4. `RagIngestionService` 的逐 block `catch (Exception)` 前增加取消专用分支并重新抛出,禁止把取消记录成普通 block 失败后继续。 +5. Settings 页把取消与失败分开显示;取消不计入 failed files。 + +**测试:** 分别在读取、embedding、图片处理、Upsert 阶段触发取消;后续 block 不再处理,调用方收到 `OperationCanceledException`。 + +### P1-4:只同步 CLIP Session 初始化(#8) + +**改动:** `ClipImageEmbeddingService.cs:85-100,272-315` 及 Dispose。 + +1. 用 `Lazy` 或私有锁保护一次性初始化。 +2. Lazy 工厂内部捕获初始化异常并返回 null,避免 `Lazy` 永久缓存异常导致降级路径不可用。 +3. `GenerateAsync` 取得已发布的 Session 后可并发调用 `Run()`;不要加全局推理锁。 +4. Dispose 仅在 `IsValueCreated` 时释放 Session,并与并发 Dispose 做幂等保护。 + +**测试:** 32 个并发首次调用只创建一次 Session;初始化失败只记录一次并稳定走 fallback;并发推理不被串行化。 + +### P1-5:WebSocket 释放不阻塞 UI(#15) + +**改动:** `BinanceWebSocketService.cs:180-227` 及其所有者。 + +1. `DisposeAsync()` 负责协议级 Close 和等待接收循环结束。 +2. `Dispose()` 只取消 CTS、原子交换 `_ws`、直接释放本地资源;不得等待网络。 +3. 用 `Interlocked.Exchange` 或统一状态锁保证 Dispose/DisposeAsync 并发时只释放一次。 +4. 正常可等待的调用方优先 `await DisposeAsync()`;应用崩溃或同步兜底才走 Dispose。 + +**验收:** UI 线程调用 Dispose 在 100ms 内返回;服务器不响应 Close 时应用仍可退出。 + +### P1-6:流式 Token 回退使用真实文本(#24) + +**改动:** `TokenTrackingMiddleware.cs:61-96`,`TokenTrackingMiddlewareTest.cs`。 + +1. 用 `StringBuilder` 累积 `update.Text`,而不是只统计字符数。 +2. 无 Usage 时调用 `TokenEstimator.EstimateTokens(actualText)`。 +3. 有 Usage 时仍以提供商值为准;测试 Usage 可以在最后一个 update 到达。 +4. 保持取消语义:枚举取消时不要把不完整输出记成完整精确值;如需记录,标记为估算。 + +**测试:** 中文、英文、代码、纯空格四类流式输出;回退值必须等于对拼接真实文本直接估算的结果。 + +### P1-7:统一 symbol 元数据来源(#26) + +**改动:** `RiskManager.cs`、`BinanceMarketDataService.cs:153-165`、`BinanceMarketDataModels.cs:45-50`,以及现有 `CryptoAssetInfoService` 缓存逻辑。 + +1. 提取可复用 symbol 元数据查询服务,返回完整 `Symbol/BaseAsset/QuoteAsset`。 +2. 复用现有 Binance exchangeInfo 和一小时缓存,不新增第二套 HTTP/缓存实现。 +3. 卖出持仓检查按完整 symbol 执行,不依赖元数据解析。 +4. 买入单标的仓位检查需要 BaseAsset;元数据缺失时 fail-closed 并给出可诊断错误。 + +**测试:** BTCUSDT、ETHBTC、FDUSD 交易对、未知 symbol、元数据接口失败。 + +### P1-8:摘要改为全局 Token 预算(#27) + +**改动:** `ConversationCompressionMiddleware.cs:159-216` 及其测试。 + +1. 删除逐消息固定 500 字符截断。 +2. 定义摘要输入 Token 总预算;按角色分隔构建输入。 +3. 超预算时采用“保留消息头部 + 尾部结论”的 token-aware 截断,并显式插入省略标记;不要只保留开头。 +4. fallback 同样保留首尾,而不是固定前 100 字符。 +5. 若仍无法满足质量要求,再单独实现分块 map-reduce 摘要,不在第一版同时增加多轮 LLM 调用。 + +**测试:** 长代码、长表格、结论位于消息尾部、中文多轮对话;摘要请求不超预算且尾部结论仍出现。 + +--- + +## 7. P2 可维护性任务表 + +| 项目 | 最小实施方案 | 验收标准 | 禁止做法 | +|---|---|---|---| +| #3 主窗口生命周期 | `MainWindowViewModel` 注册 Singleton;实现幂等 Dispose 退订 `PropertyChanged` 与市场事件;由 R0 根容器释放 | 启停一次只订阅/退订一次 | 不要为此修改全部 VM 生命周期 | +| #13 Singleton 审计 | 为每个有可变字段的 Singleton 记录字段、线程、锁、释放责任;只修改有证据的类 | 审计表无“未知所有者”资源 | 禁止批量改 Scoped;桌面应用没有请求 Scope | +| #14 限流器 | R0 后再决定;如需确定释放,将 limiter 包装为 DI 管理的 Singleton holder 并由 resilience 配置引用 | 根容器退出时 holder Dispose 一次 | 不要每次请求创建 limiter | +| #16 启动设置 | 用轻量 `StartupSettingsReader` 只读取非敏感日志路径,或固定启动日志目录 | 启动只读取设置一次;不构建临时容器 | 不要直接 `new UserSettingService()` 绕过其依赖 | +| #17 DI 验证 | `BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true })` | 测试中构建完整服务集合无异常 | 不要因验证失败关闭验证,应修注册 | +| #18 异常处理器发布 | 在锁内先 `RegisterHandlers()`,成功后再赋 `_instance`;Cleanup 幂等 | 并发 Initialize 只注册一次;注册失败不发布半初始化实例 | 只加 `volatile` | +| #19 取消与超时 | 使用独立 timeout CTS,并根据哪个 token 被取消决定提示;未知取消保持取消语义 | 用户取消不显示网络超时;真实 timeout 有明确日志 | 按异常消息字符串猜全部来源 | +| #21 市场快照 | 只有确认跨线程读需求后,改私有 `int` 字段并 `Volatile.Read/Write`,或统一锁内访问 | 并发切换测试通过 | 给自动属性加不存在的 `volatile` | +| #29 Prompt 路径 | 增加 typed options 和默认 `AppContext.BaseDirectory/config/prompts` | 测试可注入临时目录 | 无产品需求时不要引入文件监控框架 | +| #30 TokenEstimator 日志 | 优先改为可注入服务;若改动面过大,先增加一次性诊断事件并由宿主记录 | tokenizer 失败在生产日志可见且不重复刷屏 | 静态类内自行创建全局 LoggerFactory | +| #32 订单状态 | Core/Trading 层引入统一枚举,保留 `RawStatus`;适配器边界完成映射 | 未知外部状态映射为 Unknown 且原值可诊断 | 在业务层散落大小写字符串比较 | +| #33 外部端点 | 使用 typed options,代码提供安全默认端点;测试覆盖自定义 base URL | 测试环境无需改源码即可替换端点 | 将 API Key 放入仓库配置 | + +#31、#34 是产品策略,只有在产品需求明确后才建任务。 + +--- + +## 8. 测试覆盖与新增测试计划 + +### 8.1 当前测试统计 + +排除 `bin/`、`obj/` 后: + +| 指标 | 数量 | +|---|---:| +| 测试源码 `.cs` 文件 | 44 | +| 含 `[TestClass]` 的文件 / 类 | 41 | +| `[TestMethod]` 方法 | 266 | + +测试框架为 MSTest,主要使用 Moq。`tests/Vectors/ClipImageEmbeddingServiceTest.cs` 已有 7 个测试,原“无测试”结论错误。 + +### 8.2 必须优先补齐的测试项目 + +| 顺序 | 测试文件 | 目的 | +|---:|---|---| +| 1 | `tests/Trading/TradingDataServiceTest.cs` | 锁定 A1 列映射、FIFO、事务回滚和数量不变量 | +| 2 | `tests/Trading/RiskManagerTest.cs` | 锁定 RemainingQuantity 和未知 symbol 的 fail-closed 行为 | +| 3 | `tests/Trading/TradeExecutorConcurrencyTest.cs` | 证明并发卖出只有一笔通过最终风控 | +| 4 | `tests/Settings/UserSettingServiceTest.cs` | 证明密钥不落盘、迁移安全、快照一致、原子保存 | +| 5 | `tests/Infrastructure/ChatClientFactoryTest.cs` | 证明配置切换不释放在途客户端,退出时只释放一次 | +| 6 | `tests/Rag/RagIngestionCancellationTest.cs` | 证明取消不被 block 级 catch 吞掉 | +| 7 | `tests/Middleware/TokenTrackingMiddlewareTest.cs` | 覆盖真实流式文本回退估算 | +| 8 | `tests/Middleware/ConversationCompressionMiddlewareTest.cs` | 覆盖全局预算、首尾保留和 fallback | +| 9 | `tests/Vectors/ClipImageEmbeddingServiceTest.cs` | 增加并发首次初始化和失败 fallback | + +测试命名统一使用 `Method_Scenario_ExpectedResult`,异步测试返回 `Task`。涉及文件系统时使用每测试独立临时目录并在 Cleanup 清理;不得访问真实用户设置或真实密钥库。 + +--- + +## 9. 删除的误报与禁止整改项 + +以下 8 项应从原整改计划删除: + +- #5:DI 不会释放 transient disposable;当前根容器未释放应按独立 A3 处理; +- #6:WebSocket 发布者必须切换到 UI 线程; +- #7:`await foreach` 中局部 `HashSet` 必然并发访问; +- #12:字典 indexer setter 在 key 不存在时抛异常; +- #20:配置修正后仍被错误冷却阻止; +- #22:当前 `AsyncLocal` 调用链已发生泄漏; +- #23:`Interlocked.Increment` 后比较可并发绕过上限; +- #28:二分递归深度等于文本长度并导致所述栈溢出。 + +A3 是核验原 #5 调用链时发现的独立问题,应按 R0 修复,但不能用于证明原 #5 成立。 + +另外,#31 和 #34 是产品策略,不应以代码缺陷计数。 + +--- + +## 10. 分阶段验收清单 + +### 阶段 0:建立基线 + +- [ ] 记录实施时 commit 和工作区状态。 +- [ ] `dotnet build MarketAssistant.slnx -c Debug` 通过。 +- [ ] `dotnet test tests/TestMarketAssistant.csproj -c Debug` 通过并保存测试数量。 +- [ ] 禁止在已有失败测试上开始重构,除非先记录并隔离已知失败。 + +### 阶段 1:交易安全 + +- [ ] T1 所有测试通过。 +- [ ] 并发卖出压力测试重复 100 次,无双重下单。 +- [ ] `closed_quantity` 始终处于 `[0, quantity]`。 +- [ ] 真实交易开关在 T1 完成前保持关闭。 + +### 阶段 2:资源与凭据 + +- [ ] R0 根容器退出清理通过。 +- [ ] S1 明文搜索为零,旧配置迁移可回滚。 +- [ ] C1 在途请求切换配置测试通过。 +- [ ] 日志与异常不包含密钥值。 + +### 阶段 3:生命周期与取消 + +- [ ] 设置页、RAG、后台记忆、WebSocket 均支持可验证取消。 +- [ ] 没有 `.Wait()`、`.Result` 或无所有者 `Task.Run` 新增。 +- [ ] `OperationCanceledException` 不被记录为普通失败或网络超时。 + +### 阶段 4:质量与维护性 + +- [ ] Token 回退估算使用真实文本。 +- [ ] 摘要保留尾部结论并满足输入预算。 +- [ ] DI 构建验证和格式验证通过。 + +--- + +## 11. 核验依据与限制 + +- 本报告基于提交 `4bf670e` 加当时工作区修改;报告文件本身当时为未跟踪文件。后续代码变更会导致行号偏移。 +- 已检查关键调用链,但未执行真实交易、压力测试、故障注入、依赖漏洞扫描、磁盘 ACL 检查或覆盖率采集。 +- 并发缺陷结论基于当前锁边界、事务边界和 .NET 运行时语义;实施后必须由任务卡中的并发测试验证。 +- .NET DI disposable 行为参考 Microsoft 官方文档:。 +- ONNX Runtime 同一 Session 并发 Run 结论参考官方维护者答复:。 +- Windows `ProtectedData` 仅支持 Windows;跨平台构建不能把 DPAPI 当成统一实现:。 +- 本报告中的类型签名是设计约束,不是可直接复制的完整补丁;实施者仍需依据当前 NuGet 版本的编译器签名完成调用。 + +--- + +## 12. 最终判断 + +修订后的报告可以作为重构主清单使用,并已补齐中初级开发者最容易遗漏的改动顺序、所有权边界、禁止方案、测试场景和完成定义。 + +但有两个明确边界: + +1. **初级开发者不得独立合并 T1、S1、C1。** 这三项涉及真实交易、凭据迁移和并发资源所有权,必须由熟悉 .NET 并发与安全存储的开发者评审。 +2. **任务完成不以“代码已改”判断,而以任务卡测试和 Definition of Done 全部满足判断。** + +当前最重要的行动不是处理全部 34 项,而是按顺序完成:`T1 → R0 → S1 → C1 → P1`。 \ No newline at end of file diff --git a/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs b/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs index c06fbe7..ba5bc65 100644 --- a/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs +++ b/src/MarketAssistant.Agents/MarketAnalysis/Models/CoordinatorResult.cs @@ -195,18 +195,23 @@ public sealed class KeyIndicator [Description("各维度评分详情")] public sealed class AnalysisDimensionScores { + [Range(1, 10)] [Description("基本面评分")] public float Fundamental { get; set; } + [Range(1, 10)] [Description("技术面评分")] public float Technical { get; set; } + [Range(1, 10)] [Description("财务面评分")] public float Financial { get; set; } + [Range(1, 10)] [Description("市场情绪评分")] public float Sentiment { get; set; } + [Range(1, 10)] [Description("新闻事件评分")] public float News { get; set; } } diff --git a/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj index 039335e..47d982e 100644 --- a/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj +++ b/src/MarketAssistant.Agents/MarketAssistant.Agents.csproj @@ -7,6 +7,10 @@ $(NoWarn);MAAI001 + + + + diff --git a/src/MarketAssistant.Agents/Middleware/ConversationCompressionMiddleware.cs b/src/MarketAssistant.Agents/Middleware/ConversationCompressionMiddleware.cs index 87afb0a..556f401 100644 --- a/src/MarketAssistant.Agents/Middleware/ConversationCompressionMiddleware.cs +++ b/src/MarketAssistant.Agents/Middleware/ConversationCompressionMiddleware.cs @@ -1,218 +1,68 @@ -using MarketAssistant.Agents.TokenManagement; using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Compaction; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; -using System.Runtime.CompilerServices; -using System.Text; namespace MarketAssistant.Agents.Middleware; /// -/// 会话压缩中间件,拦截 Agent 运行并在消息历史超过 Token 阈值时自动压缩。 -/// 通过 agent.AsBuilder().Use(runFunc:, runStreamingFunc:).Build() 附加。 +/// 为每个聊天会话创建独立的 MAF Compaction Provider。 +/// 官方 Provider 按原子消息组处理历史,保证 Function Call 与 Function Result 不被拆分。 /// -public sealed class ConversationCompressionMiddleware +public sealed class ConversationCompactionProviderFactory { - /// - /// AgentSession.StateBag 中标记是否正在执行压缩的键(防止递归) - /// - private const string IsCompressingKey = "middleware:isCompressing"; - - /// - /// AgentSession.StateBag 中存储压缩摘要的键 - /// - public const string CompressionSummaryKey = "middleware:compressionSummary"; - - private const int DefaultMaxTokens = 8000; - private const int DefaultReserveRecentCount = 4; - private const int SummaryTextTruncationThreshold = 500; - private const int SummaryMaxOutputTokens = 500; - private const float SummaryTemperature = 0.1f; - private const int FallbackSummaryTruncationThreshold = 100; - - private readonly ILogger _logger; - private readonly Func _chatClientFactory; - - /// - /// 压缩前回调钩子。在丢弃旧消息前调用,允许外部提取关键信息(紧急保存)。 - /// - public Func, CancellationToken, Task>? PreCompressHook { get; set; } - - /// - /// 触发压缩的 Token 阈值 - /// - public int MaxTokens { get; set; } = DefaultMaxTokens; + public const int DefaultMaxTokens = 8_000; + public const double ContextWindowTriggerRatio = 0.75; + public const int DefaultMinimumPreservedGroups = 8; + public const string StateKey = "market-chat:compaction"; - /// - /// 压缩时保留最近消息数 - /// - public int ReserveRecentCount { get; set; } = DefaultReserveRecentCount; + private readonly ILoggerFactory _loggerFactory; - public ConversationCompressionMiddleware(Func chatClientFactory, ILogger logger) + public ConversationCompactionProviderFactory(ILoggerFactory loggerFactory) { - _chatClientFactory = chatClientFactory; - _logger = logger; + _loggerFactory = loggerFactory; } - /// - /// 非流式 Agent Run 中间件 - /// - public async Task InvokeAsync( - IEnumerable messages, - AgentSession? session, - AgentRunOptions? options, - AIAgent innerAgent, - CancellationToken cancellationToken) + public AIContextProvider Create( + IChatClient chatClient, + int maxTokens = DefaultMaxTokens, + int minimumPreservedGroups = DefaultMinimumPreservedGroups) { - var processedMessages = await TryCompressAsync(messages, session, cancellationToken); + ArgumentNullException.ThrowIfNull(chatClient); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxTokens); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(minimumPreservedGroups); - return await innerAgent.RunAsync(processedMessages, session, options, cancellationToken) - .ConfigureAwait(false); - } - - /// - /// 流式 Agent Run 中间件 - /// - public async IAsyncEnumerable InvokeStreamingAsync( - IEnumerable messages, - AgentSession? session, - AgentRunOptions? options, - AIAgent innerAgent, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var processedMessages = await TryCompressAsync(messages, session, cancellationToken); + var strategy = new SummarizationCompactionStrategy( + chatClient, + CompactionTriggers.TokensExceed(maxTokens), + minimumPreservedGroups); - await foreach (var update in innerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken) - .ConfigureAwait(false)) - { - yield return update; - } + return new CompactionProvider(strategy, StateKey, _loggerFactory); } /// - /// 检查消息列表是否需要压缩,如需要则执行压缩并返回处理后的消息 + /// 根据模型上下文窗口创建 Provider。未知窗口使用保守默认阈值;已知窗口在 75% 时触发压缩, + /// 为系统提示、工具调用和模型输出保留至少 25% 的空间。 /// - private async Task> TryCompressAsync( - IEnumerable messages, - AgentSession? session, - CancellationToken cancellationToken) + public AIContextProvider CreateForContextWindow( + IChatClient chatClient, + int? contextWindowTokens, + int minimumPreservedGroups = DefaultMinimumPreservedGroups) { - var messageList = messages as IList ?? messages.ToList(); - - // 防止递归:压缩摘要生成过程中不再触发压缩 - if (session?.StateBag.TryGetValue(IsCompressingKey, out var flag) == true && flag == "true") - { - return messageList; - } - - var totalTokens = TokenEstimator.EstimateTotalTokens(messageList); - if (totalTokens <= MaxTokens || messageList.Count <= ReserveRecentCount) - { - return messageList; - } - - _logger.LogInformation( - "消息 Token ({Tokens}) 超过阈值 ({Max}),触发压缩,消息数: {Count}", - totalTokens, MaxTokens, messageList.Count); - - // 压缩前紧急保存钩子:让外部提取关键信息后再丢弃旧消息 - if (PreCompressHook != null) - { - try - { - await PreCompressHook(messageList, cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "压缩前紧急保存钩子执行失败"); - } - } - - var messagesToSummarize = messageList.Take(messageList.Count - ReserveRecentCount).ToList(); - var recentMessages = messageList.Skip(messageList.Count - ReserveRecentCount).ToList(); - - var summary = await GenerateSummaryAsync(messagesToSummarize, session, cancellationToken); - - var compressed = new List(ReserveRecentCount + 1) - { - new(ChatRole.System, $"[对话摘要] {summary}") - }; - compressed.AddRange(recentMessages); - - // 将摘要存到 StateBag 以便外部(如 UI)读取 - if (session != null) - { - session.StateBag.SetValue(CompressionSummaryKey, summary); - } - - var newTokens = TokenEstimator.EstimateTotalTokens(compressed); - _logger.LogInformation( - "压缩完成:{OldCount} → {NewCount} 条消息,Token: {OldTokens} → {NewTokens}", - messageList.Count, compressed.Count, totalTokens, newTokens); - - return compressed; + return Create( + chatClient, + CalculateMaxTokens(contextWindowTokens), + minimumPreservedGroups); } - /// - /// 使用 LLM 生成对话摘要(设置防递归标记) - /// - private async Task GenerateSummaryAsync( - List messages, - AgentSession? session, - CancellationToken cancellationToken) + public static int CalculateMaxTokens(int? contextWindowTokens) { - // 设置防递归标记 - if (session != null) session.StateBag.SetValue(IsCompressingKey, "true"); - - try - { - var sb = new StringBuilder(); - sb.AppendLine("请将以下对话内容压缩为简洁摘要,保留关键结论和数据点:"); - sb.AppendLine(); - - foreach (var msg in messages) - { - var role = msg.Role == ChatRole.User ? "用户" : "助手"; - var text = msg.Text; - if (string.IsNullOrWhiteSpace(text)) continue; - - if (text.Length > SummaryTextTruncationThreshold) - text = text[..SummaryTextTruncationThreshold] + "..."; - - sb.AppendLine($"【{role}】{text}"); - } + if (contextWindowTokens is null) + return DefaultMaxTokens; - var response = await _chatClientFactory().GetResponseAsync( - [new ChatMessage(ChatRole.User, sb.ToString())], - new ChatOptions { Temperature = SummaryTemperature, MaxOutputTokens = SummaryMaxOutputTokens }, - cancellationToken); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(contextWindowTokens.Value); - return response.Text ?? "对话历史摘要不可用"; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "LLM 摘要生成失败,回退到截断策略"); - return BuildFallbackSummary(messages); - } - finally - { - if (session != null) session.StateBag.TryRemoveValue(IsCompressingKey); - } - } - - /// - /// 回退摘要策略:提取每条消息的前 100 字符 - /// - private static string BuildFallbackSummary(List messages) - { - var sb = new StringBuilder(); - foreach (var msg in messages) - { - var text = msg.Text; - if (string.IsNullOrWhiteSpace(text)) continue; - var truncated = text.Length > FallbackSummaryTruncationThreshold ? text[..FallbackSummaryTruncationThreshold] + "..." : text; - sb.AppendLine($"- {truncated}"); - } - return sb.ToString(); + var calculatedThreshold = (long)Math.Floor(contextWindowTokens.Value * ContextWindowTriggerRatio); + return checked((int)Math.Clamp(calculatedThreshold, 1, int.MaxValue)); } } diff --git a/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs b/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs index 5f63979..9e19b5b 100644 --- a/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs +++ b/src/MarketAssistant.Agents/Middleware/TokenTrackingMiddleware.cs @@ -1,8 +1,10 @@ using MarketAssistant.Agents.TokenManagement; +using MarketAssistant.Infrastructure.Core; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; +using System.Text; namespace MarketAssistant.Agents.Middleware; @@ -49,7 +51,7 @@ public async Task InvokeAsync( var inputTokens = usage?.InputTokenCount ?? TokenEstimator.EstimateTotalTokens(messages); var outputTokens = usage?.OutputTokenCount ?? TokenEstimator.EstimateTotalTokens(response.Messages); - LogAndAccumulate(session, (int)inputTokens, (int)outputTokens, innerAgent.Name, + LogAndAccumulate(session, inputTokens, outputTokens, innerAgent.Name, isPrecise: usage != null); return response; @@ -65,7 +67,7 @@ public async IAsyncEnumerable InvokeStreamingAsync( AIAgent innerAgent, [EnumeratorCancellation] CancellationToken cancellationToken) { - int outputCharCount = 0; + var outputText = new StringBuilder(); UsageDetails? streamingUsage = null; var agentName = innerAgent.Name ?? "Unknown"; @@ -81,22 +83,20 @@ public async IAsyncEnumerable InvokeStreamingAsync( } if (update.Text is { Length: > 0 } text) - { - outputCharCount += text.Length; - } + outputText.Append(text); yield return update; } - // 优先使用精确值,回退到字符估算 + // 优先使用精确值;无 Usage 时对真实输出文本估算,避免用空格字符严重低估。 var inputTokens = streamingUsage?.InputTokenCount ?? TokenEstimator.EstimateTotalTokens(messages); - var outputTokens = streamingUsage?.OutputTokenCount ?? TokenEstimator.EstimateTokens(new string(' ', outputCharCount)); + var outputTokens = streamingUsage?.OutputTokenCount ?? TokenEstimator.EstimateTokens(outputText.ToString()); - LogAndAccumulate(session, (int)inputTokens, (int)outputTokens, agentName, + LogAndAccumulate(session, inputTokens, outputTokens, agentName, isPrecise: streamingUsage != null); } - private void LogAndAccumulate(AgentSession? session, int inputTokens, int outputTokens, string? agentName, + internal void LogAndAccumulate(AgentSession? session, long inputTokens, long outputTokens, string? agentName, bool isPrecise = false) { _logger.LogDebug( @@ -104,42 +104,56 @@ private void LogAndAccumulate(AgentSession? session, int inputTokens, int output agentName ?? "Unknown", inputTokens, outputTokens, isPrecise ? "提供商精确值" : "估算值"); + var activity = System.Diagnostics.Activity.Current; + if (activity?.Source.Name == MarketAssistantDiagnostics.SourceName) + { + activity.SetTag("gen_ai.usage.input_tokens", inputTokens); + activity.SetTag("gen_ai.usage.output_tokens", outputTokens); + activity.SetTag("marketassistant.token_usage.precise", isPrecise); + } + if (session == null) return; - var cumulativeInput = session.StateBag.TryGetValue(InputTokensKey, out var existing) - && int.TryParse(existing, out var existingVal) - ? existingVal + inputTokens - : inputTokens; - var cumulativeOutput = session.StateBag.TryGetValue(OutputTokensKey, out var existingOut) - && int.TryParse(existingOut, out var existingOutVal) - ? existingOutVal + outputTokens - : outputTokens; - - session.StateBag.SetValue(InputTokensKey, cumulativeInput.ToString()); - session.StateBag.SetValue(OutputTokensKey, cumulativeOutput.ToString()); - - // 熔断:累计 Token 超过上限时抛出异常,终止 Agent 执行,防止工具调用循环失控 - var total = cumulativeInput + cumulativeOutput; - if (total > MaxCumulativeTokens) + lock (session) { - _logger.LogWarning( - "Token 熔断触发 [{Agent}] - 累计 {Total} 超过上限 {Limit}(输入 {In}, 输出 {Out})", - agentName ?? "Unknown", total, MaxCumulativeTokens, cumulativeInput, cumulativeOutput); - throw new InvalidOperationException( - $"Agent 累计 Token 用量 {total} 超过熔断上限 {MaxCumulativeTokens},已终止执行以防止工具调用循环失控"); + var cumulativeInput = session.StateBag.TryGetValue(InputTokensKey, out var existing) + && long.TryParse(existing, out var existingVal) + ? checked(existingVal + inputTokens) + : inputTokens; + var cumulativeOutput = session.StateBag.TryGetValue(OutputTokensKey, out var existingOut) + && long.TryParse(existingOut, out var existingOutVal) + ? checked(existingOutVal + outputTokens) + : outputTokens; + + session.StateBag.SetValue(InputTokensKey, cumulativeInput.ToString()); + session.StateBag.SetValue(OutputTokensKey, cumulativeOutput.ToString()); + + // 熔断:累计 Token 超过上限时抛出异常,终止 Agent 执行,防止工具调用循环失控 + var total = checked(cumulativeInput + cumulativeOutput); + if (total > MaxCumulativeTokens) + { + _logger.LogWarning( + "Token 熔断触发 [{Agent}] - 累计 {Total} 超过上限 {Limit}(输入 {In}, 输出 {Out})", + agentName ?? "Unknown", total, MaxCumulativeTokens, cumulativeInput, cumulativeOutput); + throw new InvalidOperationException( + $"Agent 累计 Token 用量 {total} 超过熔断上限 {MaxCumulativeTokens},已终止执行以防止工具调用循环失控"); + } } } /// /// 从 Session 的 StateBag 中读取累计 Token 数 /// - public static (int Input, int Output) GetCumulativeTokens(AgentSession? session) + public static (long Input, long Output) GetCumulativeTokens(AgentSession? session) { if (session == null) return (0, 0); - var input = session.StateBag.TryGetValue(InputTokensKey, out var i) && int.TryParse(i, out var iv) ? iv : 0; - var output = session.StateBag.TryGetValue(OutputTokensKey, out var o) && int.TryParse(o, out var ov) ? ov : 0; - return (input, output); + lock (session) + { + var input = session.StateBag.TryGetValue(InputTokensKey, out var i) && long.TryParse(i, out var iv) ? iv : 0; + var output = session.StateBag.TryGetValue(OutputTokensKey, out var o) && long.TryParse(o, out var ov) ? ov : 0; + return (input, output); + } } /// diff --git a/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs b/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs index bf5c1c6..86d3507 100644 --- a/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs +++ b/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs @@ -2,38 +2,101 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using System.Runtime.CompilerServices; namespace MarketAssistant.Agents.Middleware; /// -/// 交易工具调用守卫中间件,拦截 Agent 的函数调用实现: -/// 1. 敏感操作(PlaceOrder)审计日志 -/// 2. 单次 Agent 运行内的工具调用计数限制 -/// 3. 需人工确认时可终止调用链 +/// 交易敏感操作的授权模式。 /// -public sealed class TradingFunctionGuardMiddleware +public enum TradingAuthorizationMode { - private const int DefaultMaxToolCalls = 20; + /// + /// 禁止执行任何真实下单或撤单操作。 + /// + Disabled, + + /// + /// 每个敏感操作均需通过外部确认回调授权。 + /// + InteractiveConfirmation, /// - /// 需人工确认的回调。返回 true 表示用户确认放行,false 表示拒绝。 - /// 未设置时自动拒绝需确认的操作。 + /// 调用方已在进入 Agent 前完成自动交易策略与风控校验。 /// - public Func>? ConfirmationCallback { get; set; } + PreAuthorizedAutomation +} + +/// +/// 交易工具调用守卫中间件,拦截 Agent 的函数调用实现: +/// 1. 敏感操作(PlaceOrder/CancelOrder)审计日志 +/// 2. 单次 Agent Run 的工具调用计数限制 +/// 3. 根据显式授权模式放行或拒绝敏感操作 +/// +public sealed class TradingFunctionGuardMiddleware +{ + private const int DefaultMaxToolCalls = 20; private readonly ILogger _logger; private readonly int _maxToolCalls; - private int _toolCallCount; + private readonly TradingAuthorizationMode _authorizationMode; + private readonly Func>? _confirmationCallback; + private readonly AsyncLocal _currentRunBudget = new(); - /// 单次 Agent 运行最大工具调用次数,防止无限循环(默认 20) - public TradingFunctionGuardMiddleware(ILogger logger, int maxToolCalls = DefaultMaxToolCalls) + /// 日志记录器。 + /// 敏感交易操作授权模式。 + /// 交互确认回调;交互模式下未提供时严格拒绝。 + /// 单次 Agent Run 最大工具调用次数,防止无限循环(默认 20)。 + public TradingFunctionGuardMiddleware( + ILogger logger, + TradingAuthorizationMode authorizationMode = TradingAuthorizationMode.Disabled, + Func>? confirmationCallback = null, + int maxToolCalls = DefaultMaxToolCalls) { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxToolCalls); + _logger = logger; + _authorizationMode = authorizationMode; + _confirmationCallback = confirmationCallback; _maxToolCalls = maxToolCalls; } /// - /// Function Calling 中间件入口,通过 agent.AsBuilder().Use(this.InvokeAsync).Build() 附加 + /// 非流式 Agent Run 边界,为本轮工具调用建立独立预算。 + /// + public async Task InvokeRunAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + CancellationToken cancellationToken) + { + using var scope = BeginRunBudgetScope(); + return await innerAgent.RunAsync(messages, session, options, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// 流式 Agent Run 边界,为本轮工具调用建立独立预算。 + /// + public async IAsyncEnumerable InvokeRunStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + using var scope = BeginRunBudgetScope(); + await foreach (var update in innerAgent + .RunStreamingAsync(messages, session, options, cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + } + + /// + /// Function Calling 中间件入口,通过 agent.AsBuilder().Use(this.InvokeAsync).Build() 附加。 /// public async ValueTask InvokeAsync( AIAgent agent, @@ -42,7 +105,10 @@ public TradingFunctionGuardMiddleware(ILogger lo CancellationToken cancellationToken) { var functionName = context.Function.Name; - var callIndex = Interlocked.Increment(ref _toolCallCount); + var runBudget = _currentRunBudget.Value + ?? throw new InvalidOperationException( + "交易工具守卫缺少 Agent Run 上下文,请同时注册 Run 和 Function 中间件"); + var callIndex = Interlocked.Increment(ref runBudget.ToolCallCount); // 1. 调用计数守卫 if (callIndex > _maxToolCalls) @@ -60,17 +126,31 @@ public TradingFunctionGuardMiddleware(ILogger lo var args = FormatArguments(context); _logger.LogInformation( - "交易敏感操作拦截 [{Function}] 参数: {Args} (第 {Index} 次调用)", - functionName, args, callIndex); + "交易敏感操作拦截 [{Function}] 参数字段: {ArgumentNames},授权模式: {AuthorizationMode} (第 {Index} 次调用)", + functionName, + FormatArgumentNames(context), + _authorizationMode, + callIndex); + + bool authorized; + try + { + authorized = await IsAuthorizedAsync(functionName, args); + } + catch (Exception ex) + { + _logger.LogError(ex, "交易授权回调执行失败,已拒绝: {Function}", functionName); + authorized = false; + } - if (ConfirmationCallback != null) + if (!authorized) { - var approved = await ConfirmationCallback(functionName, args); - if (!approved) - { - _logger.LogInformation("用户拒绝交易操作: {Function}", functionName); - return $"操作已被用户取消: {functionName}"; - } + _logger.LogWarning( + "交易操作未获授权,已拒绝: {Function}, AuthorizationMode: {AuthorizationMode}", + functionName, + _authorizationMode); + context.Terminate = true; + return $"操作未获授权,已拒绝: {functionName}"; } } else @@ -92,24 +172,61 @@ public TradingFunctionGuardMiddleware(ILogger lo return result; } + internal IDisposable BeginRunBudgetScope() + { + var previous = _currentRunBudget.Value; + _currentRunBudget.Value = new RunBudgetState(); + return new RunBudgetScope(_currentRunBudget, previous); + } + + private async Task IsAuthorizedAsync(string functionName, string args) + { + return _authorizationMode switch + { + TradingAuthorizationMode.PreAuthorizedAutomation => true, + TradingAuthorizationMode.InteractiveConfirmation when _confirmationCallback is not null + => await _confirmationCallback(functionName, args), + _ => false + }; + } + private static bool IsSensitiveOperation(string functionName) { return functionName is "PlaceOrderAsync" or "CancelOrderAsync"; } - private string FormatArguments(FunctionInvocationContext context) + private static string FormatArguments(FunctionInvocationContext context) { - try - { - // 记录实际传入参数(而非函数 Schema),用于审计追踪 - return context.Arguments != null - ? JsonSerializer.Serialize(context.Arguments) - : "N/A"; - } - catch (Exception ex) + return context.Arguments != null + ? JsonSerializer.Serialize(context.Arguments) + : "N/A"; + } + + private static string FormatArgumentNames(FunctionInvocationContext context) + { + return context.Arguments is { Count: > 0 } + ? string.Join(',', context.Arguments.Keys.OrderBy(name => name, StringComparer.Ordinal)) + : "N/A"; + } + + private sealed class RunBudgetState + { + public int ToolCallCount; + } + + private sealed class RunBudgetScope( + AsyncLocal currentRunBudget, + RunBudgetState? previous) : IDisposable + { + private bool _disposed; + + public void Dispose() { - _logger.LogWarning(ex, "序列化交易工具参数失败,函数: {Function}", context.Function.Name); - return "N/A"; + if (_disposed) + return; + + currentRunBudget.Value = previous; + _disposed = true; } } } diff --git a/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptConfig.cs b/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptConfig.cs index c13e0ca..3bfb2ce 100644 --- a/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptConfig.cs +++ b/src/MarketAssistant.Agents/PromptConfiguration/AnalystPromptConfig.cs @@ -12,4 +12,18 @@ public class AnalystPromptConfig public float TopP { get; set; } public int TopK { get; set; } public string Instructions { get; set; } = string.Empty; + + /// + /// 创建当前配置的副本,替换 Instructions 字段 + /// + public AnalystPromptConfig WithInstructions(string instructions) => new() + { + Name = Name, + DisplayName = DisplayName, + Description = Description, + Temperature = Temperature, + TopP = TopP, + TopK = TopK, + Instructions = instructions + }; } diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs index f47804f..964218b 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs @@ -3,6 +3,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Agents.PromptConfiguration; using MarketAssistant.Agents.Tools; +using MarketAssistant.Infrastructure.Providers; using MarketAssistant.Services.Settings; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -21,12 +22,6 @@ namespace MarketAssistant.Agents.Analysts; public class CoordinatorAnalystAgent : AnalystAgentBase { - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: AIJsonUtilities.CreateJsonSchema(typeof(CoordinatorResult)), - schemaName: nameof(CoordinatorResult), - schemaDescription: "协调分析师的综合分析结果,包含投资建议、评分、风险评估等结构化数据" - ); - public CoordinatorAnalystAgent( IChatClient chatClient, IList tools, @@ -38,8 +33,8 @@ public CoordinatorAnalystAgent( AgentSkillsProvider? skillsProvider = null) : base( chatClient, - promptLoader.GetConfig("CoordinatorAnalyst"), - ResponseFormat, + StructuredOutputHelper.MergeSchemaPrompt(promptLoader.GetConfig("CoordinatorAnalyst"), typeof(CoordinatorResult)), + ChatResponseFormat.Json, [.. tools, AIFunctionFactory.Create(searchTools.SearchAsync)], [ new InvestmentPreferenceContextProvider( diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs index 49fae23..1b751d7 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs @@ -2,6 +2,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Agents.PromptConfiguration; using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Providers; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -17,14 +18,6 @@ namespace MarketAssistant.Agents.Analysts; [RequiresTools(typeof(IFinancialTools))] public class FinancialAnalystAgent : AnalystAgentBase { - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(FinancialAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(FinancialAnalysisResult), - schemaDescription: "财务分析师的结构化分析结果,包含财务健康、盈利质量、现金流和风险预警" - ); - public FinancialAnalystAgent( IChatClient chatClient, IList tools, @@ -33,12 +26,11 @@ public FinancialAnalystAgent( AgentSkillsProvider? skillsProvider = null) : base( chatClient, - promptLoader.GetConfig("FinancialAnalyst"), - ResponseFormat, + StructuredOutputHelper.MergeSchemaPrompt(promptLoader.GetConfig("FinancialAnalyst"), typeof(FinancialAnalysisResult)), + ChatResponseFormat.Json, tools, aiContextProviders, skillsProvider) { } - } diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs index ada9dba..b8bf663 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs @@ -2,6 +2,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Agents.PromptConfiguration; using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Providers; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -18,14 +19,6 @@ namespace MarketAssistant.Agents.Analysts; [RequiresTools(typeof(IBasicDataTools))] public class FundamentalAnalystAgent : AnalystAgentBase { - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(FundamentalAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(FundamentalAnalysisResult), - schemaDescription: "基本面分析师的结构化分析结果,包含公司基本面、行业竞争和投资价值评估" - ); - public FundamentalAnalystAgent( IChatClient chatClient, IList tools, @@ -34,8 +27,8 @@ public FundamentalAnalystAgent( AgentSkillsProvider? skillsProvider = null) : base( chatClient, - promptLoader.GetConfig("FundamentalAnalyst"), - ResponseFormat, + StructuredOutputHelper.MergeSchemaPrompt(promptLoader.GetConfig("FundamentalAnalyst"), typeof(FundamentalAnalysisResult)), + ChatResponseFormat.Json, tools, aiContextProviders, skillsProvider) diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs index be92991..051cac8 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs @@ -2,6 +2,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Agents.PromptConfiguration; using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Providers; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -18,14 +19,6 @@ namespace MarketAssistant.Agents.Analysts; [RequiresTools(typeof(ISentimentTools))] public class MarketSentimentAnalystAgent : AnalystAgentBase { - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(MarketSentimentAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(MarketSentimentAnalysisResult), - schemaDescription: "市场情绪分析师的结构化分析结果,包含市场情绪、资金流向、投资者行为和短期策略" - ); - public MarketSentimentAnalystAgent( IChatClient chatClient, IList tools, @@ -34,8 +27,8 @@ public MarketSentimentAnalystAgent( AgentSkillsProvider? skillsProvider = null) : base( chatClient, - promptLoader.GetConfig("MarketSentimentAnalyst"), - ResponseFormat, + StructuredOutputHelper.MergeSchemaPrompt(promptLoader.GetConfig("MarketSentimentAnalyst"), typeof(MarketSentimentAnalysisResult)), + ChatResponseFormat.Json, tools, aiContextProviders, skillsProvider) diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs index cb35347..64ce28a 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs @@ -2,6 +2,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Agents.PromptConfiguration; using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Providers; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -17,14 +18,6 @@ namespace MarketAssistant.Agents.Analysts; [RequiresTools(typeof(INewsDataTools))] public class NewsEventAnalystAgent : AnalystAgentBase { - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(NewsEventAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(NewsEventAnalysisResult), - schemaDescription: "新闻事件分析师的结构化分析结果,包含事件解读、影响评估和投资启示" - ); - public NewsEventAnalystAgent( IChatClient chatClient, IList tools, @@ -33,8 +26,8 @@ public NewsEventAnalystAgent( AgentSkillsProvider? skillsProvider = null) : base( chatClient, - promptLoader.GetConfig("NewsEventAnalyst"), - ResponseFormat, + StructuredOutputHelper.MergeSchemaPrompt(promptLoader.GetConfig("NewsEventAnalyst"), typeof(NewsEventAnalysisResult)), + ChatResponseFormat.Json, tools, aiContextProviders, skillsProvider) diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs index 1536f9f..e7c4158 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs @@ -2,6 +2,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Agents.PromptConfiguration; using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Providers; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -17,14 +18,6 @@ namespace MarketAssistant.Agents.Analysts; [RequiresTools(typeof(ITechnicalDataTools))] public class TechnicalAnalystAgent : AnalystAgentBase { - private static readonly object Schema = AIJsonUtilities.CreateJsonSchema(typeof(TechnicalAnalysisResult)); - - private static readonly ChatResponseFormat ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: (JsonElement)Schema, - schemaName: nameof(TechnicalAnalysisResult), - schemaDescription: "技术分析师的结构化分析结果,包含图表形态、关键价位、技术指标和交易策略" - ); - public TechnicalAnalystAgent( IChatClient chatClient, IList tools, @@ -33,8 +26,8 @@ public TechnicalAnalystAgent( AgentSkillsProvider? skillsProvider = null) : base( chatClient, - promptLoader.GetConfig("TechnicalAnalyst"), - ResponseFormat, + StructuredOutputHelper.MergeSchemaPrompt(promptLoader.GetConfig("TechnicalAnalyst"), typeof(TechnicalAnalysisResult)), + ChatResponseFormat.Json, tools, aiContextProviders, skillsProvider) diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs index 947a9b6..01fee0d 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs @@ -3,6 +3,7 @@ using MarketAssistant.Applications.InvestmentSelection.Models; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Infrastructure.Providers; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -67,17 +68,17 @@ private async ValueTask HandleAsync( var systemPrompt = formatter.GetAnalysisInstructions(originalRequest.IsNewsAnalysis); var userPrompt = BuildAnalysisPrompt(originalRequest, assetsDataText); + var chatClient = _chatClientFactory.CreateClient(); + + var schemaPrompt = StructuredOutputHelper.BuildSchemaPromptSection(typeof(InvestmentSelectionResult), "InvestmentSelectionResult"); + systemPrompt = systemPrompt + "\n\n" + schemaPrompt; + var options = new ChatOptions { - ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: AIJsonUtilities.CreateJsonSchema(typeof(InvestmentSelectionResult)), - schemaName: "InvestmentSelectionResult", - schemaDescription: "投资选择分析结果,包含推荐资产列表和分析报告"), + ResponseFormat = ChatResponseFormat.Json, Temperature = 0.2f, MaxOutputTokens = 8000 }; - - var chatClient = _chatClientFactory.CreateClient(); var response = await chatClient.GetResponseAsync( [ new ChatMessage(ChatRole.System, systemPrompt), @@ -86,29 +87,36 @@ private async ValueTask HandleAsync( options, cancellationToken); - _logger.LogDebug("[步骤3/3-{MarketType}] AI原始响应: {Response}", originalRequest.MarketType, response.Text); + _logger.LogDebug( + "[步骤3/3-{MarketType}] AI 响应接收完成,长度: {ResponseLength}", + originalRequest.MarketType, + response.Text?.Length ?? 0); var result = LlmJsonExtractor.Deserialize(response.Text, JsonOptions); if (result == null) { - _logger.LogWarning("[步骤3/3-{MarketType}] 响应反序列化失败,原始响应: {Response}", - originalRequest.MarketType, response.Text); + _logger.LogWarning( + "[步骤3/3-{MarketType}] 响应反序列化失败,响应长度: {ResponseLength}", + originalRequest.MarketType, + response.Text?.Length ?? 0); result = CreateDefaultResult("解析分析结果失败"); } else { - var validationErrors = ValidateResult(result); + var validationErrors = ValidateResult(result, input, originalRequest); if (validationErrors.Count > 0) { - _logger.LogWarning("[步骤3/3-{MarketType}] AI返回数据验证失败: {Errors}", - originalRequest.MarketType, string.Join("; ", validationErrors)); + throw new InvalidOperationException( + $"AI 返回的数据不符合约束: {string.Join("; ", validationErrors)}"); } if (result.Recommendations.Count == 0) { - _logger.LogWarning("[步骤3/3-{MarketType}] AI未生成任何推荐,原始响应: {Response}", - originalRequest.MarketType, response.Text); + _logger.LogWarning( + "[步骤3/3-{MarketType}] AI 未生成任何推荐,响应长度: {ResponseLength}", + originalRequest.MarketType, + response.Text?.Length ?? 0); } } @@ -124,39 +132,52 @@ private async ValueTask HandleAsync( } } - private List ValidateResult(InvestmentSelectionResult result) + private static List ValidateResult( + InvestmentSelectionResult result, + AssetScreeningResult input, + InvestmentSelectionWorkflowRequest request) { - var errors = new List(); - - if (!Enum.IsDefined(typeof(SelectionType), result.SelectionType)) - errors.Add($"SelectionType 值无效: {result.SelectionType}"); - - if (string.IsNullOrWhiteSpace(result.AnalysisSummary)) - errors.Add("AnalysisSummary 不能为空"); + var errors = StructuredOutputValidator.Validate(result).ToList(); + var expectedSelectionType = request.IsNewsAnalysis + ? SelectionType.NewsBased + : SelectionType.UserRequest; - if (string.IsNullOrWhiteSpace(result.MarketEnvironmentAnalysis)) - errors.Add("MarketEnvironmentAnalysis 不能为空"); - - if (string.IsNullOrWhiteSpace(result.InvestmentAdvice)) - errors.Add("InvestmentAdvice 不能为空"); + if (result.SelectionType != expectedSelectionType) + { + errors.Add($"SelectionType 应为 {expectedSelectionType},实际为 {result.SelectionType}"); + } - if (result.RiskWarnings == null || result.RiskWarnings.Count == 0) - errors.Add("RiskWarnings 不能为空"); + if (result.Recommendations is null) + { + errors.Add("Recommendations 不能为空"); + return errors; + } - for (int i = 0; i < result.Recommendations.Count; i++) + var maxRecommendations = Math.Clamp(request.MaxRecommendations, 1, 10); + if (result.Recommendations.Count > maxRecommendations) { - var rec = result.Recommendations[i]; - if (string.IsNullOrWhiteSpace(rec.Symbol)) - errors.Add($"第{i + 1}个推荐的 Symbol 不能为空"); + errors.Add( + $"推荐数量 {result.Recommendations.Count} 超过请求上限 {maxRecommendations}"); + } - if (string.IsNullOrWhiteSpace(rec.Name)) - errors.Add($"第{i + 1}个推荐的 Name 不能为空"); + var availableSymbols = input.ScreenedAssets + .Select(asset => asset.Symbol) + .Where(symbol => !string.IsNullOrWhiteSpace(symbol)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var recommendedSymbols = new HashSet(StringComparer.OrdinalIgnoreCase); - if (string.IsNullOrWhiteSpace(rec.Reason)) - errors.Add($"第{i + 1}个推荐的 Reason 不能为空"); + for (var index = 0; index < result.Recommendations.Count; index++) + { + var recommendation = result.Recommendations[index]; + if (!availableSymbols.Contains(recommendation.Symbol)) + { + errors.Add($"Recommendations[{index}].Symbol 不在筛选结果中: {recommendation.Symbol}"); + } - if (!Enum.IsDefined(typeof(RiskLevel), rec.RiskLevel)) - errors.Add($"第{i + 1}个推荐的 RiskLevel 值无效: {rec.RiskLevel}"); + if (!recommendedSymbols.Add(recommendation.Symbol)) + { + errors.Add($"Recommendations[{index}].Symbol 重复: {recommendation.Symbol}"); + } } return errors; @@ -198,8 +219,9 @@ private string BuildAnalysisPrompt(InvestmentSelectionWorkflowRequest request, s sb.AppendLine(assetsData); sb.AppendLine(); sb.AppendLine("## 分析任务"); + var maxRecommendations = Math.Clamp(request.MaxRecommendations, 1, 10); sb.AppendLine($"请基于以上{assetType}数据和用户需求,进行综合分析并生成推荐报告。"); - sb.AppendLine($"- 从中选择最优的3-8个{assetType}进行推荐"); + sb.AppendLine($"- 最多从中选择 {maxRecommendations} 个最优{assetType}进行推荐;没有合适标的时返回空数组"); sb.AppendLine("- 说明推荐理由和风险提示"); return sb.ToString(); diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs index f197a49..b812bf6 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs @@ -2,6 +2,7 @@ using MarketAssistant.Agents.InvestmentSelection.Strategies; using MarketAssistant.Applications.AssetScreener.Models; using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Infrastructure.Providers; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -19,11 +20,6 @@ public sealed class GenerateCriteriaExecutor : Executor _strategy; private readonly ILogger> _logger; - private static readonly JsonSerializerOptions SchemaOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - public GenerateCriteriaExecutor( IChatClientFactory chatClientFactory, ICriteriaGenerationStrategy strategy, @@ -60,14 +56,12 @@ public override async ValueTask HandleAsync( var chatClient = _chatClientFactory.CreateClient(); - var schema = AIJsonUtilities.CreateJsonSchema(typeof(TCriteria), serializerOptions: SchemaOptions); + var schemaPrompt = StructuredOutputHelper.BuildSchemaPromptSection(typeof(TCriteria), typeof(TCriteria).Name); + systemPrompt = systemPrompt + "\n\n" + schemaPrompt; var chatOptions = new ChatOptions { - ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: schema, - schemaName: typeof(TCriteria).Name, - schemaDescription: $"包含筛选条件的{_strategy.SupportedMarketType}筛选参数"), + ResponseFormat = ChatResponseFormat.Json, Temperature = 0.1f, MaxOutputTokens = input.IsNewsAnalysis ? 3500 : 2000 }; @@ -80,7 +74,7 @@ public override async ValueTask HandleAsync( chatOptions, cancellationToken); - var criteria = _strategy.DeserializeCriteria(response.Text); + var criteria = _strategy.DeserializeCriteria(response.Text, input); _logger.LogInformation("[步骤1/3-{MarketType}] 筛选条件生成完成,包含 {Count} 个条件", _strategy.SupportedMarketType, diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs index 47b6560..ceeac08 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs @@ -111,7 +111,9 @@ private async Task ExecuteWorkflowAsync( // 执行工作流 await using Run run = await InProcessExecution.RunAsync( - workflow, request, checkpointManager: null, sessionId: null, cancellationToken); + workflow, + request, + cancellationToken: cancellationToken); InvestmentSelectionResult? finalResult = null; diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs index 0d32589..8e0aa16 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs @@ -9,6 +9,17 @@ namespace MarketAssistant.Agents.InvestmentSelection.Strategies; /// public class CryptoCriteriaGenerationStrategy : ICriteriaGenerationStrategy { + private static readonly HashSet SupportedIndicatorCodes = new(StringComparer.OrdinalIgnoreCase) + { + "market_cap", + "volume_24h", + "price", + "market_cap_rank", + "price_change_24h", + "price_change_7d", + "price_change_30d" + }; + private static readonly JsonSerializerOptions DeserializationOptions = new(JsonSerializerOptions.Web) { PropertyNameCaseInsensitive = true @@ -155,13 +166,49 @@ public string BuildUserPrompt(InvestmentSelectionWorkflowRequest request) } } - public CryptoCriteria DeserializeCriteria(string json) + public CryptoCriteria DeserializeCriteria(string json, InvestmentSelectionWorkflowRequest request) { var criteria = LlmJsonExtractor.Deserialize(json, DeserializationOptions); if (criteria == null) { throw new InvalidOperationException("虚拟币筛选条件 JSON 解析失败"); } + + if (criteria.Criteria is null) + { + throw new InvalidOperationException("虚拟币筛选条件列表不能为空"); + } + + for (var index = 0; index < criteria.Criteria.Count; index++) + { + var condition = criteria.Criteria[index]; + ValidateCondition(condition.Code, condition.MinValue, condition.MaxValue, index); + } + + criteria.Limit = Math.Clamp(request.MaxRecommendations, 1, 10); return criteria; } + + private static void ValidateCondition( + string code, + decimal? minValue, + decimal? maxValue, + int index) + { + if (string.IsNullOrWhiteSpace(code) || !SupportedIndicatorCodes.Contains(code)) + { + throw new InvalidOperationException($"虚拟币筛选条件[{index}]包含不支持的指标: {code}"); + } + + if (minValue is null && maxValue is null) + { + throw new InvalidOperationException($"虚拟币筛选条件[{index}]必须至少指定最小值或最大值"); + } + + if (minValue > maxValue) + { + throw new InvalidOperationException( + $"虚拟币筛选条件[{index}]最小值 {minValue} 不能大于最大值 {maxValue}"); + } + } } diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/ICriteriaGenerationStrategy.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/ICriteriaGenerationStrategy.cs index 31a2329..d232874 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/ICriteriaGenerationStrategy.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/ICriteriaGenerationStrategy.cs @@ -31,7 +31,7 @@ public interface ICriteriaGenerationStrategy where TCriteria : IScree string BuildUserPrompt(InvestmentSelectionWorkflowRequest request); /// - /// 反序列化筛选条件 + /// 反序列化并验证筛选条件,使用请求中的数量上限覆盖模型输出。 /// - TCriteria DeserializeCriteria(string json); + TCriteria DeserializeCriteria(string json, InvestmentSelectionWorkflowRequest request); } diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs index 7f9afea..755ee3c 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs @@ -9,6 +9,15 @@ namespace MarketAssistant.Agents.InvestmentSelection.Strategies; /// public class StockCriteriaGenerationStrategy : ICriteriaGenerationStrategy { + private static readonly HashSet SupportedIndicatorCodes = new(StringComparer.OrdinalIgnoreCase) + { + "mc", "fmc", "pettm", "pelyr", "pb", "psr", "roediluted", "bps", "eps", + "netprofit", "total_revenue", "dy_l", "npay", "oiy", "niota", "current", "pct", + "pct5", "pct10", "pct20", "pct60", "pct120", "pct250", "pct_current_year", + "amount", "volume", "volume_ratio", "tr", "chgpct", "follow", "tweet", "deal", + "follow7d", "tweet7d", "deal7d", "follow7dpct", "tweet7dpct", "deal7dpct" + }; + private static readonly JsonSerializerOptions DeserializationOptions = new(JsonSerializerOptions.Web) { PropertyNameCaseInsensitive = true @@ -234,13 +243,59 @@ public string BuildUserPrompt(InvestmentSelectionWorkflowRequest request) } } - public StockCriteria DeserializeCriteria(string json) + public StockCriteria DeserializeCriteria(string json, InvestmentSelectionWorkflowRequest request) { var criteria = LlmJsonExtractor.Deserialize(json, DeserializationOptions); if (criteria == null) { throw new InvalidOperationException("股票筛选条件 JSON 解析失败"); } + + if (criteria.Criteria is null) + { + throw new InvalidOperationException("股票筛选条件列表不能为空"); + } + + if (!Enum.IsDefined(criteria.Market)) + { + throw new InvalidOperationException($"A股市场类型无效: {criteria.Market}"); + } + + if (!Enum.IsDefined(criteria.Industry)) + { + throw new InvalidOperationException($"行业类型无效: {criteria.Industry}"); + } + + for (var index = 0; index < criteria.Criteria.Count; index++) + { + var condition = criteria.Criteria[index]; + ValidateCondition(condition.Code, condition.MinValue, condition.MaxValue, index); + } + + criteria.Limit = Math.Clamp(request.MaxRecommendations, 1, 10); return criteria; } + + private static void ValidateCondition( + string code, + decimal? minValue, + decimal? maxValue, + int index) + { + if (string.IsNullOrWhiteSpace(code) || !SupportedIndicatorCodes.Contains(code)) + { + throw new InvalidOperationException($"股票筛选条件[{index}]包含不支持的指标: {code}"); + } + + if (minValue is null && maxValue is null) + { + throw new InvalidOperationException($"股票筛选条件[{index}]必须至少指定最小值或最大值"); + } + + if (minValue > maxValue) + { + throw new InvalidOperationException( + $"股票筛选条件[{index}]最小值 {minValue} 不能大于最大值 {maxValue}"); + } + } } diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs index 1a93a86..1f29066 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs @@ -2,6 +2,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Infrastructure.Providers; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -31,18 +32,12 @@ public sealed partial class CoordinatorExecutor : Executor }; public CoordinatorExecutor( - IAnalystAgentFactory analystAgentFactory, + AIAgent coordinatorAgent, ILogger logger) : base("Coordinator") { - ArgumentNullException.ThrowIfNull(analystAgentFactory); + _coordinatorAgent = coordinatorAgent ?? throw new ArgumentNullException(nameof(coordinatorAgent)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - // 在构造函数中创建 Agent(确保 tools 配置正确) - // 使用非泛型方法:CreateAnalyst 返回的是中间件包装后的 AIAgent,无法强制转换为具体类型 - _coordinatorAgent = analystAgentFactory.CreateAnalyst(typeof(CoordinatorAnalystAgent)); - - _logger.LogInformation("协调分析师 Agent 已创建(支持工具调用 + 结构化输出)"); } [MessageHandler] @@ -116,7 +111,7 @@ private async ValueTask HandleAsync( } // 从协调分析师的回复文本中反序列化结构化结果 - // 某些 LLM 即使指定了 ForJsonSchema 也可能在 JSON 前后输出多余文本(前缀词、markdown 代码块等), + // 部分兼容模型即使启用 JsonObject 仍可能在 JSON 前后输出多余文本, // 使用 LlmJsonExtractor 进行多层兜底解析(直接解析 → 剥离 markdown → Utf8JsonReader 精确定位) var rawText = coordinatorMessage.Text ?? string.Empty; @@ -139,6 +134,13 @@ private async ValueTask HandleAsync( throw new InvalidOperationException("协调分析师未能返回结构化数据"); } + var validationErrors = StructuredOutputValidator.Validate(coordinatorResult); + if (validationErrors.Count > 0) + { + throw new InvalidOperationException( + $"协调分析师返回的数据不符合约束: {string.Join("; ", validationErrors)}"); + } + _logger.LogInformation( "成功获取协调分析师的结构化数据,综合评分: {Score},最终评级: {Rating}", coordinatorResult.OverallScore, diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs index fcab70f..72272eb 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs @@ -2,6 +2,7 @@ using MarketAssistant.Agents.Analysts.Attributes; using MarketAssistant.Agents.MarketAnalysis.Executors; using MarketAssistant.Agents.MarketAnalysis.Models; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Services.Settings; using MarketAssistant.Trading; @@ -20,10 +21,9 @@ namespace MarketAssistant.Agents.MarketAnalysis; /// public class MarketAnalysisWorkflow { - private readonly AnalysisAggregatorExecutor _aggregatorExecutor; - private readonly CoordinatorExecutor _coordinatorExecutor; private readonly IUserSettingService _userSettingService; private readonly IAnalystAgentFactory _analystAgentFactory; + private readonly IChatClientFactory _chatClientFactory; private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; private readonly AnalysisReportCache _reportCache; @@ -41,26 +41,22 @@ public class MarketAnalysisWorkflow /// 导致 ExecutorId 退化为 _826faad2...。故 Name 必须为 ASCII, /// 显示名在本映射中维护。 /// - private readonly Dictionary _agentNameToDisplayName = new(); - /// /// 分析进度事件 /// public event EventHandler? ProgressChanged; public MarketAnalysisWorkflow( - AnalysisAggregatorExecutor aggregatorExecutor, - CoordinatorExecutor coordinatorExecutor, IUserSettingService userSettingService, IAnalystAgentFactory analystAgentFactory, + IChatClientFactory chatClientFactory, ILoggerFactory loggerFactory, AnalysisReportCache reportCache, ILogger logger) { - _aggregatorExecutor = aggregatorExecutor ?? throw new ArgumentNullException(nameof(aggregatorExecutor)); - _coordinatorExecutor = coordinatorExecutor ?? throw new ArgumentNullException(nameof(coordinatorExecutor)); _userSettingService = userSettingService ?? throw new ArgumentNullException(nameof(userSettingService)); _analystAgentFactory = analystAgentFactory ?? throw new ArgumentNullException(nameof(analystAgentFactory)); + _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); _reportCache = reportCache ?? throw new ArgumentNullException(nameof(reportCache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -69,15 +65,33 @@ public MarketAnalysisWorkflow( /// /// 执行市场分析工作流 /// + public Task AnalyzeAsync( + string assetSymbol, + CancellationToken cancellationToken = default) + { + return AnalyzeAsync(assetSymbol, Guid.NewGuid(), cancellationToken); + } + + /// + /// 使用调用方分配的 Run ID 执行市场分析,确保并发进度事件可准确归属。 + /// public async Task AnalyzeAsync( string assetSymbol, + Guid runId, CancellationToken cancellationToken = default) { + if (runId == Guid.Empty) + throw new ArgumentException("Run ID 不能为空", nameof(runId)); + + using var activity = MarketAssistantDiagnostics.StartActivity("market_analysis.workflow.run"); + activity?.SetTag("marketassistant.run.id", runId.ToString("N")); + activity?.SetTag("marketassistant.asset.symbol", assetSymbol); + try { _logger.LogInformation("开始执行市场分析工作流,标的代码: {AssetSymbol}", assetSymbol); - OnProgressChanged(new AnalysisProgressEventArgs + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = "正在准备分析环境", IsInProgress = true @@ -96,7 +110,11 @@ public async Task AnalyzeAsync( marketSnapshot.SetData("分析标的", assetSymbol); marketSnapshot.SetData("分析时间", DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss UTC")); - var analystAgents = CreateAnalystAgents(enabledAnalysts, marketSnapshot); + var runtime = _chatClientFactory.CreateRuntime(); + activity?.SetTag("gen_ai.provider.name", runtime.ProviderId); + activity?.SetTag("gen_ai.request.model", runtime.ModelId); + activity?.SetTag("marketassistant.analyst.requested_count", enabledAnalysts.Count); + var analystAgents = CreateAnalystAgents(enabledAnalysts, marketSnapshot, runtime.Client); var failedAnalystNames = analystAgents.FailedTypes .Select(GetAnalystDisplayNameFromType) .ToList(); @@ -107,10 +125,25 @@ public async Task AnalyzeAsync( throw new InvalidOperationException("所有分析师创建失败,无法执行分析"); } - // 构建工作流(传入分析师数量) - var workflow = BuildWorkflow(createdAgents.Count, createdAgents); - - OnProgressChanged(new AnalysisProgressEventArgs + // 同一次 Run 的分析师与 Coordinator 绑定同一个 Runtime Client。 + var coordinatorAgent = _analystAgentFactory.CreateAnalyst( + typeof(CoordinatorAnalystAgent), + runtime.Client); + var coordinatorExecutor = new CoordinatorExecutor( + coordinatorAgent, + _loggerFactory.CreateLogger()); + var aggregatorExecutor = new AnalysisAggregatorExecutor( + _loggerFactory.CreateLogger()); + + // 构建工作流(所有 Executor 均为 Run 局部实例) + var agentNameToDisplayName = analystAgents.NameToDisplayName; + var workflow = BuildWorkflow( + createdAgents.Count, + createdAgents, + aggregatorExecutor, + coordinatorExecutor); + + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = $"{createdAgents.Count} 位分析师正在并发分析", IsInProgress = true, @@ -119,12 +152,20 @@ public async Task AnalyzeAsync( }); // 执行工作流(流式处理) - var finalReport = await ExecuteWorkflowAsync(workflow, assetSymbol, createdAgents.Count, cancellationToken); + var finalReport = await ExecuteWorkflowAsync( + workflow, + runId, + assetSymbol, + createdAgents.Count, + agentNameToDisplayName, + cancellationToken); // 缓存分析结果,供交易决策模块使用 _reportCache.Set(assetSymbol, finalReport); + activity?.SetTag("marketassistant.analyst.completed_count", createdAgents.Count); + activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok); - OnProgressChanged(new AnalysisProgressEventArgs + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = "分析完成", IsInProgress = false @@ -132,14 +173,17 @@ public async Task AnalyzeAsync( return finalReport; } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) { + activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, "cancelled"); + activity?.SetTag("error.type", ex.GetType().FullName); throw; } catch (Exception ex) { + MarketAssistantDiagnostics.RecordException(activity, ex); _logger.LogError(ex, "执行市场分析工作流时发生错误"); - OnProgressChanged(new AnalysisProgressEventArgs + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = $"分析失败: {ex.Message}", IsInProgress = false @@ -153,8 +197,10 @@ public async Task AnalyzeAsync( /// private async Task ExecuteWorkflowAsync( Workflow workflow, + Guid runId, string assetSymbol, int analystCount, + IReadOnlyDictionary agentNameToDisplayName, CancellationToken cancellationToken) { MarketAnalysisReport? finalReport = null; @@ -172,9 +218,7 @@ private async Task ExecuteWorkflowAsync( await using StreamingRun run = await InProcessExecution.RunStreamingAsync( workflow, assetSymbol, - checkpointManager: null, - sessionId: null, - cancellationToken); + cancellationToken: cancellationToken); try { @@ -191,10 +235,10 @@ private async Task ExecuteWorkflowAsync( "AnalysisDispatcher" => "正在分发分析任务", "AnalysisAggregator" => "正在聚合分析结果", "Coordinator" => "正在生成综合报告", - _ => $"{GetDisplayNameForExecutorId(executorInvoked.ExecutorId)} 正在分析" + _ => $"{GetDisplayNameForExecutorId(executorInvoked.ExecutorId, agentNameToDisplayName)} 正在分析" }; - OnProgressChanged(new AnalysisProgressEventArgs + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = stageName, IsInProgress = true, @@ -205,13 +249,13 @@ private async Task ExecuteWorkflowAsync( case ExecutorCompletedEvent executorComplete: activeExecutors.Remove(executorComplete.ExecutorId); - lastCompletedStep = GetDisplayNameForExecutorId(executorComplete.ExecutorId); + lastCompletedStep = GetDisplayNameForExecutorId(executorComplete.ExecutorId, agentNameToDisplayName); _logger.LogDebug("工作流步骤完成: {ExecutorId}", executorComplete.ExecutorId); if (IsAnalystExecutor(executorComplete.ExecutorId)) { completedAnalysts++; - OnProgressChanged(new AnalysisProgressEventArgs + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = $"{lastCompletedStep} 分析完成", IsInProgress = true, @@ -250,7 +294,7 @@ private async Task ExecuteWorkflowAsync( case ExecutorFailedEvent executorFailed: activeExecutors.Remove(executorFailed.ExecutorId); - var failedDisplayName = GetDisplayNameForExecutorId(executorFailed.ExecutorId); + var failedDisplayName = GetDisplayNameForExecutorId(executorFailed.ExecutorId, agentNameToDisplayName); var errorDetail = executorFailed.Data?.Message ?? "未知错误"; _logger.LogError(executorFailed.Data, "步骤失败: {ExecutorId} ({DisplayName}), 错误: {Error}", @@ -263,7 +307,7 @@ private async Task ExecuteWorkflowAsync( $"分析流程关键环节「{failedDisplayName}」执行失败: {errorDetail}"); } - OnProgressChanged(new AnalysisProgressEventArgs + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = $"{failedDisplayName} 分析失败,继续其他分析", IsInProgress = true, @@ -299,11 +343,11 @@ private async Task ExecuteWorkflowAsync( // AI 模型 API 响应超时(NetworkTimeout),精确定位卡住的分析师 var stuckAnalysts = activeExecutors .Where(id => IsAnalystExecutor(id)) - .Select(GetDisplayNameForExecutorId) + .Select(id => GetDisplayNameForExecutorId(id, agentNameToDisplayName)) .ToList(); var stuckSystem = activeExecutors .Where(id => IsSystemExecutor(id)) - .Select(GetDisplayNameForExecutorId) + .Select(id => GetDisplayNameForExecutorId(id, agentNameToDisplayName)) .ToList(); var allStuck = stuckAnalysts.Concat(stuckSystem).ToList(); @@ -422,21 +466,26 @@ private List GetEnabledAnalysts() /// /// 创建分析师代理(使用 Factory 模式),返回成功创建的 Agent 列表及失败的类型列表 /// - private (List Agents, List FailedTypes) CreateAnalystAgents( + private ( + List Agents, + List FailedTypes, + IReadOnlyDictionary NameToDisplayName) CreateAnalystAgents( List analystTypes, - MarketSnapshotContextProvider marketSnapshot) + MarketSnapshotContextProvider marketSnapshot, + IChatClient chatClient) { _logger.LogInformation("开始创建分析师代理,数量: {Count}", analystTypes.Count); var sharedProviders = new AIContextProvider[] { marketSnapshot }; var createdAgents = new List(); var failedTypes = new List(); + var nameToDisplayName = new Dictionary(StringComparer.Ordinal); foreach (var type in analystTypes) { try { - var agent = _analystAgentFactory.CreateAnalyst(type, sharedProviders); + var agent = _analystAgentFactory.CreateAnalyst(type, chatClient, sharedProviders); createdAgents.Add(agent); // 创建时即建立 Name → DisplayName 映射。 @@ -446,7 +495,7 @@ private List GetEnabledAnalysts() var displayName = GetAnalystDisplayNameFromType(type); if (!string.IsNullOrEmpty(agent.Name)) { - _agentNameToDisplayName[agent.Name] = displayName; + nameToDisplayName[agent.Name] = displayName; } } catch (Exception ex) @@ -457,7 +506,7 @@ private List GetEnabledAnalysts() } _logger.LogInformation("成功创建分析师代理,实际数量: {Count}", createdAgents.Count); - return (createdAgents, failedTypes); + return (createdAgents, failedTypes, nameToDisplayName); } /// @@ -510,11 +559,13 @@ private static bool IsDispatcherExecutor(string executorId) /// 从工作流 ExecutorId 中提取分析师显示名称。 /// 按第一个下划线切出 Name 前缀,再在 中查中文显示名。 /// - private string GetDisplayNameForExecutorId(string executorId) + private static string GetDisplayNameForExecutorId( + string executorId, + IReadOnlyDictionary agentNameToDisplayName) { var namePrefix = GetExecutorNamePrefix(executorId); - return _agentNameToDisplayName.TryGetValue(namePrefix, out var displayName) + return agentNameToDisplayName.TryGetValue(namePrefix, out var displayName) ? displayName : executorId; } @@ -526,7 +577,11 @@ private string GetDisplayNameForExecutorId(string executorId) /// 流程: /// [Dispatcher] → [并发分析师团队] → [Aggregator] → [Coordinator] /// - private Workflow BuildWorkflow(int analystCount, List analystAgents) + private Workflow BuildWorkflow( + int analystCount, + List analystAgents, + AnalysisAggregatorExecutor aggregatorExecutor, + CoordinatorExecutor coordinatorExecutor) { // 构建标准 Fan-Out/Fan-In 工作流: // @@ -552,13 +607,13 @@ private Workflow BuildWorkflow(int analystCount, List analystAgents) // 4. Fan-In: 所有分析师 → Aggregator // 框架会自动收集所有源(分析师)的消息,并作为 List 一次性传递给 Aggregator - builder.AddFanInBarrierEdge([.. analystAgents], _aggregatorExecutor); + builder.AddFanInBarrierEdge([.. analystAgents], aggregatorExecutor); // 5. Aggregator → Coordinator(将聚合结果传递给协调分析师) - builder.AddEdge(_aggregatorExecutor, _coordinatorExecutor); + builder.AddEdge(aggregatorExecutor, coordinatorExecutor); // 6. 设置输出来自 Coordinator - builder.WithOutputFrom(_coordinatorExecutor); + builder.WithOutputFrom(coordinatorExecutor); return builder.Build(); } @@ -566,8 +621,13 @@ private Workflow BuildWorkflow(int analystCount, List analystAgents) /// /// 触发进度事件 /// - protected virtual void OnProgressChanged(AnalysisProgressEventArgs e) + protected virtual void OnProgressChanged( + Guid runId, + string assetSymbol, + AnalysisProgressEventArgs e) { + e.RunId = runId; + e.AssetSymbol = assetSymbol; ProgressChanged?.Invoke(this, e); } } @@ -577,6 +637,16 @@ protected virtual void OnProgressChanged(AnalysisProgressEventArgs e) /// public sealed class AnalysisProgressEventArgs : EventArgs { + /// + /// 本次分析运行的唯一标识。 + /// + public Guid RunId { get; internal set; } + + /// + /// 本次分析对应的标的代码。 + /// + public string AssetSymbol { get; internal set; } = string.Empty; + /// /// 当前阶段描述 /// diff --git a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs index 3859a45..39d58f0 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketChatSession.cs @@ -2,6 +2,7 @@ using MarketAssistant.Agents.Middleware; using MarketAssistant.Agents.TokenManagement; using MarketAssistant.Agents.Tools; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services; using MarketAssistant.Services.Mcp; using Microsoft.Agents.AI; @@ -9,6 +10,7 @@ using Microsoft.Extensions.Logging; using System.Runtime.CompilerServices; using System.Text; +using System.Text.Json; namespace MarketAssistant.Agents; @@ -21,6 +23,9 @@ namespace MarketAssistant.Agents; /// public class MarketChatSession : IDisposable { + private const int SessionSchemaVersion = 1; + private static readonly JsonSerializerOptions SessionSerializerOptions = new(JsonSerializerDefaults.Web); + private readonly AIAgent _agent; private readonly ILogger _logger; private readonly GroundingSearchTools? _searchTools; @@ -29,11 +34,16 @@ public class MarketChatSession : IDisposable private readonly KnowledgeGraphTools? _knowledgeGraphTools; private readonly ChatSessionPersistenceService? _sessionPersistence; private readonly MemoryExtractionService? _memoryExtraction; + private readonly string _providerId; + private readonly string _modelId; + private readonly string _endpoint; + private readonly string _runtimeConfigurationFingerprint; private int _turnsSinceLastExtraction; private AgentSession? _currentSession; private readonly List _searchToolCache = []; private readonly List _conversationHistory = []; private readonly object _conversationLock = new(); + private readonly SemaphoreSlim _sendLock = new(1, 1); private string _sessionId = Guid.NewGuid().ToString("N"); private string _currentStockCode = string.Empty; @@ -42,6 +52,7 @@ public class MarketChatSession : IDisposable private CancellationTokenSource? _currentCancellationTokenSource; private bool _disposed; private bool _searchToolsInitialized; + private bool _restoreHistoryOnNextRun; /// /// 当前会话 ID(用于持久化标识) @@ -51,7 +62,7 @@ public class MarketChatSession : IDisposable /// /// 当前会话估算的 Token 数(优先从 Session StateBag 中间件数据读取,回退到本地估算) /// - public int EstimatedTokenCount + public long EstimatedTokenCount { get { @@ -76,11 +87,15 @@ public MarketChatSession( KnowledgeGraphTools? knowledgeGraphTools = null, AgentSkillsProvider? skillsProvider = null, TokenTrackingMiddleware? tokenTracking = null, - ConversationCompressionMiddleware? compressionMiddleware = null, + AIContextProvider? compactionProvider = null, LayeredMemoryContextProvider? layeredMemoryProvider = null, ChatSessionPersistenceService? sessionPersistence = null, MemoryExtractionService? memoryExtraction = null, - string? initialStockCode = null) + string? initialStockCode = null, + string? providerId = null, + string? modelId = null, + string? endpoint = null, + string? runtimeConfigurationFingerprint = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _searchTools = searchTools; @@ -90,6 +105,10 @@ public MarketChatSession( _sessionPersistence = sessionPersistence; _memoryExtraction = memoryExtraction; _currentStockCode = initialStockCode ?? string.Empty; + _providerId = providerId ?? string.Empty; + _modelId = modelId ?? string.Empty; + _endpoint = endpoint ?? string.Empty; + _runtimeConfigurationFingerprint = runtimeConfigurationFingerprint ?? string.Empty; // 收集所有 AIContextProvider:Skills + MCP 工具 + LayeredMemory (优先) / Memory + RAG var contextProviders = new List(); @@ -97,6 +116,8 @@ public MarketChatSession( if (mcpToolProvider != null) contextProviders.Add(mcpToolProvider); if (layeredMemoryProvider != null) contextProviders.Add(layeredMemoryProvider); + if (compactionProvider != null) + contextProviders.Add(compactionProvider); var baseAgent = new ChatClientAgent( chatClient, @@ -111,52 +132,29 @@ public MarketChatSession( AIContextProviders = contextProviders.Count > 0 ? [.. contextProviders] : null }); - // 挂接压缩前紧急保存钩子 - if (compressionMiddleware != null && _memoryExtraction != null) - { - compressionMiddleware.PreCompressHook = async (messages, ct) => - { - await _memoryExtraction.ExtractAndSaveAsync( - messages as IReadOnlyList ?? messages.ToList(), - isEmergency: true, ct: ct); - }; - } - - // 通过 MAF Builder 模式链式附加中间件 - _agent = BuildAgentWithMiddleware(baseAgent, tokenTracking, compressionMiddleware); + // Compaction 作为会话级 AIContextProvider 参与 Session 序列化; + // Token Tracking 仍作为无状态 Agent Middleware 附加。 + _agent = BuildAgentWithMiddleware(baseAgent, tokenTracking); _logger.LogInformation("MarketChatSession 初始化完成(工具待异步加载,已附加中间件)"); } /// - /// 使用 MAF AsBuilder 模式为 Agent 附加中间件链 - /// 中间件执行顺序:压缩(外层)→ Token 追踪(内层)→ 原始 Agent + /// 使用 MAF AsBuilder 模式为 Agent 附加 Token 追踪中间件。 /// private static AIAgent BuildAgentWithMiddleware( AIAgent baseAgent, - TokenTrackingMiddleware? tokenTracking, - ConversationCompressionMiddleware? compression) + TokenTrackingMiddleware? tokenTracking) { - var builder = baseAgent.AsBuilder(); - var hasMiddleware = false; + if (tokenTracking is null) + return baseAgent; - if (tokenTracking != null) - { - builder = builder.Use( + return baseAgent + .AsBuilder() + .Use( runFunc: tokenTracking.InvokeAsync, - runStreamingFunc: tokenTracking.InvokeStreamingAsync); - hasMiddleware = true; - } - - if (compression != null) - { - builder = builder.Use( - runFunc: compression.InvokeAsync, - runStreamingFunc: compression.InvokeStreamingAsync); - hasMiddleware = true; - } - - return hasMiddleware ? builder.Build() : baseAgent; + runStreamingFunc: tokenTracking.InvokeStreamingAsync) + .Build(); } #region 工具初始化 @@ -217,6 +215,7 @@ public void InjectAnalysisContext(string stockCode, IEnumerable ana _analysisContext = BuildAnalysisSummary(analysisMessages); _cachedInstructions = null; _currentSession = null; + _restoreHistoryOnNextRun = false; lock (_conversationLock) { _conversationHistory.Clear(); @@ -261,75 +260,107 @@ public async IAsyncEnumerable SendMessageStreamAsync( string userMessage, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - EnsureSearchToolsInitialized(); + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(userMessage); - _currentSession ??= await _agent.CreateSessionAsync(cancellationToken: cancellationToken); + if (!await _sendLock.WaitAsync(0, cancellationToken)) + throw new InvalidOperationException("当前会话正在处理另一条消息,请等待完成或先停止当前请求"); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - _currentCancellationTokenSource = cts; + var completeResponse = new StringBuilder(); + var userMessageAdded = false; + var completed = false; + CancellationTokenSource? cts = null; + using var activity = MarketAssistantDiagnostics.StartActivity("market_chat.agent.run"); + activity?.SetTag("gen_ai.provider.name", _providerId); + activity?.SetTag("gen_ai.request.model", _modelId); + activity?.SetTag("marketassistant.session.id", _sessionId); + activity?.SetTag("marketassistant.asset.symbol", _currentStockCode); - // 加锁保护 _conversationHistory 的 Add,并取快照传给流式调用 - // 避免流式枚举期间持锁,同时防止并发修改 - List historySnapshot; - lock (_conversationLock) + try { - _conversationHistory.Add(new ChatMessage(ChatRole.User, userMessage)); - historySnapshot = _conversationHistory.ToList(); - } + EnsureSearchToolsInitialized(); - var completeResponse = new StringBuilder(); + cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _currentCancellationTokenSource = cts; + _currentSession ??= await _agent.CreateSessionAsync(cancellationToken: cts.Token); - var runOptions = new ChatClientAgentRunOptions - { - ChatOptions = new ChatOptions + var currentUserMessage = new ChatMessage(ChatRole.User, userMessage); + IReadOnlyList runMessages; + lock (_conversationLock) { - Tools = _searchToolCache.Count > 0 ? _searchToolCache : null, - Instructions = BuildAgentInstructions() + runMessages = _restoreHistoryOnNextRun + ? [.. _conversationHistory, currentUserMessage] + : [currentUserMessage]; + _conversationHistory.Add(currentUserMessage); + userMessageAdded = true; } - }; + _restoreHistoryOnNextRun = false; + + var runOptions = new ChatClientAgentRunOptions + { + ChatOptions = new ChatOptions + { + Tools = _searchToolCache.Count > 0 ? _searchToolCache : null, + Instructions = BuildAgentInstructions() + } + }; - var streamingUpdates = _agent.RunStreamingAsync( - messages: historySnapshot, - session: _currentSession, - options: runOptions, - cancellationToken: cts.Token); + var streamingUpdates = _agent.RunStreamingAsync( + messages: runMessages, + session: _currentSession, + options: runOptions, + cancellationToken: cts.Token); - var completed = false; - try - { await foreach (var update in streamingUpdates.ConfigureAwait(false)) { var content = update.Text ?? string.Empty; if (!string.IsNullOrEmpty(content)) - { completeResponse.Append(content); - } + yield return content; } completed = true; } finally { - lock (_conversationLock) + if (userMessageAdded) { - if (completed) + lock (_conversationLock) { - _conversationHistory.Add(new ChatMessage(ChatRole.Assistant, completeResponse.ToString())); - } - else - { - var partial = completeResponse.ToString(); - _conversationHistory.Add(new ChatMessage(ChatRole.Assistant, - partial.Length > 0 ? partial + "\n\n[回复被中断]" : "[回复被中断]")); + var responseText = completeResponse.ToString(); + _conversationHistory.Add(new ChatMessage( + ChatRole.Assistant, + completed + ? responseText + : responseText.Length > 0 + ? responseText + "\n\n[回复被中断]" + : "[回复被中断]")); } } - _currentCancellationTokenSource = null; + activity?.SetTag("gen_ai.response.output_length", completeResponse.Length); + if (!completed) + { + // MAF 可能已将部分流式回复写入 Session。丢弃该 Session,下一轮从带中断标记的 + // UI 历史镜像单次回放,避免内部历史与用户可见历史分叉。 + _currentSession = null; + _restoreHistoryOnNextRun = userMessageAdded; + activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, "interrupted"); + } + else + { + activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok); + } + + if (ReferenceEquals(_currentCancellationTokenSource, cts)) + _currentCancellationTokenSource = null; + + cts?.Dispose(); + _sendLock.Release(); } _logger.LogInformation("流式 AI 回复完成,长度: {Length}", completeResponse.Length); - - await AutoSaveSessionAsync(cancellationToken); + await AutoSaveSessionAsync(CancellationToken.None); } /// @@ -345,6 +376,7 @@ public async Task RestoreSessionAsync(string sessionId, CancellationToken _sessionId = snapshot.Id; _currentStockCode = snapshot.StockCode; _analysisContext = snapshot.AnalysisContext ?? string.Empty; + _cachedInstructions = null; int messageCount; lock (_conversationLock) { @@ -358,9 +390,33 @@ public async Task RestoreSessionAsync(string sessionId, CancellationToken } messageCount = _conversationHistory.Count; } - _currentSession = null; // 强制创建新 AgentSession - _logger.LogInformation("恢复会话 {SessionId},消息数: {Count}", sessionId, messageCount); + _currentSession = null; + _restoreHistoryOnNextRun = messageCount > 0; + if (CanRestoreAgentSession(snapshot)) + { + try + { + _currentSession = await _agent.DeserializeSessionAsync( + snapshot.AgentSessionState!.Value, + SessionSerializerOptions, + cancellationToken); + _restoreHistoryOnNextRun = false; + } + catch (Exception ex) when (ex is JsonException or ArgumentException or InvalidOperationException) + { + _logger.LogWarning( + ex, + "恢复 MAF Session 失败,将在下一轮回放 UI 历史。SessionId: {SessionId}", + sessionId); + } + } + + _logger.LogInformation( + "恢复会话 {SessionId},消息数: {Count},MAF Session: {SessionRestored}", + sessionId, + messageCount, + _currentSession is not null); return true; } @@ -376,6 +432,15 @@ private async Task AutoSaveSessionAsync(CancellationToken cancellationToken) historyCopy = _conversationHistory.ToList(); } + JsonElement? agentSessionState = null; + if (_currentSession is not null) + { + agentSessionState = await _agent.SerializeSessionAsync( + _currentSession, + SessionSerializerOptions, + cancellationToken); + } + var snapshot = new ChatSessionSnapshot { Id = _sessionId, @@ -387,7 +452,13 @@ private async Task AutoSaveSessionAsync(CancellationToken cancellationToken) Role = m.Role.Value, Content = m.Text ?? string.Empty, AuthorName = m.AuthorName - }).ToList() + }).ToList(), + AgentSessionState = agentSessionState, + SessionSchemaVersion = SessionSchemaVersion, + ProviderId = _providerId, + ModelId = _modelId, + Endpoint = _endpoint, + RuntimeConfigurationFingerprint = _runtimeConfigurationFingerprint }; await _sessionPersistence.SaveSessionAsync(snapshot, cancellationToken); } @@ -421,6 +492,17 @@ private async Task AutoSaveSessionAsync(CancellationToken cancellationToken) } } + private bool CanRestoreAgentSession(ChatSessionSnapshot snapshot) + { + return snapshot.AgentSessionState is not null && + snapshot.SessionSchemaVersion == SessionSchemaVersion && + !string.IsNullOrEmpty(_runtimeConfigurationFingerprint) && + string.Equals( + snapshot.RuntimeConfigurationFingerprint, + _runtimeConfigurationFingerprint, + StringComparison.Ordinal); + } + private string BuildSessionTitle(List history) { var firstUserMsg = history.FirstOrDefault(m => m.Role == ChatRole.User); @@ -431,6 +513,7 @@ private string BuildSessionTitle(List history) public void ClearHistory() { _currentSession = null; + _restoreHistoryOnNextRun = false; lock (_conversationLock) { _conversationHistory.Clear(); @@ -538,8 +621,9 @@ protected virtual void Dispose(bool disposing) { if (disposing) { + // 正在发送时,CTS 和发送门闩由 SendMessageStreamAsync 的 finally 统一收尾, + // Dispose 只负责发出取消信号,避免并发 Dispose/Release 竞态。 _currentCancellationTokenSource?.Cancel(); - _currentCancellationTokenSource?.Dispose(); } _disposed = true; } diff --git a/src/MarketAssistant.App.Services/Applications/Analysis/AnalysisOrchestrationService.cs b/src/MarketAssistant.App.Services/Applications/Analysis/AnalysisOrchestrationService.cs index c01d601..e1016d9 100644 --- a/src/MarketAssistant.App.Services/Applications/Analysis/AnalysisOrchestrationService.cs +++ b/src/MarketAssistant.App.Services/Applications/Analysis/AnalysisOrchestrationService.cs @@ -48,9 +48,24 @@ public event EventHandler? ProgressChanged /// 执行分析:优先读缓存,缓存未命中则执行工作流。 /// 使用按标的加锁防止缓存击穿,同一标的并发请求只执行一次工作流。 /// + public Task AnalyzeAsync( + string assetCode, + CancellationToken cancellationToken = default) + { + return AnalyzeAsync(assetCode, Guid.NewGuid(), cancellationToken); + } + + /// + /// 使用调用方分配的 Run ID 执行分析,供 UI 精确筛选并发进度事件。 + /// public async Task AnalyzeAsync( - string assetCode, CancellationToken cancellationToken = default) + string assetCode, + Guid runId, + CancellationToken cancellationToken = default) { + if (runId == Guid.Empty) + throw new ArgumentException("Run ID 不能为空", nameof(runId)); + var cached = await _cacheService.GetCachedAnalysisAsync(assetCode); if (cached != null) { @@ -72,7 +87,7 @@ public async Task AnalyzeAsync( } _logger.LogInformation("开始新的分析: {AssetCode}", assetCode); - var report = await _workflow.AnalyzeAsync(assetCode, cancellationToken); + var report = await _workflow.AnalyzeAsync(assetCode, runId, cancellationToken); // 先归档再缓存:归档失败时不缓存,避免"缓存命中但历史缺失"的幽灵报告 // 归档失败会抛异常,此时不缓存,让用户下次重试 diff --git a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs index 4c058fd..bade8db 100644 --- a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs +++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs @@ -279,7 +279,7 @@ private InvestmentRecommendationRequest ValidateAndNormalizeUserRequest(Investme InvestmentHorizon = request.InvestmentHorizon, PreferredSectors = request.PreferredSectors, ExcludedSectors = request.ExcludedSectors, - MaxRecommendations = request.MaxRecommendations + MaxRecommendations = Math.Clamp(request.MaxRecommendations, 1, 10) }; if (string.IsNullOrWhiteSpace(normalized.RiskPreference)) diff --git a/src/MarketAssistant.App.Services/Applications/Settings/AppInfo.cs b/src/MarketAssistant.App.Services/Applications/Settings/AppInfo.cs index 5d6a4fb..2fe47d6 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/AppInfo.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/AppInfo.cs @@ -92,6 +92,16 @@ public static string Version /// public const string UserSettingsFileName = "usersettings.json"; + /// + /// 用户设置中的敏感信息安全存储名称。 + /// + public const string UserSecretsStoreName = "usersecrets.bin"; + + /// + /// MCP 配置安全存储名称。 + /// + public const string McpSecretsStoreName = "mcpservers.bin"; + /// /// MCP服务器配置文件名称 /// diff --git a/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs index 98d0502..d24a201 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfig.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace MarketAssistant.Applications.Settings; /// @@ -38,6 +40,7 @@ public class MCPServerConfig /// /// 环境变量,用于stdio类型 /// + [JsonIgnore] public Dictionary EnvironmentVariables { get; set; } = new(); /// diff --git a/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfigService.cs b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfigService.cs index 66cd94e..7eecbf3 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfigService.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/MCPServerConfigService.cs @@ -1,3 +1,6 @@ +using System.Collections.Concurrent; +using MarketAssistant.Services.Settings; + namespace MarketAssistant.Applications.Settings; /// @@ -6,8 +9,13 @@ namespace MarketAssistant.Applications.Settings; /// public class MCPServerConfigService { + private static readonly ConcurrentDictionary FileLocks = new( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private List _serverConfigs = new(); - private readonly object _lock = new(); + private readonly object _lock; + private readonly ISecureSettingsStore _secretStore; + private readonly string _configFilePath; /// /// 当前所有MCP服务器配置(返回副本,避免外部修改影响内部状态) @@ -23,15 +31,24 @@ public List ServerConfigs } } - // 配置文件路径 - private readonly string _configFilePath = Path.Combine(FileSystem.AppDataDirectory, AppInfo.MCPServerConfigFileName); - /// /// 构造函数 /// public MCPServerConfigService() + : this( + Path.Combine(FileSystem.AppDataDirectory, AppInfo.MCPServerConfigFileName), + new SecureSettingsStore(AppInfo.McpSecretsStoreName, FileSystem.AppDataDirectory)) { - // 从存储中加载设置 + } + + internal MCPServerConfigService(string configFilePath, ISecureSettingsStore secretStore) + { + ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath); + ArgumentNullException.ThrowIfNull(secretStore); + + _configFilePath = Path.GetFullPath(configFilePath); + _lock = FileLocks.GetOrAdd(_configFilePath, static _ => new object()); + _secretStore = secretStore; LoadConfigs(); } @@ -42,10 +59,33 @@ public void LoadConfigs() { lock (_lock) { + string? legacyJson = null; if (File.Exists(_configFilePath)) { - string json = File.ReadAllText(_configFilePath); - _serverConfigs = JsonSerializer.Deserialize>(json) ?? new List(); + legacyJson = File.ReadAllText(_configFilePath); + _serverConfigs = JsonSerializer.Deserialize>(legacyJson) ?? []; + } + + var legacyConfigs = string.IsNullOrWhiteSpace(legacyJson) + ? [] + : JsonSerializer.Deserialize>(legacyJson) ?? []; + var migratedSecrets = legacyConfigs + .Where(config => config.EnvironmentVariables is { Count: > 0 }) + .ToDictionary( + config => config.Id, + config => new Dictionary(config.EnvironmentVariables!), + StringComparer.Ordinal); + + if (_secretStore.Read>>() is { } storedEnvironmentVariables) + { + ApplyEnvironmentVariables(storedEnvironmentVariables); + if (migratedSecrets.Count > 0) + SaveConfigs(); + } + else if (migratedSecrets.Count > 0) + { + ApplyEnvironmentVariables(migratedSecrets); + SaveConfigs(); } } } @@ -55,24 +95,28 @@ public void LoadConfigs() /// public void SaveConfigs() { - List snapshot; lock (_lock) { - snapshot = _serverConfigs.ToList(); - } + var snapshot = _serverConfigs.ToList(); - // 确保目录存在 - var directory = Path.GetDirectoryName(_configFilePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } + var directory = Path.GetDirectoryName(_configFilePath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } - // 序列化配置对象 - var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }); + var environmentVariables = snapshot.ToDictionary( + config => config.Id, + config => new Dictionary(config.EnvironmentVariables), + StringComparer.Ordinal); + _secretStore.Write(environmentVariables); - // 保存到文件 - File.WriteAllText(_configFilePath, json); + // 序列化不含环境变量 Secret 的配置对象并原子替换目标文件。 + var json = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }); + var tempFilePath = _configFilePath + ".tmp"; + File.WriteAllText(tempFilePath, json); + File.Move(tempFilePath, _configFilePath, overwrite: true); + } } /// @@ -117,6 +161,22 @@ public void DeleteConfig(string id) SaveConfigs(); } + private void ApplyEnvironmentVariables( + IReadOnlyDictionary> environmentVariables) + { + foreach (var config in _serverConfigs) + { + if (environmentVariables.TryGetValue(config.Id, out var values)) + config.EnvironmentVariables = new Dictionary(values); + } + } + + private sealed class LegacyMcpServerSecrets + { + public string Id { get; set; } = string.Empty; + public Dictionary? EnvironmentVariables { get; set; } + } + /// /// 获取指定ID的MCP服务器配置 /// diff --git a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs index 6da4bea..4f02c89 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs @@ -9,17 +9,48 @@ namespace MarketAssistant.Applications.Settings; /// public class UserSetting : INotifyPropertyChanged { - public string ModelId { get; set; } = ""; + /// + /// 模型服务商 ID(对应 ModelProviderCatalog 中的 Id) + /// + public string ProviderId { get; set; } = ""; + + /// + /// 当前服务商的活动模型 ID。保留该字段用于兼容旧配置。 + /// + private string _modelId = ""; + public string ModelId + { + get => _modelId; + set => SetProperty(ref _modelId, value); + } + + /// + /// 按服务商保存模型 ID,切换服务商时恢复各自选择。 + /// + public Dictionary ProviderModelIds { get; set; } = new(); public string EmbeddingModelId { get; set; } = "jina-embeddings-v5-text-small"; public string EmbeddingEndpoint { get; set; } = "https://api.jina.ai"; + [JsonIgnore] public string EmbeddingApiKey { get; set; } = ""; - public string Endpoint { get; set; } = "https://api.siliconflow.cn"; + /// + /// API 端点。为空时使用服务商默认端点。 + /// + public string Endpoint { get; set; } = ""; + + /// + /// 按服务商保存自定义 Endpoint,空值表示使用目录默认地址。 + /// + public Dictionary ProviderEndpoints { get; set; } = new(); - public string ApiKey { get; set; } = ""; + /// + /// 按服务商 ID 存储各自的 API Key + /// + [JsonIgnore] + public Dictionary ProviderApiKeys { get; set; } = new(); private bool _loadKnowledge; public bool LoadKnowledge @@ -40,6 +71,7 @@ public string KnowledgeFileDirectory public bool Notification { get; set; } + [JsonIgnore] public string ZhiTuApiToken { get; set; } = ""; /// @@ -66,16 +98,19 @@ public MarketType CurrentMarketType /// CoinGecko API 密钥(Demo 版免费,需在 https://www.coingecko.com/api/dashboard 注册获取) /// /coins/markets 等端点现要求携带 Demo Key,留空可能导致虚拟币筛选失败 /// + [JsonIgnore] public string CoinGeckoApiKey { get; set; } = ""; /// /// Binance API Key(交易功能必须) /// + [JsonIgnore] public string BinanceApiKey { get; set; } = ""; /// /// Binance Secret Key(交易功能必须) /// + [JsonIgnore] public string BinanceSecretKey { get; set; } = ""; /// @@ -101,6 +136,7 @@ public bool EnableWebSearch /// /// Web Search API Key /// + [JsonIgnore] public string WebSearchApiKey { get; set; } = ""; /// diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs index 8bc3a45..004bd49 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs @@ -3,6 +3,7 @@ using MarketAssistant.Agents.Middleware; using MarketAssistant.Agents.Tools.Abstractions; using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Infrastructure.Providers; using MarketAssistant.Services.Market; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -26,6 +27,14 @@ public interface IAnalystAgentFactory /// 根据类型创建代理,附加额外的 AIContextProvider(如共享市场快照) /// AIAgent CreateAnalyst(Type agentType, AIContextProvider[]? additionalProviders); + + /// + /// 使用调用方提供的不可变 Runtime Client 创建代理,确保同一次工作流模型配置一致。 + /// + AIAgent CreateAnalyst( + Type agentType, + IChatClient chatClient, + AIContextProvider[]? additionalProviders = null); } /// @@ -63,18 +72,25 @@ public AnalystAgentFactory( /// 根据类型创建对应的代理,支持附加额外的 AIContextProvider /// public AIAgent CreateAnalyst(Type agentType, AIContextProvider[]? additionalProviders) + { + return CreateAnalyst(agentType, _chatClientFactory.CreateClient(), additionalProviders); + } + + public AIAgent CreateAnalyst( + Type agentType, + IChatClient chatClient, + AIContextProvider[]? additionalProviders = null) { try { + ArgumentNullException.ThrowIfNull(chatClient); + // 严格限制必须是 AnalystAgentBase 的子类 if (!typeof(AnalystAgentBase).IsAssignableFrom(agentType)) { throw new ArgumentException($"Type {agentType.Name} must inherit from AnalystAgentBase", nameof(agentType)); } - // 创建 ChatClient - var chatClient = _chatClientFactory.CreateClient(); - // 根据当前市场类型获取对应的工具实现 var currentMarket = _marketContext.CurrentMarket; diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs index f8d8abf..b15bf2c 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs @@ -1,9 +1,10 @@ +using System.Security.Cryptography; +using System.Text; +using MarketAssistant.Infrastructure.Providers; using MarketAssistant.Services.Settings; using Microsoft.Extensions.AI; -using OpenAI; using Polly; using Polly.Retry; -using System.ClientModel; namespace MarketAssistant.Infrastructure.Factories; @@ -11,29 +12,39 @@ namespace MarketAssistant.Infrastructure.Factories; /// ChatClient 工厂接口 /// 负责创建和管理底层的 IChatClient 实例 /// -public interface IChatClientFactory +public interface IChatClientFactory : IDisposable { /// - /// 创建配置好的 ChatClient 实例 + /// 创建配置好的 ChatClient 实例。 /// IChatClient CreateClient(); + + /// + /// 创建绑定不可变模型配置快照的 ChatClient Runtime。 + /// + ChatClientRuntime CreateRuntime(); } +/// +/// ChatClient 与其不可变模型配置快照。 +/// +public sealed record ChatClientRuntime( + IChatClient Client, + string ProviderId, + string ModelId, + string Endpoint, + string ConfigurationFingerprint, + int? ContextWindowTokens); + /// /// ChatClient 工厂实现 -/// 创建和缓存底层的 OpenAI ChatClient,并附加 LLM 瞬态错误重试管道 +/// 根据用户配置的服务商创建对应的 IChatClient,并附加 LLM 瞬态错误重试管道 /// public class ChatClientFactory : IChatClientFactory { - /// - /// 瞬态错误冷却时间:冷却期内同一配置不重试,冷却后允许再次尝试 - /// + private const int MaxCachedRuntimes = 16; private static readonly TimeSpan ErrorCooldown = TimeSpan.FromSeconds(30); - /// - /// LLM 调用重试管道:针对瞬态网络/服务端错误自动重试 2 次,指数退避 + 抖动 - /// 覆盖 Coordinator 和所有业务分析师的 LLM 调用 - /// private static readonly ResiliencePipeline LlmRetryPipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { @@ -47,138 +58,162 @@ public class ChatClientFactory : IChatClientFactory }) .Build(); - /// - /// 判断 TaskCanceledException 是否由网络超时引起(而非用户主动取消)。 - /// System.ClientModel 超时时抛出的异常链为: - /// TaskCanceledException → TaskCanceledException → IOException → SocketException - /// private static bool IsNetworkTimeout(TaskCanceledException ex) { if (ex.InnerException is TimeoutException) return true; if (ex.CancellationToken.IsCancellationRequested) return false; - // System.ClientModel 的超时消息包含 "exceeded the configured timeout" return ex.Message.Contains("timeout", StringComparison.OrdinalIgnoreCase); } private readonly IUserSettingService _userSettingService; + private readonly IModelProviderAdapterFactory _adapterFactory; private readonly object _lock = new(); - private IChatClient? _cachedClient; - private string? _lastError; - private DateTime _lastErrorTime; - - // 缓存用于创建客户端的配置,以便检测变更 - private string? _cachedModelId; - private string? _cachedEndpoint; - private string? _cachedApiKey; + private readonly Dictionary _clients = []; + private readonly Dictionary _errors = []; + private bool _disposed; - public ChatClientFactory(IUserSettingService userSettingService) + public ChatClientFactory( + IUserSettingService userSettingService, + IModelProviderAdapterFactory adapterFactory) { _userSettingService = userSettingService; + _adapterFactory = adapterFactory; } - public IChatClient CreateClient() + public IChatClient CreateClient() => CreateRuntime().Client; + + public ChatClientRuntime CreateRuntime() { - IChatClient? oldClient = null; - try + lock (_lock) { - lock (_lock) + ObjectDisposedException.ThrowIf(_disposed, this); + + var userSetting = _userSettingService.CurrentSetting; + var providerId = userSetting.ProviderId; + var modelId = userSetting.ModelId; + var provider = ModelProviderCatalog.GetProvider(providerId) + ?? throw new FriendlyException($"未知的服务商: {providerId}"); + var apiKey = userSetting.ProviderApiKeys.TryGetValue(providerId, out var key) ? key : string.Empty; + var endpoint = ResolveEndpoint(provider, userSetting.Endpoint); + var runtimeKey = new ModelRuntimeKey(providerId, modelId, endpoint, ComputeSecretFingerprint(apiKey)); + var configurationFingerprint = ComputeConfigurationFingerprint(runtimeKey); + + if (_clients.TryGetValue(runtimeKey, out var cachedClient)) { - var userSetting = _userSettingService.CurrentSetting; - var modelId = userSetting.ModelId; - var apiKey = userSetting.ApiKey; - var endpoint = userSetting.Endpoint; + return new ChatClientRuntime( + cachedClient, + providerId, + modelId, + endpoint, + configurationFingerprint, + provider.GetContextWindowTokens(modelId)); + } - bool configUnchanged = _cachedModelId == modelId - && _cachedEndpoint == endpoint - && _cachedApiKey == apiKey; + if (_errors.TryGetValue(runtimeKey, out var cachedError) && + DateTime.UtcNow - cachedError.Timestamp < ErrorCooldown) + { + throw new FriendlyException(cachedError.Message); + } - // 配置未变且有成功缓存 → 直接返回 - if (configUnchanged && _cachedClient != null) - { - return _cachedClient; - } + try + { + if (string.IsNullOrWhiteSpace(modelId)) + throw new FriendlyException("AI 功能未配置:请先在设置页面选择 AI 模型"); - // 配置未变且上次失败仍在冷却期内 → 快速失败,避免频繁重试 - if (configUnchanged - && !string.IsNullOrEmpty(_lastError) - && DateTime.UtcNow - _lastErrorTime < ErrorCooldown) + if (!provider.IsModelSupported(modelId)) { - throw new FriendlyException(_lastError); + throw new FriendlyException( + $"模型 {modelId} 不兼容 {provider.DisplayName} 当前使用的协议适配器"); } - // 配置已变更或冷却期已过,重置错误状态 - _lastError = null; - // 保存旧客户端引用,稍后在 lock 外 Dispose(避免持锁等待网络连接关闭) - oldClient = _cachedClient; - _cachedClient = null; - - try + if (provider.RequiresApiKeyForModel(modelId) && string.IsNullOrWhiteSpace(apiKey)) { - if (string.IsNullOrWhiteSpace(modelId)) - throw new FriendlyException("AI 功能未配置:请先在设置页面选择 AI 模型"); - if (string.IsNullOrWhiteSpace(apiKey)) - throw new FriendlyException("AI 功能未配置:请先在设置页面配置 API Key"); - if (string.IsNullOrWhiteSpace(endpoint)) - throw new FriendlyException("AI 功能未配置:请先在设置页面配置 API 端点"); - - // 与 EmbeddingFactory 保持一致:OpenAI SDK 需要带 /v1 的 base URL - // 规范化处理:去掉末尾斜杠,若未包含 /v1 则追加,避免重复拼接 - var normalizedEndpoint = endpoint.TrimEnd('/'); - if (!normalizedEndpoint.EndsWith("/v1", StringComparison.OrdinalIgnoreCase)) - { - normalizedEndpoint += "/v1"; - } - - var openAIClient = new OpenAIClient( - new ApiKeyCredential(apiKey), - new OpenAIClientOptions - { - Endpoint = new Uri(normalizedEndpoint), - // 分析工作流中 Agent 使用流式调用,Tool-Call 链路在等待外部 API - // 返回期间不产生 token,默认 100s 超时过于激进。 - // 设为 3 分钟兼顾长链路 Tool-Call 和异常检测。 - NetworkTimeout = TimeSpan.FromMinutes(3) - } - ); - - // 使用 ResilientChatClient 装饰器附加重试管道,所有 LLM 调用自动获得瞬态错误重试 - var rawClient = openAIClient.GetChatClient(modelId).AsIChatClient(); - _cachedClient = new ResilientChatClient(rawClient, LlmRetryPipeline); - - _cachedModelId = modelId; - _cachedEndpoint = endpoint; - _cachedApiKey = apiKey; - - return _cachedClient; + throw new FriendlyException( + $"AI 功能未配置:服务商 {provider.DisplayName} 的模型 {modelId} 需要 API Key,请先在设置页面配置"); } - catch (Exception ex) + + if (_clients.Count >= MaxCachedRuntimes) { - _lastError = ex.Message; - _lastErrorTime = DateTime.UtcNow; - _cachedClient = null; - _cachedModelId = modelId; - _cachedEndpoint = endpoint; - _cachedApiKey = apiKey; - throw new FriendlyException(_lastError); + throw new FriendlyException( + $"本次应用运行已使用 {MaxCachedRuntimes} 组不同的模型配置。" + + "为避免释放仍被会话引用的客户端,请重启应用后再切换新配置"); } + + var adapter = _adapterFactory.Create(provider); + var rawClient = adapter.CreateChatClient(apiKey, modelId, endpoint); + var resilientClient = new ResilientChatClient(rawClient, LlmRetryPipeline); + _clients.Add(runtimeKey, resilientClient); + _errors.Remove(runtimeKey); + return new ChatClientRuntime( + resilientClient, + providerId, + modelId, + endpoint, + configurationFingerprint, + provider.GetContextWindowTokens(modelId)); } - } - finally - { - // 在 lock 外 Dispose 旧客户端,避免持锁等待网络连接关闭。 - // 用 try-catch 包裹防止 Dispose 抛出异常覆盖 try 块中的原始异常 - if (oldClient != null) + catch (FriendlyException) { - try - { - oldClient.Dispose(); - } - catch (Exception) - { - // Dispose 失败不应影响主流程,仅记录 - // 此处无法使用 ILogger(工厂不持有 logger),异常被静默吞并 - } + throw; + } + catch (Exception ex) + { + _errors[runtimeKey] = new CachedError(ex.Message, DateTime.UtcNow); + throw new FriendlyException($"创建 AI 客户端失败: {ex.Message}", ex); } } } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) + return; + + foreach (var client in _clients.Values) + client.Dispose(); + + _clients.Clear(); + _errors.Clear(); + _disposed = true; + } + + GC.SuppressFinalize(this); + } + + private static string ResolveEndpoint(ModelProvider provider, string configuredEndpoint) + { + if (string.IsNullOrWhiteSpace(provider.DefaultEndpoint)) + return configuredEndpoint.Trim().TrimEnd('/'); + + return provider.DefaultEndpoint.Trim().TrimEnd('/'); + } + + private static string ComputeSecretFingerprint(string secret) + { + if (string.IsNullOrEmpty(secret)) + return string.Empty; + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))); + } + + private static string ComputeConfigurationFingerprint(ModelRuntimeKey runtimeKey) + { + var canonicalValue = string.Join( + '\n', + runtimeKey.ProviderId, + runtimeKey.ModelId, + runtimeKey.Endpoint, + runtimeKey.ApiKeyFingerprint); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonicalValue))); + } + + private sealed record ModelRuntimeKey( + string ProviderId, + string ModelId, + string Endpoint, + string ApiKeyFingerprint); + + private sealed record CachedError(string Message, DateTime Timestamp); } diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs index b706d9a..8a23afe 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs @@ -1,16 +1,20 @@ +using MarketAssistant.Infrastructure.Providers; using MarketAssistant.Services.Settings; using Microsoft.Extensions.AI; -using OpenAI; -using System.ClientModel; namespace MarketAssistant.Infrastructure.Factories; public class EmbeddingFactory : IEmbeddingFactory { private readonly IUserSettingService _userSettingService; - public EmbeddingFactory(IUserSettingService userSettingService) + private readonly IModelProviderAdapterFactory _adapterFactory; + + public EmbeddingFactory( + IUserSettingService userSettingService, + IModelProviderAdapterFactory adapterFactory) { _userSettingService = userSettingService; + _adapterFactory = adapterFactory; } public IEmbeddingGenerator> Create() @@ -25,12 +29,16 @@ public IEmbeddingGenerator> Create() if (string.IsNullOrWhiteSpace(apiKey)) throw new FriendlyException("嵌入API密钥不能为空"); - var client = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions - { - Endpoint = new Uri(endpoint + "/v1") - }); + // Embedding 使用独立的端点和密钥配置,默认通过 OpenAI 兼容协议接入 + var provider = new ModelProvider( + Id: "Embedding", + DisplayName: "Embedding Service", + DefaultEndpoint: endpoint, + ApiKeyUrl: null, + RequiresApiKey: true + ); - return client.GetEmbeddingClient(modelId).AsIEmbeddingGenerator(); + var adapter = _adapterFactory.Create(provider); + return adapter.CreateEmbeddingGenerator(apiKey, modelId, endpoint)!; } } - diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs index a4a201f..957b93e 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs @@ -34,7 +34,7 @@ public class MarketChatSessionFactory : IMarketChatSessionFactory private readonly KnowledgeGraphTools _knowledgeGraphTools; private readonly AgentSkillsProvider? _skillsProvider; private readonly TokenTrackingMiddleware _tokenTracking; - private readonly ConversationCompressionMiddleware _compressionMiddleware; + private readonly ConversationCompactionProviderFactory _compactionProviderFactory; private readonly LayeredMemoryContextProvider _layeredMemoryProvider; private readonly ChatSessionPersistenceService _sessionPersistence; private readonly MemoryExtractionService _memoryExtraction; @@ -48,7 +48,7 @@ public MarketChatSessionFactory( SessionSearchTools sessionSearchTools, KnowledgeGraphTools knowledgeGraphTools, TokenTrackingMiddleware tokenTracking, - ConversationCompressionMiddleware compressionMiddleware, + ConversationCompactionProviderFactory compactionProviderFactory, LayeredMemoryContextProvider layeredMemoryProvider, ChatSessionPersistenceService sessionPersistence, MemoryExtractionService memoryExtraction, @@ -63,7 +63,7 @@ public MarketChatSessionFactory( _knowledgeGraphTools = knowledgeGraphTools; _skillsProvider = skillsProvider; _tokenTracking = tokenTracking; - _compressionMiddleware = compressionMiddleware; + _compactionProviderFactory = compactionProviderFactory; _layeredMemoryProvider = layeredMemoryProvider; _sessionPersistence = sessionPersistence; _memoryExtraction = memoryExtraction; @@ -71,11 +71,14 @@ public MarketChatSessionFactory( public MarketChatSession Create(string? initialStockCode = null) { - var chatClient = _chatClientFactory.CreateClient(); + var runtime = _chatClientFactory.CreateRuntime(); var logger = _loggerFactory.CreateLogger(); + var compactionProvider = _compactionProviderFactory.CreateForContextWindow( + runtime.Client, + runtime.ContextWindowTokens); return new MarketChatSession( - chatClient, + runtime.Client, logger, mcpToolProvider: _mcpToolProvider, searchTools: _searchTools, @@ -84,10 +87,14 @@ public MarketChatSession Create(string? initialStockCode = null) knowledgeGraphTools: _knowledgeGraphTools, skillsProvider: _skillsProvider, tokenTracking: _tokenTracking, - compressionMiddleware: _compressionMiddleware, + compactionProvider: compactionProvider, layeredMemoryProvider: _layeredMemoryProvider, sessionPersistence: _sessionPersistence, memoryExtraction: _memoryExtraction, - initialStockCode: initialStockCode); + initialStockCode: initialStockCode, + providerId: runtime.ProviderId, + modelId: runtime.ModelId, + endpoint: runtime.Endpoint, + runtimeConfigurationFingerprint: runtime.ConfigurationFingerprint); } } diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs index b17bbd0..d26b17e 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs @@ -17,7 +17,9 @@ namespace MarketAssistant.Infrastructure.Factories; /// public interface ITradingAgentFactory { - AIAgent CreateAgent(); + AIAgent CreateAgent( + TradingAuthorizationMode authorizationMode = TradingAuthorizationMode.Disabled, + Func>? confirmationCallback = null); } /// @@ -32,13 +34,6 @@ public class TradingAgentFactory : ITradingAgentFactory private readonly ILoggerFactory _loggerFactory; private readonly ILogger _logger; - /// - /// Human-in-the-Loop 确认回调。 - /// 参数: (functionName, argsDescription) → true=放行 false=拒绝。 - /// UI 层可在创建工厂后设置此属性以接入用户确认对话框。 - /// - public Func>? TradeConfirmationCallback { get; set; } - public TradingAgentFactory( IServiceProvider serviceProvider, IChatClientFactory chatClientFactory, @@ -56,7 +51,9 @@ public TradingAgentFactory( /// /// 创建包装中间件后的 ;工具一律从 解析。 /// - public AIAgent CreateAgent() + public AIAgent CreateAgent( + TradingAuthorizationMode authorizationMode = TradingAuthorizationMode.Disabled, + Func>? confirmationCallback = null) { try { @@ -66,14 +63,18 @@ public AIAgent CreateAgent() var agent = (AIAgent)ActivatorUtilities.CreateInstance( _serviceProvider, typeof(TradingAgent), chatClient, tools); - // 创建 Function Calling 守卫中间件(每次 CreateAgent 新建实例以重置调用计数) + // 每个 Agent 使用独立守卫,授权模式由调用方显式选择;默认禁止真实交易。 var guardMiddleware = new TradingFunctionGuardMiddleware( - _loggerFactory.CreateLogger()); - guardMiddleware.ConfirmationCallback = TradeConfirmationCallback; + _loggerFactory.CreateLogger(), + authorizationMode, + confirmationCallback); // 通过 MAF Builder 模式附加中间件链:Token 追踪 + Function Calling 守卫 var middlewareAgent = agent .AsBuilder() + .Use( + runFunc: guardMiddleware.InvokeRunAsync, + runStreamingFunc: guardMiddleware.InvokeRunStreamingAsync) .Use( runFunc: _tokenTracking.InvokeAsync, runStreamingFunc: _tokenTracking.InvokeStreamingAsync) diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/IModelProviderAdapter.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/IModelProviderAdapter.cs new file mode 100644 index 0000000..ddb43af --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/IModelProviderAdapter.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// 服务商适配器接口。每个适配器负责创建对应协议的 IChatClient 和 IEmbeddingGenerator。 +/// +public interface IModelProviderAdapter +{ + /// + /// 服务商定义 + /// + ModelProvider Provider { get; } + + /// + /// 创建 ChatClient 实例 + /// + /// API 密钥(Ollama 等本地服务商可忽略) + /// 模型 ID + /// 用户自定义的 endpoint 覆盖 + IChatClient CreateChatClient(string? apiKey, string modelId, string? endpointOverride = null); + + /// + /// 创建 Embedding 生成器(不支持 embedding 的服务商返回 null) + /// + IEmbeddingGenerator>? CreateEmbeddingGenerator(string apiKey, string modelId, string? endpointOverride = null); + + /// + /// 从服务商 API 获取可用模型列表 + /// + /// API 密钥 + /// 用户自定义的 endpoint 覆盖 + /// 取消模型发现请求 + /// 模型 ID 列表 + Task> ListModelsAsync( + string? apiKey, + string? endpointOverride = null, + CancellationToken cancellationToken = default); +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProvider.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProvider.cs new file mode 100644 index 0000000..2cdc96a --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProvider.cs @@ -0,0 +1,85 @@ +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// 模型服务商定义。每个服务商通过代码注册于 。 +/// +/// 唯一标识(与 UserSetting.ProviderId 对应) +/// UI 显示名称 +/// 最终 API Base URL,不再由适配器隐式追加版本后缀 +/// API Key 获取链接 +/// 是否需要 API Key(本地部署如 Ollama 设为 false) +/// 是否支持通过标准端点获取模型列表 +/// 适配器类型,决定创建 IChatClient 的方式 +/// 模型发现结果的可选白名单前缀,用于过滤同一网关中的非兼容协议模型 +/// 服务商所有模型均可保证的上下文窗口;无法保证时必须为 null +/// 模型级上下文窗口,优先于服务商默认值 +/// 已明确确认服务端匿名可用的模型 ID;模型“免费”但仍需鉴权时不要加入 +public record ModelProvider( + string Id, + string DisplayName, + string DefaultEndpoint, + string? ApiKeyUrl, + bool RequiresApiKey = true, + bool SupportsModelListing = true, + ProviderAdapterKind AdapterKind = ProviderAdapterKind.OpenAICompatible, + IReadOnlyList? SupportedModelIdPrefixes = null, + int? DefaultContextWindowTokens = null, + IReadOnlyDictionary? ModelContextWindowTokens = null, + IReadOnlyList? ApiKeyOptionalModelIds = null) +{ + /// + /// 判断模型是否兼容当前服务商适配器。 + /// + public bool IsModelSupported(string modelId) + { + if (SupportedModelIdPrefixes is not { Count: > 0 }) + return true; + + return SupportedModelIdPrefixes.Any(prefix => + modelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 判断指定模型是否需要 API Key。 + /// + /// + /// “免费模型”不等于“匿名接口”。只有服务商或模型目录明确确认无需鉴权时,才允许留空 API Key。 + /// + public bool RequiresApiKeyForModel(string? modelId) + { + if (!RequiresApiKey) + return false; + + if (string.IsNullOrWhiteSpace(modelId)) + return true; + + return ApiKeyOptionalModelIds?.Contains(modelId, StringComparer.OrdinalIgnoreCase) != true; + } + + /// + /// 获取模型上下文窗口。模型级显式配置优先,未知模型仅在服务商可保证统一下限时使用默认值。 + /// + public int? GetContextWindowTokens(string modelId) + { + if (ModelContextWindowTokens?.TryGetValue(modelId, out var modelContextWindow) == true) + return modelContextWindow > 0 ? modelContextWindow : null; + + return DefaultContextWindowTokens is > 0 ? DefaultContextWindowTokens : null; + } +} + +/// +/// 适配器种类,决定 的具体实现 +/// +public enum ProviderAdapterKind +{ + /// + /// OpenAI 兼容协议(覆盖绝大多数国内服务商 + OpenAI) + /// + OpenAICompatible, + + /// + /// Ollama 原生协议(使用 OllamaSharp SDK) + /// + Ollama, +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderAdapterFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderAdapterFactory.cs new file mode 100644 index 0000000..dcaab41 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderAdapterFactory.cs @@ -0,0 +1,33 @@ +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// 服务商适配器工厂。 +/// +public interface IModelProviderAdapterFactory +{ + IModelProviderAdapter Create(ModelProvider provider); +} + +/// +/// 通过共享 HttpClientFactory 创建协议适配器,统一模型发现请求的连接生命周期。 +/// +public sealed class ModelProviderAdapterFactory : IModelProviderAdapterFactory +{ + private readonly IHttpClientFactory _httpClientFactory; + + public ModelProviderAdapterFactory(IHttpClientFactory httpClientFactory) + { + _httpClientFactory = httpClientFactory; + } + + public IModelProviderAdapter Create(ModelProvider provider) + { + ArgumentNullException.ThrowIfNull(provider); + + return provider.AdapterKind switch + { + ProviderAdapterKind.Ollama => new OllamaAdapter(provider, _httpClientFactory), + _ => new OpenAICompatibleAdapter(provider, _httpClientFactory) + }; + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderCatalog.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderCatalog.cs new file mode 100644 index 0000000..c731e3d --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderCatalog.cs @@ -0,0 +1,165 @@ +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// 服务商注册表。所有地址均为可直接交给对应 SDK 的最终 API Base URL。 +/// +public static class ModelProviderCatalog +{ + /// + /// ��有预置服务商(顺序即 UI 显示顺序)。 + /// + public static readonly IReadOnlyList Providers = + [ + // 国内服务商 + new( + Id: "SiliconFlow", + DisplayName: "硅基流动", + DefaultEndpoint: "https://api.siliconflow.cn/v1", + ApiKeyUrl: "https://cloud.siliconflow.cn/i/z4lbHdBE"), + new( + Id: "DeepSeek", + DisplayName: "DeepSeek", + DefaultEndpoint: "https://api.deepseek.com", + ApiKeyUrl: "https://platform.deepseek.com/api_keys", + ModelContextWindowTokens: new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["deepseek-v4-flash"] = 1_000_000, + ["deepseek-v4-pro"] = 1_000_000 + }), + new( + Id: "Moonshot", + DisplayName: "月之暗面 (Kimi)", + DefaultEndpoint: "https://api.moonshot.cn/v1", + ApiKeyUrl: "https://platform.moonshot.cn/console/api-keys"), + new( + Id: "Zhipu", + DisplayName: "智谱 AI", + DefaultEndpoint: "https://open.bigmodel.cn/api/paas/v4", + ApiKeyUrl: "https://open.bigmodel.cn/usercenter/apikeys"), + new( + Id: "Qwen", + DisplayName: "通义千问", + DefaultEndpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1", + ApiKeyUrl: "https://dashscope.console.aliyun.com/apiKey"), + new( + Id: "Baichuan", + DisplayName: "百川", + DefaultEndpoint: "https://api.baichuan-ai.com/v1", + ApiKeyUrl: "https://platform.baichuan-ai.com/console/apikey"), + new( + Id: "MiniMax", + DisplayName: "MiniMax", + DefaultEndpoint: "https://api.minimax.chat/v1", + ApiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information/interface-key"), + new( + Id: "Doubao", + DisplayName: "字节豆包", + DefaultEndpoint: "https://ark.cn-beijing.volces.com/api/v3", + ApiKeyUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey"), + new( + Id: "ModelScope", + DisplayName: "魔搭社区", + DefaultEndpoint: "https://api-inference.modelscope.cn/v1", + ApiKeyUrl: "https://modelscope.cn/my/myaccesstoken"), + new( + Id: "PPIO", + DisplayName: "PPIO 派欧云", + DefaultEndpoint: "https://api.ppio.com/openai/v1", + ApiKeyUrl: "https://console.ppinfra.com/user/token"), + new( + Id: "HuaweiCloud", + DisplayName: "华为云", + DefaultEndpoint: "https://infer-models.cn-southwest-2.myhuaweicloud.com/v1", + ApiKeyUrl: "https://console.huaweicloud.com/maas/management/key"), + + // 国外服务商 + new( + Id: "OpenCodeZen", + DisplayName: "OpenCode Zen", + DefaultEndpoint: "https://opencode.ai/zen/v1", + ApiKeyUrl: "https://opencode.ai/auth", + SupportedModelIdPrefixes: + [ + "grok-", + "deepseek-", + "glm-", + "minimax-", + "kimi-", + "big-pickle", + "mimo-", + "ling-", + "nemotron-", + "north-", + "laguna-" + ]), + new( + Id: "OpenAI", + DisplayName: "OpenAI", + DefaultEndpoint: "https://api.openai.com/v1", + ApiKeyUrl: "https://platform.openai.com/api-keys"), + new( + Id: "OpenRouter", + DisplayName: "OpenRouter", + DefaultEndpoint: "https://openrouter.ai/api/v1", + ApiKeyUrl: "https://openrouter.ai/keys"), + new( + Id: "Groq", + DisplayName: "Groq", + DefaultEndpoint: "https://api.groq.com/openai/v1", + ApiKeyUrl: "https://console.groq.com/keys"), + new( + Id: "Grok", + DisplayName: "Grok (xAI)", + DefaultEndpoint: "https://api.x.ai/v1", + ApiKeyUrl: "https://console.x.ai"), + new( + Id: "Mistral", + DisplayName: "Mistral AI", + DefaultEndpoint: "https://api.mistral.ai/v1", + ApiKeyUrl: "https://console.mistral.ai/api-keys"), + new( + Id: "Together", + DisplayName: "Together AI", + DefaultEndpoint: "https://api.together.xyz/v1", + ApiKeyUrl: "https://api.together.ai/settings/api-keys"), + new( + Id: "Perplexity", + DisplayName: "Perplexity", + DefaultEndpoint: "https://api.perplexity.ai", + ApiKeyUrl: "https://www.perplexity.ai/settings/api"), + + // 本地部署 — DefaultEndpoint 留空,由适配器或用户输入提供 + new( + Id: "Ollama", + DisplayName: "Ollama (本地)", + DefaultEndpoint: string.Empty, + ApiKeyUrl: null, + RequiresApiKey: false, + AdapterKind: ProviderAdapterKind.Ollama), + new( + Id: "LMStudio", + DisplayName: "LM Studio (本地)", + DefaultEndpoint: string.Empty, + ApiKeyUrl: null, + RequiresApiKey: false), + + // 自定义 OpenAI 兼容服务 + new( + Id: "Custom", + DisplayName: "自定义", + DefaultEndpoint: string.Empty, + ApiKeyUrl: null, + SupportsModelListing: false), + ]; + + /// + /// 根据 ID 获取服务商定义。 + /// + public static ModelProvider? GetProvider(string? id) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + + return Providers.FirstOrDefault(p => p.Id == id); + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/OllamaAdapter.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/OllamaAdapter.cs new file mode 100644 index 0000000..b1ffd2a --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/OllamaAdapter.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.AI; +using OllamaSharp; +using System.Net.Http.Json; + +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// Ollama 本地部署适配器。使用 OllamaSharp SDK(原生 Ollama API)。 +/// 无需 API Key,通过 OllamaApiClient 创建 IChatClient。 +/// +public class OllamaAdapter : IModelProviderAdapter +{ + private readonly IHttpClientFactory _httpClientFactory; + + public ModelProvider Provider { get; } + + public OllamaAdapter( + ModelProvider provider, + IHttpClientFactory httpClientFactory) + { + Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + } + + public IChatClient CreateChatClient(string? apiKey, string modelId, string? endpointOverride = null) + { + var ollamaClient = CreateClient(modelId, endpointOverride); + + // Function Invocation 由上层 ChatClientAgent 统一拥有,避免双重工具调用循环。 + return ollamaClient; + } + + public IEmbeddingGenerator>? CreateEmbeddingGenerator( + string apiKey, string modelId, string? endpointOverride = null) + { + var ollamaClient = CreateClient(modelId, endpointOverride); + return ollamaClient; + } + + public async Task> ListModelsAsync( + string? apiKey, + string? endpointOverride = null, + CancellationToken cancellationToken = default) + { + if (!Provider.SupportsModelListing) + return []; + + var endpoint = endpointOverride ?? Provider.DefaultEndpoint; + if (string.IsNullOrWhiteSpace(endpoint)) + endpoint = "http://localhost:11434"; + + var baseUri = endpoint.TrimEnd('/'); + var http = _httpClientFactory.CreateClient("ModelDiscovery"); + var response = await http.GetFromJsonAsync( + $"{baseUri}/api/tags", + cancellationToken); + return response?.Models? + .Select(m => m.Name ?? m.Model ?? "") + .Where(name => !string.IsNullOrWhiteSpace(name)) + .OrderBy(name => name) + .ToList() ?? []; + } + + private OllamaApiClient CreateClient(string modelId, string? endpointOverride) + { + var endpoint = endpointOverride ?? Provider.DefaultEndpoint; + if (string.IsNullOrWhiteSpace(endpoint)) + endpoint = "http://localhost:11434"; + + var uri = new Uri(endpoint.TrimEnd('/')); + return new OllamaApiClient(uri, modelId); + } + + private sealed class OllamaTagsResponse + { + public List? Models { get; set; } + } + + private sealed class OllamaModelInfo + { + public string? Name { get; set; } + public string? Model { get; set; } + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/OpenAICompatibleAdapter.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/OpenAICompatibleAdapter.cs new file mode 100644 index 0000000..f607c24 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/OpenAICompatibleAdapter.cs @@ -0,0 +1,126 @@ +using Microsoft.Extensions.AI; +using OpenAI; +using System.ClientModel; +using System.Net.Http.Json; + +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// OpenAI 兼容协议适配器。覆盖所有支持 OpenAI API 兼容格式的服务商。 +/// 包括硅基流动、DeepSeek、月之暗面、智谱、通义千问、百川、MiniMax、豆包、OpenAI。 +/// Ollama 使用独立的 OllamaSharp SDK 适配器。 +/// +public class OpenAICompatibleAdapter : IModelProviderAdapter +{ + private readonly IHttpClientFactory _httpClientFactory; + + public ModelProvider Provider { get; } + + public OpenAICompatibleAdapter( + ModelProvider provider, + IHttpClientFactory httpClientFactory) + { + Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + } + + public IChatClient CreateChatClient(string? apiKey, string modelId, string? endpointOverride = null) + { + var (endpoint, key) = BuildConfig(apiKey, modelId, endpointOverride); + + var openAIClient = new OpenAIClient( + new ApiKeyCredential(key), + new OpenAIClientOptions + { + Endpoint = new Uri(endpoint), + NetworkTimeout = TimeSpan.FromMinutes(3) + } + ); + + return openAIClient.GetChatClient(modelId).AsIChatClient(); + } + + public IEmbeddingGenerator>? CreateEmbeddingGenerator( + string apiKey, string modelId, string? endpointOverride = null) + { + var (endpoint, key) = BuildConfig(apiKey, modelId, endpointOverride); + + var openAIClient = new OpenAIClient( + new ApiKeyCredential(key), + new OpenAIClientOptions + { + Endpoint = new Uri(endpoint) + } + ); + + return openAIClient.GetEmbeddingClient(modelId).AsIEmbeddingGenerator(); + } + + public async Task> ListModelsAsync( + string? apiKey, + string? endpointOverride = null, + CancellationToken cancellationToken = default) + { + if (!Provider.SupportsModelListing) + return []; + + var (endpoint, key) = BuildConfig(apiKey, null, endpointOverride); + var http = _httpClientFactory.CreateClient("ModelDiscovery"); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{endpoint}/models"); + request.Headers.Authorization = new("Bearer", key); + using var response = await http.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + response.EnsureSuccessStatusCode(); + + var modelsResponse = await response.Content.ReadFromJsonAsync( + cancellationToken: cancellationToken); + return modelsResponse?.Data? + .Select(m => m.Id) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Where(Provider.IsModelSupported) + .OrderBy(id => id) + .ToList() ?? []; + } + + private (string endpoint, string key) BuildConfig( + string? apiKey, + string? modelId, + string? endpointOverride) + { + var endpoint = NormalizeEndpoint(endpointOverride ?? Provider.DefaultEndpoint); + var requiresApiKey = string.IsNullOrWhiteSpace(modelId) + ? Provider.RequiresApiKey + : Provider.RequiresApiKeyForModel(modelId); + var key = string.IsNullOrWhiteSpace(apiKey) + ? (requiresApiKey ? "sk-placeholder" : "public") + : apiKey; + return (endpoint, key); + } + + private string NormalizeEndpoint(string endpoint) + { + var normalized = endpoint.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + throw new InvalidOperationException($"服务商 {Provider.DisplayName} 未配置 API Base URL"); + + if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https")) + { + throw new InvalidOperationException($"服务商 {Provider.DisplayName} 的 API Base URL 无效"); + } + + return normalized.TrimEnd('/'); + } + + private sealed class OpenAIModelsResponse + { + public List? Data { get; set; } + } + + private sealed class OpenAIModelItem + { + public string Id { get; set; } = ""; + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/StructuredOutputHelper.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/StructuredOutputHelper.cs new file mode 100644 index 0000000..6f7d3a4 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/StructuredOutputHelper.cs @@ -0,0 +1,51 @@ +using MarketAssistant.Agents.PromptConfiguration; +using Microsoft.Extensions.AI; +using System.Text.Json; + +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// 为 JsonObject 结构化输出生成 JSON Schema 提示词。 +/// API 层由调用方使用 保证合法 JSON, +/// 提示词约束对象结构,解析侧由 LlmJsonExtractor 负责容错提取。 +/// +public static class StructuredOutputHelper +{ + /// + /// 为指定结果类型生成 JSON Schema 提示词。 + /// + public static string BuildSchemaPromptSection(Type resultType, string schemaName) + { + var schema = AIJsonUtilities.CreateJsonSchema(resultType); + var schemaJson = JsonSerializer.Serialize(schema, new JsonSerializerOptions { WriteIndented = true }); + + return $""" + ## JSON 输出格式要求 + + 仅返回一个符合下列 JSON Schema 的合法 JSON 对象。 + 回复必须以 JSON 对象的左大括号开始、以右大括号结束,不得输出 JSON 对象之外的任何内容。 + + JSON Schema({schemaName}): + ```json + {schemaJson} + ``` + + 必须遵守: + - 仅输出一个 JSON 对象 + - 不要添加解释、标题、前言或结尾 + - 不要使用 Markdown 代码块包裹最终结果 + - 字段名称和字段类型必须符合上述 JSON Schema + - 所有必填字段必须存在并具有有效值 + - 枚举字段必须使用 JSON Schema 规定的值 + """; + } + + /// + /// 将 schema 描述注入到 AnalystPromptConfig 的 Instructions 中,返回新实例。 + /// + public static AnalystPromptConfig MergeSchemaPrompt(AnalystPromptConfig config, Type resultType) + { + var schemaPrompt = BuildSchemaPromptSection(resultType, resultType.Name); + return config.WithInstructions(config.Instructions + "\n\n" + schemaPrompt); + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/StructuredOutputValidator.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/StructuredOutputValidator.cs new file mode 100644 index 0000000..b4531e3 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/StructuredOutputValidator.cs @@ -0,0 +1,118 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// 递归验证模型结构化输出中的 DataAnnotations 和枚举值。 +/// +public static class StructuredOutputValidator +{ + private static readonly NullabilityInfoContext NullabilityContext = new(); + + public static IReadOnlyList Validate(object? value) + { + if (value is null) + { + return ["结构化输出不能为空"]; + } + + var errors = new List(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + ValidateNode(value, "$", errors, visited); + return errors; + } + + private static void ValidateNode( + object value, + string path, + List errors, + HashSet visited) + { + var type = value.GetType(); + if (IsTerminalType(type)) + { + if (type.IsEnum && !Enum.IsDefined(type, value)) + { + errors.Add($"{path}: 枚举值 {value} 无效"); + } + + return; + } + + if (!type.IsValueType && !visited.Add(value)) + { + return; + } + + if (value is IEnumerable enumerable) + { + var index = 0; + foreach (var item in enumerable) + { + if (item is null) + { + errors.Add($"{path}[{index}]: 值不能为空"); + } + else + { + ValidateNode(item, $"{path}[{index}]", errors, visited); + } + + index++; + } + + return; + } + + var validationResults = new List(); + Validator.TryValidateObject( + value, + new ValidationContext(value), + validationResults, + validateAllProperties: true); + + foreach (var result in validationResults) + { + var members = result.MemberNames.Any() + ? string.Join(", ", result.MemberNames.Select(member => $"{path}.{member}")) + : path; + errors.Add($"{members}: {result.ErrorMessage}"); + } + + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + if (!property.CanRead || property.GetIndexParameters().Length > 0) + { + continue; + } + + var propertyValue = property.GetValue(value); + if (propertyValue is null) + { + if (NullabilityContext.Create(property).ReadState == NullabilityState.NotNull) + { + errors.Add($"{path}.{property.Name}: 值不能为空"); + } + + continue; + } + + ValidateNode(propertyValue, $"{path}.{property.Name}", errors, visited); + } + } + + private static bool IsTerminalType(Type type) + { + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + return underlyingType.IsPrimitive + || underlyingType.IsEnum + || underlyingType == typeof(string) + || underlyingType == typeof(decimal) + || underlyingType == typeof(DateTime) + || underlyingType == typeof(DateTimeOffset) + || underlyingType == typeof(TimeSpan) + || underlyingType == typeof(Guid); + } +} diff --git a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj index 1ff8af2..38ab5a9 100644 --- a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj +++ b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj @@ -5,9 +5,12 @@ MarketAssistant enable $(NoWarn);MAAI001 - TestMarketAssistant + + + + @@ -23,6 +26,7 @@ + @@ -33,9 +37,11 @@ + + diff --git a/src/MarketAssistant.App.Services/Services/ChatSessionPersistenceService.cs b/src/MarketAssistant.App.Services/Services/ChatSessionPersistenceService.cs index fb7a5c1..c4c6506 100644 --- a/src/MarketAssistant.App.Services/Services/ChatSessionPersistenceService.cs +++ b/src/MarketAssistant.App.Services/Services/ChatSessionPersistenceService.cs @@ -24,14 +24,30 @@ public async Task SaveSessionAsync(ChatSessionSnapshot snapshot, CancellationTok await using var conn = await OpenConnectionAsync(ct); await using var cmd = conn.CreateCommand(); cmd.CommandText = """ - INSERT OR REPLACE INTO chat_sessions (id, stock_code, title, messages_json, analysis_context, created_at, updated_at) - VALUES (@id, @stockCode, @title, @messagesJson, @analysisContext, @createdAt, @updatedAt) + INSERT OR REPLACE INTO chat_sessions ( + id, stock_code, title, messages_json, analysis_context, + agent_session_json, session_schema_version, provider_id, model_id, endpoint, + runtime_configuration_fingerprint, created_at, updated_at) + VALUES ( + @id, @stockCode, @title, @messagesJson, @analysisContext, + @agentSessionJson, @sessionSchemaVersion, @providerId, @modelId, @endpoint, + @runtimeConfigurationFingerprint, @createdAt, @updatedAt) """; cmd.Parameters.AddWithValue("@id", snapshot.Id); cmd.Parameters.AddWithValue("@stockCode", snapshot.StockCode); cmd.Parameters.AddWithValue("@title", snapshot.Title); cmd.Parameters.AddWithValue("@messagesJson", JsonSerializer.Serialize(snapshot.Messages)); cmd.Parameters.AddWithValue("@analysisContext", (object?)snapshot.AnalysisContext ?? DBNull.Value); + cmd.Parameters.AddWithValue( + "@agentSessionJson", + snapshot.AgentSessionState is { } state ? state.GetRawText() : DBNull.Value); + cmd.Parameters.AddWithValue("@sessionSchemaVersion", snapshot.SessionSchemaVersion); + cmd.Parameters.AddWithValue("@providerId", snapshot.ProviderId); + cmd.Parameters.AddWithValue("@modelId", snapshot.ModelId); + cmd.Parameters.AddWithValue("@endpoint", snapshot.Endpoint); + cmd.Parameters.AddWithValue( + "@runtimeConfigurationFingerprint", + snapshot.RuntimeConfigurationFingerprint); cmd.Parameters.AddWithValue("@createdAt", snapshot.CreatedAt.ToString("O")); cmd.Parameters.AddWithValue("@updatedAt", DateTime.UtcNow.ToString("O")); await cmd.ExecuteNonQueryAsync(ct); @@ -113,6 +129,10 @@ private static ChatSessionSnapshot ReadSnapshot(SqliteDataReader reader) var messages = JsonSerializer.Deserialize>(messagesJson) ?? []; var contextOrd = reader.GetOrdinal("analysis_context"); + var agentSessionOrd = reader.GetOrdinal("agent_session_json"); + var agentSessionJson = reader.IsDBNull(agentSessionOrd) + ? null + : reader.GetString(agentSessionOrd); return new ChatSessionSnapshot { @@ -121,6 +141,15 @@ private static ChatSessionSnapshot ReadSnapshot(SqliteDataReader reader) Title = reader.GetString(reader.GetOrdinal("title")), Messages = messages, AnalysisContext = reader.IsDBNull(contextOrd) ? null : reader.GetString(contextOrd), + AgentSessionState = string.IsNullOrWhiteSpace(agentSessionJson) + ? null + : JsonDocument.Parse(agentSessionJson).RootElement.Clone(), + SessionSchemaVersion = reader.GetInt32(reader.GetOrdinal("session_schema_version")), + ProviderId = reader.GetString(reader.GetOrdinal("provider_id")), + ModelId = reader.GetString(reader.GetOrdinal("model_id")), + Endpoint = reader.GetString(reader.GetOrdinal("endpoint")), + RuntimeConfigurationFingerprint = reader.GetString( + reader.GetOrdinal("runtime_configuration_fingerprint")), CreatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("created_at"))), UpdatedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("updated_at"))) }; @@ -207,6 +236,38 @@ INSERT INTO chat_messages_fts (session_id, role, content, author_name, message_i } } + private static async Task EnsureSessionColumnsAsync(SqliteConnection conn) + { + var existingColumns = new HashSet(StringComparer.OrdinalIgnoreCase); + await using (var pragmaCommand = conn.CreateCommand()) + { + pragmaCommand.CommandText = "PRAGMA table_info(chat_sessions)"; + await using var reader = await pragmaCommand.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + existingColumns.Add(reader.GetString(1)); + } + + var requiredColumns = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["agent_session_json"] = "TEXT", + ["session_schema_version"] = "INTEGER NOT NULL DEFAULT 0", + ["provider_id"] = "TEXT NOT NULL DEFAULT ''", + ["model_id"] = "TEXT NOT NULL DEFAULT ''", + ["endpoint"] = "TEXT NOT NULL DEFAULT ''", + ["runtime_configuration_fingerprint"] = "TEXT NOT NULL DEFAULT ''" + }; + + foreach (var (columnName, definition) in requiredColumns) + { + if (existingColumns.Contains(columnName)) + continue; + + await using var alterCommand = conn.CreateCommand(); + alterCommand.CommandText = $"ALTER TABLE chat_sessions ADD COLUMN {columnName} {definition}"; + await alterCommand.ExecuteNonQueryAsync(); + } + } + protected override async Task InitializeDatabaseAsync() { try @@ -220,6 +281,12 @@ CREATE TABLE IF NOT EXISTS chat_sessions ( title TEXT NOT NULL, messages_json TEXT NOT NULL, analysis_context TEXT, + agent_session_json TEXT, + session_schema_version INTEGER NOT NULL DEFAULT 0, + provider_id TEXT NOT NULL DEFAULT '', + model_id TEXT NOT NULL DEFAULT '', + endpoint TEXT NOT NULL DEFAULT '', + runtime_configuration_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -236,7 +303,8 @@ CREATE VIRTUAL TABLE IF NOT EXISTS chat_messages_fts USING fts5( ); """; await cmd.ExecuteNonQueryAsync(); - Logger.LogInformation("聊天会话数据库初始化完成(含 FTS5 索引)"); + await EnsureSessionColumnsAsync(conn); + Logger.LogInformation("聊天会话数据库初始化完成(含 FTS5 索引与 MAF Session 状态)"); } catch (Exception ex) { @@ -256,6 +324,12 @@ public class ChatSessionSnapshot public string Title { get; set; } = string.Empty; public List Messages { get; set; } = []; public string? AnalysisContext { get; set; } + public JsonElement? AgentSessionState { get; set; } + public int SessionSchemaVersion { get; set; } + public string ProviderId { get; set; } = string.Empty; + public string ModelId { get; set; } = string.Empty; + public string Endpoint { get; set; } = string.Empty; + public string RuntimeConfigurationFingerprint { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; } diff --git a/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs b/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs index eec5aaa..059dfb6 100644 --- a/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs +++ b/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs @@ -1,4 +1,7 @@ +using System.Security.Cryptography; +using System.Text; using MarketAssistant.Applications.Settings; +using MarketAssistant.Infrastructure.Core; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using ModelContextProtocol.Client; @@ -6,100 +9,112 @@ namespace MarketAssistant.Services.Mcp; /// -/// MCP(Model Context Protocol)服务 -/// 统一处理 MCP 客户端的创建、连接和工具加载 +/// MCP(Model Context Protocol)服务。 +/// 按连接配置指纹复用客户端,并保留已向 Agent 暴露过工具的旧客户端,直至应用退出。 /// -public class McpService : IAsyncDisposable +public sealed class McpService : IAsyncDisposable { + private const int MaxRetainedRuntimes = 32; + private readonly ILogger _logger; private readonly McpToolAuditLogger _auditLogger; private readonly MCPServerConfigService _configService; - private readonly List _mcpClients = new(); - private readonly object _clientsLock = new(); + private readonly IMcpClientSessionFactory _clientFactory; + private readonly SemaphoreSlim _runtimeGate = new(1, 1); + private readonly Dictionary _activeRuntimes = new(StringComparer.Ordinal); + private readonly List _retainedRuntimes = []; + private bool _disposed; /// - /// 已连接的 MCP 服务器数量 + /// 当前活动配置对应的 MCP 连接数量。 + /// 配置刷新后,旧连接会保留到服务释放,但不计入活动连接。 /// public int ActiveConnectionCount { - get { lock (_clientsLock) return _mcpClients.Count; } + get + { + _runtimeGate.Wait(); + try + { + return _activeRuntimes.Count; + } + finally + { + _runtimeGate.Release(); + } + } } - /// - /// 创建 MCP 服务 - /// public McpService( ILogger logger, McpToolAuditLogger auditLogger, MCPServerConfigService configService) + : this(logger, auditLogger, configService, new McpClientSessionFactory()) + { + } + + internal McpService( + ILogger logger, + McpToolAuditLogger auditLogger, + MCPServerConfigService configService, + IMcpClientSessionFactory clientFactory) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger)); _configService = configService ?? throw new ArgumentNullException(nameof(configService)); + _clientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); } /// - /// 获取 MCP 工具作为 AITool 列表 + /// 获取 MCP 工具作为 AITool 列表。 + /// 相同连接配置复用已建立的客户端;工具白名单在每次读取时重新应用。 /// - /// MCP 服务器配置列表 - /// AITool 列表 public async Task> GetAIToolsAsync( - IEnumerable configs) + IEnumerable configs, + CancellationToken cancellationToken = default) { - var tools = new List(); + ArgumentNullException.ThrowIfNull(configs); + var tools = new List(); foreach (var config in configs) { + cancellationToken.ThrowIfCancellationRequested(); + using var activity = MarketAssistantDiagnostics.StartActivity("mcp.tools.load"); + activity?.SetTag("server.address", config.Name); + activity?.SetTag("network.transport", config.TransportType); + activity?.SetTag("marketassistant.mcp.category", config.Category); + try { - var clientTransport = CreateClientTransport(config); - var options = new McpClientOptions - { - ClientInfo = new() { Name = config.Name, Version = "1.0.0" } - }; - - var mcpClient = await McpClient.CreateAsync(clientTransport, options); - - lock (_clientsLock) - { - _mcpClients.Add(mcpClient); - } - - var mcpTools = await mcpClient.ListToolsAsync().ConfigureAwait(false); - - // 安全策略:AllowedTools 为空时允许所有工具加载,但发出安全警告, - // 提示用户显式配置白名单以实现最小暴露原则。 - if (config.AllowedTools.Count == 0) - { - _logger.LogWarning( - "MCP 服务器 {Name} 未配置 AllowedTools 白名单,将加载全部 {Count} 个工具。" + - "建议在配置中显式指定允许的工具以遵循最小暴露原则。", - config.Name, mcpTools.Count); - } - - foreach (var tool in mcpTools.Cast()) - { - var toolName = tool.Name; - - if (config.AllowedTools.Count > 0 && - !config.AllowedTools.Contains(toolName, StringComparer.OrdinalIgnoreCase)) - { - _auditLogger.LogToolFiltered(config.Name, toolName, - "不在允许列表中"); - continue; - } - - _auditLogger.LogToolLoaded(config.Name, toolName, config.Category); - tools.Add(tool); - } + var runtime = await GetOrCreateRuntimeAsync(config, cancellationToken) + .ConfigureAwait(false); + var loadedForServer = AddAllowedTools(config, runtime.Tools, tools); + activity?.SetTag("marketassistant.mcp.tools.available_count", runtime.Tools.Count); + activity?.SetTag("marketassistant.mcp.tools.loaded_count", loadedForServer); + activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok); _logger.LogInformation( - "成功连接到 MCP 服务器 {Name} (分类: {Category}),加载 {Count}/{Total} 个工具", - config.Name, config.Category, tools.Count, mcpTools.Count); + "成功连接到 MCP 服务器 {Name} (分类: {Category}),加载 {LoadedCount}/{TotalCount} 个工具", + config.Name, + config.Category, + loadedForServer, + runtime.Tools.Count); + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + activity?.SetStatus(System.Diagnostics.ActivityStatusCode.Error, "cancelled"); + activity?.SetTag("error.type", ex.GetType().FullName); + throw; + } + catch (ObjectDisposedException ex) + { + MarketAssistantDiagnostics.RecordException(activity, ex); + throw; } catch (Exception ex) { + MarketAssistantDiagnostics.RecordException(activity, ex); _logger.LogWarning(ex, "连接到 MCP 服务器 {Name} 失败", config.Name); } } @@ -108,23 +123,21 @@ public async Task> GetAIToolsAsync( } /// - /// 获取所有启用的 MCP 服务器配置 + /// 获取所有启用的 MCP 服务器配置。 /// - /// 启用的配置列表 public List GetEnabledConfigs() { - return _configService.ServerConfigs.Where(c => c.IsEnabled).ToList(); + return _configService.ServerConfigs.Where(config => config.IsEnabled).ToList(); } /// - /// 创建客户端传输 + /// 创建客户端传输。 /// - /// MCP 服务器配置 - /// 客户端传输实例 - /// 不支持的传输类型 public static IClientTransport CreateClientTransport(MCPServerConfig config) { - return config.TransportType.ToLower() switch + ArgumentNullException.ThrowIfNull(config); + + return config.TransportType.ToLowerInvariant() switch { "stdio" => CreateStdioTransport(config), "sse" => CreateSseTransport(config), @@ -134,15 +147,180 @@ public static IClientTransport CreateClientTransport(MCPServerConfig config) } /// - /// 创建 Stdio 传输 + /// 使当前活动连接映射失效。 + /// 已向 Agent 暴露的工具持有底层客户端引用,因此旧客户端不能在刷新时立即释放。 /// + public async Task ResetConnectionsAsync(CancellationToken cancellationToken = default) + { + await _runtimeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + var invalidatedCount = _activeRuntimes.Count; + _activeRuntimes.Clear(); + + _logger.LogInformation( + "已使 {Count} 个 MCP 活动连接失效;旧连接将保留到应用退出,以保证已注入工具仍可调用", + invalidatedCount); + } + finally + { + _runtimeGate.Release(); + } + } + + public async ValueTask DisposeAsync() + { + List runtimesToDispose; + + await _runtimeGate.WaitAsync().ConfigureAwait(false); + try + { + if (_disposed) + return; + + _disposed = true; + runtimesToDispose = [.. _retainedRuntimes]; + _activeRuntimes.Clear(); + _retainedRuntimes.Clear(); + } + finally + { + _runtimeGate.Release(); + } + + foreach (var runtime in runtimesToDispose) + { + try + { + await runtime.Session.DisposeAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "释放 MCP 客户端时发生错误: {Name}", runtime.ServerName); + } + } + + _runtimeGate.Dispose(); + GC.SuppressFinalize(this); + } + + internal static string ComputeConfigurationFingerprint(MCPServerConfig config) + { + ArgumentNullException.ThrowIfNull(config); + + var canonical = new StringBuilder() + .Append(config.Name.Trim()).Append('\n') + .Append(config.TransportType.Trim().ToLowerInvariant()).Append('\n') + .Append(config.Command.Trim()).Append('\n') + .Append(config.Arguments.Trim()).Append('\n'); + + foreach (var variable in config.EnvironmentVariables.OrderBy(pair => pair.Key, StringComparer.Ordinal)) + { + canonical + .Append(variable.Key) + .Append('=') + .Append(variable.Value) + .Append('\n'); + } + + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString()))); + } + + private async Task GetOrCreateRuntimeAsync( + MCPServerConfig config, + CancellationToken cancellationToken) + { + var fingerprint = ComputeConfigurationFingerprint(config); + + await _runtimeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + + if (_activeRuntimes.TryGetValue(fingerprint, out var existingRuntime)) + return existingRuntime; + + if (_retainedRuntimes.Count >= MaxRetainedRuntimes) + { + throw new InvalidOperationException( + $"MCP Runtime 已达到安全上限 {MaxRetainedRuntimes}。" + + "为避免释放仍被 Agent 工具引用的客户端,本次连接被拒绝;请重启应用后重试。"); + } + + var runtime = await CreateRuntimeAsync(config, fingerprint, cancellationToken) + .ConfigureAwait(false); + _activeRuntimes.Add(fingerprint, runtime); + _retainedRuntimes.Add(runtime); + return runtime; + } + finally + { + _runtimeGate.Release(); + } + } + + private async Task CreateRuntimeAsync( + MCPServerConfig config, + string fingerprint, + CancellationToken cancellationToken) + { + IMcpClientSession? session = null; + try + { + session = await _clientFactory.CreateAsync(config, cancellationToken).ConfigureAwait(false); + var runtimeTools = await session.ListToolsAsync(cancellationToken).ConfigureAwait(false); + return new McpRuntime(config.Name, fingerprint, session, runtimeTools); + } + catch + { + if (session is not null) + await session.DisposeAsync().ConfigureAwait(false); + + throw; + } + } + + private int AddAllowedTools( + MCPServerConfig config, + IReadOnlyList availableTools, + List destination) + { + if (config.AllowedTools.Count == 0) + { + _logger.LogWarning( + "MCP 服务器 {Name} 未配置 AllowedTools 白名单,将加载全部 {Count} 个工具。" + + "建议在配置中显式指定允许的工具以遵循最小暴露原则。", + config.Name, + availableTools.Count); + } + + var loadedCount = 0; + foreach (var tool in availableTools) + { + var toolName = tool.Name; + if (config.AllowedTools.Count > 0 && + !config.AllowedTools.Contains(toolName, StringComparer.OrdinalIgnoreCase)) + { + _auditLogger.LogToolFiltered(config.Name, toolName, "不在允许列表中"); + continue; + } + + _auditLogger.LogToolLoaded(config.Name, toolName, config.Category); + destination.Add(tool); + loadedCount++; + } + + return loadedCount; + } + private static IClientTransport CreateStdioTransport(MCPServerConfig config) { var arguments = string.IsNullOrEmpty(config.Arguments) ? Array.Empty() : config.Arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries); - return new StdioClientTransport(new() + return new StdioClientTransport(new StdioClientTransportOptions { Name = config.Name, Command = config.Command, @@ -151,12 +329,9 @@ private static IClientTransport CreateStdioTransport(MCPServerConfig config) }); } - /// - /// 创建 SSE 传输 - /// private static IClientTransport CreateSseTransport(MCPServerConfig config) { - return new HttpClientTransport(new() + return new HttpClientTransport(new HttpClientTransportOptions { Name = config.Name, TransportMode = HttpTransportMode.AutoDetect, @@ -164,12 +339,9 @@ private static IClientTransport CreateSseTransport(MCPServerConfig config) }); } - /// - /// 创建 Streamable HTTP 传输 - /// private static IClientTransport CreateStreamableHttpTransport(MCPServerConfig config) { - return new HttpClientTransport(new() + return new HttpClientTransport(new HttpClientTransportOptions { Name = config.Name, TransportMode = HttpTransportMode.StreamableHttp, @@ -177,63 +349,61 @@ private static IClientTransport CreateStreamableHttpTransport(MCPServerConfig co }); } - /// - /// 断开所有现有 MCP 连接,释放资源。 - /// 用于配置变更后重建连接。 - /// - public async Task ResetConnectionsAsync() + private void ThrowIfDisposed() { - List clientsToDispose; - lock (_clientsLock) - { - clientsToDispose = [.. _mcpClients]; - _mcpClients.Clear(); - } + ObjectDisposedException.ThrowIf(_disposed, this); + } - foreach (var client in clientsToDispose) - { - try - { - await client.DisposeAsync(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "重置 MCP 连接时释放客户端出错"); - } - } + private sealed record McpRuntime( + string ServerName, + string Fingerprint, + IMcpClientSession Session, + IReadOnlyList Tools); +} - _logger.LogInformation("已重置 {Count} 个 MCP 连接", clientsToDispose.Count); - } +internal interface IMcpClientSessionFactory +{ + Task CreateAsync( + MCPServerConfig config, + CancellationToken cancellationToken); +} - /// - /// 释放资源 - /// - public async ValueTask DisposeAsync() - { - if (_disposed) - return; +internal interface IMcpClientSession : IAsyncDisposable +{ + Task> ListToolsAsync(CancellationToken cancellationToken); +} - List clientsToDispose; - lock (_clientsLock) +internal sealed class McpClientSessionFactory : IMcpClientSessionFactory +{ + public async Task CreateAsync( + MCPServerConfig config, + CancellationToken cancellationToken) + { + var transport = McpService.CreateClientTransport(config); + var options = new McpClientOptions { - clientsToDispose = [.. _mcpClients]; - _mcpClients.Clear(); - } + ClientInfo = new() { Name = config.Name, Version = "1.0.0" } + }; + var client = await McpClient.CreateAsync( + transport, + options, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + return new McpClientSession(client); + } +} - foreach (var mcpClient in clientsToDispose) - { - try - { - await mcpClient.DisposeAsync(); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "释放 MCP 客户端时发生错误"); - } - } +internal sealed class McpClientSession(McpClient client) : IMcpClientSession +{ + public async Task> ListToolsAsync(CancellationToken cancellationToken) + { + var tools = await client.ListToolsAsync(cancellationToken: cancellationToken) + .ConfigureAwait(false); + return tools.Cast().ToList(); + } - _disposed = true; - GC.SuppressFinalize(this); + public ValueTask DisposeAsync() + { + return client.DisposeAsync(); } } - diff --git a/src/MarketAssistant.App.Services/Services/Mcp/McpToolContextProvider.cs b/src/MarketAssistant.App.Services/Services/Mcp/McpToolContextProvider.cs index 4787bd3..47eda1e 100644 --- a/src/MarketAssistant.App.Services/Services/Mcp/McpToolContextProvider.cs +++ b/src/MarketAssistant.App.Services/Services/Mcp/McpToolContextProvider.cs @@ -71,8 +71,8 @@ private async Task> GetOrLoadToolsAsync(CancellationToken cancellat if (_invalidated) { - // 释放旧连接后重新加载 - await _mcpService.ResetConnectionsAsync(); + // 仅使活动连接映射失效。旧 Agent 工具仍持有底层 Client,不能在刷新时立即释放。 + await _mcpService.ResetConnectionsAsync(cancellationToken); _invalidated = false; } diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs index 4327297..aa2944c 100644 --- a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs @@ -28,6 +28,7 @@ using MarketAssistant.Applications.Telegrams; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Infrastructure.Http; +using MarketAssistant.Infrastructure.Providers; using MarketAssistant.Rag.Extensions; using MarketAssistant.Services.Archive; using MarketAssistant.Services.Cache; @@ -208,6 +209,13 @@ private static IServiceCollection AddHttpClientsCore(IServiceCollection services client.DefaultRequestHeaders.UserAgent.ParseAdd(AppInfo.UserAgent); }); + services.AddHttpClient("ModelDiscovery", client => + { + client.Timeout = TimeSpan.FromSeconds(15); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd(AppInfo.UserAgent); + }).AddStandardResilienceHandler(); + return services; } @@ -254,18 +262,16 @@ private static IServiceCollection AddAgentTools(this IServiceCollection services private static IServiceCollection AddAgentInfrastructure(this IServiceCollection services) { + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - // MAF 中间件 + // MAF 中间件与会话级 Context Provider 工厂 services.AddSingleton(); - services.AddSingleton(sp => - new ConversationCompressionMiddleware( - () => sp.GetRequiredService().CreateClient(), - sp.GetRequiredService>())); + services.AddSingleton(); services.AddSingleton(sp => new AgentSkillsProvider( @@ -343,9 +349,7 @@ private static IServiceCollection AddWorkflowServices(this IServiceCollection se services.AddSingleton(); services.AddSingleton(); - // 市场分析工作流 - services.AddSingleton(); - services.AddSingleton(); + // 市场分析工作流;Executor 在每次 Run 内创建,避免共享可变状态和模型固化。 services.AddSingleton(); services.AddSingleton(); diff --git a/src/MarketAssistant.App.Services/Services/Settings/SecureSettingsStore.cs b/src/MarketAssistant.App.Services/Services/Settings/SecureSettingsStore.cs new file mode 100644 index 0000000..cbdb91e --- /dev/null +++ b/src/MarketAssistant.App.Services/Services/Settings/SecureSettingsStore.cs @@ -0,0 +1,106 @@ +using System.Diagnostics; +using Microsoft.Identity.Client.Extensions.Msal; + +namespace MarketAssistant.Services.Settings; + +internal interface ISecureSettingsStore +{ + T? Read(); + + void Write(T value); + + bool Exists(); +} + +internal sealed class SecureSettingsException : Exception +{ + public SecureSettingsException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// +/// 跨平台安全设置存储。Windows 使用 DPAPI,macOS 使用 Keychain,Linux 使用 Secret Service。 +/// 不启用任何明文回退;平台安全存储不可用时调用方会收到明确异常。 +/// +internal sealed class SecureSettingsStore : ISecureSettingsStore +{ + private const string MacKeyChainServiceName = "com.x2agent.marketassistant"; + private const string MacKeyChainAccountName = "MarketAssistant"; + private const string LinuxKeyringSchemaName = "com.x2agent.marketassistant"; + private const string LinuxKeyringCollection = "default"; + private const string LinuxKeyringSecretLabel = "MarketAssistant secure settings"; + + private readonly Storage _storage; + private readonly object _lock = new(); + + public SecureSettingsStore(string fileName, string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + + var builder = new StorageCreationPropertiesBuilder(fileName, directory) + .WithMacKeyChain(MacKeyChainServiceName, $"{MacKeyChainAccountName}:{fileName}") + .WithLinuxKeyring( + LinuxKeyringSchemaName, + LinuxKeyringCollection, + LinuxKeyringSecretLabel, + new KeyValuePair("Version", "1"), + new KeyValuePair("Store", fileName)); + + _storage = Storage.Create(builder.Build(), new TraceSource(nameof(SecureSettingsStore))); + } + + public T? Read() + { + lock (_lock) + { + try + { + var data = _storage.ReadData(); + if (data is not { Length: > 0 }) + return default; + + return JsonSerializer.Deserialize(data); + } + catch (Exception ex) when (ex is not SecureSettingsException) + { + throw new SecureSettingsException("读取操作系统安全存储失败", ex); + } + } + } + + public void Write(T value) + { + ArgumentNullException.ThrowIfNull(value); + + lock (_lock) + { + try + { + var data = JsonSerializer.SerializeToUtf8Bytes(value); + _storage.WriteData(data); + } + catch (Exception ex) when (ex is not SecureSettingsException) + { + throw new SecureSettingsException("写入操作系统安全存储失败", ex); + } + } + } + + public bool Exists() + { + lock (_lock) + { + try + { + return _storage.ReadData() is { Length: > 0 }; + } + catch (Exception ex) when (ex is not SecureSettingsException) + { + throw new SecureSettingsException("访问操作系统安全存储失败", ex); + } + } + } +} diff --git a/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs b/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs index 44f8098..a219ee8 100644 --- a/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs +++ b/src/MarketAssistant.App.Services/Services/Settings/UserSettingService.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using MarketAssistant.Applications.Settings; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -9,9 +10,13 @@ namespace MarketAssistant.Services.Settings; /// public class UserSettingService : IUserSettingService { + private static readonly ConcurrentDictionary FileLocks = new( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private readonly ILogger _logger; - private readonly object _fileLock = new(); - private readonly string _settingsFilePath = Path.Combine(FileSystem.AppDataDirectory, AppInfo.UserSettingsFileName); + private readonly object _fileLock; + private readonly string _settingsFilePath; + private readonly ISecureSettingsStore _secretStore; private UserSetting _currentSetting = new(); @@ -21,7 +26,24 @@ public class UserSettingService : IUserSettingService public UserSetting CurrentSetting => _currentSetting; public UserSettingService(ILogger? logger = null) + : this( + Path.Combine(FileSystem.AppDataDirectory, AppInfo.UserSettingsFileName), + new SecureSettingsStore(AppInfo.UserSecretsStoreName, FileSystem.AppDataDirectory), + logger) + { + } + + internal UserSettingService( + string settingsFilePath, + ISecureSettingsStore secretStore, + ILogger? logger = null) { + ArgumentException.ThrowIfNullOrWhiteSpace(settingsFilePath); + ArgumentNullException.ThrowIfNull(secretStore); + + _settingsFilePath = Path.GetFullPath(settingsFilePath); + _fileLock = FileLocks.GetOrAdd(_settingsFilePath, static _ => new object()); + _secretStore = secretStore; _logger = logger ?? NullLogger.Instance; LoadSettings(); } @@ -35,27 +57,38 @@ public void LoadSettings() { try { + string? legacyJson = null; if (File.Exists(_settingsFilePath)) { - string json = File.ReadAllText(_settingsFilePath); - _currentSetting = JsonSerializer.Deserialize(json) ?? new UserSetting(); + legacyJson = File.ReadAllText(_settingsFilePath); + _currentSetting = JsonSerializer.Deserialize(legacyJson) ?? new UserSetting(); } else { _currentSetting = new UserSetting(); } + MigrateProviderSettings(_currentSetting); + var migratedLegacySecrets = LoadSecrets(_currentSetting, legacyJson); + if (migratedLegacySecrets) + SaveSettings(); + // 如果日志路径为空,设置为默认日志目录(与启动阶段保持一致) if (string.IsNullOrWhiteSpace(_currentSetting.LogPath)) { _currentSetting.LogPath = Path.Combine(FileSystem.AppDataDirectory, AppInfo.LogsDirectoryName); } } + catch (SecureSettingsException ex) + { + _logger.LogCritical(ex, "操作系统安全存储不可用,拒绝以空 Secret 继续启动设置服务"); + throw; + } catch (Exception ex) { _logger.LogError(ex, "加载设置时出错"); - // 如果加载失败,使用默认值 + // 普通设置损坏时回退默认值;安全存储失败必须向上传播。 _currentSetting = new UserSetting(); } } @@ -77,13 +110,19 @@ public void SaveSettings() Directory.CreateDirectory(directory); } - // 序列化并保存 + // 先写安全存储,成功后再写不含 Secret 的普通设置,避免界面显示“已保存”但密钥丢失。 + _secretStore.Write(UserSecrets.From(_currentSetting)); + + // 先写临时文件,再原子替换,避免进程中断留下半个 JSON。 var json = JsonSerializer.Serialize(_currentSetting, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(_settingsFilePath, json); + var tempFilePath = _settingsFilePath + ".tmp"; + File.WriteAllText(tempFilePath, json); + File.Move(tempFilePath, _settingsFilePath, overwrite: true); } catch (Exception ex) { _logger.LogError(ex, "保存设置时出错"); + throw; } } } @@ -98,6 +137,117 @@ public void UpdateSettings(UserSetting setting) SaveSettings(); } + private static void MigrateProviderSettings(UserSetting setting) + { + setting.ProviderApiKeys ??= []; + setting.ProviderModelIds ??= []; + setting.ProviderEndpoints ??= []; + + if (string.IsNullOrWhiteSpace(setting.ProviderId)) + return; + + if (!string.IsNullOrWhiteSpace(setting.ModelId) && + !setting.ProviderModelIds.ContainsKey(setting.ProviderId)) + { + setting.ProviderModelIds[setting.ProviderId] = setting.ModelId; + } + + if (!string.IsNullOrWhiteSpace(setting.Endpoint) && + !setting.ProviderEndpoints.ContainsKey(setting.ProviderId)) + { + setting.ProviderEndpoints[setting.ProviderId] = setting.Endpoint; + } + } + + private bool LoadSecrets(UserSetting setting, string? legacyJson) + { + LegacyUserSecrets? legacySecrets = null; + if (!string.IsNullOrWhiteSpace(legacyJson)) + { + // JsonIgnore 会阻止旧 Secret 进入当前设置,因此仅在迁移阶段用独立 DTO 检测一次。 + legacySecrets = JsonSerializer.Deserialize(legacyJson); + } + + var containsLegacySecrets = legacySecrets?.HasAnyValue() == true; + if (_secretStore.Read() is { } storedSecrets) + { + storedSecrets.ApplyTo(setting); + if (containsLegacySecrets) + _logger.LogInformation("检测到普通设置文件仍含旧版明文 Secret,正在净化文件"); + return containsLegacySecrets; + } + + if (!containsLegacySecrets) + return false; + + legacySecrets!.ApplyTo(setting); + _logger.LogInformation("检测到旧版明文 Secret,正在迁移到操作系统安全存储"); + return true; + } + + private sealed record UserSecrets( + Dictionary ProviderApiKeys, + string EmbeddingApiKey, + string ZhiTuApiToken, + string CoinGeckoApiKey, + string BinanceApiKey, + string BinanceSecretKey, + string WebSearchApiKey) + { + public static UserSecrets From(UserSetting setting) => new( + new Dictionary(setting.ProviderApiKeys, StringComparer.Ordinal), + setting.EmbeddingApiKey, + setting.ZhiTuApiToken, + setting.CoinGeckoApiKey, + setting.BinanceApiKey, + setting.BinanceSecretKey, + setting.WebSearchApiKey); + + public void ApplyTo(UserSetting setting) + { + setting.ProviderApiKeys = new Dictionary(ProviderApiKeys, StringComparer.Ordinal); + setting.EmbeddingApiKey = EmbeddingApiKey; + setting.ZhiTuApiToken = ZhiTuApiToken; + setting.CoinGeckoApiKey = CoinGeckoApiKey; + setting.BinanceApiKey = BinanceApiKey; + setting.BinanceSecretKey = BinanceSecretKey; + setting.WebSearchApiKey = WebSearchApiKey; + } + } + + private sealed class LegacyUserSecrets + { + public Dictionary? ProviderApiKeys { get; set; } + public string? EmbeddingApiKey { get; set; } + public string? ZhiTuApiToken { get; set; } + public string? CoinGeckoApiKey { get; set; } + public string? BinanceApiKey { get; set; } + public string? BinanceSecretKey { get; set; } + public string? WebSearchApiKey { get; set; } + + public bool HasAnyValue() => + ProviderApiKeys is { Count: > 0 } || + !string.IsNullOrWhiteSpace(EmbeddingApiKey) || + !string.IsNullOrWhiteSpace(ZhiTuApiToken) || + !string.IsNullOrWhiteSpace(CoinGeckoApiKey) || + !string.IsNullOrWhiteSpace(BinanceApiKey) || + !string.IsNullOrWhiteSpace(BinanceSecretKey) || + !string.IsNullOrWhiteSpace(WebSearchApiKey); + + public void ApplyTo(UserSetting setting) + { + setting.ProviderApiKeys = ProviderApiKeys is null + ? [] + : new Dictionary(ProviderApiKeys, StringComparer.Ordinal); + setting.EmbeddingApiKey = EmbeddingApiKey ?? string.Empty; + setting.ZhiTuApiToken = ZhiTuApiToken ?? string.Empty; + setting.CoinGeckoApiKey = CoinGeckoApiKey ?? string.Empty; + setting.BinanceApiKey = BinanceApiKey ?? string.Empty; + setting.BinanceSecretKey = BinanceSecretKey ?? string.Empty; + setting.WebSearchApiKey = WebSearchApiKey ?? string.Empty; + } + } + /// /// 重置设置为默认值 /// @@ -107,4 +257,3 @@ public void ResetSettings() SaveSettings(); } } - diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs index b81b007..a194b49 100644 --- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Text; using System.Threading.Channels; +using MarketAssistant.Agents.Middleware; using MarketAssistant.Agents.Trading; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Services.Data; @@ -425,7 +426,9 @@ 如果置信度低于 60,建议 HOLD。 /// private async Task InvokeAgentAsync(string prompt) { - var agent = _agentFactory.CreateAgent(); + // 到达此处前,策略状态、硬性止盈止损、日交易次数和仓位预算已完成校验。 + // 后台任务必须显式声明预授权,禁止依赖空确认回调的隐式放行。 + var agent = _agentFactory.CreateAgent(TradingAuthorizationMode.PreAuthorizedAutomation); var messages = new List { new(ChatRole.User, prompt) @@ -433,7 +436,9 @@ private async Task InvokeAgentAsync(string prompt) var response = await agent.RunAsync(messages, session: null, options: null, cancellationToken: MonitorToken); - _logger.LogDebug("TradingAgent 响应: {Content}", response.Text); + _logger.LogDebug( + "TradingAgent 调用完成,响应长度: {ResponseLength}", + response.Text?.Length ?? 0); } /// diff --git a/src/MarketAssistant.App/AGENTS.md b/src/MarketAssistant.App/AGENTS.md index b946f4c..7643feb 100644 --- a/src/MarketAssistant.App/AGENTS.md +++ b/src/MarketAssistant.App/AGENTS.md @@ -9,7 +9,6 @@ ``` MarketAssistant.App/ ├── config/ -│ ├── models.yaml ← AI 模型与供应商配置 │ └── prompts/ ← 分析师提示词配置(每个分析师一个 yaml 文件) │ ├── FundamentalAnalyst.yaml │ ├── FinancialAnalyst.yaml @@ -91,7 +90,6 @@ MarketAssistant.App/ ## 配置文件 - `config/prompts/*.yaml`:分析师提示词配置,每个分析师一个独立文件(如 `FundamentalAnalyst.yaml`),含 `name`、`displayName`、`temperature`、`topP`、`topK`、`instructions` 字段,运行时热加载。 -- `config/models.yaml`:AI 模型与供应商配置。 --- diff --git a/src/MarketAssistant.App/MarketAssistant.App.csproj b/src/MarketAssistant.App/MarketAssistant.App.csproj index c994b79..573cf92 100644 --- a/src/MarketAssistant.App/MarketAssistant.App.csproj +++ b/src/MarketAssistant.App/MarketAssistant.App.csproj @@ -52,9 +52,6 @@ - - Always - Always diff --git a/src/MarketAssistant.App/Program.cs b/src/MarketAssistant.App/Program.cs index 98c6950..8e597f8 100644 --- a/src/MarketAssistant.App/Program.cs +++ b/src/MarketAssistant.App/Program.cs @@ -2,6 +2,7 @@ using MarketAssistant.Rag.Extensions; using MarketAssistant.Services; using MarketAssistant.Services.Settings; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; namespace MarketAssistant @@ -29,13 +30,15 @@ public static IServiceProvider ConfigureServices() { var services = new ServiceCollection(); - // ConfigureLogging 只需要在启动时读取一次日志路径,直接实例化即可, - // 避免构建临时容器(捕获依赖问题)。 - // IUserSettingService 的正式 Singleton 由 AddAgentTools() 内部负责注册。 - services.AddLogging(builder => builder.ConfigureLogging(new UserSettingService())); + // 日志配置与业务层复用同一个设置实例,避免同一进程重复打开安全存储 + // 或并行写入同一设置文件。后续同类型注册覆盖 AddBusinessServices 的默认注册。 + var userSettingService = new UserSettingService(); + services.AddLogging(builder => builder.ConfigureLogging(userSettingService)); - // 注册应用程序业务服务 + // 注册应用程序业务服务,并用启动期实例替换默认设置服务注册。 services.AddApplicationServices(); + services.RemoveAll(); + services.AddSingleton(userSettingService); // 注册ViewModels services.AddViewModels(); diff --git a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml index 37615d4..9c813e3 100644 --- a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml @@ -124,8 +124,8 @@ 480 360 720 - 320 - 72 + 440 + 120 400 200 80 diff --git a/src/MarketAssistant.App/Services/Notification/NotificationService.cs b/src/MarketAssistant.App/Services/Notification/NotificationService.cs index 20fb298..9d0b29c 100644 --- a/src/MarketAssistant.App/Services/Notification/NotificationService.cs +++ b/src/MarketAssistant.App/Services/Notification/NotificationService.cs @@ -7,7 +7,7 @@ namespace MarketAssistant.Services.Notification; /// public class NotificationService : INotificationService { - private const int DefaultDuration = 3000; + private const int DefaultDuration = 5000; public void ShowSuccess(string message, int durationMs = DefaultDuration) { diff --git a/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs index 2804759..eb9e709 100644 --- a/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs @@ -54,6 +54,7 @@ public partial class AgentAnalysisViewModel : ViewModelBase, INavigationAware /// 供 View 在 AttachedToVisualTree 时注入 @@ -165,6 +166,12 @@ private void OnChatSidebarPropertyChanged(object? sender, System.ComponentModel. private void OnAnalysisProgressChanged(object? sender, AnalysisProgressEventArgs e) { + if (_activeAnalysisRunId != e.RunId || + !string.Equals(StockCode, e.AssetSymbol, StringComparison.OrdinalIgnoreCase)) + { + return; + } + Dispatcher.UIThread.InvokeAsync(() => { IsAnalysisInProgress = e.IsInProgress; @@ -204,9 +211,16 @@ await Dispatcher.UIThread.InvokeAsync(() => await RefreshHistoryAsync(StockCode); _analysisCts?.Cancel(); + _analysisCts?.Dispose(); _analysisCts = new CancellationTokenSource(); - - var result = await _orchestrationService.AnalyzeAsync(StockCode, _analysisCts.Token); + var runId = Guid.NewGuid(); + _activeAnalysisRunId = runId; + var assetCode = StockCode; + + var result = await _orchestrationService.AnalyzeAsync( + assetCode, + runId, + _analysisCts.Token); var report = result.Report; _lastReport = report; @@ -326,6 +340,7 @@ protected override void OnMarketChanged(MarketType newMarket) _analysisCts?.Cancel(); _analysisCts?.Dispose(); _analysisCts = null; + _activeAnalysisRunId = null; _lastReport = null; // 重置分析状态 @@ -343,6 +358,7 @@ public void Dispose() _orchestrationService.ProgressChanged -= OnAnalysisProgressChanged; _analysisCts?.Cancel(); _analysisCts?.Dispose(); + _activeAnalysisRunId = null; if (MarketContext != null) UnsubscribeFromMarketChanges(MarketContext); diff --git a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs index 3fc3ef5..d764162 100644 --- a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs @@ -13,7 +13,10 @@ namespace MarketAssistant.ViewModels; /// public partial class ChatSidebarViewModel : ViewModelBase, IDisposable { - private readonly MarketChatSession _chatSession; + private readonly IMarketChatSessionFactory _chatSessionFactory; + private MarketChatSession? _chatSession; + private string? _pendingContextStockCode; + private List? _pendingAnalysisMessages; public ObservableCollection ChatMessages { get; } = []; @@ -40,7 +43,7 @@ public ChatSidebarViewModel( IMarketChatSessionFactory chatSessionFactory) : base(logger) { - _chatSession = chatSessionFactory.Create(); + _chatSessionFactory = chatSessionFactory; SendMessageCommand = new AsyncRelayCommand(SendMessageAsync, CanSendMessage); } @@ -84,11 +87,12 @@ private async Task SendMessageAsync() try { + EnsureChatSession(); _currentCancellationTokenSource = new CancellationTokenSource(); var contentBuilder = new System.Text.StringBuilder(); bool hasReceivedContent = false; - await foreach (var chunk in _chatSession.SendMessageStreamAsync(currentInput, _currentCancellationTokenSource.Token)) + await foreach (var chunk in _chatSession!.SendMessageStreamAsync(currentInput, _currentCancellationTokenSource.Token)) { if (!string.IsNullOrEmpty(chunk)) { @@ -139,6 +143,21 @@ private async Task SendMessageAsync() } } + private void EnsureChatSession() + { + if (_chatSession is not null) + return; + + _chatSession = _chatSessionFactory.Create(_pendingContextStockCode ?? StockCode); + + if (_pendingContextStockCode is not null && _pendingAnalysisMessages is not null) + { + _chatSession!.InjectAnalysisContext(_pendingContextStockCode, _pendingAnalysisMessages); + _pendingContextStockCode = null; + _pendingAnalysisMessages = null; + } + } + /// /// 添加欢迎消息 /// @@ -161,7 +180,15 @@ public Task InitializeWithAnalysisHistory(string stockCode, IEnumerable public void Dispose() { - _chatSession.Dispose(); + _chatSession?.Dispose(); _currentCancellationTokenSource?.Cancel(); _currentCancellationTokenSource?.Dispose(); _currentCancellationTokenSource = null; diff --git a/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs index 7c2706a..614e04d 100644 --- a/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/MCPConfigPageViewModel.cs @@ -249,7 +249,7 @@ private async Task TestConnection() try { - var tools = await _mcpService.GetAIToolsAsync([testConfig]).WaitAsync(cts.Token); + var tools = await _mcpService.GetAIToolsAsync([testConfig], cts.Token); var toolCount = tools.Count; if (toolCount > 0) diff --git a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs index d409a9c..a673e9a 100644 --- a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs @@ -7,6 +7,7 @@ using MarketAssistant.Applications.Settings; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; +using MarketAssistant.Infrastructure.Providers; using MarketAssistant.Rag; using MarketAssistant.Rag.Interfaces; using MarketAssistant.Services.Notification; @@ -16,7 +17,6 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Reflection; -using YamlDotNet.Serialization; namespace MarketAssistant.ViewModels; @@ -28,10 +28,13 @@ public partial class SettingsPageViewModel : ViewModelBase, IDisposable private readonly IRagIngestionService _ragIngestionService; private readonly INotificationService _notificationService; private readonly IUserSettingService _userSettingService; + private readonly IModelProviderAdapterFactory _adapterFactory; private readonly IEmbeddingFactory _embeddingFactory; private readonly VectorStore _vectorStore; private readonly Services.Market.MarketContext _marketContext; private IStorageProvider? _storageProvider; + private bool _isInitializingProvider; + private CancellationTokenSource? _modelFetchCancellationTokenSource; // UserSetting对象,包含所有用户设置 [ObservableProperty] @@ -70,6 +73,9 @@ private void ForwardComputedProperties(object? sender, PropertyChangedEventArgs case nameof(UserSetting.KnowledgeFileDirectory): OnPropertyChanged(nameof(IsKnowledgeDirectoryValid)); break; + case nameof(UserSetting.ModelId): + OnPropertyChanged(nameof(RequiresApiKey)); + break; } } @@ -77,6 +83,84 @@ private void ForwardComputedProperties(object? sender, PropertyChangedEventArgs [ObservableProperty] private ObservableCollection _models = []; + // 服务商列表 + public List Providers => ModelProviderCatalog.Providers.ToList(); + + // 当前选中的服务商 + [ObservableProperty] + private ModelProvider? _selectedProvider; + + // 当前服务商的 API Key(从 ProviderApiKeys 中读取当前服务商的 Key) + public string ApiKey + { + get => UserSetting.ProviderApiKeys.TryGetValue(UserSetting.ProviderId, out var key) ? key : ""; + set => UserSetting.ProviderApiKeys[UserSetting.ProviderId] = value; + } + + // 当前服务商的当前模型是否需要 API Key。 + // 服务商级免鉴权与模型级免鉴权分别由 ModelProvider 负责判断。 + public bool RequiresApiKey => SelectedProvider?.RequiresApiKeyForModel(UserSetting.ModelId) ?? true; + + // 当前服务商的 API Key 获取链接 + public string? ProviderApiKeyUrl => SelectedProvider?.ApiKeyUrl; + + // DefaultEndpoint 为空时(本地部署 / 自定义),用户需要自行输入端点 + public bool CanOverrideEndpoint => string.IsNullOrWhiteSpace(SelectedProvider?.DefaultEndpoint); + + // 当前服务商是否支持在线获取模型列表 + public bool SupportsModelListing => SelectedProvider?.SupportsModelListing ?? false; + + // 获取按钮可见:支持列表获取且未在加载中 + public bool CanShowFetchButton => SupportsModelListing && !IsLoadingModels; + + // 是否正在加载模型列表 + [ObservableProperty] + private bool _isLoadingModels; + + partial void OnIsLoadingModelsChanged(bool value) + { + OnPropertyChanged(nameof(CanShowFetchButton)); + } + + partial void OnSelectedProviderChanged(ModelProvider? oldValue, ModelProvider? newValue) + { + if (newValue != null) + { + UserSetting.ProviderId = newValue.Id; + + _modelFetchCancellationTokenSource?.Cancel(); + Models.Clear(); + + if (!_isInitializingProvider && oldValue is not null) + { + UserSetting.ProviderModelIds[oldValue.Id] = UserSetting.ModelId; + UserSetting.ProviderEndpoints[oldValue.Id] = UserSetting.Endpoint; + } + + UserSetting.ModelId = UserSetting.ProviderModelIds.GetValueOrDefault(newValue.Id, string.Empty); + UserSetting.Endpoint = UserSetting.ProviderEndpoints.GetValueOrDefault(newValue.Id, string.Empty); + + // 触发关联属性通知 + OnPropertyChanged(nameof(ApiKey)); + OnPropertyChanged(nameof(RequiresApiKey)); + OnPropertyChanged(nameof(ProviderApiKeyUrl)); + OnPropertyChanged(nameof(CanOverrideEndpoint)); + OnPropertyChanged(nameof(SupportsModelListing)); + OnPropertyChanged(nameof(CanShowFetchButton)); + + if (!_isInitializingProvider) + { + // 如果已有 API Key 或不需要 API Key,自动获取模型列表 + var currentKey = UserSetting.ProviderApiKeys.TryGetValue(newValue.Id, out var key) ? key : ""; + if (newValue.SupportsModelListing && + (!string.IsNullOrWhiteSpace(currentKey) || !newValue.RequiresApiKeyForModel(UserSetting.ModelId))) + { + _ = FetchModels(); + } + } + } + } + // 分析师角色列表 [ObservableProperty] private ObservableCollection _analystRoles = new(); @@ -106,7 +190,6 @@ private void ForwardComputedProperties(object? sender, PropertyChangedEventArgs public List InvestmentHorizonOptions { get; } = Enum.GetValues().ToList(); // API密钥获取URL - public string ModelApiUrl { get; } = "https://cloud.siliconflow.cn/i/z4lbHdBE"; public string ZhiTuApiUrl { get; } = "https://www.zhituapi.com/gettoken.html"; public string CoinGeckoApiUrl { get; } = "https://www.coingecko.com/en/api"; public string JinaApiUrl { get; } = "https://jina.ai/embeddings"; @@ -252,6 +335,7 @@ public SettingsPageViewModel( IRagIngestionService ragIngestionService, INotificationService notificationService, IUserSettingService userSettingService, + IModelProviderAdapterFactory adapterFactory, IEmbeddingFactory embeddingFactory, VectorStore vectorStore, Services.Market.MarketContext marketContext, @@ -260,6 +344,7 @@ public SettingsPageViewModel( _ragIngestionService = ragIngestionService; _notificationService = notificationService; _userSettingService = userSettingService; + _adapterFactory = adapterFactory; _embeddingFactory = embeddingFactory; _vectorStore = vectorStore; _marketContext = marketContext; @@ -276,10 +361,30 @@ public void SetStorageProvider(IStorageProvider? storageProvider) private async Task InitializeAsync() { - // 先加载模型列表 - await LoadModelsAsync(); // 加载用户设置(OnUserSettingChanged 会自动订阅 PropertyChanged) UserSetting = _userSettingService.CurrentSetting; + + // 同步服务商选择。初始化期间保留已保存的 ModelId 和 Endpoint, + // 仅用户主动切换服务商时才清空这些字段。 + _isInitializingProvider = true; + try + { + SelectedProvider = ModelProviderCatalog.GetProvider(UserSetting.ProviderId) + ?? ModelProviderCatalog.Providers.First(); + } + finally + { + _isInitializingProvider = false; + } + + // 如果已配置 API Key 或不需要 API Key,自动获取模型列表 + var currentKey = UserSetting.ProviderApiKeys.TryGetValue(UserSetting.ProviderId, out var key) ? key : ""; + if (SelectedProvider?.SupportsModelListing == true && + (!string.IsNullOrWhiteSpace(currentKey) || !SelectedProvider.RequiresApiKeyForModel(UserSetting.ModelId))) + { + await FetchModels(); + } + // 同步市场类型到MarketContext _marketContext.SwitchMarket(UserSetting.CurrentMarketType); // 加载分析师角色 @@ -327,7 +432,7 @@ private void LoadAnalystRoles() /// 打开API密钥网站命令 /// [RelayCommand] - private Task OpenModelApiWebsite() => OpenUrlAsync(ModelApiUrl); + private Task OpenModelApiWebsite() => ProviderApiKeyUrl != null ? OpenUrlAsync(ProviderApiKeyUrl) : Task.CompletedTask; [RelayCommand] private Task OpenZhiTuApiWebsite() => OpenUrlAsync(ZhiTuApiUrl); @@ -520,6 +625,12 @@ private void Save() UserSetting.EnabledAnalystRoles[role.Id] = role.IsEnabled; } + if (!string.IsNullOrWhiteSpace(UserSetting.ProviderId)) + { + UserSetting.ProviderModelIds[UserSetting.ProviderId] = UserSetting.ModelId; + UserSetting.ProviderEndpoints[UserSetting.ProviderId] = UserSetting.Endpoint; + } + // 同步市场类型到MarketContext _marketContext.SwitchMarket(UserSetting.CurrentMarketType); @@ -555,63 +666,83 @@ private void NavigateToMCPConfig() } /// - /// 加载模型列表 + /// 从服务商 API 获取模型列表(用户填好 API Key 后手动触发) /// - private async Task LoadModelsAsync() + [RelayCommand] + private async Task FetchModels() { + var provider = SelectedProvider; + if (provider is null || !provider.SupportsModelListing) + return; + + if (provider.RequiresApiKey && string.IsNullOrWhiteSpace(ApiKey)) + { + _notificationService.ShowWarning( + $"服务商 {provider.DisplayName} 的模型列表接口需要 API Key,请先配置后再获取;也可以直接手工输入模型 ID"); + return; + } + + _modelFetchCancellationTokenSource?.Cancel(); + _modelFetchCancellationTokenSource?.Dispose(); + var cts = new CancellationTokenSource(); + _modelFetchCancellationTokenSource = cts; + var requestedProviderId = provider.Id; + + IsLoadingModels = true; try { - // 清空当前模型列表 - Models.Clear(); + var adapter = _adapterFactory.Create(provider); + var apiKey = provider.RequiresApiKeyForModel(UserSetting.ModelId) ? ApiKey : null; + var endpoint = string.IsNullOrWhiteSpace(UserSetting.Endpoint) ? null : UserSetting.Endpoint; + var models = await adapter.ListModelsAsync(apiKey, endpoint, cts.Token); + + // 取消不能保证远端立即停止;响应落 UI 前再次校验 Provider 身份。 + if (cts.IsCancellationRequested || SelectedProvider?.Id != requestedProviderId) + return; - // 从YAML文件加载模型 - var modelsFromYaml = await LoadModelsFromYamlAsync(); + Models.Clear(); + foreach (var model in models) + { + if (!Models.Contains(model)) + Models.Add(model); + } - foreach (var model in modelsFromYaml) + if (Models.Count == 0) { - Models.Add(model); + _notificationService.ShowWarning("未获取到模型列表,可直接手工输入模型 ID"); } } - catch (Exception) + catch (OperationCanceledException) when (cts.IsCancellationRequested) { - // 忽略加载错误 + Logger?.LogDebug("已取消服务商 {ProviderId} 的模型列表请求", requestedProviderId); } - } - - /// - /// 从yaml文件加载模型列表 - /// - private async Task> LoadModelsFromYamlAsync() - { - try + catch (HttpRequestException ex) when ( + ex.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden) { - var models = new List(); - - // 读取config/models.yaml文件 - var configPath = Path.Combine(AppContext.BaseDirectory, "config", "models.yaml"); - if (!File.Exists(configPath)) + if (SelectedProvider?.Id == requestedProviderId) { - return models; + Logger?.LogWarning(ex, "服务商模型列表鉴权失败: {ProviderId}", requestedProviderId); + _notificationService.ShowError( + $"服务商 {provider.DisplayName} 的模型列表接口拒绝访问,请检查 API Key。免费模型不代表接口免鉴权,也可以直接手工输入模型 ID"); } - - var yamlContent = await File.ReadAllTextAsync(configPath); - - // 解析yaml内容 - var deserializer = new DeserializerBuilder().Build(); - var yamlData = deserializer.Deserialize>>(yamlContent); - - // 返回模型列表 - if (yamlData != null && yamlData.ContainsKey("models")) + } + catch (Exception ex) + { + if (SelectedProvider?.Id == requestedProviderId) { - models = yamlData["models"]; + Logger?.LogWarning(ex, "获取服务商模型列表失败: {ProviderId}", requestedProviderId); + var message = ErrorMessageMapper.GetUserFriendlyMessage(ex); + _notificationService.ShowError($"获取模型列表失败:{message}。也可以直接手工输入模型 ID"); } - - return models; } - catch (Exception ex) + finally { - Logger?.LogWarning(ex, "从 YAML 加载模型列表失败"); - return new List(); + if (ReferenceEquals(_modelFetchCancellationTokenSource, cts)) + { + _modelFetchCancellationTokenSource = null; + IsLoadingModels = false; + } + cts.Dispose(); } } @@ -639,6 +770,10 @@ public void Dispose() if (_disposed) return; _disposed = true; + _modelFetchCancellationTokenSource?.Cancel(); + _modelFetchCancellationTokenSource?.Dispose(); + _modelFetchCancellationTokenSource = null; + // 取消 UserSetting.PropertyChanged 订阅,避免 Singleton 持有已释放 ViewModel 的引用 if (UserSetting is not null) UserSetting.PropertyChanged -= ForwardComputedProperties; diff --git a/src/MarketAssistant.App/Views/Pages/SettingsPageView.axaml b/src/MarketAssistant.App/Views/Pages/SettingsPageView.axaml index 92d7e00..35973b8 100644 --- a/src/MarketAssistant.App/Views/Pages/SettingsPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/SettingsPageView.axaml @@ -13,6 +13,21 @@ + + + + @@ -43,30 +58,90 @@ - + - - + + + + + + + + + + + - +