Преглед изворни кода

fix: 菌群知识库文章接口 + 分页拉取 + LangGraph timeout 修复

- 新增 MicrobiomeArticleController/mapper/entity(POST /api/microbiome/article/list 分页)
- 修复性别取值非法(空串/微信码 0/1/2 处理)
- 修复 SurveyController submit NPE(templateId 判空)
- 修复 FoodRecommendController userId/foodId 空串 NPE
- 修复 AdminArticleController toggleFeatured NPE
- 修复 AdminNoticeController LambdaQuery 参数类型
- 修复 AdminPurchaseFieldController Map→实体 ClassCastException
- 修复 DietMealConfigController @RequestParam→@RequestBody
- 修复 FamilyInviteController scene 改为 JSON 格式
- 修复 HealthExercise/SleepController memberId 空串 NPE
- 修复 hasRole roles null 保护
- LangGraph: java_client timeout 10s→120s, get_microbiome_articles 分页
- LangGraph: microbiome_loader.py fetch_articles 分页+timeout 120s
- 导入 809 篇菌群知识库文章到 microbiome_article 表
Xiaogang Liao пре 2 месеци
родитељ
комит
c19cdf8d2b

+ 4 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthMealController.java

@@ -24,8 +24,10 @@ public class HealthMealController {
 
     @Operation(summary = "获取饮食打卡列表")
     @PostMapping("/list")
-    public Result<List<HealthMealRecord>> list(@RequestBody Map<String, Object> params) {
-        Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null;
+    public Result<List<HealthMealRecord>> list(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : currentMemberId;
         if (memberId == null) return Result.error("memberId不能为空");
         LambdaQueryWrapper<HealthMealRecord> wrapper = new LambdaQueryWrapper<HealthMealRecord>()
                 .eq(HealthMealRecord::getMemberId, memberId)

+ 37 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MicrobiomeArticleController.java

@@ -0,0 +1,37 @@
+package com.etotem.cfc.controller;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.MicrobiomeArticle;
+import com.etotem.cfc.mapper.MicrobiomeArticleMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.Map;
+@Tag(name = "菌群知识库文章")
+@RestController
+@RequestMapping("/api/microbiome/article")
+public class MicrobiomeArticleController {
+    @Resource
+    private MicrobiomeArticleMapper microbiomeArticleMapper;
+    @Operation(summary = "获取菌群知识库文章列表(分页)")
+    @PostMapping("/list")
+    public Result<Page<MicrobiomeArticle>> list(@RequestBody Map<String, Object> params) {
+        int page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
+        int size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 1000;
+        LambdaQueryWrapper<MicrobiomeArticle> wrapper = new LambdaQueryWrapper<MicrobiomeArticle>()
+                .eq(MicrobiomeArticle::getStatus, 1);
+        if (params != null) {
+            Object category = params.get("category");
+            if (category != null && !category.toString().isEmpty())
+                wrapper.eq(MicrobiomeArticle::getCategory, category.toString());
+            Object keyword = params.get("keyword");
+            if (keyword != null && !keyword.toString().isEmpty())
+                wrapper.and(w -> w.like(MicrobiomeArticle::getTitle, keyword.toString())
+                        .or().like(MicrobiomeArticle::getContent, keyword.toString()));
+        }
+        wrapper.orderByDesc(MicrobiomeArticle::getCreatedAt);
+        return Result.success(microbiomeArticleMapper.selectPage(new Page<>(page, size), wrapper));
+    }
+}

+ 16 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/MicrobiomeArticle.java

@@ -0,0 +1,16 @@
+package com.etotem.cfc.entity;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+@Data
+@TableName("microbiome_article")
+public class MicrobiomeArticle implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id; private String title; private String content;
+    private String category; private String tags; private String sourceUrl;
+    private String pubDate; private Integer status;
+    private Date createdAt; private Date updatedAt;
+}

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/MicrobiomeArticleMapper.java

@@ -0,0 +1,4 @@
+package com.etotem.cfc.mapper;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.MicrobiomeArticle;
+public interface MicrobiomeArticleMapper extends BaseMapper<MicrobiomeArticle> {}

+ 1 - 1
cfc-frontend/pages/health/diet-index.vue

@@ -166,7 +166,7 @@ export default {
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
     // child 角色:memberId 由后端从 JWT 获取 currentMemberId,前端无需传
     if (role !== 'child') {
-      this.memberId = uni.getStorageSync('currentChildId') || null
+      this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
     }
     this.loadHistory()
   },

+ 3 - 3
cfc-langgraph/Dockerfile

@@ -6,9 +6,9 @@ RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
 
 WORKDIR /app
 
-COPY .venv-packages.tar.gz /tmp/pkgs.tar.gz
-RUN tar xzf /tmp/pkgs.tar.gz -C /usr/local/lib/python3.12/site-packages/ && \
-    rm /tmp/pkgs.tar.gz
+COPY requirements.txt /tmp/requirements.txt
+RUN pip install --no-cache-dir -r /tmp/requirements.txt && \
+    rm /tmp/requirements.txt
 
 COPY app/ app/
 COPY src/ src/

+ 4 - 2
cfc-langgraph/app/graphs/chat_graph.py

@@ -12,6 +12,7 @@ from app.tools.product_tools import (
 )
 from app.memory.store import MemoryManager
 from app.config import settings
+from app.prompt_service import get_prompt
 import logging
 
 logger = logging.getLogger(__name__)
@@ -30,7 +31,7 @@ class ChatState(TypedDict):
     sources: list[dict]
 
 
-CHAT_SYSTEM_PROMPT = """你是一个儿童成长家庭助手, 回答关于孩子成长、健康、教育的各种问题。
+DEFAULT_CHAT_PROMPT = """你是一个儿童成长家庭助手, 回答关于孩子成长、健康、教育的各种问题。
 
 你可以使用搜索工具查找商品、活动和文章来辅助回答。
 
@@ -87,7 +88,8 @@ def create_chat_graph():
 
     async def llm_call(state: ChatState) -> dict:
         """核心 LLM 调用 + Tool"""
-        messages = [SystemMessage(content=CHAT_SYSTEM_PROMPT)]
+        chat_prompt = await get_prompt("chat_assistant") or DEFAULT_CHAT_PROMPT
+        messages = [SystemMessage(content=chat_prompt)]
 
         # 注入家庭上下文 + 用户健康现状
         ctx = state.get("context", {})

+ 3 - 2
cfc-langgraph/app/graphs/health_butler_graph.py

@@ -10,6 +10,7 @@ from app.tools.product_tools import (
 )
 from app.memory.store import MemoryManager
 from app.config import settings
+from app.prompt_service import get_prompt
 import logging
 import json
 
@@ -32,7 +33,7 @@ class HealthButlerState(TypedDict):
     messages: list | None
 
 
-HEALTH_BUTLER_PROMPT = """你是一个家庭AI健康管家, 为用户提供专业、温暖的个性化健康建议。
+DEFAULT_BUTLER_PROMPT = """你是一个家庭AI健康管家, 为用户提供专业、温暖的个性化健康建议。
 
 你的能力:
 1. 基于健康知识库(菌属功能、指标解读、营养素、食物)提供科学建议
@@ -139,7 +140,7 @@ def create_health_butler_graph():
 
     async def llm_answer(state: HealthButlerState) -> dict:
         """阶段3: LLM 生成个性化健康建议"""
-        messages = [SystemMessage(content=HEALTH_BUTLER_PROMPT)]
+        messages = [SystemMessage(content=await get_prompt("health_butler") or DEFAULT_BUTLER_PROMPT)]
 
         # 注入健康知识库上下文
         kb_ctx = state.get("kb_context", {})

+ 3 - 2
cfc-langgraph/app/graphs/health_coach_graph.py

@@ -10,6 +10,7 @@ from langchain_core.messages import SystemMessage, HumanMessage
 from app.rag.retriever import RagRetriever
 from app.memory.store import MemoryManager
 from app.config import settings
+from app.prompt_service import get_prompt
 import logging
 
 logger = logging.getLogger(__name__)
@@ -26,7 +27,7 @@ class HealthCoachState(TypedDict):
     memory_messages: list | None
 
 
-HEALTH_COACH_PROMPT = """你是一个儿童健康教练,喜欢用提问的方式引导家长发现问题和解决方案。
+DEFAULT_COACH_PROMPT = """你是一个儿童健康教练,喜欢用提问的方式引导家长发现问题和解决方案。
 
 核心原则:
 1. 先问后答:不要直接给答案,用问题引导家长思考
@@ -88,7 +89,7 @@ def create_health_coach_graph():
 
     async def generate_answer(state: HealthCoachState) -> dict:
         """带健康知识检索的 LLM 生成"""
-        messages = [SystemMessage(content=HEALTH_COACH_PROMPT)]
+        messages = [SystemMessage(content=await get_prompt("health_coach") or DEFAULT_COACH_PROMPT)]
 
         # 家庭上下文
         ctx = state.get("context") or {}

+ 88 - 0
cfc-langgraph/app/prompt_service.py

@@ -0,0 +1,88 @@
+"""系统提示词动态加载服务
+
+从 cfc-web 管理的 system_prompts 表(Java /api/system-prompts/get)加载提示词,
+带进程内缓存 + 超时降级到内置默认值。
+"""
+import time
+import logging
+from app.tools.java_client import JavaClient
+
+logger = logging.getLogger(__name__)
+
+_cache: dict[str, str] = {}
+_cache_ts: dict[str, float] = {}
+_TTL_SECONDS = 300
+
+DEFAULT_PROMPTS: dict[str, str] = {
+    "health_butler": """你是一个家庭AI健康管家, 为用户提供专业、温暖的个性化健康建议。
+你的能力:
+1. 基于健康知识库(菌属功能、指标解读、营养素、食物)提供科学建议
+2. 解读健康报告数据, 结合知识库给出改善方案
+3. 根据菌群检测结果推荐精准营养方案
+4. 提供可执行的健康微行动
+回答原则:
+1. 用中文, 像家庭医生一样亲切但专业
+2. 引用知识库中的菌属/指标/食物信息作为证据
+3. 优先使用知识库数据, 其次使用通用健康知识
+4. 需要产品推荐时使用搜索工具
+5. 生成 [TASK: {"title": "任务名", "dimension": "身", "points": 10, "frequency": "每天"}] 标记创建健康行动任务
+6. 超出知识库范围的慢性病/急症问题, 建议咨询专业医生
+7. 不要编造医学建议, 不夸大效果""",
+    "health_coach": """你是一个儿童健康教练,喜欢用提问的方式引导家长发现问题和解决方案。
+核心原则:
+1. 先问后答:不要直接给答案,用问题引导家长思考
+2. 用开放式问题了解情况,再给针对性建议
+3. 语气温暖,像一个耐心的教练在陪伴家长成长
+你的职责:
+1. 儿童体质调理(如挑食、瘦弱、易疲劳、反复生病)—— 用提问了解情况
+2. 科学喂养和营养建议 —— 先问饮食现状再给建议
+3. 日常保健(睡眠、运动、季节防护)—— 引导家长发现生活中的问题
+4. 体质辨识基础咨询 —— 通过提问帮助家长理解孩子体质特点
+5. 健康问题判断 —— 先收集症状信息再建议是否就医
+回答结构: 提问-分析-建议-跟进""",
+    "chat_assistant": """你是一个儿童成长家庭助手, 回答关于孩子成长、健康、教育的各种问题。
+
+你可以使用搜索工具查找商品、活动和文章来辅助回答。
+
+回答原则:
+1. 用中文, 语气温暖亲切
+2. 如果用户提到具体孩子, 参考提供的家庭上下文
+3. 需要推荐时使用搜索工具
+4. 可以生成 [TASK: {"title": "任务名", "dimension": "身/心/智/行/富", "points": 10}] 标记来创建行动任务
+5. 不要编造医疗建议, 严重问题建议咨询医生""",
+    "analysis": """你是一个儿童健康报告解读专家。根据健康报告数据和调研问卷, 提供专业、易懂的分析。""",
+    "recommend": """你是一个儿童成长营养推荐助手。根据用户的需求和营养标签, 推荐合适的商品、活动或文章。""",
+}
+
+
+async def get_prompt(prompt_key: str) -> str:
+    """获取指定提示词,带缓存与降级。返回 str 内容(找不到/禁用返回空串)。"""
+    now = time.time()
+    if prompt_key in _cache and now - _cache_ts.get(prompt_key, 0) < _TTL_SECONDS:
+        return _cache[prompt_key]
+
+    try:
+        java = JavaClient()
+        client = await java._get_client()
+        resp = await client.post("/api/system-prompts/get", json={"promptKey": prompt_key}, timeout=5.0)
+        data = resp.json()
+        if data.get("code") == 200 and data.get("data"):
+            content = data["data"].get("content")
+            if content:
+                _cache[prompt_key] = content
+                _cache_ts[prompt_key] = now
+                return content
+    except Exception as e:
+        logger.warning("加载提示词 %s 失败: %s", prompt_key, e)
+
+    return DEFAULT_PROMPTS.get(prompt_key, "")
+
+
+def clear_cache(prompt_key: str | None = None):
+    """清除缓存(管理端修改后调用)"""
+    if prompt_key:
+        _cache.pop(prompt_key, None)
+        _cache_ts.pop(prompt_key, None)
+    else:
+        _cache.clear()
+        _cache_ts.clear()

+ 129 - 0
cfc-langgraph/app/rag/microbiome_loader.py

@@ -0,0 +1,129 @@
+"""菌群知识库加载器: 从 Java 后端同步文章到 ChromaDB"""
+import logging
+import httpx
+from langchain_chroma import Chroma
+from langchain_core.documents import Document
+from app.config import settings
+from app.rag.embeddings import get_embeddings
+from app.rag.splitter import get_knowledge_splitter
+
+logger = logging.getLogger(__name__)
+COLLECTION_NAME = "microbiome_kb"
+
+
+class MicrobiomeLoader:
+
+    def __init__(self):
+        self.embeddings = get_embeddings()
+        self.vectorstore = Chroma(
+            collection_name=COLLECTION_NAME,
+            embedding_function=self.embeddings,
+            persist_directory=settings.chroma_db_path,
+        )
+        self.splitter = get_knowledge_splitter()
+
+    async def fetch_articles(self, keyword=None) -> list[dict]:
+        client = httpx.AsyncClient(base_url=settings.java_base_url, timeout=120)
+        try:
+            if keyword:
+                resp = await client.post("/api/microbiome/article/search", json={"keyword": keyword})
+                data = resp.json()
+                if data.get("code") == 200:
+                    return data.get("data", [])
+            else:
+                page = 1
+                size = 50
+                records = []
+                while True:
+                    resp = await client.post("/api/microbiome/article/list",
+                                             json={"page": page, "size": size, "keyword": "", "category": ""})
+                    data = resp.json()
+                    if data.get("code") != 200:
+                        break
+                    body = data.get("data", {}) or {}
+                    items = body.get("records", []) or []
+                    records.extend(items)
+                    total = body.get("total", 0)
+                    if page * size >= int(total or 0) or not items:
+                        break
+                    page += 1
+                return records
+        except Exception as e:
+            logger.warning("获取菌群文章失败: %s", e)
+        finally:
+            await client.aclose()
+        return []
+
+    async def fetch_article(self, article_id: int) -> dict | None:
+        client = httpx.AsyncClient(base_url=settings.java_base_url, timeout=120)
+        try:
+            resp = await client.post("/api/microbiome/article/detail", json={"id": article_id})
+            data = resp.json()
+            if data.get("code") == 200:
+                return data.get("data")
+        except Exception as e:
+            logger.warning("获取菌群文章详情失败: %s", e)
+        finally:
+            await client.aclose()
+        return None
+
+    def _build_chunks(self, article: dict) -> list[Document]:
+        content = f"{article.get('title', '')}\n\n{article.get('content', '')}"
+        split_texts = self.splitter.split_text(content)
+        category = article.get("category", "科普")
+        tags = article.get("tags", "")
+        docs = []
+        for i, text in enumerate(split_texts):
+            docs.append(Document(
+                page_content=text,
+                metadata={
+                    "id": f"article_{article['id']}_{i}",
+                    "postId": str(article["id"]),
+                    "title": article.get("title", ""),
+                    "category": category,
+                    "tags": tags,
+                    "source_url": article.get("sourceUrl", ""),
+                    "pub_date": article.get("pubDate", ""),
+                    "chunk_index": i,
+                    "chunk_total": len(split_texts),
+                    "source": "microbiome_kb",
+                },
+            ))
+        return docs
+
+    async def sync_all(self):
+        articles = await self.fetch_articles()
+        if not articles:
+            logger.info("菌群知识库同步: 无文章")
+            return
+        try:
+            existing_ids = self.vectorstore.get()["ids"]
+            if existing_ids:
+                self.vectorstore.delete(existing_ids)
+        except Exception:
+            pass
+        all_docs = []
+        for article in articles:
+            all_docs.extend(self._build_chunks(article))
+        for i in range(0, len(all_docs), 200):
+            await self.vectorstore.aadd_documents(all_docs[i:i + 200])
+        logger.info("菌群知识库全量同步完成: %d 篇文章, %d 个chunk", len(articles), len(all_docs))
+
+    async def sync_one(self, article_id: int):
+        article = await self.fetch_article(article_id)
+        if not article:
+            logger.warning("菌群文章不存在: %d", article_id)
+            return
+        prefix = f"article_{article_id}_"
+        try:
+            existing = self.vectorstore.get()
+            ids_to_delete = [i for i in existing["ids"] if i.startswith(prefix)]
+            if ids_to_delete:
+                self.vectorstore.delete(ids_to_delete)
+        except Exception:
+            pass
+        docs = self._build_chunks(article)
+        if docs:
+            await self.vectorstore.aadd_documents(docs)
+        logger.info("菌群文章同步完成: %d (%s), %d 个chunk",
+                    article_id, article.get("title", ""), len(docs))

+ 74 - 22
cfc-langgraph/app/tasks/knowledge_sync.py

@@ -30,8 +30,46 @@ def _save_sync_state(state: dict):
         json.dump(state, f)
 
 
+def _delete_by_prefix(vectorstore, prefix: str):
+    try:
+        existing = vectorstore.get()
+        ids = [i for i in existing.get("ids", []) if i.startswith(prefix)]
+        if ids:
+            vectorstore.delete(ids)
+            logger.info("已删除前缀 %s 的 %d 个旧块", prefix, len(ids))
+    except Exception as e:
+        logger.warning("删除前缀 %s 旧块失败: %s", prefix, e)
+
+
+def _build_chunks(splitter, items: list[dict], source: str) -> list[Document]:
+    chunks = []
+    for item in items:
+        item_id = item.get("id")
+        title = item.get("title", "")
+        content = item.get("content", "")
+        if item_id is None or not content:
+            continue
+        full_text = f"{title}\n\n{content}"
+        split_texts = splitter.split_text(full_text)
+        for i, text in enumerate(split_texts):
+            metadata = {
+                "id": f"{source}_{item_id}_{i}",
+                "source": source,
+                "post_id": str(item_id),
+                "title": title,
+                "category": item.get("category", ""),
+                "tags": item.get("tags", ""),
+                "source_url": item.get("sourceUrl", ""),
+                "pub_date": item.get("pubDate", ""),
+                "chunk_index": i,
+                "chunk_total": len(split_texts),
+            }
+            chunks.append(Document(page_content=text, metadata=metadata))
+    return chunks
+
+
 async def sync_knowledge_base():
-    """执行知识库同步 (全量+增量)"""
+    """同步三个数据源: articles 增量, microbiome/dan_knowledge 全量重建到 cfc_knowledge"""
     loader = KnowledgeLoader()
     splitter = get_knowledge_splitter()
     embeddings = get_embeddings()
@@ -43,35 +81,49 @@ async def sync_knowledge_base():
     )
 
     state = _load_sync_state()
+    BATCH_SIZE = 100
+    stats = {}
 
     articles = await loader.load_updated_since(state["last_sync"])
-    if not articles:
-        logger.info("知识库同步: 无更新内容")
+    article_chunks = []
+    if articles:
+        docs = loader.format_for_indexing(articles)
+        for doc in docs:
+            if not doc.get("content"):
+                continue
+            split_texts = splitter.split_text(doc["content"])
+            for i, text in enumerate(split_texts):
+                metadata = dict(doc["metadata"])
+                metadata["chunk_index"] = i
+                article_chunks.append(Document(page_content=text, metadata=metadata))
+    stats["article"] = len(article_chunks)
+    logger.info("文章增量: %d 篇 -> %d 块", len(articles), len(article_chunks))
+
+    microbiome = await loader.java.get_microbiome_articles()
+    _delete_by_prefix(vectorstore, "microbiome_")
+    microbiome_chunks = _build_chunks(splitter, microbiome, "microbiome")
+    stats["microbiome"] = len(microbiome_chunks)
+    logger.info("菌群知识库: %d 篇 -> %d 块", len(microbiome), len(microbiome_chunks))
+
+    dan_kb = await loader.java.get_dan_knowledge_base()
+    _delete_by_prefix(vectorstore, "dan_knowledge_")
+    dan_chunks = _build_chunks(splitter, dan_kb, "dan_knowledge")
+    stats["dan_knowledge"] = len(dan_chunks)
+    logger.info("知识库(dan): %d 篇 -> %d 块", len(dan_kb), len(dan_chunks))
+
+    all_chunks = article_chunks + microbiome_chunks + dan_chunks
+    if not all_chunks:
+        logger.info("知识库同步: 无新增内容")
         return
 
-    docs = loader.format_for_indexing(articles)
-
-    chunks = []
-    for doc in docs:
-        split_texts = splitter.split_text(doc["content"])
-        for i, text in enumerate(split_texts):
-            metadata = dict(doc["metadata"])
-            metadata["chunk_index"] = i
-            chunks.append(Document(page_content=text, metadata=metadata))
-
-    if not chunks:
-        logger.info("知识库同步: 无新增块")
-        return
-
-    BATCH_SIZE = 100
-    for i in range(0, len(chunks), BATCH_SIZE):
-        batch = chunks[i:i + BATCH_SIZE]
+    for i in range(0, len(all_chunks), BATCH_SIZE):
+        batch = all_chunks[i:i + BATCH_SIZE]
         await vectorstore.aadd_documents(batch)
         vectorstore.persist()
-        logger.debug("知识库同步进度: %d/%d", i + len(batch), len(chunks))
+        logger.debug("知识库同步进度: %d/%d", i + len(batch), len(all_chunks))
 
     import datetime
     state["last_sync"] = datetime.datetime.now().isoformat()
     _save_sync_state(state)
 
-    logger.info("知识库同步完成: 新增 %d 篇文章, %d 个块", len(articles), len(chunks))
+    logger.info("知识库同步完成: 各源块数 %s", stats)

+ 92 - 1
cfc-langgraph/app/tools/java_client.py

@@ -27,7 +27,7 @@ class JavaClient:
             )
             self._client = httpx.AsyncClient(
                 base_url=self._base_url,
-                timeout=httpx.Timeout(10.0, connect=3.0),
+                timeout=httpx.Timeout(120.0, connect=3.0),
                 limits=limits,
             )
         return self._client
