fix: limit decision signals to AI page (#1737)

* fix: limit decision signals to AI page

* fix: preserve report signal lookup

* docs: sync decision signal guide

* fix(review-feedback-1737): add a report-id filter or deep link on /decision-signals before
This commit is contained in:
mumu
2026-06-21 16:45:12 +08:00
committed by GitHub
parent f61c5362a6
commit e1faabaa62
12 changed files with 109 additions and 695 deletions

View File

@@ -1,282 +0,0 @@
import type React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Activity } from 'lucide-react';
import { decisionSignalsApi } from '../../api/decisionSignals';
import { getParsedApiError, type ParsedApiError } from '../../api/error';
import { ApiErrorAlert, Card, Drawer, EmptyState } from '../common';
import {
DecisionSignalCard,
DecisionSignalDetails,
} from '../decision-signals/DecisionSignalDisplay';
import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { ReportType } from '../../types/analysis';
import type {
DecisionSignalFeedbackItem,
DecisionSignalItem,
DecisionSignalOutcomeItem,
} from '../../types/decisionSignals';
interface ReportDecisionSignalsProps {
recordId?: number;
reportType?: ReportType;
}
type DetailSidecarState = {
signalId: number | null;
outcomes: {
items: DecisionSignalOutcomeItem[];
loading: boolean;
error: ParsedApiError | null;
};
feedback: {
item: DecisionSignalFeedbackItem | null;
loading: boolean;
error: ParsedApiError | null;
};
};
function createDetailSidecarState(signalId: number | null = null, loading = false): DetailSidecarState {
return {
signalId,
outcomes: {
items: [],
loading,
error: null,
},
feedback: {
item: null,
loading,
error: null,
},
};
}
export const ReportDecisionSignals: React.FC<ReportDecisionSignalsProps> = ({
recordId,
reportType,
}) => {
const { t } = useUiLanguage();
const [items, setItems] = useState<DecisionSignalItem[]>([]);
const [selected, setSelected] = useState<DecisionSignalItem | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ParsedApiError | null>(null);
const [detailSidecar, setDetailSidecar] = useState<DetailSidecarState>(() => createDetailSidecarState());
const requestIdRef = useRef(0);
const detailRequestIdRef = useRef(0);
const shouldRender = Boolean(recordId) && reportType !== 'market_review';
const loadSignals = useCallback(async () => {
if (!recordId || reportType === 'market_review') return;
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
setLoading(true);
setItems([]);
setSelected(null);
detailRequestIdRef.current += 1;
setDetailSidecar(createDetailSidecarState());
setError(null);
try {
const response = await decisionSignalsApi.list({
sourceReportId: recordId,
sourceType: 'analysis',
page: 1,
pageSize: 20,
});
if (requestIdRef.current !== requestId) return;
setItems(response.items);
setError(null);
} catch (err) {
if (requestIdRef.current !== requestId) return;
setError(getParsedApiError(err));
setItems([]);
} finally {
if (requestIdRef.current === requestId) {
setLoading(false);
}
}
}, [recordId, reportType]);
useEffect(() => {
if (!shouldRender) {
requestIdRef.current += 1;
setLoading(false);
setItems([]);
setSelected(null);
detailRequestIdRef.current += 1;
setDetailSidecar(createDetailSidecarState());
setError(null);
return;
}
void loadSignals();
return () => {
requestIdRef.current += 1;
};
}, [loadSignals, shouldRender]);
useEffect(() => {
if (!selected) {
detailRequestIdRef.current += 1;
setDetailSidecar(createDetailSidecarState());
return;
}
const signalId = selected.id;
const requestId = detailRequestIdRef.current + 1;
detailRequestIdRef.current = requestId;
setDetailSidecar(createDetailSidecarState(signalId, true));
void decisionSignalsApi.getSignalOutcomes(signalId)
.then((response) => {
if (detailRequestIdRef.current !== requestId) return;
setDetailSidecar((current) => (
current.signalId === signalId
? {
...current,
outcomes: {
items: response.items,
loading: false,
error: null,
},
}
: current
));
})
.catch((err) => {
if (detailRequestIdRef.current !== requestId) return;
const parsed = getParsedApiError(err);
setDetailSidecar((current) => (
current.signalId === signalId
? {
...current,
outcomes: {
items: [],
loading: false,
error: parsed,
},
}
: current
));
});
void decisionSignalsApi.getFeedback(signalId)
.then((response) => {
if (detailRequestIdRef.current !== requestId) return;
setDetailSidecar((current) => (
current.signalId === signalId
? {
...current,
feedback: {
item: response,
loading: false,
error: null,
},
}
: current
));
})
.catch((err) => {
if (detailRequestIdRef.current !== requestId) return;
const parsed = getParsedApiError(err);
setDetailSidecar((current) => (
current.signalId === signalId
? {
...current,
feedback: {
item: null,
loading: false,
error: parsed,
},
}
: current
));
});
}, [selected]);
const handleSelectSignal = useCallback((item: DecisionSignalItem) => {
if (selected?.id === item.id) return;
detailRequestIdRef.current += 1;
setSelected(item);
setDetailSidecar(createDetailSidecarState(item.id, true));
}, [selected?.id]);
const handleCloseDetails = useCallback(() => {
detailRequestIdRef.current += 1;
setSelected(null);
setDetailSidecar(createDetailSidecarState());
}, []);
if (!shouldRender) {
return null;
}
const sidecarMatches = selected !== null && detailSidecar.signalId === selected.id;
const detailOutcomes = sidecarMatches
? detailSidecar.outcomes
: { items: [], loading: Boolean(selected), error: null };
const detailFeedback = sidecarMatches
? detailSidecar.feedback
: { item: null, loading: Boolean(selected), error: null };
return (
<>
<Card
title={t('decisionSignals.reportSectionTitle')}
subtitle={t('decisionSignals.reportSectionDescription')}
padding="md"
>
{error ? (
<ApiErrorAlert
error={{ ...error, title: t('decisionSignals.reportErrorTitle') }}
actionLabel={t('common.retry')}
onAction={() => void loadSignals()}
/>
) : null}
{loading && items.length === 0 ? (
<div className="grid gap-3">
<div className="h-24 animate-pulse rounded-2xl border border-border/70 bg-card/60" />
<div className="h-24 animate-pulse rounded-2xl border border-border/70 bg-card/60" />
</div>
) : null}
{!loading && !error && items.length === 0 ? (
<EmptyState
className="border-none bg-transparent py-6 shadow-none"
title={t('decisionSignals.reportEmptyTitle')}
description={t('decisionSignals.reportEmptyDescription')}
icon={<Activity className="h-6 w-6" />}
/>
) : null}
{items.length > 0 ? (
<div className="grid gap-3 lg:grid-cols-2">
{items.map((item) => (
<DecisionSignalCard
key={item.id}
item={item}
onSelect={handleSelectSignal}
selected={selected?.id === item.id}
/>
))}
</div>
) : null}
</Card>
<Drawer
isOpen={Boolean(selected)}
onClose={handleCloseDetails}
title={t('decisionSignals.detailTitle')}
width="max-w-3xl"
>
{selected ? (
<DecisionSignalDetails
item={selected}
outcomes={detailOutcomes.items}
outcomesLoading={detailOutcomes.loading}
outcomesError={detailOutcomes.error?.message ?? null}
feedback={detailFeedback.item}
feedbackLoading={detailFeedback.loading}
feedbackError={detailFeedback.error?.message ?? null}
/>
) : null}
</Drawer>
</>
);
};

