Pārlūkot izejas kodu

Merge pull request #494 from Shawnxyxy/feature/health-record-agent

Feature/health record agent
jjyaoao 2 mēneši atpakaļ
vecāks
revīzija
1b14c310ef
35 mainītis faili ar 3867 papildinājumiem un 231 dzēšanām
  1. 9 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/.gitignore
  2. 165 86
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/README.md
  3. 24 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/.env.example
  4. 15 2
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/agents/advice.py
  5. 2 1
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/agents/base.py
  6. 9 2
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/agents/report.py
  7. 16 2
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/api/main.py
  8. 168 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/api/routes/diet.py
  9. 63 15
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/api/routes/health.py
  10. 11 2
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/core/config.py
  11. 37 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/memory/__init__.py
  12. 479 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/memory/store.py
  13. 13 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/__init__.py
  14. 43 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/embedding.py
  15. 125 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/indexers.py
  16. 156 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/milvus_store.py
  17. 81 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/retriever.py
  18. 24 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/scripts/reindex_milvus.py
  19. 37 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/diet_errors.py
  20. 650 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/diet_pipeline.py
  21. 39 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/diet_recommend_service.py
  22. 114 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/diet_schemas.py
  23. 62 7
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/health_analysis.py
  24. 95 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/observability_views.py
  25. 13 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/tools/__init__.py
  26. 106 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/tools/diet_tools.py
  27. 0 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/data/.gitkeep
  28. 528 30
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/app.js
  29. 146 45
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/index.html
  30. 0 0
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/screenshots/.gitkeep
  31. BIN
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/screenshots/diet.png
  32. BIN
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/screenshots/reflect.png
  33. BIN
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/screenshots/report.png
  34. 633 38
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/style.css
  35. 4 1
      Co-creation-projects/Shawnxyxy-HealthRecordAgent/requirements.txt

+ 9 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/.gitignore

