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

资讯详情

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

需求分析考试题PDF自动归类:规则引擎+AI兜底全攻略

需求分析考试题PDF自动归类:规则引擎+AI兜底全攻略

简介:一份面向软件工程课程与期末备考的需求分析考试知识点归类PDF,系统梳理了需求分类及相互关系、软件过程的概念与分类、软件需求工程六个阶段、软件体系结构与B/S结构、用户界面设计三部分,以及数据库结构设计与行为设计等核心考点。内容以问答形式展开,覆盖需求分析定义与基本任务、结构化分析方法及其描述工具(数据流图、数据字典、结构化语言、判定表、判定树等),并对业务需求、用户需求、系统需求的关系进行细致说明;同时涉及软件过程中获取、供应、开发、运行、维护等基本过程的具体内容,以及需求规格说明文档的作者(涉众)与半形式化/形式化表现手段,可帮助读者快速搭建知识框架、应对常见考试题型。资源为单个PDF文档,压缩包约222KB,轻量便于下载打印或导入笔记软件。目前已有213人学习浏览,适合软件工程、计算机相关专业学生及需求分析备考者使用。

1. 需求分析考试题归类:把散落的PDF变成能复习的知识清单

期末前一周,电脑里躺着十几份需求分析课程的考试题PDF,有的是老师发的往年真题,有的是从文印店扫描的纸质卷,还有从课程群里转来的各种模拟卷。想按“需求获取”“用例建模”“SRS规格说明”逐个知识点刷题,却发现每份PDF的排版都不一样,题号不连续、页眉全是课程名、表格里的需求条目挤成乱码。需求分析考试题归类.pdf 这个需求,核心就一句话:把一批原始PDF考试题,抽成结构化文本,按知识域、题型、难度映射成一份可检索、可统计、可重跑的题库清单。这篇文章写给两类人:一类是准备期末复习、想按知识点集中刷题的考生;另一类是需要给题库做标签的老师或培训讲师。顺序我按自己实际跑通的管线来写:PDF解析、文本清洗、归类规则、批量落盘、AI兜底,每一步都带能直接改的代码和参数。

2. 把PDF变成可归类的文本:抽文本、清噪声、判题型三步走

2.1 先看需求分析考题长什么样:四个知识域和三类题型

拿任何一所高校的需求分析考试题来看,题目通常不会跑出软件工程教材的范围。我习惯把需求分析的知识点压成四个域:需求获取(访谈、问卷、观察、原型法)、需求建模(用例图、类图、数据流图DFD、状态转换图)、需求规格说明(SRS的结构、功能与非功能需求、质量属性)、需求验证与管理(评审、测试用例对照、变更控制、需求追溯)。

题型从PDF的版面特征上就能判断:选择题有“A. B. C. D.”或“( )”,填空题有横线或“____”,简答题开头是“简述”“说明”“列举”,案例分析题则会有“阅读以下描述”“根据以上需求”这类带上下文的引导语。把题型和知识域交叉,就是归类的最终标签,例如“简答题-需求建模-用例图”和“选择题-需求获取-访谈”。

归类前必须解决一个现实问题:PDF本身是排版产物,不是文本数据。即使是用Word另存的PDF,页眉、页脚、页码、题目编号都和正文混在一个流里。如果直接拿字符串去做关键词匹配,一个页眉就可能被当成一道题,所以必须先把PDF拆成可控的块:逐页读取、逐行判断,把版面上的位置信息和文本内容一起保留下来。

2.2 PDF解析:用pdfplumber抽页、抽表格、抽坐标

我常用的PDF抽取工具是pdfplumber,它比PyPDF2多一个能力:能拿到每个字符或单词的坐标。坐标对后续清洗非常关键——页眉通常在页面顶部固定区域,页码在底部,题目正文在中间,坐标可以直接做区域裁剪,比纯正则硬匹配稳定得多。

下面这段代码完成原始数据的抽取,把每一页的文本、表格、页面尺寸都存成JSON,为后续清洗打基础。

