错题处理失败时直接显示具体错误信息。
This commit is contained in:
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+64
-64
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-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>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 识别与标注,请核对后再使用"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user