作业帮式错题标注: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
File diff suppressed because one or more lines are too long
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="copyright" content="Copyright (c) 马建军. All rights reserved." />
<title>中学成绩档案</title>
<script type="module" crossorigin src="/assets/index-FkWLM-t9.js"></script>
<script type="module" crossorigin src="/assets/index-CKqFHGFD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css">
</head>
<body>
+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: '月考',