diff --git a/api/v1/endpoints/analysis.py b/api/v1/endpoints/analysis.py index 1eedee8f7..736ddcd2b 100644 --- a/api/v1/endpoints/analysis.py +++ b/api/v1/endpoints/analysis.py @@ -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, diff --git a/api/v1/endpoints/history.py b/api/v1/endpoints/history.py index 597d5e152..10c3f2d40 100644 --- a/api/v1/endpoints/history.py +++ b/api/v1/endpoints/history.py @@ -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, diff --git a/api/v1/schemas/history.py b/api/v1/schemas/history.py index d7b054860..01b668497 100644 --- a/api/v1/schemas/history.py +++ b/api/v1/schemas/history.py @@ -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( diff --git a/apps/dsa-web/src/components/report/ReportOverview.tsx b/apps/dsa-web/src/components/report/ReportOverview.tsx index 03397b992..96511148d 100644 --- a/apps/dsa-web/src/components/report/ReportOverview.tsx +++ b/apps/dsa-web/src/components/report/ReportOverview.tsx @@ -304,6 +304,14 @@ export const ReportOverview: React.FC = ({

{summary.analysisSummary || text.noAnalysisSummary}

+ {details?.emptyNewsDisclosure ? ( +

+ {details.emptyNewsDisclosure} +

+ ) : null} diff --git a/apps/dsa-web/src/components/report/__tests__/ReportOverview.test.tsx b/apps/dsa-web/src/components/report/__tests__/ReportOverview.test.tsx index 60573d93e..b99dcd4bf 100644 --- a/apps/dsa-web/src/components/report/__tests__/ReportOverview.test.tsx +++ b/apps/dsa-web/src/components/report/__tests__/ReportOverview.test.tsx @@ -288,6 +288,21 @@ describe('ReportOverview', () => { expect(screen.queryByText('板块联动')).not.toBeInTheDocument(); }); + it('renders the persisted empty-news disclosure beside the core conclusion', () => { + render( + , + ); + + expect(screen.getByRole('note')).toHaveTextContent('未配置搜索渠道'); + expect(screen.getByRole('note')).toHaveTextContent('未纳入新闻面证据'); + }); + it('fails open on malformed ranking payloads', () => { render( ; contextSnapshot?: Record & { marketReviewPayload?: MarketReviewPayload }; analysisContextPackOverview?: AnalysisContextPackOverview | null; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 1dec5238a..fa978f815 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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) diff --git a/docs/data-source-stability.md b/docs/data-source-stability.md index 70a4b2483..23299e402 100644 --- a/docs/data-source-stability.md +++ b/docs/data-source-stability.md @@ -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 页面:展示每个源最近成功时间、失败原因、熔断状态和下一次恢复探测时间。 diff --git a/src/agent/news_evidence.py b/src/agent/news_evidence.py new file mode 100644 index 000000000..646c59217 --- /dev/null +++ b/src/agent/news_evidence.py @@ -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) diff --git a/src/agent/tools/search_tools.py b/src/agent/tools/search_tools.py index 3d3a91f34..7a8c0a3f5 100644 --- a/src/agent/tools/search_tools.py +++ b/src/agent/tools/search_tools.py @@ -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, diff --git a/src/analyzer.py b/src/analyzer.py index 43c57cc04..837070f2d 100644 --- a/src/analyzer.py +++ b/src/analyzer.py @@ -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, diff --git a/src/core/pipeline.py b/src/core/pipeline.py index ad516938e..03ff2619d 100644 --- a/src/core/pipeline.py +++ b/src/core/pipeline.py @@ -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( diff --git a/src/notification.py b/src/notification.py index 363ccf7b3..e501ad5be 100644 --- a/src/notification.py +++ b/src/notification.py @@ -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: diff --git a/src/services/analysis_service.py b/src/services/analysis_service.py index 64f60da10..3deb5969f 100644 --- a/src/services/analysis_service.py +++ b/src/services/analysis_service.py @@ -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, diff --git a/src/services/empty_news.py b/src/services/empty_news.py new file mode 100644 index 000000000..8877d66b6 --- /dev/null +++ b/src/services/empty_news.py @@ -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) diff --git a/src/services/history_service.py b/src/services/history_service.py index 5a537219a..d0ab81047 100644 --- a/src/services/history_service.py +++ b/src/services/history_service.py @@ -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: diff --git a/src/services/report_renderer.py b/src/services/report_renderer.py index a8a41e852..0e7ec7efd 100644 --- a/src/services/report_renderer.py +++ b/src/services/report_renderer.py @@ -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), diff --git a/templates/report_brief.j2 b/templates/report_brief.j2 index f6f2e73a1..9187188bf 100644 --- a/templates/report_brief.j2 +++ b/templates/report_brief.j2 @@ -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 }}* diff --git a/templates/report_markdown.j2 b/templates/report_markdown.j2 index 954895b9c..33619686c 100644 --- a/templates/report_markdown.j2 +++ b/templates/report_markdown.j2 @@ -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 }} diff --git a/templates/report_wechat.j2 b/templates/report_wechat.j2 index 7e7f6193a..b774eead0 100644 --- a/templates/report_wechat.j2 +++ b/templates/report_wechat.j2 @@ -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 %} diff --git a/tests/test_analysis_api_contract.py b/tests/test_analysis_api_contract.py index 8a105650a..afaadb2ea 100644 --- a/tests/test_analysis_api_contract.py +++ b/tests/test_analysis_api_contract.py @@ -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() diff --git a/tests/test_analysis_history.py b/tests/test_analysis_history.py index 5cfaaa775..1e2711cdb 100644 --- a/tests/test_analysis_history.py +++ b/tests/test_analysis_history.py @@ -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.""" diff --git a/tests/test_notification_empty_news_disclosure.py b/tests/test_notification_empty_news_disclosure.py new file mode 100644 index 000000000..6e67c3cc0 --- /dev/null +++ b/tests/test_notification_empty_news_disclosure.py @@ -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()