2026-08-14-ai-dynamic-questionnaire.md 86 KB

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.pyloader.pysplitter.pyembeddings.py RAG 检索与知识库同步(恢复;loader.pyknowledge_sync.py 引用)
app/tools/java_client.py Java 后端 HTTP 客户端(工作区已有完整版 117 行,保留)
app/tasks/knowledge_sync.py 知识库定时同步(工作区已有完整版 129 行,保留)
app/middleware.pylog_config.pymonitoring.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/advancePOST /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.javaAiQSession.javaAiQProfile.java MyBatis-Plus 实体
src/main/java/com/etotem/cfc/mapper/AiQSceneMapper.javaAiQSessionMapper.javaAiQProfileMapper.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/2f685d22src/ 问卷模块取 f18dd86e~1)。工作区已有 app/tools/java_client.py(117 行)与 app/tasks/knowledge_sync.py(129 行)不可丢弃。

文件:

  • 恢复:cfc-langgraph/app/config.pyapp/main.pyapp/middleware.pyapp/log_config.pyapp/monitoring.pyapp/rag/*.pyapp/api/health.pysrc/llm/client.pysrc/app.pysrc/schemas/*src/prompts/*src/graphs/*
  • 保留(勿覆盖):app/tools/java_client.pyapp/tasks/knowledge_sync.py

  • [ ] 步骤 1:从 git 历史恢复 app/ 关键文件

    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.pyM 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 会失败)。

# 验证 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:启动验证

    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

    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.pysrc/qna/prompts.pysrc/qna/graph.py
  • 测试:cfc-langgraph/tests/qna/test_graph.py

  • [ ] 步骤 1:编写失败的测试

创建 tests/qna/test_graph.py

"""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:运行测试验证失败

    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

"""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

"""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

"""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:运行测试验证通过

    cd /app/cfc/cfc-langgraph && .venv/bin/python -m pytest tests/qna/test_graph.py -v 2>&1 | tail -10
    

预期:4 个测试全部 PASS。

  • [ ] 步骤 7:Commit

    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

"""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_decidegraph.py 中真正的知识注入版本——把 kb_context 转为文本后调用 decide_next。在 graph.py 末尾追加:

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 之后追加:

from .qna.router import router as qna_router
app.include_router(qna_router)
  • [ ] 步骤 3:语法校验与导入验证

    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:启动 + 冒烟测试

    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

    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.javaAiQSession.javaAiQProfile.java
  • 创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/AiQSceneMapper.javaAiQSessionMapper.javaAiQProfileMapper.java

  • [ ] 步骤 1:schema.sql 追加 3 张表

schema.sql 末尾追加(与规格 5.1-5.3 一致):

-- =============================================
-- 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.javarunMigrations() 方法末尾(找到 // 迁移N 最新编号后追加):

// 迁移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

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

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

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

    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> {
    }
    

AiQSessionMapperAiQProfileMapper 同构,分别对应 AiQSession/AiQProfile。)

  • [ ] 步骤 5:编译验证

    cd /app/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -5
    

预期:BUILD SUCCESS(无编译错误)。

  • [ ] 步骤 6:Commit

    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 之后)追加:

    /**
     * 调用 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;
        }
    }

注:超时依赖 RestTemplateSimpleClientHttpRequestFactorysetConnectTimeout/setReadTimeout)——修改 AiGatewayrestTemplate 初始化时对 generateProfile 使用独立的 90s readTimeout 客户端:

    private final RestTemplate profileRestTemplate = new RestTemplate() {{
        SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory();
        f.setConnectTimeout(5000);
        f.setReadTimeout(90000);
        setRequestFactory(f);
    }};

并将 generateProfilerestTemplate 替换为 profileRestTemplate

  • [ ] 步骤 2:application.yml 增加配置

    langgraph:
    base-url: ${LANGGRAPH_BASE_URL:http://localhost:9000}
    profile-timeout-ms: 90000   # 画像生成推理可达 30-60s
    
  • [ ] 步骤 3:创建 AiQuestionnaireService 接口

    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(关键逻辑,完整实现):

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 中不可用(ArrayNodeaddObject 无参方法),改用 hist.add(objectMapper.valueToTree(item))——编写 AiGateway 步骤时以对象节点添加。并发防护:answerselectById 后校验 status,更新时最后写(UPDATE 整行),极端并发下后到者读到的 status 可能已是 finished,返回"问卷已完成"。

  • [ ] 步骤 5:编译验证

    cd /app/cfc/cfc-backend && mvn clean compile -q 2>&1 | tail -8
    

预期:BUILD SUCCESS。若 hist::addObject 编译失败,按上方说明改为 hist.add(objectMapper.valueToTree(item))

  • [ ] 步骤 6:Commit

    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:创建控制器

    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:编译 + 路由重复检查

    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 SUCCESSgrep 输出统计 /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

    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 封装

    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 的表格+弹窗模式):

<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 权限模式):

{
  path: '/admin/ai-questionnaire/scenes',
  name: 'AiQuestionnaireScenes',
  component: () => import('@/views/admin/AiQuestionnaireScenes.vue'),
  meta: { title: 'AI 问卷场景配置', roles: ['admin'] }
}
  • [ ] 步骤 4:语法校验

    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

    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.vuepages/health/ai-questionnaire-result.vue
  • 修改:cfc-frontend/pages.jsoncfc-frontend/pages/health-main/index.vue

  • [ ] 步骤 1:utils/api.js 新增接口

utils/api.js 末尾追加:

// ── 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 表达式、日期/数字处理):

<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

<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.jsonpages/health 分包 pages 数组中追加:

{
  "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 合适位置加入口(参照现有功能卡片按钮写法):

<view class="ai-q-entry" @click="goAiQuestionnaire">
  <text class="ai-q-entry-text">AI 健康问卷</text>
  <text class="ai-q-entry-sub">智能问答,生成您的健康画像</text>
</view>
// methods 中新增
goAiQuestionnaire() {
  uni.navigateTo({ url: '/pages/health/ai-questionnaire' })
}
  • [ ] 步骤 5:语法校验(不打包)

    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')"
    

预期:两个页面均输出 OKpages.json 为合法 JSON。

  • [ ] 步骤 6:Commit

    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 参数一致。✅