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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 09:40:18 +08:00
commit ddbb79b9f6
167 changed files with 44147 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# 国土空间规划课程智能体后端服务
基于FastAPI和LangGraph的智能问答系统后端服务。
## 技术栈
- **框架**: FastAPI + Uvicorn
- **AI框架**: LangChain + LangGraph
- **大模型**: 硅基流动API (Qwen3-30B)
- **向量数据库**: Chroma
- **数据库**: PostgreSQL
## 快速开始
### 使用Docker(推荐)
```bash
docker-compose up -d
```
### 本地开发
```bash
uv sync
uv run main.py
```
## API文档
启动服务后访问:http://localhost:8000/docs
+26
View File
@@ -0,0 +1,26 @@
-- PostgreSQL数据库初始化脚本
-- 为国土空间规划课程智能体创建扩展和配置
-- 创建扩展(如果需要)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 设置数据库编码(lc_collate 和 lc_ctype 只能在创建数据库时设置,不能通过 ALTER DATABASE 修改)
ALTER DATABASE course_agent_db SET client_encoding TO 'UTF8';
-- 创建表空间(可选)
-- CREATE TABLESPACE course_agent_space LOCATION '/var/lib/postgresql/data';
-- 设置搜索路径
ALTER DATABASE course_agent_db SET search_path TO public;
-- 创建用于监控的只读用户(可选)
-- CREATE USER monitor WITH PASSWORD 'monitor_password';
-- GRANT CONNECT ON DATABASE course_agent_db TO monitor;
-- GRANT USAGE ON SCHEMA public TO monitor;
-- GRANT SELECT ON ALL TABLES IN SCHEMA public TO monitor;
-- 设置连接限制
ALTER DATABASE course_agent_db CONNECTION LIMIT 100;
-- 记录初始化完成
COMMENT ON DATABASE course_agent_db IS '国土空间规划课程智能体数据库 - 初始化于 ' || current_timestamp;
+275
View File
@@ -0,0 +1,275 @@
"""
国土空间规划课程智能体后端服务
FastAPI应用入口
"""
import os
import sys
from pathlib import Path
from datetime import datetime
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import uvicorn
# 设置系统时区为北京时间
os.environ['TZ'] = 'Asia/Shanghai'
# 添加src目录到Python路径
sys.path.append(str(Path(__file__).parent / "src"))
from src.core.config import get_settings, ensure_directories, is_postgresql_database
from src.core.database import create_tables, check_database_connection, get_database_info
from src.api import auth, chat
# 获取配置
settings = get_settings()
# 确保必要目录存在
ensure_directories()
# 创建FastAPI应用
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="基于大模型的国土空间规划课程智能体系统",
docs_url="/docs",
redoc_url="/redoc"
)
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 启动知识库文件监控服务
def startup_knowledge_base():
"""启动知识库服务"""
try:
from src.services.file_watcher_service import start_file_watcher
start_file_watcher()
print("知识库文件监控服务启动成功")
except Exception as e:
print(f"启动知识库文件监控服务失败: {str(e)}")
# 应用启动事件
@app.on_event("startup")
async def startup_event():
"""应用启动时执行"""
import time
import asyncio
import sys
print("=" * 50, file=sys.stderr)
print("应用启动事件开始", file=sys.stderr)
print("=" * 50, file=sys.stderr)
sys.stderr.flush()
# 等待数据库可用(重试机制,处理 DNS 解析延迟)
max_retries = 10
retry_delay = 2
for attempt in range(max_retries):
try:
print(f"尝试连接数据库(尝试 {attempt + 1}/{max_retries}...", file=sys.stderr)
sys.stderr.flush()
# 尝试创建数据库表
create_tables()
print("数据库表创建成功", file=sys.stderr)
sys.stderr.flush()
break
except Exception as e:
if attempt < max_retries - 1:
print(f"数据库连接失败(尝试 {attempt + 1}/{max_retries}: {e}", file=sys.stderr)
print(f"等待 {retry_delay} 秒后重试...", file=sys.stderr)
sys.stderr.flush()
await asyncio.sleep(retry_delay)
else:
print(f"数据库连接失败,已达到最大重试次数: {e}", file=sys.stderr)
sys.stderr.flush()
# 不抛出异常,让应用继续启动,但数据库操作会失败
try:
startup_knowledge_base()
except Exception as e:
print(f"启动知识库服务失败: {e}", file=sys.stderr)
sys.stderr.flush()
print("应用启动事件完成", file=sys.stderr)
sys.stderr.flush()
# 应用关闭事件
@app.on_event("shutdown")
async def shutdown_event():
"""应用关闭时执行"""
try:
from src.services.file_watcher_service import stop_file_watcher
stop_file_watcher()
print("知识库文件监控服务已停止")
except Exception as e:
print(f"停止知识库文件监控服务失败: {str(e)}")
# 注册路由
app.include_router(auth.router)
app.include_router(chat.router)
# 导入并注册文档API
from src.api import document
app.include_router(document.router)
# 导入并注册图像API
from src.api import image
app.include_router(image.router)
# 导入并注册分析API
from src.api import analytics
app.include_router(analytics.router)
# 导入并注册知识库API(已合并knowledge.py的功能)
from src.api import knowledge_base
app.include_router(knowledge_base.router)
# 导入并注册课程内容API
from src.api import course_content, forum
app.include_router(course_content.router)
app.include_router(forum.router)
# 静态文件服务
if os.path.exists("uploads"):
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# 挂载生成图像目录
if os.path.exists("generated_images"):
app.mount("/generated_images", StaticFiles(directory="generated_images"), name="generated_images")
# 根路径
@app.get("/")
async def root():
"""根路径"""
return {
"message": f"欢迎使用{settings.app_name}",
"version": settings.app_version,
"docs": "/docs"
}
# 健康检查端点
@app.get("/health")
async def health_check():
"""健康检查端点"""
# 检查数据库连接
db_healthy = check_database_connection()
status = "healthy" if db_healthy else "unhealthy"
return {
"status": status,
"timestamp": datetime.now().isoformat(),
"version": settings.app_version,
"database": "connected" if db_healthy else "disconnected",
"services": {
"api": "running",
"database": "connected" if db_healthy else "disconnected"
}
}
# 系统状态端点
@app.get("/system/status")
async def system_status():
"""系统状态端点"""
db_info = get_database_info()
# 获取系统信息
import psutil
import platform
system_info = {
"platform": platform.platform(),
"python_version": platform.python_version(),
"cpu_count": psutil.cpu_count(),
"memory_total": psutil.virtual_memory().total,
"memory_available": psutil.virtual_memory().available,
}
# 检查磁盘使用情况
try:
disk_usage = psutil.disk_usage("/")._asdict()
system_info["disk_usage"] = disk_usage
except:
system_info["disk_usage"] = None
# 获取服务状态
services_status = {
"api": "running",
"database": db_info.get("type", "unknown"),
"vector_store": "available" if os.path.exists(settings.vector_store_path) else "unavailable"
}
return {
"status": "operational",
"timestamp": datetime.now().isoformat(),
"system": system_info,
"services": services_status,
"database": db_info,
"config": {
"app_name": settings.app_name,
"app_version": settings.app_version,
"debug": settings.debug,
"database_type": "postgresql" if is_postgresql_database() else "sqlite"
}
}
# 服务发现端点
@app.get("/services")
async def list_services():
"""列出所有服务"""
services = {
"backend": {
"name": "FastAPI后端服务",
"endpoint": f"http://localhost:{settings.port}",
"health": "/health",
"docs": "/docs"
},
"database": {
"name": "数据库服务",
"type": "postgresql" if is_postgresql_database() else "sqlite",
"status": "running"
},
"vectorizer": {
"name": "向量化服务",
"description": "独立文档向量化处理服务",
"status": "available"
},
"frontend": {
"name": "Next.js前端服务",
"endpoint": "http://localhost:8001",
"status": "external"
}
}
return {
"services": services,
"timestamp": datetime.now().isoformat()
}
if __name__ == "__main__":
import sys
print("=" * 50, file=sys.stderr)
print("启动 Uvicorn 服务器...", file=sys.stderr)
print(f"Host: {settings.host}, Port: {settings.port}", file=sys.stderr)
print("=" * 50, file=sys.stderr)
sys.stderr.flush()
uvicorn.run(
"main:app",
host=settings.host,
port=settings.port,
reload=settings.debug,
log_level=settings.log_level.lower(),
log_config=None # 使用默认日志配置
)
+70
View File
@@ -0,0 +1,70 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "course-agent-backend"
version = "0.1.0"
description = "国土空间规划课程智能体后端服务"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.110.0",
"uvicorn[standard]>=0.27.1",
"langchain>=1.0.0",
"langchain-openai>=0.3.0",
"langchain-community>=0.3.0",
"langchain-chroma>=0.2.0",
"langchain-text-splitters>=0.3.0",
"langgraph>=0.3.5",
"chromadb>=0.4.0",
"sentence-transformers>=2.2.2",
"pypdf2>=3.0.0",
"python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4",
"python-multipart>=0.0.6",
"sqlalchemy>=2.0.0",
"psycopg[binary]>=3.1.0",
"sse-starlette>=1.6.5",
"pydantic[email]>=2.5.0",
"pydantic-settings>=2.1.0",
"python-dotenv>=1.0.1",
"httpx>=0.28.1",
"jinja2>=3.1.3",
"markdownify>=1.1.0",
"watchdog>=3.0.0",
"numpy>=2.2.3",
"pandas>=2.2.3",
"zstandard>=0.25.0",
"lxml>=6.0.2",
"python-docx>=1.2.0",
"bcrypt==4.0.1",
"duckduckgo-search>=6.0.0",
"docx2txt>=0.9",
"pypdf>=6.12.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.0.0",
"black>=24.0.0",
"ruff>=0.1.0",
"mypy>=1.8.0"
]
[tool.hatch.build.targets.wheel]
packages = ["src"]
[tool.uv]
required-version = ">=0.6.15"
[tool.ruff]
line-length = 88
indent-width = 4
target-version = "py312"
[tool.ruff.format]
indent-style = "space"
line-ending = "auto"
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""
数据备份脚本 - 备份数据库和向量存储
"""
import os
import sys
import shutil
import json
import tarfile
from datetime import datetime
from pathlib import Path
import logging
from typing import Dict, List, Optional
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class DataBackup:
"""数据备份类"""
def __init__(self, backup_dir: str = "./backups"):
self.backup_dir = Path(backup_dir)
self.backup_dir.mkdir(parents=True, exist_ok=True)
# 备份配置
self.backup_items = {
"database": {
"name": "数据库",
"paths": ["course_agent.db"],
"enabled": True
},
"vector_store": {
"name": "向量存储",
"paths": ["vector_store"],
"enabled": True
},
"knowledge_base": {
"name": "知识库",
"paths": ["data/knowledge_base"],
"enabled": True
},
"uploads": {
"name": "上传文件",
"paths": ["uploads"],
"enabled": True
},
"logs": {
"name": "日志文件",
"paths": ["logs"],
"enabled": False # 默认不备份日志
}
}
def create_backup(self, backup_name: Optional[str] = None) -> str:
"""创建备份"""
if not backup_name:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"backup_{timestamp}"
backup_path = self.backup_dir / backup_name
backup_path.mkdir(exist_ok=True)
logger.info(f"开始创建备份: {backup_name}")
backup_info = {
"name": backup_name,
"created_at": datetime.now().isoformat(),
"items": {}
}
# 备份每个项目
for item_id, item_config in self.backup_items.items():
if not item_config["enabled"]:
continue
item_backup_path = backup_path / item_id
item_backup_path.mkdir(exist_ok=True)
item_info = self._backup_item(item_id, item_config, item_backup_path)
backup_info["items"][item_id] = item_info
if item_info["status"] == "success":
logger.info(f"{item_config['name']}: {item_info['file_count']} 个文件")
else:
logger.warning(f"⚠️ {item_config['name']}: {item_info['error']}")
# 保存备份信息
info_file = backup_path / "backup_info.json"
with open(info_file, "w", encoding="utf-8") as f:
json.dump(backup_info, f, ensure_ascii=False, indent=2)
# 创建压缩包
tar_path = self._create_tar_archive(backup_path)
# 清理临时目录
shutil.rmtree(backup_path)
logger.info(f"✅ 备份创建完成: {tar_path}")
return str(tar_path)
def _backup_item(self, item_id: str, item_config: Dict, backup_path: Path) -> Dict:
"""备份单个项目"""
item_info = {
"name": item_config["name"],
"paths": item_config["paths"],
"status": "pending",
"file_count": 0,
"total_size": 0
}
try:
file_count = 0
total_size = 0
for path_str in item_config["paths"]:
source_path = Path(path_str)
if not source_path.exists():
logger.warning(f"路径不存在: {source_path}")
continue
if source_path.is_file():
# 备份单个文件
shutil.copy2(source_path, backup_path / source_path.name)
file_count += 1
total_size += source_path.stat().st_size
else:
# 备份目录
for root, dirs, files in os.walk(source_path):
for file in files:
source_file = Path(root) / file
rel_path = source_file.relative_to(source_path)
target_file = backup_path / rel_path
target_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_file, target_file)
file_count += 1
total_size += source_file.stat().st_size
item_info.update({
"status": "success",
"file_count": file_count,
"total_size": total_size
})
except Exception as e:
item_info.update({
"status": "error",
"error": str(e)
})
return item_info
def _create_tar_archive(self, backup_path: Path) -> Path:
"""创建tar.gz压缩包"""
tar_filename = f"{backup_path.name}.tar.gz"
tar_path = self.backup_dir / tar_filename
with tarfile.open(tar_path, "w:gz") as tar:
# 添加备份信息文件
info_file = backup_path / "backup_info.json"
if info_file.exists():
tar.add(info_file, arcname="backup_info.json")
# 添加其他文件
for item_dir in backup_path.iterdir():
if item_dir.is_dir():
tar.add(item_dir, arcname=item_dir.name)
return tar_path
def list_backups(self) -> List[Dict]:
"""列出所有备份"""
backups = []
for item in self.backup_dir.iterdir():
if item.is_file() and item.suffix == ".gz":
backup_info = self._get_backup_info(item)
if backup_info:
backups.append(backup_info)
# 按创建时间排序
backups.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return backups
def _get_backup_info(self, backup_file: Path) -> Optional[Dict]:
"""从备份文件中获取信息"""
try:
with tarfile.open(backup_file, "r:gz") as tar:
# 查找备份信息文件
info_member = None
for member in tar.getmembers():
if member.name == "backup_info.json":
info_member = member
break
if info_member:
# 提取并读取信息文件
info_data = tar.extractfile(info_member)
if info_data:
info = json.load(info_data)
info["filename"] = backup_file.name
info["size"] = backup_file.stat().st_size
return info
except Exception as e:
logger.error(f"读取备份信息失败 {backup_file}: {e}")
return None
def restore_backup(self, backup_filename: str, restore_dir: str = ".") -> bool:
"""恢复备份"""
backup_file = self.backup_dir / backup_filename
if not backup_file.exists():
logger.error(f"备份文件不存在: {backup_filename}")
return False
restore_path = Path(restore_dir)
restore_path.mkdir(parents=True, exist_ok=True)
logger.info(f"开始恢复备份: {backup_filename}")
try:
with tarfile.open(backup_file, "r:gz") as tar:
# 提取所有文件
tar.extractall(restore_path)
logger.info(f"✅ 备份恢复完成到: {restore_path}")
return True
except Exception as e:
logger.error(f"恢复备份失败: {e}")
return False
def cleanup_old_backups(self, keep_days: int = 30) -> int:
"""清理旧备份"""
cutoff_date = datetime.now() - timedelta(days=keep_days)
deleted_count = 0
for backup_info in self.list_backups():
created_at_str = backup_info.get("created_at")
if not created_at_str:
continue
try:
created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
if created_at < cutoff_date:
backup_file = self.backup_dir / backup_info["filename"]
backup_file.unlink()
logger.info(f"删除旧备份: {backup_info['filename']}")
deleted_count += 1
except Exception as e:
logger.error(f"解析备份日期失败: {e}")
return deleted_count
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(description="数据备份工具")
parser.add_argument("action", choices=["create", "list", "restore", "cleanup"],
help="执行的操作")
parser.add_argument("--name", help="备份名称(用于create和restore")
parser.add_argument("--backup-dir", default="./backups", help="备份目录")
parser.add_argument("--restore-dir", default=".", help="恢复目录")
parser.add_argument("--keep-days", type=int, default=30, help="保留备份的天数")
args = parser.parse_args()
backup = DataBackup(args.backup_dir)
if args.action == "create":
backup_path = backup.create_backup(args.name)
print(f"备份创建完成: {backup_path}")
elif args.action == "list":
backups = backup.list_backups()
print(f"\n找到 {len(backups)} 个备份:\n")
for b in backups:
print(f"名称: {b['name']}")
print(f"文件: {b['filename']}")
print(f"大小: {b['size'] / 1024 / 1024:.2f} MB")
print(f"时间: {b['created_at']}")
print(f"项目: {', '.join(b['items'].keys())}")
print("-" * 40)
elif args.action == "restore":
if not args.name:
print("错误: 恢复备份需要指定备份文件名")
sys.exit(1)
success = backup.restore_backup(args.name, args.restore_dir)
if success:
print("✅ 备份恢复成功")
else:
print("❌ 备份恢复失败")
sys.exit(1)
elif args.action == "cleanup":
deleted = backup.cleanup_old_backups(args.keep_days)
print(f"清理了 {deleted} 个旧备份")
if __name__ == "__main__":
main()
+304
View File
@@ -0,0 +1,304 @@
#!/usr/bin/env python3
"""
Docker环境初始化脚本
"""
import os
import sys
import time
import logging
from pathlib import Path
from datetime import datetime
# 添加src目录到Python路径
sys.path.append(str(Path(__file__).parent.parent))
from src.core.database import SessionLocal, create_tables, check_database_connection, get_database_info
from src.core.config import get_settings, is_postgresql_database
from src.models.user import User
from src.models.knowledge_base import KnowledgeBase
from src.models.forum import ForumCategory
from src.services.auth_service import AuthService
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class DockerInitializer:
"""Docker环境初始化"""
def __init__(self):
self.settings = get_settings()
self.session = SessionLocal()
self.auth_service = AuthService(self.session)
def initialize(self):
"""执行初始化"""
logger.info("=" * 60)
logger.info("Docker环境初始化")
logger.info("=" * 60)
# 1. 检查数据库连接
if not self._check_database():
return False
# 2. 创建数据库表
self._create_tables()
# 3. 创建默认管理员用户
self._create_admin_user()
# 4. 创建系统知识库
self._create_system_knowledge_bases()
# 5. 创建论坛分类
self._create_forum_categories()
# 6. 检查数据目录
self._check_data_directories()
logger.info("✅ Docker环境初始化完成")
return True
def _check_database(self):
"""检查数据库连接"""
logger.info("检查数据库连接...")
max_retries = 10
retry_delay = 5
for i in range(max_retries):
try:
if check_database_connection():
db_info = get_database_info()
logger.info(f"✅ 数据库连接成功: {db_info.get('type', 'Unknown')}")
if is_postgresql_database():
logger.info(f" 数据库: {db_info.get('database', 'Unknown')}")
logger.info(f" 版本: {db_info.get('version', 'Unknown')}")
return True
else:
logger.warning(f"数据库连接失败,重试 {i+1}/{max_retries}...")
except Exception as e:
logger.warning(f"数据库连接异常: {e}")
if i < max_retries - 1:
time.sleep(retry_delay)
logger.error("❌ 数据库连接失败,请检查数据库服务")
return False
def _create_tables(self):
"""创建数据库表"""
logger.info("创建数据库表...")
try:
create_tables()
logger.info("✅ 数据库表创建完成")
except Exception as e:
logger.error(f"❌ 创建数据库表失败: {e}")
raise
def _create_admin_user(self):
"""创建默认管理员用户"""
logger.info("创建默认管理员用户...")
try:
# 检查是否已存在管理员用户
admin_user = self.session.query(User).filter(
User.username == "admin"
).first()
if admin_user:
logger.info("管理员用户已存在")
return
# 创建管理员用户
admin_data = {
"username": "admin",
"email": "admin@course-agent.local",
"password": "admin123", # 默认密码,首次登录后应修改
"full_name": "系统管理员",
"is_superuser": True
}
admin_user = self.auth_service.create_user(**admin_data)
if admin_user:
logger.info("✅ 管理员用户创建成功")
logger.info(f" 用户名: {admin_user.username}")
logger.info(f" 邮箱: {admin_user.email}")
logger.info("⚠️ 请尽快修改默认密码")
else:
logger.warning("管理员用户创建失败")
except Exception as e:
logger.error(f"创建管理员用户失败: {e}")
def _create_system_knowledge_bases(self):
"""创建系统知识库"""
logger.info("创建系统知识库...")
system_kbs = [
{
"name": "国土空间法律法规集",
"description": "国土空间规划相关法律法规、政策文件",
"is_system": True,
"is_active": True
},
{
"name": "国土空间规划原理集",
"description": "国土空间规划理论、原理、方法",
"is_system": True,
"is_active": True
},
{
"name": "国土空间规划案例集",
"description": "各级国土空间规划案例",
"is_system": True,
"is_active": True
}
]
created_count = 0
for kb_data in system_kbs:
try:
# 检查是否已存在
existing = self.session.query(KnowledgeBase).filter(
KnowledgeBase.name == kb_data["name"],
KnowledgeBase.is_system == True
).first()
if existing:
continue
# 创建知识库
kb = KnowledgeBase(
user_id=1, # 管理员用户ID
**kb_data
)
self.session.add(kb)
created_count += 1
except Exception as e:
logger.error(f"创建知识库失败 {kb_data['name']}: {e}")
if created_count > 0:
self.session.commit()
logger.info(f"✅ 创建了 {created_count} 个系统知识库")
else:
logger.info("系统知识库已存在")
def _create_forum_categories(self):
"""创建论坛分类"""
logger.info("创建论坛分类...")
categories = [
{
"name": "系统使用优化建议",
"description": "分享系统使用经验,提出改进建议"
},
{
"name": "课程学习反馈",
"description": "交流学习心得,讨论课程内容"
}
]
created_count = 0
for cat_data in categories:
try:
# 检查是否已存在
existing = self.session.query(ForumCategory).filter(
ForumCategory.name == cat_data["name"]
).first()
if existing:
continue
# 创建分类
category = ForumCategory(**cat_data)
self.session.add(category)
created_count += 1
except Exception as e:
logger.error(f"创建论坛分类失败 {cat_data['name']}: {e}")
if created_count > 0:
self.session.commit()
logger.info(f"✅ 创建了 {created_count} 个论坛分类")
else:
logger.info("论坛分类已存在")
def _check_data_directories(self):
"""检查数据目录"""
logger.info("检查数据目录...")
directories = [
self.settings.vector_store_path,
self.settings.upload_dir,
self.settings.knowledge_base_dir,
self.settings.generated_images_dir,
os.path.dirname(self.settings.log_file),
]
for dir_path in directories:
try:
path = Path(dir_path)
path.mkdir(parents=True, exist_ok=True)
# 检查权限
test_file = path / ".test_write"
test_file.touch()
test_file.unlink()
logger.info(f"✅ 目录可访问: {dir_path}")
except Exception as e:
logger.error(f"❌ 目录访问失败 {dir_path}: {e}")
def close(self):
"""关闭资源"""
if self.session:
self.session.close()
def main():
"""主函数"""
initializer = None
try:
initializer = DockerInitializer()
success = initializer.initialize()
if success:
print("\n" + "=" * 60)
print("初始化完成!")
print("=" * 60)
print("\n访问信息:")
print(f" 前端: http://localhost:8001")
print(f" 后端API: http://localhost:8000")
print(f" API文档: http://localhost:8000/docs")
print("\n默认管理员账号:")
print(" 用户名: admin")
print(" 密码: admin123")
print(" ⚠️ 请尽快修改默认密码")
print("\n" + "=" * 60)
else:
print("❌ 初始化失败")
sys.exit(1)
except Exception as e:
print(f"❌ 初始化过程中发生错误: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
if initializer:
initializer.close()
if __name__ == "__main__":
main()
+162
View File
@@ -0,0 +1,162 @@
"""
导入书籍结构到数据库
解析LaTeX文件并导入书籍、章节、节、小节(知识点)
层级结构:书籍 -> Chapter -> Section -> Subsection(知识点)
"""
import sys
import re
from pathlib import Path
# 添加项目根目录到路径
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.orm import Session
from src.core.database import SessionLocal, create_tables
from src.core.config import get_settings
from src.models.book_structure import Book, Chapter, Section, Subsection
from src.services.latex_parser import LaTeXParser
def import_book_structure():
"""导入书籍结构"""
settings = get_settings()
book_dir = Path(settings.book_dir)
# 如果相对路径不存在,尝试从项目根目录查找
if not book_dir.exists():
# 脚本位置: dofile/backend/scripts/import_book_structure.py
# 项目根目录: 向上3级
script_dir = Path(__file__).parent.parent.parent.parent # 项目根目录
book_dir = script_dir / "data" / "book"
if not book_dir.exists():
print(f"错误: 书籍目录不存在")
print(f"尝试的路径1: {settings.book_dir}")
print(f"尝试的路径2: {book_dir}")
return
print(f"使用书籍目录: {book_dir}")
# 创建数据库表
print("创建数据库表...")
create_tables()
# 创建数据库会话
db: Session = SessionLocal()
try:
# 清空旧数据(如果存在)
db.query(Subsection).delete()
db.query(Section).delete()
db.query(Chapter).delete()
db.query(Book).delete()
db.commit()
print("已清空旧的书籍结构数据。")
# 创建书籍
book_title = "国土空间规划:概念、原理、方法"
book_description = "基于LaTeX书籍内容构建的知识图谱系统"
book = Book(title=book_title, description=book_description)
db.add(book)
db.commit()
db.refresh(book)
print(f"已创建书籍: {book.title}")
parser = LaTeXParser(book_dir)
# 解析主文件获取章节顺序
main_tex_path = book_dir / "main.tex"
if not main_tex_path.exists():
print(f"错误: main.tex 文件不存在于 {book_dir}")
return
main_content = main_tex_path.read_text(encoding='utf-8')
chapter_input_re = re.compile(r'^\\input{(chapter\d+)}', re.MULTILINE)
chapter_files_in_order = []
for match in chapter_input_re.finditer(main_content):
chapter_files_in_order.append(f"{match.group(1)}.tex")
if not chapter_files_in_order:
print("未在 main.tex 中找到章节文件引用。")
return
total_chapters = 0
total_sections = 0
total_subsections = 0
# 遍历章节文件并导入
for chapter_idx, chapter_filename in enumerate(chapter_files_in_order):
chapter_file_path = book_dir / chapter_filename
if not chapter_file_path.exists():
print(f"警告: 章节文件 {chapter_file_path} 不存在,跳过。")
continue
print(f"解析LaTeX文件: {chapter_file_path.name}...")
chapter_structure = parser.parse_chapter_file(chapter_file_path)
if not chapter_structure:
print(f"警告: 文件 {chapter_file_path.name} 未解析出任何结构。")
continue
# 每个文件只包含一个 \chapter
parsed_chapter_data = chapter_structure[0]
chapter_obj = Chapter(
book_id=book.id,
chapter_number=parsed_chapter_data["chapter_number"],
title=parsed_chapter_data["title"],
file_path=chapter_file_path.name,
start_line=parsed_chapter_data["start_line"],
end_line=parsed_chapter_data["end_line"],
display_order=parsed_chapter_data["chapter_number"]
)
db.add(chapter_obj)
db.flush() # Flush to get chapter_obj.id
total_chapters += 1
# 导入节(Section
for section_data in parsed_chapter_data.get("sections", []):
section_obj = Section(
chapter_id=chapter_obj.id,
section_number=section_data["section_number"],
title=section_data["title"],
file_path=chapter_file_path.name,
start_line=section_data["start_line"],
end_line=section_data["end_line"],
display_order=section_data["section_number"]
)
db.add(section_obj)
db.flush() # Flush to get section_obj.id
total_sections += 1
# 导入小节(Subsection,作为知识点)
for subsection_data in section_data.get("subsections", []):
subsection_obj = Subsection(
section_id=section_obj.id,
subsection_number=subsection_data["subsection_number"],
title=subsection_data["title"],
file_path=chapter_file_path.name,
start_line=subsection_data["start_line"],
end_line=subsection_data["end_line"],
display_order=subsection_data["subsection_number"]
)
db.add(subsection_obj)
total_subsections += 1
db.commit()
print(f"\n[SUCCESS] 书籍结构导入成功!")
print(f" - 书籍: {book.title}")
print(f" - 章节: {total_chapters}")
print(f" - 节: {total_sections}")
print(f" - 小节(知识点): {total_subsections}")
except Exception as e:
db.rollback()
print(f"[ERROR] 导入失败: {e}")
import traceback
traceback.print_exc()
raise
finally:
db.close()
if __name__ == "__main__":
import_book_structure()
+372
View File
@@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""
服务监控脚本 - 监控多服务架构的健康状态
"""
import os
import sys
import time
import json
import logging
import requests
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from pathlib import Path
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('service_monitor.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class ServiceMonitor:
"""服务监控器"""
def __init__(self):
self.services = {
"backend": {
"name": "FastAPI后端服务",
"url": "http://localhost:8000/health",
"timeout": 5,
"required": True
},
"frontend": {
"name": "Next.js前端服务",
"url": "http://localhost:8001",
"timeout": 5,
"required": True
},
"database": {
"name": "PostgreSQL数据库",
"type": "internal", # 内部服务,通过后端检查
"required": True
},
"vectorizer": {
"name": "向量化服务",
"type": "process", # 进程检查
"process_name": "vectorizer_service.py",
"required": False
}
}
self.check_interval = 30 # 检查间隔(秒)
self.alert_threshold = 3 # 报警阈值(连续失败次数)
self.status_history = []
self.max_history = 100
def check_service(self, service_id: str, service_config: Dict) -> Dict:
"""检查单个服务"""
service_status = {
"service_id": service_id,
"name": service_config["name"],
"timestamp": datetime.now().isoformat(),
"status": "unknown",
"response_time": None,
"error": None
}
try:
start_time = time.time()
if service_config.get("type") == "internal":
# 内部服务,通过后端检查
backend_status = self._check_backend_service(service_id)
service_status.update(backend_status)
elif service_config.get("type") == "process":
# 进程检查
process_status = self._check_process_service(service_config)
service_status.update(process_status)
else:
# HTTP服务检查
response = requests.get(
service_config["url"],
timeout=service_config.get("timeout", 5)
)
response_time = (time.time() - start_time) * 1000 # 毫秒
if response.status_code == 200:
service_status["status"] = "healthy"
service_status["response_time"] = response_time
# 解析响应内容
try:
data = response.json()
service_status["details"] = data
except:
pass
else:
service_status["status"] = "unhealthy"
service_status["error"] = f"HTTP {response.status_code}"
except requests.exceptions.Timeout:
service_status["status"] = "timeout"
service_status["error"] = "请求超时"
except requests.exceptions.ConnectionError:
service_status["status"] = "unreachable"
service_status["error"] = "连接失败"
except Exception as e:
service_status["status"] = "error"
service_status["error"] = str(e)
return service_status
def _check_backend_service(self, service_id: str) -> Dict:
"""检查后端内部服务"""
try:
# 通过后端API检查数据库状态
response = requests.get(
"http://localhost:8000/system/status",
timeout=5
)
if response.status_code == 200:
data = response.json()
if service_id == "database":
db_status = data.get("database", {})
db_type = db_status.get("type", "unknown")
return {
"status": "healthy" if db_type != "unknown" else "unhealthy",
"details": db_status
}
return {"status": "unhealthy", "error": "后端检查失败"}
except Exception as e:
return {"status": "error", "error": str(e)}
def _check_process_service(self, service_config: Dict) -> Dict:
"""检查进程服务"""
process_name = service_config.get("process_name")
if not process_name:
return {"status": "unknown", "error": "未配置进程名"}
try:
# 检查进程是否存在(Linux/Mac)
if sys.platform != "win32":
import subprocess
result = subprocess.run(
["pgrep", "-f", process_name],
capture_output=True,
text=True
)
if result.returncode == 0:
return {"status": "healthy", "details": {"processes": result.stdout.strip().split()}}
else:
return {"status": "stopped", "error": "进程未运行"}
else:
# Windows平台检查
import psutil
process_count = 0
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
cmdline = proc.info.get('cmdline')
if cmdline and process_name in ' '.join(cmdline):
process_count += 1
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
if process_count > 0:
return {"status": "healthy", "details": {"process_count": process_count}}
else:
return {"status": "stopped", "error": "进程未运行"}
except Exception as e:
return {"status": "error", "error": str(e)}
def check_all_services(self) -> Dict:
"""检查所有服务"""
logger.info("开始检查所有服务...")
results = {}
overall_status = "healthy"
failed_services = []
for service_id, service_config in self.services.items():
status = self.check_service(service_id, service_config)
results[service_id] = status
if status["status"] != "healthy":
if service_config.get("required", False):
overall_status = "unhealthy"
failed_services.append(service_id)
logger.warning(f"{service_config['name']}: {status['status']} - {status.get('error', '')}")
else:
logger.info(f"{service_config['name']}: 健康")
# 保存到历史记录
check_result = {
"timestamp": datetime.now().isoformat(),
"overall_status": overall_status,
"services": results,
"failed_services": failed_services
}
self.status_history.append(check_result)
if len(self.status_history) > self.max_history:
self.status_history = self.status_history[-self.max_history:]
# 保存到文件
self._save_status_history()
logger.info(f"检查完成: 总体状态 - {overall_status}")
return check_result
def _save_status_history(self):
"""保存状态历史"""
try:
history_file = Path("service_status_history.json")
# 只保存最近24小时的数据
cutoff_time = datetime.now() - timedelta(hours=24)
recent_history = [
h for h in self.status_history
if datetime.fromisoformat(h["timestamp"].replace("Z", "+00:00")) > cutoff_time
]
with open(history_file, "w", encoding="utf-8") as f:
json.dump(recent_history, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"保存状态历史失败: {e}")
def load_status_history(self) -> List[Dict]:
"""加载状态历史"""
try:
history_file = Path("service_status_history.json")
if history_file.exists():
with open(history_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"加载状态历史失败: {e}")
return []
def get_service_stats(self, hours: int = 24) -> Dict:
"""获取服务统计信息"""
cutoff_time = datetime.now() - timedelta(hours=hours)
relevant_history = [
h for h in self.status_history
if datetime.fromisoformat(h["timestamp"].replace("Z", "+00:00")) > cutoff_time
]
if not relevant_history:
return {}
stats = {
"total_checks": len(relevant_history),
"healthy_checks": sum(1 for h in relevant_history if h["overall_status"] == "healthy"),
"uptime_percentage": 0,
"service_stats": {}
}
if stats["total_checks"] > 0:
stats["uptime_percentage"] = (stats["healthy_checks"] / stats["total_checks"]) * 100
# 计算每个服务的统计
for service_id in self.services.keys():
service_checks = []
for check in relevant_history:
if service_id in check["services"]:
service_checks.append(check["services"][service_id])
if service_checks:
healthy_count = sum(1 for s in service_checks if s["status"] == "healthy")
total_count = len(service_checks)
stats["service_stats"][service_id] = {
"name": self.services[service_id]["name"],
"total_checks": total_count,
"healthy_checks": healthy_count,
"availability": (healthy_count / total_count * 100) if total_count > 0 else 0,
"last_status": service_checks[-1]["status"] if service_checks else "unknown"
}
return stats
def run_monitor(self, duration_minutes: Optional[int] = None):
"""运行监控器"""
logger.info("=" * 60)
logger.info("服务监控器启动")
logger.info("=" * 60)
start_time = time.time()
try:
while True:
# 检查是否达到运行时长限制
if duration_minutes:
elapsed_minutes = (time.time() - start_time) / 60
if elapsed_minutes >= duration_minutes:
logger.info(f"达到运行时长限制 ({duration_minutes} 分钟),停止监控")
break
# 执行检查
self.check_all_services()
# 显示统计信息
stats = self.get_service_stats(hours=1)
if stats:
logger.info(f"最近1小时可用性: {stats.get('uptime_percentage', 0):.1f}%")
# 等待下一次检查
logger.info(f"等待 {self.check_interval} 秒...")
time.sleep(self.check_interval)
except KeyboardInterrupt:
logger.info("收到中断信号,停止监控")
except Exception as e:
logger.error(f"监控器运行异常: {e}")
finally:
logger.info("服务监控器停止")
def main():
"""主函数"""
import argparse
parser = argparse.ArgumentParser(description="服务监控工具")
parser.add_argument("action", choices=["check", "monitor", "stats"],
help="执行的操作")
parser.add_argument("--duration", type=int, default=60,
help="监控持续时间(分钟),仅用于monitor模式")
parser.add_argument("--hours", type=int, default=24,
help="统计小时数,用于stats模式")
args = parser.parse_args()
monitor = ServiceMonitor()
if args.action == "check":
# 单次检查
result = monitor.check_all_services()
print(json.dumps(result, ensure_ascii=False, indent=2))
elif args.action == "monitor":
# 持续监控
monitor.run_monitor(args.duration)
elif args.action == "stats":
# 显示统计信息
stats = monitor.get_service_stats(args.hours)
print(json.dumps(stats, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
向量化服务 - 独立处理文档向量化
"""
import os
import sys
import time
import logging
from pathlib import Path
from datetime import datetime, timedelta
# 添加src目录到Python路径
sys.path.append(str(Path(__file__).parent.parent))
from src.core.database import SessionLocal, get_database_info
from src.models.document import Document
from src.services.document_service import DocumentService
from src.core.config import get_settings
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('vectorizer_service.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# 减少不必要的日志
logging.getLogger("pypdf").setLevel(logging.ERROR)
logging.getLogger("PIL").setLevel(logging.ERROR)
logging.getLogger("chromadb").setLevel(logging.WARNING)
class VectorizerService:
"""向量化服务"""
def __init__(self):
self.settings = get_settings()
self.batch_size = 5 # 每次处理的文档数量
self.check_interval = 60 # 检查间隔(秒)
self.max_retries = 3 # 最大重试次数
self.retry_delay = 30 # 重试延迟(秒)
def get_unprocessed_documents(self, session):
"""获取未处理的文档"""
try:
# 查找未处理的文档
documents = session.query(Document).filter(
Document.is_processed == False,
Document.source_type == "knowledge_base"
).order_by(Document.id).limit(self.batch_size).all()
# 过滤掉文件不存在的文档
valid_documents = []
for doc in documents:
if Path(doc.file_path).exists():
valid_documents.append(doc)
else:
logger.warning(f"文档文件不存在: {doc.file_path}")
# 标记为已处理以避免重复检查
doc.is_processed = True
session.commit()
return valid_documents
except Exception as e:
logger.error(f"获取未处理文档失败: {e}")
return []
def process_document(self, session, document_service, document):
"""处理单个文档"""
logger.info(f"开始处理文档: {document.file_path} (ID: {document.id})")
for retry in range(self.max_retries):
try:
# 使用异步处理
import asyncio
# 创建新的事件循环
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# 处理文档
success = loop.run_until_complete(
document_service.process_document(document.id)
)
loop.close()
if success:
logger.info(f"✅ 文档处理成功: {document.file_path}")
return True
else:
logger.warning(f"❌ 文档处理失败: {document.file_path}")
if retry < self.max_retries - 1:
logger.info(f"等待 {self.retry_delay} 秒后重试...")
time.sleep(self.retry_delay)
except Exception as e:
logger.error(f"文档处理异常 (尝试 {retry + 1}/{self.max_retries}): {e}")
if retry < self.max_retries - 1:
logger.info(f"等待 {self.retry_delay} 秒后重试...")
time.sleep(self.retry_delay)
logger.error(f"文档处理最终失败: {document.file_path}")
return False
def run(self):
"""运行向量化服务"""
logger.info("=" * 60)
logger.info("向量化服务启动")
logger.info("=" * 60)
# 显示数据库信息
db_info = get_database_info()
logger.info(f"数据库: {db_info.get('type', 'Unknown')}")
logger.info(f"批量大小: {self.batch_size}")
logger.info(f"检查间隔: {self.check_interval}")
session = SessionLocal()
document_service = DocumentService(session)
try:
while True:
try:
# 获取未处理的文档
documents = self.get_unprocessed_documents(session)
if documents:
logger.info(f"找到 {len(documents)} 个待处理文档")
success_count = 0
for doc in documents:
if self.process_document(session, document_service, doc):
success_count += 1
logger.info(f"批量处理完成: {success_count}/{len(documents)} 成功")
else:
logger.info("没有待处理的文档,等待中...")
# 等待下一次检查
time.sleep(self.check_interval)
except KeyboardInterrupt:
logger.info("收到中断信号,停止服务")
break
except Exception as e:
logger.error(f"服务运行异常: {e}")
logger.info(f"等待 {self.check_interval} 秒后继续...")
time.sleep(self.check_interval)
finally:
session.close()
logger.info("向量化服务停止")
def main():
"""主函数"""
# 检查环境
if not os.path.exists("vectorizer_service.log"):
with open("vectorizer_service.log", "w") as f:
f.write(f"向量化服务日志 - 启动时间: {datetime.now()}\n")
# 运行服务
service = VectorizerService()
service.run()
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
# 国土空间规划课程智能体后端服务
+7
View File
@@ -0,0 +1,7 @@
# API路由模块
+284
View File
@@ -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)}"
)
+221
View File
@@ -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
+714
View File
@@ -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)
+232
View File
@@ -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)}"
)
+332
View File
@@ -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)}"
)
+225
View File
@@ -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 "",
)
+411
View File
@@ -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)}")
+809
View File
@@ -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)}"
)
+7
View File
@@ -0,0 +1,7 @@
# 核心配置模块
+161
View File
@@ -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"
+209
View File
@@ -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
+84
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# LangGraph工作流模块
+212
View File
@@ -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
+74
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# 大模型集成模块
+108
View File
@@ -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()
+29
View File
@@ -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",
]
+104
View File
@@ -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}')>"
+49
View File
@@ -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}')>"
+40
View File
@@ -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})>"
+62
View File
@@ -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})>"
+50
View File
@@ -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")
+34
View File
@@ -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}')>"
+107
View File
@@ -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="模式描述")
+29
View File
@@ -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})>"
+41
View File
@@ -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}')>"
+7
View File
@@ -0,0 +1,7 @@
# RAG系统模块
+209
View File
@@ -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
)
+129
View File
@@ -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)
+43
View File
@@ -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
+69
View File
@@ -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
+27
View File
@@ -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}")
])
+105
View File
@@ -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
+82
View File
@@ -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)
+41
View File
@@ -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)
+98
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
# 业务逻辑服务模块
+215
View File
@@ -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)}")
+202
View File
@@ -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
+127
View File
@@ -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
+326
View File
@@ -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": "横版,适合横幅和全景图"}
]
+4358
View File
File diff suppressed because it is too large Load Diff