|
@@ -10,7 +10,9 @@ import { DEFAULTS } from './defaults.js';
|
|
|
import { isKitchenName } from './geometry.js';
|
|
import { isKitchenName } from './geometry.js';
|
|
|
|
|
|
|
|
const API_BASE = process?.env?.FMODE_API_BASE || 'https://api.fmode.cn/v1';
|
|
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() {
|
|
function apiKey() {
|
|
|
if (process?.env?.FMODE_API_KEY) return process.env.FMODE_API_KEY;
|
|
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();
|
|
const k = key || apiKey();
|
|
|
if (!k) throw new Error('缺少 API key:请设置 FMODE_API_KEY 或传入 apiKey 参数');
|
|
if (!k) throw new Error('缺少 API key:请设置 FMODE_API_KEY 或传入 apiKey 参数');
|
|
|
const body = {
|
|
const body = {
|
|
@@ -30,11 +32,22 @@ export async function callVLM({ imageDataUrl, prompt, model, maxTokens = 8192, t
|
|
|
{ type: 'text', text: prompt },
|
|
{ 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) {
|
|
if (!resp.ok) {
|
|
|
const t = await resp.text().catch(() => '');
|
|
const t = await resp.text().catch(() => '');
|
|
|
throw new Error(`VLM 调用失败 ${resp.status}: ${t.slice(0, 300)}`);
|
|
throw new Error(`VLM 调用失败 ${resp.status}: ${t.slice(0, 300)}`);
|
|
@@ -112,21 +125,40 @@ const PROMPT_ANALYZE = `你是全屋定制行业的户型图分析专家。分
|
|
|
- dimensions 逐条列出图面上所有能看到的数字标注(含总线尺寸、分间尺寸、门窗洞口尺寸),这是后续尺寸交叉验证的参考真值
|
|
- dimensions 逐条列出图面上所有能看到的数字标注(含总线尺寸、分间尺寸、门窗洞口尺寸),这是后续尺寸交叉验证的参考真值
|
|
|
- 分不清的就标 confidence 低一点,不要编造`;
|
|
- 分不清的就标 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 }) {
|
|
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
|
|
- 衣柜/鞋柜/阳台柜/餐边柜高度通常 = 层高减100~200mm(顶部留空),即 2.4~2.7m;吊柜单独高度约0.7~0.9m
|
|
|
- 橱柜按地柜沿墙长度估:厨房一面墙则 walls=[长,宽,长,宽];L型 [长,宽2,长,0];U型 [长,宽,长,宽](单位米)
|
|
- 橱柜按地柜沿墙长度估:厨房一面墙则 walls=[长,宽,长,宽];L型 [长,宽2,长,0];U型 [长,宽,长,宽](单位米)
|
|
|
- 若图上有明确尺寸标注(如"3600"),优先用它换算,basis 写"图上直读"
|
|
- 若图上有明确尺寸标注(如"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`;
|
|
- 实在无依据时按房间开间的 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 h = floorHeight || analysis?.floorHeight || DEFAULTS.height;
|
|
|
const prompt = PROMPT_MEASURE.replace('{height}', String(h));
|
|
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) || {};
|
|
const parsed = extractJSON(content) || {};
|
|
|
parsed._usage = usage;
|
|
parsed._usage = usage;
|
|
|
return parsed;
|
|
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);
|
|
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 });
|
|
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 || [])) {
|
|
for (const c of (analysis?.cabinets || [])) {
|
|
|
const stdName = normalizeCabinetName(c.name || '');
|
|
const stdName = normalizeCabinetName(c.name || '');
|
|
|
if (!stdName || seen.has(stdName)) continue;
|
|
if (!stdName || seen.has(stdName)) continue;
|
|
|
seen.add(stdName);
|
|
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))) {
|
|
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 r2(v) { return Math.round(v * 100) / 100; }
|
|
|
function clamp(v, a, b) { return Math.min(b, Math.max(a, v)); }
|
|
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 = '') {
|
|
export function normalizeCabinetName(name = '') {
|
|
|
const n = String(name).replace(/\s/g, '');
|
|
const n = String(name).replace(/\s/g, '');
|
|
|
// 厨房类强制收敛为「厨房橱柜」(避免「厨房吊柜」等变体绕过按延米计价)
|
|
// 厨房类强制收敛为「厨房橱柜」(避免「厨房吊柜」等变体绕过按延米计价)
|