diff --git a/tutor/history_quiz.py b/tutor/history_quiz.py new file mode 100644 index 0000000..070d3a6 --- /dev/null +++ b/tutor/history_quiz.py @@ -0,0 +1,184 @@ +""" +历史知识问答引擎 — 七年级中国历史背诵题库 +从 data/历史背诵题库.json 加载题目,支持随机出题、答题判断、进度追踪 +""" + +import json +import random +import os +from pathlib import Path +from datetime import datetime + +QUIZ_FILE = Path(os.environ.get( + "HISTORY_QUIZ_FILE", + os.path.join(os.path.dirname(__file__), "..", "data", "历史背诵题库.json") +)) + +# 单元别名映射(用于前端显示) +UNIT_ALIASES = { + "U1": "七上·第一单元 史前时期", + "U2": "七上·第二单元 夏商周时期", + "U3": "七上·第三单元 秦汉时期", + "U4": "七上·第四单元 三国两晋南北朝", + "U5": "七下·第一单元 隋唐时期", + "U6": "七下·第二单元 辽宋夏金元", + "U7": "七下·第三单元 明清时期", +} + + +class HistoryQuiz: + """历史题库引擎""" + + def __init__(self): + self.data = self._load_quiz() + self.units = {u["unit_id"]: u for u in self.data.get("units", [])} + self.all_questions = [] + for unit in self.data.get("units", []): + for q in unit.get("questions", []): + q["unit_id"] = unit["unit_id"] + q["unit_title"] = unit.get("title", "") + self.all_questions.append(q) + + def _load_quiz(self) -> dict: + if QUIZ_FILE.exists(): + return json.loads(QUIZ_FILE.read_text(encoding="utf-8")) + # 降级:空题库 + return {"metadata": {}, "units": []} + + def get_units(self) -> list: + """获取所有单元信息(含题目数量)""" + result = [] + for unit in self.data.get("units", []): + uid = unit["unit_id"] + result.append({ + "unit_id": uid, + "title": unit.get("title", ""), + "grade": unit.get("grade", ""), + "alias": UNIT_ALIASES.get(uid, uid), + "question_count": len(unit.get("questions", [])), + "lessons": unit.get("lessons", []), + }) + return result + + def get_random_question(self, unit_id: str = None, question_type: str = None) -> dict: + """随机获取一道题,可选按单元/题型过滤""" + pool = self.all_questions + if unit_id: + pool = [q for q in pool if q.get("unit_id") == unit_id] + if question_type: + pool = [q for q in pool if q.get("type") == question_type] + if not pool: + return {"error": "该条件下没有题目"} + q = random.choice(pool) + return { + "id": q["id"], + "type": q["type"], + "question": q["question"], + "answer": q["answer"], + "unit_id": q["unit_id"], + "unit_title": q.get("unit_title", ""), + "unit_alias": UNIT_ALIASES.get(q["unit_id"], q["unit_id"]), + "source": q.get("source", ""), + } + + def get_batch(self, count: int = 10, unit_id: str = None, question_type: str = None) -> list: + """批量获取题目(不重复)""" + pool = list(self.all_questions) + if unit_id: + pool = [q for q in pool if q.get("unit_id") == unit_id] + if question_type: + pool = [q for q in pool if q.get("type") == question_type] + random.shuffle(pool) + selected = pool[:count] + return [{ + "id": q["id"], + "type": q["type"], + "question": q["question"], + "answer": q["answer"], + "unit_id": q["unit_id"], + "unit_title": q.get("unit_title", ""), + "unit_alias": UNIT_ALIASES.get(q["unit_id"], q["unit_id"]), + } for q in selected] + + def check_answer(self, question_id: str, student_answer: str) -> dict: + """检查答案 — 模糊匹配(关键词匹配)""" + q = next((q for q in self.all_questions if q["id"] == question_id), None) + if not q: + return {"correct": False, "error": "题目不存在"} + + correct = q["answer"].strip() + student = student_answer.strip() + + # 精确匹配 + if student == correct: + return { + "correct": True, + "question_id": question_id, + "student_answer": student, + "correct_answer": correct, + "match_type": "exact" + } + + # 包含匹配(学生答案包含在标准答案中,或反之) + if len(student) >= 2 and (student in correct or correct in student): + return { + "correct": True, + "question_id": question_id, + "student_answer": student, + "correct_answer": correct, + "match_type": "partial" + } + + # 关键词匹配(答案拆分为关键词,匹配度>60%算对) + keywords = set(correct.replace(",", " ").replace("、", " ").replace(";", " ").split()) + student_words = set(student.replace(",", " ").replace("、", " ").replace(";", " ").split()) + if keywords and student_words: + overlap = len(keywords & student_words) / len(keywords) + if overlap >= 0.6: + return { + "correct": True, + "question_id": question_id, + "student_answer": student, + "correct_answer": correct, + "match_type": "keyword", + "keyword_match": f"{overlap:.0%}" + } + + return { + "correct": False, + "question_id": question_id, + "student_answer": student, + "correct_answer": correct, + "hint": self._generate_hint(correct) + } + + def _generate_hint(self, answer: str) -> str: + """生成提示(显示答案首字+长度)""" + if len(answer) <= 1: + return "答案很短,再想想?" + first_char = answer[0] + return f"提示:答案以「{first_char}」开头,共{len(answer)}个字" + + def get_stats(self) -> dict: + """题库统计""" + type_counts = {} + for q in self.all_questions: + t = q.get("type", "unknown") + type_counts[t] = type_counts.get(t, 0) + 1 + + return { + "total_questions": len(self.all_questions), + "total_units": len(self.units), + "by_type": type_counts, + "by_unit": {uid: len(u.get("questions", [])) for uid, u in self.units.items()}, + } + + +# 全局单例 +_quiz_instance = None + +def get_quiz() -> HistoryQuiz: + global _quiz_instance + if _quiz_instance is None: + _quiz_instance = HistoryQuiz() + return _quiz_instance