feat: 完成全量功能开发,包括前端管理后台与后端服务优化

此提交实现了完整的知识库管理系统:
1. 新增Vue3 + Antd Vue前端管理后台,包含登录、文档管理、检索、类目设置等完整页面
2. 重构后端LLM调用抽象层,支持Ollama与OpenAI兼容服务动态切换
3. 调整默认嵌入模型配置为本地bge-m3模式
4. 优化入库任务去重逻辑与缓存清理机制
5. 完善Docker镜像构建与docker-compose部署配置
6. 修复多项测试用例与兼容性问题
7. 新增运行时配置API,支持动态调整系统参数
This commit is contained in:
2026-07-31 12:05:25 +08:00
parent fdb664e546
commit 2ab8b56a01
52 changed files with 7030 additions and 171 deletions
+254
View File
@@ -0,0 +1,254 @@
"""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}