ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

.doc题库结构化:正则状态机解析与SQLite组卷刷题

.doc题库结构化:正则状态机解析与SQLite组卷刷题 简介这份资料是2021—2022年间整理的江苏省高校教师岗前培训《高等教育政策与法规》配套题库面向参加江苏省高校教师资格岗前培训、需要一次性通过该门课程机考的高校新入职教师与辅导员。内容以章节为单位组织覆盖教育法治化的内涵、教师法治素养与践行能力、高等教育政策的历史沿革、高等教育组织政策、教师政策、教育教学政策、《中华人民共和国学位条例》与学术委员会规程、学生政策以及我国教育的方针原则与基本制度等模块每节均配有单选、多选题目及标准答案与分值便于对照记忆考点、检验复习效果。整包共1个doc文档约195KB可直接打印或导入平板刷题体量轻便但题量集中适合考前突击与逐章自查。目前已有169人学习下载可作为岗前培训复习阶段的题库补充材料使用。1. 从一份 .doc 题库到可检索题库为什么值得拆高校教师岗前培训的备考资料里《高等教育政策与法规》这一科常年是重灾区题量大、章节碎、答案散落在题干与选项之间。手里这份 2021-2022 年收藏的教育资料把 0-1 到 7-4 十几个小节的单选、多选连同题干、选项、标准答案甚至“您的得分30 分”这类练习回显全部塞进一个 .doc 文件里直接打印出来能背但想按章节抽题、按题型组卷、统计错题就完全不够用了。真正把它拆开以后会发现这份文件的排版规律其实相当整齐章节号以x-y打头题干尾部带(30 分)或(40 分)选项统一用A、全角顿号答案固定以标准答案引出。这种“半结构化”文本是最好处理的——不需要 OCR不需要语义模型一条正则加一个状态机就能切干净。适合两类人一类是想把纸质题库变成可刷题系统的考生一类是手上有一堆同类 .doc 教育资料、想批量入库做检索的开发者。2. .doc/.docx 题库的结构解析与无损提取2.1 先摸清题干、选项、答案的排版规律拿到文件第一件事不是写代码而是抽 200 行打印出来看排版。这份题库的题块结构可以归纳成四种行行类型特征示例形态章节头数字-数字加空格加标题2-4 某条例学位委员会题干行题号 . 题干 (分值 分)1.……是在()。(30 分)选项行A、到D、开头A、 选项内容答案行标准答案加字母序列标准答案B,C,D有两个坑必须提前知道。第一同一份文件里章节号会重复比如6-3出现了两次题干还完全不同靠章节号做唯一键一定翻车。第二部分题目的题号会丢失出现“裸题干”直接接选项的情况解析器要对这种缺号行有兜底。第三文件里混着您的得分30 分这类交互回显必须在清洗阶段整行丢弃。提示先把文件转成纯文本用grep -c 标准答案数一下答案行数量再用grep -c 分))数题干行数量。两个数字对不上说明有题块排版异常先定位再写解析。2.2 docx 与 doc 两条提取路线.doc是二进制复合文档格式python-docx 读不了。常见的三条路线# 路线 ALibreOffice 无头模式批量转 docx保段落结构最完整 soffice --headless --convert-to docx --outdir ./converted ./raw # 路线 Bantiword 直接抽纯文本快但丢样式 antiword -w 0 ./raw/bank.doc ./converted/bank.txt # 路线 Cpandoc 转 markdown方便肉眼核对 pandoc -f doc -t markdown ./raw/bank.doc -o ./converted/bank.md我一般走路线 A。理由是这份题库的题干、选项、答案各自独占一个段落docx 的paragraphs顺序正好等于阅读顺序转成纯文本后行序不会乱。用 antiword 也够用但遇到跨段落合并时会丢换行后面状态机会误判。转换完成后先做一次段落级别的抽样核对from docx import Document doc Document(converted/bank.docx) # 只打印前 60 段确认章节头、题干、选项、答案的相对顺序 for i, p in enumerate(doc.paragraphs[:60]): text p.text.strip() if text: print(i, repr(text))Document(...)加载的是解压后的 XMLparagraphs里包含空段落所以要做strip()判空。这一步不产出数据只确认一件事答案行是否紧跟在最后一个选项行之后。如果是切题就能用“遇新题干即收尾”的状态机如果答案被挪到了文末统一存放整个解析逻辑要重写。2.3 用正则把章节和题块切干净解析分成两级先按章节头切大块再在块内按题干切小题。核心正则如下import re # 章节头2-4 某条例学位委员会 RE_CHAPTER re.compile(r^\s*(\d{1,2})\s*-\s*(\d{1,2})\s(\S.*)$) # 题干1.题干文本()。(30 分) 分值可能带空格 RE_STEM re.compile(r^\s*(\d{1,2})\s*[.、]\s*(.?)\s*[(]\s*(\d{1,3})\s*分\s*[)]\s*$) # 选项A、 选项内容全角顿号或英文点号都要兼容 RE_OPT re.compile(r^\s*([A-D])\s*[、.]\s*(.?)\s*$) # 答案标准答案B,C,D 或 标准答案D RE_ANS re.compile(r^\s*标准答案\s*[:]\s*([A-D](?:\s*[,]\s*[A-D])*)\s*$) # 需要整行丢弃的练习回显 RE_NOISE re.compile(r^\s*您的得分\s*[:])RE_STEM里最关键的是把分值捕获出来。单选固定 30 分、多选固定 40 分这条规则本身就等于题型标签——题干带 30 分的基本是单选40 分的是多选不需要再猜。RE_OPT用[、.]兼容全角顿号和英文点号因为同一个人手工整理文件时标点经常前后不一致。RE_ANS用非捕获的重复组处理多选答案B,C,D和BCD全角逗号都能吃下来。解析主体是一个两态状态机当前处于“等题干”还是“收选项”状态。def parse_lines(lines): chapters, cur_chapter, cur_q [], None, None for raw in lines: line raw.strip() if not line or RE_NOISE.match(line): continue m RE_CHAPTER.match(line) if m and not RE_ANS.match(line): cur_chapter {no: f{m.group(1)}-{m.group(2)}, title: m.group(3).strip(), items: []} chapters.append(cur_chapter) continue m RE_STEM.match(line) if m: cur_q {no: int(m.group(1)), stem: m.group(2).strip(), score: int(m.group(3)), options: [], answer: []} if cur_chapter is not None: cur_chapter[items].append(cur_q) continue m RE_OPT.match(line) if m and cur_q is not None: cur_q[options].append({key: m.group(1), text: m.group(2)}) continue m RE_ANS.match(line) if m and cur_q is not None: cur_q[answer] [k.strip() for k in re.split(r[,], m.group(1))] cur_q None # 答案即题块结束进入待机态 return chapters状态机的收尾条件放在答案行而不是下一个题干行是因为题库里存在题号丢失的情况裸题干前面没有题号RE_STEM匹配不上如果靠“遇新题收尾”这些题会被整块吞掉。改成“答案即收尾”之后缺号的题干会作为孤立行被跳过但已解析的题不会串味。代价是丢题所以解析完必须做数量核对章节数 × 每章 3 题与解析结果比对差额就是被跳过的行回头单独人工补。3. 结构化入库从纯文本到 SQLite 题目表3.1 表结构把题干、选项、答案拆到三张表题库这种规模用不上 MySQLSQLite 一个文件就够还能直接塞进 Docker 镜像带着走。表设计如下表名关键字段说明chapterid、no、title、seqno不唯一需加seq做行内序号questionid、chapter_id、qtype、stem、score、fingerprintfingerprint唯一索引用于去重optionid、question_id、key、text、seq选项顺序要保留别靠字母排序answerquestion_id、key多选一题多行天然支持部分分计算CREATE TABLE chapter ( id INTEGER PRIMARY KEY AUTOINCREMENT, no TEXT NOT NULL, title TEXT NOT NULL, seq INTEGER NOT NULL ); CREATE TABLE question ( id INTEGER PRIMARY KEY AUTOINCREMENT, chapter_id INTEGER NOT NULL REFERENCES chapter(id), qtype TEXT NOT NULL CHECK (qtype IN (single,multi)), stem TEXT NOT NULL, score INTEGER NOT NULL, fingerprint TEXT NOT NULL ); CREATE UNIQUE INDEX idx_q_fp ON question(fingerprint); CREATE TABLE option ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL REFERENCES question(id), key TEXT NOT NULL, text TEXT NOT NULL, seq INTEGER NOT NULL ); CREATE TABLE answer ( question_id INTEGER NOT NULL REFERENCES question(id), key TEXT NOT NULL, PRIMARY KEY (question_id, key) );题型不用额外字段判断直接由分值推导qtype single if score 30 else multi。这看着取巧但对这份题库是准确的写进导入脚本时用CHECK约束兜底将来遇到其它分值再改规则。3.2 重复章节号与题干归一化去重6-3重复出现意味着章节表的no不能建唯一索引。导入时给章节加seq用UNIQUE(no, seq)区分。题干去重则靠指纹import hashlib, re def fingerprint(stem: str) - str: # 去掉所有空白 s re.sub(r\s, , stem) # 去掉括号内的填空标记()、内容不一致但题目相同 s re.sub(r[(][^)]{0,20}[)], , s) # 去掉题号残留和标点 s re.sub(r^[0-9][.、], , s) s re.sub(r[,。.;:、], , s) return hashlib.md5(s.encode(utf-8)).hexdigest()指纹的三步归一化各有分工去空白解决全角空格和制表符混用去括号解决同一道题在不同章节里填空标记长短不一去标点解决手工录入时逗号句号乱用。实测这份题库里唯一一组真正重复的题会被指纹命中其它同题干不同选项的题不会被误杀——因为题干本身有差异。注意指纹只对题干归一化不要把选项拼进去。选项在手工整理时顺序被打乱的概率很高拼进去会让本该去重的题逃过检测。3.3 答案归一化与一致性校验导入前必须跑三条校验任何一条不过就别入库def validate(q): errs [] keys {o[key] for o in q[options]} ans set(q[answer]) # 1. 答案必须是选项的子集 if not ans keys: errs.append(f答案 {sorted(ans)} 不在选项 {sorted(keys)} 中) # 2. 单选只能有一个答案多选至少两个 if q[score] 30 and len(ans) ! 1: errs.append(单选题出现多个答案) if q[score] 40 and len(ans) 2: errs.append(多选题答案少于两个) # 3. 选项字母必须连续且从 A 开始 if sorted(keys) ! list(ABCD[:len(keys)]): errs.append(f选项字母不连续{sorted(keys)}) return errs答案存储统一按字母升序写库B,C,D而不是C,B,D这样后面判分直接比集合不用再排序。校验跑完会打印一份问题清单交给人工过一遍比入库后再翻库找错要省事得多。4. 在线练习与随机组卷Flask SQLite 搭本地刷题页4.1 组卷接口按章节权重抽题组卷的核心需求是“给定章节范围和题量抽出一套卷子同一套卷子里不重复”。SQLite 支持ORDER BY RANDOM()但大表上性能一般这份题库总共几百道题直接随机排序完全没问题。from flask import Flask, request, jsonify import sqlite3, random app Flask(__name__) DB bank.sqlite def conn(): c sqlite3.connect(DB) c.row_factory sqlite3.Row return c app.get(/api/paper) def make_paper(): chapters request.args.get(chapters, ) # 形如 0-1,2-4 n_single int(request.args.get(single, 5)) # 单选数量 n_multi int(request.args.get(multi, 2)) # 多选数量 c conn() where, args , [] if chapters: nos [x.strip() for x in chapters.split(,) if x.strip()] where AND ch.no IN (%s) % ,.join(? * len(nos)) args nos def pick(qtype, n): sql fSELECT q.id FROM question q JOIN chapter ch ON ch.id q.chapter_id WHERE q.qtype ? {where} ORDER BY RANDOM() LIMIT ? return [r[id] for r in c.execute(sql, [qtype] args [n])] ids pick(single, n_single) pick(multi, n_multi) random.shuffle(ids) # 打乱题型顺序避免单选永远在前 return jsonify({question_ids: ids})chapters参数用逗号分隔的章节号where片段是动态拼的但用?占位符传值不存在拼接注入。random.shuffle那一步是必须的如果单选永远排在多选前面用户做到后半段注意力会明显下降练习效果打折。4.2 判分逻辑单选、多选、部分分的三种策略判分规则建议做成可配置因为不同题库的给分习惯不一样策略单选多选适用场景strict全对得满分少选、错选均 0 分接近真实考试partial全对得满分少选得一半错选 0 分平时练习half全对得满分漏选按比例给分错选不给逐题讲解def judge(qtype, correct, chosen, full, modepartial): correct, chosen set(correct), set(chosen) if qtype single: return full if chosen correct else 0 # 多选有错选直接 0 分无论漏没漏 if not chosen correct: return 0 if mode strict: return full if chosen correct else 0 if mode partial: return full if chosen correct else full // 2 # half按命中比例给分向下取整到 5 的倍数避免出现 23 分这种怪分数 ratio len(chosen) / len(correct) return int(full * ratio // 5 * 5)多选的关键判断是chosen correct——只要用户勾了任何一个错误选项无论漏没漏选一律 0 分。这是最常见的考试规则也是唯一能防住“全勾”作弊策略的规则。half模式下把分值向下取整到 5 的倍数是为了让分数看起来正常不加这一步会出现 26 分、23 分这类让人怀疑算错的数字。4.3 错题本与掌握度统计错题本不需要单独建表加一张记录表按question_id累加即可CREATE TABLE attempt ( id INTEGER PRIMARY KEY AUTOINCREMENT, question_id INTEGER NOT NULL REFERENCES question(id), chosen TEXT NOT NULL, -- 逗号分隔的字母空串表示未作答 got INTEGER NOT NULL, -- 本题得分 ts INTEGER NOT NULL -- unix 时间戳 ); CREATE INDEX idx_attempt_q ON attempt(question_id, ts);掌握度用“最近 3 次作答的平均得分率”来算比用历史胜率更贴近真实水平SELECT q.id, ROUND(AVG(a.got) * 1.0 / q.score, 2) AS rate FROM attempt a JOIN question q ON q.id a.question_id WHERE a.id IN ( SELECT id FROM attempt a2 WHERE a2.question_id a.question_id ORDER BY ts DESC LIMIT 3 ) GROUP BY q.id HAVING rate 0.6 ORDER BY rate ASC;这个查询会吐出掌握度低于 60% 的题按掌握度升序排。子查询用ORDER BY ts DESC LIMIT 3取最近三次外层再对同一题的多条记录求平均。如果直接把所有历史记录丢进AVG一道三个月前做错、最近三次全对的题会被永远钉在错题本里统计就失去意义了。5. 进阶技巧YAML 源文件与入库前自检入库之后回头改题是很麻烦的事——SQLite 里改一条题干得写 SQL。更省事的做法是保留一份 YAML 作为唯一真源SQLite 只当查询缓存每次改完 YAML 重新导一次。# reimport.py —— 从 YAML 重建整个库幂等 import yaml, sqlite3, hashlib SCHEMA open(schema.sql, encodingutf-8).read() def load_yaml(path): with open(path, encodingutf-8) as f: return yaml.safe_load(f) def rebuild(yaml_path, db_path): data load_yaml(yaml_path) c sqlite3.connect(db_path) c.executescript(SCHEMA) # schema.sql 里先 DROP TABLE IF EXISTS for ch in data[chapters]: cur c.execute(INSERT INTO chapter(no,title,seq) VALUES(?,?,?), (ch[no], ch[title], ch[seq])) cid cur.lastrowid for q in ch[questions]: fp fingerprint(q[stem]) cur c.execute( INSERT OR IGNORE INTO question(chapter_id,qtype,stem,score,fingerprint) VALUES(?,?,?,?,?), (cid, q[type], q[stem], q[score], fp)) if cur.rowcount 0: continue # 指纹重复整题跳过 qid cur.lastrowid for i, o in enumerate(q[options]): c.execute(INSERT INTO option(question_id,key,text,seq) VALUES(?,?,?,?), (qid, o[key], o[text], i)) for k in sorted(q[answer]): c.execute(INSERT INTO answer(question_id,key) VALUES(?,?), (qid, k)) c.commit() return cINSERT OR IGNORE配合fingerprint的唯一索引让整段导入天然幂等同一份 YAML 导十次结果和一 次完全一样。cur.rowcount 0是判断“刚才是被唯一索引拦下的”的标准写法命中就跳过该题的所有选项和答案写入避免出现有选项没题干、或有答案没选项的孤儿数据。自检单独写一个脚本在 CI 或本地 pre-commit 里跑自检项判定方式失败后动作答案越界答案字母不在选项集合内报行号人工修选项字母跳号出现 A、B、D 缺 C报行号人工修题干重复fingerprint冲突打印两条题干二选一答案未排序写入前未sorted脚本自动修正章节号重复(no, seq)冲突提示补seqdef selfcheck(data): problems, seen [], {} for ch in data[chapters]: for q in ch[questions]: fp fingerprint(q[stem]) if fp in seen: problems.append((重复题干, seen[fp], q[stem][:30])) seen[fp] q[stem][:30] keys {o[key] for o in q[options]} if not set(q[answer]) keys: problems.append((答案越界, ch[no], q[stem][:30])) if sorted(keys) ! list(ABCD[:len(keys)]): problems.append((选项跳号, ch[no], sorted(keys))) return problems跑完自检、把problems清空之后再执行rebuild题库就能长期维护下去。章节号重复这种问题在原始 .doc 里无法自动判定谁对谁错只能靠seq显式区分所以 YAML 的章节节点里必须手写seq让它成为结构的一部分而不是隐式顺序。本文还有配套的精品资源点击获取
返回列表