Files
bid-doctor/docs/architecture/architecture.md
YANG JIANKUAN f99bb8b358 feat(server): 参考 BidEngine 补五项:原因码、截断重跑、项目信息逐项表态、占位符、提示词规则对应校验
- 待确认记原因码(extract.FAILURES,存在 ai_values.failures),解析结果按原因计数
  (verify_failures);前端待确认条目显示具体原因
- 相邻分块重叠约 800 字;模型输出被截断或不是合法 JSON 时对半重跑(最多 3 层),
  拆到底仍不完整记 output_incomplete。修复:以前截断时整块条目被当成「没有结果」悄悄丢掉
- 项目基本信息提示词 v2:12 个字段逐项表态 found / ambiguous / not_found,跨分块合并为
  found / absent / needs_review(info_fields)
- 「____元」「××」「( )」「待填」等占位符不算取值
- 提示词 substantive v4,qualification / scoring / schedule / tech_param v3:每条禁止事项
  对应一个原因码,对照表在 prompts/README.md
- 顾问(Fable)深读参考项目的结论补进 ref-bid-engine-analysis.md §5;架构 §13 SK12
- 待确认:国动 4 → 2,S02 13 → 3,S06 57 → 55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:59:12 +08:00

869 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 技术架构设计 v0.3
> 依据:PRD v0.9.1 第 7 章(非功能需求)、`docs/analysis/`(功能需求、领域模型、样本结论)、`docs/decisions.md`(T1 及各阶段决策)、参考项目分析 `ref-bid-engine-analysis.md`。
> 状态:v0.3。v0.2 的选型已全部确认;v0.3 新增 §3.14:MVP 的表结构先按多人、多登录方式建,给以后的团队版、企业版、飞书 / 企业微信登录留结构(AR11、AR12,已确认)。物理数据模型见 `data-model.md`(DM1–DM7,已确认);接口契约见 `api-design.md` + `openapi.yaml`(API1–API5,已确认)。
> - 已确认(2026-11-03):T2–T5;AR1、AR3、AR5–AR9 按建议;AR2 改为 Vue 3 + Vite。
> - 已确认:AR4 修订后的建议(引入 Valkey,只存可以丢的数据,§6.1);AR10 不引入带样式的 UI 框架(§4.3)。至此选型全部确认。
> - 确认后写入 `docs/decisions.md`;构建、运行、测试命令等工程骨架搭好后回填到 AGENTS.md。
> 范围:MVP(M2 + M3 + M6 的 P0 部分),同时给 V1 的标书生成、资产库,P2 的私有化、信创、桌面客户端留出扩展点。
## 0. 结论速览
| 层 | 选型 | 状态 |
|---|---|---|
| 后端语言 | **Python 单语言**:业务接口和后台任务是同一套代码、两种进程 | T2 已确认 |
| 后端框架 | FastAPI + Pydantic v2 + SQLAlchemy 2 + Alembic;包管理用 uv | 建议 |
| 架构形态 | **模块化单体**:按业务模块分包,按负载拆成几个独立部署的进程;不做微服务 | AR1 已确认 |
| 前端 | **Vue 3 + TypeScript + Vite 单页应用**,pdf.js | AR2 已确认 |
| UI 组件 | **不引入带样式的 UI 框架**:视觉自己写(tokens.css + Vue 单文件组件),复杂交互用无样式组件库 Reka UI 打底 | AR10 已确认 |
| 数据库 | **PostgreSQL**(16 以上) | AR3 已确认 |
| 任务队列 | **用 PostgreSQL 做队列**(Procrastinate):任务状态以数据库为准 | AR4 已确认 |
| 缓存、限流、消息推送 | **Valkey**(Redis 的 BSD 许可分支),只存丢了也没关系的数据 | AR4 已确认 |
| 文件存储 | S3 协议的对象存储(SaaS 用云厂商托管服务)+ 本地磁盘驱动(单机私有化);不用 MinIO | AR5 已确认 |
| 文档转换 | Gotenberg(封装了 LibreOffice 和 Chromium):Word 转 PDF、报告 HTML 转 PDF | 建议 |
| OCR | PaddleOCR 独立服务;多模态大模型作补充 | 建议,需验证 |
| 大模型 | 自建模型路由层,按能力声明路由,只接白名单内的国产模型(T1) | 已定方向 |
| 部署 | 同一批镜像,两种编排:Docker Compose 和 Helm(K8s)。**SaaS 内测期用 Compose,商用后切到托管 K8s** | T4 已确认 |
| 云厂商 | **暂不定**:只依赖 PostgreSQL、S3、OpenAI 兼容接口三类标准接口 | T5 已确认 |
| 桌面客户端 | **连服务端的客户端外壳**,界面和网页版同一套;倾向 Electron,到时再定 | T3、AR6 已确认 |
## 1. 设计输入
### 1.1 业务负载特点
这些特点决定了架构不需要很重:
- **请求量低、单个任务耗时长**:B 端投标团队,并发用户少。重活是解析和检查:一份 300 页的文件目标 10 分钟内完成(FR-M2-02),中间要调用几十到上百次大模型。
- **文件大、页数多**:单份文件几十 MB、几百页,前端要能流畅翻页并高亮原文。
- **强审计**:额度流水只追加,人工确认全程留痕,检查批次要能复现当时的判断依据(04 §4)。
- **两种部署形态共用一套代码**:SaaS 多租户;私有化部署一套就是一个租户(04 §4.5),信创环境作为可选项(PRD 7)。
- **数据敏感**:标书里有报价策略、资质、业绩,日志和监控里也不能出现文档内容。
### 1.2 来自产品红线的硬约束
| 红线 | 对架构的要求 | 落在哪一节 |
|---|---|---|
| 可复核(2) | 出处精确到原句和坐标;AI 结论必须通过证据校验,否则进待确认区 | §3.5、§3.6 |
| 全程留痕(1) | 审计事件和业务修改在同一个事务里写入;AI 产出记录模型和提示词版本 | §3.6、§3.10 |
| 额度即货币(9) | 预扣和建任务在同一个事务里;按错误类别决定是否退回;流水只追加 | §3.9 |
| 报价不由系统生成(6) | 报价检查只用确定性规则,不交给模型 | §3.7 |
| 数据敏感(10) | 按租户强制隔离,加密存储,可以彻底删除,日志脱敏 | §3.3、§3.11 |
| T1 国产模型 | 业务代码只声明能力,供应商白名单由运维配置,用户不能填模型地址 | §3.6 |
### 1.3 从参考项目吸取的教训
详见 `ref-bid-engine-analysis.md`,下表是对照结果:
| 标擎的问题 | 本项目的做法 |
|---|---|
| 解析、审核、生成各存一份数据,靠复制快照衔接 | 一份项目检查清单贯穿全流程,要求项编号稳定(§3.8) |
| 只核对证据编号,不核对引文内容;审核证据不校验 | 两道证据校验,适用于所有 AI 结论(§3.6) |
| 用户可以填任意模型地址;API Key 明文存库 | 供应商白名单由运维配置;密钥加密存储(§3.6、§3.11) |
| 自研 Redis 队列,取出任务和标记状态分两步,会丢任务 | 任务状态以数据库为准,入队和建任务在同一个事务里(§3.4) |
| 租户隔离靠每个接口手写条件,传 0 就不过滤 | 数据访问层强制带租户上下文,再加数据库行级策略兜底(§3.3) |
| 登录密钥写死在源码里,开发开关能直接拿到超级管理员身份 | 密钥只从环境注入;调试开关在生产环境下拒绝启动(§3.11) |
| 重跑后人工确认丢失;已处理状态按列表顺序号存 | 人工确认挂在稳定编号上,重跑时按编号对齐(§3.8) |
| 提示词写死在代码里,没有版本号,没有评测集 | 提示词单独存成带版本号的文件,有评测集,CI 跑回归(§3.6) |
| 接口文件 1500 行、组件 3200 行;没有测试 | 分层约定、文件规模上限、测试是合并的前提(§8) |
## 2. 总体架构
### 2.1 为什么不做微服务
用户要求重点考虑微服务。结论是 MVP **不拆微服务,做模块化单体**,理由有三点:
1. **团队小**:微服务带来的服务间调用、分布式事务、链路排查、版本兼容,都是纯成本。
2. **私有化交付**:每多一个服务,客户现场就多一份部署、升级、排障的负担。政企客户的运维往往只会 Docker。
3. **核心流程要求强一致**:「预扣额度 + 建任务 + 入队」、「改清单 + 写审计」都必须在一个数据库事务里完成,拆开后就得上分布式事务。
模块化单体不等于一个进程:代码按业务模块划分边界(§3.2),**按负载特性拆成几个进程独立部署和扩容**(§2.2)。以后真要拆服务时,模块边界就是拆分线。
### 2.2 进程与部署单元
```mermaid
flowchart LR
subgraph client[客户端]
B[浏览器]
D[桌面客户端外壳<br/>以后]
end
subgraph edge[入口]
N[web<br/>nginx:静态页面 + 反向代理]
end
subgraph app[应用(同一个 Python 镜像)]
A[api<br/>FastAPI]
W[worker<br/>解析 / 检查 / 报告]
end
subgraph aux[辅助服务]
G[converter<br/>Gotenberg]
O[ocr<br/>PaddleOCR]
end
subgraph data[数据]
P[(PostgreSQL<br/>业务数据 + 任务队列)]
S[(对象存储 / 本地磁盘)]
V[(Valkey<br/>限流 / 推送 / 缓存)]
end
L[[大模型<br/>国产云端 API / 客户自部署]]
B --> N
D --> N
N --> A
A --> P
A --> S
A --> V
W --> P
W --> S
W --> V
W --> G
W --> O
W --> L
A -. SSE 进度 .-> N
```
| 单元 | 镜像 | 说明 | 扩容方式 |
|---|---|---|---|
| web | `bid-doctor-web` | nginx 托管前端静态文件,并把 `/api` 反向代理到 api | 几乎不需要扩 |
| api | `bid-doctor-app` | 接口服务,只做请求处理和短事务,不跑重活 | 按请求量扩 |
| worker | `bid-doctor-app`(同一个镜像,启动命令不同) | 跑解析、检查、报告等后台任务;可以按队列拆成 `worker-parse` 和 `worker-light` | 按排队长度扩 |
| converter | 官方 Gotenberg 镜像 + 字体层 | Word 转 PDF、HTML 转 PDF;**不开网络出口** | 按转换量扩 |
| ocr | `bid-doctor-ocr` | PaddleOCR 推理服务,有 CPU 版和 GPU 版两种镜像 | 按扫描件量扩 |
| PostgreSQL | 托管服务或官方镜像 | 业务数据、会话、任务队列都在这里,是**唯一的权威数据源** | 纵向扩,加只读副本 |
| Valkey | 托管 Redis 兼容服务或官方镜像 | 限流计数、验证码、进度推送、模型调用并发控制、缓存;**不持久化、不备份**,清空后系统照常运行 | 几乎不需要扩 |
| 对象存储 | 托管 OSS / 本地磁盘 | 原始文件、规范化 PDF、文字层、报告 | — |
单机私有化最少只要 7 个容器:web、api、worker、converter、ocr、postgres、valkey。文件存在本地磁盘上,不需要对象存储,也不需要 K8s。
## 3. 后端
### 3.1 语言与基础库(T2)
- **Python 3.12 以上**,用 uv 管理依赖和锁文件,用 ruff 做代码检查和格式化,用 pyright 的严格模式做类型检查,测试用 pytest。
- **FastAPI**:接口层。用 Pydantic v2 定义请求和响应模型,自动生成 OpenAPI 文档,前端再据此生成类型和客户端(§4.2)。
- **SQLAlchemy 2 + Alembic**:数据库访问层和版本化的迁移脚本;不用自动建表。
- **httpx**:调用大模型和内部服务,统一设置超时和重试策略。
- 选 Python 的代价是私有化交付会暴露源码。缓解办法见 AR7:用 Nuitka 把核心包编译成二进制,再加上授权文件校验。
### 3.2 模块划分
按业务划分模块,每个模块自己管自己的表,模块之间只能通过对方公开的服务接口调用,**不能直接读写别的模块的表**。
| 模块 | 职责 | 对应需求 |
|---|---|---|
| `identity` | 人、登录方式、企业主体(租户)、成员身份、会话、短信验证码、登录风控、权限点(§3.14) | FR-M6-04 |
| `billing` | 套餐、订阅、额度账户、额度流水 | FR-M6-03、05 |
| `project` | 项目、分包、状态流、就绪标记 | FR-M6-01、02 |
| `document` | 文件上传、格式识别、转换、文字层、OCR、出处定位 | FR-M2-01、06 |
| `checklist` | 项目检查清单、清单项、待确认区、版本、补遗合并 | FR-M2-03~10 |
| `check` | 检查批次、问题、人工确认、复查沿用 | FR-M3-01~08 |
| `rules` | 确定性规则:报价、日期、法律体系、形式要件 | FR-M3-04、06 |
| `ai` | 模型路由、提示词注册表、调用流水账、证据校验 | T1 |
| `report` | 检查报告的生成与归档 | FR-M3-09 |
| `qualification` | 最小资质库 | FR-M6-08 |
| `audit` | 审计事件 | FR-M6-07 |
| `ops` | 运营后台接口、误报 / 漏报反馈 | FR-M6-09 |
| `notification` | 站内通知(解析 / 检查完成、额度提醒、开通) | FR-M2-02、FR-M6-03 |
每个模块内部分三层:
- `api`:路由、请求校验、调用服务,一个接口通常不超过 30 行。
- `service`:业务规则和事务边界。
- `repo`:数据访问,**必须带租户上下文**。
模块之间的依赖方向用 import-linter 在 CI 里检查。
### 3.3 租户隔离
用三层防护:
1. **数据访问层强制带租户**:所有仓储方法的第一个参数都是 `TenantContext`,没有「传 0 就查全部」这种写法。跨租户查询只有运营后台能用,而且必须写审计事件。
2. **PostgreSQL 行级安全策略(RLS)兜底**:每个事务开始时设置当前租户,业务表的策略按 `tenant_id` 过滤。即使代码漏了条件,数据库也不会把别的租户的数据返回出去。
3. **测试**:每个接口都跑一条「用 A 租户的令牌访问 B 租户资源」的用例,返回必须是 404。
私有化部署只有一个租户,代码不需要改。如果客户的数据库是信创数据库、不支持行级安全策略,可以关掉第 2 层(AR3)。
### 3.4 异步任务与流水线
**原则:任务状态以 PostgreSQL 为准,队列只负责派发。**
- **建任务和入队在同一个事务里完成**:「预扣额度 → 建解析任务 → 入队」在一个数据库事务内提交。所以不会出现「扣了点但任务没进队列」或者「任务跑了但没扣点」。这是选用 PostgreSQL 做队列的主要理由(AR4)。
- **队列库用 Procrastinate**:基于 PostgreSQL 的成熟开源库,支持锁、重试、定时任务和队列优先级;不自己写队列。它靠 LISTEN/NOTIFY 及时发现新任务,所以 **worker 要直连数据库**,或者经过会话模式的连接池,不能走事务模式的连接池。
- **流水线分阶段、可续跑**:解析任务分为格式识别 → 转换 → 文字层 → OCR 补全 → 结构识别 → 抽取 → 证据校验 → 清单合并。每个阶段完成后把检查点写入数据库,失败后从失败的那个阶段续跑。每个阶段都要**幂等**:开始前先清掉这个阶段的残留产物。
- **过时结果直接丢弃**:任务带运行编号,写结果前比对;发现已经有更新的一次运行,就直接丢弃本次结果。
- **取消**:取消标记写在数据库里(权威),同时通过 Valkey 发一条消息,让 worker 立即知道。worker 在阶段之间、每批模型调用之间检查这个标记,不需要每 300ms 轮询数据库。即使消息丢了,worker 到下一个检查点也会读到数据库里的标记。
- **优雅退出**:worker 收到 SIGTERM 后不再领取新任务,等手头这个阶段跑完再退出;超时没跑完的任务由队列的租约机制自动交还给别的 worker。
- **进度推送**:worker 把进度写进数据库,再通过 Valkey 的发布 / 订阅通知;api 用 SSE 推给前端。多个 api 实例都能收到这条通知。前端断线后用轮询兜底,轮询读的是数据库里的进度。
- **并发**:worker 内部用 asyncio 并发调用大模型,每个供应商、每个租户都有并发上限(§3.6)。
### 3.5 文档处理流水线
这是产品的核心能力,也是技术风险最大的地方,§10 列出了需要先验证的事项。
```
上传 → 格式识别(看文件头,不看扩展名)
→ 预处理(旧版 DOC / WPS 先转 DOCX;修正被错标成 SYMBOL 字符集的中文字体,见技术验证 S1)
→ 转换(DOCX → PDF,用 Gotenberg + LibreOffice + 按宋体行高调整过的开源字体包)
→ 转换质量检查(大段 OpenSymbol 字体、抽出的文字大量重复同一个字 → 判为转换异常,不扣点)
→ 规范化 PDF(只生成一次,存哈希,以后不再重新生成;它是出处页码的唯一依据)
→ 文字层(逐字的坐标、字体、斜体、下划线;按行、句、块切分,给出稳定编号)
→ 逐页检查覆盖:文字太少的页交给 OCR(PaddleOCR),OCR 结果并入文字层
→ 结构识别(章节、表格、分包、「标注规则说明」)
→ 交给 ai 模块抽取 → 三道校验(编号、原文、依据)→ 标注类条款规则兜底 → 并入检查清单
```
关键设计:
- **文字层是一份带编号的数据**:每一句都有编号(文档 + 页 + 序号),并记录它在原文中的字符范围和坐标框。模型只能引用句子编号;高亮时按坐标框画,直接满足「原句高亮」(FR-M2-06)。
- **格式必须保留**:PDF 里的斜体靠字体名或字形倾斜判断,下划线靠文字下方的线条判断,加粗靠字体粗细判断。转成模型的输入时,这些格式变成文本标记(例如 `<u><i>…</i></u>`),对应样本结论 F1。DOCX 原文件里的格式信息可以用来交叉校验。
- **PDF 库选择**:用 pypdfium2(Apache / BSD)和 pdfplumber(MIT)。**不用 PyMuPDF**,它是 AGPL 许可,或者要买商业授权。
- **布局分析**:S2 已验证,**不用 Docling 做主解析器**:它从 DOCX 解析能保留格式但没有坐标,从 PDF 解析有坐标但丢了格式,CPU 上 199 页要 96 秒。文字层自己从规范化 PDF 提取(格式 + 坐标都有)。Docling 的表格结构识别可以作为表格切句的候选。MinerU 历史上用 AGPL 许可,选用前要核实它当前的许可证。
- **DOCX 交叉校验**:读 DOCX 文字时要同时读 `<w:t>` 和 `<w:sym>`(WPS 文档转换后有一部分正文是符号字符),并处理样式继承(S1、S2)。
- **字体与页码**:Word 转 PDF 的分页取决于字体。招标文件常用宋体、仿宋、黑体,这些是商业字体,不能随镜像分发。替代字体会导致分页和用户在 Word 里看到的不同。处理办法:
- 出处页码统一按规范化 PDF 计,界面上说明「页码以系统转换后的 PDF 为准」。平台导出的 DOCX(例如苏采云,由 ONLYOFFICE 生成)本来就没有记录 Word 里的页数,用户看到的分页取决于他们自己的软件,所以这条是必需的。
- S1 结论:分页偏差的主因是行高。用 Noto CJK 克隆出行高 1.0em 的「BD Song / BD Hei」(OFL,改名后仍按 OFL 分发),加 fontconfig 替换规则,有真值的 5 个数据点偏差都在 ±3% 内;默认配置偏差 +17%~+37%。
- 私有化时,客户如果有正版字体,可以挂载进去。
- **沙箱**:转换服务不开网络出口,不运行宏,限制 CPU、内存和处理时长;上传入口要拦截 zip 炸弹和超大文件。
### 3.6 AI 层(模型路由、提示词、证据校验)
**模型路由**
- 业务代码只声明能力和任务,例如 `ai.run(task="extract.qualification", capability=Capability.REASONING, ...)`。路由表决定实际用哪个供应商、哪个模型。
- 能力分类沿用 T1:`vision`(多模态)、`fast`(快速)、`reasoning`(深度思考);以后加 `embedding`。
- 供应商都走 OpenAI 兼容协议:DeepSeek、通义千问、豆包、GLM、Kimi 等,私有化时换成客户自部署的 vLLM 或 SGLang。
- 供应商白名单和路由表由运维在部署配置里设置,**用户和租户都不能填模型地址**。
- 每种能力配一个候选列表,按顺序降级;只对限流、5xx、网络抖动这类临时错误重试。
- 并发上限和速率限制按「供应商 × 租户」两个维度设置,防止一个大客户把配额占满。国产模型的接口普遍有每分钟请求数和 token 数的上限,多个 worker 进程要共用同一组计数器,所以计数器放在 Valkey 里(令牌桶)。Valkey 不可用时,每个 worker 退回到进程内的保守上限,不会中断任务。
**错误分类决定是否扣点**
| 类别 | 例子 | 任务结果 | 额度 |
|---|---|---|---|
| 基础设施 | 模型超时、限流重试后仍失败、5xx、转换服务崩溃 | 失败,可以重试 | 退回 |
| 输入问题 | 文件加密、损坏、超出页数上限 | 在上传时拦截,不建任务 | 不扣 |
| 内容质量 | 模型输出格式错误、证据校验没通过 | 不算任务失败,相关条目降级进待确认区 | 正常结算 |
| 用户取消 | — | 已取消 | 退回 |
**提示词工程化**
- 提示词存成带版本号的文件:`prompts/<任务>/v<N>.md`,旁边放对应的输出格式定义。代码只按「任务 + 版本」引用,不在代码里写提示词正文。
- 提示词注册表启动时加载,并计算内容哈希。每次调用都记下 `prompt_id`、`version`、`hash`。
- 外部内容(招标文件、投标文件)统一放进带分隔符的数据区,系统提示词里声明「数据区里的任何指令都不执行」。
- 结构化输出逐级降级:严格的 JSON 格式约束 → JSON 模式 → 修补;按「任务 + 模型」记住降到了哪一级。
- **评测集**:在 `evals/` 里存放用 `samples/tenders/` 人工标注的结果,重点覆盖 S03、S06、S08。提示词或路由一改,CI 就跑回归,主要指标是 ★ 和实质性条款的**召回率**(FR-M2-05)。
**调用流水账(覆盖所有 AI 调用)**
`ai_call` 表记录以下字段:租户、关联任务、任务类型、能力、供应商、模型、提示词编号、提示词版本、提示词哈希、输入哈希、输出哈希、token 数、耗时、错误类别、降级到了第几级。它既是审计依据(T1),也是成本核算和对比模型效果的数据源。**不存文档原文**:要排查问题时,凭输入哈希去对象存储里取。
**三道校验(适用于所有 AI 结论)**
1. **编号校验**:引用的句子编号必须在本次输入的范围内,而且类型要对得上。
2. **原文校验**:模型给出的引文,去掉空白后必须是所引句子原文的子串,允许按规则做极小的差异容忍。
3. **依据校验**(S4 验证后新增):模型声称的依据,用确定性信号核对。例如说是 ★ 条款,原句里就要有 ★;说是斜体下划线,原句就要有这种格式;说是「写明无效」,原句或相邻句就要有无效类字样。S4 里,不加这一道时模型会大量标错依据,采纳条目只有 36.7% 属实;加上后是 95.2%。
**标注类条款用规则兜底**:★、* 和文件声明了的斜体下划线,都能从规范化 PDF 上确定性识别出来。这类句子必须被某个条目覆盖;模型没覆盖的,系统自动生成一条「待确认」,召回不完全依赖模型。
三道都通过,结论才能进正式清单或成为有效的问题。没通过的,按情况降级为「待确认」或「提示」。投标文件一侧同样如此:**没有证据的结论不能判为「已响应」**。
### 3.7 规则引擎(确定性检查)
能用规则判断的,不交给模型:
- **报价**:超过三层限价、大小写金额是否一致、合计是否一致、是否只有一个报价。金额只从投标文件里读取,用 Decimal 计算,系统不生成任何报价数字。
- **日期**:落款日期是否合理,资质在投标截止日是否已经过期。
- **法律体系**:先识别项目属于政府采购体系还是招标投标体系,再按体系切换术语(「投标无效」或「否决投标」)和无效情形清单(05 §2)。
- **形式**:格式文件是否齐全,固定文字有没有被改动,文件里有没有出现其他项目的名称(这一步可以用模型召回,但判定结论要走证据校验)。
规则写成 Python 代码,每条规则有编号;规则集有版本,检查批次会记下当时用的规则版本(FR-M3-02)。
### 3.8 检查清单与重跑对齐
- **清单项有稳定编号**,出处(Citation)单独建表,清单项和问题都引用它(04 §2)。
- **重新解析时不删清单**:新结果先和原有清单项对齐(按类别、所属包、原文句子编号、文本相似度)。
- 对上的:只更新 AI 给出的字段,人工确认、修改和备注都保留。
- 对不上的旧项:标为「本次解析未出现」,交给用户处理,**不静默删除**。
- 新出现的项:进待确认区或正式清单。
- **人工值和 AI 值分开存**:用户修改不覆盖 AI 原值,留着用来做误报分析。
- **清单有版本**:确认清单时生成一个不可变快照,检查批次引用的就是这个版本(04 §4.2)。
- **复查沿用**:问题按「清单项编号 + 问题类型」匹配(FR-M3-08),不用列表顺序号。
### 3.9 额度
- 额度流水只追加,余额由流水汇总得出;另存一个缓存余额,和流水在同一个事务里更新,并定期对账。
- 每笔流水都带幂等键(任务编号 + 动作),重复执行也不会重复扣点。
- 任务的三种结局(成功结算、失败退回、取消退回)都由任务状态机统一触发,退不退回按 §3.6 的错误分类表决定。
- 24 小时免扣(决策 P1):每个项目记一个「上次扣点检查时间」,是否免扣由规则判断。
### 3.10 审计
- 审计事件和业务修改**在同一个事务里写入**,审计写失败,整个操作就失败;不能像标擎那样忽略写入错误。
- 字段:操作人、租户、动作、对象类型、对象编号、修改前、修改后、时间、请求编号。
- 审计表只追加;可以加哈希链(每条记录带上一条的哈希),让篡改可以被发现,作为可选项。
- 删除项目时,按 G10 只保留不含业务内容的审计记录:「修改前 / 修改后」里的业务字段要能单独抹掉。
### 3.11 安全
- **会话**(AR8):不用长期有效的 JWT,改用服务端会话。
- 令牌是 256 位随机串,只发给客户端一次;数据库里**只存它的 SHA-256 哈希**,数据库泄露也拿不到可用的令牌。
- 会话表 `user_session` 放在 PostgreSQL 里,字段包括:令牌哈希、用户、租户、设备信息、IP、创建时间、最近活跃时间、过期时间、吊销时间。
- 每个请求按令牌哈希做一次主键查询,耗时不到 1 毫秒。「最近活跃时间」最多每 5 分钟更新一次,避免每个请求都写库。
- 放在 PostgreSQL 而不是 Redis,理由见 §6.1:能持久化、能按用户列出所有会话(用来实现「同一账号异地多设备登录时提示并限制」,FR-M6-04),删除租户时也能级联清理。
- 网页端用 HttpOnly + Secure cookie,桌面端用 Bearer 令牌并存进系统钥匙串。
- **验证码和登录限流**:短信验证码、发送频率、登录失败次数都放在 Valkey 里,带过期时间。这些数据丢了,最多是用户重新获取一次验证码。
- **身份来源可插拔**:SaaS 用手机号 + 短信验证码;私有化用账号密码,并预留 OIDC、LDAP 接入(P2 的 SSO);飞书、企业微信登录按同一接口扩展(§3.14)。
- **密钥**:只从环境变量或密钥管理服务注入,代码和镜像里一律不放。启动时如果检测到默认密钥或弱密钥,在生产环境下拒绝启动。
- **调试开关**:「跳过登录」之类的开关只在测试配置里存在;生产环境检测到这类开关会直接退出。
- **加密**:
- 传输:全链路 TLS。
- 存储:云盘和 OSS 开服务端加密。文件另做**信封加密**,每个项目一把数据密钥(资质附件用企业级密钥,DM4),删除时销毁密钥,备份里的数据也就随之不可读,用来落实 G10 的「彻底删除」。
- 模型供应商的 API Key 加密存储。
- **运营后台**:独立应用、独立登录,加 IP 白名单和二次验证,所有操作都写审计。
- **日志脱敏**:日志和监控里只出现编号和哈希,不出现文档内容、引文、金额;有专门的脱敏中间件,加上测试。
### 3.12 报告
- 报告用 HTML 模板渲染,再交给 Gotenberg 里的 Chromium 转成 PDF。字体嵌入思源宋体和思源黑体(OFL)。
- 报告生成后不再修改,存哈希;重新导出就生成一份新报告(04 §2)。
- 模板在 CI 里做文案检查:不出现「通过 / 合格 / 保证」这类字样(FR-M3-09,红线 3)。
### 3.13 文档组件:参考标擎的做法
用户要求在 Office 解析、PDF 解析、标书组装和格式转换这三块参考开源项目。下面是标擎的做法(2026-11-03 阅读源码,路径相对于 `opensource-ref/bid-engine/`)和我们的取舍。只借鉴做法,不复制代码(许可证限制,见 `ref-bid-engine-analysis.md`)。
#### Office / Word 文件
**标擎的做法**
- **转换服务单独一个容器**(`bid-engine-backend/Dockerfile.converter`、`cmd/converter/`),主后端镜像里不装 LibreOffice。
- **转换服务的工程细节**做得扎实:
- 每次转换用独立的 LibreOffice 配置目录(`-env:UserInstallation`),避免并发转换时互相锁住;
- 超时后杀掉**整个进程组**,避免残留 `soffice.bin` 孤儿进程;
- 并发上限默认 2;服务端超时(240s)短于客户端超时(300s),保证错误先由服务端明确返回;
- 上传文件时流式发送,200MB 级的文档也不会整份读进内存。
- **LibreOffice 版本有坑**:Dockerfile 注释写明,LibreOffice 7.3 和 24.2 处理部分含内嵌 OLE 对象的公文类 docx 时会崩溃,换成 26.2 才正常,所以用了 ubuntu:26.04 作基础镜像。
- 字体用 `fonts-noto-cjk`;转换失败时依次回退到 soffice、unoconv(`pkg/repo/pdf/pdf_impl.go:235-300`)。
- DOC 先转成 DOCX,再用 gooxml 按样式识别标题,用来生成标书大纲(`lib/docx/docx.go`)。
- **不保留文字格式**:解析链路里没有处理斜体和下划线。
**我们的取舍**
- **采纳**:转换服务独立部署、不开网络出口、有并发上限、整组清理进程、分层超时、流式上传。这些 Gotenberg 已经内置。
- **要核实**:Gotenberg 镜像里的 LibreOffice 版本。S1 要专门用含 OLE 对象的公文类 docx 做测试;如果版本过旧导致崩溃,就自建转换镜像,沿用上面的工程做法,并锁定 LibreOffice 版本。
- **补上标擎没做的**:DOCX 原文件用 python-docx(MIT)读取文字和格式(斜体、下划线、加粗、样式),和规范化 PDF 的文字层交叉校验(§3.5,对应样本结论 F1)。
- **字体**:Noto CJK 只解决「字能显示」,解决不了「分页一致」:宋体和 Noto 的字宽不同,会导致分页偏移。所以 S1 要比较几种替代字体的分页偏差,标擎在这一点上没有处理。
#### PDF 文件
**标擎的做法**
- **用 Docling 做解析服务**:单独一个容器 `docling-serve` v1.16.1(MIT)。
- 请求参数:输出格式选 json、md、html、text,表格识别选精确模式,OCR 可开关(引擎默认是 easyocr,可选 rapidocr)(`pkg/repo/docling/docling_impl.go:176-231`)。
- 从结果里取文本块的坐标框和表格单元格。
- **按页检查覆盖率**:平均每页文字太少,就强制开 OCR 重新解析一次(`pkg/handler/bidanalysisv3/document.go:391-454`)。
- **PDF 拆页**用 pdfcpu(Apache 2.0)。
- **另有一个不开源的「PDF 服务」**:通过 `pdf_base_url` 加应用编号和密钥调用(`pkg/repo/pdf/pdf.go`),负责解析、生成 HTML 和字符索引。这部分能力离开它就跑不起来,参考价值有限。
- **注意**:compose 里给 docling 镜像写死了 `platform: linux/amd64`。
**我们的取舍**
- **采纳**:
- 解析服务化,主应用不装模型类重型依赖;
- 按页检查覆盖率,不够就用 OCR 补;
- 从解析结果里保留坐标框。
- **调整**:
- 坐标粒度从「块」细化到「句」(§3.5)。
- 文字层自己用 pypdfium2 和 pdfplumber 提取,字体、斜体、下划线信息由我们自己掌握。
- Docling 只作为版面和表格识别的候选,在 S2 里和自研方案对比。
- OCR 用 PaddleOCR:中文效果更好,坐标也更精细。
- **信创风险**:如果用 Docling,要确认它的镜像或依赖有 arm64 版本,标擎写死 amd64 就是一个信号。S2 要顺带验证。
#### 标书的组装、转码和格式转换(V1 的 M4,现在先定方向)
**标擎的做法**
- **编辑与生成**:
- 富文本编辑器用 TipTap 3。
- 前端用 `docx`(npm 包,MIT)把编辑器内容拼成 DOCX,包括封面、目录和样式(`bid-engine-frontend/components/file-gen/docx-export.ts`)。
- 导出 PDF 时,后端再把 DOCX 交给 LibreOffice 转换(`pkg/handler/bidgen/export.go`)。
- **两遍导出回填目录页码**:
- 前端拼 DOCX 时算不出分页,所以第一遍先把 DOCX 转成 PDF,在正文区按章节标题回读每章的起始页码(`pkg/handler/bidgen/page_map.go:30`);
- 第二遍再把真实页码写进目录。
- **模板导入**:上传 DOC 模板时先转成 DOCX,再按样式拆出大纲(`template_outline.go:74`)。
- **审核报告**用 excelize 导出为多个工作表的 Excel。
- **许可证风险**:前端依赖 TipTap Pro 的私有源(`.npmrc`),Pro 扩展是**收费商业组件**。
**我们的方向**
- **组装放在服务端(Python),不放前端**。理由有三:
- 标书要套用招标文件自带的格式文件(投标函、授权书、开标一览表),本质是「按模板填充 + 合并章节 + 附上资质扫描件」,服务端更好控制;
- 网页版和桌面版的输出能保证一致;
- 组装结果还要回过头来过 M3 的检查。
- **组件候选**(都是宽松许可证,V1 立项时再做验证):
- python-docx(MIT):读写 DOCX;
- docxtpl(LGPL-2.1):在 DOCX 里用模板语法填充,许可证要评审;不行就自己基于 python-docx 写填充;
- docxcompose(MIT):合并多个 DOCX,并保留样式和编号;
- pypdf(BSD):合并 PDF,例如在标书末尾附上资质扫描件。
- **目录页码**:标擎的「两遍导出」可以用,但更好的做法是:
- 在 DOCX 里写入**真正的目录域**;
- 转 PDF 时让 LibreOffice 先更新索引再导出,DOCX 和 PDF 的页码由此一致;
- 用户用 Word 打开 DOCX 时也能自己更新目录。
- 这个做法要验证;不行再退回两遍导出。
- **编辑器**:TipTap 核心是 MIT 许可,但不依赖它的 Pro 扩展;V1 立项时再和其他编辑器对比。
- **报价**:模板里的报价位置只放空白填充位,由用户自己填写(红线 6)。
- **OFD 格式**:标擎没有处理国家标准的 OFD 版式文件(GB/T 33190)。部分政府采购平台和电子公文会用到 OFD,需要在种子用户访谈时确认是否需要支持;需要的话再评估开源的 OFD 解析和转换库。
### 3.14 账号、企业与权限:给以后的版本留结构
以后会上团队版和企业版:一个企业多个席位,按角色和项目分权限;也会支持飞书、企业微信登录,以及私有化部署的 SSO。**这些功能早期版本都不做**。这一节只定一件事:MVP 的表结构和代码分层先按多人、多登录方式来建,以后上这些功能时只加功能,不用重构。
#### 原设计的问题
原设计的租户隔离(§3.3)和建在企业上的额度池(§3.9)不用改。会导致以后要重构的有三处:
1. **用户表直接带所属企业**(04 §2)。以后一个人要能有多种登录方式、要能离职、要能加入别的企业,就得拆表、迁移数据。
2. **权限只有租户这一层**。以后加项目级权限,要回头给每个接口补检查,还要改所有列表查询。
3. **记录「谁」的字段没约定指向哪张表**。如果指向会被删除的账号,人离职以后,留痕就断了。
#### MVP 就按这个结构建
| 内容 | MVP 的样子 | 以后接入时 |
|---|---|---|
| `user_account`:一个自然人,全局表,不带 `tenant_id` | 注册时建一条 | 不变 |
| `login_identity`:登录方式,字段有来源、外部组织编号、外部用户编号;唯一键是「来源 + 外部组织 + 外部用户」 | 只有「手机号」一种来源 | 飞书、企业微信、账号密码、OIDC、LDAP 都只是新的来源 |
| `membership`:人在企业里的成员身份,字段有企业角色、状态 | 每个企业一条,角色是「所有者」;「一人只属于一个企业」(FR-M6-04)在服务层检查,不写进表结构 | 加邀请、停用,放开一人多企业 |
| `user_session` 绑定 `membership_id` | 当前租户从成员身份上取 | 切换企业就是换一个会话 |
| `project_member`:项目、用户、项目角色 | 建项目的人自动成为负责人 | 加项目成员管理、可见范围 |
| 订阅带「席位数」 | 写 1 | 按席位计费 |
| 权限点枚举 + service 层统一调用 `authz.require(ctx, 权限点, 对象)` | 所有者拥有全部权限点,检查总是通过 | 加角色、改角色和权限点的对应关系;接口代码不用改 |
| 列表查询走 repo 层统一的可见范围过滤 | 过滤条件只有租户 | 加「只有项目成员可见」的条件 |
| `IdentityProvider` 接口:授权地址 → 回调换取用户信息 → 统一的外部身份 | 只有手机号验证码一个实现 | 每种新登录方式加一个实现 |
约定:
- 业务表里记录「谁」的字段(创建人、确认人、处理人)都指向 `user_account`。人不做物理删除,离职只停用成员身份。
- 额度流水和审计事件都记操作人;审计再存一份操作人姓名快照,人离职后,报告和留痕上仍显示当时确认的人。
- `user_account` 和 `login_identity` 是 §3.3 唯一的例外:全局表,不受行级安全策略约束,只能由 `identity` 模块读写,在 import-linter 规则里写明。
- 行级安全策略只管租户这一层,项目级权限在应用层判断。
这些在 MVP 里多出来的工作只是两三张表和一层检查调用,界面上看不出任何区别。
#### 接入时再定的事
下面这些不影响表结构,到对应版本立项时再设计:
- 团队版、企业版的具体角色和权限点划分(PRD 写的是管理员 / 编标 / 只读);
- 项目可见范围的默认值、分工指派;
- 飞书、企业微信的应用形态,外部组织怎么绑定到企业,新成员是否需要批准,账号怎么合并;
- 桌面端的第三方登录回调方式;
- 个人注销和企业注销的拆分(MVP 只有一个成员,两者是同一个操作)。
## 4. 前端
### 4.1 Vue 3 单页应用(AR2,已确认)
- **为什么是单页应用、不做服务端渲染**:
- 桌面客户端:单页应用打包后就是一堆静态文件,Electron 或 Tauri 直接加载即可;服务端渲染要多带一个 Node 进程。
- 私有化交付:静态文件由 nginx 托管就行,客户现场少一个 Node 服务要运维。
- 不需要 SEO:工作台在登录之后。以后的官网是独立站点,可以用 Astro 生成静态页。
- **为什么选 Vue 3**(用户决定):政企项目的前端人才和现成案例以 Vue 为主,组件库生态成熟,私有化客户的二次开发和运维人员也更熟悉。
### 4.2 技术选型
| 用途 | 选型 | 说明 |
|---|---|---|
| 构建 | Vite + TypeScript(严格模式)+ vue-tsc | |
| 写法 | 单文件组件 + `<script setup lang="ts">` + 组合式 API | 不用选项式 API |
| 路由 | Vue Router 4 | |
| 客户端状态 | Pinia | 只放界面状态(当前选中的清单项、筛选条件等) |
| 服务端数据 | TanStack Query for Vue(`@tanstack/vue-query`) | 缓存、重试、轮询兜底;服务端数据不放进 Pinia |
| 接口类型 | openapi-typescript + openapi-fetch | 由后端的 OpenAPI 文档生成,不手写类型 |
| UI 组件 | 自己写(`packages/ui`),不引入带样式的 UI 框架(AR10,见 §4.3) | 样式只来自 `tokens.css` 和 `components.css` |
| 交互基础组件 | Reka UI(原 Radix Vue,无样式) | 对话框、下拉选择、日期选择、弹出层、轻提示等的键盘操作和无障碍 |
| 表格 | 自己写样式;需要排序、筛选时用 TanStack Table,长列表用 TanStack Virtual | 都是无样式库 |
| 样式 | `docs/design/system/tokens.css` 的变量 + 单文件组件的 scoped CSS | 不引入 Tailwind,避免和组件库、设计规范形成第三套样式体系 |
| 图标 | Lucide(`lucide-vue-next`,U3) | 不用组件库自带的图标集,全站统一 |
| PDF | pdf.js(pdfjs-dist,Apache 2.0)+ 自研高亮层 | 按页虚拟滚动,按坐标框高亮原句 |
| 表单校验 | Zod + 自写的 `useForm` 组合式函数 | 校验时机按设计规范 7.13 |
| 测试 | Vitest + Vue Test Utils(单元测试)+ Playwright(端到端测试) | |
| 代码检查 | ESLint(flat config)+ Prettier | |
### 4.3 要不要引入 UI 框架(AR10)
**结论:不引入 Element Plus 这类带样式的 UI 框架。** 视觉全部由我们自己用 Vue 单文件组件和样式表实现;只在交互复杂、无障碍要求高的少数几类组件上,用**无样式交互组件库 Reka UI** 打底。
**为什么不用带样式的 UI 框架**
带样式的框架适合「直接用它自带外观」的项目,比如内部系统。我们有一套自己的高保真设计规范,用它反而吃亏,有三个原因:
1. **核心组件它根本不提供**:出处批注、问题行、清单项、原文查看器、参数对照表、项目行、额度、任务进度(设计规范 7.2~7.11),都是产品专属组件,不管用不用框架都得自己写。框架能覆盖的只是外围的基础件。
2. **基础件的默认行为和规范冲突**,几乎每一个都要改造:
| 设计规范的要求 | 带样式框架的典型默认行为 |
|---|---|
| 没有红色危险按钮(7.1) | 提供 danger 按钮,删除确认框默认用红色 |
| 加载中按钮把文字换成「正在…」,宽度不变(7.1) | 在按钮里加转圈图标,宽度会变 |
| 必填项在标签后写「(必填)」,不用红色星号(7.13) | 默认显示红色星号 |
| 错误提示:赭色边框 + 下方赭色小字 + 「!」图标(7.13) | 红色边框,错误文字的位置和结构是固定的 |
| 轻提示 4 秒后消失,悬停暂停;页内提醒用左边线样式(7.12) | 自带一套提示样式和图标 |
| 级别靠实心、描边、无框来区分(3.3) | 标签组件只有几种固定的视觉变体 |
改造的办法是覆盖框架的内部样式(例如 `:deep(.el-input__wrapper)`),要和框架的选择器比优先级。**框架一升级、内部结构一变,这些覆盖就可能悄悄失效。**
3. **两套样式体系并存**:框架有自己的变量、尺寸档位和间距,和 `tokens.css` 并存,开发时要不断判断「该用谁的」,很容易偏离设计稿。
**为什么也不完全手写**
纯手写对外观没问题,但少数几类组件的**交互和无障碍**很难写对,这类代码写得不对,就会在键盘操作、屏幕阅读器、浮层定位上不断出边角问题:
| 组件 | 难点 | 用在哪里 |
|---|---|---|
| 对话框 | 焦点锁定、关闭后焦点回到原处、Esc 关闭、背景禁止滚动 | 删除确认、忽略原因(7.14) |
| 下拉选择、可搜索的下拉框 | 键盘上下选择、输入即搜索、浮层定位和翻转 | 分包选择、资质类型 |
| 日期 / 日期时间选择 | 日历键盘导航、中文本地化、日期范围 | 关键日期修改、资质有效期 |
| 弹出层、提示气泡、下拉菜单 | 定位、碰撞检测、悬停延迟、外部点击关闭 | 出处预览、行内更多操作 |
| 轻提示 | 队列、悬停暂停、读屏播报 | 操作结果(7.12) |
| 标签页、复选框、单选、开关 | 方向键切换、ARIA 状态 | 清单分类、批量勾选 |
**中间方案:无样式交互组件库(headless)**
这类库只提供行为、键盘操作和无障碍属性,**不带任何样式**;外观完全由我们自己写,和设计规范没有冲突。
| 候选 | 最新版本 | 最近发版 | npm 周下载 | 许可证 | 判断 |
|---|---|---|---|---|---|
| **Reka UI**(原 Radix Vue) | 2.11.0 | 2026-10-05 | 约 285 万 | MIT | **推荐**:组件最全(对话框、下拉选择、可搜索下拉框、日期选择、弹出层、菜单、轻提示、标签页等),Nuxt UI 和 shadcn-vue 都基于它 |
| Ark UI(Vue 版) | 5.40.0 | 2026-10-29 | 约 4 万 | MIT | 备选:基于状态机,质量好,但 Vue 社区规模小 |
| Headless UI(Vue 版) | 1.7.23 | 2024-09-09 | — | MIT | 不选:两年没有发版,组件也少 |
(2026-11-03 查询 npm。shadcn-vue 也基于 Reka UI,但它依赖 Tailwind,和我们「只用 tokens.css」的约定冲突,所以不用。)
**最终方案**
```
packages/ui/
tokens.css、base.css ← 来自 docs/design/system/,作为唯一的样式来源
基础件(自己写,纯 Vue + 样式表)
Button、Tag、SeverityTag、Card、Alert、Input、Textarea、Field、
Table、Pagination、Empty、Skeleton、Progress、Steps……
交互件(Reka UI 打底,外观自己写)
Dialog、Select、Combobox、DatePicker、Popover、Tooltip、
DropdownMenu、Toast、Tabs、Checkbox、RadioGroup、Switch
业务组件(自己写)
Citation(出处)、FindingRow(问题行)、ChecklistItem、
PdfViewer(pdf.js + 高亮层)、ParamCompareTable、ProjectRow、
QuotaMeter、TaskProgress、AiMark……
```
其余配套库也都选无样式的:
- **表格**:项目列表、清单、问题列表都是自己写的表格样式。需要排序、筛选、列宽调整时,用 TanStack Table(无样式);长列表用 TanStack Virtual 做虚拟滚动。
- **表单**:用 Zod 定义校验规则,配一个自写的 `useForm` 组合式函数,实现设计规范 7.13 的校验时机(离开输入框时校验、提交时汇总)。表单变复杂后再评估 VeeValidate。
- **图标**:Lucide(`lucide-vue-next`,U3)。
- **浮层定位**:Reka UI 内置了 Floating UI,不用另外引入。
**工作量评估**
- 设计规范第 7 节和原型 `docs/prototype/` 已经把样式和结构定好了,`components.css` 可以直接拿来用。所以基础件和交互件主要是「把现有的样式封装成 Vue 组件」,预计 **2~3 人周**,并且可以和页面开发并行。
- 用 Element Plus 起步看似更快,但要改造的组件和要写的覆盖样式加起来,加上后续升级的维护,不会更省;而业务组件那部分工作量,两种方案完全一样。
- 质量保证:每个组件写一个预览页,继承现有的 `components.html`,作为视觉对照和回归检查;交互件补上键盘操作的单元测试。
**运营后台**:不在这一阶段讨论,等做运营后台时再定。它是独立应用(`apps/admin`),用不用带样式的框架都不影响用户端。
### 4.4 应用划分
- `apps/web`:用户端(工作台、读标、查标、额度、资质库)。
- `apps/admin`:运营后台,单独构建、单独部署,私有化时不交付;技术选型等做到时再定。
- `packages/ui`:基础件、交互件(Reka UI 打底)和业务组件,样式只来自设计规范,见 §4.3。
- `packages/api-client`:生成的接口客户端。
- `packages/platform`:平台能力的抽象层,见 §5.2。
### 4.5 代码规模约定
吸取参考项目的教训:单文件组件超过 400 行要拆分,页面组件只负责组合;服务端数据由 TanStack Query 管理,不在页面里堆一堆 `ref` 和定时器。
## 5. 桌面客户端(T3:连服务端的客户端外壳)
### 5.1 形态
桌面端 = 本地外壳 + **随安装包附带的同一套前端静态文件** + 连接 SaaS 或私有化服务器。外壳不内嵌后端、OCR 和模型。
外壳只额外提供几样本地能力:
- 选择文件或文件夹、拖入大文件;
- 系统通知;
- 报告存到本地并打开;
- 登录令牌存进系统钥匙串;
- 自动更新(SaaS)或离线升级包(私有化);
- 首次启动时选择服务器地址(SaaS 或私有化服务器)。
### 5.2 现在就要守住的约束(保证以后打包成本低)
1. **前端不能依赖服务端渲染**,必须是纯静态构建产物(§4.1)。
2. **接口地址可配置**:不能写死「同源 `/api`」,从运行时配置里读取。网页版默认同源,桌面版用用户选择的服务器地址。
3. **鉴权同时支持 cookie 和 Bearer 令牌**(§3.11)。桌面外壳以自定义协议加载页面时,跨域 cookie 不可靠。
4. **所有平台能力都走 `packages/platform` 抽象层**:文件选择、下载保存、通知、打开外部链接、剪贴板。现在只实现网页版,以后加桌面版实现,业务代码不用改。
5. **不依赖浏览器专有能力**:PDF 预览用 pdf.js 自己渲染,不用浏览器内置的 PDF 查看器;字体全部自托管(已经这样做了);不依赖第三方 CDN。
6. **路由在 `file://` 或自定义协议下也能用**:路由基础路径可配置。
7. **大文件上传**:用分片上传、支持断点续传,接口设计成和平台无关的样子。
### 5.3 外壳选型(AR6,到时再定)
| | Electron | Tauri 2 |
|---|---|---|
| 渲染引擎 | 自带 Chromium,各平台显示一致,和网页版测试的 Chrome 一样 | 用系统的 WebView:Windows 上是 WebView2,macOS 上是 WebKit,Linux 上是 WebKitGTK,各平台有差异 |
| 安装包大小 | 约 100MB | 约 10MB |
| 信创 Linux(UOS / 麒麟) | 有 arm64 构建;LoongArch 要靠社区构建,需要实测 | 依赖系统自带的 WebKitGTK 版本,需要实测 |
| 本地能力开发 | Node.js | Rust |
**倾向 Electron**:读标和查标高度依赖 pdf.js 渲染和精确高亮,渲染一致性比安装包大小更重要;团队也不必再学 Rust。如果到时信创环境对体积敏感,或者 WebKitGTK 实测没问题,再考虑 Tauri。因为有 §5.2 的约束,两者切换的成本都不高。
**估计工作量**:外壳 PoC 约 1 周,加上代码签名(Windows EV 证书、Apple 公证)、自动更新和信创打包,正式版约 3~4 周。
## 6. 配套组件
**许可证策略**:MIT、BSD、Apache 2.0、MPL、OFL 可以直接用。AGPL、SSPL、BSL 以及商业许可要先评审,因为私有化交付属于「分发」。CI 里做依赖许可证扫描,并生成 SBOM(软件物料清单)。
| 组件 | 选型 | 许可证 | SaaS | 私有化 | 不选什么、为什么 |
|---|---|---|---|---|---|
| 数据库 | PostgreSQL 16 以上 | PostgreSQL License | 云厂商托管实例 | 官方镜像,或客户已有的实例 | MySQL:行级安全策略、JSONB、LISTEN/NOTIFY 都不如 PostgreSQL;而且主流信创数据库(人大金仓、openGauss / GaussDB、瀚高)多数兼容 PostgreSQL |
| 任务队列 | Procrastinate(基于 PostgreSQL) | MIT | 同左 | 同左 | Celery + Redis:入队和数据库事务无法原子提交;自研队列:标擎的教训 |
| 缓存、限流、推送 | Valkey 8(§6.1) | BSD | 托管的 Redis 兼容服务 | 官方镜像,不持久化 | Redis:7.4 以后改了许可证(RSAL / SSPL,Redis 8 又加了 AGPL),私有化分发有风险;Valkey 是 Linux 基金会维护的分支,协议兼容 |
| 对象存储 | S3 协议;本地磁盘驱动 | — | 云厂商的 OSS / COS / OBS | 单机用本地磁盘;集群用客户已有的 S3,或 SeaweedFS(Apache 2.0) | MinIO 社区版:2025-10 起不再发布镜像和二进制,随后停止维护,许可证还是 AGPL |
| 文档转换 | Gotenberg(LibreOffice + Chromium) | MIT(LibreOffice 是 MPL) | 同左 | 同左 | 自己封装 LibreOffice:并发和稳定性要自己处理。如果 Gotenberg 里的 LibreOffice 版本太旧,就按标擎的做法自建转换镜像(§3.13) |
| Office 读写 | python-docx、docxcompose;V1 再评估 docxtpl | MIT、MIT、LGPL | — | — | 标擎在前端用 `docx` 拼文件:模板填充和网页版、桌面版输出的一致性都不好控制(§3.13) |
| OCR | PaddleOCR | 历史上是 Apache 2.0,以仓库为准 | CPU 版,量大时上 GPU;也可以用多模态大模型 | CPU 或 GPU 版 | 云 OCR 接口:私有化时用不了 |
| PDF 处理 | pypdfium2、pdfplumber | Apache / BSD、MIT | — | — | PyMuPDF:AGPL |
| 反向代理 | nginx | BSD | 同左,前面再放云负载均衡 | 同左 | Caddy 也行,但政企运维更熟悉 nginx |
| 监控 | OpenTelemetry + Prometheus + Grafana + Loki | Apache / AGPL | 自建或用云厂商的监控 | **不随包交付**:只暴露指标接口和日志文件,由客户接入自己的监控 | — |
| 错误追踪 | GlitchTip 或云厂商服务 | MIT | 内部使用 | 不交付 | Sentry 自部署版是 FSL 许可,内部使用没问题,但不分发 |
### 6.1 要不要引入 Redis(AR4 复议)
v0.1 建议「MVP 不引入 Redis」。用户追问了两点:会话令牌存在哪里?不引入 Redis 到底能省多少?重新评估后的结论是:**引入 Valkey,但只让它存丢了也没关系的数据。**
**先把两件事分开。** v0.1 的建议里混了两个决定:
1. **任务状态和队列放在 PostgreSQL**:「预扣额度 + 建任务 + 入队」在一个事务里原子提交。这是额度红线的保障,**保留不变**。
2. **整个系统不要 Redis**:这一条才是复议的对象。
第 1 条的收益和有没有 Redis 无关。把两者分开看,「不要 Redis」本身能省下的东西很少。
**不引入 Redis 能省多少**:
| 方面 | 能省下的 | 实际分量 |
|---|---|---|
| SaaS 成本 | 一个小规格的托管 Redis 实例,每月几百元 | 小 |
| SaaS 运维 | 托管服务基本免运维 | 很小 |
| 私有化 | 少一个容器(内存约 100MB);只要不让它存权威数据,就不用备份,也不用做高可用 | 小 |
| 代码 | 少一个客户端库 | 很小 |
**不引入 Redis 要付出的代价**(MVP 里确实会遇到的需求):
| 需求 | 只用 PostgreSQL 怎么做 | 问题 |
|---|---|---|
| **模型接口限速**:国产模型普遍限制每分钟请求数和 token 数,多个 worker 要共用计数器 | 用表加行锁或咨询锁模拟令牌桶 | 高频写入,锁竞争重,实现别扭 |
| **短信防刷、登录限流** | 计数表按时间窗口 upsert | 攻击流量会直接打到主库上 |
| **SSE 进度推送到多个 api 实例** | LISTEN/NOTIFY | 每个实例要单独占一条长连接;云厂商的托管 PostgreSQL 前面常有连接池代理,事务模式下用不了 LISTEN |
| 验证码、短期缓存 | 带过期时间字段的表,再加定时清理 | 能做,但要自己处理过期 |
**结论**:不引入 Redis 省下的只有「一个轻量容器」;而限速、防刷、多实例推送这三件 MVP 必做的事,用 PostgreSQL 做都很别扭。**值得为它付出一个容器的代价**,前提是守住下面的边界。
**Valkey 的使用边界**:
| 放在 Valkey | 放在 PostgreSQL(权威) |
|---|---|
| 模型接口的限速计数器、并发信号量 | 用户、租户、会话 |
| 短信验证码、发送频率、登录失败计数 | 项目、清单、问题、审计 |
| 进度和取消的推送通知 | 任务状态、进度、取消标记 |
| 短期缓存(例如规则配置) | 额度账户和流水 |
| | 任务队列(Procrastinate) |
判断标准:**Valkey 被清空后,系统照常运行,最多是用户重新获取一次验证码、限速计数从零开始**。所以 Valkey 不开持久化、不做备份;私有化时就是一个普通容器。
**会话为什么放在 PostgreSQL 而不是 Valkey**(回答「令牌存在哪里」):
- **持久**:Valkey 一重启,所有人就都掉线了;放在数据库里没有这个问题。
- **要做关系查询**:「同一账号异地多设备登录时提示并限制」要按用户列出所有会话;运营后台要查登录记录;删除租户时要级联清理。这些都是关系型操作。
- **性能不是问题**:每个请求一次主键查询,不到 1 毫秒;B 端的并发量远远够用。真要优化,再在 Valkey 里加一层短期缓存,并在吊销会话时主动清掉。
具体设计见 §3.11。
### 6.2 不引入的组件
不引入的组件和理由:
- **Elasticsearch**:投标文件里的检索范围只是单个项目的几百页,在内存里用 BM25 加 jieba 分词就够了。
- **独立向量库**:MVP 不需要,V1 的知识库先用 pgvector。
- **Kafka、RabbitMQ**:消息量太小,用不上。
## 7. 部署与运维
### 7.1 一套镜像,三种形态
| 形态 | 编排 | 数据库、存储 | 用在 |
|---|---|---|---|
| SaaS 内测(T4) | Docker Compose,1~2 台云服务器 | 托管 PostgreSQL + 托管对象存储 | 种子用户阶段;顺带打磨私有化的 Compose 交付 |
| SaaS 商用 | 托管 K8s + Helm chart | 托管 PostgreSQL(高可用)+ 托管对象存储 | 公测以后 |
| 私有化 | 小客户用 Compose 单机;有 K8s 的客户用 Helm | 自带 PostgreSQL + 本地磁盘 / 客户的 S3 | P2 企业版 |
- **Compose 和 Helm 用同一批镜像、同一份配置结构**。配置项定义只写一份,用来生成 `.env` 模板和 Helm 的 `values.yaml`。
- **CI 每次都要验证两种编排**:在 CI 里用 Compose 起一整套服务跑冒烟测试;Helm chart 在 kind 集群里做安装测试。
- **镜像从第一天起就同时构建 amd64 和 arm64**:鲲鹏、飞腾这类信创服务器都是 arm64。
### 7.2 配置与发布
- 配置全部来自环境变量,启动时用 Pydantic Settings 校验,缺项或者值不合法就直接退出。
- **数据库迁移**作为一个一次性任务,在新版本上线前执行;采用「先扩后缩」的方式(先加列,再切换代码,最后删旧列),支持滚动发布。
- 健康检查分两种:存活检查(进程活着)和就绪检查(数据库能连上、迁移版本一致)。
### 7.3 私有化交付包
- **离线安装包**:所有镜像(`docker save` 导出)+ Compose 文件或 Helm chart + 安装脚本 + 校验和 + 签名。字体、OCR 模型都打进镜像,安装时不需要联网。
- **授权文件**:用 Ed25519 签名,内容包括客户名称、有效期、可用功能;离线校验,过期后系统改为只读。
- **代码保护**(AR7):私有化镜像里的 Python 核心包用 Nuitka 编译成二进制,提示词文件加密打包。
- **大模型**:路由层改指向客户自部署的服务(vLLM、SGLang、Xinference 上跑的 Qwen、DeepSeek 等),或者客户批准使用的云端接口。
- **运维脚本**:备份(`pg_dump` + 文件目录)、恢复、升级(含迁移)、一键导出诊断包(日志、版本、配置,自动脱敏)。
### 7.4 信创适配(企业版可选,P2)
- **CPU 和操作系统**:鲲鹏、飞腾(arm64)的镜像从第一天就构建。龙芯(LoongArch)要按需评估,难点是 Python 依赖在 loong64 上有没有现成的 wheel 包(龙芯有自己的 PyPI 镜像,需要实测)。操作系统是麒麟或 UOS,容器运行时用 Docker 或 iSulad。
- **数据库**:核心 SQL 只用 PostgreSQL 的常见子集,不依赖小众扩展,确保能迁到人大金仓、openGauss、瀚高。行级安全策略、LISTEN/NOTIFY、Procrastinate 在这些数据库上的兼容性,等第一个信创客户出现时专门验证(AR3)。
- **中间件**:如果客户要求用东方通、宝兰德这类国产 Web 中间件,只把 nginx 那一层换掉。
### 7.5 可观测性
- 用 OpenTelemetry 打通 api → worker → 大模型调用的完整链路。
- **核心指标**:
- 各解析阶段的耗时,P95 对照 10 分钟的目标;
- 队列长度;
- 各模型供应商的错误率、耗时和 token 用量;
- 证据校验的通过率,它下降意味着提示词或模型出了问题;
- 额度对账的差异。
- 日志统一用 JSON 格式,带请求编号、租户编号、任务编号,**不带文档内容**(§3.11)。
### 7.6 备份与容灾
- SaaS:托管 PostgreSQL 开时间点恢复(PITR);对象存储开版本控制。RPO、RTO 的目标在商用前确定。
- 删除项目时同步清理对象存储,并销毁对应的数据密钥(§3.11),保证备份里的数据也无法再读取。
## 8. 代码仓库与工程规范
采用单一仓库(monorepo),前端用 pnpm workspace,后端用 uv workspace:
```
apps/
web/ # 用户端单页应用
admin/ # 运营后台
desktop/ # 桌面外壳(以后再建)
packages/
ui/ # 设计规范组件库
api-client/ # 由 OpenAPI 文档生成的接口客户端
platform/ # 平台能力抽象层(网页版 / 桌面版)
server/
src/bid_doctor/
identity/ billing/ project/ document/ checklist/ check/
rules/ ai/ report/ qualification/ audit/ ops/
core/ # 配置、数据库、租户上下文、错误、日志
migrations/ # Alembic 迁移脚本
tests/
services/
ocr/ # PaddleOCR 服务封装
prompts/ # 带版本号的提示词与输出格式定义
evals/ # 评测集(标注数据 + 评测脚本)
deploy/
compose/ # Docker Compose
helm/ # Helm chart
offline/ # 私有化离线包构建脚本
docs/ # 现有文档
```
- **合并代码的前提**:类型检查通过、单元测试通过、租户隔离用例通过;改了提示词或路由的,还要跑评测回归。
- **测试分层**:
- 规则引擎和证据校验用单元测试覆盖,要求接近 100%;
- 接口用集成测试,跑在真实的 PostgreSQL 上,用 testcontainers;
- 关键流程用端到端测试:上传 → 解析 → 检查 → 导出。
- **大模型在测试里用录制回放**,CI 不调用真实模型;评测回归单独跑,需要真实模型。
## 9. 关键数据设计要点
物理建模在下一步细化,这里先定原则:
- 主键用 **UUIDv7**:按时间有序、不可猜测;私有化环境和 SaaS 之间迁移数据时也不会冲突。
- 所有业务表都带 `tenant_id`、`created_at`、`updated_at`;时间一律用 `timestamptz`。例外只有 `user_account` 和 `login_identity` 两张全局表(§3.14)。
- 业务表里记录「谁」的字段(创建人、确认人、处理人)都指向 `user_account`,不指向成员身份;人不做物理删除。
- 出处(Citation)存:文档编号、规范化 PDF 的哈希、页码、句子编号、字符范围、坐标框数组(`quads`)。
- AI 值和人工值分开存(§3.8);状态字段用枚举,不用自由文本。
- 文字层(逐字坐标、格式、句子切分)存在对象存储里,按页分片;**句子不进数据库**(DM1),数据库只存页摘要和出处快照(原句、坐标框)。
- 完整的表结构见 `data-model.md` 和 `schema-draft.sql`。
## 10. 编码前要做的技术验证
| # | 验证内容 | 通过标准 | 影响 |
|---|---|---|---|
| S1 | Word 转 PDF 的保真度:用 10 份样本,加上含内嵌 OLE 对象的公文类 docx,比较替代字体下的分页偏差,并核实 Gotenberg 里的 LibreOffice 版本 | 转换不崩溃;分页偏差可接受,或者有可用的字体替换表 | §3.5、§3.13 |
| S2 | 在规范化 PDF 里识别斜体和下划线,并和 DOCX 原文件的格式交叉校验;Docling 和自研方案对比,同时确认 Docling 有没有 arm64 版本 | S06、S08 的实质性条款能全部识别出来 | FR-M2-05、07 F1 |
| S3 | 原句级的坐标切分和高亮 | 抽查 50 条,位置都对 | FR-M2-06 |
| S4 | 国产模型的结构化输出和证据校验:在 S01–S10 上测 ★ 和实质性条款的召回率,同时测成本 | 召回率目标在验证时定;同时给出 D2 额度定价所需的单次成本 | FR-M2-05、D2 |
| S5 | PaddleOCR 处理扫描件:中文准确率、坐标精度、CPU 上的耗时 | 300 页在 10 分钟内 | FR-M2-02 |
| S6 | 用多模态模型检测签章「位置与有无」 | 给出准确率,决定 G12 怎么做 | FR-M3-04 |
| S7 | Procrastinate 的稳定性:worker 被杀掉、并发领取任务、优雅退出 | 不丢任务、不重复执行 | §3.4 |
S1–S4 风险最高,建议先做;S8(桌面外壳 PoC)等桌面版立项时再做。
**S1–S4 已于 2026-11-03 完成**,详见 `tech-validation.md`,代码在 `spikes/`。结论:
| # | 结论 | 关键数字 |
|---|---|---|
| S1 | 可行,但要加两步:修正 WPS 错标的字体字符集,换成按宋体行高调整过的开源字体。Gotenberg 有 arm64 镜像,自带 LibreOffice 26.8.1;10 份文件都没有崩溃 | 默认配置偏差 +17%~+37%,S07 正文空白;调整后有真值的 5 个数据点都在 ±3% 内。本批样本没有含 OLE 嵌入对象的文件,未测 |
| S2 | 可行,在规范化 PDF 上识别格式,不需要 Docling | 斜体且下划线:4 份文件按字符 100% 一致 |
| S3 | 正文可行;表格内和页眉页脚带要改进切句 | 几何回读 300/300;文本保真:正文 97.5%,表格 78% |
| S4 | 可行,加上第三道依据校验和规则兜底 | ★ 召回 93.5%(扣除银标准噪音后约 96.6%),斜体下划线 99.8%;采纳条目 95.2% 属实;单项抽取每份约 0.15 元(DeepSeek Flash,高峰价) |
据此建议:FR-M2-05 标注类条款召回 ≥ 99%(规则兜底 + 模型),采纳条目准确率 ≥ 95%;FR-M2-06 出处页码正确率 100%,原句高亮准确率正文 ≥ 98%、表格 ≥ 95%(`tech-validation.md` §5)。
## 11. 架构决策清单
| # | 事项 | 结论 | 状态 |
|---|---|---|---|
| AR1 | 架构形态 | 模块化单体 + 按负载拆进程,不拆微服务(§2.1) | 已确认 |
| AR2 | 前端框架 | **Vue 3 + TypeScript + Vite 单页应用**,不做服务端渲染(§4.1) | 已确认(用户改为 Vue 3) |
| AR3 | 数据库 | PostgreSQL,并以兼容 PostgreSQL 的信创数据库作为迁移路线;行级安全策略作为第二层隔离,在不支持它的信创数据库上可以关掉 | 已确认 |
| AR4 | 任务队列与 Redis | 任务状态和队列放在 PostgreSQL(Procrastinate);**引入 Valkey,只存丢了也没关系的数据**(限速、验证码、推送、缓存);会话放在 PostgreSQL(§6.1) | 已确认(修订后) |
| AR5 | 对象存储 | S3 协议 + 本地磁盘驱动;不用 MinIO | 已确认 |
| AR6 | 桌面外壳 | 倾向 Electron,桌面版立项时再定;现在守住 §5.2 的 7 条约束 | 已确认 |
| AR7 | 私有化代码保护 | 用 Nuitka 编译核心包 + 签名授权文件 + 提示词加密 | 已确认 |
| AR8 | 会话机制 | 服务端会话(可吊销),令牌只存哈希;网页端用 cookie,桌面端用 Bearer 令牌 | 已确认 |
| AR9 | 仓库组织 | 单一仓库(monorepo) | 已确认 |
| AR10 | UI 组件 | **不引入带样式的 UI 框架**:视觉自己写,复杂交互用 Reka UI(无样式)打底;表格、表单也用无样式方案(§4.3)。运营后台另议 | 已确认 |
| AR11 | 身份模型 | 拆成人(`user_account`)、登录方式(`login_identity`)、成员身份(`membership`)三层,MVP 就按这个结构建;「一人只属于一个企业」在服务层限制(§3.14) | 已确认 |
| AR12 | 权限检查点 | MVP 就建 `project_member` 表,service 层统一调用 `authz.require`,列表查询统一按可见范围过滤;具体角色划分到团队版再定(§3.14) | 已确认 |
## 12. 下一步
1. 做技术验证 S1–S4,结论写进本文件,并据此确定 FR-M2-05、FR-M2-06 的目标值。
2. ~~物理数据建模~~:✅ 已完成,见 `data-model.md`、`schema-draft.sql`(DM1–DM7 已确认)。
3. ~~定接口契约~~:✅ 已完成,见 `api-design.md`、`openapi.yaml`(API1–API5 已确认)。
4. ~~搭工程骨架~~:✅ 已完成,见 §13 和 AGENTS.md「工程」一节。
## 13. 工程骨架的落地记录(2026-11-03)
搭骨架时按代码实际情况做了下面这些调整或补充。SK1、SK2 改了上文的写法,待确认;其余是实现细节,记在这里备查。
| # | 事项 | 做法 | 状态 |
|---|---|---|---|
| SK1 | 进度推送(§3.4) | 用 **Valkey stream** 代替发布 / 订阅:一个结构同时解决实时推送和断线补发(按 `Last-Event-ID` 续读),多个 api 实例都能读到。Valkey 丢了也只是没有推送,前端轮询兜底 | 待确认 |
| SK2 | 接口集成测试(§8) | 不用 testcontainers,改为连 Compose 起的 PostgreSQL,每次测试会话重建 `bid_doctor_test` 库;CI 里用 GitHub Actions 的服务容器。更快,本地和 CI 走同一套配置 | 待确认 |
| SK3 | 建任务和入队在同一个事务里(§3.4) | Procrastinate 的 `defer` 用的是它自己的连接,进不了我们的事务,所以直接调用它的 SQL 函数 `procrastinate_defer_jobs_v1`(`core/queue.py`);它的表放在 public schema。pyproject 锁定了大版本,升级时要核对 | 已实现 |
| SK4 | 上传会话(§5.2 第 7 条) | 上传会话的元数据放在 Valkey(24 小时过期),分片按企业级数据密钥加密后写进对象存储;挂到项目上时换成项目密钥 | 已实现 |
| SK5 | ORM 模型 | 表结构以迁移为准,ORM 模型只做列映射、不声明外键,所以写入顺序要自己 `flush` 保证(测试里踩到过两次) | 编码约定 |
| SK6 | 文字层 | 有的 PDF(如 Chromium 生成的)把汉字映射成康熙部首码位(「目」成了 U+2F6C),引文校验会对不上;文字层把康熙部首和部首补充区换回常用汉字,其他字符不动 | 已实现 |
| SK7 | 斜体下划线(07 F1) | 只有文件的「标注规则说明」声明过斜体下划线标注,才按它认定实质性条款;没声明的文件里,标题、表头也常是斜体或下划线(S02 实测踩到) | 已实现 |
| SK8 | 登录验证码 | 登录和注册共用一个验证码;会话建好之后才作废,用户确认挤下线(API3)或发现「还没注册」时不用重新获取 | 已实现 |
| SK9 | 读标抽取(2026-11-03 补) | 五类清单(资格、无效 / 实质性、评分、日期与递交、技术参数)加项目基本信息各一个抽取任务、一份提示词(`prompts/extract.*`),分块并发调用;每类都过三道校验,第三道按类别核对:实质性条款核对标注或「无效」字样,其他类别核对日期、分值、参数值、金额在原文里找得到,是 / 否字段核对说法。各任务抽到同一条要求时按首句合并 | 已实现 |
| SK10 | 文字层 v2 与结构识别 | 页眉页脚带(页码、各页重复的行)单独成句,不并进正文;标记写到页尾的句子,和下一页首句接续成一条要求(出处两页各一条);最小结构识别:十进制分级编号和冒号引出的列表找上级条款,上级条款写明无效情形的,下面的条目照样认定。文字层 v1、v2 的句子编号不同,规范化 PDF 不变 | 已实现 |
| SK11 | 待确认审计后的修正(2026-11-03) | 国动样本 73 条待确认里多数是好结果被校验误拦,逐条审计后按「规则只修真正的缺陷、不迁就模型的错误输出;模型输出不合理的改提示词」处理。**文字层 v3**:两端接竖线的横线是表格框线,不算下划线(功能表整格被误认成斜体下划线条款);句号后紧跟的右括号、右引号并进同一句(切句 s3-v3,句子编号和 v2 不同)。**结构识别**:中文编号小节(「三、……否则投标无效」)下的 1. 2.,以冒号结尾的编号条目下的句子;依据核对沿上级链往上找。**校验**:比对引文前去掉我们自己加进输入的格式标记和〔…〕附注;不足 8 个字的短句整句复制时句末标点可省;取值核对认带量词的中文数字、同一行的单元格和评分因素标题;补「投标(响应)无效」「不能通过资格审查」「没有实质性响应」「拒收」等写法。引文最短 8 个字、编号必须对、证据要全部通过,这些不放宽。**提示词**:substantive v3、qualification / scoring / schedule / tech_param v2,统一证据规则(引文复制整句、并列多项不拆、跨页只引上一行、数字写阿拉伯数字),除实质性条款外输入改成纯文本;资格不收附件范本和价格扣除条件,日期不收合同条款,技术参数一句一条。**模型路由**:`BD_AI_MODELS` 按提示词声明的能力选模型,没配的用 `BD_AI_MODEL`。离线评测工具 `evals/run_extract.py` | 已实现 |
| SK12 | 参考 BidEngine 的五项改进(2026-11-03) | 见 `ref-bid-engine-analysis.md` §5。**原因码**:没通过校验的结果记具体原因(`extract.FAILURES`,存在清单项 `ai_values.failures`),解析结果按原因计数(`verify_failures`),提示词规则和原因码的对照表在 `prompts/README.md`。**分块**:相邻分块重叠约 800 字;模型输出被截断(`finish_reason=length`)或不是合法 JSON 时,把分块对半重跑,最多 3 层,拆到底仍不完整的记 `output_incomplete`(以前当成「这一块没有结果」,审计发现 deepseek-flash 有输出正好停在 8192 token、整块条目悄悄丢掉的情况)。**项目基本信息**:提示词 v2,12 个字段每个都要表态 found / ambiguous / not_found,跨分块合并后分 found / absent(文件本身没写)/ needs_review(抽取有问题),记在解析结果 `info_fields`。**占位符**:「____元」「××」「( )」「待填」不算取值。**提示词**:substantive v4,qualification / scoring / schedule / tech_param v3,每条禁止事项对应一个原因码。三份样本的待确认:国动 4 → 2,S02 13 → 3,S06 57 → 55(S06 有 4 次对半重跑,截断的分块都救回来了) | 已实现 |
SK11 修正前后(同一模型 deepseek-chat,即 deepseek-flash 不思考模式)的待确认数:国动 46 → 4,S02 17 → 13,S06 174 → 57;同一句被拆成多条的情况没有了。剩下的待确认多数是表格两栏或竖排文字串行(要按单元格切句才能解决)。实质性条款任务上比较了 flash 不思考、flash 思考(deepseek-reasoner)、deepseek-v4-pro:抽到的条款大体一致、互有多少,后两者慢 15~45 倍、输出 token 多 7~12 倍,没有看出明显更准;选型等标注集评测出结果再定。
用国动便民小程序(71 页)、S02、S06 三份文件实测(真实模型 DeepSeek):单份 33~87 秒,清单 216~1348 条,正式清单里的条目都带页码和原句。发现并已修复:标注规则说明的碎片被当成条款、页码混进正文、跨页拆成两条、「做无效响应处理」等写法漏认、上级标题下列出的无效情形被合成一条或漏掉、「无效质疑」被误当成投标无效、「是否接受联合体」取错证据。
还没做、留了 TODO 的:转换镜像的字体包和 WPS 旧版 DOC 预处理(S1)、表格按单元格切句(S3)、OCR(S5)、章节层级和分包对应到清单项、模型路由的候选列表、降级和限流、在 S01–S10 上的标注集评测(召回率)、查标全部。