Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [测试] 补充设置项帮助元数据、API schema、前端弹窗交互测试,并修复 Bot 名称路由与调度时间 provider 测试的离线 CI 稳定性问题。
- [修复] 港股日线跳过不支持港股的内置历史数据源,避免港股代码错配到非港股市场数据。
- [修复] 修正分析 API 对北交所 `BJ` 前缀与 `.BJ` 后缀股票代码的校验,保持前端自动补全与 Tushare `ts_code` 调用格式一致。
- [修复] 大盘复盘“近三日催化线索”改为明确展示摘要片段、来源日期和 URL,避免把搜索摘要截断内容误呈现为完整事件。

## [3.15.0] - 2026-05-05

Expand Down
71 changes: 50 additions & 21 deletions src/market_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,34 +728,61 @@ def _build_sector_block(self, overview: MarketOverview) -> str:
return "\n".join(lines)

def _build_news_block(self, news: List) -> str:
"""Build a compact news catalyst table for the rendered report."""
"""Build a source-aware news catalyst table for the rendered report."""
if not news:
return ""
if self._get_review_language() == "en":
lines = [
"#### News Catalysts",
"| # | Headline | Signal |",
"|---|----------|--------|",
"| # | Headline | Snippet / Lead | Source |",
"|---|----------|----------------|--------|",
]
else:
lines = [
"#### 近三日催化线索",
"| 序号 | 事件/标题 | 关注点 |",
"|------|-----------|--------|",
"| 序号 | 事件/标题 | 摘要/线索片段 | 来源 |",
"|------|-----------|----------------|------|",
]

for idx, item in enumerate(news[:5], 1):
if hasattr(item, "title"):
title = getattr(item, "title", "") or "-"
snippet = getattr(item, "snippet", "") or ""
else:
title = item.get("title", "-") or "-"
snippet = item.get("snippet", "") or ""
title = self._escape_table_cell(str(title).strip()[:42])
signal = self._escape_table_cell(str(snippet).strip().replace("\n", " ")[:58] or "-")
lines.append(f"| {idx} | {title} | {signal} |")
title = self._escape_table_cell(
self._compact_news_text(self._get_news_field(item, "title"), limit=80) or "-"
)
snippet = self._escape_table_cell(
self._compact_news_text(self._get_news_field(item, "snippet"), limit=180) or "-"
)
source = self._escape_table_cell(self._format_news_source_cell(item) or "-")
lines.append(f"| {idx} | {title} | {snippet} | {source} |")
return "\n".join(lines)

@staticmethod
def _get_news_field(item: Any, field: str) -> str:
if hasattr(item, field):
value = getattr(item, field, "") or ""
elif isinstance(item, dict):
value = item.get(field, "") or ""
else:
value = ""
return str(value).strip()

@classmethod
def _format_news_source_cell(cls, item: Any) -> str:
source = cls._compact_news_text(cls._get_news_field(item, "source"), limit=40)
date_text = cls._compact_news_text(cls._get_news_field(item, "published_date"), limit=24)
url = cls._compact_news_text(cls._get_news_field(item, "url"), limit=0)
label_parts = [part for part in (source, date_text) if part]
label = " / ".join(label_parts)
if url:
return f"[{label or 'URL'}]({url})"
return label

@staticmethod
def _compact_news_text(value: str, *, limit: int) -> str:
text = " ".join(str(value or "").split())
if limit <= 0 or len(text) <= limit:
return text
return text[: max(0, limit - 3)].rstrip() + "..."

@staticmethod
def _format_optional_number(value: float) -> str:
return "N/A" if value in (None, 0, 0.0) else f"{value:.2f}"
Expand Down Expand Up @@ -847,13 +874,15 @@ def _build_review_prompt(self, overview: MarketOverview, news: List) -> str:
news_text = ""
for i, n in enumerate(news[:6], 1):
# 兼容 SearchResult 对象和字典
if hasattr(n, 'title'):
title = n.title[:50] if n.title else ''
snippet = n.snippet[:100] if n.snippet else ''
else:
title = n.get('title', '')[:50]
snippet = n.get('snippet', '')[:100]
news_text += f"{i}. {title}\n {snippet}\n"
title = self._compact_news_text(self._get_news_field(n, "title"), limit=90)
snippet = self._compact_news_text(self._get_news_field(n, "snippet"), limit=220)
source = self._compact_news_text(self._get_news_field(n, "source"), limit=60)
published_date = self._compact_news_text(self._get_news_field(n, "published_date"), limit=30)
url = self._compact_news_text(self._get_news_field(n, "url"), limit=180)
meta_parts = [part for part in (source, published_date) if part]
meta = f" ({' / '.join(meta_parts)})" if meta_parts else ""
url_line = f"\n URL: {url}" if url else ""
news_text += f"{i}. {title}{meta}\n {snippet or '-'}{url_line}\n"

# 按 region 组装市场概况与板块区块(美股无涨跌家数、板块数据)
stats_block = ""
Expand Down
66 changes: 66 additions & 0 deletions tests/test_market_analyzer_generate_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,72 @@ def test_inject_data_into_review_matches_reference_style_chinese_headings(self):
assert "#### 近三日催化线索" in result
assert "AI算力板块走强" in result

def test_news_block_labels_snippets_and_preserves_source_url(self):
from src.market_analyzer import MarketAnalyzer

ma = MarketAnalyzer.__new__(MarketAnalyzer)
ma.config = SimpleNamespace(report_language="zh")
ma.region = "cn"
long_snippet = (
"复盘必读 2026-05-06 复盘的意义在于更清晰地把握市场脉搏,"
"综合描述 A 股三大指数今日集体反弹,成交额放大,科技成长方向领涨。"
)

result = ma._build_news_block([
{
"title": "A股收评:科创50指数放量反弹涨5.47% 两市成交额重回3万亿元",
"snippet": long_snippet,
"source": "东方财富",
"published_date": "2026-05-06",
"url": "https://example.com/news/1",
}
])

assert "摘要/线索片段" in result
assert "关注点" not in result
assert "成交额放大" in result
assert "[东方财富 / 2026-05-06](https://example.com/news/1)" in result

def test_news_block_uses_dash_when_source_metadata_missing(self):
from src.market_analyzer import MarketAnalyzer

ma = MarketAnalyzer.__new__(MarketAnalyzer)
ma.config = SimpleNamespace(report_language="zh")
ma.region = "cn"

result = ma._build_news_block([
{
"title": "政策利好带动板块活跃",
"snippet": "相关主题成交放大",
}
])

assert "| 1 | 政策利好带动板块活跃 | 相关主题成交放大 | - |" in result
assert "| 1 | 政策利好带动板块活跃 | 相关主题成交放大 | source |" not in result

def test_review_prompt_caps_news_url_context(self):
from src.market_analyzer import MarketOverview

ma = self._make_market_analyzer_with_mock_generate_text(return_value="review")
long_url = "https://example.com/redirect?" + "utm_campaign=" + ("x" * 420)

prompt = ma._build_review_prompt(
MarketOverview(date="2026-05-06"),
[
{
"title": "A股收评:指数放量反弹",
"snippet": "科技成长方向领涨",
"source": "测试来源",
"published_date": "2026-05-06",
"url": long_url,
}
],
)

assert long_url not in prompt
assert "URL: https://example.com/redirect?" in prompt
assert ("x" * 220) not in prompt

def test_market_light_snapshot_marks_defensive_market_red(self):
from src.market_analyzer import MarketIndex, MarketOverview

Expand Down
Loading