Просмотр исходного кода

feat(skill) v2: 企微实战复盘升级——magic-byte嗅探/GLM族回退/质量门/fpc auto一键流水线/口径一致性回归测试

- parse.js: sniffImageMime() 魔数嗅探, .bin/无后缀自动识别 (企微缓存兼容)
- vision.js: GLM族回退链(glm-5.3-flash→z-ai→glm-5.3), isSubstantive替代image_tokens误判,
  assessQuality质量门(低质量不出单,退出码3), dims匹配升级(占用排除+开间优先), 网络重试+超时
- index.js: fpc auto 一键流水线(嗅探→识别→质量门→报价→Playwright触屏回归→公告→JSON摘要)
- test: +6项回归(18全过), 含报告内嵌口径一致性锁死
- changelog: 企微实战复盘与v2升级
Hermes Agent 2 недель назад
Родитель
Сommit
e3bc298cee

+ 46 - 0
changelog/user/2026-09-01-企微实战复盘与v2升级.md

@@ -0,0 +1,46 @@
+# 复盘 · 2026-09-01 · 企微实战首单暴露的断点与 v2 升级
+
+> 事件:企微销售发来户型图(63KB JPEG,缓存为 `.bin` 无后缀)+「做下这个户型报价单」。
+> 这是技能上线后第一次真实实战,暴露四个断点,全部复盘并落地修复(skill v2)。
+
+## 一、事件经过(Session 20260901_043643_e6a3c1ab)
+
+1. 技能跑通但**静默降级**:glm-5.3-flash 返回「未收到图片」,管线未拦截,用默认尺寸 1.5×2.6 出了 7 组柜体 ¥129,500 的垃圾报价
+2. 人工发现尺寸可疑 → vision_analyze 备援读图成功(证明图没问题)→ 调试发现 `usage.prompt_tokens_details.image_tokens=0` → **误判 GLM 不吃图 → 违规切换 gpt-4o** 跑识别 → 人肉按 gpt-4o 结果重建报告(绕过技能)
+3. 后续又发现报告内嵌状态丢 `walls`/柜高 → 页面口径 ¥136,550 ≠ 服务端 ¥123,900 → 二次修复(c45b0ef)
+
+## 二、根因
+
+| # | 根因 | 影响 |
+|---|---|---|
+| 1 | parse.js 只认扩展名,不嗅探 magic bytes;企微缓存文件无后缀 | 一进来就靠人肉 cp 才能跑 |
+| 2 | **网关 `usage.prompt_tokens_details.image_tokens` 恒为 0(统计 bug)**——事后用三种 MIME 实测 GLM-5.3-flash 均能正确回答图片内容(img_tokens 恒 0 但回答正确)。代码与人肉判断都踩了同一个坑 | 误判 → 违规换模型 → 人肉重建,浪费 10+ 轮 |
+| 3 | 无识别质量门:尺寸全默认值、confidence 全低的报价单照样出街 | 差点把垃圾报价发到客户群 |
+| 4 | 无一键流水线:sniff→quote→验证→公告 全靠 agent 手工粘合 | 交互来回,速度慢、易错 |
+| 5 | (已在 c45b0ef 修)报告内嵌状态丢 walls/柜高 | 页面口径与服务端不一致 |
+
+## 三、v2 升级内容(全部已实现)
+
+- **A** `parse.js`:`sniffImageMime()` magic bytes 嗅探(jpeg/png/webp/gif/bmp),`.bin`/无后缀自动识别;`loadDocument` 对未知类型给出明确报错指引
+- **B** `vision.js`:
+  - 模型纪律落地:`VLM_MODELS = glm-5.3-flash → z-ai/glm-5.3-flash → glm-5.3`(GLM 族内回退,禁止跨族)
+  - `isSubstantive()`:判定图片是否送达的唯一标准是「回复是否含与图片相关实质内容」,**废除 image_tokens 判据**
+  - `assessQuality()` 质量门:尺寸线直读(+2/条≤4) + 高置信柜体(+1/个≤3) + 厨房(+1) + 柜体数≥3(+1);≤2 分或全部 confidence<0.5 → 不出单
+  - PROMPT_MEASURE 纳入实战维度推断规则(墙厚 120、衣柜贴整边、阳台扣洗衣机位 0.7m、厨房 L 型直读)
+  - callVLM 网络抖动重试 3 次
+- **C** `index.js`:`fpc auto` 一键流水线——附件→识别→**质量门(不过退出码 3 并提示向销售补什么)**→报价→报告→Playwright 触屏回归(页面价≠CLI 价则退出码 4)→群发公告 Markdown→JSON 摘要
+- **D** `test/run.js`:+6 项回归(嗅探/质量门×2/口径一致性锁死/公告生成),共 18 项
+- **E** SKILL.md v2:auto 优先、企微兼容说明、模型纪律、群发指引
+
+## 四、纪律(新增)
+
+1. **模型纪律**:VLM 只用 GLM 族;`image_tokens=0` 是网关统计 bug,不是失败信号;失败判定以「回复是否与图片内容相关」为准
+2. **质量纪律**:低置信度报价不出街;宁可问销售要尺寸,不发默认值垃圾单
+3. **流程纪律**:实战走 `fpc auto`,不许人肉拆步骤;群发走 `send_wecom_doc.py`(文字走 hermes send)
+4. **口径纪律**:任何报告渲染改动必须跑 D3 口径一致性测试(页面重算=服务端到手价)
+
+## 五、验证记录
+
+- `node test/run.js`:**18 通过, 0 失败**
+- 企微实战样例 `.bin` 端到端:见 auto 流水线验收(本文件提交时的同批验证)
+- 群发能力:adapter 直发已实战验证(报价单+公告发到硅基生命测试群)

+ 25 - 0
changelog/user/2026-09-01-口径一致性修复与ClaudeCode接入.md

@@ -0,0 +1,25 @@
+# 协作记录 · 2026-09-01 · MGT-041 落地与口径一致性修复
+
+## 一、背景
+用户拍板:glm-5.3-flash 为主力跑通;报告默认展示原始户型图(重绘与原图结构差距大,SVG 仅实验);手机触屏「调整报价」操作台点击无效需修复;专业开发交给 Claude Code(经本地协议代理 @ fmode 网关 deepseek-v4-flash,禁 Sonnet)。
+
+## 二、Claude Code 接入(fcc.sh)
+- 网关 anthropic→openai 翻译层对 glm 思考块 502 → 本地 gw-proxy.js(127.0.0.1:8787)做 Anthropic↔OpenAI 协议转换并过滤 reasoning
+- 模型环境变量全家桶钉死 deepseek-v4-flash(SONNET/OPUS/HAIKU/SUBAGENT 全覆盖),禁 Sonnet
+- CLAUDE.md 承载任务上下文,print 模式派活,允许工具 Read/Edit/Write/Bash/Glob/Grep
+
+## 三、修复内容
+1. **Bug1 原图优先**:renderReport 增加 preferSVG 开关,默认 imageDataUrl(原图)展示
+2. **Bug2 触屏**:document 级事件委托 + click/touchend 双通道(12px 滑动阈值防误触)+ touch-action:manipulation + 页内 toast 替代 alert(微信内置浏览器兼容)
+3. **口径一致性**(另一报价 session 遗留):内嵌 __Q__ 状态补全厨房 walls(计费墙长)与各柜真实高度(从 dims 还原),页面加载重算 = 服务端到手价(¥123,900 验证一致)
+4. WeCom 群发:hermes send CLI 不支持 wecom 附件 → 改用插件 adapter(WeComAdapter.send_document)直发;文字用 hermes send 正常
+
+## 四、验证
+- 12 项单测全过
+- Playwright iPhone 13 触屏仿真:tap 全链路(开抽屉/切材质/切分型/关抽屉/toast)PASS,零 JS 报错
+- 两张实测图(时代之城 110㎡ 三房 / 6840×7230 两房)端到端出单
+- git: 5f2a821 → bb38f11 → 04be258 → c45b0ef(main)
+
+## 五、遗留
+- 个人 Storage→OBS→s3.fmode.cn 上传通道:s3.fmode.cn 为只读预览域(PUT 405),server.fmode.cn/parse 文件模块损坏(GridFS/mongo 未连接,任何文件 130),签发端点不存在 → 需平台管理员提供 OBS AK/SK 或修复文件服务(publish.js 已就绪,拿到凭证即通)
+- Vue 附件真链路待 WeCom 群展示验收

