Procházet zdrojové kódy

Merge remote-tracking branch 'origin/cfclub' into cfclub

jiapu před 3 měsíci
rodič
revize
499707f0e5
100 změnil soubory, kde provedl 14733 přidání a 11064 odebrání
  1. 0 16
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
  2. 41 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/ContextApiController.java
  3. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ai/RecommendationController.java
  4. 10 1
      cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyFortuneMapper.java
  5. 30 10
      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
  6. 203 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  7. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DifySyncService.java
  8. 8 0
      cfc-backend/src/main/resources/application.yml
  9. 9 0
      cfc-frontend/.postcssrc.js
  10. 1 1
      cfc-frontend/components/FamilyMemberCard.vue
  11. 1 1
      cfc-frontend/components/RecommendedFeed.vue
  12. 361 381
      cfc-frontend/package-lock.json
  13. 5 2
      cfc-frontend/package.json
  14. 4 4
      cfc-frontend/pages.json
  15. 3 0
      cfc-frontend/pages/index/parent-index.vue
  16. 6 1
      cfc-frontend/pages/parent/market/detail.vue
  17. 5 1
      cfc-frontend/pages/profile/components/ProfileMenu.vue
  18. 5 15
      cfc-frontend/pages/profile/profile.vue
  19. 5 0
      cfc-frontend/postcss.config.js
  20. binární
      cfc-frontend/static/tab-wisdom-active.png
  21. binární
      cfc-frontend/static/tab-wisdom.png
  22. 6 0
      cfc-frontend/vue.config.js
  23. 26 0
      cfc-langgraph/.env.example
  24. 11 0
      cfc-langgraph/.gitignore
  25. 35 0
      cfc-langgraph/Dockerfile
  26. 0 0
      cfc-langgraph/app/__init__.py
  27. 0 0
      cfc-langgraph/app/agents/__init__.py
  28. 25 0
      cfc-langgraph/app/agents/analysis_agent.py
  29. 35 0
      cfc-langgraph/app/agents/chat_agent.py
  30. 57 0
      cfc-langgraph/app/agents/intent_classifier.py
  31. 79 0
      cfc-langgraph/app/agents/multimodal_agent.py
  32. 0 0
      cfc-langgraph/app/api/__init__.py
  33. 45 0
      cfc-langgraph/app/api/analyze.py
  34. 58 0
      cfc-langgraph/app/api/chat.py
  35. 8 0
      cfc-langgraph/app/api/health.py
  36. 42 0
      cfc-langgraph/app/api/recommend.py
  37. 45 0
      cfc-langgraph/app/api/tongue.py
  38. 52 0
      cfc-langgraph/app/config.py
  39. 0 0
      cfc-langgraph/app/graphs/__init__.py
  40. 96 0
      cfc-langgraph/app/graphs/analysis_graph.py
  41. 169 0
      cfc-langgraph/app/graphs/chat_graph.py
  42. 79 0
      cfc-langgraph/app/graphs/recommend_graph.py
  43. 44 0
      cfc-langgraph/app/log_config.py
  44. 74 0
      cfc-langgraph/app/main.py
  45. 1 0
      cfc-langgraph/app/memory/__init__.py
  46. 89 0
      cfc-langgraph/app/memory/store.py
  47. 3 0
      cfc-langgraph/app/models/__init__.py
  48. 18 0
      cfc-langgraph/app/models/chat.py
  49. 18 0
      cfc-langgraph/app/models/common.py
  50. 29 0
      cfc-langgraph/app/models/recommend.py
  51. 137 0
      cfc-langgraph/app/monitoring.py
  52. 2 0
      cfc-langgraph/app/rag/__init__.py
  53. 15 0
      cfc-langgraph/app/rag/embeddings.py
  54. 49 0
      cfc-langgraph/app/rag/loader.py
  55. 109 0
      cfc-langgraph/app/rag/retriever.py
  56. 21 0
      cfc-langgraph/app/rag/splitter.py
  57. 0 0
      cfc-langgraph/app/tasks/__init__.py
  58. 77 0
      cfc-langgraph/app/tasks/knowledge_sync.py
  59. 1 0
      cfc-langgraph/app/tools/__init__.py
  60. 88 0
      cfc-langgraph/app/tools/java_client.py
  61. 58 0
      cfc-langgraph/app/tools/product_tools.py
  62. 63 0
      cfc-langgraph/app/tools/report_tools.py
  63. 65 0
      cfc-langgraph/docker-compose.yml
  64. 97 0
      cfc-langgraph/docs/deployment.md
  65. 121 0
      cfc-langgraph/docs/operations.md
  66. 9 0
      cfc-langgraph/prometheus.yml
  67. 34 0
      cfc-langgraph/pyproject.toml
  68. 0 0
      cfc-langgraph/tests/__init__.py
  69. 16 0
      cfc-langgraph/tests/conftest.py
  70. 30 0
      cfc-langgraph/tests/test_analyze.py
  71. 18 0
      cfc-langgraph/tests/test_chat.py
  72. 37 0
      cfc-langgraph/tests/test_intent.py
  73. 25 0
      cfc-langgraph/tests/test_memory.py
  74. 44 0
      cfc-langgraph/tests/test_recommend.py
  75. 12 0
      cfc-langgraph/tests/test_tongue.py
  76. 1 1
      cfc-web/.last_build_commit
  77. 2 2
      cfc-web/package-lock.json
  78. 1 1
      cfc-web/package.json
  79. 120 0
      cfc-web/public/CHANGELOG-v1.0.md
  80. 121 1
      cfc-web/public/CHANGELOG.md
  81. 922 0
      docs/architecture/2026-07-20-SB3-upgrade-plan.md
  82. 705 0
      docs/architecture/Dify替代迁移可行性分析.md
  83. 569 0
      docs/architecture/SpringBoot3升级+Dify替代实施计划.md
  84. 1293 0
      docs/superpowers/plans/2026-07-20-langgraph-phase1.md
  85. 990 0
      docs/superpowers/plans/2026-07-20-langgraph-phase2.md
  86. 841 0
      docs/superpowers/plans/2026-07-20-langgraph-phase3.md
  87. 945 0
      docs/superpowers/plans/2026-07-20-langgraph-phase4.md
  88. 495 0
      docs/superpowers/specs/2026-07-20-langgraph-migration-design.md
  89. 1491 3797
      docs/参考资料/501999942-某人.json
  90. 1481 3789
      docs/参考资料/530010234-侯.json
  91. 1 2
      docs/参考资料/547982403.json
  92. 1 2
      docs/参考资料/556168709-mi.json
  93. binární
      docs/参考资料/556168709-mi.pdf
  94. 37 20
      docs/参考资料/extract_full_report_v5.py
  95. 1719 3014
      docs/参考资料/儿童示例.json
  96. 1 2
      docs/参考资料/朱评估报告.json
  97. binární
      web/img/ref-13.png
  98. binární
      web/img/ref-16.png
  99. binární
      web/img/ref-18.png
  100. binární
      web/img/ref-19.png

+ 0 - 16
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -410,20 +410,4 @@ public class AIChatController {
         return s;
     }
 
-    /**
-     * Dify Workflow HTTP 节点回调:获取用户上下文
-     * 用于意图分类后的上下文注入
-     */
-    @Operation(summary = "获取AI上下文(供Dify Workflow回调)", hidden = true)
-    @PostMapping("/context")
-    public Result<Map<String, Object>> getContext(@RequestBody Map<String, String> params) {
-        String userIdStr = params.get("user_id");
-        String conversationId = params.get("conversation_id");
-        if (userIdStr == null || conversationId == null) {
-            return Result.error("缺少参数: user_id, conversation_id");
-        }
-        Long userId = Long.valueOf(userIdStr);
-        Map<String, Object> ctx = aiService.getContext(userId, conversationId);
-        return Result.success(ctx);
-    }
 }

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/ContextApiController.java

@@ -0,0 +1,41 @@
+package com.etotem.cfc.controller.ai;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.AiContextService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 上下文数据 API (供 LangGraph Python 服务调用)
+ * 替代 Dify Workflow HTTP 回调的 /api/ai/context
+ */
+@Tag(name = "AI上下文", description = "供 LangGraph 服务调用的上下文数据接口")
+@RestController("aiContextApiController")
+@RequestMapping("/api/ai/context")
+public class ContextApiController {
+
+    @Resource
+    private AiContextService aiContextService;
+
+    @Operation(summary = "获取 AI 上下文数据", hidden = true)
+    @PostMapping("")
+    public Result<Map<String, Object>> getContext(@RequestBody Map<String, Object> params) {
+        String userIdStr = (String) params.get("user_id");
+        String intentType = (String) params.get("intent_type");
+        Map<String, Object> contextParams = (Map<String, Object>) params.get("params");
+
+        if (userIdStr == null) {
+            return Result.error("缺少参数: user_id");
+        }
+        Long userId = Long.valueOf(userIdStr);
+        Map<String, Object> ctx = aiContextService.getContext(intentType, userId, contextParams);
+        return Result.success(ctx);
+    }
+}

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/RecommendationController.java

@@ -3,9 +3,12 @@ package com.etotem.cfc.controller.ai;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.RecommendationQuery;
 import com.etotem.cfc.dto.RecommendationResult;
+import com.etotem.cfc.service.AiGateway;
 import com.etotem.cfc.service.RecommendationService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -21,13 +24,32 @@ import java.util.List;
 @RequestMapping("/api/recommend")
 public class RecommendationController {
 
+    private static final Logger log = LoggerFactory.getLogger(RecommendationController.class);
+
     @Resource
     private RecommendationService recommendationService;
 
+    @Resource
+    private AiGateway aiGateway;
+
     @Operation(summary = "按营养标签搜索推荐内容")
     @PostMapping("/search")
     public Result<List<RecommendationResult>> search(
             @RequestBody RecommendationQuery query) {
+
+        // 灰度: 5% 流量走 Python LangGraph
+        Long userId = query.getUserId() != null ? query.getUserId() : 0L;
+        if (aiGateway.isEnabled() && Math.random() < 0.05) {
+            List<RecommendationResult> pythonResults = aiGateway.recommend(
+                userId, query.getNutritionTags(), query.getTypes(), query.getLimit());
+            if (pythonResults != null && !pythonResults.isEmpty()) {
+                log.info("[灰度] Recommend via LangGraph, userId={}", userId);
+                return Result.success(pythonResults);
+            }
+            log.info("[灰度] LangGraph 无结果/异常, fallback 本地搜索");
+        }
+
+        // 现有业务逻辑
         List<RecommendationResult> results = recommendationService.search(query);
         return Result.success(results);
     }

+ 10 - 1
cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyFortuneMapper.java

@@ -11,7 +11,16 @@ public interface FamilyFortuneMapper extends BaseMapper<FamilyFortune> {
     /**
      * 获取家庭某天的运势信息
      */
-    @Select("SELECT * FROM family_fortune WHERE family_id = #{familyId} AND fortune_date = #{date} LIMIT 1")
+    @Select({
+        "SELECT id, family_id as familyId, date as fortuneDate, ",
+        "five_elements_main as dominantElement, ",
+        "fortune_level_text as fortuneLevelText, fortune_description as fortuneDescription, ",
+        "family_advice as dailyTip, focus_member_tip as weeklyTip, ",
+        "mood_score as moodScore, mood_emoji as moodEmoji, mood_text as moodText, ",
+        "created_at as createdAt, updated_at as updatedAt ",
+        "FROM tianpan_daily_fortune ",
+        "WHERE family_id = #{familyId} AND date = DATE_FORMAT(#{date}, '%Y-%m-%d') LIMIT 1"
+    })
     FamilyFortune selectByFamilyAndDate(@Param("familyId") Long familyId, @Param("date") LocalDate date);
     
     /**

+ 30 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java

@@ -32,6 +32,9 @@ public class AIService {
     @Resource
     private RestTemplate restTemplate;
 
+    @Resource
+    private AiGateway aiGateway;
+
     @Value("${dify.base-url}")
     private String difyBaseUrl;
 
@@ -76,19 +79,35 @@ public class AIService {
     }
 
     /**
-     * 发送聊天消息(阻塞模式)
-     *
-     * @param query          用户消息
-     * @param userId         用户标识(本地userId转字符串)
-     * @param conversationId 会话ID(空字符串=新建会话)
-     * @param inputs         Dify 应用变量(家庭上下文)
-     * @return answer + conversation_id
+     * 发送聊天消息: 优先 Python LangGraph, 失败回退 Dify
      */
     public Map<String, Object> sendMessage(String query, String userId,
                                            String conversationId,
                                            Map<String, Object> inputs) {
-        String url = difyBaseUrl + "/chat-messages";
+        Long uid = Long.valueOf(userId);
+
+        // 1. 尝试 Python LangGraph
+        Map<String, Object> pythonResult = aiGateway.chat(query, uid, conversationId, inputs);
+        if (pythonResult != null) {
+            log.debug("LangGraph chat 成功: userId={}", userId);
+            mirrorConversation(uid,
+                (String) pythonResult.getOrDefault("conversationId", conversationId),
+                "family", query, (String) pythonResult.get("answer"), inputs);
+            return pythonResult;
+        }
 
+        // 2. Fallback: Dify
+        log.info("LangGraph 不可用, fallback to Dify: userId={}", userId);
+        return sendMessageToDify(query, userId, conversationId, inputs);
+    }
+
+    /**
+     * 原有 Dify 发送逻辑 (提取为独立方法)
+     */
+    private Map<String, Object> sendMessageToDify(String query, String userId,
+                                                   String conversationId,
+                                                   Map<String, Object> inputs) {
+        String url = difyBaseUrl + "/chat-messages";
         Map<String, Object> body = new LinkedHashMap<>();
         body.put("query", query);
         body.put("user", userId);
@@ -104,8 +123,9 @@ public class AIService {
         if (resp.getBody() != null) {
             result.put("answer", resp.getBody().get("answer"));
             result.put("conversationId", resp.getBody().get("conversation_id"));
-            mirrorConversation(Long.valueOf(userId), (String) resp.getBody().get("conversation_id"),
-                    "family", query, (String) resp.getBody().get("answer"), inputs);
+            mirrorConversation(Long.valueOf(userId),
+                (String) resp.getBody().get("conversation_id"),
+                "family", query, (String) resp.getBody().get("answer"), inputs);
         }
         return result;
     }

+ 203 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java

@@ -0,0 +1,203 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.RecommendationResult;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.PostConstruct;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * LangGraph Python 服务网关
+ * 统一管理 Python 服务调用、熔断、Fallback
+ */
+@Service
+public class AiGateway {
+
+    private static final Logger log = LoggerFactory.getLogger(AiGateway.class);
+
+    @Value("${python.enabled:false}")
+    private boolean enabled;
+
+    @Value("${python.base-url:http://localhost:9000}")
+    private String baseUrl;
+
+    @Value("${python.timeout-ms:15000}")
+    private int timeoutMs;
+
+    @Value("${python.circuit-breaker.failure-threshold:3}")
+    private int failureThreshold;
+
+    @Value("${python.circuit-breaker.reset-timeout-ms:30000}")
+    private int resetTimeoutMs;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    // 熔断器状态
+    private final AtomicInteger consecutiveFailures = new AtomicInteger(0);
+    private final AtomicLong lastFailureTime = new AtomicLong(0);
+    private volatile boolean circuitOpen = false;
+
+    @PostConstruct
+    public void init() {
+        if (enabled) {
+            log.info("AiGateway 已启用: baseUrl={}, timeout={}ms", baseUrl, timeoutMs);
+        } else {
+            log.info("AiGateway 已禁用, 所有请求走 Dify");
+        }
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    private boolean isCircuitOpen() {
+        if (!circuitOpen) return false;
+        // 检查是否到重置时间
+        if (System.currentTimeMillis() - lastFailureTime.get() > resetTimeoutMs) {
+            circuitOpen = false;
+            consecutiveFailures.set(0);
+            log.info("AiGateway 熔断器已半开");
+            return false;
+        }
+        return true;
+    }
+
+    private void recordFailure() {
+        int failures = consecutiveFailures.incrementAndGet();
+        lastFailureTime.set(System.currentTimeMillis());
+        if (failures >= failureThreshold) {
+            circuitOpen = true;
+            log.warn("AiGateway 熔断器已打开 (连续{}次失败)", failures);
+        }
+    }
+
+    /**
+     * 调用 Python 推荐服务, 失败时返回 null (由调用方决定 fallback)
+     */
+    public List<RecommendationResult> recommend(Long userId, List<String> tags, List<String> types, Integer limit) {
+        if (!enabled || isCircuitOpen()) return null;
+
+        try {
+            // 使用 ObjectMapper 构造 JSON 替代手动拼接
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("user_id", userId);
+            if (tags != null && !tags.isEmpty()) {
+                ArrayNode tagsArray = body.putArray("tags");
+                tags.forEach(tagsArray::add);
+            }
+            if (types != null && !types.isEmpty()) {
+                ArrayNode typesArray = body.putArray("types");
+                types.forEach(typesArray::add);
+            }
+            body.put("limit", limit != null ? limit : 5);
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/recommend";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                JsonNode items = root.get("items");
+                if (items != null && items.isArray()) {
+                    List<RecommendationResult> results = new ArrayList<>();
+                    for (JsonNode item : items) {
+                        RecommendationResult r = new RecommendationResult();
+                        r.setType(item.get("type").asText());
+                        r.setId(item.get("id").asLong());
+                        r.setName(item.get("name").asText());
+                        r.setDescription(item.has("description") ? item.get("description").asText() : "");
+                        r.setReason(item.has("reason") ? item.get("reason").asText() : "");
+                        r.setSource("langgraph");
+                        results.add(r);
+                    }
+                    consecutiveFailures.set(0);
+                    log.debug("AiGateway recommend 成功: {} 条", results.size());
+                    return results;
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway recommend 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    /**
+     * 调用 Python ChatAgent, 失败时返回 null
+     */
+    public Map<String, Object> chat(String query, Long userId, String conversationId,
+                                    Map<String, Object> inputs) {
+        if (!enabled || isCircuitOpen()) return null;
+
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("query", query);
+            body.put("user_id", userId);
+            body.put("conversation_id", conversationId != null ? conversationId : "");
+
+            if (inputs != null && !inputs.isEmpty()) {
+                ObjectNode ctx = body.putObject("context");
+                inputs.forEach((key, value) -> {
+                    if (value instanceof String) ctx.put(key, (String) value);
+                    else if (value instanceof Number) ctx.put(key, ((Number) value).doubleValue());
+                    else if (value instanceof Boolean) ctx.put(key, (Boolean) value);
+                });
+            }
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/chat";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("answer", root.get("answer").asText());
+                result.put("conversationId", root.get("conversation_id").asText());
+                result.put("tasks", parseTasks(root.get("tasks")));
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway chat 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    private List<Map<String, Object>> parseTasks(JsonNode tasksNode) {
+        List<Map<String, Object>> tasks = new ArrayList<>();
+        if (tasksNode != null && tasksNode.isArray()) {
+            for (JsonNode task : tasksNode) {
+                Map<String, Object> t = new LinkedHashMap<>();
+                t.put("title", task.get("title").asText());
+                t.put("dimension", task.has("dimension") ? task.get("dimension").asText() : "");
+                t.put("rewardPoints", task.has("points") ? task.get("points").asInt() : 0);
+                tasks.add(t);
+            }
+        }
+        return tasks;
+    }
+
+    private org.springframework.http.HttpHeaders createJsonHeaders() {
+        org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
+        headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
+        return headers;
+    }
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DifySyncService.java

@@ -20,6 +20,12 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
+/**
+ * 知识库同步服务
+ * @deprecated 知识库同步已迁移到 LangGraph Python 服务 (cfc-langgraph/app/tasks/knowledge_sync.py)
+ * 计划在下一个大版本移除
+ */
+@Deprecated
 @Service
 public class DifySyncService {
 

+ 8 - 0
cfc-backend/src/main/resources/application.yml

@@ -89,6 +89,14 @@ dify:
   tongue-api-key: ""  # 舌诊分析,空字符串=mock模式
   callback-secret: dify-callback-secret  # Dify Workflow HTTP回调认证密钥
 
+python:
+  enabled: true
+  base-url: http://localhost:9000
+  timeout-ms: 15000
+  circuit-breaker:
+    failure-threshold: 3
+    reset-timeout-ms: 30000
+
 math:
   verify-mode: dify  # dify | eval; dify=走Dify工作流, eval=服务端计算(兜底)
   workflow-id: ""    # Dify 数学判题工作流 ID(留空则使用 eval 兜底)

+ 9 - 0
cfc-frontend/.postcssrc.js

@@ -0,0 +1,9 @@
+// PostCSS config for uni-app
+const path = require('path');
+module.exports = {
+  plugins: {
+    'autoprefixer': {
+      overrideBrowserslist: ['> 1%', 'last 2 versions', 'not dead']
+    }
+  }
+}

+ 1 - 1
cfc-frontend/components/FamilyMemberCard.vue

@@ -24,7 +24,7 @@ export default {
       return '/static/images/default-avatar.png'
     },
     roleBadgeColor: function() {
-      var role = member && (member.effectiveRole || member.memberType)
+      var role = this.member && (this.member.effectiveRole || this.member.memberType)
       if (role === 'parent') return '#F97316'
       if (role === 'child') return '#0EA5E9'
       if (role === 'teacher') return '#8B5CF6'

+ 1 - 1
cfc-frontend/components/RecommendedFeed.vue

@@ -7,7 +7,7 @@
     <view class="feed-list">
       <!-- 无数据时显示占位 -->
       <template v-if="(!items || items.length === 0) && !loading">
-        <view class="feed-card feed-placeholder" v-for="i in 4" :key="'ph-' + i">
+        <view class="feed-card feed-placeholder" v-for="i in 4" :key="i">
           <view class="feed-img-placeholder"></view>
           <view class="feed-info">
             <text class="feed-title-placeholder line-clamp-2">精彩内容即将上线</text>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 361 - 381
cfc-frontend/package-lock.json


+ 5 - 2
cfc-frontend/package.json

@@ -24,15 +24,18 @@
     "@dcloudio/uni-template-compiler": "^2.0.2-5010420260703001",
     "@dcloudio/webpack-uni-pages-loader": "^2.0.2-5010420260703001",
     "@vue/cli-plugin-babel": "^5.0.9",
-    "@vue/cli-service": "^5.0.9",
+    "@vue/cli-service": "^4.5.19",
     "babel-jest": "^26.6.3",
-    "copy-webpack-plugin": "^14.0.0",
+    "copy-webpack-plugin": "^5.1.2",
     "jest": "^26.6.3",
     "vue": "^2.7.16",
     "vuex": "^3.6.2"
   },
   "dependencies": {
     "@dcloudio/vue-cli-plugin-uni": "^2.0.2-5000720260410001",
+    "@dcloudio/webpack-uni-mp-loader": "^2.0.2-5010520260709001",
+    "postcss": "^8.4.49",
+    "regenerator-runtime": "^0.14.1",
     "yargs-parser": "^22.0.0"
   }
 }

+ 4 - 4
cfc-frontend/pages.json

@@ -1327,14 +1327,14 @@
       {
         "pagePath": "pages/wisdom/index",
         "text": "智",
-        "iconPath": "static/tab-wealth.png",
-        "selectedIconPath": "static/tab-wealth-active.png"
+        "iconPath": "static/tab-wisdom.png",
+        "selectedIconPath": "static/tab-wisdom-active.png"
       },
       {
         "pagePath": "pages/wealth/index",
         "text": "富",
-        "iconPath": "static/tab-profile.png",
-        "selectedIconPath": "static/tab-profile-active.png"
+        "iconPath": "static/tab-wealth.png",
+        "selectedIconPath": "static/tab-wealth-active.png"
       }
     ]
   },

+ 3 - 0
cfc-frontend/pages/index/parent-index.vue

@@ -423,6 +423,9 @@ export default {
     }
   },
   computed: {
+    topPendingTasks() {
+      return this.pendingTasks.slice(0, 3)
+    },
     energyScores() {
       var d = this.energyData
       if (!d) return { body: 0, mind: 0, wisdom: 0, action: 0, wealth: 0 }

+ 6 - 1
cfc-frontend/pages/parent/market/detail.vue

@@ -116,7 +116,12 @@ export default {
             })
             uni.showToast({ title: '应用成功' })
             setTimeout(() => {
-              uni.navigateBack()
+              var pages = getCurrentPages()
+              if (pages.length > 1) {
+                uni.navigateBack()
+              } else {
+                uni.switchTab({ url: '/pages/index/index' })
+              }
             }, 1500)
           } catch (e) {
             uni.showToast({ title: '应用失败', icon: 'none' })

+ 5 - 1
cfc-frontend/pages/profile/components/ProfileMenu.vue

@@ -19,7 +19,7 @@
       </view>
 
       <!-- ===== 服务 ===== -->
-      <view class="menu-group">
+      <view class="menu-group" v-if="showServices">
         <view class="menu-group-title">── 服务 ──</view>
         <view class="menu-item" @click="goToMembership">
           <text>👑 会员中心</text>
@@ -96,6 +96,10 @@ export default {
     role: {
       type: String,
       default: 'parent'
+    },
+    showServices: {
+      type: Boolean,
+      default: true
     }
   },
   data() {

+ 5 - 15
cfc-frontend/pages/profile/profile.vue

@@ -13,23 +13,17 @@
 
     <!-- ===== 已登录状态 ===== -->
     <template v-else>
-      <!-- 品牌头部 -->
-      <PageBanner theme="wealth" tagline="丰盈内心,富足生活,厚德载物" quote="" />
-
-      <!-- 用户卡片 + 富沛能量 + 切换 -->
+      <!-- 用户卡片 -->
       <ProfileHeader @avatar-click="goToEditInfo" />
 
-      <!-- 行动数据 + 财富数据 -->
-      <ProfileStats />
+      <!-- 成长报告 -->
+      <ProfileGrowth />
 
       <!-- 成就徽章 -->
       <ProfileBadges />
 
-      <!-- 成长报告 -->
-      <ProfileGrowth />
-
-      <!-- 菜单列表 -->
-      <ProfileMenu :role="role" @invite-generate="onInviteGenerate" />
+      <!-- 菜单列表(仅个人信息 + 设置,不含服务/商城板块) -->
+      <ProfileMenu :role="role" :show-services="false" @invite-generate="onInviteGenerate" />
     </template>
     <AIFloatingAvatar />
   </view>
@@ -37,9 +31,7 @@
 
 <script>
 import TabTransition from '../../components/tab-transition.vue'
-import PageBanner from '../../components/PageBanner.vue'
 import ProfileHeader from './components/ProfileHeader.vue'
-import ProfileStats from './components/ProfileStats.vue'
 import ProfileBadges from './components/ProfileBadges.vue'
 import ProfileGrowth from './components/ProfileGrowth.vue'
 import ProfileMenu from './components/ProfileMenu.vue'
@@ -48,9 +40,7 @@ import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 export default {
   components: {
     TabTransition,
-    PageBanner,
     ProfileHeader,
-    ProfileStats,
     ProfileBadges,
     ProfileGrowth,
     ProfileMenu,

+ 5 - 0
cfc-frontend/postcss.config.js

@@ -0,0 +1,5 @@
+// PostCSS config for uni-app mp-weixin build
+module.exports = {
+  plugins: []
+}
+

binární
cfc-frontend/static/tab-wisdom-active.png


binární
cfc-frontend/static/tab-wisdom.png


+ 6 - 0
cfc-frontend/vue.config.js

@@ -0,0 +1,6 @@
+// vue.config.js - disable thread-loader for compatibility
+module.exports = {
+  parallel: false,
+  // Ensure we use the correct source directory
+  pages: undefined,
+}

+ 26 - 0
cfc-langgraph/.env.example

@@ -0,0 +1,26 @@
+# LLM
+LLM_API_KEY=sk-your-key
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_MODEL=deepseek-chat
+
+# Embedding
+EMBEDDING_API_KEY=${LLM_API_KEY}
+EMBEDDING_BASE_URL=https://api.deepseek.com/v1
+EMBEDDING_MODEL=text-embedding-v3
+
+# Java Backend
+JAVA_BASE_URL=http://localhost:9082
+JAVA_CONTEXT_URL=${JAVA_BASE_URL}/api/ai/context
+
+# LangSmith (optional)
+LANGCHAIN_TRACING_V2=false
+LANGCHAIN_API_KEY=
+LANGCHAIN_PROJECT=cfc-langgraph
+
+# Service
+SERVICE_HOST=0.0.0.0
+SERVICE_PORT=9000
+LOG_LEVEL=info
+
+# Chroma
+CHROMA_DB_PATH=./data/chroma_db

+ 11 - 0
cfc-langgraph/.gitignore

@@ -0,0 +1,11 @@
+__pycache__/
+*.py[cod]
+.env
+data/
+*.egg-info/
+dist/
+.ruff_cache/
+.pytest_cache/
+
+# Un-ignore .py files (root .gitignore has *.py)
+!*.py

+ 35 - 0
cfc-langgraph/Dockerfile

@@ -0,0 +1,35 @@
+# ── 构建阶段 ──
+FROM python:3.11-slim AS builder
+
+WORKDIR /build
+COPY pyproject.toml .
+RUN pip install --no-cache-dir -e . && \
+    pip install --no-cache-dir gunicorn
+
+# ── 运行阶段 ──
+FROM python:3.11-slim
+
+ENV TZ=Asia/Shanghai
+RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
+
+WORKDIR /app
+
+COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
+COPY --from=builder /usr/local/bin/ /usr/local/bin/
+
+COPY app/ app/
+COPY data/ data/ 2>/dev/null || true
+
+RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
+USER appuser
+
+EXPOSE 9000
+
+CMD ["gunicorn", "app.main:app", \
+     "--worker-class", "uvicorn.workers.UvicornWorker", \
+     "--bind", "0.0.0.0:9000", \
+     "--workers", "2", \
+     "--timeout", "60", \
+     "--keep-alive", "10", \
+     "--access-logfile", "-", \
+     "--error-logfile", "-"]

+ 0 - 0
cfc-langgraph/app/__init__.py


+ 0 - 0
cfc-langgraph/app/agents/__init__.py


+ 25 - 0
cfc-langgraph/app/agents/analysis_agent.py

@@ -0,0 +1,25 @@
+from app.tools.java_client import JavaClient
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class AnalysisAgent:
+    """报告解读 Agent: 拉取报告数据 + 问卷数据 + LLM 分析"""
+
+    def __init__(self):
+        self.java = JavaClient()
+
+    async def get_report_summary(self, report_id: int) -> dict:
+        """获取报告概要 (用于 LLM 上下文)"""
+        try:
+            client = await self.java._get_client()
+            resp = await client.post("/api/health/report/summary", json={
+                "reportId": report_id,
+            })
+            data = resp.json()
+            if data.get("code") == 200:
+                return data.get("data", {})
+        except Exception as e:
+            logger.warning("获取报告概要失败: %s", e)
+        return {}

+ 35 - 0
cfc-langgraph/app/agents/chat_agent.py

@@ -0,0 +1,35 @@
+from typing import Optional
+from app.tools.java_client import JavaClient
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ChatAgent:
+    """家庭聊天 Agent: 包装业务逻辑, 被 StateGraph 节点调用"""
+
+    def __init__(self):
+        self.java = JavaClient()
+
+    async def load_context(self, user_id: int, child_id: Optional[int] = None) -> dict:
+        """从 Java 侧加载用户上下文"""
+        params = {}
+        if child_id:
+            params["childId"] = child_id
+        return await self.java.get_user_context(user_id, params)
+
+    async def extract_tasks(self, answer: str, user_id: int, conv_id: str) -> list[dict]:
+        """从 AI 回答中解析 [TASK] 标记"""
+        import re
+        tasks = []
+        pattern = r'\[TASK:\s*\{([^}]+)\}\]'
+        matches = re.findall(pattern, answer)
+        for match in matches:
+            task_info = {}
+            for kv in match.split(","):
+                if ":" in kv:
+                    k, v = kv.split(":", 1)
+                    task_info[k.strip()] = v.strip().strip('"').strip("'")
+            if task_info.get("title"):
+                tasks.append(task_info)
+        return tasks

+ 57 - 0
cfc-langgraph/app/agents/intent_classifier.py

@@ -0,0 +1,57 @@
+from enum import Enum
+from langchain_openai import ChatOpenAI
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class Intent(str, Enum):
+    CHAT = "chat"           # 日常聊天 / 育儿交流
+    ANALYSIS = "analysis"   # 报告解读 / 数据分析
+    RECOMMEND = "recommend" # 商品 / 活动推荐
+    TASK = "task"           # 任务创建 / 进度查询
+    MIND = "mind"           # 情绪疏导 / 心理支持
+    HEALTH = "health"       # 健康咨询 / 舌诊
+
+
+CLASSIFY_PROMPT = """从用户消息中识别意图, 只返回意图代码, 不要解释:
+
+- chat: 日常聊天、育儿交流、询问建议(非具体商品/报告)
+- analysis: 报告解读\数据分析\趋势查看(提到"报告"/"分析"/"评分")
+- recommend: 商品\活动\文章推荐(提到"推荐"/"买"/"吃什么"/"适合")
+- task: 任务相关(提到"任务"/"打卡"/"完成"/"奖励")
+- mind: 情绪问题\心理支持(提到"心情"/"难过"/"焦虑"/"不开心")
+- health: 健康咨询\舌诊(提到"舌"/"健康"/"体质")
+
+用户消息: {query}
+
+历史上下文: {context}
+
+意图代码:
+"""
+
+
+class IntentClassifier:
+    def __init__(self):
+        self.llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            temperature=0.1,
+            max_tokens=20,
+        )
+
+    async def classify(self, query: str, context: str = "") -> Intent:
+        prompt = CLASSIFY_PROMPT.format(query=query[:200], context=context[:300])
+        try:
+            resp = await self.llm.ainvoke(prompt)
+            intent_str = resp.content.strip().lower()
+            for intent in Intent:
+                if intent.value in intent_str:
+                    return intent
+            logger.warning("无法识别的意图: %s, 默认 chat", intent_str)
+            return Intent.CHAT
+        except Exception as e:
+            logger.warning("意图分类失败: %s, 默认 chat", e)
+            return Intent.CHAT

+ 79 - 0
cfc-langgraph/app/agents/multimodal_agent.py

@@ -0,0 +1,79 @@
+import httpx
+from typing import Optional
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class TongueDiagnosisAgent:
+    """舌诊分析 Agent
+
+    当前实现: 代理到 Dify Workflow (多模态最成熟)
+    后续可替换: 直接调用多模态 LLM API
+    """
+
+    def __init__(self):
+        self.dify_base = settings.dify_base_url or ""
+        self.dify_api_key = settings.dify_tongue_api_key or ""
+
+    async def diagnose(
+        self,
+        image_url: str,
+        user_id: int,
+        additional_context: Optional[dict] = None,
+    ) -> dict:
+        """舌诊分析: 调用 Dify Workflow 或直接 LLM"""
+        if self.dify_base and self.dify_api_key:
+            return await self._via_dify(image_url, user_id, additional_context)
+        else:
+            return await self._via_llm(image_url)
+
+    async def _via_dify(
+        self, image_url: str, user_id: int, context: Optional[dict]
+    ) -> dict:
+        """通过 Dify Workflow 执行舌诊"""
+        url = f"{self.dify_base}/workflows/run"
+        headers = {
+            "Authorization": f"Bearer {self.dify_api_key}",
+            "Content-Type": "application/json",
+        }
+        inputs = {"tongue_image": {"type": "image", "url": image_url}}
+        if context:
+            inputs.update(context)
+
+        body = {
+            "inputs": inputs,
+            "user": str(user_id),
+            "response_mode": "blocking",
+        }
+
+        try:
+            async with httpx.AsyncClient(timeout=30) as client:
+                resp = await client.post(url, json=body, headers=headers)
+                data = resp.json()
+                if "data" in data and "outputs" in data["data"]:
+                    return data["data"]["outputs"]
+        except Exception as e:
+            logger.warning("Dify 舌诊失败: %s", e)
+
+        return self._mock_result()
+
+    async def _via_llm(self, image_url: str) -> dict:
+        """直接调用多模态 LLM (预留)"""
+        logger.warning("多模态 LLM 未配置, 返回模拟数据")
+        return self._mock_result()
+
+    def _mock_result(self) -> dict:
+        return {
+            "overall_assessment": "舌象基本正常, 舌质淡红, 苔薄白, 提示脾胃功能尚可。",
+            "indicators": [
+                {"code": "tongue_color", "value": "淡红"},
+                {"code": "coating_color", "value": "薄白"},
+                {"code": "coating_texture", "value": "润"},
+                {"code": "fissure", "value": "无"},
+                {"code": "teeth_mark", "value": "轻"},
+                {"code": "sublingual_vein", "value": "正常"},
+                {"code": "constitution", "value": "平和质"},
+            ],
+        }

+ 0 - 0
cfc-langgraph/app/api/__init__.py


+ 45 - 0
cfc-langgraph/app/api/analyze.py

@@ -0,0 +1,45 @@
+from fastapi import APIRouter
+from pydantic import BaseModel
+from typing import Optional
+from app.graphs.analysis_graph import create_analysis_graph
+
+router = APIRouter(prefix="/api/v1", tags=["analyze"])
+
+_graph = None
+
+
+def get_graph():
+    global _graph
+    if _graph is None:
+        _graph = create_analysis_graph()
+    return _graph
+
+
+class AnalyzeRequest(BaseModel):
+    report_id: int
+    user_id: int
+    focus: Optional[str] = None
+
+
+class AnalyzeResponse(BaseModel):
+    analysis: str = ""
+    recommendations: list[str] = []
+    trace_id: str = ""
+
+
+@router.post("/analyze", response_model=AnalyzeResponse)
+async def analyze(req: AnalyzeRequest):
+    graph = get_graph()
+    import uuid
+    trace_id = str(uuid.uuid4())
+    result = await graph.ainvoke({
+        "report_id": req.report_id,
+        "user_id": req.user_id,
+        "focus": req.focus,
+        "report_data": None,
+        "survey_data": None,
+        "dimension_scores": None,
+        "analysis": None,
+        "recommendations": [],
+    })
+    return AnalyzeResponse(analysis=result.get("analysis", ""), trace_id=trace_id)

+ 58 - 0
cfc-langgraph/app/api/chat.py

@@ -0,0 +1,58 @@
+import uuid
+from fastapi import APIRouter
+from app.models.chat import ChatRequest, ChatResponse, SourceInfo
+from app.graphs.chat_graph import create_chat_graph
+
+router = APIRouter(prefix="/api/v1", tags=["chat"])
+
+_graph = None
+
+
+def get_graph():
+    global _graph
+    if _graph is None:
+        _graph = create_chat_graph()
+    return _graph
+
+
+@router.post("/chat", response_model=ChatResponse)
+async def chat(req: ChatRequest):
+    """家庭聊天: 意图分类→上下文→LLM→记忆"""
+    trace_id = str(uuid.uuid4())
+    graph = get_graph()
+
+    initial_state = {
+        "query": req.query,
+        "user_id": req.user_id,
+        "conversation_id": req.conversation_id,
+        "child_id": req.context.child_id if req.context else None,
+        "intent": None,
+        "context": None,
+        "messages": None,
+        "answer": None,
+        "tasks": [],
+        "sources": [],
+    }
+
+    config = {
+        "configurable": {"thread_id": req.conversation_id or str(req.user_id)},
+    }
+
+    result = await graph.ainvoke(initial_state, config)
+
+    sources = []
+    for s in result.get("sources", []):
+        sources.append(SourceInfo(
+            type=s.get("type", "tool"),
+            title=s.get("name", ""),
+        ))
+
+    conv_id = req.conversation_id or f"conv_{req.user_id}_{__import__('time').time()}"
+
+    return ChatResponse(
+        answer=result.get("answer", ""),
+        conversation_id=conv_id,
+        sources=sources,
+        tasks=result.get("tasks", []),
+        trace_id=trace_id,
+    )

+ 8 - 0
cfc-langgraph/app/api/health.py

@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/health")
+async def health():
+    return {"status": "ok"}

+ 42 - 0
cfc-langgraph/app/api/recommend.py

@@ -0,0 +1,42 @@
+import uuid
+from fastapi import APIRouter
+from app.models.recommend import RecommendRequest, RecommendResponse, RecommendItem
+from app.graphs.recommend_graph import RecommendAgent
+import logging
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["recommend"])
+
+_agent: RecommendAgent = None
+
+
+def get_agent() -> RecommendAgent:
+    global _agent
+    if _agent is None:
+        _agent = RecommendAgent()
+    return _agent
+
+
+@router.post("/recommend", response_model=RecommendResponse)
+async def recommend(req: RecommendRequest):
+    """营养推荐: Agent 搜索+LLM 解释"""
+    trace_id = str(uuid.uuid4())
+    try:
+        agent = get_agent()
+        result = await agent.run(query=req.query, tags=req.tags, limit=req.limit)
+
+        items_data = result.get("items", [])
+        items = []
+        for item in items_data:
+            items.append(RecommendItem(
+                type=item.get("type", "product"),
+                id=item.get("id", 0),
+                name=item.get("name", ""),
+                description=item.get("description", ""),
+                reason=item.get("reason", ""),
+            ))
+
+        return RecommendResponse(items=items, source="agent", trace_id=trace_id)
+    except Exception as e:
+        logger.error("RecommendAgent 调用失败: %s", e, exc_info=True)
+        return RecommendResponse(items=[], source="error", trace_id=trace_id)

+ 45 - 0
cfc-langgraph/app/api/tongue.py

@@ -0,0 +1,45 @@
+from fastapi import APIRouter, UploadFile, File, Form
+from typing import Optional
+from app.agents.multimodal_agent import TongueDiagnosisAgent
+import logging
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["tongue"])
+
+_agent: Optional[TongueDiagnosisAgent] = None
+
+
+def get_agent() -> TongueDiagnosisAgent:
+    global _agent
+    if _agent is None:
+        _agent = TongueDiagnosisAgent()
+    return _agent
+
+
+@router.post("/tongue/diagnose")
+async def tongue_diagnose(
+    file: UploadFile = File(...),
+    user_id: int = Form(...),
+):
+    """舌诊分析: 上传舌苔图片, 返回分析结果"""
+    agent = get_agent()
+
+    import tempfile, os
+    ext = os.path.splitext(file.filename or "tongue.jpg")[1] or ".jpg"
+    tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
+    content = await file.read()
+    tmp.write(content)
+    tmp.close()
+
+    try:
+        import base64
+        b64 = base64.b64encode(content).decode()
+        data_url = f"data:image/{ext[1:]};base64,{b64}"
+
+        result = await agent.diagnose(image_url=data_url, user_id=user_id)
+        return {"code": 200, "data": result}
+    except Exception as e:
+        logger.error("舌诊分析失败: %s", e, exc_info=True)
+        return {"code": 500, "message": "舌诊分析失败"}
+    finally:
+        os.unlink(tmp.name)

+ 52 - 0
cfc-langgraph/app/config.py

@@ -0,0 +1,52 @@
+from pydantic_settings import BaseSettings
+from typing import Optional
+
+
+class Settings(BaseSettings):
+    # LLM
+    llm_api_key: str
+    llm_base_url: str = "https://api.deepseek.com/v1"
+    llm_model: str = "deepseek-chat"
+
+    # Embedding
+    embedding_api_key: Optional[str] = None
+    embedding_base_url: Optional[str] = None
+    embedding_model: str = "text-embedding-v3"
+
+    # Java Backend
+    java_base_url: str = "http://localhost:9082"
+    java_context_url: Optional[str] = None
+
+    # LangSmith
+    langchain_tracing_v2: bool = False
+    langchain_api_key: Optional[str] = None
+    langchain_project: str = "cfc-langgraph"
+
+    # Service
+    service_host: str = "0.0.0.0"
+    service_port: int = 9000
+    log_level: str = "info"
+
+    # Dify Fallback
+    dify_base_url: Optional[str] = None
+    dify_tongue_api_key: Optional[str] = None
+
+    # Chroma
+    chroma_db_path: str = "./data/chroma_db"
+
+    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+
+    @property
+    def effective_embedding_api_key(self) -> str:
+        return self.embedding_api_key or self.llm_api_key
+
+    @property
+    def effective_embedding_base_url(self) -> str:
+        return self.embedding_base_url or self.llm_base_url
+
+    @property
+    def effective_java_context_url(self) -> str:
+        return self.java_context_url or f"{self.java_base_url}/api/ai/context"
+
+
+settings = Settings()

+ 0 - 0
cfc-langgraph/app/graphs/__init__.py


+ 96 - 0
cfc-langgraph/app/graphs/analysis_graph.py

@@ -0,0 +1,96 @@
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langgraph.checkpoint import MemorySaver
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.tools.report_tools import get_report_detail, get_survey_data, get_dimension_scores
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class AnalysisState(TypedDict):
+    report_id: int
+    user_id: int
+    focus: Optional[str]          # nutrition / gut / chronic / overall
+    report_data: Optional[dict]
+    survey_data: Optional[dict]
+    dimension_scores: Optional[dict]
+    analysis: Optional[str]
+    recommendations: list[str]
+
+
+ANALYSIS_SYSTEM_PROMPT = """你是一个儿童健康报告解读专家。根据健康报告数据和调研问卷, 提供专业、易懂的分析。
+
+分析原则:
+1. 用通俗语言解释各项指标含义
+2. 关注异常指标, 给出改善建议
+3. 结合问卷数据提供个性化分析
+4. 五维能量(身/心/智/行/富)角度解读整体状况
+5. 输出格式: 总体评估 → 分项分析 → 改善建议
+"""
+
+
+def create_analysis_graph():
+    """创建报告解读 StateGraph"""
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.3,
+    )
+    llm_with_tools = llm.bind_tools([
+        get_report_detail,
+        get_survey_data,
+        get_dimension_scores,
+    ])
+
+    builder = StateGraph(AnalysisState)
+
+    async def gather_data(state: AnalysisState) -> dict:
+        """收集报告 + 问卷 + 维度数据"""
+        result = {}
+        if state.get("report_id"):
+            try:
+                detail = await get_report_detail.ainvoke({"report_id": state["report_id"]})
+                result["report_data"] = detail
+            except Exception as e:
+                logger.warning("获取报告详情失败: %s", e)
+            try:
+                survey = await get_survey_data.ainvoke({"report_id": state["report_id"]})
+                result["survey_data"] = survey
+            except Exception as e:
+                logger.warning("获取问卷数据失败: %s", e)
+        return result
+
+    async def analyze(state: AnalysisState) -> dict:
+        """LLM 分析"""
+        context_parts = []
+        if state.get("report_data"):
+            context_parts.append(f"报告数据: {state['report_data']}")
+        if state.get("survey_data"):
+            context_parts.append(f"问卷数据: {state['survey_data']}")
+        if state.get("focus"):
+            context_parts.append(f"重点关注: {state['focus']}")
+
+        context_text = "\n".join(context_parts) if context_parts else "暂无数据"
+
+        messages = [
+            SystemMessage(content=ANALYSIS_SYSTEM_PROMPT),
+            SystemMessage(content=f"待分析数据:\n{context_text}"),
+            HumanMessage(content="请分析以上健康数据, 给出评估和建议。"),
+        ]
+
+        response = await llm_with_tools.ainvoke(messages)
+        return {"analysis": response.content}
+
+    builder.add_node("gather_data", gather_data)
+    builder.add_node("analyze", analyze)
+
+    builder.add_edge(START, "gather_data")
+    builder.add_edge("gather_data", "analyze")
+    builder.add_edge("analyze", END)
+
+    checkpointer = MemorySaver()
+    return builder.compile(checkpointer=checkpointer)

+ 169 - 0
cfc-langgraph/app/graphs/chat_graph.py

@@ -0,0 +1,169 @@
+from typing import TypedDict, Literal
+from langgraph.graph import StateGraph, START, END
+from langgraph.checkpoint import MemorySaver
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.agents.intent_classifier import IntentClassifier, Intent
+from app.agents.chat_agent import ChatAgent
+from app.tools.product_tools import (
+    search_product_by_keyword,
+    search_article_by_keyword,
+    search_activity_by_keyword,
+)
+from app.memory.store import MemoryManager
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ChatState(TypedDict):
+    query: str
+    user_id: int
+    conversation_id: str
+    child_id: int | None
+    intent: Intent | None
+    context: dict | None
+    messages: list | None
+    answer: str | None
+    tasks: list[dict]
+    sources: list[dict]
+
+
+CHAT_SYSTEM_PROMPT = """你是一个儿童成长家庭助手, 回答关于孩子成长、健康、教育的各种问题。
+
+你可以使用搜索工具查找商品、活动和文章来辅助回答。
+
+回答原则:
+1. 用中文, 语气温暖亲切
+2. 如果用户提到具体孩子, 参考提供的家庭上下文
+3. 需要推荐时使用搜索工具
+4. 可以生成 [TASK: {"title": "任务名", "dimension": "身/心/智/行/富", "points": 10}] 标记来创建行动任务
+5. 不要编造医疗建议, 严重问题建议咨询医生
+"""
+
+
+def create_chat_graph():
+    """创建聊天 StateGraph"""
+    agent = ChatAgent()
+    classifier = IntentClassifier()
+    memory_mgr = MemoryManager()
+
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.7,
+    )
+    llm_with_tools = llm.bind_tools([
+        search_product_by_keyword,
+        search_article_by_keyword,
+        search_activity_by_keyword,
+    ])
+
+    builder = StateGraph(ChatState)
+
+    # ── 节点 ──
+
+    async def classify_intent(state: ChatState) -> dict:
+        intent = await classifier.classify(
+            state["query"],
+            context=str(state.get("context", {})),
+        )
+        return {"intent": intent}
+
+    async def load_context(state: ChatState) -> dict:
+        ctx = await agent.load_context(state["user_id"], state.get("child_id"))
+        return {"context": ctx}
+
+    async def llm_call(state: ChatState) -> dict:
+        """核心 LLM 调用 + Tool"""
+        messages = [SystemMessage(content=CHAT_SYSTEM_PROMPT)]
+
+        # 注入家庭上下文
+        ctx = state.get("context", {})
+        if ctx:
+            ctx_text = f"\n家庭上下文:\n{ctx}"
+            messages.append(SystemMessage(content=ctx_text))
+
+        # 注入长期记忆
+        try:
+            memories = await memory_mgr.recall(state["user_id"], state["query"])
+            if memories:
+                mem_text = "\n".join([f"- {m}" for m in memories])
+                messages.append(SystemMessage(
+                    content=f"相关历史对话:\n{mem_text}"
+                ))
+        except Exception as e:
+            logger.warning("召回记忆失败: %s", e)
+
+        # 用户消息
+        messages.append(HumanMessage(content=state["query"]))
+
+        response = await llm_with_tools.ainvoke(messages)
+        answer = response.content
+
+        # 提取任务
+        tasks = await agent.extract_tasks(answer, state["user_id"], state["conversation_id"])
+
+        # 提取来源
+        sources = []
+        if response.response_metadata.get("tool_calls"):
+            for tc in response.response_metadata["tool_calls"]:
+                sources.append({
+                    "type": "tool",
+                    "name": tc.get("name", ""),
+                    "input": tc.get("args", {}),
+                })
+
+        return {
+            "answer": answer,
+            "tasks": tasks,
+            "sources": sources,
+            "messages": [{"role": "user", "content": state["query"]},
+                         {"role": "assistant", "content": answer}],
+        }
+
+    async def save_memory(state: ChatState) -> dict:
+        """对话后保存到长期记忆"""
+        try:
+            if state.get("messages"):
+                await memory_mgr.save_conversation(
+                    state["user_id"],
+                    state["conversation_id"],
+                    state["messages"],
+                )
+        except Exception as e:
+            logger.warning("保存记忆失败: %s", e)
+        return {}
+
+    # ── 路由 ──
+
+    def route_by_intent(state: ChatState) -> Literal["llm_call", END]:
+        if state["intent"] in (
+            Intent.RECOMMEND,
+            Intent.ANALYSIS,
+            Intent.HEALTH,
+        ):
+            # 这些意图需要更专业的 Agent (Phase 3 实现)
+            # 当前先走通用 LLM
+            pass
+        return "llm_call"
+
+    # ── 构建图 ──
+
+    builder.add_node("classify_intent", classify_intent)
+    builder.add_node("load_context", load_context)
+    builder.add_node("llm_call", llm_call)
+    builder.add_node("save_memory", save_memory)
+
+    builder.add_edge(START, "classify_intent")
+    builder.add_edge("classify_intent", "load_context")
+    builder.add_conditional_edges("load_context", route_by_intent)
+    builder.add_edge("llm_call", "save_memory")
+    builder.add_edge("save_memory", END)
+
+    checkpointer = MemorySaver()
+    graph = builder.compile(checkpointer=checkpointer)
+
+    return graph

+ 79 - 0
cfc-langgraph/app/graphs/recommend_graph.py

@@ -0,0 +1,79 @@
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.tools.product_tools import search_product_by_keyword, search_activity_by_keyword, search_article_by_keyword
+from app.config import settings
+from app.monitoring import monitor_agent
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT = """你是一个儿童成长营养推荐助手。根据用户的需求和营养标签, 推荐合适的商品、活动或文章。
+
+推荐原则:
+1. 首先尝试使用搜索工具查找匹配的内容
+2. 如果搜索结果为空, 基于你的知识给出建议
+3. 每项推荐必须附带推荐理由
+4. 以 JSON 格式输出推荐结果
+
+输出格式:
+{
+    "items": [
+        {
+            "source": "tool" 或 "knowledge",
+            "type": "product" / "activity" / "article",
+            "id": 数字,
+            "name": "名称",
+            "description": "描述",
+            "reason": "为什么推荐这个"
+        }
+    ]
+}
+"""
+
+
+class RecommendAgent:
+    def __init__(self):
+        self.llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            temperature=0.3,
+        )
+        self.tools = [
+            search_product_by_keyword,
+            search_activity_by_keyword,
+            search_article_by_keyword,
+        ]
+        self.llm_with_tools = self.llm.bind_tools(self.tools)
+
+    @monitor_agent("recommend")
+    async def run(self, query: str, tags: list[str], limit: int = 5) -> dict:
+        """执行推荐 Agent, 返回推荐结果"""
+        # 如果传入了 tags, 构造搜索关键词
+        search_query = query or " ".join(tags)
+
+        messages = [
+            SystemMessage(content=SYSTEM_PROMPT),
+            HumanMessage(content=f"用户需求: {search_query}\n最大返回数量: {limit}\n请搜索并推荐合适的内容。"),
+        ]
+
+        # LangChain Tool calling 自动完成: LLM 决定调哪个 Tool → 工具返回结果 → LLM 组织回答
+        response = await self.llm_with_tools.ainvoke(messages)
+
+        # 尝试解析 JSON 输出
+        content = response.content
+        try:
+            # 提取 JSON 块
+            if "```json" in content:
+                json_str = content.split("```json")[1].split("```")[0].strip()
+            elif "```" in content:
+                json_str = content.split("```")[1].split("```")[0].strip()
+            else:
+                json_str = content.strip()
+            result = json.loads(json_str)
+            return result
+        except (json.JSONDecodeError, IndexError):
+            # 非 JSON 输出, 包装为文本回答
+            logger.warning("Agent 输出非 JSON, raw: %s", content[:200])
+            return {"items": [], "text": content}

+ 44 - 0
cfc-langgraph/app/log_config.py

@@ -0,0 +1,44 @@
+import logging
+import json
+import sys
+from datetime import datetime, timezone
+
+
+class JsonFormatter(logging.Formatter):
+    """JSON 日志格式化器 (适合生产环境日志聚合)"""
+
+    def format(self, record: logging.LogRecord) -> str:
+        log_entry = {
+            "timestamp": datetime.now(timezone.utc).isoformat(),
+            "level": record.levelname,
+            "logger": record.name,
+            "message": record.getMessage(),
+        }
+        if hasattr(record, "trace_id"):
+            log_entry["trace_id"] = record.trace_id
+        if record.exc_info and record.exc_info[0]:
+            log_entry["exception"] = self.formatException(record.exc_info)
+        return json.dumps(log_entry, ensure_ascii=False)
+
+
+def setup_logging(level: str = "INFO", json_format: bool = False):
+    root = logging.getLogger()
+    root.setLevel(getattr(logging, level.upper(), logging.INFO))
+
+    root.handlers.clear()
+
+    handler = logging.StreamHandler(sys.stdout)
+
+    if json_format:
+        handler.setFormatter(JsonFormatter())
+    else:
+        handler.setFormatter(logging.Formatter(
+            "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+            datefmt="%Y-%m-%d %H:%M:%S",
+        ))
+
+    root.addHandler(handler)
+
+    logging.getLogger("httpx").setLevel(logging.WARNING)
+    logging.getLogger("chromadb").setLevel(logging.WARNING)
+    logging.getLogger("langchain").setLevel(logging.WARNING)

+ 74 - 0
cfc-langgraph/app/main.py

@@ -0,0 +1,74 @@
+import os
+import time
+import asyncio
+import logging
+from fastapi import FastAPI, Request
+from app.api import health, recommend, chat, analyze, tongue
+from app import monitoring
+
+logger = logging.getLogger(__name__)
+
+app = FastAPI(title="cfc-langgraph", version="0.3.0")
+
+app.include_router(health.router)
+app.include_router(recommend.router)
+app.include_router(chat.router)
+app.include_router(analyze.router)
+app.include_router(tongue.router)
+app.include_router(monitoring.router)
+
+
+@app.middleware("http")
+async def timing_middleware(request: Request, call_next):
+    start = time.perf_counter()
+    response = await call_next(request)
+    elapsed = time.perf_counter() - start
+
+    if elapsed > 5:
+        logger.warning("SLOW_REQUEST: %s %s took %.2fs",
+                       request.method, request.url.path, elapsed)
+    else:
+        logger.debug("REQUEST: %s %s took %.2fs",
+                     request.method, request.url.path, elapsed)
+
+    response.headers["X-Response-Time"] = f"{elapsed:.3f}s"
+    return response
+
+
+@app.on_event("startup")
+async def startup():
+    from app.config import settings
+    from app.log_config import setup_logging
+
+    json_logs = os.getenv("JSON_LOGS", "false").lower() == "true"
+    setup_logging(level=settings.log_level, json_format=json_logs)
+
+    if os.getenv("LANGCHAIN_TRACING_V2", "").lower() == "true":
+        logger.info(
+            "LangSmith 已启用: project=%s, api_key=%s...",
+            settings.langchain_project,
+            settings.langchain_api_key[:8] if settings.langchain_api_key else "none",
+        )
+
+    from app.rag.retriever import RagRetriever
+    retriever = RagRetriever()
+    await retriever.initialize()
+
+    async def schedule_kb_sync():
+        while True:
+            try:
+                from app.tasks.knowledge_sync import sync_knowledge_base
+                await sync_knowledge_base()
+            except Exception as e:
+                logger.warning("知识库同步失败: %s", e)
+            await asyncio.sleep(3600)
+
+    asyncio.create_task(schedule_kb_sync())
+    logger.info("知识库定时同步已启动 (间隔: 3600s)")
+
+
+@app.on_event("shutdown")
+async def shutdown():
+    from app.tools.java_client import JavaClient
+    client = JavaClient()
+    await client.close()

+ 1 - 0
cfc-langgraph/app/memory/__init__.py

@@ -0,0 +1 @@
+from .store import MemoryManager

+ 89 - 0
cfc-langgraph/app/memory/store.py

@@ -0,0 +1,89 @@
+from langchain.memory import ConversationSummaryBufferMemory, VectorStoreRetrieverMemory
+from langchain_openai import ChatOpenAI, OpenAIEmbeddings
+from langchain_chroma import Chroma
+from app.config import settings
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class MemoryManager:
+    """三层记忆管理器
+
+    Layer 1 - 工作记忆: 最近 20 轮 + 超出自动摘要
+    Layer 2 - 长期事实: VectorStoreRetrieverMemory, 跨会话相似召回
+    Layer 3 - 语义记忆: 每次对话后向量化存储, 供后续会话召回
+    """
+
+    def __init__(self):
+        self.llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+        )
+        self.embeddings = OpenAIEmbeddings(
+            model=settings.embedding_model,
+            api_key=settings.effective_embedding_api_key,
+            base_url=settings.effective_embedding_base_url,
+        )
+        # Layer 3 向量库: 历史对话记忆
+        self.memory_vectorstore = Chroma(
+            collection_name="user_memory",
+            embedding_function=self.embeddings,
+            persist_directory=settings.chroma_db_path + "_memory",
+        )
+
+    def get_working_memory(self) -> ConversationSummaryBufferMemory:
+        """Layer 1: 工作记忆 (当前会话)"""
+        return ConversationSummaryBufferMemory(
+            llm=self.llm,
+            max_token_limit=2000,
+            memory_key="history",
+            return_messages=True,
+        )
+
+    def get_longterm_memory(self, user_id: int) -> VectorStoreRetrieverMemory:
+        """Layer 2+3: 长期 + 语义记忆"""
+        return VectorStoreRetrieverMemory(
+            retriever=self.memory_vectorstore.as_retriever(
+                search_kwargs={
+                    "k": 3,
+                    "filter": {"user_id": user_id},
+                }
+            ),
+            memory_key="long_term_memory",
+            input_key="input",
+        )
+
+    async def save_conversation(self, user_id: int, conversation_id: str,
+                                messages: list[dict]):
+        """会话结束后保存到向量记忆库"""
+        texts = []
+        for msg in messages:
+            role = msg.get("role", "unknown")
+            content = msg.get("content", "")
+            texts.append(f"[{role}] {content}")
+
+        full_text = "\n".join(texts)
+        metadata = {
+            "user_id": user_id,
+            "conversation_id": conversation_id,
+            "timestamp": str(__import__("datetime").datetime.now()),
+        }
+
+        await self.memory_vectorstore.aadd_texts(
+            texts=[full_text],
+            metadatas=[metadata],
+        )
+        self.memory_vectorstore.persist()
+        logger.info("已保存对话到向量记忆: conv=%s, user=%s", conversation_id, user_id)
+
+    async def recall(self, user_id: int, query: str, k: int = 3) -> list[str]:
+        """语义召回: 查询与 query 最相似的历史对话片段"""
+        results = self.memory_vectorstore.similarity_search(
+            query,
+            k=k,
+            filter={"user_id": user_id},
+        )
+        return [doc.page_content for doc in results]

+ 3 - 0
cfc-langgraph/app/models/__init__.py

@@ -0,0 +1,3 @@
+from .common import UserContext, SourceInfo
+from .chat import ChatRequest, ChatResponse
+from .recommend import RecommendRequest, RecommendResponse, RecommendItem

+ 18 - 0
cfc-langgraph/app/models/chat.py

@@ -0,0 +1,18 @@
+from pydantic import BaseModel
+from typing import Optional
+from .common import UserContext, SourceInfo
+
+
+class ChatRequest(BaseModel):
+    query: str
+    user_id: int
+    conversation_id: str = ""
+    context: Optional[UserContext] = None
+
+
+class ChatResponse(BaseModel):
+    answer: str
+    conversation_id: str
+    sources: list[SourceInfo] = []
+    tasks: list[dict] = []
+    trace_id: str = ""

+ 18 - 0
cfc-langgraph/app/models/common.py

@@ -0,0 +1,18 @@
+from pydantic import BaseModel
+from typing import Optional
+
+
+class UserContext(BaseModel):
+    """Java 侧传来的业务上下文"""
+    child_id: Optional[int] = None
+    report_id: Optional[int] = None
+    survey_id: Optional[int] = None
+    family_id: Optional[int] = None
+    mascot_code: Optional[str] = None
+
+
+class SourceInfo(BaseModel):
+    """回答引用来源"""
+    type: str  # knowledge / tool
+    title: str
+    score: Optional[float] = None

+ 29 - 0
cfc-langgraph/app/models/recommend.py

@@ -0,0 +1,29 @@
+from pydantic import BaseModel
+from typing import Optional
+
+
+class RecommendItem(BaseModel):
+    type: str  # product / activity / article
+    id: int
+    name: str
+    description: str = ""
+    cover_image: str = ""
+    price: Optional[float] = None
+    url: str = ""
+    score: float = 0.0
+    reason: str = ""
+
+
+class RecommendRequest(BaseModel):
+    user_id: int
+    query: str = ""
+    tags: list[str] = []
+    types: Optional[list[str]] = None
+    limit: int = 5
+    context: Optional[dict] = None
+
+
+class RecommendResponse(BaseModel):
+    items: list[RecommendItem]
+    source: str = ""  # sql / vector / hybrid
+    trace_id: str = ""

+ 137 - 0
cfc-langgraph/app/monitoring.py

@@ -0,0 +1,137 @@
+"""Prometheus 监控指标"""
+from prometheus_client import Counter, Histogram, Gauge, generate_latest
+from fastapi import APIRouter, Response
+import time
+import functools
+
+
+llm_calls_total = Counter(
+    "llm_calls_total", "Total LLM API calls",
+    ["model", "status"],
+)
+llm_duration_seconds = Histogram(
+    "llm_duration_seconds", "LLM call duration",
+    ["model"],
+    buckets=(0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0),
+)
+llm_tokens_total = Counter(
+    "llm_tokens_total", "Total tokens used",
+    ["model", "type"],
+)
+
+rag_retrievals_total = Counter(
+    "rag_retrievals_total", "Total RAG retrievals",
+    ["method"],
+)
+rag_duration_seconds = Histogram(
+    "rag_duration_seconds", "RAG retrieval duration",
+    ["method"],
+    buckets=(0.01, 0.05, 0.1, 0.5, 1.0),
+)
+
+agent_calls_total = Counter(
+    "agent_calls_total", "Total Agent invocations",
+    ["agent_type"],
+)
+agent_duration_seconds = Histogram(
+    "agent_duration_seconds", "Agent execution duration",
+    ["agent_type"],
+    buckets=(0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0),
+)
+
+java_calls_total = Counter(
+    "java_calls_total", "Total calls to Java backend",
+    ["endpoint", "status"],
+)
+java_duration_seconds = Histogram(
+    "java_duration_seconds", "Java backend call duration",
+    ["endpoint"],
+    buckets=(0.01, 0.05, 0.1, 0.5, 1.0, 2.0),
+)
+
+kb_sync_duration = Gauge(
+    "kb_sync_duration_seconds", "Last knowledge base sync duration"
+)
+kb_sync_documents = Gauge(
+    "kb_sync_documents_total", "Documents processed in last sync"
+)
+
+
+def monitor_agent(agent_type: str):
+    """Agent 性能监控装饰器"""
+    def decorator(func):
+        @functools.wraps(func)
+        async def wrapper(*args, **kwargs):
+            agent_calls_total.labels(agent_type=agent_type).inc()
+            start = time.perf_counter()
+            try:
+                result = await func(*args, **kwargs)
+                agent_duration_seconds.labels(agent_type=agent_type).observe(
+                    time.perf_counter() - start)
+                return result
+            except Exception as e:
+                agent_duration_seconds.labels(agent_type=agent_type).observe(
+                    time.perf_counter() - start)
+                raise
+        return wrapper
+    return decorator
+
+
+router = APIRouter(tags=["monitoring"])
+
+
+@router.get("/metrics")
+async def metrics():
+    return Response(
+        content=generate_latest(),
+        media_type="text/plain; charset=utf-8",
+    )
+
+
+@router.get("/api/v1/health")
+async def detailed_health():
+    """详细健康检查 (含组件状态)"""
+    from app.config import settings
+    status = {"status": "ok", "components": {}}
+
+    try:
+        import os
+        chroma_path = settings.chroma_db_path
+        status["components"]["chromadb"] = {
+            "status": "ok",
+            "path": chroma_path,
+            "exists": os.path.exists(chroma_path),
+        }
+    except Exception as e:
+        status["components"]["chromadb"] = {"status": "error", "message": str(e)}
+        status["status"] = "degraded"
+
+    try:
+        from langchain_openai import ChatOpenAI
+        llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            max_tokens=5,
+        )
+        await llm.ainvoke("ping")
+        status["components"]["llm"] = {"status": "ok"}
+    except Exception as e:
+        status["components"]["llm"] = {"status": "error", "message": str(e)}
+        status["status"] = "degraded"
+
+    try:
+        from app.tools.java_client import JavaClient
+        client = JavaClient()
+        jc = await client._get_client()
+        resp = await jc.get("/health")
+        if resp.status_code == 200:
+            status["components"]["java_backend"] = {"status": "ok"}
+        else:
+            status["components"]["java_backend"] = {"status": "error", "code": resp.status_code}
+            status["status"] = "degraded"
+    except Exception as e:
+        status["components"]["java_backend"] = {"status": "error", "message": str(e)}
+        status["status"] = "degraded"
+
+    return status

+ 2 - 0
cfc-langgraph/app/rag/__init__.py

@@ -0,0 +1,2 @@
+from .embeddings import get_embeddings
+from .retriever import RagRetriever

+ 15 - 0
cfc-langgraph/app/rag/embeddings.py

@@ -0,0 +1,15 @@
+from langchain_openai import OpenAIEmbeddings
+from app.config import settings
+
+_embeddings = None
+
+
+def get_embeddings():
+    global _embeddings
+    if _embeddings is None:
+        _embeddings = OpenAIEmbeddings(
+            model=settings.embedding_model,
+            api_key=settings.effective_embedding_api_key,
+            base_url=settings.effective_embedding_base_url,
+        )
+    return _embeddings

+ 49 - 0
cfc-langgraph/app/rag/loader.py

@@ -0,0 +1,49 @@
+from app.tools.java_client import JavaClient
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class KnowledgeLoader:
+    """知识库加载器: 从 Java 侧拉取文章并格式化"""
+
+    def __init__(self):
+        self.java = JavaClient()
+
+    async def load_all_articles(self) -> list[dict]:
+        """获取所有已发布文章"""
+        return await self.java.get_published_articles()
+
+    async def load_updated_since(self, since: str) -> list[dict]:
+        """增量获取: 获取某个时间后更新的文章"""
+        try:
+            client = await self.java._get_client()
+            resp = await client.post("/api/article/updated-since", json={
+                "since": since,
+                "status": "published",
+            })
+            data = resp.json()
+            if data.get("code") == 200:
+                return data.get("data", [])
+        except Exception as e:
+            logger.warning("增量获取文章失败: %s", e)
+        return []
+
+    def format_for_indexing(self, articles: list[dict]) -> list[dict]:
+        """将文章格式化为可索引的文档"""
+        docs = []
+        for article in articles:
+            content = f"{article.get('title', '')}\n\n{article.get('summary', '')}\n\n{article.get('content', '')}"
+            docs.append({
+                "id": f"article_{article['id']}",
+                "content": content,
+                "metadata": {
+                    "source": "article",
+                    "article_id": article["id"],
+                    "title": article.get("title", ""),
+                    "tags": article.get("tags", ""),
+                    "updated_at": article.get("updatedAt", ""),
+                },
+            })
+        return docs

+ 109 - 0
cfc-langgraph/app/rag/retriever.py

@@ -0,0 +1,109 @@
+from langchain_chroma import Chroma
+from langchain.retrievers import EnsembleRetriever
+from langchain_community.retrievers import BM25Retriever
+from langchain.retrievers.document_compressors import LLMChainExtractor
+from langchain.retrievers import ContextualCompressionRetriever
+from langchain_openai import ChatOpenAI
+from .embeddings import get_embeddings
+from app.config import settings
+from app.tools.java_client import JavaClient
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class RagRetriever:
+    """升级版混合检索器: 向量 + BM25 + LLM 压缩重排序"""
+
+    def __init__(self, collection_name: str = "cfc_knowledge"):
+        embeddings = get_embeddings()
+        self.vectorstore = Chroma(
+            collection_name=collection_name,
+            embedding_function=embeddings,
+            persist_directory=settings.chroma_db_path,
+        )
+        self.java_client = JavaClient()
+        self._bm25_retriever: Optional[BM25Retriever] = None
+        self._bm25_texts: list[str] = []
+
+    async def initialize(self):
+        """从 Java 侧拉取知识库, 构建 BM25 索引"""
+        try:
+            articles = await self.java_client.get_published_articles()
+            self._bm25_texts = [
+                f"{a['title']} {a['summary']} {a.get('tags', '')}"
+                for a in articles
+            ]
+            if self._bm25_texts:
+                self._bm25_retriever = BM25Retriever.from_texts(
+                    self._bm25_texts,
+                    metadatas=articles,
+                )
+                logger.info("BM25 索引就绪: %d 条", len(self._bm25_texts))
+        except Exception as e:
+            logger.warning("BM25 初始化失败: %s", e)
+
+    async def retrieve(
+        self,
+        query: str,
+        filters: Optional[dict] = None,
+        k: int = 5,
+        use_compression: bool = True,
+    ) -> list[dict]:
+        """混合检索 + 可选 LLM 压缩重排序"""
+        retrievers = []
+
+        # 1. 向量检索 (多取一些方便后续 ensemble 排序)
+        vector_retriever = self.vectorstore.as_retriever(
+            search_kwargs={"k": k * 2, "filter": filters},
+        )
+        retrievers.append(vector_retriever)
+
+        # 2. BM25 关键词检索
+        if self._bm25_retriever:
+            bm25_k = self._bm25_retriever.k
+            self._bm25_retriever.k = k * 2
+            retrievers.append(self._bm25_retriever)
+            self._bm25_retriever.k = bm25_k
+
+        if len(retrievers) == 1:
+            docs = await retrievers[0].ainvoke(query)
+            ensemble = retrievers[0]
+        else:
+            ensemble = EnsembleRetriever(
+                retrievers=retrievers,
+                weights=[0.6, 0.4],
+            )
+            docs = await ensemble.ainvoke(query)
+
+        # 3. LLM 压缩 (剔除不相关内容)
+        if use_compression and docs:
+            llm = ChatOpenAI(
+                model=settings.llm_model,
+                api_key=settings.llm_api_key,
+                base_url=settings.llm_base_url,
+                temperature=0,
+            )
+            compressor = LLMChainExtractor.from_llm(llm)
+            compression_retriever = ContextualCompressionRetriever(
+                base_compressor=compressor,
+                base_retriever=ensemble if len(retrievers) > 1 else retrievers[0],
+            )
+            docs = await compression_retriever.ainvoke(query)
+
+        # 4. 格式化为统一输出 + 去重
+        results = []
+        seen = set()
+        for doc in docs:
+            content_hash = hash(doc.page_content[:100])
+            if content_hash in seen:
+                continue
+            seen.add(content_hash)
+            results.append({
+                "content": doc.page_content,
+                "metadata": doc.metadata,
+                "score": doc.metadata.get("score", 0) if hasattr(doc, "metadata") else 0,
+            })
+
+        return results[:k]

+ 21 - 0
cfc-langgraph/app/rag/splitter.py

@@ -0,0 +1,21 @@
+from langchain.text_splitter import RecursiveCharacterTextSplitter
+
+
+def get_knowledge_splitter() -> RecursiveCharacterTextSplitter:
+    """知识库文档分块器"""
+    return RecursiveCharacterTextSplitter(
+        chunk_size=500,
+        chunk_overlap=50,
+        separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
+        length_function=len,
+    )
+
+
+def get_summary_splitter() -> RecursiveCharacterTextSplitter:
+    """摘要分块器 (用于对话记忆)"""
+    return RecursiveCharacterTextSplitter(
+        chunk_size=1000,
+        chunk_overlap=100,
+        separators=["\n\n", "\n", "。", " ", ""],
+        length_function=len,
+    )

+ 0 - 0
cfc-langgraph/app/tasks/__init__.py


+ 77 - 0
cfc-langgraph/app/tasks/knowledge_sync.py

@@ -0,0 +1,77 @@
+"""知识库同步定时任务: 定期从 Java 拉取文章, 更新 ChromaDB"""
+from app.rag.loader import KnowledgeLoader
+from app.rag.splitter import get_knowledge_splitter
+from app.rag.embeddings import get_embeddings
+from app.config import settings
+from langchain_chroma import Chroma
+from langchain_core.documents import Document
+import logging
+import os
+import json
+
+logger = logging.getLogger(__name__)
+
+SYNC_STATE_FILE = os.path.join(settings.chroma_db_path, ".sync_state")
+
+
+def _load_sync_state() -> dict:
+    try:
+        if os.path.exists(SYNC_STATE_FILE):
+            with open(SYNC_STATE_FILE) as f:
+                return json.load(f)
+    except Exception:
+        pass
+    return {"last_sync": "2000-01-01T00:00:00"}
+
+
+def _save_sync_state(state: dict):
+    os.makedirs(os.path.dirname(SYNC_STATE_FILE), exist_ok=True)
+    with open(SYNC_STATE_FILE, "w") as f:
+        json.dump(state, f)
+
+
+async def sync_knowledge_base():
+    """执行知识库同步 (全量+增量)"""
+    loader = KnowledgeLoader()
+    splitter = get_knowledge_splitter()
+    embeddings = get_embeddings()
+
+    vectorstore = Chroma(
+        collection_name="cfc_knowledge",
+        embedding_function=embeddings,
+        persist_directory=settings.chroma_db_path,
+    )
+
+    state = _load_sync_state()
+
+    articles = await loader.load_updated_since(state["last_sync"])
+    if not articles:
+        logger.info("知识库同步: 无更新内容")
+        return
+
+    docs = loader.format_for_indexing(articles)
+
+    chunks = []
+    for doc in docs:
+        split_texts = splitter.split_text(doc["content"])
+        for i, text in enumerate(split_texts):
+            metadata = dict(doc["metadata"])
+            metadata["chunk_index"] = i
+            chunks.append(Document(page_content=text, metadata=metadata))
+
+    if not chunks:
+        logger.info("知识库同步: 无新增块")
+        return
+
+    BATCH_SIZE = 100
+    for i in range(0, len(chunks), BATCH_SIZE):
+        batch = chunks[i:i + BATCH_SIZE]
+        await vectorstore.aadd_documents(batch)
+        vectorstore.persist()
+        logger.debug("知识库同步进度: %d/%d", i + len(batch), len(chunks))
+
+    import datetime
+    state["last_sync"] = datetime.datetime.now().isoformat()
+    _save_sync_state(state)
+
+    logger.info("知识库同步完成: 新增 %d 篇文章, %d 个块", len(articles), len(chunks))

+ 1 - 0
cfc-langgraph/app/tools/__init__.py

@@ -0,0 +1 @@
+from .java_client import JavaClient

+ 88 - 0
cfc-langgraph/app/tools/java_client.py

@@ -0,0 +1,88 @@
+import httpx
+from typing import Optional
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class JavaClient:
+    """Java 后端 HTTP 客户端 (单例, 复用连接池)"""
+
+    _instance: Optional["JavaClient"] = None
+
+    def __new__(cls):
+        if cls._instance is None:
+            cls._instance = super().__new__(cls)
+            cls._instance._client = None
+            cls._instance._base_url = settings.java_base_url
+        return cls._instance
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        if self._client is None:
+            limits = httpx.Limits(
+                max_connections=10,
+                max_keepalive_connections=5,
+                keepalive_expiry=30,
+            )
+            self._client = httpx.AsyncClient(
+                base_url=self._base_url,
+                timeout=httpx.Timeout(10.0, connect=3.0),
+                limits=limits,
+            )
+        return self._client
+
+    async def close(self):
+        if self._client:
+            await self._client.aclose()
+            self._client = None
+            JavaClient._instance = None
+
+    async def get_published_articles(self) -> list[dict]:
+        client = await self._get_client()
+        resp = await client.post("/api/article/list", json={"status": "published", "limit": 1000})
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def search_products(self, keyword: str, limit: int = 5) -> list[dict]:
+        client = await self._get_client()
+        resp = await client.post("/api/product/search", json={
+            "keyword": keyword, "status": "上架", "limit": limit
+        })
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def search_activities(self, keyword: str, limit: int = 5) -> list[dict]:
+        client = await self._get_client()
+        resp = await client.post("/api/activity/search", json={
+            "keyword": keyword, "status": "published", "limit": limit
+        })
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def search_articles(self, keyword: str, limit: int = 5) -> list[dict]:
+        client = await self._get_client()
+        resp = await client.post("/api/article/search", json={
+            "keyword": keyword, "status": "published", "limit": limit
+        })
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def get_user_context(self, user_id: int, params: Optional[dict] = None) -> dict:
+        client = await self._get_client()
+        resp = await client.post(settings.effective_java_context_url, json={
+            "user_id": str(user_id),
+            "params": params or {},
+        })
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", {})
+        return {}

+ 58 - 0
cfc-langgraph/app/tools/product_tools.py

@@ -0,0 +1,58 @@
+from langchain_core.tools import tool
+from app.tools.java_client import JavaClient
+import logging
+
+logger = logging.getLogger(__name__)
+_java = JavaClient()
+
+
+@tool
+async def search_product_by_keyword(keyword: str, limit: int = 5) -> str:
+    """按关键词搜索上架商品, 返回 JSON 商品列表"""
+    try:
+        products = await _java.search_products(keyword, limit)
+        if not products:
+            return "[]"
+        return str([{
+            "id": p["id"],
+            "name": p["name"],
+            "price": p.get("price"),
+            "description": p.get("intro") or p.get("description", ""),
+        } for p in products])
+    except Exception as e:
+        logger.warning("搜索商品失败: %s", e)
+        return "[]"
+
+
+@tool
+async def search_activity_by_keyword(keyword: str, limit: int = 5) -> str:
+    """按关键词搜索进行中的活动, 返回 JSON 活动列表"""
+    try:
+        activities = await _java.search_activities(keyword, limit)
+        if not activities:
+            return "[]"
+        return str([{
+            "id": a["id"],
+            "name": a["title"],
+            "description": a.get("description", ""),
+        } for a in activities])
+    except Exception as e:
+        logger.warning("搜索活动失败: %s", e)
+        return "[]"
+
+
+@tool
+async def search_article_by_keyword(keyword: str, limit: int = 5) -> str:
+    """按关键词搜索已发布文章, 返回 JSON 文章列表"""
+    try:
+        articles = await _java.search_articles(keyword, limit)
+        if not articles:
+            return "[]"
+        return str([{
+            "id": a["id"],
+            "name": a["title"],
+            "summary": a.get("summary", ""),
+        } for a in articles])
+    except Exception as e:
+        logger.warning("搜索文章失败: %s", e)
+        return "[]"

+ 63 - 0
cfc-langgraph/app/tools/report_tools.py

@@ -0,0 +1,63 @@
+from langchain_core.tools import tool
+from app.tools.java_client import JavaClient
+import logging
+
+logger = logging.getLogger(__name__)
+_java = JavaClient()
+
+
+@tool
+async def get_report_detail(report_id: int) -> str:
+    """获取健康报告的完整详情, 包含各维度评分和解读"""
+    try:
+        client = _java
+        resp = await client._get_client().post(
+            "/api/health/report/detail",
+            json={"reportId": report_id},
+        )
+        data = resp.json()
+        if data.get("code") == 200:
+            import json
+            return json.dumps(data.get("data", {}), ensure_ascii=False)
+        return "{}"
+    except Exception as e:
+        logger.warning("获取报告详情失败: %s", e)
+        return "{}"
+
+
+@tool
+async def get_survey_data(report_id: int) -> str:
+    """获取与报告关联的调研问卷数据"""
+    try:
+        client = _java
+        resp = await client._get_client().post(
+            "/api/survey/completed",
+            json={"reportId": report_id},
+        )
+        data = resp.json()
+        if data.get("code") == 200:
+            import json
+            return json.dumps(data.get("data", {}), ensure_ascii=False)
+        return "{}"
+    except Exception as e:
+        logger.warning("获取问卷数据失败: %s", e)
+        return "{}"
+
+
+@tool
+async def get_dimension_scores(family_id: int) -> str:
+    """获取家庭成员的五维能量分数 (身/心/智/行/富)"""
+    try:
+        client = _java
+        resp = await client._get_client().post(
+            "/api/dimension/scores",
+            json={"familyId": family_id},
+        )
+        data = resp.json()
+        if data.get("code") == 200:
+            import json
+            return json.dumps(data.get("data", {}), ensure_ascii=False)
+        return "{}"
+    except Exception as e:
+        logger.warning("获取维度分数失败: %s", e)
+        return "{}"

+ 65 - 0
cfc-langgraph/docker-compose.yml

@@ -0,0 +1,65 @@
+version: "3.8"
+
+services:
+  cfc-backend:
+    build:
+      context: ../cfc-backend
+      dockerfile: Dockerfile
+    ports:
+      - "9082:9082"
+    environment:
+      - SPRING_PROFILES_ACTIVE=prod
+      - python.enabled=true
+      - python.base-url=http://langgraph-svc:9000
+    networks:
+      - cfc-net
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9082/actuator/health"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+
+  langgraph-svc:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    ports:
+      - "9000:9000"
+    env_file:
+      - .env.production
+    environment:
+      - JAVA_BASE_URL=http://cfc-backend:9082
+      - CHROMA_DB_PATH=/data/chroma_db
+      - JSON_LOGS=true
+      - LANGCHAIN_TRACING_V2=${LANGCHAIN_TRACING_V2:-false}
+    volumes:
+      - langgraph-data:/data
+    networks:
+      - cfc-net
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/api/v1/health"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    depends_on:
+      - cfc-backend
+
+  prometheus:
+    image: prom/prometheus:latest
+    volumes:
+      - ./prometheus.yml:/etc/prometheus/prometheus.yml
+      - prometheus-data:/prometheus
+    networks:
+      - cfc-net
+    restart: unless-stopped
+    profiles:
+      - monitoring
+
+networks:
+  cfc-net:
+
+volumes:
+  langgraph-data:
+  prometheus-data:

+ 97 - 0
cfc-langgraph/docs/deployment.md

@@ -0,0 +1,97 @@
+# LangGraph Sidecar 部署文档
+
+## 前置依赖
+
+- Docker + Docker Compose (推荐)
+- 或 Python 3.11+ + pip (直接部署)
+- LLM API Key (DeepSeek / OpenAI 兼容)
+
+## 目录结构
+
+```
+cfc-langgraph/
+├── Dockerfile
+├── docker-compose.yml
+├── prometheus.yml
+├── .env.production          # 生产配置 (手动创建, 不上传 git)
+├── app/                     # 服务代码
+│   ├── main.py              # FastAPI 入口
+│   ├── config.py            # 配置管理
+│   ├── api/                 # HTTP 接口
+│   ├── agents/              # Agent 定义
+│   ├── graphs/              # LangGraph StateGraph
+│   ├── tools/               # Agent Tool
+│   ├── rag/                 # RAG Pipeline
+│   ├── memory/              # 三层记忆
+│   └── tasks/               # 定时任务
+├── data/
+│   └── chroma_db/           # ChromaDB 持久化 (自动创建)
+└── docs/
+    ├── deployment.md        # 本文件
+    └── operations.md        # 运维手册
+```
+
+## Docker Compose 部署
+
+```bash
+# 1. 创建生产配置
+cp .env.example .env.production
+# 编辑 .env.production 填入 LLM_API_KEY
+
+# 2. 构建并启动
+docker-compose up -d
+
+# 3. 验证
+curl http://localhost:9000/api/v1/health
+
+# 4. 查看日志
+docker-compose logs -f langgraph-svc
+```
+
+## 直接部署 (无 Docker)
+
+```bash
+# 1. 安装依赖
+pip install -e .
+
+# 2. 配置环境变量
+export LLM_API_KEY=sk-xxx
+export JAVA_BASE_URL=http://localhost:9082
+
+# 3. 启动
+gunicorn app.main:app \
+  --worker-class uvicorn.workers.UvicornWorker \
+  --bind 0.0.0.0:9000 \
+  --workers 2 \
+  --timeout 60 \
+  --access-logfile - \
+  --error-logfile -
+```
+
+## 环境变量说明
+
+| 变量 | 必填 | 说明 |
+|------|------|------|
+| LLM_API_KEY | 是 | LLM API Key |
+| LLM_BASE_URL | 否 | 默认 https://api.deepseek.com/v1 |
+| LLM_MODEL | 否 | 默认 deepseek-chat |
+| JAVA_BASE_URL | 是 | Java 后端地址 |
+| CHROMA_DB_PATH | 否 | ChromaDB 持久化路径, 默认 ./data/chroma_db |
+| LOG_LEVEL | 否 | 日志级别, 默认 info |
+| JSON_LOGS | 否 | 启用 JSON 日志格式, 默认 false |
+
+## 健康检查
+
+```
+GET /api/v1/health
+
+Response:
+{
+  "status": "ok",
+  "components": {
+    "chromadb": {"status": "ok", "path": "...", "exists": true},
+    "llm": {"status": "ok"},
+    "java_backend": {"status": "ok"}
+  }
+}
+```

+ 121 - 0
cfc-langgraph/docs/operations.md

@@ -0,0 +1,121 @@
+# LangGraph Sidecar 运维手册
+
+## 日常监控
+
+### 1. 健康检查
+
+生产环境建议配置 30 秒定时健康检查:
+
+```bash
+curl -f http://localhost:9000/api/v1/health
+```
+
+预期返回 `{"status":"ok"}`。组件降级时返回 `{"status":"degraded"}`。
+
+### 2. Prometheus 指标
+
+```
+GET /metrics
+
+关键指标:
+- agent_calls_total{agent_type="chat|recommend|analysis"}
+- agent_duration_seconds{agent_type="..."}
+- llm_calls_total{model="deepseek-chat",status="ok|error"}
+- llm_duration_seconds{model="..."}
+- kb_sync_duration_seconds        # 知识库同步耗时
+```
+
+### 3. 日志
+
+JSON 格式日志可直接接入 ELK / Loki:
+
+```json
+{"timestamp":"2026-07-20T10:00:00","level":"WARN","logger":"app.main","message":"SLOW_REQUEST: POST /api/v1/chat took 8.23s"}
+```
+
+## 常见问题
+
+### Python 服务无法启动
+
+```bash
+# 检查依赖
+pip list | grep -E "fastapi|langchain|langgraph"
+
+# 检查配置
+python -c "from app.config import settings; print(settings.llm_model)"
+
+# 检查端口占用
+netstat -ano | grep 9000
+```
+
+### LLM 调用失败
+
+1. 检查 `.env.production` 中的 `LLM_API_KEY` 是否有效
+2. 检查 `LLM_BASE_URL` 是否可访问
+3. 查看日志: `docker-compose logs langgraph-svc | grep llm_call`
+
+### ChromaDB 损坏
+
+```bash
+# 删除后重建 (知识库会自动同步)
+rm -rf data/chroma_db/
+docker-compose restart langgraph-svc
+
+# 或触发手动同步
+curl -X POST http://localhost:9000/api/v1/admin/kb-sync
+```
+
+### 知识库同步失败
+
+```bash
+# 检查 Java 后端是否可访问
+curl http://localhost:9082/health
+
+# 检查文章接口
+curl -X POST http://localhost:9082/api/article/updated-since \
+  -H "Content-Type: application/json" \
+  -d '{"since":"2026-01-01T00:00:00","status":"published"}'
+
+# 手动触发同步
+docker-compose exec langgraph-svc python -c "
+import asyncio
+from app.tasks.knowledge_sync import sync_knowledge_base
+asyncio.run(sync_knowledge_base())
+"
+```
+
+### Java 端回退 Dify
+
+如果 Python 服务异常, Java 会自动回退 Dify:
+
+```yaml
+# application.yml 检查配置
+python:
+  enabled: true
+  circuit-breaker:
+    failure-threshold: 3
+    reset-timeout-ms: 30000
+```
+
+熔断器打开时, Java 侧日志会输出 `AiGateway 熔断器已打开`, 等待 `reset-timeout-ms` 后自动半开重试。
+
+## 扩缩容
+
+LangGraph 服务是无状态的 (ChromaDB 在共享存储上):
+
+```yaml
+# docker-compose 增加副本数
+services:
+  langgraph-svc:
+    deploy:
+      replicas: 2
+```
+
+注意: ChromaDB 文件模式不支持并发写入, 多副本时知识库同步需加锁或切 PGVector。
+
+## 备份
+
+```bash
+# ChromaDB 数据
+tar czf chroma_backup_$(date +%Y%m%d).tar.gz data/chroma_db/
+```

+ 9 - 0
cfc-langgraph/prometheus.yml

@@ -0,0 +1,9 @@
+global:
+  scrape_interval: 15s
+  evaluation_interval: 15s
+
+scrape_configs:
+  - job_name: "langgraph-svc"
+    static_configs:
+      - targets: ["langgraph-svc:9000"]
+    metrics_path: /metrics

+ 34 - 0
cfc-langgraph/pyproject.toml

@@ -0,0 +1,34 @@
+[project]
+name = "cfc-langgraph"
+version = "0.1.0"
+description = "cfc AI sidecar service with LangGraph"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.115",
+    "uvicorn[standard]>=0.34",
+    "pydantic>=2.10",
+    "pydantic-settings>=2.7",
+    "httpx>=0.28",
+    "langchain>=0.3",
+    "langchain-community>=0.3",
+    "langchain-openai>=0.3",
+    "langgraph>=0.3",
+    "chromadb>=0.6",
+    "langchain-chroma>=0.2",
+    "python-multipart>=0.0.20",
+    "prometheus-client>=0.21",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=8",
+    "pytest-asyncio>=0.25",
+    "ruff>=0.8",
+    "httpx",
+]
+
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+select = ["E", "F", "I", "N", "W"]
+ignore = ["E501"]

+ 0 - 0
cfc-langgraph/tests/__init__.py


+ 16 - 0
cfc-langgraph/tests/conftest.py

@@ -0,0 +1,16 @@
+import pytest
+from httpx import AsyncClient, ASGITransport
+from app.main import app
+
+
+@pytest.fixture
+async def client():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as ac:
+        yield ac
+
+
+@pytest.fixture
+async def health_response(client):
+    resp = await client.get("/health")
+    return resp

+ 30 - 0
cfc-langgraph/tests/test_analyze.py

@@ -0,0 +1,30 @@
+import pytest
+from httpx import AsyncClient, ASGITransport
+from app.main import app
+
+
+@pytest.mark.asyncio
+async def test_analyze_endpoint():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/analyze", json={
+            "report_id": 1,
+            "user_id": 1,
+            "focus": "overall",
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert "analysis" in data
+
+
+@pytest.mark.asyncio
+async def test_analyze_missing_report():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/analyze", json={
+            "report_id": -1,
+            "user_id": 1,
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert isinstance(data.get("analysis"), str)

+ 18 - 0
cfc-langgraph/tests/test_chat.py

@@ -0,0 +1,18 @@
+import pytest
+from httpx import AsyncClient, ASGITransport
+from app.main import app
+
+
+@pytest.mark.asyncio
+async def test_chat_endpoint():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/chat", json={
+            "query": "你好",
+            "user_id": 1,
+            "conversation_id": "",
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert "answer" in data
+        assert isinstance(data.get("tasks"), list)

+ 37 - 0
cfc-langgraph/tests/test_intent.py

@@ -0,0 +1,37 @@
+import pytest
+from app.agents.intent_classifier import IntentClassifier, Intent
+
+
+@pytest.mark.asyncio
+async def test_classify_chat():
+    classifier = IntentClassifier()
+    intent = await classifier.classify("今天天气真好")
+    assert intent == Intent.CHAT
+
+
+@pytest.mark.asyncio
+async def test_classify_analysis():
+    classifier = IntentClassifier()
+    intent = await classifier.classify("帮我看看小明的健康报告")
+    assert intent == Intent.ANALYSIS
+
+
+@pytest.mark.asyncio
+async def test_classify_recommend():
+    classifier = IntentClassifier()
+    intent = await classifier.classify("推荐一些补钙的食物")
+    assert intent == Intent.RECOMMEND
+
+
+@pytest.mark.asyncio
+async def test_classify_mind():
+    classifier = IntentClassifier()
+    intent = await classifier.classify("孩子最近心情不好")
+    assert intent == Intent.MIND
+
+
+@pytest.mark.asyncio
+async def test_classify_health():
+    classifier = IntentClassifier()
+    intent = await classifier.classify("看看我的舌苔")
+    assert intent == Intent.HEALTH

+ 25 - 0
cfc-langgraph/tests/test_memory.py

@@ -0,0 +1,25 @@
+import pytest
+from app.memory.store import MemoryManager
+
+
+@pytest.mark.asyncio
+async def test_memory_manager_init():
+    mgr = MemoryManager()
+    assert mgr is not None
+    assert mgr.memory_vectorstore is not None
+
+
+@pytest.mark.asyncio
+async def test_working_memory():
+    mgr = MemoryManager()
+    mem = mgr.get_working_memory()
+    assert mem is not None
+    assert mem.memory_key == "history"
+
+
+@pytest.mark.asyncio
+async def test_longterm_memory():
+    mgr = MemoryManager()
+    mem = mgr.get_longterm_memory(user_id=1)
+    assert mem is not None
+    assert mem.memory_key == "long_term_memory"

+ 44 - 0
cfc-langgraph/tests/test_recommend.py

@@ -0,0 +1,44 @@
+import pytest
+from httpx import AsyncClient, ASGITransport
+from app.main import app
+
+
+@pytest.mark.asyncio
+async def test_health_check():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.get("/health")
+        assert resp.status_code == 200
+        assert resp.json() == {"status": "ok"}
+
+
+@pytest.mark.asyncio
+async def test_recommend_empty_tags():
+    """空标签应返回空列表"""
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/recommend", json={
+            "user_id": 1,
+            "tags": [],
+            "query": "推荐一些适合孩子的活动",
+            "limit": 3,
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert "items" in data
+
+
+@pytest.mark.asyncio
+async def test_recommend_with_tags():
+    """带标签应返回推荐结果"""
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/recommend", json={
+            "user_id": 1,
+            "tags": ["益生菌", "肠胃"],
+            "limit": 3,
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert "items" in data
+        assert "source" in data

+ 12 - 0
cfc-langgraph/tests/test_tongue.py

@@ -0,0 +1,12 @@
+import pytest
+from httpx import AsyncClient, ASGITransport
+from app.main import app
+
+
+@pytest.mark.asyncio
+async def test_tongue_diagnose_missing_file():
+    """无文件上传应返回 422"""
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/tongue/diagnose", data={"user_id": 1})
+        assert resp.status_code == 422

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-229757d1b35843902fc41ea6b64db69cccc8a554
+faedcc3354260dc538895e3607261202d8893c0b

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.434",
+  "version": "1.0.448",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.434",
+      "version": "1.0.448",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.435",
+  "version": "1.0.449",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 120 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,126 @@
 
 ---
 
+## v1.0.448 (2026-07-20)
+
+### Bug 修复
+- FamilyMemberCard roleBadgeColor use this.member instead of bare member
+
+
+## v1.0.447 (2026-07-20)
+
+### 其他
+- web(plastic-health): replace 亿图腾→浠艾福, add Clarity, fix two-col mobile responsive
+
+
+## v1.0.446 (2026-07-20)
+
+### Bug 修复
+- 运势接口Mybatis错误 - 添加@Select注解修复Invalid bound statement + 表结构对齐
+
+### 重构
+- 我的页面改为仅个人信息+配置,移除富相关组件
+
+### 其他
+- - 移除 ProfileStats(行动/财富数据,归属富页面)
+- - ProfileMenu.vue 新增 showServices prop,我的页面隐藏"服务"板块(会员/订单/售后等归富页面)
+- - 保留:ProfileHeader / ProfileGrowth / ProfileBadges / 个人+设置菜单
+- 
+
+
+## v1.0.445 (2026-07-20)
+
+### Bug 修复
+- add fallback when navigateBack has no pages in stack
+
+
+## v1.0.444 (2026-07-20)
+
+### 文档
+- LangGraph Phase 2-4 implementation plans
+
+
+## v1.0.443 (2026-07-20)
+
+### 新功能
+- AiGateway + 5% grayscale routing to LangGraph
+- config, models, RAG, Java client, RecommendAgent
+- scaffold Python sidecar project
+
+### 其他
+- - application.yml: 新增 python.* 配置项
+- - RecommendationController: 5% 流量走 Python,异常自动 fallback
+- 
+- - models/: 请求/响应 Pydantic 模型
+- - rag/: ChromaDB + Embedding + 混合检索 (BM25+向量)
+- - tools/: JavaClient HTTP 封装 + product_tools (3 个 @tool)
+- - graphs/recommend_graph.py: RecommendAgent (LLM + Tool Calling)
+- - api/recommend.py: POST /api/v1/recommend 端点
+- - main.py: 注册路由 + startup/shutdown 钩子
+- 
+- - FastAPI app skeleton with /health endpoint
+- - Dockerfile, .env.example, .gitignore
+- - Fix root .gitignore *.py exclusion for cfc-langgraph/
+- 
+
+
+## v1.0.442 (2026-07-20)
+
+### 文档
+- LangGraph migration design doc + Phase 1 implementation plan
+
+
+## v1.0.441 (2026-07-20)
+
+### 文档
+- add AxonHub model routing and multi-model workflow to upgrade plans
+
+
+## v1.0.440 (2026-07-20)
+
+### 文档
+- SB3 upgrade implementation plan with task breakdown
+- Spring Boot 3升级 + Dify替代实施计划
+
+
+## v1.0.439 (2026-07-20)
+
+### 文档
+- add Dify migration feasibility analysis
+
+
+## v1.0.438 (2026-07-20)
+
+### Bug 修复
+- parent-index wx:for 添加 wx:key 消除 WXML 运行时警告
+
+### 其他
+-   uni-app 编译器能正确生成 wx:key="id"
+- - placeholder 文章占位卡片的 :key="'ph-'+i" 已改为 :key="i"
+- 
+
+
+## v1.0.437 (2026-07-20)
+
+### Bug 修复
+- extract_full_report_v5.py - report interpretation content no longer truncated
+
+### 其他
+-   instead of first occurrence on page 1 summary; 566 chars (was 300)
+- - 肠道年龄评估: now captures full gut age (page 12) + flora assessment
+-   (pages 13-14, 菌群平衡/菌群多样性); 1938 chars (was 500)
+- - Removed hard-coded [:500] and [:300] truncation limits
+- - Proper end-marker detection using 菌群构成层级/菌群详细构成
+- 
+
+
+## v1.0.436 (2026-07-20)
+
+### 其他
+- web(plastic-health): optimize screenshot sizes for faster loading
+- web(plastic-health): add ref-22 WWF weekly 5g intake citation
+
+
 ## v1.0.435 (2026-07-20)
 
 ### 其他

+ 121 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.435
+> 当前版本: v1.0.448
 
 ## 历史版本
 
@@ -8,6 +8,126 @@
 
 ---
 
+## v1.0.448 (2026-07-20)
+
+### Bug 修复
+- FamilyMemberCard roleBadgeColor use this.member instead of bare member
+
+
+## v1.0.447 (2026-07-20)
+
+### 其他
+- web(plastic-health): replace 亿图腾→浠艾福, add Clarity, fix two-col mobile responsive
+
+
+## v1.0.446 (2026-07-20)
+
+### Bug 修复
+- 运势接口Mybatis错误 - 添加@Select注解修复Invalid bound statement + 表结构对齐
+
+### 重构
+- 我的页面改为仅个人信息+配置,移除富相关组件
+
+### 其他
+- - 移除 ProfileStats(行动/财富数据,归属富页面)
+- - ProfileMenu.vue 新增 showServices prop,我的页面隐藏"服务"板块(会员/订单/售后等归富页面)
+- - 保留:ProfileHeader / ProfileGrowth / ProfileBadges / 个人+设置菜单
+- 
+
+
+## v1.0.445 (2026-07-20)
+
+### Bug 修复
+- add fallback when navigateBack has no pages in stack
+
+
+## v1.0.444 (2026-07-20)
+
+### 文档
+- LangGraph Phase 2-4 implementation plans
+
+
+## v1.0.443 (2026-07-20)
+
+### 新功能
+- AiGateway + 5% grayscale routing to LangGraph
+- config, models, RAG, Java client, RecommendAgent
+- scaffold Python sidecar project
+
+### 其他
+- - application.yml: 新增 python.* 配置项
+- - RecommendationController: 5% 流量走 Python,异常自动 fallback
+- 
+- - models/: 请求/响应 Pydantic 模型
+- - rag/: ChromaDB + Embedding + 混合检索 (BM25+向量)
+- - tools/: JavaClient HTTP 封装 + product_tools (3 个 @tool)
+- - graphs/recommend_graph.py: RecommendAgent (LLM + Tool Calling)
+- - api/recommend.py: POST /api/v1/recommend 端点
+- - main.py: 注册路由 + startup/shutdown 钩子
+- 
+- - FastAPI app skeleton with /health endpoint
+- - Dockerfile, .env.example, .gitignore
+- - Fix root .gitignore *.py exclusion for cfc-langgraph/
+- 
+
+
+## v1.0.442 (2026-07-20)
+
+### 文档
+- LangGraph migration design doc + Phase 1 implementation plan
+
+
+## v1.0.441 (2026-07-20)
+
+### 文档
+- add AxonHub model routing and multi-model workflow to upgrade plans
+
+
+## v1.0.440 (2026-07-20)
+
+### 文档
+- SB3 upgrade implementation plan with task breakdown
+- Spring Boot 3升级 + Dify替代实施计划
+
+
+## v1.0.439 (2026-07-20)
+
+### 文档
+- add Dify migration feasibility analysis
+
+
+## v1.0.438 (2026-07-20)
+
+### Bug 修复
+- parent-index wx:for 添加 wx:key 消除 WXML 运行时警告
+
+### 其他
+-   uni-app 编译器能正确生成 wx:key="id"
+- - placeholder 文章占位卡片的 :key="'ph-'+i" 已改为 :key="i"
+- 
+
+
+## v1.0.437 (2026-07-20)
+
+### Bug 修复
+- extract_full_report_v5.py - report interpretation content no longer truncated
+
+### 其他
+-   instead of first occurrence on page 1 summary; 566 chars (was 300)
+- - 肠道年龄评估: now captures full gut age (page 12) + flora assessment
+-   (pages 13-14, 菌群平衡/菌群多样性); 1938 chars (was 500)
+- - Removed hard-coded [:500] and [:300] truncation limits
+- - Proper end-marker detection using 菌群构成层级/菌群详细构成
+- 
+
+
+## v1.0.436 (2026-07-20)
+
+### 其他
+- web(plastic-health): optimize screenshot sizes for faster loading
+- web(plastic-health): add ref-22 WWF weekly 5g intake citation
+
+
 ## v1.0.435 (2026-07-20)
 
 ### 其他

+ 922 - 0
docs/architecture/2026-07-20-SB3-upgrade-plan.md

@@ -0,0 +1,922 @@
+# Spring Boot 3.x 升级 + Dify 替代 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将 cfc-backend 从 Spring Boot 2.7.18 + Java 8 升级到 Spring Boot 3.4.x + Java 21 LTS,用 Spring AI + AxonHub 替换 Dify 全部功能。
+
+**Architecture:** 分层替代 Dify:Spring AI 承担 AI 应用层(对话/RAG/工具),AxonHub 承担模型路由层(分发/降级/计费),专用工具承担文档预处理。5 阶段串行,每阶段有独立编译/测试验证 + git tag 回退点。
+
+**Tech Stack:** Java 21, Spring Boot 3.4.x, Spring AI 1.x, AxonHub (axonhub.iwintrue.com), MyBatis-Plus 3.5.9, springdoc-openapi 2.7, exp4j 0.4.8, jjwt 0.12.6, mysql-connector-java 8.0.33, sqlite-vec, docling (PDF预处理)
+
+---
+
+## Global Constraints
+
+1. **Java 21 LTS** — JDK 21 必须安装到所有开发/CI/生产环境,maven-compiler-plugin source/target=21
+2. **Spring Boot BOM** — 以 spring-boot-starter-parent 3.4.x 为根 BOM,避免传递依赖冲突
+3. **javax → jakarta** — 所有 javax.* import 必须改为 jakarta.*,包括但不限于 persistence/annotation/validation/servlet/sql
+4. **Nashorn 已废弃** — WisdomMathService 中的 ScriptEngine eval 必须替换为 exp4j
+5. **springdoc v2** — artifactId 从 `springdoc-openapi-ui` 改为 `springdoc-openapi-starter-webmvc-ui`
+6. **jjwt 0.12** — `Jwts.parserBuilder()` 改为 `Jwts.parser()`, Keys API 变化
+7. **统一 @PostMapping** — 遵循项目现有规范,禁止 @GetMapping/@PutMapping/@DeleteMapping
+8. **统一 Result 响应** — 所有接口返回 `Result<T>` (code/message/data)
+9. **不再依赖 Dify** — 全部 8 个 Service + 3 个 Controller 必须改为 Spring AI ChatClient
+10. **AxonHub 统一网关** — 所有模型调用经 `axonhub.iwintrue.com`,Spring AI 配置 `base-url` 指向 AxonHub
+11. **多模型编排** — 支持工作流各节点使用不同 `ChatModel` Bean(DeepSeek/Qwen/本地模型等),通过 `@Qualifier` 注入
+12. **PDF 预处理** — 复杂文档预处理使用 Python 专有工具(docling / unstructured.io),不在 Spring AI ETL 中处理
+
+---
+
+## 文件变更清单
+
+### 创建
+| 文件 | 用途 |
+|------|------|
+| `src/main/java/com/etotem/cfc/service/ai/LLMService.java` | Spring AI ChatClient 封装 |
+| `src/main/java/com/etotem/cfc/service/ai/SystemPromptLoader.java` | System Prompt 外部化加载 |
+| `src/main/java/com/etotem/cfc/service/ai/RAGService.java` | 本地知识库查询 |
+| `src/main/java/com/etotem/cfc/config/SpringAiConfig.java` | Spring AI ChatClient Bean 配置 |
+| `resources/prompts/family-advisor.yaml` | 家庭顾问 System Prompt |
+| `resources/prompts/growth-plan.yaml` | 成长规划 System Prompt |
+| `resources/prompts/diet-recommend.yaml` | 饮食推荐 System Prompt |
+| `resources/prompts/math-tutor.yaml` | 数学辅导 System Prompt |
+| `scripts/doc-preprocess/` | PDF 预处理 Python 脚本(docling) |
+
+### 修改
+| 文件 | 变更类型 |
+|------|---------|
+| `pom.xml` | 全部依赖版本更新 + 新增 |
+| `src/main/resources/application.yml` | Spring AI 配置、springdoc 配置调整 |
+| 所有 `@Entity` 类 (~80) | javax.persistence → jakarta.persistence |
+| 所有 DTO 类 (~200) | javax.validation → jakarta.validation |
+| 所有 Service 类 (~60) | javax.annotation → jakarta.annotation |
+| 所有 Controller 类 (~48) | javax.servlet → jakarta.servlet (如果使用) |
+| 所有 Config 类 | 同上 |
+| `JwtUtil.java` | jjwt 0.12 API 适配 |
+| `WisdomMathService.java` | Nashorn → exp4j |
+| `AIService.java` | 重构为 LLMService 封装 |
+| `DifySyncService.java` | 替换为本地知识库同步 |
+| `AiConversationSummaryService.java` | 替换为 Spring AI MemoryAdvisor |
+| `AiUserFactService.java` | 替换为 @Tool + ChatClient |
+| 其余 Dify 相关 4 个 Service | 逐一替换 |
+| `AiChatController.java` + 2 个相关 Controller | Spring AI 实现 |
+| 删除 `springdoc-openapi-ui` 依赖 | v1→v2 artifact 替换 |
+
+---
+
+## 任务分解
+
+### Phase 0 — 前置准备(3 天)
+
+#### Task 0.1: 环境准备 + BOM 对齐
+
+**Files:**
+- Modify: `pom.xml`
+
+**Interfaces:**
+- Produces: `mvn clean compile` 通过(尚未改 javax,暂时只对齐依赖)
+
+- [ ] **Step 1:更新 parent 和 properties**
+
+在 `pom.xml` 中修改:
+```xml
+<parent>
+    <groupId>org.springframework.boot</groupId>
+    <artifactId>spring-boot-starter-parent</artifactId>
+    <version>3.4.3</version>      <!-- 2026年最新 3.4.x -->
+    <relativePath/>
+</parent>
+<properties>
+    <java.version>21</java.version>
+    <mybatis-plus.version>3.5.9</mybatis-plus.version>
+    <jjwt.version>0.12.6</jjwt.version>
+</properties>
+```
+
+- [ ] **Step 2:替换 springdoc v1 → v2**
+
+找到并替换:
+```xml
+<!-- 删除 -->
+<dependency>
+    <groupId>org.springdoc</groupId>
+    <artifactId>springdoc-openapi-ui</artifactId>
+    <version>1.8.0</version>
+</dependency>
+
+<!-- 添加 -->
+<dependency>
+    <groupId>org.springdoc</groupId>
+    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
+    <version>2.7.0</version>
+</dependency>
+```
+
+- [ ] **Step 3:移除 jjwt 旧版本,确认 BOM 覆盖**
+
+```xml
+<!-- jjwt 版本已通过 properties 控制 -->
+<dependency>
+    <groupId>io.jsonwebtoken</groupId>
+    <artifactId>jjwt-api</artifactId>
+    <version>${jjwt.version}</version>
+</dependency>
+<dependency>
+    <groupId>io.jsonwebtoken</groupId>
+    <artifactId>jjwt-impl</artifactId>
+    <version>${jjwt.version}</version>
+    <scope>runtime</scope>
+</dependency>
+<dependency>
+    <groupId>io.jsonwebtoken</groupId>
+    <artifactId>jjwt-jackson</artifactId>
+    <version>${jjwt.version}</version>
+    <scope>runtime</scope>
+</dependency>
+```
+
+- [ ] **Step 4:添加 exp4j 依赖**
+
+```xml
+<dependency>
+    <groupId>net.objecthunter</groupId>
+    <artifactId>exp4j</artifactId>
+    <version>0.4.8</version>
+</dependency>
+```
+
+- [ ] **Step 5:编译验证**
+
+运行:
+```bash
+mvn clean compile
+```
+Expected: 编译失败(因为 javax 包名不存在,这是预期的。失败信息用来确认 Phase 1 的替换范围)
+
+- [ ] **Step 6:录制 javax 报错清单**
+
+```bash
+mvn clean compile 2>&1 | Select-String "javax\." | Sort-Object -Unique > docs/javax-errors.txt
+```
+
+查看输出行数:`Get-Content docs/javax-errors.txt | Measure-Object`
+
+- [ ] **Step 7:打基线 tag**
+
+```bash
+git tag v2.7.18-baseline
+git push origin v2.7.18-baseline
+```
+
+---
+
+#### Task 0.2: 确认 Nashorn 影响范围
+
+**Files:**
+- Read: `src/main/java/.../WisdomMathService.java`
+
+- [ ] **Step 1:提取 Nashorn 表达式求值用例**
+
+在 WisdomMathService 中搜索所有 `engine.eval(` 和 `ScriptEngine` 出现的位置,提取所有使用的表达式样例(记录输入和输出)。
+
+```java
+// 按行记录类似这样的调用
+engine.eval("12+38*2");        // → 88
+engine.eval("Math.round(a)");  // → 四舍五入
+// ... 记录全部
+```
+
+- [ ] **Step 2:验证 exp4j 是否覆盖全部语法**
+
+对照提取的表达式,确认 exp4j 支持:
+- 四则运算 + - * / ✅
+- 括号 ✅
+- 幂运算 ^ ✅
+- Math 函数: abs, sin, cos, log, round, ceil, floor ✅
+
+对于不支持的函数(如果有),记录到 Phase 2 的自定义函数实现方案。
+
+---
+
+### Phase 1 — javax → jakarta 迁移(5 天)
+
+#### Task 1.1: 实体类包名替换
+
+**Files:**
+- Modify: `src/main/java/com/etotem/cfc/entity/` 下全部 Entity 类(~80 个)
+
+**Interfaces:**
+- Produces: 全部 Entity 类编译通过(包名迁移后)
+
+- [ ] **Step 1:全局替换 javax.persistence → jakarta.persistence**
+
+```bash
+# PowerShell 在 entity 目录下执行
+Get-ChildItem -Path "src/main/java/com/etotem/cfc/entity" -Filter "*.java" -Recurse | 
+  ForEach-Object { (Get-Content $_.FullName) -replace "javax\.persistence\.", "jakarta.persistence." | 
+  Set-Content $_.FullName }
+```
+
+- [ ] **Step 2:编译验证**
+
+```bash
+mvn clean compile 2>&1 | Select-String "ERROR"
+```
+Expected: javax 错误行数减少(仅 entity 包部分消除)
+
+---
+
+#### Task 1.2: DTO 校验注解替换
+
+**Files:**
+- Modify: `src/main/java/com/etotem/cfc/dto/` 下全部 DTO 类(~200 个)
+
+- [ ] **Step 1:全局替换 javax.validation → jakarta.validation**
+
+```bash
+Get-ChildItem -Path "src/main/java/com/etotem/cfc/dto" -Filter "*.java" -Recurse | 
+  ForEach-Object { (Get-Content $_.FullName) -replace "javax\.validation\.", "jakarta.validation." | 
+  Set-Content $_.FullName }
+```
+
+- [ ] **Step 2:编译验证**
+
+```bash
+mvn clean compile 2>&1 | Select-String "ERROR" | Select-String "javax"
+```
+Expected: 仅剩非 persistence/validation 的 javax 错误
+
+---
+
+#### Task 1.3: 全局 javax.* 收尾替换
+
+**Files:**
+- Modify: 所有剩余包含 `javax.*` 的 Java 文件
+
+- [ ] **Step 1:扫描所有剩余 javax.* import**
+
+```bash
+Get-ChildItem -Path "src/main/java" -Filter "*.java" -Recurse | 
+  Select-String "^import javax\." | 
+  ForEach-Object { $_.Filename + ": " + $_.Line.Trim() } | 
+  Sort-Object -Unique
+```
+
+- [ ] **Step 2:按以下规则逐条替换**
+
+| 原 import | 目标 import |
+|-----------|------------|
+| `javax.annotation.Resource` | `jakarta.annotation.Resource` |
+| `javax.annotation.PostConstruct` | `jakarta.annotation.PostConstruct` |
+| `javax.annotation.PreDestroy` | `jakarta.annotation.PreDestroy` |
+| `javax.servlet.http.HttpServletRequest` | `jakarta.servlet.http.HttpServletRequest` |
+| `javax.servlet.http.HttpServletResponse` | `jakarta.servlet.http.HttpServletResponse` |
+| `javax.servlet.Filter` | `jakarta.servlet.Filter` |
+| `javax.servlet.FilterChain` | `jakarta.servlet.FilterChain` |
+| `javax.servlet.ServletException` | `jakarta.servlet.ServletException` |
+| `javax.sql.DataSource` | `jakarta.sql.DataSource` |
+| `javax.xml.bind.*` | 确认是否使用,如使用需加 jakarta.xml.bind 依赖 |
+
+- [ ] **Step 3:逐文件编译验证**
+
+每替换完一批,运行:
+```bash
+mvn clean compile
+```
+
+修复残留错误,直到零错。
+
+- [ ] **Step 4:打阶段 tag**
+
+```bash
+git add -A
+git commit -m "phase1: javax → jakarta migration complete"
+git tag v3.0-javax-migrated
+```
+
+---
+
+### Phase 2 — 行为适配 + Nashorn 替换(4 天)
+
+#### Task 2.1: WisdomMathService Nashorn → exp4j
+
+**Files:**
+- Modify: `src/main/java/.../service/WisdomMathService.java`
+
+- [ ] **Step 1:编写 exp4j 替换实现**
+
+```java
+// 替换前
+import javax.script.ScriptEngineManager;
+import javax.script.ScriptEngine;
+
+ScriptEngineManager mgr = new ScriptEngineManager();
+ScriptEngine engine = mgr.getEngineByName("nashorn");
+Object result = engine.eval("12+38*2");
+
+// 替换后
+import net.objecthunter.exp4j.ExpressionBuilder;
+
+double result = new ExpressionBuilder("12+38*2").build().evaluate();
+```
+
+如果需要支持 Math.round/ceil/floor 等函数,注册自定义函数:
+```java
+import net.objecthunter.exp4j.function.Function;
+
+Function roundFunc = new Function("round", 1) {
+    @Override
+    public double apply(double... args) {
+        return Math.round(args[0]);
+    }
+};
+
+Expression exp = new ExpressionBuilder(expression)
+    .function(roundFunc)
+    .build();
+```
+
+- [ ] **Step 2:编译验证**
+
+```bash
+mvn clean compile
+```
+Expected: 零错误
+
+---
+
+#### Task 2.2: jjwt 0.12 API 适配
+
+**Files:**
+- Modify: 搜索所有使用 jjwt 的文件
+
+- [ ] **Step 1:找到所有 jjwt 调用**
+
+```bash
+Get-ChildItem -Path "src/main/java" -Filter "*.java" -Recurse | 
+  Select-String "Jwts\.|Keys\.|SignatureAlgorithm" | 
+  ForEach-Object { $_.Filename + "(" + $_.LineNumber + "): " + $_.Line.Trim() }
+```
+
+- [ ] **Step 2:按规则适配**
+
+```java
+// jjwt 0.11                          // jjwt 0.12
+Jwts.builder()                         // 基本不变(返回 Builder 类型更新)
+    .signWith(key, SignatureAlgorithm.HS256)  // → .signWith(key) 自动推导算法
+Jwts.parserBuilder()                   // → Jwts.parser()
+    .setSigningKey(key)                // → .verifyWith(key) 或 .decryptWith(key)
+    .build()
+    .parseClaimsJws(token)             // 基本不变
+Keys.secretKeyFor(SignatureAlgorithm.HS256)  // → Jwts.SIG.HS256.keyBuilder().build()
+```
+
+- [ ] **Step 3:编译验证**
+
+```bash
+mvn clean compile
+```
+
+---
+
+#### Task 2.3: Spring Boot 3 行为变更适配
+
+**Files:**
+- Modify: `src/main/resources/application.yml`
+
+- [ ] **Step 1:检查路径匹配策略**
+
+添加/确认配置(如果现有代码依赖 Ant 风格路径匹配):
+```yaml
+spring:
+  mvc:
+    pathmatch:
+      matching-strategy: ant-path-matcher
+```
+
+- [ ] **Step 2:检查 springdoc 配置**
+
+```yaml
+# v1 (如有)
+springdoc:
+  swagger-ui:
+    path: /swagger-ui.html
+
+# v2 相同,但验证可用
+```
+
+- [ ] **Step 3:编译运行验证**
+
+```bash
+mvn clean compile
+```
+
+验证应用启动:
+```bash
+mvn spring-boot:run
+```
+Expected: 应用启动成功,Swagger UI 可访问 /swagger-ui.html(或 v2 默认路径)
+
+---
+
+#### Task 2.4: springdoc v2 Swagger UI 验证
+
+- [ ] **Step 1:启动应用**
+
+```bash
+mvn spring-boot:run
+```
+
+- [ ] **Step 2:访问 Swagger UI 验证 API 文档正确渲染**
+
+```bash
+curl http://localhost:9082/v3/api-docs | Select-Object -First 20
+```
+Expected: 返回 OpenAPI 3.0 JSON,paths 字段包含 /api/ 路由
+
+- [ ] **Step 3:打阶段 tag**
+
+```bash
+git commit -am "phase2: Spring Boot 3 behavior adaptation complete"
+git tag v3.0-build-ready
+```
+
+---
+
+### Phase 3 — Spring AI 集成 + Dify 替换(5 天)
+
+#### Task 3.1: 引入 Spring AI 依赖 + 配置
+
+**Files:**
+- Modify: `pom.xml`
+- Modify: `src/main/resources/application.yml`
+
+- [ ] **Step 1:添加 Spring AI BOM + starter**
+
+```xml
+<dependencyManagement>
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.ai</groupId>
+            <artifactId>spring-ai-bom</artifactId>
+            <version>1.0.0</version>   <!-- 2026年最新 1.x -->
+            <type>pom</type>
+            <scope>import</scope>
+        </dependency>
+    </dependencies>
+</dependencyManagement>
+
+<dependencies>
+    <dependency>
+        <groupId>org.springframework.ai</groupId>
+        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
+    </dependency>
+    <dependency>
+        <groupId>org.springframework.ai</groupId>
+        <artifactId>spring-ai-pdf-document-reader</artifactId>
+    </dependency>
+</dependencies>
+```
+
+- [ ] **Step 2:配置 AxonHub 模型网关(application.yml)**
+
+所有模型调用走 AxonHub 统一网关,不直连任何模型 Provider:
+
+```yaml
+spring:
+  ai:
+    openai:                                     # 默认模型 → AxonHub → DeepSeek
+      base-url: https://axonhub.iwintrue.com/v1
+      api-key: ${AXONHUB_API_KEY}
+      chat:
+        options:
+          model: deepseek-chat
+          temperature: 0.7
+    dashscope:                                   # 第二模型 → AxonHub → Qwen (可选)
+      api-key: ${AXONHUB_API_KEY}
+      chat:
+        options:
+          model: qwen-max
+    ollama:                                      # 本地模型 (不经过 AxonHub)
+      base-url: http://localhost:11434
+      chat:
+        options:
+          model: qwen2.5:7b
+    vectorstore:
+      sqlite:
+        path: ${cfc.data.dir:/data/cfc}/knowledge.db
+```
+
+- [ ] **Step 3:编译验证**
+
+```bash
+mvn clean compile
+```
+
+---
+
+#### Task 3.2: 创建 LLMService 核心封装
+
+**Files:**
+- Create: `src/main/java/com/etotem/cfc/service/ai/LLMService.java`
+- Create: `src/main/java/com/etotem/cfc/config/SpringAiConfig.java`
+
+- [ ] **Step 1:创建 SpringAiConfig(含多模型支持)**
+
+```java
+package com.etotem.cfc.config;
+
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class SpringAiConfig {
+
+    /** 默认聊天客户端(经 AxonHub 路由) */
+    @Bean
+    ChatClient chatClient(ChatClient.Builder builder) {
+        return builder.build();
+    }
+
+    /**
+     * 如需在工作流中使用不同模型,注入命名后的 ChatModel Bean:
+     * @Resource @Qualifier("openAiChatModel")  private ChatModel deepSeek;
+     * @Resource @Qualifier("dashScopeChatModel") private ChatModel qwen;
+     * @Resource @Qualifier("ollamaChatModel")   private ChatModel localModel;
+     *
+     * Spring AI 自动注册以下 Bean(按 application.yml 配置):
+     *   openAiChatModel     → AxonHub → DeepSeek
+     *   dashScopeChatModel  → AxonHub → Qwen (如果配置了 dashscope)
+     *   ollamaChatModel     → localhost:11434 (如果配置了 ollama)
+     */
+}
+```
+
+- [ ] **Step 2:创建 LLMService(含多模型编排)**
+
+```java
+package com.etotem.cfc.service.ai;
+
+import jakarta.annotation.Resource;
+import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.memory.ChatMemory;
+import org.springframework.ai.chat.memory.InMemoryChatMemory;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.stereotype.Service;
+import reactor.core.publisher.Flux;
+
+@Service
+public class LLMService {
+
+    @Resource
+    private ChatClient chatClient;
+
+    @Resource @Qualifier("openAiChatModel")
+    private ChatModel deepSeekModel;         // → AxonHub → DeepSeek
+
+    @Resource @Qualifier("dashScopeChatModel")
+    private ChatModel qwenModel;             // → AxonHub → Qwen
+
+    @Resource @Qualifier("ollamaChatModel")
+    private ChatModel localModel;            // → 本地 Ollama
+
+    private final ChatMemory chatMemory = new InMemoryChatMemory();
+
+    // 默认模型聊天
+    public String chat(String query, String systemPrompt, String conversationId) {
+        return chatClient.prompt()
+                .system(systemPrompt)
+                .user(query)
+                .advisors(new MessageChatMemoryAdvisor(chatMemory, conversationId, 10))
+                .call()
+                .content();
+    }
+
+    // 指定模型的聊天(工作流各节点可用不同模型)
+    public String chatWithModel(String query, String systemPrompt,
+                                 String conversationId, ChatModel model) {
+        return ChatClient.builder(model).build()
+                .prompt()
+                .system(systemPrompt)
+                .user(query)
+                .advisors(new MessageChatMemoryAdvisor(chatMemory, conversationId, 10))
+                .call()
+                .content();
+    }
+
+    // 流式聊天
+    public Flux<String> stream(String query, String systemPrompt, String conversationId) {
+        return chatClient.prompt()
+                .system(systemPrompt)
+                .user(query)
+                .advisors(new MessageChatMemoryAdvisor(chatMemory, conversationId, 10))
+                .stream()
+                .content();
+    }
+
+    // 多模型工作流示例
+    public WorkflowResult executeMultiModelWorkflow(String input) {
+        // Step 1: DeepSeek 做意图分类(强推理)
+        String intent = ChatClient.builder(deepSeekModel).build()
+                .prompt().user("分类用户意图: " + input).call().content();
+        // Step 2: Qwen 做长文本分析(长上下文)
+        String analysis = ChatClient.builder(qwenModel).build()
+                .prompt().system("分析专家").user(intent).call().content();
+        // Step 3: 本地模型格式化输出(零成本)
+        String output = ChatClient.builder(localModel).build()
+                .prompt().user("格式化: " + analysis).call().content();
+        return new WorkflowResult(intent, analysis, output);
+    }
+}
+```
+
+- [ ] **Step 3:编译验证**
+
+```bash
+mvn clean compile
+```
+
+---
+
+#### Task 3.3: System Prompt 外部化
+
+**Files:**
+- Create: `resources/prompts/family-advisor.yaml`
+- Create: `resources/prompts/growth-plan.yaml`
+- Create: `resources/prompts/diet-recommend.yaml`
+- Create: `resources/prompts/math-tutor.yaml`
+- Create: `src/main/java/.../service/ai/SystemPromptLoader.java`
+
+- [ ] **Step 1:创建 SystemPromptLoader**
+
+```java
+package com.etotem.cfc.service.ai;
+
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.stereotype.Component;
+import org.yaml.snakeyaml.Yaml;
+import jakarta.annotation.Resource;
+import java.util.Map;
+
+@Component
+public class SystemPromptLoader {
+
+    @Resource
+    private ResourceLoader resourceLoader;
+
+    public String load(String promptName) {
+        Resource resource = resourceLoader.getResource("classpath:prompts/" + promptName + ".yaml");
+        // 解析 YAML,返回 prompt 字段
+        Map<String, String> data = new Yaml().load(resource.getInputStream());
+        return data.get("prompt");
+    }
+}
+```
+
+- [ ] **Step 2:创建可替换的原 Dify Prompt 映射表**
+
+从数据库 chat_assistant 表的 description/system_prompt 字段提取:
+```yaml
+# resources/prompts/family-advisor.yaml
+prompt: |
+  你是家庭成长顾问,帮助家长理解孩子的成长需求。
+  请基于五维能量体系(身/心/智/行/富)提供建议。
+  ...
+```
+
+---
+
+#### Task 3.4: AIService 重构为 LLMService
+
+**Files:**
+- Modify: `src/main/java/.../service/AIService.java`
+- Modify: `src/main/java/.../controller/AiChatController.java`
+
+- [ ] **Step 1:重构 AIService**
+
+```java
+// 原有 AIService 用 OkHttp 调 Dify API,改为注入 LLMService
+@Service
+public class AIService {
+
+    @Resource
+    private LLMService llmService;
+
+    @Resource
+    private SystemPromptLoader promptLoader;
+
+    public String sendMessage(String assistantType, String query, String conversationId) {
+        String systemPrompt = promptLoader.load(assistantType);
+        return llmService.chat(query, systemPrompt, conversationId);
+    }
+
+    // 流式版本
+    public Flux<String> sendMessageStream(String assistantType, String query, String conversationId) {
+        String systemPrompt = promptLoader.load(assistantType);
+        return llmService.stream(query, systemPrompt, conversationId);
+    }
+}
+```
+
+- [ ] **Step 2:编译验证 + 冒烟测试**
+
+```bash
+mvn clean compile
+mvn spring-boot:run
+# 调用 /api/ai/chat/send 验证响应
+```
+
+---
+
+#### Task 3.5: DifySyncService → RAGService
+
+**Files:**
+- Create: `src/main/java/.../service/ai/RAGService.java`
+- Modify: `src/main/java/.../service/DifySyncService.java`
+
+- [ ] **Step 1:创建 RAGService**
+
+```java
+package com.etotem.cfc.service.ai;
+
+import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.ai.document.Document;
+import org.springframework.stereotype.Service;
+import jakarta.annotation.Resource;
+import java.util.List;
+
+@Service
+public class RAGService {
+
+    // sqlite-vec 或其他 VectorStore 实现
+    // 先使用搜索+vector 方案,第一期可简化为 keyword search
+    @Resource
+    private VectorStore vectorStore;
+
+    public List<Document> search(String query, int topK) {
+        return vectorStore.similaritySearch(query, topK);
+    }
+
+    public void addDocument(Document doc) {
+        vectorStore.add(List.of(doc));
+    }
+}
+```
+
+- [ ] **Step 2:DifySyncService 标记为待迁移**
+
+保留原有接口签名,内部改为从本地知识库读取:
+```java
+@Service
+public class DifySyncService {
+
+    @Resource
+    private RAGService ragService;
+
+    public List<String> searchKnowledge(String query) {
+        return ragService.search(query, 5).stream()
+                .map(Document::getContent)
+                .collect(Collectors.toList());
+    }
+}
+```
+
+---
+
+#### Task 3.6: 剩余 6 个 Dify 相关 Service 替换
+
+**Files:**
+- Modify: 逐个修改以下 Service(每个独立的文件修改,验证后继续下一个)
+  - `AiConversationSummaryService.java`
+  - `AiUserFactService.java`
+  - 其余 4 个
+
+- [ ] **Step 1-6:逐一替换**
+
+每个 Service 的工作模式:
+1. 读取当前代码,理解其调用的 Dify API(chat-messages / workflows / datasets)
+2. 映射到 LLMService 或 RAGService 的对应方法
+3. 替换实现,保持接口签名不变(最小化 Controller 层变动)
+4. 编译验证
+
+---
+
+#### Task 3.7: 打阶段 tag
+
+- [ ] **Step 1:提交 + tag**
+
+```bash
+git commit -am "phase3: Spring AI integration + Dify migration complete"
+git tag v3.0-spring-ai
+```
+
+---
+
+### Phase 4 — 回归验证 + 上线(3 天)
+
+#### Task 4.1: 全量功能回归
+
+- [ ] **Step 1:Spring Boot 启动验证**
+
+```bash
+mvn spring-boot:run
+```
+Expected: 应用启动成功,无 Error 日志
+
+- [ ] **Step 2:所有 Controller API 冒烟测试**
+
+使用 Postman 用例集或 curl 脚本验证以下关键路径:
+```
+POST /api/ai/chat/send          → AI 聊天正常
+POST /api/ai/chat/stream        → 流式返回正常
+POST /api/auth/login             → JWT 正常签发
+POST /api/user/info              → 用户信息正常
+... 以及全部 48 个 Controller
+```
+
+- [ ] **Step 3:数学表达式精度对比验证**
+
+```
+输入: "12+38*2"   → 期望: 88.0
+输入: "(1+2)^3"   → 期望: 27.0
+输入: "round(3.7)" → 期望: 4.0
+输入: "abs(-5)"   → 期望: 5.0
+... 提取全部原表达式用例
+```
+
+---
+
+#### Task 4.2: 性能基准对比
+
+- [ ] **Step 1:记录原 Dify 链路延迟(基線)**
+
+如果 Dify 仍可用,取 10 次请求的 P50/P95/P99 延迟。
+如果 Dify 已不可用,使用 Phase 3 完成后的测试数据。
+
+- [ ] **Step 2:记录 Spring AI 链路延迟**
+
+对相同查询,取 10 次请求的 P50/P95/P99 延迟。
+
+- [ ] **Step 3:确认不比原链路差**
+
+延迟、错误率均不劣化则可进入上线。
+
+---
+
+#### Task 4.3: 灰度上线
+
+- [ ] **Step 1:灰度 10% 流量(1 天)**
+
+Nginx/网关配置 10% 流量指向新版本,监控错误率。
+
+- [ ] **Step 2:灰度 50% 流量(1 天)**
+
+确认 10% 稳定后扩大到 50%,继续监控。
+
+- [ ] **Step 3:全量上线 + Dify 实例保留下线**
+
+100% 流量切到新版本,Dify 实例暂停但不销毁(数据保留 30 天)。
+
+- [ ] **Step 4:打最终 tag**
+
+```bash
+git tag v3.0-production
+```
+
+---
+
+## 执行顺序图
+
+```
+Phase 0 ── Task 0.1 (BOM对齐)
+         └── Task 0.2 (Nashorn范围)
+
+Phase 1 ── Task 1.1 (Entity)
+         ├── Task 1.2 (DTO)
+         └── Task 1.3 (全局收尾)
+
+Phase 2 ── Task 2.1 (exp4j)
+         ├── Task 2.2 (jjwt)
+         ├── Task 2.3 (SB3行为)
+         └── Task 2.4 (springdoc验证)
+
+Phase 3 ── Task 3.1 (Spring AI依赖)
+         ├── Task 3.2 (LLMService)
+         ├── Task 3.3 (System Prompt)
+         ├── Task 3.4 (AIService重构)
+         ├── Task 3.5 (RAGService)
+         └── Task 3.6 (剩余Service)
+
+Phase 4 ── Task 4.1 (功能回归)
+         ├── Task 4.2 (性能对比)
+         └── Task 4.3 (灰度上线)
+```
+
+Phase 1 可在 Task 1.1 + 1.2 中 2 人并行。
+Phase 3 的 Task 3.2-3.6 建议 1 人连续完成(上下文依赖强)。
+其余阶段按顺序执行。

+ 705 - 0
docs/architecture/Dify替代迁移可行性分析.md

@@ -0,0 +1,705 @@
+# Dify 替代迁移可行性分析
+
+> 版本:v1.0  
+> 日期:2026-07-20  
+> 范围:cfc-backend (Spring Boot 2.7.18 / Java 8) + 前端 + 运营后台
+
+---
+
+## 目录
+
+1. [背景与现状](#1-背景与现状)
+2. [Dify 依赖全景](#2-dify-依赖全景)
+3. [替代方案评估矩阵](#3-替代方案评估矩阵)
+4. [推荐方案:Spring AI + AxonHub 分层替换](#4-推荐方案spring-ai--axonhub-分层替换)
+5. [Dify 全功能可替代性结论](#5-dify-全功能可替代性结论)
+6. [分阶段实施计划](#6-分阶段实施计划)
+7. [风险评估与应对](#7-风险评估与应对)
+8. [成本收益分析](#8-成本收益分析)
+9. [决策树](#9-决策树)
+
+---
+
+## 1. 背景与现状
+
+### 1.1 项目技术栈
+
+| 模块 | 技术栈 | 版本限制 |
+|------|--------|---------|
+| 后端 | Spring Boot + Java 8 | JDK 1.8,**无法升级至 17+** |
+| ORM | MyBatis-Plus | — |
+| 数据库 | MySQL 8.0 | 无向量检索能力 |
+| AI 平台 | Dify 自部署 | `http://dify.bianwoyou.cn/v1` |
+| 服务端 | 国内自建服务器 | 需国产 LLM API 合规 |
+
+### 1.2 Dify 接入现状
+
+Dify 在项目中承担 **AI 能力中间件** 角色,并非核心业务系统,但深度嵌入 8 个 Service、3 个 Controller:
+
+```
+Dify 能力               项目消费者                           依赖深度
+────────────────────────────────────────────────────────────────────
+Chatbot API             AIService.sendMessage()              ⭐⭐⭐ 核心
+                        AIService.sendNutritionMessage()     ⭐⭐⭐ 核心
+                        ArticleService.getAiQuestions()      ⭐⭐ 功能
+Workflow API            AIService.runWorkflow()              ⭐⭐⭐ 核心
+                        AIService.sendTongueDiagnosis()      ⭐⭐ 可替换
+                        AIService.requestSummaryAsync()      ⭐⭐ 可替换
+                        WisdomMathService.verifyAnswer()     ⭐ 已有兜底
+Datasets API            DifySyncService.syncToDify()         ⭐⭐ 可替换
+                        DifySyncService.removeFromDify()     ⭐ 低频
+会话管理                AIService.getConversations()          ⭐ 已有本地镜像
+                        AIService.getMessages()               ⭐ 已有本地镜像
+```
+
+### 1.3 关键发现
+
+- **已有本地镜像层**:`ChatMirrorService` 已完成 Dify 会话和消息的本地存储
+- **已有降级机制**:舌诊 mock、数学判题 eval、会话列表回退
+- **Java 8 限制**:Spring AI(需 Boot 3.x)、LangChain4j starter(需 Boot 3.x)不可用
+- **Dify 运维成本**:单独部署维护,版本升级带来兼容风险
+
+---
+
+## 2. Dify 依赖全景
+
+### 2.1 代码依赖映射
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│                      AIService (523 行)                         │
+│            Dify API 的核心抽象,8 个公共方法                     │
+├─────────────────────────────────────────────────────────────────┤
+│                                                                 │
+│  ┌─ sendMessage() ─────────── Dify Chatbot (通用助手)           │
+│  ├─ sendNutritionMessage() ── Dify Chatbot (营养助手, 独立 Key) │
+│  ├─ sendTongueDiagnosis() ─── Dify Workflow (舌诊)              │
+│  ├─ runWorkflow() ─────────── Dify Workflow (通用)              │
+│  ├─ requestSummaryAsync() ─── Dify Workflow (摘要生成)          │
+│  ├─ getConversations() ────── Dify REST API (会话列表)          │
+│  ├─ getMessages() ─────────── Dify REST API (消息历史)          │
+│  └─ deleteConversation() ──── Dify REST API (删除会话)          │
+│                                                                 │
+└──────────────────────┬──────────────────────────────────────────┘
+                       │ 被以下服务调用
+                       ▼
+┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐
+│AIChat    │Butler    │Article   │Meal      │Wisdom    │Tongue    │
+│Controller│Controller│Service   │Recommend │MathService│Diagnosis │
+│          │          │          │Service   │          │Service   │
+└──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘
+```
+
+### 2.2 数据表依赖
+
+| 表名 | 用途 | 迁移影响 |
+|------|------|---------|
+| `chat_conversations` | Dify 会话本地镜像 | 保留,转正为主数据源 |
+| `chat_messages` | Dify 消息本地镜像 | 保留,转正为主数据源 |
+| `ai_conversation_summary` | AI 生成的对话摘要 | 保留,来源从 Dify Workflow 改为本地 LLM |
+| `ai_user_facts` | AI 提取的用户关键事实 | 保留,来源从 Dify Workflow 改为本地 LLM |
+| `butler_sessions` | AI 管家会话 | 保留,移除 conversation_id 的 Dify 依赖 |
+| `product_dimension_config.dify_dataset_id` | 维度 → Dify 数据集 | 移除,改用本地向量检索 |
+| `dan_knowledge_base*` | 知识库 | 保留,同步目标从 Dify 改为本地 |
+
+### 2.3 不可替换的组件(需保留等价能力)
+
+| Dify 组件 | 等价能力需求 |
+|-----------|-------------|
+| LLM 反向代理 | HTTP 调用 LLM API |
+| System Prompt 管理 | 多套 System Prompt 配置 |
+| 上下文注入 | 已有 `FamilyContextService` |
+| 知识库检索 | 向量检索 + 相似度排序 |
+| 多步 Workflow | Java 服务链编排 |
+
+---
+
+## 3. 替代方案评估矩阵
+
+### 3.1 Java 生态方案
+
+| 方案 | 类型 | 与 Java 集成 | 可视化 Workflow | 知识库 RAG | 运维成本 | 综合得分 |
+|-----|------|-------------|----------------|-----------|---------|---------|
+| **D. 模块化混合替换** | Java + API | ⭐⭐⭐⭐⭐ 原生 | ❌ 代码编排 | ⭐⭐⭐ 自建 | ⭐⭐⭐⭐⭐ 极低 | **🥇 9/10** |
+| A. 直接 LLM API | Java 嵌入 | ⭐⭐⭐⭐⭐ 原生 | ❌ 代码编排 | ⭐⭐ 需自建 | ⭐⭐⭐⭐⭐ 极低 | 🥈 8/10 |
+| FastGPT 替代 | 平台 | ⭐⭐⭐ HTTP | ✅ 可视化 | ⭐⭐⭐⭐ 强 | ⭐⭐ 中 | 5/10 |
+| LangChain4j | Java 库 | ⭐⭐⭐ 部分兼容 | ❌ | ⭐⭐⭐ 内置 | ⭐⭐⭐⭐ 低 | 5/10 |
+| Spring AI | Java 框架 | ❌ 需 Boot 3.x | ❌ | ⭐⭐ | ❌ 不可用 | 0/10 |
+
+### 3.2 Python 生态方案
+
+| 方案 | 类型 | 集成方式 | 定位匹配度 | 运维成本 | 适合本项目? | 综合得分 |
+|-----|------|---------|-----------|---------|------------|---------|
+| **LangChain (Python)** | LLM 框架 | 独立服务 HTTP | ⭐⭐⭐ | ⭐⭐ 中 | ⚠️ 有条件可行 | 6/10 |
+| LangGraph | Workflow 框架 | 独立服务 HTTP | ⭐⭐ | ⭐ 高 | ❌ 不推荐 | 3/10 |
+| OpenClaw (小龙虾) | Agent 运行时 | 独立服务 | ⭐ | ⭐ 高 | ❌ 定位不匹配 | 2/10 |
+
+### 3.3 Python 方案详细分析
+
+#### LangChain (Python)
+
+| 评估项 | 说明 |
+|--------|------|
+| **部署形式** | 额外的 Python 服务进程,与 Java 后端通过 HTTP 通信 |
+| **可复用的项目资产** | `FamilyContextService`(上下文组装)、`ChatMirrorService`(持久化)需保留在 Java 侧 |
+| **需要新开发的** | FastAPI 网关层、Python 侧 Agent/RAG 逻辑、双语言监控告警 |
+| **运维新增** | Python 运行时管理、pip 依赖管理、进程守护、跨语言排查 |
+| **团队要求** | 需要 Python 工程师维护 AI 服务层 |
+
+**适合场景**:团队有 Python 工程师、AI 侧需要频繁实验(Prompt 调优、RAG 策略测试)、愿意接受多语言栈运维。
+
+**不适合场景**:纯 Java 团队、运维人力紧张、对延迟敏感(每增加一次 HTTP 中转增加 10-50ms)。
+
+#### LangGraph
+
+LangGraph 是 LangChain 生态中专门做有状态 Agent 编排的框架。如果当前 Dify Workflow 的复杂编排是核心痛点,LangGraph 可以提供代码级等价能力。但:
+
+- **学习曲线极其陡峭** — StateGraph、Node/Edge 抽象、条件路由
+- **额外的复杂度** — 一个 Python 方案中再嵌套一个复杂框架
+- **如果不需要复杂 Workflow**,LangGraph 就是过度设计
+
+本项目当前的 Workflow 使用(舌诊分析、对话摘要、事实提取、数学判题)**都不超过 3 个节点**,完全可以用 Java 直接实现。
+
+#### 小龙虾 (OpenClaw / 皮皮虾 / Nanobot)
+
+OpenClaw 生态(包括国产衍生产品 PP-Claw 皮皮虾、网易 LobsterAI、Nanobot 等)的本质是 **个人 AI 助手运行时**,而不是 **面向终端用户的 AI 应用平台**。
+
+| OpenClaw 能力 | 本项目需求 | 匹配度 |
+|--------------|-----------|--------|
+| IM 渠道接入 (飞书/钉钉/企微) | ❌ 不需要(有前端页面) | ❌ |
+| 文件/Shell/代码执行 | ❌ 不需要(AI 只做推理) | ❌ |
+| 持续后台运行 | ❌ 不需要(请求-响应模式) | ❌ |
+| 多 LLM Provider 切换 | ✅ 需要 | ⭐⭐ 非核心诉求 |
+| Chatbot API | ❌ 不提供标准 API | ❌ |
+| 知识库 RAG | ❌ 弱 | ❌ |
+| 可视化 Workflow | ❌ 无 | ❌ |
+
+OpenClaw 的流行不代表它适合替换 Dify。两者定位完全不同:
+
+> Dify = 给开发者用的 **AI 应用后端平台**  
+> OpenClaw = 给终端用户用的 **AI 个人助手**
+
+---
+
+## 4. 推荐方案:Spring AI + AxonHub 分层替换
+
+### 4.1 最终架构(2026-07-20 决策)
+
+```
+升级前:
+┌──────────────────────────────────────────────────────────────┐
+│  Frontend                                                     │
+│  chat.vue → AIChatController → AIService → Dify → LLM API    │
+│                                      (应用层+路由层一体)       │
+└──────────────────────────────────────────────────────────────┘
+
+升级后:
+┌──────────────────────────────────────────────────────────────┐
+│  Frontend                                                     │
+│  chat.vue → AIChatController → LLMService (Spring AI)        │
+│                                  ├─ ChatClient (Chat)         │
+│                                  ├─ @Tool (Function Calling)  │
+│                                  ├─ VectorStore (RAG)         │
+│                                  └─ MemoryAdvisor (记忆)      │
+│                                       │                        │
+│                                       ▼                        │
+│                              AxonHub (模型路由层)               │
+│                              axonhub.iwintrue.com              │
+│                              ├─ 模型分发 / 自动降级            │
+│                              ├─ 用量计费 / 密钥管理            │
+│                              └─ 多模型统一网关                 │
+│                                       │                        │
+│                  ┌────────────────────┼──────────────────┐     │
+│                  ▼                    ▼                  ▼     │
+│             DeepSeek               Qwen              本地模型  │
+│                                                                 │
+│  本地持久化:                                                    │
+│  ├─ ChatMirrorService (对话缓存,转正)                           │
+│  ├─ ChatMemory (JDBC/Redis 持久化)                              │
+│  ├─ sqlite-vec (知识库向量存储)                                 │
+│  └─ docling / unstructured (复杂PDF预处理,Python脚本)          │
+└──────────────────────────────────────────────────────────────┘
+
+分层职责:
+┌──────────────┬──────────────────┬──────────────────────────────┐
+│ 层           │ 技术             │ 职责                         │
+├──────────────┼──────────────────┼──────────────────────────────┤
+│ AI 应用层    │ Spring AI        │ 对话、RAG、Tool Calling、    │
+│              │                  │ 记忆管理、多模型编排、MCP    │
+├──────────────┼──────────────────┼──────────────────────────────┤
+│ 模型路由层   │ AxonHub          │ 模型分发、自动降级、         │
+│              │ (axonhub.iwintrue.com) │ 用量计费、统一网关       │
+├──────────────┼──────────────────┼──────────────────────────────┤
+│ 文档预处理   │ docling /        │ 复杂PDF/表格/多栏布局解析   │
+│              │ unstructured     │                              │
+└──────────────┴──────────────────┴──────────────────────────────┘
+```
+
+### 4.2 核心替换策略
+
+#### 4.2.1 Spring AI ChatClient 封装(替代手写 LLMService)
+
+```java
+@Service
+public class LLMService {
+
+    @Resource
+    private ChatClient chatClient;    // Spring AI 自动配置
+
+    @Resource @Qualifier("openAiChatModel")
+    private ChatModel deepSeekModel;  // → AxonHub → DeepSeek
+
+    @Resource @Qualifier("dashScopeChatModel")
+    private ChatModel qwenModel;      // → AxonHub → Qwen
+
+    // 默认聊天
+    public String chat(String query, String systemPrompt, String conversationId) {
+        return chatClient.prompt()
+            .system(systemPrompt)
+            .user(query)
+            .advisors(new MessageChatMemoryAdvisor(chatMemory, conversationId, 10))
+            .call()
+            .content();
+    }
+
+    // 工作流各节点可用不同模型
+    public String chatWithModel(String query, String systemPrompt,
+                                 String conversationId, ChatModel model) {
+        return ChatClient.builder(model).build()
+            .prompt()
+            .system(systemPrompt)
+            .user(query)
+            .call()
+            .content();
+    }
+
+    // 多模型编排
+    public String executeWorkflow(String input) {
+        String intent = ChatClient.builder(deepSeekModel).build()
+            .prompt().user("分类: " + input).call().content();
+        return ChatClient.builder(qwenModel).build()
+            .prompt().system("分析专家").user(intent).call().content();
+    }
+}
+```
+
+对比原方案的手写 `DirectLLMService`,Spring AI 省去了:
+- 自己维护 HTTP 调用(Spring AI 内置)
+- 自己解析 JSON 响应(`StructuredOutput` 直接转 POJO)
+- 自己处理流式(`Flux<String>` 原生)
+- 自己实现重试/降级(Advisors 链式扩展)
+
+#### 4.2.2 System Prompt 外部化
+
+从 Dify 应用配置迁移到本地:
+
+```yaml
+# application.yml
+ai:
+  assistants:
+    family:
+      system-prompt: "你是一个家庭成长助手..."
+      model: "deepseek-chat"
+      temperature: 0.7
+      max-tokens: 2048
+    nutrition:
+      system-prompt: "你是一个精准营养分析专家..."
+      model: "deepseek-chat"
+      temperature: 0.5
+      max-tokens: 4096
+    tongue:
+      system-prompt: "你是一个中医舌诊专家..."
+      model: "qwen-vl-max"  # 多模态
+      temperature: 0.3
+```
+
+#### 4.2.3 AxonHub 模型路由配置
+
+所有模型调用统一经 AxonHub 网关,不直连任何 Provider:
+
+```yaml
+# application.yml
+spring:
+  ai:
+    openai:                                     # 默认模型 → AxonHub → DeepSeek
+      base-url: https://axonhub.iwintrue.com/v1
+      api-key: ${AXONHUB_API_KEY}
+      chat:
+        options:
+          model: deepseek-chat
+          temperature: 0.7
+    dashscope:                                   # 第二模型 → AxonHub → Qwen
+      api-key: ${AXONHUB_API_KEY}
+      chat:
+        options:
+          model: qwen-max
+    ollama:                                      # 本地模型 (不经过 AxonHub)
+      base-url: http://localhost:11434
+      chat:
+        options:
+          model: qwen2.5:7b
+```
+
+AxonHub 接管原 Dify 的模型路由职责:自动分发请求到对应模型、故障时降级到备用模型、统一计费和密钥管理。Spring AI 只需指定 model 参数。
+
+#### 4.2.4 Workflow 拆解
+
+| 当前 Dify Workflow | 替换方式 | 工作量 |
+|--------------------|---------|--------|
+| 舌诊分析 | 直接调 Qwen-VL API + 结构化输出解析 | 0.5 天 |
+| 对话摘要生成 | `ChatSummaryService` 定时调用 LLM | 1 天 |
+| 事实提取 | `FactExtractionService` 结构化 Prompt 解析 | 0.5 天 |
+| 数学判题 | 已有 Eval 兜底,永久化 | 0 天 |
+| 食谱推荐 | 直接调 LLM API + `MealRecommendService` 聚合上下文 | 0.5 天 |
+
+#### 4.2.5 知识库 RAG 本地化
+
+```
+当前:管理端创建知识点 → DifySyncService → Dify Datasets → Dify 检索
+替换后:管理端创建知识点 → TextEmbeddingService → sqlite-vec → 相似度检索
+```
+
+**向量方案选择:**
+
+| 方案 | 说明 | 推荐度 |
+|------|------|--------|
+| **sqlite-vec** | 纯 SQLite 扩展,零运维,适合知识量 < 10 万条 | ⭐⭐⭐ 首选 |
+| 独立 pgvector | PostgreSQL 插件,需新建 PG 实例 | ⭐⭐ 次选 |
+| 阿里云 AnalyticDB | 托管向量库,免运维 | ⭐⭐ 适合云上 |
+| Milvus / Qdrant | 专业向量库,太重 | ❌ |
+
+**推荐 sqlite-vec 理由**:项目当前使用 MySQL,无法做向量检索。sqlite-vec 作为一个零配置的 SQLite 扩展,可以独立运行,不需要额外部署服务。适合当前几千条知识库的规模。
+
+#### 4.2.6 文档预处理(PDF 复杂解析)
+
+Dify 内置的 PDF 解析对简单文档足够,但复杂 PDF(表格、多栏布局)效果一般。可用 Python 专有工具替代:
+
+| 工具 | 说明 | 推荐度 |
+|------|------|--------|
+| **docling** (IBM) | 开源 PDF 解析,表格/多栏布局强,输出 Markdown/JSON | ⭐⭐⭐ 首选 |
+| unstructured.io | 商业级文档解析,多格式支持 | ⭐⭐ 次选 |
+| Marker | 开源 PDF 转 Markdown,精度好 | ⭐⭐ 备选 |
+
+Spring AI ETL Pipeline 处理简单文档,复杂文档走 Python 预处理脚本。
+
+---
+
+## 5. Dify 全功能可替代性结论
+
+### 5.1 功能对照表
+
+| 编号 | Dify 功能 | Spring AI | AxonHub | 其他工具 | 可替代? | 本项目需要? |
+|------|----------|-----------|---------|---------|---------|------------|
+| 1 | 聊天机器人 (Chatbot) | ChatClient + MemoryAdvisor | — | — | ✅ | ✅ |
+| 2 | 流式聊天响应 | Flux<String> | — | — | ✅ | ✅ |
+| 3 | 对话记忆管理 | MessageChatMemoryAdvisor | — | — | ✅ | ✅ |
+| 4 | 文本生成 (Completion) | ChatClient.call() | — | — | ✅ | ✅ |
+| 5 | **可视化工作流画布** | ❌ 无 | — | 代码编排 | ❌ **不可替代** | ❌ **不需要(只调API)** |
+| 6 | 工作流: LLM 节点 | ChatClient | AxonHub 路由 | — | ✅ | ✅ |
+| 7 | 工作流: 条件分支 | Java if/switch | — | — | ✅ | ✅ |
+| 8 | 工作流: 循环/迭代 | Java for/while | — | — | ✅ | ✅ |
+| 9 | 工作流: 代码节点 | Java 方法 | — | — | ✅ **更好** | ✅ |
+| 10 | 工作流: HTTP 请求 | RestTemplate/WebClient | — | — | ✅ **更好** | ✅ |
+| 11 | **工作流: 各节点不同模型** | @Qualifier 多 ChatModel | ✅ 统一网关 | — | ✅ **更灵活** | ✅ |
+| 12 | RAG: 文档分块/向量化 | ETL Pipeline | — | — | ✅ | ✅ |
+| 13 | RAG: 混合搜索 | 依赖 VectorStore | — | — | ⚠️ 部分 | ⚠️ 未来 |
+| 14 | RAG: 重排序 (Rerank) | 需手动实现 | — | — | ⚠️ 需手动 | ⚠️ 未来 |
+| 15 | **RAG: 复杂PDF/表格解析** | ❌ 较弱 | — | docling/unstructured | ✅ **第三方更强** | ⚠️ 未来 |
+| 16 | **模型路由/自动降级** | ❌ 无 | ✅ **AxonHub** | — | ✅ **更专业** | ✅ |
+| 17 | **模型用量计费** | ❌ 无 | ✅ **AxonHub** | — | ✅ **更专业** | ⚠️ 可选项 |
+| 18 | 50+ 内置工具 | @Tool 手动封装 | — | — | ⚠️ 需手动 | ❌ 不需要 |
+| 19 | **Prompt IDE/版本管理** | ❌ 无 | — | Git 管理 | ❌ **不可替代** | ❌ **不需要** |
+| 20 | **LLMOps 仪表盘** | Micrometer + Grafana | AxonHub 计费 | — | ✅ **标准化更强** | ✅ |
+| 21 | Token 用量追踪 | gen_ai.client.token.usage | AxonHub | — | ✅ | ✅ |
+| 22 | 用户反馈/标注 | 需手动实现 | — | — | ⚠️ 需手动 | ❌ 不需要 |
+| 23 | A/B Prompt 测试 | ❌ 无 | — | — | ❌ 不可替代 | ❌ 不需要 |
+| 24 | 函数调用 (Function Calling) | @Tool 注解 | — | — | ✅ **类型安全** | ✅ |
+| 25 | 多 Agent 协作 | Advisors + @Tool | — | — | ✅ | ❌ 不需要 |
+| 26 | 语音转文字 (STT) | AudioTranscription | — | — | ✅ | ❌ 不需要 |
+| 27 | 文字转语音 (TTS) | TextToSpeech | — | — | ✅ | ❌ 不需要 |
+| 28 | 图片生成 | ImageModel | — | — | ✅ | ❌ 不需要 |
+| 29 | 图片理解 (Vision) | ChatClient 多模态 | — | — | ✅ | ✅ |
+| 30 | 内容审核 | ModerationModel | — | — | ✅ | ❌ 不需要 |
+| 31 | SSO/RBAC | Spring Security | — | — | ✅ **更成熟** | ❌ 不需要 |
+| 32 | API 限流 | Spring Cloud Gateway | AxonHub | — | ✅ | ❌ 不需要 |
+| 33 | MCP 集成 | MCP Boot Starter | — | — | ✅ **一级支持** | ⚠️ 未来 |
+| 34 | 批处理 | Spring Batch | — | — | ✅ | ❌ 不需要 |
+
+### 5.2 结论
+
+```
+不可替代且本项目需要的功能:0 个
+不可替代但不需要的功能:   3 个(可视化画布、Prompt IDE、A/B 测试—因为只调 API 不需要 UI)
+可替代且更好的功能:     大多数(类型安全、零网络跳、Spring 原生)
+```
+
+## 7. 分阶段实施计划(详见独立文档)
+
+> 完整的 5 阶段 Task 级计划见 `docs/architecture/2026-07-20-SB3-upgrade-plan.md`
+> 设计概览见 `docs/architecture/SpringBoot3升级+Dify替代实施计划.md`
+
+### 总览
+
+| 阶段 | 内容 | 时间 |
+|------|------|------|
+| Phase 0 | 前置准备(JDK 21、依赖对齐) | 3 天 |
+| Phase 1 | javax → jakarta 迁移 | 5 天 |
+| Phase 2 | 行为适配 + Nashorn 替换 | 4 天 |
+| Phase 3 | Spring AI + AxonHub + Dify 替换 | 5 天 |
+| Phase 4 | 回归验证 + 灰度上线 | 3 天 |
+| **合计** | | **20 人天** |
+
+### 阶段 1:基础设施(1 周)
+
+**目标**:建立 LLMService 抽象层,核心对话不再依赖 Dify
+
+```
+Week 1
+├── Day 1-2: LLMService 接口 + DirectLLMService 实现
+│   ├── 接口定义 (sendChat / streamChat / isAvailable)
+│   ├── DeepSeek API 适配
+│   ├── Qwen API 适配
+│   └── AIService 改为 DifyLLMService 实现
+├── Day 3: System Prompt 迁移
+│   ├── 从 Dify 导出所有 System Prompt
+│   ├── 配置到 application.yml
+│   └── Strategy Router 实现多 Assistant 路由
+├── Day 4: 上下文注入改造
+│   ├── FamilyContextService → ContextBuilder 接口
+│   └── Prompt 拼接逻辑替换 Dify inputs 注入
+└── Day 5: 健康检查 + 降级
+    ├── LLMService 健康检查
+    ├── DirectLLMService 不可用时自动降级到 DifyLLMService
+    └── 单元测试 + 集成测试
+```
+
+**产出**:
+- `LLMService.java` 接口 + 2 个实现类
+- `AssistantConfig` 配置体系
+- `ContextBuilder.java` 独立接口
+- 降级机制
+
+**验证标准**:
+- `POST /api/ai/chat/send` 响应 ≤ 2s (P95)
+- 会话历史、记忆层功能正常
+- Dify 停服时自动降级到 DeepSeek
+
+### 阶段 2:Workflow 拆解(1 周)
+
+**目标**:去掉所有 Dify Workflow 依赖
+
+```
+Week 2
+├── Day 1: 舌诊 Workflow → Qwen-VL
+│   ├── 新增 QwenVLService (多模态 API)
+│   ├── TongueDiagnosisService 迁移
+│   └── 移除 mock 数据逻辑
+├── Day 2: 对话摘要 + 事实提取
+│   ├── ChatSummaryService (定时 + 触发双模式)
+│   ├── FactExtractionService (结构化 Prompt)
+│   └── AIService.requestSummaryAsync → 新实现
+├── Day 3: 数学判题 → Eval 永久化
+│   ├── WisdomMathService verifyMode 默认改为 eval
+│   └── 移除 Dify workflow-id 配置依赖
+├── Day 4: 食谱推荐 → LLM 直调
+│   └── MealRecommendService 改为调用 DirectLLMService
+└── Day 5: 文章测验 + 标签生成
+    ├── ArticleService.getAiQuestions → 直调 LLM
+    └── ArticleService.autoGenerateTags → 直调 LLM
+```
+
+**产出**:
+- 5 个新的/改造的 Service
+- 移除所有 Workflow 调用代码
+
+**验证标准**:
+- 舌诊分析准确率 ≥ 当前 Dify Workflow
+- 数学判题 100% 可用(已用 eval 兜底)
+- 对话摘要能在 3 秒内生成
+
+### 阶段 3:知识库 RAG 本地化(1 周)
+
+**目标**:去掉 Dify Datasets 依赖
+
+```
+Week 3
+├── Day 1-2: 向量检索基础设施
+│   ├── sqlite-vec 集成
+│   ├── EmbeddingService (调用 BGE/bge-large-zh 或 API)
+│   └── RAGService (检索 + 重排序 + 上下文注入)
+├── Day 3: 知识库同步改造
+│   ├── DifySyncService → LocalKBService
+│   ├── 保存知识时自动向量化
+│   └── 管理端接口不变
+├── Day 4: 知识库检索替换
+│   ├── RecommendationService.searchViaDify → searchViaLocal
+│   └── 注入 LLM 上下文
+└── Day 5: 清理
+    ├── 删除 Dify 相关配置
+    ├── 删除 DifySyncService
+    ├── AIService 中移除 Dify 方法(或标记 Deprecated)
+    └── 端到端回归测试
+```
+
+**产出**:
+- `RAGService.java`
+- `EmbeddingService.java`
+- `LocalKBService.java`(替代 DifySyncService)
+- sqlite-vec 向量库初始化
+
+**验证标准**:
+- 知识库检索召回率 ≥ Dify Datasets
+- 管理端 CRUD 功能完全一致
+- 端到端回归测试通过
+
+### 阶段 4:收尾(3 天)
+
+```
+Week 4
+├── Day 1: 配置清理
+│   ├── application.yml 移除 dify.* 配置
+│   ├── DatabaseInitializer 移除 Dify 相关迁移
+│   └── schema.sql 确认表结构
+├── Day 2: 代码清理
+│   ├── 删除 DifySyncService.java
+│   ├── AIService.java 简化(移除 Dify 方法)
+│   └── 全局搜索 dify 引用,确认无残留
+└── Day 3: 文档 + 部署
+    ├── 更新 数据与AI结合技术方案.md
+    ├── 部署文档更新
+    └── Dify 服务器下线确认
+```
+
+---
+
+## 8. 风险评估与应对
+
+### 8.1 风险矩阵
+
+| 风险 | 概率 | 影响 | 应对措施 |
+|------|------|------|---------|
+| DeepSeek/Qwen API 不稳定 | 中 | 高 | 多 Provider 自动切换 + 本地降级回复 |
+| LLM API 费用超预期 | 中 | 中 | Token 用量监控 + 缓存策略 + Prompt 压缩 |
+| sqlite-vec 检索质量低于 Dify | 低 | 中 | Hybrid Search (向量 + 关键词) + 重排序 Reranker |
+| 团队不熟悉新架构 | 低 | 中 | 增量迁移,保持 Dify 降级通道 1 个月 |
+| 迁移期间 Dify 出问题 | 低 | 高 | 阶段 1 先建好降级机制再动 |
+
+### 6.2 回退计划
+
+每个阶段都是**可逆的**:
+
+```
+阶段 1 回退:切换 LLMService 的 @Primary 回到 DifyLLMService
+阶段 2 回退:恢复 AIService 的 Workflow 调用
+阶段 3 回退:DifySyncService 仍保留,只是不再主动调用
+阶段 4 回退:保留 Dify 配置,不清理
+```
+
+### 6.3 不在范围内的
+
+- 前端 chat.vue 不改动(只改后端 API 响应)
+- AI 记忆层数据结构不改动(`ai_conversation_summary`、`ai_user_facts` 表不变)
+- Dify 服务器暂不下线(保留 1 个月观察期)
+
+---
+
+## 9. 成本收益分析
+
+### 7.1 开发成本
+
+| 阶段 | 人天 | 说明 |
+|------|------|------|
+| 阶段 1:基础设施 | 5 人天 | LLMService 抽象、System Prompt 迁移、降级机制 |
+| 阶段 2:Workflow 拆解 | 5 人天 | 舌诊/摘要/事实提取/食谱/文章 5 个模块 |
+| 阶段 3:知识库 RAG | 5 人天 | sqlite-vec、Embedding、知识库同步 |
+| 阶段 4:收尾清理 | 3 人天 | 配置/代码/文档清理 |
+| **合计** | **18 人天** | 约 3-4 周 |
+
+### 7.2 运维成本变化
+
+| 项目 | 迁移前 | 迁移后 | 变化 |
+|------|--------|--------|------|
+| Dify 服务器 | 2C4G 云服务器 | 移除 | **节省** |
+| Dify 维护 | 版本升级、Docker 运维 | 零 | **节省** |
+| LLM API 费用 | 透传(不变) | 不变 | 持平 |
+| 向量库 | 无 | sqlite-vec (零运维) | 新增但极低 |
+| Python 服务 | 无 | 无(全部 Java) | 零 |
+
+**直接节省**:至少 1 台云服务器的费用(按 2C4G 约 ¥200-400/月)+ 运维时间。
+
+### 7.3 性能收益
+
+| 指标 | 迁移前(经 Dify) | 迁移后(直调) | 提升 |
+|------|-----------------|---------------|------|
+| 请求延迟(P50) | ~800ms + Dify 中转 | ~600ms | -25% |
+| 请求延迟(P95) | ~2.5s | ~1.8s | -28% |
+| 故障点 | Java + Dify + LLM | Java + LLM | 减少 1 个 |
+| HTTP 跳转 | 2 跳(Java→Dify→LLM) | 1 跳(Java→LLM) | 减半 |
+
+---
+
+## 10. 决策树
+
+```
+需要替换 Dify?
+│
+├─ 是,彻底替换 ──────────────────────────→ 方案 D:模块化混合替换(18 人天)
+│
+├─ 是,但团队有 Python 工程师 ─────────────→ 方案 D + Python LangChain AI 服务层
+│                                              (Java 做业务层,Python 做 AI 层)
+│
+├─ 否,只想降低成本 ──────────────────────→ 保留 Dify,优化措施:
+│   ① 合并 API Key(3→1)           
+│   ② Dify 降配服务器                   
+│   ③ 增强 ChatMirrorService 本地兜底    
+│
+├─ 否,但担心 Dify 不可用 ────────────────→ 降级通道方案(1 周):
+│   ① LLMService 接口 + DirectLLMService  
+│   ② Dify 作为降级(主直调,Dify 备用)   
+│   ③ 无需迁移全部功能                     
+│
+└─ 不确定 ──────────────────────────────→ 从阶段 1 开始(LLMService 接口)
+                                              第 1 周后评估是否继续
+```
+
+---
+
+## 附录
+
+### A.1 涉及文件清单
+
+| 文件 | 操作 |
+|------|------|
+| `service/AIService.java` | 改造为 DifyLLMService 实现 |
+| `service/LLMService.java` | 新建 |
+| `service/DirectLLMService.java` | 新建 |
+| `service/ContextBuilder.java` | 从 FamilyContextService 提取接口 |
+| `service/RAGService.java` | 新建 |
+| `service/EmbeddingService.java` | 新建 |
+| `service/ChatSummaryService.java` | 新建 |
+| `service/FactExtractionService.java` | 新建 |
+| `service/LocalKBService.java` | 新建 |
+| `config/AssistantConfig.java` | 新建(System Prompt 配置类) |
+| `service/DifySyncService.java` | 删除(阶段 4) |
+| `service/WisdomMathService.java` | 修改(eval 默认) |
+| `service/TongueDiagnosisService.java` | 修改(直调 Qwen-VL) |
+| `service/ArticleService.java` | 修改(直调 LLM) |
+| `service/MealRecommendService.java` | 修改(直调 LLM) |
+| `controller/ai/AIChatController.java` | 修改(引用 LLMService) |
+| `controller/ai/ButlerController.java` | 修改(引用 LLMService) |
+| `application.yml` | 修改(添加 AI 配置,移除 Dify 配置) |
+| `schema.sql` | 无变化 |
+| `DatabaseInitializer.java` | 无变化 |
+
+### A.2 参考文档
+
+- [数据与AI结合技术方案.md](../数据与AI结合技术方案.md) — 现有 AI 架构文档
+- `AGENTS.md` — 项目开发规范
+- DeepSeek API 文档:`https://platform.deepseek.com/api-docs`
+- sqlite-vec 项目:`https://github.com/asg017/sqlite-vec`
+
+### A.3 变更记录
+
+| 版本 | 日期 | 变更内容 |
+|------|------|---------|
+| v1.0 | 2026-07-20 | 初稿,覆盖 4 阶段迁移计划 |

+ 569 - 0
docs/architecture/SpringBoot3升级+Dify替代实施计划.md

@@ -0,0 +1,569 @@
+# Spring Boot 3.x 升级 + Dify 替代 实施计划
+
+> 版本: v1.1
+> 日期: 2026-07-20
+> 范围: cfc-backend (Spring Boot 2.7.18 + Java 8 → Spring Boot 3.4.x + Java 21)
+
+---
+
+## 一、目标状态
+
+| 维度 | 当前 | 目标 |
+|------|------|------|
+| Java | 1.8 (JDK 8) | **21 LTS** |
+| Spring Boot | 2.7.18 | **3.4.x** (最新稳定) |
+| AI 框架 | 直调 Dify API | **Spring AI 1.x** |
+| 模型路由 | Dify 内置 | **AxonHub** (`axonhub.iwintrue.com`) |
+| 知识库 | Dify Datasets | **本地 sqlite-vec** |
+| 文档预处理 | Dify 内置 | **Python 专有工具** (docling / unstructured) |
+| 数学引擎 | Nashorn (JDK 15 已移除) | **exp4j / SpEL** |
+| API 文档 | springdoc-openapi 1.8.0 | **springdoc-openapi 2.7+** |
+| ORM | MyBatis-Plus 3.5.3.1 | **MyBatis-Plus 3.5.7+** |
+| JWT | jjwt 0.11.5 | **jjwt 0.12.6** |
+
+---
+
+## 二、架构变更
+
+### 2.1 分层架构演进
+
+```
+升级前:
+用户 → Spring Boot → Dify API (应用层+路由层一体) → DeepSeek
+                        ↑
+                Dify 同时承担应用逻辑和模型路由
+
+升级后:
+用户 → Spring Boot → AxonHub (模型路由/降级/计费) → DeepSeek/Qwen/...
+           ↑
+    Spring AI (应用层: 对话/RAG/工具/记忆)
+                      
+    文档预处理 → Python 专有工具 (docling/unstructured)
+```
+
+**三层各司其职:**
+
+| 层 | 技术 | 职责 |
+|---|------|------|
+| AI 应用层 | **Spring AI** | 对话、RAG、Tool Calling、记忆、MCP、多模型编排 |
+| 模型路由层 | **AxonHub** | 模型分发、自动降级/回退、用量计费、密钥管理 |
+| 文档预处理 | **专用工具** | 复杂 PDF/表格/多栏布局解析、文档清洗 |
+
+### 2.2 Dify 全功能可替代性结论
+
+| Dify 功能模块 | 替代方案 | 不可替代? | 本项目需要? | 说明 |
+|--------------|---------|-----------|------------|------|
+| 聊天/对话 | Spring AI `ChatClient` | ✅ 替代 | ✅ | 类型安全,省掉 Dify 网络一跳 |
+| 流式响应 | Spring AI `Flux<String>` | ✅ 替代 | ✅ | Reactor 原生 |
+| 对话记忆 | Spring AI `MemoryAdvisor` | ✅ 替代 | ✅ | 可插拔持久化后端 |
+| RAG 知识库 | Spring AI `VectorStore` + ETL | ✅ 替代 | ✅ | 20+ 向量数据库可选 |
+| Function Calling | Spring AI `@Tool` | ✅ 替代 | ✅ | 直接调 Java 方法 |
+| 可视化工作流 | 代码编排 (Java) | **❌ 不可替代** | ❌ 不需要 | 本项目通过 API 调用,不用画布 |
+| Prompt IDE | YAML/代码管理 | **❌ 不可替代** | ❌ 不需要 | Git 管理更专业 |
+| 50+ 内置工具 | 按需写 `@Tool` | ⚠️ 需封装 | ❌ 不需要 | 只用 Function Calling |
+| 模型路由 | **AxonHub** | ✅ 替代 | ✅ | 专用路由网关,比 Dify 内置更强 |
+| 文档预处理 | **Python 专有工具** | ✅ 替代 | ⚠️ 未来 | docling 等更适合复杂 PDF |
+| 多模态 (STT/TTS/Image) | Spring AI 全覆盖 | ✅ 替代 | ❌ 不需要 | — |
+| LLMOps 仪表盘 | Micrometer + Grafana | ✅ 替代 | ✅ | 标准化 OpenTelemetry |
+| 企业安全 (SSO/RBAC) | Spring Security | ✅ 替代 | ❌ 不需要 | Spring 原生 |
+| MCP 集成 | Spring AI MCP 一级支持 | ✅ 替代 | ⚠️ 未来 | Spring AI 2.0 核心特性 |
+
+### 2.3 多模型编排(工作流各节点用不同模型)
+
+Spring AI 支持在同一工作流的不同步骤调用不同模型:
+
+```java
+// 注入多个模型
+@Resource @Qualifier("openAiChatModel")  private ChatModel deepSeek;   // → AxonHub → DeepSeek
+@Resource @Qualifier("dashScopeChatModel") private ChatModel qwen;     // → AxonHub → Qwen
+@Resource @Qualifier("ollamaChatModel")   private ChatModel local;     // → 本地模型
+
+public Result process(String input) {
+    ChatClient client1 = ChatClient.builder(deepSeek).build();
+    ChatClient client2 = ChatClient.builder(qwen).build();
+    ChatClient client3 = ChatClient.builder(local).build();
+
+    // Step 1: DeepSeek 做意图分类(强推理)
+    String intent = client1.prompt().user("分类: " + input).call().content();
+
+    // Step 2: Qwen 做长文本分析(长上下文)
+    String analysis = client2.prompt().system("分析专家").user(intent).call().content();
+
+    // Step 3: 本地模型做格式化(零成本)
+    String output = client3.prompt().user("格式化: " + analysis).call().content();
+}
+```
+
+对比 Dify:Dify 工作流也支持节点级模型切换,但 Spring AI **更灵活** — 不受 50 节点上限限制,无画布约束,可在任意代码分支换模型。配合 AxonHub,所有模型调用走统一网关。
+
+---
+
+## 三、总体路线
+
+```
+5 阶段 / 20 人天 / 可回退 / 每阶段独立验证
+
+Phase 0 ─── Phase 1 ─── Phase 2 ─── Phase 3 ─── Phase 4
+前置准备    javax→      行为适配     Spring AI    回归验证
+(3天)       jakarta      +            +            +
+            迁移         Nashorn       Dify         上线
+            (5天)        替换         替换          (3天)
+                        (4天)        (5天)
+```
+
+**回退机制**:每阶段结束时有一个 `git tag`,任一阶段失败可退回到上个稳定点。
+
+---
+
+## 三、各阶段详细任务
+
+### Phase 0 — 前置准备(3 天)
+
+**目标**:环境就绪,依赖对齐,识别所有需要改动的文件。
+
+| 任务 | 产出 | 验证 |
+|------|------|------|
+| 0.1 JDK 21 安装 + Maven 3.9+ 升级 | `java -version` → 21 | 开发/CI 环境确认 |
+| 0.2 pom.xml: parent → spring-boot 3.4.x | BOM 对齐 | `mvn dependency:tree` 无冲突 |
+| 0.3 依赖版本批量更新 | 见下方依赖表 | 编译无报错 |
+| 0.4 maven-compiler-plugin source/target → 21 | `java.version=21` | 编译通过 |
+| 0.5 `mvn compile` 采集完整 javax 报错清单 | javax 影响范围清单 | 确认 482 个文件全部覆盖 |
+| 0.6 Nashorn 调用栈录制 | WisdomMathService 表达式求值场景 | 确认替换范围 |
+| 0.7 创建 git tag: `v2.7.18-baseline` | 可回退点 | `git log` 确认 |
+
+**依赖版本变更表:**
+
+```xml
+<!-- pom.xml 关键变更 -->
+<parent>
+    <groupId>org.springframework.boot</groupId>
+    <artifactId>spring-boot-starter-parent</artifactId>
+    <version>3.4.x</version>           <!-- 2.7.18 → 3.4.x -->
+</parent>
+<properties>
+    <java.version>21</java.version>    <!-- 1.8 → 21 -->
+    <mybatis-plus.version>3.5.9</mybatis-plus.version>  <!-- 3.5.3.1 → 3.5.9 -->
+</properties>
+
+<!-- 替换或新增 -->
+<dependency>
+    <!-- springdoc v1 → v2 (包名变更) -->
+    <groupId>org.springdoc</groupId>
+    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
+    <version>2.7.0</version>
+</dependency>
+
+<dependency>
+    <!-- jjwt 0.11.5 → 0.12.6 -->
+    <groupId>io.jsonwebtoken</groupId>
+    <artifactId>jjwt-api</artifactId>
+    <version>0.12.6</version>
+</dependency>
+
+<!-- 移除:nashorn-core (如果显式引入) -->
+<!-- 新增:表达式求值替代库 -->
+<dependency>
+    <groupId>net.objecthunter</groupId>
+    <artifactId>exp4j</artifactId>
+    <version>0.4.8</version>
+</dependency>
+```
+
+---
+
+### Phase 1 — javax → jakarta 迁移(5 天)
+
+**目标**:482 个文件的 `javax.*` 引用全部替换为 `jakarta.*`,编译通过。
+
+**替换规则:**
+
+| 原包名 | 目标包名 | 影响模块 |
+|--------|---------|---------|
+| `javax.persistence.*` | `jakarta.persistence.*` | 所有 @Entity, @Table, @Column |
+| `javax.annotation.*` | `jakarta.annotation.*` | @Resource, @PostConstruct, @PreDestroy |
+| `javax.validation.*` | `jakarta.validation.*` | @Valid, @NotBlank, @Size, DTO |
+| `javax.servlet.*` | `jakarta.servlet.*` | HttpServletRequest/Response, Filter |
+| `javax.sql.*` | `jakarta.sql.*` | DataSource (如果直接引用) |
+
+**操作方式:**
+
+```
+方式 A:IDE 全局替换(推荐)
+  IntelliJ: 重构 → 迁移包 → javax→jakarta
+  或 sed 逐目录确认
+
+方式 B:分批替换
+  day1: entity 包(~80 个文件)—— 最规则,可批量
+  day2: dto 包(所有 @NotBlank/@Valid)—— 替换量大但简单
+  day3: config + task + filter 包 —— @Resource/@PostConstruct
+  day4: service + controller 包 —— 逐一检查
+  day5: 全量编译 + 修复残留问题
+```
+
+**风险提示**:
+- `javax.annotation.Resource` 和 `javax.annotation.PostConstruct` 在 Spring Boot 3 中内置了 jakarta 适配,但显式引用仍需改包名
+- Spring 自己的 `@Autowired` 不受影响
+- MyBatis-Plus 的注解 (`@TableName` 等) 不受影响(不走 javax)
+
+**产出**:`mvn clean compile` 零错误。
+
+---
+
+### Phase 2 — 行为适配 + Nashorn 替换(4 天)
+
+**目标**:Spring Boot 3 行为变更对齐,废弃 API 替换,全量编译通过。
+
+#### 2.1 Spring Boot 3 行为变更
+
+| 变更项 | 影响 | 处理方式 |
+|--------|------|---------|
+| PathPatternParser (默认) | 某些 Ant 风格路径可能不匹配 | 配置 `spring.mvc.pathmatch.matching-strategy=ant-path-matcher` 可回退 |
+| `spring.config.location` 弃用 | 如果使用了 | 改为 `spring.config.import` |
+| Spring Security 默认变更 | 如果有 Security 配置 | 检查 CSRF/鉴权默认值 |
+| `WebMvcConfigurerAdapter` 已删除 | 如果继承了 | 改为实现 `WebMvcConfigurer` |
+| Health indicator 变更 | actuator 配置 | 确认无需更改 |
+
+#### 2.2 Nashorn → exp4j 替换
+
+当前代码(WisdomMathService):
+```java
+ScriptEngineManager mgr = new ScriptEngineManager();
+ScriptEngine engine = mgr.getEngineByName("nashorn");
+engine.eval("12+38*2");  // → 88
+```
+
+替换方式:
+```java
+// exp4j (轻量,仅数学表达式,推荐)
+import net.objecthunter.exp4j.ExpressionBuilder;
+
+Expression exp = new ExpressionBuilder(expression).build();
+double result = exp.evaluate();
+```
+
+**验证**:
+- 提取 WisdomMathService 的所有表达式测试用例
+- 确保 exp4j 覆盖全部运算符(+ - * / ^ % 括号 函数)
+- 表达式求值精度一致
+
+#### 2.3 springdoc-openapi v1 → v2
+
+| v1 (当前) | v2 (目标) |
+|-----------|----------|
+| `springdoc-openapi-ui` | `springdoc-openapi-starter-webmvc-ui` |
+| 包名: `org.springdoc` | 包名: `org.springdoc` (同但内部结构变) |
+| 配置路径: `springdoc.swagger-ui.path` | 同(但需确认) |
+
+改动:pom.xml artifactId 变更,配置确认。
+
+#### 2.4 jjwt 0.11 → 0.12
+
+主要 API 变化:
+- `Jwts.builder()` → 签名方法参数类型变化
+- `Keys.secretKeyFor()` → 返回类型变更为 `SecretKey`
+- `Jwts.parserBuilder()` → `Jwts.parser()` (不再需要 .builder())
+
+需检查 `JwtUtil.java` / 任何使用 jjwt 的地方,逐一适配。
+
+#### 2.5 编译验证
+
+`mvn clean compile` 零错误。
+
+---
+
+### Phase 3 — Spring AI 集成 + Dify 替换(5 天)
+
+**目标**:用 Spring AI 替换 Dify 的全部调用,本地知识库就位。
+
+#### 3.1 Spring AI 依赖
+
+```xml
+<!-- Spring AI - BOM -->
+<dependencyManagement>
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.ai</groupId>
+            <artifactId>spring-ai-bom</artifactId>
+            <version>1.x.x</version>
+            <type>pom</type>
+            <scope>import</scope>
+        </dependency>
+    </dependencies>
+</dependencyManagement>
+
+<dependencies>
+    <!-- OpenAI 兼容协议 (覆盖 DeepSeek, Ollama) -->
+    <dependency>
+        <groupId>org.springframework.ai</groupId>
+        <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
+    </dependency>
+    <!-- 如果用 Qwen, 加 dashscope starter -->
+</dependencies>
+```
+
+#### 3.2 AxonHub 模型路由配置
+
+所有模型调用经 AxonHub 统一路由(模型分发、自动降级、用量计费):
+
+```yaml
+# application.yml — AxonHub 作为统一模型网关
+spring:
+  ai:
+    openai:
+      base-url: https://axonhub.iwintrue.com/v1
+      api-key: ${AXONHUB_API_KEY}       # AxonHub 统一 API Key
+      chat:
+        options:
+          model: deepseek-chat           # 由 AxonHub 路由到对应模型
+
+# 多模型配置(如需在同一个流程中使用不同模型)
+---
+spring:
+  ai:
+    dashscope:                           # Qwen 通过 DashScope 协议接入
+      api-key: ${AXONHUB_API_KEY}
+      chat:
+        options:
+          model: qwen-max
+  config:
+    activate:
+      on-profile: qwen-enabled
+```
+
+AxonHub 接管了原 Dify 的模型路由能力,Spring AI 只需指定 model 参数:
+
+```java
+// 所有模型通过 AxonHub 统一出口
+ChatModel model = openAiChatModel;
+String result = ChatClient.builder(model).build()
+    .prompt().user("hello").call().content();
+// AxonHub 根据 model=deepseek-chat 自动路由到 DeepSeek
+// 故障时自动降级到备用模型
+```
+
+#### 3.3 ChatClient 封装(替代 AIService)
+
+```java
+// 核心抽象 — 替代原来的 AIService + OkHttp 直调
+@Service
+public class LLMService {
+
+    @Resource
+    private ChatClient.Builder chatClientBuilder;
+
+    @Resource
+    @Qualifier("openAiChatModel")
+    private ChatModel deepSeekModel;         // → AxonHub → DeepSeek
+
+    @Resource
+    @Qualifier("dashScopeChatModel")
+    private ChatModel qwenModel;             // → AxonHub → Qwen
+
+    // 默认聊天(使用配置的默认模型)
+    public String chat(String query, String systemPrompt, String conversationId) {
+        return chatClientBuilder.build()
+            .prompt()
+            .system(systemPrompt)
+            .user(query)
+            .advisors(new MessageChatMemoryAdvisor(chatMemory, conversationId, 10))
+            .call()
+            .content();
+    }
+
+    // 指定模型的聊天(工作流各节点可用不同模型)
+    public String chatWithModel(String query, String systemPrompt,
+                                 String conversationId, ChatModel model) {
+        return ChatClient.builder(model).build()
+            .prompt()
+            .system(systemPrompt)
+            .user(query)
+            .advisors(new MessageChatMemoryAdvisor(chatMemory, conversationId, 10))
+            .call()
+            .content();
+    }
+
+    // 流式
+    public Flux<String> stream(String query, String systemPrompt, String conversationId) {
+        return chatClientBuilder.build()
+            .prompt()
+            .system(systemPrompt)
+            .user(query)
+            .advisors(new MessageChatMemoryAdvisor(chatMemory, conversationId, 10))
+            .stream()
+            .content();
+    }
+
+    // 多模型工作流示例:不同步骤使用不同模型
+    public WorkflowResult executeMultiModelWorkflow(String input) {
+        // Step 1: DeepSeek 做意图分类(强推理模型)
+        String intent = ChatClient.builder(deepSeekModel).build()
+            .prompt().user("分类用户意图: " + input).call().content();
+
+        // Step 2: Qwen 做长文本分析(长上下文优势)
+        String analysis = ChatClient.builder(qwenModel).build()
+            .prompt().system("分析专家").user(intent).call().content();
+
+        return new WorkflowResult(intent, analysis);
+    }
+}
+```
+
+#### 3.3 8 个 Service 逐一替换
+
+参考 `docs/architecture/Dify替代迁移可行性分析.md` 第 4.2 节的 `LLMService` 接口映射:
+
+| Service | Dify API | Spring AI 替代 | 工作量 |
+|---------|----------|---------------|--------|
+| AIService | chat-messages POST | ChatClient | ⭐ 小 |
+| DifySyncService | datasets | VectorStore + 本地管理 | ⭐⭐⭐ 中 |
+| AiConversationSummaryService | conversations | MemoryAdvisor | ⭐ 小 |
+| AiUserFactService | /workflows | ChatClient + @Tool | ⭐⭐ 中 |
+| ChatMirrorService | 日志镜像 | 转正为缓存层 | ⭐ 小 |
+| 其余 3 个 Service | 各异 | 具体分析 | ⭐⭐ 中 |
+
+#### 3.5 多模型编排能力
+
+Spring AI 支持工作流各节点使用不同模型,不受 Dify 50 节点上限限制:
+
+```java
+@Resource
+private LLMService llmService;
+
+@PostMapping("/api/ai/workflow/process")
+public Result process(@RequestBody WorkflowRequest req) {
+    // 不同节点注入不同模型
+    // 所有模型调用统一走 AxonHub 网关
+    return Result.success(llmService.executeMultiModelWorkflow(req.getInput()));
+}
+```
+
+配合 AxonHub,可在不修改代码的前提下切换任何 AxonHub 支持的模型。
+
+#### 3.6 知识库本地化
+
+Dify Datasets 替代方案:
+
+```yaml
+# 方案投票:pgvector vs sqlite-vec
+# PostgreSQL 扩展 — 如果主库是 MySQL 则需要额外 PG 实例,引入成本高
+# sqlite-vec — 嵌入式,零运维,适合知识库 < 10万条
+
+# 选型:sqlite-vec(嵌入式,匹配项目规模)
+spring:
+  ai:
+    vectorstore:
+      sqlite:
+        path: ${data.dir}/knowledge.db
+```
+
+#### 3.5 System Prompt 外部化
+
+```
+不再依赖 Dify 的 Prompt 编辑器 →
+改为 YAML 文件或数据库表管理:
+
+resources/prompts/
+├── family-advisor.yaml
+├── growth-plan.yaml
+├── diet-recommend.yaml
+└── math-tutor.yaml
+```
+
+#### 3.6 Dify 数据归档
+
+Dify 替代表(原 6 张表):
+- `dify_*` 表 → 上游 Dify 不再写入后,保留 30 天观察期 → 归档/删除
+
+#### 3.7 验证标准
+
+- 原 Dify API 调用的 8 个 Service 全部改用 Spring AI
+- 流式聊天正常
+- 知识库检索准确率 >= Dify 时期
+- 对话记忆正常
+
+---
+
+### Phase 4 — 回归验证 + 上线(3 天)
+
+**目标**:全量回归通过,灰度上线。
+
+#### 4.1 回归范围
+
+| 测试范围 | 方法 | 负责人 |
+|---------|------|--------|
+| 所有 48 个 Controller API | 手动 + Postman 用例集 | 后端 |
+| 小程序端主流程 | 真机/开发者工具 | 前端联调 |
+| Web 管理端主流程 | 浏览器 | 前端联调 |
+| AI 聊天流程 | 对比测试(原 Dify vs Spring AI) | 后端 |
+| 数学表达式求值 | 用例集 + 精度对比 | 后端 |
+| 文件上传/PDF 解析 | 功能测试 | 后端 |
+
+#### 4.2 性能对比
+
+| 指标 | Dify 链路 | Spring AI 链路 | 目标 |
+|------|-----------|---------------|------|
+| 非流式首 Token 延迟 | ~2-3s | ~1-2s | 不劣化 |
+| 流式响应 TTFB | ~1.5s | ~0.8s | -30% |
+| 并发 50 用户 CPU | 待测 | 待测 | 不劣化 |
+| 内存使用 | 待测 | 待测 | 不劣化 |
+
+#### 4.3 上线方案
+
+```
+Step 1: 灰度 10% 流量(1 天) → 监控错误率
+Step 2: 灰度 50% 流量(1 天) → 监控延迟/错误率
+Step 3: 全量上线           → 观察 48h
+Step 4: Dify 实例下线       → 保留数据 30 天后清理
+```
+
+#### 4.4 回滚条件
+
+任一触发立即回滚:
+- API 错误率 > 1%
+- AI 响应可用率 < 99%
+- 核心接口延迟翻倍
+
+---
+
+## 四、风险评估
+
+| 风险 | 概率 | 影响 | 缓解措施 |
+|------|------|------|---------|
+| 三方库不兼容 Spring Boot 3 | 低 | 高 | Phase 0 先行验证,确认全部依赖兼容 |
+| javax→jakarta 遗漏某些深层引用 | 中 | 中 | Phase 1 分步替换,逐包编译验证 |
+| Spring AI 对 DeepSeek 的支持缺陷 | 低 | 中 | 降级方案:直调 OKHttp(方案 D) |
+| 知识库检索质量下降 | 中 | 中 | Phase 3 做对比测试,保留 Dify 数据 30 天 |
+| Nashorn 替换导致数学结果偏差 | 低 | 高 | Phase 2 提取全部测试用例,逐条验证 |
+
+---
+
+## 五、回退点
+
+| 阶段 | 回退点 | 回退方式 |
+|------|--------|---------|
+| Phase 0 完成 | `git tag v2.7.18-baseline` | `git checkout` |
+| Phase 1 完成 | `git tag v3.0-javax-migrated` | `git reset --hard` |
+| Phase 2 完成 | `git tag v3.0-build-ready` | `git reset --hard` |
+| Phase 3 完成 | `git tag v3.0-spring-ai` | `git reset --hard` + 切回 Dify 数据源 |
+
+每阶段可独立回退,不会影响其他阶段的工作。
+
+---
+
+## 六、资源估算
+
+```
+Phase 0  前置准备     3 天 × 1 人
+Phase 1  javax→jkt    5 天 × 1 人
+Phase 2  行为适配     4 天 × 1 人
+Phase 3  Spring AI    5 天 × 1 人
+Phase 4  回归+上线    3 天 × 1 人
+──────────────────────────────
+总计                   20 人天
+
+建议 2 人并行(后端 1 + 后端 2):
+Phase 0-2 可 2 人并行(一人改包名,一人适配行为)
+Phase 3    集中 1 人(Spring AI 集成需连续上下文)
+Phase 4    2 人并行回归
+→ 日历时间约 2 周
+```

+ 1293 - 0
docs/superpowers/plans/2026-07-20-langgraph-phase1.md

@@ -0,0 +1,1293 @@
+# LangGraph Sidecar — Phase 1 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 在 `cfc-langgraph/` 建立 Python LangGraph sidecar 服务,上线 RecommendAgent,Java 端支持灰度切换(5% 流量走 Python,异常自动 fallback)。
+
+**Architecture:** FastAPI 服务 (port 9000) 承载 LangChain Agent,通过 HTTP 与 Java (port 9082) 通信。数据存储用 ChromaDB 文件模式 + Java 侧 MySQL。Agent 调 LLM API(DeepSeek-V3)进行推荐+解释。
+
+**Tech Stack:** Python 3.11, FastAPI, LangChain, LangGraph, ChromaDB, Docker, Java 8 (不变)
+
+## Global Constraints
+
+- Python 3.11+ (不要求最新 3.12/3.13)
+- ChromaDB 文件模式(不引入额外容器或中间件)
+- Java 端 JDK 8 / Spring Boot 2.7.18 保持不变
+- 所有 Tool 调用通过 HTTP 访问 Java 接口,而非直连 MySQL
+- Dify 作为 Fallback:Python 超时或异常时 Java 自动回退 Dify
+- 所有项目文件路径相对于 `D:\workspace\cfc\`
+
+---
+
+### Task 1: Python 项目脚手架
+
+**Files:**
+- Create: `cfc-langgraph/pyproject.toml`
+- Create: `cfc-langgraph/.env.example`
+- Create: `cfc-langgraph/.gitignore`
+- Create: `cfc-langgraph/Dockerfile`
+- Create: `cfc-langgraph/app/__init__.py`
+- Create: `cfc-langgraph/app/main.py`
+
+**Interfaces:**
+- Produces: FastAPI app 入口 `app.main:app`,`/health` 返回 200
+
+- [ ] **Step 1: 创建 `pyproject.toml`**
+
+```toml
+[project]
+name = "cfc-langgraph"
+version = "0.1.0"
+description = "cfc AI sidecar service with LangGraph"
+requires-python = ">=3.11"
+dependencies = [
+    "fastapi>=0.115",
+    "uvicorn[standard]>=0.34",
+    "pydantic>=2.10",
+    "pydantic-settings>=2.7",
+    "httpx>=0.28",
+    "langchain>=0.3",
+    "langchain-community>=0.3",
+    "langchain-openai>=0.3",
+    "langgraph>=0.3",
+    "chromadb>=0.6",
+    "langchain-chroma>=0.2",
+    "python-multipart>=0.0.20",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=8",
+    "pytest-asyncio>=0.25",
+    "ruff>=0.8",
+    "httpx",
+]
+
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+select = ["E", "F", "I", "N", "W"]
+ignore = ["E501"]
+```
+
+- [ ] **Step 2: 创建 `.env.example`**
+
+```bash
+# LLM
+LLM_API_KEY=sk-your-key
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_MODEL=deepseek-chat
+
+# Embedding
+EMBEDDING_API_KEY=${LLM_API_KEY}
+EMBEDDING_BASE_URL=https://api.deepseek.com/v1
+EMBEDDING_MODEL=text-embedding-v3
+
+# Java Backend
+JAVA_BASE_URL=http://localhost:9082
+JAVA_CONTEXT_URL=${JAVA_BASE_URL}/api/ai/context
+
+# LangSmith (optional)
+LANGCHAIN_TRACING_V2=false
+LANGCHAIN_API_KEY=
+LANGCHAIN_PROJECT=cfc-langgraph
+
+# Service
+SERVICE_HOST=0.0.0.0
+SERVICE_PORT=9000
+LOG_LEVEL=info
+
+# Chroma
+CHROMA_DB_PATH=./data/chroma_db
+```
+
+- [ ] **Step 3: 创建 `.gitignore`**
+
+```
+__pycache__/
+*.py[cod]
+.env
+data/
+*.egg-info/
+dist/
+.ruff_cache/
+.pytest_cache/
+```
+
+- [ ] **Step 4: 创建 `Dockerfile`**
+
+```dockerfile
+FROM python:3.11-slim
+
+WORKDIR /app
+
+COPY pyproject.toml .
+RUN pip install --no-cache-dir -e .
+
+COPY app/ app/
+COPY data/ data/ 2>/dev/null || true
+
+EXPOSE 9000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9000"]
+```
+
+- [ ] **Step 5: 创建 `app/__init__.py`**(空文件)
+
+- [ ] **Step 6: 创建 `app/main.py`**
+
+```python
+from fastapi import FastAPI
+from app.api import health
+
+app = FastAPI(title="cfc-langgraph", version="0.1.0")
+
+app.include_router(health.router)
+
+@app.on_event("startup")
+async def startup():
+    pass  # 后续 Phase 在此初始化 RAG / Agent
+
+@app.on_event("shutdown")
+async def shutdown():
+    pass  # 后续 Phase 在此清理资源
+```
+
+- [ ] **Step 7: 验证服务可启动**
+
+```bash
+cd cfc-langgraph
+pip install -e .
+uvicorn app.main:app --port 9000 &
+curl http://localhost:9000/health
+# 预期: {"status":"ok"}
+kill %1
+```
+
+- [ ] **Step 8: 创建 `app/api/__init__.py`**(空文件)
+
+- [ ] **Step 9: 创建 `app/api/health.py`**
+
+```python
+from fastapi import APIRouter
+
+router = APIRouter(tags=["health"])
+
+@router.get("/health")
+async def health():
+    return {"status": "ok"}
+```
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add cfc-langgraph/
+git commit -m "feat(langgraph): scaffold Python sidecar project"
+```
+
+---
+
+### Task 2: 配置管理
+
+**Files:**
+- Create: `cfc-langgraph/app/config.py`
+
+**Interfaces:**
+- Produces: `app.config.settings` — Pydantic Settings 单例,所有模块通过 `from app.config import settings` 读取
+
+- [ ] **Step 1: 创建 `app/config.py`**
+
+```python
+from pydantic_settings import BaseSettings
+from typing import Optional
+
+class Settings(BaseSettings):
+    # LLM
+    llm_api_key: str
+    llm_base_url: str = "https://api.deepseek.com/v1"
+    llm_model: str = "deepseek-chat"
+
+    # Embedding
+    embedding_api_key: Optional[str] = None
+    embedding_base_url: Optional[str] = None
+    embedding_model: str = "text-embedding-v3"
+
+    # Java Backend
+    java_base_url: str = "http://localhost:9082"
+    java_context_url: Optional[str] = None
+
+    # LangSmith
+    langchain_tracing_v2: bool = False
+    langchain_api_key: Optional[str] = None
+    langchain_project: str = "cfc-langgraph"
+
+    # Service
+    service_host: str = "0.0.0.0"
+    service_port: int = 9000
+    log_level: str = "info"
+
+    # Chroma
+    chroma_db_path: str = "./data/chroma_db"
+
+    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
+
+    @property
+    def effective_embedding_api_key(self) -> str:
+        return self.embedding_api_key or self.llm_api_key
+
+    @property
+    def effective_embedding_base_url(self) -> str:
+        return self.embedding_base_url or self.llm_base_url
+
+    @property
+    def effective_java_context_url(self) -> str:
+        return self.java_context_url or f"{self.java_base_url}/api/ai/context"
+
+settings = Settings()
+```
+
+- [ ] **Step 2: 创建 `.env` 文件(从 `.env.example` 复制,填入真实 Key)**
+
+```bash
+cp cfc-langgraph/.env.example cfc-langgraph/.env
+# 手动编辑填入 LLM_API_KEY
+```
+
+- [ ] **Step 3: 验证配置可加载**
+
+```bash
+cd cfc-langgraph
+python -c "from app.config import settings; print(settings.llm_model)"
+# 预期输出: deepseek-chat
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-langgraph/
+git commit -m "feat(langgraph): config management with pydantic-settings"
+```
+
+---
+
+### Task 3: Pydantic 数据模型
+
+**Files:**
+- Create: `cfc-langgraph/app/models/__init__.py`
+- Create: `cfc-langgraph/app/models/common.py`
+- Create: `cfc-langgraph/app/models/chat.py`
+- Create: `cfc-langgraph/app/models/recommend.py`
+
+**Interfaces:**
+- Produces: Phase 1 所需的请求/响应模型
+
+- [ ] **Step 1: 创建 `app/models/__init__.py`**
+
+```python
+from .common import UserContext, SourceInfo
+from .chat import ChatRequest, ChatResponse
+from .recommend import RecommendRequest, RecommendResponse, RecommendItem
+```
+
+- [ ] **Step 2: 创建 `app/models/common.py`**
+
+```python
+from pydantic import BaseModel
+from typing import Optional
+
+class UserContext(BaseModel):
+    """Java 侧传来的业务上下文"""
+    child_id: Optional[int] = None
+    report_id: Optional[int] = None
+    survey_id: Optional[int] = None
+    family_id: Optional[int] = None
+    mascot_code: Optional[str] = None
+
+class SourceInfo(BaseModel):
+    """回答引用来源"""
+    type: str  # knowledge / tool
+    title: str
+    score: Optional[float] = None
+```
+
+- [ ] **Step 3: 创建 `app/models/chat.py`**
+
+```python
+from pydantic import BaseModel
+from typing import Optional
+from .common import UserContext, SourceInfo
+
+class ChatRequest(BaseModel):
+    query: str
+    user_id: int
+    conversation_id: str = ""
+    context: Optional[UserContext] = None
+
+class ChatResponse(BaseModel):
+    answer: str
+    conversation_id: str
+    sources: list[SourceInfo] = []
+    tasks: list[dict] = []
+    trace_id: str = ""
+```
+
+- [ ] **Step 4: 创建 `app/models/recommend.py`**
+
+```python
+from pydantic import BaseModel
+from typing import Optional
+
+class RecommendItem(BaseModel):
+    type: str  # product / activity / article
+    id: int
+    name: str
+    description: str = ""
+    cover_image: str = ""
+    price: Optional[float] = None
+    url: str = ""
+    score: float = 0.0
+    reason: str = ""
+
+class RecommendRequest(BaseModel):
+    user_id: int
+    query: str = ""
+    tags: list[str] = []
+    types: Optional[list[str]] = None
+    limit: int = 5
+    context: Optional[dict] = None
+
+class RecommendResponse(BaseModel):
+    items: list[RecommendItem]
+    source: str = ""  # sql / vector / hybrid
+    trace_id: str = ""
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-langgraph/
+git commit -m "feat(langgraph): pydantic models for API"
+```
+
+---
+
+### Task 4: ChromaDB + Embedding 基础设施
+
+**Files:**
+- Create: `cfc-langgraph/app/rag/__init__.py`
+- Create: `cfc-langgraph/app/rag/embeddings.py`
+- Create: `cfc-langgraph/app/rag/retriever.py`
+
+**Interfaces:**
+- Produces: `embeddings.get_embeddings()` → Embeddings 实例
+- Produces: `retriever.RagRetriever` 类,提供 `retrieve(query, filters, k)` 方法
+
+- [ ] **Step 1: 创建 `app/rag/__init__.py`**
+
+```python
+from .embeddings import get_embeddings
+from .retriever import RagRetriever
+```
+
+- [ ] **Step 2: 创建 `app/rag/embeddings.py`**
+
+```python
+from langchain_openai import OpenAIEmbeddings
+from app.config import settings
+
+_embeddings = None
+
+def get_embeddings():
+    global _embeddings
+    if _embeddings is None:
+        _embeddings = OpenAIEmbeddings(
+            model=settings.embedding_model,
+            api_key=settings.effective_embedding_api_key,
+            base_url=settings.effective_embedding_base_url,
+        )
+    return _embeddings
+```
+
+- [ ] **Step 3: 创建 `app/rag/retriever.py`**
+
+```python
+from langchain_chroma import Chroma
+from langchain.retrievers import EnsembleRetriever
+from langchain_community.retrievers import BM25Retriever
+from langchain.retrievers.document_compressors import LLMChainExtractor
+from langchain.retrievers import ContextualCompressionRetriever
+from .embeddings import get_embeddings
+from app.config import settings
+from app.tools.java_client import JavaClient
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+class RagRetriever:
+    """混合检索器: ChromaDB 向量 + BM25 关键词 + 可选 LLM 压缩"""
+
+    def __init__(self, collection_name: str = "cfc_knowledge"):
+        embeddings = get_embeddings()
+        self.vectorstore = Chroma(
+            collection_name=collection_name,
+            embedding_function=embeddings,
+            persist_directory=settings.chroma_db_path,
+        )
+        self.java_client = JavaClient()
+        self._bm25_retriever = None
+        self._bm25_texts = []
+
+    async def initialize(self):
+        """从 Java 侧拉取知识库数据, 构建 BM25 索引"""
+        try:
+            articles = await self.java_client.get_published_articles()
+            self._bm25_texts = [
+                f"{a['title']} {a['summary']} {a.get('tags', '')}"
+                for a in articles
+            ]
+            if self._bm25_texts:
+                self._bm25_retriever = BM25Retriever.from_texts(
+                    self._bm25_texts, metadatas=articles
+                )
+                logger.info("BM25 索引就绪: %d 条", len(self._bm25_texts))
+        except Exception as e:
+            logger.warning("BM25 初始化失败(不影响向量检索): %s", e)
+
+    async def retrieve(
+        self,
+        query: str,
+        filters: Optional[dict] = None,
+        k: int = 5,
+        use_compression: bool = False,
+    ) -> list[dict]:
+        """混合检索, 返回 [{content, metadata, score}]"""
+        retrievers = []
+
+        # 向量检索
+        vector_retriever = self.vectorstore.as_retriever(
+            search_kwargs={"k": k, "filter": filters}
+        )
+        retrievers.append(vector_retriever)
+
+        # BM25 检索
+        if self._bm25_retriever:
+            retrievers.append(self._bm25_retriever)
+
+        if len(retrievers) == 1:
+            docs = await retrievers[0].ainvoke(query)
+        else:
+            ensemble = EnsembleRetriever(
+                retrievers=retrievers, weights=[0.6, 0.4]
+            )
+            docs = await ensemble.ainvoke(query)
+
+        # 可选: LLM 压缩去噪
+        if use_compression and docs:
+            from langchain_openai import ChatOpenAI
+            llm = ChatOpenAI(
+                model=settings.llm_model,
+                api_key=settings.llm_api_key,
+                base_url=settings.llm_base_url,
+            )
+            compressor = LLMChainExtractor.from_llm(llm)
+            compression_retriever = ContextualCompressionRetriever(
+                base_compressor=compressor,
+                base_retriever=self.vectorstore.as_retriever(),
+            )
+            docs = await compression_retriever.ainvoke(query)
+
+        results = []
+        for doc in docs:
+            results.append({
+                "content": doc.page_content,
+                "metadata": doc.metadata,
+                "score": getattr(doc, "metadata", {}).get("score", 0),
+            })
+        return results[:k]
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-langgraph/
+git commit -m "feat(langgraph): ChromaDB + RAG retriever infrastructure"
+```
+
+---
+
+### Task 5: Java HTTP 客户端(Python 侧 Tool 用)
+
+**Files:**
+- Create: `cfc-langgraph/app/tools/__init__.py`
+- Create: `cfc-langgraph/app/tools/java_client.py`
+
+**Interfaces:**
+- Produces: `JavaClient` 类, 封装对所有 Java 接口的 HTTP 调用
+- Produces: `get_published_articles()`, `search_products()`, `search_activities()`, `search_articles()`
+
+- [ ] **Step 1: 创建 `app/tools/__init__.py`**
+
+```python
+from .java_client import JavaClient
+```
+
+- [ ] **Step 2: 创建 `app/tools/java_client.py`**
+
+```python
+import httpx
+from typing import Optional
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+class JavaClient:
+    """Java 后端 HTTP 客户端 (所有 Python→Java 通信的单一入口)"""
+
+    def __init__(self):
+        self.base_url = settings.java_base_url
+        self._client: Optional[httpx.AsyncClient] = None
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        if self._client is None:
+            self._client = httpx.AsyncClient(
+                base_url=self.base_url,
+                timeout=httpx.Timeout(10.0, connect=3.0),
+            )
+        return self._client
+
+    async def close(self):
+        if self._client:
+            await self._client.aclose()
+            self._client = None
+
+    async def get_published_articles(self) -> list[dict]:
+        """获取已发布的文章列表 (用于构建知识库)"""
+        client = await self._get_client()
+        resp = await client.post("/api/article/list", json={"status": "published", "limit": 1000})
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def search_products(self, keyword: str, limit: int = 5) -> list[dict]:
+        """按关键词搜索上架商品"""
+        client = await self._get_client()
+        resp = await client.post("/api/product/search", json={
+            "keyword": keyword, "status": "上架", "limit": limit
+        })
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def search_activities(self, keyword: str, limit: int = 5) -> list[dict]:
+        """按关键词搜索进行中的活动"""
+        client = await self._get_client()
+        resp = await client.post("/api/activity/search", json={
+            "keyword": keyword, "status": "published", "limit": limit
+        })
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def search_articles(self, keyword: str, limit: int = 5) -> list[dict]:
+        """按关键词搜索已发布文章"""
+        client = await self._get_client()
+        resp = await client.post("/api/article/search", json={
+            "keyword": keyword, "status": "published", "limit": limit
+        })
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def get_user_context(self, user_id: int, params: Optional[dict] = None) -> dict:
+        """获取用户上下文 (对应 Java AiContextService)"""
+        client = await self._get_client()
+        resp = await client.post(settings.effective_java_context_url, json={
+            "user_id": str(user_id),
+            "params": params or {},
+        })
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", {})
+        return {}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/
+git commit -m "feat(langgraph): Java HTTP client for tool calls"
+```
+
+---
+
+### Task 6: RecommendAgent
+
+**Files:**
+- Create: `cfc-langgraph/app/tools/product_tools.py`
+- Create: `cfc-langgraph/app/agents/__init__.py`
+- Create: `cfc-langgraph/app/agents/recommend_agent.py`
+- Create: `cfc-langgraph/app/graphs/__init__.py`
+- Create: `cfc-langgraph/app/graphs/recommend_graph.py`
+- Create: `cfc-langgraph/app/api/recommend.py`
+- Modify: `cfc-langgraph/app/main.py` (注册路由)
+
+**Interfaces:**
+- Consumes: `JavaClient`, `RagRetriever`, `ChatOpenAI`
+- Produces: `POST /api/v1/recommend` 接口
+
+- [ ] **Step 1: 创建 `app/tools/product_tools.py`**
+
+```python
+from langchain_core.tools import tool
+from app.tools.java_client import JavaClient
+import logging
+
+logger = logging.getLogger(__name__)
+_java = JavaClient()
+
+@tool
+async def search_product_by_keyword(keyword: str, limit: int = 5) -> str:
+    """按关键词搜索上架商品, 返回 JSON 商品列表"""
+    try:
+        products = await _java.search_products(keyword, limit)
+        if not products:
+            return "[]"
+        return str([{
+            "id": p["id"],
+            "name": p["name"],
+            "price": p.get("price"),
+            "description": p.get("intro") or p.get("description", ""),
+        } for p in products])
+    except Exception as e:
+        logger.warning("搜索商品失败: %s", e)
+        return "[]"
+
+@tool
+async def search_activity_by_keyword(keyword: str, limit: int = 5) -> str:
+    """按关键词搜索进行中的活动, 返回 JSON 活动列表"""
+    try:
+        activities = await _java.search_activities(keyword, limit)
+        if not activities:
+            return "[]"
+        return str([{
+            "id": a["id"],
+            "name": a["title"],
+            "description": a.get("description", ""),
+        } for a in activities])
+    except Exception as e:
+        logger.warning("搜索活动失败: %s", e)
+        return "[]"
+
+@tool
+async def search_article_by_keyword(keyword: str, limit: int = 5) -> str:
+    """按关键词搜索已发布文章, 返回 JSON 文章列表"""
+    try:
+        articles = await _java.search_articles(keyword, limit)
+        if not articles:
+            return "[]"
+        return str([{
+            "id": a["id"],
+            "name": a["title"],
+            "summary": a.get("summary", ""),
+        } for a in articles])
+    except Exception as e:
+        logger.warning("搜索文章失败: %s", e)
+        return "[]"
+```
+
+- [ ] **Step 2: 创建 `app/agents/__init__.py`**(空)
+
+- [ ] **Step 3: 创建 `app/graphs/__init__.py`**(空)
+
+- [ ] **Step 4: 创建 `app/graphs/recommend_graph.py`**
+
+Phase 1 用**最简单的单步 Agent**(不涉及 StateGraph 复杂特性),后续 Phase 再升级为图编排:
+
+```python
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.tools.product_tools import search_product_by_keyword, search_activity_by_keyword, search_article_by_keyword
+from app.config import settings
+import json
+import logging
+
+logger = logging.getLogger(__name__)
+
+SYSTEM_PROMPT = """你是一个儿童成长营养推荐助手。根据用户的需求和营养标签, 推荐合适的商品、活动或文章。
+
+推荐原则:
+1. 首先尝试使用搜索工具查找匹配的内容
+2. 如果搜索结果为空, 基于你的知识给出建议
+3. 每项推荐必须附带推荐理由
+4. 以 JSON 格式输出推荐结果
+
+输出格式:
+{
+    "items": [
+        {
+            "source": "tool" 或 "knowledge",
+            "type": "product" / "activity" / "article",
+            "id": 数字,
+            "name": "名称",
+            "description": "描述",
+            "reason": "为什么推荐这个"
+        }
+    ]
+}
+"""
+
+class RecommendAgent:
+    def __init__(self):
+        self.llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            temperature=0.3,
+        )
+        self.tools = [
+            search_product_by_keyword,
+            search_activity_by_keyword,
+            search_article_by_keyword,
+        ]
+        self.llm_with_tools = self.llm.bind_tools(self.tools)
+
+    async def run(self, query: str, tags: list[str], limit: int = 5) -> dict:
+        """执行推荐 Agent, 返回推荐结果"""
+        # 如果传入了 tags, 构造搜索关键词
+        search_query = query or " ".join(tags)
+
+        messages = [
+            SystemMessage(content=SYSTEM_PROMPT),
+            HumanMessage(content=f"用户需求: {search_query}\n最大返回数量: {limit}\n请搜索并推荐合适的内容。"),
+        ]
+
+        # LangChain Tool calling 自动完成: LLM 决定调哪个 Tool → 工具返回结果 → LLM 组织回答
+        response = await self.llm_with_tools.ainvoke(messages)
+
+        # 尝试解析 JSON 输出
+        content = response.content
+        try:
+            # 提取 JSON 块
+            if "```json" in content:
+                json_str = content.split("```json")[1].split("```")[0].strip()
+            elif "```" in content:
+                json_str = content.split("```")[1].split("```")[0].strip()
+            else:
+                json_str = content.strip()
+            result = json.loads(json_str)
+            return result
+        except (json.JSONDecodeError, IndexError):
+            # 非 JSON 输出, 包装为文本回答
+            logger.warning("Agent 输出非 JSON, raw: %s", content[:200])
+            return {"items": [], "text": content}
+```
+
+- [ ] **Step 5: 创建 `app/api/recommend.py`**
+
+```python
+from fastapi import APIRouter
+from app.models.recommend import RecommendRequest, RecommendResponse, RecommendItem
+from app.graphs.recommend_graph import RecommendAgent
+from app.rag.retriever import RagRetriever
+import logging
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["recommend"])
+
+_agent: RecommendAgent = None
+_retriever: RagRetriever = None
+
+def get_agent() -> RecommendAgent:
+    global _agent
+    if _agent is None:
+        _agent = RecommendAgent()
+    return _agent
+
+def get_retriever() -> RagRetriever:
+    global _retriever
+    if _retriever is None:
+        _retriever = RagRetriever()
+    return _retriever
+
+@router.post("/recommend", response_model=RecommendResponse)
+async def recommend(req: RecommendRequest):
+    """营养推荐: Agent 搜索+LLM 解释"""
+    try:
+        agent = get_agent()
+        result = await agent.run(query=req.query, tags=req.tags, limit=req.limit)
+
+        items_data = result.get("items", [])
+        items = []
+        for item in items_data:
+            items.append(RecommendItem(
+                type=item.get("type", "product"),
+                id=item.get("id", 0),
+                name=item.get("name", ""),
+                description=item.get("description", ""),
+                reason=item.get("reason", ""),
+            ))
+
+        return RecommendResponse(items=items, source="agent")
+    except Exception as e:
+        logger.error("RecommendAgent 调用失败: %s", e, exc_info=True)
+        return RecommendResponse(items=[], source="error")
+```
+
+- [ ] **Step 6: 修改 `app/main.py` 注册路由**
+
+```python
+from fastapi import FastAPI
+from app.api import health, recommend
+
+app = FastAPI(title="cfc-langgraph", version="0.1.0")
+
+app.include_router(health.router)
+app.include_router(recommend.router)
+
+@app.on_event("startup")
+async def startup():
+    from app.rag.retriever import RagRetriever
+    retriever = RagRetriever()
+    await retriever.initialize()
+
+@app.on_event("shutdown")
+async def shutdown():
+    from app.tools.java_client import JavaClient
+    client = JavaClient()
+    await client.close()
+```
+
+- [ ] **Step 7: 验证 Recommend API**
+
+```bash
+cd cfc-langgraph
+# 确保 Java 后端在运行 (localhost:9082)
+uvicorn app.main:app --port 9000 &
+curl -X POST http://localhost:9000/api/v1/recommend \
+  -H "Content-Type: application/json" \
+  -d '{"user_id": 1, "tags": ["益生菌", "肠胃"], "limit": 3}'
+# 预期: {"items": [...], "source": "agent"}
+kill %1
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add cfc-langgraph/
+git commit -m "feat(langgraph): RecommendAgent with tool calling"
+```
+
+---
+
+### Task 7: Java 侧 AiGateway(HTTP → Python + Dify Fallback)
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java`
+- Modify: `cfc-backend/src/main/resources/application.yml`(加 python.* 配置)
+
+**Interfaces:**
+- Produces: `AiGateway.recommend()` — Java 端统一的 Python 调用入口,超时/异常自动 fallback
+- Consumes: Python `POST /api/v1/recommend`
+
+- [ ] **Step 1: 在 `application.yml` 中新增配置**
+
+```yaml
+# application.yml 底部追加
+python:
+  enabled: true
+  base-url: http://localhost:9000
+  timeout-ms: 15000
+  circuit-breaker:
+    failure-threshold: 3
+    reset-timeout-ms: 30000
+```
+
+- [ ] **Step 2: 创建 `AiGateway.java`**
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.RecommendationResult;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+
+import javax.annotation.PostConstruct;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * LangGraph Python 服务网关
+ * 统一管理 Python 服务调用、熔断、Fallback
+ */
+@Service
+public class AiGateway {
+
+    private static final Logger log = LoggerFactory.getLogger(AiGateway.class);
+
+    @Value("${python.enabled:false}")
+    private boolean enabled;
+
+    @Value("${python.base-url:http://localhost:9000}")
+    private String baseUrl;
+
+    @Value("${python.timeout-ms:15000}")
+    private int timeoutMs;
+
+    @Value("${python.circuit-breaker.failure-threshold:3}")
+    private int failureThreshold;
+
+    @Value("${python.circuit-breaker.reset-timeout-ms:30000}")
+    private int resetTimeoutMs;
+
+    private final RestTemplate restTemplate = new RestTemplate();
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    // 熔断器状态
+    private final AtomicInteger consecutiveFailures = new AtomicInteger(0);
+    private final AtomicLong lastFailureTime = new AtomicLong(0);
+    private volatile boolean circuitOpen = false;
+
+    @PostConstruct
+    public void init() {
+        if (enabled) {
+            log.info("AiGateway 已启用: baseUrl={}, timeout={}ms", baseUrl, timeoutMs);
+        } else {
+            log.info("AiGateway 已禁用, 所有请求走 Dify");
+        }
+    }
+
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    private boolean isCircuitOpen() {
+        if (!circuitOpen) return false;
+        // 检查是否到重置时间
+        if (System.currentTimeMillis() - lastFailureTime.get() > resetTimeoutMs) {
+            circuitOpen = false;
+            consecutiveFailures.set(0);
+            log.info("AiGateway 熔断器已半开");
+            return false;
+        }
+        return true;
+    }
+
+    private void recordFailure() {
+        int failures = consecutiveFailures.incrementAndGet();
+        lastFailureTime.set(System.currentTimeMillis());
+        if (failures >= failureThreshold) {
+            circuitOpen = true;
+            log.warn("AiGateway 熔断器已打开 (连续{}次失败)", failures);
+        }
+    }
+
+    /**
+     * 调用 Python 推荐服务, 失败时返回 null (由调用方决定 fallback)
+     */
+    public List<RecommendationResult> recommend(Long userId, List<String> tags, List<String> types, Integer limit) {
+        if (!enabled || isCircuitOpen()) return null;
+
+        try {
+            // 使用 ObjectMapper 构造 JSON 替代手动拼接
+            ObjectNode body = objectMapper.createObjectNode();
+            body.put("user_id", userId);
+            if (tags != null && !tags.isEmpty()) {
+                ArrayNode tagsArray = body.putArray("tags");
+                tags.forEach(tagsArray::add);
+            }
+            if (types != null && !types.isEmpty()) {
+                ArrayNode typesArray = body.putArray("types");
+                types.forEach(typesArray::add);
+            }
+            body.put("limit", limit != null ? limit : 5);
+
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/recommend";
+
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                JsonNode items = root.get("items");
+                if (items != null && items.isArray()) {
+                    List<RecommendationResult> results = new ArrayList<>();
+                    for (JsonNode item : items) {
+                        RecommendationResult r = new RecommendationResult();
+                        r.setType(item.get("type").asText());
+                        r.setId(item.get("id").asLong());
+                        r.setName(item.get("name").asText());
+                        r.setDescription(item.has("description") ? item.get("description").asText() : "");
+                        r.setReason(item.has("reason") ? item.get("reason").asText() : "");
+                        r.setSource("langgraph");
+                        results.add(r);
+                    }
+                    consecutiveFailures.set(0);
+                    log.debug("AiGateway recommend 成功: {} 条", results.size());
+                    return results;
+                }
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway recommend 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    private org.springframework.http.HttpHeaders createJsonHeaders() {
+        org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
+        headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
+        return headers;
+    }
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd cfc-backend
+mvn clean compile -q
+# 预期: BUILD SUCCESS
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
+git add cfc-backend/src/main/resources/application.yml
+git commit -m "feat(backend): AiGateway with circuit breaker for LangGraph"
+```
+
+---
+
+### Task 8: Java 端 RecommendController 灰度切换
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/RecommendationController.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java`
+
+**Interfaces:**
+- Consumes: `AiGateway.recommend()`
+- 灰度策略: 5% 流量走 Python, 异常自动 fallback 现有逻辑
+
+- [ ] **Step 1: 修改 `RecommendationController.java`**
+
+```java
+// 注入 AiGateway
+@Resource
+private AiGateway aiGateway;
+
+@Operation(summary = "按营养标签搜索推荐内容")
+@PostMapping("/search")
+public Result<List<RecommendationResult>> search(
+        @RequestBody RecommendationQuery query,
+        HttpServletRequest request) {
+
+    Long userId = (Long) request.getAttribute("userId");
+    if (userId == null) userId = 0L;
+
+    // 灰度: 5% 流量走 Python LangGraph
+    if (aiGateway.isEnabled() && Math.random() < 0.05) {
+        List<RecommendationResult> pythonResults = aiGateway.recommend(
+            userId, query.getNutritionTags(), query.getTypes(), query.getLimit());
+        if (pythonResults != null && !pythonResults.isEmpty()) {
+            log.info("[灰度] Recommend via LangGraph, userId={}", userId);
+            return Result.success(pythonResults);
+        }
+        log.info("[灰度] LangGraph 无结果/异常, fallback 本地搜索");
+    }
+
+    // 现有业务逻辑
+    List<RecommendationResult> results = recommendationService.search(query);
+    return Result.success(results);
+}
+```
+
+注意: `Math.random() < 0.05` 需要在 Controller 中注入 `HttpServletRequest`,当前方法签名没有这个参数。Java 端统一通过 `@RequestAttribute("userId")` 获取,所以需要修改方法签名或改用 RequestContextHolder。
+
+安全且兼容的改法:
+
+```java
+@PostMapping("/search")
+public Result<List<RecommendationResult>> search(
+        @RequestBody RecommendationQuery query,
+        @RequestAttribute(value = "userId", required = false) Long userId) {
+    // ...
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend
+mvn clean compile -q
+# 预期: BUILD SUCCESS
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/RecommendationController.java
+git commit -m "feat(backend): 5% recommend traffic routed to LangGraph"
+```
+
+---
+
+### Task 9: Docker Compose 本地开发编排
+
+**Files:**
+- Create: `cfc-langgraph/docker-compose.yml`
+
+- [ ] **Step 1: 创建 `docker-compose.yml`**
+
+```yaml
+version: "3.8"
+
+services:
+  langgraph-svc:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    ports:
+      - "9000:9000"
+    env_file:
+      - .env
+    environment:
+      - JAVA_BASE_URL=http://host.docker.internal:9082
+      - CHROMA_DB_PATH=/data/chroma_db
+    volumes:
+      - langgraph-data:/data
+      - ./app:/app/app  # 开发模式: 热重载
+    command: uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload
+    restart: unless-stopped
+
+volumes:
+  langgraph-data:
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add cfc-langgraph/docker-compose.yml
+git commit -m "chore(langgraph): Docker Compose for local dev"
+```
+
+---
+
+### Task 10: Phase 1 集成测试
+
+**Files:**
+- Create: `cfc-langgraph/tests/__init__.py`
+- Create: `cfc-langgraph/tests/conftest.py`
+- Create: `cfc-langgraph/tests/test_recommend.py`
+
+- [ ] **Step 1: 创建 `tests/__init__.py`**(空文件)
+
+- [ ] **Step 2: 创建 `tests/conftest.py`**
+
+```python
+import pytest
+from httpx import AsyncClient, ASGITransport
+from app.main import app
+
+@pytest.fixture
+async def client():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as ac:
+        yield ac
+
+@pytest.fixture
+async def health_response(client):
+    resp = await client.get("/health")
+    return resp
+```
+
+- [ ] **Step 3: 创建 `tests/test_recommend.py`**
+
+```python
+import pytest
+from httpx import AsyncClient, ASGITransport
+from app.main import app
+
+@pytest.mark.asyncio
+async def test_health_check():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.get("/health")
+        assert resp.status_code == 200
+        assert resp.json() == {"status": "ok"}
+
+@pytest.mark.asyncio
+async def test_recommend_empty_tags():
+    """空标签应返回空列表"""
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/recommend", json={
+            "user_id": 1,
+            "tags": [],
+            "query": "推荐一些适合孩子的活动",
+            "limit": 3,
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert "items" in data
+
+@pytest.mark.asyncio
+async def test_recommend_with_tags():
+    """带标签应返回推荐结果"""
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/recommend", json={
+            "user_id": 1,
+            "tags": ["益生菌", "肠胃"],
+            "limit": 3,
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert "items" in data
+        assert "source" in data
+```
+
+- [ ] **Step 4: 运行测试**
+
+```bash
+cd cfc-langgraph
+pip install -e ".[dev]"
+pytest tests/ -v
+# 预期: 2 passed (test_health_check, test_recommend_empty_tags 通过)
+# test_recommend_with_tags 可能因 Java 后端未运行而返回空列表, 但不应该报错
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-langgraph/tests/
+git commit -m "test(langgraph): Phase 1 integration tests"
+```
+
+---
+
+## 自审清单
+
+- [ ] 每个 Task 的产出物独立可测试
+- [ ] 没有 "TBD" / "TODO" 占位符
+- [ ] Python 侧没有直连 MySQL
+- [ ] Java 侧灰度开关 + 熔断器 + Fallback 三层保护
+- [ ] Docker Compose 可一键启动开发环境
+- [ ] Phase 1 产出物: 运行中的 Recommend API + 知识库基础设施

+ 990 - 0
docs/superpowers/plans/2026-07-20-langgraph-phase2.md

@@ -0,0 +1,990 @@
+# LangGraph Sidecar — Phase 2 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 用 ChatAgent 替代 Dify 核心聊天能力,接入三层记忆系统,RAG Pipeline 升级为混合检索+重排序。
+
+**Architecture:** LangGraph StateGraph 编排聊天流程(意图分类→上下文加载→LLM+Tool→任务提取),LangChain Memory 管理三层记忆,RAG 升级为 EnsembleRetriever + ContextualCompressionRetriever。
+
+**Tech Stack:** Python 3.11, FastAPI, LangChain, LangGraph, ChromaDB, httpx
+
+## Global Constraints
+
+- Python 3.11+,所有 HTTP 通信通过 httpx
+- ChromaDB 文件模式,不引入额外中间件
+- Java 端 JDK 8 / Spring Boot 2.7.18 保持不变
+- 所有 Tool 调用通过 Java HTTP API,不直连 MySQL
+- Dify 作为 Fallback:Python 超时或异常时 Java 自动回退 Dify
+- 所有项目文件路径相对于 `D:\workspace\cfc\`
+
+---
+
+### Task 1: 三层记忆模块
+
+**Files:**
+- Create: `cfc-langgraph/app/memory/__init__.py`
+- Create: `cfc-langgraph/app/memory/store.py`
+
+**Interfaces:**
+- Produces: `MemoryManager` 类,提供 `load(user_id, conv_id)` / `save(user_id, conv_id, messages)` / `recall(user_id, query)`
+
+- [ ] **Step 1: 创建 `app/memory/__init__.py`**
+
+```python
+from .store import MemoryManager
+```
+
+- [ ] **Step 2: 创建 `app/memory/store.py`**
+
+```python
+from langchain.memory import ConversationSummaryBufferMemory, VectorStoreRetrieverMemory
+from langchain.memory import ConversationBufferWindowMemory
+from langchain_openai import ChatOpenAI, OpenAIEmbeddings
+from langchain_chroma import Chroma
+from app.config import settings
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class MemoryManager:
+    """三层记忆管理器
+
+    Layer 1 - 工作记忆: 最近 20 轮 + 超出自动摘要
+    Layer 2 - 长期事实: VectorStoreRetrieverMemory, 跨会话相似召回
+    Layer 3 - 语义记忆: 每次对话后向量化存储, 供后续会话召回
+    """
+
+    def __init__(self):
+        self.llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+        )
+        self.embeddings = OpenAIEmbeddings(
+            model=settings.embedding_model,
+            api_key=settings.effective_embedding_api_key,
+            base_url=settings.effective_embedding_base_url,
+        )
+        # Layer 3 向量库: 历史对话记忆
+        self.memory_vectorstore = Chroma(
+            collection_name="user_memory",
+            embedding_function=self.embeddings,
+            persist_directory=settings.chroma_db_path + "_memory",
+        )
+
+    def get_working_memory(self) -> ConversationSummaryBufferMemory:
+        """Layer 1: 工作记忆 (当前会话)"""
+        return ConversationSummaryBufferMemory(
+            llm=self.llm,
+            max_token_limit=2000,
+            memory_key="history",
+            return_messages=True,
+        )
+
+    def get_longterm_memory(self, user_id: int) -> VectorStoreRetrieverMemory:
+        """Layer 2+3: 长期 + 语义记忆"""
+        return VectorStoreRetrieverMemory(
+            retriever=self.memory_vectorstore.as_retriever(
+                search_kwargs={
+                    "k": 3,
+                    "filter": {"user_id": user_id},
+                }
+            ),
+            memory_key="long_term_memory",
+            input_key="input",
+        )
+
+    async def save_conversation(self, user_id: int, conversation_id: str,
+                                  messages: list[dict]):
+        """会话结束后保存到向量记忆库"""
+        texts = []
+        for msg in messages:
+            role = msg.get("role", "unknown")
+            content = msg.get("content", "")
+            texts.append(f"[{role}] {content}")
+
+        full_text = "\n".join(texts)
+        metadata = {
+            "user_id": user_id,
+            "conversation_id": conversation_id,
+            "timestamp": str(__import__("datetime").datetime.now()),
+        }
+
+        await self.memory_vectorstore.aadd_texts(
+            texts=[full_text],
+            metadatas=[metadata],
+        )
+        self.memory_vectorstore.persist()
+        logger.info("已保存对话到向量记忆: conv=%s, user=%s", conversation_id, user_id)
+
+    async def recall(self, user_id: int, query: str, k: int = 3) -> list[str]:
+        """语义召回: 查询与 query 最相似的历史对话片段"""
+        results = self.memory_vectorstore.similarity_search(
+            query,
+            k=k,
+            filter={"user_id": user_id},
+        )
+        return [doc.page_content for doc in results]
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/app/memory/
+git commit -m "feat(langgraph): 3-layer memory manager"
+```
+
+---
+
+### Task 2: 意图分类器
+
+**Files:**
+- Create: `cfc-langgraph/app/agents/intent_classifier.py`
+
+**Interfaces:**
+- Produces: `IntentClassifier.classify(query, context)` → `Intent` 枚举
+
+- [ ] **Step 1: 创建 `app/agents/intent_classifier.py`**
+
+```python
+from enum import Enum
+from langchain_openai import ChatOpenAI
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class Intent(str, Enum):
+    CHAT = "chat"           # 日常聊天 / 育儿交流
+    ANALYSIS = "analysis"   # 报告解读 / 数据分析
+    RECOMMEND = "recommend" # 商品 / 活动推荐
+    TASK = "task"           # 任务创建 / 进度查询
+    MIND = "mind"           # 情绪疏导 / 心理支持
+    HEALTH = "health"       # 健康咨询 / 舌诊
+
+
+CLASSIFY_PROMPT = """从用户消息中识别意图, 只返回意图代码, 不要解释:
+
+- chat: 日常聊天、育儿交流、询问建议(非具体商品/报告)
+- analysis: 报告解读\数据分析\趋势查看(提到"报告"/"分析"/"评分")
+- recommend: 商品\活动\文章推荐(提到"推荐"/"买"/"吃什么"/"适合")
+- task: 任务相关(提到"任务"/"打卡"/"完成"/"奖励")
+- mind: 情绪问题\心理支持(提到"心情"/"难过"/"焦虑"/"不开心")
+- health: 健康咨询\舌诊(提到"舌"/"健康"/"体质")
+
+用户消息: {query}
+
+历史上下文: {context}
+
+意图代码:
+"""
+
+
+class IntentClassifier:
+    def __init__(self):
+        self.llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            temperature=0.1,
+            max_tokens=20,
+        )
+
+    async def classify(self, query: str, context: str = "") -> Intent:
+        prompt = CLASSIFY_PROMPT.format(query=query[:200], context=context[:300])
+        try:
+            resp = await self.llm.ainvoke(prompt)
+            intent_str = resp.content.strip().lower()
+            for intent in Intent:
+                if intent.value in intent_str:
+                    return intent
+            logger.warning("无法识别的意图: %s, 默认 chat", intent_str)
+            return Intent.CHAT
+        except Exception as e:
+            logger.warning("意图分类失败: %s, 默认 chat", e)
+            return Intent.CHAT
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add cfc-langgraph/app/agents/intent_classifier.py
+git commit -m "feat(langgraph): intent classifier for chat routing"
+```
+
+---
+
+### Task 3: ChatAgent (LangGraph StateGraph)
+
+**Files:**
+- Create: `cfc-langgraph/app/agents/chat_agent.py`
+- Create: `cfc-langgraph/app/graphs/chat_graph.py`
+
+**Interfaces:**
+- Consumes: `MemoryManager`, `IntentClassifier`, `JavaClient`
+- Produces: `ChatAgent.run()` → `{"answer", "conversation_id", "tasks", "sources"}`
+
+- [ ] **Step 1: 创建 `app/agents/chat_agent.py`**
+
+```python
+from typing import Optional
+from app.tools.java_client import JavaClient
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ChatAgent:
+    """家庭聊天 Agent: 包装业务逻辑, 被 StateGraph 节点调用"""
+
+    def __init__(self):
+        self.java = JavaClient()
+
+    async def load_context(self, user_id: int, child_id: Optional[int] = None) -> dict:
+        """从 Java 侧加载用户上下文"""
+        params = {}
+        if child_id:
+            params["childId"] = child_id
+        return await self.java.get_user_context(user_id, params)
+
+    async def extract_tasks(self, answer: str, user_id: int, conv_id: str) -> list[dict]:
+        """从 AI 回答中解析 [TASK] 标记"""
+        import re
+        tasks = []
+        pattern = r'\[TASK:\s*\{([^}]+)\}\]'
+        matches = re.findall(pattern, answer)
+        for match in matches:
+            task_info = {}
+            for kv in match.split(","):
+                if ":" in kv:
+                    k, v = kv.split(":", 1)
+                    task_info[k.strip()] = v.strip().strip('"').strip("'")
+            if task_info.get("title"):
+                tasks.append(task_info)
+        return tasks
+```
+
+- [ ] **Step 2: 创建 `app/graphs/chat_graph.py`**
+
+```python
+from typing import TypedDict, Literal
+from langgraph.graph import StateGraph, START, END
+from langgraph.checkpoint import MemorySaver
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.agents.intent_classifier import IntentClassifier, Intent
+from app.agents.chat_agent import ChatAgent
+from app.tools.product_tools import (
+    search_product_by_keyword,
+    search_article_by_keyword,
+    search_activity_by_keyword,
+)
+from app.memory.store import MemoryManager
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class ChatState(TypedDict):
+    query: str
+    user_id: int
+    conversation_id: str
+    child_id: int | None
+    intent: Intent | None
+    context: dict | None
+    messages: list | None
+    answer: str | None
+    tasks: list[dict]
+    sources: list[dict]
+
+
+CHAT_SYSTEM_PROMPT = """你是一个儿童成长家庭助手, 回答关于孩子成长、健康、教育的各种问题。
+
+你可以使用搜索工具查找商品、活动和文章来辅助回答。
+
+回答原则:
+1. 用中文, 语气温暖亲切
+2. 如果用户提到具体孩子, 参考提供的家庭上下文
+3. 需要推荐时使用搜索工具
+4. 可以生成 [TASK: {"title": "任务名", "dimension": "身/心/智/行/富", "points": 10}] 标记来创建行动任务
+5. 不要编造医疗建议, 严重问题建议咨询医生
+"""
+
+
+def create_chat_graph():
+    """创建聊天 StateGraph"""
+    agent = ChatAgent()
+    classifier = IntentClassifier()
+    memory_mgr = MemoryManager()
+
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.7,
+    )
+    llm_with_tools = llm.bind_tools([
+        search_product_by_keyword,
+        search_article_by_keyword,
+        search_activity_by_keyword,
+    ])
+
+    builder = StateGraph(ChatState)
+
+    # ── 节点 ──
+
+    async def classify_intent(state: ChatState) -> dict:
+        intent = await classifier.classify(
+            state["query"],
+            context=str(state.get("context", {})),
+        )
+        return {"intent": intent}
+
+    async def load_context(state: ChatState) -> dict:
+        ctx = await agent.load_context(state["user_id"], state.get("child_id"))
+        return {"context": ctx}
+
+    async def llm_call(state: ChatState) -> dict:
+        """核心 LLM 调用 + Tool"""
+        messages = [SystemMessage(content=CHAT_SYSTEM_PROMPT)]
+
+        # 注入家庭上下文
+        ctx = state.get("context", {})
+        if ctx:
+            ctx_text = f"\n家庭上下文:\n{ctx}"
+            messages.append(SystemMessage(content=ctx_text))
+
+        # 注入长期记忆
+        try:
+            memories = await memory_mgr.recall(state["user_id"], state["query"])
+            if memories:
+                mem_text = "\n".join([f"- {m}" for m in memories])
+                messages.append(SystemMessage(
+                    content=f"相关历史对话:\n{mem_text}"
+                ))
+        except Exception as e:
+            logger.warning("召回记忆失败: %s", e)
+
+        # 用户消息
+        messages.append(HumanMessage(content=state["query"]))
+
+        response = await llm_with_tools.ainvoke(messages)
+        answer = response.content
+
+        # 提取任务
+        tasks = await agent.extract_tasks(answer, state["user_id"], state["conversation_id"])
+
+        # 提取来源
+        sources = []
+        if response.response_metadata.get("tool_calls"):
+            for tc in response.response_metadata["tool_calls"]:
+                sources.append({
+                    "type": "tool",
+                    "name": tc.get("name", ""),
+                    "input": tc.get("args", {}),
+                })
+
+        return {
+            "answer": answer,
+            "tasks": tasks,
+            "sources": sources,
+            "messages": [{"role": "user", "content": state["query"]},
+                         {"role": "assistant", "content": answer}],
+        }
+
+    async def save_memory(state: ChatState) -> dict:
+        """对话后保存到长期记忆"""
+        try:
+            if state.get("messages"):
+                await memory_mgr.save_conversation(
+                    state["user_id"],
+                    state["conversation_id"],
+                    state["messages"],
+                )
+        except Exception as e:
+            logger.warning("保存记忆失败: %s", e)
+        return {}
+
+    # ── 路由 ──
+
+    def route_by_intent(state: ChatState) -> Literal["llm_call", END]:
+        if state["intent"] in (
+            Intent.RECOMMEND,
+            Intent.ANALYSIS,
+            Intent.HEALTH,
+        ):
+            # 这些意图需要更专业的 Agent (Phase 3 实现)
+            # 当前先走通用 LLM
+            pass
+        return "llm_call"
+
+    # ── 构建图 ──
+
+    builder.add_node("classify_intent", classify_intent)
+    builder.add_node("load_context", load_context)
+    builder.add_node("llm_call", llm_call)
+    builder.add_node("save_memory", save_memory)
+
+    builder.add_edge(START, "classify_intent")
+    builder.add_edge("classify_intent", "load_context")
+    builder.add_conditional_edges("load_context", route_by_intent)
+    builder.add_edge("llm_call", "save_memory")
+    builder.add_edge("save_memory", END)
+
+    checkpointer = MemorySaver()
+    graph = builder.compile(checkpointer=checkpointer)
+
+    return graph
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/app/agents/chat_agent.py cfc-langgraph/app/graphs/chat_graph.py
+git commit -m "feat(langgraph): ChatAgent StateGraph with intent routing and memory"
+```
+
+---
+
+### Task 4: Chat API 端点
+
+**Files:**
+- Create: `cfc-langgraph/app/api/chat.py`
+- Modify: `cfc-langgraph/app/main.py` (注册路由)
+
+- [ ] **Step 1: 创建 `app/api/chat.py`**
+
+```python
+from fastapi import APIRouter
+from app.models.chat import ChatRequest, ChatResponse, SourceInfo
+from app.graphs.chat_graph import create_chat_graph
+
+router = APIRouter(prefix="/api/v1", tags=["chat"])
+
+# 编译图 (单例)
+_graph = None
+
+def get_graph():
+    global _graph
+    if _graph is None:
+        _graph = create_chat_graph()
+    return _graph
+
+
+@router.post("/chat", response_model=ChatResponse)
+async def chat(req: ChatRequest):
+    """家庭聊天: 意图分类→上下文→LLM→记忆"""
+    graph = get_graph()
+
+    initial_state = {
+        "query": req.query,
+        "user_id": req.user_id,
+        "conversation_id": req.conversation_id,
+        "child_id": req.context.child_id if req.context else None,
+        "intent": None,
+        "context": None,
+        "messages": None,
+        "answer": None,
+        "tasks": [],
+        "sources": [],
+    }
+
+    # 配置: 使用 conversation_id 作为线程 ID, 支持历史续接
+    config = {
+        "configurable": {"thread_id": req.conversation_id or str(req.user_id)},
+    }
+
+    result = await graph.ainvoke(initial_state, config)
+
+    sources = []
+    for s in result.get("sources", []):
+        sources.append(SourceInfo(
+            type=s.get("type", "tool"),
+            title=s.get("name", ""),
+        ))
+
+    conv_id = req.conversation_id or f"conv_{req.user_id}_{__import__('time').time()}"
+
+    return ChatResponse(
+        answer=result.get("answer", ""),
+        conversation_id=conv_id,
+        sources=sources,
+        tasks=result.get("tasks", []),
+    )
+```
+
+- [ ] **Step 2: 修改 `app/main.py` 注册路由**
+
+```python
+from app.api import health, recommend, chat  # 新增 chat
+
+app.include_router(chat.router)  # 新增
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/app/api/chat.py cfc-langgraph/app/main.py
+git commit -m "feat(langgraph): chat API endpoint with LangGraph graph"
+```
+
+---
+
+### Task 5: RAG Pipeline 升级 (混合检索+重排序)
+
+**Files:**
+- Modify: `cfc-langgraph/app/rag/retriever.py` (升级为 ensemble + compression)
+
+- [ ] **Step 1: 重写 `app/rag/retriever.py`**
+
+```python
+from langchain_chroma import Chroma
+from langchain.retrievers import EnsembleRetriever
+from langchain_community.retrievers import BM25Retriever
+from langchain.retrievers.document_compressors import LLMChainExtractor
+from langchain.retrievers import ContextualCompressionRetriever
+from langchain_openai import ChatOpenAI
+from .embeddings import get_embeddings
+from app.config import settings
+from app.tools.java_client import JavaClient
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class RagRetriever:
+    """升级版混合检索器: 向量 + BM25 + LLM 压缩重排序"""
+
+    def __init__(self, collection_name: str = "cfc_knowledge"):
+        embeddings = get_embeddings()
+        self.vectorstore = Chroma(
+            collection_name=collection_name,
+            embedding_function=embeddings,
+            persist_directory=settings.chroma_db_path,
+        )
+        self.java_client = JavaClient()
+        self._bm25_retriever: Optional[BM25Retriever] = None
+        self._bm25_texts: list[str] = []
+
+    async def initialize(self):
+        """从 Java 侧拉取知识库, 构建 BM25 索引"""
+        try:
+            articles = await self.java_client.get_published_articles()
+            self._bm25_texts = [
+                f"{a['title']} {a['summary']} {a.get('tags', '')}"
+                for a in articles
+            ]
+            if self._bm25_texts:
+                self._bm25_retriever = BM25Retriever.from_texts(
+                    self._bm25_texts,
+                    metadatas=articles,
+                )
+                logger.info("BM25 索引就绪: %d 条", len(self._bm25_texts))
+        except Exception as e:
+            logger.warning("BM25 初始化失败: %s", e)
+
+    async def retrieve(
+        self,
+        query: str,
+        filters: Optional[dict] = None,
+        k: int = 5,
+        use_compression: bool = True,
+    ) -> list[dict]:
+        """混合检索 + 可选 LLM 压缩重排序"""
+        retrievers = []
+
+        # 1. 向量检索
+        vector_retriever = self.vectorstore.as_retriever(
+            search_kwargs={"k": k * 2, "filter": filters},
+        )
+        retrievers.append(vector_retriever)
+
+        # 2. BM25 关键词检索
+        if self._bm25_retriever:
+            bm25_k = self._bm25_retriever.k
+            self._bm25_retriever.k = k * 2
+            retrievers.append(self._bm25_retriever)
+            self._bm25_retriever.k = bm25_k
+
+        if len(retrievers) == 1:
+            docs = await retrievers[0].ainvoke(query)
+        else:
+            ensemble = EnsembleRetriever(
+                retrievers=retrievers,
+                weights=[0.6, 0.4],
+            )
+            docs = await ensemble.ainvoke(query)
+
+        # 3. LLM 压缩 (剔除不相关内容)
+        if use_compression and docs:
+            llm = ChatOpenAI(
+                model=settings.llm_model,
+                api_key=settings.llm_api_key,
+                base_url=settings.llm_base_url,
+                temperature=0,
+            )
+            compressor = LLMChainExtractor.from_llm(llm)
+            compression_retriever = ContextualCompressionRetriever(
+                base_compressor=compressor,
+                base_retriever=ensemble if len(retrievers) > 1 else retrievers[0],
+            )
+            docs = await compression_retriever.ainvoke(query)
+
+        # 4. 格式化为统一输出
+        results = []
+        seen = set()
+        for doc in docs:
+            content_hash = hash(doc.page_content[:100])
+            if content_hash in seen:
+                continue
+            seen.add(content_hash)
+            results.append({
+                "content": doc.page_content,
+                "metadata": doc.metadata,
+                "score": doc.metadata.get("score", 0) if hasattr(doc, "metadata") else 0,
+            })
+
+        return results[:k]
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add cfc-langgraph/app/rag/retriever.py
+git commit -m "feat(langgraph): RAG pipeline upgraded to ensemble + compression"
+```
+
+---
+
+### Task 6: Java 侧 AiContextService → HTTP API
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AiContextService.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/ai/ContextApiController.java`
+
+**Interfaces:**
+- Produces: `POST /api/ai/context` (Python 可调用的 HTTP 数据接口, 接收 `user_id` + `intent_type` + `params`)
+
+- [ ] **Step 1: 创建 `ContextApiController.java`**
+
+```java
+package com.etotem.cfc.controller.ai;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.AiContextService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 上下文数据 API (供 LangGraph Python 服务调用)
+ * 替代 Dify Workflow HTTP 回调的 /api/ai/context
+ */
+@Tag(name = "AI上下文", description = "供 LangGraph 服务调用的上下文数据接口")
+@RestController("aiContextApiController")
+@RequestMapping("/api/ai/context")
+public class ContextApiController {
+
+    @Resource
+    private AiContextService aiContextService;
+
+    @Operation(summary = "获取 AI 上下文数据", hidden = true)
+    @PostMapping("")
+    public Result<Map<String, Object>> getContext(@RequestBody Map<String, Object> params) {
+        String userIdStr = (String) params.get("user_id");
+        String intentType = (String) params.get("intent_type");
+        Map<String, Object> contextParams = (Map<String, Object>) params.get("params");
+
+        if (userIdStr == null) {
+            return Result.error("缺少参数: user_id");
+        }
+        Long userId = Long.valueOf(userIdStr);
+        Map<String, Object> ctx = aiContextService.getContext(intentType, userId, contextParams);
+        return Result.success(ctx);
+    }
+}
+```
+
+- [ ] **Step 2: 编译验证**
+
+```bash
+cd cfc-backend
+mvn clean compile -q
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/ContextApiController.java
+git commit -m "feat(backend): context API for LangGraph Python service"
+```
+
+---
+
+### Task 7: Java AIService.sendMessage Python 路由
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java`
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java` (新增 chat 方法)
+
+- [ ] **Step 1: AiGateway 新增 `chat()` 方法**
+
+```java
+// 在 AiGateway.java 中追加
+
+/**
+ * 调用 Python ChatAgent, 失败时返回 null
+ */
+public Map<String, Object> chat(String query, Long userId, String conversationId,
+                                 Map<String, Object> inputs) {
+    if (!enabled || isCircuitOpen()) return null;
+
+    try {
+        ObjectNode body = objectMapper.createObjectNode();
+        body.put("query", query);
+        body.put("user_id", userId);
+        body.put("conversation_id", conversationId != null ? conversationId : "");
+
+        if (inputs != null && !inputs.isEmpty()) {
+            ObjectNode ctx = body.putObject("context");
+            inputs.forEach((key, value) -> {
+                if (value instanceof String) ctx.put(key, (String) value);
+                else if (value instanceof Number) ctx.put(key, ((Number) value).doubleValue());
+                else if (value instanceof Boolean) ctx.put(key, (Boolean) value);
+            });
+        }
+
+        HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+        String url = baseUrl + "/api/v1/chat";
+
+        ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+
+        if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+            JsonNode root = objectMapper.readTree(response.getBody());
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("answer", root.get("answer").asText());
+            result.put("conversationId", root.get("conversation_id").asText());
+            result.put("tasks", parseTasks(root.get("tasks")));
+            consecutiveFailures.set(0);
+            return result;
+        }
+        return null;
+    } catch (Exception e) {
+        log.warn("AiGateway chat 调用失败: {}", e.getMessage());
+        recordFailure();
+        return null;
+    }
+}
+
+private List<Map<String, Object>> parseTasks(JsonNode tasksNode) {
+    List<Map<String, Object>> tasks = new ArrayList<>();
+    if (tasksNode != null && tasksNode.isArray()) {
+        for (JsonNode task : tasksNode) {
+            Map<String, Object> t = new LinkedHashMap<>();
+            t.put("title", task.get("title").asText());
+            t.put("dimension", task.has("dimension") ? task.get("dimension").asText() : "");
+            t.put("rewardPoints", task.has("points") ? task.get("points").asInt() : 0);
+            tasks.add(t);
+        }
+    }
+    return tasks;
+}
+```
+
+- [ ] **Step 2: 修改 `AIService.sendMessage()`, 增加 Python 优先路由**
+
+```java
+// 在 AIService 中注入 AiGateway
+@Resource
+private AiGateway aiGateway;
+
+/**
+ * 发送聊天消息: 优先 Python LangGraph, 失败回退 Dify
+ */
+public Map<String, Object> sendMessage(String query, String userId,
+                                       String conversationId,
+                                       Map<String, Object> inputs) {
+    Long uid = Long.valueOf(userId);
+
+    // 1. 尝试 Python LangGraph
+    Map<String, Object> pythonResult = aiGateway.chat(query, uid, conversationId, inputs);
+    if (pythonResult != null) {
+        log.debug("LangGraph chat 成功: userId={}", userId);
+        // 镜像到本地 (兼容现有 ChatMirrorService)
+        mirrorConversation(uid,
+            (String) pythonResult.getOrDefault("conversationId", conversationId),
+            "family", query, (String) pythonResult.get("answer"), inputs);
+        return pythonResult;
+    }
+
+    // 2. Fallback: Dify
+    log.info("LangGraph 不可用, fallback to Dify: userId={}", userId);
+    return sendMessageToDify(query, userId, conversationId, inputs);
+}
+
+/**
+ * 原有 Dify 发送逻辑 (提取为独立方法)
+ */
+private Map<String, Object> sendMessageToDify(String query, String userId,
+                                               String conversationId,
+                                               Map<String, Object> inputs) {
+    String url = difyBaseUrl + "/chat-messages";
+    Map<String, Object> body = new LinkedHashMap<>();
+    body.put("query", query);
+    body.put("user", userId);
+    body.put("response_mode", "blocking");
+    body.put("conversation_id", conversationId != null ? conversationId : "");
+    body.put("inputs", inputs != null ? inputs : Collections.emptyMap());
+    body.put("auto_generate_name", true);
+
+    HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, authHeaders());
+    ResponseEntity<Map> resp = restTemplate.postForEntity(url, entity, Map.class);
+
+    Map<String, Object> result = new LinkedHashMap<>();
+    if (resp.getBody() != null) {
+        result.put("answer", resp.getBody().get("answer"));
+        result.put("conversationId", resp.getBody().get("conversation_id"));
+        mirrorConversation(Long.valueOf(userId),
+            (String) resp.getBody().get("conversation_id"),
+            "family", query, (String) resp.getBody().get("answer"), inputs);
+    }
+    return result;
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd cfc-backend
+mvn clean compile -q
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
+git commit -m "feat(backend): AIService routes to LangGraph with Dify fallback"
+```
+
+---
+
+### Task 8: 测试
+
+**Files:**
+- Create: `cfc-langgraph/tests/test_chat.py`
+- Create: `cfc-langgraph/tests/test_memory.py`
+- Create: `cfc-langgraph/tests/test_intent.py`
+
+- [ ] **Step 1: 创建 `tests/test_intent.py`**
+
+```python
+import pytest
+from app.agents.intent_classifier import IntentClassifier, Intent
+
+@pytest.mark.asyncio
+async def test_classify_chat():
+    classifier = IntentClassifier()
+    intent = await classifier.classify("今天天气真好")
+    assert intent == Intent.CHAT
+
+@pytest.mark.asyncio
+async def test_classify_analysis():
+    classifier = IntentClassifier()
+    intent = await classifier.classify("帮我看看小明的健康报告")
+    assert intent == Intent.ANALYSIS
+
+@pytest.mark.asyncio
+async def test_classify_recommend():
+    classifier = IntentClassifier()
+    intent = await classifier.classify("推荐一些补钙的食物")
+    assert intent == Intent.RECOMMEND
+
+@pytest.mark.asyncio
+async def test_classify_mind():
+    classifier = IntentClassifier()
+    intent = await classifier.classify("孩子最近心情不好")
+    assert intent == Intent.MIND
+```
+
+- [ ] **Step 2: 创建 `tests/test_memory.py`**
+
+```python
+import pytest
+from app.memory.store import MemoryManager
+
+@pytest.mark.asyncio
+async def test_memory_manager_init():
+    mgr = MemoryManager()
+    assert mgr is not None
+    assert mgr.memory_vectorstore is not None
+
+@pytest.mark.asyncio
+async def test_working_memory():
+    mgr = MemoryManager()
+    mem = mgr.get_working_memory()
+    assert mem is not None
+    assert mem.memory_key == "history"
+```
+
+- [ ] **Step 3: 创建 `tests/test_chat.py`**
+
+```python
+import pytest
+from httpx import AsyncClient, ASGITransport
+from app.main import app
+
+@pytest.mark.asyncio
+async def test_chat_endpoint():
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/chat", json={
+            "query": "你好",
+            "user_id": 1,
+            "conversation_id": "",
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert "answer" in data
+        assert isinstance(data.get("tasks"), list)
+```
+
+- [ ] **Step 4: 运行测试**
+
+```bash
+cd cfc-langgraph
+pytest tests/ -v
+# 预期: test_intent 通过 (需要 LLM API Key)
+# test_memory 通过
+# test_chat 通过 (需要 Java 后端 + LLM API Key)
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-langgraph/tests/
+git commit -m "test(langgraph): Phase 2 tests for chat, memory, intent"
+```
+
+---
+
+### Phase 2 自审清单
+
+- [ ] 三层记忆模块: 工作记忆/长期事实/语义向量召回
+- [ ] 意图分类器: 6 类意图, 失败默认 chat
+- [ ] ChatAgent StateGraph: 分类→上下文→LLM→任务提取→保存记忆
+- [ ] Chat API: `POST /api/v1/chat` 完整链路
+- [ ] RAG: 混合检索 + LLM 压缩重排序
+- [ ] Java ContextApiController: Python 可调用
+- [ ] Java AIService: Python 优先 + Dify 回退

+ 841 - 0
docs/superpowers/plans/2026-07-20-langgraph-phase3.md

@@ -0,0 +1,841 @@
+# LangGraph Sidecar — Phase 3 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 实现 AnalysisAgent(报告解读)、MultiModalAgent(舌诊)、知识库自动化 Pipeline、接入 LangSmith 全链路 Trace。
+
+**Architecture:** AnalysisAgent 复用 ChatAgent 的 StateGraph 模式,新增报告 Tool 和舌诊 Tool。知识库自动化通过定时任务从 Java 拉取增量数据→向量化→更新 ChromaDB。LangSmith 通过环境变量一键接入,不侵入业务代码。
+
+**Tech Stack:** Python 3.11, FastAPI, LangChain, LangGraph, LangSmith, ChromaDB, httpx
+
+## Global Constraints
+
+- Python 3.11+,所有 HTTP 通信通过 httpx
+- ChromaDB 文件模式,不引入额外中间件
+- 舌诊保留 Dify Workflow 作为 Fallback(Dify 在多模态上最成熟)
+- 知识库更新支持增量(只处理变更文档)
+- LangSmith 通过环境变量开关,不硬编码
+- 所有项目文件路径相对于 `D:\workspace\cfc\`
+
+---
+
+### Task 1: 报告解读 Tool
+
+**Files:**
+- Create: `cfc-langgraph/app/tools/report_tools.py`
+
+**Interfaces:**
+- Produces: 3 个 Tool:`get_report_detail`, `get_survey_data`, `get_dimension_scores`
+
+- [ ] **Step 1: 创建 `app/tools/report_tools.py`**
+
+```python
+from langchain_core.tools import tool
+from app.tools.java_client import JavaClient
+import logging
+
+logger = logging.getLogger(__name__)
+_java = JavaClient()
+
+
+@tool
+async def get_report_detail(report_id: int) -> str:
+    """获取健康报告的完整详情, 包含各维度评分和解读"""
+    try:
+        client = _java
+        resp = await client._get_client().post(
+            "/api/health/report/detail",
+            json={"reportId": report_id},
+        )
+        data = resp.json()
+        if data.get("code") == 200:
+            import json
+            return json.dumps(data.get("data", {}), ensure_ascii=False)
+        return "{}"
+    except Exception as e:
+        logger.warning("获取报告详情失败: %s", e)
+        return "{}"
+
+
+@tool
+async def get_survey_data(report_id: int) -> str:
+    """获取与报告关联的调研问卷数据"""
+    try:
+        client = _java
+        resp = await client._get_client().post(
+            "/api/survey/completed",
+            json={"reportId": report_id},
+        )
+        data = resp.json()
+        if data.get("code") == 200:
+            import json
+            return json.dumps(data.get("data", {}), ensure_ascii=False)
+        return "{}"
+    except Exception as e:
+        logger.warning("获取问卷数据失败: %s", e)
+        return "{}"
+
+
+@tool
+async def get_dimension_scores(family_id: int) -> str:
+    """获取家庭成员的五维能量分数 (身/心/智/行/富)"""
+    try:
+        client = _java
+        resp = await client._get_client().post(
+            "/api/dimension/scores",
+            json={"familyId": family_id},
+        )
+        data = resp.json()
+        if data.get("code") == 200:
+            import json
+            return json.dumps(data.get("data", {}), ensure_ascii=False)
+        return "{}"
+    except Exception as e:
+        logger.warning("获取维度分数失败: %s", e)
+        return "{}"
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add cfc-langgraph/app/tools/report_tools.py
+git commit -m "feat(langgraph): report analysis tools"
+```
+
+---
+
+### Task 2: AnalysisAgent (报告解读)
+
+**Files:**
+- Create: `cfc-langgraph/app/agents/analysis_agent.py`
+- Create: `cfc-langgraph/app/graphs/analysis_graph.py`
+- Create: `cfc-langgraph/app/api/analyze.py`
+- Modify: `cfc-langgraph/app/main.py` (注册路由)
+
+**Interfaces:**
+- Consumes: `report_tools`, `JavaClient`
+- Produces: `POST /api/v1/analyze` 报告解读接口
+
+- [ ] **Step 1: 创建 `app/agents/analysis_agent.py`**
+
+```python
+from app.tools.java_client import JavaClient
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class AnalysisAgent:
+    """报告解读 Agent: 拉取报告数据 + 问卷数据 + LLM 分析"""
+
+    def __init__(self):
+        self.java = JavaClient()
+
+    async def get_report_summary(self, report_id: int) -> dict:
+        """获取报告概要 (用于 LLM 上下文)"""
+        try:
+            client = await self.java._get_client()
+            resp = await client.post("/api/health/report/summary", json={
+                "reportId": report_id,
+            })
+            data = resp.json()
+            if data.get("code") == 200:
+                return data.get("data", {})
+        except Exception as e:
+            logger.warning("获取报告概要失败: %s", e)
+        return {}
+```
+
+- [ ] **Step 2: 创建 `app/graphs/analysis_graph.py`**
+
+```python
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langgraph.checkpoint import MemorySaver
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.tools.report_tools import get_report_detail, get_survey_data, get_dimension_scores
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class AnalysisState(TypedDict):
+    report_id: int
+    user_id: int
+    focus: Optional[str]          # nutrition / gut / chronic / overall
+    report_data: Optional[dict]
+    survey_data: Optional[dict]
+    dimension_scores: Optional[dict]
+    analysis: Optional[str]
+    recommendations: list[str]
+
+
+ANALYSIS_SYSTEM_PROMPT = """你是一个儿童健康报告解读专家。根据健康报告数据和调研问卷, 提供专业、易懂的分析。
+
+分析原则:
+1. 用通俗语言解释各项指标含义
+2. 关注异常指标, 给出改善建议
+3. 结合问卷数据提供个性化分析
+4. 五维能量(身/心/智/行/富)角度解读整体状况
+5. 输出格式: 总体评估 → 分项分析 → 改善建议
+"""
+
+
+def create_analysis_graph():
+    """创建报告解读 StateGraph"""
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.3,
+    )
+    llm_with_tools = llm.bind_tools([
+        get_report_detail,
+        get_survey_data,
+        get_dimension_scores,
+    ])
+
+    builder = StateGraph(AnalysisState)
+
+    async def gather_data(state: AnalysisState) -> dict:
+        """收集报告 + 问卷 + 维度数据"""
+        result = {}
+        if state.get("report_id"):
+            try:
+                detail = await get_report_detail.ainvoke({"report_id": state["report_id"]})
+                result["report_data"] = detail
+            except Exception as e:
+                logger.warning("获取报告详情失败: %s", e)
+            try:
+                survey = await get_survey_data.ainvoke({"report_id": state["report_id"]})
+                result["survey_data"] = survey
+            except Exception as e:
+                logger.warning("获取问卷数据失败: %s", e)
+        return result
+
+    async def analyze(state: AnalysisState) -> dict:
+        """LLM 分析"""
+        context_parts = []
+        if state.get("report_data"):
+            context_parts.append(f"报告数据: {state['report_data']}")
+        if state.get("survey_data"):
+            context_parts.append(f"问卷数据: {state['survey_data']}")
+        if state.get("focus"):
+            context_parts.append(f"重点关注: {state['focus']}")
+
+        context_text = "\n".join(context_parts) if context_parts else "暂无数据"
+
+        messages = [
+            SystemMessage(content=ANALYSIS_SYSTEM_PROMPT),
+            SystemMessage(content=f"待分析数据:\n{context_text}"),
+            HumanMessage(content="请分析以上健康数据, 给出评估和建议。"),
+        ]
+
+        response = await llm_with_tools.ainvoke(messages)
+        return {"analysis": response.content}
+
+    builder.add_node("gather_data", gather_data)
+    builder.add_node("analyze", analyze)
+
+    builder.add_edge(START, "gather_data")
+    builder.add_edge("gather_data", "analyze")
+    builder.add_edge("analyze", END)
+
+    checkpointer = MemorySaver()
+    return builder.compile(checkpointer=checkpointer)
+```
+
+- [ ] **Step 3: 创建 `app/api/analyze.py`**
+
+```python
+from fastapi import APIRouter
+from pydantic import BaseModel
+from typing import Optional
+from app.graphs.analysis_graph import create_analysis_graph
+
+router = APIRouter(prefix="/api/v1", tags=["analyze"])
+
+_graph = None
+
+def get_graph():
+    global _graph
+    if _graph is None:
+        _graph = create_analysis_graph()
+    return _graph
+
+
+class AnalyzeRequest(BaseModel):
+    report_id: int
+    user_id: int
+    focus: Optional[str] = None
+
+
+class AnalyzeResponse(BaseModel):
+    analysis: str = ""
+    recommendations: list[str] = []
+
+
+@router.post("/analyze", response_model=AnalyzeResponse)
+async def analyze(req: AnalyzeRequest):
+    graph = get_graph()
+    result = await graph.ainvoke({
+        "report_id": req.report_id,
+        "user_id": req.user_id,
+        "focus": req.focus,
+        "report_data": None,
+        "survey_data": None,
+        "dimension_scores": None,
+        "analysis": None,
+        "recommendations": [],
+    })
+    return AnalyzeResponse(analysis=result.get("analysis", ""))
+```
+
+- [ ] **Step 4: 修改 `app/main.py`**
+
+```python
+from app.api import health, recommend, chat, analyze  # 新增 analyze
+
+app.include_router(analyze.router)  # 新增
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-langgraph/app/agents/analysis_agent.py \
+      cfc-langgraph/app/graphs/analysis_graph.py \
+      cfc-langgraph/app/api/analyze.py \
+      cfc-langgraph/app/main.py
+git commit -m "feat(langgraph): AnalysisAgent for health report interpretation"
+```
+
+---
+
+### Task 3: MultiModalAgent (舌诊)
+
+**Files:**
+- Create: `cfc-langgraph/app/agents/multimodal_agent.py`
+- Create: `cfc-langgraph/app/api/tongue.py`
+- Modify: `cfc-langgraph/app/main.py` (注册路由)
+
+**Design Decision:** 舌诊涉及图片上传+多模态识别,Dify Workflow 在这块最成熟。Python 侧做薄代理层:HTTP 透传图片到 Dify Workflow,返回结果。后续若需替换多模态模型,改 Python 侧即可。
+
+- [ ] **Step 1: 创建 `app/agents/multimodal_agent.py`**
+
+```python
+import httpx
+from typing import Optional
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class TongueDiagnosisAgent:
+    """舌诊分析 Agent
+
+    当前实现: 代理到 Dify Workflow (多模态最成熟)
+    后续可替换: 直接调用多模态 LLM API
+    """
+
+    def __init__(self):
+        self.dify_base = settings.dify_base_url or ""
+        self.dify_api_key = settings.dify_tongue_api_key or ""
+
+    async def diagnose(
+        self,
+        image_url: str,
+        user_id: int,
+        additional_context: Optional[dict] = None,
+    ) -> dict:
+        """舌诊分析: 调用 Dify Workflow 或直接 LLM"""
+        if self.dify_base and self.dify_api_key:
+            return await self._via_dify(image_url, user_id, additional_context)
+        else:
+            return await self._via_llm(image_url)
+
+    async def _via_dify(
+        self, image_url: str, user_id: int, context: Optional[dict]
+    ) -> dict:
+        """通过 Dify Workflow 执行舌诊"""
+        url = f"{self.dify_base}/workflows/run"
+        headers = {
+            "Authorization": f"Bearer {self.dify_api_key}",
+            "Content-Type": "application/json",
+        }
+        inputs = {"tongue_image": {"type": "image", "url": image_url}}
+        if context:
+            inputs.update(context)
+
+        body = {
+            "inputs": inputs,
+            "user": str(user_id),
+            "response_mode": "blocking",
+        }
+
+        try:
+            async with httpx.AsyncClient(timeout=30) as client:
+                resp = await client.post(url, json=body, headers=headers)
+                data = resp.json()
+                if "data" in data and "outputs" in data["data"]:
+                    return data["data"]["outputs"]
+        except Exception as e:
+            logger.warning("Dify 舌诊失败: %s", e)
+
+        return self._mock_result()
+
+    async def _via_llm(self, image_url: str) -> dict:
+        """直接调用多模态 LLM (预留)"""
+        logger.warning("多模态 LLM 未配置, 返回模拟数据")
+        return self._mock_result()
+
+    def _mock_result(self) -> dict:
+        return {
+            "overall_assessment": "舌象基本正常, 舌质淡红, 苔薄白, 提示脾胃功能尚可。",
+            "indicators": [
+                {"code": "tongue_color", "value": "淡红"},
+                {"code": "coating_color", "value": "薄白"},
+                {"code": "coating_texture", "value": "润"},
+                {"code": "fissure", "value": "无"},
+                {"code": "teeth_mark", "value": "轻"},
+                {"code": "sublingual_vein", "value": "正常"},
+                {"code": "constitution", "value": "平和质"},
+            ],
+        }
+```
+
+- [ ] **Step 2: 创建 `app/api/tongue.py`**
+
+```python
+from fastapi import APIRouter, UploadFile, File, Form
+from typing import Optional
+from app.agents.multimodal_agent import TongueDiagnosisAgent
+import logging
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/v1", tags=["tongue"])
+
+_agent: Optional[TongueDiagnosisAgent] = None
+
+def get_agent() -> TongueDiagnosisAgent:
+    global _agent
+    if _agent is None:
+        _agent = TongueDiagnosisAgent()
+    return _agent
+
+
+@router.post("/tongue/diagnose")
+async def tongue_diagnose(
+    file: UploadFile = File(...),
+    user_id: int = Form(...),
+):
+    """舌诊分析: 上传舌苔图片, 返回分析结果"""
+    agent = get_agent()
+
+    # 保存上传文件到临时路径
+    import tempfile, os
+    ext = os.path.splitext(file.filename or "tongue.jpg")[1] or ".jpg"
+    tmp = tempfile.NamedTemporaryFile(delete=False, suffix=ext)
+    content = await file.read()
+    tmp.write(content)
+    tmp.close()
+
+    try:
+        # 上传到临时可访问的 URL (需要 Java 侧提供图片上传接口)
+        # 当前简化: 直接用 file:// 或 base64
+        import base64
+        b64 = base64.b64encode(content).decode()
+        data_url = f"data:image/{ext[1:]};base64,{b64}"
+
+        result = await agent.diagnose(image_url=data_url, user_id=user_id)
+        return {"code": 200, "data": result}
+    except Exception as e:
+        logger.error("舌诊分析失败: %s", e, exc_info=True)
+        return {"code": 500, "message": "舌诊分析失败"}
+    finally:
+        os.unlink(tmp.name)
+```
+
+- [ ] **Step 3: 修改 `app/main.py`**
+
+```python
+from app.api import health, recommend, chat, analyze, tongue  # 新增 tongue
+
+app.include_router(tongue.router)  # 新增
+```
+
+- [ ] **Step 4: 添加 Dify 配置到 `app/config.py`**
+
+```python
+# 在 Settings 类中追加
+dify_base_url: Optional[str] = None
+dify_tongue_api_key: Optional[str] = None
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-langgraph/app/agents/multimodal_agent.py \
+      cfc-langgraph/app/api/tongue.py \
+      cfc-langgraph/app/main.py \
+      cfc-langgraph/app/config.py
+git commit -m "feat(langgraph): multimodal agent for tongue diagnosis"
+```
+
+---
+
+### Task 4: 知识库自动化 Pipeline
+
+**Files:**
+- Create: `cfc-langgraph/app/rag/loader.py`
+- Create: `cfc-langgraph/app/rag/splitter.py`
+- Create: `cfc-langgraph/app/tasks/__init__.py`
+- Create: `cfc-langgraph/app/tasks/knowledge_sync.py`
+
+**Interfaces:**
+- Produces: 定时任务, 从 Java 拉取文章→分块→向量化→更新 ChromaDB
+- Produces: 增量更新策略 (只处理有变动的文档)
+
+- [ ] **Step 1: 创建 `app/rag/loader.py`**
+
+```python
+from app.tools.java_client import JavaClient
+from typing import Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class KnowledgeLoader:
+    """知识库加载器: 从 Java 侧拉取文章并格式化"""
+
+    def __init__(self):
+        self.java = JavaClient()
+
+    async def load_all_articles(self) -> list[dict]:
+        """获取所有已发布文章"""
+        return await self.java.get_published_articles()
+
+    async def load_updated_since(self, since: str) -> list[dict]:
+        """增量获取: 获取某个时间后更新的文章"""
+        try:
+            client = await self.java._get_client()
+            resp = await client.post("/api/article/updated-since", json={
+                "since": since,
+                "status": "published",
+            })
+            data = resp.json()
+            if data.get("code") == 200:
+                return data.get("data", [])
+        except Exception as e:
+            logger.warning("增量获取文章失败: %s", e)
+        return []
+
+    def format_for_indexing(self, articles: list[dict]) -> list[dict]:
+        """将文章格式化为可索引的文档"""
+        docs = []
+        for article in articles:
+            content = f"{article.get('title', '')}\n\n{article.get('summary', '')}\n\n{article.get('content', '')}"
+            docs.append({
+                "id": f"article_{article['id']}",
+                "content": content,
+                "metadata": {
+                    "source": "article",
+                    "article_id": article["id"],
+                    "title": article.get("title", ""),
+                    "tags": article.get("tags", ""),
+                    "updated_at": article.get("updatedAt", ""),
+                },
+            })
+        return docs
+```
+
+- [ ] **Step 2: 创建 `app/rag/splitter.py`**
+
+```python
+from langchain.text_splitter import RecursiveCharacterTextSplitter
+
+
+def get_knowledge_splitter() -> RecursiveCharacterTextSplitter:
+    """知识库文档分块器"""
+    return RecursiveCharacterTextSplitter(
+        chunk_size=500,
+        chunk_overlap=50,
+        separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
+        length_function=len,
+    )
+
+
+def get_summary_splitter() -> RecursiveCharacterTextSplitter:
+    """摘要分块器 (用于对话记忆)"""
+    return RecursiveCharacterTextSplitter(
+        chunk_size=1000,
+        chunk_overlap=100,
+        separators=["\n\n", "\n", "。", " ", ""],
+        length_function=len,
+    )
+```
+
+- [ ] **Step 3: 创建 `app/tasks/__init__.py`**(空文件)
+
+- [ ] **Step 4: 创建 `app/tasks/knowledge_sync.py`**
+
+```python
+"""知识库同步定时任务: 定期从 Java 拉取文章, 更新 ChromaDB"""
+from app.rag.loader import KnowledgeLoader
+from app.rag.splitter import get_knowledge_splitter
+from app.rag.embeddings import get_embeddings
+from app.config import settings
+from langchain_chroma import Chroma
+from langchain_core.documents import Document
+import logging
+import os
+import json
+
+logger = logging.getLogger(__name__)
+
+# 同步状态文件 (记录上次同步时间)
+SYNC_STATE_FILE = os.path.join(settings.chroma_db_path, ".sync_state")
+
+
+def _load_sync_state() -> dict:
+    try:
+        if os.path.exists(SYNC_STATE_FILE):
+            with open(SYNC_STATE_FILE) as f:
+                return json.load(f)
+    except Exception:
+        pass
+    return {"last_sync": "2000-01-01T00:00:00"}
+
+
+def _save_sync_state(state: dict):
+    os.makedirs(os.path.dirname(SYNC_STATE_FILE), exist_ok=True)
+    with open(SYNC_STATE_FILE, "w") as f:
+        json.dump(state, f)
+
+
+async def sync_knowledge_base():
+    """执行知识库同步 (全量+增量)"""
+    loader = KnowledgeLoader()
+    splitter = get_knowledge_splitter()
+    embeddings = get_embeddings()
+
+    vectorstore = Chroma(
+        collection_name="cfc_knowledge",
+        embedding_function=embeddings,
+        persist_directory=settings.chroma_db_path,
+    )
+
+    state = _load_sync_state()
+
+    # 增量获取更新文章
+    articles = await loader.load_updated_since(state["last_sync"])
+    if not articles:
+        logger.info("知识库同步: 无更新内容")
+        return
+
+    # 格式化为文档
+    docs = loader.format_for_indexing(articles)
+
+    # 分块
+    chunks = []
+    for doc in docs:
+        split_texts = splitter.split_text(doc["content"])
+        for i, text in enumerate(split_texts):
+            metadata = dict(doc["metadata"])
+            metadata["chunk_index"] = i
+            chunks.append(Document(page_content=text, metadata=metadata))
+
+    if not chunks:
+        logger.info("知识库同步: 无新增块")
+        return
+
+    # 添加到 ChromaDB
+    await vectorstore.aadd_documents(chunks)
+    vectorstore.persist()
+
+    # 更新同步状态
+    import datetime
+    state["last_sync"] = datetime.datetime.now().isoformat()
+    _save_sync_state(state)
+
+    logger.info("知识库同步完成: 新增 %d 篇文章, %d 个块", len(articles), len(chunks))
+```
+
+- [ ] **Step 5: 在 FastAPI 启动时注册定时任务**
+
+```python
+# 修改 app/main.py 中的 startup 事件
+
+import asyncio
+
+@app.on_event("startup")
+async def startup():
+    # 现有初始化代码...
+    # 启动知识库同步定时任务 (每小时执行一次)
+    async def schedule_kb_sync():
+        while True:
+            try:
+                from app.tasks.knowledge_sync import sync_knowledge_base
+                await sync_knowledge_base()
+            except Exception as e:
+                logger.warning("知识库同步失败: %s", e)
+            await asyncio.sleep(3600)  # 1 小时
+
+    asyncio.create_task(schedule_kb_sync())
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cfc-langgraph/app/rag/loader.py \
+      cfc-langgraph/app/rag/splitter.py \
+      cfc-langgraph/app/tasks/ \
+      cfc-langgraph/app/main.py
+git commit -m "feat(langgraph): knowledge base auto-sync pipeline"
+```
+
+---
+
+### Task 5: LangSmith Trace 接入
+
+**Files:**
+- Modify: `cfc-langgraph/app/config.py` (LangSmith 配置已存在)
+- Modify: `cfc-langgraph/app/main.py` (启动时配置 LangSmith)
+
+**Note:** LangChain 通过环境变量 `LANGCHAIN_TRACING_V2=true` + `LANGCHAIN_API_KEY` + `LANGCHAIN_PROJECT` 自动接入 LangSmith,零代码侵入。
+
+- [ ] **Step 1: 验证 LangSmith 配置**
+
+```python
+# 在 app/main.py startup 中添加:
+
+@app.on_event("startup")
+async def startup():
+    import os
+    if os.getenv("LANGCHAIN_TRACING_V2", "").lower() == "true":
+        logger.info(
+            "LangSmith 已启用: project=%s, api_key=%s...",
+            settings.langchain_project,
+            settings.langchain_api_key[:8] if settings.langchain_api_key else "none",
+        )
+    # ... 其余初始化
+```
+
+- [ ] **Step 2: 在 API handler 中注入 trace_id**
+
+```python
+# 修改 app/api/chat.py 和 app/api/recommend.py 等
+# 在响应中包含 trace_id
+
+from langchain.callbacks.tracers import LangChainTracer
+from langchain.callbacks import manager as cb_manager
+import uuid
+
+# 在每个 handler 中生成 trace_id
+trace_id = str(uuid.uuid4())
+
+# 返回给客户端
+return ChatResponse(
+    ...,
+    trace_id=trace_id,
+)
+```
+
+具体改动: 在 `chat.py` 的 `chat()` 函数末尾加入 `trace_id=trace_id`:
+
+```python
+# 修改 ChatResponse 返回
+return ChatResponse(
+    answer=result.get("answer", ""),
+    conversation_id=conv_id,
+    sources=sources,
+    tasks=result.get("tasks", []),
+    trace_id=trace_id,
+)
+```
+
+同理修改 `recommend.py` 和 `analyze.py`。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/app/main.py \
+      cfc-langgraph/app/api/chat.py \
+      cfc-langgraph/app/api/recommend.py \
+      cfc-langgraph/app/api/analyze.py
+git commit -m "feat(langgraph): LangSmith tracing enabled"
+```
+
+---
+
+### Task 6: Phase 3 集成测试
+
+**Files:**
+- Create: `cfc-langgraph/tests/test_analyze.py`
+- Create: `cfc-langgraph/tests/test_tongue.py`
+
+- [ ] **Step 1: 创建 `tests/test_analyze.py`**
+
+```python
+import pytest
+from httpx import AsyncClient, ASGITransport
+from app.main import app
+
+@pytest.mark.asyncio
+async def test_analyze_endpoint():
+    """报告解读端点可响应"""
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/analyze", json={
+            "report_id": 1,
+            "user_id": 1,
+            "focus": "overall",
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert "analysis" in data
+
+@pytest.mark.asyncio
+async def test_analyze_missing_report():
+    """不存在的 report_id 应返回空分析"""
+    transport = ASGITransport(app=app)
+    async with AsyncClient(transport=transport, base_url="http://test") as client:
+        resp = await client.post("/api/v1/analyze", json={
+            "report_id": -1,
+            "user_id": 1,
+        })
+        assert resp.status_code == 200
+        data = resp.json()
+        assert isinstance(data.get("analysis"), str)
+```
+
+- [ ] **Step 2: 运行测试**
+
+```bash
+cd cfc-langgraph
+pytest tests/ -v
+# 预期: 全部通过 (需要 Java 后端 + LLM API Key)
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/tests/
+git commit -m "test(langgraph): Phase 3 tests for analyze and tongue APIs"
+```
+
+---
+
+### Phase 3 自审清单
+
+- [ ] 报告解读 Tool: 获取报告详情/问卷/维度分数
+- [ ] AnalysisAgent StateGraph: 数据收集→LLM 分析
+- [ ] `POST /api/v1/analyze` 端点
+- [ ] MultiModalAgent: 舌诊图片上传→Dify Workflow/LLM
+- [ ] `POST /api/v1/tongue/diagnose` 端点
+- [ ] 知识库定时同步: 增量拉取→分块→向量化→入库
+- [ ] LangSmith Trace: 环境变量驱动, 零侵入
+- [ ] 所有 API 响应含 trace_id

+ 945 - 0
docs/superpowers/plans/2026-07-20-langgraph-phase4.md

@@ -0,0 +1,945 @@
+# LangGraph Sidecar — Phase 4 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 性能调优、监控接入、部署标准化、清理 Dify 遗留代码, 使 LangGraph sidecar 达到可长期维护的生产状态。
+
+**Architecture:** Prometheus 指标暴露 + 结构化日志 + Docker Compose 生产编排 + Java 端废弃代码清理。
+
+**Tech Stack:** Python 3.11, FastAPI, Prometheus (client), Docker Compose, SLF4J
+
+## Global Constraints
+
+- Python 3.11+,性能调优不改变外部接口签名
+- 监控指标通过 HTTP `/metrics` 暴露 (Prometheus 拉取模式)
+- Docker Compose 同时编排 Java + Python 服务
+- Java 端只清理 Dify 相关代码, 不动业务逻辑
+- 所有项目文件路径相对于 `D:\workspace\cfc\`
+
+---
+
+### Task 1: 性能调优
+
+**Files:**
+- Modify: `cfc-langgraph/app/tools/java_client.py` (连接池复用)
+- Modify: `cfc-langgraph/app/rag/embeddings.py` (缓存)
+
+- [ ] **Step 1: 优化 `JavaClient` 连接池**
+
+```python
+# app/tools/java_client.py — 确保 httpx.AsyncClient 复用连接池
+
+import httpx
+from typing import Optional
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class JavaClient:
+    """Java 后端 HTTP 客户端 (单例, 复用连接池)"""
+
+    _instance: Optional["JavaClient"] = None
+
+    def __new__(cls):
+        if cls._instance is None:
+            cls._instance = super().__new__(cls)
+            cls._instance._client = None
+            cls._instance._base_url = settings.java_base_url
+        return cls._instance
+
+    async def _get_client(self) -> httpx.AsyncClient:
+        if self._client is None:
+            limits = httpx.Limits(
+                max_connections=10,
+                max_keepalive_connections=5,
+                keepalive_expiry=30,
+            )
+            self._client = httpx.AsyncClient(
+                base_url=self._base_url,
+                timeout=httpx.Timeout(10.0, connect=3.0),
+                limits=limits,
+            )
+        return self._client
+
+    async def close(self):
+        if self._client:
+            await self._client.aclose()
+            self._client = None
+            JavaClient._instance = None
+
+    # ... 其余方法保持与 Phase 1/2/3 一致
+```
+
+- [ ] **Step 2: Embedding 实例缓存 (单例模式)**
+
+```python
+# app/rag/embeddings.py — 已实现单例, 验证即可
+# 确保 _embeddings 不会被重复创建
+```
+
+- [ ] **Step 3: 添加请求耗时中间件**
+
+```python
+# 在 app/main.py 中追加
+
+import time
+from fastapi import Request
+import logging
+
+logger = logging.getLogger(__name__)
+
+@app.middleware("http")
+async def timing_middleware(request: Request, call_next):
+    start = time.perf_counter()
+    response = await call_next(request)
+    elapsed = time.perf_counter() - start
+    
+    # 慢查询日志: > 5s 的记录
+    if elapsed > 5:
+        logger.warning("SLOW_REQUEST: %s %s took %.2fs",
+                       request.method, request.url.path, elapsed)
+    else:
+        logger.debug("REQUEST: %s %s took %.2fs",
+                     request.method, request.url.path, elapsed)
+    
+    response.headers["X-Response-Time"] = f"{elapsed:.3f}s"
+    return response
+```
+
+- [ ] **Step 4: ChromaDB 批量写入优化**
+
+知识库同步时使用批量写入替代逐条写入:
+
+```python
+# app/tasks/knowledge_sync.py — 确认已使用 aadd_documents 批量 API
+# 分块数 < 100 时一次提交, > 100 时分批
+
+BATCH_SIZE = 100
+
+async def sync_knowledge_base():
+    # ... 前置逻辑不变 ...
+    
+    # 分批写入
+    for i in range(0, len(chunks), BATCH_SIZE):
+        batch = chunks[i:i + BATCH_SIZE]
+        await vectorstore.aadd_documents(batch)
+        vectorstore.persist()
+        logger.debug("知识库同步进度: %d/%d", i + len(batch), len(chunks))
+    
+    # ... 后续逻辑不变 ...
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-langgraph/app/tools/java_client.py \
+      cfc-langgraph/app/main.py \
+      cfc-langgraph/app/tasks/knowledge_sync.py
+git commit -m "perf(langgraph): connection pool, timing middleware, batch write"
+```
+
+---
+
+### Task 2: 监控指标暴露
+
+**Files:**
+- Create: `cfc-langgraph/app/monitoring.py`
+- Modify: `cfc-langgraph/app/main.py` (注册 metrics 路由)
+
+- [ ] **Step 1: 创建 `app/monitoring.py`**
+
+```python
+"""Prometheus 监控指标"""
+from prometheus_client import Counter, Histogram, Gauge, generate_latest
+from fastapi import APIRouter, Response
+import time
+import functools
+
+# ── 指标定义 ──
+
+# LLM 调用
+llm_calls_total = Counter(
+    "llm_calls_total", "Total LLM API calls",
+    ["model", "status"],
+)
+llm_duration_seconds = Histogram(
+    "llm_duration_seconds", "LLM call duration",
+    ["model"],
+    buckets=(0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0),
+)
+llm_tokens_total = Counter(
+    "llm_tokens_total", "Total tokens used",
+    ["model", "type"],  # type = input / output
+)
+
+# RAG 检索
+rag_retrievals_total = Counter(
+    "rag_retrievals_total", "Total RAG retrievals",
+    ["method"],  # method = vector / bm25 / ensemble
+)
+rag_duration_seconds = Histogram(
+    "rag_duration_seconds", "RAG retrieval duration",
+    ["method"],
+    buckets=(0.01, 0.05, 0.1, 0.5, 1.0),
+)
+
+# Agent 调用
+agent_calls_total = Counter(
+    "agent_calls_total", "Total Agent invocations",
+    ["agent_type"],  # agent_type = chat / recommend / analysis
+)
+agent_duration_seconds = Histogram(
+    "agent_duration_seconds", "Agent execution duration",
+    ["agent_type"],
+    buckets=(0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0),
+)
+
+# Java 后端调用
+java_calls_total = Counter(
+    "java_calls_total", "Total calls to Java backend",
+    ["endpoint", "status"],
+)
+java_duration_seconds = Histogram(
+    "java_duration_seconds", "Java backend call duration",
+    ["endpoint"],
+    buckets=(0.01, 0.05, 0.1, 0.5, 1.0, 2.0),
+)
+
+# 知识库同步
+kb_sync_duration = Gauge(
+    "kb_sync_duration_seconds", "Last knowledge base sync duration"
+)
+kb_sync_documents = Gauge(
+    "kb_sync_documents_total", "Documents processed in last sync"
+)
+
+# ── 装饰器 ──
+
+def monitor_agent(agent_type: str):
+    """Agent 性能监控装饰器"""
+    def decorator(func):
+        @functools.wraps(func)
+        async def wrapper(*args, **kwargs):
+            agent_calls_total.labels(agent_type=agent_type).inc()
+            start = time.perf_counter()
+            try:
+                result = await func(*args, **kwargs)
+                agent_duration_seconds.labels(agent_type=agent_type).observe(
+                    time.perf_counter() - start)
+                return result
+            except Exception as e:
+                agent_duration_seconds.labels(agent_type=agent_type).observe(
+                    time.perf_counter() - start)
+                raise
+        return wrapper
+    return decorator
+
+
+# ── Metrics 路由 ──
+
+router = APIRouter(tags=["monitoring"])
+
+@router.get("/metrics")
+async def metrics():
+    return Response(
+        content=generate_latest(),
+        media_type="text/plain; charset=utf-8",
+    )
+
+
+@router.get("/api/v1/health")
+async def detailed_health():
+    """详细健康检查 (含组件状态)"""
+    status = {"status": "ok", "components": {}}
+
+    # 检查 ChromaDB
+    try:
+        from app.config import settings
+        import os
+        chroma_path = settings.chroma_db_path
+        status["components"]["chromadb"] = {
+            "status": "ok",
+            "path": chroma_path,
+            "exists": os.path.exists(chroma_path),
+        }
+    except Exception as e:
+        status["components"]["chromadb"] = {"status": "error", "message": str(e)}
+        status["status"] = "degraded"
+
+    # 检查 LLM API
+    try:
+        from langchain_openai import ChatOpenAI
+        llm = ChatOpenAI(
+            model=settings.llm_model,
+            api_key=settings.llm_api_key,
+            base_url=settings.llm_base_url,
+            max_tokens=5,
+        )
+        await llm.ainvoke("ping")
+        status["components"]["llm"] = {"status": "ok"}
+    except Exception as e:
+        status["components"]["llm"] = {"status": "error", "message": str(e)}
+        status["status"] = "degraded"
+
+    # 检查 Java 后端
+    try:
+        from app.tools.java_client import JavaClient
+        client = JavaClient()
+        jc = await client._get_client()
+        resp = await jc.get("/health")
+        if resp.status_code == 200:
+            status["components"]["java_backend"] = {"status": "ok"}
+        else:
+            status["components"]["java_backend"] = {"status": "error", "code": resp.status_code}
+            status["status"] = "degraded"
+    except Exception as e:
+        status["components"]["java_backend"] = {"status": "error", "message": str(e)}
+        status["status"] = "degraded"
+
+    return status
+```
+
+- [ ] **Step 2: 注册 metrics 路由到 `app/main.py`**
+
+```python
+from app import monitoring  # 新增
+
+app.include_router(monitoring.router)  # 新增
+```
+
+- [ ] **Step 3: 在 Agent 调用处插入监控装饰器**
+
+```python
+# app/graphs/recommend_graph.py — RecommendAgent.run()
+
+from app.monitoring import monitor_agent
+
+class RecommendAgent:
+    @monitor_agent("recommend")
+    async def run(self, query: str, tags: list[str], limit: int = 5) -> dict:
+        # ... 原有代码不变 ...
+```
+
+同理在 `chat_graph.py` 的 `llm_call` 节点和 `analysis_graph.py` 的 `analyze` 节点也加上。
+
+- [ ] **Step 4: 添加 `prometheus-client` 依赖到 `pyproject.toml`**
+
+```toml
+dependencies = [
+    # ... 现有依赖保持不变 ...
+    "prometheus-client>=0.21",
+]
+```
+
+- [ ] **Step 5: 验证 metrics 端点**
+
+```bash
+cd cfc-langgraph
+pip install -e .
+uvicorn app.main:app --port 9000 &
+curl http://localhost:9000/metrics
+# 预期: 以 # HELP / # TYPE 开头的 Prometheus 格式文本
+kill %1
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add cfc-langgraph/app/monitoring.py \
+      cfc-langgraph/app/main.py \
+      cfc-langgraph/app/graphs/recommend_graph.py \
+      cfc-langgraph/app/graphs/chat_graph.py \
+      cfc-langgraph/app/graphs/analysis_graph.py \
+      cfc-langgraph/pyproject.toml
+git commit -m "feat(langgraph): Prometheus metrics and detailed health check"
+```
+
+---
+
+### Task 3: 结构化日志
+
+**Files:**
+- Create: `cfc-langgraph/app/log_config.py`
+- Modify: `cfc-langgraph/app/main.py`
+
+- [ ] **Step 1: 创建 `app/log_config.py`**
+
+```python
+"""结构化日志配置"""
+import logging
+import json
+import sys
+from datetime import datetime, timezone
+
+
+class JsonFormatter(logging.Formatter):
+    """JSON 日志格式化器 (适合生产环境日志聚合)"""
+
+    def format(self, record: logging.LogRecord) -> str:
+        log_entry = {
+            "timestamp": datetime.now(timezone.utc).isoformat(),
+            "level": record.levelname,
+            "logger": record.name,
+            "message": record.getMessage(),
+        }
+        if hasattr(record, "trace_id"):
+            log_entry["trace_id"] = record.trace_id
+        if record.exc_info and record.exc_info[0]:
+            log_entry["exception"] = self.formatException(record.exc_info)
+        return json.dumps(log_entry, ensure_ascii=False)
+
+
+def setup_logging(level: str = "INFO", json_format: bool = False):
+    """配置日志
+
+    Args:
+        level: 日志级别
+        json_format: True=JSON 格式, False=开发友好格式
+    """
+    root = logging.getLogger()
+    root.setLevel(getattr(logging, level.upper(), logging.INFO))
+
+    # 清除已有 handler
+    root.handlers.clear()
+
+    handler = logging.StreamHandler(sys.stdout)
+
+    if json_format:
+        handler.setFormatter(JsonFormatter())
+    else:
+        handler.setFormatter(logging.Formatter(
+            "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+            datefmt="%Y-%m-%d %H:%M:%S",
+        ))
+
+    root.addHandler(handler)
+
+    # 第三方库日志级别
+    logging.getLogger("httpx").setLevel(logging.WARNING)
+    logging.getLogger("chromadb").setLevel(logging.WARNING)
+    logging.getLogger("langchain").setLevel(logging.WARNING)
+```
+
+- [ ] **Step 2: 在 `app/main.py` 启动时配置**
+
+```python
+@app.on_event("startup")
+async def startup():
+    import os
+    json_logs = os.getenv("JSON_LOGS", "false").lower() == "true"
+    setup_logging(level=settings.log_level, json_format=json_logs)
+    # ... 其余初始化代码 ...
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/app/log_config.py cfc-langgraph/app/main.py
+git commit -m "feat(langgraph): structured JSON logging"
+```
+
+---
+
+### Task 4: 生产级 Docker Compose 编排
+
+**Files:**
+- Create: `cfc-langgraph/docker-compose.yml` (覆盖 Phase 1 的开发版)
+- Create: `cfc-langgraph/Dockerfile` (多阶段构建)
+
+- [ ] **Step 1: 优化 `Dockerfile` (多阶段构建)**
+
+```dockerfile
+# ── 构建阶段 ──
+FROM python:3.11-slim AS builder
+
+WORKDIR /build
+COPY pyproject.toml .
+RUN pip install --no-cache-dir -e . && \
+    pip install --no-cache-dir gunicorn
+
+# ── 运行阶段 ──
+FROM python:3.11-slim
+
+# 时区
+ENV TZ=Asia/Shanghai
+RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
+
+WORKDIR /app
+
+COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
+COPY --from=builder /usr/local/bin/ /usr/local/bin/
+
+COPY app/ app/
+COPY data/ data/ 2>/dev/null || true
+
+RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
+USER appuser
+
+EXPOSE 9000
+
+CMD ["gunicorn", "app.main:app", \
+     "--worker-class", "uvicorn.workers.UvicornWorker", \
+     "--bind", "0.0.0.0:9000", \
+     "--workers", "2", \
+     "--timeout", "60", \
+     "--keep-alive", "10", \
+     "--access-logfile", "-", \
+     "--error-logfile", "-"]
+```
+
+- [ ] **Step 2: 创建生产 `docker-compose.yml`**
+
+```yaml
+version: "3.8"
+
+services:
+  cfc-backend:
+    build:
+      context: ../cfc-backend
+      dockerfile: Dockerfile
+    ports:
+      - "9082:9082"
+    environment:
+      - SPRING_PROFILES_ACTIVE=prod
+      - python.enabled=true
+      - python.base-url=http://langgraph-svc:9000
+    networks:
+      - cfc-net
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9082/actuator/health"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+
+  langgraph-svc:
+    build:
+      context: .
+      dockerfile: Dockerfile
+    ports:
+      - "9000:9000"
+    env_file:
+      - .env.production
+    environment:
+      - JAVA_BASE_URL=http://cfc-backend:9082
+      - CHROMA_DB_PATH=/data/chroma_db
+      - JSON_LOGS=true
+      - LANGCHAIN_TRACING_V2=${LANGCHAIN_TRACING_V2:-false}
+    volumes:
+      - langgraph-data:/data
+    networks:
+      - cfc-net
+    restart: unless-stopped
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9000/api/v1/health"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    depends_on:
+      - cfc-backend
+
+  # Prometheus (可选: 指标采集)
+  prometheus:
+    image: prom/prometheus:latest
+    volumes:
+      - ./prometheus.yml:/etc/prometheus/prometheus.yml
+      - prometheus-data:/prometheus
+    networks:
+      - cfc-net
+    restart: unless-stopped
+    profiles:
+      - monitoring
+
+networks:
+  cfc-net:
+
+volumes:
+  langgraph-data:
+  prometheus-data:
+```
+
+- [ ] **Step 3: 创建 Prometheus 配置**
+
+```yaml
+# cfc-langgraph/prometheus.yml
+global:
+  scrape_interval: 15s
+  evaluation_interval: 15s
+
+scrape_configs:
+  - job_name: "langgraph-svc"
+    static_configs:
+      - targets: ["langgraph-svc:9000"]
+    metrics_path: /metrics
+```
+
+- [ ] **Step 4: 创建生产环境 `.env.production` 模板**
+
+```bash
+# cfc-langgraph/.env.production (不上传 git, 手动部署时创建)
+LLM_API_KEY=sk-your-production-key
+LLM_BASE_URL=https://api.deepseek.com/v1
+LLM_MODEL=deepseek-chat
+
+EMBEDDING_API_KEY=${LLM_API_KEY}
+EMBEDDING_BASE_URL=${LLM_BASE_URL}
+EMBEDDING_MODEL=text-embedding-v3
+
+LANGCHAIN_TRACING_V2=false
+LANGCHAIN_API_KEY=
+LANGCHAIN_PROJECT=cfc-langgraph-prod
+
+LOG_LEVEL=info
+JSON_LOGS=true
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-langgraph/Dockerfile \
+      cfc-langgraph/docker-compose.yml \
+      cfc-langgraph/prometheus.yml \
+      cfc-langgraph/.env.production
+git commit -m "ops(langgraph): production Docker Compose with monitoring"
+```
+
+---
+
+### Task 5: Java 端废弃代码清理
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/DifySyncService.java` (标记废弃)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java` (简化, 移除 Dify 直接调用)
+- Delete: `cfc-backend/src/main/resources/application.yml` 中 dify.* 配置 (可选保留 Dify fallback)
+
+- [ ] **Step 1: 标记 `DifySyncService` 废弃**
+
+```java
+// DifySyncService.java 头部加 @Deprecated 注解
+
+/**
+ * 知识库同步服务
+ * @deprecated 知识库同步已迁移到 LangGraph Python 服务 (cfc-langgraph/app/tasks/knowledge_sync.py)
+ * 计划在下一个大版本移除
+ */
+@Deprecated
+@Service
+public class DifySyncService {
+    // ... 代码保持不变 ...
+}
+```
+
+- [ ] **Step 2: 简化 `AIService.java`**
+
+Dify 相关的方法合并为单一的 fallback 块:
+
+```java
+// AIService.java
+// sendMessageToDify() 保留为私有 fallback 方法
+// 公开的 sendMessage() 优先 Python, 仅当 Python 不可用时调用 sendMessageToDify()
+
+/**
+ * 发送聊天消息 (自动选择后端)
+ * 优先 LangGraph Python 服务, 失败时回退 Dify
+ */
+public Map<String, Object> sendMessage(String query, String userId,
+                                       String conversationId,
+                                       Map<String, Object> inputs) {
+    // Phase 2 已实现的逻辑: aiGateway.chat() → fallback sendMessageToDify()
+    // 如果 aiGateway 长期稳定, 可在此处移除 Dify fallback
+}
+```
+
+- [ ] **Step 3: 清理 `application.yml` 中的 Dify 配置 (保留 fallback 选项)**
+
+```yaml
+# application.yml — 保留 dify.* 配置但标记为降级通道
+dify:
+  base-url: http://dify.bianwoyou.cn/v1
+  api-key: ${DIFY_API_KEY:}
+  nutrition-api-key: ${DIFY_NUTRITION_API_KEY:}
+  tongue-api-key: ${DIFY_TONGUE_API_KEY:}
+  callback-secret: ${DIFY_CALLBACK_SECRET:}
+  # 注意: 以上配置仅作为 LangGraph 不可用时的降级通道
+  # 正常情况下应通过 python.enabled=true 启用 LangGraph
+```
+
+- [ ] **Step 4: 编译验证**
+
+```bash
+cd cfc-backend
+mvn clean compile -q
+# 预期: BUILD SUCCESS (可能有 @Deprecated 警告, 不影响编译)
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/DifySyncService.java \
+      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java \
+      cfc-backend/src/main/resources/application.yml
+git commit -m "chore(backend): mark DifySyncService as deprecated, clean up AIService"
+```
+
+---
+
+### Task 6: 运维文档
+
+**Files:**
+- Create: `cfc-langgraph/docs/deployment.md`
+- Create: `cfc-langgraph/docs/operations.md`
+
+- [ ] **Step 1: 创建 `docs/deployment.md`**
+
+```markdown
+# LangGraph Sidecar 部署文档
+
+## 前置依赖
+
+- Docker + Docker Compose (推荐)
+- 或 Python 3.11+ + pip (直接部署)
+- LLM API Key (DeepSeek / OpenAI 兼容)
+
+## 目录结构
+
+```
+cfc-langgraph/
+├── Dockerfile
+├── docker-compose.yml
+├── prometheus.yml
+├── .env.production          # 生产配置 (手动创建, 不上传 git)
+├── app/                     # 服务代码
+│   ├── main.py              # FastAPI 入口
+│   ├── config.py            # 配置管理
+│   ├── api/                 # HTTP 接口
+│   ├── agents/              # Agent 定义
+│   ├── graphs/              # LangGraph StateGraph
+│   ├── tools/               # Agent Tool
+│   ├── rag/                 # RAG Pipeline
+│   ├── memory/              # 三层记忆
+│   └── tasks/               # 定时任务
+├── data/
+│   └── chroma_db/           # ChromaDB 持久化 (自动创建)
+└── docs/
+    ├── deployment.md        # 本文件
+    └── operations.md        # 运维手册
+```
+
+## Docker Compose 部署
+
+```bash
+# 1. 创建生产配置
+cp .env.example .env.production
+# 编辑 .env.production 填入 LLM_API_KEY
+
+# 2. 构建并启动
+docker-compose up -d
+
+# 3. 验证
+curl http://localhost:9000/api/v1/health
+
+# 4. 查看日志
+docker-compose logs -f langgraph-svc
+```
+
+## 直接部署 (无 Docker)
+
+```bash
+# 1. 安装依赖
+pip install -e .
+
+# 2. 配置环境变量
+export LLM_API_KEY=sk-xxx
+export JAVA_BASE_URL=http://localhost:9082
+
+# 3. 启动
+gunicorn app.main:app \
+  --worker-class uvicorn.workers.UvicornWorker \
+  --bind 0.0.0.0:9000 \
+  --workers 2 \
+  --timeout 60 \
+  --access-logfile - \
+  --error-logfile -
+```
+
+## 环境变量说明
+
+| 变量 | 必填 | 说明 |
+|------|------|------|
+| LLM_API_KEY | 是 | LLM API Key |
+| LLM_BASE_URL | 否 | 默认 https://api.deepseek.com/v1 |
+| LLM_MODEL | 否 | 默认 deepseek-chat |
+| JAVA_BASE_URL | 是 | Java 后端地址 |
+| CHROMA_DB_PATH | 否 | ChromaDB 持久化路径, 默认 ./data/chroma_db |
+| LOG_LEVEL | 否 | 日志级别, 默认 info |
+| JSON_LOGS | 否 | 启用 JSON 日志格式, 默认 false |
+
+## 健康检查
+
+```
+GET /api/v1/health
+
+Response:
+{
+  "status": "ok",
+  "components": {
+    "chromadb": {"status": "ok", "path": "...", "exists": true},
+    "llm": {"status": "ok"},
+    "java_backend": {"status": "ok"}
+  }
+}
+```
+```
+
+- [ ] **Step 2: 创建 `docs/operations.md`**
+
+```markdown
+# LangGraph Sidecar 运维手册
+
+## 日常监控
+
+### 1. 健康检查
+
+生产环境建议配置 30 秒定时健康检查:
+
+```bash
+curl -f http://localhost:9000/api/v1/health
+```
+
+预期返回 `{"status":"ok"}`。组件降级时返回 `{"status":"degraded"}`。
+
+### 2. Prometheus 指标
+
+```
+GET /metrics
+
+关键指标:
+- agent_calls_total{agent_type="chat|recommend|analysis"}
+- agent_duration_seconds{agent_type="..."}
+- llm_calls_total{model="deepseek-chat",status="ok|error"}
+- llm_duration_seconds{model="..."}
+- kb_sync_duration_seconds        # 知识库同步耗时
+```
+
+### 3. 日志
+
+JSON 格式日志可直接接入 ELK / Loki:
+
+```json
+{"timestamp":"2026-07-20T10:00:00","level":"WARN","logger":"app.main","message":"SLOW_REQUEST: POST /api/v1/chat took 8.23s"}
+```
+
+## 常见问题
+
+### Python 服务无法启动
+
+```bash
+# 检查依赖
+pip list | grep -E "fastapi|langchain|langgraph"
+
+# 检查配置
+python -c "from app.config import settings; print(settings.llm_model)"
+
+# 检查端口占用
+netstat -ano | grep 9000
+```
+
+### LLM 调用失败
+
+1. 检查 `.env.production` 中的 `LLM_API_KEY` 是否有效
+2. 检查 `LLM_BASE_URL` 是否可访问
+3. 查看日志: `docker-compose logs langgraph-svc | grep llm_call`
+
+### ChromaDB 损坏
+
+```bash
+# 删除后重建 (知识库会自动同步)
+rm -rf data/chroma_db/
+docker-compose restart langgraph-svc
+
+# 或触发手动同步
+curl -X POST http://localhost:9000/api/v1/admin/kb-sync
+```
+
+### 知识库同步失败
+
+```bash
+# 检查 Java 后端是否可访问
+curl http://localhost:9082/health
+
+# 检查文章接口
+curl -X POST http://localhost:9082/api/article/updated-since \
+  -H "Content-Type: application/json" \
+  -d '{"since":"2026-01-01T00:00:00","status":"published"}'
+
+# 手动触发同步
+docker-compose exec langgraph-svc python -c "
+import asyncio
+from app.tasks.knowledge_sync import sync_knowledge_base
+asyncio.run(sync_knowledge_base())
+"
+```
+
+### Java 端回退 Dify
+
+如果 Python 服务异常, Java 会自动回退 Dify:
+
+```yaml
+# application.yml 检查配置
+python:
+  enabled: true
+  circuit-breaker:
+    failure-threshold: 3
+    reset-timeout-ms: 30000
+```
+
+熔断器打开时, Java 侧日志会输出 `AiGateway 熔断器已打开`, 等待 `reset-timeout-ms` 后自动半开重试。
+
+## 扩缩容
+
+LangGraph 服务是无状态的 (ChromaDB 在共享存储上):
+
+```yaml
+# docker-compose 增加副本数
+services:
+  langgraph-svc:
+    deploy:
+      replicas: 2
+```
+
+注意: ChromaDB 文件模式不支持并发写入, 多副本时知识库同步需加锁或切 PGVector。
+
+## 备份
+
+```bash
+# ChromaDB 数据
+tar czf chroma_backup_$(date +%Y%m%d).tar.gz data/chroma_db/
+```
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-langgraph/docs/
+git commit -m "docs(langgraph): deployment and operations manual"
+```
+
+---
+
+### Phase 4 自审清单
+
+- [ ] Python 连接池复用 (单例 + httpx limits)
+- [ ] 请求耗时中间件 + 慢查询告警
+- [ ] ChromaDB 批量写入 (每批 100 条)
+- [ ] Prometheus 指标: LLM/RAG/Agent/Java/知识库
+- [ ] 详细健康检查: ChromaDB/LLM/Java 组件状态
+- [ ] Agent 监控装饰器
+- [ ] 结构化 JSON 日志
+- [ ] 生产 Dockerfile (多阶段构建)
+- [ ] Docker Compose 编排 (Java + Python + Prometheus)
+- [ ] DifySyncService @Deprecated 标记
+- [ ] AIService 简化
+- [ ] 部署手册 (deployment.md)
+- [ ] 运维手册 (operations.md)

+ 495 - 0
docs/superpowers/specs/2026-07-20-langgraph-migration-design.md

@@ -0,0 +1,495 @@
+# LangGraph Sidecar 迁移设计
+
+**日期**: 2026-07-20
+**状态**: 草稿
+**版本**: 1.0
+
+---
+
+## 1. 概述
+
+将 cfc-backend 的 AI 能力从 Dify 迁移到自建的 Python LangGraph sidecar 服务。Java 端的 JDK 8 / Spring Boot 2.7.18 保持不变。
+
+### 目标
+
+- 消除对 Dify 平台的依赖(数据安全、黑盒 prompt、不可控的发布节奏)
+- 获得 LangGraph 的 Agent 编排、有状态持久化、HITL 能力
+- 团队完全掌控 AI 层的 prompt、模型、知识库
+
+### 非目标
+
+- 不改动 Java 端 JDK/Spring Boot 版本
+- 不改动 cfc 业务逻辑
+- 不引入额外的中间件(PG 可选, Chroma 文件模式优先)
+
+---
+
+## 2. 架构
+
+```
+┌──────────────────────────────────────────────────────────────────────┐
+│                          User (微信小程序 / Web)                       │
+└─────────────────────────────┬────────────────────────────────────────┘
+                              │ HTTP
+┌─────────────────────────────▼────────────────────────────────────────┐
+│                    cfc-backend (Java 8, Spring Boot 2.7.18)           │
+│                                                                       │
+│  ┌──────────────┐  ┌──────────────────┐  ┌───────────────────────┐   │
+│  │ 业务 Controller│  │ AI Gateway       │  │ Context Provider      │   │
+│  │ (不变)        │  │ AIService.java   │  │ AiContextService.java │   │
+│  │              │  │ (HTTP→Python)     │  │ (Java→Python 数据源)  │   │
+│  └──────────────┘  │ + Dify Fallback   │  └───────────────────────┘   │
+│                    └────────┬─────────┘                               │
+│                             │ HTTP (内网)                             │
+└─────────────────────────────┼────────────────────────────────────────┘
+                              │
+┌─────────────────────────────▼────────────────────────────────────────┐
+│                   langgraph-service (Python 3.11)                     │
+│                                                                       │
+│  ┌──────────────────────────────────────────────────────────────┐    │
+│  │                     FastAPI 网关 (port 9000)                   │    │
+│  │  POST /api/v1/chat      → ChatAgent                           │    │
+│  │  POST /api/v1/recommend → RecommendAgent                      │    │
+│  │  POST /api/v1/analyze   → AnalysisAgent                       │    │
+│  │  POST /api/v1/agent     → LangGraph Orchestrator              │    │
+│  │  POST /api/v1/tongue    → MultiModalAgent                     │    │
+│  └──────────────────────────────────────────────────────────────┘    │
+│                              │                                        │
+│  ┌──────────────────────────▼───────────────────────────────────┐    │
+│  │                    LangGraph Engine                           │    │
+│  │                                                               │    │
+│  │  ┌────────────────┐ ┌────────────────┐ ┌────────────────┐    │    │
+│  │  │ StateGraph      │ │ Checkpointer   │ │ Memory Manager │    │    │
+│  │  │ (条件路由/循环)  │ │ (PG / Memory)  │ │ (3层记忆)      │    │    │
+│  │  └────────────────┘ └────────────────┘ └────────────────┘    │    │
+│  └──────────────────────────────────────────────────────────────┘    │
+│                              │                                        │
+│  ┌──────────────────────────▼───────────────────────────────────┐    │
+│  │                    RAG Pipeline                                │    │
+│  │  ┌────────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐   │    │
+│  │  │Document    │→│Text      │→│Embedding │→│VectorStore│   │    │
+│  │  │Loader      │  │Splitter  │  │          │  │(Chroma)  │   │    │
+│  │  └────────────┘  └──────────┘  └──────────┘  └──────────┘   │    │
+│  │                                    │                          │    │
+│  │  ┌─────────────────────────────────▼──────────────────────┐   │    │
+│  │  │ EnsembleRetriever (向量 + BM25) → Rerank → Compressor  │   │    │
+│  │  └────────────────────────────────────────────────────────┘   │    │
+│  └──────────────────────────────────────────────────────────────┘    │
+│                              │                                        │
+│  ┌──────────────────────────▼───────────────────────────────────┐    │
+│  │                    Agent 工具层                                │    │
+│  │  @tool search_product()    — HTTP → Java / 直连 MySQL         │    │
+│  │  @tool get_health_report() — HTTP → Java AiContextService     │    │
+│  │  @tool get_emotion_data()  — HTTP → Java EmotionCheckin      │    │
+│  │  @tool search_knowledge()  — 本地 RAG 检索                     │    │
+│  └──────────────────────────────────────────────────────────────┘    │
+└──────────────────────────────────────────────────────────────────────┘
+                              │
+                              ▼
+┌──────────────────────────────────────────────────────────────────────┐
+│                           LLM API                                     │
+│  DeepSeek-V3 (聊天/分析) / Qwen2.5 (推荐) / Embedding API            │
+└──────────────────────────────────────────────────────────────────────┘
+```
+
+### 关键设计决策
+
+1. **Tool 通过 HTTP 调 Java 而非直连 DB** — Python 不直连 MySQL,数据主权在 Java 侧,安全可控
+2. **ChromaDB 文件模式** — 无需额外容器,数据存在 `data/chroma_db/`,打包备份方便
+3. **Checkpointer 先用 MemorySaver** — Phase 1-2 不依赖 PostgreSQL,后续按需升级
+4. **与现有 Dify 体系共存** — 配置开关控制流量切分,异常自动 fallback
+
+---
+
+## 3. 项目结构
+
+```
+cfc-langgraph/                          # 新建 Python 项目
+├── pyproject.toml                      # 依赖管理 (pip / poetry)
+├── Dockerfile                          # 容器构建
+├── docker-compose.yml                  # 编排 (可选)
+├── .env.example                        # 环境变量模板
+│
+├── app/
+│   ├── __init__.py
+│   ├── main.py                         # FastAPI 入口 + 路由注册
+│   ├── config.py                       # 配置管理
+│   │
+│   ├── api/                            # HTTP 接口层
+│   │   ├── __init__.py
+│   │   ├── chat.py                     # POST /api/v1/chat
+│   │   ├── recommend.py                # POST /api/v1/recommend
+│   │   ├── analyze.py                  # POST /api/v1/analyze
+│   │   └── health.py                   # GET /api/v1/health
+│   │
+│   ├── agents/                         # LangGraph Agent 定义
+│   │   ├── __init__.py
+│   │   ├── chat_agent.py               # 家庭聊天 Agent
+│   │   ├── recommend_agent.py          # 推荐 Agent
+│   │   ├── analysis_agent.py           # 报告解读 Agent
+│   │   ├── task_agent.py               # 任务解析 Agent
+│   │   └── orchestrator.py             # 主管 Agent (多Agent编排)
+│   │
+│   ├── graphs/                         # LangGraph StateGraph 定义
+│   │   ├── __init__.py
+│   │   ├── chat_graph.py               # 聊天流程图
+│   │   ├── recommend_graph.py          # 推荐流程图
+│   │   └── analysis_graph.py           # 分析流程图
+│   │
+│   ├── tools/                          # Agent Tool 定义
+│   │   ├── __init__.py
+│   │   ├── product_tools.py            # 商品/活动/文章检索
+│   │   ├── report_tools.py             # 健康报告查询
+│   │   ├── emotion_tools.py            # 情绪数据查询
+│   │   ├── user_tools.py               # 用户信息查询
+│   │   └── knowledge_tools.py          # 知识库 RAG 检索
+│   │
+│   ├── rag/                            # RAG Pipeline
+│   │   ├── __init__.py
+│   │   ├── loader.py                   # 文档加载器
+│   │   ├── splitter.py                 # 文本分块策略
+│   │   ├── embeddings.py              # Embedding 封装
+│   │   └── retriever.py               # 混合检索 + 重排序
+│   │
+│   ├── memory/                         # 记忆管理
+│   │   ├── __init__.py
+│   │   ├── store.py                    # 记忆存储接口
+│   │   └── schemas.py                  # 记忆数据结构
+│   │
+│   └── models/                         # Pydantic 数据模型
+│       ├── __init__.py
+│       ├── chat.py                     # 聊天请求/响应
+│       ├── recommend.py                # 推荐请求/响应
+│       └── common.py                   # 通用模型(用户/上下文)
+│
+├── data/                               # 运行时数据 (gitignore)
+│   ├── chroma_db/                      # ChromaDB 持久化文件
+│   └── logs/                           # 日志
+│
+└── tests/                              # 测试
+    ├── conftest.py
+    ├── test_agents/
+    ├── test_tools/
+    └── test_rag/
+```
+
+---
+
+## 4. API 设计
+
+### 4.1 聊天
+
+```http
+POST /api/v1/chat
+Content-Type: application/json
+
+{
+    "query": "小明最近肠胃不好,有什么建议?",
+    "user_id": 12345,
+    "conversation_id": "conv_abc",     // 空串=新会话
+    "context": {                        // Java 侧传来的业务上下文
+        "child_id": 67890,
+        "report_id": 1024,
+        "mascot_code": "dolphin"
+    }
+}
+
+Response:
+{
+    "answer": "根据小明的健康报告...",
+    "conversation_id": "conv_abc",
+    "sources": [
+        {"type": "knowledge", "title": "儿童肠胃调理指南", "score": 0.92},
+        {"type": "tool", "name": "get_health_report", "result": "..."}
+    ],
+    "tasks": [                          // 自动提取的任务
+        {"title": "每天喝一杯益生菌", "dimension": "身", "points": 10}
+    ],
+    "trace_id": "trc_xxx"              // LangSmith Trace ID
+}
+```
+
+### 4.2 推荐
+
+```http
+POST /api/v1/recommend
+Content-Type: application/json
+
+{
+    "user_id": 12345,
+    "tags": ["肠胃", "益生菌", "消化"],
+    "types": ["product", "activity"],   // 不传=全类型
+    "limit": 5,
+    "context": { "family_id": 333 }
+}
+
+Response:
+{
+    "items": [
+        {
+            "type": "product",
+            "id": 201,
+            "name": "益生菌粉剂",
+            "score": 0.95,
+            "reason": "适合肠胃调理"
+        }
+    ],
+    "source": "hybrid"                  // sql / vector / hybrid
+}
+```
+
+### 4.3 健康
+
+```http
+POST /api/v1/analyze
+Content-Type: application/json
+
+{
+    "report_id": 1024,
+    "user_id": 12345,
+    "focus": "nutrition"               // 可选: nutrition/gut/chronic/overall
+}
+
+Response:
+{
+    "summary": "...",
+    "findings": [...],
+    "recommendations": [...]
+}
+```
+
+---
+
+## 5. 记忆设计
+
+```
+┌──────────────────────────────────────────────────────────────┐
+│                       Memory Manager                          │
+├──────────────────────────────────────────────────────────────┤
+│  Layer 1: 工作记忆 (BufferMemory)                             │
+│  ├─ 当前会话最后 20 轮 (内存)                                 │
+│  └─ 超出 → ConversationSummaryBufferMemory 自动摘要           │
+│                                                              │
+│  Layer 2: 长期事实 (EntityMemory)                             │
+│  ├─ 自动提取实体: 偏好 / 健康指标 / 家庭成员                  │
+│  ├─ 跨会话跟踪, 按置信度排序                                  │
+│  └─ 替代现有 AiUserFact (Java 侧可同步)                      │
+│                                                              │
+│  Layer 3: 语义记忆 (VectorStoreRetrieverMemory)               │
+│  ├─ 每次对话结束后自动向量化存储                              │
+│  ├─ 新对话时召回前 3 条最相似的历史片段                      │
+│  └─ 实现"我记得你上次说…"的能力                               │
+└──────────────────────────────────────────────────────────────┘
+
+同步策略:
+  Python 是记忆的主写入端
+  Java 侧的 ChatMirrorService 作为只读镜像 (同步 conversation + message)
+  AiUserFact / AiConversationSummary → 随迁移推进逐渐废弃
+```
+
+---
+
+## 6. 迁移阶段
+
+### Phase 1 (第1-2周): 基建 + 推荐 Agent
+
+**目标**: Python 服务跑起来, 第一个 Agent 上线
+
+```
+Java 侧修改:
+  ├── 新增 AiGateway.java (HTTP → Python, fallback → Dify)
+  │   ├── 连接池: 5连接, 超时 15s
+  │   ├── 熔断: 3次失败→停30s
+  │   └── 配置开关: python.enabled=true/false
+  └── RecommendationController.search()
+      先调 Python → 超时/异常 → fallback 现有 Java SQL 搜索
+
+Python 侧:
+  ├── FastAPI 骨架 + 配置管理
+  ├── RecommendAgent (最简单的单个 Agent, 无图)
+  │   ├── @tool: search_product, search_article
+  │   └── 调用 LLM 返回推荐理由
+  └── ChromaDB 初始化 + 知识库文章加载
+
+交付物:
+  ├── Python 服务可启动, health check 通过
+  ├── /api/v1/recommend 可返回结果
+  └── Java 侧灰度开关: 5% 流量走 Python
+```
+
+### Phase 2 (第3-4周): 聊天 Agent + RAG
+
+**目标**: 替代 Dify 核心聊天能力
+
+```
+Python 侧:
+  ├── ChatAgent (LangGraph StateGraph)
+  │   ├── Node: classify_intent → 意图分类
+  │   ├── Node: load_context → 调 Java Context API
+  │   ├── Node: llm_call → LLM + Tool
+  │   ├── Node: parse_tasks → [TASK] 提取
+  │   └── Edge: 条件路由(chat/analysis/recommend)
+  ├── Memory 接入
+  │   ├── SummaryBufferMemory
+  │   └── VectorStoreRetrieverMemory
+  └── RAG Pipeline
+      ├── 混合检索 (向量 + BM25)
+      └── 重排序 + 压缩
+
+Java 侧:
+  ├── AIService.sendMessage() → 优先调 Python
+  ├── AiContextService 改为 HTTP 数据接口 (Python 调)
+  └── AiUserFact / AiConversationSummary 设为只读
+
+交付物:
+  ├── 家庭聊天通过 Python 完成
+  ├── 记忆层可追溯
+  └── 灰度 30% 流量
+```
+
+### Phase 3 (第5-6周): 高级 Agent + 知识库管理
+
+**目标**: 剩下场景迁移 + 知识库自动化
+
+```
+Python 侧:
+  ├── AnalysisAgent (报告解读)
+  │   ├── @tool: get_report_detail, get_survey_data
+  │   └── LangGraph 图: 分析→发现→建议
+  ├── MultiModalAgent (舌诊, 保留 Dify Workflow fallback)
+  ├── KnowledgePipeline (自动化知识库更新)
+  │   ├── 定时任务: 从 Java 拉文章→分块→向量化
+  │   └── 增量更新: 只处理有变动的文档
+  └── LangSmith Trace 接入 (调试用)
+
+Java 侧:
+  ├── DifySyncService.java 标记废弃
+  └── 知识库管理接口调整为 Python 侧触发
+
+交付物:
+  ├── 全场景 AI 能力由 Python 覆盖
+  ├── 知识库自动同步
+  └── 灰度 100% (Dify 降级为冷备)
+```
+
+### Phase 4 (第7-8周): 收尾
+
+**目标**: 运维完善, 代码清理
+
+```
+Python 侧:
+  ├── 性能调优 (连接池、缓存、异步)
+  ├── 监控接入 (指标暴露 / 日志聚合)
+  ├── 部署文档 + 运维手册
+  └── Docker Compose 编排
+
+Java 侧:
+  ├── 清理废弃代码 (Dify 相关)
+  ├── 调整 ChatMirrorService 同步策略
+  └── 移除 Dify 配置项
+
+交付物:
+  ├── 运维手册完成
+  ├── 老代码清理 PR
+  └── 架构文档更新
+```
+
+---
+
+## 7. 部署方案
+
+### 方案 A: Docker Compose (推荐)
+
+```yaml
+version: "3.8"
+services:
+  cfc-backend:
+    build: ./cfc-backend
+    ports: "9082:9082"
+    networks: [app-net]
+
+  langgraph-svc:
+    build: ./cfc-langgraph
+    ports: "9000:9000"
+    environment:
+      - LLM_API_KEY=${LLM_API_KEY}
+      - LLM_BASE_URL=${LLM_BASE_URL}
+      - JAVA_CONTEXT_URL=http://cfc-backend:9082/api/ai/context
+      - CHROMA_DB_PATH=/data/chroma_db
+      - LANGCHAIN_TRACING_V2=true
+      - LANGCHAIN_API_KEY=${LANGCHAIN_API_KEY}
+    volumes:
+      - langgraph-data:/data
+    networks: [app-net]
+    restart: unless-stopped
+
+networks:
+  app-net:
+
+volumes:
+  langgraph-data:
+```
+
+### 方案 B: 直接 systemd + 文件模式
+
+```
+Python:  systemd 管理, Python 3.11 venv
+Chroma:  文件模式, 目录 /var/lib/cfc-langgraph/chroma_db
+日志:    文件 → rsyslog / 直接 stdout
+部署:    git pull → pip install -r requirements → systemctl restart
+```
+
+### 资源需求
+
+| 资源 | 最低 | 推荐 |
+|------|------|------|
+| CPU | 0.5 核 | 1 核 |
+| 内存 | 256MB | 512MB |
+| 磁盘 | 1GB (代码+Chroma) | 5GB (含日志) |
+
+---
+
+## 8. 风险与缓解
+
+| 风险 | 概率 | 影响 | 缓解措施 |
+|------|------|------|---------|
+| Python 服务不稳定 | 中 | 高 | Phase 1 灰度 5% + Dify Fallback + 熔断 |
+| LangGraph 学习成本 | 高 | 中 | Phase 1 从最简 Agent 开始, 不追逐复杂图结构 |
+| 记忆数据迁移丢失 | 低 | 中 | Phase 2 前 Java 侧保持双写, 验证对齐后再切 |
+| LLM API 费用增加 | 中 | 低 | Agent 多轮多约 10% token, 对比 Dify 调用成本基本持平 |
+| ChromaDB 性能瓶颈 | 低 | 中 | 10 万条内单机足够, 超出后切 PGVector 或 Milvus |
+
+---
+
+## 9. 目录清单
+
+```
+cfc-langgraph/
+├── pyproject.toml
+├── Dockerfile
+├── docker-compose.yml
+├── .env.example
+├── .gitignore
+├── app/
+│   ├── __init__.py
+│   ├── main.py
+│   ├── config.py
+│   ├── api/
+│   ├── agents/
+│   ├── graphs/
+│   ├── tools/
+│   ├── rag/
+│   ├── memory/
+│   └── models/
+├── data/
+└── tests/
+```
+
+```
+cfc-backend (修改清单):
+  src/main/java/.../service/AiGateway.java        (新增)
+  src/main/java/.../service/AIService.java         (改造: 加 Python 路由)
+  src/main/java/.../service/AiContextService.java  (改造: 加 HTTP API)
+  src/main/java/.../controller/ai/AIChatController.java (改造: 调 AiGateway)
+  application.yml                                  (加 python.* 配置)
+```

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1491 - 3797
docs/参考资料/501999942-某人.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1481 - 3789
docs/参考资料/530010234-侯.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 2
docs/参考资料/547982403.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 2
docs/参考资料/556168709-mi.json


binární
docs/参考资料/556168709-mi.pdf


+ 37 - 20
docs/参考资料/extract_full_report_v5.py

@@ -1257,35 +1257,52 @@ def extract_interpretation(full_text, indicator_name, pattern_type='nutrition'):
 def extract_overall_assessment(full_text):
     """提取整体评估描述(P0)
 
-    包括:肠道年龄评估、健康总分解读、菌群健康状况评估
+    包括:健康总分解读、肠道年龄评估
     """
     assessments = {}
 
-    # 肠道年龄评估 - 找"肠道菌群与众多的健康问题息息相关"附近的内容
-    for keyword in ['肠道菌群与众多的健康问题息息相关', '肠道菌群与健康问题']:
+    # 健康总分解读 - 找"健康总分\n总分说明"(page 11 详细解读,非首页概览)
+    for keyword in ['健康总分\n总分说明', '健康总分 总分说明', '总分说明']:
         idx = full_text.find(keyword)
         if idx != -1:
-            end_markers = ['健康总分', '疾病风险评估', '主要消化道致病菌', '指标范围']
-            end_pos = len(full_text)
-            for m in end_markers:
-                ei = full_text.find(m, idx)
-                if ei != -1 and ei < end_pos:
-                    end_pos = ei
+            # 从"健康总分"开始(回退到"健康总分"的位置)
+            total_idx = full_text.rfind('健康总分', 0, idx + 50)
+            if total_idx != -1:
+                idx = total_idx
+            # 结束于"肠道年龄"(下一个大段)
+            end_pos = full_text.find('\n肠道年龄', idx)
+            if end_pos == -1:
+                end_pos = full_text.find('健康总分', idx + 50)  # 避免找到同一处
+                if end_pos != -1:
+                    pass  # 使用这个 end_pos
+                else:
+                    end_pos = idx + 2000
             text = full_text[idx:end_pos].strip()
-            text = re.sub(r'\d+/\d+', '', text).strip()[:500]
+            # 页码标记去除,保留内容
+            text = re.sub(r'\d+/\d+', '', text).strip()
             if len(text) > 50:
-                assessments['肠道年龄评估'] = text
+                assessments['健康总分解读'] = text
                 break
 
-    # 健康总分解读
-    idx = full_text.find('健康总分')
-    if idx != -1:
-        # 找"健康总分"后到"肠道菌群"之间的文本
-        end_pos = idx + 800
-        text = full_text[idx:end_pos].strip()
-        text = re.sub(r'\d+/\d+', '', text).strip()[:300]
-        if len(text) > 30:
-            assessments['健康总分解读'] = text
+    # 肠道年龄评估:包含"肠道年龄"(page 12)+ "菌群评估"(pages 13-14)
+    # 起始:"肠道年龄\n真实年龄" 或 "肠道菌群与众多的健康问题息息相关"
+    start = full_text.find('肠道年龄\n真实年龄')
+    if start == -1:
+        start = full_text.find('肠道年龄\n真实年龄:')
+    if start == -1:
+        start = full_text.find('肠道菌群与众多的健康问题息息相关')
+    if start != -1:
+        # 结束于下一个大段 "菌群构成层级" 或 "菌群详细构成"
+        end_markers = ['\n菌群构成层级', '\n菌群详细构成', '\n菌群检出详细列表']
+        end_pos = len(full_text)
+        for m in end_markers:
+            ei = full_text.find(m, start)
+            if ei != -1 and ei < end_pos:
+                end_pos = ei
+        text = full_text[start:end_pos].strip()
+        text = re.sub(r'\d+/\d+', '', text).strip()
+        if len(text) > 50:
+            assessments['肠道年龄评估'] = text
 
     return assessments
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1719 - 3014
docs/参考资料/儿童示例.json


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 1 - 2
docs/参考资料/朱评估报告.json


binární
web/img/ref-13.png


binární
web/img/ref-16.png


binární
web/img/ref-18.png


binární
web/img/ref-19.png


Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů