Files
daily_stock_analysis/tests/test_notification_empty_news_disclosure.py
T
青玉案andMach-Chan d0e66a1dc3 feat: 新闻检索为空时在报告中如实标注 (#2229)
* feat: 新闻检索为空时在报告中如实标注

消息面章节此前是「有内容才渲染」,检索一条没拿到时整段直接消失,
读报告的人无从判断是确实没新闻,还是检索静默失败了(搜索源限流、
未配置可用渠道等)。这把「抓取失败」呈现成了「确实没有新闻」。

- src/analyzer.py: AnalysisResult 新增 news_result_count,默认 None
- src/core/pipeline.py: 把 Step 4 已算好的计数交给结果对象
  (此前只进了 diagnostic context snapshot,报告层拿不到)
- src/notification.py: news_lines 为空且计数为 0 时,渲染明确提示,
  并说明结论未纳入新闻维度证据
- tests: 新增 5 条用例,含两条负例——计数为 None 时不得报警
  (那是未配置搜索渠道,不是失败)、拿到新闻时行为与改动前一致

不触碰任何检索路径,纯展示层增量。

* fix: 把新闻缺失提示放进真实渲染路径,并独立于模型输出判定

按 review 三条意见修正:

P1-1 提示只存在于 generate_daily_report,而正常流程从不调用它——
_send_single_stock_notification 与聚合报告走的是 dashboard / brief /
single_stock。原实现对所有标准 REPORT_TYPE 都不生效。
改为抽出共享判定 _empty_news_disclosure,四个渲染器统一接入。

P2 检索零命中但模型按 schema 写出了 market_sentiment / hot_topics 时,
原 elif 分支被跳过,报告会展示模型生成的情绪判断却隐瞒无新闻证据。
改为独立判定 news_result_count == 0,与模型是否产出文字无关。

P1-2 补 docs/CHANGELOG.md [Unreleased] 条目,并在
docs/data-source-stability.md 的「用户可见提示建议」一节记录该行为,
含 None / 0 / >0 三态语义表。

测试从 5 条增至 10 条,新增覆盖 dashboard、brief、single_stock 三个真实
渲染器,以及「模型有输出但检索为空」这一最糟组合。39 passed

* fix: 把新闻零命中披露覆盖到模板链路与企业微信入口

按 review 指出的 blocker 修正。此前只接了字符串拼接分支,遗漏两类活路径:

1. REPORT_RENDERER_ENABLED=true 时,generate_dashboard_report /
   generate_brief_report / generate_wechat_dashboard 会先 return render(...),
   模板链路一路不渲染披露;
2. generate_wechat_dashboard 的非模板 fallback 从未接入,而 pipeline 在
   企业微信非 brief 场景会直接调用它。

后果是同一份分析结果在部分渠道披露、在另一些渠道沉默。

改法不再逐点打补丁,而是抽出单一事实来源:

- 新增 src/services/empty_news.py 持有判定与中英文案
- src/notification.py 的 _empty_news_disclosure 改为委托该模块
- src/services/report_renderer.py 为每条结果预计算 empty_news_disclosure,
  三个平台模板共用
- templates/report_markdown.j2 / report_brief.j2 / report_wechat.j2 各加渲染分支
- generate_wechat_dashboard 的 fallback 正文接入披露

新增 6 条回归测试:模板链路三个平台各一条、企业微信入口一条,
外加两条负例(未执行检索时模板与企业微信均不得提示)。

本文件测试 10 → 16 全过;全量 5824 passed,9 个既有失败与本 PR 无关
(干净 main 上同样失败,属测试顺序依赖)。

* fix: 修正计数源头的两处缺口(自查发现)

按 review 的 merge-base..HEAD 方法自查全链路,发现此前几轮都只盯着渲染出口,
从未核对计数源头,而源头本身在两条路径上是错的:

1. src/core/pipeline.py: news_result_count 只在 intel_results 非空时赋值,
   搜索服务整体失败(正是所有搜索源限流全挂的场景)时停留在 None,
   语义为「未执行检索」,于是本 PR 想解决的头号场景反而不提示。
   改为检索一发起即置 0。

2. _analyze_with_agent: Agent 模式自行调用 search_stock_news 完成检索,
   却从不回写计数,该路径下零命中永远静默。改为按检索结果回写 0 或实际条数。

渲染层再周全,源头数据不对则全部落空。

新增 2 条测试锁住这两处语义(18 passed,此前 16)。
全量 5826 passed,9 个既有失败与本 PR 无关。

* fix: disclose missing news search configuration

* chore: remove unrelated agent guidance

* test: run all empty news tests directly

* fix: preserve empty news disclosure across reports

* fix: 让 Agent 模式的新闻披露跟随实际消费的证据

原问题:agent_arch=multi 等受支持的 Agent 配置下,报告可能声称「未纳入新闻
面证据」而分析其实用了新闻,或反过来该提示而不提示。

根因:news_result_count 取自 executor.run() 结束后为持久化情报补打的一次
search_stock_news()。真实情报由 IntelAgent 通过 search_comprehensive_intel
取得,两者不等价,因此披露与真实证据链可能相反。

修复点:新增 src/agent/news_evidence.py,以运行期证据作用域收集 Agent 搜索
工具的真实返回条数;搜索渠道不可用为 None(未执行检索),可用则从 0 起步、
拿到多少算多少。pipeline 在 executor.run() 前后开启并读取该作用域,事后的
持久化补查不再回写计数。

回归风险:工具在 ThreadPoolExecutor 中执行,runner.py 以
contextvars.copy_context() 提交,故作用域中必须是可变累加器对象,换成不可变
值会让父线程读不到;已加回归测试锁住该机制。原 test_agent_path_records_count
断言的正是被修复的错误行为,已替换为反向断言。

Refs #2225

* fix: 让新闻披露以实际证据为准而非搜索命中数

原问题:本地已落库的资讯池或社交情绪进入 news_context 参与分析后,报告仍可能
声称「未配置搜索渠道,本次分析未纳入新闻面证据」或「零命中」。

根因:news_context 由三路来源拼成——实时检索、社交情绪(美股)、本地资讯池,
但只有实时检索会更新 news_result_count。披露断言的是「结论有没有用到新闻面
证据」,而计数只是「搜索命中了几条」,两者是不同命题,后两路参与时必然失真。

修复点:AnalysisResult 新增 news_evidence_present,由 news_context 是否非空
得出,pipeline 两条路径共用 src/services/empty_news.news_evidence_present()
这一个判定函数。披露改为先看有无证据;确无证据时才用计数解释原因
(None=未配置渠道,0=检索零命中)。历史重建同步恢复该字段。

回归风险:旧记录没有该字段,按计数回退推断,与该记录当时的报告表现一致,不会
追溯改变旧报告;已有用例锁住。review 只点名了本地资讯池,社交情绪属同一缺陷类,
本次一并修复并加测试。另加源码断言:任一 pipeline 路径改回只传计数即失败。

Refs #2225

* fix: 按来源登记新闻证据,不让零命中占位文本冒充证据

原问题:普通分析链路在「搜索已执行但一条证据都没拿到」时,报告不再显示零命中
披露——正是本 PR 要修的核心场景,反而比改动前更差。

根因:src/search_service.py 的 format_intel_report() 即使所有维度失败或为空,
也会输出「【XX 情报搜索结果】」标题和每个维度的「未找到相关信息」占位文本,
整段永远非空。上一版把拼好的 news_context 整段交给 news_evidence_present()
判定,于是 news_result_count == 0 时 evidence 被翻成 true,披露被吞掉,错误
状态还会经 to_dict() 持久化,继续影响历史、详情 API 与 Web。

修复点:判定改为按来源逐个登记——实时检索的真实命中数、社交情绪内容、本地
资讯池内容,任一为真才算有证据;两条 pipeline 路径都不再传拼好的整段。
news_evidence_present() 的契约随之改为接收各来源,并在文档串里写明为什么不能
传整段。

回归风险:新增反例用真实的 format_intel_report() 产出占位文本(不用 mock),
断言其不得被判成证据、且报告必须出现零命中披露。另有源码断言:谁把整段
news_context 交回判定函数即失败。上一版两条测试实际在保护该缺陷(一条名为
「任何非空 context 都算证据」,一条要求必须传入 news_context),已一并纠正。

Refs #2225

---------

Co-authored-by: Mach-Chan <zz-b240@zz-b240deMacBook-Air.local>
2026-08-22 21:27:04 +08:00

788 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""新闻面为空时必须在报告里如实标注。
背景:消息面章节原先是「有内容才渲染」,检索一条没拿到时整段直接消失,
读报告的人无从判断是确实没新闻,还是检索静默失败了(搜索源限流、
未配置可用渠道等)。这会把「抓取失败」呈现成「确实没有新闻」,
比单纯的慢更容易误导结论。
这些用例锁住三个状态:
1. 未执行检索(count is None)时,说明未配置渠道且未纳入新闻面证据;
2. 检索执行了但为空(count == 0)时,保留原有零命中提示;
3. 正常拿到新闻(count > 0)时,不出现缺失提示。
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from src.notification import NotificationService
ZERO_HIT_DISCLOSURE = "⚠️ 本次未获取到可用的新闻面数据,以下结论未纳入新闻维度证据。"
NO_CHANNEL_DISCLOSURE = "⚠️ 未配置搜索渠道,本次分析未纳入新闻面证据。"
EN_ZERO_HIT_DISCLOSURE = (
"⚠️ No news data could be retrieved for this run; "
"the conclusions below do not incorporate news-based evidence."
)
EN_NO_CHANNEL_DISCLOSURE = (
"⚠️ No news search channel is configured; "
"this analysis does not incorporate news-based evidence."
)
KO_ZERO_HIT_DISCLOSURE = (
"⚠️ 이번 분석에서 사용 가능한 뉴스 데이터를 가져오지 못해 "
"아래 결론에는 뉴스 근거를 반영하지 않았습니다."
)
KO_NO_CHANNEL_DISCLOSURE = (
"⚠️ 뉴스 검색 채널이 설정되지 않아 이번 분석에는 "
"뉴스 근거를 반영하지 않았습니다."
)
def _make_result(
*,
news_summary="",
news_result_count=None,
report_language="zh",
news_evidence_present=False,
):
"""构造一个最小可渲染的分析结果。
只填渲染日报必需的字段,避免与被测行为无关的细节耦合。
"""
from src.analyzer import AnalysisResult
return AnalysisResult(
code="600519",
name="测试标的",
sentiment_score=50,
trend_prediction="震荡",
operation_advice="观望",
analysis_summary="用于测试的综合分析。",
report_language=report_language,
news_summary=news_summary,
news_result_count=news_result_count,
news_evidence_present=news_evidence_present,
success=True,
)
def _make_service():
"""造一个用于渲染的 NotificationService。
走真实 __init__ 以拿到全部渲染所需属性;本测试只读取返回的报告文本,
不调用任何推送方法,因此不会向外发送。
"""
return NotificationService()
class EmptyNewsDisclosureTestCase(unittest.TestCase):
def setUp(self):
self.service = _make_service()
def _render(self, result):
return NotificationService.generate_daily_report(
self.service, [result], report_date="2026-08-18"
)
def test_discloses_when_search_ran_but_returned_nothing(self):
"""检索执行了但零命中:报告必须说出来。"""
report = self._render(_make_result(news_result_count=0))
self.assertIn(ZERO_HIT_DISCLOSURE, report)
self.assertNotIn(NO_CHANNEL_DISCLOSURE, report)
self.assertIn("消息面", report)
def test_discloses_when_search_was_not_performed(self):
"""未配置搜索渠道时,报告必须说明新闻面证据没有纳入。"""
report = self._render(_make_result(news_result_count=None))
self.assertIn(NO_CHANNEL_DISCLOSURE, report)
self.assertNotIn(ZERO_HIT_DISCLOSURE, report)
def test_unchanged_when_news_is_available(self):
"""拿到新闻时行为与改动前一致:渲染正文,不出现提示。"""
report = self._render(
_make_result(news_summary="公司发布季度财报,营收同比增长。", news_result_count=3)
)
self.assertIn("公司发布季度财报", report)
self.assertNotIn(ZERO_HIT_DISCLOSURE, report)
self.assertNotIn(NO_CHANNEL_DISCLOSURE, report)
def test_disclosure_states_the_consequence_not_just_the_absence(self):
"""提示要说清后果,让读者知道结论该打几折,而不只是「没数据」。"""
report = self._render(_make_result(news_result_count=0))
self.assertIn("未纳入新闻维度证据", report)
class ResultFieldContractTestCase(unittest.TestCase):
def test_result_defaults_to_none_not_zero(self):
"""默认必须是 None,才能与执行后零命中使用不同披露文案。"""
result = _make_result()
self.assertIsNone(result.news_result_count)
class ActiveRenderersDiscloseTestCase(unittest.TestCase):
"""真实流程走的是 dashboard / brief / single_stock,不是 generate_daily_report。
只在 generate_daily_report 里加提示等于没加——标准 REPORT_TYPE 一个都覆盖不到。
这些用例锁住四个渲染器全部接入同一个共享判定。
"""
def setUp(self):
self.service = _make_service()
def test_dashboard_report_discloses_empty_news(self):
report = NotificationService.generate_dashboard_report(
self.service, [_make_result(news_result_count=0)], report_date="2026-08-18"
)
self.assertIn(ZERO_HIT_DISCLOSURE, report)
def test_brief_report_discloses_empty_news(self):
report = NotificationService.generate_brief_report(
self.service, [_make_result(news_result_count=0)], report_date="2026-08-18"
)
self.assertIn(ZERO_HIT_DISCLOSURE, report)
def test_single_stock_report_discloses_empty_news(self):
report = NotificationService.generate_single_stock_report(
self.service, _make_result(news_result_count=0)
)
self.assertIn(ZERO_HIT_DISCLOSURE, report)
def test_renderers_disclose_when_search_not_performed(self):
for name, call in (
("dashboard", lambda r: NotificationService.generate_dashboard_report(
self.service, [r], report_date="2026-08-18")),
("brief", lambda r: NotificationService.generate_brief_report(
self.service, [r], report_date="2026-08-18")),
("single", lambda r: NotificationService.generate_single_stock_report(
self.service, r)),
):
with self.subTest(renderer=name):
report = call(_make_result(news_result_count=None))
self.assertIn(NO_CHANNEL_DISCLOSURE, report)
self.assertNotIn(ZERO_HIT_DISCLOSURE, report)
class DisclosureIndependentOfModelTextTestCase(unittest.TestCase):
"""最糟的组合:检索零命中,但模型仍按 schema 写出了情绪判断。
此时若以「消息面文字是否为空」决定是否提示,报告会展示模型生成的情绪,
同时隐瞒没有新闻证据这一事实。判定必须独立于模型输出。
"""
def setUp(self):
self.service = _make_service()
def test_warns_even_when_model_supplied_sentiment(self):
result = _make_result(news_result_count=0)
result.market_sentiment = "市场情绪偏中性。"
result.hot_topics = "暂无明显热点。"
report = NotificationService.generate_daily_report(
self.service, [result], report_date="2026-08-18"
)
self.assertIn(ZERO_HIT_DISCLOSURE, report)
self.assertIn("市场情绪偏中性", report)
class TemplateRendererDiscloseTestCase(unittest.TestCase):
"""REPORT_RENDERER_ENABLED=true 时走模板链路,会在 render() 处提前返回。
此前只修了字符串拼接分支,模板链路一路沉默——同一份分析结果在部分渠道
披露、在另一些渠道不披露,跨渠道事实呈现不一致。
"""
def setUp(self):
self.service = _make_service()
def _render_with_templates(self, method, result, platform_hint=""):
from unittest.mock import patch
from src.config import get_config
cfg = get_config()
with patch.object(type(cfg), "report_renderer_enabled", True, create=True):
return method(self.service, [result], report_date="2026-08-18")
def test_markdown_template_discloses_empty_news(self):
from src.services.report_renderer import render
out = render(
platform="markdown",
results=[_make_result(news_result_count=0)],
report_date="2026-08-18",
summary_only=False,
extra_context={"report_language": "zh"},
)
self.assertTrue(out)
self.assertIn(ZERO_HIT_DISCLOSURE, out)
def test_brief_template_discloses_empty_news(self):
from src.services.report_renderer import render
out = render(
platform="brief",
results=[_make_result(news_result_count=0)],
report_date="2026-08-18",
summary_only=False,
extra_context={"report_language": "zh"},
)
self.assertTrue(out)
self.assertIn(ZERO_HIT_DISCLOSURE, out)
def test_wechat_template_discloses_empty_news(self):
from src.services.report_renderer import render
out = render(
platform="wechat",
results=[_make_result(news_result_count=0)],
report_date="2026-08-18",
summary_only=False,
extra_context={"report_language": "zh"},
)
self.assertTrue(out)
self.assertIn(ZERO_HIT_DISCLOSURE, out)
def test_templates_disclose_when_search_not_performed(self):
from src.services.report_renderer import render
for platform in ("markdown", "brief", "wechat"):
with self.subTest(platform=platform):
out = render(
platform=platform,
results=[_make_result(news_result_count=None)],
report_date="2026-08-18",
summary_only=False,
extra_context={"report_language": "zh"},
)
self.assertIn(NO_CHANNEL_DISCLOSURE, out or "")
self.assertNotIn(ZERO_HIT_DISCLOSURE, out or "")
class WechatDashboardDiscloseTestCase(unittest.TestCase):
"""generate_wechat_dashboard 是企业微信非 brief 场景的真实入口,
pipeline 会直接调用它,此前完全没有接入披露。"""
def setUp(self):
self.service = _make_service()
def test_wechat_dashboard_discloses_empty_news(self):
out = NotificationService.generate_wechat_dashboard(
self.service, [_make_result(news_result_count=0)]
)
self.assertIn(ZERO_HIT_DISCLOSURE, out)
def test_wechat_dashboard_discloses_when_search_not_performed(self):
out = NotificationService.generate_wechat_dashboard(
self.service, [_make_result(news_result_count=None)]
)
self.assertIn(NO_CHANNEL_DISCLOSURE, out)
self.assertNotIn(ZERO_HIT_DISCLOSURE, out)
class NoSearchProviderDisclosureTestCase(unittest.TestCase):
"""锁住 #2225 的 fresh-clone 场景:没有 key,公共实例默认关闭。"""
def test_no_registered_providers_discloses_missing_news_evidence(self):
from src.search_service import SearchService
search_service = SearchService(searxng_public_instances_enabled=False)
self.assertEqual([], search_service._providers)
self.assertFalse(search_service.is_available)
report = NotificationService.generate_daily_report(
_make_service(), [_make_result(news_result_count=None)], report_date="2026-08-18"
)
self.assertIn(NO_CHANNEL_DISCLOSURE, report)
self.assertNotIn(ZERO_HIT_DISCLOSURE, report)
class SupportedLanguageDisclosureTestCase(unittest.TestCase):
"""每种受支持报告语言都必须显式映射,不能把未知语言默认为中文。"""
EXPECTED = {
"zh": (NO_CHANNEL_DISCLOSURE, ZERO_HIT_DISCLOSURE),
"en": (EN_NO_CHANNEL_DISCLOSURE, EN_ZERO_HIT_DISCLOSURE),
"ko": (KO_NO_CHANNEL_DISCLOSURE, KO_ZERO_HIT_DISCLOSURE),
}
def setUp(self):
self.service = _make_service()
def _string_renderers(self, result):
return {
"daily": NotificationService.generate_daily_report(
self.service, [result], report_date="2026-08-18"
),
"dashboard": NotificationService.generate_dashboard_report(
self.service, [result], report_date="2026-08-18"
),
"brief": NotificationService.generate_brief_report(
self.service, [result], report_date="2026-08-18"
),
"single": NotificationService.generate_single_stock_report(
self.service, result
),
"wechat_dashboard": NotificationService.generate_wechat_dashboard(
self.service, [result]
),
"wechat_summary": NotificationService.generate_wechat_summary(
self.service, [result]
),
}
def test_every_supported_language_is_used_by_string_renderers(self):
from src.report_language import SUPPORTED_REPORT_LANGUAGES
self.assertEqual(set(SUPPORTED_REPORT_LANGUAGES), set(self.EXPECTED))
for language in SUPPORTED_REPORT_LANGUAGES:
for count, expected_index in ((None, 0), (0, 1)):
expected = self.EXPECTED[language][expected_index]
result = _make_result(
news_result_count=count,
report_language=language,
)
for renderer, report in self._string_renderers(result).items():
with self.subTest(
language=language,
count=count,
renderer=renderer,
):
self.assertIn(expected, report)
if language != "zh":
self.assertNotIn(NO_CHANNEL_DISCLOSURE, report)
self.assertNotIn(ZERO_HIT_DISCLOSURE, report)
def test_every_supported_language_is_used_by_templates(self):
from src.report_language import SUPPORTED_REPORT_LANGUAGES
from src.services.report_renderer import render
for language in SUPPORTED_REPORT_LANGUAGES:
result = _make_result(news_result_count=0, report_language=language)
for platform in ("markdown", "brief", "wechat"):
for summary_only in (False, True):
with self.subTest(
language=language,
platform=platform,
summary_only=summary_only,
):
out = render(
platform=platform,
results=[result],
report_date="2026-08-18",
summary_only=summary_only,
extra_context={"report_language": language},
)
self.assertIn(self.EXPECTED[language][1], out or "")
if language != "zh":
self.assertNotIn(ZERO_HIT_DISCLOSURE, out or "")
def test_summary_only_string_renderers_keep_disclosure(self):
self.service._report_summary_only = True
result = _make_result(news_result_count=0)
for renderer, report in (
(
"daily",
NotificationService.generate_daily_report(
self.service, [result], report_date="2026-08-18"
),
),
(
"dashboard",
NotificationService.generate_dashboard_report(
self.service, [result], report_date="2026-08-18"
),
),
(
"wechat",
NotificationService.generate_wechat_dashboard(self.service, [result]),
),
):
with self.subTest(renderer=renderer):
self.assertIn(ZERO_HIT_DISCLOSURE, report)
def test_unknown_language_fails_loudly(self):
from src.services.empty_news import empty_news_disclosure
with self.assertRaisesRegex(ValueError, "Unsupported report language"):
empty_news_disclosure(_make_result(news_result_count=0), "future-language")
class PipelineCountSemanticsTestCase(unittest.TestCase):
"""计数的三态语义必须在 pipeline 侧就正确产生,否则展示层再周全也无用。
两个曾经的缺口:
1. 搜索服务整体失败(intel_results 为空)时计数停留在 None,
于是「所有搜索源全线失败」这一最该提示的场景反而不提示;
2. Agent 模式(_analyze_with_agent)自行检索却从不记录计数,
该路径下零命中永远静默。
"""
def _read_pipeline_source(self):
from pathlib import Path
return (Path(__file__).resolve().parents[1] / "src" / "core" / "pipeline.py").read_text(
encoding="utf-8"
)
def test_count_set_to_zero_once_search_is_attempted(self):
"""检索一旦发起就置 0,不能等到拿到结果对象才赋值。"""
src = self._read_pipeline_source()
idx = src.index("开始多维度情报搜索")
window = src[idx : idx + 600]
self.assertIn("news_result_count = 0", window)
self.assertLess(
window.index("news_result_count = 0"),
window.index("search_comprehensive_intel"),
"计数必须在发起检索之前置 0,否则整体失败时会落回 None",
)
def test_post_hoc_persistence_query_does_not_write_the_count(self):
"""分析结束后的补查只为持久化情报,绝不能回写计数。
它发生在 executor.run() 之后,与 Agent 实际消费的证据无关;用它做披露
判定会两个方向都失真(review OR-COR-5f5d7a2e)。真正的计数由
src/agent/news_evidence.py 的证据作用域收集。
"""
src = self._read_pipeline_source()
idx = src.index("Agent 模式: 新闻情报已保存")
window = src[max(0, idx - 1200) : idx]
# 只看代码:解释这条约束的注释本身就含有该标识符。
code_only = "\n".join(
line for line in window.splitlines() if not line.strip().startswith("#")
)
self.assertNotIn("result.news_result_count", code_only)
class _StubSearchResult:
def __init__(self, index):
self.title = f"标题{index}"
self.snippet = f"摘要{index}"
self.url = f"https://example.invalid/{index}"
self.source = "stub"
self.published_date = "2026-08-20"
class _StubSearchResponse:
def __init__(self, count, *, success=True, query="stub-query"):
self.success = success
self.results = [_StubSearchResult(i) for i in range(count)]
self.query = query
self.provider = "stub"
self.error_message = None if success else "stub failure"
class _StubSearchService:
"""只实现搜索工具真正会用到的接口。
intel_counts 是 Agent 通过 search_comprehensive_intel 实际拿到的证据,
news_count 是 pipeline 事后为持久化而补打的 search_stock_news 的结果 ——
两者刻意不同,用来证明披露跟随的是前者。
"""
def __init__(self, *, intel_counts=None, news_count=0, news_success=True, available=True):
self._intel_counts = intel_counts or {}
self._news_count = news_count
self._news_success = news_success
self._available = available
@property
def is_available(self):
return self._available
def search_comprehensive_intel(self, stock_code, stock_name, max_searches=6):
return {
dimension: _StubSearchResponse(count)
for dimension, count in self._intel_counts.items()
}
def format_intel_report(self, intel_results, stock_name):
return "stub intel report"
def search_stock_news(self, stock_code, stock_name, max_results=5):
return _StubSearchResponse(self._news_count, success=self._news_success)
class AgentNewsEvidenceTestCase(unittest.TestCase):
"""Agent 模式的披露必须跟随 Agent 真正消费的新闻证据。
曾经的缺口(review OR-COR-5f5d7a2e):计数取自分析结束后补打的一次
search_stock_news()。Agent 明明通过 search_comprehensive_intel 用了新闻,
却可能因补查失败被标成「未纳入新闻面证据」;反过来 Agent 什么都没拿到,
也可能因补查有结果而错误地不提示。
"""
def setUp(self):
from src.agent import news_evidence
self.news_evidence = news_evidence
token = news_evidence.activate_news_evidence_scope()
self.accumulator = news_evidence.get_current_news_evidence()
self.addCleanup(news_evidence.reset_news_evidence_scope, token)
def _install_service(self, service):
from unittest.mock import patch
patcher = patch(
"src.agent.tools.search_tools._get_search_service", return_value=service
)
patcher.start()
self.addCleanup(patcher.stop)
db_patcher = patch("src.agent.tools.search_tools._get_db")
db_patcher.start()
self.addCleanup(db_patcher.stop)
def test_agent_evidence_survives_a_failing_post_hoc_query(self):
"""Agent 用了 6 条新闻,事后补查零命中 —— 不得谎称未纳入新闻证据。"""
from src.agent.tools.search_tools import _handle_search_comprehensive_intel
service = _StubSearchService(
intel_counts={"latest_news": 4, "risk_check": 2}, news_count=0
)
self._install_service(service)
_handle_search_comprehensive_intel("600519", "测试标的")
# pipeline 事后的持久化补查返回完全不同的结果,且不经过证据作用域
post_hoc = service.search_stock_news("600519", "测试标的", max_results=5)
self.assertEqual(0, len(post_hoc.results))
self.assertEqual(6, self.accumulator.resolve(search_available=True))
def test_agent_zero_hit_is_not_masked_by_a_successful_post_hoc_query(self):
"""反方向:Agent 一条没拿到,事后补查有结果 —— 提示不得被抑制。"""
from src.agent.tools.search_tools import _handle_search_comprehensive_intel
service = _StubSearchService(
intel_counts={"latest_news": 0}, news_count=5
)
self._install_service(service)
_handle_search_comprehensive_intel("600519", "测试标的")
post_hoc = service.search_stock_news("600519", "测试标的", max_results=5)
self.assertEqual(5, len(post_hoc.results))
self.assertEqual(0, self.accumulator.resolve(search_available=True))
def test_failed_agent_search_records_zero_rather_than_nothing(self):
"""检索发起但失败,是「搜过但没拿到」,不是「未配置渠道」。"""
from src.agent.tools.search_tools import _handle_search_stock_news
service = _StubSearchService(news_count=0, news_success=False)
self._install_service(service)
_handle_search_stock_news("600519", "测试标的")
self.assertEqual(0, self.accumulator.resolve(search_available=True))
def test_unavailable_channel_resolves_to_not_configured(self):
"""渠道不可用时工具直接返回错误、不记录,计数必须是 None。"""
from src.agent.tools.search_tools import _handle_search_stock_news
service = _StubSearchService(available=False)
self._install_service(service)
_handle_search_stock_news("600519", "测试标的")
self.assertIsNone(self.accumulator.resolve(search_available=False))
def test_available_channel_never_searched_reports_zero_hit_not_missing_channel(self):
"""渠道可用但 Agent 一次都没搜:仍是「没拿到新闻」,不能谎称未配置渠道。"""
self.assertEqual(0, self.accumulator.resolve(search_available=True))
def test_tool_threads_accumulate_into_the_parent_scope(self):
"""工具在线程池中执行,累加必须对 pipeline 可见。
src/agent/runner.py 用 contextvars.copy_context() + pool.submit(ctx.run, ...)
提交工具调用。ContextVar 里必须是可变累加器对象,换成不可变值父线程就读不到。
"""
import contextvars
from concurrent.futures import ThreadPoolExecutor
record = self.news_evidence.record_news_evidence
with ThreadPoolExecutor(max_workers=3) as pool:
futures = []
for count in (2, 0, 5):
ctx = contextvars.copy_context()
futures.append(pool.submit(ctx.run, record, count))
for future in futures:
future.result()
self.assertEqual(7, self.accumulator.resolve(search_available=True))
def test_recording_outside_a_scope_is_ignored(self):
"""报告页的后续资讯检索等场景不得影响本次分析的披露判定。"""
token = self.news_evidence.activate_news_evidence_scope()
outer = self.news_evidence.get_current_news_evidence()
self.news_evidence.reset_news_evidence_scope(token)
self.news_evidence.record_news_evidence(99)
self.assertEqual(0, outer.resolve(search_available=True))
class NewsEvidenceSourcesTestCase(unittest.TestCase):
"""披露断言的是「结论有没有用到新闻面证据」,不是「搜索命中了几条」。
`news_context` 由三路来源拼成,只有实时检索会产生计数:
1. 实时多维检索 —— 更新 news_result_count
2. 社交情绪(美股)—— 不更新计数
3. 本地已落库的资讯池 —— 不更新计数
只看计数就会把后两路参与的分析误报成「未纳入新闻面证据」
(review OR-COR-2e4b9d61 点名了第 3 路;第 2 路是同一缺陷类,一并锁住)。
"""
def _result(self, *, count, evidence):
return _make_result(news_result_count=count, news_evidence_present=evidence)
def test_local_intel_without_search_channel_is_not_reported_as_missing(self):
"""本地资讯池已进入分析输入,即使没配搜索渠道也不能说未纳入新闻证据。"""
report = NotificationService.generate_daily_report(
_make_service(),
[self._result(count=None, evidence=True)],
report_date="2026-08-20",
)
self.assertNotIn(NO_CHANNEL_DISCLOSURE, report)
self.assertNotIn(ZERO_HIT_DISCLOSURE, report)
def test_social_sentiment_without_search_hits_is_not_reported_as_missing(self):
"""社交情绪同样进入 news_context,零命中也不能否认已用证据。"""
report = NotificationService.generate_daily_report(
_make_service(),
[self._result(count=0, evidence=True)],
report_date="2026-08-20",
)
self.assertNotIn(ZERO_HIT_DISCLOSURE, report)
self.assertNotIn(NO_CHANNEL_DISCLOSURE, report)
def test_no_evidence_at_all_still_discloses_with_the_right_reason(self):
"""真的没有任何证据时,原有两种原因文案必须照旧。"""
no_channel = NotificationService.generate_daily_report(
_make_service(),
[self._result(count=None, evidence=False)],
report_date="2026-08-20",
)
zero_hit = NotificationService.generate_daily_report(
_make_service(),
[self._result(count=0, evidence=False)],
report_date="2026-08-20",
)
self.assertIn(NO_CHANNEL_DISCLOSURE, no_channel)
self.assertIn(ZERO_HIT_DISCLOSURE, zero_hit)
def test_evidence_helper_registers_sources_one_by_one(self):
from src.services.empty_news import news_evidence_present
# 真实命中数 / 社交情绪 / 本地资讯池,任一为真即算有证据
self.assertTrue(news_evidence_present(4, None, None))
self.assertTrue(news_evidence_present(0, "reddit 讨论……", None))
self.assertTrue(news_evidence_present(0, None, "## 本地资讯证据池"))
self.assertFalse(news_evidence_present(0, None, None))
self.assertFalse(news_evidence_present(0, "", " \n\t "))
self.assertFalse(news_evidence_present(None, None, None))
def test_zero_hit_placeholder_report_is_not_mistaken_for_evidence(self):
"""零命中时 format_intel_report 仍吐占位文本,绝不能被当成证据。
这是真实反例(review OR-COR-8f4c2d1b):`format_intel_report()` 即使所有
维度都失败,也会输出「【XX 情报搜索结果】」标题和每个维度的「未找到相关
信息」,整段永远非空。曾经的实现把整段 news_context 传进判定函数,于是
「搜了但一条没拿到」被翻成「有证据」,恰好吞掉本 PR 要补的那条披露。
这里用真实函数产出反例,不用 mock。
"""
from src.search_service import SearchService
from src.services.empty_news import news_evidence_present
class _FailedResponse:
success = False
results = []
provider = "stub"
service = SearchService.__new__(SearchService)
placeholder = SearchService.format_intel_report(
service,
{"latest_news": _FailedResponse(), "risk_check": _FailedResponse()},
"测试标的",
)
# 前提:这段占位文本确实非空,否则这条反例就失去意义
self.assertTrue(placeholder.strip())
self.assertIn("未找到相关信息", placeholder)
# 按来源登记:实时 0 条、无社交、无本地资讯池 —— 必须判定为没有证据
self.assertFalse(news_evidence_present(0, None, None))
# 端到端:这种情况报告必须出现零命中披露
report = NotificationService.generate_daily_report(
_make_service(),
[_make_result(news_result_count=0, news_evidence_present=False)],
report_date="2026-08-21",
)
self.assertIn(ZERO_HIT_DISCLOSURE, report)
def test_stored_record_round_trips_the_evidence_flag(self):
from src.services.empty_news import (
empty_news_disclosure_from_stored,
persisted_news_evidence_present,
)
stored = self._result(count=None, evidence=True).to_dict()
self.assertIn("news_evidence_present", stored)
self.assertTrue(persisted_news_evidence_present(stored, None))
self.assertIsNone(empty_news_disclosure_from_stored(stored, None, "zh"))
def test_legacy_record_without_the_flag_keeps_its_original_behaviour(self):
"""旧记录没有该字段时按计数推断,不追溯改变当时的报告表现。"""
from src.services.empty_news import persisted_news_evidence_present
self.assertTrue(persisted_news_evidence_present({"news_result_count": 4}, 4))
self.assertFalse(persisted_news_evidence_present({"news_result_count": 0}, 0))
def test_pipeline_registers_sources_and_never_passes_the_whole_context(self):
"""两条 pipeline 路径都必须按来源登记,且都不许传拼好的整段 news_context。
源码断言:谁把整段 news_context 交回判定函数,本用例就会失败——那正是
零命中占位文本冒充证据的入口。
"""
from pathlib import Path
src = (Path(__file__).resolve().parents[1] / "src" / "core" / "pipeline.py").read_text(
encoding="utf-8"
)
code_only = "\n".join(
line for line in src.splitlines() if not line.strip().startswith("#")
)
calls = code_only.count("result.news_evidence_present = news_evidence_present(")
self.assertEqual(2, calls, "普通路径与 Agent 路径各要有一次登记")
# 三路来源都要出现在登记参数里
self.assertIn("social_evidence_context", code_only)
self.assertIn("persisted_intelligence_context", code_only)
# 整段 news_context 不许再被交给判定函数
self.assertNotIn("news_evidence_present(\n news_context", code_only)
self.assertNotIn("news_evidence_present(news_context", code_only)
self.assertNotIn('news_evidence_present(\n initial_context.get("news_context")', code_only)
if __name__ == "__main__":
unittest.main()