feat: add governed knowledge lifecycle

This commit is contained in:
hailin
2026-07-27 19:01:19 +08:00
parent 1a49ce4eae
commit 07c9ea1cc4
32 changed files with 5893 additions and 178 deletions

View File

@@ -1,4 +1,15 @@
from apps.knowledge.models import KnowledgeBase, KnowledgeChunk, KnowledgeDocument, KnowledgeItem
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from apps.knowledge.documents import process_knowledge_document
from apps.knowledge.models import (
GovernedAsset,
KnowledgeBase,
KnowledgeChunk,
KnowledgeDocument,
KnowledgeItem,
)
from apps.tickets.materials import MaterialScanError
def test_create_and_list_knowledge_base(admin_client, db):
@@ -23,7 +34,7 @@ def test_create_and_list_knowledge_base(admin_client, db):
assert response.json()[0]["name"] == "税务采集知识库"
def test_create_knowledge_item_builds_chunks(admin_client, db):
def test_create_knowledge_item_always_creates_governed_draft(admin_client, db):
base = KnowledgeBase.objects.create(name="税务采集知识库", scope="数据采集")
response = admin_client.post(
@@ -45,8 +56,33 @@ def test_create_knowledge_item_builds_chunks(admin_client, db):
assert response.status_code == 200
item = KnowledgeItem.objects.get(id=response.json()["id"])
assert item.status == "published"
assert KnowledgeChunk.objects.filter(item=item).count() >= 1
assert item.status == "draft"
assert KnowledgeChunk.objects.filter(item=item).count() == 0
assert GovernedAsset.objects.filter(
asset_type="knowledge_item",
object_id=item.id,
versions__status="draft",
).exists()
def test_create_knowledge_item_rejects_unknown_category(admin_client, db):
base = KnowledgeBase.objects.create(name="税务采集知识库", scope="数据采集")
response = admin_client.post(
"/api/knowledge/items",
data={
"knowledge_base_id": base.id,
"title": "未知分类知识",
"problem": "分类编码不存在",
"category_code": "unknown_category",
"solution": "不应写入知识库",
},
content_type="application/json",
)
assert response.status_code == 400
assert response.json()["detail"] == "知识适用分类不存在或已停用"
assert not KnowledgeItem.objects.filter(title="未知分类知识").exists()
def test_ingest_document_text_creates_document_and_chunks(admin_client, db):
@@ -67,3 +103,223 @@ def test_ingest_document_text_creates_document_and_chunks(admin_client, db):
document = KnowledgeDocument.objects.get(id=response.json()["id"])
assert document.parse_status == "success"
assert KnowledgeChunk.objects.filter(document=document).count() >= 1
def test_upload_document_preserves_original_and_indexes_text(
admin_client,
db,
monkeypatch,
):
base = KnowledgeBase.objects.create(name="文件知识库", scope="技术支持")
objects = {}
monkeypatch.setattr(
"apps.knowledge.documents.put_object",
lambda path, raw, mime_type: objects.update({path: raw}),
)
monkeypatch.setattr(
"apps.knowledge.documents.get_object",
lambda path: objects[path],
)
monkeypatch.setattr(
"apps.knowledge.api.dispatch_knowledge_document_processing",
lambda document_id: process_knowledge_document(document_id),
)
response = admin_client.post(
f"/api/knowledge/documents/upload?knowledge_base_id={base.id}",
data={
"file": SimpleUploadedFile(
"support.log",
"接口超时,请检查连接池。".encode(),
content_type="text/plain",
)
},
)
assert response.status_code == 200
payload = response.json()
document = KnowledgeDocument.objects.get(public_id=payload["public_id"])
assert document.scan_status == "clean"
assert document.parse_status == "success"
assert document.storage_path in objects
assert document.content_text == "接口超时,请检查连接池。"
assert document.sha256
assert document.file_size == len(objects[document.storage_path])
assert document.processing_attempts == 1
assert document.indexed_at is not None
assert document.chunks.count() >= 1
assert payload["has_original"] is True
listed = admin_client.get(f"/api/knowledge/documents?knowledge_base_id={base.id}")
assert listed.status_code == 200
assert listed.json()[0]["public_id"] == str(document.public_id)
def test_upload_document_rejects_disguised_file(admin_client, db):
base = KnowledgeBase.objects.create(name="文件知识库")
response = admin_client.post(
f"/api/knowledge/documents/upload?knowledge_base_id={base.id}",
data={
"file": SimpleUploadedFile(
"fake.pdf",
b"not a pdf",
content_type="application/pdf",
)
},
)
assert response.status_code == 415
assert not KnowledgeDocument.objects.exists()
def test_document_scan_failure_keeps_original_and_can_retry(
admin_client,
db,
monkeypatch,
):
base = KnowledgeBase.objects.create(name="文件知识库")
objects = {}
monkeypatch.setattr(
"apps.knowledge.documents.put_object",
lambda path, raw, mime_type: objects.update({path: raw}),
)
monkeypatch.setattr(
"apps.knowledge.documents.get_object",
lambda path: objects[path],
)
monkeypatch.setattr(
"apps.knowledge.documents.scan_bytes",
lambda raw: (_ for _ in ()).throw(MaterialScanError("扫描服务不可用")),
)
monkeypatch.setattr(
"apps.knowledge.api.dispatch_knowledge_document_processing",
lambda document_id: process_knowledge_document(document_id),
)
response = admin_client.post(
f"/api/knowledge/documents/upload?knowledge_base_id={base.id}",
data={
"file": SimpleUploadedFile(
"support.txt",
"重试后可解析".encode(),
content_type="text/plain",
)
},
)
document = KnowledgeDocument.objects.get(public_id=response.json()["public_id"])
assert response.status_code == 200
assert document.scan_status == "failed"
assert document.parse_status == "failed"
assert document.storage_path in objects
assert document.chunks.count() == 0
monkeypatch.setattr(
"apps.knowledge.documents.scan_bytes",
lambda raw: {"status": "clean", "engine": "test"},
)
retried = admin_client.post(f"/api/knowledge/documents/{document.public_id}/retry")
assert retried.status_code == 200
document.refresh_from_db()
assert document.parse_status == "success"
assert document.processing_attempts == 2
assert document.chunks.count() >= 1
process_knowledge_document(document.id)
document.refresh_from_db()
assert document.processing_attempts == 2
def test_upload_document_storage_failure_does_not_create_record(
admin_client,
db,
monkeypatch,
):
base = KnowledgeBase.objects.create(name="文件知识库")
monkeypatch.setattr(
"apps.knowledge.documents.put_object",
lambda *args: (_ for _ in ()).throw(OSError("storage down")),
)
response = admin_client.post(
f"/api/knowledge/documents/upload?knowledge_base_id={base.id}",
data={
"file": SimpleUploadedFile(
"support.txt",
b"content",
content_type="text/plain",
)
},
)
assert response.status_code == 503
assert not KnowledgeDocument.objects.exists()
def test_download_uploaded_document_returns_original(
admin_client,
db,
monkeypatch,
):
base = KnowledgeBase.objects.create(name="文件知识库")
document = KnowledgeDocument.objects.create(
knowledge_base=base,
file_name="支持手册.txt",
file_type="text",
extension="txt",
mime_type="text/plain",
file_size=7,
sha256="a" * 64,
storage_path="knowledge-documents/manual.txt",
scan_status="clean",
parse_status="success",
)
monkeypatch.setattr("apps.knowledge.api.get_object", lambda path: b"content")
response = admin_client.get(f"/api/knowledge/documents/{document.public_id}/download")
assert response.status_code == 200
assert response.content == b"content"
assert response["Content-Type"] == "text/plain"
assert "attachment" in response["Content-Disposition"]
def test_download_infected_document_is_blocked(admin_client, db, monkeypatch):
base = KnowledgeBase.objects.create(name="文件知识库")
document = KnowledgeDocument.objects.create(
knowledge_base=base,
file_name="可疑文件.txt",
storage_path="knowledge-documents/infected.txt",
scan_status="infected",
parse_status="failed",
)
monkeypatch.setattr(
"apps.knowledge.api.get_object",
lambda path: (_ for _ in ()).throw(AssertionError("隔离文件不应从对象存储读取")),
)
response = admin_client.get(f"/api/knowledge/documents/{document.public_id}/download")
assert response.status_code == 423
@override_settings(KNOWLEDGE_EMBEDDING_ALLOW_HASH_FALLBACK=False)
def test_knowledge_draft_creation_does_not_require_embedding_deployment(admin_client, db):
base = KnowledgeBase.objects.create(name="正式知识库")
response = admin_client.post(
"/api/knowledge/items",
data={
"knowledge_base_id": base.id,
"title": "订单超时",
"problem": "订单接口超时",
"solution": "检查下游连接池",
"status": "published",
},
content_type="application/json",
)
assert response.status_code == 200
item = KnowledgeItem.objects.get(title="订单超时")
assert item.status == "draft"
assert item.chunks.count() == 0