From c1829250494eba02a236a6e5176b81dd5708ba5a Mon Sep 17 00:00:00 2001 From: felixhuwang Date: Wed, 20 May 2026 18:15:41 +0800 Subject: [PATCH 1/2] refactor: simplify orchestrator to config-driven harness Make ConfigDrivenHarnessAgent the default orchestration path and remove the obsolete AgentRuntime/harness feature-flag layers. --- docs/ARCHITECTURE.md | 155 +- docs/HARNESS_MIGRATION_GUIDE.md | 2 +- docs/HARNESS_MIGRATION_METHOD.md | 24 +- .../proposal.md | 23 + .../specs/chat-orchestration/spec.md | 26 + .../refactor-orchestrator-simplify/tasks.md | 9 + src/stock_datasource/agents/__init__.py | 117 +- src/stock_datasource/agents/chat_agent.py | 74 +- .../agents/config_driven_harness_agent.py | 8 - src/stock_datasource/agents/deep_agent.py | 74 - .../agents/enhanced_portfolio_agent.py | 1667 ----------------- src/stock_datasource/agents/market_agent.py | 2 + src/stock_datasource/agents/memory_agent.py | 179 -- src/stock_datasource/agents/orchestrator.py | 1569 ++-------------- src/stock_datasource/agents/overview_agent.py | 131 -- .../agents/portfolio_agent.py | 39 +- src/stock_datasource/agents/report_agent.py | 5 +- src/stock_datasource/agents/workflow_agent.py | 28 - .../agents/workflow_generator_agent.py | 23 - src/stock_datasource/api/workflow_routes.py | 123 +- .../modules/overview/service.py | 16 +- .../modules/wechat_bridge/service.py | 2 +- .../services/agent_registrations.py | 2 +- .../services/agent_registry.py | 2 +- .../services/agent_runtime.py | 757 -------- .../services/daily_analysis_service.py | 6 +- .../services/session_memory_service.py | 6 +- .../services/tool_registry.py | 34 +- tests/test_agent_runtime.py | 624 ------ tests/test_user_scoped_features.py | 53 - 30 files changed, 479 insertions(+), 5301 deletions(-) create mode 100644 openspec/changes/refactor-orchestrator-simplify/proposal.md create mode 100644 openspec/changes/refactor-orchestrator-simplify/specs/chat-orchestration/spec.md create mode 100644 openspec/changes/refactor-orchestrator-simplify/tasks.md delete mode 100644 src/stock_datasource/agents/deep_agent.py delete mode 100644 src/stock_datasource/agents/enhanced_portfolio_agent.py delete mode 100644 src/stock_datasource/agents/memory_agent.py delete mode 100644 src/stock_datasource/agents/overview_agent.py delete mode 100644 src/stock_datasource/agents/workflow_agent.py delete mode 100644 src/stock_datasource/agents/workflow_generator_agent.py delete mode 100644 src/stock_datasource/services/agent_runtime.py delete mode 100644 tests/test_agent_runtime.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e352984..c2753f22 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -49,13 +49,10 @@ This is a **sophisticated multi-agent financial AI platform** with a **hierarchi ``` src/stock_datasource/agents/ ├── base_agent.py # LangGraphAgent base class -├── orchestrator.py # OrchestratorAgent - main routing agent +├── orchestrator.py # Lightweight config-driven routing agent +├── config_driven_harness_agent.py # DB-configured Harness agent ├── chat_agent.py # ChatAgent - conversational interface -├── memory_agent.py # MemoryAgent - user memory management -├── workflow_agent.py # WorkflowAgent - workflow execution -├── workflow_generator_agent.py # WorkflowGeneratorAgent - AI workflow generation -├── deep_agent.py # DeepAgent - deep research capability -├── [15 specialized agents] # Market, Report, Portfolio, Index, etc. +├── [specialized agents] # Legacy direct-import compatibility ├── middlewares/ # Safety & memory middleware │ ├── base.py # Middleware base class │ ├── cross_validation.py # Cross-validation middleware @@ -67,29 +64,26 @@ src/stock_datasource/agents/ └── tools.py # Shared tools ``` -### 2.2 The 18+ Specialized Agents +### 2.2 Specialized Agent Names + +Most chat-facing agents are now DB configurations executed by `ConfigDrivenHarnessAgent`. Some Python agent files remain only for direct-import compatibility. | Agent Name | Purpose | Key Responsibilities | |------------|---------|----------------------| -| **OverviewAgent** | Market overview | Market sentiment, daily trends, aggregate statistics | -| **MarketAgent** | Technical analysis | Stock analysis, price trends, technical indicators | +| **OverviewAgent** | Market overview | DB-configured market sentiment and aggregate analysis | +| **MarketAgent** | Technical analysis | Legacy direct-import compatibility for market tools | | **ScreenerAgent** | Intelligent stock screening | Find stocks matching criteria, quantitative filtering | -| **ReportAgent** | Financial analysis | Fundamental analysis, financial statements, valuations | +| **ReportAgent** | Financial analysis | Legacy direct-import compatibility for report tools | | **HKReportAgent** | Hong Kong stocks | HK-specific financial analysis | -| **PortfolioAgent** | Portfolio management | Portfolio monitoring, asset allocation, risk analysis | -| **EnhancedPortfolioAgent** | Advanced portfolio analysis | Deep portfolio diagnostics, optimization | +| **PortfolioAgent** | Portfolio management | Legacy direct-import compatibility for portfolio tools | | **BacktestAgent** | Strategy backtesting | Historical testing, performance evaluation | | **IndexAgent** | Index analysis | Index tracking, comparative analysis | | **EtfAgent** | ETF analysis | ETF holdings, performance, tracking error | | **TopListAgent** | Limit-up/limit-down boards | Trading sentiment, institutional activity | | **NewsAnalystAgent** | News analysis | Market news interpretation, sentiment analysis | | **KnowledgeAgent** | Knowledge retrieval | Research reports, announcements (RAG system) | -| **MemoryAgent** | User memory | Preference storage, portfolio tracking | | **DataManageAgent** | Data management | Data refresh, quality checks, maintenance | -| **WorkflowAgent** | Multi-step workflows | Complex task orchestration | -| **WorkflowGeneratorAgent** | AI workflow generation | Auto-generate workflow definitions | | **ChatAgent** | General conversation | Fallback for unmatched queries | -| **DeepAgent** | Deep research | Complex multi-turn analysis | ### 2.3 Arena System (Multi-Agent Competition) @@ -124,23 +118,15 @@ src/stock_datasource/arena/ ## 3. Core Orchestration Services -### 3.1 Agent Runtime (`services/agent_runtime.py`) +### 3.1 Config-Driven Harness Runtime -**Purpose:** Unified control plane for all agent execution +**Purpose:** Build and execute agents from ClickHouse `agent_configs` records. **Architecture:** -- Uses **LangGraph Supervisor** (`langgraph_supervisor.create_supervisor`) -- Native multi-agent patterns with `create_react_agent` -- Handoff mechanism via `Command(goto=...)` -- **v2 streaming** with `astream_events(version="v2")` - -**Key Features:** -- SSE event adaptation for frontend compatibility -- Observability metrics (cold start, token cost, classification count) -- Middleware chain integration -- Memory store for cross-session persistence - -**Feature Flag:** `AGENT_RUNTIME_ENABLED` env var +- `OrchestratorAgent` performs lightweight intent classification. +- `ConfigDrivenHarnessAgent` loads the selected DB config and builds a DeepAgents harness. +- `tool_registry.py` resolves configured skills/tool names to callable functions. +- No `AGENT_RUNTIME_ENABLED` or `HARNESS_MODE_ENABLED` switch is required. ### 3.2 Execution Planner (`services/execution_planner.py`) @@ -326,21 +312,21 @@ Competition Phase └── Periodic elimination & replenishment ``` -### 5.3 Workflow Agent Pattern (WorkflowAgent) +### 5.3 Config-Driven Agent Pattern **Flow:** ``` -Workflow Definition (YAML/JSON) - ├── Steps (each step → agent call) - ├── Conditional logic - ├── Parallel execution groups - └── Error handling +Agent config (ClickHouse) + ├── system_prompt + ├── skills / tool names + ├── model_config + └── runtime_config ↓ -WorkflowAgent executes steps - ├── Sequential or parallel execution - ├── State management between steps - ├── Tool invocation - └── Fallback on error +ConfigDrivenHarnessAgent executes request + ├── Resolve tools via tool_registry + ├── Build create_deep_agent harness + ├── Stream SSE-compatible events + └── Persist session metadata ``` ### 5.4 Middleware Chain (Safety & Memory) @@ -544,11 +530,11 @@ Each module provides: - Features: Discussion modes, backtesting, elimination - Specs affected: `multi-agent-arena`, `strategy-competition`, `agent-discussion` -**Refactor Agent Runtime Extensibility** (`refactor-agent-runtime-extensibility/`) +**Orchestrator Simplification** (`refactor-orchestrator-simplify/`) - Status: Active proposal -- Scope: Unified orchestration layer -- Goals: Converge OrchestratorAgent, WorkflowAgent, MultiAgentArena into single runtime -- Key changes: Agent Registry, Execution Planner, Session Memory Service +- Scope: Config-driven Harness as the chat orchestration path +- Goals: Remove feature-flagged runtime layers and deprecated agent files +- Key changes: Lightweight OrchestratorAgent, ConfigDrivenHarnessAgent dispatch, tool registry resolution **Intelligent Strategy System** (`add-intelligent-strategy-system/`) - Status: Archived 2026-01-11 @@ -569,39 +555,27 @@ openspec/specs/ ## 11. Hierarchical Execution Patterns -### 11.1 Three-Tier Hierarchy +### 11.1 Two-Tier Chat Hierarchy **Tier 1: OrchestratorAgent** -- Top-level router +- Top-level chat router - Intent classification -- Agent selection -- Concurrent/sequential execution coordination -- Middleware chain orchestration - -**Tier 2: Specialized Agents** -- Domain-specific agents (MarketAgent, ReportAgent, etc.) -- Tool invocation -- LLM reasoning -- Result formatting - -**Tier 3: WorkflowAgent / MultiAgentArena** -- Multi-step orchestration -- Sub-agent coordination -- Complex reasoning -- Arena-specific competition logic +- Config-driven agent selection +- SSE metadata normalization + +**Tier 2: ConfigDrivenHarnessAgent** +- Loads DB agent configuration +- Resolves tools through `tool_registry` +- Builds the DeepAgents harness +- Streams tool/content/done events ### 11.2 Agent Discovery & Loading **Process:** -1. Runtime scans `src/stock_datasource/agents/` for `*_agent.py` files -2. Extracts classes inheriting from `LangGraphAgent` -3. Creates AgentDescriptor entries -4. Lazy instantiation on demand -5. Caching for performance - -**Classes Excluded:** -- `OrchestratorAgent` (special role) -- `StockDeepAgent` (deprecated) +1. Orchestrator reads visible agent configs from ClickHouse `agent_configs`. +2. LLM classification selects an `agent_name` from that catalog. +3. `get_config_driven_agent(agent_name)` loads and caches the harness agent. +4. Skills/tool names resolve through `tool_registry`. --- @@ -635,10 +609,7 @@ Centralized discovery: - Plugin registry ### 12.5 Adapter Pattern -WorkflowAgent and MultiAgentArena are adapters that: -- Convert domain-specific requests to agent calls -- Provide specialized orchestration -- Integrate with main runtime +External systems can adapt domain-specific requests by creating or selecting DB agent configurations and invoking the config-driven harness path. --- @@ -715,24 +686,20 @@ OrchestratorAgent ## 15. Summary: Multi-Agent Architecture Highlights ### Strengths: -1. **Modular Design**: 18+ specialized agents with clear responsibilities -2. **Flexible Orchestration**: Multiple coordination patterns (route-only, parallel, handoff, discussion) -3. **Safety & Control**: 5 middleware layers for validation, memory, safety -4. **Scalability**: Concurrent agent execution, async/await throughout -5. **Observability**: Langfuse tracing, SSE event streaming, debug metadata -6. **Extensibility**: Plugin system, skill registry, strategy registry -7. **Arena Competition**: Unique multi-agent debate system for strategy refinement -8. **Unified Runtime**: Converging toward single control plane (in progress) +1. **Config-Driven Design**: Chat-facing agents are managed through DB configuration. +2. **Simplified Orchestration**: Orchestrator performs classification and delegates to one harness path. +3. **Tool Registry**: Skills map consistently to callable tool functions. +4. **Observability**: Langfuse tracing, SSE event streaming, debug metadata. +5. **Extensibility**: Plugin system, skill registry, strategy registry. +6. **Arena Competition**: Separate multi-agent debate system for strategy refinement. ### Complexity Points: -1. Multiple agent discovery mechanisms (registry + scanning) -2. Complex state management (session, cache, memory layers) -3. Heavy middleware chain (5 transforms per request) -4. Arena state machine (6 states, concurrent management) -5. Triple execution modes (OrchestratorAgent, WorkflowAgent, MultiAgentArena) +1. DB agent catalog availability now determines chat routing coverage. +2. Some legacy direct-import agents remain for compatibility. +3. Arena state machine remains separate from the lightweight chat orchestrator. ### Future Evolution (Planned): -1. Complete Agent Runtime unification (Phase 1 in progress) +1. Continue config-driven Harness consolidation 2. Explicit Skill Registry standardization 3. SubAgent protocol for better sub-task isolation 4. High-cost path optimization (reduce redundant classification/context) @@ -744,12 +711,12 @@ OrchestratorAgent **Key Agent Files:** - `src/stock_datasource/agents/base_agent.py` - LangGraphAgent interface -- `src/stock_datasource/agents/orchestrator.py` - Main orchestrator (500+ lines) -- `src/stock_datasource/agents/memory_agent.py` - User memory management -- `src/stock_datasource/agents/workflow_agent.py` - Workflow execution +- `src/stock_datasource/agents/orchestrator.py` - Lightweight config-driven orchestrator +- `src/stock_datasource/agents/config_driven_harness_agent.py` - DB-configured Harness agent +- `src/stock_datasource/agents/tools.py` - Shared tool functions **Key Service Files:** -- `src/stock_datasource/services/agent_runtime.py` - Unified control plane +- `src/stock_datasource/services/tool_registry.py` - Skill/tool resolution - `src/stock_datasource/services/execution_planner.py` - Routing config - `src/stock_datasource/services/agent_registry.py` - Agent discovery - `src/stock_datasource/services/agent_cache.py` - Shared caching @@ -783,5 +750,5 @@ This is a **production-grade multi-agent system** with sophisticated orchestrati - **Services** (infrastructure for runtime, registry, memory) - **Middleware** (cross-cutting concerns like safety and memory) -The system is actively evolving toward a unified `Agent Runtime` that will consolidate three separate orchestration layers (OrchestratorAgent, WorkflowAgent, MultiAgentArena) into a single control plane, following patterns from advanced systems like OpenClaw. +The chat orchestration path now favors a lightweight `OrchestratorAgent` plus `ConfigDrivenHarnessAgent`, with legacy direct-import agents retained only where external modules still depend on them. diff --git a/docs/HARNESS_MIGRATION_GUIDE.md b/docs/HARNESS_MIGRATION_GUIDE.md index 071ec64e..bfff8868 100644 --- a/docs/HARNESS_MIGRATION_GUIDE.md +++ b/docs/HARNESS_MIGRATION_GUIDE.md @@ -214,4 +214,4 @@ Phase 3 (1天): P2 + 回归测试 | SubAgentMiddleware 不支持自定义 SSE event 格式 | 保留 execute_stream wrapper,只替内部逻辑 | | MemoryMiddleware 与现有 FactExtractor 冲突 | 两者并行运行,MemoryMiddleware 管会话级,FactExtractor 管知识级 | | deepagents 版本不稳定 | pin 版本,封装 adapter 层 | -| 迁移期间功能回退 | feature flag `HARNESS_MODE_ENABLED` 控制新旧路径 | +| 迁移期间功能回退 | 历史方案曾使用 feature flag;当前默认走 `ConfigDrivenHarnessAgent` | diff --git a/docs/HARNESS_MIGRATION_METHOD.md b/docs/HARNESS_MIGRATION_METHOD.md index 8a101c75..9734390a 100644 --- a/docs/HARNESS_MIGRATION_METHOD.md +++ b/docs/HARNESS_MIGRATION_METHOD.md @@ -12,7 +12,7 @@ Harness 模式为每个 Agent 增加以下能力: - **InMemoryStore**: 跨会话持久化存储 - **astream_events v2**: 与现有 SSE 管道完全兼容 -迁移目标:在**不修改原 Agent 任何代码**的前提下,创建一个 Harness 变体,通过环境变量 `HARNESS_MODE_ENABLED=true` 切换。 +迁移目标(历史):在**不修改原 Agent 任何代码**的前提下创建 Harness 变体。当前主链路已改为默认使用 `ConfigDrivenHarnessAgent`,不再通过 `HARNESS_MODE_ENABLED` 切换。 --- @@ -78,8 +78,7 @@ def _get_harness_store() -> InMemoryStore: _harness_store = InMemoryStore() return _harness_store -def is_harness_mode_enabled() -> bool: - return os.getenv("HARNESS_MODE_ENABLED", "").lower() == "true" +# 历史方案曾使用 HARNESS_MODE_ENABLED 开关;当前不再需要 feature flag。 ``` ### Step 5: 实现 `_init_harness_agent` @@ -130,17 +129,14 @@ def _init_harness_agent(self): 6. 处理 `on_tool_start`、`on_tool_end`、`on_chat_model_stream` 事件 7. 保存历史、发送 agent_end + done 事件 -### Step 7: 在 Orchestrator 添加 Feature Flag +### Step 7: 接入配置驱动 Orchestrator -在 `orchestrator.py` 的单 Agent 路由逻辑中添加: +当前 `orchestrator.py` 默认通过 `get_config_driven_agent(agent_name)` 调度,无需新增 feature flag。 ```python -if plan[0] == "OriginalAgent": - from .harness_original_agent import is_harness_mode_enabled - if is_harness_mode_enabled(): - from .harness_original_agent import get_harness_original_agent - agent = get_harness_original_agent() - logger.info("[Harness] Using Harness instead of ") +from .config_driven_harness_agent import get_config_driven_agent + +agent = get_config_driven_agent(agent_name) ``` ### Step 8: 添加 Singleton 工厂 @@ -161,8 +157,8 @@ def get_harness_xxx_agent() -> HarnessXxxAgent: # 1. 导入测试 python -c "from stock_datasource.agents.harness_xxx_agent import HarnessXxxAgent; a = HarnessXxxAgent(); print(f'OK: {len(a.get_tools())} tools')" -# 2. 功能测试(需要设置环境变量) -HARNESS_MODE_ENABLED=true python -c "..." +# 2. 功能测试(ConfigDrivenHarnessAgent 已是默认路径) +python -c "..." ``` --- @@ -171,7 +167,6 @@ HARNESS_MODE_ENABLED=true python -c "..." | 组件 | 说明 | |------|------| -| `is_harness_mode_enabled()` | 环境变量检查 | | `_harness_store` 单例 | `InMemoryStore` 模块级单例 | | `_get_harness_store()` | Store 获取函数 | | `AgentConfig(name=..., description=..., temperature=0.5, max_tokens=8000)` | 配置 | @@ -207,7 +202,6 @@ HARNESS_MODE_ENABLED=true python -c "..." | `_harness_agent` 未初始化 | `__init__` 中设为 `None`,首次调用时初始化 | | 工具函数签名不兼容 | 确保工具有 docstring + type hints(LangGraph 需要) | | SSE 事件格式不一致 | 严格复用模板中的 event dict 结构 | -| 环境变量大小写 | `is_harness_mode_enabled()` 已做 `.lower()` | --- diff --git a/openspec/changes/refactor-orchestrator-simplify/proposal.md b/openspec/changes/refactor-orchestrator-simplify/proposal.md new file mode 100644 index 00000000..76fc624c --- /dev/null +++ b/openspec/changes/refactor-orchestrator-simplify/proposal.md @@ -0,0 +1,23 @@ +# Change: 精简 Orchestrator 为配置驱动 Harness 路径 + +## Why + +`OrchestratorAgent` 当前保留旧 agent 扫描、AgentRuntime feature flag、Harness feature flag、MCP fallback、多 agent/arena 队列等多套执行路径,导致聊天主链路难以维护。`ConfigDrivenHarnessAgent` 已可从 DB 配置动态构建 agent,并通过 `tool_registry` 解析 skill/tool,具备成为默认路径的条件。 + +## What Changes + +- 移除 `HARNESS_MODE_ENABLED` 与 `AGENT_RUNTIME_ENABLED` 双路径开关。 +- 删除 `services/agent_runtime.py` 中间层,聊天调度直接进入 `ConfigDrivenHarnessAgent`。 +- 精简 `agents/orchestrator.py`,仅保留意图分类、股票代码提取、配置驱动 agent 调度和 SSE 兼容包装。 +- 删除无保留价值的废弃 agent 文件,并修正仍指向这些文件的兼容引用。 +- 保留 `market_agent.py`、`report_agent.py`、`portfolio_agent.py` 的直接 import 兼容,但标记 deprecated。 + +## Impact + +- Affected specs: `chat-orchestration` +- Affected code: + - `src/stock_datasource/agents/orchestrator.py` + - `src/stock_datasource/agents/config_driven_harness_agent.py` + - `src/stock_datasource/services/agent_runtime.py` + - deprecated files under `src/stock_datasource/agents/` + - workflow/overview/daily-analysis references and related tests/docs diff --git a/openspec/changes/refactor-orchestrator-simplify/specs/chat-orchestration/spec.md b/openspec/changes/refactor-orchestrator-simplify/specs/chat-orchestration/spec.md new file mode 100644 index 00000000..6f6b4e71 --- /dev/null +++ b/openspec/changes/refactor-orchestrator-simplify/specs/chat-orchestration/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: Chat协调Agent调度 +系统 MUST 在chat入口使用协调Agent进行意图解析,并 MUST 通过数据库中的 Agent 配置构建 `ConfigDrivenHarnessAgent` 执行请求。 + +#### Scenario: 用户发起对话请求 +- **WHEN** 用户向chat入口发送消息 +- **THEN** 协调Agent解析意图并选择合适的配置驱动Agent执行 + +#### Scenario: 配置驱动Agent缺失 +- **WHEN** 协调Agent无法找到匹配的数据库Agent配置 +- **THEN** 系统返回可理解的错误事件而不是回退到旧Agent文件或AgentRuntime + +### Requirement: Agent发现与能力编目 +系统 MUST 从数据库 Agent 配置中读取可用Agent清单与能力描述以供调度。 + +#### Scenario: 系统启动或首次请求 +- **WHEN** chat入口初始化调度 +- **THEN** 协调Agent获取数据库中可见的Agent清单与能力描述 + +### Requirement: MCP回退调度 +系统 MUST NOT 在chat协调入口维护独立的MCP回退执行路径;工具能力 MUST 通过配置驱动Agent的 skills 与 tool registry 装配。 + +#### Scenario: 无匹配Agent +- **WHEN** 协调Agent无法匹配可用Agent +- **THEN** 系统返回标准 error/done 事件并提示缺少可用Agent配置 diff --git a/openspec/changes/refactor-orchestrator-simplify/tasks.md b/openspec/changes/refactor-orchestrator-simplify/tasks.md new file mode 100644 index 00000000..5f8669c5 --- /dev/null +++ b/openspec/changes/refactor-orchestrator-simplify/tasks.md @@ -0,0 +1,9 @@ +## 1. Implementation + +- [x] 1.1 Create `refactor/orchestrator-simplify` branch from clean `main`. +- [x] 1.2 Rewrite `orchestrator.py` to use config-driven harness dispatch only. +- [x] 1.3 Remove `HARNESS_MODE_ENABLED` and `AGENT_RUNTIME_ENABLED` code paths. +- [x] 1.4 Delete `agent_runtime.py` and deprecated agent files. +- [x] 1.5 Update imports/routes/tests/docs that referenced removed files. +- [x] 1.6 Mark retained legacy direct-import agents as deprecated. +- [x] 1.7 Run reference search, compile checks, and targeted tests. diff --git a/src/stock_datasource/agents/__init__.py b/src/stock_datasource/agents/__init__.py index efe541e7..fbfbab57 100644 --- a/src/stock_datasource/agents/__init__.py +++ b/src/stock_datasource/agents/__init__.py @@ -1,56 +1,64 @@ -"""Agent layer for AI stock platform. +"""Agent layer exports. -All agents are built on LangGraph/DeepAgents framework for: -- Tool calling with function calling -- Multi-step reasoning -- Langfuse observability -- Streaming responses - -Provides specialized agents for: -- Market analysis (K-line, indicators, trend) -- Stock screening -- Financial report analysis -- Portfolio management -- Strategy backtesting -- User memory/preferences -- Data management +Imports are intentionally lazy so importing ``stock_datasource.agents`` does not +load every legacy direct-import agent and its optional data-service dependencies. """ -from .backtest_agent import BacktestAgent +from __future__ import annotations + from .base_agent import ( AgentConfig, AgentContext, AgentResult, - BaseAgent, # Backward compatibility alias - BaseStockAgent, # Backward compatibility alias - BaseTool, # Backward compatibility alias + BaseAgent, + BaseStockAgent, + BaseTool, LangGraphAgent, ToolDefinition, get_langchain_model, get_langfuse_handler, ) -from .chat_agent import ChatAgent -from .datamanage_agent import DataManageAgent -# For backward compatibility with deep_agent imports -from .deep_agent import StockDeepAgent, get_stock_agent -from .etf_agent import EtfAgent, get_etf_agent -from .hk_report_agent import HKReportAgent -from .index_agent import IndexAgent, get_index_agent -from .knowledge_agent import KnowledgeAgent, get_knowledge_agent -from .market_agent import MarketAgent, get_market_agent -from .memory_agent import MemoryAgent -from .news_analyst_agent import NewsAnalystAgent, get_news_analyst_agent -from .orchestrator import OrchestratorAgent, get_orchestrator -from .overview_agent import OverviewAgent, get_overview_agent -from .portfolio_agent import PortfolioAgent -from .report_agent import ReportAgent -from .screener_agent import ScreenerAgent, get_screener_agent -from .tools import STOCK_TOOLS -from .toplist_agent import TopListAgent +_LAZY_EXPORTS = { + "BacktestAgent": (".backtest_agent", "BacktestAgent"), + "ChatAgent": (".chat_agent", "ChatAgent"), + "DataManageAgent": (".datamanage_agent", "DataManageAgent"), + "EtfAgent": (".etf_agent", "EtfAgent"), + "get_etf_agent": (".etf_agent", "get_etf_agent"), + "HKReportAgent": (".hk_report_agent", "HKReportAgent"), + "IndexAgent": (".index_agent", "IndexAgent"), + "get_index_agent": (".index_agent", "get_index_agent"), + "KnowledgeAgent": (".knowledge_agent", "KnowledgeAgent"), + "get_knowledge_agent": (".knowledge_agent", "get_knowledge_agent"), + "MarketAgent": (".market_agent", "MarketAgent"), + "get_market_agent": (".market_agent", "get_market_agent"), + "NewsAnalystAgent": (".news_analyst_agent", "NewsAnalystAgent"), + "get_news_analyst_agent": (".news_analyst_agent", "get_news_analyst_agent"), + "OrchestratorAgent": (".orchestrator", "OrchestratorAgent"), + "get_orchestrator": (".orchestrator", "get_orchestrator"), + "PortfolioAgent": (".portfolio_agent", "PortfolioAgent"), + "ReportAgent": (".report_agent", "ReportAgent"), + "ScreenerAgent": (".screener_agent", "ScreenerAgent"), + "get_screener_agent": (".screener_agent", "get_screener_agent"), + "STOCK_TOOLS": (".tools", "STOCK_TOOLS"), + "TopListAgent": (".toplist_agent", "TopListAgent"), +} + + +def __getattr__(name: str): + if name not in _LAZY_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + import importlib + + module_name, attr_name = _LAZY_EXPORTS[name] + module = importlib.import_module(module_name, __name__) + value = getattr(module, attr_name) + globals()[name] = value + return value + __all__ = [ - # Base classes "LangGraphAgent", "BaseStockAgent", "BaseAgent", @@ -59,38 +67,7 @@ "BaseTool", "AgentContext", "AgentResult", - # Utilities "get_langchain_model", "get_langfuse_handler", - # Orchestrator - "OrchestratorAgent", - "get_orchestrator", - # Tools - "STOCK_TOOLS", - # Specialized agents (all based on LangGraph) - "ChatAgent", - "MarketAgent", - "get_market_agent", - "ScreenerAgent", - "get_screener_agent", - "ReportAgent", - "HKReportAgent", - "MemoryAgent", - "DataManageAgent", - "PortfolioAgent", - "BacktestAgent", - "IndexAgent", - "get_index_agent", - "EtfAgent", - "get_etf_agent", - "OverviewAgent", - "get_overview_agent", - "TopListAgent", - "NewsAnalystAgent", - "get_news_analyst_agent", - "KnowledgeAgent", - "get_knowledge_agent", - # DeepAgent (for backward compatibility) - "StockDeepAgent", - "get_stock_agent", + *_LAZY_EXPORTS.keys(), ] diff --git a/src/stock_datasource/agents/chat_agent.py b/src/stock_datasource/agents/chat_agent.py index 57f4a221..53b4864e 100644 --- a/src/stock_datasource/agents/chat_agent.py +++ b/src/stock_datasource/agents/chat_agent.py @@ -136,75 +136,11 @@ def list_user_workflows() -> str: def execute_workflow(workflow_id: str, variables: str = "{}") -> str: - """执行指定的AI工作流。 - - Args: - workflow_id: 工作流ID,如 template_single_stock 或自定义工作流ID - variables: JSON格式的变量值,如 {"stock_code": "600519.SH"} - - Returns: - 工作流执行结果 - """ - try: - import asyncio - - from stock_datasource.agents.workflow_agent import create_workflow_agent - from stock_datasource.services.workflow_service import get_workflow_service - - # 解析变量 - try: - vars_dict = ( - json.loads(variables) if isinstance(variables, str) else variables - ) - except json.JSONDecodeError: - return '变量格式错误,请使用JSON格式,如: {"stock_code": "600519.SH"}' - - # 获取工作流 - service = get_workflow_service() - workflow = service.get_workflow(workflow_id) - - if not workflow: - return f"未找到工作流: {workflow_id}。请使用 list_user_workflows 查看可用工作流。" - - # 验证必填变量 - for var in workflow.variables: - if var.required and var.name not in vars_dict: - if var.default: - vars_dict[var.name] = var.default - else: - return f"缺少必填变量: {var.label} ({var.name})" - - # 创建工作流Agent并执行 - agent = create_workflow_agent(workflow) - - # 同步执行(收集所有结果) - async def run_workflow(): - content_parts = [] - async for event in agent.execute_workflow(vars_dict): - event_type = event.get("type") - if event_type == "content": - content_parts.append(event.get("content", "")) - elif event_type == "error": - return f"执行错误: {event.get('error', '未知错误')}" - return "".join(content_parts) - - # 运行异步任务 - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - result = loop.run_until_complete(run_workflow()) - - if not result: - return f"工作流 {workflow.name} 执行完成,但未返回结果。" - - return f"## {workflow.name} 执行结果\n\n{result}" - - except Exception as e: - logger.error(f"Failed to execute workflow {workflow_id}: {e}") - return f"执行工作流失败: {e!s}" + """Deprecated workflow execution entry point.""" + return ( + "AI工作流执行已迁移到Agent编排系统。" + f"请在新的编排页面执行工作流 {workflow_id}。" + ) def find_workflow_by_name(name: str) -> str: diff --git a/src/stock_datasource/agents/config_driven_harness_agent.py b/src/stock_datasource/agents/config_driven_harness_agent.py index c015a5fe..8f551437 100644 --- a/src/stock_datasource/agents/config_driven_harness_agent.py +++ b/src/stock_datasource/agents/config_driven_harness_agent.py @@ -10,14 +10,11 @@ The agent reads config from ClickHouse (via agent_config_service) at runtime and dynamically assembles a create_deep_agent(...) instance. - -Feature flag: HARNESS_MODE_ENABLED=true """ from __future__ import annotations import logging -import os import time from collections.abc import AsyncGenerator, Callable from typing import Any @@ -41,11 +38,6 @@ logger = logging.getLogger(__name__) -def is_harness_mode_enabled() -> bool: - """Check whether harness mode is activated via environment variable.""" - return os.getenv("HARNESS_MODE_ENABLED", "").lower() == "true" - - # Shared store instance (module-level singleton) _shared_store: InMemoryStore | None = None diff --git a/src/stock_datasource/agents/deep_agent.py b/src/stock_datasource/agents/deep_agent.py deleted file mode 100644 index f8fecfe4..00000000 --- a/src/stock_datasource/agents/deep_agent.py +++ /dev/null @@ -1,74 +0,0 @@ -"""DeepAgent implementation for stock analysis platform. - -This is a general-purpose stock analysis agent that combines all tools. -For specialized tasks, use the specific agents (MarketAgent, ScreenerAgent, etc.) -""" - -import logging -from collections.abc import Callable - -from .base_agent import AgentConfig, LangGraphAgent -from .tools import STOCK_TOOLS - -logger = logging.getLogger(__name__) - - -class StockDeepAgent(LangGraphAgent): - """General-purpose stock analysis agent using DeepAgents framework. - - This agent has access to all stock analysis tools and can handle - a wide variety of queries. For specialized tasks, consider using - the specific agents (MarketAgent, ScreenerAgent, etc.) - """ - - def __init__(self): - config = AgentConfig( - name="StockDeepAgent", - description="通用股票分析智能体,具备所有分析工具", - recursion_limit=50, - ) - super().__init__(config) - - def get_tools(self) -> list[Callable]: - """Return all stock analysis tools.""" - return STOCK_TOOLS - - def get_system_prompt(self) -> str: - """Return system prompt for the general agent.""" - return """你是一个专业的A股股票分析AI助手。 - -## 可用工具 -- get_stock_info: 获取股票基本信息和最新行情 -- get_stock_kline: 获取K线数据 -- get_stock_valuation: 获取PE、PB等估值指标 -- calculate_technical_indicators: 计算均线、趋势等技术指标 -- screen_stocks: 根据条件筛选股票 -- get_market_overview: 获取大盘整体情况 - -## 常用股票代码 -- 贵州茅台: 600519 -- 平安银行: 000001 -- 比亚迪: 002594 - -## 分析原则 -1. 直接调用工具获取数据,不要过度规划 -2. 获取数据后立即给出分析结论 -3. 使用中文回复,简洁专业 -4. 最多调用3个工具,然后给出结论 -5. 明确指出投资风险 - -## 免责声明 -分析仅供参考,不构成投资建议。投资有风险,入市需谨慎。 -""" - - -# Singleton instance -_stock_agent: StockDeepAgent | None = None - - -def get_stock_agent() -> StockDeepAgent: - """Get or create the stock analysis agent.""" - global _stock_agent - if _stock_agent is None: - _stock_agent = StockDeepAgent() - return _stock_agent diff --git a/src/stock_datasource/agents/enhanced_portfolio_agent.py b/src/stock_datasource/agents/enhanced_portfolio_agent.py deleted file mode 100644 index d47c1ff3..00000000 --- a/src/stock_datasource/agents/enhanced_portfolio_agent.py +++ /dev/null @@ -1,1667 +0,0 @@ -"""Enhanced Portfolio Agent for comprehensive portfolio analysis using LangGraph/DeepAgents.""" - -import logging -from collections.abc import AsyncGenerator -from datetime import datetime -from typing import Any - -import numpy as np -import pandas as pd - -from .base_agent import AgentConfig, AgentResult, LangGraphAgent - -logger = logging.getLogger(__name__) - - -class EnhancedPortfolioAgent(LangGraphAgent): - """Enhanced Portfolio Agent with comprehensive analysis capabilities.""" - - def __init__(self, config: AgentConfig): - super().__init__(config) - self._portfolio_service = None - self._market_service = None - self._toplist_service = None - self._toplist_analysis_service = None - # Current user context (set during execute) - self._current_user_id: str = "default_user" - - # Add portfolio-specific tools - self.tools.extend( - [ - { - "name": "analyze_portfolio_performance", - "description": "分析投资组合整体表现", - "function": self.analyze_portfolio_performance, - }, - { - "name": "analyze_individual_stock", - "description": "分析单个股票的技术面和基本面", - "function": self.analyze_individual_stock, - }, - { - "name": "assess_portfolio_risk", - "description": "评估投资组合风险", - "function": self.assess_portfolio_risk, - }, - { - "name": "generate_investment_recommendations", - "description": "生成投资建议", - "function": self.generate_investment_recommendations, - }, - { - "name": "calculate_technical_indicators", - "description": "计算技术指标", - "function": self.calculate_technical_indicators, - }, - { - "name": "analyze_fundamental_metrics", - "description": "分析基本面指标", - "function": self.analyze_fundamental_metrics, - }, - { - "name": "detect_market_signals", - "description": "检测市场信号", - "function": self.detect_market_signals, - }, - { - "name": "optimize_portfolio_allocation", - "description": "优化投资组合配置", - "function": self.optimize_portfolio_allocation, - }, - { - "name": "analyze_portfolio_toplist", - "description": "分析投资组合相关龙虎榜情况", - "function": self.analyze_portfolio_toplist, - }, - { - "name": "check_position_toplist_status", - "description": "检查持仓股票的龙虎榜状态", - "function": self.check_position_toplist_status, - }, - { - "name": "analyze_position_capital_flow", - "description": "分析持仓股票的资金流向", - "function": self.analyze_position_capital_flow, - }, - ] - ) - - @property - def portfolio_service(self): - """Lazy load portfolio service.""" - if self._portfolio_service is None: - try: - from stock_datasource.modules.portfolio.enhanced_service import ( - get_enhanced_portfolio_service, - ) - - self._portfolio_service = get_enhanced_portfolio_service() - except Exception as e: - logger.warning(f"Failed to get portfolio service: {e}") - return self._portfolio_service - - @property - def market_service(self): - """Lazy load market service.""" - if self._market_service is None: - try: - from stock_datasource.modules.market.service import get_market_service - - self._market_service = get_market_service() - except Exception as e: - logger.warning(f"Failed to get market service: {e}") - return self._market_service - - @property - def toplist_service(self): - """Lazy load toplist service.""" - if self._toplist_service is None: - try: - from stock_datasource.services.toplist_service import TopListService - - self._toplist_service = TopListService() - except Exception as e: - logger.warning(f"Failed to get toplist service: {e}") - return self._toplist_service - - @property - def toplist_analysis_service(self): - """Lazy load toplist analysis service.""" - if self._toplist_analysis_service is None: - try: - from stock_datasource.services.toplist_analysis_service import ( - TopListAnalysisService, - ) - - self._toplist_analysis_service = TopListAnalysisService() - except Exception as e: - logger.warning(f"Failed to get toplist analysis service: {e}") - return self._toplist_analysis_service - - async def analyze_portfolio_performance( - self, analysis_period: int = 30 - ) -> dict[str, Any]: - """分析投资组合整体表现.""" - try: - if not self.portfolio_service: - return {"error": "Portfolio service not available"} - - # Use current user_id from context - user_id = self._current_user_id - - # Get portfolio summary - summary = await self.portfolio_service.get_summary(user_id) - - # Get profit history - profit_history = await self.portfolio_service.get_profit_history( - user_id, analysis_period - ) - - # Calculate performance metrics - performance_metrics = self._calculate_performance_metrics(profit_history) - - # Analyze sector allocation - sector_analysis = self._analyze_sector_allocation( - summary.sector_distribution or {} - ) - - return { - "summary": { - "total_value": summary.total_value, - "total_cost": summary.total_cost, - "total_profit": summary.total_profit, - "profit_rate": summary.profit_rate, - "position_count": summary.position_count, - }, - "performance_metrics": performance_metrics, - "sector_analysis": sector_analysis, - "top_performer": summary.top_performer, - "worst_performer": summary.worst_performer, - "risk_score": summary.risk_score or 50.0, - } - - except Exception as e: - logger.error(f"Failed to analyze portfolio performance: {e}") - return {"error": str(e)} - - async def analyze_individual_stock( - self, ts_code: str, analysis_type: str = "comprehensive" - ) -> dict[str, Any]: - """分析单个股票的技术面和基本面.""" - try: - analysis_result = { - "ts_code": ts_code, - "analysis_type": analysis_type, - "timestamp": datetime.now().isoformat(), - } - - # Get basic stock info - stock_info = await self._get_stock_basic_info(ts_code) - analysis_result["basic_info"] = stock_info - - if analysis_type in ["technical", "comprehensive"]: - # Technical analysis - technical_analysis = await self._perform_technical_analysis(ts_code) - analysis_result["technical_analysis"] = technical_analysis - - if analysis_type in ["fundamental", "comprehensive"]: - # Fundamental analysis - fundamental_analysis = await self._perform_fundamental_analysis(ts_code) - analysis_result["fundamental_analysis"] = fundamental_analysis - - # Generate overall recommendation - recommendation = self._generate_stock_recommendation(analysis_result) - analysis_result["recommendation"] = recommendation - - return analysis_result - - except Exception as e: - logger.error(f"Failed to analyze individual stock {ts_code}: {e}") - return {"error": str(e), "ts_code": ts_code} - - async def assess_portfolio_risk(self) -> dict[str, Any]: - """评估投资组合风险.""" - try: - if not self.portfolio_service: - return {"error": "Portfolio service not available"} - - # Use current user_id from context - user_id = self._current_user_id - - # Get positions - positions = await self.portfolio_service.get_positions(user_id) - - if not positions: - return {"risk_level": "无风险", "message": "无持仓"} - - # Calculate various risk metrics - risk_assessment = { - "overall_risk_score": 0.0, - "concentration_risk": self._assess_concentration_risk(positions), - "sector_risk": self._assess_sector_risk(positions), - "volatility_risk": await self._assess_volatility_risk(positions), - "liquidity_risk": self._assess_liquidity_risk(positions), - "correlation_risk": await self._assess_correlation_risk(positions), - "recommendations": [], - } - - # Calculate overall risk score - risk_assessment["overall_risk_score"] = self._calculate_overall_risk_score( - risk_assessment - ) - - # Generate risk recommendations - risk_assessment["recommendations"] = self._generate_risk_recommendations( - risk_assessment - ) - - return risk_assessment - - except Exception as e: - logger.error(f"Failed to assess portfolio risk: {e}") - return {"error": str(e)} - - async def generate_investment_recommendations( - self, market_condition: str = "neutral" - ) -> dict[str, Any]: - """生成投资建议.""" - try: - recommendations = { - "timestamp": datetime.now().isoformat(), - "market_condition": market_condition, - "portfolio_recommendations": [], - "individual_stock_recommendations": [], - "risk_management_recommendations": [], - "allocation_recommendations": [], - } - - if not self.portfolio_service: - return {"error": "Portfolio service not available"} - - # Use current user_id from context - user_id = self._current_user_id - - # Get portfolio data - positions = await self.portfolio_service.get_positions(user_id) - summary = await self.portfolio_service.get_summary(user_id) - - # Portfolio-level recommendations - portfolio_recs = await self._generate_portfolio_recommendations( - summary, market_condition - ) - recommendations["portfolio_recommendations"] = portfolio_recs - - # Individual stock recommendations - for position in positions[:5]: # Limit to top 5 positions - stock_rec = await self._generate_individual_stock_recommendation( - position, market_condition - ) - recommendations["individual_stock_recommendations"].append(stock_rec) - - # Risk management recommendations - risk_recs = await self._generate_risk_management_recommendations( - positions, summary - ) - recommendations["risk_management_recommendations"] = risk_recs - - # Allocation recommendations - allocation_recs = self._generate_allocation_recommendations( - summary, market_condition - ) - recommendations["allocation_recommendations"] = allocation_recs - - return recommendations - - except Exception as e: - logger.error(f"Failed to generate investment recommendations: {e}") - return {"error": str(e)} - - async def calculate_technical_indicators( - self, ts_code: str, period: int = 60 - ) -> dict[str, Any]: - """计算技术指标.""" - try: - # Get historical price data - price_data = await self._get_price_data(ts_code, period) - - if price_data is None or len(price_data) < 20: - return {"error": "Insufficient price data"} - - indicators = {} - - # Moving averages - indicators["ma5"] = price_data["close"].rolling(5).mean().iloc[-1] - indicators["ma10"] = price_data["close"].rolling(10).mean().iloc[-1] - indicators["ma20"] = price_data["close"].rolling(20).mean().iloc[-1] - indicators["ma60"] = ( - price_data["close"].rolling(60).mean().iloc[-1] - if len(price_data) >= 60 - else None - ) - - # RSI - indicators["rsi"] = self._calculate_rsi(price_data["close"]) - - # MACD - macd_data = self._calculate_macd(price_data["close"]) - indicators.update(macd_data) - - # Bollinger Bands - bb_data = self._calculate_bollinger_bands(price_data["close"]) - indicators.update(bb_data) - - # Volume indicators - if "volume" in price_data.columns: - indicators["volume_ma5"] = ( - price_data["volume"].rolling(5).mean().iloc[-1] - ) - indicators["volume_ratio"] = ( - price_data["volume"].iloc[-1] / indicators["volume_ma5"] - ) - - # Current price and change - indicators["current_price"] = price_data["close"].iloc[-1] - indicators["price_change"] = ( - price_data["close"].iloc[-1] - price_data["close"].iloc[-2] - ) - indicators["price_change_pct"] = ( - indicators["price_change"] / price_data["close"].iloc[-2] - ) * 100 - - return { - "ts_code": ts_code, - "calculation_date": datetime.now().date().isoformat(), - "indicators": indicators, - "data_points": len(price_data), - } - - except Exception as e: - logger.error(f"Failed to calculate technical indicators for {ts_code}: {e}") - return {"error": str(e), "ts_code": ts_code} - - async def analyze_fundamental_metrics(self, ts_code: str) -> dict[str, Any]: - """分析基本面指标.""" - try: - # Get fundamental data (mock implementation) - fundamental_data = await self._get_fundamental_data(ts_code) - - if not fundamental_data: - return {"error": "No fundamental data available"} - - analysis = { - "ts_code": ts_code, - "analysis_date": datetime.now().date().isoformat(), - "valuation_metrics": {}, - "profitability_metrics": {}, - "growth_metrics": {}, - "financial_health": {}, - "overall_score": 0.0, - } - - # Valuation metrics - analysis["valuation_metrics"] = { - "pe_ratio": fundamental_data.get("pe_ratio", 0), - "pb_ratio": fundamental_data.get("pb_ratio", 0), - "ps_ratio": fundamental_data.get("ps_ratio", 0), - "peg_ratio": fundamental_data.get("peg_ratio", 0), - } - - # Profitability metrics - analysis["profitability_metrics"] = { - "roe": fundamental_data.get("roe", 0), - "roa": fundamental_data.get("roa", 0), - "gross_margin": fundamental_data.get("gross_margin", 0), - "net_margin": fundamental_data.get("net_margin", 0), - } - - # Growth metrics - analysis["growth_metrics"] = { - "revenue_growth": fundamental_data.get("revenue_growth", 0), - "earnings_growth": fundamental_data.get("earnings_growth", 0), - "book_value_growth": fundamental_data.get("book_value_growth", 0), - } - - # Financial health - analysis["financial_health"] = { - "debt_to_equity": fundamental_data.get("debt_to_equity", 0), - "current_ratio": fundamental_data.get("current_ratio", 0), - "quick_ratio": fundamental_data.get("quick_ratio", 0), - } - - # Calculate overall score - analysis["overall_score"] = self._calculate_fundamental_score(analysis) - - return analysis - - except Exception as e: - logger.error(f"Failed to analyze fundamental metrics for {ts_code}: {e}") - return {"error": str(e), "ts_code": ts_code} - - async def detect_market_signals(self, ts_code: str) -> dict[str, Any]: - """检测市场信号.""" - try: - signals = { - "ts_code": ts_code, - "detection_time": datetime.now().isoformat(), - "technical_signals": [], - "volume_signals": [], - "momentum_signals": [], - "overall_signal": "neutral", - } - - # Get technical indicators - tech_indicators = await self.calculate_technical_indicators(ts_code) - - if "error" in tech_indicators: - return tech_indicators - - indicators = tech_indicators["indicators"] - - # Technical signals - if indicators.get("current_price", 0) > indicators.get("ma20", 0): - signals["technical_signals"].append("价格突破20日均线") - - if indicators.get("rsi", 50) > 70: - signals["technical_signals"].append("RSI超买信号") - elif indicators.get("rsi", 50) < 30: - signals["technical_signals"].append("RSI超卖信号") - - # MACD signals - if indicators.get("macd", 0) > indicators.get("macd_signal", 0): - signals["technical_signals"].append("MACD金叉信号") - - # Volume signals - if indicators.get("volume_ratio", 1) > 2: - signals["volume_signals"].append("成交量放大") - - # Momentum signals - if indicators.get("price_change_pct", 0) > 5: - signals["momentum_signals"].append("强势上涨") - elif indicators.get("price_change_pct", 0) < -5: - signals["momentum_signals"].append("快速下跌") - - # Determine overall signal - signals["overall_signal"] = self._determine_overall_signal(signals) - - return signals - - except Exception as e: - logger.error(f"Failed to detect market signals for {ts_code}: {e}") - return {"error": str(e), "ts_code": ts_code} - - async def optimize_portfolio_allocation( - self, target_risk: str = "moderate" - ) -> dict[str, Any]: - """优化投资组合配置.""" - try: - if not self.portfolio_service: - return {"error": "Portfolio service not available"} - - # Use current user_id from context - user_id = self._current_user_id - - # Get current portfolio - positions = await self.portfolio_service.get_positions(user_id) - summary = await self.portfolio_service.get_summary(user_id) - - optimization = { - "user_id": user_id, - "target_risk": target_risk, - "current_allocation": {}, - "recommended_allocation": {}, - "rebalancing_actions": [], - "expected_improvement": {}, - } - - # Analyze current allocation - current_allocation = self._analyze_current_allocation(positions, summary) - optimization["current_allocation"] = current_allocation - - # Generate recommended allocation - recommended_allocation = self._generate_recommended_allocation( - current_allocation, target_risk - ) - optimization["recommended_allocation"] = recommended_allocation - - # Generate rebalancing actions - rebalancing_actions = self._generate_rebalancing_actions( - current_allocation, recommended_allocation - ) - optimization["rebalancing_actions"] = rebalancing_actions - - # Calculate expected improvement - expected_improvement = self._calculate_expected_improvement( - current_allocation, recommended_allocation - ) - optimization["expected_improvement"] = expected_improvement - - return optimization - - except Exception as e: - logger.error(f"Failed to optimize portfolio allocation: {e}") - return {"error": str(e)} - - # Private helper methods - def _calculate_performance_metrics( - self, profit_history: list[dict] - ) -> dict[str, float]: - """Calculate portfolio performance metrics.""" - if not profit_history: - return {} - - # Convert to DataFrame for easier calculation - df = pd.DataFrame(profit_history) - - if "total_profit" not in df.columns: - return {} - - returns = df["total_profit"].pct_change().dropna() - - return { - "total_return": df["total_profit"].iloc[-1] if len(df) > 0 else 0, - "volatility": returns.std() * np.sqrt(252) if len(returns) > 1 else 0, - "sharpe_ratio": (returns.mean() / returns.std()) * np.sqrt(252) - if len(returns) > 1 and returns.std() > 0 - else 0, - "max_drawdown": self._calculate_max_drawdown(df["total_profit"]) - if len(df) > 1 - else 0, - "win_rate": (returns > 0).mean() if len(returns) > 0 else 0, - } - - def _calculate_max_drawdown(self, values: pd.Series) -> float: - """Calculate maximum drawdown.""" - peak = values.expanding().max() - drawdown = (values - peak) / peak - return drawdown.min() - - def _analyze_sector_allocation( - self, sector_distribution: dict[str, float] - ) -> dict[str, Any]: - """Analyze sector allocation.""" - if not sector_distribution: - return {"message": "No sector data available"} - - total_allocation = sum(sector_distribution.values()) - - analysis = { - "sector_weights": sector_distribution, - "concentration_level": "low", - "diversification_score": 0.0, - "recommendations": [], - } - - # Check concentration - max_weight = max(sector_distribution.values()) if sector_distribution else 0 - if max_weight > 50: - analysis["concentration_level"] = "high" - analysis["recommendations"].append("考虑降低单一行业集中度") - elif max_weight > 30: - analysis["concentration_level"] = "medium" - - # Calculate diversification score (inverse of Herfindahl index) - if total_allocation > 0: - normalized_weights = [ - w / total_allocation for w in sector_distribution.values() - ] - herfindahl_index = sum(w**2 for w in normalized_weights) - analysis["diversification_score"] = 1 - herfindahl_index - - return analysis - - def _assess_concentration_risk(self, positions: list) -> dict[str, Any]: - """Assess concentration risk.""" - if not positions: - return {"risk_level": "无", "message": "无持仓"} - - total_value = sum(p.market_value or 0 for p in positions) - - if total_value == 0: - return {"risk_level": "无法评估", "message": "无市值数据"} - - # Calculate position weights - weights = [(p.market_value or 0) / total_value for p in positions] - max_weight = max(weights) - - risk_assessment = { - "max_position_weight": max_weight, - "position_count": len(positions), - "risk_level": "低", - "recommendations": [], - } - - if max_weight > 0.4: - risk_assessment["risk_level"] = "高" - risk_assessment["recommendations"].append("单一持仓占比过高,建议分散投资") - elif max_weight > 0.25: - risk_assessment["risk_level"] = "中" - risk_assessment["recommendations"].append("注意单一持仓集中度") - - if len(positions) < 5: - risk_assessment["recommendations"].append("持仓数量较少,建议增加分散度") - - return risk_assessment - - def _assess_sector_risk(self, positions: list) -> dict[str, Any]: - """Assess sector risk.""" - sector_exposure = {} - total_value = sum(p.market_value or 0 for p in positions) - - for position in positions: - sector = position.sector or "未知" - value = position.market_value or 0 - sector_exposure[sector] = sector_exposure.get(sector, 0) + value - - if total_value == 0: - return {"risk_level": "无法评估", "message": "无市值数据"} - - # Normalize to percentages - sector_weights = {k: v / total_value for k, v in sector_exposure.items()} - max_sector_weight = max(sector_weights.values()) if sector_weights else 0 - - risk_assessment = { - "sector_weights": sector_weights, - "max_sector_weight": max_sector_weight, - "risk_level": "低", - "recommendations": [], - } - - if max_sector_weight > 0.6: - risk_assessment["risk_level"] = "高" - risk_assessment["recommendations"].append("行业集中度过高,建议跨行业分散") - elif max_sector_weight > 0.4: - risk_assessment["risk_level"] = "中" - risk_assessment["recommendations"].append("注意行业集中风险") - - return risk_assessment - - async def _assess_volatility_risk(self, positions: list) -> dict[str, Any]: - """Assess volatility risk.""" - # Mock implementation - in reality would calculate based on historical volatility - return { - "portfolio_volatility": 0.25, - "risk_level": "中", - "recommendations": ["监控市场波动"], - } - - def _assess_liquidity_risk(self, positions: list) -> dict[str, Any]: - """Assess liquidity risk.""" - # Mock implementation - in reality would check trading volume and market cap - return {"liquidity_score": 0.8, "risk_level": "低", "recommendations": []} - - async def _assess_correlation_risk(self, positions: list) -> dict[str, Any]: - """Assess correlation risk.""" - # Mock implementation - in reality would calculate correlation matrix - return {"average_correlation": 0.3, "risk_level": "低", "recommendations": []} - - def _calculate_overall_risk_score(self, risk_assessment: dict) -> float: - """Calculate overall risk score.""" - # Simple weighted average of different risk components - weights = { - "concentration_risk": 0.3, - "sector_risk": 0.25, - "volatility_risk": 0.25, - "liquidity_risk": 0.1, - "correlation_risk": 0.1, - } - - risk_scores = {"低": 20, "中": 50, "高": 80} - - total_score = 0 - for risk_type, weight in weights.items(): - if risk_type in risk_assessment: - risk_level = risk_assessment[risk_type].get("risk_level", "中") - score = risk_scores.get(risk_level, 50) - total_score += score * weight - - return total_score - - def _generate_risk_recommendations(self, risk_assessment: dict) -> list[str]: - """Generate risk management recommendations.""" - recommendations = [] - - overall_score = risk_assessment.get("overall_risk_score", 50) - - if overall_score > 70: - recommendations.append("整体风险较高,建议降低仓位或增加分散度") - elif overall_score > 50: - recommendations.append("风险水平适中,建议定期监控") - else: - recommendations.append("风险水平较低,可考虑适当增加收益型投资") - - # Add specific recommendations from each risk component - for risk_type, risk_data in risk_assessment.items(): - if isinstance(risk_data, dict) and "recommendations" in risk_data: - recommendations.extend(risk_data["recommendations"]) - - return list(set(recommendations)) # Remove duplicates - - # Technical indicator calculation methods - def _calculate_rsi(self, prices: pd.Series, period: int = 14) -> float: - """Calculate RSI.""" - delta = prices.diff() - gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() - loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() - rs = gain / loss - rsi = 100 - (100 / (1 + rs)) - return rsi.iloc[-1] if not pd.isna(rsi.iloc[-1]) else 50.0 - - def _calculate_macd( - self, prices: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9 - ) -> dict[str, float]: - """Calculate MACD.""" - ema_fast = prices.ewm(span=fast).mean() - ema_slow = prices.ewm(span=slow).mean() - macd = ema_fast - ema_slow - macd_signal = macd.ewm(span=signal).mean() - macd_hist = macd - macd_signal - - return { - "macd": macd.iloc[-1] if not pd.isna(macd.iloc[-1]) else 0.0, - "macd_signal": macd_signal.iloc[-1] - if not pd.isna(macd_signal.iloc[-1]) - else 0.0, - "macd_hist": macd_hist.iloc[-1] if not pd.isna(macd_hist.iloc[-1]) else 0.0, - } - - def _calculate_bollinger_bands( - self, prices: pd.Series, period: int = 20, std_dev: int = 2 - ) -> dict[str, float]: - """Calculate Bollinger Bands.""" - sma = prices.rolling(window=period).mean() - std = prices.rolling(window=period).std() - - return { - "bb_upper": (sma + (std * std_dev)).iloc[-1] - if not pd.isna(sma.iloc[-1]) - else 0.0, - "bb_middle": sma.iloc[-1] if not pd.isna(sma.iloc[-1]) else 0.0, - "bb_lower": (sma - (std * std_dev)).iloc[-1] - if not pd.isna(sma.iloc[-1]) - else 0.0, - } - - # Mock data methods (to be replaced with real data sources) - async def _get_stock_basic_info(self, ts_code: str) -> dict[str, Any]: - """Get basic stock information.""" - # Mock implementation - return { - "ts_code": ts_code, - "name": f"股票{ts_code}", - "industry": "未知", - "market_cap": 1000000000, - "pe_ratio": 25.0, - } - - async def _get_price_data(self, ts_code: str, period: int) -> pd.DataFrame | None: - """Get historical price data.""" - # Mock implementation - generate sample data - dates = pd.date_range(end=datetime.now(), periods=period, freq="D") - base_price = 100.0 - - # Generate random walk price data - returns = np.random.normal(0.001, 0.02, period) - prices = [base_price] - for ret in returns[1:]: - prices.append(prices[-1] * (1 + ret)) - - return pd.DataFrame( - { - "date": dates, - "close": prices, - "volume": np.random.randint(1000000, 10000000, period), - } - ) - - async def _get_fundamental_data(self, ts_code: str) -> dict[str, float]: - """Get fundamental data.""" - # Mock implementation - return { - "pe_ratio": 25.0, - "pb_ratio": 3.2, - "ps_ratio": 5.1, - "peg_ratio": 1.2, - "roe": 15.5, - "roa": 8.2, - "gross_margin": 35.0, - "net_margin": 12.0, - "revenue_growth": 8.5, - "earnings_growth": 12.0, - "book_value_growth": 10.0, - "debt_to_equity": 0.4, - "current_ratio": 2.1, - "quick_ratio": 1.5, - } - - def _calculate_fundamental_score(self, analysis: dict) -> float: - """Calculate overall fundamental score.""" - # Simple scoring based on key metrics - score = 50.0 # Base score - - valuation = analysis.get("valuation_metrics", {}) - profitability = analysis.get("profitability_metrics", {}) - growth = analysis.get("growth_metrics", {}) - - # Valuation scoring (lower is better for PE, PB) - pe_ratio = valuation.get("pe_ratio", 25) - if pe_ratio < 15: - score += 10 - elif pe_ratio > 30: - score -= 10 - - # Profitability scoring - roe = profitability.get("roe", 0) - if roe > 15: - score += 15 - elif roe < 5: - score -= 15 - - # Growth scoring - revenue_growth = growth.get("revenue_growth", 0) - if revenue_growth > 10: - score += 10 - elif revenue_growth < 0: - score -= 10 - - return max(0, min(100, score)) - - def _determine_overall_signal(self, signals: dict) -> str: - """Determine overall market signal.""" - bullish_signals = 0 - bearish_signals = 0 - - # Count signals - for signal_list in [ - signals["technical_signals"], - signals["volume_signals"], - signals["momentum_signals"], - ]: - for signal in signal_list: - if any(word in signal for word in ["突破", "金叉", "放大", "上涨"]): - bullish_signals += 1 - elif any(word in signal for word in ["下跌", "超卖", "死叉"]): - bearish_signals += 1 - - if bullish_signals > bearish_signals + 1: - return "bullish" - elif bearish_signals > bullish_signals + 1: - return "bearish" - else: - return "neutral" - - # Recommendation generation methods - async def _perform_technical_analysis(self, ts_code: str) -> dict[str, Any]: - """Perform technical analysis.""" - indicators = await self.calculate_technical_indicators(ts_code) - signals = await self.detect_market_signals(ts_code) - - return { - "indicators": indicators.get("indicators", {}), - "signals": signals, - "trend": self._determine_trend(indicators.get("indicators", {})), - "support_resistance": self._calculate_support_resistance( - indicators.get("indicators", {}) - ), - } - - async def _perform_fundamental_analysis(self, ts_code: str) -> dict[str, Any]: - """Perform fundamental analysis.""" - return await self.analyze_fundamental_metrics(ts_code) - - def _generate_stock_recommendation(self, analysis_result: dict) -> dict[str, Any]: - """Generate stock recommendation based on analysis.""" - recommendation = { - "action": "hold", - "confidence": 0.5, - "target_price": None, - "stop_loss": None, - "reasoning": [], - } - - # Technical analysis influence - if "technical_analysis" in analysis_result: - tech_signals = analysis_result["technical_analysis"].get("signals", {}) - overall_signal = tech_signals.get("overall_signal", "neutral") - - if overall_signal == "bullish": - recommendation["action"] = "buy" - recommendation["confidence"] += 0.2 - recommendation["reasoning"].append("技术面呈现多头信号") - elif overall_signal == "bearish": - recommendation["action"] = "sell" - recommendation["confidence"] += 0.2 - recommendation["reasoning"].append("技术面呈现空头信号") - - # Fundamental analysis influence - if "fundamental_analysis" in analysis_result: - fund_score = analysis_result["fundamental_analysis"].get( - "overall_score", 50 - ) - - if fund_score > 70: - if recommendation["action"] != "sell": - recommendation["action"] = "buy" - recommendation["confidence"] += 0.2 - recommendation["reasoning"].append("基本面评分良好") - elif fund_score < 30: - recommendation["action"] = "sell" - recommendation["confidence"] += 0.2 - recommendation["reasoning"].append("基本面评分较差") - - # Ensure confidence is within bounds - recommendation["confidence"] = max(0.1, min(0.9, recommendation["confidence"])) - - return recommendation - - def _determine_trend(self, indicators: dict) -> str: - """Determine price trend.""" - current_price = indicators.get("current_price", 0) - ma20 = indicators.get("ma20", 0) - ma60 = indicators.get("ma60", 0) - - if current_price > ma20 > ma60: - return "uptrend" - elif current_price < ma20 < ma60: - return "downtrend" - else: - return "sideways" - - def _calculate_support_resistance(self, indicators: dict) -> dict[str, float]: - """Calculate support and resistance levels.""" - current_price = indicators.get("current_price", 0) - bb_upper = indicators.get("bb_upper", 0) - bb_lower = indicators.get("bb_lower", 0) - ma20 = indicators.get("ma20", 0) - - return { - "resistance": max(bb_upper, current_price * 1.05), - "support": min(bb_lower, current_price * 0.95), - "key_level": ma20, - } - - async def _generate_portfolio_recommendations( - self, summary, market_condition: str - ) -> list[dict[str, Any]]: - """Generate portfolio-level recommendations.""" - recommendations = [] - - # Performance-based recommendations - if summary.profit_rate > 10: - recommendations.append( - { - "type": "performance", - "message": "投资组合表现良好,建议保持当前策略", - "priority": "low", - } - ) - elif summary.profit_rate < -10: - recommendations.append( - { - "type": "performance", - "message": "投资组合亏损较大,建议重新评估投资策略", - "priority": "high", - } - ) - - # Market condition based recommendations - if market_condition == "bearish": - recommendations.append( - { - "type": "market", - "message": "市场环境偏空,建议降低仓位或增加防御性资产", - "priority": "medium", - } - ) - elif market_condition == "bullish": - recommendations.append( - { - "type": "market", - "message": "市场环境向好,可考虑适当增加仓位", - "priority": "medium", - } - ) - - return recommendations - - async def _generate_individual_stock_recommendation( - self, position, market_condition: str - ) -> dict[str, Any]: - """Generate recommendation for individual stock.""" - analysis = await self.analyze_individual_stock(position.ts_code) - - recommendation = { - "ts_code": position.ts_code, - "stock_name": position.stock_name, - "current_position": { - "quantity": position.quantity, - "cost_price": position.cost_price, - "current_price": position.current_price, - "profit_rate": position.profit_rate, - }, - } - - if "recommendation" in analysis: - recommendation.update(analysis["recommendation"]) - else: - recommendation.update( - { - "action": "hold", - "confidence": 0.5, - "reasoning": ["数据不足,建议保持观望"], - } - ) - - return recommendation - - async def _generate_risk_management_recommendations( - self, positions: list, summary - ) -> list[dict[str, Any]]: - """Generate risk management recommendations.""" - recommendations = [] - - # Position size recommendations - total_value = summary.total_value - for position in positions: - if position.market_value and total_value > 0: - weight = position.market_value / total_value - if weight > 0.3: - recommendations.append( - { - "type": "position_size", - "ts_code": position.ts_code, - "message": f"{position.stock_name}仓位过重({weight:.1%}),建议减仓", - "priority": "medium", - } - ) - - # Stop loss recommendations - for position in positions: - if position.profit_rate and position.profit_rate < -15: - recommendations.append( - { - "type": "stop_loss", - "ts_code": position.ts_code, - "message": f"{position.stock_name}亏损较大({position.profit_rate:.1f}%),建议考虑止损", - "priority": "high", - } - ) - - return recommendations - - def _generate_allocation_recommendations( - self, summary, market_condition: str - ) -> list[dict[str, Any]]: - """Generate allocation recommendations.""" - recommendations = [] - - sector_dist = summary.sector_distribution or {} - - # Sector diversification recommendations - if len(sector_dist) < 3: - recommendations.append( - { - "type": "diversification", - "message": "建议增加行业分散度,目前行业集中度较高", - "priority": "medium", - } - ) - - # Sector-specific recommendations based on market condition - if market_condition == "bullish": - recommendations.append( - { - "type": "sector_allocation", - "message": "市场向好,可考虑增加成长型行业配置", - "priority": "low", - } - ) - elif market_condition == "bearish": - recommendations.append( - { - "type": "sector_allocation", - "message": "市场偏弱,建议增加防御性行业配置", - "priority": "medium", - } - ) - - return recommendations - - def _analyze_current_allocation(self, positions: list, summary) -> dict[str, Any]: - """Analyze current portfolio allocation.""" - total_value = summary.total_value - - allocation = { - "total_value": total_value, - "position_weights": {}, - "sector_weights": summary.sector_distribution or {}, - "risk_metrics": {"concentration": 0.0, "diversification": 0.0}, - } - - # Calculate position weights - for position in positions: - if position.market_value and total_value > 0: - weight = position.market_value / total_value - allocation["position_weights"][position.ts_code] = { - "weight": weight, - "stock_name": position.stock_name, - "sector": position.sector, - } - - # Calculate concentration (max position weight) - if allocation["position_weights"]: - allocation["risk_metrics"]["concentration"] = max( - pos["weight"] for pos in allocation["position_weights"].values() - ) - - return allocation - - def _generate_recommended_allocation( - self, current_allocation: dict, target_risk: str - ) -> dict[str, Any]: - """Generate recommended allocation based on target risk.""" - # Risk-based allocation targets - risk_profiles = { - "conservative": { - "max_position": 0.15, - "max_sector": 0.25, - "min_positions": 8, - }, - "moderate": {"max_position": 0.20, "max_sector": 0.35, "min_positions": 6}, - "aggressive": { - "max_position": 0.30, - "max_sector": 0.50, - "min_positions": 4, - }, - } - - profile = risk_profiles.get(target_risk, risk_profiles["moderate"]) - - recommended = { - "target_risk": target_risk, - "allocation_targets": profile, - "sector_targets": { - "金融": 0.20, - "科技": 0.25, - "消费": 0.20, - "医药": 0.15, - "其他": 0.20, - }, - "rebalancing_needed": False, - } - - # Check if rebalancing is needed - current_concentration = current_allocation["risk_metrics"]["concentration"] - if current_concentration > profile["max_position"]: - recommended["rebalancing_needed"] = True - - return recommended - - def _generate_rebalancing_actions( - self, current: dict, recommended: dict - ) -> list[dict[str, Any]]: - """Generate specific rebalancing actions.""" - actions = [] - - if not recommended["rebalancing_needed"]: - return actions - - max_position = recommended["allocation_targets"]["max_position"] - - # Find overweight positions - for ts_code, position_data in current["position_weights"].items(): - if position_data["weight"] > max_position: - reduce_amount = position_data["weight"] - max_position - actions.append( - { - "action": "reduce", - "ts_code": ts_code, - "stock_name": position_data["stock_name"], - "current_weight": position_data["weight"], - "target_weight": max_position, - "reduce_percentage": reduce_amount, - "reason": f"仓位超过{max_position:.1%}限制", - } - ) - - return actions - - def _calculate_expected_improvement( - self, current: dict, recommended: dict - ) -> dict[str, Any]: - """Calculate expected improvement from rebalancing.""" - return { - "risk_reduction": "预期降低10-15%的组合风险", - "diversification_improvement": "提高投资组合分散度", - "expected_return": "在控制风险的前提下优化收益", - "implementation_cost": "预计交易成本0.2-0.5%", - } - - async def analyze_portfolio_toplist(self) -> dict[str, Any]: - """分析投资组合相关的龙虎榜情况""" - try: - if not self.portfolio_service or not self.toplist_service: - return {"error": "Required services not available"} - - # Use current user_id from context - user_id = self._current_user_id - - # 获取持仓股票 - positions = await self.portfolio_service.get_positions(user_id) - if not positions: - return {"message": "当前无持仓股票"} - - # 分析持仓股票的龙虎榜情况 - toplist_analysis = { - "on_list_positions": [], - "capital_flow_analysis": {}, - "risk_alerts": [], - "investment_suggestions": [], - } - - # 获取最近5天的数据 - from datetime import datetime - - end_date = datetime.now().date() - - for position in positions: - ts_code = position.get("ts_code") - if not ts_code: - continue - - # 获取该股票的龙虎榜历史 - history = await self.toplist_service.get_stock_top_list_history( - ts_code, 5 - ) - - if history: - # 计算席位集中度 - concentration = await self.toplist_analysis_service.calculate_seat_concentration( - ts_code, 5 - ) - - position_analysis = { - "ts_code": ts_code, - "stock_name": position.get("stock_name", ""), - "position_weight": position.get("weight", 0), - "toplist_appearances": len(history), - "latest_appearance": history[0]["trade_date"] - if history - else None, - "concentration_index": concentration.get( - "concentration_index", 0 - ), - "institution_dominance": concentration.get( - "institution_dominance", 0 - ), - "recent_net_flow": sum( - item.get("net_amount", 0) for item in history - ), - "risk_level": self._assess_toplist_risk(history, concentration), - } - - toplist_analysis["on_list_positions"].append(position_analysis) - - # 生成风险预警 - if concentration.get("concentration_index", 0) > 0.7: - toplist_analysis["risk_alerts"].append( - { - "ts_code": ts_code, - "type": "high_concentration", - "message": f"{position.get('stock_name', ts_code)}席位高度集中,需关注流动性风险", - } - ) - - if position_analysis["recent_net_flow"] < -100000: # 净流出超过10万 - toplist_analysis["risk_alerts"].append( - { - "ts_code": ts_code, - "type": "capital_outflow", - "message": f"{position.get('stock_name', ts_code)}近期资金净流出明显,建议关注", - } - ) - - # 生成投资建议 - toplist_analysis["investment_suggestions"] = ( - self._generate_toplist_suggestions( - toplist_analysis["on_list_positions"] - ) - ) - - # 整体资金流向分析 - total_net_flow = sum( - pos["recent_net_flow"] for pos in toplist_analysis["on_list_positions"] - ) - avg_concentration = ( - np.mean( - [ - pos["concentration_index"] - for pos in toplist_analysis["on_list_positions"] - ] - ) - if toplist_analysis["on_list_positions"] - else 0 - ) - - toplist_analysis["capital_flow_analysis"] = { - "total_net_flow": total_net_flow, - "average_concentration": avg_concentration, - "positions_on_toplist": len(toplist_analysis["on_list_positions"]), - "high_risk_positions": len( - [ - pos - for pos in toplist_analysis["on_list_positions"] - if pos["risk_level"] == "high" - ] - ), - } - - return { - "success": True, - "data": toplist_analysis, - "message": f"成功分析投资组合龙虎榜情况,{len(toplist_analysis['on_list_positions'])}只股票有龙虎榜记录", - } - - except Exception as e: - logger.error(f"Failed to analyze portfolio toplist: {e}") - return {"success": False, "error": str(e)} - - async def check_position_toplist_status(self) -> dict[str, Any]: - """检查持仓股票的龙虎榜状态""" - try: - if not self.portfolio_service or not self.toplist_service: - return {"error": "Required services not available"} - - # Use current user_id from context - user_id = self._current_user_id - - positions = await self.portfolio_service.get_positions(user_id) - if not positions: - return {"message": "当前无持仓股票"} - - # 获取今日龙虎榜数据 - from datetime import datetime - - today = datetime.now().strftime("%Y-%m-%d") - today_toplist = await self.toplist_service.get_top_list_by_date(today) - - # 检查持仓股票是否在今日龙虎榜中 - toplist_codes = {item["ts_code"] for item in today_toplist} - - status_results = [] - for position in positions: - ts_code = position.get("ts_code") - if ts_code in toplist_codes: - # 找到对应的龙虎榜数据 - toplist_data = next( - (item for item in today_toplist if item["ts_code"] == ts_code), - None, - ) - if toplist_data: - status_results.append( - { - "ts_code": ts_code, - "stock_name": position.get("stock_name", ""), - "on_toplist": True, - "pct_chg": toplist_data.get("pct_chg", 0), - "net_amount": toplist_data.get("net_amount", 0), - "reason": toplist_data.get("reason", ""), - "position_weight": position.get("weight", 0), - } - ) - else: - status_results.append( - { - "ts_code": ts_code, - "stock_name": position.get("stock_name", ""), - "on_toplist": False, - "position_weight": position.get("weight", 0), - } - ) - - on_toplist_count = len([r for r in status_results if r["on_toplist"]]) - - return { - "success": True, - "data": { - "positions_status": status_results, - "total_positions": len(status_results), - "on_toplist_count": on_toplist_count, - "toplist_ratio": on_toplist_count / len(status_results) - if status_results - else 0, - }, - "message": f"持仓中有{on_toplist_count}只股票今日上榜龙虎榜", - } - - except Exception as e: - logger.error(f"Failed to check toplist status: {e}") - return {"success": False, "error": str(e)} - - async def analyze_position_capital_flow(self, days: int = 5) -> dict[str, Any]: - """分析持仓股票的资金流向""" - try: - if not self.portfolio_service or not self.toplist_analysis_service: - return {"error": "Required services not available"} - - # Use current user_id from context - user_id = self._current_user_id - - positions = await self.portfolio_service.get_positions(user_id) - if not positions: - return {"message": "当前无持仓股票"} - - flow_analysis = { - "position_flows": [], - "summary": { - "total_positions": len(positions), - "analyzed_positions": 0, - "net_inflow_positions": 0, - "net_outflow_positions": 0, - "total_net_flow": 0, - }, - } - - for position in positions: - ts_code = position.get("ts_code") - if not ts_code: - continue - - try: - # 获取席位集中度分析 - concentration = await self.toplist_analysis_service.calculate_seat_concentration( - ts_code, days - ) - - # 获取龙虎榜历史 - history = await self.toplist_service.get_stock_top_list_history( - ts_code, days - ) - - if history: - net_flow = sum(item.get("net_amount", 0) for item in history) - avg_pct_chg = np.mean( - [item.get("pct_chg", 0) for item in history] - ) - - position_flow = { - "ts_code": ts_code, - "stock_name": position.get("stock_name", ""), - "position_weight": position.get("weight", 0), - "net_flow": net_flow, - "avg_pct_chg": avg_pct_chg, - "concentration_index": concentration.get( - "concentration_index", 0 - ), - "institution_dominance": concentration.get( - "institution_dominance", 0 - ), - "appearance_count": len(history), - "flow_direction": "流入" if net_flow > 0 else "流出", - "risk_assessment": self._assess_flow_risk( - net_flow, concentration, avg_pct_chg - ), - } - - flow_analysis["position_flows"].append(position_flow) - flow_analysis["summary"]["analyzed_positions"] += 1 - flow_analysis["summary"]["total_net_flow"] += net_flow - - if net_flow > 0: - flow_analysis["summary"]["net_inflow_positions"] += 1 - else: - flow_analysis["summary"]["net_outflow_positions"] += 1 - - except Exception as e: - logger.warning(f"Failed to analyze flow for {ts_code}: {e}") - continue - - # 按净流入排序 - flow_analysis["position_flows"].sort( - key=lambda x: x["net_flow"], reverse=True - ) - - return { - "success": True, - "data": flow_analysis, - "message": f"成功分析{flow_analysis['summary']['analyzed_positions']}只持仓股票的资金流向", - } - - except Exception as e: - logger.error(f"Failed to analyze capital flow: {e}") - return {"success": False, "error": str(e)} - - def _assess_toplist_risk(self, history: list[dict], concentration: dict) -> str: - """评估龙虎榜风险等级""" - risk_score = 0 - - # 基于出现频率 - if len(history) >= 3: - risk_score += 2 - elif len(history) >= 2: - risk_score += 1 - - # 基于席位集中度 - hhi = concentration.get("concentration_index", 0) - if hhi > 0.7: - risk_score += 3 - elif hhi > 0.5: - risk_score += 2 - elif hhi > 0.3: - risk_score += 1 - - # 基于资金流向 - net_flow = sum(item.get("net_amount", 0) for item in history) - if net_flow < -200000: # 大额流出 - risk_score += 2 - elif net_flow < -50000: - risk_score += 1 - - if risk_score >= 5: - return "high" - elif risk_score >= 3: - return "medium" - else: - return "low" - - def _assess_flow_risk( - self, net_flow: float, concentration: dict, avg_pct_chg: float - ) -> str: - """评估资金流向风险""" - risk_factors = [] - - if net_flow < -100000: - risk_factors.append("大额资金流出") - - if concentration.get("concentration_index", 0) > 0.6: - risk_factors.append("席位高度集中") - - if abs(avg_pct_chg) > 8: - risk_factors.append("价格波动剧烈") - - if len(risk_factors) >= 2: - return f"高风险: {', '.join(risk_factors)}" - elif len(risk_factors) == 1: - return f"中等风险: {risk_factors[0]}" - else: - return "低风险" - - def _generate_toplist_suggestions(self, positions: list[dict]) -> list[str]: - """生成基于龙虎榜的投资建议""" - suggestions = [] - - if not positions: - return ["当前持仓股票无龙虎榜记录,建议关注市场热点"] - - # 高风险持仓建议 - high_risk_positions = [pos for pos in positions if pos["risk_level"] == "high"] - if high_risk_positions: - suggestions.append( - f"建议关注{len(high_risk_positions)}只高风险股票,考虑适当减仓" - ) - - # 资金流出建议 - outflow_positions = [ - pos for pos in positions if pos["recent_net_flow"] < -50000 - ] - if outflow_positions: - suggestions.append( - f"{len(outflow_positions)}只股票存在明显资金流出,建议密切关注" - ) - - # 席位集中度建议 - high_concentration = [ - pos for pos in positions if pos["concentration_index"] > 0.6 - ] - if high_concentration: - suggestions.append( - f"{len(high_concentration)}只股票席位集中度较高,注意流动性风险" - ) - - # 机构主导建议 - institution_dominated = [ - pos for pos in positions if pos["institution_dominance"] > 0.7 - ] - if institution_dominated: - suggestions.append( - f"{len(institution_dominated)}只股票机构主导明显,可关注后续动向" - ) - - if not suggestions: - suggestions.append("持仓股票龙虎榜表现相对稳定,建议继续观察") - - return suggestions - - async def execute(self, task: str, context: dict[str, Any] = None) -> AgentResult: - """Execute with user context injection.""" - context = context or {} - # Set current user_id from context - self._current_user_id = context.get("user_id", "default_user") - return await super().execute(task, context) - - async def execute_stream( - self, task: str, context: dict[str, Any] = None - ) -> AsyncGenerator[dict[str, Any], None]: - """Execute stream with user context injection.""" - context = context or {} - # Set current user_id from context - self._current_user_id = context.get("user_id", "default_user") - async for event in super().execute_stream(task, context): - yield event - - -# Global agent instance -_enhanced_portfolio_agent = None - - -def get_enhanced_portfolio_agent() -> EnhancedPortfolioAgent: - """Get enhanced portfolio agent instance.""" - global _enhanced_portfolio_agent - if _enhanced_portfolio_agent is None: - config = AgentConfig( - name="enhanced_portfolio_agent", - description="Enhanced portfolio analysis and management agent", - model_name="deepseek-chat", - temperature=0.3, - ) - _enhanced_portfolio_agent = EnhancedPortfolioAgent(config) - return _enhanced_portfolio_agent diff --git a/src/stock_datasource/agents/market_agent.py b/src/stock_datasource/agents/market_agent.py index 248da3ef..bf452a95 100644 --- a/src/stock_datasource/agents/market_agent.py +++ b/src/stock_datasource/agents/market_agent.py @@ -1,5 +1,7 @@ """Market Agent for stock analysis using LangGraph/DeepAgents. +@deprecated Direct-import compatibility only; new orchestration uses ConfigDrivenHarnessAgent. + This agent provides AI-powered market analysis capabilities: - K-line data retrieval and interpretation - Technical indicator calculation and analysis diff --git a/src/stock_datasource/agents/memory_agent.py b/src/stock_datasource/agents/memory_agent.py deleted file mode 100644 index f03bee08..00000000 --- a/src/stock_datasource/agents/memory_agent.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Memory Agent for user context management using LangGraph/DeepAgents. - -Task 2.3: Delegates all storage to ``SessionMemoryService`` so that -preferences and watchlists are properly isolated by ``user_id``. -The old module-level ``_memory_store`` is removed. -""" - -import logging -from collections.abc import Callable - -from .base_agent import AgentConfig, LangGraphAgent - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Tool functions (LLM-callable) -# --------------------------------------------------------------------------- -# A ``_user_id`` context variable is injected by the agent before each run -# so that tool functions know *whose* data they are operating on. - -_current_user_id: str = "default" - - -def _svc(): - from stock_datasource.services.session_memory_service import ( - get_session_memory_service, - ) - - return get_session_memory_service() - - -def save_user_preference(key: str, value: str, category: str = "style") -> str: - """保存用户偏好设置。 - - Args: - key: 偏好键名,如 risk_level, favorite_industries - value: 偏好值 - category: 偏好类别 - risk(风险偏好), industry(行业偏好), - style(投资风格), notification(通知设置) - - Returns: - 保存结果消息 - """ - _svc().save_preference(_current_user_id, key, value, category=category) - return f"已保存偏好设置: {key} = {value} (类别: {category})" - - -def get_user_preference(key: str) -> str: - """获取用户偏好设置。 - - Args: - key: 偏好键名 - - Returns: - 偏好值或未找到提示 - """ - val = _svc().get_preference(_current_user_id, key) - if val is not None: - return f"{key} = {val}" - return f"未找到偏好设置: {key}" - - -def manage_watchlist(action: str, code: str = "", group: str = "default") -> str: - """管理用户自选股。 - - Args: - action: 操作类型 - add(添加), remove(删除), list(列出), clear(清空) - code: 股票代码(add/remove时需要) - group: 自选股分组名称 - - Returns: - 操作结果消息 - """ - svc = _svc() - uid = _current_user_id - if action == "add": - if code and svc.add_to_watchlist(uid, code, group): - return f"已将 {code} 添加到自选股分组 [{group}]" - return f"{code} 已在自选股中" - if action == "remove": - if svc.remove_from_watchlist(uid, code, group): - return f"已将 {code} 从自选股分组 [{group}] 移除" - return f"{code} 不在自选股中" - if action == "list": - wl = svc.get_watchlist(uid, group) - if wl: - return f"自选股分组 [{group}]: {', '.join(wl)}" - return f"自选股分组 [{group}] 为空" - if action == "clear": - # Remove all items one by one - for c in list(svc.get_watchlist(uid, group)): - svc.remove_from_watchlist(uid, c, group) - return f"已清空自选股分组 [{group}]" - return f"未知操作: {action}" - - -def get_memory_summary() -> str: - """获取用户记忆摘要。 - - Returns: - 用户偏好和自选股的摘要信息 - """ - svc = _svc() - uid = _current_user_id - lines = ["## 用户记忆摘要\n"] - - prefs = svc.list_preferences(uid) - if prefs: - lines.append("### 偏好设置") - for key, val in prefs.items(): - lines.append(f"- {key}: {val}") - lines.append("") - else: - lines.append("### 偏好设置\n暂无设置\n") - - wl = svc.get_watchlist(uid) - if wl: - lines.append("### 自选股") - lines.append(f"- [default]: {', '.join(wl)}") - lines.append("") - else: - lines.append("### 自选股\n暂无自选股\n") - - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Agent class -# --------------------------------------------------------------------------- - - -class MemoryAgent(LangGraphAgent): - """Memory Agent – manages user preferences and watchlists. - - Before each execution, ``_current_user_id`` is set so that tool - functions operate on the correct user's data. - """ - - def __init__(self): - config = AgentConfig( - name="MemoryAgent", - description="负责用户记忆管理,包括偏好设置、自选股管理等", - ) - super().__init__(config) - - def get_tools(self) -> list[Callable]: - return [ - save_user_preference, - get_user_preference, - manage_watchlist, - get_memory_summary, - ] - - def get_system_prompt(self) -> str: - return """你是用户记忆管理助手,帮助用户管理投资偏好和自选股。 - -## 可用工具 -- save_user_preference: 保存用户偏好(风险偏好、行业偏好等) -- get_user_preference: 获取用户偏好 -- manage_watchlist: 管理自选股(添加/删除/列出/清空) -- get_memory_summary: 获取用户记忆摘要 - -## 工作原则 -- 准确理解用户意图 -- 确认操作后执行 -- 给出操作结果反馈 -""" - - async def execute(self, task, context=None): - global _current_user_id - _current_user_id = (context or {}).get("user_id", "default") - return await super().execute(task, context) - - async def execute_stream(self, task, context=None): - global _current_user_id - _current_user_id = (context or {}).get("user_id", "default") - async for event in super().execute_stream(task, context): - yield event diff --git a/src/stock_datasource/agents/orchestrator.py b/src/stock_datasource/agents/orchestrator.py index 9943e1f9..8892305e 100644 --- a/src/stock_datasource/agents/orchestrator.py +++ b/src/stock_datasource/agents/orchestrator.py @@ -1,79 +1,26 @@ -"""Orchestrator Agent for routing and coordinating multiple LangGraph agents. +"""Lightweight orchestrator for config-driven harness agents.""" -Uses LangGraph to create a multi-agent workflow that routes user requests -to the appropriate specialized agent. +from __future__ import annotations -Features: -- Plan-to-do thinking: Shows the execution plan before routing -- ReAct mode: Progressive reasoning when using MCP fallback -- Streaming events: Real-time thinking/tool/content updates -- Concurrent agent execution: Parallel execution of independent agents -- Agent handoff: Transfer control between agents with shared context -- Shared cache: Redis-based data sharing between agents -""" - -import asyncio -import importlib -import inspect import json import logging -import pkgutil import re import time from collections.abc import AsyncGenerator from typing import Any -from stock_datasource.services.agent_cache import AgentSharedCache, get_agent_cache -from stock_datasource.services.agent_runtime import ( - get_agent_runtime, - is_runtime_enabled, -) -from stock_datasource.services.execution_planner import ( - AGENT_HANDOFF_MAP, - can_run_concurrently, -) -from stock_datasource.services.mcp_client import MCPClient -from stock_datasource.services.chat_arena_adapter import ( - get_chat_arena_adapter, -) - - -from .base_agent import ( - AgentResult, - LangGraphAgent, - compress_tool_result, - get_langchain_model, - get_langfuse_handler, -) +from .base_agent import AgentResult, get_langchain_model, get_langfuse_handler +from .config_driven_harness_agent import get_config_driven_agent logger = logging.getLogger(__name__) -AGENT_MODULE_SUFFIX = "_agent" -AGENT_EXCLUDE_CLASS_NAMES = {"OrchestratorAgent", "StockDeepAgent"} - - class OrchestratorAgent: - """Orchestrator for routing requests to specialized LangGraph agents. - - This orchestrator: - 1. Uses LLM to analyze intent and create execution plan - 2. Extracts stock codes from the query - 3. Routes to the appropriate specialized agent - 4. Falls back to MCP tools with ReAct reasoning when no agent matches - """ - - def __init__(self): - self._agents: dict[str, LangGraphAgent] = {} - self._agent_classes: dict[str, type] = {} - self._agent_descriptions: dict[str, str] = {} - self._discovered = False - self._cache: AgentSharedCache = get_agent_cache() + """Classify chat intent and dispatch to ConfigDrivenHarnessAgent.""" def _make_debug_event( self, debug_type: str, data: dict[str, Any] ) -> dict[str, Any]: - """Create a standardized debug event for orchestrator.""" return { "type": "debug", "debug_type": debug_type, @@ -82,58 +29,6 @@ def _make_debug_event( "data": data, } - def _discover_agents(self) -> None: - if self._discovered: - return - try: - import stock_datasource.agents as agents_pkg - - for module_info in pkgutil.iter_modules( - agents_pkg.__path__, agents_pkg.__name__ + "." - ): - module_name = module_info.name - if not module_name.endswith(AGENT_MODULE_SUFFIX): - continue - try: - module = importlib.import_module(module_name) - except Exception as e: - logger.debug(f"Failed to import {module_name}: {e}") - continue - for _, obj in inspect.getmembers(module, inspect.isclass): - if not issubclass(obj, LangGraphAgent) or obj is LangGraphAgent: - continue - if obj.__name__ in AGENT_EXCLUDE_CLASS_NAMES: - continue - if not obj.__module__.startswith("stock_datasource.agents"): - continue - try: - instance = obj() - except Exception as e: - logger.debug(f"Skip agent {obj.__name__}: {e}") - continue - name = instance.config.name - self._agent_classes[name] = obj - self._agent_descriptions[name] = instance.config.description - finally: - self._discovered = True - - def _list_available_agents(self) -> list[dict[str, str]]: - self._discover_agents() - return [ - {"name": name, "description": desc} - for name, desc in self._agent_descriptions.items() - ] - - def _get_agent(self, agent_name: str) -> LangGraphAgent | None: - """Get or create an agent by name.""" - self._discover_agents() - agent_cls = self._agent_classes.get(agent_name) - if not agent_cls: - return None - if agent_name not in self._agents: - self._agents[agent_name] = agent_cls() - return self._agents[agent_name] - def _parse_json_from_text(self, text: str) -> dict[str, Any]: if not text: return {} @@ -148,19 +43,41 @@ def _parse_json_from_text(self, text: str) -> dict[str, Any]: return {} return {} + def _list_available_agents(self) -> list[dict[str, str]]: + try: + from stock_datasource.services.agent_config_service import ( + get_agent_config_service, + ) + + service = get_agent_config_service() + configs = service.list_agents(user_id="system", include_public=True) + except Exception as e: + logger.warning("Failed to load config-driven agent catalog: %s", e) + return [] + + seen: set[str] = set() + agents: list[dict[str, str]] = [] + for config in configs: + if not config.name or config.name in seen: + continue + seen.add(config.name) + agents.append( + { + "name": config.name, + "description": config.description or "Config-driven harness agent", + } + ) + return agents + async def _classify_with_llm( self, query: str, context: dict[str, Any] | None = None ) -> tuple[str, str | None, str]: - """Classify user intent and select appropriate agent. - - Returns: - Tuple of (intent, agent_name, rationale) - """ - self._discover_agents() + """Classify user intent and select a config-driven agent name.""" agents = self._list_available_agents() if not agents: - logger.warning("[Orchestrator] No agents available for classification") - return "unknown", None, "没有可用的Agent" + logger.warning("[Orchestrator] No config-driven agents available") + return "unknown", None, "没有可用的Agent配置" + system_prompt = ( "你是一个智能协调Agent。你的任务是:\n" "1. 理解用户的意图\n" @@ -168,13 +85,15 @@ async def _classify_with_llm( "3. 给出简短的推理说明\n\n" '仅输出JSON,格式: {"intent": string, "agent_name": string, "rationale": string}。\n' "如果没有匹配的Agent,请将agent_name设为空字符串。\n\n" - "intent的可选值: market_analysis, stock_screening, financial_report, hk_financial_report, hk_market_analysis, portfolio_management, " - "strategy_backtest, index_analysis, etf_analysis, market_overview, news_analysis, knowledge_search, general_chat\n\n" + "intent的可选值: market_analysis, stock_screening, financial_report, " + "hk_financial_report, hk_market_analysis, portfolio_management, " + "strategy_backtest, index_analysis, etf_analysis, market_overview, " + "news_analysis, knowledge_search, general_chat\n\n" "注意:\n" - "- 如果用户询问港股(代码格式如00700.HK)的技术分析、K线、技术指标,intent设为market_analysis,agent_name设为MarketAgent\n" - "- 如果用户同时询问港股的技术面和财务面,intent设为market_analysis,agent_name设为MarketAgent(系统会自动组合HKReportAgent)\n" - "- 如果用户询问研报、公告、政策文件、规章制度等文档内容,intent设为knowledge_search,agent_name设为KnowledgeAgent\n" - "- 如果用户查询包含'根据研报'、'根据公告'、'文档中'等关键词,优先选择KnowledgeAgent" + "- 如果用户询问港股(代码格式如00700.HK)的技术分析、K线、技术指标," + "intent设为market_analysis,agent_name选择最匹配的行情分析Agent\n" + "- 如果用户询问研报、公告、政策文件、规章制度等文档内容," + "intent设为knowledge_search,agent_name选择最匹配的知识检索Agent" ) user_prompt = ( f"User query: {query}\n\n" @@ -185,995 +104,142 @@ async def _classify_with_llm( session_id = context.get("session_id", "") try: - logger.debug(f"[Orchestrator] Classifying query: {query[:100]}...") model = get_langchain_model() callbacks = [] handler = get_langfuse_handler() if handler: callbacks.append(handler) + + metadata = { + "langfuse_user_id": user_id, + "langfuse_session_id": session_id, + "langfuse_tags": ["OrchestratorAgent"], + } + config = {"callbacks": callbacks, "metadata": metadata} if callbacks else {"metadata": metadata} response = await model.ainvoke( [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], - config={ - "callbacks": callbacks, - "metadata": { - "langfuse_user_id": user_id, - "langfuse_session_id": session_id, - "langfuse_tags": ["OrchestratorAgent"], - }, - } - if callbacks - else { - "metadata": { - "langfuse_user_id": user_id, - "langfuse_session_id": session_id, - "langfuse_tags": ["OrchestratorAgent"], - } - }, - ) - content = ( - response.content if hasattr(response, "content") else str(response) + config=config, ) - logger.debug(f"[Orchestrator] LLM response: {content[:200]}") + content = response.content if hasattr(response, "content") else str(response) parsed = self._parse_json_from_text(content) intent = parsed.get("intent") or "unknown" agent_name = parsed.get("agent_name") or "" rationale = parsed.get("rationale") or "" - if agent_name not in self._agent_classes: + + available_names = {agent["name"] for agent in agents} + if agent_name not in available_names: logger.debug( - f"[Orchestrator] Agent '{agent_name}' not found, will fallback" + "[Orchestrator] Agent '%s' not found in config catalog", + agent_name, ) agent_name = None logger.info( - f"[Orchestrator] Classified: intent={intent}, agent={agent_name}, rationale={rationale[:50]}..." + "[Orchestrator] Classified: intent=%s, agent=%s, rationale=%s", + intent, + agent_name, + rationale[:50], ) return intent, agent_name, rationale except Exception as e: - import traceback - - logger.warning( - f"[Orchestrator] LLM classify failed: {e}\n{traceback.format_exc()}" - ) - fallback_agent = "ChatAgent" if "ChatAgent" in self._agent_classes else None - return ( - ("general_chat" if fallback_agent else "unknown"), - fallback_agent, - "使用默认处理", - ) + logger.warning("[Orchestrator] LLM classify failed: %s", e) + return "unknown", None, "意图识别失败" def _extract_stock_codes(self, query: str) -> list[str]: """Extract stock codes from query (supports A-share and HK).""" codes = [] - # Pattern: HK code with suffix (00700.HK) - hk_pattern1 = r"(\d{5}\.HK)" - matches = re.findall(hk_pattern1, query, re.IGNORECASE) - codes.extend([m.upper() for m in matches]) + hk_matches = re.findall(r"(\d{5}\.HK)", query, re.IGNORECASE) + codes.extend([match.upper() for match in hk_matches]) - # Pattern: A-share code with suffix (600519.SH or 000001.SZ) - pattern1 = r"(\d{6}\.[A-Za-z]{2})" - matches = re.findall(pattern1, query) - codes.extend([m.upper() for m in matches]) + a_matches = re.findall(r"(\d{6}\.[A-Za-z]{2})", query) + codes.extend([match.upper() for match in a_matches]) - # Pattern: 6-digit A-share code - pattern2 = r"(? list[str]: - """Build execution plan with optional concurrent agents. - - Args: - primary_agent: The main agent to handle the request - stock_codes: Extracted stock codes from query - query: Original user query (for detecting combined analysis needs) - - Returns: - List of agent names to execute (in order, concurrent ones grouped) - """ - self._discover_agents() - if not primary_agent: - return [] - plan = [primary_agent] - - # Separate HK and A-share codes - hk_codes = [c for c in stock_codes if c.upper().endswith(".HK")] - a_codes = [c for c in stock_codes if not c.upper().endswith(".HK")] - - # Detect if user wants combined technical + fundamental analysis - query_lower = query.lower() - tech_keywords = [ - "技术", - "技术面", - "技术指标", - "k线", - "kline", - "走势", - "macd", - "rsi", - "kdj", - "均线", - "趋势", - ] - fund_keywords = [ - "财务", - "财报", - "基本面", - "盈利", - "收入", - "利润", - "资产", - "现金流", - "全面分析", - "综合分析", - ] - wants_tech = any(kw in query_lower for kw in tech_keywords) - wants_fund = any(kw in query_lower for kw in fund_keywords) - - # Check if we can add concurrent agents for richer analysis - if stock_codes and primary_agent == "MarketAgent": - if hk_codes and "HKReportAgent" in self._agent_classes: - # HK stocks: combine MarketAgent + HKReportAgent - if "HKReportAgent" not in plan: - plan.append("HKReportAgent") - if a_codes and "ReportAgent" in self._agent_classes: - # A-share stocks: combine MarketAgent + ReportAgent - if "ReportAgent" not in plan: - plan.append("ReportAgent") - - # If primary is HKReportAgent but user also wants technical analysis - if stock_codes and primary_agent == "HKReportAgent" and wants_tech: - if "MarketAgent" in self._agent_classes and "MarketAgent" not in plan: - plan.insert(0, "MarketAgent") # MarketAgent first for technical - - # If primary is ReportAgent but user also wants technical analysis - if stock_codes and primary_agent == "ReportAgent" and wants_tech: - if "MarketAgent" in self._agent_classes and "MarketAgent" not in plan: - plan.insert(0, "MarketAgent") - - return plan - - def _can_run_concurrently(self, agents: list[str]) -> bool: - """Check if agents can run concurrently (delegates to execution_planner).""" - return can_run_concurrently(agents) - - def _get_handoff_targets(self, agent_name: str) -> list[str]: - """Get possible handoff targets for an agent. - - Args: - agent_name: Source agent name - - Returns: - List of possible target agent names - """ - return AGENT_HANDOFF_MAP.get(agent_name, []) - - def _build_agent_query( - self, agent_name: str, query: str, stock_codes: list[str] - ) -> str: - """Build query for a specific agent. - - Args: - agent_name: Target agent name - query: Original user query - stock_codes: Extracted stock codes - - Returns: - Query string tailored for the agent - """ - if agent_name == "ReportAgent" and stock_codes: - return f"请对{stock_codes[0]}进行财务分析" - if agent_name == "HKReportAgent" and stock_codes: - hk_codes = [c for c in stock_codes if c.endswith(".HK")] - if hk_codes: - return f"请对{hk_codes[0]}进行港股财务分析" - return query - - async def _execute_local_stock_fallback_stream( + def _error_event( self, - query: str, + message: str, intent: str, stock_codes: list[str], - ) -> AsyncGenerator[dict[str, Any], None]: - """Handle common stock-analysis queries without any LLM dependency.""" - if not stock_codes: - return - - query_lower = query.lower() - tech_keywords = [ - "技术", - "技术面", - "技术指标", - "macd", - "rsi", - "kdj", - "均线", - "趋势", - ] - kline_keywords = ["k线", "kline", "日线", "走势", "蜡烛图"] - wants_tech = any(keyword in query_lower for keyword in tech_keywords) - wants_kline = any(keyword in query_lower for keyword in kline_keywords) - - if not wants_tech and not wants_kline: - return - - from stock_datasource.agents.tools import ( - calculate_technical_indicators, - get_stock_kline, - ) - - ts_code = stock_codes[0] - sections: list[str] = [] - - yield { - "type": "thinking", - "agent": "LocalFallback", - "status": f"正在直接分析 {ts_code}", - "intent": intent, - "stock_codes": stock_codes, - } - - if wants_tech: - sections.append(calculate_technical_indicators(ts_code)) - if wants_kline: - sections.append(get_stock_kline(ts_code, days=30)) - - content = "\n\n".join(section for section in sections if section) - if not content: - return - - yield { - "type": "content", - "content": content, - } - yield { - "type": "done", + available_agents: list[dict[str, str]], + ) -> dict[str, Any]: + return { + "type": "error", + "error": message, "metadata": { - "agent": "LocalFallback", + "agent": "OrchestratorAgent", "intent": intent, "stock_codes": stock_codes, "routed_by": "OrchestratorAgent", + "available_agents": available_agents, }, } - def _share_data_to_next_agent( - self, session_id: str, from_agent: str, to_agent: str, data: dict[str, Any] - ) -> bool: - """Share data from one agent to another via cache. - - Args: - session_id: Session ID - from_agent: Source agent name - to_agent: Target agent name - data: Data to share - - Returns: - True if successful - """ - success = self._cache.share_data_between_agents( - session_id, from_agent, to_agent, data - ) - # Store the data_sharing event for later emission in streaming - if not hasattr(self, "_pending_debug_events"): - self._pending_debug_events: list[dict[str, Any]] = [] - self._pending_debug_events.append( - self._make_debug_event( - "data_sharing", - { - "from_agent": from_agent, - "to_agent": to_agent, - "data_summary": { - k: str(v)[:100] for k, v in list(data.items())[:5] - }, - "success": success, - }, - ) - ) - return success - - def _receive_shared_data( - self, session_id: str, from_agent: str, to_agent: str - ) -> dict[str, Any] | None: - """Receive data shared from another agent. - - Args: - session_id: Session ID - from_agent: Source agent name - to_agent: Target agent name - - Returns: - Shared data or None - """ - return self._cache.receive_shared_data(session_id, from_agent, to_agent) - - def _cache_stock_data(self, ts_code: str, data_type: str, data: Any) -> bool: - """Cache stock data for sharing between agents. - - Args: - ts_code: Stock code - data_type: Type of data (info, daily, etc.) - data: Data to cache - - Returns: - True if successful - """ - if data_type == "info": - return self._cache.cache_stock_info(ts_code, data) - elif data_type == "daily": - # For daily data, we need start/end dates - return False - elif data_type == "financial": - # For financial, we need period - return False - return False - - def _get_cached_stock_data(self, ts_code: str, data_type: str) -> Any | None: - """Get cached stock data. - - Args: - ts_code: Stock code - data_type: Type of data - - Returns: - Cached data or None - """ - if data_type == "info": - return self._cache.get_stock_info(ts_code) - elif data_type == "realtime": - return self._cache.get_stock_realtime(ts_code) - return None - - def _parse_tool_call_from_query( - self, query: str - ) -> tuple[str | None, dict[str, Any]]: - if not query: - return None, {} - stripped = query.strip() - json_payload = None - if stripped.startswith("{") and stripped.endswith("}"): - json_payload = stripped - else: - match = re.search(r"\{.*\}", query, re.S) - if match: - json_payload = match.group(0) - if not json_payload: - return None, {} - try: - data = json.loads(json_payload) - except Exception: - return None, {} - tool_name = data.get("tool") or data.get("name") or data.get("tool_name") - args = data.get("args") or data.get("arguments") or {} - if not isinstance(args, dict): - args = {} - return tool_name, args - - def _normalize_tool(self, tool: Any) -> tuple[str, str, dict[str, Any]]: - if isinstance(tool, dict): - name = tool.get("name", "") - desc = tool.get("description", "") - schema = tool.get("inputSchema") or tool.get("input_schema") or {} - else: - name = getattr(tool, "name", "") - desc = getattr(tool, "description", "") - schema = ( - getattr(tool, "input_schema", None) - or getattr(tool, "inputSchema", None) - or {} - ) - return name, desc or "", schema or {} - - def _score_tool(self, query: str, name: str, desc: str) -> int: - query_lower = query.lower() - tokens = set( - re.findall(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]+", f"{name} {desc}".lower()) - ) - return sum(1 for t in tokens if t and t in query_lower) - - def _select_mcp_tool( - self, query: str, tools: list[Any] - ) -> tuple[str | None, dict[str, Any]]: - best_score = 0 - best_tool = None - best_schema: dict[str, Any] = {} - for tool in tools: - name, desc, schema = self._normalize_tool(tool) - if not name: - continue - score = self._score_tool(query, name, desc) - if score > best_score: - best_score = score - best_tool = name - best_schema = schema - if best_score == 0: - return None, {} - return best_tool, best_schema - - async def _execute_with_mcp( - self, - query: str, - context: dict[str, Any], - intent: str, - stock_codes: list[str], - ) -> AgentResult: - client = MCPClient() - await client.connect() - tool_calls = [] - try: - tools = await client.list_tools() - tool_name, tool_args = self._parse_tool_call_from_query(query) - tool_schema = {} - if tool_name: - for tool in tools: - name, _, schema = self._normalize_tool(tool) - if name == tool_name: - tool_schema = schema - break - else: - tool_name, tool_schema = self._select_mcp_tool(query, tools) - if not tool_name: - return AgentResult( - response="未找到可用的MCP工具,请提供明确的工具名称或参数。", - success=False, - metadata={ - "agent": "MCPFallback", - "routed_by": "OrchestratorAgent", - "intent": intent, - "stock_codes": stock_codes, - "available_agents": self._list_available_agents(), - }, - ) - required = ( - tool_schema.get("required", []) if isinstance(tool_schema, dict) else [] - ) - if required and not all(k in tool_args for k in required): - return AgentResult( - response=f"缺少必要参数: {required}", - success=False, - metadata={ - "agent": "MCPFallback", - "routed_by": "OrchestratorAgent", - "intent": intent, - "stock_codes": stock_codes, - "tool": tool_name, - "available_agents": self._list_available_agents(), - }, - ) - result = await client.call_tool(tool_name, **tool_args) - tool_calls.append({"name": tool_name, "args": tool_args}) - return AgentResult( - response=str(compress_tool_result(result)), - success=True, - metadata={ - "agent": "MCPFallback", - "routed_by": "OrchestratorAgent", - "intent": intent, - "stock_codes": stock_codes, - }, - tool_calls=tool_calls, - ) - finally: - await client.disconnect() - - async def _execute_with_mcp_stream( - self, - query: str, - context: dict[str, Any], - intent: str, - stock_codes: list[str], - ) -> AsyncGenerator[dict[str, Any], None]: - client = MCPClient() - tool_calls = [] - try: - await client.connect() - yield { - "type": "thinking", - "agent": "MCPFallback", - "status": "尝试使用MCP工具", - "intent": intent, - "stock_codes": stock_codes, - } - tools = await client.list_tools() - tool_name, tool_args = self._parse_tool_call_from_query(query) - tool_schema = {} - if tool_name: - for tool in tools: - name, _, schema = self._normalize_tool(tool) - if name == tool_name: - tool_schema = schema - break - else: - tool_name, tool_schema = self._select_mcp_tool(query, tools) - if not tool_name: - yield { - "type": "error", - "error": "未找到可用的MCP工具,请提供明确的工具名称或参数。", - } - return - required = ( - tool_schema.get("required", []) if isinstance(tool_schema, dict) else [] - ) - if required and not all(k in tool_args for k in required): - yield { - "type": "error", - "error": f"缺少必要参数: {required}", - } - return - yield { - "type": "tool", - "tool": tool_name, - "args": tool_args, - } - result = await client.call_tool(tool_name, **tool_args) - tool_calls.append({"name": tool_name, "args": tool_args}) - yield { - "type": "content", - "content": str(compress_tool_result(result)), - } - yield { - "type": "done", - "metadata": { - "agent": "MCPFallback", - "intent": intent, - "stock_codes": stock_codes, - "tool_calls": tool_calls, - "routed_by": "OrchestratorAgent", - }, - } - except Exception as e: - logger.error(f"MCP fallback failed: {e}") - yield { - "type": "error", - "error": str(e), - } - yield { - "type": "done", - "metadata": { - "agent": "MCPFallback", - "intent": intent, - "stock_codes": stock_codes, - "error": str(e), - }, - } - finally: - await client.disconnect() - - async def _execute_with_mcp_react_stream( - self, - query: str, - context: dict[str, Any], - intent: str, - stock_codes: list[str], - ) -> AsyncGenerator[dict[str, Any], None]: - """Execute MCP tools using ReAct (Reasoning + Acting) pattern. - - This method progressively reasons about the query and selects appropriate - MCP tools, showing the thinking process to the user. - """ - client = MCPClient() - tool_calls = [] - react_steps = [] - - try: - await client.connect() - - # Step 1: List available tools - yield { - "type": "thinking", - "agent": "MCPFallback", - "status": "🔍 正在分析可用工具...", - "intent": intent, - "stock_codes": stock_codes, - } - - tools = await client.list_tools() - tool_summaries = [] - for tool in tools[:20]: # Limit to first 20 tools for context - name, desc, _ = self._normalize_tool(tool) - if name: - tool_summaries.append(f"- {name}: {desc[:100]}") - - # Step 2: Use LLM to reason about which tool to use (ReAct Thought) - yield { - "type": "thinking", - "agent": "MCPFallback", - "status": "💭 正在推理最佳处理方案...", - "intent": intent, - "stock_codes": stock_codes, - } - - react_prompt = f"""你是一个使用ReAct模式的智能助手。你需要逐步思考并选择合适的工具。 - -用户问题: {query} - -可用工具: -{chr(10).join(tool_summaries[:15])} - -请使用以下格式回答: -Thought: [你的思考过程] -Action: [选择的工具名称] -Action Input: [工具参数,JSON格式] - -如果无法找到合适的工具,请回答: -Thought: [说明为什么没有合适的工具] -Action: none -Action Input: {{}} -""" - - user_id = context.get("user_id", "") - session_id = context.get("session_id", "") - - try: - model = get_langchain_model() - callbacks = [] - handler = get_langfuse_handler() - if handler: - callbacks.append(handler) + async def execute(self, query: str, context: dict[str, Any] = None) -> AgentResult: + """Execute query and collect streaming output into an AgentResult.""" + content_parts: list[str] = [] + metadata: dict[str, Any] = {} + tool_calls: list[dict[str, Any]] = [] + success = True - response = await model.ainvoke( - [{"role": "user", "content": react_prompt}], - config={ - "callbacks": callbacks, - "metadata": { - "langfuse_user_id": user_id, - "langfuse_session_id": session_id, - "langfuse_tags": ["MCPFallback"], - }, + async for event in self.execute_stream(query, context): + event_type = event.get("type") + if event_type == "content": + content_parts.append(event.get("content", "")) + elif event_type == "tool": + tool_calls.append( + { + "name": event.get("tool", ""), + "args": event.get("args", {}), } - if callbacks - else { - "metadata": { - "langfuse_user_id": user_id, - "langfuse_session_id": session_id, - "langfuse_tags": ["MCPFallback"], - } - }, - ) - - react_response = ( - response.content if hasattr(response, "content") else str(response) ) - - # Parse ReAct response - thought_match = re.search( - r"Thought:\s*(.+?)(?=Action:|$)", react_response, re.S - ) - action_match = re.search(r"Action:\s*(\S+)", react_response) - input_match = re.search( - r"Action Input:\s*(\{.*?\})", react_response, re.S - ) - - thought = thought_match.group(1).strip() if thought_match else "" - action = action_match.group(1).strip() if action_match else "" - action_input = {} - - if input_match: - try: - action_input = json.loads(input_match.group(1)) - except: - pass - - # Step 3: Show the thought process - if thought: - react_steps.append({"thought": thought, "action": action}) - yield { - "type": "thinking", - "agent": "MCPFallback", - "status": f"💡 {thought[:100]}..." - if len(thought) > 100 - else f"💡 {thought}", - "intent": intent, - "stock_codes": stock_codes, - } - - # Step 4: Execute the action - if action and action.lower() != "none": - # Find the tool - tool_name = None - tool_schema = {} - for tool in tools: - name, _, schema = self._normalize_tool(tool) - if ( - name.lower() == action.lower() - or action.lower() in name.lower() - ): - tool_name = name - tool_schema = schema - break - - if tool_name: - yield { - "type": "tool", - "tool": tool_name, - "args": action_input, - "agent": "MCPFallback", - "status": f"⚡ 执行: {tool_name}", - } - - # Execute the tool - result = await client.call_tool(tool_name, **action_input) - tool_calls.append({"name": tool_name, "args": action_input}) - - # Use LLM to summarize the result - compressed = compress_tool_result(result) - - summary_prompt = f"""用户问题: {query} - -工具 {tool_name} 返回结果: -{str(compressed)[:2000]} - -请用中文简洁地总结上述结果,帮助用户理解。如果结果是数据,请提取关键信息。""" - - summary_response = await model.ainvoke( - [{"role": "user", "content": summary_prompt}], - config={ - "callbacks": callbacks, - "metadata": { - "langfuse_user_id": user_id, - "langfuse_session_id": session_id, - "langfuse_tags": ["MCPFallback"], - }, - } - if callbacks - else { - "metadata": { - "langfuse_user_id": user_id, - "langfuse_session_id": session_id, - "langfuse_tags": ["MCPFallback"], - } - }, - ) - - summary = ( - summary_response.content - if hasattr(summary_response, "content") - else str(compressed) - ) - - yield { - "type": "content", - "content": summary, - } - else: - yield { - "type": "content", - "content": f"抱歉,找不到名为 '{action}' 的工具。请尝试更具体的描述。", - } - else: - # No suitable tool found - yield { - "type": "content", - "content": "抱歉,当前没有找到合适的工具来处理您的请求。请尝试使用以下方式提问:\n" - "- 查询股票行情时请提供股票代码(如:600519)\n" - "- 需要K线数据时请说明时间范围\n" - "- 需要财务数据时请指定具体的财务指标", - } - - except Exception as e: - logger.warning(f"ReAct reasoning failed: {e}") - # Fallback to simple tool selection - async for event in self._execute_with_mcp_stream( - query, context, intent, stock_codes - ): - yield event - return - - yield { - "type": "done", - "metadata": { - "agent": "MCPFallback", - "intent": intent, - "stock_codes": stock_codes, - "tool_calls": tool_calls, - "react_steps": react_steps, - "routed_by": "OrchestratorAgent", - }, - } - - except Exception as e: - logger.error(f"MCP ReAct fallback failed: {e}") - yield { - "type": "error", - "error": str(e), - } - yield { - "type": "done", - "metadata": { - "agent": "MCPFallback", - "intent": intent, - "stock_codes": stock_codes, - "error": str(e), - }, - } - finally: - await client.disconnect() - - async def execute(self, query: str, context: dict[str, Any] = None) -> AgentResult: - """Execute query by routing to appropriate agent. - - When the runtime feature flag is enabled, this collects streaming - events and returns them as a single AgentResult for backward compat. - - Args: - query: User's query - context: Optional context - - Returns: - AgentResult from the specialized agent - """ - # ---- New runtime delegation (feature-flagged) ---- - if is_runtime_enabled(): - runtime = get_agent_runtime() - content_parts = [] - metadata = {} - tool_calls = [] - async for event in runtime.execute_stream_sse(query, context): - etype = event.get("type") - if etype == "content": - content_parts.append(event.get("content", "")) - elif etype == "done": - metadata = event.get("metadata", {}) - elif etype == "tool": - tool_calls.append( - { - "name": event.get("tool", ""), - "args": event.get("args", {}), - } - ) - return AgentResult( - response="".join(content_parts), - success=True, - metadata=metadata, - tool_calls=tool_calls, - ) - - # ---- Original logic ---- - context = context or {} - - # Classify intent + agent via LLM - intent, agent_name, rationale = await self._classify_with_llm(query, context) - - # Extract stock codes - stock_codes = self._extract_stock_codes(query) - - # Update context - context["intent"] = intent - if stock_codes: - context["stock_codes"] = stock_codes - - plan = self._build_multi_agent_plan(agent_name, stock_codes, query) - if not plan: - logger.info(f"No agent available for intent: {intent}, fallback to MCP") - return await self._execute_with_mcp(query, context, intent, stock_codes) - - if len(plan) == 1: - agent = self._get_agent(plan[0]) - if not agent: - logger.info(f"No agent available for intent: {intent}, fallback to MCP") - return await self._execute_with_mcp(query, context, intent, stock_codes) - logger.info(f"Routing to {plan[0]} for intent: {intent}") - result = await agent.execute(query, context) - result.metadata["routed_by"] = "OrchestratorAgent" - result.metadata["intent"] = intent - result.metadata["stock_codes"] = stock_codes - result.metadata["available_agents"] = self._list_available_agents() - return result - - logger.info(f"Routing to multi-agent plan: {plan}") - tasks = [] - names = [] - for agent_name in plan: - agent = self._get_agent(agent_name) - if not agent: - continue - agent_query = self._build_agent_query(agent_name, query, stock_codes) - tasks.append(agent.execute(agent_query, context)) - names.append(agent_name) - - results = await asyncio.gather(*tasks, return_exceptions=True) - responses = [] - sub_metadata = [] - tool_calls = [] - success = True - for agent_name, result in zip(names, results): - if isinstance(result, Exception): + elif event_type == "done": + metadata = event.get("metadata", {}) + elif event_type == "error": success = False - responses.append(f"### {agent_name}\n执行失败: {result}") - sub_metadata.append( - {"agent": agent_name, "metadata": {"error": str(result)}} - ) - continue - success = success and result.success - title = self._agent_descriptions.get(agent_name, agent_name) - responses.append(f"### {title}\n{result.response}") - sub_metadata.append({"agent": agent_name, "metadata": result.metadata}) - tool_calls.extend(result.tool_calls or []) + if not content_parts: + content_parts.append(event.get("error", "")) + metadata = event.get("metadata", metadata) return AgentResult( - response="\n\n".join(responses) if responses else "", + response="".join(content_parts), success=success, - metadata={ - "agent": "OrchestratorAgent", - "routed_by": "OrchestratorAgent", - "intent": intent, - "stock_codes": stock_codes, - "sub_agents": plan, - "sub_agent_metadata": sub_metadata, - "available_agents": self._list_available_agents(), - }, + metadata=metadata, tool_calls=tool_calls, ) async def execute_stream( self, query: str, context: dict[str, Any] = None ) -> AsyncGenerator[dict[str, Any], None]: - """Execute query with streaming response. - - Shows Plan-To-Do thinking process before execution. - - When the ``AGENT_RUNTIME_ENABLED`` feature flag is set, delegates - execution to the unified ``AgentRuntime`` which emits legacy - SSE-compatible events. Otherwise falls back to the original - orchestration logic below. - - Args: - query: User's query - context: Optional context - - Yields: - Event dicts from the specialized agent - """ - # ---- New runtime delegation (feature-flagged) ---- - if is_runtime_enabled(): - runtime = get_agent_runtime() - async for event in runtime.execute_stream_sse(query, context): - yield event - return - - # ---- Original orchestration logic ---- + """Classify intent and stream from the selected ConfigDrivenHarnessAgent.""" context = context or {} - session_id = context.get("session_id", "") - - # Try to get cached stock data if available - if session_id: - cached_context = self._cache.get_session_data( - session_id, "orchestrator_context" - ) - if cached_context: - context.update(cached_context) + available_agents = self._list_available_agents() - # Step 1: Emit initial thinking status yield { "type": "thinking", "agent": "OrchestratorAgent", @@ -1182,13 +248,12 @@ async def execute_stream( "stock_codes": [], } - # Classify intent + agent via LLM intent, agent_name, rationale = await self._classify_with_llm(query, context) - - # Extract stock codes stock_codes = self._extract_stock_codes(query) + context["intent"] = intent + if stock_codes: + context["stock_codes"] = stock_codes - # Emit debug: classification yield self._make_debug_event( "classification", { @@ -1196,383 +261,99 @@ async def execute_stream( "selected_agent": agent_name, "rationale": rationale, "stock_codes": stock_codes, - "available_agents": [a["name"] for a in self._list_available_agents()], + "available_agents": [agent["name"] for agent in available_agents], }, ) - - # Step 2: Emit plan thinking status with intent and rationale yield { "type": "thinking", "agent": "OrchestratorAgent", - "status": f"意图分析: {rationale}" - if rationale - else "正在选择合适的处理方案...", + "status": f"意图分析: {rationale}" if rationale else "正在选择合适的处理方案...", "intent": intent, "stock_codes": stock_codes, } - # Update context - context["intent"] = intent - if stock_codes: - context["stock_codes"] = stock_codes - - # Pre-cache stock info for sharing between agents - if session_id: - # Cache stock codes for this session - self._cache.set_session_data( - session_id, "current_stock_codes", stock_codes - ) - - # Try to get cached stock info to speed up agents - for ts_code in stock_codes[:3]: # Limit to first 3 stocks - cached_info = self._cache.get_stock_info(ts_code) - if cached_info: - context.setdefault("cached_stock_info", {})[ts_code] = ( - cached_info - ) - - # Save orchestrator context for future reference - if session_id: - self._cache.set_session_data( - session_id, - "orchestrator_context", - { + if not agent_name: + error = "未找到匹配的Agent配置" + yield self._error_event(error, intent, stock_codes, available_agents) + yield { + "type": "done", + "metadata": { + "agent": "OrchestratorAgent", "intent": intent, - "agent_name": agent_name, "stock_codes": stock_codes, + "routed_by": "OrchestratorAgent", + "available_agents": available_agents, + "success": False, }, - ) - - plan = self._build_multi_agent_plan(agent_name, stock_codes, query) - - # Force multi-agent + Arena debate for first-message stock queries - # This creates the "whoa" moment: user types "分析茅台" and sees 3 agents debate - # Guard: only for analysis intents, not info queries like "600519是什么公司" - analysis_intents = { - "market_analysis", "financial_report", "hk_financial_report", - "hk_market_analysis", "stock_screening", "news_analysis", - } - history = context.get("history", []) - is_first_query = len(history) <= 1 - has_stock = bool(stock_codes) - is_analysis = intent in analysis_intents - if is_first_query and has_stock and is_analysis and len(plan) <= 1: - logger.info( - f"[Onboarding] First stock query detected, forcing multi-agent plan " - f"for whoa moment (was: {plan})" - ) - forced_plan = [] - if "MarketAgent" in self._agent_classes: - forced_plan.append("MarketAgent") - if "ReportAgent" in self._agent_classes: - forced_plan.append("ReportAgent") - if "NewsAnalystAgent" in self._agent_classes: - forced_plan.append("NewsAnalystAgent") - if len(forced_plan) >= 2: - plan = forced_plan - - if not plan: - logger.info(f"No agent available for intent: {intent}, fallback to MCP") - # Emit status about using ReAct mode with MCP - yield { - "type": "thinking", - "agent": "MCPFallback", - "status": "使用ReAct模式逐步分析...", - "intent": intent, - "stock_codes": stock_codes, } - async for event in self._execute_with_mcp_react_stream( - query, context, intent, stock_codes - ): - yield event return - if len(plan) == 1: - agent = self._get_agent(plan[0]) - - # Feature flag: use ConfigDrivenHarnessAgent when harness mode is enabled - from .config_driven_harness_agent import is_harness_mode_enabled - if is_harness_mode_enabled(): - from .config_driven_harness_agent import get_config_driven_agent - config_agent = get_config_driven_agent(plan[0]) - if config_agent: - agent = config_agent - logger.info("[Harness] Using ConfigDrivenHarnessAgent for %s", plan[0]) - - if not agent: - logger.info(f"No agent available for intent: {intent}, fallback to MCP") - async for event in self._execute_with_mcp_stream( - query, context, intent, stock_codes - ): - yield event - return - logger.info(f"Streaming via {plan[0]} for intent: {intent}") - - # Emit debug: routing (single agent) - yield self._make_debug_event( - "routing", - { - "from_agent": "OrchestratorAgent", - "to_agent": plan[0], - "is_parallel": False, - "plan": plan, + agent = get_config_driven_agent(agent_name) + if not agent: + error = f"未找到Agent配置: {agent_name}" + yield self._error_event(error, intent, stock_codes, available_agents) + yield { + "type": "done", + "metadata": { + "agent": "OrchestratorAgent", + "intent": intent, + "stock_codes": stock_codes, + "routed_by": "OrchestratorAgent", + "selected_agent": agent_name, + "available_agents": available_agents, + "success": False, }, - ) - - # Pass parent_agent to sub-agent context for debug tracing - context["parent_agent"] = "OrchestratorAgent" - - has_error = False - error_msg = "" - try: - async for event in agent.execute_stream(query, context): - event_type = event.get("type") - if event_type == "thinking": - event.setdefault("agent", agent.config.name) - event["routed_by"] = "OrchestratorAgent" - event["intent"] = intent - event["stock_codes"] = stock_codes - elif event_type == "done": - metadata = event.get("metadata", {}) - metadata["agent"] = metadata.get("agent", agent.config.name) - metadata["intent"] = intent - metadata["stock_codes"] = stock_codes - metadata["routed_by"] = "OrchestratorAgent" - metadata["available_agents"] = self._list_available_agents() - event["metadata"] = metadata - elif event_type == "error": - has_error = True - error_msg = event.get("error", "Unknown error") - continue - yield event - except Exception as e: - has_error = True - error_msg = str(e) - logger.error(f"Agent {plan[0]} execution failed: {e}") - - # If agent failed, try to provide a graceful response - if has_error: - yield { - "type": "content", - "content": f"\n\n> ⚠️ {plan[0]} 在处理过程中遇到问题: {error_msg}\n\n我正在尝试其他方式为您解答...", - } - - local_fallback_used = False - async for event in self._execute_local_stock_fallback_stream( - query, intent, stock_codes - ): - local_fallback_used = True - yield event - if local_fallback_used: - return - - async for event in self._execute_with_mcp_stream( - query, context, intent, stock_codes - ): - yield event + } return - logger.info(f"Streaming via multi-agent plan: {plan}") - is_parallel = self._can_run_concurrently(plan) - - # Emit debug: routing for each agent in the plan - for target_agent in plan: - yield self._make_debug_event( - "routing", - { - "from_agent": "OrchestratorAgent", - "to_agent": target_agent, - "is_parallel": is_parallel, - "plan": plan, - }, - ) + yield self._make_debug_event( + "routing", + { + "from_agent": "OrchestratorAgent", + "to_agent": agent_name, + "is_parallel": False, + "plan": [agent_name], + }, + ) - # Pass parent_agent to sub-agent context context["parent_agent"] = "OrchestratorAgent" - - # Phase 2: Initialize arena discussion for multi-agent scenarios - # This allows decision signals to be displayed in the chat "决策" sidebar - arena_task = None - arena_adapter = None - arena_id = None + done_seen = False try: - session_id = context.get("session_id", "") - user_id = context.get("user_id", "") - if session_id and user_id and len(plan) > 1: - arena_adapter = get_chat_arena_adapter() - arena_id = await arena_adapter.create_arena_for_chat_session( - session_id=session_id, - user_id=user_id, - stock_codes=stock_codes, - agents_in_plan=plan, - market_context={ - "intent": intent, - "query": query, - "timestamp": time.time(), - }, - ) - logger.info(f"Created arena {arena_id} for chat session {session_id}") - except Exception as e: - logger.warning(f"Failed to initialize arena (non-blocking): {e}") - - - tool_calls = [] - sub_metadata = [] - queue: asyncio.Queue = asyncio.Queue() - active = 0 - heading_sent: dict[str, bool] = {} - - # Launch arena discussion AFTER queue is created, so events push to queue in real-time - if arena_adapter and arena_id: - async def _run_arena_discussion(): - """Run arena discussion in parallel, push events to queue in real-time.""" - try: - async for arena_event in arena_adapter.run_discussion_and_collect_signals( - arena_id=arena_id, - discussion_mode="debate", - ): - await queue.put(("__arena__", arena_event)) - logger.debug(f"Pushed arena event to queue: {arena_event.get('debug_type')}") - except Exception as e: - logger.warning(f"Arena discussion failed (non-blocking): {e}") - - arena_task = asyncio.create_task(_run_arena_discussion()) - logger.info(f"Launched arena discussion task for arena {arena_id}") - - async def _run_agent(agent_name: str): - agent = self._get_agent(agent_name) - if not agent: - await queue.put( - ( - agent_name, - {"type": "error", "error": f"Agent not found: {agent_name}"}, - ) - ) - await queue.put( - ( - agent_name, - { - "type": "content", - "content": f"\n> ⚠️ Agent {agent_name} 未找到\n", - }, - ) - ) - await queue.put((agent_name, None)) - return - agent_query = self._build_agent_query(agent_name, query, stock_codes) - try: - async for event in agent.execute_stream(agent_query, context): - await queue.put((agent_name, event)) - except Exception as e: - logger.error(f"Agent {agent_name} failed: {e}") - await queue.put((agent_name, {"type": "error", "error": str(e)})) - # Provide a graceful message instead of breaking - await queue.put( - ( - agent_name, - { - "type": "content", - "content": f"\n> ⚠️ {agent_name} 处理过程中遇到问题: {str(e)[:100]}\n\n请稍后重试或换个问题。\n", - }, - ) - ) - finally: - await queue.put((agent_name, None)) - - tasks = [] - for agent_name in plan: - heading_sent[agent_name] = False - tasks.append(asyncio.create_task(_run_agent(agent_name))) - active += 1 - - while active > 0: - agent_name, event = await queue.get() - if event is None: - active -= 1 - continue - # Arena events arrive via "__arena__" sentinel — yield directly - if agent_name == "__arena__": - yield event - continue - event_type = event.get("type") - if event_type == "thinking": - event.setdefault("agent", agent_name) - event["routed_by"] = "OrchestratorAgent" - event["intent"] = intent - event["stock_codes"] = stock_codes - yield event - elif event_type == "content": - if not heading_sent.get(agent_name): - title = self._agent_descriptions.get(agent_name, agent_name) - yield {"type": "content", "content": f"\n\n### {title}\n"} - heading_sent[agent_name] = True - yield event - elif event_type == "tool": - event.setdefault("agent", agent_name) - yield event - elif event_type == "debug": - # Forward debug events from sub-agents - yield event - elif event_type == "visualization": - # Forward visualization events from sub-agents - yield event - elif event_type == "done": - metadata = event.get("metadata", {}) - metadata["agent"] = metadata.get("agent", agent_name) - metadata["intent"] = intent - metadata["stock_codes"] = stock_codes - metadata["routed_by"] = "OrchestratorAgent" - sub_metadata.append({"agent": agent_name, "metadata": metadata}) - tool_calls.extend(metadata.get("tool_calls", [])) - elif event_type == "error": + async for event in agent.execute_stream(query, context): + event_type = event.get("type") + if event_type == "thinking": + event.setdefault("agent", agent.config.name) + event["routed_by"] = "OrchestratorAgent" + event["intent"] = intent + event["stock_codes"] = stock_codes + elif event_type == "done": + done_seen = True + metadata = event.get("metadata", {}) + metadata["agent"] = metadata.get("agent", agent.config.name) + metadata["intent"] = intent + metadata["stock_codes"] = stock_codes + metadata["routed_by"] = "OrchestratorAgent" + metadata["available_agents"] = available_agents + event["metadata"] = metadata yield event + except Exception as e: + logger.error("Agent %s execution failed: %s", agent_name, e) + yield self._error_event(str(e), intent, stock_codes, available_agents) + done_seen = False - for task in tasks: - if not task.done(): - task.cancel() - - # Wait for arena discussion to finish and drain remaining events - if arena_task and not arena_task.done(): - try: - await asyncio.wait_for(arena_task, timeout=5.0) - except asyncio.TimeoutError: - logger.warning("Arena discussion task did not complete in time, cancelling") - arena_task.cancel() - except Exception as e: - logger.debug(f"Arena task error (non-blocking): {e}") - - # Drain any remaining arena events from queue - while not queue.empty(): - try: - agent_name, event = queue.get_nowait() - if agent_name == "__arena__" and event: - yield event - except asyncio.QueueEmpty: - break - - - # Flush any pending debug events (e.g., data_sharing) - if hasattr(self, "_pending_debug_events"): - for debug_event in self._pending_debug_events: - yield debug_event - self._pending_debug_events.clear() - - yield { - "type": "done", - "metadata": { - "agent": "OrchestratorAgent", - "intent": intent, - "stock_codes": stock_codes, - "routed_by": "OrchestratorAgent", - "sub_agents": plan, - "sub_agent_metadata": sub_metadata, - "tool_calls": tool_calls, - "available_agents": self._list_available_agents(), - }, - } + if not done_seen: + yield { + "type": "done", + "metadata": { + "agent": agent_name, + "intent": intent, + "stock_codes": stock_codes, + "routed_by": "OrchestratorAgent", + "available_agents": available_agents, + }, + } -# Singleton instance _orchestrator: OrchestratorAgent | None = None diff --git a/src/stock_datasource/agents/overview_agent.py b/src/stock_datasource/agents/overview_agent.py deleted file mode 100644 index 2c895271..00000000 --- a/src/stock_datasource/agents/overview_agent.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Overview Agent for intelligent market overview analysis using LangGraph/DeepAgents.""" - -import logging -from collections.abc import Callable - -from .base_agent import AgentConfig, LangGraphAgent -from .overview_tools import ( - get_hot_etfs_analysis, - get_major_indices_status, - get_market_breadth, - get_market_daily_summary, - get_market_sentiment, - get_sector_performance, -) - -logger = logging.getLogger(__name__) - - -class OverviewAgent(LangGraphAgent): - """Overview Agent for intelligent market overview analysis using DeepAgents. - - Handles: - - Major indices status analysis - - Market breadth analysis - - Sector performance ranking - - Hot ETFs analysis - - Market sentiment analysis - """ - - def __init__(self): - config = AgentConfig( - name="OverviewAgent", - description="负责市场概览分析,提供主要指数、涨跌家数、板块表现、热门ETF、市场情绪等多维度分析", - ) - super().__init__(config) - - def get_tools(self) -> list[Callable]: - """Return market overview analysis tools.""" - return [ - get_major_indices_status, - get_market_breadth, - get_sector_performance, - get_hot_etfs_analysis, - get_market_sentiment, - get_market_daily_summary, - ] - - def get_system_prompt(self) -> str: - """Return system prompt for market overview analysis.""" - return """你是一个专业的A股市场分析师,专注于每日市场概览分析。 - -## 可用数据 -你可以获取以下数据进行分析: -1. 主要指数状态:上证指数、深证成指、沪深300、中证500、上证50、创业板指等 -2. 市场广度:涨跌家数、涨跌停数量、成交额 -3. 板块表现:行业板块涨跌排名 -4. 热门ETF:按成交额/涨跌幅排序的ETF -5. 市场情绪:综合情绪指标 - -## 可用工具 -- get_major_indices_status: 获取主要指数涨跌状态 -- get_market_breadth: 获取市场广度(涨跌家数、涨跌停) -- get_sector_performance: 获取板块表现排名 -- get_hot_etfs_analysis: 获取热门ETF分析 -- get_market_sentiment: 获取市场情绪指标 -- get_market_daily_summary: 生成每日综合摘要(推荐使用) - -## 分析框架 -请按以下框架进行分析: - -### 1. 指数表现 -- 主要指数涨跌情况 -- 强势/弱势指数 -- 指数走势特点 - -### 2. 市场广度 -- 涨跌家数对比 -- 涨跌停数量 -- 成交额水平 - -### 3. 板块表现 -- 领涨板块 -- 领跌板块 -- 热点主题 - -### 4. 热门ETF -- 成交活跃ETF -- 涨幅居前ETF -- 资金流向 - -### 5. 市场情绪 -- 情绪指标 -- 多空力量对比 -- 风险提示 - -### 6. 综合建议 -- 市场整体判断 -- 关注方向 -- 风险提示 -- **免责声明:以上分析仅供参考,不构成投资建议。投资有风险,入市需谨慎。** - -## 常见问题类型 -用户可能会问: -- "今日市场整体表现如何?" -- "哪些板块表现最好?" -- "有哪些值得关注的ETF?" -- "市场情绪如何?" -- "今天有什么热点?" - -## 分析原则 -- 数据驱动:所有结论必须基于实际数据 -- 客观中立:不做主观预测 -- 风险优先:突出风险提示 -- 使用中文回复 - -## 工作流程 -1. 如果用户问整体市场情况,优先使用 get_market_daily_summary 获取完整摘要 -2. 如果用户问具体方面(如板块、ETF),使用对应的分析工具 -3. 基于工具返回的数据,给出专业解读和建议 - -## 重要输出规则 -- **绝对禁止**直接输出工具返回的原始JSON数据 -- 必须用自然语言解读和总结工具返回的数据 -- 将数据转化为用户友好的分析报告,使用表格、列表等格式展示关键信息 -- 例如:工具返回 {"pct_chg": -0.64} 时,应该说"上证指数下跌0.64%"而不是输出JSON -""" - - -def get_overview_agent() -> OverviewAgent: - """Get Overview Agent instance.""" - return OverviewAgent() diff --git a/src/stock_datasource/agents/portfolio_agent.py b/src/stock_datasource/agents/portfolio_agent.py index 7475f495..8ee5ca20 100644 --- a/src/stock_datasource/agents/portfolio_agent.py +++ b/src/stock_datasource/agents/portfolio_agent.py @@ -1,4 +1,7 @@ -"""Portfolio Agent for position management using LangGraph/DeepAgents.""" +"""Portfolio Agent for position management using LangGraph/DeepAgents. + +@deprecated Direct-import compatibility only; new orchestration uses ConfigDrivenHarnessAgent. +""" import logging from collections.abc import Callable @@ -137,6 +140,25 @@ def get_positions() -> str: logger.info(f"get_positions called for user: {_current_user_id}") + if _user_portfolio_store: + portfolio = _get_user_portfolio() + positions = portfolio.get("positions", {}) + if not positions: + return f"用户 {_current_user_id} 当前没有持仓记录。" + + lines = ["## 持仓列表(本地缓存)\n"] + lines.append("| 代码 | 数量(股) | 成本价 | 成本金额 | 买入日期 |") + lines.append("|------|----------|--------|----------|----------|") + total_cost = 0 + for code, pos in positions.items(): + lines.append( + f"| {code} | {pos['quantity']} | {pos['cost_price']:.2f} | " + f"{pos['total_cost']:.2f} | {pos['buy_date']} |" + ) + total_cost += pos["total_cost"] + lines.append(f"\n**总成本金额**: {total_cost:.2f}元") + return "\n".join(lines) + try: # Use the same PortfolioService as API to ensure data consistency from stock_datasource.modules.portfolio.service import get_portfolio_service @@ -236,6 +258,7 @@ def get_positions() -> str: total_cost += pos["total_cost"] lines.append(f"\n**总成本金额**: {total_cost:.2f}元") + return "\n".join(lines) def calculate_portfolio_pnl() -> str: @@ -250,6 +273,20 @@ def calculate_portfolio_pnl() -> str: logger.info(f"calculate_portfolio_pnl called for user: {_current_user_id}") + if _user_portfolio_store: + portfolio = _get_user_portfolio() + positions = portfolio.get("positions", {}) + if not positions: + return f"用户 {_current_user_id} 当前没有持仓,无法计算盈亏。" + + lines = ["## 持仓盈亏统计(本地缓存)\n"] + total_cost = 0 + for code, pos in positions.items(): + total_cost += pos["total_cost"] + lines.append(f"- {code}: 成本 {pos['total_cost']:.2f}元") + lines.append(f"\n**总成本**: {total_cost:.2f}元") + return "\n".join(lines) + try: # Use the same PortfolioService as API to ensure data consistency from stock_datasource.modules.portfolio.service import get_portfolio_service diff --git a/src/stock_datasource/agents/report_agent.py b/src/stock_datasource/agents/report_agent.py index 91d10e67..f4ecc61b 100644 --- a/src/stock_datasource/agents/report_agent.py +++ b/src/stock_datasource/agents/report_agent.py @@ -1,4 +1,7 @@ -"""Report Agent for financial report analysis using LangGraph/DeepAgents.""" +"""Report Agent for financial report analysis using LangGraph/DeepAgents. + +@deprecated Direct-import compatibility only; new orchestration uses ConfigDrivenHarnessAgent. +""" import logging from collections.abc import Callable diff --git a/src/stock_datasource/agents/workflow_agent.py b/src/stock_datasource/agents/workflow_agent.py deleted file mode 100644 index 9eb4bede..00000000 --- a/src/stock_datasource/agents/workflow_agent.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Workflow agent — DEPRECATED. - -Replaced by the Agent Orchestration system: -- Agent configs: /api/agents/ (ClickHouse agent_configs table) -- Pipeline orchestration: /api/orchestrations/ (orchestration_engine.py) - -This stub is kept so chat_agent.py imports don't break. -""" - -import logging - -logger = logging.getLogger(__name__) - - -class WorkflowAgent: - """Deprecated stub. Use OrchestrationEngine instead.""" - - def __init__(self, *args, **kwargs): - logger.warning("WorkflowAgent is deprecated. Use Agent Orchestration (/orchestration) instead.") - - async def execute(self, *args, **kwargs): - return {"content": "[此功能已迁移到Agent编排系统,请使用新的编排页面]"} - - -def create_workflow_agent(workflow=None): - """Deprecated stub.""" - logger.warning("create_workflow_agent is deprecated. Use OrchestrationEngine.") - return WorkflowAgent() diff --git a/src/stock_datasource/agents/workflow_generator_agent.py b/src/stock_datasource/agents/workflow_generator_agent.py deleted file mode 100644 index b8f5b849..00000000 --- a/src/stock_datasource/agents/workflow_generator_agent.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Workflow generator agent — DEPRECATED. - -Replaced by Agent Management UI where users directly configure agents -with prompts + skills. No more auto-generation of workflow templates. - -This stub is kept so workflow_routes.py imports don't break (routes are disabled). -""" - -import logging - -logger = logging.getLogger(__name__) - - -class WorkflowGeneratorAgent: - """Deprecated stub.""" - - async def generate(self, *args, **kwargs): - return None - - -def get_workflow_generator(): - """Deprecated stub.""" - return WorkflowGeneratorAgent() diff --git a/src/stock_datasource/api/workflow_routes.py b/src/stock_datasource/api/workflow_routes.py index b1598b9e..63c59876 100644 --- a/src/stock_datasource/api/workflow_routes.py +++ b/src/stock_datasource/api/workflow_routes.py @@ -207,54 +207,24 @@ async def execute_workflow( async def _execute_workflow_stream(workflow: AIWorkflow, variables: dict[str, Any]): - """流式执行工作流。""" - from stock_datasource.agents.workflow_agent import create_workflow_agent - - agent = create_workflow_agent(workflow) - - try: - async for event in agent.execute_workflow(variables): - event_type = event.get("type", "unknown") - - # 格式化为SSE - if event_type == "thinking": - yield f"event: thinking\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" - elif event_type == "content": - yield f"event: content\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" - elif event_type == "done": - yield f"event: done\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" - elif event_type == "error": - yield f"event: error\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" - else: - yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" - - except Exception as e: - logger.error(f"Workflow execution error: {e}") - yield f"event: error\ndata: {json.dumps({'type': 'error', 'error': str(e)}, ensure_ascii=False)}\n\n" + """Deprecated workflow execution stream.""" + event = { + "type": "error", + "error": "AI工作流执行已迁移到Agent编排系统,请使用新的编排入口。", + "workflow_id": workflow.id, + } + yield f"event: error\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" async def _execute_workflow_sync( workflow: AIWorkflow, variables: dict[str, Any] ) -> dict[str, Any]: - """同步执行工作流。""" - from stock_datasource.agents.workflow_agent import create_workflow_agent - - agent = create_workflow_agent(workflow) - - full_content = "" - tool_calls = [] - - async for event in agent.execute_workflow(variables): - event_type = event.get("type", "") - - if event_type == "content": - full_content += event.get("content", "") - elif event_type == "thinking" and event.get("tool"): - tool_calls.append(event.get("tool")) - elif event_type == "error": - return {"success": False, "error": event.get("error")} - - return {"success": True, "content": full_content, "tool_calls": tool_calls} + """Deprecated workflow execution response.""" + return { + "success": False, + "error": "AI工作流执行已迁移到Agent编排系统,请使用新的编排入口。", + "workflow_id": workflow.id, + } # ============================================================================ @@ -284,62 +254,17 @@ async def generate_workflow( async def _generate_workflow_stream(description: str): - """流式生成工作流。""" - from stock_datasource.agents.workflow_generator_agent import get_workflow_generator - from stock_datasource.services.workflow_service import get_workflow_service - - generator = get_workflow_generator() - service = get_workflow_service() - - # 设置可用工具 - tools = service.get_available_tools() - generator.set_available_tools(tools) - - try: - async for event in generator.generate_workflow(description): - event_type = event.get("type", "unknown") - - if event_type == "thinking": - yield f"event: thinking\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" - elif event_type == "generating": - yield f"event: generating\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" - elif event_type == "workflow": - yield f"event: workflow\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" - elif event_type == "done": - yield f"event: done\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" - elif event_type == "error": - yield f"event: error\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" - else: - yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" - - except Exception as e: - logger.error(f"Workflow generation error: {e}") - yield f"event: error\ndata: {json.dumps({'type': 'error', 'error': str(e)}, ensure_ascii=False)}\n\n" + """Deprecated workflow generation stream.""" + event = { + "type": "error", + "error": "AI工作流生成已迁移到Agent管理UI,请直接配置Agent提示词与技能。", + } + yield f"event: error\ndata: {json.dumps(event, ensure_ascii=False)}\n\n" async def _generate_workflow_sync(description: str) -> dict[str, Any]: - """同步生成工作流。""" - from stock_datasource.agents.workflow_generator_agent import get_workflow_generator - from stock_datasource.services.workflow_service import get_workflow_service - - generator = get_workflow_generator() - service = get_workflow_service() - - # 设置可用工具 - tools = service.get_available_tools() - generator.set_available_tools(tools) - - workflow_config = None - - async for event in generator.generate_workflow(description): - event_type = event.get("type", "") - - if event_type == "workflow": - workflow_config = event.get("workflow") - elif event_type == "error": - return {"success": False, "error": event.get("error")} - - if workflow_config: - return {"success": True, "workflow": workflow_config} - else: - return {"success": False, "error": "生成失败"} + """Deprecated workflow generation response.""" + return { + "success": False, + "error": "AI工作流生成已迁移到Agent管理UI,请直接配置Agent提示词与技能。", + } diff --git a/src/stock_datasource/modules/overview/service.py b/src/stock_datasource/modules/overview/service.py index ab92f3c9..2423af40 100644 --- a/src/stock_datasource/modules/overview/service.py +++ b/src/stock_datasource/modules/overview/service.py @@ -357,12 +357,24 @@ async def analyze_market( Returns: Analysis result with session info """ - from stock_datasource.agents import get_overview_agent + from stock_datasource.agents.config_driven_harness_agent import ( + get_config_driven_agent, + ) if not date: date = self._get_best_available_trade_date() - agent = get_overview_agent() + agent = get_config_driven_agent("OverviewAgent") + if not agent: + return { + "date": date, + "question": question, + "response": "未找到OverviewAgent配置,请在Agent管理中创建对应配置。", + "success": False, + "metadata": {"agent": "OverviewAgent", "missing_config": True}, + "session_id": "", + "history_length": 0, + } # Build task task = f"请基于{date}的市场数据回答:{question}" diff --git a/src/stock_datasource/modules/wechat_bridge/service.py b/src/stock_datasource/modules/wechat_bridge/service.py index f98902b7..876f57b6 100644 --- a/src/stock_datasource/modules/wechat_bridge/service.py +++ b/src/stock_datasource/modules/wechat_bridge/service.py @@ -496,7 +496,7 @@ def _build_agents_md(agents: list[dict]) -> str: 10. 龙虎榜 → TopListAgent 11. 新闻 → NewsAnalystAgent 12. 研报/公告 → KnowledgeAgent -13. 自选股/偏好 → MemoryAgent +13. 自选股/偏好 → 配置驱动偏好Agent 14. 数据管理 → DataManageAgent 15. 一般对话 → ChatAgent diff --git a/src/stock_datasource/services/agent_registrations.py b/src/stock_datasource/services/agent_registrations.py index 4922801b..d1203785 100644 --- a/src/stock_datasource/services/agent_registrations.py +++ b/src/stock_datasource/services/agent_registrations.py @@ -4,7 +4,7 @@ and managed via the /api/agents/ REST API + Agent管理 UI. This file is kept as a no-op stub so that existing call sites -(agent_runtime.py, wechat_bridge) don't break. +(e.g. wechat_bridge) don't break. """ import logging diff --git a/src/stock_datasource/services/agent_registry.py b/src/stock_datasource/services/agent_registry.py index bc89c1a2..3fd4bf4f 100644 --- a/src/stock_datasource/services/agent_registry.py +++ b/src/stock_datasource/services/agent_registry.py @@ -177,7 +177,7 @@ def reset_instance(self, name: str) -> None: # Fallback: package scanning (backward compat) # ------------------------------------------------------------------ - _SCAN_EXCLUDE = {"OrchestratorAgent", "StockDeepAgent"} + _SCAN_EXCLUDE = {"OrchestratorAgent"} def ensure_fallback_scan(self) -> None: """Scan ``stock_datasource.agents`` if no explicit registrations exist. diff --git a/src/stock_datasource/services/agent_runtime.py b/src/stock_datasource/services/agent_runtime.py deleted file mode 100644 index 213b7c2e..00000000 --- a/src/stock_datasource/services/agent_runtime.py +++ /dev/null @@ -1,757 +0,0 @@ -"""Agent Runtime: Unified control plane based on LangGraph Supervisor. - -The ``AgentRuntime`` leverages LangGraph's native multi-agent patterns -(``langgraph_supervisor.create_supervisor``, ``create_react_agent``, -``Command`` + handoff) instead of reimplementing orchestration from scratch. - -Architecture ------------- -- **Supervisor Graph**: A LangGraph ``create_supervisor`` graph that uses an - LLM to route user requests to the most appropriate sub-agent. -- **Sub-Agents**: Each business agent is wrapped as a ``create_react_agent`` - with its own tools and system prompt. -- **Handoff**: Uses LangGraph native ``Command(goto=...)`` handoff mechanism. -- **Streaming**: Uses LangGraph's ``astream_events(version="v2")`` for - real-time SSE event emission. -- **Memory Store**: LangGraph Store for cross-session fact persistence. -- **Middleware Chain**: Before/after hooks for loop detection, summarization, - guardrails, memory injection, and cross-validation. - -Tasks implemented: -- 4.3: SubAgentEnvelope integration for invocation protocol -- 5.2: Observability metrics (cold start, token cost, classification count, - concurrent failure rate, cache usage) -- Memory: LangGraph Store + FactExtractor + Middleware Chain -""" - -from __future__ import annotations - -import logging -import os -import time -from collections.abc import AsyncGenerator -from typing import Any - -from langgraph.checkpoint.memory import MemorySaver -from langgraph.prebuilt import create_react_agent - -from .agent_registry import AgentRegistry, AgentRole, get_agent_registry - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Feature flag -# --------------------------------------------------------------------------- - - -def is_runtime_enabled() -> bool: - """Check whether the new Agent Runtime is enabled. - - Controlled by env var ``AGENT_RUNTIME_ENABLED`` (default ``false``). - This allows gradual rollout and instant rollback. - """ - return os.getenv("AGENT_RUNTIME_ENABLED", "false").lower() in ("true", "1", "yes") - - -# --------------------------------------------------------------------------- -# SSE Event Adapter -# --------------------------------------------------------------------------- - - -def adapt_langgraph_event_to_sse(event: dict[str, Any]) -> dict[str, Any] | None: - """Convert a LangGraph astream_events v2 event to legacy SSE format. - - The frontend expects: thinking, content, tool, debug, visualization, done, error. - This bridges LangGraph's native event model to our SSE contract. - """ - etype = event.get("event", "") - data = event.get("data", {}) - name = event.get("name", "") - tags = event.get("tags", []) - - if etype == "on_chat_model_stream": - chunk = data.get("chunk") - if chunk: - content = None - if hasattr(chunk, "content") and chunk.content: - raw = chunk.content - if isinstance(raw, str): - content = raw - elif isinstance(raw, list): - parts = [] - for block in raw: - if isinstance(block, dict): - if block.get("type") == "text" and block.get("text"): - parts.append(block["text"]) - elif isinstance(block, str): - parts.append(block) - if parts: - content = "".join(parts) - elif isinstance(chunk, dict) and chunk.get("content"): - content = chunk["content"] - - if content and isinstance(content, str): - return {"type": "content", "content": content} - return None - - if etype == "on_tool_start": - tool_input = data.get("input", {}) - return { - "type": "tool", - "tool": name, - "args": tool_input if isinstance(tool_input, dict) else {}, - "agent": _extract_agent_from_tags(tags), - "status": f"调用工具: {name}", - } - - if etype == "on_tool_end": - tool_output = data.get("output", "") - result_str = str(tool_output) - summary = result_str[:500] + "..." if len(result_str) > 500 else result_str - - # Extract visualization if present - viz = _extract_visualization(tool_output) - events = [] - events.append( - { - "type": "debug", - "debug_type": "tool_result", - "agent": _extract_agent_from_tags(tags), - "timestamp": time.time(), - "data": { - "tool": name, - "result_summary": summary, - }, - } - ) - if viz: - events.append( - { - "type": "visualization", - "visualization": viz, - "agent": _extract_agent_from_tags(tags), - "tool": name, - } - ) - # Return first event; caller handles multi-event case - return events[0] if len(events) == 1 else events - - if etype == "on_chain_start" and "supervisor" in name.lower(): - return { - "type": "thinking", - "agent": "AgentRuntime", - "status": "正在理解您的需求...", - "intent": "", - "stock_codes": [], - } - - # Skip noisy internal events - if etype in ( - "on_chain_start", - "on_chain_end", - "on_chain_stream", - "on_chat_model_start", - "on_chat_model_end", - "on_prompt_start", - "on_prompt_end", - ): - return None - - return None - - -def _extract_agent_from_tags(tags: list[str]) -> str: - """Try to extract agent name from LangGraph event tags.""" - for tag in tags: - if tag.endswith("Agent") or tag.endswith("_agent"): - return tag - return "" - - -def _extract_visualization(tool_output: Any) -> dict[str, Any] | None: - """Extract _visualization from tool output.""" - import json as _json - - if isinstance(tool_output, dict) and "_visualization" in tool_output: - return tool_output["_visualization"] - if hasattr(tool_output, "content"): - content = tool_output.content - if isinstance(content, dict) and "_visualization" in content: - return content["_visualization"] - if isinstance(content, str): - try: - parsed = _json.loads(content) - if isinstance(parsed, dict) and "_visualization" in parsed: - return parsed["_visualization"] - except (_json.JSONDecodeError, TypeError): - pass - if isinstance(tool_output, str): - try: - parsed = _json.loads(tool_output) - if isinstance(parsed, dict) and "_visualization" in parsed: - return parsed["_visualization"] - except (_json.JSONDecodeError, TypeError): - pass - return None - - -# --------------------------------------------------------------------------- -# AgentRuntime -# --------------------------------------------------------------------------- - - -class AgentRuntime: - """Unified control plane powered by LangGraph Supervisor. - - Instead of reimplementing parallel/sequential/handoff execution, - this delegates all orchestration to LangGraph's native - ``create_supervisor`` which handles: - - LLM-based routing to the right sub-agent - - Agent handoff via ``Command(goto=...)`` - - State management via ``MessagesState`` - - Checkpointing via ``MemorySaver`` - - Lifecycle - --------- - 1. Build sub-agents as ``create_react_agent`` from registry - 2. Build a supervisor graph with ``create_supervisor`` - 3. Stream events via ``astream_events(version="v2")`` - 4. Translate LangGraph events → legacy SSE events - """ - - def __init__( - self, - registry: AgentRegistry | None = None, - default_timeout: int = 120, - ): - self.registry = registry or get_agent_registry() - self.default_timeout = default_timeout - self._supervisor = None - self._checkpointer = MemorySaver() - self._sub_agents: dict[str, Any] = {} - # Task 5.2: Observability metrics - self._cold_start_ms: float | None = None - self._classification_count: int = 0 - self._concurrent_failures: int = 0 - self._total_invocations: int = 0 - - # Memory Store + Middleware Chain (feature-flagged) - self._store = None - self._middleware_chain: list = [] - self._init_memory_and_middlewares() - - # Trigger explicit agent registrations (no-op if already done) - try: - from .agent_registrations import register_all_agents - - register_all_agents() - except Exception as exc: - logger.debug("Agent registrations skipped: %s", exc) - - def _init_memory_and_middlewares(self) -> None: - """Initialize MemoryStore and middleware chain if feature flag is set.""" - try: - from stock_datasource.agents.middlewares import ( - build_default_middleware_chain, - ) - from stock_datasource.modules.memory.store import ( - get_memory_store, - is_memory_store_enabled, - ) - - if is_memory_store_enabled(): - self._store = get_memory_store() - self._middleware_chain = build_default_middleware_chain( - store=self._store - ) - logger.info( - "Memory store + middleware chain initialized (%d middlewares)", - len(self._middleware_chain), - ) - else: - logger.debug("Memory store disabled (MEMORY_STORE_ENABLED not set)") - except Exception as exc: - logger.warning("Failed to init memory/middlewares: %s", exc) - - # ------------------------------------------------------------------ - # Sub-Agent construction - # ------------------------------------------------------------------ - - def _build_sub_agents(self) -> list: - """Build LangGraph sub-agents from registry descriptors. - - Each registered business agent is wrapped as a ``create_react_agent`` - graph, using the agent's existing tools and system prompt. - """ - from stock_datasource.agents.base_agent import ( - get_langchain_model, - ) - - self.registry.ensure_fallback_scan() - - agents = [] - model = get_langchain_model() - - for desc in self.registry.list_descriptors(role=AgentRole.AGENT): - if not desc.enabled: - continue - - try: - # Get or create the business agent instance - agent_instance = self.registry.get_agent(desc.name) - if agent_instance is None: - continue - - # Extract tools and prompt from the existing agent - tools = agent_instance.get_tools() - system_prompt = agent_instance.get_system_prompt() + getattr( - agent_instance, "COMMON_OUTPUT_RULES", "" - ) - - # Build a LangGraph react agent - react_agent = create_react_agent( - model=model, - tools=tools, - prompt=system_prompt, - name=desc.name, - ) - - agents.append(react_agent) - self._sub_agents[desc.name] = react_agent - logger.info("Built sub-agent: %s (%d tools)", desc.name, len(tools)) - - except Exception as exc: - logger.warning("Failed to build sub-agent %s: %s", desc.name, exc) - - return agents - - def _get_supervisor_prompt(self) -> str: - """Build the supervisor's system prompt with agent descriptions.""" - agent_descs = [] - for desc in self.registry.list_descriptors(role=AgentRole.AGENT): - if desc.enabled: - markets = ( - ", ".join(desc.capability.markets) - if desc.capability.markets - else "通用" - ) - intents = ( - ", ".join(desc.capability.intents) - if desc.capability.intents - else "" - ) - agent_descs.append( - f"- **{desc.name}**: {desc.description} (市场: {markets})" - ) - - agents_list = "\n".join(agent_descs) if agent_descs else "无可用Agent" - - return f"""你是一个智能股票分析平台的协调者(Supervisor)。你的职责是理解用户的意图并将请求路由到最合适的专业Agent。 - -## 可用Agent -{agents_list} - -## 路由规则 -1. 分析用户问题,识别意图(行情分析、财务分析、选股、回测等) -2. 根据意图选择最合适的Agent -3. 如果涉及港股(代码如 00700.HK),技术分析选 MarketAgent,财务分析选 HKReportAgent -4. 如果用户同时需要技术面+基本面分析,可以先调用 MarketAgent 再调用 ReportAgent -5. 如果用户询问研报、公告等文档,选 KnowledgeAgent -6. 一般性对话选 ChatAgent - -## 注意事项 -- 直接将用户问题转发给选中的Agent,不要自己回答专业问题 -- 如果需要多个Agent协作,按顺序逐个调用 -- 始终用中文回复用户""" - - def _build_supervisor(self): - """Build the LangGraph Supervisor graph. - - Uses ``create_supervisor`` from ``langgraph_supervisor`` which - handles all routing/handoff/state management internally. - """ - from langgraph_supervisor import create_supervisor - - from stock_datasource.agents.base_agent import get_langchain_model - - t0 = time.time() - - agents = self._build_sub_agents() - if not agents: - logger.warning("No sub-agents built, supervisor will be limited") - return None - - model = get_langchain_model() - - supervisor = create_supervisor( - agents=agents, - model=model, - prompt=self._get_supervisor_prompt(), - supervisor_name="OrchestratorSupervisor", - output_mode="full_history", - add_handoff_back_messages=True, - include_agent_name="inline", - ) - - # Compile with checkpointer for session persistence - compile_kwargs = {"checkpointer": self._checkpointer} - # Inject Memory Store if available (enables graph-level Store access) - if self._store is not None: - try: - compile_kwargs["store"] = self._store.raw_store - except Exception: - pass - self._supervisor = supervisor.compile(**compile_kwargs) - - # Task 5.2: Record cold start time - self._cold_start_ms = (time.time() - t0) * 1000 - - logger.info( - "Supervisor graph built with %d sub-agents in %.0fms: %s", - len(agents), - self._cold_start_ms, - list(self._sub_agents.keys()), - ) - return self._supervisor - - def _ensure_supervisor(self): - """Lazy-build the supervisor on first use.""" - if self._supervisor is None: - self._build_supervisor() - return self._supervisor - - # ------------------------------------------------------------------ - # Public streaming API - # ------------------------------------------------------------------ - - async def execute_stream( - self, - query: str, - context: dict[str, Any] = None, - ) -> AsyncGenerator[dict[str, Any], None]: - """Execute a query via LangGraph Supervisor, yielding raw events. - - Uses ``astream_events(version="v2")`` for real-time streaming. - """ - context = context or {} - session_id = context.get("session_id", "default") - user_id = context.get("user_id", "default") - - supervisor = self._ensure_supervisor() - if supervisor is None: - yield { - "event": "error", - "data": {"error": "No supervisor available (no agents built)"}, - } - return - - config = { - "configurable": {"thread_id": session_id}, - "recursion_limit": 50, - "metadata": { - "langfuse_user_id": user_id, - "langfuse_session_id": session_id, - "langfuse_tags": ["AgentRuntime"], - }, - } - - # Add langfuse callbacks if available - try: - from stock_datasource.agents.base_agent import get_langfuse_handler - - handler = get_langfuse_handler() - if handler: - config["callbacks"] = [handler] - except Exception: - pass - - messages = [{"role": "user", "content": query}] - - try: - async for event in supervisor.astream_events( - {"messages": messages}, - config=config, - version="v2", - ): - yield event - except TimeoutError: - yield { - "event": "error", - "data": {"error": f"执行超时 ({self.default_timeout}s)"}, - } - except Exception as exc: - logger.error("Supervisor execution failed: %s", exc) - yield { - "event": "error", - "data": {"error": str(exc)}, - } - - async def execute_stream_sse( - self, - query: str, - context: dict[str, Any] = None, - ) -> AsyncGenerator[dict[str, Any], None]: - """Execute and yield legacy SSE-compatible events. - - Translates LangGraph ``astream_events`` into the frontend's - expected format (thinking, content, tool, done, etc.). - - Task 4.3: Builds a ``SubAgentEnvelope`` to record invocation - metadata. - Task 5.2: Bumps observability counters. - """ - context = context or {} - session_id = context.get("session_id", "default") - user_id = context.get("user_id", "default") - - # --- Middleware before() phase --- - mw_context = None - mw_trace_id = "-" - if self._middleware_chain: - try: - from stock_datasource.agents.middlewares.base import AgentContext - from stock_datasource.utils.request_context import ( - generate_middleware_trace_id, - middleware_trace_id_var, - ) - - mw_trace_id = generate_middleware_trace_id() - middleware_trace_id_var.set(mw_trace_id) - - mw_context = AgentContext( - query=query, - session_id=session_id, - user_id=user_id, - ) - for mw in self._middleware_chain: - if mw.enabled: - mw_context = await mw.before_with_logging(mw_context) - - # Inject memory block into context for downstream use - if mw_context.memory_block: - context["memory_block"] = mw_context.memory_block - # Check if query was flagged as non-financial - if not mw_context.is_financial_query: - yield { - "type": "content", - "content": "抱歉,我是一个专业的股票分析助手,只能回答与金融投资相关的问题。\n\n" - "您可以试试以下问题:\n" - "- 查询某只股票的行情和走势\n" - "- 对股票进行技术面或基本面分析\n" - "- 筛选符合条件的股票\n" - "- 制定投资策略和回测\n\n" - f"您的问题:{query}", - } - yield { - "type": "done", - "metadata": {"agent": "AgentRuntime", "redirected": True}, - } - return - except Exception as exc: - logger.warning("Middleware before() chain failed: %s", exc) - mw_context = None - - # Task 4.3: Create envelope for this invocation - from .session_memory_service import SubAgentEnvelope, get_session_memory_service - - envelope = SubAgentEnvelope( - agent_name="AgentRuntime", - session_id=session_id, - user_id=user_id, - query=query, - ) - - # Task 5.2: Bump counters - self._total_invocations += 1 - self._classification_count += 1 - mem_svc = get_session_memory_service() - mem_svc.record_stat("runtime_invocations") - - t0 = time.time() - - # Emit initial thinking - yield { - "type": "thinking", - "agent": "AgentRuntime", - "status": "正在理解您的需求...", - "intent": "", - "stock_codes": [], - } - - has_content = False - has_error = False - content_parts: list[str] = [] - - async for raw_event in self.execute_stream(query, context): - # Handle error events from our own wrapper - if raw_event.get("event") == "error": - yield { - "type": "error", - "error": raw_event.get("data", {}).get("error", "Unknown error"), - } - has_error = True - self._concurrent_failures += 1 - mem_svc.record_stat("runtime_errors") - continue - - sse_event = adapt_langgraph_event_to_sse(raw_event) - if sse_event is None: - continue - - # Handle multi-event case (tool_end with visualization) - if isinstance(sse_event, list): - for e in sse_event: - yield e - if e.get("type") == "content": - has_content = True - content_parts.append(e.get("content", "")) - else: - yield sse_event - if sse_event.get("type") == "content": - has_content = True - content_parts.append(sse_event.get("content", "")) - - # Task 5.2: Record execution duration - duration_ms = int((time.time() - t0) * 1000) - mem_svc.record_stat("runtime_total_duration_ms", duration_ms) - - # Task 4.3: Fill envelope response - envelope.response = "".join(content_parts) - envelope.success = not has_error - envelope.metadata = { - "duration_ms": duration_ms, - "has_content": has_content, - } - - # --- Middleware after() phase --- - if self._middleware_chain and mw_context is not None: - try: - from stock_datasource.agents.middlewares.base import AgentResponse - - mw_response = AgentResponse( - content=envelope.response, - success=envelope.success, - metadata=envelope.metadata, - tool_calls=[], - ) - for mw in reversed(self._middleware_chain): - if mw.enabled: - mw_response = await mw.after_with_logging( - mw_context, mw_response - ) - - # Apply middleware modifications - if mw_response.warnings: - # Append warnings to content - for warning in mw_response.warnings: - content_parts.append(f"\n\n{warning}") - envelope.response = "".join(content_parts) - if mw_response.validation_result: - context["validation_result"] = mw_response.validation_result - except Exception as exc: - logger.warning("Middleware after() chain failed: %s", exc) - - # Emit done - yield { - "type": "done", - "metadata": { - "agent": "AgentRuntime", - "available_agents": self.registry.list_available(), - "has_error": has_error, - "duration_ms": duration_ms, - "middleware_trace_id": mw_trace_id, - "middleware_trace": mw_context.middleware_trace if mw_context else [], - }, - } - - # Reset middleware trace context - if mw_trace_id != "-": - try: - from stock_datasource.utils.request_context import ( - middleware_trace_id_var, - ) - - middleware_trace_id_var.set("-") - except Exception: - pass - - # ------------------------------------------------------------------ - # Non-streaming API - # ------------------------------------------------------------------ - - async def execute( - self, - query: str, - context: dict[str, Any] = None, - ) -> dict[str, Any]: - """Execute synchronously by collecting all SSE events.""" - content_parts = [] - metadata = {} - tool_calls = [] - - async for event in self.execute_stream_sse(query, context): - etype = event.get("type") - if etype == "content": - content_parts.append(event.get("content", "")) - elif etype == "done": - metadata = event.get("metadata", {}) - elif etype == "tool": - tool_calls.append( - { - "name": event.get("tool", ""), - "args": event.get("args", {}), - } - ) - - return { - "response": "".join(content_parts), - "success": not metadata.get("has_error", False), - "metadata": metadata, - "tool_calls": tool_calls, - } - - # ------------------------------------------------------------------ - # Introspection - # ------------------------------------------------------------------ - - @property - def agent_names(self) -> set[str]: - """Names of agents available in the supervisor.""" - return set(self._sub_agents.keys()) - - @property - def stats(self) -> dict[str, Any]: - """Task 5.2: Return observability snapshot.""" - return { - "cold_start_ms": self._cold_start_ms, - "total_invocations": self._total_invocations, - "classification_count": self._classification_count, - "concurrent_failures": self._concurrent_failures, - "failure_rate": ( - self._concurrent_failures / self._total_invocations - if self._total_invocations > 0 - else 0.0 - ), - "sub_agent_count": len(self._sub_agents), - } - - def reset(self) -> None: - """Reset the supervisor (e.g., after agent registration changes).""" - self._supervisor = None - self._sub_agents.clear() - self._cold_start_ms = None - - -# --------------------------------------------------------------------------- -# Singleton -# --------------------------------------------------------------------------- - -_runtime: AgentRuntime | None = None - - -def get_agent_runtime() -> AgentRuntime: - global _runtime - if _runtime is None: - _runtime = AgentRuntime() - return _runtime diff --git a/src/stock_datasource/services/daily_analysis_service.py b/src/stock_datasource/services/daily_analysis_service.py index 90663c1a..9241d573 100644 --- a/src/stock_datasource/services/daily_analysis_service.py +++ b/src/stock_datasource/services/daily_analysis_service.py @@ -90,11 +90,9 @@ def agent(self): """Lazy load portfolio agent.""" if self._agent is None: try: - from stock_datasource.agents.enhanced_portfolio_agent import ( - EnhancedPortfolioAgent, - ) + from stock_datasource.agents.portfolio_agent import PortfolioAgent - self._agent = EnhancedPortfolioAgent() + self._agent = PortfolioAgent() except Exception as e: logger.warning(f"Failed to get portfolio agent: {e}") return self._agent diff --git a/src/stock_datasource/services/session_memory_service.py b/src/stock_datasource/services/session_memory_service.py index 562c6cec..f755760a 100644 --- a/src/stock_datasource/services/session_memory_service.py +++ b/src/stock_datasource/services/session_memory_service.py @@ -5,9 +5,9 @@ 2. Session Cache: per-session tool-result caching with TTL 3. Long-term Memory: user-scoped preferences and watchlists -Other components (``SessionMemory`` in base_agent, ``ChatService``, -``MemoryAgent``) delegate to this service rather than maintaining their -own state – eliminating the duplicate-storage and cache-inconsistency +Other components (``SessionMemory`` in base_agent and ``ChatService``) +delegate to this service rather than maintaining their own state – eliminating +the duplicate-storage and cache-inconsistency problems that existed before. Observability counters (task 5.2) are exposed as properties so that diff --git a/src/stock_datasource/services/tool_registry.py b/src/stock_datasource/services/tool_registry.py index dc2e3bab..420cbd38 100644 --- a/src/stock_datasource/services/tool_registry.py +++ b/src/stock_datasource/services/tool_registry.py @@ -76,6 +76,7 @@ def get_registered_tool_names() -> list[str]: SKILL_TOOL_MAP: dict[str, list[str]] = { # Market / Technical analysis "market_analysis": ["get_kline", "calculate_indicators", "analyze_trend", "get_market_overview", "get_stock_info"], + "hk_market_analysis": ["get_kline", "calculate_indicators", "analyze_trend", "get_stock_info"], "technical_analysis": ["get_kline", "calculate_indicators", "analyze_trend", "calculate_technical_indicators"], "kline_analysis": ["get_kline", "get_stock_kline"], # Financial / Fundamental @@ -89,6 +90,9 @@ def get_registered_tool_names() -> list[str]: "news_analysis": ["get_news_by_stock", "get_market_news", "analyze_news_sentiment", "get_hot_topics", "summarize_news"], "sentiment_analysis": ["analyze_news_sentiment", "get_stock_signal_summary"], # Portfolio / Backtest + "portfolio_management": ["get_positions", "add_position", "update_position", "calculate_portfolio_pnl"], + "position_analysis": ["get_positions", "calculate_portfolio_pnl"], + "risk_assessment": ["get_positions", "calculate_portfolio_pnl", "get_stock_valuation"], "strategy_backtest": ["screen_stocks"], # TODO: add backtest tools when available "performance_analysis": ["get_comprehensive_financial_analysis"], # ETF @@ -97,7 +101,9 @@ def get_registered_tool_names() -> list[str]: # General "stock_info": ["get_stock_info", "get_stock_profile"], "sector_analysis": ["get_sector_stocks", "get_available_sectors"], - "market_overview": ["get_market_overview"], + "market_overview": ["get_market_overview", "get_market_daily_summary", "get_major_indices_status", "get_market_breadth"], + "sector_rotation": ["get_sector_performance", "get_market_breadth"], + "market_sentiment": ["get_market_sentiment", "get_market_breadth", "get_hot_etfs_analysis"], } @@ -145,6 +151,8 @@ def auto_discover_tools() -> int: - agents/market_agent.py (market-specific tools) - agents/report_agent.py (financial report tools) - agents/news_analyst_agent.py (news tools) + - agents/portfolio_agent.py (portfolio tools) + - agents/overview_tools.py (market overview tools) Returns: Number of tools registered @@ -209,6 +217,30 @@ def auto_discover_tools() -> int: ], ) + # 5. Portfolio tools + _register_from_module( + "stock_datasource.agents.portfolio_agent", + names=[ + "add_position", + "update_position", + "get_positions", + "calculate_portfolio_pnl", + ], + ) + + # 6. Market overview tools + _register_from_module( + "stock_datasource.agents.overview_tools", + names=[ + "get_major_indices_status", + "get_market_breadth", + "get_sector_performance", + "get_hot_etfs_analysis", + "get_market_sentiment", + "get_market_daily_summary", + ], + ) + registered = len(TOOL_REGISTRY) - initial_count logger.info( "Tool registry auto-discovery complete: %d new tools registered (total: %d)", diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py deleted file mode 100644 index 448927f1..00000000 --- a/tests/test_agent_runtime.py +++ /dev/null @@ -1,624 +0,0 @@ -"""Tests for Agent Registry, Execution Planner, and Agent Runtime. - -Covers: -- AgentRegistry: registration, discovery, instance lifecycle -- ExecutionPlanner: config data and agent expansion -- AgentRuntime: feature flag, SSE adapter, supervisor build -""" - -import os -import time -from unittest.mock import MagicMock - -# --------------------------------------------------------------------------- -# Stubs -# --------------------------------------------------------------------------- - - -class FakeAgentConfig: - def __init__(self, name, description=""): - self.name = name - self.description = description - - -class FakeLangGraphAgent: - COMMON_OUTPUT_RULES = "\n## Test output rules" - - def __init__(self, name="FakeAgent", description="Fake"): - self.config = FakeAgentConfig(name, description) - - def get_tools(self): - return [] - - def get_system_prompt(self): - return "You are a fake agent." - - -# =========================================================================== -# AgentRegistry Tests -# =========================================================================== - - -class TestAgentRegistry: - def _make_registry(self): - from stock_datasource.services.agent_registry import AgentRegistry - - return AgentRegistry() - - def _make_descriptor(self, name="TestAgent", enabled=True, role=None, intents=None): - from stock_datasource.services.agent_registry import ( - AgentDescriptor, - AgentRole, - CapabilityDescriptor, - ) - - return AgentDescriptor( - name=name, - description=f"Description of {name}", - agent_class=type( - name, - (FakeLangGraphAgent,), - {"__init__": lambda self: FakeLangGraphAgent.__init__(self, name)}, - ), - role=role or AgentRole.AGENT, - capability=CapabilityDescriptor(intents=intents or []), - enabled=enabled, - ) - - def test_register_and_list(self): - reg = self._make_registry() - reg.register(self._make_descriptor("AgentA")) - reg.register(self._make_descriptor("AgentB")) - assert reg.count == 2 - assert "AgentA" in reg.names - assert len(reg.list_available()) == 2 - - def test_register_overwrite(self): - reg = self._make_registry() - reg.register(self._make_descriptor("AgentA")) - d2 = self._make_descriptor("AgentA") - d2.description = "Updated" - reg.register(d2) - assert reg.count == 1 - assert reg.get_descriptor("AgentA").description == "Updated" - - def test_unregister(self): - reg = self._make_registry() - reg.register(self._make_descriptor("AgentA")) - assert reg.unregister("AgentA") is True - assert reg.unregister("AgentA") is False - assert reg.count == 0 - - def test_enabled_filter(self): - reg = self._make_registry() - reg.register(self._make_descriptor("Enabled", enabled=True)) - reg.register(self._make_descriptor("Disabled", enabled=False)) - assert len(reg.list_descriptors(enabled_only=True)) == 1 - assert len(reg.list_descriptors(enabled_only=False)) == 2 - - def test_role_filter(self): - from stock_datasource.services.agent_registry import AgentRole - - reg = self._make_registry() - reg.register(self._make_descriptor("A", role=AgentRole.AGENT)) - reg.register(self._make_descriptor("B", role=AgentRole.SUB_AGENT)) - reg.register(self._make_descriptor("C", role=AgentRole.ADAPTER)) - assert len(reg.list_descriptors(role=AgentRole.AGENT)) == 1 - - def test_find_by_intent(self): - reg = self._make_registry() - reg.register(self._make_descriptor("MarketAgent", intents=["market_analysis"])) - reg.register(self._make_descriptor("ReportAgent", intents=["financial_report"])) - found = reg.find_by_intent("market_analysis") - assert len(found) == 1 - assert found[0].name == "MarketAgent" - assert len(reg.find_by_intent("nonexistent")) == 0 - - def test_get_agent_lazy_instantiation(self): - reg = self._make_registry() - reg.register(self._make_descriptor("LazyAgent")) - a1 = reg.get_agent("LazyAgent") - assert a1 is not None - a2 = reg.get_agent("LazyAgent") - assert a2 is a1 - - def test_get_agent_disabled(self): - reg = self._make_registry() - reg.register(self._make_descriptor("Disabled", enabled=False)) - assert reg.get_agent("Disabled") is None - - def test_get_agent_unknown(self): - reg = self._make_registry() - assert reg.get_agent("NonExistent") is None - - def test_reset_instance(self): - reg = self._make_registry() - reg.register(self._make_descriptor("R")) - a1 = reg.get_agent("R") - reg.reset_instance("R") - a2 = reg.get_agent("R") - assert a1 is not a2 - - def test_priority_ordering(self): - reg = self._make_registry() - d1 = self._make_descriptor("Lo") - d1.priority = 1 - d2 = self._make_descriptor("Hi") - d2.priority = 10 - reg.register(d1) - reg.register(d2) - assert reg.list_descriptors()[0].name == "Hi" - - def test_register_many(self): - reg = self._make_registry() - reg.register_many([self._make_descriptor(f"A{i}") for i in range(5)]) - assert reg.count == 5 - - -# =========================================================================== -# ExecutionPlanner Tests -# =========================================================================== - - -class TestExecutionPlanner: - def _make_planner(self): - from stock_datasource.services.execution_planner import ExecutionPlanner - - return ExecutionPlanner() - - def test_expand_single_agent(self): - p = self._make_planner() - assert p.expand_agents( - primary="MarketAgent", available_agents={"MarketAgent"} - ) == ["MarketAgent"] - - def test_expand_none(self): - p = self._make_planner() - assert p.expand_agents(primary=None) == [] - - def test_expand_market_with_a_shares(self): - p = self._make_planner() - agents = p.expand_agents( - primary="MarketAgent", - stock_codes=["600519.SH"], - query="分析600519", - available_agents={"MarketAgent", "ReportAgent", "HKReportAgent"}, - ) - assert "MarketAgent" in agents and "ReportAgent" in agents - - def test_expand_market_with_hk(self): - p = self._make_planner() - agents = p.expand_agents( - primary="MarketAgent", - stock_codes=["00700.HK"], - available_agents={"MarketAgent", "HKReportAgent"}, - ) - assert "HKReportAgent" in agents - - def test_reverse_expansion_hk_tech(self): - p = self._make_planner() - agents = p.expand_agents( - primary="HKReportAgent", - stock_codes=["00700.HK"], - query="腾讯技术面", - available_agents={"MarketAgent", "HKReportAgent"}, - ) - assert "MarketAgent" in agents - - def test_reverse_expansion_report_tech(self): - p = self._make_planner() - agents = p.expand_agents( - primary="ReportAgent", - stock_codes=["600519.SH"], - query="茅台技术走势", - available_agents={"MarketAgent", "ReportAgent"}, - ) - assert "MarketAgent" in agents - - def test_concurrent_check(self): - p = self._make_planner() - assert p.can_run_concurrently(["MarketAgent", "ReportAgent"]) is True - assert p.can_run_concurrently(["MarketAgent", "ScreenerAgent"]) is False - - def test_handoff_targets(self): - p = self._make_planner() - assert "ReportAgent" in p.get_handoff_targets("MarketAgent") - assert p.get_handoff_targets("UnknownAgent") == [] - - -class TestEnums: - def test_execution_mode_values(self): - from stock_datasource.services.execution_planner import ExecutionMode - - assert ExecutionMode.ROUTE_ONLY.value == "route_only" - assert ExecutionMode.SUPERVISOR.value == "supervisor" - - def test_node_status_values(self): - from stock_datasource.services.execution_planner import NodeStatus - - assert NodeStatus.PENDING.value == "pending" - assert NodeStatus.TIMED_OUT.value == "timed_out" - - -# =========================================================================== -# AgentRuntime Tests -# =========================================================================== - - -class TestAgentRuntime: - def test_feature_flag_default_off(self): - from stock_datasource.services.agent_runtime import is_runtime_enabled - - os.environ.pop("AGENT_RUNTIME_ENABLED", None) - assert is_runtime_enabled() is False - - def test_feature_flag_on(self): - from stock_datasource.services.agent_runtime import is_runtime_enabled - - os.environ["AGENT_RUNTIME_ENABLED"] = "true" - try: - assert is_runtime_enabled() is True - finally: - del os.environ["AGENT_RUNTIME_ENABLED"] - - def test_feature_flag_variants(self): - from stock_datasource.services.agent_runtime import is_runtime_enabled - - for val in ("1", "yes", "True", "TRUE"): - os.environ["AGENT_RUNTIME_ENABLED"] = val - assert is_runtime_enabled() is True, f"Expected True for {val}" - for val in ("false", "0", "no", ""): - os.environ["AGENT_RUNTIME_ENABLED"] = val - assert is_runtime_enabled() is False, f"Expected False for {val}" - os.environ.pop("AGENT_RUNTIME_ENABLED", None) - - def test_sse_adapter_content_stream(self): - from stock_datasource.services.agent_runtime import adapt_langgraph_event_to_sse - - # Simulate on_chat_model_stream with a mock chunk - chunk = MagicMock() - chunk.content = "Hello world" - event = {"event": "on_chat_model_stream", "data": {"chunk": chunk}} - sse = adapt_langgraph_event_to_sse(event) - assert sse is not None - assert sse["type"] == "content" - assert sse["content"] == "Hello world" - - def test_sse_adapter_tool_start(self): - from stock_datasource.services.agent_runtime import adapt_langgraph_event_to_sse - - event = { - "event": "on_tool_start", - "name": "get_stock_data", - "data": {"input": {"code": "600519"}}, - "tags": ["MarketAgent"], - } - sse = adapt_langgraph_event_to_sse(event) - assert sse["type"] == "tool" - assert sse["tool"] == "get_stock_data" - assert sse["args"]["code"] == "600519" - - def test_sse_adapter_tool_end(self): - from stock_datasource.services.agent_runtime import adapt_langgraph_event_to_sse - - event = { - "event": "on_tool_end", - "name": "get_stock_data", - "data": {"output": "result data"}, - "tags": ["MarketAgent"], - } - sse = adapt_langgraph_event_to_sse(event) - assert sse["type"] == "debug" - assert sse["debug_type"] == "tool_result" - - def test_sse_adapter_skip_internal(self): - from stock_datasource.services.agent_runtime import adapt_langgraph_event_to_sse - - for etype in ("on_chain_start", "on_chain_end", "on_chat_model_start"): - event = {"event": etype, "data": {}, "tags": []} - assert adapt_langgraph_event_to_sse(event) is None - - def test_sse_adapter_empty_chunk(self): - from stock_datasource.services.agent_runtime import adapt_langgraph_event_to_sse - - chunk = MagicMock() - chunk.content = "" - event = {"event": "on_chat_model_stream", "data": {"chunk": chunk}} - assert adapt_langgraph_event_to_sse(event) is None - - def test_sse_adapter_list_content(self): - """Handle thinking model content (list of blocks).""" - from stock_datasource.services.agent_runtime import adapt_langgraph_event_to_sse - - chunk = MagicMock() - chunk.content = [ - {"type": "thinking", "thinking": "reasoning..."}, - {"type": "text", "text": "actual answer"}, - ] - event = {"event": "on_chat_model_stream", "data": {"chunk": chunk}} - sse = adapt_langgraph_event_to_sse(event) - assert sse is not None - assert sse["type"] == "content" - assert sse["content"] == "actual answer" - - def test_runtime_init(self): - """AgentRuntime can be created with an empty registry.""" - from stock_datasource.services.agent_registry import AgentRegistry - from stock_datasource.services.agent_runtime import AgentRuntime - - reg = AgentRegistry() - rt = AgentRuntime(registry=reg) - assert rt.registry is reg - assert rt._supervisor is None - - def test_runtime_reset(self): - from stock_datasource.services.agent_registry import AgentRegistry - from stock_datasource.services.agent_runtime import AgentRuntime - - rt = AgentRuntime(registry=AgentRegistry()) - rt._sub_agents["test"] = "dummy" - rt.reset() - assert rt._supervisor is None - assert len(rt._sub_agents) == 0 - - def test_runtime_agent_names_empty(self): - from stock_datasource.services.agent_registry import AgentRegistry - from stock_datasource.services.agent_runtime import AgentRuntime - - rt = AgentRuntime(registry=AgentRegistry()) - assert rt.agent_names == set() - - def test_supervisor_prompt_generation(self): - """Test that supervisor prompt includes agent descriptions.""" - from stock_datasource.services.agent_registry import ( - AgentDescriptor, - AgentRegistry, - CapabilityDescriptor, - ) - from stock_datasource.services.agent_runtime import AgentRuntime - - reg = AgentRegistry() - reg.register( - AgentDescriptor( - name="MarketAgent", - description="A股行情分析", - agent_class=FakeLangGraphAgent, - capability=CapabilityDescriptor(markets=["A", "HK"]), - ) - ) - rt = AgentRuntime(registry=reg) - prompt = rt._get_supervisor_prompt() - assert "MarketAgent" in prompt - assert "A股行情分析" in prompt - assert "A, HK" in prompt - - -# =========================================================================== -# SessionMemoryService Tests -# =========================================================================== - - -class TestSessionMemoryService: - def _make_service(self): - from stock_datasource.services.session_memory_service import ( - SessionMemoryService, - ) - - return SessionMemoryService() - - def test_session_lifecycle(self): - svc = self._make_service() - sid = svc.make_session_id("TestAgent", "user1") - svc.touch_session(sid, "user1") - assert svc.active_session_count == 1 - svc.clear_session(sid) - assert svc.active_session_count == 0 - - def test_add_and_get_history(self): - svc = self._make_service() - svc.add_message("s1", "user", "Hello") - svc.add_message("s1", "assistant", "Hi") - h = svc.get_history("s1") - assert len(h) == 2 - assert h[0]["role"] == "user" - - def test_history_max_messages(self): - svc = self._make_service() - for i in range(40): - svc.add_message("s1", "user", f"msg {i}", max_messages=10) - h = svc.get_history("s1") - assert len(h) <= 10 - - def test_scoped_history(self): - svc = self._make_service() - for i in range(20): - svc.add_message("s1", "user", f"msg {i}") - h = svc.get_scoped_history("s1", max_messages=3) - assert len(h) == 3 - - def test_cache_ttl(self): - svc = self._make_service() - svc.set_cache("s1", "key", "val", ttl=1) - assert svc.get_cache("s1", "key") == "val" - time.sleep(1.1) - assert svc.get_cache("s1", "key") is None - - def test_preferences(self): - svc = self._make_service() - svc.save_preference("u1", "theme", "dark") - assert svc.get_preference("u1", "theme") == "dark" - prefs = svc.list_preferences("u1") - assert prefs["theme"] == "dark" - - def test_watchlist(self): - svc = self._make_service() - assert svc.add_to_watchlist("u1", "600519.SH") is True - assert svc.add_to_watchlist("u1", "600519.SH") is False - assert svc.get_watchlist("u1") == ["600519.SH"] - assert svc.remove_from_watchlist("u1", "600519.SH") is True - assert svc.get_watchlist("u1") == [] - - def test_stats_counters(self): - """Task 5.2: Verify observability counters.""" - svc = self._make_service() - svc.record_stat("custom_counter", 5) - assert svc._stats["custom_counter"] == 5 - svc.record_stat("custom_counter", 3) - assert svc._stats["custom_counter"] == 8 - - def test_stats_property(self): - """Task 5.2: Verify stats snapshot.""" - svc = self._make_service() - svc.touch_session("s1") - svc.set_cache("s1", "k1", "v1") - stats = svc.stats - assert stats["active_sessions"] == 1 - assert stats["total_cached_keys"] == 1 - assert "cache_hits" in stats - - def test_user_isolation(self): - """Task 2.3: Verify user isolation for preferences and watchlists.""" - svc = self._make_service() - svc.save_preference("u1", "theme", "dark") - svc.save_preference("u2", "theme", "light") - assert svc.get_preference("u1", "theme") == "dark" - assert svc.get_preference("u2", "theme") == "light" - svc.add_to_watchlist("u1", "600519.SH") - svc.add_to_watchlist("u2", "000001.SZ") - assert svc.get_watchlist("u1") == ["600519.SH"] - assert svc.get_watchlist("u2") == ["000001.SZ"] - - -# =========================================================================== -# SubAgentEnvelope Tests (Task 4.3) -# =========================================================================== - - -class TestSubAgentEnvelope: - def test_envelope_creation(self): - from stock_datasource.services.session_memory_service import SubAgentEnvelope - - env = SubAgentEnvelope( - agent_name="MarketAgent", session_id="s1", query="分析600519" - ) - assert env.agent_name == "MarketAgent" - assert env.success is True - assert env.response == "" - - def test_envelope_fill_response(self): - from stock_datasource.services.session_memory_service import SubAgentEnvelope - - env = SubAgentEnvelope(agent_name="Test", session_id="s1") - env.response = "Analysis complete" - env.success = True - env.metadata = {"duration_ms": 100} - assert env.response == "Analysis complete" - assert env.metadata["duration_ms"] == 100 - - def test_envelope_defaults(self): - from stock_datasource.services.session_memory_service import SubAgentEnvelope - - env = SubAgentEnvelope(agent_name="A", session_id="s") - assert env.user_id == "default" - assert env.scoped_history == [] - assert env.shared_state_keys == [] - assert env.tool_calls == [] - - -# =========================================================================== -# AgentRuntime Stats Tests (Task 5.2) -# =========================================================================== - - -class TestAgentRuntimeStats: - def test_runtime_stats_initial(self): - from stock_datasource.services.agent_registry import AgentRegistry - from stock_datasource.services.agent_runtime import AgentRuntime - - rt = AgentRuntime(registry=AgentRegistry()) - stats = rt.stats - assert stats["total_invocations"] == 0 - assert stats["concurrent_failures"] == 0 - assert stats["failure_rate"] == 0.0 - assert stats["cold_start_ms"] is None - - def test_runtime_stats_after_reset(self): - from stock_datasource.services.agent_registry import AgentRegistry - from stock_datasource.services.agent_runtime import AgentRuntime - - rt = AgentRuntime(registry=AgentRegistry()) - rt._cold_start_ms = 500.0 - rt.reset() - assert rt.stats["cold_start_ms"] is None - - -# =========================================================================== -# SkillRegistry Tests -# =========================================================================== - - -class TestSkillRegistry: - def _make_registry(self): - from stock_datasource.services.skill_registry import SkillRegistry - - return SkillRegistry() - - def test_register_and_list(self): - from stock_datasource.services.skill_registry import SkillDescriptor - - reg = self._make_registry() - reg.register(SkillDescriptor(name="s1", category="market", source="builtin")) - reg.register(SkillDescriptor(name="s2", category="report", source="mcp")) - assert reg.count == 2 - assert "s1" in reg.names - - def test_find_by_trigger(self): - from stock_datasource.services.skill_registry import SkillDescriptor - - reg = self._make_registry() - reg.register(SkillDescriptor(name="s1", triggers=["stock_query"])) - found = reg.find_by_trigger("stock_query") - assert len(found) == 1 - - def test_catalog(self): - from stock_datasource.services.skill_registry import SkillDescriptor - - reg = self._make_registry() - reg.register(SkillDescriptor(name="s1", description="test", category="c")) - cat = reg.to_catalog() - assert len(cat) == 1 - assert cat[0]["name"] == "s1" - - -# =========================================================================== -# Singletons -# =========================================================================== - - -class TestSingletons: - def test_agent_registry_singleton(self): - from stock_datasource.services.agent_registry import get_agent_registry - - assert get_agent_registry() is get_agent_registry() - - def test_execution_planner_singleton(self): - from stock_datasource.services.execution_planner import get_execution_planner - - assert get_execution_planner() is get_execution_planner() - - def test_agent_runtime_singleton(self): - from stock_datasource.services.agent_runtime import get_agent_runtime - - assert get_agent_runtime() is get_agent_runtime() - - def test_session_memory_singleton(self): - from stock_datasource.services.session_memory_service import ( - get_session_memory_service, - ) - - assert get_session_memory_service() is get_session_memory_service() - - def test_skill_registry_singleton(self): - from stock_datasource.services.skill_registry import get_skill_registry - - assert get_skill_registry() is get_skill_registry() diff --git a/tests/test_user_scoped_features.py b/tests/test_user_scoped_features.py index a95caa87..c34a803a 100644 --- a/tests/test_user_scoped_features.py +++ b/tests/test_user_scoped_features.py @@ -489,59 +489,6 @@ def test_get_user_portfolio_helper(self): assert "test" not in portfolio_y.get("positions", {}) -# ============ Enhanced Portfolio Agent Tests ============ - - -class TestEnhancedPortfolioAgent: - """Test EnhancedPortfolioAgent user context handling.""" - - def test_enhanced_portfolio_agent_has_execute_override(self): - """Test that EnhancedPortfolioAgent has execute() override.""" - import inspect - - from stock_datasource.agents.enhanced_portfolio_agent import ( - EnhancedPortfolioAgent, - ) - - # Check execute method is defined - execute_method = getattr(EnhancedPortfolioAgent, "execute", None) - assert execute_method is not None - - source = inspect.getsource(execute_method) - assert "user_id" in source or "_current_user_id" in source - - def test_enhanced_portfolio_agent_has_execute_stream_override(self): - """Test that EnhancedPortfolioAgent has execute_stream() override.""" - import inspect - - from stock_datasource.agents.enhanced_portfolio_agent import ( - EnhancedPortfolioAgent, - ) - - # Check execute_stream method is defined - execute_stream_method = getattr(EnhancedPortfolioAgent, "execute_stream", None) - assert execute_stream_method is not None - - source = inspect.getsource(execute_stream_method) - assert "user_id" in source or "_current_user_id" in source - - def test_enhanced_portfolio_agent_tools_use_instance_variable(self): - """Test that tools use self._current_user_id instead of parameter.""" - import inspect - - from stock_datasource.agents.enhanced_portfolio_agent import ( - EnhancedPortfolioAgent, - ) - - # Check analyze_portfolio_performance method at class level - method = getattr(EnhancedPortfolioAgent, "analyze_portfolio_performance", None) - if method: - source = inspect.getsource(method) - # Should use self._current_user_id, not user_id parameter - assert "_current_user_id" in source - # Should NOT have user_id as a method parameter with default value - assert 'user_id: str = "default_user"' not in source - if __name__ == "__main__": pytest.main([__file__, "-v"]) From fa352bc9d18f6baab33cbb3d412007edd33f26af Mon Sep 17 00:00:00 2001 From: felixhuwang Date: Wed, 20 May 2026 18:24:17 +0800 Subject: [PATCH 2/2] fix: restore real-db test compatibility Declare runtime SQL parsing dependency, make package imports lazy, restore legacy schema exports, and preserve task usernames through Redis-backed queue history. --- pyproject.toml | 1 + src/stock_datasource/__init__.py | 16 ++- src/stock_datasource/models/schemas.py | 48 ++++++- .../modules/datamanage/service.py | 3 +- src/stock_datasource/services/task_queue.py | 3 + tests/conftest.py | 127 ++---------------- 6 files changed, 74 insertions(+), 124 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cc8735ff..1f37d28c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "yfinance>=0.2.0", "finnhub-python>=2.4.27", "pyyaml>=6.0", + "sqlparse>=0.5.0", ] [project.optional-dependencies] diff --git a/src/stock_datasource/__init__.py b/src/stock_datasource/__init__.py index 1e53c0d6..2f186452 100644 --- a/src/stock_datasource/__init__.py +++ b/src/stock_datasource/__init__.py @@ -1,14 +1,22 @@ """Stock Data Source - Local financial database for A-share/HK stocks.""" +from __future__ import annotations + +import importlib + __version__ = "0.1.0" __author__ = "Stock Data Source Team" -from .models import * -from .services import * -from .utils import * - __all__ = [ "models", "services", "utils", ] + + +def __getattr__(name: str): + if name in __all__: + module = importlib.import_module(f".{name}", __name__) + globals()[name] = module + return module + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/stock_datasource/models/schemas.py b/src/stock_datasource/models/schemas.py index 9bfcc93a..4a4d1e22 100644 --- a/src/stock_datasource/models/schemas.py +++ b/src/stock_datasource/models/schemas.py @@ -19,6 +19,13 @@ class TableType(str, Enum): VW = "vw" # View +class MarketType(str, Enum): + """Market types.""" + + CN = "CN" + HK = "HK" + + class ColumnDefinition(BaseModel): """Column definition for dynamic schema.""" @@ -179,7 +186,46 @@ class TableSchema(BaseModel): # # NOTE: # - Most ODS/DIM schemas are loaded from plugins (schema.json) and created dynamically. -# - A small set of core FACT tables are also defined here because business APIs depend on them. +# - Compatibility schemas below are kept for legacy tests and callers. + +ODS_DAILY_SCHEMA = TableSchema( + table_name="ods_daily", + table_type=TableType.ODS, + columns=[ + ColumnDefinition(name="ts_code", data_type="String", nullable=False), + ColumnDefinition(name="trade_date", data_type="String", nullable=False), + ColumnDefinition(name="open", data_type="Float64"), + ColumnDefinition(name="high", data_type="Float64"), + ColumnDefinition(name="low", data_type="Float64"), + ColumnDefinition(name="close", data_type="Float64"), + ColumnDefinition(name="pre_close", data_type="Float64"), + ColumnDefinition(name="change", data_type="Float64"), + ColumnDefinition(name="pct_chg", data_type="Float64"), + ColumnDefinition(name="vol", data_type="Float64"), + ColumnDefinition(name="amount", data_type="Float64"), + ColumnDefinition(name="version", data_type="UInt32", nullable=False), + ColumnDefinition(name="_ingested_at", data_type="DateTime", nullable=False), + ], + partition_by="toYYYYMM(trade_date)", + order_by=["ts_code", "trade_date"], + comment="Legacy ODS daily schema compatibility alias", +) + +DIM_SECURITY_SCHEMA = TableSchema( + table_name="dim_security", + table_type=TableType.DIM, + columns=[ + ColumnDefinition(name="ts_code", data_type="String", nullable=False), + ColumnDefinition(name="market", data_type="String", nullable=False), + ColumnDefinition(name="ticker", data_type="String", nullable=False), + ColumnDefinition(name="name", data_type="String", nullable=False), + ColumnDefinition(name="list_date", data_type="String"), + ColumnDefinition(name="status", data_type="String"), + ColumnDefinition(name="exchange", data_type="String"), + ], + order_by=["ts_code"], + comment="Legacy security dimension schema compatibility alias", +) FACT_DAILY_BAR_SCHEMA = TableSchema( table_name="fact_daily_bar", diff --git a/src/stock_datasource/modules/datamanage/service.py b/src/stock_datasource/modules/datamanage/service.py index d693a556..8aafc516 100644 --- a/src/stock_datasource/modules/datamanage/service.py +++ b/src/stock_datasource/modules/datamanage/service.py @@ -1662,6 +1662,7 @@ def create_task( execution_id=execution_id, user_id=user_id, timeout_seconds=timeout_seconds, + username=username, ) except RedisUnavailableError as e: raise ValueError(f"Redis unavailable: {e}") @@ -1817,7 +1818,7 @@ def _parse_dt(value: str) -> datetime | None: completed_at=_parse_dt(str(task_data.get("completed_at", ""))), error_message=str(task_data.get("error_message", "")), user_id=str(task_data.get("user_id", "")) or None, - username=None, + username=str(task_data.get("username", "")) or None, ) def get_running_tasks(self) -> list[SyncTask]: diff --git a/src/stock_datasource/services/task_queue.py b/src/stock_datasource/services/task_queue.py index b144614f..5ad940a7 100644 --- a/src/stock_datasource/services/task_queue.py +++ b/src/stock_datasource/services/task_queue.py @@ -104,6 +104,7 @@ def enqueue( execution_id: str = None, user_id: str = None, timeout_seconds: int = None, + username: str = None, ) -> str | None: """Add a task to the queue. @@ -115,6 +116,7 @@ def enqueue( execution_id: Batch execution ID if part of a batch user_id: User who triggered the task timeout_seconds: Maximum task execution time in seconds (default: 3600) + username: Username of the user who triggered the task Returns: Task ID if successful, None otherwise @@ -139,6 +141,7 @@ def enqueue( "completed_at": "", "execution_id": execution_id or "", "user_id": user_id or "", + "username": username or "", "priority": priority.value, "attempt": 0, "max_attempts": 3, diff --git a/tests/conftest.py b/tests/conftest.py index 5fea45a3..d88082b6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,125 +1,16 @@ -"""Shared test configuration. +"""Shared pytest configuration. -Pre-mocks the entire stock_datasource package tree to avoid triggering -database connections, file logging, and other heavy imports during tests. -Individual test files then import specific modules they need. +Tests in this repository should exercise real project modules and, where data is +needed, the real configured database. Do not replace database-backed behavior +with synthetic mock data in this file. """ -import sys -import tempfile -import types from pathlib import Path -from unittest.mock import MagicMock -_test_log_dir = Path(tempfile.mkdtemp(prefix="sd_test_logs_")) -_src_dir = Path(__file__).parent.parent / "src" +import pytest -def _make_module(name, is_pkg=False): - """Create a mock module and register it in sys.modules.""" - mod = types.ModuleType(name) - if is_pkg: - mod.__path__ = [str(_src_dir / name.replace(".", "/"))] - sys.modules[name] = mod - return mod - - -def pytest_configure(config): - """Pre-mock the stock_datasource package tree.""" - if "stock_datasource" in sys.modules: - return - - # --- settings --- - class _S: - LOGS_DIR = _test_log_dir - LOG_LEVEL = "WARNING" - BASE_DIR = _src_dir / "stock_datasource" - DEBUG = False - MCP_JWT_PUBLIC_KEY_PATH = None - MCP_USAGE_REPORT_KEY = None - CLICKHOUSE_HOST = "localhost" - CLICKHOUSE_PORT = 9000 - CLICKHOUSE_DATABASE = "test" - CLICKHOUSE_USER = "test" - CLICKHOUSE_PASSWORD = "" - CLICKHOUSE_BACKUP_HOST = "" - CLICKHOUSE_BACKUP_PORT = 9000 - BACKUP_CLICKHOUSE_HOST = "" - BACKUP_CLICKHOUSE_PORT = 9000 - BACKUP_CLICKHOUSE_USER = "" - BACKUP_CLICKHOUSE_PASSWORD = "" - BACKUP_CLICKHOUSE_DATABASE = "" - AUTH_ADMIN_EMAILS = [] - - settings_obj = _S() - - # Top-level package — we need a REAL import, not a mock, so that - # sub-packages under stock_datasource.modules resolve correctly. - # But we need to prevent __init__.py from running its heavy imports. - # Strategy: create the package module manually, set __path__, and - # skip the __init__.py entirely. - sd = _make_module("stock_datasource", is_pkg=True) - - # config - cfg = _make_module("stock_datasource.config", is_pkg=True) - cfg_settings = _make_module("stock_datasource.config.settings") - cfg_settings.settings = settings_obj - cfg_settings.Settings = type(settings_obj) - cfg_settings.Optional = None # in case anything references it - - # config.runtime_config - _make_module("stock_datasource.config.runtime_config") - - # models - mock_db_client = MagicMock(name="db_client") - models = _make_module("stock_datasource.models", is_pkg=True) - models.db_client = mock_db_client - db_mod = _make_module("stock_datasource.models.database") - db_mod.db_client = mock_db_client - _make_module("stock_datasource.models.schemas") - - # utils - mock_logger = MagicMock() - utils = _make_module("stock_datasource.utils", is_pkg=True) - logger_mod = _make_module("stock_datasource.utils.logger") - logger_mod.logger = mock_logger - logger_mod.setup_logging = lambda: mock_logger - _make_module("stock_datasource.utils.extractor") - - # core - core = _make_module("stock_datasource.core", is_pkg=True) - bs = _make_module("stock_datasource.core.base_service") - bs.BaseService = type("BaseService", (), {}) - bs.db_client = mock_db_client - sg = _make_module("stock_datasource.core.service_generator") - sg.ServiceGenerator = MagicMock - - # services (mock the heavy sub-modules) - services = _make_module("stock_datasource.services", is_pkg=True) - _make_module("stock_datasource.services.ingestion") - # mcp_server is real — we'll let it import naturally - # http_server mock - _make_module("stock_datasource.services.http_server") - - # modules (real package — needs __path__ to resolve sub-packages) - modules = _make_module("stock_datasource.modules", is_pkg=True) - - # modules.mcp_api_key (real package for jwt_verifier) - mcp_api_key = _make_module("stock_datasource.modules.mcp_api_key", is_pkg=True) - - # modules.auth — do NOT mock auth.service (tests need AuthService) - _make_module("stock_datasource.modules.auth", is_pkg=True) - - # plugins (mock) - _make_module("stock_datasource.plugins", is_pkg=True) - - _test_log_dir.mkdir(parents=True, exist_ok=True) - - -def pytest_unconfigure(config): - import shutil - - try: - shutil.rmtree(_test_log_dir, ignore_errors=True) - except Exception: - pass +@pytest.fixture(scope="session") +def project_root() -> Path: + """Return the repository root for tests that need file paths.""" + return Path(__file__).resolve().parents[1]