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:
EduBrain Dev
2026-04-13 22:25:08 +08:00
commit b17786b57b
56 changed files with 9300 additions and 0 deletions

3
app/api/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
API 路由包
"""

3
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
API v1 路由包
"""

565
app/api/v1/images.py Normal file
View File

@@ -0,0 +1,565 @@
"""
图片 OCR API 路由
OCR 识别 + BM25 倒排索引搜索
"""
from __future__ import annotations
import asyncio
import base64
import json
import logging
import os
import time
from collections import defaultdict
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, UploadFile, Form, Query
from fastapi.responses import StreamingResponse, Response
from pydantic import BaseModel
from sqlalchemy import select, func, text, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.models.base import OCRImage
from app.services.ocr_service import OCRService
from app.services.search_engine import BM25Index, tokenize
from app.services.llm_service import LLMService
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
# 全局 BM25 索引实例(在 main.py 中初始化)
bm25_index: Optional[BM25Index] = None
def _check_image_ext(filename: str) -> str:
suffix = os.path.splitext(filename)[1].lower()
if suffix not in ALLOWED_EXTENSIONS:
raise HTTPException(status_code=400, detail=f"不支持的图片格式: {suffix}")
return suffix
def _save_ocr_result(record: OCRImage, ocr_result, db: AsyncSession):
"""保存 OCR 识别结果到数据库"""
record.ocr_text = ocr_result.text
record.confidence = ocr_result.confidence
record.provider = ocr_result.provider
record.status = "completed"
# 保留 keywords如果有
if hasattr(ocr_result, 'keywords') and ocr_result.keywords:
record.tags = json.dumps(ocr_result.keywords, ensure_ascii=False)
def _add_to_index(record: OCRImage):
"""将 OCR 结果添加到 BM25 索引"""
if bm25_index is None or not record.ocr_text:
return
# 索引内容 = OCR 文本(权重最高)
index_text = record.ocr_text
# 如果有 tags/story_summary 也加入索引
if record.tags:
try:
tags = json.loads(record.tags)
if tags:
index_text += "\n" + " ".join(tags)
except Exception:
pass
if record.story_summary:
index_text += "\n" + record.story_summary
bm25_index.add_document(
doc_id=record.id,
text=index_text,
metadata={
"file_path": record.file_path,
"tags": json.loads(record.tags) if record.tags else [],
"story_summary": record.story_summary or "",
"confidence": record.confidence,
"provider": record.provider,
"created_at": str(record.created_at),
},
)
async def _check_ocr_duplicate(ocr_text: str, db: AsyncSession) -> Optional[int]:
"""检查 OCR 文本是否已存在,返回已存在记录的 ID 或 None"""
if not ocr_text:
return None
dup_query = select(OCRImage.id).where(
OCRImage.ocr_text == ocr_text,
OCRImage.status == "completed",
).limit(1)
dup_result = await db.execute(dup_query)
return dup_result.scalar_one_or_none()
# ─── 上传 ───
@router.post("/upload", summary="上传图片")
async def upload_image(file: UploadFile, db: AsyncSession = Depends(get_db)):
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
_check_image_ext(file.filename)
image_record = OCRImage(file_path=file.filename, status="pending")
db.add(image_record)
await db.flush()
await db.refresh(image_record)
return {"id": image_record.id, "file_path": image_record.file_path, "original_filename": file.filename, "status": image_record.status, "created_at": str(image_record.created_at)}
# ─── 单文件识别 ───
@router.post("/{image_id}/recognize", summary="识别图片文字")
async def recognize_image(image_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(OCRImage).where(OCRImage.id == image_id))
image_record = result.scalar_one_or_none()
if image_record is None:
raise HTTPException(status_code=404, detail="图片记录不存在")
if not os.path.exists(image_record.file_path):
raise HTTPException(status_code=404, detail=f"图片文件不存在: {image_record.file_path}")
image_record.status = "processing"
await db.flush()
try:
ocr_result = await OCRService.recognize(image_record.file_path)
_save_ocr_result(image_record, ocr_result, db)
await db.flush()
await db.refresh(image_record)
_add_to_index(image_record)
return {
"id": image_record.id,
"file_path": image_record.file_path,
"ocr_text": image_record.ocr_text,
"tags": json.loads(image_record.tags) if image_record.tags else [],
"confidence": image_record.confidence,
"provider": image_record.provider,
"status": image_record.status,
"block_count": len(ocr_result.blocks),
}
except Exception as e:
image_record.status = "failed"
image_record.error_message = str(e)
await db.flush()
raise HTTPException(status_code=500, detail=f"OCR 识别失败: {str(e)}")
# ─── 批量识别 ───
@router.post("/batch-recognize", summary="批量上传并识别图片")
async def batch_recognize(files: List[UploadFile], db: AsyncSession = Depends(get_db)):
results = []
for file in files:
if not file.filename:
results.append({"filename": "unknown", "status": "error", "message": "文件名为空"})
continue
suffix = _check_image_ext(file.filename)
dest_path = settings.images_dir / file.filename
counter = 1
stem = os.path.splitext(file.filename)[0]
while dest_path.exists():
dest_path = settings.images_dir / f"{stem}_{counter}{suffix}"
counter += 1
content = await file.read()
dest_path.write_bytes(content)
image_record = OCRImage(file_path=str(dest_path), status="processing")
db.add(image_record)
await db.flush()
try:
ocr_result = await OCRService.recognize(str(dest_path))
# OCR 内容去重
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
if existing_id is not None:
# 已存在相同内容,回滚当前记录,删除图片文件
await db.delete(image_record)
await db.flush()
try:
os.unlink(dest_path)
except OSError:
pass
results.append({
"filename": file.filename,
"file_path": str(dest_path),
"status": "duplicate",
"duplicate_of": existing_id,
"ocr_text": ocr_result.text,
})
continue
_save_ocr_result(image_record, ocr_result, db)
await db.flush()
await db.refresh(image_record)
_add_to_index(image_record)
results.append({
"id": image_record.id, "filename": file.filename,
"file_path": str(dest_path), "status": "completed",
"ocr_text": ocr_result.text,
"tags": json.loads(image_record.tags) if image_record.tags else [],
"confidence": ocr_result.confidence, "provider": ocr_result.provider,
"block_count": len(ocr_result.blocks),
})
except Exception as e:
image_record.status = "failed"
image_record.error_message = str(e)
await db.flush()
results.append({"id": image_record.id, "filename": file.filename, "file_path": str(dest_path), "status": "failed", "message": str(e)})
return {"total": len(results), "success": sum(1 for r in results if r["status"] == "completed"), "failed": sum(1 for r in results if r["status"] == "failed"), "duplicates": sum(1 for r in results if r["status"] == "duplicate"), "results": results}
# ─── 服务器路径导入 ───
class PathImportRequest(BaseModel):
paths: List[str]
recursive: bool = False
@router.post("/import-paths", summary="从服务器路径批量导入图片")
async def import_from_paths(data: PathImportRequest, db: AsyncSession = Depends(get_db)):
all_paths = []
for p in data.paths:
if os.path.isfile(p):
if os.path.splitext(p)[1].lower() in ALLOWED_EXTENSIONS:
all_paths.append(p)
elif os.path.isdir(p):
walk = os.walk(p) if data.recursive else [(p, [], os.listdir(p))]
for root, dirs, files in walk:
for f in sorted(files):
fp = os.path.join(root, f)
if os.path.isfile(fp) and os.path.splitext(f)[1].lower() in ALLOWED_EXTENSIONS:
all_paths.append(fp)
if not all_paths:
return {"total": 0, "success": 0, "failed": 0, "message": "未找到图片文件", "results": []}
results = []
for file_path in all_paths:
image_record = OCRImage(file_path=file_path, status="processing")
db.add(image_record)
await db.flush()
try:
ocr_result = await OCRService.recognize(file_path)
# OCR 内容去重
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
if existing_id is not None:
await db.delete(image_record)
await db.flush()
results.append({"id": image_record.id, "filename": os.path.basename(file_path), "file_path": file_path, "status": "duplicate", "duplicate_of": existing_id, "ocr_text": ocr_result.text})
continue
_save_ocr_result(image_record, ocr_result, db)
await db.flush()
await db.refresh(image_record)
_add_to_index(image_record)
results.append({"id": image_record.id, "filename": os.path.basename(file_path), "file_path": file_path, "status": "completed", "ocr_text": ocr_result.text, "tags": json.loads(image_record.tags) if image_record.tags else [], "confidence": ocr_result.confidence, "provider": ocr_result.provider})
except Exception as e:
image_record.status = "failed"
image_record.error_message = str(e)
await db.flush()
results.append({"id": image_record.id, "filename": os.path.basename(file_path), "file_path": file_path, "status": "failed", "message": str(e)})
return {"total": len(results), "success": sum(1 for r in results if r["status"] == "completed"), "failed": sum(1 for r in results if r["status"] == "failed"), "results": results}
# ─── 一步上传+识别 ───
@router.post("/recognize-direct", summary="直接上传并识别图片")
async def recognize_direct(file: UploadFile, db: AsyncSession = Depends(get_db)):
if not file.filename:
raise HTTPException(status_code=400, detail="文件名不能为空")
suffix = _check_image_ext(file.filename)
dest_path = settings.images_dir / file.filename
counter = 1
stem = os.path.splitext(file.filename)[0]
while dest_path.exists():
dest_path = settings.images_dir / f"{stem}_{counter}{suffix}"
counter += 1
content = await file.read()
dest_path.write_bytes(content)
image_record = OCRImage(file_path=str(dest_path), status="processing")
db.add(image_record)
await db.flush()
try:
ocr_result = await OCRService.recognize(str(dest_path))
# OCR 内容去重
existing_id = await _check_ocr_duplicate(ocr_result.text, db)
if existing_id is not None:
await db.delete(image_record)
await db.flush()
try:
os.unlink(dest_path)
except OSError:
pass
return {
"id": image_record.id, "file_path": str(dest_path),
"original_filename": file.filename,
"status": "duplicate", "duplicate_of": existing_id,
"ocr_text": ocr_result.text,
}
_save_ocr_result(image_record, ocr_result, db)
await db.flush()
await db.refresh(image_record)
_add_to_index(image_record)
return {
"id": image_record.id, "file_path": image_record.file_path,
"original_filename": file.filename,
"ocr_text": image_record.ocr_text,
"tags": json.loads(image_record.tags) if image_record.tags else [],
"confidence": image_record.confidence, "provider": image_record.provider,
"status": image_record.status,
"blocks": [{"text": b.text, "bbox": b.bbox, "confidence": b.confidence} for b in ocr_result.blocks],
}
except Exception as e:
image_record.status = "failed"
image_record.error_message = str(e)
await db.flush()
raise HTTPException(status_code=500, detail=f"OCR 识别失败: {str(e)}")
# ─── LLM 精排搜索SSE 流式) ───
@router.get("/search", summary="AI 搜索图片(流式)")
async def search_images(
keyword: str,
limit: int = Query(5, ge=1, le=100),
sort: str = Query("time_desc"),
db: AsyncSession = Depends(get_db),
):
"""
AI 搜索图片SSE 流式返回)。
1. 从 DB 取所有图片,按指定排序
2. 每批 5 条发给 Qwen3-8B 判断
3. 并发池最多 10 个请求
4. 匹配结果实时 SSE 推送
5. 找够 limit 个或遍历完 DB 后结束
"""
async def event_stream():
found = 0
offset = 0
batch_size = settings.JUDGE_BATCH_SIZE
max_concurrent = 10
semaphore = asyncio.Semaphore(max_concurrent)
# 发送开始事件
yield f"data: {json.dumps({'type': 'start', 'keyword': keyword, 'limit': limit}, ensure_ascii=False)}\n\n"
# 先获取总记录数
count_result = await db.execute(
select(func.count(OCRImage.id)).where(OCRImage.status == "completed")
)
total_records = count_result.scalar() or 0
if total_records == 0:
yield f"data: {json.dumps({'type': 'done', 'total_found': 0, 'total_checked': 0}, ensure_ascii=False)}\n\n"
return
# 排序
order_clause = OCRImage.created_at.desc()
if sort == "time_asc":
order_clause = OCRImage.created_at.asc()
elif sort == "random":
order_clause = text("RANDOM()")
# 用于收集结果的队列
result_queue = asyncio.Queue()
checked_count = 0
async def process_batch(batch_items: list, batch_offset: int):
"""处理一批图片:发给 LLM 判断"""
nonlocal checked_count
async with semaphore:
articles = [{"id": item.id, "text": item.ocr_text or ""} for item in batch_items]
matched_ids = await LLMService.judge_batch(keyword, articles)
checked_count += len(batch_items)
# 发送进度
progress_data = {
"type": "progress",
"checked": checked_count,
"total": total_records,
"found": found,
}
await result_queue.put(progress_data)
# 如果有匹配且未达上限,发送结果
for match_id in matched_ids:
if found >= limit:
break
item = next((i for i in batch_items if i.id == match_id), None)
if item:
file_name = item.file_path.split('/')[-1] if item.file_path else ''
# 读取图片并转为 base64用于发送到企业微信等
image_base64 = ""
if file_name:
img_path = settings.images_dir / file_name
if img_path.exists():
image_base64 = base64.b64encode(img_path.read_bytes()).decode()
result_data = {
"type": "result",
"id": item.id,
"file_path": item.file_path,
"image_url": file_name,
"image_base64": image_base64,
"ocr_text": item.ocr_text,
"ocr_text_preview": (item.ocr_text[:150] + "...") if item.ocr_text and len(item.ocr_text) > 150 else item.ocr_text,
"tags": json.loads(item.tags) if item.tags else [],
"confidence": item.confidence,
"provider": item.provider,
"created_at": str(item.created_at),
}
await result_queue.put(result_data)
# 启动所有批次的异步任务
tasks = []
while offset < total_records:
query = select(OCRImage).where(OCRImage.status == "completed").order_by(order_clause).offset(offset).limit(batch_size)
result = await db.execute(query)
batch_items = result.scalars().all()
if not batch_items:
break
tasks.append(asyncio.create_task(process_batch(batch_items, offset)))
offset += batch_size
# 等待所有任务完成
async def wait_all():
await asyncio.gather(*tasks)
await result_queue.put({"type": "all_done"})
asyncio.create_task(wait_all())
# 从队列读取结果并 yield
while True:
try:
data = await asyncio.wait_for(result_queue.get(), timeout=300)
except asyncio.TimeoutError:
break
if data.get("type") == "all_done":
break
if data.get("type") == "result":
found += 1
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
if found >= limit:
# 找够了,发送结束事件
yield f"data: {json.dumps({'type': 'done', 'total_found': found, 'total_checked': checked_count}, ensure_ascii=False)}\n\n"
return
elif data.get("type") == "progress":
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
# 发送结束事件
yield f"data: {json.dumps({'type': 'done', 'total_found': found, 'total_checked': checked_count}, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ─── 删除单张图片 ───
@router.delete("/{image_id}", summary="删除单张图片", status_code=204)
async def delete_image(image_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(OCRImage).where(OCRImage.id == image_id))
image_record = result.scalar_one_or_none()
if image_record is None:
raise HTTPException(status_code=404, detail="图片记录不存在")
# 从 BM25 索引移除
if bm25_index is not None:
bm25_index.remove_document(image_record.id)
# 删除磁盘上的图片文件
try:
os.unlink(image_record.file_path)
except OSError:
pass
# 删除数据库记录
await db.delete(image_record)
await db.flush()
return Response(status_code=204)
# ─── 其他接口 ───
@router.get("/{image_id}", summary="获取图片识别结果")
async def get_image_result(image_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(OCRImage).where(OCRImage.id == image_id))
image_record = result.scalar_one_or_none()
if image_record is None:
raise HTTPException(status_code=404, detail="图片记录不存在")
return image_record.to_dict()
@router.get("", summary="获取图片列表")
async def list_images(page: int = 1, page_size: int = 20, status: Optional[str] = None, db: AsyncSession = Depends(get_db)):
query = select(OCRImage)
count_query = select(func.count(OCRImage.id))
if status:
query = query.where(OCRImage.status == status)
count_query = count_query.where(OCRImage.status == status)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(OCRImage.created_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
items = result.scalars().all()
return {"total": total, "page": page, "page_size": page_size, "items": [item.to_dict() for item in items]}
# ─── 一键去重 ───
@router.post("/dedup", summary="一键去重:删除 OCR 内容重复的图片")
async def dedup_images(db: AsyncSession = Depends(get_db)):
# 查询所有 status='completed' 且 ocr_text 不为空的记录
query = select(OCRImage).where(
OCRImage.status == "completed",
OCRImage.ocr_text.isnot(None),
OCRImage.ocr_text != "",
).order_by(OCRImage.created_at.asc())
result = await db.execute(query)
all_records = result.scalars().all()
total_checked = len(all_records)
# 按 ocr_text 分组
groups: dict[str, list[OCRImage]] = defaultdict(list)
for record in all_records:
groups[record.ocr_text].append(record)
duplicates_found = 0
deleted = 0
kept = 0
for ocr_text, group in groups.items():
if len(group) <= 1:
kept += 1
continue
# 保留 created_at 最早的一条(已按 asc 排序,第一条即最早)
duplicates_found += len(group) - 1
kept += 1
for record in group[1:]:
# 从 BM25 索引移除
if bm25_index is not None:
bm25_index.remove_document(record.id)
# 删除磁盘上的图片文件
try:
os.unlink(record.file_path)
except OSError:
pass
# 删除数据库记录
await db.delete(record)
deleted += 1
await db.flush()
return {
"total_checked": total_checked,
"duplicates_found": duplicates_found,
"deleted": deleted,
"kept": kept,
}

215
app/api/v1/import_export.py Normal file
View 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,
}

