借助大模型,做一个能读取合同、输出风险提示的 AI 原型并不复杂。但要做到结论有据可查、支持用户质疑对抗、状态持久可复现,却是工程落地中的一大难题。
把一份合同交给大模型,然后让它输出几条风险,看起来并不难。一个能"读合同、列风险"的 AI 并不难;难的是让用户能追问它、反驳它,并在刷新页面、重启 Worker 或发起对簿之后,仍然拿回同一条可验证的证据链。这篇文章,是我把 AI 合同审查从 Demo 推向可验收系统过程中的完整复盘。
真正困难的是下一步:用户为什么应该相信这些风险?结论依据的是哪一版合同、哪一条现行法律?模型看错原文或引用错法条时,系统能否停下来?用户刷新页面、Worker 重启,或者对某条结论提出异议后,整个审查过程还能否继续并保留历史?
CounterClause(对薄)要解决的不是一次性的"合同问答",而是个人用户从上传合同、核对解析、确认立场,到查看风险、发起对簿、逐条修订和导出的完整决策过程。项目首期聚焦租赁、劳动和借款合同,正式解析统一支持 PDF、JPEG/JPG、PNG 和 DOCX。
这篇文章不按目录逐个介绍框架,而是复盘四个贯穿项目的设计判断:如何建立不可变的合同事实,如何让 AI 工作流在两个人工节点可靠暂停,如何让 RAG 的证据约束真正进入风险政策,以及为什么 Pro/Con/Judge 只能成为用户主动触发的升级流程。
为了便于读者建立整体印象,先给出项目当前边界。这里的"真实"指在本机完整容器栈中使用真实解析器、本地向量模型、真实搜索索引和已批准模型路由完成验收,不等同于已经部署到公网生产环境。
| 维度 | 当前实现 |
|---|---|
| 首批合同领域 | 住宅租赁、劳动、借款 |
| 正式输入格式 | PDF、JPEG/JPG、PNG、DOCX |
| 浏览器入口 | Nginx 同源入口,Nuxt 页面通过 /api/ 访问 FastAPI |
| 解析 | MinerU 3.4.4,正式运行无 Local 解析分支 |
| AI 工作流 | LangGraph 审查图 + 用户触发的 Pro/Con/Judge 辩论图 |
| 检索 | 结构化、BM25、BGE-M3 Dense 三路召回,RRF 融合与 BGE Reranker 精排 |
| 事实与对象 | PostgreSQL 16、MinIO;OpenSearch 3 仅保存可重建派生索引 |
| 长任务 | Celery 5 + Redis 7,PostgreSQL checkpoint 支撑人工暂停和恢复 |
| 前端 | Nuxt 4、Vue 3、TypeScript、Pinia、TanStack Query |
| 工程基线 | Python 3.12、OpenAPI 生成客户端、TDD、分层测试和真实浏览器验收 |
一、用户要的不是风险列表,而是一条可以追问的决策链
合同审查最容易被低估的地方,是把"模型输出"误认为"产品结果"。对用户而言,一条可用的风险结论至少要同时回答六个问题:
- 风险对应原合同的哪一页、哪一段、哪一条款?
- 系统是否正确理解了合同领域和用户代表的当事方?
- 结论依据的是法律、司法解释、示范文本,还是一般协商经验?
- 引用原文是否真的存在,且在当前时间和问题上适用?
- 用户不认同结论时,能否要求正反双方重新论证?
- 最终改动是否经过用户逐条确认,并能追溯到原文、证据和历史决定?
因此,我把产品闭环拆成"材料事实---审查事实---用户决定"三层。模型只能生成候选分析,不能直接改合同,也不能越过证据校验替用户作决定。
1.1 三层事实分别解决什么问题
材料事实回答"用户到底上传了什么"。它包括原始文件、文件版本、MinerU 解析块、条款、页码、坐标、解析告警和用户确认过的 ParseSet。材料事实一旦发布不原地修改,解析修正通过新版本表达。
审查事实回答"系统依据什么得出结论"。它包括合同领域、代表方、法律知识快照、规则版本、模型路由版本、检索候选、引用验证、风险颜色和报告版本。即使知识库未来更新,旧报告仍然可以还原当时使用的证据环境。
用户决定回答"AI 建议最终是否进入合同"。接受、拒绝、编辑后接受、对簿后的重新确认、导出前警告确认都采用追加记录,不把界面上的当前状态当作事实。工作台看到的"已处理"只是这些不可变记录计算出来的投影。
这三层的分离很重要。如果把它们压成一个可编辑的 JSON,修改解析文本可能悄悄改变旧报告,模型重跑可能覆盖用户已经拒绝的建议,导出时也无法证明最终文本从哪里来。
1.2 领域和代表方为什么必须由用户确认
同一句合同文本对不同当事方的风险并不相同。例如"提前退租需支付两个月租金"对出租方可能是履约保障,对承租方则可能是过高的退出成本。系统可以从文本中识别租赁领域、提取甲乙方名称,但这种识别只能作为候选。
工作区创建时让用户选择预期领域和代表方,是为了减少后续操作;它不是法律事实。解析完成后,用户仍要确认合同属于租赁、劳动或借款,核对双方名称,并明确自己代表 party_a 还是 party_b。确认结果绑定当前 ParseSet,首个 ReviewRun 启动后保持不可变;如果要从另一方立场审查,应创建新的工作区,而不是改写已有历史。
1.3 风险不是被限制为固定六组
产品层可以把风险按责任、金额、期限、解除、证据充分度等维度聚合展示,但审查事实并没有"最多六组"的硬限制。系统以条款级 ClauseAssessment 和问题级 WorkbenchIssue 为基本单元,风险数量由合同内容、规则命中和证据验证共同决定。
真实租赁合同验收中,132 个条款形成了 20 个问题轨道项目,已经说明系统不是把一份合同强行压缩为六个结论。若未来需要首页只展示六个摘要组,也应当把它实现为可配置的阅读投影,而不是丢失底层条款结论。
#mermaid-svg-JF0lLKIQOMf7JE3N{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JF0lLKIQOMf7JE3N .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JF0lLKIQOMf7JE3N .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JF0lLKIQOMf7JE3N .error-icon{fill:#552222;}#mermaid-svg-JF0lLKIQOMf7JE3N .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JF0lLKIQOMf7JE3N .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JF0lLKIQOMf7JE3N .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JF0lLKIQOMf7JE3N .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JF0lLKIQOMf7JE3N .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JF0lLKIQOMf7JE3N .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JF0lLKIQOMf7JE3N .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JF0lLKIQOMf7JE3N .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JF0lLKIQOMf7JE3N .marker.cross{stroke:#333333;}#mermaid-svg-JF0lLKIQOMf7JE3N svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JF0lLKIQOMf7JE3N p{margin:0;}#mermaid-svg-JF0lLKIQOMf7JE3N .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JF0lLKIQOMf7JE3N .cluster-label text{fill:#333;}#mermaid-svg-JF0lLKIQOMf7JE3N .cluster-label span{color:#333;}#mermaid-svg-JF0lLKIQOMf7JE3N .cluster-label span p{background-color:transparent;}#mermaid-svg-JF0lLKIQOMf7JE3N .label text,#mermaid-svg-JF0lLKIQOMf7JE3N span{fill:#333;color:#333;}#mermaid-svg-JF0lLKIQOMf7JE3N .node rect,#mermaid-svg-JF0lLKIQOMf7JE3N .node circle,#mermaid-svg-JF0lLKIQOMf7JE3N .node ellipse,#mermaid-svg-JF0lLKIQOMf7JE3N .node polygon,#mermaid-svg-JF0lLKIQOMf7JE3N .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JF0lLKIQOMf7JE3N .rough-node .label text,#mermaid-svg-JF0lLKIQOMf7JE3N .node .label text,#mermaid-svg-JF0lLKIQOMf7JE3N .image-shape .label,#mermaid-svg-JF0lLKIQOMf7JE3N .icon-shape .label{text-anchor:middle;}#mermaid-svg-JF0lLKIQOMf7JE3N .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JF0lLKIQOMf7JE3N .rough-node .label,#mermaid-svg-JF0lLKIQOMf7JE3N .node .label,#mermaid-svg-JF0lLKIQOMf7JE3N .image-shape .label,#mermaid-svg-JF0lLKIQOMf7JE3N .icon-shape .label{text-align:center;}#mermaid-svg-JF0lLKIQOMf7JE3N .node.clickable{cursor:pointer;}#mermaid-svg-JF0lLKIQOMf7JE3N .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JF0lLKIQOMf7JE3N .arrowheadPath{fill:#333333;}#mermaid-svg-JF0lLKIQOMf7JE3N .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JF0lLKIQOMf7JE3N .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JF0lLKIQOMf7JE3N .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JF0lLKIQOMf7JE3N .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JF0lLKIQOMf7JE3N .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JF0lLKIQOMf7JE3N .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JF0lLKIQOMf7JE3N .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JF0lLKIQOMf7JE3N .cluster text{fill:#333;}#mermaid-svg-JF0lLKIQOMf7JE3N .cluster span{color:#333;}#mermaid-svg-JF0lLKIQOMf7JE3N div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JF0lLKIQOMf7JE3N .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JF0lLKIQOMf7JE3N rect.text{fill:none;stroke-width:0;}#mermaid-svg-JF0lLKIQOMf7JE3N .icon-shape,#mermaid-svg-JF0lLKIQOMf7JE3N .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JF0lLKIQOMf7JE3N .icon-shape p,#mermaid-svg-JF0lLKIQOMf7JE3N .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JF0lLKIQOMf7JE3N .icon-shape .label rect,#mermaid-svg-JF0lLKIQOMf7JE3N .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JF0lLKIQOMf7JE3N .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JF0lLKIQOMf7JE3N .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JF0lLKIQOMf7JE3N :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
创建独立审查工作区
上传主合同与附件
核对 MinerU 解析结果
确认合同领域与代表方
生成带证据链的条款风险
用户是否质疑结论
逐条接受、拒绝或编辑建议
主动发起 Pro/Con/Judge 对簿
形成新的报告版本
一致性检查与合同体检
导出修订合同、报告或决策摘要
这张流程图最值得关注的不是 AI 节点,而是三个明确的人工边界:解析后确认、立场确认、修改决定。它们把模型的不确定性留在可检查的范围内,也让"原文是什么""从谁的立场审查""最终是否修改"分别由用户确认。
二、整体架构:把事实、搜索、长任务和交互放在各自擅长的位置
项目采用模块化单体 API 配合独立 Worker,而没有一开始就拆成大量微服务。首期更需要跨模块事务、统一权限和快速演进;解析、向量化、审查、辩论等资源密集任务又必须与普通请求隔离,所以 Worker 是最先拆出的执行边界。
#mermaid-svg-wwm5NPc8HACBDqER{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wwm5NPc8HACBDqER .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wwm5NPc8HACBDqER .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wwm5NPc8HACBDqER .error-icon{fill:#552222;}#mermaid-svg-wwm5NPc8HACBDqER .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wwm5NPc8HACBDqER .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wwm5NPc8HACBDqER .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wwm5NPc8HACBDqER .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wwm5NPc8HACBDqER .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wwm5NPc8HACBDqER .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wwm5NPc8HACBDqER .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wwm5NPc8HACBDqER .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wwm5NPc8HACBDqER .marker.cross{stroke:#333333;}#mermaid-svg-wwm5NPc8HACBDqER svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wwm5NPc8HACBDqER p{margin:0;}#mermaid-svg-wwm5NPc8HACBDqER .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wwm5NPc8HACBDqER .cluster-label text{fill:#333;}#mermaid-svg-wwm5NPc8HACBDqER .cluster-label span{color:#333;}#mermaid-svg-wwm5NPc8HACBDqER .cluster-label span p{background-color:transparent;}#mermaid-svg-wwm5NPc8HACBDqER .label text,#mermaid-svg-wwm5NPc8HACBDqER span{fill:#333;color:#333;}#mermaid-svg-wwm5NPc8HACBDqER .node rect,#mermaid-svg-wwm5NPc8HACBDqER .node circle,#mermaid-svg-wwm5NPc8HACBDqER .node ellipse,#mermaid-svg-wwm5NPc8HACBDqER .node polygon,#mermaid-svg-wwm5NPc8HACBDqER .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wwm5NPc8HACBDqER .rough-node .label text,#mermaid-svg-wwm5NPc8HACBDqER .node .label text,#mermaid-svg-wwm5NPc8HACBDqER .image-shape .label,#mermaid-svg-wwm5NPc8HACBDqER .icon-shape .label{text-anchor:middle;}#mermaid-svg-wwm5NPc8HACBDqER .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wwm5NPc8HACBDqER .rough-node .label,#mermaid-svg-wwm5NPc8HACBDqER .node .label,#mermaid-svg-wwm5NPc8HACBDqER .image-shape .label,#mermaid-svg-wwm5NPc8HACBDqER .icon-shape .label{text-align:center;}#mermaid-svg-wwm5NPc8HACBDqER .node.clickable{cursor:pointer;}#mermaid-svg-wwm5NPc8HACBDqER .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wwm5NPc8HACBDqER .arrowheadPath{fill:#333333;}#mermaid-svg-wwm5NPc8HACBDqER .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wwm5NPc8HACBDqER .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wwm5NPc8HACBDqER .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wwm5NPc8HACBDqER .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wwm5NPc8HACBDqER .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wwm5NPc8HACBDqER .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wwm5NPc8HACBDqER .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wwm5NPc8HACBDqER .cluster text{fill:#333;}#mermaid-svg-wwm5NPc8HACBDqER .cluster span{color:#333;}#mermaid-svg-wwm5NPc8HACBDqER div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wwm5NPc8HACBDqER .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wwm5NPc8HACBDqER rect.text{fill:none;stroke-width:0;}#mermaid-svg-wwm5NPc8HACBDqER .icon-shape,#mermaid-svg-wwm5NPc8HACBDqER .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wwm5NPc8HACBDqER .icon-shape p,#mermaid-svg-wwm5NPc8HACBDqER .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wwm5NPc8HACBDqER .icon-shape .label rect,#mermaid-svg-wwm5NPc8HACBDqER .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wwm5NPc8HACBDqER .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wwm5NPc8HACBDqER .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wwm5NPc8HACBDqER :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 个人用户
Nginx 单一入口
合同决策工作台
Nuxt 4
业务接口与权限
FastAPI
业务事实、版本与事件
PostgreSQL
原件与导出对象
MinIO
长任务队列与短期状态
Redis / Celery
解析、审查、辩论 Worker
MinerU 3.4.4
LangGraph 审查图与辩论图
BM25 与向量派生索引
OpenSearch
统一模型边界
ModelGateway
图中有两组看似重复、实际职责完全不同的组合。
第一组是 Celery 与 LangGraph。Celery 负责把耗时工作移出 HTTP 请求,处理投递、重试和 Worker 资源;LangGraph 负责表达审查内部的节点、条件边、循环上限和恢复位置。一个回答"任务在哪里执行",另一个回答"业务状态接下来走向哪里"。
第二组是 PostgreSQL 与 OpenSearch。PostgreSQL 保存合同版本、审查状态、证据元数据、报告和用户决定,是唯一业务事实源;OpenSearch 只保存可重建的 BM25 与向量索引。搜索命中必须回到 PostgreSQL 校验版本、生效状态和权限,索引损坏时可以重建,但绝不能反向覆盖业务事实。
这种分工避免了两个常见问题:用消息队列当状态数据库,以及把搜索引擎中的派生文档误当成不可丢失的业务记录。
2.1 为什么选择模块化单体,而不是立即微服务化
项目的主要复杂度来自业务一致性,而不是早期的独立扩容。创建工作区、确认 ParseSet、保存 PartyConfirmation、启动 ReviewRun、写出 checkpoint 和发布事件之间存在清晰的事务关系。如果一开始就把身份、文件、审查、辩论和修订拆成多个服务,会过早引入分布式事务、跨服务鉴权和契约发布成本。
我最终采用"一个模块化 API 部署单元 + 独立后台 Worker"的形态:
| 备选方案 | 业务一致性 | 资源隔离 | 早期维护成本 | 当前适配度 |
|---|---|---|---|---|
| 单进程同步应用 | 最简单 | 解析和模型会阻塞普通 API | 低 | 不适合分钟级审查任务 |
| 全量微服务 | 需要跨服务一致性方案 | 最强 | 高 | 首期业务边界仍在快速收敛,过早 |
| 模块化单体 + Worker | API 内保留本地事务 | 重任务独立运行 | 中 | 同时满足一致性和资源隔离 |
API 内部仍按 identity、workspaces、documents、parsing、knowledge、reviews、debates、revisions、exports、events 和 deletion 划分模块。模块通过应用服务和已提交事件协作,不直接把其他模块的数据表当内部 API。未来如果某类 Worker 压力达到独立扩缩容收益,再沿现有 Port 和任务契约拆分,不需要先为"可能的规模"支付复杂度。
2.2 三类请求使用三条不同路径
并不是所有操作都应该经过同一种通信方式。项目将浏览器行为分成三类:
- 短命令和查询 :创建工作区、确认解析、保存逐条决定等使用 REST。所有资源写命令都有
operation_id和幂等键。 - 大文件正文:浏览器先向 API 申请上传意图,再直接上传到 MinIO 的短期签名地址,最后调用 finalize。合同正文不经过 FastAPI 或 Nginx 内存转发。
- 后台状态变化 :解析、审查、辩论和导出进度通过已提交事件投影到 SSE;断线后携带
Last-Event-ID恢复,若事件出现缺口则先拉取工作区快照。
这里没有选择 WebSocket,是因为首期主要是服务端向浏览器单向推送状态,客户端命令仍由 REST 完成。SSE 自带浏览器重连语义,也更容易经过 Nginx 同源代理。等到产品出现真正的高频双向协作需求,再重新评估 WebSocket,而不是为了"实时"这个词提前增加连接状态复杂度。
2.3 Nginx 为什么必须是唯一浏览器入口
开发环境里让前端分别访问 Nuxt、FastAPI 和 MinIO 很方便,但正式交互会立刻遇到跨域 Cookie、CSRF、Origin 判断和 SSE 代理差异。项目规定浏览器只访问 Nginx:页面请求转发到 Nuxt,/api/ 转发到 FastAPI,SSE 禁用代理缓冲。
这个选择把认证 Cookie、CSRF 双提交校验和应用 Origin 统一在一个边界内。MinIO 直传虽然使用短期签名 URL,但上传意图和 finalize 都必须先经过当前工作区授权,因此"能拿到对象地址"不等于"能把对象挂到任意工作区"。
2.4 Redis、PostgreSQL 和进程内状态如何划界
Redis 在这里不是业务事实源,只承担 Celery broker、限流和短期缓存。进程内对象只用于一次节点执行中的临时计算。只要一个状态需要在浏览器刷新、进程重启或任务重试后仍然成立,它就必须进入 PostgreSQL 或不可变对象存储。
这条规则看似保守,却显著减少了恢复逻辑中的猜测:Redis 清空不会丢报告,Worker 重启不会丢人工等待,SSE 断线也不会改变服务端事实。
三、关键决策一:先建立不可变合同事实,再允许 AI 分析
3.1 为什么不能直接把解析文本交给模型
如果解析结果只是一大段文本,那么报告里的"第 12 条"很难稳定指回原文件。OCR 修正、重新切分或追加附件后,文本偏移还会变化,旧报告和新合同也容易混在一起。
项目因此将原件、解析结果和审查输入拆成三个版本概念:
DocumentVersion保存原始、解析、人工修正或重建版本,正文对象以哈希保证不可变;ContractAnchor为条款和段落提供稳定身份,精确高亮使用段落内文本范围;ParseSet表示一次可确认的条款集合,审查必须绑定确切的已确认解析集。
人工修正不会原地覆盖解析结果,而是创建新的 DocumentVersion 和 ParseSet。同样,报告、对簿裁决和修订版本也只追加新版本。这样一来,任何风险都能沿着 报告 → 条款 → 锚点 → 原始文档版本 返回来源。
3.2 为什么正式运行只保留一个解析器入口
开发早期同时存在本地简化解析和 MinerU 路径,容易出现"本地内存验收通过,但 Worker 对真实文件表现不同"的分叉。最终我们删除 Local 正式运行时分支,让 API、验收运行时和 Worker 都通过同一个工厂装配 MinerU 3.4.4,并让上传白名单与解析工厂共享扩展名和 MIME 映射。
下面的代码片段体现了这个边界。它不仅检查 MIME,还检查最终文件后缀,避免 contract.pdf.exe 一类绕过;任何生产配置试图选择 MinerU 之外的解析器都会直接失败。
python
_MEDIA_TYPES_BY_EXTENSION = {
".pdf": {"application/pdf"},
".docx": {DOCX_MEDIA_TYPE},
".png": {"image/png"},
".jpg": {"image/jpeg", "image/jpg"},
".jpeg": {"image/jpeg", "image/jpg"},
}
def build_content_parser(provider: str = "mineru"):
if provider.strip().lower() != "mineru":
raise ValueError("PARSER_PROVIDER must be mineru")
return MineruDocumentParserProvider()
对用户而言,这个选择的价值不是"解析器更统一"这么抽象,而是上传页面、API 校验、后台实际解析和验收测试对支持格式给出同一个答案。解析失败也能按"不支持、文件损坏、解析失败"等稳定问题类型进入前端,而不是在不同运行路径里表现成不同异常。
3.3 文件从上传到 ParseSet 经历了什么
完整上传链路不是一个 multipart/form-data 接口,而是三个明确动作:
- 浏览器提交文件名、MIME、大小和工作区,请求
upload intent; - API 校验登录状态、CSRF、工作区所有权、格式白名单和配额,返回短期 MinIO 签名地址;
- 浏览器将字节直传对象存储,再提交 finalize;API 对象核验成功后创建不可变
DocumentVersion并投递解析任务。
这样设计的业务意义是,大文件传输失败不会长时间占用 API Worker,上传重试也不会重复创建文档。finalize 是对象进入业务事实的门槛:未完成的临时上传不能被解析任务或其他工作区引用。
MinerU 的标准化输出不只是文本,还包括页码、块类型、段落、表格、边界框、字符区间、置信度和告警。应用层把这些解析块转换为 Clause、Paragraph Anchor 和 party candidates,但 ParserProvider 本身不能直接写业务表。这个边界让解析器升级、错误归一化和业务版本创建彼此独立。
3.4 为什么锚点只保留 clause 和 paragraph 两级
一开始很容易把每个句子、词语或高亮片段都建成独立实体,但这样会产生大量生命周期问题:用户修改一句话后,Fragment 是否仍然有效?插入内容属于前一段还是后一段?历史报告如何寻找已经删除的片段?
项目最终只让条款和段落拥有稳定身份。精确引用采用段落内 TextRangeLocator:
text
TextRangeLocator = {
paragraph_anchor_id,
start,
end,
quoted_text_hash
}
插入、删除和移动通过 before / after / parent / historical 谱系关系表达。这样既能做到精确高亮,又不会为短暂文本片段建立一套容易失控的独立状态机。
| 数据对象 | 是否可变 | 主要职责 |
|---|---|---|
| 原始 DocumentVersion | 否 | 保存用户上传原件及内容哈希 |
| Parsed/Corrected DocumentVersion | 否 | 保存特定解析器和配置形成的结构化版本 |
| ParseSet | 确认后不可变 | 作为 ReviewRun 的确切条款输入 |
| ReviewReport | 否 | 绑定解析、知识、规则、模型和立场版本 |
| RevisionDecision | 追加 | 记录用户对单条建议的接受、拒绝或编辑接受 |
| RevisionVersion | 发布后不可变 | 组合已确认决定形成可导出的合同版本 |
3.5 工作台如何使用这些事实
工作台左侧展示不可编辑的原件或只读原文,右侧承载受控修订和风险批注。两栏可以独立滚动、缩放和分页;只有用户点击某个问题或"定位原文"时,系统才按稳定锚点执行一次跳转和短暂高亮。
这里我刻意没有做持续同步滚动。长合同两栏内容长度不同,强制同步会不断拉走用户当前阅读位置。一次性的语义定位更符合"我正在处理右侧问题,需要确认左侧证据"的真实动作,也保留了两边各自阅读的自由。
这里的"原文"已经按文件类型区分呈现。PDF 使用 PDF.js 将原文件逐页绘制到 Canvas,MinerU 的 bbox 被换算成页内百分比坐标,只在用户定位时显示一个无文本矩形高亮;它不会把抽取文本叠到 PDF 上,也不会改变页面排版。图片保留原图显示,DOCX 则使用结构化只读呈现并明确保真边界。
Phase 25 的专项验收进一步验证了这个细节:真实 PDF 在两次不同条款定位前后都保持 8 个页面容器、8 个 Canvas 和 132 个坐标锚点,每次只激活一个具有 left/top/width/height 的矩形,所有锚点内部都不包含重复文本。这解决了"定位高亮反而覆盖原文件、让用户误以为正文被改动"的交互风险。
3.6 右侧为什么是受控合同视图,而不是自由 Word 编辑器
右侧需要允许用户查看风险批注、比较建议、逐条接受或编辑,但不能变成一个与证据谱系脱离的自由文本框。否则用户修改整段文本后,条款 ID、证据链、历史决定和导出来源都会失去对应关系。
因此,右侧编辑以条款为单位:保留 clause identity 和 anchor lineage,每个变更都能落到一个明确建议和用户决定上。风险颜色只是阅读提示,详情抽屉才承载问题、代表方影响、证据充分度、修改理由、建议文本、决定按钮和可选对簿入口。没有风险的条款保持正常原文样式,不会为了展示 AI 能力而制造视觉噪声。
四、关键决策二:人工等待不能占住 Worker,也不能依赖浏览器内存
审查图包含 16 个业务节点,从加载解析集、规则匹配、检索证据,到引用验证、风险政策和报告发布。真正棘手的并不是节点数量,而是其中两个节点必须暂停等待用户:
await_parse_confirmation:确认这一版解析结果可以进入审查;await_domain_confirmation:确认租赁/劳动/借款领域、双方名称和用户代表方。
4.1 16 个节点不是 16 个 Agent
审查图中的节点大多是确定性业务步骤,只有结构化发现生成需要经过 ModelGateway。把每个函数都称为 Agent 会模糊控制责任,也很难解释错误应该由谁处理。
| 顺序 | 节点 | 输入与职责 | 可能产生的控制变化 |
|---|---|---|---|
| 1 | load_parse_set |
加载确切 ParseSet、条款和定位摘要 | 输入不存在则失败关闭 |
| 2 | validate_scope_and_parse_quality |
检查作用域、条款数量和解析告警 | 进入解析确认等待 |
| 3 | await_parse_confirmation |
写出"确认当前解析版本"待办 | 暂停并释放 Worker |
| 4 | classify_contract_domain |
计算租赁、劳动、借款候选及置信度 | 领域不确定也必须确认 |
| 5 | await_domain_confirmation |
等待领域、双方名称和代表方确认 | 暂停并释放 Worker |
| 6 | build_domain_context |
绑定知识快照、规则和模型配置 | 配置不一致时失败关闭 |
| 7 | extract_clauses_and_elements |
生成审查所需条款要素 | 保留 clause identity |
| 8 | apply_deterministic_rules |
执行版本化领域规则 | 为高价值检索生成线索 |
| 9 | retrieve_evidence_subgraph |
执行作用域安全的混合检索 | 工具预算耗尽时停止扩展 |
| 10 | generate_structured_findings |
生成简体中文结构化候选 | Schema 不合格进入有限修复 |
| 11 | verify_citations |
校验来源、版本、原文和适用性 | 无有效证据则标记拒绝 |
| 12 | repair_or_gray |
在循环预算内修复引用 | 超出预算后进入灰色降级 |
| 13 | apply_risk_policy |
根据证据和规则确定颜色 | 模型不能直接决定颜色 |
| 14 | build_revision_suggestions |
为可处理问题形成修改建议 | 仍不改变合同正文 |
| 15 | persist_report |
原子保存不可变报告版本 | 重放不得生成重复报告 |
| 16 | publish_report_ready |
发布已提交的报告就绪事件 | 前端恢复并打开报告 |
这张表体现了一个关键边界:LangGraph 是流程控制器,而不是多智能体数量放大器。Retriever、CitationVerifier、RiskPolicy 和 Orchestrator 都是受控基础设施或工作流节点,正式 Agent 只有用户主动对簿时出现的 Pro、Con、Judge。
4.2 ReviewState 是一次审查的可恢复快照
ReviewState 不是把所有数据库记录复制进内存,而是保存让下一节点确定执行所需的最小状态。它大致分为六组:
- 作用域与不可变输入:owner、workspace、document、ParseSet、ReviewRun;
- 人工确认:是否要求确认、当前 pending action、已确认领域和代表方;
- 解析与规则结果:clause IDs、elements、deterministic hits、parse warnings;
- RAG 与模型结果:knowledge snapshot、evidence candidates、finding candidates;
- 验证与政策结果:verified findings、rejected IDs、assessments、repair attempts;
- 执行控制:graph version、state version、checkpoint node、resume node、工具和时间预算。
这样组织后,每个节点只返回自己负责的增量字段。checkpoint 保存的是节点提交后的完整可恢复状态,日志则只记录计数、状态和不可逆摘要,不记录合同正文。
最直接的实现是让一个 Worker 任务一直等待浏览器操作,但这会长期占用进程;把等待状态只放在浏览器或 LangGraph 进程内存里,则经不起刷新和 Worker 重启。项目最终采用"LangGraph 分段执行 + PostgreSQL checkpoint + 幂等 REST continuation"的方式。
LangGraph 审查图 Celery Worker PostgreSQL FastAPI Nuxt 工作台 LangGraph 审查图 Celery Worker PostgreSQL FastAPI Nuxt 工作台 #mermaid-svg-ZYq0MqrVYbmDmm6R{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZYq0MqrVYbmDmm6R .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZYq0MqrVYbmDmm6R .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZYq0MqrVYbmDmm6R .error-icon{fill:#552222;}#mermaid-svg-ZYq0MqrVYbmDmm6R .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZYq0MqrVYbmDmm6R .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZYq0MqrVYbmDmm6R .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZYq0MqrVYbmDmm6R .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZYq0MqrVYbmDmm6R .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZYq0MqrVYbmDmm6R .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZYq0MqrVYbmDmm6R .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZYq0MqrVYbmDmm6R .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZYq0MqrVYbmDmm6R .marker.cross{stroke:#333333;}#mermaid-svg-ZYq0MqrVYbmDmm6R svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZYq0MqrVYbmDmm6R p{margin:0;}#mermaid-svg-ZYq0MqrVYbmDmm6R .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZYq0MqrVYbmDmm6R text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ZYq0MqrVYbmDmm6R .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZYq0MqrVYbmDmm6R .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ZYq0MqrVYbmDmm6R .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ZYq0MqrVYbmDmm6R .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ZYq0MqrVYbmDmm6R #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ZYq0MqrVYbmDmm6R .sequenceNumber{fill:white;}#mermaid-svg-ZYq0MqrVYbmDmm6R #sequencenumber{fill:#333;}#mermaid-svg-ZYq0MqrVYbmDmm6R #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ZYq0MqrVYbmDmm6R .messageText{fill:#333;stroke:none;}#mermaid-svg-ZYq0MqrVYbmDmm6R .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZYq0MqrVYbmDmm6R .labelText,#mermaid-svg-ZYq0MqrVYbmDmm6R .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ZYq0MqrVYbmDmm6R .loopText,#mermaid-svg-ZYq0MqrVYbmDmm6R .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ZYq0MqrVYbmDmm6R .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ZYq0MqrVYbmDmm6R .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ZYq0MqrVYbmDmm6R .noteText,#mermaid-svg-ZYq0MqrVYbmDmm6R .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ZYq0MqrVYbmDmm6R .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZYq0MqrVYbmDmm6R .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZYq0MqrVYbmDmm6R .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ZYq0MqrVYbmDmm6R .actorPopupMenu{position:absolute;}#mermaid-svg-ZYq0MqrVYbmDmm6R .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ZYq0MqrVYbmDmm6R .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ZYq0MqrVYbmDmm6R .actor-man circle,#mermaid-svg-ZYq0MqrVYbmDmm6R line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ZYq0MqrVYbmDmm6R :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户 启动审查创建 ReviewRun提交运行事实与幂等操作投递第一段执行到解析确认节点保存 checkpoint、state_version、pending_action正常结束为 waiting_for_human确认解析版本confirm_parse + expected_state_version原子写入确认与唯一 continuation投递第二段重建最新 ReviewState执行到领域与代表方确认节点再次持久化并释放 Worker确认领域、双方名称和代表方confirm_domain_and_party投递最终段检索、分析、校验并生成报告发布不可变报告版本与事件SSE / 查询恢复最新状态 用户
每个 checkpoint 都记录图版本、单调递增的 state_version、最近节点、待办动作和不可变输入标识。确认命令必须携带 expected_state_version 和幂等键:
- 相同命令重放,返回同一个 operation;
- 使用过期版本或错误待办动作,返回冲突;
- 事务提交确认事实和唯一 continuation 后,才允许投递下一段任务。
4.3 确认命令为什么要同时检查版本、动作和幂等键
只检查幂等键还不够。用户可能在两个标签页中打开同一工作区:标签页 A 已经确认解析,标签页 B 仍显示旧状态。如果 B 的旧请求被接受,它可能覆盖新的 pending action,甚至让图从错误节点继续。
服务端因此同时校验三个维度:
python
if command.expected_state_version != run.state_version:
raise StaleStateConflict()
if command.action != run.pending_action:
raise PendingActionConflict()
return idempotency_store.execute_once(command.key, command.payload_hash)
真正实现时,这些判断、确认事实、state version 递增和 continuation operation 创建位于同一 PostgreSQL 事务内。Celery 投递采用 outbox/恢复机制,因此"数据库已经确认但消息尚未发出"可以被恢复任务重新投递,而不会让用户再次点击。
4.4 为什么不用一个长期占用的 LangGraph interrupt
LangGraph 的 interrupt/checkpointer 很适合在单一运行环境中暂停和继续,但项目还要满足 SQLAlchemy 事务、Celery 任务释放、REST 幂等命令、RLS 和进程重建等约束。最终保留 LangGraph 节点语义,同时在外部使用显式分段恢复适配器:
- 第一段正常运行到等待节点,提交 checkpoint 后任务成功结束;
- REST 命令提交人工事实并创建下一段 operation;
- 新 Celery 任务从 PostgreSQL 最新 checkpoint 重建状态;
resume_from_node只允许进入事先声明的下一个节点,避免重放前段副作用。
这是一个有意识的取舍:没有把恢复能力完全交给框架内部,而是让业务数据库掌握可审计的人工等待事实。框架升级不会改变对外的确认契约。
这套设计把 LangGraph 的流程表达能力与 PostgreSQL 的事务事实结合起来。浏览器关闭不会取消后台工作,Worker 重启也不会丢失人工等待位置;更重要的是,用户等待期间不会占住一个加载了本地模型的 Worker slot。
五、关键决策三:RAG 不是"搜几段文本",而是非灰色结论的证据门禁
法律检索同时存在关键词、语义和结构化条件。如果只用向量相似度,法条编号、合同领域和来源等级可能被语义近似结果淹没;只用 BM25,又难以处理"提前退租"和"合同解除"这类口语与法律表述差异;只用结构化规则,则覆盖不了开放表达。
因此,项目采用结构化精确、BM25 稀疏、BGE-M3 稠密三路召回,并分别作用于全局法律知识和当前工作区证据。候选通过 RRF 融合,再由 BGE-Reranker-v2-m3 精排。
#mermaid-svg-X5pJbjmdsRXz03wd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-X5pJbjmdsRXz03wd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-X5pJbjmdsRXz03wd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-X5pJbjmdsRXz03wd .error-icon{fill:#552222;}#mermaid-svg-X5pJbjmdsRXz03wd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-X5pJbjmdsRXz03wd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-X5pJbjmdsRXz03wd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-X5pJbjmdsRXz03wd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-X5pJbjmdsRXz03wd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-X5pJbjmdsRXz03wd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-X5pJbjmdsRXz03wd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-X5pJbjmdsRXz03wd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-X5pJbjmdsRXz03wd .marker.cross{stroke:#333333;}#mermaid-svg-X5pJbjmdsRXz03wd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-X5pJbjmdsRXz03wd p{margin:0;}#mermaid-svg-X5pJbjmdsRXz03wd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-X5pJbjmdsRXz03wd .cluster-label text{fill:#333;}#mermaid-svg-X5pJbjmdsRXz03wd .cluster-label span{color:#333;}#mermaid-svg-X5pJbjmdsRXz03wd .cluster-label span p{background-color:transparent;}#mermaid-svg-X5pJbjmdsRXz03wd .label text,#mermaid-svg-X5pJbjmdsRXz03wd span{fill:#333;color:#333;}#mermaid-svg-X5pJbjmdsRXz03wd .node rect,#mermaid-svg-X5pJbjmdsRXz03wd .node circle,#mermaid-svg-X5pJbjmdsRXz03wd .node ellipse,#mermaid-svg-X5pJbjmdsRXz03wd .node polygon,#mermaid-svg-X5pJbjmdsRXz03wd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-X5pJbjmdsRXz03wd .rough-node .label text,#mermaid-svg-X5pJbjmdsRXz03wd .node .label text,#mermaid-svg-X5pJbjmdsRXz03wd .image-shape .label,#mermaid-svg-X5pJbjmdsRXz03wd .icon-shape .label{text-anchor:middle;}#mermaid-svg-X5pJbjmdsRXz03wd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-X5pJbjmdsRXz03wd .rough-node .label,#mermaid-svg-X5pJbjmdsRXz03wd .node .label,#mermaid-svg-X5pJbjmdsRXz03wd .image-shape .label,#mermaid-svg-X5pJbjmdsRXz03wd .icon-shape .label{text-align:center;}#mermaid-svg-X5pJbjmdsRXz03wd .node.clickable{cursor:pointer;}#mermaid-svg-X5pJbjmdsRXz03wd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-X5pJbjmdsRXz03wd .arrowheadPath{fill:#333333;}#mermaid-svg-X5pJbjmdsRXz03wd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-X5pJbjmdsRXz03wd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-X5pJbjmdsRXz03wd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-X5pJbjmdsRXz03wd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-X5pJbjmdsRXz03wd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-X5pJbjmdsRXz03wd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-X5pJbjmdsRXz03wd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-X5pJbjmdsRXz03wd .cluster text{fill:#333;}#mermaid-svg-X5pJbjmdsRXz03wd .cluster span{color:#333;}#mermaid-svg-X5pJbjmdsRXz03wd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-X5pJbjmdsRXz03wd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-X5pJbjmdsRXz03wd rect.text{fill:none;stroke-width:0;}#mermaid-svg-X5pJbjmdsRXz03wd .icon-shape,#mermaid-svg-X5pJbjmdsRXz03wd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-X5pJbjmdsRXz03wd .icon-shape p,#mermaid-svg-X5pJbjmdsRXz03wd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-X5pJbjmdsRXz03wd .icon-shape .label rect,#mermaid-svg-X5pJbjmdsRXz03wd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-X5pJbjmdsRXz03wd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-X5pJbjmdsRXz03wd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-X5pJbjmdsRXz03wd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
条款文本与领域上下文
法律同义词扩展
BGE-M3 查询向量
结构化精确召回
BM25 中文全文召回
Dense 向量召回
RRF 排名融合
PostgreSQL 事实与权限复核
BGE Reranker 精排
引用原文、版本、时效与主题适用性校验
证据是否足够
确定性风险政策生成非灰色结论
有限修复后降级为灰色
RRF 使用 score(doc) = Σ 1 / (60 + rank_i),不直接比较 BM25 分数和向量相似度。它只关心候选在各通道中的名次,因此能在分数尺度不同的情况下稳定融合多路结果。
5.1 三路召回分别补什么短板
| 通道 | 擅长的问题 | 典型例子 | 单独使用的不足 |
|---|---|---|---|
| Structured | 领域、法域、来源等级、法条编号、有效期等精确条件 | 只检索当前租赁知识快照中的现行高权威来源 | 无法理解开放式语义 |
| BM25 | 关键词和中文法律短语的精确匹配 | "押金""提前解除""竞业限制" | 用户口语与法条用词不同会漏召回 |
| Dense / BGE-M3 | 语义相似和改写表达 | "提前退租成本"匹配"单方解除责任" | 近义不等于法律上适用,容易召回主题相近材料 |
检索前还会执行版本化法律同义词扩展,例如把用户表达补充为法条常用措辞。扩展后的查询只用于召回和重排,不改写用户合同,也不会被当成新的事实。
每个通道在逻辑上查询两种作用域:
global_knowledge:必须绑定知识快照、法域、来源等级和有效时间,不能带用户归属;workspace_user:必须同时带owner_id + workspace_id,用于当前案件的补充材料,不允许进入全局知识索引。
也就是说,作用域不是取回结果后的普通过滤项,而是搜索请求本身的硬条件。检索器返回候选后,应用层还会再次检查 scope,随后由 PostgreSQL fact source 验证实体版本和有效状态。这种双层校验可以防止索引映射错误或适配器缺陷把其他工作区的材料带入模型上下文。
5.2 为什么融合后还要回事实源,再做 Cross-Encoder 精排
OpenSearch 里可能存在因为异步索引尚未清理而过期的文档,也可能存在已经发布新版本但旧索引仍可命中的窗口。直接把搜索结果交给模型,会让派生状态反客为主。
因此顺序是:多路召回 → RRF → PostgreSQL 事实复核 → BGE-Reranker 精排。只有当前有效的候选才进入精排,避免把本地重排算力浪费在已失效或越权数据上。Reranker 使用 query 与候选全文做 Cross-Encoder 判断,比单纯 embedding 距离更适合 Top 30~50 到 Top 8~12 的细粒度筛选。
这里也解释了为什么没有首期引入 Milvus 或 GraphRAG。当前难点主要是法律来源版本、字段过滤、关键词与语义的混合匹配,并没有评估证据证明需要图上的复杂多跳推理。OpenSearch 同时承载中文 BM25 和 1024 维向量索引,能够在较少基础设施下完成现阶段检索目标。等评估集证明存在结构化字段与混合检索无法解决的稳定多跳问题,再增加图数据库才有业务依据。
真正决定系统可信度的是图的后半段。CitationVerifier 不只判断"模型给了一个 evidence_id",还会检查:
- 引用文字能否在指定来源版本中找到;
- 法律来源在审查日期是否生效;
- 来源主题与具体问题是否匹配;
- 用户证据是否同时满足
owner_id + workspace_id; - 报告绑定的知识快照是否与证据版本一致。
只有通过验证的证据才能支撑红、黄、绿结论。引用不存在、版本缺失、时效不符或主题不适用时,系统先进行有上限的结构化修复,仍无法解决就降级为灰色,而不是让模型以流畅措辞掩盖证据空缺。
5.3 "引用存在"不等于"引用适用"
可靠性验收中出现过一个很典型的问题:模型对押金条款给出了格式正确的 evidence reference,但候选来源实际讨论的是收益归属。引用文字确实存在,source version 也正确,如果只做字符串校验就会被判定为 verified。
我们没有因此取消引用验证或把责任重新交给模型,而是在 CitationVerifier 中增加保守的主题适用性检查。押金、维修、转租、违约金、解除、工资、利息、担保等受控主题如果出现在条款和 finding 中,对应来源正文也必须覆盖该主题。无法确认适用性时,证据被拒绝,结论进入修复或灰色降级。
这个实现不会宣称"词汇重合就证明法律适用",它只负责阻止明显不同主题的来源成为决定性证据。更复杂的法律适用仍需要高质量语料、评估集和必要时的专业复核。
5.4 模型负责候选,RiskPolicy 负责最终颜色
风险颜色直接影响用户判断,不能让模型在自由文本中随意决定。结构化模型输出只包含问题、代表方影响、候选证据引用、建议和置信度,之后由确定性政策结合规则严重度、引用状态和证据充分度生成颜色:
| 颜色 | 系统含义 | 进入条件 |
|---|---|---|
| 红色 | 有已验证依据的高严重度风险 | 强制性规范冲突,或版本化规则判定高风险且证据充分 |
| 黄色 | 协商风险、歧义或实质基准偏差 | 有可验证问题,但不应直接描述为违法 |
| 绿色 | 已审查且未发现可证实风险 | 解析与证据条件足够,不能仅因"模型没说话"变绿 |
| 灰色 | 当前证据不足或无法可靠判断 | 原文未确认、引用失败、适用性有争议或置信度不足 |
灰色不是系统失败的遮羞布,而是高风险 AI 产品中必要的诚实状态。它明确告诉用户"这条现在不能可靠下结论",比强行输出一个红黄绿更有价值。
5.5 ModelGateway 为什么是唯一大模型边界
审查、Pro、Con 和 Judge 都不能在业务代码里各自直接调用供应商 SDK。项目通过版本化 ModelRoutingProfile 统一声明供应商、区域、分析模型、主审模型、允许回退的错误类别、隐私政策、超时和预算。
真实验收 profile 中,结构化审查与 Pro/Con 使用分析模型,Judge 使用更强的裁决模型;只有声明内的短暂可用性故障才允许 Judge 回退到分析模型,并记录原因。认证失败、隐私策略不匹配、非批准模型或结构化输出错误均失败关闭,永远不能静默切回 fake provider。
每次调用只保存 provider、model、role/task、routing version、token、延迟、供应商返回的成本、operation ID 和不可逆 input hash,不保存合同正文、Prompt 或模型输入。这既支持调试和成本追踪,也守住了敏感合同不进入普通日志的边界。
当前真实本地验收环境为租赁、劳动和借款三个领域发布了不可变知识快照,OpenSearch 的 legal_chunks_current 别名对应 1,211 条已索引证据。这个数字本身并不代表法律知识"完整",它证明的是知识来源已经进入版本化、可重建和可验收的检索链,而不是临时拼接在 Prompt 中。
六、关键决策四:多智能体对抗只在用户明确质疑时启动
把每个合同领域都做成一个 Agent,或让多个 Agent 对每条合同自动辩论,会显著增加成本、延迟和不可控性,而且普通审查并不需要这种复杂度。
项目中的正式 LLM 角色固定为 Pro、Con、Judge。Orchestrator、Retriever、CitationVerifier 和 RiskPolicy 都是受控工作流组件,不包装成额外 Agent。普通审查由结构化审查图完成;只有用户在当前风险详情中点击"发起对簿",系统才冻结当前条款、基础报告、知识快照、证据快照和可选质疑内容,创建辩论会话。
6.1 三个角色为什么不能共享一段不断增长的聊天记录
如果 Pro 先发言,Con 直接看到其完整推理并在同一个上下文中继续生成,后发角色很容易被前文措辞牵引;Judge 如果继续沿用双方的对话上下文,也可能继承其中未经验证的假设。
项目为三个角色构造不同的最小上下文:
| 角色 | 主要任务 | 可见信息 | 不允许的行为 |
|---|---|---|---|
| Pro | 论证现有风险或修改建议成立 | 冻结条款、用户立场、共享证据、用户质疑 | 引用快照之外的材料作为既成事实 |
| Con | 论证风险被高估、存在例外或有替代解释 | 与 Pro 相同的冻结输入,开场时看不到 Pro 输出 | 通过重复同一观点增加权重 |
| Judge | 比较双方经过验证的观点并形成结构化裁决 | 已验证正权重论点、客观条款、用户立场、证据快照 | 读取未验证论点或直接覆盖基础报告 |
这不是让三个模型自由聊天,而是一个固定拓扑、固定阶段、固定预算的受限对抗协议。
6.2 三阶段协议如何控制成本和循环
| 阶段 | Pro / Con 行为 | 系统控制 |
|---|---|---|
| 独立立论 | 各自提交 claim 和 evidence IDs | 双方互不可见,先验证证据再登记 |
| 交叉质证 | 回应已登记观点或补充新证据 | 必须带 responds_to,重复语义指纹不增权 |
| 总结陈词 | 在现有登记簿上收束争议 | 不允许无限新增轮次,随后交给 Judge |
会话同时受总时限、阶段上限、工具调用和模型成本预算约束。若用户中止或依赖失败,系统保留已经生成的事件和终止原因,但不会把半成品裁决写回报告。上诉也不会复用并修改旧会话,而是创建新的 DebateSession。
对簿严格沿着"冻结基础报告与证据快照 → Pro/Con 独立立论 → 一次交叉质证 → 双方总结 → 引用验证 → Judge 独立裁决 → RiskPolicy 兜底 → 新报告版本"的固定链路运行。开场阶段正反方互相不可见,降低顺序影响;质证阶段只能回应已登记观点或提交新证据。ArgumentLedger 为每个观点记录证据、回应关系、语义指纹和验证状态,重复或无证据论点不会增加权重。Judge 使用独立上下文,只读取通过验证的正权重观点,最终颜色仍要经过确定性风险政策。
辩论结果不会覆盖基础报告,而是创建 vN+1。如果新裁决改变了用户已经处理过的建议,旧决定继续保留,新建议进入"对簿后待确认"。这条规则把多智能体输出限制在建议层,最终合同变化仍然属于用户。
6.3 为什么按租赁、劳动、借款拆三个领域包,而不是三个领域 Agent
领域差异主要体现在规则、槽位、知识快照、查询扩展和风险政策,而不是需要三套完全不同的自主规划能力。把领域做成版本化 Domain Pack,能够复用同一审查图和同一安全边界;如果做成三个自由 Agent,提示词、工具权限、错误恢复和评估都要重复维护。
因此,领域路由是确定性节点,Agent 只承担确实需要相互独立观点的争议论证。这种设计让增加新合同领域时更像"增加一个经过评估的领域包",而不是复制一套难以证明行为一致的新智能体。
6.4 为什么普通审查绝不自动发起对簿
系统可以根据低置信度、证据冲突、高影响修改或用户拒绝高风险建议,提示"建议发起对簿",但推荐本身不创建会话。原因有三点:
- 对簿会增加真实模型调用、等待时间和成本;
- 争议是否值得深入,是用户的业务判断,不应由模型替代;
- 自动对簿容易让用户把多轮生成误解为更权威的法律结论。
只有显式命令才能创建 DebateSession,也让验收可以清楚证明"普通报告没有悄悄运行 Pro/Con/Judge"。
七、隔离不是一个鉴权装饰器,而是贯穿整条数据链
合同正文属于高敏感数据,单纯在 API 路由前检查一次用户身份还不够。项目把作用域设计为每层都必须携带的业务上下文:
- PostgreSQL 行级安全策略校验
owner_id; - 所有派生产物继续绑定
workspace_id; - MinIO 对象键固定在用户和工作区前缀下,下载前先经过 API 授权;
- Celery payload 只携带 ID、期望版本和幂等键,不携带合同正文;
- 工作区证据检索缺少
owner_id或workspace_id时直接拒绝; - ModelGateway 只接收去标识化的最小条款片段,凭据不进入浏览器、任务消息和普通日志。
状态更新与前端事件写入同一数据库事务,SSE 只转发已经提交的事件。这样可以避免"页面显示完成但报告尚未提交"或断线重连后重复生成报告。访问令牌过期时,前端也只自动重放读取请求或带稳定幂等键的资源命令,登录、改密等凭证操作不会被自动重放。
7.1 权限上下文为什么必须进入 Worker 和搜索查询
API 鉴权成功不代表异步任务天然安全。任务可能在几分钟后由另一个进程执行,期间工作区可能已经进入删除状态,或者客户端提交的期望版本已经过期。
Celery envelope 因此包含 owner、workspace、resource、expected version、deadline 和 operation key。Worker 开始前重新读取数据库并验证所有权、资源状态和期望版本;如果作用域与 ReviewState 不一致直接失败。它不能仅凭"消息来自可信队列"就执行模型调用。
同样,OpenSearch 的用户证据适配器没有无 scope 的公共查询路径。全局知识允许跨工作区复用,但 owner/workspace 字段必须为空;工作区证据必须同时匹配两级作用域。这种正反约束可以阻止用户合同片段误入全局知识库。
7.2 主要威胁与控制点
| 威胁 | 仅靠表层鉴权会发生什么 | 项目中的控制 |
|---|---|---|
| 猜测其他工作区 ID | API 查询可能返回他人报告 | API scope + PostgreSQL RLS + 复合外键 |
| 复用签名下载地址 | 绕过工作区页面读取对象 | 短期签名、签名前再次授权、作用域对象键 |
| 重放确认或导出命令 | 重复报告、重复导出或覆盖新状态 | Idempotency-Key + expected state version + payload hash |
| SSE 断线重连 | 丢事件或重复应用旧事件 | 工作区连续游标、Last-Event-ID、快照恢复 |
| 模型或日志泄露合同 | 敏感正文进入第三方或普通日志 | 最小片段、去标识化、允许供应商策略、正文日志禁令 |
| 搜索索引串租户 | 他人证据进入当前 Prompt | 查询前硬过滤 + 返回后二次 scope 校验 + PostgreSQL 复核 |
| 删除后后台任务继续 | 已删除合同再次被模型处理 | deleting 立即撤权、Worker 执行前复核、派生存储清理 |
7.3 长审查中的会话刷新为何特别危险
真实审查可能超过短访问令牌寿命。简单做法是在收到 401 后自动重试所有请求,但凭证命令、确认命令和导出命令被无条件重放,可能产生安全和幂等问题。
前端只允许两类自动恢复:GET 等读取请求,以及原本就带稳定 Idempotency-Key 的资源写命令。刷新 Cookie 在服务端以哈希保存并轮换,CSRF Cookie 同步轮换;Origin、双提交 Cookie 或刷新令牌任一不匹配都失败关闭。刷新失败不会取消服务端正在运行的审查,只提示用户重新登录后恢复工作区。
完整浏览器验收曾暴露刷新 session compare-and-swap 在 RLS 下没有先建立正确 owner scope。修复后的顺序是先以 token 范围定位会话并建立匹配的数据库用户上下文,再原子撤销旧 token、创建新 token;同一个旧 token 第二次消费会被拒绝。
7.4 删除不是一条 SQL,而是跨存储清理协议
用户确认删除后,工作区首先进入 deleting 并立即撤销读取、下载和推理权限,随后由删除任务清理 PostgreSQL 业务记录、MinIO 原件和导出、OpenSearch 派生索引、缓存和临时文件。在线数据在声明 SLA 内核验清除,备份按保留期自然过期,只留下不含标题和正文的匿名删除证明。
这种"先撤权、再异步清理、最后生成最小证明"的顺序兼顾了用户立即不可访问和跨存储操作需要时间两个事实。
八、开发任务如何从功能原型收敛成真实闭环
截至 2026 年 8 月 23 日,任务清单共有 170 个任务已完成,最新 Phase 25 还保留一个必须由真实人工确认完成的浏览器门禁 T176。与其把这些任务看作一串提交,不如按风险递增的垂直切片理解:每个阶段先建立失败测试,再实现最小行为,最后通过跨层验收把前后端接到同一份契约上。
| 阶段 | 任务范围 | 主要问题 | 形成的系统能力 |
|---|---|---|---|
| Setup | Phase 1,T001--T007 | 仓库、运行时和契约如何可复现 | Python 3.12/pnpm 锁定、Nuxt/FastAPI/Celery 骨架、Compose、Schema 验证和匿名 fixture |
| Foundational | Phase 2,T008--T020 | 状态、版本、幂等、RLS 和事件怎样先于业务功能建立 | 领域状态机、PostgreSQL 迁移、任务 envelope、MinIO/OpenSearch 边界和发布阻断测试 |
| MVP 上传与解析 | Phase 3,T021--T031 | 文件怎样安全进入系统并可核对 | 工作区、直传、finalize、解析进度、原文和结构化内容对应 |
| 证据审查 | Phase 4--5,T032--T048 | 立场、知识、规则、模型和证据如何形成报告 | Domain Pack、混合检索、审查图、CitationVerifier、条款报告和证据抽屉 |
| 对簿与修订 | Phase 6--7,T049--T066 | 争议如何升级,建议如何由用户决定 | Pro/Con/Judge、ArgumentLedger、RevisionDecision、版本化导出 |
| 生命周期与隐私 | Phase 8--10,T067--T098 | 多工作区、账户、删除、性能和跨切面规则 | 账户与会话、隔离、删除证明、可观察任务和综合评估 |
| 工程基线收敛 | Phase 12,T104--T115 | 原型实现与正式规格之间如何消除漂移 | 契约修复、稳定测试基线、发布证据与残余配置清理 |
| 合同决策工作台 | Phase 13--18,T116--T134 | 原文、风险、修订、对簿和导出如何成为一个闭环 | 双栏工作台、PartyConfirmation、问题轨道、详情抽屉、健康检查和真实 E2E |
| 真实服务接线 | Phase 19--20,T135--T150 | 正式 profile 怎样禁止测试替身并安全调用外部能力 | Bailian 双模型路由、QQ SMTP、失败关闭、模型审计、并发 token 安全 |
| 中文与长会话 | Phase 21,T151--T156 | 长审查中用户如何始终看到中文、可读、可恢复的状态 | 简体中文结构化约束、条款标签、段落高亮、一次安全 refresh replay |
| 解析单一路径 | Phase 22,T157 | API、本地验收和 Worker 为什么不能存在三套解析行为 | MinerU 3.4.4 统一工厂、四类输入白名单、Local 正式分支删除 |
| Durable HITL | Phase 23,T158--T166 | 两个人工节点如何跨刷新和 Worker 重启恢复 | PostgreSQL checkpoint、REST continuation、production admission、真实 RAG/对簿验收 |
| 真实可靠性 | Phase 24,T167--T173 | 长审查、错误 evidence ref、适用性和命令接线如何收敛 | 长轮询恢复、灰色降级、可重试失败、session RLS 修复和报告 v2 对簿 |
| 解析确认与 PDF 定位 | Phase 25,T174--T176 | 确认入口和定位高亮如何不破坏原 PDF | 首屏确认工具栏、PDF Canvas、无文本 bbox overlay;最终人工状态转换待 T176 完成 |
Phase 11 被明确标记为 superseded draft,没有继续作为第二套前端规格。这一点同样重要:当设计已经收敛到正式 spec 时,旧草案应该归档,而不是让后续实现同时满足两套相互冲突的页面想象。
8.1 TDD 在这个项目中不是口号,而是任务结构
每个关键能力都遵循 Red → Green → Refactor → Regression:
- 在 unit、contract、integration、security、evaluation 或 E2E 层先写能证明需求的失败测试;
- 保存 Red 的命令、退出码和失败原因,避免"补完代码再补测试"伪装成 TDD;
- 实现满足契约的最小行为,再运行目标层;
- 重构后运行相邻模块和跨层回归;
- 只有真实依赖和浏览器证据满足任务要求,才在
tasks.md标记完成。
例如 Phase 23 不是先写两个等待页面,而是先用契约测试定义 pending_action、expected_state_version、重复/过期确认和 RLS;再用单元与集成测试证明检索和模型在两次确认前不会发生、Worker 重建后能续跑且只生成一份报告;最后才接生成的 TypeScript Client 和前端确认页面。
8.2 OpenAPI 为什么是前后端之间的唯一长期契约
前端业务 UI 不手写一套长期存在的 API 类型。OpenAPI 描述确认命令、ReviewRun 状态、事件枚举、报告和工作台投影,生成 TypeScript schema/client;后端合同测试和 drift 检查阻止实现与契约悄悄分叉。
这使得 awaiting_parse_confirmation、awaiting_domain_confirmation、retryable_failed 等状态在数据库、API 和 Vue 页面之间有同一个来源。新增状态时必须先修改规格和契约,再生成客户端并处理所有穷尽分支,避免前端把未知状态显示成"处理中"。
8.3 真实链路反推设计:几个最值得记录的坑
项目中最有价值的问题通常不是语法错误,而是"每个组件单独都正常,组合起来却违反业务事实"。下面这些问题直接推动了架构收敛。
坑一:知识检索有结果,但事实复核后变成空集
第一,检索代码本身可以正常返回候选,但如果审查绑定的是合成领域 ID,而不是已发布的知识快照 ID,PostgreSQL 事实复核会把所有候选过滤掉。修复不是放松过滤,而是让 ReviewRun 从发布事实解析正确快照,并通过别名切换管理可重建索引。
这个问题说明安全过滤不能为了"让结果出现"而关闭。正确做法是让上游输入绑定真实发布版本,使严格过滤也能得到正确结果。
坑二:浏览器超时不等于后台审查失败
第二,单元测试中的短任务无法暴露真实模型审查耗时。真实浏览器运行超过原先前端等待窗口后,我们把"前端等待超时"与"服务端任务失败"分开:页面持续轮询并可恢复,终态错误提供中文原因和幂等重试,后台任务不会因为页面刷新而丢失。
真实可靠性复验中的一次审查运行了约 165.51 秒。如果前端在 120 秒直接宣布失败,用户会重复发起任务,而服务端可能稍后又完成第一份报告。新的实现以服务端状态为准,页面只负责持续观察和恢复。
坑三:跨层命令必须先创建事实,再进行页面导航
第三,完整演示暴露了刷新会话的 RLS 写作用域和对簿按钮只导航、未先创建命令的问题。两处都没有用手工改库绕过,而是先补 Red 测试,再修正事务作用域与命令接线,重建镜像后重新走浏览器流程。这也是端到端验收的价值:它验证的是组件之间的缝隙,而不是某个函数能否独立通过。
坑四:源码删掉 Local parser,不代表旧容器里也已经消失
本地源码删除 local.py 后,运行中的旧 Worker 镜像仍可能保留历史 layer。仅检查 Git 工作区会得到"已经删除"的结论,但真实任务仍可能 import 旧文件。
我们最终把解析器版本、模型 manifest、OpenSearch mapping、知识快照和 ModelGateway 路由都放进 production admission,并在重建脚本中进入容器检查 MinerU 版本和 local.py 是否存在。演示验收记录的是实际运行 image digest,而不是 Dockerfile 看起来应该产生什么。
坑五:可重建索引也要避免重复导入和错误别名
OpenSearch 重建时曾出现新旧派生文档同时存在,计数从预期 1,211 变为 2,422。由于 PostgreSQL 才是事实源,我们没有手工挑选并修改业务记录,而是删除明确可重建的目标索引,用稳定文档 ID 和本地 BGE-M3 重新构建,再原子切换 legal_chunks_current 别名。
这个处理保留了"索引可以丢弃重建"的架构承诺,也让验收能通过别名、文档数、mapping 维度和模型 manifest 共同确认当前检索环境。
坑六:Docker build context 会被测试临时目录拖垮
Windows 上执行 Web 镜像构建时,Docker BuildKit 会遍历整个 build context。pytest 或验收留下的受限临时目录即使与 Web 构建无关,也可能让 context sender 报 Access is denied,导致开发者误以为 Dockerfile 有问题。
解决方向不是反复重启构建,而是收紧 .dockerignore,让测试临时数据、验收派生物和模型缓存不进入 Web build context,同时保证真正需要的 workspace 文件仍可复制。这个坑强化了一个朴素原则:构建上下文应当是显式产品输入,而不是仓库目录的全部副作用。
坑七:PDF 高亮不能靠把抽取文本叠回页面
早期定位方案把解析文本节点参与页面流,容易造成原 PDF 内容看起来被覆盖或重新排版。Phase 25 改用 PDF.js Canvas 保留原页面,MinerU bbox 只生成绝对定位、无文本的透明矩形。定位前后页面容器和 Canvas 数量保持不变,真正改变的只有一个短暂 active anchor。
这不是单纯 CSS 修补,而是重新明确"原件显示"和"解析语义定位"是两层职责:前者负责视觉真实性,后者只提供坐标,不应重新绘制正文。
九、一次真实本地栈验收说明了什么
最终验收不是调用测试 fixture,也不是直接修改数据库状态。流程从 Nginx 入口开始,通过浏览器创建工作区、经 MinIO 上传一份 8 页的真实租赁合同样本,由生产配置 Worker 调用 MinerU 3.4.4 完成解析,再依次经过两个人工确认、三路 RAG、引用验证、报告展示和一次用户主动发起的对簿。
9.1 一次完整演示实际走过哪些页面和状态
- 用户登录后创建工作区,选择预期租赁领域和代表方;
- 浏览器申请上传意图,将 PDF 直传 MinIO,再 finalize 文档;
- 工作区实时显示解析状态,Worker 使用 MinerU 3.4.4 生成 ParseSet;
- 用户在解析工作台查看原 PDF 和结构化条款,点击"确认解析并继续";
- ReviewRun 到达第一人工节点后续跑,分类领域,再等待第二次人工确认;
- 用户核对双方名称、租赁领域和代表方,提交带 state version 的确认命令;
- Worker 从 PostgreSQL checkpoint 恢复,执行规则、三路 RAG、模型候选、引用验证和风险政策;
- 报告完成后,工作台展示颜色批注和问题轨道,点击问题可展开详情和定位原文;
- 用户选择一条争议问题,填写或选择质疑理由,显式创建对簿会话;
- 页面展示 Pro/Con/Judge 三阶段进度,完成后切换到新的报告版本;
- 用户对修改建议执行接受、拒绝或编辑后接受,刷新页面验证决定仍然存在;
- 修订导出前运行一致性检查和 Contract Health Check,未决决定或版本冲突作为硬阻断,其余风险由用户确认后决定是否继续。
这个路径的重点不是"页面都能点",而是每次页面跳转背后都有可验证的事实:上传对象、解析版本、人工确认、checkpoint、知识快照、报告版本、对簿会话和用户决定都能在刷新后恢复。
脱敏验收记录给出了以下可复核结果:
- MinerU 解析出 132 个条款;初始完整审查展示 8 个原文页面,后续 PDF 定位专项升级为 8 个 PDF.js Canvas 和 132 个无文本坐标锚点;
- 审查保存 17 个 checkpoint,并各经过一次解析确认和领域/代表方确认;
- 风险轨道展示 20 个问题,其中 7 个红色、12 个黄色、1 个绿色,其余 112 个条款未进入问题轨道;
- 点击风险详情可以看到已验证证据和权威来源,并定位到原文第 3 页,产生一次临时高亮;
- 三轮 Pro/Con/Judge 对簿形成 6 条论点,6 条均通过证据验证,并生成与基础报告不同的新版本;
- 用户"保留原文"的决定在刷新后仍然存在;
- 浏览器诊断记录中 console/page error 为 0,成功链路未使用 fake 或 fixture provider。
可靠性复验中,真实审查运行约 165.51 秒,页面无需手动刷新,也没有被旧的客户端超时截断。测试证据还覆盖了 Python 3.12 下的单元、契约、安全、集成和评估层,前端 30 个测试文件共 204 个测试通过,并完成 Nuxt 类型检查和生产镜像构建。
9.2 不只看容器 Up,而是做 production admission
Docker 容器处于 running 只说明入口进程没有退出,不能证明审查依赖可用。Worker 接受真实任务前会验证:
RUNTIME_PROFILE是批准的真实 profile;- MinerU 版本必须严格为 3.4.4;
- BGE-M3 和 BGE-Reranker 模型 manifest 通过文件完整性校验;
- embedding 维度为 1024;
legal_chunks_currentalias、mapping 和证据存在;- rent、labor、loan 三个领域都有 published knowledge snapshot;
- ModelGateway route 是批准的非 fake、非 fixture 配置。
任一条件不满足,Worker 在接收 review/debate work 前失败关闭。测试 Harness 仍可通过构造函数注入 fake provider,但 production profile 中没有"为了跑通演示临时切 fake"的环境变量后门。
9.3 测试矩阵覆盖了哪些失效方式
| 测试层 | 主要证明的问题 | 最新相关证据 |
|---|---|---|
| Unit | 状态机、图节点、RRF、引用验证、幂等、解析工厂、Vue 组件 | 后端 aggregate 记录 601 个 unit;Phase 25 前端目标测试 16/16 |
| Contract | OpenAPI、Problem Details、Schema 和生成客户端是否一致 | aggregate 记录 163 个 contract |
| Integration | PostgreSQL、RLS、Celery 恢复、MinIO、OpenSearch、完整任务事件 | aggregate 记录 107 个 integration,2 个显式 opt-in skip |
| Security | 跨用户访问、CSRF、Origin、日志脱敏、删除和模型隐私 | aggregate 记录 34 个 security |
| Evaluation | 高风险召回、误报、证据有效率、对簿和导出接受标准 | aggregate 记录 40 个 evaluation |
| Frontend | 页面状态、长轮询、条款定位、决定持久化、对簿命令 | 可靠性阶段 30 个文件 / 204 个测试;Phase 25 全量 206/206 |
| Browser E2E | 真实网关、真实文件、真实解析和依赖之间的缝隙 | 完整审查/对簿通过;PDF 定位专项 0 console error/warning |
测试数量不是文章的结论,关键是每一层都有不同职责。单元测试不能替代真实模型耗时,浏览器测试也不适合穷举所有引用适用性分支。把同一需求映射到恰当层级,才能兼顾反馈速度和跨层可信度。
9.4 最新 Phase 25 为什么仍保留一个人工门禁
Phase 25 已经用 Red/Green 测试证明确认按钮首屏可见、保存或编辑时禁用、只发出一次命令;真实浏览器也证明 PDF Canvas 和 bbox 定位不会重排页面。但新的真实页面没有自动点击"确认解析并继续"。
这是刻意保留的人类边界:点击按钮意味着用户认可当前解析文本和结构可以进入法律审查,自动化不应替用户作出这个事实确认。因此 T176 在任务清单中仍保持未完成,直到用户本人完成点击并观察 waiting state 正确转换。此前完整工作流已经通过两个人工节点,不影响这项最新 UI 改动需要单独复验的事实。
这些数字不能证明系统可以代替律师,也不能等同于线上生产部署。它们证明的是:同一条真实文件链路已经贯穿浏览器、API、对象存储、任务队列、真实解析器、本地向量模型、搜索索引、模型网关、人工节点、报告版本和用户决定,而不是停留在架构图或测试替身里。
十、技术选型不是终点:哪些边界被刻意保留
10.1 为什么主流程不用普通 if/else、AgentExecutor 或 Airflow
普通 if/else 可以快速写出线性 Demo,但当流程出现两个可恢复人工节点、引用修复循环、条件降级和多版本持久化后,分支会散落在服务函数中,难以观察"当前运行停在哪个业务节点"。LangGraph 的价值是把节点、边和状态转换显式化。
LangChain AgentExecutor 更适合模型自主选择工具,但合同审查不希望模型决定是否跳过引用验证或直接写报告。Airflow 则偏向批处理 DAG 和调度周期,不适合用户在网页中发起、暂停、确认并实时恢复的交互工作流。Dify/RAGFlow 可以快速搭建 AI 应用,却会让本项目已经严格定义的 RLS、不可变版本、REST 幂等和生成契约迁入平台私有抽象。
所以选择 LangGraph 不是因为它"更像 Agent",而是因为它能表达受限、可测试的状态图,同时允许我们把持久化事实继续掌握在 PostgreSQL 中。
10.2 为什么法律知识不在审查时临时联网搜索
普通审查不会在运行时对任意网页做开放搜索。法律结论需要可复现:同一份报告必须能说明当时使用了哪个来源版本、何时生效、来自哪个知识快照。如果实时搜索结果随排序、网页内容或网络状态改变,历史报告很难复核,恶意网页也可能把提示注入模型上下文。
项目采用"采集与发布"和"审查检索"分离的方式:法律法规、司法解释、官方示范文本等先成为 KnowledgeSource,经过来源、版本、有效期、内容哈希和领域标注后进入不可变 KnowledgeSnapshot;审查只查询已发布快照。来源 URL 和引用 locator 会展示给用户,但网页不是模型运行时的无审查工具。
这也意味着 1,211 条证据不能被描述为覆盖全部中国法律。它是当前租赁、劳动和借款领域经过版本化处理的知识基线。扩充知识库时应创建新 snapshot、重建派生索引、运行分领域 recall/MRR 与引用评估,达标后再切换 alias;历史报告继续绑定旧 snapshot。
10.3 什么时候才值得引入 GraphRAG、GPU 或微服务
技术升级需要由可观测瓶颈触发,而不是由名词热度触发:
| 候选升级 | 当前没有直接采用的原因 | 合理触发条件 |
|---|---|---|
| GraphRAG / Neo4j | 当前问题主要由字段过滤与混合召回解决 | 评估集持续出现需要跨法规、主体、时间关系的多跳失败 |
| Milvus | OpenSearch 已同时满足中文 BM25 与当前向量规模 | 向量规模或检索 SLA 超出 OpenSearch 可接受范围 |
| pgvector 单库方案 | 能减少组件,但中文全文、alias 切换和混合检索能力不如现有组合直接 | 运维成本成为主要矛盾且评估证明效果不下降 |
| GPU / ONNX / 量化 | 当前先要建立 CPU 的延迟、内存和吞吐基线 | 并发审查或 p90 延迟证明本地模型是主要瓶颈 |
| 拆分微服务 | 会提前引入跨服务一致性和权限传播 | 某模块达到稳定高并发并有明确独立扩缩容收益 |
| 更多自主 Agent | 会扩大成本与不可控工具调用 | 出现无法由固定图和领域包解决、且可被评估约束的任务 |
这种演进方式让架构有方向但不预支复杂度。项目不是拒绝新技术,而是要求每次替换都能回答:它解决了哪个已经测量到的业务问题,是否破坏证据、隔离或版本不变量,以及如何在评估集中证明收益。
10.4 产品边界同样需要诚实表达
系统提供的是合同风险辅助审查,不是律师执业意见。红色表示在当前解析、知识快照、规则和证据政策下存在已验证高风险,不代表法院必然作出某种裁判;绿色也只表示在已审查范围内没有发现可证实风险,不代表合同绝对安全。
PDF 原件可以通过 Canvas 保留页面视觉并做坐标定位,但从 PDF 或图片生成新的可编辑 DOCX/PDF 时,字体、分页、签章和手写内容仍可能无法完全保真。导出前的 Contract Health Check 会显示这些限制和未处理风险,但除未决必选决定或版本冲突外,不替用户作出是否继续导出的商业选择。
十一、总结:AI 应该被放进可验证的业务系统,而不是成为事实源
CounterClause 最重要的工程结果,不是"接入了多少模型",而是形成了一个可追溯的合同决策闭环:原件和解析版本不可变,人工确认能够跨进程恢复,非灰色结论必须通过证据门禁,多智能体对抗只能由用户主动触发,任何修改都要回到逐条用户决定。
这套设计背后有几条可以迁移到其他高风险 AI 产品的经验:
- 先定义事实、版本和人工权限,再决定模型能做什么;
- 把工作流状态放在持久化事实源中,不让浏览器或 Worker 内存承担恢复责任;
- RAG 的价值不只是增加上下文,而是建立"无证据就降级"的输出政策;
- 多智能体适合处理用户明确提出的争议,不适合成为每次请求的默认装饰;
- 真实浏览器验收应该贯穿所有基础设施,因为最难的问题往往出现在组件边界。
当 AI 的输出被锚定到原文、证据、版本和用户决定之后,它才从一次不可控的生成,变成一个可以核对、质疑、恢复和继续工作的业务系统。