优化 #3

Merged
hnu202326010215 merged 3 commits from develop into main 5 months ago

@ -2,47 +2,64 @@
## 一、项目概览
本项目是一个命令行程序,旨在为小学、初中、高中不同年级的用户自动生成数学试卷。程序实现了用户登录、根据账户类型生成指定数量的题目、题目查重、以及按用户账户保存卷子等核心功能。所有账户信息都通过一个轻量级的 **SQLite 数据库**进行持久化管理,确保数据的稳定性和可扩展性。
本项目是一个命令行程序,旨在为小学、初中、高中不同年级的用户自动生成数学试卷。程序实现了用户登录、根据账户类型生成指定数量的题目、题目查重、以及按用户账户保存卷子等核心功能。所有账户信息和已生成题目都通过一个轻量级的 **SQLite 数据库**进行持久化管理,确保数据的稳定性和查重的高效性。
## 二、项目目录结构
---
X班_姓名_个人项目/├── src/│ ├── main.py # 程序主入口和命令行交互逻辑│ └── questions.py # 题目生成模块├── doc/│ └── README.md # 项目说明文档└── papers/ # 自动生成的试卷保存目录 (无需提交该目录)├── 张三1/│ └── 2025-09-25-16-30-01.txt└── ...
## 二、核心功能
## 三、运行环境与安装
* **用户认证与退出**: 程序通过命令行接收用户名和密码。除了标准的登录流程,用户还可以在登录界面输入 `-2` 随时退出程序。
* **智能题目生成**:
* **小学**: 题目只包含 `+ - * /``()`,并内置了鲁棒的校验逻辑,**确保答案为非负整数,且不会出现除零错误**。
* **初中/高中**: 题目操作数范围调整为 `1-5`。如果只有一个操作数,程序会直接对其进行开方、平方或三角函数运算,而不是生成带 `+ - * /` 的表达式。
* **高效题目查重**: 所有老师(用户)已生成的题目都会被记录到中央数据库中。每次生成新题目时,程序会查询该用户的历史题目,确保不会生成重复的题目。
* **命令行交互**: 用户可以通过输入 `-1` 退出当前账户,或使用“`切换为 XX`”(注意`切换为`后有一个空格)命令来切换题目类型,程序会进行严格的格式校验。
* **文件保存**: 自动生成的试卷会保存在 `papers/<用户名>/` 目录下,并以 `年-月-日-时-分-秒.txt` 的格式命名。
---
## 三、项目目录结构
个人项目/
├── src/
│ ├── main.py # 程序主入口和命令行交互逻辑
│ └── questions.py # 题目生成模块
├── doc/
│ └── README.md # 项目说明文档
└── papers/ # 自动生成的试卷保存目录 (无需提交该目录)
---
## 四、运行环境与安装
* **运行环境**: Python 3.8 或更高版本。
* **依赖**: 本项目仅使用 Python 标准库,无需安装任何额外依赖。
* **启动**: 在终端中,**进入项目的根目录**(即 `src` 目录的上层),执行以下命令来启动程序:`python -m src.main`
## 四、预设账户
* **启动**: 在终端中,进入 `src` 目录的上层,执行以下命令即可启动程序:
`python3 src/main.py``python src/main.py`
程序启动时会自动初始化一个名为 `accounts.db` 的数据库文件,并导入以下预设账户。所有账户的密码均为 `123`
---
| 账户类型 | 用户名 | 备注 |
| --- | --- | --- |
| 小学 | 张三1, 张三2, 张三3 | |
| 初中 | 李四1, 李四2, 李四3 | |
| 高中 | 王五1, 王五2, 王五3 | |
## 五、使用说明(交互流程)
**登录示例**:在命令行提示符下,输入 `张三1 123` 并回车即可登录。
1. **启动程序**:运行上述命令后,程序将自动初始化数据库并提示登录。
2. **登录**:输入用户名和密码,例如:`张三1 123`,或输入 `-2` 退出。
3. **生成题目**:登录成功后,输入 `10-30` 之间的整数来指定题目数量。
4. **切换类型**:在登录状态下,输入 `切换为 初中``切换为 高中` 来更改题目类型。
5. **退出登录**:输入 `-1` 即可退出当前账户,返回登录界面。
## 五、程序交互与功能说明
---
1. **登录**: 程序启动后会提示输入用户名和密码。成功登录后,系统会显示当前选择的出题类型。
2. **题目数量**: 登录后,程序会提示输入生成题目的数量。有效范围为 **10-30** 题。
3. **题目生成**:
* **小学题**: 题目只包含加、减、乘、除和括号运算符。
* **初中题**: 题目中至少包含一个平方或一个开根号运算符。
* **高中题**: 题目中至少包含 `sin`, `cos`, 或 `tan` 三角函数运算符。
* **操作数**: 每道题目包含的操作数在 **1-5** 个之间,数值范围为 **1-100**
4. **查重**: 生成题目时,程序会自动检查并避免与该账户已生成的卷子中的题目重复。
5. **卷子保存**: 生成的卷子文件将自动保存至 `papers/<用户名>/` 目录下,文件名格式为 `年-月-日-时-分-秒.txt`。每道题目都有题号,且题目之间空一行,以确保格式清晰。
6. **命令操作**:
* 输入 `-1` : 退出当前用户,返回登录界面。
* 输入 `切换为 XX` : 在登录状态下切换出题类型,`XX` 选项为 `小学`、`初中` 或 `高中`
## 六、说明与优化
## 六、开发说明
本项目在开发过程中,为了提升程序的**鲁棒性和易用性**,对部分需求进行了优化:
* 本项目遵循 Google Python 风格指南。
* 主要代码逻辑已按功能拆分为两个模块:`main.py` 负责程序主入口和交互,`questions.py` 负责题目生成。
* 项目满足课程需求文档中的各项功能和规范。
* **查重机制**: 采用 **SQLite 数据库**进行集中式查重,相比于通过读取文件进行查重,此方法更为高效、可靠,且不受文件误删等影响。
* **题目生成**: 初中和高中题目新增了对单操作数表达式的生成,使其更符合实际教学场景。

