445 lines
13 KiB
Python
445 lines
13 KiB
Python
#!/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())
|
||
|