+ 102 - 28
skill/CLAUDE.md

@@ -1,30 +1,104 @@
-# floorplan-quotation skill 开发上下文(给 Claude Code)
-
-## 项目
-拉迷全屋定制「户型图→报价单」智能体技能,纯 JS ESM(浏览器/CLI 共用)。
-- `skill/src/` 各模块:defaults.js(规则库) quote.js(计价) vision.js(GLM视觉识别) geometry.js redraw.js(SVG重绘) parse.js(PDF/图片) report.js(HTML报告) publish.js(S3) index.js(管线+CLI)
-- CLI:`node bin/fpc.js quote <图> --client X --tier standard`
-- 测试:`node test/run.js`(12 项断言,必须全过)
-- 计价口径与公司 Excel 一致:投影面积×三档材质区间单价;厨房按计费延米(L/U型扣转角0.6m×处数+冰箱位1m);客户三型 price/standard/quality;谈价区间=到手价±浮动率
-
-## 当前任务(用户反馈的两个 bug)
-
-### Bug 1(必须修):报告默认展示重绘 SVG,但与原图结构相差太大
-用户要求:**默认展示原始户型图**。修改 `skill/src/report.js` 的 renderReport:
-- planBlock 逻辑改为:`imageDataUrl` 存在时**优先原图**(`.plan-img`),SVG 仅在 `opts.preferSVG === true` 时使用
-- 同步改 `skill/src/index.js`:quoteFromFile 不再把 planSVG 传给 renderReport(或传 preferSVG:false)
-- 保留 redraw.js 不删(未来实验用)
-
-### Bug 2(必须修):手机上「调整报价」操作台点击无效
-桌面浏览器正常、手机无效 → 触屏兼容问题。修改 report.js 内嵌脚本与样式:
-- 所有交互元素加 `cursor:pointer; touch-action:manipulation; -webkit-tap-highlight-color:transparent`
-- 事件绑定从裸 `addEventListener('click')` 改为**统一事件委托**且同时监听 `click` 与 `touchend`(touchend 里 preventDefault 防止幽灵 click 双触发),委托到 document 级
-- `.chip`/`.btn`/`.del`/`.room-h` 全部走委托;input 的 change 事件用委托 `change` 监听
-- `#cabList` 行内 input 的 `data-i` 索引绑定逻辑重写:委托模式下从 `e.target` 读 dataset
-- 不要用 alert()(微信内置浏览器可能屏蔽),改用页内 toast div
-- 检查 CSS:`:active` 状态替代 `:hover`(触屏无 hover)
+# floorplan-quotation skill 升级任务(v2 · 企微实战复盘版)
+
+## 背景(必读)
+
+上线后第一次实战(企微销售发图出报价)暴露三个断点,本次升级全部修复。证据链:
+1. 企微发来的图片缓存为 `.bin` 无后缀 → 当时人肉判断是 JPEG 后手动 cp 成 .jpg 才跑
+2. GLM-5.3-flash 带图调用返回"未收到图片"→ 当时误判模型不吃图 → 违规换 gpt-4o → 又人肉重建报告
+   - **已实证**:GLM-5.3-flash 能正常看图(三种 MIME 均正确回答图片内容),网关的 `usage.prompt_tokens_details.image_tokens` 统计恒为 0(网关统计 bug)。**今后禁止以 image_tokens==0 判断图片未送达;禁止换模型;GLM-5.3-flash 是唯一主力**
+3. 报告 HTML 内嵌状态丢 `walls`/柜高 → 页面重算口径 ≠ 服务端口径(已在 c45b0ef 修过一次,回归测试要锁死)
+4. 没有"一条命令从企微附件到群消息"的流水线,agent 每步人肉粘合,浪费大量轮次
+
+## 环境事实(不要重新探索)
+
+- 技能根:`/opt/data/floorplan-quotation/skill/`(仓库 `/opt/data/floorplan-quotation`,remote main 可推)
+- API:`https://api.fmode.cn/v1/chat/completions`,key 从 `/opt/data/config.yaml` 的 `model.api_key` 读
+- **VLM 只用 `glm-5.3-flash`**(思考型:正文可能空,兜底读 `reasoning_content`;max_tokens ≥ 8192;JSON 需截断修复,已有 repairJSON)
+- 群发送:`/opt/data/tools/pwtest/send_wecom_doc.py`(adapter 直发,已验证)。公告文字用 `/opt/hermes/.venv/bin/hermes send --to "wecom:wrbAdKCAAAUkSbMbznGGG2wt4VoZtVNA" "..."`
+- playwright 验证:`/opt/data/tools/pwtest/verify_report.js`,browsers 在 `/opt/data/tools/pwtest/browsers`(环境变量 PLAYWRIGHT_BROWSERS_PATH)
+- 测试样例图:`/opt/data/floorplan-quotation-testdata/sample1_shidaichengzhi_q1.jpg`(110㎡ 营销图,无尺寸标注)
+- 企微真实样例:`/opt/data/cache/images/img_83594a936f27.bin`(63KB JPEG,.bin 后缀!两室两厅,含 6840/7230/2200/2950/2520/1400 尺寸线)
+- FMODE_API_KEY 导入方式(skill 内代码保持现状):`export FMODE_API_KEY=$(python3 -c "import re;print(re.search(r'api_key:\s*(\S+)', open('/opt/data/config.yaml').read()).group(1))")`
+
+## 任务清单(按序,全部要落地代码)
+
+### A. parse.js:企微/通用无后缀文件兼容
+1. `sniffImageMime(buf)`:读文件头 magic bytes → jpeg(FFD8FF)/png(89504E47)/webp(RIFF..WEBP)/gif/gif87a/gif89a/bmp(BM)
+2. `loadDocument()` 与 `imageToDataUrl()`:扩展名未知(.bin/.tmp/无后缀)或 mime 为 application/octet-stream 时,用 magic bytes 决定 MIME;识别不了再报错
+3. `isPDF` 已有 sniff,保持;补充:dataURL 一律带正确 mime
+4. 新增单测:copy 样例图为 `.bin` 与无后缀两个文件,loadDocument 均应得到 `data:image/jpeg;base64,...`
+
+### B. vision.js:健壮化(禁换模型前提下)
+1. **删除/废弃一切"image_tokens==0 → 图片未送达"类判断逻辑**(若有);判定图片是否生效的唯一标准:模型回复是否含与图片相关的实质内容(JSON 有 rooms/cabinets/dimensions 任一非空,或文本含 户/房/柜/厨 等关键词)
+2. 单阶段失败重试已有;**新增同族回退**:glm-5.3-flash 连续 2 次拿不到有效 JSON 时,依次尝试 `z-ai/glm-5.3-flash`、`glm-5.3`(同为 GLM 族,仍然禁止非 GLM 模型),成功后把生效模型名写入返回值 `analysis._model`
+3. 结果质量门 `assessQuality(rec)`:返回 { ok, score, reasons[] }。计分:有尺寸线直读(+2/条, 上限4)、柜体 confidence≥0.7(+1/个,上限3)、识别到厨房(+1)、柜体数≥3(+1);总分≤2 或 柜体全部 confidence<0.5 → ok:false
+4. CLI `quote` 在 ok:false 时:stderr 打印「⚠️ 识别质量低(原因),建议销售补充:户型总尺寸/各房间宽深/柜体位置」且退出码 3;`--force` 可跳过
+5. PROMPT_MEASURE 增补维度推断规则(来自实战):总宽/总深已知时,房间宽=该朝向尺寸线减墙厚(120mm);衣柜宽通常贴房间整边(如次卧南墙2950 → 次卧衣柜 2.95m);阳台柜宽=阳台宽扣洗衣机位0.7m;厨房台面沿墙长直读(如2880)+侧墙0.95 → walls=[2.88,0.95,2.88,0] L型
+
+### C. index.js:一键流水线 `fpc auto`
+新增子命令:
+```
+fpc auto <企微附件路径或任意图片/PDF> \
+  --client "称呼" --note "销售原话" [--tier standard] [--material PET] [--float 15] \
+  [--out 报告路径] [--announce 报告路径]   # --announce: 输出一段可直接群发的 Markdown 公告到指定文件
+```
+流程:sniff 识别 → quote 管线(内部已含质量门)→ 生成报告 → **自动触发 Playwright 触屏回归**(调 `/opt/data/tools/pwtest/verify_report.js`,任何 STEP 失败则整体失败)→ 生成公告 Markdown(含:图纸概况、到手价、区间、明细行、文件路径、功能提示)→ 打印 JSON 摘要 `{quote:{...}, report, announce, quality}`
+- 质量门不通过时 auto 不产出报告(除非 --force),退出码 3
+- verify_report.js 需小改:抽成可 require 的模块或增加 --json 输出(保持原行为兼容)
+
+### D. test/run.js:新增回归(在现有 12 项后追加)
+1. `.bin` 嗅探:`loadDocument` 对无后缀 JPEG 返回正确 dataURL 前缀
+2. 质量门:用 mock 的 rec(低置信度)调 assessQuality 应 ok:false
+3. 口径一致性(锁死 c45b0ef 的修复):renderReport 产出的 HTML 里 `window.__Q__` 的 cabinets JSON 必须含 厨房 walls 数组且各柜 height 与 dims 解析值一致(用正则从 HTML 提取 `window.__Q__ = {...};` 解析断言)
+4. announce 生成:fpc auto 纯本地路径(不调网络,用固定 quote 对象走内部函数)能产出含「到手价」「谈价参考区间」的 markdown
+
+### E. SKILL.md 更新(/opt/data/skills/floorplan-quotation/SKILL.md)
+- 快速用法改成 `fpc auto` 优先;写明企微 .bin 兼容已内置;写明质量门行为(退出码 3 时该问销售要什么);明确「模型固定 glm-5.3-flash(GLM 族回退),禁止换非 GLM 模型;image_tokens 网关恒 0 不是失败信号」
+
+## 验收(全部满足才算完)
+1. `cd /opt/data/floorplan-quotation/skill && node test/run.js` 全过(≥16 项)
+2. `export FMODE_API_KEY=... && node bin/fpc.js auto /opt/data/cache/images/img_83594a936f27.bin --client "测试客户" --note "实战回归" --out /tmp/regress.html` 跑通且质量门 ok(该图有完整尺寸线,score 应≥6);报告里至少 4 组柜体、次卧衣柜 2.95m 尺寸线直读
+3. 用 `/opt/data/tools/pwtest/verify_report.js` 验证 /tmp/regress.html:页面首屏价 == CLI 到手价(关键:厨房 L 型口径一致)
+4. 不修改 quote.js 计价口径、不改 defaults.js 价格
+5. git 提交(含 changelog/user/2026-09-01-企微实战复盘与v2升级.md,见 F)并 push origin main
+
+### F. 复盘报告(我先写好草稿在下方,你核对后随代码一起提交)
+路径:`changelog/user/2026-09-01-企微实战复盘与v2升级.md`
 
 ## 约束
