Просмотр исходного кода

Merge remote-tracking branch 'origin/cfclub' into cfclub

李宇 3 месяцев назад
Родитель
Сommit
f783202308

+ 7 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/PromotionController.java

@@ -1,9 +1,11 @@
 package com.etotem.cfc.controller;
 
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyCommission;
+import com.etotem.cfc.entity.PromotionBinding;
 import com.etotem.cfc.service.FamilyCommissionService;
 import org.springframework.web.bind.annotation.*;
-
 import javax.annotation.Resource;
 import java.util.List;
 import java.util.Map;
@@ -16,14 +18,14 @@ public class PromotionController {
     private FamilyCommissionService familyCommissionService;
 
     @PostMapping("/bind")
-    public Result<Map<String, Object>> bind(@RequestBody Map<String, Object> params) {
+    public Result<PromotionBinding> bind(@RequestBody Map<String, Object> params) {
         String inviteCode = params.get("inviteCode") != null ? params.get("inviteCode").toString() : null;
         Long promoterFamilyId = params.get("promoterFamilyId") != null
                 ? Long.valueOf(params.get("promoterFamilyId").toString()) : null;
         if (inviteCode == null || promoterFamilyId == null) {
             return Result.error("邀请码和推广方家庭ID不能为空");
         }
-        var binding = familyCommissionService.bindByInviteCode(promoterFamilyId, inviteCode);
+        PromotionBinding binding = familyCommissionService.bindByInviteCode(promoterFamilyId, inviteCode);
         if (binding == null) {
             return Result.error("绑定失败,请检查邀请码");
         }
@@ -39,7 +41,7 @@ public class PromotionController {
         if (familyId == null) {
             return Result.error("家庭ID不能为空");
         }
-        var pageResult = familyCommissionService.getCommissionList(familyId, page, size);
+        Page<FamilyCommission> pageResult = familyCommissionService.getCommissionList(familyId, page, size);
         Map<String, Object> result = new java.util.HashMap<>();
         result.put("records", pageResult.getRecords());
         result.put("total", pageResult.getTotal());
@@ -58,4 +60,4 @@ public class PromotionController {
         List bindings = familyCommissionService.getBindings(familyId);
         return Result.success(bindings);
     }
-}
+}

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/HealthCircleService.java

@@ -186,14 +186,14 @@ public class HealthCircleService {
         LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
                 .eq(HealthCircleMember::getFamilyId, familyId)
                 .eq(HealthCircleMember::getStatus, "active");
-        return (int) healthCircleMemberMapper.selectCount(mw);
+        return (int) (long) healthCircleMemberMapper.selectCount(mw);
     }
 
     private int countCircleMembers(Long circleId) {
         LambdaQueryWrapper<HealthCircleMember> mw = new LambdaQueryWrapper<HealthCircleMember>()
                 .eq(HealthCircleMember::getCircleId, circleId)
                 .eq(HealthCircleMember::getStatus, "active");
-        return (int) healthCircleMemberMapper.selectCount(mw);
+        return (int) (long) healthCircleMemberMapper.selectCount(mw);
     }
 
     private String generateInviteCode() {

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/HealthScoreService.java

@@ -79,7 +79,7 @@ public class HealthScoreService {
 
         // 饮食: 每次+2 (上限每日1次)
         long mealCount = healthMealRecordMapper.selectCount(
-            new LambdaQueryWrapper<HealthMealRecord>().eq(HealthMealRecord::getChildId, childId));
+            new LambdaQueryWrapper<HealthMealRecord>().eq(HealthMealRecord::getMemberId, childId));
         long mealDays = countDistinctDays(childId, "meal");
         total += (int) Math.min(mealCount, mealDays * 1) * 2;
 

+ 1 - 0
cfc-backend/src/test/java/com/etotem/cfc/config/FamilyAccessInterceptorTest.java

@@ -13,6 +13,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import java.util.Collections;
 import java.util.Set;
 import javax.servlet.http.HttpServletResponse;
+import org.springframework.mock.web.MockHttpServletRequest;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.Mockito.*;

+ 35 - 11
cfc-frontend/pages/ai/chat.vue

@@ -5,7 +5,7 @@
       <view class="nav-back" @click="goBack">
         <text class="nav-back-icon">‹</text>
       </view>
-      <text class="nav-title">{{ isNutrition ? '精准营养助手' : 'AI 家庭助手' }}</text>
+      <text class="nav-title">{{ isNutrition ? '精准营养助手' : (isHealthButler ? 'AI 健康管家' : 'AI 家庭助手') }}</text>
       <view class="nav-actions">
         <view class="mascot-badge" v-if="mascotCode && !isNutrition" @click="showConversations = !showConversations">
           <text class="mascot-badge-icon">{{ mascotIcon }}</text>
@@ -38,19 +38,19 @@
       @scrolltoupper="loadMoreMessages" id="messageList">
       <view class="message-list-inner">
         <view class="welcome-card" v-if="messages.length === 0 && !sending">
-          <view class="welcome-icon">{{ mascotIcon || (isNutrition ? '🥗' : '🤖') }}</view>
-          <text class="welcome-title">{{ isNutrition ? '你好!我是精准营养助手' : ('你好!我是' + (mascotName || '家庭助手')) }}</text>
+          <view class="welcome-icon">{{ mascotIcon || (isNutrition ? '🥗' : (isHealthButler ? '🏥' : '🤖')) }}</view>
+          <text class="welcome-title">{{ isNutrition ? '你好!我是精准营养助手' : (isHealthButler ? '你好!我是AI健康管家' : ('你好!我是' + (mascotName || '家庭助手'))) }}</text>
           <text class="welcome-desc">{{ welcomeSubtitle }}</text>
           <view class="welcome-suggestions">
-            <view class="welcome-suggestion" v-if="!isNutrition" @click="quickQuestion('小明最近任务完成怎么样?')">
+            <view class="welcome-suggestion" v-if="!isNutrition && !isHealthButler" @click="quickQuestion('小明最近任务完成怎么样?')">
               <text class="suggestion-icon">📋</text>
               <text class="suggestion-text">小明最近任务完成怎么样?</text>
             </view>
-            <view class="welcome-suggestion" v-if="!isNutrition" @click="quickQuestion('我家孩子的能量评分怎么样?')">
+            <view class="welcome-suggestion" v-if="!isNutrition && !isHealthButler" @click="quickQuestion('我家孩子的能量评分怎么样?')">
               <text class="suggestion-icon">⚡</text>
               <text class="suggestion-text">孩子的能量评分怎么样?</text>
             </view>
-            <view class="welcome-suggestion" v-if="!isNutrition" @click="quickQuestion('今天有什么需要我做的事情?')">
+            <view class="welcome-suggestion" v-if="!isNutrition && !isHealthButler" @click="quickQuestion('今天有什么需要我做的事情?')">
               <text class="suggestion-icon">✅</text>
               <text class="suggestion-text">今天有什么需要我做的事情?</text>
             </view>
@@ -62,6 +62,18 @@
               <text class="suggestion-icon">🛒</text>
               <text class="suggestion-text">有哪些食物或产品推荐?</text>
             </view>
+            <view class="welcome-suggestion" v-if="isHealthButler" @click="quickQuestion('我家孩子挑食怎么办?')">
+              <text class="suggestion-icon">🍽️</text>
+              <text class="suggestion-text">我家孩子挑食怎么办?</text>
+            </view>
+            <view class="welcome-suggestion" v-if="isHealthButler" @click="quickQuestion('孩子最近总感冒,怎么调养?')">
+              <text class="suggestion-icon">🤧</text>
+              <text class="suggestion-text">孩子最近总感冒,怎么调养?</text>
+            </view>
+            <view class="welcome-suggestion" v-if="isHealthButler" @click="quickQuestion('什么样的体质?如何调理?')">
+              <text class="suggestion-icon">🔍</text>
+              <text class="suggestion-text">什么样的体质?如何调理?</text>
+            </view>
           </view>
         </view>
 
@@ -72,7 +84,7 @@
           </view>
           <!-- AI 回复 -->
           <view class="message message-ai" v-if="msg.answer">
-            <view class="message-avatar">{{ mascotIcon || '🤖' }}</view>
+<view class="message-avatar">{{ isHealthButler ? '🏥' : (mascotIcon || '🤖') }}</view>
             <view class="message-bubble ai-bubble">
               <text class="ai-answer-text">{{ msg.answer }}</text>
         <!-- 推荐卡片 -->
@@ -128,7 +140,7 @@
 </template>
 
 <script>
-import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation, aiSendNutritionMessage } from '../../utils/api.js'
+import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation, aiSendNutritionMessage, aiSendHealthButlerMessage } from '../../utils/api.js'
 import BaseEmpty from '../../components/BaseEmpty.vue'
 import AITaskCard from '../../components/AITaskCard.vue'
 
@@ -158,10 +170,16 @@ export default {
     isNutrition: function() {
       return this.mode === 'nutrition'
     },
+    isHealthButler: function() {
+      return this.mode === 'health'
+    },
     welcomeSubtitle: function() {
       if (this.isNutrition) {
         return '我可以基于菌群检测报告,为你提供个性化的营养建议和推荐'
       }
+      if (this.isHealthButler) {
+        return '专注儿童健康咨询:体质调理、营养喂养、科学保健'
+      }
       if (this.fromPage) {
         var pageNames = {
           'pages/body/index': '身',
@@ -289,10 +307,8 @@ export default {
 
       var res, data
       if (this.mode === 'nutrition') {
-        // 营养模式:调精准营养助手
         res = await aiSendNutritionMessage(params)
         data = res.data || {}
-        // 更新最后一条消息的回复 + 推荐列表 + 任务
         const lastMsg = this.messages[this.messages.length - 1]
         if (lastMsg) {
           lastMsg.answer = data.answer || ''
@@ -302,10 +318,18 @@ export default {
             lastMsg.sourceReport = { id: this.reportId, type: 'nutrition' }
           }
         }
+      } else if (this.mode === 'health') {
+        res = await aiSendHealthButlerMessage(params)
+        data = res.data || {}
+        const lastMsg = this.messages[this.messages.length - 1]
+        if (lastMsg) {
+          lastMsg.answer = data.answer || ''
+          lastMsg.recommendations = data.recommendations || []
+          lastMsg.tasks = data.tasks || []
+        }
       } else {
         res = await aiSendMessage(params)
         data = res.data || {}
-        // 更新最后一条消息的回复 + 推荐列表 + 任务
         const lastMsg = this.messages[this.messages.length - 1]
         if (lastMsg) {
           lastMsg.answer = data.answer || ''

+ 37 - 0
cfc-frontend/utils/api.js

@@ -1149,6 +1149,43 @@ export const aiSendNutritionMessage = (data) => {
   })
 }
 
+// AI 健康管家
+export const aiSendHealthButlerMessage = (data) => {
+  var query = data && data.query ? data.query : ''
+  var conversationId = data && data.conversationId ? data.conversationId : ''
+  var userId = uni.getStorageSync('userId') || ''
+  var langgraphBody = {
+    query: query,
+    user: userId,
+    user_id: userId,
+    conversation_id: conversationId,
+    response_mode: 'blocking',
+    bot_name: '健康管家'
+  }
+  if (data && data.childId) {
+    langgraphBody.inputs = { child_id: data.childId }
+  }
+  return langgraphRequest({
+    langgraphPath: '/api/v1/health/butler',
+    langgraphBody: langgraphBody,
+    difyFallbackPath: '/api/ai/chat/send',
+    difyFallbackData: data,
+    transform: function(lgRes) {
+      var content = ''
+      if (lgRes && lgRes.choices && lgRes.choices.length > 0) {
+        var msg = lgRes.choices[0].message
+        if (msg && msg.content) { content = msg.content }
+      }
+      return {
+        answer: content,
+        conversationId: conversationId,
+        recommendations: [],
+        tasks: []
+      }
+    }
+  })
+}
+
 // ===================== 幸福邻里部落 - 服务商模块 =====================
 
 // 提交服务商入驻申请

+ 52 - 0
cfc-langgraph/app/api/adapter.py

@@ -3,6 +3,7 @@ from pydantic import BaseModel
 from typing import Optional
 from app.graphs.chat_graph import create_chat_graph
 from app.graphs.analysis_graph import create_analysis_graph
+from app.graphs.health_butler_graph import create_health_butler_graph
 import uuid
 import time
 
@@ -152,3 +153,54 @@ async def analysis_run(req: DifyAnalysisRequest):
             total_tokens=len((req.focus or "").split()) + len(content.split()),
         ),
     )
+
+
+@router.post("/health/butler", response_model=DifyResponse)
+async def health_butler(req: DifyChatRequest):
+    query = _extract_query(req)
+    if not query:
+        raise HTTPException(status_code=400, detail="query 为空")
+
+    graph = create_health_butler_graph()
+    initial_state = {
+        "query": query,
+        "user_id": int(req.user_id) if str(req.user_id).isdigit() else 0,
+        "child_id": int(req.inputs.get("child_id")) if req.inputs.get("child_id") else None,
+        "conversation_id": req.conversation_id or None,
+        "context": _to_langgraph_context(req),
+        "answer": None,
+        "sources": [],
+        "memory_messages": None,
+    }
+    config = {
+        "configurable": {"thread_id": req.conversation_id or f"health_{req.user_id}"},
+    }
+    result = await graph.ainvoke(initial_state, config)
+    answer = result.get("answer") or ""
+
+    sources = result.get("sources") or []
+    metadata = {}
+    if sources:
+        metadata["sources"] = [
+            {"title": s.get("title", ""), "type": s.get("type", "knowledge")}
+            for s in sources
+        ]
+
+    return DifyResponse(
+        id=f"health-{uuid.uuid4().hex[:24]}",
+        created=_now_ts(),
+        model="langgraph-health-butler",
+        choices=[
+            DifyChoice(
+                index=0,
+                message={"role": "assistant", "content": answer},
+                finish_reason="stop",
+            )
+        ],
+        usage=DifyUsage(
+            prompt_tokens=len(query.split()),
+            completion_tokens=len(answer.split()),
+            total_tokens=len(query.split()) + len(answer.split()),
+        ),
+        metadata=metadata,
+    )

+ 171 - 0
cfc-langgraph/app/graphs/health_butler_graph.py

@@ -0,0 +1,171 @@
+"""
+健康管家 LangGraph - 专为儿童健康咨询设计的对话图
+Uses RAG retrieval from health knowledge base.
+"""
+from typing import TypedDict
+from langgraph.graph import StateGraph, START, END
+from langgraph.checkpoint.memory import MemorySaver
+from langchain_openai import ChatOpenAI, OpenAIEmbeddings
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.rag.retriever import RagRetriever
+from app.memory.store import MemoryManager
+from app.config import settings
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class HealthButlerState(TypedDict):
+    query: str
+    user_id: int
+    child_id: int | None
+    conversation_id: str | None
+    context: dict | None
+    answer: str | None
+    sources: list[dict]
+    memory_messages: list | None
+
+
+HEALTH_BUTLER_PROMPT = """你是一个专业、温暖的家庭健康顾问,专注于儿童健康成长。
+
+你的职责:
+1. 儿童体质调理咨询(如:挑食、瘦弱、易疲劳、反复生病)
+2. 科学喂养和营养建议(辅食搭配、食欲不振、营养补充)
+3. 日常保健知识(科学睡眠、运动建议、季节防护)
+4. 体质辨识基础咨询(气虚/阳虚/阴虚/痰湿等体质特征解读)
+5. 健康问题初步判断和就医建议
+
+回答原则:
+1. 用中文,语气温暖专业,像一个关心孩子的家庭医生
+2. 涉及具体健康问题,先问清年龄、性别、症状持续时间等关键信息
+3. 不确定时明确说"建议咨询专业医生",绝不臆断严重疾病
+4. 回答结构:问题分析 → 建议方案 → 日常调养tip
+5. 涉及儿童安全(性早熟、严重外伤、急性症状等)立刻建议就医
+
+你可以通过检索健康知识库来获取依据。
+"""
+
+
+def create_health_butler_graph():
+    """创建健康管家 StateGraph"""
+    # LLM
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=0.7,
+    )
+
+    # 嵌入和检索
+    embeddings = OpenAIEmbeddings(
+        model=settings.embedding_model,
+        api_key=settings.effective_embedding_api_key,
+        base_url=settings.effective_embedding_base_url,
+    )
+    retriever = RagRetriever(collection_name="health_knowledge")
+    memory_mgr = MemoryManager(embeddings)
+
+    # 带工具的 LLM(健康知识库检索)
+    async def health_retrieve(query: str, user_id: int) -> list[dict]:
+        try:
+            results = await retriever.retrieve(
+                query,
+                filters={"user_id": user_id},
+                k=3,
+            )
+            return results
+        except Exception as e:
+            logger.warning("健康知识检索失败: %s", e)
+            return []
+
+    async def generate_answer(state: HealthButlerState) -> dict:
+        """带健康知识检索的 LLM 生成"""
+        messages = [SystemMessage(content=HEALTH_BUTLER_PROMPT)]
+
+        # 家庭上下文
+        ctx = state.get("context") or {}
+        if ctx:
+            child_info = []
+            if ctx.get("child_id"):
+                child_info.append(f"孩子ID: {ctx['child_id']}")
+            if ctx.get("family_id"):
+                child_info.append(f"家庭ID: {ctx['family_id']}")
+            if child_info:
+                messages.append(SystemMessage(
+                    content="用户背景信息: " + ", ".join(child_info)
+                ))
+
+        # 健康知识检索
+        retrieved = await health_retrieve(state["query"], state["user_id"])
+        sources = []
+        if retrieved:
+            context_texts = []
+            for i, r in enumerate(retrieved[:3]):
+                src = r.get("metadata", {})
+                title = src.get("title", "健康知识")
+                content = r["content"][:300]
+                context_texts.append(f"[{i+1}] {title}:\n{content}")
+                sources.append({
+                    "type": "knowledge",
+                    "title": title,
+                    "content": content,
+                })
+            knowledge_context = "\n\n---\n".join(context_texts)
+            messages.append(SystemMessage(
+                content=f"以下是与问题相关的健康知识参考:\n{knowledge_context}"
+            ))
+
+        # 长期记忆
+        try:
+            memories = await memory_mgr.recall(state["user_id"], state["query"])
+            if memories:
+                mem_text = "\n".join([f"- {m}" for m in memories[:3]])
+                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.ainvoke(messages)
+        answer = response.content
+
+        return {
+            "answer": answer,
+            "sources": sources,
+            "memory_messages": [
+                {"role": "user", "content": state["query"]},
+                {"role": "assistant", "content": answer},
+            ],
+        }
+
+    async def save_memory(state: HealthButlerState) -> dict:
+        """保存对话到长期记忆"""
+        try:
+            if state.get("memory_messages"):
+                await memory_mgr.save_conversation(
+                    state["user_id"],
+                    state.get("conversation_id") or f"health_{state['user_id']}",
+                    state["memory_messages"],
+                )
+        except Exception as e:
+            logger.warning("保存健康管家记忆失败: %s", e)
+        return {}
+
+    # ── 构建图 ──
+
+    builder = StateGraph(HealthButlerState)
+
+    builder.add_node("generate_answer", generate_answer)
+    builder.add_node("save_memory", save_memory)
+
+    builder.add_edge(START, "generate_answer")
+    builder.add_edge("generate_answer", "save_memory")
+    builder.add_edge("save_memory", END)
+
+    checkpointer = MemorySaver()
+    graph = builder.compile(checkpointer=checkpointer)
+
+    return graph