diff --git a/docs/内部公文模块/内部公文模块业务逻辑梳理.md b/docs/内部公文模块/内部公文模块业务逻辑梳理.md new file mode 100644 index 0000000..10f293f --- /dev/null +++ b/docs/内部公文模块/内部公文模块业务逻辑梳理.md @@ -0,0 +1,673 @@ +# 内部公文模块业务逻辑梳理 + +## 1. 文档目的 + +本文档用于明确当前 `leaudit-platform` 中 `govdoc`(内部公文模块)的业务定位、核心对象、标准流程、权限边界与结果语义。 + +这份文档的目标不是讲技术实现,而是先把“这个模块业务上到底是什么、用户怎么用、系统应该怎么理解”说清楚,作为后续前后端对接补齐和修复的业务基线。 + +--- + +## 2. 模块定位 + +`govdoc` 模块是当前平台中的一个一等业务模块,不是外挂独立系统。 + +它的核心业务目标是: + +- 上传一份内部公文文件 +- 按公文格式规范自动发起审查 +- 输出结构化问题、规则检查结果、结构/大纲/实体识别结果 +- 提供 HTML 报告、批注 DOCX、原文下载 +- 全流程复用当前平台的账号、权限、地区、文档主档、OSS、异步任务体系 + +一句话概括: + +**内部公文模块本质上是平台里的“公文格式审查模块”。** + +--- + +## 3. 业务边界 + +### 3.1 本模块负责的事 + +- 公文文档上传 +- 自动格式审查 +- 审查结果展示 +- 审查历史留痕 +- 规则查看 +- 报告下载 + +### 3.2 本模块不负责的事 + +- 公文流转审批 +- 协同编辑 +- 发文流程管理 +- 独立规则平台 +- 第二套独立文档系统 +- 通用 RAG / 合同评审 / 交叉评查 + +因此,第一阶段不要把 `govdoc` 理解成“公文业务平台”,而应理解成“公文格式审查能力模块”。 + +--- + +## 4. 审查对象 + +从现有页面文案、规则展示和引擎能力看,`govdoc` 模块重点审查的是“公文格式规范”,而不是公文业务内容是否正确。 + +它重点关注的对象包括但不限于: + +- 标题 +- 发文字号 +- 主送机关 +- 正文层级 +- 附件标记 +- 附件标题 +- 署名 +- 成文日期 +- 字体字号 +- 行距 +- 标点 +- 禁用表述 +- 易混词 +- 文种合法性 +- 层级编号规范 +- 需要 AI/语义辅助判断的规则 + +因此,模块输出的“分数”和“问题”,本质上是: + +**公文格式规范符合度结果,不是业务内容质量结果。** + +--- + +## 5. 核心业务对象 + +本模块建议围绕以下 4 层业务对象展开。 + +### 5.1 文档 + +文档是平台主档中的一份公文文件。 + +业务语义: + +- 用户上传的一份公文文件 +- 是平台中的主对象 +- 需要标记该文档属于 `govdoc` 模块 + +建议复用: + +- `leaudit_documents` +- `leaudit_document_files` + +同时在主档中补充模块标识,例如: + +- `engine_type = 'govdoc'` + +### 5.2 审查运行(Run) + +Run 表示: + +- 某份文档的一次审查执行记录 +- 同一份文档可以有多次 run +- 新 run 不覆盖旧 run +- run 是执行留痕,不是业务主对象 + +### 5.3 规则结果 + +规则结果表示: + +- 每条规则对当前文档检查后的结果 + +单条规则最终状态不是只有“通过/不通过”,而是: + +- `pass` +- `fail` +- `skipped` + +其中 `skipped` 表示: + +- 条件不足,无法检查 +- 或外部依赖能力异常,临时跳过 + +### 5.4 报告产物 + +审查完成后会产出一组可展示或可下载的结果文件,例如: + +- HTML 报告 +- 批注 DOCX +- 段落 HTML +- 原文下载 +- 其他结构化中间结果 + +--- + +## 6. 主对象结论 + +结合当前业务确认,推荐正式拍板如下: + +- **业务主对象是文档,不是 run** +- run 是文档的一次审查执行留痕 + +原因: + +- 列表页已经确定为文档列表 +- 首页统计按文档最新结果计算 +- 删除、下载原文、权限继承都天然是文档维度 +- 用户面对的是“这份公文”,而不是技术上的 run + +--- + +## 7. 详情页主键结论 + +推荐正式拍板如下: + +- **详情页路由主键使用 `documentId`** +- 页面默认展示该文档“最新一次 run”的结果 +- 历史 run 在详情页内切换查看 + +推荐语义: + +- 页面入口面向文档 +- 页面内容展示 run 结果 +- 最新 run 为默认视图 + +不建议直接把页面主键设计成 `runId`,否则会带来这些问题: + +- 用户难以理解自己打开的是“哪份文档” +- 同一文档多次审查后页面入口不稳定 +- 列表页与详情页的主对象不一致 + +--- + +## 8. 标准业务流程 + +### 8.1 进入模块 + +用户进入 `govdoc` 模块后,可以看到: + +- 模块首页/概览 +- 最近文档 +- 统计数据 + +### 8.2 上传公文 + +用户上传一份公文文件。 + +第一阶段支持的文件类型应以现有前端为准,例如: + +- `doc` +- `docx` +- `wps` + +上传成功后: + +- 创建平台文档主档 +- 文档被标记为 `govdoc` + +### 8.3 自动发起审查 + +已确认第一阶段默认行为为: + +- 上传成功后自动发起审查 + +也就是: + +- 上传成功 +- 立即创建一条新的 run +- 投递异步任务处理 + +因此在业务上: + +- 上传 ≠ 审查结果已经出来 +- 上传 = 文档入库 + 自动创建 run + +### 8.4 异步执行审查 + +Worker 异步执行完整审查链路,包括: + +- 读取原文 +- 解析段落与样式 +- 识别段落角色 +- 抽取实体 +- 加载规则 +- 执行规则评估 +- 生成问题结果 +- 生成报告产物 +- 回写数据库与 OSS + +### 8.5 查看详情 + +用户打开某份文档详情后,默认应看到: + +- 该文档最新一次 run 的结果 + +包括: + +- 问题列表 +- 已通过规则 +- 已跳过规则 +- 结构面板 +- 大纲面板 +- 实体面板 +- 报告下载 + +### 8.6 查看历史审查记录 + +同一份文档允许存在多个 run。 + +已确认需要保留历史 run,因此: + +- 新 run 不覆盖旧 run +- 历史记录在详情页中查看 +- 第一阶段前端不主动开放“重跑按钮”,但底层模型要支持 + +--- + +## 9. 列表页业务定义 + +已确认列表页应为: + +- **文档列表** + +不是: + +- run 列表 + +因此每一行表示的是: + +- 一份公文文档 + +展示的是该文档当前/最近一次审查摘要,例如: + +- 文档名称 +- 文件类型 +- 上传时间 +- 当前状态 +- 最新分数 +- 最新问题数 +- 最近一次审查时间 + +为什么不能做成 run 列表: + +- 同一份文档多次重跑会出现多行重复记录 +- 用户业务上关心的是“这份公文当前状态如何” +- 列表页会与文档权限、删除语义、首页统计口径冲突 + +--- + +## 10. Run 的业务定位 + +Run 是内部执行留痕,不是页面主对象,但它在业务上必须存在。 + +Run 的业务价值: + +- 记录某次审查什么时候发起 +- 记录谁触发了审查 +- 记录本次状态、阶段、分数、错误信息 +- 保留历史版本,支持追踪和回溯 + +当前业务确认: + +- 需要支持重跑 +- 但前端第一阶段不开放重跑按钮 +- 处理方式与其他文档类型保持一致 + +因此应理解为: + +- 能力层支持多 run +- 数据层必须保留历史 run +- 交互层是否开放重跑,可后续逐步释放 + +--- + +## 11. 结果页业务语义 + +结果页中几个核心对象的业务含义如下。 + +### 11.1 Findings + +Findings 是最细粒度的问题明细。 + +其业务语义是: + +- 某条规则在某一段落或某个结构位置上发现的问题 + +典型信息包括: + +- 规则 ID +- 规则名称 +- 严重级别 +- 分类 +- 位置 +- 实际值 +- 期望值 +- 建议 +- 证据文本 + +本质上是“段落级问题清单”。 + +### 11.2 Checked Rules + +这是规则维度的检查结果。 + +每条规则的最终状态只有一个: + +- `pass` +- `fail` +- `skipped` + +这个列表用于回答: + +- 哪些规则通过了 +- 哪些规则失败了 +- 哪些规则因为条件不足被跳过了 + +### 11.3 Structure + +Structure 表示: + +- 文档结构统计 + +它关注的是: + +- 识别出了哪些结构角色 +- 各角色出现了多少次 +- 是否缺少预期结构 +- 样式是否一致 + +### 11.4 Outline + +Outline 表示: + +- 文档大纲树 + +本质上是标题层级结构,用于帮助用户快速跳到对应段落。 + +### 11.5 Entities + +Entities 表示: + +- 从公文中识别出的关键实体 + +例如: + +- 标题 +- 发文字号 +- 主送机关 +- 附件 +- 署名 +- 日期 +- 文种 + +--- + +## 12. 右侧结果面板的业务定义 + +当前详情右侧面板可归纳为 3 类结果: + +### 12.1 问题 + +显示: + +- 所有失败规则对应的问题明细 + +这是用户最主要的修正文档入口。 + +### 12.2 已通过 + +显示: + +- 当前 run 中已通过的规则 + +作用: + +- 帮助用户知道哪些规范项已经满足 + +### 12.3 已跳过 + +显示: + +- 当前 run 中未被真正执行的规则 + +典型原因: + +- 目标实体未识别到 +- 检查条件不满足 +- 外部智能检查服务不可用 + +业务上要明确: + +- `skipped` 不算失败 +- 但也不等于通过 + +--- + +## 13. 评分语义 + +已确认分数语义为: + +- **格式审查分** + +它表示: + +- 这份公文在格式规范层面的符合程度 + +不表示: + +- 公文业务内容质量 +- 法律风险程度 +- 审批流转质量 + +因此用户看到的分数,本质上是格式规范扣分结果。 + +--- + +## 14. 首页统计口径 + +已确认首页统计应按: + +- **文档最新结果** + +而不是: + +- 所有历史 run 累加 + +因此统计时应遵循: + +- 一份文档多次重跑,只按最新结果参与当前统计 +- “本月评查文件数”按文档数,不按 run 数 +- “问题数”“通过率”也按文档最新结果计算 + +如果按所有 run 统计,会导致: + +- 文档数量虚高 +- 问题数虚高 +- 同一文档反复重跑导致指标失真 + +--- + +## 15. 权限模型 + +本模块应使用独立的 `govdoc` 权限命名空间,不与其他模块混用。 + +### 15.1 模块级权限 + +- `govdoc:module:read` + +### 15.2 文档权限 + +- `govdoc:document:create` +- `govdoc:document:read` +- `govdoc:document:update` +- `govdoc:document:delete` + +### 15.3 Run 权限 + +- `govdoc:run:create` +- `govdoc:run:read` +- `govdoc:run:retry` + +### 15.4 结果与报告权限 + +- `govdoc:result:read` +- `govdoc:report:read` + +### 15.5 规则权限 + +- `govdoc:rule:read` +- `govdoc:rule:manage` + +--- + +## 16. 数据范围规则 + +当前业务确认如下。 + +### 16.1 普通用户(common) + +- 只能看自己上传/自己触发的文档与结果 +- 不能通过参数查看别人数据 + +### 16.2 地区管理员(admin) + +- 只能看本地区数据 +- 即使前端传入其他 `region`,后端也必须拦截 + +### 16.3 全局角色 + +- `super_admin` +- `provincial_admin` + +可看更大范围或全量数据 + +--- + +## 17. 结果与报告权限继承原则 + +已确认以下资源不单独放宽: + +- findings +- entities +- HTML 报告 +- 批注 DOCX +- 原文下载 + +统一原则: + +- **能否查看结果 = 能否查看该文档** + +也就是说: + +- 不能看这份文档,就不能看它的任何结果和报告产物 + +--- + +## 18. 删除语义 + +已确认删除语义为: + +- **软删除文档** + +业务含义: + +- 删除的是文档在模块中的业务可见性/状态 +- 不是立刻物理删除 OSS 文件 +- 历史 run 和产物先保留 + +因此第一阶段不应把“删除文档”理解成“彻底清除所有数据”。 + +--- + +## 19. 文档修改权限 + +已确认普通用户默认不应具备较高的文档管理权限。 + +推荐权限语义: + +- 普通用户可 `create/read` +- 普通用户默认不具备 `update/delete` + +因此: + +- `govdoc:document:update` +- `govdoc:document:delete` + +应高于: + +- `govdoc:document:create` +- `govdoc:document:read` +- `govdoc:run:create` + +--- + +## 20. 规则模块关系 + +已确认规则模块口径为: + +- **按照现有规则模块功能怎么做,内部公文模块就怎么接** + +这意味着: + +- `govdoc` 不额外发明一套私有规则治理方式 +- 规则版本、生效规则、规则选择逻辑,应尽量复用平台现有规则体系 +- `govdoc` 只是作为一个业务模块接入既有规则治理框架 + +因此第一阶段不建议: + +- 单独做一套 `govdoc` 私有规则选择交互 +- 单独维护一套与平台脱节的规则管理入口 + +--- + +## 21. 第一阶段用户可见能力 + +第一阶段用户可见能力应聚焦在: + +- 上传公文 +- 自动格式审查 +- 查看问题 +- 查看结构/大纲/实体 +- 下载报告 + +不作为第一阶段重点暴露的能力: + +- 手动重跑按钮 +- 高级规则版本选择 +- 复杂模块配置页 + +其中: + +- 重跑能力底层可以支持 +- 规则版本能力可先遵循现有平台规则模块现状 + +--- + +## 22. 推荐正式拍板结论 + +为了保证后续前后端对接和修复方向稳定,推荐正式拍板以下两条: + +### 22.1 主对象结论 + +- 主对象 = 文档 + +### 22.2 详情页结论 + +- 详情页路由 = `documentId` +- 页面默认展示该文档最新 run +- 历史 run 在详情页内部切换查看 + +这两条一旦定稿,以下模块都会更清晰: + +- 列表页 +- 详情聚合 +- 权限继承 +- 首页统计 +- 历史记录展示 +- 下载与删除语义 + +--- + +## 23. 一句话总结 + +`govdoc` 模块应被定义为: + +**以“公文文档”为主对象、以“审查 run”为执行留痕、以“规则结果 + 报告产物”为输出、复用平台统一底座的内部公文格式审查模块。** diff --git a/docs/内部公文模块/内部公文模块剩余细节与边界结论.md b/docs/内部公文模块/内部公文模块剩余细节与边界结论.md new file mode 100644 index 0000000..0a7fba4 --- /dev/null +++ b/docs/内部公文模块/内部公文模块剩余细节与边界结论.md @@ -0,0 +1,194 @@ +# 内部公文模块剩余细节与边界结论 + +## 1. 文档目的 + +本文档只回答 2 个问题: + +- 当前内部公文模块还差哪些细节没有真正收口 +- 哪些问题属于运行部署边界,不能再误判成业务逻辑问题 + +本文档基于 2026-05-17 当前仓库、当前机器、当前联调结果整理。 + +--- + +## 2. 先给结论 + +截至当前,内部公文模块不再是“没接上”的状态。 + +更准确的结论是: + +- 业务语义主线已经对齐 +- 前后端主链路已经接通 +- 报告产物主闭环已经补齐 +- 当前最大的剩余问题是运行部署收口,而不是公文业务逻辑方向错误 + +因此后续动作应坚持一个边界: + +- 可以继续补齐平台化能力和运行稳定性 +- 但不要再为了排查运行问题去改动公文业务语义 + +--- + +## 3. 当前已经成立的事实 + +### 3.1 业务主语义已经成立 + +当前内部公文模块的业务主语义已经能稳定描述为: + +- 主对象是 `document` +- 审查历史通过 `run` 保留 +- 详情默认展示文档当前最新一次运行结果 +- 结果语义仍然包括 `summary / findings / checkedRules / entities / structure / outline` + +这与旧 `govdoc-audit` 的业务方向是一致的,只是承载方式换成了当前平台的 `document + run + artifact` 模型。 + +### 3.2 页面与接口链路已经成立 + +当前链路已经核实通过: + +1. 浏览器访问 `5173` +2. `nginx` 转发到 `5193` +3. `legal-platform-frontend` 页面走 `/api/govdoc/*` +4. `/api/govdoc/*` 代理到当前后端 `8096/api/govdoc/*` +5. 当前后端路由已能实际处理请求 + +因此: + +- 现在再出现 `401`,说明链路已经通了,只是没有有效登录态 +- 现在再出现跳 `/login`,优先说明会话 cookie 不完整,而不是页面路由不存在 + +### 3.3 正式报告产物闭环已经成立 + +当前真实运行已验证以下产物可以生成并落库: + +- `annotated_docx` +- `html_report` +- `paragraph_html` + +并且: + +- 列表与详情已经能感知这些产物是否存在 +- HTML 报告、DOCX 报告、段落视图都已有当前仓库侧实现 + +--- + +## 4. 现在还差的细节 + +### 4.1 旧 worker 干扰还没清掉 + +当前机器上仍有旧进程: + +- `python start_worker_with_routing.py --config-port 8096` +- `cwd=/home/wren-dev/Porject/docauditai` + +这件事的影响非常直接: + +- 它会让“当前页面 + 旧执行链”混在一起 +- 它会导致故障判断失真 +- 它会让人误以为是当前仓库业务没实现 + +这不是业务逻辑缺口,而是运行环境没有彻底收口。 + +### 4.2 正式启动方式还不够固化 + +当前 `./leaudit.sh start` 在当前机器上可以把链路拉起来,但仍需注意: + +- 临时 shell 启动不等于正式守护方式 +- 需要确保正式重启、后台保活、日志落点都固定在当前仓库 +- 要避免以后又混回旧仓库进程 + +### 4.3 登录态验收还没完整做完 + +当前已经确认: + +- 未登录访问 `/govdoc/audits` 会被 `requireAuth()` 正常重定向到 `/login` +- `/api/*` 请求的 `Authorization` 依赖 `access_token` cookie 注入 + +但还没有完成一轮带真实登录态的完整验收,包括: + +- 列表加载 +- 详情打开 +- HTML 报告打开 +- DOCX 报告下载 +- 段落视图联动 +- 重跑与历史 run 切换 + +### 4.4 历史运行数据需要补跑 + +修复前已经成功但没有正式产物的旧 run,不会自动补齐。 + +因此仍需: + +- 对需要交付报告的历史文档重新触发审查 +- 确认 `current_run_id` 指向最新有效 run + +### 4.5 规则来源策略还没有平台化 + +当前 `govdoc` 已经有可用规则文件: + +- `rules/govdoc/govdoc_general/rules.yaml` + +但当前仍是“单路径硬编码解析”: + +- 还没有按文种/类型切换规则 +- 还没有按版本切换规则 +- 还没有接入统一规则管理 + +这不是阻塞当前链路跑通的 P0 问题,但它是后续正式化必须收口的 P1。 + +### 4.6 产物语义仍有两个小尾巴 + +当前最关键的正式产物已经补齐,但还有两点没完全平台化: + +- `canonical.docx` 还没有作为显式产物沉淀 +- `result.json` 还没有作为显式产物沉淀 + +这不会阻塞当前页面使用,但会影响与旧系统“完整产物语义”一一对应的程度。 + +--- + +## 5. 哪些问题不要再误判 + +### 5.1 跳 `/login` 不等于页面坏了 + +当前 `/govdoc/audits` 在服务端布局阶段就会做鉴权。 + +所以: + +- 没有 `user_info` cookie 时跳转是正常行为 +- 这不代表 `govdoc` 页面不存在 + +### 5.2 `401` 不等于没接后端 + +当前 `/api/govdoc/documents` 返回 `401` 时,更准确的含义是: + +- 请求已经到达当前后端 +- 只是当前请求没有有效凭证 + +### 5.3 旧 worker 活着时,不能只看页面表象下判断 + +只要旧 `docauditai` worker 还在: + +- 页面报错不一定是当前仓库逻辑错 +- 任务结果异常也不一定是当前仓库执行错 + +必须先判断任务到底被谁消费。 + +--- + +## 6. 后续实施边界 + +后续如果继续推进,建议边界固定如下: + +1. 不改内部公文核心业务语义。 +2. 允许继续按当前平台规范补齐运行、规则管理、产物治理。 +3. 先处理运行收口,再做带登录态验收。 +4. 只有在运行链路完全收口后,才继续判断剩余产品细节是否需要调整。 + +--- + +## 7. 一句话结论 + +内部公文模块当前已经进入“运行部署收口 + 平台化细节补齐”阶段。 + +主业务逻辑方向当前没有发现需要推翻重做的问题,真正还没做好的,主要是旧链路清退、真实登录态验收、历史数据补跑、规则来源平台化和少量产物治理细节。 diff --git a/docs/内部公文模块/内部公文模块实施进展与运行依赖说明.md b/docs/内部公文模块/内部公文模块实施进展与运行依赖说明.md new file mode 100644 index 0000000..d430ba0 --- /dev/null +++ b/docs/内部公文模块/内部公文模块实施进展与运行依赖说明.md @@ -0,0 +1,396 @@ +# 内部公文模块实施进展与运行依赖说明 + +## 1. 文档目的 + +本文档只回答 3 个问题: + +- 当前 `govdoc` 模块代码已经接到了哪一步 +- 现在还缺哪些实现与运行依赖 +- 当前为什么仍不能视为“可正式运行” + +本文档基于 2026-05-17 当前仓库实际代码状态整理,不讨论理想方案,只描述现状。 + +--- + +## 2. 当前实施进展 + +### 2.0 2026-05-17 运行面最新结论 + +本次联调补充确认了一个非常关键的事实: + +- `govdoc` 前后端链路现在已经接到了当前 `leaudit-platform` +- `GET /api/govdoc/documents` 不再是之前的 `404` +- 当前未登录访问时,接口返回 `401 Unauthorized` +- 当前未登录访问 `http://172.16.0.59:5173/govdoc/audits?entryModuleId=3` 时,会被正常重定向到 `/login` + +这说明: + +- “文档列表没有接到 1 后端”这个问题,按 2026-05-17 当前代码与运行验证结果看,**已经不是接口未接通问题** +- 当前更真实的阻塞已经变成: + - 运行进程是否真的按当前仓库拉起 + - 浏览器是否带了有效登录态 + - 正式部署是否还混用了旧项目或错误上游 + +本次实测时,以下链路已确认成立: + +- `legal-platform-frontend` 的 `/api/govdoc/*` 代理会转发到当前后端 `http://127.0.0.1:8096/api/govdoc/*` +- 后端 `8096` 确认加载的是当前仓库 `fastapi_admin.app:app` +- `GET /api/govdoc/documents?page=1&pageSize=1` 已到达当前后端,并返回认证失败而非路由不存在 + +结论: + +- **当前剩余主阻塞不是“列表接口没接上”,而是“运行部署链路混乱 + 登录态/正式进程不稳定”。** + +### 2.1 业务基线已明确 + +当前业务口径已经明确: + +- `govdoc` 是“内部公文格式审查模块” +- 主对象是“文档”,不是 `run` +- 上传后可自动触发审查 +- 历史 `run` 需要保留 +- 详情应以 `documentId` 为主,默认展示最新一次 `run` + +这部分结论已在已有业务文档中明确,不再重复展开。 + +### 2.2 后端基础骨架已形成 + +当前后端已具备以下基础结构: + +- `controller` 层已有 `govdoc` 路由入口 +- `service` 层已有 `GovdocServiceImpl` +- `bridge` 层已有 `tasks / runner / storage_adapter / input_resolver / result_adapter` +- `engine` 层已有 `govdoc_engine` 解析、规则执行、结构/大纲、报告相关代码 +- `model` 与 `DDL` 已具备 `govdoc_runs / govdoc_rule_results / govdoc_report_artifacts` 基础定义 + +说明: + +- 这代表模块已经不是“纯占位”,而是已经具备一条从文档到运行结果的后端主链路雏形。 + +### 2.3 文档与运行主链路已有实际实现 + +当前 `GovdocServiceImpl` 已经实现了以下能力: + +- 文档上传 +- 文档列表 +- 文档详情 +- 创建运行 `CreateRun` +- 运行状态查询 +- 运行结果读取 +- findings / entities / structure / outline 读取 +- 原文下载 +- HTML / DOCX 报告地址读取 +- 规则列表 / 规则详情读取 + +说明: + +- 这意味着服务层已经不是之前那种“多数方法占位返回空壳”的阶段。 +- 当前更准确的判断应是:**后端主链路基本接通,但仍存在关键运行依赖缺口和若干结果完整性缺口。** + +### 2.4 worker / bridge / DDL 的 P0 已完成一轮修复 + +截至当前代码状态,以下确定性断点已完成修复: + +- `dispatch_govdoc_task` 已支持透传 `rulesPath` +- `govdoc_execute_task` 已支持接收 `rulesPath` +- `GovdocRunner.Execute()` 已显式校验 `rulesPath` +- `UpdateRunStatus()` 已兼容 `phase / Phase` +- `govdoc_runs.rules_path` 已补入 model / DDL +- `govdoc_rule_results.skip_reason` 已补入 model / DDL + +这部分修复的意义是: + +- 任务链不再因为参数签名不一致、字段缺列这类低级错位直接报错 +- 但“能跑到哪一步”仍取决于上游运行依赖是否齐全 + +--- + +## 3. 当前已完成项 + +从“能不能形成后端最小闭环”角度看,当前已完成项如下。 + +### 3.1 文档主档复用已接上 + +- 上传公文时,已复用 `leaudit_documents` +- 原始文件已复用 `leaudit_document_files` +- 上传后会把 `engine_type` 标记为 `govdoc` + +### 3.2 运行记录域已接上 + +- 已创建 `govdoc_runs` +- 已支持记录 `status / phase / total_score / result_status` +- 已支持写入 `task_id / started_at / finished_at` + +### 3.3 规则结果域已接上 + +- 已支持写入 `govdoc_rule_results` +- 已支持读取 `result / severity / category / message` +- 已支持读取 `skip_reason` + +### 3.4 异步任务链已接上 + +- 已支持 `CreateRun -> dispatch_govdoc_task -> govdoc_execute_task -> GovdocRunner.Execute` +- 已支持 worker 中更新 run/document 状态 + +### 3.5 结果读取链已接上 + +- 已支持从 `govdoc_runs + govdoc_rule_results` 读取结果摘要 +- 已支持组装 `checkedRules / findings / structure / outline` +- 已支持详情页所需的最新运行与历史运行信息 + +--- + +## 4. 当前待完成项 + +以下内容属于“主链路已经搭上,但还没有真正闭环”。 + +### 4.1 规则文件已落位,但规则来源策略仍是临时写法 + +当前实现里,`GovdocServiceImpl._resolve_rules_path()` 仍只尝试两个固定候选路径: + +- `/home/wren-dev/Porject/leaudit-platform/rules/govdoc/govdoc_general/rules.yaml` +- `/home/wren-dev/Porject/leaudit-platform/rules/govdoc_general/rules.yaml` + +按 2026-05-17 当前仓库实际状态看: + +- 第一条路径对应的规则文件已经存在 +- 当前真实运行也已经证明 `govdoc` 审查链路可以成功加载该规则并完成一次完整审查 + +因此: + +- 现在的问题已经不是“规则文件本体不存在” +- 而是**规则来源策略仍然是硬编码单规则集** + +当前仍待补的问题是: + +- 不支持按文种/类型切换不同规则集 +- 不支持按地区/版本切换规则集 +- 不支持平台化规则绑定与管理 + +这不是当前链路跑不通的主阻塞,但它是后续正式化接入前必须收口的技术债。 + +### 4.2 govdoc 专用规则资产已入库,但尚未平台化 + +当前仓库已存在明确的 `govdoc` 规则目录: + +- `rules/govdoc/govdoc_general/rules.yaml` + +这说明: + +- `govdoc` 已经不再处于“只有代码骨架,没有规则资产”的阶段 +- 后端规则读取、规则列表、规则详情的最小语义已经有了真实落点 + +但当前仍只能视为“单规则集可运行”: + +- 规则目录结构尚未接入统一规则管理策略 +- `rules_path` 仍主要服务于当前单套内部公文规则 +- 规则版本、切换与配置来源还没有平台化闭环 + +### 4.3 报告产物主闭环已打通,但历史运行仍需补跑 + +当前代码已补齐正式报告产物生成与写库: + +- 审查完成后会生成 `annotated_docx` +- 审查完成后会生成 `html_report` +- 审查完成后会生成 `paragraph_html` +- 产物会落入 `govdoc_report_artifacts` + +并且已做过真实运行验证。 + +但仍有两个现实问题没有自动消失: + +- 历史上在修复前跑成功的 run,并不会自动补出产物 +- 这类历史 run 需要重新触发一次审查,才能补齐正式报告文件 + +### 4.4 页面接入已通,但真实登录态联调尚未完成 + +虽然本文档不展开前端全部细节,但从当前代码与运行态核对结果看,前端接入边界已经比较明确: + +- `/govdoc/audits` 已接到当前 `legal-platform-frontend` +- 页面真实数据请求走的是 `/api/govdoc/* -> 8096/api/govdoc/*` +- 未登录访问时,`app/(audit)/layout.tsx -> requireAuth()` 会先基于 `user_info` cookie 重定向到 `/login` +- `/api/*` 请求上的 `Authorization` 由 `middleware.ts` 从 `access_token` cookie 注入 + +因此当前状态更适合描述为: + +- 前端页面和代理链路已经接通 +- 当前仍缺“带真实登录态”的完整回归验收 +- 当前若页面跳 `/login` 或接口 `401`,不能再直接判断为“govdoc 没接后端” + +--- + +## 5. 当前已知运行阻塞 + +以下是“现在就会影响模块能否真实跑通”的已知阻塞。 + +### 5.1 阻塞一:正式运行进程与旧链路混用 + +这是当前最关键、最真实的运行阻塞。 + +现状: + +- 当前仓库后端标准端口应为 `8096` +- 当前仓库 worker 应从 `leaudit-platform/scripts/start_worker.sh` 启动 +- 实机上曾长期存在旧 worker: + - `python start_worker_with_routing.py --config-port 8096` + - `cwd=/home/wren-dev/Porject/docauditai` +- 实机联调过程中还出现过: + - `5173` 前端入口存在 + - 但 `8096` 没有当前仓库后端在监听 + - 因此页面表现为 `502 / 404 / 500` 来回切换 + +结果: + +- 浏览器打开页面,不代表当前仓库后端已经真正生效 +- 即使代码已经修好,只要正式进程仍混用旧项目,上层依然会报错 + +结论: + +- **当前 `govdoc` 最大阻塞已经不是接口代码未接通,而是部署运行链路未彻底切回当前仓库。** + +### 5.2 阻塞二:旧 `docauditai` worker 仍长期存活 + +当前机器上已确认存在旧进程: + +- `python start_worker_with_routing.py --config-port 8096` +- `cwd=/home/wren-dev/Porject/docauditai` +- `PPID=1` +- 标准输出与错误输出落在 `/tmp/worker_8096.log` + +这说明它更像是: + +- 历史上人工或脚本后台拉起后长期遗留的旧 worker + +它与当前仓库的主要冲突点不是抢占 `8096` HTTP 端口,而是: + +- 它仍可能消费旧 `docauditai` 任务链路 +- 它会制造“页面是新的,执行链还是旧的”的混合运行态 +- 它会严重干扰“问题究竟在当前仓库还是旧仓库”的判断 + +在没有确认任务投递、结果写回、旧队列消费都已切到当前仓库前,不宜只凭主观感觉直接停掉该进程。 + +### 5.3 阻塞三:规则来源策略仍是临时写法 + +当前 `_resolve_rules_path()` 只使用硬编码候选路径。 + +这意味着即便后续补了规则文件,也仍然存在以下待补问题: + +- 不支持按文档类型选择不同规则 +- 不支持按地区 / 版本 / 规则集切换 +- 不支持平台化规则管理 + +这个问题不一定导致“立刻报错”,但会阻碍后续正式上线。 + +### 5.4 阻塞四:当前终端启动方式不等于正式部署方式 + +本次排障中还发现一个容易误判的问题: + +- 在当前 Codex 执行环境里,普通“后台启动后退出命令”的方式,子进程可能随会话一起被回收 +- 因此会出现: + - 日志显示 `startup complete` + - 但随后端口马上消失 + +这个现象说明: + +- 不能把当前临时终端会话,等同于正式 supervisor / systemd / PM2 / nginx upstream 部署环境 +- 正式部署必须由稳定守护方式接管 +- 不能仅凭一次临时 shell `start` 成功,就判断运行问题已经彻底解决 + +这属于运行验证边界,不属于 govdoc 业务逻辑问题。 + +--- + +## 6. 当前运行依赖清单 + +从“要让 `govdoc` 至少完成一次真实审查”角度,当前最小运行依赖如下。 + +### 6.1 数据库依赖 + +- `leaudit_documents` +- `leaudit_document_files` +- `govdoc_runs` +- `govdoc_rule_results` +- `govdoc_report_artifacts` + +并且需执行当前 `schema_add_govdoc_module.sql`,保证以下列存在: + +- `govdoc_runs.rules_path` +- `govdoc_rule_results.skip_reason` +- `leaudit_documents.engine_type` + +### 6.2 文件与存储依赖 + +- 上传文档需能写入 OSS / MinIO +- worker 执行时需能下载原始文档到本地临时目录 + +### 6.3 异步任务依赖 + +- Celery worker 需可消费当前 `leaudit-platform` 队列 +- 当前实际队列为 `leaudit.normal` / `leaudit.urgent` +- worker 进程需可访问数据库、OSS、本地临时目录 + +### 6.4 规则引擎依赖 + +- 必须存在可用的 `govdoc rules.yaml` +- `rulesPath` 必须能被 service 层解析出来 +- worker 所在运行环境必须能读取该规则文件 + +### 6.5 解析与报告依赖 + +- `python-docx` 相关解析依赖需正常 +- 段落解析、规则执行、结构/大纲构建需可正常运行 +- 审查完成后需能生成并上传正式报告产物 + +--- + +## 7. 当前剩余细节清单 + +从“业务语义已经基本对齐”往“可稳定交付”推进,当前剩余细节主要有这些: + +### 7.1 运行部署侧 + +- 正式 `8096` 后端进程要固定切到当前 `leaudit-platform` +- 正式 worker 要确认只消费当前 `leaudit-platform` 队列 +- 旧 `docauditai` 的 `start_worker_with_routing.py` 需要在确认安全前置条件后退出正式链路 +- `5173` 对应的 nginx / 前端上游要固定指向当前前端服务 +- 需要一份正式重启与巡检手册,避免以后又混回旧进程 + +### 7.2 数据与历史运行侧 + +- 修复前的历史成功 run 需要补跑一次,才能产出正式报告文件 +- 需要确认 `leaudit_documents.current_run_id` 是否都正确指向最新成功 run + +### 7.3 产品细节侧 + +- `canonical.docx` 与 `result.json` 还没有作为显式产物沉淀 +- 前端某些展示位仍以 `run completed` 为主判断,而不是严格以产物存在性判断 +- 按钮开放策略还需要继续保持“和其他文档类型一致,但不额外放出不该开放的按钮” + +### 7.4 验收侧 + +- 需要带真实登录态回归验证: + - 列表页 + - 详情页 + - HTML 报告 + - DOCX 报告 + - 段落联动视图 + - 重跑与历史 run 切换 + +--- + +## 8. 当前结论 + +截至 2026-05-17,`govdoc` 当前真实状态应表述为: + +- 业务语义基线已经明确 +- 后端主链路已经接通 +- 正式报告产物主闭环已经补齐 +- 前端列表/详情接口已接到当前后端契约 +- 当前 `rules/govdoc/govdoc_general/rules.yaml` 已存在且已被真实运行验证过 +- 当前最主要问题已经转为运行部署链路不稳定,而不是业务逻辑未实现 + +因此后续工作重点不应再回到“列表接口有没有接上”,而应转向: + +- 固化正式运行方式 +- 清理旧 worker / 旧上游 +- 带登录态做完整联调验收 diff --git a/docs/内部公文模块/内部公文模块现状偏差与对接补齐计划.md b/docs/内部公文模块/内部公文模块现状偏差与对接补齐计划.md new file mode 100644 index 0000000..68ff29e --- /dev/null +++ b/docs/内部公文模块/内部公文模块现状偏差与对接补齐计划.md @@ -0,0 +1,719 @@ +# 内部公文模块现状偏差与对接补齐计划 + +## 1. 文档目的 + +本文档基于当前仓库代码、已有迁移设计文档,以及已确认的业务口径,回答 3 个问题: + +- 当前 `govdoc` 模块业务上到底应该怎么跑 +- 当前代码实际上跑成了什么状态 +- 后续应该按什么顺序补齐,才能低风险落地 + +这份文档是后续前后端对接、数据库补齐、权限修复、页面改造的执行基线。 + +--- + +## 2. 已确认的业务结论 + +结合现有页面语义、引擎能力和你已确认的口径,当前正式业务结论如下。 + +### 2.1 模块定位 + +`govdoc` 不是公文流转系统,也不是审批系统。 + +它的第一阶段定位是: + +- **内部公文格式审查模块** + +核心能力是: + +- 上传公文 +- 自动发起格式审查 +- 输出问题、规则结果、结构结果、报告产物 +- 保留历史审查记录 + +### 2.2 业务主对象 + +当前应正式拍板: + +- **主对象是文档** +- `run` 只是某份文档的一次审查执行记录 + +原因: + +- 列表页已经确定是文档列表 +- 删除、权限、原文下载天然属于文档 +- 首页统计按文档最新结果算 +- 用户认知对象是“这份公文”,不是“某次 run” + +### 2.3 页面入口结论 + +详情页建议固定为: + +- **按 `documentId` 进入详情** + +详情页默认行为: + +- 默认展示该文档最新一次审查结果 +- 页面内可切换历史 run + +因此正确语义是: + +- 列表展示文档 +- 详情入口指向文档 +- 结果展示某个 run + +### 2.4 审查流程结论 + +当前业务流程应为: + +1. 用户上传公文 +2. 创建平台文档主档,标记 `engine_type='govdoc'` +3. 自动创建一条新的 `run` +4. 投递异步任务 +5. Worker 执行格式审查 +6. 产出规则结果、结构结果、报告产物 +7. 详情页默认展示最新 run,保留历史 run + +### 2.5 权限与可见范围结论 + +- `common` 只能看自己上传的文档及结果 +- `admin` 只能看本地区 +- 报告、原文、findings、history 都必须继承文档可见性 +- 删除是软删除 +- 普通用户默认不开放文档更新/删除 +- 需要保留重跑能力,但前端暂不开放按钮 + +### 2.6 指标与结果语义结论 + +- 分数是**公文格式规范符合度** +- 不是内容质量分 +- 首页统计要按**每份文档的最新 run** 计算 +- 不是把所有 run 累加 + +--- + +## 3. 当前实现的总体判断 + +结论先说: + +- 当前仓库已经把 `govdoc` 的目录、接口骨架、前端页面壳、SQL 草案、Worker 编排骨架都铺出来了 +- 但它还没有形成一个真正闭环的“可用模块” + +当前状态更准确地说是: + +- **已经完成模块迁移骨架** +- **但业务主模型、接口契约、运行链路、权限落地还没有完全收口** + +这不是一个“修几个接口”的问题,而是一个“已经搭了半套系统,但业务模型还没完全统一”的问题。 + +--- + +## 4. 现状偏差分析 + +以下按 4 层来看:业务模型、前后端契约、运行链路、权限与统计。 + +### 4.1 业务模型偏差 + +#### 偏差 1:后端设计想走文档模型,前端详情仍然是 run 模型 + +后端控制器已经提供: + +- `/govdoc/documents` +- `/govdoc/documents/{documentId}` +- `/govdoc/runs` +- `/govdoc/runs/{runId}/...` + +说明后端设计方向其实已经倾向: + +- 文档是主对象 +- run 是子对象 + +但前端详情页实际还是: + +- `/govdoc/[id]` + +且页面直接把 `id` 当作 `auditId` 传给旧组件。 + +这意味着当前前端仍在延续旧的“run 即详情页主键”思路。 + +直接影响: + +- 文档列表跳详情时主键语义不稳定 +- 同一文档多次运行后,入口对象变成“当前 run” +- 后续历史 run 展示很难自然补进去 + +#### 偏差 2:列表页名义上是文档列表,数据上仍在混用文档 ID 和 run ID + +当前前端列表适配时把: + +- `audit_id = currentRunId ?? documentId` + +这其实是在用一个字段混装两种对象: + +- 有最新 run 时,这个 ID 代表 run +- 没有 run 时,这个 ID 代表 document + +这会直接带来: + +- 详情页路由语义漂移 +- 删除、下载原文、查看结果时对象类型不确定 +- 前端组件后续越来越难维护 + +#### 偏差 3:上传与发起审查的业务闭环还没有真正做完 + +业务上已经确认: + +- 上传后应自动发起审查 + +但当前后端 `UploadDocument()` 只是占位返回; +`CreateRun()` 也是占位返回; +前端 `uploadAudit()` 虽然按“先上传、再建 run”的思路写了,但后端并没有真正完成这两个动作。 + +因此现在只是: + +- 前端以为自己在走业务闭环 +- 后端实际上还没有落地 + +### 4.2 前后端接口与路由契约偏差 + +#### 偏差 4:前端代理路径与实际代理实现不一致 + +当前前端 API 客户端里大量请求走的是: + +- `/api/govdoc/...` + +但仓库里实际存在的代理路由是: + +- `/api/govdoc-audit/[...path]` + +这代表至少在当前仓库状态下: + +- 代码意图已经切到新模块路径 +- 代理实现仍停留在旧路径 + +运行时风险很直接: + +- 前端请求可能 404 +- 或必须依赖外部额外 rewrite 才能工作 + +#### 偏差 5:前端页面路由已经叫 `govdoc`,但菜单和别名体系仍残留大量 `govdoc-audit` + +当前仓库同时存在两套路由语义: + +- 新路由:`/govdoc` +- 旧路由:`/govdoc-audit` + +遗留点包括: + +- 菜单映射 +- 最小权限白名单 +- breadcrumb +- 角色权限页面提示文案 +- route alias 配置 +- 某些入口页跳转目标 + +这意味着当前前端不是“已经迁完”,而是处于: + +- 新旧路径并存 +- 新路径只接了一半 + +直接后果: + +- 菜单权限和真实页面可能不一致 +- 用户访问入口与页面跳转链路可能绕回旧路径 +- 后续排障会出现“接口没问题但入口不对”的假象 + +#### 偏差 6:SQL 菜单种子与前端真实详情路由不一致 + +当前 SQL 种子注册的是: + +- `/govdoc/detail` + +但前端真实详情页现在是: + +- `/govdoc/[id]` + +而业务推荐最终应为: + +- `/govdoc/detail/:documentId` + +这说明目前菜单/RBAC 路由、Next 页面路由、业务路由设计三者还没有统一。 + +### 4.3 后端服务实现偏差 + +#### 偏差 7:`GovdocServiceImpl` 大部分接口仍是占位实现 + +当前真正有一定查询逻辑的主要只有: + +- `GetRunResult()` + +其余关键接口基本仍是骨架: + +- `UploadDocument()` +- `ListDocuments()` +- `GetDocumentDetail()` +- `UpdateDocument()` +- `DeleteDocument()` +- `CreateRun()` +- `GetRunStatus()` +- `GetRunFindings()` +- `GetRunEntities()` +- `GetRunParagraphs()` +- `GetRunStructure()` +- `GetRunOutline()` +- `GetReportHtml()` +- `GetReportDocx()` +- `DownloadOriginal()` + +这意味着: + +- 控制器接口已经暴露出来 +- 但绝大部分业务服务还没有真正接平台主档、结果表、OSS、权限范围 + +#### 偏差 8:`GetRunResult()` 所依赖字段与当前建表 SQL 不一致 + +代码查询了: + +- `govdoc_runs.rules_path` +- `govdoc_rule_results.skip_reason` + +但当前建表 SQL 中并没有这两个字段。 + +这属于很典型的: + +- 服务代码和 DDL 没同步 + +直接后果: + +- 查询会报 SQL 字段不存在 +- 或部署后不同环境表现不一致 + +#### 偏差 9:结果接口仍是 run 视角,缺少“文档详情聚合接口” + +业务上真正需要的详情能力是: + +- 输入 `documentId` +- 返回文档基础信息 +- 返回最新 run 摘要 +- 返回历史 run 列表 +- 默认选中最新 run 的 findings / structure / outline / artifacts + +当前后端虽然有: + +- `GetDocumentDetail(documentId)` + +但只是占位返回 `{"documentId": ...}`。 + +这意味着真正业务需要的“文档详情聚合接口”还没做。 + +### 4.4 Worker / Bridge / 运行时偏差 + +#### 偏差 10:Worker 调用 `GovdocRunner.Execute()` 时缺少必填 `RulesPath` + +`GovdocRunner.Execute()` 的签名要求: + +- `RulesPath: str` + +但 Celery 任务调用时没有传这个参数。 + +这意味着当前一旦任务真正执行到这里,运行时就会直接报错。 + +#### 偏差 11:状态更新参数名不一致,`phase` 很可能写不进去 + +`StorageAdapter.UpdateRunStatus()` 定义的是: + +- `Phase` + +但调用时传的是: + +- `phase=...` + +由于这里大小写不一致,`Phase` 不会接收到值。 + +直接后果: + +- `govdoc_runs.phase` 可能一直不更新 +- 前端状态页看到的阶段信息不可信 + +#### 偏差 12:结果持久化只写了规则结果和产物,没有把文档维度聚合真正打通 + +当前 Bridge 更像是在做: + +- run 生命周期管理 +- 规则结果入库 +- 报告产物索引入库 + +但还没有完全补齐: + +- 文档最新 run 的选择逻辑 +- 文档级状态汇总 +- 文档级最新分数/最新结果统计口径 +- 首页统计查询口径 + +所以底层虽然开始有 run 表,但“平台层文档视角”还没有真正建立。 + +### 4.5 权限、可见范围、删除语义偏差 + +#### 偏差 13:控制器做了登录校验,但还没真正落细粒度数据范围 + +当前控制器统一用了: + +- `verify_access_token` + +说明登录态有了。 + +但真正业务要求的是: + +- `common` 仅本人 +- `admin` 仅本地区 +- 报告/原文/结果/历史全部继承文档范围 + +而这些逻辑目前还没有在 `GovdocServiceImpl` 中真正实现。 + +所以当前状态更接近: + +- 只有“是否登录” +- 还没有“能看谁的数据” + +#### 偏差 14:删除语义业务上要求软删除,但当前服务只是占位返回 + +业务上已经确认删除必须是: + +- 软删除 + +当前 `DeleteDocument()` 只是返回: + +- `{"documentId": ..., "deleted": True}` + +没有真正处理: + +- 文档主档软删 +- 文件记录是否保留 +- run 是否跟随隐藏 +- 报告产物是否继续留存但不可见 + +#### 偏差 15:普通用户不应默认拥有更新/删除,但当前接口已开放,权限键还没真正落表 + +接口层已经有: + +- `PATCH /documents/{documentId}` +- `DELETE /documents/{documentId}` + +这本身没错,因为管理员要用。 + +但当前问题是: + +- 角色默认能力 +- 权限键 +- 按角色/地区/本人范围控制 + +这些还没真正打通。 + +所以现在属于“接口先开出来了,但权限收口还没跟上”。 + +--- + +## 5. 当前代码实际反映出的真实业务状态 + +从代码现状反推,当前模块实际上处于下面这个阶段: + +### 5.1 已经确定的部分 + +- 决定把 `govdoc-audit` 收口进主平台 +- 决定复用主平台账号、权限、文档主档、OSS、Celery +- 决定新建 `govdoc_runs / govdoc_rule_results / govdoc_report_artifacts` +- 决定后端 API 挂到 `/govdoc` +- 决定前端页面也逐步迁到 `/govdoc` + +这部分方向是对的。 + +### 5.2 还没有完全拍平的部分 + +- 详情页到底按 `documentId` 还是 `runId` +- 列表项到底代表文档还是运行 +- 前端入口到底以 `govdoc` 还是 `govdoc-audit` 为准 +- 路由种子、前端页面、菜单权限是否统一 +- 文档级详情聚合接口长什么样 +- 统计到底按 run 还是按文档最新结果 + +### 5.3 因此当前模块不是“坏了”,而是“未收口” + +更准确地说: + +- 架构方向基本正确 +- 但业务主模型尚未完全统一 +- 结果导致前端、后端、SQL、任务链各自朝着相近但不完全一致的方向在写 + +这就是当前所有错位问题的根因。 + +--- + +## 6. 建议的正式收口方案 + +为了后面少返工,建议现在先正式拍板以下模型,不再来回摇摆。 + +### 6.1 正式主模型 + +- 主对象:`document` +- 执行对象:`run` +- 结果对象:`rule_result` +- 产物对象:`artifact` + +### 6.2 正式页面路由 + +- `/govdoc` +- `/govdoc/home` +- `/govdoc/upload` +- `/govdoc/audits` 或 `/govdoc/list` +- `/govdoc/detail/[documentId]` +- `/govdoc/rules` + +建议最终二选一: + +- 要么统一保留 `/govdoc/audits` +- 要么统一收敛为 `/govdoc/list` + +不要两个同时长期并存。 + +从当前业务文案看,我更建议统一为: + +- `/govdoc/list` +- `/govdoc/detail/[documentId]` + +因为它更贴近“文档列表 / 文档详情”的业务语义。 + +### 6.3 正式接口模型 + +建议稳定成两层接口。 + +第一层:文档接口 + +- `POST /govdoc/documents` +- `GET /govdoc/documents` +- `GET /govdoc/documents/{documentId}` +- `PATCH /govdoc/documents/{documentId}` +- `DELETE /govdoc/documents/{documentId}` +- `GET /govdoc/documents/{documentId}/original` +- `GET /govdoc/documents/{documentId}/runs` + +第二层:run 结果接口 + +- `POST /govdoc/runs` +- `GET /govdoc/runs/{runId}` +- `GET /govdoc/runs/{runId}/result` +- `GET /govdoc/runs/{runId}/findings` +- `GET /govdoc/runs/{runId}/entities` +- `GET /govdoc/runs/{runId}/structure` +- `GET /govdoc/runs/{runId}/outline` +- `GET /govdoc/runs/{runId}/paragraphs` +- `GET /govdoc/runs/{runId}/report/html` +- `GET /govdoc/runs/{runId}/report/docx` + +其中: + +- 页面入口以 `documentId` 为准 +- 结果读取以 `runId` 为准 + +### 6.4 正式详情页返回模型 + +建议把 `GET /govdoc/documents/{documentId}` 做成真正的聚合详情: + +- 文档基本信息 +- 当前状态 +- 当前最新 run 摘要 +- 历史 run 列表 +- 当前默认 runId +- 当前可用报告产物摘要 + +页面进入后: + +- 先拿文档聚合详情 +- 再按默认 runId 拉 findings / structure / outline / entities + +这样前端会非常稳定。 + +--- + +## 7. 分阶段补齐计划 + +以下是建议执行顺序。顺序不能乱,因为前一层不收口,后一层会反复返工。 + +### Phase 1:先把业务主模型收口 + +目标: + +- 明确 `document` 是主对象 +- 统一详情入口为 `documentId` +- 统一列表项表达的是文档 + +具体动作: + +- 前端详情路由改为 `/govdoc/detail/[documentId]` +- 列表接口返回文档维度 DTO,不再混用 `documentId/runId` +- 列表项保留 `latestRunId` 字段,但不能拿它充当主键 +- SQL 路由种子、前端菜单、页面跳转统一成同一套路由 + +产出结果: + +- 页面导航语义稳定 +- 历史 run 可自然挂入详情页 +- 后续删除、下载、权限继承都有明确主语 + +### Phase 2:补齐后端文档主链路 + +目标: + +- 真正打通“上传文档 -> 创建主档 -> 标记为 govdoc -> 自动建 run” + +具体动作: + +- `UploadDocument()` 接入平台文档主档服务 +- 文档主档写入 `engine_type='govdoc'` +- 上传后默认自动触发 `CreateRun()` +- `CreateRun()` 真正写入 `govdoc_runs` +- 投递 Celery 任务 +- 回写 `leaudit_documents.processing_status/current_run_id` + +产出结果: + +- 用户上传后能真正看到一条 govdoc 文档 +- 文档状态从上传到处理中可追踪 + +### Phase 3:修复 Worker 运行闭环 + +目标: + +- 让异步任务真正可跑完 + +具体动作: + +- 修复 `RulesPath` 来源与传参 +- 修复 `UpdateRunStatus()` 的 `Phase/phase` 参数不一致 +- 明确默认规则文件解析策略 +- 补 run 开始时间、结束时间、失败信息 +- 补结果持久化后的文档最新状态回写 + +产出结果: + +- Celery worker 可以稳定完成一次 run +- 失败时数据库里有清晰可读的错误和状态 + +### Phase 4:补齐结果查询与详情聚合 + +目标: + +- 让详情页真正能按业务方式展示 + +具体动作: + +- `GetDocumentDetail()` 改为聚合查询 +- 增加 `GET /documents/{documentId}/runs` +- `GetRunFindings()` / `GetRunEntities()` / `GetRunStructure()` / `GetRunOutline()` / `GetReportHtml()` / `GetReportDocx()` / `DownloadOriginal()` 全部接真实数据 +- HTML/DOCX/original 下载统一继承文档权限 + +产出结果: + +- 详情页默认展示最新结果 +- 用户可切换历史 run +- 报告与原文下载链路完整 + +### Phase 5:补齐权限、数据范围、软删除 + +目标: + +- 让模块真正符合平台权限模型 + +具体动作: + +- 落 `govdoc:*:*` 权限键 +- `common` 按本人过滤 +- `admin` 按地区过滤 +- `provincial_admin/super_admin` 按更大范围过滤 +- 删除改为主档软删 +- 结果/报告/历史查询全部继承文档范围 + +产出结果: + +- 模块可以上线到真实账号体系 +- 不会出现越权查看他人公文结果 + +### Phase 6:清理前端旧壳与统计口径 + +目标: + +- 把遗留的 `govdoc-audit` 痕迹真正收口 + +具体动作: + +- 统一 `/govdoc` 与 `/govdoc-audit` 路由 +- 菜单、breadcrumb、minimal-scope、route alias 统一 +- 首页统计改为按文档最新 run 聚合 +- 前端隐藏重跑按钮,但保留后端能力 + +产出结果: + +- 前端不再混用新旧模块名 +- 用户看到的是完整一致的一套业务入口 + +--- + +## 8. 优先级建议 + +如果要按最小可用闭环来排,我建议优先级如下: + +### P0:不修就跑不起来 + +- 统一详情主模型为 `documentId` +- 修复 `/api/govdoc` 与实际代理不一致 +- 实现 `UploadDocument()` / `CreateRun()` +- 修复 `RulesPath` 缺失 +- 修复 `phase` 状态写回失败 +- 修复 DDL 与服务代码字段不一致 + +### P1:能跑但还不能交付 + +- 实现文档聚合详情 +- 实现历史 run 查询 +- 实现 findings / structure / outline / artifacts 真实读取 +- 实现原文/HTML/DOCX 下载 + +### P2:上线前必须补齐 + +- 权限键 +- 数据范围 +- 软删除 +- 首页统计口径 +- 新旧路由与菜单彻底收口 + +--- + +## 9. 最终建议 + +当前最重要的不是马上补某一个接口,而是先统一下面这句话: + +- **内部公文模块是“文档为主、run 为辅、详情按 documentId 进入、结果按 runId 展示”的业务模型。** + +只要这个模型拍板,后面所有实现都可以顺下来: + +- 列表怎么查 +- 详情怎么开 +- 历史怎么挂 +- 统计怎么算 +- 权限怎么继承 +- 删除怎么定义 + +如果这句话不拍板,后面无论先修前端还是后端,都会反复返工。 + +--- + +## 10. 审核结论 + +基于当前业务确认和代码现状,我给出的正式判断是: + +- 当前模块方向是对的 +- 当前实现还未闭环 +- 最大问题不是单点 bug,而是“主业务模型尚未完全统一” + +建议正式以本文方案收口后,再进入代码补齐阶段。 + +这样改一次,后面才不会反复拆。 diff --git a/docs/内部公文模块/内部公文模块运行与验收手册.md b/docs/内部公文模块/内部公文模块运行与验收手册.md new file mode 100644 index 0000000..3abee97 --- /dev/null +++ b/docs/内部公文模块/内部公文模块运行与验收手册.md @@ -0,0 +1,242 @@ +# 内部公文模块运行与验收手册 + +## 1. 文档目的 + +本文档只解决 4 个现实问题: + +- 当前 `govdoc` 正确的运行链路是什么 +- 现在页面为什么还可能报 `404 / 500 / 502 / 401` +- 应该按什么顺序排障 +- 如何判断“内部公文模块已经真正接到当前 1 后端” + +本文档基于 2026-05-17 当前机器与当前仓库实际联调结果整理。 + +--- + +## 2. 正确链路 + +当前内部公文模块正确链路应为: + +1. 浏览器访问 `http://172.16.0.59:5173/govdoc/audits?entryModuleId=3` +2. `5173` 由 `nginx` 提供入口 +3. `nginx` 上游转到当前前端开发服务 `127.0.0.1:5193` +4. 前端 `/api/govdoc/*` 代理到 `http://172.16.0.59:8096/api/govdoc/*` +5. `8096` 由当前仓库 `leaudit-platform` 后端提供 +6. 审查任务由当前仓库 Celery worker 消费 + +只要这 6 段里任意一段错了,外部表现就会异常。 + +--- + +## 3. 当前已确认事实 + +本次联调已确认以下事实成立: + +- 当前仓库后端标准端口是 `8096` +- 当前仓库前端开发服务端口是 `5193` +- 当前对外入口端口是 `5173` +- 当前 `govdoc` 接口代理文件是: + - [route.ts](/home/wren-dev/Porject/leaudit-platform/legal-platform-frontend/app/api/govdoc/[...path]/route.ts) +- 当前前端环境配置里: + - `API_BACKEND_TARGET=http://172.16.0.59:8096` +- 当前 `GET /api/govdoc/documents?page=1&pageSize=1` 已经能到达当前后端 +- 未登录访问该接口时,返回 `401 Unauthorized` +- 未登录访问 `/govdoc/audits` 时,会重定向到 `/login` + +这说明: + +- `govdoc` 文档列表接口已经接到当前 1 后端 +- 当前若再看到异常,优先怀疑运行进程或登录态,而不是先怀疑接口没接 + +--- + +## 4. 当前机器上的关键风险 + +当前机器上还存在一个高风险旧进程: + +- `python start_worker_with_routing.py --config-port 8096` +- `cwd=/home/wren-dev/Porject/docauditai` + +它不是当前仓库 worker。 + +这意味着: + +- 即使当前仓库代码已经修好 +- 只要正式任务仍被旧 worker 消费 +- `govdoc` 实际执行结果仍可能继续偏向旧项目行为 + +结论: + +- 当前最大运行风险不是代码本身,而是**旧 worker 未彻底退出正式链路** + +--- + +## 5. 正确启动方式 + +当前仓库建议使用: + +```bash +cd /home/wren-dev/Porject/leaudit-platform +./leaudit.sh start +``` + +查看状态: + +```bash +./leaudit.sh status +``` + +查看巡检结果: + +```bash +./leaudit.sh doctor +``` + +查看日志: + +```bash +./leaudit.sh logs backend +./leaudit.sh logs frontend +./leaudit.sh logs worker +./leaudit.sh logs beat +``` + +当前脚本关键文件: + +- [leaudit.sh](/home/wren-dev/Porject/leaudit-platform/leaudit.sh) +- [start_worker.sh](/home/wren-dev/Porject/leaudit-platform/scripts/start_worker.sh) +- [start_beat.sh](/home/wren-dev/Porject/leaudit-platform/scripts/start_beat.sh) + +--- + +## 6. 四类典型报错怎么判断 + +### 6.1 `404` + +通常说明: + +- `govdoc` 代理没走到当前后端 +- 当前后端没启动 +- 请求被打到了错误服务 + +优先检查: + +- `8096` 是否监听 +- `/api/govdoc/*` 是否仍代理到当前 `8096` +- 是否还在访问旧项目接口 + +### 6.2 `500` + +通常说明: + +- 路由已经接到了正确后端 +- 但数据库、表、规则文件、运行数据或服务层逻辑有问题 + +这类问题要优先看: + +- `.codex-run/backend.log` +- 后端 traceback +- `govdoc_runs / govdoc_report_artifacts` 相关表和数据 + +### 6.3 `502` + +通常说明: + +- `5173` 的 nginx 上游失活 +- `5193` 前端服务未启动 +- `8096` 后端未启动 + +这类问题不是业务 bug,先查进程和端口。 + +### 6.4 `401` + +通常说明: + +- 链路已经通到当前后端 +- 只是当前请求没有有效登录态 + +这类情况恰恰说明: + +- `govdoc` 接口已经不是 `404` +- 前后端契约已经在工作 + +--- + +## 7. 当前最小排障顺序 + +每次出现问题,建议严格按这个顺序查: + +1. 先看 `./leaudit.sh status` +2. 再看 `ss -ltnp` 是否存在 `5173 / 5193 / 8096` +3. 再看旧 worker 是否还在: + - `ps -ef | rg "start_worker_with_routing.py --config-port 8096"` +4. 再看前端 `govdoc` 代理是否还是指向 `8096` +5. 再看后端日志是 `401 / 404 / 500` 哪一类 +6. 最后才去怀疑业务代码 + +不要反过来先改代码。 + +--- + +## 8. 当前验收标准 + +如果要判断“内部公文模块已经真正接到当前后端并基本可用”,至少要满足: + +### 8.1 运行层 + +- `5173` 正常访问 +- `5193` 正常运行 +- `8096` 正常运行 +- 当前仓库 worker 正常运行 +- 当前仓库 beat 正常运行 + +### 8.2 接口层 + +- 未登录访问 `/govdoc/audits` 时,正常跳到登录页 +- 已登录访问 `/api/govdoc/documents` 时,返回真实列表数据 +- 不再出现 `404 Not Found` + +### 8.3 业务层 + +- 文档列表可打开 +- 详情页可打开 +- 新 run 完成后有正式报告产物 +- 可打开 HTML 报告 +- 可下载批注 DOCX +- 段落视图可渲染 + +### 8.4 数据层 + +- `govdoc_runs` 有最新 run +- `govdoc_report_artifacts` 有正式产物索引 +- 历史修复前 run 如需报告,已补跑 + +--- + +## 9. 当前还没完全收口的点 + +截至当前,还没彻底收口的不是主链是否存在,而是这些细节: + +- 旧 `docauditai` worker 仍在机器上存活 +- 正式 supervisor / 守护方式还没有完全切回当前仓库 +- 历史成功 run 不会自动补出报告产物,需要补跑 +- 还需要带真实登录态做一轮完整前端验收 + +--- + +## 10. 当前结论 + +当前内部公文模块的真实状态应表述为: + +- 业务语义主线已基本对齐 +- 后端主链路已接通 +- 报告产物主闭环已补齐 +- 前端 `govdoc` 列表接口已接到当前 1 后端 +- 当前最大的剩余问题是运行部署收口,而不是“文档列表没接后端” + +后续如果继续推进,优先级应为: + +1. 清理旧 worker 干扰 +2. 固化正式启动/重启方式 +3. 带真实登录态做完整联调验收 +4. 再处理余下产品细节 diff --git a/docs/内部公文模块/内部公文规则补齐实施清单.md b/docs/内部公文模块/内部公文规则补齐实施清单.md new file mode 100644 index 0000000..8b92d91 --- /dev/null +++ b/docs/内部公文模块/内部公文规则补齐实施清单.md @@ -0,0 +1,411 @@ +# 内部公文规则补齐实施清单 + +## 1. 文档目的 + +本文档只回答一个实施问题: + +- 在**不改变旧项目内部公文业务语义**的前提下,如何把旧项目 `31` 条规则按当前 `leaudit-platform` 的 `govdoc` 规范补齐回来 + +本文档不讨论“是否需要重新设计一套公文规则引擎”,因为当前结论已经明确: + +- 旧项目和当前平台使用的是同一套 `govdoc DSL` +- 当前平台 `govdoc_engine` 已具备承接旧规则的大部分执行能力 +- 当前缺的主要不是引擎能力,而是**规则内容尚未完整迁入** + +--- + +## 2. 结论先行 + +当前内部公文规则补齐工作,应按下面这个判断推进: + +1. **规则 DSL 不需要重做** +2. **现有执行器不需要先推翻重写** +3. **第一优先级是把旧项目规则语义按当前平台 YAML 补齐** +4. **只有在迁入后验证发现误报/漏报时,才对角色识别、提示词、个别 check 实现做小范围修正** + +换句话说: + +- 当前不是“先开发引擎,再迁规则” +- 而是“先补规则,再基于验证结果修实现细节” + +--- + +## 3. 当前平台承接能力盘点 + +## 3.1 DSL 结构已对齐 + +当前平台规则文件: + +- `rules/govdoc/govdoc_general/rules.yaml` + +旧项目规则文件: + +- `/home/wren-dev/Porject/govdoc-audit/rules/govdoc_general/rules.yaml` + +两边都是: + +- `metadata` +- `extract` +- `rules` + +因此补齐规则时,不需要把规则改写成合同模块那套 DSL。 + +--- + +## 3.2 当前平台已支持的 check 类型 + +旧项目规则实际用到的 check 类型如下: + +- `ai` +- `attachment_marker_style` +- `confused_pair` +- `cross_role` +- `font` +- `forbid_chars` +- `forbid_phrase` +- `hierarchy` +- `punctuation` +- `regex_forbid` +- `wenzhong_whitelist` + +这些 check 类型当前平台都已经存在于 `govdoc_engine` 中。 + +因此从实施角度判断: + +- **绝大多数旧规则可以直接按 YAML 迁入** + +--- + +## 3.3 当前平台已具备的 role 识别前提 + +当前 `role_tagger_rule.py` 已可识别: + +- `title` +- `doc_number` +- `date` +- `recipient` +- `signature` +- `attachment_marker` +- `attachment_title` +- `heading_1` +- `heading_2` +- `heading_3` +- `heading_4` +- `body` + +这意味着旧项目规则里依赖以下 role 的规则已有运行前提: + +- `heading_1` +- `heading_2` +- `heading_3` +- `heading_4` +- `body` +- `attachment_marker` +- `any` + +结论是: + +- **旧项目规则在“选段目标”这一层,没有明显的结构性阻塞** + +--- + +## 3.4 当前平台现状不是“没规则”,而是“规则过少” + +当前平台内部公文规则现状: + +- `2` 个规则组 +- `6` 条规则 + +当前保留的规则主要是: + +- 标题必填 +- 发文字号必填 +- 署名必填 +- 日期必填 +- 文种白名单 +- 附件标记样式 + +这属于: + +- **最小可运行规则集** + +不属于: + +- **旧项目完整规则语义** + +--- + +## 4. 旧项目 31 条规则迁移分类 + +为避免后续实施发散,旧规则应先按“迁移方式”分组,而不是按代码文件分组。 + +## 4.1 A 类:可直接迁入的确定性规则 + +这类规则特点是: + +- 不依赖 LLM 推理 +- 主要依赖正则、字体、标点、层级、固定短语等确定性判断 +- 迁移成本最低 +- 最适合作为第一批补齐内容 + +包含如下规则: + +### 标题类 + +- `GW-T-002` 标题不可有“请求+请示”重复 +- `GW-T-003` 标题不可有“上报+报告”重复 +- `GW-T-004` 标题介词连用 +- `GW-T-005` 标题文种白名单 + +### 发文字号类 + +- `GW-N-001` 发文字号必须用六角括号 +- `GW-N-002` 发文字号不可加“第”字 +- `GW-N-003` 发文字号顺序号不编虚位 + +### 格式类 + +- `GW-F-001` 主标题用方正小标宋简体二号 +- `GW-F-002` 一级标题用黑体三号 +- `GW-F-003` 二级标题用楷体三号 +- `GW-F-004` 正文用仿宋三号 +- `GW-F-005` 附件后不加冒号 +- `GW-F-006` 不使用“此页无正文” +- `GW-F-007` 附件项末尾不加标点 +- `GW-F-008` 三级标题用仿宋三号 +- `GW-F-009` 四级标题用仿宋三号 +- `GW-F-010` 附件标记用黑体三号不加粗 + +### 层级类 + +- `GW-H-001` 层级序号格式 +- `GW-H-002` 二级标题换行不带句号 + +### 标点类 + +- `GW-P-001` 多书名号/引号并列不加顿号 +- `GW-P-002` 句内括号末尾不加标点 +- `GW-P-003` 引号嵌套不规范 + +### 文字提法类 + +- `GW-W-001` 易混淆词使用 +- `GW-W-003` 成文日期用阿拉伯数字 +- `GW-W-004` 成文日期不编虚位 + +这批规则的实施结论: + +- **优先补** +- **优先恢复旧 rule_id** +- **优先保持旧 messages / severity / category 语义** + +--- + +## 4.2 B 类:可迁入,但依赖 LLM 提示词稳定性的语义规则 + +这类规则当前平台格式和引擎都支持,但它们的稳定性更依赖: + +- 提示词是否原样保留 +- 实体抽取结果是否稳定 +- role 识别和上下文拼接是否足够 + +包含如下规则: + +### 标题类 + +- `GW-T-001` 标题文种合规性 +- `GW-T-006` 标题回行词意完整 +- `GW-T-008` 标题字体(语义实体通道示例) + +### 文字表述类 + +- `GW-W-002` 简称使用规范 + +### 发文机关类 + +- `GW-S-001` 发文机关署名不能用简称 +- `GW-S-002` 发文机关确定严谨性 + +这批规则的实施结论: + +- **可以按 YAML 直接迁入** +- **不需要先开发新 check** +- **但迁入后必须做专项样本验证** + +这里的重点不是“能不能跑”,而是: + +- **误报率是否可接受** +- **fail 条件是否与旧项目提示词语义一致** + +--- + +## 4.3 C 类:当前已有最小替代规则,但语义仍未完全恢复 + +当前平台已有的 6 条规则中,有一部分与旧语义有关联,但并不等价。 + +例如: + +- `govdoc_wenzhong_whitelist` +- `govdoc_attachment_marker_style` + +问题不在于它们“错了”,而在于: + +- 它们使用的是新命名 +- 它们只覆盖了旧规则中的一个点 +- 它们没有连带恢复同组其它规则 + +因此这类规则后续要按一个明确原则处理: + +- **不是继续叠加 bootstrap 规则** +- **而是回到旧项目规则语义集合,统一整理成正式规则集** + +否则后面会出现: + +- bootstrap 规则一套命名 +- 旧项目规则一套命名 +- 语义重叠但口径不同 + +这会直接破坏规则可追溯性和前端规则展示一致性。 + +--- + +## 5. 建议的实施顺序 + +## 5.1 第一阶段:规则集语义回正 + +这一阶段只做一件事: + +- 把当前 `govdoc_general` 规则文件从“最小运行版”恢复到“旧项目正式规则版” + +建议动作: + +1. 以旧项目 `31` 条规则为基准 +2. 保留当前平台 `govdoc DSL` 结构不变 +3. 优先恢复旧 `rule_id / name / severity / category / messages` +4. 把当前 6 条 bootstrap 规则合并回正式规则语义,而不是长期并存 + +这一阶段的目标不是调优,而是: + +- **先把规则集语义补齐** + +--- + +## 5.2 第二阶段:确定性规则验证 + +这一阶段重点验证 A 类规则: + +- 正则规则是否命中正确 +- 字体规则是否受样式解析影响 +- 标点/层级规则是否存在明显误报 + +建议验证方式: + +1. 选取一批旧项目正例/反例文档 +2. 对比迁移前后 `checked_rules / findings` +3. 重点看是否出现“旧项目 fail,当前 pass”或“旧项目 pass,当前 fail” + +这一阶段的验收重点是: + +- **确定性规则尽量做到与旧项目结论一致** + +--- + +## 5.3 第三阶段:语义规则验证 + +这一阶段只关注 B 类规则。 + +建议验证项: + +1. 提示词是否与旧项目一致 +2. 上下文变量是否完整可用 +3. LLM 返回 `pass / warn / fail` 时,当前平台展示是否保持旧语义 +4. 是否出现明显漂移,例如该 pass 的规则被大量误报为 fail + +这一阶段允许的改动是: + +- 小范围调整 prompt +- 小范围补上下文字段 +- 小范围修正实体输入 + +这一阶段不应做的事情是: + +- 重新定义业务判定标准 +- 为了追求“看起来更智能”而改变旧规则语义 + +--- + +## 5.4 第四阶段:规则集治理接轨平台 + +当规则内容补齐并验证后,再处理平台治理问题: + +- `type_id` 命名是否回归 `govdoc.general` +- 规则版本记录如何写入平台规则治理链路 +- 规则列表、规则详情接口如何展示完整 metadata + +这一步的重点是: + +- **把已经恢复的旧业务规则集,真正纳入当前平台的规则版本治理** + +而不是继续长期依赖固定文件路径硬编码。 + +--- + +## 6. 实施边界 + +## 6.1 可以做的事情 + +- 按当前 `govdoc DSL` 补旧规则 YAML +- 复用当前 `govdoc_engine` 已有 check +- 复用当前 role tagger +- 对少量提示词或 role 识别做兼容性修正 +- 按当前平台规范接入规则列表、规则详情、版本记录 + +--- + +## 6.2 不应做的事情 + +- 把内部公文规则改写成合同 DSL +- 因为当前有 6 条 bootstrap 规则,就以它们为最终标准 +- 在没有业务确认的情况下重命名旧规则语义 +- 为了迁移方便删除 `skipped`、`warning`、`pass` 三分态 +- 用“新平台习惯”替代旧项目正式审查语义 + +--- + +## 7. 当前建议的优先级 + +## P0:必须先补 + +- A 类确定性规则整体迁入 +- 旧规则 metadata 语义恢复 +- 旧 rule_id 体系恢复 +- 规则列表 / 规则详情能看到正式规则集 + +## P1:紧随其后 + +- B 类 LLM 语义规则迁入 +- 样本文档专项验证 +- 报告页中规则结果与旧项目口径对齐 + +## P2:最后收口 + +- `type_id` 命名统一 +- 规则版本治理正式接轨平台 +- 后台规则管理入口与平台其它文档类型规则治理一致 + +--- + +## 8. 最终结论 + +内部公文规则补齐这件事,现在不该再停留在“有没有 YAML”“格式支不支持”这个层面。 + +当前真实状态是: + +- 平台格式已具备 +- 运行链路已具备 +- 承接 check 已具备 +- 缺的是**旧项目正式规则内容没有完整迁回** + +因此下一步实施应按下面的最小原则推进: + +> **不改业务语义,不重造引擎,先把旧项目 31 条规则按当前平台 `govdoc DSL` 补齐回来,再用样本文档验证执行效果。** diff --git a/docs/内部公文模块/实施边界锁定后的补齐修复计划.md b/docs/内部公文模块/实施边界锁定后的补齐修复计划.md new file mode 100644 index 0000000..49a004d --- /dev/null +++ b/docs/内部公文模块/实施边界锁定后的补齐修复计划.md @@ -0,0 +1,486 @@ +# 实施边界锁定后的补齐修复计划 + +## 1. 文档目的 + +在 [迁移前后业务语义对照分析.md](./迁移前后业务语义对照分析.md) 已经明确“业务逻辑保持一致、代码实现按当前平台规范重构”的前提下,本文档进一步回答 4 个问题: + +- 当前内部公文模块还差哪些补齐项 +- 这些补齐项里哪些是 P0 / P1 / P2 +- 应该按什么顺序推进,才能低风险落地 +- 每一阶段完成后,什么算真正完成 + +本文档不再讨论“要不要按旧系统照搬”,而是直接面向实施。 + +--- + +## 2. 总体判断 + +当前 `govdoc` 模块的状态不是“从零开始”,也不是“只差几个 bug”。 + +更准确地说: + +- **业务骨架已迁入平台** +- **执行主链已基本打通** +- **但交付语义还没有完全恢复** + +因此后续工作重点不是继续铺骨架,而是: + +- **把已经迁进来的骨架收口成一套完整、可验收的业务闭环** + +从实施角度看,当前缺口应分成三层: + +- `P0`:不补就不构成完整业务闭环 +- `P1`:主链能跑,但结果质量或体验还不满足验收 +- `P2`:结构性优化和后续治理项 + +--- + +## 3. 实施总原则 + +后续所有修复都必须同时满足: + +1. **业务语义对齐迁移前项目** +2. **代码实现遵守当前 `leaudit-platform` 规范** + +落到执行上,就是三条硬规则: + +- 不允许为了快,直接把旧项目代码原样塞进当前平台 +- 不允许为了平台化,削弱旧项目已成立的业务语义 +- 不允许只把接口点亮,而不恢复正式结果闭环 + +--- + +## 4. 当前待补齐项总览 + +## 4.1 P0:必须先收口的闭环项 + +### P0-1 报告产物闭环恢复 + +当前问题: + +- `run` 完成后,后端报告读取接口虽然存在,但产物生成链路并未真正闭环 +- 前端详情页和列表页已经暴露了 `报告 HTML / 批注 DOCX` 能力 +- 这会形成“页面有入口、业务无结果”的假闭环 + +业务原因: + +- 旧项目里报告产物是正式业务结果,不是附属能力 + +实施目标: + +- 每次 `govdoc run` 成功后,必须形成并持久化: + - `annotated.docx` + - `report.html` + - `paragraphs.html` +- 平台侧应将这些产物写入正式索引,并稳定提供读取能力 + +对应代码区域: + +- `fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py` +- `fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py` +- `fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py` +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` + +完成判定: + +- `run completed` 后,详情页 `批注 DOCX` 和 `报告 HTML` 均真实可用 +- `GetRunParagraphs()` 返回稳定可渲染段落视图 +- 列表页“报告”入口可直接打开真实 HTML 报告 + +--- + +### P0-2 规则生效链路正式化 + +当前问题: + +- 接口已出现 `ruleVersionId` +- 但当前执行时仍主要依赖固定 `rules.yaml` 路径解析 +- 这意味着“参数存在”与“规则真正生效”不是一回事 + +业务原因: + +- 旧项目至少保证“当前生效规则集明确、审查结果知道自己用了哪版规则” +- 平台化后不能退回到长期硬编码规则路径 + +实施目标: + +- `ruleVersionId` 必须真正进入 `govdoc` 执行链路 +- 审查结果必须可追溯到本次使用的规则版本 +- `/govdoc/rules` 与 `/govdoc/rules/{ruleId}` 的返回语义必须与实际生效规则一致 + +对应代码区域: + +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/controllers/govdocController.py` +- `fastapi_modules/fastapi_leaudit/govdoc_bridge/tasks.py` +- 平台现有规则版本治理相关实现 + +完成判定: + +- 上传或创建 run 时指定规则版本,执行链路实际按该版本生效 +- 结果页、规则页、审查记录三处能追溯同一规则版本 +- 不再依赖长期硬编码路径作为正式方案 + +--- + +### P0-3 详情结果对象收口 + +当前问题: + +- 底层已是 `document + run` +- 但详情结果仍有多接口拼接、不完全稳定、产物和结果状态不同步的问题 + +业务原因: + +- 旧项目详情页拿到的是一份完整审查结果 +- 当前平台即使分对象存储,也必须对前端恢复同样的完整语义 + +实施目标: + +- 以 `documentId` 为详情主入口保持不变 +- 对外提供一份完整、稳定、可直接渲染的详情结果对象 +- `summary / findings / checked_rules / entities / structure / outline / reports` 必须共同成立 + +对应代码区域: + +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` +- `legal-platform-frontend/lib/api/govdoc-audit/api.ts` +- `legal-platform-frontend/components/govdoc-audit/audit.tsx` + +完成判定: + +- 详情页不再依赖“接口凑合可用” +- 数据对象语义稳定,状态与产物一致 +- 多次历史 run 下,默认最新 run,且可正确切换结果 + +--- + +### P0-4 数据库与环境初始化正式化 + +当前问题: + +- 当前依赖运行时 `_ensureGovdocSchema()` 做大量兜底建表 +- 这虽然能保命,但不是正式交付方式 + +业务原因: + +- 环境初始化不稳定会直接造成模块“今天能跑、明天因表缺失又挂” + +实施目标: + +- 将 `govdoc` 相关表结构、索引、必要种子数据纳入正式初始化方案 +- 运行时兜底可保留,但不应再作为主方案 + +对应代码区域: + +- `scripts/创建sql/schema_add_govdoc_module.sql` +- 权限/entry module seed SQL +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` + +完成判定: + +- 新环境按正式 SQL / migration 能直接初始化 +- 不依赖首次访问接口触发表自动创建 +- 菜单、权限、模块入口在环境中可直接可用 + +--- + +## 4.2 P1:影响验收质量的补齐项 + +### P1-1 识别与规则结果质量调优 + +当前问题: + +- 主链能跑不代表结果可验收 +- 标题、发文字号、日期、署名、主送机关等核心实体识别仍偏弱 +- 规则命中质量尚未达到正式交付水位 + +业务原因: + +- 内部公文模块交付的是“审查结果质量”,不是“接口运行成功率” + +实施目标: + +- 优先提高核心实体识别准确率 +- 优先修正高频规则误判/漏判 +- 保证结构、实体、findings 三者结果相互一致 + +重点区域: + +- `govdoc_engine/parser/docx_parser.py` +- `govdoc_engine/parser/role_tagger_rule.py` +- `govdoc_engine/parser/entity_builder.py` +- `rules/govdoc/govdoc_general/rules.yaml` + +完成判定: + +- 典型正反例文档结果可解释 +- 核心实体识别达到可验收水平 +- 主要规则不再出现大面积误判 + +--- + +### P1-2 文件能力与前端展示对齐 + +当前问题: + +- 旧项目业务支持 `.docx / .doc / .wps` +- 当前平台后端上传只支持 `.docx` +- 但列表筛选和部分前端文案仍残留 `.doc / .wps` + +业务原因: + +- 如果平台决定暂时只支持 `.docx`,这必须是明确业务决策,不是前后端不一致 + +实施目标: + +- 明确当前阶段文件支持范围 +- 前端上传、筛选、提示、错误返回统一 +- 若恢复 `.doc / .wps`,则补齐转换链路;若不恢复,则前端彻底收口到 `.docx` + +对应代码区域: + +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` +- `legal-platform-frontend/components/govdoc-audit/upload-zone.tsx` +- `legal-platform-frontend/components/govdoc-audit/audits.tsx` + +完成判定: + +- 页面展示、接口能力、错误文案三者一致 +- 用户不会被“页面可选但后台不支持”的假能力误导 + +--- + +### P1-3 列表、详情、报告入口行为统一 + +当前问题: + +- 列表页、详情页、报告入口的行为已经大体搭起来 +- 但部分入口仍受到底层状态缺口影响 + +业务原因: + +- 旧项目交互语义很明确,迁移后不能出现“有的入口跳详情,有的入口拿不到结果”的不稳定体验 + +实施目标: + +- 列表页保持文档主入口语义 +- 详情页保持完整审查结果页语义 +- 报告入口、原文入口、历史入口行为一致可预期 + +对应代码区域: + +- `legal-platform-frontend/components/govdoc-audit/audits.tsx` +- `legal-platform-frontend/components/govdoc-audit/audit.tsx` +- `legal-platform-frontend/lib/api/govdoc-audit/api.ts` + +完成判定: + +- 列表到详情、详情到报告、详情到原文的链路无歧义 +- 同一文档在不同入口下看到的是一致结果 + +--- + +### P1-4 历史 run 与最新结果语义补全 + +当前问题: + +- 当前业务已确认“文档为主、run 为辅、默认展示最新 run、保留历史 run” +- 但这套能力还没有完全收口为正式产品语义 + +实施目标: + +- 详情页默认展示最新 run +- 历史 run 清晰可切换 +- 首页统计、列表状态、详情摘要统一按最新 run 计算 + +完成判定: + +- 同一文档多次执行后,结果语义稳定 +- 用户能明确看到“当前结果”和“历史结果”的边界 + +--- + +## 4.3 P2:结构治理与后续优化项 + +### P2-1 实体结果结构化治理 + +当前问题: + +- 实体结果目前主要通过 `result_summary_json` 承载 +- 这在短期内可用,但不利于后续检索、统计和演进 + +实施目标: + +- 评估是否需要独立 `govdoc_entities` 或等价结构化承载 +- 不影响 P0/P1 交付前提下,作为后续演进项 + +--- + +### P2-2 文档与实施说明同步 + +当前问题: + +- 部分文档仍反映旧阶段问题,和当前代码状态不完全一致 + +实施目标: + +- 在完成 P0/P1 后,同步更新: + - 运行依赖说明 + - 对接补齐计划 + - 接口与权限说明 + +--- + +### P2-3 运行数据清理与演示样本整理 + +当前问题: + +- 历史失败 run、临时测试数据、旧入口痕迹容易干扰验收 + +实施目标: + +- 在提测前统一清理历史无效 run +- 整理一套正反例验收文档 + +--- + +## 5. 推荐实施顺序 + +建议严格按以下顺序推进。 + +## 第一阶段:恢复正式闭环 + +目标: + +- 先让模块从“能跑”变成“能完整交付” + +顺序: + +1. 报告产物闭环恢复 +2. 详情结果对象收口 +3. 数据库与初始化正式化 + +阶段完成标志: + +- 上传一份文档,能够形成完整 run 结果 +- 详情页可稳定展示完整结果 +- HTML 报告、批注 DOCX、段落预览全部真实可用 + +--- + +## 第二阶段:恢复规则正式语义 + +目标: + +- 把“临时规则文件方案”升级为“平台规则生效方案” + +顺序: + +1. `ruleVersionId` 真正接入执行链路 +2. 当前生效规则集与规则详情统一 +3. 审查结果规则追溯打通 + +阶段完成标志: + +- 指定规则版本可以真实生效 +- 规则页、审查页、结果记录三者语义一致 + +--- + +## 第三阶段:提高结果质量与交互一致性 + +目标: + +- 让结果从“可用”提升为“可验收” + +顺序: + +1. 核心实体识别调优 +2. 高频规则误判修正 +3. 文件能力与前端展示对齐 +4. 历史 run 与入口行为统一 + +阶段完成标志: + +- 典型样本结果可解释 +- 页面行为稳定 +- 用户不会在上传、列表、详情、报告之间遇到语义冲突 + +--- + +## 第四阶段:治理与收尾 + +目标: + +- 为长期维护和后续扩展做准备 + +顺序: + +1. 实体结构化治理 +2. 文档更新 +3. 历史测试数据与演示数据收尾 + +--- + +## 6. 每阶段实施时的约束 + +### 6.1 第一阶段禁止事项 + +- 禁止继续增加新页面能力 +- 禁止先把按钮点亮再补后端产物 +- 禁止绕开正式存储方案临时返回假链接 + +### 6.2 第二阶段禁止事项 + +- 禁止长期保留硬编码规则路径作为正式方案 +- 禁止出现“前端选了规则版本,后端实际没用”的假配置 + +### 6.3 第三阶段禁止事项 + +- 禁止在未确认业务收缩前,默默砍掉 `.doc / .wps` +- 禁止用前端文案掩盖后端能力缺失 + +--- + +## 7. 验收口径 + +后续每轮实施完成后,建议按下面口径验收。 + +## 7.1 P0 验收口径 + +- 上传文档后自动创建并完成一条 run +- 列表可见文档最新结果 +- 详情页可稳定打开 +- `findings / checked_rules / entities / structure / outline` 全部正常 +- `原始文件 / 批注 DOCX / 报告 HTML / 段落预览` 全部真实可用 + +## 7.2 P1 验收口径 + +- 核心正反例样本结果合理 +- 核心实体识别基本稳定 +- 列表筛选、分数分段、详情交互一致 +- 文件类型能力与页面提示一致 + +## 7.3 P2 验收口径 + +- 结构治理方案明确 +- 文档同步到当前代码状态 +- 演示和测试环境数据整洁 + +--- + +## 8. 结论 + +接下来的实施重点,不是继续扩功能,而是: + +- **先把旧项目已经成立的业务语义完整恢复到平台实现中** + +最重要的优先级顺序应固定为: + +1. 恢复报告与详情闭环 +2. 恢复规则正式生效语义 +3. 提升结果质量和前端一致性 +4. 做结构治理和文档收尾 + +只要坚持这个顺序,内部公文模块就能在不改业务逻辑的前提下,按当前项目规范稳妥完成迁移收口。 diff --git a/docs/内部公文模块/按阶段执行任务拆解清单.md b/docs/内部公文模块/按阶段执行任务拆解清单.md new file mode 100644 index 0000000..6406ff7 --- /dev/null +++ b/docs/内部公文模块/按阶段执行任务拆解清单.md @@ -0,0 +1,444 @@ +# 按阶段执行任务拆解清单 + +## 1. 文档目的 + +本文档基于以下两份已确认文档继续往下拆: + +- [迁移前后业务语义对照分析.md](./迁移前后业务语义对照分析.md) +- [实施边界锁定后的补齐修复计划.md](./实施边界锁定后的补齐修复计划.md) + +目标是把后续工作拆成可执行任务,方便逐项审核、排期、实施和验收。 + +--- + +## 2. 执行总原则 + +每项任务都按同一口径执行: + +- **业务语义按旧项目保持一致** +- **代码实现按当前 `leaudit-platform` 规范落地** +- **先做 P0,再做 P1,最后做 P2** + +每项任务都要求明确: + +- 改什么 +- 改哪里 +- 不改什么 +- 做完怎么验 + +--- + +## 3. 第一阶段:P0 闭环恢复 + +## 3.1 P0-1 恢复报告产物闭环 + +### 目标 + +恢复一次 `govdoc run` 完成后的正式产物闭环: + +- `annotated.docx` +- `report.html` +- `paragraphs.html` + +### 后端任务 + +1. 将旧项目报告生成逻辑映射到当前 `govdoc_bridge` 执行链路。 +2. 在 run 执行完成后,正式生成报告产物,而不是只写规则结果。 +3. 将生成后的产物上传到平台正式存储。 +4. 将产物索引写入 `govdoc_report_artifacts`。 +5. 确保同一 run 重复执行时,产物记录的覆盖/新增策略明确。 + +### 前端任务 + +1. 详情页的 `批注 DOCX`、`报告 HTML` 仅在真实产物存在时开放。 +2. 列表页“报告”入口指向真实 HTML 报告。 +3. 如果产物不存在,页面应明确呈现“未生成”,不能假成功。 + +### SQL / 环境任务 + +1. 核对 `govdoc_report_artifacts` 字段是否满足产物索引需要。 +2. 明确 OSS 路径命名规则和产物类型枚举。 + +### 重点文件 + +- `fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py` +- `fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py` +- `fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py` +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` +- `legal-platform-frontend/components/govdoc-audit/audit.tsx` +- `legal-platform-frontend/components/govdoc-audit/audits.tsx` + +### 不改事项 + +- 不改现有文档主档模型 +- 不改 `document + run` 结构 +- 不改页面主入口语义 + +### 完成标准 + +- 新上传一份文档,run 完成后可下载批注 DOCX +- 新上传一份文档,run 完成后可打开 HTML 报告 +- 段落视图可正常渲染并联动 findings + +--- + +## 3.2 P0-2 收口详情结果对象 + +### 目标 + +把当前分散在多个接口里的详情结果,收口为稳定可渲染的完整业务对象。 + +### 后端任务 + +1. 明确详情页主接口的返回语义。 +2. 确保以下数据在同一条业务语义上成立: + - `summary` + - `findings` + - `checked_rules` + - `entities` + - `structure` + - `outline` + - `reports` +3. 明确“默认 latest run”和“指定历史 run”的组装规则。 +4. 保证 `checked_rules`、`findings`、`summary` 三者统计一致。 + +### 前端任务 + +1. 详情页 API 适配层统一按 `documentId` 工作。 +2. 历史 run 切换后,页面所有标签页和产物入口同步切换。 +3. 错误态统一,不再出现部分接口成功、部分接口失败导致的半渲染页面。 + +### SQL / 环境任务 + +1. 核对 run 结果摘要字段与详情页所需数据是否匹配。 +2. 明确 `result_summary_json` 的职责边界,避免前后重复计算。 + +### 重点文件 + +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` +- `legal-platform-frontend/lib/api/govdoc-audit/api.ts` +- `legal-platform-frontend/components/govdoc-audit/audit.tsx` + +### 不改事项 + +- 不把详情入口改回 `runId` 主入口 +- 不把前端强行改成只看局部接口数据 + +### 完成标准 + +- 详情页首次打开就能完整稳定渲染 +- 切换历史 run 后,结果、报告、段落视图一致切换 +- 不再依赖“某几个接口恰好成功”才能正常展示 + +--- + +## 3.3 P0-3 规则版本正式接入执行链路 + +### 目标 + +把 `ruleVersionId` 从“接口参数”变成“实际生效规则版本”。 + +### 后端任务 + +1. 明确 `govdoc` 模块应如何接入平台现有规则版本治理。 +2. 打通: + - 上传文档传入 `ruleVersionId` + - 手动创建 run 传入 `ruleVersionId` + - run 执行阶段解析规则版本 + - 实际加载该版本规则内容 +3. 在 run 记录中保存本次实际使用的规则版本信息。 +4. `GetRuleDetail` / `ListRules` 应与当前生效规则版本一致。 + +### 前端任务 + +1. 如果当前阶段不开放规则版本选择,则隐藏选择能力,避免假入口。 +2. 如果开放规则版本选择,则页面选中的版本必须真生效。 + +### SQL / 环境任务 + +1. 明确 `govdoc` 与平台规则版本表的关联方式。 +2. 如需新增字段或绑定关系,纳入正式 schema / migration。 + +### 重点文件 + +- `fastapi_modules/fastapi_leaudit/controllers/govdocController.py` +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` +- `fastapi_modules/fastapi_leaudit/govdoc_bridge/tasks.py` +- 平台规则版本治理相关实现 + +### 不改事项 + +- 不长期依赖硬编码本地规则路径作为正式方案 + +### 完成标准 + +- 指定规则版本后,执行结果能证明使用的是该版本 +- 审查结果页、规则页、run 记录的规则版本信息一致 + +--- + +## 3.4 P0-4 数据库与初始化正式化 + +### 目标 + +将当前运行时兜底初始化,收口为正式初始化方案。 + +### 后端任务 + +1. 梳理 `govdoc` 所需正式表、索引、字段、默认值。 +2. 明确哪些保留运行时兜底,哪些必须前置初始化。 +3. 去掉“必须先访问接口一次,表才建出来”的隐性依赖。 + +### 前端任务 + +1. 无直接改动要求,主要配合环境验证。 + +### SQL / 环境任务 + +1. 整理并核对: + - `govdoc_runs` + - `govdoc_rule_results` + - `govdoc_report_artifacts` + - `leaudit_documents.engine_type` +2. 核对权限、菜单、entry module 种子 SQL。 +3. 新环境按正式 SQL 能一次初始化完成。 + +### 重点文件 + +- `scripts/创建sql/schema_add_govdoc_module.sql` +- 权限与 entry module 相关 seed SQL +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` + +### 不改事项 + +- 不删除必要兜底逻辑前,先保证正式初始化可跑通 + +### 完成标准 + +- 全新环境初始化后可直接运行 +- 不再出现缺表导致的 404/500 + +--- + +## 4. 第二阶段:P1 质量与一致性补齐 + +## 4.1 P1-1 核心实体识别调优 + +### 目标 + +优先把对业务最关键的实体识别稳定下来: + +- 标题 +- 发文字号 +- 日期 +- 署名 +- 主送机关 +- 文种 + +### 后端任务 + +1. 梳理旧项目实体抽取逻辑与当前 `govdoc_engine` 的差异。 +2. 逐项修正: + - `docx_parser` + - `role_tagger_rule` + - `entity_builder` +3. 确保“先结构化抽取,后差量 LLM 抽取”的语义不变。 + +### 前端任务 + +1. 实体页展示逻辑跟随后端真实结果,不自行兜假值。 + +### 验证任务 + +1. 选取典型正反例文档做实体识别比对。 +2. 建一组固定验收样本。 + +### 完成标准 + +- 核心实体识别达到可验收水平 +- 正反例结果基本符合业务预期 + +--- + +## 4.2 P1-2 高频规则误判修正 + +### 目标 + +优先解决最影响结果可信度的规则误判/漏判。 + +### 后端任务 + +1. 梳理当前高频错判规则。 +2. 修正规则 YAML、规则执行逻辑或实体依赖。 +3. 确保 `findings` 与 `checked_rules` 状态一致。 + +### 重点文件 + +- `rules/govdoc/govdoc_general/rules.yaml` +- 相关 parser / engine check 实现 + +### 完成标准 + +- 典型样本下不再出现明显错误结论 +- 高优先级规则结果可解释 + +--- + +## 4.3 P1-3 文件能力与页面提示统一 + +### 目标 + +把“系统真正支持什么文件”和“页面告诉用户支持什么文件”统一起来。 + +### 后端任务 + +1. 明确当前阶段是否恢复 `.doc / .wps -> docx` 转换能力。 +2. 如果恢复,补齐转换链路。 +3. 如果不恢复,统一收口为 `.docx`,并形成明确业务口径。 + +### 前端任务 + +1. 上传页提示、accept、错误提示与后端能力一致。 +2. 列表筛选文件类型与实际能力一致。 + +### 重点文件 + +- `fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py` +- `legal-platform-frontend/components/govdoc-audit/upload-zone.tsx` +- `legal-platform-frontend/components/govdoc-audit/audits.tsx` + +### 完成标准 + +- 用户不会再看到“页面允许、后端拒绝”的不一致 + +--- + +## 4.4 P1-4 列表、详情、历史 run 语义统一 + +### 目标 + +把文档主模型和历史 run 语义真正落成产品行为。 + +### 后端任务 + +1. 明确列表展示的是“文档最新结果”。 +2. 明确详情默认 latest run,支持历史 run 切换。 +3. 明确首页统计按文档 latest run 计算。 + +### 前端任务 + +1. 列表跳详情统一按 `documentId`。 +2. 历史 run 的显示、切换、当前标识明确。 +3. 避免再混用 `documentId` 和 `runId` 做同一主键字段。 + +### 完成标准 + +- 同一文档多次执行后,用户能看懂当前结果和历史结果 +- 列表、详情、统计三处的“最新结果”口径一致 + +--- + +## 5. 第三阶段:P2 治理与收尾 + +## 5.1 P2-1 实体结果结构化治理 + +### 目标 + +评估是否需要独立实体表或更正式的结构化结果域。 + +### 任务 + +1. 梳理现有 `result_summary_json` 承载内容。 +2. 明确哪些长期适合 JSON,哪些适合结构化。 +3. 如需拆表,作为后续治理任务安排,不阻塞 P0/P1。 + +--- + +## 5.2 P2-2 文档同步更新 + +### 目标 + +让内部文档反映当前真实代码状态和业务边界。 + +### 任务 + +1. 更新运行依赖说明。 +2. 更新现状偏差与补齐计划。 +3. 更新接口、权限、环境说明。 + +--- + +## 5.3 P2-3 测试与演示数据收尾 + +### 目标 + +为提测和演示整理稳定环境。 + +### 任务 + +1. 清理历史失败 run 和无效测试文档。 +2. 沉淀一组固定验收样本。 +3. 明确演示环境初始化步骤。 + +--- + +## 6. 推荐执行顺序 + +建议实际开发顺序如下: + +1. `P0-1` 报告产物闭环 +2. `P0-2` 详情结果对象收口 +3. `P0-4` 数据库与初始化正式化 +4. `P0-3` 规则版本正式接入 +5. `P1-1` 核心实体识别调优 +6. `P1-2` 高频规则误判修正 +7. `P1-3` 文件能力与页面提示统一 +8. `P1-4` 列表、详情、历史 run 语义统一 +9. `P2` 治理与文档收尾 + +之所以把 `P0-4` 放到 `P0-3` 前面,是因为: + +- 没有稳定环境初始化,规则接入和报告链路都容易被环境问题反复干扰 + +--- + +## 7. 建议的每轮交付节奏 + +为了避免一次改太大,建议每轮只交付一个可验证闭环。 + +### 第一轮 + +- 报告产物闭环 +- 详情页真实可下载/可打开报告 + +### 第二轮 + +- 规则版本正式接入 +- 规则页与执行结果规则版本一致 + +### 第三轮 + +- 核心实体识别调优 +- 高频规则误判修正 + +### 第四轮 + +- 文件能力统一 +- 历史 run 与列表/详情语义统一 + +### 第五轮 + +- 文档、数据、治理收尾 + +--- + +## 8. 最终结论 + +后续实施不应再以“能不能先跑起来”为目标,而应以: + +- **是否恢复完整业务闭环** +- **是否符合当前平台规范** + +作为唯一标准。 + +按这个拆解顺序执行,内部公文模块就能在不改业务逻辑的前提下,逐阶段恢复为一套真正可验收、可维护、可持续扩展的正式模块。 diff --git a/docs/内部公文模块/迁移前后业务语义对照分析.md b/docs/内部公文模块/迁移前后业务语义对照分析.md new file mode 100644 index 0000000..1a33d03 --- /dev/null +++ b/docs/内部公文模块/迁移前后业务语义对照分析.md @@ -0,0 +1,897 @@ +# 迁移前后业务语义对照分析 + +## 1. 文档目的 + +本文档只做一件事: + +- 把迁移前项目 `/home/wren-dev/Porject/govdoc-audit` 的**真实业务语义**与当前 `leaudit-platform` 中 `govdoc` 模块的**平台化实现语义**对齐清楚 + +本文档的目标不是要求“旧项目代码逐行照搬”,而是明确以下边界: + +- **业务逻辑不改** +- **代码实现方式可以按当前项目规范重构** +- **平台化接入可以改承载层,但不能改业务语义** + +换句话说: + +- 可以从旧项目的 `audit_id` 单体模型迁移为当前平台的 `document + run` 模型 +- 可以从本地文件持久化迁移为 `DB + OSS + worker` +- 可以从独立 FastAPI 项目迁移为 `leaudit-platform` 的模块化结构 + +但不能把“公文格式审查业务”改造成另一套语义。 + +--- + +## 2. 结论先行 + +本次迁移应遵守的总原则是: + +> **保持业务语义一致,允许技术实现重构。** + +具体解释如下: + +- **不要求旧项目的目录结构、表结构、路由写法、同步/异步实现方式一字不改** +- **要求用户视角的业务流程、结果语义、报告语义、规则语义、页面交互语义保持一致** +- **要求新增实现遵守当前 `leaudit-platform` 的代码规范、分层方式、权限体系、存储方式、路由组织方式** + +因此,后续所有补齐和修复都必须先回答一个问题: + +> 这是“实现方式变化”,还是“业务语义变化”? + +只有前者允许直接重构;后者必须先明确是否获得业务确认。 + +--- + +## 2.1 必须继承什么,不继承什么 + +为了避免把“参考旧项目”误解成“照搬旧代码”,这里把边界明确写死。 + +### 必须继承的是业务语义 + +必须继承的,是旧项目已经成立并已对外生效的业务语义,包括: + +- 审查对象是什么 +- 审查链路顺序是什么 +- 规则怎么定义、怎么解释 +- 结果对象由哪些部分构成 +- `pass / fail / skipped` 三分态语义 +- 报告产物有哪些 +- 详情页交付什么能力 +- 规则版本如何追溯 + +也就是说,必须继承的是: + +- **业务口径** +- **结果口径** +- **规则口径** +- **交付口径** + +### 不必须继承的是旧实现 + +不必须继承的,是旧项目为了落地这些业务语义所采用的具体技术实现,包括: + +- 旧目录结构 +- 旧项目模块拆分 +- 旧接口路径 +- 旧表结构 +- 旧本地文件存储方式 +- 旧同步执行方式 +- 旧前端工程组织方式 +- 旧缓存或本地路径约定 + +也就是说,不需要继承的是: + +- **代码写法** +- **工程结构** +- **部署方式** +- **存储承载方式** + +### 正确理解 + +因此,“以旧项目为基线”这句话,准确含义应是: + +> **以旧项目作为业务语义基线,而不是作为代码实现模板。** + +后续所有实施动作,都应按下面这个标准判断: + +- 如果是在恢复旧业务语义,可以推进 +- 如果是在复制旧实现细节,不应作为目标 + +这也是后续为什么应说: + +- **按旧业务语义补齐** + +而不应简单说: + +- **补齐到旧项目** + +--- + +## 3. 迁移前项目的业务基准 + +## 3.1 业务定位 + +迁移前项目 `govdoc-audit` 的本质不是普通文档管理,也不是审批流系统,而是: + +- **内部公文格式审查系统** + +其目标是: + +- 上传一份公文文件 +- 对公文格式、结构、文种、实体、规则进行审查 +- 产出一份完整审查结果和对应报告产物 + +因此它的核心不是“保存文件”,而是: + +- **完成一次完整审查并交付审查结果** + +--- + +## 3.2 核心业务对象 + +旧项目的核心业务对象是: + +- `AuditResult` + +它聚合了以下业务结果: + +- `document` +- `summary` +- `findings` +- `checked_rules` +- `entities` +- `structure` +- `outline` + +这说明旧项目的业务中心是: + +- **一次完整审查结果** + +而不是: + +- 单纯的文档主档 +- 单纯的异步任务记录 +- 单纯的某次规则执行流水 + +当前平台即使改为 `document + run` 模型,也必须保证对外仍能还原这一语义。 + +--- + +## 3.3 旧项目固定主链路 + +旧项目的审查链路顺序是明确且稳定的: + +1. 上传文件 +2. 统一转换为 `canonical.docx` +3. 解析文档结构 `parse_docx` +4. 角色标注 `role_tag` +5. 加载规则 `load_rules` +6. 先做结构化实体构建 `build_entities` +7. 对缺失实体做差量 LLM 抽取 +8. 执行规则评估 `evaluate_rules` +9. 生成完整 `AuditResult` +10. 生成报告产物 +11. 持久化结果 + +这条链路可以按当前项目规范拆层,但业务顺序不能被破坏。 + +--- + +## 3.4 旧项目的结果语义 + +旧项目对结果有几条明确语义: + +- `summary` 是结果总览,不是从前端临时拼装出来的派生值 +- `findings` 是问题清单,是详情页审查视图的主数据 +- `checked_rules` 是规则级结果清单,且必须保留 `pass / fail / skipped` +- `entities` 不只是调试数据,而是前端“实体”标签页正式展示内容 +- `structure` 与 `outline` 是正式交付数据,不是可有可无的附加信息 + +这意味着迁移后不能只保证“能查到一部分结果”,而必须保证: + +- **前端详情页所依赖的一整组业务结果是完整成立的** + +--- + +## 3.5 旧项目的规则语义 + +旧项目规则体系虽然技术上只是单个 YAML 文件,但业务语义是完整的: + +- 有一个当前生效规则集 +- 规则集有明确元数据:`type_id / version / source / description` +- 每次审查结果都知道自己使用的是哪一版规则 +- 前端可以查看当前规则列表和单条规则详情 + +因此迁移到平台后: + +- 可以不再沿用旧项目“本地单 YAML + 本地缓存”的实现方式 +- 但必须保留“当前生效规则集 + 规则版本可追溯 + 规则详情可查看”的业务语义 + +--- + +## 3.6 旧项目的报告语义 + +旧项目一次审查完成后,正式产物包括: + +- 原始文件 `original` +- 标准化文档 `canonical.docx` +- 审查结果 `result.json` +- 批注文档 `annotated.docx` +- 报告页面 `report.html` +- 段落视图 `paragraphs.html` + +这里最关键的是: + +- **报告产物不是附属功能,而是审查业务结果的一部分** + +旧项目里,详情页默认就可以使用: + +- 原始文件下载 +- 批注 DOCX 下载 +- 报告 HTML 打开 +- 段落视图联动预览 + +因此迁移到平台后,不能接受以下状态长期存在: + +- run 已完成,但报告产物为空 +- 前端按钮可点,但实际上无真实产物 +- 详情页只展示结构化 JSON 结果,不恢复正式报告闭环 + +--- + +## 3.7 旧项目的页面交互语义 + +旧项目前端已形成稳定交互语义: + +- 列表页是主入口 +- 上传成功后直接进入详情页 +- 详情页是完整审查结果页 +- `报告 HTML / 批注 DOCX / 原始文件 / 段落预览` 是四种不同能力 +- 问题列表与段落视图双向联动 +- `问题 / 已通过 / 已跳过` 是三分态展示,不可压扁为两态 +- 详情页 `review / structure / outline / entities` 是正式功能页签 + +迁移时页面风格、代码写法、路由框架可以变化,但交互语义不能被弱化。 + +--- + +## 4. 当前平台允许变化的实现层 + +以下变化属于**承载层重构**,原则上允许: + +## 4.1 对象模型变化 + +旧项目: + +- `audit_id` 是单一业务主键 + +当前平台可改为: + +- `document` 作为文档主档 +- `run` 作为执行记录 + +但要求: + +- 对外仍然要能还原“某份文档当前/某次完整审查结果” +- 前端详情页不能被迫暴露底层拆分复杂性 + +--- + +## 4.2 存储方式变化 + +旧项目: + +- 本地目录存文件 +- SQLite/本地 DB 存列表摘要 + +当前平台可改为: + +- OSS 存原文和报告产物 +- PostgreSQL 存文档、run、规则结果、产物索引 + +但要求: + +- 存储迁移不能导致产物语义丢失 +- 列表和详情所需的结果字段必须仍然可稳定读取 + +--- + +## 4.3 执行方式变化 + +旧项目: + +- 上传后同步或准同步返回完整结果 + +当前平台可改为: + +- 上传后创建 `run` +- 通过 Celery/worker 异步执行 + +但要求: + +- 最终完成后必须补齐旧项目那套完整审查结果语义 +- 不能因为改成异步,就把“正式报告产物”降级成以后再说的可选项 + +--- + +## 4.4 代码组织变化 + +旧项目: + +- 独立项目,接口、存储、规则、前端相对直连 + +当前平台可改为: + +- `controller / service / bridge / engine / model / frontend api adapter` + +但要求: + +- 新实现必须遵守当前项目开发规范 +- 不能为了“像旧项目”而破坏当前平台已有规范 + +换句话说: + +- **保业务,不保旧代码写法** + +--- + +## 4.5 路由变化 + +旧项目: + +- `/audit` +- `/audits` +- `/rules` + +当前平台可改为: + +- `/govdoc/documents` +- `/govdoc/runs` +- `/govdoc/rules` + +但要求: + +- API 适配层必须把旧前端依赖的业务语义重新拼装出来 +- 不能让前端为了适应后端重构而失去旧业务能力 + +--- + +## 5. 当前平台必须保持不变的业务语义 + +以下内容视为迁移边界,后续不得随意更改。 + +## 5.1 审查本质不变 + +`govdoc` 仍然是: + +- **内部公文格式审查模块** + +不是: + +- 流程审批模块 +- 纯文档归档模块 +- 单纯的规则管理模块 + +--- + +## 5.2 结果对象语义不变 + +即便底层是 `document + run`,对外仍必须稳定提供: + +- `summary` +- `findings` +- `checked_rules` +- `entities` +- `structure` +- `outline` + +这些必须被视为同一份审查结果的正式组成部分。 + +--- + +## 5.3 内置实体语义不变 + +以下 8 个内置实体语义必须保留: + +- `title` +- `doc_number` +- `recipient` +- `date` +- `signature` +- `attachments` +- `wenzhong` +- `issuer` + +迁移后可以调整存储位置和实现方式,但不能弱化为“可有可无”。 + +--- + +## 5.4 评分语义不变 + +评分规则保持: + +- `error` 每条扣 10 +- `warning` 每条扣 3 +- 最低 0 + +列表状态分段保持: + +- `pass >= 90` +- `warning 70-89` +- `fail < 70` + +这既影响后端汇总,也影响前端筛选和颜色语义。 + +--- + +## 5.5 规则状态语义不变 + +`checked_rules` 必须保留: + +- `pass` +- `fail` +- `skipped` + +不能做以下简化: + +- 把 `skipped` 并入 `fail` +- 只保留问题规则,不保留通过/跳过规则 + +--- + +## 5.6 报告产物语义不变 + +迁移后必须恢复并保持以下正式能力: + +- 原始文件下载 +- 批注 DOCX 下载 +- 报告 HTML 打开 +- 段落视图联动预览 + +如果某项能力在页面上出现按钮或入口,就必须对应真实可用的后端产物。 + +--- + +## 5.7 规则版本追溯语义不变 + +每次审查必须知道: + +- 使用了哪套规则 +- 使用了哪一版规则 + +旧项目是 `ruleset_id / ruleset_version`,新平台可以改成平台自己的规则版本对象,但业务追溯语义不能丢。 + +--- + +## 5.8 页面交互语义不变 + +必须保留: + +- 上传成功后直接进入详情 +- 列表作为主入口 +- 列表可查看、导出、删除 +- 详情页具备完整结果展示 +- 问题与段落视图双向联动 +- 详情页保留 `review / structure / outline / entities` + +页面样式可以按当前项目统一,但交互语义不能被削弱。 + +--- + +## 6. 当前平台与旧项目的主要差异判断 + +这里不讨论代码优劣,只判断“是否偏离业务语义”。 + +## 6.1 已经属于等价平台化改造的部分 + +以下变化本身没有问题: + +- 以 `leaudit_documents` 复用文档主档 +- 新增 `govdoc_runs` 记录执行历史 +- 使用 worker 异步执行 +- 使用 `govdoc_rule_results` 存储规则结果 +- 前端通过适配层把 `document + run` 再组装成详情页结果 + +这些属于合理平台化改造。 + +--- + +## 6.2 已偏离旧项目业务语义的部分 + +以下问题不是单纯代码 bug,而是业务语义没有完全迁回来: + +### 6.2.1 报告产物闭环未恢复 + +当前若 `run completed` 但 `annotated.docx / report.html` 没有真实产物,则已偏离旧项目“完成一次审查即交付正式报告”的业务语义。 + +### 6.2.2 规则版本参数未真正生效 + +如果接口出现了 `ruleVersionId`,但执行时仍然只走固定 YAML 路径,则不满足旧项目“当前规则集明确、结果可追溯”的业务语义,也不满足平台化后的规则治理预期。 + +### 6.2.3 文件能力被收缩但未做业务确认 + +旧项目支持 `.docx / .doc / .wps`,当前平台若只支持 `.docx`,这不一定绝对错误,但它已经是业务能力收缩,必须明确确认,不能默认当作正常迁移。 + +### 6.2.4 详情页结果仍依赖多接口拼接且存在缺口 + +旧项目详情页本质上拿到的是一份完整审查结果。当前平台如果只是“部分接口可用、部分产物缺失、部分状态不一致”,则尚未恢复旧项目业务完整性。 + +--- + +## 6.3 属于当前平台规范问题但不构成业务变更的部分 + +以下属于实现层改良点,不构成业务逻辑变化: + +- 按当前项目规范补 service 分层 +- 按当前项目规范补 schema / model / DDL +- 按当前项目规范补权限、菜单、entryModule +- 使用 OSS 而不是本地目录 +- 使用 worker 而不是同步执行 +- 把旧项目前端接口改成新平台 API adapter + +这些都应当做,而且应该做得更规范。 + +--- + +## 7. 后续实施的硬边界 + +后续所有开发和修复,必须同时满足两条: + +1. **业务语义对齐旧项目** +2. **代码实现遵守当前平台规范** + +任何方案如果只满足其中一条,都不能视为合格迁移。 + +### 7.1 不能接受的方案 + +- 只求接口返回 200,不管结果是否构成完整审查语义 +- 只把页面先点亮,不补正式报告产物 +- 只保留 JSON 结果,不恢复批注 DOCX 和 HTML 报告 +- 为了适配平台,擅自删除 `skipped`、实体页、结构页、大纲页等旧业务能力 +- 用硬编码规则路径长期替代规则版本治理 + +### 7.2 可以接受的方案 + +- 底层对象从 `audit` 拆成 `document + run` +- 用异步 `run` 承载旧项目同步审查流程 +- 用平台规则版本体系承载旧项目 `ruleset_id / ruleset_version` +- 用 OSS 索引表承载旧项目本地报告文件 +- 用平台前端适配层恢复旧项目页面语义 + +--- + +## 8. 面向后续实施的判定标准 + +后续补齐时,建议每一项改动都按下面三个问题判断: + +### 8.1 这项改动是在补实现,还是在改业务? + +如果是补实现,可以继续推进。 + +如果是改业务,必须先业务确认。 + +### 8.2 这项改动是否恢复了旧项目正式能力? + +例如: + +- 是否恢复正式报告产物 +- 是否恢复规则版本追溯 +- 是否恢复详情页完整结果 + +### 8.3 这项改动是否符合当前项目规范? + +例如: + +- 是否走当前项目的 service / controller / bridge 分层 +- 是否复用平台已有文档主档、权限、存储、规则治理能力 +- 是否避免写出一套脱离平台的新“旧系统兼容层” + +--- + +## 9. 结论 + +本次内部公文模块迁移,不应理解为: + +- 旧项目代码照搬 + +也不应理解为: + +- 只要能在当前平台里跑起来就算完成 + +正确理解应是: + +> **旧项目负责定义业务语义,当前平台负责承载实现。** + +因此后续实施必须坚持: + +- **业务逻辑保持一致** +- **代码实现按当前项目规范重构** +- **平台化不等于业务降级** + +在这个前提下,后续才有资格进入正式的补齐修复计划和实施排期。 + +--- + +## 10. YAML 迁移现状补充结论 + +这一节专门回答一个更具体的问题: + +- 旧项目内部公文的 `rules.yaml` 有没有迁到当前平台 +- 迁过来之后,是否符合当前平台的格式 + +### 10.1 先说结论 + +结论分三层: + +1. **DSL 格式层面:已经迁过来,而且当前平台可以直接解析执行** +2. **规则内容层面:不是完整迁移,只迁了一个最小可运行规则集** +3. **规则标识层面:`type_id` 命名发生了变化,这属于语义边界,需要后续明确是否回归旧命名** + +也就是说: + +- 不能说“内部公文 YAML 没迁” +- 但也不能说“旧项目规则已经完整迁完” + +更准确的表述是: + +> **当前平台已经有一份按 `govdoc` DSL 编写、可被当前引擎真实加载执行的内部公文规则 YAML,但它只是旧项目完整规则语义的缩减版。** + +--- + +### 10.2 当前平台与旧项目用的是同一套 govdoc DSL + +旧项目规则文件: + +- `/home/wren-dev/Porject/govdoc-audit/rules/govdoc_general/rules.yaml` + +当前平台规则文件: + +- `/home/wren-dev/Porject/leaudit-platform/rules/govdoc/govdoc_general/rules.yaml` + +两边都采用同样的三段结构: + +- `metadata` +- `extract` +- `rules` + +并且两边 DSL schema 是同源的: + +- 旧项目:`/home/wren-dev/Porject/govdoc-audit/src/govdoc_audit/dsl/schema.py` +- 当前平台:`/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/govdoc_engine/dsl/schema.py` + +我已直接核对: + +- `CheckType` 集合一致 +- `Rule / RuleGroup / RuleSet` 结构一致 +- `load_rules()` 加载方式一致 + +因此这里的事实不是: + +- 旧项目一套 YAML +- 当前平台另一套 YAML + +而是: + +- **旧项目和当前平台本来就在使用同一套 `govdoc DSL`** + +所以“按照我们的平台格式”这句话,正确理解应该是: + +- **是否符合当前平台 `govdoc_engine` 的 DSL 格式** + +而不是: + +- 是否长得像合同规则那套 `leaudit DSL` + +--- + +### 10.3 当前平台这份 YAML 已经被真实接入,不是摆设 + +当前平台 `govdoc` 服务里,规则文件解析路径已经明确指向: + +- `rules/govdoc/govdoc_general/rules.yaml` + +代码位置: + +- [govdocServiceImpl.py](/home/wren-dev/Porject/leaudit-platform/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py:963) + +并且我已用当前平台 loader 直接解析成功: + +- `metadata.type_id = govdoc_general` +- `metadata.name = 内部公文通用规则` +- `2` 个规则组 +- `6` 条规则 + +这说明: + +- **当前平台里的内部公文 YAML 不是未接入状态** +- **它已经是当前 `govdoc` 引擎实际使用的规则源** + +--- + +### 10.4 但规则内容不是旧项目全量迁移 + +旧项目规则文件统计结果: + +- `8` 个规则组 +- `31` 条规则 + +当前平台规则文件统计结果: + +- `2` 个规则组 +- `6` 条规则 + +而且两边 `rule_id` 没有重叠,说明当前平台这份 YAML 不是“把旧规则原样搬过来后删了几条”,而是: + +- **重新写了一个最小规则集** + +当前平台保留的 6 条规则是: + +- `govdoc_title_required` +- `govdoc_doc_number_required` +- `govdoc_signature_required` +- `govdoc_date_required` +- `govdoc_wenzhong_whitelist` +- `govdoc_attachment_marker_style` + +这 6 条规则只覆盖了: + +- 标题是否识别 +- 发文字号是否识别 +- 署名是否识别 +- 日期是否识别 +- 文种是否合法 +- 附件标记样式 + +它更像: + +- **内部公文链路先跑通时的最小可运行审查集** + +而不是: + +- **旧项目完整业务规则集** + +--- + +### 10.5 旧项目已存在、但当前平台尚未完整迁入的规则语义 + +旧项目那份规则 YAML 中,明确存在以下业务语义,而当前平台现有 YAML 尚未完整覆盖: + +#### 10.5.1 标题类规则 + +- 标题文种合规性 AI 判断 +- `"请求"+"请示"` 重复表述 +- `"上报"+"报告"` 重复表述 +- `"关于"+"对"` 介词连用 +- 标题回行破词检查 +- 标题字体字号检查 + +#### 10.5.2 发文字号规则 + +- 年份必须用六角括号 `〔〕` +- 顺序号前不加 `第` +- 顺序号不编虚位 + +#### 10.5.3 格式规则 + +- 主标题字体 +- 一级标题字体 +- 二级标题字体 +- 正文字体 +- 三级标题字体 +- 四级标题字体 +- 附件后不加冒号 +- 不使用“此页无正文” +- 附件项末尾不加标点 +- 附件标记字体样式 + +#### 10.5.4 层级序号规则 + +- 层级序号格式 +- 二级标题换行不带句号 + +#### 10.5.5 标点规则 + +- 并列书名号/引号之间不加顿号 +- 句内括号末尾不加标点 +- 引号嵌套规范 + +#### 10.5.6 文字表述与提法规则 + +- 易混淆词 +- 简称使用规范 +- 成文日期必须用阿拉伯数字 +- 成文日期不编虚位 + +#### 10.5.7 发文机关规则 + +- 发文机关署名不能用简称 +- 发文机关与党务/行政文稿性质一致性检查 + +这些规则并不是“旧项目里某些未来能力”,而是旧项目已经写进当前正式规则集、并通过 DSL 表达出来的既有业务语义。 + +因此从迁移判断上讲,当前状态应定义为: + +- **格式已迁** +- **规则内容仅部分迁移** + +不能定义为: + +- 完整迁移 + +--- + +### 10.6 当前平台 DSL 能力足够承接旧规则,不存在“格式不兼容”问题 + +我核对了旧项目规则里实际使用到的检查类型,包括: + +- `ai` +- `attachment_marker_style` +- `confused_pair` +- `cross_role` +- `font` +- `forbid_chars` +- `forbid_phrase` +- `hierarchy` +- `punctuation` +- `regex_forbid` +- `wenzhong_whitelist` + +这些检查类型都已经存在于当前平台 `govdoc_engine` 的 schema 与执行器中。 + +这意味着: + +- 当前没完整迁入旧规则,**主要不是因为平台 DSL 不支持** +- 而是因为当前仓库只先落了一份缩减规则集 + +这点很重要,因为它直接限定了后续实施边界: + +- 后续补齐旧规则时,原则上应优先补 YAML 内容 +- 不应先假设必须重写一套新的业务逻辑 + +--- + +### 10.7 需要特别注意的边界:`type_id` 命名已变化 + +旧项目规则元数据: + +- `metadata.type_id: govdoc.general` + +当前平台规则元数据: + +- `metadata.type_id: govdoc_general` + +这不是格式错误,但它是一个需要明确的语义边界,因为旧项目里与规则集相关的设计、文档、测试、存储语义,大量使用的是: + +- `govdoc.general` + +当前平台现在改成了: + +- `govdoc_general` + +因此这里至少要明确一件事: + +- 这是当前平台有意做的命名收敛 +- 还是迁移过程中临时改写后的遗留差异 + +在没有进一步统一之前,当前更稳妥的结论是: + +- **当前平台规则 YAML 在 DSL 格式上是合法的** +- **但其 `type_id` 与旧项目规则集标识并不完全一致** + +这个差异不一定马上要改,但必须在后续实施时被明确记录,避免后面在规则版本治理、回溯、接口契约、前端展示上继续漂移。 + +--- + +### 10.8 最终判断 + +针对“内部公文 YAML 有没有迁移过来,是否按我们平台格式”这个问题,最终判断如下: + +- **有迁过来** +- **格式上符合当前平台 `govdoc` 引擎 DSL** +- **并且已经被当前平台真实加载使用** +- **但只迁了最小可运行规则集,不等于旧项目完整规则语义已经迁完** +- **另外 `metadata.type_id` 从 `govdoc.general` 变成了 `govdoc_general`,这是一个应显式管理的命名差异** + +所以后续如果要继续推进,不应再讨论“要不要重新发明一套内部公文规则格式”,而应直接进入更准确的问题: + +> **在保持当前平台 `govdoc DSL` 不变的前提下,如何把旧项目内部公文 31 条规则语义按当前平台规范补齐回来。** diff --git a/fastapi_admin/celery_app.py b/fastapi_admin/celery_app.py index 636dd50..615448b 100644 --- a/fastapi_admin/celery_app.py +++ b/fastapi_admin/celery_app.py @@ -33,7 +33,10 @@ celery_app = Celery( celery_app.conf.update( task_default_queue=LEAUDIT_WORKER_QUEUE_NORMAL, - imports=("fastapi_modules.fastapi_leaudit.leaudit_bridge.tasks",), + imports=( + "fastapi_modules.fastapi_leaudit.leaudit_bridge.tasks", + "fastapi_modules.fastapi_leaudit.govdoc_bridge.tasks", + ), task_queues=( Queue(LEAUDIT_WORKER_QUEUE_URGENT), Queue(LEAUDIT_WORKER_QUEUE_NORMAL), @@ -56,9 +59,11 @@ celery_app.conf.update( celery_app.autodiscover_tasks( [ "fastapi_modules.fastapi_leaudit.leaudit_bridge", + "fastapi_modules.fastapi_leaudit.govdoc_bridge", ], force=True, ) # 显式导入任务模块,避免 worker 在某些启动方式下漏注册 bridge tasks。 from fastapi_modules.fastapi_leaudit.leaudit_bridge import tasks as _leaudit_bridge_tasks # noqa: F401,E402 +from fastapi_modules.fastapi_leaudit.govdoc_bridge import tasks as _govdoc_bridge_tasks # noqa: F401,E402 diff --git a/fastapi_modules/fastapi_leaudit/controllers/govdocController.py b/fastapi_modules/fastapi_leaudit/controllers/govdocController.py index 0161258..75f645c 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/govdocController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/govdocController.py @@ -31,7 +31,7 @@ class GovdocController(BaseController): file: UploadFile = File(...), typeId: int | None = Form(default=None), region: str = Form(default="default"), - autoRun: bool = Form(default=False), + autoRun: bool = Form(default=True), speed: str = Form(default="normal"), ruleVersionId: int | None = Form(default=None), payload: dict[str, Any] = Depends(verify_access_token), @@ -56,6 +56,7 @@ class GovdocController(BaseController): page: int = Query(default=1, ge=1), pageSize: int = Query(default=20, ge=1, le=100), keyword: str | None = Query(default=None), + fileExt: str | None = Query(default=None), region: str | None = Query(default=None), status: str | None = Query(default=None), resultStatus: str | None = Query(default=None), @@ -72,6 +73,7 @@ class GovdocController(BaseController): page=page, pageSize=pageSize, keyword=keyword, + fileExt=fileExt, region=region, status=status, resultStatus=resultStatus, diff --git a/fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py b/fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py index 85b526d..1601877 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_bridge/result_adapter.py @@ -26,6 +26,7 @@ class ResultAdapter: EngineResult: AuditResult, Structure: list[dict[str, Any]] | None = None, Outline: list[dict[str, Any]] | None = None, + Entities: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """从 AuditResult.summary 提取 run 汇总字段。 @@ -40,6 +41,10 @@ class ResultAdapter: aux["structure"] = Structure if Outline is not None: aux["outline"] = Outline + if Entities is not None: + aux["entities"] = { + entity["name"]: entity for entity in Entities if entity.get("name") + } return { "totalScore": s.score, @@ -100,6 +105,7 @@ class ResultAdapter: "primaryRole": entity.primary_role, "source": entity.source, "confidence": entity.confidence, + "extra": entity.extra, }) return entities diff --git a/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py b/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py index b75022d..c8ecdd6 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_bridge/runner.py @@ -9,14 +9,26 @@ from __future__ import annotations +import hashlib +import shutil +import tempfile from dataclasses import dataclass, field +from pathlib import Path from typing import Any +from sqlalchemy import text + from fastapi_common.fastapi_common_logger import logger +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils from fastapi_modules.fastapi_leaudit.govdoc_bridge.input_resolver import InputResolver -from fastapi_modules.fastapi_leaudit.govdoc_bridge.result_adapter import ResultAdapter from fastapi_modules.fastapi_leaudit.govdoc_bridge.storage_adapter import StorageAdapter +from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.docx_parser import parse_docx +from fastapi_modules.fastapi_leaudit.govdoc_engine.reporter.docx_annotator import annotate_docx +from fastapi_modules.fastapi_leaudit.govdoc_engine.reporter.html_paragraph import paragraphs_to_html +from fastapi_modules.fastapi_leaudit.govdoc_engine.reporter.html_renderer import render_html +from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl log = logger @@ -33,13 +45,30 @@ class GovdocRunner: InputResolver: InputResolver = field(default_factory=InputResolver) Storage: StorageAdapter = field(default_factory=StorageAdapter) - ResultAdapter: ResultAdapter = field(default_factory=ResultAdapter) + OssService: OssServiceImpl = field(default_factory=OssServiceImpl) + ResultAdapter: Any | None = None + + def ResolveRulesPath(self, RulesPath: str | None) -> str: + """解析并校验执行所需规则文件路径。""" + candidate = (RulesPath or "").strip() + if not candidate: + raise ValueError("未提供 govdoc rules_path,当前任务无法执行规则审查") + + path = Path(candidate).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + path = path.resolve() + + if not path.is_file(): + raise FileNotFoundError(f"govdoc 规则文件不存在: {path}") + + return str(path) async def Execute( self, DocumentId: int, RunId: int, - RulesPath: str, + RulesPath: str | None = None, TriggerUserId: int | None = None, Speed: str = "normal", ) -> dict[str, Any]: @@ -54,57 +83,216 @@ class GovdocRunner: Returns: 执行摘要 dict。 """ + resolvedRulesPath = self.ResolveRulesPath(RulesPath) log.info(f"[Govdoc] Starting execution: runId={RunId}, documentId={DocumentId}") + artifactTempDir: str | None = None + inputPayload = None + try: + # 1. 更新 run 状态 → processing + await self.Storage.UpdateRunStatus(RunId, "processing", Phase="parsing") + await self.Storage.UpdateDocumentStatus(DocumentId, "processing", RunId) - # 1. 更新 run 状态 → processing - await self.Storage.UpdateRunStatus(RunId, "processing", phase="parsing") - await self.Storage.UpdateDocumentStatus(DocumentId, "processing", RunId) + # 2. 解析输入文件 + inputPayload = await self.InputResolver.ResolveForDocument(DocumentId) + log.info(f"[Govdoc] Input resolved: {inputPayload.fileName} → {inputPayload.localPath}") - # 2. 解析输入文件 - inputPayload = await self.InputResolver.ResolveForDocument(DocumentId) - log.info(f"[Govdoc] Input resolved: {inputPayload.fileName} → {inputPayload.localPath}") + # 3. 调用 govdoc_engine 执行审查 + from fastapi_modules.fastapi_leaudit.govdoc_bridge.result_adapter import ResultAdapter + from fastapi_modules.fastapi_leaudit.govdoc_engine.pipeline import run as engine_run - # 3. 调用 govdoc_engine 执行审查 - from fastapi_modules.fastapi_leaudit.govdoc_engine.pipeline import run as engine_run + if self.ResultAdapter is None: + self.ResultAdapter = ResultAdapter() - engineResult = await engine_run( - file_path=inputPayload.localPath, - rules_path=RulesPath, - llm_client=None, # 使用默认 LlmClient (从平台配置加载) + engineResult = await engine_run( + file_path=inputPayload.localPath, + rules_path=resolvedRulesPath, + llm_client=None, # 使用默认 LlmClient (从平台配置加载) + ) + engineResult.document["filename"] = inputPayload.fileName + + # 4. 适配引擎结果 + structure = self.ResultAdapter.AdaptStructure(engineResult) + outline = self.ResultAdapter.AdaptOutline(engineResult) + entities = self.ResultAdapter.AdaptEntities(engineResult) + runSummary = self.ResultAdapter.AdaptRunSummary( + engineResult, + Structure=structure, + Outline=outline, + Entities=entities, + ) + ruleResults = self.ResultAdapter.AdaptRuleResults(engineResult) + checkedRuleResults = self.ResultAdapter.AdaptCheckedRules(engineResult) + artifactTempDir, artifacts = await self._GenerateArtifacts( + DocumentId=DocumentId, + RunId=RunId, + InputPath=inputPayload.localPath, + InputFileName=inputPayload.fileName, + EngineResult=engineResult, + RuleResults=ruleResults, + ) + + failedRuleIds = {str(row.get("ruleId") or "") for row in ruleResults} + for checkedRule in checkedRuleResults: + ruleId = str(checkedRule.get("ruleId") or "") + if checkedRule.get("result") == "fail" and ruleId in failedRuleIds: + continue + ruleResults.append(checkedRule) + + # 将 rules_path 附带到 runSummary 中,供 GetRuleDetail 后续解析 + runSummary["rulesPath"] = resolvedRulesPath + + # 5. 持久化结果 + await self.Storage.UpdateRunResult(RunId, runSummary) + await self.Storage.SaveRuleResults(RunId, ruleResults) + await self.Storage.SaveArtifacts(RunId, artifacts) + + # 6. 更新终态 + await self.Storage.UpdateRunStatus(RunId, "completed", Phase="reporting") + await self.Storage.UpdateDocumentStatus(DocumentId, "completed", RunId) + + log.info(f"[Govdoc] Execution completed: runId={RunId}") + + return { + "runId": RunId, + "documentId": DocumentId, + "status": "completed", + "ruleResultsCount": len(ruleResults), + "structureCount": len(structure), + "outlineCount": len(outline), + "artifactCount": len(artifacts), + } + finally: + if artifactTempDir: + shutil.rmtree(artifactTempDir, ignore_errors=True) + if inputPayload and inputPayload.tempDir: + shutil.rmtree(inputPayload.tempDir, ignore_errors=True) + + async def _GenerateArtifacts( + self, + DocumentId: int, + RunId: int, + InputPath: str, + InputFileName: str, + EngineResult: Any, + RuleResults: list[dict[str, Any]], + ) -> tuple[str, list[dict[str, Any]]]: + """生成报告产物并上传到 OSS。""" + artifactDir = tempfile.mkdtemp(prefix=f"govdoc_artifacts_{RunId}_") + sourcePath = Path(InputPath) + baseName = OssPathUtils.BuildSafeFileStem(InputFileName) + region = await self._GetDocumentRegion(DocumentId) + + annotatedPath = Path(artifactDir) / f"{baseName}.annotated.docx" + annotate_docx(sourcePath, annotatedPath, EngineResult) + + htmlReport = render_html(EngineResult) + + doc = parse_docx(sourcePath) + findingMap: dict[int, list[str]] = {} + for row in RuleResults: + if row.get("result") != "fail": + continue + paragraphIndex = row.get("paragraphIndex") + if paragraphIndex is None: + continue + findingId = f"{row.get('ruleId') or 'finding'}-{paragraphIndex}" + findingMap.setdefault(int(paragraphIndex), []).append(findingId) + paragraphsHtml = paragraphs_to_html(doc, findingMap) + + annotatedUrl = await self.OssService.UploadBytes( + ObjectKey=OssPathUtils.BuildArtifactKey( + Region=region, + RunId=RunId, + ArtifactType="annotated_docx", + Detail=f"{baseName}.annotated.docx", + ), + Content=annotatedPath.read_bytes(), + ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + htmlUrl = await self.OssService.UploadText( + ObjectKey=OssPathUtils.BuildArtifactKey( + Region=region, + RunId=RunId, + ArtifactType="html_report", + Detail=f"{baseName}.report.html", + ), + Content=htmlReport, + ContentType="text/html; charset=utf-8", + ) + paragraphUrl = await self.OssService.UploadText( + ObjectKey=OssPathUtils.BuildArtifactKey( + Region=region, + RunId=RunId, + ArtifactType="paragraph_html", + Detail=f"{baseName}.paragraphs.html", + ), + Content=paragraphsHtml, + ContentType="text/html; charset=utf-8", ) - # 4. 适配引擎结果 - structure = self.ResultAdapter.AdaptStructure(engineResult) - outline = self.ResultAdapter.AdaptOutline(engineResult) - runSummary = self.ResultAdapter.AdaptRunSummary( - engineResult, - Structure=structure, - Outline=outline, - ) - ruleResults = self.ResultAdapter.AdaptRuleResults(engineResult) - entities = self.ResultAdapter.AdaptEntities(engineResult) - artifacts = self.ResultAdapter.AdaptArtifacts(engineResult, RunId) + return artifactDir, [ + self._BuildArtifactRow( + artifactType="annotated_docx", + fileName=f"{baseName}.annotated.docx", + mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + content=annotatedPath.read_bytes(), + ossUrl=annotatedUrl, + description="批注 DOCX", + ), + self._BuildArtifactRow( + artifactType="html_report", + fileName=f"{baseName}.report.html", + mimeType="text/html; charset=utf-8", + content=htmlReport.encode("utf-8"), + ossUrl=htmlUrl, + description="HTML 审查报告", + ), + self._BuildArtifactRow( + artifactType="paragraph_html", + fileName=f"{baseName}.paragraphs.html", + mimeType="text/html; charset=utf-8", + content=paragraphsHtml.encode("utf-8"), + ossUrl=paragraphUrl, + description="段落联动视图", + ), + ] - # 将 rules_path 附带到 runSummary 中,供 GetRuleDetail 后续解析 - runSummary["rulesPath"] = RulesPath - - # 5. 持久化结果 - await self.Storage.UpdateRunResult(RunId, runSummary) - await self.Storage.SaveRuleResults(RunId, ruleResults) - await self.Storage.SaveArtifacts(RunId, artifacts) - - # 6. 更新终态 - await self.Storage.UpdateRunStatus(RunId, "completed", phase="reporting") - await self.Storage.UpdateDocumentStatus(DocumentId, "completed", RunId) - - log.info(f"[Govdoc] Execution completed: runId={RunId}") + async def _GetDocumentRegion(self, DocumentId: int) -> str: + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT COALESCE(region, 'default') AS region + FROM leaudit_documents + WHERE id = :document_id + LIMIT 1 + """ + ), + {"document_id": DocumentId}, + ) + ).mappings().first() + return str(row["region"] or "default") if row else "default" + def _BuildArtifactRow( + self, + *, + artifactType: str, + fileName: str, + mimeType: str, + content: bytes, + ossUrl: str, + description: str, + ) -> dict[str, Any]: + fileExt = Path(fileName).suffix.lstrip(".").lower() return { - "runId": RunId, - "documentId": DocumentId, - "status": "completed", - "ruleResultsCount": len(ruleResults), - "structureCount": len(structure), - "outlineCount": len(outline), - "artifactCount": len(artifacts), + "artifactType": artifactType, + "fileName": fileName, + "fileExt": fileExt, + "mimeType": mimeType, + "fileSize": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + "ossUrl": ossUrl, + "storageProvider": "minio", + "description": description, } diff --git a/fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py b/fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py index b0d716e..0d52001 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_bridge/storage_adapter.py @@ -6,6 +6,7 @@ govdoc_report_artifacts 表,并更新 leaudit_documents 状态。 from __future__ import annotations +import json from datetime import datetime, timezone from typing import Any @@ -16,6 +17,33 @@ from sqlalchemy import text log = logger +_RUN_RESULT_FALLBACK_KEYS = { + "total_score": ["totalScore", "score"], + "passed_count": ["passedCount", "passed_count"], + "failed_count": ["failedCount", "failed_count"], + "skipped_count": ["skippedCount", "skipped_count"], + "result_status": ["resultStatus", "result_status"], + "result_summary_json": ["resultSummaryJson", "result_summary_json"], +} + + +def _pick_value(payload: dict[str, Any], *keys: str) -> Any: + for key in keys: + if key in payload: + return payload[key] + return None + + +def _to_text_payload(value: Any) -> str | None: + if value is None: + return None + if isinstance(value, str): + return value + if isinstance(value, (dict, list, tuple, bool, int, float)): + return json.dumps(value, ensure_ascii=False) + return str(value) + + class StorageAdapter: """Govdoc 结果持久化适配器。 @@ -51,11 +79,23 @@ class StorageAdapter: log.info(f"[Govdoc] Run created: runId={run_id}, documentId={RunData['documentId']}") return run_id - async def UpdateRunStatus(self, RunId: int, Status: str, Phase: str | None = None, **Extra: Any) -> None: - """更新 run 状态和阶段。""" + async def UpdateRunStatus( + self, + RunId: int, + Status: str, + Phase: str | None = None, + **Extra: Any, + ) -> None: + """更新 run 状态和阶段。 + + 兼容调用方传 ``phase=`` 或 ``Phase=``,避免因大小写不一致导致阶段不落库。 + """ set_clauses = ["status = :status", "updated_at = now()"] params: dict[str, Any] = {"rid": RunId, "status": Status} + if Phase is None: + Phase = _pick_value(Extra, "phase", "Phase") + if Phase is not None: set_clauses.append("phase = :phase") params["phase"] = Phase @@ -63,6 +103,9 @@ class StorageAdapter: if Status == "completed" or Status == "failed": set_clauses.append("finished_at = :finished_at") params["finished_at"] = datetime.now(timezone.utc) + elif Status == "processing": + set_clauses.append("started_at = COALESCE(started_at, :started_at)") + params["started_at"] = datetime.now(timezone.utc) async with GetAsyncSession() as session: await session.execute( @@ -74,7 +117,7 @@ class StorageAdapter: async def UpdateRunResult(self, RunId: int, Summary: dict[str, Any]) -> None: """写入 run 结果汇总字段(含 rules_path / structure / outline)。""" - rules_path = Summary.get("rulesPath") + rules_path = _pick_value(Summary, "rulesPath", "rules_path") set_clauses = [ "total_score = :total_score", "passed_count = :passed_count", @@ -86,12 +129,12 @@ class StorageAdapter: ] params: dict[str, Any] = { "rid": RunId, - "total_score": Summary.get("totalScore"), - "passed_count": Summary.get("passedCount", 0), - "failed_count": Summary.get("failedCount", 0), - "skipped_count": Summary.get("skippedCount", 0), - "result_status": Summary.get("resultStatus"), - "result_summary_json": Summary.get("resultSummaryJson"), + "total_score": _pick_value(Summary, *_RUN_RESULT_FALLBACK_KEYS["total_score"]), + "passed_count": _pick_value(Summary, *_RUN_RESULT_FALLBACK_KEYS["passed_count"]) or 0, + "failed_count": _pick_value(Summary, *_RUN_RESULT_FALLBACK_KEYS["failed_count"]) or 0, + "skipped_count": _pick_value(Summary, *_RUN_RESULT_FALLBACK_KEYS["skipped_count"]) or 0, + "result_status": _pick_value(Summary, *_RUN_RESULT_FALLBACK_KEYS["result_status"]), + "result_summary_json": _pick_value(Summary, *_RUN_RESULT_FALLBACK_KEYS["result_summary_json"]), } if rules_path: set_clauses.append("rules_path = :rules_path") @@ -137,11 +180,11 @@ class StorageAdapter: (run_id, rule_id, rule_name, severity, category, message, suggestion, actual, expected, evidence, paragraph_index, paragraph_text, location_path, - result, score, created_at, updated_at) + result, skip_reason, score, created_at, updated_at) VALUES (:run_id, :rule_id, :rule_name, :severity, :category, :message, :suggestion, :actual, :expected, :evidence, :paragraph_index, :paragraph_text, :location_path, - :result, :score, now(), now())""" + :result, :skip_reason, :score, now(), now())""" ), { "run_id": RunId, @@ -151,13 +194,14 @@ class StorageAdapter: "category": row.get("category"), "message": row.get("message"), "suggestion": row.get("suggestion"), - "actual": row.get("actual"), - "expected": row.get("expected"), - "evidence": row.get("evidence"), + "actual": _to_text_payload(row.get("actual")), + "expected": _to_text_payload(row.get("expected")), + "evidence": _to_text_payload(row.get("evidence")), "paragraph_index": row.get("paragraphIndex"), "paragraph_text": row.get("paragraphText"), "location_path": row.get("locationPath"), "result": row.get("result", "pass"), + "skip_reason": _pick_value(row, "skipReason", "skip_reason"), "score": row.get("score"), }, ) diff --git a/fastapi_modules/fastapi_leaudit/govdoc_bridge/tasks.py b/fastapi_modules/fastapi_leaudit/govdoc_bridge/tasks.py index be4bc37..2f9634c 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_bridge/tasks.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_bridge/tasks.py @@ -11,13 +11,17 @@ from typing import Any from fastapi_common.fastapi_common_logger import logger from fastapi_admin.celery_app import celery_app +from fastapi_admin.config import ( + LEAUDIT_WORKER_QUEUE_NORMAL, + LEAUDIT_WORKER_QUEUE_URGENT, +) from fastapi_modules.fastapi_leaudit.govdoc_bridge.runner import GovdocRunner from fastapi_modules.fastapi_leaudit.govdoc_bridge.storage_adapter import StorageAdapter log = logger -GOVDOC_WORKER_QUEUE = "govdoc" -GOVDOC_WORKER_QUEUE_URGENT = "govdoc_urgent" +GOVDOC_WORKER_QUEUE = LEAUDIT_WORKER_QUEUE_NORMAL +GOVDOC_WORKER_QUEUE_URGENT = LEAUDIT_WORKER_QUEUE_URGENT def resolve_govdoc_queue(speed: str = "normal") -> str: @@ -30,6 +34,7 @@ def resolve_govdoc_queue(speed: str = "normal") -> str: def dispatch_govdoc_task( documentId: int, runId: int, + rulesPath: str | None = None, triggerUserId: int | None = None, speed: str = "normal", ) -> Any: @@ -52,6 +57,7 @@ def dispatch_govdoc_task( kwargs={ "documentId": documentId, "runId": runId, + "rulesPath": rulesPath, "triggerUserId": triggerUserId, "speed": speed, }, @@ -73,6 +79,7 @@ def govdoc_execute_task( self, documentId: int, runId: int, + rulesPath: str | None = None, triggerUserId: int | None = None, speed: str = "normal", ) -> dict[str, Any]: @@ -89,7 +96,7 @@ def govdoc_execute_task( try: # 更新 run 状态 → running - loop.run_until_complete(storage.UpdateRunStatus(runId, "processing", phase="parsing")) + loop.run_until_complete(storage.UpdateRunStatus(runId, "processing", Phase="parsing")) # 执行完整审查链路 runner = GovdocRunner() @@ -97,6 +104,7 @@ def govdoc_execute_task( runner.Execute( DocumentId=documentId, RunId=runId, + RulesPath=rulesPath, TriggerUserId=triggerUserId, Speed=speed, ) diff --git a/fastapi_modules/fastapi_leaudit/govdoc_engine/__init__.py b/fastapi_modules/fastapi_leaudit/govdoc_engine/__init__.py index 287cc8d..854e2a9 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_engine/__init__.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_engine/__init__.py @@ -1,30 +1,12 @@ """Govdoc 公文格式审查引擎内核。 -从旧 govdoc-audit 项目裁剪迁入,去除独立 API 层、SQLite 存储层、 -本地运行记录器 (RunRecorder) 和旧配置系统。 - -导出: - - pipeline.run() — 异步审查入口 (bridge 层主调用) - - pipeline.audit_file() — 同步审查入口 (兼容) - - models — 核心数据模型 (Pydantic) - - parser — 文档解析与实体抽取 - - dsl — YAML 规则 DSL 定义与加载 - - engine — 规则执行引擎与结果模型 - - reporter — 报告生成 (HTML/DOCX/JSON) - - llm — LLM 客户端 (OpenAI 兼容协议) +保持包级导入轻量,避免在控制器注册阶段提前拉起 LLM/OpenAI 依赖。 +真正执行审查时再按需导入 pipeline / result 模块。 """ from __future__ import annotations -from fastapi_modules.fastapi_leaudit.govdoc_engine.pipeline import ( - audit_file, - run, -) -from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import ( - AuditResult, - AuditSummary, - CheckedRule, -) +from typing import Any __all__ = [ "audit_file", @@ -33,3 +15,31 @@ __all__ = [ "AuditSummary", "CheckedRule", ] + + +def audit_file(*args: Any, **kwargs: Any): + from fastapi_modules.fastapi_leaudit.govdoc_engine.pipeline import audit_file as _audit_file + + return _audit_file(*args, **kwargs) + + +async def run(*args: Any, **kwargs: Any): + from fastapi_modules.fastapi_leaudit.govdoc_engine.pipeline import run as _run + + return await _run(*args, **kwargs) + + +def __getattr__(name: str): + if name in {"AuditResult", "AuditSummary", "CheckedRule"}: + from fastapi_modules.fastapi_leaudit.govdoc_engine.engine.result import ( + AuditResult, + AuditSummary, + CheckedRule, + ) + + return { + "AuditResult": AuditResult, + "AuditSummary": AuditSummary, + "CheckedRule": CheckedRule, + }[name] + raise AttributeError(name) diff --git a/fastapi_modules/fastapi_leaudit/govdoc_engine/llm/client.py b/fastapi_modules/fastapi_leaudit/govdoc_engine/llm/client.py index 0dd379d..6044a00 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_engine/llm/client.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_engine/llm/client.py @@ -11,7 +11,22 @@ import re import time from typing import Any -from openai import AsyncOpenAI, OpenAI, APIError, APIConnectionError, RateLimitError +try: + from openai import AsyncOpenAI, OpenAI, APIError, APIConnectionError, RateLimitError + _OPENAI_IMPORT_ERROR: Exception | None = None +except ModuleNotFoundError as exc: # pragma: no cover - optional dependency + AsyncOpenAI = None # type: ignore[assignment] + OpenAI = None # type: ignore[assignment] + _OPENAI_IMPORT_ERROR = exc + + class APIError(Exception): + status_code: int | None = None + + class APIConnectionError(Exception): + pass + + class RateLimitError(Exception): + pass from fastapi_admin.config import ( LLM_API_KEY, @@ -125,7 +140,13 @@ class LlmClient: ): key = api_key or LLM_API_KEY self._misconfigured_error: LlmConfigError | None = None - if not key: + if OpenAI is None or AsyncOpenAI is None: + self._client = None + self._aclient = None + self._misconfigured_error = LlmConfigError( + "python package 'openai' is not installed; govdoc LLM features are unavailable." + ) + elif not key: self._client = None self._aclient = None self._misconfigured_error = LlmConfigError( diff --git a/fastapi_modules/fastapi_leaudit/govdoc_engine/pipeline.py b/fastapi_modules/fastapi_leaudit/govdoc_engine/pipeline.py index 12af0c1..3c492f5 100644 --- a/fastapi_modules/fastapi_leaudit/govdoc_engine/pipeline.py +++ b/fastapi_modules/fastapi_leaudit/govdoc_engine/pipeline.py @@ -130,12 +130,12 @@ def _merge_llm_into_entities( # ── 实体构建 (同步,供 sync 入口使用) ────────────────── def _build_entities( - doc, ruleset: RuleSet, llm: LlmClient, + doc, ruleset: RuleSet, llm: LlmClient | None, ) -> dict[str, SemanticEntity | None]: """构建实体 + 差量 LLM 抽取(同步)。""" entities = EntityBuilder().build(doc) spec = _compute_missing_spec(entities, ruleset.extract.entities) - if spec: + if spec and llm is not None: llm_vals = FieldExtractor(llm).extract_missing(doc, spec) _merge_llm_into_entities(entities, llm_vals) return entities @@ -144,12 +144,12 @@ def _build_entities( # ── 实体构建 (异步,供 async 入口使用) ────────────────── async def _build_entities_async( - doc, ruleset: RuleSet, llm: LlmClient, + doc, ruleset: RuleSet, llm: LlmClient | None, ) -> dict[str, SemanticEntity | None]: """构建实体 + 差量 LLM 抽取(异步)。""" entities = EntityBuilder().build(doc) spec = _compute_missing_spec(entities, ruleset.extract.entities) - if spec: + if spec and llm is not None: llm_vals = await FieldExtractor(llm).extract_missing_async(doc, spec) _merge_llm_into_entities(entities, llm_vals) return entities @@ -174,7 +174,7 @@ def audit_file( """ docx_path = Path(docx_path) rules_path = Path(rules_path) - llm = llm_client or LlmClient() + llm = llm_client doc = parse_docx(docx_path) RoleTagger(llm_client=llm).tag(doc) @@ -210,7 +210,7 @@ async def run( """ file_path = Path(file_path) rules_path = Path(rules_path) - llm = llm_client or LlmClient() + llm = llm_client _log.info("Govdoc pipeline start: %s", file_path.name) @@ -219,18 +219,21 @@ async def run( _log.info(" parsed: %d paragraphs", len(doc.paragraphs)) # 2. 段落角色标注 - RoleTagger(llm_client=llm).tag(doc) + if llm is not None: + await RoleTagger(llm_client=llm).tag_async(doc) + else: + RoleTagger(llm_client=None).tag(doc) # 3. 加载规则 ruleset = load_rules(rules_path) - _log.info(" rules: %d groups, %d rules", len(ruleset.groups), len(ruleset.all_rules())) + _log.info(" rules: %d groups, %d rules", len(ruleset.rules), len(ruleset.all_rules())) # 4. 实体抽取 (含差量 LLM) entities = await _build_entities_async(doc, ruleset, llm) _log.info(" entities: %d/%d resolved", sum(1 for v in entities.values() if v), len(entities)) # 5. 规则评估 - findings, outcomes = RuleRunner(llm_client=llm).evaluate( + findings, outcomes = await RuleRunner(llm_client=llm).evaluate_async( ruleset.all_rules(), doc, entities ) _log.info(" evaluated: %d findings from %d rules", len(findings), len(outcomes)) diff --git a/fastapi_modules/fastapi_leaudit/models/govdocRuleResult.py b/fastapi_modules/fastapi_leaudit/models/govdocRuleResult.py index 1468df2..1abc6c8 100644 --- a/fastapi_modules/fastapi_leaudit/models/govdocRuleResult.py +++ b/fastapi_modules/fastapi_leaudit/models/govdocRuleResult.py @@ -36,4 +36,5 @@ class GovdocRuleResult(BaseModel): # 判定 result: Mapped[str] = mapped_column("result", String(32), default="pass", comment="执行结果:pass/fail/skipped/error") + skipReason: Mapped[str | None] = mapped_column("skip_reason", Text, comment="跳过原因,仅 skipped/error 时使用") score: Mapped[float | None] = mapped_column("score", Numeric(10, 2), comment="本条得分") diff --git a/fastapi_modules/fastapi_leaudit/models/govdocRun.py b/fastapi_modules/fastapi_leaudit/models/govdocRun.py index 59ca654..a1f71a2 100644 --- a/fastapi_modules/fastapi_leaudit/models/govdocRun.py +++ b/fastapi_modules/fastapi_leaudit/models/govdocRun.py @@ -31,6 +31,7 @@ class GovdocRun(BaseModel): engineVersion: Mapped[str | None] = mapped_column("engine_version", String(64), comment="引擎版本号") llmProvider: Mapped[str | None] = mapped_column("llm_provider", String(64), comment="LLM 提供商") llmModel: Mapped[str | None] = mapped_column("llm_model", String(128), comment="LLM 模型名") + rulesPath: Mapped[str | None] = mapped_column("rules_path", String(1024), comment="本次运行使用的规则文件路径") # 结果汇总 totalScore: Mapped[float | None] = mapped_column("total_score", Numeric(10, 2), comment="总分") diff --git a/fastapi_modules/fastapi_leaudit/services/govdocService.py b/fastapi_modules/fastapi_leaudit/services/govdocService.py index cca7f6d..6084de1 100644 --- a/fastapi_modules/fastapi_leaudit/services/govdocService.py +++ b/fastapi_modules/fastapi_leaudit/services/govdocService.py @@ -31,6 +31,7 @@ class IGovdocService(ABC): page: int = 1, pageSize: int = 20, keyword: str | None = None, + fileExt: str | None = None, region: str | None = None, status: str | None = None, resultStatus: str | None = None, diff --git a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py index faf4ee4..9be7f12 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py @@ -163,6 +163,7 @@ class DocumentServiceImpl(IDocumentService): root_group_id=resolvedRootGroupId, region=normalizedRegion, normalized_name=normalizedName, + file_ext=fileExt, ) internalDocumentNo = time.time_ns() @@ -2712,16 +2713,25 @@ async def _find_latest_version_candidate( root_group_id: int | None, region: str, normalized_name: str, + file_ext: str | None = None, ) -> dict | None: - """Find the latest primary document version candidate by normalized name. + """Find the latest primary document version candidate by normalized name + extension.""" + ext_clause = "" + ext_params: dict[str, object] = {} + if file_ext: + ext_clause = " AND f.file_ext = :file_ext" + ext_params["file_ext"] = file_ext - Preferred rule: same region + same root group + same normalized name. - Fallback rule: when a root group cannot be resolved, keep the old same-type behavior. - """ if root_group_id is not None: + params: dict[str, object] = { + "root_group_id": root_group_id, + "region": region, + "normalized_name": normalized_name, + **ext_params, + } result = await session.execute( text( - """ + f""" SELECT d.id AS document_id, d.version_group_key, @@ -2751,7 +2761,7 @@ async def _find_latest_version_candidate( WHERE d.region = :region AND d.normalized_name = :normalized_name AND d.is_latest_version = true - AND d.deleted_at IS NULL + AND d.deleted_at IS NULL{ext_clause} AND COALESCE( CASE WHEN eg.id IS NULL THEN NULL @@ -2764,19 +2774,21 @@ async def _find_latest_version_candidate( LIMIT 1 """ ), - { - "root_group_id": root_group_id, - "region": region, - "normalized_name": normalized_name, - }, + params, ) row = result.mappings().first() if row: return dict(row) + params = { + "type_id": type_id, + "region": region, + "normalized_name": normalized_name, + **ext_params, + } result = await session.execute( text( - """ + f""" SELECT d.id AS document_id, d.version_group_key, @@ -2791,18 +2803,14 @@ async def _find_latest_version_candidate( AND f.file_role = 'primary' WHERE d.type_id = :type_id AND d.region = :region - AND d.normalized_name = :normalized_name + AND d.normalized_name = :normalized_name{ext_clause} AND d.is_latest_version = true AND d.deleted_at IS NULL ORDER BY d.version_no DESC, d.id DESC LIMIT 1 """ ), - { - "type_id": type_id, - "region": region, - "normalized_name": normalized_name, - }, + params, ) row = result.mappings().first() return dict(row) if row else None diff --git a/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py index 065479f..2e658e4 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/govdocServiceImpl.py @@ -1,26 +1,65 @@ -"""Govdoc 公文模块服务实现(阶段骨架)。 - -本文件为 Phase 1 骨架实现,所有方法暂返回占位结果。 -后续步骤将逐步接入: - - govdoc_bridge 执行桥接 - - govdoc_engine 引擎内核 - - 文档主档复用 - - OSS / Celery 集成 -""" +"""Govdoc 公文模块服务实现。""" from __future__ import annotations +import hashlib +import json +import mimetypes +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path from typing import Any from fastapi import UploadFile +from sqlalchemy import text from fastapi_common.fastapi_common_logger import logger -from fastapi_modules.fastapi_leaudit.services import IGovdocService +from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession +from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils +from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum +from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException + +from fastapi_modules.fastapi_leaudit.govdoc_bridge.storage_adapter import StorageAdapter +from fastapi_modules.fastapi_leaudit.govdoc_bridge.tasks import dispatch_govdoc_task +from fastapi_modules.fastapi_leaudit.govdoc_engine.parser.docx_parser import parse_docx +from fastapi_modules.fastapi_leaudit.govdoc_engine.reporter.html_paragraph import paragraphs_to_html +from fastapi_modules.fastapi_leaudit.models import LeauditDocument, LeauditDocumentFile +from fastapi_modules.fastapi_leaudit.services import IGovdocService, IOssService +from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl + + +@dataclass(frozen=True) +class _GovdocDocumentRow: + documentId: int + region: str + processingStatus: str + currentRunId: int | None + createdAt: Any + updatedAt: Any + fileId: int + fileName: str + fileExt: str | None + mimeType: str | None + fileSize: int | None + ossUrl: str | None + createdBy: int | None + resultStatus: str | None + totalScore: float | None + passedCount: int | None + failedCount: int | None + skippedCount: int | None + hasHtmlReport: bool + hasDocxReport: bool class GovdocServiceImpl(IGovdocService): """公文处理与格式审查服务实现。""" + def __init__(self, OssService: IOssService | None = None) -> None: + self.OssService = OssService or OssServiceImpl() + self.Storage = StorageAdapter() + # ── 文档 ────────────────────────────────────────────── async def UploadDocument( @@ -28,19 +67,126 @@ class GovdocServiceImpl(IGovdocService): file: UploadFile, typeId: int | None = None, region: str = "default", - autoRun: bool = False, + autoRun: bool = True, speed: str = "normal", ruleVersionId: int | None = None, createdBy: int | None = None, ) -> dict[str, Any]: - logger.info("[Govdoc] UploadDocument placeholder — file=%s region=%s", file.filename, region) + if createdBy is None: + raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录") + if file is None or not file.filename: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "上传文件不能为空") + + content = await file.read() + if not content: + raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "上传文件内容不能为空") + + normalizedRegion = (region or "default").strip() or "default" + fileName = file.filename + fileExt = Path(fileName).suffix.lstrip(".").lower() or None + if fileExt != "docx": + raise LeauditException( + StatusCodeEnum.HTTP_400_BAD_REQUEST, + "当前内部公文模块仅支持上传 DOCX 文件", + ) + mimeType = file.content_type or mimetypes.guess_type(fileName)[0] or "application/octet-stream" + fileSha256 = hashlib.sha256(content).hexdigest() + uploadedAt = datetime.now() + normalizedName = self._normalize_document_name(fileName) + + async with GetAsyncSession() as session: + await self._ensureGovdocSchema(session) + currentUser = await self._getCurrentUserContext(createdBy) + resolvedRegion = self._resolve_upload_region(currentUser, normalizedRegion) + + document = await LeauditDocument.create_new( + session, + bizDocumentId=time.time_ns(), + typeId=typeId, + groupId=None, + region=resolvedRegion, + processingStatus="waiting", + currentRunId=None, + versionGroupKey=None, + versionNo=1, + previousVersionId=None, + rootVersionId=None, + isLatestVersion=True, + normalizedName=normalizedName, + reviewScope="govdoc", + ) + document.rootVersionId = document.Id + await session.flush() + + objectKey = OssPathUtils.BuildBusinessDocKey( + Region=resolvedRegion, + TypeCode="govdoc", + DocumentId=document.Id, + Version="v1", + FileRole="original", + FileName=fileName, + Year=uploadedAt.year, + Month=uploadedAt.month, + ) + ossUrl = await self.OssService.UploadBytes( + ObjectKey=objectKey, + Content=content, + ContentType=mimeType, + ) + + documentFile = LeauditDocumentFile( + documentId=document.Id, + fileRole="original", + fileName=fileName, + fileExt=fileExt, + mimeType=mimeType, + fileSize=len(content), + sha256=fileSha256, + localPath=None, + ossUrl=ossUrl, + storageProvider="minio", + isActive=True, + createdBy=createdBy, + ) + session.add(documentFile) + await session.flush() + + await session.execute( + text( + """ + UPDATE leaudit_documents + SET engine_type = 'govdoc' + WHERE id = :document_id + """ + ), + {"document_id": document.Id}, + ) + await session.commit() + + await session.refresh(document) + await session.refresh(documentFile) + + runPayload: dict[str, Any] | None = None + shouldAutoRun = bool(autoRun) + if shouldAutoRun: + runPayload = await self.CreateRun( + documentId=document.Id, + ruleVersionId=ruleVersionId, + speed=speed, + force=False, + triggerUserId=createdBy, + ) + return { - "documentId": 0, - "fileId": 0, - "fileName": file.filename, - "region": region, + "documentId": document.Id, + "fileId": documentFile.Id, + "fileName": documentFile.fileName, + "region": resolvedRegion, "engineType": "govdoc", - "autoRunTriggered": autoRun, + "processingStatus": "processing" if runPayload else (document.processingStatus or "waiting"), + "autoRunTriggered": shouldAutoRun, + "latestRunId": runPayload["runId"] if runPayload else None, + "run": runPayload, } async def ListDocuments( @@ -48,6 +194,7 @@ class GovdocServiceImpl(IGovdocService): page: int = 1, pageSize: int = 20, keyword: str | None = None, + fileExt: str | None = None, region: str | None = None, status: str | None = None, resultStatus: str | None = None, @@ -56,19 +203,374 @@ class GovdocServiceImpl(IGovdocService): dateTo: str | None = None, userId: int | None = None, ) -> dict[str, Any]: - logger.info("[Govdoc] ListDocuments placeholder — page=%s pageSize=%s", page, pageSize) - return {"items": [], "total": 0, "page": page, "pageSize": pageSize} + if userId is None: + raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录") + + currentUser = await self._getCurrentUserContext(userId) + page = max(1, int(page)) + pageSize = max(1, min(int(pageSize), 100)) + offset = (page - 1) * pageSize + + async with GetAsyncSession() as session: + await self._ensureGovdocSchema(session) + + params: dict[str, Any] = { + "limit": pageSize, + "offset": offset, + } + filters = [ + "d.deleted_at IS NULL", + "f.deleted_at IS NULL", + "f.is_active = true", + "f.file_role = 'original'", + "COALESCE(d.engine_type, 'leaudit') = 'govdoc'", + ] + filters.extend( + self._buildDocumentScopeFilters( + CurrentUserId=userId, + CurrentUser=currentUser, + Params=params, + DocumentAlias="d", + FileAlias="f", + RequestedRegion=region, + RequestedUserId=createdBy, + ) + ) + if keyword: + filters.append("(f.file_name ILIKE :keyword OR COALESCE(d.normalized_name, '') ILIKE :keyword)") + params["keyword"] = f"%{keyword.strip()}%" + if fileExt: + normalizedExt = fileExt.strip().lstrip(".").lower() + if normalizedExt: + filters.append("LOWER(COALESCE(f.file_ext, '')) = :file_ext") + params["file_ext"] = normalizedExt + if status: + filters.append("COALESCE(d.processing_status, '') = :status") + params["status"] = status.strip() + if resultStatus: + filters.append("COALESCE(gr.result_status, '') = :result_status") + params["result_status"] = resultStatus.strip() + if dateFrom: + filters.append("d.created_at >= CAST(:date_from AS date)") + params["date_from"] = dateFrom.strip() + if dateTo: + filters.append("d.created_at < (CAST(:date_to AS date) + INTERVAL '1 day')") + params["date_to"] = dateTo.strip() + + whereClause = " AND ".join(filters) + + async with GetAsyncSession() as session: + rows = ( + await session.execute( + text( + f""" + SELECT + d.id AS document_id, + COALESCE(d.region, 'default') AS region, + COALESCE(d.processing_status, 'waiting') AS processing_status, + d.current_run_id, + d.created_at, + d.updated_at, + f.id AS file_id, + f.file_name, + f.file_ext, + f.mime_type, + f.file_size, + f.oss_url, + f.created_by, + gr.result_status, + gr.total_score, + gr.passed_count, + gr.failed_count, + gr.skipped_count, + EXISTS( + SELECT 1 + FROM govdoc_report_artifacts gra + WHERE gra.run_id = d.current_run_id + AND gra.artifact_type = 'html_report' + AND gra.deleted_at IS NULL + ) AS has_html_report, + EXISTS( + SELECT 1 + FROM govdoc_report_artifacts gra + WHERE gra.run_id = d.current_run_id + AND gra.artifact_type = 'annotated_docx' + AND gra.deleted_at IS NULL + ) AS has_docx_report + FROM leaudit_documents d + JOIN leaudit_document_files f + ON f.document_id = d.id + AND f.is_active = true + AND f.file_role = 'original' + AND f.deleted_at IS NULL + LEFT JOIN govdoc_runs gr + ON gr.id = d.current_run_id + WHERE {whereClause} + ORDER BY d.created_at DESC + LIMIT :limit OFFSET :offset + """ + ), + params, + ) + ).mappings().all() + + total = int( + ( + await session.execute( + text( + f""" + SELECT COUNT(1) + FROM leaudit_documents d + JOIN leaudit_document_files f + ON f.document_id = d.id + AND f.is_active = true + AND f.file_role = 'original' + AND f.deleted_at IS NULL + LEFT JOIN govdoc_runs gr + ON gr.id = d.current_run_id + WHERE {whereClause} + """ + ), + params, + ) + ).scalar_one() + ) + + items = [] + for row in rows: + mapped = self._map_document_row(row) + summary = self._build_summary_payload( + mapped.totalScore, + mapped.passedCount, + mapped.failedCount, + mapped.skippedCount, + ) + items.append( + { + "documentId": mapped.documentId, + "fileId": mapped.fileId, + "fileName": mapped.fileName, + "fileExt": mapped.fileExt, + "mimeType": mapped.mimeType, + "fileSize": mapped.fileSize, + "region": mapped.region, + "processingStatus": mapped.processingStatus, + "currentRunId": mapped.currentRunId, + "latestRunId": mapped.currentRunId, + "resultStatus": mapped.resultStatus, + "score": float(mapped.totalScore) if mapped.totalScore is not None else None, + "passedCount": mapped.passedCount or 0, + "failedCount": mapped.failedCount or 0, + "skippedCount": mapped.skippedCount or 0, + "latestRun": { + "runId": mapped.currentRunId, + "summary": summary, + } if mapped.currentRunId else None, + "reports": { + "hasHtmlReport": mapped.hasHtmlReport, + "hasDocxReport": mapped.hasDocxReport, + }, + "createdAt": self._iso(mapped.createdAt), + "updatedAt": self._iso(mapped.updatedAt), + } + ) + + return {"items": items, "total": total, "page": page, "pageSize": pageSize} async def GetDocumentDetail(self, documentId: int, userId: int | None = None) -> dict[str, Any]: - logger.info("[Govdoc] GetDocumentDetail placeholder — id=%s", documentId) - return {"documentId": documentId} + if userId is None: + raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录") + + currentUser = await self._getCurrentUserContext(userId) + params: dict[str, Any] = {"document_id": documentId, "limit": 20} + filters = [ + "d.id = :document_id", + "d.deleted_at IS NULL", + "f.deleted_at IS NULL", + "f.is_active = true", + "f.file_role = 'original'", + "COALESCE(d.engine_type, 'leaudit') = 'govdoc'", + ] + filters.extend( + self._buildDocumentScopeFilters( + CurrentUserId=userId, + CurrentUser=currentUser, + Params=params, + DocumentAlias="d", + FileAlias="f", + ) + ) + whereClause = " AND ".join(filters) + + async with GetAsyncSession() as session: + await self._ensureGovdocSchema(session) + row = ( + await session.execute( + text( + f""" + SELECT + d.id AS document_id, + COALESCE(d.region, 'default') AS region, + COALESCE(d.processing_status, 'waiting') AS processing_status, + d.current_run_id, + d.created_at, + d.updated_at, + f.id AS file_id, + f.file_name, + f.file_ext, + f.mime_type, + f.file_size, + f.oss_url, + f.created_by, + gr.result_status, + gr.total_score, + gr.passed_count, + gr.failed_count, + gr.skipped_count + FROM leaudit_documents d + JOIN leaudit_document_files f + ON f.document_id = d.id + AND f.is_active = true + AND f.file_role = 'original' + AND f.deleted_at IS NULL + LEFT JOIN govdoc_runs gr + ON gr.id = d.current_run_id + WHERE {whereClause} + LIMIT 1 + """ + ), + params, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "公文文档不存在或无权访问") + + runs = ( + await session.execute( + text( + """ + SELECT + id, + document_id, + status, + phase, + result_status, + total_score, + passed_count, + failed_count, + skipped_count, + error_message, + created_at, + updated_at, + started_at, + finished_at + FROM govdoc_runs + WHERE document_id = :document_id + AND deleted_at IS NULL + ORDER BY id DESC + LIMIT :limit + """ + ), + params, + ) + ).mappings().all() + + artifactRows = ( + await session.execute( + text( + """ + SELECT run_id, artifact_type, file_name, file_ext, mime_type, oss_url, description + FROM govdoc_report_artifacts + WHERE run_id = ANY( + SELECT id + FROM govdoc_runs + WHERE document_id = :document_id + AND deleted_at IS NULL + ) + AND deleted_at IS NULL + ORDER BY id DESC + """ + ), + {"document_id": documentId}, + ) + ).mappings().all() + + mapped = self._map_document_row(row) + runItems = [self._build_run_summary(item) for item in runs] + latestRunId = mapped.currentRunId or (runItems[0]["runId"] if runItems else None) + latestRun = next((item for item in runItems if item["runId"] == latestRunId), runItems[0] if runItems else None) + artifactsByRun = self._group_artifacts_by_run(artifactRows) + + return { + "documentId": mapped.documentId, + "latestRunId": latestRunId, + "document": { + "documentId": mapped.documentId, + "fileId": mapped.fileId, + "filename": mapped.fileName, + "fileExt": mapped.fileExt, + "mimeType": mapped.mimeType, + "fileSize": mapped.fileSize, + "region": mapped.region, + "processingStatus": mapped.processingStatus, + "createdAt": self._iso(mapped.createdAt), + "updatedAt": self._iso(mapped.updatedAt), + }, + "latestRun": latestRun, + "currentRun": latestRun, + "runs": runItems, + "reports": artifactsByRun.get(int(latestRunId), {}) if latestRunId else {}, + } async def UpdateDocument(self, documentId: int, body: dict[str, Any], userId: int | None = None) -> dict[str, Any]: - logger.info("[Govdoc] UpdateDocument placeholder — id=%s", documentId) - return {"documentId": documentId, **body} + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前阶段暂不开放修改公文信息") async def DeleteDocument(self, documentId: int, userId: int | None = None) -> dict[str, Any]: - logger.info("[Govdoc] DeleteDocument placeholder — id=%s", documentId) + if userId is None: + raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录") + + currentUser = await self._getCurrentUserContext(userId) + params: dict[str, Any] = {"document_id": documentId} + filters = ["d.id = :document_id", "d.deleted_at IS NULL", "f.is_active = true", "f.file_role = 'original'"] + filters.extend( + self._buildDocumentScopeFilters( + CurrentUserId=userId, + CurrentUser=currentUser, + Params=params, + DocumentAlias="d", + FileAlias="f", + ) + ) + whereClause = " AND ".join(filters) + async with GetAsyncSession() as session: + await self._ensureGovdocSchema(session) + row = ( + await session.execute( + text( + f""" + SELECT d.id + FROM leaudit_documents d + JOIN leaudit_document_files f + ON f.document_id = d.id + AND f.is_active = true + AND f.file_role = 'original' + AND f.deleted_at IS NULL + WHERE {whereClause} + LIMIT 1 + """ + ), + params, + ) + ).first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "公文文档不存在或无权访问") + + await session.execute( + text("UPDATE leaudit_documents SET deleted_at = now(), updated_at = now() WHERE id = :document_id"), + {"document_id": documentId}, + ) + await session.commit() + return {"documentId": documentId, "deleted": True} # ── 审查运行 ────────────────────────────────────────── @@ -81,157 +583,354 @@ class GovdocServiceImpl(IGovdocService): force: bool = False, triggerUserId: int | None = None, ) -> dict[str, Any]: - logger.info("[Govdoc] CreateRun placeholder — documentId=%s", documentId) + if triggerUserId is None: + raise LeauditException(StatusCodeEnum.HTTP_401_UNAUTHORIZED, "当前用户未登录") + + async with GetAsyncSession() as session: + await self._ensureGovdocSchema(session) + + currentUser = await self._getCurrentUserContext(triggerUserId) + documentMeta = await self._get_document_for_run(documentId, triggerUserId, currentUser) + if documentMeta.currentRunId and not force: + currentRun = await self.GetRunStatus(documentMeta.currentRunId) + if currentRun["status"] in {"pending", "processing"}: + return { + "runId": documentMeta.currentRunId, + "documentId": documentId, + "status": currentRun["status"], + "phase": currentRun.get("phase"), + "reused": True, + } + + runId = await self.Storage.CreateRun( + { + "documentId": documentId, + "documentFileId": documentMeta.fileId, + "runNo": await self._next_run_no(documentId), + "triggerSource": "upload" if not documentMeta.currentRunId else "manual", + "triggerUserId": triggerUserId, + } + ) + rulesPath = await self._resolve_rules_path() + await self.Storage.UpdateDocumentStatus(documentId, "processing", runId) + task = dispatch_govdoc_task( + documentId=documentId, + runId=runId, + rulesPath=rulesPath, + triggerUserId=triggerUserId, + speed=speed, + ) + + async with GetAsyncSession() as session: + await session.execute( + text("UPDATE govdoc_runs SET task_id = :task_id, started_at = now(), updated_at = now() WHERE id = :run_id"), + {"task_id": str(getattr(task, "id", "") or ""), "run_id": runId}, + ) + await session.commit() + return { - "runId": 0, + "runId": runId, "documentId": documentId, - "status": "queued", + "status": "pending", "phase": "dispatch", + "taskId": str(getattr(task, "id", "") or ""), } async def GetRunStatus(self, runId: int) -> dict[str, Any]: - logger.info("[Govdoc] GetRunStatus placeholder — runId=%s", runId) - return {"runId": runId, "status": "pending"} + async with GetAsyncSession() as session: + await self._ensureGovdocSchema(session) + row = ( + await session.execute( + text( + """ + SELECT + id, + document_id, + status, + phase, + result_status, + total_score, + passed_count, + failed_count, + skipped_count, + error_message, + task_id, + created_at, + updated_at, + started_at, + finished_at + FROM govdoc_runs + WHERE id = :run_id + AND deleted_at IS NULL + LIMIT 1 + """ + ), + {"run_id": runId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "审查运行不存在") + return self._build_run_summary(row) # ── 结果与报告 ──────────────────────────────────────── async def GetRunResult(self, runId: int) -> dict[str, Any]: """从 govdoc_runs + govdoc_rule_results 读取审查结果,含 structure/outline。""" - import json as _json - from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession - from sqlalchemy import text - async with GetAsyncSession() as session: - run_row = await session.execute( - text( - """SELECT id, document_id, status, phase, total_score, passed_count, - failed_count, skipped_count, result_status, result_summary_json, - rules_path, started_at, finished_at - FROM govdoc_runs WHERE id = :rid""" - ), - {"rid": runId}, + await self._ensureGovdocSchema(session) + runRow = ( + await session.execute( + text( + """ + SELECT + id, + document_id, + status, + phase, + total_score, + passed_count, + failed_count, + skipped_count, + result_status, + result_summary_json, + started_at, + finished_at + FROM govdoc_runs + WHERE id = :run_id + AND deleted_at IS NULL + LIMIT 1 + """ + ), + {"run_id": runId}, + ) + ).mappings().first() + if not runRow: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "审查运行不存在") + + documentRow = ( + await session.execute( + text( + """ + SELECT + d.id AS document_id, + f.file_name + FROM leaudit_documents d + JOIN leaudit_document_files f + ON f.document_id = d.id + AND f.is_active = true + AND f.file_role = 'original' + AND f.deleted_at IS NULL + WHERE d.id = :document_id + AND d.deleted_at IS NULL + LIMIT 1 + """ + ), + {"document_id": int(runRow["document_id"])}, + ) + ).mappings().first() + + rulesRows = ( + await session.execute( + text( + """ + SELECT + rule_id, + rule_name, + severity, + category, + result, + skip_reason, + message, + suggestion, + actual, + expected, + evidence, + paragraph_index, + paragraph_text, + location_path, + score + FROM govdoc_rule_results + WHERE run_id = :run_id + AND deleted_at IS NULL + ORDER BY id ASC + """ + ), + {"run_id": runId}, + ) + ).mappings().all() + + aux = self._parse_json(runRow.get("result_summary_json")) + findings = [] + checkedRules = [] + severityStats: dict[str, int] = {} + categoryStats: dict[str, int] = {} + seenRuleIds: set[str] = set() + for rr in rulesRows: + location = { + "paragraph_index": rr["paragraph_index"] or 0, + "role": rr.get("location_path"), + "char_start": 0, + "char_end": 0, + "context": rr.get("paragraph_text") or "", + } + if rr.get("result") == "fail": + severity = str(rr.get("severity") or "info") + category = str(rr.get("category") or "") + severityStats[severity] = severityStats.get(severity, 0) + 1 + if category: + categoryStats[category] = categoryStats.get(category, 0) + 1 + findings.append( + { + "finding_id": f"{rr['rule_id']}-{rr.get('paragraph_index') or len(findings)}", + "rule_id": rr["rule_id"], + "rule_name": rr.get("rule_name") or rr["rule_id"], + "severity": severity, + "category": category, + "location": location, + "actual": self._parse_json(rr.get("actual")) or {}, + "expected": self._parse_json(rr.get("expected")) or {}, + "message": rr.get("message") or "", + "suggestion": rr.get("suggestion") or "", + "evidence": rr.get("evidence") or "", + "confidence": 1.0, + } + ) + ruleId = str(rr["rule_id"]) + if ruleId in seenRuleIds: + continue + seenRuleIds.add(ruleId) + status = str(rr.get("result") or "pass") + checkedRules.append( + { + "rule_id": ruleId, + "name": rr.get("rule_name") or ruleId, + "severity": rr.get("severity") or "info", + "category": rr.get("category") or "", + "status": status if status in {"pass", "fail", "skipped"} else "pass", + "skip_reason": rr.get("skip_reason") or "", + } ) - run_data = run_row.mappings().first() - if not run_data: - return {"runId": runId, "summary": {}, "checkedRules": [], "findings": [], - "structure": [], "outline": [], "entities": {}} - - rules_rows = await session.execute( - text( - """SELECT rule_id, rule_name, severity, category, result, skip_reason, - message, suggestion, actual, expected, evidence, - paragraph_index, paragraph_text, location_path, score - FROM govdoc_rule_results WHERE run_id = :rid""" - ), - {"rid": runId}, - ) - rule_results = [dict(r._mapping) for r in rules_rows.fetchall()] - - aux_raw = run_data.get("result_summary_json") - aux = {} - if aux_raw: - try: - aux = _json.loads(aux_raw) if isinstance(aux_raw, str) else aux_raw - except (TypeError, _json.JSONDecodeError): - pass - - findings = [] - for rr in rule_results: - loc = {} - if rr.get("paragraph_index") is not None: - loc["paragraph_index"] = rr["paragraph_index"] - if rr.get("paragraph_text"): - loc["context"] = rr["paragraph_text"] - if rr.get("location_path"): - loc["role"] = rr["location_path"] - findings.append({ - "finding_id": f"{rr['rule_id']}-{rr['paragraph_index'] or 0}", - "rule_id": rr["rule_id"], - "rule_name": rr["rule_name"], - "severity": rr["severity"], - "category": rr["category"], - "location": loc if loc else None, - "actual": rr.get("actual") or {}, - "expected": rr.get("expected") or {}, - "message": rr.get("message") or "", - "suggestion": rr.get("suggestion") or "", - "evidence": rr.get("evidence") or "", - "confidence": 1.0, - }) - - checked_rules = [] - seen = set() - for rr in rule_results: - rid = rr["rule_id"] - if rid in seen: - continue - seen.add(rid) - status = rr.get("result", "pass") - checked_rules.append({ - "rule_id": rid, - "name": rr["rule_name"], - "severity": rr["severity"], - "category": rr["category"], - "status": status if status in ("pass", "fail", "skipped") else "pass", - "skip_reason": rr.get("skip_reason"), - }) + totalScore = float(runRow.get("total_score") or 0) return { "runId": runId, - "summary": { - "score": run_data.get("total_score", 100), - "total_findings": len(findings), - "by_severity": {}, - "by_category": {}, - "passed_count": run_data.get("passed_count", 0), - "failed_count": run_data.get("failed_count", 0), - "skipped_count": run_data.get("skipped_count", 0), + "documentId": int(runRow["document_id"]), + "document": { + "documentId": int(runRow["document_id"]), + "filename": str(documentRow["file_name"] or "") if documentRow else "", }, - "checkedRules": checked_rules, + "summary": { + "score": totalScore, + "total_findings": len(findings), + "by_severity": severityStats, + "by_category": categoryStats, + "passed_count": int(runRow.get("passed_count") or 0), + "failed_count": int(runRow.get("failed_count") or 0), + "skipped_count": int(runRow.get("skipped_count") or 0), + }, + "checkedRules": checkedRules, "findings": findings, "structure": aux.get("structure", []), "outline": aux.get("outline", []), - "entities": {}, + "entities": aux.get("entities", {}), } async def GetRunFindings(self, runId: int) -> dict[str, Any]: - logger.info("[Govdoc] GetRunFindings placeholder — runId=%s", runId) - return {"runId": runId, "findings": []} + result = await self.GetRunResult(runId) + return {"runId": runId, "findings": result["findings"]} async def GetRunEntities(self, runId: int) -> dict[str, Any]: - logger.info("[Govdoc] GetRunEntities placeholder — runId=%s", runId) - return {"runId": runId, "entities": []} + result = await self.GetRunResult(runId) + return {"runId": runId, "entities": result.get("entities", {})} - async def GetRunParagraphs(self, runId: int) -> dict[str, Any]: - logger.info("[Govdoc] GetRunParagraphs placeholder — runId=%s", runId) - return {"runId": runId, "paragraphs": []} + async def GetRunParagraphs(self, runId: int) -> str: + runStatus = await self.GetRunStatus(runId) + if runStatus["status"] != "completed": + raise LeauditException( + StatusCodeEnum.HTTP_409_CONFLICT, + "当前审查尚未完成,暂时无法加载文档视图", + ) + + paragraphArtifact = await self._get_report_artifact(runId, "paragraph_html") + if paragraphArtifact: + content = await self.OssService.DownloadBytes(str(paragraphArtifact["oss_url"])) + return content.decode("utf-8") + + documentMeta = await self._get_document_for_read(int(runStatus["documentId"])) + fileRow = await self._get_active_original_file(documentMeta.documentId) + + ossUrl = getattr(fileRow, "ossUrl", None) or fileRow.get("oss_url") + fileExt = getattr(fileRow, "fileExt", None) or fileRow.get("file_ext") + + if not ossUrl: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "原始文档未找到可用存储地址") + if (fileExt or "").strip().lower() != "docx": + raise LeauditException( + StatusCodeEnum.HTTP_400_BAD_REQUEST, + "当前文档视图仅支持 DOCX 原文预览,请上传 DOCX 文件", + ) + + tempPath = await self.OssService.DownloadToTempFile( + Source=ossUrl, + Suffix=f".{fileExt or 'docx'}", + Prefix="govdoc-run-", + ) + try: + doc = parse_docx(tempPath) + findingsResult = await self.GetRunFindings(runId) + findingMap: dict[int, list[str]] = {} + for finding in findingsResult["findings"]: + pi = int(finding.get("location", {}).get("paragraph_index") or 0) + findingMap.setdefault(pi, []).append(str(finding["finding_id"])) + return paragraphs_to_html(doc, findingMap) + finally: + try: + Path(tempPath).unlink(missing_ok=True) + except Exception: + pass async def GetRunStructure(self, runId: int) -> dict[str, Any]: - logger.info("[Govdoc] GetRunStructure placeholder — runId=%s", runId) - return {"runId": runId, "structure": []} + result = await self.GetRunResult(runId) + return {"runId": runId, "structure": result.get("structure", [])} async def GetRunOutline(self, runId: int) -> dict[str, Any]: - logger.info("[Govdoc] GetRunOutline placeholder — runId=%s", runId) - return {"runId": runId, "outline": []} + result = await self.GetRunResult(runId) + return {"runId": runId, "outline": result.get("outline", [])} async def GetReportHtml(self, runId: int) -> dict[str, Any]: - logger.info("[Govdoc] GetReportHtml placeholder — runId=%s", runId) - return {"runId": runId, "htmlUrl": ""} + artifact = await self._get_report_artifact(runId, "html_report") + if not artifact: + return {"runId": runId, "htmlUrl": ""} + return { + "runId": runId, + "htmlUrl": await self.OssService.PresignGetUrl(str(artifact["oss_url"])), + } async def GetReportDocx(self, runId: int) -> dict[str, Any]: - logger.info("[Govdoc] GetReportDocx placeholder — runId=%s", runId) - return {"runId": runId, "docxUrl": ""} + artifact = await self._get_report_artifact(runId, "annotated_docx") + if not artifact: + return {"runId": runId, "docxUrl": ""} + return { + "runId": runId, + "docxUrl": await self.OssService.PresignGetUrl(str(artifact["oss_url"])), + } async def DownloadOriginal(self, documentId: int) -> dict[str, Any]: - logger.info("[Govdoc] DownloadOriginal placeholder — documentId=%s", documentId) - return {"documentId": documentId, "downloadUrl": ""} + fileRow = await self._get_active_original_file(documentId) + ossUrl = getattr(fileRow, "ossUrl", None) or fileRow.get("oss_url") + if not ossUrl: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "原始文档不存在") + return { + "documentId": documentId, + "downloadUrl": await self.OssService.PresignGetUrl(str(ossUrl)), + } # ── 规则 ────────────────────────────────────────────── async def ListRules(self, rulesPath: str | None = None) -> dict[str, Any]: """从 govdoc 规则 YAML 文件加载规则清单。""" rules = await self._load_rules_list(rulesPath) - return {"rules": rules, "total_rules": len(rules)} + return {"metadata": {}, "rules": rules, "total_rules": len(rules)} async def GetRuleDetail(self, ruleId: str, rulesPath: str | None = None) -> dict[str, Any]: """获取单条规则完整详情(名称、严重度、stages、消息等)。""" @@ -254,55 +953,556 @@ class GovdocServiceImpl(IGovdocService): } return {"rule_id": ruleId, "name": ruleId, "severity": "info", "category": "", "group": ""} - # ── 规则加载助手 ──────────────────────────────────── + # ── 助手 ────────────────────────────────────────────── async def _resolve_rules_path(self, rulesPath: str | None = None) -> str | None: - """解析规则 YAML 文件路径。 - - 优先级:传入参数 > govdoc_runs 表记录 > None - """ + """解析规则 YAML 文件路径。""" if rulesPath: return rulesPath - # 尝试从最近的 completed run 中获取 rules_path - try: - from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession - from sqlalchemy import text - async with GetAsyncSession() as session: - row = await session.execute( - text( - """SELECT rules_path FROM govdoc_runs - WHERE rules_path IS NOT NULL AND status = 'completed' - ORDER BY id DESC LIMIT 1""" - ) - ) - result = row.mappings().first() - if result and result.get("rules_path"): - return result["rules_path"] - except Exception: - pass + candidates = [ + Path("/home/wren-dev/Porject/leaudit-platform/rules/govdoc/govdoc_general/rules.yaml"), + Path("/home/wren-dev/Porject/leaudit-platform/rules/govdoc_general/rules.yaml"), + ] + for candidate in candidates: + if candidate.is_file(): + return str(candidate) return None async def _load_ruleset(self, rulesPath: str | None = None): - """加载 rules.yaml 为 RuleSet 对象。""" resolved = await self._resolve_rules_path(rulesPath) if not resolved: logger.warning("[Govdoc] Cannot resolve rules path for GetRuleDetail/ListRules") return None from fastapi_modules.fastapi_leaudit.govdoc_engine.dsl.loader import load_rules + return load_rules(resolved) async def _load_rules_list(self, rulesPath: str | None = None) -> list[dict[str, Any]]: - """加载规则列表(简要信息)。""" ruleset = await self._load_ruleset(rulesPath) if ruleset is None: return [] result = [] for rule in ruleset.all_rules(): - result.append({ - "rule_id": rule.rule_id, - "name": rule.name, - "severity": rule.severity, - "category": rule.category, - "group": "", - }) + result.append( + { + "rule_id": rule.rule_id, + "name": rule.name, + "severity": rule.severity, + "category": rule.category, + "group": "", + } + ) return result + + async def _ensureGovdocSchema(self, session) -> None: + statements = [ + """ + ALTER TABLE leaudit_documents + ADD COLUMN IF NOT EXISTS engine_type VARCHAR(32) NOT NULL DEFAULT 'leaudit' + """, + """ + CREATE TABLE IF NOT EXISTS public.govdoc_runs ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + document_id BIGINT NOT NULL, + document_file_id BIGINT, + run_no INTEGER NOT NULL DEFAULT 1, + trigger_source VARCHAR(64) NOT NULL DEFAULT 'upload', + trigger_user_id BIGINT, + task_id VARCHAR(128), + status VARCHAR(64) NOT NULL DEFAULT 'pending', + phase VARCHAR(32), + engine_version VARCHAR(64), + llm_provider VARCHAR(64), + llm_model VARCHAR(128), + rules_path VARCHAR(1024), + total_score NUMERIC(10, 2), + passed_count INTEGER, + failed_count INTEGER, + skipped_count INTEGER, + result_status VARCHAR(32), + result_summary_json TEXT, + error_message TEXT, + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ DEFAULT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS public.govdoc_rule_results ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + run_id BIGINT NOT NULL, + rule_id VARCHAR(128) NOT NULL, + rule_name VARCHAR(256), + severity VARCHAR(32), + category VARCHAR(128), + message TEXT, + suggestion TEXT, + actual TEXT, + expected TEXT, + evidence TEXT, + paragraph_index INTEGER, + paragraph_text TEXT, + location_path VARCHAR(512), + result VARCHAR(32) NOT NULL DEFAULT 'pass', + skip_reason TEXT, + score NUMERIC(10, 2), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ DEFAULT NULL + ) + """, + """ + CREATE TABLE IF NOT EXISTS public.govdoc_report_artifacts ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + run_id BIGINT NOT NULL, + artifact_type VARCHAR(64) NOT NULL, + file_name VARCHAR(512) NOT NULL, + file_ext VARCHAR(32), + mime_type VARCHAR(128), + file_size BIGINT, + sha256 VARCHAR(64), + oss_url VARCHAR(2048), + storage_provider VARCHAR(32), + description VARCHAR(512), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ DEFAULT NULL + ) + """, + """ + CREATE INDEX IF NOT EXISTS idx_leaudit_documents_engine_type + ON public.leaudit_documents(engine_type) WHERE deleted_at IS NULL + """, + """ + CREATE INDEX IF NOT EXISTS idx_govdoc_runs_document_id + ON public.govdoc_runs(document_id) WHERE deleted_at IS NULL + """, + """ + CREATE INDEX IF NOT EXISTS idx_govdoc_runs_status + ON public.govdoc_runs(status) WHERE deleted_at IS NULL + """, + """ + CREATE INDEX IF NOT EXISTS idx_govdoc_runs_trigger_user_id + ON public.govdoc_runs(trigger_user_id) + """, + """ + CREATE INDEX IF NOT EXISTS idx_govdoc_rule_results_run_id + ON public.govdoc_rule_results(run_id) WHERE deleted_at IS NULL + """, + """ + CREATE INDEX IF NOT EXISTS idx_govdoc_rule_results_rule_id + ON public.govdoc_rule_results(rule_id) WHERE deleted_at IS NULL + """, + """ + CREATE INDEX IF NOT EXISTS idx_govdoc_rule_results_result + ON public.govdoc_rule_results(result) WHERE deleted_at IS NULL + """, + """ + CREATE INDEX IF NOT EXISTS idx_govdoc_rule_results_paragraph + ON public.govdoc_rule_results(run_id, paragraph_index) WHERE deleted_at IS NULL + """, + """ + CREATE INDEX IF NOT EXISTS idx_govdoc_report_artifacts_run_id + ON public.govdoc_report_artifacts(run_id) WHERE deleted_at IS NULL + """, + """ + CREATE INDEX IF NOT EXISTS idx_govdoc_report_artifacts_type + ON public.govdoc_report_artifacts(run_id, artifact_type) WHERE deleted_at IS NULL + """, + ] + for statement in statements: + await session.execute(text(statement)) + await session.commit() + + async def _getCurrentUserContext(self, CurrentUserId: int) -> dict[str, Any]: + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT + u.id, + COALESCE(u.area, '') AS area, + COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, + COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage, + COALESCE(bool_or(r.role_key = 'super_admin'), FALSE) AS is_super_admin + FROM sso_users u + LEFT JOIN user_role ur ON ur.user_id = u.id + LEFT JOIN roles r ON r.id = ur.role_id + WHERE u.id = :user_id + GROUP BY u.id, u.area + """ + ), + {"user_id": CurrentUserId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") + return { + "area": str(row["area"] or ""), + "is_global": bool(row["is_global"]), + "can_manage": bool(row["can_manage"]), + "is_super_admin": bool(row["is_super_admin"]), + } + + def _buildDocumentScopeFilters( + self, + CurrentUserId: int, + CurrentUser: dict[str, Any], + Params: dict[str, object], + DocumentAlias: str, + FileAlias: str, + RequestedRegion: str | None = None, + RequestedUserId: int | None = None, + ) -> list[str]: + filters: list[str] = [] + requestedRegion = (RequestedRegion or "").strip() + area = str(CurrentUser["area"] or "").strip() + + if CurrentUser["is_global"]: + if requestedRegion: + filters.append(f"{DocumentAlias}.region = :requested_region") + Params["requested_region"] = requestedRegion + if RequestedUserId is not None: + filters.append(f"{FileAlias}.created_by = :requested_user_id") + Params["requested_user_id"] = RequestedUserId + return filters + + if CurrentUser["can_manage"]: + if not area: + filters.append("1 = 0") + return filters + if requestedRegion and requestedRegion != area: + filters.append("1 = 0") + return filters + filters.append(f"{DocumentAlias}.region = :scope_region") + Params["scope_region"] = area + if RequestedUserId is not None: + filters.append(f"{FileAlias}.created_by = :requested_user_id") + Params["requested_user_id"] = RequestedUserId + return filters + + filters.append(f"{FileAlias}.created_by = :scope_user_id") + Params["scope_user_id"] = CurrentUserId + if requestedRegion: + filters.append(f"{DocumentAlias}.region = :requested_region") + Params["requested_region"] = requestedRegion + if RequestedUserId is not None and RequestedUserId != CurrentUserId: + filters.append("1 = 0") + return filters + + def _resolve_upload_region(self, currentUser: dict[str, Any], requestedRegion: str) -> str: + area = str(currentUser["area"] or "").strip() + if currentUser["is_global"]: + return requestedRegion or area or "default" + if currentUser["can_manage"]: + if area and requestedRegion and requestedRegion != area: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本地区") + return area or requestedRegion or "default" + if area and requestedRegion and requestedRegion != area: + raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "不能上传到非本人地区") + return area or requestedRegion or "default" + + def _normalize_document_name(self, fileName: str) -> str: + suffix = Path(fileName).suffix + return fileName[: -len(suffix)] if suffix else fileName + + async def _get_document_for_run( + self, + documentId: int, + userId: int, + currentUser: dict[str, Any], + ) -> _GovdocDocumentRow: + params: dict[str, Any] = {"document_id": documentId} + filters = [ + "d.id = :document_id", + "d.deleted_at IS NULL", + "f.deleted_at IS NULL", + "f.is_active = true", + "f.file_role = 'original'", + "COALESCE(d.engine_type, 'leaudit') = 'govdoc'", + ] + filters.extend( + self._buildDocumentScopeFilters( + CurrentUserId=userId, + CurrentUser=currentUser, + Params=params, + DocumentAlias="d", + FileAlias="f", + ) + ) + whereClause = " AND ".join(filters) + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + f""" + SELECT + d.id AS document_id, + COALESCE(d.region, 'default') AS region, + COALESCE(d.processing_status, 'waiting') AS processing_status, + d.current_run_id, + d.created_at, + d.updated_at, + f.id AS file_id, + f.file_name, + f.file_ext, + f.mime_type, + f.file_size, + f.oss_url, + f.created_by, + gr.result_status, + gr.total_score, + gr.passed_count, + gr.failed_count, + gr.skipped_count + FROM leaudit_documents d + JOIN leaudit_document_files f + ON f.document_id = d.id + AND f.is_active = true + AND f.file_role = 'original' + AND f.deleted_at IS NULL + LEFT JOIN govdoc_runs gr + ON gr.id = d.current_run_id + WHERE {whereClause} + LIMIT 1 + """ + ), + params, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "公文文档不存在或无权访问") + return self._map_document_row(row) + + async def _get_document_for_read(self, documentId: int) -> _GovdocDocumentRow: + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT + d.id AS document_id, + COALESCE(d.region, 'default') AS region, + COALESCE(d.processing_status, 'waiting') AS processing_status, + d.current_run_id, + d.created_at, + d.updated_at, + f.id AS file_id, + f.file_name, + f.file_ext, + f.mime_type, + f.file_size, + f.oss_url, + f.created_by, + gr.result_status, + gr.total_score, + gr.passed_count, + gr.failed_count, + gr.skipped_count + FROM leaudit_documents d + JOIN leaudit_document_files f + ON f.document_id = d.id + AND f.is_active = true + AND f.file_role = 'original' + AND f.deleted_at IS NULL + LEFT JOIN govdoc_runs gr + ON gr.id = d.current_run_id + WHERE d.id = :document_id + AND d.deleted_at IS NULL + AND COALESCE(d.engine_type, 'leaudit') = 'govdoc' + LIMIT 1 + """ + ), + {"document_id": documentId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "公文文档不存在") + return self._map_document_row(row) + + async def _get_active_original_file(self, documentId: int): + async with GetAsyncSession() as session: + row = ( + await session.execute( + text( + """ + SELECT id, document_id, file_name, file_ext, mime_type, file_size, oss_url, created_by + FROM leaudit_document_files + WHERE document_id = :document_id + AND is_active = true + AND file_role = 'original' + AND deleted_at IS NULL + ORDER BY id DESC + LIMIT 1 + """ + ), + {"document_id": documentId}, + ) + ).mappings().first() + if not row: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "原始文档不存在") + return row + + async def _next_run_no(self, documentId: int) -> int: + async with GetAsyncSession() as session: + current = ( + await session.execute( + text( + """ + SELECT COALESCE(MAX(run_no), 0) + FROM govdoc_runs + WHERE document_id = :document_id + AND deleted_at IS NULL + """ + ), + {"document_id": documentId}, + ) + ).scalar_one() + return int(current or 0) + 1 + + def _build_run_summary(self, row: Any) -> dict[str, Any]: + summary = self._build_summary_payload( + row.get("total_score"), + row.get("passed_count"), + row.get("failed_count"), + row.get("skipped_count"), + ) + return { + "runId": int(row["id"]), + "documentId": int(row["document_id"]), + "status": str(row.get("status") or "pending"), + "phase": row.get("phase"), + "resultStatus": row.get("result_status"), + "score": float(row.get("total_score") or 0), + "passedCount": int(row.get("passed_count") or 0), + "failedCount": int(row.get("failed_count") or 0), + "skippedCount": int(row.get("skipped_count") or 0), + "summary": summary, + "errorMessage": row.get("error_message"), + "taskId": row.get("task_id"), + "createdAt": self._iso(row.get("created_at")), + "updatedAt": self._iso(row.get("updated_at")), + "startedAt": self._iso(row.get("started_at")), + "finishedAt": self._iso(row.get("finished_at")), + } + + def _build_summary_payload( + self, + totalScore: Any, + passedCount: Any, + failedCount: Any, + skippedCount: Any, + ) -> dict[str, Any]: + return { + "score": float(totalScore or 0), + "total_findings": int(failedCount or 0), + "by_severity": {}, + "by_category": {}, + "passed_count": int(passedCount or 0), + "failed_count": int(failedCount or 0), + "skipped_count": int(skippedCount or 0), + } + + def _group_artifacts_by_run(self, rows: list[Any]) -> dict[int, dict[str, Any]]: + grouped: dict[int, dict[str, Any]] = {} + artifactTypeMap = { + "html_report": "htmlUrl", + "annotated_docx": "docxUrl", + "paragraph_html": "paragraphHtmlUrl", + } + for row in rows: + runId = int(row["run_id"]) + payload = grouped.setdefault( + runId, + { + "artifacts": [], + "hasHtmlReport": False, + "hasDocxReport": False, + "hasParagraphHtml": False, + }, + ) + artifactType = str(row.get("artifact_type") or "") + if artifactType in artifactTypeMap and row.get("oss_url") and artifactTypeMap[artifactType] not in payload: + payload[artifactTypeMap[artifactType]] = row["oss_url"] + if artifactType == "html_report": + payload["hasHtmlReport"] = True + elif artifactType == "annotated_docx": + payload["hasDocxReport"] = True + elif artifactType == "paragraph_html": + payload["hasParagraphHtml"] = True + payload["artifacts"].append( + { + "artifactType": artifactType, + "fileName": row.get("file_name") or "", + "fileExt": row.get("file_ext") or "", + "mimeType": row.get("mime_type") or "", + "ossUrl": row.get("oss_url") or "", + "description": row.get("description") or "", + } + ) + return grouped + + def _map_document_row(self, row: Any) -> _GovdocDocumentRow: + return _GovdocDocumentRow( + documentId=int(row["document_id"]), + region=str(row["region"] or "default"), + processingStatus=str(row["processing_status"] or "waiting"), + currentRunId=int(row["current_run_id"]) if row.get("current_run_id") is not None else None, + createdAt=row.get("created_at"), + updatedAt=row.get("updated_at"), + fileId=int(row["file_id"]), + fileName=str(row["file_name"] or ""), + fileExt=str(row["file_ext"]) if row.get("file_ext") else None, + mimeType=str(row["mime_type"]) if row.get("mime_type") else None, + fileSize=int(row["file_size"]) if row.get("file_size") is not None else None, + ossUrl=str(row["oss_url"]) if row.get("oss_url") else None, + createdBy=int(row["created_by"]) if row.get("created_by") is not None else None, + resultStatus=str(row["result_status"]) if row.get("result_status") else None, + totalScore=float(row["total_score"]) if row.get("total_score") is not None else None, + passedCount=int(row["passed_count"]) if row.get("passed_count") is not None else None, + failedCount=int(row["failed_count"]) if row.get("failed_count") is not None else None, + skippedCount=int(row["skipped_count"]) if row.get("skipped_count") is not None else None, + hasHtmlReport=bool(row.get("has_html_report")), + hasDocxReport=bool(row.get("has_docx_report")), + ) + + async def _get_report_artifact(self, runId: int, artifactType: str) -> Any | None: + async with GetAsyncSession() as session: + await self._ensureGovdocSchema(session) + return ( + await session.execute( + text( + """ + SELECT oss_url + FROM govdoc_report_artifacts + WHERE run_id = :run_id + AND artifact_type = :artifact_type + AND deleted_at IS NULL + AND COALESCE(oss_url, '') <> '' + ORDER BY id DESC + LIMIT 1 + """ + ), + {"run_id": runId, "artifact_type": artifactType}, + ) + ).mappings().first() + + def _parse_json(self, raw: Any) -> Any: + if raw is None or raw == "": + return None + if isinstance(raw, (dict, list)): + return raw + try: + return json.loads(raw) + except Exception: + return None + + def _iso(self, value: Any) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.isoformat() + return str(value) diff --git a/rules/govdoc/govdoc_general/rules.yaml b/rules/govdoc/govdoc_general/rules.yaml new file mode 100644 index 0000000..f052154 --- /dev/null +++ b/rules/govdoc/govdoc_general/rules.yaml @@ -0,0 +1,546 @@ +metadata: + type_id: govdoc_general + name: 内部公文通用规则 + version: "0.1.0" + source: 公文文稿常见错误汇编(第一期)·2025-11 + description: 基于旧内部公文正式规则语义整理的当前平台规则集。 + +extract: + # 8 个内置实体(title / doc_number / recipient / date / + # signature / attachments / wenzhong / issuer)由代码自动产出。 + entities: [] + +rules: + - group: 标题(错误汇编 一) + rules: + - rule_id: GW-T-001 + name: 标题文种合规性 + severity: error + category: 标题 + target: title + on_missing: fail + stages: + - check: ai + prompt: | + 审查公文标题是否符合规范。 + 标题:{{title.text}} + + 15 种合法文种:决议、决定、命令(令)、公报、公告、通告、意见、 + 通知、通报、报告、请示、批复、议案、函、纪要 + + 检查要点: + 1. 是否使用了合法文种 + 2. 方案/规划/办法/细则等是否以"通知"形式下发(应为"关于印发〈xxx〉的通知") + 3. 标题中是否有"印发"等动词 + messages: + pass: 标题文种合规 + fail: 标题文种不合规 + + - rule_id: GW-T-002 + name: 标题不可有"请求"+"请示"重复 + severity: error + category: 标题 + target: title + on_missing: skip + stages: + - check: regex_forbid + pattern: '关于请求.*的请示' + messages: + pass: ok + fail: '"请示"已包含"请求"之意,应删去"请求"' + + - rule_id: GW-T-003 + name: 标题不可有"上报"+"报告"重复 + severity: error + category: 标题 + target: title + on_missing: skip + stages: + - check: regex_forbid + pattern: '关于上报.*的报告' + messages: + pass: ok + fail: '"报告"已包含"上报"之意,应删去"上报"' + + - rule_id: GW-T-004 + name: 标题介词连用 + severity: warning + category: 标题 + target: title + on_missing: skip + stages: + - check: regex_forbid + pattern: '关于对.*的(批复|通知|通报)' + messages: + pass: ok + fail: '"关于"+"对" 介词连用不规范' + + - rule_id: GW-T-005 + name: 标题文种白名单 + severity: error + category: 文种 + target: wenzhong + on_missing: skip + stages: + - check: wenzhong_whitelist + messages: + pass: 文种合规 + fail: 非法定文种(出现"工作情况""汇报""方案""办法"等当文种) + + - rule_id: GW-T-006 + name: 标题回行词意完整 + severity: warning + category: 标题 + target: title + on_missing: skip + stages: + - check: ai + prompt: | + 只在标题里**明确出现破词**时才报错。 + 破词示例:「广东省烟草专卖局关于xx的通知」如果在"专"和"卖"之间有换行 → fail + 其它情况(单行标题、合理换行点、词意完整)→ **必须 pass** + + 判断准则: + - 标题已经是单行字符串,没有明显断点 → pass + - 不要凭直觉揣测,只判断是否能在原文中**逐字定位**破词位置 + - 找不到具体破词位置就 pass + + 标题原文: + {{title.text}} + messages: + pass: 标题回行合规 + fail: 标题回行破词 + + - group: 发文字号(错误汇编 三、六.3) + rules: + - rule_id: GW-N-001 + name: 发文字号必须用六角括号 + severity: error + category: 发文 + target: doc_number + on_missing: fail + stages: + - check: forbid_chars + chars: ["[", "]"] + messages: + pass: ok + fail: 发文字号年份应用六角括号「〔〕」,不得使用方括号 + + - rule_id: GW-N-002 + name: 发文字号不可加"第"字 + severity: error + category: 发文 + target: doc_number + on_missing: fail + stages: + - check: regex_forbid + pattern: '〔\d{4}〕第\d+号' + messages: + pass: ok + fail: 发文字号顺序号前不应加"第"字 + + - rule_id: GW-N-003 + name: 发文字号顺序号不编虚位 + severity: error + category: 发文 + target: doc_number + on_missing: fail + stages: + - check: regex_forbid + pattern: '〔\d{4}〕0\d+号' + messages: + pass: ok + fail: 发文字号顺序号不编虚位(如"02号"应为"2号") + + - group: 格式(错误汇编 二) + rules: + - rule_id: GW-F-001 + name: 主标题用方正小标宋简体二号 + severity: error + category: 格式 + target: title + on_missing: fail + stages: + - check: font + expect: + eastasia: 方正小标宋简体 + size_pt: 22 + messages: + pass: ok + fail: 主标题应使用方正小标宋简体二号 + + - rule_id: GW-F-002 + name: 一级标题用黑体三号 + severity: error + category: 格式 + applies_to: + role: heading_1 + on_missing: skip + stages: + - check: font + expect: + eastasia: 黑体 + size_pt: 16 + messages: + pass: ok + fail: 一级标题应使用黑体三号 + + - rule_id: GW-F-003 + name: 二级标题用楷体三号 + severity: error + category: 格式 + applies_to: + role: heading_2 + on_missing: skip + stages: + - check: font + expect: + eastasia: 楷体 + size_pt: 16 + messages: + pass: ok + fail: 二级标题应使用楷体三号 + + - rule_id: GW-F-004 + name: 正文用仿宋三号 + severity: warning + category: 格式 + applies_to: + role: body + on_missing: skip + stages: + - check: font + expect: + eastasia: 仿宋 + size_pt: 16 + messages: + pass: ok + fail: 正文应使用仿宋(GB2312)三号 + + - rule_id: GW-F-005 + name: 附件后不加冒号 + severity: error + category: 格式 + applies_to: + role: attachment_marker + on_missing: skip + stages: + - check: regex_forbid + pattern: '^附件\d+:' + messages: + pass: ok + fail: '"附件1"等字样后不应加冒号' + + - rule_id: GW-F-006 + name: 不使用"(此页无正文)" + severity: warning + category: 格式 + applies_to: + role: any + on_missing: skip + stages: + - check: forbid_phrase + phrases: + - (此页无正文) + - (此页无正文) + messages: + pass: ok + fail: 应通过编辑排版避免出现"(此页无正文)" + + - rule_id: GW-F-007 + name: 附件项末尾不加标点 + severity: warning + category: 格式 + applies_to: + role: any + on_missing: skip + stages: + - check: cross_role + rules: + - type: attachment_item_no_trailing_punct + messages: + pass: ok + fail: 附件名称(内容)后不应使用标点符号 + + - rule_id: GW-F-008 + name: 三级标题用仿宋三号 + severity: warning + category: 格式 + applies_to: + role: heading_3 + on_missing: skip + stages: + - check: font + expect: + eastasia: 仿宋 + size_pt: 16 + messages: + pass: ok + fail: 三级标题应使用仿宋(GB2312)三号 + + - rule_id: GW-F-009 + name: 四级标题用仿宋三号 + severity: warning + category: 格式 + applies_to: + role: heading_4 + on_missing: skip + stages: + - check: font + expect: + eastasia: 仿宋 + size_pt: 16 + messages: + pass: ok + fail: 四级标题应使用仿宋(GB2312)三号 + + - rule_id: GW-F-010 + name: 附件标记用黑体三号不加粗 + severity: error + category: 格式 + applies_to: + role: attachment_marker + on_missing: skip + stages: + - check: attachment_marker_style + expect: + eastasia: 黑体 + size_pt: 16 + bold: false + messages: + pass: ok + fail: '"附件:"或"附件1"等标记应使用黑体三号,且不加粗' + + - group: 层级序号(错误汇编 四) + rules: + - rule_id: GW-H-001 + name: 层级序号格式 + severity: error + category: 层级 + applies_to: + role: any + on_missing: skip + stages: + - check: hierarchy + forbid_patterns: + - '^[一二三四五六七八九十]+、.*[、。]$' + - '^\d+、' + - '^([一二三四五六七八九十]+)、' + messages: + pass: ok + fail: 层级序号格式错误 + + - rule_id: GW-H-002 + name: 二级标题换行不带句号 + severity: warning + category: 层级 + applies_to: + role: heading_2 + on_missing: skip + stages: + - check: cross_role + rules: + - type: h2_no_period_then_break + messages: + pass: ok + fail: 二级标题在换行分段时不应使用句号 + + - group: 标点符号(错误汇编 六) + rules: + - rule_id: GW-P-001 + name: 多书名号/引号并列不加顿号 + severity: warning + category: 标点 + applies_to: + role: any + on_missing: skip + stages: + - check: punctuation + rules: + - type: no_dunhao_between_quotes + messages: + pass: ok + fail: 多个书名号/引号并列时不应用顿号分隔 + + - rule_id: GW-P-002 + name: 句内括号末尾不加标点 + severity: warning + category: 标点 + applies_to: + role: any + on_missing: skip + stages: + - check: punctuation + rules: + - type: no_punct_inside_inline_paren + messages: + pass: ok + fail: 句内括号行文末尾通常不应含标点 + + - rule_id: GW-P-003 + name: 引号嵌套不规范 + severity: warning + category: 标点 + applies_to: + role: any + on_missing: skip + stages: + - check: punctuation + rules: + - type: no_outer_quote_when_inner_quote + messages: + pass: ok + fail: 双引号内已含单引号强调时,外层不应再加双引号(如"卓'粤'创一流"应为 卓"粤"创一流) + + - group: 文字表述与提法(错误汇编 七、八、九) + rules: + - rule_id: GW-W-001 + name: 易混淆词使用 + severity: warning + category: 文字 + applies_to: + role: any + on_missing: skip + stages: + - check: confused_pair + pairs: + - wrong: 截至到 + correct: 截止到 + reason: '"截至" 已含"到"之意' + - wrong: 下称 + correct: 以下简称 + reason: 标注简称应用"以下简称" + - wrong_pattern: '截止\d{4}年' + suggest: 截至YYYY年 + reason: 用于到某时点应为"截至" + messages: + pass: ok + fail: 易混淆词使用不当 + + - rule_id: GW-W-002 + name: 简称使用规范 + severity: warning + category: 简称 + applies_to: + role: body + on_missing: skip + stages: + - check: ai + prompt: | + 只在文中出现以下两种省级职务简称错误时才报错,否则一律 pass: + - "X省省委书记" 错误(应为 "X省委书记",省字不重复) + - "X省长" 错误(应为 "X省省长",省字不可省略) + + 若文中没有"省委书记"或"省长"等省级职务字样,**必须 pass**。 + 若不能在文中找到准确的错误原文,**必须 pass**。 + 不要做语气、措辞、其它简称的检查。 + + 全文片段: + {{paragraphs[0]}} + messages: + pass: 简称规范 + fail: 简称使用不规范 + + - rule_id: GW-W-003 + name: 成文日期用阿拉伯数字 + severity: error + category: 提法 + target: date + on_missing: fail + stages: + - check: regex_forbid + pattern: '[一二三四五六七八九十○〇零]+年' + messages: + pass: ok + fail: 成文日期应使用阿拉伯数字(如"2023年10月9日") + + - rule_id: GW-W-004 + name: 成文日期不编虚位 + severity: warning + category: 提法 + target: date + on_missing: fail + stages: + - check: regex_forbid + pattern: '\d{4}年0\d月|\d{4}年\d{1,2}月0\d日' + messages: + pass: ok + fail: 成文日期月、日不编虚位 + + - group: 发文机关(错误汇编 十) + rules: + - rule_id: GW-S-001 + name: 发文机关署名不能用简称 + severity: error + category: 机关 + target: signature + on_missing: fail + stages: + - check: ai + prompt: | + 判断署名是否含**明确的简称错误**。 + 典型错误: + - "广东省烟草专卖局(公司)" — 用括号缩短两个机关 → 错 + - "省局" / "粤烟" 等单独缩写 → 错 + 典型正确: + - "广东省烟草专卖局" 单独出现 → pass(即使可能存在配套总公司,但单独存在不算简称) + - "广东省烟草专卖局 中国烟草总公司广东省公司" → pass + + 判断准则: + - 若署名是一个完整、官方、可独立成立的机关名 → **必须 pass** + - 若署名带"(公司)"、"省局"、明显缩写、行业内部代号 → fail + + 署名原文: + {{signature.text}} + messages: + pass: 署名规范 + fail: 发文机关署名使用了简称 + + - rule_id: GW-S-002 + name: 发文机关确定严谨性 + severity: warning + category: 机关 + target: signature + on_missing: fail + stages: + - check: ai + prompt: | + 只判断**这一个明确条件**: + - 标题或正文里明确涉及"党组""党的xx工作""组织部""纪委"等党务事项, + 但署名是行政机关(局/公司/委员会等),未署"党组"或党务机构 → fail + - 其它情况(行政事务、缺乏证据、性质模糊)→ **必须 pass** + + 判断时需要看到**明确的党务关键词**(党组/党委/党的xx会议/党风/反腐倡廉等), + 没有这些关键词就 pass。 + + 署名原文:{{signature.text}} + 标题:{{title.text}} + messages: + pass: 发文机关一致 + fail: 发文机关与文稿性质不一致 + + - group: 标题字体(target 通道示例) + rules: + - rule_id: GW-T-008 + name: 标题字体(语义实体通道) + severity: warning + category: 标题 + target: title + on_missing: warn + stages: + - check: ai + prompt: | + 判断公文标题的字体与字号是否合规。 + 要求:字体 = 方正小标宋简体;字号 = 22pt(或 22.0)。 + + 实际: + - 标题:{{title.text}} + - 字体:{{title.style.font_eastasia}} + - 字号:{{title.style.font_size_pt}}pt + + 若实际字体为空或与要求一致 → pass + 若字体明显不符(例如 仿宋/楷体/黑体)→ fail + 若仅字号轻微差异 → warn + messages: + pass: 标题字体字号合规 + fail: 标题字体或字号不符合 GB/T 9704 diff --git a/scripts/创建sql/schema_add_govdoc_module.sql b/scripts/创建sql/schema_add_govdoc_module.sql index 2f31778..a322aa3 100644 --- a/scripts/创建sql/schema_add_govdoc_module.sql +++ b/scripts/创建sql/schema_add_govdoc_module.sql @@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS public.govdoc_runs ( engine_version VARCHAR(64), llm_provider VARCHAR(64), llm_model VARCHAR(128), + rules_path VARCHAR(1024), -- 结果汇总 total_score NUMERIC(10, 2), @@ -52,6 +53,9 @@ CREATE TABLE IF NOT EXISTS public.govdoc_runs ( deleted_at TIMESTAMPTZ DEFAULT NULL ); +ALTER TABLE public.govdoc_runs + ADD COLUMN IF NOT EXISTS rules_path VARCHAR(1024); + COMMENT ON TABLE public.govdoc_runs IS '公文审查运行主表'; COMMENT ON COLUMN public.govdoc_runs.id IS '自增主键'; COMMENT ON COLUMN public.govdoc_runs.document_id IS '关联 leaudit_documents.id'; @@ -65,6 +69,7 @@ COMMENT ON COLUMN public.govdoc_runs.phase IS '当前阶段:parsing/executing/ COMMENT ON COLUMN public.govdoc_runs.engine_version IS '引擎版本号'; COMMENT ON COLUMN public.govdoc_runs.llm_provider IS 'LLM 提供商'; COMMENT ON COLUMN public.govdoc_runs.llm_model IS 'LLM 模型名'; +COMMENT ON COLUMN public.govdoc_runs.rules_path IS '本次运行使用的规则文件路径'; COMMENT ON COLUMN public.govdoc_runs.total_score IS '总分'; COMMENT ON COLUMN public.govdoc_runs.passed_count IS '通过规则数'; COMMENT ON COLUMN public.govdoc_runs.failed_count IS '未通过规则数'; @@ -106,6 +111,7 @@ CREATE TABLE IF NOT EXISTS public.govdoc_rule_results ( -- 判定 result VARCHAR(32) NOT NULL DEFAULT 'pass', + skip_reason TEXT, score NUMERIC(10, 2), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -113,6 +119,9 @@ CREATE TABLE IF NOT EXISTS public.govdoc_rule_results ( deleted_at TIMESTAMPTZ DEFAULT NULL ); +ALTER TABLE public.govdoc_rule_results + ADD COLUMN IF NOT EXISTS skip_reason TEXT; + COMMENT ON TABLE public.govdoc_rule_results IS '公文规则执行结果明细表'; COMMENT ON COLUMN public.govdoc_rule_results.id IS '自增主键'; COMMENT ON COLUMN public.govdoc_rule_results.run_id IS '关联 govdoc_runs.id'; @@ -129,6 +138,7 @@ COMMENT ON COLUMN public.govdoc_rule_results.paragraph_index IS '段落索引'; COMMENT ON COLUMN public.govdoc_rule_results.paragraph_text IS '段落原文'; COMMENT ON COLUMN public.govdoc_rule_results.location_path IS '文档结构位置路径'; COMMENT ON COLUMN public.govdoc_rule_results.result IS '执行结果:pass/fail/skipped/error'; +COMMENT ON COLUMN public.govdoc_rule_results.skip_reason IS '跳过原因,仅 skipped/error 时使用'; COMMENT ON COLUMN public.govdoc_rule_results.score IS '本条得分'; CREATE INDEX IF NOT EXISTS idx_govdoc_rule_results_run_id ON public.govdoc_rule_results(run_id) WHERE deleted_at IS NULL; @@ -195,4 +205,4 @@ END $$; -- 为 engine_type 加索引,方便按模块过滤文档列表 CREATE INDEX IF NOT EXISTS idx_leaudit_documents_engine_type ON public.leaudit_documents(engine_type) WHERE deleted_at IS NULL; -COMMIT; \ No newline at end of file +COMMIT;