Преглед изворни кода

refactor: 清理死代码、简化仪表盘、增强画像增量更新

- 移除群管理中的消息同步游标、GBK 编码修复等死代码,精简 syncMsg 依赖
- 画像标签改为增量更新模式,新增 evidence state 追踪与消息去重标识
- 仪表盘移除群同步状态与群维度响应监听,聚焦客户维度视图
- 清理 agent-service / response-monitor-service / server 冗余逻辑
- 更新冒烟测试、技能文档与 README
ETO-kai пре 1 месец
родитељ
комит
ad99307cf2
23 измењених фајлова са 433 додато и 753 уклоњено
  1. 1 1
      claude-code/claude-code-qiwe-assistant/README.md
  2. 10 3
      claude-code/claude-code-qiwe-assistant/mcp/src/core/llm-client.js
  3. 8 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js
  4. 2 86
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  5. 38 92
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  6. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/business-diagnosis-service.js
  7. 4 61
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/response-monitor-service.js
  8. 1 39
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  9. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/workspace-library-service.js
  10. 9 29
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  11. 9 323
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js
  12. 118 31
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js
  13. 9 1
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/index.mjs
  14. 75 0
      claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/portrait-queue-worker.mjs
  15. 55 1
      claude-code/claude-code-qiwe-assistant/scripts/callback-relay-smoke-test.js
  16. 23 1
      claude-code/claude-code-qiwe-assistant/scripts/callback-runtime-smoke-test.mjs
  17. 9 21
      claude-code/claude-code-qiwe-assistant/scripts/response-monitor-smoke-test.js
  18. 28 27
      claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js
  19. 11 28
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-management/SKILL.md
  20. 1 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/SKILL.md
  21. 1 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/quality-rules.md
  22. 16 2
      claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md
  23. 3 3
      claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md

+ 1 - 1
claude-code/claude-code-qiwe-assistant/README.md

@@ -346,7 +346,7 @@ Dashboard 的「智能会话」页把回调消息、意图识别、需求画像
 - 点击「管理白名单」,从当前企微外部联系人中搜索并勾选允许 Agent 处理的客户;
 - 企业版由 MCP/Skill 自动连接公网回调并启动独立 Relay 守护进程;Dashboard 关闭后仍持续接收,离线期间的消息保留在服务端队列;
 - 个人版仍可点击「启动 AI 监听」,仅处理已选白名单联系人;
-- 已确认的客户群会进入独立的「群聊智能回复」列表,Agent 自动生成草稿,但必须人工编辑或审核后才能发送到群
+- 已确认的客户群只通过公网回调采集新消息并触发画像增量更新,不进入客服工作台回复链路
 - 企业版通过全局 `paused` 或单会话人工接管停止 Agent 处理,但消息仍持续接收并保存;
 - 真实发送只允许命中 `QIWEI_AUTO_REPLY_ALLOWED_SENDERS` 白名单的联系人;
 - 客服输入文本后可自动识别「自然、友好、真诚致歉、温和关怀、明确提醒」,人工确认后通过 Fmode `/api/voice/indextts2` 生成并发送本人音色的原生企微语音;试听默认禁用,避免额外产生一次合成费用;

+ 10 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/core/llm-client.js

@@ -11,6 +11,10 @@ function buildLlmUrl(apiBase) {
 
 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 || '未知';
@@ -18,12 +22,14 @@ function buildPortraitAnalysisPrompt(context) {
     return `${idx + 1}. [${time}] ${sender}: ${text}`;
   }).join('\n');
 
-  return `你是房产经纪行业的客户画像分析专家。请根据下面这位客户在企微群聊中的历史消息,生成一份结构化客户画像 JSON。
+  return `你是房产经纪行业的客户画像分析专家。请根据下面这位客户在企微群聊中的${incremental ? '新增消息' : '全部消息'},生成一份结构化客户画像 JSON。
 
 客户 externalUserId:${context.externalUserId}
 消息数量:${context.messageCount || messages.length}
+更新模式:${incremental ? '增量更新' : '全量更新'}
+${incremental ? `现有画像:\n${existingPortrait}\n` : ''}
 
-历史消息:
+${incremental ? '新增消息' : '全部消息'}
 ${messageLines || '(无文本消息)'}
 
 分析维度(JSON 字段):
@@ -48,7 +54,8 @@ ${messageLines || '(无文本消息)'}
 1. 只输出合法 JSON,不要 markdown 代码块,不要额外解释。
 2. 没有依据的字段必须填 null 或 "暂无法推断",禁止编造预算、区域、房型。
 3. 如果消息极少或没有有效需求信息,confidence 必须为 low,aiSummary 要如实说明。
-4. 字段名必须严格使用上述英文,值可用中文。`;
+4. 字段名必须严格使用上述英文,值可用中文。
+5. ${incremental ? '必须在现有画像基础上合并新增证据;新增消息未涉及的旧字段应保留,只有新证据明确冲突时才更新。输出完整合并后的画像。' : '应完全依据本次提供的全部消息重新计算,不沿用旧结论。'}`;
 }
 
 function extractJsonFromLlmOutput(text) {

+ 8 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js

@@ -617,6 +617,14 @@ async function routeMessageToAgent(event, eventFilePath = null) {
   const archived = isGroup
     ? await storeGroupMessageFromWebhook(event, eventFilePath)
     : { success: false, ignored: true, reason: 'private_message' };
+  if (isGroup) {
+    return {
+      success: Boolean(archived.success),
+      ignored: Boolean(!archived.success),
+      archived,
+      agent: null,
+    };
+  }
   const agentResult = await require('../dashboard/agent-service').ingestWebhookMessage(raw);
   const agentStatus = String(agentResult?.status || '');
   const agentHandled = Boolean(agentStatus && !agentStatus.startsWith('ignored_'));

+ 2 - 86
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -19,7 +19,6 @@ const { messageTimestamp, roomIdOf, isGroupMessage, evaluatePolledMessage } = re
 const { setActiveQiweiContext } = require('../core/credentials');
 const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const { responseMonitor } = require('./response-monitor-service');
-const { GroupAgentService } = require('./group-agent-service');
 const { normalizeAllowlistIds, normalizeAllowlistContact, writeEnvValue } = require('../core/allowlist-config');
 const { getProductMode } = require('../core/product-mode');
 const { getRelayDaemonStatus } = require('../core/relay-daemon');
@@ -367,11 +366,7 @@ class QiweiAgentPoller {
 
 async function ingestMessageForWorkbench(target, message = {}, source = 'callback') {
   if (isGroupMessage(message)) {
-    try { return await groupAgentService.ingestPolledMessage(message, target.config); }
-    catch (error) {
-      target.db.audit({ actor: 'agent', action: 'group_agent_failed', detail: { roomId: roomIdOf(message), source, message: error.message } });
-      throw error;
-    }
+    return { status: 'ignored_group_reply_disabled' };
   }
   const candidate = evaluatePolledMessage(message, target.config);
   if (!candidate.eligible) return { status: `ignored_${candidate.reason || 'ineligible'}` };
@@ -484,12 +479,6 @@ function activeAccountMetadata() {
   };
 }
 
-const groupAgentService = new GroupAgentService({
-  projectRoot: PROJECT_ROOT,
-  getRuntime: () => workbench,
-  getAccount: () => activeAccountMetadata(),
-});
-
 function applyActiveAccountContext(account) {
   setActiveQiweiContext(account);
   Object.assign(workbench.config.qiwei, account);
@@ -1086,73 +1075,6 @@ function getResponseMonitor() {
   };
 }
 
-function getGroupAgents() {
-  const groups = groupAgentService.list();
-  return {
-    status: 'ok',
-    assistantMessage: `已读取 ${groups.length} 个客户群的智能回复状态`,
-    summary: {
-      groupCount: groups.length,
-      pendingCount: groups.filter(item => item.pendingReply).length,
-      errorCount: groups.filter(item => item.agentError || item.sendError).length,
-      autoCount: groups.filter(item => item.mode === 'auto').length,
-    },
-    data: { groups },
-    warnings: [],
-    errors: [],
-  };
-}
-
-async function generateGroupReply(roomId, options = {}) {
-  try {
-    const result = await groupAgentService.generate(roomId, options);
-    return {
-      status: 'ok',
-      assistantMessage: result.status === 'no_reply_needed'
-        ? 'Agent 判断这条群消息无需回复'
-        : result.status === 'auto_sent' ? '群聊 Agent 已忽略风险并自动发送回复' : '群聊 Agent 已生成待审核草稿',
-      data: result,
-    };
-  } catch {
-    throw new Error('群聊 Agent 暂时不可用,消息已保留,可稍后重试或人工回复');
-  }
-}
-
-function changeGroupMode(roomId, mode, confirmation) {
-  const group = groupAgentService.setMode(roomId, mode, confirmation);
-  return {
-    status: 'ok',
-    assistantMessage: group.mode === 'auto'
-      ? '群聊全自动模式已开启:后续 Agent 非空回复将忽略风险并直接发送'
-      : '群聊已切回人工审核模式',
-    data: { group },
-  };
-}
-
-async function approveGroupDraft(roomId, draftId, content) {
-  try {
-    const result = await groupAgentService.approve(roomId, draftId, content);
-    return { status: 'ok', assistantMessage: '群聊回复已真实发送', data: result };
-  } catch (error) {
-    if (/不存在|已经是|不能为空|过长|尚未确认为/.test(error.message)) throw error;
-    throw new Error('群聊发送失败,请确认企微账号在线后重试');
-  }
-}
-
-function rejectGroupDraft(roomId, draftId, reason) {
-  const result = groupAgentService.reject(roomId, draftId, reason);
-  return { status: 'ok', assistantMessage: '群聊草稿已驳回', data: result };
-}
-
-async function regenerateGroupDraft(roomId, draftId) {
-  try {
-    const result = await groupAgentService.regenerate(roomId, draftId);
-    return { status: 'ok', assistantMessage: '群聊 Agent 已重新生成待审核草稿', data: result };
-  } catch {
-    throw new Error('群聊 Agent 暂时不可用,原草稿已保留');
-  }
-}
-
 function updateCustomerProfile(conversationId, input = {}) {
   const conversation = workbench.db.getConversation(conversationId);
   if (!conversation) throw new Error('客户会话不存在');
@@ -1686,12 +1608,6 @@ module.exports = {
   updateAllowlist,
   getConversations,
   getResponseMonitor,
-  getGroupAgents,
-  changeGroupMode,
-  generateGroupReply,
-  approveGroupDraft,
-  rejectGroupDraft,
-  regenerateGroupDraft,
   updateCustomerProfile,
   updateCustomerRecommendation,
   syncConversations,
@@ -1718,5 +1634,5 @@ module.exports = {
   stopListener,
   getAgentRuntimeConfig,
   createWorkbench,
-  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, groupAgentService, startListenerForWorkbench },
+  __testing: { loadAgentConfig, normalizeAllowlistIds, normalizeAllowlistContact, accountRuntimeKey, accountWorkbenchOverrides, activeAccountMetadata, FmodeQiweiClient, QiweiAgentPoller, ingestMessageForWorkbench, conversationChannelInfo, publicConversation, backfillCustomerIntelligence, backfillPropertyRecommendations, backfillSentVoiceAudioPaths, sentVoiceRuns, pendingVoiceDraft, markVoiceDraftSent, detectedPropertiesInMessage, startListenerForWorkbench },
 };

+ 38 - 92
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -47,7 +47,6 @@
     currentAccountId: localStorage.getItem(CURRENT_ACCOUNT_KEY) || null,
     status: null,
     groups: [],
-    groupSync: {},
     groupAutoSyncStarted: false,
     groupFilters: loadFilters(GROUP_FILTERS_KEY, { keyword: '', status: '', showCustomerOnly: false }),
     groupKeywords: null,
@@ -143,10 +142,6 @@
     localStorage.setItem(CURRENT_ACCOUNT_KEY, state.currentAccountId || '');
   }
 
-  function isGroupSynced(roomId) {
-    return !!(state.groupSync[roomId] && state.groupSync[roomId].lastSyncAt);
-  }
-
   function addAccount(account) {
     const existing = state.accounts.find(a =>
       (account.uid && a.uid === account.uid) ||
@@ -1286,7 +1281,7 @@
         actions: [
           { label: '处理风险', description: '查看原消息证据并建立整改任务', hash: '#group-ops' },
           { label: '审核今日计划', description: '批准话术或退回修改', hash: '#group-ops' },
-          { label: '核对客户群', description: '确认群身份与消息同步状态', hash: '#groups' }
+          { label: '核对客户群', description: '确认群身份与回调接入范围', hash: '#groups' }
         ]
       };
     }
@@ -2447,10 +2442,7 @@
     const config = monitor.config || {};
     const scope = monitor.scope || { customers: [], groups: [] };
     const reminders = monitor.reminders || [];
-    const scopeItems = [
-      ...(scope.customers || []).map(item => ({ ...item, typeLabel: '客户' })),
-      ...(scope.groups || []).map(item => ({ ...item, typeLabel: '客户群' })),
-    ];
+    const scopeItems = (scope.customers || []).map(item => ({ ...item, typeLabel: '客户' }));
     return `
       <section class="response-monitor ${summary.urgent ? 'has-urgent' : ''}">
         <header class="response-monitor-header">
@@ -2461,7 +2453,6 @@
           </div>
           <div class="response-monitor-kpis">
             <span><b>${summary.monitoredCustomers || 0}</b> 个客户</span>
-            <span><b>${summary.monitoredGroups || 0}</b> 个客户群</span>
             <span class="${summary.overdue ? 'danger' : ''}"><b>${summary.overdue || 0}</b> 个超时</span>
           </div>
         </header>
@@ -2469,7 +2460,7 @@
           <div class="response-monitor-scope">
             <div class="response-monitor-section-title"><strong>当前监听范围</strong><span>${config.warningMinutes || 15} 分钟提醒 · ${config.urgentMinutes || 60} 分钟紧急</span></div>
             <div class="response-scope-list">
-              ${scopeItems.length ? scopeItems.map(item => `<span class="response-scope-chip ${escapeHtml(item.status || '')}"><i>${escapeHtml(item.typeLabel)}</i>${escapeHtml(item.name)}<em>${item.status === 'awaiting_sync' ? '待同步' : item.status === 'overdue' ? '待回复' : item.status === 'replied' ? '已回复' : item.status === 'no_reply_needed' ? '无需回复' : '监控中'}</em></span>`).join('') : '<span class="response-monitor-empty">尚未配置监听客户或客户群</span>'}
+              ${scopeItems.length ? scopeItems.map(item => `<span class="response-scope-chip ${escapeHtml(item.status || '')}"><i>${escapeHtml(item.typeLabel)}</i>${escapeHtml(item.name)}<em>${item.status === 'awaiting_sync' ? '待同步' : item.status === 'overdue' ? '待回复' : item.status === 'replied' ? '已回复' : item.status === 'no_reply_needed' ? '无需回复' : '监控中'}</em></span>`).join('') : '<span class="response-monitor-empty">尚未配置监听客户</span>'}
             </div>
           </div>
           <div class="response-reminder-list">
@@ -2541,7 +2532,7 @@
       warning: '即将超时',
       replied: '已回复',
       no_reply_needed: '无需回复',
-      awaiting_sync: '等待消息同步',
+      awaiting_sync: '等待回调新消息',
       monitoring: '监控中',
     })[status] || '监控中';
   }
@@ -2566,7 +2557,7 @@
   }
 
   function groupConversationFlow(group) {
-    const synced = group?.status !== 'awaiting_sync';
+    const confirmed = Boolean(group?.roomId);
     const hasMessage = Boolean(group?.messages?.length || group?.lastMessageAt);
     const pending = Boolean(group?.pendingReply);
     const failed = Boolean(group?.agentError);
@@ -2575,8 +2566,8 @@
     const auto = group?.mode === 'auto';
     const sent = ['group_message_sent', 'group_message_auto_sent'].includes(group?.lastOutcome?.action) || group?.status === 'replied';
     return [
-      { label: '群消息接入', detail: hasMessage ? '消息已同步' : '等待同步', state: hasMessage ? 'done' : 'current' },
-      { label: '范围识别', detail: synced ? '已确认客户群' : '等待确认', state: synced ? 'done' : 'waiting' },
+      { label: '群消息接入', detail: hasMessage ? '已收到回调新消息' : '等待回调新消息', state: hasMessage ? 'done' : 'current' },
+      { label: '范围识别', detail: confirmed ? '已确认客户群' : '等待确认', state: confirmed ? 'done' : 'waiting' },
       { label: 'Agent 分析', detail: failed ? '生成失败' : pending || sent || noReply ? '分析完成' : '等待生成', state: failed ? 'error' : pending || sent || noReply ? 'done' : hasMessage ? 'current' : 'waiting' },
       { label: auto ? '自动放行' : '人工审核', detail: auto ? (sendFailed ? '发送失败待处理' : sent ? '已自动放行' : noReply ? '无需放行' : '等待 Agent 分析') : pending ? '草稿待审核' : sent || noReply ? '已完成' : '等待草稿', state: sendFailed ? 'error' : auto ? (sent || noReply ? 'done' : 'waiting') : pending ? 'current' : sent || noReply ? 'done' : 'waiting' },
       { label: auto ? '自动发送' : '群内发送', detail: sent ? '回复已发送' : noReply ? '无需回复' : sendFailed ? '发送失败' : '尚未发送', state: sent || noReply ? 'done' : sendFailed ? 'error' : 'waiting' },
@@ -3170,16 +3161,12 @@
     const agent = status.agent || {};
     const knowledge = status.knowledge || {};
     const conversations = state.agent.conversations || [];
-    const groupAgents = state.agent.groupAgents || [];
     const privateConversations = conversations.filter(item => item.channelType !== 'group');
-    const groupConversations = agentGroupMonitorItems(conversations, state.agent.responseMonitor || {}, groupAgents);
-    const listMode = state.agent.listMode === 'group' ? 'group' : 'private';
-    const selected = listMode === 'private'
-      ? privateConversations.find(item => item.id === state.agent.selectedId) || privateConversations[0] || null
-      : null;
-    const selectedGroup = listMode === 'group'
-      ? groupConversations.find(item => item.id === state.agent.selectedGroupId) || groupConversations[0] || null
-      : null;
+    const groupAgents = [];
+    const groupConversations = [];
+    const listMode = 'private';
+    const selected = privateConversations.find(item => item.id === state.agent.selectedId) || privateConversations[0] || null;
+    const selectedGroup = null;
     if (selected) state.agent.selectedId = selected.id;
     if (selectedGroup) state.agent.selectedGroupId = selectedGroup.id;
     const sessionGuide = selected?.claudeSession || null;
@@ -3239,7 +3226,7 @@
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
           <button class="agent-mode-btn ${globalPaused ? 'active danger' : ''}" data-agent-mode="paused">全局暂停</button>
         </div>
-        <div class="agent-guard"><span>发送边界</span>私聊限 ${allowlistCount} 位白名单联系人 · 客户群按各群独立模式处理${groupAgents.some(item => item.mode === 'auto') ? ` · ${groupAgents.filter(item => item.mode === 'auto').length} 个群已开启全自动` : ''}</div>
+        <div class="agent-guard"><span>发送边界</span>客服工作台仅处理 ${allowlistCount} 位白名单客户私聊 · 群聊只采集消息并沉淀画像</div>
       </section>
 
       ${renderResponseMonitor(state.agent.responseMonitor || {})}
@@ -3252,10 +3239,9 @@
 
       <section class="agent-workspace">
         <aside class="agent-conversation-list">
-          <div class="agent-panel-heading"><div><strong>消息入口</strong><small>私聊与群聊智能回复已分开</small></div><span class="agent-count">${privateConversations.length + groupConversations.length}</span></div>
+          <div class="agent-panel-heading"><div><strong>消息入口</strong><small>仅展示白名单客户私聊</small></div><span class="agent-count">${privateConversations.length}</span></div>
           <div class="agent-list-tabs" role="tablist" aria-label="消息类型">
-            <button class="${listMode === 'private' ? 'active' : ''}" role="tab" aria-selected="${listMode === 'private'}" data-agent-list-mode="private"><span>客户私聊</span><b>${privateConversations.length}</b></button>
-            <button class="${listMode === 'group' ? 'active' : ''}" role="tab" aria-selected="${listMode === 'group'}" data-agent-list-mode="group"><span>群聊智能回复</span><b>${groupConversations.length}</b></button>
+            <button class="active" role="tab" aria-selected="true" data-agent-list-mode="private"><span>客户私聊</span><b>${privateConversations.length}</b></button>
           </div>
           <div class="agent-list-scroll">
             ${listMode === 'private' && privateConversations.length ? privateConversations.map(item => {
@@ -3267,7 +3253,7 @@
               </button>`;
             }).join('') : listMode === 'group' && groupConversations.length ? groupConversations.map(item => `<button class="agent-conversation-item group ${item.id === selectedGroup?.id ? 'active' : ''}" data-group-monitor-id="${escapeHtml(item.id)}">
                 <span class="agent-avatar group">群</span>
-                <span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.name || '客户群')}</strong><i class="agent-channel-badge group">群聊</i></span><small>${escapeHtml(item.lastMessagePreview || '等待群消息同步')}</small><em class="${escapeHtml(item.status || '')}">${item.mode === 'auto' ? '全自动' : item.pendingReply ? '草稿待审核' : item.agentError ? 'Agent 生成失败' : escapeHtml(agentMonitorStatusLabel(item.status))}${item.waitingMinutes ? ` · ${formatReplyWaiting(item.waitingMinutes)}` : ''}</em></span>
+                <span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.name || '客户群')}</strong><i class="agent-channel-badge group">群聊</i></span><small>${escapeHtml(item.lastMessagePreview || '等待群回调新消息')}</small><em class="${escapeHtml(item.status || '')}">${item.mode === 'auto' ? '全自动' : item.pendingReply ? '草稿待审核' : item.agentError ? 'Agent 生成失败' : escapeHtml(agentMonitorStatusLabel(item.status))}${item.waitingMinutes ? ` · ${formatReplyWaiting(item.waitingMinutes)}` : ''}</em></span>
                 <time>${formatAgentTime(item.lastMessageAt)}</time>
               </button>`).join('') : `<div class="agent-empty"><strong>${listMode === 'group' ? '还没有已确认客户群' : allowlistReady ? '等待第一条真实消息' : '还没有选择白名单'}</strong><span>${listMode === 'group' ? '先在客户群管理中确认需要智能回复的客户群。' : allowlistReady ? (product.mode === 'enterprise' ? '公网回调会持续接收消息,请从已选企微客户发送测试内容。' : '启动监听后,请从已选企微客户发送测试内容。') : '点击上方“管理白名单”,直接从企微联系人中选择。'}</span></div>`}
           </div>
