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