const { readFmodeApiKey, readFmodeLlmBase } = require('./credentials'); const { redactSecret } = require('../providers/fmode-wecom-gateway'); const DEFAULT_MODEL = 'deepseek-v4-pro'; const DEFAULT_TIMEOUT_MS = 120000; function buildLlmUrl(apiBase) { const root = String(apiBase || readFmodeLlmBase()).replace(/\/$/, ''); return `${root}/v1/chat/completions`; } function buildPortraitAnalysisPrompt(context) { const messages = Array.isArray(context.sampleMessages) ? context.sampleMessages : []; const incremental = context.updateMode === 'incremental'; const existingPortrait = context.existingPortrait && typeof context.existingPortrait === 'object' ? JSON.stringify(context.existingPortrait, null, 2) : '{}'; const messageLines = messages.map((m, idx) => { const time = m.timestamp ? new Date(m.timestamp).toLocaleString('zh-CN') : '未知时间'; const sender = m.senderName || m.senderId || '未知'; const text = m.content || ''; return `${idx + 1}. [${time}] ${sender}: ${text}`; }).join('\n'); return `你是房产经纪行业的客户画像分析专家。请根据下面这位客户在企微群聊中的${incremental ? '新增消息' : '全部消息'},生成一份结构化客户画像 JSON。 客户 externalUserId:${context.externalUserId} 消息数量:${context.messageCount || messages.length} 更新模式:${incremental ? '增量更新' : '全量更新'} ${incremental ? `现有画像:\n${existingPortrait}\n` : ''} ${incremental ? '新增消息' : '全部消息'}: ${messageLines || '(无文本消息)'} 分析维度(JSON 字段): - intent: 购房/租房/出售/置换/投资等意图;无信号填 null - budgetRange: 预算范围,例如 "200-300万"、"首付100万";无信号填 null - preferredAreas: 意向区域数组;无信号填 null - houseType: 房型偏好;无信号填 null - timeline: 时间线;无信号填 null - keyConcerns: 关键关注点数组(学区、地铁、医院、商圈、装修、物业、停车、电梯、采光等);无信号填 null - urgency: 急迫程度(高/中/低/null) - aiSummary: 用 1-3 句话总结客户状态 - personaType: 客户 persona 类型,例如 "刚需首套"、"改善置换"、"投资客"、"潜在客户(待激活)" 等 - fiveW2H: 对象,包含 Who/What/When/Where/Why/How/HowMuch - priorityMatrix: 对象,包含 needsClarity/engagement/actionPriority - confidence: 对画像整体置信度(high/medium/low) - coreAnxiety: 核心顾虑;无信号填 null - decisionMaker: 决策人情况;无信号填 null - loanCapacity: 贷款能力;无信号填 null - negotiationStage: 当前谈判/跟进阶段 要求: 1. 只输出合法 JSON,不要 markdown 代码块,不要额外解释。 2. 没有依据的字段必须填 null 或 "暂无法推断",禁止编造预算、区域、房型。 3. 如果消息极少或没有有效需求信息,confidence 必须为 low,aiSummary 要如实说明。 4. 字段名必须严格使用上述英文,值可用中文。 5. ${incremental ? '必须在现有画像基础上合并新增证据;新增消息未涉及的旧字段应保留,只有新证据明确冲突时才更新。输出完整合并后的画像。' : '应完全依据本次提供的全部消息重新计算,不沿用旧结论。'}`; } function extractJsonFromLlmOutput(text) { if (!text) return null; const trimmed = String(text).trim(); // 尝试直接解析 try { return JSON.parse(trimmed); } catch { // ignore } // 尝试从 markdown 代码块中提取 const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/); if (codeBlockMatch) { try { return JSON.parse(codeBlockMatch[1].trim()); } catch { // ignore } } // 尝试从第一个 { 到最后一个 } 提取 const firstBrace = trimmed.indexOf('{'); const lastBrace = trimmed.lastIndexOf('}'); if (firstBrace >= 0 && lastBrace > firstBrace) { try { return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1)); } catch { // ignore } } return null; } async function callLlmChat({ messages, model, apiKey, apiBase, timeoutMs, temperature }) { const key = apiKey || readFmodeApiKey(); const base = apiBase || readFmodeLlmBase(); if (!key) { const err = new Error('缺少 Fmode API Key,请在 .env.local 配置 FMODE_API_KEY 或确保 ~/.fmode/config.json 存在'); err.kind = 'auth'; throw err; } const url = buildLlmUrl(base); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), Math.max(5000, Number(timeoutMs) || DEFAULT_TIMEOUT_MS)); try { const response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: model || DEFAULT_MODEL, messages, temperature: temperature !== undefined ? temperature : 0.3 }), signal: controller.signal }); const text = await response.text(); let json; try { json = JSON.parse(text); } catch { json = null; } if (!response.ok) { const message = json?.error?.message || json?.message || text.slice(0, 500) || `HTTP ${response.status}`; const err = new Error(`LLM 请求失败:${redactSecret(message)}`); err.kind = response.status === 401 || response.status === 403 ? 'auth' : 'upstream'; err.httpStatus = response.status; throw err; } const content = json?.choices?.[0]?.message?.content; if (!content) { const err = new Error('LLM 返回为空,无法解析画像'); err.kind = 'upstream'; throw err; } return { content, usage: json?.usage || null }; } catch (error) { if (error.name === 'AbortError') { const err = new Error('LLM 请求超时'); err.kind = 'upstream'; throw err; } throw error; } finally { clearTimeout(timer); } } async function analyzePortraitWithLlm(context, options = {}) { const promptMessages = [ { role: 'system', content: '你是一位严谨的客户画像分析助手,只输出合法 JSON,不编造无依据的信息。' }, { role: 'user', content: buildPortraitAnalysisPrompt(context) } ]; const { content, usage } = await callLlmChat({ messages: promptMessages, model: options.model, apiKey: options.apiKey, apiBase: options.apiBase, timeoutMs: options.timeoutMs, temperature: options.temperature }); const portrait = extractJsonFromLlmOutput(content); if (!portrait || typeof portrait !== 'object') { const err = new Error('LLM 输出无法解析为 JSON'); err.kind = 'upstream'; err.rawOutput = content.slice(0, 2000); throw err; } return { portrait, rawOutput: content, usage }; } module.exports = { analyzePortraitWithLlm, callLlmChat, buildPortraitAnalysisPrompt, extractJsonFromLlmOutput, DEFAULT_MODEL };