From 1d389edea9add83bfcb97aa2b37bb0cc8989a318 Mon Sep 17 00:00:00 2001 From: 123dwdsbb Date: Sun, 17 May 2026 01:01:52 +0800 Subject: [PATCH 1/2] feat: Enhance API error handling and timeout management - Introduced timeout handling in API requests with a new `timeoutMs` option. - Added constants for API timeout reasons and codes. - Implemented retry logic for API requests with exponential backoff. - Updated error formatting to handle timeout scenarios gracefully. - Refactored report fetching logic to improve error handling and loading states. - Enhanced UI components to display loading and error states for various report sections. - Added new CSS styles for report metadata and error messages. - Updated tests to cover new functionality and ensure robustness. --- .../report_module/runtime_reader.py | 11 +- consensusinvest/report_module/schemas.py | 15 + consensusinvest/report_module/stock_views.py | 75 +++- frontend/package-lock.json | 39 -- frontend/src/api/errors.ts | 65 +++ frontend/src/api/http.ts | 44 +- frontend/src/api/report.ts | 69 ++- frontend/src/features/reports/ReportPage.css | 123 +++++- frontend/src/features/reports/ReportPage.tsx | 418 ++++++++++++++---- frontend/src/types/report.ts | 14 + frontend/tsconfig.json | 1 - tests/test_report_module.py | 76 +++- 12 files changed, 791 insertions(+), 159 deletions(-) diff --git a/consensusinvest/report_module/runtime_reader.py b/consensusinvest/report_module/runtime_reader.py index f221351..6317d0d 100644 --- a/consensusinvest/report_module/runtime_reader.py +++ b/consensusinvest/report_module/runtime_reader.py @@ -95,11 +95,16 @@ def latest_judgment_for_entity(self, entity_id: str) -> JudgmentRecord | None: while True: rows, total = self.workflow_repository.list_runs(ticker=ticker, limit=limit, offset=offset) for run in rows: - if run.entity_id is not None and run.entity_id != entity_id: + run_entity_id = getattr(run, "entity_id", None) + if run_entity_id is not None and run_entity_id != entity_id: continue - if stock_code is not None and run.stock_code not in {None, stock_code}: + run_stock_code = getattr(run, "stock_code", None) + if stock_code is not None and run_stock_code not in {None, stock_code}: continue - judgment = self.agent_repository.get_judgment_by_workflow(run.workflow_run_id) + workflow_run_id = getattr(run, "workflow_run_id", None) + if not workflow_run_id: + continue + judgment = self.agent_repository.get_judgment_by_workflow(workflow_run_id) if judgment is not None: return judgment offset += len(rows) diff --git a/consensusinvest/report_module/schemas.py b/consensusinvest/report_module/schemas.py index 35063ed..bf102ef 100644 --- a/consensusinvest/report_module/schemas.py +++ b/consensusinvest/report_module/schemas.py @@ -119,6 +119,20 @@ class ActionView(TraceableModel): source: Literal["main_judgment_summary"] +class HeroMetaItem(TraceableModel): + label: str + value: str + + +class HeroView(TraceableModel): + title: str + summary: str + status_note: str + source_note: str + limitation_note: str | None = None + meta: list[HeroMetaItem] = Field(default_factory=list) + + class TraceRefs(TraceableModel): evidence_ids: list[str] = Field(default_factory=list) market_snapshot_ids: list[str] = Field(default_factory=list) @@ -143,6 +157,7 @@ class StockAnalysisView(TraceableModel): report_run_id: str report_mode: ReportMode data_state: DataState + hero: HeroView action: ActionView | None = None report: ReportBody trace_refs: TraceRefs diff --git a/consensusinvest/report_module/stock_views.py b/consensusinvest/report_module/stock_views.py index d6190d6..92c0bf0 100644 --- a/consensusinvest/report_module/stock_views.py +++ b/consensusinvest/report_module/stock_views.py @@ -37,6 +37,8 @@ EventImpactItem, EventImpactRankingView, EvidenceMatch, + HeroMetaItem, + HeroView, IndustryDetailsView, IndustryLinks, KeyEvidence, @@ -54,6 +56,42 @@ +def _hero_summary(reasoning: str | None) -> tuple[str, str | None]: + if not reasoning or not reasoning.strip(): + return "", None + text = " ".join(reasoning.split()) + implementation_note = None + marker = "原始判断说明包含英文或乱码,已按中文展示兜底处理。" + if marker in text: + text = text.replace(marker, "").strip() + implementation_note = "原始判断说明已按中文可读形式展示。" + for separator in ("\n\n", "。", ";", ";", "!", "!", "?", "?"): + if separator in text: + first = text.split(separator, 1)[0].strip() + if first: + return first + ("。" if separator == "。" else ""), implementation_note + return text[:120].rstrip(",,;;::") + ("…" if len(text) > 120 else ""), implementation_note + + +def _data_state_label(data_state: DataState) -> str: + return { + DataState.READY: "已就绪", + DataState.PARTIAL: "部分就绪", + DataState.MISSING: "资料缺失", + DataState.PENDING_REFRESH: "等待补齐", + DataState.REFRESHING: "正在补齐", + DataState.STALE: "待更新", + DataState.FAILED: "加载失败", + }[data_state] + + +def _report_mode_label(report_mode: ReportMode) -> str: + return { + ReportMode.WITH_WORKFLOW_TRACE: "主链路判断视图", + ReportMode.REPORT_GENERATION: "资料汇总视图", + }[report_mode] + + def build_stock_search( *, reader: ReportRuntimeReader, @@ -145,6 +183,13 @@ def build_stock_analysis_view( "报告视图基于已入库 Evidence Structure 与 MarketSnapshot 拼装," "未运行主 workflow;不含 Agent Swarm 论证或 Judge 结论。" ) + hero_summary = "基于已入库资料整理当前标的要点,暂无主链路判断结论。" + hero_status_note = "仅基于已入库资料生成报告。" + hero_source_note = "当前页面未关联主 workflow Judgment。" + hero_limitation_note: str | None = "此视图用于资料编排与引用展示,不单独生成投资建议。" + hero_meta = [ + HeroMetaItem(label="报告模式", value=_report_mode_label(ReportMode.REPORT_GENERATION)), + ] if judgment is not None: action = ActionView( label=_action_label(judgment.final_signal), @@ -157,8 +202,20 @@ def build_stock_analysis_view( for text in judgment.risk_notes ] summary_text = judgment.reasoning + hero_summary, implementation_note = _hero_summary(judgment.reasoning) + positive_evidence_count = len(judgment.key_positive_evidence_ids) + negative_evidence_count = len(judgment.key_negative_evidence_ids) + hero_status_note = f"当前主判断为{action.label}。" + hero_source_note = "内容来自主 workflow 的 Judgment 摘要。" + hero_limitation_note = implementation_note + hero_meta = [ + HeroMetaItem(label="报告模式", value=_report_mode_label(ReportMode.WITH_WORKFLOW_TRACE)), + HeroMetaItem(label="关键证据", value=f"{positive_evidence_count} 条正向 / {negative_evidence_count} 条负向"), + HeroMetaItem(label="风险提示", value=f"{len(judgment.risk_notes)} 条"), + ] else: risks = _risk_disclosures(evidence_records) + hero_meta.append(HeroMetaItem(label="关键证据", value=f"{len(evidence_records)} 条已入库资料")) key_evidence = [ KeyEvidence( @@ -193,6 +250,14 @@ def build_stock_analysis_view( if refresh_task_id is not None: data_state = DataState.REFRESHING + report_mode = ReportMode.WITH_WORKFLOW_TRACE if has_workflow else ReportMode.REPORT_GENERATION + hero_meta.extend( + [ + HeroMetaItem(label="数据状态", value=_data_state_label(data_state)), + HeroMetaItem(label="更新时间", value=_dt(judgment.created_at) if judgment else _now_iso()), + ] + ) + view = StockAnalysisView( stock_code=_stock_code(entity) or stock_code, ticker=ticker, @@ -201,8 +266,16 @@ def build_stock_analysis_view( workflow_run_id=judgment.workflow_run_id if judgment else None, judgment_id=judgment.judgment_id if judgment else None, report_run_id=report_run_id, - report_mode=ReportMode.WITH_WORKFLOW_TRACE if has_workflow else ReportMode.REPORT_GENERATION, + report_mode=report_mode, data_state=data_state, + hero=HeroView( + title=entity.name, + summary=hero_summary, + status_note=hero_status_note, + source_note=hero_source_note, + limitation_note=hero_limitation_note, + meta=hero_meta, + ), action=action, report=ReportBody( title="个股研究聚合视图", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 330db7c..1db060b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -832,9 +832,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -848,9 +845,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -864,9 +858,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -880,9 +871,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -896,9 +884,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -912,9 +897,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -928,9 +910,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -944,9 +923,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -960,9 +936,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -976,9 +949,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -992,9 +962,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1008,9 +975,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1024,9 +988,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts index 4e406ce..809e749 100644 --- a/frontend/src/api/errors.ts +++ b/frontend/src/api/errors.ts @@ -1,9 +1,13 @@ +export const API_TIMEOUT_REASON = 'timeout'; + export type ApiErrorBody = { code?: string; message?: string; details?: Record; }; +export const API_TIMEOUT_CODE = 'TIMEOUT'; + export class ApiRequestError extends Error { readonly status: number; readonly path: string; @@ -51,6 +55,9 @@ export async function readJsonResponse(response: Response, path: string): Pro export function formatApiError(error: unknown, fallback: string): string { if (error instanceof ApiRequestError) { + if (error.code === API_TIMEOUT_CODE) { + return `${fallback}:加载 10 秒仍无结果,请稍后重试`; + } const code = error.code ? `,错误码:${error.code}` : ''; return `${fallback}(${error.path},HTTP ${error.status}${code}):${error.message}`; } @@ -60,6 +67,64 @@ export function formatApiError(error: unknown, fallback: string): string { return fallback; } +export function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError'; +} + +export function isTimeoutError(error: unknown): boolean { + return error instanceof ApiRequestError && error.code === API_TIMEOUT_CODE; +} + +export function toApiError(error: unknown, path: string, reason?: unknown): unknown { + if (!isAbortError(error)) { + return error; + } + + if (reason !== API_TIMEOUT_REASON) { + return error; + } + + return new ApiRequestError({ + status: 408, + path, + code: API_TIMEOUT_CODE, + message: '请求超时', + }); +} + +export function mergeAbortSignals(...signals: Array): AbortSignal | undefined { + const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); + if (activeSignals.length === 0) { + return undefined; + } + if (activeSignals.length === 1) { + return activeSignals[0]; + } + + const controller = new AbortController(); + const cleanup = new Map void>(); + + const abortFrom = (signal: AbortSignal) => { + for (const [currentSignal, listener] of cleanup) { + currentSignal.removeEventListener('abort', listener); + } + cleanup.clear(); + controller.abort(signal.reason); + }; + + for (const signal of activeSignals) { + if (signal.aborted) { + abortFrom(signal); + return controller.signal; + } + const listener = () => abortFrom(signal); + cleanup.set(signal, listener); + signal.addEventListener('abort', listener, { once: true }); + } + + return controller.signal; +} + function parseJson(text: string): any { if (!text.trim()) { return null; diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index b7098bb..a83092a 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -1,4 +1,4 @@ -import { readJsonResponse } from './errors'; +import { API_TIMEOUT_REASON, mergeAbortSignals, readJsonResponse, toApiError } from './errors'; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''; @@ -26,6 +26,11 @@ export type ListResponse = { }; }; +export type ApiRequestOptions = { + signal?: AbortSignal; + timeoutMs?: number; +}; + export function withBase(path: string): string { if (path.startsWith('http://') || path.startsWith('https://')) { return path; @@ -33,12 +38,27 @@ export function withBase(path: string): string { return `${API_BASE}${path}`; } -export async function apiGet(path: string, signal?: AbortSignal): Promise { - const response = await fetch(withBase(path), { - headers: { Accept: 'application/json' }, - signal, - }); - return readJsonResponse(response, path); +export async function apiGet(path: string, options?: AbortSignal | ApiRequestOptions): Promise { + const requestOptions = normalizeRequestOptions(options); + const timeoutController = requestOptions.timeoutMs ? new AbortController() : null; + const timeoutId = + timeoutController && requestOptions.timeoutMs + ? window.setTimeout(() => timeoutController.abort(API_TIMEOUT_REASON), requestOptions.timeoutMs) + : null; + + try { + const response = await fetch(withBase(path), { + headers: { Accept: 'application/json' }, + signal: mergeAbortSignals(requestOptions.signal, timeoutController?.signal), + }); + return readJsonResponse(response, path); + } catch (error) { + throw toApiError(error, path, timeoutController?.signal.reason); + } finally { + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + } } export async function apiPost(path: string, body: unknown): Promise { @@ -52,3 +72,13 @@ export async function apiPost(path: string, body: unknown): Promise { }); return readJsonResponse(response, path); } + +function normalizeRequestOptions(options?: AbortSignal | ApiRequestOptions): ApiRequestOptions { + if (!options) { + return {}; + } + if (options instanceof AbortSignal) { + return { signal: options }; + } + return options; +} diff --git a/frontend/src/api/report.ts b/frontend/src/api/report.ts index 6b1ac60..9f9445c 100644 --- a/frontend/src/api/report.ts +++ b/frontend/src/api/report.ts @@ -1,3 +1,4 @@ +import { API_TIMEOUT_REASON, ApiRequestError, isAbortError, isTimeoutError, toApiError } from './errors'; import { apiGet, type ListResponse, type SingleResponse } from './http'; import type { BenefitsRisksView, @@ -13,6 +14,10 @@ import type { StockSearchHit, } from '../types/report'; +const REPORT_TIMEOUT_MS = 10_000; +const REPORT_RETRY_DELAY_MS = 500; +const REPORT_RETRY_ATTEMPTS = 2; + export async function searchStocks(keyword: string, signal?: AbortSignal): Promise { const params = new URLSearchParams({ keyword, limit: '10', include_evidence: 'true' }); return apiGet>(`/api/v1/stocks/search?${params.toString()}`, signal).then( @@ -22,35 +27,35 @@ export async function searchStocks(keyword: string, signal?: AbortSignal): Promi export async function getStockAnalysis(stockCode: string, signal?: AbortSignal): Promise { const params = new URLSearchParams({ refresh: 'never', latest: 'true' }); - return apiGet>( + return getReportResource>( `/api/v1/stocks/${encodeURIComponent(stockCode)}/analysis?${params.toString()}`, signal, ).then((response) => response.data); } export async function getIndustryDetails(stockCode: string, signal?: AbortSignal): Promise { - return apiGet>( + return getReportResource>( `/api/v1/stocks/${encodeURIComponent(stockCode)}/industry-details`, signal, ).then((response) => response.data); } export async function getEventImpactRanking(stockCode: string, signal?: AbortSignal): Promise { - return apiGet>( + return getReportResource>( `/api/v1/stocks/${encodeURIComponent(stockCode)}/event-impact-ranking?limit=10`, signal, ).then((response) => response.data); } export async function getBenefitsRisks(stockCode: string, signal?: AbortSignal): Promise { - return apiGet>( + return getReportResource>( `/api/v1/stocks/${encodeURIComponent(stockCode)}/benefits-risks`, signal, ).then((response) => response.data); } export async function getIndexOverview(signal?: AbortSignal): Promise { - return apiGet>('/api/v1/market/index-overview?refresh=stale', signal).then( + return getReportResource>('/api/v1/market/index-overview?refresh=stale', signal).then( (response) => response.data, ); } @@ -76,13 +81,63 @@ export async function getMarketStocks(signal?: AbortSignal): Promise { - return apiGet>('/api/v1/market/concept-radar?limit=8&refresh=stale', signal).then( + return getReportResource>('/api/v1/market/concept-radar?limit=8&refresh=stale', signal).then( (response) => response.data, ); } export async function getMarketWarnings(signal?: AbortSignal): Promise { - return apiGet>('/api/v1/market/warnings?limit=8&refresh=stale', signal).then( + return getReportResource>('/api/v1/market/warnings?limit=8&refresh=stale', signal).then( (response) => response.data, ); } + +async function getReportResource(path: string, signal?: AbortSignal): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt <= REPORT_RETRY_ATTEMPTS; attempt += 1) { + try { + return await apiGet(path, { signal, timeoutMs: REPORT_TIMEOUT_MS }); + } catch (error) { + const normalizedError = toApiError(error, path, signal?.reason); + if (signal?.aborted || isAbortError(normalizedError) || !shouldRetry(normalizedError) || attempt === REPORT_RETRY_ATTEMPTS) { + throw normalizedError; + } + lastError = normalizedError; + } + + await delay(REPORT_RETRY_DELAY_MS, signal); + } + + throw lastError; +} + +function shouldRetry(error: unknown): boolean { + if (error instanceof TypeError || isTimeoutError(error)) { + return true; + } + if (error instanceof ApiRequestError) { + return error.status >= 500; + } + return false; +} + +async function delay(ms: number, signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + + await new Promise((resolve, reject) => { + const timeoutId = window.setTimeout(() => { + signal?.removeEventListener('abort', abort); + resolve(); + }, ms); + + const abort = () => { + window.clearTimeout(timeoutId); + reject(new DOMException('The operation was aborted.', 'AbortError')); + }; + + signal?.addEventListener('abort', abort, { once: true }); + }); +} diff --git a/frontend/src/features/reports/ReportPage.css b/frontend/src/features/reports/ReportPage.css index db70198..aa6cf05 100644 --- a/frontend/src/features/reports/ReportPage.css +++ b/frontend/src/features/reports/ReportPage.css @@ -137,6 +137,23 @@ font-size: 28px; } +.report-heading-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.report-heading-meta span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--black); + font-size: 12px; + font-weight: 700; +} + .secondary-action { display: inline-flex; align-items: center; @@ -170,24 +187,67 @@ line-height: 1.7; } -.signal-pill { - display: grid; - grid-template-columns: 120px 1fr; +.report-summary-meta { + display: flex; + flex-wrap: wrap; gap: 12px; margin-top: 18px; - padding: 12px; +} + +.summary-meta-item { + min-width: 140px; + padding: 10px 12px; + border: 1px solid var(--black); + font-family: "Courier New", monospace; +} + +.summary-meta-item span { + display: block; + font-size: 11px; + margin-bottom: 6px; +} + +.summary-meta-item strong { + font-size: 13px; +} + +.signal-card { + display: grid; + gap: 10px; + margin-top: 18px; + padding: 16px; border: 1px solid var(--black); font-family: "Courier New", monospace; } -.signal-pill.positive { +.signal-card.positive { border-color: #0074ff; } -.signal-pill.negative { +.signal-card.negative { border-color: #b91c1c; } +.signal-card-header { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.signal-card-header strong { + font-size: 16px; +} + +.signal-card p { + line-height: 1.6; +} + +.signal-card small { + color: #4b5563; + line-height: 1.5; +} + .report-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -228,12 +288,55 @@ line-height: 1.55; } -.report-item small { - font-family: "Courier New", monospace; - overflow-wrap: anywhere; +.report-item:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; } -.market-report { +.report-empty { + margin: 0; + color: #4b5563; + line-height: 1.6; +} + +.market-empty { + margin: 0; + padding: 12px 0; + color: #4b5563; + font-size: 13px; + line-height: 1.5; +} + +.market-error { + margin: 12px 0 0; + padding: 10px; + border: 1px solid #b91c1c; + color: #b91c1c; + font-size: 12px; + line-height: 1.5; +} + +.market-loading { + margin: 0; + padding: 12px 0; + font-size: 13px; + line-height: 1.5; +} + +.market-section { + margin-top: 18px; +} + +.market-section:first-of-type { + margin-top: 0; +} + +.market-section > :not(h3) + :not(h3) { + margin-top: 0; +} + +.market-state { padding: 30px 18px; border-left: 1px solid var(--black); } diff --git a/frontend/src/features/reports/ReportPage.tsx b/frontend/src/features/reports/ReportPage.tsx index 527ab64..4a8318b 100644 --- a/frontend/src/features/reports/ReportPage.tsx +++ b/frontend/src/features/reports/ReportPage.tsx @@ -19,30 +19,41 @@ import type { StockAnalysisView, StockSearchHit, } from '../../types/report'; -import { formatApiError } from '../../api/errors'; +import { formatApiError, isAbortError } from '../../api/errors'; import GlobalNav from '../../components/GlobalNav'; import './ReportPage.css'; -type ReportBundle = { - analysis: StockAnalysisView; - industry: IndustryDetailsView; - events: EventImpactRankingView; - benefitsRisks: BenefitsRisksView; +type SectionState = { + data: T | null; + loading: boolean; + error: string; }; +const emptySection = (): SectionState => ({ + data: null, + loading: false, + error: '', +}); + function ReportPage() { const [stockCode, setStockCode] = useState('002594.SZ'); const [searchText, setSearchText] = useState('002594'); const [searchHits, setSearchHits] = useState([]); - const [report, setReport] = useState(null); + const [analysis, setAnalysis] = useState>(() => emptySection()); + const [industry, setIndustry] = useState>(() => emptySection()); + const [events, setEvents] = useState>(() => emptySection()); + const [benefitsRisks, setBenefitsRisks] = useState>(() => emptySection()); const [market, setMarket] = useState(null); const [concepts, setConcepts] = useState([]); const [warnings, setWarnings] = useState([]); - const [loading, setLoading] = useState(false); + const [marketLoading, setMarketLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(''); + const [marketErrorMessage, setMarketErrorMessage] = useState(''); useEffect(() => { const controller = new AbortController(); + setMarketLoading(true); + setMarketErrorMessage(''); Promise.all([ getIndexOverview(controller.signal), getConceptRadar(controller.signal), @@ -54,8 +65,13 @@ function ReportPage() { setWarnings(nextWarnings); }) .catch((error) => { + if (!isAbortError(error) && !controller.signal.aborted) { + setMarketErrorMessage(formatApiError(error, '市场报告加载失败')); + } + }) + .finally(() => { if (!controller.signal.aborted) { - setErrorMessage(formatApiError(error, '市场报告加载失败')); + setMarketLoading(false); } }); return () => controller.abort(); @@ -63,7 +79,7 @@ function ReportPage() { useEffect(() => { const controller = new AbortController(); - loadReport(stockCode, controller.signal); + void loadReport(stockCode, controller.signal); return () => controller.abort(); }, [stockCode]); @@ -85,27 +101,63 @@ function ReportPage() { } async function loadReport(nextStockCode: string, signal?: AbortSignal) { - setLoading(true); setErrorMessage(''); + setAnalysis((current) => ({ data: current.data, loading: true, error: '' })); + setIndustry(emptySection()); + setEvents(emptySection()); + setBenefitsRisks(emptySection()); + try { - const [analysis, industry, events, benefitsRisks] = await Promise.all([ - getStockAnalysis(nextStockCode, signal), - getIndustryDetails(nextStockCode, signal), - getEventImpactRanking(nextStockCode, signal), - getBenefitsRisks(nextStockCode, signal), - ]); - setReport({ analysis, industry, events, benefitsRisks }); - } catch (error) { - if (!signal?.aborted) { - setErrorMessage(formatApiError(error, '资讯报告加载失败')); + const nextAnalysis = await getStockAnalysis(nextStockCode, signal); + if (signal?.aborted) { + return; } - } finally { - if (!signal?.aborted) { - setLoading(false); + setAnalysis({ data: nextAnalysis, loading: false, error: '' }); + } catch (error) { + if (signal?.aborted || isAbortError(error)) { + return; } + setAnalysis({ data: null, loading: false, error: formatApiError(error, '资讯报告加载失败') }); + return; } + + void loadReportSection({ + request: (requestSignal) => getIndustryDetails(nextStockCode, requestSignal), + setSection: setIndustry, + signal, + fallback: '行业详情加载失败', + }); + void loadReportSection({ + request: (requestSignal) => getEventImpactRanking(nextStockCode, requestSignal), + setSection: setEvents, + signal, + fallback: '事件影响加载失败', + }); + void loadReportSection({ + request: (requestSignal) => getBenefitsRisks(nextStockCode, requestSignal), + setSection: setBenefitsRisks, + signal, + fallback: '利好与风险加载失败', + }); } + const currentStockName = analysis.data?.stock_name ?? stockCode; + const currentDataState = getDataStateLabel(analysis.loading && !analysis.data ? 'loading' : analysis.data?.data_state); + const currentReportRunId = formatReportRunId(analysis.data?.report_run_id); + const reportTitle = analysis.data?.report.title ?? '个股研究聚合视图'; + const analysisTicker = analysis.data?.ticker || stockCode; + const hero = analysis.data?.hero; + const summaryText = getSummaryText(hero?.summary, analysis.loading); + const heroTitle = hero?.title ?? currentStockName; + const heroStatusNote = hero?.status_note ?? (analysis.loading ? '正在整理顶部摘要。' : '暂无顶部状态摘要。'); + const heroSourceNote = hero?.source_note ?? '当前仅展示 Report Module 视图。'; + const heroLimitationNote = hero?.limitation_note ?? null; + const heroMeta = hero?.meta ?? []; + const keyEvidence = analysis.data?.report.key_evidence ?? []; + const eventItems = events.data?.items ?? []; + const benefitsRiskItems = buildBenefitsRiskItems(benefitsRisks.data ?? undefined); + const mainBusy = analysis.loading || industry.loading || events.loading || benefitsRisks.loading; + return (
@@ -125,11 +177,11 @@ function ReportPage() {
当前标的 - {report?.analysis.stock_name ?? stockCode} + {currentStockName} Data State - {report?.analysis.data_state ?? '-'} + {currentDataState} Report Run - {report?.analysis.report_run_id.slice(-8) ?? '-'} + {currentReportRunId}
{searchHits.length > 0 ? ( @@ -144,74 +196,125 @@ function ReportPage() { ) : null} {errorMessage ?
{errorMessage}
: null} + {analysis.error ?
{analysis.error}
: null} -
+
Report Module -

{report?.analysis.report.title ?? '个股研究聚合视图'}

+

{reportTitle}

+
+ {heroTitle} + {analysis.data?.stock_code ?? stockCode} + {analysis.data ? getReportModeLabel(analysis.data.report_mode) : '资料汇总视图'} + {analysis.data ? getDataStateLabel(analysis.data.data_state) : currentDataState} + {analysis.data ? formatUpdatedAt(analysis.data.updated_at) : '更新时间待加载'} +
- + 进入主分析
-

{report?.analysis.stock_name ?? stockCode}

-

{report?.analysis.report.summary ?? '正在读取 Report Module 视图。'}

- {report?.analysis.action ? ( -
- {report.analysis.action.label} - {report.analysis.action.reason} +

{heroTitle}

+ {analysis.error ? :

{summaryText}

} +
+ {heroMeta.map((item) => ( +
+ {item.label} + {item.value} +
+ ))} +
+ {analysis.data?.action ? ( +
+
+ {analysis.data.action.label} + {heroStatusNote} +
+

{heroSourceNote}

+ {heroLimitationNote ? {heroLimitationNote} : null}
- ) : ( -
- {report?.analysis.report_mode ?? 'report_generation'} - 无主链路 Judgment 时不展示投资建议。 + ) : analysis.data ? ( +
+
+ 暂无主链路判断 + {heroStatusNote} +
+

{heroSourceNote}

+ {heroLimitationNote ? {heroLimitationNote} : null}
- )} + ) : null}
- {(report?.analysis.report.key_evidence ?? []).map((item) => ( -
-

{item.title}

-

{item.objective_summary}

- {item.evidence_id} -
- ))} + {analysis.error ? ( + + ) : keyEvidence.length > 0 ? ( + keyEvidence.map((item) => ( +
+

{item.title}

+

{item.objective_summary}

+ {item.evidence_id} +
+ )) + ) : ( + + )}
- {report ? ( + {industry.error ? ( + + ) : industry.data ? (
-

{report.industry.industry_name}

-

{report.industry.policy_support_desc}

-

{report.industry.supply_demand_status} / {report.industry.competition_landscape}

+

{industry.data.industry_name}

+

{industry.data.policy_support_desc}

+

+ {industry.data.supply_demand_status} / {industry.data.competition_landscape} +

+ {joinEvidenceIds(industry.data.referenced_evidence_ids)}
- ) : null} + ) : ( + + )}
- {(report?.events.items ?? []).map((item) => ( -
-

{item.event_name}

-

{item.impact_level} / {item.direction ?? '无方向'} / {item.impact_score}

- {item.evidence_ids.join(', ') || '-'} -
- ))} + {events.error ? ( + + ) : eventItems.length > 0 ? ( + eventItems.map((item) => ( +
+

{item.event_name}

+

+ {item.impact_level} / {item.direction ?? '无方向'} / {item.impact_score} +

+ {joinEvidenceIds(item.evidence_ids)} +
+ )) + ) : ( + + )}
- {[...(report?.benefitsRisks.benefits ?? []), ...(report?.benefitsRisks.risks ?? [])].map((item) => ( -
-

{item.source}

-

{item.text}

- {item.evidence_ids.join(', ') || '-'} -
- ))} + {benefitsRisks.error ? ( + + ) : benefitsRiskItems.length > 0 ? ( + benefitsRiskItems.map((item) => ( +
+

{item.heading}

+

{item.text}

+ {joinEvidenceIds(item.evidence_ids)} +
+ )) + ) : ( + + )}
@@ -219,37 +322,164 @@ function ReportPage() {
); } +async function loadReportSection({ + request, + setSection, + signal, + fallback, +}: { + request: (signal?: AbortSignal) => Promise; + setSection: (value: SectionState | ((current: SectionState) => SectionState)) => void; + signal?: AbortSignal; + fallback: string; +}) { + setSection({ data: null, loading: true, error: '' }); + try { + const data = await request(signal); + if (!signal?.aborted) { + setSection({ data, loading: false, error: '' }); + } + } catch (error) { + if (!signal?.aborted && !isAbortError(error)) { + setSection({ data: null, loading: false, error: formatApiError(error, fallback) }); + } + } +} + +function getSummaryText(summary: string | undefined, loading: boolean) { + if (summary && summary.trim()) { + return summary; + } + return loading ? '正在读取 Report Module 视图。' : '暂无可展示的顶部摘要。'; +} + +function getDataStateLabel(dataState: string | undefined) { + switch (dataState) { + case 'ready': + return '已就绪'; + case 'partial': + return '部分就绪'; + case 'missing': + return '资料缺失'; + case 'pending_refresh': + return '等待补齐'; + case 'refreshing': + return '正在补齐'; + case 'stale': + return '待更新'; + case 'failed': + return '加载失败'; + case 'loading': + return '加载中'; + default: + return '未加载'; + } +} + +function getReportModeLabel(reportMode: StockAnalysisView['report_mode'] | undefined) { + switch (reportMode) { + case 'with_workflow_trace': + return '主链路判断视图'; + case 'report_generation': + return '资料汇总视图'; + default: + return '资料汇总视图'; + } +} + +function formatUpdatedAt(updatedAt: string | undefined) { + if (!updatedAt) { + return '更新时间待加载'; + } + return `更新于 ${updatedAt.replace('T', ' ').replace('Z', '')}`; +} + +function formatReportRunId(reportRunId: string | undefined) { + if (!reportRunId) { + return '-'; + } + return reportRunId.slice(-8); +} + +function joinEvidenceIds(evidenceIds: string[]) { + return evidenceIds.length > 0 ? evidenceIds.join(', ') : '-'; +} + +function buildBenefitsRiskItems(benefitsRisks: BenefitsRisksView | undefined) { + if (!benefitsRisks) { + return []; + } + + return [ + ...benefitsRisks.benefits.map((item) => ({ + ...item, + kind: 'benefit' as const, + heading: `利好 · ${item.source}`, + })), + ...benefitsRisks.risks.map((item) => ({ + ...item, + kind: 'risk' as const, + heading: `风险 · ${item.source}`, + })), + ]; +} + function Panel({ children, title }: { children: ReactNode; title: string }) { return (
@@ -259,4 +489,12 @@ function Panel({ children, title }: { children: ReactNode; title: string }) { ); } +function EmptyState({ text }: { text: string }) { + return

{text}

; +} + +function SectionError({ message }: { message: string }) { + return

{message}

; +} + export default ReportPage; diff --git a/frontend/src/types/report.ts b/frontend/src/types/report.ts index ff0e96d..8e310d6 100644 --- a/frontend/src/types/report.ts +++ b/frontend/src/types/report.ts @@ -24,6 +24,17 @@ export type StockAnalysisView = { report_run_id: string; report_mode: 'report_generation' | 'with_workflow_trace'; data_state: string; + hero: { + title: string; + summary: string; + status_note: string; + source_note: string; + limitation_note: string | null; + meta: Array<{ + label: string; + value: string; + }>; + }; action: { label: string; signal: 'positive' | 'neutral' | 'negative'; @@ -37,6 +48,8 @@ export type StockAnalysisView = { evidence_id: string; title: string; objective_summary: string; + publish_time?: string; + fetched_at?: string; source_quality: number; relevance: number; }>; @@ -61,6 +74,7 @@ export type StockAnalysisView = { updated_at: string; }; + export type IndustryDetailsView = { stock_code: string; ticker: string; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index df11947..e00f3a4 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -10,7 +10,6 @@ "strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", - "moduleResolution": "Node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, diff --git a/tests/test_report_module.py b/tests/test_report_module.py index 53a19c8..5ca0660 100644 --- a/tests/test_report_module.py +++ b/tests/test_report_module.py @@ -321,7 +321,81 @@ def test_report_run_saves_report_view_cited_references_and_tolerates_missing_evi repo.close() -def test_industry_details_view_uses_entity_relation_and_evidence(tmp_path: Path) -> None: +def test_stock_analysis_ignores_incomplete_workflow_rows_when_selecting_latest_judgment(tmp_path: Path) -> None: + repo = SQLiteReportRunRepository(tmp_path / "report_runs.sqlite3") + reader = _reader_with_stock_entity_only(repo) + + class IncompleteRun: + def __init__(self, workflow_run_id: str) -> None: + self.workflow_run_id = workflow_run_id + self.entity_id = "ent_company_002594" + + class FakeWorkflowRepository: + def list_runs(self, *, ticker: str | None = None, status: str | None = None, limit: int = 20, offset: int = 0): + _ = status + assert ticker == "002594" + if offset == 0: + return [IncompleteRun("wr_incomplete"), WorkflowRunRecord( + workflow_run_id="wr_complete", + correlation_id="corr_complete", + ticker="002594", + analysis_time=datetime(2026, 5, 13, 9, 0, tzinfo=timezone.utc), + workflow_config_id="cfg_default", + status="completed", + stage="judge", + query=WorkflowQuery(), + options=WorkflowOptions(), + entity_id="ent_company_002594", + stock_code="002594.SZ", + created_at=datetime(2026, 5, 13, 9, 0, tzinfo=timezone.utc), + started_at=None, + completed_at=datetime(2026, 5, 13, 9, 5, tzinfo=timezone.utc), + judgment_id="jdg_complete", + final_signal="positive", + confidence=0.7, + progress=WorkflowProgress(), + failure_code=None, + failure_message=None, + evidence_gaps=(), + search_task_ids=(), + )], 2 + return [], 2 + + reader.workflow_repository = FakeWorkflowRepository() + reader.agent_repository.save_judgment( + JudgmentRecord( + judgment_id="jdg_complete", + workflow_run_id="wr_complete", + final_signal="positive", + confidence=0.7, + time_horizon="short_term", + key_positive_evidence_ids=(), + key_negative_evidence_ids=(), + reasoning="完整 workflow 行仍可提供最新 judgment。", + risk_notes=(), + referenced_agent_argument_ids=(), + limitations=(), + created_at=datetime(2026, 5, 13, 9, 5, tzinfo=timezone.utc), + ) + ) + + view, refresh_task_id = build_stock_analysis_view( + reader=reader, + stock_code="002594.SZ", + query=None, + workflow_run_id=None, + latest=True, + refresh=RefreshPolicy.NEVER, + ) + + assert refresh_task_id is None + assert view.data_state == "ready" + assert view.workflow_run_id == "wr_complete" + assert view.judgment_id == "jdg_complete" + assert view.action is not None + repo.close() + + repo = SQLiteReportRunRepository(tmp_path / "report_runs.sqlite3") reader = _reader_with_industry_relation(repo) From 373d8560f23961bb37664e615f93fa787530c43b Mon Sep 17 00:00:00 2001 From: 123dwdsbb Date: Sun, 17 May 2026 02:11:29 +0800 Subject: [PATCH 2/2] reprotpage ungrade --- frontend/src/api/report.ts | 22 +++----- frontend/src/features/reports/ReportPage.css | 41 ++------------ frontend/src/features/reports/ReportPage.tsx | 57 ++------------------ frontend/src/types/report.ts | 16 ------ 4 files changed, 15 insertions(+), 121 deletions(-) diff --git a/frontend/src/api/report.ts b/frontend/src/api/report.ts index 9f9445c..af5b9c9 100644 --- a/frontend/src/api/report.ts +++ b/frontend/src/api/report.ts @@ -6,7 +6,6 @@ import type { EventImpactRankingView, IndexIntraday, IndexOverview, - IndustryDetailsView, MarketStocksList, MarketWarning, SearchTaskStatusView, @@ -27,35 +26,28 @@ export async function searchStocks(keyword: string, signal?: AbortSignal): Promi export async function getStockAnalysis(stockCode: string, signal?: AbortSignal): Promise { const params = new URLSearchParams({ refresh: 'never', latest: 'true' }); - return getReportResource>( + return getRetriedReportResource>( `/api/v1/stocks/${encodeURIComponent(stockCode)}/analysis?${params.toString()}`, signal, ).then((response) => response.data); } -export async function getIndustryDetails(stockCode: string, signal?: AbortSignal): Promise { - return getReportResource>( - `/api/v1/stocks/${encodeURIComponent(stockCode)}/industry-details`, - signal, - ).then((response) => response.data); -} - export async function getEventImpactRanking(stockCode: string, signal?: AbortSignal): Promise { - return getReportResource>( + return getRetriedReportResource>( `/api/v1/stocks/${encodeURIComponent(stockCode)}/event-impact-ranking?limit=10`, signal, ).then((response) => response.data); } export async function getBenefitsRisks(stockCode: string, signal?: AbortSignal): Promise { - return getReportResource>( + return getRetriedReportResource>( `/api/v1/stocks/${encodeURIComponent(stockCode)}/benefits-risks`, signal, ).then((response) => response.data); } export async function getIndexOverview(signal?: AbortSignal): Promise { - return getReportResource>('/api/v1/market/index-overview?refresh=stale', signal).then( + return apiGet>('/api/v1/market/index-overview?refresh=stale', signal).then( (response) => response.data, ); } @@ -81,18 +73,18 @@ export async function getMarketStocks(signal?: AbortSignal): Promise { - return getReportResource>('/api/v1/market/concept-radar?limit=8&refresh=stale', signal).then( + return apiGet>('/api/v1/market/concept-radar?limit=8&refresh=stale', signal).then( (response) => response.data, ); } export async function getMarketWarnings(signal?: AbortSignal): Promise { - return getReportResource>('/api/v1/market/warnings?limit=8&refresh=stale', signal).then( + return apiGet>('/api/v1/market/warnings?limit=8&refresh=stale', signal).then( (response) => response.data, ); } -async function getReportResource(path: string, signal?: AbortSignal): Promise { +async function getRetriedReportResource(path: string, signal?: AbortSignal): Promise { let lastError: unknown; for (let attempt = 0; attempt <= REPORT_RETRY_ATTEMPTS; attempt += 1) { diff --git a/frontend/src/features/reports/ReportPage.css b/frontend/src/features/reports/ReportPage.css index aa6cf05..13a028e 100644 --- a/frontend/src/features/reports/ReportPage.css +++ b/frontend/src/features/reports/ReportPage.css @@ -211,43 +211,6 @@ font-size: 13px; } -.signal-card { - display: grid; - gap: 10px; - margin-top: 18px; - padding: 16px; - border: 1px solid var(--black); - font-family: "Courier New", monospace; -} - -.signal-card.positive { - border-color: #0074ff; -} - -.signal-card.negative { - border-color: #b91c1c; -} - -.signal-card-header { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: center; -} - -.signal-card-header strong { - font-size: 16px; -} - -.signal-card p { - line-height: 1.6; -} - -.signal-card small { - color: #4b5563; - line-height: 1.5; -} - .report-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -255,6 +218,10 @@ padding-top: 22px; } +.report-panel--key-evidence { + grid-column: 1 / -1; +} + .report-panel { border: 2px solid var(--black); min-height: 220px; diff --git a/frontend/src/features/reports/ReportPage.tsx b/frontend/src/features/reports/ReportPage.tsx index 4a8318b..2afbc0a 100644 --- a/frontend/src/features/reports/ReportPage.tsx +++ b/frontend/src/features/reports/ReportPage.tsx @@ -4,7 +4,6 @@ import { getConceptRadar, getEventImpactRanking, getIndexOverview, - getIndustryDetails, getMarketWarnings, getStockAnalysis, searchStocks, @@ -14,7 +13,6 @@ import type { ConceptRadarItem, EventImpactRankingView, IndexOverview, - IndustryDetailsView, MarketWarning, StockAnalysisView, StockSearchHit, @@ -40,7 +38,6 @@ function ReportPage() { const [searchText, setSearchText] = useState('002594'); const [searchHits, setSearchHits] = useState([]); const [analysis, setAnalysis] = useState>(() => emptySection()); - const [industry, setIndustry] = useState>(() => emptySection()); const [events, setEvents] = useState>(() => emptySection()); const [benefitsRisks, setBenefitsRisks] = useState>(() => emptySection()); const [market, setMarket] = useState(null); @@ -103,7 +100,6 @@ function ReportPage() { async function loadReport(nextStockCode: string, signal?: AbortSignal) { setErrorMessage(''); setAnalysis((current) => ({ data: current.data, loading: true, error: '' })); - setIndustry(emptySection()); setEvents(emptySection()); setBenefitsRisks(emptySection()); @@ -121,12 +117,6 @@ function ReportPage() { return; } - void loadReportSection({ - request: (requestSignal) => getIndustryDetails(nextStockCode, requestSignal), - setSection: setIndustry, - signal, - fallback: '行业详情加载失败', - }); void loadReportSection({ request: (requestSignal) => getEventImpactRanking(nextStockCode, requestSignal), setSection: setEvents, @@ -149,14 +139,11 @@ function ReportPage() { const hero = analysis.data?.hero; const summaryText = getSummaryText(hero?.summary, analysis.loading); const heroTitle = hero?.title ?? currentStockName; - const heroStatusNote = hero?.status_note ?? (analysis.loading ? '正在整理顶部摘要。' : '暂无顶部状态摘要。'); - const heroSourceNote = hero?.source_note ?? '当前仅展示 Report Module 视图。'; - const heroLimitationNote = hero?.limitation_note ?? null; const heroMeta = hero?.meta ?? []; const keyEvidence = analysis.data?.report.key_evidence ?? []; const eventItems = events.data?.items ?? []; const benefitsRiskItems = buildBenefitsRiskItems(benefitsRisks.data ?? undefined); - const mainBusy = analysis.loading || industry.loading || events.loading || benefitsRisks.loading; + const mainBusy = analysis.loading || events.loading || benefitsRisks.loading; return (
@@ -228,29 +215,10 @@ function ReportPage() { ))} - {analysis.data?.action ? ( -
-
- {analysis.data.action.label} - {heroStatusNote} -
-

{heroSourceNote}

- {heroLimitationNote ? {heroLimitationNote} : null} -
- ) : analysis.data ? ( -
-
- 暂无主链路判断 - {heroStatusNote} -
-

{heroSourceNote}

- {heroLimitationNote ? {heroLimitationNote} : null} -
- ) : null}
- + {analysis.error ? ( ) : keyEvidence.length > 0 ? ( @@ -266,23 +234,6 @@ function ReportPage() { )} - - {industry.error ? ( - - ) : industry.data ? ( -
-

{industry.data.industry_name}

-

{industry.data.policy_support_desc}

-

- {industry.data.supply_demand_status} / {industry.data.competition_landscape} -

- {joinEvidenceIds(industry.data.referenced_evidence_ids)} -
- ) : ( - - )} -
- {events.error ? ( @@ -480,9 +431,9 @@ function buildBenefitsRiskItems(benefitsRisks: BenefitsRisksView | undefined) { ]; } -function Panel({ children, title }: { children: ReactNode; title: string }) { +function Panel({ children, title, className = '' }: { children: ReactNode; title: string; className?: string }) { return ( -
+

{title}

{children}
diff --git a/frontend/src/types/report.ts b/frontend/src/types/report.ts index 1e2bce5..af8b549 100644 --- a/frontend/src/types/report.ts +++ b/frontend/src/types/report.ts @@ -74,22 +74,6 @@ export type StockAnalysisView = { updated_at: string; }; - -export type IndustryDetailsView = { - stock_code: string; - ticker: string; - report_run_id: string; - industry_entity_id: string; - industry_name: string; - policy_support_level: 'low' | 'medium' | 'high'; - policy_support_desc: string; - supply_demand_status: string; - competition_landscape: string; - referenced_evidence_ids: string[]; - market_snapshot_ids: string[]; - updated_at: string; -}; - export type EventImpactRankingView = { stock_code: string; ticker: string;