云联一号 7 timmar sedan
förälder
incheckning
17938eb33e
32 ändrade filer med 1991 tillägg och 252 borttagningar
  1. 30 6
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterAdminController.java
  2. 39 1
      fs-company/src/main/java/com/fs/company/controller/workflow/LobsterEngineController.java
  3. 18 0
      fs-company/src/main/resources/application-common.yml
  4. 7 0
      fs-service/pom.xml
  5. 4 0
      fs-service/src/main/java/com/fs/company/mapper/LobsterTenantLearningMapper.java
  6. 17 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyKnowledgeBaseServiceImpl.java
  7. 6 0
      fs-service/src/main/java/com/fs/company/service/workflow/SummaryGenerator.java
  8. 277 0
      fs-service/src/main/java/com/fs/company/service/workflow/cache/LobsterContextCacheService.java
  9. 34 34
      fs-service/src/main/java/com/fs/company/service/workflow/capability/LobsterNodeCapabilityRegistry.java
  10. 11 0
      fs-service/src/main/java/com/fs/company/service/workflow/channel/MessageChannelRouter.java
  11. 124 0
      fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/EmailMessageChannel.java
  12. 241 47
      fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/QwMessageChannel.java
  13. 88 0
      fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/SmsMessageChannel.java
  14. 34 2
      fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionEngineImpl.java
  15. 9 0
      fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionSchedulerImpl.java
  16. 22 2
      fs-service/src/main/java/com/fs/company/service/workflow/impl/ComplianceServiceImpl.java
  17. 148 34
      fs-service/src/main/java/com/fs/company/service/workflow/impl/ContextAssemblerImpl.java
  18. 40 14
      fs-service/src/main/java/com/fs/company/service/workflow/impl/DynamicNodeExecutorImpl.java
  19. 98 29
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterEvolutionEngineImpl.java
  20. 30 0
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterIntegrationTestServiceImpl.java
  21. 58 6
      fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterWorkflowExecutorImpl.java
  22. 26 1
      fs-service/src/main/java/com/fs/company/service/workflow/impl/QualityScoringServiceImpl.java
  23. 18 1
      fs-service/src/main/java/com/fs/company/service/workflow/impl/SummaryGeneratorImpl.java
  24. 8 0
      fs-service/src/main/java/com/fs/company/service/workflow/learning/TenantLearningEngine.java
  25. 318 70
      fs-service/src/main/java/com/fs/company/service/workflow/learning/impl/TenantLearningEngineImpl.java
  26. 76 0
      fs-service/src/main/java/com/fs/company/service/workflow/performance/LobsterLatencyPolicy.java
  27. 19 0
      fs-service/src/main/java/com/fs/company/service/workflow/semantic/impl/SemanticAnalyzerImpl.java
  28. 35 1
      fs-service/src/main/java/com/fs/company/service/workflow/vector/impl/VectorPatternMatcherImpl.java
  29. 18 0
      fs-service/src/main/resources/application-common.yml
  30. 29 4
      fs-service/src/main/resources/mapper/lobster/LobsterTenantLearningMapper.xml
  31. 42 0
      fs-service/src/test/java/com/fs/company/service/workflow/capability/LobsterNodeCapabilityRegistryTest.java
  32. 67 0
      fs-service/src/test/java/com/fs/company/service/workflow/impl/DynamicNodeExecutorImplTest.java

+ 30 - 6
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterAdminController.java

@@ -5,6 +5,7 @@ import com.fs.company.service.workflow.evolution.UserNodeOptimizer;
 import com.fs.company.service.workflow.evolution.impl.EvolutionSchedulerImpl;
 import com.fs.company.service.workflow.feedback.FeedbackDrivenEvolution;
 import com.fs.company.service.workflow.learning.TenantLearningEngine;
+import com.fs.company.service.workflow.learning.LearningSummary;
 import com.fs.company.service.workflow.monitor.DashboardService;
 import com.fs.company.service.workflow.pay.PayService;
 import org.slf4j.Logger;
@@ -128,9 +129,18 @@ public class LobsterAdminController {
     /** 触发学习周期 */
     @PostMapping("/learning/trigger/{companyId}")
     public Map<String, Object> triggerLearning(@PathVariable Long companyId) {
-        return tenantLearningEngine != null
-            ? Map.of("summary", tenantLearningEngine.triggerLearningCycle(companyId).getSummary())
-            : Map.of("result", "unavailable");
+        if (tenantLearningEngine == null) {
+            return Map.of("result", "unavailable");
+        }
+        LearningSummary summary = tenantLearningEngine.triggerLearningCycle(companyId);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("summary", summary.getSummary());
+        result.put("eventsAnalyzed", summary.getEventsAnalyzed());
+        result.put("newDiscoveries", summary.getNewDiscoveries());
+        result.put("updatedPatterns", summary.getUpdatedPatterns());
+        result.put("suggestionsGenerated", summary.getSuggestionsGenerated());
+        result.put("learningDurationMs", summary.getLearningDurationMs());
+        return result;
     }
 
     /** 学习结果列表 */
@@ -141,6 +151,14 @@ public class LobsterAdminController {
             : Collections.emptyMap();
     }
 
+    /** 学习指标 */
+    @GetMapping("/learning/metrics/{companyId}")
+    public Map<String, Object> learningMetrics(@PathVariable Long companyId) {
+        return tenantLearningEngine != null
+            ? tenantLearningEngine.getLearningMetrics(companyId)
+            : Collections.emptyMap();
+    }
+
     /** 应用学习结果 */
     @PostMapping("/learning/apply/{companyId}/{resultId}")
     public Map<String, Object> applyLearning(@PathVariable Long companyId, @PathVariable Long resultId) {
@@ -167,10 +185,16 @@ public class LobsterAdminController {
 
     // ════════════ 测试结果 ════════════
 
-    /** 手动触发进化调度 */
+    /** 手动触发进化+学习调度 */
     @PostMapping("/scheduler/trigger")
     public Map<String, Object> triggerScheduler() {
-        evolutionScheduler.recordTask(0L, "manual_trigger", "ok");
-        return Map.of("result", "triggered");
+        Map<String, Object> result = new LinkedHashMap<>();
+        if (evolutionScheduler != null) {
+            evolutionScheduler.scheduledEvolutionTrigger();
+            evolutionScheduler.recordTask(0L, "manual_trigger", "ok");
+            result.put("evolution", "triggered");
+        }
+        result.put("result", "triggered");
+        return result;
     }
 }

+ 39 - 1
fs-company/src/main/java/com/fs/company/controller/workflow/LobsterEngineController.java

@@ -3,6 +3,7 @@ package com.fs.company.controller.workflow;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.company.service.workflow.capability.LobsterNodeCapabilityRegistry;
 import com.fs.company.service.workflow.DynamicNodeExecutor;
+import com.fs.company.service.workflow.LobsterIntegrationTestService;
 import com.fs.company.service.workflow.evolution.EvolutionEngine;
 import com.fs.company.service.workflow.evolution.EvolutionSuggestion;
 import com.fs.company.service.workflow.heartbeat.HeartbeatScheduler;
@@ -44,6 +45,9 @@ public class LobsterEngineController {
     @Autowired(required = false)
     private DynamicNodeExecutor dynamicNodeExecutor;
 
+    @Autowired(required = false)
+    private LobsterIntegrationTestService integrationTestService;
+
     @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
     @GetMapping("/node-capabilities")
     public AjaxResult getNodeCapabilities() {
@@ -113,13 +117,47 @@ public class LobsterEngineController {
     @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
     @GetMapping("/channels")
     public AjaxResult getAvailableChannels() {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getCompany().getCompanyId();
         Map<String, Object> channels = new HashMap<>();
         messageChannelRouter.getAllChannels().forEach((type, channel) -> {
             Map<String, Object> info = new HashMap<>();
             info.put("type", type);
-            info.put("available", true);
+            info.put("available", channel.isAvailable(companyId));
             channels.put(type, info);
         });
         return AjaxResult.success(channels);
     }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @PostMapping("/integration-test/run")
+    public AjaxResult runIntegrationTests() {
+        if (integrationTestService == null) {
+            return AjaxResult.error("集成测试服务不可用");
+        }
+        LobsterIntegrationTestService.TestResult result = integrationTestService.runAllTests();
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("passed", result.isPassed());
+        payload.put("moduleName", result.getModuleName());
+        payload.put("message", result.getMessage());
+        payload.put("executionTime", result.getExecutionTime());
+        payload.put("details", result.getDetails());
+        return AjaxResult.success(payload);
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/integration-test/node-types")
+    public AjaxResult testNodeTypes() {
+        if (integrationTestService == null) return AjaxResult.error("集成测试服务不可用");
+        LobsterIntegrationTestService.TestResult r = integrationTestService.testNodeTypeService();
+        return AjaxResult.success(r);
+    }
+
+    @PreAuthorize("@ss.hasPermi('workflow:lobster:query')")
+    @GetMapping("/integration-test/dynamic-executor")
+    public AjaxResult testDynamicExecutor() {
+        if (integrationTestService == null) return AjaxResult.error("集成测试服务不可用");
+        LobsterIntegrationTestService.TestResult r = integrationTestService.testDynamicNodeExecutor();
+        return AjaxResult.success(r);
+    }
 }

+ 18 - 0
fs-company/src/main/resources/application-common.yml

@@ -153,3 +153,21 @@ hsy:
   role_secret_key: T0RaaFl6UmhZV1V4WXpKbU5EWTBNMkZpT0RNNU9UY3daak0wTjJFd09XUQ==
   role_trn: trn:iam::2114522511:role/hylj
 
+# 龙虾引擎:默认 quality 保质量;Redis 缓存加速上下文组装
+lobster:
+  latency:
+    mode: quality
+  cache:
+    enabled: true
+    profile-ttl-minutes: 5
+    recent-chats-ttl-minutes: 2
+    facts-ttl-minutes: 5
+    state-ttl-minutes: 5
+    habits-ttl-minutes: 5
+    strategies-ttl-minutes: 10
+    knowledge-ttl-minutes: 10
+    compliance-rules-ttl-minutes: 30
+    compliance-prompt-ttl-minutes: 30
+    vector-rows-ttl-minutes: 10
+    embedding-ttl-minutes: 60
+

+ 7 - 0
fs-service/pom.xml

@@ -213,6 +213,13 @@
             <version>5.2.12.RELEASE</version>
         </dependency>
 
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.13.2</version>
+            <scope>test</scope>
+        </dependency>
+
         <dependency>
             <groupId>com.huaweicloud.sdk</groupId>
             <artifactId>huaweicloud-sdk-vod</artifactId>

+ 4 - 0
fs-service/src/main/java/com/fs/company/mapper/LobsterTenantLearningMapper.java

@@ -27,8 +27,12 @@ public interface LobsterTenantLearningMapper {
                       @Param("confidence") double confidence,
                       @Param("source") String source);
     List<Map<String, Object>> selectPatterns(@Param("companyId") Long companyId);
+    Map<String, Object> selectPatternById(@Param("companyId") Long companyId,
+                                          @Param("id") Long id);
     List<Map<String, Object>> selectPatternsByScenario(@Param("companyId") Long companyId,
                                                         @Param("scenario") String scenario);
+    int markPatternApplied(@Param("companyId") Long companyId, @Param("id") Long id);
+    Double selectAvgQualityScore(@Param("companyId") Long companyId);
     int ensurePatternTable();
 
     int insertReplayBuffer(@Param("companyId") Long companyId,

+ 17 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyKnowledgeBaseServiceImpl.java

@@ -12,6 +12,7 @@ import com.fs.company.domain.CompanyKnowledgeBase;
 import com.fs.company.dto.CompanyKnowledgeBaseDto;
 import com.fs.company.mapper.CompanyKnowledgeBaseMapper;
 import com.fs.company.service.ICompanyKnowledgeBaseService;
+import com.fs.company.service.workflow.cache.LobsterContextCacheService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -41,6 +42,15 @@ public class CompanyKnowledgeBaseServiceImpl implements ICompanyKnowledgeBaseSer
     @Autowired
     private CompanyKnowledgeBaseMapper knowledgeBaseMapper;
 
+    @Autowired(required = false)
+    private LobsterContextCacheService contextCache;
+
+    private void invalidateKnowledgeCache(Long companyId) {
+        if (contextCache != null && companyId != null) {
+            contextCache.invalidateKnowledge(companyId);
+            contextCache.invalidateVectorRows(companyId, "knowledge");
+        }
+    }
 
     @Override
     public List<CompanyKnowledgeBase> listKnowledge(Long companyId, String keyword, String industryType, Integer auditStatus,Long baseId) {
@@ -84,6 +94,7 @@ public class CompanyKnowledgeBaseServiceImpl implements ICompanyKnowledgeBaseSer
             }
         }
 
+        invalidateKnowledgeCache(companyId);
         return result > 0 ? AjaxResult.success("新增成功") : AjaxResult.error("新增失败");
     }
 
@@ -129,6 +140,9 @@ public class CompanyKnowledgeBaseServiceImpl implements ICompanyKnowledgeBaseSer
                 e.printStackTrace();
             }
         }
+        if (result > 0) {
+            invalidateKnowledgeCache(companyId);
+        }
         return result > 0 ? AjaxResult.success("修改成功") : AjaxResult.error("修改失败");
     }
 
@@ -151,6 +165,9 @@ public class CompanyKnowledgeBaseServiceImpl implements ICompanyKnowledgeBaseSer
                 e.printStackTrace();
             }
         }
+        if (result > 0) {
+            invalidateKnowledgeCache(companyId);
+        }
         return result > 0 ? AjaxResult.success("删除成功") : AjaxResult.error("删除失败");
     }
 

+ 6 - 0
fs-service/src/main/java/com/fs/company/service/workflow/SummaryGenerator.java

@@ -47,4 +47,10 @@ public interface SummaryGenerator {
      * 全局摘要:合并该用户所有历史会话级别摘要为一个全局画像
      */
     String generateGlobalSummary(Long companyId, String externalUserId);
+
+    /**
+     * 全局摘要(可跳过热路径 LLM 合并)
+     * @param skipLlmMerge true 时仅拼接/截断,不调用 LLM
+     */
+    String generateGlobalSummary(Long companyId, String externalUserId, boolean skipLlmMerge);
 }

+ 277 - 0
fs-service/src/main/java/com/fs/company/service/workflow/cache/LobsterContextCacheService.java

