Compare commits

...

10 Commits

Author SHA1 Message Date
hblu
b399edb061 Update content
All checks were successful
autograde-final-vibevault / check-trigger (push) Successful in 12s
autograde-final-vibevault / grade (push) Has been skipped
2025-12-03 13:04:02 +08:00
hblu
8dfb1bba8a Update content 2025-12-03 12:46:38 +08:00
hblu
f6a83a1c80 Update content 2025-12-03 12:10:37 +08:00
cf43ba662d Update content [skip ci] 2025-12-02 16:21:06 +08:00
b979960828 feat: add [skip ci] support and tag trigger
- Skip CI when commit message contains [skip ci]
- Add tag trigger: push 'submit' or 'submit-*' tag to grade
- Prevents unnecessary CI runs during initialization
2025-12-02 16:04:42 +08:00
9fe26069d6 Add grade report for 6ab2aec 2025-12-02 15:58:23 +08:00
6ab2aec57e Update content 2025-12-02 15:55:32 +08:00
5e8d901a18 security: remove .autograde from student repo
- Grading scripts are now fetched from tests repo at runtime
- Students cannot modify grading scripts to cheat
- Workflow fetches scripts from private tests repo
2025-12-02 15:17:54 +08:00
8db9ee9b05 Add grade report for 3dc3436 2025-12-02 14:38:47 +08:00
3dc343684d fix: enforce integer scores matching rubric scoring_guide
- Prompt now explicitly requires integer scores (0/1/2/3/4)
- Code rounds any decimal scores to nearest integer
- Prevents LLM from giving 2.5, 3.5 etc.
2025-12-02 14:36:33 +08:00
36 changed files with 67 additions and 3920 deletions

View File

@ -1,119 +0,0 @@
#!/usr/bin/env python3
"""
期末大作业成绩汇总脚本
汇总编程测试分数 + REPORT.md 分数 + FRONTEND.md 分数
"""
import argparse
import json
import os
import sys
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 main():
parser = argparse.ArgumentParser(description="Aggregate final project grades")
parser.add_argument("--programming", required=True, help="Programming test grade JSON")
parser.add_argument("--report", required=True, help="REPORT.md LLM grade JSON")
parser.add_argument("--frontend", required=True, help="FRONTEND.md LLM grade JSON")
parser.add_argument("--out", default="final_grade.json", help="Output JSON file")
parser.add_argument("--summary", default="final_summary.md", help="Output summary markdown")
args = parser.parse_args()
# 加载各部分成绩
prog_grade = load_json(args.programming, {"total_score": 0, "max_score": 80})
report_grade = load_json(args.report, {"total": 0})
frontend_grade = load_json(args.frontend, {"total": 0})
# 提取分数
prog_score = prog_grade.get("total_score", 0)
prog_max = prog_grade.get("max_score", 80)
report_score = report_grade.get("total", 0)
report_max = 10 # REPORT.md 满分 10 分
frontend_score = frontend_grade.get("total", 0)
frontend_max = 10 # FRONTEND.md 满分 10 分
# 计算总分
total_score = prog_score + report_score + frontend_score
total_max = prog_max + report_max + frontend_max
# 构建最终成绩数据(保留 LLM 评分的详细内容)
final_grade = {
"total_score": round(total_score, 2),
"max_score": total_max,
"breakdown": {
"programming": {
"score": round(prog_score, 2),
"max_score": prog_max,
"groups": prog_grade.get("groups", {})
},
"report": {
"score": round(report_score, 2),
"max_score": report_max,
"flags": report_grade.get("flags", []),
"confidence": report_grade.get("confidence"),
"criteria": report_grade.get("criteria", []) # LLM 评分详情
},
"frontend": {
"score": round(frontend_score, 2),
"max_score": frontend_max,
"flags": frontend_grade.get("flags", []),
"confidence": frontend_grade.get("confidence"),
"criteria": frontend_grade.get("criteria", []) # LLM 评分详情
}
}
}
# 保存 final_grade.json
with open(args.out, "w", encoding="utf-8") as f:
json.dump(final_grade, f, ensure_ascii=False, indent=2)
# 生成 summary.md
with open(args.summary, "w", encoding="utf-8") as f:
f.write("# 期末大作业成绩报告\n\n")
f.write(f"## 总分:{total_score:.2f} / {total_max}\n\n")
f.write("## 分项成绩\n\n")
f.write("| 项目 | 得分 | 满分 | 备注 |\n")
f.write("|------|------|------|------|\n")
f.write(f"| 编程测试 | {prog_score:.2f} | {prog_max} | Core + Advanced + Challenge |\n")
f.write(f"| REPORT.md | {report_score:.2f} | {report_max} | 后端与系统设计报告 |\n")
f.write(f"| FRONTEND.md | {frontend_score:.2f} | {frontend_max} | 前端界面与交互设计报告 |\n")
# 编程测试详情
if prog_grade.get("groups"):
f.write("\n### 编程测试详情\n\n")
f.write("| 分组 | 通过 | 总数 | 得分 | 满分 |\n")
f.write("|------|------|------|------|------|\n")
for group_name, group_info in prog_grade["groups"].items():
f.write(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)} |\n")
# 标记
all_flags = report_grade.get("flags", []) + frontend_grade.get("flags", [])
if all_flags:
f.write("\n### 标记\n\n")
for flag in set(all_flags):
f.write(f"- {flag}\n")
print(f"Final grade: {total_score:.2f}/{total_max}")
if __name__ == "__main__":
main()

View File

@ -1,121 +0,0 @@
#!/usr/bin/env python3
"""
聚合多个 LLM 评分结果
"""
import json
import argparse
from pathlib import Path
def load_grade(filepath):
"""加载单个评分文件"""
try:
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except FileNotFoundError:
print(f"Warning: {filepath} not found")
return None
except json.JSONDecodeError as e:
print(f"Error parsing {filepath}: {e}")
return None
def aggregate_grades(input_files, output_file, summary_file):
"""聚合多个评分文件"""
grades = []
total_score = 0
max_score = 0
need_review_count = 0
for input_file in input_files:
grade = load_grade(input_file)
if grade:
grades.append(grade)
# 支持两种格式:'total' (llm_grade.py) 或 'score' (旧格式)
score = grade.get('total', grade.get('score', 0))
total_score += score
# 默认每题 10 分
max_score += grade.get('max_score', 10)
# 检查是否需要审核
if 'need_review' in grade.get('flags', []) or grade.get('need_review', False):
need_review_count += 1
# 计算总分
final_score = total_score if max_score > 0 else 0
final_max_score = max_score
# 生成汇总结果
result = {
'total_score': final_score,
'max_score': final_max_score,
'questions': len(grades),
'need_review': need_review_count > 0,
'details': grades
}
# 保存 JSON
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(result, f, indent=2, ensure_ascii=False)
# 生成 Markdown 摘要
summary_lines = [
'# LLM 简答题评分汇总',
'',
f'**总分**: {final_score:.1f} / {final_max_score:.1f}',
f'**题目数**: {len(grades)}',
f'**需要人工审核**: {"" if result["need_review"] else ""}',
'',
'## 各题详情',
''
]
for i, grade in enumerate(grades, 1):
q_name = grade.get('question', f'Q{i}')
# 支持两种格式:'total' (llm_grade.py) 或 'score' (旧格式)
score = grade.get('total', grade.get('score', 0))
max_q_score = grade.get('max_score', 10)
# 检查是否需要审核
need_review = 'need_review' in grade.get('flags', []) or grade.get('need_review', False)
confidence = grade.get('confidence', 1.0)
summary_lines.append(f'### SA{i}')
summary_lines.append(f'- **得分**: {score:.2f} / {max_q_score:.1f}')
summary_lines.append(f'- **置信度**: {confidence:.2f}')
if need_review:
summary_lines.append('- ⚠️ **需要人工审核**')
# 显示分项评分
if 'criteria' in grade:
summary_lines.append('- **分项**:')
for criterion in grade['criteria']:
crit_id = criterion.get('id', '')
crit_score = criterion.get('score', 0)
crit_reason = criterion.get('reason', '')
summary_lines.append(f' - {crit_id}: {crit_score:.1f} - {crit_reason}')
summary_lines.append('')
with open(summary_file, 'w', encoding='utf-8') as f:
f.write('\n'.join(summary_lines))
print(f"✅ Aggregated {len(grades)} grades")
print(f" Total: {final_score:.1f} / {final_max_score:.1f}")
print(f" Output: {output_file}")
def main():
parser = argparse.ArgumentParser(description='Aggregate LLM grading results')
parser.add_argument('--inputs', nargs='+', required=True,
help='Input grade JSON files')
parser.add_argument('--out', required=True,
help='Output aggregated JSON file')
parser.add_argument('--summary', required=True,
help='Output summary Markdown file')
args = parser.parse_args()
aggregate_grades(args.inputs, args.out, args.summary)
if __name__ == '__main__':
main()

View File

