diff --git a/backend/app/models/mistake.py b/backend/app/models/mistake.py index 0e3f524..569ad73 100644 --- a/backend/app/models/mistake.py +++ b/backend/app/models/mistake.py @@ -33,6 +33,7 @@ class Mistake(Base): grade_level: Mapped[str] = mapped_column(String(20), nullable=True) question_text: Mapped[str] = mapped_column(Text, nullable=False) + question_html: Mapped[str] = mapped_column(Text, nullable=True) # AI-generated formatted HTML correct_answer: Mapped[str] = mapped_column(Text, nullable=True) student_answer: Mapped[str] = mapped_column(Text, nullable=True) error_analysis: Mapped[str] = mapped_column(Text, nullable=True) diff --git a/backend/app/routers/exam.py b/backend/app/routers/exam.py index 4e3a037..28dbf15 100644 --- a/backend/app/routers/exam.py +++ b/backend/app/routers/exam.py @@ -15,6 +15,7 @@ from app.models.mistake import Mistake from app.models.user import User from app.routers.dependencies import get_current_user from app.schemas.exam import ExamGenerateRequest, ExamGenerateResponse +from app.services.html_pdf_service import generate_html_exam_pdf, HAS_HTML2PDF from app.services.pdf_service import generate_exam_pdf router = APIRouter(prefix="/exam", tags=["试卷"]) @@ -43,10 +44,11 @@ async def generate_exam( if not mistakes: raise HTTPException(status_code=400, detail="没有符合条件的错题") - # Prepare mistake dicts for PDF (include image paths) + # Prepare mistake dicts for PDF (include image paths + AI HTML) mistake_dicts = [ { "question_text": m.question_text, + "question_html": m.question_html, "question_type": m.question_type, "correct_answer": m.correct_answer, "student_answer": m.student_answer, @@ -60,20 +62,30 @@ async def generate_exam( for m in mistakes ] - # Generate PDF + # Generate PDF — prefer HTML-based (weasyprint) for rich formatting filename = f"exam_{uuid.uuid4().hex[:12]}.pdf" user_dir = os.path.join(settings.UPLOAD_DIR, str(user.id)) os.makedirs(user_dir, exist_ok=True) output_path = os.path.join(user_dir, filename) - generate_exam_pdf( - title=data.title, - student_name=user.display_name or user.username, - grade=user.grade or "", - mistakes=mistake_dicts, - include_answers=data.include_answers, - output_path=output_path, - ) + if HAS_HTML2PDF: + generate_html_exam_pdf( + title=data.title, + student_name=user.display_name or user.username, + grade=user.grade or "", + mistakes=mistake_dicts, + include_answers=data.include_answers, + output_path=output_path, + ) + else: + generate_exam_pdf( + title=data.title, + student_name=user.display_name or user.username, + grade=user.grade or "", + mistakes=mistake_dicts, + include_answers=data.include_answers, + output_path=output_path, + ) # Save record exam = ExamPaper( diff --git a/backend/app/routers/ocr.py b/backend/app/routers/ocr.py index c718580..8cb62d5 100644 --- a/backend/app/routers/ocr.py +++ b/backend/app/routers/ocr.py @@ -62,7 +62,9 @@ async def ocr_mistake( ocr_result = all_ocr_results[0] if len(all_ocr_results) > 1: combined_text = "\n".join(r.get("question_text", "") for r in all_ocr_results if r.get("question_text")) + combined_html = "
".join(r.get("question_html", "") for r in all_ocr_results if r.get("question_html")) ocr_result["question_text"] = combined_text + ocr_result["question_html"] = combined_html # Create mistake record mistake = Mistake( @@ -70,6 +72,7 @@ async def ocr_mistake( subject=ocr_result.get("subject") or subject or "数学", question_type=ocr_result.get("question_type"), question_text=ocr_result.get("question_text") or "识别失败,请手动输入", + question_html=ocr_result.get("question_html"), correct_answer=ocr_result.get("correct_answer"), student_answer=ocr_result.get("student_answer"), error_analysis=ocr_result.get("error_analysis"), diff --git a/backend/app/schemas/mistake.py b/backend/app/schemas/mistake.py index aae84d2..e7cde5c 100644 --- a/backend/app/schemas/mistake.py +++ b/backend/app/schemas/mistake.py @@ -36,6 +36,7 @@ class MistakeResponse(BaseModel): question_type: Optional[str] grade_level: Optional[str] question_text: str + question_html: Optional[str] = None correct_answer: Optional[str] student_answer: Optional[str] error_analysis: Optional[str] diff --git a/backend/app/services/html_pdf_service.py b/backend/app/services/html_pdf_service.py new file mode 100644 index 0000000..92c740d --- /dev/null +++ b/backend/app/services/html_pdf_service.py @@ -0,0 +1,249 @@ +"""HTML-based PDF generation using xhtml2pdf + AI-generated question HTML.""" +from __future__ import annotations +import os +import hashlib +from datetime import date + +# Fix reportlab 4.x + Python 3.8 compatibility (must be before xhtml2pdf import) +_original_md5 = hashlib.md5 +def _patched_md5(*args, **kwargs): + kwargs.pop("usedforsecurity", None) + return _original_md5(*args, **kwargs) +hashlib.md5 = _patched_md5 + +try: + from xhtml2pdf import pisa + HAS_HTML2PDF = True +except ImportError: + HAS_HTML2PDF = False + +from app.config import settings + +# Chinese font stack +FONT_FAMILY = '"Noto Sans SC", "Microsoft YaHei", "PingFang SC", sans-serif' + +HTML_TEMPLATE = """ + + + + + + +
+

