Compare commits

10 Commits
Author SHA1 Message Date
simon ff911cf6f7 feat: Token Plan 迁移与 .env 热加载,并修复日报 AI 摘要为空
Token Plan 迁移 / 配置热加载:
- configs/llm_models.yaml: 各场景切到 Token Plan(deepseek-v4.1-flash / qwen3.6-flash)
- 新增 configs/runtime_env.py: .env 按 (mtime_ns, size) 热加载并同步 os.environ,
  统一 env_get 取值;llm / embedding / vectorstore / mcp / pipeline 改用 env_get
- configs/loader.py / scripts/run_scheduler.py 等配套调整
- 新增 tests/test_hot_reload.py

日报 AI 摘要为空修复(2026-09-25):
- 根因: 推理模型的 reasoning token 与正文共用 max_tokens, 预算 1500 被"思考"
  占满 -> text_tokens=0 / finish_reason=length, 摘要静默为空且不重试
- daily_report 场景新增 max_tokens(默认 4000, YAML 保存即热生效);
  LLMConfig 支持可选 max_tokens; 分块预算 800 -> 2000
- _llm_call 拆出 _call_once, 正文为空时自动加倍预算重试(上限 16000),
  用尽才降级返回空串; 网络异常重试语义不变
- docs/user-guide.md 新增 FAQ; continuation.md 记录本次排查
- 已重跑 2026-09-25 日报(report_id=357)补回 466 字摘要

测试: 相关用例 56 passed(test_hot_reload 12 passed);
      ruff 无新增问题; 3 个 crawler 既有失败与本改动无关
2026-09-25 11:13:37 +08:00
simon 2eaea2ee81 fix: cninfo 抓取限制浏览器并发,修复整机冻结 (06:00 任务压穿内存)
根因: _render_page 每次新建完整 headless Chromium(实测 857MB/实例),
crawl_watchlist 对 15 只股票全量并发 → 峰值需求 ≈12.5GB ≫ 7.9GB RAM,
2026-09-06/08/10 三次 06:0x 整机冻结(load 65 → 看门狗复位)。

- crawler/cninfo.py: 新增 MAX_RENDER_CONCURRENCY(默认 2,env 可配)
  + 模块级 _render_sem 信号量,渲染全程持槽;顺带清理 3 个死导入
- tests/test_cninfo.py: 新增 8 个(并发上限/串行/异常释放/env 解析)
- 实测: 峰值 1796MB / 22 进程 / load 2.75 / 耗时 441s(上限 900s)
- 全量 291 passed
2026-09-10 20:54:30 +08:00
simon 6dede790c6 docs: 新增 MCP 工具说明文档 docs/mcp_tools.md
- 5 个工具参数/返回格式/降级策略/工作原理/调试连接/测试/数据前提
- user-guide §8.3 与 architecture §3.8 挂链接
2026-09-01 18:00:42 +08:00
simon 4fa48ff030 fix: 修正 .mcp.json 为本项目 MCP 配置 + 补充多源展示测试
- .mcp.json 原为 Mac 上另一项目(serena)残留配置,按文档 8.2 重写为 a-share-research
- 补充 test_fmt_results_multi_source_tag(验证 [多源] 展示)
- MCP 服务核心(5 工具/stdio/SSE/依赖/测试)此前已实现并经端到端验证
2026-09-01 17:35:08 +08:00
simon 7b33182f67 fix: 修复补跑误含 cninfo 三步与时区不一致 (P1-2/P1-3)
- P1-2: 提取 DEFAULT_NEWS_STEPS(全链路去 report+cninfo),定时/补跑/默认三处统一;
  启动补跑不再误执行 cninfo 公告管道
- P1-3: 新增 scheduler/timeutil.py(调度时区统一入口,SCHEDULE_TZ 可覆盖,
  默认 Asia/Shanghai);run_scheduler 定时/补跑/--once、crawler 补跑保护、
  reporter 兜底日期全部改用调度时区,避免系统时区非上海时日期错位
- 新增 6 个测试;全量 282 passed
2026-08-23 05:40:52 +08:00
simon 639d857ffb fix: 回填脚本改为定向回填多源 point(本地模式全量不可行)
- 本地模式 Qdrant 写入逐点 0.4~0.65s,全量 3.3 万条需 4~6h 且占单进程锁
- 改为仅回填指纹库 227 条多源记录(单源点展示层兜底 source_id):1m57s 完成
- 测试改为 _load_multi_source 语义(NULL/损坏/单源排除)
2026-08-22 22:39:10 +08:00
simon 80828310d6 feat: 打通多源新闻记录链路,检索/日报/知识库可见多源 (方案A+B)
- 模型层: EmbeddingResult/SearchResult 新增 sources 字段(主源居首,旧产物兜底)
- M5 run_embedding: events/deduped 产物透传 sources 进 EmbeddingResult
- M6 run_qdrant_ingest: payload 写入 sources(M5 → 回查 M4 → 兜底 [主源])
- vectorstore: 检索读取 payload.sources
- 日报 HTML / CLI search / MCP: 多源显示「财联社 / 新浪 [多源]」
- 新增 scripts/backfill_qdrant_sources.py: 指纹库为权威源,scroll+upsert 回填存量
  (本地模式 set_payload 逐点 0.65s 不可行,改走 ingest 同款快速路径)
