feat: Web 个股分析支持选择策略 (#1331)

* feat: support strategy selection in web analysis

* fix(review-feedback-1331): The worker receives the original skills list, while TaskInfo stores a

* fix(review-feedback-1331): 补充对应 Web/API 文档,或在 PR 中明确说明现有哪份文档已覆盖且本次无需更新的依据
This commit is contained in:
mumu
2026-05-17 22:59:49 +08:00
committed by GitHub
parent 48de69ec53
commit 0a987086d3
17 changed files with 497 additions and 14 deletions

View File

@@ -324,6 +324,7 @@ def _handle_async_analysis_batch(
original_query = request.original_query if (is_single or preserve_batch_metadata) else None
selection_source = request.selection_source if (is_single or preserve_batch_metadata) else None
notify = getattr(request, "notify", True)
skills = getattr(request, "skills", None)
submit_kwargs = dict(
stock_codes=stock_codes,
@@ -334,6 +335,8 @@ def _handle_async_analysis_batch(
force_refresh=request.force_refresh,
notify=notify,
)
if skills is not None:
submit_kwargs["skills"] = skills
accepted_tasks, duplicate_errors = task_queue.submit_tasks_batch(**submit_kwargs)
@@ -415,6 +418,7 @@ def _handle_sync_analysis(
force_refresh=request.force_refresh,
query_id=query_id,
send_notification=getattr(request, "notify", True),
skills=getattr(request, "skills", None),
)
if result is None:
@@ -748,6 +752,7 @@ def get_analysis_status(task_id: str) -> TaskStatus:
stock_name=task.stock_name,
original_query=task.original_query,
selection_source=task.selection_source,
skills=getattr(task, "skills", None),
)
# 2. 从数据库查询已完成的记录
@@ -789,8 +794,12 @@ def get_analysis_status(task_id: str) -> TaskStatus:
# Extract current_price / change_pct from context_snapshot
current_price = None
change_pct = None
skills = None
context_snapshot = parse_json_field(getattr(record, 'context_snapshot', None))
if context_snapshot and isinstance(context_snapshot, dict):
raw_skills = context_snapshot.get("skills")
if isinstance(raw_skills, list):
skills = [str(skill) for skill in raw_skills]
enhanced_context = context_snapshot.get('enhanced_context') or {}
realtime = enhanced_context.get('realtime') or {}
current_price = realtime.get('price')
@@ -841,7 +850,8 @@ def get_analysis_status(task_id: str) -> TaskStatus:
report=report_dict,
created_at=record.created_at.isoformat() if record.created_at else datetime.now().isoformat()
),
error=None
error=None,
skills=skills,
)
except Exception as e:

View File

@@ -13,7 +13,7 @@
from typing import Optional, List, Any
from enum import Enum
from pydantic import BaseModel, Field
from pydantic import AliasChoices, BaseModel, Field
from src.utils.analysis_metadata import SELECTION_SOURCE_PATTERN
@@ -71,6 +71,12 @@ class AnalyzeRequest(BaseModel):
True,
description="是否发送推送通知Telegram/企业微信等)"
)
skills: Optional[List[str]] = Field(
None,
validation_alias=AliasChoices("skills", "strategies"),
description="本次分析使用的策略 skill ID 列表;兼容 legacy strategies 字段",
example=["bull_trend", "growth_quality"]
)
class Config:
json_schema_extra = {
@@ -82,7 +88,8 @@ class AnalyzeRequest(BaseModel):
"stock_name": "贵州茅台",
"original_query": "茅台",
"selection_source": "autocomplete",
"notify": True
"notify": True,
"skills": ["bull_trend"]
}
}
@@ -259,6 +266,7 @@ class TaskStatus(BaseModel):
description="选择来源",
pattern=SELECTION_SOURCE_PATTERN,
)
skills: Optional[List[str]] = Field(None, description="本次任务使用的策略 skill ID 列表")
class Config:
json_schema_extra = {
@@ -271,7 +279,8 @@ class TaskStatus(BaseModel):
"error": None,
"stock_name": "贵州茅台",
"original_query": "茅台",
"selection_source": "autocomplete"
"selection_source": "autocomplete",
"skills": ["bull_trend"]
}
}
@@ -300,6 +309,7 @@ class TaskInfo(BaseModel):
description="选择来源",
pattern=SELECTION_SOURCE_PATTERN,
)
skills: Optional[List[str]] = Field(None, description="本次任务使用的策略 skill ID 列表")
class Config:
json_schema_extra = {
@@ -316,7 +326,8 @@ class TaskInfo(BaseModel):
"completed_at": None,
"error": None,
"original_query": "茅台",
"selection_source": "autocomplete"
"selection_source": "autocomplete",
"skills": ["bull_trend"]
}
}

View File

