fix: 结构化大盘复盘报告展示 (#1576)

* feat: add dedicated market review report view

* fix(review-feedback-1576): Use localized labels for English market reviews

* fix: structure market review payload rendering

* fix(review-feedback-1576): preserve market labels when rendering combined reviews and Use the

* fix(review-feedback-1576): Render structured data for every combined market and Localize

* fix: dedupe market review rendering

* test: align market review language prompt expectation

* fix(review-feedback-1576): 澄清并补足 LLM/运行时配置相关检测命中的兼容性证据

* fix(review-feedback-1576): 解决冲突并重新确认 CI

* fix(review-feedback-1576): docs/CHANGELOG.md:[Unreleased] 新增内容中除 1555 大盘复盘展示外,还包含 Agent

* fix(review-feedback-1576): 修复

* fix(review-feedback-1576): 修复美股中文大盘复盘仍混入英文策略/市场标签的问题,并补齐对应回归测试
This commit is contained in:
mumu
2026-06-04 13:40:42 +08:00
committed by GitHub
parent 3281d96788
commit b612fb23e4
22 changed files with 1618 additions and 113 deletions
+18
View File
@@ -148,12 +148,18 @@ def _run_market_review_background(
"search_service": search_service,
"send_notification": send_notification,
"override_region": override_region,
"return_structured": True,
}
if query_id:
review_kwargs["query_id"] = query_id
report = run_market_review(**review_kwargs)
if not report:
raise RuntimeError("大盘复盘未返回可持久化报告")
if hasattr(report, "report"):
return {
"result": report.report,
"market_review_payload": getattr(report, "market_review_payload", None),
}
return {"result": report}
finally:
_release_market_review_lock(lock_token)
@@ -849,12 +855,16 @@ def get_analysis_status(task_id: str) -> TaskStatus:
if task:
result: Optional[AnalysisResultResponse] = None
market_review_report = None
market_review_payload = None
if task.status == TaskStatusEnum.COMPLETED and isinstance(task.result, dict):
if task.stock_code == "market_review":
report_text = task.result.get("result")
if isinstance(report_text, str) and report_text.strip():
market_review_report = report_text
payload = task.result.get("market_review_payload")
if isinstance(payload, dict):
market_review_payload = payload
else:
try:
result = _build_task_analysis_result(task)
@@ -871,6 +881,7 @@ def get_analysis_status(task_id: str) -> TaskStatus:
progress=task.progress,
result=result,
market_review_report=market_review_report,
market_review_payload=market_review_payload,
error=task.error,
stock_name=task.stock_name,
original_query=task.original_query,
@@ -890,6 +901,12 @@ def get_analysis_status(task_id: str) -> TaskStatus:
raw_result = parse_json_field(record.raw_result)
if getattr(record, "report_type", None) == "market_review":
market_review_report = None
context_snapshot = parse_json_field(getattr(record, "context_snapshot", None))
market_review_payload = None
if isinstance(context_snapshot, dict):
payload = context_snapshot.get("market_review_payload")
if isinstance(payload, dict):
market_review_payload = payload
if isinstance(raw_result, dict):
report_text = raw_result.get("raw_response") or raw_result.get("market_review_report")
if isinstance(report_text, str) and report_text.strip():
@@ -904,6 +921,7 @@ def get_analysis_status(task_id: str) -> TaskStatus:
progress=100,
result=None,
market_review_report=market_review_report,
market_review_payload=market_review_payload,
error=None,
stock_name=record.name,
)
+4
View File
@@ -271,6 +271,10 @@ class TaskStatus(BaseModel):
None,
description="大盘复盘任务返回的报告文本(仅大盘复盘任务)",
)
market_review_payload: Optional[Any] = Field(
None,
description="Structured market-review payload for API/Web consumers.",
)
error: Optional[str] = Field(
None,
description="错误信息(仅在 failed 时存在)"
@@ -0,0 +1,553 @@
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { BarChart3, Clipboard, FileText, Gauge, Layers, ShieldAlert, TrendingUp, WalletCards } from 'lucide-react';
import { historyApi } from '../../api/history';
import type {
AnalysisReport,
MarketReviewPayload,
MarketReviewPayloadSection,
ReportLanguage,
} from '../../types/analysis';
import { markdownToPlainText } from '../../utils/markdown';
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
import { Card } from '../common';
import { Tooltip } from '../common/Tooltip';
import { ReportMarkdownBody } from './ReportMarkdownBody';
interface MarketReviewReportViewProps {
report?: AnalysisReport;
recordId?: number;
content?: string;
payload?: MarketReviewPayload | null;
reportLanguage?: ReportLanguage;
className?: string;
}
type CopyType = 'markdown' | 'text';
type LoadedMarkdown = {
recordId: number;
content: string;
};
type LoadError = {
recordId: number;
message: string;
};
type MarketReviewSection = {
id: string;
title: string;
content: string;
icon: typeof FileText;
};
type StructuredMarketData = {
id: string;
title?: string;
breadth?: MarketReviewPayload['breadth'];
indices: NonNullable<MarketReviewPayload['indices']>;
};
const isMarketReviewPayload = (value: unknown): value is MarketReviewPayload =>
Boolean(value && typeof value === 'object');
const TOP_HEADING_PATTERN = /^\s*#\s+(.+?)\s*(?:\n+|$)/;
const SECTION_HEADING_PATTERN = /^(#{2,3})\s+(.+?)\s*$/gm;
const normalizeHeading = (value: string): string =>
value.trim().replace(/\s+/g, ' ').toLowerCase();
const stripTopHeading = (markdown: string, title?: string): string => {
const match = markdown.match(TOP_HEADING_PATTERN);
if (!match) {
return markdown.trim();
}
const heading = normalizeHeading(match[1]);
const reportTitle = normalizeHeading(title || '');
const genericTitles = new Set([
'market review',
'大盘复盘',
'大盘复盘详情',
'a股市场复盘',
'a 股市场复盘',
]);
if (heading === reportTitle || genericTitles.has(heading)) {
return markdown.slice(match[0].length).trim();
}
return markdown.trim();
};
const getSectionIcon = (title: string): typeof FileText => {
const normalized = normalizeHeading(title);
if (/指数|index|overview|大盘/.test(normalized)) {
return BarChart3;
}
if (/情绪|赚钱|sentiment|breadth|temperature/.test(normalized)) {
return Gauge;
}
if (/行业|板块|主题|轮动|sector|theme|rotation/.test(normalized)) {
return TrendingUp;
}
if (/资金|成交|量能|flow|turnover|volume|capital/.test(normalized)) {
return WalletCards;
}
if (/风险|机会|观察|risk|watch|next/.test(normalized)) {
return ShieldAlert;
}
return FileText;
};
const splitMarketReviewSections = (markdown: string): MarketReviewSection[] => {
const matches = Array.from(markdown.matchAll(SECTION_HEADING_PATTERN));
if (matches.length === 0) {
return [{
id: 'full-review',
title: '复盘正文',
content: markdown,
icon: FileText,
}];
}
const intro = markdown.slice(0, matches[0].index).trim();
const sections: MarketReviewSection[] = intro
? [{
id: 'overview',
title: '复盘概览',
content: intro,
icon: FileText,
}]
: [];
matches.forEach((match, index) => {
const start = (match.index ?? 0) + match[0].length;
const end = index + 1 < matches.length ? matches[index + 1].index ?? markdown.length : markdown.length;
const title = match[2].trim();
const content = markdown.slice(start, end).trim();
if (!content) {
return;
}
sections.push({
id: `${index}-${normalizeHeading(title).replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-').replace(/^-|-$/g, '') || 'section'}`,
title,
content,
icon: getSectionIcon(title),
});
});
return sections;
};
const getPayloadSections = (payload?: MarketReviewPayload | null): MarketReviewSection[] => {
if (!payload) {
return [];
}
if (payload.markets) {
return Object.entries(payload.markets).flatMap(([region, marketPayload]) => {
const marketTitle = marketPayload.title || region.toUpperCase();
return getPayloadSections(marketPayload).map((section) => ({
...section,
id: `${region}-${section.id}`,
title: `${marketTitle} / ${section.title}`,
}));
});
}
const payloadTitle = normalizeHeading(payload.title || '');
return (payload.sections || [])
.filter((section: MarketReviewPayloadSection) => section.markdown?.trim())
.filter((section: MarketReviewPayloadSection) => normalizeHeading(section.title || '') !== payloadTitle)
.map((section, index) => ({
id: `${section.key || index}-${normalizeHeading(section.title).replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') || 'section'}`,
title: section.title || 'Review',
content: section.markdown,
icon: getSectionIcon(section.title || ''),
}));
};
const hasStructuredMarketData = (payload?: MarketReviewPayload | null): boolean =>
Boolean(payload?.breadth || payload?.indices?.length);
const getStructuredMarketData = (payload?: MarketReviewPayload | null): StructuredMarketData[] => {
if (!payload) {
return [];
}
if (payload.markets) {
return Object.entries(payload.markets)
.filter(([, marketPayload]) => hasStructuredMarketData(marketPayload))
.map(([region, marketPayload]) => ({
id: region,
title: marketPayload.title || region.toUpperCase(),
breadth: marketPayload.breadth,
indices: marketPayload.indices || [],
}));
}
if (!hasStructuredMarketData(payload)) {
return [];
}
return [{
id: payload.region || 'market',
title: payload.title,
breadth: payload.breadth,
indices: payload.indices || [],
}];
};
const MARKET_REVIEW_TEXT: Record<ReportLanguage, {
reviewSummary: string;
noReviewSummary: string;
noSentimentScore: string;
rotationAndFunds: string;
noRotationView: string;
riskAndWatch: string;
noRiskWatch: string;
structuredMarketData: string;
noBreadthData: string;
advancers: string;
decliners: string;
limitUpDown: string;
turnover: string;
index: string;
last: string;
change: string;
highLow: string;
}> = {
zh: {
reviewSummary: '复盘摘要',
noReviewSummary: '暂无摘要',
noSentimentScore: '暂无评分',
rotationAndFunds: '轮动与资金',
noRotationView: '暂无轮动观点',
riskAndWatch: '风险与观察',
noRiskWatch: '暂无观察重点',
structuredMarketData: '结构化大盘数据',
noBreadthData: '暂无数据',
advancers: '上涨家数',
decliners: '下跌家数',
limitUpDown: '涨停/跌停',
turnover: '成交额',
index: '指数',
last: '最新',
change: '涨跌幅',
highLow: '高/低',
},
en: {
reviewSummary: 'Review Summary',
noReviewSummary: 'No review summary yet',
noSentimentScore: 'No score yet',
rotationAndFunds: 'Rotation & Funds',
noRotationView: 'No rotation view yet',
riskAndWatch: 'Risks & Watchlist',
noRiskWatch: 'No key observations yet',
structuredMarketData: 'Structured Market Data',
noBreadthData: 'No data',
advancers: 'Advancers',
decliners: 'Decliners',
limitUpDown: 'Limit Up/Down',
turnover: 'Turnover',
index: 'Index',
last: 'Last',
change: 'Change',
highLow: 'High/Low',
},
};
export const MarketReviewReportView: React.FC<MarketReviewReportViewProps> = ({
report,
recordId,
content: providedContent,
payload: providedPayload,
reportLanguage = 'zh',
className = '',
}) => {
const normalizedReportLanguage = normalizeReportLanguage(reportLanguage);
const text = getReportText(normalizedReportLanguage);
const marketReviewText = MARKET_REVIEW_TEXT[normalizedReportLanguage];
const [loadedMarkdown, setLoadedMarkdown] = useState<LoadedMarkdown | null>(null);
const [loadError, setLoadError] = useState<LoadError | null>(null);
const [copiedType, setCopiedType] = useState<CopyType | null>(null);
const summary = report?.summary;
const meta = report?.meta;
const contextPayload = report?.details?.contextSnapshot?.marketReviewPayload;
const marketReviewPayload = providedPayload ?? (isMarketReviewPayload(contextPayload) ? contextPayload : null);
const loadedContent = loadedMarkdown && loadedMarkdown.recordId === recordId ? loadedMarkdown.content : '';
const content = providedContent ?? marketReviewPayload?.markdownReport ?? loadedContent;
const error = loadError && loadError.recordId === recordId ? loadError.message : null;
const hasStructuredContent = Boolean(marketReviewPayload?.sections?.length || marketReviewPayload?.markets);
const isLoading = Boolean(recordId && !providedContent && !hasStructuredContent && loadedMarkdown?.recordId !== recordId && !error);
const displayTitle = marketReviewPayload?.rootTitle || marketReviewPayload?.title || meta?.stockName || 'Market Review';
const structuredContent = useMemo(
() => stripTopHeading(content, displayTitle),
[content, displayTitle],
);
const sections = useMemo(
() => {
const payloadSections = getPayloadSections(marketReviewPayload);
return payloadSections.length > 0 ? payloadSections : splitMarketReviewSections(structuredContent);
},
[marketReviewPayload, structuredContent],
);
const structuredMarketData = useMemo(
() => getStructuredMarketData(marketReviewPayload),
[marketReviewPayload],
);
const showStructuredMarketTitles = Boolean(marketReviewPayload?.markets);
useEffect(() => {
if (!recordId || providedContent || hasStructuredContent) {
return undefined;
}
let isMounted = true;
historyApi.getMarkdown(recordId)
.then((markdownContent) => {
if (isMounted) {
setLoadedMarkdown({ recordId, content: markdownContent });
setLoadError(null);
}
})
.catch((err: unknown) => {
if (isMounted) {
setLoadError({
recordId,
message: err instanceof Error ? err.message : text.loadReportFailed,
});
}
});
return () => {
isMounted = false;
};
}, [hasStructuredContent, providedContent, recordId, text.loadReportFailed]);
const handleCopy = useCallback(async (type: CopyType) => {
if (!content) {
return;
}
try {
const value = type === 'markdown' ? content : markdownToPlainText(content);
await navigator.clipboard.writeText(value);
setCopiedType(type);
window.setTimeout(() => setCopiedType(null), 2000);
} catch (err) {
console.error('Copy failed:', err);
}
}, [content]);
const insightCards = useMemo(() => [
{
icon: FileText,
label: marketReviewText.reviewSummary,
value: summary?.analysisSummary || marketReviewText.noReviewSummary,
},
{
icon: Gauge,
label: text.marketSentiment,
value: summary?.sentimentScore !== undefined
? `${summary.sentimentScore} / 100`
: marketReviewText.noSentimentScore,
},
{
icon: Layers,
label: marketReviewText.rotationAndFunds,
value: summary?.operationAdvice || marketReviewText.noRotationView,
},
{
icon: ShieldAlert,
label: marketReviewText.riskAndWatch,
value: summary?.trendPrediction || marketReviewText.noRiskWatch,
},
], [marketReviewText, summary, text.marketSentiment]);
return (
<div className={`animate-fade-in space-y-4 pb-8 ${className}`}>
<Card variant="gradient" padding="md" className="home-report-hero text-left">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="mb-2 inline-flex items-center gap-2 text-xs font-semibold text-secondary-text">
<BarChart3 className="h-4 w-4" aria-hidden="true" />
<span>MARKET REVIEW</span>
</div>
<h2 className="text-[26px] font-bold leading-tight text-foreground sm:text-[30px]">
{displayTitle}
</h2>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-text">
{meta?.stockCode ? (
<span className="home-accent-chip px-2 py-0.5 font-mono">{meta.stockCode}</span>
) : null}
{meta?.createdAt ? <span>{new Date(meta.createdAt).toLocaleString()}</span> : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Tooltip content={text.copyMarkdownSource}>
<span className="inline-flex">
<button
type="button"
onClick={() => void handleCopy('markdown')}
disabled={isLoading || !content || copiedType !== null}
className="home-surface-button flex h-10 w-10 items-center justify-center rounded-lg text-secondary-text hover:text-foreground disabled:opacity-50"
aria-label={text.copyMarkdownSource}
>
{copiedType === 'markdown' ? (
<svg className="h-5 w-5 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<Clipboard className="h-5 w-5" aria-hidden="true" />
)}
</button>
</span>
</Tooltip>
<Tooltip content={text.copyPlainText}>
<span className="inline-flex">
<button
type="button"
onClick={() => void handleCopy('text')}
disabled={isLoading || !content || copiedType !== null}
className="home-surface-button flex h-10 w-10 items-center justify-center rounded-lg text-secondary-text hover:text-foreground disabled:opacity-50"
aria-label={text.copyPlainText}
>
{copiedType === 'text' ? (
<svg className="h-5 w-5 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<FileText className="h-5 w-5" aria-hidden="true" />
)}
</button>
</span>
</Tooltip>
</div>
</div>
</Card>
{summary ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{insightCards.map(({ icon: Icon, label, value }) => (
<Card key={label} variant="bordered" padding="sm" className="home-panel-card text-left">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" aria-hidden="true" />
</div>
<div className="min-w-0">
<p className="label-uppercase">{label}</p>
<p className="mt-2 line-clamp-4 text-sm leading-6 text-foreground">{value}</p>
</div>
</div>
</Card>
))}
</div>
) : null}
{structuredMarketData.length > 0 ? (
<Card variant="bordered" padding="md" className="home-panel-card text-left">
<div className="mb-3 flex items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<BarChart3 className="h-4 w-4" aria-hidden="true" />
</span>
<h3 className="text-base font-semibold text-foreground">{marketReviewText.structuredMarketData}</h3>
</div>
<div className="space-y-5">
{structuredMarketData.map((marketData) => (
<div key={marketData.id} className="space-y-3">
{showStructuredMarketTitles ? (
<h4 className="text-sm font-semibold text-foreground">{marketData.title}</h4>
) : null}
{marketData.breadth ? (
<div className="grid grid-cols-2 gap-2 text-sm md:grid-cols-4">
<div className="rounded-lg border border-subtle p-3">
<p className="label-uppercase">{marketReviewText.advancers}</p>
<p className="mt-1 font-semibold text-foreground">{marketData.breadth.upCount ?? '-'}</p>
</div>
<div className="rounded-lg border border-subtle p-3">
<p className="label-uppercase">{marketReviewText.decliners}</p>
<p className="mt-1 font-semibold text-foreground">{marketData.breadth.downCount ?? '-'}</p>
</div>
<div className="rounded-lg border border-subtle p-3">
<p className="label-uppercase">{marketReviewText.limitUpDown}</p>
<p className="mt-1 font-semibold text-foreground">
{marketData.breadth.limitUpCount ?? '-'} / {marketData.breadth.limitDownCount ?? '-'}
</p>
</div>
<div className="rounded-lg border border-subtle p-3">
<p className="label-uppercase">{marketReviewText.turnover}</p>
<p className="mt-1 font-semibold text-foreground">
{marketData.breadth.totalAmount ?? '-'} {marketData.breadth.turnoverUnit || ''}
</p>
</div>
</div>
) : (
<p className="text-sm text-secondary-text">{marketReviewText.noBreadthData}</p>
)}
{marketData.indices.length > 0 ? (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="text-left text-xs uppercase text-muted-text">
<tr>
<th className="px-2 py-2">{marketReviewText.index}</th>
<th className="px-2 py-2">{marketReviewText.last}</th>
<th className="px-2 py-2">{marketReviewText.change}</th>
<th className="px-2 py-2">{marketReviewText.highLow}</th>
</tr>
</thead>
<tbody className="divide-y divide-subtle">
{marketData.indices.map((index) => (
<tr key={index.code || index.name}>
<td className="px-2 py-2 font-medium text-foreground">{index.name}</td>
<td className="px-2 py-2 text-secondary-text">{index.current ?? '-'}</td>
<td className="px-2 py-2 text-secondary-text">{index.changePct !== undefined ? `${index.changePct}%` : '-'}</td>
<td className="px-2 py-2 text-secondary-text">{index.high ?? '-'} / {index.low ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
))}
</div>
</Card>
) : null}
{isLoading ? (
<Card variant="bordered" padding="md" className="home-panel-card text-left">
<div className="flex h-64 flex-col items-center justify-center">
<div className="home-spinner h-10 w-10 animate-spin border-[3px]" />
<p className="mt-4 text-sm text-secondary-text">{text.loadingReport}</p>
</div>
</Card>
) : error ? (
<Card variant="bordered" padding="md" className="home-panel-card text-left">
<div className="flex h-64 flex-col items-center justify-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-danger/10">
<ShieldAlert className="h-6 w-6 text-danger" aria-hidden="true" />
</div>
<p className="text-sm text-danger">{error}</p>
</div>
</Card>
) : (
<div data-testid="market-review-report" className="space-y-4">
{sections.map(({ id, title, content: sectionContent, icon: Icon }) => (
<Card key={id} variant="bordered" padding="md" className="home-panel-card text-left">
<div className="mb-3 flex items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
<h3 className="text-base font-semibold text-foreground">{title}</h3>
</div>
<ReportMarkdownBody
content={sectionContent}
className="market-review-markdown"
/>
</Card>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,40 @@
import type React from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface ReportMarkdownBodyProps {
content: string;
className?: string;
testId?: string;
}
export const ReportMarkdownBody: React.FC<ReportMarkdownBodyProps> = ({
content,
className = '',
testId,
}) => (
<div
data-testid={testId}
className={`home-markdown-prose prose prose-invert prose-sm max-w-none
prose-headings:text-foreground prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2
prose-h1:text-xl
prose-h2:text-lg
prose-h3:text-base
prose-p:leading-relaxed prose-p:mb-3 prose-p:last:mb-0
prose-strong:text-foreground prose-strong:font-semibold
prose-ul:my-2 prose-ol:my-2 prose-li:my-1
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
prose-pre:border
prose-table:border-collapse
prose-hr:my-4
prose-a:no-underline hover:prose-a:underline
prose-blockquote:text-secondary-text
whitespace-pre-line break-words
${className}
`}
>
<Markdown remarkPlugins={[remarkGfm]}>
{content}
</Markdown>
</div>
);
@@ -1,12 +1,11 @@
import type React from 'react';
import { useCallback, useEffect, useState } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { historyApi } from '../../api/history';
import type { ReportLanguage } from '../../types/analysis';
import { markdownToPlainText } from '../../utils/markdown';
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
import { Tooltip } from '../common/Tooltip';
import { ReportMarkdownBody } from './ReportMarkdownBody';
export interface ReportMarkdownPanelProps {
recordId: number;
@@ -166,28 +165,7 @@ export const ReportMarkdownPanel: React.FC<ReportMarkdownPanelProps> = ({
</button>
</div>
) : (
<div
className="home-markdown-prose prose prose-invert prose-sm max-w-none
prose-headings:text-foreground prose-headings:font-semibold prose-headings:mt-4 prose-headings:mb-2
prose-h1:text-xl
prose-h2:text-lg
prose-h3:text-base
prose-p:leading-relaxed prose-p:mb-3 prose-p:last:mb-0
prose-strong:text-foreground prose-strong:font-semibold
prose-ul:my-2 prose-ol:my-2 prose-li:my-1
prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none
prose-pre:border
prose-table:border-collapse
prose-hr:my-4
prose-a:no-underline hover:prose-a:underline
prose-blockquote:text-secondary-text
whitespace-pre-line break-words
"
>
<Markdown remarkPlugins={[remarkGfm]}>
{content}
</Markdown>
</div>
<ReportMarkdownBody content={content} />
)}
<div className="home-divider mt-6 flex justify-end border-t pt-4">
@@ -6,6 +6,7 @@ import { ReportNews } from './ReportNews';
import { ReportDetails } from './ReportDetails';
import { ReportDiagnostics } from './ReportDiagnostics';
import { AnalysisContextSummary } from './AnalysisContextSummary';
import { MarketReviewReportView } from './MarketReviewReportView';
import { getReportText, normalizeReportLanguage } from '../../utils/reportLanguage';
interface ReportSummaryProps {
@@ -43,6 +44,16 @@ export const ReportSummary: React.FC<ReportSummaryProps> = ({
modelUsed && !['unknown', 'error', 'none', 'null', 'n/a'].includes(modelUsed.toLowerCase()),
);
if (meta.reportType === 'market_review') {
return (
<MarketReviewReportView
report={report}
recordId={recordId}
reportLanguage={reportLanguage}
/>
);
}
return (
<div className="space-y-5 pb-8 animate-fade-in">
{/* 概览区(首屏) */}
@@ -0,0 +1,177 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { AnalysisReport, MarketReviewPayload } from '../../../types/analysis';
import { MarketReviewReportView } from '../MarketReviewReportView';
vi.mock('../../../api/history', () => ({
historyApi: {
getMarkdown: vi.fn(),
},
}));
const englishMarketReviewReport: AnalysisReport = {
meta: {
queryId: 'market-review-q-1',
stockCode: 'MARKET',
stockName: 'Market Review',
reportType: 'market_review',
reportLanguage: 'en',
createdAt: '2026-03-18T08:00:00Z',
},
summary: {
analysisSummary: '',
operationAdvice: '',
trendPrediction: '',
sentimentScore: undefined as unknown as number,
},
};
const combinedMarketReviewPayload: MarketReviewPayload = {
version: 1,
kind: 'market_review',
region: 'cn,hk',
language: 'zh',
rootTitle: '大盘复盘',
markets: {
cn: {
title: 'A股市场',
breadth: {
upCount: 3120,
downCount: 1420,
limitUpCount: 72,
limitDownCount: 4,
totalAmount: 9600,
turnoverUnit: '亿元',
},
indices: [{
code: '000300',
name: '沪深300',
current: 3920.2,
changePct: 1.2,
high: 3940.5,
low: 3860.1,
}],
},
hk: {
title: '港股市场',
breadth: {
upCount: 680,
downCount: 410,
limitUpCount: 0,
limitDownCount: 0,
totalAmount: 1180,
turnoverUnit: '亿港元',
},
indices: [{
code: 'HSI',
name: '恒生指数',
current: 18920.4,
changePct: -0.5,
high: 19050.2,
low: 18780.3,
}],
},
},
};
const noBreadthMarketReviewPayload: MarketReviewPayload = {
version: 1,
kind: 'market_review',
region: 'us',
language: 'en',
title: 'Market Review',
rootTitle: 'Market Review',
indices: [{
code: 'SPX',
name: 'S&P 500',
current: 5200,
changePct: 0.68,
high: 5235.2,
low: 5170.4,
}],
sectors: {
top: [{ name: 'Technology', changePct: 1.9 }],
bottom: [{ name: 'Energy', changePct: -0.8 }],
},
news: [],
sections: [],
};
describe('MarketReviewReportView', () => {
it('uses localized summary card labels and fallbacks for English reports', () => {
render(
<MarketReviewReportView
report={englishMarketReviewReport}
content="# Market Review"
reportLanguage="en"
/>,
);
expect(screen.getByText('Review Summary')).toBeInTheDocument();
expect(screen.getByText('No review summary yet')).toBeInTheDocument();
expect(screen.getByText('Market Sentiment')).toBeInTheDocument();
expect(screen.getByText('No score yet')).toBeInTheDocument();
expect(screen.getByText('Rotation & Funds')).toBeInTheDocument();
expect(screen.getByText('No rotation view yet')).toBeInTheDocument();
expect(screen.getByText('Risks & Watchlist')).toBeInTheDocument();
expect(screen.getByText('No key observations yet')).toBeInTheDocument();
expect(screen.queryByText('复盘摘要')).not.toBeInTheDocument();
expect(screen.queryByText('暂无摘要')).not.toBeInTheDocument();
});
it('renders structured data for every market in a combined market review payload', () => {
render(
<MarketReviewReportView
payload={combinedMarketReviewPayload}
content="# 大盘复盘"
reportLanguage="zh"
/>,
);
expect(screen.getByText('A股市场')).toBeInTheDocument();
expect(screen.getByText('港股市场')).toBeInTheDocument();
expect(screen.getByText('沪深300')).toBeInTheDocument();
expect(screen.getByText('恒生指数')).toBeInTheDocument();
expect(screen.getByText('3120')).toBeInTheDocument();
expect(screen.getByText('680')).toBeInTheDocument();
});
it('localizes structured market data labels for Chinese reports', () => {
render(
<MarketReviewReportView
payload={combinedMarketReviewPayload}
content="# 大盘复盘"
reportLanguage="zh"
/>,
);
expect(screen.getByText('结构化大盘数据')).toBeInTheDocument();
expect(screen.getAllByText('上涨家数')).toHaveLength(2);
expect(screen.getAllByText('下跌家数')).toHaveLength(2);
expect(screen.getAllByText('涨停/跌停')).toHaveLength(2);
expect(screen.getAllByText('成交额')).toHaveLength(2);
expect(screen.getAllByText('指数')).toHaveLength(2);
expect(screen.getAllByText('最新')).toHaveLength(2);
expect(screen.getAllByText('涨跌幅')).toHaveLength(2);
expect(screen.getAllByText('高/低')).toHaveLength(2);
expect(screen.queryByText('Structured Market Data')).not.toBeInTheDocument();
expect(screen.queryByText('Advancers')).not.toBeInTheDocument();
expect(screen.queryByText('Index')).not.toBeInTheDocument();
});
it('shows "No data" when breadth is not available for a market review payload', () => {
render(
<MarketReviewReportView
payload={noBreadthMarketReviewPayload}
content="# Market Review"
reportLanguage="en"
/>,
);
expect(screen.getByText('Structured Market Data')).toBeInTheDocument();
expect(screen.getByText('No data')).toBeInTheDocument();
expect(screen.getByText('S&P 500')).toBeInTheDocument();
expect(screen.queryByText('Advancers')).not.toBeInTheDocument();
expect(screen.queryByText('Decliners')).not.toBeInTheDocument();
});
});
@@ -6,3 +6,5 @@ export * from './ReportStrategy';
export * from './ReportNews';
export * from './ReportDetails';
export * from './ReportMarkdown';
export * from './ReportMarkdownBody';
export * from './MarketReviewReportView';
+7
View File
@@ -1913,6 +1913,13 @@ textarea {
padding: 4px 6px;
}
.home-markdown-prose :where(table) {
display: block;
width: 100%;
overflow-x: auto;
white-space: nowrap;
}
.home-markdown-prose :where(th) {
background: hsl(var(--elevated) / 0.92);
color: hsl(var(--foreground));
+33 -41
View File
@@ -12,12 +12,14 @@ import { DashboardStateBlock } from '../components/dashboard';
import { StockAutocomplete } from '../components/StockAutocomplete';
import { StockHistoryTrendDrawer, StockBar } from '../components/history';
import { ReportMarkdownDrawer } from '../components/report/ReportMarkdownDrawer';
import { MarketReviewReportView } from '../components/report/MarketReviewReportView';
import { ReportSummary } from '../components/report/ReportSummary';
import { TaskPanel } from '../components/tasks';
import { useDashboardLifecycle, useHomeDashboardState } from '../hooks';
import { useWatchlist } from '../hooks/useWatchlist';
import type { SetupStatusResponse } from '../types/systemConfig';
import { getReportText, normalizeReportLanguage } from '../utils/reportLanguage';
import type { MarketReviewPayload } from '../types/analysis';
type MarketReviewNotice = {
variant: 'success' | 'warning' | 'danger';
@@ -32,7 +34,7 @@ const HomePage: React.FC = () => {
const [marketReviewNotice, setMarketReviewNotice] = useState<MarketReviewNotice>(null);
const [marketReviewError, setMarketReviewError] = useState<ParsedApiError | null>(null);
const [marketReviewReport, setMarketReviewReport] = useState<string | null>(null);
const [marketReviewReportCopied, setMarketReviewReportCopied] = useState(false);
const [marketReviewPayload, setMarketReviewPayload] = useState<MarketReviewPayload | null>(null);
const [analysisSkills, setAnalysisSkills] = useState<SkillInfo[]>([]);
const [selectedStrategyId, setSelectedStrategyId] = useState('');
const [strategyMenuOpen, setStrategyMenuOpen] = useState(false);
@@ -176,6 +178,7 @@ const HomePage: React.FC = () => {
}, [analysisSkills, selectedStrategyId]);
const reportLanguage = normalizeReportLanguage(selectedReport?.meta.reportLanguage);
const liveMarketReviewLanguage = normalizeReportLanguage(marketReviewPayload?.language);
const reportText = getReportText(reportLanguage);
const isMarketReviewHistoryReport = selectedReport?.meta.reportType === 'market_review';
const isHistoryTrendUnavailable = !selectedReport || selectedReport.meta.reportType === 'market_review'
@@ -316,10 +319,19 @@ const HomePage: React.FC = () => {
const watchlistState = useWatchlist();
const clearMarketReviewState = useCallback(() => {
stopMarketReviewPolling();
setMarketReviewReport(null);
setMarketReviewPayload(null);
setMarketReviewNotice(null);
setMarketReviewError(null);
}, [stopMarketReviewPolling]);
const handleHistoryItemClick = useCallback((recordId: number) => {
clearMarketReviewState();
void selectHistoryItem(recordId);
setSidebarOpen(false);
}, [selectHistoryItem]);
}, [clearMarketReviewState, selectHistoryItem]);
const [isDeletingStock, setIsDeletingStock] = useState(false);
const handleDeleteStock = useCallback(async (stockCode: string) => {
@@ -391,6 +403,7 @@ const HomePage: React.FC = () => {
if (attempts >= maxAttempts) {
stopMarketReviewPolling();
setMarketReviewReport(null);
setMarketReviewPayload(null);
setMarketReviewNotice({
variant: 'danger',
title: '大盘复盘已超时',
@@ -406,6 +419,7 @@ const HomePage: React.FC = () => {
const status = await analysisApi.getStatus(taskId);
if (status.status === 'pending' || status.status === 'processing') {
setMarketReviewReport(null);
setMarketReviewPayload(null);
const progress = typeof status.progress === 'number'
? `${status.progress}%`
: '进行中';
@@ -423,6 +437,7 @@ const HomePage: React.FC = () => {
? status.marketReviewReport
: '';
setMarketReviewReport(marketReviewText ? marketReviewText.trim() : null);
setMarketReviewPayload(status.marketReviewPayload ?? null);
setMarketReviewNotice({
variant: 'success',
title: '大盘复盘已完成',
@@ -436,6 +451,7 @@ const HomePage: React.FC = () => {
if (status.status === 'failed') {
stopMarketReviewPolling();
setMarketReviewReport(null);
setMarketReviewPayload(null);
setMarketReviewError(
getParsedApiError({
response: {
@@ -454,6 +470,7 @@ const HomePage: React.FC = () => {
stopMarketReviewPolling();
setMarketReviewReport(null);
setMarketReviewPayload(null);
setMarketReviewNotice({
variant: 'danger',
title: '大盘复盘状态异常',
@@ -466,6 +483,7 @@ const HomePage: React.FC = () => {
if (attempts >= maxAttempts) {
stopMarketReviewPolling();
setMarketReviewReport(null);
setMarketReviewPayload(null);
setMarketReviewError(parsed);
setMarketReviewNotice(null);
scrollMarketReviewFeedbackIntoView();
@@ -495,6 +513,7 @@ const HomePage: React.FC = () => {
setMarketReviewNotice(null);
setMarketReviewError(null);
setMarketReviewReport(null);
setMarketReviewPayload(null);
scrollMarketReviewFeedbackIntoView();
try {
const result = await analysisApi.triggerMarketReview({ sendNotification: notify });
@@ -517,22 +536,6 @@ const HomePage: React.FC = () => {
}
}, [notify, pollMarketReviewStatus, scrollMarketReviewFeedbackIntoView]);
const handleCopyMarketReviewReport = useCallback(() => {
if (!marketReviewReport) {
return;
}
void navigator.clipboard.writeText(marketReviewReport).then(
() => {
setMarketReviewReportCopied(true);
setTimeout(() => setMarketReviewReportCopied(false), 2000);
},
(err) => {
console.error('复制失败:', err);
},
);
}, [marketReviewReport]);
const sidebarContent = useMemo(
() => (
<div className="flex min-h-0 h-full flex-col gap-3 overflow-hidden">
@@ -778,25 +781,12 @@ const HomePage: React.FC = () => {
) : null}
{marketReviewReport ? (
<div className="mb-3 rounded-xl border border-subtle bg-surface/70 px-3 py-3 text-xs text-secondary-text shadow-sm">
<div className="mb-2 flex items-center justify-between gap-2">
<p className="font-semibold text-foreground">大盘复盘报告</p>
<button
type="button"
className="home-surface-button h-7 rounded-md px-3 py-1 text-xs text-foreground"
disabled={marketReviewReportCopied}
onClick={() => void handleCopyMarketReviewReport()}
>
{marketReviewReportCopied ? '已复制' : '复制'}
</button>
</div>
<pre
data-testid="market-review-report"
className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-background px-3 py-2 leading-relaxed"
>
{marketReviewReport}
</pre>
</div>
<MarketReviewReportView
content={marketReviewReport}
payload={marketReviewPayload}
reportLanguage={liveMarketReviewLanguage}
className="mb-3"
/>
) : null}
{error ? (
@@ -806,12 +796,13 @@ const HomePage: React.FC = () => {
onDismiss={clearError}
/>
) : null}
{isLoadingReport ? (
{!marketReviewReport && isLoadingReport ? (
<div className="flex h-full flex-col items-center justify-center">
<DashboardStateBlock title="加载报告中..." loading />
</div>
) : selectedReport ? (
) : !marketReviewReport && selectedReport ? (
<div className={isHistoryTrendOpen ? 'max-w-6xl space-y-4 pb-8' : 'max-w-4xl space-y-4 pb-8'}>
{!isMarketReviewHistoryReport ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="home-action-ai"
@@ -863,6 +854,7 @@ const HomePage: React.FC = () => {
{reportText.fullReport}
</Button>
</div>
) : null}
{isHistoryTrendOpen ? (
<StockHistoryTrendDrawer
key={`stock-history-${selectedReport.meta.id}`}
@@ -893,7 +885,7 @@ const HomePage: React.FC = () => {
/>
)}
</div>
) : (
) : !marketReviewReport ? (
<div className="flex h-full items-center justify-center">
<EmptyState
title="开始分析"
@@ -906,7 +898,7 @@ const HomePage: React.FC = () => {
)}
/>
</div>
)}
) : null}
</section>
</div>
</div>
@@ -290,6 +290,51 @@ describe('HomePage', () => {
expect(analysisApi.getStatus).toHaveBeenCalledWith('task-1');
});
it('uses the payload language for live market review controls', async () => {
vi.mocked(historyApi.getList).mockResolvedValue({
total: 0,
page: 1,
limit: 20,
items: [],
});
vi.mocked(analysisApi.triggerMarketReview).mockResolvedValue({
status: 'accepted',
sendNotification: true,
message: 'Market review task submitted',
taskId: 'task-1',
});
vi.mocked(analysisApi.getStatus).mockResolvedValue({
taskId: 'task-1',
status: 'completed',
marketReviewReport: '# US Market Recap\n\n## Summary\n\nUS market review body',
marketReviewPayload: {
kind: 'market_review',
region: 'us',
language: 'en',
title: 'US Market Recap',
sections: [
{
key: 'summary',
title: 'Summary',
markdown: 'US market review body',
},
],
},
});
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: '大盘复盘' }));
expect(await screen.findByRole('button', { name: 'Copy Markdown Source' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy Plain Text' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '复制 Markdown 源码' })).not.toBeInTheDocument();
});
it('scrolls the dashboard to market review feedback after toolbar clicks', async () => {
vi.mocked(historyApi.getList).mockResolvedValue({
total: 1,
@@ -354,7 +399,56 @@ describe('HomePage', () => {
vi.mocked(analysisApi.getStatus).mockResolvedValue({
taskId: 'task-1',
status: 'completed',
marketReviewReport: Array.from({ length: 30 }, (_, index) => `第 ${index + 1} 行复盘内容`).join('\n'),
marketReviewReport: [
'# A股市场复盘',
'',
'> 市场情绪修复',
'',
'## 指数概览',
'',
'| 指数 | 表现 |',
'| --- | --- |',
'| 上证指数 | 震荡走强 |',
'',
'## 风险提示',
'',
'- 资金回流核心资产',
].join('\n'),
marketReviewPayload: {
kind: 'market_review',
region: 'cn',
title: 'A股市场复盘',
breadth: {
upCount: 3200,
downCount: 1700,
limitUpCount: 60,
limitDownCount: 8,
totalAmount: 9800,
turnoverUnit: '亿',
},
indices: [
{
code: '000001',
name: '上证指数',
current: 3150.2,
changePct: 0.62,
high: 3168.4,
low: 3120.8,
},
],
sections: [
{
key: 'index_overview',
title: '指数概览',
markdown: '| 指数 | 表现 |\n| --- | --- |\n| 上证指数 | 震荡走强 |',
},
{
key: 'risk',
title: '风险提示',
markdown: '- 资金回流核心资产',
},
],
},
});
render(
@@ -370,7 +464,15 @@ describe('HomePage', () => {
expect(dashboardScroll).toContainElement(marketReviewReport);
expect(marketReviewReport.className).not.toContain('max-h-64');
expect(marketReviewReport.className).not.toContain('overflow-y-auto');
expect(await screen.findByText('开始分析')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '结构化大盘数据' })).toBeInTheDocument();
expect(screen.getByText('3200')).toBeInTheDocument();
expect(screen.getByText('3150.2')).toBeInTheDocument();
expect(marketReviewReport.querySelector('h2, h3')?.textContent).not.toBe('A股市场复盘');
expect(screen.getByRole('heading', { name: '指数概览' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '风险提示' })).toBeInTheDocument();
expect(screen.getAllByRole('table').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('# A股市场复盘')).not.toBeInTheDocument();
expect(screen.queryByText('开始分析')).not.toBeInTheDocument();
});
it('shows first-run setup gaps and links to settings', async () => {
@@ -701,7 +803,7 @@ describe('HomePage', () => {
expect(trigger).toHaveFocus();
});
it('disables stock reanalysis and follow-up for market review history reports', async () => {
it('renders market review history reports with a dedicated markdown view', async () => {
vi.mocked(historyApi.getList).mockResolvedValue({
total: 1,
page: 1,
@@ -709,6 +811,19 @@ describe('HomePage', () => {
items: [marketReviewHistoryItem],
});
vi.mocked(historyApi.getDetail).mockResolvedValue(marketReviewHistoryReport);
vi.mocked(historyApi.getMarkdown).mockResolvedValue([
'# 大盘复盘详情',
'',
'## 市场情绪与赚钱效应',
'',
'**赚钱效应** 改善',
'',
'## 行业/主题轮动',
'',
'| 方向 | 状态 |',
'| --- | --- |',
'| 半导体 | 轮动增强 |',
].join('\n'));
render(
<MemoryRouter>
@@ -717,16 +832,107 @@ describe('HomePage', () => {
);
await screen.findByText('大盘复盘摘要');
const reanalyzeButton = screen.getByRole('button', { name: '重新分析' });
const followUpButton = screen.getByRole('button', { name: '追问 AI' });
expect(reanalyzeButton).toBeDisabled();
expect(followUpButton).toBeDisabled();
fireEvent.click(reanalyzeButton);
fireEvent.click(followUpButton);
expect(screen.queryByRole('heading', { name: '大盘复盘详情' })).not.toBeInTheDocument();
expect(await screen.findByRole('heading', { name: '市场情绪与赚钱效应' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '行业/主题轮动' })).toBeInTheDocument();
expect(screen.getByText('赚钱效应')).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '重新分析' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '追问 AI' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '历史趋势' })).not.toBeInTheDocument();
expect(historyApi.getMarkdown).toHaveBeenCalledWith(marketReviewHistoryReport.meta.id);
expect(analysisApi.analyzeAsync).not.toHaveBeenCalled();
expect(navigateMock).not.toHaveBeenCalled();
});
it('clears live market review output when switching to a history report', async () => {
vi.mocked(historyApi.getList).mockResolvedValue({
total: 1,
page: 1,
limit: 20,
items: [historyItem],
});
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
total: 2,
items: [
{
id: 2,
stockCode: 'MARKET',
stockName: '大盘复盘',
sentimentScore: 50,
operationAdvice: '查看复盘',
analysisCount: 1,
lastAnalysisTime: '2026-03-18T08:30:00Z',
reportType: 'market_review',
},
{
id: 1,
stockCode: '600519',
stockName: '贵州茅台',
sentimentScore: 82,
operationAdvice: '买入',
analysisCount: 1,
lastAnalysisTime: '2026-03-18T08:00:00Z',
reportType: 'detailed',
},
],
});
vi.mocked(historyApi.getDetail).mockImplementation((recordId: number) => {
if (recordId === 2) {
return Promise.resolve(marketReviewHistoryReport);
}
return Promise.resolve(historyReport);
});
vi.mocked(historyApi.getMarkdown).mockResolvedValue([
'# 大盘复盘详情',
'',
'## 市场情绪与赚钱效应',
'',
'**赚钱效应** 改善',
'',
'## 行业/主题轮动',
'',
'| 方向 | 状态 |',
'| --- | --- |',
'| 半导体 | 轮动增强 |',
].join('\n'));
vi.mocked(analysisApi.triggerMarketReview).mockResolvedValue({
status: 'accepted',
sendNotification: true,
message: '大盘复盘任务已提交',
taskId: 'task-1',
});
vi.mocked(analysisApi.getStatus).mockResolvedValue({
taskId: 'task-1',
status: 'completed',
marketReviewReport: '市场复盘报告示例文本',
});
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
await screen.findByText('趋势维持强势');
fireEvent.click(screen.getByRole('button', { name: '大盘复盘' }));
await waitFor(() => {
expect(screen.getByText('大盘复盘已完成')).toBeInTheDocument();
expect(screen.getByText('市场复盘报告示例文本')).toBeInTheDocument();
});
const marketHistoryButton = screen.getByRole('button', { name: /MARKET/ });
fireEvent.click(marketHistoryButton);
await waitFor(() => {
expect(screen.queryByText('市场复盘报告示例文本')).not.toBeInTheDocument();
expect(screen.queryByText('大盘复盘已完成')).not.toBeInTheDocument();
});
expect(await screen.findByText('大盘复盘摘要')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '市场情绪与赚钱效应' })).toBeInTheDocument();
expect(vi.mocked(historyApi.getDetail)).toHaveBeenCalledWith(2);
});
});
+52 -1
View File
@@ -125,6 +125,56 @@ export interface SectorRankings {
bottom?: SectorRankingItem[];
}
export interface MarketReviewPayloadSection {
key?: string;
title: string;
markdown: string;
}
export interface MarketReviewIndex {
code: string;
name: string;
current?: number;
change?: number;
changePct?: number;
open?: number;
high?: number;
low?: number;
volume?: number;
amount?: number;
amplitude?: number;
}
export interface MarketReviewBreadth {
upCount?: number;
downCount?: number;
flatCount?: number;
limitUpCount?: number;
limitDownCount?: number;
totalAmount?: number;
turnoverUnit?: string;
}
export interface MarketReviewPayload {
version?: number;
kind?: 'market_review' | string;
region?: string;
language?: ReportLanguage | string;
title?: string;
rootTitle?: string;
generatedAt?: string;
date?: string;
marketScope?: string;
marketLight?: Record<string, unknown>;
breadth?: MarketReviewBreadth;
indices?: MarketReviewIndex[];
sectors?: SectorRankings;
news?: Array<Record<string, unknown>>;
sections?: MarketReviewPayloadSection[];
markets?: Record<string, MarketReviewPayload>;
markdownReport?: string;
}
export type AnalysisContextPackBlockStatus =
| 'available'
| 'missing'
@@ -190,7 +240,7 @@ export interface AnalysisContextPackOverview {
export interface ReportDetails {
newsContent?: string;
rawResult?: Record<string, unknown>;
contextSnapshot?: Record<string, unknown>;
contextSnapshot?: Record<string, unknown> & { marketReviewPayload?: MarketReviewPayload };
analysisContextPackOverview?: AnalysisContextPackOverview | null;
financialReport?: Record<string, unknown>;
dividendMetrics?: Record<string, unknown>;
@@ -292,6 +342,7 @@ export interface TaskStatus {
progress?: number;
result?: AnalysisResult;
marketReviewReport?: string;
marketReviewPayload?: MarketReviewPayload;
error?: string;
stockName?: string;
originalQuery?: string;
+7
View File
@@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
- [修复] Web/桌面端左侧导航选中态改用 border 实现,避免蓝色竖条指示器溢出侧栏边界;侧栏展开宽度 116px → 136px,新增 rail 紧凑模式。
- [修复] Agent 分析路径生成 AnalysisContextPack overview 前复用已落库日线分析上下文,避免日线已抓取成功仍显示 `daily_bars_missing`。
- [新功能] Web 大盘复盘报告新增专用展示视图,历史入口和首页即时结果统一使用 Markdown/GFM 渲染并隐藏个股专属模块。
- [新功能] 大盘复盘新增结构化 `market_review_payload`,Web、历史详情和推送统一基于结构化数据渲染,并保留 Markdown 兼容展示。
- [文档] 本次迭代仅重构大盘复盘展示链路(统一 Markdown/GFM 渲染与结构化 payload 渲染),不涉及 `LITELLM_*`、`LLM_*`、`provider/model/base_url` 等运行时配置语义;如需回退采用常规发布回滚。
- [修复] 修正大盘复盘结构化 `breadth` 的可用性判断:当市场不支持/抓取失败(如美股、港股或 A 股 breadth 不可用)时不下发 `breadth`,前端展示“暂无数据”,避免误导性 0 值。
- [修复] 明确大盘复盘语言行为调整为遵循全局 `report_language`,并在回退场景保持原语种提示(如美股/港股默认会按配置语言展示);兼容性变化说明见该条款,无需额外改动 provider/model/base_url。
- [修复] 美股中文场景下,市场标签与策略蓝图(`Strategy Blueprint/Strategy Framework`)已本地化为中文显示,避免 `report_language=zh` 下混入英文策略段落与市场标签;与 Issue #1555 的历史/即时结果一致。
<!-- 新条目格式:- [类型] 描述(类型取值:新功能/改进/修复/文档/测试/chore)-->
<!-- 每条独立一行追加到本段末尾,无需分类标题,合并时冲突最小 -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

+2
View File
@@ -1347,6 +1347,8 @@ FastAPI 提供 RESTful API 服务,支持配置管理和触发分析。
> 说明:`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,避免再次触发分析重算。
> 说明:`POST /api/v1/analysis/market-review` 的返回与历史持久化都会包含 `market_review_payload`:`market_scope`、`sections`、`sectors`、`news`、`market_light`、`indices` 等结构化字段。Web 端 Markdown 渲染与历史详情会复用该结构化字段;若结构化字段为空则回退到原始 Markdown。
> 说明:`market_review_payload` 中的 `breadth` 仅在行情宽度数据真实可用时下发;当美股/港股或接口暂不可用时不下发该字段。前端显示层需按“字段缺失”降级为“暂无数据”而不是展示 0。
> 说明:该端点若返回 `task_id`,WebUI 会轮询 `GET /api/v1/analysis/status/{task_id}` 展示状态。状态为 `completed` 时给出完成提示(报告已生成并按配置推送),状态为 `failed` 时在前端错误区域显示 `error` 原因。
> 说明:`GET /api/v1/history/{record_id}/diagnostics` 支持历史记录主键 ID 或 `query_id`,返回 `normal/degraded/failed/unknown` 摘要、关键链路组件和可复制的脱敏 `copy_text`;旧报告缺少诊断快照时返回 `unknown`,不影响报告读取。
> 说明:`GET /api/v1/history` 的列表摘要可按 `stock_code` 分页查询同一股票历史,并返回趋势判断、分析摘要、模型名与分析时价格/涨跌幅等可选字段;旧记录缺少快照字段时返回空值。Web 报告页的“历史趋势”抽屉复用该接口加载同股历史。
+2
View File
@@ -1179,6 +1179,8 @@ FastAPI provides RESTful API service for configuration management and triggering
> 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.
> Note: `/api/v1/analysis/market-review` responses and persisted history include a structured `market_review_payload` with fields like `market_scope`, `sections`, `sectors`, `news`, `market_light`, `indices`, etc. Web rendering and history detail use the same structure and fall back to raw `markdown_report` only if the structure is unavailable.
> Note: `market_review_payload.breadth` is emitted only when breadth data is truly available. For markets/feeds without usable breadth, the field is omitted and UI should display `No data` (not a misleading zero value).
> Note: when `/api/v1/analysis/market-review` returns a `task_id`, the WebUI polls `GET /api/v1/analysis/status/{task_id}`. The UI renders clear `pending/processing` progress, shows completion feedback when status becomes `completed`, and surfaces `error` content on `failed`.
> Note: `GET /api/v1/history/{record_id}/diagnostics` accepts either the history primary key ID or `query_id`, and returns a `normal/degraded/failed/unknown` summary, key pipeline components, and sanitized `copy_text`. Older reports without `context_snapshot.diagnostics` return `unknown` without affecting normal report reads.
> Note: `GET /api/v1/history` list summaries can be paginated by `stock_code` for same-stock history and now include optional trend, summary, model, and analysis-time price/change fields. Older rows without persisted snapshots return empty values. The Web report page's "History Trend" drawer reuses this endpoint.
+162 -4
View File
@@ -11,6 +11,7 @@
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Dict, Optional
import uuid
@@ -36,6 +37,14 @@ _MARKET_REVIEW_REGION_ORDER = tuple(market for market, _, _ in _MARKET_REVIEW_MA
_VALID_MARKET_REVIEW_REGIONS = frozenset(_MARKET_REVIEW_REGION_ORDER)
@dataclass
class MarketReviewRunResult:
"""Structured result for API/Web consumers while keeping Markdown compatibility."""
report: str
market_review_payload: Dict[str, Any] = field(default_factory=dict)
def _get_market_review_text(language: str) -> dict[str, str]:
normalized = normalize_report_language(language)
if normalized == "en":
@@ -83,7 +92,8 @@ def run_market_review(
merge_notification: bool = False,
override_region: Optional[str] = None,
query_id: Optional[str] = None,
) -> Optional[str]:
return_structured: bool = False,
) -> Optional[str] | Optional[MarketReviewRunResult]:
"""
执行大盘复盘分析
@@ -115,6 +125,7 @@ def run_market_review(
# 多市场顺序执行,合并报告
parts = []
market_light_snapshots: Dict[str, Dict[str, Any]] = {}
market_review_payloads: Dict[str, Dict[str, Any]] = {}
for mkt, title_key, label in _MARKET_REVIEW_MARKETS:
if mkt not in run_markets:
continue
@@ -125,6 +136,11 @@ def run_market_review(
review_result = mkt_analyzer.run_daily_review_with_snapshot()
mkt_report = review_result.report
market_light_snapshots[mkt] = review_result.market_light_snapshot
market_review_payloads[mkt] = _coerce_market_review_payload(
review_result,
region=mkt,
report=mkt_report,
)
if mkt_report:
parts.append(f"{review_text[title_key]}\n\n{mkt_report}")
if parts:
@@ -141,24 +157,43 @@ def run_market_review(
review_result = market_analyzer.run_daily_review_with_snapshot()
review_report = review_result.report
market_light_snapshots = {run_region: review_result.market_light_snapshot}
market_review_payloads = {
run_region: _coerce_market_review_payload(
review_result,
region=run_region,
report=review_report,
)
}
if review_report:
market_review_payload = _build_combined_market_review_payload(
review_report=review_report,
payloads=market_review_payloads,
region=persist_region,
language=getattr(config, "report_language", "zh"),
root_title=review_text["root_title"],
)
markdown_report = _render_market_review_payload_markdown(
market_review_payload,
wrapper_title=review_text["root_title"],
)
# 保存报告到文件
date_str = datetime.now().strftime('%Y%m%d')
report_filename = f"market_review_{date_str}.md"
filepath = notifier.save_report_to_file(
f"{review_text['root_title']}\n\n{review_report}",
markdown_report,
report_filename
)
logger.info(f"大盘复盘报告已保存: {filepath}")
_persist_market_review_history(
review_report=review_report,
markdown_report=f"{review_text['root_title']}\n\n{review_report}",
markdown_report=markdown_report,
region=persist_region,
config=config,
query_id=query_id,
market_light_snapshots=market_light_snapshots,
market_review_payload=market_review_payload,
)
# 推送通知(合并模式下跳过,由 main 层统一发送)
@@ -166,7 +201,10 @@ def run_market_review(
logger.info("合并推送模式:跳过大盘复盘单独推送,将在个股+大盘复盘后统一发送")
elif send_notification and notifier.is_available():
# 添加标题
report_content = f"{review_text['push_title']}\n\n{review_report}"
report_content = _render_market_review_payload_markdown(
market_review_payload,
wrapper_title=review_text["push_title"],
)
success = notifier.send(report_content, email_send_to_all=True, route_type="report")
if success:
@@ -176,6 +214,11 @@ def run_market_review(
elif not send_notification:
logger.info("已跳过推送通知 (--no-notify)")
if return_structured:
return MarketReviewRunResult(
report=review_report,
market_review_payload=market_review_payload,
)
return review_report
except Exception as e:
@@ -184,6 +227,118 @@ def run_market_review(
return None
def _coerce_market_review_payload(
review_result: Any,
*,
region: str,
report: Optional[str],
) -> Dict[str, Any]:
payload = getattr(review_result, "structured_payload", None)
if isinstance(payload, dict) and payload:
return payload
return {
"version": 1,
"kind": MARKET_REVIEW_REPORT_TYPE,
"region": region,
"title": "",
"sections": [{"key": "full_review", "title": "Review", "markdown": report or ""}],
"markdown_report": report or "",
}
def _build_combined_market_review_payload(
*,
review_report: str,
payloads: Dict[str, Dict[str, Any]],
region: str,
language: str,
root_title: str,
) -> Dict[str, Any]:
normalized_language = normalize_report_language(language)
title = root_title.lstrip("#").strip()
if len(payloads) == 1:
payload = dict(next(iter(payloads.values())))
payload["version"] = payload.get("version") or 1
payload["kind"] = MARKET_REVIEW_REPORT_TYPE
payload["region"] = region
payload["language"] = payload.get("language") or normalized_language
payload["root_title"] = title
payload["markdown_report"] = review_report
return payload
return {
"version": 1,
"kind": MARKET_REVIEW_REPORT_TYPE,
"region": region,
"language": normalized_language,
"title": title,
"root_title": title,
"markets": payloads,
"markdown_report": review_report,
}
def _render_market_review_payload_markdown(
payload: Dict[str, Any],
*,
wrapper_title: Optional[str] = None,
) -> str:
"""Render Markdown from the structured market-review payload for file/push compatibility."""
body = _render_market_review_payload_body(payload)
if wrapper_title:
return f"{wrapper_title}\n\n{body}".strip()
return body.strip()
def _render_market_review_payload_body(payload: Dict[str, Any]) -> str:
markets = payload.get("markets")
if isinstance(markets, dict) and markets:
markdown_report = payload.get("markdown_report")
if isinstance(markdown_report, str) and markdown_report.strip():
return markdown_report.strip()
parts = []
for market in _MARKET_REVIEW_REGION_ORDER:
market_payload = markets.get(market)
if isinstance(market_payload, dict):
parts.append(_render_single_market_review_payload(market_payload))
return "\n\n---\n\n".join(part for part in parts if part).strip()
return _render_single_market_review_payload(payload)
def _render_single_market_review_payload(payload: Dict[str, Any]) -> str:
sections = payload.get("sections")
if not isinstance(sections, list) or not sections:
markdown = payload.get("markdown_report")
return markdown if isinstance(markdown, str) else ""
title = payload.get("title")
normalized_title = _normalize_market_review_heading(title)
lines = []
if isinstance(title, str) and title.strip():
lines.extend([f"## {title.strip()}", ""])
for section in sections:
if not isinstance(section, dict):
continue
section_title = str(section.get("title") or "").strip()
markdown = str(section.get("markdown") or "").strip()
if not markdown:
continue
should_render_section_title = (
section_title
and section.get("key") != "overview"
and _normalize_market_review_heading(section_title) != normalized_title
)
if should_render_section_title:
lines.extend([f"### {section_title}", ""])
lines.extend([markdown, ""])
return "\n".join(lines).strip()
def _normalize_market_review_heading(value: Any) -> str:
if not isinstance(value, str):
return ""
return " ".join(value.lstrip("#").strip().lower().split())
def _persist_market_review_history(
*,
review_report: str,
@@ -192,6 +347,7 @@ def _persist_market_review_history(
config: object,
query_id: Optional[str] = None,
market_light_snapshots: Optional[Dict[str, Dict[str, Any]]] = None,
market_review_payload: Optional[Dict[str, Any]] = None,
) -> int:
"""Persist market review output into the existing analysis history table."""
try:
@@ -229,6 +385,8 @@ def _persist_market_review_history(
}
if market_light_snapshots:
context_snapshot["market_light_snapshots"] = market_light_snapshots
if market_review_payload:
context_snapshot["market_review_payload"] = market_review_payload
saved = DatabaseManager.get_instance().save_analysis_history(
result=result,
+168 -25
View File
@@ -11,6 +11,7 @@
"""
import logging
import re
import time
from dataclasses import dataclass, field
from datetime import datetime
@@ -101,6 +102,7 @@ class MarketLightReviewResult:
overview: MarketOverview
report: str
market_light_snapshot: Dict[str, Any]
structured_payload: Dict[str, Any] = field(default_factory=dict)
class MarketAnalyzer:
@@ -138,12 +140,9 @@ class MarketAnalyzer:
self.strategy = get_market_strategy_blueprint(self.region)
def _get_review_language(self) -> str:
configured = normalize_report_language(
return normalize_report_language(
getattr(getattr(self, "config", None), "report_language", "zh")
)
if self.region == "us":
return "en"
return configured
def _get_template_review_language(self) -> str:
return normalize_report_language(
@@ -153,7 +152,7 @@ class MarketAnalyzer:
def _get_market_scope_name(self, review_language: str | None = None) -> str:
review_language = review_language or self._get_review_language()
if self.region == "us":
return "US market"
return "US market" if review_language == "en" else "美股市场"
if self.region == "hk":
return "Hong Kong market" if review_language == "en" else "港股市场"
if review_language == "en":
@@ -230,6 +229,24 @@ Focus on HSI trend, southbound flow dynamics, and sector rotation to define next
- Risk-on: broad index breakout with expanding southbound participation.
- Neutral: mixed index signals; focus on selective relative strength.
- Risk-off: failed breakouts and rising volatility; prioritize capital preservation."""
if self.region == "us" and self._get_review_language() == "zh":
return """## 美股市场三段式复盘策略
聚焦指数趋势、宏观叙事与板块轮动,给出次日风控与仓位框架。
### 策略原则
- 先看标普500、纳斯达克、道琼斯是否同向,确认主线是否一致。
- 结合宏观与流动性指标,识别风险偏好是修复还是转弱。
- 将复盘输出映射为“进攻/均衡/防守”动作建议,并给出明确触发失效条件。
### 分析维度
- 趋势结构:明确市场处于上冲、震荡还是防守转向,判断是否存在关键支撑位背离。
- 资金与情绪:区分宏观政策、货币面与波动率对权益风险的影响。
- 主题线索:识别持续性最强的主题与板块轮动是否形成可交易主线。
### 行动框架
- 进攻:主板块联动上行且量能/风险位同步改善。
- 均衡:指数分化或量能未明显放大,仓位保守执行。
- 防守:突破失守且波动率抬升时,优先减码并保留反弹可交易性。"""
if not (self.region == "cn" and self._get_review_language() == "en"):
return self.strategy.to_prompt_block()
return """## Strategy Blueprint: A-share Three-Phase Recap Strategy
@@ -266,6 +283,12 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
- **Trend Regime**: Classify the market as momentum, range, or risk-off based on HSI/HSTECH/HSCEI alignment.
- **Capital Flows**: Track southbound flow direction and macro narrative for risk appetite signals.
- **Sector Themes**: Focus on tech/internet platform persistence and financials/property policy sensitivity.
"""
if self.region == "us" and review_language == "zh":
return """### 六、策略框架
- **趋势结构**:判断市场在进攻、震荡与防守中的状态是否一致。
- **资金与情绪**:结合波动率、宽度和主题轮动评估风险偏好。
- **主题主线**:识别可延续和可放大的行业主线与防守线索。
"""
if not (self.region == "cn" and review_language == "en"):
return self.strategy.to_markdown_block()
@@ -435,12 +458,17 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
# 按 region 使用不同的新闻搜索词
search_queries = self.profile.news_queries
review_language = self._get_review_language()
market_names = {
"cn": "大盘" if review_language == "zh" else "A-share market",
"us": "美股市场" if review_language == "zh" else "US market",
"hk": "港股市场" if review_language == "zh" else "HK market",
}
try:
logger.info("[大盘] 开始搜索市场新闻...")
# 根据 region 设置搜索上下文名称,避免美股搜索被解读为 A 股语境
market_names = {"cn": "大盘", "us": "US market", "hk": "HK market"}
market_name = market_names.get(self.region, "大盘")
for query in search_queries:
response = self.search_service.search_stock_news(
@@ -474,21 +502,141 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
if not self.analyzer or not self.analyzer.is_available():
logger.warning("[大盘] AI分析器未配置或不可用,使用模板生成报告")
return self._generate_template_review(overview, news)
# 构建 Prompt
prompt = self._build_review_prompt(overview, news)
logger.info("[大盘] 调用大模型生成复盘报告...")
# Use the public generate_text() entry point — never access private analyzer attributes.
# Use the public generate_text() entry point - never access private analyzer attributes.
review = self.analyzer.generate_text(prompt, max_tokens=8192, temperature=0.7)
if review:
logger.info("[大盘] 复盘报告生成成功,长度: %d 字符", len(review))
# Inject structured data tables into LLM prose sections
return self._inject_data_into_review(review, overview, news)
else:
logger.warning("[大盘] 大模型返回为空,使用模板报告")
return self._generate_template_review(overview, news)
logger.warning("[大盘] 大模型返回为空,使用模板报告")
return self._generate_template_review(overview, news)
def build_market_review_payload(
self,
overview: MarketOverview,
news: List,
report: str,
market_light_snapshot: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build the structured market-review contract consumed by API, Web, and notifications."""
language = self._get_review_language()
sections = self._split_report_sections(report)
title = self._extract_report_title(report) or self._get_review_title(overview.date).lstrip("# ").strip()
light = market_light_snapshot or self.build_market_light_snapshot(overview)
breadth_dimensions = None
if isinstance(light, dict):
dimensions = light.get("dimensions")
if isinstance(dimensions, dict):
breadth_dimensions = dimensions.get("breadth")
breadth_supported = bool(self.profile.has_market_stats)
if breadth_supported and isinstance(breadth_dimensions, dict) and "available" in breadth_dimensions:
breadth_supported = bool(breadth_dimensions.get("available"))
has_breadth_data = False
if breadth_supported:
if isinstance(breadth_dimensions, dict) and "available" in breadth_dimensions:
has_breadth_data = bool(breadth_dimensions.get("available"))
else:
breadth_available = overview.up_count + overview.down_count + overview.flat_count > 0
limit_available = overview.limit_up_count + overview.limit_down_count > 0
has_breadth_data = bool(breadth_available or limit_available)
payload = {
"version": 1,
"kind": "market_review",
"region": self.region,
"language": language,
"title": title,
"generated_at": datetime.now().isoformat(),
"date": overview.date,
"market_scope": self._get_market_scope_name(language),
"market_light": light,
"indices": [idx.to_dict() for idx in overview.indices],
"sectors": {
"top": list(overview.top_sectors or []),
"bottom": list(overview.bottom_sectors or []),
},
"news": [self._normalize_news_item(item) for item in (news or [])[:8]],
"sections": sections,
"markdown_report": report,
}
if has_breadth_data:
payload["breadth"] = {
"up_count": overview.up_count,
"down_count": overview.down_count,
"flat_count": overview.flat_count,
"limit_up_count": overview.limit_up_count,
"limit_down_count": overview.limit_down_count,
"total_amount": overview.total_amount,
"turnover_unit": self._get_turnover_unit_label(),
}
return payload
@staticmethod
def _extract_report_title(report: str) -> str:
for line in (report or "").splitlines():
stripped = line.strip()
if stripped.startswith("#"):
return stripped.lstrip("#").strip()
return ""
@classmethod
def _split_report_sections(cls, report: str) -> List[Dict[str, str]]:
text = (report or "").strip()
if not text:
return []
matches = list(re.finditer(r"^(#{2,3})\s+(.+?)\s*$", text, flags=re.MULTILINE))
if not matches:
return [{"key": "full_review", "title": "Review", "markdown": text}]
sections: List[Dict[str, str]] = []
first_match = matches[0]
starts_with_report_title = first_match.start() == 0 and first_match.group(1) == "##"
content_start_index = 1 if starts_with_report_title else 0
intro_start = first_match.end() if starts_with_report_title else 0
intro_end = (
matches[1].start()
if starts_with_report_title and len(matches) > 1
else (len(text) if starts_with_report_title else matches[0].start())
)
intro = text[intro_start:intro_end].strip()
if intro:
sections.append({"key": "overview", "title": "Overview", "markdown": intro})
for index, match in enumerate(matches[content_start_index:], start=content_start_index):
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
title = match.group(2).strip()
markdown = text[start:end].strip()
if not markdown:
continue
key = re.sub(r"[^0-9a-zA-Z\u4e00-\u9fff]+", "_", title).strip("_").lower()
sections.append({
"key": key or f"section_{index + 1}",
"title": title,
"markdown": markdown,
})
return sections
@classmethod
def _normalize_news_item(cls, item: Any) -> Dict[str, str]:
return {
"title": cls._compact_news_text(cls._get_news_field(item, "title"), limit=120),
"snippet": cls._compact_news_text(cls._get_news_field(item, "snippet"), limit=260),
"source": cls._compact_news_text(cls._get_news_field(item, "source"), limit=80),
"published_date": cls._compact_news_text(cls._get_news_field(item, "published_date"), limit=40),
"url": cls._compact_news_text(cls._get_news_field(item, "url"), limit=240),
}
def _inject_data_into_review(
self,
@@ -501,7 +649,6 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
stats_block = self._build_stats_block(overview)
indices_block = self._build_indices_block(overview)
sector_block = self._build_sector_block(overview)
news_block = self._build_news_block(news or [])
patterns = (
_ENGLISH_SECTION_PATTERNS
if self._get_review_language() == "en"
@@ -529,13 +676,6 @@ Focus on index trend, liquidity, and sector rotation to shape the next-session t
sector_block,
)
if news_block and "news_catalysts" in patterns:
review = self._insert_after_section(
review,
patterns["news_catalysts"],
news_block,
)
return review
@staticmethod
@@ -1221,11 +1361,7 @@ Market conditions can change quickly. The data above is for reference only and d
### 五、消息催化
- 暂无可用新闻时,应降低对题材持续性的确定性判断。
### 六、明日交易计划
- **结论**:均衡观察。
- **仓位**:控制在中性区间,等待指数与主线共振。
- **关注方向**:{top_text or "强于指数的主线板块"}。
- **回避方向**:{bottom_text or "连续走弱且缺少修复信号的方向"}。
{self._get_strategy_markdown_block(template_language)}
### 七、风险提示
- 市场有风险,投资需谨慎。以上数据仅供参考,不构成投资建议。
@@ -1247,6 +1383,12 @@ Market conditions can change quickly. The data above is for reference only and d
# 3. 生成复盘报告
report = self.generate_market_review(overview, news)
snapshot = self.build_market_light_snapshot(overview)
structured_payload = self.build_market_review_payload(
overview,
news,
report,
snapshot,
)
logger.info("========== 大盘复盘分析完成 ==========")
@@ -1254,6 +1396,7 @@ Market conditions can change quickly. The data above is for reference only and d
overview=overview,
report=report,
market_light_snapshot=snapshot,
structured_payload=structured_payload,
)
def run_daily_review(self) -> str:
+7 -1
View File
@@ -280,6 +280,7 @@ class AnalysisApiContractTestCase(unittest.TestCase):
search_service=runtime_search,
send_notification=False,
override_region="cn,us",
return_structured=True,
)
def test_market_review_runtime_initializes_analyzer_for_litellm_provider(self) -> None:
@@ -331,6 +332,7 @@ class AnalysisApiContractTestCase(unittest.TestCase):
search_service=runtime_search,
send_notification=False,
override_region="cn",
return_structured=True,
)
def test_get_analysis_status_returns_market_review_report_from_queue(self) -> None:
@@ -344,7 +346,10 @@ class AnalysisApiContractTestCase(unittest.TestCase):
stock_name="大盘复盘",
status=analysis_endpoint_module.TaskStatusEnum.COMPLETED,
progress=100,
result={"result": "市场复盘报告示例文本"},
result={
"result": "市场复盘报告示例文本",
"market_review_payload": {"kind": "market_review", "sections": []},
},
error=None,
original_query=None,
selection_source=None,
@@ -356,6 +361,7 @@ class AnalysisApiContractTestCase(unittest.TestCase):
self.assertEqual(status.status, "completed")
self.assertEqual(status.market_review_report, "市场复盘报告示例文本")
self.assertEqual(status.market_review_payload["kind"], "market_review")
self.assertIsNone(status.result)
def test_get_analysis_status_normalizes_completed_queue_result_contract(self) -> None:
+102 -2
View File
@@ -1022,6 +1022,8 @@ class TestMarketAnalyzerBypassFix:
assert "## 2026-03-05 大盘复盘" in result
assert "### 一、盘面总览" in result
assert "今日美股市场整体呈现**小幅下跌**态势" in result
assert "### 6. Strategy Framework" not in result
assert "### 六、策略框架" in result
assert "### 1. Market Summary" not in result
assert "US Market Recap" not in result
@@ -1141,10 +1143,27 @@ Sector text.
assert "| 上证指数 | 3300.00 | 🟢 +0.36% | 3288.00 | 3312.00 | 3276.00 | 1.10% | 1450 |" in result
assert "#### 领涨板块 Top 5" in result
assert "| 1 | AI算力 | +3.25% |" in result
assert "#### 近三日市场线索" in result
assert "AI算力板块走强" in result
assert "#### 近三日市场线索" not in result
assert "AI算力板块走强" not in result
assert "新闻。" in result
assert "算力产业链延续活跃" not in result
def test_market_review_payload_sections_skip_top_report_title(self):
from src.market_analyzer import MarketAnalyzer
ma = MarketAnalyzer.__new__(MarketAnalyzer)
sections = ma._split_report_sections("""## 2026-06-03 大盘复盘
> 今日指数分化。
### 一、盘面总览
正文
""")
assert sections[0]["key"] == "overview"
assert "今日指数分化" in sections[0]["markdown"]
assert all(section["title"] != "2026-06-03 大盘复盘" for section in sections)
def test_news_block_renders_title_source_and_link_only(self):
from src.market_analyzer import MarketAnalyzer
@@ -1322,6 +1341,87 @@ Sector text.
assert snapshot["dimensions"]["index"]["available"] is True
assert snapshot["dimensions"]["limit"] == {"score": 50, "available": False}
def test_market_review_payload_omits_breadth_for_markets_without_stats(self):
from src.core.market_profile import US_PROFILE
from src.market_analyzer import MarketIndex, MarketOverview
ma = self._make_market_analyzer_with_mock_generate_text(return_value="复盘结果")
ma.region = "us"
ma.profile = US_PROFILE
payload = ma.build_market_review_payload(
MarketOverview(
date="2026-03-18",
indices=[
MarketIndex(code="SPX", name="S&P 500", current=5200.0, change_pct=0.6),
],
up_count=1000,
down_count=400,
limit_up_count=10,
limit_down_count=0,
total_amount=9800.0,
),
[],
"美股复盘报告",
market_light_snapshot={"dimensions": {"breadth": {"score": 60, "available": True}}},
)
assert "breadth" not in payload
assert payload["indices"][0]["code"] == "SPX"
def test_market_review_payload_omits_breadth_for_cn_market_without_available_stats(self):
from src.market_analyzer import MarketIndex, MarketOverview
ma = self._make_market_analyzer_with_mock_generate_text(return_value="复盘结果")
payload = ma.build_market_review_payload(
MarketOverview(
date="2026-03-18",
indices=[
MarketIndex(code="000001", name="上证指数", current=3200.0, change_pct=0.6),
],
up_count=0,
down_count=0,
flat_count=0,
limit_up_count=0,
limit_down_count=0,
total_amount=0.0,
),
[],
"A股复盘报告",
market_light_snapshot={"dimensions": {"breadth": {"score": 55, "available": False}}},
)
assert "breadth" not in payload
assert payload["indices"][0]["name"] == "上证指数"
def test_market_review_payload_includes_breadth_only_when_stats_available(self):
from src.market_analyzer import MarketIndex, MarketOverview
ma = self._make_market_analyzer_with_mock_generate_text(return_value="复盘结果")
payload = ma.build_market_review_payload(
MarketOverview(
date="2026-03-18",
indices=[
MarketIndex(code="000001", name="上证指数", current=3200.0, change_pct=0.6),
],
up_count=1200,
down_count=900,
flat_count=60,
limit_up_count=12,
limit_down_count=4,
total_amount=12345.0,
),
[],
"A股复盘报告",
market_light_snapshot={"dimensions": {"breadth": {"score": 62, "available": True}}},
)
assert payload["breadth"] is not None
assert payload["breadth"]["up_count"] == 1200
assert payload["breadth"]["down_count"] == 900
assert payload["breadth"]["limit_up_count"] == 12
assert payload["breadth"]["total_amount"] == 12345.0
def test_us_english_indices_do_not_label_turnover_as_cny(self):
from src.core.market_profile import US_PROFILE
from src.core.market_strategy import get_market_strategy_blueprint
+38 -3
View File
@@ -128,8 +128,8 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
market_review_module,
"MarketAnalyzer",
side_effect=[cn_analyzer, hk_analyzer, us_analyzer],
), patch.object(market_review_module, "_persist_market_review_history"):
result = run_market_review(notifier, send_notification=False)
), patch.object(market_review_module, "_persist_market_review_history") as persist_history:
result = run_market_review(notifier, send_notification=True)
self.assertIn("# A-share Market Recap\n\nCN body", result)
self.assertIn("# HK Market Recap\n\nHK body", result)
@@ -137,7 +137,17 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
self.assertIn("# US Market Recap\n\nUS body", result)
saved_content = notifier.save_report_to_file.call_args.args[0]
self.assertTrue(saved_content.startswith("# 🎯 Market Review\n\n"))
notifier.send.assert_not_called()
self.assertIn("# A-share Market Recap\n\nCN body", saved_content)
self.assertIn("> Next market recap follows", saved_content)
self.assertIn("# HK Market Recap\n\nHK body", saved_content)
self.assertIn("# US Market Recap\n\nUS body", saved_content)
self.assertIn(
"# A-share Market Recap\n\nCN body",
persist_history.call_args.kwargs["markdown_report"],
)
sent_content = notifier.send.call_args.args[0]
self.assertTrue(sent_content.startswith("🎯 Market Review\n\n"))
self.assertIn("# US Market Recap\n\nUS body", sent_content)
def test_run_market_review_comma_joined_subset_cn_us(self) -> None:
"""Regression: compute_effective_region("both", {"cn","us"}) -> "cn,us"
@@ -300,6 +310,24 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
snapshots = persist_history.call_args.kwargs["market_light_snapshots"]
self.assertEqual(set(snapshots), {"cn"})
def test_render_market_review_payload_markdown_does_not_repeat_title(self) -> None:
markdown = market_review_module._render_market_review_payload_markdown(
{
"title": "2026-06-03 大盘复盘",
"sections": [
{
"key": "daily_review",
"title": "2026-06-03 大盘复盘",
"markdown": "> 今日指数强弱分化。\n\n### 一、盘面总览\n正文",
}
],
},
wrapper_title="🎯 大盘复盘",
)
self.assertEqual(markdown.count("2026-06-03 大盘复盘"), 1)
self.assertTrue(markdown.startswith("🎯 大盘复盘\n\n## 2026-06-03 大盘复盘"))
def test_persist_market_review_history_saves_markdown_report(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
old_db_path = os.environ.get("DATABASE_PATH")
@@ -331,6 +359,12 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
"data_quality": "ok",
}
},
market_review_payload={
"version": 1,
"kind": "market_review",
"region": "cn",
"sections": [{"title": "今日大盘", "markdown": "复盘正文"}],
},
)
self.assertEqual(saved, 1)
@@ -346,6 +380,7 @@ class MarketReviewLocalizationTestCase(unittest.TestCase):
self.assertEqual(row.news_content, "## 今日大盘\n\n复盘正文")
self.assertIn("# 🎯 大盘复盘", row.raw_result)
self.assertIn('"market_light_snapshots"', row.context_snapshot)
self.assertIn('"market_review_payload"', row.context_snapshot)
self.assertIn('"trade_date": "2026-03-06"', row.context_snapshot)
finally:
DatabaseManager.reset_instance()
+14 -1
View File
@@ -40,12 +40,25 @@ class TestMarketAnalyzerStrategyPrompt(unittest.TestCase):
self.assertIn("A股市场三段式复盘策略", prompt)
def test_us_prompt_contains_strategy_plan_section(self):
analyzer = MarketAnalyzer(region="us")
with patch("src.market_analyzer.get_config", return_value=SimpleNamespace(report_language="en")):
analyzer = MarketAnalyzer(region="us")
prompt = analyzer._build_review_prompt(MarketOverview(date="2026-02-24"), [])
self.assertIn("Strategy Plan", prompt)
self.assertIn("US Market Regime Strategy", prompt)
def test_us_prompt_localizes_strategy_markdown_when_report_language_is_zh(self):
with patch("src.market_analyzer.get_config", return_value=SimpleNamespace(report_language="zh")):
analyzer = MarketAnalyzer(region="us")
prompt = analyzer._build_review_prompt(MarketOverview(date="2026-02-24"), [])
self.assertIn("美股市场", prompt)
self.assertNotIn("US Market Regime Strategy", prompt)
self.assertNotIn("Strategy Blueprint", prompt)
self.assertIn("风险偏好", prompt)
def test_cn_prompt_uses_english_shell_when_report_language_is_en(self):
with patch("src.market_analyzer.get_config", return_value=SimpleNamespace(report_language="en")):
analyzer = MarketAnalyzer(region="cn")