Files
rag/tests/test_version_management.py
lacerate551 cb75b9b274 fix(boundary): 修复多库边界问题、版本管理及删除清理
多库检索与存储修复:
- RRF 融合去重改用 (collection, chunk_id) 复合键,修复同名文件结果被吞
- DocStore 存储路径加 collection 前缀,修复跨库同名切片数据覆盖
- search_multiple 去重改用复合键
- chunk_id 解析改用 rsplit 兼容下划线文件名

上传与版本管理修复:
- 同名文件上传改为覆盖模式,自动清理旧切片
- 修复首次上传不创建版本记录
- 修复覆盖上传版本号回退到 v1
- sync ADDED 分支改用动态版本号生成
- _generate_version_id 改为基于全部版本递增
- 废止/恢复操作同步 SQLite 版本记录
- mark_document_as_superseded 改为仅更新 SQLite

删除清理修复:
- 删除文档时同步清理 SQLite 版本记录和变更日志
- 删除向量库时同步清理该库所有版本记录
- cleanup 改为清理 SQLite 记录而非 ChromaDB

测试:
- test_version_management.py: 27 条版本管理单元测试
- test_edge_cases.py: 28 条边界用例测试
- test_upload_dedup.py: 5 条上传去重测试
- e2e_risk_test.py: 27 条端到端风险测试

文档:
- 新增风险边界问题修复注意事项.md(面向后端的对接文档)
- 新增向量库边界风险分析.md
- 更新多篇现有文档
2026-06-04 23:58:44 +08:00

