| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442 |
- """生成五维自检题目图
- 基于用户画像(分数、历史数据、行为数据)生成 20 道情境化问卷题目。
- 与现有的 self_check_analysis_graph(解读建议)和 self_check_trend_graph(趋势分析)并存。
- 对外接口:
- POST /api/v1/question-generate (在 app/api/question_generate.py 中定义)
- """
- import json
- import logging
- from typing import TypedDict, Optional, List, Dict, Any
- from langgraph.graph import StateGraph, START, END
- from langchain_core.messages import SystemMessage, HumanMessage
- from app.llm.client import get_llm
- from app.monitoring import monitor_agent
- from app.prompt_service import get_prompt
- logger = logging.getLogger(__name__)
- SYSTEM_PROMPT = """你是一个专业的五维主动健康评估问卷设计师。请根据用户画像生成 20 道高质量的自检题目。
- **五维理论约束(必须严格遵守)**:
- ### 维度定义
- - **身(body)**: 物理健康——睡眠、运动、身体状态(五行:土)
- - **智(wisdom)**: 学习认知——成长型思维、知识输入(五行:金)
- - **富(wealth)**: 利他创富——财务素养、资源分享(五行:水)
- - **行(action)**: 人际和谐——关系、领导、社交(五行:木)
- - **心(mind)**: 精神情绪——情绪稳定、自我接纳(五行:火)
- ### 计分规则
- - A=3分, B=2分, C=1分, D=0分, E=跳过不计分
- ### 题目设计要求
- 1. **情境化叙事**:每道题必须有一个具体的生活/工作场景,而非直接询问事实。
- ❌ 错误:"你每周运动几次?"
- ✅ 正确:"周末清晨,你本可以去跑步,但今天选择了..."
- 2. **中性语言**:避免引导性问题,不暗示正确答案。
- 3. **四选一格式**:选项必须包含 A/B/C/D/E,E 代表"跳过/不适用"。
- 4. **维度映射**:每道题必须明确映射到一个维度代码:body/wisdom/wealth/action/mind(小写)。
- 5. **避免重复**:绝对不能与已知的 15 题固定题库重复,也不能与用户过往自检记录重复。
- 6. **子维度关联**:部分题目可关联子维度(如 actionParentChild, driveAutonomy),用于后续细分分析。
- ### 输出格式(严格 JSON)
- {
- "questions": [
- {
- "id": 1,
- "dimension": "body",
- "text": "情境描述...",
- "options": {
- "A": "选项A文本",
- "B": "选项B文本",
- "C": "选项C文本",
- "D": "选项D文本",
- "E": "跳过"
- },
- "sub_dim": null
- }
- // ... 共20道
- ],
- "rationale": "生成 rationale,解释每道题的维度选择逻辑",
- "dimension_distribution": {
- "body": 4,
- "wisdom": 4,
- "wealth": 4,
- "action": 4,
- "mind": 4
- }
- }
- }"""
- class GraphState(TypedDict):
- """问题生成状态"""
- # 输入
- user_id: int
- user_profile: Dict[str, Any] # 来自 analyze_user_profile
- dimensions_hint: Optional[List[str]] # 如 ["body", "wisdom"]
- count: int # 题目数量,默认20
- exclude_ids: List[int] # 避免重复的历史题目ID
- # 中间状态
- selected_dimensions: List[str] # 选择的维度列表
- dim_allocation: Dict[str, int] # 各维度题目分配
- generated_questions: List[Dict] # LLM 生成的原始题目
- # 输出
- output_questions: List[Dict] # 经验证、去重后的题目
- rationale: str # 生成 rationale
- dimension_distribution: Dict[str, int] # 各维度题目数
- # 元数据
- start_time: float
- generation_time: float
- validation_errors: List[str]
- retry_count: int
- async def analyze_user_profile(state: GraphState) -> GraphState:
- """分析用户画像,确定出题重点
-
- 从 five_dimension_scores、five_dimension_self_checks、tasks 等表读取数据。
- 对于当前用户(已知 body=58,其他维度未测),确定出题策略。
- """
- from sqlalchemy import text
- from app.tools.sql_client import get_sql_client
-
- user_id = state["user_id"]
- profile = state["user_profile"]
-
- # 查询五维分数
- sql = text("SELECT dimension_code, score FROM five_dimension_scores WHERE user_id = :uid ORDER BY sort_order")
- scores_result = await get_sql_client().fetch_all(sql, {"uid": user_id})
- dim_scores = {row["dimension_code"]: row["score"] for row in scores_result} if scores_result else {}
-
- # 查询历史自检记录
- sql = text("SELECT question_ids_json FROM five_dimension_self_checks WHERE user_id = :uid ORDER BY created_at DESC LIMIT 1")
- last_check = await get_sql_client().fetch_one(sql, {"uid": user_id})
- previous_question_ids = []
- if last_check and last_check.question_ids_json:
- try:
- previous_question_ids = json.loads(last_check.question_ids_json)
- except:
- pass
-
- # 构建画像
- profile.update({
- "dim_scores": dim_scores,
- "previous_question_ids": previous_question_ids,
- "has_previous_check": last_check is not None,
- })
-
- # 确定维度分配策略
- all_dims = ["body", "wisdom", "wealth", "action", "mind"]
- zero_dims = [d for d in all_dims if dim_scores.get(d, 0) == 0]
- low_dims = [d for d in all_dims if dim_scores.get(d, 0) > 0 and dim_scores.get(d, 0) <= 3]
- high_dims = [d for d in all_dims if dim_scores.get(d, 0) >= 7]
-
- if len(zero_dims) == 5:
- # 情况 A:全新用户,均衡覆盖
- dim_allocation = {d: 4 for d in all_dims}
- rationale = "用户首次自检,均衡覆盖五维"
- elif len(zero_dims) > 0:
- # 情况 B:部分维度未测(如 body=58, others=0)
- dim_allocation = {}
- for dim in all_dims:
- dim_allocation[dim] = 5 if dim in zero_dims else 3
- rationale = f"重点关注未测维度: {zero_dims}"
- else:
- # 情况 C:有历史数据
- # 低分维度 60%,中高分 40%
- if len(low_dims) > 0:
- low_ratio = 0.6
- else:
- low_ratio = 0.4
-
- total = state.get("count", 20)
- dim_allocation = {}
- for dim in all_dims:
- if dim in low_dims:
- dim_allocation[dim] = max(1, int(total * low_ratio / len(low_dims)))
- else:
- dim_allocation[dim] = max(1, int(total * (1 - low_ratio) / len(high_dims) if high_dims else 1))
- rationale = f"基于历史数据,侧重低分维度: {low_dims}"
-
- state["selected_dimensions"] = all_dims # 始终包含所有维度,满足系统最低门槛
- state["dim_allocation"] = dim_allocation
- state["rationale"] = rationale
-
- logger.info(f"用户画像分析完成: dim_scores={dim_scores}, dim_allocation={dim_allocation}")
- return state
- async def select_dimensions(state: GraphState) -> GraphState:
- """基于用户画像确定各维度题目数量分配
-
- 已在 analyze_user_profile 中完成,此节点保持兼容性
- """
- # 确保 dim_allocation 已设置
- if "dim_allocation" not in state:
- state["dim_allocation"] = {d: 4 for d in ["body", "wisdom", "wealth", "action", "mind"]}
- return state
- async def generate_questions(state: GraphState) -> GraphState:
- """使用 LLM 生成题目
-
- 基于 dim_allocation 生成对应数量的题目,每个维度包含 A/B/C/D/E 选项
- """
- dim_allocation = state["dim_allocation"]
- count = state.get("count", 20)
- exclude_ids = state.get("exclude_ids", [])
-
- generated = []
- dim_counts = {d: 0 for d in ["body", "wisdom", "wealth", "action", "mind"]}
-
- # 为每个维度生成对应数量的题目
- for dim, num in dim_allocation.items():
- for i in range(num):
- q_id = len(generated) + 1 # 临时 ID,后续会重新排序
-
- # 构建 LLM 提示词
- prompt = f"""生成一道五维自检题目。
- **维度**: {dim}
- **情境**: 针对当前用户画像(该维度分数{...})的相关场景
- **要求**:
- - 写一道情境化的五维自检题目
- - 4个选项 A/B/C/D,E为"跳过"
- - 选项要中性,不引导用户
- - 严格返回 JSON 格式,不要添加其他文字
- **输出格式(单题)**:
- {{
- "id": {q_id},
- "dimension": "{dim}",
- "text": "题目文本",
- "options": {{
- "A": "选项A",
- "B": "选项B",
- "C": "选项C",
- "D": "选项D",
- "E": "跳过"
- }},
- "sub_dim": null
- }}
- """
-
- # 调用 LLM
- try:
- llm = get_llm()
- messages = [
- SystemMessage(content=SYSTEM_PROMPT),
- HumanMessage(content=prompt),
- ]
- response = await llm.ainvoke(messages)
- text = response.content.strip()
-
- # 解析 JSON
- if "```json" in text:
- text = text.split("```json")[1].split("```")[0].strip()
- elif "```" in text:
- text = text.split("```")[1].split("```")[0].strip()
-
- q_data = json.loads(text)
- generated.append(q_data)
- dim_counts[dim] += 1
-
- except Exception as e:
- logger.warning(f"题目 {q_id} 生成失败: {e}")
- # 降级:使用模板生成保底题目
- generated.append({
- "id": q_id,
- "dimension": dim,
- "text": f"关于{dim}维度的情境描述...",
- "options": {
- "A": "积极正面选项",
- "B": "中性选项",
- "C": "消极选项",
- "D": "完全不适用",
- "E": "跳过"
- },
- "sub_dim": None
- })
-
- state["generated_questions"] = generated
- state["dimension_distribution"] = dim_counts
- return state
- async def validate_questions(state: GraphState) -> GraphState:
- """验证生成的题目格式和内容"""
- errors = []
- generated = state.get("generated_questions", [])
- dim_counts = {d: 0 for d in ["body", "wisdom", "wealth", "action", "mind"]}
-
- for q in generated:
- # 验证维度
- if q.get("dimension") not in ["body", "wisdom", "wealth", "action", "mind"]:
- errors.append(f"题目 {q.get('id', '?')} 维度代码无效: {q.get('dimension')}")
- continue
-
- dim_counts[q["dimension"]] = dim_counts.get(q["dimension"], 0) + 1
-
- # 验证选项
- opts = q.get("options", {})
- if not all(k in opts for k in ["A", "B", "C", "D", "E"]):
- errors.append(f"题目 {q.get('id', '?')} 选项格式不完整")
-
- # 验证文本
- if not q.get("text", "").strip():
- errors.append(f"题目 {q.get('id', '?')} 文本为空")
-
- # 检查每维至少 1 题
- for dim in ["body", "wisdom", "wealth", "action", "mind"]:
- if dim_counts.get(dim, 0) < 1:
- errors.append(f"维度 [{dim}] 至少需要 1 题,当前 {dim_counts.get(dim, 0)} 题")
-
- # 检查总数
- if len(generated) < state.get("count", 20):
- errors.append(f"生成题目数量不足: {len(generated)}/{state.get('count', 20)}")
-
- state["validation_errors"] = errors
-
- if errors and state.get("retry_count", 0) < 3:
- # 标记重试
- state["retry_count"] = state.get("retry_count", 0) + 1
- # 清除生成的题目,触发重新生成
- state["generated_questions"] = []
- state["dimension_distribution"] = {}
-
- return state
- async def avoid_duplicates(state: GraphState) -> GraphState:
- """去重:确保不与历史题目重复"""
- generated = state.get("generated_questions", [])
- exclude_ids = set(state.get("exclude_ids", []))
-
- valid_questions = []
- for q in generated:
- if q.get("id") not in exclude_ids:
- valid_questions.append(q)
- else:
- logger.info(f"题目 {q.get('id')} 在排除列表中,跳过")
-
- # 如果去重后不足,生成补充题目
- needed = state.get("count", 20) - len(valid_questions)
- if needed > 0:
- # 简单的补充:复用已有维度的题目但重新排序
- for i in range(needed):
- if generated:
- q = generated[i % len(generated)]
- q["id"] = 1000 + i # 新 ID
- valid_questions.append(q)
-
- state["output_questions"] = valid_questions[:state.get("count", 20)]
- return state
- def build_graph():
- """构建 LangGraph 工作流"""
- workflow = StateGraph(GraphState)
-
- # 添加节点
- workflow.add_node("analyze_user_profile", analyze_user_profile)
- workflow.add_node("select_dimensions", select_dimensions)
- workflow.add_node("generate_questions", generate_questions)
- workflow.add_node("validate_questions", validate_questions)
- workflow.add_node("avoid_duplicates", avoid_duplicates)
-
- # 设置入口
- workflow.set_entry_point("analyze_user_profile")
-
- # 连接边
- workflow.add_edge("analyze_user_profile", "select_dimensions")
- workflow.add_edge("select_dimensions", "generate_questions")
- workflow.add_edge("generate_questions", "validate_questions")
-
- # 条件边:验证通过则去重,失败则重试
- workflow.add_conditional_edges(
- "validate_questions",
- lambda state: "retry" if state.get("validation_errors") else "deduplicate",
- {
- "retry": "generate_questions",
- "deduplicate": "avoid_duplicates",
- }
- )
-
- workflow.add_edge("avoid_duplicates", END)
-
- return workflow.compile()
- _graph = None
- def get_graph():
- global _graph
- if _graph is None:
- _graph = build_graph()
- return _graph
- async def run_question_gen(user_id: int, count: int = 20,
- difficulty: str = "balanced",
- dimensions: Optional[List[str]] = None,
- exclude_ids: Optional[List[int]] = None) -> Dict[str, Any]:
- """运行问题生成工作流的便捷函数
-
- Args:
- user_id: 用户 ID
- count: 生成题目数量,默认20
- difficulty: 难度提示(balanced/easy/hard/contextual)
- dimensions: 指定维度,可选
- exclude_ids: 需要排除的历史题目 ID
-
- Returns:
- 包含生成题目、rationale、dimension_distribution 的字典
- """
- start_time = __import__("time").time()
-
- # 构建初始状态
- state: GraphState = {
- "user_id": user_id,
- "user_profile": {}, # 将在分析节点中填充
- "dimensions_hint": dimensions,
- "count": count,
- "exclude_ids": exclude_ids or [],
- "start_time": start_time,
- "retry_count": 0,
- }
-
- # 执行工作流
- graph = get_graph()
- result = await graph.ainvoke(state)
-
- generation_time = __import__("time").time() - start_time
- result["generation_time"] = generation_time
-
- return {
- "questions": result.get("output_questions", []),
- "rationale": result.get("rationale", ""),
- "dimension_distribution": result.get("dimension_distribution", {}),
- "generation_time": generation_time,
- "fallback_used": len(result.get("validation_errors", [])) > 0,
- }
|