修复错题一直显示处理中:超时、自动刷新与状态更新。

This commit is contained in:
dekun
2026-06-28 14:09:10 +08:00
parent c42cd0b46d
commit 6200dbb596
7 changed files with 163 additions and 46 deletions
+3
View File
@@ -19,6 +19,9 @@ class Settings(BaseSettings):
FRONTEND_DIST: str = "../frontend/dist"
ADMIN_DEFAULT_USERNAME: str = "admin"
ADMIN_DEFAULT_PASSWORD: str = "admin123"
OCR_TIMEOUT_SECONDS: int = 300
AI_TIMEOUT_SECONDS: int = 600
PROCESS_STALE_MINUTES: int = 20
class Config:
env_file = ".env"
+70 -8
View File
@@ -1,5 +1,7 @@
import json
import uuid
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from datetime import datetime, timedelta, timezone
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status
@@ -26,6 +28,28 @@ def _short_error(exc: BaseException, prefix: str = "") -> str:
return f"{prefix}{msg}" if prefix else msg
def _is_still_processing(wq: WrongQuestion) -> bool:
if wq.status == WrongQuestionStatus.pending:
return True
if wq.status == WrongQuestionStatus.ocr_done and not wq.question_text and not wq.error_message:
return True
return False
def _expire_stale_processing(wq: WrongQuestion, db: Session) -> None:
if not _is_still_processing(wq):
return
created = wq.created_at
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - created
if age <= timedelta(minutes=settings.PROCESS_STALE_MINUTES):
return
wq.status = WrongQuestionStatus.failed
wq.error_message = f"处理超时(超过 {settings.PROCESS_STALE_MINUTES} 分钟),请点击「重新识别标注」重试"
db.commit()
def _parse_mark_regions(raw: str | None) -> list[dict] | None:
if not raw:
return None
@@ -57,25 +81,41 @@ def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
async def _run_ai_pipeline(wq: WrongQuestion, db: Session, ocr_lines: list[dict], ocr_text: str):
import asyncio
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
olympiad = wq.category == WrongQuestionCategory.olympiad
ai_cfg = llm_service.load_ai_config(db)
image_full = str(Path(settings.UPLOAD_DIR) / wq.image_path)
timeout = settings.AI_TIMEOUT_SECONDS
try:
detect_resp = await asyncio.wait_for(
llm_service.detect_wrong_line_ids(ai_cfg, subject_name, ocr_lines, school_level),
timeout=min(90, timeout),
)
wrong_ids = annotation_service.parse_wrong_line_ids(detect_resp, ocr_lines)
except Exception:
wrong_ids = annotation_service.heuristic_wrong_line_ids(ocr_lines)
detect_resp = await llm_service.detect_wrong_line_ids(ai_cfg, subject_name, ocr_lines, school_level)
wrong_ids = annotation_service.parse_wrong_line_ids(detect_resp, ocr_lines)
regions = annotation_service.regions_from_lines(ocr_lines, wrong_ids)
wq.mark_regions_json = json.dumps(regions, ensure_ascii=False)
ann_rel = ocr_service.annotated_rel_path(wq.image_path)
wq.annotated_image_path = annotation_service.draw_annotated_image(
image_full, ocr_lines, wrong_ids, ann_rel
)
db.commit()
question_text = await llm_service.format_question(ai_cfg, subject_name, ocr_text, school_level)
solution_full = await llm_service.generate_solution(
ai_cfg, subject_name, question_text, school_level, olympiad=olympiad
question_text = await asyncio.wait_for(
llm_service.format_question(ai_cfg, subject_name, ocr_text, school_level),
timeout=timeout,
)
solution_full = await asyncio.wait_for(
llm_service.generate_solution(
ai_cfg, subject_name, question_text, school_level, olympiad=olympiad
),
timeout=timeout,
)
approach, solution_body = annotation_service.split_solution_sections(solution_full)
wq.question_text = question_text
@@ -87,6 +127,7 @@ async def _run_ai_pipeline(wq: WrongQuestion, db: Session, ocr_lines: list[dict]
def _process_wrong_question(question_id: uuid.UUID):
db = SessionLocal()
wq = None
try:
wq = (
db.query(WrongQuestion)
@@ -100,7 +141,9 @@ def _process_wrong_question(question_id: uuid.UUID):
wq.error_message = None
image_full = Path(settings.UPLOAD_DIR) / wq.image_path
try:
ocr_result = ocr_service.run_ocr_with_regions(str(image_full))
with ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(ocr_service.run_ocr_with_regions, str(image_full))
ocr_result = future.result(timeout=settings.OCR_TIMEOUT_SECONDS)
ocr_text = ocr_result["text"]
ocr_lines = ocr_result["lines"]
wq.ocr_raw_text = ocr_text or None
@@ -111,6 +154,14 @@ def _process_wrong_question(question_id: uuid.UUID):
return
wq.status = WrongQuestionStatus.ocr_done
db.commit()
except FuturesTimeout:
wq.status = WrongQuestionStatus.failed
wq.error_message = (
f"OCR 识别超时(>{settings.OCR_TIMEOUT_SECONDS}秒)。"
" 首次加载模型较慢,请稍后点「重新识别标注」重试"
)
db.commit()
return
except Exception as exc:
wq.status = WrongQuestionStatus.failed
msg = _short_error(exc, "OCR 识别失败:")
@@ -129,10 +180,18 @@ def _process_wrong_question(question_id: uuid.UUID):
db.commit()
except Exception as exc:
wq.status = WrongQuestionStatus.failed
wq.error_message = _short_error(exc, "AI 处理失败:")
detail = _short_error(exc, "AI 处理失败:")
if "Timeout" in type(exc).__name__ or "timeout" in str(exc).lower():
detail = "AI 处理超时,请检查 Ollama/OpenAI 是否可用后重试"
wq.error_message = detail
db.commit()
finally:
loop.close()
except Exception as exc:
if wq is not None:
wq.status = WrongQuestionStatus.failed
wq.error_message = _short_error(exc, "处理失败:")
db.commit()
finally:
db.close()
@@ -164,6 +223,8 @@ def list_wrong_questions(
| (WrongQuestion.ocr_raw_text.ilike(pattern))
)
items = query.order_by(WrongQuestion.created_at.desc()).all()
for w in items:
_expire_stale_processing(w, db)
return [_wq_to_out(w) for w in items]
@@ -233,6 +294,7 @@ def get_wrong_question(
)
if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
_expire_stale_processing(wq, db)
return _wq_to_out(wq)