feat: v1.2.0 - 图片去重与管理、微信机器人优化、搜索设置可配置
主要功能: - 图片上传时 OCR 内容去重(3个上传端点统一使用公共函数 _check_ocr_duplicate) - 图片管理 Tab:展示所有图片、手动删除、一键去重 - 搜索结果详情弹窗增加删除按钮(带确认弹窗) - 图片管理卡片点击查看详情(复用 showOcrDetailModal) - 搜索限制和 LLM 批量判断数量可通过网站设置 - MiniMax API 调用添加 reasoning_split=True - 企业微信机器人:WebSocket 长连接、图片搜索、配置化搜索数量 - 版本号升级至 1.2.0
This commit is contained in:
215
app/api/v1/import_export.py
Normal file
215
app/api/v1/import_export.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
导入导出 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,
|
||||
}
|
||||
Reference in New Issue
Block a user