Initial commit: 国土空间规划课程智能体 v1.0

单容器 Docker 架构的国土空间规划课程智能问答系统,集成 FastAPI 后端与 Next.js 前端。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 09:40:18 +08:00
commit ddbb79b9f6
167 changed files with 44147 additions and 0 deletions
@@ -0,0 +1,466 @@
"""
知识库管理服务
"""
import os
import hashlib
from pathlib import Path
from typing import List, Dict, Any, Optional
from datetime import datetime
from sqlalchemy.orm import Session
from sqlalchemy import and_
from ..models.document import Document, DocumentChunk
from ..core.config import get_settings
from .document_service import DocumentService
settings = get_settings()
class KnowledgeBaseService:
"""知识库管理服务"""
def __init__(self, db: Session):
self.db = db
self.document_service = DocumentService(db)
self.knowledge_base_dir = Path(settings.knowledge_base_dir)
def scan_directory(self, directory: Optional[str] = None) -> Dict[str, Any]:
"""扫描知识库目录并批量导入"""
if directory is None:
directory = self.knowledge_base_dir
else:
directory = Path(directory)
if not directory.exists():
return {"success": False, "message": f"目录不存在: {directory}"}
results = {
"scanned_files": 0,
"new_files": 0,
"updated_files": 0,
"skipped_files": 0,
"errors": []
}
# 递归扫描目录
for file_path in directory.rglob("*"):
if file_path.is_file() and self._is_supported_file(file_path):
try:
result = self.process_file(str(file_path))
results["scanned_files"] += 1
if result["status"] == "new":
results["new_files"] += 1
elif result["status"] == "updated":
results["updated_files"] += 1
elif result["status"] == "skipped":
results["skipped_files"] += 1
elif result["status"] == "error":
results["errors"].append({
"file": str(file_path),
"error": result["error"]
})
except Exception as e:
results["errors"].append({
"file": str(file_path),
"error": str(e)
})
results["success"] = len(results["errors"]) == 0
return results
def process_file(self, file_path: str) -> Dict[str, Any]:
"""处理单个文件(检查、提取、入库)"""
try:
file_path = Path(file_path)
# 检查文件是否存在
if not file_path.exists():
return {"status": "error", "error": "文件不存在"}
# 检查文件类型
if not self._is_supported_file(file_path):
return {"status": "skipped", "message": "不支持的文件类型"}
# 获取文件信息
file_stat = file_path.stat()
file_size = file_stat.st_size
last_modified = datetime.fromtimestamp(file_stat.st_mtime)
# 计算文件哈希
file_hash = self._calculate_file_hash(file_path)
# 检查文件是否已存在
existing_doc = self.db.query(Document).filter(
and_(
Document.file_path == str(file_path),
Document.source_type == "knowledge_base"
)
).first()
if existing_doc:
# 检查是否需要更新
if (existing_doc.last_modified and
existing_doc.last_modified >= last_modified and
existing_doc.file_hash == file_hash):
return {"status": "skipped", "message": "文件未修改"}
# 更新现有文档
return self._update_document(existing_doc, file_path, file_size, last_modified, file_hash)
else:
# 创建新文档
return self._create_document(file_path, file_size, last_modified, file_hash)
except Exception as e:
return {"status": "error", "error": str(e)}
def _is_supported_file(self, file_path: Path) -> bool:
"""检查是否为支持的文件类型"""
return file_path.suffix.lower() in settings.allowed_extensions
def _calculate_file_hash(self, file_path: Path) -> str:
"""计算文件哈希"""
hash_md5 = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def _create_document(self, file_path: Path, file_size: int, last_modified: datetime, file_hash: str,
knowledge_base_id: Optional[int] = None, user_id: Optional[int] = None) -> Dict[str, Any]:
"""创建新文档"""
try:
# 计算相对路径用于描述
try:
relative_path = file_path.relative_to(self.knowledge_base_dir)
except ValueError:
relative_path = file_path.name
# 创建文档记录
document = Document(
user_id=user_id, # 如果指定了user_id则使用,否则为None(系统文档)
knowledge_base_id=knowledge_base_id, # 关联知识库
filename=file_path.name,
original_filename=file_path.name,
file_path=str(file_path),
file_size=file_size,
file_type=file_path.suffix.lower(),
title=file_path.stem,
description=f"知识库文档: {relative_path}",
is_processed=False,
is_public=True,
source_type="knowledge_base",
last_modified=last_modified,
file_hash=file_hash
)
self.db.add(document)
self.db.commit()
self.db.refresh(document)
# 处理文档(向量化)- 在后台异步处理,不阻塞主流程
try:
# 使用同步方法,但不等待完成(在后台处理)
import threading
def process_in_background():
try:
self.document_service.process_document(document.id)
except Exception as e:
print(f"后台处理文档 {document.id} 失败: {e}")
thread = threading.Thread(target=process_in_background, daemon=True)
thread.start()
except Exception as e:
print(f"启动文档处理线程失败: {e}")
return {"status": "new", "document_id": document.id, "message": "文档创建成功,正在处理中"}
except Exception as e:
self.db.rollback()
return {"status": "error", "error": str(e)}
def _update_document(self, document: Document, file_path: Path, file_size: int, last_modified: datetime, file_hash: str) -> Dict[str, Any]:
"""更新现有文档"""
try:
# 更新文档信息
document.file_size = file_size
document.last_modified = last_modified
document.file_hash = file_hash
document.is_processed = False # 标记为未处理,需要重新处理
# 删除旧的文档块
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document.id).delete()
self.db.commit()
# 重新处理文档 - 在后台异步处理
try:
import threading
def process_in_background():
try:
self.document_service.process_document(document.id)
except Exception as e:
print(f"后台处理文档 {document.id} 失败: {e}")
thread = threading.Thread(target=process_in_background, daemon=True)
thread.start()
except Exception as e:
print(f"启动文档处理线程失败: {e}")
return {"status": "updated", "document_id": document.id, "message": "文档更新成功,正在重新处理中"}
except Exception as e:
self.db.rollback()
return {"status": "error", "error": str(e)}
def is_file_updated(self, file_path: str) -> bool:
"""检查文件是否需要更新"""
try:
file_path = Path(file_path)
if not file_path.exists():
return False
# 获取文件信息
file_stat = file_path.stat()
last_modified = datetime.fromtimestamp(file_stat.st_mtime)
file_hash = self._calculate_file_hash(file_path)
# 查询数据库中的记录
existing_doc = self.db.query(Document).filter(
and_(
Document.file_path == str(file_path),
Document.source_type == "knowledge_base"
)
).first()
if not existing_doc:
return True # 新文件
# 检查修改时间和哈希
if (existing_doc.last_modified and
existing_doc.last_modified < last_modified):
return True
if existing_doc.file_hash != file_hash:
return True
return False
except Exception:
return True # 出错时默认需要更新
def get_knowledge_base_status(self) -> Dict[str, Any]:
"""获取知识库状态"""
try:
# 统计文档数量
total_docs = self.db.query(Document).filter(
Document.source_type == "knowledge_base"
).count()
processed_docs = self.db.query(Document).filter(
and_(
Document.source_type == "knowledge_base",
Document.is_processed == True
)
).count()
# 统计文件大小
total_size = self.db.query(Document).filter(
Document.source_type == "knowledge_base"
).with_entities(Document.file_size).all()
total_size_bytes = sum(size[0] for size in total_size) if total_size else 0
# 统计文件类型
file_types = {}
docs_by_type = self.db.query(Document.file_type).filter(
Document.source_type == "knowledge_base"
).all()
for file_type in docs_by_type:
file_type_str = file_type[0]
file_types[file_type_str] = file_types.get(file_type_str, 0) + 1
return {
"total_documents": total_docs,
"processed_documents": processed_docs,
"unprocessed_documents": total_docs - processed_docs,
"total_size_bytes": total_size_bytes,
"total_size_mb": round(total_size_bytes / (1024 * 1024), 2),
"file_types": file_types,
"knowledge_base_dir": str(self.knowledge_base_dir),
"directory_exists": self.knowledge_base_dir.exists()
}
except Exception as e:
return {"error": str(e)}
def delete_document(self, document_id: int) -> Dict[str, Any]:
"""删除知识库文档"""
try:
document = self.db.query(Document).filter(
and_(
Document.id == document_id,
Document.source_type == "knowledge_base"
)
).first()
if not document:
return {"success": False, "message": "文档不存在"}
# 删除文档块
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document_id).delete()
# 删除文档记录
self.db.delete(document)
self.db.commit()
return {"success": True, "message": "文档删除成功"}
except Exception as e:
self.db.rollback()
return {"success": False, "message": str(e)}
def reindex_document(self, document_id: int) -> Dict[str, Any]:
"""重新索引指定文档"""
try:
document = self.db.query(Document).filter(
and_(
Document.id == document_id,
Document.source_type == "knowledge_base"
)
).first()
if not document:
return {"success": False, "message": "文档不存在"}
# 删除旧的文档块
self.db.query(DocumentChunk).filter(DocumentChunk.document_id == document_id).delete()
# 重新处理文档
success = self.document_service.process_document(document_id)
if success:
return {"success": True, "message": "文档重新索引成功"}
else:
return {"success": False, "message": "文档重新索引失败"}
except Exception as e:
self.db.rollback()
return {"success": False, "message": str(e)}
def import_files_to_knowledge_base(
self,
knowledge_base_id: int,
directory: Path,
user_id: Optional[int] = None
) -> Dict[str, Any]:
"""
从指定目录导入真实文件到知识库
Args:
knowledge_base_id: 知识库ID
directory: 要扫描的目录路径
user_id: 用户ID(可选,用于系统知识库时可为None)
Returns:
包含统计信息的字典
"""
if isinstance(directory, str):
directory = Path(directory)
if not directory.exists():
return {
"success": False,
"message": f"目录不存在: {directory}",
"scanned_files": 0,
"new_files": 0,
"updated_files": 0,
"skipped_files": 0,
"errors": []
}
results = {
"scanned_files": 0,
"new_files": 0,
"updated_files": 0,
"skipped_files": 0,
"errors": []
}
# 递归扫描目录下的所有真实文件
all_files = [f for f in directory.rglob("*") if f.is_file() and self._is_supported_file(f)]
total_files = len(all_files)
print(f" 找到 {total_files} 个支持的文件,开始处理...")
for idx, file_path in enumerate(all_files, 1):
try:
# 获取文件信息
file_stat = file_path.stat()
file_size = file_stat.st_size
last_modified = datetime.fromtimestamp(file_stat.st_mtime)
file_hash = self._calculate_file_hash(file_path)
# 检查文件是否已存在于该知识库中
existing_doc = self.db.query(Document).filter(
and_(
Document.file_path == str(file_path),
Document.knowledge_base_id == knowledge_base_id,
Document.source_type == "knowledge_base"
)
).first()
results["scanned_files"] += 1
# 显示进度(每10个文件或最后一个文件时显示)
if idx % 10 == 0 or idx == total_files:
percentage = (idx * 100) // total_files if total_files > 0 else 0
print(f"\r 处理进度: {idx}/{total_files} ({percentage}%)", end="", flush=True)
if existing_doc:
# 检查是否需要更新
if (existing_doc.last_modified and
existing_doc.last_modified >= last_modified and
existing_doc.file_hash == file_hash):
results["skipped_files"] += 1
continue
# 更新现有文档
result = self._update_document(existing_doc, file_path, file_size, last_modified, file_hash)
if result["status"] == "updated":
results["updated_files"] += 1
else:
results["errors"].append({
"file": str(file_path),
"error": result.get("error", "更新失败")
})
else:
# 创建新文档
result = self._create_document(
file_path,
file_size,
last_modified,
file_hash,
knowledge_base_id=knowledge_base_id,
user_id=user_id
)
if result["status"] == "new":
results["new_files"] += 1
else:
results["errors"].append({
"file": str(file_path),
"error": result.get("error", "创建失败")
})
except Exception as e:
results["errors"].append({
"file": str(file_path),
"error": str(e)
})
print() # 换行
results["success"] = len(results["errors"]) == 0
return results