Files
QMDSearch/app/core/runtime_settings.py
kplam 2ab8b56a01 feat: 完成全量功能开发,包括前端管理后台与后端服务优化
此提交实现了完整的知识库管理系统:
1. 新增Vue3 + Antd Vue前端管理后台,包含登录、文档管理、检索、类目设置等完整页面
2. 重构后端LLM调用抽象层,支持Ollama与OpenAI兼容服务动态切换
3. 调整默认嵌入模型配置为本地bge-m3模式
4. 优化入库任务去重逻辑与缓存清理机制
5. 完善Docker镜像构建与docker-compose部署配置
6. 修复多项测试用例与兼容性问题
7. 新增运行时配置API,支持动态调整系统参数
2026-07-31 12:05:25 +08:00

225 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""运行时可调配置(runtime settings
与 `app.config.settings`(启动时从环境变量加载、不可变)互补:本模块管理运行时
可通过管理后台动态调整的子集,持久化到 JSON 文件,启动时加载覆盖到内存单例。
设计要点:
- RuntimeSettings 只包含「可运行时调整」的字段(模型/解析插件/去重等),
不包含敏感或启动期固定的字段(端口、数据库连接等)。
- 持久化路径默认 `data/runtime_settings.json`,可由 env `RUNTIME_SETTINGS_PATH` 覆盖。
- 加载失败/文件缺失时回退到默认值,不阻塞启动。
- 写入采用「先临时文件后 rename」原子替换,避免半写损坏。
- 全模块只通过 `get_runtime_settings()` 访问单例,避免直接读 JSON。
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from threading import RLock
from typing import Any, Literal
import structlog
from pydantic import BaseModel, Field
from app.config import settings
logger = structlog.get_logger()
# 默认持久化路径(相对工作目录);可由 env 覆盖
DEFAULT_RUNTIME_SETTINGS_PATH = "./data/runtime_settings.json"
_lock = RLock()
# ---------------------------------------------------------------------------- #
# 模型配置(文档总结 / 查询 / 分类 各一份独立配置)
# ---------------------------------------------------------------------------- #
class LlmProviderConfig(BaseModel):
"""单个用途的 LLM 提供方配置"""
provider: Literal["ollama", "openai_compatible"] = Field(
default="ollama", description="提供方:ollama 走 Ollama HTTPopenai_compatible 走 OpenAI 兼容 chat/completions"
)
base_url: str = Field(default="", description="服务地址;空则 ollama 用 settings.ollama_base_urlopenai_compatible 用 settings.openai_base_url")
api_key: str = Field(default="", description="API Key(仅 openai_compatible 需要;ollama 忽略)")
model: str = Field(default="", description="模型名;空则 ollama 用 settings.ollama_modelopenai_compatible 用 settings.embedding_model 同级(如 gpt-4o-mini")
timeout: float = Field(default=120.0, description="请求超时秒数")
temperature: float = Field(default=0.3, description="采样温度(0~2")
class ModelSettings(BaseModel):
"""模型相关运行时配置:三种用途独立配置"""
summarize: LlmProviderConfig = Field(default_factory=LlmProviderConfig, description="文档三级总结")
query: LlmProviderConfig = Field(default_factory=LlmProviderConfig, description="检索链路 query 解析与结果总结")
classify: LlmProviderConfig = Field(default_factory=LlmProviderConfig, description="文档分类判定")
# ---------------------------------------------------------------------------- #
# 解析插件配置
# ---------------------------------------------------------------------------- #
class PluginConfig(BaseModel):
"""单个解析插件的选择与参数"""
plugin: str = Field(default="", description="插件名;空则用默认")
params: dict[str, Any] = Field(default_factory=dict, description="插件参数(透传给插件实现)")
class ParserSettings(BaseModel):
"""解析插件运行时配置"""
ocr: PluginConfig = Field(default_factory=lambda: PluginConfig(plugin="rapidocr"), description="OCR 插件")
pdf: PluginConfig = Field(default_factory=lambda: PluginConfig(plugin="pypdf"), description="PDF 文本层提取插件")
docx: PluginConfig = Field(default_factory=lambda: PluginConfig(plugin="python_docx"), description="DOCX 解析插件")
# ---------------------------------------------------------------------------- #
# 去重策略配置
# ---------------------------------------------------------------------------- #
class DedupSettings(BaseModel):
"""文本去重策略运行时配置"""
strategy: Literal["none", "sha256", "simhash"] = Field(
default="sha256", description="去重策略:none 关闭;sha256 精确匹配;simhash 近似匹配"
)
simhash_threshold: int = Field(default=3, description="simhash 海明距离阈值(仅 strategy=simhash 生效,0~64")
ttl_seconds: int = Field(default=86400, description="去重记录 Redis 保留秒数")
# ---------------------------------------------------------------------------- #
# 顶层 RuntimeSettings
# ---------------------------------------------------------------------------- #
class RuntimeSettings(BaseModel):
"""运行时可调配置顶层模型"""
models: ModelSettings = Field(default_factory=ModelSettings)
parsers: ParserSettings = Field(default_factory=ParserSettings)
dedup: DedupSettings = Field(default_factory=DedupSettings)
# ---------------------------------------------------------------------------- #
# 单例 + 持久化
# ---------------------------------------------------------------------------- #
_runtime_settings: RuntimeSettings | None = None
def _resolve_path() -> Path:
"""解析持久化文件路径:env RUNTIME_SETTINGS_PATH > settings 自定义 > 默认"""
path_str = os.environ.get("RUNTIME_SETTINGS_PATH", "") or getattr(settings, "runtime_settings_path", "") or DEFAULT_RUNTIME_SETTINGS_PATH
return Path(path_str).expanduser().resolve()
def _default_with_env_fallback() -> RuntimeSettings:
"""构造默认 RuntimeSettings,并把启动 env 中已有的模型相关字段填充进去
这样首次启动(无持久化文件)时,UI 显示的不是空字符串而是 env 当前值。
"""
cfg = RuntimeSettings()
# 模型默认值沿用 env
cfg.models.summarize.base_url = settings.ollama_base_url
cfg.models.summarize.model = settings.ollama_model
cfg.models.query.base_url = settings.ollama_base_url
cfg.models.query.model = settings.ollama_model
cfg.models.classify.base_url = settings.ollama_base_url
cfg.models.classify.model = settings.ollama_model
# 若 env 提供了 OpenAI key/url,预填到 openai_compatible 字段方便切换
if settings.openai_api_key:
for usage in ("summarize", "query", "classify"):
getattr(cfg.models, usage).api_key = settings.openai_api_key
getattr(cfg.models, usage).base_url = settings.openai_base_url if settings.openai_base_url else getattr(cfg.models, usage).base_url
return cfg
def load_runtime_settings() -> RuntimeSettings:
"""从磁盘加载 RuntimeSettings;文件缺失或损坏时回退到默认值(带 env 兜底)"""
path = _resolve_path()
try:
if path.exists():
data = json.loads(path.read_text(encoding="utf-8"))
return RuntimeSettings.model_validate(data)
except Exception:
logger.warning("RuntimeSettings 加载失败,回退默认值", path=str(path), exc_info=True)
return _default_with_env_fallback()
def save_runtime_settings(cfg: RuntimeSettings) -> None:
"""原子写入 RuntimeSettings 到磁盘(先临时文件后 rename)"""
path = _resolve_path()
path.parent.mkdir(parents=True, exist_ok=True)
data = cfg.model_dump(mode="json")
# 写到同目录临时文件再 rename,避免半写损坏
fd, tmp_path = tempfile.mkstemp(prefix=".runtime_settings.", suffix=".tmp", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
def get_runtime_settings() -> RuntimeSettings:
"""获取 RuntimeSettings 单例(首次调用时加载)"""
global _runtime_settings
with _lock:
if _runtime_settings is None:
_runtime_settings = load_runtime_settings()
return _runtime_settings
def update_runtime_settings(patch: dict[str, Any]) -> RuntimeSettings:
"""以 patch 字典更新 RuntimeSettings 并持久化
支持部分更新(顶层键可缺失,缺省保留原值)。例如:
update_runtime_settings({"models": {"summarize": {"model": "qwen2.5:3b"}}})
"""
with _lock:
current = get_runtime_settings()
merged = current.model_dump(mode="json")
_deep_merge(merged, patch)
new_cfg = RuntimeSettings.model_validate(merged)
save_runtime_settings(new_cfg)
# 替换单例后立即返回,新值对所有后续读取生效
global _runtime_settings
_runtime_settings = new_cfg
logger.info("RuntimeSettings 已更新并持久化", path=str(_resolve_path()))
return new_cfg
def _deep_merge(target: dict[str, Any], patch: dict[str, Any]) -> None:
"""递归把 patch 合并到 target(同 key 字典则递归,否则覆盖)"""
for k, v in patch.items():
if k in target and isinstance(target[k], dict) and isinstance(v, dict):
_deep_merge(target[k], v)
else:
target[k] = v
def reload_runtime_settings() -> RuntimeSettings:
"""强制从磁盘重新加载(管理后台触发)"""
with _lock:
global _runtime_settings
_runtime_settings = load_runtime_settings()
return _runtime_settings
def reset_runtime_settings() -> RuntimeSettings:
"""重置为默认值并持久化(管理后台触发)"""
with _lock:
new_cfg = _default_with_env_fallback()
save_runtime_settings(new_cfg)
global _runtime_settings
_runtime_settings = new_cfg
return new_cfg