@@ -0,0 +1,9 @@
+data/*.db
+backend/.venv/
+
+# 记忆评测产物与本地脚本(不入库)
+backend/memory_eval_result_v2.json
+backend/memory_eval_report.md
+backend/scripts/eval_memory_labeled.py
+backend/scripts/memory_eval_dataset.json
+backend/scripts/seed_memory_eval_user.py

+ 165 - 86
Co-creation-projects/Shawnxyxy-HealthRecordAgent/README.md

@@ -1,105 +1,184 @@
-# 健康档案助手-HealthRecordAgent
-
-> 基于 HelloAgents 框架的多智能体健康档案助手
-> 支持体检报告、健康档案(文本、PDF)结构化解析、指标解读和健康建议生成
-
-## 📝 项目简介
-
-在现实场景中,体检报告通常以 PDF 或表格形式给出,包含大量医学指标,但:
-- 指标含义不清晰
-- 正常 / 异常范围难以判断
-- 缺乏整体健康解读和可执行建议
-
-本项目通过 **多智能体协作(Multi-Agent)** 的方式,对体检报告进行:
-- 结构化解析
-- 指标语义解释
-- 风险初步评估
-- 个性化健康建议生成
-
-适用于:
-- 个人健康管理
-- 健康数据理解与科普
-- 多智能体应用 / Agent 系统毕业设计示例
-
-## ✨ 核心功能
-
-- [x] **体检报告解析**
-  - 支持 PDF / 文本形式的体检报告输入
-  - 自动抽取关键健康指标(如血常规、生化指标等)
-- [x] **健康指标解读**
-  - 给出指标含义、参考范围与异常提示
-  - 使用自然语言进行“非医学术语”的解释
-- [x] **多智能体协作分析**
-  - 不同 Agent 分工完成解析、判断与建议生成
-  - 提高分析的结构性与可解释性
-- [ ] **健康档案长期管理(规划中)**
-  - 多次体检记录对比
-  - 趋势分析与健康变化追踪
-
-## 🛠️ 技术栈
-
-- HelloAgents框架
-- 使用的智能体范式(如ReAct、Plan-and-Solve等)
-    Plan-and-Solve + ReAct(混合)
-    由一个 Planner 规划健康分析流程,多个 Specialist Agent 按步骤协作完成健康档案解读   
-- **后端框架**: FastAPI + Uvicorn
-- **异步处理**: asyncio
-- **PDF 解析**: pdfplumber
-
-智能体协作方式:
-- PlannerAgent 负责整体分析流程规划  
-- HealthIndicatorAgent 解析指标并解读  
-- RiskAssessmentAgent 进行风险评估  
-- AdviceAgent 生成个性化健康建议  
-- ReportAgent 汇总输出最终报告 
-
-## 🚀 快速开始
-
-### 环境要求
-
-- Python 3.10+
-- 其他要求
-
-### 安装依赖
+# HealthRecordAgent · 健康档案助手
 
+基于 **HelloAgents**(`HelloAgentsLLM`)与 **FastAPI** 的多智能体应用:体检报告解读、饮食推荐与执行反馈闭环,可选 **Milvus 语义检索 + SQLite** 长期记忆。
+
+> **声明**:本项目输出仅供健康信息与流程演示,**不能替代**执业医师的诊断或处方。
+
+---
+
+## 界面概览
+
+截图位于 **`frontend/screenshots/`**,更新时替换同名文件即可。
+
+**档案与报告**(`report.png`)
+
+![档案与报告](frontend/screenshots/report.png)
+
+**饮食推荐**(`diet.png`)
+
+![饮食推荐](frontend/screenshots/diet.png)
+
+**执行反馈 Reflect**(`reflect.png`)
+
+![执行反馈 Reflect](frontend/screenshots/reflect.png)
+
+---
+
+## 功能概览
+
+| 模块 | 说明 |
+|------|------|
+| **档案分析** | 文本或 PDF 体检报告 → 多 Agent 流水线(规划 → 指标 → 风险 → 建议 → 报告),异步任务可轮询状态 |
+| **饮食助手** | 自然语言 **今日饮食日志** → LLM 解析与营养汇总 → 营养师 / 教练 / 习惯 多阶段结构化输出;结合历史记忆与 Reflect 反馈 |
+| **长期记忆** | SQLite 存运行记录与反馈;可选 Milvus 向量索引 + Hybrid 检索(失败回退 SQL 列表) |
+| **可观测** | `pipeline_trace`、`errors` / `degraded`、`rag_debug`;报告/饮食 run 的 observability 接口与饮食 **replay** |
+| **前端** | 静态页 + Tab(档案分析 \| 饮食助手 \| 历史);类 Apple Health 信息层级;**开发者模式**控制技术细节展示;饮食 **Reflect** 反馈闭环 |
+
+---
+
+## 架构要点
+
+- **编排**:健康分析为 **Plan-and-Execute** 风格(`PlannerAgent` 后多 Specialist 串行);饮食为 **多阶段流水线**(食物解析 → 营养师 → 教练 → 习惯),各阶段 **Pydantic** 校验与失败降级。
+- **工具**:饮食场景内 **Tool Use**(如营养查询、活动/睡眠摘要 Mock,可替换真实数据源)。
+- **LLM**:通过 `hello_agents.HelloAgentsLLM` 调用兼容 OpenAI 的 API;Agent 基类与业务流水线在本仓库 `backend/agents`、`backend/service` 中实现。
+- **记忆与 RAG**:历史报告、饮食与反馈等落在 **SQLite**;需要语义召回时,对记忆做 **向量索引(Milvus)**,按用户与场景检索相关片段并注入 Agent。Milvus 未开或不可用时 **自动回退** 为基于 SQL 的近期记忆列表。
+
+---
+
+## 目录结构(节选)
+
+```
+HealthRecordAgent/
+├── README.md
+├── requirements.txt
+├── data/                    # 默认 SQLite:health_memory.db(可 .gitignore)
+├── backend/
+│   ├── api/main.py          # FastAPI 入口
+│   ├── agents/              # 报告分析各 Agent
+│   ├── service/             # health_analysis、diet_pipeline 等
+│   ├── memory/              # SQLite 存取
+│   ├── rag/                 # 嵌入、Milvus、统一 retrieve
+│   └── tools/               # 饮食相关工具
+└── frontend/
+    ├── index.html, app.js, style.css
+    └── screenshots/         # README 界面截图(见「界面概览」)
+```
+
+---
+
+## 环境要求
+
+- **Python**:3.10+(建议使用虚拟环境)
+- **可选**:本地 **Milvus**(Docker)与可用的 **Embedding** 接口,用于开启 RAG
+
+---
+
+## 快速开始
+
+### 1. 安装依赖
+
+进入 **本 README 所在目录**(即 `HealthRecordAgent` 项目根目录):
+
+```bash
+python3 -m venv backend/.venv
+source backend/.venv/bin/activate   # Windows: backend\.venv\Scripts\activate
 pip install -r requirements.txt
+```
+
+### 2. 配置环境变量
 
-### 配置API密钥
+在 **`backend/`** 下创建 `.env`(`python-dotenv` 随进程工作目录加载;**请在 `backend` 目录下启动 Uvicorn**,以便正确读取 `backend/.env`):
 
-# 创建.env文件
+```bash
+cd backend
 cp .env.example .env
+# 编辑 .env:至少配置 OPENAI_API_KEY;使用兼容网关时需配置 OPENAI_BASE_URL
+```
+
+主要变量说明见 **`backend/.env.example`**。开启语义记忆检索时设置 `RAG_ENABLED=true`,并保证 `MILVUS_URI` 与嵌入相关变量可用。
+
+### 3. 启动后端
+
+```bash
+cd backend
+source .venv/bin/activate   # 若尚未激活虚拟环境
+python -m uvicorn api.main:app --host 127.0.0.1 --port 8000 --reload
+```
+
+- Swagger:**http://127.0.0.1:8000/docs**
+- 路由前缀:**`/api`**(例如 `POST /api/health/analysis`)
+
+### 4. 启动前端(静态服务)
+
+另开终端:
+
+```bash
+cd frontend
+python3 -m http.server 8080 --bind 127.0.0.1
+```
+
+浏览器打开:**http://127.0.0.1:8080/**  
+
+前端默认请求 **`http://127.0.0.1:8000`**(见 `frontend/app.js` 中 `API_BASE`),请与后端端口一致。
+
+---
+
+## API 一览
+
+### 健康分析
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/api/health/analysis` | 文本报告分析,返回 `task_id` |
+| POST | `/api/health/analysis/pdf` | 上传 PDF 分析 |
+| GET | `/api/health/task_status/{task_id}` | 任务与 Agent 状态 |
+| GET | `/api/health/users/{user_id}/report_history` | 用户历史报告 |
+| GET | `/api/health/report_runs/{task_id}` | 单次运行详情 |
+| GET | `/api/health/report_runs/{task_id}/observability` | 可观测性摘要 |
+
+### 饮食
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/api/diet/recommend` | 饮食推荐(`context.today_food_log_text` 等) |
+| POST | `/api/diet/reflect` | 是否按推荐执行及原因(闭环记忆) |
+| GET | `/api/diet/users/{user_id}/runs` | 饮食运行历史 |
+| GET | `/api/diet/users/{user_id}/reflect_history` | 反馈历史 |
+| GET | `/api/diet/runs/{run_id}` | 单次饮食 run |
+| GET | `/api/diet/runs/{run_id}/observability` | 可观测性视图 |
+| POST | `/api/diet/runs/{run_id}/replay` | 同输入重跑(新 `run_id`) |
+
+---
 
-# 编辑.env文件,填入你的API密钥
+## Milvus(可选)
 
+1. 使用官方 Docker Compose 或单机镜像拉起 Milvus,保证 **`19530`** 可访问(与 `MILVUS_URI` 一致)。
+2. 设置 `RAG_ENABLED=true`,并配置与 LLM 网关一致的 **Embedding** 调用(见 `.env.example`)。
+3. 需要为历史数据建索引时,可使用仓库内脚本(若存在)如 `backend/scripts/reindex_milvus.py` 按需执行。
 
-### 运行项目
+未启用 Milvus 时,检索会自动使用 **SQL 侧记忆列表**作为回退,不影响主流程演示。
 
-uvicorn backend.api.routes.main:app --reload
+---
 
-服务启动后,可通过浏览器或前端调用 API 接口:
-	•	文本报告分析: POST /api/health/analysis
-	•	PDF 报告分析: POST /api/health/analysis/pdf
-	•	任务状态查询: GET /api/health/task_status/{task_id}
+## 常见问题
 
-## 🎯 项目亮点
-	•	多智能体分工协作,结构化解析体检报告
-	•	支持文本与 PDF 输入,自动抽取健康指标
-	•	可扩展健康档案管理与趋势分析
-	•	异步任务处理,前端实时显示 Agent 执行状态
+- **前端能开但接口报错**:确认后端已启动且端口为 **8000**,或与 `frontend/app.js` 里 `API_BASE` 一致。
+- **RAG 不生效**:检查 `RAG_ENABLED`、Milvus 进程与嵌入 API;响应中的 `rag_debug.mode` 可帮助判断当前是 `milvus` 还是回退。
+- **数据库文件位置**:默认 **`HealthRecordAgent/data/health_memory.db`**,可通过环境变量 `HEALTH_MEMORY_DB_PATH` 覆盖。
 
-## 📖 使用示例
+---
 
-![报告分析](frontend/screenshots/example.png)
+## 相关链接
 
-## 🤝 贡献指南
+- [Hello-Agents 教程与社区](https://github.com/datawhalechina/hello-agents)
+- 作者:[@Shawnxyxy](https://github.com/Shawnxyxy)
 
-欢迎提出Issue和Pull Request!
+## 致谢
 
-## 👤 作者
+感谢 **DataWhale** 与 **Hello-Agents** 项目提供的教程与 `hello-agents` 依赖生态。
 
-- GitHub: [@Shawnxyxy](https://github.com/Shawnxyxy)
-- Email: 852679909@qq.com
+---
 
-## 🙏 致谢
+## 贡献与许可
 
-感谢Datawhale社区和Hello-Agents项目!
+欢迎 Issue / PR。使用本项目时请遵守仓库根目录及上游社区的许可约定;若作为学习案例引用,建议注明出处。

+ 24 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/.env.example

@@ -0,0 +1,24 @@
+# 复制为 backend/.env 后填写真实值
+# cp .env.example .env
+
+# ---------- LLM(OpenAI 兼容接口)----------
+OPENAI_API_KEY=
+OPENAI_BASE_URL=
+OPENAI_MODEL_ID=qwen-turbo
+
+# ---------- RAG / Milvus(可选)----------
+# 开启后需本地或远程 Milvus,并配置嵌入接口
+RAG_ENABLED=false
+RAG_TOP_K=5
+MILVUS_URI=http://127.0.0.1:19530
+MILVUS_TOKEN=
+MILVUS_COLLECTION=health_memory_chunks
+
+# 嵌入(可与 LLM 共用 base_url,或单独指定)
+EMBEDDING_API_KEY=
+EMBEDDING_BASE_URL=
+EMBEDDING_MODEL=text-embedding-v1
+
+# ---------- 数据(可选)----------
+# 默认:项目根目录下 data/health_memory.db
+# HEALTH_MEMORY_DB_PATH=

+ 15 - 2
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/agents/advice.py

@@ -18,12 +18,20 @@ class AdviceAgent(BaseAgent):
         risk_factors = input_data.get("risk_factors", [])
         potential_conditions = input_data.get("potential_conditions", [])
         confidence = input_data.get("confidence", 0.0)
+        if isinstance(input_data.get("risk_assessment"), dict):
+            ra = input_data["risk_assessment"]
+            overall_risk_level = ra.get("overall_risk_level", overall_risk_level)
+            risk_factors = ra.get("risk_factors", risk_factors)
+            potential_conditions = ra.get("potential_conditions", potential_conditions)
+            confidence = ra.get("confidence", confidence)
+        retrieved_memory = str(input_data.get("retrieved_memory") or "(暂无召回记忆)")
 
         prompt = self._build_prompt(
             overall_risk_level,
             risk_factors,
             potential_conditions,
-            confidence
+            confidence,
+            retrieved_memory,
         )
 
         response = await self.think(prompt)
@@ -37,13 +45,15 @@ class AdviceAgent(BaseAgent):
             }
 
         self.set_state("completed")
+        return result
 
     def _build_prompt(
         self,
         overall_risk_level: str,
         risk_factors: List[str],
         potential_conditions: List[str],
-        confidence: float
+        confidence: float,
+        retrieved_memory: str,
     ) -> str:
         return f"""
 你是一名专业的健康管理助手。
@@ -55,6 +65,9 @@ class AdviceAgent(BaseAgent):
 - 潜在健康问题:{potential_conditions}
 - 评估置信度:{confidence}
 
+历史记忆召回(RAG):
+{retrieved_memory}
+
 请遵循以下原则:
 - 不进行医学诊断
 - 建议应偏向生活方式、预防、监测和就医提示

+ 2 - 1
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/agents/base.py

@@ -18,9 +18,10 @@ from enum import Enum
 # 全局任务状态管理
 TASKS = {}
 
-def create_task(task_id: str):
+def create_task(task_id: str, user_id: str | None = None):
     TASKS[task_id] = {
         "task_id": task_id,
+        "user_id": user_id,
         "state": "running",
         "agents": {
             "PlannerAgent": "pending",

+ 9 - 2
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/agents/report.py

@@ -18,10 +18,13 @@ class ReportAgent(BaseAgent):
         risk_assessment = input_data.get("risk_assessment", {})
         advice = input_data.get("advice") or {}
         confidence = risk_assessment.get("confidence", 0.5)
+        retrieved_memory = str(input_data.get("retrieved_memory") or "(暂无召回记忆)")
 
         advice_list = advice.get("advice", [])
 
-        prompt = self._build_prompt(indicators, risk_assessment, advice_list, confidence)
+        prompt = self._build_prompt(
+            indicators, risk_assessment, advice_list, confidence, retrieved_memory
+        )
         response = await self.think(prompt)
 
         try:
@@ -58,7 +61,8 @@ class ReportAgent(BaseAgent):
     indicators: List[Dict[str, Any]],
     risk_assessment: Dict[str, Any],
     advice_list: List[Dict[str, Any]],
-    confidence: float
+    confidence: float,
+    retrieved_memory: str,
 ) -> str:
 
         return f"""
@@ -77,6 +81,9 @@ class ReportAgent(BaseAgent):
 整体置信度:
 {confidence}
 
+历史记忆召回(RAG):
+{retrieved_memory}
+
 要求:
 - 不新增分析结论
 - 不修改已有判断

+ 16 - 2
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/api/main.py

@@ -1,13 +1,27 @@
+from contextlib import asynccontextmanager
+
 from fastapi import FastAPI
-from api.routes.health import router as health_router
 from fastapi.middleware.cors import CORSMiddleware
 
+from api.routes.diet import router as diet_router
+from api.routes.health import router as health_router
+from memory.store import init_db
+
+
+@asynccontextmanager
+async def lifespan(_app: FastAPI):
+    init_db()
+    yield
+
+
 app = FastAPI(
     title="HealthRecordAgent API",
-    version="1.0.0"
+    version="1.0.0",
+    lifespan=lifespan,
 )
 
 app.include_router(health_router, prefix="/api")
+app.include_router(diet_router, prefix="/api")
 
 app.add_middleware(
     CORSMiddleware,

+ 168 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/api/routes/diet.py

@@ -0,0 +1,168 @@
+import asyncio
+from typing import List, Literal, Optional
+
+from fastapi import APIRouter, Body, HTTPException
+from pydantic import BaseModel, Field, field_validator
+
+from memory.store import (
+    get_diet_run,
+    insert_diet_reflect,
+    list_diet_runs_for_user,
+    list_recent_diet_reflect,
+)
+from service.diet_recommend_service import DietRecommendService, replay_diet_run
+from service.observability_views import build_diet_observability
+from rag.indexers import index_reflect_event
+
+router = APIRouter()
+
+
+class DietContext(BaseModel):
+    today_food_log_text: str = Field(
+        ..., min_length=4, max_length=8000, description="今天吃了什么(自由文本)"
+    )
+    goal: Literal["muscle_gain", "fat_loss", "maintain"] = Field(
+        default="muscle_gain", description="健康目标"
+    )
+    channels: List[str] = Field(
+        default_factory=lambda: ["convenience_store", "delivery"],
+        description="可购买渠道标签",
+    )
+    activity_context: str = Field(default="", max_length=2000, description="运动/睡眠等上下文")
+    free_notes: str = Field(
+        default="", max_length=2000, description="额外说明(如只有便利店)"
+    )
+
+
+class DietRecommendRequest(BaseModel):
+    user_id: str = Field(..., min_length=1, max_length=256)
+    context: DietContext
+
+    @field_validator("user_id")
+    @classmethod
+    def strip_uid(cls, v: str) -> str:
+        v = v.strip()
+        if not v:
+            raise ValueError("user_id 不能为空")
+        return v
+
+
+class DietReplayRequest(BaseModel):
+    """可选:传入 user_id 时必须与 run 一致,防止误重放。"""
+
+    user_id: Optional[str] = Field(default=None, max_length=256)
+
+
+class DietReflectRequest(BaseModel):
+    user_id: str = Field(..., min_length=1, max_length=256)
+    diet_run_id: str = Field(..., min_length=8, max_length=64)
+    followed: bool = Field(..., description="是否按上次推荐执行")
+    reason_code: Optional[
+        Literal["cant_buy", "too_late", "dont_want", "executed_ok", "other"]
+    ] = Field(default=None, description="未执行或总结原因类型")
+    reason_detail: Optional[str] = Field(default=None, max_length=2000)
+
+    @field_validator("user_id", "diet_run_id")
+    @classmethod
+    def strip_ids(cls, v: str) -> str:
+        return v.strip()
+
+
+@router.post("/diet/recommend")
+async def diet_recommend(body: DietRecommendRequest):
+    """
+    饮食推荐:阶段 2 为 **Nutritionist → Coach → Habit** 三 Agent,固定 JSON schema + Pydantic 校验;
+    每阶段最多 2 次尝试,失败则降级并写入 `errors` / `degraded`。
+    仍落库 `diet_runs`,并读取 Reflect 记忆。
+    """
+    svc = DietRecommendService()
+    ctx = body.context.model_dump()
+    result = await svc.run(body.user_id, ctx)
+    return result
+
+
+@router.post("/diet/reflect")
+async def diet_reflect(body: DietReflectRequest):
+    """
+    Reflect:用户反馈是否执行及原因,写入 diet_reflect;下次 recommend 自动读取。
+    """
+    row = get_diet_run(body.diet_run_id)
+    if not row:
+        raise HTTPException(status_code=404, detail="diet_run_id 不存在")
+    if row.get("user_id") != body.user_id:
+        raise HTTPException(status_code=403, detail="该 run 不属于此 user_id")
+
+    rc = body.reason_code
+    if body.followed and rc is None:
+        rc = "executed_ok"
+
+    rid = insert_diet_reflect(
+        user_id=body.user_id,
+        diet_run_id=body.diet_run_id,
+        followed=body.followed,
+        reason_code=rc,
+        reason_detail=body.reason_detail,
+    )
+    asyncio.create_task(asyncio.to_thread(index_reflect_event, rid))
+    return {
+        "ok": True,
+        "reflect_id": rid,
+        "user_id": body.user_id,
+        "diet_run_id": body.diet_run_id,
+    }
+
+
+@router.get("/diet/users/{user_id}/runs")
+async def diet_runs(user_id: str, limit: int = 20):
+    uid = user_id.strip()
+    if not uid:
+        raise HTTPException(status_code=400, detail="user_id 无效")
+    return {"user_id": uid, "items": list_diet_runs_for_user(uid, limit=limit)}
+
+
+@router.get("/diet/users/{user_id}/reflect_history")
+async def diet_reflect_history(user_id: str, limit: int = 20):
+    uid = user_id.strip()
+    if not uid:
+        raise HTTPException(status_code=400, detail="user_id 无效")
+    return {"user_id": uid, "items": list_recent_diet_reflect(uid, limit=limit)}
+
+
+@router.get("/diet/runs/{run_id}")
+async def diet_run_detail(run_id: str):
+    row = get_diet_run(run_id.strip())
+    if not row:
+        raise HTTPException(status_code=404, detail="未找到该饮食推荐 run")
+    return row
+
+
+@router.get("/diet/runs/{run_id}/observability")
+async def diet_run_observability(run_id: str):
+    """
+    阶段 3:可观测性视图 — timeline / errors / replay 说明(trace 已持久化在 diet_runs)。
+    """
+    row = get_diet_run(run_id.strip())
+    if not row:
+        raise HTTPException(status_code=404, detail="未找到该饮食推荐 run")
+    return build_diet_observability(row)
+
+
+@router.post("/diet/runs/{run_id}/replay")
+async def diet_run_replay(
+    run_id: str,
+    body: DietReplayRequest | None = Body(default=None),
+):
+    """
+    阶段 3:用该 run 落库的 input 重跑流水线(新 run_id;列 replayed_from_run_id 与 output.replayed_from 溯源)。
+    Mock 工具确定性较高,LLM 输出仍可能不同。
+    """
+    rid = run_id.strip()
+    row = get_diet_run(rid)
+    if not row:
+        raise HTTPException(status_code=404, detail="run 不存在")
+    if body and body.user_id and body.user_id.strip() != row["user_id"]:
+        raise HTTPException(status_code=403, detail="user_id 与 run 不匹配")
+    try:
+        return await replay_diet_run(rid)
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))

+ 63 - 15
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/api/routes/health.py

@@ -1,37 +1,54 @@
-from fastapi import APIRouter, UploadFile, File
-from pydantic import BaseModel
 from io import BytesIO
-import pdfplumber
-
 from uuid import uuid4
 import asyncio
 
+import pdfplumber
+from fastapi import APIRouter, File, Form, HTTPException, UploadFile
+from pydantic import BaseModel, Field, field_validator
+
+from memory.store import get_report_run, list_report_runs_for_user
+from service.observability_views import build_report_observability
 from service.health_analysis import HealthAnalysisService
 
 router = APIRouter()
 
+
 class HealthRequest(BaseModel):
     report_text: str
+    user_id: str = Field(..., min_length=1, max_length=256)
+
+    @field_validator("user_id")
+    @classmethod
+    def normalize_user_id(cls, v: str) -> str:
+        v = v.strip()
+        if not v:
+            raise ValueError("user_id 不能为空")
+        return v
+
 
 @router.post("/health/analysis")
 async def analysis_health(request: HealthRequest):
     task_id = str(uuid4())
 
-    service = HealthAnalysisService(task_id=task_id)
-    # 异步启动分析,不阻塞接口返回
-    asyncio.create_task(service.run(request.report_text))
+    service = HealthAnalysisService(task_id=task_id, user_id=request.user_id)
+    asyncio.create_task(service.run(request.report_text, request.user_id))
+
+    return {"task_id": task_id, "user_id": request.user_id}
 
-    return {"task_id": task_id}
 
 @router.post("/health/analysis/pdf")
-async def analysis_health_pdf(file: UploadFile = File(...)):
+async def analysis_health_pdf(
+    file: UploadFile = File(...),
+    user_id: str = Form(...),
+):
+    uid = user_id.strip()
+    if not uid:
+        return {"error": "user_id 不能为空"}
 
-    # 读取 PDF 二进制
     contents = await file.read()
 
     text = ""
 
-    # 使用 pdfplumber 提取文本
     with pdfplumber.open(BytesIO(contents)) as pdf:
         for page in pdf.pages:
             page_text = page.extract_text()
@@ -42,11 +59,11 @@ async def analysis_health_pdf(file: UploadFile = File(...)):
         return {"error": "无法从PDF中提取文本"}
 
     task_id = str(uuid4())
-    service = HealthAnalysisService(task_id=task_id)
+    service = HealthAnalysisService(task_id=task_id, user_id=uid)
 
-    asyncio.create_task(service.run(text))
+    asyncio.create_task(service.run(text, uid))
 
-    return {"task_id": task_id}
+    return {"task_id": task_id, "user_id": uid}
 
 @router.get("/health/task_status/{task_id}")
 async def task_status(task_id: str):
@@ -56,4 +73,35 @@ async def task_status(task_id: str):
     if not status:
         return {"error": "task not found"}
 
-    return status
+    return status
+
+
+@router.get("/health/users/{user_id}/report_history")
+async def report_history(user_id: str, limit: int = 50):
+    uid = user_id.strip()
+    if not uid:
+        return {"error": "user_id 无效", "items": []}
+    items = list_report_runs_for_user(uid, limit=limit)
+    return {"user_id": uid, "items": items}
+
+
+@router.get("/health/report_runs/{task_id}")
+async def report_run_detail(task_id: str):
+    row = get_report_run(task_id)
+    if not row:
+        return {"error": "未找到该次分析记录(可能尚未落库或 task_id 无效)"}
+    return row
+
+
+@router.get("/health/report_runs/{task_id}/observability")
+async def report_run_observability(
+    task_id: str, include_raw_trace: bool = False
+):
+    """
+    阶段 3:体检分析可观测性 — 各 Agent trace 已随 report_runs 持久化(新产生任务)。
+    `include_raw_trace=true` 时返回完整 trace(体积可能较大)。
+    """
+    row = get_report_run(task_id.strip())
+    if not row:
+        raise HTTPException(status_code=404, detail="未找到该次分析记录")
+    return build_report_observability(row, include_raw_trace=include_raw_trace)

+ 11 - 2
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/core/config.py

@@ -33,8 +33,17 @@ class AgentConfig:
 # ========== RAG ==========
 @dataclass
 class RAGConfig:
-    enabled: bool = False
-    top_k: int = 5
+    enabled: bool = field(
+        default_factory=lambda: os.getenv("RAG_ENABLED", "false").lower() in ("1", "true", "yes")
+    )
+    top_k: int = field(default_factory=lambda: int(os.getenv("RAG_TOP_K", "5")))
+    milvus_uri: str = field(default_factory=lambda: os.getenv("MILVUS_URI", "http://127.0.0.1:19530"))
+    milvus_token: Optional[str] = field(default_factory=lambda: os.getenv("MILVUS_TOKEN"))
+    milvus_collection: str = field(default_factory=lambda: os.getenv("MILVUS_COLLECTION", "health_memory_chunks"))
+    embedding_model: str = field(default_factory=lambda: os.getenv("EMBEDDING_MODEL", "text-embedding-v1"))
+    embedding_api_key: Optional[str] = field(default_factory=lambda: os.getenv("EMBEDDING_API_KEY"))
+    embedding_base_url: Optional[str] = field(default_factory=lambda: os.getenv("EMBEDDING_BASE_URL"))
+    fallback_embedding_dim: int = field(default_factory=lambda: int(os.getenv("RAG_FALLBACK_EMBED_DIM", "64")))
 # ========== App ==========
 @dataclass
 class AppConfig:

+ 37 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/memory/__init__.py

@@ -0,0 +1,37 @@
+"""
+长期记忆与档案持久化(SQLite)。
+"""
+
+from memory.store import (
+    ensure_user,
+    format_reflect_memory_for_prompt,
+    get_db_path,
+    get_diet_reflect,
+    get_diet_run,
+    init_db,
+    insert_diet_reflect,
+    list_all_user_ids,
+    list_diet_runs_for_user,
+    list_recent_diet_reflect,
+    list_report_runs_for_user,
+    list_user_memory_chunks_sql,
+    save_completed_report_run,
+    save_diet_run,
+)
+
+__all__ = [
+    "get_db_path",
+    "init_db",
+    "ensure_user",
+    "save_completed_report_run",
+    "list_report_runs_for_user",
+    "save_diet_run",
+    "insert_diet_reflect",
+    "list_recent_diet_reflect",
+    "list_diet_runs_for_user",
+    "get_diet_run",
+    "get_diet_reflect",
+    "list_all_user_ids",
+    "list_user_memory_chunks_sql",
+    "format_reflect_memory_for_prompt",
+]

+ 479 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/memory/store.py

@@ -0,0 +1,479 @@
+"""
+SQLite 持久化:用户表 + 体检分析履历(report_runs)。
+同步 API,在 async 路由中通过 asyncio.to_thread 调用。
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import sqlite3
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+# backend/memory/store.py -> 项目根目录(HealthRecordAgent)
+_PROJECT_ROOT = Path(__file__).resolve().parents[2]
+_DEFAULT_DATA_DIR = _PROJECT_ROOT / "data"
+_DEFAULT_DB_PATH = _DEFAULT_DATA_DIR / "health_memory.db"
+
+
+def get_db_path() -> Path:
+    override = os.getenv("HEALTH_MEMORY_DB_PATH")
+    if override:
+        return Path(override).expanduser().resolve()
+    return _DEFAULT_DB_PATH
+
+
+def _connect() -> sqlite3.Connection:
+    path = get_db_path()
+    path.parent.mkdir(parents=True, exist_ok=True)
+    conn = sqlite3.connect(str(path), check_same_thread=False)
+    conn.row_factory = sqlite3.Row
+    conn.execute("PRAGMA foreign_keys = ON")
+    return conn
+
+
+def _ensure_legacy_columns(conn: sqlite3.Connection) -> None:
+    """旧库补列(阶段 3:体检 trace、饮食 replay 溯源)。"""
+    cols = {r[1] for r in conn.execute("PRAGMA table_info(report_runs)").fetchall()}
+    if "agent_trace_json" not in cols:
+        conn.execute("ALTER TABLE report_runs ADD COLUMN agent_trace_json TEXT")
+    cols_d = {r[1] for r in conn.execute("PRAGMA table_info(diet_runs)").fetchall()}
+    if "replayed_from_run_id" not in cols_d:
+        conn.execute("ALTER TABLE diet_runs ADD COLUMN replayed_from_run_id TEXT")
+
+
+def init_db() -> None:
+    """创建表与索引(幂等)。"""
+    with _connect() as conn:
+        conn.executescript(
+            """
+            CREATE TABLE IF NOT EXISTS users (
+                user_id TEXT PRIMARY KEY,
+                created_at TEXT NOT NULL
+            );
+
+            CREATE TABLE IF NOT EXISTS report_runs (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                task_id TEXT NOT NULL UNIQUE,
+                user_id TEXT NOT NULL,
+                created_at TEXT NOT NULL,
+                summary_text TEXT,
+                report_json TEXT NOT NULL,
+                agent_trace_json TEXT,
+                FOREIGN KEY (user_id) REFERENCES users (user_id)
+            );
+
+            CREATE INDEX IF NOT EXISTS idx_report_runs_user_created
+            ON report_runs (user_id, created_at DESC);
+
+            CREATE TABLE IF NOT EXISTS user_profiles (
+                user_id TEXT PRIMARY KEY,
+                profile_json TEXT NOT NULL DEFAULT '{}',
+                updated_at TEXT NOT NULL,
+                FOREIGN KEY (user_id) REFERENCES users (user_id)
+            );
+
+            CREATE TABLE IF NOT EXISTS diet_runs (
+                run_id TEXT PRIMARY KEY,
+                user_id TEXT NOT NULL,
+                created_at TEXT NOT NULL,
+                input_json TEXT NOT NULL,
+                steps_trace_json TEXT NOT NULL,
+                output_json TEXT NOT NULL,
+                replayed_from_run_id TEXT,
+                FOREIGN KEY (user_id) REFERENCES users (user_id)
+            );
+
+            CREATE TABLE IF NOT EXISTS diet_reflect (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                user_id TEXT NOT NULL,
+                diet_run_id TEXT NOT NULL,
+                followed INTEGER NOT NULL DEFAULT 0,
+                reason_code TEXT,
+                reason_detail TEXT,
+                created_at TEXT NOT NULL,
+                FOREIGN KEY (user_id) REFERENCES users (user_id)
+            );
+
+            CREATE INDEX IF NOT EXISTS idx_diet_runs_user_created
+            ON diet_runs (user_id, created_at DESC);
+
+            CREATE INDEX IF NOT EXISTS idx_diet_reflect_user_created
+            ON diet_reflect (user_id, created_at DESC);
+            """
+        )
+        _ensure_legacy_columns(conn)
+        conn.commit()
+    logger.info("SQLite 记忆库已就绪: %s", get_db_path())
+
+
+def ensure_user(user_id: str) -> None:
+    now = datetime.now(timezone.utc).isoformat()
+    with _connect() as conn:
+        conn.execute(
+            "INSERT OR IGNORE INTO users (user_id, created_at) VALUES (?, ?)",
+            (user_id, now),
+        )
+        conn.commit()
+
+
+def save_completed_report_run(
+    user_id: str,
+    task_id: str,
+    final_report: Dict[str, Any],
+    agent_trace: Optional[Dict[str, Any]] = None,
+) -> None:
+    """
+    分析成功完成后写入一条履历;失败时由调用方捕获日志,不影响主流程。
+    agent_trace: 各 Agent 的 trace 列表(阶段 3 可观测性)。
+    """
+    ensure_user(user_id)
+    summary = ""
+    report_inner = final_report.get("report") if isinstance(final_report, dict) else None
+    if isinstance(report_inner, dict):
+        s = report_inner.get("summary")
+        if isinstance(s, str):
+            summary = s[:8000]
+        elif s is not None:
+            summary = str(s)[:8000]
+
+    payload = json.dumps(final_report, ensure_ascii=False)
+    trace_payload = json.dumps(agent_trace, ensure_ascii=False) if agent_trace else None
+    now = datetime.now(timezone.utc).isoformat()
+
+    with _connect() as conn:
+        conn.execute(
+            """
+            INSERT INTO report_runs (task_id, user_id, created_at, summary_text, report_json, agent_trace_json)
+            VALUES (?, ?, ?, ?, ?, ?)
+            """,
+            (task_id, user_id, now, summary or None, payload, trace_payload),
+        )
+        conn.commit()
+
+
+def list_report_runs_for_user(user_id: str, limit: int = 50) -> List[Dict[str, Any]]:
+    limit = max(1, min(limit, 200))
+    with _connect() as conn:
+        cur = conn.execute(
+            """
+            SELECT task_id, user_id, created_at, summary_text
+            FROM report_runs
+            WHERE user_id = ?
+            ORDER BY created_at DESC
+            LIMIT ?
+            """,
+            (user_id, limit),
+        )
+        rows = cur.fetchall()
+    return [dict(r) for r in rows]
+
+
+def get_report_run(task_id: str) -> Optional[Dict[str, Any]]:
+    with _connect() as conn:
+        cur = conn.execute(
+            """
+            SELECT task_id, user_id, created_at, summary_text, report_json, agent_trace_json
+            FROM report_runs
+            WHERE task_id = ?
+            """,
+            (task_id,),
+        )
+        row = cur.fetchone()
+    if not row:
+        return None
+    d = dict(row)
+    if d.get("report_json"):
+        try:
+            d["report"] = json.loads(d["report_json"])
+        except json.JSONDecodeError:
+            d["report"] = None
+        del d["report_json"]
+    raw_trace = d.pop("agent_trace_json", None)
+    if raw_trace:
+        try:
+            d["agent_trace"] = json.loads(raw_trace)
+        except json.JSONDecodeError:
+            d["agent_trace"] = None
+    else:
+        d["agent_trace"] = None
+    return d
+
+
+def save_diet_run(
+    user_id: str,
+    run_id: str,
+    input_payload: Dict[str, Any],
+    steps_trace: List[Dict[str, Any]],
+    output_payload: Dict[str, Any],
+    replayed_from_run_id: Optional[str] = None,
+) -> None:
+    ensure_user(user_id)
+    now = datetime.now(timezone.utc).isoformat()
+    with _connect() as conn:
+        conn.execute(
+            """
+            INSERT INTO diet_runs (run_id, user_id, created_at, input_json, steps_trace_json, output_json, replayed_from_run_id)
+            VALUES (?, ?, ?, ?, ?, ?, ?)
+            """,
+            (
+                run_id,
+                user_id,
+                now,
+                json.dumps(input_payload, ensure_ascii=False),
+                json.dumps(steps_trace, ensure_ascii=False),
+                json.dumps(output_payload, ensure_ascii=False),
+                replayed_from_run_id,
+            ),
+        )
+        conn.commit()
+
+
+def insert_diet_reflect(
+    user_id: str,
+    diet_run_id: str,
+    followed: bool,
+    reason_code: str | None,
+    reason_detail: str | None,
+) -> int:
+    ensure_user(user_id)
+    now = datetime.now(timezone.utc).isoformat()
+    with _connect() as conn:
+        cur = conn.execute(
+            """
+            INSERT INTO diet_reflect (user_id, diet_run_id, followed, reason_code, reason_detail, created_at)
+            VALUES (?, ?, ?, ?, ?, ?)
+            """,
+            (
+                user_id,
+                diet_run_id,
+                1 if followed else 0,
+                reason_code,
+                (reason_detail or "")[:2000] or None,
+                now,
+            ),
+        )
+        conn.commit()
+        return int(cur.lastrowid)
+
+
+def list_recent_diet_reflect(user_id: str, limit: int = 8) -> List[Dict[str, Any]]:
+    limit = max(1, min(limit, 50))
+    with _connect() as conn:
+        cur = conn.execute(
+            """
+            SELECT id, diet_run_id, followed, reason_code, reason_detail, created_at
+            FROM diet_reflect
+            WHERE user_id = ?
+            ORDER BY created_at DESC
+            LIMIT ?
+            """,
+            (user_id, limit),
+        )
+        rows = cur.fetchall()
+    out = []
+    for r in rows:
+        d = dict(r)
+        d["followed"] = bool(d["followed"])
+        out.append(d)
+    return out
+
+
+def format_reflect_memory_for_prompt(user_id: str, limit: int = 5) -> str:
+    rows = list_recent_diet_reflect(user_id, limit=limit)
+    if not rows:
+        return "(暂无历史执行反馈)"
+    lines = []
+    for r in rows:
+        fl = "已执行" if r["followed"] else "未执行"
+        rc = r.get("reason_code") or "-"
+        rd = (r.get("reason_detail") or "").strip()
+        lines.append(
+            f"- {r['created_at'][:19]} | run={r['diet_run_id'][:8]}… | {fl} | 原因码={rc}"
+            + (f" | 说明={rd}" if rd else "")
+        )
+    return "\n".join(lines)
+
+
+def get_diet_run(run_id: str) -> Optional[Dict[str, Any]]:
+    with _connect() as conn:
+        cur = conn.execute(
+            """
+            SELECT run_id, user_id, created_at, input_json, steps_trace_json, output_json, replayed_from_run_id
+            FROM diet_runs
+            WHERE run_id = ?
+            """,
+            (run_id,),
+        )
+        row = cur.fetchone()
+    if not row:
+        return None
+    d = dict(row)
+    mapping = {
+        "input_json": "input",
+        "steps_trace_json": "steps_trace",
+        "output_json": "output",
+    }
+    for raw_key, out_key in mapping.items():
+        raw = d.pop(raw_key, None)
+        if raw:
+            try:
+                d[out_key] = json.loads(raw)
+            except json.JSONDecodeError:
+                d[out_key] = None
+        else:
+            d[out_key] = None
+    return d
+
+
+def list_diet_runs_for_user(user_id: str, limit: int = 30) -> List[Dict[str, Any]]:
+    limit = max(1, min(limit, 100))
+    with _connect() as conn:
+        cur = conn.execute(
+            """
+            SELECT run_id, user_id, created_at,
+                   json_extract(output_json, '$.meal_plan.total_est_protein_g') AS total_protein
+            FROM diet_runs
+            WHERE user_id = ?
+            ORDER BY created_at DESC
+            LIMIT ?
+            """,
+            (user_id, limit),
+        )
+        rows = cur.fetchall()
+    return [dict(r) for r in rows]
+
+
+def get_diet_reflect(reflect_id: int) -> Optional[Dict[str, Any]]:
+    with _connect() as conn:
+        cur = conn.execute(
+            """
+            SELECT id, user_id, diet_run_id, followed, reason_code, reason_detail, created_at
+            FROM diet_reflect
+            WHERE id = ?
+            """,
+            (reflect_id,),
+        )
+        row = cur.fetchone()
+    if not row:
+        return None
+    d = dict(row)
+    d["followed"] = bool(d["followed"])
+    return d
+
+
+def list_all_user_ids(limit: int = 5000) -> List[str]:
+    limit = max(1, min(limit, 20000))
+    with _connect() as conn:
+        cur = conn.execute(
+            """
+            SELECT user_id
+            FROM users
+            ORDER BY created_at DESC
+            LIMIT ?
+            """,
+            (limit,),
+        )
+        rows = cur.fetchall()
+    return [r["user_id"] for r in rows]
+
+
+def list_user_memory_chunks_sql(user_id: str, limit: int = 50) -> List[Dict[str, Any]]:
+    """
+    SQL 回退检索:按时间抓取用户近期文本记忆。
+    """
+    limit = max(1, min(limit, 500))
+    out: List[Dict[str, Any]] = []
+    with _connect() as conn:
+        r1 = conn.execute(
+            """
+            SELECT task_id, created_at, summary_text
+            FROM report_runs
+            WHERE user_id = ?
+            ORDER BY created_at DESC
+            LIMIT ?
+            """,
+            (user_id, limit),
+        ).fetchall()
+        for r in r1:
+            txt = (r["summary_text"] or "").strip()
+            if not txt:
+                continue
+            out.append(
+                {
+                    "chunk_id": f"report:{r['task_id']}",
+                    "user_id": user_id,
+                    "source_type": "report_summary",
+                    "source_id": r["task_id"],
+                    "created_at": r["created_at"],
+                    "text": txt[:8000],
+                }
+            )
+
+        r2 = conn.execute(
+            """
+            SELECT run_id, created_at, output_json
+            FROM diet_runs
+            WHERE user_id = ?
+            ORDER BY created_at DESC
+            LIMIT ?
+            """,
+            (user_id, limit),
+        ).fetchall()
+        for r in r2:
+            txt = ""
+            try:
+                obj = json.loads(r["output_json"] or "{}")
+                mp = obj.get("meal_plan") or {}
+                items = mp.get("items") or []
+                hints = (obj.get("habit_extras") or {}).get("execution_hints", [])
+                txt = ";".join(
+                    [f"{it.get('name')} {it.get('portion')} {it.get('why','')}" for it in items if isinstance(it, dict)]
+                )
+                if hints:
+                    txt += "\n执行提示:" + ";".join([str(h) for h in hints])
+            except Exception:
+                txt = str(r["output_json"] or "")
+            txt = txt.strip()
+            if not txt:
+                continue
+            out.append(
+                {
+                    "chunk_id": f"diet:{r['run_id']}",
+                    "user_id": user_id,
+                    "source_type": "diet_plan",
+                    "source_id": r["run_id"],
+                    "created_at": r["created_at"],
+                    "text": txt[:8000],
+                }
+            )
+
+        r3 = conn.execute(
+            """
+            SELECT id, created_at, followed, reason_code, reason_detail
+            FROM diet_reflect
+            WHERE user_id = ?
+            ORDER BY created_at DESC
+            LIMIT ?
+            """,
+            (user_id, limit),
+        ).fetchall()
+        for r in r3:
+            txt = f"执行={bool(r['followed'])} 原因={r['reason_code'] or '-'} 说明={r['reason_detail'] or ''}".strip()
+            out.append(
+                {
+                    "chunk_id": f"reflect:{r['id']}",
+                    "user_id": user_id,
+                    "source_type": "diet_reflect",
+                    "source_id": str(r["id"]),
+                    "created_at": r["created_at"],
+                    "text": txt[:8000],
+                }
+            )
+    out.sort(key=lambda x: x.get("created_at", ""), reverse=True)
+    return out[:limit]

+ 13 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/__init__.py

@@ -0,0 +1,13 @@
+"""
+RAG 模块导出。
+"""
+
+from rag.retriever import retrieve
+from rag.indexers import index_diet_run, index_reflect_event, index_report_run
+
+__all__ = [
+    "retrieve",
+    "index_report_run",
+    "index_diet_run",
+    "index_reflect_event",
+]

+ 43 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/embedding.py

@@ -0,0 +1,43 @@
+"""
+Embedding 封装。默认使用 OpenAI 兼容 embedding 接口。
+"""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+from typing import List
+
+from core.config import get_config
+
+logger = logging.getLogger(__name__)
+
+
+def _hash_embedding(text: str, dim: int = 64) -> List[float]:
+    """
+    本地兜底 embedding(仅在外部 embedding 失败时使用)。
+    目的不是高质量召回,而是保证流程可运行。
+    """
+    digest = hashlib.sha256(text.encode("utf-8")).digest()
+    vals: List[float] = []
+    for i in range(dim):
+        b = digest[i % len(digest)]
+        vals.append((b / 255.0) * 2.0 - 1.0)
+    return vals
+
+
+def embed_texts(texts: List[str]) -> List[List[float]]:
+    cfg = get_config()
+    model = cfg.rag.embedding_model
+    try:
+        from openai import OpenAI
+
+        client = OpenAI(
+            api_key=cfg.rag.embedding_api_key or cfg.llm.api_key,
+            base_url=cfg.rag.embedding_base_url or cfg.llm.base_url,
+        )
+        resp = client.embeddings.create(model=model, input=texts)
+        return [d.embedding for d in resp.data]
+    except Exception as e:
+        logger.warning("Embedding API 调用失败,回退哈希向量: %s", e)
+        return [_hash_embedding(t, dim=cfg.rag.fallback_embedding_dim) for t in texts]

+ 125 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/indexers.py

@@ -0,0 +1,125 @@
+"""
+将 SQLite 中的历史记录写入 Milvus 向量索引。
+"""
+
+from __future__ import annotations
+
+import hashlib
+from typing import Any, Dict, List, Optional
+
+from memory.store import (
+    get_diet_reflect,
+    get_diet_run,
+    get_report_run,
+    list_user_memory_chunks_sql,
+)
+from rag.embedding import embed_texts
+from rag.milvus_store import upsert_chunks
+
+
+def _chunk_id(source_type: str, source_id: str, text: str) -> str:
+    h = hashlib.sha1(text.encode("utf-8")).hexdigest()[:16]
+    return f"{source_type}:{source_id}:{h}"
+
+
+def _to_chunk(
+    user_id: str,
+    source_type: str,
+    source_id: str,
+    text: str,
+    created_at: str | None = None,
+) -> Dict[str, Any]:
+    return {
+        "chunk_id": _chunk_id(source_type, source_id, text),
+        "user_id": user_id,
+        "source_type": source_type,
+        "source_id": source_id,
+        "text": text[:8000],
+        "created_at": created_at or "",
+    }
+
+
+def _embed_and_upsert(chunks: List[Dict[str, Any]]) -> int:
+    if not chunks:
+        return 0
+    vecs = embed_texts([c["text"] for c in chunks])
+    for c, v in zip(chunks, vecs):
+        c["vector"] = v
+    return upsert_chunks(chunks)
+
+
+def index_report_run(task_id: str) -> int:
+    row = get_report_run(task_id)
+    if not row:
+        return 0
+    txt = row.get("summary_text") or ""
+    if not txt:
+        report = row.get("report") or {}
+        report_in = report.get("report") if isinstance(report, dict) else {}
+        txt = (report_in or {}).get("summary") or ""
+    if not txt:
+        return 0
+    chunk = _to_chunk(
+        user_id=row["user_id"],
+        source_type="report_summary",
+        source_id=row["task_id"],
+        text=txt,
+        created_at=row.get("created_at"),
+    )
+    return _embed_and_upsert([chunk])
+
+
+def index_diet_run(run_id: str) -> int:
+    row = get_diet_run(run_id)
+    if not row:
+        return 0
+    output = row.get("output") or {}
+    mp = (output.get("meal_plan") or {}) if isinstance(output, dict) else {}
+    hints = (output.get("habit_extras") or {}).get("execution_hints", [])
+    items = mp.get("items") or []
+    txt = ";".join(
+        [f"{it.get('name')} {it.get('portion')} {it.get('why','')}" for it in items if isinstance(it, dict)]
+    )
+    if hints:
+        txt += "\n执行提示:" + ";".join([str(x) for x in hints])
+    if not txt:
+        txt = str(output)[:2000]
+    chunk = _to_chunk(
+        user_id=row["user_id"],
+        source_type="diet_plan",
+        source_id=row["run_id"],
+        text=txt,
+        created_at=row.get("created_at"),
+    )
+    return _embed_and_upsert([chunk])
+
+
+def index_reflect_event(reflect_id: int | str) -> int:
+    row = get_diet_reflect(int(reflect_id))
+    if not row:
+        return 0
+    txt = f"执行={row['followed']} 原因={row.get('reason_code') or '-'} 说明={row.get('reason_detail') or ''}"
+    chunk = _to_chunk(
+        user_id=row["user_id"],
+        source_type="diet_reflect",
+        source_id=str(row["id"]),
+        text=txt,
+        created_at=row.get("created_at"),
+    )
+    return _embed_and_upsert([chunk])
+
+
+def reindex_user(user_id: str, limit: int = 200) -> int:
+    rows = list_user_memory_chunks_sql(user_id=user_id, limit=limit)
+    chunks = [
+        _to_chunk(
+            user_id=r["user_id"],
+            source_type=r["source_type"],
+            source_id=r["source_id"],
+            text=r["text"],
+            created_at=r.get("created_at"),
+        )
+        for r in rows
+        if r.get("text")
+    ]
+    return _embed_and_upsert(chunks)

+ 156 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/milvus_store.py

@@ -0,0 +1,156 @@
+"""
+Milvus 存储层(可选启用)。
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict, List, Optional
+
+from core.config import get_config
+
+logger = logging.getLogger(__name__)
+
+
+def _import_milvus():
+    try:
+        from pymilvus import (  # type: ignore
+            Collection,
+            CollectionSchema,
+            DataType,
+            FieldSchema,
+            connections,
+            utility,
+        )
+
+        return Collection, CollectionSchema, DataType, FieldSchema, connections, utility
+    except Exception:
+        return None
+
+
+def _connect() -> bool:
+    cfg = get_config().rag
+    pkg = _import_milvus()
+    if pkg is None:
+        logger.warning("pymilvus 不可用,RAG 将回退 SQL 检索")
+        return False
+    _, _, _, _, connections, _ = pkg
+    try:
+        connections.connect(
+            alias="default",
+            uri=cfg.milvus_uri,
+            token=cfg.milvus_token or None,
+        )
+        return True
+    except Exception as e:
+        logger.warning("Milvus 连接失败,RAG 回退 SQL 检索: %s", e)
+        return False
+
+
+def init_collection(dim: int) -> bool:
+    cfg = get_config().rag
+    pkg = _import_milvus()
+    if pkg is None or not _connect():
+        return False
+    Collection, CollectionSchema, DataType, FieldSchema, _, utility = pkg
+    name = cfg.milvus_collection
+    try:
+        if utility.has_collection(name):
+            return True
+        fields = [
+            FieldSchema(name="chunk_id", dtype=DataType.VARCHAR, is_primary=True, max_length=128),
+            FieldSchema(name="user_id", dtype=DataType.VARCHAR, max_length=256),
+            FieldSchema(name="source_type", dtype=DataType.VARCHAR, max_length=64),
+            FieldSchema(name="source_id", dtype=DataType.VARCHAR, max_length=128),
+            FieldSchema(name="created_at", dtype=DataType.VARCHAR, max_length=64),
+            FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=8192),
+            FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dim),
+        ]
+        schema = CollectionSchema(fields=fields, description="Health memory chunks")
+        col = Collection(name=name, schema=schema)
+        index_params = {"metric_type": "IP", "index_type": "AUTOINDEX", "params": {}}
+        col.create_index(field_name="vector", index_params=index_params)
+        col.load()
+        return True
+    except Exception as e:
+        logger.warning("Milvus 集合初始化失败: %s", e)
+        return False
+
+
+def upsert_chunks(chunks: List[Dict[str, Any]]) -> int:
+    cfg = get_config().rag
+    pkg = _import_milvus()
+    if pkg is None or not chunks:
+        return 0
+    Collection, _, _, _, _, _ = pkg
+    if not _connect():
+        return 0
+    dim = len(chunks[0].get("vector") or [])
+    if dim <= 0 or not init_collection(dim):
+        return 0
+    try:
+        col = Collection(cfg.milvus_collection)
+        col.load()
+        data = [
+            [c["chunk_id"] for c in chunks],
+            [c["user_id"] for c in chunks],
+            [c["source_type"] for c in chunks],
+            [c["source_id"] for c in chunks],
+            [c.get("created_at", "") for c in chunks],
+            [c["text"] for c in chunks],
+            [c["vector"] for c in chunks],
+        ]
+        col.upsert(data)
+        col.flush()
+        return len(chunks)
+    except Exception as e:
+        logger.warning("Milvus upsert 失败: %s", e)
+        return 0
+
+
+def search(
+    user_id: str,
+    query_vector: List[float],
+    top_k: int = 5,
+    source_types: Optional[List[str]] = None,
+) -> List[Dict[str, Any]]:
+    cfg = get_config().rag
+    pkg = _import_milvus()
+    if pkg is None or not query_vector:
+        return []
+    Collection, _, _, _, _, _ = pkg
+    if not _connect():
+        return []
+    try:
+        col = Collection(cfg.milvus_collection)
+        col.load()
+        expr = f'user_id == "{user_id}"'
+        if source_types:
+            src_expr = " or ".join([f'source_type == "{s}"' for s in source_types])
+            expr = f"{expr} and ({src_expr})"
+        res = col.search(
+            data=[query_vector],
+            anns_field="vector",
+            param={"metric_type": "IP", "params": {}},
+            limit=max(1, min(top_k, 20)),
+            expr=expr,
+            output_fields=["chunk_id", "source_type", "source_id", "text", "created_at"],
+        )
+        rows: List[Dict[str, Any]] = []
+        for hits in res:
+            for h in hits:
+                entity = h.entity
+                rows.append(
+                    {
+                        "chunk_id": entity.get("chunk_id"),
+                        "source_type": entity.get("source_type"),
+                        "source_id": entity.get("source_id"),
+                        "text": entity.get("text"),
+                        "created_at": entity.get("created_at"),
+                        "score": float(h.distance),
+                    }
+                )
+        return rows
+    except Exception as e:
+        logger.warning("Milvus 检索失败: %s", e)
+        return []

+ 81 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/rag/retriever.py

@@ -0,0 +1,81 @@
+"""
+统一检索接口:retrieve(user_id, query_context)。
+优先 Milvus 语义检索,不可用时回退 SQL 文本记忆。
+"""
+
+from __future__ import annotations
+
+import time
+from collections import Counter
+from typing import Any, Dict, List
+
+from core.config import get_config
+from memory.store import list_user_memory_chunks_sql
+from rag.embedding import embed_texts
+from rag.milvus_store import search
+
+
+def _build_query_text(query_context: Dict[str, Any]) -> str:
+    if not query_context:
+        return "健康记忆检索"
+    keys = [
+        "goal",
+        "query",
+        "scenario",
+        "risk_focus",
+        "free_notes",
+        "today_food_log_text",
+    ]
+    pieces: List[str] = []
+    for k in keys:
+        if k in query_context and query_context[k]:
+            pieces.append(f"{k}:{query_context[k]}")
+    if not pieces:
+        pieces.append(str(query_context))
+    return " | ".join(pieces)[:4000]
+
+
+def retrieve(user_id: str, query_context: Dict[str, Any], top_k: int | None = None) -> Dict[str, Any]:
+    cfg = get_config().rag
+    k = top_k or cfg.top_k
+    t0 = time.perf_counter()
+    query_text = _build_query_text(query_context)
+    chunks: List[Dict[str, Any]] = []
+    mode = "sql_fallback"
+
+    if cfg.enabled:
+        vec = embed_texts([query_text])[0]
+        chunks = search(user_id=user_id, query_vector=vec, top_k=k)
+        if chunks:
+            mode = "milvus"
+
+    if not chunks:
+        rows = list_user_memory_chunks_sql(user_id=user_id, limit=max(8, k * 3))
+        chunks = [
+            {
+                "chunk_id": r["chunk_id"],
+                "source_type": r["source_type"],
+                "source_id": r["source_id"],
+                "text": r["text"],
+                "created_at": r.get("created_at"),
+                "score": 0.0,
+            }
+            for r in rows[:k]
+        ]
+
+    summary = "\n".join([f"- [{c['source_type']}] {c['text']}" for c in chunks[:k]]) or "(暂无检索结果)"
+    source_breakdown = dict(Counter([c.get("source_type", "unknown") for c in chunks]))
+    ms = int((time.perf_counter() - t0) * 1000)
+    return {
+        "chunks": chunks[:k],
+        "summary": summary[:12000],
+        "debug": {
+            "rag_enabled": cfg.enabled,
+            "mode": mode,
+            "retrieved_count": len(chunks[:k]),
+            "top_k": k,
+            "retrieval_ms": ms,
+            "source_breakdown": source_breakdown,
+            "query_text_preview": query_text[:240],
+        },
+    }

+ 24 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/scripts/reindex_milvus.py

@@ -0,0 +1,24 @@
+"""
+将 SQLite 历史文本记忆回填到 Milvus。
+运行:
+  cd backend && .venv/bin/python scripts/reindex_milvus.py
+"""
+
+from __future__ import annotations
+
+from memory.store import list_all_user_ids
+from rag.indexers import reindex_user
+
+
+def main() -> None:
+    users = list_all_user_ids(limit=5000)
+    total = 0
+    for uid in users:
+        n = reindex_user(uid, limit=500)
+        total += n
+        print(f"user={uid} indexed={n}")
+    print(f"done users={len(users)} total_chunks={total}")
+
+
+if __name__ == "__main__":
+    main()

+ 37 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/diet_errors.py

@@ -0,0 +1,37 @@
+"""
+饮食流水线统一错误码(便于 Observability / failure mode 统计)。
+"""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Any, Dict, Optional
+
+
+class DietErrorCode(str, Enum):
+    LLM_PARSE_ERROR = "LLM_PARSE_ERROR"
+    VALIDATION_FAILED = "VALIDATION_FAILED"
+    LLM_TIMEOUT = "LLM_TIMEOUT"
+    TOOL_ERROR = "TOOL_ERROR"
+    STAGE_ABORTED = "STAGE_ABORTED"
+    DEGRADED_FALLBACK = "DEGRADED_FALLBACK"
+
+
+def diet_error_record(
+    stage: str,
+    code: DietErrorCode | str,
+    message: str,
+    *,
+    attempt: Optional[int] = None,
+    detail: Any = None,
+) -> Dict[str, Any]:
+    rec: Dict[str, Any] = {
+        "stage": stage,
+        "code": str(code),
+        "message": message,
+    }
+    if attempt is not None:
+        rec["attempt"] = attempt
+    if detail is not None:
+        rec["detail"] = detail
+    return rec

+ 650 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/diet_pipeline.py

@@ -0,0 +1,650 @@
+"""
+阶段 2:Nutritionist → Coach → Habit 三 Agent 串行流水线;
+每阶段 LLM 输出经 Pydantic 校验,失败自动重试;统一错误码与降级。
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import re
+import uuid
+from typing import Any, Dict, List, Optional, Tuple, Type
+
+from pydantic import BaseModel, ValidationError
+
+from core.llm_adapter import get_llm_adapter
+from memory.store import format_reflect_memory_for_prompt, save_diet_run
+from rag.indexers import index_diet_run
+from rag.retriever import retrieve
+from service.diet_errors import DietErrorCode, diet_error_record
+from service.diet_schemas import (
+    SCHEMA_VERSION,
+    CoachOutput,
+    FoodParseOutput,
+    HabitOutput,
+    MealPlan,
+    MealPlanItem,
+    NutritionistOutput,
+    NutritionSummary,
+)
+from tools.diet_tools import dispatch_tool
+
+logger = logging.getLogger(__name__)
+
+DIET_STAGE_TIMEOUT_SEC = 95.0
+MAX_STAGE_ATTEMPTS = 2
+
+
+def _extract_json_object(text: str) -> Optional[Dict[str, Any]]:
+    if not text:
+        return None
+    t = text.strip()
+    m = re.search(r"```(?:json)?\s*([\s\S]*?)```", t)
+    if m:
+        t = m.group(1).strip()
+    try:
+        return json.loads(t)
+    except json.JSONDecodeError:
+        i = t.find("{")
+        j = t.rfind("}")
+        if i >= 0 and j > i:
+            try:
+                return json.loads(t[i : j + 1])
+            except json.JSONDecodeError:
+                return None
+    return None
+
+
+def _goal_target_protein(context: Dict[str, Any]) -> float:
+    goal = str(context.get("goal") or "maintain")
+    if goal == "muscle_gain":
+        return 130.0
+    if goal == "fat_loss":
+        return 95.0
+    return 105.0
+
+
+def _fallback_food_parse(context: Dict[str, Any]) -> FoodParseOutput:
+    raw = str(context.get("today_food_log_text") or "")
+    pieces = [p.strip(" ,。;;\n\t") for p in re.split(r"[,,;;。]\s*", raw) if p.strip()]
+    items = []
+    for p in pieces[:10]:
+        items.append(
+            {
+                "meal_time": "",
+                "food_name": p[:40],
+                "portion_text": "未明确",
+                "confidence": 0.45,
+            }
+        )
+    return FoodParseOutput(
+        items=items,
+        nutrition_summary=NutritionSummary(),
+        parse_notes="(降级)食物解析阶段失败,已按日志片段做粗略拆分;营养值未估算。",
+    )
+
+
+def _fallback_nutritionist(context: Dict[str, Any], nutrition_summary: NutritionSummary) -> NutritionistOutput:
+    tgt = _goal_target_protein(context)
+    cur = float(nutrition_summary.protein_g or 0)
+    gap = max(0.0, tgt - cur)
+    return NutritionistOutput(
+        protein_gap_g=gap,
+        rationale="(降级)根据目标与日志解析结果估算蛋白缺口;LLM 阶段未通过校验或超时。",
+        suggested_lookup_queries=["鸡蛋,希腊酸奶,牛奶,豆浆,即食鸡胸肉"],
+        candidate_focus=["便利店高蛋白", "训练后补充"],
+    )
+
+
+def _fallback_coach(context: Dict[str, Any]) -> CoachOutput:
+    activity_text = str(context.get("activity_context") or "")
+    train = any(k in activity_text for k in ["训练", "力量", "健身", "workout", "training"])
+    return CoachOutput(
+        training_recovery_note="(降级)晚间安排力量训练时需优先补充蛋白与适量碳水;具体强度以当日体感为准。"
+        if train
+        else "(降级)非训练日仍以均衡蛋白为主,避免睡前过饱。",
+        timing_constraints="训练后 1~2 小时内尽量安排一餐;便利店即食优先选成分表蛋白较高的品类。"
+        if train
+        else "晚餐时间尽量规律,避免过晚大量进食。",
+        energy_note="",
+        coach_constraints_for_menu=["少油炸", "避免单次过量乳糖不耐受品类"],
+    )
+
+
+def _fallback_habit(
+    context: Dict[str, Any], reflect_mem: str, nutrition_summary: NutritionSummary
+) -> HabitOutput:
+    tgt = _goal_target_protein(context)
+    cur = float(nutrition_summary.protein_g or 0)
+    gap = max(25.0, min(80.0, max(0.0, tgt - cur)))
+    return HabitOutput(
+        reflect_alignment="(降级)未能生成完整习惯层输出;已忽略部分 Reflect 细节,仅做安全兜底推荐。"
+        + (" 近期有用户反馈记录,建议下次缩短决策链或检查模型输出格式。" if "暂无" not in reflect_mem else ""),
+        execution_hints=["优先买得到、可立即食用的组合", "若仍失败请改选外卖蛋白套餐"],
+        meal_plan=MealPlan(
+            items=[
+                MealPlanItem(
+                    name="希腊酸奶",
+                    portion="约 150g×1 杯",
+                    est_protein_g=min(18.0, gap * 0.35),
+                    why="便利店常见,蛋白密度较高",
+                ),
+                MealPlanItem(
+                    name="水煮蛋",
+                    portion="2 个",
+                    est_protein_g=12.0,
+                    why="易购买、蛋白稳定",
+                ),
+                MealPlanItem(
+                    name="豆浆",
+                    portion="300ml",
+                    est_protein_g=min(12.0, gap * 0.2),
+                    why="补充液体蛋白与水分",
+                ),
+            ],
+            total_est_protein_g=round(min(gap, 45.0), 1),
+            tips=["此为 schema/LLM 失败时的安全兜底菜单,建议重试或检查 API。"],
+        ),
+    )
+
+
+async def _run_validated_stage(
+    llm: Any,
+    stage: str,
+    prompt: str,
+    model_cls: Type[BaseModel],
+    errors: List[Dict[str, Any]],
+    timeout_sec: float = DIET_STAGE_TIMEOUT_SEC,
+) -> Tuple[Optional[BaseModel], List[Dict[str, Any]]]:
+    attempts: List[Dict[str, Any]] = []
+    repair_hint = ""
+    for attempt in range(MAX_STAGE_ATTEMPTS):
+        full_prompt = prompt
+        if repair_hint:
+            full_prompt += (
+                "\n\n【修正要求】上一输出未通过 schema 校验或无法解析:\n"
+                f"{repair_hint}\n请只输出 **一个** JSON 对象,字段齐全、类型正确,不要 Markdown。"
+            )
+        try:
+            raw = await asyncio.wait_for(llm.ainvoke(full_prompt), timeout=timeout_sec)
+        except asyncio.TimeoutError:
+            errors.append(
+                diet_error_record(
+                    stage,
+                    DietErrorCode.LLM_TIMEOUT,
+                    "LLM 调用超时",
+                    attempt=attempt,
+                )
+            )
+            attempts.append(
+                {"attempt": attempt, "ok": False, "error_code": DietErrorCode.LLM_TIMEOUT.value}
+            )
+            repair_hint = "上次超时;请输出更紧凑的 JSON,保留所有必填字段。"
+            continue
+        except Exception as e:
+            # 上游模型网关 5xx / SDK 异常都归一为阶段中止错误,避免接口直接 500。
+            errors.append(
+                diet_error_record(
+                    stage,
+                    DietErrorCode.STAGE_ABORTED,
+                    f"LLM 调用异常: {type(e).__name__}",
+                    attempt=attempt,
+                    detail=str(e)[:1200],
+                )
+            )
+            attempts.append(
+                {
+                    "attempt": attempt,
+                    "ok": False,
+                    "error_code": DietErrorCode.STAGE_ABORTED.value,
+                    "exception": type(e).__name__,
+                }
+            )
+            repair_hint = "上轮调用失败,请仅输出合法 JSON。"
+            continue
+
+        obj = _extract_json_object(raw)
+        if obj is None:
+            errors.append(
+                diet_error_record(
+                    stage,
+                    DietErrorCode.LLM_PARSE_ERROR,
+                    "无法从模型输出解析 JSON",
+                    attempt=attempt,
+                    detail=(raw[:1200] if raw else ""),
+                )
+            )
+            attempts.append(
+                {
+                    "attempt": attempt,
+                    "ok": False,
+                    "error_code": DietErrorCode.LLM_PARSE_ERROR.value,
+                    "llm_preview": (raw[:1500] if raw else ""),
+                }
+            )
+            repair_hint = "模型输出不是合法 JSON;请严格输出 JSON only。"
+            continue
+
+        try:
+            validated = model_cls.model_validate(obj)
+            attempts.append(
+                {
+                    "attempt": attempt,
+                    "ok": True,
+                    "error_code": None,
+                    "llm_preview": raw[:2500] if raw else "",
+                }
+            )
+            return validated, attempts
+        except ValidationError as ve:
+            err_text = ve.json()[:2000]
+            errors.append(
+                diet_error_record(
+                    stage,
+                    DietErrorCode.VALIDATION_FAILED,
+                    "Pydantic 校验失败",
+                    attempt=attempt,
+                    detail=err_text,
+                )
+            )
+            attempts.append(
+                {
+                    "attempt": attempt,
+                    "ok": False,
+                    "error_code": DietErrorCode.VALIDATION_FAILED.value,
+                    "validation_detail": err_text,
+                    "parsed_shape": {k: type(v).__name__ for k, v in obj.items()}
+                    if isinstance(obj, dict)
+                    else None,
+                }
+            )
+            repair_hint = err_text
+
+    return None, attempts
+
+
+def _prefetch_tools(user_id: str, context: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
+    trace_tools: List[Dict[str, Any]] = []
+    activity: Dict[str, Any] = {}
+    nutrition: Dict[str, Any] = {}
+    try:
+        activity = dispatch_tool(
+            "activity_sleep_summary", {"user_id": user_id}, user_id
+        )
+    except Exception as e:
+        trace_tools.append(
+            {
+                "tool": "activity_sleep_summary",
+                "ok": False,
+                "error": str(e),
+            }
+        )
+    else:
+        trace_tools.append({"tool": "activity_sleep_summary", "ok": True, "result": activity})
+
+    default_q = "鸡蛋,希腊酸奶,牛奶,豆浆,即食鸡胸肉"
+    try:
+        nutrition = dispatch_tool(
+            "nutrition_lookup",
+            {"query": context.get("nutrition_prefetch_query") or default_q},
+            user_id,
+        )
+    except Exception as e:
+        trace_tools.append({"tool": "nutrition_lookup", "ok": False, "error": str(e)})
+    else:
+        trace_tools.append({"tool": "nutrition_lookup", "ok": True, "result": nutrition})
+
+    return {"activity": activity, "nutrition": nutrition}, trace_tools
+
+
+class DietMultiAgentPipeline:
+    def __init__(self) -> None:
+        self.llm = get_llm_adapter()
+
+    async def run(
+        self,
+        user_id: str,
+        context: Dict[str, Any],
+        *,
+        replayed_from_run_id: Optional[str] = None,
+    ) -> Dict[str, Any]:
+        run_id = str(uuid.uuid4())
+        reflect_mem = format_reflect_memory_for_prompt(user_id, limit=8)
+        errors: List[Dict[str, Any]] = []
+        pipeline_trace: List[Dict[str, Any]] = []
+        rag_result = await asyncio.to_thread(
+            retrieve,
+            user_id,
+            {
+                "scenario": "diet_recommendation",
+                "goal": context.get("goal"),
+                "free_notes": context.get("free_notes", ""),
+                "today_food_log_text": str(context.get("today_food_log_text") or "")[:600],
+                "query": "训练后蛋白补齐与执行阻碍规避",
+            },
+        )
+        rag_summary = rag_result.get("summary", "(暂无召回记忆)")
+        pipeline_trace.append({"phase": "rag_retrieve", "debug": rag_result.get("debug", {})})
+
+        tool_bundle, tool_trace = _prefetch_tools(user_id, context)
+        pipeline_trace.append({"phase": "tool_prefetch", "tools": tool_trace})
+
+        degraded = False
+
+        # ----- Food Parse (LLM) -----
+        fp_prompt = f"""你是食物日志解析 Agent。请把用户自然语言饮食记录解析为 JSON。只输出一个 JSON 对象,不要 Markdown。
+
+结构:
+{{
+  "items": [
+    {{
+      "meal_time": string,      // breakfast/lunch/dinner/snack 或空字符串
+      "food_name": string,
+      "portion_text": string,
+      "confidence": number      // 0~1
+    }}
+  ],
+  "nutrition_summary": {{
+    "protein_g": number,
+    "carb_g": number,
+    "fat_g": number,
+    "fiber_g": number,
+    "sodium_mg": number,
+    "calories_kcal": number
+  }},
+  "parse_notes": string
+}}
+
+要求:
+- 从 today_food_log_text 中尽可能提取食物与份量;没有明确份量可写“未明确”。
+- nutrition_summary 给出粗略估计值;无法判断可填 0。
+- 字段齐全且类型正确。
+
+用户场景:
+{json.dumps(context, ensure_ascii=False, indent=2)}
+"""
+        fp, fp_attempts = await _run_validated_stage(
+            self.llm, "food_parse", fp_prompt, FoodParseOutput, errors
+        )
+        fp_fb = False
+        if fp is None:
+            fp = _fallback_food_parse(context)
+            fp_fb = True
+            degraded = True
+            errors.append(
+                diet_error_record(
+                    "food_parse",
+                    DietErrorCode.DEGRADED_FALLBACK,
+                    "食物解析阶段失败,已使用规则降级输出",
+                )
+            )
+        pipeline_trace.append(
+            {
+                "phase": "food_parse",
+                "fallback_used": fp_fb,
+                "attempts": fp_attempts,
+                "output": fp.model_dump(),
+            }
+        )
+
+        # ----- Nutritionist -----
+        n_prompt = f"""你是 **Nutritionist(营养师)Agent**。只输出 **一个 JSON**,不要其它文字。
+
+字段与类型必须完全一致:
+{{
+  "protein_gap_g": number,
+  "rationale": string,
+  "suggested_lookup_queries": string[],
+  "candidate_focus": string[]
+}}
+
+用户场景:
+{json.dumps(context, ensure_ascii=False, indent=2)}
+
+食物解析结果(LLM):
+{json.dumps(fp.model_dump(), ensure_ascii=False, indent=2)}
+
+Reflect 记忆(调整推荐策略):
+{reflect_mem}
+
+历史记忆召回(RAG):
+{rag_summary}
+
+Mock 营养表检索结果(供参考):
+{json.dumps(tool_bundle.get("nutrition", {}), ensure_ascii=False, indent=2)}
+"""
+        nu, nu_attempts = await _run_validated_stage(
+            self.llm, "nutritionist", n_prompt, NutritionistOutput, errors
+        )
+        nu_fb = False
+        if nu is None:
+            nu = _fallback_nutritionist(context, fp.nutrition_summary)
+            nu_fb = True
+            degraded = True
+            errors.append(
+                diet_error_record(
+                    "nutritionist",
+                    DietErrorCode.DEGRADED_FALLBACK,
+                    "营养师阶段失败,已使用规则降级输出",
+                )
+            )
+        pipeline_trace.append(
+            {
+                "phase": "nutritionist",
+                "fallback_used": nu_fb,
+                "attempts": nu_attempts,
+                "output": nu.model_dump(),
+            }
+        )
+
+        # 按营养师建议追加一次营养查询(可选)
+        extra_nutrition: Dict[str, Any] = {}
+        if nu.suggested_lookup_queries:
+            q = ",".join(nu.suggested_lookup_queries[:3])
+            try:
+                extra_nutrition = dispatch_tool(
+                    "nutrition_lookup", {"query": q[:200]}, user_id
+                )
+            except Exception as e:
+                errors.append(
+                    diet_error_record(
+                        "tool",
+                        DietErrorCode.TOOL_ERROR,
+                        f"nutrition_lookup 追加查询失败: {e}",
+                    )
+                )
+                extra_nutrition = {"error": str(e)}
+        tool_bundle["nutrition_extra"] = extra_nutrition
+
+        # ----- Coach -----
+        c_prompt = f"""你是 **Coach(运动恢复)Agent**。只输出 **一个 JSON**。
+
+结构:
+{{
+  "training_recovery_note": string,
+  "timing_constraints": string,
+  "energy_note": string,
+  "coach_constraints_for_menu": string[]
+}}
+
+用户场景:
+{json.dumps(context, ensure_ascii=False, indent=2)}
+
+食物解析(营养汇总):
+{json.dumps(fp.nutrition_summary.model_dump(), ensure_ascii=False, indent=2)}
+
+营养师结论:
+{json.dumps(nu.model_dump(), ensure_ascii=False, indent=2)}
+
+活动/睡眠摘要:
+{json.dumps(tool_bundle.get("activity", {}), ensure_ascii=False, indent=2)}
+
+历史记忆召回(RAG):
+{rag_summary}
+"""
+        co, co_attempts = await _run_validated_stage(
+            self.llm, "coach", c_prompt, CoachOutput, errors
+        )
+        co_fb = False
+        if co is None:
+            co = _fallback_coach(context)
+            co_fb = True
+            degraded = True
+            errors.append(
+                diet_error_record(
+                    "coach",
+                    DietErrorCode.DEGRADED_FALLBACK,
+                    "Coach 阶段失败,已使用模板降级输出",
+                )
+            )
+        pipeline_trace.append(
+            {
+                "phase": "coach",
+                "fallback_used": co_fb,
+                "attempts": co_attempts,
+                "output": co.model_dump(),
+            }
+        )
+
+        # ----- Habit -----
+        h_prompt = f"""你是 **Habit(习惯养成)Agent**。只输出 **一个 JSON**。
+
+结构:
+{{
+  "reflect_alignment": string,
+  "execution_hints": string[],
+  "meal_plan": {{
+    "items": [{{ "name": string, "portion": string, "est_protein_g": number, "why": string }}],
+    "total_est_protein_g": number,
+    "tips": string[]
+  }}
+}}
+
+要求:
+- meal_plan.items 至少 1 条;份量具体、可执行;适合便利店/外卖。
+- 结合 Reflect 记忆,说明本次如何规避上次失败原因。
+- est_protein_g 为粗略估计。
+
+用户场景:
+{json.dumps(context, ensure_ascii=False, indent=2)}
+
+食物解析结果:
+{json.dumps(fp.model_dump(), ensure_ascii=False, indent=2)}
+
+Reflect 记忆:
+{reflect_mem}
+
+历史记忆召回(RAG):
+{rag_summary}
+
+营养师:
+{json.dumps(nu.model_dump(), ensure_ascii=False, indent=2)}
+
+Coach:
+{json.dumps(co.model_dump(), ensure_ascii=False, indent=2)}
+
+营养数据(含追加查询):
+{json.dumps(tool_bundle, ensure_ascii=False, indent=2)[:12000]}
+"""
+        ha, ha_attempts = await _run_validated_stage(
+            self.llm, "habit", h_prompt, HabitOutput, errors
+        )
+        ha_fb = False
+        if ha is None:
+            ha = _fallback_habit(context, reflect_mem, fp.nutrition_summary)
+            ha_fb = True
+            degraded = True
+            errors.append(
+                diet_error_record(
+                    "habit",
+                    DietErrorCode.DEGRADED_FALLBACK,
+                    "Habit 阶段失败,已使用安全兜底菜单",
+                )
+            )
+        pipeline_trace.append(
+            {
+                "phase": "habit",
+                "fallback_used": ha_fb,
+                "attempts": ha_attempts,
+                "output": ha.model_dump(),
+            }
+        )
+
+        meal_plan = ha.meal_plan.model_dump()
+
+        planning = {
+            "reasoning": nu.rationale,
+            "plan_steps": [
+                "FoodParse:从饮食日志抽取食物与份量并估算营养",
+                f"Nutritionist:缺口约 {nu.protein_gap_g:.1f}g 蛋白",
+                "Coach:训练/进食窗口与恢复约束",
+                "Habit:对齐 Reflect 的可执行菜单",
+            ],
+            "agent_pipeline": [
+                "FoodParseAgent",
+                "NutritionistAgent",
+                "CoachAgent",
+                "HabitAgent",
+            ],
+        }
+
+        output: Dict[str, Any] = {
+            "run_id": run_id,
+            "user_id": user_id,
+            "schema_version": SCHEMA_VERSION,
+            "pipeline_mode": "multi_agent",
+            "replayed_from": replayed_from_run_id,
+            "degraded": degraded,
+            "errors": errors,
+            "planning": planning,
+            "stages": {
+                "nutritionist": {
+                    "ok": not nu_fb,
+                    "fallback_used": nu_fb,
+                    "output": nu.model_dump(),
+                },
+                "coach": {
+                    "ok": not co_fb,
+                    "fallback_used": co_fb,
+                    "output": co.model_dump(),
+                },
+                "habit": {
+                    "ok": not ha_fb,
+                    "fallback_used": ha_fb,
+                    "output": ha.model_dump(),
+                },
+            },
+            "meal_plan": meal_plan,
+            "food_parse": fp.model_dump(),
+            "nutrition_summary": fp.nutrition_summary.model_dump(),
+            "habit_extras": {
+                "reflect_alignment": ha.reflect_alignment,
+                "execution_hints": ha.execution_hints,
+            },
+            "react_trace": pipeline_trace,
+            "reflect_memory_used": reflect_mem,
+            "retrieved_memory": rag_summary,
+            "rag_debug": rag_result.get("debug", {}),
+        }
+
+        try:
+            save_diet_run(
+                user_id=user_id,
+                run_id=run_id,
+                input_payload=context,
+                steps_trace=pipeline_trace,
+                output_payload=output,
+                replayed_from_run_id=replayed_from_run_id,
+            )
+        except Exception as e:
+            logger.exception("diet_runs 落库失败: %s", e)
+        try:
+            # 最佳努力索引,不影响主流程
+            await asyncio.to_thread(index_diet_run, run_id)
+        except Exception as e:
+            logger.warning("diet run 向量索引失败(不影响返回): %s", e)
+
+        return output
+        

+ 39 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/diet_recommend_service.py

@@ -0,0 +1,39 @@
+"""
+饮食推荐入口:阶段 2 默认使用多 Agent 流水线(Nutritionist / Coach / Habit)。
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict
+
+from memory.store import get_diet_run
+from service.diet_pipeline import DietMultiAgentPipeline
+
+
+class DietRecommendService:
+    """对外稳定接口;实现细节见 `diet_pipeline.DietMultiAgentPipeline`。"""
+
+    async def run(
+        self,
+        user_id: str,
+        context: Dict[str, Any],
+        *,
+        replayed_from_run_id: str | None = None,
+    ) -> Dict[str, Any]:
+        pipeline = DietMultiAgentPipeline()
+        return await pipeline.run(
+            user_id, context, replayed_from_run_id=replayed_from_run_id
+        )
+
+
+async def replay_diet_run(original_run_id: str) -> Dict[str, Any]:
+    """阶段 3:用历史 run 的 input 重跑流水线(新 run_id;溯源 replayed_from)。"""
+    row = get_diet_run(original_run_id.strip())
+    if not row or not isinstance(row.get("input"), dict):
+        raise ValueError("diet run 不存在或缺少 input")
+    svc = DietRecommendService()
+    return await svc.run(
+        row["user_id"],
+        row["input"],
+        replayed_from_run_id=original_run_id.strip(),
+    )

+ 114 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/diet_schemas.py

@@ -0,0 +1,114 @@
+"""
+多 Agent 饮食流水线:各阶段固定输出 Schema(Pydantic v2)。
+"""
+
+from __future__ import annotations
+
+from typing import List
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+
+SCHEMA_VERSION = "2"
+
+
+class FoodItem(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    meal_time: str = Field(default="", max_length=40)
+    food_name: str = Field(min_length=1, max_length=120)
+    portion_text: str = Field(min_length=1, max_length=120)
+    confidence: float = Field(default=0.7, ge=0, le=1)
+
+
+class NutritionSummary(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    protein_g: float = Field(default=0, ge=0, le=800)
+    carb_g: float = Field(default=0, ge=0, le=1200)
+    fat_g: float = Field(default=0, ge=0, le=800)
+    fiber_g: float = Field(default=0, ge=0, le=300)
+    sodium_mg: float = Field(default=0, ge=0, le=20000)
+    calories_kcal: float = Field(default=0, ge=0, le=12000)
+
+
+class FoodParseOutput(BaseModel):
+    """饮食日志解析:由 LLM 从自由文本抽取食物条目并估算营养。"""
+
+    model_config = ConfigDict(extra="forbid")
+
+    items: List[FoodItem] = Field(default_factory=list, max_length=40)
+    nutrition_summary: NutritionSummary = Field(default_factory=NutritionSummary)
+    parse_notes: str = Field(default="", max_length=1200)
+
+
+class MealPlanItem(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    name: str = Field(min_length=1, max_length=120)
+    portion: str = Field(min_length=1, max_length=220)
+    est_protein_g: float = Field(ge=0, le=250)
+    why: str = Field(default="", max_length=600)
+
+
+class MealPlan(BaseModel):
+    model_config = ConfigDict(extra="forbid")
+
+    items: List[MealPlanItem] = Field(min_length=1, max_length=15)
+    total_est_protein_g: float = Field(ge=0, le=500)
+    tips: List[str] = Field(default_factory=list, max_length=12)
+
+    @field_validator("tips")
+    @classmethod
+    def cap_tip_len(cls, v: List[str]) -> List[str]:
+        return [t.strip()[:500] for t in v if t and t.strip()][:12]
+
+
+class NutritionistOutput(BaseModel):
+    """营养师 Agent:缺口与检索方向。"""
+
+    model_config = ConfigDict(extra="forbid")
+
+    protein_gap_g: float = Field(ge=0, le=400)
+    rationale: str = Field(min_length=4, max_length=2000)
+    suggested_lookup_queries: List[str] = Field(min_length=1, max_length=10)
+    candidate_focus: List[str] = Field(default_factory=list, max_length=10)
+
+    @field_validator("suggested_lookup_queries")
+    @classmethod
+    def v_queries(cls, v: List[str]) -> List[str]:
+        out = [str(s).strip() for s in v if s and str(s).strip()]
+        if not out:
+            raise ValueError("至少提供一条 suggested_lookup_queries")
+        return out[:10]
+
+    @field_validator("candidate_focus")
+    @classmethod
+    def v_focus(cls, v: List[str]) -> List[str]:
+        return [str(s).strip() for s in v if s and str(s).strip()][:10]
+
+
+class CoachOutput(BaseModel):
+    """运动恢复 Coach:时间与恢复约束。"""
+
+    model_config = ConfigDict(extra="forbid")
+
+    training_recovery_note: str = Field(min_length=4, max_length=2000)
+    timing_constraints: str = Field(min_length=4, max_length=1200)
+    energy_note: str = Field(default="", max_length=1200)
+    coach_constraints_for_menu: List[str] = Field(default_factory=list, max_length=12)
+
+
+class HabitOutput(BaseModel):
+    """习惯 Agent:对齐 Reflect + 最终可执行菜单。"""
+
+    model_config = ConfigDict(extra="forbid")
+
+    reflect_alignment: str = Field(min_length=4, max_length=2000)
+    execution_hints: List[str] = Field(default_factory=list, max_length=12)
+    meal_plan: MealPlan
+
+    @field_validator("execution_hints")
+    @classmethod
+    def strip_hints(cls, v: List[str]) -> List[str]:
+        return [t.strip()[:400] for t in v if t and t.strip()][:12]

+ 62 - 7
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/health_analysis.py

@@ -4,6 +4,7 @@
 """
 
 import asyncio
+import logging
 from typing import Dict, Any
 from uuid import uuid4
 
@@ -13,12 +14,19 @@ from agents.risk_assess import RiskAssessmentAgent
 from agents.advice import AdviceAgent
 from agents.report import ReportAgent
 from agents.base import create_task, update_agent_state, complete_task
+from memory.store import save_completed_report_run
+from rag.indexers import index_report_run
+from rag.retriever import retrieve
+
+logger = logging.getLogger(__name__)
+
 
 class HealthAnalysisService:
-    def __init__(self, task_id: str = None):
+    def __init__(self, task_id: str | None = None, user_id: str | None = None):
         self.task_id = task_id or str(uuid4())
+        self.user_id = user_id
         # 任务初始化
-        create_task(self.task_id)
+        create_task(self.task_id, user_id=user_id)
 
         self.planner = PlannerAgent(task_id=self.task_id)
         self.indicator_agent = HealthIndicatorAgent(task_id=self.task_id)
@@ -26,7 +34,25 @@ class HealthAnalysisService:
         self.advice_agent = AdviceAgent(task_id=self.task_id)
         self.report_agent = ReportAgent(task_id=self.task_id)
 
-    async def run(self, report_text: str) -> Dict[str, Any]:
+    def _bundle_agent_traces(self, limit_per_agent: int = 80) -> Dict[str, Any]:
+        """阶段 3:各 Agent 的 trace 切片落库。"""
+        pairs = [
+            ("PlannerAgent", self.planner),
+            ("HealthIndicatorAgent", self.indicator_agent),
+            ("RiskAssessmentAgent", self.risk_agent),
+            ("AdviceAgent", self.advice_agent),
+            ("ReportAgent", self.report_agent),
+        ]
+        out: Dict[str, Any] = {}
+        for name, ag in pairs:
+            try:
+                t = ag.get_traces()
+                out[name] = t[-limit_per_agent:] if len(t) > limit_per_agent else list(t)
+            except Exception:
+                out[name] = []
+        return out
+
+    async def run(self, report_text: str, user_id: str) -> Dict[str, Any]:
         """
         执行完整的健康分析流程
         """
@@ -51,10 +77,22 @@ class HealthAnalysisService:
         })
         update_agent_state(self.task_id, "RiskAssessmentAgent", "completed", partial_report={"risk_assessment": risk_result})
 
+        rag_result = await asyncio.to_thread(
+            retrieve,
+            user_id,
+            {
+                "scenario": "health_report_analysis",
+                "risk_focus": str(risk_result.get("overall_risk_level", "")),
+                "query": "历史体检变化与执行反馈",
+            },
+        )
+        retrieved_memory = rag_result.get("summary", "(暂无召回记忆)")
+
         # 4. 健康建议生成
         update_agent_state(self.task_id, "AdviceAgent", "running")
         advice_result = await self.advice_agent.run({
-            "risk_assessment": risk_result
+            "risk_assessment": risk_result,
+            "retrieved_memory": retrieved_memory,
         })
         update_agent_state(self.task_id, "AdviceAgent", "completed", partial_report={"advice": advice_result})
 
@@ -64,11 +102,28 @@ class HealthAnalysisService:
         final_report = await self.report_agent.run({
             "indicators": indicator_result,
             "risk_assessment": risk_result,
-            "advice": advice_result
+            "advice": advice_result,
+            "retrieved_memory": retrieved_memory,
         })
         update_agent_state(self.task_id, "ReportAgent", "completed")
         complete_task(self.task_id, final_report)
 
+        try:
+            traces = self._bundle_agent_traces()
+            await asyncio.to_thread(
+                save_completed_report_run,
+                user_id,
+                self.task_id,
+                final_report,
+                traces,
+            )
+        except Exception as e:
+            logger.exception("写入 SQLite 履历失败(分析结果仍有效): %s", e)
+        try:
+            await asyncio.to_thread(index_report_run, self.task_id)
+        except Exception as e:
+            logger.warning("report run 向量索引失败(不影响返回): %s", e)
+
         return self.task_id
 
 # ---------- 临时本地验证入口 ----------
@@ -79,8 +134,8 @@ async def _demo():
         总胆固醇 6.2 mmol/L,空腹血糖 6.1 mmol/L。
         """
 
-    workflow = HealthAnalysisService()
-    result = await workflow.run(demo_text)
+    workflow = HealthAnalysisService(user_id="local-demo-user")
+    result = await workflow.run(demo_text, user_id="local-demo-user")
     print(result)
 
 if __name__ == "__main__":

+ 95 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/service/observability_views.py

@@ -0,0 +1,95 @@
+"""
+阶段 3:从已落库 run 构建可观测性视图(timeline / 摘要),供 GET .../observability 使用。
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+
+def build_diet_observability(row: Dict[str, Any]) -> Dict[str, Any]:
+    """`get_diet_run` 返回的 row。"""
+    out = row.get("output") or {}
+    steps: List[Dict[str, Any]] = row.get("steps_trace") or []
+    timeline: List[Dict[str, Any]] = []
+
+    for s in steps:
+        ph = s.get("phase")
+        if ph == "tool_prefetch":
+            tools = s.get("tools") or []
+            timeline.append(
+                {
+                    "phase": ph,
+                    "tool_calls": len(tools),
+                    "tools_ok": [bool(t.get("ok")) for t in tools],
+                }
+            )
+        elif ph in ("nutritionist", "coach", "habit"):
+            ats = s.get("attempts") or []
+            timeline.append(
+                {
+                    "phase": ph,
+                    "fallback_used": bool(s.get("fallback_used")),
+                    "llm_attempts": len(ats),
+                    "last_attempt_ok": any(a.get("ok") for a in ats) if ats else False,
+                }
+            )
+        else:
+            timeline.append({"phase": ph or "unknown", "raw_keys": list(s.keys())})
+
+    mp = out.get("meal_plan") or {}
+    items = mp.get("items") or []
+
+    return {
+        "kind": "diet",
+        "run_id": row.get("run_id"),
+        "user_id": row.get("user_id"),
+        "created_at": row.get("created_at"),
+        "replayed_from_run_id": row.get("replayed_from_run_id")
+        or out.get("replayed_from"),
+        "schema_version": out.get("schema_version"),
+        "pipeline_mode": out.get("pipeline_mode"),
+        "degraded": out.get("degraded"),
+        "errors": out.get("errors") or [],
+        "rag_debug": out.get("rag_debug") or {},
+        "trace_timeline": timeline,
+        "input_snapshot": row.get("input"),
+        "meal_plan_item_count": len(items),
+        "estimated_total_protein_g": mp.get("total_est_protein_g"),
+        "replay": {
+            "supported": True,
+            "method": "POST",
+            "path_template": "/api/diet/runs/{run_id}/replay",
+            "note": "使用同一份 input 重新跑流水线,生成新 run_id;Mock 工具确定性较高,LLM 输出仍可能不同。",
+        },
+    }
+
+
+def build_report_observability(
+    row: Dict[str, Any], *, include_raw_trace: bool = False
+) -> Dict[str, Any]:
+    """`get_report_run` 返回的 row。默认只返回摘要,避免 trace 过大。"""
+    trace = row.get("agent_trace")
+    summary: Dict[str, Any] = {}
+    if isinstance(trace, dict):
+        for agent_name, events in trace.items():
+            if isinstance(events, list):
+                summary[agent_name] = {
+                    "event_count": len(events),
+                    "last_titles": [e.get("title") for e in events[-5:] if isinstance(e, dict)],
+                }
+            else:
+                summary[agent_name] = {"event_count": 0}
+
+    out: Dict[str, Any] = {
+        "kind": "health_report",
+        "task_id": row.get("task_id"),
+        "user_id": row.get("user_id"),
+        "created_at": row.get("created_at"),
+        "summary_text_preview": (row.get("summary_text") or "")[:240] or None,
+        "has_agent_trace": bool(trace),
+        "agent_trace_summary": summary,
+    }
+    if include_raw_trace:
+        out["agent_trace"] = trace
+    return out

+ 13 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/tools/__init__.py

@@ -0,0 +1,13 @@
+from tools.diet_tools import (
+    activity_sleep_summary,
+    dispatch_tool,
+    nutrition_lookup,
+    tools_spec,
+)
+
+__all__ = [
+    "nutrition_lookup",
+    "activity_sleep_summary",
+    "dispatch_tool",
+    "tools_spec",
+]

+ 106 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/backend/tools/diet_tools.py

@@ -0,0 +1,106 @@
+"""
+饮食场景 Mock 工具:营养查询、运动/睡眠摘要(可替换为真实 API)。
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, List
+
+# 便利店/外卖常见高蛋白选项(演示用)
+_NUTRITION_MOCK: Dict[str, Dict[str, Any]] = {
+    "鸡蛋": {"protein_g_per_unit": 6.0, "unit": "1个(约50g)", "kcal_per_unit": 70},
+    "水煮蛋": {"protein_g_per_unit": 6.0, "unit": "1个", "kcal_per_unit": 70},
+    "希腊酸奶": {"protein_g_per_unit": 12.0, "unit": "100g", "kcal_per_unit": 95},
+    "酸奶": {"protein_g_per_unit": 4.0, "unit": "100g", "kcal_per_unit": 85},
+    "牛奶": {"protein_g_per_unit": 3.3, "unit": "100ml", "kcal_per_unit": 60},
+    "豆浆": {"protein_g_per_unit": 3.6, "unit": "100ml", "kcal_per_unit": 40},
+    "即食鸡胸肉": {"protein_g_per_unit": 24.0, "unit": "100g", "kcal_per_unit": 120},
+    "鸡腿肉": {"protein_g_per_unit": 20.0, "unit": "100g", "kcal_per_unit": 180},
+    "金枪鱼罐头": {"protein_g_per_unit": 22.0, "unit": "100g", "kcal_per_unit": 110},
+    "蛋白棒": {"protein_g_per_unit": 15.0, "unit": "1根(约40g)", "kcal_per_unit": 180},
+    "豆腐": {"protein_g_per_unit": 8.0, "unit": "100g", "kcal_per_unit": 80},
+    "豆干": {"protein_g_per_unit": 16.0, "unit": "100g", "kcal_per_unit": 140},
+}
+
+
+def nutrition_lookup(query: str) -> Dict[str, Any]:
+    """
+    按关键词匹配 mock 营养表;支持多个关键词逗号分隔。
+    """
+    q = (query or "").strip()
+    if not q:
+        return {"matches": [], "hint": "请提供食物名称关键词"}
+
+    keys = [k.strip() for k in q.replace(",", ",").split(",") if k.strip()]
+    if not keys:
+        keys = [q]
+
+    matches: List[Dict[str, Any]] = []
+    for kw in keys:
+        for name, meta in _NUTRITION_MOCK.items():
+            if kw in name or name in kw:
+                matches.append({"name": name, **meta})
+        # 直接命中
+        if kw in _NUTRITION_MOCK and not any(m["name"] == kw for m in matches):
+            matches.append({"name": kw, **_NUTRITION_MOCK[kw]})
+
+    # 去重按 name
+    seen = set()
+    uniq: List[Dict[str, Any]] = []
+    for m in matches:
+        if m["name"] not in seen:
+            seen.add(m["name"])
+            uniq.append(m)
+
+    return {
+        "query": q,
+        "matches": uniq[:20],
+        "source": "mock_nutrition_db",
+    }
+
+
+def activity_sleep_summary(user_id: str) -> Dict[str, Any]:
+    """
+    Mock:可穿戴/手填摘要。后续可改为读 user_profiles 或外部 API。
+    """
+    _ = user_id
+    return {
+        "user_id": user_id,
+        "date": "今日",
+        "steps": 8200,
+        "sleep_hours": 6.5,
+        "sleep_quality": "一般",
+        "evening_workout": True,
+        "workout_type": "力量训练",
+        "notes": "mock:连续感知数据可接手环/OpenAPI",
+        "source": "mock_wearable",
+    }
+
+
+def tools_spec() -> str:
+    return json.dumps(
+        [
+            {
+                "name": "nutrition_lookup",
+                "description": "查询常见便利店/外卖食物蛋白质含量与份量单位",
+                "parameters": {"query": "关键词,多个用英文逗号分隔"},
+            },
+            {
+                "name": "activity_sleep_summary",
+                "description": "获取用户今日步数、睡眠与晚间是否安排训练等摘要",
+                "parameters": {"user_id": "用户 ID"},
+            },
+        ],
+        ensure_ascii=False,
+        indent=2,
+    )
+
+
+def dispatch_tool(name: str, action_input: Dict[str, Any], user_id: str) -> Dict[str, Any]:
+    if name == "nutrition_lookup":
+        return nutrition_lookup(str(action_input.get("query", "")))
+    if name == "activity_sleep_summary":
+        uid = str(action_input.get("user_id") or user_id)
+        return activity_sleep_summary(uid)
+    return {"error": f"未知工具: {name}"}

+ 0 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/data/.gitkeep


+ 528 - 30
Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/app.js

@@ -1,12 +1,519 @@
 const API_BASE = "http://127.0.0.1:8000";
-// 显示多Agent执行状态
+const USER_ID_STORAGE_KEY = "healthRecordAgent_userId";
+const LAST_DIET_RUN_KEY = "healthRecordAgent_lastDietRunId";
+const DEV_MODE_STORAGE_KEY = "healthRecordAgent_devMode";
+/** 兼容旧版「技术详情」开关 */
+const LEGACY_TECH_STORAGE_KEY = "healthRecordAgent_showTech";
+
+function isDeveloperMode() {
+    const el = document.getElementById("devModeToggle");
+    return !!(el && el.checked);
+}
+
+function getUserIdOrEmpty() {
+    return document.getElementById("userId")?.value?.trim() || "";
+}
+
+/** 体检分析进度:默认对用户显示中文步骤名 */
+function getHealthProgressAgents() {
+    if (isDeveloperMode()) {
+        return [
+            { key: "PlannerAgent", label: "PlannerAgent 规划" },
+            { key: "HealthIndicatorAgent", label: "HealthIndicatorAgent 指标" },
+            { key: "RiskAssessmentAgent", label: "RiskAssessmentAgent 风险" },
+            { key: "AdviceAgent", label: "AdviceAgent 建议" },
+            { key: "ReportAgent", label: "ReportAgent 报告" },
+        ];
+    }
+    return [
+        { key: "PlannerAgent", label: "规划" },
+        { key: "HealthIndicatorAgent", label: "指标解读" },
+        { key: "RiskAssessmentAgent", label: "风险评估" },
+        { key: "AdviceAgent", label: "建议" },
+        { key: "ReportAgent", label: "汇总报告" },
+    ];
+}
+
+function getUserId() {
+    const el = document.getElementById("userId");
+    const raw = el ? el.value.trim() : "";
+    if (!raw) {
+        alert("请填写用户 ID");
+        return null;
+    }
+    try {
+        localStorage.setItem(USER_ID_STORAGE_KEY, raw);
+    } catch (_) { /* ignore */ }
+    return raw;
+}
+
+function setTab(name) {
+    const tabs = ["analysis", "diet", "history"];
+    const n = tabs.includes(name) ? name : "analysis";
+    tabs.forEach((t) => {
+        const panel = document.getElementById(`tab-${t}`);
+        if (panel) panel.classList.toggle("hidden", t !== n);
+    });
+    document.querySelectorAll(".tab-segment [role='tab']").forEach((btn) => {
+        const on = btn.dataset.tab === n;
+        btn.setAttribute("aria-selected", on ? "true" : "false");
+    });
+    if (`#${n}` !== location.hash) {
+        history.replaceState(null, "", `#${n}`);
+    }
+    if (n === "diet") {
+        refreshReflectRunOptions();
+    }
+}
+
+function tabFromHash() {
+    const h = (location.hash || "").replace(/^#/, "").toLowerCase();
+    if (h === "diet" || h === "history" || h === "analysis") return h;
+    return "analysis";
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+    const el = document.getElementById("userId");
+    if (el) {
+        try {
+            const saved = localStorage.getItem(USER_ID_STORAGE_KEY);
+            if (saved) el.value = saved;
+        } catch (_) { /* ignore */ }
+    }
+
+    setTab(tabFromHash());
+    window.addEventListener("hashchange", () => setTab(tabFromHash()));
+
+    document.querySelectorAll(".tab-segment [data-tab]").forEach((btn) => {
+        btn.addEventListener("click", () => setTab(btn.dataset.tab || "analysis"));
+    });
+
+    const devCb = document.getElementById("devModeToggle");
+    if (devCb) {
+        try {
+            const dm = localStorage.getItem(DEV_MODE_STORAGE_KEY);
+            const legacy = localStorage.getItem(LEGACY_TECH_STORAGE_KEY);
+            if (dm === "1" || legacy === "1") devCb.checked = true;
+        } catch (_) { /* ignore */ }
+        devCb.addEventListener("change", () => {
+            try {
+                localStorage.setItem(DEV_MODE_STORAGE_KEY, devCb.checked ? "1" : "0");
+            } catch (_) { /* ignore */ }
+            refreshReflectRunOptions();
+        });
+    }
+
+    const dlg = document.getElementById("reflectPromptDialog");
+    const go = document.getElementById("reflectDialogGo");
+    const later = document.getElementById("reflectDialogLater");
+    if (go) {
+        go.addEventListener("click", () => {
+            if (dlg && typeof dlg.close === "function") dlg.close();
+            focusFeedbackSection();
+        });
+    }
+    if (later) {
+        later.addEventListener("click", () => {
+            if (dlg && typeof dlg.close === "function") dlg.close();
+        });
+    }
+
+    document.querySelectorAll('input[name="reflectFollowedChoice"]').forEach((el) => {
+        el.addEventListener("change", syncReflectReasonVisibility);
+    });
+    syncReflectReasonVisibility();
+});
+
+/** 选「否」时展示未执行原因;选「是」时隐藏并清空原因(后端会将 reason 置为 executed_ok)。 */
+function syncReflectReasonVisibility() {
+    const yes = document.getElementById("reflectFollowedYes");
+    const no = document.getElementById("reflectFollowedNo");
+    const block = document.getElementById("reflectReasonBlock");
+    const sel = document.getElementById("reflectReasonCode");
+    const detail = document.getElementById("reflectDetail");
+    if (!block || !sel) return;
+    if (no?.checked) {
+        block.classList.remove("hidden");
+    } else {
+        block.classList.add("hidden");
+        sel.value = "";
+        if (detail) detail.value = "";
+    }
+}
+
+/** 拉取近期饮食推荐,填充「反馈」下拉的选项;preferredRunId 优先选中(如刚生成的一条)。 */
+async function refreshReflectRunOptions(preferredRunId) {
+    const sel = document.getElementById("reflectRunSelect");
+    if (!sel) return;
+
+    const userId = getUserIdOrEmpty();
+    sel.innerHTML = "";
+
+    const addPlaceholder = (text, disabled = true) => {
+        const o = document.createElement("option");
+        o.value = "";
+        o.textContent = text;
+        if (disabled) o.disabled = true;
+        o.selected = true;
+        sel.appendChild(o);
+    };
+
+    if (!userId) {
+        addPlaceholder("请先填写用户 ID");
+        return;
+    }
+
+    try {
+        const res = await fetch(
+            `${API_BASE}/api/diet/users/${encodeURIComponent(userId)}/runs?limit=20`
+        );
+        const data = await res.json().catch(() => ({}));
+        const items = data.items || [];
+        if (!items.length) {
+            addPlaceholder("暂无推荐记录,请先生成一次饮食推荐");
+            return;
+        }
+
+        const dev = isDeveloperMode();
+        items.forEach((row) => {
+            const o = document.createElement("option");
+            o.value = row.run_id;
+            let label = "";
+            try {
+                const t = row.created_at
+                    ? new Date(row.created_at).toLocaleString("zh-CN", {
+                          month: "2-digit",
+                          day: "2-digit",
+                          hour: "2-digit",
+                          minute: "2-digit",
+                      })
+                    : "";
+                const tp =
+                    row.total_protein != null
+                        ? `约 ${row.total_protein} g 蛋白`
+                        : "饮食推荐";
+                label = t ? `${t} · ${tp}` : tp;
+                if (dev) label += ` · ${row.run_id}`;
+            } catch (_) {
+                label = row.run_id;
+            }
+            o.textContent = label;
+            sel.appendChild(o);
+        });
+
+        const pick =
+            preferredRunId ||
+            (() => {
+                try {
+                    return localStorage.getItem(LAST_DIET_RUN_KEY);
+                } catch (_) {
+                    return null;
+                }
+            })();
+        if (pick && Array.from(sel.options).some((opt) => opt.value === pick)) {
+            sel.value = pick;
+        }
+    } catch (e) {
+        console.error(e);
+        addPlaceholder("加载推荐列表失败,请稍后重试");
+    }
+}
+
+function openReflectPromptDialog() {
+    const dlg = document.getElementById("reflectPromptDialog");
+    if (dlg && typeof dlg.showModal === "function") {
+        dlg.showModal();
+    } else {
+        focusFeedbackSection();
+    }
+}
+
+function focusFeedbackSection() {
+    const h = document.getElementById("feedbackSectionTitle");
+    h?.scrollIntoView({ behavior: "smooth", block: "start" });
+    const first = document.getElementById("reflectRunSelect");
+    if (first) {
+        setTimeout(() => first.focus(), 400);
+    }
+}
+
+function renderMealPlan(mp) {
+    if (!mp) return "<p>(无 meal_plan)</p>";
+    const tips = Array.isArray(mp.tips) ? mp.tips.filter(Boolean).join(";") : "";
+    let h = `<p><strong>估算总蛋白</strong>:${mp.total_est_protein_g ?? "—"} g</p><ul class="meal-plan-list">`;
+    (mp.items || []).forEach((it) => {
+        h += `<li><strong>${escapeHtml(it.name || "")}</strong> — ${escapeHtml(it.portion || "")}`;
+        if (it.est_protein_g != null) h += `(约 <strong>${it.est_protein_g}</strong> g 蛋白)`;
+        if (it.why) h += `<br><span class="muted-why">${escapeHtml(it.why)}</span>`;
+        h += "</li>";
+    });
+    h += "</ul>";
+    if (tips) h += `<p class="meal-tips"><strong>提示</strong>:${escapeHtml(tips)}</p>`;
+    return h;
+}
+
+function escapeHtml(s) {
+    if (!s) return "";
+    const div = document.createElement("div");
+    div.textContent = s;
+    return div.innerHTML;
+}
+
+async function recommendDiet() {
+    const userId = getUserId();
+    if (!userId) return;
+
+    const statusEl = document.getElementById("dietStatus");
+    const outEl = document.getElementById("dietResult");
+    if (!statusEl || !outEl) return;
+
+    statusEl.textContent = isDeveloperMode()
+        ? "⏳ 正在调用 Planning + ReAct(可能需多次 LLM,请稍候)…"
+        : "⏳ 正在生成推荐,请稍候…";
+    outEl.classList.add("hidden");
+    outEl.innerHTML = "";
+
+    const foodLog = document.getElementById("dietFoodLog")?.value?.trim() || "";
+    if (!foodLog) {
+        statusEl.textContent = "⚠️ 请先填写今天吃了什么";
+        return;
+    }
+
+    const body = {
+        user_id: userId,
+        context: {
+            today_food_log_text: foodLog,
+            goal: document.getElementById("dietGoal")?.value || "muscle_gain",
+            channels: ["convenience_store", "delivery"],
+            activity_context: document.getElementById("dietActivityContext")?.value?.trim() || "",
+            free_notes: document.getElementById("dietNotes")?.value?.trim() || "",
+        },
+    };
+
+    try {
+        const res = await fetch(`${API_BASE}/api/diet/recommend`, {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify(body),
+        });
+        const data = await res.json().catch(() => ({}));
+        if (!res.ok) {
+            throw new Error(data.detail ? JSON.stringify(data.detail) : `HTTP ${res.status}`);
+        }
+
+        const runId = data.run_id;
+        try {
+            localStorage.setItem(LAST_DIET_RUN_KEY, runId);
+        } catch (_) { /* ignore */ }
+        const planning = data.planning || {};
+        const ver = data.schema_version || "1";
+        const mode = data.pipeline_mode || "legacy";
+        const tech = isDeveloperMode();
+
+        let html = "";
+        if (tech) {
+            html += `<p><strong>run_id</strong>:<code>${escapeHtml(runId)}</code> &nbsp; <small>schema=${escapeHtml(String(ver))} / ${escapeHtml(String(mode))}</small></p>`;
+        }
+        if (data.degraded) {
+            html += tech
+                ? `<p class="banner banner-warning"><strong>降级</strong>:部分阶段使用规则/模板兜底,请查看 <code>errors</code>。</p>`
+                : `<p class="banner banner-warning"><strong>说明</strong>:部分内容由规则自动补齐,请以列表中的可执行项为准。</p>`;
+        }
+        if (planning.reasoning) {
+            html += tech
+                ? `<p><strong>Planning(Nutritionist 摘要)</strong>:${escapeHtml(planning.reasoning)}</p>`
+                : `<p><strong>营养分析摘要</strong>:${escapeHtml(planning.reasoning)}</p>`;
+        }
+        const ns = data.nutrition_summary || {};
+        if (!tech) {
+            html += `<p><strong>今日营养估算</strong>:蛋白 ${escapeHtml(String(ns.protein_g ?? 0))}g,碳水 ${escapeHtml(String(ns.carb_g ?? 0))}g,脂肪 ${escapeHtml(String(ns.fat_g ?? 0))}g,热量 ${escapeHtml(String(ns.calories_kcal ?? 0))} kcal</p>`;
+        } else {
+            html += `<details class="diet-trace"><summary>食物解析与营养估算</summary><pre style="white-space:pre-wrap;max-height:220px;overflow:auto;">${escapeHtml(JSON.stringify({ food_parse: data.food_parse, nutrition_summary: data.nutrition_summary }, null, 2))}</pre></details>`;
+        }
+        const hx = data.habit_extras;
+        if (hx && hx.reflect_alignment) {
+            html += tech
+                ? `<p><strong>Habit · Reflect 对齐</strong>:${escapeHtml(hx.reflect_alignment)}</p>`
+                : `<p><strong>与历史反馈对齐</strong>:${escapeHtml(hx.reflect_alignment)}</p>`;
+            if (hx.execution_hints && hx.execution_hints.length) {
+                html += `<p><strong>执行提示</strong>:${escapeHtml(hx.execution_hints.join(";"))}</p>`;
+            }
+        }
+        html += `<h4>推荐方案</h4>${renderMealPlan(data.meal_plan)}`;
+
+        if (tech) {
+            if (data.errors && data.errors.length) {
+                html += `<details class="diet-trace"><summary>错误记录(${data.errors.length})</summary><pre style="white-space:pre-wrap;max-height:200px;overflow:auto;">${escapeHtml(JSON.stringify(data.errors, null, 2))}</pre></details>`;
+            }
+            if (data.reflect_memory_used) {
+                html += `<details class="diet-trace"><summary>已注入的 Reflect 记忆摘要</summary><pre style="white-space:pre-wrap;">${escapeHtml(String(data.reflect_memory_used))}</pre></details>`;
+            }
+            if (data.react_trace && data.react_trace.length) {
+                html += `<details class="diet-trace"><summary>流水线轨迹(${data.react_trace.length} 段)</summary><pre style="white-space:pre-wrap;max-height:280px;overflow:auto;">${escapeHtml(JSON.stringify(data.react_trace, null, 2))}</pre></details>`;
+            }
+        }
+
+        outEl.innerHTML = html;
+        outEl.classList.remove("hidden");
+        statusEl.textContent = data.degraded
+            ? tech
+                ? "⚠️ 推荐完成(含降级,已写入 diet_runs)"
+                : "⚠️ 推荐已保存(部分内容已自动处理)"
+            : tech
+              ? "✅ 推荐完成(已写入 diet_runs)"
+              : "✅ 推荐已保存";
+
+        await refreshReflectRunOptions(runId);
+        openReflectPromptDialog();
+    } catch (e) {
+        console.error(e);
+        statusEl.textContent = "❌ 请求失败";
+        outEl.innerHTML = `<p class="banner-error">${escapeHtml(e.message || String(e))}</p>`;
+        outEl.classList.remove("hidden");
+    }
+}
+
+async function submitDietReflect() {
+    const userId = getUserId();
+    if (!userId) return;
+
+    const runId = document.getElementById("reflectRunSelect")?.value?.trim();
+    if (!runId) {
+        alert("请先在列表里选择一条要反馈的推荐,或先生成一次饮食推荐");
+        return;
+    }
+
+    const yes = document.getElementById("reflectFollowedYes")?.checked;
+    const no = document.getElementById("reflectFollowedNo")?.checked;
+    if (!yes && !no) {
+        alert("请先选择「是否按这条推荐执行」");
+        return;
+    }
+    const followed = !!yes;
+    let reasonCode = null;
+    let detail = null;
+    if (followed) {
+        reasonCode = null;
+        detail = null;
+    } else {
+        reasonCode = document.getElementById("reflectReasonCode")?.value?.trim() || null;
+        if (!reasonCode) {
+            alert("请选择未执行的主要原因");
+            return;
+        }
+        detail = document.getElementById("reflectDetail")?.value?.trim() || null;
+    }
+
+    const statusEl = document.getElementById("dietStatus");
+    if (statusEl) statusEl.textContent = "⏳ 正在保存反馈…";
+
+    try {
+        const res = await fetch(`${API_BASE}/api/diet/reflect`, {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({
+                user_id: userId,
+                diet_run_id: runId,
+                followed,
+                reason_code: reasonCode,
+                reason_detail: detail,
+            }),
+        });
+        const data = await res.json().catch(() => ({}));
+        if (!res.ok) {
+            throw new Error(data.detail ? JSON.stringify(data.detail) : `HTTP ${res.status}`);
+        }
+        if (statusEl) {
+            statusEl.textContent = isDeveloperMode()
+                ? `✅ Reflect 已保存(id=${data.reflect_id}),下次推荐会读取`
+                : "✅ 反馈已保存,将在下次推荐时参考";
+        }
+        await loadDietHistory();
+    } catch (e) {
+        console.error(e);
+        if (statusEl) statusEl.textContent = "❌ 保存失败:" + (e.message || e);
+    }
+}
+
+async function loadDietHistory() {
+    const userId = getUserId();
+    if (!userId) return;
+
+    const pre = document.getElementById("dietHistoryPre");
+    const hint = document.getElementById("historyEmptyHint");
+    const summaryEl = document.getElementById("historySummary");
+    const rawDetails = document.getElementById("historyRawDetails");
+    if (!pre) return;
+
+    if (hint) hint.classList.add("hidden");
+    if (summaryEl) {
+        summaryEl.classList.remove("hidden");
+        summaryEl.textContent = "加载中…";
+    }
+    if (rawDetails) {
+        rawDetails.classList.add("hidden");
+        rawDetails.open = false;
+    }
+    pre.textContent = "";
+
+    try {
+        const [r1, r2] = await Promise.all([
+            fetch(`${API_BASE}/api/diet/users/${encodeURIComponent(userId)}/runs?limit=15`).then((r) => r.json()),
+            fetch(`${API_BASE}/api/diet/users/${encodeURIComponent(userId)}/reflect_history?limit=15`).then((r) => r.json()),
+        ]);
+        const n1 = (r1.items || []).length;
+        const n2 = (r2.items || []).length;
+        if (summaryEl) {
+            summaryEl.textContent = `已加载 ${n1} 条饮食推荐记录、${n2} 条反馈记录。`;
+        }
+        pre.textContent = JSON.stringify({ diet_runs: r1, reflect: r2 }, null, 2);
+        if (rawDetails) {
+            if (isDeveloperMode()) {
+                rawDetails.classList.remove("hidden");
+            } else {
+                rawDetails.classList.add("hidden");
+            }
+        }
+    } catch (e) {
+        if (summaryEl) {
+            summaryEl.textContent = "加载失败:" + (e.message || e);
+        }
+        pre.textContent = "";
+    }
+}
+/**
+ * 显示 / 更新多 Agent 进度。仅在 agents 数量变化时重建 DOM,轮询时只更新状态文案,避免整表闪烁。
+ */
 function showAgentProgress(agentContainer, agents, statusFunc) {
-    agentContainer.innerHTML = "";
-    agents.forEach(agent => {
-        const li = document.createElement("li");
-        const status = typeof statusFunc === "function" ? statusFunc(agent.key) : statusFunc;
-        li.textContent = `${agent.label}: ${status}`;
-        agentContainer.appendChild(li);
+    const getStatus =
+        typeof statusFunc === "function" ? statusFunc : () => statusFunc;
+    const needRebuild =
+        agentContainer.children.length !== agents.length ||
+        agents.some((a, i) => agentContainer.children[i]?.dataset?.agentKey !== a.key);
+
+    if (needRebuild) {
+        agentContainer.innerHTML = "";
+        agents.forEach((agent) => {
+            const li = document.createElement("li");
+            li.dataset.agentKey = agent.key;
+            const labelSpan = document.createElement("span");
+            labelSpan.className = "agent-progress-label";
+            labelSpan.textContent = agent.label;
+            const statusSpan = document.createElement("span");
+            statusSpan.className = "agent-progress-status";
+            statusSpan.textContent = getStatus(agent.key);
+            li.appendChild(labelSpan);
+            li.appendChild(document.createTextNode(":"));
+            li.appendChild(statusSpan);
+            agentContainer.appendChild(li);
+        });
+        return;
+    }
+
+    agents.forEach((agent, i) => {
+        const li = agentContainer.children[i];
+        const statusSpan = li?.querySelector?.(".agent-progress-status");
+        if (statusSpan) statusSpan.textContent = getStatus(agent.key);
     });
 }
 
@@ -50,6 +557,9 @@ async function submitAndPollTask(url, body, agents, resultCard, reportDiv, analy
 
 // 文本报告分析
 async function analyze() {
+    const userId = getUserId();
+    if (!userId) return;
+
     const reportText = document.getElementById("reportText").value;
     if (!reportText) {
         alert("请输入体检报告内容");
@@ -61,34 +571,31 @@ async function analyze() {
     const analysisDiv = document.getElementById("analysis");
     const progressList = document.getElementById("progressList");
 
-    const agents = [
-        { key: "PlannerAgent", label: "PlannerAgent 规划分析" },
-        { key: "HealthIndicatorAgent", label: "HealthIndicatorAgent 指标分析" },
-        { key: "RiskAssessmentAgent", label: "RiskAssessmentAgent 风险评估" },
-        { key: "AdviceAgent", label: "AdviceAgent 建议生成" },
-        { key: "ReportAgent", label: "ReportAgent 报告生成" }
-    ];
+    const agents = getHealthProgressAgents();
 
     await submitAndPollTask(
         `${API_BASE}/api/health/analysis`,
         {
             method: "POST",
             headers: { "Content-Type": "application/json" },
-            body: JSON.stringify({ report_text: reportText })
+            body: JSON.stringify({ report_text: reportText, user_id: userId })
         },
         agents,
         resultCard,
         reportDiv,
         analysisDiv,
         progressList,
-        "⏳ 正在分析文本报告,请稍候...",
-        "✅ 文本分析完成",
+        isDeveloperMode() ? "⏳ 正在分析文本报告,请稍候…" : "⏳ 正在分析,请稍候…",
+        "✅ 分析完成",
         "报告生成失败"
     );
 }
 
 // PDF报告分析
 async function uploadPDF() {
+    const userId = getUserId();
+    if (!userId) return;
+
     const fileInput = document.getElementById("pdfFile");
     const file = fileInput.files[0];
     if (!file) {
@@ -97,6 +604,7 @@ async function uploadPDF() {
     }
 
     const formData = new FormData();
+    formData.append("user_id", userId);
     formData.append("file", file);
 
     const resultCard = document.getElementById("resultCard");
@@ -104,13 +612,7 @@ async function uploadPDF() {
     const analysisDiv = document.getElementById("analysis");
     const progressList = document.getElementById("progressList");
 
-    const agents = [
-        { key: "PlannerAgent", label: "PlannerAgent 规划分析" },
-        { key: "HealthIndicatorAgent", label: "HealthIndicatorAgent 指标分析" },
-        { key: "RiskAssessmentAgent", label: "RiskAssessmentAgent 风险评估" },
-        { key: "AdviceAgent", label: "AdviceAgent 建议生成" },
-        { key: "ReportAgent", label: "ReportAgent 报告生成" }
-    ];
+    const agents = getHealthProgressAgents();
 
     await submitAndPollTask(
         `${API_BASE}/api/health/analysis/pdf`,
@@ -120,12 +622,8 @@ async function uploadPDF() {
         reportDiv,
         analysisDiv,
         progressList,
-        "⏳ 正在分析 PDF 报告,请稍候...",
-        "✅ PDF分析完成",
+        isDeveloperMode() ? "⏳ 正在分析 PDF 报告,请稍候…" : "⏳ 正在分析 PDF,请稍候…",
+        "✅ 分析完成",
         "上传失败"
     );
 }
-
-// 绑定按钮事件
-document.getElementById("analyzeBtn")?.addEventListener("click", analyze);
-document.getElementById("uploadBtn")?.addEventListener("click", uploadPDF);

+ 146 - 45
Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/index.html

@@ -1,62 +1,163 @@
 <!DOCTYPE html>
-<html lang="zh">
+<html lang="zh-CN">
 <head>
     <meta charset="UTF-8">
-    <title>HealthRecordAgent</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+    <title>健康助手</title>
     <link rel="stylesheet" href="style.css">
-    <style>
-        .container { max-width: 800px; margin: auto; padding: 20px; font-family: sans-serif; }
-        h1 { text-align: center; margin-bottom: 30px; }
-        .card { background: #f7f7f7; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); }
-        textarea { width: 100%; height: 120px; padding: 10px; border-radius: 4px; border: 1px solid #ccc; resize: vertical; }
-        input[type="file"] { width: 100%; padding: 6px; }
-        button { padding: 8px 16px; border: none; border-radius: 4px; background-color: #007bff; color: white; cursor: pointer; }
-        button:hover { background-color: #0056b3; }
-        .flex-row { display: flex; gap: 20px; flex-wrap: wrap; }
-        .flex-column { display: flex; flex-direction: column; gap: 10px; flex: 1; }
-        #analysis { margin-top: 10px; font-weight: bold; }
-        #report { margin-top: 15px; white-space: pre-wrap; }
-        #progressList { margin-top: 10px; padding-left: 20px; list-style: none; }
-        #progressList li { margin-bottom: 5px; }
-        .hidden { display: none; }
-    </style>
 </head>
-<body>
+<body class="app">
 
-<div class="container">
-    <h1>HealthRecordAgent 多Agent健康分析系统</h1>
+    <header class="app-header">
+        <h1 class="app-title">健康助手</h1>
+        <p class="app-subtitle">档案分析与饮食建议</p>
+    </header>
 
-    <div class="card">
-        <h2>📄 输入体检报告或上传 PDF</h2>
+    <section class="card user-strip" aria-label="用户">
+        <span class="field-label">用户 ID</span>
+        <input type="text" id="userId" placeholder="例如 mobile_13800000000 或 demo-alice" autocomplete="username" />
+        <label class="tech-toggle">
+            <input type="checkbox" id="devModeToggle" />
+            开发者模式(内部编号、Agent 全名、轨迹与原始 JSON)
+        </label>
+    </section>
 
-        <div class="flex-row">
-            <!-- 左边:文字输入 -->
-            <div class="flex-column">
-                <label>📝 文本报告输入</label>
-                <textarea id="reportText" placeholder="请输入体检报告内容..."></textarea>
-                <button onclick="analyze()">💡 分析文本报告</button>
-            </div>
+    <nav class="tab-segment" role="tablist" aria-label="主功能">
+        <button type="button" role="tab" id="tabBtn-analysis" aria-selected="true" aria-controls="tab-analysis" data-tab="analysis">档案分析</button>
+        <button type="button" role="tab" id="tabBtn-diet" aria-selected="false" aria-controls="tab-diet" data-tab="diet">饮食助手</button>
+        <button type="button" role="tab" id="tabBtn-history" aria-selected="false" aria-controls="tab-history" data-tab="history">历史记录</button>
+    </nav>
+
+    <!-- Tab:档案分析 -->
+    <div id="tab-analysis" class="tab-panel" role="tabpanel" aria-labelledby="tabBtn-analysis">
+        <div class="card">
+            <h2>体检报告</h2>
+            <p class="section-lead">粘贴文本或上传 PDF,生成结构化解读与建议。</p>
 
-            <!-- 右边:PDF 上传 -->
-            <div class="flex-column">
-                <label>📄 PDF 报告上传</label>
-                <input type="file" id="pdfFile" accept=".pdf" />
-                <button onclick="uploadPDF()">💡 分析 PDF 报告</button>
+            <div class="flex-row">
+                <div class="flex-column">
+                    <span class="field-label-inline">文本</span>
+                    <textarea id="reportText" placeholder="在此粘贴体检报告内容…"></textarea>
+                    <button type="button" class="btn btn-primary" onclick="analyze()">分析文本报告</button>
+                </div>
+                <div class="flex-column">
+                    <span class="field-label-inline">PDF 文件</span>
+                    <input type="file" id="pdfFile" accept=".pdf" />
+                    <button type="button" class="btn btn-primary" onclick="uploadPDF()">分析 PDF 报告</button>
+                </div>
             </div>
+
+            <div id="analysis"></div>
+            <ul id="progressList" class="hidden"></ul>
         </div>
 
-        <!-- 分析状态显示 -->
-        <div id="analysis"> </div>
-        <ul id="progressList" class="hidden"></ul>
+        <div id="resultCard" class="card hidden">
+            <h2>分析摘要</h2>
+            <div id="report"></div>
+        </div>
+    </div>
+
+    <!-- Tab:饮食助手 -->
+    <div id="tab-diet" class="tab-panel hidden" role="tabpanel" aria-labelledby="tabBtn-diet">
+        <div class="card" id="dietCard">
+            <h2>饮食推荐</h2>
+            <p class="section-lead">先填写「今天吃了什么」,系统会解析营养并生成下一餐可执行建议;你的反馈会纳入后续推荐。</p>
+
+            <div class="diet-grid">
+                <label>健康目标
+                    <select id="dietGoal">
+                        <option value="muscle_gain">增肌</option>
+                        <option value="fat_loss">减脂</option>
+                        <option value="maintain">维持</option>
+                    </select>
+                </label>
+            </div>
+
+            <label class="field-label-inline" style="margin-top: 16px;">今天吃了什么(必填)</label>
+            <textarea id="dietFoodLog" rows="4" placeholder="例如:早餐豆浆300ml+鸡蛋2个;午餐鸡腿饭;下午拿铁;晚餐前香蕉1根"></textarea>
+
+            <label class="field-label-inline" style="margin-top: 12px;">运动/睡眠情况</label>
+            <input type="text" id="dietActivityContext" placeholder="例如:今晚力量训练60分钟,昨晚睡眠6.5小时" />
+
+            <label class="field-label-inline" style="margin-top: 12px;">补充说明</label>
+            <textarea id="dietNotes" rows="3" placeholder="例如:只能便利店、训练后 1 小时内要吃上"></textarea>
+
+            <div class="btn-row">
+                <button type="button" class="btn btn-primary" onclick="recommendDiet()">生成饮食推荐</button>
+            </div>
+
+            <div id="dietStatus"></div>
+            <div id="dietResult" class="hidden"></div>
+
+            <div class="card-hr" role="presentation"></div>
+
+            <h3 id="feedbackSectionTitle">反馈执行情况</h3>
+            <p class="section-lead">告诉系统你是否按<strong>某一次</strong>推荐执行、原因是什么;下次生成推荐时会参考。列表来自你的历史推荐,不必手抄编号。</p>
+
+            <label class="field-label-inline">要为哪一条推荐写反馈?</label>
+            <div class="reflect-run-row">
+                <select id="reflectRunSelect" aria-describedby="reflectRunHint"></select>
+                <button type="button" class="btn btn-secondary btn-compact" onclick="refreshReflectRunOptions()">刷新列表</button>
+            </div>
+            <span id="reflectRunHint" class="field-hint">生成推荐后会自动出现在列表中;刚生成完也可点「刷新列表」。</span>
+
+            <fieldset class="reflect-follow-fieldset" style="margin-top: 16px;">
+                <legend class="field-label-inline">是否按这条推荐执行?</legend>
+                <label class="radio-row">
+                    <input type="radio" name="reflectFollowedChoice" value="yes" id="reflectFollowedYes" />
+                    是,已尽量按推荐吃
+                </label>
+                <label class="radio-row">
+                    <input type="radio" name="reflectFollowedChoice" value="no" id="reflectFollowedNo" />
+                    否,没有完全按推荐做
+                </label>
+            </fieldset>
+
+            <div id="reflectReasonBlock" class="reflect-reason-block hidden">
+                <label class="field-label-inline" for="reflectReasonCode">未执行的主要原因</label>
+                <select id="reflectReasonCode">
+                    <option value="">请选择</option>
+                    <option value="cant_buy">买不到</option>
+                    <option value="too_late">太晚/闭店</option>
+                    <option value="dont_want">不想吃</option>
+                    <option value="other">其他</option>
+                </select>
+                <label class="field-label-inline" style="margin-top: 10px;">补充说明</label>
+                <input type="text" id="reflectDetail" placeholder="可选,例如具体缺货商品" />
+            </div>
+
+            <div class="btn-row">
+                <button type="button" class="btn btn-primary" onclick="submitDietReflect()">保存反馈</button>
+            </div>
+        </div>
     </div>
 
-    <!-- 分析结果 -->
-    <div id="resultCard" class="card hidden">
-        <h2>📊 分析结果</h2>
-        <div id="report"></div>
+    <!-- Tab:历史记录 -->
+    <div id="tab-history" class="tab-panel hidden" role="tabpanel" aria-labelledby="tabBtn-history">
+        <div class="card">
+            <h2>历史记录</h2>
+            <p class="section-lead">饮食推荐与反馈摘要;原始 JSON 仅在「开发者模式」下展示。</p>
+            <div class="btn-row">
+                <button type="button" class="btn btn-secondary" onclick="loadDietHistory()">刷新</button>
+            </div>
+            <p id="historySummary" class="history-summary hidden" aria-live="polite"></p>
+            <details id="historyRawDetails" class="history-raw hidden">
+                <summary>原始数据(JSON)</summary>
+                <pre id="dietHistoryPre"></pre>
+            </details>
+            <p id="historyEmptyHint" class="history-placeholder">点击「刷新」加载数据。</p>
+        </div>
     </div>
-</div>
 
-<script src="app.js"></script>
+    <dialog id="reflectPromptDialog" class="app-dialog" aria-labelledby="reflectDialogTitle">
+        <h2 id="reflectDialogTitle" class="dialog-title">推荐已保存</h2>
+        <p class="dialog-body">是否现在填写执行情况?(可选,帮助下次推荐更贴合你。)</p>
+        <div class="btn-row dialog-actions">
+            <button type="button" class="btn btn-primary" id="reflectDialogGo">填写反馈</button>
+            <button type="button" class="btn btn-secondary" id="reflectDialogLater">稍后</button>
+        </div>
+    </dialog>
+
+    <script src="app.js"></script>
 </body>
-</html>
+</html>

+ 0 - 0
Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/screenshots/.gitkeep


BIN
Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/screenshots/diet.png


BIN
Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/screenshots/reflect.png


BIN
Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/screenshots/report.png


+ 633 - 38
Co-creation-projects/Shawnxyxy-HealthRecordAgent/frontend/style.css

@@ -1,88 +1,683 @@
+/* Apple Health–inspired: light grouped background, SF-like stack, soft cards */
+
+:root {
+    --bg-grouped: #f2f2f7;
+    --bg-elevated: #ffffff;
+    --separator: rgba(60, 60, 67, 0.12);
+    --label-primary: #000000;
+    --label-secondary: rgba(60, 60, 67, 0.6);
+    --label-tertiary: rgba(60, 60, 67, 0.3);
+    --accent-blue: #007aff;
+    --accent-green: #34c759;
+    --accent-orange: #ff9500;
+    --accent-red: #ff3b30;
+    --radius-lg: 20px;
+    --radius-md: 12px;
+    --radius-sm: 10px;
+    --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
+    --font-stack: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
+        "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+    --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+}
+
+*,
+*::before,
+*::after {
+    box-sizing: border-box;
+}
+
+html {
+    -webkit-font-smoothing: antialiased;
+}
+
 body {
-    font-family: Arial, sans-serif;
-    background: #f4f6f9;
     margin: 0;
-    padding: 30px;
+    min-height: 100vh;
+    font-family: var(--font-stack);
+    font-size: 17px;
+    line-height: 1.47;
+    color: var(--label-primary);
+    background: var(--bg-grouped);
+    padding: 0 0 48px;
 }
 
-.container {
-    max-width: 900px;
-    margin: auto;
+.app {
+    max-width: 820px;
+    margin: 0 auto;
+    padding: 16px 20px 32px;
 }
 
-h1 {
+/* —— Header(大标题区,类似「健康」顶部)—— */
+.app-header {
     text-align: center;
-    margin-bottom: 30px;
+    padding: 24px 8px 8px;
 }
 
-.card {
-    background: white;
-    padding: 20px;
+.app-title {
+    margin: 0;
+    font-size: 34px;
+    font-weight: 700;
+    letter-spacing: 0.37px;
+    line-height: 1.12;
+}
+
+.app-subtitle {
+    margin: 4px 0 0;
+    font-size: 13px;
+    font-weight: 400;
+    line-height: 1.35;
+    letter-spacing: 0.02em;
+    color: var(--label-tertiary);
+}
+
+/* —— 用户条 —— */
+.user-strip {
+    margin-bottom: 16px;
+}
+
+.user-strip .field-label {
+    display: block;
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--label-secondary);
+    text-transform: uppercase;
+    letter-spacing: -0.08px;
+    margin-bottom: 6px;
+}
+
+.user-strip .field-hint {
+    display: block;
+    margin-top: 8px;
+    font-size: 13px;
+    color: var(--label-secondary);
+}
+
+.tech-toggle {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin-top: 14px;
+    padding-top: 14px;
+    border-top: 1px solid var(--separator);
+    font-size: 15px;
+    font-weight: 500;
+    color: var(--label-secondary);
+    cursor: pointer;
+}
+
+.tech-toggle input {
+    width: auto;
+    accent-color: var(--accent-blue);
+}
+
+.reflect-run-row {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: stretch;
+    gap: 10px;
+}
+
+.reflect-run-row select {
+    flex: 1 1 200px;
+    min-width: 0;
+}
+
+.reflect-follow-fieldset {
+    border: none;
+    margin: 0;
+    padding: 0;
+}
+
+.reflect-follow-fieldset legend {
+    padding: 0;
+}
+
+.radio-row {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin-top: 10px;
+    font-size: 15px;
+    font-weight: 500;
+    cursor: pointer;
+}
+
+.radio-row input {
+    width: auto;
+    accent-color: var(--accent-blue);
+}
+
+.reflect-reason-block {
+    margin-top: 14px;
+    padding: 14px 16px;
+    background: rgba(118, 118, 128, 0.06);
+    border-radius: var(--radius-md);
+    border: 1px solid var(--separator);
+}
+
+.btn-compact {
+    padding: 10px 14px;
+    font-size: 15px;
+    font-weight: 600;
+    font-family: inherit;
+    border: none;
+    border-radius: var(--radius-md);
+    cursor: pointer;
+    background: rgba(118, 118, 128, 0.12);
+    color: var(--accent-blue);
+    white-space: nowrap;
+}
+
+.btn-compact:hover {
+    background: rgba(118, 118, 128, 0.18);
+}
+
+.app-dialog {
+    max-width: 360px;
+    width: calc(100vw - 40px);
+    padding: 22px 20px;
+    border: none;
+    border-radius: var(--radius-lg);
+    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
+}
+
+.app-dialog::backdrop {
+    background: rgba(0, 0, 0, 0.35);
+}
+
+.dialog-title {
+    margin: 0 0 10px;
+    font-size: 20px;
+    font-weight: 700;
+}
+
+.dialog-body {
+    margin: 0 0 18px;
+    font-size: 15px;
+    color: var(--label-secondary);
+    line-height: 1.45;
+}
+
+.dialog-actions {
+    margin-top: 0;
+    justify-content: flex-end;
+}
+
+.history-summary {
+    margin-top: 12px;
+    padding: 14px 16px;
+    font-size: 15px;
+    line-height: 1.45;
+    color: var(--label-primary);
+    background: rgba(118, 118, 128, 0.06);
+    border-radius: var(--radius-md);
+}
+
+.history-raw {
+    margin-top: 12px;
+}
+
+.history-raw summary {
+    cursor: pointer;
+    font-size: 15px;
+    font-weight: 600;
+    color: var(--accent-blue);
+    padding: 8px 0;
+}
+
+.history-raw pre {
+    margin: 8px 0 0;
+}
+
+/* —— 分段控件(Tab)—— */
+.tab-segment {
+    display: flex;
+    padding: 4px;
+    margin: 0 0 20px;
+    background: rgba(118, 118, 128, 0.12);
     border-radius: 10px;
-    margin-bottom: 20px;
-    box-shadow: 0 2px 8px rgba(0,0,0,0.05);
+    gap: 2px;
+}
+
+.tab-segment button {
+    flex: 1;
+    border: none;
+    padding: 8px 10px;
+    font-size: 13px;
+    font-weight: 600;
+    font-family: inherit;
+    color: var(--label-primary);
+    background: transparent;
+    border-radius: 8px;
+    cursor: pointer;
+    transition: background 0.2s ease, color 0.2s ease;
 }
 
+.tab-segment button[aria-selected="true"] {
+    background: var(--bg-elevated);
+    box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08), 0 1px 1px rgba(0, 0, 0, 0.04);
+}
+
+.tab-segment button[aria-selected="false"] {
+    color: var(--label-secondary);
+}
+
+.tab-segment button:focus-visible {
+    outline: 2px solid var(--accent-blue);
+    outline-offset: 2px;
+}
+
+.tab-panel {
+    animation: fadeIn 0.22s ease;
+}
+
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+    }
+    to {
+        opacity: 1;
+    }
+}
+
+/* —— 卡片 —— */
+.card {
+    background: var(--bg-elevated);
+    border-radius: var(--radius-lg);
+    padding: 20px 18px;
+    margin-bottom: 16px;
+    box-shadow: var(--shadow-card);
+}
+
+.card h2 {
+    margin: 0 0 4px;
+    font-size: 22px;
+    font-weight: 700;
+    letter-spacing: 0.35px;
+}
+
+.card h3 {
+    margin: 20px 0 8px;
+    font-size: 20px;
+    font-weight: 600;
+}
+
+.card > p.muted,
+.section-lead {
+    margin: 0 0 16px;
+    font-size: 15px;
+    color: var(--label-secondary);
+}
+
+.card-hr {
+    margin: 24px 0;
+    border: none;
+    height: 1px;
+    background: var(--separator);
+}
+
+/* —— 表单控件 —— */
+.field-label-inline {
+    display: block;
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--label-secondary);
+    margin-bottom: 6px;
+}
+
+input[type="text"],
+input[type="number"],
+input[type="file"],
+select,
 textarea {
     width: 100%;
-    height: 150px;
-    padding: 10px;
-    margin-top: 10px;
-    margin-bottom: 10px;
+    max-width: 100%;
+    font-family: inherit;
+    font-size: 17px;
+    padding: 12px 14px;
+    border-radius: var(--radius-sm);
+    border: 1px solid var(--separator);
+    background: rgba(118, 118, 128, 0.05);
+    color: var(--label-primary);
 }
 
-button {
-    padding: 10px 20px;
-    font-size: 15px;
+input::placeholder,
+textarea::placeholder {
+    color: var(--label-tertiary);
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+    outline: none;
+    border-color: var(--accent-blue);
+    box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.25);
+    background: var(--bg-elevated);
+}
+
+textarea {
+    min-height: 120px;
+    resize: vertical;
+}
+
+/* 主按钮:填充蓝 */
+.btn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    padding: 12px 20px;
+    font-size: 17px;
+    font-weight: 600;
+    font-family: inherit;
+    border: none;
+    border-radius: var(--radius-md);
     cursor: pointer;
+    transition: transform 0.15s ease, opacity 0.15s ease;
 }
 
-.section {
-    margin-top: 20px;
+.btn:active {
+    transform: scale(0.98);
 }
 
-.hidden {
-    display: none;
+.btn-primary {
+    background: var(--accent-blue);
+    color: #fff;
+}
+
+.btn-primary:hover {
+    opacity: 0.92;
+}
+
+.btn-secondary {
+    background: rgba(118, 118, 128, 0.12);
+    color: var(--accent-blue);
+}
+
+.btn-secondary:hover {
+    background: rgba(118, 118, 128, 0.18);
+}
+
+.btn-row {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 10px;
+    margin-top: 14px;
+}
+
+.flex-row {
+    display: flex;
+    gap: 16px;
+    flex-wrap: wrap;
+}
+
+.flex-column {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+    flex: 1;
+    min-width: 260px;
+}
+
+.diet-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+    gap: 14px;
+    align-items: end;
+}
+
+.diet-grid label {
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--label-secondary);
+}
+
+.diet-grid label.checkbox-row {
+    flex-direction: row;
+    align-items: center;
+    gap: 10px;
+}
+
+.diet-grid input[type="number"],
+.diet-grid select {
+    font-weight: 400;
+    color: var(--label-primary);
+}
+
+.checkbox-row {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 10px;
+    font-size: 15px;
+    font-weight: 500;
+}
+
+.checkbox-row input[type="checkbox"] {
+    width: auto;
+    accent-color: var(--accent-blue);
+}
+
+/* —— 状态与分析 —— */
+#analysis {
+    margin-top: 16px;
+    font-size: 15px;
+    font-weight: 600;
+    color: var(--label-secondary);
+}
+
+#progressList {
+    margin-top: 12px;
+    padding: 12px 16px;
+    list-style: none;
+    background: rgba(118, 118, 128, 0.06);
+    border-radius: var(--radius-md);
+}
+
+#progressList li {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 0 6px;
+    margin-bottom: 8px;
+    font-size: 15px;
+    padding-bottom: 8px;
+    border-bottom: 1px solid var(--separator);
+    min-height: 1.5em;
+}
+
+#progressList .agent-progress-label {
+    flex: 0 1 auto;
+    font-weight: 600;
+    color: var(--label-secondary);
+}
+
+#progressList .agent-progress-status {
+    flex: 1 1 120px;
+    color: var(--label-primary);
+    word-break: break-word;
+}
+
+#progressList li:last-child {
+    margin-bottom: 0;
+    padding-bottom: 0;
+    border-bottom: none;
 }
 
-ul {
+#report {
+    margin-top: 8px;
+    font-size: 15px;
+    line-height: 1.5;
+}
+
+#report :where(p, ul, ol) {
+    margin: 0 0 12px;
+}
+
+/* —— 饮食结果区 —— */
+#dietStatus {
+    margin-top: 12px;
+    font-size: 15px;
+    font-weight: 600;
+    color: var(--label-secondary);
+}
+
+#dietResult {
+    margin-top: 16px;
+    padding: 16px;
+    background: rgba(118, 118, 128, 0.06);
+    border-radius: var(--radius-md);
+    border: 1px solid var(--separator);
+    font-size: 15px;
+}
+
+#dietResult h4 {
+    margin: 0 0 10px;
+    font-size: 17px;
+    font-weight: 600;
+}
+
+#dietResult code {
+    font-family: var(--font-mono);
+    font-size: 13px;
+    background: rgba(118, 118, 128, 0.1);
+    padding: 2px 6px;
+    border-radius: 6px;
+}
+
+#dietHistoryPre {
+    margin-top: 12px;
+    max-height: 360px;
+    overflow: auto;
+    font-family: var(--font-mono);
+    font-size: 12px;
+    line-height: 1.45;
+    background: rgba(118, 118, 128, 0.06);
+    padding: 14px;
+    border-radius: var(--radius-md);
+    border: 1px solid var(--separator);
+    white-space: pre-wrap;
+    word-break: break-word;
+}
+
+/* —— 横幅与详情 —— */
+.banner {
+    padding: 12px 14px;
+    border-radius: var(--radius-md);
+    font-size: 15px;
+    margin: 12px 0;
+}
+
+.banner-warning {
+    background: rgba(255, 149, 0, 0.12);
+    color: #c65a00;
+    border: 1px solid rgba(255, 149, 0, 0.35);
+}
+
+.banner-error {
+    color: var(--accent-red);
+}
+
+.meal-plan-list {
+    margin: 8px 0;
     padding-left: 20px;
 }
 
