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

@@ -0,0 +1,196 @@
"""知识文档的安全上传、解析、分块和索引状态编排。"""
from django.db import transaction
from django.utils import timezone
from apps.audit.models import OperationAuditLog
from apps.knowledge.models import KnowledgeDocument
from apps.knowledge.services import rebuild_document_chunks
from apps.tickets.materials import (
MaterialParseError,
MaterialScanError,
MaterialValidationError,
PreparedMaterial,
parse_material,
prepare_material,
scan_bytes,
)
from apps.tickets.storage import delete_object, get_object, put_object
def prepare_document(raw: bytes, file_name: str, content_type: str) -> PreparedMaterial:
prepared = prepare_material(raw, file_name, content_type)
if prepared.material_type == "image":
raise MaterialValidationError(415, "知识文档暂不支持图片,请上传可提取文本的文档")
return prepared
def create_uploaded_document(
*,
knowledge_base_id: int,
prepared: PreparedMaterial,
operator=None,
) -> KnowledgeDocument:
"""先保存原件再创建文档记录,提交后异步执行扫描、解析和索引。"""
document = KnowledgeDocument(
knowledge_base_id=knowledge_base_id,
file_name=prepared.file_name,
file_type=prepared.material_type,
extension=prepared.extension,
mime_type=prepared.mime_type,
file_size=len(prepared.data),
sha256=prepared.sha256,
scan_status="pending",
parse_status="pending",
created_by=operator,
)
document.storage_path = (
f"knowledge-documents/{knowledge_base_id}/{document.public_id}."
f"{prepared.extension}"
)
put_object(document.storage_path, prepared.data, prepared.mime_type)
try:
with transaction.atomic():
document.save()
OperationAuditLog.objects.create(
user=operator,
module="knowledge",
action="document_uploaded",
target_type="knowledge_document",
target_id=str(document.public_id),
request_snapshot={
"knowledge_base_id": knowledge_base_id,
"file_name": document.file_name,
"file_size": document.file_size,
"sha256": document.sha256,
},
result="success",
)
except Exception:
try:
delete_object(document.storage_path)
except Exception:
pass
raise
return document
def _mark_failed(
document: KnowledgeDocument,
*,
scan_status: str | None = None,
scan_result: dict | None = None,
message: str,
) -> None:
if scan_status is not None:
document.scan_status = scan_status
if scan_result is not None:
document.scan_result = scan_result
document.parse_status = "failed"
document.parse_error = message
document.indexed_at = None
document.save()
OperationAuditLog.objects.create(
user=document.created_by,
module="knowledge",
action="document_processing_failed",
target_type="knowledge_document",
target_id=str(document.public_id),
request_snapshot={
"scan_status": document.scan_status,
"error_type": message.split(":", 1)[0],
},
result="failed",
)
def process_knowledge_document(document_id: int) -> KnowledgeDocument:
"""处理单个知识文档;失败保留原件和明确状态供人工重试。"""
with transaction.atomic():
document = (
KnowledgeDocument.objects.select_for_update()
.select_related("knowledge_base", "created_by")
.get(id=document_id)
)
if document.parse_status != "pending" or document.scan_status != "pending":
return document
document.processing_attempts += 1
document.scan_status = "scanning"
document.scan_result = {}
document.parse_error = ""
document.indexed_at = None
document.save()
try:
raw = get_object(document.storage_path)
except Exception:
_mark_failed(document, message="原始文件读取失败:对象存储暂时不可用")
return document
try:
scan_result = scan_bytes(raw)
except MaterialScanError as error:
_mark_failed(
document,
scan_status="failed",
scan_result={"status": "failed", "message": str(error)},
message=f"恶意文件扫描失败:{error}",
)
return document
except Exception:
_mark_failed(
document,
scan_status="failed",
scan_result={"status": "failed"},
message="恶意文件扫描失败:扫描器发生未预期错误",
)
return document
document.scan_status = scan_result["status"]
document.scan_result = scan_result
if document.scan_status != "clean":
_mark_failed(
document,
scan_status=document.scan_status,
scan_result=scan_result,
message="恶意文件扫描未通过:原件已隔离保留",
)
return document
document.parse_status = "parsing"
document.save(update_fields=["scan_status", "scan_result", "parse_status", "updated_at"])
try:
content = parse_material(raw, document.extension)
if not content.strip():
raise MaterialParseError("文档中未提取到可索引文本")
except (MaterialParseError, MaterialValidationError) as error:
_mark_failed(document, message=f"文档解析失败:{error}")
return document
document.content_text = content
document.parse_status = "indexing"
document.parse_error = ""
document.save(update_fields=["content_text", "parse_status", "parse_error", "updated_at"])
try:
rebuild_document_chunks(document)
except Exception as error:
_mark_failed(
document,
message=f"文档索引失败:{type(error).__name__}",
)
return document
document.parse_status = "success"
document.parse_error = ""
document.indexed_at = timezone.now()
document.save(update_fields=["parse_status", "parse_error", "indexed_at", "updated_at"])
OperationAuditLog.objects.create(
user=document.created_by,
module="knowledge",
action="document_indexed",
target_type="knowledge_document",
target_id=str(document.public_id),
request_snapshot={
"knowledge_base_id": document.knowledge_base_id,
"chunk_count": document.chunks.count(),
"processing_attempts": document.processing_attempts,
},
result="success",
)
return document