assessment_service.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. """
  2. 水平检测服务
  3. 使用AI实时生成测试题目,评估用户水平
  4. """
  5. import json
  6. import uuid
  7. from typing import List, Optional, Dict
  8. from datetime import datetime
  9. from ..models.learning import (
  10. AssessmentQuestion, AssessmentResult, UserLevel, LearningPath
  11. )
  12. from .llm_service import get_llm
  13. from .learning_content import get_learning_path
  14. from .data_store import data_store
  15. # 测试配置
  16. TOTAL_QUESTIONS = 10
  17. # 各路径的测试分类定义
  18. PATH_CATEGORIES = {
  19. LearningPath.FRONTEND: {
  20. "html_css": "HTML/CSS基础",
  21. "javascript": "JavaScript核心",
  22. "vue": "Vue.js框架",
  23. "browser_apis": "浏览器API与DOM",
  24. },
  25. LearningPath.BACKEND: {
  26. "python": "Python基础",
  27. "api_design": "REST API设计",
  28. "database": "数据库操作",
  29. "system_design": "系统设计",
  30. },
  31. LearningPath.FULLSTACK: {
  32. "html_css": "HTML/CSS",
  33. "javascript": "JavaScript",
  34. "python": "Python",
  35. "vue": "Vue.js",
  36. "api_design": "API设计",
  37. },
  38. }
  39. def get_categories_for_path(path_type: str) -> Dict[str, str]:
  40. """获取指定路径的测试分类"""
  41. try:
  42. path = LearningPath(path_type)
  43. return PATH_CATEGORIES.get(path, PATH_CATEGORIES[LearningPath.FRONTEND])
  44. except ValueError:
  45. return PATH_CATEGORIES[LearningPath.FRONTEND]
  46. def get_modules_for_path(path_type: str) -> List[Dict]:
  47. """获取指定路径的模块信息"""
  48. try:
  49. path = LearningPath(path_type)
  50. path_data = get_learning_path(path)
  51. return [
  52. {
  53. "id": m.id,
  54. "title": m.title,
  55. "description": m.description,
  56. "lessons": [l.title for l in m.lessons],
  57. }
  58. for m in path_data.modules
  59. ]
  60. except ValueError:
  61. return []
  62. # ===== AI题目生成 =====
  63. def generate_assessment_questions(path_type: str) -> List[AssessmentQuestion]:
  64. """使用AI生成测试题目"""
  65. categories = get_categories_for_path(path_type)
  66. modules = get_modules_for_path(path_type)
  67. categories_text = ", ".join([f"{k}({v})" for k, v in categories.items()])
  68. modules_text = json.dumps([m["title"] for m in modules], ensure_ascii=False)
  69. prompt = """请为"{path_type}"学习路径生成{total}道编程水平测试题。
  70. 测试分类:{categories}
  71. 涉及模块:{modules}
  72. 要求:
  73. 1. 混合4种题型:choice(选择题/知识题)、code_output(预测输出)、code_fill(代码填空)、bug_fix(找Bug)
  74. 2. 每道题包含:id(q1到q10)、category(分类key)、difficulty(难度1-5)、question_type(题型)、content(题目描述,用中文)、code_snippet(代码片段,选择题填null)、options(A/B/C/D选项,用中文)、correct_answer(正确答案字母)、explanation(解析,用中文)
  75. 3. 代码题必须包含5-15行的代码片段
  76. 4. 难度分布:简单30%、中等40%、困难30%
  77. 5. 每个分类至少2道题
  78. 6. 所有题目内容、选项、解析必须用中文输出
  79. 只输出JSON数组:
  80. [
  81. {{"id":"q1","category":"html_css","difficulty":2,"question_type":"choice","content":"关于HTML语义化标签的说法,正确的是?","code_snippet":null,"options":["A. <div>是语义化标签","B. <header>表示页面头部区域","C. <span>是块级元素","D. <article>只能用于博客文章"],"correct_answer":"B","explanation":"<header>是HTML5语义化标签,表示页面或区块的头部区域。div是无语义容器,span是行内元素,article可用于任何独立内容。"}},
  82. {{"id":"q2","category":"javascript","difficulty":3,"question_type":"code_output","content":"以下代码的输出是什么?","code_snippet":"const arr = [1, 2, 3];\\nconst result = arr.map(x => x * 2).filter(x => x > 3);\\nconsole.log(result);","options":["A. [2, 4, 6]","B. [4, 6]","C. [2, 4]","D. [6]"],"correct_answer":"B","explanation":"map将每个元素乘2得到[2,4,6],filter筛选大于3的元素得到[4,6]。"}}
  83. ]""".format(
  84. path_type=path_type,
  85. total=TOTAL_QUESTIONS,
  86. categories=categories_text,
  87. modules=modules_text,
  88. )
  89. try:
  90. llm = get_llm()
  91. messages = [{"role": "user", "content": prompt}]
  92. response = llm.invoke(messages)
  93. content = response if isinstance(response, str) else str(response)
  94. # 提取JSON
  95. start_idx = content.find("[")
  96. end_idx = content.rfind("]") + 1
  97. if start_idx == -1 or end_idx == 0:
  98. print("[ERROR] AI返回格式错误,无法解析题目")
  99. return _get_fallback_questions(path_type)
  100. questions_data = json.loads(content[start_idx:end_idx])
  101. questions = []
  102. for q in questions_data:
  103. questions.append(AssessmentQuestion(
  104. id=q["id"],
  105. category=q["category"],
  106. difficulty=q.get("difficulty", 3),
  107. content=q["content"],
  108. question_type=q.get("question_type", "choice"),
  109. code_snippet=q.get("code_snippet"),
  110. options=q["options"],
  111. correct_answer=q["correct_answer"],
  112. explanation=q["explanation"],
  113. ))
  114. print(f"[OK] AI生成了 {len(questions)} 道题目")
  115. return questions[:TOTAL_QUESTIONS]
  116. except Exception as e:
  117. print(f"[ERROR] AI生成题目失败: {e}")
  118. return _get_fallback_questions(path_type)
  119. def _get_fallback_questions(path_type: str) -> List[AssessmentQuestion]:
  120. """备用题目(当AI生成失败时)"""
  121. categories = get_categories_for_path(path_type)
  122. cat_keys = list(categories.keys())
  123. fallback = []
  124. for i in range(TOTAL_QUESTIONS):
  125. cat = cat_keys[i % len(cat_keys)]
  126. fallback.append(AssessmentQuestion(
  127. id=f"q{i+1}",
  128. category=cat,
  129. difficulty=2,
  130. content=f"这是一道关于{categories[cat]}的测试题(备用题目)",
  131. question_type="choice",
  132. code_snippet=None,
  133. options=["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"],
  134. correct_answer="A",
  135. explanation="备用题目解析",
  136. ))
  137. return fallback
  138. # ===== 评估会话管理 =====
  139. class AssessmentSession:
  140. """评估会话"""
  141. def __init__(self, session_id: str, path_type: str, questions: List[AssessmentQuestion], user_id: str = "default"):
  142. self.session_id = session_id
  143. self.path_type = path_type
  144. self.user_id = user_id
  145. self.questions = questions
  146. self.answers: Dict[str, str] = {} # question_id -> answer
  147. self.current_index = 0
  148. self.created_at = datetime.now()
  149. @property
  150. def is_completed(self) -> bool:
  151. return self.current_index >= len(self.questions)
  152. @property
  153. def total_questions(self) -> int:
  154. return len(self.questions)
  155. def get_current_question(self) -> Optional[AssessmentQuestion]:
  156. if self.current_index < len(self.questions):
  157. return self.questions[self.current_index]
  158. return None
  159. def submit_answer(self, question_id: str, answer: str) -> Dict:
  160. """提交答案"""
  161. question = self.questions[self.current_index]
  162. if question.id != question_id:
  163. return {"error": "题目ID不匹配"}
  164. self.answers[question_id] = answer
  165. is_correct = answer.upper() == question.correct_answer.upper()
  166. self.current_index += 1
  167. next_question = self.get_current_question()
  168. return {
  169. "is_correct": is_correct,
  170. "correct_answer": question.correct_answer,
  171. "explanation": question.explanation,
  172. "next_question": next_question.model_dump() if next_question else None,
  173. "current_index": self.current_index,
  174. "total_questions": self.total_questions,
  175. "is_completed": self.is_completed,
  176. }
  177. # 会话存储
  178. _sessions: Dict[str, AssessmentSession] = {}
  179. def create_session(path_type: str, user_id: str = "default") -> AssessmentSession:
  180. """创建新的评估会话"""
  181. session_id = str(uuid.uuid4())[:8]
  182. questions = generate_assessment_questions(path_type)
  183. session = AssessmentSession(session_id, path_type, questions, user_id=user_id)
  184. _sessions[session_id] = session
  185. print(f"[INFO] 创建评估会话 {session_id}(用户: {user_id}),{len(questions)}道题")
  186. return session
  187. def get_session(session_id: str) -> Optional[AssessmentSession]:
  188. """获取评估会话"""
  189. return _sessions.get(session_id)
  190. def delete_session(session_id: str):
  191. """删除评估会话"""
  192. if session_id in _sessions:
  193. del _sessions[session_id]
  194. # ===== 评分与结果 =====
  195. def calculate_result(session: AssessmentSession, user_id: str = "default") -> AssessmentResult:
  196. """计算评估结果"""
  197. questions = session.questions
  198. answers = session.answers
  199. categories = get_categories_for_path(session.path_type)
  200. # 统计
  201. total = len(questions)
  202. correct = 0
  203. category_correct = {cat: 0 for cat in categories}
  204. category_total = {cat: 0 for cat in categories}
  205. for q in questions:
  206. user_answer = answers.get(q.id, "")
  207. is_correct = user_answer.upper() == q.correct_answer.upper()
  208. if is_correct:
  209. correct += 1
  210. if q.category in category_correct:
  211. category_correct[q.category] += 1
  212. if q.category in category_total:
  213. category_total[q.category] += 1
  214. # 总分
  215. score = (correct / total * 100) if total > 0 else 0
  216. # 各分类得分
  217. category_scores = {}
  218. for cat in categories:
  219. if category_total.get(cat, 0) > 0:
  220. category_scores[cat] = round(
  221. category_correct[cat] / category_total[cat] * 100, 1
  222. )
  223. else:
  224. category_scores[cat] = 0.0
  225. # 确定水平
  226. if score >= 80:
  227. level = UserLevel.ADVANCED
  228. elif score >= 50:
  229. level = UserLevel.INTERMEDIATE
  230. else:
  231. level = UserLevel.BEGINNER
  232. # 推荐开始模块
  233. recommended_module = _recommend_module(session.path_type, level, category_scores)
  234. return AssessmentResult(
  235. user_id=user_id,
  236. path_type=session.path_type,
  237. total_questions=total,
  238. correct_count=correct,
  239. score=round(score, 1),
  240. level=level,
  241. category_scores=category_scores,
  242. recommended_start_module=recommended_module,
  243. completed_at=datetime.now(),
  244. is_current=True,
  245. )
  246. def _recommend_module(
  247. path_type: str, level: UserLevel, category_scores: Dict[str, float]
  248. ) -> str:
  249. """根据水平推荐开始模块"""
  250. try:
  251. path = LearningPath(path_type)
  252. path_data = get_learning_path(path)
  253. modules = sorted(path_data.modules, key=lambda m: m.order)
  254. if level == UserLevel.BEGINNER:
  255. return modules[0].id
  256. # 中级:检查各分类得分,跳过掌握较好的模块
  257. for module in modules:
  258. # 检查模块相关的分类得分
  259. module_cats = _get_module_categories(module.id)
  260. avg_score = sum(
  261. category_scores.get(cat, 0) for cat in module_cats
  262. ) / max(len(module_cats), 1)
  263. if avg_score < 70:
  264. return module.id
  265. return modules[0].id
  266. except Exception:
  267. return ""
  268. def _get_module_categories(module_id: str) -> List[str]:
  269. """根据模块ID获取相关分类"""
  270. mapping = {
  271. "fe-html-css": ["html_css"],
  272. "fe-javascript": ["javascript"],
  273. "fe-vue": ["vue"],
  274. "fe-project": ["html_css", "javascript", "vue"],
  275. "be-python": ["python"],
  276. "be-api": ["api_design"],
  277. "be-system": ["system_design", "database"],
  278. "be-project": ["python", "api_design", "system_design"],
  279. "fs-web-basics": ["html_css", "javascript"],
  280. "fs-frontend": ["vue"],
  281. "fs-backend": ["python", "api_design"],
  282. "fs-fullstack": ["html_css", "javascript", "python", "vue", "api_design"],
  283. }
  284. return mapping.get(module_id, [])
  285. # ===== 顶层API =====
  286. def start_assessment(path_type: str, user_id: str = "default") -> Dict:
  287. """开始评估"""
  288. session = create_session(path_type, user_id=user_id)
  289. question = session.get_current_question()
  290. return {
  291. "session_id": session.session_id,
  292. "question": question.model_dump() if question else None,
  293. "current_index": session.current_index,
  294. "total_questions": session.total_questions,
  295. }
  296. def submit_answer(session_id: str, question_id: str, answer: str, user_id: str = "default") -> Dict:
  297. """提交答案"""
  298. session = get_session(session_id)
  299. if not session:
  300. return {"error": "会话不存在或已过期"}
  301. result = session.submit_answer(question_id, answer)
  302. # 如果完成,计算结果
  303. if result.get("is_completed"):
  304. assessment_result = calculate_result(session, user_id=user_id)
  305. data_store.save_assessment(assessment_result)
  306. result["assessment_result"] = assessment_result
  307. # 发放XP奖励
  308. from .gamification_service import get_gamification_service
  309. svc = get_gamification_service()
  310. svc.award_xp(user_id, 20, f"完成水平检测: {session.path_type}")
  311. if assessment_result.score >= 100:
  312. svc.award_xp(user_id, 30, "满分通关奖励")
  313. svc.check_perfect_score(user_id)
  314. svc.check_speed_demon(user_id)
  315. return result
  316. def complete_assessment(session_id: str, user_id: str = "default") -> Optional[AssessmentResult]:
  317. """强制完成评估(跳过剩余题目)"""
  318. session = get_session(session_id)
  319. if not session:
  320. return None
  321. result = calculate_result(session, user_id=user_id)
  322. data_store.save_assessment(result)
  323. delete_session(session_id)
  324. # 发放XP奖励
  325. from .gamification_service import get_gamification_service
  326. svc = get_gamification_service()
  327. svc.award_xp(user_id, 20, f"完成水平检测: {session.path_type}")
  328. if result.score >= 100:
  329. svc.award_xp(user_id, 30, "满分通关奖励")
  330. svc.check_perfect_score(user_id)
  331. # 检查速度徽章也在这里
  332. svc.check_speed_demon(user_id)
  333. return result