@@ -86,3 +86,94 @@ class JavaClient:
         if data.get("code") == 200:
             return data.get("data", {})
         return {}
+
+    async def get_microbiome_articles(self) -> list[dict]:
+        """拉取菌群知识库文章(分页,避免单次响应过大导致 Broken pipe)"""
+        client = await self._get_client()
+        page = 1
+        size = 50
+        records = []
+        while True:
+            resp = await client.post("/api/microbiome/article/list",
+                                     json={"page": page, "size": size, "keyword": "", "category": ""})
+            data = resp.json()
+            if data.get("code") != 200:
+                break
+            body = data.get("data", {}) or {}
+            items = body.get("records", []) or []
+            records.extend(items)
+            total = body.get("total", 0)
+            if page * size >= int(total or 0) or not items:
+                break
+            page += 1
+        return records
+
+    async def get_dan_knowledge_base(self) -> list[dict]:
+        """拉取知识库内容(dan_knowledge_base 表,仅启用 status=1)"""
+        client = await self._get_client()
+        page = 1
+        size = 1000
+        records = []
+        while True:
+            resp = await client.post("/api/admin/knowledge-base/list", json={"page": page, "size": size, "status": 1})
+            data = resp.json()
+            if data.get("code") != 200:
+                break
+            body = data.get("data", {}) or {}
+            items = body.get("records", []) or []
+            records.extend(items)
+            total = body.get("total", 0)
+            if page * size >= int(total or 0) or not items:
+                break
+            page += 1
+        return records
+
+    async def get_member_reports(self, member_id: int) -> list[dict]:
+        """获取某个家庭成员的健康报告列表"""
+        client = await self._get_client()
+        resp = await client.post("/api/health/report/list", json={"memberId": member_id})
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def get_report_indicators(self, report_id: int) -> list[dict]:
+        """获取健康报告的所有指标明细"""
+        client = await self._get_client()
+        resp = await client.post("/api/health/report/indicator/list", json={"reportId": report_id})
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def query_health_knowledge(self, item_type: str, item_name: str) -> dict | None:
+        """查询健康知识库中某个指标/菌属/营养素的定义"""
+        client = await self._get_client()
+        resp = await client.post("/api/health/knowledge/query", json={
+            "itemType": item_type, "itemName": item_name,
+        })
+        data = resp.json()
+        if data.get("code") == 200 and data.get("data"):
+            return data["data"]
+        return None
+
+    async def batch_query_health_knowledge(self, queries: list[dict]) -> list[dict]:
+        """批量查询健康知识库"""
+        client = await self._get_client()
+        resp = await client.post("/api/health/knowledge/batch-query", json={"queries": queries})
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", [])
+        return []
+
+    async def get_family_context(self, user_id: int, intent_type: str = "child_info", params: dict | None = None) -> dict:
+        """获取家庭上下文数据(通过公开的 /api/ai/context 端点)"""
+        client = await self._get_client()
+        body = {"user_id": str(user_id), "intent_type": intent_type}
+        if params:
+            body["params"] = params
+        resp = await client.post("/api/ai/context", json=body)
+        data = resp.json()
+        if data.get("code") == 200:
+            return data.get("data", {})
+        return {}