重置项目并建立文档治理基础架构
- 移除旧清洗器、规则、测试和打包配置 - 增加 AGENTS.md 与 CLAUDE.md 同步协作规范 - 建立 research-wiki 文档生命周期和首个设计记录
This commit is contained in:
+27
-6
@@ -1,9 +1,30 @@
|
|||||||
# 数据与产物
|
# Python and local tools
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Real documents and private datasets
|
||||||
|
data/
|
||||||
|
datasets/
|
||||||
|
uploads/
|
||||||
|
*.pdf
|
||||||
|
*.doc
|
||||||
|
*.docx
|
||||||
|
|
||||||
|
# Generated and experimental outputs
|
||||||
|
artifacts/
|
||||||
|
outputs/
|
||||||
|
experiments/
|
||||||
*.cleaned.md
|
*.cleaned.md
|
||||||
report.json
|
report.json
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
# Editor and operating-system files
|
||||||
.venv/
|
.DS_Store
|
||||||
dist/
|
.idea/
|
||||||
*.egg-info/
|
.vscode/
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **当前是文档治理基础阶段,不是已实现的清洗工具。**
|
||||||
|
>
|
||||||
|
> 1. 本仓库研究政务文档 PDF→Markdown 清洗问题;当前没有可运行实现。
|
||||||
|
> 2. 真实文档和外部数据默认只读,不修改、不复制、不提交。
|
||||||
|
> 3. 面向用户的说明使用简体中文;代码、命令、路径和标识符使用英文。
|
||||||
|
> 4. `AGENTS.md` 与 `CLAUDE.md` 是同步镜像,除第一行标题外正文必须一致。
|
||||||
|
|
||||||
|
## 0. 事实权威
|
||||||
|
|
||||||
|
发生冲突时,以对应的唯一权威为准,不把不同版本拼成新的说法。
|
||||||
|
|
||||||
|
| 事实类型 | 唯一权威 |
|
||||||
|
|---|---|
|
||||||
|
| 当前阶段与已经完成的工作 | 根目录 `README.md` |
|
||||||
|
| Wiki 分类、冻结规则与更新机制 | `research-wiki/README.md` |
|
||||||
|
| 已批准的选择、权衡与否决方案 | `research-wiki/design/` 中对应编号记录 |
|
||||||
|
| 当前有效的清洗机制与原因 | `research-wiki/explanation/`;没有文档时就是尚未确定 |
|
||||||
|
| 参数、输入输出和运行行为 | 未来的代码与测试;代码无法表达的事实才进入 `reference/` |
|
||||||
|
| 可复现的操作与排障步骤 | `research-wiki/guides/` |
|
||||||
|
| Agent 协作与执行规范 | 本文件及其同步镜像 |
|
||||||
|
|
||||||
|
路径、参数、命令、指标口径和当前进度不得维护多个权威版本。发现冲突时,先确认权威,再修复过期内容。
|
||||||
|
|
||||||
|
## 1. 项目定位与当前阶段
|
||||||
|
|
||||||
|
`govdoc-md-cleaner` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、
|
||||||
|
比较候选方案并积累可复核证据。
|
||||||
|
|
||||||
|
当前只建立文档治理基础。源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系均未获批准、
|
||||||
|
也未实现。不得因为 README 中描述了目标,就把目标写成已经存在的能力。
|
||||||
|
|
||||||
|
本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。
|
||||||
|
|
||||||
|
## 2. 数据与外部材料
|
||||||
|
|
||||||
|
已知外部真实材料位于 `/home/lihaoze/gov_test_data`。除非用户另行明确授权,执行以下边界:
|
||||||
|
|
||||||
|
- 真实文档、审核材料、历史产出和客户可识别内容只读;
|
||||||
|
- 不向外部数据目录回写清洗结果,不修改文件名和目录结构;
|
||||||
|
- 不把原文、软链接、大型结果或可还原客户信息的片段提交到本仓库;
|
||||||
|
- 读取外部仓库或数据不等于获得修改、发布或迁移它们的权限;
|
||||||
|
- 新增实验输出目录前,先在 design 中明确保存位置、保留周期与脱敏要求。
|
||||||
|
|
||||||
|
任何删除、覆盖、移动或公开真实材料的动作,都必须先得到用户对具体范围的明确确认。
|
||||||
|
|
||||||
|
## 3. 开始工作前
|
||||||
|
|
||||||
|
1. 阅读根目录 `README.md`、本文件、`research-wiki/README.md` 和相关 design;
|
||||||
|
2. 查看当前分支、工作区状态和最近提交,已有改动默认属于用户;
|
||||||
|
3. 区分当前事实、候选方案和未来目标;
|
||||||
|
4. 明确问题、输入范围、非目标、成功标准和验证方法;
|
||||||
|
5. 涉及真实材料时,再次确认只读来源、允许写入的位置和敏感信息边界。
|
||||||
|
|
||||||
|
不存在的命令、脚本、目录或测试不得当作可用能力引用。
|
||||||
|
|
||||||
|
## 4. 设计与确认门
|
||||||
|
|
||||||
|
以下事项实施前必须新增 design,并等待用户明确批准:
|
||||||
|
|
||||||
|
- 定义或改变清洗语义、规则优先级、误删容忍度和保真原则;
|
||||||
|
- 定义输入输出格式、统计口径、评估指标或标注方法;
|
||||||
|
- 创建源码/测试/实验目录,引入运行依赖或确定公共命令行接口;
|
||||||
|
- 改变 Wiki 分类、事实权威、数据边界或跨仓职责;
|
||||||
|
- 修改、删除、移动或公开真实数据和大型实验产出;
|
||||||
|
- 提交、推送、创建 PR、发布,或修改其他仓库与外部系统。
|
||||||
|
|
||||||
|
纯文档勘误、只读调查和已批准设计范围内的机械实施,不需要重复建立设计。
|
||||||
|
|
||||||
|
design 草稿可以在评审中修改;批准后冻结。决策发生变化时创建下一编号,用 `supersedes` 指向旧记录,
|
||||||
|
不回写历史让它看起来从未改变。
|
||||||
|
|
||||||
|
## 5. 未来实现的质量底线
|
||||||
|
|
||||||
|
当实现获得批准后:
|
||||||
|
|
||||||
|
- 正确性和正文保真优先于清洗率与性能;不确定内容默认保留;
|
||||||
|
- 清洗步骤应可追踪、可复现,并能解释每类修改的依据;
|
||||||
|
- 算法逻辑与文件读写分层,核心处理尽量保持确定性;
|
||||||
|
- 公共接口提供类型信息,复杂逻辑说明原因和不变量;
|
||||||
|
- 显式覆盖空文档、编码异常、OCR 重复、表格、图片、极端长度和幂等性;
|
||||||
|
- 不吞异常、不静默降级、不用放宽断言掩盖失败;
|
||||||
|
- 未经真实证据支持,不把局部样本结果概括为一般结论。
|
||||||
|
|
||||||
|
这些是未来实施约束,不表示当前已经存在任何代码或测试。
|
||||||
|
|
||||||
|
## 6. 文档规则
|
||||||
|
|
||||||
|
完整分类规则以 `research-wiki/README.md` 为准:
|
||||||
|
|
||||||
|
- `design/`:动工前的选择、代价与否决方案;批准后冻结;
|
||||||
|
- `explanation/`:当前有效机制及原因;事实变化时更新;
|
||||||
|
- `reference/`:代码无法完整表达的稳定查询事实;
|
||||||
|
- `guides/`:经过实际验证的操作步骤;
|
||||||
|
- `scratch/`:调研、计划和未收敛草稿,不作为当前事实引用。
|
||||||
|
|
||||||
|
新文档从实际问题或读者可观察的现象开始,写清理由、适用边界、代价和验证状态。
|
||||||
|
易漂移的路径、参数、命令和指标只链接到唯一权威,不复制出第二份。
|
||||||
|
|
||||||
|
## 7. 验证与报告
|
||||||
|
|
||||||
|
- 只报告本轮真实执行过的检查;未运行或无法验证的内容明确写“未验证”;
|
||||||
|
- 声称完成前检查实际目录、文档镜像、Git diff 和工作区状态;
|
||||||
|
- 未来实验必须能追溯输入范围、版本、参数、环境、指标和输出位置;
|
||||||
|
- 性能结论同时记录数据规模、运行条件、计时范围和重复次数;
|
||||||
|
- 区分“实现可运行”“指标改善”和“适合生产”,三者不能互相替代。
|
||||||
|
|
||||||
|
当前有效的基础检查命令只以根目录 `README.md` 为准。
|
||||||
|
|
||||||
|
## 8. Git 与文件操作
|
||||||
|
|
||||||
|
- 已有改动默认属于用户,不覆盖、不回滚、不混入无关变更;
|
||||||
|
- 不执行 `git reset --hard`、`git checkout --` 等不可恢复操作;
|
||||||
|
- 删除前确认具体目标,优先保证可从 Git 或备份恢复;
|
||||||
|
- 提交前检查 staged diff、敏感信息、大文件和验证结果;
|
||||||
|
- 未经用户明确要求,不提交、不推送、不创建 PR、不发布;
|
||||||
|
- 不在提交信息、源码或文档中添加 AI 署名。
|
||||||
|
|
||||||
|
## 9. 沟通与协作
|
||||||
|
|
||||||
|
- 像同事协作一样,用直接、可读的中文说明判断、变化和风险;
|
||||||
|
- 问什么答什么,一次聚焦当前问题,不把未经请求的后续工作一起推进;
|
||||||
|
- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户;
|
||||||
|
- 进度和最终报告必须对应真实工具输出,不把计划描述成结果;
|
||||||
|
- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **当前是文档治理基础阶段,不是已实现的清洗工具。**
|
||||||
|
>
|
||||||
|
> 1. 本仓库研究政务文档 PDF→Markdown 清洗问题;当前没有可运行实现。
|
||||||
|
> 2. 真实文档和外部数据默认只读,不修改、不复制、不提交。
|
||||||
|
> 3. 面向用户的说明使用简体中文;代码、命令、路径和标识符使用英文。
|
||||||
|
> 4. `AGENTS.md` 与 `CLAUDE.md` 是同步镜像,除第一行标题外正文必须一致。
|
||||||
|
|
||||||
|
## 0. 事实权威
|
||||||
|
|
||||||
|
发生冲突时,以对应的唯一权威为准,不把不同版本拼成新的说法。
|
||||||
|
|
||||||
|
| 事实类型 | 唯一权威 |
|
||||||
|
|---|---|
|
||||||
|
| 当前阶段与已经完成的工作 | 根目录 `README.md` |
|
||||||
|
| Wiki 分类、冻结规则与更新机制 | `research-wiki/README.md` |
|
||||||
|
| 已批准的选择、权衡与否决方案 | `research-wiki/design/` 中对应编号记录 |
|
||||||
|
| 当前有效的清洗机制与原因 | `research-wiki/explanation/`;没有文档时就是尚未确定 |
|
||||||
|
| 参数、输入输出和运行行为 | 未来的代码与测试;代码无法表达的事实才进入 `reference/` |
|
||||||
|
| 可复现的操作与排障步骤 | `research-wiki/guides/` |
|
||||||
|
| Agent 协作与执行规范 | 本文件及其同步镜像 |
|
||||||
|
|
||||||
|
路径、参数、命令、指标口径和当前进度不得维护多个权威版本。发现冲突时,先确认权威,再修复过期内容。
|
||||||
|
|
||||||
|
## 1. 项目定位与当前阶段
|
||||||
|
|
||||||
|
`govdoc-md-cleaner` 是独立的清洗算法研究与治理仓库。它用于理解 PDF→Markdown 噪音、定义清洗边界、
|
||||||
|
比较候选方案并积累可复核证据。
|
||||||
|
|
||||||
|
当前只建立文档治理基础。源码目录、测试目录、依赖配置、命令行接口、规则格式和评估体系均未获批准、
|
||||||
|
也未实现。不得因为 README 中描述了目标,就把目标写成已经存在的能力。
|
||||||
|
|
||||||
|
本仓库的研究结论不会自动成为其他仓库的生产契约。跨仓落地必须在目标仓库重新评审并获得授权。
|
||||||
|
|
||||||
|
## 2. 数据与外部材料
|
||||||
|
|
||||||
|
已知外部真实材料位于 `/home/lihaoze/gov_test_data`。除非用户另行明确授权,执行以下边界:
|
||||||
|
|
||||||
|
- 真实文档、审核材料、历史产出和客户可识别内容只读;
|
||||||
|
- 不向外部数据目录回写清洗结果,不修改文件名和目录结构;
|
||||||
|
- 不把原文、软链接、大型结果或可还原客户信息的片段提交到本仓库;
|
||||||
|
- 读取外部仓库或数据不等于获得修改、发布或迁移它们的权限;
|
||||||
|
- 新增实验输出目录前,先在 design 中明确保存位置、保留周期与脱敏要求。
|
||||||
|
|
||||||
|
任何删除、覆盖、移动或公开真实材料的动作,都必须先得到用户对具体范围的明确确认。
|
||||||
|
|
||||||
|
## 3. 开始工作前
|
||||||
|
|
||||||
|
1. 阅读根目录 `README.md`、本文件、`research-wiki/README.md` 和相关 design;
|
||||||
|
2. 查看当前分支、工作区状态和最近提交,已有改动默认属于用户;
|
||||||
|
3. 区分当前事实、候选方案和未来目标;
|
||||||
|
4. 明确问题、输入范围、非目标、成功标准和验证方法;
|
||||||
|
5. 涉及真实材料时,再次确认只读来源、允许写入的位置和敏感信息边界。
|
||||||
|
|
||||||
|
不存在的命令、脚本、目录或测试不得当作可用能力引用。
|
||||||
|
|
||||||
|
## 4. 设计与确认门
|
||||||
|
|
||||||
|
以下事项实施前必须新增 design,并等待用户明确批准:
|
||||||
|
|
||||||
|
- 定义或改变清洗语义、规则优先级、误删容忍度和保真原则;
|
||||||
|
- 定义输入输出格式、统计口径、评估指标或标注方法;
|
||||||
|
- 创建源码/测试/实验目录,引入运行依赖或确定公共命令行接口;
|
||||||
|
- 改变 Wiki 分类、事实权威、数据边界或跨仓职责;
|
||||||
|
- 修改、删除、移动或公开真实数据和大型实验产出;
|
||||||
|
- 提交、推送、创建 PR、发布,或修改其他仓库与外部系统。
|
||||||
|
|
||||||
|
纯文档勘误、只读调查和已批准设计范围内的机械实施,不需要重复建立设计。
|
||||||
|
|
||||||
|
design 草稿可以在评审中修改;批准后冻结。决策发生变化时创建下一编号,用 `supersedes` 指向旧记录,
|
||||||
|
不回写历史让它看起来从未改变。
|
||||||
|
|
||||||
|
## 5. 未来实现的质量底线
|
||||||
|
|
||||||
|
当实现获得批准后:
|
||||||
|
|
||||||
|
- 正确性和正文保真优先于清洗率与性能;不确定内容默认保留;
|
||||||
|
- 清洗步骤应可追踪、可复现,并能解释每类修改的依据;
|
||||||
|
- 算法逻辑与文件读写分层,核心处理尽量保持确定性;
|
||||||
|
- 公共接口提供类型信息,复杂逻辑说明原因和不变量;
|
||||||
|
- 显式覆盖空文档、编码异常、OCR 重复、表格、图片、极端长度和幂等性;
|
||||||
|
- 不吞异常、不静默降级、不用放宽断言掩盖失败;
|
||||||
|
- 未经真实证据支持,不把局部样本结果概括为一般结论。
|
||||||
|
|
||||||
|
这些是未来实施约束,不表示当前已经存在任何代码或测试。
|
||||||
|
|
||||||
|
## 6. 文档规则
|
||||||
|
|
||||||
|
完整分类规则以 `research-wiki/README.md` 为准:
|
||||||
|
|
||||||
|
- `design/`:动工前的选择、代价与否决方案;批准后冻结;
|
||||||
|
- `explanation/`:当前有效机制及原因;事实变化时更新;
|
||||||
|
- `reference/`:代码无法完整表达的稳定查询事实;
|
||||||
|
- `guides/`:经过实际验证的操作步骤;
|
||||||
|
- `scratch/`:调研、计划和未收敛草稿,不作为当前事实引用。
|
||||||
|
|
||||||
|
新文档从实际问题或读者可观察的现象开始,写清理由、适用边界、代价和验证状态。
|
||||||
|
易漂移的路径、参数、命令和指标只链接到唯一权威,不复制出第二份。
|
||||||
|
|
||||||
|
## 7. 验证与报告
|
||||||
|
|
||||||
|
- 只报告本轮真实执行过的检查;未运行或无法验证的内容明确写“未验证”;
|
||||||
|
- 声称完成前检查实际目录、文档镜像、Git diff 和工作区状态;
|
||||||
|
- 未来实验必须能追溯输入范围、版本、参数、环境、指标和输出位置;
|
||||||
|
- 性能结论同时记录数据规模、运行条件、计时范围和重复次数;
|
||||||
|
- 区分“实现可运行”“指标改善”和“适合生产”,三者不能互相替代。
|
||||||
|
|
||||||
|
当前有效的基础检查命令只以根目录 `README.md` 为准。
|
||||||
|
|
||||||
|
## 8. Git 与文件操作
|
||||||
|
|
||||||
|
- 已有改动默认属于用户,不覆盖、不回滚、不混入无关变更;
|
||||||
|
- 不执行 `git reset --hard`、`git checkout --` 等不可恢复操作;
|
||||||
|
- 删除前确认具体目标,优先保证可从 Git 或备份恢复;
|
||||||
|
- 提交前检查 staged diff、敏感信息、大文件和验证结果;
|
||||||
|
- 未经用户明确要求,不提交、不推送、不创建 PR、不发布;
|
||||||
|
- 不在提交信息、源码或文档中添加 AI 署名。
|
||||||
|
|
||||||
|
## 9. 沟通与协作
|
||||||
|
|
||||||
|
- 像同事协作一样,用直接、可读的中文说明判断、变化和风险;
|
||||||
|
- 问什么答什么,一次聚焦当前问题,不把未经请求的后续工作一起推进;
|
||||||
|
- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户;
|
||||||
|
- 进度和最终报告必须对应真实工具输出,不把计划描述成结果;
|
||||||
|
- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。
|
||||||
@@ -1,93 +1,67 @@
|
|||||||
# govdoc-md-cleaner
|
# govdoc-md-cleaner
|
||||||
|
|
||||||
政务文档(招标 / 投标 / 采购 / 合同)**PDF→Markdown 产物**的规则化清洗工具。
|
政务文档 PDF→Markdown 清洗方向的独立研究与治理仓库。
|
||||||
|
|
||||||
上游解析管线(MinerU / OCR)把 PDF 转成 Markdown 后,会带入大量非正文噪音:
|
本仓库用于澄清清洗问题、记录设计选择、积累可复核证据,并在方案获得确认后再建立实现。
|
||||||
逐页重复的页眉页脚、页码行、目录点线、死链图片引用、压成单行的 HTML 表格、
|
它目前不是可安装的 Python 包,也不提供命令行工具或生产接口。
|
||||||
行尾硬换行双空格,以及 OCR 重复崩坏段。本工具用一套 **YAML 声明、按序应用、
|
|
||||||
逐条统计** 的规则把它们清掉,输出可直接进入比对 / RAG / 审核管线的干净 Markdown。
|
|
||||||
|
|
||||||
## 数据来源
|
## 当前阶段
|
||||||
|
|
||||||
清洗目标为 `/home/lihaoze/gov_test_data`(律所上传的真实政务文件的筛选子集,
|
2026-08-20 已完成仓库重置与最小文档治理骨架:
|
||||||
详见其 `compare/readme.md`),未来会持续接入更多批次的 Markdown 文件。
|
|
||||||
**本项目只包含清洗代码与规则,不包含任何业务数据**——测试数据不入库。
|
|
||||||
|
|
||||||
## 清洗规则(rules/default.yaml)
|
- 原有 Python 实现、YAML 规则、测试和打包配置已经移除;
|
||||||
|
- `AGENTS.md` 与 `CLAUDE.md` 提供同步的协作规则;
|
||||||
|
- `research-wiki/` 按文档生命周期区分设计、说明、参考、指南和草稿;
|
||||||
|
- `research-wiki/design/0001-repository-foundation.md` 记录本次基础架构选择。
|
||||||
|
|
||||||
| 顺序 | 规则 | 处理对象 | 示例 |
|
当前没有清洗算法、可执行命令、运行依赖、测试套件或已批准的输入输出契约。
|
||||||
|---|---|---|---|
|
目录存在只代表文档落点已经建立,不代表相应能力已经完成。
|
||||||
| 10 | `strip_page_lines` | 页码行 | `第 3 页 共 53 页`、`第4页共4页`、`Page 3 of 10`、`- 4 -` |
|
|
||||||
| 20 | `strip_repeated_short_lines` | 页眉页脚 + OCR 崩坏 | 全篇重复 ≥3 次的短行;连续刷屏 ≥5 次(实测"审计程序"×1359) |
|
|
||||||
| 30 | `strip_toc_dots` | 目录点线 | `第一章 投标邀请函 ………… 2` → 保留标题,删点线页码 |
|
|
||||||
| 40 | `drop_images` | 死链图片 | ``、base64 内嵌图 |
|
|
||||||
| 50 | `normalize_tables` | HTML 表格 | `<table><tr><td>…` 单行压缩 → Markdown 管道表格 |
|
|
||||||
| 60 | `strip_stray_html` | 散落标签 | `<br/>` |
|
|
||||||
| 70 | `rstrip_lines` | 行尾空白 | MinerU 每行行尾的双空格硬换行 |
|
|
||||||
| 80 | `collapse_blank_lines` | 空行折叠 | 连续空行 → 1 行;裁掉文首文末 |
|
|
||||||
|
|
||||||
### 防误伤设计(来自真实数据踩坑)
|
## 项目边界
|
||||||
|
|
||||||
- **protect 正则**:`投标人:(公章)`、`法定代表人签名:`、`日期: 年 月 日`、
|
- 研究对象是 MinerU、OCR 等 PDF→Markdown 管线产生的噪音与清洗方法;
|
||||||
`致:xxx`、声明函结尾句——标书里逐章重复,**是正文模板不是页眉**,默认保护。
|
- `/home/lihaoze/gov_test_data` 中的真实材料属于外部只读数据,不复制、不修改、不提交;
|
||||||
- **内容形态豁免**:编号条款 `(1)…`、`1、…`、`一、…`、列表/表格行、
|
- 任何清洗语义、规则格式、评估指标、代码目录或运行依赖,都应先形成设计记录并获得确认;
|
||||||
`乙方:xxx` 字段行——平行结构天然重复,引擎侧直接不参与页眉判定。
|
- 研究结果只有经过独立评审后,才能进入其他产品或生产仓库。
|
||||||
- **burst 检测**:同一短行连续刷屏 ≥5 次(间隔 ≤2 行)判为 OCR 重复崩坏,
|
|
||||||
直接删除(003-10 案例出现"审计程序"连续 1359 行)。
|
|
||||||
|
|
||||||
## 安装
|
## 目录结构
|
||||||
|
|
||||||
```bash
|
```text
|
||||||
pip install -e . # 或直接 python -m cleaner.cli(仅需 PyYAML)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 单文件:清洗 + 打印统计
|
|
||||||
md-clean single input.md -o output.md --diff
|
|
||||||
|
|
||||||
# 批量:递归清洗目录下所有 .md,输出到平行目录 + JSON 报告
|
|
||||||
md-clean batch /path/to/uploads -o /path/to/uploads_cleaned \
|
|
||||||
--pattern "*.md" --report report.json
|
|
||||||
|
|
||||||
# 用自定义规则集(复制 default.yaml 改参数即可)
|
|
||||||
md-clean batch uploads/ --rules rules/strict.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
`--diff` 输出 unified diff,`report.json` 记录每个文件每条规则的命中数,
|
|
||||||
清洗过程完全可审计、可回滚(重跑即得原结果的对照)。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
govdoc-md-cleaner/
|
govdoc-md-cleaner/
|
||||||
├── cleaner/
|
├── AGENTS.md
|
||||||
│ ├── cleaner.py # 引擎:按序应用规则,输出 CleanResult(text, stats)
|
├── CLAUDE.md
|
||||||
│ ├── rules.py # 规则实现 + REGISTRY + YAML 加载
|
├── README.md
|
||||||
│ └── cli.py # single / batch 两个子命令
|
└── research-wiki/
|
||||||
├── rules/
|
├── README.md
|
||||||
│ └── default.yaml # 默认规则集(顺序、开关、参数、protect 列表)
|
├── design/ # 方案选择与冻结决策
|
||||||
├── tests/
|
├── explanation/ # 当前有效机制及原因
|
||||||
│ └── test_rules.py # 每条规则的最小样例 + 防误伤回归
|
├── reference/ # 需要准确查询的稳定事实
|
||||||
└── docs/
|
├── guides/ # 已实际验证的操作步骤
|
||||||
└── RULES.md # 规则编写指南(新增规则的方法)
|
└── scratch/ # 调研笔记和未收敛草稿
|
||||||
```
|
```
|
||||||
|
|
||||||
## 新增一条规则
|
## 开始工作
|
||||||
|
|
||||||
1. `cleaner/rules.py`:实现 `_your_rule(text, params, stats) -> text`,注册进 `REGISTRY`;
|
进入仓库后依次阅读:
|
||||||
2. `rules/default.yaml`:追加 `- name: your_rule / order: / params:`;
|
|
||||||
3. `tests/test_rules.py`:加一个真实数据浓缩出的最小样例。
|
|
||||||
|
|
||||||
原则:**规则只删噪音不删正文**;拿不准的形态默认保留,靠 protect/exempt 收紧。
|
1. 本文件,确认当前阶段;
|
||||||
|
2. `AGENTS.md` 或 `CLAUDE.md`,确认协作与安全边界;
|
||||||
|
3. `research-wiki/README.md`,确认文档应放在哪里;
|
||||||
|
4. 与任务直接相关的 `research-wiki/design/` 记录。
|
||||||
|
|
||||||
## 测试
|
下一项实质工作开始前,应新增下一编号的 design,明确问题、备选方案、输入范围、验收方法和非目标。
|
||||||
|
|
||||||
|
## 当前可用检查
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m unittest discover tests -v
|
# 两份 Agent 入口除标题外必须一致;无输出且退出码为 0 表示通过
|
||||||
|
diff -u <(tail -n +2 AGENTS.md) <(tail -n +2 CLAUDE.md)
|
||||||
|
|
||||||
|
# 查看当前 Wiki 中实际存在的文档
|
||||||
|
find research-wiki -maxdepth 2 -type f | sort
|
||||||
|
|
||||||
|
# 检查本地变更
|
||||||
|
git status --short
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
当前没有测试命令;在真实实现和测试体系获批并落地前,不应声明测试通过。
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
"""govdoc-md-cleaner: 政务文档 Markdown 清洗工具包。
|
|
||||||
|
|
||||||
针对 PDF→Markdown 转换产物(MinerU / OCR 管线输出)的常见脏数据,
|
|
||||||
提供基于 YAML 规则的、可复现、可审计的清洗能力。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from cleaner.cleaner import MarkdownCleaner, CleanResult, load_rules
|
|
||||||
from cleaner.cli import main
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
__all__ = ["MarkdownCleaner", "CleanResult", "load_rules", "main"]
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""清洗引擎:按规则顺序应用,输出清洗后文本 + 统计报告。"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import difflib
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from cleaner.rules import REGISTRY, Rule, load_rules
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CleanResult:
|
|
||||||
"""一次清洗的结果:产物 + 可审计的统计。"""
|
|
||||||
|
|
||||||
text: str
|
|
||||||
stats: Dict[str, int] = field(default_factory=dict)
|
|
||||||
rules_applied: List[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def total_hits(self) -> int:
|
|
||||||
return sum(v for k, v in self.stats.items() if k != "collapsed_blanks")
|
|
||||||
|
|
||||||
|
|
||||||
class MarkdownCleaner:
|
|
||||||
def __init__(self, rules: Optional[List[Rule]] = None, rules_path: Optional[Path] = None):
|
|
||||||
self.rules = rules if rules is not None else load_rules(rules_path)
|
|
||||||
|
|
||||||
def clean_text(self, text: str) -> CleanResult:
|
|
||||||
stats: Dict[str, int] = {}
|
|
||||||
applied: List[str] = []
|
|
||||||
for rule in self.rules:
|
|
||||||
if not rule.enabled:
|
|
||||||
continue
|
|
||||||
func = REGISTRY[rule.name]
|
|
||||||
text = func(text, rule.params, stats)
|
|
||||||
applied.append(rule.name)
|
|
||||||
return CleanResult(text=text, stats=stats, rules_applied=applied)
|
|
||||||
|
|
||||||
def clean_file(self, src: Path, dst: Optional[Path] = None) -> CleanResult:
|
|
||||||
raw = Path(src).read_text(encoding="utf-8")
|
|
||||||
result = self.clean_text(raw)
|
|
||||||
if dst is not None:
|
|
||||||
Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
Path(dst).write_text(result.text, encoding="utf-8")
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def diff(before: str, after: str, context: int = 1) -> str:
|
|
||||||
return "\n".join(
|
|
||||||
difflib.unified_diff(
|
|
||||||
before.splitlines(), after.splitlines(),
|
|
||||||
fromfile="before", tofile="after", lineterm="", n=context,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""命令行入口。
|
|
||||||
|
|
||||||
用法:
|
|
||||||
md-clean single <input.md> [-o output.md] [--diff] [--rules rules.yaml]
|
|
||||||
md-clean batch <dir> [-o outdir] [--pattern "*.md"] [--report report.json]
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from cleaner.cleaner import MarkdownCleaner
|
|
||||||
from cleaner.rules import load_rules
|
|
||||||
|
|
||||||
|
|
||||||
def _build(path: Optional[Path]) -> MarkdownCleaner:
|
|
||||||
return MarkdownCleaner(rules=load_rules(path))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_single(args: argparse.Namespace) -> int:
|
|
||||||
cleaner = _build(args.rules)
|
|
||||||
src = Path(args.input)
|
|
||||||
raw = src.read_text(encoding="utf-8")
|
|
||||||
result = cleaner.clean_text(raw)
|
|
||||||
if args.output:
|
|
||||||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
Path(args.output).write_text(result.text, encoding="utf-8")
|
|
||||||
print(f"已写入 {args.output}")
|
|
||||||
if args.diff:
|
|
||||||
print(MarkdownCleaner.diff(raw, result.text))
|
|
||||||
print(json.dumps(result.stats, ensure_ascii=False, indent=2))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_batch(args: argparse.Namespace) -> int:
|
|
||||||
cleaner = _build(args.rules)
|
|
||||||
src_dir = Path(args.directory)
|
|
||||||
files = sorted(p for p in src_dir.rglob(args.pattern) if p.is_file())
|
|
||||||
if not files:
|
|
||||||
print(f"在 {src_dir} 下未找到匹配 {args.pattern} 的文件", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
out_dir = Path(args.output) if args.output else src_dir.parent / (src_dir.name + "_cleaned")
|
|
||||||
report = []
|
|
||||||
for f in files:
|
|
||||||
rel = f.relative_to(src_dir)
|
|
||||||
dst = out_dir / rel
|
|
||||||
result = cleaner.clean_file(f, dst)
|
|
||||||
report.append({"file": str(rel), "stats": result.stats})
|
|
||||||
hits = result.total_hits
|
|
||||||
print(f"[ok] {rel} 命中 {hits} 处")
|
|
||||||
if args.report:
|
|
||||||
Path(args.report).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
Path(args.report).write_text(
|
|
||||||
json.dumps({"files": report}, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
||||||
)
|
|
||||||
print(f"报告已写入 {args.report}")
|
|
||||||
print(f"共清洗 {len(files)} 个文件 → {out_dir}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog="md-clean", description="政务文档 Markdown 清洗工具")
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
p1 = sub.add_parser("single", help="清洗单个文件")
|
|
||||||
p1.add_argument("input", help="输入 .md 文件")
|
|
||||||
p1.add_argument("-o", "--output", help="输出路径(缺省打印统计不写文件)")
|
|
||||||
p1.add_argument("--diff", action="store_true", help="打印 unified diff")
|
|
||||||
p1.add_argument("--rules", type=Path, help="规则 YAML 路径")
|
|
||||||
|
|
||||||
p2 = sub.add_parser("batch", help="批量清洗目录(递归)")
|
|
||||||
p2.add_argument("directory", help="输入目录")
|
|
||||||
p2.add_argument("-o", "--output", help="输出目录(缺省 <dir>_cleaned)")
|
|
||||||
p2.add_argument("--pattern", default="*.md", help="文件 glob(默认 *.md)")
|
|
||||||
p2.add_argument("--report", help="清洗报告 JSON 输出路径")
|
|
||||||
p2.add_argument("--rules", type=Path, help="规则 YAML 路径")
|
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
if args.command == "single":
|
|
||||||
return cmd_single(args)
|
|
||||||
return cmd_batch(args)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
"""规则模型:一条清洗规则 = 名称 + 开关 + 参数 + 应用顺序。
|
|
||||||
|
|
||||||
规则用 YAML 声明(rules/*.yaml),引擎按 order 依次应用,
|
|
||||||
这样清洗过程可复现、可 diff、可回滚。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Rule:
|
|
||||||
"""一条清洗规则。
|
|
||||||
|
|
||||||
name: 唯一标识(报告里引用)
|
|
||||||
order: 应用顺序,小的先执行
|
|
||||||
enabled: 开关,方便对某个用例单独关掉
|
|
||||||
params: 传给处理函数的额外参数
|
|
||||||
"""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
order: int = 100
|
|
||||||
enabled: bool = True
|
|
||||||
params: Dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_dict(d: Dict[str, Any]) -> "Rule":
|
|
||||||
return Rule(
|
|
||||||
name=d["name"],
|
|
||||||
order=int(d.get("order", 100)),
|
|
||||||
enabled=bool(d.get("enabled", True)),
|
|
||||||
params=dict(d.get("params") or {}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 每条规则的具体实现。函数签名统一为 (text, params, stats) -> text。
|
|
||||||
# stats 是 {rule_name: 删改行数},用于生成清洗报告。
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
RuleFunc = Callable[[str, Dict[str, Any], Dict[str, int]], str]
|
|
||||||
|
|
||||||
# 页码类:第X页 共Y页 / 第X页共Y页 / Page x of y / - 3 - 等
|
|
||||||
_RE_PAGE_CN = re.compile(
|
|
||||||
r"^[ \t]*第\s*[0-90-9]+\s*页\s*(?:[,,/]?\s*共\s*[0-90-9]+\s*页)?[ \t]*$"
|
|
||||||
)
|
|
||||||
_RE_PAGE_EN = re.compile(
|
|
||||||
r"^[ \t]*(?:[-–—]?\s*Page\s+\d+(?:\s+of\s+\d+)?\s*[-–—]?"
|
|
||||||
r"|[-–—]\s*\d{1,4}\s*[-–—]"
|
|
||||||
r"|\d+\s*/\s*\d+)[ \t]*$",
|
|
||||||
re.IGNORECASE,
|
|
||||||
)
|
|
||||||
# 纯页码数字行(单独一行只有 1-4 位数字,且不是标题编号场景)
|
|
||||||
_RE_PAGE_BARE = re.compile(r"^[ \t]*\d{1,4}[ \t]*$")
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_page_lines(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str:
|
|
||||||
keep_bare_numbers = bool(params.get("keep_bare_numbers", True))
|
|
||||||
out, n = [], 0
|
|
||||||
for line in text.splitlines():
|
|
||||||
if _RE_PAGE_CN.match(line) or _RE_PAGE_EN.match(line):
|
|
||||||
n += 1
|
|
||||||
continue
|
|
||||||
if not keep_bare_numbers and _RE_PAGE_BARE.match(line):
|
|
||||||
n += 1
|
|
||||||
continue
|
|
||||||
out.append(line)
|
|
||||||
stats["page_lines"] = stats.get("page_lines", 0) + n
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
# 页眉/页脚:同一短行在全篇重复出现 >= N 次(默认 3),视为页眉页脚删除。
|
|
||||||
# 两道保险避免误伤正文:
|
|
||||||
# 1. protect 正则 —— 标书里逐章重复的模板行(签章/日期/声明结尾)
|
|
||||||
# 2. 内容形态行直接豁免 —— 编号条款 "(1)…"/"1、…"/"一、…"/列表/表格行
|
|
||||||
# 在平行结构的标书里天然重复,但它们是正文不是页眉。
|
|
||||||
_RE_CONTENT_LIKE = re.compile(
|
|
||||||
r"^[\d0-9((\[【\-–—*•·①-⑳一二三四五六七八九十百第章节条款、,.。::|]"
|
|
||||||
r"|[一-鿿]{1,6}[::]\s*\S" # "乙方:xxx" / "地址:xxx" 这类字段行
|
|
||||||
r"|^[一-鿿A-Za-z]{1,6}[::]\s*$" # "乙方:" / "注:" 字段标签行
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_repeated_short_lines(
|
|
||||||
text: str, params: Dict[str, Any], stats: Dict[str, int]
|
|
||||||
) -> str:
|
|
||||||
threshold = int(params.get("threshold", 3))
|
|
||||||
max_len = int(params.get("max_len", 40))
|
|
||||||
# 同一短行在文中连续出现 >= burst_limit 次(间隔 <= burst_gap 行)视为
|
|
||||||
# OCR 重复崩坏(如 003-10 案例"审计程序"连续刷屏 1359 次),无论阈值直接删。
|
|
||||||
burst_limit = int(params.get("burst_limit", 5))
|
|
||||||
burst_gap = int(params.get("burst_gap", 2))
|
|
||||||
protect = [re.compile(p) for p in params.get("protect", [])]
|
|
||||||
lines = text.splitlines()
|
|
||||||
counts: Dict[str, int] = {}
|
|
||||||
positions: Dict[str, List[int]] = {}
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
s = line.strip()
|
|
||||||
if (
|
|
||||||
0 < len(s) <= max_len
|
|
||||||
and not s.startswith("#")
|
|
||||||
and not _RE_CONTENT_LIKE.match(s)
|
|
||||||
and "![" not in s
|
|
||||||
):
|
|
||||||
counts[s] = counts.get(s, 0) + 1
|
|
||||||
positions.setdefault(s, []).append(i)
|
|
||||||
repeated = {
|
|
||||||
s
|
|
||||||
for s, c in counts.items()
|
|
||||||
if c >= threshold and not any(rx.search(s) for rx in protect)
|
|
||||||
}
|
|
||||||
# OCR 崩坏连续段:即使该行被 protect/内容豁免,连续刷屏也删
|
|
||||||
for s, pos in positions.items():
|
|
||||||
best_run = run = 1
|
|
||||||
for a, b in zip(pos, pos[1:]):
|
|
||||||
run = run + 1 if b - a <= burst_gap else 1
|
|
||||||
best_run = max(best_run, run)
|
|
||||||
if best_run >= burst_limit:
|
|
||||||
repeated.add(s)
|
|
||||||
if not repeated:
|
|
||||||
return text
|
|
||||||
out, n = [], 0
|
|
||||||
for line in lines:
|
|
||||||
if line.strip() in repeated:
|
|
||||||
n += 1
|
|
||||||
continue
|
|
||||||
out.append(line)
|
|
||||||
stats["header_footer_lines"] = stats.get("header_footer_lines", 0) + n
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
# 目录点线:标题文字 ………… 12 / ······ 3 之类(… U+2026 也算;# 前缀可选)
|
|
||||||
_RE_TOC_DOTS = re.compile(
|
|
||||||
r"^(#{1,6}\s+)?.*?[ \t]*[\.。·•‧…]{6,}[ \t]*[\d0-9]*[ \t]*$"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_toc_dots(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str:
|
|
||||||
out, n = [], 0
|
|
||||||
for line in text.splitlines():
|
|
||||||
m = _RE_TOC_DOTS.match(line)
|
|
||||||
if m:
|
|
||||||
# 去掉点线和页码,保留标题文字;纯点线+页码的目录行整行删
|
|
||||||
title = re.sub(r"[ \t]*[\.。·•‧…]{6,}[ \t]*[\d0-9]*[ \t]*$", "", line).rstrip()
|
|
||||||
if title.strip() and not re.fullmatch(r"[\.。·•‧…\d0-9\s]+", title):
|
|
||||||
out.append(title)
|
|
||||||
n += 1
|
|
||||||
continue
|
|
||||||
out.append(line)
|
|
||||||
stats["toc_dot_lines"] = stats.get("toc_dot_lines", 0) + n
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
# 图片引用: —— 图片目录不在交付物里,引用是死链
|
|
||||||
_RE_IMAGE = re.compile(r"[ \t]*!\[[^\]]*\]\([^)]*\)[ \t]*")
|
|
||||||
|
|
||||||
|
|
||||||
def _drop_images(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str:
|
|
||||||
placeholder = params.get("placeholder") # None=整行删除;否则替换为占位文本
|
|
||||||
out, n = [], 0
|
|
||||||
for line in text.splitlines():
|
|
||||||
if _RE_IMAGE.fullmatch(line):
|
|
||||||
n += 1
|
|
||||||
if placeholder:
|
|
||||||
out.append(str(placeholder))
|
|
||||||
continue
|
|
||||||
new = _RE_IMAGE.sub("", line)
|
|
||||||
if new != line:
|
|
||||||
n += 1
|
|
||||||
line = new.rstrip()
|
|
||||||
out.append(line)
|
|
||||||
stats["image_refs"] = stats.get("image_refs", 0) + n
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
# HTML 表格规范化:<table ...><tr><td>…</td></tr></table> 压缩为合法 Markdown 管道表格
|
|
||||||
_RE_TABLE = re.compile(r"<table[^>]*>(.*?)</table>", re.DOTALL | re.IGNORECASE)
|
|
||||||
_RE_TR = re.compile(r"<tr[^>]*>(.*?)</tr>", re.DOTALL | re.IGNORECASE)
|
|
||||||
_RE_TD = re.compile(r"<t[dh][^>]*>(.*?)</t[dh]>", re.DOTALL | re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _cell_text(raw: str) -> str:
|
|
||||||
cell = re.sub(r"<br\s*/?>", " ", raw, flags=re.IGNORECASE)
|
|
||||||
cell = re.sub(r"<[^>]+>", "", cell)
|
|
||||||
return " ".join(cell.split()).replace("|", "\\|")
|
|
||||||
|
|
||||||
|
|
||||||
def _table_to_md(tbl_html: str) -> str:
|
|
||||||
rows: List[List[str]] = []
|
|
||||||
for tr in _RE_TR.findall(tbl_html):
|
|
||||||
cells = [_cell_text(td) for td in _RE_TD.findall(tr)]
|
|
||||||
if cells:
|
|
||||||
rows.append(cells)
|
|
||||||
if not rows:
|
|
||||||
return ""
|
|
||||||
width = max(len(r) for r in rows)
|
|
||||||
rows = [r + [""] * (width - len(r)) for r in rows]
|
|
||||||
lines = ["| " + " | ".join(rows[0]) + " |", "|" + "---|" * width]
|
|
||||||
lines.extend("| " + " | ".join(r) + " |" for r in rows[1:])
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_tables(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str:
|
|
||||||
def _sub(m: re.Match[str]) -> str:
|
|
||||||
md = _table_to_md(m.group(1))
|
|
||||||
return md if md else ""
|
|
||||||
|
|
||||||
new, n = _RE_TABLE.subn(_sub, text)
|
|
||||||
# 残缺兜底:文档截断导致 <table> 未闭合时,把剩余 <tr> 行也转掉
|
|
||||||
if "<table" in new:
|
|
||||||
tail_i = new.rfind("<table")
|
|
||||||
head, tail = new[:tail_i], new[tail_i:]
|
|
||||||
tail = _RE_TR.sub(
|
|
||||||
lambda m: "\n" + _table_to_md("<table>" + m.group(0) + "</table>"),
|
|
||||||
tail,
|
|
||||||
)
|
|
||||||
tail = re.sub(r"</?table[^>]*>", "", tail)
|
|
||||||
new = head + tail
|
|
||||||
n += 1
|
|
||||||
stats["html_tables"] = stats.get("html_tables", 0) + n
|
|
||||||
return new
|
|
||||||
|
|
||||||
|
|
||||||
# 表格内 <br/> 会被上面规则拍平;这里处理散落的 HTML 换行/空白标签
|
|
||||||
def _strip_stray_html(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str:
|
|
||||||
new = re.sub(r"<br\s*/?>", " ", text, flags=re.IGNORECASE)
|
|
||||||
if new != text:
|
|
||||||
stats["stray_html"] = stats.get("stray_html", 0) + text.count("<br")
|
|
||||||
return new
|
|
||||||
|
|
||||||
|
|
||||||
# 行尾双空格(MinerU 每行都带,语义是硬换行,清洗后统一去掉)
|
|
||||||
def _rstrip_lines(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str:
|
|
||||||
out, n = [], 0
|
|
||||||
for line in text.splitlines():
|
|
||||||
stripped = line.rstrip()
|
|
||||||
if stripped != line:
|
|
||||||
n += 1
|
|
||||||
out.append(stripped)
|
|
||||||
stats["trailing_ws_lines"] = stats.get("trailing_ws_lines", 0) + n
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
# 连续空行压成一行;文首文末空白裁掉
|
|
||||||
def _collapse_blank_lines(
|
|
||||||
text: str, params: Dict[str, Any], stats: Dict[str, int]
|
|
||||||
) -> str:
|
|
||||||
text = re.sub(r"[ \t]*\n(?:[ \t]*\n){2,}", "\n\n", text)
|
|
||||||
text = text.strip("\n") + "\n" if text.strip() else ""
|
|
||||||
stats["collapsed_blanks"] = stats.get("collapsed_blanks", 1)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
REGISTRY: Dict[str, RuleFunc] = {
|
|
||||||
"strip_page_lines": _strip_page_lines,
|
|
||||||
"strip_repeated_short_lines": _strip_repeated_short_lines,
|
|
||||||
"strip_toc_dots": _strip_toc_dots,
|
|
||||||
"drop_images": _drop_images,
|
|
||||||
"normalize_tables": _normalize_tables,
|
|
||||||
"strip_stray_html": _strip_stray_html,
|
|
||||||
"rstrip_lines": _rstrip_lines,
|
|
||||||
"collapse_blank_lines": _collapse_blank_lines,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def load_rules(path: Optional[Path] = None) -> List[Rule]:
|
|
||||||
"""从 YAML 加载规则;未指定路径时用包内默认规则。"""
|
|
||||||
if path is None:
|
|
||||||
path = Path(__file__).resolve().parent.parent / "rules" / "default.yaml"
|
|
||||||
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
||||||
rules = [Rule.from_dict(d) for d in data.get("rules", [])]
|
|
||||||
unknown = [r.name for r in rules if r.name not in REGISTRY]
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(f"未知规则: {unknown},可用规则: {sorted(REGISTRY)}")
|
|
||||||
return sorted(rules, key=lambda r: r.order)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# 规则编写指南
|
|
||||||
|
|
||||||
一条清洗规则 = `cleaner/rules.py` 里的一个函数 + `rules/*.yaml` 里的一条声明。
|
|
||||||
|
|
||||||
## 函数签名
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _your_rule(text: str, params: dict, stats: dict) -> str:
|
|
||||||
...
|
|
||||||
stats["your_hits"] = stats.get("your_hits", 0) + n # 计入报告
|
|
||||||
return new_text
|
|
||||||
```
|
|
||||||
|
|
||||||
- 输入输出都是**整篇文本**;引擎按 `order` 从小到大依次调用。
|
|
||||||
- `params` 来自 YAML,改参数不用改代码。
|
|
||||||
- `stats` 的 key 会出现在 `report.json`,命名用蛇形复数(如 `page_lines`)。
|
|
||||||
|
|
||||||
## YAML 声明
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
rules:
|
|
||||||
- name: your_rule # 必须与 REGISTRY 键一致,加载时校验
|
|
||||||
order: 55 # 应用顺序;同段处理尽量插在相关规则之间
|
|
||||||
enabled: true # 某用例不适用的规则可单关
|
|
||||||
params:
|
|
||||||
threshold: 3
|
|
||||||
protect: ["正则1", "正则2"]
|
|
||||||
```
|
|
||||||
|
|
||||||
## 设计守则(从 gov_test_data 踩坑总结)
|
|
||||||
|
|
||||||
1. **只删噪音,不删正文**。拿不准的形态默认保留,宁可漏删不可误删。
|
|
||||||
2. **重复 ≠ 页眉**。标书是平行模板文档:签章栏、日期栏、声明结尾句、
|
|
||||||
编号条款都会重复出现。判定页眉前先过:
|
|
||||||
- `protect` 正则(业务模板白名单)
|
|
||||||
- 内容形态豁免(编号/列表/表格/字段行)
|
|
||||||
- burst 检测(连续刷屏才是 OCR 崩坏)
|
|
||||||
3. **每个规则独立可测**。tests/ 里用真实数据浓缩的最小样例做回归,
|
|
||||||
尤其是防误伤样例(protect 命中、编号条款保留)。
|
|
||||||
4. **统计必须可见**。每条规则报告命中数,批量清洗后扫一眼 report.json
|
|
||||||
就能发现某条规则突然命中异常(多半是误伤)。
|
|
||||||
|
|
||||||
## 已知规则明细
|
|
||||||
|
|
||||||
见 `rules/default.yaml` 内注释与 README 规则表。
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["setuptools>=68"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
||||||
|
|
||||||
[project]
|
|
||||||
name = "govdoc-md-cleaner"
|
|
||||||
version = "0.1.0"
|
|
||||||
description = "政务文档(招标/投标/采购/合同)PDF→Markdown 产物的清洗工具"
|
|
||||||
readme = "README.md"
|
|
||||||
requires-python = ">=3.10"
|
|
||||||
license = { text = "MIT" }
|
|
||||||
dependencies = ["PyYAML>=6.0"]
|
|
||||||
|
|
||||||
[project.scripts]
|
|
||||||
md-clean = "cleaner.cli:main"
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
|
||||||
include = ["cleaner*"]
|
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
|
||||||
cleaner = ["../rules/*.yaml"]
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# research-wiki 文档治理说明
|
||||||
|
|
||||||
|
> **更新触发点:** 新增、删除或改变 Wiki 分类,修改 design 冻结方式、事实权威或数据边界时,
|
||||||
|
> 必须在同一变更中更新本文件。
|
||||||
|
|
||||||
|
清洗研究会同时产生不同寿命的材料:某次方案选择需要保留当时的判断,当前机制需要随实现更新,
|
||||||
|
运行步骤需要反复验证,探索笔记则可能很快失效。它们不能使用同一种维护方式。
|
||||||
|
|
||||||
|
## 1. 目录与生命周期
|
||||||
|
|
||||||
|
| 内容 | 目录 | 维护方式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 动工前的方案比较、选择和代价 | `design/` | 批准后冻结;改变时新增下一编号 |
|
||||||
|
| 当前有效的机制、数据流和原因 | `explanation/` | 事实变化时同步更新 |
|
||||||
|
| 代码无法完整表达的稳定查询事实 | `reference/` | 权威事实变化时更新 |
|
||||||
|
| 可复现运行、验证与排障步骤 | `guides/` | 操作变化时更新并重新验证 |
|
||||||
|
| 调研笔记、计划和未收敛草稿 | `scratch/` | 不作为当前事实;由项目负责人决定去留 |
|
||||||
|
| 当前阶段和已完成工作 | 根目录 `README.md` | 阶段变化时更新 |
|
||||||
|
|
||||||
|
根目录 `AGENTS.md` 与 `CLAUDE.md` 是协作者入口,不放入 Wiki。
|
||||||
|
空分类使用 `.gitkeep` 保留,不创建只写未来设想的占位文档。
|
||||||
|
|
||||||
|
## 2. 常青层与记录层
|
||||||
|
|
||||||
|
`explanation/`、`reference/`、`guides/` 属于常青层:现实改变后直接更新原文,使其继续描述当前事实。
|
||||||
|
|
||||||
|
`design/` 属于记录层:它保存决策发生时的问题、方案、理由和代价。批准后即使结论后来被替代,
|
||||||
|
也保留原文并新增一份 design。
|
||||||
|
|
||||||
|
判断文档去向时可以问:三个月后发现它不再正确,是应该改掉原文,还是保留原判断并记录新决定?
|
||||||
|
前者进入常青层,后者进入 `design/`。
|
||||||
|
|
||||||
|
## 3. design:先记录选择,再实施
|
||||||
|
|
||||||
|
以下事项需要 design:
|
||||||
|
|
||||||
|
- 清洗语义、保真边界、规则顺序或冲突处理方式;
|
||||||
|
- 输入输出契约、评估指标、标注方法或实验方案;
|
||||||
|
- 重要依赖、公共接口、源码结构或数据目录;
|
||||||
|
- 事实权威、数据安全和跨仓职责的改变;
|
||||||
|
- 一旦选错会造成大面积返工或使实验作废的决策。
|
||||||
|
|
||||||
|
文件使用 `NNNN-short-title.md`,编号四位递增,英文短名只用于稳定引用。建议结构如下:
|
||||||
|
|
||||||
|
1. 状态;
|
||||||
|
2. 问题与可观察现象;
|
||||||
|
3. 目标与非目标;
|
||||||
|
4. 候选方案;
|
||||||
|
5. 决定与理由;
|
||||||
|
6. 风险和边界;
|
||||||
|
7. 实施与验收。
|
||||||
|
|
||||||
|
草稿可以在评审期间修改。批准后冻结;如果决策改变,新文档必须写明 `supersedes: NNNN`,并保留旧文档。
|
||||||
|
|
||||||
|
## 4. explanation:解释当前为什么这样工作
|
||||||
|
|
||||||
|
这里解释已经生效的算法流程、数据流、边界、适用条件和代价。第一节从实际问题、失败案例或总览开始。
|
||||||
|
|
||||||
|
未批准或未实现的机制必须明确标注为候选方案,并链接到对应 design,不能通过 explanation 绕过确认门。
|
||||||
|
|
||||||
|
## 5. reference:准确查询事实
|
||||||
|
|
||||||
|
这里保存需要稳定查询、但未来代码和测试不能完整表达的事实,例如外部格式约束、共享字段语义、
|
||||||
|
经验证的第三方行为或数据集的脱敏统计。
|
||||||
|
|
||||||
|
函数签名、默认参数和数据结构如果已由代码或测试表达,不在这里维护第二份副本。
|
||||||
|
|
||||||
|
## 6. guides:完成一项已验证操作
|
||||||
|
|
||||||
|
guide 必须来自实际运行,至少包含前置条件、准确命令、预期结果、验证日期和失败后的判断方式。
|
||||||
|
尚不存在的脚本或预想中的流程不能写成指南。
|
||||||
|
|
||||||
|
## 7. scratch:容纳尚未收敛的材料
|
||||||
|
|
||||||
|
调研摘记、比较表、执行计划和 design 草稿可以放在 `scratch/`。它们可以支持连续协作,
|
||||||
|
但不能作为当前机制或已批准决定引用。Agent 不自动删除 scratch 内容。
|
||||||
|
|
||||||
|
## 8. 数据与实验材料
|
||||||
|
|
||||||
|
- 客户原文、审核文件、历史大结果和软链接不属于 Wiki;
|
||||||
|
- 外部真实数据默认只读,不因研究需要复制进仓库;
|
||||||
|
- 实验产出只有在目录和脱敏规则获批后才可保存;
|
||||||
|
- 小型汇总进入 Git 前必须确认无法还原客户内容;
|
||||||
|
- 每个实验应能追溯输入范围、版本、参数、环境、指标和输出位置。
|
||||||
|
|
||||||
|
## 9. 写作与审查
|
||||||
|
|
||||||
|
- 面向没有参加过讨论、但具备相关技术背景的读者;
|
||||||
|
- 从真实问题或读者可见现象开始,不先堆术语;
|
||||||
|
- 结论写清理由、代价、适用边界和验证状态;
|
||||||
|
- 参数、路径、命令、指标和当前阶段只维护一个权威版本;
|
||||||
|
- 不把目标写成已完成,不泄露真实文档内容,不用文档替代测试;
|
||||||
|
- 简单事实用短段落,只有比较关系确实更清楚时才使用表格或图。
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 0001:文档治理基础架构
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
已批准。2026-08-20,项目负责人明确要求清除现有代码,并参考
|
||||||
|
[`Bepr4/GovDoc-compare`](https://github.com/Bepr4/GovDoc-compare) 建立最基础的 Wiki 与 Agent 入口框架。
|
||||||
|
|
||||||
|
## 1. 问题
|
||||||
|
|
||||||
|
仓库此前已经包含一个可运行的 Python 清洗器、YAML 规则和测试,但尚未先建立研究问题、清洗边界、
|
||||||
|
评估方法与决策记录。继续在实现上迭代会让“当前代码做了什么”和“我们为什么应该这样做”混在一起。
|
||||||
|
|
||||||
|
项目需要先回到可审查的治理基础:明确事实放在哪里、设计何时冻结、真实数据如何隔离,以及不同 Agent
|
||||||
|
进入仓库时遵循哪一份规则。
|
||||||
|
|
||||||
|
## 2. 目标与非目标
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 清除既有应用代码和工程配置,避免把旧实现默认为新基线;
|
||||||
|
- 为人类与编码 Agent 提供一致的仓库入口;
|
||||||
|
- 区分冻结决策、当前说明、稳定事实、验证指南和临时草稿;
|
||||||
|
- 明确外部真实文档的只读与不入库边界;
|
||||||
|
- 只建立当前确实需要的最小文档骨架。
|
||||||
|
|
||||||
|
非目标:
|
||||||
|
|
||||||
|
- 本次不定义新的清洗算法、规则 schema 或评估指标;
|
||||||
|
- 不创建源码、测试、实验或 CI 空壳;
|
||||||
|
- 不保留旧实现作为默认兼容基线;
|
||||||
|
- 不读取、复制、修改或清洗真实业务文档;
|
||||||
|
- 不提交、不推送,也不修改参考仓库。
|
||||||
|
|
||||||
|
## 3. 方案比较
|
||||||
|
|
||||||
|
### 方案 A:文档优先的最小骨架(采用)
|
||||||
|
|
||||||
|
保留 Git 历史,删除现有实现,建立 README、同步 Agent 入口和五类 Wiki 目录。结构足以约束下一步研究,
|
||||||
|
同时不会提前固定语言、依赖、接口和实验契约。
|
||||||
|
|
||||||
|
### 方案 B:保留旧代码并补 Wiki
|
||||||
|
|
||||||
|
迁移成本较低,但旧实现会继续被误解为已批准基线,与“清除所有代码”的明确要求冲突。
|
||||||
|
|
||||||
|
### 方案 C:同时创建完整工程空壳
|
||||||
|
|
||||||
|
可以提前提供 `src/`、`tests/`、配置和 CI,但这些选择尚无设计依据。空目录还容易让 README 把目标状态写成现状。
|
||||||
|
|
||||||
|
## 4. 决定
|
||||||
|
|
||||||
|
采用方案 A,并借鉴参考仓库的文档生命周期,而不是复制其算法、代码或与 GovDoc-SaaS 相关的具体约束。
|
||||||
|
|
||||||
|
```text
|
||||||
|
govdoc-md-cleaner/
|
||||||
|
├── AGENTS.md
|
||||||
|
├── CLAUDE.md
|
||||||
|
├── README.md
|
||||||
|
└── research-wiki/
|
||||||
|
├── README.md
|
||||||
|
├── design/
|
||||||
|
├── explanation/
|
||||||
|
├── reference/
|
||||||
|
├── guides/
|
||||||
|
└── scratch/
|
||||||
|
```
|
||||||
|
|
||||||
|
- 根 README 是当前阶段和完成进度的唯一权威;
|
||||||
|
- `AGENTS.md` 与 `CLAUDE.md` 除首行标题外逐字一致;
|
||||||
|
- `research-wiki/README.md` 定义文档分类和更新规则;
|
||||||
|
- `design/` 批准后冻结,变化通过下一编号记录;
|
||||||
|
- 当前不加入镜像检查脚本,因为用户要求清空代码;先使用 README 中的 shell 检查命令验证。
|
||||||
|
|
||||||
|
## 5. 数据与恢复边界
|
||||||
|
|
||||||
|
- `/home/lihaoze/gov_test_data` 视为外部只读材料;本次不访问其内容;
|
||||||
|
- 原 Python 包、规则、测试和配置从工作树删除,但仍可从 Git 历史恢复;
|
||||||
|
- `.git` 历史保留,不执行重写历史或远端操作;
|
||||||
|
- 新实现只有在后续 design 明确范围并获批后才建立。
|
||||||
|
|
||||||
|
## 6. 风险与控制
|
||||||
|
|
||||||
|
- **文档镜像漂移:** 当前以 `diff` 命令人工检查;需要自动化时另建设计;
|
||||||
|
- **空目录被视为能力:** README 明确当前没有实现,空分类只保存 `.gitkeep`;
|
||||||
|
- **旧实现被无意恢复:** 后续设计需要重新说明需求和验收标准,不从历史代码推断契约;
|
||||||
|
- **客户数据误入库:** Agent 规则和 `.gitignore` 同时声明边界,提交前仍需检查实际 diff;
|
||||||
|
- **治理过重:** 本次只保留一个必要 design,不创建教程、API 文档或未来工程占位。
|
||||||
|
|
||||||
|
## 7. 实施与验收
|
||||||
|
|
||||||
|
实施内容:
|
||||||
|
|
||||||
|
1. 删除 `cleaner/`、`rules/`、`tests/`、旧 `docs/` 与 `pyproject.toml`;
|
||||||
|
2. 重写根 README 和忽略规则;
|
||||||
|
3. 创建正文镜像的 `AGENTS.md` 与 `CLAUDE.md`;
|
||||||
|
4. 创建 Wiki 入口、首个 design 和五类目录;
|
||||||
|
5. 检查目录中不存在应用代码,验证两份入口正文一致并审查 Git diff。
|
||||||
|
|
||||||
|
验收条件:
|
||||||
|
|
||||||
|
- 工作树中不存在原 Python、YAML 规则、测试或打包配置;
|
||||||
|
- 两份 Agent 入口除首行外无差异;
|
||||||
|
- Wiki 分类与维护方式有唯一说明;
|
||||||
|
- README 只声明实际存在的能力和可运行检查;
|
||||||
|
- 没有访问或写入外部真实数据;
|
||||||
|
- 没有提交、推送或修改远端状态。
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# 政务文档 Markdown 默认清洗规则集
|
|
||||||
# 每条规则: name(必填,须在引擎 REGISTRY 中注册) / order(应用顺序) / enabled / params
|
|
||||||
# 针对 PDF→Markdown 管线(MinerU/OCR)产物的典型脏数据。
|
|
||||||
|
|
||||||
rules:
|
|
||||||
# 1) 页码行:"第 X 页 共 Y 页" / "第X页共4页" / "Page 3 of 10" / "3 / 10"
|
|
||||||
- name: strip_page_lines
|
|
||||||
order: 10
|
|
||||||
params:
|
|
||||||
keep_bare_numbers: true # 单独一行纯数字(可能是页码也可能是编号),默认保留
|
|
||||||
|
|
||||||
# 2) 页眉页脚 + OCR 重复崩坏行:
|
|
||||||
# a) 同一短行全篇重复 >= threshold 次(如逐页出现的项目名、"正本")
|
|
||||||
# b) 同一短行连续刷屏 >= burst_limit 次(OCR 崩坏,如"审计程序"×1359)
|
|
||||||
# protect 列出"重复但属于正文模板"的保护正则(标书里逐章出现的签章/日期栏)
|
|
||||||
# 内容形态行(编号条款/列表/表格行)天然重复,已在引擎侧豁免
|
|
||||||
- name: strip_repeated_short_lines
|
|
||||||
order: 20
|
|
||||||
params:
|
|
||||||
threshold: 3
|
|
||||||
max_len: 40
|
|
||||||
burst_limit: 5
|
|
||||||
burst_gap: 2
|
|
||||||
protect:
|
|
||||||
- "公章" # 投标人:(公章)
|
|
||||||
- "签名|签字|盖章" # 法定代表人签名:
|
|
||||||
- "日期|年.*月.*日" # 日期: / 日期: 年 月 日
|
|
||||||
- "^致[::]" # 致:xxx(投标函收件人)
|
|
||||||
- "负责|声明" # 声明函固定结尾句(中小企业声明函等)
|
|
||||||
|
|
||||||
# 3) 目录点线:"第一章 投标邀请函 ……………… 2"(保留标题文字,去掉点线和页码)
|
|
||||||
- name: strip_toc_dots
|
|
||||||
order: 30
|
|
||||||
|
|
||||||
# 4) 图片引用: 为死链,整行删除
|
|
||||||
- name: drop_images
|
|
||||||
order: 40
|
|
||||||
params:
|
|
||||||
placeholder: null # 需要保留位置时改为 "[图]" 之类
|
|
||||||
|
|
||||||
# 5) HTML 表格 → Markdown 管道表格(<table><tr><td> 单行压缩形态)
|
|
||||||
- name: normalize_tables
|
|
||||||
order: 50
|
|
||||||
|
|
||||||
# 6) 散落的 <br/> 标签
|
|
||||||
- name: strip_stray_html
|
|
||||||
order: 60
|
|
||||||
|
|
||||||
# 7) 行尾空白(MinerU 输出每行带双空格硬换行符)
|
|
||||||
- name: rstrip_lines
|
|
||||||
order: 70
|
|
||||||
|
|
||||||
# 8) 连续空行压为 1 行,裁掉文首文末空白
|
|
||||||
- name: collapse_blank_lines
|
|
||||||
order: 80
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
"""清洗规则单元测试。
|
|
||||||
|
|
||||||
fixtures 里是各脏数据模式的最小样例,跑一遍断言规则命中且正文无损。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
||||||
|
|
||||||
from cleaner.cleaner import MarkdownCleaner
|
|
||||||
from cleaner.rules import (
|
|
||||||
_drop_images,
|
|
||||||
_normalize_tables,
|
|
||||||
_rstrip_lines,
|
|
||||||
_strip_page_lines,
|
|
||||||
_strip_repeated_short_lines,
|
|
||||||
_strip_toc_dots,
|
|
||||||
load_rules,
|
|
||||||
)
|
|
||||||
|
|
||||||
S = {} # 每个用例独立 stats
|
|
||||||
|
|
||||||
|
|
||||||
def st():
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class TestPageLines(unittest.TestCase):
|
|
||||||
def test_cn_page(self):
|
|
||||||
text = "正文A\n第 3 页 共 53 页\n正文B\n第4页共4页\n结尾"
|
|
||||||
out = _strip_page_lines(text, {}, st())
|
|
||||||
self.assertEqual(out, "正文A\n正文B\n结尾")
|
|
||||||
|
|
||||||
def test_en_page(self):
|
|
||||||
text = "foo\nPage 3 of 10\nbar\n- 4 -\nbaz"
|
|
||||||
out = _strip_page_lines(text, {"keep_bare_numbers": False}, st())
|
|
||||||
self.assertNotIn("Page 3", out)
|
|
||||||
self.assertNotIn("- 4 -", out)
|
|
||||||
|
|
||||||
def test_bare_number_kept_by_default(self):
|
|
||||||
text = "条款\n12\n下文"
|
|
||||||
out = _strip_page_lines(text, {}, st())
|
|
||||||
self.assertIn("12", out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRepeatedLines(unittest.TestCase):
|
|
||||||
def test_header_removed(self):
|
|
||||||
lines = ["某某采购项目招标文件"] + ["内容%d" % i for i in range(5)]
|
|
||||||
text = "\n".join(("某某采购项目招标文件 \n" + l) for l in lines)
|
|
||||||
out = _strip_repeated_short_lines(text, {"threshold": 3, "max_len": 40}, st())
|
|
||||||
self.assertNotIn("某某采购项目招标文件", out)
|
|
||||||
self.assertIn("内容1", out)
|
|
||||||
|
|
||||||
def test_protected_signature_kept(self):
|
|
||||||
text = "\n".join(["投标人:(公章)"] * 4 + ["正文"])
|
|
||||||
out = _strip_repeated_short_lines(
|
|
||||||
text, {"threshold": 3, "max_len": 40, "protect": ["公章"]}, st()
|
|
||||||
)
|
|
||||||
self.assertIn("投标人:(公章)", out)
|
|
||||||
|
|
||||||
def test_numbered_clause_kept(self):
|
|
||||||
# 编号条款是正文不是页眉
|
|
||||||
text = "\n".join(["(1)乙方须接受甲方监督。"] * 4 + ["正文"])
|
|
||||||
out = _strip_repeated_short_lines(text, {"threshold": 3, "max_len": 40}, st())
|
|
||||||
self.assertIn("(1)乙方须接受甲方监督。", out)
|
|
||||||
|
|
||||||
def test_ocr_burst_removed(self):
|
|
||||||
text = "\n".join(["审计程序"] * 30)
|
|
||||||
out = _strip_repeated_short_lines(
|
|
||||||
text,
|
|
||||||
{"threshold": 999, "max_len": 40, "burst_limit": 5, "burst_gap": 2},
|
|
||||||
st(),
|
|
||||||
)
|
|
||||||
self.assertNotIn("审计程序", out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTocDots(unittest.TestCase):
|
|
||||||
def test_toc_line(self):
|
|
||||||
text = "第一章 投标邀请函 ……………………………………… 2"
|
|
||||||
out = _strip_toc_dots(text, {}, st())
|
|
||||||
self.assertEqual(out, "第一章 投标邀请函")
|
|
||||||
|
|
||||||
def test_plain_toc_line_keeps_text(self):
|
|
||||||
# 无标题目录行:去掉点线页码,保留"序号+标题"文字
|
|
||||||
text = "21 迷交的投标文件 ………………………………………………… 25"
|
|
||||||
out = _strip_toc_dots(text, {}, st())
|
|
||||||
self.assertEqual(out, "21 迷交的投标文件")
|
|
||||||
|
|
||||||
def test_body_with_ellipsis_kept(self):
|
|
||||||
text = "此处省略部分内容……后续"
|
|
||||||
out = _strip_toc_dots(text, {}, st())
|
|
||||||
self.assertIn("后续", out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestImages(unittest.TestCase):
|
|
||||||
def test_image_line_dropped(self):
|
|
||||||
text = " \n正文"
|
|
||||||
out = _drop_images(text, {}, st())
|
|
||||||
self.assertEqual(out, "正文")
|
|
||||||
|
|
||||||
def test_placeholder(self):
|
|
||||||
text = "\n正文"
|
|
||||||
out = _drop_images(text, {"placeholder": "[图]"}, st())
|
|
||||||
self.assertIn("[图]", out)
|
|
||||||
|
|
||||||
def test_base64_image_dropped(self):
|
|
||||||
text = "\n正文"
|
|
||||||
out = _drop_images(text, {}, st())
|
|
||||||
self.assertNotIn("base64", out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestTables(unittest.TestCase):
|
|
||||||
def test_table_to_pipe(self):
|
|
||||||
html = "<table><tr><td>序号</td><td>名称</td></tr><tr><td>1</td><td>保洁</td></tr></table>"
|
|
||||||
out = _normalize_tables(html, {}, st())
|
|
||||||
self.assertIn("| 序号 | 名称 |", out)
|
|
||||||
self.assertIn("| 1 | 保洁 |", out)
|
|
||||||
self.assertIn("|---|---|", out)
|
|
||||||
|
|
||||||
def test_pipe_escaped(self):
|
|
||||||
html = "<table><tr><td>a|b</td></tr></table>"
|
|
||||||
out = _normalize_tables(html, {}, st())
|
|
||||||
self.assertIn("a\\|b", out)
|
|
||||||
|
|
||||||
def test_br_in_cell(self):
|
|
||||||
html = "<table><tr><td>品<br/>目</td></tr></table>"
|
|
||||||
out = _normalize_tables(html, {}, st())
|
|
||||||
self.assertIn("品 目", out)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEngine(unittest.TestCase):
|
|
||||||
def test_full_pipeline(self):
|
|
||||||
cleaner = MarkdownCleaner(rules=load_rules())
|
|
||||||
raw = (
|
|
||||||
"# 标题\n \n第 1 页 共 2 页 \n"
|
|
||||||
"正文一段。 \n<table><tr><td>a</td></tr></table> \n\n\n\n尾部\n"
|
|
||||||
)
|
|
||||||
result = cleaner.clean_text(raw)
|
|
||||||
self.assertNotIn("images/", result.text)
|
|
||||||
self.assertNotIn("第 1 页", result.text)
|
|
||||||
self.assertNotIn("<table>", result.text)
|
|
||||||
self.assertIn("| a |", result.text)
|
|
||||||
self.assertNotIn("\n\n\n", result.text)
|
|
||||||
|
|
||||||
def test_default_rules_load(self):
|
|
||||||
rules = load_rules()
|
|
||||||
self.assertTrue(len(rules) >= 8)
|
|
||||||
self.assertEqual(rules, sorted(rules, key=lambda r: r.order))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main(verbosity=2)
|
|
||||||
Reference in New Issue
Block a user