Browse Source

chore: auto bump version and changelog [skip ci]

iwt 2 months ago
parent
commit
e93392b303
45 changed files with 1777 additions and 69 deletions
  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. 30 10
      cfc-backend/src/main/java/com/etotem/cfc/service/AIService.java
  4. 58 0
      cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
  5. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DifySyncService.java
  6. 25 3
      cfc-langgraph/Dockerfile
  7. 25 0
      cfc-langgraph/app/agents/analysis_agent.py
  8. 35 0
      cfc-langgraph/app/agents/chat_agent.py
  9. 57 0
      cfc-langgraph/app/agents/intent_classifier.py
  10. 79 0
      cfc-langgraph/app/agents/multimodal_agent.py
  11. 45 0
      cfc-langgraph/app/api/analyze.py
  12. 58 0
      cfc-langgraph/app/api/chat.py
  13. 4 2
      cfc-langgraph/app/api/recommend.py
  14. 45 0
      cfc-langgraph/app/api/tongue.py
  15. 4 0
      cfc-langgraph/app/config.py
  16. 96 0
      cfc-langgraph/app/graphs/analysis_graph.py
  17. 169 0
      cfc-langgraph/app/graphs/chat_graph.py
  18. 2 0
      cfc-langgraph/app/graphs/recommend_graph.py
  19. 44 0
      cfc-langgraph/app/log_config.py
  20. 56 3
      cfc-langgraph/app/main.py
  21. 1 0
      cfc-langgraph/app/memory/__init__.py
  22. 89 0
      cfc-langgraph/app/memory/store.py
  23. 137 0
      cfc-langgraph/app/monitoring.py
  24. 49 0
      cfc-langgraph/app/rag/loader.py
  25. 30 16
      cfc-langgraph/app/rag/retriever.py
  26. 21 0
      cfc-langgraph/app/rag/splitter.py
  27. 0 0
      cfc-langgraph/app/tasks/__init__.py
  28. 77 0
      cfc-langgraph/app/tasks/knowledge_sync.py
  29. 17 10
      cfc-langgraph/app/tools/java_client.py
  30. 63 0
      cfc-langgraph/app/tools/report_tools.py
  31. 47 4
      cfc-langgraph/docker-compose.yml
  32. 97 0
      cfc-langgraph/docs/deployment.md
  33. 121 0
      cfc-langgraph/docs/operations.md
  34. 9 0
      cfc-langgraph/prometheus.yml
  35. 1 0
      cfc-langgraph/pyproject.toml
  36. 30 0
      cfc-langgraph/tests/test_analyze.py
  37. 18 0
      cfc-langgraph/tests/test_chat.py
  38. 37 0
      cfc-langgraph/tests/test_intent.py
  39. 25 0
      cfc-langgraph/tests/test_memory.py
  40. 12 0
      cfc-langgraph/tests/test_tongue.py
  41. 1 1
      cfc-web/.last_build_commit
  42. 2 2
      cfc-web/package-lock.json
  43. 1 1
      cfc-web/package.json
  44. 6 0
      cfc-web/public/CHANGELOG-v1.0.md
  45. 7 1
      cfc-web/public/CHANGELOG.md

+ 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);
+    }
+}

+ 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;
     }

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

@@ -137,6 +137,64 @@ public class AiGateway {
         }
     }
 
