错题处理失败时直接显示具体错误信息。

This commit is contained in:
dekun
2026-06-28 13:54:43 +08:00
parent a2a6d59f7c
commit a145f38606
9 changed files with 159 additions and 106 deletions
+1
View File
@@ -133,6 +133,7 @@ class WrongQuestion(Base):
solution_text: Mapped[str | None] = mapped_column(Text, nullable=True) solution_text: Mapped[str | None] = mapped_column(Text, nullable=True)
mark_regions_json: Mapped[str | None] = mapped_column(Text, nullable=True) mark_regions_json: Mapped[str | None] = mapped_column(Text, nullable=True)
annotated_image_path: Mapped[str | None] = mapped_column(String(512), nullable=True) annotated_image_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[WrongQuestionStatus] = mapped_column( status: Mapped[WrongQuestionStatus] = mapped_column(
Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending
) )
+22 -7
View File
@@ -19,6 +19,13 @@ from app.services.student_access import get_student_for_user
router = APIRouter(tags=["wrong_questions"]) router = APIRouter(tags=["wrong_questions"])
def _short_error(exc: BaseException, prefix: str = "") -> str:
msg = str(exc).strip() or type(exc).__name__
if len(msg) > 500:
msg = msg[:500] + ""
return f"{prefix}{msg}" if prefix else msg
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
@@ -43,6 +50,7 @@ def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
solution_text=wq.solution_text, solution_text=wq.solution_text,
mark_regions=_parse_mark_regions(wq.mark_regions_json), mark_regions=_parse_mark_regions(wq.mark_regions_json),
has_annotated_image=bool(wq.annotated_image_path), has_annotated_image=bool(wq.annotated_image_path),
error_message=wq.error_message,
status=wq.status, status=wq.status,
created_at=wq.created_at, created_at=wq.created_at,
) )
@@ -74,6 +82,7 @@ async def _run_ai_pipeline(wq: WrongQuestion, db: Session, ocr_lines: list[dict]
wq.solution_approach = approach wq.solution_approach = approach
wq.solution_text = solution_body if approach else solution_full wq.solution_text = solution_body if approach else solution_full
wq.status = WrongQuestionStatus.solved wq.status = WrongQuestionStatus.solved
wq.error_message = None
def _process_wrong_question(question_id: uuid.UUID): def _process_wrong_question(question_id: uuid.UUID):
@@ -88,22 +97,26 @@ def _process_wrong_question(question_id: uuid.UUID):
if wq is None: if wq is None:
return return
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)) ocr_result = ocr_service.run_ocr_with_regions(str(image_full))
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
wq.status = WrongQuestionStatus.ocr_done if ocr_text else WrongQuestionStatus.failed if not ocr_text:
wq.status = WrongQuestionStatus.failed
wq.error_message = "OCR 未识别到文字,请拍摄更清晰、光线充足的题目照片"
db.commit()
return
wq.status = WrongQuestionStatus.ocr_done
db.commit() db.commit()
except Exception: except Exception as exc:
wq.status = WrongQuestionStatus.failed wq.status = WrongQuestionStatus.failed
wq.error_message = _short_error(exc, "OCR 识别失败:")
db.commit() db.commit()
return return
if not ocr_text:
return
import asyncio import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@@ -111,8 +124,9 @@ def _process_wrong_question(question_id: uuid.UUID):
try: try:
loop.run_until_complete(_run_ai_pipeline(wq, db, ocr_lines, ocr_text)) loop.run_until_complete(_run_ai_pipeline(wq, db, ocr_lines, ocr_text))
db.commit() db.commit()
except Exception: except Exception as exc:
wq.status = WrongQuestionStatus.ocr_done wq.status = WrongQuestionStatus.failed
wq.error_message = _short_error(exc, "AI 处理失败:")
db.commit() db.commit()
finally: finally:
loop.close() loop.close()
@@ -293,6 +307,7 @@ def retry_ocr(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
wq.status = WrongQuestionStatus.pending wq.status = WrongQuestionStatus.pending
wq.error_message = None
db.commit() db.commit()
background_tasks.add_task(_process_wrong_question, wq.id) background_tasks.add_task(_process_wrong_question, wq.id)
return _wq_to_out(wq) return _wq_to_out(wq)
+1
View File
@@ -237,6 +237,7 @@ class WrongQuestionOut(BaseModel):
solution_text: str | None solution_text: str | None
mark_regions: list[dict] | None = None mark_regions: list[dict] | None = None
has_annotated_image: bool = False has_annotated_image: bool = False
error_message: str | None = None
status: WrongQuestionStatusEnum status: WrongQuestionStatusEnum
created_at: datetime created_at: datetime
+2
View File
@@ -75,6 +75,8 @@ def run_migrations() -> None:
wq_alters.append("ADD COLUMN mark_regions_json TEXT") wq_alters.append("ADD COLUMN mark_regions_json TEXT")
if "annotated_image_path" not in wq_columns: if "annotated_image_path" not in wq_columns:
wq_alters.append("ADD COLUMN annotated_image_path VARCHAR(512)") wq_alters.append("ADD COLUMN annotated_image_path VARCHAR(512)")
if "error_message" not in wq_columns:
wq_alters.append("ADD COLUMN error_message TEXT")
if wq_alters: if wq_alters:
with engine.begin() as conn: with engine.begin() as conn:
for clause in wq_alters: for clause in wq_alters:
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-CKqFHGFD.js"></script> <script type="module" crossorigin src="/assets/index-C3DkjkK0.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css"> <link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css">
</head> </head>
<body> <body>
+54 -33
View File
@@ -1,5 +1,5 @@
import { DeleteOutlined } from '@ant-design/icons' import { DeleteOutlined } from '@ant-design/icons'
import { Button, Popconfirm, Tag, Typography, message } from 'antd' import { Button, Popconfirm, Space, Tag, Typography, message } from 'antd'
import { wrongQuestionApi } from '../api/client' import { wrongQuestionApi } from '../api/client'
import type { WrongQuestion } from '../types' import type { WrongQuestion } from '../types'
import { STATUS_LABELS } from '../types' import { STATUS_LABELS } from '../types'
@@ -14,6 +14,19 @@ interface Props {
emptyText?: string emptyText?: string
} }
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: '正在识别、标注并生成解题思路…' }
}
return {
tone: 'normal',
text: wq.question_text || wq.ocr_raw_text || STATUS_LABELS[wq.status],
}
}
export default function WrongQuestionList({ export default function WrongQuestionList({
items, items,
selectedId, selectedId,
@@ -35,40 +48,48 @@ export default function WrongQuestionList({
return ( return (
<> <>
<div className="wq-grid"> <div className="wq-grid">
{items.map((wq) => ( {items.map((wq) => {
<div key={wq.id} className="wq-card"> const summary = cardSummary(wq)
<div className="wq-card-click" onClick={() => onSelect(wq.id)}> return (
<AuthenticatedImage questionId={wq.id} variant="annotated" alt="题目" className="wq-card-img" /> <div key={wq.id} className="wq-card">
<div className="wq-card-body"> <div className="wq-card-click" onClick={() => onSelect(wq.id)}>
<Typography.Text strong>{wq.subject_name}</Typography.Text> <AuthenticatedImage questionId={wq.id} variant="annotated" alt="题目" className="wq-card-img" />
{wq.category === 'olympiad' && ( <div className="wq-card-body">
<Tag color="gold" style={{ marginLeft: 4 }}> <Space size={4} wrap>
<Typography.Text strong>{wq.subject_name}</Typography.Text>
</Tag> {wq.category === 'olympiad' && <Tag color="gold"></Tag>}
)} <Tag color={wq.error_message || wq.status === 'failed' ? 'error' : wq.status === 'pending' ? 'processing' : 'default'}>
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}> {wq.error_message ? '失败' : STATUS_LABELS[wq.status]}
{STATUS_LABELS[wq.status]} </Tag>
</Typography.Text> </Space>
<Typography.Paragraph ellipsis={{ rows: 2 }} style={{ margin: '8px 0 0', fontSize: 13 }}> <Typography.Paragraph
{wq.question_text || wq.ocr_raw_text || '处理中…'} ellipsis={{ rows: 3 }}
</Typography.Paragraph> style={{
margin: '8px 0 0',
fontSize: 13,
color: summary.tone === 'error' ? '#ff4d4f' : summary.tone === 'pending' ? '#1677ff' : undefined,
}}
>
{summary.text}
</Typography.Paragraph>
</div>
</div>
<div className="wq-card-actions">
<Popconfirm title="确定删除该题?" onConfirm={() => handleDelete(wq.id)}>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
>
</Button>
</Popconfirm>
</div> </div>
</div> </div>
<div className="wq-card-actions"> )
<Popconfirm title="确定删除该题?" onConfirm={() => handleDelete(wq.id)}> })}
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
>
</Button>
</Popconfirm>
</div>
</div>
))}
</div> </div>
{items.length === 0 && <Typography.Text type="secondary">{emptyText}</Typography.Text>} {items.length === 0 && <Typography.Text type="secondary">{emptyText}</Typography.Text>}
{selectedId && ( {selectedId && (
+13 -1
View File
@@ -135,10 +135,22 @@ export default function WrongQuestionDetail({
<> <>
<Space wrap style={{ marginBottom: 8 }}> <Space wrap style={{ marginBottom: 8 }}>
<Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text> <Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text>
{wq.has_annotated_image && ( {wq.has_annotated_image && !wq.error_message && (
<Typography.Text type="danger"></Typography.Text> <Typography.Text type="danger"></Typography.Text>
)} )}
</Space> </Space>
{wq.error_message && (
<Alert
message="处理失败"
description={wq.error_message}
type="error"
showIcon
style={{ marginBottom: 12 }}
/>
)}
{wq.status === 'pending' && !wq.error_message && (
<Alert message="正在识别、标注并生成解题思路,请稍候…" type="info" showIcon style={{ marginBottom: 12 }} />
)}
{(wq.solution_approach || wq.solution_text) && ( {(wq.solution_approach || wq.solution_text) && (
<Alert <Alert
message="AI 识别与标注,请核对后再使用" message="AI 识别与标注,请核对后再使用"
+1
View File
@@ -114,6 +114,7 @@ export interface WrongQuestion {
solution_text: string | null solution_text: string | null
mark_regions: MarkRegion[] | null mark_regions: MarkRegion[] | null
has_annotated_image: boolean has_annotated_image: boolean
error_message: string | null
status: WrongQuestionStatus status: WrongQuestionStatus
created_at: string created_at: string
} }