112
app/api/v1/pages.py Normal file
View File

@@ -0,0 +1,112 @@
"""
知识页面 API 路由
提供知识页面的 CRUD 接口
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.page import PageCreate, PageDetailResponse, PageListResponse, PageResponse, PageUpdate
from app.services.page_service import PageService
router = APIRouter()
@router.get("", response_model=PageListResponse, summary="获取知识页面列表")
async def list_pages(
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
course_name: Optional[str] = Query(None, description="按课程名称过滤"),
teacher_name: Optional[str] = Query(None, description="按讲师名称过滤"),
keyword: Optional[str] = Query(None, description="关键词搜索"),
db: AsyncSession = Depends(get_db),
):
"""分页查询知识页面列表,支持按课程/讲师/关键词过滤"""
service = PageService(db)
result = await service.list_pages(
page=page,
page_size=page_size,
course_name=course_name,
teacher_name=teacher_name,
keyword=keyword,
)
return PageListResponse(
total=result["total"],
page=result["page"],
page_size=result["page_size"],
items=[PageResponse.model_validate(p) for p in result["items"]],
)
@router.get("/{page_id}", response_model=PageDetailResponse, summary="获取知识页面详情")
async def get_page(
page_id: int,
db: AsyncSession = Depends(get_db),
):
"""根据 ID 获取知识页面详情,包含关联的分块信息"""
service = PageService(db)
page = await service.get_page(page_id)
if page is None:
raise HTTPException(status_code=404, detail="知识页面不存在")
chunks = await service.get_page_chunks(page_id)
return PageDetailResponse(
**page.to_dict(),
chunks=[c.to_dict() for c in chunks],
)
@router.post("", response_model=PageResponse, status_code=201, summary="创建知识页面")
async def create_page(
data: PageCreate,
db: AsyncSession = Depends(get_db),
):
"""创建新的知识页面"""
service = PageService(db)
page = await service.create_page(data)
return PageResponse.model_validate(page)
@router.put("/{page_id}", response_model=PageResponse, summary="更新知识页面")
async def update_page(
page_id: int,
data: PageUpdate,
db: AsyncSession = Depends(get_db),
):
"""更新知识页面信息"""
service = PageService(db)
page = await service.update_page(page_id, data)
if page is None:
raise HTTPException(status_code=404, detail="知识页面不存在")
return PageResponse.model_validate(page)
@router.delete("/{page_id}", status_code=204, summary="删除知识页面")
async def delete_page(
page_id: int,
db: AsyncSession = Depends(get_db),
):
"""删除知识页面及其关联的所有分块"""
service = PageService(db)
success = await service.delete_page(page_id)
if not success:
raise HTTPException(status_code=404, detail="知识页面不存在")
@router.post("/{page_id}/reindex", summary="重新索引知识页面")
async def reindex_page(
page_id: int,
db: AsyncSession = Depends(get_db),
):
"""重新对知识页面进行分块和向量化(页面内容更新后使用)"""
service = PageService(db)
try:
result = await service.reindex_page(page_id)
return result
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))

30
app/api/v1/search.py Normal file
View File

@@ -0,0 +1,30 @@
"""
语义搜索 API 路由
提供向量搜索 + 全文搜索混合查询接口
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas.search import SearchRequest, SearchResponse
from app.services.search_service import SearchService
router = APIRouter()
@router.post("", response_model=SearchResponse, summary="语义搜索")
async def semantic_search(
request: SearchRequest,
db: AsyncSession = Depends(get_db),
):
"""
对知识库进行语义搜索。
支持向量相似度搜索和中文全文搜索的混合排序。
可按课程名称、讲师名称、直播日期范围过滤结果。
"""
service = SearchService(db)
return await service.search(request)

