llm-client.js 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. const { readFmodeApiKey, readFmodeLlmBase } = require('./credentials');
  2. const { redactSecret } = require('../providers/fmode-wecom-gateway');
  3. const DEFAULT_MODEL = 'deepseek-v4-pro';
  4. const DEFAULT_TIMEOUT_MS = 120000;
  5. function buildLlmUrl(apiBase) {
  6. const root = String(apiBase || readFmodeLlmBase()).replace(/\/$/, '');
  7. return `${root}/v1/chat/completions`;
  8. }
  9. function buildPortraitAnalysisPrompt(context) {
  10. const messages = Array.isArray(context.sampleMessages) ? context.sampleMessages : [];
  11. const incremental = context.updateMode === 'incremental';
  12. const existingPortrait = context.existingPortrait && typeof context.existingPortrait === 'object'
  13. ? JSON.stringify(context.existingPortrait, null, 2)
  14. : '{}';
  15. const messageLines = messages.map((m, idx) => {
  16. const time = m.timestamp ? new Date(m.timestamp).toLocaleString('zh-CN') : '未知时间';
  17. const sender = m.senderName || m.senderId || '未知';
  18. const text = m.content || '';
  19. return `${idx + 1}. [${time}] ${sender}: ${text}`;
  20. }).join('\n');
  21. return `你是房产经纪行业的客户画像分析专家。请根据下面这位客户在企微群聊中的${incremental ? '新增消息' : '全部消息'},生成一份结构化客户画像 JSON。
  22. 客户 externalUserId:${context.externalUserId}
  23. 消息数量:${context.messageCount || messages.length}
  24. 更新模式:${incremental ? '增量更新' : '全量更新'}
  25. ${incremental ? `现有画像:\n${existingPortrait}\n` : ''}
  26. ${incremental ? '新增消息' : '全部消息'}:
  27. ${messageLines || '(无文本消息)'}
  28. 分析维度(JSON 字段):
  29. - intent: 购房/租房/出售/置换/投资等意图;无信号填 null
  30. - budgetRange: 预算范围,例如 "200-300万"、"首付100万";无信号填 null
  31. - preferredAreas: 意向区域数组;无信号填 null
  32. - houseType: 房型偏好;无信号填 null
  33. - timeline: 时间线;无信号填 null
  34. - keyConcerns: 关键关注点数组(学区、地铁、医院、商圈、装修、物业、停车、电梯、采光等);无信号填 null
  35. - urgency: 急迫程度(高/中/低/null)
  36. - aiSummary: 用 1-3 句话总结客户状态
  37. - personaType: 客户 persona 类型,例如 "刚需首套"、"改善置换"、"投资客"、"潜在客户(待激活)" 等
  38. - fiveW2H: 对象,包含 Who/What/When/Where/Why/How/HowMuch
  39. - priorityMatrix: 对象,包含 needsClarity/engagement/actionPriority
  40. - confidence: 对画像整体置信度(high/medium/low)
  41. - coreAnxiety: 核心顾虑;无信号填 null
  42. - decisionMaker: 决策人情况;无信号填 null
  43. - loanCapacity: 贷款能力;无信号填 null
  44. - negotiationStage: 当前谈判/跟进阶段
  45. 要求:
  46. 1. 只输出合法 JSON,不要 markdown 代码块,不要额外解释。
  47. 2. 没有依据的字段必须填 null 或 "暂无法推断",禁止编造预算、区域、房型。
  48. 3. 如果消息极少或没有有效需求信息,confidence 必须为 low,aiSummary 要如实说明。
  49. 4. 字段名必须严格使用上述英文,值可用中文。
  50. 5. ${incremental ? '必须在现有画像基础上合并新增证据;新增消息未涉及的旧字段应保留,只有新证据明确冲突时才更新。输出完整合并后的画像。' : '应完全依据本次提供的全部消息重新计算,不沿用旧结论。'}`;
  51. }
  52. function extractJsonFromLlmOutput(text) {
  53. if (!text) return null;
  54. const trimmed = String(text).trim();
  55. // 尝试直接解析
  56. try {
  57. return JSON.parse(trimmed);
  58. } catch {
  59. // ignore
  60. }
  61. // 尝试从 markdown 代码块中提取
  62. const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
  63. if (codeBlockMatch) {
  64. try {
  65. return JSON.parse(codeBlockMatch[1].trim());
  66. } catch {
  67. // ignore
  68. }
  69. }
  70. // 尝试从第一个 { 到最后一个 } 提取
  71. const firstBrace = trimmed.indexOf('{');
  72. const lastBrace = trimmed.lastIndexOf('}');
  73. if (firstBrace >= 0 && lastBrace > firstBrace) {
  74. try {
  75. return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
  76. } catch {
  77. // ignore
  78. }
  79. }
  80. return null;
  81. }
  82. async function callLlmChat({ messages, model, apiKey, apiBase, timeoutMs, temperature }) {
  83. const key = apiKey || readFmodeApiKey();
  84. const base = apiBase || readFmodeLlmBase();
  85. if (!key) {
  86. const err = new Error('缺少 Fmode API Key,请在 .env.local 配置 FMODE_API_KEY 或确保 ~/.fmode/config.json 存在');
  87. err.kind = 'auth';
  88. throw err;
  89. }
  90. const url = buildLlmUrl(base);
  91. const controller = new AbortController();
  92. const timer = setTimeout(() => controller.abort(), Math.max(5000, Number(timeoutMs) || DEFAULT_TIMEOUT_MS));
  93. try {
  94. const response = await fetch(url, {
  95. method: 'POST',
  96. headers: {
  97. Authorization: `Bearer ${key}`,
  98. 'Content-Type': 'application/json'
  99. },
  100. body: JSON.stringify({
  101. model: model || DEFAULT_MODEL,
  102. messages,
  103. temperature: temperature !== undefined ? temperature : 0.3
  104. }),
  105. signal: controller.signal
  106. });
  107. const text = await response.text();
  108. let json;
  109. try {
  110. json = JSON.parse(text);
  111. } catch {
  112. json = null;
  113. }
  114. if (!response.ok) {
  115. const message = json?.error?.message || json?.message || text.slice(0, 500) || `HTTP ${response.status}`;
  116. const err = new Error(`LLM 请求失败:${redactSecret(message)}`);
  117. err.kind = response.status === 401 || response.status === 403 ? 'auth' : 'upstream';
  118. err.httpStatus = response.status;
  119. throw err;
  120. }
  121. const content = json?.choices?.[0]?.message?.content;
  122. if (!content) {
  123. const err = new Error('LLM 返回为空,无法解析画像');
  124. err.kind = 'upstream';
  125. throw err;
  126. }
  127. return { content, usage: json?.usage || null };
  128. } catch (error) {
  129. if (error.name === 'AbortError') {
  130. const err = new Error('LLM 请求超时');
  131. err.kind = 'upstream';
  132. throw err;
  133. }
  134. throw error;
  135. } finally {
  136. clearTimeout(timer);
  137. }
  138. }
  139. async function analyzePortraitWithLlm(context, options = {}) {
  140. const promptMessages = [
  141. { role: 'system', content: '你是一位严谨的客户画像分析助手,只输出合法 JSON,不编造无依据的信息。' },
  142. { role: 'user', content: buildPortraitAnalysisPrompt(context) }
  143. ];
  144. const { content, usage } = await callLlmChat({
  145. messages: promptMessages,
  146. model: options.model,
  147. apiKey: options.apiKey,
  148. apiBase: options.apiBase,
  149. timeoutMs: options.timeoutMs,
  150. temperature: options.temperature
  151. });
  152. const portrait = extractJsonFromLlmOutput(content);
  153. if (!portrait || typeof portrait !== 'object') {
  154. const err = new Error('LLM 输出无法解析为 JSON');
  155. err.kind = 'upstream';
  156. err.rawOutput = content.slice(0, 2000);
  157. throw err;
  158. }
  159. return { portrait, rawOutput: content, usage };
  160. }
  161. module.exports = {
  162. analyzePortraitWithLlm,
  163. callLlmChat,
  164. buildPortraitAnalysisPrompt,
  165. extractJsonFromLlmOutput,
  166. DEFAULT_MODEL
  167. };