Files
ai-contract-platform/backend/app/services/knowledge_base_service.py
T

354 lines
16 KiB
Python

import io
import re
import zipfile
from typing import Any, Dict, Optional
from uuid import uuid4
from xml.etree import ElementTree
import pdfplumber
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.knowledge_base import KnowledgeBase
from app.models.knowledge_base_document import KnowledgeBaseDocument
from app.repositories.knowledge_base_document_repository import KnowledgeBaseDocumentRepository
from app.repositories.knowledge_base_repository import KnowledgeBaseRepository
MAX_TEXT_LENGTH = 20000
DOCX_NAMESPACE = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
BINARY_TEXT_PATTERN = re.compile(r"[\u4e00-\u9fffA-Za-z0-9,。;:、?!《》()【】\-—_]+")
PDF_TEXT_MARKERS = ("合同", "违约", "免责", "付款", "保密", "续约")
SUPPORTED_TEXT_SUFFIXES = (".txt", ".md", ".csv", ".json")
DOCX_SUFFIX = ".docx"
PDF_SUFFIX = ".pdf"
KNOWLEDGE_BASE_DOCUMENT_STATUS_PENDING = "pending"
KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING = "processing"
KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED = "indexed"
KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED = "failed"
class KnowledgeBaseService:
def __init__(self, db: Session):
self.db = db
self.repository = KnowledgeBaseRepository(db)
self.document_repository = KnowledgeBaseDocumentRepository(db)
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int = 1, page_size: int = 20) -> dict:
self._require_admin(current_user)
tenant_id = current_user["tenant_id"]
normalized_page = max(page, 1)
normalized_page_size = max(min(page_size, 100), 1)
knowledge_bases = self.repository.list_paginated(tenant_id, normalized_page, normalized_page_size)
counts = self.document_repository.count_by_knowledge_base_ids(tenant_id, [item.id for item in knowledge_bases])
return {
"items": [self._to_item(item, counts.get(item.id, 0)) for item in knowledge_bases],
"page": normalized_page,
"page_size": normalized_page_size,
"total": self.repository.count(tenant_id),
}
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict:
self._require_admin(current_user)
normalized_name = name.strip()
normalized_description = description.strip() if description else None
knowledge_base = KnowledgeBase(
id=f"kb-{uuid4().hex[:12]}",
tenant_id=current_user["tenant_id"],
name=normalized_name,
description=normalized_description,
status="active",
)
created = self.repository.create(knowledge_base)
return self._to_item(created, 0)
def upload_document(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
file_name: str,
mime_type: Optional[str],
content: bytes,
) -> dict:
self._require_admin(current_user)
knowledge_base = self.repository.get_by_id(knowledge_base_id)
if knowledge_base is None or knowledge_base.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="knowledge base not found")
document = KnowledgeBaseDocument(
id=f"kbdoc-{uuid4().hex[:12]}",
knowledge_base_id=knowledge_base.id,
tenant_id=current_user["tenant_id"],
file_name=file_name,
mime_type=mime_type,
file_size=len(content),
status=KNOWLEDGE_BASE_DOCUMENT_STATUS_PENDING,
content_excerpt=None,
error_message=None,
)
created = self.document_repository.create(document)
return self._process_document_content(created, content, mime_type, file_name)
def retry_document(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
document_id: str,
file_name: Optional[str] = None,
mime_type: Optional[str] = None,
content: Optional[bytes] = None,
) -> dict:
self._require_admin(current_user)
knowledge_base = self.repository.get_by_id(knowledge_base_id)
if knowledge_base is None or knowledge_base.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="knowledge base not found")
document = self.document_repository.get_by_id(
document_id,
tenant_id=current_user["tenant_id"],
knowledge_base_id=knowledge_base_id,
)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="knowledge base document not found")
if content is not None:
document.file_name = file_name or document.file_name
document.mime_type = mime_type or document.mime_type
document.file_size = len(content)
return self._process_document_content(document, content, document.mime_type, document.file_name)
if document.content_excerpt:
content_bytes = document.content_excerpt.encode("utf-8")
return self._process_document_content(document, content_bytes, "text/plain", document.file_name)
document.status = KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED
document.content_excerpt = None
document.error_message = "重试需要重新上传可复制文本版本,当前记录未保留可重新索引的原文。"
document = self.document_repository.update(document)
return self._to_document_item(document)
def _process_document_content(
self,
document: KnowledgeBaseDocument,
content: bytes,
mime_type: Optional[str],
file_name: str,
) -> dict:
document.status = KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING
document.error_message = None
document = self.document_repository.update(document)
try:
text_content = self._extract_text_from_file(content, mime_type, file_name)
if not text_content:
raise ValueError("无法从该文档中提取可检索文本")
document.status = KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED
document.content_excerpt = text_content
document.error_message = None
document = self.document_repository.update(document)
return self._to_document_item(document)
except Exception:
document.status = KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED
document.content_excerpt = None
document.error_message = "无法从该文档中提取可检索文本"
document = self.document_repository.update(document)
return self._to_document_item(document)
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int = 1, page_size: int = 20) -> dict:
self._require_admin(current_user)
knowledge_base = self.repository.get_by_id(knowledge_base_id)
if knowledge_base is None or knowledge_base.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="knowledge base not found")
normalized_page = max(page, 1)
normalized_page_size = max(min(page_size, 100), 1)
tenant_id = current_user["tenant_id"]
documents = self.document_repository.list_paginated(tenant_id, knowledge_base_id, normalized_page, normalized_page_size)
return {
"items": [self._to_document_item(item) for item in documents],
"page": normalized_page,
"page_size": normalized_page_size,
"total": self.document_repository.count(tenant_id, knowledge_base_id),
}
def _require_admin(self, current_user: Dict[str, Any]):
if current_user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden")
def _to_item(self, knowledge_base: KnowledgeBase, document_count: int) -> dict:
return {
"id": knowledge_base.id,
"name": knowledge_base.name,
"description": knowledge_base.description,
"status": knowledge_base.status,
"document_count": document_count,
"created_at": knowledge_base.created_at.isoformat(),
"updated_at": knowledge_base.updated_at.isoformat(),
}
def _extract_text_from_file(self, content: bytes, mime_type: Optional[str], file_name: str) -> Optional[str]:
if not content:
return None
normalized_name = (file_name or "").lower()
normalized_mime = (mime_type or "").lower()
try:
if normalized_mime.startswith("text/") or normalized_name.endswith(SUPPORTED_TEXT_SUFFIXES):
return self._normalize_extracted_text(content.decode("utf-8", errors="ignore"))
if normalized_name.endswith(DOCX_SUFFIX) or "wordprocessingml" in normalized_mime:
return self._extract_text_from_docx(content)
if normalized_name.endswith(PDF_SUFFIX) or normalized_mime == "application/pdf":
return self._extract_text_from_pdf(content)
except Exception:
return None
return self._extract_text_from_binary(content)
def _extract_text_from_docx(self, file_bytes: bytes) -> Optional[str]:
try:
with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive:
xml_content = archive.read("word/document.xml")
except (KeyError, zipfile.BadZipFile):
return self._extract_text_from_binary(file_bytes)
root = ElementTree.fromstring(xml_content)
texts = [node.text for node in root.findall(".//w:t", DOCX_NAMESPACE) if node.text]
return self._normalize_extracted_text("\n".join(texts))
def _extract_text_from_pdf(self, file_bytes: bytes) -> Optional[str]:
extracted_text = self._extract_with_pdfplumber(file_bytes)
if extracted_text:
return extracted_text
return self._extract_text_from_binary(file_bytes)
def _extract_with_pdfplumber(self, file_bytes: bytes) -> Optional[str]:
try:
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
text_parts = []
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text_parts.append(page_text)
if not text_parts:
return None
normalized = self._normalize_extracted_text("\n".join(text_parts))
if not normalized:
return None
if any(marker in normalized for marker in PDF_TEXT_MARKERS) or len(normalized) > 100:
return normalized
return None
except Exception:
return None
def _extract_text_from_binary(self, file_bytes: bytes) -> Optional[str]:
decoded = file_bytes.decode("utf-8", errors="ignore")
matches = BINARY_TEXT_PATTERN.findall(decoded)
if not matches:
return None
return self._normalize_extracted_text("\n".join(matches))
def _build_document_status_detail(self, document: KnowledgeBaseDocument) -> str:
if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED:
return "文本提取与索引已完成,可直接用于检索和问答。"
if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED:
return document.error_message or "文档处理失败,请检查文件内容或格式后重试。"
if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING:
return "系统正在提取文本并写入检索索引。"
return "文档已接收,等待进入文本提取阶段。"
def _build_document_failure_reason(self, document: KnowledgeBaseDocument, parse_status: str, index_status: str) -> Optional[str]:
if document.status not in {KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED, KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING}:
return None
if index_status == "blocked":
return document.error_message or "文本提取尚未完成,索引阶段暂时阻塞。"
if parse_status == "failed":
return document.error_message or "未能从文档中提取到可检索文本。"
if index_status == "failed":
return document.error_message or "文本已提取,但写入索引阶段失败。"
return document.error_message or "文档处理失败。"
def _build_document_next_action(self, document: KnowledgeBaseDocument, parse_status: str, index_status: str) -> str:
if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED:
return "可直接进入问答,或继续补充更多制度与条款文档。"
if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_PENDING:
return "等待系统进入文本提取阶段。"
if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING:
if index_status == "blocked":
return "请等待文本提取完成;提取成功后系统会继续进入索引阶段。"
return "等待文本提取与索引完成,完成后即可用于检索。"
if parse_status == "failed":
return "请检查文件格式、内容编码或改为可复制文本后重新上传。"
if index_status == "failed":
return "请重新上传文档;若反复失败,需检查索引链路。"
return "请检查处理结果后重试。"
def _build_document_lifecycle(self, document: KnowledgeBaseDocument) -> dict:
parse_status = "pending"
index_status = "pending"
current_stage = "parse"
current_stage_label = "等待解析"
if document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_PROCESSING:
parse_status = "processing"
index_status = "blocked"
current_stage = "parse"
current_stage_label = "文本提取中"
elif document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_INDEXED:
parse_status = "completed"
index_status = "completed"
current_stage = "indexed"
current_stage_label = "已完成索引"
elif document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED:
parse_status = "failed"
index_status = "blocked"
current_stage = "failed"
current_stage_label = "文本提取失败"
return {
"current_stage": current_stage,
"current_stage_label": current_stage_label,
"status_detail": self._build_document_status_detail(document),
"parse_status": parse_status,
"index_status": index_status,
"failure_reason": self._build_document_failure_reason(document, parse_status, index_status),
"next_action": self._build_document_next_action(document, parse_status, index_status),
"retryable": document.status == KNOWLEDGE_BASE_DOCUMENT_STATUS_FAILED,
}
def _to_document_item(self, document: KnowledgeBaseDocument) -> dict:
raw_content = document.content_excerpt or ""
excerpt = raw_content
preview_truncated = False
if excerpt and len(excerpt) > 500:
excerpt = f"{excerpt[:500]}..."
preview_truncated = True
lifecycle = self._build_document_lifecycle(document)
return {
"id": document.id,
"knowledge_base_id": document.knowledge_base_id,
"file_name": document.file_name,
"mime_type": document.mime_type,
"file_size": document.file_size,
"status": document.status,
"content_excerpt": excerpt,
"error_message": document.error_message,
"extracted_text_length": len(raw_content),
"preview_truncated": preview_truncated,
"created_at": document.created_at.isoformat(),
"current_stage": lifecycle["current_stage"],
"current_stage_label": lifecycle["current_stage_label"],
"status_detail": lifecycle["status_detail"],
"parse_status": lifecycle["parse_status"],
"index_status": lifecycle["index_status"],
"failure_reason": lifecycle["failure_reason"],
"next_action": lifecycle["next_action"],
"retryable": lifecycle["retryable"],
}
def _normalize_extracted_text(self, value: str) -> Optional[str]:
normalized = "\n".join(line.strip() for line in value.splitlines() if line.strip())
if not normalized:
return None
return normalized[:MAX_TEXT_LENGTH]