import pdfplumber import json def pdf_to_raw_json(pdf_path, out_json_path): raw_pages = [] with pdfplumber.open(pdf_path) as pdf: for page_idx, page in enumerate(pdf.pages): text = page.extract_text() tables = page.extract_tables() # 保存页面尺寸与文本,坐标由 text 自带富信息可选 raw_pages.append({ "page": page_idx + 1, "width": page.width, "height": page.height, "text": text, "table_count": len(tables), "tables": tables }) with open(out_json_path, "w", encoding="utf-8") as f: json.dump(raw_pages, f, ensure_ascii=False, indent=2) return len(raw_pages) if __name__ == "__main__": pages = pdf_to_raw_json("exam_2024.pdf", "exam_2024_raw.json") print(f"抽取 {pages} 页")

参数说明:extract_text()默认按PDF内部的阅读顺序返回字符串,遇到分栏版式可能顺序乱,所以我在代码里保留了宽高,一旦发现抽出的文本顺序颠倒,就要在清洗阶段用坐标重排。extract_tables()返回的是二维数组,每个单元格是字符串或None,表格型的案例题后面专门有坑,这里先落盘不处理。

逻辑说明:为什么要存成JSON而不是直接在内存里处理?因为PDF来源杂,一个文件抽出来什么样,你无法预判。先落盘、再洗、再归类,每一步都能回看中间产物,出问题时能定位是哪一步坏了。我自己的习惯是每个PDF都生成一个同名JSON,放在raw目录下,方便重复调整清洗参数。

2.3 清洗规则:去页眉页脚、去页码、还原题号

原始文本拿到后,噪音比想象的多。页眉通常是“需求分析考试题”或“第X页/共X页”,页脚是页码,还有一类很烦人的是题目中间的“第X题”被PDF阅读器自动插入了换行。我的清洗分四步,每一步都是正则或简单规则,不引入依赖:

import re def clean_page_text(text: str, page_num: int, page_height: float, top_margin=60, bottom_margin=60): lines = text.split("\n") cleaned_lines = [] for line in lines: # 只去除正文区域之外的页眉页脚,通过行位置判断依赖 extract_words, # 这里先用正则兜底常见页眉模式 if re.search(r"第\s*\d+\s*页|共\s*\d+\s*页|课程名称|考试时间", line): continue if line.strip().isdigit(): continue # 还原因PDF换行断开的题号,例如 "1." 被拆成 "1" 和 "." if re.match(r"^\d{1,3}$", line.strip()): cleaned_lines.append(line.strip() + ".") continue cleaned_lines.append(line) return "\n".join(cleaned_lines) def split_questions(text: str): # 按题号切分,支持 1. 1、 一、 简答题这样的层级 parts = re.split(r"(?m)^\s*(\d{1,3})[\.、.]\s*", text) questions = [] # parts[0] 是题号之前的说明 for i in range(1, len(parts), 2): q_no = parts[i] q_text = parts[i+1] if i+1 < len(parts) else "" questions.append({"no": q_no, "text": q_text.strip()}) return questions

参数说明:top_margin和bottom_margin定义了坐标裁剪区,但这段代码先用正则处理,因为pdfplumber在纯文本模式下拿不到准确的行坐标。如果你需要更精确的按坐标裁剪,要改用extract_words()拿到每个词的top和bottom,然后过滤top < 60或bottom > page_height - 60的行。80%的PDF用正则可以搞定,剩下20%必须坐标裁剪。

这里有一个关键取舍:题号正则\d{1,3}[\.、.]覆盖了1到999号,但会把“2024年”里的“2024”误拆。所以我建议切分前先把年份、数字开头的专业名词过滤掉,或者在切分后检查每个切出来的片段是否包含题干特征词,比如“下列”“简述”“请说明”,没有特征词的块可以合并到上一题。

2.4 题型判定正则:选择题、填空题、简答题、案例题

题型判定不需要上机器学习,正则就够了。判定顺序要讲优先级:先判案例题,再判简答,然后选填。

def detect_question_type(q_text: str) -> str: # 案例题:题干长,且带有场景描述 if len(q_text) > 150 and re.search(r"系统|项目|场景|用户|业务|流程", q_text): return "案例题" # 填空题:有括号或横线 if re.search(r"(\s*)|\(_\s*\)|____|——", q_text): return "填空题" # 选择题:四个选项 if re.search(r"[A-D][\.、.]\s*", q_text) and re.search(r"正确|错误|下列|属于|是", q_text): return "选择题" # 简答题:特征动词开头 if re.search(r"^(简述|说明|列举|比较|分析|讨论|什么是|为什么|怎样)", q_text.strip()): return "简答题" return "未识别"

