# ADR-0014: billcost base 报价与图片 overlay 统一抽取格式 (复用 WMS `cost_type` 区分标准/调价成本) + overlay 经引擎跨层核对进统一校验 (不合并数据 / 不动 base 反射器 / 人确认图为前置) - Status: Accepted (PM 2026-06-04 确认形状, 待实现) - Supersedes / extends: ADR-0013 (内嵌图 overlay 落库 + 统一分析视图). 本 ADR 把 ADR-0013 的"统一分析视图"从**被动展示**升为**主动跨层核对**, 并统一两条抽取路的字段格式. - 设计依据: design workflow wf_23d1351e (8 agent 并行研究两边 schema + 校验管线 + 当前 overlay 流向, 综合 + 三视角对抗 critique). critique 揪出的两个事实修正已并入决策 (见 §2.4 / §3). --- ## 1. 背景 / Context ### 1.1 两条抽取路, 格式几乎一套但缺一个判别字段 billcost 有两条把原始资料抽成结构化报价的路: - **base 报价** (`sub_agent.md`, 表格 -> JSON, 落 `phase_1_result`): WMS master + N detail. 定价是**绝对一口价** (`base_amount` 首重价 / `increment_price` 续重价, 单位元). 有 `is_target_specific` / `target_sites` / `is_delivery_fee` / `is_strip_fee` 全套调价路由 flag (实证: `sub_agent.md` schema + 示例已带). **无 per-row 日期** (日期在 master). **无 `cost_type` 字段** (隐含标准成本). - **图片 overlay** (`_vision.md`, 内嵌图 -> JSON, 落 `quote_dispatch_adjustment_*`): 同 WMS master + N detail 形态. 临时加价是 **delta** (`other_amount` "加收 X 元"). master 已带 `cost_type=1` (AdjustedCost, ADR-0013 §2.3 DDL 固定). 两边字段名**本就基本一套** (overlay 当年照 base 的 WMS 形态设计). 真正的差异只有: overlay 多 `other_amount` (delta) + master `cost_type=1`; base 是绝对价 + 隐含 `cost_type=0`. ### 1.2 当前态缺口: overlay 是"人审终点", 没有任何引擎校验 ADR-0013 把 overlay 做到了: VLM 抽 -> 人审卡 (可编辑) -> confirm 落库 -> `/analysis` 分层展示. 但: - **缺口 1**: overlay 落库后**没有任何引擎核对**. base 报价过 sub->反射器->main->audit 一整套 (核对/检查/旁观); overlay 只有"人眼审一下就存". F-WMS 已诚实记: overlay 是 terminal sink. - **缺口 2**: `/analysis` 的 coverage verdict (matched/orphan/unverifiable) 是**浏览器 `main-screen.jsx` 现算**的展示 label, 不是服务端权威结果. 换个客户端 / 不刷新就没有. ### 1.3 PM 诉求 (2026-06-04, 经两轮澄清) 1. **格式统一, 不是数据统一**: 两条路输出**同一套字段格式**, 但 base + overlay 数据**永远分两份, 不合并** (ADR-0013 F-LAYER never-flatten). PM 原话: "数据格式统一, 没让你把数据统一". 2. **overlay 走统一校验**: 图片解析不该是人审终点, 要跟 base 一样过引擎核对 -- 因为"你也不确定解析的是不是对". 但**图片解析结果可以让人先确认** (看图是人的强项). 3. **加收成本即调价成本**: PM 指出加收金额"设置成调价成本不就行了么? 到时候要计算的时候肯定一起计算了" -- 即复用 WMS `cost_type` (标准/调价), 计费合并是**未来**那一步, cost_type 现在把地基铺好. ### 1.4 图片真相是图, 引擎是文字 (硬约束) overlay 的真相是**那张图**. main/audit/反射器是文字模型, **不能重读图**. 故引擎对 overlay 的校验只能查**结构 / 内部一致 / 跨层一致**, 不能重核"抽取是否忠于原图" -- 那是**人确认图**那一步的事. 这条约束决定了 §2.5 的分工. --- ## 2. 决策 / Decision ### 2.0 confirmed decisions (设计前提, 不 relitigate) 1. **格式统一 != 数据合并**. 两条路一套字段; 数据两份分存 (`phase_1_result` / `quote_dispatch_adjustment_*`), 永不 join / flatten / 叠加. 2. **layer 身份靠 source table, 不靠 flag**. base 行也合法带 `is_delivery_fee` 等 (ADR-0013 F-LAYER); 判别字段是 `cost_type` 这个**值**, 不是 `is_overlay` 这种层标记. 3. **人确认图 = 前置 gate**. 不省. 引擎校验在人 confirm 之后, 对已确认数据跑. 4. **计费合并 = 未来 / 本 ADR 不做**. cost_type 铺地基, 真算 (标准 + 调价 按 grain 相加) 是后续独立工作. ### 2.1 复用 WMS `cost_type` 做标准/调价判别 (否决新造 `price_kind`) WMS 本有 `cost_type`: **标准成本 = 0 / 调价成本 = 1**. overlay 落库已是 `cost_type=1`. 故: - **base master 补 `cost_type=0`** (一份 base 报价整体是标准成本). overlay master 已 `cost_type=1`. - 加收金额就是**调价成本的金额**, `cost_type` 标着它"是调价". **不存在"delta 还是绝对价"的歧义** -- 设计早期一版想加 `price_kind` (absolute|delta) 判别, 被 PM 这条消解: cost_type 已经区分了标准 vs 调价, 不需要第二个判别字段. - 计费时 (未来): 按 grain 取 `cost_type=0` 标准 + `cost_type=1` 调价**一起算**. cost_type 是这步的天然钥匙. ### 2.2 统一字段集 (superset, 同 schema 两份填充) 两条路输出**同一套字段** (WMS master + detail 形态). 每条路填自己用得到的, 用不到的留空/0: - **共有** (两边都有, 已对齐): `province_name` / `city_name` / `limit_bottom` / `limit_top` / `base_weight` / `base_amount` / `increment_weight` / `increment_price` / `is_target_specific` / `target_sites` / `is_delivery_fee` / `is_strip_fee`. - **新增判别**: `cost_type` (0 标准 / 1 调价) -- base 补 0, overlay 已 1. - **overlay 用 base 多半空**: `other_amount` (调价 delta 金额). base 恒空/0. - **base 用 overlay 多半空**: 多段重量阶梯 / 偏远行 / `return_fee` / `master.raw_extraction` prose. overlay 恒空. - **保留位**: per-row `start_date`/`end_date` (Go struct 已有, 两边今天都只在 master 填; 留作 superset, **本 ADR 不让任一路真填** -- 见 §4.2 risk). base 抽取的 JSON decoder 是 lenient (`json.NewDecoder` 无 `DisallowUnknownFields`, 全仓实证), 故给 `sub_agent.md` 加 `cost_type` 不会破坏 base 解析; 是叠加非破坏. ### 2.3 跨层核对 = 核心净增价值 (matched / orphan / unverifiable), 服务端**读时算**不持久化 这是本 ADR 的**主价值**, 不是反射器 (见 §2.4 诚实): 引擎能查、而人盯着图**看不出来**的是 -- 这条加价对应的 (省/网点/重量段) 在 base 报价里**有没有覆盖**, 加价**生效期是否落在** base 有效期内. - 三态 verdict (沿 ADR-0013 §2.8): **matched** (base 有对应 grain) / **orphan** (base 无对应 -- 这个加价覆盖不到任何基础报价, 多半抽错或 base 漏) / **unverifiable** (target_sites 网点级, base 是省级无法对应). - **全国落库展开成 31 省 (v2 逆转, 2026-06-04 PM 实测后)**: overlay `province_name="全国"/"全境"` 在 **confirm 落库时**由代码 (`expandNationwide`, 非 VLM/prompt) 展开成 31 个大陆省行, 同段被单独列出的省覆盖其展开行 (偏远价赢, band-scoped). **全国行一律展开无例外**: 全国 + `target_sites` 是矛盾口径 (全国 vs 网点级), 真实数据零出现 (2026-06-04 实测全历史落库 全国行与网点行零重叠), 故不开特例 -- 放过去反会把 "全国" 漏进 DB (本函数要消灭的). PM 原话: "数据库里并没有全国这个省份, 实际落库的时候应该把全国拆解成每个省份, 没必要让 LLM 做来浪费 token" + "1.31 省". **草稿态** (preview / `/analysis` 重算) 仍 match-time 展开 (`coverageFor`); **落库态**已是 31 省无需 view-time 展. 31 省名实测等于 base `phase_1_result` distinct `province_name` (短名), 顺带把 explicit 省行归一到 base 短名词表 (`covCanonProv`) 兑现 §2.1 格式统一. **原方案 (superseded for persist)**: v1 设计是"全国落字面 + 仅 view-time 展开, 绝不落库展开 (F-COV: 怕展开后偏远省静默丢)" -- v2 用 band-scoped override 解决了偏远丢失 (偏远行覆盖其展开行), 且 PM 指出真实 DB 不存"全国"字面, 落库展开才与 WMS 同构 (base 报价的 `expandPartitions` 同样 persist 展开, 实证一致); F-COV "静默 orphan" 风险由"落库无全国字面 -> 比对见的是已展开的真省"消除. **草稿/未落库**态仍按 v1 字面+view-time 展开. - **服务端权威 + 读时算, 不持久化**: 把 `coverageVerdict` 从浏览器 `main-screen.jsx` 移到服务端, `GET /analysis` 每次**实时算**返回. **不持久化** -- 因为 `SetPhase1Result` 是无状态守卫的 `UPDATE` (ADR-0013 §2.6 支持 re-open + 重抽), 持久化的 verdict 会在 base 重抽后**对着不存在的旧 base 过期** (critique 3 实证 staleness). 读时算永远对当前 base, 零 staleness. - **只读对应, 不合并**: 给每条 overlay 行算 verdict, **不**把价叠到 base, 不 join, 不改 base. ### 2.4 overlay 结构校验 = 单独轻校验, **不动 base 共享反射器** (critique 事实修正) 设计早期一版想"让 overlay 走同一个反射器, 换个 config preset". critique 1+2 实证这**对本仓不成立**: `billcost.ReflectConfig` 只有 2 个旋钮 (`ExpectedPartitions` / `MaxWeightG`); `reflect.go` 里只有 coverage 和 `segment_end_short` 受 config 门控, 其余 per-partition 规则 (`segment_order` / `segment_gap` / `segment_overlap` / `segment_start_nonzero` / `monotonic` / `first_lt_increment` / `band_incomplete`) **全无条件跑**. 故"加个 preset 关掉这些"**做不到** -- 要么改 `reflect.go` 加 per-rule 门控 (那是 base dispatch loop 在跑的**共享**文件, 动它连累 base 报价, 违 PM 约束). 决策: overlay 用**独立的轻量结构校验** (新文件, 不碰 `billcost.reflect`): - 稀疏 overlay 不是连续 31 省大表, 故**不套** coverage / 段升序 / 段接吻 / 起点为零 / 单调 等连续表规则. - 查 overlay 该查的: **每条恰好一类成本** (标准价字段 set 或 `other_amount` set, 与 `cost_type` 一致) / 重量段自身 sanity (`limit_top > limit_bottom`) / `duplicate` 行 / target_sites 与 `is_target_specific` 一致. - 这个轻校验是**结构地板**, 主信号仍是 §2.3 跨层核对. ### 2.5 人确认图 = 前置 gate, 引擎不重读图 (图片真相边界) - 人 confirm 图: 看图、改抽错、定稿. **这是 overlay 抽取忠实度的唯一裁判** (引擎不能重读图). - confirm 后引擎跑 §2.3 跨层核对 + §2.4 结构校验 -- 全在文字层 (base = `Phase1Result`, overlay = 已确认 payload, confirm 时都在引擎内), 满足"不重读图 + 不合并". - **诚实边界**: "加收金额填错" 这类 (人把调价金额填错数) 引擎查不出 -- 但 §2.1 cost_type 让"它是调价"无歧义, 人的活只剩"金额/范围对不对", 编辑器让人改, 引擎不假装能 auto-flag. ### 2.5.1 VLM 审核纠错环 (v3, 2026-06-04 PM "必须得做") §2.5 的"引擎不重读图"约束的是**文字模型** (main/audit/反射器). **VLM 本身可以重读图** -- 这正是 LLM 视觉审核 (VisionAuditor / `_vision_audit.md`): 用对抗 prompt **独立 fresh 重读**本图审核先前抽取. v3 把审核从"只标注给人看"升为**纠错环**: 审核报真问题时, 把"上一遍 bundle + 审核意见"作为反馈**回灌 VLM 重抽** (仿基础报价 `ParseQuoteWithFeedback`). - **护栏 (关键)**: 纠错 bundle 只在**不比原来差** (`visionBundleRichness`: 明细数/有价行/网点/日期 >=) 时才替换第一遍. live 实测**纠错被错误应用会越纠越乱** (如把金额从 other_amount 挪进 increment_price), 故更差的重抽**直接拒, 保留第一遍**. 护栏拦覆盖丢失, **不拦**覆盖不变的值质量退化 -- 那仍交 §2.5 人审闸. - **纠错环上限 = 审核模型能力** (live 实测): 有能力的模型 (e4b-8bit) 经纠错能把跨页日期 / 漏网点补回; 4-bit 弱模型审核自身会幻觉 (续重 1 元说成 0.5 元) -> 反馈带毒救不回. **零容忍标准下 ("接近正确=不正确") 没有任何本地模型 + 纠错能保证复杂表全对** -- 生产仍用 MiniMax + 人审定稿. 纠错环是**抬高草稿质量 + 少漏**, 不是替代人审. - **诚实定位**: 人确认图仍是 overlay 忠实度的**唯一最终裁判** (§2.5 不变). 纠错环只是让送到人面前的草稿更全更准, 人改得更少. ### 2.6 计费合并 (标准 + 调价 一起算) = 未来 / deferred cost_type 把地基铺好. 真正"按 grain 取 cost_type=0 + cost_type=1 相加出最终价"是后续独立工作 (与 ADR-0013 F-WMS / C7 时间基准 join 同族). 本 ADR **不做**. 现在只到"同格式两份 typed + 跨层核对". --- ## 3. 替代方案 / Alternatives - **新造 `price_kind` (absolute|delta) 判别字段**: 否决. WMS `cost_type` 已区分标准/调价, 第二个判别字段冗余 (PM 指正). 复用 cost_type 还顺手给未来计费铺钥匙. - **持久化 coverage verdict 到新列/表**: 否决. base 可 re-open 重抽 (`SetPhase1Result` 无状态守卫 UPDATE), 持久化 verdict 会 staleness (critique 3 实证). 改为**读时算**. - **overlay 走 base 共享反射器 (换 config preset)**: 否决. ReflectConfig 只 2 旋钮, 关连续表规则要改共享 `reflect.go` 连累 base (critique 1+2 实证). 改为**独立轻校验**. - **数据合并 / 叠加 delta 到 base 价 / 时间基准 join / 算合并后最终价**: 否决 (PM 明确否, ADR-0013 F-LAYER). 要"示意合计价"只标"示意·非系统价", 不落库不权威. - **升级 `_vision.md` 真填 per-row 日期**: deferred. struct 留位但不填; 是有风险的 prompt 改动, 当前通知不 demonstrably 需要 (§4.2 risk 1). --- ## 4. 影响 / Consequences ### 4.1 正面 - overlay 不再是人审终点: confirm 后过引擎跨层核对 + 结构校验, 跟 base 同级校验严肃度 (缺口 1 闭). - coverage verdict 成服务端权威实时结果, 非浏览器 only 展示 (缺口 2 闭). 换客户端 / SDK 消费者都拿得到. - 两条路一套格式 (`cost_type` 判别), 给未来计费合并铺地基, 且**不**现在合并 (守 never-flatten). - 复用 WMS cost_type, 零新造概念; base 加字段走 lenient decoder, 叠加非破坏. ### 4.2 诚实记录的风险 / 边界 (不埋) 1. **per-row 日期是保留位, 没人真填** (open question, 需 PM 决): superset 留了 per-row `start_date`/`end_date` 但两边都只在 master 填. 升级 `_vision.md` 真填是有风险 prompt 改动, 当前通知不需要 -> deferred 至真有通知要求. 2. **引擎查不出"调价金额填错数"** (honesty boundary): `other_amount=1.5` 对 vs 填成 `1.6` 错, 都是良构行, 引擎只查良构不查"对不对原图". 只有人看图能抓. 不宣称引擎"handles" 这个. 3. **稀疏 overlay 结构校验抓得少**: overlay 不是连续表, 大部分连续表规则不适用 -> 独立轻校验是地板, 真价值在跨层核对. 别把"过了结构校验"当"overlay 对了". 4. **base 报价契约新增 `cost_type`**: lenient decoder 不破坏, 但是动了 base 的 LLM 契约 (sub_agent.md). 改动小 (恒填 0), 但需回归验 base 抽取不退化. ### 4.3 trap (实现时易踩, 显式 flag) - **别动 `core/pkg/billcost/reflect.go`** 给 overlay 加门控 -- 那是 base dispatch loop 共享的, 动它连累 base. overlay 走**独立**校验文件. - **coverage verdict 别持久化** -- base 重抽会 staleness. 读时算. - **~~全国别落库展开~~ -> v2 逆转: 全国落库展开成 31 省 (`expandNationwide`, band-scoped override 防偏远丢)** -- 草稿态仍字面 + view-time 展开 (§2.3 v2 逆转). 真实 DB 无"全国"字面, 落库展开才与 WMS 同构. --- ## 5. 验证 / Validation - **格式 round-trip**: base 抽取加 `cost_type=0` 后 JSON 解析不退化 (lenient decoder + 回归真跑 ytosample base 抽取对真值不变); overlay `cost_type=1` round-trip. - **跨层核对 (服务端读时算)**: 单测构造 base (省级) + overlay (matched 省 / orphan 省 / target_sites 网点级 unverifiable / 全国草稿), 断言 `/analysis` 返三态正确 + 全国 view-time 展开 matched (草稿态) + base 重抽后 verdict 实时跟新 base 走 (不 staleness). **落库态全国已 persist 展开成 31 省 (v2 §2.3)**, 端到端实测 (真 confirm + 真 Postgres) 落库零"全国"省级行 / 偏远 override 价赢 / 31 省完整. - **overlay 独立结构校验**: 单测 "恰好一类成本" (cost_type 与字段一致) / 重量段 sanity / duplicate / target_sites 一致; 对真 ytosample 内嵌图跑不误报. - **不合并 / 不动 base**: 断言 base `phase_1_result` 在跨层核对后逐字节不变; 断言 `billcost.reflect` 文件未被 overlay 路径调用 (grep / 测试隔离). - 全量 `go test -race` + jsxcheck + 真 ytosample 端到端 (base + 6 图 overlay -> 跨层核对) 对真值. ## 6. 触发重新评估的条件 / Trigger conditions - 真有通知要求 **per-row 日期** -> 升级 `_vision.md` + struct 真填 (当前保留位). - 要做**计费合并** (标准 + 调价 出最终价) -> 独立 ADR (cost_type 地基已在; C7 时间基准 join 在那处理). - 要 **WMS production push** (overlay 从 review artifact 变生产价源) -> F-WMS 解封, 独立评估. - overlay 独立结构校验**抓不到真问题** (价值全在跨层核对) -> 重估是否保留结构校验或合进跨层. ## 7. 工程量 / Engineering footprint - **schema/prompt**: `sub_agent.md` 加 `cost_type: 0` (恒); overlay 路径 stamp `cost_type=1` (master 已有, 确认 detail 一致). 无新造字段. - **全国落库展开 (v2 §2.3, 已实现)**: `expandNationwide` 在 `handleQuoteDispatchAdjustmentsConfirm` 的 `SaveAdjustments` 前把全国行展成 31 省 + explicit 省名归一 (`covCanonProv`); 草稿态 coverage 仍 match-time 展开. - **跨层核对服务端化**: `handleQuoteDispatchAnalysis` 加服务端 coverage 计算 (matched/orphan/unverifiable + 全国草稿态 view-time 展开), `/analysis` 返权威 verdict; 前端 `main-screen.jsx` 的 `coverageVerdict` 改读服务端结果 (浏览器不再现算). - **overlay 独立结构校验**: 新文件 (不碰 `billcost.reflect`), 接 `handleQuoteDispatchAdjustmentsConfirm` (post-`SaveAdjustments`) 跑, 违规 emit/记. - **存储**: 无新持久化 verdict 列 (读时算). base 表加 `cost_type` 字段表达 (master 级). - **测试**: 跨层核对单测 + overlay 校验单测 + base 格式回归 + e2e 对真值. - **不做**: 数据 merge / 时间 join / 算合并价 / 改 WMS / 动 base 反射器 / 持久化 verdict / per-row 日期真填. ## 8. 修订记录 / Revision history - 2026-06-04 v1 (Accepted, 待实现): PM 经两轮澄清确认形状 (格式统一非数据合并 / 复用 cost_type 非新造 price_kind / 跨层核对读时算非持久化 / overlay 独立校验非动 base 反射器 / 人确认图为前置 / 计费合并 deferred). 设计依据 design workflow wf_23d1351e + 三视角对抗 critique (修正"换 config preset 走共享反射器"不可行 + "持久化 verdict" staleness 两个事实错误). - 2026-06-04 v2 (§2.1 格式统一 + §2.3 全国落库展开 已实现): PM 实测 v1 后**逆转** "全国绝不落库展开" (F-COV) -> 改为 confirm 落库时代码展开成 31 省 (`expandNationwide`, band-scoped override 防偏远丢; 全国行一律展开无例外 -- PM 指出 全国+网点 是矛盾口径真实数据零出现, 实测确认, 不开特例否则 全国 漏进 DB), explicit 省名归一到 base 短名词表 (`covCanonProv`). 理由: 真实 DB 不存"全国"字面 (base 报价的 `expandPartitions` 同样 persist 展开, 实证一致), 让 LLM 展开浪费 token. 31 省名实测等于 base `phase_1_result` distinct `province_name`; 真 confirm + 真 Postgres 端到端实测落库正确 (零全国省级行 / 偏远 override / 31 省完整 / 广东省->广东 归一). 草稿态保持 v1 字面 + view-time 展开. **§2.3 跨层核对服务端化 / §2.4 overlay 独立结构校验仍待实现**. - 2026-06-05 v3 (§2.5.1 VLM 审核纠错环 已实现): PM 实测各本地 gemma4 视觉模型 + 对真图逐字段核对后, 确立两条标准/决策: (a) **零容忍** -- "接近正确=不正确", 复杂表少一个字段就算错, 故**生产用 MiniMax** (本地 gemma4 仅简单派费通知能抽对); (b) **审核纠错环必须做** ("没法讨巧"). 实现: `extractEmbeddedImageNotices` 抽取 -> LLM 审核 -> 审核报真问题时带反馈重抽 (`visionAuditFoundIssues` 判触发), 护栏 `visionBundleRichness` 只采纳不比原来差的纠错 (防越纠越乱), 采纳页 emit `corrected:true`. 顺带 `defaultVisionTimeout` 180s->300s (最复杂通知在推理重 VLM 上单次 ~196s). live e2e 实证: e4b-8bit image5+6 经纠错环, 跨页日期 10-21..11-12 准确 lift + corrected 标记 + 派费/续重/网点补全 (但首重金额仍漏 -> 印证人审仍是最终闸). 5 确定性单测 + 全量 -race 绿. 已部署 m2max.