十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PDF教学大纲结构化抽取:文本层解析、学时对账与目标分级落库

PDF教学大纲结构化抽取:文本层解析、学时对账与目标分级落库 简介这份《临床医学五年制法医学课程教学大纲》面向临床医学专业五年制本科生及授课教师可用于课前了解课程框架、复习考试或在教学中对照学时安排备课。大纲围绕法医学的基本理论、基本知识与基本技能展开覆盖绪论、死亡与尸体现象、机械性损伤、机械性窒息、高温低温及电流所致损伤、猝死、强奸与杀婴、活体法医学鉴定、中毒、亲子鉴定、生物性检材个人识别等章节并逐章列出目的要求、教学内容与重点难点附有课程性质、教材与参考书、考核方式及学时分配。资源包内含1个PDF文件整体约19KB篇幅精简、便于打印与随身查阅属提纲式文档而非教材正文。目前已有58人学习下载适合临床医学学生复习备考及教师备课参考。1. 一份 5 页教学大纲 PDF为什么值得用程序拆一遍教务老师把《临床医学五年制法医学课程教学大纲》发过来的时候绝大多数人的第一反应是打开 PDF 复制粘贴进 Excel。5 页、课程编码 100105、30 学时 3 学分、12 个章节外加解剖录像和案例讨论看上去半小时能收工。真动手才发现处处是坑学时数写成全角的「学时」正文章标题标称的学时和末尾那张没有表格线的分配表可能对不上「掌握 / 熟悉 / 了解」三档教学目标跟具体知识点的对应关系全埋在段落里六条参考书目又是一行行没有分隔符的长串。全角半角混排、无框线表格、章 / 节 / 条目 / 子条目四层编号叠在同一个页流里这是 PDF 结构化抽取最容易翻车的组合。pdftotext 出来的纯文本人能读但喂不进教务系统做学时学分对账也没法切成检索片段进医学知识库。下面就把这 5 页当成一个真实的半结构化语料样本从文本层判定一路做到结构化入库和切片检索。做教务系统对接、医学教育数据治理、或者要给课程大纲建检索索引的工程师步骤可以直接抄。2. 文本层判定与教学大纲版式解析2.1 先确认这份 PDF 有没有文本层扫描件和文本层 PDF 的处理路径完全不同动手前必须先分流。pdfinfo 法医学教学大纲.pdf # 看 Pages / Page size pdffonts 法医学教学大纲.pdf # 看嵌入字体空输出≈扫描件 pdftotext -layout -f 1 -l 5 法医学教学大纲.pdf out.txt wc -l out.txt head -40 out.txtpdfinfo确认页数和纸张尺寸这份是 5 页。pdffonts列出所有嵌入字体如果输出为空基本可以判定是纯图像扫描需要走 OCR 分支。pdftotext -layout保留版面空白是判断「有没有可用字符流」最快的办法中文能正常读出、栏位没串行说明文本层质量可用。注意pdffonts有字体但pdftotext出来是乱码或缺字多半是字体子集没带 ToUnicode CMap。这种情况不要反复调 pdftotext 参数直接换 pdfplumber 取字符坐标或者降级 OCR。2.2 用 pdfplumber 拿字符坐标重建行纯文本丢掉了 x/y 坐标而版式信息哪一列、哪个层级恰恰藏在坐标里。import pdfplumber from collections import defaultdict def iter_lines(page, y_tol2.5, x_tol1.5): 按字符纵坐标聚类成行行内按横坐标排序拼接。 words page.extract_words(use_text_flowFalse, keep_blank_charsFalse, x_tolerancex_tol, y_tolerancey_tol) rows defaultdict(list) for w in words: rows[round(w[top] / y_tol)].append(w) # 纵坐标量化成桶 for key in sorted(rows): row sorted(rows[key], keylambda w: w[x0]) # 行内从左到右 yield key * y_tol, row, .join(w[text] for w in row)逻辑说明extract_words已经做了初步成词但中文 PDF 的词切分经常把「第一章」和「绪论」拆成两个 word所以行内拼接时不加空格靠后面的归一化再补。y_tolerance控制同一行的判定阈值中文正文行距通常在 1420pt取 2.5 比较稳如果 PDF 有轻微旋转或字块基线浮动可以放大到 3.5代价是相邻两行可能被并成一行。参数上还有一个容易忽略的点x_tolerance调大会把「30/ 3」这种中间带空格的字段粘成一个词调小又会把「教学时数」拆开。实测这套文档 1.5 是平衡点。真正的兜底手段是后面按 x 坐标区间分列而不是指望x_tolerance。2.3 全角数字、断行章节号和散落编号的归一化原始文本里有大量非规范写法先归一化再写正则能省掉一半的匹配分支。import re FULLWIDTH str.maketrans(, 0123456789().,;:) CN 一二三四五六七八九十 def normalize(line: str) - str: s line.translate(FULLWIDTH).replace(\u3000, ) s re.sub(r(?[\u4e00-\u9fa5])\s(?[\u4e00-\u9fa5]), , s) # 中文词间多余空格 s re.sub(r第([ CN r]{1,3})章, r第\1章 , s) # 章号后补空格 s re.sub(r^[.·]*\s*(\d{1,2})\s*[.]\s*, r\1. , s) # 行首残号修复 return s.strip()归一化前后的典型对照原始形态出现位置处理规则结果学时章标题末全角转半角(2学时)第一章绪 论章标题章号补空格 去字间空格第一章 绪论5法医学史教学内容条目剥离行首残留圆点5. 法医学史30/ 3课程信息头斜杠后去空格30/3解剖录像学时无章号段落补虚拟章次 1313. 解剖录像最后一行的「解剖录像」和「案例讨论」在原文里没有章号但占独立学时必须补上虚拟章次否则学时对账时会漏掉 4 个学时。有了归一化行类型判定就可以用一组短正则行类型正则示例章标题^第[一二三四五六七八九十]章第三章 机械性损伤小节标签^【(目的与要求|教学内容|重点与难点)】【重点与难点】目标条目^\d{1,2}\.\s*(掌握|熟悉|了解)1. 掌握脑死亡的概念学时括号(\d)\s*学时3 学时2.4 课程信息头与参考书目的切分锚点文档开头这一段是真麻烦。教材行原文是「法医学人民卫生出版社王保捷2001 年 9 月第 3 版」书名、出版社、主编、出版时间四个字段完全没有分隔符。硬用正则拆不出来可行的做法是拿出版社做锚点。PRESS r(人民卫生出版社|科学出版社|上海科学技术出版社|北京大学医学出版社) def split_book(line: str): m re.search(PRESS, line) if not m: return {title: line, press: None, rest: None} return { title: line[:m.start()].strip(), # 锚点左侧是书名 press: m.group(1), rest: line[m.end():].strip(), # 右侧是主编年月版次 } def parse_rest(rest: str): year re.search(r(\d{4})\s*年, rest) month re.search(r(\d{1,2})\s*月, rest) ed re.search(r第\s*(\d)\s*版, rest) author rest[:year.start()].strip() if year else rest return {author: author, year: int(year.group(1)) if year else None, month: int(month.group(1)) if month else None, edition: int(ed.group(1)) if ed else None}思路是「先锚定出版社再在右侧串里锚定年份」。主编名一定在出版社和年份之间年份和版次之间有固定中文串这两段锚点比任何通用分词都稳。参考书目六条会得到书名、出版社、主编、年月的结构化结果后续做教材版本比对或者书目去重都能直接用。3. 学时分配表结构化从有线表格到坐标聚类3.1 extract_tables 在无框线表上为什么失灵pdfplumber 的extract_tables默认按线框lines或文字边缘text来推断单元格边界。这份大纲末尾的分配表没有完整的可见边框直接调会返回空表或者把「章次」和「教学内容」两列粘成一格。import pdfplumber with pdfplumber.open(法医学教学大纲.pdf) as pdf: page pdf.pages[-1] print(page.extract_tables()) # 依赖线框 print(page.extract_tables({vertical_strategy: text, horizontal_strategy: text})) # 依赖文字边缘两种策略都拿不到结构时不要再调text_tolerance之类的参数直接退到坐标聚类。原因是这张表右侧「22/0」这种字段本身没有空白分隔文字边缘策略会把理论学时和实验学时判成一个单元格越调越乱。3.2 按表头锚点做三列聚类的落地方案关键是不写死 x 阈值而是从表头词的坐标算出分栏边界换纸张尺寸也不会崩。def parse_hours_table(page, lines): # 1) 定位表头行取三个表头词的起点 head_idx next(i for i, (_, _, t) in enumerate(lines) if 教学时数 in t) _, head_words, _ lines[head_idx] anchors sorted((w[x0], w[text]) for w in head_words) c1 next(x for x, t in anchors if t.startswith(章)) c2 next(x for x, t in anchors if t.startswith(教学)) c3 next(x for x, t in anchors if t.startswith(教学时) or 时数 in t) b12, b23 (c1 c2) / 2, (c2 c3) / 2 # 取中点作分栏边界 rows [] for _, words, text in lines[head_idx 1:]: if 合计 in text: break cols {0: [], 1: [], 2: []} for w in words: idx 0 if w[x0] b12 else (1 if w[x0] b23 else 2) cols[idx].append(w[text]) rows.append({chapter_no: .join(cols[0]).strip(), title: .join(cols[1]).strip(), hours_raw: .join(cols[2]).strip()}) return rows逻辑说明表头三个词的水平起点天然把表格切成三栏中点就是最安全的分界线。此后每一行的 word 只要落到哪个区间就归哪一栏.join不加空格因为中文单元格本身就是连写。参数说明hours_raw里的22/0要再抽一次数字。括号内的「理论/实验」分子分母都是 0 的实验学时说明这门课全程无实验这一点在后续统计里很重要——它决定了学时校验时加不加实验项。import re def parse_hours(raw: str): m re.match(r(\d)\s*[(]\s*(\d)\s*/\s*(\d)\s*[)], raw) if not m: return None return {total: int(m.group(1)), theory: int(m.group(2)), lab: int(m.group(3))}3.3 正文标称学时与分配表学时对账这一步是整条链路最有价值的产出因为它能直接暴露文档本身的不一致。def hours_from_body(lines): out {} for _, _, t in lines: m re.match(r^第([一二三四五六七八九十])章\s*(.?)(\d)\s*学时, t) if m: out[cn2int(m.group(1))] (m.group(2).strip(), int(m.group(3))) return out def reconcile(body, table): diff 0 for no, (title, h_body) in sorted(body.items()): row next((r for r in table if r[chapter_no] str(no)), None) if row is None: print(f[缺行] 第{no}章 {title}) continue h_tab parse_hours(row[hours_raw])[total] if h_body ! h_tab: print(f[学时不一致] 第{no}章 {title}: 正文标题标 {h_body}分配表 {h_tab}) diff h_tab - h_body return diff跑完这份大纲结果如下章次章名正文标称分配表一致1绪论22是2死亡与尸体现象33是3机械性损伤33是4机械性窒息33是5高温、低温及电流所致损伤与死亡11是6猝死11是7强奸、杀婴11是8活体法医学鉴定32否9中毒33是10亲子鉴定22是11生物性检材的个人识别22是12医疗纠纷33是第八章正文标题写「3 学时」分配表写 2 学时差 1。这个差值不是噪声而是口径问题的信号把 12 个正课章按分配表求和是 26加上「解剖录像」2 和「案例讨论」2 正好 30与课程信息头的「30/3」吻合按正文标称求和则是 27加 4 得 31。提示学时校验必须先选定单一权威口径。稳妥做法是以分配表为准把正文标称值单独存成hours_body字段两者都入库、不覆盖让差异在报表里暴露出来而不是在清洗阶段悄悄抹平。4. 教学目标动词分级抽取与 JSON 落库4.1 「掌握 / 熟悉 / 了解」三档在数据模型上怎么表达教学大纲的骨架其实是每个知识点挂一个认知层级。三档动词不是修辞是可量化的教学要求落库时应该映射成整数等级。原文动词认知层级level取值典型误判掌握应用 / 分析3「熟练掌握」「重点掌握」需要归一到 3熟悉理解2段落中出现「熟悉」但属于上一级条目的续行了解记忆 / 识记1与「了解…有关内容」这种收尾句混在一起鉴别 / 评估 / 推断实操性掌握要求3常藏在句尾不在条目首个动词位置最后一行是最容易被漏掉的。像「生前溺死与死后抛尸入水的鉴别」这类内容句子开头的动词可能是「掌握」但真正的能力要求是「鉴别」做检索或题库生成时需要额外把这类动词打上标签。4.2 按条目编号切段动词决定层级LEVEL {掌握: 3, 熟悉: 2, 了解: 1} ITEM re.compile(r^(\d{1,2})\.\s*(掌握|熟悉|了解)(.*)$) STOP (【目的与要求】, 【教学内容】, 【重点与难点】) def parse_objectives(lines): objs, cur [], None for _, _, raw in lines: t normalize(raw) if t.startswith(【): if t.startswith(STOP[0]): cur None # 离开目的与要求区停止续行拼接 continue m ITEM.match(t) if m: cur {seq: int(m.group(1)), verb: m.group(2), level: LEVEL[m.group(2)], text: m.group(3).strip(。; )} objs.append(cur) elif cur and cur[text] and not re.match(r^第.章, t): cur[text] t.strip() # 续行拼回同一条目 return objs逻辑说明ITEM只匹配行首编号加动词避免把正文里的「1、」「1」误吞。续行拼接的终止条件有两个——遇到下一个【标签】用STOP[0]判断是否离开目标区或遇到章标题正则。这两个边界缺一个条目文本就会串到下一章去。参数说明normalize必须先跑否则「5法医学史」这种带残留圆点的行会让ITEM直接漏匹配导致条目编号出现空洞。跑完后建议加一条断言同一章内的seq必须从 1 连续到 N否则打印章号和缺失序号。4.3 结构化 Schema 与 SQLite 落库抽出来的东西建议先落成 JSON 做中间态方便人工抽查再入 SQLite 供查询。{ course: { code: 100105, name: 法医学, en: Forensic Medicine, term: 学年第二学期, credits: 3, hours: 30, type: 必修, major: 临床医学五年制 }, chapters: [ { no: 2, title: 死亡与尸体现象, hours_body: 3, hours_table: 3, objectives: [ {seq: 1, verb: 掌握, level: 3, text: 脑死亡的概念及诊断标准法医学的死亡分类、早期尸体现象、晚期尸体现象}, {seq: 2, verb: 熟悉, level: 2, text: 死亡的概念、死亡的过程及超生反应、死后经过时间的推测} ], key_points: [早期尸体现象, 晚期尸体现象] } ] }CREATE TABLE course ( code TEXT PRIMARY KEY, name TEXT, en TEXT, term TEXT, credits INTEGER, hours INTEGER, type TEXT, major TEXT ); CREATE TABLE chapter ( course_code TEXT NOT NULL REFERENCES course(code), chapter_no INTEGER NOT NULL, title TEXT NOT NULL, hours_body INTEGER, hours_table INTEGER, PRIMARY KEY (course_code, chapter_no) ); CREATE TABLE objective ( course_code TEXT NOT NULL, chapter_no INTEGER NOT NULL, seq INTEGER NOT NULL, level INTEGER NOT NULL CHECK (level BETWEEN 1 AND 3), verb TEXT NOT NULL, content TEXT NOT NULL, PRIMARY KEY (course_code, chapter_no, seq) ); CREATE INDEX idx_obj_level ON objective (course_code, level);objective表用(course_code, chapter_no, seq)做复合主键天然防重跑插入。hours_body和hours_table两列并存正是为了保住第 3 章发现的那处 1 学时差异让下游系统自己决定采信哪一列。写入用executemany批量提交5 页文档量级下没必要上 ORMimport sqlite3 conn sqlite3.connect(syllabus.db) conn.executemany( INSERT OR REPLACE INTO objective VALUES (?,?,?,?,?,?), [(code, ch, o[seq], o[level], o[verb], o[text]) for ch, objs in parsed.items() for o in objs]) conn.commit()入库之后的第一个实用查询是看哪几章的「掌握级」要求最密集——这直接对应学生的复习权重SELECT c.chapter_no, c.title, SUM(CASE WHEN o.level 3 THEN 1 ELSE 0 END) AS must_cnt, SUM(CASE WHEN o.level 1 THEN 1 ELSE 0 END) AS know_cnt FROM chapter c JOIN objective o ON o.course_code c.course_code AND o.chapter_no c.chapter_no WHERE c.course_code 100105 GROUP BY c.chapter_no, c.title ORDER BY must_cnt DESC;用SUM(CASE WHEN ...)而不是COUNT(*) FILTER是为了兼容 3.30 以下的 SQLite 版本教务系统里跑的旧版本比例不低。5. 大纲结构化后的巡检规则与检索切片5.1 四条能自动跑的巡检规则结构化数据最大的价值是让文档缺陷变得可枚举。这套大纲跑下来值得固化成定时任务的检查有四条一是学时双口径差额SUM(hours_table) 4必须等于course.hours二是章号连续性虚拟章次 13解剖录像、14案例讨论之外不允许有空洞三是目标条目完整性任何一章的objectives为空即告警通常是【目的与要求】标签被断行拆散了四是重点难点对齐key_points里的词应当能在同章objectives文本中找到至少一次子串命中。def lint(course, chapters): issues [] if sum(c[hours_table] for c in chapters) 4 ! course[hours]: issues.append((学时合计, 分配表口径与课程总学时不符)) for c in chapters: if not c[objectives]: issues.append((f第{c[no]}章, 目的与要求区未抽到条目)) joined .join(o[text] for o in c[objectives]) for kp in c[key_points]: if kp not in joined: issues.append((f第{c[no]}章, f重点[{kp}]未出现在目标条目中)) return issues第四条规则在其它大纲上命中率意外地高因为写大纲的人经常在正文里改重点忘了同步到目标条目。5.2 按「章 小节标签」切片别按字数切给医学知识库做切片时固定字数切会把「掌握」的条目和「了解」的条目混进同一片检索出来的上下文层级是乱的。正确做法是以章为外层、以标签为内层切每片带上完整元数据。def build_chunks(ch, max_len320): chunks [] for tag, items in ((objectives, ch[objectives]), (key_points, ch[key_points])): buf [] for it in items: text it[text] if isinstance(it, dict) else it meta f第{ch[no]}章 {ch[title]} if isinstance(it, dict): meta f L{it[level]} buf.append(f{meta} | {text}) if sum(len(x) for x in buf) max_len: chunks.append(\n.join(buf)); buf [] if buf: chunks.append(\n.join(buf)) return chunks元数据前缀里带L3 / L2 / L1是关键。学生问「哪些内容必须掌握」用level3做前置过滤再走向量召回能有效滤掉「了解」级噪声——这批大纲里了解级条目占了将近一半不过滤的话召回精度会掉得很难看。提示切片文本里保留「第X章 章名」前缀还会顺带解决一个检索问题——同一门课里「鉴定」这个词在机械性损伤、机械性窒息、医疗纠纷三章都出现没有章级前缀时向量距离几乎一样模型分不清问的是哪一章。一条可用的验证语句查询「生前溺死与死后抛尸入水怎么鉴别」正确命中应该是第四章的重点难点切片元数据前缀为第4章 机械性窒息 L3。如果召回落到第五章「高温、低温及电流所致损伤与死亡」说明切片里丢了key_points标签域把两类「死后/生前」表述混在了一起——这个失败模式在只按章节切、不区分标签域的方案里几乎必然出现。本文还有配套的精品资源点击获取
返回列表