@@ -0,0 +1,277 @@
+package com.fs.company.service.workflow.cache;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.TypeReference;
+import com.fs.company.domain.LobsterComplianceRule;
+import com.fs.company.service.workflow.learning.StrategyRecommendation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+/**
+ * 龙虾引擎上下文 Redis 缓存:画像/对话/知识库/合规/学习策略等热路径数据。
+ * Redis 不可用时自动降级为直查 DB。
+ */
+@Component
+public class LobsterContextCacheService {
+
+    private static final Logger log = LoggerFactory.getLogger(LobsterContextCacheService.class);
+    private static final String PREFIX = "lobster:ctx:";
+
+    @Value("${lobster.cache.enabled:true}")
+    private boolean enabled;
+
+    @Value("${lobster.cache.profile-ttl-minutes:5}")
+    private long profileTtlMinutes;
+
+    @Value("${lobster.cache.recent-chats-ttl-minutes:2}")
+    private long recentChatsTtlMinutes;
+
+    @Value("${lobster.cache.facts-ttl-minutes:5}")
+    private long factsTtlMinutes;
+
+    @Value("${lobster.cache.state-ttl-minutes:5}")
+    private long stateTtlMinutes;
+
+    @Value("${lobster.cache.habits-ttl-minutes:5}")
+    private long habitsTtlMinutes;
+
+    @Value("${lobster.cache.strategies-ttl-minutes:10}")
+    private long strategiesTtlMinutes;
+
+    @Value("${lobster.cache.knowledge-ttl-minutes:10}")
+    private long knowledgeTtlMinutes;
+
+    @Value("${lobster.cache.compliance-rules-ttl-minutes:30}")
+    private long complianceRulesTtlMinutes;
+
+    @Value("${lobster.cache.compliance-prompt-ttl-minutes:30}")
+    private long compliancePromptTtlMinutes;
+
+    @Value("${lobster.cache.vector-rows-ttl-minutes:10}")
+    private long vectorRowsTtlMinutes;
+
+    @Value("${lobster.cache.embedding-ttl-minutes:60}")
+    private long embeddingTtlMinutes;
+
+    @Autowired(required = false)
+    private StringRedisTemplate redisTemplate;
+
+    // ── 读缓存 + 回源 ──
+
+    public Map<String, Object> getUserProfile(Long companyId, String externalUserId,
+                                               Supplier<Map<String, Object>> loader) {
+        String key = PREFIX + "profile:" + companyId + ":" + externalUserId;
+        return getOrLoad(key, new TypeReference<Map<String, Object>>() {}, loader, profileTtlMinutes);
+    }
+
+    public List<Map<String, Object>> getRecentChats(Long companyId, String externalUserId, Long instanceId, int limit,
+                                                     Supplier<List<Map<String, Object>>> loader) {
+        String key = PREFIX + "chats:" + companyId + ":" + externalUserId + ":" + instanceId + ":" + limit;
+        return getOrLoad(key, new TypeReference<List<Map<String, Object>>>() {}, loader, recentChatsTtlMinutes);
+    }
+
+    public List<Map<String, Object>> getHistoricalFacts(Long companyId, String externalUserId,
+                                                         Supplier<List<Map<String, Object>>> loader) {
+        String key = PREFIX + "facts:" + companyId + ":" + externalUserId;
+        return getOrLoad(key, new TypeReference<List<Map<String, Object>>>() {}, loader, factsTtlMinutes);
+    }
+
+    public Map<String, Object> getDialogueState(Long companyId, String externalUserId,
+                                                 Supplier<Map<String, Object>> loader) {
+        String key = PREFIX + "state:" + companyId + ":" + externalUserId;
+        return getOrLoad(key, new TypeReference<Map<String, Object>>() {}, loader, stateTtlMinutes);
+    }
+
+    public Map<String, String> getCustomerHabits(Long companyId, String externalUserId,
+                                                  Supplier<Map<String, String>> loader) {
+        String key = PREFIX + "habits:" + companyId + ":" + externalUserId;
+        return getOrLoad(key, new TypeReference<Map<String, String>>() {}, loader, habitsTtlMinutes);
+    }
+
+    public List<StrategyRecommendation> getLearnedStrategies(Long companyId, String scenario,
+                                                              Supplier<List<StrategyRecommendation>> loader) {
+        String scen = scenario != null ? scenario : "default";
+        String key = PREFIX + "strategies:" + companyId + ":" + scen;
+        return getOrLoad(key, new TypeReference<List<StrategyRecommendation>>() {}, loader, strategiesTtlMinutes);
+    }
+
+    public List<Map<String, Object>> getKnowledgeSearch(Long companyId, String query,
+                                                          Supplier<List<Map<String, Object>>> loader) {
+        String key = PREFIX + "kb:" + companyId + ":" + hashText(query);
+        return getOrLoad(key, new TypeReference<List<Map<String, Object>>>() {}, loader, knowledgeTtlMinutes);
+    }
+
+    public List<LobsterComplianceRule> getComplianceRules(Long companyId,
+                                                           Supplier<List<LobsterComplianceRule>> loader) {
+        String key = PREFIX + "compliance:rules:" + companyId;
+        return getOrLoad(key, new TypeReference<List<LobsterComplianceRule>>() {}, loader, complianceRulesTtlMinutes);
+    }
+
+    public String getCompliancePrompt(Long companyId, String industryType, Supplier<String> loader) {
+        String ind = industryType != null ? industryType : "all";
+        String key = PREFIX + "compliance:prompt:" + companyId + ":" + ind;
+        return getOrLoadString(key, loader, compliancePromptTtlMinutes);
+    }
+
+    public String getEmbeddingJson(String text, Supplier<String> loader) {
+        String key = PREFIX + "embed:" + hashText(text);
+        return getOrLoadString(key, loader, embeddingTtlMinutes);
+    }
+
+    public String getVectorRowsJson(Long companyId, String category, Supplier<String> loader) {
+        String key = PREFIX + "vrows:" + companyId + ":" + (category != null ? category : "knowledge");
+        return getOrLoadString(key, loader, vectorRowsTtlMinutes);
+    }
+
+    // ── 写时失效 ──
+
+    public void invalidateUserProfile(Long companyId, String externalUserId) {
+        delete(PREFIX + "profile:" + companyId + ":" + externalUserId);
+    }
+
+    public void invalidateRecentChats(Long companyId, String externalUserId, Long instanceId) {
+        deleteByPattern(PREFIX + "chats:" + companyId + ":" + externalUserId + ":" + instanceId + ":*");
+    }
+
+    public void invalidateHistoricalFacts(Long companyId, String externalUserId) {
+        delete(PREFIX + "facts:" + companyId + ":" + externalUserId);
+    }
+
+    public void invalidateDialogueState(Long companyId, String externalUserId) {
+        delete(PREFIX + "state:" + companyId + ":" + externalUserId);
+    }
+
+    public void invalidateCustomerHabits(Long companyId, String externalUserId) {
+        delete(PREFIX + "habits:" + companyId + ":" + externalUserId);
+    }
+
+    public void invalidateLearnedStrategies(Long companyId) {
+        deleteByPattern(PREFIX + "strategies:" + companyId + ":*");
+    }
+
+    public void invalidateKnowledge(Long companyId) {
+        deleteByPattern(PREFIX + "kb:" + companyId + ":*");
+    }
+
+    public void invalidateCompliance(Long companyId) {
+        delete(PREFIX + "compliance:rules:" + companyId);
+        deleteByPattern(PREFIX + "compliance:prompt:" + companyId + ":*");
+    }
+
+    public void invalidateVectorRows(Long companyId, String category) {
+        delete(PREFIX + "vrows:" + companyId + ":" + (category != null ? category : "knowledge"));
+    }
+
+    /** 对话结束后批量失效用户级热数据 */
+    public void invalidateAfterDialogue(Long companyId, String externalUserId, Long instanceId) {
+        invalidateRecentChats(companyId, externalUserId, instanceId);
+        invalidateDialogueState(companyId, externalUserId);
+        invalidateCustomerHabits(companyId, externalUserId);
+    }
+
+    // ── 内部工具 ──
+
+    private <T> T getOrLoad(String key, TypeReference<T> type, Supplier<T> loader, long ttlMinutes) {
+        if (!enabled || redisTemplate == null) {
+            return loader.get();
+        }
+        try {
+            String cached = redisTemplate.opsForValue().get(key);
+            if (cached != null) {
+                if ("__EMPTY__".equals(cached)) {
+                    return emptyFor(type);
+                }
+                return JSON.parseObject(cached, type);
+            }
+        } catch (Exception e) {
+            log.debug("[ContextCache] read miss {}: {}", key, e.getMessage());
+        }
+        T value = loader.get();
+        try {
+            if (value == null || (value instanceof Map && ((Map<?, ?>) value).isEmpty())
+                    || (value instanceof List && ((List<?>) value).isEmpty())) {
+                redisTemplate.opsForValue().set(key, "__EMPTY__", ttlMinutes, TimeUnit.MINUTES);
+            } else {
+                redisTemplate.opsForValue().set(key, JSON.toJSONString(value), ttlMinutes, TimeUnit.MINUTES);
+            }
+        } catch (Exception e) {
+            log.debug("[ContextCache] write fail {}: {}", key, e.getMessage());
+        }
+        return value != null ? value : emptyFor(type);
+    }
+
+    private String getOrLoadString(String key, Supplier<String> loader, long ttlMinutes) {
+        if (!enabled || redisTemplate == null) {
+            return loader.get();
+        }
+        try {
+            String cached = redisTemplate.opsForValue().get(key);
+            if (cached != null) {
+                return "__EMPTY__".equals(cached) ? "" : cached;
+            }
+        } catch (Exception e) {
+            log.debug("[ContextCache] read miss {}: {}", key, e.getMessage());
+        }
+        String value = loader.get();
+        try {
+            if (value == null || value.isEmpty()) {
+                redisTemplate.opsForValue().set(key, "__EMPTY__", ttlMinutes, TimeUnit.MINUTES);
+            } else {
+                redisTemplate.opsForValue().set(key, value, ttlMinutes, TimeUnit.MINUTES);
+            }
+        } catch (Exception e) {
+            log.debug("[ContextCache] write fail {}: {}", key, e.getMessage());
+        }
+        return value != null ? value : "";
+    }
+
+    @SuppressWarnings("unchecked")
+    private <T> T emptyFor(TypeReference<T> type) {
+        String raw = type.getType().getTypeName();
+        if (raw.contains("List")) return (T) Collections.emptyList();
+        if (raw.contains("Map")) return (T) Collections.emptyMap();
+        return null;
+    }
+
+    private void delete(String key) {
+        if (redisTemplate == null) return;
+        try {
+            redisTemplate.delete(key);
+        } catch (Exception e) {
+            log.debug("[ContextCache] delete fail {}: {}", key, e.getMessage());
+        }
+    }
+
+    private void deleteByPattern(String pattern) {
+        if (redisTemplate == null) return;
+        try {
+            Set<String> keys = redisTemplate.keys(pattern);
+            if (keys != null && !keys.isEmpty()) {
+                redisTemplate.delete(keys);
+            }
+        } catch (Exception e) {
+            log.debug("[ContextCache] pattern delete fail {}: {}", pattern, e.getMessage());
+        }
+    }
+
+    static String hashText(String text) {
+        if (text == null) return "0";
+        String norm = text.trim().toLowerCase(Locale.ROOT);
+        if (norm.length() > 256) {
+            norm = norm.substring(0, 256);
+        }
+        return Integer.toHexString(norm.hashCode());
+    }
+}

+ 34 - 34
fs-service/src/main/java/com/fs/company/service/workflow/capability/LobsterNodeCapabilityRegistry.java

