mastery_service.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. # -*- coding: utf-8 -*-
  2. """
  3. 弱点建模与掌握度更新服务
  4. 测评 → 诊断(弱点更新) → 每日一练(推送) → 训练/辅导 → 再测评 闭环核心逻辑
  5. """
  6. from datetime import datetime, date
  7. from typing import Dict, List, Optional, Tuple
  8. from sqlalchemy.orm import Session
  9. from backend.models import (
  10. Question,
  11. AnswerRecord,
  12. KnowledgePoint,
  13. QuestionKnowledgePoint,
  14. StudentKnowledgeMastery,
  15. StudentProfile,
  16. DailyRecommendation,
  17. )
  18. # 弱点更新参数
  19. ALPHA = 1 # 答错时 weakness_weight 增加系数
  20. BETA = 5 # 答错时 mastery_score 降低系数
  21. GAMMA = 1 # 答对时 weakness_weight 衰减系数
  22. MASTERY_GAIN = 3 # 答对时 mastery_score 提升量
  23. MASTERY_THRESHOLD = 80 # 推荐时排除已掌握的知识点(mastery_score > 此值)
  24. def _get_knowledge_points_for_question(
  25. db: Session, question_id: int, question: Question = None
  26. ) -> List[Tuple[int, int]]:
  27. """
  28. 获取题目关联的知识点 (knowledge_point_id, weight) 列表。
  29. 优先从 question_knowledge_points 读取;若无则从 Question.knowledge_point 字符串解析并懒建映射。
  30. """
  31. qkps = (
  32. db.query(QuestionKnowledgePoint)
  33. .filter(QuestionKnowledgePoint.question_id == question_id)
  34. .all()
  35. )
  36. if qkps:
  37. return [(qkp.knowledge_point_id, qkp.weight or 1) for qkp in qkps]
  38. # 回退:从 Question.knowledge_point 字符串解析
  39. if question is None:
  40. question = db.query(Question).filter(Question.id == question_id).first()
  41. if not question or not (question.knowledge_point or "").strip():
  42. return []
  43. kp_name = (question.knowledge_point or "").strip()
  44. kp = db.query(KnowledgePoint).filter(KnowledgePoint.name == kp_name).first()
  45. if not kp:
  46. kp = KnowledgePoint(name=kp_name)
  47. db.add(kp)
  48. db.flush() # 获取 id
  49. # 懒建 question_knowledge_points
  50. qkp = QuestionKnowledgePoint(
  51. question_id=question_id,
  52. knowledge_point_id=kp.id,
  53. weight=1,
  54. )
  55. db.add(qkp)
  56. db.flush()
  57. return [(kp.id, 1)]
  58. def _get_or_create_profile(db: Session, user_id: int) -> StudentProfile:
  59. """获取或创建学生学习档案"""
  60. profile = db.query(StudentProfile).filter(StudentProfile.user_id == user_id).first()
  61. if not profile:
  62. profile = StudentProfile(user_id=user_id)
  63. db.add(profile)
  64. db.flush()
  65. return profile
  66. def _get_or_create_mastery(
  67. db: Session, user_id: int, knowledge_point_id: int
  68. ) -> StudentKnowledgeMastery:
  69. """获取或创建学生-知识点掌握度记录"""
  70. row = (
  71. db.query(StudentKnowledgeMastery)
  72. .filter(
  73. StudentKnowledgeMastery.user_id == user_id,
  74. StudentKnowledgeMastery.knowledge_point_id == knowledge_point_id,
  75. )
  76. .first()
  77. )
  78. if not row:
  79. row = StudentKnowledgeMastery(
  80. user_id=user_id,
  81. knowledge_point_id=knowledge_point_id,
  82. )
  83. db.add(row)
  84. db.flush()
  85. return row
  86. def update_mastery_from_answers(
  87. db: Session,
  88. user_id: int,
  89. answers: List[dict],
  90. session_kind: str = "practice",
  91. include_diagnostic_in_mastery: bool = False,
  92. ) -> None:
  93. """
  94. 根据一次测评的答题记录更新 student_knowledge_mastery 与 student_profiles。
  95. answers: [{"question_id": int, "selected_option": str}, ...]
  96. 内部会查询 Question 判断 is_correct。
  97. """
  98. # 诊断/再测评用于弱点建模与推荐题选择,不直接影响“学情分析展示口径”
  99. # (掌握度颜色/雷达图/章节均值/最近练习),避免用户感知到“被重置”。
  100. is_diagnostic = session_kind in {"diagnostic", "re_evaluate"}
  101. update_mastery_score = not is_diagnostic
  102. update_last_practiced_at = not is_diagnostic
  103. if is_diagnostic and include_diagnostic_in_mastery:
  104. # 兼容式开关:仅在显式开启时,诊断/再测评也计入掌握度分值
  105. update_mastery_score = True
  106. update_last_practiced_at = True
  107. profile = _get_or_create_profile(db, user_id)
  108. profile.total_questions_answered += len(answers)
  109. profile.last_active_at = datetime.utcnow()
  110. for item in answers:
  111. qid = item.get("question_id")
  112. selected = (item.get("selected_option") or "").strip()
  113. if not qid or not selected:
  114. continue
  115. question = db.query(Question).filter(Question.id == qid).first()
  116. if not question:
  117. continue
  118. is_correct = selected == question.correct_option
  119. kp_list = _get_knowledge_points_for_question(db, qid, question)
  120. for kp_id, weight in kp_list:
  121. mastery = _get_or_create_mastery(db, user_id, kp_id)
  122. if is_correct:
  123. mastery.correct_count = (mastery.correct_count or 0) + 1
  124. mastery.weakness_weight = max(
  125. 0, (mastery.weakness_weight or 0) - GAMMA * weight
  126. )
  127. if update_mastery_score:
  128. mastery.mastery_score = min(
  129. 100, (mastery.mastery_score or 0) + MASTERY_GAIN
  130. )
  131. else:
  132. mastery.wrong_count = (mastery.wrong_count or 0) + 1
  133. mastery.weakness_weight = (mastery.weakness_weight or 0) + ALPHA * weight
  134. if update_mastery_score:
  135. mastery.mastery_score = max(
  136. 0, (mastery.mastery_score or 0) - BETA * weight
  137. )
  138. if update_last_practiced_at:
  139. mastery.last_practiced_at = datetime.utcnow()
  140. def mark_daily_recommendations_done(
  141. db: Session,
  142. user_id: int,
  143. question_ids: List[int],
  144. today: date = None,
  145. answer_record_ids: Optional[Dict[int, int]] = None,
  146. ) -> None:
  147. """将今日推荐中已作答的题目标记为完成"""
  148. if today is None:
  149. today = date.today()
  150. if not question_ids:
  151. return
  152. if not answer_record_ids:
  153. db.query(DailyRecommendation).filter(
  154. DailyRecommendation.user_id == user_id,
  155. DailyRecommendation.recommend_date == today,
  156. DailyRecommendation.question_id.in_(question_ids),
  157. ).update({DailyRecommendation.is_done: 1}, synchronize_session=False)
  158. return
  159. # 精细化更新:为每道题写入对应的 answer_record_id(用于闭环追踪)
  160. for qid in set(question_ids):
  161. arid = answer_record_ids.get(qid)
  162. if not arid:
  163. # 没拿到 answer_record_id 就只标记完成,避免影响主流程
  164. continue
  165. db.query(DailyRecommendation).filter(
  166. DailyRecommendation.user_id == user_id,
  167. DailyRecommendation.recommend_date == today,
  168. DailyRecommendation.question_id == qid,
  169. ).update(
  170. {DailyRecommendation.is_done: 1, DailyRecommendation.answer_record_id: arid},
  171. synchronize_session=False,
  172. )
  173. # 兜底:对那些没命中 answer_record_ids 的题,至少标记完成
  174. missing_qids = [qid for qid in question_ids if qid not in answer_record_ids]
  175. if missing_qids:
  176. db.query(DailyRecommendation).filter(
  177. DailyRecommendation.user_id == user_id,
  178. DailyRecommendation.recommend_date == today,
  179. DailyRecommendation.question_id.in_(missing_qids),
  180. ).update({DailyRecommendation.is_done: 1}, synchronize_session=False)