exam.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. from fastapi import APIRouter, Depends, Request
  2. from sqlalchemy.orm import Session
  3. from pydantic import BaseModel, Field
  4. from typing import Optional
  5. from database import get_db
  6. from models.chat import AIMessage
  7. from services.qwen_service import qwen_service
  8. from utils.logger import logger
  9. router = APIRouter()
  10. class QuestionTypeItem(BaseModel):
  11. questionType: str = ""
  12. name: str = ""
  13. count: int = 0
  14. questionCount: int = 0
  15. scorePerQuestion: int = 0
  16. romanNumeral: str = ""
  17. class BuildPromptRequest(BaseModel):
  18. mode: str = ""
  19. client: str = ""
  20. projectType: str = ""
  21. examTitle: str = ""
  22. totalScore: int = 0
  23. questionTypes: list[QuestionTypeItem] = Field(default_factory=list)
  24. pptContent: str = ""
  25. @router.post("/exam/build_prompt")
  26. async def build_exam_prompt(
  27. request: Request,
  28. data: BuildPromptRequest,
  29. db: Session = Depends(get_db)
  30. ):
  31. """根据前端考试工坊参数生成提示词"""
  32. user = request.state.user
  33. if not user:
  34. return {"statusCode": 401, "msg": "未授权"}
  35. question_desc = []
  36. total_count = 0
  37. for item in data.questionTypes:
  38. count = item.count or item.questionCount or 0
  39. score = item.scorePerQuestion or 0
  40. qtype = item.questionType or item.name or "未命名题型"
  41. total_count += count
  42. question_desc.append(f"{qtype}{count}道,每道{score}分")
  43. question_text = ";".join(question_desc) if question_desc else "题型未提供"
  44. question_schema_lines = []
  45. for item in data.questionTypes:
  46. count = item.count or item.questionCount or 0
  47. score = item.scorePerQuestion or 0
  48. qtype = item.questionType or item.name or "未命名题型"
  49. if count <= 0:
  50. continue
  51. question_schema_lines.append(f"- {qtype}: {count}道,每道{score}分")
  52. question_schema = "\n".join(
  53. question_schema_lines) if question_schema_lines else "- 未提供有效题型"
  54. ppt_content = (data.pptContent or "").strip()
  55. if ppt_content:
  56. max_chars = 12000
  57. if len(ppt_content) > max_chars:
  58. head_len = max_chars // 2
  59. tail_len = max_chars - head_len
  60. ppt_content = (
  61. ppt_content[:head_len]
  62. + "\n\n(出题依据内容过长,已截断,以下为结尾片段)\n\n"
  63. + ppt_content[-tail_len:]
  64. )
  65. logger.info(
  66. f"[exam/build_prompt] pptContent truncated: original_len={len(data.pptContent)} kept_len={len(ppt_content)}"
  67. )
  68. prompt = (
  69. "请根据以下要求直接生成一份完整试卷,并严格返回纯 JSON,不要输出 markdown 代码块、解释说明或额外文字。\n"
  70. f"生成模式:{data.mode or '未指定'}\n"
  71. f"客户端:{data.client or '未指定'}\n"
  72. f"项目类型:{data.projectType or '未指定'}\n"
  73. f"考试标题:{data.examTitle or '未命名考试'}\n"
  74. f"总分:{data.totalScore or 0}\n"
  75. f"总题量:{total_count}\n"
  76. f"题型要求:{question_text}\n"
  77. f"出题依据内容:{ppt_content or '无'}\n"
  78. "出题依据内容是本次试卷的核心来源,所有题目必须围绕该内容中的知识点、术语、流程、规范要求和场景展开。\n"
  79. "如果出题依据内容中出现了章节、条款、培训主题或专业术语,题目必须优先考查这些内容,不能偏离到无关知识。\n"
  80. "单选题、多选题、判断题和简答题的题干、选项、答案解析都要与出题依据内容直接相关,不能泛泛而谈。\n"
  81. "请结合出题依据内容、工程类型和题型要求,生成有具体内容、具体选项、具体答案、具体解析的试卷。\n"
  82. "凡是题型配置中 count 大于 0 的题型,必须返回对应数量的非空题目,不能返回空数组,不能少题。\n"
  83. "即使出题依据内容较短,也要优先围绕已有内容中的关键词、术语、场景和要求组织出题,不能因为信息少而返回空题目。\n"
  84. "如果某题型要求生成 3 道题,就必须生成 3 道完整可作答的题目,少于要求数量视为不合格。\n"
  85. "禁止输出“选项A”“题目1”“桥梁工程相关单选题1”“题目内容”“解析内容”这类占位内容,所有题目必须是可直接展示和作答的真实内容。\n"
  86. "下面的 JSON 结构示例只用于说明字段格式,示例中的字符串不能原样照抄到最终结果中,最终返回的每个字符串都必须替换成结合出题依据生成的具体内容。\n"
  87. "JSON 输出结构必须符合以下格式:\n"
  88. "{\n"
  89. ' "title": "试卷标题",\n'
  90. ' "totalScore": 100,\n'
  91. ' "totalQuestions": 10,\n'
  92. ' "singleChoice": {"scorePerQuestion": 2, "totalScore": 20, "count": 10, "questions": [{"text": "<单选题题干>", "options": [{"key": "A", "text": "<选项A具体内容>"}, {"key": "B", "text": "<选项B具体内容>"}, {"key": "C", "text": "<选项C具体内容>"}, {"key": "D", "text": "<选项D具体内容>"}], "answer": "A", "analysis": "<解析内容>"}]},\n'
  93. ' "judge": {"scorePerQuestion": 2, "totalScore": 0, "count": 0, "questions": [{"text": "<判断题题干>", "answer": "正确", "analysis": "<解析内容>"}]},\n'
  94. ' "multiple": {"scorePerQuestion": 3, "totalScore": 0, "count": 0, "questions": [{"text": "<多选题题干>", "options": [{"key": "A", "text": "<选项A具体内容>"}, {"key": "B", "text": "<选项B具体内容>"}, {"key": "C", "text": "<选项C具体内容>"}, {"key": "D", "text": "<选项D具体内容>"}], "answers": ["A", "C"], "analysis": "<解析内容>"}]},\n'
  95. ' "short": {"scorePerQuestion": 10, "totalScore": 0, "count": 0, "questions": [{"text": "<简答题题干>", "outline": {"keyFactors": "<答题要点>", "measures": "<参考措施>"}}]}\n'
  96. "}\n"
  97. "请按下面的题型配置生成对应数量的题目,没有的题型 count 返回 0、questions 返回空数组:\n"
  98. f"{question_schema}"
  99. )
  100. return {
  101. "statusCode": 200,
  102. "msg": "success",
  103. "data": {"prompt": prompt}
  104. }
  105. class BuildSinglePromptRequest(BaseModel):
  106. question_type: str
  107. topic: str
  108. difficulty: str
  109. @router.post("/exam/build_single_prompt")
  110. async def build_single_question_prompt(
  111. request: Request,
  112. data: BuildSinglePromptRequest,
  113. db: Session = Depends(get_db)
  114. ):
  115. """生成单题提示词 - 对齐Go版本函数名"""
  116. user = request.state.user
  117. if not user:
  118. return {"statusCode": 401, "msg": "未授权"}
  119. prompt = f"""请生成1道关于{data.topic}的{data.question_type},难度为{data.difficulty}。"""
  120. return {
  121. "statusCode": 200,
  122. "msg": "success",
  123. "data": {"prompt": prompt}
  124. }
  125. class ModifyQuestionRequest(BaseModel):
  126. ai_conversation_id: int
  127. content: str
  128. @router.post("/re_modify_question")
  129. async def re_modify_question(
  130. request: Request,
  131. data: ModifyQuestionRequest,
  132. db: Session = Depends(get_db)
  133. ):
  134. """修改考试题目 - 实际修改ai_message表"""
  135. user = request.state.user
  136. if not user:
  137. return {"statusCode": 401, "msg": "未授权"}
  138. # 修改ai_message表中type='ai'的content
  139. result = db.query(AIMessage).filter(
  140. AIMessage.ai_conversation_id == data.ai_conversation_id,
  141. AIMessage.type == 'ai'
  142. ).update({"content": data.content})
  143. if result == 0:
  144. return {"statusCode": 404, "msg": "消息不存在"}
  145. db.commit()
  146. return {"statusCode": 200, "msg": "success"}
  147. class ReproduceSingleQuestionRequest(BaseModel):
  148. message: str = ""
  149. ai_conversation_id: Optional[int] = None
  150. regenerate_reason: str = ""
  151. @router.post("/re_produce_single_question")
  152. async def re_produce_single_question(
  153. request: Request,
  154. data: ReproduceSingleQuestionRequest,
  155. db: Session = Depends(get_db)
  156. ):
  157. """重新生成单题"""
  158. user = request.state.user
  159. if not user:
  160. return {"statusCode": 401, "msg": "未授权"}
  161. prompt = (data.message or "").strip()
  162. # 兼容旧版调用:未传 message 时,尝试根据会话和重生成原因构造提示词。
  163. if not prompt and data.ai_conversation_id:
  164. message = db.query(AIMessage).filter(
  165. AIMessage.ai_conversation_id == data.ai_conversation_id,
  166. AIMessage.type == 'ai'
  167. ).first()
  168. if not message:
  169. return {"statusCode": 404, "msg": "消息不存在"}
  170. prompt = (message.content or "").strip()
  171. if data.regenerate_reason:
  172. prompt = f"{prompt}\n\n请根据以下要求重新生成:{data.regenerate_reason}"
  173. if not prompt:
  174. return {"statusCode": 400, "msg": "缺少生成内容"}
  175. try:
  176. new_question = await qwen_service.chat([
  177. {"role": "user", "content": prompt}
  178. ])
  179. except Exception as e:
  180. return {"statusCode": 500, "msg": f"AI生成失败: {str(e)}"}
  181. return {
  182. "statusCode": 200,
  183. "msg": "success",
  184. "data": {
  185. "ai_conversation_id": data.ai_conversation_id,
  186. "new_question": new_question,
  187. "reply": new_question,
  188. "content": new_question,
  189. "message": new_question
  190. }
  191. }