2ab8b56a01
此提交实现了完整的知识库管理系统: 1. 新增Vue3 + Antd Vue前端管理后台,包含登录、文档管理、检索、类目设置等完整页面 2. 重构后端LLM调用抽象层,支持Ollama与OpenAI兼容服务动态切换 3. 调整默认嵌入模型配置为本地bge-m3模式 4. 优化入库任务去重逻辑与缓存清理机制 5. 完善Docker镜像构建与docker-compose部署配置 6. 修复多项测试用例与兼容性问题 7. 新增运行时配置API,支持动态调整系统参数
255 lines
9.9 KiB
Python
255 lines
9.9 KiB
Python
"""RuntimeSettings 单元测试:默认值 / 加载 / 保存 / 部分更新 / 重置 / 单例
|
||
|
||
每个测试通过 monkeypatch 把 RUNTIME_SETTINGS_PATH 指向独立临时文件,并在前后
|
||
重置模块级 `_runtime_settings` 单例,避免跨测试串扰。
|
||
"""
|
||
|
||
import json
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from app.core import runtime_settings as rs
|
||
|
||
|
||
@pytest.fixture
|
||
def isolated_settings_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||
"""每个测试独立持久化路径,并在前后清空模块级单例"""
|
||
path = tmp_path / "runtime_settings.json"
|
||
monkeypatch.setenv("RUNTIME_SETTINGS_PATH", str(path))
|
||
# 重置单例,强制下次 get_runtime_settings 重新加载
|
||
rs._runtime_settings = None
|
||
yield path
|
||
rs._runtime_settings = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------- #
|
||
# 默认值
|
||
# ---------------------------------------------------------------------------- #
|
||
|
||
|
||
class TestDefaults:
|
||
def test_default_models_provider_is_ollama(self):
|
||
cfg = rs.RuntimeSettings()
|
||
assert cfg.models.summarize.provider == "ollama"
|
||
assert cfg.models.query.provider == "ollama"
|
||
assert cfg.models.classify.provider == "ollama"
|
||
|
||
def test_default_parsers_plugins(self):
|
||
cfg = rs.RuntimeSettings()
|
||
assert cfg.parsers.ocr.plugin == "rapidocr"
|
||
assert cfg.parsers.pdf.plugin == "pypdf"
|
||
assert cfg.parsers.docx.plugin == "python_docx"
|
||
|
||
def test_default_dedup(self):
|
||
cfg = rs.RuntimeSettings()
|
||
assert cfg.dedup.strategy == "sha256"
|
||
assert cfg.dedup.simhash_threshold == 3
|
||
assert cfg.dedup.ttl_seconds == 86400
|
||
|
||
def test_default_with_env_fallback_uses_ollama_env(self):
|
||
"""无持久化文件时,base_url/model 取自 settings.ollama_*"""
|
||
cfg = rs._default_with_env_fallback()
|
||
from app.config import settings
|
||
|
||
assert cfg.models.summarize.base_url == settings.ollama_base_url
|
||
assert cfg.models.summarize.model == settings.ollama_model
|
||
assert cfg.models.query.base_url == settings.ollama_base_url
|
||
assert cfg.models.classify.model == settings.ollama_model
|
||
|
||
|
||
# ---------------------------------------------------------------------------- #
|
||
# 加载
|
||
# ---------------------------------------------------------------------------- #
|
||
|
||
|
||
class TestLoad:
|
||
def test_missing_file_returns_env_fallback(self, isolated_settings_path: Path):
|
||
"""文件不存在时回退到带 env 兜底的默认值(不抛异常)"""
|
||
assert not isolated_settings_path.exists()
|
||
cfg = rs.load_runtime_settings()
|
||
# base_url 应来自 env fallback
|
||
from app.config import settings
|
||
|
||
assert cfg.models.summarize.base_url == settings.ollama_base_url
|
||
|
||
def test_valid_file_parsed(self, isolated_settings_path: Path):
|
||
isolated_settings_path.write_text(
|
||
json.dumps(
|
||
{
|
||
"models": {
|
||
"summarize": {"provider": "openai_compatible", "model": "gpt-4o-mini", "api_key": "k"}
|
||
},
|
||
"dedup": {"strategy": "simhash", "simhash_threshold": 5},
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
cfg = rs.load_runtime_settings()
|
||
assert cfg.models.summarize.provider == "openai_compatible"
|
||
assert cfg.models.summarize.model == "gpt-4o-mini"
|
||
assert cfg.models.summarize.api_key == "k"
|
||
assert cfg.dedup.strategy == "simhash"
|
||
assert cfg.dedup.simhash_threshold == 5
|
||
# 未指定的字段保留默认
|
||
assert cfg.dedup.ttl_seconds == 86400
|
||
assert cfg.models.query.provider == "ollama"
|
||
|
||
def test_corrupted_file_falls_back(self, isolated_settings_path: Path):
|
||
isolated_settings_path.write_text("not-json{", encoding="utf-8")
|
||
cfg = rs.load_runtime_settings()
|
||
# 回退到默认(ollama provider)
|
||
assert cfg.models.summarize.provider == "ollama"
|
||
|
||
def test_invalid_values_falls_back(self, isolated_settings_path: Path):
|
||
"""字段值非法(如未知 provider)时整体回退默认"""
|
||
isolated_settings_path.write_text(
|
||
json.dumps({"models": {"summarize": {"provider": "unknown_provider"}}}),
|
||
encoding="utf-8",
|
||
)
|
||
cfg = rs.load_runtime_settings()
|
||
assert cfg.models.summarize.provider == "ollama" # 回退默认
|
||
|
||
|
||
# ---------------------------------------------------------------------------- #
|
||
# 保存
|
||
# ---------------------------------------------------------------------------- #
|
||
|
||
|
||
class TestSave:
|
||
def test_save_writes_valid_json(self, isolated_settings_path: Path):
|
||
cfg = rs.RuntimeSettings()
|
||
cfg.dedup.strategy = "simhash"
|
||
rs.save_runtime_settings(cfg)
|
||
assert isolated_settings_path.exists()
|
||
data = json.loads(isolated_settings_path.read_text(encoding="utf-8"))
|
||
assert data["dedup"]["strategy"] == "simhash"
|
||
|
||
def test_save_creates_parent_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||
path = tmp_path / "nested" / "deep" / "runtime_settings.json"
|
||
monkeypatch.setenv("RUNTIME_SETTINGS_PATH", str(path))
|
||
rs.save_runtime_settings(rs.RuntimeSettings())
|
||
assert path.exists()
|
||
|
||
def test_save_atomic_no_tmp_left(self, isolated_settings_path: Path):
|
||
"""保存后同目录无残留 .tmp 临时文件"""
|
||
rs.save_runtime_settings(rs.RuntimeSettings())
|
||
tmps = list(isolated_settings_path.parent.glob(".runtime_settings.*.tmp"))
|
||
assert tmps == []
|
||
|
||
|
||
# ---------------------------------------------------------------------------- #
|
||
# 单例 + reload
|
||
# ---------------------------------------------------------------------------- #
|
||
|
||
|
||
class TestSingleton:
|
||
def test_get_returns_singleton(self, isolated_settings_path: Path):
|
||
cfg1 = rs.get_runtime_settings()
|
||
cfg2 = rs.get_runtime_settings()
|
||
assert cfg1 is cfg2
|
||
|
||
def test_reload_rereads_disk(self, isolated_settings_path: Path):
|
||
"""reload 强制重新读盘,单例替换为新对象"""
|
||
cfg1 = rs.get_runtime_settings()
|
||
# 直接改盘上文件
|
||
isolated_settings_path.write_text(
|
||
json.dumps({"dedup": {"strategy": "none"}}), encoding="utf-8"
|
||
)
|
||
cfg2 = rs.reload_runtime_settings()
|
||
assert cfg2 is not cfg1
|
||
assert cfg2.dedup.strategy == "none"
|
||
|
||
|
||
# ---------------------------------------------------------------------------- #
|
||
# 部分更新(深合并)
|
||
# ---------------------------------------------------------------------------- #
|
||
|
||
|
||
class TestUpdate:
|
||
def test_partial_update_models_summarize(self, isolated_settings_path: Path):
|
||
rs.get_runtime_settings() # 初始化单例
|
||
new_cfg = rs.update_runtime_settings(
|
||
{"models": {"summarize": {"model": "qwen2.5:3b"}}}
|
||
)
|
||
assert new_cfg.models.summarize.model == "qwen2.5:3b"
|
||
# 其他字段保留
|
||
assert new_cfg.models.summarize.provider == "ollama"
|
||
assert new_cfg.models.query.provider == "ollama"
|
||
|
||
def test_partial_update_dedup(self, isolated_settings_path: Path):
|
||
rs.get_runtime_settings()
|
||
new_cfg = rs.update_runtime_settings(
|
||
{"dedup": {"strategy": "simhash", "simhash_threshold": 5}}
|
||
)
|
||
assert new_cfg.dedup.strategy == "simhash"
|
||
assert new_cfg.dedup.simhash_threshold == 5
|
||
# ttl 未在 patch 中,保留默认
|
||
assert new_cfg.dedup.ttl_seconds == 86400
|
||
|
||
def test_update_persists_to_disk(self, isolated_settings_path: Path):
|
||
rs.get_runtime_settings()
|
||
rs.update_runtime_settings({"dedup": {"strategy": "none"}})
|
||
data = json.loads(isolated_settings_path.read_text(encoding="utf-8"))
|
||
assert data["dedup"]["strategy"] == "none"
|
||
|
||
def test_update_replaces_singleton(self, isolated_settings_path: Path):
|
||
old = rs.get_runtime_settings()
|
||
new = rs.update_runtime_settings({"dedup": {"strategy": "none"}})
|
||
assert new is not old
|
||
# 后续 get 拿到的是新单例
|
||
assert rs.get_runtime_settings() is new
|
||
|
||
def test_update_empty_patch_keeps_all(self, isolated_settings_path: Path):
|
||
"""空 patch 不改变任何字段"""
|
||
rs.get_runtime_settings()
|
||
new_cfg = rs.update_runtime_settings({})
|
||
assert new_cfg.dedup.strategy == "sha256"
|
||
|
||
|
||
# ---------------------------------------------------------------------------- #
|
||
# 重置
|
||
# ---------------------------------------------------------------------------- #
|
||
|
||
|
||
class TestReset:
|
||
def test_reset_returns_defaults(self, isolated_settings_path: Path):
|
||
# 先污染
|
||
rs.get_runtime_settings()
|
||
rs.update_runtime_settings({"dedup": {"strategy": "none"}})
|
||
assert rs.get_runtime_settings().dedup.strategy == "none"
|
||
# 重置
|
||
cfg = rs.reset_runtime_settings()
|
||
assert cfg.dedup.strategy == "sha256"
|
||
assert cfg.dedup.simhash_threshold == 3
|
||
assert cfg.parsers.ocr.plugin == "rapidocr"
|
||
|
||
def test_reset_persists_to_disk(self, isolated_settings_path: Path):
|
||
rs.get_runtime_settings()
|
||
rs.update_runtime_settings({"dedup": {"strategy": "none"}})
|
||
rs.reset_runtime_settings()
|
||
data = json.loads(isolated_settings_path.read_text(encoding="utf-8"))
|
||
assert data["dedup"]["strategy"] == "sha256"
|
||
|
||
|
||
# ---------------------------------------------------------------------------- #
|
||
# 深合并工具函数
|
||
# ---------------------------------------------------------------------------- #
|
||
|
||
|
||
class TestDeepMerge:
|
||
def test_nested_dict_merged(self):
|
||
target = {"a": {"b": 1, "c": 2}, "d": 3}
|
||
rs._deep_merge(target, {"a": {"b": 10}})
|
||
assert target == {"a": {"b": 10, "c": 2}, "d": 3}
|
||
|
||
def test_non_dict_overrides(self):
|
||
target = {"a": {"b": 1}}
|
||
rs._deep_merge(target, {"a": 99})
|
||
assert target == {"a": 99}
|
||
|
||
def test_new_key_added(self):
|
||
target = {"a": 1}
|
||
rs._deep_merge(target, {"b": 2})
|
||
assert target == {"a": 1, "b": 2}
|