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:
3
app/api/v1/__init__.py
Normal file
3
app/api/v1/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
API v1 路由包
|
||||
"""
|
||||
565
app/api/v1/images.py
Normal file
565
app/api/v1/images.py
Normal 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
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,
|
||||
}
|
||||
112
app/api/v1/pages.py
Normal file
112
app/api/v1/pages.py
Normal 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
30
app/api/v1/search.py
Normal 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
223
app/api/v1/settings.py
Normal 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_case,Settings 用 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)}"}
|
||||
Reference in New Issue
Block a user