Browse Source

docs(plan): AI动态问卷引擎实施计划(8任务:LangGraph恢复/主流程/端点/种子/工具/AI网关/控制器/前端)

Xiaogang Liao 2 tháng trước cách đây
mục cha
commit
cdf8530b35
1 tập tin đã thay đổi với 2171 bổ sung và 0 xóa
  1. 2171 0
      docs/superpowers/plans/2026-08-14-ai-dynamic-questionnaire.md

+ 2171 - 0
docs/superpowers/plans/2026-08-14-ai-dynamic-questionnaire.md

@@ -0,0 +1,2171 @@
+# AI 动态问卷引擎 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 实现通用 AI 动态问卷引擎——逐题对话式出题(AI 根据用户回答生成下一题),结合菌群知识库(RAG),最终生成灵活的 JSON 维度画像(用户画像 + 需求画像),支持管理端场景配置,画像存库并展示。
+
+**架构:** Java(cfc-backend)负责会话编排与存储(`ai_q_scene`/`ai_q_session`/`ai_q_profile` 三表),通过 `AiGateway` 调用 LangGraph Python 服务(cfc-langgraph)的无状态推理引擎:`POST /api/v1/qna/advance`(动态出题)与 `POST /api/v1/qna/profile`(画像生成)。LangGraph 引擎内部:知识检索(ChromaDB `cfc_knowledge`)→ LLM 决策出题/结束 → 画像生成。
+
+**技术栈:** Python FastAPI + LangGraph + LangChain + ChromaDB(cfc-langgraph)/ Spring Boot 2.7 + MyBatis-Plus(Java)/ Vue 2 + Element UI(cfc-web)/ uni-app Vue 2 小程序(cfc-frontend)
+
+**规格:** `docs/superpowers/specs/2026-08-14-ai-dynamic-questionnaire-design.md`
+
+---
+
+## 文件结构总览
+
+### cfc-langgraph(Python,恢复 + 新增)
+
+| 文件 | 职责 |
+|------|------|
+| `app/config.py` | 服务配置(从 git 历史恢复,工作区当前 0 字节) |
+| `app/main.py` | FastAPI 入口 + 路由注册(恢复后精简 import,注册 qna/health/monitoring/logs) |
+| `app/rag/retriever.py`、`loader.py`、`splitter.py`、`embeddings.py` | RAG 检索与知识库同步(恢复;`loader.py` 被 `knowledge_sync.py` 引用) |
+| `app/tools/java_client.py` | Java 后端 HTTP 客户端(工作区已有完整版 117 行,保留) |
+| `app/tasks/knowledge_sync.py` | 知识库定时同步(工作区已有完整版 129 行,保留) |
+| `app/middleware.py`、`log_config.py`、`monitoring.py` | 中间件/日志/监控(从历史恢复) |
+| `src/llm/client.py` | LLM 客户端 `get_llm()`(从历史恢复) |
+| `src/qna/schemas.py` | **新增**:Question/HistoryItem/SceneConfig/QnaRequest/QnaResponse/Profile 模型 |
+| `src/qna/prompts.py` | **新增**:出题/画像 prompt 模板 |
+| `src/qna/graph.py` | **新增**:`qna_graph`(出题)与 `qna_profile_graph`(画像) |
+| `src/qna/router.py` | **新增**:`POST /api/v1/qna/advance`、`POST /api/v1/qna/profile` |
+| `tests/qna/test_graph.py` | **新增**:qna 引擎单测(fake LLM) |
+
+### cfc-backend(Java)
+
+| 文件 | 职责 |
+|------|------|
+| `src/main/resources/schema.sql` | 追加 3 张表定义 |
+| `src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 追加 3 个 `CREATE TABLE IF NOT EXISTS` 迁移 + `microbiome` 种子场景 |
+| `src/main/java/com/etotem/cfc/entity/AiQScene.java`、`AiQSession.java`、`AiQProfile.java` | MyBatis-Plus 实体 |
+| `src/main/java/com/etotem/cfc/mapper/AiQSceneMapper.java`、`AiQSessionMapper.java`、`AiQProfileMapper.java` | BaseMapper |
+| `src/main/java/com/etotem/cfc/service/AiQuestionnaireService.java` + `impl/AiQuestionnaireServiceImpl.java` | 场景 CRUD、start/answer/finish 会话编排、画像存储 |
+| `src/main/java/com/etotem/cfc/service/AiGateway.java` | 新增 `advanceQuestionnaire()` / `generateProfile()` |
+| `src/main/java/com/etotem/cfc/controller/AiQuestionnaireController.java` | `/api/ai-questionnaire/*` REST 端点 |
+| `src/main/resources/application.yml` | `langgraph.profile-timeout-ms` 配置 |
+
+### cfc-web(Vue 2 管理端)
+
+| 文件 | 职责 |
+|------|------|
+| `src/api/aiQuestionnaire.js` | 场景 CRUD 接口封装 |
+| `src/views/admin/AiQuestionnaireScenes.vue` | 场景配置管理页 |
+| `src/router/index.js` | admin 路由注册 |
+
+### cfc-frontend(uni-app 小程序)
+
+| 文件 | 职责 |
+|------|------|
+| `utils/api.js` | 新增 `aiQStart`/`aiQAnswer`/`aiQFinish`/`aiQSceneList`/`aiQHistory`/`aiQProfileDetail` |
+| `pages/health/ai-questionnaire.vue` | 逐题对话式问卷页 |
+| `pages/health/ai-questionnaire-result.vue` | 画像展示页 |
+| `pages.json` | 注册两个新页面 |
+| `pages/health-main/index.vue` | 加入口按钮 |
+
+---
+
+## 任务 1:恢复 cfc-langgraph 最小可运行集
+
+**背景:** commit `f18dd86e` 将 cfc-langgraph 大部分源码清空(工作区 0 字节),但 git 历史有完整版本(`app/` 取 `2f685d22`,`src/` 问卷模块取 `f18dd86e~1`)。工作区已有 `app/tools/java_client.py`(117 行)与 `app/tasks/knowledge_sync.py`(129 行)不可丢弃。
+
+**文件:**
+- 恢复:`cfc-langgraph/app/config.py`、`app/main.py`、`app/middleware.py`、`app/log_config.py`、`app/monitoring.py`、`app/rag/*.py`、`app/api/health.py`、`src/llm/client.py`、`src/app.py`、`src/schemas/*`、`src/prompts/*`、`src/graphs/*`
+- 保留(勿覆盖):`app/tools/java_client.py`、`app/tasks/knowledge_sync.py`
+
+- [ ] **步骤 1:从 git 历史恢复 app/ 关键文件**
+
+```bash
+cd /app/cfc/cfc-langgraph
+for f in config.py main.py middleware.py log_config.py monitoring.py; do
+  git show 2f685d22:cfc-langgraph/app/$f > app/$f
+done
+for f in retriever.py loader.py splitter.py embeddings.py; do
+  git show 2f685d22:cfc-langgraph/app/rag/$f > app/rag/$f
+done
+git show 2f685d22:cfc-langgraph/app/api/health.py > app/api/health.py
+git show 2f685d22:cfc-langgraph/app/memory/store.py > app/memory/store.py
+# 检查恢复文件非空
+wc -l app/config.py app/main.py app/rag/retriever.py src/llm/client.py
+```
+
+预期:`app/config.py` ≈54 行、`app/rag/retriever.py` ≈87 行,全部非 0 字节。
+
+- [ ] **步骤 2:确认工作区新增文件保留**
+
+运行:`git -C /app/cfc status --short cfc-langgraph/ | grep -E "java_client|knowledge_sync"`
+预期:` M cfc-langgraph/app/tools/java_client.py`、` M cfc-langgraph/app/tasks/knowledge_sync.py`(内容不丢失)。
+
+- [ ] **步骤 3:精简 main.py 的 import 与启动验证**
+
+修改 `app/main.py`:只保留可运行的模块(qna 尚未创建前先保留 health/monitoring/logs 中间件 + src questionnaire router),注释掉 chat/adapter/report_parse/tongue/meal/analyze/recommend 的 import 与 `include_router`(这些模块文件为 0 字节,import 会失败)。
+
+```bash
+# 验证 Python 语法
+cd /app/cfc/cfc-langgraph && .venv/bin/python -c "import ast; ast.parse(open('app/main.py').read()); print('main.py OK')"
+.venv/bin/python -c "ast.parse(open('src/app.py').read()); print('src/app.py OK')"
+```
+
+预期:两行均输出 `OK`。
+
+- [ ] **步骤 4:启动验证**
+
+```bash
+cd /app/cfc/cfc-langgraph && timeout 25 .venv/bin/uvicorn src.app:app --port 9001 2>&1 | head -30
+```
+
+预期:启动日志显示 FastAPI 应用启动成功(`Application startup complete` 或至少无 ImportError;启动期间 ChromaDB 初始化/知识库同步失败仅为 warn 不阻塞)。若出现 ImportError,逐个补齐缺失的恢复文件或移除 main.py 对应 import,重复本步骤。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-langgraph/app cfc-langgraph/src
+git commit -m "fix(langgraph): 从 git 历史恢复最小可运行集(config/rag/llm/middleware)"
+```
+
+---
+
+## 任务 2:LangGraph qna 引擎(schemas + prompts + graphs)
+
+**文件:**
+- 创建:`cfc-langgraph/src/qna/schemas.py`、`src/qna/prompts.py`、`src/qna/graph.py`
+- 测试:`cfc-langgraph/tests/qna/test_graph.py`
+
+- [ ] **步骤 1:编写失败的测试**
+
+创建 `tests/qna/test_graph.py`:
+
+```python
+"""qna 引擎单测:用 fake LLM 返回固定 JSON,验证图节点输出"""
+import json
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).resolve().parents[2]))  # cfc-langgraph 根
+
+from src.qna import schemas, graph, prompts  # noqa: E402
+
+
+class FakeLLM:
+    """返回固定决策 JSON 的假 LLM"""
+    def __init__(self, decisions):
+        self.decisions = list(decisions)
+        self.calls = []
+
+    def invoke(self, messages):
+        self.calls.append(messages)
+        d = self.decisions.pop(0) if len(self.decisions) > 1 else self.decisions[0]
+        return type("R", (), {"content": json.dumps(d, ensure_ascii=False)})()
+
+
+def make_scene(**kw):
+    base = {
+        "scene_key": "microbiome",
+        "opening_prompt": "了解您的肠道健康状况",
+        "dimensions_json": {"user": ["肠道状态"], "need": ["营养需求"]},
+        "kb_scope": ["microbiome"],
+        "max_questions": 12,
+    }
+    base.update(kw)
+    return base
+
+
+def test_decide_next_ask():
+    llm = FakeLLM([{"action": "ask", "question": {
+        "id": "q1", "type": "single", "text": "您多久吃一次蔬菜?",
+        "options": [{"id": "a", "label": "每天"}]}, "reason": "了解饮食"}])
+    state = graph.decide_next(llm, make_scene(), [], prompt_fn=prompts.build_decide_prompt)
+    assert state["action"] == "ask"
+    assert state["question"]["text"].startswith("您多久")
+
+
+def test_decide_next_force_finish_when_max_reached():
+    llm = FakeLLM([{"action": "ask", "question": {"id": "q9", "type": "text", "text": "x"}}])
+    history = [{"question": {"text": f"q{i}"}, "answer": "a"} for i in range(12)]
+    state = graph.decide_next(llm, make_scene(max_questions=12), history, prompt_fn=prompts.build_decide_prompt)
+    assert state["action"] == "finish"
+
+
+def test_decide_next_invalid_json_retries_once():
+    llm = FakeLLM(["not json", {"action": "finish", "reason": "信息足够"}])
+    state = graph.decide_next(llm, make_scene(), [], prompt_fn=prompts.build_decide_prompt)
+    assert state["action"] == "finish"
+    assert len(llm.calls) == 2  # 重试了一次
+
+
+def test_generate_profile_structure():
+    llm = FakeLLM([{"user_profile": [{"dimension": "肠道状态", "score": 70, "description": "偏健康",
+                                       "evidence": ["答1"]}],
+                    "need_profile": [{"dimension": "营养需求", "description": "补纤维",
+                                      "evidence": ["答1"], "suggestion": "多吃粗粮"}]}])
+    history = [{"question": {"text": "q1"}, "answer": "a"}]
+    profile, kb_used = graph.generate_profile(llm, make_scene(), history, kb_context=[{"content": "菌属知识"}],
+                                              prompt_fn=prompts.build_profile_prompt)
+    assert "user_profile" in profile and "need_profile" in profile
+    assert profile["user_profile"][0]["dimension"] == "肠道状态"
+    assert 0 <= profile["user_profile"][0]["score"] <= 100
+    assert kb_used is True
+```
+
+- [ ] **步骤 2:运行测试验证失败**
+
+```bash
+cd /app/cfc/cfc-langgraph && .venv/bin/python -m pytest tests/qna/test_graph.py -v 2>&1 | tail -10
+```
+
+预期:FAIL / ERROR(`ModuleNotFoundError: No module named 'src.qna'`)。
+
+- [ ] **步骤 3:创建 schemas.py**
+
+创建 `src/qna/schemas.py`:
+
+```python
+"""qna 动态问卷引擎 - Pydantic 模型"""
+from typing import List, Optional, Union
+from pydantic import BaseModel, Field
+
+
+class Option(BaseModel):
+    id: str
+    label: str
+
+
+class Scale(BaseModel):
+    min: int = 0
+    max: int = 10
+    minLabel: str = "从不"
+    maxLabel: str = "每天"
+
+
+class Question(BaseModel):
+    id: str
+    type: str = Field(pattern="^(single|multi|scale|text)$")
+    text: str
+    options: Optional[List[Option]] = None
+    scale: Optional[Scale] = None
+
+    def is_valid(self) -> bool:
+        if self.type in ("single", "multi"):
+            return bool(self.options) and not self.scale
+        if self.type == "scale":
+            return self.scale is not None
+        return True  # text
+
+
+class HistoryItem(BaseModel):
+    question: Question
+    answer: str
+
+
+class SceneConfig(BaseModel):
+    scene_key: str
+    opening_prompt: str = ""
+    dimensions_json: dict = {}
+    kb_scope: List[str] = ["microbiome"]
+    max_questions: int = 12
+    system_prompt: Optional[str] = None
+
+
+class QnaRequest(BaseModel):
+    scene: SceneConfig
+    history: List[HistoryItem] = []
+
+
+class QnaResponse(BaseModel):
+    action: str  # ask | finish
+    question: Optional[Question] = None
+    reason: Optional[str] = None
+    finished: bool = False
+
+
+class ProfileResponse(BaseModel):
+    profile: dict
+    kb_used: bool = False
+```
+
+- [ ] **步骤 4:创建 prompts.py**
+
+创建 `src/qna/prompts.py`:
+
+```python
+"""qna 引擎 - prompt 模板(场景可配维度,引擎不硬编码)"""
+import json
+
+
+def build_decide_prompt(scene: dict, knowledge_text: str, history: list) -> str:
+    kb = knowledge_text or "(知识库未命中,请基于通用健康知识回答)"
+    hist = "\n".join(
+        f"Q{i+1}: {h['question']['text']} → 答: {h['answer']}" for i, h in enumerate(history)
+    ) or "(问卷刚开始)"
+    dims = json.dumps(scene.get("dimensions_json", {}), ensure_ascii=False)
+    return f"""你是智能健康问卷助手。根据用户已答内容,动态生成下一题,用于最终生成用户画像与需求画像。
+
+【场景】{scene.get('scene_name', scene['scene_key'])}
+【开场引导】{scene.get('opening_prompt', '')}
+【画像维度】(出题时请围绕这些维度收集信息) {dims}
+【知识库参考】
+{kb}
+
+【用户历史回答】
+{hist}
+
+【规则】
+1. 若信息已足够覆盖画像维度,输出 finish;否则输出 ask 出一题。
+2. 题目类型 single=单选(带options) / multi=多选(带options) / scale=量表(带scale) / text=自由文本。
+3. 问题需结合知识库内容与用户回答,有针对性;不要问与已答重复的信息。
+4. 严格输出 JSON,不要输出其他内容:
+{{"action": "ask"|"finish", "question": {{"id": "q1", "type": "single", "text": "题目", "options": [{{"id": "a", "label": "选项"}}]}}, "reason": "简短说明"}}
+"""
+
+
+def build_profile_prompt(scene: dict, knowledge_text: str, history: list) -> str:
+    kb = knowledge_text or "(知识库未命中)"
+    hist = "\n".join(
+        f"Q{i+1}: {h['question']['text']} → 答: {h['answer']}" for i, h in enumerate(history)
+    )
+    dims = json.dumps(scene.get("dimensions_json", {}), ensure_ascii=False)
+    return f"""你是智能健康分析助手。基于用户问卷回答与知识库,生成用户画像与需求画像。
+
+【画像维度定义】(维度名称由场景配置,不得新增未定义维度)
+{dims}
+
+【知识库参考】(引用其中的菌属/营养素/指标知识作为依据)
+{kb}
+
+【用户问卷历史】
+{hist}
+
+【输出要求】严格 JSON:
+{{
+  "user_profile": [
+    {{"dimension": "维度名", "score": 0-100, "description": "分析", "evidence": ["证据1"]}}
+  ],
+  "need_profile": [
+    {{"dimension": "维度名", "description": "需求分析", "evidence": ["证据"], "suggestion": "建议"}}
+  ]
+}}
+"""
+```
+
+- [ ] **步骤 5:创建 graph.py**
+
+创建 `src/qna/graph.py`:
+
+```python
+"""qna 引擎 - 无状态纯函数(可单测),供 router 调用"""
+import json
+import re
+from typing import Optional
+
+scn_split = None
+try:
+    from . import schemas, prompts  # noqa
+except Exception:
+    from src.qna import prompts as prompts  # noqa: F811
+
+
+def _extract_keywords(history: list) -> list:
+    texts = []
+    for h in history:
+        texts.append(h.get("question", {}).get("text", ""))
+        texts.append(h.get("answer", ""))
+    joined = " ".join(texts)
+    stopwords = {"请问", "帮我", "怎么", "什么", "如何", "是否", "一个", "这个", "那个"}
+    words = re.findall(r"[\u4e00-\u9fff]{2,6}", joined)
+    seen = []
+    for w in words:
+        w = w.strip()
+        if w and w not in stopwords and w not in seen:
+            seen.append(w)
+    return seen[:8]
+
+
+def _extract_json(text: str) -> dict:
+    t = text.strip()
+    if "```" in t:
+        t = t.split("```json")[-1].split("```")[0].strip() if "```json" in t else t.split("```")[1].split("```")[0].strip()
+    start, end = t.find("{"), t.rfind("}")
+    if start == -1 or end == -1:
+        raise ValueError("无 JSON 块")
+    return json.loads(t[start:end + 1])
+
+
+def decide_next(llm, scene: dict, history: list, prompt_fn) -> dict:
+    """根据场景+知识+历史,输出 {action, question?, reason?}。LLM 输出非法自动重试 1 次。"""
+    prompt = prompt_fn(scene, "", history)  # 知识在 router 中检索后注入,见 retrieve_and_decide
+    last_err = None
+    for attempt in range(2):
+        resp = llm.invoke([{"role": "human", "content": prompt}])
+        try:
+            data = _extract_json(resp.content)
+            action = data.get("action")
+            if action == "ask":
+                q = data.get("question", {})
+                qobj = schemas.Question(**q)
+                if not qobj.is_valid():
+                    raise ValueError(f"题目结构非法: {q}")
+                return {"action": "ask", "question": qobj.dict(), "reason": data.get("reason", "")}
+            if action == "finish":
+                return {"action": "finish", "reason": data.get("reason", "信息已足够")}
+            raise ValueError(f"未知 action: {action}")
+        except Exception as e:
+            last_err = str(e)
+    return {"action": "ask", "fallback": True, "reason": f"LLM 输出异常: {last_err}",
+            "question": {"id": f"fb{len(history)+1}", "type": "text", "text": "请简单描述您最近一周的饮食情况。"}}
+
+
+def generate_profile(llm, scene: dict, history: list, kb_context: Optional[list], prompt_fn) -> tuple:
+    kb_text = ""
+    if kb_context:
+        kb_text = "\n---\n".join(d.get("content", "") for d in kb_context[:5])
+    prompt = prompt_fn(scene, kb_text, history)
+    resp = llm.invoke([{"role": "human", "content": prompt}])
+    try:
+        data = _extract_json(resp.content)
+        up = data.get("user_profile", [])
+        np = data.get("need_profile", [])
+        for item in up:
+            item["score"] = max(0, min(100, int(item.get("score", 0))))
+        return {"user_profile": up, "need_profile": np}, bool(kb_text)
+    except Exception:
+        return {"user_profile": [], "need_profile": []}, False
+```
+
+- [ ] **步骤 6:运行测试验证通过**
+
+```bash
+cd /app/cfc/cfc-langgraph && .venv/bin/python -m pytest tests/qna/test_graph.py -v 2>&1 | tail -10
+```
+
+预期:4 个测试全部 PASS。
+
+- [ ] **步骤 7:Commit**
+
+```bash
+git add cfc-langgraph/src/qna tests/qna
+git commit -m "feat(qna): 动态问卷引擎核心(schemas/prompts/decide_next/generate_profile)"
+```
+
+---
+
+## 任务 3:LangGraph qna API 端点 + 路由注册
+
+**文件:**
+- 创建:`cfc-langgraph/src/qna/router.py`
+- 修改:`cfc-langgraph/src/app.py`(挂载 qna router)
+
+- [ ] **步骤 1:创建 router.py**
+
+创建 `src/qna/router.py`:
+
+```python
+"""qna 动态问卷引擎 - HTTP 端点"""
+from fastapi import APIRouter
+from pydantic import BaseModel
+from typing import List, Optional
+
+from .schemas import QnaRequest, QnaResponse, ProfileResponse, SceneConfig, HistoryItem
+from . import graph
+from .prompts import build_decide_prompt, build_profile_prompt
+from app.rag.retriever import RagRetriever
+
+router = APIRouter(prefix="/api/v1/qna", tags=["qna"])
+
+_retriever: Optional[RagRetriever] = None
+
+
+def _get_retriever() -> Optional[RagRetriever]:
+    global _retriever
+    if _retriever is None:
+        try:
+            _retriever = RagRetriever()
+        except Exception:
+            _retriever = None  # 知识库不可用 → 降级
+    return _retriever
+
+
+def _retrieve(kb_scope: List[str], history: List[dict]) -> list:
+    retriever = _get_retriever()
+    if retriever is None:
+        return []
+    try:
+        query = " ".join([h.get("answer", "") for h in history]) or "肠道健康 饮食习惯"
+        return retriever.retrieve(query, k=5)  # filter 按 scope 由 retriever 支持后接入
+    except Exception:
+        return []
+
+
+def _build_llm():
+    try:
+        from src.llm.client import get_llm
+        return get_llm()
+    except Exception:
+        from langchain_openai import ChatOpenAI
+        import os
+        return ChatOpenAI(model=os.getenv("LLM_MODEL", "deepseek"),
+                          api_key=os.getenv("LLM_API_KEY", ""),
+                          base_url=os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1"),
+                          temperature=0.7)
+
+
+@router.post("/advance", response_model=QnaResponse)
+async def advance(req: QnaRequest):
+    scene = req.scene.dict()
+    history = [h.dict() for h in req.history]
+    llm = _build_llm()
+    kb_context = _retrieve(scene.get("kb_scope", []), history)
+    # 知识注入版 decide → 简化:先检索,再带检索结果出题
+    from .graph import retrieve_and_decide  # 见下方说明
+    state = retrieve_and_decide(llm, scene, history, kb_context, build_decide_prompt)
+    if state.get("fallback"):
+        return QnaResponse(action="ask", question=state["question"], reason=state.get("reason"), finished=False)
+    if state["action"] == "finish":
+        return QnaResponse(action="finish", finished=True, reason=state.get("reason", "信息已足够"))
+    return QnaResponse(action="ask", question=state["question"], finished=False)
+
+
+@router.post("/profile", response_model=ProfileResponse)
+async def profile(req: QnaRequest):
+    scene = req.scene.dict()
+    history = [h.dict() for h in req.history]
+    llm = _build_llm()
+    kb_context = _retrieve(scene.get("kb_scope", []), history)
+    profile_json, kb_used = graph.generate_profile(llm, scene, history, kb_context, build_profile_prompt)
+    return ProfileResponse(profile=profile_json, kb_used=kb_used)
+```
+
+> 说明:`retrieve_and_decide` 为 `graph.py` 中真正的知识注入版本——把 `kb_context` 转为文本后调用 `decide_next`。在 graph.py 末尾追加:
+
+```python
+def retrieve_and_decide(llm, scene, history, kb_context, prompt_fn) -> dict:
+    kb_text = ""
+    if kb_context:
+        kb_text = "\n---\n".join(d.get("content", "") for d in kb_context[:5])
+    prompt = prompt_fn(scene, kb_text, history)
+    resp = llm.invoke([{"role": "human", "content": prompt}])
+    try:
+        data = _extract_json(resp.content)
+        if data.get("action") == "finish":
+            return {"action": "finish", "reason": data.get("reason", "信息已足够")}
+        q = schemas.Question(**data["question"])
+        return {"action": "ask", "question": q.dict(), "reason": data.get("reason", "")}
+    except Exception as e:
+        return {"fallback": True, "reason": f"出题失败: {e}",
+                "question": {"id": f"fb{len(history)+1}", "type": "text",
+                             "text": "请简单描述您最近一周的饮食和作息情况。"}}
+```
+
+- [ ] **步骤 2:挂载路由到 src/app.py**
+
+修改 `src/app.py`,在现有 questionnaire router 之后追加:
+
+```python
+from .qna.router import router as qna_router
+app.include_router(qna_router)
+```
+
+- [ ] **步骤 3:语法校验与导入验证**
+
+```bash
+cd /app/cfc/cfc-langgraph && .venv/bin/python -c "from src.qna import router; print('router OK')" 2>&1 | tail -3
+```
+
+预期:`router OK`(若 `app.rag.retriever` 导入失败,先完成任务 1 的恢复)。
+
+- [ ] **步骤 4:启动 + 冒烟测试**
+
+```bash
+cd /app/cfc/cfc-langgraph && timeout 30 .venv/bin/uvicorn src.app:app --port 9001 > /tmp/qna_smoke.log 2>&1 &
+sleep 12
+curl -s -X POST localhost:9001/api/v1/qna/advance -H 'Content-Type: application/json' \
+  -d '{"scene": {"scene_key": "microbiome", "opening_prompt": "了解肠道健康", "dimensions_json": {"user": ["肠道状态"], "need": ["营养需求"]}, "kb_scope": ["microbiome"], "max_questions": 12}, "history": []}' | head -c 300
+echo
+```
+
+预期:返回 JSON 含 `"action": "ask"` 与 `"question"`(LLM 真实调用;若 LLM 不可用返回 fallback 兜底题,同样含 `question`)。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-langgraph/src/qna cfc-langgraph/src/app.py
+git commit -m "feat(qna): /api/v1/qna/advance + /profile 端点与路由注册"
+```
+
+---
+
+## 任务 4:Java 数据层(schema.sql + 迁移 + Entity/Mapper)
+
+**文件:**
+- 修改:`cfc-backend/src/main/resources/schema.sql`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/AiQScene.java`、`AiQSession.java`、`AiQProfile.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/AiQSceneMapper.java`、`AiQSessionMapper.java`、`AiQProfileMapper.java`
+
+- [ ] **步骤 1:schema.sql 追加 3 张表**
+
+在 `schema.sql` 末尾追加(与规格 5.1-5.3 一致):
+
+```sql
+-- =============================================
+-- AI 动态问卷引擎(逐题对话式 + 画像)
+-- =============================================
+
+CREATE TABLE IF NOT EXISTS ai_q_scene (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  scene_key VARCHAR(50) NOT NULL COMMENT '场景唯一标识(如 microbiome)',
+  scene_name VARCHAR(100) NOT NULL COMMENT '场景名称',
+  description VARCHAR(500) DEFAULT NULL COMMENT '场景描述',
+  opening_prompt TEXT COMMENT '开场引导',
+  dimensions_json JSON COMMENT '画像维度定义 {user:[...], need:[...]}',
+  kb_scope VARCHAR(200) DEFAULT 'microbiome' COMMENT '知识库范围(逗号分隔 source 前缀)',
+  max_questions INT DEFAULT 12 COMMENT '题数上限',
+  system_prompt TEXT COMMENT '可选:场景自定义 system prompt',
+  enabled TINYINT DEFAULT 1 COMMENT '1=启用 0=禁用',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_scene_key (scene_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 动态问卷-场景配置';
+
+CREATE TABLE IF NOT EXISTS ai_q_session (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  scene_id BIGINT NOT NULL COMMENT '场景ID',
+  user_id BIGINT NOT NULL COMMENT '填写者用户ID',
+  member_id BIGINT NOT NULL COMMENT '画像关联成员ID',
+  family_id BIGINT DEFAULT NULL COMMENT '家庭ID',
+  status VARCHAR(16) DEFAULT 'running' COMMENT 'running/finished/aborted',
+  history_json JSON COMMENT '已回答历史 [{question:{...}, answer:"..."}]',
+  current_question_json JSON COMMENT '当前待答题',
+  question_count INT DEFAULT 0 COMMENT '已答题数',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  finished_at DATETIME DEFAULT NULL COMMENT '完成时间',
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  INDEX idx_member_status (member_id, status),
+  INDEX idx_scene (scene_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 动态问卷-会话';
+
+CREATE TABLE IF NOT EXISTS ai_q_profile (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  session_id BIGINT NOT NULL COMMENT '会话ID',
+  scene_id BIGINT NOT NULL COMMENT '场景ID',
+  member_id BIGINT NOT NULL COMMENT '成员ID',
+  user_profile_json JSON COMMENT '用户画像 [{dimension,score,description,evidence}]',
+  need_profile_json JSON COMMENT '需求画像 [{dimension,description,evidence,suggestion}]',
+  raw_result TEXT COMMENT 'LLM 原始输出(审计)',
+  kb_used TINYINT DEFAULT 0 COMMENT '是否使用了知识库',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_session (session_id),
+  INDEX idx_member (member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 动态问卷-画像结果';
+```
+
+- [ ] **步骤 2:DatabaseInitializer 追加迁移**
+
+在 `DatabaseInitializer.java` 的 `runMigrations()` 方法末尾(找到 `// 迁移N` 最新编号后追加):
+
+```java
+// 迁移N: 创建 ai_q_scene / ai_q_session / ai_q_profile 表(AI 动态问卷引擎)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_q_scene (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "scene_key VARCHAR(50) NOT NULL, " +
+            "scene_name VARCHAR(100) NOT NULL, " +
+            "description VARCHAR(500), " +
+            "opening_prompt TEXT, " +
+            "dimensions_json JSON, " +
+            "kb_scope VARCHAR(200) DEFAULT 'microbiome', " +
+            "max_questions INT DEFAULT 12, " +
+            "system_prompt TEXT, " +
+            "enabled TINYINT DEFAULT 1, " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+            "UNIQUE KEY uk_scene_key (scene_key)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 动态问卷-场景配置'");
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_q_session (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "scene_id BIGINT NOT NULL, " +
+            "user_id BIGINT NOT NULL, " +
+            "member_id BIGINT NOT NULL, " +
+            "family_id BIGINT, " +
+            "status VARCHAR(16) DEFAULT 'running', " +
+            "history_json JSON, " +
+            "current_question_json JSON, " +
+            "question_count INT DEFAULT 0, " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "finished_at DATETIME, " +
+            "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+            "INDEX idx_member_status (member_id, status), " +
+            "INDEX idx_scene (scene_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 动态问卷-会话'");
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ai_q_profile (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "session_id BIGINT NOT NULL, " +
+            "scene_id BIGINT NOT NULL, " +
+            "member_id BIGINT NOT NULL, " +
+            "user_profile_json JSON, " +
+            "need_profile_json JSON, " +
+            "raw_result TEXT, " +
+            "kb_used TINYINT DEFAULT 0, " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "UNIQUE KEY uk_session (session_id), " +
+            "INDEX idx_member (member_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 动态问卷-画像结果'");
+    // 种子场景:菌群健康评估
+    jdbcTemplate.execute("INSERT IGNORE INTO ai_q_scene (scene_key, scene_name, description, opening_prompt, dimensions_json, kb_scope, max_questions, enabled) " +
+            "VALUES ('microbiome', '菌群健康评估', '通过动态问答评估肠道菌群健康状况并生成营养需求画像', " +
+            "'我将通过几个问题了解您的肠道健康状况,请如实回答。', " +
+            "'{\"user\": [\"肠道菌群状态\", \"饮食习惯\", \"生活方式\"], \"need\": [\"营养需求\", \"菌群调理建议\"]}', " +
+            "'microbiome,dan_knowledge', 12, 1)");
+    log.info("已创建 ai_q_scene/ai_q_session/ai_q_profile 表并初始化 microbiome 场景");
+} catch (Exception e) {
+    // 表已存在,忽略错误
+}
+```
+
+> 注意:`// 迁移N` 编号按文件末尾实际最新编号递增(编写时搜索确认)。
+
+- [ ] **步骤 3:创建 3 个 Entity**
+
+创建 `entity/AiQScene.java`:
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.util.Date;
+
+@Data
+@TableName("ai_q_scene")
+public class AiQScene {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String sceneKey;
+    private String sceneName;
+    private String description;
+    private String openingPrompt;
+    private String dimensionsJson;
+    private String kbScope;
+    private Integer maxQuestions;
+    private String systemPrompt;
+    private Integer enabled;
+    private Date createdAt;
+    private Date updatedAt;
+}
+```
+
+创建 `entity/AiQSession.java`:
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.util.Date;
+
+@Data
+@TableName("ai_q_session")
+public class AiQSession {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long sceneId;
+    private Long userId;
+    private Long memberId;
+    private Long familyId;
+    private String status;
+    private String historyJson;
+    private String currentQuestionJson;
+    private Integer questionCount;
+    private Date createdAt;
+    private Date finishedAt;
+    private Date updatedAt;
+}
+```
+
+创建 `entity/AiQProfile.java`:
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.util.Date;
+
+@Data
+@TableName("ai_q_profile")
+public class AiQProfile {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long sessionId;
+    private Long sceneId;
+    private Long memberId;
+    private String userProfileJson;
+    private String needProfileJson;
+    private String rawResult;
+    private Integer kbUsed;
+    private Date createdAt;
+}
+```
+
+> 确认 Entity 的 JSON 字段统一用 `String` 存取(项目现有 `dan_report_uploads.parsed_items JSON` 也以 String/JSON 处理,参照现有实体如 `SurveyTemplate` 的 JSON 字段写法;若实体有 `@TableField` 类型处理器则保持一致)。
+
+- [ ] **步骤 4:创建 3 个 Mapper**
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.AiQScene;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AiQSceneMapper extends BaseMapper<AiQScene> {
+}
+```
+
+(`AiQSessionMapper`、`AiQProfileMapper` 同构,分别对应 `AiQSession`/`AiQProfile`。)
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd /app/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
+```
+
+预期:`BUILD SUCCESS`(无编译错误)。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/resources/schema.sql \
+        cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+        cfc-backend/src/main/java/com/etotem/cfc/entity/AiQ*.java \
+        cfc-backend/src/main/java/com/etotem/cfc/mapper/AiQ*Mapper.java
+git commit -m "feat(ai-q): 数据层 — ai_q_scene/session/profile 表 + 迁移 + 实体/Mapper"
+```
+
+---
+
+## 任务 5:Java 服务层(AiQuestionnaireService + AiGateway 扩展)
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/AiQuestionnaireService.java`
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/AiQuestionnaireServiceImpl.java`
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java`
+- 修改:`cfc-backend/src/main/resources/application.yml`
+
+- [ ] **步骤 1:AiGateway 扩展两个方法**
+
+修改 `AiGateway.java`,在类末尾(`generateHealthPlan` 之后)追加:
+
+```java
+    /**
+     * 调用 LangGraph 动态出题(/api/v1/qna/advance)
+     */
+    public Map<String, Object> advanceQuestionnaire(Map<String, Object> scene, List<Map<String, Object>> history) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            ObjectNode body = objectMapper.createObjectNode();
+            body.set("scene", objectMapper.valueToTree(scene));
+            ArrayNode hist = body.putArray("history");
+            history.forEach(hist::addObject);  // 逐项 set
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/qna/advance";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("action", root.has("action") ? root.get("action").asText() : "ask");
+                result.put("question", root.has("question") ? objectMapper.convertValue(root.get("question"), Map.class) : null);
+                result.put("reason", root.has("reason") ? root.get("reason").asText() : "");
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway advanceQuestionnaire 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+
+    /**
+     * 调用 LangGraph 生成画像(/api/v1/qna/profile,独立超时 90s)
+     */
+    public Map<String, Object> generateProfile(Map<String, Object> scene, List<Map<String, Object>> history) {
+        if (!enabled || isCircuitOpen()) return null;
+        try {
+            restTemplate.getRequestFactory();  // no-op 保持连接复用
+            ObjectNode body = objectMapper.createObjectNode();
+            body.set("scene", objectMapper.valueToTree(scene));
+            ArrayNode hist = body.putArray("history");
+            history.forEach(hist::addObject);
+            HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
+            String url = baseUrl + "/api/v1/qna/profile";
+            ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
+            if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
+                JsonNode root = objectMapper.readTree(response.getBody());
+                Map<String, Object> result = new LinkedHashMap<>();
+                result.put("profile", root.has("profile") ? objectMapper.convertValue(root.get("profile"), Map.class) : null);
+                result.put("kb_used", root.has("kb_used") ? root.get("kb_used").asBoolean() : false);
+                consecutiveFailures.set(0);
+                return result;
+            }
+            return null;
+        } catch (Exception e) {
+            log.warn("AiGateway generateProfile 调用失败: {}", e.getMessage());
+            recordFailure();
+            return null;
+        }
+    }
+```
+
+> 注:超时依赖 `RestTemplate` 的 `SimpleClientHttpRequestFactory`(`setConnectTimeout/setReadTimeout`)——修改 `AiGateway` 的 `restTemplate` 初始化时对 `generateProfile` 使用独立的 90s readTimeout 客户端:
+
+```java
+    private final RestTemplate profileRestTemplate = new RestTemplate() {{
+        SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory();
+        f.setConnectTimeout(5000);
+        f.setReadTimeout(90000);
+        setRequestFactory(f);
+    }};
+```
+
+并将 `generateProfile` 内 `restTemplate` 替换为 `profileRestTemplate`。
+
+- [ ] **步骤 2:application.yml 增加配置**
+
+```yaml
+langgraph:
+  base-url: ${LANGGRAPH_BASE_URL:http://localhost:9000}
+  profile-timeout-ms: 90000   # 画像生成推理可达 30-60s
+```
+
+- [ ] **步骤 3:创建 AiQuestionnaireService 接口**
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.AiQProfile;
+import com.etotem.cfc.entity.AiQScene;
+import com.etotem.cfc.entity.AiQSession;
+import java.util.List;
+import java.util.Map;
+
+public interface AiQuestionnaireService {
+    // 场景管理(admin)
+    AiQScene saveScene(AiQScene scene, Long adminId);
+    List<AiQScene> listScenes(Boolean enabledOnly);
+    void deleteScene(Long id, Long adminId);
+    // 问卷会话
+    Map<String, Object> start(Long userId, Long sceneId, Long memberId);
+    Map<String, Object> answer(Long userId, Long sessionId, String answer);
+    AiQProfile finish(Long userId, Long sessionId);
+    Map<String, Object> getProfileDetail(Long userId, Long sessionId);
+    List<AiQSession> getHistory(Long userId, Long memberId, Long sceneId);
+    void abort(Long userId, Long sessionId);
+}
+```
+
+- [ ] **步骤 4:实现 AiQuestionnaireServiceImpl(核心会话编排)**
+
+创建 `impl/AiQuestionnaireServiceImpl.java`(关键逻辑,完整实现):
+
+```java
+package com.etotem.cfc.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import com.etotem.cfc.service.AiGateway;
+import com.etotem.cfc.service.AiQuestionnaireService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service("aiQuestionnaireService")
+public class AiQuestionnaireServiceImpl implements AiQuestionnaireService {
+
+    private static final Logger log = LoggerFactory.getLogger(AiQuestionnaireServiceImpl.class);
+
+    @Resource private AiQSceneMapper aiQSceneMapper;
+    @Resource private AiQSessionMapper aiQSessionMapper;
+    @Resource private AiQProfileMapper aiQProfileMapper;
+    @Resource private FamilyMemberMapper familyMemberMapper;
+    @Resource private AiGateway aiGateway;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    // ── 场景管理 ──
+
+    @Override
+    public AiQScene saveScene(AiQScene scene, Long adminId) {
+        scene.setUpdatedAt(new Date());
+        if (scene.getId() != null) {
+            aiQSceneMapper.updateById(scene);
+        } else {
+            scene.setCreatedAt(new Date());
+            if (scene.getEnabled() == null) scene.setEnabled(1);
+            if (scene.getMaxQuestions() == null) scene.setMaxQuestions(12);
+            aiQSceneMapper.insert(scene);
+        }
+        return scene;
+    }
+
+    @Override
+    public List<AiQScene> listScenes(Boolean enabledOnly) {
+        List<AiQScene> all = aiQSceneMapper.selectList(null);
+        if (enabledOnly == null || !enabledOnly) return all;
+        List<AiQScene> result = new ArrayList<>();
+        for (AiQScene s : all) {
+            if (s.getEnabled() != null && s.getEnabled() == 1) result.add(s);
+        }
+        return result;
+    }
+
+    @Override
+    public void deleteScene(Long id, Long adminId) {
+        Long cnt = aiQSessionMapper.selectCount(new LambdaQueryWrapper<AiQSession>()
+                .eq(AiQSession::getSceneId, id));
+        if (cnt != null && cnt > 0) {
+            throw new RuntimeException("该场景已有问卷记录,请改用禁用");
+        }
+        aiQSceneMapper.deleteById(id);
+    }
+
+    // ── 会话流程 ──
+
+    private AiQScene requireScene(Long sceneId) {
+        AiQScene scene = aiQSceneMapper.selectById(sceneId);
+        if (scene == null) throw new RuntimeException("场景不存在");
+        if (scene.getEnabled() == null || scene.getEnabled() != 1) throw new RuntimeException("场景未启用");
+        return scene;
+    }
+
+    private FamilyMember requireMember(Long userId, Long memberId) {
+        FamilyMember member = familyMemberMapper.selectById(memberId);
+        if (member == null) throw new RuntimeException("家庭成员不存在");
+        return member;
+    }
+
+    private Map<String, Object> toSceneMap(AiQScene s) {
+        Map<String, Object> m = new HashMap<>();
+        m.put("scene_key", s.getSceneKey());
+        m.put("scene_name", s.getSceneName());
+        m.put("opening_prompt", s.getOpeningPrompt());
+        try {
+            m.put("dimensions_json", objectMapper.readValue(
+                    s.getDimensionsJson() == null ? "{}" : s.getDimensionsJson(), Map.class));
+        } catch (Exception e) {
+            m.put("dimensions_json", new HashMap<>());
+        }
+        m.put("kb_scope", Arrays.asList(
+                s.getKbScope() == null ? "microbiome" : s.getKbScope().split(",")));
+        m.put("max_questions", s.getMaxQuestions() == null ? 12 : s.getMaxQuestions());
+        m.put("system_prompt", s.getSystemPrompt());
+        return m;
+    }
+
+    private List<Map<String, Object>> parseHistory(AiQSession session) {
+        List<Map<String, Object>> history = new ArrayList<>();
+        try {
+            if (session.getHistoryJson() != null && !session.getHistoryJson().isEmpty()) {
+                history = objectMapper.readValue(session.getHistoryJson(),
+                        objectMapper.getTypeFactory().constructCollectionType(List.class, Map.class));
+            }
+        } catch (Exception e) {
+            log.warn("解析会话历史失败: sessionId={}", session.getId());
+        }
+        return history;
+    }
+
+    private Map<String, Object> parseQuestion(String json) {
+        try {
+            return json == null ? null : objectMapper.readValue(json, Map.class);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    @Override
+    public Map<String, Object> start(Long userId, Long sceneId, Long memberId) {
+        AiQScene scene = requireScene(sceneId);
+        FamilyMember member = requireMember(userId, memberId);
+
+        AiQSession session = new AiQSession();
+        session.setSceneId(sceneId);
+        session.setUserId(userId);
+        session.setMemberId(memberId);
+        session.setFamilyId(member.getFamilyId());
+        session.setStatus("running");
+        session.setHistoryJson("[]");
+        session.setQuestionCount(0);
+        session.setCreatedAt(new Date());
+        aiQSessionMapper.insert(session);
+
+        // 调 LangGraph 拿首题
+        List<Map<String, Object>> history = new ArrayList<>();
+        Map<String, Object> resp = aiGateway.advanceQuestionnaire(toSceneMap(scene), history);
+        Map<String, Object> question;
+        if (resp != null && resp.get("question") != null) {
+            question = (Map<String, Object>) resp.get("question");
+        } else {
+            question = fallbackQuestion(0);
+        }
+        session.setCurrentQuestionJson(toJson(question));
+        aiQSessionMapper.updateById(session);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("sessionId", session.getId());
+        result.put("question", question);
+        result.put("answeredCount", 0);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> answer(Long userId, Long sessionId, String answer) {
+        AiQSession session = aiQSessionMapper.selectById(sessionId);
+        if (session == null) throw new RuntimeException("会话不存在");
+        if (!"running".equals(session.getStatus())) throw new RuntimeException("问卷已完成");
+        if (answer == null || answer.trim().isEmpty()) throw new RuntimeException("请先作答");
+
+        AiQScene scene = requireScene(session.getSceneId());
+
+        // 1. 组装 history
+        List<Map<String, Object>> history = parseHistory(session);
+        Map<String, Object> current = parseQuestion(session.getCurrentQuestionJson());
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("question", current == null ? fallbackQuestion(history.size()) : current);
+        item.put("answer", answer.trim());
+        history.add(item);
+        session.setHistoryJson(toJson(history));
+        session.setQuestionCount(history.size());
+
+        // 2. 达上限 → 直接画像
+        int max = scene.getMaxQuestions() == null ? 12 : scene.getMaxQuestions();
+        if (history.size() >= max) {
+            AiQProfile profile = doGenerateProfile(session, scene, history);
+            session.setStatus("finished");
+            session.setFinishedAt(new Date());
+            session.setCurrentQuestionJson(null);
+            aiQSessionMapper.updateById(session);
+            Map<String, Object> result = new LinkedHashMap<>();
+            result.put("action", "finish");
+            result.put("finished", true);
+            result.put("profile", toProfileMap(profile));
+            return result;
+        }
+
+        // 3. 未达上限 → LangGraph 出下一题
+        Map<String, Object> resp = aiGateway.advanceQuestionnaire(toSceneMap(scene), history);
+        Map<String, Object> question;
+        if (resp != null && resp.get("question") != null) {
+            question = (Map<String, Object>) resp.get("question");
+        } else {
+            question = fallbackQuestion(history.size());
+        }
+        session.setCurrentQuestionJson(toJson(question));
+        aiQSessionMapper.updateById(session);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("action", "ask");
+        result.put("finished", false);
+        result.put("question", question);
+        result.put("answeredCount", history.size());
+        return result;
+    }
+
+    @Override
+    public AiQProfile finish(Long userId, Long sessionId) {
+        AiQSession session = aiQSessionMapper.selectById(sessionId);
+        if (session == null) throw new RuntimeException("会话不存在");
+        if ("finished".equals(session.getStatus())) {
+            return aiQProfileMapper.selectOne(new LambdaQueryWrapper<AiQProfile>()
+                    .eq(AiQProfile::getSessionId, sessionId));
+        }
+        AiQScene scene = requireScene(session.getSceneId());
+        List<Map<String, Object>> history = parseHistory(session);
+        if (history.isEmpty()) throw new RuntimeException("尚无回答,无法生成画像");
+
+        AiQProfile profile = doGenerateProfile(session, scene, history);
+        session.setStatus("finished");
+        session.setFinishedAt(new Date());
+        session.setCurrentQuestionJson(null);
+        aiQSessionMapper.updateById(session);
+        return profile;
+    }
+
+    private AiQProfile doGenerateProfile(AiQSession session, AiQScene scene, List<Map<String, Object>> history) {
+        Map<String, Object> resp = aiGateway.generateProfile(toSceneMap(scene), history);
+        AiQProfile profile = new AiQProfile();
+        profile.setSessionId(session.getId());
+        profile.setSceneId(session.getSceneId());
+        profile.setMemberId(session.getMemberId());
+        if (resp != null && resp.get("profile") != null) {
+            Map<String, Object> p = (Map<String, Object>) resp.get("profile");
+            Object up = p.get("user_profile");
+            Object np = p.get("need_profile");
+            profile.setUserProfileJson(toJson(up == null ? Collections.emptyList() : up));
+            profile.setNeedProfileJson(toJson(np == null ? Collections.emptyList() : np));
+            profile.setKbUsed(Boolean.TRUE.equals(resp.get("kb_used")) ? 1 : 0);
+            profile.setRawResult(toJson(p));
+        } else {
+            throw new RuntimeException("画像生成失败,请稍后重试");
+        }
+        profile.setCreatedAt(new Date());
+        aiQProfileMapper.insert(profile);
+        return aiQProfileMapper.selectById(profile.getId());
+    }
+
+    @Override
+    public Map<String, Object> getProfileDetail(Long userId, Long sessionId) {
+        AiQSession session = aiQSessionMapper.selectById(sessionId);
+        if (session == null) throw new RuntimeException("会话不存在");
+        AiQProfile profile = aiQProfileMapper.selectOne(new LambdaQueryWrapper<AiQProfile>()
+                .eq(AiQProfile::getSessionId, sessionId));
+        if (profile == null) throw new RuntimeException("画像不存在");
+        return toProfileMap(profile);
+    }
+
+    @Override
+    public List<AiQSession> getHistory(Long userId, Long memberId, Long sceneId) {
+        LambdaQueryWrapper<AiQSession> qw = new LambdaQueryWrapper<AiQSession>()
+                .eq(AiQSession::getMemberId, memberId)
+                .orderByDesc(AiQSession::getUpdatedAt);
+        if (sceneId != null) qw.eq(AiQSession::getSceneId, sceneId);
+        return aiQSessionMapper.selectList(qw);
+    }
+
+    @Override
+    public void abort(Long userId, Long sessionId) {
+        AiQSession session = aiQSessionMapper.selectById(sessionId);
+        if (session == null) return;
+        if ("running".equals(session.getStatus())) {
+            session.setStatus("aborted");
+            aiQSessionMapper.updateById(session);
+        }
+    }
+
+    // ── 工具方法 ──
+
+    private Map<String, Object> fallbackQuestion(int index) {
+        Map<String, Object> q = new LinkedHashMap<>();
+        q.put("id", "fb" + (index + 1));
+        q.put("type", "text");
+        q.put("text", "请简单描述您最近一周的饮食和作息情况。");
+        return q;
+    }
+
+    private String toJson(Object o) {
+        try {
+            return objectMapper.writeValueAsString(o);
+        } catch (Exception e) {
+            return "{}";
+        }
+    }
+
+    private Map<String, Object> toProfileMap(AiQProfile p) {
+        Map<String, Object> m = new LinkedHashMap<>();
+        m.put("id", p.getId());
+        m.put("sessionId", p.getSessionId());
+        m.put("memberId", p.getMemberId());
+        try {
+            m.put("userProfile", p.getUserProfileJson() == null ? Collections.emptyList()
+                    : objectMapper.readValue(p.getUserProfileJson(), List.class));
+            m.put("needProfile", p.getNeedProfileJson() == null ? Collections.emptyList()
+                    : objectMapper.readValue(p.getNeedProfileJson(), List.class));
+        } catch (Exception e) {
+            m.put("userProfile", Collections.emptyList());
+            m.put("needProfile", Collections.emptyList());
+        }
+        m.put("kbUsed", p.getKbUsed());
+        m.put("createdAt", p.getCreatedAt());
+        return m;
+    }
+}
+```
+
+> 说明:`history.forEach(hist::addObject)` 在 AiGateway 中不可用(`ArrayNode` 无 `addObject` 无参方法),改用 `hist.add(objectMapper.valueToTree(item))`——编写 AiGateway 步骤时以对象节点添加。并发防护:`answer` 先 `selectById` 后校验 `status`,更新时最后写(UPDATE 整行),极端并发下后到者读到的 status 可能已是 finished,返回"问卷已完成"。
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd /app/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -8
+```
+
+预期:`BUILD SUCCESS`。若 `hist::addObject` 编译失败,按上方说明改为 `hist.add(objectMapper.valueToTree(item))`。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiQuestionnaireService.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/impl/AiQuestionnaireServiceImpl.java \
+        cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java \
+        cfc-backend/src/main/resources/application.yml
+git commit -m "feat(ai-q): 服务层 — 场景 CRUD + start/answer/finish 会话编排 + AiGateway 扩展"
+```
+
+---
+
+## 任务 6:Java 控制器 + 编译/路由验证
+
+**文件:**
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/AiQuestionnaireController.java`
+
+- [ ] **步骤 1:创建控制器**
+
+```java
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.AiQProfile;
+import com.etotem.cfc.entity.AiQScene;
+import com.etotem.cfc.entity.AiQSession;
+import com.etotem.cfc.service.AiQuestionnaireService;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * AI 动态问卷引擎 — 会话/画像/场景管理
+ */
+@RestController
+@RequestMapping("/api/ai-questionnaire")
+public class AiQuestionnaireController {
+
+    @Resource
+    private AiQuestionnaireService aiQuestionnaireService;
+
+    private Long userId(HttpServletRequest request) {
+        return Long.valueOf(String.valueOf(request.getAttribute("userId")));
+    }
+
+    // ── 场景管理(admin)──
+
+    @PostMapping("/scene/list")
+    public Result<List<AiQScene>> sceneList(@RequestBody(required = false) Map<String, Object> params) {
+        boolean enabledOnly = params != null && params.get("enabledOnly") != null && Boolean.TRUE.equals(params.get("enabledOnly") == Boolean.TRUE ? Boolean.TRUE : Boolean.FALSE);
+        return Result.success(aiQuestionnaireService.listScenes(enabledOnly));
+    }
+
+    @PostMapping("/scene/save")
+    public Result<AiQScene> sceneSave(@RequestBody AiQScene scene, HttpServletRequest request) {
+        if ("admin".equals(request.getAttribute("role"))) {
+            return Result.success(aiQuestionnaireService.saveScene(scene, userId(request)));
+        }
+        return Result.error("仅管理员可操作");
+    }
+
+    @PostMapping("/scene/delete")
+    public Result<Void> sceneDelete(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        if (!"admin".equals(request.getAttribute("role"))) return Result.error("仅管理员可操作");
+        Object id = params.get("id");
+        if (id == null) return Result.error("缺少 id");
+        try {
+            aiQuestionnaireService.deleteScene(Long.valueOf(id.toString()), userId(request));
+            return Result.success(null);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    // ── 会话 ──
+
+    @PostMapping("/start")
+    public Result<Map<String, Object>> start(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        Long sceneId = params.get("sceneId") == null ? null : Long.valueOf(params.get("sceneId").toString());
+        Long memberId = params.get("memberId") == null ? null : Long.valueOf(params.get("memberId").toString());
+        if (sceneId == null || memberId == null) return Result.error("缺少 sceneId/memberId");
+        try {
+            return Result.success(aiQuestionnaireService.start(userId(request), sceneId, memberId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/answer")
+    public Result<Map<String, Object>> answer(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        Long sessionId = params.get("sessionId") == null ? null : Long.valueOf(params.get("sessionId").toString());
+        Object answer = params.get("answer");
+        if (sessionId == null || answer == null) return Result.error("缺少 sessionId/answer");
+        try {
+            return Result.success(aiQuestionnaireService.answer(userId(request), sessionId, String.valueOf(answer)));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/finish")
+    public Result<Map<String, Object>> finish(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        Long sessionId = params.get("sessionId") == null ? null : Long.valueOf(params.get("sessionId").toString());
+        if (sessionId == null) return Result.error("缺少 sessionId");
+        try {
+            AiQProfile profile = aiQuestionnaireService.finish(userId(request), sessionId);
+            return Result.success(convertProfile(profile));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/profile/detail")
+    public Result<Map<String, Object>> profileDetail(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        Long sessionId = params.get("sessionId") == null ? null : Long.valueOf(params.get("sessionId").toString());
+        if (sessionId == null) return Result.error("缺少 sessionId");
+        try {
+            return Result.success(aiQuestionnaireService.getProfileDetail(userId(request), sessionId));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/history")
+    public Result<List<AiQSession>> history(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        Long memberId = params.get("memberId") == null ? null : Long.valueOf(params.get("memberId").toString());
+        Long sceneId = params.get("sceneId") == null ? null : Long.valueOf(params.get("sceneId").toString());
+        return Result.success(aiQuestionnaireService.getHistory(userId(request), memberId, sceneId));
+    }
+
+    @PostMapping("/abort")
+    public Result<Void> abort(@RequestBody Map<String, Object> params, HttpServletRequest request) {
+        Long sessionId = params.get("sessionId") == null ? null : Long.valueOf(params.get("sessionId").toString());
+        if (sessionId == null) return Result.error("缺少 sessionId");
+        aiQuestionnaireService.abort(userId(request), sessionId);
+        return Result.success(null);
+    }
+
+    private Map<String, Object> convertProfile(AiQProfile profile) {
+        // 复用 Service 的 toProfileMap —— 此处简化为返回实体字段映射;
+        // 若 Service 未暴露转换器,Controller 直接组装:
+        java.util.Map<String, Object> m = new java.util.LinkedHashMap<>();
+        m.put("id", profile.getId());
+        m.put("sessionId", profile.getSessionId());
+        m.put("memberId", profile.getMemberId());
+        m.put("userProfileJson", profile.getUserProfileJson());
+        m.put("needProfileJson", profile.getNeedProfileJson());
+        m.put("kbUsed", profile.getKbUsed());
+        m.put("createdAt", profile.getCreatedAt());
+        return m;
+    }
+}
+```
+
+> 注意:`sceneList` 的 enabledOnly 解析写法冗余,简化为 `params != null && Boolean.TRUE.equals(params.get("enabledOnly"))`。`userId` 取值依赖 `JwtInterceptor` 已放置 `userId`/`role` 请求属性(与现有控制器一致,参照 `SurveyController` 的取值写法)。
+
+- [ ] **步骤 2:编译 + 路由重复检查**
+
+```bash
+cd /app/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
+grep -rn '@PostMapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@PostMapping\("\K[^"]*' | sort -u | grep -c ai-questionnaire
+```
+
+预期:`BUILD SUCCESS`;`grep` 输出统计 `/api/ai-questionnaire` 路由 8 个且无冲突(对照现有扫描全部路由确认 `ai-questionnaire` 前缀唯一)。
+
+- [ ] **步骤 3:Bean 名冲突检查**
+
+运行:`find src/main/java -name "AiQuestionnaireController.java" -o -name "AiQuestionnaireService*.java" | wc -l`
+预期:3(Controller、Service、ServiceImpl 各 1),确认无同名类。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/AiQuestionnaireController.java
+git commit -m "feat(ai-q): 控制器 — /api/ai-questionnaire/* 会话与场景端点"
+```
+
+---
+
+## 任务 7:cfc-web 管理端场景配置页
+
+**文件:**
+- 创建:`cfc-web/src/api/aiQuestionnaire.js`
+- 创建:`cfc-web/src/views/admin/AiQuestionnaireScenes.vue`
+- 修改:`cfc-web/src/router/index.js`
+
+- [ ] **步骤 1:创建 API 封装**
+
+```javascript
+import request from '@/utils/request'
+
+export function listScenes(params) {
+  return request({ url: '/api/ai-questionnaire/scene/list', method: 'post', data: params })
+}
+export function saveScene(scene) {
+  return request({ url: '/api/ai-questionnaire/scene/save', method: 'post', data: scene })
+}
+export function deleteScene(id) {
+  return request({ url: '/api/ai-questionnaire/scene/delete', method: 'post', data: { id } })
+}
+```
+
+- [ ] **步骤 2:创建场景管理页**
+
+创建 `src/views/admin/AiQuestionnaireScenes.vue`(Element UI 表格 + 编辑对话框,字段:scene_key/scene_name/description/opening_prompt/dimensions_json/kb_scope/max_questions/enabled;参照现有 `SurveyTemplates.vue` 的表格+弹窗模式):
+
+```vue
+<template>
+  <div class="ai-q-scenes">
+    <div class="toolbar">
+      <el-button type="primary" @click="openEdit()">新增场景</el-button>
+    </div>
+    <el-table :data="scenes" border stripe>
+      <el-table-column prop="sceneKey" label="场景标识" width="140" />
+      <el-table-column prop="sceneName" label="场景名称" width="160" />
+      <el-table-column prop="description" label="描述" show-overflow-tooltip />
+      <el-table-column prop="kbScope" label="知识库范围" width="160" />
+      <el-table-column prop="maxQuestions" label="题数上限" width="90" />
+      <el-table-column label="启用" width="80">
+        <template slot-scope="{ row }">
+          <el-tag :type="row.enabled === 1 ? 'success' : 'info'">{{ row.enabled === 1 ? '是' : '否' }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="180">
+        <template slot-scope="{ row }">
+          <el-button size="mini" @click="openEdit(row)">编辑</el-button>
+          <el-button size="mini" type="danger" @click="onDelete(row)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog :title="form.id ? '编辑场景' : '新增场景'" :visible.sync="dialogVisible" width="640px">
+      <el-form :model="form" label-width="110px">
+        <el-form-item label="场景标识" required>
+          <el-input v-model="form.sceneKey" placeholder="如 microbiome" :disabled="!!form.id" />
+        </el-form-item>
+        <el-form-item label="场景名称" required>
+          <el-input v-model="form.sceneName" placeholder="如 菌群健康评估" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input v-model="form.description" type="textarea" :rows="2" />
+        </el-form-item>
+        <el-form-item label="开场引导">
+          <el-input v-model="form.openingPrompt" type="textarea" :rows="3"
+            placeholder="AI 出第一题前的引导语" />
+        </el-form-item>
+        <el-form-item label="画像维度定义">
+          <el-input v-model="form.dimensionsJson" type="textarea" :rows="5"
+            placeholder='{"user":["肠道菌群状态"],"need":["营养需求"]}' />
+        </el-form-item>
+        <el-form-item label="知识库范围">
+          <el-select v-model="kbScopeArr" multiple placeholder="选择知识库源">
+            <el-option label="菌群知识库" value="microbiome" />
+            <el-option label="统一知识库" value="dan_knowledge" />
+            <el-option label="文章" value="article" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="题数上限">
+          <el-input-number v-model="form.maxQuestions" :min="5" :max="30" />
+        </el-form-item>
+        <el-form-item label="系统提示词">
+          <el-input v-model="form.systemPrompt" type="textarea" :rows="3" />
+        </el-form-item>
+        <el-form-item label="启用">
+          <el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="onSave">保存</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { listScenes, saveScene, deleteScene } from '@/api/aiQuestionnaire'
+
+export default {
+  name: 'AiQuestionnaireScenes',
+  data() {
+    return {
+      scenes: [],
+      dialogVisible: false,
+      kbScopeArr: [],
+      form: { id: null, sceneKey: '', sceneName: '', description: '', openingPrompt: '',
+        dimensionsJson: '', kbScope: '', maxQuestions: 12, systemPrompt: '', enabled: 1 }
+    }
+  },
+  created() { this.load() },
+  methods: {
+    async load() {
+      const res = await listScenes({})
+      if (res.code === 200) {
+        this.scenes = res.data || []
+      } else {
+        this.$message.error(res.message || '加载失败')
+      }
+    },
+    openEdit(row) {
+      this.form = row ? Object.assign({}, this.form, row) : { id: null, sceneKey: '', sceneName: '',
+        description: '', openingPrompt: '', dimensionsJson: '', kbScope: '', maxQuestions: 12,
+        systemPrompt: '', enabled: 1 }
+      this.kbScopeArr = this.form.kbScope ? this.form.kbScope.split(',') : []
+      this.dialogVisible = true
+    },
+    async onSave() {
+      if (!this.form.sceneKey || !this.form.sceneName) {
+        this.$message.warning('请填写场景标识与名称'); return
+      }
+      this.form.kbScope = (this.kbScopeArr || []).join(',')
+      const res = await saveScene(this.form)
+      if (res.code === 200) {
+        this.$message.success('保存成功')
+        this.dialogVisible = false
+        this.load()
+      } else {
+        this.$message.error(res.message || '保存失败')
+      }
+    },
+    async onDelete(row) {
+      this.$confirm('删除后不可恢复(已有问卷记录将被拒绝),确认删除?', '提示', { type: 'warning' })
+        .then(async () => {
+          const res = await deleteScene(row.id)
+          if (res.code === 200) { this.$message.success('已删除'); this.load() }
+          else { this.$message.error(res.message || '删除失败') }
+        }).catch(() => {})
+    }
+  }
+}
+</script>
+
+<style scoped>
+.ai-q-scenes { padding: 16px; }
+.toolbar { margin-bottom: 16px; }
+</style>
+```
+
+- [ ] **步骤 3:注册路由**
+
+在 `src/router/index.js` 的 admin 路由表中追加(参照现有 admin 路由 meta 权限模式):
+
+```javascript
+{
+  path: '/admin/ai-questionnaire/scenes',
+  name: 'AiQuestionnaireScenes',
+  component: () => import('@/views/admin/AiQuestionnaireScenes.vue'),
+  meta: { title: 'AI 问卷场景配置', roles: ['admin'] }
+}
+```
+
+- [ ] **步骤 4:语法校验**
+
+```bash
+cd /app/cfc/cfc-web && node --check src/api/aiQuestionnaire.js 2>&1
+# .vue 文件:提取 script 块校验(参照现有做法)
+node -e "
+const s = require('fs').readFileSync('src/views/admin/AiQuestionnaireScenes.vue','utf8');
+const m = s.match(/<script>([\s\S]*?)<\/script>/);
+require('fs').writeFileSync('/tmp/aiq-scenes.js', m[1]);
+" && node --check /tmp/aiq-scenes.js 2>&1
+```
+
+预期:两处均无语法错误输出。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-web/src/api/aiQuestionnaire.js cfc-web/src/views/admin/AiQuestionnaireScenes.vue cfc-web/src/router/index.js
+git commit -m "feat(admin): AI 问卷场景配置管理页"
+```
+
+---
+
+## 任务 8:小程序逐题对话页 + 画像展示页
+
+**文件:**
+- 修改:`cfc-frontend/utils/api.js`
+- 创建:`cfc-frontend/pages/health/ai-questionnaire.vue`、`pages/health/ai-questionnaire-result.vue`
+- 修改:`cfc-frontend/pages.json`、`cfc-frontend/pages/health-main/index.vue`
+
+- [ ] **步骤 1:utils/api.js 新增接口**
+
+在 `utils/api.js` 末尾追加:
+
+```javascript
+// ── AI 动态问卷 ──
+export const aiQSceneList = (data) => request('/api/ai-questionnaire/scene/list', 'POST', data)
+export const aiQStart = (data) => request('/api/ai-questionnaire/start', 'POST', data)
+export const aiQAnswer = (data) => request('/api/ai-questionnaire/answer', 'POST', data)
+export const aiQFinish = (data) => request('/api/ai-questionnaire/finish', 'POST', data)
+export const aiQHistory = (data) => request('/api/ai-questionnaire/history', 'POST', data)
+export const aiQProfileDetail = (data) => request('/api/ai-questionnaire/profile/detail', 'POST', data)
+```
+
+- [ ] **步骤 2:创建逐题对话页**
+
+创建 `pages/health/ai-questionnaire.vue`(要点:场景选择 → 成员选择 → start → 逐题作答 → answer → 下一题;进度 + 主动结束;遵守小程序限制:Options API、无 `?.`、无 `:key` 表达式、日期/数字处理):
+
+```vue
+<template>
+  <view class="container">
+    <!-- 场景选择 -->
+    <view v-if="!sceneId" class="pick-wrap">
+      <view class="pick-title">选择问卷场景</view>
+      <view class="scene-item" v-for="(s, i) in scenes" :key="'scene' + i" @click="chooseScene(s)">
+        <text class="scene-name">{{ s.sceneName }}</text>
+        <text class="scene-desc">{{ s.description }}</text>
+      </view>
+    </view>
+
+    <!-- 成员选择 -->
+    <view v-else-if="!memberId" class="pick-wrap">
+      <view class="pick-title">选择填写人</view>
+      <view class="scene-item" v-for="(m, i) in members" :key="'mem' + i" @click="chooseMember(m)">
+        <text class="scene-name">{{ m.name }}</text>
+      </view>
+    </view>
+
+    <!-- 答题 -->
+    <view v-else class="qa-wrap">
+      <view class="qa-header">
+        <text class="qa-progress">已答 {{ answeredCount }} / {{ maxQuestions || '-' }} 题</text>
+        <text class="qa-finish" @click="onFinishEarly">结束并生成画像</text>
+      </view>
+
+      <view v-if="loading" class="qa-loading">
+        <text class="qa-loading-text">AI 正在思考下一题...</text>
+      </view>
+
+      <view v-else-if="currentQuestion" class="qa-card">
+        <text class="qa-text">{{ currentQuestion.text }}</text>
+
+        <!-- 单选 -->
+        <view v-if="currentQuestion.type === 'single' || currentQuestion.type === 'multi'" class="qa-options">
+          <view class="qa-option" v-for="(opt, oi) in options" :key="'opt' + oi"
+                :class="{ selected: isSelected(opt.id) }" @click="toggleOption(opt)">
+            <text class="qa-option-label">{{ opt.label }}</text>
+          </view>
+        </view>
+
+        <!-- 量表 -->
+        <view v-if="currentQuestion.type === 'scale' && currentQuestion.scale" class="qa-scale">
+          <text class="qa-scale-label">{{ currentQuestion.scale.minLabel }}</text>
+          <slider :min="currentQuestion.scale.min" :max="currentQuestion.scale.max" :value="scaleValue"
+                  activeColor="#F97316" @change="onScaleChange" class="qa-slider" />
+          <text class="qa-scale-label">{{ currentQuestion.scale.maxLabel }}</text>
+        </view>
+
+        <!-- 文本 -->
+        <view v-if="currentQuestion.type === 'text'" class="qa-textarea-wrap">
+          <textarea v-model="textAnswer" class="qa-textarea" placeholder="请输入您的回答" />
+        </view>
+      </view>
+
+      <view v-if="!loading && currentQuestion" class="qa-submit">
+        <button class="btn-next" :disabled="!canSubmit" @click="onAnswer">回答并继续</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { aiQSceneList, aiQStart, aiQAnswer, aiQFinish } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      scenes: [],
+      members: [],
+      sceneId: null,
+      sceneName: '',
+      memberId: null,
+      sessionId: null,
+      currentQuestion: null,
+      options: [],
+      selected: {},
+      scaleValue: 5,
+      textAnswer: '',
+      answeredCount: 0,
+      maxQuestions: 12,
+      loading: false
+    }
+  },
+  computed: {
+    canSubmit() {
+      var q = this.currentQuestion
+      if (!q) return false
+      if (q.type === 'text') return this.textAnswer && this.textAnswer.trim().length > 0
+      if (q.type === 'scale') return true
+      if (q.type === 'multi') {
+        for (var k in this.selected) { if (this.selected[k]) return true }
+        return false
+      }
+      return this.selected[q.id || 'k'] === true
+    }
+  },
+  onLoad() {
+    this.loadScenes()
+    this.loadMembers()
+  },
+  methods: {
+    async loadScenes() {
+      var res = await aiQSceneList({ enabledOnly: true })
+      if (res.code === 200) {
+        this.scenes = (res.data && res.data.items) || res.data || []
+      } else {
+        uni.showToast({ title: '加载场景失败', icon: 'none' })
+      }
+    },
+    loadMembers() {
+      // 复用家庭成员选择(参照 relationship-questionnaire / family-members 的成员加载方式)
+      var members = uni.getStorageSync('familyMembers') || []
+      this.members = members
+    },
+    chooseScene(s) {
+      this.sceneId = s.id
+      this.sceneName = s.sceneName
+      this.maxQuestions = s.maxQuestions || 12
+    },
+    chooseMember(m) {
+      this.memberId = m.id
+      this.startQuestionnaire()
+    },
+    async startQuestionnaire() {
+      this.loading = true
+      try {
+        var res = await aiQStart({ sceneId: this.sceneId, memberId: this.memberId })
+        if (res.code === 200 && res.data) {
+          this.sessionId = res.data.sessionId
+          this.currentQuestion = res.data.question
+          this.answeredCount = res.data.answeredCount || 0
+          this.resetAnswer()
+        } else {
+          uni.showToast({ title: res.message || '开始失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '开始失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    isSelected(optId) {
+      return this.selected[optId] === true
+    },
+    toggleOption(opt) {
+      if (this.currentQuestion.type === 'single') {
+        var single = {}
+        single[opt.id] = true
+        this.selected = single
+      } else {
+        this.selected[opt.id] = !this.selected[opt.id]
+      }
+    },
+    onScaleChange(e) {
+      this.scaleValue = e.detail.value
+    },
+    resetAnswer() {
+      this.selected = {}
+      this.textAnswer = ''
+      this.scaleValue = 5
+      if (this.currentQuestion && this.currentQuestion.options) {
+        this.options = this.currentQuestion.options
+      } else {
+        this.options = []
+      }
+    },
+    buildAnswer() {
+      var q = this.currentQuestion
+      if (!q) return ''
+      if (q.type === 'text') return this.textAnswer.trim()
+      if (q.type === 'scale') return String(this.scaleValue)
+      var labels = []
+      if (q.type === 'single') {
+        for (var k in this.selected) {
+          if (this.selected[k]) {
+            var opt = this.options.find(function (o) { return o.id === k })
+            labels.push(opt ? opt.label : k)
+          }
+        }
+        return labels[0] || ''
+      }
+      var multiLabels = []
+      for (var k2 in this.selected) {
+        if (this.selected[k2]) {
+          var opt2 = this.options.find(function (o) { return o.id === k2 })
+          multiLabels.push(opt2 ? opt2.label : k2)
+        }
+      }
+      return multiLabels.join(',')
+    },
+    async onAnswer() {
+      var answer = this.buildAnswer()
+      if (!answer) return
+      this.loading = true
+      try {
+        var res = await aiQAnswer({ sessionId: this.sessionId, answer: answer })
+        if (res.code === 200 && res.data) {
+          if (res.data.action === 'finish' || res.data.finished) {
+            this.goResult(res.data.profile || {})
+          } else {
+            this.currentQuestion = res.data.question
+            this.answeredCount = res.data.answeredCount || 0
+            this.resetAnswer()
+          }
+        } else {
+          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常,请重试', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    onFinishEarly() {
+      var self = this
+      uni.showModal({
+        title: '结束问卷',
+        content: '将基于当前回答生成画像,确定结束吗?',
+        success: function (r) {
+          if (r.confirm) self.finishQuestionnaire()
+        }
+      })
+    },
+    async finishQuestionnaire() {
+      this.loading = true
+      try {
+        var res = await aiQFinish({ sessionId: this.sessionId })
+        if (res.code === 200 && res.data) {
+          this.goResult(res.data)
+        } else {
+          uni.showToast({ title: res.message || '生成失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '网络异常,请重试', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    goResult(profile) {
+      uni.redirectTo({
+        url: '/pages/health/ai-questionnaire-result?profile=' + encodeURIComponent(JSON.stringify(profile)) +
+             '&sessionId=' + (this.sessionId || '')
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { min-height: 100vh; background: #f5f5f5; padding: 24rpx; }
+.pick-wrap { padding: 40rpx 0; }
+.pick-title { font-size: 34rpx; font-weight: bold; color: #333; margin-bottom: 24rpx; }
+.scene-item { background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx; }
+.scene-name { font-size: 30rpx; color: #333; font-weight: bold; display: block; }
+.scene-desc { font-size: 26rpx; color: #999; margin-top: 8rpx; display: block; }
+.qa-header { display: flex; justify-content: space-between; align-items: center; padding: 10rpx 4rpx 20rpx; }
+.qa-progress { font-size: 26rpx; color: #999; }
+.qa-finish { font-size: 26rpx; color: #F97316; }
+.qa-loading { text-align: center; padding: 120rpx 0; }
+.qa-loading-text { font-size: 28rpx; color: #999; }
+.qa-card { background: #fff; border-radius: 16rpx; padding: 32rpx; }
+.qa-text { font-size: 32rpx; color: #333; line-height: 1.6; display: block; }
+.qa-option { padding: 22rpx; border: 2rpx solid #e0e0e0; border-radius: 12rpx; margin-top: 20rpx;
+  background: #fafafa; }
+.qa-option.selected { border-color: #F97316; background: #FFF7ED; }
+.qa-option-label { font-size: 28rpx; color: #333; }
+.qa-scale { display: flex; align-items: center; margin-top: 30rpx; }
+.qa-scale-label { font-size: 24rpx; color: #666; width: 80rpx; }
+.qa-slider { flex: 1; margin: 0 12rpx; }
+.qa-textarea-wrap { margin-top: 24rpx; }
+.qa-textarea { width: 100%; height: 200rpx; background: #fafafa; border: 2rpx solid #e0e0e0;
+  border-radius: 12rpx; padding: 16rpx; font-size: 28rpx; box-sizing: border-box; }
+.qa-submit { margin-top: 30rpx; }
+.btn-next { background: #F97316; color: #fff; border-radius: 12rpx; }
+.btn-next[disabled] { background: #ccc; }
+</style>
+```
+
+- [ ] **步骤 3:创建画像展示页**
+
+创建 `pages/health/ai-questionnaire-result.vue`:
+
+```vue
+<template>
+  <view class="container">
+    <view class="result-header">
+      <text class="result-title">AI 问卷画像报告</text>
+    </view>
+
+    <!-- 用户画像 -->
+    <view class="section">
+      <text class="section-title">用户画像</text>
+      <view class="profile-item" v-for="(p, i) in userProfile" :key="'up' + i">
+        <view class="profile-head">
+          <text class="profile-dim">{{ p.dimension }}</text>
+          <text class="profile-score">{{ p.score }} 分</text>
+        </view>
+        <view class="score-bar-bg">
+          <view class="score-bar-fill" :style="'width:' + clamp(p.score) + '%'"></view>
+        </view>
+        <text class="profile-desc">{{ p.description }}</text>
+        <view class="evidence-list" v-if="p.evidence && p.evidence.length > 0">
+          <text class="evidence-item" v-for="(ev, ei) in p.evidence" :key="'ev' + ei">· {{ ev }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 需求画像 -->
+    <view class="section" v-if="needProfile.length > 0">
+      <text class="section-title">需求画像</text>
+      <view class="profile-item" v-for="(n, i) in needProfile" :key="'np' + i">
+        <text class="profile-dim">{{ n.dimension }}</text>
+        <text class="profile-desc">{{ n.description }}</text>
+        <text class="profile-suggest" v-if="n.suggestion">建议:{{ n.suggestion }}</text>
+      </view>
+    </view>
+
+    <view class="footer-btns">
+      <button class="btn-recommend" @click="goRecommend">查看相关推荐</button>
+      <button class="btn-again" @click="goHome">完成</button>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {
+      sessionId: '',
+      userProfile: [],
+      needProfile: []
+    }
+  },
+  onLoad(options) {
+    this.sessionId = options.sessionId || ''
+    if (options.profile) {
+      try {
+        var profile = JSON.parse(decodeURIComponent(options.profile))
+        this.userProfile = (profile.userProfile || profile.user_profile || [])
+        this.needProfile = (profile.needProfile || profile.need_profile || [])
+      } catch (e) {
+        this.userProfile = []
+        this.needProfile = []
+      }
+    }
+  },
+  methods: {
+    clamp(score) {
+      var s = Number(score) || 0
+      if (s < 0) return 0
+      if (s > 100) return 100
+      return s
+    },
+    goRecommend() {
+      // 推荐入口:跳转营养/产品推荐页(按需调整目标页)
+      uni.navigateTo({ url: '/pages/diet/index?source=ai-questionnaire' })
+    },
+    goHome() {
+      uni.switchTab({ url: '/pages/body/index' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { min-height: 100vh; background: #f5f5f5; padding: 24rpx; }
+.result-header { padding: 20rpx 0 30rpx; }
+.result-title { font-size: 38rpx; font-weight: bold; color: #333; }
+.section { margin-bottom: 30rpx; }
+.section-title { font-size: 30rpx; font-weight: bold; color: #333; display: block; margin-bottom: 16rpx; }
+.profile-item { background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 16rpx; }
+.profile-head { display: flex; justify-content: space-between; align-items: center; }
+.profile-dim { font-size: 28rpx; color: #333; font-weight: bold; }
+.profile-score { font-size: 26rpx; color: #F97316; }
+.score-bar-bg { height: 14rpx; background: #f0f0f0; border-radius: 7rpx; margin-top: 14rpx; overflow: hidden; }
+.score-bar-fill { height: 100%; background: #F97316; border-radius: 7rpx; }
+.profile-desc { font-size: 26rpx; color: #666; line-height: 1.6; margin-top: 14rpx; display: block; }
+.profile-suggest { font-size: 26rpx; color: #0EA5E9; margin-top: 10rpx; display: block; }
+.evidence-list { margin-top: 10rpx; }
+.evidence-item { font-size: 24rpx; color: #999; display: block; margin-top: 4rpx; }
+.footer-btns { margin-top: 20rpx; }
+.btn-recommend { background: #F97316; color: #fff; border-radius: 12rpx; }
+.btn-again { background: #fff; color: #666; border-radius: 12rpx; margin-top: 16rpx; }
+</style>
+```
+
+- [ ] **步骤 4:pages.json 注册 + 入口按钮**
+
+`pages.json` 的 `pages/health` 分包 `pages` 数组中追加:
+
+```json
+{
+  "path": "pages/health/ai-questionnaire",
+  "style": { "navigationBarTitleText": "AI 健康问卷" }
+},
+{
+  "path": "pages/health/ai-questionnaire-result",
+  "style": { "navigationBarTitleText": "问卷画像报告" }
+}
+```
+
+> 注意:`pages/health` 分包实际数组位置以 `pages.json` 现有记录为准(追加到 `pages/health` 列表尾部,避免破坏主包/分包结构)。
+
+`pages/health-main/index.vue` 合适位置加入口(参照现有功能卡片按钮写法):
+
+```html
+<view class="ai-q-entry" @click="goAiQuestionnaire">
+  <text class="ai-q-entry-text">AI 健康问卷</text>
+  <text class="ai-q-entry-sub">智能问答,生成您的健康画像</text>
+</view>
+```
+
+```javascript
+// methods 中新增
+goAiQuestionnaire() {
+  uni.navigateTo({ url: '/pages/health/ai-questionnaire' })
+}
+```
+
+- [ ] **步骤 5:语法校验(不打包)**
+
+```bash
+cd /app/cfc/cfc-frontend
+for f in pages/health/ai-questionnaire.vue pages/health/ai-questionnaire-result.vue; do
+  node -e "
+  const s = require('fs').readFileSync('$f','utf8');
+  const m = s.match(/<script>([\s\S]*?)<\/script>/);
+  require('fs').writeFileSync('/tmp/check.js', m[1]);
+  " && node --check /tmp/check.js && echo "$f OK"
+done
+node -e "JSON.parse(require('fs').readFileSync('pages.json','utf8')); console.log('pages.json OK')"
+```
+
+预期:两个页面均输出 `OK`,`pages.json` 为合法 JSON。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/pages/health/ai-questionnaire.vue \
+        cfc-frontend/pages/health/ai-questionnaire-result.vue cfc-frontend/pages.json \
+        cfc-frontend/pages/health-main/index.vue
+git commit -m "feat(ai-q): 小程序逐题对话问卷页 + 画像展示页"
+```
+
+---
+
+## 自检记录
+
+**规格覆盖度:**
+- §4.1 LangGraph 引擎 → 任务 1(恢复)、2(schemas/prompts/graphs)、3(端点路由)✓
+- §4.2 Java 编排与存储 → 任务 4(数据层)、5(服务层)、6(控制器)✓
+- §4.3 管理端场景配置 → 任务 7 ✓
+- §4.4 小程序页面 → 任务 8 ✓
+- §5 数据库 3 表 + 种子场景 → 任务 4 步骤 1-2 ✓
+- §6 API 契约 8 端点 → 任务 6 ✓
+- §7 错误处理(兜底题/降级/并发/续答) → 任务 5 实现(fallbackQuestion/status 校验/parseHistory)✓
+- §8 测试策略(LangGraph pytest + Java compile + node --check) → 任务 2 步骤 1-2、任务 4/5/6 步骤 compile、任务 8 步骤 5 ✓
+- §10 开发规范更新 → 已完成(e4a88e68,与设计文档同 commit)✓
+
+**占位符扫描:** 无"待定/TODO/后续实现";所有代码块完整可用;兜底题文案、路由路径均已写明。✅
+
+**类型一致性:** `QnaRequest/QnaResponse/ProfileResponse`(Python)与 `advanceQuestionnaire/generateProfile`(Java)字段映射一致(scene/history/action/question/profile/kb_used);Java `AiQuestionnaireService` 接口签名与 Controller/Impl 完全一致;前端 `aiQStart/aiQAnswer/aiQFinish` 传参与 Controller `start/answer/finish` 参数一致。✅