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