# 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`** ```python from .store import MemoryManager ``` - [x] **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] ``` - [x] **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` 枚举 - [x] **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 ``` - [x] **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"}` - [x] **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 ``` - [x] **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 ``` - [x] **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` (注册路由) - [x] **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", []), ) ``` - [x] **Step 2: 修改 `app/main.py` 注册路由** ```python from app.api import health, recommend, chat # 新增 chat app.include_router(chat.router) # 新增 ``` - [x] **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) - [x] **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] ``` - [x] **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`) - [x] **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> getContext(@RequestBody Map params) { String userIdStr = (String) params.get("user_id"); String intentType = (String) params.get("intent_type"); Map contextParams = (Map) params.get("params"); if (userIdStr == null) { return Result.error("缺少参数: user_id"); } Long userId = Long.valueOf(userIdStr); Map ctx = aiContextService.getContext(intentType, userId, contextParams); return Result.success(ctx); } } ``` - [x] **Step 2: 编译验证** ```bash cd cfc-backend mvn clean compile -q ``` - [x] **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 方法) - [x] **Step 1: AiGateway 新增 `chat()` 方法** ```java // 在 AiGateway.java 中追加 /** * 调用 Python ChatAgent, 失败时返回 null */ public Map chat(String query, Long userId, String conversationId, Map 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 entity = new HttpEntity<>(body.toString(), createJsonHeaders()); String url = baseUrl + "/api/v1/chat"; ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { JsonNode root = objectMapper.readTree(response.getBody()); Map 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> parseTasks(JsonNode tasksNode) { List> tasks = new ArrayList<>(); if (tasksNode != null && tasksNode.isArray()) { for (JsonNode task : tasksNode) { Map 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 优先路由** ```java // 在 AIService 中注入 AiGateway @Resource private AiGateway aiGateway; /** * 发送聊天消息: 优先 Python LangGraph, 失败回退 Dify */ public Map sendMessage(String query, String userId, String conversationId, Map inputs) { Long uid = Long.valueOf(userId); // 1. 尝试 Python LangGraph Map 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 sendMessageToDify(String query, String userId, String conversationId, Map inputs) { String url = difyBaseUrl + "/chat-messages"; Map 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> entity = new HttpEntity<>(body, authHeaders()); ResponseEntity resp = restTemplate.postForEntity(url, entity, Map.class); Map 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: 编译验证** ```bash cd cfc-backend mvn clean compile -q ``` - [x] **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` - [x] **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 ``` - [x] **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" ``` - [x] **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) ``` - [x] **Step 4: 运行测试** ```bash cd cfc-langgraph pytest tests/ -v # 预期: test_intent 通过 (需要 LLM API Key) # test_memory 通过 # test_chat 通过 (需要 Java 后端 + LLM API Key) ``` - [x] **Step 5: Commit** ```bash git add cfc-langgraph/tests/ git commit -m "test(langgraph): Phase 2 tests for chat, memory, intent" ``` --- ### Phase 2 自审清单 - [x] 三层记忆模块: 工作记忆/长期事实/语义向量召回 - [x] 意图分类器: 6 类意图, 失败默认 chat - [x] ChatAgent StateGraph: 分类→上下文→LLM→任务提取→保存记忆 - [x] Chat API: `POST /api/v1/chat` 完整链路 - [x] RAG: 混合检索 + LLM 压缩重排序 - [x] Java ContextApiController: Python 可调用 - [x] Java AIService: Python 优先 + Dify 回退