逻辑说明:案例题优先是因为它的文本里既可能有“正确”“属于”,也有很长的描述,如果先判选择题就会误判。长度阈值150是我试过的经验值,太短可能是某道简答题被拆碎了,太长的一定是带场景的案例。填空题的横线在不同PDF里形态完全不同,全角横线、下划线、括号空格我都列进去了,你如果遇到“{___}”这种带花括号的,往正则里加即可。

判完题型后还要做一道工序:把题型和后续的知识域映射解耦。题型是语法特征,知识域是语义特征,语法归语法,语义归语义,不要混在一个函数里,否则后面加关键词要改两处。

3. 设计归类规则:用关键词映射知识域,空跑一轮看分布

3.1 词库怎么建:需求工程生命周期是天然字典

需求分析这门课的考点边界比大多数专业课清晰,因为需求工程有标准的生命周期,每个阶段都有专属术语。你不用自己去发明词库,直接照生命周期拆:需求获取、需求分析、需求建模、规格说明、验证确认、变更管理。

每个域我配上干扰词更少的特征词。参考这样一个词库设计:

DOMAIN_KEYWORDS = { "需求获取": ["访谈", "问卷", "观察", "原型", "头脑风暴", "JAD", "联合应用设计", "用户故事", "干系人"], "需求建模": ["用例图", "用例", "类图", "DFD", "数据流图", "状态图", "ER图", "实体联系", "顺序图", "活动图"], "规格说明": ["SRS", "规格说明", "功能需求", "非功能需求", "性能需求", "质量属性", "接口需求", "约束", "验收标准"], "验证与管理": ["评审", "测试用例", "验收测试", "变更", "版本控制", "追溯", "跟踪矩阵", "配置管理", "基线"] }

词库设计的两个原则:一是用“术语”不用“日常词”。比如“系统”这个词在每道题里都出现,放在任何域里都会造成误判,干脆不放。二是“功能需求”和“非功能需求”要分开处理,因为非功能需求归在规格说明域,但题目里常同时出现两个词,我后面用加权而不是命中即得分来缓解。

3.2 归类打分:关键词命中不是非0即1,要加权

简单统计每个域的关键词命中次数再取最大值,效果通常不差,但有两个问题:一是需求获取和需求建模两个域的边界词多,比如“用户故事”既是获取手段也是建模输入;二是题干长,一个词在案例题里出现多次,权重会被放大。我的做法是给每个关键词一个权重,并用TF(词频)和覆盖度两个指标共同决定。

from collections import Counter def score_question(q_text: str, domain_keywords: dict) -> dict: q_lower = q_text.lower() scores = {} for domain, kws in domain_keywords.items(): hit_count = 0 hit_weight = 0 for kw in kws: # 统计每个关键词出现次数 cnt = q_lower.count(kw.lower()) if cnt > 0: hit_count += 1 # 权重=关键词长度,越长越具体,给分越高 hit_weight += len(kw) * min(cnt, 3) # 覆盖度 = 命中的关键词数占该域总关键词数的比例 coverage = hit_count / len(kws) scores[domain] = { "hit_keywords": hit_count, "weight": hit_weight, "coverage": round(coverage, 3) } return scores def decide_domain(scores: dict, weight_threshold=12, coverage_threshold=0.15): best = None best_score = 0 for domain, s in scores.items(): # 覆盖率低但命中词长时,说明是关键术语强信号 combined = s["weight"] + s["coverage"] * 30 if combined > best_score: best = domain best_score = combined # 低于阈值的返回未归类 if best_score < weight_threshold: return "未归类" return best

参数说明:weight_threshold=12意味着至少要命中几个短词或一个长术语才敢下结论。coverage * 30把覆盖率折算到和权重同一量纲,覆盖率提升0.1等于直接加3分权重。这个30是经验值:一个域的覆盖率达到30%时,基本可以判定题目属于该域,那么coverage=0.3给9分,换算成关键词权重大约是2到3个短词的命中强度。你可以按自己的题库微调,我没法给出一个通吃参数。

