final-vibevault-template/.autograde/generate_pdf_report.py
sit002 f54d84136c feat: generate PDF grade report and upload to student repo
- Add generate_pdf_report.py using weasyprint
- Install weasyprint dependencies in workflow
- Generate combined report with grades, REPORT.md, FRONTEND.md
- Upload PDF/Markdown report to student repo's reports/ directory
2025-12-02 12:48:34 +08:00

304 lines
8.9 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
生成 PDF 成绩报告
合并 REPORT.md、FRONTEND.md、成绩摘要生成完整的 PDF 报告
使用 weasyprint 进行 HTML→PDF 转换
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
try:
import markdown
from weasyprint import HTML, CSS
HAS_PDF_SUPPORT = True
except ImportError:
HAS_PDF_SUPPORT = False
def load_json(filepath, default=None):
"""安全加载 JSON 文件"""
if not os.path.exists(filepath):
return default or {}
try:
with open(filepath, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"Error loading {filepath}: {e}", file=sys.stderr)
return default or {}
def read_file(filepath):
"""读取文件内容"""
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
return ""
def generate_grade_summary(final_grade):
"""生成成绩摘要 Markdown"""
total = final_grade.get("total_score", 0)
max_score = final_grade.get("max_score", 100)
breakdown = final_grade.get("breakdown", {})
lines = [
"# 📊 成绩报告",
"",
f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
f"## 总分: {total:.2f} / {max_score}",
"",
"### 分项成绩",
"",
"| 项目 | 得分 | 满分 |",
"|------|------|------|",
]
# 编程测试
prog = breakdown.get("programming", {})
if prog:
lines.append(f"| 编程测试 | {prog.get('score', 0):.2f} | {prog.get('max_score', 80)} |")
# REPORT.md
report = breakdown.get("report", {})
if report:
lines.append(f"| REPORT.md | {report.get('score', 0):.2f} | {report.get('max_score', 10)} |")
# FRONTEND.md
frontend = breakdown.get("frontend", {})
if frontend:
lines.append(f"| FRONTEND.md | {frontend.get('score', 0):.2f} | {frontend.get('max_score', 10)} |")
# 编程测试详情
if prog.get("groups"):
lines.extend([
"",
"### 编程测试详情",
"",
"| 分组 | 通过 | 总数 | 得分 | 满分 |",
"|------|------|------|------|------|",
])
for group_name, group_info in prog["groups"].items():
lines.append(
f"| {group_name} | {group_info.get('passed', 0)} | "
f"{group_info.get('total', 0)} | {group_info.get('score', 0):.2f} | "
f"{group_info.get('max_score', 0)} |"
)
# LLM 评分详情
for section_key, section_title in [
("report", "📝 REPORT.md 评分详情"),
("frontend", "🎨 FRONTEND.md 评分详情")
]:
section = breakdown.get(section_key, {})
criteria = section.get("criteria", [])
if criteria:
lines.extend([
"",
f"### {section_title}",
"",
"| 评分项 | 得分 | 评语 |",
"|--------|------|------|",
])
for c in criteria:
reason = c.get("reason", "").replace("|", "").replace("\n", " ")[:80]
lines.append(f"| {c.get('id', '')} | {c.get('score', 0)} | {reason} |")
confidence = section.get("confidence")
if confidence:
lines.append(f"\n*置信度: {confidence:.2f}*")
flags = section.get("flags", [])
if flags:
lines.append(f"\n*标记: {', '.join(flags)}*")
lines.extend(["", "---", ""])
return "\n".join(lines)
def fix_image_paths(content, images_dir):
"""修复图片路径为绝对路径(用于 PDF 生成)"""
if not images_dir or not os.path.isdir(images_dir):
return content
abs_images_dir = os.path.abspath(images_dir)
# 替换 Markdown 图片语法
def replace_img(match):
alt = match.group(1)
src = match.group(2)
if not src.startswith(('http://', 'https://', '/')):
# 相对路径,转为绝对路径
abs_src = os.path.join(abs_images_dir, os.path.basename(src))
if os.path.exists(abs_src):
return f'![{alt}](file://{abs_src})'
return match.group(0)
content = re.sub(r'!\[([^\]]*)\]\(([^)]+)\)', replace_img, content)
return content
def create_combined_markdown(args, final_grade):
"""创建合并后的 Markdown 文件"""
# 成绩摘要
grade_summary = generate_grade_summary(final_grade)
# 读取学生报告
report_content = read_file(args.report)
frontend_content = read_file(args.frontend)
# 修复图片路径
frontend_content = fix_image_paths(frontend_content, args.images)
# 合并内容
combined = [
grade_summary,
"# 📝 后端开发反思报告",
"",
report_content.strip() if report_content.strip() else "*(未提交)*",
"",
"---",
"",
"# 🎨 前端开发反思报告",
"",
frontend_content.strip() if frontend_content.strip() else "*(未提交)*",
]
return "\n".join(combined)
def markdown_to_html(md_content):
"""将 Markdown 转换为 HTML"""
extensions = ['tables', 'fenced_code', 'codehilite']
html_body = markdown.markdown(md_content, extensions=extensions)
# 添加 CSS 样式
html = f'''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
font-family: "Noto Sans CJK SC", "Microsoft YaHei", "PingFang SC", sans-serif;
font-size: 12pt;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}}
h1 {{ color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }}
h2 {{ color: #34495e; }}
h3 {{ color: #7f8c8d; }}
table {{
border-collapse: collapse;
width: 100%;
margin: 15px 0;
}}
th, td {{
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}}
th {{ background-color: #3498db; color: white; }}
tr:nth-child(even) {{ background-color: #f9f9f9; }}
code {{
background-color: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: "Consolas", monospace;
}}
pre {{
background-color: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
}}
img {{
max-width: 100%;
height: auto;
border: 1px solid #ddd;
border-radius: 5px;
margin: 10px 0;
}}
hr {{ border: none; border-top: 1px solid #ddd; margin: 30px 0; }}
blockquote {{
border-left: 4px solid #3498db;
margin: 15px 0;
padding: 10px 20px;
background-color: #f9f9f9;
}}
</style>
</head>
<body>
{html_body}
</body>
</html>'''
return html
def convert_to_pdf(md_content, pdf_file, images_dir=None):
"""使用 weasyprint 将 Markdown 转换为 PDF"""
if not HAS_PDF_SUPPORT:
print("weasyprint not available, skipping PDF generation", file=sys.stderr)
return False
try:
html_content = markdown_to_html(md_content)
# 设置基础 URL 用于图片加载
base_url = os.path.abspath(images_dir) if images_dir else os.getcwd()
HTML(string=html_content, base_url=base_url).write_pdf(pdf_file)
return True
except Exception as e:
print(f"PDF generation error: {e}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description="Generate PDF grade report")
parser.add_argument("--report", default="REPORT.md", help="REPORT.md file path")
parser.add_argument("--frontend", default="FRONTEND.md", help="FRONTEND.md file path")
parser.add_argument("--grade", default="final_grade.json", help="Final grade JSON file")
parser.add_argument("--images", default="images", help="Images directory")
parser.add_argument("--out", default="grade_report.pdf", help="Output PDF file")
parser.add_argument("--student-id", default="", help="Student ID for filename")
args = parser.parse_args()
# 加载成绩
final_grade = load_json(args.grade, {"total_score": 0, "max_score": 100, "breakdown": {}})
# 创建合并的 Markdown
combined_md = create_combined_markdown(args, final_grade)
# 保存 Markdown 文件(始终保存,作为备份)
md_out = args.out.replace(".pdf", ".md")
with open(md_out, "w", encoding="utf-8") as f:
f.write(combined_md)
print(f"📝 Markdown report saved: {md_out}")
# 尝试生成 PDF
if HAS_PDF_SUPPORT:
if convert_to_pdf(combined_md, args.out, args.images):
print(f"✅ PDF report generated: {args.out}")
return 0
else:
print(f"⚠️ PDF generation failed, markdown saved as fallback", file=sys.stderr)
return 0
else:
print(f" PDF support not available (weasyprint not installed), markdown saved")
return 0
if __name__ == "__main__":
sys.exit(main())