修复错题一直显示处理中:超时、自动刷新与状态更新。
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+33
-33
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
||||
<meta name="author" content="马建军" />
|
||||
<meta name="copyright" content="Copyright (c) 马建军. All rights reserved." />
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { DeleteOutlined } from '@ant-design/icons'
|
||||
import { Button, Popconfirm, Space, Tag, Typography, message } from 'antd'
|
||||
import { useEffect } from 'react'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { WrongQuestion } from '../types'
|
||||
import { isWrongQuestionProcessing, processingHint } from '../utils/wqProcessing'
|
||||
import { STATUS_LABELS } from '../types'
|
||||
import AuthenticatedImage from './AuthenticatedImage'
|
||||
import WrongQuestionDetail from '../pages/WrongQuestionDetail'
|
||||
@@ -12,14 +14,15 @@ interface Props {
|
||||
onSelect: (id: string | null) => void
|
||||
onRefresh: () => void
|
||||
emptyText?: string
|
||||
pollWhenProcessing?: boolean
|
||||
}
|
||||
|
||||
function cardSummary(wq: WrongQuestion): { tone: 'error' | 'pending' | 'normal'; text: string } {
|
||||
if (wq.error_message) {
|
||||
return { tone: 'error', text: wq.error_message }
|
||||
}
|
||||
if (wq.status === 'pending') {
|
||||
return { tone: 'pending', text: '正在识别、标注并生成解题思路…' }
|
||||
if (isWrongQuestionProcessing(wq)) {
|
||||
return { tone: 'pending', text: processingHint(wq) }
|
||||
}
|
||||
return {
|
||||
tone: 'normal',
|
||||
@@ -33,7 +36,16 @@ export default function WrongQuestionList({
|
||||
onSelect,
|
||||
onRefresh,
|
||||
emptyText = '暂无记录',
|
||||
pollWhenProcessing = true,
|
||||
}: 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) => {
|
||||
try {
|
||||
await wrongQuestionApi.remove(id)
|
||||
@@ -58,7 +70,7 @@ export default function WrongQuestionList({
|
||||
<Space size={4} wrap>
|
||||
<Typography.Text strong>{wq.subject_name}</Typography.Text>
|
||||
{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]}
|
||||
</Tag>
|
||||
</Space>
|
||||
|
||||
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'
|
||||
import AuthenticatedImage from '../components/AuthenticatedImage'
|
||||
import { wrongQuestionApi } from '../api/client'
|
||||
import type { WrongQuestion } from '../types'
|
||||
import { isWrongQuestionProcessing, processingHint } from '../utils/wqProcessing'
|
||||
import { STATUS_LABELS } from '../types'
|
||||
|
||||
interface Props {
|
||||
@@ -49,6 +50,24 @@ export default function WrongQuestionDetail({
|
||||
if (open && questionId) load()
|
||||
}, [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 () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
@@ -149,7 +168,10 @@ export default function WrongQuestionDetail({
|
||||
/>
|
||||
)}
|
||||
{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) && (
|
||||
<Alert
|
||||
|
||||
@@ -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 '正在识别、标注并生成解题思路…'
|
||||
}
|
||||
Reference in New Issue
Block a user