Переглянути джерело

chore: auto bump version and changelog [skip ci]

iwt 3 тижнів тому
батько
коміт
6829d3478a

+ 15 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java

@@ -59,6 +59,9 @@ public class AIChatController {
     @Resource
     private AiGateway aiGateway;
 
+    @Resource
+    private com.etotem.cfc.service.PortraitService portraitService;
+
     @Operation(summary = "发送聊天消息(支持传入reportId以解读报告)")
     @PostMapping("/chat/send")
     public Result<Map<String, Object>> sendMessage(
@@ -272,9 +275,17 @@ public class AIChatController {
             inputs.put("mascot_persona", "");
         }
 
-        // 3. 调用精准营养助手
-        Map<String, Object> difyResp = aiService.sendNutritionMessage(
-                query, String.valueOf(userId), conversationId, inputs);
+        // 3. 注入用户画像
+        String memberIdStr = params.get("memberId");
+        Long memberId = memberIdStr != null && !memberIdStr.trim().isEmpty()
+                ? Long.valueOf(memberIdStr) : null;
+        String portraitPrompt = portraitService.buildPortraitPrompt(userId, memberId);
+        if (portraitPrompt != null) {
+            inputs.put("portrait_prompt", portraitPrompt);
+        }
+
+        // 4. 调用营养助手(走 LangGraph)
+        Map<String, Object> resp = aiGateway.chat(query, userId, conversationId, inputs);
 
         String answer = (String) difyResp.getOrDefault("answer", "");
 
@@ -340,7 +351,7 @@ public class AIChatController {
     // 5. 组装结果
     Map<String, Object> result = new LinkedHashMap<>();
     result.put("answer", cleanAnswer);
-    result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
+    result.put("conversationId", resp != null ? resp.getOrDefault("conversationId", "") : "");
     result.put("recommendations", recommendations != null ? recommendations : Collections.emptyList());
     result.put("tasks", tasks);
     return Result.success(result);

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

@@ -5,6 +5,7 @@ from app.graphs.chat_graph import create_chat_graph
 from app.graphs.analysis_graph import create_analysis_graph
 from app.graphs.health_coach_graph import create_health_coach_graph
 from app.graphs.health_butler_graph import create_health_butler_graph
+from app.graphs.nutrition_graph import create_nutrition_graph
 from app.tools.java_client import JavaClient
 from app.rag.retriever import RagRetriever
 from app.config import settings
@@ -801,3 +802,56 @@ async def health_plan_regenerate(req: HealthPlanRegenerateRequest):
     except Exception as e:
         logger.error("重新生成 section 失败: %s", e)
         return {"success": False, "error": str(e)}
+
+
+@router.post("/nutrition/send", response_model=DifyResponse)
+async def nutrition_send(req: DifyChatRequest):
+    """AI 营养助手 — 基于健康报告的个性化营养建议"""
+    query = _extract_query(req)
+    if not query:
+        raise HTTPException(status_code=400, detail="query 为空")
+
+    graph = create_nutrition_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": [],
+        "tasks": [],
+        "messages": None,
+    }
+    config = {
+        "configurable": {"thread_id": req.conversation_id or f"nutrition_{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("name", ""), "type": s.get("type", "tool")}
+            for s in sources
+        ]
+
+    return DifyResponse(
+        id=f"nutrition-{uuid.uuid4().hex[:24]}",
+        created=_now_ts(),
+        model="langgraph-nutrition",
+        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,
+    )

+ 159 - 0
cfc-langgraph/app/graphs/nutrition_graph.py

@@ -0,0 +1,159 @@
+"""AI 营养助手 LangGraph — 基于健康报告的个性化营养建议"""
+from typing import TypedDict
+from langgraph.graph import StateGraph, START, END
+from langgraph.checkpoint.memory import MemorySaver
+from langchain_openai import ChatOpenAI
+from langchain_core.messages import SystemMessage, HumanMessage
+from app.tools.java_client import JavaClient
+from app.tools.product_tools import (
+    search_product_by_keyword,
+    search_article_by_keyword,
+)
+from app.memory.store import MemoryManager
+from app.config import settings
+from app.prompt_service import get_prompt
+from app.portrait_service import get_user_portrait_text
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+class NutritionState(TypedDict):
+    query: str
+    user_id: int
+    conversation_id: str
+    child_id: int | None
+    report_id: int | None
+    context: dict | None
+    answer: str | None
+    tasks: list[dict]
+    sources: list[dict]
+    messages: list | None
+
+
+DEFAULT_NUTRITION_PROMPT = """你是一个儿童营养健康顾问,专注于基于检测报告提供精准的营养改善建议。
+
+你的能力:
+1. 解读儿童健康报告(菌群、营养指标、体检数据)
+2. 基于报告数据推荐合适的营养产品和膳食方案
+3. 结合孩子体质给出个性化的饮食建议
+4. 推荐相关的健康活动和科普文章
+
+回答原则:
+1. 用中文,语气温暖专业,像营养师一样亲切
+2. 必须基于报告数据给出建议,不凭空猜测
+3. 引用知识库中的营养素/食物信息作为证据
+4. 需要产品推荐时使用搜索工具,说明推荐理由
+5. 生成 [TASK: {"title": "任务名", "dimension": "身", "points": 10, "frequency": "每天"}] 标记创建营养行动任务
+6. 严重健康问题建议咨询专业医生或营养师
+7. 不要编造医学建议,不夸大效果
+"""
+
+
+def create_nutrition_graph():
+    """创建营养助手 StateGraph"""
+    llm = ChatOpenAI(
+        model=settings.llm_model,
+        api_key=settings.llm_api_key,
+        base_url=settings.llm_base_url,
+        temperature=settings.llm_temperature,
+    )
+
+    from app.rag.embeddings import get_embeddings
+    embeddings = get_embeddings()
+    memory_mgr = MemoryManager(embeddings)
+    java = JavaClient()
+
+    llm_with_tools = llm.bind_tools([
+        search_product_by_keyword,
+        search_article_by_keyword,
+    ])
+
+    builder = StateGraph(NutritionState)
+
+    async def generate_answer(state: NutritionState) -> dict:
+        """核心 LLM 调用 + 画像注入"""
+        # 加载营养助手 prompt
+        nutrition_prompt = await get_prompt("nutrition_assistant") or DEFAULT_NUTRITION_PROMPT
+        messages = [SystemMessage(content=nutrition_prompt)]
+
+        # 画像注入(在人格 prompt 之后)
+        member_id = state.get("child_id")
+        portrait_text = await get_user_portrait_text(java, state["user_id"], member_id)
+        if portrait_text:
+            messages.insert(1, SystemMessage(content=portrait_text))
+
+        # 家庭上下文
+        ctx = state.get("context") or {}
+        if ctx:
+            ctx_parts = []
+            if ctx.get("child_id"):
+                ctx_parts.append(f"孩子ID: {ctx['child_id']}")
+            if ctx.get("family_id"):
+                ctx_parts.append(f"家庭ID: {ctx['family_id']}")
+            if ctx.get("report_id"):
+                ctx_parts.append(f"报告ID: {ctx['report_id']}")
+            if ctx_parts:
+                messages.append(SystemMessage(content="用户背景信息: " + ", ".join(ctx_parts)))
+
+        # 长期记忆
+        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_with_tools.ainvoke(messages)
+        answer = response.content
+
+        # 提取任务和来源
+        tasks = []
+        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: NutritionState) -> dict:
+        """保存对话到长期记忆"""
+        try:
+            if state.get("messages"):
+                await memory_mgr.save_conversation(
+                    state["user_id"],
+                    state.get("conversation_id") or f"nutrition_{state['user_id']}",
+                    state["messages"],
+                )
+        except Exception as e:
+            logger.warning("保存营养记忆失败: %s", e)
+        return {}
+
+    # 构建图
+    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

+ 110 - 0
cfc-langgraph/app/portrait_service.py

@@ -0,0 +1,110 @@
+"""用户画像 prompt 组装服务
+
+职责:
+1. 拉取用户自定义画像文本(600s 缓存)
+2. 拉取 profile_snapshot 并渲染为文本(900s 缓存)
+3. 拼装成 SystemMessage 内容;无数据时返回 None
+"""
+import time
+import logging
+from app.tools.java_client import JavaClient
+
+logger = logging.getLogger(__name__)
+
+_TTL_PROMPT = 600    # 用户自定义 prompt 缓存 10 分钟
+_TTL_SNAPSHOT = 900  # 画像快照缓存 15 分钟
+
+_cache_prompt: dict = {}
+_cache_snapshot: dict = {}
+
+
+def _render_snapshot(profile: dict) -> str:
+    """将 profile Map 渲染为中文指标清单文本"""
+    if not profile or "error" in profile:
+        return ""
+    lines = []
+    member = profile.get("member") or {}
+    name = member.get("name", "用户")
+    age = member.get("age", "?")
+    gender_raw = str(member.get("gender", ""))
+    gender = "男" if gender_raw == "male" else "女" if gender_raw == "female" else "未知"
+    lines.append(f"## 画像对象:{name}({age}岁,{gender})")
+
+    dims = profile.get("dimension_scores") or {}
+    if dims:
+        lines.append(f"五维评分:身 {dims.get('body', '?')} 智 {dims.get('wisdom', '?')} "
+                     f"心 {dims.get('mind', '?')} 行 {dims.get('action', '?')} 富 {dims.get('wealth', '?')}")
+
+    body = profile.get("body_metrics") or {}
+    if body.get("sleep_dur_avg"):
+        lines.append(f"平均睡眠:{body['sleep_dur_avg']}小时/天")
+    if body.get("exercise_count_week"):
+        lines.append(f"周运动频次:{body['exercise_count_week']}次")
+
+    mind = profile.get("mind_metrics") or {}
+    if mind.get("stress_avg"):
+        lines.append(f"平均压力:{mind['stress_avg']}/10")
+
+    problems = profile.get("problem_domains") or []
+    if problems:
+        lines.append(f"关注问题域:{', '.join(problems[:5])}")
+
+    return "\n".join(lines)
+
+
+async def get_user_portrait_text(java: JavaClient, user_id: int, member_id) -> str | None:
+    """返回画像 SystemMessage 内容;无画像数据时返回 None"""
+    parts = []
+
+    # 1. 用户自定义 prompt(600s 缓存)
+    now = time.time()
+    cached_text, cached_ts = _cache_prompt.get(user_id, (None, 0))
+    if now - cached_ts < _TTL_PROMPT and cached_text is not None:
+        user_text = cached_text
+    else:
+        try:
+            client = await java._get_client()
+            resp = await client.post("/api/user/portrait/get", json={"userId": user_id}, timeout=5.0)
+            data = resp.json()
+            user_text = (data.get("data") or {}).get("portraitPrompt") or ""
+            _cache_prompt[user_id] = (user_text, now)
+        except Exception as e:
+            logger.warning("拉取用户画像 prompt 失败: %s", e)
+            user_text = ""
+
+    if user_text.strip():
+        parts.append(user_text)
+
+    # 2. 快照渲染文本(仅当有 member_id 时)
+    if member_id:
+        snap_now = time.time()
+        cached_snap, cached_snap_ts = _cache_snapshot.get(int(member_id), (None, 0))
+        if snap_now - cached_snap_ts < _TTL_SNAPSHOT and cached_snap is not None:
+            snap_text = cached_snap
+        else:
+            try:
+                profile = await java.get_member_profile(int(member_id))
+                snap_text = _render_snapshot(profile) if profile else ""
+                _cache_snapshot[int(member_id)] = (snap_text, snap_now)
+            except Exception as e:
+                logger.warning("拉取成员画像快照失败: %s", e)
+                snap_text = ""
+
+        if snap_text.strip():
+            if user_text.strip():
+                parts.append("\n---参考指标---\n" + snap_text)
+            else:
+                parts.append("以下是用户画像数据(系统自动生成),请结合这些数据给出更针对性的建议:\n" + snap_text)
+
+    return "\n\n".join(parts) if parts else None
+
+
+def clear_cache(user_id: int | None = None, member_id: int | None = None):
+    """清除缓存"""
+    if user_id is not None:
+        _cache_prompt.pop(user_id, None)
+    if member_id is not None:
+        _cache_snapshot.pop(member_id, None)
+    if user_id is None and member_id is None:
+        _cache_prompt.clear()
+        _cache_snapshot.clear()

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-bef1428f267caf1f070fd840abe38878b8502766
+8bedce3bf98bb62c5529382587857cc19b48a429

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1218",
+  "version": "1.0.1219",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1218",
+      "version": "1.0.1219",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1219",
+  "version": "1.0.1220",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 14 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,20 @@
 
 ---
 
+## v1.0.1220 (2026-08-23)
+
+### 文档
+- 对齐 TabBar 重设计为实际实现(4Tab+中间⭐扇形菜单)
+
+### 其他
+- - 行跳转:pages/index-home/index→pages/action-detail/index
+- - 四维跳转路径:pages/X/index→pages/X-detail/index
+- - 健康Tab:pages/health/index→pages/health-main/index(5子Tab+雷达图)
+- - 同步更新技术方案、边界情况、实施状态表
+- - PROJECT-OVERVIEW v2.8→v2.9
+- 
+
+
 ## v1.0.1219 (2026-08-23)
 
 ### Bug 修复

+ 15 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1219
+> 当前版本: v1.0.1220
 
 ## 历史版本
 
@@ -8,6 +8,20 @@
 
 ---
 
+## v1.0.1220 (2026-08-23)
+
+### 文档
+- 对齐 TabBar 重设计为实际实现(4Tab+中间⭐扇形菜单)
+
+### 其他
+- - 行跳转:pages/index-home/index→pages/action-detail/index
+- - 四维跳转路径:pages/X/index→pages/X-detail/index
+- - 健康Tab:pages/health/index→pages/health-main/index(5子Tab+雷达图)
+- - 同步更新技术方案、边界情况、实施状态表
+- - PROJECT-OVERVIEW v2.8→v2.9
+- 
+
+
 ## v1.0.1219 (2026-08-23)
 
 ### Bug 修复