@ -1,372 +0,0 @@
#!/usr/bin/env python3
"""
创建完整的成绩元数据文件
grade.json / final_grade.json / llm_grade.json 生成 metadata.json
包含所有详细信息未通过的测试各题详情等
"""
import json
import os
import sys
import re
from datetime import datetime
def extract_student_id():
"""从环境变量或仓库名中提取学生 ID"""
# 优先从环境变量获取
student_id = os.getenv("STUDENT_ID")
if student_id:
return student_id
# 从仓库名提取
# 支持格式org/assignment-stu_xxx 或 org/assignment-stu-xxx
repo = os.getenv("REPO", "")
if repo:
# 匹配 xxx-stu_yyy 或 xxx-stu-yyy 格式
match = re.search(r'-stu[_-]([a-zA-Z0-9_]+)$', repo)
if match:
return match.group(1)
# 也尝试匹配 stu_xxx 在路径中的情况
match = re.search(r'stu[_-]([a-zA-Z0-9_]+)', repo)
if match:
return match.group(1)
return None
def extract_assignment_id():
"""从环境变量或仓库名中提取作业 ID"""
# 优先从环境变量获取
assignment_id = os.getenv("ASSIGNMENT_ID")
if assignment_id:
return assignment_id
# 从仓库名提取
# 支持格式org/assignment-stu_xxx 或 org/assignment-template
repo = os.getenv("REPO", "")
if repo:
# 取仓库名部分(去掉组织)
repo_name = repo.split("/")[-1] if "/" in repo else repo
# 移除 -stu_xxx 或 -template 后缀
assignment = re.sub(r'-stu[_-][a-zA-Z0-9_]+$', '', repo_name)
assignment = re.sub(r'-template$', '', assignment)
if assignment:
return assignment
return "unknown"
def create_final_metadata(final_grade_file='final_grade.json'):
"""从 final_grade.json 创建元数据(期末大作业专用)"""
try:
with open(final_grade_file, 'r', encoding='utf-8') as f:
final_data = json.load(f)
assignment_id = extract_assignment_id()
student_id = extract_student_id()
total_score = final_data.get("total_score", 0)
max_score = final_data.get("max_score", 100)
breakdown = final_data.get("breakdown", {})
# 构建各组成部分
components = []
# 编程测试部分
prog = breakdown.get("programming", {})
if prog:
prog_component = {
"type": "programming_java",
"score": prog.get("score", 0),
"max_score": prog.get("max_score", 80),
"details": {
"groups": prog.get("groups", {})
}
}
components.append(prog_component)
# REPORT.md 部分
report = breakdown.get("report", {})
if report:
report_component = {
"type": "llm_report",
"score": report.get("score", 0),
"max_score": report.get("max_score", 10),
"details": {
"flags": report.get("flags", []),
"confidence": report.get("confidence"),
"criteria": report.get("criteria", []) # LLM 各评分项及理由
}
}
components.append(report_component)
# FRONTEND.md 部分
frontend = breakdown.get("frontend", {})
if frontend:
frontend_component = {
"type": "llm_frontend",
"score": frontend.get("score", 0),
"max_score": frontend.get("max_score", 10),
"details": {
"flags": frontend.get("flags", []),
"confidence": frontend.get("confidence"),
"criteria": frontend.get("criteria", []) # LLM 各评分项及理由
}
}
components.append(frontend_component)
metadata = {
"version": "1.0",
"assignment": assignment_id,
"student_id": student_id,
"components": components,
"total_score": round(total_score, 2),
"total_max_score": max_score,
"timestamp": datetime.now().isoformat(),
"generator": "gitea-autograde"
}
return metadata
except Exception as e:
print(f"Error creating final metadata: {e}", file=sys.stderr)
return {}
def create_grade_metadata(grade_file='grade.json'):
"""从 grade.json 创建元数据,包含所有详细信息"""
try:
with open(grade_file, 'r') as f:
grade_data = json.load(f)
assignment_id = extract_assignment_id()
student_id = extract_student_id()
language = os.getenv("LANGUAGE", "java")
# 提取所有相关信息
final_score = grade_data.get("final_score", grade_data.get("total_score", grade_data.get("score", 0)))
base_score = grade_data.get("base_score", final_score)
penalty = grade_data.get("penalty", 0)
passed = grade_data.get("passed", 0)
total = grade_data.get("total", 0)
fails = grade_data.get("fails", [])
max_score = grade_data.get("max_score", 100)
test_framework = grade_data.get("test_framework", "junit")
coverage = grade_data.get("coverage")
raw_score = grade_data.get("raw_score")
groups = grade_data.get("groups", {})
# 动态生成 type 字段
type_map = {
"python": "programming_python",
"java": "programming_java",
"r": "programming_r"
}
component_type = type_map.get(language, f"programming_{language}")
component = {
"type": component_type,
"language": language,
"score": round(final_score, 2),
"max_score": max_score,
"details": {
"passed": passed,
"total": total,
"base_score": round(base_score, 2),
"penalty": round(penalty, 2),
"coverage": round(coverage, 2) if coverage else None,
"raw_score": round(raw_score, 2) if raw_score else None,
"failed_tests": fails,
"test_framework": test_framework,
"groups": groups
}
}
metadata = {
"version": "1.0",
"assignment": assignment_id,
"student_id": student_id,
"components": [component],
"total_score": round(final_score, 2),
"total_max_score": max_score,
"timestamp": datetime.now().isoformat(),
"generator": "gitea-autograde"
}
return metadata
except Exception as e:
print(f"Error creating grade metadata: {e}", file=sys.stderr)
return {}
def create_llm_metadata(llm_grade_file='artifacts/llm_grade.json'):
"""从 llm_grade.json 创建元数据,包含所有详细信息"""
try:
with open(llm_grade_file, 'r') as f:
llm_data = json.load(f)
assignment_id = extract_assignment_id()
student_id = extract_student_id()
# 提取聚合后的信息
total_score = llm_data.get("total_score", llm_data.get("total", 0))
max_score = llm_data.get("max_score", 30)
need_review = llm_data.get("need_review", False)
questions_data = llm_data.get("details", llm_data.get("questions", []))
# 构建各题详情
question_details = []
for i, q_data in enumerate(questions_data, 1):
q_score = q_data.get("total", q_data.get("score", 0))
q_max = q_data.get("max_score", 10)
q_confidence = q_data.get("confidence", 1.0)
q_flags = q_data.get("flags", [])
q_need_review = "need_review" in q_flags or q_data.get("need_review", False)
q_criteria = q_data.get("criteria", [])
# 规范化 criteria 格式
formatted_criteria = []
for crit in q_criteria:
formatted_criteria.append({
"id": crit.get("id", ""),
"score": round(float(crit.get("score", 0)), 2),
"reason": crit.get("reason", "")
})
question_detail = {
"question_id": f"SA{i}",
"question_name": q_data.get("question", f"SA{i}"),
"score": round(float(q_score), 2),
"max_score": q_max,
"confidence": round(float(q_confidence), 2),
"need_review": q_need_review,
"flags": q_flags,
"criteria": formatted_criteria
}
question_details.append(question_detail)
component = {
"type": "llm_essay",
"score": round(float(total_score), 2),
"max_score": max_score,
"details": {
"questions": len(question_details),
"need_review": need_review,
"question_details": question_details
}
}
metadata = {
"version": "1.0",
"assignment": assignment_id,
"student_id": student_id,
"components": [component],
"total_score": round(float(total_score), 2),
"total_max_score": max_score,
"timestamp": datetime.now().isoformat(),
"generator": "gitea-autograde"
}
return metadata
except Exception as e:
print(f"Error creating LLM metadata: {e}", file=sys.stderr)
return {}
def create_objective_metadata(objective_file='objective_grade.json'):
"""从 objective_grade.json 创建元数据"""
try:
with open(objective_file, 'r', encoding='utf-8') as f:
objective_data = json.load(f)
assignment_id = extract_assignment_id()
student_id = extract_student_id()
total_score = objective_data.get("score", 0)
max_score = objective_data.get("max_score", 0)
components = objective_data.get("components", [])
formatted_components = []
for comp in components:
comp_type = comp.get("type", "objective")
formatted_components.append({
"type": f"objective_{comp_type}",
"score": comp.get("score", 0),
"max_score": comp.get("max_score", 0),
"details": comp.get("details", {})
})
if not formatted_components:
formatted_components.append({
"type": "objective_total",
"score": total_score,
"max_score": max_score,
"details": {}
})
metadata = {
"version": "1.0",
"assignment": assignment_id,
"student_id": student_id,
"components": formatted_components,
"total_score": total_score,
"total_max_score": max_score,
"timestamp": datetime.now().isoformat(),
"generator": "gitea-autograde"
}
return metadata
except Exception as e:
print(f"Error creating objective metadata: {e}", file=sys.stderr)
return {}
def main():
"""主函数"""
# 检查命令行参数或环境变量
grade_type = os.getenv("GRADE_TYPE", "programming").lower()
grade_file_override = os.getenv("GRADE_FILE")
if grade_type == "final":
# 期末大作业成绩(包含编程+报告)
final_file = grade_file_override or "final_grade.json"
if os.path.exists(final_file):
metadata = create_final_metadata(final_file)
else:
print(f"Error: {final_file} not found", file=sys.stderr)
metadata = {}
elif grade_type == "llm":
# LLM 成绩
llm_file = grade_file_override or "artifacts/llm_grade.json"
if os.path.exists(llm_file):
metadata = create_llm_metadata(llm_file)
elif os.path.exists("llm_grade.json"):
metadata = create_llm_metadata("llm_grade.json")
else:
print(f"Error: {llm_file} not found", file=sys.stderr)
metadata = {}
elif grade_type == "objective":
objective_file = grade_file_override or "objective_grade.json"
if os.path.exists(objective_file):
metadata = create_objective_metadata(objective_file)
else:
print(f"Error: {objective_file} not found", file=sys.stderr)
metadata = {}
else:
# 编程成绩
grade_file = grade_file_override or "grade.json"
if os.path.exists(grade_file):
metadata = create_grade_metadata(grade_file)
else:
print(f"Error: {grade_file} not found", file=sys.stderr)
metadata = {}
# 输出到 stdout
print(json.dumps(metadata, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()

View File

@ -1,815 +0,0 @@
#!/usr/bin/env python3
"""
生成专业的 PDF 成绩报告
适用于打印归档包含
- 封面页课程信息学生信息
- 后端开发反思报告
- 前端开发反思报告
- 评分详情页
- 防伪水印
"""
import argparse
import hashlib
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
try:
import markdown
from weasyprint import HTML, CSS
from weasyprint.text.fonts import FontConfiguration
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 fix_image_paths(content, images_dir):
"""修复图片路径为绝对路径"""
if not images_dir or not os.path.isdir(images_dir):
return content
abs_images_dir = os.path.abspath(images_dir)
def replace_img(match):
alt = match.group(1)
src = match.group(2)
if not src.startswith(('http://', 'https://', 'file://', '/')):
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 markdown_to_html(md_content):
"""将 Markdown 转换为 HTML仅内容部分"""
extensions = ['tables', 'fenced_code', 'nl2br']
return markdown.markdown(md_content, extensions=extensions)
def generate_watermark_id(student_id, commit_sha):
"""生成唯一的水印标识"""
raw = f"{student_id}-{commit_sha}-{datetime.now().isoformat()}"
return hashlib.sha256(raw.encode()).hexdigest()[:16].upper()
def generate_cover_page(student_id, student_name="", class_name="",
assignment_name="VibeVault 期末大作业"):
"""生成封面页 HTML"""
current_date = datetime.now().strftime('%Y年%m月%d')
current_semester = "2025年秋季学期"
# 如果有学生姓名,直接显示;否则留空供手写
name_value = student_name if student_name else ' ' * 8
class_value = class_name if class_name else ' ' * 8
id_value = student_id if student_id else ' ' * 8
return f'''
<div class="cover-page">
<div class="cover-header">
<div class="university-name">课程大作业报告</div>
</div>
<div class="cover-title">
<h1>Java 程序设计</h1>
<h2>期末大作业</h2>
<h3>{assignment_name}</h3>
</div>
<div class="cover-info">
<table class="info-table">
<tr>
<td class="label">&emsp;&emsp;</td>
<td class="value underline">{id_value}</td>
</tr>
<tr>
<td class="label">&emsp;&emsp;</td>
<td class="value underline">{name_value}</td>
</tr>
<tr>
<td class="label">&emsp;&emsp;</td>
<td class="value underline">{class_value}</td>
</tr>
<tr>
<td class="label">提交日期</td>
<td class="value underline">{current_date}</td>
</tr>
</table>
</div>
<div class="cover-footer">
<p>{current_semester}</p>
</div>
</div>
'''
def generate_report_section(title, content, icon="📝"):
"""生成报告章节 HTML"""
if not content or content.strip() in ['', '*(未提交)*']:
html_content = '<p class="empty-notice">(未提交)</p>'
else:
html_content = markdown_to_html(content)
return f'''
<div class="report-section">
<h1 class="section-title">{icon} {title}</h1>
<div class="section-content">
{html_content}
</div>
</div>
'''
def generate_grade_page(final_grade):
"""生成评分详情页 HTML"""
total = final_grade.get("total_score", 0)
max_score = final_grade.get("max_score", 100)
breakdown = final_grade.get("breakdown", {})
# 编程测试详情
prog = breakdown.get("programming", {})
prog_rows = ""
if prog.get("groups"):
for group_name, group_info in prog["groups"].items():
prog_rows += f'''
<tr>
<td>{group_name}</td>
<td>{group_info.get('passed', 0)} / {group_info.get('total', 0)}</td>
<td>{group_info.get('score', 0):.1f}</td>
<td>{group_info.get('max_score', 0)}</td>
</tr>
'''
# LLM 评分详情
def format_llm_details(section_data):
criteria = section_data.get("criteria", [])
if not criteria:
return f'<p class="no-detail">无详细评分</p>'
rows = ""
for c in criteria:
reason = c.get("reason", "").replace("<", "&lt;").replace(">", "&gt;")
rows += f'''
<tr>
<td>{c.get('id', '')}</td>
<td class="score-cell">{c.get('score', 0)}</td>
<td class="reason-cell">{reason}</td>
</tr>
'''
confidence = section_data.get("confidence")
flags = section_data.get("flags", [])
footer = ""
if confidence:
footer += f'<span class="confidence">置信度: {confidence:.2f}</span>'
if flags:
footer += f'<span class="flags">标记: {", ".join(flags)}</span>'
return f'''
<table class="detail-table">
<thead>
<tr><th>评分项</th><th>得分</th><th>评语</th></tr>
</thead>
<tbody>{rows}</tbody>
</table>
<div class="detail-footer">{footer}</div>
'''
report = breakdown.get("report", {})
frontend = breakdown.get("frontend", {})
return f'''
<div class="grade-page">
<h1 class="page-title">📊 评分详情</h1>
<div class="total-score">
<div class="score-circle">
<span class="score-value">{total:.1f}</span>
<span class="score-max">/ {max_score}</span>
</div>
<div class="score-label">总分</div>
</div>
<div class="grade-summary">
<h2>成绩汇总</h2>
<table class="summary-table">
<thead>
<tr><th>项目</th><th>得分</th><th>满分</th><th>占比</th></tr>
</thead>
<tbody>
<tr>
<td>编程测试</td>
<td class="score-cell">{prog.get('score', 0):.1f}</td>
<td>{prog.get('max_score', 80)}</td>
<td>{prog.get('max_score', 80)}%</td>
</tr>
<tr>
<td>后端反思报告</td>
<td class="score-cell">{report.get('score', 0):.1f}</td>
<td>{report.get('max_score', 10)}</td>
<td>{report.get('max_score', 10)}%</td>
</tr>
<tr>
<td>前端反思报告</td>
<td class="score-cell">{frontend.get('score', 0):.1f}</td>
<td>{frontend.get('max_score', 10)}</td>
<td>{frontend.get('max_score', 10)}%</td>
</tr>
</tbody>
</table>
</div>
<div class="grade-details">
<h2>编程测试详情</h2>
<table class="detail-table">
<thead>
<tr><th>测试组</th><th>通过数</th><th>得分</th><th>满分</th></tr>
</thead>
<tbody>{prog_rows or '<tr><td colspan="4">无测试数据</td></tr>'}</tbody>
</table>
</div>
<div class="grade-details">
<h2>后端反思报告评分</h2>
{format_llm_details(report)}
</div>
<div class="grade-details">
<h2>前端反思报告评分</h2>
{format_llm_details(frontend)}
</div>
<div class="grade-footer">
<p>报告生成时间{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
<p>本报告由自动评分系统生成</p>
</div>
</div>
'''
def get_css_styles(watermark_text="", commit_sha=""):
"""获取 PDF 样式,包含水印和版本标记"""
# 水印样式
watermark_css = ""
if watermark_text:
watermark_css = f'''
/* 水印 */
body::after {{
content: "{watermark_text}";
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 60pt;
color: rgba(200, 200, 200, 0.15);
white-space: nowrap;
pointer-events: none;
z-index: 9999;
}}
.report-section::before,
.grade-page::before {{
content: "{watermark_text}";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-45deg);
font-size: 48pt;
color: rgba(200, 200, 200, 0.12);
white-space: nowrap;
pointer-events: none;
z-index: -1;
}}
'''
# 版本标记(右上角)
commit_marker = ""
if commit_sha:
short_sha = commit_sha[:7] if len(commit_sha) > 7 else commit_sha
commit_marker = f'''
@top-right {{
content: "{short_sha}";
font-size: 8pt;
color: #999;
font-family: 'Consolas', 'Monaco', monospace;
}}
'''
return f'''
@page {{
size: A4;
margin: 2cm 2.5cm;
{commit_marker}
@bottom-center {{
content: counter(page);
font-size: 10pt;
color: #666;
}}
}}
@page cover {{
margin: 0;
@bottom-center {{ content: none; }}
}}
@font-face {{
font-family: 'Noto Sans CJK SC';
src: local('Noto Sans CJK SC'), local('Noto Sans SC'),
local('Source Han Sans SC'), local('Source Han Sans CN'),
local('PingFang SC'), local('Microsoft YaHei'),
local('SimHei'), local('WenQuanYi Micro Hei');
}}
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: 'Noto Sans CJK SC', 'Source Han Sans SC', 'PingFang SC',
'Microsoft YaHei', 'SimHei', 'WenQuanYi Micro Hei', sans-serif;
font-size: 11pt;
line-height: 1.8;
color: #333;
}}
{watermark_css}
/* 封面页样式 */
.cover-page {{
page: cover;
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 3cm;
page-break-after: always;
}}
.cover-header {{
margin-bottom: 4cm;
}}
.university-name {{
font-size: 18pt;
color: #1a5490;
letter-spacing: 0.5em;
font-weight: bold;
}}
.cover-title h1 {{
font-size: 26pt;
color: #1a5490;
margin-bottom: 0.5cm;
font-weight: bold;
}}
.cover-title h2 {{
font-size: 20pt;
color: #333;
margin-bottom: 0.3cm;
font-weight: normal;
}}
.cover-title h3 {{
font-size: 14pt;
color: #666;
font-weight: normal;
}}
.cover-info {{
margin-top: 3cm;
}}
.info-table {{
margin: 0 auto;
border-collapse: collapse;
}}
.info-table td {{
padding: 0.4cm 0.5cm;
font-size: 12pt;
}}
.info-table .label {{
text-align: right;
color: #333;
}}
.info-table .value {{
text-align: left;
min-width: 6cm;
}}
.info-table .underline {{
border-bottom: 1px solid #333;
}}
.cover-footer {{
margin-top: 4cm;
color: #666;
font-size: 11pt;
}}
/* 报告章节样式 */
.report-section {{
page-break-before: always;
position: relative;
}}
.section-title {{
font-size: 18pt;
color: #1a5490;
border-bottom: 2px solid #1a5490;
padding-bottom: 0.3cm;
margin-bottom: 0.8cm;
}}
.section-content {{
text-align: justify;
}}
.section-content h1 {{
font-size: 16pt;
color: #1a5490;
margin: 1cm 0 0.5cm 0;
}}
.section-content h2 {{
font-size: 14pt;
color: #333;
margin: 0.8cm 0 0.4cm 0;
}}
.section-content h3 {{
font-size: 12pt;
color: #555;
margin: 0.6cm 0 0.3cm 0;
}}
.section-content p {{
margin: 0.4cm 0;
text-indent: 2em;
}}
.section-content ul, .section-content ol {{
margin: 0.4cm 0 0.4cm 1.5cm;
}}
.section-content li {{
margin: 0.2cm 0;
}}
.section-content img {{
max-width: 100%;
height: auto;
margin: 0.5cm auto;
display: block;
border: 1px solid #ddd;
}}
.section-content code {{
font-family: 'Consolas', 'Monaco', monospace;
background: #f5f5f5;
padding: 0.1cm 0.2cm;
border-radius: 3px;
font-size: 10pt;
}}
.section-content pre {{
background: #f5f5f5;
padding: 0.5cm;
border-radius: 5px;
overflow-x: auto;
font-size: 9pt;
margin: 0.5cm 0;
}}
.section-content blockquote {{
border-left: 4px solid #1a5490;
padding-left: 0.5cm;
margin: 0.5cm 0;
color: #555;
background: #f9f9f9;
padding: 0.3cm 0.5cm;
}}
.section-content table {{
width: 100%;
border-collapse: collapse;
margin: 0.5cm 0;
font-size: 10pt;
}}
.section-content th, .section-content td {{
border: 1px solid #ddd;
padding: 0.3cm;
text-align: left;
}}
.section-content th {{
background: #1a5490;
color: white;
}}
.section-content tr:nth-child(even) {{
background: #f9f9f9;
}}
.empty-notice {{
color: #999;
font-style: italic;
text-align: center;
padding: 2cm;
}}
/* 评分页样式 */
.grade-page {{
page-break-before: always;
position: relative;
}}
.page-title {{
font-size: 18pt;
color: #1a5490;
text-align: center;
margin-bottom: 1cm;
}}
.total-score {{
text-align: center;
margin: 1cm 0;
}}
.score-circle {{
display: inline-block;
width: 4cm;
height: 4cm;
border: 4px solid #1a5490;
border-radius: 50%;
line-height: 4cm;
text-align: center;
}}
.score-value {{
font-size: 28pt;
font-weight: bold;
color: #1a5490;
}}
.score-max {{
font-size: 14pt;
color: #666;
}}
.score-label {{
font-size: 12pt;
color: #666;
margin-top: 0.3cm;
}}
.grade-summary, .grade-details {{
margin: 0.8cm 0;
}}
.grade-summary h2, .grade-details h2 {{
font-size: 14pt;
color: #333;
border-bottom: 1px solid #ddd;
padding-bottom: 0.2cm;
margin-bottom: 0.4cm;
}}
.summary-table, .detail-table {{
width: 100%;
border-collapse: collapse;
font-size: 10pt;
}}
.summary-table th, .summary-table td,
.detail-table th, .detail-table td {{
border: 1px solid #ddd;
padding: 0.25cm 0.4cm;
text-align: left;
}}
.summary-table th, .detail-table th {{
background: #1a5490;
color: white;
font-weight: normal;
}}
.summary-table tr:nth-child(even),
.detail-table tr:nth-child(even) {{
background: #f9f9f9;
}}
.score-cell {{
text-align: center;
font-weight: bold;
color: #1a5490;
}}
.reason-cell {{
font-size: 9pt;
color: #555;
max-width: 10cm;
}}
.detail-footer {{
font-size: 9pt;
color: #666;
margin-top: 0.2cm;
}}
.detail-footer .confidence {{
margin-right: 1cm;
}}
.detail-footer .flags {{
color: #c00;
}}
.no-detail {{
color: #999;
font-style: italic;
padding: 0.5cm;
text-align: center;
}}
.grade-footer {{
margin-top: 1cm;
padding-top: 0.5cm;
border-top: 1px solid #ddd;
font-size: 9pt;
color: #999;
text-align: center;
}}
.grade-footer p {{
margin: 0.1cm 0;
text-indent: 0;
}}
'''
def create_full_html(args, final_grade, student_info):
"""创建完整的 HTML 文档"""
# 读取报告内容
report_content = read_file(args.report)
frontend_content = read_file(args.frontend)
# 修复图片路径
frontend_content = fix_image_paths(frontend_content, args.images)
# 移除报告中的标题行(避免重复)
report_content = re.sub(r'^#\s*后端开发反思报告.*\n', '', report_content, flags=re.MULTILINE)
frontend_content = re.sub(r'^#\s*前端开发反思报告.*\n', '', frontend_content, flags=re.MULTILINE)
# 提取学生信息
student_id = student_info.get("student_id", "")
student_name = student_info.get("name", "")
class_name = student_info.get("class_name", "")
commit_sha = student_info.get("commit_sha", "")
# 生成水印文本
watermark_text = ""
if student_id:
watermark_id = generate_watermark_id(student_id, commit_sha)
watermark_text = f"{student_id} · {watermark_id}"
# 构建 HTML
html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Java程序设计 - 期末大作业报告</title>
<style>{get_css_styles(watermark_text, commit_sha)}</style>
</head>
<body>
{generate_cover_page(student_id, student_name, class_name)}
{generate_report_section("后端开发反思报告", report_content)}
{generate_report_section("前端开发反思报告", frontend_content, "🎨")}
{generate_grade_page(final_grade)}
</body>
</html>'''
return html
def convert_to_pdf(html_content, pdf_file, images_dir=None):
"""使用 weasyprint 生成 PDF"""
if not HAS_PDF_SUPPORT:
print("weasyprint not available", file=sys.stderr)
return False
try:
font_config = FontConfiguration()
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,
font_config=font_config
)
return True
except Exception as e:
print(f"PDF generation error: {e}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description="Generate professional 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")
parser.add_argument("--student-name", default="", help="Student name")
parser.add_argument("--class-name", default="", help="Class name")
parser.add_argument("--commit-sha", default="", help="Commit SHA for watermark")
args = parser.parse_args()
# 从环境变量获取学生信息
student_id = args.student_id or os.getenv("STUDENT_ID", "")
student_name = args.student_name or os.getenv("STUDENT_NAME", "")
class_name = args.class_name or os.getenv("CLASS_NAME", "")
commit_sha = args.commit_sha or os.getenv("COMMIT_SHA", "")
# 从仓库名提取学生 ID
if not student_id:
repo = os.getenv("REPO", "")
match = re.search(r'-stu[_-]?st?(\d+)$', repo)
if match:
student_id = match.group(1)
else:
match = re.search(r'-stu[_-]([a-zA-Z0-9_]+)$', repo)
if match:
student_id = match.group(1)
student_info = {
"student_id": student_id,
"name": student_name,
"class_name": class_name,
"commit_sha": commit_sha
}
# 加载成绩
final_grade = load_json(args.grade, {"total_score": 0, "max_score": 100, "breakdown": {}})
# 创建 HTML
html_content = create_full_html(args, final_grade, student_info)
# 保存 HTML调试用
html_out = args.out.replace(".pdf", ".html")
with open(html_out, "w", encoding="utf-8") as f:
f.write(html_content)
# 生成 PDF
if HAS_PDF_SUPPORT:
if convert_to_pdf(html_content, args.out, args.images):
print(f"✅ PDF report generated: {args.out}")
return 0
else:
print(f"⚠️ PDF generation failed", file=sys.stderr)
return 1
else:
print(f" weasyprint not installed, HTML saved: {html_out}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,187 +0,0 @@
#!/usr/bin/env python3
"""
编程题评分脚本
解析 JUnit XML 报告计算分数考虑迟交扣分生成 grade.json summary.md
"""
import argparse
import xml.etree.ElementTree as ET
import json
import subprocess
import os
import time
import sys
from pathlib import Path
from dotenv import load_dotenv
# 加载环境变量(支持从 .env 文件或环境变量读取)
load_dotenv()
def commit_ts():
"""获取最后一次提交的时间戳Unix 时间戳)"""
try:
out = subprocess.check_output(
["git", "log", "-1", "--format=%ct"],
stderr=subprocess.DEVNULL
).decode().strip()
return int(out)
except Exception:
return int(time.time())
def parse_junit(junit_path):
"""
解析 JUnit XML 报告
Returns
-------
passed : int
通过的测试数
total : int
总测试数
fails : list
失败的测试名称列表
"""
if not os.path.exists(junit_path):
return (0, 0, [])
try:
root = ET.parse(junit_path).getroot()
total = 0
passed = 0
fails = []
for testsuite in root.iter("testsuite"):
for testcase in testsuite.iter("testcase"):
total += 1
# 检查是否有 failure、error 或 skipped 子元素
if list(testcase):
classname = testcase.get("classname", "")
name = testcase.get("name", "")
full_name = f"{classname}.{name}" if classname else name
fails.append(full_name)
else:
passed += 1
return (passed, total, fails)
except Exception as e:
print(f"Error parsing JUnit XML: {e}", file=sys.stderr)
return (0, 0, [])
def calculate_late_penalty(deadline_str):
"""
计算迟交扣分
Parameters
----------
deadline_str : str
ISO 格式的截止时间 "2025-03-15T23:59:59+08:00"
Returns
-------
penalty : float
扣分数0-30
"""
if not deadline_str:
return 0.0
try:
# 解析截止时间(支持多种格式)
deadline_str = deadline_str.strip()
# 移除时区信息(简化处理)
if '+' in deadline_str:
deadline_str = deadline_str.split('+')[0]
elif 'Z' in deadline_str:
deadline_str = deadline_str.replace('Z', '')
# 解析时间
if 'T' in deadline_str:
dl = time.mktime(time.strptime(deadline_str[:19], "%Y-%m-%dT%H:%M:%S"))
else:
dl = time.mktime(time.strptime(deadline_str[:19], "%Y-%m-%d %H:%M:%S"))
commit_time = commit_ts()
late_sec = max(0, commit_time - dl)
days = late_sec / 86400
# 扣分规则:第一天 10 分,之后每天 5 分,最多 30 分
if days > 0:
penalty = min(30.0, 10.0 + 5.0 * days)
else:
penalty = 0.0
return round(penalty, 2)
except Exception as e:
print(f"Error calculating late penalty: {e}", file=sys.stderr)
return 0.0
def main():
parser = argparse.ArgumentParser(description="Grade programming assignments from JUnit XML")
parser.add_argument("--junit", required=True, help="Path to JUnit XML file")
parser.add_argument("--out", default="grade.json", help="Output JSON file")
parser.add_argument("--summary", default="summary.md", help="Output summary markdown file")
parser.add_argument("--bonus", default=None, help="Optional bonus file (e.g., lintr.rds)")
args = parser.parse_args()
# 解析 JUnit XML
passed, total, fails = parse_junit(args.junit)
# 计算基础分数
if total > 0:
base_score = 100.0 * (passed / total)
else:
base_score = 0.0
# 计算迟交扣分
deadline = os.getenv("DEADLINE", "")
penalty = calculate_late_penalty(deadline)
# 最终分数
final_score = max(0.0, round(base_score - penalty, 2))
# 生成 grade.json
grade_data = {
"score": final_score,
"base_score": round(base_score, 2),
"penalty": penalty,
"passed": passed,
"total": total,
"fails": fails,
"timestamp": int(time.time())
}
with open(args.out, "w", encoding="utf-8") as f:
json.dump(grade_data, f, ensure_ascii=False, indent=2)
# 生成 summary.md
with open(args.summary, "w", encoding="utf-8") as f:
f.write("# 成绩报告\n\n")
f.write(f"- **通过用例**{passed}/{total}\n")
f.write(f"- **原始分**{base_score:.2f}/100\n")
if penalty > 0:
f.write(f"- **迟交扣分**-{penalty:.2f}\n")
f.write(f"- **最终分****{final_score:.2f}/100**\n\n")
if fails:
f.write("## 未通过的测试\n\n")
for fail in fails:
f.write(f"- {fail}\n")
f.write("\n")
if deadline:
f.write(f"## 截止时间\n\n")
f.write(f"- 截止时间:{deadline}\n")
commit_time_str = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(commit_ts()))
f.write(f"- 提交时间:{commit_time_str}\n")
print(f"Grading complete: {final_score:.2f}/100 ({passed}/{total} tests passed)")
if __name__ == "__main__":
main()

View File

@ -1,207 +0,0 @@
#!/usr/bin/env python3
"""
分组编程题评分脚本
解析 JUnit XML 报告按测试分组Core/Advanced/Challenge计算加权分数
"""
import argparse
import xml.etree.ElementTree as ET
import json
import os
import re
import sys
from pathlib import Path
from glob import glob
def parse_junit_files(junit_dir):
"""
解析目录下所有 JUnit XML 报告
Returns
-------
results : list of dict
每个测试的结果包含 classname, name, passed
"""
results = []
xml_files = glob(os.path.join(junit_dir, "TEST-*.xml"))
if not xml_files:
xml_files = glob(os.path.join(junit_dir, "*.xml"))
for xml_file in xml_files:
try:
root = ET.parse(xml_file).getroot()
for testsuite in root.iter("testsuite"):
for testcase in testsuite.iter("testcase"):
classname = testcase.get("classname", "")
name = testcase.get("name", "")
# 检查是否有 failure、error 或 skipped 子元素
failed = any(testcase.iter("failure")) or any(testcase.iter("error"))
skipped = any(testcase.iter("skipped"))
results.append({
"classname": classname,
"name": name,
"passed": not failed and not skipped,
"skipped": skipped
})
except Exception as e:
print(f"Error parsing {xml_file}: {e}", file=sys.stderr)
return results
def load_groups_config(groups_file):
"""加载测试分组配置"""
if not os.path.exists(groups_file):
# 默认配置
return {
"groups": {
"core": {"pattern": ".*core.*", "weight": 0.75, "max_score": 60},
"advanced": {"pattern": ".*advanced.*", "weight": 0.125, "max_score": 10},
"challenge": {"pattern": ".*challenge.*", "weight": 0.125, "max_score": 10}
},
"fallback_group": "core"
}
with open(groups_file, "r", encoding="utf-8") as f:
return json.load(f)
def categorize_test(classname, groups_config):
"""根据 classname 将测试分类到对应的组"""
for group_name, group_info in groups_config.get("groups", {}).items():
pattern = group_info.get("pattern", "")
if re.search(pattern, classname, re.IGNORECASE):
return group_name
return groups_config.get("fallback_group", "core")
def calculate_grouped_score(test_results, groups_config):
"""
按分组计算加权分数
Returns
-------
dict
包含各组得分和总分的字典
"""
groups = groups_config.get("groups", {})
# 初始化各组统计
group_stats = {}
for group_name, group_info in groups.items():
group_stats[group_name] = {
"passed": 0,
"total": 0,
"max_score": group_info.get("max_score", 10),
"weight": group_info.get("weight", 0.1),
"tests": []
}
# 分类并统计测试结果
for test in test_results:
group = categorize_test(test["classname"], groups_config)
if group not in group_stats:
group = groups_config.get("fallback_group", "core")
group_stats[group]["total"] += 1
if test["passed"]:
group_stats[group]["passed"] += 1
else:
group_stats[group]["tests"].append(f"{test['classname']}.{test['name']}")
# 计算各组得分
total_score = 0
group_scores = {}
for group_name, stats in group_stats.items():
if stats["total"] > 0:
pass_rate = stats["passed"] / stats["total"]
group_score = pass_rate * stats["max_score"]
else:
group_score = 0
group_scores[group_name] = {
"passed": stats["passed"],
"total": stats["total"],
"max_score": stats["max_score"],
"score": round(group_score, 2),
"failed_tests": stats["tests"][:10] # 只保留前 10 个失败测试
}
total_score += group_score
return {
"total_score": round(total_score, 2),
"max_score": 80, # 编程测试总分 80 分Core 60 + Advanced 10 + Challenge 10
"groups": group_scores
}
def main():
parser = argparse.ArgumentParser(description="Grade programming assignments with test groups")
parser.add_argument("--junit-dir", required=True, help="Directory containing JUnit XML files")
parser.add_argument("--groups", default="test_groups.json", help="Test groups configuration file")
parser.add_argument("--out", default="grade.json", help="Output JSON file")
parser.add_argument("--summary", default="summary.md", help="Output summary markdown file")
args = parser.parse_args()
# 解析测试结果
test_results = parse_junit_files(args.junit_dir)
if not test_results:
print("Warning: No test results found", file=sys.stderr)
grade_data = {
"total_score": 0,
"max_score": 80,
"groups": {},
"error": "No test results found"
}
else:
# 加载分组配置
groups_config = load_groups_config(args.groups)
# 计算分组分数
grade_data = calculate_grouped_score(test_results, groups_config)
# 保存 grade.json
with open(args.out, "w", encoding="utf-8") as f:
json.dump(grade_data, f, ensure_ascii=False, indent=2)
# 生成 summary.md
with open(args.summary, "w", encoding="utf-8") as f:
f.write("# 编程测试成绩报告\n\n")
f.write(f"**总分:{grade_data['total_score']:.2f} / {grade_data['max_score']}**\n\n")
f.write("## 分组得分\n\n")
f.write("| 分组 | 通过 | 总数 | 得分 | 满分 |\n")
f.write("|------|------|------|------|------|\n")
for group_name, group_info in grade_data.get("groups", {}).items():
f.write(f"| {group_name} | {group_info['passed']} | {group_info['total']} | "
f"{group_info['score']:.2f} | {group_info['max_score']} |\n")
# 列出失败的测试
all_failed = []
for group_name, group_info in grade_data.get("groups", {}).items():
all_failed.extend(group_info.get("failed_tests", []))
if all_failed:
f.write("\n## 未通过的测试\n\n")
for test in all_failed[:20]: # 最多显示 20 个
f.write(f"- {test}\n")
if len(all_failed) > 20:
f.write(f"\n... 还有 {len(all_failed) - 20} 个未通过的测试\n")
print(f"Grading complete: {grade_data['total_score']:.2f}/{grade_data['max_score']}")
if __name__ == "__main__":
main()

View File

@ -1,241 +0,0 @@
#!/usr/bin/env python3
"""
LLM 简答题评分脚本
调用 LLM API按评分量表对简答题进行评分输出 JSON 格式结果
"""
import os
import json
import argparse
import requests
import sys
from pathlib import Path
from dotenv import load_dotenv
# 加载环境变量(支持从 .env 文件或环境变量读取)
load_dotenv()
def read_file(path):
"""读取文件内容"""
if os.path.exists(path):
return open(path, 'r', encoding='utf-8').read()
return ""
def read_file_or_string(value):
"""
如果 value 是一个存在的文件路径读取文件内容
否则直接返回 value 作为字符串
"""
if os.path.exists(value):
return open(value, 'r', encoding='utf-8').read()
return value # 当作字符串直接返回
PROMPT_TEMPLATE = """你是严格且一致的助教,按提供的评分量表为学生的简答题评分。
- 只依据量表中各评分项的 max_score scoring_guide 进行评分
- 每个评分项的分数范围是 0 到该项的 max_score
- 不输出任何解释性文本只输出 JSON
输出格式
{{
"total": number (各项分数之和保留两位小数),
"criteria": [
{{"id": "评分项id", "score": number(0到该项max_score), "reason": "简短评语"}},
...
],
"flags": [],
"confidence": number(0-1, 评分置信度)
}}
重要
- 每个评分项的 score 必须在 0 到该项 max_score 范围内
- total 必须等于所有 criteria score 之和
- 如果答案与题目无关或为空total=0并加 flag "need_review"
题目
<<<{question}>>>
评分量表
<<<{rubric}>>>
学生答案
<<<{answer}>>>
"""
def call_llm(url, key, model, prompt):
"""
调用 LLM API
Parameters
----------
url : str
API 地址
key : str
API 密钥
model : str
模型名称
prompt : str
提示词
Returns
-------
dict
LLM 返回的 JSON 结果
"""
headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json"
}
data = {
"model": model,
"temperature": 0,
"top_p": 1,
"messages": [{"role": "user", "content": prompt}],
"response_format": {"type": "json_object"}
}
try:
# 设置超时:连接超时 10 秒,读取超时 60 秒
response = requests.post(
url,
headers=headers,
json=data,
timeout=(10, 60)
)
response.raise_for_status()
result = response.json()
content = result.get("choices", [{}])[0].get("message", {}).get("content", "{}")
return json.loads(content)
except requests.exceptions.Timeout as e:
print(f"LLM API request timeout: {e}", file=sys.stderr)
raise
except requests.exceptions.HTTPError as e:
print(f"LLM API HTTP error: {e} (status: {response.status_code})", file=sys.stderr)
raise
except requests.exceptions.RequestException as e:
print(f"LLM API request failed: {e}", file=sys.stderr)
raise
except json.JSONDecodeError as e:
print(f"Failed to parse LLM response as JSON: {e}", file=sys.stderr)
raise
def main():
parser = argparse.ArgumentParser(description="Grade short answer questions using LLM")
parser.add_argument("--question", required=True, help="Path to question file")
parser.add_argument("--answer", required=True, help="Path to answer file")
parser.add_argument("--rubric", required=True, help="Path to rubric JSON file")
parser.add_argument("--out", default="grade.json", help="Output JSON file")
parser.add_argument("--summary", default="summary.md", help="Output summary markdown file")
parser.add_argument("--model", default=os.getenv("LLM_MODEL", "deepseek-chat"))
parser.add_argument("--api_url", default=os.getenv("LLM_API_URL", "https://api.deepseek.com/chat/completions"))
parser.add_argument("--api_key", default=os.getenv("LLM_API_KEY", ""))
args = parser.parse_args()
# 验证必需的配置
if not args.api_key:
print("Warning: LLM_API_KEY not set. LLM grading may fail.", file=sys.stderr)
# 读取文件或字符串
# question 可以是文件路径或直接的问题字符串
question = read_file_or_string(args.question).strip()
# answer 和 rubric 必须是文件路径
answer = read_file(args.answer).strip()
rubric_text = read_file(args.rubric).strip()
if not question or not answer:
print(f"Warning: Empty question or answer file", file=sys.stderr)
resp = {
"total": 0,
"criteria": [],
"flags": ["need_review", "empty_answer"],
"confidence": 0.0
}
else:
# 调用 LLM
try:
prompt = PROMPT_TEMPLATE.format(
question=question,
rubric=rubric_text,
answer=answer
)
resp = call_llm(args.api_url, args.api_key, args.model, prompt)
except Exception as e:
print(f"LLM grading failed: {e}", file=sys.stderr)
resp = {
"total": 0,
"criteria": [],
"flags": ["need_review", "llm_error"],
"confidence": 0.0
}
# 重新计算 total不信任 LLM 返回的 total使用各项得分之和
criteria = resp.get("criteria", [])
if criteria:
calculated_total = sum(float(c.get("score", 0)) for c in criteria)
resp["total"] = round(calculated_total, 2)
# 边界带自动送审
try:
rubric_data = json.loads(rubric_text)
lo, hi = rubric_data.get("borderline_band", [None, None])
total = float(resp.get("total", 0))
flags = set(resp.get("flags", []))
if lo is not None and hi is not None and lo <= total <= hi:
flags.add("need_review")
# 低置信度送审
confidence = resp.get("confidence", 1.0)
if confidence < 0.7:
flags.add("need_review")
resp["flags"] = sorted(list(flags))
except Exception:
pass
# 保存 grade.json
with open(args.out, "w", encoding="utf-8") as f:
json.dump(resp, f, ensure_ascii=False, indent=2)
# 生成 summary.md
try:
rubric_data = json.loads(rubric_text)
max_score = rubric_data.get("max_score", 10)
except Exception:
max_score = 10
lines = [
f"# 简答题评分",
f"",
f"- **总分****{resp.get('total', 0):.2f} / {max_score}**",
f"- **置信度**{resp.get('confidence', 0):.2f}",
f"- **标记**{', '.join(resp.get('flags', [])) or ''}",
f"",
f"## 分项评分"
]
for criterion in resp.get("criteria", []):
criterion_id = criterion.get("id", "")
score = criterion.get("score", 0)
reason = criterion.get("reason", "")
lines.append(f"- **{criterion_id}**: {score}")
if reason:
lines.append(f" - {reason}")
with open(args.summary, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"LLM grading complete: {resp.get('total', 0):.2f}/{max_score}")
if __name__ == "__main__":
main()

View File

@ -1,444 +0,0 @@
#!/usr/bin/env python3
"""
选择题/判断题评分脚本
读取学生答案和标准答案生成成绩 JSON 文件
"""
import json
import argparse
import sys
from datetime import datetime
from pathlib import Path
def load_answers(answer_file):
"""
加载学生答案文件支持 JSON 和简单文本格式
JSON 格式示例
{
"MC1": "A",
"MC2": "B",
"TF1": true,
"TF2": false
}
文本格式示例每行一个答案
A
B
true
false
"""
try:
with open(answer_file, 'r', encoding='utf-8') as f:
content = f.read().strip()
# 尝试作为 JSON 加载
if content.startswith('{'):
return json.loads(content)
# 否则按行加载,忽略空行和注释
lines = [line.strip() for line in content.split('\n') if line.strip() and not line.strip().startswith('#')]
# 转换为字典格式:{"MC1": answer, "MC2": answer, ...}
answers = {}
for i, line in enumerate(lines, 1):
# 尝试识别题型
if line.lower() in ('true', 'false', 't', 'f'):
question_id = f"TF{len([k for k in answers if k.startswith('TF')])+1}"
answers[question_id] = line.lower() in ('true', 't')
else:
question_id = f"MC{len([k for k in answers if k.startswith('MC')])+1}"
answers[question_id] = line.upper()
return answers
except Exception as e:
print(f"Error loading answers: {e}", file=sys.stderr)
return {}
def load_standard_answers(std_file):
"""加载标准答案文件JSON 格式)"""
try:
with open(std_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"Error loading standard answers: {e}", file=sys.stderr)
return {}
def grade_multiple_choice(student_answers, standard_answers, question_texts=None):
"""
评选择题
Parameters
----------
student_answers : dict
学生答案格式 {"MC1": "A", "MC2": "B", ...}
standard_answers : dict
标准答案格式 {"MC1": "A", "MC2": "B", ...}
question_texts : dict, optional
题目文本格式 {"MC1": "题目文本", ...}
Returns
-------
dict
成绩数据
"""
questions = []
correct_count = 0
for question_id, std_answer in standard_answers.items():
if not question_id.startswith('MC'):
continue
student_answer = student_answers.get(question_id, "")
is_correct = str(student_answer).upper() == str(std_answer).upper()
if is_correct:
correct_count += 1
score = 1
else:
score = 0
questions.append({
"question_id": question_id,
"question_text": question_texts.get(question_id, "") if question_texts else "",
"correct_answer": str(std_answer).upper(),
"student_answer": str(student_answer).upper(),
"correct": is_correct,
"score": score,
"max_score": 1
})
total_count = len(questions)
return {
"type": "multiple_choice",
"score": correct_count,
"max_score": total_count,
"details": {
"correct": correct_count,
"total": total_count,
"questions": questions
}
}
def grade_true_false(student_answers, standard_answers, question_texts=None):
"""
评判断题
Parameters
----------
student_answers : dict
学生答案格式 {"TF1": true, "TF2": false, ...}
standard_answers : dict
标准答案格式 {"TF1": true, "TF2": false, ...}
question_texts : dict, optional
题目文本
Returns
-------
dict
成绩数据
"""
questions = []
correct_count = 0
for question_id, std_answer in standard_answers.items():
if not question_id.startswith('TF'):
continue
student_answer = student_answers.get(question_id, None)
# 规范化布尔值
if isinstance(student_answer, str):
student_answer = student_answer.lower() in ('true', 't', '1', 'yes')
is_correct = bool(student_answer) == bool(std_answer)
if is_correct:
correct_count += 1
score = 1
else:
score = 0
questions.append({
"question_id": question_id,
"question_text": question_texts.get(question_id, "") if question_texts else "",
"correct_answer": bool(std_answer),
"student_answer": bool(student_answer) if student_answer is not None else None,
"correct": is_correct,
"score": score,
"max_score": 1
})
total_count = len(questions)
return {
"type": "true_false",
"score": correct_count,
"max_score": total_count,
"details": {
"correct": correct_count,
"total": total_count,
"questions": questions
}
}
def grade_multiple_select(student_answers, standard_answers, question_texts=None):
"""
评多选题
Parameters
----------
student_answers : dict
学生答案格式 {"MS1": ["A", "B"], "MS2": ["C"], ...}
standard_answers : dict
标准答案格式 {"MS1": ["A", "B"], "MS2": ["C"], ...}
question_texts : dict, optional
题目文本
Returns
-------
dict
成绩数据
"""
questions = []
correct_count = 0
for question_id, std_answer in standard_answers.items():
if not question_id.startswith('MS'):
continue
student_answer = student_answers.get(question_id, [])
# 规范化答案(转为大写并排序)
if isinstance(student_answer, str):
student_answer = [student_answer]
if not isinstance(student_answer, list):
student_answer = []
std_set = set([str(a).upper() for a in std_answer])
stu_set = set([str(a).upper() for a in student_answer])
is_correct = std_set == stu_set
if is_correct:
correct_count += 1
score = 1
else:
score = 0
questions.append({
"question_id": question_id,
"question_text": question_texts.get(question_id, "") if question_texts else "",
"correct_answer": sorted(list(std_set)),
"student_answer": sorted(list(stu_set)) if stu_set else [],
"correct": is_correct,
"score": score,
"max_score": 1
})
total_count = len(questions)
return {
"type": "multiple_select",
"score": correct_count,
"max_score": total_count,
"details": {
"correct": correct_count,
"total": total_count,
"questions": questions
}
}
def grade_fill_blank(student_answers, standard_answers, question_texts=None):
"""
评填空题
Parameters
----------
student_answers : dict
学生答案格式 {"FB1": "答案", "FB2": ["答案1", "答案2"], ...}
standard_answers : dict
标准答案格式同上
question_texts : dict, optional
题目文本
Returns
-------
dict
成绩数据
"""
questions = []
correct_count = 0
def normalize_answer(ans):
"""规范化答案:去除空格、转小写"""
if isinstance(ans, str):
return ans.strip().lower()
elif isinstance(ans, list):
return [a.strip().lower() for a in ans]
return ans
def compare_answers(student, standard):
"""比较答案是否相等"""
student_norm = normalize_answer(student)
standard_norm = normalize_answer(standard)
if isinstance(standard_norm, list) and isinstance(student_norm, list):
return student_norm == standard_norm
elif isinstance(standard_norm, str) and isinstance(student_norm, str):
return student_norm == standard_norm
return False
for question_id, std_answer in standard_answers.items():
if not question_id.startswith('FB'):
continue
student_answer = student_answers.get(question_id, "")
is_correct = compare_answers(student_answer, std_answer)
if is_correct:
correct_count += 1
score = 1
else:
score = 0
questions.append({
"question_id": question_id,
"question_text": question_texts.get(question_id, "") if question_texts else "",
"correct_answer": std_answer,
"student_answer": student_answer,
"correct": is_correct,
"score": score,
"max_score": 1
})
total_count = len(questions)
return {
"type": "fill_blank",
"score": correct_count,
"max_score": total_count,
"details": {
"correct": correct_count,
"total": total_count,
"questions": questions
}
}
def main():
parser = argparse.ArgumentParser(description="Grade objective questions")
parser.add_argument("--answers", required=True, help="Student answers file (JSON or text)")
parser.add_argument("--standard", required=True, help="Standard answers file (JSON)")
parser.add_argument("--questions", help="Question texts file (JSON, optional)")
parser.add_argument("--out", default="grade.json", help="Output grade JSON file")
parser.add_argument("--summary", default="summary.md", help="Output summary markdown file")
parser.add_argument("--type", choices=['mc', 'tf', 'ms', 'fb', 'all'], default='all',
help="Question type to grade")
args = parser.parse_args()
# 加载文件
student_answers = load_answers(args.answers)
standard_answers = load_standard_answers(args.standard)
question_texts = None
if args.questions:
try:
with open(args.questions, 'r', encoding='utf-8') as f:
question_texts = json.load(f)
except Exception as e:
print(f"Warning: Could not load question texts: {e}", file=sys.stderr)
if not student_answers or not standard_answers:
print("Error: Could not load answers", file=sys.stderr)
sys.exit(1)
# 评分
components = []
total_score = 0
total_max_score = 0
if args.type in ('mc', 'all'):
mc_grade = grade_multiple_choice(student_answers, standard_answers, question_texts)
if mc_grade['details']['total'] > 0:
components.append(mc_grade)
total_score += mc_grade['score']
total_max_score += mc_grade['max_score']
if args.type in ('tf', 'all'):
tf_grade = grade_true_false(student_answers, standard_answers, question_texts)
if tf_grade['details']['total'] > 0:
components.append(tf_grade)
total_score += tf_grade['score']
total_max_score += tf_grade['max_score']
if args.type in ('ms', 'all'):
ms_grade = grade_multiple_select(student_answers, standard_answers, question_texts)
if ms_grade['details']['total'] > 0:
components.append(ms_grade)
total_score += ms_grade['score']
total_max_score += ms_grade['max_score']
if args.type in ('fb', 'all'):
fb_grade = grade_fill_blank(student_answers, standard_answers, question_texts)
if fb_grade['details']['total'] > 0:
components.append(fb_grade)
total_score += fb_grade['score']
total_max_score += fb_grade['max_score']
# 生成 grade.json
grade_data = {
"score": total_score,
"max_score": total_max_score,
"components": components,
"timestamp": int(__import__('time').time())
}
with open(args.out, 'w', encoding='utf-8') as f:
json.dump(grade_data, f, ensure_ascii=False, indent=2)
# 生成 summary.md
summary_lines = [
"# 客观题评分\n",
f"- **总分**{total_score} / {total_max_score}\n",
f"- **组件数**{len(components)}\n",
""
]
for comp in components:
comp_type = comp['type']
correct = comp['details']['correct']
total = comp['details']['total']
type_names = {
'multiple_choice': '选择题',
'true_false': '判断题',
'multiple_select': '多选题',
'fill_blank': '填空题'
}
type_name = type_names.get(comp_type, comp_type)
summary_lines.append(f"## {type_name}\n")
summary_lines.append(f"- **正确**{correct} / {total}\n")
summary_lines.append("")
with open(args.summary, 'w', encoding='utf-8') as f:
f.write("\n".join(summary_lines))
print(f"Grading complete: {total_score}/{total_max_score}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,155 +0,0 @@
#!/usr/bin/env python3
"""
发送评论到 Gitea PR
从环境变量读取配置发送评论到指定的 PR
支持在 Markdown 评论中嵌入 JSON 数据便于后续结构化提取
"""
import os
import sys
import json
import requests
from datetime import datetime
def create_comment_with_metadata(summary, commit_sha, comment_type='grade', metadata=None):
"""
创建包含元数据的评论内容
Parameters
----------
summary : str
人类可读的 Markdown 格式总结
commit_sha : str
提交 SHA
comment_type : str
评论类型 ('grade', 'llm', 'combined')
metadata : dict, optional
结构化的成绩数据将嵌入为 JSON
Returns
-------
str
完整的评论内容Markdown + JSON
"""
commit_short = commit_sha[:7] if commit_sha else 'unknown'
# 根据类型设置标题和图标
if comment_type == 'llm':
title = "🤖 LLM 简答题评分结果"
footer = "*此评论由 Gitea Actions 自动生成(使用 DeepSeek API | Commit: `{}`*"
elif comment_type == 'combined':
title = "📊 综合评分结果"
footer = "*此评论由 Gitea Actions 自动生成 | Commit: `{}`*"
else:
title = "🤖 自动评分结果"
footer = "*此评论由 Gitea Actions 自动生成 | Commit: `{}`*"
# 构建评论
parts = [
f"## {title}",
"",
summary,
""
]
# 如果提供了元数据,嵌入 JSON
if metadata:
# 确保元数据包含版本和时间戳
if 'version' not in metadata:
metadata['version'] = '1.0'
if 'timestamp' not in metadata:
metadata['timestamp'] = datetime.now().isoformat()
# 使用 Markdown 代码块嵌入 JSON更可靠Gitea 会保留)
# 放在评论末尾,对学生不太显眼
json_str = json.dumps(metadata, ensure_ascii=False, indent=2)
parts.extend([
"",
"---",
"",
"<!-- GRADE_METADATA -->",
"```json",
json_str,
"```",
""
])
parts.extend([
footer.format(commit_short)
])
return "\n".join(parts)
def main():
# 从环境变量读取配置
api_url = os.environ.get('API_URL', '')
repo = os.environ.get('REPO', '')
pr_number = os.environ.get('PR_NUMBER', '')
token = os.environ.get('GITEA_TOKEN', '')
summary = os.environ.get('SUMMARY', '')
commit_sha = os.environ.get('COMMIT_SHA', '')
comment_type = os.environ.get('COMMENT_TYPE', 'grade')
# 可选:从环境变量读取 JSON 元数据
metadata_str = os.environ.get('GRADE_METADATA', '')
metadata = None
if metadata_str:
try:
metadata = json.loads(metadata_str)
except json.JSONDecodeError as e:
print(f"Warning: Failed to parse GRADE_METADATA: {e}", file=sys.stderr)
# 验证必需参数
if not all([api_url, repo, pr_number, token, summary]):
print("Error: Missing required environment variables", file=sys.stderr)
print(f"API_URL: {api_url}", file=sys.stderr)
print(f"REPO: {repo}", file=sys.stderr)
print(f"PR_NUMBER: {pr_number}", file=sys.stderr)
print(f"GITEA_TOKEN: {'set' if token else 'not set'}", file=sys.stderr)
print(f"SUMMARY: {'set' if summary else 'not set'}", file=sys.stderr)
sys.exit(1)
# 构建评论内容(包含元数据)
comment_body = create_comment_with_metadata(
summary=summary,
commit_sha=commit_sha,
comment_type=comment_type,
metadata=metadata
)
# 构建 API URL
comment_url = f"{api_url}/repos/{repo}/issues/{pr_number}/comments"
# 发送请求
headers = {
"Authorization": f"token {token}",
"Content-Type": "application/json"
}
data = {"body": comment_body}
try:
print(f"Posting comment to: {comment_url}")
if metadata:
print("✓ Comment includes structured metadata")
response = requests.post(comment_url, headers=headers, json=data, timeout=30)
response.raise_for_status()
print("✅ Comment posted successfully to PR")
return 0
except requests.exceptions.Timeout:
print("⚠️ Request timeout", file=sys.stderr)
return 1
except requests.exceptions.HTTPError as e:
print(f"⚠️ HTTP error: {e}", file=sys.stderr)
print(f"Response: {response.text}", file=sys.stderr)
return 1
except Exception as e:
print(f"⚠️ Failed to post comment: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,164 +0,0 @@
#!/usr/bin/env python3
"""
通用测试运行器 - 根据语言配置运行测试并生成 JUnit XML
支持的语言:
- python: pytest
- java: maven (mvn test)
- r: testthat (通过 JUnit Reporter)
环境变量:
- LANGUAGE: 编程语言 (python/java/r)
- TEST_DIR: 测试目录路径
- SOURCE_DIR: 源代码目录路径
"""
import argparse
import subprocess
import sys
import os
from pathlib import Path
def run_python_tests(test_dir, output_xml, **kwargs):
"""运行 Python pytest 测试"""
cmd = [
"pytest", test_dir,
f"--junit-xml={output_xml}",
"-v", "--tb=short"
]
# 添加覆盖率选项(如果指定)
source_dir = kwargs.get('source_dir')
if source_dir:
cmd.extend([
f"--cov={source_dir}",
"--cov-report=term-missing",
"--cov-report=json:coverage.json"
])
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=False)
return result
def run_java_tests(test_dir, output_xml, **kwargs):
"""运行 Java Maven 测试"""
cmd = ["mvn", "test", "-B"]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=False)
# Maven 自动生成 XML 在 target/surefire-reports/
# 需要复制到指定的输出位置
surefire_dir = Path("target/surefire-reports")
if surefire_dir.exists():
# 合并所有 TEST-*.xml 文件
import xml.etree.ElementTree as ET
xml_files = list(surefire_dir.glob("TEST-*.xml"))
if xml_files:
# 简单情况:只复制第一个(或合并)
import shutil
if len(xml_files) == 1:
shutil.copy(xml_files[0], output_xml)
else:
# 合并多个 XML 文件(简化版本)
root = ET.Element("testsuites")
for xml_file in xml_files:
tree = ET.parse(xml_file)
root.append(tree.getroot())
tree = ET.ElementTree(root)
tree.write(output_xml, encoding='utf-8', xml_declaration=True)
return result
def run_r_tests(test_dir, output_xml, **kwargs):
"""运行 R testthat 测试"""
# R 脚本:使用 testthat 的 JUnitReporter
# 注意:需要安装 testthat (>= 3.0.0)
r_script = f"""
library(testthat)
# 配置 JUnit reporter
reporter <- JunitReporter$new(file = '{output_xml}')
# 运行测试
test_dir(
path = '{test_dir}',
reporter = reporter,
stop_on_failure = FALSE
)
"""
# 将脚本写入临时文件
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.R', delete=False) as f:
f.write(r_script)
script_path = f.name
try:
cmd = ["Rscript", script_path]
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=False)
return result
finally:
# 清理临时文件
if os.path.exists(script_path):
os.remove(script_path)
def main():
parser = argparse.ArgumentParser(
description="通用测试运行器 - 支持 Python/Java/R"
)
parser.add_argument(
"--language",
required=True,
choices=["python", "java", "r"],
help="编程语言"
)
parser.add_argument(
"--test-dir",
required=True,
help="测试目录路径"
)
parser.add_argument(
"--output-xml",
default="test-results.xml",
help="JUnit XML 输出文件路径"
)
parser.add_argument(
"--source-dir",
help="源代码目录(用于覆盖率)"
)
args = parser.parse_args()
# 语言对应的运行器
runners = {
"python": run_python_tests,
"java": run_java_tests,
"r": run_r_tests,
}
if args.language not in runners:
print(f"❌ Unsupported language: {args.language}", file=sys.stderr)
sys.exit(1)
# 运行测试
result = runners[args.language](
args.test_dir,
args.output_xml,
source_dir=args.source_dir
)
sys.exit(result.returncode)
if __name__ == "__main__":
main()

View File

@ -1,95 +0,0 @@
#!/bin/bash
# 测试客观题评分脚本
set -e
echo "=== 测试客观题评分脚本 ==="
cd "$(dirname "$0")/.."
# 测试 1: 使用 JSON 格式答案
echo ""
echo "测试 1: JSON 格式答案(全对)"
python3 ./.autograde/objective_grade.py \
--answers objective_questions/standard_answers.json \
--standard objective_questions/standard_answers.json \
--questions objective_questions/question_texts.json \
--out test_grade1.json \
--summary test_summary1.md \
--type both
echo "分数:"
python3 -c "import json; data=json.load(open('test_grade1.json')); print(f\"{data['score']}/{data['max_score']}\")"
echo ""
echo "摘要:"
cat test_summary1.md
# 测试 2: 使用部分错误的答案
echo ""
echo "测试 2: 部分错误答案"
cat > test_answers2.json << 'EOF'
{
"MC1": "A",
"MC2": "A",
"MC3": "C",
"MC4": "B",
"MC5": "C",
"TF1": true,
"TF2": false,
"TF3": true,
"TF4": true,
"TF5": false
}
EOF
python3 ./.autograde/objective_grade.py \
--answers test_answers2.json \
--standard objective_questions/standard_answers.json \
--questions objective_questions/question_texts.json \
--out test_grade2.json \
--summary test_summary2.md \
--type both
echo "分数:"
python3 -c "import json; data=json.load(open('test_grade2.json')); print(f\"{data['score']}/{data['max_score']}\")"
echo ""
echo "摘要:"
cat test_summary2.md
# 测试 3: 只评选择题
echo ""
echo "测试 3: 只评选择题"
python3 ./.autograde/objective_grade.py \
--answers objective_questions/standard_answers.json \
--standard objective_questions/standard_answers.json \
--questions objective_questions/question_texts.json \
--out test_grade3.json \
--summary test_summary3.md \
--type mc
echo "分数:"
python3 -c "import json; data=json.load(open('test_grade3.json')); print(f\"{data['score']}/{data['max_score']}\")"
# 测试 4: 只评判断题
echo ""
echo "测试 4: 只评判断题"
python3 ./.autograde/objective_grade.py \
--answers objective_questions/standard_answers.json \
--standard objective_questions/standard_answers.json \
--questions objective_questions/question_texts.json \
--out test_grade4.json \
--summary test_summary4.md \
--type tf
echo "分数:"
python3 -c "import json; data=json.load(open('test_grade4.json')); print(f\"{data['score']}/{data['max_score']}\")"
# 清理测试文件
rm -f test_grade*.json test_summary*.md test_answers*.json
echo ""
echo "✅ 所有测试通过!"

View File

@ -1,157 +0,0 @@
#!/usr/bin/env python3
"""
Upload metadata.json to teacher-only repository via Gitea API.
"""
import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import urlparse
def detect_host(server_url: str, external_host: str | None) -> str:
"""Detect the Gitea host to use for API calls.
If server_url uses internal name (like 'gitea'), use external_host instead.
"""
parsed = urlparse(server_url)
raw_host = parsed.netloc or parsed.path.split("/")[0]
host = raw_host
if raw_host.lower().startswith("gitea"):
if not external_host:
raise ValueError(
f"Server URL uses internal name '{raw_host}' but EXTERNAL_GITEA_HOST is not set. "
"Please configure EXTERNAL_GITEA_HOST in .env and run sync_runner_config.sh"
)
host = external_host
return host
def main() -> int:
parser = argparse.ArgumentParser(description="Upload metadata.json to course metadata repo")
parser.add_argument("--metadata-file", required=True)
parser.add_argument("--metadata-repo", required=True, help="owner/repo of metadata store")
parser.add_argument("--branch", default="main")
parser.add_argument("--student-repo", required=True)
parser.add_argument("--run-id", required=True)
parser.add_argument("--commit-sha", required=True)
parser.add_argument("--workflow", required=True, choices=["grade", "objective", "llm"])
parser.add_argument("--server-url", required=True)
parser.add_argument("--external-host")
parser.add_argument("--assignment-id", help="Assignment ID (e.g., hw1)")
args = parser.parse_args()
token = os.environ.get("METADATA_TOKEN")
if not token:
print("METADATA_TOKEN is not set", file=sys.stderr)
return 1
path = Path(args.metadata_file)
if not path.is_file():
print(f"metadata file not found: {path}", file=sys.stderr)
return 0
try:
owner, repo_name = args.metadata_repo.split("/", 1)
except ValueError:
print(f"Invalid metadata repo: {args.metadata_repo}", file=sys.stderr)
return 1
# Extract student ID from student repo name
# student repo format: hw1-stu_20250001 or hw1-stu_student1
student_id = args.student_repo.split("/")[-1] # Get repo name
# Auto-detect assignment ID from student repo if not provided
assignment_id = args.assignment_id
if not assignment_id:
# Try to extract from student_repo format: hw1-stu_xxx
repo_name_part = args.student_repo.split("/")[-1]
if "-stu_" in repo_name_part:
assignment_id = repo_name_part.split("-stu_")[0]
elif "-template" in repo_name_part:
assignment_id = repo_name_part.split("-template")[0]
elif "-tests" in repo_name_part:
assignment_id = repo_name_part.split("-tests")[0]
else:
assignment_id = "unknown"
# New path structure: {assignment_id}/{student_id}/{workflow}_{run_id}_{sha}.json
target_path = f"{assignment_id}/{student_id}/{args.workflow}_{args.run_id}_{args.commit_sha[:7]}.json"
host = detect_host(args.server_url, args.external_host)
api_url = f"http://{host}/api/v1/repos/{owner}/{repo_name}/contents/{target_path}"
message = f"Upload {args.workflow} metadata for {args.student_repo} {args.commit_sha}"
# Check if file exists to determine if we need to update (PUT) or create (POST)
get_req = urllib.request.Request(
api_url,
headers={"Authorization": f"token {token}"},
method="GET"
)
sha = None
try:
with urllib.request.urlopen(get_req) as resp:
existing_file = json.loads(resp.read().decode())
# API may return a list (directory contents) or dict (single file)
if isinstance(existing_file, dict):
sha = existing_file.get("sha")
print(f"File exists, updating (sha: {sha})")
elif isinstance(existing_file, list):
# Response is a directory listing, file doesn't exist at this exact path
print(f"Path is a directory or file not found in expected format")
else:
print(f"Unexpected response type: {type(existing_file)}")
except urllib.error.HTTPError as e:
if e.code != 404:
print(f"Error checking file existence: {e}", file=sys.stderr)
return 1
# File doesn't exist, proceed with creation
content = base64.b64encode(path.read_bytes()).decode()
payload = {
"content": content,
"message": message,
"branch": args.branch
}
if sha:
payload["sha"] = sha
data = json.dumps(payload).encode()
req = urllib.request.Request(
api_url,
data=data,
headers={
"Authorization": f"token {token}",
"Content-Type": "application/json",
},
method="PUT" if sha else "POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_body = resp.read().decode()
print(resp_body)
except urllib.error.HTTPError as exc:
print(f"Metadata upload failed: {exc.status} {exc.reason}", file=sys.stderr)
print(exc.read().decode(), file=sys.stderr)
return 1
except urllib.error.URLError as exc:
print(f"Metadata upload failed: {exc}", file=sys.stderr)
return 1
print(f"✅ Metadata stored at {args.metadata_repo}:{target_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,216 +0,0 @@
# Workflow 模板
本目录包含不同编程语言的 Gitea Actions workflow 模板。
## 可用模板
| 文件 | 语言 | 容器 | 测试框架 |
|------|------|------|----------|
| `python.yml` | Python | python:3.11 | pytest |
| `java.yml` | Java | maven:3.9-eclipse-temurin-17 | JUnit 5 |
| `r.yml` | R | r-base:4.3 | testthat |
## 使用方法
### 1. 选择模板
根据你的编程语言选择对应的模板:
```bash
# 对于 Python 作业
cp .autograde/workflow_templates/python.yml .gitea/workflows/grade.yml
# 对于 Java 作业
cp .autograde/workflow_templates/java.yml .gitea/workflows/grade.yml
# 对于 R 作业
cp .autograde/workflow_templates/r.yml .gitea/workflows/grade.yml
```
### 2. 自定义配置
编辑 `.gitea/workflows/grade.yml` 根据需要修改:
- **容器版本**:修改 `container:` 字段
- **超时时间**:修改 `timeout-minutes:`
- **依赖安装**:修改 "Install dependencies" 步骤
- **测试命令**:修改测试运行步骤
### 3. 配置 Secrets
确保在 Gitea 仓库设置中配置了以下 Secrets
- `TESTS_TOKEN`:用于访问隐藏测试仓库的 token可选
- `EXTERNAL_GITEA_HOST`:外部访问的 Gitea 地址(可选)
## Python 模板 (python.yml)
### 特点
- 使用 `python:3.11` 容器
- 自动安装 `requirements.txt` 中的依赖
- 使用 `run_tests.py` 运行 pytest
- 支持代码覆盖率
### 自定义选项
```yaml
# 修改 Python 版本
container: python:3.10 # 或 python:3.9
# 添加额外的依赖
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install numpy pandas # 额外的包
```
## Java 模板 (java.yml)
### 特点
- 使用 `maven:3.9-eclipse-temurin-17` 容器
- Maven 自动管理依赖(通过 `pom.xml`
- JUnit 5 测试框架
- 自动提取 Surefire 报告
### 自定义选项
```yaml
# 修改 JDK 版本
container: maven:3.9-eclipse-temurin-11 # Java 11
container: maven:3.9-eclipse-temurin-21 # Java 21
# 自定义 Maven 命令
run: |
mvn clean test -B -DskipTests=false
```
### Maven 配置提示
确保 `pom.xml` 中配置了 Surefire 插件:
```xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.2</version>
<configuration>
<reportsDirectory>${project.build.directory}/surefire-reports</reportsDirectory>
</configuration>
</plugin>
```
## R 模板 (r.yml)
### 特点
- 使用 `r-base:4.3` 容器
- 自动从 `DESCRIPTION` 安装依赖
- testthat 测试框架
- JUnitReporter 输出 XML
### 自定义选项
```yaml
# 修改 R 版本
container: r-base:4.2 # 或其他版本
# 修改 CRAN 镜像
run: |
Rscript -e "install.packages('testthat', repos='https://cran.r-project.org/')"
```
### R 项目结构要求
```
project/
├── DESCRIPTION # 包依赖定义
├── R/ # R 源代码
└── tests/
└── testthat/ # testthat 测试
```
## 通用 Workflow 流程
所有模板都遵循相同的流程:
1. **安装系统依赖**git, rsync 等)
2. **检出代码** - 克隆学生仓库
3. **安装语言依赖** - 根据语言安装包
4. **获取隐藏测试**(可选)- 从私有仓库获取
5. **运行测试** - 生成 JUnit XML
6. **评分** - 解析 XML计算分数
7. **生成元数据** - 创建 JSON metadata
8. **发布评论** - 在 PR 中发布结果
## 高级配置
### 添加代码质量检查
```yaml
- name: Run linter
run: |
# Python: pylint, flake8
pip install pylint
pylint src/
# Java: checkstyle
mvn checkstyle:check
# R: lintr
Rscript -e "lintr::lint_package()"
```
### 自定义评分规则
修改 `grade.py` 的调用参数:
```yaml
- name: Grade
run: |
python3 ./.autograde/grade.py \
--junit junit.xml \
--out grade.json \
--summary summary.md \
--bonus bonus.json # 可选的加分项
```
### 多个测试套件
```yaml
- name: Run public tests
run: |
pytest tests_public/ --junit-xml=public.xml
- name: Run hidden tests
run: |
pytest tests_hidden/ --junit-xml=hidden.xml
- name: Merge test results
run: |
python3 ./.autograde/merge_junit.py public.xml hidden.xml -o junit.xml
```
## 故障排查
### 测试无法运行
- 检查测试目录路径是否正确
- 确认依赖是否正确安装
- 查看 Actions 日志中的错误信息
### JUnit XML 未生成
- Python: 确保 pytest 命令包含 `--junit-xml`
- Java: 检查 Surefire 插件配置
- R: 确认 testthat >= 3.0.0
### 元数据为空
- 检查 `grade.json` 是否生成
- 确认 `LANGUAGE` 环境变量设置正确
- 查看 `create_minimal_metadata.py` 的输出
## 相关文档
- [运行测试脚本](../run_tests.py) - 通用测试运行器
- [评分脚本](../grade.py) - JUnit XML 解析和评分
- [元数据生成](../create_minimal_metadata.py) - JSON 元数据
- [示例](../../examples/) - 各语言的完整示例
---
最后更新: 2025-11-13

View File

@ -1,200 +0,0 @@
name: autograde-java
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
java:
runs-on: docker
container:
image: gradle:9.0-jdk21
options: --user root
timeout-minutes: 20
steps:
- name: Install dependencies (CN mirror)
run: |
set -e
# 替换 Debian/Ubuntu 源为腾讯云镜像
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
[ -f "$f" ] || continue
sed -i -E 's|https?://deb.debian.org|http://mirrors.cloud.tencent.com|g' "$f" || true
sed -i -E 's|https?://security.debian.org|http://mirrors.cloud.tencent.com/debian-security|g' "$f" || true
sed -i -E 's|https?://archive.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
sed -i -E 's|https?://ports.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
done
apt-get -o Acquire::Check-Valid-Until=false -o Acquire::AllowInsecureRepositories=true update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip nodejs rsync
pip3 install --break-system-packages python-dotenv requests -i https://mirrors.cloud.tencent.com/pypi/simple
rm -rf /var/lib/apt/lists/*
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Fix permissions
run: |
# Ensure workspace is owned by current user
chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
- name: Fetch hidden tests (if available)
working-directory: ${{ github.workspace }}
env:
EXTERNAL_GITEA_HOST: ${{ secrets.EXTERNAL_GITEA_HOST }}
run: |
set -e
TESTS_USERNAME="${RUNNER_TESTS_USERNAME:-}"
TESTS_TOKEN="${RUNNER_TESTS_TOKEN:-}"
if [ -z "$TESTS_TOKEN" ] || [ -z "$TESTS_USERNAME" ]; then
echo "Warning: RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN not set, skipping private tests"
exit 0
fi
# Resolve Gitea Host
if [ -n "$EXTERNAL_GITEA_HOST" ]; then
HOST="$EXTERNAL_GITEA_HOST"
elif [ -n "$GITEA_ROOT_URL" ]; then
HOST=$(echo "$GITEA_ROOT_URL" | sed 's|https\?://||' | sed 's|/$||')
else
HOST=$(echo "${{ github.server_url }}" | sed 's|https\?://||' | cut -d'/' -f1)
fi
echo "📥 Fetching private tests repository..."
echo " Gitea host: $HOST"
# Infer organization and assignment ID from repository name
ORG=$(echo "${{ github.repository }}" | cut -d'/' -f1)
REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2)
# Extract assignment ID from repo name (e.g., hw2-stu_xxx -> hw2, hw2-template -> hw2)
if echo "$REPO_NAME" | grep -q -- '-stu_'; then
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-stu_.*//')
elif echo "$REPO_NAME" | grep -q -- '-template'; then
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-template.*//')
else
ASSIGNMENT_ID="hw1" # fallback
fi
echo " Organization: $ORG"
echo " Assignment ID: $ASSIGNMENT_ID"
# Clone private test repository
AUTH_URL="http://${TESTS_USERNAME}:${TESTS_TOKEN}@${HOST}/${ORG}/${ASSIGNMENT_ID}-tests.git"
if ! git -c http.sslVerify=false clone --depth=1 "$AUTH_URL" _priv_tests 2>&1; then
echo "❌ Failed to clone ${ASSIGNMENT_ID}-tests repository!"
exit 1
fi
# Verify test directory exists
if [ ! -d "_priv_tests/java" ]; then
echo "❌ java/ directory not found in ${ASSIGNMENT_ID}-tests!"
exit 1
fi
if [ ! -d "_priv_tests/java/src/test" ]; then
echo "❌ java/src/test/ not found in ${ASSIGNMENT_ID}-tests!"
exit 1
fi
# Copy tests to src/test/
rsync -a _priv_tests/java/src/test/ src/test/
echo "✅ Private tests copied: _priv_tests/java/src/test/ → src/test/"
- name: Run tests using Gradle
working-directory: ${{ github.workspace }}
env:
LANGUAGE: java
run: |
gradle test --no-daemon || true
- name: Extract test results
working-directory: ${{ github.workspace }}
run: |
# Find JUnit XML report
XML_REPORT=$(find build/test-results/test -name "TEST-*.xml" | head -n 1)
if [ -n "$XML_REPORT" ]; then
cp "$XML_REPORT" junit.xml
echo "✅ Found JUnit report: $XML_REPORT"
else
echo "⚠️ No JUnit report found!"
touch junit.xml
fi
- name: Grade
working-directory: ${{ github.workspace }}
env:
LANGUAGE: java
run: |
python3 ./.autograde/grade.py --junit junit.xml --out grade.json --summary summary.md
- name: Prepare artifacts
working-directory: ${{ github.workspace }}
run: |
mkdir -p artifacts
cp junit.xml summary.md grade.json artifacts/ 2>/dev/null || true
- name: Create grade metadata
working-directory: ${{ github.workspace }}
env:
LANGUAGE: java
REPO: ${{ github.repository }}
run: |
if [ ! -f grade.json ]; then
echo "⚠️ grade.json not found, skipping metadata creation"
exit 0
fi
# 生成 JSON 元数据
if [ -f ./.autograde/create_grade_metadata.py ]; then
python3 ./.autograde/create_grade_metadata.py > metadata.json || echo "{}" > metadata.json
echo "✅ Grade metadata created (using create_grade_metadata.py)"
elif [ -f ./.autograde/create_minimal_metadata.py ]; then
export GRADE_TYPE=programming
python3 ./.autograde/create_minimal_metadata.py > metadata.json || echo "{}" > metadata.json
echo "✅ Grade metadata created (using create_minimal_metadata.py)"
else
echo "⚠️ No metadata creation script found, skipping"
echo "{}" > metadata.json
fi
- name: Upload metadata (teacher only)
if: env.RUNNER_METADATA_REPO != '' && env.RUNNER_METADATA_TOKEN != ''
working-directory: ${{ github.workspace }}
shell: bash
env:
METADATA_REPO: ${{ env.RUNNER_METADATA_REPO }}
METADATA_TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
METADATA_BRANCH: ${{ env.RUNNER_METADATA_BRANCH }}
STUDENT_REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
COMMIT_SHA: ${{ github.sha }}
SERVER_URL: ${{ github.server_url }}
run: |
set -e
if [ ! -f metadata.json ]; then
echo "No metadata.json found, skip uploading."
exit 0
fi
python3 ./.autograde/upload_metadata.py \
--metadata-file metadata.json \
--metadata-repo "${METADATA_REPO}" \
--branch "${METADATA_BRANCH:-main}" \
--student-repo "${STUDENT_REPO}" \
--run-id "${RUN_ID}" \
--commit-sha "${COMMIT_SHA}" \
--workflow grade \
--server-url "${SERVER_URL}" \
--external-host "${EXTERNAL_GITEA_HOST}"
rm -f metadata.json

View File

@ -1,206 +0,0 @@
name: autograde-python
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
python:
runs-on: docker
container: python:3.11
timeout-minutes: 15
steps:
- name: Install dependencies (CN mirror)
run: |
set -e
# 替换 Debian/Ubuntu 源为腾讯云镜像
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
[ -f "$f" ] || continue
sed -i -E 's|https?://deb.debian.org|http://mirrors.cloud.tencent.com|g' "$f" || true
sed -i -E 's|https?://security.debian.org|http://mirrors.cloud.tencent.com/debian-security|g' "$f" || true
sed -i -E 's|https?://archive.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
sed -i -E 's|https?://ports.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
done
apt-get -o Acquire::Check-Valid-Until=false -o Acquire::AllowInsecureRepositories=true update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip nodejs rsync
rm -rf /var/lib/apt/lists/*
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Fix permissions
run: |
# Ensure workspace is owned by current user
chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
- name: Install Python deps
working-directory: ${{ github.workspace }}
run: |
# 使用腾讯云镜像源加速
python -m pip install -U pip -i https://mirrors.cloud.tencent.com/pypi/simple
if [ -f requirements.txt ]; then pip install -r requirements.txt -i https://mirrors.cloud.tencent.com/pypi/simple; fi
if [ -f pyproject.toml ]; then pip install . -i https://mirrors.cloud.tencent.com/pypi/simple; fi
pip install pytest pytest-cov junit-xml python-dotenv requests -i https://mirrors.cloud.tencent.com/pypi/simple
- name: Fetch private tests
working-directory: ${{ github.workspace }}
env:
EXTERNAL_GITEA_HOST: ${{ secrets.EXTERNAL_GITEA_HOST }}
run: |
set -e
TESTS_USERNAME="${RUNNER_TESTS_USERNAME:-}"
TESTS_TOKEN="${RUNNER_TESTS_TOKEN:-}"
if [ -z "$TESTS_TOKEN" ] || [ -z "$TESTS_USERNAME" ]; then
echo "❌ RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN 未配置!"
echo "测试必须从私有的 tests 仓库获取"
exit 1
fi
# Resolve Gitea Host
if [ -n "$EXTERNAL_GITEA_HOST" ]; then
HOST="$EXTERNAL_GITEA_HOST"
elif [ -n "$GITEA_ROOT_URL" ]; then
HOST=$(echo "$GITEA_ROOT_URL" | sed 's|https\?://||' | sed 's|/$||')
else
HOST=$(echo "${{ github.server_url }}" | sed 's|https\?://||' | cut -d'/' -f1)
fi
echo "📥 Fetching private tests repository..."
echo " Gitea host: $HOST"
# Infer organization and assignment ID from repository name
ORG=$(echo "${{ github.repository }}" | cut -d'/' -f1)
REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2)
# Extract assignment ID from repo name (e.g., hw1-stu_xxx -> hw1, hw1-template -> hw1)
if echo "$REPO_NAME" | grep -q -- '-stu_'; then
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-stu_.*//')
elif echo "$REPO_NAME" | grep -q -- '-template'; then
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-template.*//')
else
ASSIGNMENT_ID="hw1" # fallback
fi
echo " Organization: $ORG"
echo " Assignment ID: $ASSIGNMENT_ID"
AUTH_URL="http://${TESTS_USERNAME}:${TESTS_TOKEN}@${HOST}/${ORG}/${ASSIGNMENT_ID}-tests.git"
if ! git -c http.sslVerify=false clone --depth=1 "$AUTH_URL" _priv_tests 2>&1; then
echo "❌ Failed to clone ${ASSIGNMENT_ID}-tests repository!"
exit 1
fi
# 验证测试目录存在
if [ ! -d "_priv_tests/python" ]; then
echo "❌ python/ directory not found in ${ASSIGNMENT_ID}-tests!"
exit 1
fi
if [ ! -d "_priv_tests/python/tests" ]; then
echo "❌ python/tests/ not found in ${ASSIGNMENT_ID}-tests!"
exit 1
fi
# 复制测试到 tests/
mkdir -p tests
rsync -a _priv_tests/python/tests/ tests/
echo "✅ Tests copied: _priv_tests/python/tests/ → tests/"
# 复制数据文件(如果存在)
if [ -d "_priv_tests/python/data" ]; then
mkdir -p tests/data
rsync -a _priv_tests/python/data/ tests/data/
echo "✅ Data files copied: _priv_tests/python/data/ → tests/data/"
fi
# 验证测试文件
if [ -z "$(find tests -name 'test_*.py' 2>/dev/null)" ]; then
echo "❌ No test files found in tests/ directory!"
exit 1
fi
echo "✅ Test suite ready:"
find tests -name 'test_*.py'
- name: Run tests
working-directory: ${{ github.workspace }}
run: |
# 设置随机种子
export PYTHONHASHSEED=2025
pytest -q --maxfail=0 --junitxml=junit.xml --tb=short || true
- name: Grade
working-directory: ${{ github.workspace }}
run: |
python ./.autograde/grade.py --junit junit.xml --out grade.json --summary summary.md
- name: Prepare artifacts
working-directory: ${{ github.workspace }}
run: |
mkdir -p artifacts
cp junit.xml summary.md grade.json artifacts/ 2>/dev/null || true
- name: Create grade metadata
working-directory: ${{ github.workspace }}
env:
REPO: ${{ github.repository }}
run: |
if [ ! -f grade.json ]; then
echo "⚠️ grade.json not found, skipping metadata creation"
exit 0
fi
# 生成 JSON 元数据
if [ -f ./.autograde/create_grade_metadata.py ]; then
python3 ./.autograde/create_grade_metadata.py > metadata.json || echo "{}" > metadata.json
echo "✅ Grade metadata created (using create_grade_metadata.py)"
elif [ -f ./.autograde/create_minimal_metadata.py ]; then
export GRADE_TYPE=programming
python3 ./.autograde/create_minimal_metadata.py > metadata.json || echo "{}" > metadata.json
echo "✅ Grade metadata created (using create_minimal_metadata.py)"
else
echo "⚠️ No metadata creation script found, skipping"
echo "{}" > metadata.json
fi
- name: Upload metadata (teacher only)
if: env.RUNNER_METADATA_REPO != '' && env.RUNNER_METADATA_TOKEN != ''
working-directory: ${{ github.workspace }}
shell: bash
env:
METADATA_REPO: ${{ env.RUNNER_METADATA_REPO }}
METADATA_TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
METADATA_BRANCH: ${{ env.RUNNER_METADATA_BRANCH }}
STUDENT_REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
COMMIT_SHA: ${{ github.sha }}
SERVER_URL: ${{ github.server_url }}
run: |
set -e
if [ ! -f metadata.json ]; then
echo "No metadata.json found, skip uploading."
exit 0
fi
python ./.autograde/upload_metadata.py \
--metadata-file metadata.json \
--metadata-repo "${METADATA_REPO}" \
--branch "${METADATA_BRANCH:-main}" \
--student-repo "${STUDENT_REPO}" \
--run-id "${RUN_ID}" \
--commit-sha "${COMMIT_SHA}" \
--workflow grade \
--server-url "${SERVER_URL}" \
--external-host "${EXTERNAL_GITEA_HOST}"
rm -f metadata.json

View File

@ -1,200 +0,0 @@
name: autograde-r
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
r:
runs-on: docker
container: r-base:4.3.1
timeout-minutes: 15
steps:
- name: Install dependencies (CN mirror)
run: |
set -e
# 替换 Debian/Ubuntu 源为腾讯云镜像
for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
[ -f "$f" ] || continue
sed -i -E 's|https?://deb.debian.org|http://mirrors.cloud.tencent.com|g' "$f" || true
sed -i -E 's|https?://security.debian.org|http://mirrors.cloud.tencent.com/debian-security|g' "$f" || true
sed -i -E 's|https?://archive.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
sed -i -E 's|https?://ports.ubuntu.com|http://mirrors.cloud.tencent.com|g' "$f" || true
done
apt-get -o Acquire::Check-Valid-Until=false -o Acquire::AllowInsecureRepositories=true update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends git ca-certificates python3 python3-pip nodejs rsync libcurl4-openssl-dev libssl-dev libxml2-dev
pip3 install --break-system-packages python-dotenv requests -i https://mirrors.cloud.tencent.com/pypi/simple
rm -rf /var/lib/apt/lists/*
- name: Configure CRAN Mirror
run: |
echo 'options(repos = c(CRAN = "https://mirrors.tuna.tsinghua.edu.cn/CRAN/"))' >> ~/.Rprofile
- name: Install R packages
run: |
Rscript -e 'install.packages(c("testthat", "covr", "xml2"))'
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Fix permissions
run: |
# Ensure workspace is owned by current user
chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
- name: Fetch private tests
working-directory: ${{ github.workspace }}
env:
EXTERNAL_GITEA_HOST: ${{ secrets.EXTERNAL_GITEA_HOST }}
run: |
set -e
TESTS_USERNAME="${RUNNER_TESTS_USERNAME:-}"
TESTS_TOKEN="${RUNNER_TESTS_TOKEN:-}"
if [ -z "$TESTS_TOKEN" ] || [ -z "$TESTS_USERNAME" ]; then
echo "❌ RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN 未配置!"
echo "测试必须从私有的 tests 仓库获取"
exit 1
fi
# Resolve Gitea Host
if [ -n "$EXTERNAL_GITEA_HOST" ]; then
HOST="$EXTERNAL_GITEA_HOST"
elif [ -n "$GITEA_ROOT_URL" ]; then
HOST=$(echo "$GITEA_ROOT_URL" | sed 's|https\?://||' | sed 's|/$||')
else
HOST=$(echo "${{ github.server_url }}" | sed 's|https\?://||' | cut -d'/' -f1)
fi
echo "📥 Fetching private tests repository..."
echo " Gitea host: $HOST"
# Infer organization and assignment ID from repository name
ORG=$(echo "${{ github.repository }}" | cut -d'/' -f1)
REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2)
# Extract assignment ID from repo name (e.g., hw1-stu_xxx -> hw1, hw1-template -> hw1)
if echo "$REPO_NAME" | grep -q -- '-stu_'; then
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-stu_.*//')
elif echo "$REPO_NAME" | grep -q -- '-template'; then
ASSIGNMENT_ID=$(echo "$REPO_NAME" | sed 's/-template.*//')
else
ASSIGNMENT_ID="hw1" # fallback
fi
echo " Organization: $ORG"
echo " Assignment ID: $ASSIGNMENT_ID"
AUTH_URL="http://${TESTS_USERNAME}:${TESTS_TOKEN}@${HOST}/${ORG}/${ASSIGNMENT_ID}-tests.git"
if ! git -c http.sslVerify=false clone --depth=1 "$AUTH_URL" _priv_tests 2>&1; then
echo "❌ Failed to clone ${ASSIGNMENT_ID}-tests repository!"
exit 1
fi
# 验证测试目录存在
if [ ! -d "_priv_tests/r" ]; then
echo "❌ r/ directory not found in ${ASSIGNMENT_ID}-tests!"
exit 1
fi
if [ ! -d "_priv_tests/r/tests" ]; then
echo "❌ r/tests/ not found in ${ASSIGNMENT_ID}-tests!"
exit 1
fi
# 复制测试到 tests/
mkdir -p tests
rsync -a _priv_tests/r/tests/ tests/
echo "✅ Tests copied: _priv_tests/r/tests/ → tests/"
# 验证测试文件
if [ -z "$(find tests -name 'test_*.R' 2>/dev/null)" ]; then
echo "❌ No test files found in tests/ directory!"
exit 1
fi
echo "✅ Test suite ready:"
find tests -name 'test_*.R'
- name: Run tests
working-directory: ${{ github.workspace }}
run: |
Rscript -e 'library(testthat); test_dir("tests", reporter = JunitReporter$new(file = "junit.xml"))' || true
- name: Grade
working-directory: ${{ github.workspace }}
env:
LANGUAGE: r
run: |
python3 ./.autograde/grade.py --junit junit.xml --out grade.json --summary summary.md
- name: Prepare artifacts
working-directory: ${{ github.workspace }}
run: |
mkdir -p artifacts
cp junit.xml summary.md grade.json artifacts/ 2>/dev/null || true
- name: Create grade metadata
working-directory: ${{ github.workspace }}
env:
LANGUAGE: r
REPO: ${{ github.repository }}
run: |
if [ ! -f grade.json ]; then
echo "⚠️ grade.json not found, skipping metadata creation"
exit 0
fi
# 生成 JSON 元数据
if [ -f ./.autograde/create_grade_metadata.py ]; then
python3 ./.autograde/create_grade_metadata.py > metadata.json || echo "{}" > metadata.json
echo "✅ Grade metadata created (using create_grade_metadata.py)"
elif [ -f ./.autograde/create_minimal_metadata.py ]; then
export GRADE_TYPE=programming
python3 ./.autograde/create_minimal_metadata.py > metadata.json || echo "{}" > metadata.json
echo "✅ Grade metadata created (using create_minimal_metadata.py)"
else
echo "⚠️ No metadata creation script found, skipping"
echo "{}" > metadata.json
fi
- name: Upload metadata (teacher only)
if: env.RUNNER_METADATA_REPO != '' && env.RUNNER_METADATA_TOKEN != ''
working-directory: ${{ github.workspace }}
shell: bash
env:
METADATA_REPO: ${{ env.RUNNER_METADATA_REPO }}
METADATA_TOKEN: ${{ env.RUNNER_METADATA_TOKEN }}
METADATA_BRANCH: ${{ env.RUNNER_METADATA_BRANCH }}
STUDENT_REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
COMMIT_SHA: ${{ github.sha }}
SERVER_URL: ${{ github.server_url }}
run: |
set -e
if [ ! -f metadata.json ]; then
echo "No metadata.json found, skip uploading."
exit 0
fi
python3 ./.autograde/upload_metadata.py \
--metadata-file metadata.json \
--metadata-repo "${METADATA_REPO}" \
--branch "${METADATA_BRANCH:-main}" \
--student-repo "${STUDENT_REPO}" \
--run-id "${RUN_ID}" \
--commit-sha "${COMMIT_SHA}" \
--workflow grade \
--server-url "${SERVER_URL}" \
--external-host "${EXTERNAL_GITEA_HOST}"
rm -f metadata.json

View File

@ -4,6 +4,9 @@ on:
push:
branches:
- main
tags:
- 'submit' # 仍然允许标签触发
- 'submit-*'
workflow_dispatch:
permissions:
@ -11,7 +14,30 @@ permissions:
pull-requests: write
jobs:
# 检查是否应该触发 CI仅在 commit message 包含 “完成作业” 时执行)
check-trigger:
runs-on: docker
container:
image: alpine:latest
outputs:
should_run: ${{ steps.check.outputs.trigger }}
steps:
- name: Check commit message for trigger keyword
id: check
run: |
COMMIT_MSG="${{ github.event.head_commit.message || '' }}"
echo "Commit message: $COMMIT_MSG"
if echo "$COMMIT_MSG" | grep -q "完成作业"; then
echo "trigger=true" >> $GITHUB_OUTPUT
echo "✅ Commit contains \"完成作业\",即将执行评分"
else
echo "trigger=false" >> $GITHUB_OUTPUT
echo "⛔ 只有包含“完成作业”的提交才会执行自动评分" >&2
fi
grade:
needs: check-trigger
if: needs.check-trigger.outputs.should_run == 'true'
runs-on: docker
container:
image: gradle:9.0-jdk21
@ -72,7 +98,7 @@ jobs:
- name: Fix permissions
run: chown -R $(whoami):$(whoami) ${{ github.workspace }} || true
- name: Fetch hidden tests
- name: Fetch hidden tests and grading scripts
working-directory: ${{ github.workspace }}
env:
EXTERNAL_GITEA_HOST: ${{ secrets.EXTERNAL_GITEA_HOST }}
@ -83,8 +109,9 @@ jobs:
TESTS_TOKEN="${RUNNER_TESTS_TOKEN:-}"
if [ -z "$TESTS_TOKEN" ] || [ -z "$TESTS_USERNAME" ]; then
echo "Warning: RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN not set, skipping private tests"
exit 0
echo "❌ RUNNER_TESTS_USERNAME / RUNNER_TESTS_TOKEN not set!"
echo "Cannot fetch grading scripts - aborting."
exit 1
fi
# Resolve Gitea Host
@ -108,7 +135,7 @@ jobs:
ASSIGNMENT_ID="final-vibevault"
fi
echo "📥 Fetching private tests from ${ORG}/${ASSIGNMENT_ID}-tests..."
echo "📥 Fetching tests and grading scripts from ${ORG}/${ASSIGNMENT_ID}-tests..."
AUTH_URL="http://${TESTS_USERNAME}:${TESTS_TOKEN}@${HOST}/${ORG}/${ASSIGNMENT_ID}-tests.git"
@ -117,6 +144,19 @@ jobs:
exit 1
fi
# ===== Copy grading scripts (from tests repo, cannot be modified by students) =====
if [ -d "_priv_tests/autograde" ]; then
# Remove any local .autograde (prevent student tampering)
rm -rf .autograde
mkdir -p .autograde
cp _priv_tests/autograde/*.py .autograde/
cp _priv_tests/autograde/*.sh .autograde/ 2>/dev/null || true
echo "✅ Grading scripts copied from tests repo"
else
echo "❌ No autograde directory in tests repo!"
exit 1
fi
# Copy Java tests
if [ -d "_priv_tests/java/src/test" ]; then
rsync -a _priv_tests/java/src/test/ src/test/
@ -135,6 +175,9 @@ jobs:
cp _priv_tests/llm/*.json .llm_rubrics/ 2>/dev/null || true
echo "✅ LLM rubrics copied"
fi
# Cleanup
rm -rf _priv_tests
- name: Run tests
working-directory: ${{ github.workspace }}

View File

@ -146,27 +146,25 @@
> 📝 **写作指导**:请参考 `REPORT_GUIDE.md``FRONTEND_GUIDE.md` 了解详细的写作要求和格式说明。
### REPORT.md10 分)
### REPORT.md - 后端开发反思报告10 分)
`REPORT.md` 文件中撰写后端与系统设计报告,建议 15003000 字,包括
`REPORT.md` 文件中撰写后端开发反思报告,建议 **8001500 字**,围绕以下三个问题展开
- 系统架构与数据建模
- 业务逻辑与事务
- 安全与访问控制
- 测试策略与质量保障
- AI 协同开发反思
- 总结与自我评估
1. **问题解决4 分)**:你遇到的最大挑战是什么?你是如何解决的?
2. **反思深度3 分)**:如果重新做一遍,你会有什么不同的设计决策?
3. **AI 使用3 分)**:你如何使用 AI 辅助开发?有什么经验教训?
### FRONTEND.md10 分)
> ⚠️ 我们想听到的是**你的思考**,而非代码的复述。
`FRONTEND.md` 文件中撰写前端界面与交互设计报告,建议 8001500 字,包括:
### FRONTEND.md - 前端开发反思报告10 分)
- 前端技术栈与使用流程
- 关键界面截图与说明36 张)
- 组件划分与状态管理
- 与后端的对接方式
- 交互细节与用户体验
- 自我评价与改进思路
`FRONTEND.md` 文件中撰写前端开发反思报告,建议 **6001200 字**,围绕以下三个问题展开:
1. **界面展示5 分)**:提供 36 张截图展示你的界面,每张配简要说明
2. **问题解决3 分)**:你在前端开发中遇到的最大挑战是什么?
3. **反思改进2 分)**:如果重新做一遍,你会如何改进?
> 📁 **截图位置**:将截图保存到仓库根目录下的 `images/` 文件夹
---
@ -191,7 +189,12 @@
3. 运行 `./gradlew test` 确保公开测试通过
4. 完成 `REPORT.md``FRONTEND.md`
5. `git add / commit / push``main` 分支
6. 在 Gitea Actions 页面查看评分结果
6. **触发自动评分**(二选一):
- 在 commit message 中包含 **"完成作业"** 字样
- 或推送 `submit` 开头的标签:`git tag submit && git push origin submit`
7. 在 Gitea Actions 页面查看评分结果
> 💡 **提示**:普通提交不会触发自动评分,这样你可以在开发过程中自由提交而不用担心消耗评分次数。
---

0
gradlew vendored Executable file → Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.