| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192 |
- 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
- };
|