修复错题一直显示处理中:超时、自动刷新与状态更新。
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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="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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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