+    /**
+     * 调用 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);

+ 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 {
 

+ 25 - 3
cfc-langgraph/Dockerfile

@@ -1,13 +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 pyproject.toml .
-RUN pip install --no-cache-dir -e .
+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 ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "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", "-"]

+ 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": "平和质"},
+            ],
+        }

+ 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,
+    )

+ 4 - 2
cfc-langgraph/app/api/recommend.py

@@ -1,3 +1,4 @@
+import uuid
 from fastapi import APIRouter
 from app.models.recommend import RecommendRequest, RecommendResponse, RecommendItem
 from app.graphs.recommend_graph import RecommendAgent
@@ -19,6 +20,7 @@ def get_agent() -> RecommendAgent:
 @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)
@@ -34,7 +36,7 @@ async def recommend(req: RecommendRequest):
                 reason=item.get("reason", ""),
             ))
 
-        return RecommendResponse(items=items, source="agent")
+        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")
+        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)

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

@@ -27,6 +27,10 @@ class Settings(BaseSettings):
     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"
 

+ 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

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

@@ -2,6 +2,7 @@ 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
 
@@ -46,6 +47,7 @@ class RecommendAgent:
         ]
         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, 构造搜索关键词

+ 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)

+ 56 - 3
cfc-langgraph/app/main.py

@@ -1,18 +1,71 @@
-from fastapi import FastAPI
-from app.api import health, recommend
+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
 
-app = FastAPI(title="cfc-langgraph", version="0.1.0")
+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():

+ 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]

+ 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

+ 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

+ 30 - 16
cfc-langgraph/app/rag/retriever.py

@@ -3,6 +3,7 @@ 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
@@ -13,7 +14,7 @@ logger = logging.getLogger(__name__)
 
 
 class RagRetriever:
-    """混合检索器: ChromaDB 向量 + BM25 关键词 + 可选 LLM 压缩"""
+    """升级版混合检索器: 向量 + BM25 + LLM 压缩重排序"""
 
     def __init__(self, collection_name: str = "cfc_knowledge"):
         embeddings = get_embeddings()
@@ -23,11 +24,11 @@ class RagRetriever:
             persist_directory=settings.chroma_db_path,
         )
         self.java_client = JavaClient()
-        self._bm25_retriever = None
-        self._bm25_texts = []
+        self._bm25_retriever: Optional[BM25Retriever] = None
+        self._bm25_texts: list[str] = []
 
     async def initialize(self):
-        """从 Java 侧拉取知识库数据, 构建 BM25 索引"""
+        """从 Java 侧拉取知识库, 构建 BM25 索引"""
         try:
             articles = await self.java_client.get_published_articles()
             self._bm25_texts = [
@@ -36,60 +37,73 @@ class RagRetriever:
             ]
             if self._bm25_texts:
                 self._bm25_retriever = BM25Retriever.from_texts(
-                    self._bm25_texts, metadatas=articles
+                    self._bm25_texts,
+                    metadatas=articles,
                 )
                 logger.info("BM25 索引就绪: %d 条", len(self._bm25_texts))
         except Exception as e:
-            logger.warning("BM25 初始化失败(不影响向量检索): %s", e)
+            logger.warning("BM25 初始化失败: %s", e)
 
     async def retrieve(
         self,
         query: str,
         filters: Optional[dict] = None,
         k: int = 5,
-        use_compression: bool = False,
+        use_compression: bool = True,
     ) -> list[dict]:
-        """混合检索, 返回 [{content, metadata, score}]"""
+        """混合检索 + 可选 LLM 压缩重排序"""
         retrievers = []
 
-        # 向量检索
+        # 1. 向量检索 (多取一些方便后续 ensemble 排序)
         vector_retriever = self.vectorstore.as_retriever(
-            search_kwargs={"k": k, "filter": filters}
+            search_kwargs={"k": k * 2, "filter": filters},
         )
         retrievers.append(vector_retriever)
 
-        # BM25 检索
+        # 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]
+                retrievers=retrievers,
+                weights=[0.6, 0.4],
             )
             docs = await ensemble.ainvoke(query)
 
-        # 可选: LLM 压缩去噪
+        # 3. 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,
+                temperature=0,
             )
             compressor = LLMChainExtractor.from_llm(llm)
             compression_retriever = ContextualCompressionRetriever(
                 base_compressor=compressor,
-                base_retriever=self.vectorstore.as_retriever(),
+                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": getattr(doc, "metadata", {}).get("score", 0),
+                "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))

+ 17 - 10
cfc-langgraph/app/tools/java_client.py

@@ -7,17 +7,28 @@ logger = logging.getLogger(__name__)
 
 
 class JavaClient:
-    """Java 后端 HTTP 客户端 (所有 Python→Java 通信的单一入口)"""
+    """Java 后端 HTTP 客户端 (单例, 复用连接池)"""
 
-    def __init__(self):
-        self.base_url = settings.java_base_url
-        self._client: Optional[httpx.AsyncClient] = None
+    _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,
+                base_url=self._base_url,
                 timeout=httpx.Timeout(10.0, connect=3.0),
+                limits=limits,
             )
         return self._client
 
@@ -25,9 +36,9 @@ class JavaClient:
         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()
@@ -36,7 +47,6 @@ class JavaClient:
         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
@@ -47,7 +57,6 @@ class JavaClient:
         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
@@ -58,7 +67,6 @@ class JavaClient:
         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
@@ -69,7 +77,6 @@ class JavaClient:
         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),

+ 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 "{}"

+ 47 - 4
cfc-langgraph/docker-compose.yml

@@ -1,6 +1,25 @@
 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: .
@@ -8,15 +27,39 @@ services:
     ports:
       - "9000:9000"
     env_file:
-      - .env
+      - .env.production
     environment:
-      - JAVA_BASE_URL=http://host.docker.internal:9082
+      - 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
-      - ./app:/app/app  # 开发模式: 热重载
-    command: uvicorn app.main:app --host 0.0.0.0 --port 9000 --reload
+    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

+ 1 - 0
cfc-langgraph/pyproject.toml

@@ -16,6 +16,7 @@ dependencies = [
     "chromadb>=0.6",
     "langchain-chroma>=0.2",
     "python-multipart>=0.0.20",
+    "prometheus-client>=0.21",
 ]
 
 [project.optional-dependencies]

+ 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"

+ 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 @@
-935296b34616d245b8c0c5e6b5e07f00ea554070
+14f0978ee4f9fff98e0e87820e697f41227d5ef1

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

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.443",
+  "version": "1.0.444",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.443",
+      "version": "1.0.444",
       "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.444",
+  "version": "1.0.445",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

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

@@ -4,6 +4,12 @@
 
 ---
 
+## v1.0.445 (2026-07-20)
+
+### Bug 修复
+- add fallback when navigateBack has no pages in stack
+
+
 ## v1.0.444 (2026-07-20)
 
 ### 文档

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.444
+> 当前版本: v1.0.445
 
 ## 历史版本
 
@@ -8,6 +8,12 @@
 
 ---
 
+## v1.0.445 (2026-07-20)
+
+### Bug 修复
+- add fallback when navigateBack has no pages in stack
+
+
 ## v1.0.444 (2026-07-20)
 
 ### 文档