作业帮式错题标注:OCR 定位错误红框 + 解题思路。

- PaddleOCR 行级坐标 + AI 识别错答区域,生成标注图

- 解法拆分为「解题思路」与「详细解答」

- 详情页标注图/原图切换,列表显示标注缩略图
This commit is contained in:
dekun
2026-06-28 13:50:20 +08:00
parent c30e21b51e
commit a2a6d59f7c
16 changed files with 852 additions and 507 deletions
+36 -12
View File
@@ -3,6 +3,7 @@ import api from '../api/client'
interface Props {
questionId: string
variant?: 'original' | 'annotated'
className?: string
alt?: string
style?: React.CSSProperties
@@ -10,6 +11,7 @@ interface Props {
export default function AuthenticatedImage({
questionId,
variant = 'original',
className,
alt = '题目',
style,
@@ -21,36 +23,58 @@ export default function AuthenticatedImage({
let objectUrl: string | null = null
let cancelled = false
api
.get(`/wrong-questions/${questionId}/image`, { responseType: 'blob' })
.then((res) => {
const load = async (path: string, fallback?: string) => {
try {
const res = await api.get(path, { responseType: 'blob' })
if (cancelled) return
objectUrl = URL.createObjectURL(res.data)
setSrc(objectUrl)
setFailed(false)
})
.catch(() => {
if (!cancelled) setFailed(true)
})
} catch {
if (fallback && !cancelled) {
await load(fallback)
} else if (!cancelled) {
setFailed(true)
}
}
}
const annotatedPath = `/wrong-questions/${questionId}/annotated-image`
const originalPath = `/wrong-questions/${questionId}/image`
if (variant === 'annotated') {
load(annotatedPath, originalPath)
} else {
load(originalPath)
}
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [questionId])
}, [questionId, variant])
if (failed) {
return (
<div className={className} style={{ ...style, background: '#fafafa', color: '#999', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12 }}>
<div
className={className}
style={{
...style,
background: '#fafafa',
color: '#999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12,
}}
>
</div>
)
}
if (!src) {
return (
<div className={className} style={{ ...style, background: '#fafafa' }} />
)
return <div className={className} style={{ ...style, background: '#fafafa' }} />
}
return <img src={src} alt={alt} className={className} style={style} />
@@ -38,7 +38,7 @@ export default function WrongQuestionList({
{items.map((wq) => (
<div key={wq.id} className="wq-card">
<div className="wq-card-click" onClick={() => onSelect(wq.id)}>
<AuthenticatedImage questionId={wq.id} alt="题目" className="wq-card-img" />
<AuthenticatedImage questionId={wq.id} variant="annotated" alt="题目" className="wq-card-img" />
<div className="wq-card-body">
<Typography.Text strong>{wq.subject_name}</Typography.Text>
{wq.category === 'olympiad' && (
+1 -1
View File
@@ -198,7 +198,7 @@ export default function StudentDetailPage() {
children: (
<div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{stageLabel}
{stageLabel}
</Typography.Paragraph>
<WrongQuestionUpload
studentId={id!}
+59 -11
View File
@@ -1,4 +1,4 @@
import { Alert, Button, Col, Input, Modal, Popconfirm, Row, Space, Spin, Typography, message } from 'antd'
import { Alert, Button, Col, Input, Modal, Popconfirm, Row, Segmented, Space, Spin, Typography, message } from 'antd'
import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import AuthenticatedImage from '../components/AuthenticatedImage'
@@ -24,7 +24,9 @@ export default function WrongQuestionDetail({
const [wq, setWq] = useState<WrongQuestion | null>(null)
const [loading, setLoading] = useState(false)
const [questionText, setQuestionText] = useState('')
const [approachText, setApproachText] = useState('')
const [solutionText, setSolutionText] = useState('')
const [imageMode, setImageMode] = useState<'annotated' | 'original'>('annotated')
const [saving, setSaving] = useState(false)
const [regenerating, setRegenerating] = useState(false)
const [deleting, setDeleting] = useState(false)
@@ -35,7 +37,9 @@ export default function WrongQuestionDetail({
const { data } = await wrongQuestionApi.get(questionId)
setWq(data)
setQuestionText(data.question_text || '')
setApproachText(data.solution_approach || '')
setSolutionText(data.solution_text || '')
setImageMode(data.has_annotated_image ? 'annotated' : 'original')
} finally {
setLoading(false)
}
@@ -50,6 +54,7 @@ export default function WrongQuestionDetail({
try {
await wrongQuestionApi.update(questionId, {
question_text: questionText,
solution_approach: approachText,
solution_text: solutionText,
})
message.success('已保存')
@@ -65,8 +70,9 @@ export default function WrongQuestionDetail({
const { data } = await wrongQuestionApi.regenerate(questionId)
setWq(data)
setQuestionText(data.question_text || '')
setApproachText(data.solution_approach || '')
setSolutionText(data.solution_text || '')
message.success('解已重新生成')
message.success('解题思路已重新生成')
onUpdated()
} catch {
message.error('生成失败,请检查 AI 模型配置')
@@ -77,7 +83,7 @@ export default function WrongQuestionDetail({
const handleRetryOcr = async () => {
await wrongQuestionApi.retryOcr(questionId)
message.info('已重新识别,请稍后刷新')
message.info('已重新识别并标注,请稍后刷新')
onUpdated()
onClose()
}
@@ -114,9 +120,9 @@ export default function WrongQuestionDetail({
</Button>
</Popconfirm>
<Button onClick={handleRetryOcr}> OCR</Button>
<Button onClick={handleRetryOcr}></Button>
<Button loading={regenerating} onClick={handleRegenerate}>
</Button>
<Button type="primary" loading={saving} onClick={handleSave}>
@@ -127,10 +133,15 @@ export default function WrongQuestionDetail({
<Spin spinning={loading}>
{wq && (
<>
<Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text>
{wq.solution_text && (
<Space wrap style={{ marginBottom: 8 }}>
<Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text>
{wq.has_annotated_image && (
<Typography.Text type="danger"></Typography.Text>
)}
</Space>
{(wq.solution_approach || wq.solution_text) && (
<Alert
message="AI 生成内容,请核对后再使用"
message="AI 识别与标注,请核对后再使用"
type="warning"
showIcon
style={{ margin: '12px 0' }}
@@ -138,8 +149,21 @@ export default function WrongQuestionDetail({
)}
<Row gutter={16} style={{ marginTop: 12 }}>
<Col xs={24} md={10}>
{wq.has_annotated_image && (
<Segmented
block
style={{ marginBottom: 8 }}
value={imageMode}
onChange={(v) => setImageMode(v as 'annotated' | 'original')}
options={[
{ label: '标注图', value: 'annotated' },
{ label: '原图', value: 'original' },
]}
/>
)}
<AuthenticatedImage
questionId={wq.id}
variant={imageMode}
alt="原题"
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
/>
@@ -164,12 +188,36 @@ export default function WrongQuestionDetail({
<Col xs={24} md={14}>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={6}
rows={5}
value={questionText}
onChange={(e) => setQuestionText(e.target.value)}
style={{ marginTop: 8, marginBottom: 16 }}
/>
<Typography.Text strong></Typography.Text>
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={4}
value={approachText}
onChange={(e) => setApproachText(e.target.value)}
placeholder="识别完成后自动生成,类似作业帮「解题思路」"
style={{ marginTop: 8, marginBottom: 16 }}
/>
{approachText && (
<div
style={{
background: '#e6f4ff',
padding: 12,
borderRadius: 8,
marginBottom: 16,
border: '1px solid #91caff',
}}
>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<ReactMarkdown>{approachText}</ReactMarkdown>
</div>
)}
<Typography.Text strong></Typography.Text>
<Input.TextArea
rows={8}
value={solutionText}
@@ -179,7 +227,7 @@ export default function WrongQuestionDetail({
{solutionText && (
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<ReactMarkdown>{solutionText}</ReactMarkdown>
</div>
+11
View File
@@ -110,11 +110,22 @@ export interface WrongQuestion {
image_path: string
ocr_raw_text: string | null
question_text: string | null
solution_approach: string | null
solution_text: string | null
mark_regions: MarkRegion[] | null
has_annotated_image: boolean
status: WrongQuestionStatus
created_at: string
}
export interface MarkRegion {
line_id: number
text: string
bbox: number[]
type: string
label: string
}
export const EXAM_TYPE_LABELS: Record<ExamType, string> = {
weekly: '周考',
monthly: '月考',