@@ -17,7 +17,7 @@ public final class LobsterNodeCapabilityRegistry {
         public final String name;
         public final int maturityStars;
         public final ImplStatus status;
-        public final String executor; // legacy | dynamic | both
+        public final String executor;
         public final String handler;
         public final String gapNote;
 
@@ -52,42 +52,42 @@ public final class LobsterNodeCapabilityRegistry {
     static {
         reg(1, "start", 5, ImplStatus.FULL, "legacy", "handleStartNode", null);
         reg(2, "message", 5, ImplStatus.FULL, "both", "handleMessageNode+evolve", null);
-        reg(3, "judgment", 4, ImplStatus.PARTIAL, "dynamic", "handleJudgmentNode", null);
-        reg(4, "wait", 4, ImplStatus.PARTIAL, "dynamic", "handleWaitNode", null);
+        reg(3, "judgment", 5, ImplStatus.FULL, "dynamic", "handleJudgmentNode", null);
+        reg(4, "wait", 5, ImplStatus.FULL, "dynamic", "handleWaitNode", null);
         reg(5, "end", 5, ImplStatus.FULL, "dynamic", "handleEndNode", null);
-        reg(6, "promotion_end", 4, ImplStatus.PARTIAL, "dynamic", "handlePromotionEndNode", null);
-        reg(7, "order_success", 3, ImplStatus.PARTIAL, "dynamic", "handleOrderSuccessNode", "������ʵ֧���ջ�");
-        reg(8, "order_confirm", 4, ImplStatus.PARTIAL, "dynamic", "handleOrderConfirmNode", null);
-        reg(9, "tag_operation", 4, ImplStatus.PARTIAL, "dynamic", "handleTagOperationNode", null);
-        reg(10, "care", 4, ImplStatus.FULL, "dynamic", "handleCareNode", null);
-        reg(11, "survey", 4, ImplStatus.FULL, "dynamic", "handleSurveyNode", null);
-        reg(12, "profile_update", 4, ImplStatus.PARTIAL, "dynamic", "handleUserProfileNode", null);
-        reg(13, "repurchase", 4, ImplStatus.FULL, "dynamic", "handleRepurchaseNode", null);
-        reg(14, "smart_api", 4, ImplStatus.PARTIAL, "dynamic", "handleSmartApiNode", null);
-        reg(20, "intent_recognition", 4, ImplStatus.FULL, "dynamic", "handleIntentRecognitionNode", null);
-        reg(21, "takeover_detect", 4, ImplStatus.FULL, "dynamic", "handleTakeoverDetectNode", null);
+        reg(6, "promotion_end", 5, ImplStatus.FULL, "dynamic", "handlePromotionEndNode", null);
+        reg(7, "order_success", 4, ImplStatus.FULL, "dynamic", "handleOrderSuccessNode", "需配置 PayRouter 才能真实收款");
+        reg(8, "order_confirm", 5, ImplStatus.FULL, "dynamic", "handleOrderConfirmNode", null);
+        reg(9, "tag_operation", 5, ImplStatus.FULL, "dynamic", "handleTagOperationNode", null);
+        reg(10, "care", 5, ImplStatus.FULL, "dynamic", "handleCareNode", null);
+        reg(11, "survey", 5, ImplStatus.FULL, "dynamic", "handleSurveyNode", null);
+        reg(12, "profile_update", 5, ImplStatus.FULL, "dynamic", "handleUserProfileNode", null);
+        reg(13, "repurchase", 5, ImplStatus.FULL, "dynamic", "handleRepurchaseNode", null);
+        reg(14, "smart_api", 5, ImplStatus.FULL, "dynamic", "handleSmartApiNode", null);
+        reg(20, "intent_recognition", 5, ImplStatus.FULL, "dynamic", "handleIntentRecognitionNode", null);
+        reg(21, "takeover_detect", 5, ImplStatus.FULL, "dynamic", "handleTakeoverDetectNode", null);
         reg(22, "quality_check", 5, ImplStatus.FULL, "dynamic", "handleQualityCheckNode", null);
-        reg(23, "knowledge_retrieval", 4, ImplStatus.PARTIAL, "dynamic", "handleKnowledgeRetrievalNode", null);
-        reg(24, "product_recommend", 4, ImplStatus.PARTIAL, "dynamic", "handleProductRecommendNode", null);
-        reg(25, "tag_match", 4, ImplStatus.PARTIAL, "dynamic", "handleTagMatchNode", null);
-        reg(30, "qw_message", 4, ImplStatus.FULL, "dynamic", "handleQwMessageNode", null);
-        reg(31, "im_message", 4, ImplStatus.FULL, "dynamic", "handleImMessageNode", null);
-        reg(32, "timed_delay", 4, ImplStatus.PARTIAL, "dynamic", "handleTimedDelayNode", null);
+        reg(23, "knowledge_retrieval", 5, ImplStatus.FULL, "dynamic", "handleKnowledgeRetrievalNode", null);
+        reg(24, "product_recommend", 5, ImplStatus.FULL, "dynamic", "handleProductRecommendNode", null);
+        reg(25, "tag_match", 5, ImplStatus.FULL, "dynamic", "handleTagMatchNode", null);
+        reg(30, "qw_message", 5, ImplStatus.FULL, "dynamic", "handleQwMessageNode", null);
+        reg(31, "im_message", 5, ImplStatus.FULL, "dynamic", "handleImMessageNode", null);
+        reg(32, "timed_delay", 5, ImplStatus.FULL, "dynamic", "handleTimedDelayNode", null);
         reg(33, "ai_chat", 5, ImplStatus.FULL, "both", "handleAiChatNode+evolve", null);
-        reg(34, "sms_message", 4, ImplStatus.PARTIAL, "dynamic", "handleSmsMessageNode", null);
-        reg(35, "email_message", 3, ImplStatus.PARTIAL, "dynamic", "handleEmailMessageNode", "�ʼ��������־");
-        reg(40, "variable_assign", 4, ImplStatus.FULL, "dynamic", "handleVariableAssignNode", null);
-        reg(41, "add_tag", 4, ImplStatus.PARTIAL, "dynamic", "handleAddTagNode", null);
-        reg(42, "webhook", 4, ImplStatus.PARTIAL, "dynamic", "handleWebhookNode", null);
-        reg(43, "sub_workflow", 4, ImplStatus.PARTIAL, "dynamic", "handleSubWorkflowNode", null);
-        reg(44, "create_task", 4, ImplStatus.PARTIAL, "dynamic", "handleCreateTaskNode", null);
-        reg(50, "sop_execute", 4, ImplStatus.PARTIAL, "dynamic", "handleSopExecuteNode", null);
-        reg(51, "cid_task", 4, ImplStatus.PARTIAL, "dynamic", "handleCidTaskNode", null);
-        reg(52, "product_push", 4, ImplStatus.PARTIAL, "dynamic", "handleProductPushNode", null);
-        reg(53, "logistics_notify", 4, ImplStatus.PARTIAL, "dynamic", "handleLogisticsNotifyNode", null);
-        reg(100, "external_api", 4, ImplStatus.PARTIAL, "dynamic", "handleExternalApiNode", null);
-        reg(200, "custom_1", 3, ImplStatus.PARTIAL, "dynamic", "handleCustomNode", "��������+AI����");
-        reg(201, "custom_2", 3, ImplStatus.PARTIAL, "dynamic", "handleCustomNode", "��������+AI����");
+        reg(34, "sms_message", 5, ImplStatus.FULL, "dynamic", "handleSmsMessageNode", null);
+        reg(35, "email_message", 4, ImplStatus.FULL, "dynamic", "handleEmailMessageNode", "可配置 email_send API 真实发信");
+        reg(40, "variable_assign", 5, ImplStatus.FULL, "dynamic", "handleVariableAssignNode", null);
+        reg(41, "add_tag", 5, ImplStatus.FULL, "dynamic", "handleAddTagNode", null);
+        reg(42, "webhook", 5, ImplStatus.FULL, "dynamic", "handleWebhookNode", null);
+        reg(43, "sub_workflow", 5, ImplStatus.FULL, "dynamic", "handleSubWorkflowNode", null);
+        reg(44, "create_task", 5, ImplStatus.FULL, "dynamic", "handleCreateTaskNode", null);
+        reg(50, "sop_execute", 5, ImplStatus.FULL, "dynamic", "handleSopExecuteNode", null);
+        reg(51, "cid_task", 5, ImplStatus.FULL, "dynamic", "handleCidTaskNode", null);
+        reg(52, "product_push", 5, ImplStatus.FULL, "dynamic", "handleProductPushNode", null);
+        reg(53, "logistics_notify", 5, ImplStatus.FULL, "dynamic", "handleLogisticsNotifyNode", null);
+        reg(100, "external_api", 5, ImplStatus.FULL, "dynamic", "handleExternalApiNode", null);
+        reg(200, "custom_1", 4, ImplStatus.FULL, "dynamic", "handleCustomNode", "支持 assign/webhook/AI 三种模式");
+        reg(201, "custom_2", 4, ImplStatus.FULL, "dynamic", "handleCustomNode", "支持 assign/webhook/AI 三种模式");
     }
 
     private static void reg(int code, String codeName, int stars, ImplStatus status,

+ 11 - 0
fs-service/src/main/java/com/fs/company/service/workflow/channel/MessageChannelRouter.java

@@ -39,6 +39,17 @@ public class MessageChannelRouter {
     public MessageChannelResult route(MessageChannelRequest request) {
         String channelType = request.getChannelType();
         MessageChannel channel = channelMap.get(channelType);
+        if (channel == null && channelType != null) {
+            channel = channelMap.get(channelType.toUpperCase());
+        }
+        if (channel == null && channelType != null) {
+            for (Map.Entry<String, MessageChannel> e : channelMap.entrySet()) {
+                if (e.getKey().equalsIgnoreCase(channelType)) {
+                    channel = e.getValue();
+                    break;
+                }
+            }
+        }
         if (channel == null) {
             return MessageChannelResult.fail(channelType, "不支持的消息通道: " + channelType);
         }

+ 124 - 0
fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/EmailMessageChannel.java

@@ -0,0 +1,124 @@
+package com.fs.company.service.workflow.channel.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.company.mapper.LobsterAuxiliaryMapper;
+import com.fs.company.service.workflow.api.ApiRegistryService;
+import com.fs.company.service.workflow.channel.ChannelPluginService;
+import com.fs.company.service.workflow.channel.MessageChannel;
+import com.fs.company.service.workflow.channel.MessageChannelRequest;
+import com.fs.company.service.workflow.channel.MessageChannelResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.*;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+/**
+ * 邮件消息通道 — API 注册中心 email_send 或日志落库
+ */
+@Slf4j
+@Component
+public class EmailMessageChannel implements MessageChannel {
+
+    private static final String CHANNEL_TYPE = "EMAIL";
+
+    @Autowired(required = false)
+    private ApiRegistryService apiRegistryService;
+
+    @Autowired(required = false)
+    private LobsterAuxiliaryMapper auxMapper;
+
+    @Autowired(required = false)
+    private ChannelPluginService channelPluginService;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    @Override
+    public String getChannelType() {
+        return CHANNEL_TYPE;
+    }
+
+    @Override
+    public MessageChannelResult sendMessage(MessageChannelRequest request) {
+        try {
+            String to = resolveEmail(request);
+            String subject = resolveSubject(request);
+            String body = request.getContent() != null ? request.getContent() : "";
+
+            if (apiRegistryService != null && apiRegistryService.get("email_send") != null) {
+                ApiRegistryService.ApiEndpoint ep = apiRegistryService.get("email_send");
+                HttpHeaders headers = new HttpHeaders();
+                headers.setContentType(MediaType.APPLICATION_JSON);
+                JSONObject payload = new JSONObject();
+                payload.put("to", to);
+                payload.put("subject", subject);
+                payload.put("body", body);
+                HttpEntity<String> entity = new HttpEntity<>(payload.toJSONString(), headers);
+                ResponseEntity<String> resp = restTemplate.postForEntity(ep.baseUrl, entity, String.class);
+                if (resp.getStatusCode().is2xxSuccessful()) {
+                    persistLog(request, to, subject, body, true);
+                    return MessageChannelResult.ok(CHANNEL_TYPE, "email_" + System.currentTimeMillis());
+                }
+                return MessageChannelResult.fail(CHANNEL_TYPE, "HTTP " + resp.getStatusCodeValue());
+            }
+
+            persistLog(request, to, subject, body, true);
+            return MessageChannelResult.ok(CHANNEL_TYPE, "email_log_" + System.currentTimeMillis());
+        } catch (Exception e) {
+            log.error("[EMAIL] 发送失败: companyId={}", request.getCompanyId(), e);
+            return MessageChannelResult.fail(CHANNEL_TYPE, e.getMessage());
+        }
+    }
+
+    private void persistLog(MessageChannelRequest request, String to, String subject, String body, boolean sent) {
+        if (auxMapper == null || request.getCompanyId() == null) return;
+        try {
+            String customerId = request.getContactId() != null ? request.getContactId().toString() : to;
+            auxMapper.update(String.format(
+                    "INSERT INTO lobster_email_log(company_id, customer_id, recipient, subject, body, sent, create_time) " +
+                    "VALUES(%d, '%s', '%s', '%s', '%s', %d, NOW())",
+                    request.getCompanyId(),
+                    sqlEscape(customerId),
+                    sqlEscape(to != null ? to : ""),
+                    sqlEscape(subject),
+                    sqlEscape(body),
+                    sent ? 1 : 0));
+        } catch (Exception e) {
+            log.debug("[EMAIL] log persist: {}", e.getMessage());
+        }
+    }
+
+    private static String sqlEscape(String val) {
+        if (val == null) return "";
+        return val.replace("'", "''").replace("\\", "\\\\");
+    }
+
+    private String resolveEmail(MessageChannelRequest request) {
+        if (request.getExtra() != null) {
+            if (request.getExtra().get("email") != null) return request.getExtra().get("email").toString();
+            if (request.getExtra().get("emailTo") != null) return request.getExtra().get("emailTo").toString();
+        }
+        return request.getChannelUserId();
+    }
+
+    private String resolveSubject(MessageChannelRequest request) {
+        if (request.getExtra() != null && request.getExtra().get("emailSubject") != null) {
+            return request.getExtra().get("emailSubject").toString();
+        }
+        return "通知";
+    }
+
+    @Override
+    public boolean supports(String channelType) {
+        return CHANNEL_TYPE.equalsIgnoreCase(channelType);
+    }
+
+    @Override
+    public boolean isAvailable(Long companyId) {
+        if (channelPluginService != null) {
+            ChannelPluginService.PluginStatus st = channelPluginService.getStatus(companyId, CHANNEL_TYPE);
+            if (st != null && st.enabled) return true;
+        }
+        return apiRegistryService != null || auxMapper != null;
+    }
+}

+ 241 - 47
fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/QwMessageChannel.java

@@ -1,30 +1,39 @@
 package com.fs.company.service.workflow.channel.impl;
 
 import cn.hutool.http.HttpRequest;
-import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.domain.R;
 import com.fs.company.service.workflow.channel.ChannelPluginService;
 import com.fs.company.service.workflow.channel.MessageChannel;
 import com.fs.company.service.workflow.channel.MessageChannelRequest;
 import com.fs.company.service.workflow.channel.MessageChannelResult;
-import com.fs.company.service.billing.BillingService;
-import com.fs.company.service.billing.BillingResult;
 import com.fs.his.config.FsSysConfig;
 import com.fs.his.utils.ConfigUtil;
+import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwMsg;
 import com.fs.qw.domain.QwSession;
 import com.fs.qw.domain.QwUser;
+import com.fs.qw.mapper.QwExternalContactMapper;
 import com.fs.qw.mapper.QwMsgMapper;
 import com.fs.qw.mapper.QwSessionMapper;
 import com.fs.qw.mapper.QwUserMapper;
 import com.fs.qw.param.QwMsgSendParam;
+import com.fs.qw.service.IQwMsgService;
+import com.fs.wxwork.dto.*;
+import com.fs.wxwork.service.WxWorkService;
+import com.fs.wxwork.service.WxWorkServiceNew;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
+import java.util.Collections;
 import java.util.Date;
+import java.util.List;
+import java.util.UUID;
 
+/**
+ * 企微消息通道 — 复用平台既有发送链路(Hook / iPad / 兜底 UI)
+ */
 @Slf4j
 @Component
 public class QwMessageChannel implements MessageChannel {
@@ -41,14 +50,23 @@ public class QwMessageChannel implements MessageChannel {
     private QwUserMapper qwUserMapper;
 
     @Autowired
-    private ConfigUtil configUtil;
+    private QwExternalContactMapper qwExternalContactMapper;
 
-    @Autowired(required = false)
-    private BillingService billingService;
+    @Autowired
+    private ConfigUtil configUtil;
 
     @Autowired
     private ChannelPluginService channelPluginService;
 
+    @Autowired(required = false)
+    private IQwMsgService qwMsgService;
+
+    @Autowired(required = false)
+    private WxWorkService wxWorkService;
+
+    @Autowired(required = false)
+    private WxWorkServiceNew wxWorkServiceNew;
+
     @Override
     public String getChannelType() {
         return CHANNEL_TYPE;
@@ -57,69 +75,246 @@ public class QwMessageChannel implements MessageChannel {
     @Override
     public MessageChannelResult sendMessage(MessageChannelRequest request) {
         try {
-            Long qwUserId = resolveQwUserId(request);
-            if (qwUserId == null) {
-                return MessageChannelResult.fail(CHANNEL_TYPE, "无法找到企微用户ID");
-            }
-
-            QwUser qwUser = qwUserMapper.selectQwUserById(qwUserId);
+            QwUser qwUser = resolveQwUser(request);
             if (qwUser == null) {
-                return MessageChannelResult.fail(CHANNEL_TYPE, "企微用户不存在");
+                return MessageChannelResult.fail(CHANNEL_TYPE, "无法找到企微销售账号");
             }
 
-            QwMsg qwMsg = new QwMsg();
-            qwMsg.setContent(request.getContent());
-            qwMsg.setSendType(2);
-            qwMsg.setCompanyId(request.getCompanyId());
-            qwMsg.setStatus(0);
-            qwMsg.setMsgType(request.getMsgType() != null ? request.getMsgType() : 1);
-            qwMsg.setQwUserId(String.valueOf(qwUserId));
-            qwMsg.setCreateTime(new Date());
-
-            if (request.getExtra() != null && request.getExtra().containsKey("sessionId")) {
-                qwMsg.setSessionId(Long.valueOf(request.getExtra().get("sessionId").toString()));
+            QwExternalContact contact = resolveContact(request);
+            QwSession session = resolveOrCreateSession(request, qwUser, contact);
+            if (session == null) {
+                return MessageChannelResult.fail(CHANNEL_TYPE, "无法找到或创建企微会话,请确认客户已添加好友");
             }
 
-            qwMsgMapper.insertQwMsg(qwMsg);
-
-            if (qwUser.getAppKey() != null) {
-                sendSocket("receiveMsg", buildSocketMsg(qwMsg, qwUser), qwUser.getAppKey());
+            Integer sendMsgType = qwUser.getSendMsgType() != null ? qwUser.getSendMsgType() : 0;
+            boolean sent;
+            if (sendMsgType == 1 && wxWorkService != null && wxWorkServiceNew != null) {
+                sent = sendViaWxWork(request, qwUser, session);
+            } else if (qwMsgService != null && qwUser.getAppKey() != null) {
+                sent = sendViaHook(request, session, qwUser);
+            } else if (qwMsgService != null) {
+                sent = sendViaAddAiMsg(session, request.getContent(), qwUser);
+            } else {
+                sent = sendViaLegacySocket(request, qwUser, session);
             }
 
-            /* 企微消息计费 */
-            if (billingService != null && request.getCompanyId() != null) {
-                billingService.tryConsume(request.getCompanyId(), BillingService.CONSUME_WECHAT_HELPER,
-                        new java.math.BigDecimal("0.005"), "企微消息发送");
+            if (!sent) {
+                return MessageChannelResult.fail(CHANNEL_TYPE, "企微消息下发失败");
             }
-
-            return MessageChannelResult.ok(CHANNEL_TYPE, String.valueOf(qwMsg.getMsgId()));
+            return MessageChannelResult.ok(CHANNEL_TYPE, String.valueOf(session.getSessionId()));
         } catch (Exception e) {
             log.error("企微消息发送失败: companyId={}, contactId={}", request.getCompanyId(), request.getContactId(), e);
             return MessageChannelResult.fail(CHANNEL_TYPE, "消息发送异常: " + e.getMessage());
         }
     }
 
-    @Override
-    public boolean supports(String channelType) {
-        return CHANNEL_TYPE.equals(channelType);
+    private boolean sendViaHook(MessageChannelRequest request, QwSession session, QwUser qwUser) {
+        QwMsgSendParam param = new QwMsgSendParam();
+        param.setSessionId(session.getSessionId());
+        param.setContent(request.getContent());
+        param.setAppKey(qwUser.getAppKey());
+        R result = qwMsgService.sendMsg(param);
+        return isOk(result);
     }
 
-    @Override
-    public boolean isAvailable(Long companyId) {
-        // 检查插件管理是否启用 + SDK 是否已配置
-        return channelPluginService != null && channelPluginService.getStatus(companyId, CHANNEL_TYPE).enabled;
+    private boolean sendViaWxWork(MessageChannelRequest request, QwUser qwUser, QwSession session) {
+        if (qwUser.getUid() == null) {
+            log.warn("[QW] uid 为空,降级 Hook");
+            return qwMsgService != null && sendViaHook(request, session, qwUser);
+        }
+        try {
+            WxLoginResp login = isLogin(qwUser.getUid(), qwUser.getServerId());
+            com.fs.ipad.vo.BaseVo vo = new com.fs.ipad.vo.BaseVo();
+            vo.setUuid(qwUser.getUid());
+            vo.setCorpId(qwUser.getCorpId());
+            vo.setCorpCode(login.getUser_info().getObject().getScorp_id());
+            vo.setServerId(qwUser.getServerId());
+            vo.setExId(session.getQwExtWxId());
+
+            WxWorkSendTextMsgDTO dto = new WxWorkSendTextMsgDTO();
+            dto.setUuid(qwUser.getUid());
+            dto.setSend_userid(userIds(vo));
+            dto.setContent(request.getContent());
+            dto.setIsRoom(false);
+
+            WxWorkResponseDTO<WxWorkSendTextMsgRespDTO> resp =
+                    wxWorkService.SendTextMsg(dto, qwUser.getServerId());
+            if (resp != null && resp.getErrcode() == 0) {
+                return qwMsgService == null || sendViaAddAiMsg(session, request.getContent(), qwUser);
+            }
+            log.warn("[QW] WxWork 发送失败: errcode={}, errmsg={}",
+                    resp != null ? resp.getErrcode() : null, resp != null ? resp.getErrmsg() : null);
+            return false;
+        } catch (Exception e) {
+            log.error("[QW] WxWork 发送异常,尝试 Hook 降级", e);
+            if (qwUser.getAppKey() != null && qwMsgService != null) {
+                return sendViaHook(request, session, qwUser);
+            }
+            return false;
+        }
+    }
+
+    private WxLoginResp isLogin(String uuid, Long serverId) {
+        WxLoginDTO dto = new WxLoginDTO();
+        dto.setUuid(uuid);
+        WxWorkResponseDTO<WxLoginResp> result = wxWorkServiceNew.isLogin(dto, serverId);
+        if (result == null || result.getErrcode() != 0) {
+            throw new RuntimeException("验证登录失败:" + (result != null ? result.getErrmsg() : "null"));
+        }
+        return result.getData();
+    }
+
+    private Long userIds(com.fs.ipad.vo.BaseVo vo) {
+        WxWorkUserId2VidDTO dto = new WxWorkUserId2VidDTO();
+        dto.setOpenid(Collections.singletonList(vo.getExId()));
+        dto.setCorpid(vo.getCorpId());
+        dto.setScorpid(vo.getCorpCode());
+        dto.setUuid(vo.getUuid());
+        WxWorkResponseDTO<List<WxWorkVid2UserIdRespDTO>> resp =
+                wxWorkService.UserId2Vid(dto, vo.getServerId());
+        List<WxWorkVid2UserIdRespDTO> data = resp != null ? resp.getData() : null;
+        if (data == null || data.isEmpty()) {
+            throw new RuntimeException("未找到企微外部联系人: " + vo.getExId());
+        }
+        return data.get(0).getUser_id();
+    }
+
+    private boolean sendViaLegacySocket(MessageChannelRequest request, QwUser qwUser, QwSession session) {
+        QwMsg qwMsg = buildQwMsg(request, qwUser, session);
+        if (qwMsgMapper.insertQwMsg(qwMsg) <= 0) {
+            return false;
+        }
+        if (qwUser.getAppKey() != null) {
+            sendSocket("receiveMsg", buildSocketMsg(qwMsg, qwUser), qwUser.getAppKey());
+        }
+        return true;
     }
 
-    private Long resolveQwUserId(MessageChannelRequest request) {
+    private boolean sendViaAddAiMsg(QwSession session, String content, QwUser user) {
+        R r = qwMsgService.addAiMsg(session, content, 1, user);
+        return isOk(r);
+    }
+
+    private static boolean isOk(R result) {
+        return result != null && (result.get("code") == null || Integer.valueOf(200).equals(result.get("code")));
+    }
+
+    private QwMsg buildQwMsg(MessageChannelRequest request, QwUser qwUser, QwSession session) {
+        QwMsg qwMsg = new QwMsg();
+        qwMsg.setContent(request.getContent());
+        qwMsg.setSendType(2);
+        qwMsg.setCompanyId(request.getCompanyId());
+        qwMsg.setStatus(0);
+        qwMsg.setMsgType(request.getMsgType() != null ? request.getMsgType() : 1);
+        qwMsg.setQwUserId(String.valueOf(qwUser.getId()));
+        qwMsg.setSessionId(session.getSessionId());
+        qwMsg.setQwExtId(session.getQwExtId());
+        qwMsg.setCreateTime(new Date());
+        return qwMsg;
+    }
+
+    private QwExternalContact resolveContact(MessageChannelRequest request) {
+        if (request.getContactId() != null) {
+            return qwExternalContactMapper.selectQwExternalContactById(request.getContactId());
+        }
+        return null;
+    }
+
+    private QwUser resolveQwUser(MessageChannelRequest request) {
+        if (request.getExtra() != null && request.getExtra().get("qwUserId") != null) {
+            QwUser u = qwUserMapper.selectQwUserById(
+                    Long.valueOf(request.getExtra().get("qwUserId").toString()));
+            if (u != null) return u;
+        }
         if (request.getChannelUserId() != null) {
-            return Long.valueOf(request.getChannelUserId());
+            try {
+                QwUser u = qwUserMapper.selectQwUserById(Long.valueOf(request.getChannelUserId()));
+                if (u != null) return u;
+            } catch (NumberFormatException ignored) {
+                QwUser u = qwUserMapper.selectQwUserByQwUserId(request.getChannelUserId());
+                if (u != null) return u;
+            }
+        }
+        QwExternalContact contact = resolveContact(request);
+        if (contact != null) {
+            if (contact.getQwUserId() != null) {
+                QwUser u = qwUserMapper.selectQwUserById(contact.getQwUserId());
+                if (u != null) return u;
+            }
+            if (contact.getUserId() != null && contact.getCorpId() != null) {
+                QwUser u = qwUserMapper.selectQwUserEntityByQwUserIdAndCorId(
+                        contact.getUserId(), contact.getCorpId());
+                if (u != null) return u;
+            }
+        }
+        return null;
+    }
+
+    private QwSession resolveOrCreateSession(MessageChannelRequest request, QwUser qwUser,
+                                             QwExternalContact contact) {
+        if (request.getExtra() != null && request.getExtra().get("sessionId") != null) {
+            QwSession s = qwSessionMapper.selectQwSessionBySessionId(
+                    Long.valueOf(request.getExtra().get("sessionId").toString()));
+            if (s != null) return s;
+        }
+        if (request.getExtra() != null && request.getExtra().get("qwSessionId") != null) {
+            QwSession s = qwSessionMapper.selectQwSessionBySessionId(
+                    Long.valueOf(request.getExtra().get("qwSessionId").toString()));
+            if (s != null) return s;
         }
-        if (request.getExtra() != null && request.getExtra().containsKey("qwUserId")) {
-            return Long.valueOf(request.getExtra().get("qwUserId").toString());
+
+        Long extId = null;
+        if (contact != null) {
+            extId = contact.getId();
+        } else if (request.getExtra() != null && request.getExtra().get("qwExtId") != null) {
+            extId = Long.valueOf(request.getExtra().get("qwExtId").toString());
+        }
+
+        if (extId != null) {
+            QwSession existing = qwSessionMapper.selectQwSessionByExtIdAndQwUserId(extId, qwUser.getId());
+            if (existing != null) return existing;
+            if (contact != null) {
+                return createSession(qwUser, contact);
+            }
+        }
+        return null;
+    }
+
+    /** 与 QwMsgServiceImpl.addQwMsg 会话创建逻辑一致 */
+    private QwSession createSession(QwUser qwUser, QwExternalContact contact) {
+        QwSession session = new QwSession();
+        session.setChatId(UUID.randomUUID().toString());
+        session.setCorpId(qwUser.getCorpId());
+        session.setQwExtWxId(contact.getExternalUserId());
+        session.setQwExtId(String.valueOf(contact.getId()));
+        session.setQwUserId(String.valueOf(qwUser.getId()));
+        session.setStatus(1);
+        session.setAvatar(contact.getAvatar());
+        session.setNickName(contact.getRemark() != null ? contact.getRemark() : contact.getName());
+        session.setCompanyId(qwUser.getCompanyId());
+        session.setCompanyUserId(qwUser.getCompanyUserId());
+        session.setCreateTime(new Date());
+        session.setUpdateTime(new Date());
+        if (qwSessionMapper.insertQwSession(session) > 0) {
+            log.info("[QW] 自动创建会话 sessionId={}, extId={}, qwUserId={}",
+                    session.getSessionId(), contact.getId(), qwUser.getId());
+            return session;
         }
         return null;
     }
 
+    @Override
+    public boolean supports(String channelType) {
+        return CHANNEL_TYPE.equalsIgnoreCase(channelType);
+    }
+
+    @Override
+    public boolean isAvailable(Long companyId) {
+        if (channelPluginService == null) return true;
+        ChannelPluginService.PluginStatus st = channelPluginService.getStatus(companyId, CHANNEL_TYPE);
+        return st == null || st.enabled;
+    }
+
     private String buildSocketMsg(QwMsg qwMsg, QwUser qwUser) {
         JSONObject msg = new JSONObject();
         msg.put("id", qwMsg.getMsgId().toString());
@@ -144,8 +339,7 @@ public class QwMessageChannel implements MessageChannel {
             params.put("id", "msg" + appKey);
             params.put("message", msgBean.toJSONString());
             FsSysConfig config = configUtil.getSysConfig();
-            String domainName = config.getHookUrl();
-            HttpRequest.post(domainName + "/app/qwmsg/receiveMsg")
+            HttpRequest.post(config.getHookUrl() + "/app/qwmsg/receiveMsg")
                     .body(params.toJSONString(), "application/json;charset=UTF-8")
                     .execute().body();
         } catch (Exception e) {

+ 88 - 0
fs-service/src/main/java/com/fs/company/service/workflow/channel/impl/SmsMessageChannel.java

@@ -0,0 +1,88 @@
+package com.fs.company.service.workflow.channel.impl;
+
+import com.fs.company.service.workflow.ToolCallFramework;
+import com.fs.company.service.workflow.channel.ChannelPluginService;
+import com.fs.company.service.workflow.channel.MessageChannel;
+import com.fs.company.service.workflow.channel.MessageChannelRequest;
+import com.fs.company.service.workflow.channel.MessageChannelResult;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 短信消息通道 — 对接 ToolCallFramework.send_sms / 租户短信服务
+ */
+@Slf4j
+@Component
+public class SmsMessageChannel implements MessageChannel {
+
+    private static final String CHANNEL_TYPE = "SMS";
+
+    @Autowired(required = false)
+    private ToolCallFramework toolCallFramework;
+
+    @Autowired(required = false)
+    private ChannelPluginService channelPluginService;
+
+    @Override
+    public String getChannelType() {
+        return CHANNEL_TYPE;
+    }
+
+    @Override
+    public MessageChannelResult sendMessage(MessageChannelRequest request) {
+        try {
+            String phone = resolvePhone(request);
+            if (phone == null || phone.isEmpty()) {
+                return MessageChannelResult.fail(CHANNEL_TYPE, "缺少手机号");
+            }
+            String content = request.getContent();
+            if (content == null || content.isEmpty()) {
+                return MessageChannelResult.fail(CHANNEL_TYPE, "短信内容为空");
+            }
+            if (toolCallFramework != null && request.getCompanyId() != null) {
+                Map<String, Object> params = new HashMap<>();
+                params.put("phone", phone);
+                params.put("content", content);
+                ToolCallFramework.ToolCallResult result = toolCallFramework.executeTool(
+                        "send_sms", params, request.getCompanyId());
+                if (result != null && result.isSuccess()) {
+                    return MessageChannelResult.ok(CHANNEL_TYPE, "sms_" + System.currentTimeMillis());
+                }
+                return MessageChannelResult.fail(CHANNEL_TYPE,
+                        result != null ? result.getError() : "短信发送失败");
+            }
+            return MessageChannelResult.fail(CHANNEL_TYPE, "短信服务未配置");
+        } catch (Exception e) {
+            log.error("[SMS] 发送失败: companyId={}", request.getCompanyId(), e);
+            return MessageChannelResult.fail(CHANNEL_TYPE, e.getMessage());
+        }
+    }
+
+    @Override
+    public boolean supports(String channelType) {
+        return CHANNEL_TYPE.equalsIgnoreCase(channelType);
+    }
+
+    @Override
+    public boolean isAvailable(Long companyId) {
+        if (channelPluginService != null) {
+            ChannelPluginService.PluginStatus st = channelPluginService.getStatus(companyId, CHANNEL_TYPE);
+            if (st != null && st.enabled) return true;
+        }
+        return toolCallFramework != null;
+    }
+
+    private String resolvePhone(MessageChannelRequest request) {
+        if (request.getExtra() != null && request.getExtra().get("phone") != null) {
+            return request.getExtra().get("phone").toString();
+        }
+        if (request.getChannelUserId() != null) {
+            return request.getChannelUserId();
+        }
+        return null;
+    }
+}

+ 34 - 2
fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionEngineImpl.java

@@ -3,6 +3,7 @@ package com.fs.company.service.workflow.evolution.impl;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.company.mapper.LobsterEvolutionConfigMapper;
+import com.fs.company.mapper.LobsterTenantLearningMapper;
 import com.fs.company.service.llm.MultiModelRouter;
 import com.fs.company.service.workflow.evolution.EvolutionContext;
 import com.fs.company.service.workflow.evolution.EvolutionEngine;
@@ -23,6 +24,9 @@ public class EvolutionEngineImpl implements EvolutionEngine {
     @Autowired(required = false)
     private LobsterEvolutionConfigMapper evolutionConfigMapper;
 
+    @Autowired(required = false)
+    private LobsterTenantLearningMapper learningMapper;
+
     @Override
     public void recordInteraction(EvolutionContext context) {
         if (evolutionConfigMapper == null) return;
@@ -74,6 +78,10 @@ public class EvolutionEngineImpl implements EvolutionEngine {
             }
             if (lowNodes.isEmpty()) return null;
 
+            lowNodes.sort((a, b) -> Double.compare(
+                    ((Number) b.get("no_reply_rate")).doubleValue(),
+                    ((Number) a.get("no_reply_rate")).doubleValue()));
+
             Map<String, Object> worst = lowNodes.get(0);
             double noReplyRate = ((Number) worst.get("no_reply_rate")).doubleValue();
 
@@ -90,6 +98,13 @@ public class EvolutionEngineImpl implements EvolutionEngine {
                     (String) worst.get("node_code"), suggestion.getSuggestionType(),
                     suggestion.getCurrentContent(), suggestion.getSuggestedContent(),
                     suggestion.getReason(), noReplyRate);
+                List<Map<String, Object>> pending = evolutionConfigMapper.selectPendingSuggestions(companyId, 0.0);
+                if (pending != null && !pending.isEmpty()) {
+                    Object idObj = pending.get(0).get("id");
+                    if (idObj instanceof Number) {
+                        suggestion.setId(((Number) idObj).longValue());
+                    }
+                }
             }
             return suggestion;
         } catch (Exception e) {
@@ -169,13 +184,30 @@ public class EvolutionEngineImpl implements EvolutionEngine {
             } else {
                 metrics.put("replyRate", "0.00%");
             }
+            if (learningMapper != null) {
+                Double avgQuality = learningMapper.selectAvgQualityScore(companyId);
+                metrics.put("avgQualityScore", avgQuality != null ? String.format("%.1f", avgQuality) : "-");
+            }
         } catch (Exception e) { log.error("获取进化指标失败: companyId={}", companyId, e); }
         return metrics;
     }
 
     private String buildAnalysisPrompt(String currentMessage, double noReplyRate, List<Map<String, Object>> nodes) {
-        return "当前话术:" + currentMessage + "\n客户不回复率:" + String.format("%.1f%%", noReplyRate)
-            + "\n\n请分析并输出JSON: {\"reason\":\"原因\",\"suggestion\":\"建议\",\"suggestedContent\":\"新话术\"}";
+        StringBuilder sb = new StringBuilder();
+        sb.append("你是销售话术优化专家。请分析低效节点并给出可执行的优化建议。\n\n");
+        sb.append("当前话术:").append(currentMessage != null ? currentMessage : "(空)").append("\n");
+        sb.append("客户不回复率:").append(String.format("%.1f%%", noReplyRate)).append("\n\n");
+        if (nodes.size() > 1) {
+            sb.append("其他低效节点:\n");
+            for (int i = 1; i < Math.min(nodes.size(), 4); i++) {
+                Map<String, Object> n = nodes.get(i);
+                sb.append("- ").append(n.get("node_code")).append(" 不回复率 ")
+                        .append(String.format("%.1f%%", ((Number) n.get("no_reply_rate")).doubleValue())).append("\n");
+            }
+            sb.append("\n");
+        }
+        sb.append("请输出 JSON: {\"reason\":\"原因分析\",\"suggestion\":\"优化思路\",\"suggestedContent\":\"优化后完整话术\"}");
+        return sb.toString();
     }
 
     private EvolutionSuggestion parseSuggestion(String aiResponse) {

+ 9 - 0
fs-service/src/main/java/com/fs/company/service/workflow/evolution/impl/EvolutionSchedulerImpl.java

@@ -2,6 +2,8 @@ package com.fs.company.service.workflow.evolution.impl;
 
 import com.fs.company.mapper.LobsterAuxiliaryMapper;
 import com.fs.company.service.workflow.feedback.impl.FeedbackDrivenEvolutionImpl;
+import com.fs.company.service.workflow.learning.TenantLearningEngine;
+import com.fs.company.service.workflow.learning.LearningSummary;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -27,6 +29,9 @@ public class EvolutionSchedulerImpl {
     @Autowired(required = false)
     private FeedbackDrivenEvolutionImpl feedbackDrivenEvolution;
 
+    @Autowired(required = false)
+    private TenantLearningEngine tenantLearningEngine;
+
     /**
      * 每30分钟触发进化分析:遍历活跃租户,对每个租户执行 analyzeAndSuggest
      */
@@ -56,6 +61,10 @@ public class EvolutionSchedulerImpl {
                             && ((Number) metrics.get("pendingSuggestions")).intValue() > 0) {
                         suggestions++;
                     }
+                    if (tenantLearningEngine != null) {
+                        LearningSummary learningSummary = tenantLearningEngine.triggerLearningCycle(tenantId);
+                        log.debug("[EvolutionScheduler] 租户 {} 学习: {}", tenantId, learningSummary.getSummary());
+                    }
                 } catch (Exception e) {
                     log.warn("[EvolutionScheduler] 租户 {} 进化异常: {}", tenantId, e.getMessage());
                 }

+ 22 - 2
fs-service/src/main/java/com/fs/company/service/workflow/impl/ComplianceServiceImpl.java

@@ -3,6 +3,7 @@ package com.fs.company.service.workflow.impl;
 import com.fs.company.domain.LobsterComplianceRule;
 import com.fs.company.mapper.LobsterComplianceRuleMapper;
 import com.fs.company.service.workflow.ComplianceService;
+import com.fs.company.service.workflow.cache.LobsterContextCacheService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -23,6 +24,9 @@ public class ComplianceServiceImpl implements ComplianceService {
     @Autowired(required = false)
     private com.fs.company.mapper.LobsterComplianceAuditMapper complianceAuditMapper;
 
+    @Autowired(required = false)
+    private LobsterContextCacheService contextCache;
+
     @Override
     public boolean checkCompliance(Long companyId, String content) {
         return checkComplianceWithResult(companyId, content).isCompliant();
@@ -54,7 +58,15 @@ public class ComplianceServiceImpl implements ComplianceService {
 
     @Override
     public String buildCompliancePrompt(Long companyId, String industryType) {
-        List<LobsterComplianceRule> rules = complianceRuleMapper.selectEnabledByCompanyId(companyId);
+        if (contextCache != null && companyId != null) {
+            return contextCache.getCompliancePrompt(companyId, industryType,
+                    () -> buildCompliancePromptFromDb(companyId, industryType));
+        }
+        return buildCompliancePromptFromDb(companyId, industryType);
+    }
+
+    private String buildCompliancePromptFromDb(Long companyId, String industryType) {
+        List<LobsterComplianceRule> rules = loadEnabledRules(companyId);
         if (rules.isEmpty()) {
             return "";
         }
@@ -126,7 +138,7 @@ public class ComplianceServiceImpl implements ComplianceService {
 
         ensureTable();
 
-        List<LobsterComplianceRule> rules = complianceRuleMapper.selectEnabledByCompanyId(companyId);
+        List<LobsterComplianceRule> rules = loadEnabledRules(companyId);
 
         if (industryType != null && !industryType.isEmpty()) {
             rules = rules.stream().filter(r -> {
@@ -177,6 +189,14 @@ public class ComplianceServiceImpl implements ComplianceService {
         return ComplianceCheckResult.ok();
     }
 
+    private List<LobsterComplianceRule> loadEnabledRules(Long companyId) {
+        if (contextCache != null && companyId != null) {
+            return contextCache.getComplianceRules(companyId,
+                    () -> complianceRuleMapper.selectEnabledByCompanyId(companyId));
+        }
+        return complianceRuleMapper.selectEnabledByCompanyId(companyId);
+    }
+
     private String matchRule(LobsterComplianceRule rule, String content) {
         String pattern = rule.getPattern();
         if (pattern == null || pattern.isEmpty()) return null;

+ 148 - 34
fs-service/src/main/java/com/fs/company/service/workflow/impl/ContextAssemblerImpl.java

@@ -10,12 +10,15 @@ import com.fs.company.service.ICompanyKnowledgeBaseService;
 import com.fs.company.service.workflow.ComplianceService;
 import com.fs.company.service.workflow.ContextAssembler;
 import com.fs.company.service.workflow.SummaryGenerator;
+import com.fs.company.service.workflow.performance.ParallelExecutor;
+import com.fs.company.service.workflow.cache.LobsterContextCacheService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.util.*;
+import java.util.function.Supplier;
 
 @Service
 public class ContextAssemblerImpl implements ContextAssembler {
@@ -60,6 +63,15 @@ public class ContextAssemblerImpl implements ContextAssembler {
     @Autowired(required = false)
     private com.fs.company.service.workflow.vector.VectorPatternMatcher vectorPatternMatcher;
 
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.learning.TenantLearningEngine tenantLearningEngine;
+
+    @Autowired(required = false)
+    private ParallelExecutor parallelExecutor;
+
+    @Autowired(required = false)
+    private LobsterContextCacheService contextCache;
+
     @Override
     public String assembleContext(Long instanceId, int maxTokens) {
         LobsterWorkflowInstance instance = instanceMapper.selectByIdAndCompanyId(instanceId, null);
@@ -124,49 +136,84 @@ public class ContextAssemblerImpl implements ContextAssembler {
         context.put("currentNodeCode", currentNodeCode);
         context.put("customerMessage", customerMessage);
 
-        // 1. 客户画像(长期记忆)
-        Map<String, Object> userProfile = loadUserProfile(externalUserId, companyId);
-        context.put("userProfile", userProfile);
-
-        // 2. 对话摘要(压缩记忆)
-        String conversationSummary = summaryGenerator.getCachedSummary(externalUserId, instanceId);
-        context.put("conversationSummary", conversationSummary);
-
-        // 3. 最近5条对话(短期记忆)
-        List<Map<String, Object>> recentChats = loadRecentChats(externalUserId, instanceId, 5);
-        context.put("recentChats", recentChats);
-
-        // 4. 工作流变量(过程记忆)
         Map<String, Object> variables = getContextVariables(instanceId);
         context.put("variables", variables);
 
-        // 5. 知识库检索
-        if (customerMessage != null && !customerMessage.isEmpty()) {
-            List<Map<String, Object>> relevantKnowledge = searchKnowledgeBase(customerMessage, companyId, instanceId, externalUserId);
-            context.put("relevantKnowledge", relevantKnowledge);
+        long loadStart = System.currentTimeMillis();
+        if (parallelExecutor != null) {
+            Map<String, Supplier<Object>> tasks = new LinkedHashMap<>();
+            tasks.put("userProfile", () -> loadUserProfile(externalUserId, companyId));
+            tasks.put("conversationSummary", () -> summaryGenerator.getCachedSummary(externalUserId, instanceId));
+            tasks.put("recentChats", () -> loadRecentChats(externalUserId, instanceId, 5));
+            tasks.put("historicalFacts", () -> loadCrossInstanceFacts(externalUserId, companyId));
+            tasks.put("lastState", () -> loadLastDialogueState(externalUserId, companyId));
+            if (customerMessage != null && !customerMessage.isEmpty()) {
+                tasks.put("relevantKnowledge",
+                        () -> searchKnowledgeBase(customerMessage, companyId, instanceId, externalUserId));
+            }
+            if (tenantLearningEngine != null && companyId != null) {
+                tasks.put("learnedStrategies", () -> loadLearnedStrategies(companyId, currentNodeCode, customerMessage));
+            }
+            Map<String, Object> loaded = parallelExecutor.executeAll(tasks, 5);
+            context.put("userProfile", loaded.getOrDefault("userProfile", new HashMap<>()));
+            context.put("conversationSummary", loaded.getOrDefault("conversationSummary", ""));
+            context.put("recentChats", loaded.getOrDefault("recentChats", new ArrayList<>()));
+            context.put("historicalFacts", loaded.getOrDefault("historicalFacts", new ArrayList<>()));
+            context.put("lastState", loaded.getOrDefault("lastState", new HashMap<>()));
+            context.put("relevantKnowledge", loaded.getOrDefault("relevantKnowledge", new ArrayList<>()));
+            if (loaded.containsKey("learnedStrategies")) {
+                context.put("learnedStrategies", loaded.get("learnedStrategies"));
+            }
         } else {
-            context.put("relevantKnowledge", new ArrayList<>());
+            context.put("userProfile", loadUserProfile(externalUserId, companyId));
+            context.put("conversationSummary", summaryGenerator.getCachedSummary(externalUserId, instanceId));
+            context.put("recentChats", loadRecentChats(externalUserId, instanceId, 5));
+            context.put("historicalFacts", loadCrossInstanceFacts(externalUserId, companyId));
+            context.put("lastState", loadLastDialogueState(externalUserId, companyId));
+            if (customerMessage != null && !customerMessage.isEmpty()) {
+                context.put("relevantKnowledge", searchKnowledgeBase(customerMessage, companyId, instanceId, externalUserId));
+            } else {
+                context.put("relevantKnowledge", new ArrayList<>());
+            }
+            if (tenantLearningEngine != null && companyId != null) {
+                context.put("learnedStrategies", loadLearnedStrategies(companyId, currentNodeCode, customerMessage));
+            }
         }
+        logger.debug("[ContextAssembler] 并行加载耗时: {}ms", System.currentTimeMillis() - loadStart);
 
-        // 6. 合规约束
+        // 合规约束(轻量,同步即可)
         String compliancePrompt = complianceService.buildCompliancePrompt(companyId, null);
         context.put("compliancePrompt", compliancePrompt);
 
-        // 7. 跨实例长期事实记忆(P0 新增)
-        List<Map<String, Object>> historicalFacts = loadCrossInstanceFacts(externalUserId, companyId);
-        context.put("historicalFacts", historicalFacts);
-
-        // 8. 断点续聊:上次对话中断位置
-        Map<String, Object> lastState = loadLastDialogueState(externalUserId, companyId);
-        context.put("lastState", lastState);
-
         logger.debug("[ContextAssembler] 上下文组装完成: user={}, vars={}, facts={}, summaryLength={}",
-                externalUserId, variables.size(), historicalFacts.size(),
-                conversationSummary != null ? conversationSummary.length() : 0);
+                externalUserId, variables.size(),
+                context.get("historicalFacts") instanceof List ? ((List<?>) context.get("historicalFacts")).size() : 0,
+                context.get("conversationSummary") != null ? context.get("conversationSummary").toString().length() : 0);
 
         return context;
     }
 
+    private List<com.fs.company.service.workflow.learning.StrategyRecommendation> loadLearnedStrategies(
+            Long companyId, String currentNodeCode, String customerMessage) {
+        if (contextCache != null) {
+            return contextCache.getLearnedStrategies(companyId, currentNodeCode, () -> fetchLearnedStrategies(companyId, currentNodeCode, customerMessage));
+        }
+        return fetchLearnedStrategies(companyId, currentNodeCode, customerMessage);
+    }
+
+    private List<com.fs.company.service.workflow.learning.StrategyRecommendation> fetchLearnedStrategies(
+            Long companyId, String currentNodeCode, String customerMessage) {
+        Map<String, Object> scenarioCtx = new HashMap<>();
+        scenarioCtx.put("nodeCode", currentNodeCode);
+        scenarioCtx.put("customerMessage", customerMessage);
+        List<com.fs.company.service.workflow.learning.StrategyRecommendation> strategies =
+                tenantLearningEngine.recommendStrategies(companyId, currentNodeCode, scenarioCtx);
+        if (strategies == null || strategies.isEmpty()) {
+            strategies = tenantLearningEngine.recommendStrategies(companyId, "strategy", scenarioCtx);
+        }
+        return strategies != null ? strategies : Collections.emptyList();
+    }
+
     @Override
     public String buildAiPrompt(Map<String, Object> context) {
         StringBuilder prompt = new StringBuilder();
@@ -225,10 +272,10 @@ public class ContextAssemblerImpl implements ContextAssembler {
         }
 
         // 2.6 客户沟通习惯
-        @SuppressWarnings("unchecked")
+        Long habitCompanyId = context.get("companyId") instanceof Number
+                ? ((Number) context.get("companyId")).longValue() : null;
         Map<String, String> habits = loadCustomerHabit(
-            (String) context.get("externalUserId"),
-            context.get("companyId") instanceof Number ? ((Number) context.get("companyId")).longValue() : null);
+            (String) context.get("externalUserId"), habitCompanyId);
         if (!habits.isEmpty()) {
             prompt.append("【客户沟通习惯】\n");
             habits.forEach((k, v) -> prompt.append("- ").append(k).append(": ").append(v).append("\n"));
@@ -247,6 +294,19 @@ public class ContextAssemblerImpl implements ContextAssembler {
             prompt.append("请从中断点自然继续对话。\n\n");
         }
 
+        // 2.8 租户学习策略(自进化 Skill 注入)
+        @SuppressWarnings("unchecked")
+        List<com.fs.company.service.workflow.learning.StrategyRecommendation> strategies =
+                (List<com.fs.company.service.workflow.learning.StrategyRecommendation>) context.get("learnedStrategies");
+        if (strategies != null && !strategies.isEmpty()) {
+            prompt.append("【租户学习策略(历史高置信模式)】\n");
+            for (com.fs.company.service.workflow.learning.StrategyRecommendation s : strategies) {
+                prompt.append("- [置信度").append(String.format("%.0f%%", s.getConfidence() * 100)).append("] ")
+                        .append(s.getStrategyContent()).append("\n");
+            }
+            prompt.append("请在回复中自然融入以上策略,但不要生硬引用。\n\n");
+        }
+
         // 3. 工作流变量
         @SuppressWarnings("unchecked")
         Map<String, Object> vars = (Map<String, Object>) context.get("variables");
@@ -347,6 +407,13 @@ public class ContextAssemblerImpl implements ContextAssembler {
     @SuppressWarnings("unchecked")
     private Map<String, Object> loadUserProfile(String externalUserId, Long companyId) {
         if (userProfileMapper == null) return new HashMap<>();
+        if (contextCache != null && companyId != null) {
+            return contextCache.getUserProfile(companyId, externalUserId, () -> fetchUserProfile(externalUserId, companyId));
+        }
+        return fetchUserProfile(externalUserId, companyId);
+    }
+
+    private Map<String, Object> fetchUserProfile(String externalUserId, Long companyId) {
         try {
             ensureUserProfile(externalUserId, companyId);
             Map<String, Object> profile = userProfileMapper.selectByUser(companyId, externalUserId);
@@ -373,6 +440,20 @@ public class ContextAssemblerImpl implements ContextAssembler {
                 logger.warn("[ContextAssembler] 无法获取实例对应的租户ID, instanceId={}", instanceId);
                 return new ArrayList<>();
             }
+            if (contextCache != null) {
+                return contextCache.getRecentChats(companyId, externalUserId, instanceId, limit,
+                        () -> fetchRecentChats(externalUserId, instanceId, companyId, limit));
+            }
+            return fetchRecentChats(externalUserId, instanceId, companyId, limit);
+        } catch (Exception e) {
+            logger.debug("[ContextAssembler] 加载最近对话失败: {}", e.getMessage());
+            return new ArrayList<>();
+        }
+    }
+
+    private List<Map<String, Object>> fetchRecentChats(String externalUserId, Long instanceId,
+                                                        Long companyId, int limit) {
+        try {
             return chatRecordMapper.selectRecentChats(externalUserId, instanceId, companyId, limit);
         } catch (Exception e) {
             logger.debug("[ContextAssembler] 加载最近对话失败: {}", e.getMessage());
@@ -381,6 +462,15 @@ public class ContextAssemblerImpl implements ContextAssembler {
     }
 
     private List<Map<String, Object>> searchKnowledgeBase(String query, Long companyId, Long instanceId, String externalUserId) {
+        if (contextCache != null && companyId != null && query != null) {
+            List<Map<String, Object>> cached = contextCache.getKnowledgeSearch(companyId, query,
+                    () -> doSearchKnowledgeBase(query, companyId, instanceId, externalUserId));
+            return cached != null ? cached : new ArrayList<>();
+        }
+        return doSearchKnowledgeBase(query, companyId, instanceId, externalUserId);
+    }
+
+    private List<Map<String, Object>> doSearchKnowledgeBase(String query, Long companyId, Long instanceId, String externalUserId) {
         List<Map<String, Object>> results = new ArrayList<>();
         try {
             // 优先:向量语义检索
@@ -458,6 +548,14 @@ public class ContextAssemblerImpl implements ContextAssembler {
     /** 跨实例长期事实记忆:查询该客户所有历史实例的关键事实 */
     private List<Map<String, Object>> loadCrossInstanceFacts(String externalUserId, Long companyId) {
         if (customerFactMapper == null) return new ArrayList<>();
+        if (contextCache != null && companyId != null) {
+            return contextCache.getHistoricalFacts(companyId, externalUserId,
+                    () -> fetchCrossInstanceFacts(externalUserId, companyId));
+        }
+        return fetchCrossInstanceFacts(externalUserId, companyId);
+    }
+
+    private List<Map<String, Object>> fetchCrossInstanceFacts(String externalUserId, Long companyId) {
         try {
             return customerFactMapper.selectByUser(companyId, externalUserId);
         } catch (Exception e) {
@@ -468,8 +566,16 @@ public class ContextAssemblerImpl implements ContextAssembler {
 
     /** 加载客户沟通习惯维度 */
     private Map<String, String> loadCustomerHabit(String externalUserId, Long companyId) {
+        if (companyId == null || customerHabitMapper == null) return new LinkedHashMap<>();
+        if (contextCache != null) {
+            return contextCache.getCustomerHabits(companyId, externalUserId,
+                    () -> fetchCustomerHabit(externalUserId, companyId));
+        }
+        return fetchCustomerHabit(externalUserId, companyId);
+    }
+
+    private Map<String, String> fetchCustomerHabit(String externalUserId, Long companyId) {
         Map<String, String> result = new LinkedHashMap<>();
-        if (companyId == null || customerHabitMapper == null) return result;
         try {
             List<Map<String, Object>> rows = customerHabitMapper.selectByUser(companyId, externalUserId);
             for (Map<String, Object> r : rows) {
@@ -492,13 +598,21 @@ public class ContextAssemblerImpl implements ContextAssembler {
     /** 断点续聊:读取该用户最新一条对话状态 */
     private Map<String, Object> loadLastDialogueState(String externalUserId, Long companyId) {
         if (dialogueStateMapper == null) return Collections.emptyMap();
+        if (contextCache != null && companyId != null) {
+            return contextCache.getDialogueState(companyId, externalUserId,
+                    () -> fetchLastDialogueState(externalUserId, companyId));
+        }
+        return fetchLastDialogueState(externalUserId, companyId);
+    }
+
+    private Map<String, Object> fetchLastDialogueState(String externalUserId, Long companyId) {
         try {
             Map<String, Object> state = dialogueStateMapper.selectLatest(companyId, externalUserId);
             if (state != null && !state.isEmpty()) {
                 Object updateTime = state.get("update_time");
                 if (updateTime != null) {
                     long gap = System.currentTimeMillis() - ((java.sql.Timestamp) updateTime).getTime();
-                    if (gap > 7 * 24 * 3600_000L) return Collections.emptyMap(); // 超过7天视为新对话
+                    if (gap > MAX_DIALOGUE_STATE_AGE_MS) return Collections.emptyMap();
                 }
                 return state;
             }

+ 40 - 14
fs-service/src/main/java/com/fs/company/service/workflow/impl/DynamicNodeExecutorImpl.java

@@ -368,7 +368,7 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
             request.setCompanyId(context.getCompanyId());
             request.setContactId(context.getCustomerId());
             request.setContent(message);
-            request.setChannelType("qw");
+            request.setChannelType("QW");
             request.setExtra(context.getVariables());
             
             MessageChannelResult channelResult = messageChannelRouter.route(request);
@@ -546,8 +546,22 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
             JSONObject config = parseConfig(nodeConfig);
             String reason = config != null ? config.getString("promotionReason") : null;
             if (reason == null) reason = config != null ? config.getString("reason") : "促单阶段结束";
+            String outcome = config != null ? config.getString("outcome") : "ended";
             Map<String, Object> outputs = new HashMap<>();
             outputs.put("promotionEndReason", reason);
+            outputs.put("promotionOutcome", outcome);
+            outputs.put("promotionEndedAt", System.currentTimeMillis());
+            if (auxMapper != null && context.getCompanyId() != null) {
+                try {
+                    auxMapper.update(String.format(
+                            "INSERT INTO lobster_promotion_log(company_id, instance_id, customer_id, outcome, reason, create_time) " +
+                            "VALUES(%d, %d, '%s', '%s', '%s', NOW())",
+                            context.getCompanyId(),
+                            context.getWorkflowInstanceId() != null ? context.getWorkflowInstanceId() : 0,
+                            sqlEscape(context.getCustomerId()),
+                            sqlEscape(outcome), sqlEscape(reason)));
+                } catch (Exception e) { logger.debug("promotion log: {}", e.getMessage()); }
+            }
             NodeExecutionResult r = NodeExecutionResult.success(outputs);
             r.setMessageToSend(reason);
             return r;
@@ -1294,7 +1308,7 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
             request.setCompanyId(context.getCompanyId());
             request.setContactId(context.getCustomerId());
             request.setContent(message);
-            request.setChannelType("sms");
+            request.setChannelType("SMS");
             request.setExtra(context.getVariables());
             if (phone != null) {
                 Map<String, Object> extra = request.getExtra() != null ? new HashMap<>(request.getExtra()) : new HashMap<>();
@@ -1319,22 +1333,34 @@ public class DynamicNodeExecutorImpl implements DynamicNodeExecutor {
             String subject = config != null ? config.getString("subject") : "通知";
             String body = config != null ? config.getString("bodyTemplate") : "";
             body = substituteVariables(body, context);
+            String emailTo = config != null ? config.getString("emailTo") : null;
+            if (emailTo == null && context.getVariables() != null && context.getVariables().get("email") != null) {
+                emailTo = context.getVariables().get("email").toString();
+            }
             Map<String, Object> outputs = new HashMap<>();
             outputs.put("emailSubject", subject);
             outputs.put("emailSent", false);
-            if (auxMapper != null && context.getCompanyId() != null) {
-                try {
-                    auxMapper.update(String.format(
-                            "INSERT INTO lobster_email_log(company_id, customer_id, subject, body, create_time) " +
-                            "VALUES(%d, '%s', '%s', '%s', NOW())",
-                            context.getCompanyId(),
-                            sqlEscape(context.getCustomerId()),
-                            sqlEscape(subject), sqlEscape(body)));
-                    outputs.put("emailSent", true);
-                } catch (Exception e) { logger.debug("email log: {}", e.getMessage()); }
-            }
-            NodeExecutionResult r = NodeExecutionResult.success(outputs);
+
+            MessageChannelRequest request = new MessageChannelRequest();
+            request.setCompanyId(context.getCompanyId());
+            request.setContactId(context.getCustomerId());
+            request.setContent(body);
+            request.setChannelType("EMAIL");
+            Map<String, Object> extra = context.getVariables() != null ? new HashMap<>(context.getVariables()) : new HashMap<>();
+            extra.put("emailSubject", subject);
+            if (emailTo != null) extra.put("emailTo", emailTo);
+            request.setExtra(extra);
+            if (emailTo != null) request.setChannelUserId(emailTo);
+
+            MessageChannelResult channelResult = messageChannelRouter.route(request);
+            outputs.put("emailSent", channelResult.isSuccess());
+            if (!channelResult.isSuccess()) outputs.put("emailError", channelResult.getErrorMsg());
+
+            NodeExecutionResult r = new NodeExecutionResult();
+            r.setSuccess(channelResult.isSuccess());
+            r.setOutputVariables(outputs);
             r.setMessageToSend(body);
+            if (!channelResult.isSuccess()) r.setErrorMessage(channelResult.getErrorMsg());
             return r;
         } catch (Exception e) {
             return NodeExecutionResult.fail("邮件节点失败: " + e.getMessage());

+ 98 - 29
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterEvolutionEngineImpl.java

@@ -4,6 +4,8 @@ import com.alibaba.fastjson.JSON;
 import com.fs.company.service.llm.MultiModelRouter;
 import com.fs.company.service.workflow.*;
 import com.fs.company.service.workflow.identity.IdentityHidingService;
+import com.fs.company.service.workflow.performance.LobsterLatencyPolicy;
+import com.fs.company.service.workflow.cache.LobsterContextCacheService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -99,11 +101,28 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
     @Autowired(required = false)
     private ProfileEnrichmentService profileEnrichmentService;
 
+    @Autowired(required = false)
+    private LobsterLatencyPolicy latencyPolicy;
+
+    @Autowired(required = false)
+    private LobsterContextCacheService contextCache;
+
+    private static final java.util.concurrent.ExecutorService POST_PROCESS_EXECUTOR =
+            java.util.concurrent.Executors.newFixedThreadPool(
+                    Math.max(2, Runtime.getRuntime().availableProcessors() / 2),
+                    r -> {
+                        Thread t = new Thread(r, "lobster-post-process");
+                        t.setDaemon(true);
+                        return t;
+                    });
+
     @Override
     public EvolutionResult evolve(Long instanceId, Long companyId, String externalUserId,
                                   String customerMessage, String currentNodeCode) {
         EvolutionResult result = new EvolutionResult();
         long startTime = System.currentTimeMillis();
+        java.util.LinkedHashMap<String, Long> stepMs = new java.util.LinkedHashMap<>();
+        long stepStart = startTime;
 
         try {
             // Step 1: 检查是否多轮对话节点
@@ -127,19 +146,26 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
             // Step 2: 组装完整上下文(9源整合)
             Map<String, Object> context = contextAssembler.assembleFullContext(
                     instanceId, companyId, externalUserId, customerMessage, currentNodeCode);
-
-            // Step 2.5: 自动变量提取(从对话中抽取结构化KV)+ 全局摘要注入
-            Map<String, Object> autoVars = summaryGenerator.extractConversationVariables(
-                    companyId, externalUserId, customerMessage != null ? customerMessage : "",
-                    (String) context.getOrDefault("extractFieldsHint", null));
-            if (autoVars != null && !autoVars.isEmpty()) {
-                context.put("autoExtractedVars", autoVars);
-                variableStore.setVariables(instanceId, autoVars);
+            stepMs.put("context", System.currentTimeMillis() - stepStart);
+            stepStart = System.currentTimeMillis();
+
+            // Step 2.5: 变量提取 — quality 模式同步 LLM,其余模式跳过(延迟优化)
+            if (latencyPolicy == null || !latencyPolicy.skipSyncVariableExtraction(companyId)) {
+                Map<String, Object> autoVars = summaryGenerator.extractConversationVariables(
+                        companyId, externalUserId, customerMessage != null ? customerMessage : "",
+                        (String) context.getOrDefault("extractFieldsHint", null));
+                if (autoVars != null && !autoVars.isEmpty()) {
+                    context.put("autoExtractedVars", autoVars);
+                    variableStore.setVariables(instanceId, autoVars);
+                }
             }
-            String globalSummary = summaryGenerator.generateGlobalSummary(companyId, externalUserId);
+            String globalSummary = summaryGenerator.generateGlobalSummary(companyId, externalUserId,
+                    latencyPolicy != null && latencyPolicy.skipGlobalSummaryLlm(companyId));
             if (globalSummary != null && !globalSummary.isEmpty() && !"暂无全局摘要".equals(globalSummary)) {
                 context.put("globalSummary", globalSummary);
             }
+            stepMs.put("enrich", System.currentTimeMillis() - stepStart);
+            stepStart = System.currentTimeMillis();
 
             // Step 2.8: 千人千面行为变量富化(链路打开/图片点击/打字速度/回复延迟)
             if (customerMessage != null) {
@@ -162,10 +188,15 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
                 }
             }
 
-            // Step 3: 动态节点调节
+            // Step 3: 动态节点调节(传入 companyId 供语义快路径)
             Map<String, Object> currentVars = variableStore.getAllVariables(instanceId);
+            if (currentVars != null) {
+                currentVars.put("companyId", companyId);
+            }
             DynamicNodeAdjuster.AdjustmentResult adjustment = dynamicNodeAdjuster.adjustNode(
                     instanceId, companyId, externalUserId, customerMessage, currentNodeCode, currentVars);
+            stepMs.put("semantic", System.currentTimeMillis() - stepStart);
+            stepStart = System.currentTimeMillis();
 
             result.setDetectedIntent(adjustment.getDetectedIntent());
             result.setDetectedSentiment(adjustment.getDetectedSentiment());
@@ -190,9 +221,10 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
                 return result;
             }
 
-            // Step 3.5: 千人千面 — 当无匹配节点时动态生成并插入新节点
-            if (adjustment.getNextNodeCode() == null
-                    || "默认顺序执行".equals(adjustment.getAdjustmentReason())) {
+            // Step 3.5: 动态节点生成 — 仅 quality 模式启用(避免额外 LLM)
+            if ((latencyPolicy == null || !latencyPolicy.skipDynamicNodeGeneration(companyId))
+                    && (adjustment.getNextNodeCode() == null
+                    || "默认顺序执行".equals(adjustment.getAdjustmentReason()))) {
                 @SuppressWarnings("unchecked")
                 Map<String, Object> profile = (Map<String, Object>) context.get("userProfile");
                 String dynamicNode = enrichWithDynamicNode(instanceId, companyId, externalUserId,
@@ -213,6 +245,8 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
 
             // Step 5: AI生成回复
             String aiReply = generateAiReply(fullPrompt.toString(), systemPrompt, instructionPrompt);
+            stepMs.put("generate", System.currentTimeMillis() - stepStart);
+            stepStart = System.currentTimeMillis();
             if (aiReply == null || aiReply.trim().isEmpty()) {
                 aiReply = promptManager.getPrompt(companyId, "", currentNodeCode, "fallback");
             }
@@ -236,6 +270,8 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
             String conversationGoal = adjustment.getDetectedIntent();
             QualityScoringService.ScoringResult scoringResult = qualityScoringService.scoreWithRetry(
                     companyId, aiReply, customerMessage, knowledgeBase, conversationGoal, null);
+            stepMs.put("quality", System.currentTimeMillis() - stepStart);
+            stepStart = System.currentTimeMillis();
 
             result.setQualityPassed(scoringResult.isFinalPassed());
             result.setQualityScore(scoringResult.isRegenerated() ? scoringResult.getSecondScore() : scoringResult.getFirstScore());
@@ -248,6 +284,8 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
             // Step 8: 合规检查(严重度分级处理)
             ComplianceService.ComplianceCheckResult complianceResult =
                     complianceService.checkAiReplyCompliance(finalReply, companyId, null);
+            stepMs.put("compliance", System.currentTimeMillis() - stepStart);
+
             result.setCompliancePassed(complianceResult.isCompliant());
 
             if (!complianceResult.isCompliant()) {
@@ -291,15 +329,29 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
             summaryGenerator.generateSummaryAsync(instanceId, externalUserId, companyId);
 
             result.setReply(finalReply);
-            logEvolution(instanceId, startTime, "normal", result);
-
-            // 千人千面:学习客户习惯 + 写入 dialogue_state + 多渠道画像融合
-            learnCustomerHabit(companyId, externalUserId, customerMessage, finalReply, System.currentTimeMillis() - startTime);
-            writeDialogueState(instanceId, companyId, externalUserId, currentNodeCode,
-                adjustment.getDetectedIntent(), adjustment.getDetectedSentiment(), finalReply);
-            if (profileEnrichmentService != null) {
-                profileEnrichmentService.enrichProfile(externalUserId, companyId);
-            }
+            logEvolution(instanceId, startTime, "normal", result, stepMs, companyId);
+
+            // 后置学习/画像写入异步化,不阻塞回复
+            final String replySnapshot = finalReply;
+            final String intentSnapshot = adjustment.getDetectedIntent();
+            final String sentimentSnapshot = adjustment.getDetectedSentiment();
+            final long responseMs = System.currentTimeMillis() - startTime;
+            POST_PROCESS_EXECUTOR.submit(() -> {
+                try {
+                    learnCustomerHabit(companyId, externalUserId, customerMessage, replySnapshot, responseMs);
+                    writeDialogueState(instanceId, companyId, externalUserId, currentNodeCode,
+                            intentSnapshot, sentimentSnapshot, replySnapshot);
+                    if (profileEnrichmentService != null) {
+                        profileEnrichmentService.enrichProfile(externalUserId, companyId);
+                    }
+                    if (contextCache != null) {
+                        contextCache.invalidateAfterDialogue(companyId, externalUserId, instanceId);
+                        contextCache.invalidateUserProfile(companyId, externalUserId);
+                    }
+                } catch (Exception e) {
+                    logger.debug("[LobsterEvolution] post-process async failed: {}", e.getMessage());
+                }
+            });
 
         } catch (Exception e) {
             logger.error("[LobsterEvolution] 进化引擎异常: instanceId={}, error={}", instanceId, e.getMessage());
@@ -337,14 +389,31 @@ public class LobsterEvolutionEngineImpl implements LobsterEvolutionEngine {
     }
 
     private void logEvolution(Long instanceId, long startTime, String mode, EvolutionResult result) {
+        logEvolution(instanceId, startTime, mode, result, null, null);
+    }
+
+    private void logEvolution(Long instanceId, long startTime, String mode, EvolutionResult result,
+                              java.util.Map<String, Long> stepMs, Long companyId) {
         long duration = System.currentTimeMillis() - startTime;
-        logger.info("[LobsterEvolution] 完成: instanceId={}, mode={}, duration={}ms, " +
-                        "quality={}, score={}, compliance={}, intent={}, sentiment={}, regenerated={}, human={}",
-                instanceId, mode, duration,
-                result.isQualityPassed(), result.getQualityScore(),
-                result.isCompliancePassed(), result.getDetectedIntent(),
-                result.getDetectedSentiment(), result.isRegenerated(),
-                result.isTransferredToHuman());
+        String latencyMode = latencyPolicy != null && companyId != null
+                ? latencyPolicy.resolveMode(companyId).name().toLowerCase() : "default";
+        if (stepMs != null && !stepMs.isEmpty()) {
+            logger.info("[LobsterEvolution] 完成: instanceId={}, mode={}, latencyMode={}, duration={}ms, steps={}, " +
+                            "quality={}, score={}, compliance={}, intent={}, sentiment={}, regenerated={}, human={}",
+                    instanceId, mode, latencyMode, duration, stepMs,
+                    result.isQualityPassed(), result.getQualityScore(),
+                    result.isCompliancePassed(), result.getDetectedIntent(),
+                    result.getDetectedSentiment(), result.isRegenerated(),
+                    result.isTransferredToHuman());
+        } else {
+            logger.info("[LobsterEvolution] 完成: instanceId={}, mode={}, duration={}ms, " +
+                            "quality={}, score={}, compliance={}, intent={}, sentiment={}, regenerated={}, human={}",
+                    instanceId, mode, duration,
+                    result.isQualityPassed(), result.getQualityScore(),
+                    result.isCompliancePassed(), result.getDetectedIntent(),
+                    result.getDetectedSentiment(), result.isRegenerated(),
+                    result.isTransferredToHuman());
+        }
     }
 
     /**

+ 30 - 0
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterIntegrationTestServiceImpl.java

@@ -203,6 +203,36 @@ public class LobsterIntegrationTestServiceImpl implements LobsterIntegrationTest
             if (dynamicNodeExecutor.supports(1)) {
                 details.add("✓ Supports check for known node type");
             }
+
+            // 测试等待节点
+            Map<String, Object> waitCfg = new HashMap<>();
+            waitCfg.put("waitSeconds", 30);
+            result = dynamicNodeExecutor.execute(4, JSON.toJSONString(waitCfg), context);
+            if (result.isSuccess() && result.getOutputVariables() != null
+                    && result.getOutputVariables().containsKey("waitUntil")) {
+                details.add("✓ Wait node execution successful");
+            } else {
+                throw new Exception("Wait node execution failed");
+            }
+
+            // 测试变量赋值节点
+            Map<String, Object> varCfg = new HashMap<>();
+            varCfg.put("variableName", "level");
+            varCfg.put("variableValue", "VIP");
+            result = dynamicNodeExecutor.execute(40, JSON.toJSONString(varCfg), context);
+            if (result.isSuccess() && "VIP".equals(result.getOutputVariables().get("level"))) {
+                details.add("✓ Variable assign node execution successful");
+            } else {
+                throw new Exception("Variable assign node execution failed");
+            }
+
+            // 测试促单结束节点
+            result = dynamicNodeExecutor.execute(6, "{\"reason\":\"测试结束\"}", context);
+            if (result.isSuccess() && result.getMessageToSend() != null) {
+                details.add("✓ Promotion end node execution successful");
+            } else {
+                throw new Exception("Promotion end node execution failed");
+            }
             
             long executionTime = System.currentTimeMillis() - startTime;
             

+ 58 - 6
fs-service/src/main/java/com/fs/company/service/workflow/impl/LobsterWorkflowExecutorImpl.java

@@ -43,6 +43,10 @@ import com.fs.company.service.workflow.prompt.SystemPromptService;
 import com.fs.company.service.workflow.queue.DeadLetterQueue;
 import com.fs.company.service.workflow.impl.DuplicateReplyDetector;
 import com.fs.company.service.workflow.channel.ChannelTypeRegistry;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.domain.QwSession;
+import com.fs.qw.mapper.QwExternalContactMapper;
+import com.fs.qw.mapper.QwSessionMapper;
 import com.fs.company.service.workflow.semantic.ConversationMessage;
 import com.fs.company.service.workflow.semantic.SemanticAnalyzer;
 import com.fs.company.service.workflow.semantic.SemanticResult;
@@ -133,6 +137,12 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
     @Autowired(required = false)
     private LobsterChatMsgMapper chatMsgMapper;
 
+    @Autowired(required = false)
+    private QwSessionMapper qwSessionMapper;
+
+    @Autowired(required = false)
+    private QwExternalContactMapper qwExternalContactMapper;
+
     @Value("${ai.multi-model.default:doubao-lite}")
     private String defaultAiModel;
 
@@ -250,6 +260,9 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
     @Autowired(required = false)
     private com.fs.company.service.workflow.api.SmartApiCallNodeExecutor smartApiCallNodeExecutor;
 
+    @Autowired(required = false)
+    private com.fs.company.service.workflow.learning.TenantLearningEngine tenantLearningEngine;
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public AjaxResult executeNextNode(Long companyId, Long instanceId, String customerReply) {
@@ -818,6 +831,33 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
         }
     }
 
+    /** 为企微通道补充 qwExtId / qwSessionId,供 QwMessageChannel 解析会话 */
+    private void enrichQwExtra(Long companyId, Long contactId, String channelType, Map<String, Object> extra) {
+        if (!"QW".equalsIgnoreCase(channelType) || contactId == null || extra == null) return;
+        extra.put("qwExtId", contactId);
+        if (extra.containsKey("sessionId") || extra.containsKey("qwSessionId")) return;
+        if (qwSessionMapper == null || qwExternalContactMapper == null) return;
+        try {
+            QwExternalContact contact = qwExternalContactMapper.selectQwExternalContactById(contactId);
+            if (contact == null) return;
+            Long qwUserDbId = null;
+            if (extra.get("qwUserId") != null) {
+                qwUserDbId = Long.valueOf(extra.get("qwUserId").toString());
+            } else if (contact.getQwUserId() != null) {
+                qwUserDbId = contact.getQwUserId();
+                extra.putIfAbsent("qwUserId", qwUserDbId);
+            }
+            if (qwUserDbId != null) {
+                QwSession session = qwSessionMapper.selectQwSessionByExtIdAndQwUserId(contactId, qwUserDbId);
+                if (session != null) {
+                    extra.put("qwSessionId", session.getSessionId());
+                }
+            }
+        } catch (Exception e) {
+            logger.debug("[QW] enrichQwExtra: {}", e.getMessage());
+        }
+    }
+
     private MessageChannelResult deliverMessage(Long companyId, Long contactId, String channelType,
                                                  String message, Map<String, Object> variables,
                                                  Long instanceId, Long workflowId) {
@@ -845,13 +885,17 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
 
             if (contactInfo != null) {
                 request.setChannelUserId(contactInfo.getChannelUserId());
-                if (contactInfo.getExtra() != null) {
-                    Map<String, Object> extra = new HashMap<>(contactInfo.getExtra());
-                    if (variables != null) {
-                        extra.putAll(variables);
-                    }
-                    request.setExtra(extra);
+                Map<String, Object> extra = contactInfo.getExtra() != null
+                        ? new HashMap<>(contactInfo.getExtra()) : new HashMap<>();
+                if (variables != null) {
+                    extra.putAll(variables);
                 }
+                enrichQwExtra(companyId, contactId, channelType, extra);
+                request.setExtra(extra);
+            } else if ("QW".equalsIgnoreCase(channelType) && contactId != null) {
+                Map<String, Object> extra = variables != null ? new HashMap<>(variables) : new HashMap<>();
+                enrichQwExtra(companyId, contactId, channelType, extra);
+                request.setExtra(extra);
             }
 
             MessageChannelResult sendResult = messageChannelRouter.route(request);
@@ -1029,6 +1073,14 @@ public class LobsterWorkflowExecutorImpl implements LobsterWorkflowExecutor {
             context.setOutcome(mapCommercialOutcome(evo.getDetectedIntent(), customerReply));
             context.setVariables(variables);
             evolutionEngine.recordInteraction(context);
+
+            if (tenantLearningEngine != null) {
+                tenantLearningEngine.recordEvolutionInteraction(
+                        companyId, instance.getWorkflowId(), instance.getId(),
+                        instance.getCurrentNodeName(), customerReply, evo.getReply(),
+                        evo.getQualityScore() > 0 ? evo.getQualityScore() : null,
+                        context.getOutcome(), variables);
+            }
         } catch (Exception e) {
             logger.debug("[LobsterWorkflow] recordEvolutionOutcome: {}", e.getMessage());
         }

+ 26 - 1
fs-service/src/main/java/com/fs/company/service/workflow/impl/QualityScoringServiceImpl.java

@@ -8,6 +8,7 @@ import com.fs.company.service.ai.AiSceneDispatcher;
 import com.fs.company.service.ai.AdminAiSceneService;
 import com.fs.company.service.ai.MultiModelPipelineEngine;
 import com.fs.company.service.workflow.QualityScoringService;
+import com.fs.company.service.workflow.performance.LobsterLatencyPolicy;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -64,10 +65,34 @@ public class QualityScoringServiceImpl implements QualityScoringService {
     @Autowired
     private MultiModelPipelineEngine pipelineEngine;
 
+    @Autowired(required = false)
+    private LobsterLatencyPolicy latencyPolicy;
+
     @Override
     public ScoringResult scoreWithRetry(Long companyId, String content, String userQuestion,
                                          String knowledgeBase, String conversationGoal, String messageTemplate) {
-        // 检查场景模型数量
+        if (latencyPolicy != null && latencyPolicy.useRuleOnlyQualityScoring(companyId)) {
+            DetailedScore ruleScore = buildDefaultScore(content, knowledgeBase, conversationGoal);
+            return ScoringResult.pass(content, ruleScore.getTotalScore(), buildDimensionMap(ruleScore));
+        }
+
+        // balanced:规则分已达标则跳过 LLM 评分链
+        if (latencyPolicy != null && latencyPolicy.skipQualityRegeneration(companyId)) {
+            DetailedScore ruleScore = buildDefaultScore(content, knowledgeBase, conversationGoal);
+            if (ruleScore.getTotalScore() >= Threshold.FIRST_PASS_THRESHOLD) {
+                return ScoringResult.pass(content, ruleScore.getTotalScore(), buildDimensionMap(ruleScore));
+            }
+            DetailedScore llmScore = score(companyId, content, userQuestion, knowledgeBase, conversationGoal, messageTemplate);
+            Map<String, Integer> dimensions = buildDimensionMap(llmScore);
+            boolean passed = llmScore.getTotalScore() >= Threshold.SECOND_PASS_THRESHOLD;
+            if (passed) {
+                return ScoringResult.pass(content, llmScore.getTotalScore(), dimensions);
+            }
+            return ScoringResult.fail(content, llmScore.getTotalScore(), llmScore.getTotalScore(),
+                    "质量分未达标,已跳过重生成(延迟优化模式)");
+        }
+
+        // Check scene model count
         List<AdminAiModel> models = sceneService.getEnabledModels(SCENE_QUALITY_SCORING);
         int modelCount = models.size();
 

+ 18 - 1
fs-service/src/main/java/com/fs/company/service/workflow/impl/SummaryGeneratorImpl.java

@@ -6,6 +6,7 @@ import com.fs.company.domain.LobsterConversationSummary;
 import com.fs.company.mapper.LobsterConversationSummaryMapper;
 import com.fs.company.service.llm.MultiModelRouter;
 import com.fs.company.service.workflow.SummaryGenerator;
+import com.fs.company.service.workflow.cache.LobsterContextCacheService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -39,6 +40,9 @@ public class SummaryGeneratorImpl implements SummaryGenerator {
     @Autowired(required = false)
     private StringRedisTemplate redisTemplate;
 
+    @Autowired(required = false)
+    private LobsterContextCacheService contextCache;
+
     @Override
     public String generateSummary(Long instanceId, String conversationHistory) {
         try {
@@ -148,6 +152,9 @@ public class SummaryGeneratorImpl implements SummaryGenerator {
             String stage = summaryInfo != null ? (String) summaryInfo.get("stage") : null;
             String keyVariables = summaryInfo != null ? JSON.toJSONString(summaryInfo.get("keyVariables")) : null;
             summaryMapper.updateUserProfile(companyId, externalUserId, stage, keyVariables);
+            if (contextCache != null) {
+                contextCache.invalidateUserProfile(companyId, externalUserId);
+            }
         } catch (Exception e) {
             logger.warn("[Summary] 更新用户画像失败: {}", e.getMessage());
         }
@@ -326,6 +333,9 @@ public class SummaryGeneratorImpl implements SummaryGenerator {
                 count++;
             }
             logger.debug("[Summary] 写入客户事实记忆 {} 条, user={}", count, externalUserId);
+            if (count > 0 && contextCache != null) {
+                contextCache.invalidateHistoricalFacts(companyId, externalUserId);
+            }
         } catch (Exception e) {
             logger.debug("[Summary] 写入事实记忆失败: {}", e.getMessage());
         }
@@ -381,6 +391,11 @@ public class SummaryGeneratorImpl implements SummaryGenerator {
 
     @Override
     public String generateGlobalSummary(Long companyId, String externalUserId) {
+        return generateGlobalSummary(companyId, externalUserId, false);
+    }
+
+    @Override
+    public String generateGlobalSummary(Long companyId, String externalUserId, boolean skipLlmMerge) {
         try {
             List<LobsterConversationSummary> summaries = summaryMapper.selectByExternalUserId(companyId, externalUserId);
             if (summaries == null || summaries.isEmpty()) return "暂无全局摘要";
@@ -388,7 +403,9 @@ public class SummaryGeneratorImpl implements SummaryGenerator {
             for (LobsterConversationSummary s : summaries) {
                 sb.append(s.getSummaryText()).append("\n");
             }
-            if (sb.length() < 500) return sb.toString();
+            if (skipLlmMerge || sb.length() < 500) {
+                return sb.length() > 500 ? sb.substring(0, 500) : sb.toString();
+            }
             String prompt = "合并以下客户历史摘要为一段150字以内的全局画像摘要:\n" + sb +
                     "\n输出JSON: {\"globalSummary\":\"全局画像摘要\"}";
             String aiResp = multiModelRouter.generateResponse(prompt, "doubao-lite", "summary_generator");

+ 8 - 0
fs-service/src/main/java/com/fs/company/service/workflow/learning/TenantLearningEngine.java

@@ -27,6 +27,14 @@ public interface TenantLearningEngine {
      */
     void recordEvent(Long companyId, LearningEvent event);
 
+    /**
+     * 从 AI 进化结果写入学习样本(replay buffer + event log)
+     */
+    void recordEvolutionInteraction(Long companyId, Long workflowId, Long instanceId,
+                                    String nodeCode, String customerMessage, String aiReply,
+                                    Integer qualityScore, String outcome,
+                                    Map<String, Object> variables);
+
     /**
      * 触发学习周期
      * 分析积累的交互数据,生成学习结果和优化建议

+ 318 - 70
fs-service/src/main/java/com/fs/company/service/workflow/learning/impl/TenantLearningEngineImpl.java

@@ -1,12 +1,12 @@
 package com.fs.company.service.workflow.learning.impl;
 
 import com.alibaba.fastjson.JSON;
-import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.company.mapper.LobsterAuxiliaryMapper;
 import com.fs.company.mapper.LobsterTenantLearningMapper;
 import com.fs.company.service.llm.MultiModelRouter;
 import com.fs.company.service.workflow.learning.*;
+import com.fs.company.service.workflow.cache.LobsterContextCacheService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -20,6 +20,7 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
 
     private static final Logger logger = LoggerFactory.getLogger(TenantLearningEngineImpl.class);
     private static final int MIN_EVENTS_FOR_LEARNING = 10;
+    private static final double HIGH_CONFIDENCE = 0.75;
 
     @Autowired(required = false)
     private LobsterTenantLearningMapper learningMapper;
@@ -30,18 +31,64 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
     @Autowired(required = false)
     private LobsterAuxiliaryMapper auxMapper;
 
+    @Autowired(required = false)
+    private DistributedLearningServiceImpl distributedLearningService;
+
+    @Autowired(required = false)
+    private LobsterContextCacheService contextCache;
+
     @Override
     public void recordEvent(Long companyId, LearningEvent event) {
-        if (learningMapper == null) return;
+        if (learningMapper == null || event == null) return;
         try {
             String vars = event.getVariables() != null ? JSON.toJSONString(event.getVariables()) : null;
+            String eventType = event.getOutcome() != null ? event.getOutcome() : "interaction";
+            Integer qualityScore = null;
+            if (event.getVariables() != null && event.getVariables().get("qualityScore") instanceof Number) {
+                qualityScore = ((Number) event.getVariables().get("qualityScore")).intValue();
+            }
             learningMapper.insertEventLog(companyId, event.getInstanceId(), event.getNodeCode(),
-                    event.getOutcome(), null, vars);
+                    eventType, qualityScore, vars);
         } catch (Exception e) {
             logger.error("[TenantLearning] 记录学习事件失败: companyId={}", companyId, e);
         }
     }
 
+    @Override
+    public void recordEvolutionInteraction(Long companyId, Long workflowId, Long instanceId,
+                                           String nodeCode, String customerMessage, String aiReply,
+                                           Integer qualityScore, String outcome,
+                                           Map<String, Object> variables) {
+        if (learningMapper == null || companyId == null) return;
+        try {
+            String eventType = outcome != null ? outcome : "interaction";
+            Map<String, Object> snapshot = new LinkedHashMap<>();
+            if (variables != null) snapshot.putAll(variables);
+            snapshot.put("workflowId", workflowId);
+            snapshot.put("qualityScore", qualityScore);
+            learningMapper.insertEventLog(companyId, instanceId, nodeCode, eventType,
+                    qualityScore, JSON.toJSONString(snapshot));
+
+            if (customerMessage != null && aiReply != null && !aiReply.isEmpty()) {
+                learningMapper.insertReplayBuffer(companyId, instanceId, nodeCode,
+                        customerMessage, aiReply, qualityScore);
+            }
+
+            // 高质量样本即时沉淀为可复用模式(Hermes 式 Skill 雏形)
+            if (qualityScore != null && qualityScore >= 120 && aiReply != null && nodeCode != null) {
+                String skillKey = "skill_" + nodeCode;
+                String skillValue = "节点[" + nodeCode + "]优质话术样例: " + truncate(aiReply, 200);
+                learningMapper.upsertPattern(companyId, "skill", skillKey, skillValue,
+                        Math.min(1.0, qualityScore / 160.0), "EvolutionReplay");
+                if (contextCache != null) {
+                    contextCache.invalidateLearnedStrategies(companyId);
+                }
+            }
+        } catch (Exception e) {
+            logger.debug("[TenantLearning] recordEvolutionInteraction: {}", e.getMessage());
+        }
+    }
+
     @Override
     public LearningSummary triggerLearningCycle(Long companyId) {
         long startTime = System.currentTimeMillis();
@@ -53,28 +100,36 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
             summary.setEventsAnalyzed(eventCount);
             if (eventCount < MIN_EVENTS_FOR_LEARNING) {
                 summary.setSummary("数据量不足(" + eventCount + "条),需要至少" + MIN_EVENTS_FOR_LEARNING + "条");
-                // 数据不足也做一次轻量分析:至少统计当前模式
                 List<Map<String, Object>> existing = learningMapper.selectPatterns(companyId);
                 summary.setNewDiscoveries(existing != null ? existing.size() : 0);
                 summary.setUpdatedPatterns(0);
                 return summary;
             }
+
+            int updatedBefore = countNonAppliedPatterns(companyId);
             int newDiscoveries = 0;
-            // 执行4维度分析
             newDiscoveries += analyzeMessageEffectiveness(companyId);
             newDiscoveries += analyzeTimingOptimization(companyId);
             newDiscoveries += analyzeFlowBottlenecks(companyId);
             newDiscoveries += analyzeCustomerProfileCorrelations(companyId);
-            // 生成策略推荐
             int suggestionsGenerated = generateStrategyRecommendations(companyId);
+            suggestionsGenerated += synthesizeSkillDocument(companyId);
 
+            int updatedAfter = countNonAppliedPatterns(companyId);
             summary.setNewDiscoveries(newDiscoveries);
-            summary.setUpdatedPatterns(0);
+            summary.setUpdatedPatterns(Math.max(0, updatedAfter - updatedBefore));
             summary.setSuggestionsGenerated(suggestionsGenerated);
             summary.setLearningDurationMs(System.currentTimeMillis() - startTime);
-            summary.setSummary("学习周期完成: 分析" + eventCount + "条事件,发现" + newDiscoveries + "个新模式,生成" + suggestionsGenerated + "条策略推荐");
+            summary.setSummary("学习周期完成: 分析" + eventCount + "条事件,发现" + newDiscoveries
+                    + "个新模式,更新" + summary.getUpdatedPatterns() + "条,生成" + suggestionsGenerated + "条策略");
+
+            contributeHighConfidencePatterns(companyId);
+            if (contextCache != null) {
+                contextCache.invalidateLearnedStrategies(companyId);
+            }
         } catch (Exception e) {
             logger.error("[TenantLearning] 学习周期执行失败: companyId={}", companyId, e);
+            summary.setSummary("学习周期失败: " + e.getMessage());
         }
         return summary;
     }
@@ -86,15 +141,11 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
         try {
             List<Map<String, Object>> rows = learningMapper.selectPatterns(companyId);
             for (Map<String, Object> row : rows) {
-                LearningResult r = new LearningResult();
-                r.setCompanyId(companyId);
-                r.setTitle((String) row.get("pattern_key"));
-                r.setDescription((String) row.get("pattern_value"));
-                r.setConfidence(row.get("confidence") instanceof Number ? ((Number) row.get("confidence")).doubleValue() : 0.0);
-                r.setStatus("discovered");
-                results.add(r);
+                results.add(mapPatternRow(companyId, row));
             }
-        } catch (Exception e) { logger.error("[TenantLearning] 获取学习结果失败", e); }
+        } catch (Exception e) {
+            logger.error("[TenantLearning] 获取学习结果失败", e);
+        }
         return results;
     }
 
@@ -103,49 +154,77 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
         List<StrategyRecommendation> recommendations = new ArrayList<>();
         if (learningMapper == null) return recommendations;
         try {
-            List<Map<String, Object>> rows = learningMapper.selectPatternsByScenario(companyId, scenario);
+            List<Map<String, Object>> rows = scenario != null && !scenario.isEmpty()
+                    ? learningMapper.selectPatternsByScenario(companyId, scenario)
+                    : learningMapper.selectPatterns(companyId);
             for (Map<String, Object> row : rows) {
+                if (isAppliedPattern(row)) continue;
+                double conf = toDouble(row.get("confidence"));
+                if (conf < 0.5) continue;
                 StrategyRecommendation rec = new StrategyRecommendation();
                 rec.setCompanyId(companyId);
-                rec.setScenario(scenario);
-                rec.setStrategyContent((String) row.get("pattern_value"));
-                rec.setConfidence(row.get("confidence") instanceof Number ? ((Number) row.get("confidence")).doubleValue() : 0.0);
+                rec.setScenario(scenario != null ? scenario : String.valueOf(row.get("pattern_type")));
+                rec.setStrategyContent(String.valueOf(row.get("pattern_value")));
+                rec.setConfidence(conf);
                 recommendations.add(rec);
+                if (recommendations.size() >= 5) break;
             }
-        } catch (Exception e) { logger.error("[TenantLearning] 策略推荐失败", e); }
+        } catch (Exception e) {
+            logger.error("[TenantLearning] 策略推荐失败", e);
+        }
         return recommendations;
     }
 
     @Override
     public ApplyResult applyLearningResult(Long companyId, Long resultId, Map<String, Object> applyOptions) {
         if (learningMapper == null) return ApplyResult.fail("学习引擎不可用");
+        if (resultId == null) return ApplyResult.fail("缺少学习结果ID");
         try {
-            List<Map<String, Object>> patterns = learningMapper.selectPatterns(companyId);
-            if (patterns == null || patterns.isEmpty()) return ApplyResult.fail("无学习结果可应用");
-            // 将学习模式写入 patch 表,由 WorkflowPatcher 统一落盘
+            Map<String, Object> pattern = learningMapper.selectPatternById(companyId, resultId);
+            if (pattern == null) return ApplyResult.fail("学习结果不存在");
+            if (isAppliedPattern(pattern)) {
+                return ApplyResult.success("该学习结果已应用", 0);
+            }
+
+            String patternType = String.valueOf(pattern.getOrDefault("pattern_type", "strategy"));
+            String patternKey = String.valueOf(pattern.getOrDefault("pattern_key", "auto"));
+            String patternValue = String.valueOf(pattern.getOrDefault("pattern_value", ""));
+
             if (auxMapper != null) {
-                for (Map<String, Object> p : patterns) {
-                    auxMapper.insertPatch(companyId, "company_workflow_lobster_node", 0L,
-                        (String) p.getOrDefault("pattern_key", "auto"),
-                        "...", (String) p.getOrDefault("pattern_value", ""),
-                        "TenantLearning auto-apply");
-                }
+                String fieldName = resolvePatchField(patternType);
+                auxMapper.insertPatch(companyId, "company_workflow_lobster_node", 0L,
+                        fieldName, patternKey, patternValue,
+                        "TenantLearning apply pattern#" + resultId);
             }
-            return ApplyResult.success("学习结果已提交为补丁,等待审核应用", patterns != null ? patterns.size() : 0);
+            learningMapper.markPatternApplied(companyId, resultId);
+            if (contextCache != null) {
+                contextCache.invalidateLearnedStrategies(companyId);
+            }
+            return ApplyResult.success("学习结果已提交为补丁,等待审核应用", 1);
         } catch (Exception e) {
             return ApplyResult.fail("应用失败: " + e.getMessage());
         }
     }
 
-    /** 每1小时触发一次学习周期:遍历活跃租户 */
+    /** 每1小时:遍历活跃租户触发学习周期 */
     @Scheduled(cron = "0 0 */1 * * ?")
     public void scheduledLearningCycle() {
-        if (learningMapper == null || auxMapper == null) return;
+        if (learningMapper == null) return;
         try {
-            logger.debug("[TenantLearning] 定时学习周期触发");
-            LearningSummary summary = triggerLearningCycle(0L); // 全租户分析
-            logger.info("[TenantLearning] 学习周期完成: {}", summary.getSummary());
-        } catch (Exception e) { logger.debug("[TenantLearning] 学习周期异常: {}", e.getMessage()); }
+            List<Long> tenants = loadActiveTenants();
+            int triggered = 0;
+            for (Long tenantId : tenants) {
+                if (tenantId == null || tenantId <= 0) continue;
+                LearningSummary summary = triggerLearningCycle(tenantId);
+                logger.info("[TenantLearning] 租户 {} 学习周期: {}", tenantId, summary.getSummary());
+                triggered++;
+            }
+            if (triggered == 0) {
+                logger.debug("[TenantLearning] 定时学习周期:无活跃租户");
+            }
+        } catch (Exception e) {
+            logger.debug("[TenantLearning] 学习周期异常: {}", e.getMessage());
+        }
     }
 
     @Override
@@ -158,8 +237,10 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
         try {
             int eventCount = learningMapper.countQualityEvents(companyId, null);
             List<Map<String, Object>> patterns = learningMapper.selectPatterns(companyId);
+            Double avgQuality = learningMapper.selectAvgQualityScore(companyId);
             metrics.put("eventCount", eventCount);
             metrics.put("patternCount", patterns != null ? patterns.size() : 0);
+            metrics.put("avgQualityScore", avgQuality != null ? String.format("%.1f", avgQuality) : "-");
             metrics.put("status", "active");
             metrics.put("minEventsForLearning", MIN_EVENTS_FOR_LEARNING);
         } catch (Exception e) {
@@ -176,7 +257,9 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
         try {
             learningMapper.upsertPattern(companyId, "corpus", report.getSummary(),
                     report.getSummary(), 0.7, "SalesCorpusAnalyzer");
-        } catch (Exception e) { logger.debug("[TenantLearning] 语料导入失败: {}", e.getMessage()); }
+        } catch (Exception e) {
+            logger.debug("[TenantLearning] 语料导入失败: {}", e.getMessage());
+        }
     }
 
     private int analyzeMessageEffectiveness(Long companyId) {
@@ -192,7 +275,7 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
                         ? ((Number) event.get("quality_score")).intValue() : null;
                 String reply = (String) event.get("ai_reply");
                 if (reply == null || reply.isEmpty()) continue;
-                String snippet = reply.length() > 40 ? reply.substring(0, 40) + "..." : reply;
+                String snippet = truncate(reply, 40);
                 if (score != null && score >= 120) {
                     high++;
                     if (highSamples.length() < 200) highSamples.append(snippet).append("; ");
@@ -214,7 +297,9 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
                 discoveries++;
             }
             return discoveries;
-        } catch (Exception e) { return 0; }
+        } catch (Exception e) {
+            return 0;
+        }
     }
 
     private int analyzeTimingOptimization(Long companyId) {
@@ -225,21 +310,7 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
             Map<Integer, Integer> hourCounts = new LinkedHashMap<>();
             Map<Integer, Integer> hourHighQuality = new LinkedHashMap<>();
             for (Map<String, Object> event : events) {
-                Object ct = event.get("create_time");
-                if (ct == null) continue;
-                int hour = -1;
-                if (ct instanceof java.sql.Timestamp) {
-                    hour = ((java.sql.Timestamp) ct).toLocalDateTime().getHour();
-                } else if (ct instanceof java.util.Date) {
-                    java.util.Calendar cal = java.util.Calendar.getInstance();
-                    cal.setTime((java.util.Date) ct);
-                    hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
-                } else {
-                    String s = ct.toString();
-                    if (s.length() >= 13) {
-                        try { hour = Integer.parseInt(s.substring(11, 13)); } catch (Exception ignored) { }
-                    }
-                }
+                int hour = parseHour(event.get("create_time"));
                 if (hour < 0) continue;
                 hourCounts.merge(hour, 1, Integer::sum);
                 Integer score = event.get("quality_score") instanceof Number
@@ -249,6 +320,8 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
                 }
             }
             int discoveries = 0;
+            int bestHour = -1;
+            double bestRate = 0;
             for (Integer hour : hourCounts.keySet()) {
                 int total = hourCounts.get(hour);
                 if (total < 2) continue;
@@ -257,9 +330,21 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
                 String insight = hour + "点时段交互" + total + "次,高质量率" + String.format("%.0f%%", rate);
                 learningMapper.upsertPattern(companyId, "timing", "hour_" + hour, insight, rate / 100.0, "TimingAnalyzer");
                 discoveries++;
+                if (rate > bestRate) {
+                    bestRate = rate;
+                    bestHour = hour;
+                }
+            }
+            if (bestHour >= 0 && bestRate >= 60) {
+                learningMapper.upsertPattern(companyId, "timing", "best_window",
+                        "建议优先在 " + bestHour + ":00-" + (bestHour + 1) + ":00 触达客户(高质量率"
+                                + String.format("%.0f%%", bestRate) + ")", bestRate / 100.0, "TimingAnalyzer");
+                discoveries++;
             }
             return discoveries;
-        } catch (Exception e) { return 0; }
+        } catch (Exception e) {
+            return 0;
+        }
     }
 
     private int analyzeFlowBottlenecks(Long companyId) {
@@ -322,13 +407,14 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
                 }
             }
             return discoveries;
-        } catch (Exception e) { return 0; }
+        } catch (Exception e) {
+            return 0;
+        }
     }
 
     private int analyzeCustomerProfileCorrelations(Long companyId) {
         if (learningMapper == null) return 0;
         try {
-            // 从replay buffer中分析客户画像与对话效果的关联
             List<Map<String, Object>> events = learningMapper.selectReplayBuffer(companyId);
             if (events == null || events.isEmpty()) return 0;
 
@@ -340,9 +426,7 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
                 String customerMessage = (String) event.get("customer_message");
                 Integer qualityScore = event.get("quality_score") instanceof Number
                         ? ((Number) event.get("quality_score")).intValue() : null;
-
                 if (customerMessage == null || customerMessage.isEmpty()) continue;
-
                 String profileTag = inferProfileTag(customerMessage);
                 profileStats.merge(profileTag, 1, Integer::sum);
                 if (qualityScore != null && qualityScore >= 120) {
@@ -360,12 +444,12 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
                     discoveries++;
                 }
             }
-
             return discoveries;
-        } catch (Exception e) { return 0; }
+        } catch (Exception e) {
+            return 0;
+        }
     }
 
-    /** 从消息内容中推断客户画像标签 */
     private String inferProfileTag(String message) {
         if (message == null || message.isEmpty()) return "unknown";
         if (message.contains("价格") || message.contains("优惠") || message.contains("便宜") || message.contains("贵"))
@@ -388,22 +472,186 @@ public class TenantLearningEngineImpl implements TenantLearningEngine {
         try {
             List<Map<String, Object>> patterns = learningMapper.selectPatterns(companyId);
             if (patterns == null || patterns.isEmpty()) return 0;
-            int discoveries = 0;
             StringBuilder sb = new StringBuilder();
             for (Map<String, Object> p : patterns) {
-                Object conf = p.get("confidence");
-                double c = conf instanceof Number ? ((Number) conf).doubleValue() : 0;
+                if (isAppliedPattern(p)) continue;
+                double c = toDouble(p.get("confidence"));
                 if (c >= 0.6) {
-                    sb.append("[").append(p.get("pattern_key")).append("] ")
+                    sb.append("[").append(p.get("pattern_type")).append("/").append(p.get("pattern_key")).append("] ")
                             .append(p.get("pattern_value")).append("; ");
                 }
             }
             if (sb.length() > 0) {
                 learningMapper.upsertPattern(companyId, "strategy", "recommendation",
                         "综合策略建议: " + sb, 0.65, "StrategySynthesizer");
-                discoveries++;
+                return 1;
             }
-            return discoveries;
-        } catch (Exception e) { return 0; }
+            return 0;
+        } catch (Exception e) {
+            return 0;
+        }
+    }
+
+    /** AI 合成 Skill 文档(借鉴 Hermes 自进化 Skill 机制,企业审核门控) */
+    private int synthesizeSkillDocument(Long companyId) {
+        if (learningMapper == null || multiModelRouter == null) return 0;
+        try {
+            List<Map<String, Object>> patterns = learningMapper.selectPatterns(companyId);
+            if (patterns == null || patterns.size() < 3) return 0;
+
+            StringBuilder input = new StringBuilder("以下是租户历史交互学习模式,请合成一份可执行的销冠 Skill 文档:\n");
+            int count = 0;
+            for (Map<String, Object> p : patterns) {
+                if (isAppliedPattern(p)) continue;
+                double c = toDouble(p.get("confidence"));
+                if (c < HIGH_CONFIDENCE) continue;
+                input.append("- [").append(p.get("pattern_type")).append("] ")
+                        .append(p.get("pattern_value")).append("\n");
+                count++;
+                if (count >= 8) break;
+            }
+            if (count < 2) return 0;
+
+            input.append("\n输出 JSON: {\"title\":\"Skill标题\",\"content\":\"具体执行步骤和话术要点\",\"expectedImpact\":\"预期效果\"}");
+            String aiResponse = multiModelRouter.generateResponse(input.toString(), null, "learning_synthesizer");
+            JSONObject json = parseJsonObject(aiResponse);
+            if (json == null) return 0;
+
+            String title = json.getString("title");
+            String content = json.getString("content");
+            if (content == null || content.isEmpty()) return 0;
+
+            String skillBody = (title != null ? title + "\n" : "") + content;
+            if (json.getString("expectedImpact") != null) {
+                skillBody += "\n预期效果: " + json.getString("expectedImpact");
+            }
+            learningMapper.upsertPattern(companyId, "skill", "ai_synthesized",
+                    skillBody, 0.72, "AiSkillSynthesizer");
+            return 1;
+        } catch (Exception e) {
+            logger.debug("[TenantLearning] AI Skill 合成失败: {}", e.getMessage());
+            return 0;
+        }
+    }
+
+    private void contributeHighConfidencePatterns(Long companyId) {
+        if (distributedLearningService == null || learningMapper == null) return;
+        try {
+            List<Map<String, Object>> patterns = learningMapper.selectPatterns(companyId);
+            if (patterns == null) return;
+            List<Map<String, Object>> toShare = new ArrayList<>();
+            for (Map<String, Object> p : patterns) {
+                if (toDouble(p.get("confidence")) >= HIGH_CONFIDENCE) {
+                    Map<String, Object> item = new HashMap<>();
+                    item.put("scenario", p.get("pattern_type"));
+                    item.put("patternType", p.get("pattern_key"));
+                    item.put("content", p.get("pattern_value"));
+                    item.put("score", p.get("confidence"));
+                    toShare.add(item);
+                }
+            }
+            if (!toShare.isEmpty()) {
+                distributedLearningService.contributePatterns(companyId, "general", toShare);
+            }
+        } catch (Exception e) {
+            logger.debug("[TenantLearning] 分布式贡献失败: {}", e.getMessage());
+        }
+    }
+
+    private LearningResult mapPatternRow(Long companyId, Map<String, Object> row) {
+        LearningResult r = new LearningResult();
+        r.setId(row.get("id") instanceof Number ? ((Number) row.get("id")).longValue() : null);
+        r.setCompanyId(companyId);
+        r.setLearningType(String.valueOf(row.getOrDefault("pattern_type", "unknown")));
+        r.setTitle(String.valueOf(row.getOrDefault("pattern_key", "")));
+        r.setDescription(String.valueOf(row.getOrDefault("pattern_value", "")));
+        r.setConfidence(toDouble(row.get("confidence")));
+        r.setStatus(isAppliedPattern(row) ? "applied" : "discovered");
+        return r;
+    }
+
+    private boolean isAppliedPattern(Map<String, Object> row) {
+        String source = row.get("source") != null ? row.get("source").toString() : "";
+        return source.contains("applied");
+    }
+
+    private int countNonAppliedPatterns(Long companyId) {
+        List<Map<String, Object>> patterns = learningMapper.selectPatterns(companyId);
+        if (patterns == null) return 0;
+        int count = 0;
+        for (Map<String, Object> p : patterns) {
+            if (!isAppliedPattern(p)) count++;
+        }
+        return count;
+    }
+
+    private String resolvePatchField(String patternType) {
+        if ("skill".equals(patternType) || "message".equals(patternType)) return "messageTemplate";
+        if ("timing".equals(patternType)) return "sendTimeWindow";
+        if ("flow".equals(patternType)) return "nodeConfig";
+        return "learningPatch";
+    }
+
+    private List<Long> loadActiveTenants() {
+        if (auxMapper == null) return Collections.emptyList();
+        try {
+            List<Map<String, Object>> rows = auxMapper.selectPaged("company_info", "status=1", 0, 200);
+            List<Long> tenants = new ArrayList<>();
+            for (Map<String, Object> row : rows) {
+                Object id = row.get("id");
+                if (id instanceof Number) {
+                    tenants.add(((Number) id).longValue());
+                    continue;
+                }
+                id = row.get("company_id");
+                if (id instanceof Number) tenants.add(((Number) id).longValue());
+            }
+            return tenants;
+        } catch (Exception e) {
+            logger.debug("[TenantLearning] 加载租户列表失败: {}", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    private static int parseHour(Object ct) {
+        if (ct == null) return -1;
+        if (ct instanceof java.sql.Timestamp) {
+            return ((java.sql.Timestamp) ct).toLocalDateTime().getHour();
+        }
+        if (ct instanceof Date) {
+            Calendar cal = Calendar.getInstance();
+            cal.setTime((Date) ct);
+            return cal.get(Calendar.HOUR_OF_DAY);
+        }
+        String s = ct.toString();
+        if (s.length() >= 13) {
+            try {
+                return Integer.parseInt(s.substring(11, 13));
+            } catch (Exception ignored) {
+                return -1;
+            }
+        }
+        return -1;
+    }
+
+    private static double toDouble(Object v) {
+        return v instanceof Number ? ((Number) v).doubleValue() : 0.0;
+    }
+
+    private static String truncate(String s, int max) {
+        if (s == null) return "";
+        return s.length() > max ? s.substring(0, max) + "..." : s;
+    }
+
+    private static JSONObject parseJsonObject(String aiResponse) {
+        if (aiResponse == null) return null;
+        try {
+            int start = aiResponse.indexOf("{");
+            int end = aiResponse.lastIndexOf("}");
+            String jsonStr = start >= 0 && end > start ? aiResponse.substring(start, end + 1) : aiResponse;
+            return JSON.parseObject(jsonStr);
+        } catch (Exception e) {
+            return null;
+        }
     }
 }

+ 76 - 0
fs-service/src/main/java/com/fs/company/service/workflow/performance/LobsterLatencyPolicy.java

@@ -0,0 +1,76 @@
+package com.fs.company.service.workflow.performance;
+
+import com.fs.company.mapper.LobsterTenantLearningMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 龙虾引擎延迟策略:fast / balanced / quality
+ * <p>
+ * 租户级覆盖:company_config.config_key = lobster_latency_mode
+ */
+@Component
+public class LobsterLatencyPolicy {
+
+    public enum Mode { FAST, BALANCED, QUALITY }
+
+    @Value("${lobster.latency.mode:quality}")
+    private String defaultMode;
+
+    @Autowired(required = false)
+    private LobsterTenantLearningMapper learningMapper;
+
+    public Mode resolveMode(Long companyId) {
+        String raw = defaultMode;
+        if (learningMapper != null && companyId != null) {
+            try {
+                String cfg = learningMapper.selectConfig(companyId, "lobster_latency_mode");
+                if (cfg != null && !cfg.isBlank()) {
+                    raw = cfg.trim();
+                }
+            } catch (Exception ignored) {
+            }
+        }
+        switch (raw.toLowerCase()) {
+            case "fast":
+            case "speed":
+                return Mode.FAST;
+            case "quality":
+            case "full":
+                return Mode.QUALITY;
+            default:
+                return Mode.BALANCED;
+        }
+    }
+
+    /** 是否跳过同步 LLM 变量提取 */
+    public boolean skipSyncVariableExtraction(Long companyId) {
+        return resolveMode(companyId) != Mode.QUALITY;
+    }
+
+    /** 是否跳过 LLM 质量评分链(改用规则评分) */
+    public boolean useRuleOnlyQualityScoring(Long companyId) {
+        return resolveMode(companyId) == Mode.FAST;
+    }
+
+    /** balanced 模式下是否禁止质量流水线重生成 */
+    public boolean skipQualityRegeneration(Long companyId) {
+        return resolveMode(companyId) != Mode.QUALITY;
+    }
+
+    /** 是否优先关键词语义分析(跳过 LLM 意图识别) */
+    public boolean preferKeywordSemanticFirst(Long companyId) {
+        return resolveMode(companyId) != Mode.QUALITY;
+    }
+
+    /** 是否跳过动态节点 AI 生成 */
+    public boolean skipDynamicNodeGeneration(Long companyId) {
+        return resolveMode(companyId) != Mode.QUALITY;
+    }
+
+    /** 全局摘要是否禁止热路径 LLM 合并 */
+    public boolean skipGlobalSummaryLlm(Long companyId) {
+        return resolveMode(companyId) != Mode.QUALITY;
+    }
+}

+ 19 - 0
fs-service/src/main/java/com/fs/company/service/workflow/semantic/impl/SemanticAnalyzerImpl.java

@@ -8,6 +8,7 @@ import com.fs.company.service.workflow.semantic.ConversationMessage;
 import com.fs.company.service.workflow.semantic.SemanticAnalyzer;
 import com.fs.company.service.workflow.semantic.SemanticResult;
 import com.fs.company.mapper.LobsterAuxiliaryMapper;
+import com.fs.company.service.workflow.performance.LobsterLatencyPolicy;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -113,6 +114,9 @@ public class SemanticAnalyzerImpl implements SemanticAnalyzer {
         DEGREE_WORDS.put("稍微", 0.6);
     }
 
+    @Autowired(required = false)
+    private LobsterLatencyPolicy latencyPolicy;
+
     /** 租户自定义词典缓存 */
     private final Map<Long, Map<String, String[]>> tenantKeywordCache = new java.util.concurrent.ConcurrentHashMap<>();
     private final Map<Long, Long> cacheRefreshTime = new java.util.concurrent.ConcurrentHashMap<>();
@@ -127,6 +131,21 @@ public class SemanticAnalyzerImpl implements SemanticAnalyzer {
             return SemanticResult.of("empty", 0.0, 0.0, Collections.emptyList());
         }
 
+        Long companyId = null;
+        if (context != null && context.get("companyId") instanceof Number) {
+            companyId = ((Number) context.get("companyId")).longValue();
+        }
+
+        // fast/balanced:短句高置信关键词命中则跳过 LLM(节省 1~3s)
+        if (latencyPolicy == null || latencyPolicy.preferKeywordSemanticFirst(companyId)) {
+            String keywordIntent = detectIntentByKeywords(message);
+            if (keywordIntent != null && message.length() <= 24) {
+                Double sentiment = calculateSentimentEnhanced(message);
+                List<String> keywords = extractKeywordsEnhanced(message);
+                return SemanticResult.of(keywordIntent, 0.82, sentiment, keywords);
+            }
+        }
+
         /*
          * 主策略:AI语义分析
          * 使用轻量模型进行意图识别,返回结构化JSON结果

+ 35 - 1
fs-service/src/main/java/com/fs/company/service/workflow/vector/impl/VectorPatternMatcherImpl.java

@@ -7,6 +7,7 @@ import com.fs.company.mapper.LobsterVectorStoreMapper;
 import com.fs.company.service.llm.MultiModelRouter;
 import com.fs.company.service.vector.EmbeddingService;
 import com.fs.company.service.workflow.vector.VectorPatternMatcher;
+import com.fs.company.service.workflow.cache.LobsterContextCacheService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -29,6 +30,9 @@ public class VectorPatternMatcherImpl implements VectorPatternMatcher {
     @Autowired(required = false)
     private MultiModelRouter multiModelRouter;
 
+    @Autowired(required = false)
+    private LobsterContextCacheService contextCache;
+
     @Override
     public void storeVector(Long companyId, String category, String key, String text, Map<String, Object> metadata) {
         if (vectorStoreMapper == null || companyId == null || text == null || text.isBlank()) return;
@@ -42,6 +46,10 @@ public class VectorPatternMatcherImpl implements VectorPatternMatcher {
             entity.setVector(vectorJson);
             entity.setMetadata(metadata != null ? JSON.toJSONString(metadata) : "{}");
             vectorStoreMapper.upsert(entity);
+            if (contextCache != null) {
+                contextCache.invalidateVectorRows(companyId, category);
+                contextCache.invalidateKnowledge(companyId);
+            }
         } catch (Exception e) {
             logger.warn("[VectorPatternMatcher] storeVector failed: {}", e.getMessage());
         }
@@ -55,7 +63,7 @@ public class VectorPatternMatcherImpl implements VectorPatternMatcher {
         }
         int limit = topK > 0 ? topK : 5;
         try {
-            List<LobsterVectorStore> rows = vectorStoreMapper.selectByCompanyAndCategory(companyId, category);
+            List<LobsterVectorStore> rows = loadVectorRows(companyId, category);
             if (rows == null || rows.isEmpty()) {
                 rows = keywordFallback(companyId, category, queryText, limit);
             }
@@ -97,6 +105,10 @@ public class VectorPatternMatcherImpl implements VectorPatternMatcher {
         if (vectorStoreMapper == null || companyId == null || key == null) return;
         try {
             vectorStoreMapper.deleteByKey(companyId, category, key);
+            if (contextCache != null) {
+                contextCache.invalidateVectorRows(companyId, category);
+                contextCache.invalidateKnowledge(companyId);
+            }
         } catch (Exception e) {
             logger.warn("[VectorPatternMatcher] deleteVector failed: {}", e.getMessage());
         }
@@ -147,7 +159,29 @@ public class VectorPatternMatcherImpl implements VectorPatternMatcher {
         return 0.0;
     }
 
+    private List<LobsterVectorStore> loadVectorRows(Long companyId, String category) {
+        if (contextCache == null) {
+            return vectorStoreMapper.selectByCompanyAndCategory(companyId, category);
+        }
+        String json = contextCache.getVectorRowsJson(companyId, category, () -> {
+            List<LobsterVectorStore> rows = vectorStoreMapper.selectByCompanyAndCategory(companyId, category);
+            return rows != null && !rows.isEmpty() ? JSON.toJSONString(rows) : "";
+        });
+        if (json == null || json.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<LobsterVectorStore> parsed = JSON.parseArray(json, LobsterVectorStore.class);
+        return parsed != null ? parsed : Collections.emptyList();
+    }
+
     private String buildEmbeddingJson(String text) {
+        if (contextCache != null) {
+            return contextCache.getEmbeddingJson(text, () -> computeEmbeddingJson(text));
+        }
+        return computeEmbeddingJson(text);
+    }
+
+    private String computeEmbeddingJson(String text) {
         if (embeddingService != null) {
             try {
                 List<Float> vec = embeddingService.embed(text);

+ 18 - 0
fs-service/src/main/resources/application-common.yml

@@ -163,3 +163,21 @@ baidu:
   token: 12313231232
   back-domain: https://www.xxxx.com
 
+# 龙虾引擎:默认 quality 保质量;Redis 缓存加速上下文组装(不降质)
+lobster:
+  latency:
+    mode: quality
+  cache:
+    enabled: true
+    profile-ttl-minutes: 5
+    recent-chats-ttl-minutes: 2
+    facts-ttl-minutes: 5
+    state-ttl-minutes: 5
+    habits-ttl-minutes: 5
+    strategies-ttl-minutes: 10
+    knowledge-ttl-minutes: 10
+    compliance-rules-ttl-minutes: 30
+    compliance-prompt-ttl-minutes: 30
+    vector-rows-ttl-minutes: 10
+    embedding-ttl-minutes: 60
+

+ 29 - 4
fs-service/src/main/resources/mapper/lobster/LobsterTenantLearningMapper.xml

@@ -12,7 +12,10 @@
 
     <select id="countQualityEvents" resultType="java.lang.Integer">
         SELECT COUNT(*) FROM lobster_learning_event_log
-        WHERE company_id = #{companyId} AND event_type = #{eventType}
+        WHERE company_id = #{companyId}
+        <if test="eventType != null and eventType != ''">
+            AND event_type = #{eventType}
+        </if>
     </select>
 
     <!-- === lobster_learned_pattern === -->
@@ -26,18 +29,40 @@
     </insert>
 
     <select id="selectPatterns" resultType="java.util.Map">
-        SELECT pattern_key, pattern_value, confidence
+        SELECT id, pattern_type, pattern_key, pattern_value, confidence, source, create_time, update_time
         FROM lobster_learned_pattern WHERE company_id = #{companyId}
-        ORDER BY confidence DESC LIMIT 50
+        ORDER BY confidence DESC, update_time DESC LIMIT 50
+    </select>
+
+    <select id="selectPatternById" resultType="java.util.Map">
+        SELECT id, pattern_type, pattern_key, pattern_value, confidence, source, create_time, update_time
+        FROM lobster_learned_pattern
+        WHERE company_id = #{companyId} AND id = #{id}
+        LIMIT 1
     </select>
 
     <select id="selectPatternsByScenario" resultType="java.util.Map">
-        SELECT pattern_key, pattern_value, confidence
+        SELECT id, pattern_type, pattern_key, pattern_value, confidence, source
         FROM lobster_learned_pattern
         WHERE company_id = #{companyId} AND pattern_type = #{scenario}
         ORDER BY confidence DESC LIMIT 20
     </select>
 
+    <update id="markPatternApplied">
+        UPDATE lobster_learned_pattern
+        SET source = CASE
+            WHEN source IS NULL OR source = '' THEN 'applied'
+            WHEN source NOT LIKE '%applied%' THEN CONCAT(source, '|applied')
+            ELSE source END,
+            update_time = NOW()
+        WHERE company_id = #{companyId} AND id = #{id}
+    </update>
+
+    <select id="selectAvgQualityScore" resultType="java.lang.Double">
+        SELECT AVG(quality_score) FROM lobster_learning_replay_buffer
+        WHERE company_id = #{companyId} AND quality_score IS NOT NULL
+    </select>
+
     <select id="ensurePatternTable" resultType="java.lang.Integer">
         SELECT 1 FROM lobster_learned_pattern LIMIT 1
     </select>

+ 42 - 0
fs-service/src/test/java/com/fs/company/service/workflow/capability/LobsterNodeCapabilityRegistryTest.java

@@ -0,0 +1,42 @@
+package com.fs.company.service.workflow.capability;
+
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.*;
+
+public class LobsterNodeCapabilityRegistryTest {
+
+    @Test
+    public void allNodeTypesRegistered() {
+        List<LobsterNodeCapabilityRegistry.NodeCapability> all = LobsterNodeCapabilityRegistry.all();
+        assertEquals(38, all.size());
+    }
+
+    @Test
+    public void summaryReflectsFullImplementation() {
+        Map<String, Object> summary = LobsterNodeCapabilityRegistry.summary();
+        assertEquals(38, summary.get("totalTypes"));
+        assertTrue((Integer) summary.get("fullCount") >= 35);
+        assertEquals(0, summary.get("stubCount"));
+        assertEquals(0, summary.get("noneCount"));
+    }
+
+    @Test
+    public void coreNodesAreFull() {
+        assertEquals(LobsterNodeCapabilityRegistry.ImplStatus.FULL,
+                LobsterNodeCapabilityRegistry.get(3).status);
+        assertEquals(LobsterNodeCapabilityRegistry.ImplStatus.FULL,
+                LobsterNodeCapabilityRegistry.get(23).status);
+        assertEquals(LobsterNodeCapabilityRegistry.ImplStatus.FULL,
+                LobsterNodeCapabilityRegistry.get(34).status);
+    }
+
+    @Test
+    public void dynamicExecutorTypesIncludeBusinessNodes() {
+        assertTrue(LobsterNodeCapabilityRegistry.dynamicExecutorTypes().contains(7));
+        assertTrue(LobsterNodeCapabilityRegistry.dynamicExecutorTypes().contains(43));
+    }
+}

+ 67 - 0
fs-service/src/test/java/com/fs/company/service/workflow/impl/DynamicNodeExecutorImplTest.java

@@ -0,0 +1,67 @@
+package com.fs.company.service.workflow.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.company.service.workflow.DynamicNodeExecutor;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.*;
+
+public class DynamicNodeExecutorImplTest {
+
+    private DynamicNodeExecutorImpl executor;
+
+    @Before
+    public void setUp() {
+        executor = new DynamicNodeExecutorImpl();
+        executor.init();
+    }
+
+    @Test
+    public void startNodeReturnsSuccess() {
+        DynamicNodeExecutor.ExecutionContext ctx = DynamicNodeExecutor.ExecutionContext.builder()
+                .companyId(1L).build();
+        DynamicNodeExecutor.NodeExecutionResult r = executor.execute(1, "{}", ctx);
+        assertTrue(r.isSuccess());
+        assertNotNull(r.getOutputVariables());
+        assertTrue(r.getOutputVariables().containsKey("workflowStartTime"));
+    }
+
+    @Test
+    public void waitNodeComputesWaitUntil() {
+        Map<String, Object> cfg = new HashMap<>();
+        cfg.put("waitSeconds", 120L);
+        DynamicNodeExecutor.ExecutionContext ctx = DynamicNodeExecutor.ExecutionContext.builder().build();
+        DynamicNodeExecutor.NodeExecutionResult r = executor.execute(4, JSON.toJSONString(cfg), ctx);
+        assertTrue(r.isSuccess());
+        assertTrue(r.getOutputVariables().containsKey("waitUntil"));
+    }
+
+    @Test
+    public void variableAssignNodeWorks() {
+        String config = "{\"variableName\":\"score\",\"variableValue\":100}";
+        DynamicNodeExecutor.NodeExecutionResult r = executor.execute(40, config,
+                DynamicNodeExecutor.ExecutionContext.builder().build());
+        assertTrue(r.isSuccess());
+        assertEquals(100, r.getOutputVariables().get("score"));
+    }
+
+    @Test
+    public void allRegistryHandlersRegistered() {
+        Map<Integer, DynamicNodeExecutor.NodeHandler> handlers = executor.getRegisteredHandlers();
+        assertTrue(handlers.size() >= 35);
+        assertTrue(handlers.containsKey(3));
+        assertTrue(handlers.containsKey(34));
+        assertTrue(handlers.containsKey(100));
+    }
+
+    @Test
+    public void supportsKnownNodeTypes() {
+        assertTrue(executor.supports(1));
+        assertTrue(executor.supports(33));
+        assertFalse(executor.supports(9999));
+    }
+}