feat: AI识别生成带排版HTML题目,试卷PDF用HTML渲染(含图形表格)

- OCR prompt升级:输出question_html字段(CSS绘制图形、表格、数学符号)
- Mistake模型加question_html字段
- 新增html_pdf_service.py用xhtml2pdf渲染HTML为A4 PDF
- exam路由优先用HTML PDF服务,回退到reportlab
- 前端类型加question_html
This commit is contained in:
chen guanjun 2026-07-13 14:57:11 +08:00
parent cc53b5cc0c
commit f05e7ec425
7 changed files with 296 additions and 25 deletions

View File

@ -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)

View File

@ -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(

View File

@ -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 = "<br>".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"),

View File

@ -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]

View File

@ -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 = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<style>
@page {{
size: A4;
margin: 20mm;
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: {font};
font-size: 14px;
color: #1e293b;
line-height: 1.6;
}}
.header {{
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 12px;
margin-bottom: 20px;
}}
.header h1 {{
font-size: 22px;
margin-bottom: 4px;
}}
.header .subtitle {{
font-size: 12px;
color: #666;
}}
.section-title {{
font-size: 16px;
font-weight: bold;
margin-top: 24px;
margin-bottom: 12px;
padding: 6px 0;
border-bottom: 1px solid #ddd;
}}
.question {{
margin-bottom: 24px;
page-break-inside: avoid;
}}
.question .q-num {{
font-weight: bold;
margin-bottom: 6px;
}}
.question .q-content {{
padding: 8px 0;
}}
.question .q-content img {{
max-width: 60%;
height: auto;
display: block;
margin: 8px auto;
}}
.question .q-content table {{
border-collapse: collapse;
margin: 8px 0;
width: auto;
}}
.question .q-content table td,
.question .q-content table th {{
border: 1px solid #333;
padding: 4px 8px;
text-align: center;
}}
.question .q-image {{
text-align: center;
margin: 8px 0;
}}
.question .q-image img {{
max-width: 50%;
height: auto;
border: 1px solid #eee;
}}
.answer-section {{
page-break-before: always;
}}
.answer-section h2 {{
font-size: 18px;
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 8px;
margin-bottom: 16px;
}}
.answer-item {{
margin-bottom: 8px;
font-size: 13px;
}}
.answer-item .analysis {{
color: #666;
font-size: 12px;
}}
/* AI-generated question HTML overrides */
.q-content svg {{ max-width: 100%; }}
.q-content .diagram {{ text-align: center; margin: 8px 0; }}
</style>
</head>
<body>
<div class="header">
<h1>{title}</h1>
<div class="subtitle">{student_name} · {grade} · {today}</div>
</div>
{questions_html}
{answers_html}
</body>
</html>"""
def _get_image_html(m: dict) -> str:
"""Generate <img> 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'<div class="q-image"><img src="file:///{full.replace(os.sep, "/")}" /></div>')
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'<div class="section-title">{label}{qtype}(共{len(questions)}题)</div>\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'<p>{q.get("question_text", "")}</p>'
# Add original images below the question
img_html = _get_image_html(q)
student_ans = q.get("student_answer", "")
ans_note = f' <span style="color:#ef4444;font-size:12px;">(你的答案:{student_ans}</span>' if student_ans else ""
questions_html += f'''<div class="question">
<div class="q-num">{q_num}.{ans_note}</div>
<div class="q-content">{q_html}</div>
{img_html}
</div>
'''
# Answer
if include_answers:
correct = q.get("correct_answer") or ""
analysis = q.get("error_analysis") or ""
analysis_html = f' <span class="analysis">{analysis}</span>' if analysis else ""
answers_html += f'<div class="answer-item">{q_num}. {correct}{analysis_html}</div>\n'
q_num += 1
# Build answers section
answer_section = ""
if include_answers and answers_html:
answer_section = f'''<div class="answer-section">
<h2>参考答案</h2>
{answers_html}
</div>'''
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

View File

@ -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": "错误原因分析",

View File

@ -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;