简介:本资源为2025年电力监理工程师继续教育配套题库,面向电力工程监理从业人员、备考继续教育考核的监理工程师及相关施工单位技术人员,帮助其在质量监督、验收评定与事故处理等环节快速查漏补缺。题库以单选、多选等题型组织,覆盖电力质量监督检查程序(自检→预监检→监检)、DL/T5210验收范围、质量监督机构对建设单位质量行为的检查、竣工验收组织主体、抽查抽检率、监理机构全过程跟踪监督原则,以及监理工程师发布违规指令后个人责任不免除、单元工程质量检验签证、合同约定标准与新颁技术标准冲突时的执行判断等内容,并延伸至国有企业文件材料归档、水利水电与水电水利施工监理规范中开工前监理职责的界定。包内仅1个pptx文件,约70KB,按题组与答案对照编排,便于随身查阅与自测。目前已有50人学习下载,适合利用碎片时间对照考点逐条核对答案、梳理易错知识点。
1. 题库还在 PPT 里一页页翻,先把它变成能查的数据
继续教育季一到,手里那份「2025电力监理工程师继续教育题库(含答案).pptx」就变成了负担:两三百页幻灯片,一页一道题,答案用红色字压在选项下面,想找一个「旁站监理」相关的考点,只能按 Ctrl+F 一页页跳。更麻烦的是错题——翻到了、记住了、下次又从第一页开始。这份文件本质上是结构化数据被压进了一个演示文稿容器里,题干、选项、答案、页码其实都是字段,只是没有按字段存。
把 PPTX 拆成题目记录,用 SQLite 建中文全文索引,再挂一个本地刷题页和错题复习队列,整套东西一个人一天能搭完,不需要服务器也不需要账号。适合两类人:手里已经有这份题库、想把它用起来的监理从业者;以及需要处理类似「题库类 PPTX」的开发者——同样的解析路径换个文件就能复用。下面从解析讲到索引、从刷题讲到版本维护,每一步都给出能直接跑的代码和参数。
2. 用 python-pptx 解析电力监理继续教育题库 PPTX
解析这一步决定后面所有环节的上限。题目抽错一行,索引再快也没意义。所以先把 PPTX 的文本结构摸清,再谈正则。
2.1 先摸清 PPTX 的文本容器:文本框、占位符、组合形状、表格
PPTX 在磁盘上就是一个 zip 包,幻灯片正文在ppt/slides/slideN.xml,文字的最小单位是<a:t>节点,若干<a:t>组成<a:p>(段落),段落组成文本框。python-pptx 已经把这层封装好,直接操作shape.text_frame.paragraphs就行,但有几个坑必须先知道。
第一个坑是 run 拆分。题干里某个词被加粗或者标了颜色,在 XML 里就会被切成多个<a:t>,如果按run遍历取文本,一句话会碎成好几段。正确做法是按paragraph合并所有 run 的文本。
第二个坑是形状顺序。slide.shapes的返回顺序不保证等于视觉顺序,有时候答案文本框排在题干前面。必须按shape.top、shape.left排序,才能还原「题干在上、选项居中、答案在下」的阅读顺序。
第三个坑是容器嵌套。有些题库把选项做成组合形状,或者干脆用表格排版(一列题号、一列内容)。组合形状要递归下钻,表格要按行按单元格取文本,这两类都得单独处理。
2.2 抽取每页文本的最小可运行脚本
# parse_pptx.py —— 把题库 PPTX 拆成「页码 + 文本行」,保持视觉阅读顺序 from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE def shape_lines(shape): """递归取出单个形状里的所有文本行""" lines = [] if shape.shape_type == MSO_SHAPE_TYPE.GROUP: for sub in shape.shapes: # 组合形状必须递归下钻 lines.extend(shape_lines(sub)) return lines if getattr(shape, "has_table", False) and shape.has_table: for row in shape.table.rows: # 表格按行拼接,单元格用制表符分隔 cells = [c.text.strip().replace("\n", " ") for c in row.cells] if any(cells): lines.append("\t".join(cells)) return lines if shape.has_text_frame: for para in shape.text_frame.paragraphs: # 加粗/变色会把一句话拆成多个 run,必须按 paragraph 合并 text = "".join(run.text for run in para.runs).strip() if text: lines.append(text) return lines def slide_lines(slide): """按 top/left 排序,还原题干在上、答案在下的视觉顺序""" shapes = [s for s in slide.shapes if s.has_text_frame or s.shape_type == MSO_SHAPE_TYPE.GROUP or (getattr(s, "has_table", False) and s.has_table)] shapes.sort(key=lambda s: (s.top or 0, s.left or 0)) out = [] for s in shapes: out.extend(shape_lines(s)) return out if __name__ == "__main__": prs = Presentation("2025电力监理工程师继续教育题库(含答案).pptx") for i, slide in enumerate(prs.slides, 1): for line in slide_lines(slide): print(f"{i}\t{line}") # 页码 + 原文,落成 TSV 方便 grep 校验逻辑说明:shape_lines用递归把组合形状摊平,用has_table分支处理表格题;slide_lines的排序键是(top, left)的 EMU 原值,EMU 是 PPTX 的最小长度单位,直接比大小即可,不用换算成磅值。参数上唯一需要调的是排序容差——如果同一行的题干文本框和题号文本框 top 值相差几百 EMU,可能出现题号跑到题干后面的情况。
提示:先把这个脚本的输出重定向到
raw.tsv,用wc -l看一眼总行数。两三百页的题库通常在 1500 到 3000 行之间,行数明显偏少说明有大量文本藏在图片或 SmartArt 里。
2.3 题干、选项、答案的分割正则与状态机
拿到行文本之后,用一套正则加一个状态机把行流切成题目对象。核心是把「新题干开始」「选项行」「答案行」三种事件区分开。
import re RE_NUM = re.compile(r"^\s*[((]?\s*(\d{1,4})\s*[))、..]\s*(?=\S)") RE_OPT = re.compile(r"^\s*([A-Ha-h])\s*[、..))]\s*(.+)$") RE_ANS = re.compile(r"^\s*(?:答案|正确答案|参考答案|【答案】)\s*[::]?\s*(.+)$") RE_JUDGE = re.compile(r"^(对|错|正确|错误|√|×|是|否|T|F)$", re.I) def normalize_answer(raw): """把各种写法归一:判断题给 T/F,选择题给大写字母串""" raw = raw.strip() if RE_JUDGE.match(raw): return "T" if raw.upper() in ("对", "正确", "√", "是", "T") else "F" return "".join(sorted({c.upper() for c in re.findall(r"[A-Ha-h]", raw)})) def build_questions(rows): """rows 是 (页码, 文本) 的序列,返回题目列表""" qs, cur = [], None for page, line in rows: m_ans = RE_ANS.match(line) if m_ans and cur: cur["answer"] = normalize_answer(m_ans.group(1)) qs.append(cur); cur = None # 答案出现即视为一题结束 continue m_opt = RE_OPT.match(line) if m_opt and cur: cur["options"].append({"label": m_opt.group(1).upper(), "text": m_opt.group(2).strip()}) continue m_num = RE_NUM.match(line) if m_num: if cur: qs.append(cur) # 上一题没有显式答案也要落库,标记待补 cur = {"page": page, "no": int(m_num.group(1)), "stem": line[m_num.end():].strip(), "options": [], "answer": ""} continue if cur and not cur["options"]: cur["stem"] += " " + line # 题干跨行时的补丁 if cur: qs.append(cur) return qs逻辑说明:状态机靠cur指针对应「当前正在收集的题目」,遇到答案行就收口。三个正则里RE_NUM用了(?=\S)前瞻,避免把「3.1 质量控制」这种小节标题误判成题号;RE_OPT允许 A 到 H 八个选项,覆盖多选;RE_ANS兼容「答案:」「正确答案」「【答案】」几种写法。normalize_answer先把判断题压成 T/F,再用re.findall从「ABD」「A、B、D」里抠出字母并排序去重,这样后面判分可以直接字符串比较。
2.4 解析质量校验:题号连续性、选项数、答案越界
解析完不要急着入库,先跑一遍自检。这类题库最常见的三类错误是:答案被挂到相邻题目上、选项被拆成两行导致只剩一个选项、答案字母不在选项集合里。
def validate(qs): problems = [] for q in qs: labels = {o["label"] for o in q["options"]} if q["options"] and len(labels) < 2: problems.append((q["no"], "选项少于 2 个,可能是选项跨行")) if not q["answer"]: problems.append((q["no"], "缺答案")) elif q["answer"] not in ("T", "F"): bad = set(q["answer"]) - labels if bad: problems.append((q["no"], f"答案 {q['answer']} 越界,选项只有 {sorted(labels)}")) nos = [q["no"] for q in qs] if nos and nos != list(range(nos[0], nos[0] + len(nos))): problems.append((0, "题号不连续,中间有题目丢失")) return problems不同排版形态对应的处理策略差异很大,动手前先对照一下自己手里的文件属于哪一类:
| 排版形态 | 典型表现 | 解析策略 |
|---|---|---|
| 一页一题,题干选项答案同页 | 最省事的情况 | 按段落顺序直接切 |
| 答案统一放在最后一页 | 前面全是题,末尾一张答案表 | 按题号 join 回题目 |
| 选项挤在同一段落 | 「A.旁站 B.巡视 C.平行检验 D.见证」 | 用re.split按[A-H][、.]二次切分 |
| 答案靠红色字体区分 | 没有「答案」二字 | 按run.font.color.rgb判断,需逐 run 取值 |
| 表格排版 | 一列题号一列内容 | 走has_table分支按行取 |
注意:校验脚本报出的问题不要直接删题,统一导出成待确认清单,人工过一遍。电力监理的题目里有不少带「不包括」「错误的是」的否定题干,正则在题干跨行时容易把否定词切到下一行,人工复核比自动修补稳。
3. 用 SQLite FTS5 给电力监理题库建中文全文索引
数据落成 JSON 之后,下一步是能查。SQLite 自带 FTS5 全文索引,单文件、零部署,中文题干用 trigram 分词器就能实现任意长度子串匹配。
3.1 表结构:题目、选项、考点标签分开存
把题目和选项拆成两张表,是为了让选项乱序、选项级统计这些需求做起来不用改题干文本。再留一个chapter字段装考点分类,比如「质量控制」「安全管理」「进度控制」「合同管理」,后续按考点组卷就靠它。
PRAGMA journal_mode = WAL; -- 一边刷题一边导入新题时不锁库 CREATE TABLE questions ( id INTEGER PRIMARY KEY, qtype TEXT NOT NULL, -- single / multi / judge stem TEXT NOT NULL, -- 题干,已去掉题号 answer TEXT NOT NULL, -- 单选 'B',多选 'ABD',判断 'T'/'F' source_page INTEGER, -- 原 PPT 页码,方便回查原稿 chapter TEXT, -- 考点分类,可为空 fingerprint TEXT UNIQUE -- 题干归一化哈希,用于去重 ); CREATE TABLE options ( qid INTEGER NOT NULL, label TEXT NOT NULL, text TEXT NOT NULL, PRIMARY KEY (qid, label), FOREIGN KEY (qid) REFERENCES questions(id) ON DELETE CASCADE ); CREATE TABLE wrong_book ( qid INTEGER PRIMARY KEY, wrong_times INTEGER NOT NULL DEFAULT 1, last_wrong_at TEXT, next_review_at TEXT ); CREATE VIRTUAL TABLE q_fts USING fts5( stem, options_text, tokenize='trigram' );fingerprint上加 UNIQUE 是关键一笔,重复导入同一份文件时会自动被INSERT OR IGNORE拦掉。q_fts用独立表而不是 external content,是为了让rowid直接等于questions.id,查询时一句 join 就能带出答案。
3.2 建表语句与批量导入脚本
import sqlite3, hashlib, re def fingerprint(stem): """去掉标点空白括号,只留汉字字母数字,屏蔽排版差异带来的哈希漂移""" return hashlib.md5(re.sub(r"[^\u4e00-\u9fa5A-Za-z0-9]", "", stem).encode()).hexdigest() def qtype_of(q): if not q["options"]: return "judge" return "multi" if len(q["answer"]) > 1 else "single" conn = sqlite3.connect("dljl.db") cur = conn.cursor() for q in questions: opts = "\n".join(f"{o['label']}.{o['text']}" for o in q["options"]) cur.execute( "INSERT OR IGNORE INTO questions(qtype,stem,answer,source_page,chapter,fingerprint)" " VALUES(?,?,?,?,?,?)", (qtype_of(q), q["stem"], q["answer"], q["page"], q.get("chapter"), fingerprint(q["stem"]))) if cur.rowcount == 0: # 指纹撞车,说明这题已在库里 continue qid = cur.lastrowid for o in q["options"]: cur.execute("INSERT INTO options(qid,label,text) VALUES(?,?,?)", (qid, o["label"], o["text"])) cur.execute("INSERT INTO q_fts(rowid,stem,options_text) VALUES(?,?,?)", (qid, q["stem"], opts)) conn.commit()导入顺序有讲究:先插questions拿lastrowid,再插选项和 FTS 行,三者用同一个 id 对齐。选项文本单独拼成options_text放进 FTS,这样搜「见证取样」既命中题干也命中选项内容,找「选项里提到某个术语的题」特别好用。
3.3 中文分词:trigram 与 jieba 预分词的取舍
FTS5 默认的unicode61分词器按空白和标点切词,中文整句会被当成一个 token,搜「旁站」永远搜不到「旁站监理的实施要点」。三种方案的差别如下:
| 方案 | 中文效果 | 索引体积 | 适用场景 |
|---|---|---|---|
| unicode61 | 差,整句一个 token | 最小 | 纯英文题干 |
| trigram | 好,按 3 字符滑窗切分 | 约为原文 2~3 倍 | 中文题干关键词检索,首选 |
| jieba 预分词 | 好,按词切分 | 中等 | 需要词频排序、同义词扩展 |
trigram 的代价是索引膨胀,一份 3000 题的库大约 2~3 MB,对本地使用完全无感。它还有个隐式限制:查询串长度小于 3 时退化成全表扫描,所以搜「监理」这种两字词要写成MATCH '监理*'走前缀匹配,或者干脆用LIKE '%监理%'加questions.stem普通索引兜底。
3.4 查询写法:按考点、按题型、按答案分布统计
-- 按题干/选项关键词查,trigram 支持任意长度子串 SELECT q.id, highlight(q_fts, 0, '【', '】') AS hit, q.answer FROM q_fts JOIN questions q ON q.id = q_fts.rowid WHERE q_fts MATCH '旁站监理' LIMIT 20; -- 只要多选题,限定考点,按原稿页码排序 SELECT id, stem FROM questions WHERE qtype = 'multi' AND chapter = '安全管理' ORDER BY source_page; -- 取今天该复习的错题 SELECT q.id, q.stem FROM wrong_book w JOIN questions q ON q.id = w.qid WHERE w.next_review_at <= datetime('now') ORDER BY w.next_review_at LIMIT 30;highlight会把命中的词用指定符号包起来,前端直接渲染就能看到命中位置,省得自己写高亮逻辑。最后一条按答案分布统计虽然简单,但很值钱:正常题库四个选项的答案分布大致均匀,如果某个字母占了六成以上,基本可以确定解析时答案错位了,回头查原 PPT 比逐题核对快得多。
4. 本地跑通一个电力监理继续教育刷题与错题复习工具
索引建好之后,刷题界面就是一层很薄的皮。用 Flask 起三个接口足够:抽题、取题、提交判分。
4.1 Flask 后端:抽题、判分、写入错题本
from flask import Flask, jsonify, request import sqlite3, random app = Flask(__name__) DB = "dljl.db" def db(): conn = sqlite3.connect(DB) conn.row_factory = sqlite3.Row return conn @app.get("/api/paper") def paper(): n = min(int(request.args.get("n", 20)), 100) # 单次组卷上限 100,防手滑 chapter = request.args.get("chapter") sql, args = "SELECT id FROM questions WHERE 1=1", [] if chapter: sql += " AND chapter = ?"; args.append(chapter) rows = db().execute(sql, args).fetchall() picked = random.sample(rows, min(n, len(rows))) # 每次组卷都重抽,破掉顺序记忆 return jsonify([r["id"] for r in picked]) @app.get("/api/question/<int:qid>") def question(qid): conn = db() q = conn.execute("SELECT * FROM questions WHERE id=?", (qid,)).fetchone() opts = conn.execute("SELECT label, text FROM options WHERE qid=? ORDER BY label", (qid,)).fetchall() opts = [dict(o) for o in opts] random.shuffle(opts) # 选项乱序,避免背题号 return jsonify({"qtype": q["qtype"], "stem": q["stem"], "options": opts}) @app.post("/api/submit") def submit(): body = request.get_json() conn = db() q = conn.execute("SELECT answer FROM questions WHERE id=?", (body["qid"],)).fetchone() ok = sorted(body["picked"].upper()) == sorted(q["answer"]) if not ok: conn.execute( """INSERT INTO wrong_book(qid, wrong_times, last_wrong_at, next_review_at) VALUES(?, 1, datetime('now'), datetime('now','+1 day')) ON CONFLICT(qid) DO UPDATE SET wrong_times = wrong_times + 1, last_wrong_at = datetime('now'), next_review_at = datetime('now','+' || (wrong_times + 1) || ' day')""", (body["qid"],)) conn.commit() return jsonify({"correct": ok, "answer": q["answer"]})选型理由上,用 Flask 而不是 FastAPI,是因为这三个接口没有并发压力,Flask 的同步模型写起来更短,sqlite3.Row直接dict()就能序列化。判分用sorted()比较两边,正好兼容多选答案「顺序不同但集合相同」的情况。
有个容易被忽略的细节:SQLite 同一条 UPDATE 里的多个赋值表达式读的都是更新前的值,所以next_review_at里的wrong_times + 1用的是旧值,实际得到的是「错 1 次排 1 天后、错 2 次排 2 天后」的递增间隔,正好符合复习规律,不用额外写变量。
4.2 前端最小页面:题干渲染与选项乱序
<!doctype html> <meta charset="utf-8"> <title>电力监理继续教育刷题</title> <div id="box"></div> <script> async function load(qid) { const q = await (await fetch(`/api/question/${qid}`)).json(); const html = [`<h3>${q.stem}</h3>`]; q.options.forEach(o => html.push( `<label><input type="${q.qtype === 'multi' ? 'checkbox' : 'radio'}" name="opt" value="${o.label}"> ${o.label}. ${o.text}</label><br>`)); html.push(`<button onclick="submit(${qid})">提交</button><pre id="fb"></pre>`); document.getElementById('box').innerHTML = html.join(''); } async function submit(qid) { const picked = [...document.querySelectorAll('input[name=opt]:checked')] .map(i => i.value).join(''); const r = await (await fetch('/api/submit', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({qid, picked}) })).json(); document.getElementById('fb').textContent = r.correct ? '答对了' : `正确答案:${r.answer}`; } load(new URLSearchParams(location.search).get('qid') || 1); </script>判断题没有选项行,options是空数组,前端会渲染成只有一个「提交」按钮的页面。补一句if (!q.options.length)时渲染两个固定单选项(对/错)即可,值分别传T和F,后端判分逻辑不用改。
4.3 错题复习队列:简化版间隔重复参数
错题本不需要完整实现 SM-2,按「错误次数决定下次复习间隔」的简化模型就够用,参数建议如下:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 首次错题间隔 | 1 天 | 当天记住的往往是短期记忆 |
| 间隔增长序列 | 1、2、3、5、8 天 | 由wrong_times + 1自动推导 |
| 单次复习题量 | 20~30 题 | 超过 30 题正确率断崖式下降 |
| 连续答对阈值 | 2 次 | 达到后从wrong_book删除,避免队列无限膨胀 |
复习流程就是每天跑一次 3.4 里那条next_review_at <= datetime('now')的查询,把返回的 id 丢给组卷接口。答对时执行wrong_times = 0并累加一个right_streak字段,连对两次就DELETE FROM wrong_book WHERE qid = ?。这套逻辑加起来不到二十行,比背着一堆参数配置更实用。
5. 题库长期维护:版本比对、去重与图片题的兜底处理
题库每年更新一次,真正的麻烦不在第一次解析,而在第二年拿到新版文件时怎么知道多了哪些题。
5.1 用指纹做增量比对
把新旧两版分别导入两个库文件,一条 SQL 就能拉出新增题:
ATTACH DATABASE 'dljl_2024.db' AS old; -- 先建好上一版的库 SELECT q.id, q.stem, q.source_page FROM main.questions q WHERE q.fingerprint NOT IN (SELECT fingerprint FROM old.questions);反过来把main和old调换位置就是被删掉的题。指纹用的是「去标点后的题干」,所以题干里改一个数字、换一个标点都会被当成新题——这是刻意的取舍:宁可多出几十条待确认记录,也不要把一道实质改过的题悄悄判成重复。实际跑下来,一份 3000 题的库年更新差异通常在 200 到 400 条之间,人工过一遍只要半小时。
5.2 图片题与公式题的兜底清单
电力监理题库里有一类题没法纯文本处理:接线图、系统图、带公式的计算题。判断方法很简单,逐页统计图片形状数量:
from pptx.enum.shapes import MSO_SHAPE_TYPE for i, slide in enumerate(prs.slides, 1): pics = [s for s in slide.shapes if s.shape_type == MSO_SHAPE_TYPE.PICTURE] texts = [line for line in slide_lines(slide)] if pics and len("".join(texts)) < 20: # 有图且几乎没有文字 print(i, "图片题,需人工补录")阈值 20 是个经验值:题干加选项少于 20 个字符、同时页面上有图片,基本就是纯图片题。这类题不要硬凑文本,直接从索引里排除,单独出一份「图片题清单」按页码人工处理,比让它们在库里以残缺题干存在要干净。
导出给 Excel 核对时用utf-8-sig编码,否则中文列名在 Excel 里全是乱码:
import csv with open("题库.csv", "w", newline="", encoding="utf-8-sig") as f: w = csv.writer(f) w.writerow(["题号", "题型", "题干", "选项", "答案", "页码"]) for q in questions: opts = " | ".join(f"{o['label']}.{o['text']}" for o in q["options"]) w.writerow([q["no"], qtype_of(q), q["stem"], opts, q["answer"], q["page"]])导出之后先别关,看一眼「页码」列是不是单调不减、答案列有没有空格。页码列乱序说明解析阶段形状排序出了问题,答案列出现空格说明normalize_answer漏掉了某种写法,比如「答案:A、」这种末尾带顿号的情况——在正则末尾补一个[、,,;;]*就能吃掉。
本文还有配套的精品资源,点击获取