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
- 更新多篇现有文档
This commit is contained in:
lacerate551
2026-06-04 23:58:44 +08:00
parent a1a0814633
commit cb75b9b274
50 changed files with 6385 additions and 6248 deletions

View File

@@ -0,0 +1,437 @@
# -*- 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)