为什么非0即1不行?因为一道案例题通常跨多个域。比如“针对某图书馆系统的用例建模,并分析其非功能需求”,如果只按命中数,用例建模与规格说明各命中一个词,平票。但按权重,“用例建模”5个字,“非功能需求”5个字,还是平票,此时结合覆盖率——用例模型域如果一共10个词命中1个是0.1,规格说明域也是0.1,继续平票。这时候我的做法不是强行归一个,而是标记为“跨域”,让后续人工确认。

3.3 空跑结果怎么看:优先查“未归类”和“跨域”两条队列

规则引擎写完后,第一次跑一定会有大量未归类。不要急着调阈值,先把所有未归类题目打印出来,人工扫一遍。

import json def run_classification(raw_json_path, output_path="classified.json"): with open(raw_json_path, "r", encoding="utf-8") as f: pages = json.load(f) results = [] for page in pages: text = page["text"] questions = split_questions(clean_page_text(text, page["page"], page["height"])) for q in questions: scores = score_question(q["text"], DOMAIN_KEYWORDS) domain = decide_domain(scores) q_type = detect_question_type(q["text"]) results.append({ "page": page["page"], "no": q["no"], "text": q["text"], "domain": domain, "scores": scores, "type": q_type }) with open(output_path, "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2) # 打印未归类清单 unclassified = [r for r in results if r["domain"] == "未归类"] for r in unclassified[:30]: print(f"第{r['page']}页 题{r['no']}: {r['text'][:50]}") return results

我的经验是:第一轮跑完,重点看不该未归类却未归类的题。如果一道题里明显有“数据流图”几个字却没归入建模域,多半是题干被清洗时拆碎了,导致关键词分散在不同的题块里。先处理拆题问题,再调阈值。反过来,如果归类结果里每个域都有大量题目,且跨域率超过30%,说明你的词库区分度不够,此时不是调权重,而是删词——把“系统”“需求”这类高频泛义词从词库里挑出来删掉。

4. 学会归类,先躲过这四个坑:扫描件、跨页题、表格题、编码

4.1 扫描版PDF抽出来全是乱码,以为是库坏了

现象:用pdfplumber读扫描版PDF,返回的文本是一串类似“\x00\x10”的乱码,或者干脆是空字符串,但PDF在阅读器里显示正常。

原因:PDF分为文本型(文字可选中复制)和图像型(整页是图片)。扫描版属于图像型,pdfplumber只处理内容流里的文字对象,不会做OCR。

解决:先用pdfplumber把每一页导出成PNG图片,再交给OCR引擎识别。本地用Tesseract,要求不高时可以用百度或腾讯的OCR接口,注意按图片张数付费。下面是本地OCR的最小路径:

# 安装 tesseract 和中文语言包(Debian/Ubuntu 系) sudo apt install tesseract-ocr tesseract-ocr-chi-sim
import pdfplumber import pytesseract from PIL import Image def pdf_to_ocr_json(pdf_path, out_json_path, dpi=200): raw_pages = [] with pdfplumber.open(pdf_path) as pdf: for page_idx, page in enumerate(pdf.pages): # 导出高清位图,dpi过低会损失小字 im = page.to_image(resolution=dpi).original.convert("RGB") text = pytesseract.image_to_string(im, lang="chi_sim") raw_pages.append({"page": page_idx + 1, "text": text}) with open(out_json_path, "w", encoding="utf-8") as f: json.dump(raw_pages, f, ensure_ascii=False, indent=2) return len(raw_pages)

参数说明:dpi=200是我调试出来的平衡点,低于150时选择题的小字号选项容易识别成乱码,高于300时耗时翻倍且对低质量扫描件没有提升。OCR后的文本没有坐标信息,因为Tesseract的image_to_string不返回单词位置,如果你需要按区域清洗,要改用image_to_data拿每个词的坐标。

这个坑的隐蔽之处在于:OCR识别成功不代表文本能直接用于归类。扫描件的“1.”可能被识别成“1.”也可能识别成“1。”,全角逗号可能被识别成空格,所以在清洗阶段要对OCR文本额外做一遍符号归一化。

4.2 一道案例题跨了两页,被拆成两道题

现象:一个案例题明明占了半页,但在题号切分后变成两道题,后半道题没有题号,却被当成上一题的延续或直接漏掉。

原因:PDF的extract_text是按页返回的,跨页的题目在页边界处被截断。我的split_questions是逐页处理的,不跨页合并,所以片段的第二段没有题号,正则切不出来。

解决:先跨页合并文本,再做题号切分。常见做法是整个文档的文本先按页拼接,页与页之间不插入分隔符,让题号切分器有机会跨页识别:

def merge_pages_text(pages): # 合并所有页文本,不插入额外标记,避免影响题号切分 merged_lines = [] for page in pages: text = page["text"] if not text: continue lines = text.split("\n") merged_lines.extend(lines) return "\n".join(merged_lines) with open("exam_2024_raw.json", "r", encoding="utf-8") as f: pages = json.load(f) full_text = merge_pages_text(pages) questions = split_questions(full_text)

但这里有一个新问题:上一页末尾的页眉页脚也会被拼接进来,需要在合并之前先做逐页清洗。我的顺序是:逐页清洗 → 合并 → 题号切分 → 按题号分组 → 判断题型。顺序不能反,合并后文本里的页码数字会伪装成题号。

4.3 表格形式的需求条目被抽成碎片,归类直接失效

现象:案例题里包含一张表格,表格里是“编号 需求描述 优先级”,extract_tables()返回了二维数组,但extract_text()返回的文本里表格内容被挤在一行,或者表格线把单元格内容拆成不连续的片段。

原因:PDF表格的文本在内容流里按单元格顺序存储,extract_text()尝试按视觉位置重排,但遇到合并单元格和跨行单元格时经常失败。

解决:对含表格的页面,先判断table_count > 0,把表格数据转成结构化文本,再拼接回原页面的文本流:

def table_to_text(tables): lines = [] for table in tables: for row in table: # 单元格为 None 时留空,否则用 | 串联 cells = [c.replace("\n", " ") if c else "" for c in row] lines.append(" | ".join(cells)) return "\n".join(lines) def extract_page_text_with_tables(page): text = page.extract_text() or "" tables = page.extract_tables() if tables: table_text = table_to_text(tables) text = text + "\n" + table_text return text

这里的坑在于:把表格文本拼接到正文后,表格内容会参与题型判定和知识域打分,导致一道简答题因为表格里有“用例图”三个字被误判成案例题。我的处理方法是:表格文本拼接时加一个特殊前缀,在题型判定阶段先剔除表格区,只对正文区判题型,知识域评分则有权重地对表格内容打折。

4.4 中文命中的“需求”和“非功能需求”被一视同仁

现象:一道题是“请说明该系统的非功能需求有哪些”,归入规格说明域没问题。但另一道题是“需求获取的常用方法有哪些”,命中了“需求”却匹配到“需求获取”域,因为“需求”二字也在获取域的泛义词里。

原因:我的初始词库如果带了“需求”这种超高频词,打分时每个域都会命中,覆盖率被稀释,判不出来。这是规则引擎的经典问题:词频越高,区分度越低。

解决:删掉“需求”“系统”这类全域网词,或者给它们极低的权重。更稳妥的做法是使用否定词过滤器——如果题干出现“需求获取的常用方法”,应强制归入需求获取域而不是简单打分:

FORCE_RULES = [ ("需求获取", ["常用方法", "获取手段", "获取方式", "访谈过程"]), ("需求建模", ["绘制", "画", "建模", "设计类图", "画出"]), ("规格说明", ["编写", "撰写", "SRS", "格式"]), ] def force_decide(q_text: str): for domain, kws in FORCE_RULES: for kw in kws: if kw in q_text: return domain return None

强制规则是把双刃剑。它能让明显指向某域的题快速归位,但如果规则写得宽,会覆盖掉打分结果。我的用法是:强制规则优先级最高,但只保留语义指向非常明确的短语,宁可漏掉也不要误判。漏掉的会进“未归类”队列,人工一刷就补回来了;误判的混在正确结果里,反而难查。

5. 批量归一整目录:文件命名、Excel汇总、可重跑

5.1 文件和目录组织:按知识域建文件夹,原文件名保留

单份PDF跑通后,真正的使用场景是批量处理一个目录下的全部PDF。我的文件组织方式是:原始PDF放在pdfs/目录,中间产物放在raw_json/,最终归类结果放在output/下,按知识域分文件夹,每个文件夹里放该域的题目片段(Markdown格式)。

from pathlib import Path import shutil def organize_by_domain(classified_results, pdf_name: str, output_root="output"): # 先清空该文件的旧输出目录,保证幂等 out_dir = Path(output_root) / pdf_name.stem if out_dir.exists(): shutil.rmtree(out_dir) for domain in ["需求获取", "需求建模", "规格说明", "验证与管理", "未归类"]: (out_dir / domain).mkdir(parents=True, exist_ok=True) # 按域写入题目,文件名带题型和原题号 for idx, item in enumerate(classified_results): domain_dir = out_dir / item["domain"] safe_title = f"{idx + 1:03d}_{item['type']}_原题{item['no']}.md" with open(domain_dir / safe_title, "w", encoding="utf-8") as f: f.write(f"来源: {pdf_name.name} 第{item['page']}页\n\n") f.write(item["text"])

命名里带上原PDF名,非常重要。归到不同域的题目,如果丢了来源信息,后期回查原卷会非常痛苦。idx序号只是为了在同一域内稳定排序,不承担引用作用。

5.2 汇总表输出:用openpyxl写classification.xlsx

按域建目录适合人工翻阅,但需要统计分布或做筛选时,还是Excel方便。openpyxl写xlsx不需要Excel环境,直接生成表格:

from openpyxl import Workbook from openpyxl.styles import Font, PatternFill def write_excel(results, output_path="classification.xlsx"): wb = Workbook() ws = wb.active ws.title = "归类结果" ws.append(["页码", "题号", "题型", "知识域", "题目摘要", "完整内容"]) # 表头样式 for cell in ws[1]: cell.font = Font(bold=True) cell.fill = PatternFill("solid", fgColor="DDDDDD") for item in results: ws.append([ item["page"], item["no"], item["type"], item["domain"], item["text"][:50].replace("\n", " "), item["text"] ]) # 列宽调整 ws.column_dimensions["A"].width = 6 ws.column_dimensions["B"].width = 8 ws.column_dimensions["C"].width = 10 ws.column_dimensions["D"].width = 14 ws.column_dimensions["E"].width = 50 ws.column_dimensions["F"].width = 80 wb.save(output_path)

参数说明:题目摘要列的宽度是50,完整内容是80,这些是我日常用的宽度,如果你的题目很长,F列建议改成自动换行:ws.cell(font=Font(wrap_text=True))。汇总表的用途有两个:一是快速按“知识域+题型”筛选出薄弱环节,二是把“未归类”行单独抽出,给后续人工确认或AI兜底用。

要不要生成这份Excel,取决于你是否需要拿归类结果做统计。如果只是自己复习,按域分文件夹更直观,打开目录就能刷题。Excel更像交付物,适合老师或者小组共用。

5.3 可重跑:脚本幂等,输入输出路径分离

批量处理最怕一件事:昨天跑的结果和今天不一样。不一致通常源于三个地方:中间产物没落盘、路径写死、有随机性。我规定这套脚本必须满足三个条件:输入路径从命令行参数传入,输出目录每次自动清空重建,所有中间产物JSON标记好生成时间和输入文件的哈希值。

python run_pipeline.py --pdf-dir ./pdfs --raw-dir ./raw_json --out-dir ./output --excel ./classification.xlsx
import argparse import hashlib def file_hash(path: str) -> str: h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): h.update(chunk) return h.hexdigest() def main(): parser = argparse.ArgumentParser(description="需求分析考试题归类管线") parser.add_argument("--pdf-dir", required=True, help="原始PDF目录") parser.add_argument("--raw-dir", default="raw_json", help="中间JSON目录") parser.add_argument("--out-dir", default="output", help="归类输出目录") parser.add_argument("--excel", default="classification.xlsx", help="汇总表") args = parser.parse_args() # 对每个PDF生成中间JSON,文件名带哈希前8位,避免重复 for pdf_path in Path(args.pdf_dir).glob("*.pdf"): hash_tag = file_hash(pdf_path)[:8] raw_json_path = Path(args.raw_dir) / f"{pdf_path.stem}_{hash_tag}.json" # 如果中间产物存在且哈希一致,跳过解析 if raw_json_path.exists(): continue pdf_to_raw_json(str(pdf_path), str(raw_json_path))