{title}

+
{student_name} · {grade} · {today}
+
+ +{questions_html} + +{answers_html} + +""" + + +def _get_image_html(m: dict) -> str: + """Generate tags for a mistake's images.""" + upload_dir = m.get("upload_dir", "") + urls = [] + raw = m.get("image_urls") + if raw and isinstance(raw, list): + urls.extend(raw) + elif m.get("image_url"): + urls.append(m["image_url"]) + + if not urls: + return "" + + imgs = [] + for url in urls: + if not url: + continue + # Convert /uploads/2/filename.jpg to file:// absolute path + rel = url.lstrip("/") + full = os.path.normpath(os.path.join(upload_dir, "..", rel)) + if os.path.exists(full): + imgs.append(f'
') + return "\n".join(imgs) + + +def generate_html_exam_pdf( + title: str, + student_name: str, + grade: str, + mistakes: list[dict], + include_answers: bool, + output_path: str, +) -> str: + """Generate A4 PDF using AI-generated HTML + xhtml2pdf.""" + if not HAS_HTML2PDF: + raise RuntimeError("xhtml2pdf not installed. Run: pip install xhtml2pdf") + + section_labels = ["一", "二", "三", "四", "五", "六", "七", "八", "九", "十"] + + # Group by question type + groups: dict[str, list[dict]] = {} + for m in mistakes: + qtype = m.get("question_type") or "其他" + groups.setdefault(qtype, []).append(m) + + questions_html = "" + answers_html = "" + q_num = 1 + + for idx, (qtype, questions) in enumerate(groups.items()): + label = section_labels[idx] if idx < len(section_labels) else str(idx + 1) + questions_html += f'
{label}、{qtype}(共{len(questions)}题)
\n' + + for q in questions: + # Use AI-generated HTML if available, otherwise plain text + q_html = q.get("question_html", "") + if not q_html: + q_html = f'

{q.get("question_text", "")}

' + + # Add original images below the question + img_html = _get_image_html(q) + + student_ans = q.get("student_answer", "") + ans_note = f' (你的答案:{student_ans})' if student_ans else "" + + questions_html += f'''
+
{q_num}.{ans_note}
+
{q_html}
+ {img_html} +
+''' + # Answer + if include_answers: + correct = q.get("correct_answer") or "—" + analysis = q.get("error_analysis") or "" + analysis_html = f' ({analysis})' if analysis else "" + answers_html += f'
{q_num}. {correct}{analysis_html}
\n' + + q_num += 1 + + # Build answers section + answer_section = "" + if include_answers and answers_html: + answer_section = f'''
+

