主要功能: - 图片上传时 OCR 内容去重(3个上传端点统一使用公共函数 _check_ocr_duplicate) - 图片管理 Tab:展示所有图片、手动删除、一键去重 - 搜索结果详情弹窗增加删除按钮(带确认弹窗) - 图片管理卡片点击查看详情(复用 showOcrDetailModal) - 搜索限制和 LLM 批量判断数量可通过网站设置 - MiniMax API 调用添加 reasoning_split=True - 企业微信机器人:WebSocket 长连接、图片搜索、配置化搜索数量 - 版本号升级至 1.2.0
216 lines
6.9 KiB
Python
216 lines
6.9 KiB
Python
"""
|
||
导入导出 API 路由
|
||
提供文件导入和知识库导出接口
|
||
支持 .md / .txt / .docx 格式
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database import get_db
|
||
from app.services.import_service import ImportService
|
||
|
||
router = APIRouter()
|
||
|
||
# 支持的导入文件格式
|
||
SUPPORTED_EXTENSIONS = (".md", ".txt", ".docx")
|
||
|
||
|
||
@router.post("/file", summary="从文件导入知识")
|
||
async def import_from_file(
|
||
file: UploadFile = File(..., description="上传文件(.md / .txt / .docx)"),
|
||
course_name: Optional[str] = Form(None, description="课程名称"),
|
||
teacher_name: Optional[str] = Form(None, description="讲师名称"),
|
||
live_date: Optional[str] = Form(None, description="直播日期 (YYYY-MM-DD)"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
上传文件并导入到知识库。
|
||
|
||
- Markdown (.md):解析 frontmatter 元数据,按 ## 标题分页
|
||
- 纯文本 (.txt):整体作为一个页面
|
||
- Word 文档 (.docx):提取文本,按标题分页
|
||
- 导入后自动分块、生成嵌入向量、建立全文索引
|
||
"""
|
||
# 验证文件类型
|
||
if not file.filename:
|
||
raise HTTPException(status_code=400, detail="文件名不能为空")
|
||
|
||
suffix = os.path.splitext(file.filename)[1].lower()
|
||
if suffix not in SUPPORTED_EXTENSIONS:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"仅支持 {', '.join(SUPPORTED_EXTENSIONS)} 文件",
|
||
)
|
||
|
||
# 保存上传文件到 transcripts 目录
|
||
dest_dir = settings.transcripts_dir
|
||
dest_path = dest_dir / file.filename
|
||
|
||
# 如果文件已存在,添加序号
|
||
counter = 1
|
||
original_stem = os.path.splitext(file.filename)[0]
|
||
while dest_path.exists():
|
||
dest_path = dest_dir / f"{original_stem}_{counter}{suffix}"
|
||
counter += 1
|
||
|
||
# 写入文件
|
||
content = await file.read()
|
||
dest_path.write_bytes(content)
|
||
|
||
# 执行导入
|
||
service = ImportService(db)
|
||
try:
|
||
result = await service.import_file(
|
||
file_path=str(dest_path),
|
||
course_name=course_name,
|
||
teacher_name=teacher_name,
|
||
live_date=live_date,
|
||
)
|
||
return {
|
||
"message": "导入成功",
|
||
"file": str(dest_path),
|
||
**result,
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
|
||
|
||
|
||
@router.post("/directory", summary="从目录批量导入")
|
||
async def import_from_directory(
|
||
directory: str = Form(..., description="数据目录中的子目录名(相对于 transcripts 目录)"),
|
||
course_name: Optional[str] = Form(None, description="课程名称"),
|
||
teacher_name: Optional[str] = Form(None, description="讲师名称"),
|
||
live_date: Optional[str] = Form(None, description="直播日期"),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
批量导入指定目录下的所有文件(.md / .txt / .docx)。
|
||
"""
|
||
target_dir = settings.transcripts_dir / directory
|
||
if not target_dir.exists():
|
||
raise HTTPException(status_code=404, detail=f"目录不存在: {directory}")
|
||
|
||
service = ImportService(db)
|
||
total_pages = 0
|
||
total_chunks = 0
|
||
files_processed = 0
|
||
errors: list[str] = []
|
||
|
||
for filepath in sorted(target_dir.iterdir()):
|
||
if filepath.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
||
continue
|
||
|
||
try:
|
||
result = await service.import_file(
|
||
file_path=str(filepath),
|
||
course_name=course_name,
|
||
teacher_name=teacher_name,
|
||
live_date=live_date,
|
||
)
|
||
total_pages += result["pages"]
|
||
total_chunks += result["chunks"]
|
||
files_processed += 1
|
||
except Exception as e:
|
||
errors.append(f"{filepath.name}: {str(e)}")
|
||
|
||
return {
|
||
"message": "批量导入完成",
|
||
"files_processed": files_processed,
|
||
"total_pages": total_pages,
|
||
"total_chunks": total_chunks,
|
||
"errors": errors if errors else None,
|
||
}
|
||
|
||
|
||
@router.get("/export", summary="导出知识库")
|
||
async def export_knowledge_base(
|
||
format: str = "json",
|
||
course_name: Optional[str] = None,
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""
|
||
导出知识库内容。
|
||
|
||
Args:
|
||
format: 导出格式,支持 json / markdown
|
||
course_name: 按课程名称过滤导出
|
||
"""
|
||
from sqlalchemy import text
|
||
|
||
query = text("""
|
||
SELECT id, title, content, source_file, course_name, teacher_name,
|
||
live_date, page_number, metadata_json, created_at
|
||
FROM knowledge_pages
|
||
WHERE (:course_name IS NULL OR course_name = :course_name)
|
||
ORDER BY created_at DESC
|
||
""")
|
||
result = await db.execute(query, {"course_name": course_name})
|
||
pages = result.fetchall()
|
||
|
||
if format == "markdown":
|
||
# 导出为 Markdown 文件
|
||
lines = []
|
||
for row in pages:
|
||
lines.append(f"# {row.title}")
|
||
lines.append("")
|
||
meta_parts = []
|
||
if row.course_name:
|
||
meta_parts.append(f"课程: {row.course_name}")
|
||
if row.teacher_name:
|
||
meta_parts.append(f"讲师: {row.teacher_name}")
|
||
if row.live_date:
|
||
meta_parts.append(f"日期: {row.live_date}")
|
||
if meta_parts:
|
||
lines.append(" | ".join(meta_parts))
|
||
lines.append("")
|
||
lines.append(row.content)
|
||
lines.append("")
|
||
lines.append("---")
|
||
lines.append("")
|
||
|
||
export_content = "\n".join(lines)
|
||
ext = "md"
|
||
else:
|
||
# 导出为 JSON
|
||
pages_data = []
|
||
for row in pages:
|
||
pages_data.append({
|
||
"id": row.id,
|
||
"title": row.title,
|
||
"content": row.content,
|
||
"source_file": row.source_file,
|
||
"course_name": row.course_name,
|
||
"teacher_name": row.teacher_name,
|
||
"live_date": row.live_date,
|
||
"page_number": row.page_number,
|
||
"metadata_json": row.metadata_json,
|
||
"created_at": str(row.created_at),
|
||
})
|
||
export_content = json.dumps(pages_data, ensure_ascii=False, indent=2)
|
||
ext = "json"
|
||
|
||
# 保存到 exports 目录
|
||
export_dir = settings.exports_dir
|
||
export_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
filename = f"export_{timestamp}.{ext}"
|
||
export_path = export_dir / filename
|
||
export_path.write_text(export_content, encoding="utf-8")
|
||
|
||
return {
|
||
"message": "导出成功",
|
||
"file": filename,
|
||
"pages": len(pages),
|
||
"format": format,
|
||
}
|