面向 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
| 文件 | 职责 |
|---|---|
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) |
| 文件 | 职责 |
|---|---|
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 配置 |
| 文件 | 职责 |
|---|---|
src/api/aiQuestionnaire.js |
场景 CRUD 接口封装 |
src/views/admin/AiQuestionnaireScenes.vue |
场景配置管理页 |
src/router/index.js |
admin 路由注册 |
| 文件 | 职责 |
|---|---|
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 |
加入口按钮 |
背景: 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/ 关键文件
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 字节。
运行: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(内容不丢失)。
修改 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)"
文件:
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:
"""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')。
创建 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
创建 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": "建议"}}
]
}}
"""
创建 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)"
文件:
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_decide为graph.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": "请简单描述您最近一周的饮食和作息情况。"}}
修改 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 端点与路由注册"
文件:
cfc-backend/src/main/resources/schema.sqlcfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.javacfc-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 一致):
-- =============================================
-- 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 动态问卷-画像结果';
在 DatabaseInitializer.java 的 runMigrations() 方法末尾(找到 // 迁移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编号按文件末尾实际最新编号递增(编写时搜索确认)。
创建 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> {
}
(AiQSessionMapper、AiQProfileMapper 同构,分别对应 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"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/AiQuestionnaireService.javacfc-backend/src/main/java/com/etotem/cfc/service/impl/AiQuestionnaireServiceImpl.javacfc-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;
}
}
注:超时依赖
RestTemplate的SimpleClientHttpRequestFactory(setConnectTimeout/setReadTimeout)——修改AiGateway的restTemplate初始化时对generateProfile使用独立的 90s readTimeout 客户端:
private final RestTemplate profileRestTemplate = new RestTemplate() {{
SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory();
f.setConnectTimeout(5000);
f.setReadTimeout(90000);
setRequestFactory(f);
}};
并将 generateProfile 内 restTemplate 替换为 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 中不可用(ArrayNode无addObject无参方法),改用hist.add(objectMapper.valueToTree(item))——编写 AiGateway 步骤时以对象节点添加。并发防护:answer先selectById后校验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 扩展"
文件:
创建: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 SUCCESS;grep 输出统计 /api/ai-questionnaire 路由 8 个且无冲突(对照现有扫描全部路由确认 ai-questionnaire 前缀唯一)。
运行: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/* 会话与场景端点"
文件:
cfc-web/src/api/aiQuestionnaire.jscfc-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>
在 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 问卷场景配置管理页"
文件:
cfc-frontend/utils/api.jscfc-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 末尾追加:
// ── 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)
创建 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>
创建 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>
pages.json 的 pages/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')"
预期:两个页面均输出 OK,pages.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): 小程序逐题对话问卷页 + 画像展示页"
规格覆盖度:
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 参数一致。✅