mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
3451 lines
139 KiB
Python
3451 lines
139 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Tests for agent-mode pipeline integration.
|
|
|
|
Covers:
|
|
- Config: agent_mode, agent_max_steps, agent_skills fields
|
|
- _analyze_with_agent method
|
|
- _agent_result_to_analysis_result conversion
|
|
- YAML strategy loading (load_builtin_strategies)
|
|
"""
|
|
|
|
import json
|
|
import importlib
|
|
import types
|
|
import unittest
|
|
import sys
|
|
import os
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch, PropertyMock
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Optional, Dict, Any
|
|
|
|
from tests.litellm_stub import ensure_litellm_stub
|
|
|
|
ensure_litellm_stub()
|
|
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
|
|
|
|
def _builtin_strategy_names() -> set[str]:
|
|
strategies_dir = Path(__file__).resolve().parent.parent / "strategies"
|
|
return {path.stem for path in strategies_dir.glob("*.yaml")}
|
|
|
|
|
|
# ============================================================
|
|
# Config tests
|
|
# ============================================================
|
|
|
|
class TestAgentConfig(unittest.TestCase):
|
|
"""Test agent-related configuration fields load correctly."""
|
|
|
|
@patch.dict(os.environ, {}, clear=True)
|
|
@patch('src.config.load_dotenv')
|
|
def test_default_agent_config(self, _mock_dotenv):
|
|
"""Agent mode should be disabled by default."""
|
|
from src.config import AGENT_MAX_STEPS_DEFAULT, Config
|
|
Config._instance = None
|
|
config = Config._load_from_env()
|
|
self.assertEqual(config.agent_litellm_model, "")
|
|
self.assertFalse(config.agent_mode)
|
|
self.assertEqual(config.agent_max_steps, AGENT_MAX_STEPS_DEFAULT)
|
|
self.assertEqual(config.agent_skills, [])
|
|
self.assertEqual(config.agent_skill_concurrency, 3)
|
|
|
|
@patch.dict(os.environ, {
|
|
'AGENT_MODE': 'true',
|
|
'AGENT_MAX_STEPS': '15',
|
|
'AGENT_SKILLS': 'dragon_head,shrink_pullback,volume_breakout',
|
|
'AGENT_SKILL_CONCURRENCY': '4',
|
|
}, clear=True)
|
|
def test_agent_config_from_env(self):
|
|
"""Agent config should be loaded from environment."""
|
|
from src.config import Config
|
|
Config._instance = None
|
|
config = Config._load_from_env()
|
|
self.assertTrue(config.agent_mode)
|
|
self.assertEqual(config.agent_max_steps, 15)
|
|
self.assertEqual(config.agent_skills, ['dragon_head', 'shrink_pullback', 'volume_breakout'])
|
|
self.assertEqual(config.agent_skill_concurrency, 4)
|
|
|
|
@patch.dict(os.environ, {'AGENT_SKILL_CONCURRENCY': '9'}, clear=True)
|
|
def test_agent_skill_concurrency_is_clamped(self):
|
|
"""Agent skill concurrency should stay within the supported 1-4 range."""
|
|
from src.config import Config
|
|
Config._instance = None
|
|
config = Config._load_from_env()
|
|
self.assertEqual(config.agent_skill_concurrency, 4)
|
|
|
|
@patch.dict(os.environ, {'AGENT_MODE': 'false'}, clear=True)
|
|
def test_agent_mode_disabled(self):
|
|
"""Explicitly disabled agent mode."""
|
|
from src.config import Config
|
|
Config._instance = None
|
|
config = Config._load_from_env()
|
|
self.assertFalse(config.agent_mode)
|
|
|
|
@patch.dict(os.environ, {'AGENT_SKILLS': ''}, clear=True)
|
|
def test_empty_skills_list(self):
|
|
"""Empty AGENT_SKILLS should produce empty list."""
|
|
from src.config import Config
|
|
Config._instance = None
|
|
config = Config._load_from_env()
|
|
self.assertEqual(config.agent_skills, [])
|
|
|
|
@patch.dict(os.environ, {'AGENT_SKILLS': ' dragon_head , shrink_pullback '}, clear=True)
|
|
def test_skills_whitespace_handling(self):
|
|
"""Skills should have whitespace trimmed."""
|
|
from src.config import Config
|
|
Config._instance = None
|
|
config = Config._load_from_env()
|
|
self.assertEqual(config.agent_skills, ['dragon_head', 'shrink_pullback'])
|
|
|
|
@patch.dict(os.environ, {'AGENT_LITELLM_MODEL': 'gpt-4o-mini'}, clear=True)
|
|
def test_agent_is_available_when_agent_primary_model_is_configured(self):
|
|
"""Agent availability auto-detection should use effective Agent primary model."""
|
|
from src.config import Config
|
|
Config._instance = None
|
|
config = Config._load_from_env()
|
|
self.assertEqual(config.agent_litellm_model, 'openai/gpt-4o-mini')
|
|
self.assertTrue(config.is_agent_available())
|
|
|
|
def test_agent_models_to_try_inherit_legacy_provider_models(self):
|
|
"""Legacy provider key/model envs should still produce a non-empty Agent model try list."""
|
|
from src.config import Config, get_effective_agent_models_to_try
|
|
|
|
test_cases = [
|
|
(
|
|
{
|
|
"GEMINI_API_KEY": "gemini-test-key",
|
|
"GEMINI_MODEL": "gemini-2.5-flash",
|
|
"AGENT_LITELLM_MODEL": "",
|
|
},
|
|
["gemini/gemini-2.5-flash", "gemini/gemini-3-flash-preview"],
|
|
),
|
|
(
|
|
{
|
|
"OPENAI_API_KEY": "sk-test-value",
|
|
"OPENAI_MODEL": "gpt-4o-mini",
|
|
"AGENT_LITELLM_MODEL": "",
|
|
},
|
|
["openai/gpt-4o-mini"],
|
|
),
|
|
(
|
|
{
|
|
"ANTHROPIC_API_KEY": "anthropic-test-key",
|
|
"ANTHROPIC_MODEL": "claude-3-5-sonnet-20241022",
|
|
"AGENT_LITELLM_MODEL": "",
|
|
},
|
|
["anthropic/claude-3-5-sonnet-20241022"],
|
|
),
|
|
]
|
|
|
|
with patch("src.config.setup_env"), patch.object(Config, "_parse_litellm_yaml", return_value=[]):
|
|
for env, expected_models in test_cases:
|
|
with self.subTest(expected_models=expected_models), patch.dict(os.environ, env, clear=True):
|
|
Config._instance = None
|
|
config = Config._load_from_env()
|
|
self.assertEqual(get_effective_agent_models_to_try(config), expected_models)
|
|
|
|
Config._instance = None
|
|
|
|
def test_build_agent_executor_does_not_mutate_llm_route_config(self) -> None:
|
|
"""Agent factory should not rewrite model/base_url/runtime routing fields."""
|
|
provided_config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=["bull_trend"],
|
|
agent_max_steps="10",
|
|
agent_orchestrator_timeout_s="120",
|
|
litellm_model="openai/gpt-5",
|
|
agent_litellm_model="anthropic/claude-3-7-sonnet-20250219",
|
|
openai_base_url="https://api.openai.com/v1",
|
|
)
|
|
captured: Dict[str, Any] = {}
|
|
|
|
def _mock_llm_adapter(cfg):
|
|
captured["cfg"] = cfg
|
|
return MagicMock()
|
|
|
|
fake_llm_module = types.ModuleType("src.agent.llm_adapter")
|
|
fake_llm_module.LLMToolAdapter = _mock_llm_adapter
|
|
|
|
fake_executor_module = types.ModuleType("src.agent.executor")
|
|
fake_executor_cls = MagicMock(return_value=MagicMock())
|
|
fake_executor_module.AgentExecutor = fake_executor_cls
|
|
|
|
skill_manager = MagicMock()
|
|
skill_manager.list_skills.return_value = [
|
|
SimpleNamespace(
|
|
name="bull_trend",
|
|
display_name="bull_trend",
|
|
description="bull_trend desc",
|
|
instructions="测试指令",
|
|
default_active=True,
|
|
default_router=True,
|
|
default_priority=100,
|
|
user_invocable=True,
|
|
source="builtin",
|
|
)
|
|
]
|
|
skill_manager.get_skill_instructions.return_value = "测试指令"
|
|
|
|
with patch.dict(sys.modules, {
|
|
"litellm": MagicMock(),
|
|
"src.agent.llm_adapter": fake_llm_module,
|
|
"src.agent.executor": fake_executor_module,
|
|
}):
|
|
factory_module = importlib.import_module("src.agent.factory")
|
|
with patch.object(factory_module, "get_skill_manager", return_value=skill_manager), \
|
|
patch.object(factory_module, "get_tool_registry", return_value=MagicMock()):
|
|
factory_module.build_agent_executor(provided_config)
|
|
|
|
adapter_cfg = captured.get("cfg")
|
|
self.assertIs(adapter_cfg, provided_config)
|
|
self.assertEqual(provided_config.agent_max_steps, "10")
|
|
self.assertEqual(provided_config.agent_orchestrator_timeout_s, "120")
|
|
self.assertEqual(provided_config.litellm_model, "openai/gpt-5")
|
|
self.assertEqual(provided_config.agent_litellm_model, "anthropic/claude-3-7-sonnet-20250219")
|
|
self.assertEqual(provided_config.openai_base_url, "https://api.openai.com/v1")
|
|
fake_executor_cls.assert_called_once()
|
|
kwargs = fake_executor_cls.call_args.kwargs
|
|
self.assertEqual(kwargs["max_steps"], 10)
|
|
self.assertEqual(kwargs["timeout_seconds"], 120)
|
|
|
|
def test_build_agent_executor_multi_arch_does_not_mutate_llm_route_config(self) -> None:
|
|
"""Multi-arch path should keep provider/base_url/runtime fields unchanged."""
|
|
provided_config = SimpleNamespace(
|
|
agent_arch="multi",
|
|
agent_skills=["bull_trend"],
|
|
agent_max_steps="10",
|
|
agent_orchestrator_timeout_s="120",
|
|
litellm_model="openai/gpt-5",
|
|
agent_litellm_model="anthropic/claude-3-7-sonnet-20250219",
|
|
openai_base_url="https://api.openai.com/v1",
|
|
agent_orchestrator_mode="standard",
|
|
)
|
|
captured: Dict[str, Any] = {}
|
|
|
|
def _mock_llm_adapter(cfg):
|
|
captured["cfg"] = cfg
|
|
return MagicMock()
|
|
|
|
fake_llm_module = types.ModuleType("src.agent.llm_adapter")
|
|
fake_llm_module.LLMToolAdapter = _mock_llm_adapter
|
|
|
|
fake_orchestrator_module = types.ModuleType("src.agent.orchestrator")
|
|
fake_orchestrator_cls = MagicMock(return_value=MagicMock())
|
|
fake_orchestrator_module.AgentOrchestrator = fake_orchestrator_cls
|
|
|
|
skill_manager = MagicMock()
|
|
skill_manager.list_skills.return_value = [
|
|
SimpleNamespace(
|
|
name="bull_trend",
|
|
display_name="bull_trend",
|
|
description="bull_trend desc",
|
|
instructions="测试指令",
|
|
default_active=True,
|
|
default_router=True,
|
|
default_priority=100,
|
|
user_invocable=True,
|
|
source="builtin",
|
|
)
|
|
]
|
|
skill_manager.get_skill_instructions.return_value = "测试指令"
|
|
|
|
with patch.dict(sys.modules, {
|
|
"litellm": MagicMock(),
|
|
"src.agent.llm_adapter": fake_llm_module,
|
|
"src.agent.orchestrator": fake_orchestrator_module,
|
|
"src.agent.executor": MagicMock(),
|
|
}):
|
|
factory_module = importlib.import_module("src.agent.factory")
|
|
with patch.object(factory_module, "get_skill_manager", return_value=skill_manager), \
|
|
patch.object(factory_module, "get_tool_registry", return_value=MagicMock()):
|
|
factory_module.build_agent_executor(provided_config)
|
|
|
|
adapter_cfg = captured.get("cfg")
|
|
self.assertIs(adapter_cfg, provided_config)
|
|
self.assertEqual(provided_config.agent_max_steps, "10")
|
|
self.assertEqual(provided_config.agent_orchestrator_timeout_s, "120")
|
|
self.assertEqual(provided_config.litellm_model, "openai/gpt-5")
|
|
self.assertEqual(provided_config.agent_litellm_model, "anthropic/claude-3-7-sonnet-20250219")
|
|
self.assertEqual(provided_config.openai_base_url, "https://api.openai.com/v1")
|
|
fake_orchestrator_cls.assert_called_once()
|
|
kwargs = fake_orchestrator_cls.call_args.kwargs
|
|
self.assertEqual(kwargs["max_steps"], 10)
|
|
self.assertIs(kwargs["config"], provided_config)
|
|
|
|
def test_invalid_numeric_config_values_fallback_to_defaults_with_warning(self) -> None:
|
|
"""Invalid agent_max_steps / agent_orchestrator_timeout_s should fallback and emit warning."""
|
|
provided_config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=["bull_trend"],
|
|
agent_max_steps="invalid-steps",
|
|
agent_orchestrator_timeout_s="invalid-timeout",
|
|
litellm_model="openai/gpt-5",
|
|
agent_litellm_model="anthropic/claude-3-7-sonnet-20250219",
|
|
openai_base_url="https://api.openai.com/v1",
|
|
)
|
|
captured: Dict[str, Any] = {}
|
|
|
|
def _mock_llm_adapter(cfg):
|
|
captured["cfg"] = cfg
|
|
return MagicMock()
|
|
|
|
fake_llm_module = types.ModuleType("src.agent.llm_adapter")
|
|
fake_llm_module.LLMToolAdapter = _mock_llm_adapter
|
|
|
|
fake_executor_module = types.ModuleType("src.agent.executor")
|
|
fake_executor_cls = MagicMock(return_value=MagicMock())
|
|
fake_executor_module.AgentExecutor = fake_executor_cls
|
|
|
|
skill_manager = MagicMock()
|
|
skill_manager.list_skills.return_value = [
|
|
SimpleNamespace(
|
|
name="bull_trend",
|
|
display_name="bull_trend",
|
|
description="bull_trend desc",
|
|
instructions="测试指令",
|
|
default_active=True,
|
|
default_router=True,
|
|
default_priority=100,
|
|
user_invocable=True,
|
|
source="builtin",
|
|
)
|
|
]
|
|
skill_manager.get_skill_instructions.return_value = "测试指令"
|
|
|
|
with self.assertLogs("src.agent.factory", level="WARNING") as logs:
|
|
with patch.dict(sys.modules, {
|
|
"litellm": MagicMock(),
|
|
"src.agent.llm_adapter": fake_llm_module,
|
|
"src.agent.executor": fake_executor_module,
|
|
}):
|
|
factory_module = importlib.import_module("src.agent.factory")
|
|
with patch.object(factory_module, "get_skill_manager", return_value=skill_manager), \
|
|
patch.object(factory_module, "get_tool_registry", return_value=MagicMock()):
|
|
factory_module.build_agent_executor(provided_config)
|
|
|
|
adapter_cfg = captured.get("cfg")
|
|
self.assertIs(adapter_cfg, provided_config)
|
|
self.assertEqual(provided_config.litellm_model, "openai/gpt-5")
|
|
self.assertEqual(provided_config.agent_litellm_model, "anthropic/claude-3-7-sonnet-20250219")
|
|
self.assertEqual(provided_config.openai_base_url, "https://api.openai.com/v1")
|
|
|
|
log_output = "\n".join(logs.output)
|
|
self.assertIn("[AgentFactory] Invalid value for agent_max_steps", log_output)
|
|
self.assertIn("[AgentFactory] Invalid value for agent_orchestrator_timeout_s", log_output)
|
|
|
|
kwargs = fake_executor_cls.call_args.kwargs
|
|
from src.config import AGENT_MAX_STEPS_DEFAULT
|
|
self.assertEqual(kwargs["max_steps"], AGENT_MAX_STEPS_DEFAULT)
|
|
self.assertEqual(kwargs["timeout_seconds"], 0)
|
|
|
|
|
|
class TestAgentFactorySkillBaseline(unittest.TestCase):
|
|
"""Ensure explicit skill selection does not silently re-apply the default bull-trend baseline."""
|
|
|
|
@staticmethod
|
|
def _make_skill(
|
|
name: str,
|
|
*,
|
|
default_active: bool = False,
|
|
default_priority: int = 100,
|
|
source: str = "builtin",
|
|
):
|
|
return SimpleNamespace(
|
|
name=name,
|
|
display_name=name,
|
|
description=f"{name} desc",
|
|
instructions=f"{name} instructions",
|
|
default_active=default_active,
|
|
default_router=default_active,
|
|
default_priority=default_priority,
|
|
user_invocable=True,
|
|
source=source,
|
|
)
|
|
|
|
def _run_factory_case(self, config, *, request_skills, skill_catalog, instructions):
|
|
skill_manager = MagicMock()
|
|
skill_manager.list_skills.return_value = skill_catalog
|
|
skill_manager.get_skill_instructions.return_value = instructions
|
|
|
|
fake_llm_module = types.ModuleType("src.agent.llm_adapter")
|
|
fake_llm_module.LLMToolAdapter = MagicMock(return_value=MagicMock())
|
|
fake_executor_module = types.ModuleType("src.agent.executor")
|
|
fake_executor_cls = MagicMock(return_value=MagicMock())
|
|
fake_executor_module.AgentExecutor = fake_executor_cls
|
|
|
|
with patch.dict(sys.modules, {
|
|
"litellm": MagicMock(),
|
|
"src.agent.llm_adapter": fake_llm_module,
|
|
"src.agent.executor": fake_executor_module,
|
|
}):
|
|
factory_module = importlib.import_module("src.agent.factory")
|
|
|
|
with patch.object(factory_module, "get_skill_manager", return_value=skill_manager), \
|
|
patch.object(factory_module, "get_tool_registry", return_value=MagicMock()):
|
|
factory_module.build_agent_executor(config, skills=request_skills)
|
|
|
|
return fake_executor_cls.call_args.kwargs, skill_manager
|
|
|
|
def test_explicit_request_disables_default_skill_policy(self):
|
|
config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=[],
|
|
agent_max_steps=10,
|
|
agent_orchestrator_timeout_s=600,
|
|
)
|
|
kwargs, skill_manager = self._run_factory_case(
|
|
config,
|
|
request_skills=["chan_theory"],
|
|
skill_catalog=[
|
|
self._make_skill("bull_trend", default_active=True, default_priority=10),
|
|
self._make_skill("chan_theory", default_priority=20),
|
|
],
|
|
instructions="chan_theory instructions",
|
|
)
|
|
|
|
self.assertEqual(kwargs["default_skill_policy"], "")
|
|
self.assertFalse(kwargs["use_legacy_default_prompt"])
|
|
skill_manager.activate.assert_called_once_with(["chan_theory"])
|
|
|
|
def test_configured_skills_disable_default_skill_policy(self):
|
|
config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=["wave_theory"],
|
|
agent_max_steps=10,
|
|
agent_orchestrator_timeout_s=600,
|
|
)
|
|
kwargs, skill_manager = self._run_factory_case(
|
|
config,
|
|
request_skills=None,
|
|
skill_catalog=[
|
|
self._make_skill("bull_trend", default_active=True, default_priority=10),
|
|
self._make_skill("wave_theory", default_priority=20),
|
|
],
|
|
instructions="wave_theory instructions",
|
|
)
|
|
|
|
self.assertEqual(kwargs["default_skill_policy"], "")
|
|
self.assertFalse(kwargs["use_legacy_default_prompt"])
|
|
skill_manager.activate.assert_called_once_with(["wave_theory"])
|
|
|
|
def test_implicit_default_run_keeps_default_skill_policy(self):
|
|
config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=[],
|
|
agent_max_steps=10,
|
|
agent_orchestrator_timeout_s=600,
|
|
)
|
|
kwargs, skill_manager = self._run_factory_case(
|
|
config,
|
|
request_skills=None,
|
|
skill_catalog=[self._make_skill("bull_trend", default_active=True, default_priority=10)],
|
|
instructions="bull_trend instructions",
|
|
)
|
|
|
|
self.assertIn("严进策略", kwargs["default_skill_policy"])
|
|
self.assertTrue(kwargs["use_legacy_default_prompt"])
|
|
skill_manager.activate.assert_called_once_with(["bull_trend"])
|
|
|
|
def test_explicit_empty_request_falls_back_to_primary_default_skill(self):
|
|
config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=[],
|
|
agent_max_steps=10,
|
|
agent_orchestrator_timeout_s=600,
|
|
)
|
|
kwargs, skill_manager = self._run_factory_case(
|
|
config,
|
|
request_skills=[],
|
|
skill_catalog=[
|
|
self._make_skill("bull_trend", default_active=True, default_priority=10),
|
|
self._make_skill("chan_theory", default_priority=20),
|
|
],
|
|
instructions="bull_trend instructions",
|
|
)
|
|
|
|
self.assertIn("严进策略", kwargs["default_skill_policy"])
|
|
self.assertTrue(kwargs["use_legacy_default_prompt"])
|
|
skill_manager.activate.assert_called_once_with(["bull_trend"])
|
|
|
|
def test_explicit_primary_default_skill_uses_skill_aware_prompt_mode(self):
|
|
config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=[],
|
|
agent_max_steps=10,
|
|
agent_orchestrator_timeout_s=600,
|
|
)
|
|
kwargs, skill_manager = self._run_factory_case(
|
|
config,
|
|
request_skills=["bull_trend"],
|
|
skill_catalog=[
|
|
self._make_skill("bull_trend", default_active=True, default_priority=10),
|
|
self._make_skill("chan_theory", default_priority=20),
|
|
],
|
|
instructions="bull_trend instructions",
|
|
)
|
|
|
|
self.assertEqual(kwargs["default_skill_policy"], "")
|
|
self.assertFalse(kwargs["use_legacy_default_prompt"])
|
|
skill_manager.activate.assert_called_once_with(["bull_trend"])
|
|
|
|
def test_invalid_configured_skills_fall_back_to_primary_default_skill(self):
|
|
config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=["missing_skill"],
|
|
agent_max_steps=10,
|
|
agent_orchestrator_timeout_s=600,
|
|
)
|
|
kwargs, skill_manager = self._run_factory_case(
|
|
config,
|
|
request_skills=None,
|
|
skill_catalog=[
|
|
self._make_skill("bull_trend", default_active=True, default_priority=10),
|
|
self._make_skill("chan_theory", default_priority=20),
|
|
],
|
|
instructions="bull_trend instructions",
|
|
)
|
|
|
|
self.assertIn("严进策略", kwargs["default_skill_policy"])
|
|
self.assertTrue(kwargs["use_legacy_default_prompt"])
|
|
skill_manager.activate.assert_called_once_with(["bull_trend"])
|
|
|
|
def test_custom_default_skill_does_not_use_legacy_bull_prompt(self):
|
|
config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=[],
|
|
agent_max_steps=10,
|
|
agent_orchestrator_timeout_s=600,
|
|
)
|
|
kwargs, skill_manager = self._run_factory_case(
|
|
config,
|
|
request_skills=None,
|
|
skill_catalog=[
|
|
self._make_skill("custom_default", default_active=True, default_priority=10),
|
|
self._make_skill("bull_trend", default_priority=20),
|
|
],
|
|
instructions="custom_default instructions",
|
|
)
|
|
|
|
self.assertEqual(kwargs["default_skill_policy"], "")
|
|
self.assertFalse(kwargs["use_legacy_default_prompt"])
|
|
skill_manager.activate.assert_called_once_with(["custom_default"])
|
|
|
|
def test_custom_bull_trend_override_does_not_use_legacy_prompt(self):
|
|
config = SimpleNamespace(
|
|
agent_arch="single",
|
|
agent_skills=[],
|
|
agent_max_steps=10,
|
|
agent_orchestrator_timeout_s=600,
|
|
)
|
|
kwargs, skill_manager = self._run_factory_case(
|
|
config,
|
|
request_skills=None,
|
|
skill_catalog=[
|
|
self._make_skill(
|
|
"bull_trend",
|
|
default_active=True,
|
|
default_priority=10,
|
|
source="/tmp/custom-skills/bull_trend.yaml",
|
|
),
|
|
],
|
|
instructions="custom bull_trend instructions",
|
|
)
|
|
|
|
self.assertEqual(kwargs["default_skill_policy"], "")
|
|
self.assertFalse(kwargs["use_legacy_default_prompt"])
|
|
skill_manager.activate.assert_called_once_with(["bull_trend"])
|
|
|
|
|
|
# ============================================================
|
|
# AgentResult to AnalysisResult conversion
|
|
# ============================================================
|
|
|
|
class TestAgentResultConversion(unittest.TestCase):
|
|
"""Test _agent_result_to_analysis_result without spinning up the full pipeline."""
|
|
|
|
def _make_pipeline(self):
|
|
"""Create a minimal StockAnalysisPipeline with mocked dependencies."""
|
|
# We need to import and mock carefully to avoid touching real services
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'):
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_orchestrator_timeout_s = 0
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
return pipeline
|
|
|
|
def test_convert_success_dashboard(self):
|
|
"""Successful AgentResult should produce a valid AnalysisResult."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
dashboard = {
|
|
"stock_name": "贵州茅台",
|
|
"sentiment_score": 80,
|
|
"trend_prediction": "看多",
|
|
"operation_advice": "持有",
|
|
"decision_type": "hold",
|
|
"confidence_level": "高",
|
|
"dashboard": {"core_conclusion": {"one_sentence": "看好"}},
|
|
"analysis_summary": "Testing",
|
|
"key_points": "Strong",
|
|
"risk_warning": "High valuation",
|
|
"buy_reason": "Leader",
|
|
"trend_analysis": "Upward",
|
|
"technical_analysis": "Bullish MACD",
|
|
"ma_analysis": "Golden cross",
|
|
"volume_analysis": "Healthy volume",
|
|
"pattern_analysis": "Cup and handle",
|
|
"fundamental_analysis": "Strong revenue",
|
|
"sector_position": "Liquor leader",
|
|
"company_highlights": "Brand value",
|
|
"news_summary": "Recent news",
|
|
"market_sentiment": "Optimistic",
|
|
"hot_topics": "Baijiu",
|
|
"short_term_outlook": "Bullish",
|
|
"medium_term_outlook": "Stable",
|
|
}
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content=json.dumps(dashboard),
|
|
dashboard=dashboard,
|
|
tool_calls_log=[{"step": 1, "tool": "echo", "success": True}],
|
|
total_steps=3,
|
|
total_tokens=500,
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q123"
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
self.assertTrue(result.success)
|
|
self.assertEqual(result.code, "600519")
|
|
self.assertEqual(result.name, "贵州茅台")
|
|
self.assertEqual(result.sentiment_score, 80)
|
|
self.assertEqual(result.trend_prediction, "看多")
|
|
self.assertEqual(result.decision_type, "hold")
|
|
self.assertIn("agent:gemini", result.data_sources)
|
|
self.assertIsNotNone(result.dashboard)
|
|
|
|
def test_convert_preserves_top_level_phase_decision_with_nested_dashboard(self):
|
|
"""Agent top-level phase_decision should survive nested dashboard unwrapping."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
dashboard = {
|
|
"stock_name": "贵州茅台",
|
|
"sentiment_score": 80,
|
|
"trend_prediction": "看多",
|
|
"operation_advice": "持有",
|
|
"decision_type": "hold",
|
|
"confidence_level": "中",
|
|
"phase_decision": {
|
|
"phase_context": {"phase": "intraday", "market": "cn"},
|
|
"action_window": "盘中跟踪",
|
|
"immediate_action": "等待确认",
|
|
"watch_conditions": ["放量突破"],
|
|
"next_check_time": "14:30",
|
|
"confidence_reason": "等待确认",
|
|
"data_limitations": [],
|
|
},
|
|
"dashboard": {"core_conclusion": {"one_sentence": "看好"}},
|
|
"analysis_summary": "Testing",
|
|
}
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content=json.dumps(dashboard),
|
|
dashboard=dashboard,
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q-phase"
|
|
)
|
|
|
|
self.assertEqual(result.dashboard["phase_decision"]["phase_context"]["phase"], "intraday")
|
|
self.assertEqual(result.dashboard["phase_decision"]["watch_conditions"], ["放量突破"])
|
|
|
|
def test_convert_failed_dashboard(self):
|
|
"""Failed AgentResult should produce a minimal AnalysisResult."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=False,
|
|
content="",
|
|
dashboard=None,
|
|
error="Max steps exceeded",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q123"
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
self.assertFalse(result.success)
|
|
self.assertEqual(result.sentiment_score, 50)
|
|
self.assertEqual(result.operation_advice, "观望")
|
|
self.assertIn("Max steps exceeded", result.error_message)
|
|
|
|
def test_convert_agent_dashboard_preserves_explicit_action(self):
|
|
"""Explicit Agent action is display taxonomy; decision_type remains the legacy bridge."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"sentiment_score": 52,
|
|
"trend_prediction": "震荡",
|
|
"operation_advice": "持有观察",
|
|
"decision_type": "hold",
|
|
"action": "watch",
|
|
"analysis_summary": "等待确认",
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q-action"
|
|
)
|
|
raw_result = result.to_dict()
|
|
|
|
self.assertEqual(result.operation_advice, "持有观察")
|
|
self.assertEqual(result.decision_type, "hold")
|
|
self.assertEqual(result.action, "watch")
|
|
self.assertEqual(result.action_label, "观望")
|
|
self.assertEqual(raw_result["action"], "watch")
|
|
self.assertEqual(raw_result["action_label"], "观望")
|
|
|
|
def test_final_action_refresh_preserves_explicit_action_when_advice_is_unchanged(self):
|
|
"""Pre-save refresh must not overwrite an explicit Agent action without a final advice rewrite."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"sentiment_score": 52,
|
|
"trend_prediction": "震荡",
|
|
"operation_advice": "持有观察",
|
|
"decision_type": "hold",
|
|
"action": "watch",
|
|
"analysis_summary": "等待确认",
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q-action-preserve"
|
|
)
|
|
previous_operation_advice = result.operation_advice
|
|
|
|
pipeline._refresh_decision_action_for_final_result(
|
|
result,
|
|
report_type=ReportType.SIMPLE.value,
|
|
previous_operation_advice=previous_operation_advice,
|
|
)
|
|
raw_result = result.to_dict()
|
|
|
|
self.assertEqual(result.operation_advice, "持有观察")
|
|
self.assertEqual(result.action, "watch")
|
|
self.assertEqual(result.action_label, "观望")
|
|
self.assertEqual(raw_result["action"], "watch")
|
|
self.assertEqual(raw_result["action_label"], "观望")
|
|
|
|
def test_final_action_refresh_ignores_stale_pre_guardrail_action(self):
|
|
"""Post-processing can rewrite advice; refreshed action must follow the final advice."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"sentiment_score": 68,
|
|
"trend_prediction": "震荡",
|
|
"operation_advice": "买入",
|
|
"decision_type": "buy",
|
|
"action": "buy",
|
|
"analysis_summary": "等待确认",
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q-action-refresh"
|
|
)
|
|
previous_operation_advice = result.operation_advice
|
|
result.operation_advice = "持有观察"
|
|
result.decision_type = "hold"
|
|
|
|
pipeline._refresh_decision_action_for_final_result(
|
|
result,
|
|
report_type=ReportType.SIMPLE.value,
|
|
previous_operation_advice=previous_operation_advice,
|
|
)
|
|
|
|
self.assertEqual(result.action, "hold")
|
|
self.assertEqual(result.action_label, "持有")
|
|
|
|
def test_final_action_refresh_keeps_ambiguous_action_fail_closed(self):
|
|
"""Pipeline refresh must not invent an action outside the shared resolver."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.analyzer import AnalysisResult
|
|
from src.enums import ReportType
|
|
from src.services.decision_signal_extractor import (
|
|
build_decision_signal_payload_from_report,
|
|
)
|
|
|
|
result = AnalysisResult(
|
|
code="600519",
|
|
name="贵州茅台",
|
|
sentiment_score=82,
|
|
trend_prediction="看多",
|
|
operation_advice="买盘增强,继续观察",
|
|
decision_type="buy",
|
|
action=None,
|
|
)
|
|
|
|
direct_payload = build_decision_signal_payload_from_report(
|
|
result,
|
|
trace_id="q-ambiguous-direct",
|
|
query_source="test",
|
|
report_type="simple",
|
|
profile_source="auto_default",
|
|
)
|
|
pipeline._refresh_decision_action_for_final_result(
|
|
result,
|
|
report_type=ReportType.SIMPLE.value,
|
|
previous_operation_advice=result.operation_advice,
|
|
)
|
|
refreshed_payload = build_decision_signal_payload_from_report(
|
|
result,
|
|
trace_id="q-ambiguous-refreshed",
|
|
query_source="test",
|
|
report_type="simple",
|
|
profile_source="auto_default",
|
|
)
|
|
|
|
self.assertIsNone(result.action)
|
|
self.assertIsNone(result.action_label)
|
|
self.assertIsNone(direct_payload)
|
|
self.assertIsNone(refreshed_payload)
|
|
|
|
def test_convert_invalid_dashboard_preserves_local_trend_result(self):
|
|
"""Invalid Agent dashboard should not erase already-computed trend data."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="LLM returned text but no dashboard JSON",
|
|
dashboard=None,
|
|
provider="ollama",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.BUY,
|
|
signal_score=64,
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-trend-fallback",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
self.assertTrue(result.success)
|
|
self.assertEqual(result.sentiment_score, 64)
|
|
self.assertEqual(result.trend_prediction, "多头排列")
|
|
self.assertEqual(result.operation_advice, "买入")
|
|
self.assertEqual(result.decision_type, "buy")
|
|
self.assertIn("trend:fallback", result.data_sources)
|
|
|
|
def test_convert_empty_dashboard_backfills_local_trend_dashboard(self):
|
|
"""Empty Agent dashboard should still produce an integrity-ready local fallback dashboard."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.analyzer import check_content_integrity
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={},
|
|
provider="gemini",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.BUY,
|
|
signal_score=68,
|
|
support_levels=[112.3],
|
|
risk_factors=["跌破 MA20 需止损"],
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-empty-dashboard",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
ok, missing = check_content_integrity(result)
|
|
self.assertTrue(ok, missing)
|
|
self.assertEqual(result.sentiment_score, 68)
|
|
self.assertEqual(result.analysis_summary, "趋势结论:多头排列;操作建议:买入。")
|
|
self.assertEqual(result.dashboard["sentiment_score"], 68)
|
|
self.assertEqual(result.dashboard["core_conclusion"]["one_sentence"], result.analysis_summary)
|
|
self.assertEqual(result.dashboard["intelligence"]["risk_alerts"], ["跌破 MA20 需止损"])
|
|
self.assertEqual(result.dashboard["battle_plan"]["sniper_points"]["stop_loss"], 112.3)
|
|
|
|
def test_convert_dict_operation_advice_missing_decision_type_preserves_buy_signal(self):
|
|
"""When operation_advice is dict without decision_type, preserve dict-derived buy/sell hint."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"operation_advice": {
|
|
"has_position": "买入",
|
|
"no_position": "观望",
|
|
},
|
|
"trend_prediction": "看多",
|
|
"sentiment_score": 74,
|
|
},
|
|
provider="ollama",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-dict-advice",
|
|
)
|
|
|
|
self.assertEqual(result.operation_advice, "买入")
|
|
self.assertEqual(result.decision_type, "buy")
|
|
|
|
def test_convert_missing_decision_type_preserves_conditional_hold_advice(self):
|
|
"""Condition-hold wording should remain hold when decision_type is not provided."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"operation_advice": "不跌破支撑位继续持有",
|
|
"sentiment_score": 72,
|
|
},
|
|
provider="gemini",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.STRONG_BUY,
|
|
signal_score=78,
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-conditional-hold-advice",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
self.assertEqual(result.operation_advice, "不跌破支撑位继续持有")
|
|
self.assertEqual(result.decision_type, "hold")
|
|
|
|
def test_convert_empty_top_level_advice_uses_nested_dashboard_advice(self):
|
|
"""Empty top-level advice dict should not block nested dashboard fallback."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"operation_advice": {},
|
|
"dashboard": {
|
|
"operation_advice": "减仓",
|
|
"trend_prediction": "看空",
|
|
"sentiment_score": 42,
|
|
},
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-nested-advice",
|
|
)
|
|
|
|
self.assertEqual(result.operation_advice, "减仓")
|
|
self.assertEqual(result.decision_type, "sell")
|
|
self.assertEqual(result.dashboard["operation_advice"], "减仓")
|
|
|
|
def test_convert_placeholder_top_level_advice_uses_nested_dashboard_advice(self):
|
|
"""Placeholder advice dict should not block nested dashboard fallback."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"operation_advice": {
|
|
"has_position": "待补充",
|
|
"no_position": "TBD",
|
|
},
|
|
"dashboard": {
|
|
"operation_advice": "减仓",
|
|
"trend_prediction": "看空",
|
|
"sentiment_score": 42,
|
|
},
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-placeholder-advice",
|
|
)
|
|
|
|
self.assertEqual(result.operation_advice, "减仓")
|
|
self.assertEqual(result.decision_type, "sell")
|
|
self.assertEqual(result.dashboard["operation_advice"], "减仓")
|
|
|
|
def test_convert_malformed_top_level_summary_uses_nested_dashboard_summary(self):
|
|
"""Malformed top-level analysis_summary should not block nested dashboard fallback."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"analysis_summary": [],
|
|
"dashboard": {
|
|
"analysis_summary": "AI 已给出的摘要",
|
|
"trend_prediction": "看多",
|
|
"operation_advice": "持有",
|
|
"sentiment_score": 73,
|
|
},
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-nested-summary",
|
|
)
|
|
|
|
self.assertEqual(result.analysis_summary, "AI 已给出的摘要")
|
|
self.assertEqual(result.dashboard["analysis_summary"], "AI 已给出的摘要")
|
|
|
|
def test_convert_non_string_summary_falls_back_to_nested_or_local_summary(self):
|
|
"""Non-string analysis_summary should trigger fallback to nested summary or local fallback."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
for raw_summary in (0, False):
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"analysis_summary": raw_summary,
|
|
"trend_prediction": "看多",
|
|
"dashboard": {
|
|
"analysis_summary": "AI 已给出的摘要",
|
|
},
|
|
"operation_advice": "持有",
|
|
"sentiment_score": 73,
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
f"q-summary-non-string-{raw_summary}",
|
|
)
|
|
|
|
self.assertEqual(result.analysis_summary, "AI 已给出的摘要")
|
|
self.assertEqual(result.dashboard["analysis_summary"], "AI 已给出的摘要")
|
|
|
|
def test_convert_malformed_scalar_fields_fallback_to_trend_result(self):
|
|
"""Malformed non-scalar scalar fields should not be treated as valid values."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"sentiment_score": {"value": ""},
|
|
"trend_prediction": [],
|
|
"operation_advice": [],
|
|
"decision_type": {},
|
|
},
|
|
provider="gemini",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.BUY,
|
|
signal_score=66,
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-malformed-scalars",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
self.assertEqual(result.sentiment_score, 66)
|
|
self.assertEqual(result.trend_prediction, "多头排列")
|
|
self.assertEqual(result.operation_advice, "买入")
|
|
self.assertEqual(result.decision_type, "buy")
|
|
self.assertEqual(result.dashboard["sentiment_score"], 66)
|
|
self.assertEqual(result.dashboard["trend_prediction"], "多头排列")
|
|
self.assertEqual(result.dashboard["operation_advice"], "买入")
|
|
def test_convert_empty_dashboard_backfills_localized_trend_fallback_for_en(self):
|
|
"""English reports should keep trend/advice fallback values localized."""
|
|
pipeline = self._make_pipeline()
|
|
pipeline.config.report_language = "en"
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={},
|
|
provider="gemini",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.BUY,
|
|
signal_score=70,
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-en-fallback",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
self.assertEqual(result.report_language, "en")
|
|
self.assertEqual(result.trend_prediction, "Bullish")
|
|
self.assertEqual(result.operation_advice, "Buy")
|
|
self.assertEqual(
|
|
result.analysis_summary,
|
|
"Trend view: Bullish; action advice: Buy.",
|
|
)
|
|
self.assertEqual(result.dashboard["trend_prediction"], "Bullish")
|
|
self.assertEqual(result.dashboard["operation_advice"], "Buy")
|
|
self.assertEqual(
|
|
result.dashboard["core_conclusion"]["one_sentence"],
|
|
"Trend view: Bullish; action advice: Buy.",
|
|
)
|
|
|
|
def test_convert_non_dict_advice_conflict_keeps_advice_decision(self):
|
|
"""Conflict between trend fallback and explicit non-dict advice should keep advice decision."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"sentiment_score": 65,
|
|
"trend_prediction": "看空",
|
|
"operation_advice": "减仓",
|
|
},
|
|
provider="gemini",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.BUY,
|
|
signal_score=70,
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-advice-vs-trend",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
self.assertEqual(result.operation_advice, "减仓")
|
|
self.assertEqual(result.decision_type, "sell")
|
|
|
|
def test_convert_partial_dashboard_uses_trend_fallback_for_missing_scalars(self):
|
|
"""Partial Agent dashboards should keep AI fields while filling missing scalars locally."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"stock_name": "贵州茅台",
|
|
"dashboard": {
|
|
"core_conclusion": {"one_sentence": "AI 已给出的核心结论"},
|
|
"intelligence": {"risk_alerts": ["AI 风险"]},
|
|
"battle_plan": {"sniper_points": {"take_profit": "120元"}},
|
|
},
|
|
},
|
|
provider="gemini",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.BUY,
|
|
signal_score=66,
|
|
support_levels=[108.5],
|
|
risk_factors=["跌破 MA20"],
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-partial-dashboard",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
self.assertEqual(result.sentiment_score, 66)
|
|
self.assertEqual(result.trend_prediction, "多头排列")
|
|
self.assertEqual(result.operation_advice, "买入")
|
|
self.assertEqual(result.decision_type, "buy")
|
|
self.assertEqual(result.dashboard["sentiment_score"], 66)
|
|
self.assertEqual(result.dashboard["operation_advice"], "买入")
|
|
self.assertEqual(result.dashboard["core_conclusion"]["one_sentence"], "AI 已给出的核心结论")
|
|
self.assertEqual(result.dashboard["intelligence"]["risk_alerts"], ["AI 风险"])
|
|
self.assertEqual(result.dashboard["battle_plan"]["sniper_points"]["stop_loss"], 108.5)
|
|
self.assertIn("trend:fallback", result.data_sources)
|
|
|
|
def test_convert_risk_alerts_string_placeholder_uses_local_risk_factors(self):
|
|
"""String-like placeholder risk alerts should be replaced with local trend risk factors."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.analyzer import check_content_integrity
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"dashboard": {
|
|
"core_conclusion": {"one_sentence": "AI 已给出的核心结论"},
|
|
"intelligence": {"risk_alerts": "待补充"},
|
|
},
|
|
},
|
|
provider="gemini",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.BUY,
|
|
signal_score=66,
|
|
support_levels=[108.5],
|
|
risk_factors=["涨幅过快", "回撤放大"],
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-risk-alerts-string-placeholder",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
ok, missing = check_content_integrity(result)
|
|
self.assertTrue(ok, missing)
|
|
self.assertEqual(result.dashboard["intelligence"]["risk_alerts"], ["涨幅过快", "回撤放大"])
|
|
|
|
def test_convert_placeholder_dashboard_is_completed_from_local_context(self):
|
|
"""Placeholder dashboard blocks should be completed without falling back to neutral defaults."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.analyzer import check_content_integrity
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"stock_name": "贵州茅台",
|
|
"dashboard": {
|
|
"core_conclusion": {"one_sentence": "待补充"},
|
|
"intelligence": {},
|
|
"battle_plan": {"sniper_points": {"stop_loss": ""}},
|
|
},
|
|
},
|
|
provider="gemini",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.BUY,
|
|
signal_score=62,
|
|
risk_factors=["趋势跌破支撑需减仓"],
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-placeholder-dashboard",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
ok, missing = check_content_integrity(result)
|
|
self.assertTrue(ok, missing)
|
|
self.assertEqual(result.sentiment_score, 62)
|
|
self.assertEqual(result.dashboard["sentiment_score"], 62)
|
|
self.assertEqual(result.dashboard["core_conclusion"]["one_sentence"], result.analysis_summary)
|
|
self.assertEqual(result.dashboard["intelligence"]["risk_alerts"], ["趋势跌破支撑需减仓"])
|
|
self.assertEqual(result.dashboard["battle_plan"]["sniper_points"]["stop_loss"], "待补充")
|
|
|
|
def test_convert_invalid_dashboard_normalizes_strong_trend_decision_type(self):
|
|
"""Fallback preserves strong advice text while keeping stable decision_type values."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import BuySignal, TrendAnalysisResult, TrendStatus
|
|
|
|
cases = [
|
|
(BuySignal.STRONG_BUY, "buy", "强烈买入"),
|
|
(BuySignal.STRONG_SELL, "sell", "强烈卖出"),
|
|
]
|
|
|
|
for buy_signal, expected_decision, expected_advice in cases:
|
|
with self.subTest(buy_signal=buy_signal):
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="LLM returned text but no dashboard JSON",
|
|
dashboard=None,
|
|
provider="ollama",
|
|
)
|
|
trend_result = TrendAnalysisResult(
|
|
code="600519",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=buy_signal,
|
|
signal_score=80,
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result,
|
|
"600519",
|
|
"贵州茅台",
|
|
ReportType.SIMPLE,
|
|
"q-trend-fallback",
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
self.assertEqual(result.operation_advice, expected_advice)
|
|
self.assertEqual(result.decision_type, expected_decision)
|
|
|
|
def test_convert_uses_dashboard_stock_name_when_input_is_placeholder(self):
|
|
"""When input name is placeholder-like, prefer dashboard stock_name."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"stock_name": "科创芯片ETF",
|
|
"sentiment_score": 75,
|
|
"trend_prediction": "震荡偏多",
|
|
"operation_advice": "持有",
|
|
"decision_type": "hold",
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result, "588200", "股票588200", ReportType.SIMPLE, "q-placeholder"
|
|
)
|
|
self.assertEqual(result.name, "科创芯片ETF")
|
|
|
|
def test_convert_keeps_input_stock_name_when_valid(self):
|
|
"""When input name is already valid, do not overwrite with dashboard value."""
|
|
pipeline = self._make_pipeline()
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"stock_name": "错误名称",
|
|
"sentiment_score": 70,
|
|
"trend_prediction": "看多",
|
|
"operation_advice": "持有",
|
|
"decision_type": "hold",
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result, "600519", "贵州茅台", ReportType.SIMPLE, "q-valid"
|
|
)
|
|
self.assertEqual(result.name, "贵州茅台")
|
|
|
|
|
|
# ============================================================
|
|
# Skill registration in pipeline
|
|
# ============================================================
|
|
|
|
class TestPipelineSkillRegistration(unittest.TestCase):
|
|
"""Test built-in strategies load from YAML via SkillManager."""
|
|
|
|
def test_load_builtin_strategies(self):
|
|
"""SkillManager.load_builtin_strategies() should load all YAML strategies."""
|
|
from src.agent.skills.base import SkillManager
|
|
|
|
skill_manager = SkillManager()
|
|
expected = _builtin_strategy_names()
|
|
count = skill_manager.load_builtin_strategies()
|
|
self.assertEqual(count, len(expected))
|
|
|
|
skills = skill_manager.list_skills()
|
|
self.assertEqual(len(skills), len(expected))
|
|
|
|
names = {s.name for s in skills}
|
|
self.assertEqual(names, expected)
|
|
|
|
# All should be disabled by default
|
|
active = skill_manager.list_active_skills()
|
|
self.assertEqual(len(active), 0)
|
|
|
|
# All should have source='builtin'
|
|
for s in skills:
|
|
self.assertEqual(s.source, "builtin")
|
|
|
|
|
|
# ============================================================
|
|
# Pipeline dual-path routing
|
|
# ============================================================
|
|
|
|
class TestPipelineRouting(unittest.TestCase):
|
|
"""Test that analyze_stock routes to agent mode when config.agent_mode is True."""
|
|
|
|
def test_agent_mode_routes_to_agent(self):
|
|
"""When agent_mode=True, analyze_stock should call _analyze_with_agent."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'):
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 5
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.enums import ReportType
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
|
|
# Mock _analyze_with_agent to verify it gets called
|
|
pipeline._analyze_with_agent = MagicMock(return_value=None)
|
|
|
|
pipeline.analyze_stock("600519", ReportType.SIMPLE, "q1")
|
|
|
|
pipeline._analyze_with_agent.assert_called_once()
|
|
call_args = pipeline._analyze_with_agent.call_args
|
|
# Positional args: code, report_type, query_id, stock_name, realtime_quote, chip_data, fundamental_context, trend_result
|
|
self.assertEqual(call_args[0][0], "600519")
|
|
self.assertEqual(call_args[0][1], ReportType.SIMPLE)
|
|
self.assertEqual(call_args[0][2], "q1")
|
|
# trend_result (8th arg) should be present (may be a TrendAnalysisResult or None)
|
|
self.assertEqual(len(call_args[0]), 8)
|
|
|
|
def test_legacy_mode_does_not_call_agent(self):
|
|
"""When agent_mode=False, analyze_stock should NOT call _analyze_with_agent."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db') as mock_db, \
|
|
patch('src.core.pipeline.DataFetcherManager') as mock_fm, \
|
|
patch('src.core.pipeline.GeminiAnalyzer') as mock_analyzer, \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService') as mock_search:
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = False
|
|
mock_cfg.is_agent_available.return_value = False
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.enums import ReportType
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
|
|
# Mock the fetcher_manager to return None for realtime
|
|
pipeline.fetcher_manager.get_realtime_quote.return_value = None
|
|
pipeline.fetcher_manager.get_chip_distribution.return_value = None
|
|
# Mock search service
|
|
pipeline.search_service.is_available = False
|
|
# Mock DB context
|
|
pipeline.db.get_analysis_context.return_value = None
|
|
# Mock analyzer
|
|
pipeline.analyzer.analyze.return_value = None
|
|
|
|
result = pipeline.analyze_stock("600519", ReportType.SIMPLE, "q1")
|
|
|
|
# _analyze_with_agent should NOT exist as a mock (it's the real method)
|
|
# Instead, verify analyzer.analyze was called (legacy path)
|
|
pipeline.analyzer.analyze.assert_called_once()
|
|
|
|
def test_request_skills_auto_enable_agent_mode(self):
|
|
"""Request-specific skills should route the stock analysis through Agent mode."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'):
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = False
|
|
mock_cfg.agent_max_steps = 5
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.enums import ReportType
|
|
pipeline = StockAnalysisPipeline(
|
|
config=mock_cfg,
|
|
analysis_skills=["growth_quality"],
|
|
)
|
|
pipeline._analyze_with_agent = MagicMock(return_value=None)
|
|
|
|
pipeline.analyze_stock("600519", ReportType.SIMPLE, "q1")
|
|
|
|
pipeline._analyze_with_agent.assert_called_once()
|
|
self.assertEqual(pipeline.analysis_skills, ["growth_quality"])
|
|
|
|
|
|
class TestAnalyzeWithAgentStockName(unittest.TestCase):
|
|
"""Test stock-name handling in _analyze_with_agent."""
|
|
|
|
def test_analyze_with_agent_keeps_high_score_hold_action_consistent_across_outputs(self):
|
|
"""A high-score hold may align to buy, and every public output must use that action."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'), \
|
|
patch('src.agent.factory.build_agent_executor') as mock_build_executor:
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_cfg.report_language = "zh"
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.agent.runtime_facts import AgentRuntimeFacts, BaseAgentOpinionFact
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.enums import ReportType
|
|
from src.services.decision_signal_extractor import (
|
|
build_decision_signal_payload_from_report,
|
|
)
|
|
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
pipeline.search_service.is_available = False
|
|
pipeline.db.save_analysis_history.return_value = 2044
|
|
pipeline._extract_decision_signal_after_history_save = MagicMock()
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"stock_name": "贵州茅台",
|
|
"sentiment_score": 80,
|
|
"trend_prediction": "看多",
|
|
"operation_advice": "持有",
|
|
"decision_type": "hold",
|
|
"analysis_summary": "高分但模型建议持有。",
|
|
},
|
|
provider="gemini",
|
|
runtime_facts=AgentRuntimeFacts(
|
|
base_agent_opinions=(
|
|
BaseAgentOpinionFact(
|
|
agent="technical",
|
|
signal="buy",
|
|
confidence=0.8,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
mock_executor = MagicMock()
|
|
mock_executor.run.return_value = agent_result
|
|
mock_build_executor.return_value = mock_executor
|
|
|
|
result = pipeline._analyze_with_agent(
|
|
code="600519",
|
|
report_type=ReportType.SIMPLE,
|
|
query_id="q-review-hold-to-buy",
|
|
stock_name="贵州茅台",
|
|
realtime_quote=None,
|
|
chip_data=None,
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
self.assertEqual(result.decision_type, "hold")
|
|
self.assertEqual(result.action, "buy")
|
|
self.assertEqual(result.action_label, "买入")
|
|
|
|
explanation = result.dashboard["agent_disagreement_explanation"]
|
|
self.assertNotIn("final_signal", explanation)
|
|
self.assertEqual(explanation["pipeline_start_action"], "buy")
|
|
self.assertEqual(explanation["final_adjustments"], [])
|
|
self.assertEqual(explanation["final_action"], "buy")
|
|
|
|
saved_result = pipeline.db.save_analysis_history.call_args.kwargs["result"]
|
|
self.assertIs(saved_result, result)
|
|
self.assertEqual(saved_result.action, "buy")
|
|
self.assertEqual(
|
|
saved_result.dashboard["agent_disagreement_explanation"]["final_action"],
|
|
"buy",
|
|
)
|
|
|
|
signal_result = (
|
|
pipeline._extract_decision_signal_after_history_save.call_args.kwargs["result"]
|
|
)
|
|
self.assertIs(signal_result, result)
|
|
signal_payload = build_decision_signal_payload_from_report(
|
|
signal_result,
|
|
source_report_id=2044,
|
|
trace_id="q-review-hold-to-buy",
|
|
query_source="test",
|
|
report_type="simple",
|
|
profile_source="auto_default",
|
|
)
|
|
self.assertIsNotNone(signal_payload)
|
|
self.assertEqual(signal_payload["action"], "buy")
|
|
self.assertEqual(signal_payload["metadata"]["decision_type"], "hold")
|
|
|
|
def test_analyze_with_agent_keeps_ambiguous_action_fail_closed_across_outputs(self):
|
|
"""Ambiguous advice must not gain an action through Pipeline ordering."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'), \
|
|
patch('src.agent.factory.build_agent_executor') as mock_build_executor:
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_cfg.report_language = "zh"
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.agent.runtime_facts import AgentRuntimeFacts, BaseAgentOpinionFact
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.enums import ReportType
|
|
from src.services.decision_signal_extractor import (
|
|
build_decision_signal_payload_from_report,
|
|
)
|
|
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
pipeline.search_service.is_available = False
|
|
pipeline.db.save_analysis_history.return_value = 2044
|
|
pipeline._extract_decision_signal_after_history_save = MagicMock()
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"stock_name": "贵州茅台",
|
|
"sentiment_score": 82,
|
|
"trend_prediction": "看多",
|
|
"operation_advice": "买盘增强,继续观察",
|
|
"decision_type": "buy",
|
|
"analysis_summary": "方向偏多,但操作建议仍需观察。",
|
|
},
|
|
provider="gemini",
|
|
runtime_facts=AgentRuntimeFacts(
|
|
base_agent_opinions=(
|
|
BaseAgentOpinionFact(
|
|
agent="technical",
|
|
signal="buy",
|
|
confidence=0.8,
|
|
),
|
|
),
|
|
),
|
|
)
|
|
mock_executor = MagicMock()
|
|
mock_executor.run.return_value = agent_result
|
|
mock_build_executor.return_value = mock_executor
|
|
|
|
result = pipeline._analyze_with_agent(
|
|
code="600519",
|
|
report_type=ReportType.SIMPLE,
|
|
query_id="q-review-ambiguous-action",
|
|
stock_name="贵州茅台",
|
|
realtime_quote=None,
|
|
chip_data=None,
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
self.assertEqual(result.decision_type, "buy")
|
|
self.assertIsNone(result.action)
|
|
self.assertIsNone(result.action_label)
|
|
self.assertNotIn("agent_disagreement_explanation", result.dashboard)
|
|
|
|
saved_result = pipeline.db.save_analysis_history.call_args.kwargs["result"]
|
|
self.assertIs(saved_result, result)
|
|
self.assertIsNone(saved_result.action)
|
|
self.assertNotIn("agent_disagreement_explanation", saved_result.dashboard)
|
|
|
|
signal_result = (
|
|
pipeline._extract_decision_signal_after_history_save.call_args.kwargs["result"]
|
|
)
|
|
self.assertIs(signal_result, result)
|
|
signal_payload = build_decision_signal_payload_from_report(
|
|
signal_result,
|
|
source_report_id=2044,
|
|
trace_id="q-review-ambiguous-action",
|
|
query_source="test",
|
|
report_type="simple",
|
|
profile_source="auto_default",
|
|
)
|
|
self.assertIsNone(signal_payload)
|
|
|
|
def test_analyze_with_agent_uses_resolved_name_for_news_persistence(self):
|
|
"""Should use resolved stock name from dashboard for search and DB persistence."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'), \
|
|
patch('src.agent.factory.build_agent_executor') as mock_build_executor, \
|
|
patch('src.agent.executor.AgentExecutor.run') as mock_agent_run:
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"stock_name": "科创芯片ETF",
|
|
"sentiment_score": 78,
|
|
"trend_prediction": "震荡偏多",
|
|
"operation_advice": "持有",
|
|
"decision_type": "hold",
|
|
},
|
|
provider="gemini",
|
|
)
|
|
mock_executor = MagicMock()
|
|
mock_executor.run.return_value = agent_result
|
|
mock_build_executor.return_value = mock_executor
|
|
mock_agent_run.return_value = agent_result
|
|
|
|
news_response = MagicMock()
|
|
news_response.success = True
|
|
news_response.results = [{"title": "test"}]
|
|
news_response.query = "test query"
|
|
pipeline.search_service.is_available = True
|
|
pipeline.search_service.search_stock_news.return_value = news_response
|
|
|
|
result = pipeline._analyze_with_agent(
|
|
code="588200",
|
|
report_type=ReportType.SIMPLE,
|
|
query_id="q-news",
|
|
stock_name="股票588200",
|
|
realtime_quote=None,
|
|
chip_data=None
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
self.assertEqual(result.name, "科创芯片ETF")
|
|
pipeline.search_service.search_stock_news.assert_called_once_with(
|
|
stock_code="588200",
|
|
stock_name="科创芯片ETF",
|
|
max_results=5
|
|
)
|
|
pipeline.db.save_news_intel.assert_called_once()
|
|
saved_kwargs = pipeline.db.save_news_intel.call_args.kwargs
|
|
self.assertEqual(saved_kwargs["name"], "科创芯片ETF")
|
|
|
|
def test_analyze_with_agent_keeps_dashboard_top_level_fields_after_stability(self):
|
|
"""Decision stability downgrade in agent flow should sync dashboard and top-level decision fields."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'), \
|
|
patch('src.agent.factory.build_agent_executor') as mock_build_executor:
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_cfg.report_language = "zh"
|
|
mock_cfg.agent_orchestrator_timeout_s = 600
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.agent.executor import AgentResult
|
|
from src.agent.risk_override import RiskOverrideApplication
|
|
from src.agent.runtime_facts import AgentRuntimeFacts, BaseAgentOpinionFact
|
|
from src.enums import ReportType
|
|
from src.stock_analyzer import TrendAnalysisResult, TrendStatus, BuySignal
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"sentiment_score": 30,
|
|
"trend_prediction": "震荡",
|
|
"operation_advice": "卖出",
|
|
"decision_type": "sell",
|
|
"analysis_summary": "原始建议",
|
|
"dashboard": {
|
|
"core_conclusion": {"one_sentence": "初始结论"},
|
|
},
|
|
},
|
|
provider="gemini",
|
|
runtime_facts=AgentRuntimeFacts(
|
|
base_agent_opinions=(
|
|
BaseAgentOpinionFact(
|
|
agent="technical",
|
|
signal="sell",
|
|
confidence=0.8,
|
|
),
|
|
),
|
|
risk_override_application=RiskOverrideApplication(
|
|
evidence_present=False,
|
|
override_enabled=True,
|
|
trigger="none",
|
|
applied=False,
|
|
reason="no_risk_evidence",
|
|
post_risk_signal="sell",
|
|
),
|
|
),
|
|
)
|
|
mock_executor = MagicMock()
|
|
mock_executor.run.return_value = agent_result
|
|
mock_build_executor.return_value = mock_executor
|
|
pipeline.db.save_analysis_history.return_value = 1
|
|
pipeline._extract_decision_signal_after_history_save = MagicMock()
|
|
|
|
trend_result = TrendAnalysisResult(
|
|
code="002812",
|
|
trend_status=TrendStatus.BULL,
|
|
buy_signal=BuySignal.SELL,
|
|
signal_score=30,
|
|
support_levels=[30.0],
|
|
resistance_levels=[34.0],
|
|
)
|
|
fundamental_context = {
|
|
"capital_flow": {
|
|
"status": "ok",
|
|
"data": {
|
|
"stock_flow": {
|
|
"main_net_inflow": 800_000,
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
result = pipeline._analyze_with_agent(
|
|
code="002812",
|
|
report_type=ReportType.SIMPLE,
|
|
query_id="q-agent-stability",
|
|
stock_name="恩捷股份",
|
|
realtime_quote={"price": 30.4, "change_pct": -2.1},
|
|
chip_data=None,
|
|
fundamental_context=fundamental_context,
|
|
trend_result=trend_result,
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
self.assertEqual(result.decision_type, "hold")
|
|
self.assertEqual(result.operation_advice, "洗盘观察")
|
|
self.assertEqual(result.dashboard.get("decision_type"), "hold")
|
|
self.assertEqual(result.dashboard.get("operation_advice"), "洗盘观察")
|
|
self.assertEqual(result.dashboard.get("sentiment_score"), result.sentiment_score)
|
|
explanation = result.dashboard["agent_disagreement_explanation"]
|
|
self.assertEqual(explanation["risk_control"]["post_risk_signal"], "sell")
|
|
self.assertNotIn("final_signal", explanation)
|
|
self.assertEqual(explanation["final_action"], result.action)
|
|
self.assertEqual(
|
|
explanation["final_adjustments"],
|
|
[
|
|
{
|
|
"source": "structure_and_fundamentals",
|
|
"from_action": "sell",
|
|
"to_action": result.action,
|
|
}
|
|
],
|
|
)
|
|
saved_result = pipeline.db.save_analysis_history.call_args.kwargs["result"]
|
|
self.assertIs(saved_result, result)
|
|
signal_result = (
|
|
pipeline._extract_decision_signal_after_history_save.call_args.kwargs["result"]
|
|
)
|
|
self.assertIs(signal_result, result)
|
|
self.assertEqual(signal_result.action, explanation["final_action"])
|
|
from src.services.decision_signal_extractor import (
|
|
build_decision_signal_payload_from_report,
|
|
)
|
|
signal_payload = build_decision_signal_payload_from_report(
|
|
result,
|
|
source_report_id=1,
|
|
trace_id="q-agent-stability",
|
|
query_source="test",
|
|
report_type="simple",
|
|
profile_source="auto_default",
|
|
)
|
|
self.assertIsNotNone(signal_payload)
|
|
self.assertEqual(signal_payload["action"], explanation["final_action"])
|
|
self.assertEqual(signal_payload["action"], result.action)
|
|
|
|
def test_analyze_with_agent_phase_integrity_fills_missing_phase_decision(self):
|
|
"""Agent weak integrity should enforce phase_decision when phase context exists."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'), \
|
|
patch('src.agent.factory.build_agent_executor') as mock_build_executor:
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_cfg.report_language = "zh"
|
|
mock_cfg.report_integrity_enabled = True
|
|
mock_cfg.agent_orchestrator_timeout_s = 600
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.analyzer import check_content_integrity
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.enums import ReportType
|
|
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
pipeline.search_service.is_available = False
|
|
pipeline._ensure_agent_history = MagicMock()
|
|
pipeline._build_analysis_context_pack_outputs = MagicMock(
|
|
return_value=(
|
|
"",
|
|
{
|
|
"blocks": [],
|
|
"data_quality": {"limitations": []},
|
|
},
|
|
)
|
|
)
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"sentiment_score": 62,
|
|
"trend_prediction": "震荡",
|
|
"operation_advice": "减仓",
|
|
"decision_type": "sell",
|
|
"confidence_level": "中",
|
|
"analysis_summary": "盘中风险偏高",
|
|
"dashboard": {
|
|
"core_conclusion": {"one_sentence": "盘中风险偏高"},
|
|
"intelligence": {"risk_alerts": []},
|
|
},
|
|
},
|
|
provider="gemini",
|
|
)
|
|
mock_executor = MagicMock()
|
|
mock_executor.run.return_value = agent_result
|
|
mock_build_executor.return_value = mock_executor
|
|
|
|
phase_context = {
|
|
"phase": "intraday",
|
|
"market": "cn",
|
|
"market_local_time": "2026-06-02T10:30:00+08:00",
|
|
}
|
|
phase_summary = {
|
|
**phase_context,
|
|
"is_trading_day": True,
|
|
"is_market_open_now": True,
|
|
"is_partial_bar": True,
|
|
"warnings": [],
|
|
}
|
|
|
|
result = pipeline._analyze_with_agent(
|
|
code="600519",
|
|
report_type=ReportType.SIMPLE,
|
|
query_id="q-agent-phase-integrity",
|
|
stock_name="贵州茅台",
|
|
realtime_quote=None,
|
|
chip_data=None,
|
|
market_phase_context=phase_context,
|
|
market_phase_summary=phase_summary,
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
ok, missing = check_content_integrity(result, require_phase_decision=True)
|
|
self.assertTrue(ok, missing)
|
|
phase_decision = result.dashboard["phase_decision"]
|
|
self.assertEqual(phase_decision["phase_context"]["phase"], "intraday")
|
|
self.assertEqual(phase_decision["action_window"], "模型未提供阶段化行动窗口")
|
|
self.assertEqual(phase_decision["immediate_action"], "模型未提供阶段化即时动作")
|
|
self.assertEqual(phase_decision["watch_conditions"], [])
|
|
self.assertEqual(phase_decision["next_check_time"], "模型未提供下一次检查点")
|
|
self.assertEqual(phase_decision["confidence_reason"], "模型未提供阶段化置信度理由")
|
|
|
|
def test_analyze_with_agent_explains_daily_market_softening_before_risk(self):
|
|
"""A partial result produced before risk must retain its Pipeline start signal."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'), \
|
|
patch('src.core.pipeline.stabilize_decision_with_structure'), \
|
|
patch('src.agent.factory.build_agent_executor') as mock_build_executor:
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_cfg.report_language = "en"
|
|
mock_cfg.report_integrity_enabled = False
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from datetime import date
|
|
|
|
from src.agent.executor import AgentResult
|
|
from src.agent.runtime_facts import AgentRuntimeFacts, BaseAgentOpinionFact
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.enums import ReportType
|
|
from src.services.daily_market_context import DailyMarketContext
|
|
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
pipeline.search_service.is_available = False
|
|
pipeline.db.save_analysis_history.return_value = 1
|
|
pipeline._extract_decision_signal_after_history_save = MagicMock()
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"sentiment_score": 82,
|
|
"trend_prediction": "bullish",
|
|
"operation_advice": "Buy now.",
|
|
"decision_type": "buy",
|
|
"confidence_level": "high",
|
|
"analysis_summary": "Strong stock signal.",
|
|
"dashboard": {
|
|
"core_conclusion": {
|
|
"one_sentence": "Buy now and add aggressively.",
|
|
"position_advice": {
|
|
"no_position": "Buy now.",
|
|
"has_position": "Add position.",
|
|
},
|
|
},
|
|
"battle_plan": {
|
|
"position_strategy": {
|
|
"suggested_position": "Full position",
|
|
"entry_plan": "Buy the breakout",
|
|
"risk_control": "Add on pullback",
|
|
}
|
|
},
|
|
},
|
|
},
|
|
provider="gemini",
|
|
runtime_facts=AgentRuntimeFacts(
|
|
base_agent_opinions=(
|
|
BaseAgentOpinionFact(
|
|
agent="technical",
|
|
signal="buy",
|
|
confidence=0.82,
|
|
),
|
|
),
|
|
risk_override_application=None,
|
|
),
|
|
)
|
|
mock_executor = MagicMock()
|
|
mock_executor.run.return_value = agent_result
|
|
mock_build_executor.return_value = mock_executor
|
|
|
|
result = pipeline._analyze_with_agent(
|
|
code="AAPL",
|
|
report_type=ReportType.SIMPLE,
|
|
query_id="q-agent-daily-final",
|
|
stock_name="Apple",
|
|
realtime_quote=None,
|
|
chip_data=None,
|
|
daily_market_context=DailyMarketContext(
|
|
region="us",
|
|
trade_date=date(2026, 7, 19),
|
|
summary="High risk and risk-off; remain conservative.",
|
|
risk_tags=["high_risk", "conservative"],
|
|
source="test",
|
|
),
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
explanation = result.dashboard["agent_disagreement_explanation"]
|
|
self.assertEqual(result.decision_type, "hold")
|
|
self.assertEqual(explanation["risk_control"]["reason"], "not_evaluated")
|
|
self.assertEqual(explanation["risk_control"]["post_risk_signal"], "buy")
|
|
self.assertNotIn("final_signal", explanation)
|
|
self.assertEqual(explanation["final_action"], result.action)
|
|
self.assertEqual(
|
|
explanation["final_adjustments"],
|
|
[
|
|
{
|
|
"source": "daily_market_context",
|
|
"from_action": "buy",
|
|
"to_action": result.action,
|
|
}
|
|
],
|
|
)
|
|
signal_result = (
|
|
pipeline._extract_decision_signal_after_history_save.call_args.kwargs["result"]
|
|
)
|
|
self.assertEqual(signal_result.action, explanation["final_action"])
|
|
from src.services.decision_signal_extractor import (
|
|
build_decision_signal_payload_from_report,
|
|
)
|
|
signal_payload = build_decision_signal_payload_from_report(
|
|
result,
|
|
source_report_id=1,
|
|
trace_id="q-agent-daily-final",
|
|
query_source="test",
|
|
report_type="simple",
|
|
profile_source="auto_default",
|
|
)
|
|
self.assertIsNotNone(signal_payload)
|
|
self.assertEqual(signal_payload["action"], explanation["final_action"])
|
|
self.assertEqual(signal_payload["action"], result.action)
|
|
|
|
def test_analyze_with_agent_preserves_chip_structure_when_prefetch_missing(self):
|
|
"""Agent tool chip metrics should not be cleared when prefetch chip_data is unavailable."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'), \
|
|
patch('src.agent.factory.build_agent_executor') as mock_build_executor:
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_cfg.report_language = "zh"
|
|
mock_cfg.report_integrity_enabled = False
|
|
mock_cfg.agent_orchestrator_timeout_s = 600
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
pipeline.search_service.is_available = False
|
|
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"sentiment_score": 70,
|
|
"trend_prediction": "震荡",
|
|
"operation_advice": "持有",
|
|
"decision_type": "hold",
|
|
"dashboard": {
|
|
"data_perspective": {
|
|
"chip_structure": {
|
|
"profit_ratio": "52.0%",
|
|
"avg_cost": 1850.0,
|
|
"concentration": "0.00%",
|
|
"chip_health": "健康",
|
|
}
|
|
}
|
|
},
|
|
},
|
|
provider="gemini",
|
|
)
|
|
mock_executor = MagicMock()
|
|
mock_executor.run.return_value = agent_result
|
|
mock_build_executor.return_value = mock_executor
|
|
|
|
result = pipeline._analyze_with_agent(
|
|
code="600519",
|
|
report_type=ReportType.SIMPLE,
|
|
query_id="q-agent-chip",
|
|
stock_name="贵州茅台",
|
|
realtime_quote=None,
|
|
chip_data=None,
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
dp = result.dashboard["data_perspective"]
|
|
self.assertEqual(dp["chip_structure"]["concentration"], "0.00%")
|
|
self.assertNotIn("chip_unavailable_reason", dp)
|
|
|
|
def test_analyze_with_agent_history_context_includes_diagnostic_snapshot(self):
|
|
"""Agent 分析入库存档时应保留 diagnostics 快照,避免历史诊断返回 unknown。"""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'), \
|
|
patch('src.core.pipeline.fill_price_position_if_needed'), \
|
|
patch('src.core.pipeline.stabilize_decision_with_structure'), \
|
|
patch('src.core.pipeline.current_diagnostic_snapshot') as mock_diagnostic_snapshot:
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.anspire_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = True
|
|
mock_cfg.report_language = "zh"
|
|
mock_cfg.report_integrity_enabled = False
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.agent.runtime_facts import AgentRuntimeFacts, SkillOpinionFact
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.enums import ReportType
|
|
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
pipeline.search_service.is_available = False
|
|
pipeline._ensure_agent_history = MagicMock()
|
|
pipeline._agent_result_to_analysis_result = MagicMock(
|
|
return_value=SimpleNamespace(
|
|
success=True,
|
|
code="588200",
|
|
name="科创芯片ETF",
|
|
model_used="agent-model",
|
|
sentiment_score=70,
|
|
operation_advice="持有",
|
|
trend_prediction="震荡",
|
|
analysis_summary="测试摘要",
|
|
)
|
|
)
|
|
|
|
mock_executor = MagicMock()
|
|
mock_executor.run.return_value = SimpleNamespace(
|
|
success=True,
|
|
provider="agent-provider",
|
|
runtime_facts=AgentRuntimeFacts(
|
|
skill_opinions=(
|
|
SkillOpinionFact(
|
|
skill_id="alpha",
|
|
signal="buy",
|
|
confidence=0.7,
|
|
),
|
|
),
|
|
),
|
|
dashboard={"stock_name": "科创芯片ETF"},
|
|
)
|
|
with patch('src.agent.factory.build_agent_executor', return_value=mock_executor):
|
|
mock_diagnostic_snapshot.return_value = {"trace_id": "trace-1391", "query_id": "q-1391"}
|
|
pipeline.db.save_analysis_history = MagicMock(return_value=1)
|
|
pipeline._persist_skill_opinion_samples_after_history_save = MagicMock()
|
|
|
|
result = pipeline._analyze_with_agent(
|
|
code="588200",
|
|
report_type=ReportType.SIMPLE,
|
|
query_id="q-1391",
|
|
stock_name="科创芯片ETF",
|
|
realtime_quote=None,
|
|
chip_data=None,
|
|
)
|
|
|
|
self.assertIsNotNone(result)
|
|
call_kwargs = pipeline.db.save_analysis_history.call_args.kwargs
|
|
history_context = call_kwargs["context_snapshot"]
|
|
self.assertIn("diagnostics", history_context)
|
|
self.assertEqual(history_context["diagnostics"]["trace_id"], "trace-1391")
|
|
self.assertEqual(history_context["stock_name"], "科创芯片ETF")
|
|
pipeline._persist_skill_opinion_samples_after_history_save.assert_called_once()
|
|
sample_kwargs = (
|
|
pipeline._persist_skill_opinion_samples_after_history_save.call_args.kwargs
|
|
)
|
|
self.assertIs(
|
|
sample_kwargs["runtime_facts"],
|
|
mock_executor.run.return_value.runtime_facts,
|
|
)
|
|
self.assertEqual(sample_kwargs["analysis_history_id"], 1)
|
|
self.assertEqual(sample_kwargs["stock_code"], "588200")
|
|
self.assertEqual(
|
|
sample_kwargs["analysis_context_pack_overview"]["data_quality"]["level"],
|
|
"poor",
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Agent construction chain (real objects, mocked LLM)
|
|
# ============================================================
|
|
|
|
class TestAgentConstructionChain(unittest.TestCase):
|
|
"""Test that the agent construction chain wires up correctly."""
|
|
|
|
def test_llm_adapter_accepts_config(self):
|
|
"""LLMToolAdapter should accept an optional config parameter."""
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.gemini_api_key = ""
|
|
mock_cfg.anthropic_api_key = ""
|
|
mock_cfg.openai_api_key = ""
|
|
mock_cfg.openai_base_url = ""
|
|
mock_cfg.openai_model = ""
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
self.assertIsNotNone(adapter)
|
|
|
|
def test_llm_adapter_no_args(self):
|
|
"""LLMToolAdapter should also work with no arguments (uses get_config)."""
|
|
with patch('src.agent.llm_adapter.get_config') as mock_get_config:
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.gemini_api_key = ""
|
|
mock_cfg.anthropic_api_key = ""
|
|
mock_cfg.openai_api_key = ""
|
|
mock_cfg.openai_base_url = ""
|
|
mock_cfg.openai_model = ""
|
|
mock_get_config.return_value = mock_cfg
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter()
|
|
self.assertIsNotNone(adapter)
|
|
|
|
def test_full_construction_chain(self):
|
|
"""Test ToolRegistry + SkillManager + LLMToolAdapter + AgentExecutor wiring."""
|
|
from src.agent.tools.registry import ToolRegistry, ToolDefinition, ToolParameter
|
|
from src.agent.skills.base import SkillManager, Skill
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
from src.agent.executor import AgentExecutor
|
|
|
|
# Build registry with a dummy tool
|
|
registry = ToolRegistry()
|
|
|
|
def dummy_handler(x: str) -> str:
|
|
return f"echo {x}"
|
|
|
|
dummy_tool = ToolDefinition(
|
|
name="dummy_echo",
|
|
description="A test tool for echoing input.",
|
|
category="test",
|
|
parameters=[ToolParameter(name="x", type="string", description="input string", required=True)],
|
|
handler=dummy_handler,
|
|
)
|
|
registry.register(dummy_tool)
|
|
|
|
# Build skill manager with a fresh skill instance (avoid module singleton state)
|
|
skill_manager = SkillManager()
|
|
test_skill = Skill(
|
|
name="test_skill",
|
|
display_name="测试策略",
|
|
description="A test skill",
|
|
instructions="Test instructions for analysis.",
|
|
category="trend",
|
|
core_rules=[1, 2],
|
|
)
|
|
skill_manager.register(test_skill)
|
|
skill_manager.activate(["test_skill"])
|
|
instructions = skill_manager.get_skill_instructions()
|
|
self.assertIn("测试策略", instructions)
|
|
|
|
# Build LLM adapter with mocked config (no real API keys)
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.gemini_api_key = ""
|
|
mock_cfg.anthropic_api_key = ""
|
|
mock_cfg.openai_api_key = ""
|
|
mock_cfg.openai_base_url = ""
|
|
mock_cfg.openai_model = ""
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
|
|
# Build executor
|
|
executor = AgentExecutor(
|
|
tool_registry=registry,
|
|
llm_adapter=adapter,
|
|
skill_instructions=instructions,
|
|
max_steps=3,
|
|
)
|
|
self.assertEqual(executor.max_steps, 3)
|
|
self.assertIsNotNone(executor.tool_registry)
|
|
self.assertIsNotNone(executor.llm_adapter)
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_call_completion_uses_effective_agent_models_order(self, _mock_router):
|
|
"""call_completion should use Agent effective model chain in order."""
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.agent_litellm_model = "gpt-4o-mini"
|
|
mock_cfg.litellm_model = "gemini/gemini-2.5-flash"
|
|
mock_cfg.litellm_fallback_models = ["openai/gpt-4o-mini", "anthropic/claude-3-5-sonnet-20241022"]
|
|
mock_cfg.llm_model_list = []
|
|
mock_cfg.llm_temperature = 0.7
|
|
mock_cfg.gemini_api_keys = []
|
|
mock_cfg.anthropic_api_keys = []
|
|
mock_cfg.openai_api_keys = []
|
|
mock_cfg.deepseek_api_keys = []
|
|
mock_cfg.openai_base_url = None
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
|
|
calls = []
|
|
|
|
def fake_call(_messages, _tools, model, **_kwargs):
|
|
calls.append(model)
|
|
if model == "openai/gpt-4o-mini":
|
|
raise RuntimeError("primary failed")
|
|
return MagicMock(content="ok")
|
|
|
|
adapter._call_litellm_model = MagicMock(side_effect=fake_call)
|
|
|
|
result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])
|
|
|
|
self.assertEqual(calls, ["openai/gpt-4o-mini", "anthropic/claude-3-5-sonnet-20241022"])
|
|
self.assertEqual(result.content, "ok")
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_normalizes_kimi_k26_temperature(self, _mock_router):
|
|
"""Agent direct LiteLLM calls should not send unsupported temperatures to Kimi K2.6."""
|
|
mock_cfg = SimpleNamespace(
|
|
agent_litellm_model="",
|
|
litellm_model="openai/kimi-k2.6",
|
|
litellm_fallback_models=[],
|
|
llm_model_list=[],
|
|
llm_temperature=0.2,
|
|
gemini_api_keys=[],
|
|
anthropic_api_keys=[],
|
|
openai_api_keys=[],
|
|
deepseek_api_keys=[],
|
|
openai_base_url=None,
|
|
)
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
adapter._router = None
|
|
response = SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content="agent ok",
|
|
tool_calls=[],
|
|
)
|
|
)
|
|
],
|
|
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
|
|
)
|
|
|
|
with patch("src.agent.llm_adapter.litellm.completion", return_value=response) as mock_completion:
|
|
result = adapter._call_litellm_model(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"openai/kimi-k2.6",
|
|
temperature=0.2,
|
|
)
|
|
|
|
self.assertEqual(result.content, "agent ok")
|
|
self.assertEqual(mock_completion.call_args.kwargs["temperature"], 1.0)
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_normalizes_kimi_k26_temperature_for_yaml_alias(self, _mock_router):
|
|
"""Agent direct LiteLLM calls should normalize through routed YAML aliases."""
|
|
mock_cfg = SimpleNamespace(
|
|
agent_litellm_model="",
|
|
litellm_model="kimi_router",
|
|
litellm_fallback_models=[],
|
|
llm_model_list=[
|
|
{
|
|
"model_name": "kimi_router",
|
|
"litellm_params": {"model": "openai/kimi-k2.6"},
|
|
}
|
|
],
|
|
llm_temperature=0.2,
|
|
gemini_api_keys=[],
|
|
anthropic_api_keys=[],
|
|
openai_api_keys=[],
|
|
deepseek_api_keys=[],
|
|
openai_base_url=None,
|
|
)
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
adapter._router = None
|
|
response = SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content="agent ok",
|
|
tool_calls=[],
|
|
)
|
|
)
|
|
],
|
|
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
|
|
)
|
|
|
|
with patch("src.agent.llm_adapter.litellm.completion", return_value=response) as mock_completion:
|
|
result = adapter._call_litellm_model(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"kimi_router",
|
|
temperature=0.2,
|
|
)
|
|
|
|
self.assertEqual(result.content, "agent ok")
|
|
self.assertEqual(mock_completion.call_args.kwargs["temperature"], 1.0)
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_normalizes_kimi_k26_temperature_for_non_thinking_yaml_alias(self, _mock_router):
|
|
"""Agent direct LiteLLM calls should honor non-thinking Kimi YAML overrides."""
|
|
mock_cfg = SimpleNamespace(
|
|
agent_litellm_model="",
|
|
litellm_model="kimi_router",
|
|
litellm_fallback_models=[],
|
|
llm_model_list=[
|
|
{
|
|
"model_name": "kimi_router",
|
|
"litellm_params": {
|
|
"model": "openai/kimi-k2.6",
|
|
"extra_body": {"thinking": {"type": "disabled"}},
|
|
},
|
|
}
|
|
],
|
|
llm_temperature=0.2,
|
|
gemini_api_keys=[],
|
|
anthropic_api_keys=[],
|
|
openai_api_keys=[],
|
|
deepseek_api_keys=[],
|
|
openai_base_url=None,
|
|
)
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
adapter._router = None
|
|
response = SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content="agent ok",
|
|
tool_calls=[],
|
|
)
|
|
)
|
|
],
|
|
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
|
|
)
|
|
|
|
with patch("src.agent.llm_adapter.litellm.completion", return_value=response) as mock_completion:
|
|
result = adapter._call_litellm_model(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"kimi_router",
|
|
temperature=0.2,
|
|
)
|
|
|
|
self.assertEqual(result.content, "agent ok")
|
|
self.assertEqual(mock_completion.call_args.kwargs["temperature"], 0.6)
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_omits_temperature_for_gpt5_family(self, _mock_router):
|
|
"""Agent direct LiteLLM calls should omit temperature for strict default-temperature models."""
|
|
mock_cfg = SimpleNamespace(
|
|
agent_litellm_model="",
|
|
litellm_model="openai/gpt5.5-ferr",
|
|
litellm_fallback_models=[],
|
|
llm_model_list=[],
|
|
llm_temperature=0.2,
|
|
gemini_api_keys=[],
|
|
anthropic_api_keys=[],
|
|
openai_api_keys=[],
|
|
deepseek_api_keys=[],
|
|
openai_base_url=None,
|
|
)
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
adapter._router = None
|
|
response = SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content="agent ok",
|
|
tool_calls=[],
|
|
)
|
|
)
|
|
],
|
|
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
|
|
)
|
|
|
|
with patch("src.agent.llm_adapter.litellm.completion", return_value=response) as mock_completion:
|
|
result = adapter._call_litellm_model(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"openai/gpt5.5-ferr",
|
|
temperature=0.2,
|
|
)
|
|
|
|
self.assertEqual(result.content, "agent ok")
|
|
self.assertNotIn("temperature", mock_completion.call_args.kwargs)
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_recovers_from_unsupported_temperature(self, _mock_router):
|
|
"""Agent direct LiteLLM calls should retry once with a request-scoped parameter repair."""
|
|
from src.llm.generation_params import clear_litellm_generation_param_recovery_cache
|
|
|
|
clear_litellm_generation_param_recovery_cache()
|
|
mock_cfg = SimpleNamespace(
|
|
agent_litellm_model="",
|
|
litellm_model="openai/custom-temp-locked-agent",
|
|
litellm_fallback_models=[],
|
|
llm_model_list=[],
|
|
llm_temperature=0.2,
|
|
gemini_api_keys=[],
|
|
anthropic_api_keys=[],
|
|
openai_api_keys=[],
|
|
deepseek_api_keys=[],
|
|
openai_base_url=None,
|
|
)
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
adapter._router = None
|
|
response = SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content="agent ok",
|
|
tool_calls=[],
|
|
)
|
|
)
|
|
],
|
|
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
|
|
)
|
|
|
|
with patch("src.agent.llm_adapter.litellm.completion") as mock_completion:
|
|
mock_completion.side_effect = [
|
|
RuntimeError("Unsupported parameter: temperature is not supported"),
|
|
response,
|
|
]
|
|
result = adapter._call_litellm_model(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"openai/custom-temp-locked-agent",
|
|
temperature=0.2,
|
|
)
|
|
|
|
self.assertEqual(result.content, "agent ok")
|
|
self.assertEqual(mock_completion.call_args_list[0].kwargs["temperature"], 0.2)
|
|
self.assertNotIn("temperature", mock_completion.call_args_list[1].kwargs)
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_legacy_router_recovery_cache_is_scoped_to_endpoint(self, mock_router):
|
|
"""Legacy multi-key Router recoveries should not leak across base URLs."""
|
|
from src.llm.generation_params import clear_litellm_generation_param_recovery_cache
|
|
|
|
clear_litellm_generation_param_recovery_cache()
|
|
response = SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content="agent ok",
|
|
tool_calls=[],
|
|
)
|
|
)
|
|
],
|
|
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
|
|
)
|
|
strict_router = MagicMock()
|
|
flex_router = MagicMock()
|
|
strict_router.completion.side_effect = [
|
|
RuntimeError("Unsupported parameter: temperature is not supported"),
|
|
response,
|
|
]
|
|
flex_router.completion.return_value = response
|
|
mock_router.side_effect = [strict_router, flex_router]
|
|
|
|
strict_cfg = SimpleNamespace(
|
|
agent_litellm_model="",
|
|
litellm_model="openai/shared-model",
|
|
litellm_fallback_models=[],
|
|
llm_model_list=[],
|
|
llm_temperature=0.2,
|
|
gemini_api_keys=[],
|
|
anthropic_api_keys=[],
|
|
openai_api_keys=["sk-strict-key-1", "sk-strict-key-2"],
|
|
deepseek_api_keys=[],
|
|
openai_base_url="https://strict.example/v1",
|
|
)
|
|
flex_cfg = SimpleNamespace(
|
|
agent_litellm_model="",
|
|
litellm_model="openai/shared-model",
|
|
litellm_fallback_models=[],
|
|
llm_model_list=[],
|
|
llm_temperature=0.2,
|
|
gemini_api_keys=[],
|
|
anthropic_api_keys=[],
|
|
openai_api_keys=["sk-flex-key-1", "sk-flex-key-2"],
|
|
deepseek_api_keys=[],
|
|
openai_base_url="https://flex.example/v1",
|
|
)
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
|
|
strict_adapter = LLMToolAdapter(config=strict_cfg)
|
|
strict_result = strict_adapter._call_litellm_model(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"openai/shared-model",
|
|
temperature=0.2,
|
|
)
|
|
flex_adapter = LLMToolAdapter(config=flex_cfg)
|
|
flex_result = flex_adapter._call_litellm_model(
|
|
[{"role": "user", "content": "hi"}],
|
|
[],
|
|
"openai/shared-model",
|
|
temperature=0.2,
|
|
)
|
|
|
|
self.assertEqual(strict_result.content, "agent ok")
|
|
self.assertEqual(flex_result.content, "agent ok")
|
|
self.assertEqual(strict_router.completion.call_args_list[0].kwargs["temperature"], 0.2)
|
|
self.assertNotIn("temperature", strict_router.completion.call_args_list[1].kwargs)
|
|
self.assertEqual(flex_router.completion.call_args.kwargs["temperature"], 0.2)
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_fallback_does_not_leak_kimi_fixed_temperature(self, _mock_router):
|
|
"""Non-Kimi fallbacks should keep the requested temperature after a Kimi failure."""
|
|
mock_cfg = SimpleNamespace(
|
|
agent_litellm_model="",
|
|
litellm_model="openai/kimi-k2.6",
|
|
litellm_fallback_models=["openai/gpt-4o-mini"],
|
|
llm_model_list=[],
|
|
llm_temperature=0.2,
|
|
gemini_api_keys=[],
|
|
anthropic_api_keys=[],
|
|
openai_api_keys=[],
|
|
deepseek_api_keys=[],
|
|
openai_base_url=None,
|
|
)
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
response = SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
message=SimpleNamespace(
|
|
content="fallback ok",
|
|
tool_calls=[],
|
|
)
|
|
)
|
|
],
|
|
usage=SimpleNamespace(prompt_tokens=1, completion_tokens=2, total_tokens=3),
|
|
)
|
|
temperatures = []
|
|
|
|
def fake_completion(**kwargs):
|
|
temperatures.append((kwargs["model"], kwargs["temperature"]))
|
|
if kwargs["model"] == "openai/kimi-k2.6":
|
|
raise RuntimeError("primary failed")
|
|
return response
|
|
|
|
with patch("src.agent.llm_adapter.litellm.completion", side_effect=fake_completion):
|
|
result = adapter.call_completion(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=[],
|
|
temperature=0.2,
|
|
)
|
|
|
|
self.assertEqual(result.content, "fallback ok")
|
|
self.assertEqual(
|
|
temperatures,
|
|
[("openai/kimi-k2.6", 1.0), ("openai/gpt-4o-mini", 0.2)],
|
|
)
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_recomputes_timeout_for_each_fallback_attempt(self, _mock_router):
|
|
"""Each fallback model attempt should receive only the remaining timeout budget."""
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.agent_litellm_model = "gpt-4o-mini"
|
|
mock_cfg.litellm_model = None
|
|
mock_cfg.litellm_fallback_models = ["anthropic/claude-3-5-sonnet-20241022"]
|
|
mock_cfg.llm_model_list = []
|
|
mock_cfg.llm_temperature = 0.7
|
|
mock_cfg.gemini_api_keys = []
|
|
mock_cfg.anthropic_api_keys = []
|
|
mock_cfg.openai_api_keys = []
|
|
mock_cfg.deepseek_api_keys = []
|
|
mock_cfg.openai_base_url = None
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
|
|
timeouts = []
|
|
|
|
def fake_call(_messages, _tools, model, **kwargs):
|
|
timeouts.append((model, kwargs.get("timeout")))
|
|
if model == "openai/gpt-4o-mini":
|
|
raise RuntimeError("primary failed")
|
|
return MagicMock(content="ok")
|
|
|
|
adapter._call_litellm_model = MagicMock(side_effect=fake_call)
|
|
|
|
with patch("src.agent.llm_adapter.time.time", side_effect=[0.0, 0.0, 7.0, 7.0]):
|
|
result = adapter.call_completion(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=[],
|
|
timeout=10.0,
|
|
)
|
|
|
|
self.assertEqual(result.content, "ok")
|
|
self.assertEqual(timeouts[0], ("openai/gpt-4o-mini", 10.0))
|
|
self.assertEqual(timeouts[1], ("anthropic/claude-3-5-sonnet-20241022", 3.0))
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_rate_limit_backoff_is_bounded_by_remaining_timeout(self, _mock_router):
|
|
"""Rate-limit backoff should sleep, but never longer than the remaining timeout budget."""
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.agent_litellm_model = "gpt-4o-mini"
|
|
mock_cfg.litellm_model = None
|
|
mock_cfg.litellm_fallback_models = ["openai/gpt-4.1-mini"]
|
|
mock_cfg.llm_model_list = []
|
|
mock_cfg.llm_temperature = 0.7
|
|
mock_cfg.gemini_api_keys = []
|
|
mock_cfg.anthropic_api_keys = []
|
|
mock_cfg.openai_api_keys = []
|
|
mock_cfg.deepseek_api_keys = []
|
|
mock_cfg.openai_base_url = None
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
|
|
class FakeRateLimitError(Exception):
|
|
pass
|
|
|
|
timeouts = []
|
|
sleep_calls = []
|
|
clock = {"value": 0.0}
|
|
|
|
def fake_time():
|
|
return clock["value"]
|
|
|
|
def fake_sleep(seconds):
|
|
sleep_calls.append(seconds)
|
|
clock["value"] += seconds
|
|
|
|
def fake_call(_messages, _tools, model, **kwargs):
|
|
timeouts.append((model, kwargs.get("timeout")))
|
|
if model == "openai/gpt-4o-mini":
|
|
clock["value"] += 8.0
|
|
raise FakeRateLimitError("rate limited")
|
|
return MagicMock(content="ok")
|
|
|
|
adapter._call_litellm_model = MagicMock(side_effect=fake_call)
|
|
|
|
with patch("src.agent.llm_adapter.litellm.RateLimitError", FakeRateLimitError), \
|
|
patch("src.agent.llm_adapter.logger.warning"), \
|
|
patch("src.agent.llm_adapter.time.time", side_effect=fake_time), \
|
|
patch("src.agent.llm_adapter.time.sleep", side_effect=fake_sleep) as mock_sleep:
|
|
result = adapter.call_completion(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
tools=[],
|
|
timeout=10.0,
|
|
)
|
|
|
|
self.assertEqual(result.content, "ok")
|
|
self.assertEqual(timeouts[0], ("openai/gpt-4o-mini", 10.0))
|
|
self.assertEqual(timeouts[1][0], "openai/gpt-4.1-mini")
|
|
expected_backoff = min(2.0, 8.0 * 0.1 + 0.5)
|
|
expected_next_timeout = 10.0 - (8.0 + expected_backoff)
|
|
self.assertAlmostEqual(timeouts[1][1], expected_next_timeout)
|
|
mock_sleep.assert_called_once()
|
|
self.assertAlmostEqual(mock_sleep.call_args.args[0], expected_backoff)
|
|
self.assertAlmostEqual(sleep_calls[0], expected_backoff)
|
|
self.assertAlmostEqual(clock["value"], 8.0 + expected_backoff)
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_context_window_error_skips_sleep(self, _mock_router):
|
|
"""Context-window errors should continue fallback immediately without backoff."""
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.agent_litellm_model = "gpt-4o-mini"
|
|
mock_cfg.litellm_model = None
|
|
mock_cfg.litellm_fallback_models = ["anthropic/claude-3-5-sonnet-20241022"]
|
|
mock_cfg.llm_model_list = []
|
|
mock_cfg.llm_temperature = 0.7
|
|
mock_cfg.gemini_api_keys = []
|
|
mock_cfg.anthropic_api_keys = []
|
|
mock_cfg.openai_api_keys = []
|
|
mock_cfg.deepseek_api_keys = []
|
|
mock_cfg.openai_base_url = None
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
|
|
class FakeContextWindowExceededError(Exception):
|
|
pass
|
|
|
|
def fake_call(_messages, _tools, model, **_kwargs):
|
|
if model == "openai/gpt-4o-mini":
|
|
raise FakeContextWindowExceededError("window exceeded")
|
|
return MagicMock(content="ok")
|
|
|
|
adapter._call_litellm_model = MagicMock(side_effect=fake_call)
|
|
|
|
with patch(
|
|
"src.agent.llm_adapter.litellm.ContextWindowExceededError",
|
|
FakeContextWindowExceededError,
|
|
), patch("src.agent.llm_adapter.time.sleep") as mock_sleep:
|
|
result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])
|
|
|
|
self.assertEqual(result.content, "ok")
|
|
mock_sleep.assert_not_called()
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_reports_rate_limit_suffix_when_any_fallback_hit_limit(self, _mock_router):
|
|
"""Final error should note earlier rate limiting even if the last error differs."""
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.agent_litellm_model = "gpt-4o-mini"
|
|
mock_cfg.litellm_model = None
|
|
mock_cfg.litellm_fallback_models = ["anthropic/claude-3-5-sonnet-20241022"]
|
|
mock_cfg.llm_model_list = []
|
|
mock_cfg.llm_temperature = 0.7
|
|
mock_cfg.gemini_api_keys = []
|
|
mock_cfg.anthropic_api_keys = []
|
|
mock_cfg.openai_api_keys = []
|
|
mock_cfg.deepseek_api_keys = []
|
|
mock_cfg.openai_base_url = None
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
|
|
class FakeRateLimitError(Exception):
|
|
pass
|
|
|
|
class FakeContextWindowExceededError(Exception):
|
|
pass
|
|
|
|
def fake_call(_messages, _tools, model, **_kwargs):
|
|
if model == "openai/gpt-4o-mini":
|
|
raise FakeRateLimitError("rate limited")
|
|
raise FakeContextWindowExceededError("window exceeded")
|
|
|
|
adapter._call_litellm_model = MagicMock(side_effect=fake_call)
|
|
|
|
with patch("src.agent.llm_adapter.litellm.RateLimitError", FakeRateLimitError), \
|
|
patch(
|
|
"src.agent.llm_adapter.litellm.ContextWindowExceededError",
|
|
FakeContextWindowExceededError,
|
|
), \
|
|
patch("src.agent.llm_adapter.time.sleep") as mock_sleep:
|
|
result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])
|
|
|
|
self.assertEqual(result.provider, "error")
|
|
self.assertIn("All LLM models failed (rate-limit encountered during fallback).", result.content)
|
|
self.assertIn("window exceeded", result.content)
|
|
mock_sleep.assert_not_called()
|
|
|
|
@patch("src.agent.llm_adapter.Router")
|
|
def test_llm_adapter_reports_missing_configuration_without_generic_none_error(self, _mock_router):
|
|
"""Missing Agent model config should return a stable, actionable error message."""
|
|
mock_cfg = SimpleNamespace(
|
|
agent_litellm_model="",
|
|
litellm_model="",
|
|
litellm_fallback_models=[],
|
|
llm_model_list=[],
|
|
llm_temperature=0.7,
|
|
gemini_api_keys=[],
|
|
anthropic_api_keys=[],
|
|
openai_api_keys=[],
|
|
deepseek_api_keys=[],
|
|
openai_base_url=None,
|
|
)
|
|
|
|
from src.agent.llm_adapter import LLMToolAdapter
|
|
adapter = LLMToolAdapter(config=mock_cfg)
|
|
|
|
result = adapter.call_completion(messages=[{"role": "user", "content": "hi"}], tools=[])
|
|
|
|
self.assertEqual(result.provider, "error")
|
|
self.assertEqual(
|
|
result.content,
|
|
"No LLM configured. Please set LITELLM_MODEL, LLM_CHANNELS, or provider API keys before using Agent.",
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# _safe_int tests
|
|
# ============================================================
|
|
|
|
class TestSafeInt(unittest.TestCase):
|
|
"""Test the _safe_int helper for robust sentiment_score parsing."""
|
|
|
|
def _get_safe_int(self):
|
|
"""Get reference to StockAnalysisPipeline._safe_int static method."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'):
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = False
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
return StockAnalysisPipeline._safe_int
|
|
|
|
def test_int_passthrough(self):
|
|
safe_int = self._get_safe_int()
|
|
self.assertEqual(safe_int(80), 80)
|
|
|
|
def test_float_truncate(self):
|
|
safe_int = self._get_safe_int()
|
|
self.assertEqual(safe_int(75.6), 75)
|
|
|
|
def test_string_numeric(self):
|
|
safe_int = self._get_safe_int()
|
|
self.assertEqual(safe_int("80"), 80)
|
|
|
|
def test_string_with_unit(self):
|
|
"""LLM may return '80分' instead of 80."""
|
|
safe_int = self._get_safe_int()
|
|
self.assertEqual(safe_int("80分"), 80)
|
|
|
|
def test_string_with_percent(self):
|
|
safe_int = self._get_safe_int()
|
|
self.assertEqual(safe_int("75%"), 75)
|
|
|
|
def test_none_default(self):
|
|
safe_int = self._get_safe_int()
|
|
self.assertEqual(safe_int(None), 50)
|
|
self.assertEqual(safe_int(None, 60), 60)
|
|
|
|
def test_empty_string(self):
|
|
safe_int = self._get_safe_int()
|
|
self.assertEqual(safe_int(""), 50)
|
|
|
|
def test_non_numeric_string(self):
|
|
safe_int = self._get_safe_int()
|
|
self.assertEqual(safe_int("high"), 50)
|
|
|
|
def test_negative(self):
|
|
safe_int = self._get_safe_int()
|
|
self.assertEqual(safe_int("-10"), -10)
|
|
|
|
|
|
# ============================================================
|
|
# Skill activation semantics
|
|
# ============================================================
|
|
|
|
class TestSkillActivation(unittest.TestCase):
|
|
"""Test that skill activation follows the correct semantics."""
|
|
|
|
def test_skills_default_disabled(self):
|
|
"""After registration, skills should be disabled by default."""
|
|
from src.agent.skills.base import SkillManager, Skill
|
|
|
|
manager = SkillManager()
|
|
# Create a fresh Skill with default enabled=False
|
|
test_skill = Skill(
|
|
name="test_disabled",
|
|
display_name="Test",
|
|
description="test",
|
|
instructions="test",
|
|
)
|
|
manager.register(test_skill)
|
|
active = manager.list_active_skills()
|
|
self.assertEqual(len(active), 0, "Skills should be disabled by default")
|
|
|
|
def test_activate_all(self):
|
|
"""activate(['all']) should enable all registered skills."""
|
|
from src.agent.skills.base import SkillManager, Skill
|
|
|
|
manager = SkillManager()
|
|
# Create test skills instead of importing deleted Python modules
|
|
skill1 = Skill(name="dragon_head", display_name="龙头策略",
|
|
description="test", instructions="test")
|
|
skill2 = Skill(name="shrink_pullback", display_name="缩量回踩",
|
|
description="test", instructions="test")
|
|
manager.register(skill1)
|
|
manager.register(skill2)
|
|
manager.activate(["all"])
|
|
active = manager.list_active_skills()
|
|
self.assertEqual(len(active), 2)
|
|
|
|
def test_activate_specific(self):
|
|
"""activate with specific names should only enable those."""
|
|
from src.agent.skills.base import SkillManager, Skill
|
|
|
|
manager = SkillManager()
|
|
skill1 = Skill(name="dragon_head", display_name="龙头策略",
|
|
description="test", instructions="test")
|
|
skill2 = Skill(name="shrink_pullback", display_name="缩量回踩",
|
|
description="test", instructions="test")
|
|
skill3 = Skill(name="volume_breakout", display_name="放量突破",
|
|
description="test", instructions="test")
|
|
manager.register(skill1)
|
|
manager.register(skill2)
|
|
manager.register(skill3)
|
|
manager.activate(["dragon_head"])
|
|
active = manager.list_active_skills()
|
|
self.assertEqual(len(active), 1)
|
|
self.assertEqual(active[0].name, "dragon_head")
|
|
|
|
def test_empty_config_uses_primary_default_skill(self):
|
|
"""Empty agent_skills config should activate the primary default skill only."""
|
|
from src.agent.skills.base import SkillManager
|
|
from src.agent.skills.defaults import get_default_active_skill_ids
|
|
|
|
skill_manager = SkillManager()
|
|
count = skill_manager.load_builtin_strategies()
|
|
self.assertEqual(count, len(_builtin_strategy_names()), "Should load all built-in strategies from YAML")
|
|
|
|
default_ids = get_default_active_skill_ids(skill_manager.list_skills())
|
|
self.assertEqual(default_ids, ["bull_trend"])
|
|
skill_manager.activate(default_ids)
|
|
|
|
active = skill_manager.list_active_skills()
|
|
self.assertEqual([skill.name for skill in active], ["bull_trend"])
|
|
|
|
def test_sentiment_score_parsed_from_dashboard(self):
|
|
"""Verify _agent_result_to_analysis_result handles non-numeric sentiment_score."""
|
|
with patch('src.core.pipeline.get_config') as mock_config, \
|
|
patch('src.core.pipeline.get_db'), \
|
|
patch('src.core.pipeline.DataFetcherManager'), \
|
|
patch('src.core.pipeline.GeminiAnalyzer'), \
|
|
patch('src.core.pipeline.NotificationService'), \
|
|
patch('src.core.pipeline.SearchService'):
|
|
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.max_workers = 2
|
|
mock_cfg.agent_mode = True
|
|
mock_cfg.agent_max_steps = 10
|
|
mock_cfg.agent_skills = []
|
|
mock_cfg.bocha_api_keys = []
|
|
mock_cfg.tavily_api_keys = []
|
|
mock_cfg.brave_api_keys = []
|
|
mock_cfg.serpapi_keys = []
|
|
mock_cfg.searxng_base_urls = []
|
|
mock_cfg.searxng_public_instances_enabled = False
|
|
mock_cfg.news_max_age_days = 7
|
|
mock_cfg.enable_realtime_quote = True
|
|
mock_cfg.enable_chip_distribution = True
|
|
mock_cfg.realtime_source_priority = []
|
|
mock_cfg.save_context_snapshot = False
|
|
mock_config.return_value = mock_cfg
|
|
|
|
from src.core.pipeline import StockAnalysisPipeline
|
|
from src.agent.executor import AgentResult
|
|
from src.enums import ReportType
|
|
pipeline = StockAnalysisPipeline(config=mock_cfg)
|
|
|
|
# Dashboard with "80分" instead of 80
|
|
agent_result = AgentResult(
|
|
success=True,
|
|
content="{}",
|
|
dashboard={
|
|
"stock_name": "TestCo",
|
|
"sentiment_score": "80分",
|
|
"trend_prediction": "看多",
|
|
"operation_advice": "买入",
|
|
"decision_type": "buy",
|
|
},
|
|
provider="gemini",
|
|
)
|
|
|
|
result = pipeline._agent_result_to_analysis_result(
|
|
agent_result, "600519", "TestCo", ReportType.SIMPLE, "q1"
|
|
)
|
|
self.assertEqual(result.sentiment_score, 80)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|