diff --git a/.gitignore b/.gitignore index 0f71e77..84059b9 100644 --- a/.gitignore +++ b/.gitignore @@ -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 report.json -__pycache__/ -*.pyc -.venv/ -dist/ -*.egg-info/ + +# Editor and operating-system files +.DS_Store +.idea/ +.vscode/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..455e949 --- /dev/null +++ b/AGENTS.md @@ -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. 沟通与协作 + +- 像同事协作一样,用直接、可读的中文说明判断、变化和风险; +- 问什么答什么,一次聚焦当前问题,不把未经请求的后续工作一起推进; +- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户; +- 进度和最终报告必须对应真实工具输出,不把计划描述成结果; +- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3b75e68 --- /dev/null +++ b/CLAUDE.md @@ -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. 沟通与协作 + +- 像同事协作一样,用直接、可读的中文说明判断、变化和风险; +- 问什么答什么,一次聚焦当前问题,不把未经请求的后续工作一起推进; +- 能在授权范围内安全判断的事项直接完成;会改变范围或契约的选择交给用户; +- 进度和最终报告必须对应真实工具输出,不把计划描述成结果; +- 不输出内部思维链,只提供可复核的依据、实际变更和验证结果。 diff --git a/README.md b/README.md index fd29ead..057c861 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,67 @@ # govdoc-md-cleaner -政务文档(招标 / 投标 / 采购 / 合同)**PDF→Markdown 产物**的规则化清洗工具。 +政务文档 PDF→Markdown 清洗方向的独立研究与治理仓库。 -上游解析管线(MinerU / OCR)把 PDF 转成 Markdown 后,会带入大量非正文噪音: -逐页重复的页眉页脚、页码行、目录点线、死链图片引用、压成单行的 HTML 表格、 -行尾硬换行双空格,以及 OCR 重复崩坏段。本工具用一套 **YAML 声明、按序应用、 -逐条统计** 的规则把它们清掉,输出可直接进入比对 / RAG / 审核管线的干净 Markdown。 +本仓库用于澄清清洗问题、记录设计选择、积累可复核证据,并在方案获得确认后再建立实现。 +它目前不是可安装的 Python 包,也不提供命令行工具或生产接口。 -## 数据来源 +## 当前阶段 -清洗目标为 `/home/lihaoze/gov_test_data`(律所上传的真实政务文件的筛选子集, -详见其 `compare/readme.md`),未来会持续接入更多批次的 Markdown 文件。 -**本项目只包含清洗代码与规则,不包含任何业务数据**——测试数据不入库。 +2026-08-20 已完成仓库重置与最小文档治理骨架: -## 清洗规则(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` | 死链图片 | `![](images/xxx.jpg)`、base64 内嵌图 | -| 50 | `normalize_tables` | HTML 表格 | `
…` 单行压缩 → Markdown 管道表格 | -| 60 | `strip_stray_html` | 散落标签 | `
` | -| 70 | `rstrip_lines` | 行尾空白 | MinerU 每行行尾的双空格硬换行 | -| 80 | `collapse_blank_lines` | 空行折叠 | 连续空行 → 1 行;裁掉文首文末 | +当前没有清洗算法、可执行命令、运行依赖、测试套件或已批准的输入输出契约。 +目录存在只代表文档落点已经建立,不代表相应能力已经完成。 -### 防误伤设计(来自真实数据踩坑) +## 项目边界 -- **protect 正则**:`投标人:(公章)`、`法定代表人签名:`、`日期: 年 月 日`、 - `致:xxx`、声明函结尾句——标书里逐章重复,**是正文模板不是页眉**,默认保护。 -- **内容形态豁免**:编号条款 `(1)…`、`1、…`、`一、…`、列表/表格行、 - `乙方:xxx` 字段行——平行结构天然重复,引擎侧直接不参与页眉判定。 -- **burst 检测**:同一短行连续刷屏 ≥5 次(间隔 ≤2 行)判为 OCR 重复崩坏, - 直接删除(003-10 案例出现"审计程序"连续 1359 行)。 +- 研究对象是 MinerU、OCR 等 PDF→Markdown 管线产生的噪音与清洗方法; +- `/home/lihaoze/gov_test_data` 中的真实材料属于外部只读数据,不复制、不修改、不提交; +- 任何清洗语义、规则格式、评估指标、代码目录或运行依赖,都应先形成设计记录并获得确认; +- 研究结果只有经过独立评审后,才能进入其他产品或生产仓库。 -## 安装 +## 目录结构 -```bash -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` 记录每个文件每条规则的命中数, -清洗过程完全可审计、可回滚(重跑即得原结果的对照)。 - -## 项目结构 - -``` +```text govdoc-md-cleaner/ -├── cleaner/ -│ ├── cleaner.py # 引擎:按序应用规则,输出 CleanResult(text, stats) -│ ├── rules.py # 规则实现 + REGISTRY + YAML 加载 -│ └── cli.py # single / batch 两个子命令 -├── rules/ -│ └── default.yaml # 默认规则集(顺序、开关、参数、protect 列表) -├── tests/ -│ └── test_rules.py # 每条规则的最小样例 + 防误伤回归 -└── docs/ - └── RULES.md # 规则编写指南(新增规则的方法) +├── AGENTS.md +├── CLAUDE.md +├── README.md +└── research-wiki/ + ├── README.md + ├── design/ # 方案选择与冻结决策 + ├── explanation/ # 当前有效机制及原因 + ├── reference/ # 需要准确查询的稳定事实 + ├── guides/ # 已实际验证的操作步骤 + └── 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 -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 +当前没有测试命令;在真实实现和测试体系获批并落地前,不应声明测试通过。 diff --git a/cleaner/__init__.py b/cleaner/__init__.py deleted file mode 100644 index 2785bf4..0000000 --- a/cleaner/__init__.py +++ /dev/null @@ -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"] diff --git a/cleaner/cleaner.py b/cleaner/cleaner.py deleted file mode 100644 index 7764ed9..0000000 --- a/cleaner/cleaner.py +++ /dev/null @@ -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, - ) - ) diff --git a/cleaner/cli.py b/cleaner/cli.py deleted file mode 100644 index d656411..0000000 --- a/cleaner/cli.py +++ /dev/null @@ -1,89 +0,0 @@ -"""命令行入口。 - -用法: - md-clean single [-o output.md] [--diff] [--rules rules.yaml] - md-clean batch [-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="输出目录(缺省 _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()) diff --git a/cleaner/rules.py b/cleaner/rules.py deleted file mode 100644 index 5e0a25a..0000000 --- a/cleaner/rules.py +++ /dev/null @@ -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) - - -# 图片引用:![](images/xxx.jpg) —— 图片目录不在交付物里,引用是死链 -_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 表格规范化:
压缩为合法 Markdown 管道表格 -_RE_TABLE = re.compile(r"]*>(.*?)
", re.DOTALL | re.IGNORECASE) -_RE_TR = re.compile(r"]*>(.*?)", re.DOTALL | re.IGNORECASE) -_RE_TD = re.compile(r"]*>(.*?)", re.DOTALL | re.IGNORECASE) - - -def _cell_text(raw: str) -> str: - cell = re.sub(r"", " ", 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) - # 残缺兜底:文档截断导致 未闭合时,把剩余 行也转掉 - if "" + m.group(0) + "
"), - tail, - ) - tail = re.sub(r"]*>", "", tail) - new = head + tail - n += 1 - stats["html_tables"] = stats.get("html_tables", 0) + n - return new - - -# 表格内
会被上面规则拍平;这里处理散落的 HTML 换行/空白标签 -def _strip_stray_html(text: str, params: Dict[str, Any], stats: Dict[str, int]) -> str: - new = re.sub(r"", " ", text, flags=re.IGNORECASE) - if new != text: - stats["stray_html"] = stats.get("stray_html", 0) + text.count(" 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) diff --git a/docs/RULES.md b/docs/RULES.md deleted file mode 100644 index 2882943..0000000 --- a/docs/RULES.md +++ /dev/null @@ -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 规则表。 diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 0dac4ab..0000000 --- a/pyproject.toml +++ /dev/null @@ -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"] diff --git a/research-wiki/README.md b/research-wiki/README.md new file mode 100644 index 0000000..de4385c --- /dev/null +++ b/research-wiki/README.md @@ -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. 写作与审查 + +- 面向没有参加过讨论、但具备相关技术背景的读者; +- 从真实问题或读者可见现象开始,不先堆术语; +- 结论写清理由、代价、适用边界和验证状态; +- 参数、路径、命令、指标和当前阶段只维护一个权威版本; +- 不把目标写成已完成,不泄露真实文档内容,不用文档替代测试; +- 简单事实用短段落,只有比较关系确实更清楚时才使用表格或图。 diff --git a/research-wiki/design/0001-repository-foundation.md b/research-wiki/design/0001-repository-foundation.md new file mode 100644 index 0000000..dd8d5c1 --- /dev/null +++ b/research-wiki/design/0001-repository-foundation.md @@ -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 只声明实际存在的能力和可运行检查; +- 没有访问或写入外部真实数据; +- 没有提交、推送或修改远端状态。 diff --git a/research-wiki/explanation/.gitkeep b/research-wiki/explanation/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/guides/.gitkeep b/research-wiki/guides/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/reference/.gitkeep b/research-wiki/reference/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/research-wiki/scratch/.gitkeep b/research-wiki/scratch/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/rules/default.yaml b/rules/default.yaml deleted file mode 100644 index 8d7b760..0000000 --- a/rules/default.yaml +++ /dev/null @@ -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) 图片引用:![](images/xxx.jpg) 为死链,整行删除 - - name: drop_images - order: 40 - params: - placeholder: null # 需要保留位置时改为 "[图]" 之类 - - # 5) HTML 表格 → Markdown 管道表格(
单行压缩形态) - - name: normalize_tables - order: 50 - - # 6) 散落的
标签 - - name: strip_stray_html - order: 60 - - # 7) 行尾空白(MinerU 输出每行带双空格硬换行符) - - name: rstrip_lines - order: 70 - - # 8) 连续空行压为 1 行,裁掉文首文末空白 - - name: collapse_blank_lines - order: 80 diff --git a/tests/test_rules.py b/tests/test_rules.py deleted file mode 100644 index 49d5b17..0000000 --- a/tests/test_rules.py +++ /dev/null @@ -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 = "![](images/abc.jpg) \n正文" - out = _drop_images(text, {}, st()) - self.assertEqual(out, "正文") - - def test_placeholder(self): - text = "![](images/abc.jpg)\n正文" - out = _drop_images(text, {"placeholder": "[图]"}, st()) - self.assertIn("[图]", out) - - def test_base64_image_dropped(self): - text = "![名称](data:image/png;base64,AAAA)\n正文" - out = _drop_images(text, {}, st()) - self.assertNotIn("base64", out) - - -class TestTables(unittest.TestCase): - def test_table_to_pipe(self): - html = "
序号名称
1保洁
" - out = _normalize_tables(html, {}, st()) - self.assertIn("| 序号 | 名称 |", out) - self.assertIn("| 1 | 保洁 |", out) - self.assertIn("|---|---|", out) - - def test_pipe_escaped(self): - html = "
a|b
" - out = _normalize_tables(html, {}, st()) - self.assertIn("a\\|b", out) - - def test_br_in_cell(self): - html = "

" - out = _normalize_tables(html, {}, st()) - self.assertIn("品 目", out) - - -class TestEngine(unittest.TestCase): - def test_full_pipeline(self): - cleaner = MarkdownCleaner(rules=load_rules()) - raw = ( - "# 标题\n![](images/x.jpg) \n第 1 页 共 2 页 \n" - "正文一段。 \n
a
\n\n\n\n尾部\n" - ) - result = cleaner.clean_text(raw) - self.assertNotIn("images/", result.text) - self.assertNotIn("第 1 页", result.text) - self.assertNotIn("", 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)