-- 只改 skill/ 目录下文件,不碰仓库其他程序
-- 改完运行 `node test/run.js` 必须全过;再用 `node bin/fpc.js` 无 API key 的模式跑不了真实识别没关系,重点是语法与单测
-- 中文注释,保持现有代码风格
+- 只改 skill/ 与 changelog/;模型只能是 glm-5.3-flash / z-ai/glm-5.3-flash / glm-5.3
+- 全程用现有 fcc.sh(Claude Code 已配好 deepseek 后端代理)
+- 若 git push 冲突:先 git pull --no-rebase(identity 已在仓库 config 配好)再推
+
+---
+
+## 复盘报告草稿(提交时粘贴进 changelog/user/2026-09-01-企微实战复盘与v2升级.md)
+
+# 复盘 · 2026-09-01 · 企微实战首单暴露的断点与 v2 升级
+
+## 一、事件经过(Session 20260901_043643_e6a3c1ab)
+销售在企微发来户型图(63KB JPEG,缓存为 .bin 无后缀)+ "做下这个户型报价单"。
+1. 技能跑通但**静默降级**:glm-5.3-flash 返回"未收到图片",管线未拦截,用默认尺寸 1.5×2.6 出了 7 组柜体 ¥129,500 的垃圾报价
+2. 人工发现尺寸可疑 → vision_analyze 备援读图成功(证明图没问题)→ 调试发现 usage.image_tokens=0 → **误判 GLM 不吃图 → 违规切换 gpt-4o** 跑识别 → 人肉按 gpt-4o 结果重建报告(绕过技能)
+3. 后续又发现报告内嵌状态丢 walls/柜高 → 页面口径 136,550 ≠ 服务端 123,900 → 二次修复
+
+## 二、根因
+| # | 根因 | 影响 |
+|---|---|---|
+| 1 | parse.js 只认扩展名,不嗅探 magic bytes;企微缓存文件无后缀 | 一进来就靠人肉 cp 才能跑 |
+| 2 | **网关 usage.prompt_tokens_details.image_tokens 恒为 0(统计 bug)**,被当成"图片未送达"的失败信号;代码与人肉判断都踩了同一个坑 | 误判 → 违规换模型 → 人肉重建,浪费 10+ 轮 |
+| 3 | 无识别质量门:尺寸全默认值、confidence 全低的报价单照样出街 | 差点把垃圾报价发到客户群 |
+| 4 | 无一键流水线:sniff→quote→验证→公告 全靠 agent 手工粘合 | 交互来回,速度慢、易错 |
+| 5 | (已在 c45b0ef 修)报告内嵌状态丢 walls/柜高 | 页面口径与服务端不一致 |
+
+## 三、v2 升级内容(对应修复)
+- **A** parse.js 嗅探 magic bytes(jpeg/png/webp/gif/bmp/pdf),.bin/无后缀自动识别
+- **B** vision.js:废除 image_tokens 误判逻辑;GLM 族内回退(glm-5.3-flash → z-ai/glm-5.3-flash → glm-5.3),模型固定不换族;新增 assessQuality 质量门(尺寸线直读/置信度/厨房/柜体数计分),低质量不出版(退出码 3)并给出该问销售要什么;PROMPT_MEASURE 纳入实战维度推断规则
+- **C** `fpc auto` 一键流水线:附件→识别→报价→Playwright 触屏回归→群发公告 Markdown→JSON 摘要
+- **D** 回归测试 4 项新增(含 HTML 内嵌口径一致性锁死)
+- **SKILL.md** 同步更新,明确模型纪律
+
+## 四、纪律(新增)
+1. **模型纪律**:VLM 只用 GLM 族;image_tokens=0 是网关统计 bug,不是失败信号;失败判定以"回复是否与图片内容相关"为准
+2. **质量纪律**:低置信度报价不出街;宁可问销售要尺寸,不发默认值垃圾单
+3. **流程纪律**:实战走 `fpc auto`,不许人肉拆步骤;群发走 send_wecom_doc.py