参考答案

+{answers_html} +
''' + + full_html = HTML_TEMPLATE.format( + font=FONT_FAMILY, + title=title, + student_name=student_name, + grade=grade, + today=date.today().strftime("%Y年%m月%d日"), + questions_html=questions_html, + answers_html=answer_section, + ) + + # Write HTML to temp file, then convert to PDF + html_path = output_path + ".html" + with open(html_path, "w", encoding="utf-8") as f: + f.write(full_html) + + with open(output_path, "wb") as pdf_file: + status = pisa.CreatePDF(full_html, dest=pdf_file, encoding="utf-8") + + # Cleanup temp HTML + if os.path.exists(html_path): + os.remove(html_path) + + if status.err: + raise RuntimeError(f"PDF generation failed with {status.err} errors") + + return output_path diff --git a/backend/app/services/ocr_service.py b/backend/app/services/ocr_service.py index 4717613..4218aa0 100644 --- a/backend/app/services/ocr_service.py +++ b/backend/app/services/ocr_service.py @@ -6,23 +6,26 @@ import httpx from app.config import settings -SYSTEM_PROMPT = """你是一个专门为小学生错题识别设计的AI助手。 -你需要分析上传的错题图片,提取以下信息: +SYSTEM_PROMPT = """你是一个专门为小学生错题识别和排版设计的AI助手。 +你需要分析上传的错题图片,做两件事: -1. **题目内容**:完整准确地提取题目文字 -2. **题型**:判断题目类型 - - 数学:填空题、选择题、计算题、应用题、判断题 - - 语文:拼音题、字词题、句子题、阅读理解、作文 - - 英语:单词题、语法题、翻译题、阅读理解 -3. **学生答案**:如果能看到学生写的错误答案 -4. **正确答案**:如果能判断出正确答案 -5. **可能的错误原因**:根据错误类型推断 -6. **知识点**:归类到具体知识点 +## 任务一:提取结构化信息 +提取:科目、题型、题目文字、学生错误答案、正确答案、错误原因、知识点、置信度。 -注意事项: +## 任务二:生成排版HTML +把题目用精美的HTML排版出来,要求: +- 用HTML+CSS绘制题目中出现的图形(几何图形用CSS border/transform,线段图用div背景色,表格用table标签) +- 数学公式用Unicode符号(× ÷ ≥ ≤ √ ² ³ ½ ¼)或HTML标签 +- 选择题的选项用整齐排列 +- 填空题的空位用下划线或空格 +- 适合A4纸打印,宽度800px以内 +- 字体大小:题目16px,选项14px,说明12px +- 如果有图形/图表/示意图,必须用HTML+CSS重新绘制出来,不能只写文字描述 +- 如果有表格,用标准table标签,带边框 + +注意: - 手写体和印刷体都要能识别 -- 数学公式要准确提取(使用标准数学符号) -- 如果图片模糊或不确定,confidence设为较低值 +- 如果图片模糊,confidence设为较低值 - 必须返回严格的JSON格式,不要包含任何其他文字""" USER_PROMPT_TEMPLATE = """请分析这张错题图片。 @@ -32,7 +35,8 @@ USER_PROMPT_TEMPLATE = """请分析这张错题图片。 {{ "subject": "数学/语文/英语", "question_type": "题型", - "question_text": "完整题目内容", + "question_text": "完整题目内容(纯文字版,用于搜索和列表显示)", + "question_html": "题目的完整HTML排版代码(包含图形、表格等所有视觉元素,直接可用于浏览器渲染)", "student_answer": "学生写的错误答案(如果能看到)", "correct_answer": "正确答案(如果能判断)", "error_analysis": "错误原因分析", diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4e68d8d..ee11c2f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -26,6 +26,7 @@ export interface Mistake { question_type: string | null; grade_level: string | null; question_text: string; + question_html: string | null; correct_answer: string | null; student_answer: string | null; error_analysis: string | null;