438 lines
14 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.
# -*- coding: utf-8 -*-
"""
文档版本管理修复验证测试
测试覆盖:
1. mark_document_as_superseded 只更新 SQLite不操作 ChromaDB
2. deprecate_document 同步 SQLite 版本记录
3. restore_document 同步 SQLite 版本记录
4. 版本历史查询一致性
5. cleanup_superseded_versions 清理 SQLite 记录
6. 上传覆盖创建版本记录(逻辑验证)
"""
import sys
import os
import sqlite3
import tempfile
import shutil
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
passed = 0
failed = 0
def check(name, condition, detail=""):
global passed, failed
if condition:
print(f" [PASS] {name}")
passed += 1
else:
print(f" [FAIL] {name} {detail}")
failed += 1
# ======================================================================
# 辅助:创建临时 SQLite 数据库
# ======================================================================
def create_test_db():
"""创建内存中的测试数据库"""
conn = sqlite3.connect(":memory:")
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS document_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
collection TEXT,
version TEXT NOT NULL DEFAULT 'v1',
content_hash TEXT,
status TEXT NOT NULL DEFAULT 'active',
effective_date DATE,
expiry_date DATE,
deprecated_date DATETIME,
deprecated_reason TEXT,
deprecated_by TEXT,
change_summary TEXT,
changed_sections TEXT,
supersedes TEXT,
chunk_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by TEXT,
UNIQUE(document_id, collection, version)
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS version_change_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
collection TEXT,
old_version TEXT,
new_version TEXT,
old_status TEXT,
new_status TEXT,
change_type TEXT NOT NULL,
reason TEXT,
changed_by TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
return conn
def insert_version(conn, collection, doc_id, version, status="active"):
"""插入版本记录"""
conn.execute(
"""INSERT OR REPLACE INTO document_versions
(document_id, collection, version, status, created_at)
VALUES (?, ?, ?, ?, ?)""",
(doc_id, collection, version, status, datetime.now().isoformat())
)
conn.commit()
def get_versions(conn, collection, doc_id):
"""查询版本记录"""
cursor = conn.execute(
"""SELECT version, status, deprecated_date, deprecated_reason
FROM document_versions
WHERE collection = ? AND document_id = ?
ORDER BY created_at DESC""",
(collection, doc_id)
)
return cursor.fetchall()
def get_change_logs(conn, collection, doc_id):
"""查询变更日志"""
cursor = conn.execute(
"""SELECT change_type, old_version, new_version, old_status, new_status
FROM version_change_logs
WHERE collection = ? AND document_id = ?
ORDER BY created_at DESC""",
(collection, doc_id)
)
return cursor.fetchall()
# ======================================================================
# 测试 1mark_document_as_superseded 参数签名验证
# ======================================================================
print("\n=== 测试 1mark_document_as_superseded 新签名 ===")
from knowledge.manager import KnowledgeBaseManager
import inspect
sig = inspect.signature(KnowledgeBaseManager.mark_document_as_superseded)
params = list(sig.parameters.keys())
check("新签名包含 filename 参数",
"filename" in params,
f"参数列表: {params}")
check("新签名包含 new_version 参数",
"new_version" in params,
f"参数列表: {params}")
check("旧参数 old_filename 已移除",
"old_filename" not in params,
f"参数列表: {params}")
check("旧参数 new_filename 已移除",
"new_filename" not in params,
f"参数列表: {params}")
# ======================================================================
# 测试 2deprecate_document 同步 SQLitemock 验证)
# ======================================================================
print("\n=== 测试 2deprecate_document SQLite 同步逻辑 ===")
from knowledge.document import DocumentMixin
class MockDocumentManager(DocumentMixin):
"""模拟文档管理器"""
def __init__(self):
self.mock_collection = MagicMock()
self._bm25_indexes = {}
def get_collection(self, kb_name):
return self.mock_collection
def rebuild_bm25_index(self, kb_name):
pass
# 模拟场景3 个 chunkactive 状态
mgr = MockDocumentManager()
mgr.mock_collection.get.return_value = {
'ids': ['rule_0', 'rule_1', 'rule_2'],
'metadatas': [
{'source': 'rule.txt', 'status': 'active', 'version': 'v1'},
{'source': 'rule.txt', 'status': 'active', 'version': 'v1'},
{'source': 'rule.txt', 'status': 'active', 'version': 'v1'},
]
}
# 模拟 SQLite 连接
test_conn = create_test_db()
insert_version(test_conn, "test_kb", "rule.txt", "v1", "active")
with patch('data.db.get_connection', return_value=test_conn):
with patch('knowledge.document_versions.get_version_query') as mock_vq:
mock_vq_inst = MagicMock()
mock_vq_inst.get_active_version.return_value = MagicMock(version="v1")
mock_vq.return_value = mock_vq_inst
result = mgr.deprecate_document("test_kb", "rule.txt", reason="test deprecate")
check("deprecate 返回 success",
result.get("success") is True,
f"result: {result}")
check("deprecate 标记 3 个 chunks",
result.get("deprecated_chunks") == 3,
f"deprecated_chunks: {result.get('deprecated_chunks')}")
# 验证 SQLite 中的状态是否更新
versions = get_versions(test_conn, "test_kb", "rule.txt")
v1_status = versions[0][1] if versions else None
check("SQLite v1 状态改为 deprecated",
v1_status == "deprecated",
f"status: {v1_status}")
v1_deprecated_date = versions[0][2] if versions else None
check("SQLite deprecated_date 有值",
v1_deprecated_date is not None,
f"deprecated_date: {v1_deprecated_date}")
# ======================================================================
# 测试 3restore_document 同步 SQLitemock 验证)
# ======================================================================
print("\n=== 测试 3restore_document SQLite 同步逻辑 ===")
mgr2 = MockDocumentManager()
mgr2.mock_collection.get.return_value = {
'ids': ['rule_0', 'rule_1'],
'metadatas': [
{'source': 'rule.txt', 'status': 'deprecated'},
{'source': 'rule.txt', 'status': 'deprecated'},
]
}
with patch('data.db.get_connection', return_value=test_conn):
with patch('knowledge.document_versions.get_version_query') as mock_vq:
mock_vq_inst = MagicMock()
mock_vq_inst.get_document_history.return_value = [
MagicMock(status=MagicMock(value='deprecated'), version='v1')
]
mock_vq.return_value = mock_vq_inst
result = mgr2.restore_document("test_kb", "rule.txt")
check("restore 返回 success",
result.get("success") is True,
f"result: {result}")
check("restore 恢复 2 个 chunks",
result.get("restored_chunks") == 2,
f"restored_chunks: {result.get('restored_chunks')}")
versions = get_versions(test_conn, "test_kb", "rule.txt")
v1_status = versions[0][1] if versions else None
check("SQLite v1 状态恢复为 active",
v1_status == "active",
f"status: {v1_status}")
# ======================================================================
# 测试 4cleanup_superseded_versions 清理 SQLite 记录
# ======================================================================
print("\n=== 测试 4cleanup_superseded_versions SQLite 清理 ===")
from knowledge.cleanup import cleanup_superseded_versions
test_conn2 = create_test_db()
# 插入一条过期的 superseded 记录
old_date = (datetime.now() - timedelta(days=30)).isoformat()
test_conn2.execute(
"""INSERT INTO document_versions
(document_id, collection, version, status, deprecated_date)
VALUES (?, ?, ?, ?, ?)""",
("old_report.pdf", "public_kb", "v1", "superseded", old_date)
)
# 插入一条近期的 superseded 记录
recent_date = (datetime.now() - timedelta(days=2)).isoformat()
test_conn2.execute(
"""INSERT INTO document_versions
(document_id, collection, version, status, deprecated_date)
VALUES (?, ?, ?, ?, ?)""",
("new_report.pdf", "public_kb", "v1", "superseded", recent_date)
)
# 插入一条旧日志
test_conn2.execute(
"""INSERT INTO version_change_logs
(document_id, collection, change_type, created_at)
VALUES (?, ?, ?, ?)""",
("old_report.pdf", "public_kb", "supersede", old_date)
)
test_conn2.commit()
with patch('data.db.get_connection', return_value=test_conn2):
cleaned = cleanup_superseded_versions(days_to_keep=7)
check("清理了 1 条过期 superseded 记录",
cleaned == 1,
f"cleaned: {cleaned}")
# 验证剩余记录
remaining = test_conn2.execute(
"SELECT document_id, status FROM document_versions"
).fetchall()
check("近期 superseded 记录保留",
any(r[0] == "new_report.pdf" and r[1] == "superseded" for r in remaining),
f"remaining: {remaining}")
check("过期 superseded 记录已删除",
not any(r[0] == "old_report.pdf" for r in remaining),
f"remaining: {remaining}")
# 验证日志也被清理
logs_remaining = test_conn2.execute(
"SELECT COUNT(*) FROM version_change_logs"
).fetchone()[0]
check("过期变更日志已清理",
logs_remaining == 0,
f"logs_remaining: {logs_remaining}")
# ======================================================================
# 测试 5_filter_deprecated_chunks 过滤逻辑不变
# ======================================================================
print("\n=== 测试 5_filter_deprecated_chunks 过滤逻辑 ===")
from core.engine import RAGEngine
# 模拟搜索结果
mock_results = {
'ids': [['id1', 'id2', 'id3', 'id4']],
'documents': [['doc1', 'doc2', 'doc3', 'doc4']],
'metadatas': [[
{'source': 'a.txt', 'status': 'active'},
{'source': 'b.txt', 'status': 'deprecated'},
{'source': 'c.txt'}, # 无 status 字段,默认 active
{'source': 'd.txt', 'status': 'superseded'},
]],
'distances': [[0.1, 0.2, 0.3, 0.4]],
}
# 直接调用实例方法(传 None 作为 self方法中未使用 self
filter_fn = RAGEngine._filter_deprecated_chunks
filtered = filter_fn(None, mock_results)
check("过滤后保留 2 条active + 无status",
len(filtered['ids'][0]) == 2,
f"ids: {filtered['ids'][0]}")
check("保留的 id 是 id1 和 id3",
filtered['ids'][0] == ['id1', 'id3'],
f"ids: {filtered['ids'][0]}")
check("deprecated 被过滤",
'id2' not in filtered['ids'][0],
"")
check("superseded 被过滤",
'id4' not in filtered['ids'][0],
"")
# ======================================================================
# 测试 6upload 覆盖版本记录逻辑验证
# ======================================================================
print("\n=== 测试 6upload 覆盖 + sync 版本记录逻辑(代码路径验证)===")
# 验证 document_routes.py 中 replaced 分支有 superseded 标记代码
routes_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"api", "document_routes.py"
)
with open(routes_file, 'r', encoding='utf-8') as f:
source = f.read()
check("upload_document 中有 get_version_query 调用",
"get_version_query" in source,
"")
check("upload_document 中有 superseded 状态更新",
"status='superseded'" in source,
"")
check("upload_document replaced 分支标记旧版本",
"重新上传覆盖" in source,
"")
# 验证 sync.py ADDED 分支使用自动版本号(不硬编码 v1
sync_file2 = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"knowledge", "sync.py"
)
with open(sync_file2, 'r', encoding='utf-8') as f:
sync_src = f.read()
check("sync ADDED 分支使用 _generate_version_id",
"_generate_version_id" in sync_src,
"")
check("sync ADDED 版本记录使用动态版本号",
"version=new_version" in sync_src,
"")
# ======================================================================
# 测试 7sync.py MODIFIED 流程调用正确性
# ======================================================================
print("\n=== 测试 7sync.py MODIFIED 流程参数验证 ===")
sync_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"knowledge", "sync.py"
)
with open(sync_file, 'r', encoding='utf-8') as f:
sync_source = f.read()
check("MODIFIED 分支中 mark 调用包含 new_version 参数",
"new_version=new_version" in sync_source,
"")
# 在 MODIFIED 分支内验证调用顺序:先获取 old_version 再生成 new_version
modified_marker = "elif change.change_type == ChangeType.MODIFIED:"
mod_start = sync_source.index(modified_marker)
modified_section = sync_source[mod_start:mod_start + 2000]
check("MODIFIED 分支先获取 old_version 再生成 new_version",
"_get_current_version" in modified_section and
"_generate_version_id" in modified_section and
modified_section.index("_get_current_version") < modified_section.index("_generate_version_id"),
f"section contains: _get_current_version={'_get_current_version' in modified_section}, _generate_version_id={'_generate_version_id' in modified_section}")
check("MODIFIED 分支有 create_version_record 调用",
"create_version_record" in sync_source,
"")
# ======================================================================
# 汇总
# ======================================================================
print(f"\n{'='*60}")
print(f"测试完成: {passed} 通过, {failed} 失败, 共 {passed + failed}")
print(f"{'='*60}")
if failed > 0:
sys.exit(1)