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
This commit is contained in:
sit002 2025-12-02 12:48:34 +08:00
parent fa3918feb9
commit f54d84136c
2 changed files with 369 additions and 2 deletions

View File

@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""
生成 PDF 成绩报告
合并 REPORT.mdFRONTEND.md成绩摘要生成完整的 PDF 报告
使用 weasyprint 进行 HTMLPDF 转换
"""
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())

View File

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