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:
parent
fa3918feb9
commit
f54d84136c
303
.autograde/generate_pdf_report.py
Normal file
303
.autograde/generate_pdf_report.py
Normal file
@ -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''
|
||||
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())
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user