+ 113 - 4
skill/src/index.js

@@ -11,14 +11,14 @@
 import fs from 'node:fs';
 import path from 'node:path';
 import { loadDocument } from './parse.js';
-import { recognizeFloorplan } from './vision.js';
+import { recognizeFloorplan, assessQuality } from './vision.js';
 import { buildQuote, applyOps } from './quote.js';
 import { renderSVG, buildPlan, fallbackImage } from './redraw.js';
 import { renderReport, writeReport } from './report.js';
 import { publish, fmodeLogin, parseFileUpload } from './publish.js';
 
 export { buildQuote, applyOps, kitchenBillable, tierUnitPrice } from './quote.js';
-export { recognizeFloorplan, analyzeFloorplan, measureCabinets, normalizeCabinetName } from './vision.js';
+export { recognizeFloorplan, analyzeFloorplan, measureCabinets, normalizeCabinetName, assessQuality } from './vision.js';
 export { loadDocument, imageToDataUrl, pdfToImagePages } from './parse.js';
 export { renderReport, writeReport } from './report.js';
 export { renderSVG, buildPlan } from './redraw.js';
@@ -48,6 +48,15 @@ export async function quoteFromFile(file, opts = {}) {
     console.error('[fpc] usage:', JSON.stringify(rec._usage));
   }
 
+  // 2.5 质量门:低质量识别不出街(企微实战复盘:垃圾报价禁止静默生成)
+  const quality = assessQuality(rec);
+  if (!quality.ok && !opts.force) {
+    const err = new Error(`识别质量低(score=${quality.score}):${quality.reasons.join(';')}。${quality.suggestion}`);
+    err.code = 'QUALITY_GATE';
+    err.quality = quality;
+    throw err;
+  }
+
   // 3. 应用销售语音调整指令(增删改)
   let cabinets = rec.cabinets;
   if (ops.length) cabinets = applyOps(cabinets, ops);
@@ -75,11 +84,31 @@ export async function quoteFromFile(file, opts = {}) {
   if (doPublish) {
     published = await publish({ html, filename: path.basename(outPath) });
   }
-  return { quote, rec, htmlPath: path.resolve(outPath), published, doc: { kind: doc.kind, numPages: doc.numPages || 1 } };
+  return { quote, rec, quality, htmlPath: path.resolve(outPath), published, doc: { kind: doc.kind, numPages: doc.numPages || 1 } };
 }
 
 function stamp() { return new Date().toISOString().slice(5, 16).replace(/[-T:]/g, ''); }
 
