作业帮式错题标注: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
+3
View File
@@ -129,7 +129,10 @@ class WrongQuestion(Base):
image_path: Mapped[str] = mapped_column(String(512)) image_path: Mapped[str] = mapped_column(String(512))
ocr_raw_text: Mapped[str | None] = mapped_column(Text, nullable=True) ocr_raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
question_text: Mapped[str | None] = mapped_column(Text, nullable=True) question_text: Mapped[str | None] = mapped_column(Text, nullable=True)
solution_approach: Mapped[str | None] = mapped_column(Text, nullable=True)
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)
annotated_image_path: Mapped[str | None] = mapped_column(String(512), nullable=True)
status: Mapped[WrongQuestionStatus] = mapped_column( status: Mapped[WrongQuestionStatus] = mapped_column(
Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending Enum(WrongQuestionStatus), default=WrongQuestionStatus.pending
) )
+79 -22
View File
@@ -1,3 +1,4 @@
import json
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -10,6 +11,7 @@ from app.core.database import SessionLocal, get_db
from app.core.deps import get_current_user from app.core.deps import get_current_user
from app.models.user import Subject, User, WrongQuestion, WrongQuestionCategory, WrongQuestionStatus from app.models.user import Subject, User, WrongQuestion, WrongQuestionCategory, WrongQuestionStatus
from app.schemas import WrongQuestionCategoryEnum, WrongQuestionOut, WrongQuestionUpdate from app.schemas import WrongQuestionCategoryEnum, WrongQuestionOut, WrongQuestionUpdate
from app.services import annotation as annotation_service
from app.services import llm as llm_service from app.services import llm as llm_service
from app.services import ocr as ocr_service from app.services import ocr as ocr_service
from app.services.student_access import get_student_for_user from app.services.student_access import get_student_for_user
@@ -17,6 +19,16 @@ from app.services.student_access import get_student_for_user
router = APIRouter(tags=["wrong_questions"]) router = APIRouter(tags=["wrong_questions"])
def _parse_mark_regions(raw: str | None) -> list[dict] | None:
if not raw:
return None
try:
data = json.loads(raw)
return data if isinstance(data, list) else None
except json.JSONDecodeError:
return None
def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut: def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
return WrongQuestionOut( return WrongQuestionOut(
id=wq.id, id=wq.id,
@@ -27,12 +39,43 @@ def _wq_to_out(wq: WrongQuestion) -> WrongQuestionOut:
image_path=wq.image_path, image_path=wq.image_path,
ocr_raw_text=wq.ocr_raw_text, ocr_raw_text=wq.ocr_raw_text,
question_text=wq.question_text, question_text=wq.question_text,
solution_approach=wq.solution_approach,
solution_text=wq.solution_text, solution_text=wq.solution_text,
mark_regions=_parse_mark_regions(wq.mark_regions_json),
has_annotated_image=bool(wq.annotated_image_path),
status=wq.status, status=wq.status,
created_at=wq.created_at, created_at=wq.created_at,
) )
async def _run_ai_pipeline(wq: WrongQuestion, db: Session, ocr_lines: list[dict], ocr_text: str):
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
olympiad = wq.category == WrongQuestionCategory.olympiad
ai_cfg = llm_service.load_ai_config(db)
image_full = str(Path(settings.UPLOAD_DIR) / wq.image_path)
detect_resp = await llm_service.detect_wrong_line_ids(ai_cfg, subject_name, ocr_lines, school_level)
wrong_ids = annotation_service.parse_wrong_line_ids(detect_resp, ocr_lines)
regions = annotation_service.regions_from_lines(ocr_lines, wrong_ids)
wq.mark_regions_json = json.dumps(regions, ensure_ascii=False)
ann_rel = ocr_service.annotated_rel_path(wq.image_path)
wq.annotated_image_path = annotation_service.draw_annotated_image(
image_full, ocr_lines, wrong_ids, ann_rel
)
question_text = await llm_service.format_question(ai_cfg, subject_name, ocr_text, school_level)
solution_full = await llm_service.generate_solution(
ai_cfg, subject_name, question_text, school_level, olympiad=olympiad
)
approach, solution_body = annotation_service.split_solution_sections(solution_full)
wq.question_text = question_text
wq.solution_approach = approach
wq.solution_text = solution_body if approach else solution_full
wq.status = WrongQuestionStatus.solved
def _process_wrong_question(question_id: uuid.UUID): def _process_wrong_question(question_id: uuid.UUID):
db = SessionLocal() db = SessionLocal()
try: try:
@@ -47,7 +90,9 @@ def _process_wrong_question(question_id: uuid.UUID):
image_full = Path(settings.UPLOAD_DIR) / wq.image_path image_full = Path(settings.UPLOAD_DIR) / wq.image_path
try: try:
ocr_text = ocr_service.run_ocr(str(image_full)) ocr_result = ocr_service.run_ocr_with_regions(str(image_full))
ocr_text = ocr_result["text"]
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 wq.status = WrongQuestionStatus.ocr_done if ocr_text else WrongQuestionStatus.failed
db.commit() db.commit()
@@ -59,31 +104,12 @@ def _process_wrong_question(question_id: uuid.UUID):
if not ocr_text: if not ocr_text:
return return
subject_name = wq.subject.name if wq.subject else "综合"
school_level = wq.student.school_level if wq.student else None
olympiad = wq.category == WrongQuestionCategory.olympiad
ai_cfg = llm_service.load_ai_config(db)
import asyncio import asyncio
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
question_text = loop.run_until_complete( loop.run_until_complete(_run_ai_pipeline(wq, db, ocr_lines, ocr_text))
llm_service.format_question(ai_cfg, subject_name, ocr_text, school_level)
)
solution_text = loop.run_until_complete(
llm_service.generate_solution(
ai_cfg,
subject_name,
question_text,
school_level,
olympiad=olympiad,
)
)
wq.question_text = question_text
wq.solution_text = solution_text
wq.status = WrongQuestionStatus.solved
db.commit() db.commit()
except Exception: except Exception:
wq.status = WrongQuestionStatus.ocr_done wq.status = WrongQuestionStatus.ocr_done
@@ -217,6 +243,8 @@ def update_wrong_question(
wq.question_text = data.question_text wq.question_text = data.question_text
if data.solution_text is not None: if data.solution_text is not None:
wq.solution_text = data.solution_text wq.solution_text = data.solution_text
if data.solution_approach is not None:
wq.solution_approach = data.solution_approach
db.commit() db.commit()
db.refresh(wq) db.refresh(wq)
@@ -239,10 +267,13 @@ def delete_wrong_question(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
image_full = Path(settings.UPLOAD_DIR) / wq.image_path image_full = Path(settings.UPLOAD_DIR) / wq.image_path
ann_full = Path(settings.UPLOAD_DIR) / wq.annotated_image_path if wq.annotated_image_path else None
db.delete(wq) db.delete(wq)
db.commit() db.commit()
if image_full.exists(): if image_full.exists():
image_full.unlink() image_full.unlink()
if ann_full and ann_full.exists():
ann_full.unlink()
@router.post("/wrong-questions/{question_id}/retry-ocr", response_model=WrongQuestionOut) @router.post("/wrong-questions/{question_id}/retry-ocr", response_model=WrongQuestionOut)
@@ -297,13 +328,16 @@ async def regenerate_solution(
ai_cfg, subject_name, wq.ocr_raw_text, school_level ai_cfg, subject_name, wq.ocr_raw_text, school_level
) )
question_text = wq.question_text question_text = wq.question_text
wq.solution_text = await llm_service.generate_solution( solution_full = await llm_service.generate_solution(
ai_cfg, ai_cfg,
subject_name, subject_name,
question_text, question_text,
school_level, school_level,
olympiad=olympiad, olympiad=olympiad,
) )
approach, solution_body = annotation_service.split_solution_sections(solution_full)
wq.solution_approach = approach
wq.solution_text = solution_body if approach else solution_full
wq.status = WrongQuestionStatus.solved wq.status = WrongQuestionStatus.solved
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
@@ -334,3 +368,26 @@ def get_wrong_question_image(
if not image_full.exists(): if not image_full.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="图片不存在")
return FileResponse(image_full) return FileResponse(image_full)
@router.get("/wrong-questions/{question_id}/annotated-image")
def get_wrong_question_annotated_image(
question_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
wq = (
db.query(WrongQuestion)
.options(joinedload(WrongQuestion.student))
.filter(WrongQuestion.id == question_id)
.first()
)
if wq is None or wq.student.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="错题不存在")
if not wq.annotated_image_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="标注图尚未生成")
image_full = Path(settings.UPLOAD_DIR) / wq.annotated_image_path
if not image_full.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="标注图不存在")
return FileResponse(image_full)
+4
View File
@@ -233,7 +233,10 @@ class WrongQuestionOut(BaseModel):
image_path: str image_path: str
ocr_raw_text: str | None ocr_raw_text: str | None
question_text: str | None question_text: str | None
solution_approach: str | None = None
solution_text: str | None solution_text: str | None
mark_regions: list[dict] | None = None
has_annotated_image: bool = False
status: WrongQuestionStatusEnum status: WrongQuestionStatusEnum
created_at: datetime created_at: datetime
@@ -242,5 +245,6 @@ class WrongQuestionOut(BaseModel):
class WrongQuestionUpdate(BaseModel): class WrongQuestionUpdate(BaseModel):
question_text: str | None = None question_text: str | None = None
solution_approach: str | None = None
solution_text: str | None = None solution_text: str | None = None
subject_id: int | None = None subject_id: int | None = None
+109
View File
@@ -0,0 +1,109 @@
import json
import re
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from app.core.config import settings
def _parse_llm_json(text: str) -> dict | None:
text = text.strip()
match = re.search(r"\{[\s\S]*\}", text)
if not match:
return None
try:
return json.loads(match.group())
except json.JSONDecodeError:
return None
def heuristic_wrong_line_ids(lines: list[dict]) -> list[int]:
wrong: list[int] = []
for i, line in enumerate(lines):
t = line.get("text", "")
if any(c in t for c in ("×", "", "", "")):
wrong.append(i)
continue
if re.search(r"[×xX]\s*$", t.strip()):
wrong.append(i)
if wrong:
return wrong
# 单题照片:标注最后几行作答区域
if len(lines) == 1:
return [0]
if len(lines) <= 4:
return list(range(max(0, len(lines) - 2), len(lines)))
return list(range(len(lines) - 2, len(lines)))
def parse_wrong_line_ids(llm_response: str, lines: list[dict]) -> list[int]:
data = _parse_llm_json(llm_response)
if data and isinstance(data.get("wrong_line_ids"), list):
ids = [int(x) for x in data["wrong_line_ids"] if isinstance(x, (int, float, str))]
ids = [i for i in ids if 0 <= i < len(lines)]
if ids:
return ids
return heuristic_wrong_line_ids(lines)
def regions_from_lines(lines: list[dict], wrong_ids: list[int]) -> list[dict]:
regions = []
for i in wrong_ids:
if i >= len(lines):
continue
line = lines[i]
bbox = line.get("bbox") or [0, 0, 0, 0]
regions.append(
{
"line_id": i,
"text": line.get("text", ""),
"bbox": bbox,
"type": "wrong",
"label": "",
}
)
return regions
def draw_annotated_image(
src_path: str,
lines: list[dict],
wrong_ids: list[int],
dest_rel_path: str,
) -> str:
img = Image.open(src_path).convert("RGBA")
overlay = Image.new("RGBA", img.size, (255, 255, 255, 0))
draw = ImageDraw.Draw(overlay)
try:
font = ImageFont.truetype("DejaVuSans-Bold.ttf", max(14, img.size[0] // 40))
except OSError:
font = ImageFont.load_default()
for i in wrong_ids:
if i >= len(lines):
continue
bbox = lines[i].get("bbox") or [0, 0, 0, 0]
x1, y1, x2, y2 = bbox
pad = 6
box = [x1 - pad, y1 - pad, x2 + pad, y2 + pad]
draw.rounded_rectangle(box, radius=4, fill=(255, 59, 48, 55), outline=(255, 59, 48, 220), width=3)
draw.text((x1, max(0, y1 - 18)), "×", fill=(255, 59, 48, 255), font=font)
combined = Image.alpha_composite(img, overlay).convert("RGB")
full_path = Path(settings.UPLOAD_DIR) / dest_rel_path
full_path.parent.mkdir(parents=True, exist_ok=True)
combined.save(full_path, format="JPEG", quality=92)
return dest_rel_path
def split_solution_sections(text: str) -> tuple[str | None, str]:
if "## 解题思路" not in text:
return None, text
parts = re.split(r"\n##\s*", text, maxsplit=1)
if len(parts) < 2:
return None, text
approach = parts[0].replace("## 解题思路", "").strip()
rest = "## " + parts[1]
return approach or None, rest.strip()
+48 -14
View File
@@ -1,5 +1,3 @@
import enum
import httpx import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -36,7 +34,7 @@ OCR 原文:
{ocr_text} {ocr_text}
""" """
SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下题目给出详细解法 SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请像「作业帮」一样,先讲清楚解题思路,再给出完整解答
【学段要求 — 严禁超纲】 【学段要求 — 严禁超纲】
{curriculum} {curriculum}
@@ -44,14 +42,31 @@ SOLUTION_PROMPT = """你是一位耐心的{stage}{subject}老师。请为以下
题目: 题目:
{question_text} {question_text}
请按以下结构输出: 严格按以下 Markdown 结构输出:
1. 考点分析({stage}范围内)
2. 解题步骤(逐步推导,每步说明依据) ## 解题思路
3. 易错点提醒 (2-5 句话:这题考什么、从哪里入手、关键一步是什么,让学生先懂「怎么想」)
4. 若必须使用超纲方法才能解,请改用{stage}可理解的方法重新解答,不得输出超纲解法。
## 详细解答
(分步骤完整推导,每步说明依据)
## 易错点
(指出常见错误及正确做法)
严禁使用超纲方法;若原题超纲,请给出{stage}课内可理解的解法。
""" """
OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请为以下奥数题给出详细解题思路与完整解答 ERROR_DETECT_PROMPT = """你是{stage}{subject}老师。以下是试卷/作业 OCR 识别结果,每行前有编号
请找出「学生答错的部分」:错误答案、被打叉的作答、明显不正确的计算结果等。
{numbered_lines}
只输出 JSON,不要其他文字:
{{"wrong_line_ids": [行编号整数列表]}}
若整张图就是一道错题,请标注含有错误答案或作答的行;找不到则标注最后作答行。
"""
OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请像优秀辅导老师一样,先讲解题思路,再完整解答。
【奥数学段要求 — 严禁超纲】 【奥数学段要求 — 严禁超纲】
{curriculum} {curriculum}
@@ -59,11 +74,18 @@ OLYMPIAD_SOLUTION_PROMPT = """你是一位{stage}奥数教练。请为以下奥
题目: 题目:
{question_text} {question_text}
请按以下结构输出: 严格按以下 Markdown 结构输出:
1. 题型与思路切入点({stage}奥数常见技巧)
2. 详细解答步骤 ## 解题思路
3. 关键技巧总结(仅限{stage}奥数范围 (点明题型、突破口、{stage}奥数常用技巧
4. 严禁使用超出上述范围的方法;若题目过难,给出{stage}可接受的培优思路。
## 详细解答
(完整步骤)
## 关键技巧
(总结,仅限{stage}奥数范围)
严禁超纲;过难题给出{stage}可接受的培优思路。
""" """
@@ -167,3 +189,15 @@ async def generate_solution(
question_text=question_text, question_text=question_text,
) )
return await generate_text(prompt, cfg) return await generate_text(prompt, cfg)
async def detect_wrong_line_ids(
cfg: AIConfig,
subject: str,
ocr_lines: list[dict],
school_level=None,
) -> str:
stage = school_level_label(school_level)
numbered = "\n".join(f"[{i}] {line.get('text', '')}" for i, line in enumerate(ocr_lines))
prompt = ERROR_DETECT_PROMPT.format(stage=stage, subject=subject, numbered_lines=numbered)
return await generate_text(prompt, cfg)
+14
View File
@@ -65,3 +65,17 @@ def run_migrations() -> None:
with engine.begin() as conn: with engine.begin() as conn:
for clause in alters: for clause in alters:
conn.execute(text(f"ALTER TABLE system_settings {clause}")) conn.execute(text(f"ALTER TABLE system_settings {clause}"))
if "wrong_questions" in tables:
wq_columns = {col["name"] for col in inspector.get_columns("wrong_questions")}
wq_alters: list[str] = []
if "solution_approach" not in wq_columns:
wq_alters.append("ADD COLUMN solution_approach TEXT")
if "mark_regions_json" not in wq_columns:
wq_alters.append("ADD COLUMN mark_regions_json TEXT")
if "annotated_image_path" not in wq_columns:
wq_alters.append("ADD COLUMN annotated_image_path VARCHAR(512)")
if wq_alters:
with engine.begin() as conn:
for clause in wq_alters:
conn.execute(text(f"ALTER TABLE wrong_questions {clause}"))
+51 -10
View File
@@ -1,5 +1,7 @@
from pathlib import Path from pathlib import Path
from PIL import Image
from app.core.config import settings from app.core.config import settings
_ocr_engine = None _ocr_engine = None
@@ -14,18 +16,52 @@ def get_ocr_engine():
return _ocr_engine return _ocr_engine
def run_ocr(image_path: str) -> str: def _bbox_from_box(box: list) -> list[float]:
xs = [float(p[0]) for p in box]
ys = [float(p[1]) for p in box]
return [min(xs), min(ys), max(xs), max(ys)]
def run_ocr_with_regions(image_path: str) -> dict:
"""Return OCR text plus line-level bounding boxes for annotation."""
engine = get_ocr_engine() engine = get_ocr_engine()
result = engine.ocr(image_path, cls=True) result = engine.ocr(image_path, cls=True)
if not result or not result[0]: lines: list[dict] = []
return "" if result and result[0]:
lines = [] for item in result[0]:
for line in result[0]: if not item or len(item) < 2:
if line and len(line) >= 2: continue
text = line[1][0] box, rec = item[0], item[1]
if text: text = rec[0] if rec else ""
lines.append(text) conf = float(rec[1]) if rec and len(rec) > 1 else 0.0
return "\n".join(lines) if not text:
continue
lines.append(
{
"text": text,
"confidence": conf,
"box": box,
"bbox": _bbox_from_box(box),
}
)
width, height = 0, 0
try:
with Image.open(image_path) as img:
width, height = img.size
except OSError:
pass
return {
"text": "\n".join(line["text"] for line in lines),
"lines": lines,
"width": width,
"height": height,
}
def run_ocr(image_path: str) -> str:
return run_ocr_with_regions(image_path)["text"]
def save_upload_file(user_id: str, question_id: str, filename: str, content: bytes) -> str: def save_upload_file(user_id: str, question_id: str, filename: str, content: bytes) -> str:
@@ -38,3 +74,8 @@ def save_upload_file(user_id: str, question_id: str, filename: str, content: byt
full_path = Path(settings.UPLOAD_DIR) / rel_path full_path = Path(settings.UPLOAD_DIR) / rel_path
full_path.write_bytes(content) full_path.write_bytes(content)
return rel_path return rel_path
def annotated_rel_path(original_rel: str) -> str:
p = Path(original_rel)
return str(p.parent / f"{p.stem}_marked.jpg")
+3 -3
View File
@@ -104,10 +104,10 @@
进入 **错题库****奥数区** 标签: 进入 **错题库****奥数区** 标签:
1. 选择 **科目** 1. 选择 **科目**
2. 点击 **拍照上传**(调用摄像头)**相册选图** 2. 点击 **拍照上传** **相册选图**
3. 上传后后台自动:**OCR 识别 → AI 整理题目 → 生成解法** 3. 上传后自动:**OCR 识别 → 照片上红框标注错误位置 → 整理题目 → 生成「解题思路」与详细解答**
> 手机和平板已适配触控操作;拍照上传需浏览器授权摄像头 > 标注效果类似作业帮:错误作答处显示红色框和 × 标记。详情页可切换「标注图 / 原图」
### 4.2 奥数区 ### 4.2 奥数区
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="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-FkWLM-t9.js"></script> <script type="module" crossorigin src="/assets/index-CKqFHGFD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css"> <link rel="stylesheet" crossorigin href="/assets/index-GY2etMYN.css">
</head> </head>
<body> <body>
+36 -12
View File
@@ -3,6 +3,7 @@ import api from '../api/client'
interface Props { interface Props {
questionId: string questionId: string
variant?: 'original' | 'annotated'
className?: string className?: string
alt?: string alt?: string
style?: React.CSSProperties style?: React.CSSProperties
@@ -10,6 +11,7 @@ interface Props {
export default function AuthenticatedImage({ export default function AuthenticatedImage({
questionId, questionId,
variant = 'original',
className, className,
alt = '题目', alt = '题目',
style, style,
@@ -21,36 +23,58 @@ export default function AuthenticatedImage({
let objectUrl: string | null = null let objectUrl: string | null = null
let cancelled = false let cancelled = false
api const load = async (path: string, fallback?: string) => {
.get(`/wrong-questions/${questionId}/image`, { responseType: 'blob' }) try {
.then((res) => { const res = await api.get(path, { responseType: 'blob' })
if (cancelled) return if (cancelled) return
objectUrl = URL.createObjectURL(res.data) objectUrl = URL.createObjectURL(res.data)
setSrc(objectUrl) setSrc(objectUrl)
setFailed(false) setFailed(false)
}) } catch {
.catch(() => { if (fallback && !cancelled) {
if (!cancelled) setFailed(true) 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 () => { return () => {
cancelled = true cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl) if (objectUrl) URL.revokeObjectURL(objectUrl)
} }
}, [questionId]) }, [questionId, variant])
if (failed) { if (failed) {
return ( 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> </div>
) )
} }
if (!src) { if (!src) {
return ( return <div className={className} style={{ ...style, background: '#fafafa' }} />
<div className={className} style={{ ...style, background: '#fafafa' }} />
)
} }
return <img src={src} alt={alt} className={className} style={style} /> return <img src={src} alt={alt} className={className} style={style} />
@@ -38,7 +38,7 @@ export default function WrongQuestionList({
{items.map((wq) => ( {items.map((wq) => (
<div key={wq.id} className="wq-card"> <div key={wq.id} className="wq-card">
<div className="wq-card-click" onClick={() => onSelect(wq.id)}> <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"> <div className="wq-card-body">
<Typography.Text strong>{wq.subject_name}</Typography.Text> <Typography.Text strong>{wq.subject_name}</Typography.Text>
{wq.category === 'olympiad' && ( {wq.category === 'olympiad' && (
+1 -1
View File
@@ -198,7 +198,7 @@ export default function StudentDetailPage() {
children: ( children: (
<div> <div>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{stageLabel} {stageLabel}
</Typography.Paragraph> </Typography.Paragraph>
<WrongQuestionUpload <WrongQuestionUpload
studentId={id!} 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 { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import AuthenticatedImage from '../components/AuthenticatedImage' import AuthenticatedImage from '../components/AuthenticatedImage'
@@ -24,7 +24,9 @@ export default function WrongQuestionDetail({
const [wq, setWq] = useState<WrongQuestion | null>(null) const [wq, setWq] = useState<WrongQuestion | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [questionText, setQuestionText] = useState('') const [questionText, setQuestionText] = useState('')
const [approachText, setApproachText] = useState('')
const [solutionText, setSolutionText] = useState('') const [solutionText, setSolutionText] = useState('')
const [imageMode, setImageMode] = useState<'annotated' | 'original'>('annotated')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [regenerating, setRegenerating] = useState(false) const [regenerating, setRegenerating] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@@ -35,7 +37,9 @@ export default function WrongQuestionDetail({
const { data } = await wrongQuestionApi.get(questionId) const { data } = await wrongQuestionApi.get(questionId)
setWq(data) setWq(data)
setQuestionText(data.question_text || '') setQuestionText(data.question_text || '')
setApproachText(data.solution_approach || '')
setSolutionText(data.solution_text || '') setSolutionText(data.solution_text || '')
setImageMode(data.has_annotated_image ? 'annotated' : 'original')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -50,6 +54,7 @@ export default function WrongQuestionDetail({
try { try {
await wrongQuestionApi.update(questionId, { await wrongQuestionApi.update(questionId, {
question_text: questionText, question_text: questionText,
solution_approach: approachText,
solution_text: solutionText, solution_text: solutionText,
}) })
message.success('已保存') message.success('已保存')
@@ -65,8 +70,9 @@ export default function WrongQuestionDetail({
const { data } = await wrongQuestionApi.regenerate(questionId) const { data } = await wrongQuestionApi.regenerate(questionId)
setWq(data) setWq(data)
setQuestionText(data.question_text || '') setQuestionText(data.question_text || '')
setApproachText(data.solution_approach || '')
setSolutionText(data.solution_text || '') setSolutionText(data.solution_text || '')
message.success('解已重新生成') message.success('解题思路已重新生成')
onUpdated() onUpdated()
} catch { } catch {
message.error('生成失败,请检查 AI 模型配置') message.error('生成失败,请检查 AI 模型配置')
@@ -77,7 +83,7 @@ export default function WrongQuestionDetail({
const handleRetryOcr = async () => { const handleRetryOcr = async () => {
await wrongQuestionApi.retryOcr(questionId) await wrongQuestionApi.retryOcr(questionId)
message.info('已重新识别,请稍后刷新') message.info('已重新识别并标注,请稍后刷新')
onUpdated() onUpdated()
onClose() onClose()
} }
@@ -114,9 +120,9 @@ export default function WrongQuestionDetail({
</Button> </Button>
</Popconfirm> </Popconfirm>
<Button onClick={handleRetryOcr}> OCR</Button> <Button onClick={handleRetryOcr}></Button>
<Button loading={regenerating} onClick={handleRegenerate}> <Button loading={regenerating} onClick={handleRegenerate}>
</Button> </Button>
<Button type="primary" loading={saving} onClick={handleSave}> <Button type="primary" loading={saving} onClick={handleSave}>
@@ -127,10 +133,15 @@ export default function WrongQuestionDetail({
<Spin spinning={loading}> <Spin spinning={loading}>
{wq && ( {wq && (
<> <>
<Typography.Text type="secondary">{STATUS_LABELS[wq.status]}</Typography.Text> <Space wrap style={{ marginBottom: 8 }}>
{wq.solution_text && ( <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 <Alert
message="AI 生成内容,请核对后再使用" message="AI 识别与标注,请核对后再使用"
type="warning" type="warning"
showIcon showIcon
style={{ margin: '12px 0' }} style={{ margin: '12px 0' }}
@@ -138,8 +149,21 @@ export default function WrongQuestionDetail({
)} )}
<Row gutter={16} style={{ marginTop: 12 }}> <Row gutter={16} style={{ marginTop: 12 }}>
<Col xs={24} md={10}> <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 <AuthenticatedImage
questionId={wq.id} questionId={wq.id}
variant={imageMode}
alt="原题" alt="原题"
style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }} style={{ width: '100%', borderRadius: 8, border: '1px solid #f0f0f0' }}
/> />
@@ -164,12 +188,36 @@ export default function WrongQuestionDetail({
<Col xs={24} md={14}> <Col xs={24} md={14}>
<Typography.Text strong></Typography.Text> <Typography.Text strong></Typography.Text>
<Input.TextArea <Input.TextArea
rows={6} rows={5}
value={questionText} value={questionText}
onChange={(e) => setQuestionText(e.target.value)} onChange={(e) => setQuestionText(e.target.value)}
style={{ marginTop: 8, marginBottom: 16 }} 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 <Input.TextArea
rows={8} rows={8}
value={solutionText} value={solutionText}
@@ -179,7 +227,7 @@ export default function WrongQuestionDetail({
{solutionText && ( {solutionText && (
<div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}> <div style={{ background: '#fafafa', padding: 12, borderRadius: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}> <Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text> </Typography.Text>
<ReactMarkdown>{solutionText}</ReactMarkdown> <ReactMarkdown>{solutionText}</ReactMarkdown>
</div> </div>
+11
View File
@@ -110,11 +110,22 @@ export interface WrongQuestion {
image_path: string image_path: string
ocr_raw_text: string | null ocr_raw_text: string | null
question_text: string | null question_text: string | null
solution_approach: string | null
solution_text: string | null solution_text: string | null
mark_regions: MarkRegion[] | null
has_annotated_image: boolean
status: WrongQuestionStatus status: WrongQuestionStatus
created_at: string 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> = { export const EXAM_TYPE_LABELS: Record<ExamType, string> = {
weekly: '周考', weekly: '周考',
monthly: '月考', monthly: '月考',