diff --git a/Directory.Packages.props b/Directory.Packages.props index c546315..26b8ce8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,54 +4,60 @@ - - - + + + - - + + - - - - - - + + + + + + + + - + + + - + + - + - + - + - - - - - + + + + + - - - + + + - + - + \ No newline at end of file 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/README.md b/README.md index a663511..7b9ce90 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ - 策略配置:止损/止盈/追踪止损/AI 信号策略 - 实时监控:Binance WebSocket 实时价格监控 - 风控管理:多维度风控(仓位限制、单日亏损限制等) -- 当前交易执行链以 `IExchangeClient` 统一抽象接入 Binance 现货能力,合约支持仍在规划中 +- 交易执行链以 `IExchangeClient` 统一抽象接入 Binance 现货与 U 本位合约,支持实盘现货/实盘合约/合约测试网/现货 Demo 多模式切换与人工下单确认 ### 文档向量化(RAG) diff --git "a/docs/P0-P1\351\207\215\346\236\204\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/P0-P1\351\207\215\346\236\204\345\256\236\346\226\275\350\256\241\345\210\222.md" new file mode 100644 index 0000000..0378d46 --- /dev/null +++ "b/docs/P0-P1\351\207\215\346\236\204\345\256\236\346\226\275\350\256\241\345\210\222.md" @@ -0,0 +1,577 @@ +# P0 / P1 重构实施计划 + +> 基线分支:`feature/multi-provider-support`(多服务商支持) +> 建议执行分支:从其干净提交再拉 `refactor/p0-p1-correctness` +> 编写日期:2026-08-18 +> 覆盖范围:产品评审中的全部 P0/P1,以及 `docs/RAG模块分析报告.md` 中的 RAG-001~009、RAG-013 +> 不覆盖:P2(PDF/DOCX 双重解析、词表配置化、混合检索、xml prompt、SK 残留、A 股实时、更多交易所) + +本文是后续新建分支时的施工说明书。只改正确性、边界和主线可信度;不借机加功能。 + +--- + +## 0. 分支与执行约定 + +当前工作区在 `feature/multi-provider-support` 上,且存在未提交改动。重构**不要**叠在脏工作树上。 + +```text +1. 先把多服务商分支提交或暂存到干净状态 +2. git checkout feature/multi-provider-support +3. git pull +4. git checkout -b refactor/p0-p1-correctness +5. 按下文 PR / Commit 顺序逐项落地,每项可独立构建 +``` + +原则: + +- 一项问题一个提交(或一个可独立评审的 PR)。不要把 RAG 排序和 A 股 DataProviders 塞进同一个 diff。 +- 每个提交执行:`dotnet build MarketAssistant.slnx -c Debug`。 +- 触及 `src/` 下 RAG / 分析师 / 交易 / Tool 时,加跑对应测试过滤。 +- 向量 Schema、Key 规则、Embedding 维度不兼容时,新建 Collection 并重建,不原地改存量库。 +- 不在 ViewModel 或业务服务里手写新的 retry、缓存、搜索框架。 +- 提交信息遵循 `[模块] 变更概要`。 + +推荐提交顺序: + +```text +P0-01 检索距离方向 + -> P0-02/P0-03 维度校验与摄取结果 + -> P0-04 按市场裁剪分析师 + -> P0-05 分析质量最小评估骨架 + -> P1-01 稳定 Key + 文档级替换 + -> P1-02 检索保留元数据(邻接扩展可同 PR 或紧随) + -> P1-03 图片能力收敛 + -> P1-04 RAG 离线评估最小集 + -> P1-05 A 股 HTTP 下沉 DataProviders + -> P1-06 交易产品口径与入口 + -> P1-07 分析师产物改引用传递 + -> P1-08 MCP 最小暴露 +``` + +P0-05 与 P1-04 的数据集整理可以和编码并行,但代码合入不得早于对应正确性修复。 + +--- + +## 1. 问题总表 + +| ID | 级别 | 问题 | 根因一句话 | 主要落点 | +|---|---|---|---|---| +| P0-01 | P0 | 知识库排序可能反了 | `CosineDistance` 被当“越大越相关” | RAG 检索/重排 | +| P0-02 | P0 | 非 1024 维模型写入失败难定位 | Schema 固定 1024,摄取不校验 | RAG 摄取 | +| P0-03 | P0 | 部分块失败仍显示向量化成功 | 块异常被吞,UI 只看是否抛错 | RAG 摄取 + 设置页 | +| P0-04 | P0 | 币圈硬套财务分析师 | 启用名单不按 `MarketType` 过滤 | 分析工作流 + 设置 | +| P0-05 | P0 | 分析报告无法证明变好/变差 | 没有最小评估集,分数是模型自评 | tests + 编排 | +| P1-01 | P1 | 重复摄取残留旧块;同内容 Key 碰撞 | 只有 Upsert;Key 不含全局顺序 | RAG 摄取/键 | +| P1-02 | P1 | 重排用不上章节/时间/前后文 | 一搜到就压成 `TextSearchResult` | RAG 检索内部模型 | +| P1-03 | P1 | “跨模态”名不副实,哈希向量假装语义 | 只搜文本向量;CLIP 失败写哈希 | RAG 图片链路 | +| P1-04 | P1 | 无法量化检索改动 | 测试只覆盖路径,没有 Recall@K | tests/Vectors | +| P1-05 | P1 | A 股数据接入泄漏在 Tool/业务层 | DataProviders 只有币圈 API | DataProviders + Tools | +| P1-06 | P1 | 交易在 README 是主功能,Release 看不见 | 导航 `#if DEBUG` | UI + README | +| P1-07 | P1 | 协调器吃下全部分析师长文本 | Fan-In 把全文塞进对话 | 聚合器 + 归档 | +| P1-08 | P1 | MCP 空白名单等于暴露全部工具 | `AllowedTools` 为空时放行全部 | MCP 配置/加载 | + +P2 明确不做(避免扩大验证面): + +- RAG-010 PDF/DOCX → Markdown → 再解析 +- RAG-011 词表配置化 +- RAG-012 混合检索 / 换向量库 +- LLM Query Rewrite、模型化重排 +- xml prompt、SK 残留收口 +- A 股实时行情、额外交易所 + +--- + +## 2. P0 详解 + +### P0-01 检索距离方向 + +**现象:** 知识库开启时,最相关段落可能被去重丢掉或排到后面。协调分析师和追问对话都走 `GroundingSearchTools` → `IRetrievalOrchestrator.RetrieveAsync`。 + +**根因:** + +1. `TextParagraph.TextEmbedding` 配置 `DistanceFunction.CosineDistance`。在当前 `Microsoft.SemanticKernel.Connectors.SqliteVec 1.74.0-preview` 下,`VectorSearchResult.Score` 应按距离解释:越小越相关。 +2. 去重保留最大值:`RetrievalOrchestrator` 中 `OrderByDescending(x => x.VectorScore).First()`。 +3. 重排把最大值归一成最高相关:`RerankerService` 中 `(item.VectorScore - minScore) / range`。 +4. 异常回退也按降序。 +5. 类型名和注释仍写“向量相似度分数”(`ScoredSearchResult.VectorScore`),后续改动容易继续用错。 + +**方案:** + +1. `ScoredSearchResult.VectorScore` 重命名为 `VectorDistance`。接口注释改为距离语义。 +2. 去重保留最小距离:`.OrderBy(item => item.VectorDistance).First()`。 +3. 归一化改为距离反转: + +```csharp +var normalizedSimilarity = range < 1e-9 + ? 1.0 + : (maxDistance - item.VectorDistance) / range; +``` + +4. 异常回退按距离升序。 +5. 日志字段用 `distance` / `similarity`,禁止再统称 `score`。 +6. **不要**同时改 0.6/0.2/0.1/0.1 权重。**不要**用 `1 - distance` 并假设结果在 `[0,1]`。 +7. 升级 SqliteVec 时必须重测 Score 语义,不能只看属性名。 + +**改哪些文件:** + +- `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` + +**测试:** + +- 距离 0.1 必须排在 0.8 前。 +- 同一段落被多个改写查询命中时保留最小距离。 +- 所有距离相同时顺序稳定。 +- Reranker 抛错时仍按距离升序。 +- 一条 SQLiteVec 集成测试:两个可控向量,断言连接器返回顺序与距离含义。 + +**完成标准:** 链路中不再把 `CosineDistance` 当越大越好;`VectorScore` 命名从该链路消失。 + +--- + +### P0-02 文本向量维度无校验 + +**现象:** 用户换成非 1024 维 Embedding 模型后,写入或检索失败,错误难以对应到“维度不匹配”。 + +**根因:** `RagConstants.EmbeddingDimension = 1024`,`[VectorStoreVector]` 把维度写死在 schema。`RagIngestionService` 把 `GenerateAsync` 的结果原样赋给 `TextEmbedding`,没有检查 `Vector.Length`。截断/补零只发生在 CLIP 图像路径。第一阶段**不要**做动态多维度。 + +**方案:** + +1. 生成向量后、`UpsertAsync` 前校验: + - `embeddings.Count == paragraphList.Count` + - 每个 `embeddings[i].Vector.Length == RagConstants.EmbeddingDimension` +2. 不匹配则该块失败,不写入,错误消息面向用户: + +```text +Embedding 维度不匹配。当前知识库只支持 1024 维,实际为 {actual}。 +请改回支持的 Embedding 模型,或重建向量库后再切换。 +``` + +3. 不记录 API Key、全文、完整向量。 +4. 更换模型/维度的产品语义:必须重建 Collection,而不是静默截断。 + +**改哪些文件:** `RagIngestionService`、`IRagIngestionService`(与 P0-03 一起改返回值)、摄取测试。 + +**测试:** 1024 维写入成功;1536 维在第一次 Upsert 前失败;返回条数少于文本条数时失败。 + +--- + +### P0-03 块失败被报告为文件成功 + +**现象:** 文档只有部分块入库,设置页仍增加成功计数并提示“全部完成”。 + +**根因:** + +1. `RagIngestionService.IngestWithBlocksAsync` 对每个 block `catch (Exception)` 只打 Warning,方法正常返回。 +2. `IRagIngestionService.IngestFileAsync` 无返回值、无 `CancellationToken`。 +3. `SettingsPageViewModel` 只要 `await IngestFileAsync(...)` 不抛,就 `successCount++`。 +4. `OperationCanceledException` 若被块级 catch 吃掉,取消会变成“部分成功”。 + +**方案:** + +```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 && ParagraphCount > 0; + public bool IsPartialSuccess => ParagraphCount > 0 && Failures.Count > 0; + public bool IsFailure => ParagraphCount == 0; +} +``` + +接口改为: + +```csharp +Task IngestFileAsync( + VectorStoreCollection collection, + string filePath, + IEmbeddingGenerator> embeddingGenerator, + CancellationToken cancellationToken = default); +``` + +规则: + +- `OperationCanceledException` 必须继续抛出。 +- 其余块异常记入 `Failures`,可继续后续块。 +- 无 reader / 零块:返回失败,不要假装成功。 +- UI 三态:成功 / 部分成功 / 失败。部分成功不得计入“完全成功”。提示里给出失败文件和失败块数。 + +**改哪些文件:** + +- `IRagIngestionService.cs`、`RagIngestionService.cs` +- `src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs`(向量化循环,约 `successCount` 逻辑) +- `tests/Vectors/RagIngestionServiceIntegrationTest.cs` 及新增单元测试 + +**本项与 P0-02 同一提交:** 摄取接口只改一次,避免调用方改两轮。 + +--- + +### P0-04 按市场裁剪分析师 + +**现象:** A 股和虚拟币共用五位分析师。财务分析师提示词是“财务报表、ROE、现金流”;币圈却把 `IFinancialTools` Keyed 到 `CryptoMetricsTools`。角色能跑,语义被拉歪,评分维度对加密资产不诚实。 + +**根因:** + +1. `GetEnabledAnalysts()` 只看 `UserSetting.EnabledAnalystRoles` 和 `[RequiredAnalyst]`,不看 `MarketType`。 +2. `[RequiresTools(typeof(IFinancialTools))]` 在两个市场都能解析到实现,所以工厂不会失败。 +3. 设置页加载全部具体分析师类型,没有按当前市场过滤。 +4. 只有一份 `FinancialAnalyst.yaml`。 + +**方案:** + +1. 新增 `[SupportedMarkets(params MarketType[] markets)]`。缺省表示两个市场都支持(基本面、技术、情绪、新闻、协调器)。 +2. `FinancialAnalystAgent` 标记仅 `MarketType.AShare`。 +3. 新增 `CryptoMetricsAnalystAgent`(显示名如“项目指标分析师”),`[RequiresTools(typeof(ICryptoMetricsTools))]`,仅 `MarketType.Crypto`,独立 YAML(市值、换手、资金费率、持仓量等,不要套财报词)。 +4. `GetEnabledAnalysts()` 增加:类型必须支持 `_marketContext.CurrentMarket`。 +5. 设置页 `LoadAnalystRoles()` 按当前市场列出;切换市场时刷新。用户在 A 股关掉的角色不要误伤币圈名单——`EnabledAnalystRoles` 的 key 继续用类型名即可,因为两个市场的类型名将分开。 +6. `AnalysisCacheService` 若把启用角色纳入缓存键,必须包含市场类型,避免串缓存。 +7. 不要在本项改评分公式或协调器 schema。`CoordinatorResult.DimensionScores` 若仍有“财务面”字段:A 股填财务,币圈填项目指标,并在协调器提示词里写清映射,避免模型用“财报”字样描述代币。 + +**改哪些文件:** + +- `src/MarketAssistant.Agents/Analysts/Attributes/`(新特性) +- `FinancialAnalystAgent.cs`、新建 `CryptoMetricsAnalystAgent.cs` +- `src/MarketAssistant.App/config/prompts/CryptoMetricsAnalyst.yaml` +- `MarketAnalysisWorkflow.GetEnabledAnalysts` +- `SettingsPageViewModel.LoadAnalystRoles` +- `AnalystAgentFactoryTest`、工作流相关测试 +- `src/MarketAssistant.App.Services/AGENTS.md` / `Agents/AGENTS.md` 各补一句:分析师必须声明支持的市场 + +**测试:** + +- Crypto 下创建的分析师列表不含 `FinancialAnalystAgent`,含新指标分析师。 +- AShare 相反。 +- `[RequiredAnalyst]` 仍强制启用,但也要受市场过滤(协调器两边都支持)。 +- 设置页在切换市场后角色列表变化。 + +**完成标准:** 币圈分析运行时不再出现“财务报表分析师”执行器。 + +--- + +### P0-05 分析质量最小评估骨架 + +**现象:** 主线“值不值得信”无法验证。现有测试覆盖工作流身份、失败隔离、聚合器,但不覆盖“同一标的、固定数据下报告是否稳定、缺数据是否降级”。 + +**根因:** 评分来自 LLM 自评;Tool 走真实 HTTP;没有冻结的工具响应夹具,也没有“无答案 / 缺数据”断言。 + +**方案(骨架,不是大规模评测平台):** + +1. 新增 `tests/Evaluation/`(或 `tests/Analysis/Golden/`),默认不打真实模型。 +2. 第一期只锁定**编排契约**,用假 Tool / 假 ChatClient: + - 全部分析师成功 → 聚合器发给协调器的消息数 = 成功数 + 1 条维度缺失说明(失败为 0 时说明可空)。 + - 部分失败 → 协调器收不到失败标记原文,只收到缺失说明。 + - 全部失败 → 不派发协调器,工作流给出明确错误。 + - 结构化输出反序列化失败 → 计入降级,不把半截 JSON 当报告。 +3. 准备 5~10 个离线夹具(A 股 1 只、币 1 个交易对即可):冻结的行情/财务 JSON + 期望字段“必须出现数据缺失声明 / 不得出现编造 PE”。 +4. 可选:环境变量开启的 live 评估,失败不算 CI 红(与 RAG 集成测试缺 `JINA_API_KEY` 同一策略)。 +5. **不要**在本项训练提示词或调温度。评估是尺子,不是优化器。 + +**改哪些文件:** 现有 `AnalysisAggregatorExecutorTest`、`MarketAnalysisDegradationTest` 可扩展;新增 golden 夹具与 README(测试目录内短说明即可,不另开设计文档)。 + +**完成标准:** 一条本地命令能跑编排契约;缺数据路径有断言;live 评估缺密钥时跳过而不是删断言。 + +--- + +## 3. P1 详解 + +### P1-01 稳定 Key 与文档级替换 + +**现象:** 同一文件再向量化,旧段落仍能召回;同一文档两个同级同名标题,后写覆盖先写。 + +**根因:** + +1. 摄取只有 `UpsertAsync`,没有按文档删除过期 Key。 +2. 标题键:`{fileHash}:hdg:{level}:{contentHash[..8]}`,不含全局 `Order`。列表/表格/图片同样。 +3. `fileHash` 来自路径字符串哈希,不是稳定 `DocumentId`(大小写、分隔符、相对路径会变)。 + +**方案:** + +Key 统一为: + +```text +{documentId}:{blockKind}:{order:D6}:{contentHashPrefix} +``` + +`DocumentId`:`Path.GetFullPath` → 统一分隔符 → Windows 大小写不敏感 → 哈希。不要把明文路径放进 Key,也不要用全文当文档身份。 + +文档级替换语义: + +```text +解析并生成新段落 + -> 维度与数量校验(P0-02) + -> 写入 newKeys + -> 删除 oldKeys.Except(newKeys) + -> 成功后再更新文档清单 +``` + +引入 `IRagDocumentCatalog`(清单持久化先评审:优先 SQLite 旁路表或现有 VectorStore 数据字段,**禁止**在业务服务手写 FTS5/SQL 拼搜索)。清单至少含:CollectionName、DocumentId、DocumentUri、内容哈希、当前 Key 集合、Embedding 模型标识、维度、更新时间。 + +失败恢复:写入成功但清单失败时,下次摄取必须能重新收敛。删除文档要有服务入口:按清单删记录再删清单。 + +**测试:** 同一文件摄取两次记录数不增长;删一段落后旧段不可召回;两个同名标题都在且 Key 不同;内容不变可幂等;删除文档后不可召回。 + +**完成标准:** 文档更新后库中无旧块;合法重复块不互相覆盖。 + +本项会改变存量 Key → **重建 Collection**,不要写迁移脚本猜旧键。 + +--- + +### P1-02 检索保留元数据与邻接上下文 + +**现象:** `Order`、`Section`、`PublishedAt`、`BlockKind` 摄取时写了,检索后立刻丢掉。时效评分只能从 URL/正文猜时间。表格命中没有表头/前后文。 + +**根因:** `RetrievalOrchestrator` 在合并阶段就构造 `TextSearchResult`(Name/Link/Value),后续 `IRerankerService` 只看得见这三字段。 + +**方案:** + +内部模型: + +```csharp +public sealed record RagSearchCandidate( + TextParagraph Record, + double VectorDistance, + string MatchedQuery); +``` + +- 公开 `IRetrievalOrchestrator.RetrieveAsync` 仍可返回 `TextSearchResult`,转换放在**最后一步**。 +- `IRerankerService` 入参改为 `RagSearchCandidate`。 +- 去重优先 `Record.Key`,其次 `ContentHash`。 +- 时效优先 `PublishedAt`,缺失再猜 URL/正文。 +- 新增 `IContextExpansionService`:按 `DocumentId + Order` 取命中块前 1 后 1;同 Section 优先;跨文档或跨主 Section 停止;总 token 上限;重叠窗口去重。表格优先补最近标题;标题优先补后一个正文。 + +**测试:** 元数据在重排前后不丢;不跨 `DocumentUri`;不跨主 Section;扩展后不超过上限。 + +**完成标准:** 内部链路不以 `TextSearchResult` 当核心模型。 + +--- + +### P1-03 图片能力收敛 + +**现象:** 注释/常量仍写“跨模态”“文本与图像统一维度”。检索只搜 `TextEmbedding`。CLIP 失败时 `HashToVector` 仍生成 1024 维向量。普通文本块还写入全零 `ImageEmbedding`。 + +**根因:** 维度相同被当成语义空间相同。哈希向量有确定性、无语义。连接器可能不允许空向量字段,代码用零向量“保证不为 null”。 + +**方案:** + +1. 产品与注释统一为:**图片说明(Caption)的文本检索**。在 `RagConstants`、`TextParagraph`、`RagIngestionService`、设置页文案中改名。 +2. CLIP 失败:不生成可检索哈希向量;图像向量标记不可用;Caption 成功则仍可靠文本向量召回。 +3. 评估 `ImageEmbedding` 消费者:检索不用就停止写入;若连接器要求非空,用明确的 Unavailable 标记,禁止把哈希向量当语义。 +4. 只有同时具备 CLIP 文本塔查询、融合测试通过后,才允许恢复“跨模态”一词。 + +**测试:** 无 CLIP 模型时不产生哈希语义向量;Caption 成功可被文本检索命中;Caption 与 CLIP 都失败时摄取结果含失败信息。 + +--- + +### P1-04 RAG 离线评估最小集 + +**现象:** Vectors 测试再绿也无法回答“修距离之后检索是变好还是变差”。 + +**方案:** + +- 首版 10~20 条即可(报告写 30~50 是目标态,本轮不追求)。 +- 覆盖:同义改写、股票代码/数值、表格、无答案、图片 Caption(若有样本)。 +- 指标:Recall@1/3/5、MRR、无答案误召回、P50/P95。二元标签阶段不上 nDCG。 +- 每条记录:代码版本、Embedding 模型与维度、数据集版本、失败查询列表。 +- 命令可重复;缺 `JINA_API_KEY` 时标记环境阻塞,不删断言。 + +前置:P0-01 已合入,否则基线本身是反的。 + +--- + +### P1-05 A 股 HTTP 下沉到 DataProviders + +**现象:** `MarketAssistant.DataProviders` 只有 Binance/CoinGecko/CoinDesk。A 股 Tool 和业务服务直接 `CreateClient("Cls"|"ZhiTu"|"EastMoneySearch")`。 + +**根因:** 币圈接入先独立成库,A 股调用留在 `App.Services`。Keyed Tools 需要数据,就地写了 HTTP。 + +**当前散落点:** + +| 调用方 | 命名客户端 | +|---|---| +| `AShareBasicTools` | Cls、ZhiTu | +| `AShareFinancialTools` / `Technical` / `Sentiment` | ZhiTu | +| `AShareNewsTools` | EastMoneySearch | +| `AShareAssetInfoService` | Cls | +| `AShareKLineService` | ZhiTu | +| `PriceAlertService`(A 股报价) | Cls | + +**方案:** + +1. 在 `DataProviders` 增加 A 股客户端(按源拆类型,不要一个上帝类): + - `ClsQuoteClient`:行情/搜索 + - `ZhiTuMarketClient`:K 线、财务、技术、情绪 + - `EastMoneyNewsClient`:新闻搜索 +2. 反序列化模型放 DataProviders;跨模块 DTO 若已被 Tool 契约使用,留在 `Agents/Tools/Models/AShare`,客户端映射过去。 +3. 错误继续包装 `FriendlyException`。Token 仍从 `IUserSettingService` 注入,禁止写进仓库。 +4. HttpClient 命名注册可以暂时留在 `App.Services` 的 `AddBusinessServices`(宿主才有完整 Http 弹性管线)。DataProviders 通过 `IHttpClientFactory` + 现有名字消费。若要更干净,可把 `AddAShareDataProviders(this IServiceCollection)` 放在 DataProviders,由 `AddBusinessServices` 调用。 +5. Tool / AssetInfo / KLine / PriceAlert **删除**直接 `GetStringAsync` + 手解析,改为调客户端。 +6. 更新 `src/MarketAssistant.DataProviders/AGENTS.md`:本层同时覆盖加密货币与 A 股外部 API。 + +**测试:** 现有 Tool 测试改为 mock 新客户端或继续 mock `HttpMessageHandler`;每个客户端至少一条反序列化容错(`--`、null、字符串数字)。 + +**非目标:** 换官方行情源、加 WebSocket 实时。 + +--- + +### P1-06 交易产品口径与入口 + +**现象:** README“主要功能”写了自主交易;`MainWindowViewModel.RebuildNavigationItems` 用 `#if DEBUG` 包住交易页。Release 用户按文档会认为功能存在。引擎、风控、人工确认(`RiskConfig.RequireConfirmation`、`TradeConfirmationService`)其实已经有。 + +**根因:** 交易实现按实验室功能合入,文档按完整产品宣传;用编译符号隐藏入口,而不是产品开关。 + +**方案(本轮选口径,不把交易做成券商级产品):** + +推荐默认:**实验功能**。 + +1. README 交易一节改为“实验功能,默认关闭;Debug 或设置中显式开启后可见”。 +2. 去掉 `#if DEBUG`,改为 `UserSetting.EnableExperimentalTrading`(默认 false)。仅当当前市场 `SupportsTrading` 且该开关为真时显示导航。 +3. 设置页增加开关 + 风险说明(实盘、密钥、无法撤销等)。 +4. 不在本项扩展策略类型或交易所。 +5. 若产品决定“Release 也作为主功能交付”,则同一 PR 必须包含:默认确认阈值不可关死、失败策略已有测试的回归清单、README 与关于页同步。二者选一,禁止第三种(文档写有、入口没有)。 + +**改哪些文件:** `MainWindowViewModel`、`UserSetting`、设置页、`README.md`。 + +**测试:** 开关关闭时导航无交易;Crypto + 开关打开时有;A 股即使打开开关也无(`SupportsTrading == false`)。 + +--- + +### P1-07 分析师产物改为引用传递 + +**现象:** `AnalysisAggregatorExecutor` 把每位分析师的完整文本 `SendMessageAsync` 给 Coordinator。五份结构化 JSON 进一次上下文,Token 贵、易截断、易失真。追问侧 `MarketChatSession` 也会把分析消息拼进 `_analysisContext`。 + +**根因:** Fan-In 最省事的契约是 `List`。归档(`ReportArchiveService`)发生在工作流结束之后,协调阶段用不上。 + +**方案:** + +1. 聚合器在收齐后:将每份分析师文本写入本次 Run 的产物存储(复用归档 SQLite 或 `IAnalysisCacheService` 旁路表),key = `{runId}/{analystName}`。 +2. 发给 Coordinator 的消息改为短摘要:分析师名、评分/评级(若已结构化)、产物 key、字符数。另附一条系统说明:必须通过工具读取产物全文,禁止凭摘要编造细节。 +3. 给协调器增加只读工具 `GetAnalystArtifactAsync(runId, analystName)`,只允许读本次 Run。 +4. 追问会话:默认注入摘要 + 工具;用户追问涉及某维度时再拉全文。不要一上来把五份全文塞进 system。 +5. 失败分析师仍只传缺失说明,不写空产物冒充成功。 + +**测试:** 发给 Coordinator 的消息总长度显著小于原文之和;工具能读回原文;错误 runId 拒绝;全部失败仍不派发 Coordinator。 + +**非目标:** 通用多 Agent 文件系统框架、跨会话记忆重构。 + +--- + +### P1-08 MCP 最小暴露 + +**现象:** `MCPServerConfig.AllowedTools` 为空时加载该服务器全部工具,只打 Warning。`McpToolContextProvider` 在每次 Chat 调用前把这些工具注入。多服务器时模型会无约束试探。`Category` 字段已有,但加载路径未按分类限制。 + +**根因:** 白名单是可选的;空列表被实现成“全部允许”,对最小暴露不友好。 + +**方案:** + +1. 空 `AllowedTools` 的默认改为:**不暴露任何工具**,并在 MCP 配置页强制用户勾选。保留“允许全部”需要显式 `AllowAllTools = true`(默认 false),配置页用危险确认。 +2. 按 `Category` 过滤:对话会话只注入 `general`/`search`;不要把 `code` 类工具默认给市场分析 Chat。分析工作流默认不注入 MCP(当前协调器只显式加了 `GroundingSearchTools`,保持如此)。 +3. 工具名加前缀 `mcp.{serverName}.{toolName}`,降低多服务器重名冲突(若 MAF 工具名允许点号;不允许则用下划线)。 +4. 配置页:列出服务器工具,默认全不选;保存时写入 `AllowedTools`。 +5. 已有配置迁移:旧配置空白名单且曾经能用 → 启动时记一条警告,本轮不自动 `AllowAllTools=true`(避免 silently 维持过大暴露)。在关于/设置给一次性提示“请重新勾选 MCP 工具”。 + +**测试:** 空白名单 → 0 工具;`AllowAllTools` 才加载全部;分类过滤生效;过滤审计日志仍记录。 + +--- + +## 4. 提交 / PR 拆分 + +| 提交 | 内容 | 评审重点 | +|---|---|---| +| C1 | P0-01 距离语义 | 升降序和归一化是否一致 | +| C2 | P0-02 + P0-03 摄取结果 | 是否仍静默成功;取消是否被吞 | +| C3 | P0-04 市场分析师 | 币圈是否还能创建财务分析师 | +| C4 | P0-05 分析评估骨架 | CI 不依赖真实模型密钥 | +| C5 | P1-01 Key + 替换 | 幂等、删除、失败恢复;需重建库 | +| C6 | P1-02 候选模型 + 上下文 | 是否跨文档拼接 | +| C7 | P1-03 图片文案与哈希退出 | 哈希向量是否还进语义场 | +| C8 | P1-04 RAG 评估 | 可重复、缺密钥可跳过 | +| C9 | P1-05 A 股 DataProviders | Tool 内不再直接打 HTTP | +| C10 | P1-06 交易开关与 README | 口径与入口一致 | +| C11 | P1-07 产物引用 | Coordinator 上下文显著变短 | +| C12 | P1-08 MCP 白名单默认收紧 | 空配置不再等于全部暴露 | + +每个提交: + +```bash +dotnet build MarketAssistant.slnx -c Debug +``` + +C1–C8 另跑: + +```bash +dotnet test tests/TestMarketAssistant.csproj -c Debug --filter "FullyQualifiedName~TestMarketAssistant.Vectors" +``` + +C3、C4、C11 另跑分析师/工作流相关 filter。C9 跑 Tool 与 Application 测试。C10、C12 跑设置/MCP 测试。涉及 Schema/Key 时跑全量测试。 + +--- + +## 5. 验收 + +P0 全部完成后: + +1. CosineDistance 越小的候选不会在去重或重排被降权。 +2. 非 1024 维向量在 Upsert 前失败,UI 有可操作说明。 +3. 块失败不会显示为文件完全成功;取消不会被当成成功。 +4. 虚拟币分析不再运行财务分析师;A 股不再运行币圈指标分析师。 +5. 分析编排的成功/部分失败/全失败路径有不依赖真实模型的自动化测试。 + +P1 全部完成后: + +6. 同一文档重复摄取不累积旧块;删除后不可召回;重复标题不互相覆盖。 +7. 重排能用 `PublishedAt`;上下文扩展能用 `Order`/`Section`。 +8. 对外不再宣称跨模态检索;哈希向量不参与语义排序。 +9. 检索策略变更可用最小评估集对比。 +10. A 股外部 HTTP 只出现在 DataProviders(或由其注册的客户端)。 +11. README 与导航对交易的描述一致。 +12. 协调器默认上下文是摘要 + 引用,全文按需读取。 +13. MCP 空白名单不再等于暴露全部工具。 + +--- + +## 6. 开发者检查清单 + +编码前: + +- [ ] 当前在 `refactor/p0-p1-correctness`,且基于干净的 `feature/multi-provider-support` +- [ ] 本次提交只处理上表中的一个 ID(C2 允许 02+03 捆绑) +- [ ] 已为错误行为补失败测试 +- [ ] 未根据其他向量库文档猜测 SqliteVec 的 Score 语义 + +编码中: + +- [ ] 距离用 Distance,相似度用 Similarity +- [ ] 异步 API 传递 `CancellationToken`;不吞 `OperationCanceledException` +- [ ] 不记录密钥、完整文档、完整向量 +- [ ] 不用随机 GUID 当段落身份 +- [ ] 不把部分成功当成完全成功 +- [ ] 不在 Tool 里新增 A 股 HttpClient 调用 + +提交前: + +- [ ] Debug 构建通过 +- [ ] 相关测试通过;集成失败已区分环境与代码 +- [ ] 未提交 API Key、用户文档、本地向量库 +- [ ] README / 设置文案 / 代码注释对 RAG 与交易的描述一致 + +--- + +## 7. 和现有文档的关系 + +| 文档 | 关系 | +|---|---| +| `docs/RAG模块分析报告.md` | 诊断仍有效。施工以本文为准;报告第 8 章九阶段手册只采纳阶段 1–5 对应项(P0-01~P1-04),阶段 6–9 仍是 P2。 | +| `docs/Todo.md` | P2 与 Agent/Prompt 长期项仍留在 Todo;P0/P1 以本文跟踪,避免 Todo 只剩双重解析。 | +| `docs/design-system.md` | UI 间距/资源约定,本重构仅设置页文案和交易开关触及 UI 时遵守。 | + +重构完成后:更新 `docs/Todo.md` 去掉已完成项;在 RAG 报告文首加一行“P0/P1 施工状态见 P0-P1重构实施计划”。不要把本文再写成第二份九阶段空手册。 diff --git a/docs/Todo.md b/docs/Todo.md index 14cee3d..3af4c4b 100644 --- a/docs/Todo.md +++ b/docs/Todo.md @@ -1,6 +1,6 @@ ## TODO -以下仅保留仍未完成或仍需深化的事项。 +P0 / P1 正确性重构(检索距离、摄取结果、按市场分析师、A 股 DataProviders、交易口径、MCP 白名单等)见 [P0-P1重构实施计划.md](./P0-P1重构实施计划.md)。下文仅保留该计划范围之外、仍需深化的事项。 ### Agent 与 Prompt diff --git "a/docs/\345\215\207\347\272\247\351\207\215\346\236\204\346\240\270\346\237\245\346\212\245\345\221\212.md" "b/docs/\345\215\207\347\272\247\351\207\215\346\236\204\346\240\270\346\237\245\346\212\245\345\221\212.md" new file mode 100644 index 0000000..aa37e35 --- /dev/null +++ "b/docs/\345\215\207\347\272\247\351\207\215\346\236\204\346\240\270\346\237\245\346\212\245\345\221\212.md" @@ -0,0 +1,354 @@ +# MarketAssistant 升级重构核查报告 + +> 核查日期:2026-08-18 +> 核查范围:MAF 1.13.0 → 1.17.0 升级及伴随的架构重构 +> 核查方法:代码级核查 + 编译验证 + 测试验证 +> 核查基线:`df44377 重构模型发现服务,移除 SDK 依赖统一接口` + +--- + +## 1. 升级概览 + +### 1.1 包版本变更 + +| 包 | 旧版本 | 新版本 | 说明 | +|----|--------|--------|------| +| `Microsoft.Agents.AI` | 1.13.0 | **1.17.0** | MAF 核心,跨 4 个 minor 版本 | +| `Microsoft.Agents.AI.Workflows` | 1.13.0 | **1.17.0** | 工作流引擎 | +| `Microsoft.Agents.AI.Workflows.Generators` | 1.13.0 | **1.17.0** | 源生成器 | +| `Microsoft.Extensions.AI` | 10.7.0 | **10.9.0** | AI 抽象层 | +| `Microsoft.Extensions.AI.OpenAI` | 10.7.0 | **10.9.0** | OpenAI 实现 | +| `OllamaSharp` | 5.4.27 | **5.4.30** | Ollama 本地模型客户端 | +| `Microsoft.Extensions.Caching.Memory` | 10.0.9 | **10.0.11** | 内存缓存 | +| `Microsoft.Extensions.Http` | 10.0.9 | **10.0.11** | HttpClient 工厂 | +| `Microsoft.Extensions.Http.Resilience` | 10.7.0 | **10.9.0** | 弹性管道 | +| `Microsoft.Extensions.Logging.Abstractions` | 10.0.9 | **10.0.11** | 日志抽象 | +| `Microsoft.Identity.Client.Extensions.Msal` | 4.73.1 | **4.87.0** | MSAL 认证 | +| `Microsoft.Data.Sqlite` | 10.0.10 | **10.0.11** | SQLite 数据提供者 | +| `SQLitePCLRaw.bundle_e_sqlite3` | — | **3.0.5** | 新增:固定 CVE-2025-6965 | +| `PdfPig` | 0.1.14 | **0.1.15** | PDF 解析 | +| `Microsoft.Extensions.AI.Evaluation` | 10.8.0 | **10.9.0** | AI 评估 | +| `Microsoft.Extensions.AI.Evaluation.Quality` | 10.8.0 | **10.9.0** | AI 质量评估 | + +### 1.2 未变更的关键包 + +| 包 | 版本 | 说明 | +|----|------|------| +| `Microsoft.SemanticKernel.Connectors.SqliteVec` | 1.74.0-preview | RAG 向量存储,SK 残留 | +| `Microsoft.SemanticKernel.Plugins.Web` | 1.77.0-alpha | Web 搜索,SK 残留 | +| `ModelContextProtocol` | 2.2.0 | MCP 协议 | +| `Avalonia` | 12.1.1 | UI 框架 | +| `DocumentFormat.OpenXml` | 3.5.1 | DOCX 解析 | +| `Markdig` | 1.3.2 | Markdown 解析 | + +### 1.3 编译与测试验证 + +| 验证项 | 结果 | +|--------|------| +| `dotnet build` | ✅ 0 错误,0 警告 | +| Unit 测试(`TestCategory=Unit`) | ✅ 262/262 通过 | +| 降级测试(5 轮重复) | ✅ 全通过(含重试循环) | +| E2E 测试(无重试循环) | ❌ 3 个稳定失败(barrier 消息丢失) | + +--- + +## 2. 架构重构详情 + +### 2.1 模型服务商适配统一(commit `5c876e3`) + +#### 删除的组件 + +| 组件 | 说明 | 删除原因 | +|------|------|----------| +| `ResilientChatClient` | SK 风格的重试装饰器 | 被 MAF 原生重试机制替代 | +| `IModelProviderAdapter` | 模型服务商适配接口 | 统一到 `IModelProviderAdapter` 新设计 | +| `ModelProviderAdapterFactory` | 适配器工厂 | 被统一工厂替代 | +| `TradingFunctionGuardMiddleware` | 交易函数守卫中间件 | 功能合并到其他组件 | + +#### 新增的组件 + +| 组件 | 文件 | 说明 | +|------|------|------| +| `ModelDiscoveryService` | `Infrastructure/Factories/ModelDiscoveryService.cs` | 统一模型发现服务,移除 SDK 依赖 | +| `StructuredOutputOptions` | `Agents/Analysts/StructuredOutputOptions.cs` | 统一结构化输出配置 | +| `ModelProviderPolicy` | `Infrastructure/Providers/ModelProviderPolicy.cs` | 模型服务商策略 | + +#### 修改的关键文件 + +| 文件 | 变更行数 | 主要变化 | +|------|----------|----------| +| `ChatClientFactory.cs` | ±112 | 适配新统一接口 | +| `AnalystAgentFactory.cs` | ±58 | 使用 `StructuredOutputOptions` | +| `AnalystAgentBase.cs` | ±28 | 结构化输出选项集成 | +| `ModelProvider.cs` | ±66 | 策略模式重构 | +| `ModelProviderCatalog.cs` | ±41 | 适配新发现服务 | +| 各分析师 Agent(6 个) | 各 ±13-14 | 统一结构化输出配置方式 | + +### 2.2 聚合器加固(commit `5c876e3`) + +`AnalysisAggregatorExecutor` 有 ±89 行的实质重构: + +#### 改进:expectedCount 从 State 移到构造参数 + +```csharp +// 旧:从 WorkflowState 读取(跨 SuperStep 时序竞态) +var expectedCount = await context.ReadStateAsync( + WorkflowStateKeys.ExpectedAnalystCount, ...); + +// 新:构造函数直接传入 +public AnalysisAggregatorExecutor(int expectedAnalystCount, ...) +``` + +消除了通过 Workflow State 传递期望计数带来的跨 SuperStep 时序竞态。 + +#### 改进:失败消息分离 + +```csharp +// 新增 _failedMessages 列表,失败标记不再混入分析师载荷 +if (AnalystFailureMessages.IsFailureMarker(message.Text)) +{ + CollectMessage(_failedMessages, message, expectedCount); + continue; +} +``` + +失败分析师的消息被分离到独立列表,最终以 `[MISSING_DIMENSION_NOTE]` 系统提示形式附加给 Coordinator,而非混入正文。 + +#### 改进:诊断日志增强 + +新增非分析师消息过滤日志、批次计数日志、最终消息诊断日志,便于定位 barrier 消息丢失。 + +### 2.3 Coordinator 加固(commit `710a356`) + +`CoordinatorExecutor` 有 ±79 行变更: + +- 新增 `Stopwatch` 耗时计量 +- 新增输入/输出消息诊断日志(`LogMessageDiagnostics`) +- 新增 JSON 解析失败时的详细错误日志(文本长度、Content 类型、前 500 字符预览) +- 保留 `YieldOutputAsync` 显式调用(MAF `WithOutputFrom` auto-yield 在流式路径的时序缺陷仍存在) + +--- + +## 3. P0 问题逐项核查 + +### P0-1: MAF Fan-In Barrier 消息丢失 + +#### 状态:⚠️ 根因未修复,workaround 已加固 + +| 检查项 | 结果 | 证据 | +|--------|------|------| +| MAF 版本 | 1.13.0 → 1.17.0 | `Directory.Packages.props` | +| E2E 测试(无重试) | ❌ 3 个稳定失败 | `MarketAnalysisWorkflowTest` 3 个 `TestCategory("E2E")` | +| 降级测试(有重试) | ✅ 5 轮全通过 | `MarketAnalysisDegradationTest`,`maxAttempts=5/3` | +| 测试重试循环 | 仍在 | L36: `const int maxAttempts = 5` | +| 测试注释版本号 | **过时** | L33: `1.16.0`,应为 `1.17.0` | +| `CoordinatorExecutor.YieldOutputAsync` | 仍在 | L201: 注释 `WithOutputFrom 的 auto-yield 在 RunStreamingAsync 存在时序缺陷` | +| `AggregatorExecutor` 静默返回 | 仍在 | `collected+failed < expected` 时 return | + +#### 根因分析 + +MAF 的 `AddFanInBarrierEdge` 在分析师输出分属不同 superstep 时,barrier 释放后到达的消息被丢弃。`AnalysisAggregatorExecutor` 收到的消息数少于 `expectedCount` 时静默返回,不发送给 Coordinator,事件流无声终止。 + +#### 现有缓解措施 + +| 措施 | 位置 | 改进状态 | +|------|------|----------| +| `WithFailureIsolation` | 分析师包装 | 无变化 | +| `_sentToCoordinator` 锁 + Author 去重 | Aggregator | 无变化 | +| `expectedCount` 构造参数传入 | Aggregator | **改进**(从 State 移到构造参数) | +| 失败消息分离 | Aggregator | **新增**(`_failedMessages` 独立列表) | +| `YieldOutputAsync` 显式调用 | Coordinator | 无变化 | +| 终态检测 + `FriendlyException` | Workflow | 无变化 | +| 测试级有界重试 | 测试 | 无变化 | + +#### 结论 + +MAF 1.13.0 → 1.17.0 的升级**未修复 Fan-In barrier 的时序性消息丢失缺陷**。现有 workaround 通过 `expectedCount` 构造参数化和失败消息分离得到了加固,但根因仍在。测试注释中的版本号 `1.16.0` 已过时,应更新为 `1.17.0`。 + +#### 建议 + +1. 将测试注释版本号更新为 `1.17.0` +2. 在 Aggregator 内增加超时+降级发送机制:`collected+failed < expected` 时等待有限时间后降级发送已有结果 +3. 关注 MAF 后续版本 changelog 中是否提及 Fan-In barrier 修复 +4. 长期考虑替换工作流拓扑(与 P0-2 文件系统方案天然契合) + +--- + +### P0-2: Agent 结果通过对话历史传递 + +#### 状态:❌ 无变化 + +| 检查项 | 结果 | +|--------|------| +| `CoordinatorExecutor` 输入方式 | `List` 全文本拼接 | +| `session` 参数 | `null`(无状态一次性调用) | +| Token 成本 | 5 位分析师约 5-15 KB/次 | +| 文件系统引用机制 | 未实现 | +| `IAnalysisResultStore` | 不存在 | + +#### 当前数据流 + +``` +AnalystAgent (StructuredOutputOptions 产出结构化结果) + → 输出文本作为 ChatMessage 进入工作流消息流 + → AnalysisAggregatorExecutor 收集 ChatMessage + → CoordinatorExecutor 拼接为 List + 过滤 FunctionCallContent + → Coordinator.RunAsync(messages, session: null) — 完整文本进 prompt +``` + +#### 可复用的现有基础设施 + +| 设施 | 路径 | 可复用性 | +|------|------|----------| +| `ReportArchiveService` | `Services/Archive/ReportArchiveService.cs` | SQLite 持久化,模式可参考 | +| `AnalysisReportCache` | 内存缓存 | 太轻量 | +| `LocalImageStorageService` | `Rag/Services/` | **最佳模式参考**:GUID 文件名、目录隔离、安全校验 | + +#### 建议方案 + +新增 `IAnalysisResultStore`(文件系统实现),分析师写结果到 `{analysisDir}/{runId}/{analystName}.json`,工作流消息只传 `[ANALYST_REF]` 轻量引用,Coordinator 从 store 加载类型化输入。 + +--- + +### P0-3: RAG 双重解析路径 + +#### 状态:❌ 无变化(且应修正为 P2 优先级) + +本次升级未涉及 RAG 代码。 + +#### 重要修正 + +`docs/RAG模块分析报告.md` 将双解析标记为 **RAG-010 / P2**。RAG 模块有 3 个更紧急的 P0 问题: + +| ID | 问题 | 影响 | +|----|------|------| +| RAG-001 | `CosineDistance` 方向处理反了 | 去重 `OrderByDescending` 保留最大值,但距离越小越相关 → **排序可能反了** | +| RAG-002 | 文本向量维度无显式校验 | 非 1024 维模型写入/检索失败 | +| RAG-003 | 块失败被吞掉 | `catch` 只记 Warning,UI 仍显示成功 | + +--- + +## 4. P1 问题逐项核查 + +### P1-4: App.Services 层承载过重 + +#### 状态:❌ 无变化 + +| 指标 | 值 | +|------|-----| +| 文件数 | ~180 | +| `ServiceCollectionExtensions.cs` 行数 | ~500 行 | +| 关注点 | Agent/Trading/Infrastructure/业务服务全混合 | + +本次升级在 App.Services 中新增了 `ModelDiscoveryService`、`StructuredOutputOptions`、`ModelProviderPolicy` 等文件,但未改变项目结构。 + +--- + +### P1-5: IExchangeClientFactory 抽象缺失 + +#### 状态:❌ 无变化 + +`IExchangeClient` 接口本身干净且交易所无关。问题在组合根 `CreateRoutingExchangeClient()` 仍手动 `new` 12 个 Binance 对象。本次升级未涉及交易模块。 + +--- + +### P1-6: SK → MAF 迁移 + +#### 状态:🔄 有新进展,但 SK 包仍在 + +| 能力 | 之前 | 现在 | 变化 | +|------|------|------|------| +| Agent 定义 | MAF | MAF | 无变化 | +| 工作流编排 | MAF | MAF | 无变化 | +| 结构化输出 | 各 Agent 独立配置 | **`StructuredOutputOptions` 统一** | ✅ 改进 | +| 模型服务商适配 | `IModelProviderAdapter` + `AdapterFactory` | **统一到 `ModelDiscoveryService`** | ✅ 改进 | +| 重试机制 | `ResilientChatClient` 装饰器 | **已删除**,由其他机制替代 | ✅ 改进 | +| 交易函数守卫 | `TradingFunctionGuardMiddleware` | **已删除** | ✅ 简化 | +| Web 搜索 | SK (`ITextSearch`) | SK | ❌ 未迁移 | +| RAG 向量存储 | SK (`SqliteVec`) | SK | ❌ 未迁移 | +| RAG 文本分块 | SK (移植 `TextChunker`) | SK | ❌ 未迁移 | + +#### SK 包引用(版本未变) + +``` +Microsoft.SemanticKernel.Connectors.SqliteVec 1.74.0-preview +Microsoft.SemanticKernel.Plugins.Web 1.77.0-alpha +``` + +#### 结论 + +Agent 层的 SK → MAF 迁移已基本完成(Agent/工作流/结构化输出/模型适配全部在 MAF 上)。SK 残留在 RAG 基础设施和 Web 搜索,因 MAF 尚无稳定替代,本次升级未涉及。 + +--- + +### P1-7: 测试基线治理 + +#### 状态:🔄 有改善 + +| 指标 | 之前 | 现在 | +|------|------|------| +| 编译警告 | 未明确 | **0** | +| Unit 测试 | ~225 标记 | **262 全通过** | +| E2E 标记 | 未区分 | **新增 `TestCategory("E2E")`**(3 个测试) | +| `MSTestSettings.cs` | `MethodLevel` 并行 | 无变化 | +| runsettings | 无 | 仍无 | + +#### E2E 测试失败详情 + +`MarketAnalysisWorkflowTest` 中 3 个标记为 `TestCategory("E2E")` 的测试稳定失败,错误为 `所有分析师均执行失败`。这些测试依赖真实 LLM API,且无重试循环保护,受 P0-1 barrier 问题影响。 + +#### 待办 + +1. 增加 runsettings 文件,CI 默认只跑 `TestCategory=Unit` +2. 审计混合标记文件(`McpServiceTest` 等 Unit+Integration 混标) +3. 将 64 个基线失败分类为环境依赖 vs 代码缺陷 + +--- + +## 5. 升级前后对比总结 + +| 问题 | 升级前 | 升级后 | 变化 | +|------|--------|--------|------| +| **P0-1** Fan-In barrier | MAF 1.13.0,有 workaround | MAF 1.17.0,workaround 加固 | ⚠️ 根因未修复,workaround 改进 | +| **P0-2** 结果传递 | 全文本进 prompt | 同 | ❌ 无变化 | +| **P0-3** RAG 双解析 | 存在 | 同 | ❌ 无变化 | +| **P1-4** App.Services 过重 | 180 文件 | 略增 | ❌ 无变化 | +| **P1-5** IExchangeClientFactory | 缺失 | 同 | ❌ 无变化 | +| **P1-6** SK→MAF | SK 适配器残留 | **部分 SK 适配器已移除** | 🔄 有进展 | +| **P1-7** 测试基线 | 标记不统一 | **E2E 标记新增,0 警告** | 🔄 有改善 | + +--- + +## 6. 建议的后续行动 + +### 立即可做 + +| # | 行动 | 复杂度 | +|---|------|--------| +| 1 | 将 `MarketAnalysisDegradationTest.cs` 注释中的版本号从 `1.16.0` 更新为 `1.17.0` | 低 | +| 2 | 增加 `runsettings` 文件,CI 默认只跑 `TestCategory=Unit` | 低 | +| 3 | 验证 RAG-001(`CosineDistance` 方向是否反了) | 中 | + +### 短期(1-2 周) + +| # | 行动 | 复杂度 | +|---|------|--------| +| 4 | 在 `AnalysisAggregatorExecutor` 内增加超时+降级发送机制 | 中 | +| 5 | 实现 `IAnalysisResultStore`,Agent 结果改为文件系统引用传递 | 中 | +| 6 | 修复 RAG-001/002/003 三个 P0 级正确性问题 | 中 | + +### 中期(2-4 周) + +| # | 行动 | 复杂度 | +|---|------|--------| +| 7 | 收敛 RAG 双解析路径(DOCX 试点) | 中 | +| 8 | 引入 `IExchangeClientFactory` 抽象 | 中 | +| 9 | 审计测试标记,分类 64 个基线失败 | 中 | + +### 长期 + +| # | 行动 | 复杂度 | +|---|------|--------| +| 10 | 关注 MAF 1.18+ 是否修复 Fan-In barrier | — | +| 11 | SK → MAF RAG 基础设施迁移(待 MAF 提供稳定替代) | 高 | +| 12 | App.Services 项目拆分评估 | 高 | diff --git a/src/MarketAssistant.Agents/Analysts/AIAgentFailureIsolation.cs b/src/MarketAssistant.Agents/Analysts/AIAgentFailureIsolation.cs new file mode 100644 index 0000000..3ffb158 --- /dev/null +++ b/src/MarketAssistant.Agents/Analysts/AIAgentFailureIsolation.cs @@ -0,0 +1,143 @@ +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.Analysts; + +/// +/// 分析师失败隔离扩展:把分析师 Agent 的运行异常或空输出转换为 +/// 失败标记消息,使 Fan-In 聚合器 +/// 仍能从每位分析师(含失败者)收到恰好一条消息,实现 +/// 「单分析师失败不拖垮整次分析」的降级语义。 +/// 调用方取消(外部 CancellationToken)不被拦截,原样向上传播。 +/// +public static class AIAgentFailureIsolation +{ + /// + /// 包装分析师 Agent。失败时触发 回调并返回失败标记消息。 + /// + public static AIAgent WithFailureIsolation( + this AIAgent agent, + Action? onAnalysisFailed = null) + { + ArgumentNullException.ThrowIfNull(agent); + + var agentName = agent.Name ?? "UnknownAnalyst"; + return agent + .AsBuilder() + .Use( + runFunc: (messages, session, options, innerAgent, cancellationToken) => + RunGuardedAsync(agentName, onAnalysisFailed, messages, session, options, innerAgent, cancellationToken), + runStreamingFunc: (messages, session, options, innerAgent, cancellationToken) => + RunStreamingGuarded(agentName, onAnalysisFailed, messages, session, options, innerAgent, cancellationToken)) + .Build(); + } + + private static async Task RunGuardedAsync( + string agentName, + Action? onAnalysisFailed, + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + CancellationToken cancellationToken) + { + try + { + var response = await innerAgent.RunAsync(messages, session, options, cancellationToken) + .ConfigureAwait(false); + + if (!string.IsNullOrWhiteSpace(response.Text)) + return response; + + // 空输出与异常同等对待:没有结论的分析师不能假装分析成功 + throw new InvalidOperationException("模型未返回任何文本结论"); + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + onAnalysisFailed?.Invoke(ex); + return new AgentResponse(CreateFailureMessage(agentName, ex)); + } + } + + private static async IAsyncEnumerable RunStreamingGuarded( + string agentName, + Action? onAnalysisFailed, + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + AIAgent innerAgent, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var accumulatedText = new StringBuilder(); + + await using var enumerator = innerAgent + .RunStreamingAsync(messages, session, options, cancellationToken) + .GetAsyncEnumerator(cancellationToken); + + while (true) + { + var step = await StepAsync(enumerator, accumulatedText, cancellationToken).ConfigureAwait(false); + + if (step.Error != null) + { + onAnalysisFailed?.Invoke(step.Error); + yield return CreateFailureUpdate(agentName, step.Error); + yield break; + } + + if (!step.Moved) + break; + + if (step.Update is not null) + yield return step.Update; + } + + if (accumulatedText.Length == 0) + { + var error = new InvalidOperationException("模型未返回任何文本结论"); + onAnalysisFailed?.Invoke(error); + yield return CreateFailureUpdate(agentName, error); + } + } + + /// + /// 推进流式枚举并把异常带出,避免在迭代器方法内使用 try-catch 包住 yield return。 + /// + private static async Task StepAsync( + IAsyncEnumerator enumerator, + StringBuilder accumulatedText, + CancellationToken cancellationToken) + { + try + { + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + return new StreamStep(Moved: false, null, null); + } + catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) + { + return new StreamStep(Moved: false, null, ex); + } + + var update = enumerator.Current; + if (update.Text is { Length: > 0 } text) + accumulatedText.Append(text); + + return new StreamStep(Moved: true, update, null); + } + + private static ChatMessage CreateFailureMessage(string agentName, Exception exception) + => new(ChatRole.Assistant, AnalystFailureMessages.BuildFailureText(agentName, exception.Message)) + { + AuthorName = agentName + }; + + private static AgentResponseUpdate CreateFailureUpdate(string agentName, Exception exception) + => new(ChatRole.Assistant, AnalystFailureMessages.BuildFailureText(agentName, exception.Message)) + { + AuthorName = agentName + }; + + private sealed record StreamStep(bool Moved, AgentResponseUpdate? Update, Exception? Error); +} diff --git a/src/MarketAssistant.Agents/Analysts/AnalystAgentBase.cs b/src/MarketAssistant.Agents/Analysts/AnalystAgentBase.cs index 62aaafc..6bcfddd 100644 --- a/src/MarketAssistant.Agents/Analysts/AnalystAgentBase.cs +++ b/src/MarketAssistant.Agents/Analysts/AnalystAgentBase.cs @@ -1,4 +1,5 @@ using MarketAssistant.Agents.PromptConfiguration; +using MarketAssistant.Infrastructure.Core; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -32,13 +33,12 @@ protected AnalystAgentBase( float temperature, float topP, int? topK, - ChatResponseFormat? responseFormat, + Type resultType, + StructuredOutputMode structuredOutputMode, IList? tools, - AIContextProvider[]? aiContextProviders = null, - AgentSkillsProvider? skillsProvider = null) + AIContextProvider[]? aiContextProviders = null) : base(CreateInnerAgent(chatClient, instructions + DataIntegrityInstructions, name, description, - temperature, topP, topK, responseFormat, tools, - skillsProvider != null ? [skillsProvider, .. (aiContextProviders ?? [])] : aiContextProviders)) + temperature, topP, topK, resultType, structuredOutputMode, tools, aiContextProviders)) { } @@ -48,13 +48,13 @@ protected AnalystAgentBase( protected AnalystAgentBase( IChatClient chatClient, AnalystPromptConfig config, - ChatResponseFormat? responseFormat, + Type resultType, + StructuredOutputMode structuredOutputMode, IList? tools, - AIContextProvider[]? aiContextProviders = null, - AgentSkillsProvider? skillsProvider = null) + AIContextProvider[]? aiContextProviders = null) : this(chatClient, config.Instructions, config.Name, config.Description, config.Temperature, config.TopP, config.TopK, - responseFormat, tools, aiContextProviders, skillsProvider) + resultType, structuredOutputMode, tools, aiContextProviders) { } @@ -69,10 +69,18 @@ private static AIAgent CreateInnerAgent( float temperature, float topP, int? topK, - ChatResponseFormat? responseFormat, + Type resultType, + StructuredOutputMode structuredOutputMode, IList? tools, AIContextProvider[]? aiContextProviders) { + instructions = StructuredOutputOptions.AppendSchemaInstructions( + instructions, + resultType, + structuredOutputMode); + var responseFormat = StructuredOutputOptions.CreateResponseFormat( + resultType, + structuredOutputMode); var options = new ChatClientAgentOptions { Name = name, diff --git a/src/MarketAssistant.Agents/Analysts/AnalystFailureMessages.cs b/src/MarketAssistant.Agents/Analysts/AnalystFailureMessages.cs new file mode 100644 index 0000000..87e1f83 --- /dev/null +++ b/src/MarketAssistant.Agents/Analysts/AnalystFailureMessages.cs @@ -0,0 +1,45 @@ +namespace MarketAssistant.Agents.Analysts; + +/// +/// 分析师失败标记消息契约:由 包装器生成, +/// 由市场分析聚合器识别。用于在不中断 Fan-In 工作流的前提下传递分析师失败信息。 +/// +public static class AnalystFailureMessages +{ + /// + /// 失败标记前缀。选用方括号英文大写形式,避免与正常分析师输出冲突。 + /// + public const string FailureMarkerPrefix = "[ANALYST_FAILURE]"; + + /// + /// 聚合器附加给协调分析师的「维度缺失说明」前缀。 + /// + public const string MissingDimensionNotePrefix = "[MISSING_DIMENSION_NOTE]"; + + /// + /// 判断消息文本是否包含失败标记。 + /// 用 Contains 而非 StartsWith:流式路径下分析师可能先输出部分正文再抛异常, + /// 最终消息为「部分文本 + 标记」,前缀检测会漏判并把半成品当成功结论。 + /// + public static bool IsFailureMarker(string? text) + => text is not null && text.Contains(FailureMarkerPrefix, StringComparison.Ordinal); + + /// + /// 构建失败标记文本(单行,超长截断,防止异常堆栈污染工作流消息)。 + /// + public static string BuildFailureText(string agentName, string reason) + => $"{FailureMarkerPrefix} {agentName}: {NormalizeReason(reason)}"; + + /// + /// 构建附加给协调分析师的维度缺失说明,要求其在报告中如实标注数据局限。 + /// + public static string BuildMissingDimensionNote(IReadOnlyList failedAnalystDescriptions) + => $"{MissingDimensionNotePrefix} 以下分析师本次执行失败,其维度结论缺失:{string.Join(";", failedAnalystDescriptions)}。" + + "综合报告必须明确标注该数据局限,不得虚构缺失维度的结论。"; + + private static string NormalizeReason(string reason) + { + var normalized = reason.ReplaceLineEndings(" ").Trim(); + return normalized.Length > 200 ? normalized[..200] : normalized; + } +} diff --git a/src/MarketAssistant.Agents/Analysts/StructuredOutputOptions.cs b/src/MarketAssistant.Agents/Analysts/StructuredOutputOptions.cs new file mode 100644 index 0000000..f5ead81 --- /dev/null +++ b/src/MarketAssistant.Agents/Analysts/StructuredOutputOptions.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using MarketAssistant.Infrastructure.Core; +using Microsoft.Extensions.AI; + +namespace MarketAssistant.Agents.Analysts; + +/// +/// 根据模型能力配置结构化输出。Schema 统一由 Microsoft.Extensions.AI 生成。 +/// +public static class StructuredOutputOptions +{ + public static ChatResponseFormat? CreateResponseFormat( + Type resultType, + StructuredOutputMode mode, + JsonSerializerOptions? serializerOptions = null) + { + return mode switch + { + StructuredOutputMode.JsonSchema => ChatResponseFormat.ForJsonSchema(resultType, serializerOptions), + StructuredOutputMode.JsonObject => ChatResponseFormat.Json, + _ => null + }; + } + + public static string AppendSchemaInstructions( + string instructions, + Type resultType, + StructuredOutputMode mode, + JsonSerializerOptions? serializerOptions = null) + { + if (mode == StructuredOutputMode.JsonSchema) + return instructions; + + var schema = AIJsonUtilities.CreateJsonSchema( + resultType, + description: null, + hasDefaultValue: false, + defaultValue: null, + serializerOptions); + + return $$""" + {{instructions}} + + ## 结构化输出要求 + 仅返回一个符合下方 JSON Schema 的合法 JSON 对象。 + 不得输出 Markdown 代码块、解释文字、思考过程或 JSON 对象之外的内容。 + + JSON Schema: + {{schema.GetRawText()}} + """; + } +} 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..8d17d13 100644 --- a/src/MarketAssistant.Agents/Middleware/ConversationCompressionMiddleware.cs +++ b/src/MarketAssistant.Agents/Middleware/ConversationCompressionMiddleware.cs @@ -1,218 +1,45 @@ -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 中标记是否正在执行压缩的键(防止递归) + /// 统一压缩阈值。按主流 128K 上下文档位设定;真实窗口更小的模型在超长对话中可能先触发 + /// API 上下文超限,属已知取舍。 /// - private const string IsCompressingKey = "middleware:isCompressing"; + public const int DefaultMaxTokens = 128_000; + public const int DefaultMinimumPreservedGroups = 8; + public const string StateKey = "market-chat:compaction"; - /// - /// 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 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); - - return await innerAgent.RunAsync(processedMessages, session, options, cancellationToken) - .ConfigureAwait(false); - } + ArgumentNullException.ThrowIfNull(chatClient); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxTokens); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(minimumPreservedGroups); - /// - /// 流式 Agent Run 中间件 - /// - public async IAsyncEnumerable InvokeStreamingAsync( - IEnumerable messages, - AgentSession? session, - AgentRunOptions? options, - AIAgent innerAgent, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - var processedMessages = await TryCompressAsync(messages, session, cancellationToken); - - await foreach (var update in innerAgent.RunStreamingAsync(processedMessages, session, options, cancellationToken) - .ConfigureAwait(false)) - { - yield return update; - } - } - - /// - /// 检查消息列表是否需要压缩,如需要则执行压缩并返回处理后的消息 - /// - private async Task> TryCompressAsync( - IEnumerable messages, - AgentSession? session, - CancellationToken cancellationToken) - { - var messageList = messages as IList ?? messages.ToList(); + var strategy = new SummarizationCompactionStrategy( + chatClient, + CompactionTriggers.TokensExceed(maxTokens), + minimumPreservedGroups); - // 防止递归:压缩摘要生成过程中不再触发压缩 - 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; - } - - /// - /// 使用 LLM 生成对话摘要(设置防递归标记) - /// - private async Task GenerateSummaryAsync( - List messages, - AgentSession? session, - CancellationToken cancellationToken) - { - // 设置防递归标记 - 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}"); - } - - var response = await _chatClientFactory().GetResponseAsync( - [new ChatMessage(ChatRole.User, sb.ToString())], - new ChatOptions { Temperature = SummaryTemperature, MaxOutputTokens = SummaryMaxOutputTokens }, - cancellationToken); - - 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(); + return new CompactionProvider(strategy, StateKey, _loggerFactory); } } 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 deleted file mode 100644 index a22c21a..0000000 --- a/src/MarketAssistant.Agents/Middleware/TradingFunctionGuardMiddleware.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.Text.Json; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; - -namespace MarketAssistant.Agents.Middleware; - -/// -/// 交易工具调用守卫中间件,拦截 Agent 的函数调用实现: -/// 1. 敏感操作(PlaceOrder)审计日志 -/// 2. 单次 Agent 运行内的工具调用计数限制 -/// 3. 需人工确认时可终止调用链 -/// -public sealed class TradingFunctionGuardMiddleware -{ - private const int DefaultMaxToolCalls = 20; - - /// - /// 需人工确认的回调。返回 true 表示用户确认放行,false 表示拒绝。 - /// 未设置时自动拒绝需确认的操作。 - /// - public Func>? ConfirmationCallback { get; set; } - - private readonly ILogger _logger; - private readonly int _maxToolCalls; - // 使用 AsyncLocal 实现单次 Agent Run 内的计数隔离:同一个中间件实例服务多次 Run 时, - // 每次逻辑流拥有独立计数,避免跨 Run 累积。 - private readonly AsyncLocal _currentRunCallCount = new(); - - /// 单次 Agent 运行最大工具调用次数,防止无限循环(默认 20) - public TradingFunctionGuardMiddleware(ILogger logger, int maxToolCalls = DefaultMaxToolCalls) - { - _logger = logger; - _maxToolCalls = maxToolCalls; - } - - /// - /// Function Calling 中间件入口,通过 agent.AsBuilder().Use(this.InvokeAsync).Build() 附加 - /// - public async ValueTask InvokeAsync( - AIAgent agent, - FunctionInvocationContext context, - Func> next, - CancellationToken cancellationToken) - { - var functionName = context.Function.Name; - var callIndex = ++_currentRunCallCount.Value; - - // 1. 调用计数守卫 - if (callIndex > _maxToolCalls) - { - _logger.LogWarning( - "TradingAgent 工具调用次数 {Count} 超过上限 {Max},终止调用链", - callIndex, _maxToolCalls); - context.Terminate = true; - return $"错误:工具调用次数已达上限 {_maxToolCalls},请直接给出结论。"; - } - - // 2. 敏感操作审计 + Human-in-the-Loop - if (IsSensitiveOperation(functionName)) - { - var args = FormatArguments(context); - - _logger.LogInformation( - "交易敏感操作拦截 [{Function}] 参数: {Args} (第 {Index} 次调用)", - functionName, args, callIndex); - - if (ConfirmationCallback != null) - { - var approved = await ConfirmationCallback(functionName, args); - if (!approved) - { - _logger.LogInformation("用户拒绝交易操作: {Function}", functionName); - return $"操作已被用户取消: {functionName}"; - } - } - } - else - { - _logger.LogDebug("TradingAgent 工具调用: {Function} (第 {Index} 次)", functionName, callIndex); - } - - // 3. 执行实际调用 - var result = await next(context, cancellationToken); - - // 4. 执行后审计 - if (IsSensitiveOperation(functionName)) - { - _logger.LogInformation( - "交易操作完成 [{Function}] 结果摘要: {ResultType}", - functionName, result?.GetType().Name ?? "null"); - } - - return result; - } - - private static bool IsSensitiveOperation(string functionName) - { - return functionName is "PlaceOrderAsync" or "CancelOrderAsync"; - } - - private string FormatArguments(FunctionInvocationContext context) - { - try - { - // 记录实际传入参数(而非函数 Schema),用于审计追踪 - return context.Arguments != null - ? JsonSerializer.Serialize(context.Arguments) - : "N/A"; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "序列化交易工具参数失败,函数: {Function}", context.Function.Name); - return "N/A"; - } - } -} 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 4f813fe..0b0afbd 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/CoordinatorAnalystAgent.cs @@ -4,6 +4,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Agents.PromptConfiguration; using MarketAssistant.Agents.Tools; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Settings; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -22,12 +23,6 @@ namespace MarketAssistant.Services.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, @@ -35,20 +30,20 @@ public CoordinatorAnalystAgent( IUserSettingService userSettingService, ILoggerFactory loggerFactory, AnalystPromptLoader promptLoader, - AIContextProvider[]? aiContextProviders = null, - AgentSkillsProvider? skillsProvider = null) + StructuredOutputMode structuredOutputMode, + AIContextProvider[]? aiContextProviders = null) : base( chatClient, promptLoader.GetConfig("CoordinatorAnalyst"), - ResponseFormat, + typeof(CoordinatorResult), + structuredOutputMode, [.. tools, AIFunctionFactory.Create(searchTools.SearchAsync)], [ new InvestmentPreferenceContextProvider( userSettingService.CurrentSetting.InvestmentPreference, loggerFactory.CreateLogger()), .. (aiContextProviders ?? []) - ], - skillsProvider) + ]) { } } diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs index 8cd9bb0..0b1db22 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/FinancialAnalystAgent.cs @@ -18,28 +18,19 @@ namespace MarketAssistant.Services.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, AnalystPromptLoader promptLoader, - AIContextProvider[]? aiContextProviders = null, - AgentSkillsProvider? skillsProvider = null) + StructuredOutputMode structuredOutputMode, + AIContextProvider[]? aiContextProviders = null) : base( chatClient, promptLoader.GetConfig("FinancialAnalyst"), - ResponseFormat, + typeof(FinancialAnalysisResult), + structuredOutputMode, tools, - aiContextProviders, - skillsProvider) + aiContextProviders) { } - } diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs index 513039f..5cd73cb 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/FundamentalAnalystAgent.cs @@ -19,27 +19,19 @@ namespace MarketAssistant.Services.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, AnalystPromptLoader promptLoader, - AIContextProvider[]? aiContextProviders = null, - AgentSkillsProvider? skillsProvider = null) + StructuredOutputMode structuredOutputMode, + AIContextProvider[]? aiContextProviders = null) : base( chatClient, promptLoader.GetConfig("FundamentalAnalyst"), - ResponseFormat, + typeof(FundamentalAnalysisResult), + structuredOutputMode, tools, - aiContextProviders, - skillsProvider) + aiContextProviders) { } } diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs index a916334..7d81e4c 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/MarketSentimentAnalystAgent.cs @@ -19,27 +19,19 @@ namespace MarketAssistant.Services.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, AnalystPromptLoader promptLoader, - AIContextProvider[]? aiContextProviders = null, - AgentSkillsProvider? skillsProvider = null) + StructuredOutputMode structuredOutputMode, + AIContextProvider[]? aiContextProviders = null) : base( chatClient, promptLoader.GetConfig("MarketSentimentAnalyst"), - ResponseFormat, + typeof(MarketSentimentAnalysisResult), + structuredOutputMode, tools, - aiContextProviders, - skillsProvider) + aiContextProviders) { } } diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs index b07371e..4ada331 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/NewsEventAnalystAgent.cs @@ -3,6 +3,7 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Agents.PromptConfiguration; using MarketAssistant.Agents.Tools.Abstractions; +using MarketAssistant.Infrastructure.Core; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using System.ComponentModel; @@ -18,27 +19,19 @@ namespace MarketAssistant.Services.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, AnalystPromptLoader promptLoader, - AIContextProvider[]? aiContextProviders = null, - AgentSkillsProvider? skillsProvider = null) + StructuredOutputMode structuredOutputMode, + AIContextProvider[]? aiContextProviders = null) : base( chatClient, promptLoader.GetConfig("NewsEventAnalyst"), - ResponseFormat, + typeof(NewsEventAnalysisResult), + structuredOutputMode, tools, - aiContextProviders, - skillsProvider) + aiContextProviders) { } } diff --git a/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs b/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs index 9dd0d11..88b05a1 100644 --- a/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Analysts/TechnicalAnalystAgent.cs @@ -18,27 +18,19 @@ namespace MarketAssistant.Services.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, AnalystPromptLoader promptLoader, - AIContextProvider[]? aiContextProviders = null, - AgentSkillsProvider? skillsProvider = null) + StructuredOutputMode structuredOutputMode, + AIContextProvider[]? aiContextProviders = null) : base( chatClient, promptLoader.GetConfig("TechnicalAnalyst"), - ResponseFormat, + typeof(TechnicalAnalysisResult), + structuredOutputMode, tools, - aiContextProviders, - skillsProvider) + aiContextProviders) { } } diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs index 83e6899..3e7a59b 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/AnalyzeAssetsExecutor.cs @@ -1,9 +1,12 @@ using System.Text.Json; +using MarketAssistant.Agents.Analysts; using MarketAssistant.Agents.InvestmentSelection.Models; using MarketAssistant.Agents.InvestmentSelection.Strategies; 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; @@ -13,7 +16,7 @@ namespace MarketAssistant.Agents.InvestmentSelection.Executors; /// 统一的资产分析 Executor /// 对筛选出的资产进行深度分析并生成推荐报告 /// -public sealed class AnalyzeAssetsExecutor +public sealed partial class AnalyzeAssetsExecutor : Executor { private readonly IChatClientFactory _chatClientFactory; private readonly IServiceProvider _serviceProvider; @@ -28,14 +31,17 @@ public AnalyzeAssetsExecutor( IChatClientFactory chatClientFactory, IServiceProvider serviceProvider, ILogger logger) + : base("AnalyzeAssets") { _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( AssetScreeningResult input, + IWorkflowContext context, CancellationToken cancellationToken = default) { var originalRequest = input.OriginalRequest; @@ -65,18 +71,23 @@ public async ValueTask HandleAsync( var systemPrompt = formatter.GetAnalysisInstructions(originalRequest.IsNewsAnalysis); var userPrompt = BuildAnalysisPrompt(originalRequest, assetsDataText); + var runtime = _chatClientFactory.CreateRuntime(); + systemPrompt = StructuredOutputOptions.AppendSchemaInstructions( + systemPrompt, + typeof(InvestmentSelectionResult), + runtime.StructuredOutputMode, + JsonOptions); + var options = new ChatOptions { - ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: AIJsonUtilities.CreateJsonSchema(typeof(InvestmentSelectionResult)), - schemaName: "InvestmentSelectionResult", - schemaDescription: "投资选择分析结果,包含推荐资产列表和分析报告"), + ResponseFormat = StructuredOutputOptions.CreateResponseFormat( + typeof(InvestmentSelectionResult), + runtime.StructuredOutputMode, + JsonOptions), Temperature = 0.2f, MaxOutputTokens = 8000 }; - - var chatClient = _chatClientFactory.CreateClient(); - var response = await chatClient.GetResponseAsync( + var response = await runtime.Client.GetResponseAsync( [ new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, userPrompt) @@ -84,29 +95,36 @@ public 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); } } @@ -122,39 +140,52 @@ public 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}"); + var errors = StructuredOutputValidator.Validate(result).ToList(); + var expectedSelectionType = request.IsNewsAnalysis + ? SelectionType.NewsBased + : SelectionType.UserRequest; - if (string.IsNullOrWhiteSpace(result.AnalysisSummary)) - errors.Add("AnalysisSummary 不能为空"); - - 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; @@ -196,8 +227,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 f5902a7..babf2f2 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/GenerateCriteriaExecutor.cs @@ -1,8 +1,9 @@ -using System.Text.Json; +using MarketAssistant.Agents.Analysts; using MarketAssistant.Agents.InvestmentSelection.Models; using MarketAssistant.Agents.InvestmentSelection.Strategies; using MarketAssistant.Applications.AssetScreener.Models; using MarketAssistant.Infrastructure.Factories; +using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -13,29 +14,27 @@ namespace MarketAssistant.Agents.InvestmentSelection.Executors; /// 将用户需求或新闻内容转换为结构化的筛选条件 /// public sealed class GenerateCriteriaExecutor + : Executor where TCriteria : IScreeningCriteria { private readonly IChatClientFactory _chatClientFactory; private readonly ICriteriaGenerationStrategy _strategy; private readonly ILogger> _logger; - private static readonly JsonSerializerOptions SchemaOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; - public GenerateCriteriaExecutor( IChatClientFactory chatClientFactory, ICriteriaGenerationStrategy strategy, ILogger> logger) + : base($"GenerateCriteria_{strategy.SupportedMarketType}") { _chatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); _strategy = strategy ?? throw new ArgumentNullException(nameof(strategy)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async ValueTask HandleAsync( + public override async ValueTask HandleAsync( InvestmentSelectionWorkflowRequest input, + IWorkflowContext context, CancellationToken cancellationToken = default) { if (input.MarketType != _strategy.SupportedMarketType) @@ -56,21 +55,22 @@ public async ValueTask HandleAsync( string userPrompt = _strategy.BuildUserPrompt(input); - var chatClient = _chatClientFactory.CreateClient(); - - var schema = AIJsonUtilities.CreateJsonSchema(typeof(TCriteria), serializerOptions: SchemaOptions); + var runtime = _chatClientFactory.CreateRuntime(); + systemPrompt = StructuredOutputOptions.AppendSchemaInstructions( + systemPrompt, + typeof(TCriteria), + runtime.StructuredOutputMode); var chatOptions = new ChatOptions { - ResponseFormat = ChatResponseFormat.ForJsonSchema( - schema: schema, - schemaName: typeof(TCriteria).Name, - schemaDescription: $"包含筛选条件的{_strategy.SupportedMarketType}筛选参数"), + ResponseFormat = StructuredOutputOptions.CreateResponseFormat( + typeof(TCriteria), + runtime.StructuredOutputMode), Temperature = 0.1f, MaxOutputTokens = input.IsNewsAnalysis ? 3500 : 2000 }; - var response = await chatClient.GetResponseAsync( + var response = await runtime.Client.GetResponseAsync( [ new ChatMessage(ChatRole.System, systemPrompt), new ChatMessage(ChatRole.User, userPrompt) @@ -78,7 +78,7 @@ public 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/Executors/ScreenInvestmentTargetsExecutor.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs index 257c545..16f0f3f 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Executors/ScreenInvestmentTargetsExecutor.cs @@ -2,6 +2,7 @@ using MarketAssistant.Applications.AssetScreener; using MarketAssistant.Applications.AssetScreener.Models; using MarketAssistant.Infrastructure.Core; +using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.Logging; namespace MarketAssistant.Agents.InvestmentSelection.Executors; @@ -10,7 +11,7 @@ namespace MarketAssistant.Agents.InvestmentSelection.Executors; /// 步骤2: 执行投资标的筛选(共用,支持多市场) /// 通过 IAssetScreenerService 接口抽象,根据市场类型动态选择筛选服务 /// -public sealed class ScreenInvestmentTargetsExecutor +public sealed partial class ScreenInvestmentTargetsExecutor : Executor { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -18,13 +19,16 @@ public sealed class ScreenInvestmentTargetsExecutor public ScreenInvestmentTargetsExecutor( IServiceProvider serviceProvider, ILogger logger) + : base("ScreenInvestmentTargets") { _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - public async ValueTask HandleAsync( + [MessageHandler] + private async ValueTask HandleAsync( CriteriaGenerationResult input, + IWorkflowContext context, CancellationToken cancellationToken = default) { _logger.LogInformation("[步骤2/3] 执行投资标的筛选"); diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs index b8a0823..2eb01fb 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/InvestmentSelectionWorkflow.cs @@ -3,22 +3,23 @@ using MarketAssistant.Applications.AssetScreener.Models; using MarketAssistant.Applications.InvestmentSelection.Models; using MarketAssistant.Infrastructure.Core; +using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.Logging; namespace MarketAssistant.Agents.InvestmentSelection; /// -/// AI投资选择工作流,确定性三步骤流程: -/// 第1步: 生成筛选条件 → 第2步: 执行筛选 → 第3步: AI分析结果 -/// 根据市场类型(股票/虚拟币)选择对应的条件生成 Executor +/// AI 投资选择工作流,使用 Agent Framework Workflows 实现确定性三步骤流程: +/// 生成筛选条件 → 执行筛选 → AI 分析结果。 /// -public class InvestmentSelectionWorkflow +public class InvestmentSelectionWorkflow : IDisposable { private readonly GenerateCriteriaExecutor _generateStockCriteriaExecutor; private readonly GenerateCriteriaExecutor _generateCryptoCriteriaExecutor; private readonly ScreenInvestmentTargetsExecutor _screenTargetsExecutor; private readonly AnalyzeAssetsExecutor _analyzeAssetsExecutor; private readonly ILogger _logger; + private bool _disposed; public InvestmentSelectionWorkflow( GenerateCriteriaExecutor generateStockCriteriaExecutor, @@ -35,7 +36,7 @@ public InvestmentSelectionWorkflow( } /// - /// 执行基于用户需求的AI投资分析 + /// 执行基于用户需求的 AI 投资分析。 /// public async Task AnalyzeUserRequirementAsync( InvestmentRecommendationRequest request, @@ -58,7 +59,7 @@ public async Task AnalyzeUserRequirementAsync( } /// - /// 执行基于新闻内容的AI投资分析 + /// 执行基于新闻内容的 AI 投资分析。 /// public async Task AnalyzeNewsHotspotAsync( NewsBasedInvestmentRequest request, @@ -75,47 +76,104 @@ public async Task AnalyzeNewsHotspotAsync( return await ExecuteWorkflowAsync(workflowRequest, cancellationToken); } - /// - /// 执行完整的投资选择工作流(确定性三步骤) - /// private async Task ExecuteWorkflowAsync( InvestmentSelectionWorkflowRequest request, CancellationToken cancellationToken) { - _logger.LogInformation("开始执行投资选择工作流,市场类型: {MarketType},分析类型: {Type}", + _logger.LogInformation( + "开始执行投资选择工作流,市场类型: {MarketType},分析类型: {Type}", request.MarketType, request.IsNewsAnalysis ? "新闻热点" : "用户需求"); - // 步骤1: 根据市场类型选择对应的条件生成器 - CriteriaGenerationResult criteriaResult = request.MarketType switch + WorkflowBuilder workflowBuilder = request.MarketType switch { - MarketType.AShare => await _generateStockCriteriaExecutor.HandleAsync(request, cancellationToken), - MarketType.Crypto => await _generateCryptoCriteriaExecutor.HandleAsync(request, cancellationToken), + MarketType.AShare => new WorkflowBuilder(_generateStockCriteriaExecutor) + .AddEdge(_generateStockCriteriaExecutor, _screenTargetsExecutor) + .AddEdge(_screenTargetsExecutor, _analyzeAssetsExecutor) + .WithOutputFrom(_analyzeAssetsExecutor), + + MarketType.Crypto => new WorkflowBuilder(_generateCryptoCriteriaExecutor) + .AddEdge(_generateCryptoCriteriaExecutor, _screenTargetsExecutor) + .AddEdge(_screenTargetsExecutor, _analyzeAssetsExecutor) + .WithOutputFrom(_analyzeAssetsExecutor), + _ => throw new NotSupportedException($"不支持的市场类型: {request.MarketType}") }; - // 步骤2: 执行筛选 - var screeningResult = await _screenTargetsExecutor.HandleAsync(criteriaResult, cancellationToken); + var workflow = workflowBuilder.Build(); + await using Run run = await InProcessExecution.RunAsync( + workflow, + request, + cancellationToken: cancellationToken); - // 步骤3: AI 分析 - var finalResult = await _analyzeAssetsExecutor.HandleAsync(screeningResult, cancellationToken); + InvestmentSelectionResult? finalResult = null; - _logger.LogInformation("工作流完成,推荐数量: {Count}", - finalResult?.Recommendations?.Count ?? 0); + foreach (WorkflowEvent evt in run.NewEvents) + { + switch (evt) + { + case ExecutorInvokedEvent executorInvoked: + _logger.LogInformation("步骤开始: {ExecutorId}", executorInvoked.ExecutorId); + break; + case ExecutorCompletedEvent executorCompleted: + _logger.LogInformation("步骤完成: {ExecutorId}", executorCompleted.ExecutorId); + break; + case AgentResponseUpdateEvent: + break; + case WorkflowOutputEvent workflowOutput: + finalResult = workflowOutput.Data as InvestmentSelectionResult; + _logger.LogInformation( + "工作流完成,推荐数量: {Count}", + finalResult?.Recommendations?.Count ?? 0); + break; + case ExecutorFailedEvent executorFailed: + var failedMessage = executorFailed.Data?.Message ?? "未知错误"; + _logger.LogError( + executorFailed.Data, + "步骤失败: {ExecutorId}, 错误: {Error}", + executorFailed.ExecutorId, + failedMessage); + throw new FriendlyException(failedMessage); + case WorkflowErrorEvent workflowError: + var workflowErrorMessage = workflowError.Exception?.Message ?? "工作流内部发生未知错误"; + _logger.LogError( + workflowError.Exception, + "投资选择工作流发生严重错误: {Message}", + workflowErrorMessage); + throw new FriendlyException(workflowErrorMessage); + case WorkflowWarningEvent workflowWarning: + _logger.LogWarning("投资选择工作流警告: {Warning}", workflowWarning.Data); + break; + } + } return finalResult ?? CreateDefaultResult("工作流未返回结果"); } - private InvestmentSelectionResult CreateDefaultResult(string? problem = null) + private static InvestmentSelectionResult CreateDefaultResult(string? problem = null) { return new InvestmentSelectionResult { - Recommendations = new List(), + Recommendations = [], ConfidenceScore = 0, AnalysisSummary = problem ?? "分析过程中遇到问题,请稍后重试。", MarketEnvironmentAnalysis = "无可用分析", InvestmentAdvice = "建议稍后重试", - RiskWarnings = new List { "系统异常,请联系技术支持" } + RiskWarnings = ["系统异常,请联系技术支持"] }; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!_disposed && disposing) + { + _disposed = true; + } + } } diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs index 4bf2f09..1406ee3 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/CryptoCriteriaGenerationStrategy.cs @@ -1,3 +1,4 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; using MarketAssistant.Applications.AssetScreener.Models; using MarketAssistant.Infrastructure.Core; @@ -10,6 +11,22 @@ public class CryptoCriteriaGenerationStrategy : CriteriaGenerationStrategyBase "虚拟币"; + 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 + }; + public override MarketType SupportedMarketType => MarketType.Crypto; public override string BuildUserRequirementSystemPrompt() @@ -124,4 +141,76 @@ public override string BuildNewsAnalysisSystemPrompt() - price_change_7d > -20 """; } + + public override string BuildUserPrompt(InvestmentSelectionWorkflowRequest request) + { + if (request.IsNewsAnalysis) + { + return $""" + 新闻内容: + {request.Content} + + 推荐虚拟币数量限制:{request.MaxRecommendations} + + 请根据新闻内容生成虚拟币筛选条件。 + """; + } + else + { + return $""" + 用户需求: + {request.Content} + + 推荐虚拟币数量限制:{request.MaxRecommendations} + + 请根据用户需求生成虚拟币筛选条件。 + """; + } + } + + public override 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 3ff1026..df1374a 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/ICriteriaGenerationStrategy.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/ICriteriaGenerationStrategy.cs @@ -33,9 +33,9 @@ public interface ICriteriaGenerationStrategy where TCriteria : IScree string BuildUserPrompt(InvestmentSelectionWorkflowRequest request); /// - /// 反序列化筛选条件 + /// 反序列化并验证筛选条件,使用请求中的数量上限覆盖模型输出。 /// - TCriteria DeserializeCriteria(string json); + TCriteria DeserializeCriteria(string json, InvestmentSelectionWorkflowRequest request); } /// @@ -75,13 +75,15 @@ public virtual string BuildUserPrompt(InvestmentSelectionWorkflowRequest request """; } - public TCriteria DeserializeCriteria(string json) + public virtual TCriteria DeserializeCriteria(string json, InvestmentSelectionWorkflowRequest request) { var criteria = LlmJsonExtractor.Deserialize(json, DeserializationOptions); if (criteria == null) { throw new InvalidOperationException($"{AssetTypeLabel}筛选条件 JSON 解析失败"); } + + criteria.Limit = Math.Clamp(request.MaxRecommendations, 1, 10); return criteria; } } diff --git a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs index 520dc11..c995b61 100644 --- a/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs +++ b/src/MarketAssistant.App.Services/Agents/InvestmentSelection/Strategies/StockCriteriaGenerationStrategy.cs @@ -1,3 +1,4 @@ +using MarketAssistant.Agents.InvestmentSelection.Models; using MarketAssistant.Applications.AssetScreener.Models; using MarketAssistant.Infrastructure.Core; @@ -10,6 +11,20 @@ public class StockCriteriaGenerationStrategy : CriteriaGenerationStrategyBase "股票"; + 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 + }; + public override MarketType SupportedMarketType => MarketType.AShare; public override string BuildUserRequirementSystemPrompt() @@ -203,4 +218,86 @@ public override string BuildNewsAnalysisSystemPrompt() - roediluted > 8 """; } + + public override string BuildUserPrompt(InvestmentSelectionWorkflowRequest request) + { + if (request.IsNewsAnalysis) + { + return $""" + 新闻内容: + {request.Content} + + 推荐股票数量限制:{request.MaxRecommendations} + + 请根据新闻内容生成股票筛选条件。 + """; + } + else + { + return $""" + 用户需求: + {request.Content} + + 推荐股票数量限制:{request.MaxRecommendations} + + 请根据用户需求生成股票筛选条件。 + """; + } + } + + public override 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/AnalysisAggregatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs index 2699c0f..8d6622a 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisAggregatorExecutor.cs @@ -1,3 +1,4 @@ +using MarketAssistant.Agents.Analysts; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -5,45 +6,186 @@ namespace MarketAssistant.Agents.MarketAnalysis.Executors; /// -/// 分析聚合器 Executor(基于官方 Fan-In 模式) -/// 参考: https://learn.microsoft.com/zh-cn/agent-framework/tutorials/workflows/simple-concurrent-workflow -/// -/// Fan-In 工作原理: -/// 1. HandleAsync 会被多次调用(每个源 Agent 一次) -/// 2. 每次接收该 Agent 的消息列表(List) -/// 3. 内部维护列表收集所有消息 -/// 4. 收齐后使用 context.YieldOutputAsync 输出给下游 -/// +/// 分析聚合器 Executor。 +/// Fan-In barrier 会在各源至少产生一条消息后开始逐条转发, +/// 因此这里负责过滤初始 user 消息、按分析师收集最终文本,并在收齐后只发送一次。 +/// 失败隔离包装器()保证每位分析师 +/// (含失败者)恰好产出一条消息:失败者以 +/// 标记消息到达。这里把失败消息排除出协调分析师载荷,改附维度缺失说明。 +/// 全部失败时不派发 Coordinator(由工作流终局诊断给出明确错误)—— +/// Fan-In barrier 目标执行器抛出的异常不会以 ExecutorFailedEvent 暴露,不能依赖抛错中断流程。 /// +[SendsMessage(typeof(List))] public sealed partial class AnalysisAggregatorExecutor : Executor { + private const string CoordinatorExecutorId = "Coordinator"; + private const string SystemNoticeAuthorName = "SystemNotice"; + private readonly ILogger _logger; + private readonly int _expectedAnalystCount; + private readonly object _syncRoot = new(); + private readonly List _analystMessages = []; + private readonly List _failedMessages = []; + private bool _sentToCoordinator; + /// 期望的分析师消息总数(成功 + 失败标记)。 + /// 由工作流在构建时直接传入,避免经 Workflow State 传递带来的跨 SuperStep 时序竞态。 public AnalysisAggregatorExecutor( + int expectedAnalystCount, ILogger logger) : base(id: "AnalysisAggregator") { + _expectedAnalystCount = expectedAnalystCount; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } [MessageHandler] - private async ValueTask> HandleAsync( + private async ValueTask HandleAsync( List messages, IWorkflowContext context, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(messages); - // AddFanInBarrierEdge 会在上游全部完成后,将聚合后的消息列表一次性传入。 - var expectedCount = await context.ReadStateAsync( - WorkflowStateKeys.ExpectedAnalystCount, - WorkflowStateKeys.Scope, + var expectedCount = _expectedAnalystCount; + + List? readyMessages = null; + lock (_syncRoot) + { + foreach (var message in messages) + { + if (!IsAnalystTextMessage(message)) + { + _logger.LogDebug( + "聚合器忽略非分析师文本消息: Role: {Role}, Author: {Author}, TextLength: {TextLength}, ContentTypes: [{ContentTypes}]", + message.Role, + message.AuthorName ?? "null", + message.Text?.Length ?? 0, + string.Join(", ", message.Contents.Select(content => content.GetType().Name))); + continue; + } + + if (AnalystFailureMessages.IsFailureMarker(message.Text)) + { + CollectMessage(_failedMessages, message, expectedCount); + continue; + } + + CollectMessage(_analystMessages, message, expectedCount); + } + + var collectedCount = _analystMessages.Count; + var failedCount = _failedMessages.Count; + _logger.LogInformation( + "分析师汇聚收到消息,本批: {BatchCount},有效分析师文本: {CollectedCount},失败: {FailedCount},期望: {ExpectedCount},已发送: {Sent}", + messages.Count, + collectedCount, + failedCount, + expectedCount, + _sentToCoordinator); + + if (_sentToCoordinator || expectedCount <= 0 || collectedCount + failedCount < expectedCount) + return; + + _sentToCoordinator = true; + + if (collectedCount == 0) + { + // 全部失败:不派发 Coordinator(报告无从生成), + // 工作流事件流自然结束,由 AnalyzeAsync 终局诊断给出明确错误 + _logger.LogError("聚合器判定所有分析师均执行失败,不派发协调分析师: {Failures}", FormatFailures()); + return; + } + + readyMessages = + [ + .. _analystMessages, + new ChatMessage(ChatRole.Assistant, BuildMissingDimensionNote()) + { + AuthorName = SystemNoticeAuthorName + } + ]; + } + + if (readyMessages == null) + return; + + LogMessageDiagnostics(readyMessages); + await context.SendMessageAsync( + readyMessages, + CoordinatorExecutorId, cancellationToken); _logger.LogInformation( - "已收集 {Expected} 位分析师的结果,共 {TotalMessages} 条消息(Context Hash: {ContextHash})", - expectedCount, messages.Count, context.GetHashCode()); + "分析师已收齐({SuccessCount} 成功 / {FailedCount} 失败),发送 {Count} 条最终文本给 Coordinator", + _analystMessages.Count, + _failedMessages.Count, + readyMessages.Count); + } + + /// + /// 按作者去重收集消息:同一作者的重复消息以最后一条为准; + /// 无作者消息仅在未超出期望数量时兜底收集。 + /// + private static void CollectMessage(List target, ChatMessage message, int expectedCount) + { + var normalized = new ChatMessage(ChatRole.Assistant, message.Text!) + { + AuthorName = message.AuthorName + }; + + var author = message.AuthorName; + var existingIndex = author == null + ? -1 + : target.FindIndex(existing => + string.Equals(existing.AuthorName, author, StringComparison.Ordinal)); + + if (existingIndex >= 0) + target[existingIndex] = normalized; + else if (author != null || target.Count < expectedCount) + target.Add(normalized); + } + + private string FormatFailures() + => string.Join(";", _failedMessages.Select(message => + $"{message.AuthorName ?? "未知分析师"}({ExtractFailureReason(message.Text)})")); + + private static string ExtractFailureReason(string? markerText) + { + if (string.IsNullOrEmpty(markerText)) + return "未知错误"; - return messages; + // 标记格式见 AnalystFailureMessages.BuildFailureText:前缀 + 空格 + "AgentName: reason"。 + // 流式失败时标记前可能拼接了部分正文,故先定位标记再取其后的 "AgentName: reason" 段。 + var markerIndex = markerText.IndexOf(AnalystFailureMessages.FailureMarkerPrefix, StringComparison.Ordinal); + if (markerIndex < 0) + return markerText; + + var afterMarker = markerText[(markerIndex + AnalystFailureMessages.FailureMarkerPrefix.Length)..].TrimStart(); + var separatorIndex = afterMarker.IndexOf(": ", StringComparison.Ordinal); + return separatorIndex >= 0 ? afterMarker[(separatorIndex + 2)..] : afterMarker; + } + + private string BuildMissingDimensionNote() + => AnalystFailureMessages.BuildMissingDimensionNote( + _failedMessages.Select(message => + $"{message.AuthorName ?? "未知分析师"}({ExtractFailureReason(message.Text)})").ToList()); + + private static bool IsAnalystTextMessage(ChatMessage message) + => message.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(message.Text); + + private void LogMessageDiagnostics(IReadOnlyList messages) + { + for (var index = 0; index < messages.Count; index++) + { + var message = messages[index]; + _logger.LogInformation( + "分析师最终消息 [{Index}/{Count}] Author: {Author}, TextLength: {TextLength}, ContentTypes: [{ContentTypes}]", + index + 1, + messages.Count, + message.AuthorName ?? "null", + message.Text?.Length ?? 0, + string.Join(", ", message.Contents.Select(content => content.GetType().Name))); + } } } diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs index f9dbf63..e1461aa 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/AnalysisDispatcherExecutor.cs @@ -10,7 +10,7 @@ namespace MarketAssistant.Agents.MarketAnalysis.Executors; /// /// 职责: /// 1. 接收标的代码 -/// 2. 保存必要的配置到 workflow state +/// 2. 保存标的代码到 workflow state(供 CoordinatorExecutor 读取) /// 3. 广播 ChatMessage 给所有分析师(通过 SendMessageAsync) /// 4. 广播 TurnToken 触发分析师开始处理(AIAgent 收到 ChatMessage 后不会自动处理,必须收到 TurnToken 才会调用 LLM) /// @@ -20,15 +20,12 @@ public sealed partial class AnalysisDispatcherExecutor : Executor { private const string AnalysisPromptTemplate = "请对标的 {0} 进行专业分析,提供投资建议。"; - private readonly int _expectedAnalystCount; private readonly ILogger _logger; public AnalysisDispatcherExecutor( - int expectedAnalystCount, ILogger logger) : base("AnalysisDispatcher") { - _expectedAnalystCount = expectedAnalystCount; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -45,9 +42,7 @@ private async ValueTask HandleAsync( try { - _logger.LogInformation( - "分发器开始处理标的 {AssetSymbol} 的分析请求,期望 {Count} 位分析师", - assetSymbol, _expectedAnalystCount); + _logger.LogInformation("分发器开始处理标的 {AssetSymbol} 的分析请求", assetSymbol); // https://github.com/microsoft/agent-framework/issues/2162 // 保存配置到 workflow state(显式指定 scope 确保跨 Executor 可见) @@ -56,11 +51,6 @@ await context.QueueStateUpdateAsync( assetSymbol, WorkflowStateKeys.Scope, cancellationToken); - await context.QueueStateUpdateAsync( - WorkflowStateKeys.ExpectedAnalystCount, - _expectedAnalystCount, - WorkflowStateKeys.Scope, - cancellationToken); // 1. 广播 ChatMessage 给所有分析师(AIAgent 会缓存消息但不会开始处理) string prompt = string.Format(AnalysisPromptTemplate, assetSymbol); @@ -74,9 +64,7 @@ await context.SendMessageAsync( new TurnToken(emitEvents: true), cancellationToken); - _logger.LogInformation( - "分发器已将分析任务分发给 {Count} 位分析师,标的: {AssetSymbol}", - _expectedAnalystCount, assetSymbol); + _logger.LogInformation("分发器已将分析任务分发给各分析师,标的: {AssetSymbol}", assetSymbol); } catch (Exception ex) { diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs index 6a074ce..fae4b93 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/Executors/CoordinatorExecutor.cs @@ -2,11 +2,12 @@ using MarketAssistant.Agents.MarketAnalysis.Models; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; -using MarketAssistant.Services.Agents.Analysts; +using MarketAssistant.Infrastructure.Providers; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; +using System.Diagnostics; using System.Text.Json.Serialization; namespace MarketAssistant.Agents.MarketAnalysis.Executors; @@ -31,19 +32,19 @@ public sealed partial class CoordinatorExecutor : Executor } }; + /// 首次调用 + 1 次修复重试 + private const int MaxAttempts = 2; + + /// 校验错误中 null 违规的标记片段,用于区分硬伤与可降级的值违规 + private const string NullViolationMarker = "值不能为空"; + 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] @@ -76,12 +77,20 @@ private async ValueTask HandleAsync( try { + LogMessageDiagnostics("协调分析师收到上游消息", analystMessages); + // 过滤消息:移除包含工具调用(FunctionCallContent)和结果(FunctionResultContent)的消息 // 这样可以显著减少 Token 消耗,并避免 Coordinator 被中间过程干扰 var filteredMessages = analystMessages .Where(m => !m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent)) .ToList(); + _logger.LogInformation( + "协调分析师输入过滤完成,原始消息: {OriginalCount},保留消息: {FilteredCount}", + analystMessages.Count, + filteredMessages.Count); + LogMessageDiagnostics("协调分析师过滤后输入", filteredMessages); + // 所有分析师均无文本输出(仅产生工具调用)时,无法生成有意义的综合报告 if (filteredMessages.Count == 0) { @@ -98,51 +107,133 @@ private async ValueTask HandleAsync( $"请基于以上所有分析师的专业意见,为标的 {assetSymbol} 生成一份综合分析报告。") }; - // 使用带结构化输出的 ChatClientAgent 运行 - // 重试由 ResilientChatClient 装饰器统一提供,此处无需额外重试管道 - // session: null — 无状态一次性调用,无需会话累积 - var agentResponse = await _coordinatorAgent.RunAsync( - messages, - session: null, - options: null, - cancellationToken); + _logger.LogInformation( + "调用协调分析师,输入消息: {MessageCount},输入文本总长度: {TextLength}", + messages.Count, + messages.Sum(message => message.Text?.Length ?? 0)); - // 提取协调分析师的回复(最后一条 Assistant 消息) - var coordinatorMessage = agentResponse.Messages - .LastOrDefault(m => m.Role == ChatRole.Assistant); + // 修复重试循环:解析/校验失败时将错误清单反馈给模型(同一对话上下文)重试, + // 仍失败则分层降级(null 硬伤抛异常,值违规钳制后接受),避免作废整个分析流程。 + CoordinatorResult? coordinatorResult = null; + ChatMessage? coordinatorMessage = null; + var validationErrors = new List(); + var parseFailed = false; - if (coordinatorMessage == null) + for (var attempt = 1; attempt <= MaxAttempts; attempt++) { - throw new InvalidOperationException("协调分析师未能生成报告"); - } + // 使用带结构化输出的 ChatClientAgent 运行。 + // session: null — 无状态一次性调用,无需会话累积。 + var startedAt = Stopwatch.GetTimestamp(); + var agentResponse = await _coordinatorAgent.RunAsync( + messages, + session: null, + options: null, + cancellationToken); + var elapsed = Stopwatch.GetElapsedTime(startedAt); - // 从协调分析师的回复文本中反序列化结构化结果 - // 某些 LLM 即使指定了 ForJsonSchema 也可能在 JSON 前后输出多余文本(前缀词、markdown 代码块等), - // 使用 LlmJsonExtractor 进行多层兜底解析(直接解析 → 剥离 markdown → Utf8JsonReader 精确定位) - var rawText = coordinatorMessage.Text ?? string.Empty; + _logger.LogInformation( + "协调分析师调用完成(第 {Attempt}/{MaxAttempts} 次),耗时: {ElapsedMs} ms,响应消息: {MessageCount},聚合文本长度: {TextLength}", + attempt, + MaxAttempts, + elapsed.TotalMilliseconds, + agentResponse.Messages.Count, + agentResponse.Text?.Length ?? 0); + LogMessageDiagnostics("协调分析师原始响应", agentResponse.Messages); - CoordinatorResult? coordinatorResult; - try - { - coordinatorResult = LlmJsonExtractor.Deserialize(rawText, JsonOptions); - } - catch (JsonException jsonEx) - { - _logger.LogError(jsonEx, - "协调分析师 JSON 解析失败,原始文本前 500 字符: {Preview}", - rawText.Length > 500 ? rawText[..500] : rawText); - throw new InvalidOperationException( - $"协调分析师返回的数据无法解析为结构化结果: {jsonEx.Message}", jsonEx); + // 提取协调分析师的回复(最后一条 Assistant 消息) + coordinatorMessage = agentResponse.Messages + .LastOrDefault(m => m.Role == ChatRole.Assistant); + + if (coordinatorMessage == null) + { + throw new InvalidOperationException("协调分析师未能生成报告"); + } + + // 从协调分析师的回复文本中反序列化结构化结果 + // 部分兼容模型即使启用 JsonObject 仍可能在 JSON 前后输出多余文本, + // 使用 LlmJsonExtractor 进行多层兜底解析(直接解析 → 剥离 markdown → Utf8JsonReader 精确定位) + var rawText = coordinatorMessage.Text ?? string.Empty; + _logger.LogInformation( + "准备解析协调分析师最后一条 Assistant 消息,文本长度: {TextLength},Content 类型: [{ContentTypes}]", + rawText.Length, + string.Join(", ", coordinatorMessage.Contents.Select(content => content.GetType().Name))); + _logger.LogDebug( + "协调分析师最后一条 Assistant 消息预览: {Preview}", + CreatePreview(rawText)); + + // 解析与校验错误统一收集,供修复反馈与最终降级决策使用; + // 每轮重置,确保状态仅来自最近一次模型输出 + coordinatorResult = null; + parseFailed = false; + validationErrors = []; + + try + { + coordinatorResult = LlmJsonExtractor.Deserialize(rawText, JsonOptions); + } + catch (JsonException jsonEx) + { + _logger.LogError(jsonEx, + "协调分析师 JSON 解析失败,原始文本长度: {TextLength},前 500 字符: {Preview}", + rawText.Length, + CreatePreview(rawText)); + parseFailed = true; + validationErrors.Add($"JSON 解析失败: {jsonEx.Message}"); + } + + if (coordinatorResult == null && !parseFailed) + { + _logger.LogError( + "协调分析师结构化解析结果为空,最后一条 Assistant 文本长度: {TextLength},响应总消息数: {MessageCount}", + rawText.Length, + agentResponse.Messages.Count); + parseFailed = true; + validationErrors.Add("结构化输出为空"); + } + + if (coordinatorResult != null) + { + validationErrors.AddRange(StructuredOutputValidator.Validate(coordinatorResult)); + } + + if (validationErrors.Count == 0) + { + break; + } + + if (attempt < MaxAttempts) + { + _logger.LogWarning( + "协调分析师第 {Attempt}/{MaxAttempts} 次返回未通过校验,发起修复重试: {Errors}", + attempt, + MaxAttempts, + string.Join("; ", validationErrors)); + messages = [.. messages, coordinatorMessage, + new ChatMessage(ChatRole.User, BuildRepairFeedback(rawText, validationErrors, parseFailed))]; + } } - if (coordinatorResult == null) + if (validationErrors.Count > 0) { - throw new InvalidOperationException("协调分析师未能返回结构化数据"); + // 解析失败或 null 类硬伤:结果不可用,或下游(卡片解析/ViewModel)假设属性非空,保持失败 + if (coordinatorResult == null || + validationErrors.Any(error => error.Contains(NullViolationMarker, StringComparison.Ordinal))) + { + throw new InvalidOperationException( + $"协调分析师返回的数据不符合约束: {string.Join("; ", validationErrors)}"); + } + + // 降级接受:值违规(越界/长度/数量/枚举)不作废整个分析流程,钳制数值字段后继续 + _logger.LogWarning( + "协调分析师结果经 {MaxAttempts} 次调用仍未通过校验,降级接受: {Errors}", + MaxAttempts, + string.Join("; ", validationErrors)); + ClampNumericScores(coordinatorResult); } _logger.LogInformation( "成功获取协调分析师的结构化数据,综合评分: {Score},最终评级: {Rating}", - coordinatorResult.OverallScore, + coordinatorResult!.OverallScore, coordinatorResult.InvestmentRating); // 创建最终报告 @@ -152,7 +243,7 @@ private async ValueTask HandleAsync( AssetSymbol = assetSymbol, AnalystMessages = new List(filteredMessages) { - coordinatorMessage + coordinatorMessage! }, CoordinatorResult = coordinatorResult, CreatedAt = DateTime.UtcNow @@ -178,4 +269,88 @@ private async ValueTask HandleAsync( } } + /// + /// 构建修复反馈消息:将校验错误连同上一轮输出反馈给模型,要求其仅修正问题并重新输出完整 JSON。 + /// + private static string BuildRepairFeedback(string rawText, IReadOnlyList errors, bool parseFailed) + { + if (parseFailed) + { + return $""" + 你上一轮返回的内容无法解析为 JSON:{string.Join("; ", errors)} + + 请重新输出一个符合 JSON Schema 的合法 JSON 对象,不要输出任何解释文字或 Markdown 代码块。 + + 你上一轮返回的内容: + {rawText} + """; + } + + var errorList = string.Join(Environment.NewLine, errors.Select(error => $"- {error}")); + + return $""" + 你上一轮返回的 JSON 未通过约束校验,请修正后重新输出。 + + 要求: + 1. 仅修正下方校验错误对应的字段值,其余字段保持原值不变 + 2. 仅输出修正后的完整 JSON 对象,不要输出任何解释文字、思考过程或 Markdown 代码块 + 3. 所有字段约束以先前提供的 JSON Schema 为准 + + 校验错误: + {errorList} + + 你上一轮返回的内容: + {rawText} + """; + } + + /// + /// 降级路径下将数值评分字段钳制到约束范围,避免越界数值误导 UI 展示与 AI 交易决策上下文。 + /// + private static void ClampNumericScores(CoordinatorResult result) + { + result.OverallScore = Math.Clamp(result.OverallScore, 1, 10); + result.ConfidencePercentage = Math.Clamp(result.ConfidencePercentage, 0, 100); + result.DimensionScores.Fundamental = Math.Clamp(result.DimensionScores.Fundamental, 1, 10); + result.DimensionScores.Technical = Math.Clamp(result.DimensionScores.Technical, 1, 10); + result.DimensionScores.Financial = Math.Clamp(result.DimensionScores.Financial, 1, 10); + result.DimensionScores.Sentiment = Math.Clamp(result.DimensionScores.Sentiment, 1, 10); + result.DimensionScores.News = Math.Clamp(result.DimensionScores.News, 1, 10); + } + + private void LogMessageDiagnostics(string stage, IEnumerable messages) + { + var messageList = messages as IList ?? messages.ToList(); + for (var index = 0; index < messageList.Count; index++) + { + var message = messageList[index]; + var text = message.Text ?? string.Empty; + var contentTypes = string.Join(", ", message.Contents.Select(content => content.GetType().Name)); + _logger.LogInformation( + "{Stage} [{Index}/{Count}] Role: {Role}, Author: {Author}, TextLength: {TextLength}, ContentTypes: [{ContentTypes}]", + stage, + index + 1, + messageList.Count, + message.Role, + message.AuthorName ?? "null", + text.Length, + contentTypes); + _logger.LogDebug( + "{Stage} [{Index}/{Count}] 文本预览: {Preview}", + stage, + index + 1, + messageList.Count, + CreatePreview(text)); + } + } + + private static string CreatePreview(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + return ""; + + var normalized = text.ReplaceLineEndings(" "); + return normalized.Length > 500 ? normalized[..500] : normalized; + } + } diff --git a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs index a74282e..6ae2159 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/MarketAnalysisWorkflow.cs @@ -2,15 +2,16 @@ 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.Agents.Analysts; using MarketAssistant.Services.Settings; using MarketAssistant.Services.Trading; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.ComponentModel; +using System.Diagnostics; using System.Reflection; namespace MarketAssistant.Agents.MarketAnalysis; @@ -21,10 +22,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; @@ -42,26 +42,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)); @@ -70,15 +66,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 @@ -97,21 +111,71 @@ 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); + + // 运行期降级记录:失败隔离包装器在分析师运行失败时回调, + // 这里维护已降级名单并向 UI 发布进度事件(与聚合器的失败计数相互独立) + var degradedAnalysts = new List(); + var degradationGate = new object(); + var totalForProgress = 0; + + var analystAgents = CreateAnalystAgents( + enabledAnalysts, + marketSnapshot, + runtime, + onAnalystDegraded: (analystType, exception) => + { + var displayName = GetAnalystDisplayNameFromType(analystType); + List failedSnapshot; + lock (degradationGate) + { + if (!degradedAnalysts.Contains(displayName)) + degradedAnalysts.Add(displayName); + failedSnapshot = [.. degradedAnalysts]; + } + + _logger.LogWarning(exception, "分析师执行失败,降级继续其余分析师: {Analyst}", displayName); + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs + { + StageDescription = $"{displayName} 分析失败,继续其他分析", + IsInProgress = true, + TotalAnalysts = totalForProgress, + FailedAnalysts = failedSnapshot + }); + }); var failedAnalystNames = analystAgents.FailedTypes .Select(GetAnalystDisplayNameFromType) .ToList(); var createdAgents = analystAgents.Agents; + totalForProgress = createdAgents.Count; if (createdAgents.Count == 0) { throw new InvalidOperationException("所有分析师创建失败,无法执行分析"); } - // 构建工作流(传入分析师数量) - var workflow = BuildWorkflow(createdAgents.Count, createdAgents); - - OnProgressChanged(new AnalysisProgressEventArgs + // 同一次 Run 的分析师与 Coordinator 绑定同一个 Runtime Client。 + var coordinatorAgent = _analystAgentFactory.CreateAnalyst( + typeof(CoordinatorAnalystAgent), + runtime); + var coordinatorExecutor = new CoordinatorExecutor( + coordinatorAgent, + _loggerFactory.CreateLogger()); + var aggregatorExecutor = new AnalysisAggregatorExecutor( + createdAgents.Count, + _loggerFactory.CreateLogger()); + + // 构建工作流(所有 Executor 均为 Run 局部实例) + var agentNameToDisplayName = analystAgents.NameToDisplayName; + var workflow = BuildWorkflow( + createdAgents, + aggregatorExecutor, + coordinatorExecutor); + + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = $"{createdAgents.Count} 位分析师正在并发分析", IsInProgress = true, @@ -120,12 +184,21 @@ public async Task AnalyzeAsync( }); // 执行工作流(流式处理) - var finalReport = await ExecuteWorkflowAsync(workflow, assetSymbol, createdAgents.Count, cancellationToken); + var finalReport = await ExecuteWorkflowAsync( + workflow, + runId, + assetSymbol, + createdAgents.Count, + agentNameToDisplayName, + degradedAnalysts, + 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 @@ -133,14 +206,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 @@ -154,8 +230,11 @@ public async Task AnalyzeAsync( /// private async Task ExecuteWorkflowAsync( Workflow workflow, + Guid runId, string assetSymbol, int analystCount, + IReadOnlyDictionary agentNameToDisplayName, + List degradedAnalysts, CancellationToken cancellationToken) { MarketAnalysisReport? finalReport = null; @@ -166,6 +245,7 @@ private async Task ExecuteWorkflowAsync( string? lastCompletedStep = null; // 追踪当前正在运行的 Executor,超时时用于定位卡住的分析师 var activeExecutors = new HashSet(); + var executorStartedAt = new Dictionary(StringComparer.Ordinal); // 执行工作流(流式处理) // 初始输入 assetSymbol 会触发 Dispatcher,Dispatcher 再通过 context.SendMessageAsync @@ -183,17 +263,21 @@ private async Task ExecuteWorkflowAsync( { case ExecutorInvokedEvent executorInvoked: activeExecutors.Add(executorInvoked.ExecutorId); - _logger.LogDebug("工作流步骤开始: {ExecutorId}", executorInvoked.ExecutorId); + executorStartedAt[executorInvoked.ExecutorId] = Stopwatch.GetTimestamp(); + _logger.LogInformation( + "工作流步骤开始: {ExecutorId}, 输入类型: {InputType}", + executorInvoked.ExecutorId, + executorInvoked.Data?.GetType().FullName ?? "null"); string stageName = GetExecutorNamePrefix(executorInvoked.ExecutorId) switch { "AnalysisDispatcher" => "正在分发分析任务", "AnalysisAggregator" => "正在聚合分析结果", "Coordinator" => "正在生成综合报告", - _ => $"{GetDisplayNameForExecutorId(executorInvoked.ExecutorId)} 正在分析" + _ => $"{GetDisplayNameForExecutorId(executorInvoked.ExecutorId, agentNameToDisplayName)} 正在分析" }; - OnProgressChanged(new AnalysisProgressEventArgs + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = stageName, IsInProgress = true, @@ -204,13 +288,20 @@ private async Task ExecuteWorkflowAsync( case ExecutorCompletedEvent executorComplete: activeExecutors.Remove(executorComplete.ExecutorId); - lastCompletedStep = GetDisplayNameForExecutorId(executorComplete.ExecutorId); - _logger.LogDebug("工作流步骤完成: {ExecutorId}", executorComplete.ExecutorId); + lastCompletedStep = GetDisplayNameForExecutorId(executorComplete.ExecutorId, agentNameToDisplayName); + var elapsed = executorStartedAt.Remove(executorComplete.ExecutorId, out var startedAt) + ? Stopwatch.GetElapsedTime(startedAt) + : TimeSpan.Zero; + _logger.LogInformation( + "工作流步骤完成: {ExecutorId}, 耗时: {ElapsedMs} ms, 结果类型: {ResultType}", + executorComplete.ExecutorId, + elapsed.TotalMilliseconds, + executorComplete.Data?.GetType().FullName ?? "null"); if (IsAnalystExecutor(executorComplete.ExecutorId)) { completedAnalysts++; - OnProgressChanged(new AnalysisProgressEventArgs + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = $"{lastCompletedStep} 分析完成", IsInProgress = true, @@ -249,7 +340,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}", @@ -262,7 +353,7 @@ private async Task ExecuteWorkflowAsync( $"分析流程关键环节「{failedDisplayName}」执行失败: {errorDetail}"); } - OnProgressChanged(new AnalysisProgressEventArgs + OnProgressChanged(runId, assetSymbol, new AnalysisProgressEventArgs { StageDescription = $"{failedDisplayName} 分析失败,继续其他分析", IsInProgress = true, @@ -298,11 +389,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(); @@ -327,6 +418,19 @@ private async Task ExecuteWorkflowAsync( return finalReport; } + // 全部分析师被失败隔离包装器降级:聚合器不会派发 Coordinator,事件流自然结束。 + // Fan-In barrier 目标执行器的异常不会以 ExecutorFailedEvent 暴露,因此由这里终局判定。 + if (degradedAnalysts.Count >= analystCount && analystCount > 0) + { + _logger.LogError( + "全部分析师均执行失败,标的: {AssetSymbol},失败名单: [{DegradedAnalysts}]", + assetSymbol, + string.Join(", ", degradedAnalysts)); + + throw new FriendlyException( + $"所有分析师均执行失败,无法生成综合报告: {string.Join(";", degradedAnalysts)}"); + } + // 工作流正常结束但未收到 WorkflowOutputEvent,构建详细诊断信息 _logger.LogError( "工作流事件流已结束但未收到 WorkflowOutputEvent,标的: {AssetSymbol},已完成分析师: {Completed}/{Total},失败步骤: [{FailedSteps}],最后完成步骤: {LastStep}", @@ -419,23 +523,31 @@ private List GetEnabledAnalysts() } /// - /// 创建分析师代理(使用 Factory 模式),返回成功创建的 Agent 列表及失败的类型列表 + /// 创建分析师代理(使用 Factory 模式),返回成功创建的 Agent 列表及失败的类型列表。 + /// 每位成功创建的分析师都会附加失败隔离包装,保证 Fan-In 聚合器总能收齐消息。 /// - private (List Agents, List FailedTypes) CreateAnalystAgents( + private ( + List Agents, + List FailedTypes, + IReadOnlyDictionary NameToDisplayName) CreateAnalystAgents( List analystTypes, - MarketSnapshotContextProvider marketSnapshot) + MarketSnapshotContextProvider marketSnapshot, + ChatClientRuntime runtime, + Action? onAnalystDegraded = null) { _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, runtime, sharedProviders) + .WithFailureIsolation(exception => onAnalystDegraded?.Invoke(type, exception)); createdAgents.Add(agent); // 创建时即建立 Name → DisplayName 映射。 @@ -445,7 +557,7 @@ private List GetEnabledAnalysts() var displayName = GetAnalystDisplayNameFromType(type); if (!string.IsNullOrEmpty(agent.Name)) { - _agentNameToDisplayName[agent.Name] = displayName; + nameToDisplayName[agent.Name] = displayName; } } catch (Exception ex) @@ -456,7 +568,7 @@ private List GetEnabledAnalysts() } _logger.LogInformation("成功创建分析师代理,实际数量: {Count}", createdAgents.Count); - return (createdAgents, failedTypes); + return (createdAgents, failedTypes, nameToDisplayName); } /// @@ -509,11 +621,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; } @@ -525,7 +639,10 @@ private string GetDisplayNameForExecutorId(string executorId) /// 流程: /// [Dispatcher] → [并发分析师团队] → [Aggregator] → [Coordinator] /// - private Workflow BuildWorkflow(int analystCount, List analystAgents) + private Workflow BuildWorkflow( + List analystAgents, + AnalysisAggregatorExecutor aggregatorExecutor, + CoordinatorExecutor coordinatorExecutor) { // 构建标准 Fan-Out/Fan-In 工作流: // @@ -537,9 +654,8 @@ private Workflow BuildWorkflow(int analystCount, List analystAgents) // ↓ // [Coordinator] List → MarketAnalysisReport (输出) - // 1. 动态创建 Dispatcher(需要知道分析师数量) + // 1. 创建 Dispatcher(作为入口节点) var dispatcher = new AnalysisDispatcherExecutor( - analystCount, _loggerFactory.CreateLogger()); // 2. 创建工作流,Dispatcher 作为入口节点 @@ -551,13 +667,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(); } @@ -565,8 +681,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); } } @@ -576,6 +697,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/MarketAnalysis/WorkflowStateKeys.cs b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/WorkflowStateKeys.cs index 7687ad4..f787227 100644 --- a/src/MarketAssistant.App.Services/Agents/MarketAnalysis/WorkflowStateKeys.cs +++ b/src/MarketAssistant.App.Services/Agents/MarketAnalysis/WorkflowStateKeys.cs @@ -17,7 +17,7 @@ internal static class WorkflowStateKeys public const string AssetSymbol = nameof(AssetSymbol); /// - /// 预期分析师数量的状态键 + /// Fan-In 已收集的最终分析师文本消息。 /// - public const string ExpectedAnalystCount = nameof(ExpectedAnalystCount); + public const string AggregatedAnalystMessages = nameof(AggregatedAnalystMessages); } 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/Agents/Tools/Crypto/CryptoTradingExecutionTools.cs b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTradingExecutionTools.cs index 1f463a3..2a79825 100644 --- a/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTradingExecutionTools.cs +++ b/src/MarketAssistant.App.Services/Agents/Tools/Crypto/CryptoTradingExecutionTools.cs @@ -1,10 +1,12 @@ using System.ComponentModel; using MarketAssistant.Agents.Tools.Abstractions; using MarketAssistant.DataProviders; +using MarketAssistant.Infrastructure.Core; using MarketAssistant.Services.Trading; using MarketAssistant.Trading.Abstractions; using MarketAssistant.Trading.Models; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace MarketAssistant.Agents.Tools.Crypto; @@ -22,7 +24,7 @@ public class CryptoTradingExecutionTools : ITradingExecutionTools public CryptoTradingExecutionTools( CryptoPortfolioService portfolioService, - IExchangeClient exchangeClient, + [FromKeyedServices(MarketType.Crypto)] IExchangeClient exchangeClient, BinanceMarketDataService marketDataService, TradeExecutor tradeExecutor, ILogger logger) diff --git a/src/MarketAssistant.App.Services/Agents/Trading/TradingAgent.cs b/src/MarketAssistant.App.Services/Agents/Trading/TradingAgent.cs index e1791fc..8093b79 100644 --- a/src/MarketAssistant.App.Services/Agents/Trading/TradingAgent.cs +++ b/src/MarketAssistant.App.Services/Agents/Trading/TradingAgent.cs @@ -26,6 +26,10 @@ public TradingAgent( private static AIAgent CreateInnerAgent(IChatClient chatClient, IList tools) { + var functionInvokingClient = new FunctionInvokingChatClient(chatClient) + { + MaximumIterationsPerRequest = 20 + }; var options = new ChatClientAgentOptions { Name = AgentName, @@ -36,10 +40,11 @@ private static AIAgent CreateInnerAgent(IChatClient chatClient, IList to Temperature = 0.1f, TopP = 0.1f, Tools = tools - } + }, + UseProvidedChatClientAsIs = true }; - return new ChatClientAgent(chatClient, options); + return new ChatClientAgent(functionInvokingClient, options); } private static string BuildSystemPrompt() => """ 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/Assets/AShareAssetInfoService.cs b/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs index e4de8ae..51f7de0 100644 --- a/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs +++ b/src/MarketAssistant.App.Services/Applications/Assets/AShareAssetInfoService.cs @@ -193,7 +193,7 @@ public async Task> GetHotAssetsAsync() foreach (var item in jsonDocument.RootElement.EnumerateArray()) { - var symbol = item.TryGetProperty("symbol", out var symEl) ? symEl.GetString() ?? "" : ""; + var symbol = ParseString(item, "symbol"); if (symbol.Length < 2) continue; @@ -202,8 +202,8 @@ public async Task> GetHotAssetsAsync() symbol.StartsWith("bj", StringComparison.OrdinalIgnoreCase) ? "BJ" : ""; var code = symbol[2..]; - var name = item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "" : ""; - var price = item.TryGetProperty("trade", out var tradeEl) ? tradeEl.GetString() ?? "" : ""; + var name = ParseString(item, "name"); + var price = ParseString(item, "trade"); // 新浪接口的 changeratio 和 netamount 均为字符串类型,需手动解析 var changeRatio = ParseDouble(item, "changeratio"); var netAmount = ParseDouble(item, "netamount"); @@ -230,6 +230,18 @@ public async Task> GetHotAssetsAsync() } } + /// + /// 从 JSON 元素安全解析字符串值。新浪接口在股票停牌/退市时, + /// 部分字段会返回 false 而非字符串,直接 GetString() 会抛异常。 + /// + private static string ParseString(JsonElement item, string propertyName) + { + if (!item.TryGetProperty(propertyName, out var element)) + return ""; + + return element.ValueKind == JsonValueKind.String ? element.GetString() ?? "" : ""; + } + /// /// 从 JSON 元素解析 double 值,兼容字符串和数字两种类型。 /// 新浪接口返回的数值字段多为字符串类型。 diff --git a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs index c258267..5b014c1 100644 --- a/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs +++ b/src/MarketAssistant.App.Services/Applications/InvestmentSelection/InvestmentSelectionService.cs @@ -277,7 +277,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 db349a9..ffac5b5 100644 --- a/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs +++ b/src/MarketAssistant.App.Services/Applications/Settings/UserSetting.cs @@ -10,17 +10,33 @@ namespace MarketAssistant.Applications.Settings; /// public class UserSetting : INotifyPropertyChanged { - public string ModelId { get; set; } = ""; + /// + /// 模型服务商 ID(对应 ModelProviderCatalog 中的 Id) + /// + public string ProviderId { get; set; } = ""; + + /// + /// 按服务商保存模型 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"; + /// + /// 按服务商保存自定义 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 @@ -44,6 +60,7 @@ public string KnowledgeFileDirectory /// public bool Notification { get; set; } = true; + [JsonIgnore] public string ZhiTuApiToken { get; set; } = ""; /// @@ -80,16 +97,19 @@ public CryptoTradingMode CryptoTradingMode /// 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; } = ""; /// @@ -125,6 +145,7 @@ public bool EnableWebSearch /// /// Web Search API Key /// + [JsonIgnore] public string WebSearchApiKey { get; set; } = ""; /// diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/AgentFactoryBase.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/AgentFactoryBase.cs index 0e44be4..e33b35c 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/AgentFactoryBase.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/AgentFactoryBase.cs @@ -12,33 +12,24 @@ namespace MarketAssistant.Infrastructure.Factories; /// -/// Agent 工厂基类:封装 ChatClient 创建、基于 RequiresToolsAttribute 的工具解析、 -/// Token 追踪中间件附加等公共流程。子类只需关注自身特有逻辑(如额外中间件)。 +/// Agent 工厂基类:封装基于 RequiresToolsAttribute 的工具解析和 Token 追踪中间件附加。 /// public abstract class AgentFactoryBase { protected readonly IServiceProvider ServiceProvider; - protected readonly IChatClientFactory ChatClientFactory; protected readonly TokenTrackingMiddleware TokenTracking; protected readonly ILogger Logger; protected AgentFactoryBase( IServiceProvider serviceProvider, - IChatClientFactory chatClientFactory, TokenTrackingMiddleware tokenTracking, ILogger logger) { ServiceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); - ChatClientFactory = chatClientFactory ?? throw new ArgumentNullException(nameof(chatClientFactory)); TokenTracking = tokenTracking ?? throw new ArgumentNullException(nameof(tokenTracking)); Logger = logger ?? throw new ArgumentNullException(nameof(logger)); } - /// - /// 创建 ChatClient。 - /// - protected IChatClient CreateChatClient() => ChatClientFactory.CreateClient(); - /// /// 基于 agentType 上的 与指定市场类型解析工具列表。 /// @@ -53,7 +44,19 @@ protected IList ResolveToolsFor(Type agentType, MarketType marketType) var toolService = ServiceProvider.GetKeyedService(attr.ToolInterfaceType, marketType); if (toolService is IToolsProvider provider) { - tools.AddRange(provider.GetFunctions()); + var providerTools = provider.GetFunctions().ToList(); + + foreach (var tool in providerTools) + { + Logger.LogDebug( + "解析 Agent 专业工具: {AgentType}, 市场: {Market}, Tool: {ToolName}, RuntimeType: {RuntimeType}", + agentType.Name, + marketType, + tool.Name, + tool.GetType().FullName); + } + + tools.AddRange(providerTools); resolvedCount++; } else diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs index 69a9e37..062b62c 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/AnalystAgentFactory.cs @@ -1,6 +1,7 @@ using MarketAssistant.Agents.Analysts; using MarketAssistant.Agents.Middleware; using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Infrastructure.Providers; using MarketAssistant.Services.Market; using Microsoft.Agents.AI; using Microsoft.Extensions.DependencyInjection; @@ -14,14 +15,12 @@ namespace MarketAssistant.Infrastructure.Factories; public interface IAnalystAgentFactory { /// - /// 根据类型创建对应的代理(动态调用,运行时检查) + /// 使用调用方提供的不可变 Runtime Client 创建代理,确保同一次工作流模型配置一致。 /// - AIAgent CreateAnalyst(Type agentType); - - /// - /// 根据类型创建代理,附加额外的 AIContextProvider(如共享市场快照) - /// - AIAgent CreateAnalyst(Type agentType, AIContextProvider[]? additionalProviders); + AIAgent CreateAnalyst( + Type agentType, + ChatClientRuntime runtime, + AIContextProvider[]? additionalProviders = null); } /// @@ -31,41 +30,62 @@ public interface IAnalystAgentFactory public class AnalystAgentFactory : AgentFactoryBase, IAnalystAgentFactory { private readonly MarketContext _marketContext; + private readonly AgentSkillsProvider _skillsProvider; public AnalystAgentFactory( IServiceProvider serviceProvider, - IChatClientFactory chatClientFactory, MarketContext marketContext, + AgentSkillsProvider skillsProvider, TokenTrackingMiddleware tokenTracking, ILogger logger) - : base(serviceProvider, chatClientFactory, tokenTracking, logger) + : base(serviceProvider, tokenTracking, logger) { _marketContext = marketContext ?? throw new ArgumentNullException(nameof(marketContext)); + _skillsProvider = skillsProvider ?? throw new ArgumentNullException(nameof(skillsProvider)); } - /// - public AIAgent CreateAnalyst(Type agentType) => CreateAnalyst(agentType, additionalProviders: null); - - /// - public AIAgent CreateAnalyst(Type agentType, AIContextProvider[]? additionalProviders) + public AIAgent CreateAnalyst( + Type agentType, + ChatClientRuntime runtime, + AIContextProvider[]? additionalProviders = null) { try { + ArgumentNullException.ThrowIfNull(runtime); + // 严格限制必须是 AnalystAgentBase 的子类 if (!typeof(AnalystAgentBase).IsAssignableFrom(agentType)) { throw new ArgumentException($"Type {agentType.Name} must inherit from AnalystAgentBase", nameof(agentType)); } - var chatClient = CreateChatClient(); + // 根据当前市场类型获取对应的工具实现 var currentMarket = _marketContext.CurrentMarket; var tools = ResolveToolsFor(agentType, currentMarket); - // 显式传递 chatClient、合并后的工具列表和 AIContextProvider[],其余由 DI 自动解析 - var parameters = new List { chatClient, tools }; + // 显式传递 Runtime Client、结构化输出模式、工具和上下文,其余由 DI 自动解析。 + var parameters = new List + { + runtime.Client, + tools, + runtime.StructuredOutputMode + }; + + Logger.LogInformation( + "创建分析师代理: {AgentType}, 市场: {Market}, Provider: {ProviderId}, Model: {ModelId}, ResponseFormat: {StructuredOutputMode}, Tools: {ToolCount}", + agentType.Name, + currentMarket, + runtime.ProviderId, + runtime.ModelId, + runtime.StructuredOutputMode, + tools.Count); - if (additionalProviders is { Length: > 0 }) - parameters.Add(additionalProviders); + AIContextProvider[] contextProviders = + [ + _skillsProvider, + .. (additionalProviders ?? []) + ]; + parameters.Add(contextProviders); var agent = (AIAgent)ActivatorUtilities.CreateInstance(ServiceProvider, agentType, parameters.ToArray()); diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/AnonymousHttpClientPipelineTransport.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/AnonymousHttpClientPipelineTransport.cs new file mode 100644 index 0000000..f89c783 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/AnonymousHttpClientPipelineTransport.cs @@ -0,0 +1,16 @@ +using System.ClientModel.Primitives; + +namespace MarketAssistant.Infrastructure.Factories; + +/// +/// 为明确允许匿名访问的 OpenAI-compatible 端点移除 SDK 自动添加的 Authorization 请求头。 +/// +internal sealed class AnonymousHttpClientPipelineTransport(HttpClient httpClient) + : HttpClientPipelineTransport(httpClient) +{ + protected override void OnSendingRequest(PipelineMessage message, HttpRequestMessage httpRequest) + { + base.OnSendingRequest(message, httpRequest); + httpRequest.Headers.Authorization = null; + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs index f8d8abf..528686d 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/ChatClientFactory.cs @@ -1,9 +1,15 @@ +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Security.Cryptography; +using System.Text; +using MarketAssistant.Infrastructure.Core; +using MarketAssistant.Infrastructure.Providers; using MarketAssistant.Services.Settings; using Microsoft.Extensions.AI; +using Microsoft.Extensions.Http.Resilience; +using OllamaSharp; using OpenAI; using Polly; -using Polly.Retry; -using System.ClientModel; namespace MarketAssistant.Infrastructure.Factories; @@ -11,174 +17,235 @@ 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, + StructuredOutputMode StructuredOutputMode); + /// /// ChatClient 工厂实现 -/// 创建和缓存底层的 OpenAI ChatClient,并附加 LLM 瞬态错误重试管道 +/// 根据用户配置创建并缓存官方 SDK 提供的 IChatClient。 /// public class ChatClientFactory : IChatClientFactory { - /// - /// 瞬态错误冷却时间:冷却期内同一配置不重试,冷却后允许再次尝试 - /// - private static readonly TimeSpan ErrorCooldown = TimeSpan.FromSeconds(30); - - /// - /// LLM 调用重试管道:针对瞬态网络/服务端错误自动重试 2 次,指数退避 + 抖动 - /// 覆盖 Coordinator 和所有业务分析师的 LLM 调用 - /// - private static readonly ResiliencePipeline LlmRetryPipeline = new ResiliencePipelineBuilder() - .AddRetry(new RetryStrategyOptions - { - MaxRetryAttempts = 2, - BackoffType = DelayBackoffType.Exponential, - UseJitter = true, - Delay = TimeSpan.FromSeconds(2), - ShouldHandle = new PredicateBuilder() - .Handle() - .Handle(ex => IsNetworkTimeout(ex)) - }) - .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 const int MaxCachedRuntimes = 16; private readonly IUserSettingService _userSettingService; + private readonly IHttpClientFactory _httpClientFactory; 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 bool _disposed; - public ChatClientFactory(IUserSettingService userSettingService) + public ChatClientFactory( + IUserSettingService userSettingService, + IHttpClientFactory httpClientFactory) { _userSettingService = userSettingService; + _httpClientFactory = httpClientFactory; } - 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.ProviderModelIds.GetValueOrDefault(providerId, string.Empty); + var provider = ModelProviderCatalog.GetProvider(providerId) + ?? throw new FriendlyException($"未知的服务商: {providerId}"); + var configuredApiKey = userSetting.ProviderApiKeys.TryGetValue(providerId, out var key) ? key : string.Empty; + var apiKey = provider.RequiresApiKey ? configuredApiKey : string.Empty; + var endpointOverride = provider.AllowsEndpointOverride + ? userSetting.ProviderEndpoints.GetValueOrDefault(providerId, string.Empty) + : string.Empty; + var endpoint = provider.ResolveEndpoint(endpointOverride); + var structuredOutputMode = provider.StructuredOutputMode; + 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, + structuredOutputMode); + } - bool configUnchanged = _cachedModelId == modelId - && _cachedEndpoint == endpoint - && _cachedApiKey == apiKey; + try + { + if (string.IsNullOrWhiteSpace(modelId)) + throw new FriendlyException("AI 功能未配置:请先在设置页面选择 AI 模型"); - // 配置未变且有成功缓存 → 直接返回 - if (configUnchanged && _cachedClient != null) + if (provider.GetProtocol(modelId) == ModelApiProtocol.Unsupported) { - return _cachedClient; + throw new FriendlyException( + $"模型 {modelId} 当前使用的 API 协议尚未接入 {provider.DisplayName}"); } - // 配置未变且上次失败仍在冷却期内 → 快速失败,避免频繁重试 - if (configUnchanged - && !string.IsNullOrEmpty(_lastError) - && DateTime.UtcNow - _lastErrorTime < ErrorCooldown) + if (provider.RequiresApiKeyForModel(modelId) && string.IsNullOrWhiteSpace(apiKey)) { - throw new FriendlyException(_lastError); + throw new FriendlyException( + $"AI 功能未配置:服务商 {provider.DisplayName} 的模型 {modelId} 需要 API Key,请先在设置页面配置"); } - // 配置已变更或冷却期已过,重置错误状态 - _lastError = null; - // 保存旧客户端引用,稍后在 lock 外 Dispose(避免持锁等待网络连接关闭) - oldClient = _cachedClient; - _cachedClient = null; - - try - { - 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; - } - 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 client = CreateClient(provider, modelId, apiKey, endpoint); + _clients.Add(runtimeKey, client); + return new ChatClientRuntime( + client, + providerId, + modelId, + endpoint, + configurationFingerprint, + structuredOutputMode); + } + catch (FriendlyException) + { + throw; + } + catch (Exception ex) + { + throw new FriendlyException($"创建 AI 客户端失败: {ex.Message}", ex); } } - finally + } + + protected virtual IChatClient CreateClient( + ModelProvider provider, + string modelId, + string apiKey, + string endpoint) + { + return provider.GetProtocol(modelId) switch { - // 在 lock 外 Dispose 旧客户端,避免持锁等待网络连接关闭。 - // 用 try-catch 包裹防止 Dispose 抛出异常覆盖 try 块中的原始异常 - if (oldClient != null) + ModelApiProtocol.Ollama => CreateOllamaClient(endpoint, modelId), + ModelApiProtocol.OpenAIChatCompletions => CreateOpenAIClient(apiKey, endpoint) + .GetChatClient(modelId) + .AsIChatClient(), + _ => throw new FriendlyException($"模型 {modelId} 的 API 协议暂不受支持") + }; + } + + /// + /// OllamaSharp 不自带重试;用 Microsoft.Extensions.Http.Resilience 的标准重试管道 + /// 补齐与 OpenAI 协议(SDK 内建重试)对齐的弹性能力,并统一 3 分钟网络超时。 + /// + private static OllamaApiClient CreateOllamaClient(string endpoint, string modelId) + { + var pipeline = new ResiliencePipelineBuilder() + .AddRetry(new HttpRetryStrategyOptions { - try - { - oldClient.Dispose(); - } - catch (Exception) - { - // Dispose 失败不应影响主流程,仅记录 - // 此处无法使用 ILogger(工厂不持有 logger),异常被静默吞并 - } - } + MaxRetryAttempts = 3, + Delay = TimeSpan.FromSeconds(1), + BackoffType = DelayBackoffType.Exponential, + UseJitter = true + }) + .Build(); + + var httpClient = new HttpClient(new ResilienceHandler(pipeline)) + { + BaseAddress = new Uri(endpoint), + Timeout = TimeSpan.FromMinutes(3) + }; + + return new OllamaApiClient(httpClient, modelId); + } + + private OpenAIClient CreateOpenAIClient(string apiKey, string endpoint) + { + var options = new OpenAIClientOptions + { + Endpoint = new Uri(endpoint), + NetworkTimeout = TimeSpan.FromMinutes(3), + // 显式对齐 Ollama 路径的 3 次重试,不依赖 SDK 默认值 + RetryPolicy = new ClientRetryPolicy(3) + }; + + if (string.IsNullOrWhiteSpace(apiKey)) + { + options.Transport = new AnonymousHttpClientPipelineTransport( + _httpClientFactory.CreateClient("AnonymousOpenAI")); + return new OpenAIClient(new ApiKeyCredential("anonymous"), options); } + + return new OpenAIClient(new ApiKeyCredential(apiKey), options); + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) + return; + + foreach (var client in _clients.Values) + client.Dispose(); + + _clients.Clear(); + _disposed = true; + } + + GC.SuppressFinalize(this); } + + 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); } diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs index b706d9a..298fd3e 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/EmbeddingFactory.cs @@ -1,13 +1,15 @@ +using System.ClientModel; +using System.ClientModel.Primitives; 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) { _userSettingService = userSettingService; @@ -25,12 +27,21 @@ public IEmbeddingGenerator> Create() if (string.IsNullOrWhiteSpace(apiKey)) throw new FriendlyException("嵌入API密钥不能为空"); - var client = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) || + endpointUri.Scheme is not ("http" or "https")) { - Endpoint = new Uri(endpoint + "/v1") - }); + throw new FriendlyException("嵌入服务 Endpoint 无效"); + } + var client = new OpenAIClient( + new ApiKeyCredential(apiKey), + new OpenAIClientOptions + { + Endpoint = endpointUri, + NetworkTimeout = TimeSpan.FromMinutes(3), + // 显式对齐 Chat 路径的 3 次重试,不依赖 SDK 默认值 + RetryPolicy = new ClientRetryPolicy(3) + }); return client.GetEmbeddingClient(modelId).AsIEmbeddingGenerator(); } } - diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/MarketChatSessionFactory.cs index a4a201f..39393bb 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,12 @@ public MarketChatSessionFactory( public MarketChatSession Create(string? initialStockCode = null) { - var chatClient = _chatClientFactory.CreateClient(); + var runtime = _chatClientFactory.CreateRuntime(); var logger = _loggerFactory.CreateLogger(); + var compactionProvider = _compactionProviderFactory.Create(runtime.Client); return new MarketChatSession( - chatClient, + runtime.Client, logger, mcpToolProvider: _mcpToolProvider, searchTools: _searchTools, @@ -84,10 +85,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/ModelDiscoveryService.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/ModelDiscoveryService.cs new file mode 100644 index 0000000..9038829 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/ModelDiscoveryService.cs @@ -0,0 +1,119 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using MarketAssistant.Infrastructure.Providers; + +namespace MarketAssistant.Infrastructure.Factories; + +public interface IModelDiscoveryService +{ + Task> ListModelsAsync( + ModelProvider provider, + string? apiKey, + string? endpointOverride = null, + CancellationToken cancellationToken = default); +} + +/// +/// 模型列表发现服务。模型列表接口统一按 OpenAI 兼容约定:有 API Key 加 +/// Authorization: Bearer,无 Key 匿名请求(是否强制要求 Key 由 +/// 控制)。 +/// URL 由 API Base URL + 拼接, +/// 响应解析兼容常见的 data、models 与根数组形状,模型标识支持 +/// id、name、model 字段或字符串元素。发现阶段忠实返回服务商目录, +/// 不按当前聊天协议过滤;协议支持性在创建聊天客户端时单独校验。 +/// 新增 OpenAI 兼容服务商只需在 Catalog 配置数据,无需改动本服务。 +/// +public sealed class ModelDiscoveryService(IHttpClientFactory httpClientFactory) : IModelDiscoveryService +{ + private const string HttpClientName = "AnonymousOpenAI"; + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(30); + + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + public async Task> ListModelsAsync( + ModelProvider provider, + string? apiKey, + string? endpointOverride = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(provider); + if (!provider.SupportsModelListing) + return []; + + if (provider.ModelListingRequiresApiKey && string.IsNullOrWhiteSpace(apiKey)) + throw new InvalidOperationException($"服务商 {provider.DisplayName} 的模型列表接口需要 API Key"); + + var effectiveOverride = provider.AllowsEndpointOverride ? endpointOverride : null; + var endpoint = provider.ResolveEndpoint(effectiveOverride); + using var request = new HttpRequestMessage(HttpMethod.Get, $"{endpoint}{provider.ModelListingUrlPath}"); + if (!string.IsNullOrWhiteSpace(apiKey)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(RequestTimeout); + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token); + response.EnsureSuccessStatusCode(); + + await using var stream = await response.Content.ReadAsStreamAsync(cts.Token); + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cts.Token); + return ExtractModelIds(doc.RootElement) + .Where(modelId => !string.IsNullOrWhiteSpace(modelId)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(modelId => modelId, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// 提取常见模型目录响应中的模型标识。 + /// + private static IEnumerable ExtractModelIds(JsonElement root) + { + var models = root.ValueKind == JsonValueKind.Array + ? root + : TryGetArray(root, "data", out var data) + ? data + : TryGetArray(root, "models", out var modelArray) + ? modelArray + : default; + + if (models.ValueKind != JsonValueKind.Array) + yield break; + + foreach (var item in models.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + yield return item.GetString()!; + continue; + } + + if (item.ValueKind != JsonValueKind.Object) + continue; + + foreach (var propertyName in ModelIdentifierPropertyNames) + { + if (!item.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.String) + continue; + + yield return value.GetString()!; + break; + } + } + } + + private static readonly string[] ModelIdentifierPropertyNames = ["id", "name", "model"]; + + private static bool TryGetArray(JsonElement root, string propertyName, out JsonElement value) + { + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty(propertyName, out value) && + value.ValueKind == JsonValueKind.Array) + { + return true; + } + + value = default; + return false; + } + + } diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/ResilientChatClient.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/ResilientChatClient.cs deleted file mode 100644 index b1be037..0000000 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/ResilientChatClient.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Microsoft.Extensions.AI; -using Polly; - -namespace MarketAssistant.Infrastructure.Factories; - -/// -/// IChatClient 装饰器:为所有 LLM 调用附加 Polly 瞬态错误重试管道。 -/// 覆盖 Coordinator 和所有业务分析师的 LLM 调用,避免单次瞬态错误导致分析维度缺失。 -/// -internal sealed class ResilientChatClient : IChatClient -{ - private readonly IChatClient _inner; - private readonly ResiliencePipeline _pipeline; - - public ResilientChatClient(IChatClient inner, ResiliencePipeline pipeline) - { - _inner = inner; - _pipeline = pipeline; - } - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - return await _pipeline.ExecuteAsync( - async ct => await _inner.GetResponseAsync(messages, options, ct), - cancellationToken); - } - - public IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - // 流式调用不支持简单重试(流一旦开始输出无法回放),直接透传 - return _inner.GetStreamingResponseAsync(messages, options, cancellationToken); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - return _inner.GetService(serviceType, serviceKey); - } - - public void Dispose() - { - _inner.Dispose(); - } -} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs b/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs index fe4c69c..c228493 100644 --- a/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs +++ b/src/MarketAssistant.App.Services/Infrastructure/Factories/TradingAgentFactory.cs @@ -12,7 +12,7 @@ namespace MarketAssistant.Infrastructure.Factories; /// public interface ITradingAgentFactory { - AIAgent CreateAgent(); + AIAgent CreateAutomationAgent(); } /// @@ -21,55 +21,33 @@ public interface ITradingAgentFactory /// public class TradingAgentFactory : AgentFactoryBase, ITradingAgentFactory { - private readonly ILoggerFactory _loggerFactory; - - /// - /// Human-in-the-Loop 确认回调。 - /// 参数: (functionName, argsDescription) → true=放行 false=拒绝。 - /// UI 层可在创建工厂后设置此属性以接入用户确认对话框。 - /// - public Func>? TradeConfirmationCallback { get; set; } + private readonly IChatClientFactory _chatClientFactory; public TradingAgentFactory( IServiceProvider serviceProvider, IChatClientFactory chatClientFactory, TokenTrackingMiddleware tokenTracking, - ILoggerFactory loggerFactory, ILogger logger) - : base(serviceProvider, chatClientFactory, tokenTracking, logger) + : base(serviceProvider, tokenTracking, logger) { - _loggerFactory = loggerFactory; + _chatClientFactory = chatClientFactory; } /// - /// 创建包装中间件后的 ;工具一律从 解析。 + /// 创建自动交易使用的 ;工具一律从 解析。 /// - public AIAgent CreateAgent() + public AIAgent CreateAutomationAgent() { try { - var chatClient = CreateChatClient(); + var chatClient = _chatClientFactory.CreateClient(); var tools = ResolveToolsFor(typeof(TradingAgent), MarketType.Crypto); var agent = (AIAgent)ActivatorUtilities.CreateInstance( ServiceProvider, typeof(TradingAgent), chatClient, tools); - // 创建 Function Calling 守卫中间件(每次 CreateAgent 新建实例以重置调用计数) - var guardMiddleware = new TradingFunctionGuardMiddleware( - _loggerFactory.CreateLogger()); - guardMiddleware.ConfirmationCallback = TradeConfirmationCallback; - - // 通过 MAF Builder 模式附加中间件链:Token 追踪 + Function Calling 守卫 - var middlewareAgent = agent - .AsBuilder() - .Use( - runFunc: TokenTracking.InvokeAsync, - runStreamingFunc: TokenTracking.InvokeStreamingAsync) - .Use(guardMiddleware.InvokeAsync) - .Build(); - - Logger.LogInformation("成功创建 TradingAgent(已附加 Token 追踪 + 交易守卫中间件)"); - return middlewareAgent; + Logger.LogInformation("成功创建自动交易 Agent(工具执行由 TradeExecutor 统一风控)"); + return WrapWithTokenTracking(agent); } catch (Exception ex) { 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..9531217 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProvider.cs @@ -0,0 +1,90 @@ +using MarketAssistant.Infrastructure.Core; + +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// 模型服务商定义。每个服务商通过代码注册于 。 +/// +/// 唯一标识(与 UserSetting.ProviderId 对应) +/// UI 显示名称 +/// 最终 API Base URL,不再由适配器隐式追加版本后缀 +/// API Key 获取链接 +/// 服务商是否支持 API Key 配置;模型级是否强制由策略判断 +/// 是否允许用户覆盖 API Base URL,仅本地或自定义部署开启 +/// 是否支持通过标准端点获取模型列表 +/// 服务商默认模型协议 +/// 模型列表端点是否需要 API Key,可与模型调用鉴权规则不同 +/// 模型列表相对路径,拼在 API Base URL 之后;默认 /models +/// 结构化任务使用的服务商级响应格式能力 +/// 可选服务商特殊策略;单协议服务商使用默认策略 +public record ModelProvider( + string Id, + string DisplayName, + string DefaultEndpoint, + string? ApiKeyUrl, + bool RequiresApiKey = true, + bool AllowsEndpointOverride = false, + bool SupportsModelListing = true, + ModelApiProtocol Protocol = ModelApiProtocol.OpenAIChatCompletions, + bool ModelListingRequiresApiKey = true, + string ModelListingUrlPath = "/models", + StructuredOutputMode StructuredOutputMode = StructuredOutputMode.JsonObject, + IModelProviderPolicy? Policy = null) +{ + private IModelProviderPolicy EffectivePolicy => Policy ?? DefaultModelProviderPolicy.Instance; + + /// + /// 获取指定模型使用的 API 协议。 + /// + public ModelApiProtocol GetProtocol(string modelId) => EffectivePolicy.GetProtocol(this, modelId); + + /// + /// 判断当前凭据是否允许访问模型列表。 + /// + public bool CanListModels(string? apiKey) => + SupportsModelListing && + (!ModelListingRequiresApiKey || !string.IsNullOrWhiteSpace(apiKey)); + + /// + /// 判断指定模型是否需要 API Key。 + /// + /// + /// “免费模型”不等于“匿名接口”。只有服务商通过模型目录或稳定命名约定明确确认无需鉴权时,才允许留空 API Key。 + /// + public bool RequiresApiKeyForModel(string? modelId) => + EffectivePolicy.RequiresApiKeyForModel(this, modelId); + + /// + /// 解析当前服务商实际使用的 API Base URL:优先用户覆盖值,其次默认端点, + /// Ollama 兜底本地地址,并统一校验与去除尾部斜杠。 + /// + public string ResolveEndpoint(string? configuredEndpoint) + { + var endpoint = !string.IsNullOrWhiteSpace(configuredEndpoint) + ? configuredEndpoint + : string.IsNullOrWhiteSpace(DefaultEndpoint) && Protocol == ModelApiProtocol.Ollama + ? "http://localhost:11434" + : DefaultEndpoint; + + if (string.IsNullOrWhiteSpace(endpoint)) + throw new FriendlyException($"AI 功能未配置:服务商 {DisplayName} 需要配置 API Base URL"); + + if (!Uri.TryCreate(endpoint.Trim().TrimEnd('/'), UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https")) + { + throw new FriendlyException($"服务商 {DisplayName} 的 API Base URL 无效"); + } + + return uri.AbsoluteUri.TrimEnd('/'); + } +} + +/// +/// 模型 API 协议。客户端创建直接使用对应官方 SDK。 +/// +public enum ModelApiProtocol +{ + OpenAIChatCompletions, + Ollama, + Unsupported, +} 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..ec352c9 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderCatalog.cs @@ -0,0 +1,151 @@ +using MarketAssistant.Infrastructure.Core; + +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"), + 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.io/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: "SenseNova", + DisplayName: "商汤日日新", + DefaultEndpoint: "https://token.sensenova.cn/v1", + ApiKeyUrl: "https://platform.sensenova.cn/console"), + // 国外服务商 + new( + Id: "OpenCodeZen", + DisplayName: "OpenCode Zen", + DefaultEndpoint: "https://opencode.ai/zen/v1", + ApiKeyUrl: "https://opencode.ai/auth", + ModelListingRequiresApiKey: false, + Policy: OpenCodeZenModelPolicy.Instance), + new( + Id: "OpenAI", + DisplayName: "OpenAI", + DefaultEndpoint: "https://api.openai.com/v1", + ApiKeyUrl: "https://platform.openai.com/api-keys", + StructuredOutputMode: StructuredOutputMode.JsonSchema), + 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: "Ollama", + DisplayName: "Ollama (本地)", + DefaultEndpoint: "http://localhost:11434", + ApiKeyUrl: null, + RequiresApiKey: false, + AllowsEndpointOverride: true, + ModelListingRequiresApiKey: false, + ModelListingUrlPath: "/api/tags", + Protocol: ModelApiProtocol.Ollama), + new( + Id: "LMStudio", + DisplayName: "LM Studio (本地)", + DefaultEndpoint: "http://localhost:1234/v1", + ApiKeyUrl: null, + RequiresApiKey: false, + AllowsEndpointOverride: true, + ModelListingRequiresApiKey: false), + + // 自定义 OpenAI 兼容服务 + new( + Id: "Custom", + DisplayName: "自定义", + DefaultEndpoint: string.Empty, + ApiKeyUrl: null, + AllowsEndpointOverride: true, + SupportsModelListing: false, + StructuredOutputMode: StructuredOutputMode.Text), + ]; + + /// + /// 根据 ID 获取服务商定义。 + /// + public static ModelProvider? GetProvider(string? id) + { + if (string.IsNullOrWhiteSpace(id)) + return null; + + return Providers.FirstOrDefault(p => p.Id.Equals(id, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderPolicy.cs b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderPolicy.cs new file mode 100644 index 0000000..2e278d7 --- /dev/null +++ b/src/MarketAssistant.App.Services/Infrastructure/Providers/ModelProviderPolicy.cs @@ -0,0 +1,75 @@ +namespace MarketAssistant.Infrastructure.Providers; + +/// +/// 定义服务商在模型协议和鉴权方面的特殊规则。 +/// +public interface IModelProviderPolicy +{ + ModelApiProtocol GetProtocol(ModelProvider provider, string modelId); + + bool RequiresApiKeyForModel(ModelProvider provider, string? modelId); +} + +/// +/// 单协议服务商的默认规则。 +/// +internal sealed class DefaultModelProviderPolicy : IModelProviderPolicy +{ + public static DefaultModelProviderPolicy Instance { get; } = new(); + + private DefaultModelProviderPolicy() + { + } + + public ModelApiProtocol GetProtocol(ModelProvider provider, string modelId) => provider.Protocol; + + public bool RequiresApiKeyForModel(ModelProvider provider, string? modelId) => provider.RequiresApiKey; +} + +/// +/// OpenCode Zen 多协议网关规则。当前应用仅接入其 OpenAI Chat Completions 模型。 +/// +internal sealed class OpenCodeZenModelPolicy : IModelProviderPolicy +{ + private static readonly string[] ChatCompletionModelPrefixes = + [ + "deepseek-", + "minimax-", + "glm-", + "kimi-", + "mimo-", + "laguna-", + "ling-", + "north-", + "nemotron-" + ]; + + public static OpenCodeZenModelPolicy Instance { get; } = new(); + + private OpenCodeZenModelPolicy() + { + } + + public ModelApiProtocol GetProtocol(ModelProvider provider, string modelId) + { + if (modelId.Equals("big-pickle", StringComparison.OrdinalIgnoreCase) || + ChatCompletionModelPrefixes.Any(prefix => + modelId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) + { + return ModelApiProtocol.OpenAIChatCompletions; + } + + // Zen 是多协议网关。未知模型默认拒绝,避免误发到 /chat/completions。 + return ModelApiProtocol.Unsupported; + } + + public bool RequiresApiKeyForModel(ModelProvider provider, string? modelId) + { + if (string.IsNullOrWhiteSpace(modelId)) + return true; + + return !modelId.Equals("big-pickle", StringComparison.OrdinalIgnoreCase) && + !modelId.EndsWith("-free", StringComparison.OrdinalIgnoreCase); + } + +} 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 35fe8b7..20fe410 100644 --- a/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj +++ b/src/MarketAssistant.App.Services/MarketAssistant.App.Services.csproj @@ -4,8 +4,7 @@ enable MarketAssistant enable - + $(NoWarn);MAAI001;SKEXP0001;SKEXP0050 @@ -28,6 +27,7 @@ + @@ -37,10 +37,14 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs b/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs index 3924841..8445ce5 100644 --- a/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs +++ b/src/MarketAssistant.App.Services/Services/Cache/AnalysisCacheService.cs @@ -132,7 +132,8 @@ private string GetConfigFingerprint() .Where(kv => kv.Value) .Select(kv => kv.Key) .OrderBy(s => s); - return $"{setting.ModelId}|{string.Join(",", enabledRoles)}"; + var activeModelId = setting.ProviderModelIds.GetValueOrDefault(setting.ProviderId, string.Empty); + return $"{activeModelId}|{string.Join(",", enabledRoles)}"; } /// 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/Market/MarketContext.cs b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs index 0ac4b51..03602fb 100644 --- a/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs +++ b/src/MarketAssistant.App.Services/Services/Market/MarketContext.cs @@ -80,8 +80,8 @@ public void SwitchMarket(MarketType newMarket) CurrentMarketType = newMarket; } - _userSettingService.CurrentSetting.CurrentMarketType = newMarket; - _userSettingService.SaveSettings(); + // 与持久化共用同步边界,避免与其它线程的设置保存交错 + _userSettingService.UpdateSetting(setting => setting.CurrentMarketType = newMarket); OnPropertyChanged(nameof(CurrentMarket)); MarketChanged?.Invoke(this, new MarketChangedEventArgs(previousMarket, newMarket)); } diff --git a/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs b/src/MarketAssistant.App.Services/Services/Mcp/McpService.cs index eec5aaa..d33bb1c 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,104 @@ 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 = []; + + /// 活动 MCP 连接计数。与 _activeRuntimes 同步维护,供属性无锁读取,避免 UI 线程同步等待信号量。 + private int _activeRuntimeCount; + private bool _disposed; /// - /// 已连接的 MCP 服务器数量 + /// 当前活动配置对应的 MCP 连接数量。 + /// 配置刷新后,旧连接会保留到服务释放,但不计入活动连接。 /// public int ActiveConnectionCount { - get { lock (_clientsLock) return _mcpClients.Count; } + get => Volatile.Read(ref _activeRuntimeCount); } - /// - /// 创建 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 +115,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,106 +139,300 @@ public static IClientTransport CreateClientTransport(MCPServerConfig config) } /// - /// 创建 Stdio 传输 + /// 使当前活动连接映射失效。 + /// 已向 Agent 暴露的工具持有底层客户端引用,因此旧客户端不能在刷新时立即释放。 /// - private static IClientTransport CreateStdioTransport(MCPServerConfig config) + public async Task ResetConnectionsAsync(CancellationToken cancellationToken = default) { - var arguments = string.IsNullOrEmpty(config.Arguments) - ? Array.Empty() - : config.Arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries); + await _runtimeGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + var invalidatedCount = Interlocked.Exchange(ref _activeRuntimeCount, 0); + _activeRuntimes.Clear(); - return new StdioClientTransport(new() + _logger.LogInformation( + "已使 {Count} 个 MCP 活动连接失效;旧连接将保留到应用退出,以保证已注入工具仍可调用", + invalidatedCount); + } + finally { - Name = config.Name, - Command = config.Command, - Arguments = arguments, - EnvironmentVariables = config.EnvironmentVariables - }); + _runtimeGate.Release(); + } } - /// - /// 创建 SSE 传输 - /// - private static IClientTransport CreateSseTransport(MCPServerConfig config) + public async ValueTask DisposeAsync() { - return new HttpClientTransport(new() + List runtimesToDispose; + + await _runtimeGate.WaitAsync().ConfigureAwait(false); + try { - Name = config.Name, - TransportMode = HttpTransportMode.AutoDetect, - Endpoint = new Uri(config.Command) - }); - } + if (_disposed) + return; - /// - /// 创建 Streamable HTTP 传输 - /// - private static IClientTransport CreateStreamableHttpTransport(MCPServerConfig config) - { - return new HttpClientTransport(new() + _disposed = true; + runtimesToDispose = [.. _retainedRuntimes]; + _activeRuntimes.Clear(); + _retainedRuntimes.Clear(); + Interlocked.Exchange(ref _activeRuntimeCount, 0); + } + finally { - Name = config.Name, - TransportMode = HttpTransportMode.StreamableHttp, - Endpoint = new Uri(config.Command) - }); + _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); } - /// - /// 断开所有现有 MCP 连接,释放资源。 - /// 用于配置变更后重建连接。 - /// - public async Task ResetConnectionsAsync() + internal static string ComputeConfigurationFingerprint(MCPServerConfig config) { - List clientsToDispose; - lock (_clientsLock) + 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)) { - clientsToDispose = [.. _mcpClients]; - _mcpClients.Clear(); + canonical + .Append(variable.Key) + .Append('=') + .Append(variable.Value) + .Append('\n'); } - foreach (var client in clientsToDispose) + 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 { - try + ThrowIfDisposed(); + + if (_activeRuntimes.TryGetValue(fingerprint, out var existingRuntime)) + return existingRuntime; + + if (_retainedRuntimes.Count >= MaxRetainedRuntimes) { - await client.DisposeAsync(); + throw new InvalidOperationException( + $"MCP Runtime 已达到安全上限 {MaxRetainedRuntimes}。" + + "为避免释放仍被 Agent 工具引用的客户端,本次连接被拒绝;请重启应用后重试。"); } - catch (Exception ex) + + var runtime = await CreateRuntimeAsync(config, fingerprint, cancellationToken) + .ConfigureAwait(false); + _activeRuntimes.Add(fingerprint, runtime); + _retainedRuntimes.Add(runtime); + Interlocked.Increment(ref _activeRuntimeCount); + 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)) { - _logger.LogWarning(ex, "重置 MCP 连接时释放客户端出错"); + _auditLogger.LogToolFiltered(config.Name, toolName, "不在允许列表中"); + continue; } + + _auditLogger.LogToolLoaded(config.Name, toolName, config.Category); + destination.Add(tool); + loadedCount++; } - _logger.LogInformation("已重置 {Count} 个 MCP 连接", clientsToDispose.Count); + return loadedCount; + } + + private static IClientTransport CreateStdioTransport(MCPServerConfig config) + { + return new StdioClientTransport(new StdioClientTransportOptions + { + Name = config.Name, + Command = config.Command, + Arguments = ParseStdioArguments(config.Arguments), + EnvironmentVariables = config.EnvironmentVariables + }); } /// - /// 释放资源 + /// 解析 stdio 启动参数:支持双引号包裹含空格的参数(如 Windows 下 "C:\Program Files\..." 路径), + /// 避免简单按空格切分导致带空格路径被拆散。 /// - public async ValueTask DisposeAsync() + private static string[] ParseStdioArguments(string? arguments) { - if (_disposed) - return; + if (string.IsNullOrWhiteSpace(arguments)) + return []; - List clientsToDispose; - lock (_clientsLock) - { - clientsToDispose = [.. _mcpClients]; - _mcpClients.Clear(); - } + var parts = new List(); + var current = new StringBuilder(); + var inQuotes = false; - foreach (var mcpClient in clientsToDispose) + foreach (var ch in arguments) { - try + if (ch == '"') { - await mcpClient.DisposeAsync(); + inQuotes = !inQuotes; } - catch (Exception ex) + else if (char.IsWhiteSpace(ch) && !inQuotes) + { + if (current.Length > 0) + { + parts.Add(current.ToString()); + current.Clear(); + } + } + else { - _logger.LogWarning(ex, "释放 MCP 客户端时发生错误"); + current.Append(ch); } } - _disposed = true; - GC.SuppressFinalize(this); + if (current.Length > 0) + parts.Add(current.ToString()); + + return [.. parts]; } + + private static IClientTransport CreateSseTransport(MCPServerConfig config) + { + return new HttpClientTransport(new HttpClientTransportOptions + { + Name = config.Name, + TransportMode = HttpTransportMode.AutoDetect, + Endpoint = new Uri(config.Command) + }); + } + + private static IClientTransport CreateStreamableHttpTransport(MCPServerConfig config) + { + return new HttpClientTransport(new HttpClientTransportOptions + { + Name = config.Name, + TransportMode = HttpTransportMode.StreamableHttp, + Endpoint = new Uri(config.Command) + }); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + + private sealed record McpRuntime( + string ServerName, + string Fingerprint, + IMcpClientSession Session, + IReadOnlyList Tools); } +internal interface IMcpClientSessionFactory +{ + Task CreateAsync( + MCPServerConfig config, + CancellationToken cancellationToken); +} + +internal interface IMcpClientSession : IAsyncDisposable +{ + Task> ListToolsAsync(CancellationToken cancellationToken); +} + +internal sealed class McpClientSessionFactory : IMcpClientSessionFactory +{ + public async Task CreateAsync( + MCPServerConfig config, + CancellationToken cancellationToken) + { + var transport = McpService.CreateClientTransport(config); + var options = new McpClientOptions + { + ClientInfo = new() { Name = config.Name, Version = "1.0.0" } + }; + var client = await McpClient.CreateAsync( + transport, + options, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + return new McpClientSession(client); + } +} + +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(); + } + + 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/MemoryExtractionService.cs b/src/MarketAssistant.App.Services/Services/MemoryExtractionService.cs index b3dfc2a..2c2dc6f 100644 --- a/src/MarketAssistant.App.Services/Services/MemoryExtractionService.cs +++ b/src/MarketAssistant.App.Services/Services/MemoryExtractionService.cs @@ -1,9 +1,9 @@ +using MarketAssistant.Agents.Analysts; using MarketAssistant.Infrastructure.Core; using MarketAssistant.Infrastructure.Factories; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using System.Text; -using System.Text.Json; namespace MarketAssistant.Services; @@ -75,7 +75,7 @@ public async Task ExtractAndSaveAsync( private async Task ExtractMemoriesAsync( IReadOnlyList history, CancellationToken ct) { - var chatClient = _chatClientFactory.CreateClient(); + var runtime = _chatClientFactory.CreateRuntime(); var conversationText = new StringBuilder(); foreach (var msg in history.TakeLast(20)) @@ -94,17 +94,7 @@ public async Task ExtractAndSaveAsync( {{conversationText}} - 请以 JSON 格式返回提取结果,仅包含确实值得记住的内容(不要生造): - ```json - { - "memories": [ - {"category": "preference|correction|conclusion|profile", "key": "简短唯一标识", "value": "简洁的记忆内容"} - ], - "relations": [ - {"subject": "主体", "predicate": "关注|持有|分析过|属于行业|影响", "object": "客体"} - ] - } - ``` + 返回符合请求响应格式的结构化结果,仅包含确实值得记住的内容,不要生造。 规则: - memories: 只提取用户明确表达的偏好、被纠正的认知、重要结论、个人信息 @@ -112,10 +102,21 @@ public async Task ExtractAndSaveAsync( - 如果没有值得提取的内容,返回空数组 - key 和 value 用中文,保持简洁 """; + prompt = StructuredOutputOptions.AppendSchemaInstructions( + prompt, + typeof(ExtractedMemories), + runtime.StructuredOutputMode); - var response = await chatClient.GetResponseAsync( + var response = await runtime.Client.GetResponseAsync( [new ChatMessage(ChatRole.User, prompt)], - new ChatOptions { Temperature = 0.1f, MaxOutputTokens = 500 }, + new ChatOptions + { + ResponseFormat = StructuredOutputOptions.CreateResponseFormat( + typeof(ExtractedMemories), + runtime.StructuredOutputMode), + Temperature = 0.1f, + MaxOutputTokens = 500 + }, ct); var text2 = response.Text; diff --git a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs index 8f7306f..a3113aa 100644 --- a/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App.Services/Services/ServiceCollectionExtensions.cs @@ -29,6 +29,7 @@ using MarketAssistant.Infrastructure.AdaptiveCards.Parsers; using MarketAssistant.Infrastructure.Factories; using MarketAssistant.Infrastructure.Http; +using MarketAssistant.Infrastructure.Providers; using MarketAssistant.Rag.Extensions; using MarketAssistant.Services.Archive; using MarketAssistant.Services.Cache; @@ -231,6 +232,14 @@ private static IServiceCollection AddHttpClientsCore(IServiceCollection services client.DefaultRequestHeaders.UserAgent.ParseAdd(AppInfo.UserAgent); }); + // OpenAI SDK 已提供请求重试;此客户端只负责匿名传输,避免叠加 HttpClient resilience 重试。 + services.AddHttpClient("AnonymousOpenAI", client => + { + client.Timeout = TimeSpan.FromMinutes(3); + client.DefaultRequestHeaders.Accept.ParseAdd("application/json"); + client.DefaultRequestHeaders.UserAgent.ParseAdd(AppInfo.UserAgent); + }); + return services; } @@ -278,6 +287,7 @@ private static IServiceCollection AddAgentTools(this IServiceCollection services private static IServiceCollection AddAgentInfrastructure(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); // 延迟工厂:仅在向量化等真实场景解析,避免浏览设置页时构造嵌入/向量存储链路 services.AddSingleton>(sp => sp.GetRequiredService); services.AddSingleton(); @@ -293,16 +303,19 @@ private static IServiceCollection AddAgentInfrastructure(this IServiceCollection 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( - skillPath: Path.Combine(AppContext.BaseDirectory, "skills"))); + skillPath: Path.Combine(AppContext.BaseDirectory, "skills"), + options: new AgentSkillsProviderOptions + { + DisableLoadSkillApproval = true, + DisableReadSkillResourceApproval = true, + DisableRunSkillScriptApproval = true + })); services.AddSingleton(); services.AddSingleton(); @@ -432,9 +445,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/IUserSettingService.cs b/src/MarketAssistant.App.Services/Services/Settings/IUserSettingService.cs index b062a63..03966ef 100644 --- a/src/MarketAssistant.App.Services/Services/Settings/IUserSettingService.cs +++ b/src/MarketAssistant.App.Services/Services/Settings/IUserSettingService.cs @@ -28,6 +28,14 @@ public interface IUserSettingService /// 新的用户设置 void UpdateSettings(UserSetting setting); + /// + /// 在与持久化相同的同步边界内变更设置并保存。 + /// 跨线程修改设置(如交易线程切换环境、市场上下文切换市场)必须走此入口, + /// 避免锁外变更与 的并发序列化产生撕裂状态; + /// UI 双向绑定在 UI 线程上的直接属性变更可继续使用 + 。 + /// + void UpdateSetting(Action mutate); + /// /// 重置设置为默认值 /// 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..598155e 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,37 @@ 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(); } + 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 +109,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; } } } @@ -94,8 +132,116 @@ public void SaveSettings() /// 新的用户设置 public void UpdateSettings(UserSetting setting) { - _currentSetting = setting; - SaveSettings(); + ArgumentNullException.ThrowIfNull(setting); + + lock (_fileLock) + { + _currentSetting = setting; + SaveSettings(); + } + } + + /// + public void UpdateSetting(Action mutate) + { + ArgumentNullException.ThrowIfNull(mutate); + + // Monitor 对同一线程可重入:变更与保存共用 _fileLock, + // 保证跨线程变更不会与序列化/文件替换交错 + lock (_fileLock) + { + mutate(_currentSetting); + SaveSettings(); + } + } + + 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; + } } /// @@ -103,8 +249,10 @@ public void UpdateSettings(UserSetting setting) /// public void ResetSettings() { - _currentSetting = new UserSetting(); - SaveSettings(); + lock (_fileLock) + { + _currentSetting = new UserSetting(); + SaveSettings(); + } } } - diff --git a/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs b/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs index bca67dc..9659b5a 100644 --- a/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs +++ b/src/MarketAssistant.App.Services/Trading/AISignalStrategyExecutor.cs @@ -45,20 +45,77 @@ public async Task ExecuteAsync( { if (TryHandleHardBoundary(strategy, currentPrice, out var boundaryReasoning)) { - strategy.Side = strategy.Side == OrderSide.Buy ? OrderSide.Sell : OrderSide.Buy; - var boundaryResult = await _tradeExecutor.ExecuteTradeAsync( - strategy, - currentPrice, - boundaryReasoning, - requireClose: true, - ct: ct).ConfigureAwait(false); - - return new AISignalExecutionResult(boundaryResult.Success ? boundaryResult.Record : null); + return await ExecuteHardBoundaryAsync(strategy, currentPrice, boundaryReasoning, ct) + .ConfigureAwait(false); } return await ExecuteWithAgentAsync(strategy, currentPrice, ct).ConfigureAwait(false); } + /// + /// 硬性边界(止损/止盈)处理:确认仍有持仓后反向平仓。 + /// 平仓成功或持仓已消失时完结策略,防止退出条件兑现后策略按评估间隔反复触发 + /// (TradeExecutor 仅在成功时回写触发计数,失败时保持 Active 留待下个冷却期重试)。 + /// + private async Task ExecuteHardBoundaryAsync( + TradingStrategy strategy, + decimal currentPrice, + string reasoning, + CancellationToken ct) + { + try + { + var positions = await _portfolioService.GetCurrentPositionsAsync(ct).ConfigureAwait(false); + var hasPosition = positions.Any(position => + position.Symbol.Equals(strategy.Symbol, StringComparison.OrdinalIgnoreCase) && + Math.Abs(position.Quantity) > 0); + + if (!hasPosition) + { + // 持仓已不存在(如手动平仓),退出条件失去对象,策略使命结束 + _logger.LogInformation( + "硬性边界触发但已无持仓,直接完结策略: {StrategyId} {Symbol}", + strategy.Id, strategy.Symbol); + await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Completed, ct) + .ConfigureAwait(false); + return AISignalExecutionResult.NoTrade; + } + + // TradeExecutor 从 strategy 读取执行方向;用 try/finally 把翻转限制在本次调用内, + // 避免"持仓方向"语义被平仓动作污染(当前策略对象每次评估重新加载,此处防御性恢复使约定显式化) + var originalSide = strategy.Side; + try + { + strategy.Side = originalSide == OrderSide.Buy ? OrderSide.Sell : OrderSide.Buy; + + var boundaryResult = await _tradeExecutor.ExecuteTradeAsync( + strategy, currentPrice, reasoning, + requireClose: true, + ct: ct).ConfigureAwait(false); + + if (boundaryResult.Success) + { + // 止损/止盈退出即本策略使命完成;重新建仓应通过新策略表达 + _logger.LogInformation("硬性边界平仓成功,完结策略: {StrategyId}", strategy.Id); + await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Completed, ct) + .ConfigureAwait(false); + return new AISignalExecutionResult(boundaryResult.Record, boundaryResult); + } + + return new AISignalExecutionResult(null, boundaryResult); + } + finally + { + strategy.Side = originalSide; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "硬性边界处理失败: {StrategyId}", strategy.Id); + return AISignalExecutionResult.Failed; + } + } + private async Task ExecuteWithAgentAsync( TradingStrategy strategy, decimal currentPrice, @@ -82,7 +139,7 @@ private async Task ExecuteWithAgentAsync( catch (Exception ex) { _logger.LogError(ex, "AI 信号策略执行失败: {StrategyId}", strategy.Id); - return AISignalExecutionResult.None; + return AISignalExecutionResult.Failed; } finally { @@ -189,7 +246,7 @@ private async Task BuildAIPromptAsync( private async Task InvokeAgentAsync(string prompt, CancellationToken ct) { - var agent = _agentFactory.CreateAgent(); + var agent = _agentFactory.CreateAutomationAgent(); var messages = new List { new(ChatRole.User, prompt) @@ -209,7 +266,7 @@ private async Task ProcessAgentResponseAsync( .ConfigureAwait(false); var newestRecord = recentRecords.FirstOrDefault(); if (newestRecord == null || newestRecord.Id == priorLatestRecordId) - return AISignalExecutionResult.None; + return AISignalExecutionResult.NoTrade; await _dataService.UpdateStrategyTriggeredAsync(strategy.Id, ct).ConfigureAwait(false); return new AISignalExecutionResult(newestRecord); @@ -275,13 +332,51 @@ private string BuildAnalysisContext(string symbol) public sealed class AISignalExecutionResult { - public static AISignalExecutionResult None { get; } = new(null); + /// + /// AI 决策为 HOLD 或策略已自然完结(无持仓),属正常路径:不进失败冷却, + /// 重试频率由 StrategyEngine 的 LastTriggeredAt(analysisInterval)节流。 + /// + public static AISignalExecutionResult NoTrade { get; } = new(null, null, AISignalOutcome.NoTrade); + + /// + /// 执行过程发生异常。进入失败冷却,防止每个价格 tick 重复失败。 + /// + public static AISignalExecutionResult Failed { get; } = new(null, null, AISignalOutcome.Failed); public AISignalExecutionResult(TradeRecord? record) + : this(record, null, record != null ? AISignalOutcome.Executed : AISignalOutcome.NoTrade) + { + } + + public AISignalExecutionResult(TradeRecord? record, TradeResult? tradeResult) + : this(record, tradeResult, record != null ? AISignalOutcome.Executed : AISignalOutcome.Failed) + { + } + + private AISignalExecutionResult(TradeRecord? record, TradeResult? tradeResult, AISignalOutcome outcome) { Record = record; + TradeResult = tradeResult; + Outcome = outcome; } public TradeRecord? Record { get; } + public TradeResult? TradeResult { get; } + public AISignalOutcome Outcome { get; } public bool TradeExecuted => Record != null; +} + +/// +/// AISignal 策略单次执行的结局 +/// +public enum AISignalOutcome +{ + /// 已成交(含硬性边界平仓成功) + Executed, + + /// 未交易且无异常(HOLD 决策 / 无持仓完结) + NoTrade, + + /// 执行异常(网络、Agent 调用失败等) + Failed } \ No newline at end of file diff --git a/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs b/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs index 4afd9ca..54a55de 100644 --- a/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs +++ b/src/MarketAssistant.App.Services/Trading/CryptoPortfolioService.cs @@ -41,7 +41,7 @@ public CryptoPortfolioService( _logger = logger; } - public async Task GetAccountBalanceSummaryAsync(CancellationToken ct = default) + public virtual async Task GetAccountBalanceSummaryAsync(CancellationToken ct = default) { var cacheKey = CacheKeys.GetCryptoAccountSummaryKey(_environmentService.CurrentMode); if (_memoryCache.TryGetValue(cacheKey, out AccountBalanceSummary? cached) && cached != null) @@ -53,7 +53,8 @@ public async Task GetAccountBalanceSummaryAsync(Cancellat return summary; } - public async Task> GetCurrentPositionsAsync(CancellationToken ct = default) + /// virtual 供单元测试替换(AISignal 硬性边界行为测试)。 + public virtual async Task> GetCurrentPositionsAsync(CancellationToken ct = default) { var accountInfo = await _exchangeClient.GetAccountInfoAsync(ct); var positions = new List(); diff --git a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs index b8b687d..92c015d 100644 --- a/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs +++ b/src/MarketAssistant.App.Services/Trading/MarketMonitor.cs @@ -45,6 +45,12 @@ public class MarketMonitor : IDisposable /// private static readonly TimeSpan StrategyFailureCooldown = TimeSpan.FromSeconds(30); + /// + /// 网络类失败的短冷却:网络异常属临时状态,快速重试以免错过触发窗口 + /// (止损单挂 30 秒的冷却可能已造成实际损失)。 + /// + private static readonly TimeSpan NetworkFailureCooldown = TimeSpan.FromSeconds(5); + /// /// 各策略最近一次执行失败的冷却截止时间(UTC) /// @@ -330,7 +336,8 @@ private async Task ExecuteWithStrategyLockAsync( catch (Exception ex) { _logger.LogError(ex, "策略执行异常: {StrategyId}", strategy.Id); - RecordStrategyFailureCooldown(strategy.Id); + // 未分类异常(未走到 TradeResult 分类)按常规冷却处理 + RecordStrategyFailureCooldown(strategy.Id, StrategyFailureCooldown); } finally { @@ -341,8 +348,8 @@ private async Task ExecuteWithStrategyLockAsync( /// /// 记录策略执行失败的冷却截止时间 /// - private void RecordStrategyFailureCooldown(string strategyId) - => _strategyFailureCooldowns[strategyId] = DateTime.UtcNow.Add(StrategyFailureCooldown); + private void RecordStrategyFailureCooldown(string strategyId, TimeSpan cooldown) + => _strategyFailureCooldowns[strategyId] = DateTime.UtcNow.Add(cooldown); /// /// 判断策略是否处于执行失败冷却期内;冷却期结束后自动清理记录 @@ -370,10 +377,10 @@ private async Task HandleTriggeredStrategyAsync(TradingStrategy strategy, decima if (result.TradeExecuted && result.Record != null) TradeExecuted?.Invoke(result.Record); - if (result.TradeExecuted) - _strategyFailureCooldowns.TryRemove(strategy.Id, out _); - else - RecordStrategyFailureCooldown(strategy.Id); + // HOLD/无持仓完结是正常路径:不进失败冷却,重试频率由 + // StrategyEngine 的 LastTriggeredAt(analysisInterval)节流 + if (result.Outcome != AISignalOutcome.NoTrade) + await ApplyTradeFailurePolicy(strategy, result.TradeExecuted, result.TradeResult); await CheckStrategyCompletionAsync(strategy); } @@ -390,16 +397,61 @@ private async Task HandleTriggeredStrategyAsync(TradingStrategy strategy, decima if (result.Success && result.Record != null) TradeExecuted?.Invoke(result.Record); - // 执行成功清除失败冷却记录;失败则进入冷却期,避免下一 tick 立即重复触发 - if (result.Success) - _strategyFailureCooldowns.TryRemove(strategy.Id, out _); - else - RecordStrategyFailureCooldown(strategy.Id); + await ApplyTradeFailurePolicy(strategy, result.Success, result); await CheckStrategyCompletionAsync(strategy); } } + /// + /// 按失败类别应用后续策略: + /// - 成功:清除冷却记录; + /// - 拒绝类(风控/人工确认被拒/持仓校验不通过):重试不会改变结果,暂停策略并通知用户,防永久空转; + /// - 网络类:短冷却后快速重试,避免错过止损/止盈窗口; + /// - 其他:常规 30s 冷却。 + /// + private async Task ApplyTradeFailurePolicy(TradingStrategy strategy, bool success, TradeResult? tradeResult) + { + if (success) + { + _strategyFailureCooldowns.TryRemove(strategy.Id, out _); + return; + } + + var category = tradeResult?.FailureCategory ?? TradeFailureCategory.Other; + if (category == TradeFailureCategory.Rejected) + { + _strategyFailureCooldowns.TryRemove(strategy.Id, out _); + await PauseStrategyAfterRejectionAsync(strategy, tradeResult); + return; + } + + RecordStrategyFailureCooldown( + strategy.Id, + category == TradeFailureCategory.Network ? NetworkFailureCooldown : StrategyFailureCooldown); + } + + /// + /// 拒绝类失败后暂停策略:状态置为 Paused(用户可在策略页重新启用), + /// 并弹通知让用户知晓(策略静默停摆比反复重试更危险)。 + /// + private async Task PauseStrategyAfterRejectionAsync(TradingStrategy strategy, TradeResult? tradeResult) + { + var reason = tradeResult?.ErrorMessage ?? "交易被拒绝"; + _logger.LogWarning( + "策略 {StrategyId}({Type} {Symbol})触发被拒绝,已自动暂停: {Reason}", + strategy.Id, strategy.Type, strategy.Symbol, reason); + + try + { + await _strategyService.UpdateStrategyStatusAsync(strategy.Id, StrategyStatus.Paused, MonitorToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "暂停被拒策略失败,策略将保持 Active: {StrategyId}", strategy.Id); + } + } + /// /// 判定策略本次触发是否为"平仓退出"语义:无对应持仓时应在执行器层拒绝下单, /// 防止合约模式下退出型触发在持仓已平后反向开出新仓。 diff --git a/src/MarketAssistant.App.Services/Trading/RiskManager.cs b/src/MarketAssistant.App.Services/Trading/RiskManager.cs index 3cea840..b862ee6 100644 --- a/src/MarketAssistant.App.Services/Trading/RiskManager.cs +++ b/src/MarketAssistant.App.Services/Trading/RiskManager.cs @@ -32,7 +32,8 @@ public RiskManager( /// /// 校验交易是否通过风控检查 /// - public async Task ValidateOrderAsync( + /// virtual 供单元测试替换(并发卖出锁内复检测试)。 + public virtual async Task ValidateOrderAsync( string instrumentSymbol, OrderSide side, decimal quantity, decimal price, OrderType orderType = OrderType.Market, CancellationToken ct = default) @@ -111,44 +112,48 @@ public async Task ValidateOrderAsync( if (side == OrderSide.Sell) { var baseAsset = ExtractBaseAsset(instrumentSymbol); - if (!string.IsNullOrEmpty(baseAsset)) + if (string.IsNullOrEmpty(baseAsset)) { - if (_exchangeClient.IsFutures) + // fail-closed:无法解析基础资产意味着无法校验持仓充足性,必须拒绝而非跳过校验 + return RiskCheckResult.Reject( + $"无法解析交易对 {instrumentSymbol} 的基础资产,卖出持仓校验失败(fail-closed)"); + } + + if (_exchangeClient.IsFutures) + { + // 合约模式:检查交易所实际持仓,仅当持有多头时才校验平仓数量 + try { - // 合约模式:检查交易所实际持仓,仅当持有多头时才校验平仓数量 - try - { - var exchangePositions = await _exchangeClient.GetPositionsAsync(instrumentSymbol, ct).ConfigureAwait(false); - var longPosition = exchangePositions.FirstOrDefault(p => - string.Equals(p.Symbol, instrumentSymbol, StringComparison.OrdinalIgnoreCase) && - p.PositionAmt > 0); - - if (longPosition != null && quantity > longPosition.PositionAmt) - { - return RiskCheckResult.Reject( - $"平多数量 {quantity} 超过交易所多头持仓 {longPosition.PositionAmt}"); - } - } - catch (Exception ex) + var exchangePositions = await _exchangeClient.GetPositionsAsync(instrumentSymbol, ct).ConfigureAwait(false); + var longPosition = exchangePositions.FirstOrDefault(p => + string.Equals(p.Symbol, instrumentSymbol, StringComparison.OrdinalIgnoreCase) && + p.PositionAmt > 0); + + if (longPosition != null && quantity > longPosition.PositionAmt) { - // 查询交易所持仓失败时不阻止交易(可能是网络问题),仅记录警告 - _logger.LogWarning(ex, "查询交易所持仓用于风控校验失败,跳过合约平多校验: {Symbol}", instrumentSymbol); + return RiskCheckResult.Reject( + $"平多数量 {quantity} 超过交易所多头持仓 {longPosition.PositionAmt}"); } } - else + catch (Exception ex) { - // 现货模式:使用本地 FIFO 持仓追踪校验 - // 注意用剩余未平仓数量(Quantity - ClosedQuantity)而非原始开仓量, - // 否则部分平仓后仍按全额校验,会允许超出实际可卖数量的超卖 - var positions = await _dataService.GetOpenPositionsAsync(instrumentSymbol, ct).ConfigureAwait(false); - var availableQty = positions - .Where(p => p.Symbol.Equals(instrumentSymbol, StringComparison.OrdinalIgnoreCase)) - .Sum(p => p.RemainingQuantity); - if (quantity > availableQty) - return RiskCheckResult.Reject( - $"卖出数量 {quantity} 超过可用持仓 {availableQty}(含部分成交未同步的偏差)"); + // 查询交易所持仓失败时不阻止交易(可能是网络问题),仅记录警告 + _logger.LogWarning(ex, "查询交易所持仓用于风控校验失败,跳过合约平多校验: {Symbol}", instrumentSymbol); } } + else + { + // 现货模式:使用本地 FIFO 持仓追踪校验 + // 注意用剩余未平仓数量(Quantity - ClosedQuantity)而非原始开仓量, + // 否则部分平仓后仍按全额校验,会允许超出实际可卖数量的超卖 + var positions = await _dataService.GetOpenPositionsAsync(instrumentSymbol, ct).ConfigureAwait(false); + var availableQty = positions + .Where(p => p.Symbol.Equals(instrumentSymbol, StringComparison.OrdinalIgnoreCase)) + .Sum(p => p.RemainingQuantity); + if (quantity > availableQty) + return RiskCheckResult.Reject( + $"卖出数量 {quantity} 超过可用持仓 {availableQty}(含部分成交未同步的偏差)"); + } } // 最大回撤熔断 diff --git a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs index e96f548..88ed133 100644 --- a/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs +++ b/src/MarketAssistant.App.Services/Trading/StrategyEngine.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using MarketAssistant.Services.Trading.Exchanges; using MarketAssistant.Trading.Abstractions; @@ -359,7 +360,7 @@ private bool EvaluateAndUpdateGridTrading(TradingStrategy strategy, decimal curr } // 检查加倍冷却期 else if (dcaParams.LastDoubleBuyAt != null - && DateTime.TryParse(dcaParams.LastDoubleBuyAt, out var lastDouble) + && DateTime.TryParse(dcaParams.LastDoubleBuyAt, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var lastDouble) && (DateTime.UtcNow - lastDouble).TotalSeconds < dcaParams.DoubleBuyCooldownSeconds) { _logger.LogDebug("DCA 加倍冷却中: {StrategyId} 距上次加倍 {Elapsed:F0}s < 冷却 {Cooldown}s", diff --git a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs index dd24e16..8bebed0 100644 --- a/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs +++ b/src/MarketAssistant.App.Services/Trading/TradeExecutor.cs @@ -44,7 +44,8 @@ public TradeExecutor( /// 表示该触发语义为"平仓退出"(如止损、追踪止损、网格破网、AI 硬性边界): /// 合约模式下若交易所不存在对应方向的持仓则拒绝下单,防止退出型触发在无持仓时反向开出新仓。 /// - public async Task ExecuteTradeAsync( + /// virtual 供单元测试替换(AISignal 硬性边界行为测试)。 + public virtual async Task ExecuteTradeAsync( TradingStrategy strategy, decimal currentPrice, string? aiReasoning = null, string? pendingCustomParams = null, bool requireClose = false, @@ -108,19 +109,34 @@ public async Task ExecuteOrderAsync( var approved = await ConfirmationRequested.Invoke( instrumentSymbol, side, currentPrice, quantity, riskCheck.Reason ?? "需人工确认"); if (!approved) - return new TradeResult { Success = false, ErrorMessage = $"用户拒绝交易: {riskCheck.Reason}" }; + return new TradeResult + { + Success = false, + ErrorMessage = $"用户拒绝交易: {riskCheck.Reason}", + FailureCategory = TradeFailureCategory.Rejected + }; _logger.LogInformation("用户已确认交易: {InstrumentSymbol} {Side}", instrumentSymbol, side); } else { - return new TradeResult { Success = false, ErrorMessage = $"需人工确认: {riskCheck.Reason}" }; + return new TradeResult + { + Success = false, + ErrorMessage = $"需人工确认: {riskCheck.Reason}", + FailureCategory = TradeFailureCategory.Rejected + }; } } else if (!riskCheck.Passed) { _logger.LogWarning("风控拒绝: {Reason}", riskCheck.Reason); - return new TradeResult { Success = false, ErrorMessage = $"风控拒绝: {riskCheck.Reason}" }; + return new TradeResult + { + Success = false, + ErrorMessage = $"风控拒绝: {riskCheck.Reason}", + FailureCategory = TradeFailureCategory.Rejected + }; } // 仅在实际调用交易所 API 时持有 symbol 锁,防止同一标的并发重复下单 @@ -129,6 +145,31 @@ public async Task ExecuteOrderAsync( await gate.WaitAsync(ct).ConfigureAwait(false); try { + // 锁内复检:风控校验与人工确认在锁外完成(防确认等待期间锁死同一标的), + // 等待期间同一标的的其他现货卖出可能已消耗本地 FIFO 持仓。两笔并发卖出若都基于 + // 同一份持仓快照通过风控,会依次成交造成超卖与负持仓,故获取锁后必须重验。 + // 合约模式以交易所持仓为准且 reduceOnly 由交易所强制,无需本地复检。 + if (side == OrderSide.Sell && !_exchangeClient.IsFutures) + { + var openPositions = await _dataService.GetOpenPositionsAsync(instrumentSymbol, ct).ConfigureAwait(false); + var availableQuantity = openPositions + .Where(p => p.Symbol.Equals(instrumentSymbol, StringComparison.OrdinalIgnoreCase)) + .Sum(p => p.RemainingQuantity); + + if (quantity > availableQuantity) + { + _logger.LogWarning( + "锁内复检拒绝:{Symbol} 可平数量 {Available} 少于本次卖出 {Quantity}(并发卖出或确认等待期间持仓已变化)", + instrumentSymbol, availableQuantity, quantity); + return new TradeResult + { + Success = false, + ErrorMessage = $"并发校验失败:{instrumentSymbol} 可平数量 {availableQuantity} 少于本次卖出数量 {quantity}", + FailureCategory = TradeFailureCategory.Rejected + }; + } + } + return await ExecuteApprovedOrderAsync( instrumentSymbol, side, type, quantity, currentPrice, limitPrice, strategyId, aiReasoning, requireClose, ct).ConfigureAwait(false); @@ -147,9 +188,9 @@ private async Task ExecuteApprovedOrderAsync( { try { - // 生成幂等订单 ID:同一笔交易的所有重试使用相同 ID, - // 币安收到重复的 newClientOrderId 时返回已有订单而非新建,避免重复下单。 - // 币安限制 newClientOrderId 最长 36 字符,使用 Base36 编码压缩 GUID。 + // 生成订单客户端 ID("MA" + 16 位 hex,总长 18 ≤ 币安 36 字符上限)。 + // 同一次下单内的网络异常重试复用该 ID:币安收到重复的 newClientOrderId 时返回已有订单而非新建,避免重复下单。 + // 注意:幂等性仅覆盖本方法的内部重试循环;跨调用重试(如人工重发)会生成新 ID。 var clientOrderId = "MA" + Convert.ToHexString(Guid.NewGuid().ToByteArray())[..16].ToLowerInvariant(); // 合约模式:判断本次操作是开仓还是平仓 @@ -170,7 +211,8 @@ private async Task ExecuteApprovedOrderAsync( return new TradeResult { Success = false, - ErrorMessage = $"策略要求平仓但 {instrumentSymbol} 无对应方向持仓,拒绝下单" + ErrorMessage = $"策略要求平仓但 {instrumentSymbol} 无对应方向持仓,拒绝下单", + FailureCategory = TradeFailureCategory.Rejected }; } @@ -274,7 +316,11 @@ await _dataService.UpdateDailyStatsAsync(pnl, record.Commission, catch (Exception ex) { _logger.LogError(ex, "交易执行失败: {InstrumentSymbol} {Side}", instrumentSymbol, side); - return new TradeResult { Success = false, ErrorMessage = ex.Message }; + // 网络类异常(含重试耗尽)短期可恢复;其余归为其他失败由调用方按冷却策略处理 + var category = ex is HttpRequestException or TimeoutException || ex.InnerException is HttpRequestException + ? TradeFailureCategory.Network + : TradeFailureCategory.Other; + return new TradeResult { Success = false, ErrorMessage = ex.Message, FailureCategory = category }; } } diff --git a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs index 3e78b10..a79854f 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingDataService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingDataService.cs @@ -47,6 +47,36 @@ public TradingDataService( private bool IsFuturesMode => _tradingEnvironmentService.CurrentMode is CryptoTradingMode.LiveFutures or CryptoTradingMode.BinanceFuturesTestnet; + #region decimal 存取辅助 + + /// + /// 金额/数量列以 TEXT(InvariantCulture 十进制字符串)存储, + /// 避免 REAL(double)存储对高精度小数(如 8 位小数价格)造成精度损失。 + /// + private static object ToDb(decimal value) => value.ToString(CultureInfo.InvariantCulture); + + /// + private static object ToDbNullable(decimal? value) => value.HasValue ? ToDb(value.Value) : DBNull.Value; + + /// + /// 读取金额/数量列,兼容 TEXT(新格式)与 REAL/INTEGER(历史数据)两种存储形态。 + /// + private static decimal ReadDecimal(SqliteDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + return 0; + + return reader.GetValue(ordinal) switch + { + string s when decimal.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) => parsed, + double d => (decimal)d, + long l => l, + _ => 0 + }; + } + + #endregion + #region 策略 CRUD public async Task SaveStrategyAsync(TradingStrategy strategy, CancellationToken ct = default) @@ -70,17 +100,17 @@ INSERT OR REPLACE INTO strategies cmd.Parameters.AddWithValue("@type", (int)strategy.Type); cmd.Parameters.AddWithValue("@status", (int)strategy.Status); cmd.Parameters.AddWithValue("@side", (int)strategy.Side); - cmd.Parameters.AddWithValue("@triggerPrice", (double)strategy.TriggerPrice); - cmd.Parameters.AddWithValue("@slPrice", strategy.StopLossPrice.HasValue ? (object)(double)strategy.StopLossPrice.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@tpPrice", strategy.TakeProfitPrice.HasValue ? (object)(double)strategy.TakeProfitPrice.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@qty", (double)strategy.Quantity); - cmd.Parameters.AddWithValue("@maxPos", strategy.MaxPositionPercent.HasValue ? (object)(double)strategy.MaxPositionPercent.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@triggerPrice", ToDb(strategy.TriggerPrice)); + cmd.Parameters.AddWithValue("@slPrice", ToDbNullable(strategy.StopLossPrice)); + cmd.Parameters.AddWithValue("@tpPrice", ToDbNullable(strategy.TakeProfitPrice)); + cmd.Parameters.AddWithValue("@qty", ToDb(strategy.Quantity)); + cmd.Parameters.AddWithValue("@maxPos", ToDbNullable(strategy.MaxPositionPercent)); cmd.Parameters.AddWithValue("@customParams", (object?)strategy.CustomParams ?? DBNull.Value); cmd.Parameters.AddWithValue("@createdAt", strategy.CreatedAt.ToString("O")); cmd.Parameters.AddWithValue("@lastTriggered", strategy.LastTriggeredAt.HasValue ? (object)strategy.LastTriggeredAt.Value.ToString("O") : DBNull.Value); cmd.Parameters.AddWithValue("@execCount", strategy.ExecutionCount); cmd.Parameters.AddWithValue("@maxExec", strategy.MaxExecutions.HasValue ? (object)strategy.MaxExecutions.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@trailingPeak", strategy.TrailingPeakPrice.HasValue ? (object)(double)strategy.TrailingPeakPrice.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@trailingPeak", ToDbNullable(strategy.TrailingPeakPrice)); cmd.Parameters.AddWithValue("@nativeOrderId", (object?)strategy.NativeOrderId ?? DBNull.Value); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } @@ -233,7 +263,7 @@ public async Task UpdateStrategyTrailingPeakAsync(string id, decimal? trailingPe cmd.CommandText = "UPDATE strategies SET trailing_peak_price = @peak WHERE id = @id AND environment = @environment"; cmd.Parameters.AddWithValue("@id", id); cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@peak", trailingPeakPrice.HasValue ? (object)(double)trailingPeakPrice.Value : DBNull.Value); + cmd.Parameters.AddWithValue("@peak", ToDbNullable(trailingPeakPrice)); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } @@ -256,7 +286,7 @@ public async Task UpdateStrategyNativeOrderIdAsync(string id, string? nativeOrde #region 交易记录 - public async Task SaveTradeRecordAsync(TradeRecord record, CancellationToken ct = default) + public virtual async Task SaveTradeRecordAsync(TradeRecord record, CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); await using var conn = await OpenConnectionAsync(ct); @@ -277,11 +307,11 @@ INSERT INTO trade_records cmd.Parameters.AddWithValue("@symbol", record.Symbol); cmd.Parameters.AddWithValue("@side", (int)record.Side); cmd.Parameters.AddWithValue("@orderType", (int)record.OrderType); - cmd.Parameters.AddWithValue("@reqQty", (double)record.RequestedQty); - cmd.Parameters.AddWithValue("@execQty", (double)record.ExecutedQty); - cmd.Parameters.AddWithValue("@reqPrice", record.RequestedPrice.HasValue ? (object)(double)record.RequestedPrice.Value : DBNull.Value); - cmd.Parameters.AddWithValue("@execPrice", (double)record.ExecutedPrice); - cmd.Parameters.AddWithValue("@commission", (double)record.Commission); + cmd.Parameters.AddWithValue("@reqQty", ToDb(record.RequestedQty)); + cmd.Parameters.AddWithValue("@execQty", ToDb(record.ExecutedQty)); + cmd.Parameters.AddWithValue("@reqPrice", ToDbNullable(record.RequestedPrice)); + cmd.Parameters.AddWithValue("@execPrice", ToDb(record.ExecutedPrice)); + cmd.Parameters.AddWithValue("@commission", ToDb(record.Commission)); cmd.Parameters.AddWithValue("@commAsset", (object?)record.CommissionAsset ?? DBNull.Value); cmd.Parameters.AddWithValue("@status", (int)record.Status); cmd.Parameters.AddWithValue("@binanceId", record.ExchangeOrderId); @@ -509,7 +539,7 @@ await UpdateDailyStatsAsync(realizedPnl, deltaCommission, /// private static string GetTodayDateString() => DateTime.Now.ToString("yyyy-MM-dd"); - public async Task GetTodayStatsAsync(CancellationToken ct = default) + public virtual async Task GetTodayStatsAsync(CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); var today = GetTodayDateString(); @@ -526,8 +556,8 @@ public async Task GetTodayStatsAsync(CancellationToken ct = default) { Date = reader.GetString(reader.GetOrdinal("date")), TradeCount = reader.GetInt32(reader.GetOrdinal("trade_count")), - TotalPnl = (decimal)reader.GetDouble(reader.GetOrdinal("total_pnl")), - TotalCommission = (decimal)reader.GetDouble(reader.GetOrdinal("total_commission")) + TotalPnl = ReadDecimal(reader, reader.GetOrdinal("total_pnl")), + TotalCommission = ReadDecimal(reader, reader.GetOrdinal("total_commission")) }; } @@ -539,25 +569,46 @@ public async Task GetTodayStatsAsync(CancellationToken ct = default) /// 仅在订单首次实际成交(executed_qty 从 0 变为 >0)时为 true, /// 避免未成交订单被计数、以及下单与对账重复计数。 /// - public async Task UpdateDailyStatsAsync(decimal pnl, decimal commission, bool countTrade = true, CancellationToken ct = default) + public virtual async Task UpdateDailyStatsAsync(decimal pnl, decimal commission, bool countTrade = true, CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); var today = GetTodayDateString(); await using var conn = await OpenConnectionAsync(ct); + + // 金额列以 TEXT 精确存储,SQL 数值加法会把 TEXT 退化为 double, + // 因此在 C# 侧完成累加后整体写回,保持十进制精度。 + var tradeCount = countTrade ? 1 : 0; + var totalPnl = pnl; + var totalCommission = commission; + + await using (var selectCmd = conn.CreateCommand()) + { + selectCmd.CommandText = "SELECT trade_count, total_pnl, total_commission FROM daily_stats WHERE environment = @environment AND date = @date"; + selectCmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); + selectCmd.Parameters.AddWithValue("@date", today); + await using var reader = await selectCmd.ExecuteReaderAsync(ct); + if (await reader.ReadAsync(ct)) + { + tradeCount += reader.GetInt32(0); + totalPnl += ReadDecimal(reader, 1); + totalCommission += ReadDecimal(reader, 2); + } + } + await using var cmd = conn.CreateCommand(); cmd.CommandText = """ INSERT INTO daily_stats (environment, date, trade_count, total_pnl, total_commission) - VALUES (@environment, @date, @countTrade, @pnl, @comm) + VALUES (@environment, @date, @tradeCount, @pnl, @comm) ON CONFLICT(environment, date) DO UPDATE SET - trade_count = trade_count + @countTrade, - total_pnl = total_pnl + @pnl, - total_commission = total_commission + @comm + trade_count = @tradeCount, + total_pnl = @pnl, + total_commission = @comm """; cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); cmd.Parameters.AddWithValue("@date", today); - cmd.Parameters.AddWithValue("@countTrade", countTrade ? 1 : 0); - cmd.Parameters.AddWithValue("@pnl", (double)pnl); - cmd.Parameters.AddWithValue("@comm", (double)commission); + cmd.Parameters.AddWithValue("@tradeCount", tradeCount); + cmd.Parameters.AddWithValue("@pnl", ToDb(totalPnl)); + cmd.Parameters.AddWithValue("@comm", ToDb(totalCommission)); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } @@ -633,8 +684,8 @@ INSERT INTO positions (id, environment, symbol, side, quantity, entry_price, clo cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); cmd.Parameters.AddWithValue("@symbol", position.Symbol); cmd.Parameters.AddWithValue("@side", (int)position.Side); - cmd.Parameters.AddWithValue("@qty", (double)position.Quantity); - cmd.Parameters.AddWithValue("@entry", (double)position.EntryPrice); + cmd.Parameters.AddWithValue("@qty", ToDb(position.Quantity)); + cmd.Parameters.AddWithValue("@entry", ToDb(position.EntryPrice)); cmd.Parameters.AddWithValue("@stratId", (object?)position.StrategyId ?? DBNull.Value); cmd.Parameters.AddWithValue("@openedAt", position.OpenedAt.ToString("O")); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); @@ -644,7 +695,7 @@ INSERT INTO positions (id, environment, symbol, side, quantity, entry_price, clo /// 平仓:按 FIFO 顺序匹配指定方向的持仓,更新 closed_quantity,返回已实现盈亏。 /// 多头盈亏 = (平仓价 - 开仓价) × 数量;空头盈亏 = (开仓价 - 平仓价) × 数量。 /// - public async Task ClosePositionFifoAsync( + public virtual async Task ClosePositionFifoAsync( string symbol, decimal closeQty, decimal closePrice, CancellationToken ct = default, PositionSide side = PositionSide.Long) { @@ -675,9 +726,9 @@ ORDER BY opened_at ASC while (await reader.ReadAsync(ct)) { var id = reader.GetString(0); - var qty = (decimal)reader.GetDouble(1); - var entry = (decimal)reader.GetDouble(2); - var closed = (decimal)reader.GetDouble(3); + var qty = ReadDecimal(reader, 1); + var entry = ReadDecimal(reader, 2); + var closed = ReadDecimal(reader, 3); toClose.Add((id, qty - closed, entry)); } } @@ -702,7 +753,7 @@ ORDER BY opened_at ASC UPDATE positions SET closed_quantity = closed_quantity + @close WHERE id = @id """; - updateCmd.Parameters.AddWithValue("@close", (double)closeThis); + updateCmd.Parameters.AddWithValue("@close", ToDb(closeThis)); updateCmd.Parameters.AddWithValue("@id", id); await updateCmd.ExecuteNonQueryAsync(ct); @@ -722,7 +773,7 @@ ORDER BY opened_at ASC /// /// 获取指定 symbol 的当前未平仓多头持仓(用于 UI 展示与风控) /// - public async Task> GetOpenPositionsAsync(string? symbol = null, CancellationToken ct = default) + public virtual async Task> GetOpenPositionsAsync(string? symbol = null, CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); await using var conn = await OpenConnectionAsync(ct); @@ -789,9 +840,9 @@ private static Position ReadPosition(SqliteDataReader reader) Id = reader.GetString(reader.GetOrdinal("id")), Symbol = reader.GetString(reader.GetOrdinal("symbol")), Side = (PositionSide)reader.GetInt32(reader.GetOrdinal("side")), - Quantity = (decimal)reader.GetDouble(reader.GetOrdinal("quantity")), - EntryPrice = (decimal)reader.GetDouble(reader.GetOrdinal("entry_price")), - ClosedQuantity = (decimal)reader.GetDouble(reader.GetOrdinal("closed_quantity")), + Quantity = ReadDecimal(reader, reader.GetOrdinal("quantity")), + EntryPrice = ReadDecimal(reader, reader.GetOrdinal("entry_price")), + ClosedQuantity = ReadDecimal(reader, reader.GetOrdinal("closed_quantity")), OpenedAt = DateTime.Parse(reader.GetString(reader.GetOrdinal("opened_at")), CultureInfo.InvariantCulture) }; @@ -824,7 +875,7 @@ ON CONFLICT(environment, date) DO UPDATE SET """; cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); cmd.Parameters.AddWithValue("@date", today); - cmd.Parameters.AddWithValue("@value", (double)totalValueUsdt); + cmd.Parameters.AddWithValue("@value", ToDb(totalValueUsdt)); cmd.Parameters.AddWithValue("@snapshotAt", DateTime.UtcNow.ToString("O")); await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } @@ -837,7 +888,8 @@ public async Task GetPeakAccountValueAsync(CancellationToken ct = defau await EnsureInitializedAsync(InitializeDatabaseAsync); await using var conn = await OpenConnectionAsync(ct); await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT MAX(total_value_usdt) FROM account_snapshots WHERE environment = @environment"; + // 金额列以 TEXT 存储,MAX 会按字符串比较,需 CAST 为数值后再取最大值 + cmd.CommandText = "SELECT MAX(CAST(total_value_usdt AS REAL)) FROM account_snapshots WHERE environment = @environment"; cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); var result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false); if (result is double d) @@ -845,7 +897,7 @@ public async Task GetPeakAccountValueAsync(CancellationToken ct = defau return 0; } - public async Task LoadRiskConfigAsync(CancellationToken ct = default) + public virtual async Task LoadRiskConfigAsync(CancellationToken ct = default) { await EnsureInitializedAsync(InitializeDatabaseAsync); await using var conn = await OpenConnectionAsync(ct); @@ -914,6 +966,7 @@ internal static async Task MigrateDatabaseSchemaAsync(SqliteConnection conn) { await CreateTablesAsync(conn, transaction).ConfigureAwait(false); await EnsureEnvironmentSchemaAsync(conn, transaction).ConfigureAwait(false); + await MigrateMoneyColumnsToTextAsync(conn, transaction).ConfigureAwait(false); await CreateIndexesAsync(conn, transaction).ConfigureAwait(false); await transaction.CommitAsync().ConfigureAwait(false); } @@ -924,11 +977,22 @@ internal static async Task MigrateDatabaseSchemaAsync(SqliteConnection conn) } } - private static async Task CreateTablesAsync(SqliteConnection conn, SqliteTransaction transaction) + /// + /// 金额/数量列迁移计划:这些列在新 schema 中以 TEXT(十进制字符串)存储。 + /// + private static readonly (string Table, string[] MoneyColumns)[] MoneyColumnMigrations = + [ + ("strategies", ["trigger_price", "stop_loss_price", "take_profit_price", "quantity", "max_position_percent", "trailing_peak_price"]), + ("trade_records", ["requested_qty", "executed_qty", "requested_price", "executed_price", "commission"]), + ("daily_stats", ["total_pnl", "total_commission"]), + ("positions", ["quantity", "entry_price", "closed_quantity"]), + ("account_snapshots", ["total_value_usdt"]) + ]; + + /// 各表建表 DDL(金额/数量列为 TEXT),供新建库与迁移重建共用。 + private static readonly IReadOnlyDictionary TableDefinitions = new Dictionary { - await using var cmd = conn.CreateCommand(); - cmd.Transaction = transaction; - cmd.CommandText = """ + ["strategies"] = """ CREATE TABLE IF NOT EXISTS strategies ( id TEXT PRIMARY KEY, environment TEXT NOT NULL DEFAULT 'crypto-live-spot', @@ -936,20 +1000,21 @@ CREATE TABLE IF NOT EXISTS strategies ( type INTEGER NOT NULL, status INTEGER NOT NULL, side INTEGER NOT NULL, - trigger_price REAL NOT NULL, - stop_loss_price REAL, - take_profit_price REAL, - quantity REAL NOT NULL, - max_position_percent REAL, + trigger_price TEXT NOT NULL, + stop_loss_price TEXT, + take_profit_price TEXT, + quantity TEXT NOT NULL, + max_position_percent TEXT, custom_params TEXT, created_at TEXT NOT NULL, last_triggered_at TEXT, execution_count INTEGER DEFAULT 0, max_executions INTEGER, - trailing_peak_price REAL, + trailing_peak_price TEXT, native_order_id TEXT - ); - + ) + """, + ["trade_records"] = """ CREATE TABLE IF NOT EXISTS trade_records ( id TEXT PRIMARY KEY, environment TEXT NOT NULL DEFAULT 'crypto-live-spot', @@ -957,11 +1022,11 @@ CREATE TABLE IF NOT EXISTS trade_records ( symbol TEXT NOT NULL, side INTEGER NOT NULL, order_type INTEGER NOT NULL, - requested_qty REAL NOT NULL, - executed_qty REAL NOT NULL, - requested_price REAL, - executed_price REAL NOT NULL, - commission REAL DEFAULT 0, + requested_qty TEXT NOT NULL, + executed_qty TEXT NOT NULL, + requested_price TEXT, + executed_price TEXT NOT NULL, + commission TEXT DEFAULT '0', commission_asset TEXT, status INTEGER NOT NULL, binance_order_id INTEGER, @@ -969,48 +1034,142 @@ CREATE TABLE IF NOT EXISTS trade_records ( created_at TEXT NOT NULL, completed_at TEXT, FOREIGN KEY (strategy_id) REFERENCES strategies(id) - ); + ) + """, + ["daily_stats"] = """ CREATE TABLE IF NOT EXISTS daily_stats ( environment TEXT NOT NULL, date TEXT NOT NULL, trade_count INTEGER DEFAULT 0, - total_pnl REAL DEFAULT 0, - total_commission REAL DEFAULT 0, + total_pnl TEXT DEFAULT '0', + total_commission TEXT DEFAULT '0', PRIMARY KEY (environment, date) - ); - + ) + """, + ["positions"] = """ CREATE TABLE IF NOT EXISTS positions ( id TEXT PRIMARY KEY, environment TEXT NOT NULL DEFAULT 'crypto-live-spot', symbol TEXT NOT NULL, side INTEGER NOT NULL, - quantity REAL NOT NULL, - entry_price REAL NOT NULL, - closed_quantity REAL DEFAULT 0, + quantity TEXT NOT NULL, + entry_price TEXT NOT NULL, + closed_quantity TEXT DEFAULT '0', strategy_id TEXT, opened_at TEXT NOT NULL - ); - + ) + """, + ["account_snapshots"] = """ CREATE TABLE IF NOT EXISTS account_snapshots ( environment TEXT NOT NULL, date TEXT NOT NULL, - total_value_usdt REAL NOT NULL, + total_value_usdt TEXT NOT NULL, snapshot_at TEXT NOT NULL, PRIMARY KEY (environment, date) - ); - + ) + """, + ["risk_config"] = """ CREATE TABLE IF NOT EXISTS risk_config ( environment TEXT NOT NULL, market_type INTEGER NOT NULL, config_json TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY (environment, market_type) - ); - """; + ) + """ + }; + + private static async Task CreateTablesAsync(SqliteConnection conn, SqliteTransaction transaction) + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = string.Join(";\n\n", TableDefinitions.Values) + ";"; + await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); + } + + /// + /// 将历史库中金额列由 REAL 迁移为 TEXT(十进制字符串),消除 double 存储精度损失。 + /// SQLite 不支持直接修改列类型,通过"重命名旧表 → 按新 DDL 建表 → CAST 搬运 → 删旧表"重建。 + /// 重命名期间启用 PRAGMA legacy_alter_table = ON,避免其他表的 FOREIGN KEY 引用被改写到旧表名; + /// 旧表索引统一先删除,迁移完成后由 CreateIndexesAsync 重建。 + /// + internal static async Task MigrateMoneyColumnsToTextAsync(SqliteConnection conn, SqliteTransaction transaction) + { + // 先探测哪些表需要迁移,避免无谓的索引删除与表重建 + var pendingTables = new List<(string Table, HashSet MoneyColumns, List<(string Name, string Type)> Columns)>(); + foreach (var (table, moneyColumns) in MoneyColumnMigrations) + { + var columns = await GetTableColumnsAsync(conn, transaction, table).ConfigureAwait(false); + if (columns.Count == 0) + continue; + + var moneySet = moneyColumns.ToHashSet(StringComparer.OrdinalIgnoreCase); + var needsMigration = columns.Any(c => moneySet.Contains(c.Name) + && !c.Type.Equals("TEXT", StringComparison.OrdinalIgnoreCase)); + if (needsMigration) + pendingTables.Add((table, moneySet, columns)); + } + + if (pendingTables.Count == 0) + return; + + await ExecuteSchemaCommandAsync(conn, transaction, "PRAGMA legacy_alter_table = ON").ConfigureAwait(false); + + // 旧表重命名后其索引仍占用原名称,必须先删除,否则后续 CREATE INDEX IF NOT EXISTS 会静默跳过 + await ExecuteSchemaCommandAsync(conn, transaction, """ + DROP INDEX IF EXISTS idx_strategies_symbol; + DROP INDEX IF EXISTS idx_strategies_status; + DROP INDEX IF EXISTS idx_strategies_environment_status; + DROP INDEX IF EXISTS idx_records_strategy; + DROP INDEX IF EXISTS idx_records_symbol; + DROP INDEX IF EXISTS idx_records_created; + DROP INDEX IF EXISTS idx_records_environment_created; + DROP INDEX IF EXISTS idx_positions_symbol; + DROP INDEX IF EXISTS idx_positions_side; + DROP INDEX IF EXISTS idx_positions_environment_symbol; + """).ConfigureAwait(false); + + foreach (var (table, moneyColumns, columns) in pendingTables) + { + var legacyTable = $"{table}_money_legacy"; + + await ExecuteSchemaCommandAsync(conn, transaction, $"ALTER TABLE {table} RENAME TO {legacyTable}").ConfigureAwait(false); + await ExecuteSchemaCommandAsync(conn, transaction, TableDefinitions[table] + ";").ConfigureAwait(false); + + // CAST(col AS TEXT) 将历史 REAL 值转为十进制字符串文本 + var columnList = string.Join(", ", columns.Select(c => c.Name)); + var selectList = string.Join(", ", columns.Select(c => moneyColumns.Contains(c.Name) ? $"CAST({c.Name} AS TEXT)" : c.Name)); + await ExecuteSchemaCommandAsync(conn, transaction, $"INSERT INTO {table} ({columnList}) SELECT {selectList} FROM {legacyTable}").ConfigureAwait(false); + await ExecuteSchemaCommandAsync(conn, transaction, $"DROP TABLE {legacyTable}").ConfigureAwait(false); + } + + await ExecuteSchemaCommandAsync(conn, transaction, "PRAGMA legacy_alter_table = OFF").ConfigureAwait(false); + } + + private static async Task ExecuteSchemaCommandAsync(SqliteConnection conn, SqliteTransaction transaction, string commandText) + { + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = commandText; await cmd.ExecuteNonQueryAsync().ConfigureAwait(false); } + private static async Task> GetTableColumnsAsync( + SqliteConnection conn, SqliteTransaction transaction, string tableName) + { + var columns = new List<(string Name, string Type)>(); + await using var cmd = conn.CreateCommand(); + cmd.Transaction = transaction; + cmd.CommandText = $"PRAGMA table_info({tableName})"; + await using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false); + while (await reader.ReadAsync().ConfigureAwait(false)) + { + columns.Add((reader.GetString(1), reader.GetString(2))); + } + return columns; + } + private static async Task CreateIndexesAsync(SqliteConnection conn, SqliteTransaction transaction) { await using var cmd = conn.CreateCommand(); @@ -1123,10 +1282,10 @@ UPDATE trade_records """; cmd.Parameters.AddWithValue("@id", record.Id); cmd.Parameters.AddWithValue("@environment", CurrentEnvironmentKey); - cmd.Parameters.AddWithValue("@requestedQty", (double)record.RequestedQty); - cmd.Parameters.AddWithValue("@executedQty", (double)record.ExecutedQty); - cmd.Parameters.AddWithValue("@executedPrice", (double)record.ExecutedPrice); - cmd.Parameters.AddWithValue("@commission", (double)record.Commission); + cmd.Parameters.AddWithValue("@requestedQty", ToDb(record.RequestedQty)); + cmd.Parameters.AddWithValue("@executedQty", ToDb(record.ExecutedQty)); + cmd.Parameters.AddWithValue("@executedPrice", ToDb(record.ExecutedPrice)); + cmd.Parameters.AddWithValue("@commission", ToDb(record.Commission)); cmd.Parameters.AddWithValue("@commissionAsset", (object?)record.CommissionAsset ?? DBNull.Value); cmd.Parameters.AddWithValue("@status", (int)record.Status); cmd.Parameters.AddWithValue("@completedAt", record.CompletedAt.HasValue ? (object)record.CompletedAt.Value.ToString("O") : DBNull.Value); diff --git a/src/MarketAssistant.App.Services/Trading/TradingEnvironmentService.cs b/src/MarketAssistant.App.Services/Trading/TradingEnvironmentService.cs index a61c85a..2224dbd 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingEnvironmentService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingEnvironmentService.cs @@ -38,6 +38,19 @@ public TradingEnvironmentService( public string CurrentModeDescription => GetModeDescription(_currentMode); + /// + /// 判断运行中的市场监控切换到实盘环境时是否必须二次确认。 + /// + public static bool RequiresLiveModeConfirmation( + CryptoTradingMode currentMode, + CryptoTradingMode targetMode, + bool isMonitorRunning) + { + return isMonitorRunning && + targetMode != currentMode && + targetMode is CryptoTradingMode.LiveSpot or CryptoTradingMode.LiveFutures; + } + /// /// 切换交易模式。若监控正在运行,先等待其完全停止(最长 10 秒)再切换, /// 避免切换瞬间在途策略任务或订单状态同步访问新环境的账户与数据。 @@ -58,9 +71,9 @@ public async Task ApplyModeAsync(CryptoTradingMode mode) _currentMode = mode; - // 持久化到用户设置,确保重启后保持一致 - _userSettingService.CurrentSetting.CryptoTradingMode = mode; - _userSettingService.SaveSettings(); + // 持久化到用户设置,确保重启后保持一致; + // 与持久化共用同步边界,避免与其它线程的设置保存交错 + _userSettingService.UpdateSetting(setting => setting.CryptoTradingMode = mode); _logger.LogInformation("虚拟币交易模式已切换为 {Mode} 并已持久化", mode); ModeChanged?.Invoke(mode); diff --git a/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs b/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs index 55b0966..61a75ca 100644 --- a/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs +++ b/src/MarketAssistant.App.Services/Trading/TradingStrategyService.cs @@ -5,7 +5,7 @@ namespace MarketAssistant.Services.Trading; /// /// 策略编排服务:统一封装策略增删改,并广播策略集合变化。 /// -public sealed class TradingStrategyService +public class TradingStrategyService { private readonly TradingDataService _dataService; @@ -39,7 +39,8 @@ public async Task SaveStrategyAsync(TradingStrategy strategy, CancellationToken RaiseStrategiesChanged(); } - public async Task UpdateStrategyStatusAsync( + /// virtual 供单元测试替换(AISignal 硬性边界行为测试)。 + public virtual async Task UpdateStrategyStatusAsync( string strategyId, StrategyStatus status, CancellationToken ct = default) 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/App.axaml.cs b/src/MarketAssistant.App/App.axaml.cs index 450041d..9c6191c 100644 --- a/src/MarketAssistant.App/App.axaml.cs +++ b/src/MarketAssistant.App/App.axaml.cs @@ -77,6 +77,12 @@ private void OnApplicationExit(object? sender, ControlledApplicationLifetimeExit // 清理全局异常处理器 GlobalExceptionHandler.Cleanup(); + // 释放根 DI 容器:触发容器创建的全部 IDisposable 单例的释放 + // (ChatClientFactory 缓存的 IChatClient、限流器、SQLite 服务等)。 + // 必须在关闭日志之前执行,保证释放过程中仍可写日志。 + (ServiceProvider as IDisposable)?.Dispose(); + ServiceProvider = null; + // 刷新并关闭日志 Log.CloseAndFlush(); } diff --git a/src/MarketAssistant.App/Converters/NavigationIconConverter.cs b/src/MarketAssistant.App/Converters/NavigationIconConverter.cs deleted file mode 100644 index 4c0306e..0000000 --- a/src/MarketAssistant.App/Converters/NavigationIconConverter.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Avalonia.Data.Converters; -using MarketAssistant.ViewModels; -using System.Globalization; - -namespace MarketAssistant.Converters; - -/// -/// 导航图标转换器,根据选中状态返回对应的 SVG 路径 -/// -public class NavigationIconConverter : IMultiValueConverter -{ - public object? Convert(IList values, Type targetType, object? parameter, CultureInfo culture) - { - if (values.Count != 2 || - values[0] is not NavigationItemViewModel navigationItem || - values[1] is not bool isSelected) - { - return null; - } - - // 直接返回 SVG 路径,让 Svg 控件处理 - return isSelected ? navigationItem.SelectedIconPath : navigationItem.IconPath; - } -} diff --git a/src/MarketAssistant.App/Converters/NumberFormatConverter.cs b/src/MarketAssistant.App/Converters/NumberFormatConverter.cs deleted file mode 100644 index 609aefc..0000000 --- a/src/MarketAssistant.App/Converters/NumberFormatConverter.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Globalization; -using Avalonia.Data.Converters; - -namespace MarketAssistant.Converters; - -public class NumberFormatConverter : IValueConverter -{ - public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) - { - if (value is string strValue && double.TryParse(strValue, out double number)) - { - return FormatNumber(number); - } - if (value is double doubleValue) - { - return FormatNumber(doubleValue); - } - if (value is int intValue) - { - return FormatNumber(intValue); - } - if (value is long longValue) - { - return FormatNumber(longValue); - } - - return value; - } - - private string FormatNumber(double number) - { - if (number >= 100000000) - { - return $"{(number / 100000000):F2}亿"; - } - if (number >= 10000) - { - return $"{(number / 10000):F2}万"; - } - return number.ToString("N0"); - } - - public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } -} diff --git a/src/MarketAssistant.App/Converters/README.md b/src/MarketAssistant.App/Converters/README.md index ffd617d..ab72571 100644 --- a/src/MarketAssistant.App/Converters/README.md +++ b/src/MarketAssistant.App/Converters/README.md @@ -18,18 +18,7 @@ Foreground="{Binding PriceChange, Converter={StaticResource PriceChangeColorConverter}}" /> ``` -### 2. NavigationIconConverter - -根据导航项的选中状态返回对应的 SVG 图标路径,实现 `IMultiValueConverter`。 - -```xml - - - - -``` - -### 3. RadioButtonEqualityConverter +### 2. RadioButtonEqualityConverter 用于 RadioButton 的 IsChecked 属性与字符串值的双向绑定。 @@ -40,23 +29,19 @@ Mode=TwoWay}" /> ``` -### 4. EnumDescriptionConverter +### 3. EnumDescriptionConverter 将枚举值转换为 `[Description]` 特性中指定的描述文本,用于 UI 显示。 -### 5. NullableValueConverter +### 4. NullableValueConverter 可空值格式化转换器,参数格式 `format|fallback`(如 `"{0:F2}元|--"`)。 -### 6. NullableVisibilityConverter +### 5. NullableVisibilityConverter 当值为 null 时返回 false,用于 `IsVisible` 绑定。 -### 7. NumberFormatConverter - -数值格式化转换器,按参数指定的格式字符串输出。 - -### 8. ScoreToColorConverter +### 6. ScoreToColorConverter 将评分(1-10)映射到对应的颜色,用于分析报告评分展示。 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..9c5bd19 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,18 +30,26 @@ 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(); - return services.BuildServiceProvider(); + // ValidateOnBuild:启动时即暴露错误的单例注册(fail-fast,避免运行期才爆炸) + // ValidateScopes:桌面应用虽无请求作用域,仍防范误把 scoped 服务注入单例 + return services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, + ValidateScopes = true + }); } } } diff --git a/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml b/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml index 4fac7b3..5d120c9 100644 --- a/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml +++ b/src/MarketAssistant.App/Resources/Styles/CardStyles.axaml @@ -27,6 +27,11 @@ + @@ -49,6 +54,10 @@ + + diff --git a/src/MarketAssistant.App/Resources/Styles/Geometries.axaml b/src/MarketAssistant.App/Resources/Styles/Geometries.axaml index 9e87c67..9a8a888 100644 --- a/src/MarketAssistant.App/Resources/Styles/Geometries.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Geometries.axaml @@ -8,5 +8,8 @@ M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z + + M17.65,6.35C16.2,4.9 14.21,4 12,4C7.58,4 4,7.58 4,12C4,16.42 7.58,20 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.82,16.33 14.6,18 12,18C8.69,18 6,15.31 6,12C6,8.69 8.69,6 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z + diff --git a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml index b2fec78..11bcb74 100644 --- a/src/MarketAssistant.App/Resources/Styles/Spacing.axaml +++ b/src/MarketAssistant.App/Resources/Styles/Spacing.axaml @@ -112,7 +112,6 @@ 56 16 - 16,8,16,80 1 8 12 @@ -126,8 +125,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 92e7ca6..655e8d6 100644 --- a/src/MarketAssistant.App/Services/Notification/NotificationService.cs +++ b/src/MarketAssistant.App/Services/Notification/NotificationService.cs @@ -8,7 +8,7 @@ namespace MarketAssistant.Services.Notification; /// public class NotificationService : INotificationService { - private const int DefaultDuration = 3000; + private const int DefaultDuration = 5000; private readonly ILogger _logger; public NotificationService(ILogger logger) diff --git a/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs b/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs index dbf3ff6..b871d42 100644 --- a/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs +++ b/src/MarketAssistant.App/Services/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using MarketAssistant.Services.Navigation; using MarketAssistant.Services.Notification; using MarketAssistant.Services.Settings; +using MarketAssistant.Services.Trading; using MarketAssistant.ViewModels; using MarketAssistant.ViewModels.Home; using MarketAssistant.ViewModels.Trading; @@ -37,6 +38,10 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // 注册全局异常处理器(Singleton,由 DI 创建实例) services.AddSingleton(); + // 应用级交易确认:订阅 TradeExecutor.ConfirmationRequested 并弹全局对话框, + // 使 HITL 确认不依赖交易监控页存活(单例构造即接管订阅) + services.AddSingleton(); + // Keyed Service 委托工厂:避免 ViewModel 使用 IServiceProvider 反模式 services.AddSingleton>( sp => marketType => sp.GetRequiredKeyedService(marketType)); diff --git a/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs b/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs new file mode 100644 index 0000000..2928253 --- /dev/null +++ b/src/MarketAssistant.App/Services/Trading/TradeConfirmationService.cs @@ -0,0 +1,102 @@ +using MarketAssistant.Services.Dialog; +using MarketAssistant.Services.Notification; +using MarketAssistant.Trading.Abstractions; +using MarketAssistant.Trading.Models; +using Microsoft.Extensions.Logging; + +namespace MarketAssistant.Services.Trading; + +/// +/// 应用级交易确认服务:以单例生命周期订阅 , +/// 通过 弹出全局确认对话框,使 Human-in-the-Loop 确认 +/// 不再依赖交易监控页面的存活(此前仅监控页 VM 订阅,导航离开即退订, +/// 所有超阈值订单会被静默拒绝)。 +/// 对话框 60 秒无操作自动拒绝;无法获取活动窗口(如最小化到托盘)时同样拒绝并弹通知提醒。 +/// +public sealed class TradeConfirmationService : IDisposable +{ + private const int ConfirmationTimeoutSeconds = 60; + + private readonly TradeExecutor _tradeExecutor; + private readonly IDialogService _dialogService; + private readonly INotificationService _notificationService; + private readonly ILogger _logger; + + private int _pendingConfirmationCount; + + public TradeConfirmationService( + TradeExecutor tradeExecutor, + IDialogService dialogService, + INotificationService notificationService, + ILogger logger) + { + _tradeExecutor = tradeExecutor; + _dialogService = dialogService; + _notificationService = notificationService; + _logger = logger; + _tradeExecutor.ConfirmationRequested += OnConfirmationRequestedAsync; + } + + /// + /// 是否有确认请求正在进行(同一时刻只允许一个对话框,后续请求直接拒绝, + /// 避免多个待确认交易叠加导致用户误批)。 + /// + private bool HasPendingConfirmation => Volatile.Read(ref _pendingConfirmationCount) > 0; + + private async Task OnConfirmationRequestedAsync( + string symbol, OrderSide side, decimal price, decimal quantity, string reason) + { + // 单确认串行化:已有对话框挂起时拒绝新请求(fail-closed,宁可错过不可误做) + if (Interlocked.CompareExchange(ref _pendingConfirmationCount, 1, 0) != 0) + { + _logger.LogWarning("已有交易确认等待中,拒绝新确认请求: {Symbol} {Side}", symbol, side); + return false; + } + + try + { + var title = "自动交易确认"; + var message = + $"交易对:{symbol}\n方向:{side}\n价格:{price:F2}\n数量:{quantity}\n\n" + + $"触发原因:{reason}\n\n({ConfirmationTimeoutSeconds} 秒内未操作将自动拒绝)"; + + var confirmationTask = _dialogService.ShowConfirmationAsync(title, message, "批准", "拒绝"); + + // 60 秒超时自动拒绝:结果竞争,先完成者生效 + var timeoutTask = Task.Delay(TimeSpan.FromSeconds(ConfirmationTimeoutSeconds)); + var completed = await Task.WhenAny(confirmationTask, timeoutTask).ConfigureAwait(false); + + if (completed == timeoutTask) + { + _logger.LogWarning("交易确认超时自动拒绝: {Symbol} {Side}", symbol, side); + _notificationService.ShowWarning( + $"⚠ 交易确认超时已自动拒绝:{symbol} {side} {quantity}"); + return false; + } + + var approved = await confirmationTask.ConfigureAwait(false); + if (approved) + { + _logger.LogInformation("用户批准自动交易: {Symbol} {Side}", symbol, side); + } + else + { + // ShowCustomDialogAsync 拿不到活动窗口时返回 null(视为拒绝),提醒用户开启主窗口 + _logger.LogWarning("交易确认被拒绝或窗口不可用: {Symbol} {Side}", symbol, side); + _notificationService.ShowWarning($"已拒绝自动交易:{symbol} {side} {quantity}"); + } + + return approved; + } + finally + { + Volatile.Write(ref _pendingConfirmationCount, 0); + } + } + + public void Dispose() + { + _tradeExecutor.ConfirmationRequested -= OnConfirmationRequestedAsync; + GC.SuppressFinalize(this); + } +} diff --git a/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs index 2804759..bb7434d 100644 --- a/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/AgentAnalysisViewModel.cs @@ -42,6 +42,18 @@ public partial class AgentAnalysisViewModel : ViewModelBase, INavigationAware + /// 当前会话是否已有可展示的分析报告(成功产出或加载历史报告后为 true) + /// + [ObservableProperty] + private bool _hasActiveReport; + + /// + /// 分析未完成(失败或取消)时展示给用户的提示信息 + /// + [ObservableProperty] + private string _analysisFailureMessage = string.Empty; + [ObservableProperty] private AnalysisReportViewModel _analysisReportViewModel; @@ -50,10 +62,12 @@ public partial class AgentAnalysisViewModel : ViewModelBase, INavigationAware /// 供 View 在 AttachedToVisualTree 时注入 @@ -135,6 +149,7 @@ public AgentAnalysisViewModel( SubscribeToEvents(); ToggleChatSidebarCommand = new RelayCommand(ToggleChatSidebar); CancelAnalysisCommand = new RelayCommand(CancelAnalysis); + RetryAnalysisCommand = new AsyncRelayCommand(LoadAnalysisDataAsync); ExportReportCommand = new AsyncRelayCommand(ExportReportAsync); LoadHistoryReportCommand = new AsyncRelayCommand(LoadHistoryReportAsync); } @@ -165,6 +180,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; @@ -185,32 +206,45 @@ private void CancelAnalysis() } /// - /// 加载分析数据 + /// 加载分析数据。 + /// 成功产出报告后才切换到报告视图;失败或取消时停留在"分析未完成"状态, + /// 不再弹出全局错误框并落入空白的报告页。 /// public async Task LoadAnalysisDataAsync() { if (string.IsNullOrEmpty(StockCode)) return; - await SafeExecuteAsync(async () => + IsBusy = true; + try { await Dispatcher.UIThread.InvokeAsync(() => { AnalysisStage = "准备开始..."; FailedAnalystsInfo = string.Empty; AnalysisProgressPercent = 0; + HasActiveReport = false; + AnalysisFailureMessage = string.Empty; }); 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; CanExportReport = true; + HasActiveReport = true; if (!result.FromCache) await RefreshHistoryAsync(StockCode); @@ -223,8 +257,21 @@ await Dispatcher.UIThread.InvokeAsync(async () => await ChatSidebarViewModel.InitializeWithAnalysisHistory(StockCode, report.AnalystMessages); } }); - - }, "资产分析"); + } + catch (OperationCanceledException) + { + Logger?.LogInformation("资产 {StockCode} 的分析已取消", StockCode); + AnalysisFailureMessage = "分析已取消,可点击下方按钮重新发起分析"; + } + catch (Exception ex) + { + Logger?.LogError(ex, "执行 '资产分析' 时发生错误"); + AnalysisFailureMessage = ErrorMessageMapper.GetUserFriendlyMessageWithContext(ex, "资产分析"); + } + finally + { + IsBusy = false; + } } /// @@ -285,6 +332,9 @@ public void OnNavigatedTo(AssetNavigationParameter parameter, bool isReactivatio public void OnNavigatedFrom() { + _analysisCts?.Cancel(); + _activeAnalysisRunId = null; + IsChatSidebarVisible = false; } private async Task RefreshHistoryAsync(string assetCode) @@ -310,6 +360,8 @@ await SafeExecuteAsync(async () => _lastReport = report; CanExportReport = true; + HasActiveReport = true; + AnalysisFailureMessage = string.Empty; await Dispatcher.UIThread.InvokeAsync(async () => { @@ -326,6 +378,7 @@ protected override void OnMarketChanged(MarketType newMarket) _analysisCts?.Cancel(); _analysisCts?.Dispose(); _analysisCts = null; + _activeAnalysisRunId = null; _lastReport = null; // 重置分析状态 @@ -334,6 +387,8 @@ protected override void OnMarketChanged(MarketType newMarket) AnalysisProgressPercent = 0; FailedAnalystsInfo = string.Empty; CanExportReport = false; + HasActiveReport = false; + AnalysisFailureMessage = string.Empty; OnPropertyChanged(nameof(Title)); } @@ -343,6 +398,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 1dbc0a2..30da8e0 100644 --- a/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/ChatSidebarViewModel.cs @@ -14,8 +14,11 @@ namespace MarketAssistant.ViewModels; /// public partial class ChatSidebarViewModel : ViewModelBase, IDisposable { - private readonly MarketChatSession _chatSession; + private readonly IMarketChatSessionFactory _chatSessionFactory; private readonly AdaptiveCardConverter _adaptiveCardConverter; + private MarketChatSession? _chatSession; + private string? _pendingContextStockCode; + private List? _pendingAnalysisMessages; public ObservableCollection ChatMessages { get; } = []; @@ -43,7 +46,7 @@ public ChatSidebarViewModel( AdaptiveCardConverter adaptiveCardConverter) : base(logger) { - _chatSession = chatSessionFactory.Create(); + _chatSessionFactory = chatSessionFactory; _adaptiveCardConverter = adaptiveCardConverter; SendMessageCommand = new AsyncRelayCommand(SendMessageAsync, CanSendMessage); @@ -88,11 +91,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)) { @@ -143,6 +147,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; + } + } + /// /// 添加欢迎消息 /// @@ -165,7 +184,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/FavoritesPageViewModel.cs b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs index 4f2d0e8..48d2a48 100644 --- a/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/FavoritesPageViewModel.cs @@ -11,6 +11,7 @@ using MarketAssistant.Services.Market; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.ComponentModel; using static MarketAssistant.Infrastructure.Core.CryptoSymbolConverter; @@ -33,6 +34,24 @@ public partial class FavoritesPageViewModel : ViewModelBase, IRecipient private CancellationTokenSource? _loadCts; + /// + /// WebSocket 标的(Binance 格式)→ 展示对象的索引。 + /// tick 回调在后台线程先查索引:非本页标的直接返回,避免无谓的 UI 线程派发与逐项扫描。 + /// + private readonly ConcurrentDictionary _assetIndex = new(StringComparer.OrdinalIgnoreCase); + + /// + /// 待刷新的价格更新(按标的去重,只保留最新值),由 节流批量刷 UI。 + /// + private readonly ConcurrentDictionary _pendingPriceUpdates = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// 价格刷新节流定时器(250ms)。将每 tick 一次的 UI 派发合并为每秒 4 次批量更新。 + /// 惰性创建于 UI 线程,Dispose 时停止。 + /// + private DispatcherTimer? _priceFlushTimer; + private IFavoriteService FavoriteService => _serviceProvider.GetRequiredKeyedService(_marketContext.CurrentMarket); @@ -91,6 +110,8 @@ await SafeExecuteAsync(async () => Assets.Clear(); await UpdateAssetDataProgressivelyAsync(favoritesCodes, ct); + RebuildAssetIndex(); + // 以完整集合替换收藏页订阅:虚拟币市场订阅自选交易对; // 其他市场传空集合,确保切换市场后不残留上一市场的订阅 var symbols = _marketContext.CurrentMarket == MarketType.Crypto @@ -204,6 +225,8 @@ await SafeExecuteAsync(async () => if (assetToRemove != null) { Assets.Remove(assetToRemove); + _assetIndex.TryRemove(ToBinanceFormat(assetToRemove.Code), out _); + _pendingPriceUpdates.TryRemove(ToBinanceFormat(assetToRemove.Code), out _); } // 再从持久化存储中移除 @@ -216,22 +239,72 @@ await SafeExecuteAsync(async () => } /// - /// WebSocket 实时价格更新回调 + /// WebSocket 实时价格更新回调(后台线程)。 + /// 索引未命中(非本页标的)直接返回,不产生任何 UI 线程派发; + /// 命中则暂存最新值,由 250ms 节流定时器批量刷新,避免高频 tick 逐条打 UI。 /// private void OnWebSocketPriceUpdated(string symbol, decimal lastPrice, decimal changePercent) { - Dispatcher.UIThread.InvokeAsync(() => + if (!_assetIndex.ContainsKey(symbol)) + return; + + _pendingPriceUpdates[symbol] = (lastPrice, changePercent); + EnsurePriceFlushTimer(); + } + + /// + /// 重建标的索引(列表加载完成后调用)。 + /// + private void RebuildAssetIndex() + { + _assetIndex.Clear(); + foreach (var asset in Assets) { - var asset = Assets.FirstOrDefault(a => - ToBinanceFormat(a.Code).Equals(symbol, StringComparison.OrdinalIgnoreCase)); + _assetIndex[ToBinanceFormat(asset.Code)] = asset; + } + } - if (asset == null) return; + /// + /// 惰性创建节流定时器。DispatcherTimer 必须在 UI 线程构造, + /// 故先快检再 Post;Post 内二次判空防重复创建。 + /// + private void EnsurePriceFlushTimer() + { + if (_priceFlushTimer != null) + return; - asset.CurrentPrice = lastPrice.ToString("G"); - asset.ChangePercentage = $"{changePercent:F2}%"; + Dispatcher.UIThread.Post(() => + { + if (_priceFlushTimer != null) + return; + + _priceFlushTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(250) + }; + _priceFlushTimer.Tick += (_, _) => FlushPendingPriceUpdates(); + _priceFlushTimer.Start(); }); } + /// + /// 批量应用暂存的价格更新到展示对象(UI 线程,每 250ms 至多一次)。 + /// + private void FlushPendingPriceUpdates() + { + foreach (var symbol in _pendingPriceUpdates.Keys.ToList()) + { + if (!_pendingPriceUpdates.TryRemove(symbol, out var update)) + continue; + + if (_assetIndex.TryGetValue(symbol, out var asset)) + { + asset.CurrentPrice = update.Price.ToString("G"); + asset.ChangePercentage = $"{update.Change:F2}%"; + } + } + } + /// /// 接收收藏变更消息 /// @@ -244,6 +317,8 @@ public void Dispose() { _loadCts?.Cancel(); _loadCts?.Dispose(); + _priceFlushTimer?.Stop(); + _priceFlushTimer = null; UnsubscribeFromMarketChanges(_marketContext); _wsService.PriceUpdated -= OnWebSocketPriceUpdated; _ = _wsService.UnsubscribeAllAsync(WebSocketSubscriberKeys.Favorites); 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/MainWindowViewModel.cs b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs index 4c1fc87..1e85645 100644 --- a/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/MainWindowViewModel.cs @@ -16,6 +16,7 @@ public partial class MainWindowViewModel : ViewModelBase private readonly NavigationService _navigationService; private readonly MarketContext _marketContext; private readonly INotificationService _notificationService; + private bool _isSynchronizingNavigationSelection; [ObservableProperty] private NavigationItemViewModel? _selectedNavigationItem; @@ -53,10 +54,8 @@ public MainWindowViewModel( // 监听市场切换事件 SubscribeToMarketChanges(_marketContext); - // 默认导航到首页 + // 默认导航到首页。SelectedNavigationItem 的变更回调负责实际导航,避免重复入栈。 SelectedNavigationItem = NavigationItems[0]; - var homeViewModel = SelectedNavigationItem.CreateViewModel(); - _navigationService.NavigateToRoot(homeViewModel, SelectedNavigationItem.Title); } protected override void OnMarketChanged(MarketType newMarket) @@ -98,8 +97,16 @@ private void OnNavigationServicePropertyChanged(object? sender, PropertyChangedE { if (_navigationService.CurrentRootNavigationItemTitle != null) { - SelectedNavigationItem = NavigationItems.FirstOrDefault( - item => item.Title == _navigationService.CurrentRootNavigationItemTitle); + _isSynchronizingNavigationSelection = true; + try + { + SelectedNavigationItem = NavigationItems.FirstOrDefault( + item => item.Title == _navigationService.CurrentRootNavigationItemTitle); + } + finally + { + _isSynchronizingNavigationSelection = false; + } } } } @@ -143,17 +150,15 @@ private void ToggleMarket() partial void OnSelectedNavigationItemChanged(NavigationItemViewModel? value) { - if (value != null) - { - // 避免重复导航 - if (_navigationService.CurrentRootNavigationItemTitle == value.Title) - { - return; - } + if (value is null || _isSynchronizingNavigationSelection) + return; - var viewModel = value.CreateViewModel(); - _navigationService.NavigateToRoot(viewModel, value.Title); - } + // 避免重复导航 + if (_navigationService.CurrentRootNavigationItemTitle == value.Title) + return; + + var viewModel = value.CreateViewModel(); + _navigationService.NavigateToRoot(viewModel, value.Title); } } diff --git a/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs b/src/MarketAssistant.App/ViewModels/SettingsPageViewModel.cs index 5d8b1d3..e4ff1c3 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.Agents.Analysts; @@ -20,7 +21,6 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Reflection; -using YamlDotNet.Serialization; namespace MarketAssistant.ViewModels; @@ -34,6 +34,7 @@ public partial class SettingsPageViewModel : ViewModelBase, IDisposable private readonly Func _ragIngestionServiceFactory; private readonly INotificationService _notificationService; private readonly IUserSettingService _userSettingService; + private readonly IModelDiscoveryService _modelDiscoveryService; private readonly Func _embeddingFactoryFactory; private readonly Func _vectorStoreFactory; private readonly Services.Market.MarketContext _marketContext; @@ -41,6 +42,8 @@ public partial class SettingsPageViewModel : ViewModelBase, IDisposable private readonly Func _marketMonitorFactory; private readonly IDialogService _dialogService; private IStorageProvider? _storageProvider; + private bool _isInitializingProvider; + private CancellationTokenSource? _modelFetchCancellationTokenSource; // UserSetting对象,包含所有用户设置 [ObservableProperty] @@ -93,6 +96,171 @@ 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 + { + if (string.IsNullOrWhiteSpace(UserSetting.ProviderId) || ApiKey == value) + return; + + UserSetting.ProviderApiKeys[UserSetting.ProviderId] = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(CanFetchModels)); + OnPropertyChanged(nameof(ModelDiscoveryHint)); + FetchModelsCommand.NotifyCanExecuteChanged(); + } + } + + // 是否显示 API Key 配置。OpenCode Zen 即使当前使用免费模型也允许配置 Key。 + public bool SupportsApiKeyConfiguration => SelectedProvider?.RequiresApiKey ?? false; + + // 当前模型是否强制要求 API Key,用于提示而不是控制输入框可见性。 + public bool IsApiKeyRequiredForSelectedModel => + SelectedProvider?.RequiresApiKeyForModel(ModelId) ?? false; + + public string ApiKeyHint => IsApiKeyRequiredForSelectedModel + ? "当前模型需要 API Key" + : "API Key 可选;留空使用免费模型,配置后可访问账号授权模型"; + + // 当前服务商的 API Key 获取链接 + public string? ProviderApiKeyUrl => SelectedProvider?.ApiKeyUrl; + + public bool CanOverrideEndpoint => SelectedProvider?.AllowsEndpointOverride ?? false; + + // 当前服务商的活动模型 ID(按服务商存储于 ProviderModelIds)。 + public string ModelId + { + get => UserSetting.ProviderModelIds.GetValueOrDefault(UserSetting.ProviderId, string.Empty); + set + { + if (string.IsNullOrWhiteSpace(UserSetting.ProviderId)) + return; + if (ModelId == value) + return; + + UserSetting.ProviderModelIds[UserSetting.ProviderId] = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(IsApiKeyRequiredForSelectedModel)); + OnPropertyChanged(nameof(ApiKeyHint)); + } + } + + // 仅本地部署和自定义服务允许覆盖默认端点。按服务商存储于 ProviderEndpoints。 + public string Endpoint + { + get => UserSetting.ProviderEndpoints.GetValueOrDefault(UserSetting.ProviderId, string.Empty); + set + { + if (string.IsNullOrWhiteSpace(UserSetting.ProviderId)) + return; + if (Endpoint == value) + return; + + UserSetting.ProviderEndpoints[UserSetting.ProviderId] = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(EffectiveEndpoint)); + } + } + + public string EndpointPlaceholder => string.IsNullOrWhiteSpace(SelectedProvider?.DefaultEndpoint) + ? "请输入完整的 API Base URL" + : $"默认:{SelectedProvider.DefaultEndpoint}"; + + public string EffectiveEndpoint => string.IsNullOrWhiteSpace(Endpoint) + ? SelectedProvider?.DefaultEndpoint ?? string.Empty + : Endpoint.Trim(); + + // 当前服务商是否支持在线获取模型列表 + public bool SupportsModelListing => SelectedProvider?.SupportsModelListing ?? false; + + public bool CanFetchModels => + SelectedProvider is { SupportsModelListing: true } provider && + !IsLoadingModels && + (!provider.ModelListingRequiresApiKey || !string.IsNullOrWhiteSpace(ApiKey)); + + public string ModelDiscoveryHint + { + get + { + if (SelectedProvider is not { } provider) + return "请先选择模型服务商"; + + if (!provider.SupportsModelListing) + return "该服务商不提供模型目录,请直接输入模型 ID"; + + if (provider.ModelListingRequiresApiKey && string.IsNullOrWhiteSpace(ApiKey)) + return "配置 API Key 后可获取模型目录,也可以直接输入模型 ID"; + + return string.IsNullOrWhiteSpace(ModelDiscoveryStatus) + ? "可从服务商获取模型目录,也可以直接输入未列出的模型 ID" + : ModelDiscoveryStatus; + } + } + + [ObservableProperty] + private string _modelDiscoveryStatus = string.Empty; + + partial void OnModelDiscoveryStatusChanged(string value) => + OnPropertyChanged(nameof(ModelDiscoveryHint)); + + // 是否正在加载模型列表 + [ObservableProperty] + private bool _isLoadingModels; + + partial void OnIsLoadingModelsChanged(bool value) + { + OnPropertyChanged(nameof(CanFetchModels)); + OnPropertyChanged(nameof(ModelDiscoveryHint)); + FetchModelsCommand.NotifyCanExecuteChanged(); + } + + partial void OnSelectedProviderChanged(ModelProvider? oldValue, ModelProvider? newValue) + { + if (newValue is null) + return; + + UserSetting.ProviderId = newValue.Id; + + _modelFetchCancellationTokenSource?.Cancel(); + Models.Clear(); + ModelDiscoveryStatus = string.Empty; + + // ModelId/Endpoint 按服务商存储于字典,切换后通知绑定重新读取即可。 + OnPropertyChanged(nameof(ApiKey)); + OnPropertyChanged(nameof(ModelId)); + OnPropertyChanged(nameof(Endpoint)); + OnPropertyChanged(nameof(SupportsApiKeyConfiguration)); + OnPropertyChanged(nameof(IsApiKeyRequiredForSelectedModel)); + OnPropertyChanged(nameof(ApiKeyHint)); + OnPropertyChanged(nameof(ProviderApiKeyUrl)); + OnPropertyChanged(nameof(CanOverrideEndpoint)); + OnPropertyChanged(nameof(EndpointPlaceholder)); + OnPropertyChanged(nameof(EffectiveEndpoint)); + OnPropertyChanged(nameof(SupportsModelListing)); + OnPropertyChanged(nameof(CanFetchModels)); + OnPropertyChanged(nameof(ModelDiscoveryHint)); + FetchModelsCommand.NotifyCanExecuteChanged(); + + if (!_isInitializingProvider) + { + // 模型列表鉴权与具体模型调用鉴权彼此独立。 + var currentKey = UserSetting.ProviderApiKeys.TryGetValue(newValue.Id, out var key) ? key : ""; + if (newValue.CanListModels(currentKey)) + { + _ = FetchModels(); + } + } + } + // 分析师角色列表 [ObservableProperty] private ObservableCollection _analystRoles = new(); @@ -125,7 +293,6 @@ private void ForwardComputedProperties(object? sender, PropertyChangedEventArgs public List CryptoTradingModes { 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"; @@ -296,6 +463,7 @@ public SettingsPageViewModel( Func ragIngestionServiceFactory, INotificationService notificationService, IUserSettingService userSettingService, + IModelDiscoveryService modelDiscoveryService, Func embeddingFactoryFactory, Func vectorStoreFactory, Services.Market.MarketContext marketContext, @@ -307,6 +475,7 @@ public SettingsPageViewModel( _ragIngestionServiceFactory = ragIngestionServiceFactory; _notificationService = notificationService; _userSettingService = userSettingService; + _modelDiscoveryService = modelDiscoveryService; _embeddingFactoryFactory = embeddingFactoryFactory; _vectorStoreFactory = vectorStoreFactory; _marketContext = marketContext; @@ -326,10 +495,29 @@ 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; + } + + // 模型列表鉴权与具体模型调用鉴权彼此独立。 + var currentKey = UserSetting.ProviderApiKeys.TryGetValue(UserSetting.ProviderId, out var key) ? key : ""; + if (SelectedProvider?.CanListModels(currentKey) == true) + { + await FetchModels(); + } + // 同步市场类型到MarketContext _marketContext.SwitchMarket(UserSetting.CurrentMarketType); // 加载分析师角色 @@ -377,7 +565,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); @@ -571,14 +759,16 @@ await SafeExecuteAsync(async () => UserSetting.EnabledAnalystRoles[role.Id] = role.IsEnabled; } - // 切换到实盘模式时若监控正在运行,弹窗警告(运行中切换会立即对实盘账户下单) + // 监控运行中切换到实盘会使后续订单进入真实账户,必须二次确认。 var targetMode = UserSetting.CryptoTradingMode; - var isLiveTarget = targetMode is CryptoTradingMode.LiveSpot or CryptoTradingMode.LiveFutures; - if (isLiveTarget && targetMode != _tradingEnvironmentService.CurrentMode && _marketMonitorFactory().IsRunning) + if (TradingEnvironmentService.RequiresLiveModeConfirmation( + _tradingEnvironmentService.CurrentMode, + targetMode, + _marketMonitorFactory().IsRunning)) { var confirmed = await _dialogService.ShowConfirmationAsync( "切换到实盘模式", - "⚠️ 市场监控正在运行中!保存设置后将切换到实盘模式,后续触发的交易将立即对真实账户下单。\n\n请确认是否继续?", + "市场监控正在运行。保存后会先停止监控并切换到实盘模式,后续触发的交易将发送到真实账户。\n\n请确认是否继续?", "确认切换", "取消"); @@ -611,6 +801,18 @@ await SafeExecuteAsync(async () => { _userSettingService.ResetSettings(); UserSetting = _userSettingService.CurrentSetting; + + _isInitializingProvider = true; + try + { + SelectedProvider = ModelProviderCatalog.GetProvider(UserSetting.ProviderId) + ?? ModelProviderCatalog.Providers.First(); + } + finally + { + _isInitializingProvider = false; + } + _marketContext.SwitchMarket(UserSetting.CurrentMarketType); ApplyTheme(UserSetting.ThemeMode); await _tradingEnvironmentService.ApplyModeAsync(UserSetting.CryptoTradingMode); @@ -630,66 +832,92 @@ private void NavigateToMCPConfig() } /// - /// 加载模型列表 + /// 从服务商 API 获取模型列表(用户填好 API Key 后手动触发) /// - private async Task LoadModelsAsync() + private bool CanFetchModelsCommand() => CanFetchModels; + + [RelayCommand(CanExecute = nameof(CanFetchModelsCommand))] + private async Task FetchModels() { + var provider = SelectedProvider; + if (provider is null || !provider.SupportsModelListing) + return; + + _modelFetchCancellationTokenSource?.Cancel(); + _modelFetchCancellationTokenSource?.Dispose(); + var cts = new CancellationTokenSource(); + _modelFetchCancellationTokenSource = cts; + var requestedProviderId = provider.Id; + + ModelDiscoveryStatus = $"正在从 {provider.DisplayName} 获取模型目录..."; + IsLoadingModels = true; try { - // 清空当前模型列表 - Models.Clear(); - - // 从YAML文件加载模型 - var modelsFromYaml = await LoadModelsFromYamlAsync(); + var apiKey = string.IsNullOrWhiteSpace(ApiKey) ? null : ApiKey; + var endpoint = provider.AllowsEndpointOverride && !string.IsNullOrWhiteSpace(Endpoint) + ? Endpoint + : null; + var models = await _modelDiscoveryService.ListModelsAsync( + provider, + apiKey, + endpoint, + cts.Token); + + // 取消不能保证远端立即停止;响应落 UI 前再次校验 Provider 身份。 + if (cts.IsCancellationRequested || SelectedProvider?.Id != requestedProviderId) + return; - foreach (var model in modelsFromYaml) - { + Models.Clear(); + foreach (var model in models) Models.Add(model); - } + + ModelDiscoveryStatus = Models.Count == 0 + ? "服务商未返回可用模型,请直接输入模型 ID" + : $"已获取 {Models.Count} 个模型,可直接选择或继续手工输入"; } - 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)) - { - return models; - } - - var yamlContent = await File.ReadAllTextAsync(configPath); - - // 解析yaml内容 - var deserializer = new DeserializerBuilder().Build(); - var yamlData = deserializer.Deserialize>>(yamlContent); - - // 返回模型列表 - if (yamlData != null && yamlData.ContainsKey("models")) - { - models = yamlData["models"]; - } - - return models; + HandleModelDiscoveryFailure( + requestedProviderId, + ex, + $"{provider.DisplayName} 拒绝访问,请检查 API Key"); } catch (Exception ex) { - Logger?.LogWarning(ex, "从 YAML 加载模型列表失败"); - return new List(); + HandleModelDiscoveryFailure( + requestedProviderId, + ex, + $"获取失败:{ErrorMessageMapper.GetUserFriendlyMessage(ex)}"); + } + finally + { + if (ReferenceEquals(_modelFetchCancellationTokenSource, cts)) + { + _modelFetchCancellationTokenSource = null; + IsLoadingModels = false; + } + cts.Dispose(); } } + private void HandleModelDiscoveryFailure( + string requestedProviderId, + Exception exception, + string status) + { + if (SelectedProvider?.Id != requestedProviderId) + return; + + ModelDiscoveryStatus = $"{status},仍可直接输入模型 ID"; + Logger?.LogWarning(exception, "获取服务商模型列表失败: {ProviderId}", requestedProviderId); + _notificationService.ShowError(ModelDiscoveryStatus); + } + /// /// 打开URL /// @@ -714,6 +942,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/ViewModels/Trading/ApiKeyConfigViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/ApiKeyConfigViewModel.cs index a0a1573..16da8d8 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/ApiKeyConfigViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/ApiKeyConfigViewModel.cs @@ -161,7 +161,10 @@ private async Task ApplyModeAsync() await SafeExecuteAsync(async () => { // 切换到实盘模式时若监控正在运行,弹窗警告(运行中切换会立即对实盘账户下单) - if (IsLiveMode && _marketMonitor.IsRunning) + if (TradingEnvironmentService.RequiresLiveModeConfirmation( + _tradingEnvironmentService.CurrentMode, + SelectedMode, + _marketMonitor.IsRunning)) { var confirmed = await _dialogService.ShowConfirmationAsync( "切换到实盘模式", diff --git a/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs b/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs index 8378c36..3a04074 100644 --- a/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs +++ b/src/MarketAssistant.App/ViewModels/Trading/TradeMonitorViewModel.cs @@ -15,7 +15,6 @@ namespace MarketAssistant.ViewModels.Trading; public partial class TradeMonitorViewModel : ViewModelBase, IDisposable { private const decimal RiskWarningThreshold = 0.8m; - private const int ConfirmationTimeoutSeconds = 60; private readonly MarketMonitor _marketMonitor; private readonly CryptoPortfolioService _portfolioService; @@ -23,7 +22,6 @@ public partial class TradeMonitorViewModel : ViewModelBase, IDisposable private readonly TradingDataService _dataService; private readonly TradingStrategyService _strategyService; private readonly OrderStateSyncService _orderStateSyncService; - private readonly TradeExecutor _tradeExecutor; public ObservableCollection Balances { get; } = []; public ObservableCollection OpenOrders { get; } = []; @@ -46,16 +44,8 @@ public partial class TradeMonitorViewModel : ViewModelBase, IDisposable [ObservableProperty] private bool _isDailyLossHigh; [ObservableProperty] private bool _isPositionHigh; - // Human-in-the-Loop 确认 - [ObservableProperty] private bool _hasPendingConfirmation; - [ObservableProperty] private string _confirmationSymbol = string.Empty; - [ObservableProperty] private string _confirmationSide = string.Empty; - [ObservableProperty] private string _confirmationPrice = string.Empty; - [ObservableProperty] private string _confirmationQuantity = string.Empty; - [ObservableProperty] private string _confirmationReason = string.Empty; - - private TaskCompletionSource? _confirmationTcs; - private CancellationTokenSource? _confirmationCts; + // 说明:Human-in-the-Loop 交易确认已上移至应用级 TradeConfirmationService + // (全局对话框,不依赖本页存活),本 ViewModel 不再订阅 ConfirmationRequested。 public TradeMonitorViewModel( MarketMonitor marketMonitor, @@ -64,7 +54,6 @@ public TradeMonitorViewModel( TradingDataService dataService, TradingStrategyService strategyService, OrderStateSyncService orderStateSyncService, - TradeExecutor tradeExecutor, ILogger logger) : base(logger) { @@ -74,13 +63,9 @@ public TradeMonitorViewModel( _dataService = dataService; _strategyService = strategyService; _orderStateSyncService = orderStateSyncService; - _tradeExecutor = tradeExecutor; _isMonitorRunning = _marketMonitor.IsRunning; _marketMonitor.StatusChanged += OnMonitorStatusChanged; - - // 接管 TradeExecutor 的确认事件(使用事件模式,Dispose 时取消订阅) - _tradeExecutor.ConfirmationRequested += OnTradeConfirmationRequestedAsync; } [RelayCommand] @@ -195,56 +180,9 @@ private void OnMonitorStatusChanged(bool isRunning) IsMonitorRunning = isRunning; } - private Task OnTradeConfirmationRequestedAsync( - string symbol, OrderSide side, decimal price, decimal quantity, string reason) - { - ConfirmationSymbol = symbol; - ConfirmationSide = side.ToString(); - ConfirmationPrice = price.ToString("F2"); - ConfirmationQuantity = quantity.ToString("F6"); - ConfirmationReason = reason; - HasPendingConfirmation = true; - - _confirmationTcs = new TaskCompletionSource(); - - // 60 秒超时自动拒绝,避免用户离开后交易长时间挂起 - _confirmationCts?.Dispose(); - _confirmationCts = new CancellationTokenSource(TimeSpan.FromSeconds(ConfirmationTimeoutSeconds)); - _confirmationCts.Token.Register(() => _confirmationTcs.TrySetResult(false)); - - return _confirmationTcs.Task; - } - - [RelayCommand] - private void ApproveConfirmation() - { - HasPendingConfirmation = false; - // 注意:必须 Dispose 而非 Cancel。 - // Cancel 会同步触发 Token.Register 的回调(TrySetResult(false)), - // 导致随后的 TrySetResult(true) 被忽略,用户批准反而变成拒绝。 - _confirmationCts?.Dispose(); - _confirmationCts = null; - _confirmationTcs?.TrySetResult(true); - } - - [RelayCommand] - private void RejectConfirmation() - { - HasPendingConfirmation = false; - _confirmationCts?.Dispose(); - _confirmationCts = null; - _confirmationTcs?.TrySetResult(false); - } - public void Dispose() { _marketMonitor.StatusChanged -= OnMonitorStatusChanged; - // 取消订阅事件,避免单例 TradeExecutor 持有已 Dispose 的 ViewModel 引用 - _tradeExecutor.ConfirmationRequested -= OnTradeConfirmationRequestedAsync; - // 释放前若仍有待确认请求,按拒绝处理,避免调用方永久挂起 - _confirmationTcs?.TrySetResult(false); - _confirmationCts?.Dispose(); - _confirmationCts = null; GC.SuppressFinalize(this); } } diff --git a/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml.cs b/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml.cs index 9262a61..9086ad1 100644 --- a/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml.cs +++ b/src/MarketAssistant.App/Views/Components/ProgressDisplayView.axaml.cs @@ -8,9 +8,6 @@ namespace MarketAssistant.Views.Components; /// public partial class ProgressDisplayView : UserControl { - public static readonly StyledProperty IsAnalysisInProgressProperty = - AvaloniaProperty.Register(nameof(IsAnalysisInProgress), false); - public static readonly StyledProperty AnalysisStageProperty = AvaloniaProperty.Register(nameof(AnalysisStage), string.Empty); @@ -23,12 +20,6 @@ public partial class ProgressDisplayView : UserControl public static readonly StyledProperty CancelCommandProperty = AvaloniaProperty.Register(nameof(CancelCommand)); - public bool IsAnalysisInProgress - { - get => GetValue(IsAnalysisInProgressProperty); - set => SetValue(IsAnalysisInProgressProperty, value); - } - public string AnalysisStage { get => GetValue(AnalysisStageProperty); diff --git a/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml index a6f44b4..bfcc4a4 100644 --- a/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/AgentAnalysisPageView.axaml @@ -24,7 +24,6 @@ - - + + @@ -209,6 +209,56 @@ + + + + + + + + + + + + + @@ -216,6 +266,7 @@ diff --git a/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml.cs b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml.cs index d2a972d..3ed2ff6 100644 --- a/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml.cs +++ b/src/MarketAssistant.App/Views/Pages/AssetPageView.axaml.cs @@ -1,7 +1,4 @@ using Avalonia.Controls; -using Avalonia.Interactivity; -using MarketAssistant.ViewModels; -using System.ComponentModel; namespace MarketAssistant.Views.Pages; diff --git a/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml b/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml index d4284f2..85c174b 100644 --- a/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml +++ b/src/MarketAssistant.App/Views/Pages/AssetSelectionPageView.axaml @@ -101,9 +101,12 @@ - + MinWidth="{StaticResource ButtonMinWidth}" + Focusable="True" + KeyDown="OnCardKeyDown" + AutomationProperties.Name="{Binding Name}"> + + diff --git a/src/MarketAssistant.App/Views/Pages/Trading/TradeMonitorView.axaml b/src/MarketAssistant.App/Views/Pages/Trading/TradeMonitorView.axaml index 5542b1e..adf78b5 100644 --- a/src/MarketAssistant.App/Views/Pages/Trading/TradeMonitorView.axaml +++ b/src/MarketAssistant.App/Views/Pages/Trading/TradeMonitorView.axaml @@ -340,79 +340,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - -