# ============================================================================= # 企微IT智能服务台 — 测验题目 AI 生成服务 # ============================================================================= # 说明:复用 Dify Wingman API(OpenAI-compatible 格式),自动生成: # 1. IT 知识题(7 类别,排队等待期间向员工推送) # 2. 诊断题(基于近期工单模式,帮助员工自检问题) # # 生成策略: # - AI 生成的所有题目 is_active=False,需管理员审批后激活 # - 种子数据(seed_quiz.py 调用)is_active=True,bootstrap 例外 # - 定时任务每日 3:00 生成新题 + 淘汰陈旧题 # # 降级策略: # - Dify 不可用时返回空结果(不抛异常),调用方决定是否重试 # - JSON 解析三层降级:直接 parse → ```json 代码块 → [..] 提取 # - 单题校验失败跳过,不影响其他题 # ============================================================================= import json import logging import re from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Tuple import httpx from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.quiz import QuizQuestion, QuizAnswer from app.models.conversation import Conversation logger = logging.getLogger(__name__) # -------------------------------------------------------------------------- # 常量 # -------------------------------------------------------------------------- # 合法的题目类别 VALID_CATEGORIES = {"network", "vpn", "email", "system", "printer", "security", "office"} # 合法的难度值 VALID_DIFFICULTIES = {"easy", "medium", "hard"} # 类别中英文映射(用于 Dify prompt) CATEGORY_MAP: Dict[str, Tuple[str, str]] = { "network": ("网络", "局域网/WiFi/网络配置/连通性/IP分配问题"), "vpn": ("VPN", "VPN连接/零信任aTrust/远程接入/认证失败问题"), "email": ("邮箱", "企业邮箱/Outlook/邮件配置/收发失败问题"), "system": ("系统", "Windows/Mac系统/蓝屏/性能优化/系统更新问题"), "printer": ("打印机", "打印机连接/共享/驱动/扫描/卡纸问题"), "security": ("安全", "火绒杀毒/防火墙/密码策略/钓鱼邮件/数据安全"), "office": ("办公软件", "WPS/Office/Excel/Word/PPT/企微文档协同"), } # -------------------------------------------------------------------------- # Dify Prompt 模板 # -------------------------------------------------------------------------- _KNOWLEDGE_SYSTEM_PROMPT = ( "你是一个企业IT支持测验题目生成器。" "你的任务是生成高质量的多选题,帮助员工在排队等待期间学习IT知识。" "题目应贴近企业办公场景(含VPN/火绒杀毒/企微/打印机等),实用且准确。" "必须以JSON数组格式输出,不要包含任何其他文字。" ) _KNOWLEDGE_USER_TEMPLATE = ( "请生成 {count} 道关于「{category_cn}」类别的IT知识选择题。\n\n" "要求:\n" "1. 每题4个选项(A/B/C/D),只有1个正确答案\n" "2. 难度分布:约40%简单、40%中等、20%困难\n" "3. 解析要简明扼要,说明正确答案的原因\n" "4. 题目不要重复,覆盖该类别的不同知识点\n\n" "类别说明:{category_cn} —— {category_desc}\n\n" "输出格式(严格JSON数组,不要markdown代码块):\n" '[{{"question": "题目文本", ' '"options": ["选项A", "选项B", "选项C", "选项D"], ' '"correct_index": 0, ' '"explanation": "解析说明", ' '"difficulty": "medium"}}]\n\n' "注意:correct_index 是正确选项的索引(0-3),difficulty 只能是 easy/medium/hard。" ) _DIAGNOSTIC_SYSTEM_PROMPT = ( "你是一个IT故障诊断题目生成器。" "你的任务是基于近期工单模式,生成诊断性选择题," "帮助员工在排队期间自检问题,答案将提供给坐席参考。" "必须以JSON数组格式输出,不要包含任何其他文字。" ) _DIAGNOSTIC_USER_TEMPLATE = ( "请基于以下近期工单摘要,生成 {count} 道诊断性选择题。\n\n" "问题类别:{problem_category}\n\n" "近期工单摘要:\n{ticket_context}\n\n" "要求:\n" "1. 题目应帮助员工自检当前问题,如\"你的VPN客户端显示什么错误码?\"\n" "2. 选项应覆盖常见情况,便于坐席快速定位问题\n" "3. 每题4个选项,correct_index 指向最可能的选项\n" "4. difficulty 统一为 medium\n\n" "输出格式(严格JSON数组):\n" '[{{"question": "诊断题目", ' '"options": ["选项A", "选项B", "选项C", "选项D"], ' '"correct_index": 0, ' '"explanation": "此选项通常表示...", ' '"difficulty": "medium"}}]' ) class QuizGenerationService: """测验题目 AI 生成服务。 复用 Dify Wingman API(OpenAI-compatible 格式), 生成知识题、诊断题,并管理陈旧题目的自动淘汰。 所有 AI 生成的题目默认 is_active=False,需管理员审批。 种子数据调用时可通过参数设为 is_active=True。 """ def __init__(self): """初始化服务,读取 Dify API 配置。 优先使用 Wingman 专用配置;若未配置则 fallback 到主 Dify API。 """ self.api_url = settings.dify_wingman_api_url or settings.dify_api_url self.api_key = settings.dify_wingman_api_key or settings.dify_api_key self.timeout = settings.dify_wingman_timeout or settings.dify_timeout self._client: Optional[httpx.AsyncClient] = None # ================================================================== # httpx 客户端管理 # ================================================================== async def _get_client(self) -> httpx.AsyncClient: """获取 httpx 异步客户端(懒加载,复用连接池)。""" if self._client is None or self._client.is_closed: self._client = httpx.AsyncClient( timeout=httpx.Timeout(self.timeout), headers={ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", }, ) return self._client async def close(self): """关闭 httpx 客户端,释放连接池资源。""" if self._client and not self._client.is_closed: await self._client.aclose() self._client = None # ================================================================== # 公开方法 # ================================================================== async def generate_knowledge_questions_batch( self, db: AsyncSession, category: str, count: int = 5, is_active: bool = False, ) -> Dict[str, Any]: """批量生成知识题。 Args: db: 数据库会话 category: 题目类别(network/vpn/email/system/printer/security/office) count: 生成数量(默认 5) is_active: 是否直接激活(种子数据 True,定时任务 False) Returns: Dict: { "success_count": int, "failed_count": int, "errors": List[str], "questions": List[Dict], # 生成的题目摘要 } """ errors: List[str] = [] questions_created: List[Dict[str, Any]] = [] # 校验类别 if category not in VALID_CATEGORIES: return { "success_count": 0, "failed_count": count, "errors": [f"无效类别: {category}"], "questions": [], } # 构建并调用 Dify category_cn, category_desc = CATEGORY_MAP[category] user_prompt = _KNOWLEDGE_USER_TEMPLATE.format( count=count, category_cn=category_cn, category_desc=category_desc, ) raw_response = await self._call_dify( system_prompt=_KNOWLEDGE_SYSTEM_PROMPT, user_prompt=user_prompt, temperature=0.7, # 较高温度保证多样性 ) if raw_response is None: return { "success_count": 0, "failed_count": count, "errors": ["Dify API 调用失败(超时或HTTP错误)"], "questions": [], } # 解析 JSON 数组 items = self._parse_json_array(raw_response) if items is None: logger.warning(f"知识题 JSON 解析失败 [{category}]: {raw_response[:200]}") return { "success_count": 0, "failed_count": count, "errors": ["AI 返回内容无法解析为 JSON 数组"], "questions": [], } # 逐条校验并插入 success_count = 0 failed_count = 0 for i, item in enumerate(items): is_valid, err_msg, normalized = self._validate_question( item, category, q_type="knowledge" ) if not is_valid: errors.append(f"题[{i}]: {err_msg}") failed_count += 1 continue # 去重检查 is_dup = await self._check_duplicate( db, normalized["question"], category ) if is_dup: errors.append(f"题[{i}]: 与已有题目重复,跳过") failed_count += 1 continue # 插入数据库 question = QuizQuestion( type="knowledge", category=category, difficulty=normalized["difficulty"], question=normalized["question"], options=normalized["options"], correct_index=normalized["correct_index"], explanation=normalized["explanation"], is_active=is_active, created_at=datetime.now(), ) db.add(question) await db.flush() # 获取 id questions_created.append({ "id": question.id, "question": question.question[:80], "difficulty": question.difficulty, "is_active": is_active, }) success_count += 1 logger.info( f"知识题生成 [{category}]: 成功 {success_count}, 失败 {failed_count}" ) return { "success_count": success_count, "failed_count": failed_count, "errors": errors, "questions": questions_created, } async def generate_diagnostic_questions_batch( self, db: AsyncSession, problem_category: str, count: int = 3, ticket_summaries: Optional[List[str]] = None, is_active: bool = False, ) -> Dict[str, Any]: """批量生成诊断题(基于近期工单模式)。 Args: db: 数据库会话 problem_category: 问题类别(如 "vpn_disconnect") count: 生成数量(默认 3) ticket_summaries: 近期工单摘要列表(作为 Dify 上下文) is_active: 是否直接激活 Returns: Dict: 同 generate_knowledge_questions_batch """ errors: List[str] = [] questions_created: List[Dict[str, Any]] = [] # 构建工单上下文 if ticket_summaries: ticket_context = "\n".join( f"- {s}" for s in ticket_summaries[:20] ) else: ticket_context = "(暂无近期工单数据,请基于常见问题生成)" # 构建并调用 Dify user_prompt = _DIAGNOSTIC_USER_TEMPLATE.format( count=count, problem_category=problem_category, ticket_context=ticket_context, ) raw_response = await self._call_dify( system_prompt=_DIAGNOSTIC_SYSTEM_PROMPT, user_prompt=user_prompt, temperature=0.5, # 较低温度,诊断题需要准确 ) if raw_response is None: return { "success_count": 0, "failed_count": count, "errors": ["Dify API 调用失败"], "questions": [], } # 解析 JSON items = self._parse_json_array(raw_response) if items is None: logger.warning(f"诊断题 JSON 解析失败 [{problem_category}]") return { "success_count": 0, "failed_count": count, "errors": ["AI 返回内容无法解析为 JSON 数组"], "questions": [], } # 逐条校验并插入 success_count = 0 failed_count = 0 # 推断诊断题的 category(从 problem_category 提取) # problem_category 格式如 "vpn_disconnect" → category="vpn" inferred_category = problem_category.split("_")[0] if problem_category else "system" if inferred_category not in VALID_CATEGORIES: inferred_category = "system" for i, item in enumerate(items): is_valid, err_msg, normalized = self._validate_question( item, inferred_category, q_type="diagnostic" ) if not is_valid: errors.append(f"诊断题[{i}]: {err_msg}") failed_count += 1 continue # 去重 is_dup = await self._check_duplicate( db, normalized["question"], inferred_category ) if is_dup: errors.append(f"诊断题[{i}]: 重复,跳过") failed_count += 1 continue # 插入 question = QuizQuestion( type="diagnostic", category=inferred_category, problem_category=problem_category, difficulty=normalized["difficulty"], question=normalized["question"], options=normalized["options"], correct_index=normalized["correct_index"], explanation=normalized["explanation"], is_active=is_active, created_at=datetime.now(), ) db.add(question) await db.flush() questions_created.append({ "id": question.id, "question": question.question[:80], "problem_category": problem_category, "is_active": is_active, }) success_count += 1 logger.info( f"诊断题生成 [{problem_category}]: 成功 {success_count}, 失败 {failed_count}" ) return { "success_count": success_count, "failed_count": failed_count, "errors": errors, "questions": questions_created, } async def deactivate_stale_questions( self, db: AsyncSession, threshold: float = 0.8, ) -> Dict[str, Any]: """停用被过多员工答过的陈旧题目。 当一道题被 >threshold 比例的活跃员工(近30天有答题记录)答过时, 自动停用(is_active=True → False)。 Args: db: 数据库会话 threshold: 答题覆盖率阈值(0-1,默认 0.8) Returns: Dict: { "deactivated_count": int, "total_active_employees": int, "deactivated_questions": List[Dict], } """ # 1. 统计近30天活跃员工总数 thirty_days_ago = datetime.now() - timedelta(days=30) total_result = await db.execute( select(func.count(func.distinct(QuizAnswer.employee_id))).where( QuizAnswer.created_at > thirty_days_ago ) ) total_employees = total_result.scalar() or 0 if total_employees == 0: logger.debug("无活跃员工答题记录,跳过陈旧题淘汰") return { "deactivated_count": 0, "total_active_employees": 0, "deactivated_questions": [], } # 2. 统计每道题的答题人数 answer_stats = await db.execute( select( QuizAnswer.question_id, func.count(func.distinct(QuizAnswer.employee_id)).label("answered_count"), ) .where(QuizAnswer.created_at > thirty_days_ago) .group_by(QuizAnswer.question_id) ) deactivated: List[Dict[str, Any]] = [] threshold_count = total_employees * threshold for row in answer_stats: if row.answered_count >= threshold_count: # 查询并停用该题 result = await db.execute( select(QuizQuestion).where( QuizQuestion.id == row.question_id, QuizQuestion.is_active == True, # noqa: E712 ) ) question = result.scalar_one_or_none() if question: question.is_active = False deactivated.append({ "question_id": question.id, "question_text": question.question[:80], "answered_count": row.answered_count, "coverage": round(row.answered_count / total_employees, 2), }) await db.flush() logger.info( f"陈旧题停用: {len(deactivated)} 道 " f"(活跃员工 {total_employees} 人, 阈值 {threshold})" ) return { "deactivated_count": len(deactivated), "total_active_employees": total_employees, "deactivated_questions": deactivated, } # ================================================================== # 内部方法 — Dify 调用 # ================================================================== async def _call_dify( self, system_prompt: str, user_prompt: str, temperature: float = 0.7, ) -> Optional[str]: """调用 Dify API(OpenAI-compatible 格式)。 Args: system_prompt: 系统提示词 user_prompt: 用户提示词 temperature: 温度(0-1,越高越有创意) Returns: Optional[str]: AI 返回文本,失败返回 None """ payload = { "model": "Chat", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ], "stream": False, "temperature": temperature, } try: client = await self._get_client() logger.info(f"调用 Dify 生成题目: prompt_length={len(user_prompt)}") response = await client.post(self.api_url, json=payload) response.raise_for_status() data = response.json() # 解析 OpenAI 兼容格式返回 choices = data.get("choices", []) if not choices: logger.warning("Dify API 返回空 choices") return None content = choices[0]["message"]["content"] logger.info(f"Dify API 返回: content_length={len(content)}") return content except httpx.TimeoutException: logger.error("Dify API 超时(题目生成)") return None except httpx.HTTPStatusError as e: logger.error(f"Dify API HTTP 错误: status={e.response.status_code}") return None except Exception as e: logger.error(f"Dify API 调用失败: {e}") return None # ================================================================== # 内部方法 — JSON 解析 # ================================================================== def _parse_json_array(self, content: str) -> Optional[List[Dict[str, Any]]]: """解析 AI 返回的 JSON 数组。 三层降级解析: 1. 直接 json.loads 2. 提取 ```json ... ``` 代码块 3. 查找第一个 [ 到最后一个 ] Args: content: AI 返回的原始文本 Returns: Optional[List[Dict]]: 解析成功返回列表,失败返回 None """ if not content: return None # 尝试 1:直接解析 try: result = json.loads(content) if isinstance(result, list): return result except json.JSONDecodeError: pass # 尝试 2:提取 markdown 代码块中的 JSON json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', content, re.DOTALL) if json_match: try: result = json.loads(json_match.group(1).strip()) if isinstance(result, list): return result except json.JSONDecodeError: pass # 尝试 3:查找第一个 [ 到最后一个 ] start = content.find('[') end = content.rfind(']') if start != -1 and end != -1 and end > start: try: result = json.loads(content[start:end + 1]) if isinstance(result, list): return result except json.JSONDecodeError: pass logger.warning(f"JSON 数组解析失败: {content[:200]}") return None # ================================================================== # 内部方法 — 题目校验 # ================================================================== def _validate_question( self, item: Dict[str, Any], category: str, q_type: str = "knowledge", ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: """校验单个题目字段。 校验规则: - question: 非空字符串,≥5 字符 - options: 列表,恰好 4 个非空字符串 - correct_index: 整数,0-3 范围 - explanation: 非空字符串 - difficulty: 枚举值 easy/medium/hard Args: item: 待校验的题目字典 category: 题目类别 q_type: 题目类型(knowledge/diagnostic) Returns: Tuple[is_valid, error_msg, normalized_data] """ # 1. 检查必需字段 required_fields = {"question", "options", "correct_index", "explanation", "difficulty"} missing = required_fields - set(item.keys()) if missing: return False, f"缺少字段: {missing}", None # 2. question 非空字符串 question_text = item.get("question") if not isinstance(question_text, str) or len(question_text.strip()) < 5: return False, "question 必须是非空字符串(≥5字符)", None # 3. options 恰好 4 个非空字符串 options = item.get("options") if not isinstance(options, list) or len(options) != 4: opt_count = len(options) if isinstance(options, list) else "非列表" return False, f"options 必须是4个选项的列表, 实际: {opt_count}", None for i, opt in enumerate(options): if not isinstance(opt, str) or not opt.strip(): return False, f"option[{i}] 必须是非空字符串", None # 4. correct_index 0-3 整数 correct_index = item.get("correct_index") if not isinstance(correct_index, int) or correct_index < 0 or correct_index > 3: return False, f"correct_index 必须是0-3的整数, 实际: {correct_index}", None # 5. difficulty 枚举 difficulty = item.get("difficulty", "medium") if difficulty not in VALID_DIFFICULTIES: return False, f"difficulty 无效: {difficulty}, 应为 {VALID_DIFFICULTIES}", None # 6. explanation 非空 explanation = item.get("explanation", "") if not isinstance(explanation, str) or not explanation.strip(): return False, "explanation 不能为空", None # 标准化数据 normalized = { "type": q_type, "category": category, "difficulty": difficulty, "question": question_text.strip(), "options": [opt.strip() for opt in options], "correct_index": correct_index, "explanation": explanation.strip(), } return True, "", normalized # ================================================================== # 内部方法 — 去重检查 # ================================================================== async def _check_duplicate( self, db: AsyncSession, question_text: str, category: str, ) -> bool: """检查题目是否重复(前 50 字符 + category 匹配)。 Args: db: 数据库会话 question_text: 题目文本 category: 题目类别 Returns: bool: True 表示已存在重复题目 """ # 取前 50 个字符做模糊匹配 prefix = question_text[:50] result = await db.execute( select(func.count(QuizQuestion.id)).where( QuizQuestion.category == category, QuizQuestion.question.like(f"{prefix}%"), ) ) count = result.scalar() or 0 return count > 0 # ================================================================== # 内部方法 — 近期工单摘要 # ================================================================== async def _get_recent_ticket_summaries( self, db: AsyncSession, days: int = 7, limit: int = 20, ) -> List[Dict[str, Any]]: """获取近期已解决工单的摘要和标签(用于诊断题生成上下文)。 查询条件: - Conversation.status == 'resolved' - created_at > now - days - 取 last_message_summary 和 tags Args: db: 数据库会话 days: 查询天数(默认 7) limit: 返回数量上限(默认 20) Returns: List[Dict]: [{"summary": "...", "tags": [...], "category_hint": "..."}] """ cutoff = datetime.now() - timedelta(days=days) result = await db.execute( select( Conversation.id, Conversation.last_message_summary, Conversation.tags, ) .where( Conversation.status == "resolved", Conversation.created_at > cutoff, ) .order_by(Conversation.created_at.desc()) .limit(limit) ) summaries: List[Dict[str, Any]] = [] for row in result: summary_text = row.last_message_summary or "" # tags 是 Dict 类型,如 {"hand_raise": true, "emotion": "angry"} tags_dict = row.tags if isinstance(row.tags, dict) else {} tag_keys = list(tags_dict.keys()) # 从 tags 键名推断 category_hint category_hint = "" for tag_key in tag_keys: tag_lower = tag_key.lower() for cat in VALID_CATEGORIES: if cat in tag_lower: category_hint = cat break if category_hint: break summaries.append({ "summary": summary_text, "tags": tag_keys, # 返回 tag 键名列表 "category_hint": category_hint, }) return summaries # -------------------------------------------------------------------------- # 单例管理 # -------------------------------------------------------------------------- _quiz_gen_service: Optional[QuizGenerationService] = None def get_quiz_generation_service() -> QuizGenerationService: """获取 QuizGenerationService 单例实例。""" global _quiz_gen_service if _quiz_gen_service is None: _quiz_gen_service = QuizGenerationService() return _quiz_gen_service