@@ -3408,16 +3394,20 @@
     if (state.agent.loading) return;
     state.agent.loading = true;
     try {
-      const [statusRes, conversationsRes, responseMonitorRes, groupAgentsRes] = await Promise.all([
+      const [statusRes, conversationsRes, responseMonitorRes] = await Promise.all([
         api('GET', '/api/agent/status'),
         api('GET', '/api/agent/conversations'),
         api('GET', '/api/agent/response-monitor'),
-        api('GET', '/api/agent/groups'),
       ]);
       const nextStatus = statusRes.data || {};
       const nextConversations = conversationsRes.data?.conversations || [];
-      const nextResponseMonitor = responseMonitorRes.data || {};
-      const nextGroupAgents = groupAgentsRes.data?.groups || [];
+      const rawResponseMonitor = responseMonitorRes.data || {};
+      const nextResponseMonitor = {
+        ...rawResponseMonitor,
+        scope: { ...(rawResponseMonitor.scope || {}), groups: [] },
+        reminders: (rawResponseMonitor.reminders || []).filter(item => item.kind !== 'group'),
+      };
+      const nextGroupAgents = [];
       const localAccount = currentAccount();
       const activeAccount = nextStatus.account || {};
       if (localAccount && localAccount.uid && activeAccount.uid === localAccount.uid) {
@@ -4683,7 +4673,7 @@
         <div>
           <span>WECOM CUSTOMER GROUP OPERATIONS</span>
           <h2>客户群管理</h2>
-          <p>识别真实客户群、同步历史消息,并集中完成人工确认与群运营准备。</p>
+          <p>识别并确认真实客户群,确认后自动接收公网回调中的群新消息。</p>
         </div>
         ${renderGroupStatsCard(null)}
       </section>
@@ -4692,7 +4682,7 @@
         <main class="group-list-panel">
           <div class="operation-note">
             <svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z"/></svg>
-            <div><strong>先识别,再同步</strong><span>勾选目标群后可批量确认并保存历史消息;未确认的普通群不会进入客户运营流程。</span></div>
+            <div><strong>先识别,再确认</strong><span>确认后的客户群会自动接收回调新消息;未确认的普通群不会进入客户运营流程。</span></div>
           </div>
           ${renderGroupFilters()}
           <div id="groups-table-card"></div>
@@ -4728,7 +4718,6 @@
               <option value="all">全部群</option>
               <option value="self">我创建的群</option>
               <option value="session">最近聊天里的群</option>
-              <option value="messages">从消息记录里找群</option>
             </select>
           </div>
           <div class="form-group">
@@ -4777,7 +4766,7 @@
     return `
       <div class="card group-filter-card">
         <div class="card-header">
-          <div><h3 class="card-title">群列表</h3><div class="card-subtitle">识别结果与消息同步状态</div></div>
+          <div><h3 class="card-title">群列表</h3><div class="card-subtitle">识别结果与回调接入范围</div></div>
           <div class="filter-bar">
             <input id="filter-keyword" type="text" placeholder="搜索群名" value="${escapeHtml(state.groupFilters.keyword)}" />
             <select id="filter-status">
@@ -4834,9 +4823,6 @@
           <button class="btn btn-sm" id="btn-batch-confirm" ${state.selectedGroupIds.size ? '' : 'disabled'}>
             确认选中为客户群
           </button>
-          <button class="btn btn-sm" id="btn-batch-sync-msg" ${state.selectedGroupIds.size ? '' : 'disabled'}>
-            同步选中群消息
-          </button>
         </div>
         <div class="table-wrap">
           <table>
@@ -4847,14 +4833,12 @@
                 <th style="width:70px">人数</th>
                 <th style="width:100px">识别结果</th>
                 <th style="width:100px">置信度</th>
-                <th style="width:100px">消息同步</th>
                 <th>命中关键词</th>
                 <th style="width:120px">操作</th>
               </tr>
             </thead>
             <tbody>
               ${filtered.map((g, i) => {
-                const synced = isGroupSynced(g.roomId);
                 return `
                 <tr style="animation:fadeInUp 0.3s ease ${i * 0.03}s both">
                   <td>
@@ -4872,14 +4856,12 @@
                       <span style="font-size:12px;color:var(--text-muted)">${Math.round((g.confidence || 0) * 100)}%</span>
                     </div>
                   </td>
-                  <td>${synced ? `<span class="badge badge-success">已同步</span>` : `<span class="badge badge-default">未同步</span>`}</td>
                   <td>${(g.matchedKeywords || []).map(k => `<span class="tag" style="margin-bottom:4px">${escapeHtml(k)}</span>`).join(' ') || '-'}</td>
                   <td>
                     <div style="display:flex;gap:6px;flex-wrap:wrap">
                       ${!['CONFIRMED', 'AUTO_CONFIRMED'].includes(g.reviewStatus) ? `<button class="btn btn-sm" data-action="confirm" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">确认</button>` : ''}
                       ${g.reviewStatus === 'CONFIRMED' ? `<button class="btn btn-sm" data-action="detail" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">群详情</button>` : ''}
                       ${g.reviewStatus !== 'REJECTED' ? `<button class="btn btn-sm btn-secondary" data-action="reject" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">忽略</button>` : ''}
-                      <button class="btn btn-sm btn-secondary" data-action="sync-msg" data-room="${escapeHtml(g.roomId)}">${synced ? '重新同步' : '同步消息'}</button>
                     </div>
                   </td>
                 </tr>`;
@@ -4923,22 +4905,12 @@
       });
     }
 
-    const batchSyncBtn = card.querySelector('#btn-batch-sync-msg');
-    if (batchSyncBtn) {
-      batchSyncBtn.addEventListener('click', () => {
-        const roomIds = Array.from(state.selectedGroupIds);
-        if (!roomIds.length) return;
-        syncGroupMessages(roomIds, batchSyncBtn);
-      });
-    }
-
     card.querySelectorAll('[data-action]').forEach(btn => {
       btn.addEventListener('click', () => {
         const roomId = btn.dataset.room;
         const roomName = btn.dataset.name;
         if (btn.dataset.action === 'confirm') confirmGroup(roomId, roomName);
         if (btn.dataset.action === 'reject') openRejectModal(roomId, roomName);
-        if (btn.dataset.action === 'sync-msg') syncGroupMessages([roomId], btn);
         if (btn.dataset.action === 'detail') openGroupDetailModal(roomId, roomName);
       });
     });
@@ -4984,12 +4956,8 @@
 
   async function loadGroups() {
     try {
-      const [groupsRes, syncRes] = await Promise.all([
-        api('GET', '/api/groups/list'),
-        api('GET', '/api/dashboard/groups')
-      ]);
+      const groupsRes = await api('GET', '/api/groups/list');
       state.groups = groupsRes.data?.groups || [];
-      state.groupSync = syncRes.data || {};
       updateGroupStats(groupsRes.summary);
       renderGroupTable(state.groups);
     } catch (err) {
@@ -5031,7 +4999,6 @@
       customerKeywords: ['客户群', '服务群', '售后群', '咨询群', 'VIP群', '专属群', '业主群', '项目群', '订单群', '用户群'],
       highConfidenceTerms: ['客户群', '服务群'],
       autoClassify: true,
-      scanFromMessages: false,
     }, result => {
       if (tracker) tracker.innerHTML = '';
       updateGroupStats(result.summary);
@@ -5062,8 +5029,7 @@
         maxPages: Number(document.getElementById('sync-max-pages').value) || 20,
         customerKeywords: keywords,
         highConfidenceTerms: highConfidence,
-        autoClassify: document.getElementById('sync-auto-classify').checked,
-        scanFromMessages: document.getElementById('sync-scope').value === 'messages'
+        autoClassify: document.getElementById('sync-auto-classify').checked
       };
       const tracker = document.getElementById('sync-job-tracker');
       const id = Date.now().toString(36);
@@ -5192,29 +5158,6 @@
     });
   }
 
-  async function syncGroupMessages(roomIds, triggerButton = null) {
-    const guid = getGuid();
-    if (!guid) {
-      toast('请先登录企业微信账号', 'warning');
-      return;
-    }
-    const acc = currentAccount();
-    if (!acc || !acc.online) {
-      toast('账号当前离线,请先恢复登录后再同步消息', 'warning');
-      location.hash = '#status';
-      return;
-    }
-    const body = { guid, roomIds, maxPages: 20, maxMessagesPerRoom: 500 };
-    const name = 'sync-messages-' + roomIds[0];
-    if (triggerButton) {
-      triggerButton.dataset.job = name;
-    }
-    startJob(name, '/api/groups/messages/sync', body, result => {
-      toast(`共同步 ${result.summary?.totalNew || 0} 条消息`);
-      loadGroups();
-    });
-  }
-
   // ==================== Helpers ====================
   function requireOnlineAccount() {
     const acc = currentAccount();
@@ -6116,7 +6059,8 @@
         </div>
         <div class="operation-toolbar">
           <div class="toolbar-left">
-            <button class="btn" id="btn-pt-batch" data-job="batch-portraits">批量生成画像</button>
+            <button class="btn" id="btn-pt-incremental" data-job="incremental-portraits">批量增量更新</button>
+            <button class="btn btn-secondary" id="btn-pt-full" data-job="full-portraits">批量全量更新</button>
             <button class="btn btn-secondary" id="btn-pt-tag">为客户打标签</button>
             <button class="btn btn-secondary" id="btn-pt-export">导出全部画像</button>
           </div>
@@ -6172,7 +6116,8 @@
     page.querySelector('#pt-filter-source').addEventListener('change', () => loadPortraitsTable(page));
     page.querySelector('#pt-filter-tag').addEventListener('change', () => loadPortraitsTable(page));
 
-    page.querySelector('#btn-pt-batch').addEventListener('click', () => openBatchPortraitSelector(page));
+    page.querySelector('#btn-pt-incremental').addEventListener('click', () => openBatchPortraitSelector(page, 'incremental'));
+    page.querySelector('#btn-pt-full').addEventListener('click', () => openBatchPortraitSelector(page, 'full'));
     page.querySelector('#btn-pt-tag').addEventListener('click', () => openTagModal(page));
     page.querySelector('#btn-pt-export').addEventListener('click', () => {
       const portraits = state.portraits?.allPortraits || {};
@@ -6568,11 +6513,11 @@
     });
   }
 
-  async function openBatchPortraitSelector(page) {
+  async function openBatchPortraitSelector(page, updateMode = 'incremental') {
     const result = await openCustomerSelector({
       multiple: true,
-      title: '选择要生成画像的客户',
-      confirmText: '生成画像'
+      title: updateMode === 'full' ? '选择要全量更新画像的客户' : '选择要增量更新画像的客户',
+      confirmText: updateMode === 'full' ? '全量更新' : '增量更新'
     });
 
     const ids = result.externalUserIds || [];
@@ -6581,7 +6526,8 @@
       return;
     }
 
-    startJob('batch-portraits', '/api/portraits/batch', { externalUserIds: ids, aiMode: 'ai', guid: getGuid() }, () => loadPortraitsDashboard(page), 'pt-job-tracker');
+    const jobName = updateMode === 'full' ? 'full-portraits' : 'incremental-portraits';
+    startJob(jobName, '/api/portraits/batch', { externalUserIds: ids, aiMode: 'ai', updateMode, guid: getGuid() }, () => loadPortraitsDashboard(page), 'pt-job-tracker');
   }
 
   function openTagModal(page) {

+ 1 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/business-diagnosis-service.js

@@ -145,7 +145,7 @@ function diagnoseSnapshot(question, snapshot) {
       actions: [
         action('resolve-group-risks', '处理风险', '查看原消息证据并建立整改任务', '#group-ops', 'urgent'),
         action('review-group-plans', '审核今日计划', '批准话术或退回修改', '#group-ops', 'high'),
-        action('verify-group-scope', '核对客户群', '确认群身份与消息同步状态', '#groups')
+        action('verify-group-scope', '核对客户群', '确认群身份与回调接入范围', '#groups')
       ]
     };
   }

+ 4 - 61
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/response-monitor-service.js

@@ -1,14 +1,3 @@
-const fs = require('fs');
-const path = require('path');
-
-function readJson(filePath, fallback) {
-  try {
-    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
-  } catch {
-    return fallback;
-  }
-}
-
 function timestampOf(message = {}) {
   const value = message.timestamp || message.createdAt || message.created_at || message.time;
   const parsed = Date.parse(value || '');
@@ -90,32 +79,6 @@ function buildState({
   };
 }
 
-function roomMessages(messagesDir, roomId) {
-  if (!fs.existsSync(messagesDir)) return [];
-  const prefix = `room-${roomId}-`;
-  const seen = new Set();
-  const result = [];
-  const files = [
-    ...fs.readdirSync(messagesDir)
-      .filter(name => name.startsWith(prefix) && name.endsWith('.json'))
-      .map(name => path.join(messagesDir, name)),
-    ...[path.join(messagesDir, 'rooms', roomId), path.join(messagesDir, roomId)]
-      .filter(dir => fs.existsSync(dir))
-      .flatMap(dir => fs.readdirSync(dir).filter(name => name.endsWith('.json')).map(name => path.join(dir, name))),
-  ].sort().reverse();
-  for (const filePath of files) {
-    const parsed = readJson(filePath, []);
-    const messages = Array.isArray(parsed) ? parsed : [parsed];
-    for (const message of messages) {
-      const key = String(message.msgId || message.msgServerId || message.seq || `${timestampOf(message)}:${previewOf(message)}`);
-      if (seen.has(key)) continue;
-      seen.add(key);
-      result.push(message);
-    }
-  }
-  return result;
-}
-
 function responseMonitor({
   projectRoot,
   conversations = [],
@@ -144,39 +107,19 @@ function responseMonitor({
     handledMessageId: conversation.handledMessageId,
   }));
 
-  const outputsDir = path.join(projectRoot, 'outputs');
-  const confirmedGroups = readJson(path.join(outputsDir, 'groups', 'confirmed-mapping.json'), {});
-  const latestRooms = readJson(path.join(outputsDir, 'groups', 'rooms-latest.json'), []);
-  const roomById = new Map((Array.isArray(latestRooms) ? latestRooms : []).map(room => [String(room.roomId || ''), room]));
-  const groupStates = Object.entries(confirmedGroups || {}).map(([roomId, mapping]) => {
-    const room = roomById.get(String(roomId)) || {};
-    return buildState({
-      kind: 'group',
-      id: String(roomId),
-      name: String(mapping?.roomName || room.roomName || roomId),
-      messages: roomMessages(path.join(outputsDir, 'messages'), String(roomId)),
-      warningMinutes: warning,
-      urgentMinutes: urgent,
-      now,
-      selfUserIds: normalizedSelfUserIds,
-      selfNames: normalizedSelfNames,
-    });
-  });
-
-  const scope = { customers: customerStates, groups: groupStates };
-  const reminders = [...customerStates, ...groupStates]
+  const scope = { customers: customerStates, groups: [] };
+  const reminders = customerStates
     .filter(item => item.status === 'overdue')
     .sort((left, right) => right.waitingMinutes - left.waitingMinutes);
-  const allStates = [...customerStates, ...groupStates];
 
   return {
     summary: {
       monitoredCustomers: customerStates.length,
-      monitoredGroups: groupStates.length,
+      monitoredGroups: 0,
       overdue: reminders.length,
       urgent: reminders.filter(item => item.severity === 'urgent').length,
       warning: reminders.filter(item => item.severity === 'warning').length,
-      awaitingSync: allStates.filter(item => item.status === 'awaiting_sync').length,
+      awaitingSync: customerStates.filter(item => item.status === 'awaiting_sync').length,
     },
     config: {
       warningMinutes: warning,

+ 1 - 39
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -11,8 +11,7 @@ const {
   qiweiConfirmExternalGroup,
   qiweiAddExternalGroup,
   qiweiConfigureGroupKeywords,
-  qiweiRejectExternalGroup,
-  qiweiSyncGroupMessages
+  qiweiRejectExternalGroup
 } = require('../tools/qiwei-group-management-run');
 const {
   qiweiGroupOpsGetContext,
@@ -71,12 +70,6 @@ const {
   updateAllowlist,
   getConversations,
   getResponseMonitor,
-  getGroupAgents,
-  changeGroupMode,
-  generateGroupReply,
-  approveGroupDraft,
-  rejectGroupDraft,
-  regenerateGroupDraft,
   syncConversations,
   changeGlobalMode,
   changeConversationMode,
@@ -653,30 +646,6 @@ async function handleRequest(req, res) {
       json(res, 200, getResponseMonitor());
       return;
     }
-    if (pathname === '/api/agent/groups' && req.method === 'GET') {
-      json(res, 200, getGroupAgents());
-      return;
-    }
-    const groupAgentModeRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/mode$/);
-    if (groupAgentModeRoute && req.method === 'POST') {
-      const body = await readBody(req);
-      json(res, 200, changeGroupMode(decodeURIComponent(groupAgentModeRoute[1]), body.mode, body.confirmation));
-      return;
-    }
-    const groupAgentGenerateRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/generate$/);
-    if (groupAgentGenerateRoute && req.method === 'POST') {
-      json(res, 200, await generateGroupReply(decodeURIComponent(groupAgentGenerateRoute[1]), await readBody(req)));
-      return;
-    }
-    const groupAgentDraftRoute = pathname.match(/^\/api\/agent\/groups\/([^/]+)\/drafts\/([^/]+)\/(approve|reject|regenerate)$/);
-    if (groupAgentDraftRoute && req.method === 'POST') {
-      const [, roomId, draftId, action] = groupAgentDraftRoute;
-      const body = await readBody(req);
-      if (action === 'approve') json(res, 200, await approveGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.content));
-      else if (action === 'reject') json(res, 200, rejectGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId), body.reason));
-      else json(res, 200, await regenerateGroupDraft(decodeURIComponent(roomId), decodeURIComponent(draftId)));
-      return;
-    }
     if (pathname === '/api/agent/conversations/sync' && req.method === 'POST') {
       json(res, 200, await syncConversations());
       return;
@@ -888,13 +857,6 @@ async function handleRequest(req, res) {
       json(res, 200, await qiweiConfigureGroupKeywords(body));
       return;
     }
-    if (pathname === '/api/groups/messages/sync' && req.method === 'POST') {
-      const body = await readBody(req);
-      const id = createJob(() => qiweiSyncGroupMessages(body));
-      json(res, 200, { status: 'ok', data: { jobId: id } });
-      return;
-    }
-
     // Group Operations
     if (pathname === '/api/group-ops/overview' && req.method === 'GET') {
       json(res, 200, await qiweiGroupOpsGetContext({

+ 1 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/workspace-library-service.js

@@ -34,7 +34,7 @@ const CAPABILITY_TAGS = {
   'qiwei-customer-ops': ['批量加好友', '建群', '欢迎语'],
   'qiwei-customer-transfer': ['客户交接', '预览', '执行'],
   'qiwei-dashboard': ['本地工作台', '状态管理'],
-  'qiwei-group-management': ['群识别', '群同步', '历史消息'],
+  'qiwei-group-management': ['群识别', '回调接入', '新消息'],
   'qiwei-login': ['扫码登录', '订阅', '席位'],
   'qiwei-official-doc': ['文档知识库', 'Markdown', '官方 CLI'],
   'qiwei-official-meeting': ['会议同步', 'AI 知识沉淀', '官方 CLI'],

+ 9 - 29
claude-code/claude-code-qiwe-assistant/mcp/src/server.js

@@ -35,8 +35,7 @@ const {
   qiweiConfirmExternalGroup,
   qiweiAddExternalGroup,
   qiweiConfigureGroupKeywords,
-  qiweiRejectExternalGroup,
-  qiweiSyncGroupMessages
+  qiweiRejectExternalGroup
 } = require('./tools/qiwei-group-management-run');
 const {
   qiweiGroupOpsGetContext,
@@ -507,11 +506,10 @@ function createServer() {
     'qiwei_sync_external_groups',
     {
       title: '同步外部群列表',
-      description: '从多个来源扫描群列表:当前账号创建的群(self)、最近会话中的群(session)、消息流反推的群(messages)或全部(all)。支持按可配置关键词自动分类客户群,结果写入 outputs/groups/。',
+      description: '从当前账号创建的群(self)、最近会话中的群(session)或两者(all)扫描群目录。支持按可配置关键词自动分类客户群,结果写入 outputs/groups/。',
       inputSchema: {
         guid: z.string().optional(),
-        scope: z.enum(['self', 'session', 'messages', 'all']).optional(),
-        scanFromMessages: z.boolean().optional(),
+        scope: z.enum(['self', 'session', 'all']).optional(),
         includeInternalGroups: z.boolean().optional(),
         customerKeywords: z.array(z.string()).optional(),
         matchMode: z.enum(['any', 'threshold']).optional(),
@@ -521,8 +519,7 @@ function createServer() {
         maxPages: z.number().int().min(1).max(300).optional(),
         uid: z.string().optional(),
         authToken: z.string().optional(),
-        apiBase: z.string().optional(),
-        fromMessages: z.boolean().optional().describe('已废弃,请使用 scope=messages 或 scanFromMessages')
+        apiBase: z.string().optional()
       }
     },
     wrap(qiweiSyncExternalGroups)
@@ -551,7 +548,7 @@ function createServer() {
     'qiwei_analyze_group_members',
     {
       title: '分析群详情',
-      description: '对指定的 roomId 列表调用 /room/batchGetRoomDetail,按群名关键词判断哪些可能是客户群;可拉取最近 N 条消息辅助识别。',
+      description: '对指定的 roomId 列表调用 /room/batchGetRoomDetail,按群名关键词判断哪些可能是客户群。',
       inputSchema: {
         guid: z.string().optional(),
         roomIds: z.array(z.string()),
@@ -560,7 +557,6 @@ function createServer() {
         threshold: z.number().int().min(1).optional(),
         highConfidenceTerms: z.array(z.string()).optional(),
         autoClassify: z.boolean().optional(),
-        previewMessages: z.number().int().min(0).max(100).optional(),
         updateSnapshot: z.boolean().optional(),
         uid: z.string().optional(),
         authToken: z.string().optional(),
@@ -630,24 +626,6 @@ function createServer() {
     wrap(qiweiRejectExternalGroup)
   );
 
-  server.registerTool(
-    'qiwei_sync_group_messages',
-    {
-      title: '同步群消息',
-      description: '对指定 roomId 列表或所有已确认客户群调用 /msg/syncMsg 分页同步历史消息,按 room 写入 outputs/messages/。',
-      inputSchema: {
-        guid: z.string().optional(),
-        roomIds: z.array(z.string()).optional(),
-        maxPages: z.number().int().min(1).max(300).optional(),
-        maxMessagesPerRoom: z.number().int().min(1).max(5000).optional(),
-        uid: z.string().optional(),
-        authToken: z.string().optional(),
-        apiBase: z.string().optional()
-      }
-    },
-    wrap(qiweiSyncGroupMessages)
-  );
-
   server.registerTool('qiwei_group_ops_get_context', {
     title: '读取社群运营上下文',
     description: '读取当前账号的群运营总览,或指定群的主档、已发布 SOP、计划和质检风险。用于制定计划、写话术和生成管理简报前获取事实。',
@@ -786,10 +764,11 @@ function createServer() {
     'qiwei_update_customer_portrait',
     {
       title: '更新客户画像',
-      description: '默认 Agent 驱动:准备上下文并返回给调用方分析。传 aiMode=keyword 时直接基于关键词生成简版画像并保存。',
+      description: '更新客户画像。updateMode=incremental 仅分析新增消息并合并现有画像;updateMode=full 基于全部消息重新计算。传 aiMode=keyword 时使用关键词模式。',
       inputSchema: {
         externalUserId: z.string(),
         aiMode: z.enum(['keyword']).optional(),
+        updateMode: z.enum(['incremental', 'full']).optional(),
         roomIds: z.array(z.string()).optional()
       }
     },
@@ -815,10 +794,11 @@ function createServer() {
     'qiwei_batch_update_customer_portrait',
     {
       title: '批量更新客户画像',
-      description: '对多个 externalUserId 调用 qiwei_update_customer_portrait。',
+      description: '对多个 externalUserId 执行增量或全量画像更新。',
       inputSchema: {
         externalUserIds: z.array(z.string()),
         aiMode: z.enum(['keyword']).optional(),
+        updateMode: z.enum(['incremental', 'full']).optional(),
         roomIds: z.array(z.string()).optional()
       }
     },

+ 9 - 323
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js

@@ -2,7 +2,7 @@ const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
-const { recordGroupSync, recordCustomersFromGroups } = require('../core/dashboard-state');
+const { recordCustomersFromGroups } = require('../core/dashboard-state');
 const { getGroupOperationsStore } = require('../core/group-operations-store');
 const {
   buildContext,
@@ -15,7 +15,6 @@ const {
 const REQUIRED_METHODS = {
   getRoomList: '/room/getRoomList',
   batchGetRoomDetail: '/room/batchGetRoomDetail',
-  syncMsg: '/msg/syncMsg',
   getSessionPage: '/session/getSessionPage'
 };
 
@@ -69,24 +68,6 @@ function ensureGroupsDir() {
   fs.mkdirSync(groupsDir(), { recursive: true });
 }
 
-function ensureMessagesDir() {
-  fs.mkdirSync(messagesDir(), { recursive: true });
-}
-
-function messageSyncCursorPath(accountKey) {
-  const safeAccount = String(accountKey || 'local-default').trim().replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100) || 'local-default';
-  ensureGroupsDir();
-  return path.join(groupsDir(), `message-sync-cursor-${safeAccount}.json`);
-}
-
-function readMessageSyncCursor(accountKey) {
-  return safeReadJson(messageSyncCursorPath(accountKey), { travelSyncKey: 0, updatedAt: null });
-}
-
-function writeMessageSyncCursor(accountKey, cursor) {
-  fs.writeFileSync(messageSyncCursorPath(accountKey), JSON.stringify({ ...cursor, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
-}
-
 function ensureRoomMessagesDir(roomId) {
   return fs.mkdirSync(roomMessagesDir(roomId), { recursive: true });
 }
@@ -97,84 +78,6 @@ function messageFilePath(roomId, seq, msgUniqueId) {
   return path.join(roomMessagesDir(roomId), `${seq}-${safeUniqueId}.json`);
 }
 
-let gbkEncodeMap = null;
-
-function getGbkEncodeMap() {
-  if (gbkEncodeMap) return gbkEncodeMap;
-  gbkEncodeMap = new Map();
-  const decoder = new TextDecoder('gbk');
-  for (let first = 0x81; first <= 0xfe; first++) {
-    for (let second = 0x40; second <= 0xfe; second++) {
-      if (second === 0x7f) continue;
-      const bytes = Buffer.from([first, second]);
-      const char = decoder.decode(bytes);
-      if (!char || char === '\uFFFD' || char.length !== 1) continue;
-      if (!gbkEncodeMap.has(char)) gbkEncodeMap.set(char, bytes);
-    }
-  }
-  return gbkEncodeMap;
-}
-
-function repairUtf8DecodedAsGbk(value) {
-  const text = String(value || '');
-  if (!/[\u4e00-\u9fa5]/.test(text)) return text;
-  const map = getGbkEncodeMap();
-  const chunks = [];
-  for (const char of text) {
-    const code = char.codePointAt(0);
-    if (code <= 0x7f) {
-      chunks.push(Buffer.from([code]));
-      continue;
-    }
-    const bytes = map.get(char);
-    if (!bytes) return text;
-    chunks.push(bytes);
-  }
-  const repaired = Buffer.concat(chunks).toString('utf8');
-  if (repaired.includes('\uFFFD')) return text;
-  return /[\u4e00-\u9fa5]/.test(repaired) ? repaired : text;
-}
-
-function cleanExtractedText(value) {
-  const cleaned = String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
-  return repairUtf8DecodedAsGbk(cleaned);
-}
-
-function extractUtf8TextFromBase64(value) {
-  if (!value) return '';
-  let buffer;
-  try {
-    buffer = Buffer.from(String(value), 'base64');
-  } catch {
-    return '';
-  }
-  const candidates = [];
-  for (let i = 0; i < buffer.length - 1; i++) {
-    const len = buffer[i];
-    if (!len || len > 240 || i + 1 + len > buffer.length) continue;
-    const text = cleanExtractedText(buffer.slice(i + 1, i + 1 + len).toString('utf8'));
-    if (!text || text.includes('\uFFFD')) continue;
-    if (/[\u4e00-\u9fa5]/.test(text)) candidates.push(text);
-  }
-  return candidates.sort((a, b) => b.length - a.length)[0] || '';
-}
-
-function extractMessageContent(msg = {}) {
-  const rawText = extractUtf8TextFromBase64(msg.base64RawData || msg.msgData?.extras?.base64RawData);
-  if (rawText) return rawText;
-  if (typeof msg.content === 'string' && msg.content) return cleanExtractedText(msg.content);
-  if (typeof msg.msgContent === 'string' && msg.msgContent) return cleanExtractedText(msg.msgContent);
-  if (msg.msgData) {
-    if (typeof msg.msgData.content === 'string' && msg.msgData.content) return cleanExtractedText(msg.msgData.content);
-    if (typeof msg.msgData.text === 'string' && msg.msgData.text) return cleanExtractedText(msg.msgData.text);
-    if (Array.isArray(msg.msgData.moreDetail)) {
-      return cleanExtractedText(msg.msgData.moreDetail.map(item => item && item.text).filter(Boolean).join(''));
-    }
-    if (typeof msg.msgData.notifyTitle === 'string' && msg.msgData.notifyTitle) return cleanExtractedText(msg.msgData.notifyTitle);
-  }
-  return '';
-}
-
 function appendWebhookMessage(roomId, message) {
   if (!roomId || !message) return null;
   const seq = Number(message.seq) || 0;
@@ -516,45 +419,12 @@ async function enrichRoomsWithDetails(ctx, roomIds, chunkSize = 50) {
     });
     const roomList = Array.isArray(data && data.roomList) ? data.roomList : [];
     for (const room of roomList) {
-      rooms.push(createRoomRecord(room, 'messages'));
+      rooms.push(createRoomRecord(room, 'roomDetail'));
     }
   }
   return rooms;
 }
 
-async function scanRoomsFromMessages(ctx, maxPages = 300, maxTotalMessages = 20000) {
-  assertMethodsInCatalog({ syncMsg: REQUIRED_METHODS.syncMsg });
-  const roomIds = new Set();
-  let msgSeq = 0;
-  let hasMore = true;
-  let pages = 0;
-  let totalMessages = 0;
-
-  while (hasMore && pages < maxPages && totalMessages < maxTotalMessages) {
-    pages++;
-    const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
-      guid: ctx.guid,
-      msgSeq,
-      limit: 500
-    });
-    const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
-    hasMore = Boolean(data.hasMore);
-    msgSeq = data.travelSyncKey ?? msgSeq + 1;
-    totalMessages += msgList.length;
-
-    for (const msg of msgList) {
-      const roomId = String(msg.fromRoomId || '');
-      if (roomId) roomIds.add(roomId);
-    }
-
-    if (!msgList.length) break;
-  }
-
-  const roomIdList = Array.from(roomIds);
-  const details = await enrichRoomsWithDetails(ctx, roomIdList, 50);
-  return { rooms: details, pages, rawRoomIds: roomIdList, totalMessages };
-}
-
 function listGroupFiles() {
   ensureGroupsDir();
   const files = fs.readdirSync(groupsDir())
@@ -648,9 +518,8 @@ function writeRejectedMapping(mapping) {
 }
 
 function resolveScope(input) {
-  const validScopes = ['self', 'session', 'messages', 'all'];
+  const validScopes = ['self', 'session', 'all'];
   if (input.scope && validScopes.includes(input.scope)) return input.scope;
-  if (input.fromMessages === true) return 'messages';
   return 'all';
 }
 
@@ -671,8 +540,7 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
   assertMethodsInCatalog({
     getRoomList: REQUIRED_METHODS.getRoomList,
     batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail,
-    getSessionPage: REQUIRED_METHODS.getSessionPage,
-    syncMsg: REQUIRED_METHODS.syncMsg
+    getSessionPage: REQUIRED_METHODS.getSessionPage
   });
   const ctx = buildContext(input);
   requireGuid(ctx);
@@ -681,13 +549,10 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
   const maxPages = Math.max(1, Math.min(300, Number(input.maxPages || 100)));
   const includeInternalGroups = input.includeInternalGroups === true;
   const autoClassify = input.autoClassify !== false;
-  const scanFromMessages = input.scanFromMessages === true;
-  const previewMessages = Math.max(0, Math.min(100, Number(input.previewMessages || 0)));
-  const previewOnlyForUnsure = input.previewOnlyForUnsure !== false;
   const classifier = buildClassifierConfig(input);
 
   const collected = [];
-  const perSource = { roomList: { rooms: [], pages: 0 }, session: { rooms: [], pages: 0 }, messages: { rooms: [], pages: 0, totalMessages: 0 } };
+  const perSource = { roomList: { rooms: [], pages: 0 }, session: { rooms: [], pages: 0 } };
 
   if (scope === 'self' || scope === 'all') {
     try {
@@ -709,16 +574,6 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
     }
   }
 
-  if (scope === 'messages' || (scope === 'all' && scanFromMessages)) {
-    try {
-      const result = await scanRoomsFromMessages(ctx, maxPages);
-      perSource.messages = result;
-      collected.push(...result.rooms);
-    } catch (err) {
-      // 继续其他来源
-    }
-  }
-
   const deduped = dedupRooms(collected);
   const roomsNeedingDetails = deduped
     .filter(r => Number(r.memberCount || 0) > 0 && (!Array.isArray(r.members) || !r.members.length))
@@ -761,21 +616,7 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
       continue;
     }
 
-    let text = room.roomName || '';
-    let classification = classifyGroupByName(text, classifier);
-
-    if (previewMessages > 0 && previewOnlyForUnsure && classification.reviewStatus !== 'AUTO_CONFIRMED') {
-      try {
-        const previews = await fetchPreviewMessages(ctx, room.roomId, previewMessages);
-        if (previews.length) {
-          text += ' ' + previews.join(' ');
-          classification = classifyGroupByName(text, classifier);
-        }
-      } catch {
-        // 忽略消息拉取失败,保留初次分类结果
-      }
-    }
-
+    const classification = classifyGroupByName(room.roomName || '', classifier);
     classifiedRooms.push({ ...room, ...classification });
   }
 
@@ -788,8 +629,6 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
     maxPages,
     includeInternalGroups,
     autoClassify,
-    previewMessages,
-    previewOnlyForUnsure,
     matchMode: classifier.matchMode,
     threshold: classifier.threshold,
     total: classifiedRooms.length,
@@ -823,16 +662,13 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
       scanned: classifiedRooms.length,
       selfCount: perSource.roomList.rooms.length,
       sessionCount: perSource.session.rooms.length,
-      messageCount: perSource.messages.rooms.length,
       mergedCount: deduped.length,
       externalCount: filtered.length,
       autoConfirmed: classifiedRooms.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
       suggested: classifiedRooms.filter(r => r.reviewStatus === 'SUGGESTED').length,
       imported: classifiedRooms.filter(r => r.reviewStatus === 'IMPORTED').length,
       discoveredCustomers: discoveredCustomers.discovered,
-      previewMessages,
-      previewOnlyForUnsure,
-      pages: perSource.roomList.pages + perSource.session.pages + perSource.messages.pages
+      pages: perSource.roomList.pages + perSource.session.pages
     },
     data: {
       rooms: classifiedRooms.slice(0, 20),
@@ -926,37 +762,6 @@ const qiweiListExternalGroups = safeResult(async function qiweiListExternalGroup
   });
 });
 
-async function fetchPreviewMessages(ctx, roomId, previewMessages) {
-  const texts = [];
-  let msgSeq = 0;
-  let hasMore = true;
-  let pages = 0;
-  const maxPages = Math.min(10, Math.ceil(previewMessages / 100));
-
-  while (hasMore && pages < maxPages && texts.length < previewMessages) {
-    pages++;
-    const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
-      guid: ctx.guid,
-      msgSeq,
-      limit: 100
-    });
-    const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
-    hasMore = Boolean(data.hasMore);
-    msgSeq = data.travelSyncKey ?? msgSeq + 1;
-
-    for (const msg of msgList) {
-      if (String(msg.fromRoomId || '') !== roomId) continue;
-      const content = String(msg.content || msg.msgContent || '').trim();
-      if (content) texts.push(content);
-      if (texts.length >= previewMessages) break;
-    }
-
-    if (!msgList.length) break;
-  }
-
-  return texts;
-}
-
 const qiweiAnalyzeGroupMembers = safeResult(async function qiweiAnalyzeGroupMembers(input = {}) {
   assertMethodsInCatalog({ batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail });
   const ctx = buildContext(input);
@@ -967,29 +772,18 @@ const qiweiAnalyzeGroupMembers = safeResult(async function qiweiAnalyzeGroupMemb
 
   const classifier = buildClassifierConfig(input);
   const autoClassify = input.autoClassify !== false;
-  const previewMessages = Math.max(0, Math.min(100, Number(input.previewMessages || 0)));
   const updateSnapshot = input.updateSnapshot === true;
 
   const details = await enrichRoomsWithDetails(ctx, roomIds, 20);
 
   const classified = [];
   for (const room of details) {
-    let text = room.roomName || '';
-    if (previewMessages > 0) {
-      try {
-        const previews = await fetchPreviewMessages(ctx, room.roomId, previewMessages);
-        if (previews.length) text += ' ' + previews.join(' ');
-      } catch {
-        // 忽略消息拉取失败
-      }
-    }
-
     if (!autoClassify) {
       classified.push({ ...room, reviewStatus: 'IMPORTED', confidence: 0, reason: '自动分类已关闭', matchedKeywords: [] });
       continue;
     }
 
-    const classification = classifyGroupByName(text, classifier);
+    const classification = classifyGroupByName(room.roomName || '', classifier);
     classified.push({ ...room, ...classification });
   }
 
@@ -1174,114 +968,6 @@ const qiweiRejectExternalGroup = safeResult(async function qiweiRejectExternalGr
   });
 });
 
-const qiweiSyncGroupMessages = safeResult(async function qiweiSyncGroupMessages(input = {}) {
-  assertMethodsInCatalog({ syncMsg: REQUIRED_METHODS.syncMsg });
-  const ctx = buildContext(input);
-  requireGuid(ctx);
-
-  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String).filter(Boolean) : null;
-  const maxPages = Math.max(1, Math.min(300, Number(input.maxPages || 100)));
-  const maxMessagesPerRoom = Math.max(1, Math.min(5000, Number(input.maxMessagesPerRoom || 1000)));
-
-  let targetRoomIds = roomIds;
-  if (!targetRoomIds) {
-    const mapping = readConfirmedMapping();
-    targetRoomIds = Object.keys(mapping);
-    if (!targetRoomIds.length) return errorResult('未指定 roomIds 且没有已确认的客户群映射,请先调用 qiwei_confirm_external_group');
-  }
-
-  const targetSet = new Set(targetRoomIds);
-  const messagesByRoom = new Map(targetRoomIds.map(roomId => [roomId, []]));
-  const cursorBefore = readMessageSyncCursor(ctx.guid);
-  let msgSeq = Number(cursorBefore.travelSyncKey || 0);
-  let nextCursor = msgSeq;
-  let hasMore = true;
-  let pages = 0;
-  let scannedMessages = 0;
-
-  while (hasMore && pages < maxPages) {
-    pages++;
-    const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, { guid: ctx.guid, msgSeq, limit: 500 });
-    const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
-    hasMore = Boolean(data.hasMore);
-    nextCursor = data.travelSyncKey ?? msgSeq;
-    scannedMessages += msgList.length;
-    for (const msg of msgList) {
-      const roomId = String(msg.fromRoomId || '');
-      if (!targetSet.has(roomId)) continue;
-      const roomMessages = messagesByRoom.get(roomId);
-      if (roomMessages.length >= maxMessagesPerRoom) continue;
-      roomMessages.push({
-        msgId: msg.msgUniqueIdentifier || msg.msgServerId || `${roomId}_${msg.seq}`,
-        seq: msg.seq,
-        senderId: msg.senderId || '',
-        senderName: msg.senderName || '',
-        receiverId: msg.receiverId || '',
-        fromRoomId: roomId,
-        msgType: String(msg.msgType),
-        content: extractMessageContent(msg),
-        timestamp: msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : new Date().toISOString(),
-        rawData: msg
-      });
-    }
-    if (!msgList.length || nextCursor === msgSeq) break;
-    msgSeq = nextCursor;
-  }
-
-  if (!hasMore) writeMessageSyncCursor(ctx.guid, { travelSyncKey: nextCursor, pages, scannedMessages });
-
-  const results = [];
-  const mapping = readConfirmedMapping();
-  const operationStore = getGroupOperationsStore();
-  for (const roomId of targetRoomIds) {
-    const messages = messagesByRoom.get(roomId) || [];
-    if (messages.length) {
-      ensureMessagesDir();
-      let writtenCount = 0;
-      for (const message of messages) {
-        if (appendWebhookMessage(roomId, message)) writtenCount++;
-      }
-      try {
-        operationStore.upsertGroup(ctx.guid, { roomId, roomName: mapping[roomId]?.roomName || '' }, 'history-sync');
-        operationStore.ingestMessages(ctx.guid, roomId, messages.map(message => ({
-          ...message,
-          messageId: message.msgId,
-          senderRole: 'unknown',
-          sentAt: message.timestamp
-        })));
-      } catch (error) {
-        console.warn(`[群消息同步] 社群运营事实表写入失败: ${error.message}`);
-      }
-
-      // 更新 confirmed-mapping 的 lastMsgAt / lastSyncSeq
-      if (mapping[roomId]) {
-        const maxSeq = Math.max(...messages.map(m => Number(m.seq) || 0));
-        const lastMsg = messages[messages.length - 1];
-        mapping[roomId].lastMsgAt = lastMsg.timestamp || new Date().toISOString();
-        mapping[roomId].lastSyncSeq = Math.max(mapping[roomId].lastSyncSeq || 0, maxSeq);
-        recordGroupSync(roomId, {
-          messageCount: writtenCount,
-          lastMsgAt: lastMsg.timestamp || new Date().toISOString(),
-          lastSyncSeq: mapping[roomId].lastSyncSeq
-        });
-      }
-
-      results.push({ roomId, newCount: writtenCount, fileCount: messages.length });
-    } else {
-      results.push({ roomId, newCount: 0, fileCount: 0 });
-    }
-  }
-  writeConfirmedMapping(mapping);
-
-  const totalNew = results.reduce((sum, r) => sum + r.newCount, 0);
-  return okResult({
-    assistantMessage: `群消息同步完成:${targetRoomIds.length} 个群,共 ${totalNew} 条新消息。`,
-    summary: { rooms: targetRoomIds.length, totalNew, syncedRooms: results.filter(r => r.newCount > 0).length, pages, scannedMessages, cursorAdvanced: !hasMore && nextCursor !== Number(cursorBefore.travelSyncKey || 0) },
-    data: { results, cursor: { before: cursorBefore.travelSyncKey || 0, after: !hasMore ? nextCursor : cursorBefore.travelSyncKey || 0, complete: !hasMore } },
-    files: results.filter(r => r.filePath).map(r => path.join(outputsRoot(), r.filePath))
-  });
-});
-
 module.exports = {
   qiweiSyncExternalGroups,
   qiweiListExternalGroups,
@@ -1290,8 +976,8 @@ module.exports = {
   qiweiAddExternalGroup,
   qiweiConfigureGroupKeywords,
   qiweiRejectExternalGroup,
-  qiweiSyncGroupMessages,
   appendWebhookMessage,
+  messageExists,
   readRoomMessages,
   readConfirmedMapping,
   writeConfirmedMapping,

+ 118 - 31
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js

@@ -71,18 +71,15 @@ function writePortraitQueue(queue) {
 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;
-    }
+    existing.reason = reason;
+    existing.updateMode = 'incremental';
+    existing.enqueuedAt = new Date().toISOString();
+    writePortraitQueue(queue);
+    return false;
   }
-  queue.push({ externalUserId, reason, enqueuedAt: new Date().toISOString() });
+  queue.push({ externalUserId, reason, updateMode: 'incremental', enqueuedAt: new Date().toISOString() });
   writePortraitQueue(queue);
   return true;
 }
@@ -91,21 +88,26 @@ 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 });
+      const result = await qiweiUpdateCustomerPortrait({
+        externalUserId: item.externalUserId,
+        updateMode: item.updateMode || 'incremental',
+      });
+      if (result.status !== 'ok') throw new Error(result.assistantMessage || '画像更新失败');
       processed++;
+      const latestQueue = readPortraitQueue();
+      writePortraitQueue(latestQueue.filter(current =>
+        current.externalUserId !== item.externalUserId || current.enqueuedAt !== item.enqueuedAt
+      ));
     } catch (err) {
       console.warn(`[PortraitQueue] 处理 ${item.externalUserId} 失败:`, err.message);
-      remaining.push(item);
     }
   }
 
-  writePortraitQueue(remaining);
-  return { processed, remaining: remaining.length };
+  return { processed, remaining: readPortraitQueue().length };
 }
 
 function portraitFilePath(externalUserId) {
@@ -289,10 +291,65 @@ function simpleKeywordPortrait(messages) {
   return portrait;
 }
 
-function buildPortraitContext(externalUserId, messages) {
+function messageIdentity(message = {}) {
+  return String(message.msgId || message.msgUniqueIdentifier || message.rawData?.msgUniqueIdentifier || `${message.roomId || ''}:${message.seq || 0}:${message.timestamp || ''}`);
+}
+
+function buildEvidenceState(messages) {
+  const rooms = {};
+  const unsequencedMessageIds = [];
+  for (const message of messages) {
+    const roomId = String(message.roomId || message.fromRoomId || 'unknown');
+    const seq = Number(message.seq) || 0;
+    const timestamp = String(message.timestamp || '');
+    const current = rooms[roomId] || { lastSeq: 0, lastTimestamp: '' };
+    if (seq > current.lastSeq) current.lastSeq = seq;
+    if (timestamp > current.lastTimestamp) current.lastTimestamp = timestamp;
+    rooms[roomId] = current;
+    if (!seq) unsequencedMessageIds.push(messageIdentity(message));
+  }
+  return { rooms, unsequencedMessageIds: unsequencedMessageIds.slice(-500) };
+}
+
+function incrementalMessages(messages, existingPortrait) {
+  if (!existingPortrait?.evidenceState) return messages;
+  const processedUnsequenced = new Set(existingPortrait.evidenceState.unsequencedMessageIds || []);
+  return messages.filter(message => {
+    const roomId = String(message.roomId || message.fromRoomId || 'unknown');
+    const cursor = existingPortrait.evidenceState.rooms?.[roomId] || {};
+    const seq = Number(message.seq) || 0;
+    if (seq) return seq > (Number(cursor.lastSeq) || 0);
+    return !processedUnsequenced.has(messageIdentity(message));
+  });
+}
+
+function mergeEvidenceState(existingState, messages) {
+  const next = buildEvidenceState(messages);
+  const rooms = { ...(existingState?.rooms || {}) };
+  for (const [roomId, cursor] of Object.entries(next.rooms)) {
+    const previous = rooms[roomId] || {};
+    rooms[roomId] = {
+      lastSeq: Math.max(Number(previous.lastSeq) || 0, Number(cursor.lastSeq) || 0),
+      lastTimestamp: String(previous.lastTimestamp || '') > String(cursor.lastTimestamp || '')
+        ? previous.lastTimestamp
+        : cursor.lastTimestamp,
+    };
+  }
+  return {
+    rooms,
+    unsequencedMessageIds: [...new Set([
+      ...(existingState?.unsequencedMessageIds || []),
+      ...next.unsequencedMessageIds,
+    ])].slice(-500),
+  };
+}
+
+function buildPortraitContext(externalUserId, messages, options = {}) {
   const text = messages.map(m => m.content || '').join('\n');
   return {
     externalUserId,
+    updateMode: options.updateMode || 'full',
+    existingPortrait: options.existingPortrait?.portrait || null,
     messageCount: messages.length,
     sampleMessages: messages.slice(0, 50),
     fullTextPreview: text.slice(0, 8000),
@@ -333,22 +390,34 @@ const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustome
   if (!externalUserId) return errorResult('缺少 externalUserId');
 
   const aiMode = String(input.aiMode || '').trim();
+  const updateMode = input.updateMode === 'incremental' ? 'incremental' : 'full';
   const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : inferRoomIdsForExternalUserId(externalUserId);
-  const messages = collectMessagesForExternalUserId(externalUserId, roomIds);
+  const allMessages = collectMessagesForExternalUserId(externalUserId, roomIds);
+  const existing = readPortrait(externalUserId);
+  const messages = updateMode === 'incremental' ? incrementalMessages(allMessages, existing) : allMessages;
   const textMessageCount = messages.filter(m => textOfMessage(m)).length;
 
+  if (updateMode === 'incremental' && !messages.length) {
+    return okResult({
+      assistantMessage: `客户 ${externalUserId} 没有新增画像证据,无需更新。`,
+      summary: { externalUserId, updateMode, messageCount: allMessages.length, incrementalMessageCount: 0, skipped: true },
+      data: existing || null,
+    });
+  }
+
   if (!textMessageCount) {
-    deletePortrait(externalUserId);
-    return errorResult(`客户 ${externalUserId} 没有可用于生成画像的文本消息,请先同步该客户群的聊天记录。`, {
-      summary: { externalUserId, messageCount: messages.length, textMessageCount: 0, skipped: true }
+    return errorResult(`客户 ${externalUserId} 没有可用于生成画像的文本消息,请等待客户发送新消息。`, {
+      summary: { externalUserId, updateMode, messageCount: messages.length, textMessageCount: 0, skipped: true }
     });
   }
 
   if (aiMode === 'keyword') {
-    const portrait = simpleKeywordPortrait(messages);
+    const inferredPortrait = simpleKeywordPortrait(messages);
+    const portrait = updateMode === 'incremental'
+      ? { ...(existing?.portrait || {}), ...inferredPortrait }
+      : inferredPortrait;
     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)
@@ -358,26 +427,37 @@ const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustome
         data: { sampleMessages }
       });
     }
-    const saved = { externalUserId, portrait, source: 'keyword', messageCount: messages.length, updatedAt: new Date().toISOString() };
+    const saved = {
+      externalUserId,
+      portrait,
+      source: 'keyword',
+      updateMode,
+      messageCount: allMessages.length,
+      incrementalMessageCount: messages.length,
+      evidenceState: updateMode === 'incremental'
+        ? mergeEvidenceState(existing?.evidenceState, messages)
+        : buildEvidenceState(allMessages),
+      updatedAt: new Date().toISOString(),
+    };
     const filePath = writePortrait(externalUserId, saved);
     recordPortrait(externalUserId, { source: 'keyword', messageCount: messages.length, fields });
     return okResult({
       assistantMessage: `关键词模式画像更新完成:${externalUserId}。`,
-      summary: { externalUserId, messageCount: messages.length, textMessageCount, fields },
+      summary: { externalUserId, updateMode, messageCount: allMessages.length, incrementalMessageCount: messages.length, textMessageCount, fields },
       data: saved,
       files: [filePath]
     });
   }
 
   // 默认 LLM Agent 模式:生成上下文 → 调 LLM → 解析 JSON → 保存
-  const context = buildPortraitContext(externalUserId, messages);
+  const context = buildPortraitContext(externalUserId, messages, { updateMode, existingPortrait: existing });
   ensurePortraitsDir();
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const contextFilePath = path.join(runDir, `context-${externalUserId}.json`);
   fs.writeFileSync(contextFilePath, JSON.stringify(context, null, 2), 'utf8');
   writeRunManifest(runDir, {
     tool: 'qiwei_update_customer_portrait',
-    summary: { externalUserId, messageCount: messages.length, aiMode: aiMode || 'llm' },
+    summary: { externalUserId, updateMode, messageCount: messages.length, totalMessageCount: allMessages.length, aiMode: aiMode || 'llm' },
     files: [contextFilePath]
   });
 
@@ -392,7 +472,6 @@ const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustome
 
     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 }
@@ -401,18 +480,23 @@ const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustome
 
     const saved = {
       externalUserId,
-      portrait,
+      portrait: updateMode === 'incremental' ? { ...(existing?.portrait || {}), ...portrait } : portrait,
       source: 'agent',
-      messageCount: messages.length,
+      updateMode,
+      messageCount: allMessages.length,
+      incrementalMessageCount: messages.length,
+      evidenceState: updateMode === 'incremental'
+        ? mergeEvidenceState(existing?.evidenceState, messages)
+        : buildEvidenceState(allMessages),
       updatedAt: new Date().toISOString(),
       llmUsage: usage
     };
     const filePath = writePortrait(externalUserId, saved);
-    recordPortrait(externalUserId, { source: 'agent', messageCount: messages.length, fields });
+    recordPortrait(externalUserId, { source: 'agent', messageCount: allMessages.length, fields });
 
     return okResult({
       assistantMessage: `LLM Agent 画像更新完成:${externalUserId}。`,
-      summary: { externalUserId, messageCount: messages.length, textMessageCount, fields, source: 'agent' },
+      summary: { externalUserId, updateMode, messageCount: allMessages.length, incrementalMessageCount: messages.length, textMessageCount, fields, source: 'agent' },
       data: saved,
       files: [filePath, contextFilePath]
     });
@@ -439,11 +523,14 @@ const qiweiSaveCustomerPortrait = safeResult(async function qiweiSaveCustomerPor
   if (!externalUserId) return errorResult('缺少 externalUserId');
   if (!portrait || typeof portrait !== 'object') return errorResult('缺少 portrait(画像 JSON)');
 
+  const existing = readPortrait(externalUserId);
   const saved = {
     externalUserId,
     portrait,
     source: input.source || 'agent',
-    messageCount: input.messageCount || 0,
+    updateMode: input.updateMode || existing?.updateMode || 'manual',
+    messageCount: input.messageCount ?? existing?.messageCount ?? 0,
+    evidenceState: input.evidenceState || existing?.evidenceState,
     updatedAt: new Date().toISOString()
   };
   const fields = Object.keys(portrait).filter(Boolean);

+ 9 - 1
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/index.mjs

@@ -53,13 +53,16 @@ export async function startRuntime(options = {}) {
   }
 
   let controller;
+  let portraitQueueWorker = null;
   if (loaded.mode === 'enterprise') {
     const { EnterpriseRelayRuntime } = await import('./enterprise-relay-client.mjs');
+    const { PortraitQueueWorker } = await import('./portrait-queue-worker.mjs');
     controller = new EnterpriseRelayRuntime({
         config: loaded.config.enterprise.relay,
         guid: options.guid || '',
         onState: updateComponents,
       });
+    portraitQueueWorker = new PortraitQueueWorker({ onState: updateComponents });
   } else {
     const { PersonalRuntime } = await import('./personal-runtime.mjs');
     controller = new PersonalRuntime({
@@ -69,7 +72,10 @@ export async function startRuntime(options = {}) {
       });
   }
 
-  if (options.dryRun !== true) controller.start();
+  if (options.dryRun !== true) {
+    controller.start();
+    portraitQueueWorker?.start();
+  }
   state = writeRuntimeState({ ...state, status: options.dryRun ? 'ready' : 'running' }, statePath);
 
   let stopping = false;
@@ -80,6 +86,7 @@ export async function startRuntime(options = {}) {
     if (stopWatcher) clearInterval(stopWatcher);
     clearRuntimeStopRequest(statePath);
     state = writeRuntimeState({ ...state, status: 'stopping', stopReason: reason || 'requested' }, statePath);
+    await portraitQueueWorker?.stop();
     await controller.stop();
     if (dashboard?.server) {
       await new Promise(resolve => dashboard.server.close(resolve));
@@ -107,6 +114,7 @@ export async function startRuntime(options = {}) {
     workspaceRoot: loaded.workspaceRoot,
     statePath,
     controller,
+    portraitQueueWorker,
     dashboard,
     stop,
   };

+ 75 - 0
claude-code/claude-code-qiwe-assistant/runtime/callback-service/src/portrait-queue-worker.mjs

@@ -0,0 +1,75 @@
+import path from 'node:path';
+import { createRequire } from 'node:module';
+import { PACKAGE_ROOT } from './config-loader.mjs';
+
+const require = createRequire(import.meta.url);
+const { processPortraitQueue } = require(path.join(
+  PACKAGE_ROOT,
+  'mcp',
+  'src',
+  'tools',
+  'qiwei-portrait-tags-run.js',
+));
+
+export class PortraitQueueWorker {
+  constructor({ intervalMs = 60_000, limit = 5, onState = () => {}, processor = processPortraitQueue } = {}) {
+    this.intervalMs = intervalMs;
+    this.limit = limit;
+    this.onState = onState;
+    this.processor = processor;
+    this.running = false;
+    this.timer = null;
+    this.inFlight = null;
+  }
+
+  start() {
+    if (this.running) return;
+    this.running = true;
+    this.onState({ portraitQueue: { status: 'running', lastError: '' } });
+    void this.runOnce();
+    this.timer = setInterval(() => void this.runOnce(), this.intervalMs);
+    this.timer.unref?.();
+  }
+
+  async runOnce() {
+    if (!this.running) return { skipped: true, reason: 'stopped' };
+    if (this.inFlight) return { skipped: true, reason: 'in-flight' };
+
+    this.inFlight = (async () => {
+      try {
+        const result = await this.processor(this.limit);
+        this.onState({
+          portraitQueue: {
+            status: 'running',
+            lastRunAt: new Date().toISOString(),
+            processed: Number(result?.processed || 0),
+            remaining: Number(result?.remaining || 0),
+            lastError: '',
+          },
+        });
+        return result;
+      } catch (error) {
+        this.onState({
+          portraitQueue: {
+            status: 'error',
+            lastRunAt: new Date().toISOString(),
+            lastError: error.message,
+          },
+        });
+        return { processed: 0, error: error.message };
+      } finally {
+        this.inFlight = null;
+      }
+    })();
+
+    return this.inFlight;
+  }
+
+  async stop() {
+    this.running = false;
+    if (this.timer) clearInterval(this.timer);
+    this.timer = null;
+    if (this.inFlight) await this.inFlight;
+    this.onState({ portraitQueue: { status: 'stopped', stoppedAt: new Date().toISOString() } });
+  }
+}

+ 55 - 1
claude-code/claude-code-qiwe-assistant/scripts/callback-relay-smoke-test.js

@@ -24,6 +24,11 @@ require.cache[agentServicePath] = {
 
 const { processWebhookEvents } = require('../mcp/src/core/webhook-processor');
 const { readProcessedEventIds } = require('../mcp/src/core/webhook-store');
+const { getGroupOperationsStore } = require('../mcp/src/core/group-operations-store');
+const {
+  writeConfirmedMapping,
+  readRoomMessages,
+} = require('../mcp/src/tools/qiwei-group-management-run');
 const { runPollOnce } = require('./start-relay-client');
 
 async function main() {
@@ -45,6 +50,54 @@ async function main() {
   assert.strictEqual(bridged[0].msgData.content, '回调消息测试');
   assert.strictEqual(readProcessedEventIds().has(messageId), true);
 
+  const roomId = 'room-callback-test';
+  const groupMessageId = `callback-group-${Date.now()}`;
+  writeConfirmedMapping({
+    [roomId]: {
+      roomId,
+      roomName: '回调测试客户群',
+      status: 'ACTIVE',
+      reviewStatus: 'CONFIRMED',
+    },
+  });
+  const groupEnvelope = {
+    code: 0,
+    source: 'relay',
+    data: [{
+      guid: 'device-1', cmd: 15000, msgType: 2, msgUniqueIdentifier: groupMessageId,
+      fromRoomId: roomId, senderId: 'customer-2', senderName: '群客户',
+      timestamp: Math.floor(Date.now() / 1000), seq: 2,
+      msgData: { content: '客户群回调新消息' },
+    }],
+  };
+  const groupResult = await processWebhookEvents(groupEnvelope);
+  assert.strictEqual(groupResult.processed, 1);
+  assert.strictEqual(groupResult.errors, 0);
+  assert.strictEqual(readRoomMessages(roomId).length, 1);
+  assert.strictEqual(readRoomMessages(roomId)[0].content, '客户群回调新消息');
+  assert.strictEqual(bridged.length, 1, '群消息只入库和沉淀画像,不得进入客服 Agent');
+
+  const duplicateResult = await processWebhookEvents(groupEnvelope);
+  assert.strictEqual(duplicateResult.ignored, 1);
+  assert.strictEqual(readRoomMessages(roomId).length, 1, '重复群回调不得重复入库');
+  assert.strictEqual(bridged.length, 1, '重复群回调不得进入 Agent');
+
+  const unknownRoomId = 'room-callback-unknown';
+  const unknownResult = await processWebhookEvents({
+    code: 0,
+    source: 'relay',
+    data: [{
+      guid: 'device-1', cmd: 15000, msgType: 2,
+      msgUniqueIdentifier: `callback-unknown-${Date.now()}`,
+      fromRoomId: unknownRoomId, senderId: 'customer-3', senderName: '未知群客户',
+      timestamp: Math.floor(Date.now() / 1000), seq: 3,
+      msgData: { content: '未知群消息' },
+    }],
+  });
+  assert.strictEqual(unknownResult.ignored, 1);
+  assert.strictEqual(readRoomMessages(unknownRoomId).length, 0, '未确认群不得写入消息库');
+  assert.strictEqual(bridged.length, 1, '未确认群不得进入 Agent');
+
   const originalFetch = global.fetch;
   let ackRequests = 0;
   global.fetch = async url => {
@@ -68,11 +121,12 @@ async function main() {
     global.fetch = originalFetch;
   }
 
-  console.log('[ok] callback messages bridge to Agent and failed Relay events remain unacked');
+  console.log('[ok] private callbacks bridge to Agent; group callbacks only archive and deduplicate; unknown groups stay isolated; failed Relay events remain unacked');
 }
 
 main().finally(() => {
   delete require.cache[agentServicePath];
+  try { getGroupOperationsStore().close(); } catch {}
   fs.rmSync(root, { recursive: true, force: true });
 }).catch(error => {
   console.error(error);

+ 23 - 1
claude-code/claude-code-qiwe-assistant/scripts/callback-runtime-smoke-test.mjs

@@ -6,6 +6,7 @@ import path from 'node:path';
 import { pathToFileURL } from 'node:url';
 import { decryptPayload, EnterpriseRelayRuntime } from '../runtime/callback-service/src/enterprise-relay-client.mjs';
 import { loadRuntimeConfig, resolveRuntimeMode } from '../runtime/callback-service/src/config-loader.mjs';
+import { PortraitQueueWorker } from '../runtime/callback-service/src/portrait-queue-worker.mjs';
 import {
   clearRuntimeStopRequest,
   readRuntimeState,
@@ -91,6 +92,27 @@ try {
   await assert.rejects(() => failureRelay.pollOnce(relayContext), /retained 1 failed event/);
   assert.equal(failureAckCalled, false);
 
+  let releasePortraitRun;
+  let portraitRuns = 0;
+  const portraitStates = [];
+  const portraitWorker = new PortraitQueueWorker({
+    intervalMs: 60_000,
+    processor: async limit => {
+      portraitRuns += 1;
+      assert.equal(limit, 5);
+      await new Promise(resolve => { releasePortraitRun = resolve; });
+      return { processed: 2, remaining: 3 };
+    },
+    onState: patch => portraitStates.push(patch.portraitQueue),
+  });
+  portraitWorker.start();
+  assert.equal(portraitRuns, 1);
+  assert.deepEqual(await portraitWorker.runOnce(), { skipped: true, reason: 'in-flight' });
+  releasePortraitRun();
+  await portraitWorker.stop();
+  assert.equal(portraitStates.some(item => item.processed === 2 && item.remaining === 3), true);
+  assert.equal(portraitStates.at(-1).status, 'stopped');
+
   const statePath = path.join(tempRoot, 'runtime-state.json');
   writeRuntimeState({ pid: 123, status: 'running', apiSecret: 'hidden', components: { relay: { token: 'hidden' } } }, statePath);
   const state = readRuntimeState(statePath);
@@ -102,7 +124,7 @@ try {
   clearRuntimeStopRequest(statePath);
   assert.deepEqual(readRuntimeStopRequest(statePath), {});
 
-  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 19 }, null, 2)}\n`);
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 24 }, null, 2)}\n`);
 } finally {
   fs.rmSync(tempRoot, { recursive: true, force: true });
 }

+ 9 - 21
claude-code/claude-code-qiwe-assistant/scripts/response-monitor-smoke-test.js

@@ -55,32 +55,20 @@ const result = responseMonitor({
 });
 
 assert.strictEqual(result.summary.monitoredCustomers, 3);
-assert.strictEqual(result.summary.monitoredGroups, 2);
-assert.strictEqual(result.summary.overdue, 2);
-assert.strictEqual(result.summary.urgent, 1);
+assert.strictEqual(result.summary.monitoredGroups, 0);
+assert.strictEqual(result.summary.overdue, 1);
+assert.strictEqual(result.summary.urgent, 0);
 assert.strictEqual(result.summary.warning, 1);
-assert.strictEqual(result.summary.awaitingSync, 1);
-assert.strictEqual(result.reminders[0].kind, 'group');
-assert.strictEqual(result.reminders[0].severity, 'urgent');
-assert.strictEqual(result.reminders[1].conversationId, 'conversation-1');
+assert.strictEqual(result.summary.awaitingSync, 0);
+assert.strictEqual(result.reminders[0].kind, 'customer');
+assert.strictEqual(result.reminders[0].conversationId, 'conversation-1');
 assert.strictEqual(result.scope.customers[1].status, 'replied');
 assert.strictEqual(result.scope.customers[2].status, 'no_reply_needed');
 assert.strictEqual(result.scope.customers[2].waitingMinutes, 0);
-assert.strictEqual(result.scope.groups[1].status, 'awaiting_sync');
+assert.deepStrictEqual(result.scope.groups, []);
 assert.strictEqual(isStaffMessage({ senderId: 'staff-1' }, ['staff-1'], []), true);
 assert.strictEqual(isStaffMessage({ senderName: '客服小夏' }, [], ['客服小夏']), true);
 
-const roomMessagesDir = path.join(messagesDir, 'rooms', 'room-1');
-fs.mkdirSync(roomMessagesDir, { recursive: true });
-fs.writeFileSync(path.join(roomMessagesDir, '2-group-agent-reply.json'), JSON.stringify({
-  msgId: 'group-agent-reply',
-  senderId: 'staff-1',
-  senderName: '客服小夏',
-  fromRoomId: 'room-1',
-  content: '已经收到,我们马上处理。',
-  timestamp: '2026-07-20T09:00:00.000Z',
-  direction: 'outbound',
-}), 'utf8');
 const repliedResult = responseMonitor({
   projectRoot: root,
   now: Date.parse('2026-07-20T10:00:00.000Z'),
@@ -90,13 +78,13 @@ const repliedResult = responseMonitor({
   selfNames: ['客服小夏'],
   conversations: [],
 });
-assert.strictEqual(repliedResult.scope.groups[0].status, 'replied');
+assert.deepStrictEqual(repliedResult.scope.groups, []);
 assert.strictEqual(repliedResult.summary.overdue, 0);
 
 fs.rmSync(root, { recursive: true, force: true });
 console.log(JSON.stringify({
   status: 'ok',
-  checks: 17,
+  checks: 16,
   monitoredCustomers: result.summary.monitoredCustomers,
   monitoredGroups: result.summary.monitoredGroups,
   overdue: result.summary.overdue,

+ 28 - 27
claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js

@@ -2,7 +2,10 @@
 const assert = require('assert');
 const http = require('http');
 const fs = require('fs');
+const os = require('os');
 const path = require('path');
+const smokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-main-smoke-'));
+process.env.QIWEI_OUTPUTS_DIR = smokeRoot;
 const { outputsRoot } = require('../mcp/src/core/output-paths');
 const { loadCatalog, searchEndpoints, findEndpoint } = require('../mcp/src/core/api-catalog');
 const { searchQiweiApis, getQiweiApiDoc, callQiweiApi } = require('../mcp/src/tools/qiwei-api-catalog-run');
@@ -30,8 +33,7 @@ const {
   qiweiConfirmExternalGroup,
   qiweiAddExternalGroup,
   qiweiConfigureGroupKeywords,
-  qiweiRejectExternalGroup,
-  qiweiSyncGroupMessages
+  qiweiRejectExternalGroup
 } = require('../mcp/src/tools/qiwei-group-management-run');
 const {
   qiweiPrepareCustomerPortrait,
@@ -139,14 +141,6 @@ async function startMockGateway({ subscribed = true } = {}) {
             roomMemberCount: roomId === 'r-1' ? 3 : 2
           }))
         };
-      } else if (body.method === '/msg/syncMsg') {
-        data = {
-          hasMore: false,
-          travelSyncKey: 1,
-          syncMsgList: [
-            { fromRoomId: 'r-1', seq: 1, senderId: 's-1', senderName: '张三', msgType: 1, content: '你好', msgUniqueIdentifier: 'm-1', timestamp: Math.floor(Date.now() / 1000) }
-          ]
-        };
       } else if (body.method === '/label/syncLabelList') {
         data = { currentSeq: 0, hasMore: false, labelCount: 1, labelList: [{ labelId: 'l-1', labelName: '高意向' }] };
       } else if (body.method === '/label/editLabel') {
@@ -544,7 +538,7 @@ async function main() {
     assert.strictEqual(listedWithRejected.status, 'ok');
     assert.strictEqual(listedWithRejected.summary.rejected, 1);
 
-    const analyzed = await qiweiAnalyzeGroupMembers({ ...common, guid: 'guid-1', roomIds: ['r-1', 'r-3'], previewMessages: 5, updateSnapshot: true });
+    const analyzed = await qiweiAnalyzeGroupMembers({ ...common, guid: 'guid-1', roomIds: ['r-1', 'r-3'], updateSnapshot: true });
     assert.strictEqual(analyzed.status, 'ok');
     assert.strictEqual(analyzed.summary.analyzed, 2);
     assert.strictEqual(analyzed.summary.autoConfirmed, 1);
@@ -557,17 +551,8 @@ async function main() {
     const added = await qiweiAddExternalGroup({ roomId: 'r-99', roomName: '手动群', externalUserId: 'wx-u-2' });
     assert.strictEqual(added.status, 'ok');
 
-    fs.rmSync(path.join(outputsRoot(), 'messages', 'rooms', 'r-1'), { recursive: true, force: true });
-    const syncCallsBefore = gateway.requests.filter(request => request.body?.method === '/msg/syncMsg').length;
-    const messages = await qiweiSyncGroupMessages({ ...common, guid: 'guid-1', roomIds: ['r-1', 'r-99'] });
-    assert.strictEqual(messages.status, 'ok');
-    assert.strictEqual(messages.summary.totalNew, 1);
-    assert.strictEqual(messages.summary.rooms, 2);
-    assert.strictEqual(gateway.requests.filter(request => request.body?.method === '/msg/syncMsg').length - syncCallsBefore, 1, '多个群应共用一次账号级消息流拉取');
-    assert.strictEqual(messages.data.cursor.complete, true);
-    assert.strictEqual(messages.data.cursor.after, 1);
-    assertProviderHidden(messages);
-    console.log('[ok] group-management tools sync, list, analyze, configure, reject, confirm and sync messages');
+    assert.strictEqual(gateway.requests.some(request => request.body?.method === '/msg/syncMsg'), false, '群管理流程不应拉取历史消息');
+    console.log('[ok] group-management tools scan, list, analyze, configure, reject and confirm without history sync');
 
     const scanManifestFile = path.join(outputsRoot(), 'groups', 'group-scan-manifest.json');
     assert(fs.existsSync(scanManifestFile), 'group-scan-manifest.json should exist');
@@ -584,9 +569,23 @@ async function main() {
     assert.strictEqual(prepared.status, 'ok');
     assert.strictEqual(prepared.summary.messageCount, 1);
 
-    const updated = await qiweiUpdateCustomerPortrait({ externalUserId: 'wx-u-1', aiMode: 'keyword', roomIds: ['r1'] });
+    const updated = await qiweiUpdateCustomerPortrait({ externalUserId: 'wx-u-1', aiMode: 'keyword', updateMode: 'full', roomIds: ['r1'] });
     assert.strictEqual(updated.status, 'ok');
     assert.strictEqual(updated.summary.fields.length > 0, true);
+    assert.strictEqual(updated.summary.updateMode, 'full');
+
+    fs.writeFileSync(messageFile, JSON.stringify([
+      { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想买房,预算200万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) },
+      { fromRoomId: 'r1', seq: 3, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '还想看看学区房', msgUniqueIdentifier: 'm-3', timestamp: Math.floor(Date.now() / 1000) + 1 }
+    ]));
+    const incremental = await qiweiUpdateCustomerPortrait({ externalUserId: 'wx-u-1', aiMode: 'keyword', updateMode: 'incremental', roomIds: ['r1'] });
+    assert.strictEqual(incremental.status, 'ok');
+    assert.strictEqual(incremental.summary.incrementalMessageCount, 1);
+    assert(incremental.data.portrait.budgetRange, '增量更新应保留既有预算画像');
+    assert(incremental.data.portrait.keyConcerns, '增量更新应合并新增关注点');
+    const noChanges = await qiweiUpdateCustomerPortrait({ externalUserId: 'wx-u-1', aiMode: 'keyword', updateMode: 'incremental', roomIds: ['r1'] });
+    assert.strictEqual(noChanges.status, 'ok');
+    assert.strictEqual(noChanges.summary.skipped, true);
 
     const saved = await qiweiSaveCustomerPortrait({ externalUserId: 'wx-u-1', portrait: { intent: '购房', budgetRange: '200万' } });
     assert.strictEqual(saved.status, 'ok');
@@ -675,7 +674,9 @@ async function main() {
   console.log('\nAll smoke tests passed.');
 }
 
-main().catch(error => {
-  console.error('smoke test failed:', error);
-  process.exit(1);
-});
+main()
+  .finally(() => fs.rmSync(smokeRoot, { recursive: true, force: true }))
+  .catch(error => {
+    console.error('smoke test failed:', error);
+    process.exit(1);
+  });

+ 11 - 28
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-management/SKILL.md

@@ -1,22 +1,22 @@
 ---
 name: qiwei-group-management
-description: 迁移自 Qiwei 项目的群管理能力:同步外部群列表、按关键词分析/识别客户群、确认/手动添加外部群、同步群历史消息。支持自建群与非自建群扫描,所有调用通过 Fmode 网关,状态以文件形式保存在 outputs/groups/ 和 outputs/messages/。
+description: 迁移自 Qiwei 项目的群管理能力:扫描外部群目录、按关键词分析/识别客户群、确认或手动添加外部群,并通过公网 Relay 回调接收已确认群的新消息。所有调用通过 Fmode 网关,状态保存在 outputs/groups/ 和 outputs/messages/。
 ---
 
 # 企微群管理
 
 ## 使用边界
 
-本 skill 负责群的发现、识别、确认和消息同步
+本 skill 负责群的发现、识别、确认和回调接入
 
-- 同步群列表(支持自建群、最近会话群、消息流反推三种来源);
+- 扫描群列表(支持自建群和最近会话群两种来源);
 - 列出已同步群,并按状态/关键词/来源过滤;
 - 分析群详情,按可配置关键词自动识别客户群;
 - 手动确认某个群为客户群,并关联客户;
 - 手动添加同步列表之外的 roomId;
 - 手动拒绝某个群;
 - 配置客户群识别关键词与匹配模式;
-- 同步已确认群的历史消息。
+- 已确认群通过公网 Relay 回调持续接收新消息,不拉取历史消息。
 
 不要在这里重新实现登录、订阅、通用 API 检索流程。遇到未登录、订阅不足、token 缺失时,转用已有工具:
 
@@ -33,9 +33,8 @@ description: 迁移自 Qiwei 项目的群管理能力:同步外部群列表、
 
 - 同步自建群列表使用 `/room/getRoomList`;
 - 扫描最近会话群使用 `/session/getSessionPage`,`sessionType=1` 表示群;
-- 消息流反推群使用 `/msg/syncMsg` + `/room/batchGetRoomDetail`;
 - 分析群详情使用 `/room/batchGetRoomDetail`;
-- 同步消息使用 `/msg/syncMsg`;
+- 群新消息接收使用公网 Relay 回调,消息必须带有效 `fromRoomId` 和 `msgUniqueIdentifier`;
 - 请求信封固定为 `{ "uid": "...", "method": "...", "params": {...} }`;
 - 鉴权固定走 `Authorization: Bearer <Fmode token>`;
 - `guid` 必须来自已登录设备。
@@ -47,7 +46,7 @@ description: 迁移自 Qiwei 项目的群管理能力:同步外部群列表、
 先询问用户:"您要识别的客户群,是您自己创建的吗?"
 
 - **是** → 调用 `qiwei_sync_external_groups`,`scope: "self"`(仅扫描 `/room/getRoomList`)。
-- **否 / 不确定** → 调用 `qiwei_sync_external_groups`,`scope: "all"`;如需深度兜底,可附加 `scanFromMessages: true`
+- **否 / 不确定** → 调用 `qiwei_sync_external_groups`,`scope: "all"`,同时扫描自建群与最近会话群
 
 ### 第 1 步:同步外部群
 
@@ -57,19 +56,13 @@ description: 迁移自 Qiwei 项目的群管理能力:同步外部群列表、
 {
   "guid": "<已登录设备 guid>",
   "scope": "all",
-  "scanFromMessages": false,
   "maxPages": 50,
   "autoClassify": true,
   "matchMode": "threshold",
-  "threshold": 2,
-  "previewMessages": 10,
-  "previewOnlyForUnsure": true
+  "threshold": 2
 }
 ```
 