这里的幂等逻辑是:PDF的MD5没有变,就用上一次解析的中间结果,只有文件变了才重新解析。这听起来像小事,但当你把词库调了三版、重新跑了好几次、最后发现某个PDF被解析了五遍时,就知道中间产物缓存值多少钱了。

6. 用AI兜底未归类题:参数低温度、输出JSON、人工抽验Kappa

规则引擎跑完后,总会剩下一批“未归类”和“跨域”的题目。这不是缺陷,而是特性——说明这些题目的表达方式超出了词库覆盖范围。我最后的处理方式是:把未归类队列批量喂给本地大模型做二次分类,但绝不直接采用模型结果,而是走抽验流程。

调用本地模型的代码很简单,关键是参数。温度必须调低,尽量设为0,分类任务不需要创造性。输出格式强制要求JSON,否则后续解析会翻车:

import requests import json def llm_classify(question_text: str, api_url="http://localhost:11434/v1/chat/completions"): prompt = """你是需求分析课程的出题老师。下面这道题属于需求获取、需求建模、规格说明、验证与管理中的哪一类? 只输出JSON,格式为 {"domain": "其中一个选项", "reason": "一句话理由"}。 题目:""" + question_text resp = requests.post(api_url, json={ "model": "qwen2.5:7b", "messages": [{"role": "user", "content": prompt}], "temperature": 0, "max_tokens": 200, "response_format": {"type": "json_object"} }, timeout=60) content = resp.json()["choices"][0]["message"]["content"] return json.loads(content)