View File

@@ -2,7 +2,6 @@ import React from 'react';
import type { AnalysisResult, AnalysisReport } from '../../types/analysis';
import { ReportOverview } from './ReportOverview';
import { ReportStrategy } from './ReportStrategy';
import { ReportDecisionSignals } from './ReportDecisionSignals';
import { ReportNews } from './ReportNews';
import { ReportDetails } from './ReportDetails';
import { ReportDiagnostics } from './ReportDiagnostics';
@@ -72,9 +71,6 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
{/* 策略点位区 */}
<ReportStrategy strategy={strategy} language={reportLanguage} />
{/* 从当前历史报告提取的结构化信号 */}
<ReportDecisionSignals recordId={recordId} reportType={meta.reportType} />
{/* 资讯区 */}
<ReportNews recordId={recordId} limit={8} language={reportLanguage} />

View File

@@ -292,5 +292,6 @@ describe('ReportSummary analysis context placement', () => {
expect(news.compareDocumentPosition(contextSummary) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(contextSummary.compareDocumentPosition(diagnostics) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(diagnostics.compareDocumentPosition(traceability) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.queryByText('AI 建议 / 决策信号')).not.toBeInTheDocument();
});
});

View File

@@ -1,378 +0,0 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import type React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { decisionSignalsApi } from '../../../api/decisionSignals';
import { UiLanguageProvider } from '../../../contexts/UiLanguageContext';
import type {
DecisionSignalFeedbackItem,
DecisionSignalItem,
DecisionSignalOutcomeItem,
} from '../../../types/decisionSignals';
import { ReportDecisionSignals } from '../ReportDecisionSignals';
vi.mock('../../../api/decisionSignals', () => ({
decisionSignalsApi: {
list: vi.fn(),
getSignalOutcomes: vi.fn(),
getFeedback: vi.fn(),
},
}));
const signal: DecisionSignalItem = {
id: 21,
stockCode: 'HK00700',
stockName: '腾讯控股',
market: 'hk',
sourceType: 'analysis',
sourceReportId: 5,
marketPhase: 'postmarket',
triggerSource: 'history',
action: 'watch',
actionLabel: null,
confidence: 0.6,
score: 70,
horizon: '5d',
entryLow: null,
entryHigh: null,
stopLoss: null,
targetPrice: null,
invalidation: null,
watchConditions: '观察回购强度',
reason: '结构等待确认',
riskSummary: '缺少成交量确认',
catalystSummary: null,
evidence: undefined,
dataQualitySummary: undefined,
planQuality: 'partial',
status: 'active',
expiresAt: null,
createdAt: '2026-06-17T08:00:00',
updatedAt: '2026-06-17T08:00:00',
metadata: undefined,
};
const nextSignal: DecisionSignalItem = {
...signal,
id: 22,
stockCode: 'AAPL',
stockName: 'Apple',
market: 'us',
reason: '第二条信号理由',
riskSummary: '等待财报确认',
};
const outcome: DecisionSignalOutcomeItem = {
id: 120,
signalId: 21,
horizon: '5d',
engineVersion: 'decision-signal-v1',
evalStatus: 'completed',
outcome: 'hit',
directionExpected: 'not_down',
directionCorrect: true,
holdingState: 'holding',
};
const nextOutcome: DecisionSignalOutcomeItem = {
...outcome,
id: 121,
signalId: 22,
outcome: 'neutral',
};
const feedback: DecisionSignalFeedbackItem = {
signalId: 21,
feedbackValue: 'useful',
reasonCode: null,
note: null,
source: 'web',
};
const emptyFeedback: DecisionSignalFeedbackItem = {
signalId: 21,
feedbackValue: null,
reasonCode: null,
note: null,
source: null,
};
function renderComponent(props: React.ComponentProps<typeof ReportDecisionSignals>) {
return render(
<UiLanguageProvider>
<ReportDecisionSignals {...props} />
</UiLanguageProvider>,
);
}
function deferredPromise<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
beforeEach(() => {
window.localStorage.clear();
window.localStorage.setItem('dsa.uiLanguage', 'zh');
vi.clearAllMocks();
vi.mocked(decisionSignalsApi.list).mockResolvedValue({
items: [signal],
total: 1,
page: 1,
pageSize: 20,
});
vi.mocked(decisionSignalsApi.getSignalOutcomes).mockResolvedValue({
items: [outcome],
total: 1,
page: 1,
pageSize: 20,
});
vi.mocked(decisionSignalsApi.getFeedback).mockResolvedValue(feedback);
});
describe('ReportDecisionSignals', () => {
it('loads and renders analysis-bound signals for a report record', async () => {
renderComponent({ recordId: 5, reportType: 'detailed' });
expect(await screen.findByText('腾讯控股')).toBeInTheDocument();
expect(screen.getByText('缺少成交量确认')).toBeInTheDocument();
expect(screen.getByText('5 日')).toBeInTheDocument();
expect(screen.getByText('计划质量: 部分')).toBeInTheDocument();
expect(screen.getByText('阶段: 盘后')).toBeInTheDocument();
expect(screen.queryByText('5d')).not.toBeInTheDocument();
expect(screen.queryByText('postmarket')).not.toBeInTheDocument();
expect(screen.queryByText('partial')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '查看 腾讯控股 AI 建议详情' }));
expect(within(await screen.findByRole('dialog')).getByText('结构等待确认')).toBeInTheDocument();
expect(await within(screen.getByRole('dialog')).findByText('命中')).toBeInTheDocument();
expect(within(screen.getByRole('dialog')).getByText('有用')).toBeInTheDocument();
expect(decisionSignalsApi.getSignalOutcomes).toHaveBeenCalledWith(21);
expect(decisionSignalsApi.getFeedback).toHaveBeenCalledWith(21);
expect(decisionSignalsApi.list).toHaveBeenCalledWith({
sourceReportId: 5,
sourceType: 'analysis',
page: 1,
pageSize: 20,
});
});
it('does not show a previous signal sidecar while the next signal details are loading', async () => {
const nextOutcomes = deferredPromise<{
items: DecisionSignalOutcomeItem[];
total: number;
page: number;
pageSize: number;
}>();
const nextFeedback = deferredPromise<DecisionSignalFeedbackItem>();
vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce({
items: [signal, nextSignal],
total: 2,
page: 1,
pageSize: 20,
});
vi.mocked(decisionSignalsApi.getSignalOutcomes).mockImplementation((signalId: number) => {
if (signalId === 21) {
return Promise.resolve({
items: [outcome],
total: 1,
page: 1,
pageSize: 20,
});
}
return nextOutcomes.promise;
});
vi.mocked(decisionSignalsApi.getFeedback).mockImplementation((signalId: number) => {
if (signalId === 21) return Promise.resolve(feedback);
return nextFeedback.promise;
});
renderComponent({ recordId: 5, reportType: 'detailed' });
fireEvent.click(await screen.findByRole('button', { name: '查看 腾讯控股 AI 建议详情' }));
let dialog = await screen.findByRole('dialog');
expect(await within(dialog).findByText('命中')).toBeInTheDocument();
expect(within(dialog).getByText('有用')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '查看 Apple AI 建议详情' }));
dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('第二条信号理由')).toBeInTheDocument();
expect(within(dialog).queryByText('命中')).not.toBeInTheDocument();
expect(within(dialog).queryByText('有用')).not.toBeInTheDocument();
expect(within(dialog).getAllByText(/正在加载/).length).toBeGreaterThanOrEqual(2);
});
it('keeps loaded sidecar data when selecting the current signal again', async () => {
renderComponent({ recordId: 5, reportType: 'detailed' });
fireEvent.click(await screen.findByRole('button', { name: '查看 腾讯控股 AI 建议详情' }));
const dialog = await screen.findByRole('dialog');
expect(await within(dialog).findByText('命中')).toBeInTheDocument();
expect(within(dialog).getByText('有用')).toBeInTheDocument();
expect(decisionSignalsApi.getSignalOutcomes).toHaveBeenCalledTimes(1);
expect(decisionSignalsApi.getFeedback).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '查看 腾讯控股 AI 建议详情' }));
expect(within(dialog).getByText('命中')).toBeInTheDocument();
expect(within(dialog).getByText('有用')).toBeInTheDocument();
expect(within(dialog).queryByText(/正在加载/)).not.toBeInTheDocument();
expect(decisionSignalsApi.getSignalOutcomes).toHaveBeenCalledTimes(1);
expect(decisionSignalsApi.getFeedback).toHaveBeenCalledTimes(1);
});
it('ignores stale sidecar responses after selecting another report signal', async () => {
const firstOutcomes = deferredPromise<{
items: DecisionSignalOutcomeItem[];
total: number;
page: number;
pageSize: number;
}>();
const firstFeedback = deferredPromise<DecisionSignalFeedbackItem>();
vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce({
items: [signal, nextSignal],
total: 2,
page: 1,
pageSize: 20,
});
vi.mocked(decisionSignalsApi.getSignalOutcomes).mockImplementation((signalId: number) => {
if (signalId === 21) return firstOutcomes.promise;
return Promise.resolve({
items: [nextOutcome],
total: 1,
page: 1,
pageSize: 20,
});
});
vi.mocked(decisionSignalsApi.getFeedback).mockImplementation((signalId: number) => {
if (signalId === 21) return firstFeedback.promise;
return Promise.resolve({
...emptyFeedback,
signalId: 22,
});
});
renderComponent({ recordId: 5, reportType: 'detailed' });
fireEvent.click(await screen.findByRole('button', { name: '查看 腾讯控股 AI 建议详情' }));
fireEvent.click(screen.getByRole('button', { name: '查看 Apple AI 建议详情' }));
const dialog = await screen.findByRole('dialog');
expect(await within(dialog).findByText('第二条信号理由')).toBeInTheDocument();
expect(await within(dialog).findByText('中性')).toBeInTheDocument();
await act(async () => {
firstOutcomes.resolve({
items: [outcome],
total: 1,
page: 1,
pageSize: 20,
});
firstFeedback.resolve(feedback);
await Promise.all([firstOutcomes.promise, firstFeedback.promise]);
});
expect(within(dialog).queryByText('命中')).not.toBeInTheDocument();
expect(within(dialog).queryByText('有用')).not.toBeInTheDocument();
expect(within(dialog).getByText('暂无反馈')).toBeInTheDocument();
});
it('shows independent sidecar error and empty feedback states', async () => {
vi.mocked(decisionSignalsApi.getSignalOutcomes).mockRejectedValueOnce(new Error('outcomes down'));
vi.mocked(decisionSignalsApi.getFeedback).mockResolvedValueOnce(emptyFeedback);
renderComponent({ recordId: 5, reportType: 'detailed' });
fireEvent.click(await screen.findByRole('button', { name: '查看 腾讯控股 AI 建议详情' }));
const dialog = await screen.findByRole('dialog');
expect(await within(dialog).findByText('outcomes down')).toBeInTheDocument();
expect(within(dialog).getByText('暂无反馈')).toBeInTheDocument();
});
it('shows an empty state when the report has no extracted signals', async () => {
vi.mocked(decisionSignalsApi.list).mockResolvedValueOnce({
items: [],
total: 0,
page: 1,
pageSize: 20,
});
renderComponent({ recordId: 5, reportType: 'detailed' });
expect(await screen.findByText('本报告暂无决策信号')).toBeInTheDocument();
});
it('clears previous report signals while loading another report', async () => {
const secondLoad = deferredPromise<{
items: DecisionSignalItem[];
total: number;
page: number;
pageSize: number;
}>();
vi.mocked(decisionSignalsApi.list)
.mockResolvedValueOnce({
items: [signal],
total: 1,
page: 1,
pageSize: 20,
})
.mockReturnValueOnce(secondLoad.promise);
const { rerender } = render(
<UiLanguageProvider>
<ReportDecisionSignals recordId={5} reportType="detailed" />
</UiLanguageProvider>,
);
expect(await screen.findByText('腾讯控股')).toBeInTheDocument();
rerender(
<UiLanguageProvider>
<ReportDecisionSignals recordId={6} reportType="detailed" />
</UiLanguageProvider>,
);
await waitFor(() => {
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith({
sourceReportId: 6,
sourceType: 'analysis',
page: 1,
pageSize: 20,
});
});
expect(screen.queryByText('腾讯控股')).not.toBeInTheDocument();
secondLoad.resolve({
items: [],
total: 0,
page: 1,
pageSize: 20,
});
expect(await screen.findByText('本报告暂无决策信号')).toBeInTheDocument();
});
it('shows an error state when loading report signals fails', async () => {
vi.mocked(decisionSignalsApi.list).mockRejectedValueOnce(new Error('network down'));
renderComponent({ recordId: 5, reportType: 'detailed' });
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('报告信号加载失败');
expect(alert).toHaveTextContent('network down');
});
it('does not render or request without a record id', () => {
const { container } = renderComponent({ reportType: 'detailed' });
expect(container).toBeEmptyDOMElement();
expect(decisionSignalsApi.list).not.toHaveBeenCalled();
});
it('does not render or request for market review reports', async () => {
const { container } = renderComponent({ recordId: 5, reportType: 'market_review' });
await waitFor(() => expect(decisionSignalsApi.list).not.toHaveBeenCalled());
expect(container).toBeEmptyDOMElement();
});
});