- 新增 tests/test_multisource.py 10 个;全量 276 passed
2026-08-22 22:26:10 +08:00
simon 8fa27ad65b fix: 修复 dedup 高重复率返回码被掩蔽的问题 (P1-1)
- run_dedup: 生产模式重复率仅作 WARNING 告警,不影响退出码(执行成功即 0)
- run_dedup: 新增 --strict 验收模式(重复率 > 5% 返回 1),保留 M3 验收门槛
- run_dedup: 新增统计快照 data/deduped/{date}/stats.json(原子写)
- run_dedup: 顺带修复空日场景 sources.json 写入 FileNotFoundError
- pipeline: 移除 dedup rc=1 特判,恢复'非 0 即失败'统一语义
- 新增 tests/test_run_dedup.py 7 个测试;全量 266 passed
2026-08-22 19:50:14 +08:00
simon 85ba218f2f fix: 修复补跑历史日期的静默空转与日报日期错位 (P0-3/P0-4)
- run_step: 补跑保护——历史日期跳过 crawler(首页只含当天内容)并告警,复用已有 raw 数据
- run_step: report 步骤改用传入的 date_str,不再硬编码 date.today()
- 新增 4 个补跑相关测试,修正 3 个既有测试适配新行为
- 全量 259 passed(3 个 crawler 基线失败与本次无关)
2026-08-22 19:20:21 +08:00
simon 7ea8925209 fix: 修复 xwlb 日期错位与重复行问题(方案 A)
- run_xwlb: 新增 --date(处理日)参数,落盘改为处理日目录,下游 M2-M6 打通
- run_xwlb: 幂等落盘(按 url_hash 去重,重写 index 替代裸追加)
- pipeline: xwlb 步骤传 --date {date}
- run_extractor: xwlb publish_time 从 url 兜底解析真实播出日
- 新增 tests/test_run_xwlb.py 9 个测试
- 清理 60 天历史死数据(用户决策 A1)
- docs: 文档重构收尾(删除 deployment.md,已在服务器直接修改)
2026-08-22 19:11:32 +08:00
40 changed files with 2877 additions and 278 deletions
+5
View File
@@ -41,6 +41,11 @@ reasonix.toml
.env
.env.local
.env.*.local
# 迁移/切换服务商时留下的 .env 备份(含密钥,禁止入库)
.env*
!.env.example
*.bak
*.bak*
*.key
*.pem
+3 -10
View File
@@ -1,16 +1,9 @@
{
"mcpServers": {
"serena-djapi": {
"a-share-research": {
"command": "uv",
"args": [
"run",
"--directory",
"/Users/summer/Downloads/cc-cursor/mcp-servers/serena",
"serena",
"start-mcp-server",
"--project",
"/Users/summer/Downloads/cc-projects/news"
]
"args": ["run", "python", "-m", "scripts.run_mcp_server"],
"cwd": "/home/pi/news"
}
}
}
+3 -1
View File
@@ -255,7 +255,9 @@ def cmd_search(args: argparse.Namespace) -> int:
ev.get("sentiment"), ""
)
print(f"{i}. {sentiment_icon} {h.title}")
print(f" 来源: {h.source_id} | 相似度: {h.score:.4f} | 时间: {h.publish_time}")
src_display = " / ".join(h.sources) if len(h.sources) > 1 else (h.sources[0] if h.sources else h.source_id)
multi_tag = " [多源]" if len(h.sources) > 1 else ""
print(f" 来源: {src_display}{multi_tag} | 相似度: {h.score:.4f} | 时间: {h.publish_time}")
if ev.get("stock_codes"):
print(f" 代码: {','.join(ev['stock_codes'])}")
if ev.get("event_type"):
+19 -15
View File
@@ -18,7 +18,8 @@
# LLM_MODEL);若全部缺失则直接报错,绝不静默使用内置默认模型。
# · api_key_env / base_url_env 为可选字段,填写存放 API Key / 服务地址的
# 环境变量名;API Key 一律放 .env,禁止写入本文件(安全规范)。
# · 修改后无需重启常驻服务即可生效(每次调用重新读取;如需热更新缓存可重启)。
# · 修改后无需重启常驻服务即可生效:configs/loader.py 以 (mtime, size) 失效缓存,
# 保存后下一次调用即读到新值;.env 的改动由 configs/runtime_env.py 在约 2s 内热更新。
# =============================================================================
# ---- 全局默认参数(各场景可覆盖;低于 .env,高于代码内置默认)----
@@ -49,9 +50,9 @@ scenes:
# 建议模型: deepseek-v4-flash(生产实测) / deepseek-chat / qwen-plus / qwen-max
event_extraction:
provider: qwen # 建议 deepseek | qwen;留空则回退 .env 的 LLM_PROVIDER
model: qwen3.7-flash # 留空则回退 .env(DEEPSEEK_MODEL → LLM_MODEL)
api_key_env: DASHSCOPE_API_KEY # 例如: DEEPSEEK_API_KEY / QWEN_API_KEY / DASHSCOPE_API_KEY
base_url_env: QWEN_BASE_URL # 例如: DEEPSEEK_BASE_URL / QWEN_BASE_URL
model: qwen3.6-flash # Token Plan 模型;注意 Token Plan 无 qwen3.7-flash
api_key_env: QWEN_API_KEY # Token Plan 计费账号(sk-sp-…);勿用 DASHSCOPE_API_KEY
base_url_env: QWEN_BASE_URL # .env 指向 token-plan.*.maas.aliyuncs.com
temperature: 0.1
timeout_sec: 60
max_attempts: 3 # 单篇解析失败的最大重试次数
@@ -66,20 +67,23 @@ scenes:
# 使用方式:无需手动触发,定时任务自动执行;失败自动降级(日报留空,不影响入库)。
# 对模型的要求:
# · OpenAI 兼容 chat 接口(不需要 JSON 输出);
# · 输出长度 ≥ 1500 tokens(max_tokens=1500,输出超长会被截断并记 WARNING);
# · 输出长度 ≥ max_tokens 配置值(见下,输出超长会被截断并记 WARNING);
# · 中文摘要能力强、要点化输出稳定(每条一行,以 "- " 开头);
# · 上下文窗口 ≥ 8K tokens(素材按 3000 字符/块分块,多块先分段再合并);
# · temperature 0.3 左右,兼顾稳定与表达;网络失败按指数退避重试 3 次。
# · 输出长度需求:分段摘要约 800 tokens、合并摘要约 1500 tokens(代码内置,
# 不在本文件配置),模型应能稳定输出 1500+ tokens 的中文要点。
# · max_tokens 说明:推理模型(deepseek-v4.1-flash 等)的 reasoning token 与
# 正文共用该预算;预算过小时"思考"会占满配额导致正文为空
# (finish_reason=length、0 字符,日报因此没有 AI 摘要)。代码兜底见
# scheduler/reporter.py: 正文为空时自动加倍预算重试(最多 2 次,上限 16000)。
# 建议模型: deepseek-v4-flash(生产实测) / deepseek-chat / qwen-plus
daily_report:
provider: # 建议 deepseek | qwen;留空则回退 .env 的 LLM_PROVIDER
model:
api_key_env:
base_url_env:
provider: qwen # Token Plan 计费账号
model: deepseek-v4.1-flash
api_key_env: QWEN_API_KEY
base_url_env: QWEN_BASE_URL
temperature: 0.3
timeout_sec: 60
max_tokens: 4000 # 单块/合并摘要输出预算(需为 reasoning token 预留余量)
# ------------------------------------------------------------------------- #
# 场景 3: 个股 AI 要点分析
@@ -97,10 +101,10 @@ scenes:
# · 输出长度需求:约 500 tokens(代码内置,不在本文件配置)。
# 建议模型: deepseek-v4-flash(生产实测) / deepseek-chat / qwen-plus
stock_report:
provider: # 建议 deepseek | qwen;留空则回退 .env 的 LLM_PROVIDER
model:
api_key_env:
base_url_env:
provider: qwen # Token Plan(个股日报当前禁用,配置好以防将来启用时漏计费)
model: qwen3.6-flash
api_key_env: QWEN_API_KEY
base_url_env: QWEN_BASE_URL
temperature: 0.3
timeout_sec: 60
+59 -16
View File
@@ -8,33 +8,75 @@
3. 环境变量 / .env(LLM_PROVIDER、DEEPSEEK_MODEL 等,向后兼容)
4. 代码内置默认值
热加载: 缓存以 ``(mtime_ns, size)`` 为准 —— 改完 YAML 保存后,下一次读取即生效,
常驻进程(调度器 / MCP server)无需重启。
说明:API Key 一律放 .env,本文件只保存环境变量名(api_key_env),禁止写密钥。
"""
from __future__ import annotations
from functools import lru_cache
import os
import threading
from pathlib import Path
from loguru import logger
DEFAULT_CONFIG_PATH = Path("configs/llm_models.yaml")
#: 指定替代的模型配置文件路径(测试 / 多环境部署用)
MODELS_CONFIG_OVERRIDE = "A_SHARE_MODELS_CONFIG"
#: 默认模型配置文件(绝对路径,不依赖当前工作目录)
DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[1] / "configs" / "llm_models.yaml"
_lock = threading.Lock()
_cache: dict[Path, tuple[tuple[int, int] | None, dict]] = {}
def config_path() -> Path:
"""返回当前使用的 ``llm_models.yaml`` 路径。"""
override = os.environ.get(MODELS_CONFIG_OVERRIDE)
if override:
return Path(override).expanduser()
return DEFAULT_CONFIG_PATH
def _signature(path: Path) -> tuple[int, int] | None:
"""返回 ``(mtime_ns, size)``;文件不存在时返回 None。"""
try:
st = path.stat()
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
@lru_cache(maxsize=8)
def _load_yaml(path: Path) -> dict:
"""读取 YAML 文件为 dict;文件缺失或解析失败返回空 dict(走兜底配置)。"""
"""读取 YAML 为 dict;文件缺失或解析失败返回空 dict(走兜底配置)。
按 ``(mtime_ns, size)`` 失效缓存:文件一旦变化,下次调用即重新解析。
"""
sig = _signature(path)
with _lock:
cached = _cache.get(path)
if cached is not None and cached[0] == sig:
return cached[1]
if not path.is_file():
logger.debug("配置文件不存在,使用内置/环境变量兜底: {}", path)
return {}
try:
import yaml
data: dict = {}
else:
try:
import yaml
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except Exception as e: # noqa: BLE001 - YAML 语法错误等
logger.error("解析 {} 失败: {}", path, e)
return {}
return data if isinstance(data, dict) else {}
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
data = raw if isinstance(raw, dict) else {}
except Exception as e: # noqa: BLE001 - YAML 语法错误等
logger.error("解析 {} 失败: {}", path, e)
data = {}
with _lock:
_cache[path] = (sig, data)
return data
def load_scene_config(scene: str) -> dict:
@@ -45,7 +87,7 @@ def load_scene_config(scene: str) -> dict:
"""
if not scene:
return {}
data = _load_yaml(DEFAULT_CONFIG_PATH)
data = _load_yaml(config_path())
scenes = data.get("scenes") or {}
cfg = scenes.get(scene)
if cfg is None:
@@ -59,11 +101,12 @@ def load_scene_config(scene: str) -> dict:
def load_defaults() -> dict:
"""读取 llm_models.yaml 顶层 defaults(全局默认参数)。"""
data = _load_yaml(DEFAULT_CONFIG_PATH)
data = _load_yaml(config_path())
d = data.get("defaults") or {}
return d if isinstance(d, dict) else {}
def clear_cache() -> None:
"""清空 YAML 缓存(测试或热更新配置时使用)。"""
_load_yaml.cache_clear()
"""清空 YAML 缓存(测试或强制重载时使用;正常热加载无需调用)。"""
with _lock:
_cache.clear()
+188
View File
@@ -0,0 +1,188 @@
"""运行期配置热加载:改 ``.env`` 后无需重启进程即生效。
为什么需要它
------------
常驻进程(``scripts/run_scheduler.py``、``mcp_server``)启动时把 ``.env`` 读进
``os.environ``,之后再改 ``.env`` 不会生效——子进程虽然会 ``load_dotenv()``,但
它继承的是父进程那份旧环境,而 python-dotenv 默认不覆盖已存在的键,于是
"改了配置却没反应"。
做法
----
- :func:`ensure_env_loaded`:先比对 ``.env`` 的 ``(mtime_ns, size)``。文件没变时
只做一次 ``stat``;变了才重新解析并同步到 ``os.environ``。
- 删除语义:上一轮由 ``.env`` 带入、这一轮已从文件里删掉的键会被清除,
保证"文件即事实源",而不是只能加不能减。
- :func:`env_get`:先热加载再读取,供各模块统一取配置(空字符串视为未设置)。
- :func:`start_env_watcher`:守护线程周期性刷新,照顾那些仍直接读
``os.environ`` 的历史代码路径。
优先级(从高到低)
------------------
显式参数 / CLI > configs/llm_models.yaml 场景 > 进程环境(shell / systemd)
> .env 文件 > 内置默认值
其中「进程环境」与「.env」的关系是:
- 进程环境里**显式设置且与文件不同**的键优先,热加载不会覆盖它
(例如 ``LLM_PROVIDER=qwen python -m a_share_cli`` 这种一次性覆盖);
- 其余键由本模块托管,跟随 ``.env`` 文件变化即时更新;
- 从 ``.env`` 里删掉的托管键,会同步从进程环境移除。
测试或部署可用 ``A_SHARE_ENV_FILE`` 指定其它 ``.env`` 路径。
"""
from __future__ import annotations
import os
import threading
import time
from pathlib import Path
from dotenv import dotenv_values
from loguru import logger
#: 指定替代的 .env 路径(测试 / 多环境部署用)
ENV_FILE_OVERRIDE = "A_SHARE_ENV_FILE"
#: watcher 轮询间隔(秒)
WATCH_INTERVAL_SEC = 2.0
_lock = threading.Lock()
_signature: tuple[int, int] | None = None
#: 由本模块写入 os.environ 的键 -> 写入值;用于识别"外部显式覆盖"
_managed: dict[str, str] = {}
def dotenv_path() -> Path:
"""返回当前使用的 ``.env`` 路径。"""
override = os.environ.get(ENV_FILE_OVERRIDE)
if override:
return Path(override).expanduser()
return Path(__file__).resolve().parents[1] / ".env"
def file_signature(path: Path) -> tuple[int, int] | None:
"""返回 ``(mtime_ns, size)``;文件不存在时返回 None。"""
try:
st = path.stat()
except OSError:
return None
return (st.st_mtime_ns, st.st_size)
def _release_all() -> None:
"""撤下所有仍由本模块托管的键(.env 消失时)。"""
for key, managed_value in list(_managed.items()):
if os.environ.get(key) == managed_value:
os.environ.pop(key, None)
del _managed[key]
def _apply(values: dict[str, str]) -> None:
"""把文件值同步到 ``os.environ``,尊重外部显式覆盖。"""
# 1) 已从文件移除的托管键 → 同步删除
for key in list(_managed):
if key in values:
continue
if os.environ.get(key) == _managed[key]:
os.environ.pop(key, None)
del _managed[key]
# 2) 应用文件中的键
for key, value in values.items():
current = os.environ.get(key)
if current is None or current == value:
# 未设置,或与文件一致 → 交给文件托管(后续可热更新)
os.environ[key] = value
_managed[key] = value
elif _managed.get(key) == current:
# 当前值正是本模块上一轮写入的 → 跟随文件热更新
os.environ[key] = value
_managed[key] = value
else:
# 进程环境里显式设置且与文件不同 → 外部优先,不接管
_managed.pop(key, None)
def ensure_env_loaded(force: bool = False) -> bool:
"""确保 ``os.environ`` 与 ``.env`` 文件一致。
Args:
force: 忽略签名缓存,强制重新解析(首次加载 / 测试用)。
Returns:
本次是否真的重新加载了文件。
"""
global _signature
path = dotenv_path()
sig = file_signature(path)
with _lock:
if not force and sig == _signature:
return False
if sig is None:
removed = len(_managed)
_release_all()
_signature = None
if removed:
logger.warning("{} 不可读,已回退 {} 个环境变量", path, removed)
return True
values = {k: v for k, v in dotenv_values(path).items() if v is not None}
_apply(values)
_signature = sig
logger.debug("已加载/热更新 {}({} 项)", path, len(values))
return True
def env_get(key: str, default: str | None = None) -> str | None:
"""读取配置项(读取前自动热加载 ``.env``);空字符串视为未设置。"""
ensure_env_loaded()
value = os.environ.get(key)
if value is None or value.strip() == "":
return default
return value.strip()
def env_raw(key: str, default: str | None = None) -> str | None:
"""读取配置项原始值(读取前自动热加载 ``.env``)。
与 :func:`env_get` 的区别:**不把空字符串当作未设置**。
用于"显式留空表示禁用"这类开关,例如 ``STOCK_REPORT_TIME=``。
"""
ensure_env_loaded()
value = os.environ.get(key)
if value is None:
return default
return value.strip()
def start_env_watcher(interval: float = WATCH_INTERVAL_SEC) -> threading.Thread:
"""启动守护线程:周期性检查 ``.env``,变了就热更新 ``os.environ``。
只对**常驻进程**有意义;短命的一次性脚本按需读取即可。
"""
def _loop() -> None:
while True:
try:
ensure_env_loaded()
except Exception: # noqa: BLE001 - 热加载失败不应拖垮主进程
logger.exception("热加载 .env 失败")
time.sleep(interval)
thread = threading.Thread(target=_loop, name="env-watcher", daemon=True)
thread.start()
logger.info("已启动 .env 热加载监听(每 {:.0f}s 检查一次)", interval)
return thread
def reset_cache() -> None:
"""仅供测试:撤下托管键并清空签名缓存。"""
global _signature
with _lock:
_release_all()
_signature = None
+241
View File
@@ -4,6 +4,247 @@
---
## 本次完成 (2026-09-25) — 日报 AI 摘要为空修复(推理模型 reasoning 占满 max_tokens)
**现象**:用户反馈 2026-09-25 日报没有 AI 摘要。`news_report` 中 `id=357`(2026-09-25, finance) `ai_summary` 为 `NULL`;当天 07:13:55 日志:
```
WARNING | scheduler.reporter:_llm_call - AI 摘要可能被截断: max_tokens=1500 finish_reason=length 实际输出 0 字符
```
**根因(证据链闭合)**:
- 日报场景(daily_report)调用 `deepseek-v4.1-flash`(Token Plan),这是**推理模型**:`reasoning_content` 的 token 与正文**共用** `max_tokens` 预算
- 用当天真实素材原样复现:**`reasoning_tokens=1500` / `text_tokens=0` / `content=0 字符` / `finish_reason=length`** —— 预算被"思考"全部吃掉,正文为空
- 同素材把预算提到 4000:`finish_reason=stop`、reasoning 938 + text 337、摘要 523 字 ✓
- 代码缺陷:`scheduler/reporter.py:_llm_call` 只取 `message.content`,空内容**不抛异常** → `_generate_ai_summary` 返回 `""` → `ai_summary=None` 入库,pipeline 仍标 report ✅(**静默失败、无重试**)
- **非本次 Token Plan 迁移引入**:历史同为空的还有 9-12 / 9-13 / 9-15 / 9-18,当时用的是 `deepseek-v4-flash`(provider=deepseek),同样是推理类模型 → 长期间歇性缺陷
**修复(方案 B:配置化 + 代码兜底)**:
1. `configs/llm_models.yaml`:`daily_report` 新增 `max_tokens: 4000`(注释说明 reasoning 共用预算);YAML 保存即热生效
2. `llm/client.py`:`LLMConfig` 新增可选字段 `max_tokens`;`load_llm_config` 用新增的 `_pick_optional_int` 读取场景配置(未配置 = `None`,调用方回退内置默认)
3. `scheduler/reporter.py`:
- 新增常量 `DEFAULT_SUMMARY_MAX_TOKENS=4000` / `DEFAULT_SUMMARY_CHUNK_MAX_TOKENS=2000` / `MAX_SUMMARY_MAX_TOKENS=16000` / `_MAX_BUDGET_ESCALATIONS=2`
- 新增 `_summary_max_tokens()`(场景配置 > 内置默认)、`_chunk_max_tokens()`(不超过合并预算)
- `_llm_call` 拆出 `_call_once`;保持网络异常指数退避重试语义不变;新增**空正文 + finish_reason=length 时自动加倍预算重试**(上限 16000),用尽后返回空串降级(不抛异常)
- 分块预算 800 → 2000;合并预算 1500 → 配置值(4000)
**验证**:
- 线上复现 → 修复后回归:`uv run a-share report --date 20260925` 重跑,**无截断告警**,`report_id=357` 原地更新(幂等 upsert),`ai_summary` 466 字 ✓
- 测试:新增 `TestReasoningBudgetEscalation`(升级恢复 / 场景值优先 / 用尽降级返回空 / 上限)+ `test_llm.py` 的 `max_tokens` 场景配置、`_pick_optional_int`、真实 YAML 预算 ≥4000 回归保护 → `tests/test_report_builder.py tests/test_llm.py` **56 passed**
- `ruff` 改动文件无新增问题(5 条 N806/SIM115 为 reporter.py 既有);`mypy` 仅剩 `llm/client.py:86` 既有告警
**运维动作**:`report` 步骤由调度器**进程内**执行,已 `sudo systemctl restart a-share-research`(10:55,重启后任务同步正常),明早 07:00 起生效。
**遗留与后续**:
- `scheduler/stock_reporter.py:285` 个股日报 `max_tokens=500`,配置模型 `qwen3.6-flash` 亦属推理类,**同类隐患**(当前个股日报禁用);启用前建议一并按本方案处理
- 空摘要目前只降级为"无摘要",未做告警;可考虑连续 N 天为空时推送通知
- 本次改动尚未 git commit(工作区还混有 9-22 Token Plan 迁移的未提交改动,避免混提)
---
## 本次完成 (2026-09-10) — cninfo 抓取压穿内存导致整机冻结的修复
**现象**:2026-09-06 / 09-08 / 09-10 连续三次早上 06:0x 整机冻结,看门狗(硬件 2min)硬复位。
**根因(证据链闭合)**:
- 三次冻结时刻 = `cninfo` 公告管道 06:00 定时任务:`logs/scheduler_error.log` 显示 09-10 06:00:03.180~.888 **0.7 秒内打印 15 条「抓取…公告」**(15 只股票协程同时进入渲染),随后日志全无直到 06:14:08 重启
- `data/raw/cninfo/` 缺 20260906/08/10 三个目录(存续目录 mtime 均为 06:02,而落盘在 `_save_items()` 中、抓取全部完成后才执行 → 崩在写数据之前)
- `pcp-pmie` 09-10 06:01:20 报 **load 65**(4 核);DNS 全面超时(frpc/dockerd resolver)
- `journalctl --list-boots` 与三次重启时刻吻合
**代码缺陷**(`crawler/cninfo.py`):
1. `_render_page` **每次调用都 `async with AsyncWebCrawler(...)` 新建完整 Chromium**(最贵的错误)
2. `crawl_watchlist` 用 `asyncio.as_completed` 对 watchlist **全量并发**(15 只)
3. **无任何并发限制**;service 亦无资源限制(`CPUQuota=infinity`、`TasksMax=9626`)
4. 本机 `cgroup_disable=memory` → **`MemoryMax` 不可用**(只会 `cpuset cpu io pids`)
**实测(本机 8GB)**:基线 chrome=0 → **2 并发峰值 1713MB / 20 进程 = 857MB/实例**;外推 15 并发 ≈**12.5GB** ≫ 7.9GB RAM → 必然压穿(时好时坏是 zram 与时序侥幸)
**修复(第一步:限并发)**:
- `crawler/cninfo.py`:新增 `MAX_RENDER_CONCURRENCY`(默认 **2**,env `CNINFO_RENDER_CONCURRENCY` 可覆盖)+ 模块级 `_render_sem` 信号量,`_render_page` 全程持槽(含浏览器启停)
- 顺带清理 3 个既有死导入(`time`/`datetime`/`Any`)
**实测验证(2026-09-10 20:43 实跑 `a-share cninfo`)**:
| 指标 | 修复前(15 并发) | 修复后(2 并发) |
|------|----------------|---------------|
| Chromium 内存峰值 | ≈12.5GB(外推) | **1796 MB** |
| 进程峰值 | ≈150 | **22** |
| load 峰值 | **65** | **2.75** |
| available 最低 | 压穿冻结 | **3561 MB** |
| 耗时 | 崩(无 END) | **441 s**(上限 900s) |
| 结果 | 无数据 | 抓 34 条/存 23 条,补上 09-10 缺失目录 ✅ |
**测试**:新增 `tests/test_cninfo.py` 8 个(并发上限/串行/异常释放槽位/env 解析);全量 **291 passed**(3 个 crawler 基线失败无关);ruff 干净
**遗留与后续优化**:
- 耗时由 127~145s 增至 441s(用时间换内存安全),`cninfo_crawl` 超时 900s 余量由 6 倍降至 2 倍;如嫌慢可 `CNINFO_RENDER_CONCURRENCY=3`(峰值约 2.6GB,仍安全)
- **第二步(未做)**:复用单个 `AsyncWebCrawler` + `arun_many()` 批量渲染(浏览器组 15→1,可同时提速降内存)
- **第三步(未做)**:`_fetch_irm_requests()` 是同步 `requests.get` 却在 async 中直接调用,**阻塞事件循环**;可改 `asyncio.to_thread`
- 长期:改用 cninfo 公告 POST JSON API(`hisAnnouncement/query`)彻底去掉浏览器依赖
- 未执行 systemd 资源限制:`CPUQuota` 对内存型崩溃基本无效(反而延长驻留),`TasksMax` 过小会让抓取永久失败
---
## 本次完成 (2026-08-23) — MCP 新闻查询服务确认与修复
**用户需求**:实现新闻查询 MCP 服务(阅读文档步骤,确认是否已实现)。
**确认结论:核心实现完整且可用(此前已实现,本次验证 + 修复一处配置错误)**:
| 文档要求 | 现状 | 验证 |
|----------|------|------|
| `scripts/run_mcp_server.py`(stdio/SSE 入口) | ✅ 存在 | stdio 端到端调用通过 |
| `mcp_server/tools.py` 5 个工具 | ✅ 齐全 | `tools/list` 返回 5 工具,`tools/call` 真实检索成功 |
| pyproject 依赖 `mcp>=1.0` | ✅ 已声明 | 已安装 |
| `tests/test_mcp.py` | ✅ 10 个测试 | 全过 |
| `.mcp.json`(Claude Code 配置) | ❌ **配置错误** | 见下 |
**发现并修复的差距**:
- `.mcp.json` 被跟踪的内容是 **Mac 上另一项目(serena)的残留配置**(路径指向 `/Users/summer/...`),与本项目无关 → 按 `docs/user-guide.md` 8.2 重写为 `a-share-research` 配置
- 补充 1 个测试(`test_fmt_results_multi_source_tag`,验证 A2 多源展示)
**端到端验证**(真实调用,非 mock):
1. 5 个工具函数直接调用:全部返回检索结果 ✓
2. SSE 模式 `--sse 8765`:`GET /sse` 返回 `event: endpoint` 握手 ✓
3. **MCP 协议层**(stdio 客户端 → `scripts.run_mcp_server`):`tools/list` 返回 5 个工具与文档一致;`tools/call search_news("国务院常务会议")` 命中《李强主持召开国务院常务会议》等 ✓
**全量测试**:283 passed(新增 1 个多源展示),3 个 crawler 基线失败与本次无关;ruff 干净
**遗留**:无(仅既有 reporter 5 个 ruff 问题、Qdrant 本地模式性能警告)
---
## 本次完成 (2026-08-23) — P1-2/P1-3 补跑步骤与时区一致性修复
**P1-2(守护进程补跑误含 cninfo 三步)**:
- 原因:`scripts/run_scheduler.py` 启动补跑逻辑 `steps = [k for k in STEP_COMMANDS if k != "report"]` 只排除 report,**漏掉 cninfo_crawl/cninfo_extract/cninfo_pdf**;错过定时任务重启补跑时会额外执行整套公告管道(且 cninfo_crawl 无 --date,补跑历史日期静默空转)
- 修复:提取共享常量 `scheduler.pipeline.DEFAULT_NEWS_STEPS`(= 全链路去除 report 与 cninfo 三步),定时任务、补跑、run_pipeline 默认三处统一引用
**P1-3(调度时区与 date.today() 不一致)**:
- 原因:cron 触发器显式用 `Asia/Shanghai`,但 `date.today()`/`datetime.now()` 取**系统时区**;若系统时区非上海(如容器 UTC),07:00 上海(=前一日 23:00 UTC)触发时日期会错一天,整条 pipeline 落错日目录
- 修复:新增 `scheduler/timeutil.py`(schedule_tz/today_str/now,env `SCHEDULE_TZ` 可覆盖,默认 Asia/Shanghai);`run_scheduler` 定时/补跑/`--once`、`pipeline.run_step` crawler 补跑保护、`reporter.generate_report` 兜底日期全部改用它
**测试**:
- 新增 6 个(DEFAULT_NEWS_STEPS 不含 cninfo/run_pipeline 默认步骤行为/补跑源码引用/时区两例/--once 缺省日期),并入 `tests/test_incremental.py`
- 全量 **282 passed**,3 个 crawler 基线失败与本次无关;ruff 干净(5 个 reporter 既有问题未动)
**验证**:DEFAULT_NEWS_STEPS = [crawler, xwlb, extractor, dedup, llm, embedding, qdrant] ✓;today_str = 20260823(Asia/Shanghai)✓
---
## 本次完成 (2026-08-22) — 多源新闻记录链路修复(方案 A + B)
**用户需求**:一条新闻有多个来源时,全部来源都要记录并可见;此前"找不到多源"。
**诊断结论**:
- M3→M4 多源记录逻辑**本身正常**:指纹库 227 条多源、当日 sources.json 11 条、events 313 条全含 sources、MySQL news_event.sources 填充率 100% 且有 7 条真实多源(如许家印案 `["cls","sina"]`)
- 用户"看不到"的原因:① 展示层(日报 HTML/CLI/MCP)只渲染单源 `source_id`;② 知识库链路 M5→M6 **真断点**——`EmbeddingResult` 无 `sources` 字段、Qdrant payload 只写 `source_id`、`SearchResult` 无 `sources`
**修复内容**:
- B1 模型层: `embedding/models.py` `EmbeddingResult.sources` + validator(主源居首/去重/旧产物兜底);`vectorstore/models.py` `SearchResult.sources`
- B2 `scripts/run_embedding.py`: `_build_text_from_event/_build_text_from_article` 透传 sources 进 EmbeddingResult
- B3 `scripts/run_qdrant_ingest.py`: payload 写 `sources`(M5 产物 → 回查 M4 → 兜底 [主源])
- B4 `vectorstore/client.py`: 检索读取 payload.sources
- A1 `scheduler/reporter.py`: 日报 HTML 多源时显示「财联社 / 新浪 📰」
- A2 `a_share_cli/main.py` + `mcp_server/tools.py`: 检索展示「cecn / cscn [多源]」,MCP 返回 sources 数组
- 存量回填 `scripts/backfill_qdrant_sources.py`(新):以指纹库 source_ids 为权威源回填 Qdrant payload
- ⚠️ 坑:本地文件模式 Qdrant 写入逐点 ≈0.4~0.65s,**全量 3.3 万条回填需 4~6 小时**且占用单进程锁,不可行
- 改为**定向回填**:只有多源 point 需要 sources(单源点展示层兜底 source_id),仅回填指纹库 227 条多源 → **1m57s 完成 226 条**(1 条不在集合)
- 执行时机:避开定时调度窗口(22:00 pipeline 结束后 22:34 执行)
**验证**:
- 新增 `tests/test_multisource.py` 10 个(validator/`_result_to_point` 三优先级/文本构造透传/回填加载)
- 全量 **276 passed**(3 个 crawler 基线失败与本次无关);ruff 干净(reporter 5 个 N806/SIM115 为既有问题未动)
- 今日 M5/M6 重跑:313 条全含 sources(11 条多源);CLI 检索显示「来源: cecn / cscn [多源]」✓,MCP 返回 `sources: ['cecn','cscn']` ✓
- 存量回填:226 条多源 point payload 已补 sources;CLI 检索「碧根果反倾销」显示「来源: cnstock / cscn [多源]」✓;日报 HTML 冒烟测试多源渲染「财联社 / 新浪财经 / 东方财富 📰」✓
**待办/遗留**:
- 单源存量 point 无 sources 字段(展示层兜底 source_id,行为不变,如需统一可后续补)
- P1-2(补跑 steps 含 cninfo)、P1-3(时区一致性),待用户决策
---
## 本次完成 (2026-08-22) — P1-1 修复 dedup 高重复率返回码语义
**问题**:`run_dedup` 重复率 > 5% 时返回 1;`scheduler/pipeline.py` 曾把 `dedup` 的 rc=1 无条件视为成功并打日志"无新数据场景"。结果:高重复率(可能是正常无新数据,也可能是抓取源/指纹库异常)被一刀切掩盖,真实异常无法上报。
**修复(分离"执行成功"与"统计告警")**:
- `scripts/run_dedup.py`:
- 生产模式(默认):重复率仅作 **WARNING 告警**,不影响退出码(执行成功即 0);阈值常量 `_DUP_RATE_THRESHOLD = 0.05`
- 新增 `--strict` 验收模式:保留 M3 验收门槛,重复率 > 5% 时返回 1(供人工验收)
- 新增统计快照 `data/deduped/{date}/stats.json`(原子写):unique/duplicates/total/dup_rate/layers/fingerprint_total/generated_at,供运维排查与监控
- 顺带修复:空日场景 `sources.json` 写入前补 `mkdir`(原会 FileNotFoundError)
- `scheduler/pipeline.py`:移除 `dedup` rc=1 特判,恢复"非 0 即失败"的统一语义
**测试**:
- 新增 `tests/test_run_dedup.py` 7 个(生产模式高重复返回 0 / --strict 高重复返回 1 / --strict 低重复返回 0 / 空日返回 0 / stats.json 结构与数值 / pipeline 不再掩蔽失败)
- 全量 **266 passed**,3 个 crawler 基线失败与本次无关
**生产实测**:同日重跑重复率 100% → 生产模式 `rc=0` + WARNING + stats.json 完整;`--strict` 同场景 `rc=1`(用非管道方式核实退出码)
**待办/遗留**:
- P1-2(守护进程补跑 steps 含 cninfo 三步)、P1-3(调度时区与 date.today() 一致性),待用户决策
---
## 本次完成 (2026-08-22) — P0-3/P0-4 补跑日期语义修复
**P0-3(crawler 补跑历史日期静默空转)**:
- 原因:`crawler/storage.py` 落盘硬编码 `date.today()`(engine 不传 day),`pipeline --once --date {历史}` 时下游读历史目录为空,且 `run_extractor` 对空目录返回成功 → 全链路静默空转
- 修复:`run_step()` 开头加补跑保护——`date_str != 今天` 时跳过 crawler(首页只含当天内容,历史文章已滚走,补抓不可能),WARNING 日志 + `tail_msg` 说明,返回 success(补跑复用已有 raw 数据是正确行为);xwlb 不跳过(API 支持任意历史日期)
**P0-4(report 硬编码 date.today() 忽略传入日期)**:
- 原因:`run_step("report")` 未使用入参 `date_str`,取当前时间;补跑历史日期+--report 会生成今天的空日报并覆盖当天已有日报(唯一键 file_name='')
- 修复:一行改动 `report_date = date_str`;`reporter.generate_report(day_str)` 本身已支持任意日期,无需其它改动
**测试**:
- 新增 4 个(补跑跳过不执行子进程/当天正常执行/report 收到传入日期/全链路仅 crawler 跳过),并入 `tests/test_incremental.py`
- 修正 3 个既有测试(`tests/test_scheduler.py`)适配新行为(用当天日期测正常执行路径)
- 全量 **259 passed**,3 个 crawler 基线失败与本次无关
**待办/遗留**:
- ~~P1-1(dedup rc=1 静默转成功)~~ 已修复(见上方小节);余:守护进程补跑 steps 含 cninfo_*、调度时区与 date.today() 一致性,待用户决策
- 既有小问题:`tests/test_scheduler.py` 部分 run_pipeline 测试未传 state_path,会写真实 `data/pipeline/state.json`(既有行为,未改)
---
## 本次完成 (2026-08-22) — xwlb 日期错位修复(方案 A)
**背景问题**:
- P0-1: `run_xwlb` 硬编码抓前一天并落盘到「数据日」目录,而下游 extractor 按「处理日」扫目录 → 60 天联播数据从未进入知识库(实测: `data/processed/xwlb/` 仅 20260622 一天)
- P0-2: `run_xwlb` 用 `open("a")` 裸追加,同一天被多次调度触发导致 index.jsonl 重复行(实测 20260821: 70 行仅 24 唯一)
**业务约束(保持不变)**: 当天日报需要前一天晚上(19:00 播出)的新闻联播内容。
**方案 A 实施**:
1. `scripts/run_xwlb.py` 重构:
- 新增 `--date` 参数(处理日,默认今天),与管道其它步骤日期语义一致
- 内部 `数据日 = 处理日 - 1`(业务约束:抓前一晚已播出的联播)
- **落盘目录改为处理日** `data/raw/xwlb/{处理日}/` → extractor/dedup/llm/embedding/qdrant 零改动即可处理
- **幂等落盘**: 落盘前读已有 index.jsonl 构建 `url_hash` 集合,已存在条目跳过;index.jsonl 重写为去重完整集(原子写),不再裸追加
2. `scheduler/pipeline.py`: xwlb 步骤命令加 `--date {date}`
3. `scripts/run_extractor.py`: xwlb 假 HTML 无时间节点 → 新增 `_fill_xwlb_publish_time`,从 url(`xwlb://YYYY-MM-DD/sid`)兜底解析真实播出日填充 `publish_time`(否则检索时间过滤/排序失效)
4. **历史死数据 A1 清理**: 删除 `data/raw/xwlb/` 62 个旧目录(20260622~20260822)+ `data/processed/xwlb/`(仅 20260622)
**验证(生产端到端,20260822)**:
- 新增 `tests/test_run_xwlb.py` 9 个测试(处理日目录、幂等、空数据、坏日期),全部通过
- 全量测试 **255 passed**(3 个 crawler 基线失败与本次无关)
- 实跑: 处理日=20260822 抓数据日 20260821 联播 23 条 → 二次运行新增 0/跳过 23(幂等)→ M2 提取 23/23 → M3 唯一 23 → M4 LLM 23/23 → M5 23/23 → M6 Qdrant 净增 23
- 检索验证: `a-share search "国务院常务会议"` top2 命中 xwlb《李强主持召开国务院常务会议》,时间 2026-08-21(真实播出日)✓
- 日报路径不受影响(仍走 `reporter._collect_xwlb` API 直读)
**运维规则新增**: 每次代码升级完成后必须执行 `sudo systemctl restart a-share-research.service`
**待办/遗留**:
- ~~P0-3(crawler 无 --date,补跑历史日期静默空转)、P0-4(report 硬编码 date.today())~~ 已修复(见上方小节)
- P1(dedup rc=1 静默/补跑含 cninfo/时区)未修,待用户决策
- `run_extractor --force` 重提取会重复追加 `data/processed/{src}/{date}/index.jsonl`(仅影响该辅助索引,下游读 *.json 不受影响),暂不修
- 历史 60 天联播未入库(按用户决策 A1 清理,不回补)
---
## 本次完成 (2026-08-22) — 文档清理与重构
**目标:** 清理历史上多个 AI agent 文档残留,重构项目文档为 4 个核心文件。
+39 -6
View File
@@ -19,10 +19,8 @@ import hashlib
import json
import os
import re
import time
from datetime import date, datetime, timedelta
from datetime import date, timedelta
from pathlib import Path
from typing import Any
import requests
from bs4 import BeautifulSoup
@@ -48,6 +46,34 @@ MAX_IRM_ITEMS = 20
# 请求间隔(秒)
REQUEST_DELAY = 0.5
# 浏览器渲染并发上限。
# 每次 _render_page 都会启动一个完整的 headless Chromium(实测约 850MB / 10 进程),
# 而 watchlist 是 15 只股票全量并发 → 峰值需求 ≈12.5GB,远超本机 7.9GB RAM,
# 靠 zram swap 侥幸时好时坏,一旦压穿即整机冻结(2026-09-06/08/10 早崩根因)。
# 限流到 2 后峰值 ≈1.7GB。可用 env CNINFO_RENDER_CONCURRENCY 覆盖。
DEFAULT_RENDER_CONCURRENCY = 2
def _resolve_render_concurrency() -> int:
"""解析渲染并发上限(env CNINFO_RENDER_CONCURRENCY 可覆盖,非法值回退默认)。"""
raw = os.environ.get("CNINFO_RENDER_CONCURRENCY", "")
if not raw.strip():
return DEFAULT_RENDER_CONCURRENCY
try:
return max(1, int(raw))
except ValueError:
logger.warning(
"CNINFO_RENDER_CONCURRENCY 非法({!r}),回退默认 {}",
raw, DEFAULT_RENDER_CONCURRENCY,
)
return DEFAULT_RENDER_CONCURRENCY
MAX_RENDER_CONCURRENCY = _resolve_render_concurrency()
# 渲染信号量:限制同时存活的 Chromium 实例数(防内存压穿)
_render_sem = asyncio.Semaphore(MAX_RENDER_CONCURRENCY)
# --------------------------------------------------------------------------- #
# 工具函数
@@ -91,7 +117,11 @@ def _build_stock_url(code: str, org_id: str) -> str:
async def _render_page(url: str, timeout_ms: int = 60000,
delay_ms: int = 20) -> str:
"""用 Crawl4AI 渲染 SPA 页面,返回 HTML 字符串。"""
"""用 Crawl4AI 渲染 SPA 页面,返回 HTML 字符串。
通过模块级信号量 _render_sem 限制并发:每次渲染都会启动一个完整的
headless Chromium(约 850MB),必须限流以免同时驻留过多浏览器压穿内存。
"""
from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig
bconf = BrowserConfig(headless=True, verbose=False)
@@ -100,8 +130,11 @@ async def _render_page(url: str, timeout_ms: int = 60000,
page_timeout=timeout_ms,
delay_before_return_html=delay_ms,
)
async with AsyncWebCrawler(config=bconf) as c:
result = await c.arun(url=url, config=rconf)
# 限流:等待空闲渲染槽位(槽位内包含浏览器启动→渲染→关闭全过程)
async with _render_sem:
logger.debug("获得渲染槽位(并发上限 {}): {}", MAX_RENDER_CONCURRENCY, url[:70])
async with AsyncWebCrawler(config=bconf) as c:
result = await c.arun(url=url, config=rconf)
return getattr(result, "html", "") or ""
+10 -2
View File
@@ -404,6 +404,8 @@ from scheduler import (
| `search_stock_events` | query, stock_code, top_k | 按股票代码过滤 |
| `search_sentiment_trend` | query, sentiment, top_k | 情绪趋势 + 统计 |
> 工具参数/返回格式/降级策略详见 [`docs/mcp_tools.md`](mcp_tools.md)
**启动方式**:stdio 模式(Cherry Studio/Claude Code 自动管理进程)或 SSE 模式(调试:`--sse 8765`)
### 3.9 `a_share_cli/` — 统一 CLI
@@ -541,9 +543,15 @@ ReportData (Pydantic)
| 文件 | 格式 | 用途 | 热更新 |
|------|------|------|--------|
| `configs/sources.yaml` | YAML | 14 个新闻源配置 | 每次抓取重读 |
| `configs/llm_models.yaml` | YAML | 4 个 LLM 场景配置 | 每次调用重读 |
| `configs/llm_models.yaml` | YAML | 4 个 LLM 场景配置 | 每次调用重读(mtime 缓存失效) |
| `configs/watchlist.yaml` | YAML | cninfo 公告关注列表 | 每次操作重读 |
| `.env` | dotenv | API Key + 调度/超时/DB 配置 | 重启服务生效 |
| `.env` | dotenv | API Key + 调度/超时/DB 配置 | ~2s 内自动生效(常驻进程无需重启) |
> 热加载实现见 `configs/runtime_env.py`:常驻进程(调度器 / MCP server)启动后
> 会以 2s 周期比对 `.env` 的 `(mtime, size)`,变化即写入 `os.environ`;
> `configs/loader.py` 对 YAML 做同样的 mtime 失效。
> 进程环境里**显式设置且与文件不同**的变量优先(如 `LLM_PROVIDER=qwen ...`),
> `.env` 中删除的键也会同步从环境中移除。
### 5.2 配置优先级(LLM 场景)
+224
View File
@@ -0,0 +1,224 @@
# MCP 工具说明
> A 股 Deep Research 平台通过 MCP(Model Context Protocol)向 Cherry Studio / Claude Code 等 AI 客户端提供**新闻与投资事件语义检索**能力。
>
> 服务入口:`scripts/run_mcp_server.py`(stdio 默认 / SSE 调试)。
> 实现位置:`mcp_server/tools.py`,共 **5 个工具**。
---
## 一、工具总览
| # | 工具 | 参数 | 用途 |
|---|------|------|------|
| 1 | `search_news` | `query`, `top_k` | 通用语义检索财经新闻知识库 |
| 2 | `search_company_news` | `query`, `company`, `top_k` | 按公司名称过滤检索 |
| 3 | `search_industry_news` | `query`, `industry`, `top_k` | 按行业名称过滤检索 |
| 4 | `search_stock_events` | `query`, `stock_code`, `top_k` | 按股票代码过滤投资事件 |
| 5 | `search_sentiment_trend` | `query`, `sentiment`, `top_k` | 情绪倾向过滤 + 分布统计 |
---
## 二、工具详情
### 1. `search_news(query: str, top_k: int = 10) -> str`
通用语义检索,无过滤条件,返回知识库中最相关的新闻。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `query` | str | ✅ | 自然语言查询,如「宁德时代最新动态」「AI 行业政策」 |
| `top_k` | int | ❌ | 返回条数,默认 10 |
**示例**:
```text
search_news("国务院常务会议")
```
**返回**:Markdown 列表,每条含标题、来源、相似度、时间、公司/代码/行业、摘要、URL。
---
### 2. `search_company_news(query: str, company: str, top_k: int = 10) -> str`
按**公司名称**精确过滤(`company_names` 字段,MatchAny)。若精确命中 0 条,自动降级为纯语义搜索(无过滤),保证有结果可用。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `query` | str | ✅ | 自然语言查询 |
| `company` | str | ✅ | 公司名称,如「宁德时代」「贵州茅台」 |
| `top_k` | int | ❌ | 返回条数,默认 10 |
**示例**:
```text
search_company_news("业绩", company="贵州茅台")
```
**注意**:过滤字段来自 LLM 事件抽取的 `company_names`,公司简称/全称需与抽取结果一致;不匹配时自动降级为语义搜索。
---
### 3. `search_industry_news(query: str, industry: str, top_k: int = 10) -> str`
按**行业名称**精确过滤(`industries` 字段,MatchAny)。0 命中时降级为纯语义搜索。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `query` | str | ✅ | 自然语言查询 |
| `industry` | str | ✅ | 行业名,如「动力电池」「白酒」「半导体」 |
| `top_k` | int | ❌ | 返回条数,默认 10 |
**示例**:
```text
search_industry_news("政策", industry="半导体")
```
---
### 4. `search_stock_events(query: str, stock_code: str, top_k: int = 10) -> str`
按**股票代码**过滤(`stock_codes` 字段,MatchAny)。代码自动标准化:去除 `.SH/.SZ` 后缀并统一大写(如 `300750.SZ` → `300750`)。0 命中时降级为纯语义搜索。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `query` | str | ✅ | 自然语言查询 |
| `stock_code` | str | ✅ | 6 位 A 股代码,可带后缀,如「300750」「000001.SZ」 |
| `top_k` | int | ❌ | 返回条数,默认 10 |
**示例**:
```text
search_stock_events("重大事件", stock_code="300750")
```
---
### 5. `search_sentiment_trend(query: str, sentiment: str = "all", top_k: int = 20) -> str`
按**情绪倾向**过滤,并返回情绪分布统计(`🟢利好 / 🔴利空 / ⚪中性` 计数)。
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `query` | str | ✅ | 自然语言查询 |
| `sentiment` | str | ❌ | `positive`(利好) / `negative`(利空) / `neutral`(中性) / `all`(全部,默认) |
| `top_k` | int | ❌ | 返回条数,默认 20 |
**示例**:
```text
search_sentiment_trend("AI算力", sentiment="all")
```
**返回**:头部为统计行(`共 N 条 | 🟢利好 x | 🔴利空 y | ⚪中性 z`),下方为 Markdown 结果列表。
---
## 三、返回格式
所有工具返回 **Markdown 文本**,单条结果结构如下:
```markdown
### 1. 李强主持召开国务院常务会议
- 来源: xwlb | 相似度: 0.759 | ⚪中性
- 时间: 2026-08-31T00:00:00
- 行业: 宏观政策
- 摘要: 国务院常务会议部署灾后救援与地下管网建设…
- URL: https://…
```
字段说明:
| 字段 | 说明 |
|------|------|
| 来源 | 主来源(多源新闻显示「来源A / 来源B [多源]」) |
| 相似度 | 余弦相似度,保留 4 位小数 |
| 时间 | `publish_time`,ISO8601(可为空) |
| 公司/代码/行业 | LLM 事件抽取结果(存在才显示) |
| 摘要 | LLM 生成的一句话事件摘要 |
| URL | 原文链接 |
无结果时返回:`未找到与「{query}」相关的结果。`
---
## 四、工作原理
```
用户输入自然语言查询
→ DashScope 嵌入(query → 1024 维向量)
→ Qdrant 余弦相似度检索(score_threshold ≥ 0.3)
→ 按过滤条件筛选(公司/行业/代码/情绪,MatchAny)
→ Markdown 格式化返回
```
- **嵌入**:复用 M5 embedding 模块(`make_sync_provider`),读取 `.env` 的 `EMBEDDING_PROVIDER`
- **检索**:复用 M6 vectorstore(`VectorStore.query`),本地文件模式 Qdrant(`data/qdrant_storage`)
- **单例**:后端(embedder + vector_store)首次调用时初始化,后续所有工具共用
- **降级策略**:公司/行业/代码过滤精确命中 0 条时,自动降级为无过滤语义搜索,避免空结果
---
## 五、调试与连接
**stdio 模式**(默认,Cherry Studio / Claude Code 自动管理进程):
```bash
uv run python -m scripts.run_mcp_server
```
**SSE 调试模式**:
```bash
uv run python -m scripts.run_mcp_server --sse 8765
# 浏览器访问 http://<host>:8765/sse
```
**Cherry Studio 配置**(设置 → MCP 服务器 → 添加):
```json
{
"mcpServers": {
"a-share-research": {
"command": "uv",
"args": ["run", "python", "-m", "scripts.run_mcp_server"],
"cwd": "/home/pi/news"
}
}
}
```
**Claude Code 配置**(项目根目录 `.mcp.json`,已随仓库提交):
```json
{
"mcpServers": {
"a-share-research": {
"command": "uv",
"args": ["run", "python", "-m", "scripts.run_mcp_server"],
"cwd": "/home/pi/news"
}
}
}
```
---
## 六、测试
```bash
uv run pytest tests/test_mcp.py -q
```
覆盖:工具注册完整性、Markdown 格式化、多源展示、情绪统计、过滤降级、代码标准化。
---
## 七、数据前提
工具检索的是 **Qdrant 知识库**(collection `a_share_news`),数据由定时 pipeline 填充:
- M1 抓取 → M2 提取 → M3 去重 → M4 LLM 事件抽取 → M5 嵌入 → M6 入库
- 无数据时工具返回「未找到」;数据时效取决于最近一次定时任务(07:00/12:00/18:00/22:00)
+23 -3
View File
@@ -507,17 +507,25 @@ tail -f logs/scheduler.log # 文件日志
| 18:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant |
| 22:00 | crawler→xwlb→extractor→dedup→llm→embedding→qdrant |
### 7.4 修改调度时间
### 7.4 修改调度时间(无需重启)
```bash
# 编辑 .env 中的 SCHEDULE_TIMES,格式: HH:MM,HH:MM,...
nano /home/pi/news/.env
# 例: SCHEDULE_TIMES=08:00,14:00,20:00
# 重启生效
sudo systemctl restart a-share-research
# 保存即生效:常驻调度器每 30s 比对一次,自动重新注册定时任务
# 日志确认:grep "定时任务已同步" /home/pi/news/logs/scheduler.log
```
> **配置热加载**:`.env`(模型 / Key / 端点 / 超时 / DB / 调度时间)与
> `configs/llm_models.yaml`(各场景 provider / model / 温度)都是**改完保存即生效**,
> 不需要 `systemctl restart`。调度时间最长 30s 生效,其余配置约 2s 生效。
>
> 只有两种情况需要重启:
> 1. 部署/更新了 Python 代码本身;
> 2. 正在执行中的那一步(子进程)会继续用旧配置跑完,下一步才用新配置。
### 7.5 前台守护模式(调试)
```bash
@@ -571,6 +579,8 @@ Cherry Studio → 设置 → MCP 服务器 → 添加:
| `search_stock_events` | query, stock_code, top_k | `search_stock_events("重大事件", stock_code="300750")` |
| `search_sentiment_trend` | query, sentiment, top_k | `search_sentiment_trend("AI算力", sentiment="all")` |
> 📄 工具完整说明(参数/返回格式/降级策略/工作原理/调试):见 [`docs/mcp_tools.md`](mcp_tools.md)
### 8.4 调试(SSE 模式)
```bash
@@ -701,6 +711,16 @@ uv run a-share stock-report
检查 `.env` 中 `DASHSCOPE_API_KEY` 是否填写。可用 `--provider qwen` 切换到百炼测试。模型名缺失时直接报错,检查 `configs/llm_models.yaml` 中 `event_extraction` 场景的 `model` 字段。
**Q: 日报没有 AI 摘要(ai_summary 为空)?**
先查 `logs/scheduler.log` 是否有 `AI 摘要可能被截断: ... finish_reason=length 实际输出 0 字符`。根因通常是**推理模型的 reasoning token 与正文共用 `max_tokens`**:预算过小时"思考"占满配额,正文一个字都没有。处理办法:
1. 调大 `configs/llm_models.yaml` 中 `daily_report.max_tokens`(默认 4000,YAML 保存即热生效,无需重启);
2. 代码已内置兜底:正文为空时自动加倍预算重试(上限 16000),仍失败才降级为无摘要;
3. 补生成某天摘要:`uv run a-share report --date <YYYYMMDD>`(按 `(report_date, report_type, file_name)` 幂等 upsert,不会新增记录)。
注意:`report` 步骤由调度器**进程内**执行(`scheduler/pipeline.py`),改动 Python 代码后需 `sudo systemctl restart a-share-research` 才会生效;只改 YAML / `.env` 则无需重启。
**Q: Qdrant 搜索不到结果?**
```bash
+3 -6
View File
@@ -5,9 +5,8 @@
from __future__ import annotations
import os
from configs.loader import load_scene_config
from configs.runtime_env import env_get
from .base import AsyncEmbeddingProvider, EmbeddingProvider
from .models import EmbeddingError, EmbeddingProviderType
@@ -18,10 +17,8 @@ from .remote import (
def _read_env(key: str, default: str | None = None) -> str | None:
val = os.environ.get(key)
if val is None or val.strip() == "":
return default
return val.strip()
"""读取环境变量(先热加载 .env,改文件后无需重启进程)。"""
return env_get(key, default)
def resolve_provider_type(provider: str | None = None) -> EmbeddingProviderType:
+3 -5
View File
@@ -10,7 +10,6 @@
from __future__ import annotations
import asyncio
import os
from typing import TYPE_CHECKING
from loguru import logger
@@ -22,16 +21,15 @@ if TYPE_CHECKING:
from sentence_transformers import SentenceTransformer
from configs.loader import load_scene_config
from configs.runtime_env import env_get
LOCAL_DEFAULT_MODEL = "BAAI/bge-m3"
LOCAL_DEFAULT_DIM = 1024
def _read_env(key: str, default: str | None = None) -> str | None:
val = os.environ.get(key)
if val is None or val.strip() == "":
return default
return val.strip()
"""读取环境变量(先热加载 .env,改文件后无需重启进程)。"""
return env_get(key, default)
def _try_import_st() -> type[SentenceTransformer]:
+17 -2
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from typing import Self
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class EmbeddingProviderType(StrEnum):
@@ -19,7 +20,11 @@ class EmbeddingResult(BaseModel):
"""单篇文章的嵌入结果(落盘格式)。"""
url_hash: str = Field(..., description="主键,与 Article.url_hash 一致")
source_id: str = Field(..., description="来源源 id")
source_id: str = Field(..., description="主来源源 id")
sources: list[str] = Field(
default_factory=list,
description="该唯一新闻的全部来源(主源 source_id 居首),来自 M3 去重多源记录",
)
title: str = Field(..., description="原文标题(便于人工检索)")
text: str = Field(
..., description="实际送入 embedder 的文本(已截断/拼接)"
@@ -33,6 +38,16 @@ class EmbeddingResult(BaseModel):
char_count: int = Field(default=0, ge=0, description="text 字符数,便于排查")
publish_time: datetime | None = None
@model_validator(mode="after")
def _ensure_sources(self) -> Self:
"""保证 sources 非空、去重且以主源 source_id 开头(旧产物无字段时兜底)。"""
seen: list[str] = []
for s in [self.source_id, *self.sources]:
if s and s not in seen:
seen.append(s)
self.sources = seen
return self
def short_summary(self) -> str:
return (
f"[{self.source_id}] {self.title[:30]} "
+3 -5
View File
@@ -18,12 +18,12 @@
from __future__ import annotations
import asyncio
import os
from loguru import logger
from openai import AsyncOpenAI, OpenAI
from configs.loader import load_scene_config
from configs.runtime_env import env_get
from .base import AsyncEmbeddingProvider, EmbeddingProvider
from .models import EmbeddingError
@@ -43,10 +43,8 @@ SCENE_EMBEDDING = "embedding"
def _read_env(key: str, default: str | None = None) -> str | None:
val = os.environ.get(key)
if val is None or val.strip() == "":
return default
return val.strip()
"""读取环境变量(先热加载 .env,改文件后无需重启进程)。"""
return env_get(key, default)
def _scene() -> dict:
+14 -5
View File
@@ -19,13 +19,13 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from loguru import logger
from openai import AsyncOpenAI, OpenAI
from configs.loader import load_defaults, load_scene_config
from configs.runtime_env import env_get
# 默认基址
_DEEPSEEK_DEFAULT_BASE = "https://api.deepseek.com"
@@ -53,6 +53,9 @@ class LLMConfig:
timeout_sec: float = DEFAULT_TIMEOUT_SEC
temperature: float = DEFAULT_TEMPERATURE
max_attempts: int = DEFAULT_MAX_ATTEMPTS # 单次任务失败重试次数
# 单次输出预算(可选,场景配置 scenes.<scene>.max_tokens);None = 调用方用内置默认。
# 注意:推理模型(deepseek-v4.1-flash 等)的 reasoning token 与正文共用该预算。
max_tokens: int | None = None
def __post_init__(self) -> None:
if not self.api_key:
@@ -60,10 +63,8 @@ class LLMConfig:
def _read_env(key: str, default: str | None = None) -> str | None:
val = os.environ.get(key)
if val is None or val.strip() == "":
return default
return val.strip()
"""读取环境变量(先热加载 .env,改文件后无需重启进程)。"""
return env_get(key, default)
def _first_env(keys: list[str | None]) -> str | None:
@@ -147,6 +148,7 @@ def load_llm_config(
timeout = _pick_float(sc, dflt, "timeout_sec", "LLM_TIMEOUT_SEC", DEFAULT_TIMEOUT_SEC)
temperature = _pick_float(sc, dflt, "temperature", "LLM_TEMPERATURE", DEFAULT_TEMPERATURE)
max_attempts = _pick_int(sc, "max_attempts", DEFAULT_MAX_ATTEMPTS)
max_tokens = _pick_optional_int(sc, "max_tokens")
return LLMConfig(
provider=p,
@@ -156,6 +158,7 @@ def load_llm_config(
timeout_sec=timeout,
temperature=temperature,
max_attempts=max_attempts,
max_tokens=max_tokens,
)
@@ -182,6 +185,12 @@ def _pick_int(sc: dict, sc_key: str, default: int) -> int:
return int(v) if v is not None else default
def _pick_optional_int(sc: dict, sc_key: str) -> int | None:
"""可选整数场景配置;未配置或非法时返回 None(调用方回退各自内置默认)。"""
v = _num(sc.get(sc_key))
return int(v) if v is not None else None
def make_sync_client(config: LLMConfig) -> OpenAI:
"""构造同步 OpenAI 客户端(指向 DeepSeek/Qwen 兼容端点)。"""
logger.debug(
+27 -6
View File
@@ -9,15 +9,22 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from dotenv import load_dotenv
from loguru import logger
from mcp.server.fastmcp import FastMCP
from configs.loader import config_path
from configs.runtime_env import (
dotenv_path,
ensure_env_loaded,
file_signature,
start_env_watcher,
)
from embedding import make_sync_provider
from vectorstore import SearchFilter, VectorStore, make_qdrant_client
# 加载 .env(API key 等)
load_dotenv()
# 加载 .env 并开启热加载:MCP server 是常驻进程,改配置无需重启
ensure_env_loaded()
start_env_watcher()
# --------------------------------------------------------------------------- #
# 单例(模块加载时初始化,所有工具共用)
@@ -29,17 +36,27 @@ class _Backend:
vector_store: VectorStore
_backend: _Backend | None = None
_backend_sig: tuple[Any, ...] | None = None
def _config_signature() -> tuple[Any, ...]:
"""(.env, llm_models.yaml) 的 mtime 签名;变化即表示需要重建后端。"""
return (file_signature(dotenv_path()), file_signature(config_path()))
def _get_backend() -> _Backend:
global _backend
if _backend is None:
global _backend, _backend_sig
sig = _config_signature()
if _backend is None or sig != _backend_sig:
if _backend is not None:
logger.info("检测到配置变化, 重建 MCP 后端")
emb = make_sync_provider() # 读取 EMBEDDING_PROVIDER 环境变量
logger.info("MCP embedder 就绪: dim={}", emb.dim)
client = make_qdrant_client()
store = VectorStore(client)
logger.info("MCP vector_store 就绪: count={}", store.count())
_backend = _Backend(embedder=emb, vector_store=store)
_backend_sig = sig
return _backend
@@ -63,6 +80,7 @@ def _search(
"title": r.title,
"url": r.url,
"source": r.source_id,
"sources": r.sources if r.sources else ([r.source_id] if r.source_id else []),
"score": round(r.score, 4),
"publish_time": r.publish_time.isoformat() if r.publish_time else None,
"event": {
@@ -100,7 +118,10 @@ def _fmt_results(hits: list[dict[str, Any]], query: str) -> str:
ev.get("sentiment"), ""
)
lines.append(f"### {i}. {h['title']}")
lines.append(f"- 来源: {h['source']} | 相似度: {h['score']} | {sentiment}")
src_list = h.get("sources") or ([h["source"]] if h.get("source") else [])
src_display = " / ".join(src_list)
multi_tag = " [多源]" if len(src_list) > 1 else ""
lines.append(f"- 来源: {src_display}{multi_tag} | 相似度: {h['score']} | {sentiment}")
lines.append(f"- 时间: {h['publish_time'] or '未知'}")
if ev.get("company_names"):
lines.append(f"- 公司: {', '.join(ev['company_names'][:5])}")
+2
View File
@@ -7,6 +7,7 @@
"""
from .pipeline import (
DEFAULT_NEWS_STEPS,
STEP_COMMANDS,
STEP_TIMEOUTS,
PipelineResult,
@@ -16,6 +17,7 @@ from .pipeline import (
)
__all__ = [
"DEFAULT_NEWS_STEPS",
"STEP_COMMANDS",
"STEP_TIMEOUTS",
"PipelineResult",
+45 -28
View File
@@ -12,15 +12,18 @@
from __future__ import annotations
import json
import os
import subprocess
import time
from dataclasses import dataclass, field
from datetime import date, datetime
from datetime import datetime
from pathlib import Path
from loguru import logger
from configs.runtime_env import env_get, env_raw
from .timeutil import today_str
# 断点状态文件(按日期隔离,记录每步骤结果)
DEFAULT_STATE_PATH = Path("data/pipeline/state.json")
@@ -55,14 +58,14 @@ STEP_TIMEOUTS: dict[str, int] = {
"embedding": 300, # M5 向量化
"qdrant": 300, # M6 入库(数据量大时需较长时间)
"report": 30, # 日报生成+上传
"cninfo_crawl": 900, # cninfo watchlist URL 驱动(SPA 渲染,每只约 25s)
"cninfo_crawl": 900, # cninfo watchlist URL 驱动(SPA 渲染;限流 2 并发后实测约 440s)
}
# 步骤对应的 uv run 命令(参数中 {date} 会被替换为实际日期)
# crawler 不支持 --date,固定写当天目录; dedup 不加 --reset 以保持增量
STEP_COMMANDS: dict[str, list[str]] = {
"crawler": ["uv", "run", "python", "-m", "scripts.run_crawler"], # 无 --date
"xwlb": ["uv", "run", "python", "-m", "scripts.run_xwlb"],
"xwlb": ["uv", "run", "python", "-m", "scripts.run_xwlb", "--date", "{date}"], # 处理日目录
"extractor": ["uv", "run", "python", "-m", "scripts.run_extractor", "--date", "{date}"],
"dedup": ["uv", "run", "python", "-m", "scripts.run_dedup", "--date", "{date}"],
"llm": ["uv", "run", "python", "-m", "scripts.run_event_extraction", "--date", "{date}"],
@@ -74,6 +77,14 @@ STEP_COMMANDS: dict[str, list[str]] = {
"cninfo_pdf": ["uv", "run", "a-share", "cninfo", "--enrich-pdf", "--pdf-limit", "100"],
}
# 新闻链路默认步骤:全链路除去 report(单独追加)与 cninfo 独立管道(P1-2:
# 补跑/定时任务若误含 cninfo 三步,会重复执行整套公告管道,且 cninfo_crawl
# 无 --date 参数,补跑历史日期时会静默空转)。
DEFAULT_NEWS_STEPS: list[str] = [
k for k in STEP_COMMANDS
if k not in ("report", "cninfo_crawl", "cninfo_extract", "cninfo_pdf")
]
@dataclass
class StepResult:
@@ -149,20 +160,16 @@ def _llm_scene_desc(scene: str) -> str | None:
try:
from configs.loader import load_scene_config
def _env(key: str) -> str | None:
v = os.environ.get(key)
return v.strip() if v else None
sc = load_scene_config(scene)
p = (sc.get("provider") or _env("LLM_PROVIDER") or "deepseek").lower()
p = (sc.get("provider") or env_get("LLM_PROVIDER") or "deepseek").lower()
if p in ("qwen", "dashscope"):
p = "qwen"
model = sc.get("model")
if not model:
if p == "qwen":
model = _env("QWEN_MODEL") or _env("LLM_MODEL")
model = env_get("QWEN_MODEL") or env_get("LLM_MODEL")
else:
model = _env("DEEPSEEK_MODEL") or _env("LLM_MODEL")
model = env_get("DEEPSEEK_MODEL") or env_get("LLM_MODEL")
if not model:
return None
return f"provider={p}, model={model}"
@@ -182,9 +189,9 @@ def _embedding_desc() -> str | None:
model = sc.get("model")
if not model:
if pt == "dashscope":
model = os.environ.get("DASHSCOPE_EMBEDDING_MODEL") or "text-embedding-v3"
model = env_get("DASHSCOPE_EMBEDDING_MODEL") or "text-embedding-v3"
else:
model = os.environ.get("LOCAL_EMBEDDING_MODEL") or "BAAI/bge-m3"
model = env_get("LOCAL_EMBEDDING_MODEL") or "BAAI/bge-m3"
return f"provider={pt}, model={model}"
except Exception as e: # noqa: BLE001
logger.debug("embedding 描述解析失败: {}", e)
@@ -220,15 +227,30 @@ def run_step(name: str, date_str: str) -> StepResult:
返回: StepResult。
"""
# 补跑保护:抓取类步骤只能产生当天数据(网站首页只含当前内容,历史文章已滚走),
# 补跑历史日期时跳过抓取并告警,复用已有 data/raw/*/{date_str} 数据。
# 注意:仅保护 crawler; xwlb 走 API 支持任意历史日期,无需跳过。
# "今天"按调度时区判定(P1-3),避免系统时区与 cron 时区不一致时错判。
if name == "crawler" and date_str != today_str():
started = datetime.now()
logger.warning(
"补跑模式:首页只含当天内容,无法补抓 {};跳过抓取,复用已有 data/raw/*/{}",
date_str, date_str,
)
return StepResult(
name=name, success=True, elapsed_sec=0.0,
tail_msg="补跑跳过(抓取只能产生当天数据)", started_at=started,
)
# report 步骤:内部函数,不走子进程
# 日报按当天日期生成: 新闻由 _collect_news_events 回溯过去 30 小时,
# 日报按 date_str 日期生成: 新闻由 _collect_news_events 回溯过去 30 小时,
# xwlb 由 _collect_xwlb 固定取前一日(已播出)联播。
if name == "report":
started = datetime.now()
try:
from .reporter import generate_report # noqa: E402
report_date = date.today().strftime("%Y%m%d")
report_date = date_str
logger.info("日报: report_date={} (新闻 30h 回溯, xwlb 前一日)", report_date)
path = generate_report(report_date, upload=True)
@@ -256,14 +278,14 @@ def run_step(name: str, date_str: str) -> StepResult:
# 2. PIPELINE_STEP_TIMEOUT 环境变量 (全局兜底, 覆盖硬编码)
# 3. STEP_TIMEOUTS 硬编码字典 (代码内默认值)
# 4. 1800s (最终兜底)
import os
specific_key = f"TIMEOUT_{name.upper()}"
if specific_key in os.environ:
timeout = int(os.environ[specific_key])
elif "PIPELINE_STEP_TIMEOUT" in os.environ:
timeout = int(os.environ["PIPELINE_STEP_TIMEOUT"])
else:
timeout = STEP_TIMEOUTS.get(name, 1800)
timeout_raw = env_raw(specific_key) or env_raw("PIPELINE_STEP_TIMEOUT")
default_timeout = STEP_TIMEOUTS.get(name, 1800)
try:
timeout = int(timeout_raw) if timeout_raw else default_timeout
except ValueError:
logger.warning("超时配置 {!r} 非法,回退默认 {}s", timeout_raw, default_timeout)
timeout = default_timeout
started = datetime.now()
logger.info("步骤 {} 开始: {}", name, " ".join(full_cmd))
@@ -276,11 +298,6 @@ def run_step(name: str, date_str: str) -> StepResult:
elapsed = (datetime.now() - started).total_seconds()
ok = proc.returncode == 0
# dedup 返回 1 是"重复率过高"(无新文章的正常场景)
if name == "dedup" and proc.returncode == 1:
ok = True
logger.info("dedup 重复率超过阈值(无新数据场景,视为成功)")
tail_msg = f"rc={proc.returncode}" if not ok else ""
if ok:
@@ -321,7 +338,7 @@ def run_pipeline(
记录,跳过连续成功的步骤,从第一个失败/未执行步骤继续。
state_path: 断点状态文件路径(测试可注入)。
"""
names = steps or [k for k in STEP_COMMANDS if k not in ("report", "cninfo_crawl", "cninfo_extract", "cninfo_pdf")]
names = steps or list(DEFAULT_NEWS_STEPS)
result = PipelineResult(started_at=datetime.now())
state = _load_pipeline_state(state_path)
+127 -48
View File
@@ -10,7 +10,6 @@
from __future__ import annotations
import json
import os as _os
import re as _re
import subprocess
import time
@@ -22,14 +21,11 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from llm.client import LLMConfig
from dotenv import load_dotenv
from loguru import logger
from configs.runtime_env import env_get
from report_db.models import EventRow, ReportData # noqa: F401 - 供 _build_report_data 注解使用
# 确保 .env 已加载(模块级常量依赖环境变量)
load_dotenv()
# --------------------------------------------------------------------------- #
# 配置
# --------------------------------------------------------------------------- #
@@ -37,14 +33,64 @@ load_dotenv()
UPLOAD_HOST = "simon@doorcome.cn"
UPLOAD_BASE = "/var/www/html/echart/research"
CNINFO_DAYS_BACK = int(_os.environ.get("STOCK_REPORT_DAYS", "15")) # 与个股日报共用参数, 默认值保持一致
NEWS_DAYS_BACK = 1 # 新闻回溯天数
_MAX_HIGH_EVENTS = 20
# LLM 摘要调用重试参数(环境变量可覆盖)
_LLM_RETRY_TIMES = int(_os.environ.get("LLM_RETRY_TIMES", "3"))
_LLM_RETRY_BACKOFF_SEC = float(_os.environ.get("LLM_RETRY_BACKOFF_SEC", "2.0"))
# AI 摘要输出预算(token)。
# 推理模型(deepseek-v4.1-flash 等)的 reasoning token 与正文共用 max_tokens:
# 预算过小时"思考"会占满配额,正文为空(finish_reason=length、0 字符),
# 日报就会没有 AI 摘要。默认值需为 reasoning 预留余量。
DEFAULT_SUMMARY_MAX_TOKENS = 4000 # 单块 / 合并摘要默认预算
DEFAULT_SUMMARY_CHUNK_MAX_TOKENS = 2000 # 分块摘要默认预算
MAX_SUMMARY_MAX_TOKENS = 16000 # 正文为空时预算升级上限
_MAX_BUDGET_ESCALATIONS = 2 # 正文为空时最多升级预算次数
def _env_int(key: str, default: int) -> int:
"""读取整数环境变量(热加载 .env);非法值回退默认。"""
try:
return int(env_get(key, str(default)) or default)
except ValueError:
logger.warning("环境变量 {} 不是整数, 回退默认 {}", key, default)
return default
def _env_float(key: str, default: float) -> float:
"""读取浮点环境变量(热加载 .env);非法值回退默认。"""
try:
return float(env_get(key, str(default)) or default)
except ValueError:
logger.warning("环境变量 {} 不是数字, 回退默认 {}", key, default)
return default
def _cninfo_days_back() -> int:
"""公告/调研回溯天数(与个股日报共用 STOCK_REPORT_DAYS)。"""
return _env_int("STOCK_REPORT_DAYS", 15)
def _llm_retry_times() -> int:
return _env_int("LLM_RETRY_TIMES", 3)
def _llm_retry_backoff_sec() -> float:
return _env_float("LLM_RETRY_BACKOFF_SEC", 2.0)
def _summary_max_tokens(config: LLMConfig | None = None) -> int:
"""摘要输出预算:场景配置 max_tokens(热更新)> 代码内置默认。"""
value = getattr(config, "max_tokens", None)
try:
return int(value) if value else DEFAULT_SUMMARY_MAX_TOKENS
except (TypeError, ValueError):
logger.warning("场景 max_tokens 非法({!r}), 回退默认 {}", value, DEFAULT_SUMMARY_MAX_TOKENS)
return DEFAULT_SUMMARY_MAX_TOKENS
def _chunk_max_tokens(config: LLMConfig | None = None) -> int:
"""分块摘要预算:不超过单块/合并预算,避免小块调用过度消耗。"""
return min(_summary_max_tokens(config), DEFAULT_SUMMARY_CHUNK_MAX_TOKENS)
# 日报新闻回溯窗口(小时):07:00 生成当日日报时覆盖昨日全天至今晨的新闻
_NEWS_LOOKBACK_HOURS = 30
@@ -181,12 +227,14 @@ def _collect_news_events(day_str: str) -> dict[str, Any]:
}
def _collect_cninfo_events(today_str: str, days_back: int = CNINFO_DAYS_BACK) -> dict[str, Any]:
def _collect_cninfo_events(today_str: str, days_back: int | None = None) -> dict[str, Any]:
"""收集近 N 日 cninfo 公告/调研/互动(直接从 processed 数据读取,不依赖 M4 事件抽取)。
cninfo 公告/调研数据已结构化(stock_code/name/title/time/type),
无需经过 LLM 事件抽取即可直接用于日报。
"""
if days_back is None:
days_back = _cninfo_days_back()
today = datetime.strptime(today_str, "%Y%m%d")
since_str = (today - timedelta(days=days_back)).strftime("%Y-%m-%d")
wl_codes = _load_watchlist_codes()
@@ -514,7 +562,7 @@ def _generate_ai_summary(news: dict, cninfo: dict, day_str: str,
# 公告/调研
if cninfo["high"]:
lines.append(f"## 近 {CNINFO_DAYS_BACK} 日重要公告/调研 ({len(cninfo['high'])} 条)")
lines.append(f"## 近 {_cninfo_days_back()} 日重要公告/调研 ({len(cninfo['high'])} 条)")
for e in cninfo["high"][:8]:
ev = e.get("event", {})
lines.append(f"- [{ev.get('event_type', '公司公告')}] {e['title']}")
@@ -570,7 +618,7 @@ def _llm_summarize(client, config: LLMConfig, lines: list[str], day_str: str) ->
{chr(10).join(chunk)}
直接输出要点列表:"""
result = _llm_call(client, config, prompt, max_tokens=800)
result = _llm_call(client, config, prompt, max_tokens=_chunk_max_tokens(config))
if result:
partials.append(result)
logger.info("AI 摘要: 分块 {}/{} 完成 ({} 字)", i, len(chunks), len(result))
@@ -589,14 +637,14 @@ def _llm_summarize(client, config: LLMConfig, lines: list[str], day_str: str) ->
请合并为要点总结,每条一行以 "- " 开头,要求:
1. 前 3 条为影响最大的事件,说明为什么重要
2. 汇总近 {CNINFO_DAYS_BACK} 日公司公告/调研核心信息
2. 汇总近 {_cninfo_days_back()} 日公司公告/调研核心信息
3. 市场情绪基调(利好/利空/中性)
4. 值得持续关注的行业或主题
5. 纯要点,不要开场白/结束语
6. 总字数 500 字以内
直接输出要点列表:"""
return _llm_call(client, config, merge_prompt, max_tokens=1500)
return _llm_call(client, config, merge_prompt, max_tokens=_summary_max_tokens(config))
def _build_prompt(lines: list[str], day_str: str) -> str:
@@ -607,7 +655,7 @@ def _build_prompt(lines: list[str], day_str: str) -> str:
请用要点总结,每条一行,以 "- " 开头,要求:
1. 前 3 条为过去 24 小时影响最大的事件(优先参考新闻联播中的重大政策信号),说明为什么重要
2. 汇总近 {CNINFO_DAYS_BACK} 日重要公司公告/调研的核心信息
2. 汇总近 {_cninfo_days_back()} 日重要公司公告/调研的核心信息
3. 市场情绪基调(利好/利空/中性)
4. 值得持续关注的行业或主题
5. 纯要点,不要开场白/结束语/标题
@@ -616,45 +664,70 @@ def _build_prompt(lines: list[str], day_str: str) -> str:
直接输出要点列表:"""
def _llm_call(client, config: LLMConfig, prompt: str, max_tokens: int = 1500) -> str:
"""单次 LLM 调用(带重试),返回 strip 后的文本。
def _call_once(client, config: LLMConfig, prompt: str, max_tokens: int) -> tuple[str, str | None]:
"""单次 LLM 调用,返回 (正文, finish_reason)。异常由调用方处理。"""
resp = client.chat.completions.create(
model=config.model,
messages=[
{"role": "system", "content": "你是 A 股日报撰写助手,输出简洁、有洞察的新闻摘要。"},
{"role": "user", "content": prompt},
],
temperature=config.temperature,
max_tokens=max_tokens,
)
choice = resp.choices[0]
content = (choice.message.content or "").strip()
return content, getattr(choice, "finish_reason", None)
config 为 llm.client.LLMConfig(daily_report 场景),提供 model / temperature。
失败按指数退避重试 `_LLM_RETRY_TIMES` 次(默认 3),全部失败则抛出最后一次异常。
若 finish_reason 为 'length' 则说明达到 max_tokens 上限被截断。
def _llm_call(client, config: LLMConfig, prompt: str, max_tokens: int | None = None) -> str:
"""单次 LLM 调用(带重试 + 空正文预算升级),返回 strip 后的文本。
config 为 llm.client.LLMConfig(daily_report 场景),提供 model / temperature /
max_tokens(未配置 max_tokens 时用 DEFAULT_SUMMARY_MAX_TOKENS)。
网络等异常按指数退避重试 `LLM_RETRY_TIMES` 次(默认 3),全部失败则抛出最后一次异常。
若 finish_reason 为 'length' 且正文为空(推理模型 reasoning 占满预算),
自动加倍预算重试,最多 `_MAX_BUDGET_ESCALATIONS` 次、上限 MAX_SUMMARY_MAX_TOKENS;
此时返回空字符串而非抛异常,由调用方降级。
"""
retry_times = _llm_retry_times()
backoff_sec = _llm_retry_backoff_sec()
budget = max_tokens or _summary_max_tokens(config)
last_exc: Exception | None = None
for attempt in range(_LLM_RETRY_TIMES):
escalations = 0
for attempt in range(retry_times):
try:
resp = client.chat.completions.create(
model=config.model,
messages=[
{"role": "system", "content": "你是 A 股日报撰写助手,输出简洁、有洞察的新闻摘要。"},
{"role": "user", "content": prompt},
],
temperature=config.temperature,
max_tokens=max_tokens,
)
content = (resp.choices[0].message.content or "").strip()
finish = getattr(resp.choices[0], "finish_reason", None)
if finish == "length":
logger.warning(
"AI 摘要可能被截断: max_tokens={} finish_reason=length 实际输出 {} 字符",
max_tokens, len(content),
)
return content
content, finish = _call_once(client, config, prompt, budget)
except Exception as e:
last_exc = e
if attempt < _LLM_RETRY_TIMES - 1:
wait = _LLM_RETRY_BACKOFF_SEC * (2 ** attempt)
if attempt < retry_times - 1:
wait = backoff_sec * (2 ** attempt)
logger.warning(
"AI 摘要 LLM 调用失败(第 {}/{} 次): {}; {} 秒后重试",
attempt + 1, _LLM_RETRY_TIMES, e, round(wait, 2),
attempt + 1, retry_times, e, round(wait, 2),
)
time.sleep(wait)
logger.error("AI 摘要 LLM 调用重试 {} 次仍失败: {}", _LLM_RETRY_TIMES, last_exc)
assert last_exc is not None
raise last_exc
continue
if finish == "length":
logger.warning(
"AI 摘要可能被截断: max_tokens={} finish_reason=length 实际输出 {} 字符",
budget, len(content),
)
# 正文为空 = 推理占满预算;加倍预算重试(不改动调用方传入的显式预算以外逻辑)
if not content and escalations < _MAX_BUDGET_ESCALATIONS:
budget = min(budget * 2, MAX_SUMMARY_MAX_TOKENS)
escalations += 1
logger.warning("AI 摘要正文为空(推理占满预算), 提升 max_tokens 至 {} 重试", budget)
continue
return content
if last_exc is not None:
logger.error("AI 摘要 LLM 调用重试 {} 次仍失败: {}", retry_times, last_exc)
raise last_exc
logger.warning("AI 摘要 {} 次尝试仍未获得正文, 返回空(日报降级为无 AI 摘要)", retry_times)
return ""
# --------------------------------------------------------------------------- #
@@ -807,7 +880,10 @@ def _render_event_table(events: list[dict], show_source: bool = True,
imp = ev.get("importance", 0)
imp_cls = f"imp-{imp}" if imp >= 4 else ""
title = e["title"][:70]
src = _source_name(e.get("source_id", ""))
src_ids = e.get("sources") or [e.get("source_id", "")]
src = " / ".join(_source_name(s) for s in src_ids if s)
if len(src_ids) > 1:
src = f'<span title="多源新闻">{src}</span> 📰'
codes_in_event = {c.strip().split(".")[0] for c in (ev.get("stock_codes") or [])}
star = "⭐ " if codes_in_event & wl_codes else ""
code_str = f" <small>({','.join(list(codes_in_event)[:3])})</small>" if codes_in_event else ""
@@ -961,7 +1037,7 @@ def _render_html(news: dict, cninfo: dict, pipeline: dict,
news_table=news_table,
cninfo_high_count=len(cninfo["high"]),
cninfo_threshold=cninfo.get("hi_threshold", 4),
cninfo_days=CNINFO_DAYS_BACK,
cninfo_days=_cninfo_days_back(),
cninfo_table=cninfo_table,
raw_total=pipeline["raw_total"],
raw_total_24h=pipeline.get("raw_total_24h", pipeline["raw_total"]),
@@ -1053,13 +1129,16 @@ def generate_report(day_str: str | None = None, *, upload: bool = True) -> int |
`upload` 参数保留以兼容 scheduler/pipeline.py 调用,已无实际作用。
返回 report_id(成功)或 None(无数据/失败)。
"""
day_str = day_str or date.today().strftime("%Y%m%d")
# 兜底日期按调度时区取(P1-3),与 pipeline 传入的 date_str 语义一致
if not day_str:
from .timeutil import today_str
day_str = today_str()
logger.info("生成日报: {}", day_str)
# 收集数据
try:
news = _collect_news_events(day_str)
cninfo = _collect_cninfo_events(day_str, days_back=CNINFO_DAYS_BACK)
cninfo = _collect_cninfo_events(day_str, days_back=_cninfo_days_back())
pipeline = _collect_pipeline_stats(day_str)
xwlb = _collect_xwlb(day_str)
except Exception as e:
+36
View File
@@ -0,0 +1,36 @@
"""调度时区工具。
背景(P1-3):
定时任务 cron 触发时区与"今天"的判定必须一致。此前 cron 用
Asia/Shanghai,而 `date.today()` / `datetime.now()` 取系统时区——
若系统时区不是 Asia/Shanghai(如 UTC),07:00 上海(=前一日 23:00 UTC)
触发时 `date.today()` 会返回错误日期,整条 pipeline 落在错日目录。
统一入口:
schedule_tz(): 调度时区(env SCHEDULE_TZ 可覆盖,默认 Asia/Shanghai)
today_str(): 按调度时区返回 YYYYMMDD
"""
from __future__ import annotations
import os
from datetime import datetime
from zoneinfo import ZoneInfo
# 默认调度时区(与 .env 的 SCHEDULE_TIMES 语义一致)
DEFAULT_SCHEDULE_TZ = "Asia/Shanghai"
def schedule_tz() -> ZoneInfo:
"""调度时区(env SCHEDULE_TZ 可覆盖,默认 Asia/Shanghai)。"""
return ZoneInfo(os.environ.get("SCHEDULE_TZ") or DEFAULT_SCHEDULE_TZ)
def now() -> datetime:
"""当前时刻(调度时区,aware)。"""
return datetime.now(schedule_tz())
def today_str() -> str:
"""按调度时区返回今天的 YYYYMMDD。"""
return now().strftime("%Y%m%d")
+135
View File
@@ -0,0 +1,135 @@
"""一次性回填脚本:为 Qdrant 存量**多源** point 的 payload 补充 sources 字段。
背景:
M5/M6 旧链路未把 M3 多源记录写入 EmbeddingResult / Qdrant payload,
导致知识库检索只能看到单源。新链路修复后,此脚本为存量数据补齐。
范围(关键设计):
本地文件模式 Qdrant 写入逐点成本 ≈0.4~0.65s,全量 3.3 万条回填需数小时,
且本地模式为单进程锁,长时间占用会阻塞后续定时调度。
但**只有多源 point 需要 sources 字段**——单源 point 的展示层
(CLI/MCP/日报)会在 sources 为空时兜底显示 source_id。
因此本脚本仅回填指纹库中 source_ids 长度 > 1 的多源记录,分钟级完成。
数据来源:
指纹库 data/dedup/fingerprints.sqlite3 的 source_ids 列(权威多源记录)。
用法:
uv run python -m scripts.backfill_qdrant_sources # 实际执行
uv run python -m scripts.backfill_qdrant_sources --dry-run # 只统计不写入
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
from loguru import logger
from vectorstore import DEFAULT_COLLECTION, make_qdrant_client
from vectorstore.client import url_hash_to_uuid
def _setup_logger(level: str) -> None:
logger.remove()
logger.add(
sys.stderr,
level=level,
format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
)
def _load_multi_source(db_path: Path) -> dict[str, list[str]]:
"""从指纹库读多源记录: url_hash -> sources(仅 source_ids 长度 > 1)。"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT url_hash, source_id, source_ids FROM fingerprints"
).fetchall()
conn.close()
result: dict[str, list[str]] = {}
for r in rows:
if not r["source_ids"]:
continue
try:
val = json.loads(r["source_ids"])
except (TypeError, ValueError):
continue
if not isinstance(val, list):
continue
sources = [s for s in val if s]
if len(sources) <= 1:
continue
# 保证主源居首
if r["source_id"] and r["source_id"] in sources:
sources = [r["source_id"], *[s for s in sources if s != r["source_id"]]]
result[r["url_hash"]] = sources
return result
def main() -> int:
parser = argparse.ArgumentParser(description="Qdrant 存量多源 payload 回填 sources 字段")
parser.add_argument("--db", default="data/dedup/fingerprints.sqlite3",
help="指纹库路径")
parser.add_argument("--collection", default=DEFAULT_COLLECTION)
parser.add_argument("--dry-run", action="store_true", help="只统计不写入")
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
_setup_logger(args.log_level)
multi = _load_multi_source(Path(args.db))
logger.info("指纹库多源记录: {} 条", len(multi))
if not multi:
logger.info("无多源记录,退出")
return 0
# 按 sources 值分组(同值一次 set_payload)
groups: dict[str, list[str]] = defaultdict(list)
for url_hash, sources in multi.items():
groups[json.dumps(sources, ensure_ascii=False)].append(url_hash)
if args.dry_run:
logger.info("待回填: {} 组 / {} 条多源 point (--dry-run 不写入)",
len(groups), len(multi))
return 0
client = make_qdrant_client()
updated = 0
missing = 0
for sources_json, url_hashes in groups.items():
payload = {"sources": json.loads(sources_json)}
ids = [url_hash_to_uuid(h) for h in url_hashes]
try:
client.set_payload(
collection_name=args.collection,
payload=payload,
points=ids,
)
updated += len(ids)
except KeyError:
# 部分 point 已不在集合中(如指纹库保留但向量库已清理),逐点重试
for uid in ids:
try:
client.set_payload(
collection_name=args.collection,
payload=payload,
points=[uid],
)
updated += 1
except KeyError:
missing += 1
logger.info("回填完成: 更新 {} 条多源 point,{} 条不存在于集合",
updated, missing)
client.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+45 -4
View File
@@ -22,7 +22,7 @@ import argparse
import json
import sys
from collections import Counter
from datetime import date
from datetime import date, datetime
from pathlib import Path
from loguru import logger
@@ -31,6 +31,9 @@ from pydantic import ValidationError
from dedup import Deduper
from extractor import Article
# 重复率告警阈值(超过则输出 WARNING 日志;--strict 验收模式下作为返回码门槛)
_DUP_RATE_THRESHOLD = 0.05
def _setup_logger(level: str) -> None:
logger.remove()
@@ -200,6 +203,11 @@ def main() -> int:
parser.add_argument("--simhash-threshold", type=int, default=3)
parser.add_argument("--window-days", type=int, default=30)
parser.add_argument("--reset", action="store_true", help="处理前清空指纹库")
parser.add_argument(
"--strict", action="store_true",
help="验收模式:重复率超过阈值(5%)时返回 1。"
"默认为生产模式,重复率仅作统计告警,不影响退出码",
)
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
@@ -243,6 +251,7 @@ def main() -> int:
# {url_hash: [source_id, ...]},配合 uniques/{url_hash}.json 的 sources 字段
# 与指纹库 source_ids 列,提供「一条唯一新闻多个来源」的完整记录。
sources_path = out_root / args.date / "sources.json"
sources_path.parent.mkdir(parents=True, exist_ok=True)
sources_path.write_text(
json.dumps(
{k: v for k, v in sources_map.items() if v},
@@ -260,15 +269,47 @@ def main() -> int:
total = total_uniq + total_dup
rate = total_dup / max(total, 1)
logger.info(
log_fn = logger.warning if rate > _DUP_RATE_THRESHOLD else logger.info
log_fn(
"全部完成: 唯一 {} / 重复 {} (重复率 {:.1%}) layers={}",
total_uniq,
total_dup,
rate,
dict(total_layers),
)
# 验收门槛: ≤ 5%
return 0 if rate <= 0.05 or total == 0 else 1
# 统计快照: data/deduped/{date}/stats.json(原子写),供运维排查与监控
stats_payload = {
"date": args.date,
"generated_at": datetime.now().isoformat(),
"unique": total_uniq,
"duplicates": total_dup,
"total": total,
"dup_rate": round(rate, 4),
"dup_rate_threshold": _DUP_RATE_THRESHOLD,
"layers": dict(total_layers),
"fingerprint_total": deduper.stats().total,
}
stats_path = out_root / args.date / "stats.json"
stats_path.parent.mkdir(parents=True, exist_ok=True)
tmp = stats_path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(stats_payload, ensure_ascii=False, indent=2),
encoding="utf-8")
tmp.replace(stats_path)
if rate > _DUP_RATE_THRESHOLD and total > 0:
logger.warning(
"重复率 {:.1%} 超过阈值 {:.0%}(唯一 {},重复 {})。"
"同日多次调度/跨源转载属常见现象;若为当日首次处理仍异常偏高,"
"请检查指纹库与抓取源(详见 {})",
rate, _DUP_RATE_THRESHOLD, total_uniq, total_dup, stats_path,
)
# 生产模式:重复率是统计指标,不影响退出码(执行成功即 0);
# 验收模式(--strict):保留 M3 验收门槛(重复率 ≤ 5%),超标返回 1。
if args.strict and total > 0 and rate > _DUP_RATE_THRESHOLD:
return 1
return 0
if __name__ == "__main__":
+19 -10
View File
@@ -89,8 +89,11 @@ def _build_text_from_event(
event_path: Path,
processed_root: Path,
day: str,
) -> tuple[str, Article, str | None] | None:
"""从 ExtractedEvent JSON 构造嵌入文本与文章元数据。"""
) -> tuple[str, Article, str | None, list[str] | None] | None:
"""从 ExtractedEvent JSON 构造嵌入文本与文章元数据。
返回 (text, article, summary, sources);sources 为 M3 多源记录。
"""
try:
obj: dict[str, Any] = json.loads(event_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
@@ -98,6 +101,7 @@ def _build_text_from_event(
return None
article, head, summary = _from_event_dict(obj)
sources = obj.get("sources") or None
# 真正的正文要去 processed/ 找
real_article = _load_article_by_hash(processed_root, day, article.url_hash)
if real_article is None:
@@ -110,17 +114,21 @@ def _build_text_from_event(
update={"publish_time": article.publish_time or real_article.publish_time}
)
text = compose_text(real_article, head=head, summary=summary)
return text, real_article, summary
return text, real_article, summary, sources
def _build_text_from_article(article_path: Path) -> tuple[str, Article, str | None] | None:
def _build_text_from_article(
article_path: Path,
) -> tuple[str, Article, str | None, list[str] | None] | None:
try:
obj = json.loads(article_path.read_text(encoding="utf-8"))
article = Article.model_validate(obj)
except (json.JSONDecodeError, ValidationError) as e:
logger.warning("跳过损坏 article 文件 {}: {}", article_path, e)
return None
return compose_text(article), article, None
# deduped uniques JSON 含 sources 多源字段;processed 产物无此字段 → None
sources = obj.get("sources") or None
return compose_text(article), article, None, sources
def _collect_inputs(args: argparse.Namespace) -> list[tuple[Path, str]]:
@@ -192,8 +200,8 @@ async def _run(args: argparse.Namespace) -> int:
return 0
logger.info("待嵌入文章数: {} (跳过已处理 {}; input={})", len(files), skipped, args.input)
# 准备每篇文本
prepared: list[tuple[str, Article, str | None]] = []
# 准备每篇文本: (文本, 文章, 摘要, 多源列表)
prepared: list[tuple[str, Article, str | None, list[str] | None]] = []
for fp, kind in files:
if kind == "event":
built = _build_text_from_event(fp, Path(args.processed_root), args.date)
@@ -234,12 +242,12 @@ async def _run(args: argparse.Namespace) -> int:
batch_size = args.batch_size
for i in range(0, len(prepared), batch_size):
batch = prepared[i : i + batch_size]
texts = [t for t, _, _ in batch]
texts = [t for t, _, _, _ in batch]
try:
vectors = await provider.embed_batch(texts)
except EmbeddingError as e:
logger.warning("批 {} 嵌入失败: {}", i // batch_size, e)
for _, art, _ in batch:
for _, art, _, _ in batch:
fail_cnt += 1
with failed_path.open("a", encoding="utf-8") as f:
f.write(
@@ -252,7 +260,7 @@ async def _run(args: argparse.Namespace) -> int:
)
continue
for (text, article, _summary), vec in zip(batch, vectors, strict=True):
for (text, article, _summary, sources), vec in zip(batch, vectors, strict=True):
if len(vec) != provider.dim:
logger.warning(
"维度不一致 url_hash={} 实际={} 预期={}",
@@ -261,6 +269,7 @@ async def _run(args: argparse.Namespace) -> int:
result = EmbeddingResult(
url_hash=article.url_hash,
source_id=article.source_id,
sources=sources or [],
title=article.title,
text=text,
vector=vec,
+21
View File
@@ -115,9 +115,30 @@ def _process_one(rec: dict, raw_dir: Path, out_dir: Path,
logger.warning("提取失败 {} {}: {}", rec["source_id"], rec["url"], e.reason)
return None
# xwlb 的假 HTML 无时间节点,从 url(xwlb://YYYY-MM-DD/sid)兜底解析真实播出日
if src_id == "xwlb" and article.publish_time is None:
article = _fill_xwlb_publish_time(article)
return _save_article(article, out_dir)
def _fill_xwlb_publish_time(article: Article) -> Article:
"""从 xwlb url 解析播出日期填充 publish_time,失败时原样返回。"""
import re as _re
m = _re.match(r"xwlb://(\d{4}-\d{2}-\d{2})", article.url)
if not m:
return article
try:
pt = datetime.strptime(m.group(1), "%Y-%m-%d")
except ValueError:
return article
return article.model_copy(update={
"publish_time": pt,
"publish_time_raw": m.group(1),
})
def _process_cninfo(rec: dict, html_path: Path, out_dir: Path) -> Article | None:
"""处理 cninfo 公告记录:从 meta JSON 解析结构化数据。"""
import re
+25 -2
View File
@@ -52,18 +52,26 @@ def _result_to_point(
) -> dict[str, Any] | None:
"""把 EmbeddingResult JSON dict 转换为 Qdrant Point 格式。
Payload 包含 title/url/source_id/publish_time/event/计数 等。
Payload 包含 title/url/source_id/sources/publish_time/event/计数 等。
事件字段优先从 M4 ExtractedEvent 补充(EmbeddingResult 本身不含 event)。
sources 多源字段优先取 M5 产物,缺失时回查 M4,再兜底 [主源]。
"""
vector = obj.get("vector")
if not vector:
return None
url_hash = obj["url_hash"]
source_id = obj.get("source_id") or ""
sources = obj.get("sources") or None
if not sources and events_dir is not None:
sources = _load_sources_from_m4(events_dir, url_hash)
if not sources:
sources = [source_id] if source_id else []
payload = {
"url_hash": url_hash,
"title": obj.get("title") or "",
"url": obj.get("url") or "",
"source_id": obj.get("source_id") or "",
"source_id": source_id,
"sources": sources,
"publish_time": obj.get("publish_time"),
"char_count": obj.get("char_count"),
"word_count": obj.get("word_count"),
@@ -93,6 +101,21 @@ def _result_to_point(
return {"id": url_hash, "vector": vector, "payload": payload}
def _load_sources_from_m4(events_dir: Path, url_hash: str) -> list[str] | None:
"""从 M4 ExtractedEvent JSON 读取 sources 多源字段(旧 M5 产物兜底用)。"""
event_file = events_dir / f"{url_hash}.json"
if not event_file.is_file():
return None
try:
obj = json.loads(event_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return None
sources = obj.get("sources")
if isinstance(sources, list) and sources:
return [s for s in sources if s]
return None
def _load_event_from_m4(events_dir: Path, url_hash: str) -> dict[str, Any] | None:
"""从 M4 ExtractedEvent JSON 中提取事件 payload 子集。"""
event_file = events_dir / f"{url_hash}.json"
+158 -72
View File
@@ -14,17 +14,19 @@
from __future__ import annotations
import argparse
import json
import signal
import sys
from datetime import date, datetime
from pathlib import Path
from typing import Any
from dotenv import load_dotenv
from loguru import logger
from scheduler import STEP_COMMANDS, run_pipeline
from configs.runtime_env import ensure_env_loaded, env_raw, start_env_watcher
from scheduler import DEFAULT_NEWS_STEPS, run_pipeline
from scheduler.stock_reporter import generate_all_stock_reports
from scheduler.timeutil import now as tz_now
from scheduler.timeutil import schedule_tz, today_str
def _setup_logger(level: str) -> None:
@@ -58,6 +60,131 @@ def _parse_schedule_times(raw: str) -> list[tuple[int, int]]:
return out
def _parse_hhmm(raw: str, default: tuple[int, int]) -> tuple[int, int]:
"""解析 "HH:MM";非法或越界时记警告并返回 default。"""
parts = raw.split(":")
try:
hour = int(parts[0])
minute = int(parts[1]) if len(parts) > 1 else 0
except (ValueError, IndexError):
logger.warning("时间格式错误: {!r},改用默认 {:02d}:{:02d}", raw, *default)
return default
if not (0 <= hour <= 23 and 0 <= minute <= 59):
logger.warning("时间越界: {!r},改用默认 {:02d}:{:02d}", raw, *default)
return default
return hour, minute
def _scheduled_pipeline(steps: list[str] | None = None) -> None:
"""定时触发的全链路:每次触发时按调度时区重新计算日期。"""
run_pipeline(today_str(), steps=steps)
def _desired_jobs() -> dict[str, dict[str, Any]]:
"""按当前环境变量算出「期望的」定时任务集合。
每次调用都通过 env_get 读取,所以改 .env 后无需重启即可反映到 _sync_jobs。
"""
jobs: dict[str, dict[str, Any]] = {}
times = _parse_schedule_times(
env_raw("SCHEDULE_TIMES", "07:00,12:00,18:00,22:00") or ""
)
if times:
first = min(times)
for hour, minute in times:
with_report = (hour, minute) == first
jobs[f"pipeline_{hour:02d}{minute:02d}"] = {
"kind": "pipeline",
"hour": hour,
"minute": minute,
"steps": list(DEFAULT_NEWS_STEPS) + (["report"] if with_report else []),
"name": f"全链路 {'+日报' if with_report else ''} {hour:02d}:{minute:02d}",
}
cninfo_h, cninfo_m = _parse_hhmm(
env_raw("CNINFO_SCHEDULE_TIME", "06:30") or "06:30", (6, 30)
)
jobs["pipeline_cninfo"] = {
"kind": "pipeline",
"hour": cninfo_h,
"minute": cninfo_m,
"steps": ["cninfo_crawl", "cninfo_extract", "cninfo_pdf",
"dedup", "llm", "embedding", "qdrant"],
"name": f"cninfo 公告管道 {cninfo_h:02d}:{cninfo_m:02d}",
}
# STOCK_REPORT_TIME 缺省 07:30;显式留空表示禁用
stock_raw = env_raw("STOCK_REPORT_TIME")
if stock_raw is None:
stock_raw = "07:30"
if stock_raw:
stock_h, stock_m = _parse_hhmm(stock_raw, (7, 30))
jobs["stock_report"] = {
"kind": "stock",
"hour": stock_h,
"minute": stock_m,
"name": f"个股日报 {stock_h:02d}:{stock_m:02d}",
}
return jobs
_jobs_sig: str | None = None
def _sync_jobs(scheduler: Any) -> bool:
"""把「期望任务」同步到 APScheduler;只在配置变化时增删。返回是否变更。
以 ``_`` 开头的内部任务(如配置热同步自身)不参与增删。
"""
global _jobs_sig
from apscheduler.triggers.cron import CronTrigger # noqa: E402
desired = _desired_jobs()
if not any(jid.startswith("pipeline_") for jid in desired):
logger.error("SCHEDULE_TIMES 为空或全部非法,保留现有定时任务不改动")
return False
sig = json.dumps(desired, sort_keys=True, ensure_ascii=False)
if sig == _jobs_sig:
return False
for jid, spec in desired.items():
trigger = CronTrigger(
hour=spec["hour"], minute=spec["minute"], timezone=str(schedule_tz())
)
if spec["kind"] == "pipeline":
scheduler.add_job(
_scheduled_pipeline,
trigger=trigger,
kwargs={"steps": spec["steps"]},
id=jid,
name=spec["name"],
replace_existing=True,
)
else:
scheduler.add_job(
generate_all_stock_reports,
trigger=trigger,
id=jid,
name=spec["name"],
replace_existing=True,
)
for job in scheduler.get_jobs():
if job.id.startswith("_") or job.id in desired:
continue
scheduler.remove_job(job.id)
_jobs_sig = sig
logger.info(
"定时任务已同步: {}",
", ".join(f"{s['name']}" for s in desired.values()),
)
return True
def _once(args: argparse.Namespace) -> int:
"""单次执行模式。
@@ -69,82 +196,26 @@ def _once(args: argparse.Namespace) -> int:
if args.resume and args.steps:
logger.error("--resume 与 --steps 不能同时使用(断点续跑针对全链路)")
return 2
run_pipeline(args.date, steps=steps, resume=args.resume)
run_pipeline(args.date or today_str(), steps=steps, resume=args.resume)
return 0
def _daemon(args: argparse.Namespace) -> int:
"""守护进程模式(APScheduler)。"""
import os
from apscheduler.schedulers.background import BackgroundScheduler # noqa: E402
from apscheduler.triggers.cron import CronTrigger # noqa: E402
times_raw = os.environ.get("SCHEDULE_TIMES", "07:00,12:00,18:00,22:00")
times = _parse_schedule_times(times_raw)
times = _parse_schedule_times(
env_raw("SCHEDULE_TIMES", "07:00,12:00,18:00,22:00") or ""
)
if not times:
logger.error("SCHEDULE_TIMES 为空或全部非法,无法启动定时任务")
return 2
# 找出最早的时间(当天首次运行),仅该次追加日报步骤
sorted_times = sorted(times)
first_hour, first_minute = sorted_times[0] if sorted_times else (0, 0)
scheduler = BackgroundScheduler()
# 包装函数:每次触发时重新计算日期,避免 date.today() 在注册时冻结。
def _scheduled_pipeline(steps: list[str] | None = None) -> None:
run_pipeline(date.today().strftime("%Y%m%d"), steps=steps)
for hour, minute in times:
trigger = CronTrigger(hour=hour, minute=minute, timezone="Asia/Shanghai")
is_first = (hour == first_hour and minute == first_minute)
job_kwargs: dict | None = None
if is_first:
job_kwargs = {
"steps": [k for k in STEP_COMMANDS if k not in ("report", "cninfo_crawl", "cninfo_extract", "cninfo_pdf")] + ["report"]
}
scheduler.add_job(
_scheduled_pipeline,
trigger=trigger,
kwargs=job_kwargs,
id=f"pipeline_{hour:02d}{minute:02d}",
name=f"全链路 {'+日报' if is_first else ''} {hour:02d}:{minute:02d}",
)
logger.info("已注册定时任务: {}每天 {:02d}:{:02d}{}", trigger, hour, minute,
" (含日报)" if is_first else "")
# cninfo 公告管道(可配置,默认 06:30)
cninfo_raw = os.environ.get("CNINFO_SCHEDULE_TIME", "06:30")
cninfo_parts = cninfo_raw.split(":")
cninfo_h, cninfo_m = int(cninfo_parts[0]), int(cninfo_parts[1]) if len(cninfo_parts) > 1 else 0
cninfo_trigger = CronTrigger(hour=cninfo_h, minute=cninfo_m, timezone="Asia/Shanghai")
cninfo_steps = ["cninfo_crawl", "cninfo_extract", "cninfo_pdf",
"dedup", "llm", "embedding", "qdrant"]
scheduler.add_job(
_scheduled_pipeline,
trigger=cninfo_trigger,
kwargs={"steps": cninfo_steps},
id="pipeline_cninfo",
name=f"cninfo 公告管道 {cninfo_h:02d}:{cninfo_m:02d}",
)
logger.info("已注册定时任务: cninfo 公告管道 每天 {:02d}:{:02d}", cninfo_h, cninfo_m)
# 个股日报(可配置,默认 07:30, 设为空可禁用)
stock_raw = os.environ.get("STOCK_REPORT_TIME", "07:30")
if stock_raw:
stock_parts = stock_raw.split(":")
stock_h, stock_m = int(stock_parts[0]), int(stock_parts[1]) if len(stock_parts) > 1 else 0
stock_trigger = CronTrigger(hour=stock_h, minute=stock_m, timezone="Asia/Shanghai")
scheduler.add_job(
generate_all_stock_reports,
trigger=stock_trigger,
id="stock_report",
name=f"个股日报 {stock_h:02d}:{stock_m:02d}",
)
logger.info("已注册定时任务: 个股日报 每天 {:02d}:{:02d}", stock_h, stock_m)
else:
logger.info("STOCK_REPORT_TIME 为空, 已禁用个股日报")
# 首次注册;此后由 _config_watch 每 30s 热同步,改 .env 无需重启
_sync_jobs(scheduler)
# 优雅退出
def _shutdown(signum: int, frame: Any) -> None:
@@ -156,10 +227,22 @@ def _daemon(args: argparse.Namespace) -> int:
signal.signal(signal.SIGTERM, _shutdown)
scheduler.start()
logger.info("调度器已启动,等待触发... (按 Ctrl+C 退出)")
# 配置热同步:每 30s 重新计算 SCHEDULE_TIMES / CNINFO_SCHEDULE_TIME / STOCK_REPORT_TIME
scheduler.add_job(
_sync_jobs,
"interval",
seconds=30,
args=[scheduler],
id="_config_watch",
name="配置热同步",
replace_existing=True,
)
# .env 热加载监听:provider / model / key / base_url 等改动无需重启
start_env_watcher()
logger.info("调度器已启动,等待触发... (按 Ctrl+C 退出;改 .env 无需重启)")
# 启动时检查是否有因重启/宕机错过的定时任务,30 分钟内补跑
now = datetime.now()
now = tz_now()
for hour, minute in times:
scheduled = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
missed_minutes = (now - scheduled).total_seconds() / 60
@@ -168,10 +251,12 @@ def _daemon(args: argparse.Namespace) -> int:
"检测到错过的定时任务 {:02d}:{:02d} ({} 分钟前),立即补跑一次",
hour, minute, int(missed_minutes),
)
steps = [k for k in STEP_COMMANDS if k != "report"]
# P1-2:补跑只跑新闻链路(DEFAULT_NEWS_STEPS 不含 report 与 cninfo 三步),
# cninfo 公告管道由其自身定时任务负责,避免重复执行整套公告管道。
steps = list(DEFAULT_NEWS_STEPS)
if (hour, minute) == sorted_times[0]:
steps.append("report")
run_pipeline(date.today().strftime("%Y%m%d"), steps=steps)
run_pipeline(today_str(), steps=steps)
import contextlib
@@ -186,8 +271,8 @@ def main() -> int:
parser = argparse.ArgumentParser(description="A 股新闻定时任务 (M7)")
parser.add_argument("--once", action="store_true", help="立即执行一次全链路")
parser.add_argument(
"--date", default=date.today().strftime("%Y%m%d"),
help="日期 YYYYMMDD (仅 --once 模式)",
"--date", default=None,
help="日期 YYYYMMDD (仅 --once 模式,默认按调度时区取今天)",
)
parser.add_argument("--steps", default=None,
help="仅执行指定步骤,逗号分隔 (如 crawler,extractor)")
@@ -199,7 +284,8 @@ def main() -> int:
args = parser.parse_args()
_setup_logger(args.log_level)
load_dotenv()
# .env 热加载(改文件后常驻进程无需重启;--once 也会即时读取最新配置)
ensure_env_loaded()
if args.once:
return _once(args)
+85 -18
View File
@@ -1,10 +1,24 @@
"""M1 新闻联播 API 抓取脚本。
从 doorcome API /api/xwlbFine/ 获取 AI 精编的新闻联播条目,
转换为与 Web 抓取兼容的格式(data/raw/xwlb/{date}/),
供 M2-M6 管道统一处理。
《新闻联播》每天 19:00 播出:日报/知识库在早上生成时,当日报表只能引用
前一天晚上已播出的联播,因此本脚本固定抓取「处理日 - 1 天」的节目,
但**落盘到「处理日」目录** data/raw/xwlb/{处理日}/。
新闻联播晚间播出,始终抓取前一天数据,不依赖 --date 参数。
日期语义说明:
- 处理日 = pipeline 本次批次的日期(--date,默认今天),与管道其它步骤一致;
- 数据日 = 处理日 - 1(昨晚 19:00 已播出的联播),业务约束来源:
当天的日报需要前一天晚上的新闻联播内容;
- 目录名使用处理日,使 M2→M6(extractor/dedup/llm/embedding/qdrant)
按同一日期扫目录即可处理 xwlb 数据,与其它新闻源完全一致。
幂等:同一天被定时任务多次触发(如 07:00/12:00/18:00/22:00)时,
数据日相同 → 抓取结果相同;按 url_hash 去重后只保留一份,
index.jsonl 重写为去重后的完整集(不再追加产生重复行)。
用法:
uv run python -m scripts.run_xwlb # 处理日 = 今天
uv run python -m scripts.run_xwlb --date 20260823 # 指定处理日
uv run python -m scripts.run_xwlb --output-root data/raw
"""
from __future__ import annotations
@@ -33,19 +47,56 @@ def _url_hash(s: str) -> str:
return hashlib.sha1(s.encode("utf-8")).hexdigest()[:16]
def _load_existing_index(index_path: Path) -> dict[str, str]:
"""读取已有 index.jsonl,返回 {url_hash: 原始行},用于幂等去重。
已存在同 url_hash 的条目视为已落盘,跳过写入;行内容原样保留。
文件不存在或损坏行忽略,不影响本次运行。
"""
existing: dict[str, str] = {}
if not index_path.is_file():
return existing
for line in index_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
logger.warning("跳过 index.jsonl 非法行: {}", line[:80])
continue
key = rec.get("url_hash") or rec.get("url")
if key:
existing[key] = line
return existing
def main() -> int:
parser = argparse.ArgumentParser(description="新闻联播 API 抓取 (M1-xwlb)")
parser.add_argument(
"--date",
default=date.today().strftime("%Y%m%d"),
help="处理日 YYYYMMDD(知识库批次日,默认今日);抓取该日前一天播出的联播",
)
parser.add_argument("--output-root", default="data/raw")
parser.add_argument("--log-level", default="INFO")
args = parser.parse_args()
_setup_logger(args.log_level)
# 新闻联播晚间播出,始终抓取前一天
day_str = (date.today() - timedelta(days=1)).strftime("%Y%m%d")
api_url = f"https://api.doorcome.cn/api/xwlbFine/?start_date={day_str}&end_date={day_str}"
# 处理日 → 数据日(前一晚已播出的联播)
try:
process_day = datetime.strptime(args.date, "%Y%m%d").date()
except ValueError:
logger.error("--date 格式错误: {!r},应为 YYYYMMDD", args.date)
return 2
target_day = (process_day - timedelta(days=1)).strftime("%Y%m%d")
api_url = f"https://api.doorcome.cn/api/xwlbFine/?start_date={target_day}&end_date={target_day}"
logger.info("请求新闻联播 API: {}", api_url)
logger.info(
"请求新闻联播 API: {} (处理日={}, 数据日={})",
api_url, args.date, target_day,
)
try:
req = urllib.request.Request(api_url)
with urllib.request.urlopen(req, timeout=15) as resp:
@@ -56,26 +107,34 @@ def main() -> int:
raw_news = body.get("data", {}).get("news", [])
if not raw_news:
logger.warning("{} 无新闻联播数据", day_str)
logger.warning("{} 无新闻联播数据", target_day)
return 0
# 准备输出目录
out_dir = Path(args.output_root) / "xwlb" / day_str
# 输出目录: data/raw/xwlb/{处理日}/(目录 = pipeline 批次日)
out_dir = Path(args.output_root) / "xwlb" / args.date
out_dir.mkdir(parents=True, exist_ok=True)
index_path = out_dir / "index.jsonl"
# 幂等: 已有条目去重(同一天多次调度不会重复写入)
existing = _load_existing_index(index_path)
saved = 0
skipped = 0
dup_rows = 0
for n in raw_news:
sid = n.get("daily_sub_id", 0)
title = n.get("news_title", "")
content = n.get("news_improve", "")
news_day = n.get("news_days", day_str)
news_day = n.get("news_days", target_day)
fake_url = f"xwlb://{news_day}/{sid}"
h = _url_hash(fake_url)
html_file = f"{h}.html"
if h in existing:
skipped += 1
continue # 幂等: 已落盘过,跳过写入
html_file = f"{h}.html"
html_content = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{title}</title></head>
<body><article><h1>{title}</h1><div class="article-content">{content}</div></article></body>
@@ -95,13 +154,21 @@ def main() -> int:
"url_hash": h,
"html_file": html_file,
}
with index_path.open("a", encoding="utf-8") as f:
f.write(json.dumps(meta, ensure_ascii=False) + "\n")
existing[h] = json.dumps(meta, ensure_ascii=False)
saved += 1
logger.info("新闻联播 {} 抓取完成: {} 条 -> {}", day_str, saved, out_dir)
# 重写 index.jsonl 为去重后的完整集(原子写,替代裸追加)
if saved or existing:
tmp = index_path.with_suffix(".jsonl.tmp")
tmp.write_text("\n".join(existing.values()) + "\n", encoding="utf-8")
tmp.replace(index_path)
logger.info(
"新闻联播 数据日 {} 抓取完成: 新增 {} 条, 跳过已存在 {} 条 -> {}",
target_day, saved, skipped, out_dir,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())
+167
View File
@@ -0,0 +1,167 @@
"""cninfo 抓取并发限流测试。
背景:
每次 `_render_page` 都会启动一个完整的 headless Chromium(实测约 850MB /
10 进程),而 `crawl_watchlist` 对 15 只股票全量并发 → 峰值需求 ≈12.5GB,
远超本机 7.9GB RAM,曾导致 2026-09-06/08/10 三次整机冻结。
修复方式:模块级信号量限制同时存活的浏览器数(默认 2)。
本测试用假 AsyncWebCrawler 验证并发上限,不启动真实浏览器。
"""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
def _install_fake_crawler(monkeypatch: pytest.MonkeyPatch, tracker: dict) -> None:
"""把 crawl4ai.AsyncWebCrawler 换成记录并发峰值的假实现。"""
import crawl4ai
class FakeCrawler:
def __init__(self, config=None) -> None: # noqa: ARG002
pass
async def __aenter__(self) -> FakeCrawler:
tracker["current"] += 1
tracker["max"] = max(tracker["max"], tracker["current"])
return self
async def __aexit__(self, *exc: object) -> bool:
tracker["current"] -= 1
return False
async def arun(self, url: str, config=None) -> SimpleNamespace: # noqa: ARG002
# 模拟渲染耗时,制造并发窗口
await asyncio.sleep(0.05)
return SimpleNamespace(html=f"<html>{url}</html>")
monkeypatch.setattr(crawl4ai, "AsyncWebCrawler", FakeCrawler)
# --------------------------------------------------------------------------- #
# 并发上限解析
# --------------------------------------------------------------------------- #
def test_resolve_concurrency_default(monkeypatch: pytest.MonkeyPatch) -> None:
"""未设 env 时使用默认值 2。"""
monkeypatch.delenv("CNINFO_RENDER_CONCURRENCY", raising=False)
from crawler.cninfo import DEFAULT_RENDER_CONCURRENCY, _resolve_render_concurrency
assert DEFAULT_RENDER_CONCURRENCY == 2
assert _resolve_render_concurrency() == 2
def test_resolve_concurrency_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""env CNINFO_RENDER_CONCURRENCY 可覆盖。"""
monkeypatch.setenv("CNINFO_RENDER_CONCURRENCY", "4")
from crawler.cninfo import _resolve_render_concurrency
assert _resolve_render_concurrency() == 4
def test_resolve_concurrency_invalid_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
"""非法值回退默认值,不抛异常。"""
monkeypatch.setenv("CNINFO_RENDER_CONCURRENCY", "abc")
from crawler.cninfo import _resolve_render_concurrency
assert _resolve_render_concurrency() == 2
def test_resolve_concurrency_clamped_to_one(monkeypatch: pytest.MonkeyPatch) -> None:
"""0/负数夹到 1,避免信号量死锁。"""
monkeypatch.setenv("CNINFO_RENDER_CONCURRENCY", "0")
from crawler.cninfo import _resolve_render_concurrency
assert _resolve_render_concurrency() == 1
# --------------------------------------------------------------------------- #
# 渲染并发限流
# --------------------------------------------------------------------------- #
def test_render_concurrency_capped(monkeypatch: pytest.MonkeyPatch) -> None:
"""6 个并发渲染请求,同时存活的浏览器数不超过信号量上限 2。"""
from crawler import cninfo
tracker = {"current": 0, "max": 0}
_install_fake_crawler(monkeypatch, tracker)
# 独立信号量,避免跨测试污染模块级状态
monkeypatch.setattr(cninfo, "_render_sem", asyncio.Semaphore(2))
async def run() -> list[str]:
return await asyncio.gather(*[
cninfo._render_page(f"https://example.com/{i}") for i in range(6)
])
results = asyncio.run(run())
assert len(results) == 6, "全部请求都应完成(限流不应丢请求)"
assert tracker["max"] <= 2, f"并发峰值 {tracker['max']} 超过上限 2"
def test_render_concurrency_one_is_serial(monkeypatch: pytest.MonkeyPatch) -> None:
"""上限为 1 时完全串行执行。"""
from crawler import cninfo
tracker = {"current": 0, "max": 0}
_install_fake_crawler(monkeypatch, tracker)
monkeypatch.setattr(cninfo, "_render_sem", asyncio.Semaphore(1))
async def run() -> list[str]:
return await asyncio.gather(*[
cninfo._render_page(f"https://example.com/{i}") for i in range(4)
])
results = asyncio.run(run())
assert len(results) == 4
assert tracker["max"] == 1, "上限 1 时不应出现并发"
def test_render_page_returns_html(monkeypatch: pytest.MonkeyPatch) -> None:
"""渲染正常返回 html 字符串。"""
from crawler import cninfo
tracker = {"current": 0, "max": 0}
_install_fake_crawler(monkeypatch, tracker)
monkeypatch.setattr(cninfo, "_render_sem", asyncio.Semaphore(2))
html = asyncio.run(cninfo._render_page("https://example.com/x"))
assert html == "<html>https://example.com/x</html>"
def test_render_semaphore_released_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
"""渲染抛异常时也要释放槽位(否则后续渲染会死锁)。"""
import crawl4ai
from crawler import cninfo
class BoomCrawler:
def __init__(self, config=None) -> None: # noqa: ARG002
pass
async def __aenter__(self) -> BoomCrawler:
raise RuntimeError("浏览器启动失败")
async def __aexit__(self, *exc: object) -> bool:
return False
async def arun(self, url: str, config=None): # noqa: ARG002
raise RuntimeError("unreachable")
monkeypatch.setattr(crawl4ai, "AsyncWebCrawler", BoomCrawler)
monkeypatch.setattr(cninfo, "_render_sem", asyncio.Semaphore(1))
async def run() -> None:
# 第一次失败后,槽位必须已释放,第二次才能拿到
with pytest.raises(RuntimeError):
await cninfo._render_page("https://example.com/fail")
with pytest.raises(RuntimeError):
await asyncio.wait_for(
cninfo._render_page("https://example.com/fail2"), timeout=2
)
asyncio.run(run())
assert cninfo._render_sem._value == 1, "异常后槽位未释放"
+217
View File
@@ -0,0 +1,217 @@
"""配置热加载测试:改 .env / llm_models.yaml 后无需重启即生效。
覆盖三层:
- configs/loader.py YAML 场景((mtime, size) 失效缓存)
- configs/runtime_env .env(热更新 / 删除语义 / 外部显式覆盖优先)
- scripts/run_scheduler 定时任务热同步(改 SCHEDULE_TIMES 等)
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from configs import loader, runtime_env
@pytest.fixture(autouse=True)
def _clean_caches() -> None:
"""每个用例前后都清干净,避免托管的环境变量污染其它测试。"""
loader.clear_cache()
runtime_env.reset_cache()
yield
loader.clear_cache()
runtime_env.reset_cache()
def _use_env_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, content: str) -> Path:
"""把 runtime_env 指向临时 .env,返回其路径。"""
env = tmp_path / ".env"
env.write_text(content, encoding="utf-8")
monkeypatch.setenv(runtime_env.ENV_FILE_OVERRIDE, str(env))
runtime_env.reset_cache()
return env
# --------------------------------------------------------------------------- #
# YAML 场景热加载
# --------------------------------------------------------------------------- #
def test_yaml_scene_hot_reload(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""改 llm_models.yaml 后,下一次 load_scene_config 立即读到新值。"""
cfg = tmp_path / "llm_models.yaml"
cfg.write_text(
"scenes:\n daily_report:\n provider: qwen\n model: qwen3.6-flash\n",
encoding="utf-8",
)
monkeypatch.setenv(loader.MODELS_CONFIG_OVERRIDE, str(cfg))
assert loader.load_scene_config("daily_report")["model"] == "qwen3.6-flash"
# 内容长度不同,保证 (mtime, size) 签名一定变化
cfg.write_text(
"scenes:\n daily_report:\n provider: qwen\n model: deepseek-v4.1-flash\n",
encoding="utf-8",
)
# 不重启、不 clear_cache 也应生效
assert loader.load_scene_config("daily_report")["model"] == "deepseek-v4.1-flash"
def test_yaml_scene_removed_falls_back(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""场景被删掉后返回空 dict(由调用方回退 .env)。"""
cfg = tmp_path / "llm_models.yaml"
cfg.write_text("scenes:\n stock_report:\n model: x\n", encoding="utf-8")
monkeypatch.setenv(loader.MODELS_CONFIG_OVERRIDE, str(cfg))
assert loader.load_scene_config("stock_report")["model"] == "x"
cfg.write_text("scenes: {}\n", encoding="utf-8")
assert loader.load_scene_config("stock_report") == {}
def test_yaml_missing_file_returns_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(loader.MODELS_CONFIG_OVERRIDE, str(tmp_path / "nope.yaml"))
assert loader.load_scene_config("daily_report") == {}
assert loader.load_defaults() == {}
# --------------------------------------------------------------------------- #
# .env 热加载
# --------------------------------------------------------------------------- #
def test_env_hot_reload(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""改 .env 后 env_get 立即读到新值。"""
env = _use_env_file(tmp_path, monkeypatch, "HOT_RELOAD_KEY=one\n")
monkeypatch.delenv("HOT_RELOAD_KEY", raising=False)
assert runtime_env.env_get("HOT_RELOAD_KEY") == "one"
env.write_text("HOT_RELOAD_KEY=two-longer-value\n", encoding="utf-8")
assert runtime_env.env_get("HOT_RELOAD_KEY") == "two-longer-value"
def test_env_removed_key_is_unset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""从 .env 删掉的托管键要同步从 os.environ 移除。"""
env = _use_env_file(tmp_path, monkeypatch, "HOT_REMOVE_KEY=abc\n")
monkeypatch.delenv("HOT_REMOVE_KEY", raising=False)
assert runtime_env.env_get("HOT_REMOVE_KEY") == "abc"
assert os.environ["HOT_REMOVE_KEY"] == "abc"
env.write_text("OTHER_KEY=1\n", encoding="utf-8")
assert runtime_env.env_get("HOT_REMOVE_KEY") is None
assert "HOT_REMOVE_KEY" not in os.environ
def test_env_empty_value_is_unset(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_use_env_file(tmp_path, monkeypatch, "HOT_EMPTY_KEY=\n")
monkeypatch.delenv("HOT_EMPTY_KEY", raising=False)
assert runtime_env.env_get("HOT_EMPTY_KEY", "fallback") == "fallback"
def test_explicit_process_env_wins(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""进程环境显式设置且与文件不同 → 不被 .env 覆盖(一次性覆盖语义)。"""
env = _use_env_file(tmp_path, monkeypatch, "HOT_OVERRIDE_KEY=from-file\n")
monkeypatch.setenv("HOT_OVERRIDE_KEY", "from-shell")
assert runtime_env.env_get("HOT_OVERRIDE_KEY") == "from-shell"
env.write_text("HOT_OVERRIDE_KEY=from-file-changed\n", encoding="utf-8")
assert runtime_env.env_get("HOT_OVERRIDE_KEY") == "from-shell"
def test_llm_config_follows_env_hot_reload(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""端到端:改 .env 里的 QWEN_MODEL,load_llm_config 立即用新模型。"""
from llm.client import load_llm_config
contents = (
"LLM_PROVIDER=qwen\n"
"QWEN_API_KEY=sk-test\n"
"QWEN_BASE_URL=https://token-plan.example/compatible-mode/v1\n"
"QWEN_MODEL=qwen3.6-flash\n"
)
env = _use_env_file(tmp_path, monkeypatch, contents)
for key in ("LLM_PROVIDER", "QWEN_API_KEY", "QWEN_BASE_URL", "QWEN_MODEL"):
monkeypatch.delenv(key, raising=False)
cfg = load_llm_config(scene="")
assert cfg.provider == "qwen"
assert cfg.model == "qwen3.6-flash"
env.write_text(contents.replace("qwen3.6-flash", "deepseek-v4.1-flash"), encoding="utf-8")
assert load_llm_config(scene="").model == "deepseek-v4.1-flash"
# --------------------------------------------------------------------------- #
# 调度任务热同步
# --------------------------------------------------------------------------- #
def test_desired_jobs_tracks_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""SCHEDULE_TIMES / CNINFO_SCHEDULE_TIME / STOCK_REPORT_TIME 改完立即反映。"""
from scripts.run_scheduler import _desired_jobs
_use_env_file(
tmp_path,
monkeypatch,
"SCHEDULE_TIMES=09:15\nCNINFO_SCHEDULE_TIME=05:45\nSTOCK_REPORT_TIME=\n",
)
jobs = _desired_jobs()
assert set(jobs) == {"pipeline_0915", "pipeline_cninfo"}
# 当天唯一次数 → 附带日报步骤
assert jobs["pipeline_0915"]["steps"][-1] == "report"
assert jobs["pipeline_cninfo"]["hour"] == 5
assert jobs["pipeline_cninfo"]["minute"] == 45
def test_sync_jobs_adds_and_removes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""_sync_jobs 只按需增删:加时间点、启停个股日报、幂等。"""
from apscheduler.schedulers.background import BackgroundScheduler
import scripts.run_scheduler as rs
env = _use_env_file(
tmp_path,
monkeypatch,
"SCHEDULE_TIMES=09:15\nCNINFO_SCHEDULE_TIME=05:45\nSTOCK_REPORT_TIME=\n",
)
rs._jobs_sig = None
sched = BackgroundScheduler()
assert rs._sync_jobs(sched) is True
assert {j.id for j in sched.get_jobs()} == {"pipeline_0915", "pipeline_cninfo"}
# 新增一个时间点 + 启用个股日报
env.write_text(
"SCHEDULE_TIMES=09:15,16:40\nCNINFO_SCHEDULE_TIME=05:45\nSTOCK_REPORT_TIME=07:30\n",
encoding="utf-8",
)
assert rs._sync_jobs(sched) is True
ids = {j.id for j in sched.get_jobs()}
assert ids == {"pipeline_0915", "pipeline_1640", "pipeline_cninfo", "stock_report"}
# 配置未变 → 不重复变更
assert rs._sync_jobs(sched) is False
def test_parse_hhmm_falls_back_on_bad_input() -> None:
from scripts.run_scheduler import _parse_hhmm
assert _parse_hhmm("06:30", (0, 0)) == (6, 30)
assert _parse_hhmm("99:99", (6, 30)) == (6, 30)
assert _parse_hhmm("garbage", (7, 0)) == (7, 0)
# --------------------------------------------------------------------------- #
# 其它模块复用热读取
# --------------------------------------------------------------------------- #
def test_reporter_days_back_is_lazy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""日报的 STOCK_REPORT_DAYS 走懒读取,不再冻结在模块常量里。"""
from scheduler import reporter
_use_env_file(tmp_path, monkeypatch, "STOCK_REPORT_DAYS=15\n")
monkeypatch.delenv("STOCK_REPORT_DAYS", raising=False)
assert reporter._cninfo_days_back() == 15
+183
View File
@@ -8,6 +8,7 @@
from __future__ import annotations
import json
from datetime import UTC
from pathlib import Path
from types import SimpleNamespace
@@ -266,3 +267,185 @@ def test_once_rejects_resume_with_steps(monkeypatch: pytest.MonkeyPatch) -> None
date="20260616")
rc = rs._once(args)
assert rc == 2
# --------------------------------------------------------------------------- #
# P0-3: 补跑模式跳过抓取 / P0-4: report 使用传入日期
# --------------------------------------------------------------------------- #
def test_crawler_backfill_skips_past_date(monkeypatch: pytest.MonkeyPatch) -> None:
"""补跑历史日期时跳过 crawler(首页只含当天内容),不执行子进程。"""
from scheduler import pipeline
def _fail_run(cmd, timeout=None): # noqa: ARG001
raise AssertionError("补跑模式不应执行子进程")
monkeypatch.setattr(pipeline.subprocess, "run", _fail_run)
sr = pipeline.run_step("crawler", "20260101")
assert sr.success is True
assert "跳过" in sr.tail_msg
assert sr.elapsed_sec == 0.0
def test_crawler_today_runs_normally(monkeypatch: pytest.MonkeyPatch) -> None:
"""当天日期正常执行 crawler(不受补跑保护影响)。"""
from datetime import date
from scheduler import pipeline
called: list[str] = []
def _fake_run(cmd, timeout=None): # noqa: ARG001
called.append(" ".join(cmd))
return SimpleNamespace(returncode=0)
monkeypatch.setattr(pipeline.subprocess, "run", _fake_run)
sr = pipeline.run_step("crawler", date.today().strftime("%Y%m%d"))
assert sr.success is True
assert called and "run_crawler" in called[0]
def test_report_step_uses_date_str(monkeypatch: pytest.MonkeyPatch) -> None:
"""report 步骤必须使用传入的 date_str,而非 date.today()(P0-4)。"""
from scheduler import pipeline, reporter
received: list[str] = []
def _fake_generate(day_str, upload=True): # noqa: ARG001
received.append(day_str)
return 12345
monkeypatch.setattr(reporter, "generate_report", _fake_generate)
sr = pipeline.run_step("report", "20260101")
assert sr.success is True
assert received == ["20260101"]
def test_pipeline_backfill_skips_crawler_keeps_rest(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""补跑历史日期:全链路中仅 crawler 跳过,其余步骤照常执行。"""
from scheduler import pipeline
calls: list[str] = []
def _fake_run(cmd, timeout=None): # noqa: ARG001
name = next(c.split(".")[-1] for c in cmd if "scripts.run_" in c)
calls.append(name)
return SimpleNamespace(returncode=0)
monkeypatch.setattr(pipeline.subprocess, "run", _fake_run)
state_path = tmp_path / "state.json"
result = pipeline.run_pipeline(
"20260101",
steps=["crawler", "extractor", "dedup"],
state_path=state_path,
)
# crawler 跳过(未执行子进程),extractor/dedup 正常
assert calls == ["run_extractor", "run_dedup"]
assert all(s.success for s in result.steps)
state = pipeline._load_pipeline_state(state_path)
assert state["20260101"]["crawler"]["status"] == "ok"
# --------------------------------------------------------------------------- #
# P1-2: 默认步骤不含 cninfo 独立管道 / P1-3: 调度时区统一
# --------------------------------------------------------------------------- #
def test_default_news_steps_excludes_cninfo_and_report() -> None:
"""新闻链路默认步骤不含 report 与 cninfo 三步(P1-2)。"""
from scheduler import DEFAULT_NEWS_STEPS
assert "report" not in DEFAULT_NEWS_STEPS
assert "cninfo_crawl" not in DEFAULT_NEWS_STEPS
assert "cninfo_extract" not in DEFAULT_NEWS_STEPS
assert "cninfo_pdf" not in DEFAULT_NEWS_STEPS
# 新闻链路核心步骤齐全
assert {"crawler", "xwlb", "extractor", "dedup", "llm",
"embedding", "qdrant"} <= set(DEFAULT_NEWS_STEPS)
def test_run_pipeline_default_steps_excludes_cninfo(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""run_pipeline 默认 steps 不含 cninfo(只跑新闻链路)。"""
from scheduler import pipeline
from scheduler.timeutil import today_str
calls: list[str] = []
def _fake_run(cmd, timeout=None): # noqa: ARG001
name = next(c.split(".")[-1] for c in cmd if "scripts.run_" in c)
calls.append(name)
return SimpleNamespace(returncode=0)
monkeypatch.setattr(pipeline.subprocess, "run", _fake_run)
state_path = tmp_path / "state.json"
today = today_str() # 今天:避免 crawler 补跑保护跳过
result = pipeline.run_pipeline(
today, state_path=state_path,
)
assert result.all_success is True
assert calls == ["run_crawler", "run_xwlb", "run_extractor", "run_dedup",
"run_event_extraction", "run_embedding", "run_qdrant_ingest"]
assert "cninfo" not in " ".join(calls)
def test_scheduler_daemon_backfill_uses_default_news_steps(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""守护进程补跑 steps 使用 DEFAULT_NEWS_STEPS(不含 cninfo 三步)。"""
import inspect
import scripts.run_scheduler as rs
from scheduler import DEFAULT_NEWS_STEPS
# 验证补跑逻辑引用的常量:通过源码断言 + 常量内容双重保证
src = inspect.getsource(rs._daemon)
assert "list(DEFAULT_NEWS_STEPS)" in src
assert "cninfo_crawl" not in DEFAULT_NEWS_STEPS
def test_today_str_matches_schedule_timezone(monkeypatch: pytest.MonkeyPatch) -> None:
"""today_str 按调度时区返回 YYYYMMDD(P1-3)。"""
from datetime import datetime
from zoneinfo import ZoneInfo
from scheduler.timeutil import today_str
s = today_str()
assert len(s) == 8 and s.isdigit()
# 与调度时区(默认 Asia/Shanghai)当前日期一致
expect = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
assert s == expect
def test_today_str_respects_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
"""SCHEDULE_TZ 环境变量可覆盖调度时区(P1-3)。"""
from datetime import datetime
from scheduler.timeutil import today_str
# 覆盖为 UTC 后,today_str 应返回 UTC 日期(而非默认 Asia/Shanghai)
monkeypatch.setenv("SCHEDULE_TZ", "UTC")
s = today_str()
expect = datetime.now(UTC).strftime("%Y%m%d")
assert s == expect
def test_once_uses_today_str_when_date_missing(monkeypatch: pytest.MonkeyPatch) -> None:
"""--once 不带 --date 时按调度时区取今天(P1-3)。"""
import scripts.run_scheduler as rs
captured: dict[str, str] = {}
def _fake_pipeline(date_str, steps=None, resume=False): # noqa: ARG001
captured["date"] = date_str
monkeypatch.setattr(rs, "run_pipeline", _fake_pipeline)
from types import SimpleNamespace
args = SimpleNamespace(steps=None, resume=False, date=None)
rc = rs._once(args)
assert rc == 0
assert captured["date"] == rs.today_str()
+58 -1
View File
@@ -356,7 +356,23 @@ def test_load_llm_config_deepseek_from_env(monkeypatch: pytest.MonkeyPatch) -> N
assert "deepseek" in cfg.base_url
def test_load_llm_config_qwen_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
def test_load_llm_config_qwen_from_env(
monkeypatch: pytest.MonkeyPatch, tmp_path
) -> None:
"""qwen 的 key 兜底链:QWEN_API_KEY 缺失时回退 DASHSCOPE_API_KEY。
真实 .env 里带有 QWEN_API_KEY,会盖过 DASHSCOPE_API_KEY,所以本用例先把
热加载指向一个空 .env,测完再切回真实 .env。
"""
from configs import runtime_env
empty = tmp_path / ".env"
empty.write_text("", encoding="utf-8")
monkeypatch.setenv(runtime_env.ENV_FILE_OVERRIDE, str(empty))
runtime_env.reset_cache()
monkeypatch.delenv("QWEN_API_KEY", raising=False)
monkeypatch.delenv("QWEN_BASE_URL", raising=False)
monkeypatch.setenv("LLM_PROVIDER", "qwen")
monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-test-qwen")
monkeypatch.setenv("QWEN_MODEL", "qwen-plus")
@@ -367,6 +383,11 @@ def test_load_llm_config_qwen_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
assert cfg.model == "qwen-plus"
assert "dashscope" in cfg.base_url or "aliyuncs" in cfg.base_url
# 切回真实 .env,避免影响后续用例(DB / Qdrant 等直接读 os.environ 的测试)
monkeypatch.delenv(runtime_env.ENV_FILE_OVERRIDE, raising=False)
runtime_env.reset_cache()
runtime_env.ensure_env_loaded(force=True)
def test_load_llm_config_unknown_provider_raises(monkeypatch: pytest.MonkeyPatch) -> None:
with pytest.raises(ValueError):
@@ -498,3 +519,39 @@ def test_load_llm_config_scene_max_attempts_zero() -> None:
assert _pick_int({"max_attempts": 0}, "max_attempts", 3) == 0
assert _pick_int({"max_attempts": ""}, "max_attempts", 3) == 3
assert _pick_int({}, "max_attempts", 3) == 3
# --------------------------------------------------------------------------- #
# max_tokens 场景配置(推理模型 reasoning 与正文共用预算,回归 9-25 无 AI 摘要)
# --------------------------------------------------------------------------- #
def test_load_llm_config_scene_max_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
"""scenes.<scene>.max_tokens 应写入 LLMConfig;未配置则为 None(调用方走默认)。"""
monkeypatch.setenv("QWEN_API_KEY", "sk-qwen")
_patch_scene(monkeypatch, {"provider": "qwen", "model": "deepseek-v4.1-flash",
"max_tokens": 4000})
cfg = load_llm_config(scene="daily_report")
assert cfg.max_tokens == 4000
_patch_scene(monkeypatch, {"provider": "qwen", "model": "deepseek-v4.1-flash"})
assert load_llm_config(scene="daily_report").max_tokens is None
def test_pick_optional_int() -> None:
"""_pick_optional_int:未配置/空值/非法 → None;数字字符串可解析。"""
from llm.client import _pick_optional_int
assert _pick_optional_int({"max_tokens": 4000}, "max_tokens") == 4000
assert _pick_optional_int({"max_tokens": "4000"}, "max_tokens") == 4000
assert _pick_optional_int({"max_tokens": ""}, "max_tokens") is None
assert _pick_optional_int({"max_tokens": "abc"}, "max_tokens") is None
assert _pick_optional_int({}, "max_tokens") is None
def test_real_yaml_daily_report_max_tokens_has_reasoning_headroom() -> None:
"""真实配置的日报预算必须大于旧的 1500(为 reasoning token 留余量)。"""
from configs.loader import load_scene_config
scene = load_scene_config("daily_report")
max_tokens = int(scene.get("max_tokens") or 0)
assert max_tokens >= 4000, f"daily_report.max_tokens 过小: {max_tokens}"
+11 -1
View File
@@ -41,9 +41,11 @@ def _hit(title: str = "测试标题", source: str = "cls", score: float = 0.9,
sentiment: str = "positive", stock_codes: list[str] | None = None,
company_names: list[str] | None = None, summary: str = "摘要",
publish_time: str = "2026-06-16T10:00:00",
url: str = "https://example.com/1") -> dict:
url: str = "https://example.com/1",
sources: list[str] | None = None) -> dict:
return {
"title": title, "url": url, "source": source, "score": score,
"sources": sources or [source],
"publish_time": publish_time,
"event": {
"sentiment": sentiment, "importance": 4, "event_type": "重大合同",
@@ -61,6 +63,14 @@ def test_fmt_results_contains_title_and_source() -> None:
assert "0.9" in out
def test_fmt_results_multi_source_tag() -> None:
"""多源新闻显示「来源A / 来源B [多源]」。"""
hits = [_hit("多源新闻", sources=["cls", "sina"])]
out = _fmt_results(hits, "查询")
assert "cls / sina" in out
assert "[多源]" in out
def test_fmt_results_includes_event_fields() -> None:
hits = [_hit(
company_names=["宁德时代"], stock_codes=["300750"],
+224
View File
@@ -0,0 +1,224 @@
"""多源记录端到端链路测试 (方案 A + B)。
覆盖:
- EmbeddingResult.sources validator:主源居首 / 去重 / 旧产物兜底
- run_qdrant_ingest._result_to_point:payload 写入 sources(
M5 产物优先 → 回查 M4 → 兜底 [主源])
- run_embedding 文本构造透传 sources
- 回填脚本 _load_fingerprint_sources:source_ids 解析与兜底
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
from embedding import EmbeddingResult
# --------------------------------------------------------------------------- #
# EmbeddingResult.sources validator
# --------------------------------------------------------------------------- #
def _emb(**overrides) -> EmbeddingResult:
base = {
"url_hash": "a" * 16,
"source_id": "cls",
"title": "t",
"text": "x",
"vector": [0.1] * 4,
"dim": 4,
"provider": "dashscope",
"model": "m",
}
base.update(overrides)
return EmbeddingResult(**base)
def test_embedding_sources_default_fallback_to_main() -> None:
"""旧产物无 sources → 兜底为 [主源]。"""
r = _emb()
assert r.sources == ["cls"]
def test_embedding_sources_main_source_first_and_dedup() -> None:
"""主源居首且去重保序。"""
r = _emb(sources=["sina", "cls", "eastmoney"])
assert r.sources == ["cls", "sina", "eastmoney"]
def test_embedding_sources_keeps_multi() -> None:
r = _emb(sources=["cls", "eastmoney"])
assert r.sources == ["cls", "eastmoney"]
# --------------------------------------------------------------------------- #
# run_qdrant_ingest._result_to_point
# --------------------------------------------------------------------------- #
def test_result_to_point_sources_from_m5() -> None:
"""M5 产物自带 sources → 直接写入 payload。"""
from scripts.run_qdrant_ingest import _result_to_point
obj = {
"url_hash": "b" * 16,
"vector": [0.1] * 4,
"title": "t",
"url": "https://x",
"source_id": "cls",
"sources": ["cls", "eastmoney"],
}
pt = _result_to_point(obj)
assert pt is not None
assert pt["payload"]["sources"] == ["cls", "eastmoney"]
def test_result_to_point_sources_fallback_m4(tmp_path: Path) -> None:
"""M5 产物无 sources → 回查 M4 events 的 sources。"""
from scripts.run_qdrant_ingest import _result_to_point
h = "c" * 16
ev_dir = tmp_path / "events"
ev_dir.mkdir()
(ev_dir / f"{h}.json").write_text(
json.dumps({"sources": ["sina", "yicai"], "event": {}}),
encoding="utf-8",
)
obj = {
"url_hash": h,
"vector": [0.1] * 4,
"title": "t",
"url": "https://x",
"source_id": "sina",
}
pt = _result_to_point(obj, events_dir=ev_dir)
assert pt is not None
assert pt["payload"]["sources"] == ["sina", "yicai"]
def test_result_to_point_sources_fallback_main_only() -> None:
"""M5/M4 均无 sources → 兜底 [主源]。"""
from scripts.run_qdrant_ingest import _result_to_point
obj = {
"url_hash": "d" * 16,
"vector": [0.1] * 4,
"title": "t",
"url": "https://x",
"source_id": "zqrb",
}
pt = _result_to_point(obj)
assert pt is not None
assert pt["payload"]["sources"] == ["zqrb"]
# --------------------------------------------------------------------------- #
# run_embedding 文本构造透传 sources
# --------------------------------------------------------------------------- #
def test_build_text_from_event_returns_sources(tmp_path: Path) -> None:
from scripts.run_embedding import _build_text_from_event
h = "e" * 16
ev_dir = tmp_path / "events"
ev_dir.mkdir()
proc_root = tmp_path / "processed"
proc_root.mkdir()
ev = {
"url_hash": h,
"url": "https://x",
"source_id": "cls",
"title": "标题",
"publish_time": None,
"sources": ["cls", "sina"],
"event": {"summary": "摘要", "sentiment": "neutral", "importance": 2,
"event_type": "其他"},
}
fp = ev_dir / f"{h}.json"
fp.write_text(json.dumps(ev, ensure_ascii=False), encoding="utf-8")
built = _build_text_from_event(fp, proc_root, "20260616")
assert built is not None
_text, _article, _summary, sources = built
assert sources == ["cls", "sina"]
def test_build_text_from_article_reads_sources(tmp_path: Path) -> None:
from scripts.run_embedding import _build_text_from_article
h = "f" * 16
art = {
"source_id": "cls",
"url": "https://x",
"url_hash": h,
"title": "标题",
"content": "正文内容",
"word_count": 4,
"sources": ["cls", "eastmoney"],
}
fp = tmp_path / f"{h}.json"
fp.write_text(json.dumps(art, ensure_ascii=False), encoding="utf-8")
built = _build_text_from_article(fp)
assert built is not None
_text, _article, _summary, sources = built
assert sources == ["cls", "eastmoney"]
# --------------------------------------------------------------------------- #
# 回填脚本 _load_multi_source
# --------------------------------------------------------------------------- #
def _make_fp_db(tmp_path: Path) -> Path:
db = tmp_path / "fp.sqlite3"
conn = sqlite3.connect(db)
conn.execute(
"CREATE TABLE fingerprints (url_hash TEXT PRIMARY KEY, source_id TEXT, "
"source_ids TEXT)"
)
conn.execute(
"INSERT INTO fingerprints VALUES (?, ?, ?)",
("h1", "cls", json.dumps(["cls", "sina"])),
)
conn.execute(
"INSERT INTO fingerprints VALUES (?, ?, ?)", ("h2", "zqrb", None)
)
conn.execute(
"INSERT INTO fingerprints VALUES (?, ?, ?)", ("h3", "cls", "not-json")
)
conn.execute(
"INSERT INTO fingerprints VALUES (?, ?, ?)",
("h4", "cls", json.dumps(["cls"])),
)
conn.commit()
conn.close()
return db
def test_load_multi_source_only_multi(tmp_path: Path) -> None:
"""仅返回多源记录;NULL/损坏/单源均排除。"""
from scripts.backfill_qdrant_sources import _load_multi_source
db = _make_fp_db(tmp_path)
res = _load_multi_source(db)
assert res == {"h1": ["cls", "sina"]}
def test_load_multi_source_main_first(tmp_path: Path) -> None:
"""source_ids 顺序异常时保证主源居首。"""
from scripts.backfill_qdrant_sources import _load_multi_source
db = tmp_path / "fp.sqlite3"
conn = sqlite3.connect(db)
conn.execute(
"CREATE TABLE fingerprints (url_hash TEXT PRIMARY KEY, source_id TEXT, "
"source_ids TEXT)"
)
conn.execute(
"INSERT INTO fingerprints VALUES (?, ?, ?)",
("h1", "cls", json.dumps(["sina", "cls"])),
)
conn.commit()
conn.close()
res = _load_multi_source(db)
assert res["h1"] == ["cls", "sina"]
+82 -4
View File
@@ -132,8 +132,8 @@ class TestLlmCallRetry:
def test_retry_then_success(self, monkeypatch) -> None:
import scheduler.reporter as rep
monkeypatch.setattr(rep, "_LLM_RETRY_TIMES", 3)
monkeypatch.setattr(rep, "_LLM_RETRY_BACKOFF_SEC", 0.01)
monkeypatch.setattr(rep, "_llm_retry_times", lambda: 3)
monkeypatch.setattr(rep, "_llm_retry_backoff_sec", lambda: 0.01)
client, n = self._fake_client(2) # 前 2 次失败,第 3 次成功
out = rep._llm_call(client, self._cfg(), "p")
assert out == "今日要点摘要"
@@ -141,8 +141,8 @@ class TestLlmCallRetry:
def test_exhausts_retries_raises(self, monkeypatch) -> None:
import scheduler.reporter as rep
monkeypatch.setattr(rep, "_LLM_RETRY_TIMES", 2)
monkeypatch.setattr(rep, "_LLM_RETRY_BACKOFF_SEC", 0.01)
monkeypatch.setattr(rep, "_llm_retry_times", lambda: 2)
monkeypatch.setattr(rep, "_llm_retry_backoff_sec", lambda: 0.01)
client, n = self._fake_client(99) # 一直失败
with pytest.raises(ConnectionError):
rep._llm_call(client, self._cfg(), "p")
@@ -171,6 +171,84 @@ class TestLlmCallRetry:
assert n["count"] == 3 # 2 块 + 1 次合并
class TestReasoningBudgetEscalation:
"""推理模型占满 max_tokens 导致正文为空时的预算升级(回归 9-25 无 AI 摘要)。"""
@staticmethod
def _empty_then_ok_client(empty_times: int):
"""前 empty_times 次返回空正文 + finish_reason=length,之后返回正常摘要。"""
from types import SimpleNamespace
seen: list[int] = []
class Completions:
def create(self, **kwargs):
seen.append(kwargs.get("max_tokens"))
if len(seen) <= empty_times:
return SimpleNamespace(
choices=[SimpleNamespace(
message=SimpleNamespace(content=""),
finish_reason="length",
)]
)
return SimpleNamespace(
choices=[SimpleNamespace(
message=SimpleNamespace(content="恢复后的摘要"),
finish_reason="stop",
)]
)
return SimpleNamespace(chat=SimpleNamespace(completions=Completions())), seen
@staticmethod
def _cfg(max_tokens: int | None = None):
from llm.client import LLMConfig
return LLMConfig(
provider="qwen", model="deepseek-v4.1-flash",
api_key="sk-test", base_url="https://example.invalid/v1",
temperature=0.3, max_tokens=max_tokens,
)
def test_empty_content_escalates_and_recovers(self, monkeypatch) -> None:
"""正文为空时自动加倍预算并最终拿到摘要(不再静默返回空)。"""
import scheduler.reporter as rep
monkeypatch.setattr(rep, "_llm_retry_times", lambda: 3)
monkeypatch.setattr(rep, "_llm_retry_backoff_sec", lambda: 0.01)
client, seen = self._empty_then_ok_client(1)
out = rep._llm_call(client, self._cfg(4000), "p")
assert out == "恢复后的摘要"
assert seen == [4000, 8000] # 首次失败后预算翻倍
def test_scene_max_tokens_wins_over_default(self, monkeypatch) -> None:
"""场景 max_tokens 生效;未配置时回退代码默认 4000。"""
import scheduler.reporter as rep
monkeypatch.setattr(rep, "_llm_retry_times", lambda: 1)
client, seen = self._empty_then_ok_client(0)
rep._llm_call(client, self._cfg(6000), "p")
assert seen == [6000]
client, seen = self._empty_then_ok_client(0)
rep._llm_call(client, self._cfg(), "p")
assert seen == [rep.DEFAULT_SUMMARY_MAX_TOKENS]
def test_all_empty_returns_blank_without_raising(self, monkeypatch) -> None:
"""预算升级用尽仍为空时降级返回空串(日报仍可入库,不抛异常)。"""
import scheduler.reporter as rep
monkeypatch.setattr(rep, "_llm_retry_times", lambda: 3)
monkeypatch.setattr(rep, "_llm_retry_backoff_sec", lambda: 0.01)
client, seen = self._empty_then_ok_client(99)
out = rep._llm_call(client, self._cfg(4000), "p")
assert out == ""
# 4000 → 8000 → 16000(受 MAX_SUMMARY_MAX_TOKENS 上限约束)
assert seen == [4000, 8000, 16000]
def test_budget_never_exceeds_cap(self) -> None:
"""升级预算不超过 MAX_SUMMARY_MAX_TOKENS,避免无限放大。"""
import scheduler.reporter as rep
assert rep.MAX_SUMMARY_MAX_TOKENS == 16000
assert rep.DEFAULT_SUMMARY_MAX_TOKENS > rep.DEFAULT_SUMMARY_CHUNK_MAX_TOKENS
class TestCollectXwlb:
"""_collect_xwlb 取数逻辑:应查询日报前一日(已播出的联播),并跳过内容提要。"""
+173
View File
@@ -0,0 +1,173 @@
"""run_dedup 脚本测试(生产/验收返回码语义 + stats.json 统计快照)。
覆盖:
- 生产模式(默认):高重复率不影响退出码,返回 0;重复率仅作告警
- 验收模式(--strict):高重复率返回 1(保留 M3 验收门槛 ≤ 5%)
- 低重复率场景两种模式均返回 0
- stats.json 统计快照结构正确(原子写),含重复率与指纹库总量
"""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
import pytest
def _url_hash(url: str) -> str:
return hashlib.sha1(url.encode("utf-8")).hexdigest()[:16]
def _write_article(proc_dir: Path, url: str, title: str, content: str) -> None:
"""写一个 Article JSON,文件名为 url_hash。"""
h = _url_hash(url)
article = {
"source_id": "testsrc",
"url": url,
"url_hash": h,
"title": title,
"content": content,
"word_count": len(content),
}
(proc_dir / f"{h}.json").write_text(
json.dumps(article, ensure_ascii=False), encoding="utf-8"
)
def _setup_fixture(tmp_path: Path, *, dup_count: int, uniq_count: int) -> Path:
"""构造 processed 夹具:uniq_count 篇互不相同 + dup_count 篇与第 1 篇内容相同。"""
day = "20260616"
proc_dir = tmp_path / "processed" / "testsrc" / day
proc_dir.mkdir(parents=True)
base_content = "这是一条足够长的测试新闻正文内容,用于触发去重判定逻辑。" * 3
# 有重复篇时才写基准篇(重复目标);否则唯一数 = uniq_count
if dup_count > 0:
_write_article(proc_dir, "https://x.example/base", "基准新闻", base_content)
# 与基准篇内容相同、URL 不同 → 判为重复
for i in range(dup_count):
_write_article(
proc_dir, f"https://x.example/dup{i}", f"重复新闻{i}", base_content
)
# 互不相同的其它唯一文章
for i in range(uniq_count):
_write_article(
proc_dir, f"https://x.example/uniq{i}", f"独立新闻{i}",
f"完全不同的正文内容片段编号 {i},讲述另一件事。" * 3,
)
return proc_dir
def _run_dedup_main(tmp_path: Path, *extra_args: str) -> int:
"""以指定参数调用 scripts.run_dedup.main,返回退出码。"""
import scripts.run_dedup as mod
day = "20260616"
argv = [
"run_dedup",
"--processed-root", str(tmp_path / "processed"),
"--out-root", str(tmp_path / "deduped"),
"--db", str(tmp_path / "fingerprints.sqlite3"),
"--date", day,
"--log-level", "ERROR",
*extra_args,
]
old_argv = sys.argv
sys.argv = argv
try:
return mod.main()
finally:
sys.argv = old_argv
def _read_stats(tmp_path: Path) -> dict:
p = tmp_path / "deduped" / "20260616" / "stats.json"
assert p.is_file(), "stats.json 未生成"
return json.loads(p.read_text(encoding="utf-8"))
# --------------------------------------------------------------------------- #
# 返回码语义
# --------------------------------------------------------------------------- #
def test_production_mode_high_dup_returns_0(tmp_path: Path) -> None:
"""生产模式:高重复率(90%)不影响退出码,返回 0。"""
_setup_fixture(tmp_path, dup_count=9, uniq_count=0)
rc = _run_dedup_main(tmp_path)
assert rc == 0, "生产模式下重复率仅作告警,不应改变退出码"
def test_strict_mode_high_dup_returns_1(tmp_path: Path) -> None:
"""验收模式 --strict:重复率超过 5% 门槛时返回 1。"""
_setup_fixture(tmp_path, dup_count=9, uniq_count=0)
rc = _run_dedup_main(tmp_path, "--strict")
assert rc == 1
def test_strict_mode_low_dup_returns_0(tmp_path: Path) -> None:
"""验收模式:重复率 ≤ 5% 时返回 0。"""
_setup_fixture(tmp_path, dup_count=0, uniq_count=10)
rc = _run_dedup_main(tmp_path, "--strict")
assert rc == 0
def test_empty_day_returns_0(tmp_path: Path) -> None:
"""当日无文章时返回 0(两种模式)。"""
proc_dir = tmp_path / "processed" / "testsrc" / "20260616"
proc_dir.mkdir(parents=True)
assert _run_dedup_main(tmp_path) == 0
assert _run_dedup_main(tmp_path, "--strict") == 0
# --------------------------------------------------------------------------- #
# stats.json 统计快照
# --------------------------------------------------------------------------- #
def test_stats_json_written_and_correct(tmp_path: Path) -> None:
"""stats.json 结构正确:重复率、唯一/重复数、指纹库总量。"""
_setup_fixture(tmp_path, dup_count=9, uniq_count=0)
_run_dedup_main(tmp_path)
s = _read_stats(tmp_path)
assert s["date"] == "20260616"
assert s["unique"] == 1
assert s["duplicates"] == 9
assert s["total"] == 10
assert s["dup_rate"] == pytest.approx(0.9, abs=1e-6)
assert s["dup_rate_threshold"] == pytest.approx(0.05)
# 指纹库只保留唯一文章
assert s["fingerprint_total"] == 1
assert "generated_at" in s and "layers" in s
def test_stats_json_low_dup_rate(tmp_path: Path) -> None:
"""低重复率场景:dup_rate 为 0。"""
_setup_fixture(tmp_path, dup_count=0, uniq_count=5)
_run_dedup_main(tmp_path)
s = _read_stats(tmp_path)
assert s["unique"] == 5
assert s["duplicates"] == 0
assert s["dup_rate"] == 0.0
assert s["fingerprint_total"] == 5
# --------------------------------------------------------------------------- #
# pipeline: dedup 不再特判(真实失败现在会正确上报)
# --------------------------------------------------------------------------- #
def test_pipeline_dedup_failure_no_longer_masked(monkeypatch: pytest.MonkeyPatch) -> None:
"""移除特判后:dedup 非 0 退出码应如实标记为失败。"""
from datetime import date
from types import SimpleNamespace
from scheduler import pipeline
def _fake_run(cmd, timeout=None): # noqa: ARG001
return SimpleNamespace(returncode=1)
monkeypatch.setattr(pipeline.subprocess, "run", _fake_run)
sr = pipeline.run_step("dedup", date.today().strftime("%Y%m%d"))
assert sr.success is False, "dedup 返回非 0 应标记失败(特判已移除)"
assert "rc=1" in sr.tail_msg
+164
View File
@@ -0,0 +1,164 @@
"""run_xwlb 脚本测试(处理日目录语义 + 幂等去重)。
覆盖:
- 落盘目录 = 处理日(而非数据日): 处理日 20260823, 抓数据日 20260822 的联播,
写入 data/raw/xwlb/20260823/
- 处理日目录下 index.jsonl 的 source_id/url_hash 正确
- 幂等: 同一处理日重复运行,index.jsonl 不产生重复行(url_hash 唯一)
- 日期格式错误返回 2,API 无数据返回 0
"""
from __future__ import annotations
import json
from datetime import date, timedelta
from pathlib import Path
from unittest import mock
from scripts.run_xwlb import _load_existing_index, _url_hash
# --------------------------------------------------------------------------- #
# 幂等辅助函数
# --------------------------------------------------------------------------- #
def test_url_hash_stable() -> None:
"""url_hash 稳定且为 SHA1 前 16 位。"""
h1 = _url_hash("xwlb://2026-08-22/3")
h2 = _url_hash("xwlb://2026-08-22/3")
assert h1 == h2
assert len(h1) == 16
assert _url_hash("xwlb://2026-08-22/3") != _url_hash("xwlb://2026-08-22/4")
def test_load_existing_index_empty(tmp_path: Path) -> None:
"""无 index.jsonl 时返回空 dict。"""
assert _load_existing_index(tmp_path / "index.jsonl") == {}
def test_load_existing_index_parses(tmp_path: Path) -> None:
"""能读取已有行,key=url_hash;跳过损坏行。"""
p = tmp_path / "index.jsonl"
p.write_text('{"url_hash": "abc", "url": "x"}\nnot-json\n{"url_hash": "def", "url": "y"}\n', encoding="utf-8")
existing = _load_existing_index(p)
assert set(existing) == {"abc", "def"}
def test_load_existing_index_fallback_url(tmp_path: Path) -> None:
"""无 url_hash 时用 url 兜底。"""
p = tmp_path / "index.jsonl"
p.write_text('{"url": "xwlb://2026-08-22/1"}\n', encoding="utf-8")
existing = _load_existing_index(p)
assert "xwlb://2026-08-22/1" in existing
# --------------------------------------------------------------------------- #
# 主流程(以 mock 网络方式调用 main)
# --------------------------------------------------------------------------- #
def _make_api_body() -> dict:
"""构造 doorcome xwlb API 响应体(3 条 + 1 条内容提要)。"""
news = []
for sid in range(1, 5):
news.append({
"daily_sub_id": sid,
"news_title": f"联播标题{sid}",
"news_improve": f"联播正文内容{sid}",
"news_days": "2026-08-22",
})
return {"data": {"news": news}}
def _run_main(tmp_path: Path, process_day: str, body: dict | None = None) -> int:
"""以 mock API 响应调用 scripts.run_xwlb.main。"""
import scripts.run_xwlb as mod
body = body if body is not None else _make_api_body()
with mock.patch.object(mod.urllib.request, "urlopen") as m_urlopen:
resp = mock.MagicMock()
resp.read.return_value = json.dumps(body).encode("utf-8")
m_urlopen.return_value.__enter__.return_value = resp
# 直接调用 main 前的 argparse 不方便,这里调用内部逻辑等价于 main:
# 通过 subprocess 太重,改为构造 args 后调用主流程函数(main 内联,故复制流程)。
# 简化:直接验证 _load_existing_index + 手写落盘逻辑的核心——用真实 main 需要 sys.argv。
# 因此此处通过 mock sys.argv 调用 main。
import sys
old_argv = sys.argv
sys.argv = [
"run_xwlb",
"--date", process_day,
"--output-root", str(tmp_path),
"--log-level", "ERROR",
]
try:
rc = mod.main()
finally:
sys.argv = old_argv
return rc
def test_main_writes_process_day_dir(tmp_path: Path) -> None:
"""落盘目录 = 处理日(data/raw/xwlb/20260823),而非数据日 20260822。"""
rc = _run_main(tmp_path, "20260823")
assert rc == 0
out_dir = tmp_path / "xwlb" / "20260823"
assert out_dir.is_dir()
# 数据日目录不应存在
assert not (tmp_path / "xwlb" / "20260822").is_dir()
lines = (out_dir / "index.jsonl").read_text(encoding="utf-8").splitlines()
assert len(lines) == 4 # 4 条(含提要,但脚本不去重提要,只跳过 sid<=1 的逻辑在 reporter)
# 校验字段
recs = [json.loads(l) for l in lines]
assert all(r["source_id"] == "xwlb" for r in recs)
assert all(r["stage"] == "article" for r in recs)
assert all(r["url_hash"] for r in recs)
# html 文件落盘
html_files = list(out_dir.glob("*.html"))
assert len(html_files) == 4
def test_main_idempotent_no_duplicate_rows(tmp_path: Path) -> None:
"""同一处理日重复运行: index.jsonl 不产生重复行(url_hash 唯一)。"""
rc1 = _run_main(tmp_path, "20260823")
rc2 = _run_main(tmp_path, "20260823")
assert rc1 == 0 and rc2 == 0
lines = (tmp_path / "xwlb" / "20260823" / "index.jsonl").read_text(encoding="utf-8").splitlines()
hashes = [json.loads(l)["url_hash"] for l in lines]
assert len(hashes) == len(set(hashes)), "重复运行产生重复行"
assert len(lines) == 4, "第二次运行应全部跳过,行数不变"
def test_main_different_process_days_isolated(tmp_path: Path) -> None:
"""不同处理日使用不同目录,互不污染。"""
_run_main(tmp_path, "20260823")
_run_main(tmp_path, "20260824")
assert (tmp_path / "xwlb" / "20260823").is_dir()
assert (tmp_path / "xwlb" / "20260824").is_dir()
# 20260824 的数据日是 20260823,若 mock 固定返回 2026-08-22 数据,
# 两个目录内容应相同字段结构,但互不影响
n1 = len((tmp_path / "xwlb" / "20260823" / "index.jsonl").read_text(encoding="utf-8").splitlines())
n2 = len((tmp_path / "xwlb" / "20260824" / "index.jsonl").read_text(encoding="utf-8").splitlines())
assert n1 == 4 and n2 == 4
def test_main_empty_news_returns_0(tmp_path: Path) -> None:
"""API 无数据: 返回 0,不落盘。"""
rc = _run_main(tmp_path, "20260823", body={"data": {"news": []}})
assert rc == 0
assert not (tmp_path / "xwlb" / "20260823").is_dir()
def test_main_bad_date_returns_2(tmp_path: Path) -> None:
"""--date 格式错误: 返回 2。"""
import sys
import scripts.run_xwlb as mod
old_argv = sys.argv
sys.argv = ["run_xwlb", "--date", "2026-13-99", "--output-root", str(tmp_path)]
try:
rc = mod.main()
finally:
sys.argv = old_argv
assert rc == 2
+10 -3
View File
@@ -56,9 +56,12 @@ def test_pipeline_result_partial_failure() -> None:
# --------------------------------------------------------------------------- #
def test_run_step_success() -> None:
# crawler 补跑保护:仅当天日期才执行子进程,故用今天的日期
from datetime import date
today = date.today().strftime("%Y%m%d")
with patch("subprocess.run", return_value=_mock_proc(returncode=0,
stderr="INFO | 完成: 成功 20/20")):
sr = run_step("crawler", "20260616")
sr = run_step("crawler", today)
assert sr.success is True
assert sr.exit_code == 0
@@ -109,6 +112,8 @@ def test_run_pipeline_all_success() -> None:
def test_run_pipeline_continues_on_failure() -> None:
"""中间步骤失败,后续继续执行(不阻断)。"""
from datetime import date
today = date.today().strftime("%Y%m%d") # crawler 补跑保护:需当天日期才执行
call_count = {"n": 0}
def _side_effect(*args, **kwargs):
@@ -118,7 +123,7 @@ def test_run_pipeline_continues_on_failure() -> None:
return _mock_proc(returncode=0, stderr="ok")
with patch("subprocess.run", side_effect=_side_effect):
result = run_pipeline("20260616", steps=["crawler", "extractor", "dedup", "llm"])
result = run_pipeline(today, steps=["crawler", "extractor", "dedup", "llm"])
assert len(result.steps) == 4
# extractor 失败,但后续仍执行
assert result.steps[1].success is False
@@ -126,7 +131,9 @@ def test_run_pipeline_continues_on_failure() -> None:
def test_run_pipeline_custom_steps() -> None:
from datetime import date
today = date.today().strftime("%Y%m%d")
with patch("subprocess.run", return_value=_mock_proc(returncode=0, stderr="ok")):
result = run_pipeline("20260616", steps=["crawler", "extractor"])
result = run_pipeline(today, steps=["crawler", "extractor"])
assert len(result.steps) == 2
assert result.all_success is True
+5 -5
View File
@@ -10,7 +10,6 @@
from __future__ import annotations
import os
import uuid
from datetime import datetime
from pathlib import Path
@@ -30,6 +29,8 @@ from qdrant_client.http.models import (
VectorParams,
)
from configs.runtime_env import env_get
from .models import CollectionInfo, SearchFilter, SearchResult
# 默认配置
@@ -52,10 +53,8 @@ def url_hash_to_uuid(url_hash: str) -> str:
def _read_env(key: str, default: str | None = None) -> str | None:
val = os.environ.get(key)
if val is None or val.strip() == "":
return default
return val.strip()
"""读取环境变量(先热加载 .env,改文件后无需重启进程)。"""
return env_get(key, default)
def make_qdrant_client(
@@ -257,6 +256,7 @@ class VectorStore:
title=payload.get("title") or "",
url=payload.get("url") or "",
source_id=payload.get("source_id") or "",
sources=payload.get("sources") or [],
publish_time=publish_time,
event=payload.get("event"),
char_count=payload.get("char_count"),
+4
View File
@@ -31,6 +31,10 @@ class SearchResult(BaseModel):
title: str
url: str
source_id: str
sources: list[str] = Field(
default_factory=list,
description="全部来源(主源居首),来自 M3 去重多源记录;旧数据可能为空",
)
publish_time: datetime | None = None
event: dict[str, Any] | None = None # EventExtraction 展开的 dict
char_count: int | None = None