Files
rag/tests/test_upload_dedup.py

394 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.
"""
Phase 3 验证测试:重复上传旧切片残留修复
测试场景:
1. add_file_to_kb() 中的"先删后加"逻辑
2. upload_document() 中的同名文件覆盖逻辑
3. 批量上传中的同名文件处理
"""
import os
import sys
import shutil
import uuid
from pathlib import Path
# 添加项目根目录到 Python 路径
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():
"""测试 add_file_to_kb() 中的去重逻辑"""
print("\n=== 测试 1add_file_to_kb 去重逻辑 ===")
# 模拟 ChromaDB collection
class MockCollection:
def __init__(self):
self.data = {} # id -> {doc, meta, embedding}
def add(self, ids, documents, metadatas, embeddings):
for i, doc, meta, emb in zip(ids, documents, metadatas, embeddings):
self.data[i] = {'doc': doc, 'meta': meta, 'embedding': emb}
def get(self, where=None, ids=None, include=None):
results = {'ids': [], 'documents': [], 'metadatas': [], 'embeddings': []}
if ids:
for id in ids:
if id in self.data:
results['ids'].append(id)
results['documents'].append(self.data[id]['doc'])
results['metadatas'].append(self.data[id]['meta'])
results['embeddings'].append(self.data[id]['embedding'])
return results
for id, item in self.data.items():
if where:
# 简单的 where 过滤
match = True
for k, v in where.items():
if item['meta'].get(k) != v:
match = False
break
if not match:
continue
results['ids'].append(id)
results['documents'].append(item['doc'])
results['metadatas'].append(item['meta'])
results['embeddings'].append(item['embedding'])
return results
def delete(self, ids):
for id in ids:
self.data.pop(id, None)
def count(self):
return len(self.data)
# 模拟知识库管理器(仅测试去重逻辑)
class MockKBManager:
def __init__(self):
self.collections = {'test_kb': MockCollection()}
self._bm25_indexes = {}
def get_collection(self, kb_name):
return self.collections.get(kb_name)
def rebuild_bm25_index(self, kb_name):
pass
def add_file_to_kb_logic(self, kb_name, filename, chunks):
"""模拟 add_file_to_kb 的核心逻辑"""
collection = self.get_collection(kb_name)
if not collection:
return 0
# 入库前清理同名旧切片(核心修复)
existing = collection.get(where={"source": filename})
if existing and existing['ids']:
old_count = len(existing['ids'])
collection.delete(ids=existing['ids'])
print(f" [清理] 删除旧切片: {filename}, 共 {old_count}")
# 添加新切片
ids = []
documents = []
metadatas = []
embeddings = []
for i, chunk in enumerate(chunks):
chunk_id = f"{filename}_{i}"
ids.append(chunk_id)
documents.append(chunk)
metadatas.append({
"source": filename,
"chunk_index": i,
"collection": kb_name
})
embeddings.append([0.1] * 768) # 模拟向量
collection.add(ids, documents, metadatas, embeddings)
return len(ids)
# 测试场景 1首次添加
kb = MockKBManager()
count1 = kb.add_file_to_kb_logic('test_kb', '规章制度.pdf', ['内容1', '内容2', '内容3'])
assert count1 == 3, f"首次添加应返回 3实际 {count1}"
assert kb.collections['test_kb'].count() == 3, f"向量库应有 3 个切片,实际 {kb.collections['test_kb'].count()}"
print(f" [PASS] 首次添加: {count1} 个切片")
# 测试场景 2重复上传同名文件应清理旧切片
count2 = kb.add_file_to_kb_logic('test_kb', '规章制度.pdf', ['新内容1', '新内容2'])
assert count2 == 2, f"重复上传应返回 2实际 {count2}"
assert kb.collections['test_kb'].count() == 2, f"向量库应有 2 个切片(旧切片已清理),实际 {kb.collections['test_kb'].count()}"
print(f" [PASS] 重复上传: {count2} 个切片(旧切片已清理)")
# 验证切片内容是新的
result = kb.collections['test_kb'].get()
assert '新内容1' in result['documents'], "切片内容应为新内容"
assert '内容1' not in result['documents'], "旧内容应已被删除"
print(f" [PASS] 切片内容已更新为新版本")
# 测试场景 3不同名文件不互相影响
count3 = kb.add_file_to_kb_logic('test_kb', '操作手册.pdf', ['手册1'])
assert count3 == 1, f"添加新文件应返回 1实际 {count3}"
assert kb.collections['test_kb'].count() == 3, f"向量库应有 3 个切片2+1实际 {kb.collections['test_kb'].count()}"
print(f" [PASS] 不同名文件独立存在: 总计 {kb.collections['test_kb'].count()} 个切片")
def test_upload_document_overwrite():
"""测试 upload_document() 中的同名文件覆盖逻辑"""
print("\n=== 测试 2upload_document 同名文件覆盖 ===")
# 创建临时目录
temp_dir = _make_test_dir()
try:
target_dir = os.path.join(temp_dir, 'public_kb')
os.makedirs(target_dir, exist_ok=True)
# 创建旧文件
old_file = os.path.join(target_dir, '规章制度.pdf')
with open(old_file, 'w', encoding='utf-8') as f:
f.write('旧内容')
assert os.path.exists(old_file), "旧文件应存在"
old_size = os.path.getsize(old_file)
print(f" [准备] 创建旧文件: {old_file}, 大小 {old_size} 字节")
# 模拟上传逻辑:同名文件覆盖
filename = '规章制度.pdf'
filepath = os.path.join(target_dir, filename)
replaced = False
if os.path.exists(filepath):
replaced = True
# 这里应该调用 kb_manager 清理旧切片,简化测试只验证文件覆盖
print(f" [检测] 发现同名文件,准备覆盖")
# 保存新文件(覆盖)
with open(filepath, 'w', encoding='utf-8') as f:
f.write('新内容,更长一些')
assert replaced, "应检测到同名文件并标记 replaced=True"
assert os.path.exists(filepath), "新文件应存在"
new_size = os.path.getsize(filepath)
assert new_size > old_size, f"新文件应更大({new_size} > {old_size}"
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
assert content == '新内容,更长一些', "文件内容应为新内容"
print(f" [PASS] 文件已覆盖: {old_size} -> {new_size} 字节")
print(f" [PASS] replaced={replaced}")
finally:
_cleanup_test_dir(temp_dir)
def test_batch_upload_overwrite():
"""测试批量上传中的同名文件覆盖逻辑"""
print("\n=== 测试 3批量上传同名文件覆盖 ===")
temp_dir = _make_test_dir()
try:
target_dir = os.path.join(temp_dir, 'public_kb')
os.makedirs(target_dir, exist_ok=True)
# 创建旧文件
old_file = os.path.join(target_dir, '规章制度.pdf')
with open(old_file, 'w', encoding='utf-8') as f:
f.write('旧内容')
# 模拟批量上传逻辑
files = [
('规章制度.pdf', '新内容1'),
('操作手册.pdf', '手册内容'),
]
results = []
for filename, content in files:
filepath = os.path.join(target_dir, filename)
replaced = False
if os.path.exists(filepath):
replaced = True
print(f" [检测] {filename} 已存在,准备覆盖")
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
results.append({
'filename': filename,
'replaced': replaced
})
# 验证结果
assert len(results) == 2, f"应处理 2 个文件,实际 {len(results)}"
assert results[0]['replaced'] is True, "规章制度.pdf 应标记为 replaced"
assert results[1]['replaced'] is False, "操作手册.pdf 不应标记为 replaced"
print(f" [PASS] 批量上传: {len(results)} 个文件")
print(f" [PASS] 规章制度.pdf: replaced={results[0]['replaced']}")
print(f" [PASS] 操作手册.pdf: replaced={results[1]['replaced']}")
# 验证文件内容
with open(old_file, 'r', encoding='utf-8') as f:
content = f.read()
assert content == '新内容1', "规章制度.pdf 应为新内容"
print(f" [PASS] 文件内容已更新")
finally:
_cleanup_test_dir(temp_dir)
def test_docstore_cleanup():
"""测试 DocStore 文件清理逻辑"""
print("\n=== 测试 4DocStore 文件清理 ===")
temp_dir = _make_test_dir()
try:
docstore_dir = Path(temp_dir) / 'docstore'
docstore_dir.mkdir()
# 创建模拟的 DocStore 文件
collection = 'public_kb'
filename = '规章制度.pdf'
# 旧切片对应的 DocStore 文件
old_files = [
f'{collection}_{filename}_0.json',
f'{collection}_{filename}_1.json',
f'{collection}_{filename}_2.json',
]
# 其他文件的 DocStore不应被清理
other_files = [
f'{collection}_操作手册.pdf_0.json',
f'dept_1_kb_{filename}_0.json', # 不同 collection
]
for f in old_files + other_files:
(docstore_dir / f).write_text('{"test": "data"}', encoding='utf-8')
assert len(list(docstore_dir.glob('*.json'))) == 5, "应有 5 个 DocStore 文件"
print(f" [准备] 创建 5 个 DocStore 文件")
# 模拟清理逻辑
cleaned = 0
for ds_file in docstore_dir.glob(f'{collection}_{filename}_*.json'):
ds_file.unlink()
cleaned += 1
assert cleaned == 3, f"应清理 3 个旧 DocStore 文件,实际 {cleaned}"
remaining = list(docstore_dir.glob('*.json'))
assert len(remaining) == 2, f"应剩余 2 个 DocStore 文件,实际 {len(remaining)}"
print(f" [PASS] 清理了 {cleaned} 个旧 DocStore 文件")
print(f" [PASS] 剩余 {len(remaining)} 个无关文件未被清理")
finally:
_cleanup_test_dir(temp_dir)
def test_sync_hash_cleanup():
"""测试同步哈希记录清理逻辑"""
print("\n=== 测试 5同步哈希记录清理 ===")
# 模拟 SyncDatabase
class MockSyncDatabase:
def __init__(self):
self.hashes = {}
def set_document_hash(self, doc_id, doc_name, hash_val, size, mtime):
self.hashes[doc_id] = {
'document_id': doc_id,
'document_name': doc_name,
'content_hash': hash_val,
'file_size': size,
'last_modified': mtime
}
def get_document_hash(self, doc_id):
return self.hashes.get(doc_id)
def delete_document_hash(self, doc_id):
self.hashes.pop(doc_id, None)
db = MockSyncDatabase()
# 添加旧哈希记录
db.set_document_hash('public_kb/规章制度.pdf', '规章制度.pdf', 'old_hash_abc', 1024, '2026-01-01')
assert db.get_document_hash('public_kb/规章制度.pdf') is not None, "旧哈希应存在"
print(f" [准备] 创建旧哈希记录: public_kb/规章制度.pdf")
# 模拟上传时清理哈希
collection = 'public_kb'
filename = '规章制度.pdf'
db.delete_document_hash(f"{collection}/{filename}")
assert db.get_document_hash('public_kb/规章制度.pdf') is None, "旧哈希应已被清理"
print(f" [PASS] 哈希记录已清理")
def run_all_tests():
"""运行所有测试"""
print("=" * 50)
print("Phase 3 验证测试:重复上传旧切片残留修复")
print("=" * 50)
tests = [
test_add_file_to_kb_dedup,
test_upload_document_overwrite,
test_batch_upload_overwrite,
test_docstore_cleanup,
test_sync_hash_cleanup,
]
passed = 0
failed = 0
for test in tests:
try:
test()
passed += 1
except Exception as e:
print(f" [FAIL] {test.__name__}: {e}")
import traceback
traceback.print_exc()
failed += 1
print("\n" + "=" * 50)
print(f"测试结果:{passed} 通过 / {failed} 失败 / {len(tests)} 总计")
if failed == 0:
print("全部通过!")
else:
print(f"{failed} 个测试失败")
print("=" * 50)
return failed == 0
if __name__ == '__main__':
success = run_all_tests()
sys.exit(0 if success else 1)