fix: restore main runtime sources and cache consistency

This commit is contained in:
User
2026-07-14 14:19:36 +08:00
parent e40989eeab
commit f951bc6598
15 changed files with 1664 additions and 29 deletions

101
tests/test_data_db.py Normal file
View File

@@ -0,0 +1,101 @@
"""data.db fresh-clone and transaction regression tests."""
import os
import shutil
import unittest
import uuid
import config
import data.db as db
from exam_pkg.local_db import ExamLocalDB
class DataDbTests(unittest.TestCase):
def setUp(self):
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
test_root = os.path.join(project_root, ".data", "test-runs")
os.makedirs(test_root, exist_ok=True)
self.temp_dir = os.path.join(test_root, f"run-{uuid.uuid4().hex}")
os.makedirs(self.temp_dir)
self.original_paths = db.DB_PATHS
self.original_is_dev = config.IS_DEV
db.DB_PATHS = {
"feedback": os.path.join(self.temp_dir, "prod", "feedback.db"),
"knowledge": os.path.join(self.temp_dir, "prod", "knowledge.db"),
"session": os.path.join(self.temp_dir, "dev", "session.db"),
"exam": os.path.join(self.temp_dir, "dev", "exam.db"),
}
config.IS_DEV = True
db._initialized = False
def tearDown(self):
db._initialized = False
db.DB_PATHS = self.original_paths
config.IS_DEV = self.original_is_dev
test_root = os.path.realpath(os.path.join(os.path.dirname(self.temp_dir)))
target = os.path.realpath(self.temp_dir)
if os.path.commonpath([test_root, target]) != test_root:
raise RuntimeError(f"拒绝清理测试目录之外的路径: {target}")
shutil.rmtree(target, ignore_errors=True)
def _table_names(self, db_name):
with db.get_connection(db_name) as conn:
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
return {row[0] for row in rows}
def test_fresh_clone_initializes_all_dev_databases(self):
db.init_databases()
for path in db.DB_PATHS.values():
self.assertTrue(os.path.isfile(path), path)
self.assertTrue({"feedbacks", "faqs", "faq_suggestions"} <= self._table_names("feedback"))
self.assertTrue({"document_hashes", "document_versions", "sync_status"} <= self._table_names("knowledge"))
self.assertTrue({"sessions", "messages", "audit_logs"} <= self._table_names("session"))
self.assertTrue({"questions", "exams", "exam_questions"} <= self._table_names("exam"))
with db.get_connection("knowledge") as conn:
columns = {
row[1] for row in conn.execute("PRAGMA table_info(document_versions)").fetchall()
}
self.assertTrue(
{"content_hash", "expiry_date", "deprecated_date", "deprecated_by", "changed_sections"}
<= columns
)
# Initialization must remain safe when several services call it.
db.init_databases()
def test_connection_commits_and_rolls_back(self):
with db.get_connection("session") as conn:
conn.execute("CREATE TABLE tx_test (value TEXT)")
conn.execute("INSERT INTO tx_test VALUES ('committed')")
with self.assertRaisesRegex(RuntimeError, "rollback"):
with db.get_connection("session") as conn:
conn.execute("INSERT INTO tx_test VALUES ('rolled-back')")
raise RuntimeError("rollback")
with db.get_connection("session") as conn:
values = [row[0] for row in conn.execute("SELECT value FROM tx_test").fetchall()]
self.assertEqual(["committed"], values)
def test_exam_creation_ignores_missing_question_ids(self):
exam_db = ExamLocalDB()
question_id = exam_db.add_question(
{"content": "示例题", "answer": "答案", "score": 2},
source_file="example.txt",
source_collection="public",
)
exam = exam_db.create_exam("回归测试", [question_id, "missing-question"])
self.assertEqual(1, exam["total_count"])
self.assertEqual([question_id], exam["question_ids"])
self.assertEqual(1, len(exam_db.get_exam(exam["id"])["questions"]))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,197 @@
"""Regression tests for branch-shared RAG behavior."""
import ast
import json
import os
import pathlib
import unittest
from datetime import datetime
from unittest.mock import patch
from core.cache import RAGCacheManager
from core.intent_analyzer import IntentAnalyzer
from exam_pkg.generator import QuestionGenerator, analyze_document_for_exam
from exam_pkg.grader import grade_fill_blank
from knowledge.sync import ChangeType, DocumentChange, KnowledgeSyncService
class CacheInvalidationTests(unittest.TestCase):
def test_kb_version_change_clears_rerank_scores(self):
cache = RAGCacheManager()
cache.set_rerank_scores("问题", ["doc-1"], [0.91])
self.assertEqual({"doc-1": 0.91}, cache.get_rerank_scores("问题", ["doc-1"]))
cache.increment_kb_version("public")
self.assertIsNone(cache.get_rerank_scores("问题", ["doc-1"]))
class IntentCacheTests(unittest.TestCase):
def test_unknown_semantic_cache_type_is_not_used_as_intent(self):
class FakeEmbeddingModel:
def encode(self, _text):
return [1.0, 0.0]
class FakeCache:
def __init__(self):
self.saved = None
def get(self, _embedding):
return {
"cache_type": "future_cache_type",
"rewritten_query": "错误的缓存结果",
}
def set(self, _embedding, value):
self.saved = value
def get_stats(self):
return {}
analyzer = IntentAnalyzer(model="mimo-v2.5")
analyzer._embedding_model = FakeEmbeddingModel()
analyzer._cache = FakeCache()
analyzer._client = object()
llm_result = json.dumps(
{
"rewritten_query": "来自 LLM 的新结果",
"use_context": False,
"need_retrieval": True,
"reason": "新问题",
"sub_queries": ["来自 LLM 的新结果"],
"intent": "factual",
},
ensure_ascii=False,
)
with patch("core.llm_utils.call_llm", return_value=llm_result):
result = analyzer.analyze("当前问题", [])
self.assertEqual("来自 LLM 的新结果", result.rewritten_query)
self.assertEqual("intent_analysis", analyzer._cache.saved["cache_type"])
class ExamRegressionTests(unittest.TestCase):
def test_analysis_prompt_respects_requested_max_total(self):
captured = {}
def fake_call(_self, prompt):
captured["prompt"] = prompt
return json.dumps(
{
"total_knowledge_points": 2,
"suitable_types": ["single_choice"],
"question_types": {
"single_choice": 3,
"multiple_choice": 0,
"true_false": 0,
"fill_blank": 0,
"subjective": 0,
},
"reason": "测试",
},
ensure_ascii=False,
)
chunks = [{"content": "知识点" * 80, "section": "第一章"}]
with patch.object(QuestionGenerator, "_call_llm", fake_call):
result = analyze_document_for_exam(chunks, max_total=3)
self.assertIn("所有数量之和不要超过 3", captured["prompt"])
self.assertLessEqual(sum(result["question_types"].values()), 3)
def test_fill_blank_rejects_non_list_student_answer(self):
result = grade_fill_blank(
{
"question_id": "q1",
"content": {"answer": [["正确答案"]], "data": {"blank_count": 1}},
"student_answer": "正确答案",
"max_score": 2,
}
)
self.assertEqual("failed", result["grading_status"])
self.assertEqual(0, result["score"])
def test_fill_blank_rejects_non_string_items(self):
result = grade_fill_blank(
{
"question_id": "q1",
"content": {"answer": [["正确答案"]], "data": {"blank_count": 1}},
"student_answer": [{"text": "正确答案"}],
"max_score": 2,
}
)
self.assertEqual("failed", result["grading_status"])
self.assertIn("第 1 项", result["details"]["error"])
class SyncInvalidationTests(unittest.TestCase):
def test_document_delete_clears_semantic_cache(self):
class FakeDb:
def delete_document_hash(self, _document_id):
return None
class FakeKbManager:
def delete_document(self, _kb_name, _document_name):
return 1
class FakeSemanticCache:
def __init__(self):
self.cleared = False
def clear(self):
self.cleared = True
service = object.__new__(KnowledgeSyncService)
service.documents_path = "unused"
service.db = FakeDb()
semantic_cache = FakeSemanticCache()
change = DocumentChange(
document_id="public/example.pdf",
document_name="example.pdf",
change_type=ChangeType.DELETED,
old_hash="old",
new_hash=None,
change_time=datetime.now(),
)
with (
patch("knowledge.manager.get_kb_manager", return_value=FakeKbManager()),
patch("knowledge.sync.CACHE_AVAILABLE", False),
patch("core.semantic_cache.get_semantic_cache", return_value=semantic_cache),
):
self.assertTrue(service.process_change(change))
self.assertTrue(semantic_cache.cleared)
class ConfigTemplateTests(unittest.TestCase):
def test_config_example_defines_all_direct_imports(self):
root = pathlib.Path(__file__).resolve().parents[1]
template_tree = ast.parse((root / "config.example.py").read_text(encoding="utf-8"))
defined = {
node.name
for node in template_tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
}
for node in template_tree.body:
if isinstance(node, ast.Assign):
defined.update(target.id for target in node.targets if isinstance(target, ast.Name))
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
defined.add(node.target.id)
imported = set()
excluded = {"venv", ".git", ".data", ".recovery", "__pycache__"}
for path in root.rglob("*.py"):
if any(part in excluded for part in path.parts):
continue
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module == "config":
imported.update(alias.name for alias in node.names)
self.assertEqual([], sorted(imported - defined))
if __name__ == "__main__":
unittest.main()

