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

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" FRONTEND_DIST: str = "../frontend/dist"
ADMIN_DEFAULT_USERNAME: str = "admin" ADMIN_DEFAULT_USERNAME: str = "admin"
ADMIN_DEFAULT_PASSWORD: str = "admin123" ADMIN_DEFAULT_PASSWORD: str = "admin123"
OCR_TIMEOUT_SECONDS: int = 300
AI_TIMEOUT_SECONDS: int = 600
PROCESS_STALE_MINUTES: int = 20
class Config: class Config:
env_file = ".env" env_file = ".env"
+68 -6
View File
@@ -1,5 +1,7 @@
import json import json
import uuid import uuid
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, Query, UploadFile, status 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 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: def _parse_mark_regions(raw: str | None) -> list[dict] | None:
if not raw: if not raw:
return None 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): 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 "综合" subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None school_level = wq.student.school_level if wq.student else None
olympiad = wq.category == WrongQuestionCategory.olympiad olympiad = wq.category == WrongQuestionCategory.olympiad
ai_cfg = llm_service.load_ai_config(db) ai_cfg = llm_service.load_ai_config(db)
image_full = str(Path(settings.UPLOAD_DIR) / wq.image_path) image_full = str(Path(settings.UPLOAD_DIR) / wq.image_path)
timeout = settings.AI_TIMEOUT_SECONDS
detect_resp = await llm_service.detect_wrong_line_ids(ai_cfg, subject_name, ocr_lines, school_level) 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) wrong_ids = annotation_service.parse_wrong_line_ids(detect_resp, ocr_lines)
except Exception:
wrong_ids = annotation_service.heuristic_wrong_line_ids(ocr_lines)
regions = annotation_service.regions_from_lines(ocr_lines, wrong_ids) regions = annotation_service.regions_from_lines(ocr_lines, wrong_ids)
wq.mark_regions_json = json.dumps(regions, ensure_ascii=False) wq.mark_regions_json = json.dumps(regions, ensure_ascii=False)
ann_rel = ocr_service.annotated_rel_path(wq.image_path) ann_rel = ocr_service.annotated_rel_path(wq.image_path)
wq.annotated_image_path = annotation_service.draw_annotated_image( wq.annotated_image_path = annotation_service.draw_annotated_image(
image_full, ocr_lines, wrong_ids, ann_rel 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) question_text = await asyncio.wait_for(
solution_full = await llm_service.generate_solution( 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 ai_cfg, subject_name, question_text, school_level, olympiad=olympiad
),
timeout=timeout,
) )
approach, solution_body = annotation_service.split_solution_sections(solution_full) approach, solution_body = annotation_service.split_solution_sections(solution_full)
wq.question_text = question_text 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): def _process_wrong_question(question_id: uuid.UUID):
db = SessionLocal() db = SessionLocal()
wq = None
try: try:
wq = ( wq = (
db.query(WrongQuestion) db.query(WrongQuestion)
@@ -100,7 +141,9 @@ def _process_wrong_question(question_id: uuid.UUID):
wq.error_message = None wq.error_message = None
image_full = Path(settings.UPLOAD_DIR) / wq.image_path image_full = Path(settings.UPLOAD_DIR) / wq.image_path
try: 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_text = ocr_result["text"]
ocr_lines = ocr_result["lines"] ocr_lines = ocr_result["lines"]
wq.ocr_raw_text = ocr_text or None wq.ocr_raw_text = ocr_text or None
@@ -111,6 +154,14 @@ def _process_wrong_question(question_id: uuid.UUID):
return return
wq.status = WrongQuestionStatus.ocr_done wq.status = WrongQuestionStatus.ocr_done
db.commit() db.commit()
except FuturesTimeout:
wq.status = WrongQuestionStatus.failed
wq.error_message = (
f"OCR 识别超时(>{settings.OCR_TIMEOUT_SECONDS}秒)。"
" 首次加载模型较慢,请稍后点「重新识别标注」重试"
)
db.commit()
return
except Exception as exc: except Exception as exc:
wq.status = WrongQuestionStatus.failed wq.status = WrongQuestionStatus.failed
msg = _short_error(exc, "OCR 识别失败:") msg = _short_error(exc, "OCR 识别失败:")
@@ -129,10 +180,18 @@ def _process_wrong_question(question_id: uuid.UUID):
db.commit() db.commit()
except Exception as exc: except Exception as exc:
wq.status = WrongQuestionStatus.failed 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() db.commit()
finally: finally:
loop.close() loop.close()
except Exception as exc:
if wq is not None:
wq.status = WrongQuestionStatus.failed
wq.error_message = _short_error(exc, "处理失败:")
db.commit()
finally: finally:
db.close() db.close()
@@ -164,6 +223,8 @@ def list_wrong_questions(
| (WrongQuestion.ocr_raw_text.ilike(pattern)) | (WrongQuestion.ocr_raw_text.ilike(pattern))
) )
items = query.order_by(WrongQuestion.created_at.desc()).all() 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] 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: if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
_expire_stale_processing(wq, db)
return _wq_to_out(wq) return _wq_to_out(wq)
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="author" content="马建军" /> <meta name="author" content="马建军" />
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." /> <meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title> <title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-C3DkjkK0.js"></script> <script type="module" crossorigin src="/assets/index-_1CtLpiP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css"> <link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css">
</head> </head>
<body> <body>
+15 -3
View File
@@ -1,7 +1,9 @@
import { DeleteOutlined } from '@ant-design/icons' import { DeleteOutlined } from '@ant-design/icons'
import { Button, Popconfirm, Space, Tag, Typography, message } from 'antd' import { Button, Popconfirm, Space, Tag, Typography, message } from 'antd'
import { useEffect } from 'react'
import { wrongQuestionApi } from '../api/client' import { wrongQuestionApi } from '../api/client'
import type { WrongQuestion } from '../types' import type { WrongQuestion } from '../types'
import { isWrongQuestionProcessing, processingHint } from '../utils/wqProcessing'
import { STATUS_LABELS } from '../types' import { STATUS_LABELS } from '../types'
import AuthenticatedImage from './AuthenticatedImage' import AuthenticatedImage from './AuthenticatedImage'
import WrongQuestionDetail from '../pages/WrongQuestionDetail' import WrongQuestionDetail from '../pages/WrongQuestionDetail'
@@ -12,14 +14,15 @@ interface Props {
onSelect: (id: string | null) => void onSelect: (id: string | null) => void
onRefresh: () => void onRefresh: () => void
emptyText?: string emptyText?: string
pollWhenProcessing?: boolean
} }
function cardSummary(wq: WrongQuestion): { tone: 'error' | 'pending' | 'normal'; text: string } { function cardSummary(wq: WrongQuestion): { tone: 'error' | 'pending' | 'normal'; text: string } {
if (wq.error_message) { if (wq.error_message) {
return { tone: 'error', text: wq.error_message } return { tone: 'error', text: wq.error_message }
} }
if (wq.status === 'pending') { if (isWrongQuestionProcessing(wq)) {
return { tone: 'pending', text: '正在识别、标注并生成解题思路…' } return { tone: 'pending', text: processingHint(wq) }
} }
return { return {
tone: 'normal', tone: 'normal',
@@ -33,7 +36,16 @@ export default function WrongQuestionList({
onSelect, onSelect,
onRefresh, onRefresh,
emptyText = '暂无记录', emptyText = '暂无记录',
pollWhenProcessing = true,
}: Props) { }: Props) {
const hasProcessing = pollWhenProcessing && items.some(isWrongQuestionProcessing)
useEffect(() => {
if (!hasProcessing) return
const timer = window.setInterval(() => onRefresh(), 4000)
return () => window.clearInterval(timer)
}, [hasProcessing, onRefresh])
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await wrongQuestionApi.remove(id) await wrongQuestionApi.remove(id)
@@ -58,7 +70,7 @@ export default function WrongQuestionList({
<Space size={4} wrap> <Space size={4} wrap>
<Typography.Text strong>{wq.subject_name}</Typography.Text> <Typography.Text strong>{wq.subject_name}</Typography.Text>
{wq.category === 'olympiad' && <Tag color="gold"></Tag>} {wq.category === 'olympiad' && <Tag color="gold"></Tag>}
<Tag color={wq.error_message || wq.status === 'failed' ? 'error' : wq.status === 'pending' ? 'processing' : 'default'}> <Tag color={wq.error_message || wq.status === 'failed' ? 'error' : isWrongQuestionProcessing(wq) ? 'processing' : 'default'}>
{wq.error_message ? '失败' : STATUS_LABELS[wq.status]} {wq.error_message ? '失败' : STATUS_LABELS[wq.status]}
</Tag> </Tag>
</Space> </Space>
+23 -1
View File
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'
import AuthenticatedImage from '../components/AuthenticatedImage' import AuthenticatedImage from '../components/AuthenticatedImage'
import { wrongQuestionApi } from '../api/client' import { wrongQuestionApi } from '../api/client'
import type { WrongQuestion } from '../types' import type { WrongQuestion } from '../types'
import { isWrongQuestionProcessing, processingHint } from '../utils/wqProcessing'
import { STATUS_LABELS } from '../types' import { STATUS_LABELS } from '../types'
interface Props { interface Props {
@@ -49,6 +50,24 @@ export default function WrongQuestionDetail({
if (open && questionId) load() if (open && questionId) load()
}, [open, questionId]) }, [open, questionId])
useEffect(() => {
if (!open || !wq || !isWrongQuestionProcessing(wq)) return
const timer = window.setInterval(async () => {
try {
const { data } = await wrongQuestionApi.get(questionId)
setWq(data)
setQuestionText(data.question_text || '')
setApproachText(data.solution_approach || '')
setSolutionText(data.solution_text || '')
if (data.has_annotated_image) setImageMode('annotated')
if (!isWrongQuestionProcessing(data)) onUpdated()
} catch {
/* ignore poll errors */
}
}, 4000)
return () => window.clearInterval(timer)
}, [open, questionId, wq?.status, wq?.question_text, wq?.error_message])
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true)
try { try {
@@ -149,7 +168,10 @@ export default function WrongQuestionDetail({
/> />
)} )}
{wq.status === 'pending' && !wq.error_message && ( {wq.status === 'pending' && !wq.error_message && (
<Alert message="正在识别、标注并生成解题思路,请稍候…" type="info" showIcon style={{ marginBottom: 12 }} /> <Alert message={processingHint(wq)} type="info" showIcon style={{ marginBottom: 12 }} />
)}
{wq.status === 'ocr_done' && !wq.question_text && !wq.error_message && (
<Alert message={processingHint(wq)} type="info" showIcon style={{ marginBottom: 12 }} />
)} )}
{(wq.solution_approach || wq.solution_text) && ( {(wq.solution_approach || wq.solution_text) && (
<Alert <Alert
+18
View File
@@ -0,0 +1,18 @@
import type { WrongQuestion } from '../types'
export function isWrongQuestionProcessing(wq: WrongQuestion): boolean {
if (wq.error_message) return false
if (wq.status === 'pending') return true
if (wq.status === 'ocr_done' && !wq.question_text) return true
return false
}
export function processingHint(wq: WrongQuestion): string {
if (wq.status === 'pending') {
return '正在 OCR 识别(首次约 1–5 分钟,请稍候)…'
}
if (wq.status === 'ocr_done') {
return '正在标注错题并生成解题思路…'
}
return '正在识别、标注并生成解题思路…'
}