作业帮式错题标注:OCR 定位错误红框 + 解题思路。
- PaddleOCR 行级坐标 + AI 识别错答区域,生成标注图 - 解法拆分为「解题思路」与「详细解答」 - 详情页标注图/原图切换,列表显示标注缩略图
This commit is contained in:
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -104,10 +104,10 @@
|
|||||||
进入 **错题库** 或 **奥数区** 标签:
|
进入 **错题库** 或 **奥数区** 标签:
|
||||||
|
|
||||||
1. 选择 **科目**
|
1. 选择 **科目**
|
||||||
2. 点击 **拍照上传**(调用摄像头)或 **相册选图**
|
2. 点击 **拍照上传** 或 **相册选图**
|
||||||
3. 上传后后台自动:**OCR 识别 → AI 整理题目 → 生成解法**
|
3. 上传后自动:**OCR 识别 → 照片上红框标注错误位置 → 整理题目 → 生成「解题思路」与详细解答**
|
||||||
|
|
||||||
> 手机和平板已适配触控操作;拍照上传需浏览器授权摄像头。
|
> 标注效果类似作业帮:错误作答处显示红色框和 × 标记。详情页可切换「标注图 / 原图」。
|
||||||
|
|
||||||
### 4.2 奥数区
|
### 4.2 奥数区
|
||||||
|
|
||||||
|
|||||||
+432
File diff suppressed because one or more lines are too long
-432
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-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>
|
||||||
|
|||||||
@@ -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' && (
|
||||||
|
|||||||
@@ -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!}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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: '月考',
|
||||||
|
|||||||
Reference in New Issue
Block a user