From f54d84136c1f625e0533232b334aa3e10f9d53d9 Mon Sep 17 00:00:00 2001 From: sit002 Date: Tue, 2 Dec 2025 12:48:34 +0800 Subject: [PATCH] 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 --- .autograde/generate_pdf_report.py | 303 ++++++++++++++++++++++++++++++ .gitea/workflows/autograde.yml | 68 ++++++- 2 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 .autograde/generate_pdf_report.py diff --git a/.autograde/generate_pdf_report.py b/.autograde/generate_pdf_report.py new file mode 100644 index 0000000..0fbb263 --- /dev/null +++ b/.autograde/generate_pdf_report.py @@ -0,0 +1,303 @@ +#!/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''' + + + + + + +{html_body} + +''' + 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()) diff --git a/.gitea/workflows/autograde.yml b/.gitea/workflows/autograde.yml index 4254819..53dccde 100644 --- a/.gitea/workflows/autograde.yml +++ b/.gitea/workflows/autograde.yml @@ -31,8 +31,9 @@ jobs: sed -i -E 's|https?://ports.ubuntu.com|http://mirrors.aliyun.com|g' "$f" || true done apt-get -o Acquire::Check-Valid-Until=false update -y - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip rsync - pip3 install --break-system-packages python-dotenv requests -i https://mirrors.aliyun.com/pypi/simple --trusted-host mirrors.aliyun.com + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip rsync \ + libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 libffi-dev shared-mime-info + pip3 install --break-system-packages python-dotenv requests markdown weasyprint -i https://mirrors.aliyun.com/pypi/simple --trusted-host mirrors.aliyun.com rm -rf /var/lib/apt/lists/* - name: Configure Gradle mirror (Aliyun) @@ -199,6 +200,69 @@ jobs: --out final_grade.json \ --summary final_summary.md + - name: Generate PDF report + working-directory: ${{ github.workspace }} + run: | + if [ -f final_grade.json ]; then + python3 ./.autograde/generate_pdf_report.py \ + --report REPORT.md \ + --frontend FRONTEND.md \ + --grade final_grade.json \ + --images images \ + --out grade_report.pdf + fi + + - name: Upload report to student repo + if: env.RUNNER_METADATA_TOKEN != '' + working-directory: ${{ github.workspace }} + env: + TOKEN: ${{ env.RUNNER_METADATA_TOKEN }} + REPO: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + COMMIT_SHA: ${{ github.sha }} + run: | + # 上传 PDF 或 Markdown 报告到学生仓库 + REPORT_FILE="" + if [ -f grade_report.pdf ]; then + REPORT_FILE="grade_report.pdf" + elif [ -f grade_report.md ]; then + REPORT_FILE="grade_report.md" + fi + + if [ -n "$REPORT_FILE" ]; then + # 使用内部地址 + API_URL="http://gitea:3000/api/v1" + SHORT_SHA=$(echo "$COMMIT_SHA" | cut -c1-7) + DEST_PATH="reports/grade_report_${SHORT_SHA}.${REPORT_FILE##*.}" + + # Base64 编码 + CONTENT=$(base64 -w 0 "$REPORT_FILE") + + # 检查文件是否存在 + SHA=$(curl -s -H "Authorization: token $TOKEN" \ + "$API_URL/repos/$REPO/contents/$DEST_PATH" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('sha','') if isinstance(d,dict) else '')" 2>/dev/null || echo "") + + # 构建请求 + if [ -n "$SHA" ]; then + JSON_DATA="{\"message\": \"Update grade report for $SHORT_SHA\", \"content\": \"$CONTENT\", \"sha\": \"$SHA\"}" + else + JSON_DATA="{\"message\": \"Add grade report for $SHORT_SHA\", \"content\": \"$CONTENT\"}" + fi + + # 上传文件 + RESULT=$(curl -s -X PUT -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + "$API_URL/repos/$REPO/contents/$DEST_PATH" \ + -d "$JSON_DATA") + + if echo "$RESULT" | grep -q '"content"'; then + echo "✅ Report uploaded to $DEST_PATH" + else + echo "⚠️ Failed to upload report: $RESULT" + fi + fi + - name: Create metadata working-directory: ${{ github.workspace }} env: