feat: Token Plan 迁移与 .env 热加载,并修复日报 AI 摘要为空
Token Plan 迁移 / 配置热加载:
- configs/llm_models.yaml: 各场景切到 Token Plan(deepseek-v4.1-flash / qwen3.6-flash)
- 新增 configs/runtime_env.py: .env 按 (mtime_ns, size) 热加载并同步 os.environ,
统一 env_get 取值;llm / embedding / vectorstore / mcp / pipeline 改用 env_get
- configs/loader.py / scripts/run_scheduler.py 等配套调整
- 新增 tests/test_hot_reload.py
日报 AI 摘要为空修复(2026-09-25):
- 根因: 推理模型的 reasoning token 与正文共用 max_tokens, 预算 1500 被"思考"
占满 -> text_tokens=0 / finish_reason=length, 摘要静默为空且不重试
- daily_report 场景新增 max_tokens(默认 4000, YAML 保存即热生效);
LLMConfig 支持可选 max_tokens; 分块预算 800 -> 2000
- _llm_call 拆出 _call_once, 正文为空时自动加倍预算重试(上限 16000),
用尽才降级返回空串; 网络异常重试语义不变
- docs/user-guide.md 新增 FAQ; continuation.md 记录本次排查
- 已重跑 2026-09-25 日报(report_id=357)补回 466 字摘要
测试: 相关用例 56 passed(test_hot_reload 12 passed);
ruff 无新增问题; 3 个 crawler 既有失败与本改动无关
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user