ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

python-docx解析医疗器械培训试题:半结构化题库抽取与判分

python-docx解析医疗器械培训试题:半结构化题库抽取与判分 简介《2025医疗器械培训试题(含答案)》面向医疗器械生产、经营、使用及监管岗位从业者与法规备考人员可用于内部培训考核、自考自测和考前查漏补缺。题目围绕《医疗器械监督管理条例》展开覆盖行政许可注销、第一类备案与二三类注册、注册证五年有效期及延续申请、广告审批文件、生产与经营许可证管理、虚假材料申请限制、涂改出租注册证处罚、不良事件报告与再评价、器械分类和使用状态等高频考点。其中还涉及产品备案资料提交部门、经营许可30个工作日审批、境外二三类注册证核发及居民住宅不得作仓库等细节。包内共1个docx文件约32KB含填空、单选、多选等题型并附参考答案结构紧凑便于打印练习或按模块编辑整理。目前已有303人学习下载适合需要系统梳理监管法规、快速验证掌握程度的读者使用。1. 从一份 2025 医疗器械培训试题 docx 说起培训结束第二天就要抽考行政把 12 页的《2025医疗器械培训试题(含答案).docx》发到群里600 多道题横跨填空、单选、多选、判断、问答、论述六种题型答案有的写在题干括号里有的跟在选项后面还有单独一行写「答案C」。人工整理一遍至少半天法规版本一换还得重来。这份 docx 覆盖《医疗器械监督管理条例》里的分类注册、经营许可、广告审批、不良事件监测几块题目集中在有效期、办理时限、编号规则这类硬数字上。对注册、质量、经营岗它是备考材料对做内部培训系统的 IT它是一份半结构化数据源——把题干、选项、答案、考点标签抽干净就能拼出可检索、可判分、可统计错题的刷题库。下面按先建模、再解析、后服务化的顺序拆一遍。2. docx 题库的结构解剖与字段建模2.1 六种题型在段落层的分布规律打开文档看段落会发现题目不是按「块」组织的而是按「行」铺开的。单选题的典型形态是四行题干行、选项行若干、答案行选项行形如A、10答案行形如答案 C注意冒号前有一个全角空格。判断题反过来答案在最前面 × 1、申请第二类、第三类医疗器械产品注册……。填空题的答案直接嵌在正文括号里而且括号里常有大量空白字符用来对齐比如注销 、 年。还有一个更要命的坑这份文档里存在两套编号体系。前 7 页是「一、填空题 / 二、单选题 / 三、多选题 / 四、判断题 / 五、问答题 / 六、论述题」第 8 页之后又重新出现「一、多选题每题 4 分共 10 题 40 分/ 二、单项选择题 / 三、判断题」。如果只用「一、二、三」这种序号当切分键第二套会被并进第一套的题型里多选题直接串到论述题下面。切分键必须带上题型名序号只作为辅助。同时题号本身也不连续单选题标注「15 题」实际只有 11 道多选题标注「15 题」实际 11 道。入库后如果拿「题量」做统计报表数字会对不上这一点得在数据字典里写明。题型段落特征答案位置解析难度填空题题干内含 占位括号内文字中需去空白单选题题干 A、起头的选项行独立的答案 X行低多选题同单选答案多字母独立的答案ABCD行低判断题 √ 或 × 开头题干前置括号低但易与选项误匹配问答题「1、」起头的长段落无标准答案或要点罗列高需人工论述题同上篇幅更长无标准答案高需人工2.2 字段设计一份题库表该存什么题库表最容易犯的错是「只存题干和答案」。真跑起来判分要区分题型、错题回顾要显示选项、法规改版要能反查来源段落所以下面几个字段一个都不能省qtype决定判分分支options用于前端渲染tags用于按考点检索raw_text保留原始段落答案有歧义时可以直接回看原文page记录页码方便和纸质版对照。2.3 用 dataclass 固化题目结构from dataclasses import dataclass, field from typing import List, Literal # 六种题型用 Literal 约束防止手写字符串拼错 QType Literal[fill, single, multi, judge, qa, essay] dataclass class Question: qtype: QType stem: str # 题干判断题已剥离前置括号 options: List[str] field(default_factorylist) # [A、10, B、15] answer: str # 单选C、多选BCD、判断√、填空为答案文本 tags: List[str] field(default_factorylist) # 考点标签检索用 section: str # 来源章节名如单选题 page: int 0 # 原文档页码 raw_text: str # 原始段落人工复核兜底 def key(self) - str: # 用题干哈希做去重键同一道题在两套编号里重复出现时只留一条 return f{self.qtype}:{abs(hash(self.stem))}options存成列表而不是一个大字符串是为了多选判分时直接做集合运算raw_text看似冗余但在这类半结构化文档里人工复核的成本远高于几百 KB 的存储成本key()解决的是前面提到的重复题问题——文档前半段和后半段有重叠考点去重能避免错题统计被放大。3. python-docx 解析与噪声清洗3.1 段落读取与噪声预处理文档里每页底部都有第 1 页 共 12 页这类页码行页眉偶尔也会混进来。这些噪声必须在切分之前清掉否则会被当成题干的一部分。import re from docx import Document PAGE_NOISE re.compile(r第\s*\d\s*页\s*共\s*\d\s*页) SECTION re.compile(r^[一二三四五六]、\s*(填空题|单选题|多选题|判断题|问答题|论述题|多项选择题)) OPTION re.compile(r^([A-E])\s*[、.]\s*(.)$) ANSWER re.compile(r答案\s*[:]\s*([A-E])) JUDGE re.compile(r^[(]\s*([√×对错])\s*[)]\s*(\d)\s*[、.]\s*(.)) FILL_BLANK re.compile(r[(]\s*([^)]*?)\s*[)]) def load_paragraphs(path: str): 逐段读取并就地清洗页码噪声空白段落直接丢弃。 doc Document(path) for p in doc.paragraphs: text PAGE_NOISE.sub(, p.text).strip() if text: yield text这里PAGE_NOISE用\s*而不是\s因为原文里页和页码之间夹的是全角空格和半角空格混排。SECTION的正则把题型名一并捕获就是为了解决 2.1 里说的两套编号问题。JUDGE必须比OPTION先匹配否则 × 1、xxx这种行不会被OPTION吃掉因为它不以字母开头但判断题后面若跟着A、开头的选项就会误判。3.2 题型切分与选项、答案提取def parse(docx_path: str): questions, cur_section, buf [], , [] def flush(): 把缓冲区里的若干行合成一道题无法识别的行直接丢弃并打印。 if not buf: return block \n.join(buf) q build_question(block, cur_section) if q: questions.append(q) else: print([未识别], block[:60]) buf.clear() for text in load_paragraphs(docx_path): if SECTION.match(text): flush() cur_section SECTION.match(text).group(1) continue # 新题起始数字 顿号且不是在续写选项 if re.match(r^\d\s*[、.], text) or JUDGE.match(text): flush() buf.append(text) flush() return questionsflush()的职责是把累积的行交给build_question识别不出来就打日志而不是静默丢弃——题库解析最怕的就是「少了几道题却没人发现」。判新题起始的条件写成^\d[、.]加JUDGE两条是因为判断题带前置括号不满足数字开头的模式。build_question里按SECTION分发单选/多选走OPTIONANSWER两把正则判断题走JUDGE答案从group(1)取题干取group(3)行首的 √ 和序号都要剥掉填空题把FILL_BLANK匹配到的内容当答案题干里保留占位符____。正则作用需要注意的写法差异PAGE_NOISE剔除页脚全角/半角空格混排SECTION切分题型必须带题型名序号重复出现OPTION抓选项原文用、也出现过.ANSWER抓答案冒号可能是:或JUDGE抓判断题√/×与对/错都要覆盖FILL_BLANK抓填空答案括号内可能有大量空白注意填空题的答案是「填在括号里的那个词」但原文有不少题干是空的括号用来给考生填的匹配到空字符串时要跳过不能当成答案入库。3.3 落库SQLite 表结构与批量写入CREATE TABLE IF NOT EXISTS questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, qtype TEXT NOT NULL, -- fill/single/multi/judge/qa/essay stem TEXT NOT NULL, options TEXT, -- JSON 数组字符串问答题为空 answer TEXT NOT NULL, section TEXT, page INTEGER, raw_text TEXT, UNIQUE(qtype, stem) -- 题干题型唯一天然去重 ); CREATE VIRTUAL TABLE IF NOT EXISTS questions_fts USING fts5(stem, tags);import json, sqlite3 def save(conn: sqlite3.Connection, items: list): rows [(q.qtype, q.stem, json.dumps(q.options, ensure_asciiFalse), q.answer, q.section, q.page, q.raw_text) for q in items] conn.executemany( INSERT OR IGNORE INTO questions(qtype,stem,options,answer,section,page,raw_text) VALUES(?,?,?,?,?,?,?), rows) # FTS 表要单独灌一遍rowid 与主表对齐 for q in items: conn.execute(INSERT INTO questions_fts(rowid, stem, tags) VALUES ((SELECT id FROM questions WHERE qtype? AND stem?), ?, ?), (q.qtype, q.stem, q.stem, .join(q.tags))) conn.commit()INSERT OR IGNORE配合UNIQUE(qtype, stem)重复题自动跳过不用在 Python 里做 set 去重。FTS 表用INSERT而不是INSERT OR REPLACE是因为外部内容表不支持替换语义重复灌会报错——重跑脚本前要么先DELETE FROM questions_fts要么加IF NOT EXISTS判断。4. 题库检索、判分与错题复习实现4.1 考点标签与 FTS5 全文检索题库入库只是第一步真正被高频使用的是「我想练注册证有效期这块的题」。靠LIKE %有效期%能查但查不全——题干里写的是「有效期届满需要延续」写的是「注册证有效期为 年」语义是一回事字面不一样。TAG_RULES { 注册证有效期: [注册证有效期, 延续注册, 有效期届满], 经营许可时限: [工作日, 核发, 受理之日], 不良事件报告: [不良事件, 监测, 报告], 记录保存期限: [进货查验记录, 销售记录, 保存], 注册证编号: [注册号, 编排方式, 械注准, YZB], 分类管理: [风险程度, 第一类, 第二类, 第三类, 分类], } def tag_it(stem: str, answer: str) - list: text stem answer return [tag for tag, kws in TAG_RULES.items() if any(k in text for k in kws)]标签是「宽召回」一道题可以挂多个标签。检索时用 FTS5 走标签和题干两个字段SELECT q.id, q.qtype, q.stem, q.answer FROM questions_fts f JOIN questions q ON q.id f.rowid WHERE questions_fts MATCH :kw ORDER BY bm25(questions_fts) LIMIT 20;bm25()给的是相关性排序题干里密集出现关键词的题会排在前面。用标签词当查询串时相当于按考点聚题直接输入「延续注册」则是关键词召回两种用法共用一条 SQL。4.2 三种题型的判分实现判分的核心不是「比字符串」而是按题型选比较方式。多选必须按集合比顺序无关但少选多选都算错填空必须归一化后比原文档答案里带着全角空格和多余标点判断题要做√/×与对/错的映射。import re TRANS str.maketrans( , ABCDE() ) def normalize(s: str) - str: 全角转半角去掉空白和常见标点仅用于填空题比对。 s s.translate(TRANS) return re.sub(r[\s。、,.;:()], , s) JUDGE_MAP {√: √, 对: √, 正确: √, ×: ×, 错: ×, 错误: ×} def grade(qtype: str, answer: str, user_input: str) - bool: if qtype multi: return set(answer.upper()) set(user_input.upper()) if qtype single: return answer.strip().upper() user_input.strip().upper() if qtype judge: return JUDGE_MAP.get(answer.strip()) JUDGE_MAP.get(user_input.strip()) if qtype fill: return normalize(answer) normalize(user_input) return False # 问答/论述不自动判分交给人工或关键词打分normalize里把也纳入删除集合是因为填空题答案本身可能带括号比如答案写成「注销」。问答和论述题直接返回False并打上need_manual标记是刻意为之——这类题的参考答案是成段的要点罗列用关键词匹配打分会让统计失真不如老实交给人工。4.3 错题队列与间隔复习错题表不用很复杂一张表加上「下次复习时间」就够用CREATE TABLE IF NOT EXISTS wrong_book ( qid INTEGER PRIMARY KEY, wrong_cnt INTEGER DEFAULT 0, -- 累计答错次数 last_wrong TEXT, -- 最近一次答错时间 next_review TEXT, -- 下次该复习的时间 FOREIGN KEY(qid) REFERENCES questions(id) );答错时wrong_cnt 1next_review按1 天 → 3 天 → 7 天 → 15 天递推答对一次就往后跳一档连对三次从错题本移除。这套规则比完整的间隔重复算法简单得多但对几百道题的规模已经完全够用也不会让「错题本」变成一个永远清不完的列表。5. 数据校核用题目反推法规数字的易错点5.1 题库内部矛盾检测脚本这份文档里存在前后不一致的地方典型的是记录保存期限判断题第 7 题写「采购、验收、销售记录应当保存二年植入性医疗器械永久保存」答案给的是×但单选题第 13 题的答案又是「有效期后 2 年无有效期不少于 5 年植入类永久」。两处的表述有交集也有冲突靠人眼翻 12 页很难发现。import re FACTS [ (注册证有效期, 5, r注册证有效期为\s*[(]?\s*(\d)\s*[)]?\s*年), (延续注册提前期, 6, r有效期届满\s*[(]?\s*(\d)\s*[)]?\s*个月), (许可证核发时限, 30, r受理之日起\s*[(]?\s*(\d)\s*[)]?\s*个工作日), ] def audit(conn): 按考点扫描题干答案命中数字与期望值不符就打印出来人工核对。 for name, expect, pat in FACTS: for qid, stem, ans in conn.execute(SELECT id, stem, answer FROM questions): hit re.search(pat, stem ans) if hit and int(hit.group(1)) ! expect: print(f[数字冲突] {name} id{qid} 命中{hit.group(1)} 期望{expect})把每个硬数字写成一条FACTS法规改版时只改这一处跑一次audit()就知道哪些题需要跟着更新。5.2 高频数字对照表事项数字原文出处医疗器械注册证有效期5 年单选、填空延续注册提出时间届满前 6 个月单选经营许可证有效期5 年单选经营许可证延续申请届满前 6 个月单选第一类备案凭证有效期无期限单选许可证核发决定受理之日起 30 个工作日单选补正材料一次性告知当场或 5 个工作日内多选进货查验/销售记录保存有效期后 2 年无有效期不少于 5 年植入类永久单选第三类经营企业停业一年以上需提前书面报告单选重大质量事故报告24 小时内报省局省局立即上报多选虚假材料申请1 年内不得再次申请填空涂改、出租注册证书罚款3 万元以下单选这张表的价值不在背诵而在「对账」题库里凡是出现数字的题都能在这张表里找到对应的唯一值出现第二个值时就是数据有问题的信号。5.3 注册证编号的断言化解析编号规则是这份题库里最适合写成代码的考点因为它本身就是一套形式化规则×1是注册审批部门简称×2是注册形式准/进/许××××3是批准年份×4是产品管理类别××5是品种编码××××6是流水号。拿题库里的例子国械注准20143220001号做断言import re PAT re.compile(r^(?Porg[国\w]{1,2})械注(?Pform[准进许])(?Pyear\d{4})(?Pcls\d)(?Pcode\d{2})(?Pseq\d{4})号$) def parse_reg_no(s: str) - dict: m PAT.match(s.strip()) if not m: raise ValueError(f注册证号格式不匹配: {s}) return { 审批部门: m[org], # 国 境内第三类/境外 注册形式: m[form], # 准境内进境外许港澳台 批准年份: m[year], 管理类别: m[cls], # 3 第三类 品种编码: m[code], 流水号: m[seq], } assert parse_reg_no(国械注准20143220001号)[管理类别] 3 assert parse_reg_no(国械注准20143220001号)[批准年份] 2014把题库里的编号例子全部塞进assert等于给这份资料加了一层可执行的校验。判断题里那句「『滇昆械备20150002号』『沪食药监械(准)字 2014 第 2640769号』都是第二类医疗器械」答案是×原因用这套正则跑一遍立刻清楚前者是备案凭证号后者是旧的省级注册号格式两者都不符合现行「××械注准/进/许 年份 类别」的编码结构。下次法规再调整编号规则改的是正则不是记忆。本文还有配套的精品资源点击获取
返回列表