Initial commit: 国土空间规划课程智能体 v1.0
单容器 Docker 架构的国土空间规划课程智能问答系统,集成 FastAPI 后端与 Next.js 前端。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# 国土空间规划课程智能体后端服务
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# API路由模块
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
学习分析API
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.config import get_settings
|
||||
from ..core.security import get_current_user
|
||||
from ..models.user import User
|
||||
from ..models.chat import ChatSession, ChatMessage
|
||||
from ..models.document import Document
|
||||
from ..services.analytics_service import AnalyticsService
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["学习分析"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
"""统计响应模型"""
|
||||
total_sessions: int
|
||||
total_messages: int
|
||||
total_documents: int
|
||||
active_days: int
|
||||
user_since: Optional[str]
|
||||
last_login: Optional[str]
|
||||
|
||||
|
||||
class LearningReportResponse(BaseModel):
|
||||
"""学习报告响应模型"""
|
||||
user_id: int
|
||||
total_questions: int
|
||||
topics_covered: List[str]
|
||||
learning_progress: float
|
||||
recommendations: List[str]
|
||||
study_time: int
|
||||
knowledge_gaps: List[str]
|
||||
|
||||
|
||||
class PlatformStatsResponse(BaseModel):
|
||||
"""平台统计数据响应模型"""
|
||||
active_users: int
|
||||
knowledge_documents: int
|
||||
qa_dialogues: int
|
||||
generated_images: int
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=StatisticsResponse)
|
||||
async def get_statistics(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取学习统计信息"""
|
||||
try:
|
||||
# 获取用户对象
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
analytics_service = AnalyticsService(db)
|
||||
statistics = analytics_service.get_user_statistics(user.id)
|
||||
|
||||
return StatisticsResponse(**statistics)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取统计信息失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/learning-report", response_model=LearningReportResponse)
|
||||
async def get_learning_report(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取个人学习报告"""
|
||||
try:
|
||||
# 获取用户对象
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
analytics_service = AnalyticsService(db)
|
||||
report = analytics_service.get_learning_report(user.id)
|
||||
|
||||
return LearningReportResponse(**report)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取学习报告失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/trends")
|
||||
async def get_learning_trends(
|
||||
days: int = 30,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取学习趋势数据"""
|
||||
try:
|
||||
# 获取用户对象
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
analytics_service = AnalyticsService(db)
|
||||
trends = analytics_service.get_user_learning_trends(user.id, days)
|
||||
|
||||
return {"success": True, "data": trends}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取学习趋势失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/popular-questions")
|
||||
async def get_popular_questions(
|
||||
limit: int = 10,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取热门问题"""
|
||||
try:
|
||||
# 获取用户对象
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
analytics_service = AnalyticsService(db)
|
||||
questions = analytics_service.get_popular_questions(user.id, limit)
|
||||
|
||||
return {"success": True, "data": questions}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取热门问题失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/knowledge-coverage")
|
||||
async def get_knowledge_coverage(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取知识覆盖度"""
|
||||
try:
|
||||
# 获取用户对象
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
analytics_service = AnalyticsService(db)
|
||||
coverage = analytics_service.get_knowledge_coverage(user.id)
|
||||
|
||||
return {"success": True, "data": coverage}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取知识覆盖度失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/full-analytics")
|
||||
async def get_full_analytics(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取完整的学习分析数据"""
|
||||
try:
|
||||
# 获取用户对象
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
analytics_service = AnalyticsService(db)
|
||||
|
||||
# 获取所有统计数据
|
||||
statistics = analytics_service.get_user_statistics(user.id)
|
||||
trends = analytics_service.get_user_learning_trends(user.id, 30)
|
||||
popular_questions = analytics_service.get_popular_questions(user.id, 5)
|
||||
knowledge_coverage = analytics_service.get_knowledge_coverage(user.id)
|
||||
learning_report = analytics_service.get_learning_report(user.id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"statistics": statistics,
|
||||
"learning_trends": trends,
|
||||
"popular_questions": popular_questions,
|
||||
"knowledge_coverage": knowledge_coverage,
|
||||
"learning_report": learning_report
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取完整分析数据失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/platform-stats", response_model=PlatformStatsResponse)
|
||||
async def get_platform_stats(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取平台统计数据(公开接口,无需登录)"""
|
||||
try:
|
||||
# 活跃用户数(有聊天会话或文档的用户)
|
||||
users_with_sessions = db.query(func.count(func.distinct(ChatSession.user_id))).scalar() or 0
|
||||
users_with_documents = db.query(func.count(func.distinct(Document.user_id))).filter(
|
||||
Document.source_type == "knowledge_base"
|
||||
).scalar() or 0
|
||||
# 取两者中的较大值,或者使用总用户数
|
||||
total_users = db.query(func.count(User.id)).scalar() or 0
|
||||
active_users = max(users_with_sessions, users_with_documents, total_users)
|
||||
|
||||
# 知识文档数(知识库中的文档)
|
||||
knowledge_documents = db.query(func.count(Document.id)).filter(
|
||||
Document.source_type == "knowledge_base"
|
||||
).scalar() or 0
|
||||
|
||||
# 问答对话数(所有用户消息)
|
||||
qa_dialogues = db.query(func.count(ChatMessage.id)).filter(
|
||||
ChatMessage.role == "user"
|
||||
).scalar() or 0
|
||||
|
||||
# 生成图像数(统计generated_images目录中的图像文件)
|
||||
generated_images_dir = Path(settings.generated_images_dir)
|
||||
generated_images = 0
|
||||
if generated_images_dir.exists():
|
||||
# 统计目录中的所有图像文件(.png, .jpg, .jpeg等)
|
||||
image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
|
||||
generated_images = sum(
|
||||
1 for file in generated_images_dir.iterdir()
|
||||
if file.is_file() and file.suffix.lower() in image_extensions
|
||||
)
|
||||
|
||||
return PlatformStatsResponse(
|
||||
active_users=active_users,
|
||||
knowledge_documents=knowledge_documents,
|
||||
qa_dialogues=qa_dialogues,
|
||||
generated_images=generated_images
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取平台统计数据失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
用户认证API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.security import get_current_user
|
||||
from ..services.auth_service import AuthService
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""用户创建模型"""
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
full_name: Optional[str] = None
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
"""用户登录模型"""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
"""密码修改模型"""
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""用户信息更新模型"""
|
||||
full_name: Optional[str] = None
|
||||
email: Optional[EmailStr] = None
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Token响应模型"""
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""用户响应模型"""
|
||||
id: int
|
||||
username: str
|
||||
email: str
|
||||
full_name: Optional[str]
|
||||
is_active: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||
"""用户注册"""
|
||||
auth_service = AuthService(db)
|
||||
|
||||
# 创建用户
|
||||
user = auth_service.create_user(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
password=user_data.password,
|
||||
full_name=user_data.full_name
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户名或邮箱已存在"
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(login_data: UserLogin, db: Session = Depends(get_db)):
|
||||
"""用户登录"""
|
||||
auth_service = AuthService(db)
|
||||
|
||||
# 验证用户
|
||||
user = auth_service.authenticate_user(login_data.username, login_data.password)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 创建访问令牌
|
||||
token_data = auth_service.create_access_token_for_user(user)
|
||||
|
||||
return Token(
|
||||
access_token=token_data["access_token"],
|
||||
token_type=token_data["token_type"]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_current_user_info(current_user: str = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""获取当前用户信息"""
|
||||
auth_service = AuthService(db)
|
||||
user = auth_service.get_user_by_username(current_user)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.put("/me", response_model=UserResponse)
|
||||
async def update_user_info(
|
||||
user_data: UserUpdate,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新用户信息"""
|
||||
auth_service = AuthService(db)
|
||||
|
||||
# 准备更新数据
|
||||
update_data = {}
|
||||
if user_data.full_name is not None:
|
||||
update_data["full_name"] = user_data.full_name
|
||||
if user_data.email is not None:
|
||||
# 检查邮箱是否已被其他用户使用
|
||||
existing_user = auth_service.get_user_by_email(user_data.email)
|
||||
if existing_user and existing_user.username != current_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="邮箱已被其他用户使用"
|
||||
)
|
||||
update_data["email"] = user_data.email
|
||||
|
||||
# 更新用户信息
|
||||
user = auth_service.update_user(
|
||||
auth_service.get_user_by_username(current_user).id,
|
||||
**update_data
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="更新用户信息失败"
|
||||
)
|
||||
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
full_name=user.full_name,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
password_data: PasswordChange,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""修改密码"""
|
||||
auth_service = AuthService(db)
|
||||
user = auth_service.get_user_by_username(current_user)
|
||||
|
||||
success = auth_service.change_password(
|
||||
user.id,
|
||||
password_data.old_password,
|
||||
password_data.new_password
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="原密码错误或修改失败"
|
||||
)
|
||||
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_user_stats(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户统计信息(管理员功能)"""
|
||||
auth_service = AuthService(db)
|
||||
user = auth_service.get_user_by_username(current_user)
|
||||
|
||||
# 检查是否为管理员(这里简化处理,实际应该有更复杂的权限系统)
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="权限不足"
|
||||
)
|
||||
|
||||
stats = auth_service.get_user_statistics()
|
||||
return stats
|
||||
@@ -0,0 +1,714 @@
|
||||
"""
|
||||
聊天对话API
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict, Any
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
from ..core.database import get_db, SessionLocal
|
||||
from ..core.security import get_current_user
|
||||
from ..models.chat import ChatSession, ChatMessage
|
||||
from ..models.user import User
|
||||
from ..rag.chains import create_rag_chain
|
||||
from ..rag.conversation_chains import create_conversation_chain
|
||||
from ..llm.siliconflow import get_llm_client
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["聊天"])
|
||||
|
||||
|
||||
def get_user_id_by_username(db: Session, username: str) -> int:
|
||||
"""根据用户名获取用户ID"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
return user.id
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
"""聊天请求模型"""
|
||||
message: str
|
||||
session_id: Optional[int] = None
|
||||
mode: str = "normal" # normal, rag
|
||||
knowledge_base_ids: Optional[List[int]] = None
|
||||
model: Optional[str] = None # 模型ID,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
"""聊天响应模型"""
|
||||
answer: str
|
||||
sources: List[Dict[str, Any]]
|
||||
session_id: int
|
||||
message_id: int
|
||||
|
||||
|
||||
class ChatSessionResponse(BaseModel):
|
||||
"""聊天会话响应模型"""
|
||||
id: int
|
||||
title: str
|
||||
created_at: str
|
||||
message_count: int
|
||||
|
||||
|
||||
@router.post("/send", response_model=ChatResponse)
|
||||
async def send_message(
|
||||
request: ChatRequest,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""发送消息"""
|
||||
try:
|
||||
# 获取或创建会话
|
||||
if request.session_id:
|
||||
session = db.query(ChatSession).filter(
|
||||
ChatSession.id == request.session_id,
|
||||
ChatSession.user_id == db.query(User).filter(User.username == current_user).first().id
|
||||
).first()
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="会话不存在"
|
||||
)
|
||||
else:
|
||||
# 创建新会话
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
session = ChatSession(
|
||||
user_id=user.id,
|
||||
title=request.message[:50] + "..." if len(request.message) > 50 else request.message
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
|
||||
# 保存用户消息
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
role="user",
|
||||
content=request.message
|
||||
)
|
||||
db.add(user_message)
|
||||
db.commit()
|
||||
db.refresh(user_message)
|
||||
|
||||
# 自动更新会话标题(如果是第一条消息且标题为"新对话")
|
||||
message_count = db.query(ChatMessage).filter(
|
||||
ChatMessage.session_id == session.id
|
||||
).count()
|
||||
if message_count == 1 and session.title == "新对话":
|
||||
# 生成标题:取消息前30个字符
|
||||
new_title = request.message.strip()[:30] + "..." if len(request.message.strip()) > 30 else request.message.strip()
|
||||
session.title = new_title
|
||||
db.commit()
|
||||
print(f"[DEBUG-AUTO-TITLE] 自动更新会话标题: {new_title}")
|
||||
|
||||
# 根据模式运行不同的问答工作流
|
||||
if request.mode == "rag":
|
||||
print(f"[DEBUG-RAG] 非流式RAG模式")
|
||||
print(f"[DEBUG-RAG] 接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||
print(f"[DEBUG-RAG] knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
||||
if request.knowledge_base_ids:
|
||||
print(f"[DEBUG-RAG] 第一个ID: {request.knowledge_base_ids[0]}, 类型: {type(request.knowledge_base_ids[0])}")
|
||||
|
||||
# 使用LangChain 1.0 RAG链
|
||||
print(f"[DEBUG-RAG] 模型: {request.model}")
|
||||
rag_chain = create_rag_chain(knowledge_base_ids=request.knowledge_base_ids, model=request.model)
|
||||
result = rag_chain.invoke(request.message)
|
||||
else:
|
||||
# 普通模式:使用LangChain 1.0对话链
|
||||
print(f"[DEBUG-CHAT] 普通模式 - 使用LangChain对话链")
|
||||
print(f"[DEBUG-CHAT] 模型: {request.model}")
|
||||
conversation_chain = create_conversation_chain(model=request.model)
|
||||
|
||||
# 获取聊天历史
|
||||
history_messages = db.query(ChatMessage).filter(
|
||||
ChatMessage.session_id == session.id
|
||||
).order_by(ChatMessage.created_at).limit(10).all()
|
||||
|
||||
chat_history = []
|
||||
for msg in history_messages:
|
||||
chat_history.append({
|
||||
"role": msg.role,
|
||||
"content": msg.content
|
||||
})
|
||||
|
||||
result = conversation_chain.invoke(request.message, chat_history=chat_history)
|
||||
|
||||
# 保存助手回复
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
role="assistant",
|
||||
content=result["answer"],
|
||||
message_metadata=json.dumps(result["sources"], ensure_ascii=False)
|
||||
)
|
||||
db.add(assistant_message)
|
||||
db.commit()
|
||||
db.refresh(assistant_message)
|
||||
|
||||
return ChatResponse(
|
||||
answer=result["answer"],
|
||||
sources=result["sources"],
|
||||
session_id=session.id,
|
||||
message_id=assistant_message.id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"处理消息失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def stream_message(
|
||||
request: ChatRequest,
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""流式发送消息"""
|
||||
async def generate_response():
|
||||
# 在生成器内部创建新的数据库会话
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 打印请求参数调试信息
|
||||
print(f"[DEBUG-CHAT] 接收流式请求 - mode: {request.mode}, knowledge_base_ids: {request.knowledge_base_ids}, model: {request.model}, message: {request.message[:50]}...")
|
||||
|
||||
# 获取或创建会话
|
||||
if request.session_id:
|
||||
session = db.query(ChatSession).filter(
|
||||
ChatSession.id == request.session_id,
|
||||
ChatSession.user_id == db.query(User).filter(User.username == current_user).first().id
|
||||
).first()
|
||||
if not session:
|
||||
yield f"data: {json.dumps({'error': '会话不存在'})}\n\n"
|
||||
return
|
||||
else:
|
||||
# 创建新会话
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
session = ChatSession(
|
||||
user_id=user.id,
|
||||
title=request.message[:50] + "..." if len(request.message) > 50 else request.message
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
|
||||
# 保存用户消息
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
role="user",
|
||||
content=request.message
|
||||
)
|
||||
db.add(user_message)
|
||||
db.commit()
|
||||
|
||||
# 自动更新会话标题(如果是第一条消息且标题为"新对话")
|
||||
message_count = db.query(ChatMessage).filter(
|
||||
ChatMessage.session_id == session.id
|
||||
).count()
|
||||
if message_count == 1 and session.title == "新对话":
|
||||
# 生成标题:取消息前30个字符
|
||||
new_title = request.message.strip()[:30] + "..." if len(request.message.strip()) > 30 else request.message.strip()
|
||||
session.title = new_title
|
||||
db.commit()
|
||||
print(f"[DEBUG-AUTO-TITLE] 自动更新会话标题: {new_title}")
|
||||
|
||||
# 获取聊天历史
|
||||
chat_history = []
|
||||
previous_messages = db.query(ChatMessage).filter(
|
||||
ChatMessage.session_id == session.id
|
||||
).order_by(ChatMessage.created_at).limit(10).all()
|
||||
|
||||
for msg in previous_messages:
|
||||
chat_history.append({
|
||||
"role": msg.role,
|
||||
"content": msg.content
|
||||
})
|
||||
|
||||
# 根据模式选择不同的处理方式
|
||||
if request.mode == "rag":
|
||||
print(f"[DEBUG-RAG] 使用LangChain 1.0 RAG链")
|
||||
print(f"[DEBUG-RAG] 接收到的knowledge_base_ids: {request.knowledge_base_ids}")
|
||||
print(f"[DEBUG-RAG] knowledge_base_ids类型: {type(request.knowledge_base_ids)}")
|
||||
print(f"[DEBUG-RAG] 模型: {request.model}")
|
||||
if request.knowledge_base_ids:
|
||||
print(f"[DEBUG-RAG] 第一个ID: {request.knowledge_base_ids[0]}, 类型: {type(request.knowledge_base_ids[0])}")
|
||||
|
||||
# 创建RAG链
|
||||
rag_chain = create_rag_chain(
|
||||
knowledge_base_ids=request.knowledge_base_ids,
|
||||
search_type="similarity",
|
||||
k=5,
|
||||
model=request.model
|
||||
)
|
||||
|
||||
# 使用带思考过程的流式输出
|
||||
full_answer = ""
|
||||
sources = []
|
||||
thinking_steps = []
|
||||
async for result in rag_chain.astream_with_sources(request.message):
|
||||
if result["type"] == "thinking":
|
||||
# 收集思考过程
|
||||
thinking_steps.append({
|
||||
"stage": result["stage"],
|
||||
"message": result["message"],
|
||||
"doc_count": result.get("doc_count"),
|
||||
"time": result.get("time")
|
||||
})
|
||||
# 发送思考过程
|
||||
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||
elif result["type"] == "chunk":
|
||||
full_answer += result["content"]
|
||||
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||
elif result["type"] == "sources":
|
||||
sources = result["sources"]
|
||||
if sources:
|
||||
yield f"data: {json.dumps({'type': 'sources', 'sources': sources}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 保存到数据库
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
role="assistant",
|
||||
content=full_answer,
|
||||
message_metadata=json.dumps({
|
||||
"sources": sources,
|
||||
"thinking": thinking_steps,
|
||||
"mode": "rag"
|
||||
}, ensure_ascii=False)
|
||||
)
|
||||
db.add(assistant_message)
|
||||
db.commit()
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'session_id': session.id}, ensure_ascii=False)}\n\n"
|
||||
|
||||
else:
|
||||
# 普通模式:使用LangChain 1.0对话链
|
||||
print(f"[DEBUG-CHAT] 普通模式 - 使用LangChain流式对话链")
|
||||
print(f"[DEBUG-CHAT] 模型: {request.model}")
|
||||
conversation_chain = create_conversation_chain(model=request.model)
|
||||
|
||||
# 使用带思考过程的流式输出
|
||||
full_answer = ""
|
||||
thinking_steps = []
|
||||
async for result in conversation_chain.astream_with_thinking(request.message, chat_history=chat_history):
|
||||
if result["type"] == "thinking":
|
||||
# 收集思考过程
|
||||
thinking_steps.append({
|
||||
"stage": result["stage"],
|
||||
"message": result["message"],
|
||||
"time": result.get("time")
|
||||
})
|
||||
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
|
||||
elif result["type"] == "chunk":
|
||||
full_answer += result["content"]
|
||||
yield f"data: {json.dumps({'type': 'chunk', 'content': result['content']}, ensure_ascii=False)}\n\n"
|
||||
elif result["type"] == "complete":
|
||||
pass # 完成标记
|
||||
|
||||
# 保存到数据库
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
role="assistant",
|
||||
content=full_answer,
|
||||
message_metadata=json.dumps({
|
||||
"thinking": thinking_steps,
|
||||
"mode": "normal"
|
||||
}, ensure_ascii=False)
|
||||
)
|
||||
db.add(assistant_message)
|
||||
db.commit()
|
||||
|
||||
yield f"data: {json.dumps({'type': 'done', 'session_id': session.id}, ensure_ascii=False)}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
finally:
|
||||
# 确保会话在生成器结束时关闭
|
||||
db.close()
|
||||
|
||||
return StreamingResponse(
|
||||
generate_response(),
|
||||
media_type="text/plain",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=ChatSessionResponse)
|
||||
async def create_session(
|
||||
title: str = "新对话",
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建新会话"""
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
|
||||
# 创建新会话
|
||||
session = ChatSession(
|
||||
user_id=user.id,
|
||||
title=title,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
|
||||
return ChatSessionResponse(
|
||||
id=session.id,
|
||||
title=session.title,
|
||||
created_at=session.created_at.isoformat(),
|
||||
message_count=0
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=List[ChatSessionResponse])
|
||||
async def get_sessions(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取聊天会话列表"""
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
sessions = db.query(ChatSession).filter(
|
||||
ChatSession.user_id == user.id,
|
||||
ChatSession.is_active == True
|
||||
).order_by(ChatSession.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for session in sessions:
|
||||
message_count = db.query(ChatMessage).filter(
|
||||
ChatMessage.session_id == session.id
|
||||
).count()
|
||||
|
||||
result.append(ChatSessionResponse(
|
||||
id=session.id,
|
||||
title=session.title,
|
||||
created_at=session.created_at.isoformat(),
|
||||
message_count=message_count
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/messages")
|
||||
async def get_session_messages(
|
||||
session_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取会话消息"""
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
session = db.query(ChatSession).filter(
|
||||
ChatSession.id == session_id,
|
||||
ChatSession.user_id == user.id
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="会话不存在"
|
||||
)
|
||||
|
||||
messages = db.query(ChatMessage).filter(
|
||||
ChatMessage.session_id == session_id
|
||||
).order_by(ChatMessage.created_at).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": msg.id,
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
"thinking": metadata.get("thinking") if metadata else None,
|
||||
"metadata": metadata
|
||||
}
|
||||
for msg in messages
|
||||
for metadata in [json.loads(msg.message_metadata) if msg.message_metadata else None]
|
||||
]
|
||||
|
||||
|
||||
# 会话管理 API
|
||||
class SessionUpdateRequest(BaseModel):
|
||||
"""会话更新请求模型"""
|
||||
title: str
|
||||
|
||||
|
||||
class SessionExportResponse(BaseModel):
|
||||
"""会话导出响应模型"""
|
||||
session_id: int
|
||||
title: str
|
||||
messages: List[Dict[str, Any]]
|
||||
export_format: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}")
|
||||
async def update_session(
|
||||
session_id: int,
|
||||
request: SessionUpdateRequest,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""重命名会话"""
|
||||
# 获取会话
|
||||
session = db.query(ChatSession).filter(
|
||||
ChatSession.id == session_id,
|
||||
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="会话不存在"
|
||||
)
|
||||
|
||||
# 更新标题
|
||||
session.title = request.title
|
||||
db.commit()
|
||||
|
||||
return {"message": "会话重命名成功", "title": session.title}
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(
|
||||
session_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除会话"""
|
||||
# 获取会话
|
||||
session = db.query(ChatSession).filter(
|
||||
ChatSession.id == session_id,
|
||||
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="会话不存在"
|
||||
)
|
||||
|
||||
# 删除会话(级联删除消息)
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
|
||||
return {"message": "会话删除成功"}
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/export", response_model=SessionExportResponse)
|
||||
async def export_session(
|
||||
session_id: int,
|
||||
format: str = "json",
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""导出会话"""
|
||||
# 获取会话
|
||||
session = db.query(ChatSession).filter(
|
||||
ChatSession.id == session_id,
|
||||
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="会话不存在"
|
||||
)
|
||||
|
||||
# 获取消息
|
||||
messages = db.query(ChatMessage).filter(
|
||||
ChatMessage.session_id == session_id
|
||||
).order_by(ChatMessage.created_at).all()
|
||||
|
||||
# 格式化消息
|
||||
formatted_messages = []
|
||||
for msg in messages:
|
||||
formatted_messages.append({
|
||||
"id": msg.id,
|
||||
"role": msg.role,
|
||||
"content": msg.content,
|
||||
"created_at": msg.created_at.isoformat(),
|
||||
"feedback": msg.feedback,
|
||||
"edited": msg.edited
|
||||
})
|
||||
|
||||
return SessionExportResponse(
|
||||
session_id=session.id,
|
||||
title=session.title or f"会话 {session.id}",
|
||||
messages=formatted_messages,
|
||||
export_format=format,
|
||||
created_at=session.created_at.isoformat()
|
||||
)
|
||||
|
||||
|
||||
# 消息操作 API
|
||||
class MessageEditRequest(BaseModel):
|
||||
"""消息编辑请求模型"""
|
||||
content: str
|
||||
|
||||
|
||||
class MessageFeedbackRequest(BaseModel):
|
||||
"""消息反馈请求模型"""
|
||||
feedback: str # like, dislike
|
||||
|
||||
|
||||
@router.put("/messages/{message_id}")
|
||||
async def edit_message(
|
||||
message_id: int,
|
||||
request: MessageEditRequest,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""编辑用户消息"""
|
||||
# 获取消息
|
||||
message = db.query(ChatMessage).join(ChatSession).filter(
|
||||
ChatMessage.id == message_id,
|
||||
ChatMessage.role == "user",
|
||||
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||
).first()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="消息不存在或无权编辑"
|
||||
)
|
||||
|
||||
# 更新消息内容
|
||||
message.content = request.content
|
||||
message.edited = True
|
||||
db.commit()
|
||||
|
||||
return {"message": "消息编辑成功"}
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/regenerate")
|
||||
async def regenerate_message(
|
||||
message_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""重新生成AI回复"""
|
||||
# 获取原始消息
|
||||
original_message = db.query(ChatMessage).join(ChatSession).filter(
|
||||
ChatMessage.id == message_id,
|
||||
ChatMessage.role == "user",
|
||||
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||
).first()
|
||||
|
||||
if not original_message:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="消息不存在"
|
||||
)
|
||||
|
||||
# 删除该消息之后的所有消息
|
||||
later_messages = db.query(ChatMessage).filter(
|
||||
ChatMessage.session_id == original_message.session_id,
|
||||
ChatMessage.created_at > original_message.created_at
|
||||
).all()
|
||||
|
||||
for msg in later_messages:
|
||||
db.delete(msg)
|
||||
|
||||
# 重新生成回复
|
||||
try:
|
||||
# 使用RAG工作流重新生成
|
||||
response_data = await run_rag_workflow_with_context(
|
||||
original_message.content,
|
||||
original_message.session_id,
|
||||
db,
|
||||
None # No knowledge base filtering for regeneration
|
||||
)
|
||||
|
||||
# 创建新的AI回复
|
||||
new_message = ChatMessage(
|
||||
session_id=original_message.session_id,
|
||||
role="assistant",
|
||||
content=response_data["answer"],
|
||||
message_metadata=json.dumps({
|
||||
"sources": response_data["sources"],
|
||||
"metadata": response_data.get("metadata", {})
|
||||
}),
|
||||
regenerated_from=message_id
|
||||
)
|
||||
|
||||
db.add(new_message)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "消息重新生成成功",
|
||||
"new_message_id": new_message.id,
|
||||
"content": new_message.content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"重新生成失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/feedback")
|
||||
async def feedback_message(
|
||||
message_id: int,
|
||||
request: MessageFeedbackRequest,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""给消息点赞/踩"""
|
||||
# 获取消息
|
||||
message = db.query(ChatMessage).join(ChatSession).filter(
|
||||
ChatMessage.id == message_id,
|
||||
ChatMessage.role == "assistant",
|
||||
ChatSession.user_id == get_user_id_by_username(db, current_user)
|
||||
).first()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="消息不存在或无权操作"
|
||||
)
|
||||
|
||||
# 更新反馈
|
||||
message.feedback = request.feedback
|
||||
db.commit()
|
||||
|
||||
return {"message": "反馈提交成功", "feedback": request.feedback}
|
||||
|
||||
|
||||
async def run_rag_workflow_with_context(question: str, session_id: int, db: Session, knowledge_base_ids: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||
"""运行带上下文的RAG工作流"""
|
||||
try:
|
||||
# 获取会话历史消息作为上下文
|
||||
session_messages = db.query(ChatMessage).filter(
|
||||
ChatMessage.session_id == session_id
|
||||
).order_by(ChatMessage.created_at.desc()).limit(10).all()
|
||||
|
||||
# 构建上下文
|
||||
context_messages = []
|
||||
for msg in reversed(session_messages):
|
||||
if msg.role == "user":
|
||||
context_messages.append({"role": "user", "content": msg.content})
|
||||
elif msg.role == "assistant":
|
||||
context_messages.append({"role": "assistant", "content": msg.content})
|
||||
|
||||
# 运行RAG工作流
|
||||
rag_chain = create_rag_chain(knowledge_base_ids=knowledge_base_ids)
|
||||
result = rag_chain.invoke(question)
|
||||
|
||||
# 如果有上下文,增强回答
|
||||
if context_messages:
|
||||
# 这里可以添加基于上下文的增强逻辑
|
||||
# 例如:检查是否与之前的问题相关,提供更个性化的回答
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"RAG工作流执行失败: {str(e)}")
|
||||
# 降级到基础问答
|
||||
rag_chain = create_rag_chain()
|
||||
return rag_chain.invoke(question)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
课程内容API
|
||||
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.security import get_current_user
|
||||
from ..core.config import get_settings
|
||||
from ..models.course_content import CourseModule
|
||||
from ..models.book_structure import Book, Chapter, Section, Subsection
|
||||
from ..services.book_content_service import BookContentService
|
||||
|
||||
router = APIRouter(prefix="/course-content", tags=["课程内容"])
|
||||
|
||||
# 初始化书籍内容服务
|
||||
settings = get_settings()
|
||||
book_content_service = BookContentService(settings.book_dir)
|
||||
|
||||
|
||||
# 旧的响应模型(向后兼容)
|
||||
class CourseModuleResponse(BaseModel):
|
||||
"""课程模块响应模型"""
|
||||
id: int
|
||||
module_name: str
|
||||
core_knowledge_points: str
|
||||
digitalization_necessity: str
|
||||
expanded_knowledge_points: List[str]
|
||||
display_order: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# 新的书籍结构响应模型
|
||||
class SubsectionResponse(BaseModel):
|
||||
"""小节响应模型(知识点)"""
|
||||
id: int
|
||||
subsection_number: int
|
||||
title: str
|
||||
display_order: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SectionResponse(BaseModel):
|
||||
"""节响应模型"""
|
||||
id: int
|
||||
section_number: int
|
||||
title: str
|
||||
display_order: int
|
||||
subsections: List[SubsectionResponse] # 小节(知识点)列表
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ChapterResponse(BaseModel):
|
||||
"""章节响应模型"""
|
||||
id: int
|
||||
chapter_number: int
|
||||
title: str
|
||||
display_order: int
|
||||
sections: List[SectionResponse] # 节列表
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BookStructureResponse(BaseModel):
|
||||
"""书籍结构响应模型"""
|
||||
book: dict
|
||||
chapters: List[ChapterResponse]
|
||||
|
||||
|
||||
class ContentResponse(BaseModel):
|
||||
"""内容响应模型"""
|
||||
content: str
|
||||
|
||||
|
||||
@router.get("", response_model=BookStructureResponse)
|
||||
async def get_course_content(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取书籍层级结构(章节、节、小节/知识点)"""
|
||||
try:
|
||||
# 获取书籍(假设只有一个书籍)
|
||||
book = db.query(Book).first()
|
||||
if not book:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="未找到书籍数据,请先运行导入脚本"
|
||||
)
|
||||
|
||||
# 获取所有章节及其子节点
|
||||
chapters = db.query(Chapter).filter(
|
||||
Chapter.book_id == book.id
|
||||
).order_by(Chapter.display_order.asc()).all()
|
||||
|
||||
chapter_responses = []
|
||||
for chapter in chapters:
|
||||
# 获取章节下的节(Section)
|
||||
sections = db.query(Section).filter(
|
||||
Section.chapter_id == chapter.id
|
||||
).order_by(Section.display_order.asc()).all()
|
||||
|
||||
section_responses = []
|
||||
for section in sections:
|
||||
# 获取节下的小节(Subsection,作为知识点)
|
||||
subsections = db.query(Subsection).filter(
|
||||
Subsection.section_id == section.id
|
||||
).order_by(Subsection.display_order.asc()).all()
|
||||
|
||||
subsection_responses = [
|
||||
SubsectionResponse(
|
||||
id=sub.id,
|
||||
subsection_number=sub.subsection_number,
|
||||
title=sub.title,
|
||||
display_order=sub.display_order
|
||||
)
|
||||
for sub in subsections
|
||||
]
|
||||
|
||||
section_responses.append(SectionResponse(
|
||||
id=section.id,
|
||||
section_number=section.section_number,
|
||||
title=section.title,
|
||||
display_order=section.display_order,
|
||||
subsections=subsection_responses
|
||||
))
|
||||
|
||||
chapter_responses.append(ChapterResponse(
|
||||
id=chapter.id,
|
||||
chapter_number=chapter.chapter_number,
|
||||
title=chapter.title,
|
||||
display_order=chapter.display_order,
|
||||
sections=section_responses
|
||||
))
|
||||
|
||||
return BookStructureResponse(
|
||||
book={
|
||||
"id": book.id,
|
||||
"title": book.title,
|
||||
"description": book.description
|
||||
},
|
||||
chapters=chapter_responses
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取课程内容失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chapters/{chapter_id}/content", response_model=ContentResponse)
|
||||
async def get_chapter_content(
|
||||
chapter_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取章节完整内容"""
|
||||
try:
|
||||
content = book_content_service.get_chapter_content(db, chapter_id)
|
||||
if content is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="章节不存在或内容读取失败"
|
||||
)
|
||||
return ContentResponse(content=content)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取章节内容失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sections/{section_id}/content", response_model=ContentResponse)
|
||||
async def get_section_content(
|
||||
section_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取节完整内容"""
|
||||
try:
|
||||
content = book_content_service.get_section_content(db, section_id)
|
||||
if content is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="节不存在或内容读取失败"
|
||||
)
|
||||
return ContentResponse(content=content)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取节内容失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subsections/{subsection_id}/content", response_model=ContentResponse)
|
||||
async def get_subsection_content(
|
||||
subsection_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取小节完整内容(知识点,包含其下所有subsubsection内容)"""
|
||||
try:
|
||||
content = book_content_service.get_subsection_content(db, subsection_id)
|
||||
if content is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="小节不存在或内容读取失败"
|
||||
)
|
||||
return ContentResponse(content=content)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取小节内容失败: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
文档管理API
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.security import get_current_user
|
||||
from ..models.document import Document, DocumentChunk
|
||||
from ..services.document_service import DocumentService
|
||||
|
||||
router = APIRouter(prefix="/documents", tags=["文档管理"])
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
"""文档响应模型"""
|
||||
id: int
|
||||
filename: str
|
||||
title: str
|
||||
file_size: int
|
||||
file_type: str
|
||||
is_processed: bool
|
||||
is_public: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
class DocumentUploadResponse(BaseModel):
|
||||
"""文档上传响应模型"""
|
||||
id: int
|
||||
filename: str
|
||||
message: str
|
||||
|
||||
|
||||
class DocumentStats(BaseModel):
|
||||
"""文档统计模型"""
|
||||
total_documents: int
|
||||
processed_documents: int
|
||||
total_size: int
|
||||
file_types: dict
|
||||
|
||||
|
||||
@router.post("/upload", response_model=DocumentUploadResponse, deprecated=True)
|
||||
async def upload_document(
|
||||
file: UploadFile = File(...),
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""上传文档(已废弃,请使用知识库上传接口)"""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
detail="此接口已废弃,请使用知识库上传接口:POST /knowledge-bases/{knowledge_base_id}/documents"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[DocumentResponse])
|
||||
async def get_documents(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取文档列表"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取用户的文档
|
||||
documents = db.query(Document).filter(
|
||||
Document.user_id == user.id
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
return [
|
||||
DocumentResponse(
|
||||
id=doc.id,
|
||||
filename=doc.filename,
|
||||
title=doc.title,
|
||||
file_size=doc.file_size,
|
||||
file_type=doc.file_type,
|
||||
is_processed=doc.is_processed,
|
||||
is_public=doc.is_public,
|
||||
created_at=doc.created_at.isoformat()
|
||||
)
|
||||
for doc in documents
|
||||
]
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取文档列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}", response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
document_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取单个文档信息"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取文档
|
||||
document = db.query(Document).filter(
|
||||
Document.id == document_id,
|
||||
Document.user_id == user.id
|
||||
).first()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在"
|
||||
)
|
||||
|
||||
return DocumentResponse(
|
||||
id=document.id,
|
||||
filename=document.filename,
|
||||
title=document.title,
|
||||
file_size=document.file_size,
|
||||
file_type=document.file_type,
|
||||
is_processed=document.is_processed,
|
||||
is_public=document.is_public,
|
||||
created_at=document.created_at.isoformat()
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取文档信息失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{document_id}")
|
||||
async def delete_document(
|
||||
document_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除文档"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取文档
|
||||
document = db.query(Document).filter(
|
||||
Document.id == document_id,
|
||||
Document.user_id == user.id
|
||||
).first()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在"
|
||||
)
|
||||
|
||||
# 1. 先删除向量数据和文档块
|
||||
try:
|
||||
print(f"开始删除文档向量数据: {document.filename} (ID: {document.id})")
|
||||
document_service = DocumentService(db)
|
||||
vector_deleted = document_service.delete_document_chunks(document.id)
|
||||
if vector_deleted:
|
||||
print(f"成功删除文档向量数据: {document.filename}")
|
||||
else:
|
||||
print(f"警告:删除文档向量数据失败: {document.filename}")
|
||||
except Exception as e:
|
||||
print(f"删除向量数据时发生错误: {str(e)}")
|
||||
import traceback
|
||||
print(f"详细错误信息: {traceback.format_exc()}")
|
||||
|
||||
# 2. 删除物理文件
|
||||
try:
|
||||
if os.path.exists(document.file_path):
|
||||
os.remove(document.file_path)
|
||||
print(f"成功删除物理文件: {document.file_path}")
|
||||
else:
|
||||
print(f"物理文件不存在: {document.file_path}")
|
||||
except Exception as e:
|
||||
print(f"删除物理文件时发生错误: {str(e)}")
|
||||
|
||||
# 3. 删除数据库记录
|
||||
db.delete(document)
|
||||
db.commit()
|
||||
print(f"成功删除文档数据库记录: {document.filename}")
|
||||
|
||||
return {"message": "文档删除成功"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除文档失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{document_id}/process")
|
||||
async def process_document(
|
||||
document_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""处理文档(向量化)"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取文档
|
||||
document = db.query(Document).filter(
|
||||
Document.id == document_id,
|
||||
Document.user_id == user.id
|
||||
).first()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="文档不存在"
|
||||
)
|
||||
|
||||
if document.is_processed:
|
||||
return {"message": "文档已经处理过了"}
|
||||
|
||||
# 处理文档
|
||||
document_service = DocumentService(db)
|
||||
success = await asyncio.to_thread(document_service.process_document, document.id)
|
||||
|
||||
if success:
|
||||
return {"message": "文档处理成功"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="文档处理失败"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"处理文档失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats/overview", response_model=DocumentStats)
|
||||
async def get_document_stats(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取文档统计信息"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
from ..models.user import User
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 统计信息
|
||||
total_documents = db.query(Document).filter(Document.user_id == user.id).count()
|
||||
processed_documents = db.query(Document).filter(
|
||||
Document.user_id == user.id,
|
||||
Document.is_processed == True
|
||||
).count()
|
||||
|
||||
# 计算总大小
|
||||
documents = db.query(Document).filter(Document.user_id == user.id).all()
|
||||
total_size = sum(doc.file_size for doc in documents)
|
||||
|
||||
# 文件类型统计
|
||||
file_types = {}
|
||||
for doc in documents:
|
||||
file_type = doc.file_type
|
||||
file_types[file_type] = file_types.get(file_type, 0) + 1
|
||||
|
||||
return DocumentStats(
|
||||
total_documents=total_documents,
|
||||
processed_documents=processed_documents,
|
||||
total_size=total_size,
|
||||
file_types=file_types
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取统计信息失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""
|
||||
论坛功能相关 API
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.security import get_current_user
|
||||
from ..models.forum import ForumCategory, ForumPost, ForumReply
|
||||
from ..models.user import User
|
||||
|
||||
router = APIRouter(prefix="/forum", tags=["论坛"])
|
||||
|
||||
|
||||
class ForumCategoryResponse(BaseModel):
|
||||
id: int
|
||||
slug: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
post_count: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ForumPostSummary(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
author_name: str
|
||||
created_at: str
|
||||
reply_count: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ForumReplyResponse(BaseModel):
|
||||
id: int
|
||||
content: str
|
||||
author_name: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ForumPostDetail(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
author_name: str
|
||||
created_at: str
|
||||
replies: List[ForumReplyResponse]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
ForumPostDetail.model_rebuild()
|
||||
|
||||
|
||||
class CreatePostRequest(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
|
||||
|
||||
class CreateReplyRequest(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
@router.get("/categories", response_model=List[ForumCategoryResponse])
|
||||
def list_categories(db: Session = Depends(get_db)):
|
||||
categories = db.query(ForumCategory).all()
|
||||
results: List[ForumCategoryResponse] = []
|
||||
for category in categories:
|
||||
post_count = db.query(ForumPost).filter(ForumPost.category_id == category.id).count()
|
||||
results.append(
|
||||
ForumCategoryResponse(
|
||||
id=category.id,
|
||||
slug=category.slug,
|
||||
name=category.name,
|
||||
description=category.description,
|
||||
post_count=post_count,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/categories/{category_id}/posts", response_model=List[ForumPostSummary])
|
||||
def list_posts(category_id: int, db: Session = Depends(get_db), limit: int = 20):
|
||||
posts = (
|
||||
db.query(ForumPost)
|
||||
.filter(ForumPost.category_id == category_id)
|
||||
.order_by(ForumPost.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
results: List[ForumPostSummary] = []
|
||||
for post in posts:
|
||||
reply_count = db.query(ForumReply).filter(ForumReply.post_id == post.id).count()
|
||||
author_name = post.author.full_name or post.author.username if post.author else "匿名"
|
||||
results.append(
|
||||
ForumPostSummary(
|
||||
id=post.id,
|
||||
title=post.title,
|
||||
author_name=author_name,
|
||||
created_at=post.created_at.isoformat() if post.created_at else "",
|
||||
reply_count=reply_count,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@router.post(
|
||||
"/categories/{category_id}/posts",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=ForumPostDetail,
|
||||
)
|
||||
def create_post(
|
||||
category_id: int,
|
||||
payload: CreatePostRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
category = db.query(ForumCategory).filter(ForumCategory.id == category_id).first()
|
||||
if not category:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分类不存在")
|
||||
|
||||
author = db.query(User).filter(User.username == current_user).first()
|
||||
if not author:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
|
||||
post = ForumPost(
|
||||
category_id=category.id,
|
||||
user_id=author.id,
|
||||
title=payload.title.strip(),
|
||||
content=payload.content.strip(),
|
||||
)
|
||||
db.add(post)
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
|
||||
return ForumPostDetail(
|
||||
id=post.id,
|
||||
title=post.title,
|
||||
content=post.content,
|
||||
author_name=author.full_name or author.username,
|
||||
created_at=post.created_at.isoformat() if post.created_at else "",
|
||||
replies=[],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}", response_model=ForumPostDetail)
|
||||
def get_post_detail(post_id: int, db: Session = Depends(get_db)):
|
||||
post = (
|
||||
db.query(ForumPost)
|
||||
.filter(ForumPost.id == post_id)
|
||||
.first()
|
||||
)
|
||||
if not post:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="帖子不存在")
|
||||
|
||||
replies = (
|
||||
db.query(ForumReply)
|
||||
.filter(ForumReply.post_id == post.id)
|
||||
.order_by(ForumReply.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
return ForumPostDetail(
|
||||
id=post.id,
|
||||
title=post.title,
|
||||
content=post.content,
|
||||
author_name=post.author.full_name or post.author.username if post.author else "匿名",
|
||||
created_at=post.created_at.isoformat() if post.created_at else "",
|
||||
replies=[
|
||||
ForumReplyResponse(
|
||||
id=reply.id,
|
||||
content=reply.content,
|
||||
author_name=reply.author.full_name or reply.author.username if reply.author else "匿名",
|
||||
created_at=reply.created_at.isoformat() if reply.created_at else "",
|
||||
)
|
||||
for reply in replies
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/posts/{post_id}/replies",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=ForumReplyResponse,
|
||||
)
|
||||
def create_reply(
|
||||
post_id: int,
|
||||
payload: CreateReplyRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
post = db.query(ForumPost).filter(ForumPost.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="帖子不存在")
|
||||
|
||||
author = db.query(User).filter(User.username == current_user).first()
|
||||
if not author:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
|
||||
reply = ForumReply(
|
||||
post_id=post.id,
|
||||
user_id=author.id,
|
||||
content=payload.content.strip(),
|
||||
)
|
||||
db.add(reply)
|
||||
db.commit()
|
||||
db.refresh(reply)
|
||||
|
||||
return ForumReplyResponse(
|
||||
id=reply.id,
|
||||
content=reply.content,
|
||||
author_name=author.full_name or author.username,
|
||||
created_at=reply.created_at.isoformat() if reply.created_at else "",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
图像生成和分析API
|
||||
支持文生图和图生图功能
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.security import get_current_user
|
||||
from ..models.user import User
|
||||
from ..models.generated_image import GeneratedImageRecord
|
||||
from ..models.image_models import (
|
||||
TextToImageRequest, TextToImageResponse, GeneratedImage,
|
||||
ImageEditRequest, ImageEditResponse, ImageEditResult,
|
||||
ImageVariationRequest, ImageVariationResponse, ImageVariationResult,
|
||||
ModelInfo, TemplateInfo, StyleInfo, SizeInfo, EditModeInfo
|
||||
)
|
||||
from ..services.text_to_image_service import TextToImageService
|
||||
from ..services.image_to_image_service import ImageToImageService
|
||||
|
||||
router = APIRouter(prefix="/image", tags=["图像生成"])
|
||||
|
||||
# 服务实例
|
||||
text_to_image_service = TextToImageService()
|
||||
image_to_image_service = ImageToImageService()
|
||||
|
||||
|
||||
def _save_record(db: Session, user_id: int, image_id: str, image_type: str,
|
||||
image_url: str, prompt: str = None, model: str = None,
|
||||
style: str = None, size: str = None, template: str = None,
|
||||
original_filename: str = None, mode: str = None,
|
||||
strength: float = None, metadata: dict = None):
|
||||
"""保存图像生成记录到数据库"""
|
||||
record = GeneratedImageRecord(
|
||||
user_id=user_id,
|
||||
image_id=image_id,
|
||||
image_type=image_type,
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
style=style,
|
||||
size=size,
|
||||
template=template,
|
||||
image_url=image_url,
|
||||
original_filename=original_filename,
|
||||
mode=mode,
|
||||
strength=str(strength) if strength else None,
|
||||
generation_meta=json.dumps(metadata or {}, ensure_ascii=False),
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/text-to-image", response_model=TextToImageResponse)
|
||||
async def text_to_image(
|
||||
request: TextToImageRequest,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""文生图 - 从文字描述生成图像"""
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
images_data = await text_to_image_service.generate(
|
||||
prompt=request.prompt, model=request.model, template=request.template,
|
||||
style=request.style, size=request.size, num_images=request.num_images
|
||||
)
|
||||
|
||||
images = [
|
||||
GeneratedImage(id=img["id"], url=img["url"], prompt=img["prompt"],
|
||||
model=img["model"], metadata=img["metadata"])
|
||||
for img in images_data
|
||||
]
|
||||
|
||||
for img in images_data:
|
||||
_save_record(db, user.id, img["id"], "text_to_image", img["url"],
|
||||
prompt=request.prompt, model=request.model,
|
||||
style=request.style, size=request.size,
|
||||
template=request.template, metadata=img.get("metadata"))
|
||||
|
||||
return TextToImageResponse(images=images, total=len(images))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"文生图失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/image-to-image/edit", response_model=ImageEditResponse)
|
||||
async def image_edit(
|
||||
image: UploadFile = File(...),
|
||||
prompt: str = Form(...),
|
||||
mode: str = Form("optimize"),
|
||||
mask: Optional[UploadFile] = File(None),
|
||||
strength: float = Form(0.8),
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""图生图 - 图像编辑"""
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
result_data = await image_to_image_service.edit_image(
|
||||
image_file=image, prompt=prompt, mode=mode, mask_file=mask, strength=strength
|
||||
)
|
||||
|
||||
result = ImageEditResult(
|
||||
id=result_data["id"], url=result_data["url"],
|
||||
original_filename=result_data["original_filename"],
|
||||
edit_prompt=result_data["edit_prompt"], mode=result_data["mode"],
|
||||
metadata=result_data["metadata"]
|
||||
)
|
||||
|
||||
_save_record(db, user.id, result_data["id"], "image_edit", result_data["url"],
|
||||
prompt=prompt, model="Qwen-Image-Edit",
|
||||
original_filename=result_data.get("original_filename"),
|
||||
mode=mode, strength=strength, metadata=result_data.get("metadata"))
|
||||
|
||||
return ImageEditResponse(result=result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"图像编辑失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/image-to-image/variations", response_model=ImageVariationResponse)
|
||||
async def image_variations(
|
||||
image: UploadFile = File(...),
|
||||
num_variations: int = Form(3),
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""图生图 - 生成图像变体"""
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
variations_data = await image_to_image_service.create_variation(
|
||||
image_file=image, num_variations=num_variations
|
||||
)
|
||||
|
||||
variations = [
|
||||
ImageVariationResult(id=var["id"], url=var["url"], type=var["type"], metadata=var["metadata"])
|
||||
for var in variations_data
|
||||
]
|
||||
|
||||
for var in variations_data:
|
||||
_save_record(db, user.id, var["id"], "variation", var["url"],
|
||||
model="Qwen-Image-Edit", metadata=var.get("metadata"))
|
||||
|
||||
return ImageVariationResponse(variations=variations, total=len(variations))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"生成图像变体失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/image-to-image/style-transfer", response_model=ImageEditResponse)
|
||||
async def style_transfer(
|
||||
image: UploadFile = File(...),
|
||||
style_prompt: str = Form(...),
|
||||
strength: float = Form(0.8),
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""图生图 - 风格转换"""
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
result_data = await image_to_image_service.style_transfer(
|
||||
image_file=image, style_prompt=style_prompt, strength=strength
|
||||
)
|
||||
|
||||
result = ImageEditResult(
|
||||
id=result_data["id"], url=result_data["url"],
|
||||
original_filename=result_data["original_filename"],
|
||||
edit_prompt=result_data["edit_prompt"], mode=result_data["mode"],
|
||||
metadata=result_data["metadata"]
|
||||
)
|
||||
|
||||
_save_record(db, user.id, result_data["id"], "style_transfer", result_data["url"],
|
||||
prompt=style_prompt, model="Qwen-Image-Edit",
|
||||
original_filename=result_data.get("original_filename"),
|
||||
mode="style_transfer", strength=strength, metadata=result_data.get("metadata"))
|
||||
|
||||
return ImageEditResponse(result=result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"风格转换失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/image-to-image/optimize", response_model=ImageEditResponse)
|
||||
async def optimize_image(
|
||||
image: UploadFile = File(...),
|
||||
optimization_prompt: str = Form("优化图像质量,增强细节,提高清晰度"),
|
||||
strength: float = Form(0.6),
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""图生图 - 图像优化"""
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
result_data = await image_to_image_service.optimize_image(
|
||||
image_file=image, optimization_prompt=optimization_prompt, strength=strength
|
||||
)
|
||||
|
||||
result = ImageEditResult(
|
||||
id=result_data["id"], url=result_data["url"],
|
||||
original_filename=result_data["original_filename"],
|
||||
edit_prompt=result_data["edit_prompt"], mode=result_data["mode"],
|
||||
metadata=result_data["metadata"]
|
||||
)
|
||||
|
||||
_save_record(db, user.id, result_data["id"], "optimize", result_data["url"],
|
||||
prompt=optimization_prompt, model="Qwen-Image-Edit",
|
||||
original_filename=result_data.get("original_filename"),
|
||||
mode="optimize", strength=strength, metadata=result_data.get("metadata"))
|
||||
|
||||
return ImageEditResponse(result=result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"图像优化失败: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/image-to-image/outpaint", response_model=ImageEditResponse)
|
||||
async def outpaint_image(
|
||||
image: UploadFile = File(...),
|
||||
expansion_prompt: str = Form(...),
|
||||
strength: float = Form(0.7),
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""图生图 - 图像智能扩展"""
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
result_data = await image_to_image_service.outpaint_image(
|
||||
image_file=image, expansion_prompt=expansion_prompt, strength=strength
|
||||
)
|
||||
|
||||
result = ImageEditResult(
|
||||
id=result_data["id"], url=result_data["url"],
|
||||
original_filename=result_data["original_filename"],
|
||||
edit_prompt=result_data["edit_prompt"], mode=result_data["mode"],
|
||||
metadata=result_data["metadata"]
|
||||
)
|
||||
|
||||
_save_record(db, user.id, result_data["id"], "outpaint", result_data["url"],
|
||||
prompt=expansion_prompt, model="Qwen-Image-Edit",
|
||||
original_filename=result_data.get("original_filename"),
|
||||
mode="outpaint", strength=strength, metadata=result_data.get("metadata"))
|
||||
|
||||
return ImageEditResponse(result=result)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"图像扩展失败: {str(e)}")
|
||||
|
||||
|
||||
# 配置信息端点
|
||||
@router.get("/models")
|
||||
async def get_available_models():
|
||||
"""获取可用的模型列表"""
|
||||
models_data = text_to_image_service.get_available_models()
|
||||
return {
|
||||
"models": [
|
||||
ModelInfo(id=model["id"], name=model["name"], description=model["description"])
|
||||
for model in models_data
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
async def get_available_templates():
|
||||
"""获取可用的提示词模板"""
|
||||
templates_data = text_to_image_service.get_available_templates()
|
||||
return {
|
||||
"templates": [
|
||||
TemplateInfo(id=template["id"], name=template["name"], description=template["description"])
|
||||
for template in templates_data
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/styles")
|
||||
async def get_available_styles():
|
||||
"""获取可用的风格选项"""
|
||||
styles_data = text_to_image_service.get_available_styles()
|
||||
return {
|
||||
"styles": [
|
||||
StyleInfo(id=style["id"], name=style["name"], description=style["description"])
|
||||
for style in styles_data
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sizes")
|
||||
async def get_available_sizes():
|
||||
"""获取可用的尺寸选项"""
|
||||
sizes_data = text_to_image_service.get_available_sizes()
|
||||
return {
|
||||
"sizes": [
|
||||
SizeInfo(id=size["id"], name=size["name"], description=size["description"])
|
||||
for size in sizes_data
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/edit-modes")
|
||||
async def get_edit_modes():
|
||||
"""获取可用的编辑模式"""
|
||||
modes_data = image_to_image_service.get_available_modes()
|
||||
return {
|
||||
"modes": [
|
||||
EditModeInfo(id=mode["id"], name=mode["name"], description=mode["description"])
|
||||
for mode in modes_data
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/style-presets")
|
||||
async def get_style_presets():
|
||||
"""获取预设风格选项"""
|
||||
presets_data = image_to_image_service.get_style_presets()
|
||||
return {
|
||||
"presets": [
|
||||
StyleInfo(id=preset["id"], name=preset["name"], description=preset["description"])
|
||||
for preset in presets_data
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_image_history(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户的图像生成历史"""
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
total = db.query(GeneratedImageRecord).filter(GeneratedImageRecord.user_id == user.id).count()
|
||||
records = (
|
||||
db.query(GeneratedImageRecord)
|
||||
.filter(GeneratedImageRecord.user_id == user.id)
|
||||
.order_by(GeneratedImageRecord.created_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
images = []
|
||||
for r in records:
|
||||
img = {
|
||||
"id": r.image_id,
|
||||
"url": r.image_url,
|
||||
"image_type": r.image_type,
|
||||
"prompt": r.prompt,
|
||||
"model": r.model,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
if r.style:
|
||||
img["style"] = r.style
|
||||
if r.size:
|
||||
img["size"] = r.size
|
||||
if r.template:
|
||||
img["template"] = r.template
|
||||
if r.mode:
|
||||
img["mode"] = r.mode
|
||||
if r.original_filename:
|
||||
img["original_filename"] = r.original_filename
|
||||
images.append(img)
|
||||
|
||||
return {
|
||||
"images": images,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"has_next": page * page_size < total,
|
||||
"has_prev": page > 1
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"获取图像历史失败: {str(e)}")
|
||||
@@ -0,0 +1,809 @@
|
||||
"""
|
||||
知识库CRUD API
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..core.database import get_db
|
||||
from ..core.security import get_current_user
|
||||
from ..core.config import settings
|
||||
from ..models.knowledge_base import KnowledgeBase
|
||||
from ..models.document import Document
|
||||
from ..models.user import User
|
||||
from ..services.document_service import DocumentService
|
||||
|
||||
router = APIRouter(prefix="/knowledge-bases", tags=["知识库管理"])
|
||||
|
||||
|
||||
class KnowledgeBaseResponse(BaseModel):
|
||||
"""知识库响应模型"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
user_id: int
|
||||
document_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
is_system: bool
|
||||
|
||||
|
||||
class KnowledgeBaseCreate(BaseModel):
|
||||
"""创建知识库请求模型"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeBaseUpdate(BaseModel):
|
||||
"""更新知识库请求模型"""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class KnowledgeBaseDetailResponse(KnowledgeBaseResponse):
|
||||
"""知识库详情响应模型"""
|
||||
documents: List[dict]
|
||||
|
||||
|
||||
class DocumentUploadResponse(BaseModel):
|
||||
"""文档上传响应模型"""
|
||||
id: int
|
||||
filename: str
|
||||
title: str
|
||||
message: str
|
||||
|
||||
|
||||
@router.get("", response_model=List[KnowledgeBaseResponse])
|
||||
async def get_knowledge_bases(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取用户的所有知识库"""
|
||||
print(f"DEBUG: get_knowledge_bases called for user: {current_user}")
|
||||
try:
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取用户的知识库:包括用户自己的知识库 + 所有系统知识库
|
||||
knowledge_bases = db.query(KnowledgeBase).filter(
|
||||
or_(
|
||||
KnowledgeBase.user_id == user.id, # 用户自己的知识库
|
||||
KnowledgeBase.is_system == True # 所有系统知识库(所有用户可见)
|
||||
)
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for kb in knowledge_bases:
|
||||
# 计算文档数量
|
||||
document_count = db.query(Document).filter(
|
||||
Document.knowledge_base_id == kb.id
|
||||
).count()
|
||||
|
||||
result.append(KnowledgeBaseResponse(
|
||||
id=kb.id,
|
||||
name=kb.name,
|
||||
description=kb.description,
|
||||
user_id=kb.user_id,
|
||||
document_count=document_count,
|
||||
created_at=kb.created_at.isoformat(),
|
||||
updated_at=kb.updated_at.isoformat() if kb.updated_at else kb.created_at.isoformat(),
|
||||
is_system=kb.is_system
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取知识库列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=KnowledgeBaseResponse)
|
||||
async def create_knowledge_base(
|
||||
data: KnowledgeBaseCreate,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建新知识库"""
|
||||
print(f"DEBUG: create_knowledge_base called for user: {current_user}, data: {data}")
|
||||
try:
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 检查知识库名称是否已存在
|
||||
existing_kb = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.name == data.name,
|
||||
KnowledgeBase.user_id == user.id
|
||||
).first()
|
||||
|
||||
if existing_kb:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="知识库名称已存在"
|
||||
)
|
||||
|
||||
# 创建知识库
|
||||
knowledge_base = KnowledgeBase(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
user_id=user.id
|
||||
)
|
||||
|
||||
db.add(knowledge_base)
|
||||
db.commit()
|
||||
db.refresh(knowledge_base)
|
||||
|
||||
return KnowledgeBaseResponse(
|
||||
id=knowledge_base.id,
|
||||
name=knowledge_base.name,
|
||||
description=knowledge_base.description,
|
||||
user_id=knowledge_base.user_id,
|
||||
document_count=0,
|
||||
created_at=knowledge_base.created_at.isoformat(),
|
||||
updated_at=knowledge_base.updated_at.isoformat() if knowledge_base.updated_at else knowledge_base.created_at.isoformat(),
|
||||
is_system=knowledge_base.is_system
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"创建知识库失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{knowledge_base_id}", response_model=KnowledgeBaseDetailResponse)
|
||||
async def get_knowledge_base(
|
||||
knowledge_base_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取知识库详情"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取知识库:用户自己的知识库或系统知识库(所有用户可查看)
|
||||
knowledge_base = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.id == knowledge_base_id,
|
||||
or_(
|
||||
KnowledgeBase.user_id == user.id, # 用户自己的知识库
|
||||
KnowledgeBase.is_system == True # 系统知识库(所有用户可见)
|
||||
)
|
||||
).first()
|
||||
|
||||
if not knowledge_base:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="知识库不存在"
|
||||
)
|
||||
|
||||
# 获取知识库的文档
|
||||
documents = db.query(Document).filter(
|
||||
Document.knowledge_base_id == knowledge_base_id
|
||||
).all()
|
||||
|
||||
document_list = []
|
||||
for doc in documents:
|
||||
document_list.append({
|
||||
"id": doc.id,
|
||||
"filename": doc.filename,
|
||||
"title": doc.title,
|
||||
"file_size": doc.file_size,
|
||||
"file_type": doc.file_type,
|
||||
"is_processed": doc.is_processed,
|
||||
"is_public": doc.is_public,
|
||||
"knowledge_base_id": doc.knowledge_base_id,
|
||||
"created_at": doc.created_at.isoformat(),
|
||||
"updated_at": doc.updated_at.isoformat() if doc.updated_at else doc.created_at.isoformat()
|
||||
})
|
||||
|
||||
return KnowledgeBaseDetailResponse(
|
||||
id=knowledge_base.id,
|
||||
name=knowledge_base.name,
|
||||
description=knowledge_base.description,
|
||||
user_id=knowledge_base.user_id,
|
||||
document_count=len(documents),
|
||||
created_at=knowledge_base.created_at.isoformat(),
|
||||
updated_at=knowledge_base.updated_at.isoformat() if knowledge_base.updated_at else knowledge_base.created_at.isoformat(),
|
||||
is_system=knowledge_base.is_system,
|
||||
documents=document_list
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取知识库详情失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{knowledge_base_id}", response_model=KnowledgeBaseResponse)
|
||||
async def update_knowledge_base(
|
||||
knowledge_base_id: int,
|
||||
data: KnowledgeBaseUpdate,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新知识库"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取知识库
|
||||
knowledge_base = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.id == knowledge_base_id,
|
||||
KnowledgeBase.user_id == user.id
|
||||
).first()
|
||||
|
||||
if not knowledge_base:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="知识库不存在"
|
||||
)
|
||||
|
||||
# 更新字段
|
||||
if data.name is not None:
|
||||
# 检查新名称是否已存在
|
||||
existing_kb = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.name == data.name,
|
||||
KnowledgeBase.user_id == user.id,
|
||||
KnowledgeBase.id != knowledge_base_id
|
||||
).first()
|
||||
|
||||
if existing_kb:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="知识库名称已存在"
|
||||
)
|
||||
knowledge_base.name = data.name
|
||||
|
||||
if data.description is not None:
|
||||
knowledge_base.description = data.description
|
||||
|
||||
db.commit()
|
||||
db.refresh(knowledge_base)
|
||||
|
||||
# 计算文档数量
|
||||
document_count = db.query(Document).filter(
|
||||
Document.knowledge_base_id == knowledge_base_id
|
||||
).count()
|
||||
|
||||
return KnowledgeBaseResponse(
|
||||
id=knowledge_base.id,
|
||||
name=knowledge_base.name,
|
||||
description=knowledge_base.description,
|
||||
user_id=knowledge_base.user_id,
|
||||
document_count=document_count,
|
||||
created_at=knowledge_base.created_at.isoformat(),
|
||||
updated_at=knowledge_base.updated_at.isoformat() if knowledge_base.updated_at else knowledge_base.created_at.isoformat(),
|
||||
is_system=knowledge_base.is_system
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"更新知识库失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{knowledge_base_id}")
|
||||
async def delete_knowledge_base(
|
||||
knowledge_base_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除知识库"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 获取知识库
|
||||
knowledge_base = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.id == knowledge_base_id,
|
||||
KnowledgeBase.user_id == user.id
|
||||
).first()
|
||||
|
||||
if not knowledge_base:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="知识库不存在"
|
||||
)
|
||||
|
||||
# 1. 获取知识库下的所有文档
|
||||
documents = db.query(Document).filter(
|
||||
Document.knowledge_base_id == knowledge_base_id
|
||||
).all()
|
||||
|
||||
print(f"开始删除知识库 '{knowledge_base.name}',包含 {len(documents)} 个文档")
|
||||
|
||||
# 2. 逐个删除文档的向量数据和物理文件
|
||||
document_service = DocumentService(db)
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
for document in documents:
|
||||
try:
|
||||
# 删除向量数据
|
||||
vector_deleted = document_service.delete_document_chunks(document.id)
|
||||
if vector_deleted:
|
||||
print(f"成功删除文档向量数据: {document.filename}")
|
||||
else:
|
||||
print(f"警告:删除文档向量数据失败: {document.filename}")
|
||||
error_count += 1
|
||||
|
||||
# 删除物理文件
|
||||
if os.path.exists(document.file_path):
|
||||
os.remove(document.file_path)
|
||||
print(f"成功删除物理文件: {document.file_path}")
|
||||
else:
|
||||
print(f"物理文件不存在: {document.file_path}")
|
||||
|
||||
success_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"删除文档 {document.filename} 的资源时出错: {str(e)}")
|
||||
import traceback
|
||||
print(f"详细错误信息: {traceback.format_exc()}")
|
||||
error_count += 1
|
||||
# 继续处理其他文档
|
||||
|
||||
print(f"文档清理完成: 成功 {success_count} 个,失败 {error_count} 个")
|
||||
|
||||
# 3. 删除知识库(级联删除文档记录)
|
||||
db.delete(knowledge_base)
|
||||
db.commit()
|
||||
print(f"成功删除知识库数据库记录: {knowledge_base.name}")
|
||||
|
||||
return {"message": "知识库删除成功"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除知识库失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{knowledge_base_id}/documents", response_model=DocumentUploadResponse)
|
||||
async def upload_document_to_knowledge_base(
|
||||
knowledge_base_id: int,
|
||||
file: UploadFile = File(...),
|
||||
title: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""上传文档到知识库"""
|
||||
try:
|
||||
# 检查文件类型
|
||||
allowed_extensions = [".pdf", ".docx", ".txt", ".md"]
|
||||
file_extension = Path(file.filename).suffix.lower()
|
||||
|
||||
if file_extension not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的文件类型。支持的类型: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
|
||||
# 检查文件大小
|
||||
file_size = 0
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
max_size = 10 * 1024 * 1024 # 10MB
|
||||
if file_size > max_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文件大小超过限制(10MB)"
|
||||
)
|
||||
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 验证知识库存在
|
||||
knowledge_base = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.id == knowledge_base_id
|
||||
).first()
|
||||
|
||||
if not knowledge_base:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="知识库不存在"
|
||||
)
|
||||
|
||||
# 检查是否为系统知识库,系统知识库不允许任何用户上传文档
|
||||
if knowledge_base.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="系统知识库不允许上传文档,请使用您自己的知识库"
|
||||
)
|
||||
|
||||
# 验证知识库属于当前用户(非系统知识库必须属于用户)
|
||||
if knowledge_base.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="您没有权限向此知识库上传文档"
|
||||
)
|
||||
|
||||
# 生成唯一文件名
|
||||
file_id = str(uuid.uuid4())
|
||||
filename = f"{file_id}{file_extension}"
|
||||
|
||||
# 保存文件
|
||||
upload_dir = Path(settings.upload_dir)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = upload_dir / filename
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# 创建文档记录
|
||||
document = Document(
|
||||
user_id=user.id,
|
||||
knowledge_base_id=knowledge_base_id,
|
||||
filename=filename,
|
||||
original_filename=file.filename,
|
||||
file_path=str(file_path),
|
||||
file_size=file_size,
|
||||
file_type=file_extension,
|
||||
title=title or Path(file.filename).stem,
|
||||
description=description,
|
||||
is_processed=False
|
||||
)
|
||||
|
||||
db.add(document)
|
||||
db.commit()
|
||||
db.refresh(document)
|
||||
|
||||
# 自动处理文档向量化
|
||||
try:
|
||||
print(f"开始处理文档向量化: {document.filename} (ID: {document.id})")
|
||||
document_service = DocumentService(db)
|
||||
success = await document_service.process_document(document.id)
|
||||
|
||||
if success:
|
||||
print(f"文档向量化处理成功: {document.filename}")
|
||||
message = "文档上传并处理成功"
|
||||
else:
|
||||
print(f"文档向量化处理失败: {document.filename}")
|
||||
message = "文档上传成功,但向量化处理失败"
|
||||
except Exception as e:
|
||||
print(f"文档向量化处理异常: {document.filename}, 错误: {str(e)}")
|
||||
import traceback
|
||||
print(f"详细错误信息: {traceback.format_exc()}")
|
||||
message = "文档上传成功,但向量化处理失败"
|
||||
|
||||
return DocumentUploadResponse(
|
||||
id=document.id,
|
||||
filename=document.filename,
|
||||
title=document.title,
|
||||
message=message
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"文档上传失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{knowledge_base_id}/documents", response_model=List[dict])
|
||||
async def get_knowledge_base_documents(
|
||||
knowledge_base_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取知识库的文档列表"""
|
||||
try:
|
||||
# 获取用户ID
|
||||
user = db.query(User).filter(User.username == current_user).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="用户不存在"
|
||||
)
|
||||
|
||||
# 验证知识库存在且属于用户
|
||||
knowledge_base = db.query(KnowledgeBase).filter(
|
||||
KnowledgeBase.id == knowledge_base_id,
|
||||
KnowledgeBase.user_id == user.id
|
||||
).first()
|
||||
|
||||
if not knowledge_base:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="知识库不存在"
|
||||
)
|
||||
|
||||
# 获取知识库的文档
|
||||
documents = db.query(Document).filter(
|
||||
Document.knowledge_base_id == knowledge_base_id
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for doc in documents:
|
||||
result.append({
|
||||
"id": doc.id,
|
||||
"filename": doc.filename,
|
||||
"title": doc.title,
|
||||
"file_size": doc.file_size,
|
||||
"file_type": doc.file_type,
|
||||
"is_processed": doc.is_processed,
|
||||
"is_public": doc.is_public,
|
||||
"knowledge_base_id": doc.knowledge_base_id,
|
||||
"created_at": doc.created_at.isoformat(),
|
||||
"updated_at": doc.updated_at.isoformat() if doc.updated_at else doc.created_at.isoformat()
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取文档列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ========== 知识库管理操作API(从knowledge.py合并) ==========
|
||||
|
||||
from ..services.knowledge_base_service import KnowledgeBaseService
|
||||
from ..services.file_watcher_service import get_file_watcher_service
|
||||
|
||||
|
||||
class ScanResponse(BaseModel):
|
||||
"""扫描响应模型"""
|
||||
success: bool
|
||||
message: str
|
||||
results: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
"""状态响应模型"""
|
||||
success: bool
|
||||
status: Dict[str, Any]
|
||||
|
||||
|
||||
class ReindexResponse(BaseModel):
|
||||
"""重新索引响应模型"""
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
@router.post("/scan", response_model=ScanResponse)
|
||||
async def scan_knowledge_base(
|
||||
directory: Optional[str] = None,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""手动触发知识库扫描和入库"""
|
||||
try:
|
||||
kb_service = KnowledgeBaseService(db)
|
||||
results = kb_service.scan_directory(directory)
|
||||
|
||||
if results["success"]:
|
||||
return ScanResponse(
|
||||
success=True,
|
||||
message="知识库扫描完成",
|
||||
results=results
|
||||
)
|
||||
else:
|
||||
return ScanResponse(
|
||||
success=False,
|
||||
message="知识库扫描失败",
|
||||
results=results
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"扫描知识库失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=StatusResponse)
|
||||
async def get_knowledge_base_status(
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取知识库状态"""
|
||||
try:
|
||||
kb_service = KnowledgeBaseService(db)
|
||||
watcher_service = get_file_watcher_service()
|
||||
|
||||
# 获取知识库状态
|
||||
kb_status = kb_service.get_knowledge_base_status()
|
||||
|
||||
# 获取文件监控状态
|
||||
watcher_status = watcher_service.get_status()
|
||||
|
||||
# 合并状态信息
|
||||
status_info = {
|
||||
"knowledge_base": kb_status,
|
||||
"file_watcher": watcher_status
|
||||
}
|
||||
|
||||
return StatusResponse(
|
||||
success=True,
|
||||
status=status_info
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取知识库状态失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/documents/{document_id}/reindex", response_model=ReindexResponse)
|
||||
async def reindex_document(
|
||||
document_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""重新索引指定文档"""
|
||||
try:
|
||||
kb_service = KnowledgeBaseService(db)
|
||||
result = kb_service.reindex_document(document_id)
|
||||
|
||||
if result["success"]:
|
||||
return ReindexResponse(
|
||||
success=True,
|
||||
message=result["message"]
|
||||
)
|
||||
else:
|
||||
return ReindexResponse(
|
||||
success=False,
|
||||
message=result["message"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"重新索引文档失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/documents/{document_id}", response_model=ReindexResponse)
|
||||
async def delete_knowledge_base_document(
|
||||
document_id: int,
|
||||
current_user: str = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除知识库文档"""
|
||||
try:
|
||||
kb_service = KnowledgeBaseService(db)
|
||||
result = kb_service.delete_document(document_id)
|
||||
|
||||
if result["success"]:
|
||||
return ReindexResponse(
|
||||
success=True,
|
||||
message=result["message"]
|
||||
)
|
||||
else:
|
||||
return ReindexResponse(
|
||||
success=False,
|
||||
message=result["message"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"删除文档失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/watcher/start", response_model=ReindexResponse)
|
||||
async def start_file_watcher(
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""启动文件监控服务"""
|
||||
try:
|
||||
watcher_service = get_file_watcher_service()
|
||||
watcher_service.start()
|
||||
|
||||
if watcher_service.is_active():
|
||||
return ReindexResponse(
|
||||
success=True,
|
||||
message="文件监控服务启动成功"
|
||||
)
|
||||
else:
|
||||
return ReindexResponse(
|
||||
success=False,
|
||||
message="文件监控服务启动失败"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"启动文件监控服务失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/watcher/stop", response_model=ReindexResponse)
|
||||
async def stop_file_watcher(
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""停止文件监控服务"""
|
||||
try:
|
||||
watcher_service = get_file_watcher_service()
|
||||
watcher_service.stop()
|
||||
|
||||
return ReindexResponse(
|
||||
success=True,
|
||||
message="文件监控服务停止成功"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"停止文件监控服务失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/watcher/status", response_model=StatusResponse)
|
||||
async def get_watcher_status(
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""获取文件监控服务状态"""
|
||||
try:
|
||||
watcher_service = get_file_watcher_service()
|
||||
status_info = watcher_service.get_status()
|
||||
|
||||
return StatusResponse(
|
||||
success=True,
|
||||
status=status_info
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取文件监控状态失败: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
# 核心配置模块
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
应用配置管理
|
||||
"""
|
||||
import os
|
||||
from typing import List, Optional
|
||||
from pydantic import validator, field_validator, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# 应用基础配置
|
||||
app_name: str = "国土空间规划课程智能体"
|
||||
app_version: str = "0.1.0"
|
||||
debug: bool = True
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8000
|
||||
|
||||
# 硅基流动API配置
|
||||
siliconflow_api_key: str
|
||||
siliconflow_base_url: str = "https://api.siliconflow.cn/v1"
|
||||
siliconflow_model: str = "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B"
|
||||
|
||||
# 数据库配置
|
||||
database_url: str = "sqlite:///../data/database/course_agent.db"
|
||||
|
||||
# JWT配置
|
||||
secret_key: str
|
||||
algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
# 向量数据库配置
|
||||
vector_store_path: str = os.getenv("VECTOR_STORE_PATH", "./vector_store")
|
||||
embedding_model: str = "shibing624/text2vec-base-chinese"
|
||||
|
||||
# CORS配置
|
||||
allowed_origins: List[str] = [
|
||||
"http://localhost:8001",
|
||||
"http://127.0.0.1:8001"
|
||||
]
|
||||
|
||||
# 文件上传配置
|
||||
upload_dir: str = os.getenv("UPLOAD_DIR", "./uploads")
|
||||
max_file_size: int = 10485760 # 10MB
|
||||
allowed_extensions: List[str] = [".pdf", ".docx", ".txt", ".md"]
|
||||
|
||||
# 知识库配置
|
||||
knowledge_base_dir: str = os.getenv("KNOWLEDGE_BASE_DIR", "./data/knowledge_base")
|
||||
enable_file_watcher: bool = True
|
||||
|
||||
# 书籍配置
|
||||
book_dir: str = os.getenv("BOOK_DIR", "./data/book") # LaTeX书籍目录
|
||||
|
||||
# 图像生成配置
|
||||
generated_images_dir: str = os.getenv("GENERATED_IMAGES_DIR", "./generated_images")
|
||||
image_generation_timeout: int = 120 # 图像生成超时时间(秒)
|
||||
max_image_size: int = 10485760 # 最大图像文件大小(10MB)
|
||||
|
||||
# 日志配置
|
||||
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
log_file: str = os.getenv("LOG_FILE", "./logs/app.log")
|
||||
|
||||
# Hugging Face镜像配置
|
||||
hf_endpoint: Optional[str] = "https://hf-mirror.com"
|
||||
|
||||
@field_validator("allowed_origins", mode="before")
|
||||
@classmethod
|
||||
def parse_cors_origins(cls, v):
|
||||
if v is None:
|
||||
return []
|
||||
if isinstance(v, str):
|
||||
# 移除可能的引号和空格
|
||||
v = v.strip().strip('"').strip("'")
|
||||
if not v:
|
||||
return []
|
||||
# 按逗号分割并清理
|
||||
origins = [origin.strip().strip('"').strip("'") for origin in v.split(",") if origin.strip()]
|
||||
return origins
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
return []
|
||||
|
||||
@field_validator("allowed_extensions", mode="before")
|
||||
@classmethod
|
||||
def parse_extensions(cls, v):
|
||||
if isinstance(v, str):
|
||||
# 尝试解析 JSON 格式
|
||||
try:
|
||||
import json
|
||||
parsed = json.loads(v)
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# 如果不是 JSON,按逗号分割
|
||||
return [ext.strip() for ext in v.split(",")]
|
||||
return v
|
||||
|
||||
@field_validator("database_url", mode="before")
|
||||
@classmethod
|
||||
def validate_database_url(cls, v):
|
||||
"""验证数据库URL,支持SQLite和PostgreSQL"""
|
||||
if not v:
|
||||
# 默认使用SQLite
|
||||
return "sqlite:///./course_agent.db"
|
||||
|
||||
# 如果是PostgreSQL URL,确保包含必要的组件
|
||||
if v.startswith("postgresql://") or v.startswith("postgresql+psycopg://"):
|
||||
# 检查是否包含必要的认证信息
|
||||
if "@" not in v:
|
||||
raise ValueError("PostgreSQL URL必须包含认证信息")
|
||||
|
||||
return v
|
||||
|
||||
class Config:
|
||||
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "..", ".env")
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
extra = "ignore"
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""获取配置实例"""
|
||||
return settings
|
||||
|
||||
|
||||
def ensure_directories():
|
||||
"""确保必要的目录存在"""
|
||||
directories = [
|
||||
settings.vector_store_path,
|
||||
settings.upload_dir,
|
||||
settings.knowledge_base_dir,
|
||||
settings.generated_images_dir,
|
||||
os.path.dirname(settings.log_file),
|
||||
]
|
||||
|
||||
for directory in directories:
|
||||
Path(directory).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def is_postgresql_database() -> bool:
|
||||
"""检查是否使用PostgreSQL数据库"""
|
||||
db_url = get_settings().database_url
|
||||
return db_url.startswith("postgresql://") or db_url.startswith("postgresql+psycopg://")
|
||||
|
||||
|
||||
def get_database_type() -> str:
|
||||
"""获取数据库类型"""
|
||||
db_url = get_settings().database_url
|
||||
if "sqlite" in db_url:
|
||||
return "sqlite"
|
||||
elif "postgresql" in db_url:
|
||||
return "postgresql"
|
||||
else:
|
||||
return "unknown"
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
数据库连接和会话管理
|
||||
"""
|
||||
from sqlalchemy import create_engine, MetaData, text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from typing import Generator
|
||||
import logging
|
||||
|
||||
from .config import get_settings, is_postgresql_database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
# 创建数据库引擎
|
||||
def create_database_engine():
|
||||
"""创建数据库引擎,根据数据库类型配置不同的参数(带重试机制)"""
|
||||
import time
|
||||
|
||||
db_url = settings.database_url
|
||||
|
||||
# 重试机制:等待 DNS 解析就绪
|
||||
max_retries = 10
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if is_postgresql_database():
|
||||
# PostgreSQL配置
|
||||
engine = create_engine(
|
||||
db_url,
|
||||
pool_size=20,
|
||||
max_overflow=30,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
echo=settings.debug,
|
||||
echo_pool=settings.debug,
|
||||
connect_args={"connect_timeout": 10} # 连接超时
|
||||
)
|
||||
# 测试连接
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
logger.info("使用PostgreSQL数据库引擎")
|
||||
else:
|
||||
# SQLite配置
|
||||
engine = create_engine(
|
||||
db_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
echo=settings.debug
|
||||
)
|
||||
logger.info("使用SQLite数据库引擎")
|
||||
|
||||
return engine
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(f"数据库引擎创建失败(尝试 {attempt + 1}/{max_retries}): {e}")
|
||||
logger.info(f"等待 {retry_delay} 秒后重试...")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
logger.error(f"数据库引擎创建失败,已达到最大重试次数: {e}")
|
||||
raise
|
||||
|
||||
# 延迟创建引擎(避免在导入时就连接数据库,等待 DNS 解析就绪)
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
|
||||
def get_engine():
|
||||
"""获取数据库引擎(懒加载)"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_database_engine()
|
||||
return _engine
|
||||
|
||||
def get_session_local():
|
||||
"""获取会话工厂(懒加载)"""
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=get_engine())
|
||||
return _SessionLocal
|
||||
|
||||
# 为了向后兼容,提供这些变量(延迟初始化)
|
||||
# 注意:这些变量在首次访问时会触发数据库连接
|
||||
# 使用类来模拟属性访问,延迟到实际使用时才连接
|
||||
class _LazyEngine:
|
||||
def __getattr__(self, name):
|
||||
return getattr(get_engine(), name)
|
||||
def connect(self, *args, **kwargs):
|
||||
return get_engine().connect(*args, **kwargs)
|
||||
def execute(self, *args, **kwargs):
|
||||
return get_engine().execute(*args, **kwargs)
|
||||
|
||||
class _LazySessionLocal:
|
||||
def __call__(self, *args, **kwargs):
|
||||
return get_session_local()(*args, **kwargs)
|
||||
|
||||
# 为了向后兼容,提供这些变量(延迟初始化)
|
||||
engine = _LazyEngine()
|
||||
SessionLocal = _LazySessionLocal()
|
||||
|
||||
# 创建基础模型类
|
||||
Base = declarative_base()
|
||||
|
||||
# 元数据
|
||||
metadata = MetaData()
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""获取数据库会话"""
|
||||
db = get_session_local()()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def create_tables():
|
||||
"""创建数据库表(带重试机制,处理 DNS 解析延迟)"""
|
||||
import time
|
||||
|
||||
# 导入所有模型以确保它们被注册到 Base.metadata
|
||||
from ..models import User, ChatSession, ChatMessage, Document, DocumentChunk, KnowledgeBase, CourseModule
|
||||
from ..models.book_structure import Book, Chapter, Section, Subsection
|
||||
from ..models.forum import ForumCategory, ForumPost, ForumReply
|
||||
|
||||
# 重试机制:等待 DNS 解析和数据库就绪
|
||||
max_retries = 10
|
||||
retry_delay = 2
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# 创建所有表
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("数据库表创建完成")
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(f"数据库连接失败(尝试 {attempt + 1}/{max_retries}): {e}")
|
||||
logger.info(f"等待 {retry_delay} 秒后重试...")
|
||||
time.sleep(retry_delay)
|
||||
else:
|
||||
logger.error(f"数据库连接失败,已达到最大重试次数: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def check_database_connection():
|
||||
"""检查数据库连接"""
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
if is_postgresql_database():
|
||||
conn.execute(text("SELECT 1"))
|
||||
logger.info("PostgreSQL数据库连接正常")
|
||||
else:
|
||||
conn.execute(text("SELECT 1"))
|
||||
logger.info("SQLite数据库连接正常")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"数据库连接失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_database_info():
|
||||
"""获取数据库信息"""
|
||||
db_type = "PostgreSQL" if is_postgresql_database() else "SQLite"
|
||||
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
if is_postgresql_database():
|
||||
# PostgreSQL信息
|
||||
result = conn.execute(text("SELECT version()"))
|
||||
version = result.scalar()
|
||||
result = conn.execute(text("SELECT current_database()"))
|
||||
db_name = result.scalar()
|
||||
return {
|
||||
"type": db_type,
|
||||
"version": version,
|
||||
"database": db_name,
|
||||
"url": settings.database_url.split("@")[-1] if "@" in settings.database_url else settings.database_url
|
||||
}
|
||||
else:
|
||||
# SQLite信息
|
||||
return {
|
||||
"type": db_type,
|
||||
"version": "SQLite",
|
||||
"database": settings.database_url.split("/")[-1],
|
||||
"url": settings.database_url
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取数据库信息失败: {e}")
|
||||
return {
|
||||
"type": db_type,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def vacuum_database():
|
||||
"""清理数据库(仅SQLite)"""
|
||||
if not is_postgresql_database():
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
conn.execute(text("VACUUM"))
|
||||
logger.info("SQLite数据库清理完成")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"数据库清理失败: {e}")
|
||||
return False
|
||||
else:
|
||||
logger.info("PostgreSQL不需要VACUUM操作")
|
||||
return True
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
安全相关功能
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Union
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import HTTPException, status, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# 密码加密上下文
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# JWT Bearer认证
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
"""Token响应模型"""
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
"""Token数据模型"""
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""验证密码"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""生成密码哈希"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""创建访问令牌"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[str]:
|
||||
"""验证令牌并返回用户名"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
return None
|
||||
return username
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""获取当前用户"""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
try:
|
||||
token = credentials.credentials
|
||||
username = verify_token(token)
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
return username
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
@@ -0,0 +1,7 @@
|
||||
# LangGraph工作流模块
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
LangGraph节点定义
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
from typing import Dict, Any, List, TypedDict
|
||||
from langchain.schema import HumanMessage, AIMessage, SystemMessage
|
||||
from ..llm.siliconflow import get_llm_client
|
||||
from ..rag.retrievers import KnowledgeBaseRetriever
|
||||
from ..rag.vector_store import get_vector_store
|
||||
|
||||
|
||||
def _parse_json_from_response(text: str) -> dict:
|
||||
"""从LLM响应中提取JSON,兼容markdown代码块包裹的情况"""
|
||||
# 尝试提取 ```json ... ``` 中的内容
|
||||
match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
||||
if match:
|
||||
text = match.group(1).strip()
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
class GraphState(TypedDict):
|
||||
"""图状态定义"""
|
||||
messages: List[Dict[str, str]]
|
||||
current_question: str
|
||||
retrieved_docs: List[Dict[str, Any]]
|
||||
answer: str
|
||||
sources: List[Dict[str, Any]]
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
def analyze_question_node(state: GraphState) -> GraphState:
|
||||
"""问题分析节点"""
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
|
||||
# 构建分析提示
|
||||
system_prompt = """你是一个专业的国土空间规划知识问答助手。请分析用户的问题,确定问题的类型和需要检索的知识领域。
|
||||
|
||||
问题类型包括:
|
||||
1. 概念解释类:询问基本概念、定义
|
||||
2. 方法技术类:询问规划方法、技术手段
|
||||
3. 案例分析类:询问具体案例、实践应用
|
||||
4. 政策法规类:询问相关政策、法规条文
|
||||
5. 技术标准类:询问技术标准、规范要求
|
||||
|
||||
请以JSON格式返回分析结果:
|
||||
{
|
||||
"question_type": "问题类型",
|
||||
"keywords": ["关键词1", "关键词2"],
|
||||
"domain": "知识领域",
|
||||
"intent": "用户意图"
|
||||
}"""
|
||||
|
||||
messages = [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=f"请分析这个问题:{state['current_question']}")
|
||||
]
|
||||
|
||||
response = llm.chat(messages)
|
||||
|
||||
# 解析LLM返回的JSON
|
||||
analysis = _parse_json_from_response(response)
|
||||
|
||||
new_state = state.copy()
|
||||
new_state["metadata"] = {**state["metadata"], "analysis": {
|
||||
"question_type": analysis.get("question_type", "通用"),
|
||||
"keywords": analysis.get("keywords", []),
|
||||
"domain": analysis.get("domain", "国土空间规划"),
|
||||
"intent": analysis.get("intent", "获取知识")
|
||||
}}
|
||||
|
||||
return new_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"问题分析失败: {str(e)}")
|
||||
new_state = state.copy()
|
||||
new_state["metadata"] = {**state["metadata"], "analysis": {
|
||||
"question_type": "通用",
|
||||
"keywords": [],
|
||||
"domain": "国土空间规划",
|
||||
"intent": "获取知识"
|
||||
}}
|
||||
|
||||
return new_state
|
||||
|
||||
|
||||
def retrieve_knowledge_node(state: GraphState) -> GraphState:
|
||||
"""知识检索节点"""
|
||||
try:
|
||||
# 获取知识库过滤条件
|
||||
knowledge_base_ids = state.get("metadata", {}).get("knowledge_base_ids")
|
||||
|
||||
# 使用LangChain兼容的检索器
|
||||
vector_store = get_vector_store()
|
||||
retriever = KnowledgeBaseRetriever(
|
||||
vectorstore=vector_store.vectorstore,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
search_type="similarity",
|
||||
search_kwargs={"k": 5},
|
||||
score_threshold=0.4 # 转换为相似度阈值
|
||||
)
|
||||
|
||||
# 执行检索(LangChain格式)
|
||||
from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun
|
||||
from langchain_core.callbacks import CallbackManager
|
||||
|
||||
class DummyRunManager:
|
||||
pass
|
||||
|
||||
run_manager = DummyRunManager()
|
||||
langchain_docs = retriever._get_relevant_documents(
|
||||
state["current_question"],
|
||||
run_manager=run_manager
|
||||
)
|
||||
|
||||
# 转换为旧格式
|
||||
retrieved_docs = []
|
||||
sources = []
|
||||
for doc in langchain_docs:
|
||||
retrieved_docs.append({
|
||||
"content": doc.page_content,
|
||||
"metadata": doc.metadata,
|
||||
"distance": 0.0 # LangChain不直接提供distance
|
||||
})
|
||||
sources.append({
|
||||
"title": doc.metadata.get("title", "未知标题"),
|
||||
"filename": doc.metadata.get("filename", "未知文件"),
|
||||
"page": doc.metadata.get("page", 0),
|
||||
"score": 0.8, # 默认分数
|
||||
"preview": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content
|
||||
})
|
||||
|
||||
new_state = state.copy()
|
||||
new_state["retrieved_docs"] = retrieved_docs
|
||||
new_state["sources"] = sources
|
||||
|
||||
return new_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"知识检索失败: {str(e)}")
|
||||
import traceback
|
||||
print(traceback.format_exc())
|
||||
new_state = state.copy()
|
||||
new_state["retrieved_docs"] = []
|
||||
new_state["sources"] = []
|
||||
return new_state
|
||||
|
||||
|
||||
def generate_answer_node(state: GraphState) -> GraphState:
|
||||
"""答案生成节点"""
|
||||
try:
|
||||
llm = get_llm_client()
|
||||
|
||||
# 构建上下文
|
||||
context = ""
|
||||
if state["retrieved_docs"]:
|
||||
context_parts = [doc.get("content", "") for doc in state["retrieved_docs"]]
|
||||
context = "\n\n".join(context_parts)
|
||||
|
||||
# 构建系统提示
|
||||
system_prompt = """你是一个专业的国土空间规划知识问答助手。请基于提供的上下文信息回答用户的问题。
|
||||
|
||||
要求:
|
||||
1. 回答要准确、专业、详细
|
||||
2. 如果上下文中没有相关信息,请诚实说明
|
||||
3. 回答要结构清晰,逻辑性强
|
||||
4. 适当引用相关概念和术语
|
||||
5. 回答长度控制在500-1000字之间
|
||||
|
||||
上下文信息:
|
||||
{context}"""
|
||||
|
||||
# 构建消息
|
||||
messages = [
|
||||
SystemMessage(content=system_prompt.format(context=context)),
|
||||
HumanMessage(content=f"问题:{state['current_question']}")
|
||||
]
|
||||
|
||||
# 生成回答
|
||||
answer = llm.chat(messages)
|
||||
|
||||
new_state = state.copy()
|
||||
new_state["answer"] = answer
|
||||
|
||||
return new_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"答案生成失败: {str(e)}")
|
||||
new_state = state.copy()
|
||||
new_state["answer"] = "抱歉,我无法生成合适的回答。请稍后重试。"
|
||||
return new_state
|
||||
|
||||
|
||||
def format_response_node(state: GraphState) -> GraphState:
|
||||
"""响应格式化节点"""
|
||||
try:
|
||||
# 格式化最终响应
|
||||
response = {
|
||||
"answer": state["answer"],
|
||||
"sources": state["sources"],
|
||||
"metadata": state["metadata"]
|
||||
}
|
||||
|
||||
new_state = state.copy()
|
||||
new_state["metadata"] = {**state["metadata"], "formatted_response": response}
|
||||
|
||||
return new_state
|
||||
|
||||
except Exception as e:
|
||||
print(f"响应格式化失败: {str(e)}")
|
||||
return state
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
问答LangGraph工作流
|
||||
"""
|
||||
from typing import Dict, Any, Optional, List
|
||||
from langgraph.graph import StateGraph, END
|
||||
from .nodes import GraphState, analyze_question_node, retrieve_knowledge_node, generate_answer_node, format_response_node
|
||||
|
||||
|
||||
def create_qa_graph() -> StateGraph:
|
||||
"""创建问答图"""
|
||||
# 创建状态图
|
||||
builder = StateGraph(GraphState)
|
||||
|
||||
# 添加节点
|
||||
builder.add_node("analyze", analyze_question_node)
|
||||
builder.add_node("retrieve", retrieve_knowledge_node)
|
||||
builder.add_node("generate", generate_answer_node)
|
||||
builder.add_node("format", format_response_node)
|
||||
|
||||
# 添加边
|
||||
builder.add_edge("analyze", "retrieve")
|
||||
builder.add_edge("retrieve", "generate")
|
||||
builder.add_edge("generate", "format")
|
||||
builder.add_edge("format", END)
|
||||
|
||||
# 设置入口点
|
||||
builder.set_entry_point("analyze")
|
||||
|
||||
return builder
|
||||
|
||||
|
||||
def run_qa_workflow(question: str, knowledge_base_ids: Optional[List[int]] = None) -> Dict[str, Any]:
|
||||
"""运行问答工作流"""
|
||||
try:
|
||||
# 创建图
|
||||
graph_builder = create_qa_graph()
|
||||
graph = graph_builder.compile()
|
||||
|
||||
# 初始化状态
|
||||
initial_state: GraphState = {
|
||||
"messages": [],
|
||||
"current_question": question,
|
||||
"retrieved_docs": [],
|
||||
"answer": "",
|
||||
"sources": [],
|
||||
"metadata": {"knowledge_base_ids": knowledge_base_ids}
|
||||
}
|
||||
|
||||
# 运行图
|
||||
result = graph.invoke(initial_state)
|
||||
|
||||
# 返回结果
|
||||
return {
|
||||
"answer": result["answer"],
|
||||
"sources": result["sources"],
|
||||
"metadata": result["metadata"]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"问答工作流执行失败: {str(e)}")
|
||||
return {
|
||||
"answer": "抱歉,处理您的问题时出现了错误。请稍后重试。",
|
||||
"sources": [],
|
||||
"metadata": {"error": str(e)}
|
||||
}
|
||||
|
||||
|
||||
# 全局图实例
|
||||
qa_graph = create_qa_graph().compile()
|
||||
|
||||
|
||||
def get_qa_graph():
|
||||
"""获取问答图实例"""
|
||||
return qa_graph
|
||||
@@ -0,0 +1,7 @@
|
||||
# 大模型集成模块
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
硅基流动大模型API集成
|
||||
"""
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional, AsyncGenerator
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
|
||||
|
||||
from ..core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class SiliconFlowLLM:
|
||||
"""硅基流动大模型客户端"""
|
||||
|
||||
def __init__(self, model: Optional[str] = None):
|
||||
"""初始化LLM客户端"""
|
||||
# 设置环境变量
|
||||
os.environ["OPENAI_API_KEY"] = settings.siliconflow_api_key
|
||||
os.environ["OPENAI_API_BASE"] = settings.siliconflow_base_url
|
||||
|
||||
# 使用传入的模型或默认模型
|
||||
self.model_name = model or settings.siliconflow_model
|
||||
print(f"[DEBUG-LLM] 初始化LLM客户端,使用模型: {self.model_name} (传入参数: {model}, 默认配置: {settings.siliconflow_model})")
|
||||
|
||||
# 创建LLM实例
|
||||
self.llm = ChatOpenAI(
|
||||
model=self.model_name,
|
||||
api_key=settings.siliconflow_api_key,
|
||||
base_url=settings.siliconflow_base_url,
|
||||
temperature=0.7,
|
||||
max_tokens=2000,
|
||||
streaming=True
|
||||
)
|
||||
# 检查实际使用的模型名称
|
||||
actual_model = getattr(self.llm, 'model_name', None) or getattr(self.llm, 'model', None) or str(self.llm)
|
||||
print(f"[DEBUG-LLM] ChatOpenAI实例创建完成,实际模型: {actual_model}")
|
||||
|
||||
def chat(self, messages: List[BaseMessage], **kwargs) -> str:
|
||||
"""同步聊天"""
|
||||
try:
|
||||
response = self.llm.invoke(messages, **kwargs)
|
||||
return response.content
|
||||
except Exception as e:
|
||||
raise Exception(f"LLM调用失败: {str(e)}")
|
||||
|
||||
async def achat(self, messages: List[BaseMessage], **kwargs) -> str:
|
||||
"""异步聊天"""
|
||||
try:
|
||||
response = await self.llm.ainvoke(messages, **kwargs)
|
||||
return response.content
|
||||
except Exception as e:
|
||||
raise Exception(f"LLM异步调用失败: {str(e)}")
|
||||
|
||||
async def stream_chat(self, messages: List[BaseMessage], **kwargs) -> AsyncGenerator[str, None]:
|
||||
"""流式聊天"""
|
||||
try:
|
||||
async for chunk in self.llm.astream(messages, **kwargs):
|
||||
if hasattr(chunk, 'content') and chunk.content:
|
||||
yield chunk.content
|
||||
except Exception as e:
|
||||
raise Exception(f"LLM流式调用失败: {str(e)}")
|
||||
|
||||
def create_messages(
|
||||
self,
|
||||
user_message: str,
|
||||
system_prompt: Optional[str] = None,
|
||||
chat_history: Optional[List[Dict[str, str]]] = None
|
||||
) -> List[BaseMessage]:
|
||||
"""创建消息列表"""
|
||||
messages = []
|
||||
|
||||
# 添加系统提示
|
||||
if system_prompt:
|
||||
messages.append(SystemMessage(content=system_prompt))
|
||||
|
||||
# 添加聊天历史
|
||||
if chat_history:
|
||||
for msg in chat_history:
|
||||
if msg["role"] == "user":
|
||||
messages.append(HumanMessage(content=msg["content"]))
|
||||
elif msg["role"] == "assistant":
|
||||
messages.append(AIMessage(content=msg["content"]))
|
||||
|
||||
# 添加当前用户消息
|
||||
messages.append(HumanMessage(content=user_message))
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
# 全局LLM实例(使用默认模型)
|
||||
llm_client = SiliconFlowLLM()
|
||||
|
||||
|
||||
def get_llm_client(model: Optional[str] = None) -> SiliconFlowLLM:
|
||||
"""获取LLM客户端实例
|
||||
|
||||
Args:
|
||||
model: 可选的模型名称,如果提供则创建新的实例,否则返回默认实例
|
||||
"""
|
||||
if model is None:
|
||||
return llm_client
|
||||
else:
|
||||
# 为指定模型创建新实例
|
||||
return SiliconFlowLLM(model=model)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
迁移孤立文档到默认知识库
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from ..core.database import get_db
|
||||
from ..models.user import User
|
||||
from ..models.knowledge_base import KnowledgeBase
|
||||
from ..models.document import Document
|
||||
|
||||
|
||||
def migrate_orphaned_documents():
|
||||
"""将没有知识库的文档迁移到用户的默认知识库"""
|
||||
db = next(get_db())
|
||||
|
||||
try:
|
||||
# 获取所有用户
|
||||
users = db.query(User).all()
|
||||
|
||||
for user in users:
|
||||
# 检查用户是否有孤立文档
|
||||
orphaned_docs = db.query(Document).filter(
|
||||
Document.user_id == user.id,
|
||||
Document.knowledge_base_id.is_(None)
|
||||
).all()
|
||||
|
||||
if orphaned_docs:
|
||||
# 创建默认知识库
|
||||
default_kb = KnowledgeBase(
|
||||
name="默认知识库",
|
||||
description="系统自动创建的默认知识库,包含之前上传的文档",
|
||||
user_id=user.id,
|
||||
is_system=True
|
||||
)
|
||||
db.add(default_kb)
|
||||
db.commit()
|
||||
db.refresh(default_kb)
|
||||
|
||||
# 将孤立文档迁移到默认知识库
|
||||
for doc in orphaned_docs:
|
||||
doc.knowledge_base_id = default_kb.id
|
||||
|
||||
db.commit()
|
||||
print(f"用户 {user.username} 的 {len(orphaned_docs)} 个文档已迁移到默认知识库")
|
||||
|
||||
print("孤立文档迁移完成")
|
||||
|
||||
except Exception as e:
|
||||
print(f"迁移失败: {str(e)}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_orphaned_documents()
|
||||
@@ -0,0 +1,29 @@
|
||||
# 数据模型模块
|
||||
# 导入所有模型以确保它们被注册到 Base.metadata
|
||||
from .user import User
|
||||
from .chat import ChatSession, ChatMessage
|
||||
from .document import Document, DocumentChunk
|
||||
from .knowledge_base import KnowledgeBase
|
||||
from .course_content import CourseModule
|
||||
from .book_structure import Book, Chapter, Section, Subsection
|
||||
from .forum import ForumCategory, ForumPost, ForumReply
|
||||
from .generated_image import GeneratedImageRecord
|
||||
|
||||
# 确保所有模型都被导入
|
||||
__all__ = [
|
||||
"User",
|
||||
"ChatSession",
|
||||
"ChatMessage",
|
||||
"Document",
|
||||
"DocumentChunk",
|
||||
"KnowledgeBase",
|
||||
"CourseModule",
|
||||
"Book",
|
||||
"Chapter",
|
||||
"Section",
|
||||
"Subsection",
|
||||
"ForumCategory",
|
||||
"ForumPost",
|
||||
"ForumReply",
|
||||
"GeneratedImageRecord",
|
||||
]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
书籍结构数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class Book(Base):
|
||||
"""书籍模型"""
|
||||
__tablename__ = "books"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关联关系
|
||||
chapters = relationship("Chapter", back_populates="book", cascade="all, delete-orphan", order_by="Chapter.display_order")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Book(id={self.id}, title='{self.title}')>"
|
||||
|
||||
|
||||
class Chapter(Base):
|
||||
"""章节模型"""
|
||||
__tablename__ = "chapters"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
book_id = Column(Integer, ForeignKey("books.id", ondelete="CASCADE"), nullable=False)
|
||||
chapter_number = Column(Integer, nullable=False) # 章节编号(1, 2, 3...)
|
||||
title = Column(String(500), nullable=False)
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# LaTeX文件信息(用于动态读取内容)
|
||||
file_path = Column(String(500), nullable=False) # 章节文件路径
|
||||
start_line = Column(Integer, nullable=False) # 起始行号
|
||||
end_line = Column(Integer, nullable=False) # 结束行号
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关联关系
|
||||
book = relationship("Book", back_populates="chapters")
|
||||
sections = relationship("Section", back_populates="chapter", cascade="all, delete-orphan", order_by="Section.display_order")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Chapter(id={self.id}, chapter_number={self.chapter_number}, title='{self.title}')>"
|
||||
|
||||
|
||||
class Section(Base):
|
||||
"""节模型(对应LaTeX的\section)"""
|
||||
__tablename__ = "sections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
chapter_id = Column(Integer, ForeignKey("chapters.id", ondelete="CASCADE"), nullable=False)
|
||||
section_number = Column(Integer, nullable=False) # 节编号
|
||||
title = Column(String(500), nullable=False)
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# LaTeX文件信息(用于动态读取内容)
|
||||
file_path = Column(String(500), nullable=False) # 章节文件路径
|
||||
start_line = Column(Integer, nullable=False) # 起始行号
|
||||
end_line = Column(Integer, nullable=False) # 结束行号
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关联关系
|
||||
chapter = relationship("Chapter", back_populates="sections")
|
||||
subsections = relationship("Subsection", back_populates="section", cascade="all, delete-orphan", order_by="Subsection.display_order")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Section(id={self.id}, section_number={self.section_number}, title='{self.title}')>"
|
||||
|
||||
|
||||
class Subsection(Base):
|
||||
"""小节模型(对应LaTeX的\subsection,作为知识点)"""
|
||||
__tablename__ = "subsections"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
section_id = Column(Integer, ForeignKey("sections.id", ondelete="CASCADE"), nullable=False)
|
||||
subsection_number = Column(Integer, nullable=False) # 小节编号
|
||||
title = Column(String(500), nullable=False)
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# LaTeX文件信息(用于动态读取内容,包含subsubsection的内容)
|
||||
file_path = Column(String(500), nullable=False) # 章节文件路径
|
||||
start_line = Column(Integer, nullable=False) # 起始行号(包含subsection及其下所有subsubsection)
|
||||
end_line = Column(Integer, nullable=False) # 结束行号
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关联关系
|
||||
section = relationship("Section", back_populates="subsections")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subsection(id={self.id}, subsection_number={self.subsection_number}, title='{self.title}')>"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
聊天相关数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class ChatSession(Base):
|
||||
"""聊天会话模型"""
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
title = Column(String(200), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# 关联关系
|
||||
user = relationship("User", back_populates="chat_sessions")
|
||||
messages = relationship("ChatMessage", back_populates="session", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatSession(id={self.id}, user_id={self.user_id}, title='{self.title}')>"
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
"""聊天消息模型"""
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
session_id = Column(Integer, ForeignKey("chat_sessions.id"), nullable=False)
|
||||
role = Column(String(20), nullable=False) # user, assistant, system
|
||||
content = Column(Text, nullable=False)
|
||||
message_metadata = Column(Text, nullable=True) # JSON格式的元数据
|
||||
feedback = Column(String(20), nullable=True) # like, dislike
|
||||
edited = Column(Boolean, default=False) # 是否被编辑过
|
||||
regenerated_from = Column(Integer, nullable=True) # 从哪条消息重新生成
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关联关系
|
||||
session = relationship("ChatSession", back_populates="messages")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatMessage(id={self.id}, session_id={self.session_id}, role='{self.role}')>"
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
课程内容数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text
|
||||
from sqlalchemy.sql import func
|
||||
import json
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class CourseModule(Base):
|
||||
"""课程模块模型"""
|
||||
__tablename__ = "course_modules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
module_name = Column(String(200), nullable=False)
|
||||
core_knowledge_points = Column(Text, nullable=False)
|
||||
digitalization_necessity = Column(Text, nullable=False)
|
||||
expanded_knowledge_points = Column(Text, nullable=False) # JSON格式存储数组
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
def get_expanded_knowledge_points(self) -> list[str]:
|
||||
"""获取展开知识点列表"""
|
||||
if not self.expanded_knowledge_points:
|
||||
return []
|
||||
try:
|
||||
return json.loads(self.expanded_knowledge_points)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
def set_expanded_knowledge_points(self, points: list[str]):
|
||||
"""设置展开知识点列表"""
|
||||
self.expanded_knowledge_points = json.dumps(points, ensure_ascii=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CourseModule(id={self.id}, module_name='{self.module_name}', display_order={self.display_order})>"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
文档数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Boolean, Float
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class Document(Base):
|
||||
"""文档模型"""
|
||||
__tablename__ = "documents"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # None表示系统文档
|
||||
knowledge_base_id = Column(Integer, ForeignKey("knowledge_bases.id"), nullable=True) # 所属知识库
|
||||
filename = Column(String(255), nullable=False)
|
||||
original_filename = Column(String(255), nullable=False)
|
||||
file_path = Column(String(500), nullable=False)
|
||||
file_size = Column(Integer, nullable=False)
|
||||
file_type = Column(String(50), nullable=False)
|
||||
title = Column(String(200), nullable=True)
|
||||
description = Column(Text, nullable=True)
|
||||
is_processed = Column(Boolean, default=False)
|
||||
is_public = Column(Boolean, default=False)
|
||||
|
||||
# 新增字段:知识库管理
|
||||
source_type = Column(String(50), default="upload") # upload, knowledge_base
|
||||
last_modified = Column(DateTime(timezone=True), nullable=True) # 文件最后修改时间
|
||||
file_hash = Column(String(64), nullable=True) # 文件内容哈希
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关联关系
|
||||
user = relationship("User", back_populates="documents")
|
||||
knowledge_base = relationship("KnowledgeBase", back_populates="documents")
|
||||
chunks = relationship("DocumentChunk", back_populates="document", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Document(id={self.id}, filename='{self.filename}', title='{self.title}')>"
|
||||
|
||||
|
||||
class DocumentChunk(Base):
|
||||
"""文档分块模型"""
|
||||
__tablename__ = "document_chunks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
|
||||
chunk_index = Column(Integer, nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
content_hash = Column(String(64), nullable=False) # 内容哈希
|
||||
embedding_vector = Column(Text, nullable=True) # 向量嵌入(JSON格式)
|
||||
chunk_metadata = Column(Text, nullable=True) # 分块元数据(JSON格式)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# 关联关系
|
||||
document = relationship("Document", back_populates="chunks")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DocumentChunk(id={self.id}, document_id={self.document_id}, chunk_index={self.chunk_index})>"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
论坛相关模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class ForumCategory(Base):
|
||||
__tablename__ = "forum_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
slug = Column(String(50), unique=True, nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
posts = relationship("ForumPost", back_populates="category", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class ForumPost(Base):
|
||||
__tablename__ = "forum_posts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
category_id = Column(Integer, ForeignKey("forum_categories.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
title = Column(String(200), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
category = relationship("ForumCategory", back_populates="posts")
|
||||
replies = relationship("ForumReply", back_populates="post", cascade="all, delete-orphan")
|
||||
author = relationship("User", back_populates="forum_posts")
|
||||
|
||||
|
||||
class ForumReply(Base):
|
||||
__tablename__ = "forum_replies"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
post_id = Column(Integer, ForeignKey("forum_posts.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
post = relationship("ForumPost", back_populates="replies")
|
||||
author = relationship("User", back_populates="forum_replies")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
图像生成记录数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class GeneratedImageRecord(Base):
|
||||
"""图像生成记录"""
|
||||
__tablename__ = "generated_images"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
image_id = Column(String(36), nullable=False, unique=True) # UUID
|
||||
image_type = Column(String(30), nullable=False) # text_to_image, image_edit, variation, style_transfer, optimize, outpaint
|
||||
prompt = Column(Text, nullable=True)
|
||||
model = Column(String(50), nullable=True)
|
||||
style = Column(String(50), nullable=True)
|
||||
size = Column(String(20), nullable=True)
|
||||
template = Column(String(50), nullable=True)
|
||||
image_url = Column(String(500), nullable=False)
|
||||
original_filename = Column(String(255), nullable=True) # 图生图的原始文件名
|
||||
mode = Column(String(50), nullable=True) # 编辑模式
|
||||
strength = Column(String(10), nullable=True)
|
||||
generation_meta = Column(Text, nullable=True) # JSON格式的额外元数据
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user = relationship("User", back_populates="generated_images")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<GeneratedImageRecord(id={self.id}, image_type='{self.image_type}')>"
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
图像生成相关的 Pydantic 模型
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TextToImageRequest(BaseModel):
|
||||
"""文生图请求模型"""
|
||||
prompt: str = Field(..., description="图像描述提示词", min_length=1, max_length=1000)
|
||||
model: str = Field(default="kolors", description="使用的模型", pattern="^(kolors|qwen)$")
|
||||
template: str = Field(default="custom", description="提示词模板")
|
||||
style: str = Field(default="realistic", description="图像风格")
|
||||
size: str = Field(default="1024x1024", description="图像尺寸")
|
||||
num_images: int = Field(default=1, description="生成图像数量", ge=1, le=4)
|
||||
|
||||
|
||||
class ImageEditRequest(BaseModel):
|
||||
"""图像编辑请求模型"""
|
||||
prompt: str = Field(..., description="编辑描述", min_length=1, max_length=500)
|
||||
mode: str = Field(default="optimize", description="编辑模式")
|
||||
strength: float = Field(default=0.8, description="编辑强度", ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class ImageVariationRequest(BaseModel):
|
||||
"""图像变体生成请求模型"""
|
||||
num_variations: int = Field(default=3, description="变体数量", ge=1, le=6)
|
||||
|
||||
|
||||
class GeneratedImage(BaseModel):
|
||||
"""生成的图像模型"""
|
||||
id: str = Field(..., description="图像唯一ID")
|
||||
url: str = Field(..., description="图像访问URL")
|
||||
prompt: str = Field(..., description="使用的提示词")
|
||||
model: str = Field(..., description="使用的模型")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据")
|
||||
|
||||
|
||||
class ImageEditResult(BaseModel):
|
||||
"""图像编辑结果模型"""
|
||||
id: str = Field(..., description="编辑后图像ID")
|
||||
url: str = Field(..., description="图像访问URL")
|
||||
original_filename: Optional[str] = Field(None, description="原始文件名")
|
||||
edit_prompt: str = Field(..., description="编辑提示词")
|
||||
mode: str = Field(..., description="编辑模式")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据")
|
||||
|
||||
|
||||
class ImageVariationResult(BaseModel):
|
||||
"""图像变体结果模型"""
|
||||
id: str = Field(..., description="变体图像ID")
|
||||
url: str = Field(..., description="图像访问URL")
|
||||
type: str = Field(default="variation", description="图像类型")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据")
|
||||
|
||||
|
||||
class TextToImageResponse(BaseModel):
|
||||
"""文生图响应模型"""
|
||||
images: List[GeneratedImage] = Field(..., description="生成的图像列表")
|
||||
total: int = Field(..., description="图像总数")
|
||||
|
||||
|
||||
class ImageEditResponse(BaseModel):
|
||||
"""图像编辑响应模型"""
|
||||
result: ImageEditResult = Field(..., description="编辑结果")
|
||||
|
||||
|
||||
class ImageVariationResponse(BaseModel):
|
||||
"""图像变体响应模型"""
|
||||
variations: List[ImageVariationResult] = Field(..., description="变体图像列表")
|
||||
total: int = Field(..., description="变体总数")
|
||||
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
"""模型信息"""
|
||||
id: str = Field(..., description="模型ID")
|
||||
name: str = Field(..., description="模型名称")
|
||||
description: str = Field(..., description="模型描述")
|
||||
|
||||
|
||||
class TemplateInfo(BaseModel):
|
||||
"""模板信息"""
|
||||
id: str = Field(..., description="模板ID")
|
||||
name: str = Field(..., description="模板名称")
|
||||
description: str = Field(..., description="模板描述")
|
||||
|
||||
|
||||
class StyleInfo(BaseModel):
|
||||
"""风格信息"""
|
||||
id: str = Field(..., description="风格ID")
|
||||
name: str = Field(..., description="风格名称")
|
||||
description: str = Field(..., description="风格描述")
|
||||
|
||||
|
||||
class SizeInfo(BaseModel):
|
||||
"""尺寸信息"""
|
||||
id: str = Field(..., description="尺寸ID")
|
||||
name: str = Field(..., description="尺寸名称")
|
||||
description: str = Field(..., description="尺寸描述")
|
||||
|
||||
|
||||
class EditModeInfo(BaseModel):
|
||||
"""编辑模式信息"""
|
||||
id: str = Field(..., description="模式ID")
|
||||
name: str = Field(..., description="模式名称")
|
||||
description: str = Field(..., description="模式描述")
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
知识库数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text, ForeignKey, Boolean
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class KnowledgeBase(Base):
|
||||
"""知识库模型"""
|
||||
__tablename__ = "knowledge_bases"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
is_system = Column(Boolean, default=False) # 是否为系统知识库
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# 关联关系
|
||||
user = relationship("User", back_populates="knowledge_bases")
|
||||
documents = relationship("Document", back_populates="knowledge_base", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<KnowledgeBase(id={self.id}, name='{self.name}', user_id={self.user_id})>"
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
用户数据模型
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from ..core.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户模型"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
email = Column(String(100), unique=True, index=True, nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
full_name = Column(String(100), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
is_superuser = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
last_login = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# 关联关系
|
||||
chat_sessions = relationship("ChatSession", back_populates="user")
|
||||
documents = relationship("Document", back_populates="user")
|
||||
knowledge_bases = relationship("KnowledgeBase", back_populates="user")
|
||||
forum_posts = relationship("ForumPost", back_populates="author")
|
||||
forum_replies = relationship("ForumReply", back_populates="author")
|
||||
generated_images = relationship("GeneratedImageRecord", back_populates="user")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, username='{self.username}', email='{self.email}')>"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# RAG系统模块
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
RAG检索链(LangChain 1.0)
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any
|
||||
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
|
||||
from .prompts import create_rag_prompt
|
||||
from .retrievers import KnowledgeBaseRetriever
|
||||
from .vector_store import get_vector_store
|
||||
from ..llm.siliconflow import get_llm_client
|
||||
|
||||
class RAGChain:
|
||||
"""RAG问答链(LangChain 1.0标准API)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
knowledge_base_ids: Optional[List[int]] = None,
|
||||
search_type: str = "similarity",
|
||||
k: int = 5,
|
||||
score_threshold: float = 0.1,
|
||||
model: Optional[str] = None
|
||||
):
|
||||
"""初始化RAG链
|
||||
|
||||
Args:
|
||||
knowledge_base_ids: 知识库ID列表
|
||||
search_type: 搜索类型
|
||||
k: 返回文档数量
|
||||
score_threshold: 相似度阈值
|
||||
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||
"""
|
||||
print(f"[DEBUG-RAGChain] 初始化,knowledge_base_ids: {knowledge_base_ids}, model: {model}")
|
||||
self.llm = get_llm_client(model=model).llm
|
||||
self.vector_store = get_vector_store()
|
||||
self.knowledge_base_ids = knowledge_base_ids
|
||||
|
||||
# 创建自定义检索器
|
||||
self.retriever = KnowledgeBaseRetriever(
|
||||
vectorstore=self.vector_store.vectorstore,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
search_type=search_type,
|
||||
search_kwargs={"k": k},
|
||||
score_threshold=score_threshold
|
||||
)
|
||||
print(f"[DEBUG-RAGChain] 检索器创建完成,knowledge_base_ids: {self.retriever.knowledge_base_ids}")
|
||||
|
||||
# 创建Prompt
|
||||
self.prompt = create_rag_prompt()
|
||||
|
||||
# 创建RAG链
|
||||
self.chain = self._create_chain()
|
||||
|
||||
def _create_chain(self):
|
||||
"""创建检索链(LangChain 1.0 Runnable API)"""
|
||||
# 使用LangChain 1.0的runnable API构建RAG链
|
||||
def format_docs(docs):
|
||||
"""格式化文档"""
|
||||
return "\n\n".join(doc.page_content for doc in docs)
|
||||
|
||||
# 构建RAG链 - 只检索一次,返回完整结果
|
||||
def rag_with_sources(input_data):
|
||||
"""RAG处理函数,只检索一次"""
|
||||
question = input_data
|
||||
# 只检索一次
|
||||
docs = self.retriever.invoke(question)
|
||||
context = format_docs(docs)
|
||||
|
||||
# 生成答案
|
||||
messages = self.prompt.invoke({"context": context, "question": question})
|
||||
answer = self.llm.invoke(messages).content
|
||||
|
||||
return {
|
||||
"answer": answer,
|
||||
"source_documents": docs,
|
||||
"context": context,
|
||||
"question": question
|
||||
}
|
||||
|
||||
rag_chain = RunnableLambda(rag_with_sources)
|
||||
return rag_chain
|
||||
|
||||
def invoke(self, question: str) -> Dict[str, Any]:
|
||||
"""同步调用"""
|
||||
# 只调用一次,获取完整结果
|
||||
result = self.chain.invoke(question)
|
||||
|
||||
return {
|
||||
"answer": result["answer"],
|
||||
"sources": self._format_sources(result["source_documents"]),
|
||||
"metadata": {
|
||||
"has_context": bool(result["source_documents"]),
|
||||
"source_count": len(result["source_documents"])
|
||||
}
|
||||
}
|
||||
|
||||
async def ainvoke(self, question: str) -> Dict[str, Any]:
|
||||
"""异步调用"""
|
||||
# 只调用一次,获取完整结果
|
||||
result = await self.chain.ainvoke(question)
|
||||
|
||||
return {
|
||||
"answer": result["answer"],
|
||||
"sources": self._format_sources(result["source_documents"]),
|
||||
"metadata": {
|
||||
"has_context": bool(result["source_documents"]),
|
||||
"source_count": len(result["source_documents"])
|
||||
}
|
||||
}
|
||||
|
||||
async def astream(self, question: str):
|
||||
"""流式调用(只流式输出答案)"""
|
||||
# 先获取文档(这是唯一一次检索)
|
||||
docs = await self.retriever.ainvoke(question)
|
||||
context = "\n\n".join(doc.page_content for doc in docs)
|
||||
|
||||
# 构建prompt
|
||||
messages = await self.prompt.ainvoke({"context": context, "question": question})
|
||||
|
||||
# 流式生成答案
|
||||
async for chunk in self.llm.astream(messages):
|
||||
if isinstance(chunk, str):
|
||||
yield chunk
|
||||
else:
|
||||
# 处理AIMessageChunk
|
||||
yield chunk.content if hasattr(chunk, 'content') else str(chunk)
|
||||
|
||||
async def astream_with_sources(self, question: str):
|
||||
"""流式调用(返回答案流和文档,包含思考过程)"""
|
||||
import time
|
||||
|
||||
# 0. 思考阶段开始
|
||||
start_time = time.time()
|
||||
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
||||
|
||||
# 1. 检索文档
|
||||
yield {"type": "thinking", "stage": "retrieving", "message": "正在检索相关知识..."}
|
||||
retrieval_start = time.time()
|
||||
docs = await self.retriever.ainvoke(question)
|
||||
retrieval_time = time.time() - retrieval_start
|
||||
|
||||
# 发送检索结果
|
||||
yield {
|
||||
"type": "thinking",
|
||||
"stage": "retrieved",
|
||||
"message": f"找到 {len(docs)} 条相关文档",
|
||||
"doc_count": len(docs),
|
||||
"time": round(retrieval_time, 2)
|
||||
}
|
||||
|
||||
context = "\n\n".join(doc.page_content for doc in docs)
|
||||
|
||||
# 2. 构建prompt
|
||||
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
||||
messages = await self.prompt.ainvoke({"context": context, "question": question})
|
||||
|
||||
# 3. 流式生成答案
|
||||
answer_chunks = []
|
||||
async for chunk in self.llm.astream(messages):
|
||||
content = chunk.content if hasattr(chunk, 'content') else str(chunk)
|
||||
answer_chunks.append(content)
|
||||
yield {"type": "chunk", "content": content}
|
||||
|
||||
# 4. 完成,返回sources
|
||||
total_time = time.time() - start_time
|
||||
yield {
|
||||
"type": "sources",
|
||||
"sources": self._format_sources(docs),
|
||||
"answer": "".join(answer_chunks),
|
||||
"metadata": {
|
||||
"total_time": round(total_time, 2),
|
||||
"retrieval_time": round(retrieval_time, 2)
|
||||
}
|
||||
}
|
||||
|
||||
def _format_sources(self, documents: List) -> List[Dict]:
|
||||
"""格式化来源信息"""
|
||||
sources = []
|
||||
for doc in documents:
|
||||
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||
sources.append({
|
||||
"title": metadata.get("title", "未知标题"),
|
||||
"filename": metadata.get("filename", "未知文件"),
|
||||
"page": metadata.get("chunk_index", 0),
|
||||
"preview": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content
|
||||
})
|
||||
return sources
|
||||
|
||||
def create_rag_chain(
|
||||
knowledge_base_ids: Optional[List[int]] = None,
|
||||
search_type: str = "similarity",
|
||||
k: int = 5,
|
||||
model: Optional[str] = None
|
||||
) -> RAGChain:
|
||||
"""创建RAG链实例
|
||||
|
||||
Args:
|
||||
knowledge_base_ids: 知识库ID列表
|
||||
search_type: 搜索类型
|
||||
k: 返回文档数量
|
||||
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||
"""
|
||||
return RAGChain(
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
search_type=search_type,
|
||||
k=k,
|
||||
model=model
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
LangChain 1.0 对话链(Normal Mode)
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional
|
||||
from langchain_core.runnables import RunnablePassthrough
|
||||
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
||||
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
||||
from langchain_core.output_parsers import StrOutputParser
|
||||
|
||||
from ..llm.siliconflow import get_llm_client
|
||||
|
||||
|
||||
class ConversationChain:
|
||||
"""标准对话链(LangChain 1.0)"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
system_prompt: Optional[str] = None,
|
||||
model: Optional[str] = None
|
||||
):
|
||||
"""初始化对话链
|
||||
|
||||
Args:
|
||||
system_prompt: 系统提示词
|
||||
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||
"""
|
||||
self.llm = get_llm_client(model=model).llm
|
||||
self.system_prompt = system_prompt or "你是一个专业的国土空间规划知识问答助手。请基于你的知识回答用户的问题。"
|
||||
|
||||
# 创建带历史的Prompt模板
|
||||
self.prompt = ChatPromptTemplate.from_messages([
|
||||
SystemMessage(content=self.system_prompt),
|
||||
MessagesPlaceholder(variable_name="chat_history"),
|
||||
("human", "{question}")
|
||||
])
|
||||
|
||||
# 创建对话链
|
||||
self.chain = self._create_chain()
|
||||
|
||||
def _create_chain(self):
|
||||
"""创建对话链"""
|
||||
return (
|
||||
self.prompt
|
||||
| self.llm
|
||||
| StrOutputParser()
|
||||
)
|
||||
|
||||
def invoke(self, question: str, chat_history: List[Dict] = None) -> Dict[str, Any]:
|
||||
"""同步调用"""
|
||||
history_messages = self._format_history(chat_history or [])
|
||||
answer = self.chain.invoke({
|
||||
"question": question,
|
||||
"chat_history": history_messages
|
||||
})
|
||||
|
||||
return {
|
||||
"answer": answer,
|
||||
"sources": [],
|
||||
"metadata": {
|
||||
"mode": "normal",
|
||||
"has_history": bool(chat_history)
|
||||
}
|
||||
}
|
||||
|
||||
async def ainvoke(self, question: str, chat_history: List[Dict] = None) -> Dict[str, Any]:
|
||||
"""异步调用"""
|
||||
history_messages = self._format_history(chat_history or [])
|
||||
answer = await self.chain.ainvoke({
|
||||
"question": question,
|
||||
"chat_history": history_messages
|
||||
})
|
||||
|
||||
return {
|
||||
"answer": answer,
|
||||
"sources": [],
|
||||
"metadata": {
|
||||
"mode": "normal",
|
||||
"has_history": bool(chat_history)
|
||||
}
|
||||
}
|
||||
|
||||
async def astream_with_thinking(self, question: str, chat_history: List[Dict] = None):
|
||||
"""流式调用(包含思考过程)"""
|
||||
import time
|
||||
|
||||
# 思考阶段
|
||||
start_time = time.time()
|
||||
yield {"type": "thinking", "stage": "understanding", "message": "正在理解问题..."}
|
||||
|
||||
# 准备历史
|
||||
history_messages = self._format_history(chat_history or [])
|
||||
|
||||
yield {"type": "thinking", "stage": "generating", "message": "正在生成回答..."}
|
||||
|
||||
# 流式生成
|
||||
async for chunk in self.chain.astream({
|
||||
"question": question,
|
||||
"chat_history": history_messages
|
||||
}):
|
||||
yield {"type": "chunk", "content": chunk}
|
||||
|
||||
# 完成
|
||||
total_time = time.time() - start_time
|
||||
yield {
|
||||
"type": "complete",
|
||||
"metadata": {
|
||||
"total_time": round(total_time, 2)
|
||||
}
|
||||
}
|
||||
|
||||
def _format_history(self, chat_history: List[Dict]) -> List:
|
||||
"""格式化聊天历史为LangChain消息格式"""
|
||||
messages = []
|
||||
for msg in chat_history:
|
||||
if msg["role"] == "user":
|
||||
messages.append(HumanMessage(content=msg["content"]))
|
||||
elif msg["role"] == "assistant":
|
||||
messages.append(AIMessage(content=msg["content"]))
|
||||
return messages
|
||||
|
||||
|
||||
def create_conversation_chain(system_prompt: Optional[str] = None, model: Optional[str] = None) -> ConversationChain:
|
||||
"""创建对话链实例
|
||||
|
||||
Args:
|
||||
system_prompt: 系统提示词
|
||||
model: 可选的模型名称,如 deepseek-ai/DeepSeek-V3, Qwen/QwQ-32B
|
||||
"""
|
||||
return ConversationChain(system_prompt=system_prompt, model=model)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
LangChain 1.0 文档加载器封装
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from pathlib import Path
|
||||
from langchain_community.document_loaders import (
|
||||
PyPDFLoader,
|
||||
Docx2txtLoader,
|
||||
TextLoader,
|
||||
UnstructuredMarkdownLoader
|
||||
)
|
||||
from langchain_core.documents import Document
|
||||
|
||||
class DocumentLoaderFactory:
|
||||
"""文档加载器工厂"""
|
||||
|
||||
@staticmethod
|
||||
def get_loader(file_path: str, file_type: str):
|
||||
"""根据文件类型获取对应的加载器"""
|
||||
loaders = {
|
||||
".pdf": PyPDFLoader,
|
||||
".docx": Docx2txtLoader,
|
||||
".txt": TextLoader,
|
||||
".md": UnstructuredMarkdownLoader,
|
||||
}
|
||||
|
||||
loader_class = loaders.get(file_type)
|
||||
if not loader_class:
|
||||
raise ValueError(f"Unsupported file type: {file_type}")
|
||||
|
||||
return loader_class(file_path)
|
||||
|
||||
@staticmethod
|
||||
def load_document(file_path: str, file_type: str, metadata: Optional[dict] = None) -> List[Document]:
|
||||
"""加载文档并添加元数据"""
|
||||
loader = DocumentLoaderFactory.get_loader(file_path, file_type)
|
||||
documents = loader.load()
|
||||
|
||||
if metadata:
|
||||
for doc in documents:
|
||||
doc.metadata.update(metadata)
|
||||
|
||||
return documents
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
嵌入模型管理
|
||||
"""
|
||||
from typing import List
|
||||
from sentence_transformers import SentenceTransformer
|
||||
import numpy as np
|
||||
|
||||
from ..core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class EmbeddingModel:
|
||||
"""嵌入模型管理器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化嵌入模型"""
|
||||
self.model_name = settings.embedding_model
|
||||
self.model = None
|
||||
self._load_model()
|
||||
|
||||
def _load_model(self):
|
||||
"""加载嵌入模型"""
|
||||
try:
|
||||
self.model = SentenceTransformer(self.model_name)
|
||||
print(f"嵌入模型 {self.model_name} 加载成功")
|
||||
except Exception as e:
|
||||
raise Exception(f"嵌入模型加载失败: {str(e)}")
|
||||
|
||||
def encode(self, texts: List[str]) -> np.ndarray:
|
||||
"""编码文本为向量"""
|
||||
try:
|
||||
embeddings = self.model.encode(texts)
|
||||
return embeddings
|
||||
except Exception as e:
|
||||
raise Exception(f"文本编码失败: {str(e)}")
|
||||
|
||||
def encode_single(self, text: str) -> np.ndarray:
|
||||
"""编码单个文本"""
|
||||
return self.encode([text])[0]
|
||||
|
||||
def embed_query(self, text: str) -> List[float]:
|
||||
"""LangChain兼容的查询嵌入方法"""
|
||||
return self.encode_single(text).tolist()
|
||||
|
||||
def embed_documents(self, texts: List[str]) -> List[List[float]]:
|
||||
"""LangChain兼容的文档嵌入方法"""
|
||||
return self.encode(texts).tolist()
|
||||
|
||||
def get_embedding_dimension(self) -> int:
|
||||
"""获取嵌入维度"""
|
||||
if self.model is None:
|
||||
return 0
|
||||
return self.model.get_sentence_embedding_dimension()
|
||||
|
||||
|
||||
# 全局嵌入模型实例
|
||||
embedding_model = EmbeddingModel()
|
||||
|
||||
|
||||
def get_embedding_model() -> EmbeddingModel:
|
||||
"""获取嵌入模型实例"""
|
||||
return embedding_model
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
RAG系统的Prompt模板(LangChain 1.0)
|
||||
"""
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.messages import SystemMessage, HumanMessage
|
||||
|
||||
# RAG系统提示词
|
||||
RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手。请基于以下上下文信息回答用户的问题。
|
||||
|
||||
上下文信息:
|
||||
{context}
|
||||
|
||||
要求:
|
||||
1. 回答要准确、专业、详细
|
||||
2. 如果上下文中没有相关信息,请诚实说明
|
||||
3. 回答要结构清晰,逻辑性强
|
||||
4. 适当引用相关概念和术语
|
||||
5. 回答长度控制在500-1000字之间
|
||||
|
||||
请基于上述上下文信息回答用户的问题。"""
|
||||
|
||||
def create_rag_prompt() -> ChatPromptTemplate:
|
||||
"""创建RAG聊天Prompt模板"""
|
||||
return ChatPromptTemplate.from_messages([
|
||||
("system", RAG_SYSTEM_PROMPT),
|
||||
("human", "{question}")
|
||||
])
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
文档检索器
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional
|
||||
from .vector_store import get_vector_store
|
||||
from .embeddings import get_embedding_model
|
||||
|
||||
|
||||
class DocumentRetriever:
|
||||
"""文档检索器"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化检索器"""
|
||||
self.vector_store = get_vector_store()
|
||||
self.embedding_model = get_embedding_model()
|
||||
|
||||
def retrieve(
|
||||
self,
|
||||
query: str,
|
||||
top_k: int = 5,
|
||||
score_threshold: float = 0.7,
|
||||
filter_metadata: Optional[Dict] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""检索相关文档"""
|
||||
try:
|
||||
print(f"[DEBUG-RETRIEVER] 开始检索 - query: {query[:50]}..., filter_metadata: {filter_metadata}")
|
||||
|
||||
# 执行向量搜索
|
||||
results = self.vector_store.search(
|
||||
query=query,
|
||||
n_results=top_k,
|
||||
filter_metadata=filter_metadata
|
||||
)
|
||||
print(f"[DEBUG-RETRIEVER] 向量搜索返回结果数量: {len(results)}")
|
||||
|
||||
# 过滤低分结果
|
||||
filtered_results = [
|
||||
result for result in results
|
||||
if result.get("distance", 1.0) <= (1 - score_threshold)
|
||||
]
|
||||
print(f"[DEBUG-RETRIEVER] 过滤后结果数量: {len(filtered_results)}")
|
||||
|
||||
return filtered_results
|
||||
|
||||
except Exception as e:
|
||||
print(f"文档检索失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def retrieve_by_document_id(self, document_id: str) -> List[Dict[str, Any]]:
|
||||
"""根据文档ID检索"""
|
||||
try:
|
||||
results = self.vector_store.search(
|
||||
query="", # 空查询
|
||||
n_results=1000, # 获取大量结果
|
||||
filter_metadata={"document_id": document_id}
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"按文档ID检索失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_relevant_context(self, query: str, max_length: int = 2000) -> str:
|
||||
"""获取相关上下文"""
|
||||
results = self.retrieve(query, top_k=5)
|
||||
|
||||
if not results:
|
||||
return ""
|
||||
|
||||
# 按相关性排序并拼接内容
|
||||
context_parts = []
|
||||
current_length = 0
|
||||
|
||||
for result in results:
|
||||
content = result.get("content", "")
|
||||
if current_length + len(content) > max_length:
|
||||
break
|
||||
|
||||
context_parts.append(content)
|
||||
current_length += len(content)
|
||||
|
||||
return "\n\n".join(context_parts)
|
||||
|
||||
def get_sources_info(self, query: str, top_k: int = 5, filter_metadata: Optional[Dict] = None) -> List[Dict[str, Any]]:
|
||||
"""获取来源信息"""
|
||||
results = self.retrieve(query, top_k=top_k, filter_metadata=filter_metadata)
|
||||
|
||||
sources = []
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
source_info = {
|
||||
"title": metadata.get("title", "未知标题"),
|
||||
"filename": metadata.get("filename", "未知文件"),
|
||||
"page": metadata.get("page", 0),
|
||||
"score": 1 - result.get("distance", 1.0), # 转换为相似度分数
|
||||
"preview": result.get("content", "")[:200] + "..." if len(result.get("content", "")) > 200 else result.get("content", "")
|
||||
}
|
||||
sources.append(source_info)
|
||||
|
||||
return sources
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
自定义知识库检索器(LangChain 1.0)
|
||||
"""
|
||||
from typing import List, Optional
|
||||
import math
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.callbacks.manager import CallbackManagerForRetrieverRun
|
||||
from langchain_core.retrievers import BaseRetriever
|
||||
|
||||
class KnowledgeBaseRetriever(BaseRetriever):
|
||||
"""支持知识库过滤和智能分数转换的检索器"""
|
||||
|
||||
vectorstore: any
|
||||
knowledge_base_ids: Optional[List[int]] = None
|
||||
search_type: str = "similarity"
|
||||
search_kwargs: dict = {"k": 5}
|
||||
score_threshold: float = 0.1
|
||||
|
||||
def _get_relevant_documents(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
run_manager: CallbackManagerForRetrieverRun
|
||||
) -> List[Document]:
|
||||
"""获取相关文档(LangChain 1.0标准接口)"""
|
||||
print(f"[DEBUG-Retriever] 查询: {query}")
|
||||
print(f"[DEBUG-Retriever] knowledge_base_ids: {self.knowledge_base_ids}")
|
||||
|
||||
# 构建知识库过滤条件
|
||||
filter_dict = None
|
||||
if self.knowledge_base_ids:
|
||||
filter_dict = {"knowledge_base_id": {"$in": self.knowledge_base_ids}}
|
||||
print(f"[DEBUG-Retriever] 构建的过滤条件: {filter_dict}")
|
||||
else:
|
||||
print(f"[DEBUG-Retriever] 没有知识库ID,不进行过滤")
|
||||
|
||||
# 执行搜索
|
||||
if self.search_type == "similarity":
|
||||
docs_and_scores = self.vectorstore.similarity_search_with_score(
|
||||
query=query,
|
||||
k=self.search_kwargs.get("k", 5),
|
||||
filter=filter_dict
|
||||
)
|
||||
print(f"[DEBUG-Retriever] 搜索返回文档数量: {len(docs_and_scores)}")
|
||||
|
||||
# 打印每个文档的知识库ID
|
||||
for i, (doc, distance) in enumerate(docs_and_scores):
|
||||
kb_id = doc.metadata.get("knowledge_base_id", "未知")
|
||||
print(f"[DEBUG-Retriever] 文档{i+1}: knowledge_base_id={kb_id}, distance={distance:.4f}")
|
||||
|
||||
# 转换距离为分数并过滤
|
||||
filtered_docs = []
|
||||
for doc, distance in docs_and_scores:
|
||||
score = self._convert_distance_to_score(distance)
|
||||
if score > self.score_threshold:
|
||||
filtered_docs.append(doc)
|
||||
|
||||
print(f"[DEBUG-Retriever] 过滤后文档数量: {len(filtered_docs)}")
|
||||
return filtered_docs
|
||||
|
||||
elif self.search_type == "mmr":
|
||||
return self.vectorstore.max_marginal_relevance_search(
|
||||
query=query,
|
||||
k=self.search_kwargs.get("k", 5),
|
||||
fetch_k=self.search_kwargs.get("fetch_k", 20),
|
||||
filter=filter_dict
|
||||
)
|
||||
|
||||
return []
|
||||
|
||||
def _convert_distance_to_score(self, distance: float) -> float:
|
||||
"""将ChromaDB的distance转换为0-1范围的相似度分数"""
|
||||
# 内积距离(负值)
|
||||
if distance < 0:
|
||||
return (1 + distance) / 2
|
||||
|
||||
# 大距离使用对数缩放
|
||||
if distance > 100:
|
||||
return 1 / (1 + math.log(distance))
|
||||
|
||||
# 标准距离转换
|
||||
return 1 / (1 + distance)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
LangChain 1.0 文本分割器配置
|
||||
"""
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
from typing import List
|
||||
from langchain_core.documents import Document
|
||||
|
||||
class ChineseTextSplitter:
|
||||
"""中文文本分割器"""
|
||||
|
||||
def __init__(self, chunk_size: int = 1000, chunk_overlap: int = 200):
|
||||
# 针对中文优化的分隔符顺序
|
||||
self.splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
separators=[
|
||||
"\n\n", # 段落
|
||||
"\n", # 行
|
||||
"。", # 句号
|
||||
"!", # 感叹号
|
||||
"?", # 问号
|
||||
";", # 分号
|
||||
",", # 逗号
|
||||
" ", # 空格
|
||||
"", # 字符
|
||||
],
|
||||
length_function=len,
|
||||
is_separator_regex=False,
|
||||
)
|
||||
|
||||
def split_documents(self, documents: List[Document]) -> List[Document]:
|
||||
"""分割文档"""
|
||||
return self.splitter.split_documents(documents)
|
||||
|
||||
def split_text(self, text: str) -> List[str]:
|
||||
"""分割文本"""
|
||||
return self.splitter.split_text(text)
|
||||
|
||||
def get_text_splitter(chunk_size: int = 1000, chunk_overlap: int = 200) -> ChineseTextSplitter:
|
||||
"""获取文本分割器实例"""
|
||||
return ChineseTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
LangChain 1.0 向量存储封装
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from langchain_chroma import Chroma
|
||||
from langchain_core.documents import Document as LangChainDocument
|
||||
|
||||
from ..core.config import get_settings
|
||||
from .embeddings import get_embedding_model
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class VectorStore:
|
||||
"""向量存储管理器(LangChain 1.0 Chroma)"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化向量存储"""
|
||||
self.vector_store_path = Path(settings.vector_store_path)
|
||||
self.vector_store_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 获取嵌入模型(保留自定义实现)
|
||||
self.embedding_model = get_embedding_model()
|
||||
|
||||
# 使用LangChain Chroma wrapper
|
||||
self.vectorstore = Chroma(
|
||||
collection_name="course_knowledge",
|
||||
embedding_function=self.embedding_model,
|
||||
persist_directory=str(self.vector_store_path),
|
||||
collection_metadata={"hnsw:space": "l2"} # 保持L2距离度量
|
||||
)
|
||||
|
||||
def add_documents(self, documents: List[LangChainDocument]) -> bool:
|
||||
"""添加LangChain文档到向量存储"""
|
||||
try:
|
||||
self.vectorstore.add_documents(documents)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"添加文档失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def as_retriever(self, **kwargs):
|
||||
"""返回标准LangChain检索器"""
|
||||
return self.vectorstore.as_retriever(**kwargs)
|
||||
|
||||
def similarity_search_with_score(
|
||||
self,
|
||||
query: str,
|
||||
k: int = 5,
|
||||
filter: Optional[Dict] = None
|
||||
):
|
||||
"""相似度搜索(带分数)"""
|
||||
print(f"[DEBUG-VectorStore] 查询参数 - k: {k}, filter: {filter}")
|
||||
|
||||
result = self.vectorstore.similarity_search_with_score(
|
||||
query=query,
|
||||
k=k,
|
||||
filter=filter
|
||||
)
|
||||
|
||||
print(f"[DEBUG-VectorStore] 返回结果数量: {len(result)}")
|
||||
return result
|
||||
|
||||
def max_marginal_relevance_search(
|
||||
self,
|
||||
query: str,
|
||||
k: int = 5,
|
||||
fetch_k: int = 20,
|
||||
filter: Optional[Dict] = None
|
||||
):
|
||||
"""MMR搜索(多样性检索)"""
|
||||
return self.vectorstore.max_marginal_relevance_search(
|
||||
query=query,
|
||||
k=k,
|
||||
fetch_k=fetch_k,
|
||||
filter=filter
|
||||
)
|
||||
|
||||
|
||||
# 单例模式
|
||||
_vector_store_instance = None
|
||||
|
||||
def get_vector_store() -> VectorStore:
|
||||
"""获取向量存储实例"""
|
||||
global _vector_store_instance
|
||||
if _vector_store_instance is None:
|
||||
_vector_store_instance = VectorStore()
|
||||
return _vector_store_instance
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# 业务逻辑服务模块
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
学习分析服务
|
||||
提供用户学习数据的统计和分析功能
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, distinct, and_
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from ..models.user import User
|
||||
from ..models.chat import ChatSession, ChatMessage
|
||||
from ..models.document import Document
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
"""学习分析服务"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_user_statistics(self, user_id: int) -> Dict[str, Any]:
|
||||
"""获取用户统计数据"""
|
||||
try:
|
||||
# 获取用户信息
|
||||
user = self.db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise ValueError(f"用户 {user_id} 不存在")
|
||||
|
||||
# 总会话数
|
||||
total_sessions = self.db.query(ChatSession).filter(
|
||||
ChatSession.user_id == user_id
|
||||
).count()
|
||||
|
||||
# 总消息数(只统计用户发送的消息)
|
||||
total_messages = self.db.query(ChatMessage).join(ChatSession).filter(
|
||||
and_(
|
||||
ChatSession.user_id == user_id,
|
||||
ChatMessage.role == "user"
|
||||
)
|
||||
).count()
|
||||
|
||||
# 总文档数
|
||||
total_documents = self.db.query(Document).filter(
|
||||
Document.user_id == user_id
|
||||
).count()
|
||||
|
||||
# 活跃天数(基于会话创建日期)
|
||||
active_days = self.db.query(
|
||||
distinct(func.date(ChatSession.created_at))
|
||||
).filter(
|
||||
ChatSession.user_id == user_id
|
||||
).count()
|
||||
|
||||
# 用户注册时间
|
||||
user_since = user.created_at.isoformat() if user.created_at else None
|
||||
|
||||
return {
|
||||
"total_sessions": total_sessions,
|
||||
"total_messages": total_messages,
|
||||
"total_documents": total_documents,
|
||||
"active_days": active_days,
|
||||
"user_since": user_since,
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取用户统计数据失败: {str(e)}")
|
||||
raise Exception(f"获取统计数据失败: {str(e)}")
|
||||
|
||||
def get_user_learning_trends(self, user_id: int, days: int = 30) -> List[Dict[str, Any]]:
|
||||
"""获取用户学习趋势数据"""
|
||||
try:
|
||||
# 计算开始日期
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
# 按日期统计消息和会话数量
|
||||
trends_query = self.db.query(
|
||||
func.date(ChatSession.created_at).label('date'),
|
||||
func.count(distinct(ChatSession.id)).label('sessions'),
|
||||
func.count(ChatMessage.id).label('messages')
|
||||
).join(
|
||||
ChatMessage, ChatSession.id == ChatMessage.session_id
|
||||
).filter(
|
||||
and_(
|
||||
ChatSession.user_id == user_id,
|
||||
ChatSession.created_at >= start_date,
|
||||
ChatMessage.role == "user" # 只统计用户消息
|
||||
)
|
||||
).group_by(
|
||||
func.date(ChatSession.created_at)
|
||||
).order_by(
|
||||
func.date(ChatSession.created_at)
|
||||
)
|
||||
|
||||
trends = []
|
||||
for row in trends_query:
|
||||
# 处理日期格式,确保返回字符串
|
||||
date_str = row.date.isoformat() if hasattr(row.date, 'isoformat') else str(row.date)
|
||||
trends.append({
|
||||
"date": date_str,
|
||||
"sessions": row.sessions,
|
||||
"messages": row.messages
|
||||
})
|
||||
|
||||
return trends
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取学习趋势数据失败: {str(e)}")
|
||||
raise Exception(f"获取学习趋势失败: {str(e)}")
|
||||
|
||||
def get_popular_questions(self, user_id: int, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""获取用户热门问题"""
|
||||
try:
|
||||
# 统计用户消息中出现频率最高的问题
|
||||
# 这里简化处理,实际可能需要更复杂的文本分析
|
||||
popular_query = self.db.query(
|
||||
ChatMessage.content.label('question'),
|
||||
func.count(ChatMessage.id).label('count')
|
||||
).join(ChatSession).filter(
|
||||
and_(
|
||||
ChatSession.user_id == user_id,
|
||||
ChatMessage.role == "user",
|
||||
func.length(ChatMessage.content) > 10 # 过滤太短的消息
|
||||
)
|
||||
).group_by(
|
||||
ChatMessage.content
|
||||
).order_by(
|
||||
func.count(ChatMessage.id).desc()
|
||||
).limit(limit)
|
||||
|
||||
popular_questions = []
|
||||
for row in popular_query:
|
||||
popular_questions.append({
|
||||
"question": row.question,
|
||||
"count": row.count,
|
||||
"category": "用户问题" # 简化分类
|
||||
})
|
||||
|
||||
return popular_questions
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取热门问题失败: {str(e)}")
|
||||
raise Exception(f"获取热门问题失败: {str(e)}")
|
||||
|
||||
def get_knowledge_coverage(self, user_id: int) -> List[Dict[str, Any]]:
|
||||
"""获取知识覆盖度(简化版本)"""
|
||||
try:
|
||||
# 这里是一个简化的实现
|
||||
# 实际可能需要基于文档内容和问题内容进行更复杂的分析
|
||||
coverage_data = [
|
||||
{"topic": "基础概念", "coverage": 75, "questions": 20},
|
||||
{"topic": "实践方法", "coverage": 60, "questions": 15},
|
||||
{"topic": "技术标准", "coverage": 45, "questions": 10},
|
||||
{"topic": "案例分析", "coverage": 30, "questions": 8},
|
||||
{"topic": "政策法规", "coverage": 25, "questions": 5}
|
||||
]
|
||||
|
||||
return coverage_data
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取知识覆盖度失败: {str(e)}")
|
||||
raise Exception(f"获取知识覆盖度失败: {str(e)}")
|
||||
|
||||
def get_learning_report(self, user_id: int) -> Dict[str, Any]:
|
||||
"""获取学习报告"""
|
||||
try:
|
||||
stats = self.get_user_statistics(user_id)
|
||||
|
||||
# 计算学习进度(基于活跃天数和消息数量)
|
||||
days_since_registration = 1
|
||||
if stats["user_since"]:
|
||||
# 处理日期字符串,确保正确解析
|
||||
user_since_str = stats["user_since"]
|
||||
if isinstance(user_since_str, str):
|
||||
# 移除时区信息并解析
|
||||
clean_date_str = user_since_str.replace('Z', '').replace('+00:00', '')
|
||||
reg_date = datetime.fromisoformat(clean_date_str)
|
||||
else:
|
||||
reg_date = user_since_str
|
||||
days_since_registration = max(1, (datetime.now() - reg_date).days)
|
||||
|
||||
# 简化的学习进度计算
|
||||
learning_progress = min(100, (stats["active_days"] / days_since_registration) * 100)
|
||||
|
||||
# 学习建议
|
||||
recommendations = []
|
||||
if stats["total_messages"] < 50:
|
||||
recommendations.append("建议增加提问频率,多与AI助手互动学习")
|
||||
if stats["total_documents"] < 5:
|
||||
recommendations.append("建议上传更多相关文档,丰富知识库内容")
|
||||
if stats["active_days"] < 7:
|
||||
recommendations.append("建议保持每日学习习惯,提高学习连续性")
|
||||
|
||||
# 知识缺口
|
||||
knowledge_gaps = []
|
||||
if stats["total_sessions"] < 10:
|
||||
knowledge_gaps.append("基础概念理解不够深入")
|
||||
if stats["total_documents"] < 3:
|
||||
knowledge_gaps.append("实践应用能力有待提升")
|
||||
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"total_questions": stats["total_messages"],
|
||||
"topics_covered": ["国土空间规划基础理论", "规划编制方法与技术", "土地利用规划"],
|
||||
"learning_progress": round(learning_progress, 1),
|
||||
"recommendations": recommendations,
|
||||
"study_time": stats["total_messages"] * 2, # 估算学习时间(分钟)
|
||||
"knowledge_gaps": knowledge_gaps
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 获取学习报告失败: {str(e)}")
|
||||
raise Exception(f"获取学习报告失败: {str(e)}")
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
用户认证服务
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from ..models.user import User
|
||||
from ..core.security import get_password_hash, verify_password, create_access_token
|
||||
from ..core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class AuthService:
|
||||
"""用户认证服务"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create_user(
|
||||
self,
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
full_name: Optional[str] = None
|
||||
) -> Optional[User]:
|
||||
"""创建用户"""
|
||||
try:
|
||||
# 检查用户名是否已存在
|
||||
if self.get_user_by_username(username):
|
||||
return None
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
if self.get_user_by_email(email):
|
||||
return None
|
||||
|
||||
# 限制密码长度(bcrypt 最大支持72字节)
|
||||
if len(password) > 72:
|
||||
password = password[:72]
|
||||
|
||||
# 创建新用户
|
||||
hashed_password = get_password_hash(password)
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
hashed_password=hashed_password,
|
||||
full_name=full_name,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
print(f"创建用户失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
||||
"""验证用户"""
|
||||
try:
|
||||
user = self.get_user_by_username(username)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# 限制密码长度(bcrypt 最大支持72字节)
|
||||
if len(password) > 72:
|
||||
password = password[:72]
|
||||
|
||||
if not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
|
||||
if not user.is_active:
|
||||
return None
|
||||
|
||||
# 更新最后登录时间
|
||||
user.last_login = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
print(f"用户认证失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
"""根据用户名获取用户"""
|
||||
return self.db.query(User).filter(User.username == username).first()
|
||||
|
||||
def get_user_by_email(self, email: str) -> Optional[User]:
|
||||
"""根据邮箱获取用户"""
|
||||
return self.db.query(User).filter(User.email == email).first()
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[User]:
|
||||
"""根据ID获取用户"""
|
||||
return self.db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
def update_user(self, user_id: int, **kwargs) -> Optional[User]:
|
||||
"""更新用户信息"""
|
||||
try:
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(user, key) and value is not None:
|
||||
setattr(user, key, value)
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
print(f"更新用户失败: {str(e)}")
|
||||
return None
|
||||
|
||||
def deactivate_user(self, user_id: int) -> bool:
|
||||
"""停用用户"""
|
||||
try:
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user.is_active = False
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
print(f"停用用户失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
"""修改密码"""
|
||||
try:
|
||||
user = self.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if not verify_password(old_password, user.hashed_password):
|
||||
return False
|
||||
|
||||
user.hashed_password = get_password_hash(new_password)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.db.rollback()
|
||||
print(f"修改密码失败: {str(e)}")
|
||||
return False
|
||||
|
||||
def create_access_token_for_user(self, user: User) -> Dict[str, Any]:
|
||||
"""为用户创建访问令牌"""
|
||||
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": settings.access_token_expire_minutes * 60,
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"is_active": user.is_active
|
||||
}
|
||||
}
|
||||
|
||||
def get_user_statistics(self) -> Dict[str, Any]:
|
||||
"""获取用户统计信息"""
|
||||
try:
|
||||
total_users = self.db.query(User).count()
|
||||
active_users = self.db.query(User).filter(User.is_active == True).count()
|
||||
recent_users = self.db.query(User).filter(
|
||||
User.created_at >= datetime.utcnow() - timedelta(days=30)
|
||||
).count()
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"active_users": active_users,
|
||||
"recent_users": recent_users,
|
||||
"inactive_users": total_users - active_users
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取用户统计失败: {str(e)}")
|
||||
return {
|
||||
"total_users": 0,
|
||||
"active_users": 0,
|
||||
"recent_users": 0,
|
||||
"inactive_users": 0
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
书籍内容读取服务
|
||||
根据数据库中的行号范围从LaTeX文件动态读取内容
|
||||
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.book_structure import Chapter, Section, Subsection
|
||||
from .latex_parser import LaTeXParser
|
||||
|
||||
|
||||
class BookContentService:
|
||||
"""书籍内容服务"""
|
||||
|
||||
def __init__(self, book_dir: str):
|
||||
"""
|
||||
初始化服务
|
||||
|
||||
Args:
|
||||
book_dir: 书籍目录路径
|
||||
"""
|
||||
# 处理相对路径:如果相对路径不存在,尝试从项目根目录查找
|
||||
book_path = Path(book_dir)
|
||||
if not book_path.exists() or not book_path.is_absolute():
|
||||
# 脚本位置: dofile/backend/src/services/book_content_service.py
|
||||
# 项目根目录: 向上3级
|
||||
project_root = Path(__file__).parent.parent.parent.parent.parent
|
||||
book_path = project_root / "data" / "book"
|
||||
if not book_path.exists():
|
||||
# 如果还是不存在,使用原始路径
|
||||
book_path = Path(book_dir)
|
||||
|
||||
self.book_dir = book_path
|
||||
self.parser = LaTeXParser(self.book_dir)
|
||||
|
||||
def get_chapter_content(self, db: Session, chapter_id: int) -> Optional[str]:
|
||||
"""
|
||||
获取章节完整内容
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
chapter_id: 章节ID
|
||||
|
||||
Returns:
|
||||
章节内容文本,如果章节不存在则返回None
|
||||
"""
|
||||
chapter = db.query(Chapter).filter(Chapter.id == chapter_id).first()
|
||||
if not chapter:
|
||||
return None
|
||||
|
||||
try:
|
||||
file_path = self.book_dir / Path(chapter.file_path).name
|
||||
content = self.parser.get_content_by_lines(
|
||||
file_path,
|
||||
chapter.start_line,
|
||||
chapter.end_line
|
||||
)
|
||||
return content
|
||||
except Exception as e:
|
||||
print(f"读取章节内容失败: {e}")
|
||||
return None
|
||||
|
||||
def get_section_content(self, db: Session, section_id: int) -> Optional[str]:
|
||||
"""
|
||||
获取节完整内容
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
section_id: 节ID
|
||||
|
||||
Returns:
|
||||
节内容文本,如果节不存在则返回None
|
||||
"""
|
||||
section = db.query(Section).filter(Section.id == section_id).first()
|
||||
if not section:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 通过section获取chapter以获取文件路径
|
||||
chapter = db.query(Chapter).filter(Chapter.id == section.chapter_id).first()
|
||||
if not chapter:
|
||||
return None
|
||||
|
||||
file_path = self.book_dir / Path(chapter.file_path).name
|
||||
content = self.parser.get_content_by_lines(
|
||||
file_path,
|
||||
section.start_line,
|
||||
section.end_line
|
||||
)
|
||||
return content
|
||||
except Exception as e:
|
||||
print(f"读取节内容失败: {e}")
|
||||
return None
|
||||
|
||||
def get_subsection_content(self, db: Session, subsection_id: int) -> Optional[str]:
|
||||
"""
|
||||
获取小节完整内容(知识点,包含其下所有subsubsection内容)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
subsection_id: 小节ID
|
||||
|
||||
Returns:
|
||||
小节内容文本,如果小节不存在则返回None
|
||||
"""
|
||||
subsection = db.query(Subsection).filter(Subsection.id == subsection_id).first()
|
||||
if not subsection:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 通过subsection获取section和chapter以获取文件路径
|
||||
section = db.query(Section).filter(Section.id == subsection.section_id).first()
|
||||
if not section:
|
||||
return None
|
||||
|
||||
chapter = db.query(Chapter).filter(Chapter.id == section.chapter_id).first()
|
||||
if not chapter:
|
||||
return None
|
||||
|
||||
file_path = self.book_dir / Path(chapter.file_path).name
|
||||
content = self.parser.get_content_by_lines(
|
||||
file_path,
|
||||
subsection.start_line,
|
||||
subsection.end_line
|
||||
)
|
||||
return content
|
||||
except Exception as e:
|
||||
print(f"读取小节内容失败: {e}")
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
文档处理服务(LangChain 1.0)
|
||||
"""
|
||||
import os
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from langchain_core.documents import Document as LangChainDocument
|
||||
|
||||
from ..models.document import Document, DocumentChunk
|
||||
from ..rag.vector_store import get_vector_store
|
||||
from ..rag.document_loaders import DocumentLoaderFactory
|
||||
from ..rag.text_splitters import get_text_splitter
|
||||
|
||||
class DocumentService:
|
||||
"""文档处理服务"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
self.vector_store = get_vector_store()
|
||||
|
||||
async def process_document(self, document_id: int) -> bool:
|
||||
"""处理文档(使用LangChain 1.0)"""
|
||||
try:
|
||||
document = self.db.query(Document).filter(Document.id == document_id).first()
|
||||
if not document:
|
||||
return False
|
||||
|
||||
# 1. 使用LangChain加载文档
|
||||
documents = DocumentLoaderFactory.load_document(
|
||||
file_path=document.file_path,
|
||||
file_type=document.file_type,
|
||||
metadata={
|
||||
"document_id": document.id,
|
||||
"knowledge_base_id": document.knowledge_base_id,
|
||||
"title": document.title,
|
||||
"filename": document.filename
|
||||
}
|
||||
)
|
||||
|
||||
# 2. 使用中文优化的文本分割器
|
||||
text_splitter = get_text_splitter(chunk_size=1000, chunk_overlap=200)
|
||||
splits = text_splitter.split_documents(documents)
|
||||
|
||||
# 3. 添加到向量存储
|
||||
success = self.vector_store.add_documents(splits)
|
||||
|
||||
if success:
|
||||
document.is_processed = True
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理文档失败: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
|
||||
def search_documents(self, query: str, knowledge_base_ids: Optional[List[int]] = None, limit: int = 5) -> List[Dict[str, Any]]:
|
||||
"""搜索文档(保留原有接口兼容性)"""
|
||||
try:
|
||||
# 构建过滤条件
|
||||
filter_dict = None
|
||||
if knowledge_base_ids:
|
||||
filter_dict = {"knowledge_base_id": {"$in": knowledge_base_ids}}
|
||||
|
||||
# 使用LangChain Chroma进行搜索
|
||||
results = self.vector_store.similarity_search_with_score(
|
||||
query=query,
|
||||
k=limit,
|
||||
filter=filter_dict
|
||||
)
|
||||
|
||||
# 格式化结果
|
||||
search_results = []
|
||||
for doc, distance in results:
|
||||
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
|
||||
score = self._convert_distance_to_score(distance)
|
||||
|
||||
search_results.append({
|
||||
"content": doc.page_content,
|
||||
"metadata": metadata,
|
||||
"score": score,
|
||||
"distance": distance
|
||||
})
|
||||
|
||||
return search_results
|
||||
|
||||
except Exception as e:
|
||||
print(f"搜索文档失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def _convert_distance_to_score(self, distance: float) -> float:
|
||||
"""将ChromaDB的distance转换为0-1范围的相似度分数"""
|
||||
import math
|
||||
|
||||
# 内积距离(负值)
|
||||
if distance < 0:
|
||||
return (1 + distance) / 2
|
||||
|
||||
# 大距离使用对数缩放
|
||||
if distance > 100:
|
||||
return 1 / (1 + math.log(distance))
|
||||
|
||||
# 标准距离转换
|
||||
return 1 / (1 + distance)
|
||||
|
||||
def get_document_chunks(self, document_id: int) -> List[DocumentChunk]:
|
||||
"""获取文档的所有块"""
|
||||
return self.db.query(DocumentChunk).filter(
|
||||
DocumentChunk.document_id == document_id
|
||||
).order_by(DocumentChunk.chunk_index).all()
|
||||
|
||||
def delete_document_chunks(self, document_id: int) -> bool:
|
||||
"""删除文档的所有块"""
|
||||
try:
|
||||
self.db.query(DocumentChunk).filter(
|
||||
DocumentChunk.document_id == document_id
|
||||
).delete()
|
||||
self.db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"删除文档块失败: {str(e)}")
|
||||
self.db.rollback()
|
||||
return False
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
文件监控服务
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent, FileDeletedEvent
|
||||
|
||||
from ..core.config import get_settings
|
||||
from ..core.database import get_db
|
||||
from .knowledge_base_service import KnowledgeBaseService
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class KnowledgeBaseHandler(FileSystemEventHandler):
|
||||
"""知识库文件监控处理器"""
|
||||
|
||||
def __init__(self, knowledge_base_service: KnowledgeBaseService):
|
||||
self.kb_service = knowledge_base_service
|
||||
self.knowledge_base_dir = Path(settings.knowledge_base_dir)
|
||||
self.supported_extensions = settings.allowed_extensions
|
||||
|
||||
def on_created(self, event):
|
||||
"""处理文件创建事件"""
|
||||
if not event.is_directory and self._is_supported_file(event.src_path):
|
||||
print(f"检测到新文件: {event.src_path}")
|
||||
self._process_file_async(event.src_path, "created")
|
||||
|
||||
def on_modified(self, event):
|
||||
"""处理文件修改事件"""
|
||||
if not event.is_directory and self._is_supported_file(event.src_path):
|
||||
print(f"检测到文件修改: {event.src_path}")
|
||||
self._process_file_async(event.src_path, "modified")
|
||||
|
||||
def on_deleted(self, event):
|
||||
"""处理文件删除事件"""
|
||||
if not event.is_directory and self._is_supported_file(event.src_path):
|
||||
print(f"检测到文件删除: {event.src_path}")
|
||||
self._handle_file_deletion(event.src_path)
|
||||
|
||||
def _is_supported_file(self, file_path: str) -> bool:
|
||||
"""检查是否为支持的文件类型"""
|
||||
return Path(file_path).suffix.lower() in self.supported_extensions
|
||||
|
||||
def _process_file_async(self, file_path: str, event_type: str):
|
||||
"""异步处理文件"""
|
||||
def process():
|
||||
try:
|
||||
# 等待文件写入完成
|
||||
time.sleep(1)
|
||||
|
||||
result = self.kb_service.process_file(file_path)
|
||||
print(f"文件处理结果 ({event_type}): {result}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理文件失败: {file_path}, 错误: {str(e)}")
|
||||
|
||||
# 在后台线程中处理
|
||||
thread = threading.Thread(target=process)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def _handle_file_deletion(self, file_path: str):
|
||||
"""处理文件删除"""
|
||||
try:
|
||||
# 查找并删除对应的数据库记录
|
||||
from ..models.document import Document
|
||||
from sqlalchemy import and_
|
||||
|
||||
document = self.kb_service.db.query(Document).filter(
|
||||
and_(
|
||||
Document.file_path == file_path,
|
||||
Document.source_type == "knowledge_base"
|
||||
)
|
||||
).first()
|
||||
|
||||
if document:
|
||||
result = self.kb_service.delete_document(document.id)
|
||||
print(f"文件删除处理结果: {result}")
|
||||
else:
|
||||
print(f"未找到对应的数据库记录: {file_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理文件删除失败: {file_path}, 错误: {str(e)}")
|
||||
|
||||
|
||||
class FileWatcherService:
|
||||
"""文件监控服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.observer: Optional[Observer] = None
|
||||
self.knowledge_base_dir = Path(settings.knowledge_base_dir)
|
||||
self.is_running = False
|
||||
|
||||
def start(self):
|
||||
"""启动文件监控"""
|
||||
if self.is_running:
|
||||
print("文件监控服务已在运行")
|
||||
return
|
||||
|
||||
if not settings.enable_file_watcher:
|
||||
print("文件监控服务已禁用")
|
||||
return
|
||||
|
||||
if not self.knowledge_base_dir.exists():
|
||||
print(f"知识库目录不存在: {self.knowledge_base_dir}")
|
||||
return
|
||||
|
||||
try:
|
||||
# 创建数据库会话
|
||||
db = next(get_db())
|
||||
kb_service = KnowledgeBaseService(db)
|
||||
|
||||
# 创建事件处理器
|
||||
event_handler = KnowledgeBaseHandler(kb_service)
|
||||
|
||||
# 创建观察者
|
||||
self.observer = Observer()
|
||||
self.observer.schedule(
|
||||
event_handler,
|
||||
str(self.knowledge_base_dir),
|
||||
recursive=True # 递归监控子目录
|
||||
)
|
||||
|
||||
# 启动观察者
|
||||
self.observer.start()
|
||||
self.is_running = True
|
||||
|
||||
print(f"文件监控服务已启动,监控目录: {self.knowledge_base_dir}")
|
||||
|
||||
# 执行初始扫描
|
||||
print("执行初始知识库扫描...")
|
||||
scan_result = kb_service.scan_directory()
|
||||
print(f"初始扫描结果: {scan_result}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"启动文件监控服务失败: {str(e)}")
|
||||
self.is_running = False
|
||||
|
||||
def stop(self):
|
||||
"""停止文件监控"""
|
||||
if self.observer and self.is_running:
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
self.is_running = False
|
||||
print("文件监控服务已停止")
|
||||
|
||||
def is_active(self) -> bool:
|
||||
"""检查监控服务是否活跃"""
|
||||
return self.is_running and self.observer and self.observer.is_alive()
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""获取监控服务状态"""
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"is_active": self.is_active(),
|
||||
"knowledge_base_dir": str(self.knowledge_base_dir),
|
||||
"directory_exists": self.knowledge_base_dir.exists(),
|
||||
"enable_file_watcher": settings.enable_file_watcher
|
||||
}
|
||||
|
||||
|
||||
# 全局文件监控服务实例
|
||||
file_watcher_service: Optional[FileWatcherService] = None
|
||||
|
||||
|
||||
def get_file_watcher_service() -> FileWatcherService:
|
||||
"""获取文件监控服务实例"""
|
||||
global file_watcher_service
|
||||
if file_watcher_service is None:
|
||||
file_watcher_service = FileWatcherService()
|
||||
return file_watcher_service
|
||||
|
||||
|
||||
def start_file_watcher():
|
||||
"""启动文件监控服务"""
|
||||
watcher = get_file_watcher_service()
|
||||
watcher.start()
|
||||
|
||||
|
||||
def stop_file_watcher():
|
||||
"""停止文件监控服务"""
|
||||
watcher = get_file_watcher_service()
|
||||
watcher.stop()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
统一图像生成服务基类
|
||||
支持硅基流动平台的多种图像生成模型
|
||||
"""
|
||||
|
||||
import base64
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
import httpx
|
||||
from src.core.config import settings
|
||||
|
||||
|
||||
class ImageGenerationService:
|
||||
"""统一的图像生成服务基类"""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = "https://api.siliconflow.cn/v1/image/generations"
|
||||
self.api_key = settings.siliconflow_api_key
|
||||
self.timeout = 60
|
||||
|
||||
async def call_api(self, model: str, payload: dict) -> dict:
|
||||
"""调用硅基流动 API"""
|
||||
print(f"[DEBUG] 调用SiliconFlow API: {self.base_url}")
|
||||
print(f"[DEBUG] 模型: {model}")
|
||||
print(f"[DEBUG] 请求参数: {payload}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
self.base_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={"model": model, **payload}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
print(f"[DEBUG] API响应状态: {response.status_code}")
|
||||
print(f"[DEBUG] API响应类型: {type(result)}")
|
||||
print(f"[DEBUG] API响应键: {list(result.keys()) if isinstance(result, dict) else '非字典类型'}")
|
||||
|
||||
return result
|
||||
|
||||
def save_image(self, image_id: str, base64_data: str) -> str:
|
||||
"""保存 base64 图像到本地文件系统"""
|
||||
try:
|
||||
# 清理base64数据(移除可能的data URL前缀)
|
||||
if base64_data.startswith('data:image'):
|
||||
base64_data = base64_data.split(',')[1]
|
||||
|
||||
# 移除可能的空白字符
|
||||
base64_data = base64_data.strip()
|
||||
|
||||
print(f"[DEBUG] 开始解码base64数据,长度: {len(base64_data)}")
|
||||
|
||||
# 解码 base64 数据
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
|
||||
# 验证是否为有效的图片数据
|
||||
if len(image_bytes) == 0:
|
||||
raise Exception("解码后的图像数据为空")
|
||||
|
||||
print(f"[DEBUG] 图像数据大小: {len(image_bytes)} bytes")
|
||||
|
||||
# 确保目录存在
|
||||
image_dir = Path(settings.generated_images_dir)
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存图像文件
|
||||
image_path = image_dir / f"{image_id}.png"
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
print(f"[DEBUG] 图像已保存到: {image_path}")
|
||||
|
||||
return str(image_path)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 保存图像失败: {str(e)}")
|
||||
print(f"[ERROR] base64数据长度: {len(base64_data) if base64_data else 0}")
|
||||
print(f"[ERROR] base64数据前100字符: {base64_data[:100] if base64_data else 'None'}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
def generate_image_id(self) -> str:
|
||||
"""生成唯一的图像ID"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def get_image_url(self, image_id: str) -> str:
|
||||
"""获取图像的访问URL"""
|
||||
return f"/generated_images/{image_id}.png"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
图生图服务
|
||||
支持 Qwen/Qwen-Image-Edit 模型进行图像编辑
|
||||
"""
|
||||
|
||||
import base64
|
||||
import httpx
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
from fastapi import UploadFile
|
||||
from .image_generation_service import ImageGenerationService
|
||||
|
||||
|
||||
class ImageToImageService(ImageGenerationService):
|
||||
"""图生图服务"""
|
||||
|
||||
MODEL = "Qwen/Qwen-Image-Edit"
|
||||
|
||||
EDIT_MODES = {
|
||||
"optimize": "全图优化增强",
|
||||
"style_transfer": "风格转换",
|
||||
"local_edit": "局部编辑修改",
|
||||
"outpaint": "图像智能扩展"
|
||||
}
|
||||
|
||||
async def edit_image(
|
||||
self,
|
||||
image_file: UploadFile,
|
||||
prompt: str,
|
||||
mode: str = "optimize",
|
||||
mask_file: Optional[UploadFile] = None,
|
||||
strength: float = 0.8 # 保留用于兼容性,API不支持
|
||||
) -> Dict[str, Any]:
|
||||
"""编辑图像"""
|
||||
|
||||
# 验证编辑模式
|
||||
if mode not in self.EDIT_MODES:
|
||||
raise ValueError(f"不支持的编辑模式: {mode}")
|
||||
|
||||
# 读取图像数据
|
||||
image_data = await image_file.read()
|
||||
image_b64 = base64.b64encode(image_data).decode()
|
||||
|
||||
# 构建 API 请求参数
|
||||
# 注意:Qwen-Image-Edit 不支持 strength 和 image_size 参数
|
||||
payload = {
|
||||
"prompt": self._build_edit_prompt(prompt, mode),
|
||||
"image": f"data:image/png;base64,{image_b64}", # 使用正确的 base64 格式
|
||||
}
|
||||
|
||||
# 如果是局部编辑,添加蒙版(注意:API可能不支持mask)
|
||||
if mode == "local_edit" and mask_file:
|
||||
mask_data = await mask_file.read()
|
||||
mask_b64 = base64.b64encode(mask_data).decode()
|
||||
payload["mask_image"] = f"data:image/png;base64,{mask_b64}"
|
||||
|
||||
try:
|
||||
# 调用 API
|
||||
result = await self.call_api(self.MODEL, payload)
|
||||
|
||||
# 调试信息
|
||||
print(f"API响应结构: {type(result)}")
|
||||
if isinstance(result, dict):
|
||||
print(f"响应键: {list(result.keys())}")
|
||||
if "images" in result:
|
||||
print(f"图像数量: {len(result['images'])}")
|
||||
if result["images"]:
|
||||
print(f"第一个图像键: {list(result['images'][0].keys()) if isinstance(result['images'][0], dict) else '非字典类型'}")
|
||||
|
||||
# 保存结果
|
||||
image_id = self.generate_image_id()
|
||||
edited_image_data = result["images"][0]
|
||||
|
||||
# 检查是否为字典类型
|
||||
if isinstance(edited_image_data, dict):
|
||||
print(f"[DEBUG] 图像数据是字典,键: {list(edited_image_data.keys())}")
|
||||
|
||||
# 优先检查URL字段
|
||||
if "url" in edited_image_data:
|
||||
image_url = edited_image_data["url"]
|
||||
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
||||
try:
|
||||
image_path = await self.download_image_from_url(image_id, image_url)
|
||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
# 如果没有URL,尝试base64字段
|
||||
elif any(key in edited_image_data for key in ["b64_json", "b64", "data"]):
|
||||
image_b64 = edited_image_data.get("b64_json") or edited_image_data.get("b64") or edited_image_data.get("data")
|
||||
print(f"[DEBUG] 检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
||||
try:
|
||||
image_path = self.save_image(image_id, image_b64)
|
||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未找到有效的图像数据字段")
|
||||
raise Exception("API响应中未找到有效的图像数据")
|
||||
|
||||
# 如果直接是字符串,判断是URL还是base64
|
||||
elif isinstance(edited_image_data, str):
|
||||
print(f"[DEBUG] 图像数据是字符串,长度: {len(edited_image_data)}")
|
||||
if edited_image_data.startswith('http'):
|
||||
print(f"[DEBUG] 检测到URL字符串: {edited_image_data}")
|
||||
try:
|
||||
image_path = await self.download_image_from_url(image_id, edited_image_data)
|
||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
else:
|
||||
print(f"[DEBUG] 检测到base64字符串")
|
||||
try:
|
||||
image_path = self.save_image(image_id, edited_image_data)
|
||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未知的图像数据类型: {type(edited_image_data)}")
|
||||
raise Exception(f"不支持的图像数据类型: {type(edited_image_data)}")
|
||||
|
||||
return {
|
||||
"id": image_id,
|
||||
"url": self.get_image_url(image_id),
|
||||
"original_filename": image_file.filename,
|
||||
"edit_prompt": prompt,
|
||||
"mode": mode,
|
||||
"metadata": {
|
||||
"strength": strength,
|
||||
"has_mask": mask_file is not None,
|
||||
"file_path": image_path
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"图像编辑失败: {str(e)}")
|
||||
|
||||
async def create_variation(
|
||||
self,
|
||||
image_file: UploadFile,
|
||||
num_variations: int = 3
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成图像变体"""
|
||||
|
||||
# 读取图像数据
|
||||
image_data = await image_file.read()
|
||||
image_b64 = base64.b64encode(image_data).decode()
|
||||
|
||||
# 构建 API 请求参数
|
||||
payload = {
|
||||
"image": image_b64,
|
||||
"prompt": "create variations",
|
||||
"batch_size": num_variations,
|
||||
"strength": 0.6 # 变体强度
|
||||
}
|
||||
|
||||
try:
|
||||
# 调用 API
|
||||
result = await self.call_api(self.MODEL, payload)
|
||||
|
||||
# 调试信息
|
||||
print(f"API响应结构: {type(result)}")
|
||||
if isinstance(result, dict):
|
||||
print(f"响应键: {list(result.keys())}")
|
||||
if "images" in result:
|
||||
print(f"图像数量: {len(result['images'])}")
|
||||
|
||||
# 处理变体结果
|
||||
variations = []
|
||||
for i, img_data in enumerate(result["images"]):
|
||||
print(f"[DEBUG] 处理第 {i+1} 个变体")
|
||||
image_id = self.generate_image_id()
|
||||
|
||||
# 检查是否为字典类型
|
||||
if isinstance(img_data, dict):
|
||||
print(f"[DEBUG] 图像数据是字典,键: {list(img_data.keys())}")
|
||||
|
||||
# 优先检查URL字段
|
||||
if "url" in img_data:
|
||||
image_url = img_data["url"]
|
||||
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
||||
try:
|
||||
image_path = await self.download_image_from_url(image_id, image_url)
|
||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
# 如果没有URL,尝试base64字段
|
||||
elif any(key in img_data for key in ["b64_json", "b64", "data"]):
|
||||
image_b64 = img_data.get("b64_json") or img_data.get("b64") or img_data.get("data")
|
||||
print(f"[DEBUG] 检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
||||
try:
|
||||
image_path = self.save_image(image_id, image_b64)
|
||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未找到有效的图像数据字段")
|
||||
raise Exception("API响应中未找到有效的图像数据")
|
||||
|
||||
# 如果直接是字符串,判断是URL还是base64
|
||||
elif isinstance(img_data, str):
|
||||
print(f"[DEBUG] 图像数据是字符串,长度: {len(img_data)}")
|
||||
if img_data.startswith('http'):
|
||||
print(f"[DEBUG] 检测到URL字符串: {img_data}")
|
||||
try:
|
||||
image_path = await self.download_image_from_url(image_id, img_data)
|
||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
else:
|
||||
print(f"[DEBUG] 检测到base64字符串")
|
||||
try:
|
||||
image_path = self.save_image(image_id, img_data)
|
||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未知的图像数据类型: {type(img_data)}")
|
||||
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
||||
|
||||
variations.append({
|
||||
"id": image_id,
|
||||
"url": self.get_image_url(image_id),
|
||||
"type": "variation",
|
||||
"metadata": {
|
||||
"original_filename": image_file.filename,
|
||||
"file_path": image_path
|
||||
}
|
||||
})
|
||||
|
||||
return variations
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"生成图像变体失败: {str(e)}")
|
||||
|
||||
async def style_transfer(
|
||||
self,
|
||||
image_file: UploadFile,
|
||||
style_prompt: str,
|
||||
strength: float = 0.8 # 保留参数用于兼容性,但不传递给API
|
||||
) -> Dict[str, Any]:
|
||||
"""风格转换"""
|
||||
return await self.edit_image(
|
||||
image_file=image_file,
|
||||
prompt=style_prompt,
|
||||
mode="style_transfer"
|
||||
)
|
||||
|
||||
async def optimize_image(
|
||||
self,
|
||||
image_file: UploadFile,
|
||||
optimization_prompt: str = "优化图像质量,增强细节,提高清晰度",
|
||||
strength: float = 0.6 # 保留参数用于兼容性,但不传递给API
|
||||
) -> Dict[str, Any]:
|
||||
"""图像优化"""
|
||||
return await self.edit_image(
|
||||
image_file=image_file,
|
||||
prompt=optimization_prompt,
|
||||
mode="optimize"
|
||||
)
|
||||
|
||||
async def outpaint_image(
|
||||
self,
|
||||
image_file: UploadFile,
|
||||
expansion_prompt: str,
|
||||
strength: float = 0.7 # 保留参数用于兼容性,但不传递给API
|
||||
) -> Dict[str, Any]:
|
||||
"""图像智能扩展"""
|
||||
return await self.edit_image(
|
||||
image_file=image_file,
|
||||
prompt=expansion_prompt,
|
||||
mode="outpaint"
|
||||
)
|
||||
|
||||
def _build_edit_prompt(self, prompt: str, mode: str) -> str:
|
||||
"""构建编辑提示词"""
|
||||
mode_prefix = {
|
||||
"optimize": "优化和增强图像质量,",
|
||||
"style_transfer": "转换为以下风格:",
|
||||
"local_edit": "局部修改:",
|
||||
"outpaint": "智能扩展图像,保持连贯性,"
|
||||
}
|
||||
|
||||
prefix = mode_prefix.get(mode, "")
|
||||
return f"{prefix}{prompt}"
|
||||
|
||||
def get_available_modes(self) -> List[Dict[str, str]]:
|
||||
"""获取可用的编辑模式"""
|
||||
return [
|
||||
{"id": "optimize", "name": "全图优化", "description": "优化和增强图像质量,提高清晰度"},
|
||||
{"id": "style_transfer", "name": "风格转换", "description": "将图像转换为指定风格"},
|
||||
{"id": "local_edit", "name": "局部编辑", "description": "使用蒙版进行局部修改"},
|
||||
{"id": "outpaint", "name": "图像扩展", "description": "智能扩展图像边界,保持连贯性"}
|
||||
]
|
||||
|
||||
def get_style_presets(self) -> List[Dict[str, str]]:
|
||||
"""获取预设风格选项"""
|
||||
return [
|
||||
{"id": "realistic", "name": "真实感", "description": "转换为真实摄影风格"},
|
||||
{"id": "artistic", "name": "艺术化", "description": "转换为艺术绘画风格"},
|
||||
{"id": "technical", "name": "技术图", "description": "转换为技术图纸风格"},
|
||||
{"id": "sketch", "name": "素描", "description": "转换为素描手绘风格"},
|
||||
{"id": "watercolor", "name": "水彩", "description": "转换为水彩画风格"},
|
||||
{"id": "oil_painting", "name": "油画", "description": "转换为油画风格"}
|
||||
]
|
||||
|
||||
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
||||
"""从URL下载图像"""
|
||||
try:
|
||||
print(f"[DEBUG] 下载图像URL: {image_url}")
|
||||
|
||||
# 下载图像
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(image_url)
|
||||
response.raise_for_status()
|
||||
image_bytes = response.content
|
||||
|
||||
if len(image_bytes) == 0:
|
||||
raise Exception("下载的图像数据为空")
|
||||
|
||||
print(f"[DEBUG] 下载图像数据大小: {len(image_bytes)} bytes")
|
||||
|
||||
# 确保目录存在
|
||||
image_dir = Path(self.get_image_dir())
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存图像文件
|
||||
image_path = image_dir / f"{image_id}.png"
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
print(f"[DEBUG] 图像已保存到: {image_path}")
|
||||
return str(image_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 下载图像失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
def get_image_dir(self) -> str:
|
||||
"""获取图像目录"""
|
||||
from src.core.config import settings
|
||||
return settings.generated_images_dir
|
||||
@@ -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
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
LaTeX文件解析器
|
||||
解析章节文件,提取章节、节、小节的标题和内容
|
||||
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
class LaTeXParser:
|
||||
"""LaTeX解析器"""
|
||||
|
||||
def __init__(self, base_dir: Path):
|
||||
"""
|
||||
初始化解析器
|
||||
|
||||
Args:
|
||||
base_dir: 书籍目录路径
|
||||
"""
|
||||
self.base_dir = base_dir
|
||||
|
||||
def parse_chapter_file(self, file_path: Path) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
解析单个LaTeX章节文件,提取章节、节、小节的标题和行号。
|
||||
|
||||
层级结构:
|
||||
- \chapter{} -> Chapter
|
||||
- \section{} -> Section
|
||||
- \subsection{} -> Subsection(知识点,包含其下所有subsubsection内容)
|
||||
- \subsubsection{} -> 忽略,内容合并到subsection中
|
||||
|
||||
Returns:
|
||||
包含章节结构的列表,格式:
|
||||
[{
|
||||
"type": "chapter",
|
||||
"title": "...",
|
||||
"start_line": 1,
|
||||
"end_line": 100,
|
||||
"sections": [
|
||||
{
|
||||
"type": "section",
|
||||
"title": "...",
|
||||
"start_line": 5,
|
||||
"end_line": 50,
|
||||
"subsections": [
|
||||
{
|
||||
"type": "subsection",
|
||||
"title": "...",
|
||||
"start_line": 10,
|
||||
"end_line": 30,
|
||||
"subsection_number": 1
|
||||
}
|
||||
],
|
||||
"section_number": 1
|
||||
}
|
||||
],
|
||||
"chapter_number": 1
|
||||
}]
|
||||
"""
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
lines = content.splitlines()
|
||||
structure = []
|
||||
|
||||
# Regex for \chapter, \section, \subsection, \subsubsection
|
||||
chapter_re = re.compile(r'^\\chapter\*?{(.*?)}')
|
||||
section_re = re.compile(r'^\\section\*?{(.*?)}')
|
||||
subsection_re = re.compile(r'^\\subsection\*?{(.*?)}')
|
||||
subsubsection_re = re.compile(r'^\\subsubsection\*?{(.*?)}')
|
||||
|
||||
current_chapter = None
|
||||
current_section = None
|
||||
current_subsection = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_num = i + 1 # 1-based line number
|
||||
|
||||
# Try to match chapter first (highest level)
|
||||
match_chapter = chapter_re.match(line)
|
||||
if match_chapter:
|
||||
# Finalize previous chapter's end_line
|
||||
if current_chapter:
|
||||
if current_section:
|
||||
current_section["end_line"] = line_num - 1
|
||||
current_chapter["end_line"] = line_num - 1
|
||||
|
||||
title = match_chapter.group(1).strip()
|
||||
# Extract chapter number from filename if possible
|
||||
chapter_num_match = re.search(r'chapter(\d+)', file_path.name)
|
||||
chapter_number = int(chapter_num_match.group(1)) if chapter_num_match else len(structure) + 1
|
||||
|
||||
current_chapter = {
|
||||
"type": "chapter",
|
||||
"title": title,
|
||||
"start_line": line_num,
|
||||
"end_line": line_num, # Will be updated later
|
||||
"chapter_number": chapter_number,
|
||||
"sections": []
|
||||
}
|
||||
structure.append(current_chapter)
|
||||
current_section = None
|
||||
current_subsection = None
|
||||
continue
|
||||
|
||||
# Try to match section
|
||||
match_section = section_re.match(line)
|
||||
if match_section:
|
||||
# Finalize previous section's end_line
|
||||
if current_section:
|
||||
if current_subsection:
|
||||
current_subsection["end_line"] = line_num - 1
|
||||
current_section["end_line"] = line_num - 1
|
||||
|
||||
title = match_section.group(1).strip()
|
||||
# Remove numbering if present in title
|
||||
title = re.sub(r'^\d+(\.\d+)*\.\s*', '', title)
|
||||
|
||||
if current_chapter:
|
||||
section_number = len(current_chapter["sections"]) + 1
|
||||
current_section = {
|
||||
"type": "section",
|
||||
"title": title,
|
||||
"start_line": line_num,
|
||||
"end_line": line_num, # Will be updated later
|
||||
"section_number": section_number,
|
||||
"subsections": []
|
||||
}
|
||||
current_chapter["sections"].append(current_section)
|
||||
current_subsection = None
|
||||
continue
|
||||
|
||||
# Try to match subsection (知识点)
|
||||
match_subsection = subsection_re.match(line)
|
||||
if match_subsection:
|
||||
# Finalize previous subsection's end_line
|
||||
if current_subsection and current_section:
|
||||
current_subsection["end_line"] = line_num - 1
|
||||
|
||||
title = match_subsection.group(1).strip()
|
||||
# Remove numbering if present in title
|
||||
title = re.sub(r'^\d+(\.\d+)*\.\s*', '', title)
|
||||
|
||||
# Ensure current_section exists before adding subsection
|
||||
if current_section:
|
||||
subsection_number = len(current_section["subsections"]) + 1
|
||||
current_subsection = {
|
||||
"type": "subsection",
|
||||
"title": title,
|
||||
"start_line": line_num,
|
||||
"end_line": line_num, # Will be updated later, includes all subsubsections
|
||||
"subsection_number": subsection_number
|
||||
}
|
||||
current_section["subsections"].append(current_subsection)
|
||||
continue
|
||||
|
||||
# Try to match subsubsection (忽略,但内容会包含在subsection中)
|
||||
match_subsubsection = subsubsection_re.match(line)
|
||||
if match_subsubsection:
|
||||
# Subsubsection内容会被包含在当前subsection中
|
||||
# 不需要单独处理,subsection的end_line会包含所有subsubsection
|
||||
continue
|
||||
|
||||
# Finalize the end_line for the last chapter/section/subsection
|
||||
if current_chapter:
|
||||
if current_section:
|
||||
if current_subsection:
|
||||
current_subsection["end_line"] = len(lines)
|
||||
current_section["end_line"] = len(lines)
|
||||
current_chapter["end_line"] = len(lines)
|
||||
|
||||
# Post-process to ensure end_lines are correctly set for all elements
|
||||
for chap in structure:
|
||||
if chap["end_line"] == chap["start_line"]:
|
||||
chap["end_line"] = len(lines)
|
||||
for sec in chap["sections"]:
|
||||
if sec["end_line"] == sec["start_line"]:
|
||||
sec["end_line"] = len(lines)
|
||||
for sub in sec["subsections"]:
|
||||
if sub["end_line"] == sub["start_line"]:
|
||||
# Find next subsection or end of section
|
||||
next_subsection_start = None
|
||||
for other_sub in sec["subsections"]:
|
||||
if other_sub["start_line"] > sub["start_line"]:
|
||||
next_subsection_start = other_sub["start_line"]
|
||||
break
|
||||
if next_subsection_start:
|
||||
sub["end_line"] = next_subsection_start - 1
|
||||
else:
|
||||
sub["end_line"] = sec["end_line"]
|
||||
|
||||
return structure
|
||||
|
||||
def get_content_by_lines(self, file_path: Path, start_line: int, end_line: int) -> str:
|
||||
"""
|
||||
从指定文件的指定行范围读取内容。
|
||||
包含subsection及其下所有subsubsection的内容。
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return ""
|
||||
|
||||
lines = file_path.read_text(encoding='utf-8').splitlines()
|
||||
|
||||
# Adjust for 0-based indexing in Python list
|
||||
start_idx = max(0, start_line - 1)
|
||||
end_idx = min(len(lines), end_line)
|
||||
|
||||
content_lines = lines[start_idx:end_idx]
|
||||
|
||||
# 移除LaTeX命令,只保留纯文本
|
||||
cleaned_content = self._clean_latex_commands("\n".join(content_lines))
|
||||
return cleaned_content
|
||||
|
||||
def _convert_latex_table_to_markdown(self, table_content: str) -> str:
|
||||
"""
|
||||
将LaTeX表格转换为Markdown格式。
|
||||
|
||||
Args:
|
||||
table_content: LaTeX表格内容(不包含\begin{tabular}和\end{tabular})
|
||||
|
||||
Returns:
|
||||
Markdown格式的表格字符串
|
||||
"""
|
||||
if not table_content:
|
||||
return ""
|
||||
|
||||
# 先按 \\ 分割行(LaTeX表格使用 \\ 作为行分隔符)
|
||||
raw_lines = table_content.split('\\\\')
|
||||
rows = []
|
||||
|
||||
for raw_line in raw_lines:
|
||||
line = raw_line.strip()
|
||||
# 移除行内的 \hline 命令
|
||||
line = re.sub(r'\\hline', '', line)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# 分割单元格(使用 & 分隔符)
|
||||
cells = [cell.strip() for cell in line.split('&')]
|
||||
|
||||
# 清理单元格内容(移除LaTeX命令)
|
||||
cleaned_cells = []
|
||||
for cell in cells:
|
||||
# 移除常见的LaTeX命令,但保留文本内容
|
||||
cell = re.sub(r'\\(textbf|textit|emph|text)\{([^}]+)\}', r'\2', cell)
|
||||
# 移除其他带大括号的命令
|
||||
cell = re.sub(r'\\[a-zA-Z]+\{[^}]*\}', '', cell)
|
||||
# 移除单独的LaTeX命令
|
||||
cell = re.sub(r'\\([a-zA-Z]+)', '', cell)
|
||||
cell = cell.strip()
|
||||
cleaned_cells.append(cell)
|
||||
|
||||
# 过滤掉空行
|
||||
if cleaned_cells and any(cell for cell in cleaned_cells):
|
||||
rows.append(cleaned_cells)
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
# 转换为Markdown表格
|
||||
markdown_lines = []
|
||||
|
||||
# 表头(第一行)
|
||||
if rows:
|
||||
header = rows[0]
|
||||
if not header or not any(header):
|
||||
if len(rows) > 1:
|
||||
header = rows[1]
|
||||
rows = rows[1:]
|
||||
else:
|
||||
return ""
|
||||
|
||||
markdown_lines.append('| ' + ' | '.join(header) + ' |')
|
||||
markdown_lines.append('| ' + ' | '.join(['---'] * len(header)) + ' |')
|
||||
|
||||
# 数据行(跳过表头)
|
||||
for row in rows[1:]:
|
||||
while len(row) < len(header):
|
||||
row.append('')
|
||||
markdown_lines.append('| ' + ' | '.join(row[:len(header)]) + ' |')
|
||||
|
||||
return '\n'.join(markdown_lines)
|
||||
|
||||
def _clean_latex_commands(self, text: str) -> str:
|
||||
"""
|
||||
移除LaTeX命令和环境,保留纯文本。
|
||||
注意:保留段落文本内容和表格(转换为Markdown格式)。
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# 移除 \chapter{}, \section{}, \subsection{}, \subsubsection{} 命令行
|
||||
text = re.sub(r'^\s*\\(chapter|section|subsection|subsubsection)\*?{.*?}\s*$', '', text, flags=re.MULTILINE)
|
||||
# 移除 \label{}, \ref{}, \cite{} 等命令(但保留内容)
|
||||
text = re.sub(r'\\label\{[^}]+\}', '', text)
|
||||
text = re.sub(r'\\ref\{[^}]+\}', '', text)
|
||||
text = re.sub(r'\\cite\{[^}]+\}', '', text)
|
||||
|
||||
# 处理表格:将LaTeX表格转换为Markdown表格
|
||||
def replace_table(match):
|
||||
table_content = match.group(0)
|
||||
# 提取tabular环境内容
|
||||
tabular_pattern = r'\\begin\{tabular\}[^{]*\{[^}]*\}(.*?)\\end\{tabular\}'
|
||||
tabular_match = re.search(tabular_pattern, table_content, re.DOTALL)
|
||||
if tabular_match:
|
||||
inner_content = tabular_match.group(1)
|
||||
markdown_table = self._convert_latex_table_to_markdown(inner_content)
|
||||
return '\n\n' + markdown_table + '\n\n' if markdown_table else ''
|
||||
return ''
|
||||
|
||||
# 先处理完整的table环境(包含tabular)
|
||||
text = re.sub(r'\\begin\{table\}.*?\\end\{table\}', replace_table, text, flags=re.DOTALL)
|
||||
# 再处理单独的tabular环境(如果table环境没有匹配到)
|
||||
text = re.sub(r'\\begin\{tabular\}.*?\\end\{tabular\}', replace_table, text, flags=re.DOTALL)
|
||||
|
||||
# 移除图片环境
|
||||
text = re.sub(r'\\begin\{figure\}.*?\\end\{figure\}', '', text, flags=re.DOTALL)
|
||||
# 移除表格相关的LaTeX命令
|
||||
text = re.sub(r'\\(centering|caption|toprule|midrule|bottomrule|hline)\b', '', text)
|
||||
# 移除注释
|
||||
text = re.sub(r'%.*$', '', text, flags=re.MULTILINE)
|
||||
# 移除多余的空行(保留段落间的空行)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
# 移除行首尾的空白
|
||||
lines = [line.strip() for line in text.split('\n')]
|
||||
text = '\n'.join(lines)
|
||||
return text.strip()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
文生图服务
|
||||
支持 Kwai-Kolors/Kolors 和 Qwen/Qwen-Image 模型
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import httpx
|
||||
from typing import List, Dict, Any
|
||||
from pathlib import Path
|
||||
from .image_generation_service import ImageGenerationService
|
||||
|
||||
|
||||
class TextToImageService(ImageGenerationService):
|
||||
"""文生图服务"""
|
||||
|
||||
MODELS = {
|
||||
"kolors": "Kwai-Kolors/Kolors",
|
||||
"qwen": "Qwen/Qwen-Image"
|
||||
}
|
||||
|
||||
PROMPT_TEMPLATES = {
|
||||
"urban_planning": "城市总体规划图,{prompt},鸟瞰视角,清晰的功能分区,道路网络完整",
|
||||
"land_use": "土地利用规划图,{prompt},色块清晰,图例完整,专业制图风格",
|
||||
"ecological": "生态保护规划图,{prompt},生态敏感区标注,绿色基调,自然元素",
|
||||
"transportation": "交通规划图,{prompt},路网清晰,站点标注,流线设计",
|
||||
"custom": "{prompt}"
|
||||
}
|
||||
|
||||
STYLE_ENHANCEMENTS = {
|
||||
"realistic": "高清真实感,专业摄影风格,细节丰富",
|
||||
"conceptual": "概念设计图,简洁明了,设计感强,现代风格",
|
||||
"technical": "技术图纸风格,精确详细,工程制图,CAD风格",
|
||||
"artistic": "艺术化表现,创意设计,视觉冲击力强,插画风格",
|
||||
"schematic": "示意图风格,清晰标注,信息传达准确,图表化"
|
||||
}
|
||||
|
||||
SIZE_OPTIONS = {
|
||||
"512x512": "512x512",
|
||||
"1024x1024": "1024x1024",
|
||||
"1024x1792": "1024x1792",
|
||||
"1792x1024": "1792x1024"
|
||||
}
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str = "kolors",
|
||||
template: str = "custom",
|
||||
style: str = "realistic",
|
||||
size: str = "1024x1024",
|
||||
num_images: int = 1
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""生成图像"""
|
||||
|
||||
# 验证模型
|
||||
if model not in self.MODELS:
|
||||
raise ValueError(f"不支持的模型: {model}")
|
||||
|
||||
# 构建增强提示词
|
||||
enhanced_prompt = self._build_prompt(prompt, template, style)
|
||||
|
||||
# 构建 API 请求参数
|
||||
payload = {
|
||||
"prompt": enhanced_prompt,
|
||||
"image_size": size,
|
||||
"num_inference_steps": 50,
|
||||
"guidance_scale": 7.5,
|
||||
"batch_size": num_images
|
||||
}
|
||||
|
||||
try:
|
||||
# 调用 API
|
||||
result = await self.call_api(self.MODELS[model], payload)
|
||||
|
||||
# 调试信息
|
||||
print(f"API响应结构: {type(result)}")
|
||||
if isinstance(result, dict):
|
||||
print(f"响应键: {list(result.keys())}")
|
||||
if "images" in result:
|
||||
print(f"图像数量: {len(result['images'])}")
|
||||
if result["images"]:
|
||||
print(f"第一个图像键: {list(result['images'][0].keys()) if isinstance(result['images'][0], dict) else '非字典类型'}")
|
||||
|
||||
# 处理结果
|
||||
images = []
|
||||
for i, img_data in enumerate(result.get("images", [])):
|
||||
print(f"[DEBUG] 处理第 {i+1} 张图像")
|
||||
image_id = self.generate_image_id()
|
||||
|
||||
# 检查是否为字典类型
|
||||
if isinstance(img_data, dict):
|
||||
print(f"[DEBUG] 图像数据是字典,键: {list(img_data.keys())}")
|
||||
|
||||
# 优先检查URL字段
|
||||
if "url" in img_data:
|
||||
image_url = img_data["url"]
|
||||
print(f"[DEBUG] 检测到URL字段: {image_url}")
|
||||
try:
|
||||
image_path = await self.download_image_from_url(image_id, image_url)
|
||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
# 如果没有URL,尝试base64字段
|
||||
elif any(key in img_data for key in ["b64_json", "b64", "data"]):
|
||||
image_b64 = img_data.get("b64_json") or img_data.get("b64") or img_data.get("data")
|
||||
print(f"[DEBUG] 检测到base64字段,长度: {len(image_b64) if image_b64 else 0}")
|
||||
try:
|
||||
image_path = self.save_image(image_id, image_b64)
|
||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未找到有效的图像数据字段")
|
||||
raise Exception("API响应中未找到有效的图像数据")
|
||||
|
||||
# 如果直接是字符串,判断是URL还是base64
|
||||
elif isinstance(img_data, str):
|
||||
print(f"[DEBUG] 图像数据是字符串,长度: {len(img_data)}")
|
||||
if img_data.startswith('http'):
|
||||
print(f"[DEBUG] 检测到URL字符串: {img_data}")
|
||||
try:
|
||||
image_path = await self.download_image_from_url(image_id, img_data)
|
||||
print(f"[DEBUG] URL下载成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] URL下载失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
else:
|
||||
print(f"[DEBUG] 检测到base64字符串")
|
||||
try:
|
||||
image_path = self.save_image(image_id, img_data)
|
||||
print(f"[DEBUG] base64保存成功: {image_path}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] base64保存失败: {str(e)}")
|
||||
raise Exception(f"保存图像失败: {str(e)}")
|
||||
|
||||
else:
|
||||
print(f"[ERROR] 未知的图像数据类型: {type(img_data)}")
|
||||
raise Exception(f"不支持的图像数据类型: {type(img_data)}")
|
||||
|
||||
images.append({
|
||||
"id": image_id,
|
||||
"url": self.get_image_url(image_id),
|
||||
"prompt": enhanced_prompt,
|
||||
"model": model,
|
||||
"metadata": {
|
||||
"original_prompt": prompt,
|
||||
"template": template,
|
||||
"style": style,
|
||||
"size": size,
|
||||
"file_path": image_path
|
||||
}
|
||||
})
|
||||
|
||||
return images
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"图像生成失败: {str(e)}")
|
||||
|
||||
def _build_prompt(self, prompt: str, template: str, style: str) -> str:
|
||||
"""构建增强提示词"""
|
||||
# 应用模板
|
||||
if template in self.PROMPT_TEMPLATES:
|
||||
templated_prompt = self.PROMPT_TEMPLATES[template].format(prompt=prompt)
|
||||
else:
|
||||
templated_prompt = prompt
|
||||
|
||||
# 添加风格描述
|
||||
style_desc = self.STYLE_ENHANCEMENTS.get(style, "")
|
||||
|
||||
# 组合最终提示词
|
||||
if style_desc:
|
||||
final_prompt = f"{templated_prompt},{style_desc}"
|
||||
else:
|
||||
final_prompt = templated_prompt
|
||||
|
||||
return final_prompt
|
||||
|
||||
async def download_image_from_url(self, image_id: str, image_url: str) -> str:
|
||||
"""从URL下载图像"""
|
||||
try:
|
||||
print(f"[DEBUG] 下载图像URL: {image_url}")
|
||||
|
||||
# 下载图像
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(image_url)
|
||||
response.raise_for_status()
|
||||
image_bytes = response.content
|
||||
|
||||
if len(image_bytes) == 0:
|
||||
raise Exception("下载的图像数据为空")
|
||||
|
||||
print(f"[DEBUG] 下载图像数据大小: {len(image_bytes)} bytes")
|
||||
|
||||
# 确保目录存在
|
||||
image_dir = Path(self.get_image_dir())
|
||||
image_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 保存图像文件
|
||||
image_path = image_dir / f"{image_id}.png"
|
||||
with open(image_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
|
||||
print(f"[DEBUG] 图像已保存到: {image_path}")
|
||||
return str(image_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 下载图像失败: {str(e)}")
|
||||
raise Exception(f"下载图像失败: {str(e)}")
|
||||
|
||||
def get_image_dir(self) -> str:
|
||||
"""获取图像目录"""
|
||||
from src.core.config import settings
|
||||
return settings.generated_images_dir
|
||||
|
||||
def get_available_models(self) -> List[Dict[str, str]]:
|
||||
"""获取可用的模型列表"""
|
||||
return [
|
||||
{"id": "kolors", "name": "Kwai-Kolors/Kolors", "description": "快手可图,中英双语,高质量生成"},
|
||||
{"id": "qwen", "name": "Qwen/Qwen-Image", "description": "通义万象,多场景支持,快速生成"}
|
||||
]
|
||||
|
||||
def get_available_templates(self) -> List[Dict[str, str]]:
|
||||
"""获取可用的提示词模板"""
|
||||
return [
|
||||
{"id": "custom", "name": "自定义", "description": "直接使用用户输入的提示词"},
|
||||
{"id": "urban_planning", "name": "城市规划", "description": "城市总体规划图,鸟瞰视角,功能分区清晰"},
|
||||
{"id": "land_use", "name": "土地利用", "description": "土地利用规划图,色块清晰,专业制图风格"},
|
||||
{"id": "ecological", "name": "生态保护", "description": "生态保护规划图,生态敏感区标注,绿色基调"},
|
||||
{"id": "transportation", "name": "交通规划", "description": "交通规划图,路网清晰,站点标注,流线设计"}
|
||||
]
|
||||
|
||||
def get_available_styles(self) -> List[Dict[str, str]]:
|
||||
"""获取可用的风格选项"""
|
||||
return [
|
||||
{"id": "realistic", "name": "真实感", "description": "高清真实感,专业摄影风格,细节丰富"},
|
||||
{"id": "conceptual", "name": "概念图", "description": "概念设计图,简洁明了,设计感强,现代风格"},
|
||||
{"id": "technical", "name": "技术图", "description": "技术图纸风格,精确详细,工程制图,CAD风格"},
|
||||
{"id": "artistic", "name": "艺术化", "description": "艺术化表现,创意设计,视觉冲击力强,插画风格"},
|
||||
{"id": "schematic", "name": "示意图", "description": "示意图风格,清晰标注,信息传达准确,图表化"}
|
||||
]
|
||||
|
||||
def get_available_sizes(self) -> List[Dict[str, str]]:
|
||||
"""获取可用的尺寸选项"""
|
||||
return [
|
||||
{"id": "512x512", "name": "512×512", "description": "正方形,适合头像和图标"},
|
||||
{"id": "1024x1024", "name": "1024×1024", "description": "高清正方形,适合详细图像"},
|
||||
{"id": "1024x1792", "name": "1024×1792", "description": "竖版,适合海报和长图"},
|
||||
{"id": "1792x1024", "name": "1792×1024", "description": "横版,适合横幅和全景图"}
|
||||
]
|
||||
Reference in New Issue
Block a user