@@ -30,6 +30,7 @@ export const analysisApi = {
stock_name: data.stockName,
original_query: data.originalQuery,
selection_source: data.selectionSource,
skills: data.skills,
...(data.notify !== undefined && { notify: data.notify }),
};
@@ -63,6 +64,7 @@ export const analysisApi = {
stock_name: data.stockName,
original_query: data.originalQuery,
selection_source: data.selectionSource,
skills: data.skills,
...(data.notify !== undefined && { notify: data.notify }),
};

View File

@@ -1,9 +1,10 @@
import type React from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BarChart3 } from 'lucide-react';
import { BarChart3, Check, SlidersHorizontal } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { getParsedApiError, type ParsedApiError } from '../api/error';
import { analysisApi } from '../api/analysis';
import { agentApi, type SkillInfo } from '../api/agent';
import { systemConfigApi } from '../api/systemConfig';
import { ApiErrorAlert, ConfirmDialog, Button, EmptyState, InlineAlert } from '../components/common';
import { DashboardStateBlock } from '../components/dashboard';
@@ -30,8 +31,15 @@ const HomePage: React.FC = () => {
const [marketReviewError, setMarketReviewError] = useState<ParsedApiError | null>(null);
const [marketReviewReport, setMarketReviewReport] = useState<string | null>(null);
const [marketReviewReportCopied, setMarketReviewReportCopied] = useState(false);
const [analysisSkills, setAnalysisSkills] = useState<SkillInfo[]>([]);
const [selectedStrategyId, setSelectedStrategyId] = useState('');
const [strategyMenuOpen, setStrategyMenuOpen] = useState(false);
const marketReviewPollTimer = useRef<number | null>(null);
const dashboardScrollRef = useRef<HTMLElement | null>(null);
const strategyMenuRef = useRef<HTMLDivElement | null>(null);
const strategyButtonRef = useRef<HTMLButtonElement | null>(null);
const strategyItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const strategyInitialFocusIndexRef = useRef<number | null>(null);
const stopMarketReviewPolling = useCallback(() => {
if (marketReviewPollTimer.current !== null) {
@@ -117,9 +125,154 @@ const HomePage: React.FC = () => {
};
}, []);
useEffect(() => {
let active = true;
agentApi.getSkills()
.then((response) => {
if (active) {
setAnalysisSkills(response.skills);
}
})
.catch(() => {
if (active) {
setAnalysisSkills([]);
}
});
return () => {
active = false;
};
}, []);
useEffect(() => {
if (!strategyMenuOpen) {
return;
}
const handlePointerDown = (event: MouseEvent) => {
const target = event.target;
if (target instanceof Node && strategyMenuRef.current?.contains(target)) {
return;
}
setStrategyMenuOpen(false);
};
document.addEventListener('mousedown', handlePointerDown);
return () => document.removeEventListener('mousedown', handlePointerDown);
}, [strategyMenuOpen]);
useEffect(() => {
if (selectedStrategyId && !analysisSkills.some((skill) => skill.id === selectedStrategyId)) {
setSelectedStrategyId('');
}
}, [analysisSkills, selectedStrategyId]);
const reportLanguage = normalizeReportLanguage(selectedReport?.meta.reportLanguage);
const reportText = getReportText(reportLanguage);
const isMarketReviewHistoryReport = selectedReport?.meta.reportType === 'market_review';
const selectedStrategy = useMemo(
() => analysisSkills.find((skill) => skill.id === selectedStrategyId),
[analysisSkills, selectedStrategyId],
);
const selectedAnalysisSkills = useMemo(
() => (selectedStrategyId ? [selectedStrategyId] : undefined),
[selectedStrategyId],
);
const strategyOptions = useMemo(
() => [
{ id: '', name: '默认策略', description: '沿用系统默认分析框架' },
...analysisSkills.map((skill) => ({
id: skill.id,
name: skill.name,
description: skill.description,
})),
],
[analysisSkills],
);
const closeStrategyMenu = useCallback((restoreFocus = false) => {
setStrategyMenuOpen(false);
if (restoreFocus) {
strategyButtonRef.current?.focus();
}
}, []);
const selectStrategy = useCallback((strategyId: string) => {
setSelectedStrategyId(strategyId);
setStrategyMenuOpen(false);
}, []);
const focusStrategyItem = useCallback((index: number) => {
const itemCount = strategyOptions.length;
if (itemCount === 0) {
return;
}
const nextIndex = (index + itemCount) % itemCount;
strategyItemRefs.current[nextIndex]?.focus();
}, [strategyOptions.length]);
const getSelectedStrategyIndex = useCallback(() => {
const selectedIndex = strategyOptions.findIndex((option) => option.id === selectedStrategyId);
return selectedIndex >= 0 ? selectedIndex : 0;
}, [selectedStrategyId, strategyOptions]);
useEffect(() => {
strategyItemRefs.current = strategyItemRefs.current.slice(0, strategyOptions.length);
}, [strategyOptions.length]);
useEffect(() => {
if (!strategyMenuOpen) {
return undefined;
}
const targetIndex = strategyInitialFocusIndexRef.current ?? getSelectedStrategyIndex();
strategyInitialFocusIndexRef.current = null;
const timeout = window.setTimeout(() => focusStrategyItem(targetIndex), 0);
return () => window.clearTimeout(timeout);
}, [focusStrategyItem, getSelectedStrategyIndex, strategyMenuOpen]);
const handleStrategyButtonKeyDown = useCallback((event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') {
return;
}
event.preventDefault();
const targetIndex = event.key === 'ArrowUp' ? strategyOptions.length - 1 : 0;
if (strategyMenuOpen) {
focusStrategyItem(targetIndex);
return;
}
strategyInitialFocusIndexRef.current = targetIndex;
setStrategyMenuOpen(true);
}, [focusStrategyItem, strategyMenuOpen, strategyOptions.length]);
const handleStrategyMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
const itemCount = strategyOptions.length;
if (itemCount === 0) {
return;
}
const currentIndex = strategyItemRefs.current.findIndex((item) => item === document.activeElement);
switch (event.key) {
case 'Escape':
event.preventDefault();
closeStrategyMenu(true);
break;
case 'ArrowDown':
event.preventDefault();
focusStrategyItem(currentIndex >= 0 ? currentIndex + 1 : 0);
break;
case 'ArrowUp':
event.preventDefault();
focusStrategyItem(currentIndex >= 0 ? currentIndex - 1 : itemCount - 1);
break;
case 'Home':
event.preventDefault();
focusStrategyItem(0);
break;
case 'End':
event.preventDefault();
focusStrategyItem(itemCount - 1);
break;
case 'Tab':
setStrategyMenuOpen(false);
break;
default:
break;
}
}, [closeStrategyMenu, focusStrategyItem, strategyOptions.length]);
const setupNeedsAction = setupStatus ? !setupStatus.isComplete : false;
const setupMissingLabels = useMemo(() => {
if (!setupStatus) {
@@ -156,9 +309,10 @@ const HomePage: React.FC = () => {
stockName,
originalQuery: query,
selectionSource: selectionSource ?? 'manual',
skills: selectedAnalysisSkills,
});
},
[query, submitAnalysis],
[query, selectedAnalysisSkills, submitAnalysis],
);
const handleAskFollowUp = useCallback(() => {
@@ -183,8 +337,9 @@ const HomePage: React.FC = () => {
originalQuery: selectedReport.meta.stockCode,
selectionSource: 'manual',
forceRefresh: true,
skills: selectedAnalysisSkills,
});
}, [selectedReport, submitAnalysis]);
}, [selectedAnalysisSkills, selectedReport, submitAnalysis]);
const pollMarketReviewStatus = useCallback(
async (taskId: string) => {
@@ -388,7 +543,7 @@ const HomePage: React.FC = () => {
className="flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden md:flex-row sm:h-[calc(100vh-5.5rem)] lg:h-[calc(100vh-2rem)]"
>
<div className="flex-1 flex flex-col min-h-0 min-w-0 max-w-full lg:max-w-6xl mx-auto w-full">
<header className="flex min-w-0 flex-shrink-0 items-center overflow-hidden px-3 py-3 md:px-4 md:py-4">
<header className="relative z-30 flex min-w-0 flex-shrink-0 items-center overflow-visible px-3 py-3 md:px-4 md:py-4">
<div className="flex min-w-0 flex-1 flex-col gap-2.5 md:flex-row md:items-center">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<button
@@ -412,6 +567,58 @@ const HomePage: React.FC = () => {
className={inputError ? 'border-danger/50' : undefined}
/>
</div>
{analysisSkills.length > 0 ? (
<div ref={strategyMenuRef} className="relative flex-shrink-0">
<button
ref={strategyButtonRef}
id="strategy-menu-button"
type="button"
aria-haspopup="menu"
aria-expanded={strategyMenuOpen}
aria-controls={strategyMenuOpen ? 'strategy-menu' : undefined}
onClick={() => setStrategyMenuOpen((open) => !open)}
onKeyDown={handleStrategyButtonKeyDown}
disabled={isAnalyzing}
className="home-surface-button flex h-10 max-w-[8.5rem] items-center gap-1.5 rounded-xl px-3 text-xs text-foreground disabled:cursor-not-allowed disabled:opacity-60 sm:max-w-[11rem]"
>
<SlidersHorizontal className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span className="truncate">{selectedStrategy?.name || '策略'}</span>
</button>
{strategyMenuOpen ? (
<div
id="strategy-menu"
role="menu"
aria-labelledby="strategy-menu-button"
onKeyDown={handleStrategyMenuKeyDown}
className="absolute right-0 top-11 z-[120] max-h-80 w-[min(18rem,calc(100vw-1.5rem))] overflow-y-auto rounded-xl border border-subtle bg-elevated p-1.5 text-sm text-foreground shadow-2xl"
>
{strategyOptions.map((option, index) => {
const selected = selectedStrategyId === option.id;
return (
<button
key={option.id || 'default'}
ref={(node) => {
strategyItemRefs.current[index] = node;
}}
type="button"
role="menuitemradio"
aria-checked={selected}
tabIndex={-1}
onClick={() => selectStrategy(option.id)}
className="flex w-full items-start gap-2 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-hover"
>
<Check className={`mt-0.5 h-4 w-4 flex-shrink-0 ${selected ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
<span className="min-w-0">
<span className="block font-medium">{option.name}</span>
<span className="mt-0.5 line-clamp-2 block text-xs leading-5 text-muted-text">{option.description}</span>
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
</div>
<div className="flex min-w-0 flex-shrink-0 items-center gap-2.5">
<label className="flex h-10 flex-shrink-0 cursor-pointer items-center gap-1.5 rounded-xl border border-subtle bg-surface/60 px-3 text-xs text-secondary-text select-none transition-colors hover:border-subtle-hover hover:text-foreground">

View File

@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { analysisApi, DuplicateTaskError } from '../../api/analysis';
import { agentApi } from '../../api/agent';
import { historyApi } from '../../api/history';
import { systemConfigApi } from '../../api/systemConfig';
import { useStockPoolStore } from '../../stores';
@@ -46,6 +47,12 @@ vi.mock('../../api/systemConfig', () => ({
},
}));
vi.mock('../../api/agent', () => ({
agentApi: {
getSkills: vi.fn(),
},
}));
vi.mock('../../hooks/useTaskStream', () => ({
useTaskStream: vi.fn(),
}));
@@ -110,6 +117,7 @@ describe('HomePage', () => {
vi.clearAllMocks();
navigateMock.mockReset();
useStockPoolStore.getState().resetDashboardState();
vi.mocked(agentApi.getSkills).mockResolvedValue({ skills: [], default_skill_id: '' });
vi.mocked(systemConfigApi.getSetupStatus).mockResolvedValue({
isComplete: true,
readyForSmoke: true,
@@ -519,6 +527,89 @@ describe('HomePage', () => {
}));
});
it('passes the selected strategy when submitting stock analysis', async () => {
vi.mocked(agentApi.getSkills).mockResolvedValue({
default_skill_id: 'bull_trend',
skills: [
{ id: 'bull_trend', name: '默认多头趋势', description: '趋势分析' },
{ id: 'growth_quality', name: '成长质量', description: '成长股分析' },
],
});
vi.mocked(historyApi.getList).mockResolvedValue({
total: 0,
page: 1,
limit: 20,
items: [],
});
vi.mocked(analysisApi.analyzeAsync).mockResolvedValue({
taskId: 'task-strategy-1',
status: 'pending',
});
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: '策略' }));
fireEvent.click(screen.getByRole('menuitemradio', { name: /成长质量/ }));
const input = screen.getByPlaceholderText('输入股票代码或名称,如 600519、贵州茅台、AAPL');
fireEvent.change(input, { target: { value: '600519' } });
fireEvent.click(screen.getByRole('button', { name: '分析' }));
await waitFor(() => {
expect(analysisApi.analyzeAsync).toHaveBeenCalledWith(expect.objectContaining({
stockCode: '600519',
skills: ['growth_quality'],
}));
});
});
it('supports keyboard navigation in the strategy menu', async () => {
vi.mocked(agentApi.getSkills).mockResolvedValue({
default_skill_id: 'bull_trend',
skills: [
{ id: 'bull_trend', name: '默认多头趋势', description: '趋势分析' },
{ id: 'growth_quality', name: '成长质量', description: '成长股分析' },
],
});
vi.mocked(historyApi.getList).mockResolvedValue({
total: 0,
page: 1,
limit: 20,
items: [],
});
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
const trigger = await screen.findByRole('button', { name: '策略' });
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
const defaultOption = await screen.findByRole('menuitemradio', { name: /默认策略/ });
await waitFor(() => {
expect(defaultOption).toHaveFocus();
});
const menu = screen.getByRole('menu');
fireEvent.keyDown(menu, { key: 'ArrowDown' });
expect(screen.getByRole('menuitemradio', { name: /默认多头趋势/ })).toHaveFocus();
fireEvent.keyDown(menu, { key: 'End' });
expect(screen.getByRole('menuitemradio', { name: /成长质量/ })).toHaveFocus();
fireEvent.keyDown(menu, { key: 'Escape' });
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
expect(trigger).toHaveFocus();
});
it('disables stock reanalysis and follow-up for market review history reports', async () => {
vi.mocked(historyApi.getList).mockResolvedValue({
total: 1,

View File

@@ -24,6 +24,7 @@ type SubmitAnalysisOptions = {
selectionSource?: SelectionSource;
notify?: boolean;
forceRefresh?: boolean;
skills?: string[];
};
let reportRequestSeq = 0;
@@ -310,6 +311,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
const originalQuery = (options?.originalQuery ?? state.query).trim();
const notify = options?.notify ?? state.notify;
const forceRefresh = options?.forceRefresh ?? false;
const skills = options?.skills;
if (!stockCodeInput) {
set({ inputError: '请输入股票代码', duplicateError: null });
@@ -348,6 +350,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
selectionSource,
notify,
forceRefresh,
skills,
});
if (requestId !== analyzeRequestSeq) {

View File

@@ -18,6 +18,7 @@ export interface AnalysisRequest {
originalQuery?: string;
selectionSource?: 'manual' | 'autocomplete' | 'import' | 'image';
notify?: boolean;
skills?: string[];
}
export interface MarketReviewRequest {
@@ -166,6 +167,7 @@ export interface TaskStatus {
stockName?: string;
originalQuery?: string;
selectionSource?: string;
skills?: string[];
}
/** Task details used by task list and SSE events */

View File

@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] AlphaVantage 适配器在 newest-first 原始数据下 pct_chg 计算错误:改为先按日期升序排序再计算涨跌幅。
- [修复] 美股日线路由未包含 Finnhub / AlphaVantage扩展 `get_daily_data()` 美股分支的 source_order 以覆盖新增数据源。
- [文档] 新增小白客户端安装与配置指南,说明桌面客户端下载、基础模型配置、新闻源配置和常见问题。
- [新功能] Web 首页个股分析支持选择策略。
## [3.17.1] - 2026-05-16

View File

@@ -1152,6 +1152,7 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。
- 📝 **配置管理** - 查看/修改自选股列表
- 🚀 **快速分析** - 通过 API 接口触发个股分析;首页也提供“大盘复盘”按钮,可在 Docker/server 模式下后台触发大盘复盘
- 🎯 **策略选择** - 首页支持显式选择分析策略 skill不传 `skills` 时按系统默认策略运行,便于保持与历史行为兼容
- 🧭 **首次配置提示** - 首页会读取只读配置状态,缺少 LLM 主渠道、自选股等基础项时提示缺口并引导进入系统设置
- 📊 **实时进度** - 分析任务状态实时更新,支持多任务并行;普通分析链路在进入 LLM 阶段后会优先尝试 LiteLLM 流式生成,并通过任务 SSE 回灌更细粒度的 `message/progress`
- 🗂️ **大盘复盘任务可见性** - 首页触发大盘复盘后会返回 `task_id` 并轮询 `GET /api/v1/analysis/status/{task_id}`,在进行中/完成/失败场景给出可见反馈,失败时直接透出报错内容
@@ -1180,6 +1181,8 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。
| `/docs` | GET | API Swagger 文档 |
> 说明:`POST /api/v1/analysis/analyze` 在 `async_mode=false` 时仅支持单只股票;批量 `stock_codes` 需使用 `async_mode=true`。异步 `202` 响应对单股返回 `task_id`,对批量返回 `accepted` / `duplicates` 汇总结构。
> 说明:`POST /api/v1/analysis/analyze` 支持使用 `skills` 传入策略 skill ID 列表;若未传则按服务端默认策略执行。为兼容历史调用,`strategies` 字段仍作为兼容别名保留。
> 说明Web 侧首页策略下拉为显式可选策略入口。用户未手动选择时不会携带 `skills`,与历史客户端行为一致;选择策略后将透传到该接口并在任务状态与历史快照中保留。
> 说明:`POST /api/v1/analysis/market-review` 采用后端与 CLI/Bot 共用的配置路径(`GeminiAnalyzer(config=...)` 与同样的搜索/提示词构造入口。Provider 兼容路由会优先识别并使用 `litellm_model`、`llm_model_list`,若未配置则回退 legacy `GEMINI_*`、`OPENAI_*`、`ANTHROPIC_*`、`DEEPSEEK_*` 键;不会新增/调整 provider、Base URL 或 LiteLLM 路由语义。
> 审计依据:优先级与回退语义以 `src/config.py` 的 `Config._load_from_env()` 为准(`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy。配套回归见 `tests/test_llm_channel_config.py`(配置源解析)与 `tests/test_market_review_runtime.py`(共享装配路径)。该接口当前仅提供单进程/单机级防重复能力,若为多实例部署需通过外部任务队列或分布式锁补齐全局幂等。
> 说明:`POST /api/v1/analysis/market-review` 触发后,报告会以 `report_type=market_review` 写入历史库;你可直接查询 `/api/v1/history` 或 `/api/v1/history/{record_id}` 获取历史 Markdown避免再次触发分析重算。
@@ -1209,6 +1212,11 @@ curl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \
-H 'Content-Type: application/json' \
-d '{"stock_code": "600519"}'
# 透传策略(可选)
curl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \
-H 'Content-Type: application/json' \
-d '{"stock_code": "600519", "skills": ["bull_trend", "growth_quality"]}'
# 查询任务状态
curl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>

View File

@@ -1011,6 +1011,7 @@ FastAPI provides RESTful API service for configuration management and triggering
- **Configuration Management** - View/modify watchlist
- **Quick Analysis** - Trigger stock analysis via API; the Home page also provides a Market Review button that starts a background market recap in Docker/server mode
- **Strategy selection** - The Home page supports explicitly selecting analysis strategy skills; when `skills` is omitted, analysis uses the server default strategy so legacy clients keep existing behavior
- **First-run Setup Hint** - The Home page reads the read-only setup status and points users to Settings when required items such as the primary LLM channel or watchlist are missing
- **Real-time Progress** - Analysis task status updates in real-time, supports parallel tasks; the regular stock-analysis path now prefers LiteLLM streaming during the LLM stage and pushes finer-grained `message/progress` updates through task SSE
- **Market Review visibility** - After clicking Market Review, the API returns a `task_id` and the UI polls `GET /api/v1/analysis/status/{task_id}` to show progress; completed/failure states are rendered explicitly and failure messages are shown directly in the UI error area.
@@ -1037,6 +1038,8 @@ FastAPI provides RESTful API service for configuration management and triggering
| `/docs` | GET | API Swagger documentation |
> Note: `POST /api/v1/analysis/analyze` supports only one stock when `async_mode=false`; batch `stock_codes` requires `async_mode=true`. The async `202` response returns a single `task_id` for one stock, or an `accepted` / `duplicates` summary for batch requests.
> Note: `POST /api/v1/analysis/analyze` accepts `skills` as an array of strategy IDs; if omitted, server defaults are used. The legacy field `strategies` is still accepted for backward compatibility.
> Note: The Web Home page exposes an explicit strategy selector. When users do not pick one, `skills` is not sent and legacy behavior is preserved; when selected, it is passed through to this endpoint and persisted in task status/history snapshots.
> Note: `POST /api/v1/analysis/market-review` follows the same runtime configuration path as CLI/Bot market review (`GeminiAnalyzer(config=...)`, search setup, and prompt/rendering pipeline). The provider compatibility path prioritizes `litellm_model` and `llm_model_list`, then falls back to existing legacy keys (`GEMINI_*`, `OPENAI_*`, `ANTHROPIC_*`, `DEEPSEEK_*`) when those are not set; provider names, Base URL, and LiteLLM routing semantics are otherwise unchanged.
> Audit note: priority and fallback are defined by `Config._load_from_env()` in `src/config.py` (`LITELLM_CONFIG` > `LLM_CHANNELS` > legacy). Regression coverage is in `tests/test_llm_channel_config.py` (configuration source parsing) and `tests/test_market_review_runtime.py` (shared runtime assembly). The endpoint lock is process/host-level only; multi-instance deployments still need external distributed idempotency controls.
> Note: Once `/api/v1/analysis/market-review` completes, the report is persisted with `report_type=market_review`; open `/api/v1/history` and `/api/v1/history/{record_id}` (or Markdown history endpoints) to view it directly without re-running analysis.
@@ -1066,6 +1069,11 @@ curl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \
-H 'Content-Type: application/json' \
-d '{"stock_code": "600519"}'
# pass strategy list (optional)
curl -X POST http://127.0.0.1:8000/api/v1/analysis/analyze \
-H 'Content-Type: application/json' \
-d '{"stock_code": "600519", "skills": ["bull_trend", "growth_quality"]}'
# Query task status
curl http://127.0.0.1:8000/api/v1/analysis/status/<task_id>

View File

@@ -132,8 +132,9 @@ metadata:
1. **提取股票代码**:从用户消息中识别股票代码(如 600519、AAPL、hk00700。若用户仅提供中文名称如「茅台」需提示用户提供股票代码或使用常见映射茅台→600519
2. **调用 API**:向 `{DSA_BASE_URL}/api/v1/analysis/analyze` 发送 POST 请求,请求体:
```json
{"stock_code": "<提取的代码>", "report_type": "detailed", "force_refresh": true, "async_mode": false}
{"stock_code": "<提取的代码>", "report_type": "detailed", "force_refresh": true, "async_mode": false, "skills": ["bull_trend"]}
```
> `skills` 为可选策略 ID 数组;历史字段 `strategies` 仍保留兼容,建议优先使用 `skills`。
3. **等待响应**:同步模式下分析约需 25 分钟,请确保 HTTP 客户端超时足够(建议 ≥300 秒)。
4. **解析结果**:从响应的 `report.summary` 中提取 `operation_advice`、`trend_prediction`、`analysis_summary`,从 `report.strategy` 中提取 `ideal_buy`、`stop_loss`、`take_profit`,以简洁格式呈现给用户。
5. **错误处理**

View File

@@ -84,6 +84,7 @@ class StockAnalysisPipeline:
query_source: Optional[str] = None,
save_context_snapshot: Optional[bool] = None,
progress_callback: Optional[Callable[[int, str], None]] = None,
analysis_skills: Optional[List[str]] = None,
):
"""
初始化调度器
@@ -101,13 +102,14 @@ class StockAnalysisPipeline:
self.config.save_context_snapshot if save_context_snapshot is None else save_context_snapshot
)
self.progress_callback = progress_callback
self.analysis_skills = list(analysis_skills) if analysis_skills is not None else None
# 初始化各模块
self.db = get_db()
self.fetcher_manager = DataFetcherManager()
# 不再单独创建 akshare_fetcher统一使用 fetcher_manager 获取增强数据
self.trend_analyzer = StockTrendAnalyzer() # 技术分析器
self.analyzer = GeminiAnalyzer(config=self.config)
self.analyzer = GeminiAnalyzer(config=self.config, skills=self.analysis_skills)
self.notifier = NotificationService(source_message=source_message)
self._single_stock_notify_lock = threading.Lock()
@@ -311,6 +313,10 @@ class StockAnalysisPipeline:
# API Key for the traditional analysis path are not silently
# switched to Agent mode (which is slower and more expensive).
use_agent = getattr(self.config, 'agent_mode', False)
if not use_agent:
if self.analysis_skills:
use_agent = True
logger.info(f"{stock_name}({code}) Auto-enabled agent mode due to request skills: {self.analysis_skills}")
if not use_agent:
# Auto-enable agent mode when specific skills are configured (e.g., scheduled task with strategy)
configured_skills = getattr(self.config, 'agent_skills', [])
@@ -790,8 +796,13 @@ class StockAnalysisPipeline:
from src.agent.factory import build_agent_executor
report_language = normalize_report_language(getattr(self.config, "report_language", "zh"))
requested_skills = (
self.analysis_skills
if self.analysis_skills is not None
else (getattr(self.config, 'agent_skills', None) or None)
)
# Build executor from shared factory (ToolRegistry and SkillManager prototype are cached)
executor = build_agent_executor(self.config, getattr(self.config, 'agent_skills', None) or None)
executor = build_agent_executor(self.config, requested_skills)
# Build initial context to avoid redundant tool calls
initial_context = {
@@ -801,6 +812,8 @@ class StockAnalysisPipeline:
"report_language": report_language,
"fundamental_context": fundamental_context,
}
if self.analysis_skills is not None:
initial_context["skills"] = self.analysis_skills
if realtime_quote:
initial_context["realtime_quote"] = self._safe_to_dict(realtime_quote)
@@ -1518,12 +1531,15 @@ class StockAnalysisPipeline:
"""
构建分析上下文快照
"""
return {
snapshot = {
"enhanced_context": enhanced_context,
"news_content": news_content,
"realtime_quote_raw": self._safe_to_dict(realtime_quote),
"chip_distribution_raw": self._safe_to_dict(chip_data),
}
if self.analysis_skills is not None:
snapshot["skills"] = list(self.analysis_skills)
return snapshot
@staticmethod
def _resolve_resume_target_date(

View File

@@ -12,7 +12,7 @@
import logging
import uuid
from typing import Optional, Dict, Any, Callable
from typing import Optional, Dict, Any, Callable, List
from src.repositories.analysis_repo import AnalysisRepository
from src.report_language import (
@@ -46,6 +46,7 @@ class AnalysisService:
query_id: Optional[str] = None,
send_notification: bool = True,
progress_callback: Optional[Callable[[int, str], None]] = None,
skills: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
"""
执行股票分析
@@ -83,6 +84,7 @@ class AnalysisService:
query_id=query_id,
query_source="api",
progress_callback=progress_callback,
analysis_skills=skills,
)
# 确定报告类型 (API: simple/detailed/full/brief -> ReportType)

View File

@@ -71,6 +71,7 @@ class TaskInfo:
completed_at: Optional[datetime] = None
original_query: Optional[str] = None
selection_source: Optional[str] = None
skills: Optional[List[str]] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert task info into an API-friendly dictionary."""
@@ -88,6 +89,7 @@ class TaskInfo:
"error": self.error,
"original_query": self.original_query,
"selection_source": self.selection_source,
"skills": self.skills,
}
def copy(self) -> 'TaskInfo':
@@ -107,6 +109,7 @@ class TaskInfo:
completed_at=self.completed_at,
original_query=self.original_query,
selection_source=self.selection_source,
skills=list(self.skills) if self.skills is not None else None,
)
@@ -300,6 +303,7 @@ class AnalysisTaskQueue:
selection_source: Optional[str] = None,
report_type: str = "detailed",
force_refresh: bool = False,
skills: Optional[List[str]] = None,
) -> TaskInfo:
"""
Submit a single analysis task.
@@ -329,6 +333,7 @@ class AnalysisTaskQueue:
selection_source=selection_source,
report_type=report_type,
force_refresh=force_refresh,
skills=skills,
)
if duplicates:
raise duplicates[0]
@@ -343,6 +348,7 @@ class AnalysisTaskQueue:
report_type: str = "detailed",
force_refresh: bool = False,
notify: bool = True,
skills: Optional[List[str]] = None,
) -> Tuple[List[TaskInfo], List[DuplicateTaskError]]:
"""
Submit analysis tasks in batch.
@@ -370,6 +376,7 @@ class AnalysisTaskQueue:
continue
task_id = uuid.uuid4().hex
task_skills = list(skills) if skills is not None else None
task_info = TaskInfo(
task_id=task_id,
stock_code=stock_code,
@@ -379,6 +386,7 @@ class AnalysisTaskQueue:
report_type=report_type,
original_query=original_query,
selection_source=selection_source,
skills=task_skills,
)
self._tasks[task_id] = task_info
self._analyzing_stocks[dedupe_key] = task_id
@@ -391,6 +399,7 @@ class AnalysisTaskQueue:
report_type,
force_refresh,
notify,
task_skills,
)
except Exception:
# Roll back the current batch to avoid partial submission.
@@ -573,6 +582,7 @@ class AnalysisTaskQueue:
report_type: str,
force_refresh: bool,
notify: bool = True,
skills: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]:
"""
执行分析任务(在线程池中运行)
@@ -615,6 +625,7 @@ class AnalysisTaskQueue:
query_id=task_id,
send_notification=notify,
progress_callback=_on_progress,
skills=skills,
)
if result:

View File

@@ -1248,6 +1248,46 @@ class TestPipelineRouting(unittest.TestCase):
# 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."""

View File

@@ -537,6 +537,26 @@ class AnalysisApiContractTestCase(unittest.TestCase):
)
def test_analysis_service_passes_request_skills_to_pipeline(self) -> None:
service = object.__new__(AnalysisService)
pipeline_instance = MagicMock()
pipeline_instance.process_single_stock.return_value = object()
request_skills = ["growth_quality"]
with patch("src.config.get_config", return_value=SimpleNamespace()), \
patch("src.core.pipeline.StockAnalysisPipeline", return_value=pipeline_instance) as pipeline_cls, \
patch.object(AnalysisService, "_build_analysis_response", return_value={"stock_code": "600519"}):
result = AnalysisService.analyze_stock(
service,
"600519",
report_type="full",
query_id="q1",
skills=request_skills,
)
self.assertEqual(result, {"stock_code": "600519"})
self.assertEqual(pipeline_cls.call_args.kwargs["analysis_skills"], request_skills)
def test_report_type_full_is_preserved_in_response_metadata(self) -> None:
service = AnalysisService()
pipeline_instance = MagicMock()
@@ -1524,6 +1544,42 @@ class BatchTaskQueueContractTestCase(unittest.TestCase):
self.assertEqual(duplicates, [])
self.assertEqual(sorted(task.stock_code for task in queue._tasks.values()), ["600519"])
def test_batch_submit_and_worker_use_copied_request_skills(self) -> None:
class CapturingExecutor:
def __init__(self) -> None:
self.calls = []
def submit(self, fn, *args, **kwargs):
self.calls.append((fn, args, kwargs))
return Future()
queue = AnalysisTaskQueue(max_workers=1)
executor = CapturingExecutor()
queue._executor = executor
request_skills = ["growth_quality"]
accepted, duplicates = queue.submit_tasks_batch(
["600519"],
report_type="detailed",
skills=request_skills,
)
request_skills.append("mutated_after_submit")
self.assertEqual(duplicates, [])
self.assertEqual(accepted[0].skills, ["growth_quality"])
self.assertIs(executor.calls[0][1][-1], accepted[0].skills)
service_instance = MagicMock()
service_instance.analyze_stock.return_value = {"stock_name": "贵州茅台"}
with patch("src.services.analysis_service.AnalysisService", return_value=service_instance):
executor.calls[0][0](*executor.calls[0][1])
self.assertIs(
service_instance.analyze_stock.call_args.kwargs["skills"],
accepted[0].skills,
)
self.assertEqual(service_instance.analyze_stock.call_args.kwargs["skills"], ["growth_quality"])
def test_batch_submit_deduplicates_equivalent_stock_code_shapes(self) -> None:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()

View File

@@ -44,6 +44,20 @@ class TestAnalyzeRequest:
assert request.original_query is None
assert request.selection_source is None
def test_analyze_request_accepts_strategy_skills_alias(self):
"""Test analysis requests accept both skills and legacy strategies."""
request = AnalyzeRequest(
stock_code="600519",
skills=["growth_quality"],
)
assert request.skills == ["growth_quality"]
legacy_request = AnalyzeRequest(
stock_code="600519",
strategies=["event_driven"],
)
assert legacy_request.skills == ["event_driven"]
def test_analyze_request_validation_selection_source(self):
"""Test selection_source field validation"""
# Valid selection_source values