|
@@ -1,9 +1,9 @@
|
|
|
const fs = require('fs');
|
|
const fs = require('fs');
|
|
|
const path = require('path');
|
|
const path = require('path');
|
|
|
const { okResult, errorResult } = require('../core/result-envelope');
|
|
const { okResult, errorResult } = require('../core/result-envelope');
|
|
|
-const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
|
|
|
|
|
-const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
|
|
|
|
|
-const { writeObjectRows } = require('../core/xlsx-io');
|
|
|
|
|
|
|
+const { createRunDir, outputsRoot } = require('../core/output-paths');
|
|
|
|
|
+const { readState, recordPortrait, removePortrait, recordTags, recordOperation } = require('../core/dashboard-state');
|
|
|
|
|
+const { analyzePortraitWithLlm } = require('../core/llm-client');
|
|
|
const {
|
|
const {
|
|
|
buildContext,
|
|
buildContext,
|
|
|
gatewayCall,
|
|
gatewayCall,
|
|
@@ -40,6 +40,10 @@ function tagsDir() {
|
|
|
return path.join(outputsRoot(), 'tags');
|
|
return path.join(outputsRoot(), 'tags');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function portraitsQueuePath() {
|
|
|
|
|
+ return path.join(portraitsDir(), 'queue.json');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function ensurePortraitsDir() {
|
|
function ensurePortraitsDir() {
|
|
|
fs.mkdirSync(portraitsDir(), { recursive: true });
|
|
fs.mkdirSync(portraitsDir(), { recursive: true });
|
|
|
}
|
|
}
|
|
@@ -48,58 +52,67 @@ function ensureTagsDir() {
|
|
|
fs.mkdirSync(tagsDir(), { recursive: true });
|
|
fs.mkdirSync(tagsDir(), { recursive: true });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function portraitFilePath(externalUserId) {
|
|
|
|
|
- ensurePortraitsDir();
|
|
|
|
|
- return path.join(portraitsDir(), `${externalUserId}.json`);
|
|
|
|
|
|
|
+function readPortraitQueue() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (!fs.existsSync(portraitsQueuePath())) return [];
|
|
|
|
|
+ const data = JSON.parse(fs.readFileSync(portraitsQueuePath(), 'utf8'));
|
|
|
|
|
+ return Array.isArray(data) ? data : [];
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function withCustomerIntelligenceDb(callback) {
|
|
|
|
|
- const db = new AgentWorkbenchDb(latestPath('messages', 'agent-workbench.db'), {
|
|
|
|
|
- globalPaused: true,
|
|
|
|
|
- defaultMode: 'review',
|
|
|
|
|
- autoSendConfidence: 0.88,
|
|
|
|
|
- });
|
|
|
|
|
- try { return callback(db); }
|
|
|
|
|
- finally { db.close(); }
|
|
|
|
|
|
|
+function writePortraitQueue(queue) {
|
|
|
|
|
+ ensurePortraitsDir();
|
|
|
|
|
+ fs.writeFileSync(portraitsQueuePath(), JSON.stringify(queue, null, 2), 'utf8');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function publicCanonicalProfile(profile = {}) {
|
|
|
|
|
- const { __evidence, ...visible } = profile || {};
|
|
|
|
|
- return visible;
|
|
|
|
|
|
|
+function enqueuePortraitUpdate(externalUserId, reason = 'WEBHOOK') {
|
|
|
|
|
+ if (!externalUserId) return false;
|
|
|
|
|
+ const queue = readPortraitQueue();
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ const fiveMinutes = 5 * 60 * 1000;
|
|
|
|
|
+ const existing = queue.find(item => item.externalUserId === externalUserId);
|
|
|
|
|
+ if (existing) {
|
|
|
|
|
+ if (now - new Date(existing.enqueuedAt).getTime() < fiveMinutes) {
|
|
|
|
|
+ existing.reason = reason;
|
|
|
|
|
+ existing.enqueuedAt = new Date().toISOString();
|
|
|
|
|
+ writePortraitQueue(queue);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ queue.push({ externalUserId, reason, enqueuedAt: new Date().toISOString() });
|
|
|
|
|
+ writePortraitQueue(queue);
|
|
|
|
|
+ return true;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function readCanonicalPortrait(externalUserId) {
|
|
|
|
|
- return withCustomerIntelligenceDb(db => {
|
|
|
|
|
- const conversation = db.getConversationByContactId(externalUserId);
|
|
|
|
|
- if (!conversation) return null;
|
|
|
|
|
- const record = db.getProfile(conversation.id);
|
|
|
|
|
- if (!Object.keys(record.profile || {}).length && !(record.tags || []).length) return null;
|
|
|
|
|
- return {
|
|
|
|
|
- externalUserId,
|
|
|
|
|
- portrait: publicCanonicalProfile(record.profile),
|
|
|
|
|
- tags: record.tags || [],
|
|
|
|
|
- source: 'customer-intelligence-db',
|
|
|
|
|
- updatedAt: record.updatedAt,
|
|
|
|
|
- };
|
|
|
|
|
- });
|
|
|
|
|
|
|
+async function processPortraitQueue(limit = 10) {
|
|
|
|
|
+ const queue = readPortraitQueue();
|
|
|
|
|
+ if (!queue.length) return { processed: 0 };
|
|
|
|
|
+ const toProcess = queue.slice(0, Math.max(1, Number(limit) || 10));
|
|
|
|
|
+ const remaining = queue.slice(toProcess.length);
|
|
|
|
|
+ let processed = 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (const item of toProcess) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await qiweiUpdateCustomerPortrait({ externalUserId: item.externalUserId });
|
|
|
|
|
+ processed++;
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.warn(`[PortraitQueue] 处理 ${item.externalUserId} 失败:`, err.message);
|
|
|
|
|
+ remaining.push(item);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ writePortraitQueue(remaining);
|
|
|
|
|
+ return { processed, remaining: remaining.length };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function mergeCanonicalPortrait(externalUserId, portrait = {}, tags) {
|
|
|
|
|
- return withCustomerIntelligenceDb(db => {
|
|
|
|
|
- const conversation = db.ensureConversation(externalUserId, '');
|
|
|
|
|
- const current = db.getProfile(conversation.id);
|
|
|
|
|
- const patch = portrait?.portrait && typeof portrait.portrait === 'object' ? portrait.portrait : portrait;
|
|
|
|
|
- return db.updateProfile(
|
|
|
|
|
- conversation.id,
|
|
|
|
|
- { ...current.profile, ...(patch || {}) },
|
|
|
|
|
- tags === undefined ? current.tags : tags,
|
|
|
|
|
- );
|
|
|
|
|
- });
|
|
|
|
|
|
|
+function portraitFilePath(externalUserId) {
|
|
|
|
|
+ ensurePortraitsDir();
|
|
|
|
|
+ return path.join(portraitsDir(), `${externalUserId}.json`);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function readPortrait(externalUserId) {
|
|
function readPortrait(externalUserId) {
|
|
|
- const canonical = readCanonicalPortrait(externalUserId);
|
|
|
|
|
- if (canonical) return canonical;
|
|
|
|
|
const filePath = portraitFilePath(externalUserId);
|
|
const filePath = portraitFilePath(externalUserId);
|
|
|
if (!fs.existsSync(filePath)) return null;
|
|
if (!fs.existsSync(filePath)) return null;
|
|
|
try {
|
|
try {
|
|
@@ -112,7 +125,13 @@ function readPortrait(externalUserId) {
|
|
|
function writePortrait(externalUserId, portrait) {
|
|
function writePortrait(externalUserId, portrait) {
|
|
|
const filePath = portraitFilePath(externalUserId);
|
|
const filePath = portraitFilePath(externalUserId);
|
|
|
fs.writeFileSync(filePath, JSON.stringify(portrait, null, 2), 'utf8');
|
|
fs.writeFileSync(filePath, JSON.stringify(portrait, null, 2), 'utf8');
|
|
|
- mergeCanonicalPortrait(externalUserId, portrait);
|
|
|
|
|
|
|
+ return filePath;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function deletePortrait(externalUserId) {
|
|
|
|
|
+ const filePath = portraitFilePath(externalUserId);
|
|
|
|
|
+ if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
|
|
|
+ removePortrait(externalUserId);
|
|
|
return filePath;
|
|
return filePath;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -122,8 +141,6 @@ function tagFilePath(externalUserId) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function readTags(externalUserId) {
|
|
function readTags(externalUserId) {
|
|
|
- const canonical = readCanonicalPortrait(externalUserId);
|
|
|
|
|
- if (canonical && Array.isArray(canonical.tags)) return canonical.tags;
|
|
|
|
|
const filePath = tagFilePath(externalUserId);
|
|
const filePath = tagFilePath(externalUserId);
|
|
|
if (!fs.existsSync(filePath)) return [];
|
|
if (!fs.existsSync(filePath)) return [];
|
|
|
try {
|
|
try {
|
|
@@ -136,9 +153,7 @@ function readTags(externalUserId) {
|
|
|
|
|
|
|
|
function writeTags(externalUserId, tags) {
|
|
function writeTags(externalUserId, tags) {
|
|
|
const filePath = tagFilePath(externalUserId);
|
|
const filePath = tagFilePath(externalUserId);
|
|
|
- const uniqueTags = [...new Set(tags)];
|
|
|
|
|
- fs.writeFileSync(filePath, JSON.stringify({ externalUserId, tags: uniqueTags, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
|
|
|
|
|
- mergeCanonicalPortrait(externalUserId, {}, uniqueTags);
|
|
|
|
|
|
|
+ fs.writeFileSync(filePath, JSON.stringify({ externalUserId, tags: [...new Set(tags)], updatedAt: new Date().toISOString() }, null, 2), 'utf8');
|
|
|
return filePath;
|
|
return filePath;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -156,10 +171,82 @@ function extractRoomIdFromFileName(fileName) {
|
|
|
return match ? match[1] : null;
|
|
return match ? match[1] : null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function listRoomIdsFromSubdirectories() {
|
|
|
|
|
+ const dir = path.join(outputsRoot(), 'messages');
|
|
|
|
|
+ if (!fs.existsSync(dir)) return [];
|
|
|
|
|
+ return fs.readdirSync(dir)
|
|
|
|
|
+ .filter(f => {
|
|
|
|
|
+ const fullPath = path.join(dir, f);
|
|
|
|
|
+ return fs.statSync(fullPath).isDirectory() && /^[a-zA-Z0-9_-]+$/.test(f);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function listRoomMessageFiles(roomId) {
|
|
|
|
|
+ const dir = path.join(outputsRoot(), 'messages', roomId);
|
|
|
|
|
+ if (!fs.existsSync(dir)) return [];
|
|
|
|
|
+ return fs.readdirSync(dir)
|
|
|
|
|
+ .filter(f => f.endsWith('.json'))
|
|
|
|
|
+ .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
|
|
|
|
|
+ .sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function textOfMessage(message = {}) {
|
|
|
|
|
+ const rawData = message.rawData || {};
|
|
|
|
|
+ const msgData = message.msgData || rawData.msgData || {};
|
|
|
|
|
+ const raw = String(
|
|
|
|
|
+ message.content ||
|
|
|
|
|
+ message.text ||
|
|
|
|
|
+ message.msgContent ||
|
|
|
|
|
+ rawData.content ||
|
|
|
|
|
+ rawData.msgContent ||
|
|
|
|
|
+ msgData.content ||
|
|
|
|
|
+ msgData.text ||
|
|
|
|
|
+ (Array.isArray(msgData.moreDetail) ? msgData.moreDetail.map(item => item && item.text).filter(Boolean).join('') : '') ||
|
|
|
|
|
+ ''
|
|
|
|
|
+ );
|
|
|
|
|
+ return raw
|
|
|
|
|
+ .replace(/[\u0000-\u001f\u007f]/g, '')
|
|
|
|
|
+ .replace(/^[A-Za-z]{1,3}(?=[\u4e00-\u9fa5])/u, '')
|
|
|
|
|
+ .trim();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function inferRoomIdsForExternalUserId(externalUserId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const state = readState();
|
|
|
|
|
+ for (const customer of Object.values(state.customers || {})) {
|
|
|
|
|
+ if (customer && customer.externalUserId === externalUserId && Array.isArray(customer.sourceRoomIds) && customer.sourceRoomIds.length) {
|
|
|
|
|
+ return customer.sourceRoomIds.map(String).filter(Boolean);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function collectMessagesForExternalUserId(externalUserId, roomIds = null) {
|
|
function collectMessagesForExternalUserId(externalUserId, roomIds = null) {
|
|
|
- const files = listMessageFiles();
|
|
|
|
|
const messages = [];
|
|
const messages = [];
|
|
|
- for (const file of files) {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 优先:新单条文件格式 outputs/messages/{roomId}/{seq}-{msgUniqueId}.json
|
|
|
|
|
+ const roomIdList = roomIds || listRoomIdsFromSubdirectories();
|
|
|
|
|
+ for (const roomId of roomIdList) {
|
|
|
|
|
+ if (roomIds && !roomIds.includes(roomId)) continue;
|
|
|
|
|
+ const files = listRoomMessageFiles(roomId);
|
|
|
|
|
+ for (const file of files) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const msg = JSON.parse(fs.readFileSync(file.path, 'utf8'));
|
|
|
|
|
+ if (msg.senderId === externalUserId) {
|
|
|
|
|
+ messages.push({ ...msg, content: textOfMessage(msg), roomId });
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 兼容:旧数组文件格式 outputs/messages/room-{roomId}-{timestamp}.json
|
|
|
|
|
+ const legacyFiles = listMessageFiles();
|
|
|
|
|
+ for (const file of legacyFiles) {
|
|
|
const roomId = extractRoomIdFromFileName(file.name);
|
|
const roomId = extractRoomIdFromFileName(file.name);
|
|
|
if (roomIds && !roomIds.includes(roomId)) continue;
|
|
if (roomIds && !roomIds.includes(roomId)) continue;
|
|
|
try {
|
|
try {
|
|
@@ -167,18 +254,19 @@ function collectMessagesForExternalUserId(externalUserId, roomIds = null) {
|
|
|
if (!Array.isArray(data)) continue;
|
|
if (!Array.isArray(data)) continue;
|
|
|
for (const msg of data) {
|
|
for (const msg of data) {
|
|
|
if (msg.senderId === externalUserId) {
|
|
if (msg.senderId === externalUserId) {
|
|
|
- messages.push({ ...msg, roomId });
|
|
|
|
|
|
|
+ messages.push({ ...msg, content: textOfMessage(msg), roomId });
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
// ignore
|
|
// ignore
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
return messages;
|
|
return messages;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function simpleKeywordPortrait(messages) {
|
|
function simpleKeywordPortrait(messages) {
|
|
|
- const text = messages.map(m => m.content || '').join(' ');
|
|
|
|
|
|
|
+ const text = messages.map(m => textOfMessage(m)).filter(Boolean).join(' ');
|
|
|
const portrait = {};
|
|
const portrait = {};
|
|
|
for (const [field, keywords] of Object.entries(PORTRAIT_KEYWORDS)) {
|
|
for (const [field, keywords] of Object.entries(PORTRAIT_KEYWORDS)) {
|
|
|
const matched = keywords.filter(kw => text.includes(kw));
|
|
const matched = keywords.filter(kw => text.includes(kw));
|
|
@@ -190,9 +278,6 @@ function simpleKeywordPortrait(messages) {
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- if (!Object.keys(portrait).length) {
|
|
|
|
|
- portrait.aiSummary = { note: `收集了 ${messages.length} 条消息,未识别到明确画像关键词` };
|
|
|
|
|
- }
|
|
|
|
|
return portrait;
|
|
return portrait;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -212,7 +297,7 @@ const qiweiPrepareCustomerPortrait = safeResult(async function qiweiPrepareCusto
|
|
|
const externalUserId = String(input.externalUserId || '').trim();
|
|
const externalUserId = String(input.externalUserId || '').trim();
|
|
|
if (!externalUserId) return errorResult('缺少 externalUserId');
|
|
if (!externalUserId) return errorResult('缺少 externalUserId');
|
|
|
|
|
|
|
|
- const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
|
|
|
|
|
|
|
+ const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : inferRoomIdsForExternalUserId(externalUserId);
|
|
|
const messages = collectMessagesForExternalUserId(externalUserId, roomIds);
|
|
const messages = collectMessagesForExternalUserId(externalUserId, roomIds);
|
|
|
|
|
|
|
|
const context = buildPortraitContext(externalUserId, messages);
|
|
const context = buildPortraitContext(externalUserId, messages);
|
|
@@ -220,18 +305,12 @@ const qiweiPrepareCustomerPortrait = safeResult(async function qiweiPrepareCusto
|
|
|
const runDir = createRunDir('portraits', `context-${externalUserId}`);
|
|
const runDir = createRunDir('portraits', `context-${externalUserId}`);
|
|
|
const filePath = path.join(runDir, `context-${externalUserId}.json`);
|
|
const filePath = path.join(runDir, `context-${externalUserId}.json`);
|
|
|
fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
|
|
fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
|
|
|
- const manifestPath = writeRunManifest(runDir, {
|
|
|
|
|
- kind: 'customer-portrait-context',
|
|
|
|
|
- externalUserId,
|
|
|
|
|
- messageCount: messages.length,
|
|
|
|
|
- files: [filePath]
|
|
|
|
|
- });
|
|
|
|
|
|
|
|
|
|
return okResult({
|
|
return okResult({
|
|
|
assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文:共 ${messages.length} 条消息。`,
|
|
assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文:共 ${messages.length} 条消息。`,
|
|
|
summary: { externalUserId, messageCount: messages.length },
|
|
summary: { externalUserId, messageCount: messages.length },
|
|
|
data: { context, contextFile: path.relative(outputsRoot(), filePath) },
|
|
data: { context, contextFile: path.relative(outputsRoot(), filePath) },
|
|
|
- files: [filePath, manifestPath],
|
|
|
|
|
|
|
+ files: [filePath],
|
|
|
nextActions: ['基于 context 分析后调用 qiwei_save_customer_portrait 保存']
|
|
nextActions: ['基于 context 分析后调用 qiwei_save_customer_portrait 保存']
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
@@ -241,41 +320,99 @@ const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustome
|
|
|
if (!externalUserId) return errorResult('缺少 externalUserId');
|
|
if (!externalUserId) return errorResult('缺少 externalUserId');
|
|
|
|
|
|
|
|
const aiMode = String(input.aiMode || '').trim();
|
|
const aiMode = String(input.aiMode || '').trim();
|
|
|
- const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
|
|
|
|
|
|
|
+ const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : inferRoomIdsForExternalUserId(externalUserId);
|
|
|
const messages = collectMessagesForExternalUserId(externalUserId, roomIds);
|
|
const messages = collectMessagesForExternalUserId(externalUserId, roomIds);
|
|
|
|
|
+ const textMessageCount = messages.filter(m => textOfMessage(m)).length;
|
|
|
|
|
+
|
|
|
|
|
+ if (!textMessageCount) {
|
|
|
|
|
+ deletePortrait(externalUserId);
|
|
|
|
|
+ return errorResult(`客户 ${externalUserId} 没有可用于生成画像的文本消息,请先同步该客户群的聊天记录。`, {
|
|
|
|
|
+ summary: { externalUserId, messageCount: messages.length, textMessageCount: 0, skipped: true }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
if (aiMode === 'keyword') {
|
|
if (aiMode === 'keyword') {
|
|
|
const portrait = simpleKeywordPortrait(messages);
|
|
const portrait = simpleKeywordPortrait(messages);
|
|
|
|
|
+ const fields = Object.keys(portrait);
|
|
|
|
|
+ if (!fields.length) {
|
|
|
|
|
+ deletePortrait(externalUserId);
|
|
|
|
|
+ const sampleMessages = messages
|
|
|
|
|
+ .map(m => ({ senderId: m.senderId, senderName: m.senderName, content: textOfMessage(m), timestamp: m.timestamp }))
|
|
|
|
|
+ .filter(m => m.content)
|
|
|
|
|
+ .slice(0, 5);
|
|
|
|
|
+ return errorResult(`客户 ${externalUserId} 已同步到 ${textMessageCount} 条文本消息,但暂未识别到预算、区域、房型、时间或关注点等画像信息,未标记为已生成。`, {
|
|
|
|
|
+ summary: { externalUserId, messageCount: messages.length, textMessageCount, fields: [], skipped: true },
|
|
|
|
|
+ data: { sampleMessages }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
const saved = { externalUserId, portrait, source: 'keyword', messageCount: messages.length, updatedAt: new Date().toISOString() };
|
|
const saved = { externalUserId, portrait, source: 'keyword', messageCount: messages.length, updatedAt: new Date().toISOString() };
|
|
|
const filePath = writePortrait(externalUserId, saved);
|
|
const filePath = writePortrait(externalUserId, saved);
|
|
|
|
|
+ recordPortrait(externalUserId, { source: 'keyword', messageCount: messages.length, fields });
|
|
|
return okResult({
|
|
return okResult({
|
|
|
assistantMessage: `关键词模式画像更新完成:${externalUserId}。`,
|
|
assistantMessage: `关键词模式画像更新完成:${externalUserId}。`,
|
|
|
- summary: { externalUserId, messageCount: messages.length, fields: Object.keys(portrait) },
|
|
|
|
|
|
|
+ summary: { externalUserId, messageCount: messages.length, textMessageCount, fields },
|
|
|
data: saved,
|
|
data: saved,
|
|
|
files: [filePath]
|
|
files: [filePath]
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 默认 Agent 驱动:返回上下文
|
|
|
|
|
|
|
+ // 默认 LLM Agent 模式:生成上下文 → 调 LLM → 解析 JSON → 保存
|
|
|
const context = buildPortraitContext(externalUserId, messages);
|
|
const context = buildPortraitContext(externalUserId, messages);
|
|
|
ensurePortraitsDir();
|
|
ensurePortraitsDir();
|
|
|
const runDir = createRunDir('portraits', `context-${externalUserId}`);
|
|
const runDir = createRunDir('portraits', `context-${externalUserId}`);
|
|
|
- const filePath = path.join(runDir, `context-${externalUserId}.json`);
|
|
|
|
|
- fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
|
|
|
|
|
- const manifestPath = writeRunManifest(runDir, {
|
|
|
|
|
- kind: 'customer-portrait-context',
|
|
|
|
|
- externalUserId,
|
|
|
|
|
- messageCount: messages.length,
|
|
|
|
|
- files: [filePath]
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const contextFilePath = path.join(runDir, `context-${externalUserId}.json`);
|
|
|
|
|
+ fs.writeFileSync(contextFilePath, JSON.stringify(context, null, 2), 'utf8');
|
|
|
|
|
|
|
|
- return okResult({
|
|
|
|
|
- assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文,请 Agent 分析后调用 qiwei_save_customer_portrait 保存。`,
|
|
|
|
|
- summary: { externalUserId, messageCount: messages.length, requiresAgentAnalysis: true },
|
|
|
|
|
- data: { context, contextFile: path.relative(outputsRoot(), filePath), saveEndpoint: 'qiwei_save_customer_portrait' },
|
|
|
|
|
- files: [filePath, manifestPath],
|
|
|
|
|
- nextActions: ['分析 context 后调用 qiwei_save_customer_portrait']
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ try {
|
|
|
|
|
+ const { portrait, usage } = await analyzePortraitWithLlm(context, {
|
|
|
|
|
+ model: input.model,
|
|
|
|
|
+ apiKey: input.apiKey,
|
|
|
|
|
+ apiBase: input.apiBase,
|
|
|
|
|
+ timeoutMs: input.timeoutMs,
|
|
|
|
|
+ temperature: input.temperature
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const fields = Object.keys(portrait).filter(Boolean);
|
|
|
|
|
+ if (!fields.length) {
|
|
|
|
|
+ deletePortrait(externalUserId);
|
|
|
|
|
+ return errorResult(`LLM 未返回有效画像字段:${externalUserId}`, {
|
|
|
|
|
+ summary: { externalUserId, messageCount: messages.length, textMessageCount, skipped: true },
|
|
|
|
|
+ data: { context, contextFile: path.relative(outputsRoot(), contextFilePath), usage }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const saved = {
|
|
|
|
|
+ externalUserId,
|
|
|
|
|
+ portrait,
|
|
|
|
|
+ source: 'agent',
|
|
|
|
|
+ messageCount: messages.length,
|
|
|
|
|
+ updatedAt: new Date().toISOString(),
|
|
|
|
|
+ llmUsage: usage
|
|
|
|
|
+ };
|
|
|
|
|
+ const filePath = writePortrait(externalUserId, saved);
|
|
|
|
|
+ recordPortrait(externalUserId, { source: 'agent', messageCount: messages.length, fields });
|
|
|
|
|
+
|
|
|
|
|
+ return okResult({
|
|
|
|
|
+ assistantMessage: `LLM Agent 画像更新完成:${externalUserId}。`,
|
|
|
|
|
+ summary: { externalUserId, messageCount: messages.length, textMessageCount, fields, source: 'agent' },
|
|
|
|
|
+ data: saved,
|
|
|
|
|
+ files: [filePath, contextFilePath]
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ // LLM 失败时保留上下文文件,方便人工排查
|
|
|
|
|
+ return {
|
|
|
|
|
+ status: error.kind === 'auth' ? 'needs_auth' : 'error',
|
|
|
|
|
+ assistantMessage: `LLM Agent 画像生成失败:${error.message}`,
|
|
|
|
|
+ summary: { externalUserId, messageCount: messages.length, textMessageCount, errorKind: error.kind || 'runtime' },
|
|
|
|
|
+ data: { context, contextFile: path.relative(outputsRoot(), contextFilePath), rawOutput: error.rawOutput || undefined },
|
|
|
|
|
+ files: [contextFilePath],
|
|
|
|
|
+ nextActions: error.kind === 'auth'
|
|
|
|
|
+ ? ['检查 FMODE_API_KEY / ~/.fmode/config.json 中的 newapiToken']
|
|
|
|
|
+ : ['查看 context 文件并考虑手动调用 qiwei_save_customer_portrait'],
|
|
|
|
|
+ warnings: [],
|
|
|
|
|
+ errors: [{ message: error.message, kind: error.kind || 'runtime' }]
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
const qiweiSaveCustomerPortrait = safeResult(async function qiweiSaveCustomerPortrait(input = {}) {
|
|
const qiweiSaveCustomerPortrait = safeResult(async function qiweiSaveCustomerPortrait(input = {}) {
|
|
@@ -291,11 +428,14 @@ const qiweiSaveCustomerPortrait = safeResult(async function qiweiSaveCustomerPor
|
|
|
messageCount: input.messageCount || 0,
|
|
messageCount: input.messageCount || 0,
|
|
|
updatedAt: new Date().toISOString()
|
|
updatedAt: new Date().toISOString()
|
|
|
};
|
|
};
|
|
|
|
|
+ const fields = Object.keys(portrait).filter(Boolean);
|
|
|
|
|
+ if (!fields.length) return errorResult('画像 JSON 为空,未保存');
|
|
|
const filePath = writePortrait(externalUserId, saved);
|
|
const filePath = writePortrait(externalUserId, saved);
|
|
|
|
|
+ recordPortrait(externalUserId, { source: saved.source, messageCount: saved.messageCount, fields });
|
|
|
|
|
|
|
|
return okResult({
|
|
return okResult({
|
|
|
assistantMessage: `客户画像已保存:${externalUserId}。`,
|
|
assistantMessage: `客户画像已保存:${externalUserId}。`,
|
|
|
- summary: { externalUserId, fields: Object.keys(portrait) },
|
|
|
|
|
|
|
+ summary: { externalUserId, fields },
|
|
|
data: saved,
|
|
data: saved,
|
|
|
files: [filePath]
|
|
files: [filePath]
|
|
|
});
|
|
});
|
|
@@ -311,15 +451,19 @@ const qiweiBatchUpdateCustomerPortrait = safeResult(async function qiweiBatchUpd
|
|
|
for (const externalUserId of externalUserIds) {
|
|
for (const externalUserId of externalUserIds) {
|
|
|
try {
|
|
try {
|
|
|
const result = await qiweiUpdateCustomerPortrait({ ...input, externalUserId });
|
|
const result = await qiweiUpdateCustomerPortrait({ ...input, externalUserId });
|
|
|
- results.push({ externalUserId, status: result.status, summary: result.summary });
|
|
|
|
|
|
|
+ results.push({ externalUserId, status: result.status, summary: result.summary, message: result.assistantMessage });
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
results.push({ externalUserId, status: 'error', message: String(error && error.message ? error.message : error) });
|
|
results.push({ externalUserId, status: 'error', message: String(error && error.message ? error.message : error) });
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ const succeeded = results.filter(r => r.status === 'ok').length;
|
|
|
|
|
+ const failed = results.length - succeeded;
|
|
|
|
|
|
|
|
return okResult({
|
|
return okResult({
|
|
|
- assistantMessage: `批量画像更新完成:${results.filter(r => r.status === 'ok').length}/${results.length}。`,
|
|
|
|
|
- summary: { total: results.length, succeeded: results.filter(r => r.status === 'ok').length, failed: results.filter(r => r.status !== 'ok').length },
|
|
|
|
|
|
|
+ assistantMessage: failed
|
|
|
|
|
+ ? `批量画像更新完成:成功 ${succeeded}/${results.length},${failed} 位因缺少有效聊天内容或 LLM 生成失败。`
|
|
|
|
|
+ : `批量画像更新完成:成功 ${succeeded}/${results.length}。`,
|
|
|
|
|
+ summary: { total: results.length, succeeded, failed },
|
|
|
data: { results }
|
|
data: { results }
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
@@ -359,6 +503,13 @@ const qiweiExportCustomerPortraits = safeResult(async function qiweiExportCustom
|
|
|
|
|
|
|
|
if (!externalUserIds.length) return errorResult('没有可导出的画像');
|
|
if (!externalUserIds.length) return errorResult('没有可导出的画像');
|
|
|
|
|
|
|
|
|
|
+ let XLSX;
|
|
|
|
|
+ try {
|
|
|
|
|
+ XLSX = require('xlsx');
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return errorResult('当前包尚未安装 xlsx,无法导出 Excel;请运行 npm install xlsx');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const rows = [];
|
|
const rows = [];
|
|
|
for (const externalUserId of externalUserIds) {
|
|
for (const externalUserId of externalUserIds) {
|
|
|
const data = readPortrait(externalUserId);
|
|
const data = readPortrait(externalUserId);
|
|
@@ -369,7 +520,10 @@ const qiweiExportCustomerPortraits = safeResult(async function qiweiExportCustom
|
|
|
ensurePortraitsDir();
|
|
ensurePortraitsDir();
|
|
|
const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
|
|
const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
|
|
|
const filePath = path.join(portraitsDir(), fileName);
|
|
const filePath = path.join(portraitsDir(), fileName);
|
|
|
- await writeObjectRows(filePath, 'portraits', rows);
|
|
|
|
|
|
|
+ const worksheet = XLSX.utils.json_to_sheet(rows);
|
|
|
|
|
+ const workbook = XLSX.utils.book_new();
|
|
|
|
|
+ XLSX.utils.book_append_sheet(workbook, worksheet, 'portraits');
|
|
|
|
|
+ XLSX.writeFile(workbook, filePath);
|
|
|
|
|
|
|
|
return okResult({
|
|
return okResult({
|
|
|
assistantMessage: `已导出 ${rows.length} 条客户画像:${filePath}。`,
|
|
assistantMessage: `已导出 ${rows.length} 条客户画像:${filePath}。`,
|
|
@@ -401,6 +555,7 @@ const qiweiAddCustomerTags = safeResult(async function qiweiAddCustomerTags(inpu
|
|
|
const current = readTags(externalUserId);
|
|
const current = readTags(externalUserId);
|
|
|
const updated = [...new Set([...current, ...tags])];
|
|
const updated = [...new Set([...current, ...tags])];
|
|
|
writeTags(externalUserId, updated);
|
|
writeTags(externalUserId, updated);
|
|
|
|
|
+ recordTags(externalUserId, updated);
|
|
|
|
|
|
|
|
return okResult({
|
|
return okResult({
|
|
|
assistantMessage: `已为客户 ${externalUserId} 添加标签:${tags.join('、')}。`,
|
|
assistantMessage: `已为客户 ${externalUserId} 添加标签:${tags.join('、')}。`,
|
|
@@ -419,6 +574,7 @@ const qiweiRemoveCustomerTags = safeResult(async function qiweiRemoveCustomerTag
|
|
|
const tagSet = new Set(tags.map(t => t.toLowerCase()));
|
|
const tagSet = new Set(tags.map(t => t.toLowerCase()));
|
|
|
const updated = current.filter(t => !tagSet.has(String(t).toLowerCase()));
|
|
const updated = current.filter(t => !tagSet.has(String(t).toLowerCase()));
|
|
|
writeTags(externalUserId, updated);
|
|
writeTags(externalUserId, updated);
|
|
|
|
|
+ recordTags(externalUserId, updated);
|
|
|
|
|
|
|
|
return okResult({
|
|
return okResult({
|
|
|
assistantMessage: `已为客户 ${externalUserId} 移除标签:${tags.join('、')}。`,
|
|
assistantMessage: `已为客户 ${externalUserId} 移除标签:${tags.join('、')}。`,
|
|
@@ -469,13 +625,7 @@ const qiweiSyncPersonalLabels = safeResult(async function qiweiSyncPersonalLabel
|
|
|
currentSeq: 0,
|
|
currentSeq: 0,
|
|
|
labelType: 2
|
|
labelType: 2
|
|
|
});
|
|
});
|
|
|
- const labels = Array.isArray(data && data.labelList)
|
|
|
|
|
- ? data.labelList.map(item => ({
|
|
|
|
|
- ...item,
|
|
|
|
|
- labelName: item.labelName || item.name || '',
|
|
|
|
|
- labelSuperId: item.labelSuperId || item.groupId || ''
|
|
|
|
|
- }))
|
|
|
|
|
- : [];
|
|
|
|
|
|
|
+ const labels = Array.isArray(data && data.labelList) ? data.labelList : [];
|
|
|
|
|
|
|
|
return okResult({
|
|
return okResult({
|
|
|
assistantMessage: `已同步 ${labels.length} 个企微个人标签。`,
|
|
assistantMessage: `已同步 ${labels.length} 个企微个人标签。`,
|
|
@@ -591,5 +741,7 @@ module.exports = {
|
|
|
qiweiCreatePersonalLabel,
|
|
qiweiCreatePersonalLabel,
|
|
|
qiweiUpdatePersonalLabel,
|
|
qiweiUpdatePersonalLabel,
|
|
|
qiweiDeletePersonalLabel,
|
|
qiweiDeletePersonalLabel,
|
|
|
- qiweiApplyPersonalLabels
|
|
|
|
|
|
|
+ qiweiApplyPersonalLabels,
|
|
|
|
|
+ enqueuePortraitUpdate,
|
|
|
|
|
+ processPortraitQueue
|
|
|
};
|
|
};
|