+// ---------- 群发公告 Markdown(auto 流水线产物之一) ----------
+export function buildAnnounce({ quote, rec, htmlPath, clientName, note }) {
+  const money = (n) => '¥' + Math.round(n || 0).toLocaleString('zh-CN');
+  const lines = [];
+  lines.push('📦 全屋定制 · 户型图智能报价单');
+  lines.push('');
+  if (rec?.analysis?.notes) lines.push(`📐 图纸:${rec.analysis.notes}`);
+  if (clientName) lines.push(`👤 客户:${clientName}`);
+  lines.push(`💰 到手价:${money(quote.totals.finalPrice)}(${quote.meta.tierName || ''}材质 · ${quote.meta.customerLabel || '标准客户'})`);
+  lines.push(`📊 谈价参考区间:${money(quote.totals.range.low)} ~ ${money(quote.totals.range.high)}`);
+  lines.push('');
+  lines.push('明细:');
+  for (const r of quote.rooms) lines.push(`· ${r.type} ${r.dims || ''} ${money(r.subtotal ?? r.total)}`);
+  if (note) { lines.push(''); lines.push(`备注:${note}`); }
+  lines.push('');
+  lines.push(`报告文件:${htmlPath}(手机浏览器/微信打开即可交互,底部销售操作台可调价)`);
+  lines.push('AI辅助估算,实际以现场量尺与正式合同为准。');
+  return lines.join('\n');
+}
+
 // ---------- CLI ----------
 export async function main(argv = process.argv.slice(2)) {
   if (!argv.length || argv[0] === 'help' || argv[0] === '--help') {
@@ -101,7 +130,11 @@ export async function main(argv = process.argv.slice(2)) {
   --remove "<类型>"  删除柜体
   --publish          同时发布(需已配置 ~/.fpc/publish.json)
   --out <路径>       报告输出路径
-  --debug            输出识别与token用量`);
+  --debug            输出识别与token用量
+
+一键流水线:
+  fpc auto <图片/PDF/.bin路径> [同上选项] [--announce 公告.md] [--force]
+    附件嗅探→识别→质量门(不过不出单,退出码3)→报价→报告→触屏回归(失败退出码4)→公告→JSON摘要`);
     process.exit(0);
   }
   const cmd = argv[0];
@@ -135,6 +168,82 @@ export async function main(argv = process.argv.slice(2)) {
     console.log(r.url);
     return;
   }
+  if (cmd === 'auto') {
+    // 一键流水线:附件(.bin/无后缀兼容)→识别→质量门→报价→报告→触屏回归→公告→JSON摘要
+    const file = rest[0];
+    if (!file || !fs.existsSync(file)) { console.error('文件不存在:', file); process.exit(2); }
+    const opts = {
+      clientName: get('--client', ''),
+      salesNote: get('--note', ''),
+      tierKey: get('--tier', 'standard'),
+      tierName: get('--material'),
+      floatPct: parseFloat(get('--float', '15')),
+      discount: get('--discount') ? parseFloat(get('--discount')) : null,
+      out: get('--out'),
+      force: has('--force'),
+      debug: has('--debug'),
+      apiKey: process.env.FMODE_API_KEY,
+    };
+    let result;
+    try {
+      result = await quoteFromFile(file, opts);
+    } catch (e) {
+      if (e.code === 'QUALITY_GATE') {
+        console.error('⚠️ ' + e.message);
+        process.exit(3);
+      }
+      throw e;
+    }
+    const announce = buildAnnounce({ quote: result.quote, rec: result.rec, htmlPath: result.htmlPath, clientName: opts.clientName, note: opts.salesNote });
+    const announcePath = get('--announce');
+    if (announcePath) { fs.mkdirSync(path.dirname(path.resolve(announcePath)), { recursive: true }); fs.writeFileSync(announcePath, announce, 'utf8'); }
+
+    // Playwright 触屏回归(有浏览器环境才跑;页面首屏价必须等于 CLI 到手价)
+    let regression = { skipped: true, reason: '未检测到 playwright 环境(verify_report.js)' };
+    const verifyPath = '/opt/data/tools/pwtest/verify_report.js';
+    if (fs.existsSync(verifyPath)) {
+      const { execFileSync } = await import('node:child_process');
+      try {
+        const out = execFileSync('node', [verifyPath, result.htmlPath, `/tmp/fpc-regress-${Date.now()}.png`], {
+          timeout: 120000, cwd: '/opt/data/tools/pwtest',
+          env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: '/opt/data/tools/pwtest/browsers' },
+        }).toString();
+        const pagePrice = (out.match(/finalPrice":"¥([\d,]+)"/) || [])[1]?.replace(/,/g, '');
+        const cliPrice = String(Math.round(result.quote.totals.finalPrice));
+        const interactOk = /RESULT: PASS/.test(out);
+        regression = { skipped: false, pagePrice, cliPrice, priceMatch: pagePrice === cliPrice, interactOk, raw: out.slice(-400) };
+        if (!regression.priceMatch || !interactOk) {
+          console.error(`⚠️ 回归失败:页面价=${pagePrice} CLI价=${cliPrice} 交互=${interactOk ? 'PASS' : 'CHECK'}`);
+          process.exit(4);
+        }
+      } catch (e) {
+        // verify_report.js exit 2 = RESULT CHECK(交互数字不符),同样视为失败
+        const out = (e.stdout || '').toString();
+        const pagePrice = (out.match(/finalPrice":"¥([\d,]+)"/) || [])[1]?.replace(/,/g, '');
+        regression = { skipped: false, error: e.message.slice(0, 200), pagePrice };
+        if (e.status === 2 && pagePrice) {
+          console.error(`⚠️ 回归失败:页面价=${pagePrice} ≠ CLI价=${Math.round(result.quote.totals.finalPrice)}`);
+          process.exit(4);
+        }
+        console.error('[fpc] 回归环境异常(不阻塞):', e.message.slice(0, 120));
+        regression.skipped = true;
+      }
+    }
+
+    const summary = {
+      quote: {
+        clientName: opts.clientName, finalPrice: result.quote.totals.finalPrice,
+        range: result.quote.totals.range, rooms: result.quote.rooms.map(r => ({ type: r.type, dims: r.dims, total: r.subtotal ?? r.total })),
+      },
+      quality: result.quality, model: result.rec?.analysis?._model,
+      report: result.htmlPath, announce: announcePath || '(stdout)', announceText: announce,
+      regression, doc: result.doc,
+    };
+    console.log('\n========== AUTO 完成 ==========');
+    console.log(JSON.stringify({ ...summary, announceText: undefined }, null, 1));
+    if (!announcePath) console.log('\n--- 群发公告 ---\n' + announce);
+    return summary;
+  }
   if (cmd !== 'quote') { console.error('未知命令:', cmd); process.exit(1); }
 
   const file = rest[0];

+ 29 - 2
skill/src/parse.js

@@ -9,9 +9,30 @@ import path from 'node:path';
 
 const MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.bmp': 'image/bmp' };
 
+// Magic bytes 嗅探:企微/微信缓存文件常为 .bin 或无后缀,扩展名不可信
+export function sniffImageMime(buf) {
+  const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
+  if (b.length < 12) return null;
+  if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
+  if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png';
+  if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return 'image/webp';
+  if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'image/gif';
+  if (b[0] === 0x42 && b[1] === 0x4d) return 'image/bmp';
+  return null;
+}
+
 export function isPDF(file) { return /\.pdf$/i.test(file) || sniffPDF(file); }
 function sniffPDF(file) { try { const b = fs.readFileSync(file).subarray(0, 5).toString(); return b.startsWith('%PDF'); } catch { return false; } }
-function mimeOf(file) { return MIME[path.extname(file).toLowerCase()] || 'application/octet-stream'; }
+function mimeOf(file) {
+  const byExt = MIME[path.extname(file).toLowerCase()];
+  if (byExt) return byExt;
+  // 扩展名不可信(.bin/.tmp/无后缀)→ magic bytes 决定
+  try {
+    const sniffed = sniffImageMime(fs.readFileSync(file).subarray(0, 16));
+    if (sniffed) return sniffed;
+  } catch { /* 读不到按原逻辑 */ }
+  return 'application/octet-stream';
+}
 
 export function imageToDataUrl(file, { maxWidth = 1600 } = {}) {
   const buf = fs.readFileSync(file);
@@ -72,10 +93,16 @@ export async function pdfToImagePages(file, { pages = null, scale = 2.0 } = {})
 export async function loadDocument(file, opts = {}) {
   const abs = path.resolve(file);
   if (!fs.existsSync(abs)) throw new Error(`文件不存在: ${abs}`);
+  // 无后缀/.bin 等先嗅探:PDF 走 pdfjs,图片走 magic-byte MIME
+  const head = fs.readFileSync(abs).subarray(0, 16);
   if (isPDF(abs)) {
     const { pages, numPages } = await pdfToImagePages(abs, opts);
     return { kind: 'pdf', numPages, pages };
   }
+  const sniffed = sniffImageMime(head);
+  if (!sniffed && mimeOf(abs) === 'application/octet-stream') {
+    throw new Error(`无法识别文件类型(既非 PDF 也非常见图片):${abs}。请让销售重发为 JPG/PNG 图片或 PDF`);
+  }
   return { kind: 'image', pages: [{ page: 1, dataUrl: imageToDataUrl(abs), text: '' }] };
 }
-export default { loadDocument, pdfToImagePages, imageToDataUrl, isPDF };
+export default { loadDocument, pdfToImagePages, imageToDataUrl, isPDF, sniffImageMime };

+ 113 - 24
skill/src/vision.js

@@ -10,7 +10,9 @@ import { DEFAULTS } from './defaults.js';
 import { isKitchenName } from './geometry.js';
 
 const API_BASE = process?.env?.FMODE_API_BASE || 'https://api.fmode.cn/v1';
-const VLM_MODEL = process?.env?.FPC_VLM_MODEL || 'glm-5.3-flash';
+// 模型纪律:只用 GLM 族(企微实战复盘定案)。image_tokens 网关恒 0 是统计 bug,不是失败信号
+const VLM_MODELS = (process?.env?.FPC_VLM_MODEL || 'glm-5.3-flash,z-ai/glm-5.3-flash,glm-5.3').split(',').map(s => s.trim()).filter(Boolean);
+const VLM_MODEL = VLM_MODELS[0];
 
 function apiKey() {
   if (process?.env?.FMODE_API_KEY) return process.env.FMODE_API_KEY;
@@ -18,7 +20,7 @@ function apiKey() {
 }
 
 // ---------- 基础调用 ----------
-export async function callVLM({ imageDataUrl, prompt, model, maxTokens = 8192, temperature = 0.1, apiKey: key }) {
+export async function callVLM({ imageDataUrl, prompt, model, maxTokens = 8192, temperature = 0.1, apiKey: key, timeoutMs = 300000 }) {
   const k = key || apiKey();
   if (!k) throw new Error('缺少 API key:请设置 FMODE_API_KEY 或传入 apiKey 参数');
   const body = {
@@ -30,11 +32,22 @@ export async function callVLM({ imageDataUrl, prompt, model, maxTokens = 8192, t
       { type: 'text', text: prompt },
     ] }],
   };
-  const resp = await fetch(`${API_BASE}/chat/completions`, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${k}` },
-    body: JSON.stringify(body),
-  });
+  let resp = null;
+  // 网络瞬时抖动重试(实战教训:fetch failed 偶发);单次尝试限时,防 API 高峰把整条流水线拖死
+  for (let attempt = 1; attempt <= 3; attempt++) {
+    const ac = new AbortController();
+    const timer = setTimeout(() => ac.abort(), timeoutMs);
+    try {
+      resp = await fetch(`${API_BASE}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${k}` }, body: JSON.stringify(body), signal: ac.signal });
+      clearTimeout(timer);
+      break;
+    } catch (e) {
+      clearTimeout(timer);
+      const isTimeout = e.name === 'AbortError';
+      if (attempt === 3) throw new Error(`VLM 网络调用失败(重试3次): ${isTimeout ? `单次>${timeoutMs / 1000}s超时` : e.message}${e.cause ? ' | ' + (e.cause.message || '') : ''}`);
+      await new Promise((r) => setTimeout(r, 600 * attempt));
+    }
+  }
   if (!resp.ok) {
     const t = await resp.text().catch(() => '');
     throw new Error(`VLM 调用失败 ${resp.status}: ${t.slice(0, 300)}`);
@@ -112,21 +125,40 @@ const PROMPT_ANALYZE = `你是全屋定制行业的户型图分析专家。分
 - dimensions 逐条列出图面上所有能看到的数字标注(含总线尺寸、分间尺寸、门窗洞口尺寸),这是后续尺寸交叉验证的参考真值
 - 分不清的就标 confidence 低一点,不要编造`;
 
+// 判定回复是否含与图片相关的实质内容(唯一有效的"图片是否送达"判据,勿用 image_tokens)
+function isSubstantive(text) {
+  if (!text) return false;
+  if (/未接收到|没有收到|未收到图|请重新上传|无法进行.*分析/.test(text)) return false;
+  return /户|房|柜|厨|卧|厅|卫|阳台|尺寸|房|室/.test(text);
+}
+
 export async function analyzeFloorplan({ imageDataUrl, apiKey, model, debug = false }) {
-  let { content, usage } = await callVLM({ imageDataUrl, prompt: PROMPT_ANALYZE, apiKey, model });
-  let parsed = extractJSON(content);
-  if (!parsed) {
-    // 重试:强调只输出 JSON 本体
-    const retry = await callVLM({ imageDataUrl, apiKey, model, temperature: 0.1,
-      prompt: PROMPT_ANALYZE + '\n\n【重要】直接输出JSON本体,第一个字符必须是 { ,最后一个字符必须是 } 。不要输出任何解释、思考过程或markdown。' });
-    usage = { ...usage, retry: retry.usage };
-    content = retry.content;
-    parsed = extractJSON(content);
+  // GLM 族内回退链:主模型连续失败才尝试同族备选(禁止跨族换模型)
+  const chain = model ? [model] : VLM_MODELS;
+  let lastErr = null;
+  for (const m of chain) {
+    for (let attempt = 1; attempt <= 2; attempt++) {
+      let { content, usage } = await callVLM({ imageDataUrl, prompt: PROMPT_ANALYZE, apiKey, model: m });
+      let parsed = extractJSON(content);
+      if (!parsed && isSubstantive(content)) {
+        // 内容相关但 JSON 解析失败 → 强调重试
+        const retry = await callVLM({ imageDataUrl, apiKey, model: m, temperature: 0.1,
+          prompt: PROMPT_ANALYZE + '\n\n【重要】直接输出JSON本体,第一个字符必须是 { ,最后一个字符必须是 } 。不要输出任何解释、思考过程或markdown。' });
+        usage = { ...usage, retry: retry.usage };
+        content = retry.content;
+        parsed = extractJSON(content);
+      }
+      if (parsed) {
+        if (debug) parsed._raw = content;
+        parsed._usage = usage;
+        parsed._model = m;
+        return parsed;
+      }
+      lastErr = new Error(`VLM(${m}) 第${attempt}次返回无法解析为JSON:` + String(content).slice(0, 150));
+      if (debug) console.error('[fpc]', lastErr.message);
+    }
   }
-  if (!parsed) throw new Error('VLM 返回无法解析为 JSON:' + String(content).slice(0, 200));
-  if (debug) parsed._raw = content;
-  parsed._usage = usage;
-  return parsed;
+  throw lastErr || new Error('VLM 全链路失败');
 }
 
 // ---------- 第二阶段:柜体尺寸推理(与第一阶段分离,降低漏项) ----------
@@ -145,12 +177,20 @@ const PROMPT_MEASURE = `基于这张户型图估算定制柜体的安装尺寸
 - 衣柜/鞋柜/阳台柜/餐边柜高度通常 = 层高减100~200mm(顶部留空),即 2.4~2.7m;吊柜单独高度约0.7~0.9m
 - 橱柜按地柜沿墙长度估:厨房一面墙则 walls=[长,宽,长,宽];L型 [长,宽2,长,0];U型 [长,宽,长,宽](单位米)
 - 若图上有明确尺寸标注(如"3600"),优先用它换算,basis 写"图上直读"
+- 维度推断经验(实战沉淀):
+  * 总宽/总深已知时,房间宽 = 该朝向尺寸线 − 墙厚(120mm/道)
+  * 衣柜宽通常贴房间整边(如次卧南墙标 2950 → 次卧衣柜宽 2.95m,basis"图上尺寸线直读")
+  * 阳台柜宽 = 阳台宽 − 洗衣机位 0.7m
+  * 厨房:台面沿墙长直读(如 2880)+ 侧墙约 0.95 → L型 walls=[2.88,0.95,2.88,0];灶台/水槽位置决定 L 型哪边
 - 实在无依据时按房间开间的 50%~60% 估宽度,basis 写"先验估算",confidence ≤0.4`;
 
-export async function measureCabinets({ imageDataUrl, analysis, floorHeight, apiKey, model }) {
+export async function measureCabinets({ imageDataUrl, analysis, floorHeight, apiKey, model, timeoutMs = 90000 }) {
   const h = floorHeight || analysis?.floorHeight || DEFAULTS.height;
   const prompt = PROMPT_MEASURE.replace('{height}', String(h));
-  const { content, usage } = await callVLM({ imageDataUrl, prompt, apiKey, model });
+  // 思考型模型:JSON 必须 ≤ maxTokens,截断=质量崩塌(4000 实测必截断)。measure 上限 120s,超时由 analysis 兜底
+  const call = callVLM({ imageDataUrl, prompt, apiKey, model, maxTokens: 8192, timeoutMs });
+  const timeout = new Promise((resolve) => setTimeout(() => resolve({ content: '', usage: { timeout: true } }), timeoutMs + 5000));
+  const { content, usage } = await Promise.race([call, timeout]);
   const parsed = extractJSON(content) || {};
   parsed._usage = usage;
   return parsed;
@@ -200,12 +240,39 @@ export function reconcile({ analysis, measures, floorHeight, knownRooms = {} })
     width = clamp(width, 0.3, 8); height = clamp(height || h - 0.15, 0.5, h);
     out.push({ type: stdName, width: r2(width), height: r2(height), basis, confidence, walls: stdName.includes('厨房') ? (measures?.kitchenWalls || null) : undefined });
   }
-  // analysis 阶段发现、measure 漏掉的柜体 → 补入(默认估宽,低置信度)
+  // analysis 阶段发现、measure 漏掉的柜体 → 补入
+  // 尺寸推断顺序:analysis 维度标注匹配(图上直读级)→ analysis.cabinets 的 evidence 长度线索 → 先验估宽
+  // usedDims:一条尺寸线只分配给一个柜体(防主次卧抢同一条 2200)
+  const usedDims = new Set();
+  // 先给有 measures 真值的柜体占位(它们的标注已被使用)
+  for (const c of out) {
+    const m = (c.basis || '').match(/直读\((\d+)\)/);
+    if (m) usedDims.add(m[1]);
+  }
   for (const c of (analysis?.cabinets || [])) {
     const stdName = normalizeCabinetName(c.name || '');
     if (!stdName || seen.has(stdName)) continue;
     seen.add(stdName);
-    out.push({ type: stdName, width: 1.5, height: r2(h - 0.2), basis: 'analysis识别、尺寸先验估算', confidence: 0.3, walls: stdName.includes('厨房') ? (measures?.kitchenWalls || null) : undefined });
+    // 从 analysis.dimensions 找与该柜体房名匹配、且未被占用的标注(如次卧衣柜 ← 次卧 2950)
+    let width = 0; let basis = ''; let confidence = 0.35;
+    const roomKey = roomOf(stdName) || (stdName.includes('阳台') ? '生活阳台' : '') || (stdName.includes('餐') ? '餐厅' : '') || (stdName.includes('浴室') ? '卫生间' : '');
+    // 匹配优先级:nearRoom 精确等于房名 > 互含 > what 提及房名;且标注值未被占用
+    const roomHits = dimsByText.filter((d) => !usedDims.has(String(d.nums[0])) && d.nums.length && d.nums[0] >= 900 && roomKey
+      && ((d.nearRoom === roomKey) || (d.nearRoom && (d.nearRoom.includes(roomKey) || roomKey.includes(d.nearRoom))) || String(d.what || '').includes(roomKey)))
+      .sort((a, b) => (a.nearRoom === roomKey ? -1 : 1));
+    // 房间开间级标注优先于墙段/墙厚/洞口类
+    const hit = roomHits.find((d) => /开间|进深|宽|长|区域/.test(d.what || '')) || roomHits[0];
+    if (hit) {
+      const v = hit.nums[0] / 1000 >= 1.5 ? hit.nums[0] / 1000 : hit.nums[0];
+      if (v >= 1.2 && v <= 8) { width = v; basis = `图上尺寸线直读(${hit.text})`; confidence = 0.85; usedDims.add(String(hit.nums[0])); }
+    }
+    // evidence 里的长度线索(如"通长约3180")
+    if (!width) {
+      const evNums = String(c.evidence || '').match(/\d{4}/g) || [];
+      if (evNums.length) { const v = Number(evNums[0]) / 1000; if (v >= 1.2 && v <= 8) { width = r2(v); basis = `识别依据换算(${evNums[0]})`; confidence = 0.7; } }
+    }
+    if (!width) { width = 1.5; basis = 'analysis识别、尺寸先验估算'; confidence = 0.3; }
+    out.push({ type: stdName, width, height: r2(h - 0.2), basis, confidence, walls: stdName.includes('厨房') ? (measures?.kitchenWalls || null) : undefined });
   }
   // 厨房保底(原项目规则:厨房默认参与计价)
   if (!out.some((c) => isKitchenName(c.type))) {
@@ -233,6 +300,28 @@ function num2(v) { const n = parseFloat(v); return Number.isFinite(n) ? n : 0; }
 function r2(v) { return Math.round(v * 100) / 100; }
 function clamp(v, a, b) { return Math.min(b, Math.max(a, v)); }
 
+// ---------- 识别质量门(企微实战复盘:垃圾报价不许静默出街) ----------
+// 计分:尺寸线直读+2/条(上限4)、柜体confidence≥0.7 +1/个(上限3)、识别到厨房+1、柜体数≥3 +1
+// 总分≤2 或 全部柜体 confidence<0.5 → ok:false(低质量,应向销售补充信息而不是硬出单)
+export function assessQuality(rec) {
+  const reasons = [];
+  let score = 0;
+  const cabs = rec?.cabinets || [];
+  const direct = cabs.filter((c) => /直读/.test(c.basis || ''));
+  score += Math.min(4, direct.length * 2);
+  if (!direct.length) reasons.push('无任何图上尺寸线直读(缺尺寸标注或识别失败)');
+  const conf = cabs.filter((c) => (c.confidence || 0) >= 0.7);
+  score += Math.min(3, conf.length);
+  if (!conf.length) reasons.push('所有柜体置信度<0.7');
+  const hasKitchen = cabs.some((c) => isKitchenName(c.type));
+  if (hasKitchen) score += 1; else reasons.push('未识别到厨房');
+  if (cabs.length >= 3) score += 1; else reasons.push(`柜体数仅${cabs.length}组(<3)`);
+  const allLow = cabs.length > 0 && cabs.every((c) => (c.confidence || 0) < 0.5);
+  const ok = score > 2 && !allLow;
+  if (allLow) reasons.push('全部柜体置信度<0.5');
+  return { ok, score, reasons, suggestion: ok ? '' : '建议向销售补充:户型总尺寸/各房间宽深/柜体位置,或让销售重发更清晰的图纸' };
+}
+
 export function normalizeCabinetName(name = '') {
   const n = String(name).replace(/\s/g, '');
   // 厨房类强制收敛为「厨房橱柜」(避免「厨房吊柜」等变体绕过按延米计价)

+ 77 - 1
skill/test/run.js

@@ -1,9 +1,14 @@
 // fpc 端到端测试:计价引擎单元断言 + (可选)真实图纸识别
 import { buildQuote, kitchenBillable, tierUnitPrice, applyOps, withinAuthority } from '../src/quote.js';
-import { normalizeCabinetName } from '../src/vision.js';
+import { normalizeCabinetName, assessQuality } from '../src/vision.js';
 import { renderSVG, buildPlan } from '../src/redraw.js';
 import { renderReport } from '../src/report.js';
+import { loadDocument, sniffImageMime } from '../src/parse.js';
+import { buildAnnounce } from '../src/index.js';
 import assert from 'node:assert';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
 
 let pass = 0; let fail = 0;
 function t(name, fn) {
@@ -75,5 +80,76 @@ t('renderReport 产出含引擎与交互的HTML', () => {
   assert.ok(svg.startsWith('<svg'), 'SVG应可生成');
 });
 
+console.log('== v2 企微实战回归 ==');
+// D1 .bin/无后缀嗅探(企微缓存文件兼容)
+t('sniffImageMime: jpeg magic bytes', () => {
+  const jpeg = fs.readFileSync('/opt/data/floorplan-quotation-testdata/sample1_shidaichengzhi_q1.jpg');
+  assert.equal(sniffImageMime(jpeg.subarray(0, 16)), 'image/jpeg');
+});
+t('loadDocument(.bin) → data:image/jpeg dataURL', async () => {
+  const tmp = path.join(os.tmpdir(), `fpc_test_${Date.now()}.bin`);
+  fs.copyFileSync('/opt/data/floorplan-quotation-testdata/sample1_shidaichengzhi_q1.jpg', tmp);
+  try {
+    const doc = await loadDocument(tmp);
+    assert.ok(doc.pages[0].dataUrl.startsWith('data:image/jpeg;base64,'), 'dataURL 前缀应为 image/jpeg');
+  } finally { fs.unlinkSync(tmp); }
+});
+// D2 质量门
+t('assessQuality: 低置信度垃圾识别 → ok:false', () => {
+  const bad = { cabinets: [
+    { type: '厨房橱柜', width: 3, height: 2.8, basis: '默认厨房保底', confidence: 0.2 },
+    { type: '主卧衣柜', width: 1.5, height: 2.6, basis: 'analysis识别、尺寸先验估算', confidence: 0.3 },
+    { type: '次卧衣柜', width: 1.5, height: 2.6, basis: 'analysis识别、尺寸先验估算', confidence: 0.3 },
+  ] };
+  const q = assessQuality(bad);
+  assert.equal(q.ok, false);
+  assert.ok(q.reasons.length >= 1);
+  assert.ok(q.suggestion.includes('补充'));
+});
+t('assessQuality: 尺寸线直读的正常识别 → ok:true', () => {
+  const good = { cabinets: [
+    { type: '厨房橱柜', width: 2.88, height: 2.7, basis: '图上直读:台面沿墙', confidence: 0.9, walls: [2.88, 0.95, 2.88, 0] },
+    { type: '主卧衣柜', width: 2.2, height: 2.7, basis: '图上尺寸线直读(2200)', confidence: 0.95 },
+    { type: '次卧衣柜', width: 2.95, height: 2.7, basis: '图上尺寸线直读(2950)', confidence: 0.95 },
+    { type: '阳台柜', width: 1.8, height: 2.7, basis: 'VLM估算', confidence: 0.7 },
+  ] };
+  const q = assessQuality(good);
+  assert.equal(q.ok, true, `score=${q.score} reasons=${q.reasons.join(';')}`);
+  assert.ok(q.score >= 6);
+});
+// D3 口径一致性锁死(c45b0ef 修复不回退)
+t('renderReport 内嵌__Q__含厨房walls且柜高与dims一致', () => {
+  const cabinets = [
+    { type: '厨房橱柜', width: 2.88, height: 2.7, walls: [2.88, 0.95, 2.88, 0], basis: '图上直读', confidence: 0.9 },
+    { type: '主卧衣柜', width: 2.2, height: 2.7, basis: '图上直读', confidence: 0.9 },
+  ];
+  const quote = buildQuote({ cabinets, mode: 'quick', tierKey: 'standard', tierName: 'PET', floatPct: 15, clientName: '测试' });
+  const html = renderReport({ quote, imageDataUrl: 'data:image/jpeg;base64,xxxx', clientName: '测试', salesNote: '' });
+  const m = html.match(/window\.__Q__\s*=\s*(\{[\s\S]*?\});\s*\n/);
+  assert.ok(m, 'HTML 应含 window.__Q__ 初始状态');
+  const Q = JSON.parse(m[1]);
+  const kitchen = Q.cabinets.find((c) => c.type === '厨房橱柜');
+  assert.ok(kitchen, '内嵌状态应含厨房');
+  assert.ok(Array.isArray(kitchen.walls) && kitchen.walls.length >= 3 && kitchen.walls[0] === 2.88, '厨房 walls 必须随内嵌状态保留');
+  const wardrobe = Q.cabinets.find((c) => c.type === '主卧衣柜');
+  assert.equal(wardrobe.height, 2.7, '柜高应从 dims 还原(不得硬编码 2.6)');
+  // 页面口径一致:内嵌状态重算 = 服务端到手价(引擎已内嵌,用同参调用验证)
+  const pageQuote = buildQuote({ cabinets: Q.cabinets, tierKey: Q.config.tierKey, tierName: Q.config.tierName, floatPct: Q.config.floatPct, discount: Q.config.discount, mode: Q.config.mode });
+  assert.equal(pageQuote.totals.finalPrice, quote.totals.finalPrice, '页面重算价必须等于服务端到手价');
+});
+// D4 群发公告生成
+t('buildAnnounce: 含到手价/谈价区间/明细', () => {
+  const cabinets = [
+    { type: '厨房橱柜', width: 2.88, height: 2.7, walls: [2.88, 0.95, 2.88, 0], basis: '图上直读', confidence: 0.9 },
+    { type: '主卧衣柜', width: 2.2, height: 2.7, basis: '图上直读', confidence: 0.9 },
+  ];
+  const quote = buildQuote({ cabinets, mode: 'quick', tierKey: 'standard', tierName: 'PET', floatPct: 15, clientName: '张女士' });
+  const md = buildAnnounce({ quote, rec: { analysis: { notes: '两室两厅' } }, htmlPath: '/tmp/x.html', clientName: '张女士', note: '预算紧' });
+  assert.ok(md.includes('到手价'), '公告应含到手价');
+  assert.ok(md.includes('谈价参考区间'), '公告应含谈价区间');
+  assert.ok(md.includes('厨房橱柜'), '公告应含明细');
+  assert.ok(md.includes('两室两厅'), '公告应含图纸概况');
+});
+
 console.log(`\n结果: ${pass} 通过, ${fail} 失败`);
 process.exit(fail ? 1 : 0);