2026-07-20-langgraph-phase2.md 31 KB

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)

  • [x] Step 1: 创建 app/memory/__init__.py

    from .store import MemoryManager
    
  • [x] Step 2: 创建 app/memory/store.py

    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]
    
  • [x] Step 3: Commit

    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 枚举

  • [x] Step 1: 创建 app/agents/intent_classifier.py

    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
    
  • [x] Step 2: Commit

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

  • [x] Step 1: 创建 app/agents/chat_agent.py

    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
    
  • [x] Step 2: 创建 app/graphs/chat_graph.py

    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
    
  • [x] Step 3: Commit

    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 (注册路由)

  • [x] Step 1: 创建 app/api/chat.py

    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", []),
    )
    
  • [x] Step 2: 修改 app/main.py 注册路由

    from app.api import health, recommend, chat  # 新增 chat
    
    app.include_router(chat.router)  # 新增
    
  • [x] Step 3: Commit

    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)

  • [x] Step 1: 重写 app/rag/retriever.py

    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]
    
  • [x] Step 2: Commit

    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)

  • [x] Step 1: 创建 ContextApiController.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);
    }
    }
    
  • [x] Step 2: 编译验证

    cd cfc-backend
    mvn clean compile -q
    
  • [x] Step 3: Commit

    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 方法)

  • [x] Step 1: AiGateway 新增 chat() 方法

    // 在 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;
    }
    
  • [x] Step 2: 修改 AIService.sendMessage(), 增加 Python 优先路由

    // 在 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;
    }
    
  • [x] Step 3: 编译验证

    cd cfc-backend
    mvn clean compile -q
    
  • [x] Step 4: Commit

    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

  • [x] Step 1: 创建 tests/test_intent.py

    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
    
  • [x] Step 2: 创建 tests/test_memory.py

    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"
    
  • [x] Step 3: 创建 tests/test_chat.py

    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)
    
  • [x] Step 4: 运行测试

    cd cfc-langgraph
    pytest tests/ -v
    # 预期: test_intent 通过 (需要 LLM API Key)
    # test_memory 通过
    # test_chat 通过 (需要 Java 后端 + LLM API Key)
    
  • [x] Step 5: Commit

    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 回退