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