-- `previewMessages`:对不确定的群拉取最近 N 条消息辅助识别,默认 `0` 表示不拉取;
-- `previewOnlyForUnsure`:仅对初筛不是 `AUTO_CONFIRMED` 的群拉消息,默认 `true`。
-
 结果写入:
 
 - `outputs/groups/2026-07-15/142030-sync-external-groups/rooms-<timestamp>.json`
@@ -78,7 +71,7 @@ description: 迁移自 Qiwei 项目的群管理能力:同步外部群列表、
 
 群记录包含:
 
-- `source` / `sources`:群来自哪个扫描来源(`roomList`、`session`、`messages`)
+- `source` / `sources`:群来自哪个目录来源(`roomList`、`session`;详情补全记录为 `roomDetail`)
 - `roomExtType`:`0`=内部群,`2`=外部群
 - `reviewStatus`:`IMPORTED` / `SUGGESTED` / `AUTO_CONFIRMED`
 - `confidence`:置信度
@@ -105,7 +98,6 @@ description: 迁移自 Qiwei 项目的群管理能力:同步外部群列表、
 {
   "guid": "<已登录设备 guid>",
   "roomIds": ["r-1", "r-2"],
-  "previewMessages": 10,
   "updateSnapshot": true
 }
 ```
@@ -160,17 +152,9 @@ description: 迁移自 Qiwei 项目的群管理能力:同步外部群列表、
 
 配置持久化到 `outputs/groups/customer-keywords.json`,会立即影响后续 `qiwei_sync_external_groups` 和 `qiwei_list_external_groups` 的分类结果。
 
-### 第 5 步:同步群消息
-
-对已确认的客户群同步历史消息:
-
-```json
-{
-  "guid": "<已登录设备 guid>"
-}
-```
+### 第 5 步:接收群回调新消息
 
-不传 `roomIds` 时默认同步所有已确认映射中的群。结果按 room 写入 `outputs/messages/room-<roomId>-<timestamp>.json`
+群确认后无需执行消息同步。公网 Relay 会把带 `fromRoomId` 的新消息交给回调处理器;处理器仅接收已确认或已导入的客户群,按 `msgUniqueIdentifier` 去重,并写入 `outputs/messages/rooms/<roomId>/`。未知群不会进入客户运营和 Agent 流程。
 
 ## 状态说明
 
@@ -190,7 +174,7 @@ description: 迁移自 Qiwei 项目的群管理能力:同步外部群列表、
 - 拒绝映射 → `outputs/groups/rejected-mapping.json`
 - 关键词配置 → `outputs/groups/customer-keywords.json`
 - 扫描摘要 → `outputs/groups/group-scan-manifest.json`
-- 群消息 → `outputs/messages/room-*.json`
+- 群回调新消息 → `outputs/messages/rooms/<roomId>/`
 
 MCP 工具:
 
@@ -201,4 +185,3 @@ MCP 工具:
 - `qiwei_add_external_group`
 - `qiwei_reject_external_group`
 - `qiwei_configure_group_keywords`
-- `qiwei_sync_group_messages`

+ 1 - 1
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/SKILL.md

@@ -74,6 +74,6 @@ description: 统一管理企业微信客户群运营标准、SOP 版本、每日
 ## 相关能力
 
 - 群尚未发现或确认:使用 `qiwei-group-management`。
-- 需要同步群历史消息:先使用 `qiwei_sync_group_messages`
+- 群消息仅来自公网 Relay 回调;确认客户群后等待新消息进入事实库
 - 账号离线:转用 `qiwei-login`。
 - 客户档案和建群:转用 `qiwei-customer-ops`。

+ 1 - 1
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-operations/references/quality-rules.md

@@ -5,7 +5,7 @@
 - 客户消息包含问号或“怎么、如何、多少、什么时候、可以吗、有没有”等明确问句,并在 SLA 内无员工回应:生成 `unanswered_question`。
 - 消息命中投诉、退款、举报、律师、赔偿等配置词:生成 `service_risk`。
 - 模板仍含 `{{variable}}` 或命中保证性承诺:阻止批准或发送。
-- 消息同步中断或证据不完整时标记数据不足,不按 0 分评价。
+- 回调消息采集中断或证据不完整时标记数据不足,不按 0 分评价。
 
 ## 证据要求
 

+ 16 - 2
claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md

@@ -12,6 +12,7 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 - 基于群消息准备客户画像分析上下文;
 - 关键词模式快速生成简版画像;
 - 保存 Agent 分析后的画像;
+- 通过回调消息自动增量更新画像,也可人工执行全量重算;
 - 批量更新/保存/导出画像;
 - 本地客户标签增删查;
 - 企微个人标签同步、创建、更新、删除、应用到客户。
@@ -38,11 +39,24 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 
 ### 更新画像
 
-不传 `aiMode` 或传 `ai`/`agent` 时,默认使用 LLM(`deepseek-v4-pro`)自动分析消息并保存画像:
+不传 `aiMode` 或传 `ai`/`agent` 时,默认使用 LLM(`deepseek-v4-pro`)自动分析消息并保存画像。
+
+回调默认使用增量更新,只分析尚未处理的新消息并与现有画像合并:
+
+```json
+{
+  "externalUserId": "wx-u-1",
+  "updateMode": "incremental",
+  "roomIds": ["r-1"]
+}
+```
+
+需要重新核验全部证据时使用全量更新:
 
 ```json
 {
   "externalUserId": "wx-u-1",
+  "updateMode": "full",
   "roomIds": ["r-1"]
 }
 ```
@@ -76,7 +90,7 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 
 ### 批量更新/保存
 
-- `qiwei_batch_update_customer_portrait`:对多个 externalUserId 执行 `qiwei_update_customer_portrait`
+- `qiwei_batch_update_customer_portrait`:对多个 externalUserId 执行 `incremental` 或 `full` 更新
 - `qiwei_batch_save_customer_portrait`:批量保存画像 items。
 
 ### 导出画像

+ 3 - 3
claude-code/claude-code-qiwe-assistant/skills/qiwei-real-estate-auto-reply/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: qiwei-real-estate-auto-reply
-description: 管理企业微信房产 Agent、客户私聊与客户群消息监听、需求画像、房源匹配、回复草稿和人工接管。适用于小牛看房演示、白名单测试、群聊智能回复、启动或停止 4320 智能会话;企微收发统一通过 Fmode 网关。
+description: 管理企业微信房产 Agent、客户私聊、客户群消息采集、需求画像、房源匹配、回复草稿和人工接管。客户群只采集新消息并沉淀画像,不进入客服回复链路;企微收发统一通过 Fmode 网关。
 ---
 
 # 房产 AI 智能会话
@@ -12,7 +12,7 @@ description: 管理企业微信房产 Agent、客户私聊与客户群消息监
 3. 默认使用技能包内置的脱敏房源案例;客户数据可通过 `QIWEI_AGENT_PROPERTY_DATA_FILE` 覆盖。
 4. 推荐设置 `AGENT_PROVIDER=claude-code`,复用客户项目中的 Claude Code/Fmode 模型配置。
 5. 默认使用 `review`:生成待审核草稿,不自动发送。
-6. 已确认客户群的新消息会生成独立群聊草稿;群聊始终要求人工审核,不参与私聊高置信自动发送
+6. 已确认客户群的新消息只落盘并触发画像增量更新,不生成草稿、不自动或人工发送群回复
 
 ## 启动与接管
 
@@ -24,7 +24,7 @@ npm run dashboard
 
 企业版无需打开 Dashboard 或启动 AI 监听。MCP/Skill 启动后会自动连接公网 Relay,回调消息进入共享 Workbench;全局暂停或切换“人工接管”后 Agent 不再处理,但消息仍持续接收和保存。个人版仍可用 `qiwei_agent_listener` 控制主动监听。
 
-客户消息同步和发送统一通过 Fmode 网关执行 `/msg/syncMsg`、`/msg/sendText`;登录状态使用 Fmode 专用 `/login/status`,不读取、不保存底层企微 Token,也不访问本地直连接口。
+客户新消息通过公网 Relay 回调接收,消息发送通过 Fmode 网关执行 `/msg/sendText`;登录状态使用 Fmode 专用 `/login/status`,不读取、不保存底层企微 Token,也不访问本地直连接口。
 
 ## 客户 Session