View File

@@ -9,12 +9,30 @@ Phase 3 验证测试:重复上传旧切片残留修复
import os
import sys
import tempfile
import shutil
import uuid
from pathlib import Path
# 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TEST_TEMP_ROOT = os.path.join(PROJECT_ROOT, ".data", "test-runs")
sys.path.insert(0, PROJECT_ROOT)
def _make_test_dir():
"""在工作区内创建测试目录,兼容 Codex 的 Windows 文件权限。"""
os.makedirs(TEST_TEMP_ROOT, exist_ok=True)
path = os.path.join(TEST_TEMP_ROOT, f"upload-{uuid.uuid4().hex}")
os.makedirs(path)
return path
def _cleanup_test_dir(path):
root = os.path.realpath(TEST_TEMP_ROOT)
target = os.path.realpath(path)
if os.path.commonpath([root, target]) != root:
raise RuntimeError(f"拒绝清理测试目录之外的路径: {target}")
shutil.rmtree(target, ignore_errors=True)
def test_add_file_to_kb_dedup():
@@ -143,7 +161,7 @@ def test_upload_document_overwrite():
print("\n=== 测试 2upload_document 同名文件覆盖 ===")
# 创建临时目录
temp_dir = tempfile.mkdtemp()
temp_dir = _make_test_dir()
try:
target_dir = os.path.join(temp_dir, 'public_kb')
os.makedirs(target_dir, exist_ok=True)
@@ -184,14 +202,14 @@ def test_upload_document_overwrite():
print(f" [PASS] replaced={replaced}")
finally:
shutil.rmtree(temp_dir)
_cleanup_test_dir(temp_dir)
def test_batch_upload_overwrite():
"""测试批量上传中的同名文件覆盖逻辑"""
print("\n=== 测试 3批量上传同名文件覆盖 ===")
temp_dir = tempfile.mkdtemp()
temp_dir = _make_test_dir()
try:
target_dir = os.path.join(temp_dir, 'public_kb')
os.makedirs(target_dir, exist_ok=True)
@@ -240,14 +258,14 @@ def test_batch_upload_overwrite():
print(f" [PASS] 文件内容已更新")
finally:
shutil.rmtree(temp_dir)
_cleanup_test_dir(temp_dir)
def test_docstore_cleanup():
"""测试 DocStore 文件清理逻辑"""
print("\n=== 测试 4DocStore 文件清理 ===")
temp_dir = tempfile.mkdtemp()
temp_dir = _make_test_dir()
try:
docstore_dir = Path(temp_dir) / 'docstore'
docstore_dir.mkdir()
@@ -289,7 +307,7 @@ def test_docstore_cleanup():
print(f" [PASS] 剩余 {len(remaining)} 个无关文件未被清理")
finally:
shutil.rmtree(temp_dir)
_cleanup_test_dir(temp_dir)
def test_sync_hash_cleanup():