Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff911cf6f7 | ||
|
|
2eaea2ee81 | ||
|
|
6dede790c6 | ||
|
|
4fa48ff030 | ||
|
|
7b33182f67 | ||
|
|
639d857ffb | ||
|
|
80828310d6 | ||
|
|
8fa27ad65b | ||
|
|
85ba218f2f | ||
|
|
7ea8925209 |
@@ -41,6 +41,11 @@ reasonix.toml
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
# 迁移/切换服务商时留下的 .env 备份(含密钥,禁止入库)
|
||||
.env*
|
||||
!.env.example
|
||||
*.bak
|
||||
*.bak*
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 场景)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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])}")
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+84
-17
@@ -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,11 +154,19 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -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, "异常后槽位未释放"
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 取数逻辑:应查询日报前一日(已播出的联播),并跳过内容提要。"""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user