参数说明:temperature=0是分类任务的硬性要求,大于0.5时同一个题每次跑结果都可能变。max_tokens=200足够返回JSON而不会浪费算力。这里的API格式兼容OpenAI协议,所以你用vLLM或Ollama起的服务都能接。model="qwen2.5:7b"是我的本地环境,你可以换自己的模型名。

模型返回后,我用一个简单策略合并:模型给出的domain和规则引擎不一致时,以模型结果为准但标记为“模型判定”,不一致率达到20%以上时,停线检查词库。20%这个阈值来自我的粗验经验:美观上希望低于10%,但题库偏案例题时,规则覆盖率本来就低,别急着删词。

抽验方法我用双人标注加Cohen's Kappa。具体做法是:从归类结果里随机抽10%的题目,两个人都不知道规则引擎给的答案,各自独立打标签,然后算Kappa值。Kappa超过0.7说明分类结果可信,低于0.5说明标签定义本身都有分歧,这时候调词库没意义,应该先统一知识域划分标准。这段代码不复杂,但每次跑完管线我必做这一步:

def kappa_score(annotator1: list, annotator2: list, categories: list) -> float: n = len(annotator1) if n == 0: return 0.0 # 观察一致率 observed = sum(1 for a, b in zip(annotator1, annotator2) if a == b) / n # 期望一致率:每类被两个人分别标记的概率乘积之和 expected = 0.0 for cat in categories: p1 = annotator1.count(cat) / n p2 = annotator2.count(cat) / n expected += p1 * p2 if expected == 1.0: return 1.0 return (observed - expected) / (1 - expected)

