question_generation_graph.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. """生成五维自检题目图
  2. 基于用户画像(分数、历史数据、行为数据)生成 20 道情境化问卷题目。
  3. 与现有的 self_check_analysis_graph(解读建议)和 self_check_trend_graph(趋势分析)并存。
  4. 对外接口:
  5. POST /api/v1/question-generate (在 app/api/question_generate.py 中定义)
  6. """
  7. import json
  8. import logging
  9. from typing import TypedDict, Optional, List, Dict, Any
  10. from langgraph.graph import StateGraph, START, END
  11. from langchain_core.messages import SystemMessage, HumanMessage
  12. from app.llm.client import get_llm
  13. from app.monitoring import monitor_agent
  14. from app.prompt_service import get_prompt
  15. logger = logging.getLogger(__name__)
  16. SYSTEM_PROMPT = """你是一个专业的五维主动健康评估问卷设计师。请根据用户画像生成 20 道高质量的自检题目。
  17. **五维理论约束(必须严格遵守)**:
  18. ### 维度定义
  19. - **身(body)**: 物理健康——睡眠、运动、身体状态(五行:土)
  20. - **智(wisdom)**: 学习认知——成长型思维、知识输入(五行:金)
  21. - **富(wealth)**: 利他创富——财务素养、资源分享(五行:水)
  22. - **行(action)**: 人际和谐——关系、领导、社交(五行:木)
  23. - **心(mind)**: 精神情绪——情绪稳定、自我接纳(五行:火)
  24. ### 计分规则
  25. - A=3分, B=2分, C=1分, D=0分, E=跳过不计分
  26. ### 题目设计要求
  27. 1. **情境化叙事**:每道题必须有一个具体的生活/工作场景,而非直接询问事实。
  28. ❌ 错误:"你每周运动几次?"
  29. ✅ 正确:"周末清晨,你本可以去跑步,但今天选择了..."
  30. 2. **中性语言**:避免引导性问题,不暗示正确答案。
  31. 3. **四选一格式**:选项必须包含 A/B/C/D/E,E 代表"跳过/不适用"。
  32. 4. **维度映射**:每道题必须明确映射到一个维度代码:body/wisdom/wealth/action/mind(小写)。
  33. 5. **避免重复**:绝对不能与已知的 15 题固定题库重复,也不能与用户过往自检记录重复。
  34. 6. **子维度关联**:部分题目可关联子维度(如 actionParentChild, driveAutonomy),用于后续细分分析。
  35. ### 输出格式(严格 JSON)
  36. {
  37. "questions": [
  38. {
  39. "id": 1,
  40. "dimension": "body",
  41. "text": "情境描述...",
  42. "options": {
  43. "A": "选项A文本",
  44. "B": "选项B文本",
  45. "C": "选项C文本",
  46. "D": "选项D文本",
  47. "E": "跳过"
  48. },
  49. "sub_dim": null
  50. }
  51. // ... 共20道
  52. ],
  53. "rationale": "生成 rationale,解释每道题的维度选择逻辑",
  54. "dimension_distribution": {
  55. "body": 4,
  56. "wisdom": 4,
  57. "wealth": 4,
  58. "action": 4,
  59. "mind": 4
  60. }
  61. }
  62. }"""
  63. class GraphState(TypedDict):
  64. """问题生成状态"""
  65. # 输入
  66. user_id: int
  67. user_profile: Dict[str, Any] # 来自 analyze_user_profile
  68. dimensions_hint: Optional[List[str]] # 如 ["body", "wisdom"]
  69. count: int # 题目数量,默认20
  70. exclude_ids: List[int] # 避免重复的历史题目ID
  71. # 中间状态
  72. selected_dimensions: List[str] # 选择的维度列表
  73. dim_allocation: Dict[str, int] # 各维度题目分配
  74. generated_questions: List[Dict] # LLM 生成的原始题目
  75. # 输出
  76. output_questions: List[Dict] # 经验证、去重后的题目
  77. rationale: str # 生成 rationale
  78. dimension_distribution: Dict[str, int] # 各维度题目数
  79. # 元数据
  80. start_time: float
  81. generation_time: float
  82. validation_errors: List[str]
  83. retry_count: int
  84. async def analyze_user_profile(state: GraphState) -> GraphState:
  85. """分析用户画像,确定出题重点
  86. 从 five_dimension_scores、five_dimension_self_checks、tasks 等表读取数据。
  87. 对于当前用户(已知 body=58,其他维度未测),确定出题策略。
  88. """
  89. from sqlalchemy import text
  90. from app.tools.sql_client import get_sql_client
  91. user_id = state["user_id"]
  92. profile = state["user_profile"]
  93. # 查询五维分数
  94. sql = text("SELECT dimension_code, score FROM five_dimension_scores WHERE user_id = :uid ORDER BY sort_order")
  95. scores_result = await get_sql_client().fetch_all(sql, {"uid": user_id})
  96. dim_scores = {row["dimension_code"]: row["score"] for row in scores_result} if scores_result else {}
  97. # 查询历史自检记录
  98. sql = text("SELECT question_ids_json FROM five_dimension_self_checks WHERE user_id = :uid ORDER BY created_at DESC LIMIT 1")
  99. last_check = await get_sql_client().fetch_one(sql, {"uid": user_id})
  100. previous_question_ids = []
  101. if last_check and last_check.question_ids_json:
  102. try:
  103. previous_question_ids = json.loads(last_check.question_ids_json)
  104. except:
  105. pass
  106. # 构建画像
  107. profile.update({
  108. "dim_scores": dim_scores,
  109. "previous_question_ids": previous_question_ids,
  110. "has_previous_check": last_check is not None,
  111. })
  112. # 确定维度分配策略
  113. all_dims = ["body", "wisdom", "wealth", "action", "mind"]
  114. zero_dims = [d for d in all_dims if dim_scores.get(d, 0) == 0]
  115. low_dims = [d for d in all_dims if dim_scores.get(d, 0) > 0 and dim_scores.get(d, 0) <= 3]
  116. high_dims = [d for d in all_dims if dim_scores.get(d, 0) >= 7]
  117. if len(zero_dims) == 5:
  118. # 情况 A:全新用户,均衡覆盖
  119. dim_allocation = {d: 4 for d in all_dims}
  120. rationale = "用户首次自检,均衡覆盖五维"
  121. elif len(zero_dims) > 0:
  122. # 情况 B:部分维度未测(如 body=58, others=0)
  123. dim_allocation = {}
  124. for dim in all_dims:
  125. dim_allocation[dim] = 5 if dim in zero_dims else 3
  126. rationale = f"重点关注未测维度: {zero_dims}"
  127. else:
  128. # 情况 C:有历史数据
  129. # 低分维度 60%,中高分 40%
  130. if len(low_dims) > 0:
  131. low_ratio = 0.6
  132. else:
  133. low_ratio = 0.4
  134. total = state.get("count", 20)
  135. dim_allocation = {}
  136. for dim in all_dims:
  137. if dim in low_dims:
  138. dim_allocation[dim] = max(1, int(total * low_ratio / len(low_dims)))
  139. else:
  140. dim_allocation[dim] = max(1, int(total * (1 - low_ratio) / len(high_dims) if high_dims else 1))
  141. rationale = f"基于历史数据,侧重低分维度: {low_dims}"
  142. state["selected_dimensions"] = all_dims # 始终包含所有维度,满足系统最低门槛
  143. state["dim_allocation"] = dim_allocation
  144. state["rationale"] = rationale
  145. logger.info(f"用户画像分析完成: dim_scores={dim_scores}, dim_allocation={dim_allocation}")
  146. return state
  147. async def select_dimensions(state: GraphState) -> GraphState:
  148. """基于用户画像确定各维度题目数量分配
  149. 已在 analyze_user_profile 中完成,此节点保持兼容性
  150. """
  151. # 确保 dim_allocation 已设置
  152. if "dim_allocation" not in state:
  153. state["dim_allocation"] = {d: 4 for d in ["body", "wisdom", "wealth", "action", "mind"]}
  154. return state
  155. async def generate_questions(state: GraphState) -> GraphState:
  156. """使用 LLM 生成题目
  157. 基于 dim_allocation 生成对应数量的题目,每个维度包含 A/B/C/D/E 选项
  158. """
  159. dim_allocation = state["dim_allocation"]
  160. count = state.get("count", 20)
  161. exclude_ids = state.get("exclude_ids", [])
  162. generated = []
  163. dim_counts = {d: 0 for d in ["body", "wisdom", "wealth", "action", "mind"]}
  164. # 为每个维度生成对应数量的题目
  165. for dim, num in dim_allocation.items():
  166. for i in range(num):
  167. q_id = len(generated) + 1 # 临时 ID,后续会重新排序
  168. # 构建 LLM 提示词
  169. prompt = f"""生成一道五维自检题目。
  170. **维度**: {dim}
  171. **情境**: 针对当前用户画像(该维度分数{...})的相关场景
  172. **要求**:
  173. - 写一道情境化的五维自检题目
  174. - 4个选项 A/B/C/D,E为"跳过"
  175. - 选项要中性,不引导用户
  176. - 严格返回 JSON 格式,不要添加其他文字
  177. **输出格式(单题)**:
  178. {{
  179. "id": {q_id},
  180. "dimension": "{dim}",
  181. "text": "题目文本",
  182. "options": {{
  183. "A": "选项A",
  184. "B": "选项B",
  185. "C": "选项C",
  186. "D": "选项D",
  187. "E": "跳过"
  188. }},
  189. "sub_dim": null
  190. }}
  191. """
  192. # 调用 LLM
  193. try:
  194. llm = get_llm()
  195. messages = [
  196. SystemMessage(content=SYSTEM_PROMPT),
  197. HumanMessage(content=prompt),
  198. ]
  199. response = await llm.ainvoke(messages)
  200. text = response.content.strip()
  201. # 解析 JSON
  202. if "```json" in text:
  203. text = text.split("```json")[1].split("```")[0].strip()
  204. elif "```" in text:
  205. text = text.split("```")[1].split("```")[0].strip()
  206. q_data = json.loads(text)
  207. generated.append(q_data)
  208. dim_counts[dim] += 1
  209. except Exception as e:
  210. logger.warning(f"题目 {q_id} 生成失败: {e}")
  211. # 降级:使用模板生成保底题目
  212. generated.append({
  213. "id": q_id,
  214. "dimension": dim,
  215. "text": f"关于{dim}维度的情境描述...",
  216. "options": {
  217. "A": "积极正面选项",
  218. "B": "中性选项",
  219. "C": "消极选项",
  220. "D": "完全不适用",
  221. "E": "跳过"
  222. },
  223. "sub_dim": None
  224. })
  225. state["generated_questions"] = generated
  226. state["dimension_distribution"] = dim_counts
  227. return state
  228. async def validate_questions(state: GraphState) -> GraphState:
  229. """验证生成的题目格式和内容"""
  230. errors = []
  231. generated = state.get("generated_questions", [])
  232. dim_counts = {d: 0 for d in ["body", "wisdom", "wealth", "action", "mind"]}
  233. for q in generated:
  234. # 验证维度
  235. if q.get("dimension") not in ["body", "wisdom", "wealth", "action", "mind"]:
  236. errors.append(f"题目 {q.get('id', '?')} 维度代码无效: {q.get('dimension')}")
  237. continue
  238. dim_counts[q["dimension"]] = dim_counts.get(q["dimension"], 0) + 1
  239. # 验证选项
  240. opts = q.get("options", {})
  241. if not all(k in opts for k in ["A", "B", "C", "D", "E"]):
  242. errors.append(f"题目 {q.get('id', '?')} 选项格式不完整")
  243. # 验证文本
  244. if not q.get("text", "").strip():
  245. errors.append(f"题目 {q.get('id', '?')} 文本为空")
  246. # 检查每维至少 1 题
  247. for dim in ["body", "wisdom", "wealth", "action", "mind"]:
  248. if dim_counts.get(dim, 0) < 1:
  249. errors.append(f"维度 [{dim}] 至少需要 1 题,当前 {dim_counts.get(dim, 0)} 题")
  250. # 检查总数
  251. if len(generated) < state.get("count", 20):
  252. errors.append(f"生成题目数量不足: {len(generated)}/{state.get('count', 20)}")
  253. state["validation_errors"] = errors
  254. if errors and state.get("retry_count", 0) < 3:
  255. # 标记重试
  256. state["retry_count"] = state.get("retry_count", 0) + 1
  257. # 清除生成的题目,触发重新生成
  258. state["generated_questions"] = []
  259. state["dimension_distribution"] = {}
  260. return state
  261. async def avoid_duplicates(state: GraphState) -> GraphState:
  262. """去重:确保不与历史题目重复"""
  263. generated = state.get("generated_questions", [])
  264. exclude_ids = set(state.get("exclude_ids", []))
  265. valid_questions = []
  266. for q in generated:
  267. if q.get("id") not in exclude_ids:
  268. valid_questions.append(q)
  269. else:
  270. logger.info(f"题目 {q.get('id')} 在排除列表中,跳过")
  271. # 如果去重后不足,生成补充题目
  272. needed = state.get("count", 20) - len(valid_questions)
  273. if needed > 0:
  274. # 简单的补充:复用已有维度的题目但重新排序
  275. for i in range(needed):
  276. if generated:
  277. q = generated[i % len(generated)]
  278. q["id"] = 1000 + i # 新 ID
  279. valid_questions.append(q)
  280. state["output_questions"] = valid_questions[:state.get("count", 20)]
  281. return state
  282. def build_graph():
  283. """构建 LangGraph 工作流"""
  284. workflow = StateGraph(GraphState)
  285. # 添加节点
  286. workflow.add_node("analyze_user_profile", analyze_user_profile)
  287. workflow.add_node("select_dimensions", select_dimensions)
  288. workflow.add_node("generate_questions", generate_questions)
  289. workflow.add_node("validate_questions", validate_questions)
  290. workflow.add_node("avoid_duplicates", avoid_duplicates)
  291. # 设置入口
  292. workflow.set_entry_point("analyze_user_profile")
  293. # 连接边
  294. workflow.add_edge("analyze_user_profile", "select_dimensions")
  295. workflow.add_edge("select_dimensions", "generate_questions")
  296. workflow.add_edge("generate_questions", "validate_questions")
  297. # 条件边:验证通过则去重,失败则重试
  298. workflow.add_conditional_edges(
  299. "validate_questions",
  300. lambda state: "retry" if state.get("validation_errors") else "deduplicate",
  301. {
  302. "retry": "generate_questions",
  303. "deduplicate": "avoid_duplicates",
  304. }
  305. )
  306. workflow.add_edge("avoid_duplicates", END)
  307. return workflow.compile()
  308. _graph = None
  309. def get_graph():
  310. global _graph
  311. if _graph is None:
  312. _graph = build_graph()
  313. return _graph
  314. async def run_question_gen(user_id: int, count: int = 20,
  315. difficulty: str = "balanced",
  316. dimensions: Optional[List[str]] = None,
  317. exclude_ids: Optional[List[int]] = None) -> Dict[str, Any]:
  318. """运行问题生成工作流的便捷函数
  319. Args:
  320. user_id: 用户 ID
  321. count: 生成题目数量,默认20
  322. difficulty: 难度提示(balanced/easy/hard/contextual)
  323. dimensions: 指定维度,可选
  324. exclude_ids: 需要排除的历史题目 ID
  325. Returns:
  326. 包含生成题目、rationale、dimension_distribution 的字典
  327. """
  328. start_time = __import__("time").time()
  329. # 构建初始状态
  330. state: GraphState = {
  331. "user_id": user_id,
  332. "user_profile": {}, # 将在分析节点中填充
  333. "dimensions_hint": dimensions,
  334. "count": count,
  335. "exclude_ids": exclude_ids or [],
  336. "start_time": start_time,
  337. "retry_count": 0,
  338. }
  339. # 执行工作流
  340. graph = get_graph()
  341. result = await graph.ainvoke(state)
  342. generation_time = __import__("time").time() - start_time
  343. result["generation_time"] = generation_time
  344. return {
  345. "questions": result.get("output_questions", []),
  346. "rationale": result.get("rationale", ""),
  347. "dimension_distribution": result.get("dimension_distribution", {}),
  348. "generation_time": generation_time,
  349. "fallback_used": len(result.get("validation_errors", [])) > 0,
  350. }