|
|
|
|
@ -0,0 +1,276 @@
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
"""数学学习软件 - 题目生成与试卷管理模块
|
|
|
|
|
|
|
|
|
|
提供小学、初中、高中三个学段的数学选择题自动生成、查重、保存功能。
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import math
|
|
|
|
|
import os
|
|
|
|
|
import random
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import List, Tuple
|
|
|
|
|
|
|
|
|
|
# 模块级常量
|
|
|
|
|
PRIMARY_OPS = ("+", "-", "*", "/")
|
|
|
|
|
MIDDLE_OPS = ("+", "-", "*", "/", "^2", "sqrt")
|
|
|
|
|
HIGH_OPS = ("+", "-", "*", "/", "sin", "cos", "tan")
|
|
|
|
|
PRIMARY_RANGE = (1, 50)
|
|
|
|
|
MIDDLE_RANGE = (1, 100)
|
|
|
|
|
HIGH_ANGLE_RANGE = (0, 90) # 角度制
|
|
|
|
|
MAX_OPTS = 4
|
|
|
|
|
MAX_TRIES = 100
|
|
|
|
|
PAPERS_DIR = "generated_papers"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Question:
|
|
|
|
|
"""单道选择题."""
|
|
|
|
|
|
|
|
|
|
__slots__ = ("content", "options", "answer")
|
|
|
|
|
|
|
|
|
|
def __init__(self, content: str, options: List[str], answer: str):
|
|
|
|
|
"""初始化.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
content: 题干(含表达式)。
|
|
|
|
|
options: 四个选项文本。
|
|
|
|
|
answer: 正确答案文本。
|
|
|
|
|
"""
|
|
|
|
|
self.content = content
|
|
|
|
|
self.options = options
|
|
|
|
|
self.answer = answer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QuestionBank:
|
|
|
|
|
"""题库:按学段生成不重复的选择题,并保存为文本试卷."""
|
|
|
|
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
"""初始化."""
|
|
|
|
|
self.operators = {
|
|
|
|
|
"primary": PRIMARY_OPS,
|
|
|
|
|
"middle": MIDDLE_OPS,
|
|
|
|
|
"high": HIGH_OPS,
|
|
|
|
|
}
|
|
|
|
|
self.generated_questions: List[Question] = []
|
|
|
|
|
self.papers_dir = PAPERS_DIR
|
|
|
|
|
os.makedirs(self.papers_dir, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# 公共接口
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def generate_question(self, level: str) -> Question:
|
|
|
|
|
for attempt in range(MAX_TRIES):
|
|
|
|
|
try:
|
|
|
|
|
expr, value = self._make_expression(level)
|
|
|
|
|
if not self._is_valid_value(value) or expr in [q.content for q in self.generated_questions]:
|
|
|
|
|
continue
|
|
|
|
|
opts, ans = self._make_options(value, level)
|
|
|
|
|
return Question(f"计算:{expr}", opts, ans)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"[WARNING] 题目生成失败,尝试 {attempt + 1}/{MAX_TRIES},错误:{e}")
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# 兜底题目
|
|
|
|
|
print("[WARNING] 使用兜底题目")
|
|
|
|
|
expr, value = self._fallback_expression(level)
|
|
|
|
|
opts, ans = self._make_options(value, level)
|
|
|
|
|
return Question(f"计算:{expr}", opts, ans)
|
|
|
|
|
|
|
|
|
|
def generate_paper(self, level: str, count: int, username: str = "unknown") -> List[Question]:
|
|
|
|
|
"""生成整张试卷并落盘.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
level: 学段
|
|
|
|
|
count: 题目数量
|
|
|
|
|
username: 用户昵称
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
题目列表
|
|
|
|
|
"""
|
|
|
|
|
self.generated_questions.clear()
|
|
|
|
|
questions = [self.generate_question(level) for _ in range(count)]
|
|
|
|
|
self._save_paper(questions, level, username)
|
|
|
|
|
return questions
|
|
|
|
|
|
|
|
|
|
def calculate_score(self, answers: List[bool]) -> int:
|
|
|
|
|
"""计算百分制得分.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
answers: 每题是否正确
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
0~100
|
|
|
|
|
"""
|
|
|
|
|
if not answers:
|
|
|
|
|
return 0
|
|
|
|
|
return round(sum(answers) / len(answers) * 100)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# 私有辅助
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _make_expression(self, level: str) -> Tuple[str, float]:
|
|
|
|
|
"""返回表达式字符串与数值结果."""
|
|
|
|
|
if level == "primary":
|
|
|
|
|
return self._primary_expr()
|
|
|
|
|
if level == "middle":
|
|
|
|
|
return self._middle_expr()
|
|
|
|
|
return self._high_expr()
|
|
|
|
|
|
|
|
|
|
def _primary_expr(self) -> Tuple[str, float]:
|
|
|
|
|
"""小学表达式:整数结果."""
|
|
|
|
|
nums = [random.randint(*PRIMARY_RANGE) for _ in range(random.randint(2, 3))]
|
|
|
|
|
ops = list(random.choices(PRIMARY_OPS, k=len(nums) - 1))
|
|
|
|
|
self._ensure_int_div_sub(nums, ops)
|
|
|
|
|
|
|
|
|
|
parts = [str(nums[0])]
|
|
|
|
|
for o, n in zip(ops, nums[1:]):
|
|
|
|
|
parts.extend([o, str(n)])
|
|
|
|
|
expr = self._add_parentheses(parts)
|
|
|
|
|
val = self._safe_int_eval(expr)
|
|
|
|
|
if val is not None:
|
|
|
|
|
return expr, val
|
|
|
|
|
|
|
|
|
|
a, b = sorted(random.randint(*PRIMARY_RANGE) for _ in range(2))
|
|
|
|
|
op = random.choice(["+", "-", "*"])
|
|
|
|
|
expr = f"{a} {op} {b}"
|
|
|
|
|
return expr, eval(expr)
|
|
|
|
|
|
|
|
|
|
def _middle_expr(self) -> Tuple[str, float]:
|
|
|
|
|
"""初中表达式:含平方或开方."""
|
|
|
|
|
base = random.randint(*MIDDLE_RANGE)
|
|
|
|
|
if random.choice([True, False]):
|
|
|
|
|
inner = f"{base}^2"
|
|
|
|
|
val = base ** 2
|
|
|
|
|
else:
|
|
|
|
|
inner = f"sqrt({base})"
|
|
|
|
|
val = math.sqrt(base)
|
|
|
|
|
|
|
|
|
|
if random.choice([True, False]):
|
|
|
|
|
n2 = random.randint(*MIDDLE_RANGE)
|
|
|
|
|
op = random.choice(PRIMARY_OPS)
|
|
|
|
|
inner = f"({inner} {op} {n2})"
|
|
|
|
|
val = eval(f"{val} {op} {n2}")
|
|
|
|
|
|
|
|
|
|
return inner, val
|
|
|
|
|
|
|
|
|
|
def _high_expr(self) -> Tuple[str, float]:
|
|
|
|
|
"""高中表达式:含三角函数."""
|
|
|
|
|
angle = random.randint(*HIGH_ANGLE_RANGE)
|
|
|
|
|
func = random.choice(["sin", "cos", "tan"])
|
|
|
|
|
expr = f"{func}({angle})"
|
|
|
|
|
val = getattr(math, func)(math.radians(angle))
|
|
|
|
|
|
|
|
|
|
if random.choice([True, False]):
|
|
|
|
|
n2 = random.randint(1, 90)
|
|
|
|
|
op = random.choice(PRIMARY_OPS)
|
|
|
|
|
expr = f"({expr} {op} {n2})"
|
|
|
|
|
val = eval(f"{val} {op} {n2}")
|
|
|
|
|
|
|
|
|
|
return expr, val
|
|
|
|
|
|
|
|
|
|
def _fallback_expression(self, level: str) -> Tuple[str, float]:
|
|
|
|
|
"""兜底简单表达式."""
|
|
|
|
|
if level == "primary":
|
|
|
|
|
a, b = sorted(random.randint(*PRIMARY_RANGE) for _ in range(2))
|
|
|
|
|
expr = f"{a} + {b}"
|
|
|
|
|
return expr, eval(expr)
|
|
|
|
|
if level == "middle":
|
|
|
|
|
n = random.randint(1, 10)
|
|
|
|
|
expr = f"{n}^2"
|
|
|
|
|
return expr, n ** 2
|
|
|
|
|
angle = random.randint(1, 89)
|
|
|
|
|
expr = f"sin({angle})"
|
|
|
|
|
return expr, math.sin(math.radians(angle))
|
|
|
|
|
|
|
|
|
|
# -- 工具 -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _ensure_int_div_sub(nums: List[int], ops: List[str]) -> None:
|
|
|
|
|
"""调整 nums/ops 保证整数结果."""
|
|
|
|
|
for i, op in enumerate(ops):
|
|
|
|
|
if op == "/":
|
|
|
|
|
nums[i] *= nums[i + 1]
|
|
|
|
|
elif op == "-" and nums[i] < nums[i + 1]:
|
|
|
|
|
nums[i], nums[i + 1] = nums[i + 1], nums[i]
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _add_parentheses(parts: List[str]) -> str:
|
|
|
|
|
"""随机给表达式加括号."""
|
|
|
|
|
if len(parts) >= 5 and random.choice([True, False]):
|
|
|
|
|
start = random.randint(0, len(parts) - 4)
|
|
|
|
|
if start % 2 == 0:
|
|
|
|
|
parts.insert(start, "(")
|
|
|
|
|
parts.insert(start + 4, ")")
|
|
|
|
|
return " ".join(parts)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _safe_int_eval(expr: str) -> float | None:
|
|
|
|
|
"""安全计算并返回整数结果."""
|
|
|
|
|
try:
|
|
|
|
|
val = eval(expr)
|
|
|
|
|
if abs(val - round(val)) < 1e-4:
|
|
|
|
|
return round(val)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _is_valid_value(val: float) -> bool:
|
|
|
|
|
"""检查数值是否合法."""
|
|
|
|
|
return not (math.isnan(val) or math.isinf(val) or abs(val) > 1e10)
|
|
|
|
|
|
|
|
|
|
# -- 选项 & 保存 ------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _make_options(self, correct: float, level: str) -> Tuple[List[str], str]:
|
|
|
|
|
"""生成四个选项(1正确+3干扰)。"""
|
|
|
|
|
if level == "primary":
|
|
|
|
|
correct_val = int(round(correct))
|
|
|
|
|
else:
|
|
|
|
|
correct_val = round(correct, 2)
|
|
|
|
|
|
|
|
|
|
opts = [correct_val]
|
|
|
|
|
while len(opts) < 4:
|
|
|
|
|
distractor = self._make_distractor(opts, correct_val, level)
|
|
|
|
|
if distractor not in opts:
|
|
|
|
|
opts.append(distractor)
|
|
|
|
|
|
|
|
|
|
random.shuffle(opts)
|
|
|
|
|
|
|
|
|
|
# ✅ 确保答案是从 opts 中获取的,而不是原始浮点数
|
|
|
|
|
ans = str(opts[opts.index(correct_val)])
|
|
|
|
|
return [str(o) for o in opts], ans
|
|
|
|
|
|
|
|
|
|
def _make_distractor(self, existing: List[float], correct: float, level: str) -> float:
|
|
|
|
|
"""生成一个不重复的干扰项."""
|
|
|
|
|
while True:
|
|
|
|
|
if level == "primary":
|
|
|
|
|
d = int(correct) + random.randint(-5, 5)
|
|
|
|
|
if d != correct and d > 0:
|
|
|
|
|
return d
|
|
|
|
|
else:
|
|
|
|
|
delta = abs(correct) * 0.3 if correct else 1
|
|
|
|
|
d = round(correct + random.uniform(-delta, delta), 2)
|
|
|
|
|
if d not in existing:
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
def _save_paper(self, questions: List[Question], level: str, username: str) -> None:
|
|
|
|
|
"""试卷落盘."""
|
|
|
|
|
level_name = {"primary": "小学", "middle": "初中", "high": "高中"}.get(level, level)
|
|
|
|
|
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
|
|
|
|
path = os.path.join(self.papers_dir, f"{timestamp}.txt")
|
|
|
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
|
|
|
f.write(f"数学学习软件 - {level_name}数学试卷\n")
|
|
|
|
|
f.write(f"生成时间:{datetime.now()}\n")
|
|
|
|
|
f.write("=" * 50 + "\n\n")
|
|
|
|
|
for idx, q in enumerate(questions, 1):
|
|
|
|
|
f.write(f"第{idx}题:{q.content}\n选项:\n")
|
|
|
|
|
for i, opt in enumerate(q.options):
|
|
|
|
|
f.write(f" {chr(65+i)}. {opt}\n")
|
|
|
|
|
f.write(f"正确答案:{q.answer}\n\n")
|
|
|
|
|
f.write("=" * 50 + "\n")
|
|
|
|
|
f.write(f"共{len(questions)}题\n")
|
|
|
|
|
print(f"试卷已保存到:{path}")
|