逻辑说明:Kappa是修正随机一致率后的指标。如果两个人标注结果受分类数量影响很大——类别越多,随机碰上的概率越低——Kappa会比观察一致率保守得多。我在实际项目里最常遇到的情况是:规则引擎和人工一致率达到80%,但Kappa只有0.62,原因是一名标注者把“规格说明”里涉及验证的内容都归为“验证与管理”,而另一名标注者严格按生命周期归到“规格说明”。这不是分类器的问题,是知识域边界定义的问题。遇到这种情况,我会把词库和分类说明一起重新过一遍,而不是硬调阈值。

整套管线跑下来,原本十几个杂乱PDF变成一个有序的题库:想刷“需求建模”的用例图,直接打开对应目录;想知道哪种题型错得最多,看Excel的统计。AI不是必须的环节,规则引擎兜得住八成的题,剩下两成人工或AI都能消掉。但有一点是底线:归类结果必须能溯源,每一道题都能查到原始页码和原题号。没有这个,所谓的归类就是一堆不可验证的标签,拿来应付期末还行,要作为题库交付给团队或老师,必被问得下不来台。

我自己养成的一个习惯是:每次跑完管线顺手导出一次“全量题库清单”PDF,按域排列,每一题下面标注原始来源。这有点像给PDF做索引,做完之后复习效率的提升是肉眼可见的。希望这个方案帮到你。

本文还有配套的精品资源,点击获取

返回列表