@ -1,5 +1,3 @@
# src/main.py
"""
中小学数学卷子自动生成程序命令行版
功能要点
@ -10,13 +8,19 @@
- 生成题目时避免与该账号已有文件中的题目重复查重
"""
# 标准库导入
import os
import sqlite3
from datetime import datetime
from typing import Callable, List, Set
from typing import List, Set
import sys # 导入 sys 模块以支持程序退出
# 从 questions 模块导入题目生成函数
from .questions import generate_primary_question, generate_middle_question, generate_high_question
# 本地应用模块导入
from .questions import (
HighQuestionGenerator,
MiddleQuestionGenerator,
PrimaryQuestionGenerator,
)
# ------------------------------
# 数据库管理
@ -25,11 +29,20 @@ from .questions import generate_primary_question, generate_middle_question, gene
DB_NAME = "accounts.db"
VALID_LEVELS = ["小学", "初中", "高中"]
# 将难度级别与对应的生成器类进行映射
QUESTION_GENERATORS = {
"小学": PrimaryQuestionGenerator(),
"初中": MiddleQuestionGenerator(),
"高中": HighQuestionGenerator(),
}
def init_db():
"""初始化数据库和账户表,并导入预设数据。"""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
# 创建用户表
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
@ -37,6 +50,16 @@ def init_db():
level TEXT NOT NULL
)
""")
# 新增:创建题目表,用于查重
cursor.execute("""
CREATE TABLE IF NOT EXISTS questions (
question TEXT PRIMARY KEY,
username TEXT NOT NULL,
level TEXT NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
predefined_accounts = {
@ -49,14 +72,16 @@ def init_db():
for username, password in users.items():
cursor.execute("SELECT 1 FROM users WHERE username = ?", (username,))
if cursor.fetchone() is None:
cursor.execute("INSERT INTO users (username, password, level) VALUES (?, ?, ?)",
(username, password, level))
cursor.execute(
"INSERT INTO users (username, password, level) VALUES (?, ?, ?)",
(username, password, level)
)
conn.commit()
conn.close()
def authenticate_user(username, password):
def authenticate_user(username: str, password: str) -> (str, str):
"""
通过查询数据库验证用户名和密码
返回 (level, username) (None, None)
@ -64,16 +89,17 @@ def authenticate_user(username, password):
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("SELECT level FROM users WHERE username = ? AND password = ?",
(username, password))
cursor.execute(
"SELECT level FROM users WHERE username = ? AND password = ?",
(username, password)
)
result = cursor.fetchone()
conn.close()
if result:
return result[0], username
else:
return None, None
return None, None
# ------------------------------
# 文件与查重相关函数
@ -83,70 +109,67 @@ def _ensure_dir(path: str) -> None:
"""确保目录存在,不存在则创建。"""
os.makedirs(path, exist_ok=True)
def _read_existing_questions(folder: str) -> Set[str]:
def _read_existing_questions_from_db(username: str) -> Set[str]:
"""
读取指定文件夹下所有文本文件返回其中已存在的题目集合
从数据库中读取指定用户已存在的题目集合
"""
questions = set()
if not os.path.isdir(folder):
return questions
for fname in os.listdir(folder):
if not fname.lower().endswith(".txt"):
continue
fpath = os.path.join(folder, fname)
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
cursor.execute("SELECT question FROM questions WHERE username = ?", (username,))
existing_questions = {row[0] for row in cursor.fetchall()}
conn.close()
return existing_questions
def _save_new_questions_to_db(new_questions: List[str], username: str, level: str):
"""
将新生成的题目保存到数据库中
"""
conn = sqlite3.connect(DB_NAME)
cursor = conn.cursor()
for question in new_questions:
try:
with open(fpath, "r", encoding="utf-8") as f:
for line in f:
s = line.strip()
if not s:
continue
if s.split(".", 1)[0].isdigit() and s.count(".") >= 1:
parts = s.split(".", 1)
if len(parts) == 2:
content = parts[1].strip()
else:
content = s
else:
content = s
if content:
questions.add(content)
except Exception:
continue
return questions
cursor.execute(
"INSERT OR IGNORE INTO questions (question, username, level) VALUES (?, ?, ?)",
(question, username, level)
)
except sqlite3.Error as exc:
print(f"Error saving question to database: {exc}")
conn.rollback()
conn.commit()
conn.close()
# ------------------------------
# 生成卷子并保存
# ------------------------------
def generate_paper(level: str, count: int, folder: str) -> str:
def generate_paper(
level: str, count: int, folder: str, username: str
) -> str:
"""
根据 level 和题目数量 count 生成一个卷子并保存
"""
_ensure_dir(folder)
if level == "小学":
gen_func: Callable[[], str] = generate_primary_question
elif level == "初中":
gen_func = generate_middle_question
elif level == "高中":
gen_func = generate_high_question
else:
raise ValueError("level 必须是:小学、初中或高中")
# 使用多态性,动态获取生成器对象
generator = QUESTION_GENERATORS.get(level)
if not generator:
raise ValueError("无效的题目难度级别")
existing = _read_existing_questions(folder)
existing = _read_existing_questions_from_db(username)
new_questions: List[str] = []
attempt_limit = count * 20
attempts = 0
while len(new_questions) < count and attempts < attempt_limit:
attempts += 1
q = gen_func()
# 调用统一的 generate_question 方法
q = generator.generate_question()
q_norm = " ".join(q.split())
if q_norm not in existing and q_norm not in new_questions:
new_questions.append(q_norm)
if len(new_questions) < count:
print("提示:未能生成足够的不重复题目,请稍后再试或清理旧卷子")
print("提示:未能生成足够的不重复题目,请稍后再试或清理已有卷子后重试")
return ""
filename = datetime.now().strftime("%Y-%m-%d-%H-%M-%S") + ".txt"
@ -156,6 +179,9 @@ def generate_paper(level: str, count: int, folder: str) -> str:
with open(filepath, "w", encoding="utf-8") as f:
for idx, q in enumerate(new_questions, start=1):
f.write(f"{idx}. {q}\n\n")
_save_new_questions_to_db(new_questions, username, level)
except Exception as exc:
print("保存文件失败:", exc)
return ""
@ -173,7 +199,13 @@ def login_prompt() -> (str, str):
要求格式"用户名 密码"用空格分开
"""
while True:
raw = input("请输入用户名和密码(空格隔开):").strip()
raw = input("请输入用户名和密码(空格隔开 或 输入“-2”退出").strip()
# 新增的退出选项
if raw == "-2":
print("已退出。")
sys.exit(0)
parts = raw.split()
if len(parts) != 2:
print("输入格式错误,请输入:用户名 密码 (中间以空格隔开)")
@ -186,8 +218,7 @@ def login_prompt() -> (str, str):
if level:
print(f"当前选择为 {level} 出题")
return level, auth_username
else:
print("请输入正确的用户名、密码")
print("请输入正确的用户名、密码")
def main_loop() -> None:
"""主循环:登录->出题->可切换/退出登录"""
@ -196,11 +227,15 @@ def main_loop() -> None:
user_folder = os.path.join("papers", username)
while True:
prompt = f"准备生成 {level} 数学题目,请输入生成题目数量(输入 -1 退出当前用户,或输入'切换为 XX'切换类型):"
prompt = (
f"准备生成 {level} 数学题目,请输入生成题目数量"
"(输入 -1 退出当前用户,或输入'切换为 XX'切换类型):"
)
inp = input(prompt).strip()
if inp.startswith("切换为"):
new_level = inp.replace("切换为", "").strip()
# 严格处理切换命令,只允许“切换为 高中”这种格式
if inp.startswith("切换为 ") and len(inp.split()) == 2:
new_level = inp.split()[1]
if new_level in VALID_LEVELS:
level = new_level
print(f"已切换,准备生成 {level} 数学题目")
@ -222,7 +257,7 @@ def main_loop() -> None:
print("题目数量必须在 10-30 之间(包含 10 和 30")
continue
res = generate_paper(level, count, user_folder)
res = generate_paper(level, count, user_folder, username)
if not res:
print("生成失败,请尝试更小的题目数量或清理已有卷子后重试。")
else:
@ -236,5 +271,5 @@ def main() -> None:
except KeyboardInterrupt:
print("\n程序已被用户中断,退出。")
if __name__ == "__main__":
main()
main()

@ -1,94 +1,202 @@
import abc
import random
import math
from typing import List
# ------------------------------
# 工具:随机表达式生成(控制简单易懂)
# 抽象基类和辅助函数
# ------------------------------
def _rand_numbers(n: int, lo: int = 1, hi: int = 100) -> List[int]:
"""生成 n 个随机整数(闭区间),作为操作数使用。"""
return [random.randint(lo, hi) for _ in range(n)]
def _join_with_ops(numbers: List[int], ops: List[str]) -> str:
"""把数字列表用随机运算符连接成表达式字符串,可能加入括号。"""
expr = str(numbers[0])
for num in numbers[1:]:
op = random.choice(ops)
expr += f" {op} {num}"
# 20% 概率在表达式外层加一对括号
if random.random() < 0.2:
expr = "(" + expr + ")"
return expr
def generate_primary_question() -> str:
class QuestionGenerator(abc.ABC):
"""
生成小学题仅包含 + - * / 和可能的括号
操作数个数在 1-5 个之间且操作数取值 1-100
题目生成器抽象基类
定义了生成题目的统一接口确保所有子类都遵循相同的契约
"""
count = random.randint(2, 5)
nums = _rand_numbers(count)
ops = ["+", "-", "*", "/"]
return _join_with_ops(nums, ops)
def generate_middle_question() -> str:
"""
生成初中题题目中至少包含一个平方或一个开根号运算符
"""
ops = ["+", "-", "*", "/"]
special_ops = ["^2", ""]
@abc.abstractmethod
def generate_question(self) -> str:
"""
抽象方法生成一道数学题目
所有继承此类的子类都必须实现此方法
"""
raise NotImplementedError
@staticmethod
def _rand_numbers(n: int, lo: int = 1, hi: int = 100) -> List[int]:
"""生成 n 个随机整数(闭区间),作为操作数使用。"""
return [random.randint(lo, hi) for _ in range(n)]
@staticmethod
def _join_with_ops(numbers: List[int], ops: List[str]) -> str:
"""把数字列表用随机运算符连接成表达式字符串,可能加入括号。"""
expr = str(numbers[0])
for num in numbers[1:]:
op = random.choice(ops)
# 不再在此处进行除零检查,改由外部逻辑进行整体校验
expr += f" {op} {num}"
# 20% 概率在表达式外层加一对括号
if random.random() < 0.2:
expr = "(" + expr + ")"
return expr
@staticmethod
def _evaluate_expression(expression: str) -> float:
"""
辅助函数计算数学表达式的值
注意使用 eval() 存在安全风险但在此项目场景中表达式由我们自己的代码生成
不包含用户输入因此是安全的
"""
return eval(expression)
num_count = random.randint(2, 5)
numbers = _rand_numbers(num_count)
parts = []
for i in range(num_count):
parts.append(str(numbers[i]))
if i < num_count - 1:
parts.append(random.choice(ops))
# ------------------------------
# 具体实现类
# ------------------------------
special_op_pos = random.randint(0, len(parts) // 2) * 2
class PrimaryQuestionGenerator(QuestionGenerator):
"""
生成小学题目
题目只包含 + - * / 和括号运算符
"""
part_to_modify = parts[special_op_pos]
special_op = random.choice(special_ops)
def generate_question(self) -> str:
"""
生成一道小学数学题目并确保答案不会为负数也不会出现除零错误
操作数个数在 1-5 个之间且操作数取值 1-100
"""
while True:
count = random.randint(2, 5)
nums = self._rand_numbers(count)
ops = ["+", "-", "*", "/"]
# 尝试生成题目
question_str = self._join_with_ops(nums, ops)
# 检查答案是否为负数或出现除零错误
try:
result = self._evaluate_expression(question_str)
# 同时检查非负数和是否为整数,避免 5/2 这种问题
if result >= 0 and result == int(result):
return question_str
except (SyntaxError, ZeroDivisionError):
# 捕获除零错误和不合法语法,然后继续循环
continue
class MiddleQuestionGenerator(QuestionGenerator):
"""
生成初中题目
题目中至少包含一个平方或一个开根号运算符
"""
if special_op == "":
if num_count > 1:
parts[special_op_pos] = f"√({parts[special_op_pos]})"
@staticmethod
def _add_special_op(parts: List[str]) -> List[str]:
"""为表达式列表添加一个平方或开根号运算。"""
special_ops = ["^2", ""]
# 针对单操作数和多操作数进行不同的处理
if len(parts) == 1:
part_to_modify = parts[0]
special_op = random.choice(special_ops)
if special_op == "":
# 确保开方数为完全平方数,避免无理数
perfect_squares = [x ** 2 for x in range(1, 11)]
parts[0] = f"√({random.choice(perfect_squares)})"
elif special_op == "^2":
parts[0] = f"{part_to_modify}^2"
else:
parts[special_op_pos] = f"{parts[special_op_pos]}"
elif special_op == "^2":
if num_count > 1:
parts[special_op_pos] = f"({parts[special_op_pos]})^2"
# 随机选择一个数字位置进行修改
special_op_pos = random.randint(0, len(parts) // 2) * 2
part_to_modify = parts[special_op_pos]
special_op = random.choice(special_ops)
if special_op == "":
# 确保开方数为完全平方数,避免无理数
perfect_squares = [x ** 2 for x in range(1, 11)]
parts[special_op_pos] = f"√({random.choice(perfect_squares)})"
elif special_op == "^2":
# 为平方运算添加括号,以确保运算优先级正确
parts[special_op_pos] = f"({part_to_modify})^2"
return parts
def generate_question(self) -> str:
"""
生成一道初中数学题目操作数范围为 (1, 5)
"""
ops = ["+", "-", "*", "/"]
num_count = random.randint(1, 5) # 调整操作数范围
# 针对单操作数和多操作数进行不同的生成
if num_count == 1:
numbers = self._rand_numbers(num_count, lo=1, hi=5)
parts = [str(numbers[0])]
else:
parts[special_op_pos] = f"{parts[special_op_pos]}^2"
numbers = self._rand_numbers(num_count, lo=1, hi=5)
parts = [str(numbers[0])]
for num in numbers[1:]:
parts.append(random.choice(ops))
parts.append(str(num))
return "".join(parts)
# 调用辅助方法,添加特殊运算符
modified_parts = self._add_special_op(parts)
return "".join(modified_parts)
def generate_high_question() -> str:
class HighQuestionGenerator(QuestionGenerator):
"""
生成高中题题目中至少包含 sin/cos/tan
生成高中题目
题目中至少包含 sin/cos/tan
"""
ops = ["+", "-", "*", "/"]
trig_ops = ["sin", "cos", "tan"]
num_count = random.randint(2, 5)
numbers = _rand_numbers(num_count)
parts = []
for i in range(num_count):
parts.append(str(numbers[i]))
if i < num_count - 1:
parts.append(random.choice(ops))
@staticmethod
def _add_trig_op(parts: List[str]) -> List[str]:
"""为表达式列表添加一个三角函数运算。"""
trig_ops = ["sin", "cos", "tan"]
trig_op_pos = random.randint(0, len(parts) // 2) * 2
part_to_modify = parts[trig_op_pos]
trig_op = random.choice(trig_ops)
# 针对单操作数和多操作数进行不同的处理
if len(parts) == 1:
part_to_modify = parts[0]
trig_op = random.choice(trig_ops)
parts[0] = f"{trig_op}({part_to_modify})"
else:
# 随机选择一个数字位置进行修改
trig_op_pos = random.randint(0, len(parts) // 2) * 2
part_to_modify = parts[trig_op_pos]
trig_op = random.choice(trig_ops)
# 将操作数封装在三角函数中
parts[trig_op_pos] = f"{trig_op}({part_to_modify})"
return parts
def generate_question(self) -> str:
"""
生成一道高中数学题目操作数范围为 (1, 5)
"""
ops = ["+", "-", "*", "/"]
num_count = random.randint(1, 5) # 调整操作数范围
# 针对单操作数和多操作数进行不同的生成
if num_count == 1:
numbers = self._rand_numbers(num_count, lo=1, hi=5)
parts = [str(numbers[0])]
else:
numbers = self._rand_numbers(num_count, lo=1, hi=5)
parts = [str(numbers[0])]
for num in numbers[1:]:
parts.append(random.choice(ops))
parts.append(str(num))
parts[trig_op_pos] = f"{trig_op}({part_to_modify})"
# 调用辅助方法,添加三角函数
modified_parts = self._add_trig_op(parts)
if random.random() < 0.5:
return "(" + "".join(parts) + ")"
# 50% 概率在最外层加括号
question_str = "".join(modified_parts)
if random.random() < 0.5:
return f"({question_str})"
return "".join(parts)
return question_str
Loading…
Cancel
Save