View File

@@ -3,7 +3,6 @@ export * from './ReportDiagnostics';
export * from './AnalysisContextSummary';
export * from './ReportOverview';
export * from './ReportStrategy';
export * from './ReportDecisionSignals';
export * from './ReportNews';
export * from './ReportDetails';
export * from './ReportMarkdown';

View File

@@ -274,11 +274,6 @@ const zh = {
'decisionSignals.pricePlan': '价格计划',
'decisionSignals.reason': '理由',
'decisionSignals.refresh': '刷新',
'decisionSignals.reportEmptyDescription': '该报告没有沉淀出结构化信号,可能是建议不明确或不属于普通个股分析。',
'decisionSignals.reportEmptyTitle': '本报告暂无决策信号',
'decisionSignals.reportErrorTitle': '报告信号加载失败',
'decisionSignals.reportSectionDescription': '这些信号来自当前历史报告的结构化提取结果。',
'decisionSignals.reportSectionTitle': 'AI 建议 / 决策信号',
'decisionSignals.returnPct': '区间收益',
'decisionSignals.riskSummary': '风险',
'decisionSignals.score': '评分',
@@ -289,6 +284,7 @@ const zh = {
'decisionSignals.sourceType.manual': '手动',
'decisionSignals.sourceType.market_review': '大盘复盘',
'decisionSignals.sourceReport': '来源报告',
'decisionSignals.sourceReportId': '来源报告 ID',
'decisionSignals.statsDescription': '基于当前后验引擎版本统计信号表现,默认排除已归档信号。',
'decisionSignals.statsErrorTitle': '后验统计加载失败',
'decisionSignals.statsHitRate': '命中率',
@@ -945,11 +941,6 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.pricePlan': 'Price plan',
'decisionSignals.reason': 'Reason',
'decisionSignals.refresh': 'Refresh',
'decisionSignals.reportEmptyDescription': 'This report did not produce a structured signal, possibly because the recommendation was unclear or the report was not a regular stock analysis.',
'decisionSignals.reportEmptyTitle': 'No decision signals for this report',
'decisionSignals.reportErrorTitle': 'Failed to load report signals',
'decisionSignals.reportSectionDescription': 'These signals were extracted from the current historical report.',
'decisionSignals.reportSectionTitle': 'AI recommendations / decision signals',
'decisionSignals.returnPct': 'Window return',
'decisionSignals.riskSummary': 'Risk',
'decisionSignals.score': 'Score',
@@ -960,6 +951,7 @@ const en: Record<UiTextKey, string> = {
'decisionSignals.sourceType.manual': 'Manual',
'decisionSignals.sourceType.market_review': 'Market review',
'decisionSignals.sourceReport': 'Source report',
'decisionSignals.sourceReportId': 'Source report ID',
'decisionSignals.statsDescription': 'Signal performance from the current outcome engine version, excluding archived signals by default.',
'decisionSignals.statsErrorTitle': 'Failed to load outcome stats',
'decisionSignals.statsHitRate': 'Hit rate',

View File

@@ -48,6 +48,7 @@ type ListFilters = {
action: '' | DecisionAction;
marketPhase: '' | MarketPhaseValue;
sourceType: '' | DecisionSignalSourceType;
sourceReportId: string;
status: '' | DecisionSignalStatus;
};
@@ -90,7 +91,44 @@ const STATUS_ACTION_CONFIRM_KEYS: Record<PendingStatusChange['status'], UiTextKe
archived: 'decisionSignals.archiveConfirm',
};
const DEFAULT_LIST_FILTERS: ListFilters = {
market: '',
stockCode: '',
action: '',
marketPhase: '',
sourceType: '',
sourceReportId: '',
status: 'active',
};
function parseSourceReportId(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function getInitialFilters(search = typeof window === 'undefined' ? '' : window.location.search): ListFilters {
const params = new URLSearchParams(search);
const sourceReportId = parseSourceReportId(params.get('sourceReportId') ?? params.get('source_report_id') ?? '');
if (sourceReportId === undefined) return DEFAULT_LIST_FILTERS;
return {
...DEFAULT_LIST_FILTERS,
sourceReportId: String(sourceReportId),
};
}
function toListParams(filters: ListFilters, page: number): DecisionSignalListParams {
const sourceReportId = parseSourceReportId(filters.sourceReportId);
if (sourceReportId !== undefined) {
return {
sourceReportId,
sourceType: 'analysis',
page,
pageSize: PAGE_SIZE,
};
}
return {
market: filters.market || undefined,
stockCode: filters.stockCode.trim() || undefined,
@@ -125,15 +163,8 @@ function formatStatPercent(value: number | null | undefined): string {
const DecisionSignalsPage: React.FC = () => {
const { t } = useUiLanguage();
const actionLabels = useMemo(() => buildDecisionActionLabelMap(t), [t]);
const [filters, setFilters] = useState<ListFilters>({
market: '',
stockCode: '',
action: '',
marketPhase: '',
sourceType: '',
status: 'active',
});
const [appliedFilters, setAppliedFilters] = useState<ListFilters>(filters);
const [filters, setFilters] = useState<ListFilters>(() => getInitialFilters());
const [appliedFilters, setAppliedFilters] = useState<ListFilters>(() => getInitialFilters());
const [page, setPage] = useState(1);
const [items, setItems] = useState<DecisionSignalItem[]>([]);
const [total, setTotal] = useState(0);
@@ -350,7 +381,7 @@ const DecisionSignalsPage: React.FC = () => {
if (current.source === 'latest') {
return updated.status === 'active' ? { source: 'latest', item: updated } : null;
}
if (appliedFilters.status && updated.status !== appliedFilters.status) return null;
if (!parseSourceReportId(appliedFilters.sourceReportId) && appliedFilters.status && updated.status !== appliedFilters.status) return null;
return { source: 'list', item: updated };
});
setError(null);
@@ -411,7 +442,7 @@ const DecisionSignalsPage: React.FC = () => {
/>
<Card padding="md">
<form className="grid gap-3 md:grid-cols-3 xl:grid-cols-6" onSubmit={handleApplyFilters}>
<form className="grid gap-3 md:grid-cols-3 xl:grid-cols-7" onSubmit={handleApplyFilters}>
<select
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm"
value={filters.market}
@@ -463,6 +494,17 @@ const DecisionSignalsPage: React.FC = () => {
<option key={source} value={source}>{getDecisionSignalSourceTypeLabel(source, t)}</option>
))}
</select>
<input
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm"
value={filters.sourceReportId}
onChange={(event) => setFilters((current) => ({ ...current, sourceReportId: event.target.value }))}
placeholder={t('decisionSignals.sourceReportId')}
aria-label={t('decisionSignals.sourceReportId')}
inputMode="numeric"
min={1}
step={1}
type="number"
/>
<select
className="input-surface input-focus-glow h-11 rounded-xl border bg-transparent px-3 text-sm"
value={filters.status}
@@ -472,7 +514,7 @@ const DecisionSignalsPage: React.FC = () => {
<option value="">{t('decisionSignals.allStatuses')}</option>
{STATUS_OPTIONS.map((status) => <option key={status} value={status}>{t(STATUS_LABEL_KEYS[status])}</option>)}
</select>
<button type="submit" className="btn-primary inline-flex h-11 items-center justify-center gap-2 xl:col-start-6">
<button type="submit" className="btn-primary inline-flex h-11 items-center justify-center gap-2">
<Search className="h-4 w-4" />
{t('decisionSignals.filter')}
</button>

View File

@@ -148,6 +148,7 @@ function deferredPromise<T>() {
}
beforeEach(() => {
window.history.pushState({}, '', '/');
window.localStorage.clear();
window.localStorage.setItem('dsa.uiLanguage', 'zh');
vi.clearAllMocks();
@@ -185,6 +186,23 @@ describe('DecisionSignalsPage', () => {
expect(screen.getByText(formattedCreatedAt)).toBeInTheDocument();
});
it('uses a source report id query parameter as an exact analysis lookup on load', async () => {
window.history.pushState({}, '', '/decision-signals?sourceReportId=3001&status=closed&market=cn');
renderPage();
expect(await screen.findByRole('heading', { name: 'AI 建议' })).toBeInTheDocument();
await waitFor(() => {
expect(decisionSignalsApi.list).toHaveBeenCalledWith({
sourceReportId: 3001,
sourceType: 'analysis',
page: 1,
pageSize: 20,
});
});
expect(screen.getByLabelText('来源报告 ID')).toHaveValue(3001);
});
it('renders decision signal enum filter labels in Chinese', async () => {
renderPage();
await screen.findByText('贵州茅台');
@@ -194,6 +212,7 @@ describe('DecisionSignalsPage', () => {
expect(within(screen.getByLabelText('阶段')).getByRole('option', { name: '午间休市' })).toHaveValue('lunch_break');
expect(within(screen.getByLabelText('阶段')).getByRole('option', { name: '集合竞价' })).toHaveValue('closing_auction');
expect(within(screen.getByLabelText('来源')).getByRole('option', { name: '大盘复盘' })).toHaveValue('market_review');
expect(screen.getByLabelText('来源报告 ID')).toBeInTheDocument();
});
it('renders decision signal filters and card value labels in English', async () => {
@@ -214,8 +233,10 @@ describe('DecisionSignalsPage', () => {
expect(within(screen.getByLabelText('Market')).getByRole('option', { name: 'Korea' })).toHaveValue('kr');
expect(within(screen.getByLabelText('Phase')).getByRole('option', { name: 'Closing auction' })).toHaveValue('closing_auction');
expect(within(screen.getByLabelText('Source')).getByRole('option', { name: 'Market review' })).toHaveValue('market_review');
expect(screen.getByLabelText('Source report ID')).toBeInTheDocument();
expect(screen.getAllByText('Japan').length).toBeGreaterThan(1);
expect(screen.getByText('Horizon: 10 days')).toBeInTheDocument();
expect(screen.getByText('Horizon')).toBeInTheDocument();
expect(screen.getByText('10 days')).toBeInTheDocument();
expect(screen.getByText('Plan quality: Partial')).toBeInTheDocument();
expect(screen.getByText('Phase: Closing auction')).toBeInTheDocument();
expect(screen.queryByText('10d')).not.toBeInTheDocument();
@@ -243,6 +264,28 @@ describe('DecisionSignalsPage', () => {
});
});
it('uses an exact analysis source report lookup when a report id filter is applied', async () => {
renderPage();
await screen.findByText('贵州茅台');
fireEvent.change(screen.getByLabelText('市场'), { target: { value: 'cn' } });
fireEvent.change(screen.getByLabelText('股票代码'), { target: { value: '600519' } });
fireEvent.change(screen.getByLabelText('动作'), { target: { value: 'hold' } });
fireEvent.change(screen.getByLabelText('来源'), { target: { value: 'alert' } });
fireEvent.change(screen.getByLabelText('状态'), { target: { value: 'closed' } });
fireEvent.change(screen.getByLabelText('来源报告 ID'), { target: { value: '3001' } });
fireEvent.click(screen.getByRole('button', { name: '筛选' }));
await waitFor(() => {
expect(decisionSignalsApi.list).toHaveBeenLastCalledWith({
sourceReportId: 3001,
sourceType: 'analysis',
page: 1,
pageSize: 20,
});
});
});
it('queries latest active signals by stock code', async () => {
renderPage();
await screen.findByText('贵州茅台');

View File

@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
- [修复] 修复日股/韩股历史列表重建市场阶段摘要时将 non_trading 等结果阶段误传为 analysis_phase 导致列表查询失败的问题。
- [改进] Web 历史报告详情不再内嵌展示 AI 建议卡片,结构化决策信号集中在 AI 建议页查询,并保留按来源报告 ID 筛选或 URL 参数精确定位入口。
## [3.23.0] - 2026-06-20

View File

@@ -64,11 +64,11 @@ Web 展示必须把这些 wire value 映射为当前 UI 语言的用户可读标
Web 入口位于 `/decision-signals`
- 默认查询 `status=active`
- 支持按市场、股票代码、动作、市场阶段、来源和状态筛选。
- 支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态筛选。
- market filter 已包含 `cn/hk/us/jp/kr`P7 只补齐 `jp/kr` 的本地化标签,不改变筛选范围。
- 详情抽屉展示动作、状态、评分、置信度、周期、计划质量、市场阶段、价格计划、风险、观察条件、证据、数据质量和 metadata。
- Web 只能把信号标记为 `closed``invalidated``archived`,不提供 terminal 状态恢复为 active。
- 报告页只在普通个股历史报告有 `recordId` 时读取该报告绑定的 `source_type=analysis` 信号;大盘复盘和无记录 ID 的报告不触发查询。历史报告内的详情抽屉会按当前选中的信号懒加载只读后验结果和用户反馈sidecar 加载失败只影响详情内对应区块,不阻断报告主体展示
- 历史报告详情不再内嵌展示报告绑定的 `source_type=analysis` 信号,也不会因打开报告详情触发 `source_report_id` 信号查询;需要查看报告来源信号时统一进入 `/decision-signals` 页面按来源报告 ID 精确筛选,或打开 `/decision-signals?sourceReportId=<recordId>` deep link。该筛选和 deep link 都会使用 `source_type=analysis + source_report_id` 的精确查询,以保留旧报告的 best-effort 懒回填入口
- 持仓页异步查询每个唯一持仓的 latest active 信号,单只查询失败只显示降级提示,不阻断组合快照或其他持仓信号。
所有用户可见枚举必须使用 i18n 标签;技术 ID、股票代码、API 字段名、env key、URL 示例可以保留英文。
@@ -113,7 +113,7 @@ P7 的全局验收是确认信号池、通知摘要和 Web 展示不泄露 token
迁移说明:
- 升级后无需新增 `.env``.env.example` 或 Web 设置项。
- 旧历史报告不会批量回填。普通个股历史报告页在精确查询 `source_type=analysis + source_report_id` 且无命中时,才会 best-effort 懒回填。
- 旧历史报告不会批量回填。只有显式调用信号列表接口或在 Web AI 建议页按来源报告 ID 触发精确查询 `source_type=analysis + source_report_id` 且无命中时,才会 best-effort 懒回填。
- 已存在的 `decision_signals`、feedback 和 outcome 数据保持兼容。
回滚说明:

View File

@@ -1333,7 +1333,7 @@ P3 开始,生命周期由 `DecisionSignalService` 统一补齐:显式传入
这些接口继承现有 `/api/v1/*` 管理员鉴权:`ADMIN_AUTH_ENABLED=true` 时必须携带有效管理员会话 Cookie本功能不新增独立认证方式。
#1390 P4 在 Web 端接入已有 `DecisionSignal` API不新增后端契约、数据库表或配置项。侧边栏新增“AI 建议”入口 `/decision-signals`,默认展示 `status=active` 的信号,并支持按市场、股票代码、动作、市场阶段、来源和状态筛选;页面还提供按股票代码查询最新 active 信号的入口。信号详情展示动作、置信度/评分、horizon、plan_quality、market_phase、价格计划、风险、观察条件、来源报告和数据质量Web 只允许把信号标记为 `closed`、`invalidated` 或 `archived`,不提供 terminal 状态恢复为 active。
#1390 P4 在 Web 端接入已有 `DecisionSignal` API不新增后端契约、数据库表或配置项。侧边栏“AI 建议”入口 `/decision-signals` 是结构化决策信号的集中查询入口,默认展示 `status=active` 的信号,并支持按市场、股票代码、动作、市场阶段、来源、来源报告 ID 和状态筛选;页面还提供按股票代码查询最新 active 信号的入口。信号详情展示动作、置信度/评分、horizon、plan_quality、market_phase、价格计划、风险、观察条件、来源报告和数据质量Web 只允许把信号标记为 `closed`、`invalidated` 或 `archived`,不提供 terminal 状态恢复为 active。
#1390 P5 新增信号级反馈、后验评估和统计 sidecar不扩展 `decision_signals` 主表,也不复用绑定 `analysis_history_id` 的 `BacktestResult`。`decision_signal_feedback` 按 `signal_id` 保存最新 `useful|not_useful` 反馈、可选原因/备注和来源;`decision_signal_outcomes` 按 `(signal_id, horizon, engine_version)` 幂等保存后验结果,当前 `engine_version=decision-signal-v1`。Outcome 在评估时冻结 `action/market/market_phase/source_type/source_agent/plan_quality/data_quality_level/holding_state` 等统计维度,历史统计不依赖后续 live join 改写。删除历史报告时,会先找出 `source_type=analysis` 且绑定被删历史 ID 的信号,再清理对应 feedback/outcome 子表。
@@ -1347,7 +1347,7 @@ P5 在 Web `/decision-signals` 页面筛选区下方展示当前 outcome engine
#1390 P7 的收口文档见 [DecisionSignal 决策信号专题](decision-signals.md)。P7 不新增 `DECISION_SIGNAL_*` 配置、数据库 migration、API 字段或运行时开关;当前回滚方式为 revert 对应代码。回滚后信号提取和写入停止,既有报告保存、告警触发、通知发送和组合风险主流程不依赖信号池继续运行;历史 signal、feedback 和 outcome 数据不会自动清理。
普通个股历史报告详情会在策略区后展示该报告提取出的 `source_type=analysis` 信号,查询条件为 `source_report_id=<recordId>`;无 `recordId`、大盘复盘或其他非普通个股报告不会发起该查询。空结果显示“本报告暂无决策信号”,加载失败只影响该卡片,不影响报告主体、资讯、运行诊断或透明度区展示
普通个股历史报告详情不再内嵌展示该报告提取出的 `source_type=analysis` 信号,也不会因打开报告详情而发起 `source_report_id=<recordId>` 的信号查询;需要查看结构化 AI 建议时统一进入 `/decision-signals` 页面筛选来源报告 ID、打开 `/decision-signals?sourceReportId=<recordId>` deep link或按股票查询。填写来源报告 ID 或使用该 URL 参数时Web 会发起 `source_type=analysis + source_report_id=<recordId>` 的精确查询,不叠加默认 `status=active` 等其他列表筛选,以保留旧报告 best-effort 懒回填语义
## 回测功能

View File

@@ -1168,7 +1168,7 @@ Read paths lazily expire active signals whose `expires_at` has passed before lis
These endpoints inherit the existing `/api/v1/*` admin authentication middleware: when `ADMIN_AUTH_ENABLED=true`, callers must send a valid admin session cookie. DecisionSignal does not add a separate auth scheme.
#1390 P4 wires the existing `DecisionSignal` API into the Web UI without adding backend contracts, database tables, or configuration. The sidebar now includes an "AI signals" entry at `/decision-signals`; the page defaults to `status=active`, supports filtering by market, stock code, action, market phase, source, and status, and includes a latest-active lookup by stock code. Signal details show action, confidence/score, horizon, plan_quality, market_phase, price plan, risk, watch conditions, source report, and data quality. The Web UI only allows marking a signal as `closed`, `invalidated`, or `archived`; it does not restore terminal states to active.
#1390 P4 wires the existing `DecisionSignal` API into the Web UI without adding backend contracts, database tables, or configuration. The sidebar "AI signals" entry at `/decision-signals` is the centralized query surface for structured decision signals; the page defaults to `status=active`, supports filtering by market, stock code, action, market phase, source, source report ID, and status, and includes a latest-active lookup by stock code. Signal details show action, confidence/score, horizon, plan_quality, market_phase, price plan, risk, watch conditions, source report, and data quality. The Web UI only allows marking a signal as `closed`, `invalidated`, or `archived`; it does not restore terminal states to active.
#1390 P5 adds signal-level feedback, forward outcome evaluation, and stats sidecars. It does not extend the `decision_signals` main table and does not reuse `BacktestResult`, which is tied to `analysis_history_id`. `decision_signal_feedback` stores the latest `useful|not_useful` feedback per `signal_id` with optional reason/note/source. `decision_signal_outcomes` stores idempotent rows by `(signal_id, horizon, engine_version)`, currently `engine_version=decision-signal-v1`. Each outcome freezes `action/market/market_phase/source_type/source_agent/plan_quality/data_quality_level/holding_state` at evaluation time so historical stats are not rewritten by later live-join changes. Deleting history first finds `source_type=analysis` signals bound to the deleted history IDs, then removes their feedback/outcome sidecars.
@@ -1182,7 +1182,7 @@ The portfolio page loads AI signals as a non-blocking enhancement: portfolio sna
#1390 P7 is documented in [DecisionSignal Topic](decision-signals.md) (Chinese-only). P7 adds no `DECISION_SIGNAL_*` configuration, database migration, API field, or runtime switch. Rollback is to revert the related code. After rollback, signal extraction and writes stop, while report saving, alert triggering, notification sending, and the portfolio risk main flow continue through their existing paths. Historical signal, feedback, and outcome rows are not deleted automatically.
Regular stock history reports show `source_type=analysis` signals extracted from that report after the strategy block, using `source_report_id=<recordId>` as the query. Reports without a `recordId`, market reviews, and other non-regular stock reports do not issue this query. Empty results show a report-level empty state, and loading failures affect only that card, not the report body, news, diagnostics, or transparency sections.
Regular stock history report details no longer embed the extracted `source_type=analysis` signals and no longer issue a `source_report_id=<recordId>` query when the report details open. To inspect structured AI recommendations, use `/decision-signals` and filter by source report ID, open the `/decision-signals?sourceReportId=<recordId>` deep link, or search by stock. When source report ID is filled or provided through that URL parameter, the Web UI sends an exact `source_type=analysis + source_report_id=<recordId>` query without adding default `status=active` or other list filters, preserving the best-effort lazy backfill semantics for older reports.
## Backtesting