Files
bid-doctor/docs/architecture/architecture.md
YANG JIANKUAN e3f234ca0b feat: 读标可以走完一份文件;审计待确认区,修规则缺陷、提示词出新版本
读标操作(FR-M2-07、08)
- 后端:清单项详情(ETag)、修改(If-Match 选填,记 edited_fields 和 AI 原值)、
  采纳(补出处或声明「人工确认、无出处」,可带修改)、删除 / 丢弃与恢复、人工新增、
  替换出处、人工建出处(选同一页的句子)、修改记录(来自审计)、确认清单(不可变版本,
  内容没变复用上一版)、版本列表;正式清单有变化时已确认的清单回到草稿;项目已递交只读
- 重新解析不推翻人工决定:丢弃的不再冒出来,采纳的留在正式清单,人工补过的出处不被覆盖
- 前端:待确认区采纳 / 补上出处后采纳 / 修改后采纳 / 丢弃,正式清单修改、补出处、删除
  (可撤销)、修改记录、新增条目、确认清单;pdf.js 原文查看器(点出处跳页、按坐标框高亮、
  选句补出处),整屏对照布局,J / K 上下条;跨页的一句话合成一处出处显示

待确认审计(架构 §13 SK11)
- 规则只修真正的缺陷:表格框线不算下划线(文字层 v3);句号后的右括号并进同一句
  (切句 s3-v3);中文编号小节和冒号编号条目的上级条款,依据沿上级链核对;取值认中文
  数字、同一行单元格和评分因素标题;补几种无效写法;比对引文前去掉我们加进输入的标记
- 不放宽:引文最短 8 个字、编号必须对、证据全部通过
- 提示词新版本:substantive v3,qualification / scoring / schedule / tech_param v2;
  引文复制整句、并列多项不拆、跨页只引上一行,除实质性条款外输入改成纯文本
- BD_AI_MODELS 按提示词声明的能力选模型;离线评测工具 evals/run_extract.py
- 待确认:国动 46 → 4,S02 17 → 13,S06 174 → 57

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

72 KiB
Raw Blame History

技术架构设计 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 进程与部署单元

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 | 已实现 |

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 上的标注集评测(召回率)、查标全部。