Przeglądaj źródła

docs: LangGraph Phase 2-4 implementation plans

asus 3 miesięcy temu
rodzic
commit
935296b346

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