mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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. **等待响应**:同步模式下分析约需 2–5 分钟,请确保 HTTP 客户端超时足够(建议 ≥300 秒)。
|
||||
4. **解析结果**:从响应的 `report.summary` 中提取 `operation_advice`、`trend_prediction`、`analysis_summary`,从 `report.strategy` 中提取 `ideal_buy`、`stop_loss`、`take_profit`,以简洁格式呈现给用户。
|
||||
5. **错误处理**:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()})()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user