223
app/api/v1/settings.py Normal file
View File

@@ -0,0 +1,223 @@
"""
系统设置 API 路由
提供运行时配置的读取和修改接口
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.config import settings
router = APIRouter()
class SettingsResponse(BaseModel):
"""设置响应(隐藏敏感信息的中间位)"""
embedding_provider: str
embedding_model: str
embedding_dimensions: int
ocr_provider: str
llm_provider: str
minimax_base_url: str
minimax_embedding_model: str
minimax_chat_model: str
deepseek_base_url: str
deepseek_ocr_model: str
openai_base_url: str | None
chunk_size: int
chunk_overlap: int
search_limit: int
judge_batch_size: int
# API Key 只返回是否已配置(不返回实际值)
has_minimax_key: bool
has_deepseek_key: bool
has_openai_key: bool
has_zhipu_key: bool
has_dashscope_key: bool
has_aliyun_ocr: bool
has_tencent_ocr: bool
@router.get("", response_model=SettingsResponse, summary="获取当前设置")
async def get_settings():
"""获取当前系统设置API Key 脱敏)"""
return SettingsResponse(
embedding_provider=settings.EMBEDDING_PROVIDER.value,
embedding_model=settings.EMBEDDING_MODEL,
embedding_dimensions=settings.EMBEDDING_DIMENSIONS,
ocr_provider=settings.OCR_PROVIDER.value,
llm_provider=settings.LLM_PROVIDER,
minimax_base_url=settings.MINIMAX_BASE_URL,
minimax_embedding_model=settings.MINIMAX_EMBEDDING_MODEL,
minimax_chat_model=settings.MINIMAX_CHAT_MODEL,
deepseek_base_url=settings.DEEPSEEK_BASE_URL,
deepseek_ocr_model=settings.DEEPSEEK_OCR_MODEL,
openai_base_url=settings.OPENAI_BASE_URL,
chunk_size=settings.CHUNK_SIZE,
chunk_overlap=settings.CHUNK_OVERLAP,
search_limit=settings.SEARCH_LIMIT,
judge_batch_size=settings.JUDGE_BATCH_SIZE,
has_minimax_key=bool(settings.MINIMAX_API_KEY),
has_deepseek_key=bool(settings.DEEPSEEK_API_KEY),
has_openai_key=bool(settings.OPENAI_API_KEY),
has_zhipu_key=bool(settings.ZHIPU_API_KEY),
has_dashscope_key=bool(settings.DASHSCOPE_API_KEY),
has_aliyun_ocr=bool(settings.ALIYUN_OCR_ACCESS_KEY),
has_tencent_ocr=bool(settings.TENCENT_OCR_SECRET_ID),
)
class SettingsUpdate(BaseModel):
"""设置更新请求(所有字段可选)"""
embedding_provider: str | None = None
embedding_model: str | None = None
embedding_dimensions: int | None = None
ocr_provider: str | None = None
llm_provider: str | None = None
minimax_api_key: str | None = None
minimax_base_url: str | None = None
minimax_embedding_model: str | None = None
minimax_chat_model: str | None = None
deepseek_api_key: str | None = None
deepseek_base_url: str | None = None
deepseek_ocr_model: str | None = None
openai_api_key: str | None = None
openai_base_url: str | None = None
zhipu_api_key: str | None = None
dashscope_api_key: str | None = None
aliyun_ocr_access_key: str | None = None
aliyun_ocr_secret: str | None = None
tencent_ocr_secret_id: str | None = None
tencent_ocr_secret_key: str | None = None
chunk_size: int | None = None
chunk_overlap: int | None = None
search_limit: int | None = None
judge_batch_size: int | None = None
@router.put("", summary="更新设置")
async def update_settings(data: SettingsUpdate):
"""
更新系统设置(运行时生效)。
修改嵌入模型或 OCR 提供商后,对应的单例服务会自动重置。
"""
from app.config import EmbeddingProvider, OCRProvider
from app.services.embedding_service import EmbeddingService
from app.services.ocr_service import OCRService
from app.services.llm_service import LLMService
update_data = data.model_dump(exclude_none=True)
if not update_data:
raise HTTPException(status_code=400, detail="没有需要更新的字段")
provider_changed = False
# 映射字段名API 用 snake_caseSettings 用 UPPER_CASE
field_map = {
"embedding_provider": "EMBEDDING_PROVIDER",
"embedding_model": "EMBEDDING_MODEL",
"embedding_dimensions": "EMBEDDING_DIMENSIONS",
"ocr_provider": "OCR_PROVIDER",
"llm_provider": "LLM_PROVIDER",
"minimax_api_key": "MINIMAX_API_KEY",
"minimax_base_url": "MINIMAX_BASE_URL",
"minimax_embedding_model": "MINIMAX_EMBEDDING_MODEL",
"minimax_chat_model": "MINIMAX_CHAT_MODEL",
"deepseek_api_key": "DEEPSEEK_API_KEY",
"deepseek_base_url": "DEEPSEEK_BASE_URL",
"deepseek_ocr_model": "DEEPSEEK_OCR_MODEL",
"openai_api_key": "OPENAI_API_KEY",
"openai_base_url": "OPENAI_BASE_URL",
"zhipu_api_key": "ZHIPU_API_KEY",
"dashscope_api_key": "DASHSCOPE_API_KEY",
"aliyun_ocr_access_key": "ALIYUN_OCR_ACCESS_KEY",
"aliyun_ocr_secret": "ALIYUN_OCR_SECRET",
"tencent_ocr_secret_id": "TENCENT_OCR_SECRET_ID",
"tencent_ocr_secret_key": "TENCENT_OCR_SECRET_KEY",
"chunk_size": "CHUNK_SIZE",
"chunk_overlap": "CHUNK_OVERLAP",
"search_limit": "SEARCH_LIMIT",
"judge_batch_size": "JUDGE_BATCH_SIZE",
}
for api_field, config_field in field_map.items():
if api_field in update_data:
value = update_data[api_field]
# 枚举类型需要转换
if config_field == "EMBEDDING_PROVIDER":
value = EmbeddingProvider(value)
provider_changed = True
elif config_field == "OCR_PROVIDER":
value = OCRProvider(value)
provider_changed = True
setattr(settings, config_field, value)
# 如果提供商变了,重置单例
if provider_changed:
EmbeddingService.reset()
OCRService.reset()
LLMService.reset()
return {"message": "设置已更新", "provider_changed": provider_changed}
@router.get("/stats", summary="获取知识库统计")
async def get_stats():
"""获取知识库统计信息"""
from sqlalchemy import text
from app.database import async_session_factory
async with async_session_factory() as session:
result = await session.execute(text("""
SELECT
(SELECT COUNT(*) FROM knowledge_pages) AS page_count,
(SELECT COUNT(*) FROM knowledge_chunks) AS chunk_count,
(SELECT COUNT(*) FROM knowledge_chunks WHERE embedding IS NOT NULL) AS embedded_count,
(SELECT COUNT(*) FROM ocr_images) AS image_count,
(SELECT COUNT(*) FROM ocr_images WHERE status = 'completed') AS ocr_completed_count
"""))
row = result.fetchone()
return {
"page_count": row.page_count,
"chunk_count": row.chunk_count,
"embedded_count": row.embedded_count,
"image_count": row.image_count,
"ocr_completed_count": row.ocr_completed_count,
}
@router.post("/test/embedding", summary="测试嵌入模型连接")
async def test_embedding():
"""测试当前嵌入模型是否可用"""
try:
from app.services.embedding_service import EmbeddingService
result = await EmbeddingService.embed_single("测试")
return {"success": True, "dimension": len(result), "message": f"嵌入模型正常,维度: {len(result)}"}
except Exception as e:
return {"success": False, "message": f"嵌入模型测试失败: {str(e)}"}
@router.post("/test/llm", summary="测试 LLM 连接")
async def test_llm():
"""测试当前 LLM 是否可用"""
try:
from app.services.llm_service import LLMService
result = await LLMService.chat([
{"role": "user", "content": "请回复\"连接正常\""}
], max_tokens=20)
return {"success": True, "message": f"LLM 正常,回复: {result[:100]}"}
except Exception as e:
return {"success": False, "message": f"LLM 测试失败: {str(e)}"}
@router.post("/test/ocr", summary="测试 OCR使用示例文本")
async def test_ocr():
"""测试当前 OCR 提供商是否可用(不实际上传图片,只检查配置)"""
try:
from app.services.ocr_service import OCRService
provider = OCRService.get_instance()
return {"success": True, "message": f"OCR 提供商 {provider.name} 已就绪"}
except Exception as e:
return {"success": False, "message": f"OCR 测试失败: {str(e)}"}