Files
ai-contract-platform/backend/tests/test_knowledge_bases_api.py
T

511 lines
22 KiB
Python

import unittest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.knowledge_bases import get_audit_log_service, get_shared_ai_platform_client, get_tenant_service, require_current_user
from app.db import Base, get_db_session
from app.main import app
class StubAuditLogService:
def __init__(self):
self.events = []
def record_event(self, **kwargs):
self.events.append(kwargs)
return kwargs
class StubKnowledgeBaseService:
def list_knowledge_bases(self, current_user, page, page_size):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"items": [
{
"id": "kb-123",
"name": "劳动合同知识库",
"description": "劳动用工条款",
"status": "active",
"document_count": 1,
"retrieval_mode": "hybrid",
"created_at": "2026-04-16T00:00:00+00:00",
"updated_at": "2026-04-16T00:00:00+00:00",
}
],
"page": page,
"page_size": page_size,
"total": 1,
}
def create_knowledge_base(self, current_user, name, description):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"id": "kb-123",
"name": name,
"description": description,
"status": "active",
"document_count": 0,
"retrieval_mode": "hybrid",
"created_at": "2026-04-16T00:00:00+00:00",
"updated_at": "2026-04-16T00:00:00+00:00",
}
def list_knowledge_base_versions(self, current_user, knowledge_base_id):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return [
{
"id": "kbver-001",
"knowledge_base_id": knowledge_base_id,
"version_label": "v1",
"name": "劳动合同知识库",
"description": "劳动用工条款",
"retrieval_mode": "hybrid",
"status": "active",
"published_at": "2026-04-16T00:00:00+00:00",
"published_by": "user-admin",
"change_summary": "publish",
"created_at": "2026-04-16T00:00:00+00:00",
}
]
def publish_knowledge_base(self, current_user, knowledge_base_id):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"id": knowledge_base_id,
"name": "劳动合同知识库",
"description": "劳动用工条款",
"status": "active",
"document_count": 1,
"retrieval_mode": "hybrid",
"current_version_label": "v1",
"published_at": "2026-05-15T00:00:00+00:00",
"published_by": current_user["id"],
"created_at": "2026-04-16T00:00:00+00:00",
"updated_at": "2026-05-15T00:00:00+00:00",
}
def unpublish_knowledge_base(self, current_user, knowledge_base_id):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
result = self.publish_knowledge_base(current_user, knowledge_base_id)
result["status"] = "archived"
result["published_at"] = None
result["published_by"] = None
return result
def rollback_knowledge_base(self, current_user, knowledge_base_id, target_version_label, change_summary=None):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
result = self.publish_knowledge_base(current_user, knowledge_base_id)
result["current_version_label"] = target_version_label
return result
def list_documents(self, current_user, knowledge_base_id, page, page_size):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"items": [
{
"id": "kbdoc-123",
"knowledge_base_id": knowledge_base_id,
"file_name": "rules.txt",
"mime_type": "text/plain",
"file_size": 12,
"status": "indexed",
"source_type": "statute",
"authority": "全国人民代表大会",
"effective_date": "2021-01-01",
"region": "CN",
"tags": ["合同法", "通用"],
"content_excerpt": "合同解除规则",
"extracted_text_length": 6,
"preview_truncated": False,
"created_at": "2026-04-16T00:00:00+00:00",
"current_stage": "indexed",
"current_stage_label": "已完成索引",
"status_detail": "文本提取与索引已完成,可直接用于检索和问答。",
"parse_status": "completed",
"index_status": "completed",
"failure_reason": None,
"next_action": "可直接进入问答,或继续补充更多制度与条款文档。",
"retryable": False,
},
{
"id": "kbdoc-456",
"knowledge_base_id": knowledge_base_id,
"file_name": "draft.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"file_size": 18,
"status": "processing",
"source_type": "policy",
"authority": "企业法务部",
"effective_date": "2026-05-01",
"region": "CN-SH",
"tags": ["制度"],
"content_excerpt": None,
"extracted_text_length": 0,
"preview_truncated": False,
"created_at": "2026-04-16T00:10:00+00:00",
"current_stage": "parse",
"current_stage_label": "文本提取中",
"status_detail": "系统正在提取文本并写入检索索引。",
"parse_status": "processing",
"index_status": "blocked",
"failure_reason": "文本提取尚未完成,索引阶段暂时阻塞。",
"next_action": "请等待文本提取完成;提取成功后系统会继续进入索引阶段。",
"retryable": False,
}
],
"page": page,
"page_size": page_size,
"total": 2,
}
def upload_document(self, current_user, knowledge_base_id, file_name, mime_type, content):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
"id": "kbdoc-123",
"knowledge_base_id": knowledge_base_id,
"file_name": file_name,
"mime_type": mime_type,
"file_size": len(content),
"status": "indexed",
"content_excerpt": content.decode("utf-8"),
"extracted_text_length": len(content.decode("utf-8")),
"preview_truncated": False,
"created_at": "2026-04-16T00:00:00+00:00",
"current_stage": "indexed",
"current_stage_label": "已完成索引",
"status_detail": "文本提取与索引已完成,可直接用于检索和问答。",
"parse_status": "completed",
"index_status": "completed",
"failure_reason": None,
"next_action": "可直接进入问答,或继续补充更多制度与条款文档。",
"error_message": None,
}
def retry_document(self, current_user, knowledge_base_id, document_id, file_name=None, mime_type=None, content=None):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
indexed = content is not None and len(content) > 0
return {
"id": document_id,
"knowledge_base_id": knowledge_base_id,
"file_name": file_name or "draft.docx",
"mime_type": mime_type or "text/plain",
"file_size": len(content or b""),
"status": "indexed" if indexed else "failed",
"content_excerpt": content.decode("utf-8") if indexed else None,
"extracted_text_length": len(content.decode("utf-8")) if indexed else 0,
"preview_truncated": False,
"created_at": "2026-04-16T00:00:00+00:00",
"current_stage": "indexed" if indexed else "failed",
"current_stage_label": "已完成索引" if indexed else "文本提取失败",
"status_detail": "文本提取与索引已完成,可直接用于检索和问答。" if indexed else "重试需要重新上传可复制文本版本,当前记录未保留可重新索引的原文。",
"parse_status": "completed" if indexed else "failed",
"index_status": "completed" if indexed else "blocked",
"failure_reason": None if indexed else "重试需要重新上传可复制文本版本,当前记录未保留可重新索引的原文。",
"next_action": "可直接进入问答,或继续补充更多制度与条款文档。" if indexed else "请检查文件格式、内容编码或改为可复制文本后重新上传。",
"retryable": not indexed,
"error_message": None if indexed else "重试需要重新上传可复制文本版本,当前记录未保留可重新索引的原文。",
}
class StubTenantService:
def __init__(self, blocked_action=None):
self.blocked_action = blocked_action
self.calls = []
def assert_billing_allows(self, current_user, action, requested_file_size_bytes=0):
self.calls.append((action, requested_file_size_bytes))
if action == self.blocked_action:
raise HTTPException(status_code=402, detail="知识库或存储额度已用完,请升级套餐或购买超额包后继续使用。")
class KnowledgeBasesApiTestCase(unittest.TestCase):
def setUp(self):
self.audit_log_service = StubAuditLogService()
self.tenant_service = StubTenantService()
self.engine = create_engine(
"sqlite://",
future=True,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
self.testing_session_local = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
Base.metadata.create_all(bind=self.engine)
def override_db():
db = self.testing_session_local()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db_session] = override_db
app.dependency_overrides[get_shared_ai_platform_client] = lambda: StubKnowledgeBaseService()
app.dependency_overrides[get_audit_log_service] = lambda: self.audit_log_service
app.dependency_overrides[get_tenant_service] = lambda: self.tenant_service
self.client = TestClient(app)
def tearDown(self):
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_admin_can_list_knowledge_bases(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
response = self.client.get("/api/v1/knowledge-bases")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["items"][0]["name"], "劳动合同知识库")
def test_admin_can_list_documents_with_blocked_index_stage(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
response = self.client.get("/api/v1/knowledge-bases/kb-123/documents")
self.assertEqual(response.status_code, 200)
items = response.json()["data"]["items"]
self.assertEqual(len(items), 2)
blocked_item = items[1]
self.assertEqual(blocked_item["current_stage"], "parse")
self.assertEqual(blocked_item["index_status"], "blocked")
self.assertEqual(blocked_item["failure_reason"], "文本提取尚未完成,索引阶段暂时阻塞。")
self.assertIn("继续进入索引阶段", blocked_item["next_action"])
self.assertEqual(items[0]["authority"], "全国人民代表大会")
self.assertEqual(items[0]["region"], "CN")
def test_admin_can_create_knowledge_base(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
response = self.client.post(
"/api/v1/knowledge-bases",
data={"name": "采购合同知识库", "description": "采购模板与制度"},
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["name"], "采购合同知识库")
self.assertEqual(self.tenant_service.calls[0][0], "knowledge_base")
self.assertEqual(self.audit_log_service.events[0]["action"], "create_knowledge_base")
self.assertEqual(
self.audit_log_service.events[0]["detail_json"],
{
"name": "采购合同知识库",
"description": "采购模板与制度",
"status": "active",
"document_count": 0,
},
)
def test_admin_can_upload_document(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
response = self.client.post(
"/api/v1/knowledge-bases/kb-123/documents",
data={"note": "首批规则"},
files={"file": ("rules.txt", "合同解除规则".encode("utf-8"), "text/plain")},
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["file_name"], "rules.txt")
self.assertEqual(self.tenant_service.calls[0][0], "storage")
self.assertEqual(self.tenant_service.calls[0][1], len("合同解除规则".encode("utf-8")))
self.assertEqual(self.audit_log_service.events[0]["action"], "upload_knowledge_document")
self.assertEqual(
self.audit_log_service.events[0]["detail_json"],
{
"knowledge_base_id": "kb-123",
"file_name": "rules.txt",
"mime_type": "text/plain",
"file_size": len("合同解除规则".encode("utf-8")),
"status": "indexed",
"current_stage": "indexed",
"parse_status": "completed",
"index_status": "completed",
"error_message": None,
"note": "首批规则",
"source_type": None,
"authority": None,
"effective_date": None,
"region": None,
"tags": None,
},
)
def test_create_knowledge_base_returns_billing_block_message(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
app.dependency_overrides[get_tenant_service] = lambda: StubTenantService(blocked_action="knowledge_base")
response = self.client.post(
"/api/v1/knowledge-bases",
data={"name": "采购合同知识库", "description": "采购模板与制度"},
)
self.assertEqual(response.status_code, 402)
self.assertIn("额度已用完", response.json()["message"])
def test_admin_can_publish_unpublish_rollback_and_list_versions(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
versions_response = self.client.get("/api/v1/knowledge-bases/kb-123/versions")
publish_response = self.client.post("/api/v1/knowledge-bases/kb-123/publish")
unpublish_response = self.client.post("/api/v1/knowledge-bases/kb-123/unpublish")
rollback_response = self.client.post(
"/api/v1/knowledge-bases/kb-123/rollback",
data={"target_version_label": "v1", "change_summary": "rollback"},
)
self.assertEqual(versions_response.status_code, 200)
self.assertEqual(versions_response.json()["data"][0]["version_label"], "v1")
self.assertEqual(publish_response.status_code, 200)
self.assertEqual(publish_response.json()["data"]["published_by"], "user-admin")
self.assertEqual(unpublish_response.status_code, 200)
self.assertEqual(unpublish_response.json()["data"]["status"], "archived")
self.assertEqual(rollback_response.status_code, 200)
self.assertEqual(rollback_response.json()["data"]["current_version_label"], "v1")
self.assertEqual(self.audit_log_service.events[-3]["action"], "publish_knowledge_base")
self.assertEqual(self.audit_log_service.events[-2]["action"], "unpublish_knowledge_base")
self.assertEqual(self.audit_log_service.events[-1]["action"], "rollback_knowledge_base")
def test_admin_can_retry_document_with_replacement_file(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
response = self.client.post(
"/api/v1/knowledge-bases/kb-123/documents/kbdoc-456/retry",
data={"note": "替换为空文档"},
files={"file": ("rules-v2.txt", "补充合同审查规则".encode("utf-8"), "text/plain")},
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["data"]["id"], "kbdoc-456")
self.assertEqual(body["data"]["status"], "indexed")
self.assertEqual(self.audit_log_service.events[0]["action"], "retry_knowledge_document")
self.assertEqual(
self.audit_log_service.events[0]["detail_json"],
{
"knowledge_base_id": "kb-123",
"original_document_id": "kbdoc-456",
"file_name": "rules-v2.txt",
"mime_type": "text/plain",
"file_size": len("补充合同审查规则".encode("utf-8")),
"status": "indexed",
"current_stage": "indexed",
"parse_status": "completed",
"index_status": "completed",
"error_message": None,
"replacement_file": True,
"note": "替换为空文档",
"source_type": None,
"authority": None,
"effective_date": None,
"region": None,
"tags": None,
},
)
def test_uploaded_metadata_is_persisted_and_list_overlay_returns_it(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
upload_response = self.client.post(
"/api/v1/knowledge-bases/kb-123/documents",
data={
"note": "带法源元数据",
"source_type": "法律",
"authority": "全国人民代表大会",
"effective_date": "2021-01-01",
"region": "CN",
"tags_json": '["民法典","合同"]',
},
files={"file": ("rules.txt", "合同解除规则".encode("utf-8"), "text/plain")},
)
self.assertEqual(upload_response.status_code, 200)
listed = self.client.get("/api/v1/knowledge-bases/kb-123/documents")
self.assertEqual(listed.status_code, 200)
item = listed.json()["data"]["items"][0]
self.assertEqual(item["source_type"], "法律")
self.assertEqual(item["authority"], "全国人民代表大会")
self.assertEqual(item["effective_date"], "2021-01-01")
self.assertEqual(item["region"], "CN")
self.assertEqual(item["tags"], ["民法典", "合同"])
def test_invalid_tags_json_is_rejected(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-admin",
"tenant_id": "tenant-demo-001",
"role": "admin",
}
response = self.client.post(
"/api/v1/knowledge-bases/kb-123/documents",
data={"tags_json": '{"bad":"shape"}'},
files={"file": ("rules.txt", "abc".encode("utf-8"), "text/plain")},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["message"], "tags_json must be a JSON array of strings")
def test_member_cannot_access_knowledge_bases(self):
app.dependency_overrides[require_current_user] = lambda: {
"id": "user-member",
"tenant_id": "tenant-demo-001",
"role": "member",
}
response = self.client.get("/api/v1/knowledge-bases")
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["message"], "forbidden")
self.assertEqual(self.audit_log_service.events, [])
if __name__ == "__main__":
unittest.main()