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:
parent
cc53b5cc0c
commit
f05e7ec425
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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": "错误原因分析",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue