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>
This commit is contained in:
青玉案
2026-08-22 21:27:04 +08:00
committed by GitHub
parent f6b719d1fe
commit d0e66a1dc3
23 changed files with 1416 additions and 13 deletions

View File

@@ -85,6 +85,7 @@ from src.services.task_queue import (
)
from src.services.run_diagnostics import build_run_diagnostic_summary
from src.services.run_flow import build_task_run_flow_snapshot
from src.services.empty_news import empty_news_disclosure_from_stored
from src.utils.data_processing import (
normalize_model_used,
parse_json_field,
@@ -1173,6 +1174,11 @@ def get_analysis_status(task_id: str) -> TaskStatus:
context_snapshot,
raw_result,
)
news_disclosure = empty_news_disclosure_from_stored(
raw_result,
context_snapshot,
report_language,
)
has_board_details = (
bool(extracted_boards.get("belong_boards"))
or extracted_boards.get("sector_rankings") is not None
@@ -1185,9 +1191,11 @@ def get_analysis_status(task_id: str) -> TaskStatus:
or market_structure is not None
or context_snapshot is not None
or analysis_context_pack_overview is not None
or news_disclosure is not None
):
details = ReportDetails(
news_content=getattr(record, "news_content", None),
empty_news_disclosure=news_disclosure,
raw_result=raw_result,
context_snapshot=api_context_snapshot,
analysis_context_pack_overview=analysis_context_pack_overview,
@@ -1470,6 +1478,13 @@ def _build_analysis_report(
break
analysis_context_pack_overview = extract_analysis_context_pack_overview(context_snapshot)
api_context_snapshot = sanitize_context_snapshot_for_api(context_snapshot)
news_disclosure = empty_news_disclosure_from_stored(
raw_result_data,
context_snapshot,
report_language,
)
if news_disclosure is None and isinstance(details_data, dict):
news_disclosure = details_data.get("empty_news_disclosure")
details = None
has_board_details = (
bool(extracted_boards.get("belong_boards"))
@@ -1483,9 +1498,11 @@ def _build_analysis_report(
or market_structure is not None
or context_snapshot is not None
or analysis_context_pack_overview is not None
or news_disclosure is not None
):
details = ReportDetails(
news_content=details_data.get("news_summary") or details_data.get("news_content"),
empty_news_disclosure=news_disclosure,
raw_result=raw_result_data,
context_snapshot=api_context_snapshot,
analysis_context_pack_overview=analysis_context_pack_overview,

View File

@@ -637,6 +637,7 @@ def get_history_detail(
details = ReportDetails(
news_content=result.get("news_content"),
empty_news_disclosure=result.get("empty_news_disclosure"),
raw_result=result.get("raw_result"),
context_snapshot=api_context_snapshot,
analysis_context_pack_overview=analysis_context_pack_overview,

View File

@@ -136,7 +136,7 @@ class ReportMeta(BaseModel):
stock_code: str = Field(..., description="股票代码")
stock_name: Optional[str] = Field(None, description="股票名称")
report_type: Optional[str] = Field(None, description="报告类型")
report_language: Optional[str] = Field(None, description="报告输出语言zh/en")
report_language: Optional[str] = Field(None, description="报告输出语言zh/en/ko")
created_at: Optional[str] = Field(None, description="创建时间")
current_price: Optional[float] = Field(None, description="分析时股价")
change_pct: Optional[float] = Field(None, description="分析时涨跌幅(%)")
@@ -254,6 +254,10 @@ class ReportDetails(BaseModel):
"""报告详情区"""
news_content: Optional[str] = Field(None, description="新闻摘要")
empty_news_disclosure: Optional[str] = Field(
None,
description="新闻检索未执行或零命中时的用户可见披露",
)
raw_result: Optional[Any] = Field(None, description="原始分析结果JSON")
context_snapshot: Optional[Any] = Field(None, description="分析时上下文快照JSON")
analysis_context_pack_overview: Optional[AnalysisContextPackOverview] = Field(

View File

@@ -304,6 +304,14 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
<p className="mt-2 max-w-[62ch] whitespace-pre-wrap text-left text-[15px] leading-7 text-foreground">
{summary.analysisSummary || text.noAnalysisSummary}
</p>
{details?.emptyNewsDisclosure ? (
<p
role="note"
className="mt-3 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-left text-sm leading-6 text-foreground"
>
{details.emptyNewsDisclosure}
</p>
) : null}
</div>
</Card>

View File

@@ -288,6 +288,21 @@ describe('ReportOverview', () => {
expect(screen.queryByText('板块联动')).not.toBeInTheDocument();
});
it('renders the persisted empty-news disclosure beside the core conclusion', () => {
render(
<ReportOverview
meta={baseMeta}
summary={baseSummary}
details={{
emptyNewsDisclosure: '⚠️ 未配置搜索渠道,本次分析未纳入新闻面证据。',
}}
/>,
);
expect(screen.getByRole('note')).toHaveTextContent('未配置搜索渠道');
expect(screen.getByRole('note')).toHaveTextContent('未纳入新闻面证据');
});
it('fails open on malformed ranking payloads', () => {
render(
<ReportOverview

View File

@@ -356,6 +356,7 @@ export interface AnalysisContextPackOverview {
/** Details section */
export interface ReportDetails {
newsContent?: string;
emptyNewsDisclosure?: string;
rawResult?: Record<string, unknown>;
contextSnapshot?: Record<string, unknown> & { marketReviewPayload?: MarketReviewPayload };
analysisContextPackOverview?: AnalysisContextPackOverview | null;

View File

@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 单股推送模式在未配置通知渠道时仍会落盘本地个股报告CLI 启动分析若因空股票列表、个股结果全失败或本地报告保存失败而未生成报告,会显式返回失败并记录原因。
- [修复] 合并推送模式下即使个股汇总报告落盘失败,仍会先发送已有的合并通知;仅启用大盘复盘但最终未生成任何复盘内容时,分析任务会显式返回失败。
- [修复] SearXNG 公共实例发现的默认值由启用改为关闭:公共实例普遍存在限流、下线或不返回 JSON 的情况,默认开启会让未配置搜索 key 的用户每次分析多耗 30~60 秒且新闻面最终为空。运行时默认值、配置模板、中英文档与工作流诊断同步调整;显式设为 true 的用户行为不变。
- [改进] 新闻检索未执行或零命中时,报告中如实标注结论未纳入新闻面证据:零命中与「未配置搜索渠道」使用各自独立的文案,覆盖日报 / dashboard / brief / 个股 / 企业微信与模板渲染的详细与摘要分支、历史报告与分享导出、报告详情 API 与 Web 报告详情页,并按 `zh` / `en` / `ko` 分别本地化。此前该情况下消息面章节直接消失,读者无从区分「确实没有新闻」与「检索静默失败」。披露以本次分析实际收到的消息面证据为准,涵盖实时检索、社交情绪与本地已落库的资讯池三路来源;搜索命中数仅用于在确无证据时说明原因(未配置渠道 / 检索零命中避免把已用到本地或社交证据的分析误报成「未纳入新闻面证据」。Agent 模式的命中数取自 Agent 实际消费的搜索工具结果,而非分析结束后为持久化情报而补打的查询。
- [新功能] Agent 工具调用支持按类别data/search/analysis/action/market配置默认超时并允许单工具声明 `timeout_seconds`;有效超时按 first-wins 优先级解析(显式 per-run `tool_call_timeout_seconds` > 单工具显式 `timeout_seconds` > 类别默认 > 无限制),剩余 wall-clock 预算仅作不可突破的外层 cap超时后返回结构化 `{"timeout": true}` 错误(标记 `retriable: false` 并写入 `non_retriable_tool_results` 防重试重复执行)供 Agent 继续执行而非中断循环fixes #1890)。
- [修复] Agent 工具注册表(`src/agent/factory.get_tool_registry`)由模块级缓存改为按「类别超时映射的值」比对失效,规避 CPython 回收对象后地址复用(`id(config)` 相同)导致配置 reload 后的 `Config` 被误判为未变、沿用过期超时的真 bug新增 `_coerce_config_timeout` 类型白名单,使调用方传入 `MagicMock` / 缺属性 stub / 脏字符串(如 `float(MagicMock())` 静默得到 1.0)时降级为「无类别限制」而非崩溃或强加 1 秒超时;`build_agent_executor(config)` / `build_agent_chat_executor(config)` 现已把调用方 `config` 透传给 `get_tool_registry(config)`(不再无参调用冻结首构 registry`main._reload_runtime_config``SystemConfigService._reload_runtime_singletons`(及 `update()``reload_now` 路径)在配置热重载时调用 `reset_tool_registry()` 强制重建;回归测试补充「传入新 config 后 registry 重建」「reload 后新超时应生效」及「builder 透传 config」三类场景#1890 的 review follow-up闭环 OR-COM-dd1e8fa7 / OR-COM-bff42110

View File

@@ -178,6 +178,30 @@ LONGBRIDGE_ACCESS_TOKEN=your_access_token
| 多个源失败但有缓存 | 实时源不可用,本次使用上一次成功缓存;结论会降低置信度。 |
| 全部源失败且无缓存 | 当前数据不可用,请稍后重试,或配置 Tushare / TickFlow / Longbridge 等 token 型数据源。 |
### 新闻面证据缺失的报告标注(已实现)
报告会区分新闻检索**未执行**和**执行后零命中**,分别渲染对应提示:
> ⚠️ 未配置搜索渠道,本次分析未纳入新闻面证据。
> ⚠️ 本次未获取到可用的新闻面数据,以下结论未纳入新闻维度证据。
覆盖 dashboard、brief、个股与日报四个渲染路径以及历史 Markdown、分享图片和 Web
报告详情;中文、英文、韩文报告使用各自的披露文案。该提示的判定独立于模型输出:
即使 LLM 按 schema 写出了 `market_sentiment` / `hot_topics`,只要检索零命中就照常提示,
避免出现「展示模型生成的情绪判断、却隐瞒无新闻证据」这一组合。
判定依据是 `AnalysisResult.news_result_count`
| 取值 | 含义 | 是否提示 |
| --- | --- | --- |
| `None` | 未执行检索(未配置搜索渠道) | **是**——明确说明本次分析没有新闻面证据 |
| `0` | 执行了检索但零命中(搜索源限流、全部失败等) | **是** |
| `> 0` | 正常拿到新闻 | 否 |
新记录会把该三态值随分析结果持久化,保证实时报告和历史报告一致。旧记录若没有保存
`news_result_count`,其新闻检索状态只能视为未知,历史展示保持原样,不会倒推为“未配置搜索渠道”。
## 后续可做的产品化增强
1. 数据源 Doctor 页面:展示每个源最近成功时间、失败原因、熔断状态和下一次恢复探测时间。

View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""Agent 运行期实际消费的新闻证据计数。
empty-news disclosure 必须反映「本次分析真正用到的新闻证据」。Agent 模式下情报
由 Agent 自己调用搜索工具取得,因此计数只能来自这些工具的真实返回,不能用分析
结束后补打的一次 `search_stock_news()` 代替:那次补查与 Agent 消费的证据无关,
两个方向都会失真——Agent 明明用了新闻却因补查失败而被标成「未纳入新闻面证据」,
或 Agent 没拿到新闻却因补查有结果而被错误地不提示。
用法pipeline 在 `executor.run()` 前后开启并读取作用域;搜索工具在返回结果时
记录本次真正交给 Agent 的条数。工具在 ThreadPoolExecutor 中执行,
`src/agent/runner.py` 通过 `contextvars.copy_context()` 提交任务,因此 ContextVar
中的**可变**累加器在工作线程与父线程之间是同一个对象,工具线程里的累加对
pipeline 可见。请勿把它换成保存不可变值的 ContextVar那样父线程读不到。
"""
from __future__ import annotations
import logging
import threading
from contextvars import ContextVar, Token
from typing import Optional
logger = logging.getLogger(__name__)
class NewsEvidenceAccumulator:
"""线程安全地累计 Agent 本次分析实际消费的新闻条数。"""
def __init__(self) -> None:
self._lock = threading.Lock()
self._total = 0
def record(self, count: int) -> None:
"""记录一次搜索工具真实返回的条数;零命中也必须记录(记 0"""
try:
value = int(count)
except (TypeError, ValueError):
value = 0
if value < 0:
value = 0
with self._lock:
self._total += value
@property
def total(self) -> int:
with self._lock:
return self._total
def resolve(self, *, search_available: bool) -> Optional[int]:
"""收敛成 `news_result_count` 的三态语义。
- 搜索渠道不可用:`None`,即未执行检索,披露「未配置搜索渠道」。
- 渠道可用:从 `0` 起步Agent 调用工具拿到多少算多少。
渠道可用但 Agent 一次都没搜的情况刻意归入 `0` 而不是 `None`:此时报告
确实没有新闻证据,但「未配置搜索渠道」是与事实相反的解释,而「未获取到
可用的新闻面数据」在这两种子情形下都成立。
"""
if not search_available:
return None
return self.total
_CURRENT_ACCUMULATOR: ContextVar[Optional[NewsEvidenceAccumulator]] = ContextVar(
"news_evidence_accumulator",
default=None,
)
def activate_news_evidence_scope() -> Token:
"""开启一次 Agent 运行的证据作用域,返回重置令牌。"""
return _CURRENT_ACCUMULATOR.set(NewsEvidenceAccumulator())
def get_current_news_evidence() -> Optional[NewsEvidenceAccumulator]:
return _CURRENT_ACCUMULATOR.get()
def reset_news_evidence_scope(token: Optional[Token]) -> None:
if token is None:
return
try:
_CURRENT_ACCUMULATOR.reset(token)
except Exception as exc: # pragma: no cover - 防御性 fail-open
logger.warning("news evidence scope reset failed: %s", exc)
def record_news_evidence(count: int) -> None:
"""供 Agent 搜索工具调用。
没有活动作用域时静默忽略:非 Agent 路径自己直接维护计数,工具也可能在
Agent 分析之外被调用(例如报告页的后续资讯检索),那些都不应影响本次分析的
披露判定。
"""
accumulator = _CURRENT_ACCUMULATOR.get()
if accumulator is None:
return
accumulator.record(count)

View File

@@ -9,6 +9,7 @@ Tools:
import logging
from src.agent.news_evidence import record_news_evidence
from src.agent.tools.registry import ToolParameter, ToolDefinition, ToolPolicy
logger = logging.getLogger(__name__)
@@ -91,12 +92,17 @@ def _handle_search_stock_news(stock_code: str, stock_name: str) -> dict:
response = service.search_stock_news(stock_code, stock_name, max_results=5)
if not response.success:
# 检索已发起但失败Agent 这一轮没有拿到新闻证据,必须记 0 而不是不记,
# 否则报告会把「搜过但失败」误报成「未配置搜索渠道」。
record_news_evidence(0)
return {
"query": response.query,
"success": False,
"error": response.error_message,
}
record_news_evidence(len(response.results))
_persist_news_response(
stock_code=stock_code,
stock_name=stock_name,
@@ -163,15 +169,21 @@ def _handle_search_comprehensive_intel(stock_code: str, stock_name: str) -> dict
)
if not intel_results:
# 多维检索已发起但整体没有结果,同样必须记 0见 _handle_search_stock_news
record_news_evidence(0)
return {"error": "Comprehensive intel search returned no results"}
# Format into readable report
report = service.format_intel_report(intel_results, stock_name)
# 本次真正交给 Agent 的证据条数,按维度累计后一次性记录。
evidence_count = 0
# Also return structured data
dimensions = {}
for dim_name, response in intel_results.items():
if response and response.success:
evidence_count += len(response.results)
_persist_news_response(
stock_code=stock_code,
stock_name=stock_name,
@@ -191,6 +203,8 @@ def _handle_search_comprehensive_intel(stock_code: str, stock_name: str) -> dict
],
}
record_news_evidence(evidence_count)
return {
"report": report,
"dimensions": dimensions,

View File

@@ -1725,6 +1725,18 @@ class AnalysisResult:
market_snapshot: Optional[Dict[str, Any]] = None # 当日行情快照(展示用)
raw_response: Optional[str] = None # 原始响应(调试用)
search_performed: bool = False # 是否执行了联网搜索
# 新闻检索实际命中的条数。None 表示未执行检索(如未配置搜索渠道),
# 0 表示执行了检索但一条也没拿到;报告会针对两种原因使用不同披露文案。
news_result_count: Optional[int] = None
# 旧历史记录未持久化 news_result_count重建时必须与明确的 None 区分,
# 否则会把未知旧数据误报成「未配置搜索渠道」。实时分析默认值始终可信。
news_result_count_known: bool = True
# 本次分析实际收到的消息面证据news_context是否非空。
# news_result_count 只是「实时搜索命中了几条」,而披露断言的是「结论有没有用到
# 新闻面证据」——两者是不同命题news_context 还可能来自社交情绪或本地已落库的
# 资讯池,这些同样进入模型输入却不产生搜索命中。只看计数会把这类分析误报成
# 「未纳入新闻面证据」。
news_evidence_present: bool = False
data_sources: str = "" # 数据来源说明
success: bool = True
error_message: Optional[str] = None
@@ -1776,6 +1788,9 @@ class AnalysisResult:
'buy_reason': self.buy_reason,
'market_snapshot': self.market_snapshot,
'search_performed': self.search_performed,
'news_result_count': self.news_result_count,
'news_result_count_known': self.news_result_count_known,
'news_evidence_present': self.news_evidence_present,
'success': self.success,
'error_message': self.error_message,
'current_price': self.current_price,

View File

@@ -59,6 +59,12 @@ from src.agent.final_explanation import (
build_pipeline_final_explanation,
capture_pipeline_action_adjustment,
)
from src.agent.news_evidence import (
activate_news_evidence_scope,
get_current_news_evidence,
reset_news_evidence_scope,
)
from src.services.empty_news import news_evidence_present
from src.formatters import strip_hidden_markdown_metadata
from src.phase_decision_guardrail import apply_phase_decision_guardrails
from src.services.daily_market_context import (
@@ -615,6 +621,11 @@ class StockAnalysisPipeline:
if self.search_service is not None and self.search_service.is_available:
logger.info(f"{stock_name}({code}) 开始多维度情报搜索...")
# 检索已发起:此后即使一条都没拿到,也是「执行了但零命中」而非
# 「未执行检索」。若停留在 None搜索源全线失败这一最该提示的场景
# 反而不会提示。
news_result_count = 0
# 使用多维度搜索最多5次搜索
intel_results = self.search_service.search_comprehensive_intel(
stock_code=code,
@@ -651,11 +662,13 @@ class StockAnalysisPipeline:
logger.info(f"{stock_name}({code}) 搜索服务不可用,跳过情报搜索")
# Step 4.5: Social sentiment intelligence (US stocks only)
social_evidence_context: Optional[str] = None
if self.social_sentiment_service is not None and self.social_sentiment_service.is_available and is_us_stock_code(code):
try:
social_context = self.social_sentiment_service.get_social_context(code)
if social_context:
logger.info(f"{stock_name}({code}) Social sentiment data retrieved")
social_evidence_context = social_context
if news_context:
news_context = news_context + "\n\n" + social_context
else:
@@ -762,6 +775,18 @@ class StockAnalysisPipeline:
analysis_context_pack_summary=analysis_context_pack_summary,
)
llm_duration_ms = int((time.monotonic() - llm_started_at) * 1000)
if result is not None:
# 交给展示层区分「未配置渠道」「检索零命中」和「正常命中」。
# 该值此前只进了诊断快照,报告层拿不到。
result.news_result_count = news_result_count
# 三路来源逐个登记,不看拼好的 news_context 整段:
# format_intel_report() 零命中时仍输出占位文本,整段永远非空,
# 拿它判定会把「搜了但一条没拿到」误判成有证据。
result.news_evidence_present = news_evidence_present(
news_result_count,
social_evidence_context,
persisted_intelligence_context,
)
record_llm_run(
success=bool(result and getattr(result, "success", True)),
model=getattr(result, "model_used", None) if result else None,
@@ -1375,10 +1400,12 @@ class StockAnalysisPipeline:
# Agent path: inject social sentiment as news_context so both
# executor (_build_user_message) and orchestrator (ctx.set_data)
# can consume it through the existing news_context channel
social_evidence_context: Optional[str] = None
if self.social_sentiment_service is not None and self.social_sentiment_service.is_available and is_us_stock_code(code):
try:
social_context = self.social_sentiment_service.get_social_context(code)
if social_context:
social_evidence_context = social_context
existing = initial_context.get("news_context")
if existing:
initial_context["news_context"] = existing + "\n\n" + social_context
@@ -1435,6 +1462,11 @@ class StockAnalysisPipeline:
else:
message = f"请分析股票 {code} ({stock_name}),并生成决策仪表盘报告。"
llm_started_at = time.monotonic()
# Agent 自己调用搜索工具取证,所以披露计数只能来自这些工具的真实返回;
# 分析结束后补打的 search_stock_news() 与 Agent 消费的证据无关。
# 累加器对象在这里持有引用reset 之后仍可安全读取。
news_evidence_token = activate_news_evidence_scope()
news_evidence = get_current_news_evidence()
try:
record_llm_run_started(
model=getattr(self.config, "agent_litellm_model", None),
@@ -1451,6 +1483,8 @@ class StockAnalysisPipeline:
error_message=exc,
)
raise
finally:
reset_news_evidence_scope(news_evidence_token)
# 转换为 AnalysisResult
result = self._agent_result_to_analysis_result(
@@ -1461,6 +1495,24 @@ class StockAnalysisPipeline:
query_id,
trend_result=trend_result,
)
# 三态计数取自 Agent 实际消费的搜索工具结果:渠道不可用为 None未执行
# 检索),渠道可用则从 0 起步、拿到多少算多少。
if result is not None and news_evidence is not None:
result.news_result_count = news_evidence.resolve(
search_available=bool(
self.search_service is not None
and self.search_service.is_available
),
)
# 与普通路径同样按来源逐个登记Agent 运行期自己搜到的条数、注入的
# 社交情绪、注入的本地资讯池。这条路径不经过 format_intel_report()
# 但仍不传拼好的整段,避免以后有人往里加会造占位文本的来源。
result.news_evidence_present = news_evidence_present(
result.news_result_count,
social_evidence_context,
persisted_intelligence_context,
)
record_llm_run(
success=bool(result and getattr(result, "success", True)),
model=getattr(result, "model_used", None) if result else getattr(agent_result, "model", None),
@@ -1646,6 +1698,10 @@ class StockAnalysisPipeline:
stock_name=resolved_stock_name,
max_results=5
)
# 这次补查只为持久化新闻情报Fixes #396刻意不写
# result.news_result_count它发生在分析结束之后与 Agent 实际
# 消费的证据无关,用它做披露判定会两个方向都失真。真正的计数在
# executor.run() 的证据作用域里收集(见上文)。
if news_response.success and news_response.results:
query_context = self._build_query_context(query_id=query_id)
self.db.save_news_intel(

View File

@@ -410,6 +410,17 @@ class NotificationService(
self._history_compare_cache[cache_key] = history_by_code
return {"history_by_code": history_by_code}
@staticmethod
def _empty_news_disclosure(result: "AnalysisResult", language: str = "zh") -> Optional[str]:
"""新闻检索未执行或零命中时返回对应披露文案。
判定与文案由 src/services/empty_news 统一持有;字符串拼接渲染器与模板
渲染链路共用同一实现,避免同一份分析结果在部分渠道披露、另一些渠道沉默。
"""
from src.services.empty_news import empty_news_disclosure
return empty_news_disclosure(result, language)
def generate_aggregate_report(
self,
results: List[AnalysisResult],
@@ -938,6 +949,9 @@ class NotificationService(
f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
)
news_disclosure = self._empty_news_disclosure(r, report_language)
if news_disclosure:
report_lines.append(news_disclosure)
else:
report_lines.extend([f"## 📈 {labels['report_title']}", ""])
# 逐个股票的详细分析
@@ -1031,12 +1045,13 @@ class NotificationService(
news_lines.append(f"**市场情绪**{result.market_sentiment}")
if hasattr(result, 'hot_topics') and result.hot_topics:
news_lines.append(f"**相关热点**{result.hot_topics}")
if news_lines:
report_lines.extend([
"#### 📰 消息面/情绪面",
*news_lines,
"",
])
news_disclosure = self._empty_news_disclosure(result, report_language)
if news_lines or news_disclosure:
report_lines.append("#### 📰 消息面/情绪面")
if news_disclosure:
report_lines.append(news_disclosure)
report_lines.extend(news_lines)
report_lines.append("")
# 综合分析
if result.analysis_summary:
@@ -1303,6 +1318,10 @@ class NotificationService(
f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
)
if self._report_summary_only:
news_disclosure = self._empty_news_disclosure(r, report_language)
if news_disclosure:
report_lines.append(news_disclosure)
report_lines.extend([
"",
"---",
@@ -1559,12 +1578,14 @@ class NotificationService(
report_lines.append(f"**{volume_analysis_label}**: {result.volume_analysis}")
report_lines.append("")
# 消息面
if result.news_summary:
report_lines.extend([
f"### 📰 {news_heading}",
f"{result.news_summary}",
"",
])
news_disclosure = self._empty_news_disclosure(result, report_language)
if result.news_summary or news_disclosure:
report_lines.append(f"### 📰 {news_heading}")
if news_disclosure:
report_lines.append(news_disclosure)
if result.news_summary:
report_lines.append(f"{result.news_summary}")
report_lines.append("")
report_lines.extend([
"---",
@@ -1637,6 +1658,9 @@ class NotificationService(
f"{labels['score_label']} {r.sentiment_score} | "
f"{localize_trend_prediction(r.trend_prediction, report_language)}"
)
news_disclosure = self._empty_news_disclosure(r, report_language)
if news_disclosure:
lines.append(news_disclosure)
else:
for result in sorted_results:
signal_text, signal_emoji, _ = self._get_signal_level(result)
@@ -1660,6 +1684,11 @@ class NotificationService(
# 重要信息区(舆情+基本面)
info_lines = []
# 新闻零命中时必须披露,否则企业微信这一路会静默省略
news_disclosure = self._empty_news_disclosure(result, report_language)
if news_disclosure:
info_lines.append(news_disclosure)
# 业绩预期
if intel.get('earnings_outlook'):
outlook = str(intel['earnings_outlook'])[:60]
@@ -1809,6 +1838,9 @@ class NotificationService(
f"{labels['score_label']}:{result.sentiment_score} | "
f"{localize_trend_prediction(result.trend_prediction, report_language)}"
)
news_disclosure = self._empty_news_disclosure(result, report_language)
if news_disclosure:
lines.append(news_disclosure)
# 操作理由(截断)
if hasattr(result, 'buy_reason') and result.buy_reason:
@@ -1894,6 +1926,9 @@ class NotificationService(
f"{signal_text} | "
f"{labels['score_label']} {r.sentiment_score} | {one}"
)
news_disclosure = self._empty_news_disclosure(r, report_language)
if news_disclosure:
lines.append(news_disclosure)
lines.append("")
lines.append(f"*{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
models = self._collect_models_used(results)
@@ -1950,6 +1985,12 @@ class NotificationService(
# 重要信息(舆情+基本面)
info_added = False
news_disclosure = self._empty_news_disclosure(result, report_language)
if news_disclosure:
lines.append(f"### 📰 {labels['info_heading']}")
lines.append("")
lines.append(news_disclosure)
info_added = True
if intel:
if intel.get('earnings_outlook'):
if not info_added:

View File

@@ -31,6 +31,7 @@ from src.services.run_diagnostics import (
get_current_diagnostic_context,
reset_run_diagnostic_context,
)
from src.services.empty_news import empty_news_disclosure
logger = logging.getLogger(__name__)
@@ -236,6 +237,7 @@ class AnalysisService:
},
"details": {
"news_summary": result.news_summary,
"empty_news_disclosure": empty_news_disclosure(result, report_language),
"technical_analysis": result.technical_analysis,
"fundamental_analysis": result.fundamental_analysis,
"risk_warning": result.risk_warning,

176
src/services/empty_news.py Normal file
View File

@@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
"""新闻检索未执行或零命中时的报告披露文案。
单一事实来源字符串拼接渲染器src/notification.py与模板渲染链路
src/services/report_renderer.py + templates/*.j2共用本模块避免
同一份分析结果在部分渠道披露、在另一些渠道沉默。
披露断言的是「本次结论有没有用到新闻面证据」,因此第一依据是分析实际收到的消息面
证据news_context是否非空而不是搜索命中了几条。news_context 可能来自实时检索、
社交情绪或本地已落库的资讯池,后两者同样进入模型输入却不产生搜索命中;只看计数会把
这类分析误报成「未纳入新闻面证据」review OR-COR-2e4b9d61
news_result_count 因此退居第二位,只用来解释「确实没有证据」时的原因:
None 未执行检索(未配置搜索渠道)
0 执行了检索但零命中(限流、全部失败等)
> 0 实时检索有命中(此时证据必然存在)
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, Optional, Tuple
from src.report_language import SUPPORTED_REPORT_LANGUAGES
_ZH_NOT_CONFIGURED = "⚠️ 未配置搜索渠道,本次分析未纳入新闻面证据。"
_EN_NOT_CONFIGURED = (
"⚠️ No news search channel is configured; "
"this analysis does not incorporate news-based evidence."
)
_ZH_ZERO_RESULTS = "⚠️ 本次未获取到可用的新闻面数据,以下结论未纳入新闻维度证据。"
_EN_ZERO_RESULTS = (
"⚠️ No news data could be retrieved for this run; "
"the conclusions below do not incorporate news-based evidence."
)
_KO_NOT_CONFIGURED = (
"⚠️ 뉴스 검색 채널이 설정되지 않아 이번 분석에는 "
"뉴스 근거를 반영하지 않았습니다."
)
_KO_ZERO_RESULTS = (
"⚠️ 이번 분석에서 사용 가능한 뉴스 데이터를 가져오지 못해 "
"아래 결론에는 뉴스 근거를 반영하지 않았습니다."
)
_DISCLOSURES = {
"zh": (_ZH_NOT_CONFIGURED, _ZH_ZERO_RESULTS),
"en": (_EN_NOT_CONFIGURED, _EN_ZERO_RESULTS),
"ko": (_KO_NOT_CONFIGURED, _KO_ZERO_RESULTS),
}
if set(_DISCLOSURES) != set(SUPPORTED_REPORT_LANGUAGES):
raise RuntimeError(
"Empty-news disclosures must cover every SUPPORTED_REPORT_LANGUAGES value"
)
def persisted_news_result_state(
raw_result: Any,
context_snapshot: Any = None,
) -> Tuple[Optional[int], bool]:
"""从持久化载荷恢复计数及其可信度。
新记录的 raw_result 明确保存三态值;旧记录可在 context_snapshot 中留下
0 / >0 计数。两处都没有字段时只能判定为 legacy unknown不能把缺字段
当成明确的 None。
"""
if isinstance(raw_result, Mapping):
if raw_result.get("news_result_count_known") is False:
return None, False
if "news_result_count" in raw_result:
return raw_result.get("news_result_count"), True
if isinstance(context_snapshot, Mapping) and "news_result_count" in context_snapshot:
return context_snapshot.get("news_result_count"), True
return None, False
def news_evidence_present(*sources: Any) -> bool:
"""本次分析是否真的收到了消息面证据。任一来源为真即为真。
每个来源要么是真实条数int要么是**已排除占位文本**的内容字符串。
实时检索、社交情绪、本地资讯池各算一路pipeline 两条路径都用本函数,
不要在别处另写判断。
**不要把 pipeline 拼好的整段 news_context 传进来。**
`src/search_service.py` 的 `format_intel_report()` 在零命中时仍会输出
`【XX 情报搜索结果】` 标题和每个维度的「未找到相关信息」占位文本,整段永远
非空;用它判定会把「搜了但一条没拿到」翻成「有证据」,恰好吞掉本模块要补的
披露review OR-COR-8f4c2d1b。判定必须按来源逐个登记不能闻字符串。
"""
for source in sources:
if source is None or isinstance(source, bool):
if source:
return True
continue
if isinstance(source, (int, float)):
if source > 0:
return True
continue
if str(source).strip():
return True
return False
def _disclosure_for_state(
news_result_count: Optional[int],
*,
known: bool,
evidence_present: bool,
language: str,
) -> Optional[str]:
try:
not_configured, zero_results = _DISCLOSURES[language]
except KeyError as exc:
raise ValueError(f"Unsupported report language for empty-news disclosure: {language}") from exc
if not known:
return None
# 证据存在就不提示,无论它来自哪一路来源;计数只解释「没有证据」的原因。
if evidence_present:
return None
if news_result_count is None:
return not_configured
if news_result_count == 0:
return zero_results
return None
def empty_news_disclosure(result: Any, language: str = "zh") -> Optional[str]:
"""未执行或零命中时返回对应提示;正常命中时返回 None。
判定必须独立于模型是否产出了消息面文字analyzer 的输出 schema 即使
在没有新闻时也会要求填 market_sentiment / hot_topics若以这些字段
是否为空来决定,就会出现「展示模型生成的情绪判断、却隐瞒无新闻证据」
这一最糟的组合。
"""
if isinstance(result, Mapping):
news_result_count, known = persisted_news_result_state(result)
evidence_present = persisted_news_evidence_present(result, news_result_count)
else:
news_result_count = getattr(result, "news_result_count", None)
known = getattr(result, "news_result_count_known", True)
evidence_present = bool(getattr(result, "news_evidence_present", False))
return _disclosure_for_state(
news_result_count,
known=known,
evidence_present=evidence_present,
language=language,
)
def empty_news_disclosure_from_stored(
raw_result: Any,
context_snapshot: Any,
language: str = "zh",
) -> Optional[str]:
"""为历史/API 入口从持久化载荷生成披露;旧记录缺字段时保持静默。"""
news_result_count, known = persisted_news_result_state(raw_result, context_snapshot)
return _disclosure_for_state(
news_result_count,
known=known,
evidence_present=persisted_news_evidence_present(raw_result, news_result_count),
language=language,
)
def persisted_news_evidence_present(raw_result: Any, news_result_count: Optional[int]) -> bool:
"""从持久化载荷恢复「本次分析是否用到新闻面证据」。
本 PR 之前写入的记录没有该字段,此时退回按计数推断:>0 说明确有证据,
其余按无证据处理,与该记录当时的报告表现一致,不会追溯改变旧报告。
"""
if isinstance(raw_result, Mapping) and "news_evidence_present" in raw_result:
return bool(raw_result.get("news_evidence_present"))
return bool(news_result_count)

View File

@@ -40,6 +40,12 @@ from src.report_language import (
)
from src.storage import DatabaseManager
from src.services.run_diagnostics import build_run_diagnostic_summary
from src.services.empty_news import (
empty_news_disclosure,
empty_news_disclosure_from_stored,
persisted_news_evidence_present,
persisted_news_result_state,
)
from src.market_phase_summary import (
extract_market_phase_summary,
rebuild_market_phase_summary_for_stock_code,
@@ -629,6 +635,15 @@ class HistoryService:
except json.JSONDecodeError:
context_snapshot = record.context_snapshot
report_language = normalize_report_language(
raw_result.get("report_language") if isinstance(raw_result, dict) else None
)
news_disclosure = empty_news_disclosure_from_stored(
raw_result,
context_snapshot,
report_language,
)
market_review_content = None
analysis_summary = record.analysis_summary
if getattr(record, "report_type", None) == "market_review":
@@ -662,6 +677,7 @@ class HistoryService:
"stop_loss": sniper_points.get("stop_loss"),
"take_profit": sniper_points.get("take_profit"),
"news_content": market_review_content or record.news_content,
"empty_news_disclosure": news_disclosure,
"raw_result": raw_result,
"context_snapshot": context_snapshot,
"market_phase_summary": market_phase_summary,
@@ -915,6 +931,11 @@ class HistoryService:
from src.analyzer import AnalysisResult
# Extract dashboard data if available
dashboard = raw_result.get("dashboard", {})
context_snapshot = parse_json_field(getattr(record, "context_snapshot", None))
news_result_count, news_result_count_known = persisted_news_result_state(
raw_result,
context_snapshot,
)
# Build AnalysisResult with available data
result = AnalysisResult(
@@ -948,6 +969,11 @@ class HistoryService:
buy_reason=raw_result.get("buy_reason", ""),
market_snapshot=raw_result.get("market_snapshot"),
search_performed=raw_result.get("search_performed", False),
news_result_count=news_result_count,
news_result_count_known=news_result_count_known,
news_evidence_present=persisted_news_evidence_present(
raw_result, news_result_count
),
data_sources=raw_result.get("data_sources", ""),
success=raw_result.get("success", True),
error_message=raw_result.get("error_message"),
@@ -1020,6 +1046,10 @@ class HistoryService:
"",
]
news_disclosure = empty_news_disclosure(result, report_language)
if news_disclosure:
report_lines.extend([news_disclosure, ""])
# ========== 舆情与基本面概览(放在最前面)==========
intel = dashboard.get('intelligence', {}) if dashboard else {}
if intel:

View File

@@ -87,6 +87,9 @@ def _resolve_templates_dir() -> Path:
return templates_dir
from src.services.empty_news import empty_news_disclosure
def render(
platform: str,
results: List[AnalysisResult],
@@ -161,6 +164,7 @@ def render(
rn = get_localized_stock_name(r.name, r.code, report_language)
sorted_enriched.append({
"result": r,
"empty_news_disclosure": empty_news_disclosure(r, report_language),
"signal_text": display_advice,
"signal_emoji": se,
"stock_name": _escape_md(rn),

View File

@@ -10,6 +10,9 @@
{% set core = (dash.get('core_conclusion') or {}) if dash else {} %}
{% set one = (core.get('one_sentence') or e.result.analysis_summary or '')[:60] %}
**{{ e.stock_name }}({{ e.result.code }})** {{ e.signal_emoji }} {{ e.signal_text }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ one }}
{% if e.empty_news_disclosure %}
{{ e.empty_news_disclosure }}
{% endif %}
{% endfor %}
*{{ report_timestamp }}*

View File

@@ -10,6 +10,9 @@
{% for e in enriched %}
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.signal_text }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
{% if summary_only and e.empty_news_disclosure %}
{{ e.empty_news_disclosure }}
{% endif %}
{% endfor %}
---
@@ -24,6 +27,9 @@
## {{ e.signal_emoji }} {{ e.stock_name }} ({{ result.code }})
{% if e.empty_news_disclosure %}
{{ e.empty_news_disclosure }}
{% endif %}
{% if intel %}
### 📰 {{ labels.info_heading }}

View File

@@ -9,6 +9,9 @@
**📊 {{ labels.summary_heading }}**
{% for e in enriched %}
{{ e.signal_emoji }} **{{ e.stock_name }}({{ e.result.code }})**: {{ e.signal_text }} | {{ labels.score_label }} {{ e.result.sentiment_score }} | {{ e.localized_trend_prediction }}
{% if e.empty_news_disclosure %}
{{ e.empty_news_disclosure }}
{% endif %}
{% endfor %}
{% else %}
{% for e in enriched %}
@@ -24,6 +27,9 @@
{% if one_sentence %}
📌 **{{ one_sentence[:80] }}**
{% endif %}
{% if e.empty_news_disclosure %}
{{ e.empty_news_disclosure }}
{% endif %}
{% if intel.get('earnings_outlook') %}
📊 {{ labels.earnings_outlook_label }}: {{ intel.earnings_outlook[:60] }}
{% endif %}

View File

@@ -1766,6 +1766,10 @@ class AnalysisApiContractTestCase(unittest.TestCase):
news_component = result["diagnostic_summary"]["components"]["news"]
self.assertEqual(news_component["status"], "unknown")
self.assertEqual(
result["report"]["details"]["empty_news_disclosure"],
"⚠️ 未配置搜索渠道,本次分析未纳入新闻面证据。",
)
def test_build_analysis_response_includes_market_phase_summary_from_result_snapshot(self) -> None:
service = AnalysisService()

View File

@@ -2350,6 +2350,95 @@ class AnalysisHistoryTestCase(unittest.TestCase):
self.assertIsNone(session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id_1).first())
self.assertIsNotNone(session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id_2).first())
def test_empty_news_state_round_trips_through_history_markdown(self) -> None:
"""持久化、重建和历史 Markdown 必须保留三态披露。"""
no_channel = "⚠️ 未配置搜索渠道,本次分析未纳入新闻面证据。"
zero_hit = "⚠️ 本次未获取到可用的新闻面数据,以下结论未纳入新闻维度证据。"
service = HistoryService(self.db)
for suffix, count, expected in (
("none", None, no_channel),
("zero", 0, zero_hit),
("hits", 3, None),
):
with self.subTest(state=suffix):
result = self._build_result()
result.news_result_count = count
result.news_summary = ""
query_id = f"query_empty_news_round_trip_{suffix}"
record_id = self.db.save_analysis_history(
result=result,
query_id=query_id,
report_type="full",
news_content=None,
context_snapshot=None,
save_snapshot=False,
)
self.assertGreater(record_id, 0)
with self.db.get_session() as session:
row = session.query(AnalysisHistory).filter(
AnalysisHistory.id == record_id
).first()
if row is None:
self.fail("未找到保存的历史记录")
raw_result = json.loads(row.raw_result or "{}")
self.assertIn("news_result_count", raw_result)
self.assertEqual(raw_result["news_result_count"], count)
self.assertIs(raw_result["news_result_count_known"], True)
rebuilt = service._rebuild_analysis_result(raw_result, row)
self.assertIsNotNone(rebuilt)
self.assertEqual(rebuilt.news_result_count, count)
self.assertTrue(rebuilt.news_result_count_known)
markdown = service.get_markdown_report(str(record_id))
self.assertIsNotNone(markdown)
if expected is None:
self.assertNotIn(no_channel, markdown)
self.assertNotIn(zero_hit, markdown)
else:
self.assertIn(expected, markdown)
if get_history_detail is not None:
report = get_history_detail(str(record_id), db_manager=self.db)
self.assertEqual(report.details.empty_news_disclosure, expected)
def test_legacy_history_without_news_count_stays_silent(self) -> None:
"""旧记录缺少计数字段时状态未知,不能倒推为未配置渠道。"""
no_channel = "⚠️ 未配置搜索渠道,本次分析未纳入新闻面证据。"
zero_hit = "⚠️ 本次未获取到可用的新闻面数据,以下结论未纳入新闻维度证据。"
record_id = self.db.save_analysis_history(
result=self._build_result(),
query_id="query_legacy_empty_news_unknown",
report_type="full",
news_content=None,
context_snapshot=None,
save_snapshot=False,
)
self.assertGreater(record_id, 0)
with self.db.session_scope() as session:
row = session.query(AnalysisHistory).filter(AnalysisHistory.id == record_id).first()
if row is None:
self.fail("未找到保存的历史记录")
raw_result = json.loads(row.raw_result or "{}")
raw_result.pop("news_result_count", None)
raw_result.pop("news_result_count_known", None)
row.raw_result = json.dumps(raw_result, ensure_ascii=False)
record = self.db.get_analysis_history_by_id(record_id)
self.assertIsNotNone(record)
rebuilt = HistoryService(self.db)._rebuild_analysis_result(raw_result, record)
self.assertIsNotNone(rebuilt)
self.assertFalse(rebuilt.news_result_count_known)
markdown = HistoryService(self.db).get_markdown_report(str(record_id))
self.assertNotIn(no_channel, markdown or "")
self.assertNotIn(zero_hit, markdown or "")
if get_history_detail is not None:
report = get_history_detail(str(record_id), db_manager=self.db)
self.assertIsNone(report.details.empty_news_disclosure)
class HistoryItemSchemaNegativeSentimentTest(unittest.TestCase):
"""Regression: HistoryItem / ReportSummary must accept out-of-range sentiment_score from DB rows."""

View File

@@ -0,0 +1,787 @@
# -*- 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()