""" 试题生成服务 V2 - Python 原生实现 功能: - 根据岗位和知识点动态生成考试题目 - 支持错题重出模式 - 调用 AI 生成并解析 JSON 结果 提供稳定可靠的试题生成能力。 """ import json import logging from dataclasses import dataclass from typing import Any, Dict, List, Optional from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ExternalServiceError from .ai_service import AIService, AIResponse from .llm_json_parser import parse_with_fallback, clean_llm_output from .prompts.exam_generator_prompts import ( SYSTEM_PROMPT, USER_PROMPT, MISTAKE_REGEN_SYSTEM_PROMPT, MISTAKE_REGEN_USER_PROMPT, QUESTION_SCHEMA, DEFAULT_QUESTION_COUNTS, DEFAULT_DIFFICULTY_LEVEL, ) logger = logging.getLogger(__name__) @dataclass class ExamGeneratorConfig: """考试生成配置""" course_id: int position_id: int single_choice_count: int = DEFAULT_QUESTION_COUNTS["single_choice_count"] multiple_choice_count: int = DEFAULT_QUESTION_COUNTS["multiple_choice_count"] true_false_count: int = DEFAULT_QUESTION_COUNTS["true_false_count"] fill_blank_count: int = DEFAULT_QUESTION_COUNTS["fill_blank_count"] essay_count: int = DEFAULT_QUESTION_COUNTS["essay_count"] difficulty_level: int = DEFAULT_DIFFICULTY_LEVEL mistake_records: str = "" @property def total_count(self) -> int: """计算总题量""" return ( self.single_choice_count + self.multiple_choice_count + self.true_false_count + self.fill_blank_count + self.essay_count ) @property def has_mistakes(self) -> bool: """是否有错题记录""" return bool(self.mistake_records and self.mistake_records.strip()) class ExamGeneratorService: """ 试题生成服务 V2 使用 Python 原生实现。 使用示例: ```python service = ExamGeneratorService() result = await service.generate_exam( db=db_session, config=ExamGeneratorConfig( course_id=1, position_id=1, single_choice_count=5, multiple_choice_count=3, difficulty_level=3 ) ) ``` """ def __init__(self): """初始化服务""" self.ai_service = AIService(module_code="exam_generator") async def generate_exam( self, db: AsyncSession, config: ExamGeneratorConfig ) -> Dict[str, Any]: """ 生成考试题目(主入口) Args: db: 数据库会话 config: 考试生成配置 Returns: 生成结果,包含 success、questions、total_count 等字段 """ try: logger.info( f"开始生成试题 - course_id: {config.course_id}, position_id: {config.position_id}, " f"total_count: {config.total_count}, has_mistakes: {config.has_mistakes}" ) # 根据是否有错题记录,走不同分支 if config.has_mistakes: return await self._regenerate_from_mistakes(db, config) else: return await self._generate_from_knowledge(db, config) except ExternalServiceError: raise except Exception as e: logger.error( f"试题生成失败 - course_id: {config.course_id}, error: {e}", exc_info=True ) raise ExternalServiceError(f"试题生成失败: {e}") async def _generate_from_knowledge( self, db: AsyncSession, config: ExamGeneratorConfig ) -> Dict[str, Any]: """ 基于知识点生成题目(无错题模式) 流程: 1. 查询岗位信息 2. 随机查询知识点 3. 调用 AI 生成题目 4. 解析并返回结果 """ # 1. 查询岗位信息 position_info = await self._query_position(db, config.position_id) if not position_info: raise ExternalServiceError(f"岗位不存在: position_id={config.position_id}") logger.info(f"岗位信息: {position_info.get('name', 'unknown')}") # 2. 随机查询知识点 knowledge_points = await self._query_knowledge_points( db, config.course_id, config.total_count ) if not knowledge_points: raise ExternalServiceError( f"课程没有可用的知识点: course_id={config.course_id}" ) logger.info(f"查询到 {len(knowledge_points)} 个知识点") # 3. 构建提示词 system_prompt = SYSTEM_PROMPT.format( total_count=config.total_count, single_choice_count=config.single_choice_count, multiple_choice_count=config.multiple_choice_count, true_false_count=config.true_false_count, fill_blank_count=config.fill_blank_count, essay_count=config.essay_count, difficulty_level=config.difficulty_level, ) user_prompt = USER_PROMPT.format( position_info=self._format_position_info(position_info), knowledge_points=self._format_knowledge_points(knowledge_points), ) # 4. 调用 AI 生成 ai_response = await self._call_ai_generate(system_prompt, user_prompt) logger.info( f"AI 生成完成 - provider: {ai_response.provider}, " f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" ) # 5. 解析题目 questions = self._parse_questions(ai_response.content) logger.info(f"试题解析成功,数量: {len(questions)}") return { "success": True, "questions": questions, "total_count": len(questions), "mode": "knowledge_based", "ai_provider": ai_response.provider, "ai_model": ai_response.model, "ai_tokens": ai_response.total_tokens, "ai_latency_ms": ai_response.latency_ms, } async def _regenerate_from_mistakes( self, db: AsyncSession, config: ExamGeneratorConfig ) -> Dict[str, Any]: """ 错题重出模式 流程: 1. 构建错题重出提示词 2. 调用 AI 生成新题 3. 解析并返回结果 """ logger.info("进入错题重出模式") # 1. 构建提示词 system_prompt = MISTAKE_REGEN_SYSTEM_PROMPT.format( difficulty_level=config.difficulty_level, ) user_prompt = MISTAKE_REGEN_USER_PROMPT.format( mistake_records=config.mistake_records, ) # 2. 调用 AI 生成 ai_response = await self._call_ai_generate(system_prompt, user_prompt) logger.info( f"错题重出完成 - provider: {ai_response.provider}, " f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" ) # 3. 解析题目 questions = self._parse_questions(ai_response.content) logger.info(f"错题重出解析成功,数量: {len(questions)}") return { "success": True, "questions": questions, "total_count": len(questions), "mode": "mistake_regen", "ai_provider": ai_response.provider, "ai_model": ai_response.model, "ai_tokens": ai_response.total_tokens, "ai_latency_ms": ai_response.latency_ms, } async def _query_position( self, db: AsyncSession, position_id: int ) -> Optional[Dict[str, Any]]: """ 查询岗位信息 SQL:SELECT id, name, description, skills, level FROM positions WHERE id = :id AND is_deleted = FALSE """ try: result = await db.execute( text(""" SELECT id, name, description, skills, level FROM positions WHERE id = :position_id AND is_deleted = FALSE """), {"position_id": position_id} ) row = result.fetchone() if not row: return None # 将 Row 转换为字典 return { "id": row[0], "name": row[1], "description": row[2], "skills": row[3], # JSON 字段 "level": row[4], } except Exception as e: logger.error(f"查询岗位信息失败: {e}") raise ExternalServiceError(f"查询岗位信息失败: {e}") async def _query_knowledge_points( self, db: AsyncSession, course_id: int, limit: int ) -> List[Dict[str, Any]]: """ 随机查询知识点 SQL:SELECT kp.id, kp.name, kp.description, kp.topic_relation FROM knowledge_points kp INNER JOIN course_materials cm ON kp.material_id = cm.id WHERE kp.course_id = :course_id AND kp.is_deleted = FALSE AND cm.is_deleted = FALSE ORDER BY RAND() LIMIT :limit """ try: result = await db.execute( text(""" SELECT kp.id, kp.name, kp.description, kp.topic_relation FROM knowledge_points kp INNER JOIN course_materials cm ON kp.material_id = cm.id WHERE kp.course_id = :course_id AND kp.is_deleted = FALSE AND cm.is_deleted = FALSE ORDER BY RAND() LIMIT :limit """), {"course_id": course_id, "limit": limit} ) rows = result.fetchall() return [ { "id": row[0], "name": row[1], "description": row[2], "topic_relation": row[3], } for row in rows ] except Exception as e: logger.error(f"查询知识点失败: {e}") raise ExternalServiceError(f"查询知识点失败: {e}") async def _call_ai_generate( self, system_prompt: str, user_prompt: str ) -> AIResponse: """调用 AI 生成题目""" messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ] response = await self.ai_service.chat( messages=messages, temperature=0.7, # 适当的创造性 prompt_name="exam_generator" ) return response def _parse_questions(self, ai_output: str) -> List[Dict[str, Any]]: """ 解析 AI 输出的题目 JSON 使用 LLM JSON Parser 进行多层兜底解析 """ # 先清洗输出 cleaned_output, rules = clean_llm_output(ai_output) if rules: logger.debug(f"AI 输出已清洗: {rules}") # 使用带 Schema 校验的解析 questions = parse_with_fallback( cleaned_output, schema=QUESTION_SCHEMA, default=[], validate_schema=True, on_error="default" ) # 后处理:确保每个题目有必要字段 processed_questions = [] for i, q in enumerate(questions): if isinstance(q, dict): # 确保有 num 字段 if "num" not in q: q["num"] = i + 1 # 确保 num 是整数 try: q["num"] = int(q["num"]) except (ValueError, TypeError): q["num"] = i + 1 # 确保有 type 字段 if "type" not in q: # 根据是否有 options 推断类型 if q.get("topic", {}).get("options"): q["type"] = "single_choice" else: q["type"] = "essay" # 确保 knowledge_point_id 是整数或 None kp_id = q.get("knowledge_point_id") if kp_id is not None: try: q["knowledge_point_id"] = int(kp_id) except (ValueError, TypeError): q["knowledge_point_id"] = None # 验证必要字段 if q.get("topic") and q.get("correct"): processed_questions.append(q) else: logger.warning(f"题目缺少必要字段,已跳过: {q}") if not processed_questions: logger.warning("未能解析出有效的题目") return processed_questions def _format_position_info(self, position: Dict[str, Any]) -> str: """格式化岗位信息为文本""" lines = [ f"岗位名称: {position.get('name', '未知')}", f"岗位等级: {position.get('level', '未设置')}", ] if position.get('description'): lines.append(f"岗位描述: {position['description']}") skills = position.get('skills') if skills: # skills 可能是 JSON 字符串或列表 if isinstance(skills, str): try: skills = json.loads(skills) except json.JSONDecodeError: skills = [skills] if isinstance(skills, list) and skills: lines.append(f"核心技能: {', '.join(str(s) for s in skills)}") return '\n'.join(lines) def _format_knowledge_points(self, knowledge_points: List[Dict[str, Any]]) -> str: """格式化知识点列表为文本""" lines = [] for kp in knowledge_points: kp_text = f"【知识点 ID: {kp['id']}】{kp['name']}" if kp.get('description'): kp_text += f"\n{kp['description']}" if kp.get('topic_relation'): kp_text += f"\n关系描述: {kp['topic_relation']}" lines.append(kp_text) return '\n\n'.join(lines) # 创建全局实例 exam_generator_service = ExamGeneratorService() # ==================== 便捷函数 ==================== async def generate_exam( db: AsyncSession, course_id: int, position_id: int, single_choice_count: int = 4, multiple_choice_count: int = 2, true_false_count: int = 1, fill_blank_count: int = 2, essay_count: int = 1, difficulty_level: int = 3, mistake_records: str = "" ) -> Dict[str, Any]: """ 便捷函数:生成考试题目 Args: db: 数据库会话 course_id: 课程ID position_id: 岗位ID single_choice_count: 单选题数量 multiple_choice_count: 多选题数量 true_false_count: 判断题数量 fill_blank_count: 填空题数量 essay_count: 问答题数量 difficulty_level: 难度等级(1-5) mistake_records: 错题记录JSON字符串 Returns: 生成结果 """ config = ExamGeneratorConfig( course_id=course_id, position_id=position_id, single_choice_count=single_choice_count, multiple_choice_count=multiple_choice_count, true_false_count=true_false_count, fill_blank_count=fill_blank_count, essay_count=essay_count, difficulty_level=difficulty_level, mistake_records=mistake_records, ) return await exam_generator_service.generate_exam(db, config)