-li {
-    margin-bottom: 5px;
+.meal-plan-list li {
+    margin-bottom: 10px;
+}
+
+.muted-why {
+    color: var(--label-secondary);
+    font-size: 0.94em;
+}
+
+.meal-tips {
+    font-size: 0.95em;
+    color: var(--label-secondary);
+}
+
+details.diet-trace {
+    margin-top: 12px;
+    font-size: 13px;
+    color: var(--label-secondary);
 }
 
+details.diet-trace summary {
+    cursor: pointer;
+    font-weight: 600;
+    color: var(--accent-blue);
+    padding: 8px 0;
+}
+
+details.diet-trace pre {
+    margin: 8px 0 0;
+    padding: 12px;
+    background: rgba(0, 0, 0, 0.04);
+    border-radius: var(--radius-sm);
+    overflow: auto;
+    max-height: 280px;
+    font-family: var(--font-mono);
+    font-size: 11px;
+}
+
+/* —— 历史页空状态 —— */
+.history-placeholder {
+    padding: 24px 16px;
+    text-align: center;
+    color: var(--label-secondary);
+    font-size: 15px;
+}
+
+.hidden {
+    display: none !important;
+}
+
+/* 兼容旧版 score 条(若仍使用) */
 .score-container {
     margin-top: 20px;
-    padding: 10px;
-    background: #f8f9fa;
-    border-radius: 8px;
+    padding: 14px;
+    background: rgba(118, 118, 128, 0.06);
+    border-radius: var(--radius-md);
 }
 
 .score-label {
-    font-weight: bold;
+    font-weight: 600;
     margin-bottom: 8px;
+    font-size: 15px;
 }
 
 .score-bar-bg {
     width: 100%;
-    height: 24px;
-    background-color: #ddd;
-    border-radius: 12px;
+    height: 8px;
+    background: rgba(118, 118, 128, 0.2);
+    border-radius: 4px;
     overflow: hidden;
 }
 
 .score-bar {
     height: 100%;
     width: 0%;
-    background-color: green;
+    background: var(--accent-green);
     transition: width 0.8s ease;
-    border-radius: 12px;
+    border-radius: 4px;
 }
 
 .score-text {
     margin-top: 8px;
     font-size: 15px;
-    font-weight: bold;
-}
+    font-weight: 600;
+}
+
+@media (max-width: 600px) {
+    .app-title {
+        font-size: 28px;
+    }
+
+    .tab-segment button {
+        font-size: 12px;
+        padding: 8px 6px;
+    }
+}

+ 4 - 1
Co-creation-projects/Shawnxyxy-HealthRecordAgent/requirements.txt

@@ -36,4 +36,7 @@ pydantic-settings==2.12.0
 
 # 可选工具库
 python-dotenv==1.2.1
-rich==14.3.2
+rich==14.3.2
+
+# RAG / 向量检索
+pymilvus