teacher_report_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. # -*- coding: utf-8 -*-
  2. """
  3. 教师端:班级学情报表聚合服务
  4. 口径说明(用于前端展示):
  5. - 知识点掌握:使用 `student_knowledge_mastery.mastery_score`,并把“缺失记录”视为 0(与前端学生端 outer join 后的默认 0 一致)。
  6. - 高频错题知识点排行:使用 `student_knowledge_mastery.wrong_count` 在班级内求和。
  7. - 知识点掌握分层:对“每个知识点在全班的平均掌握度(0-100)”进行分桶:<=40/<=60/<=80/>80。
  8. - 实时进度:按 `daily_recommendations` 的 recommend_date 统计完成率(is_done=1)。
  9. - 提升效果:用最近 7 天 vs 前 7 天的整体正确率(AnswerRecord 维度)与完成率(DailyRecommendation 维度)对比。
  10. """
  11. from datetime import date, datetime, timedelta
  12. from typing import Any, Dict, List, Optional, Tuple
  13. from sqlalchemy import case, func
  14. from sqlalchemy.orm import Session
  15. from backend.models import (
  16. AnswerRecord,
  17. DailyRecommendation,
  18. KnowledgePoint,
  19. StudentKnowledgeMastery,
  20. User,
  21. )
  22. # 复用已存在的知识点/题目初始化逻辑,避免 tables 为空导致全班统计为空
  23. from backend.services.recommendation import _ensure_knowledge_points_from_questions, _sync_questions_from_chemistry_bank
  24. def _get_teacher_class_students(db: Session, teacher_id: int) -> tuple[str, List[int]]:
  25. teacher = db.query(User).filter(User.id == teacher_id).first()
  26. if not teacher or not teacher.class_name:
  27. raise ValueError("teacher class_name is empty")
  28. student_rows = (
  29. db.query(User.id)
  30. .filter(User.role == "student")
  31. .filter(User.class_name == teacher.class_name)
  32. .all()
  33. )
  34. student_ids = [int(r[0]) for r in student_rows]
  35. return teacher.class_name, student_ids
  36. def _bucket_tier(avg_score: float) -> Dict[str, Any]:
  37. # 与前端 Analysis.jsx masteryColor() 的分级保持一致:
  38. # <=40 红;<=60 橙;<=80 黄;>80 绿
  39. if avg_score <= 40:
  40. return {"tier": "薄弱", "min": 0, "max": 40}
  41. if avg_score <= 60:
  42. return {"tier": "待提升", "min": 41, "max": 60}
  43. if avg_score <= 80:
  44. return {"tier": "良好", "min": 61, "max": 80}
  45. return {"tier": "优秀", "min": 81, "max": 100}
  46. def get_teacher_class_report(
  47. db: Session,
  48. teacher_id: int,
  49. days: int = 14,
  50. weak_top: int = 5,
  51. wrong_top: int = 10,
  52. ) -> Dict[str, Any]:
  53. """
  54. 返回教师端班级学情报表(用于前端可视化)。
  55. """
  56. class_name, student_ids = _get_teacher_class_students(db, teacher_id)
  57. # 若 teacher 班级没有学生,则返回空壳结构(前端可提示“暂无数据”)
  58. students_count = len(student_ids)
  59. if students_count == 0:
  60. return {
  61. "classInfo": {"className": class_name, "studentsCount": 0},
  62. "overall": {"avgMasteryScore": 0, "masteredKnowledgePointRate": 0, "totalKnowledgePoints": 0},
  63. "wrongKnowledgePointRanking": [],
  64. "weakKnowledgePoints": [],
  65. "tierDistribution": {"tiers": []},
  66. "progress": {
  67. "completionTrend": [],
  68. "accuracyTrend": [],
  69. "improvementSummary": {},
  70. },
  71. }
  72. # 确保 questions / knowledge_points 至少有基础数据可用于聚合
  73. _sync_questions_from_chemistry_bank(db)
  74. _ensure_knowledge_points_from_questions(db)
  75. knowledge_points = db.query(KnowledgePoint.id, KnowledgePoint.name, KnowledgePoint.chapter).all()
  76. total_kp = len(knowledge_points)
  77. if total_kp == 0:
  78. return {
  79. "classInfo": {"className": class_name, "studentsCount": students_count},
  80. "overall": {"avgMasteryScore": 0, "masteredKnowledgePointRate": 0, "totalKnowledgePoints": 0},
  81. "wrongKnowledgePointRanking": [],
  82. "weakKnowledgePoints": [],
  83. "tierDistribution": {"tiers": []},
  84. "progress": {
  85. "completionTrend": [],
  86. "accuracyTrend": [],
  87. "improvementSummary": {},
  88. },
  89. }
  90. # 一次性拉取:全班学生在每个知识点的掌握/错题聚合(缺失按 0 处理)
  91. mastery_rows = (
  92. db.query(
  93. StudentKnowledgeMastery.knowledge_point_id,
  94. StudentKnowledgeMastery.mastery_score,
  95. StudentKnowledgeMastery.wrong_count,
  96. )
  97. .filter(StudentKnowledgeMastery.user_id.in_(student_ids))
  98. .all()
  99. )
  100. sum_mastery_by_kp: Dict[int, int] = {int(kp_id): 0 for kp_id, _, _ in knowledge_points}
  101. sum_wrong_by_kp: Dict[int, int] = {int(kp_id): 0 for kp_id, _, _ in knowledge_points}
  102. participant_count_by_kp: Dict[int, int] = {int(kp_id): 0 for kp_id, _, _ in knowledge_points}
  103. for kp_id, mastery_score, wrong_count in mastery_rows:
  104. kp_id = int(kp_id)
  105. sum_mastery_by_kp[kp_id] = sum_mastery_by_kp.get(kp_id, 0) + int(mastery_score or 0)
  106. sum_wrong_by_kp[kp_id] = sum_wrong_by_kp.get(kp_id, 0) + int(wrong_count or 0)
  107. participant_count_by_kp[kp_id] = participant_count_by_kp.get(kp_id, 0) + 1
  108. # 每个知识点的“全班平均掌握度”
  109. avg_mastery_by_kp: Dict[int, float] = {}
  110. for kp_id in sum_mastery_by_kp.keys():
  111. avg_mastery_by_kp[kp_id] = (sum_mastery_by_kp[kp_id] / students_count) if students_count else 0.0
  112. # 业务口径优化:
  113. # - 旧口径:全量知识点(未覆盖知识点按 0),容易被大量未练习知识点稀释为 0
  114. # - 新口径:优先使用“班级有记录的活跃知识点”(student_knowledge_mastery 中出现过)
  115. # 若活跃知识点为空,再回退到全量知识点,避免无数据时报错。
  116. active_kp_ids = {
  117. int(getattr(r, "knowledge_point_id", r[0]))
  118. for r in mastery_rows
  119. if int(getattr(r, "knowledge_point_id", r[0])) in avg_mastery_by_kp
  120. }
  121. metric_kp_ids = sorted(active_kp_ids) if active_kp_ids else sorted(avg_mastery_by_kp.keys())
  122. metric_kp_count = len(metric_kp_ids)
  123. # 活跃口径下,按“参与该知识点练习的学生”计算知识点均值,避免被未覆盖学生二次稀释。
  124. metric_scores = []
  125. for kp_id in metric_kp_ids:
  126. participants = int(participant_count_by_kp.get(kp_id, 0))
  127. if participants > 0:
  128. metric_scores.append(float(sum_mastery_by_kp.get(kp_id, 0)) / participants)
  129. else:
  130. metric_scores.append(float(avg_mastery_by_kp.get(kp_id, 0.0)))
  131. overall_avg_mastery = int(round(sum(metric_scores) / metric_kp_count)) if metric_kp_count else 0
  132. mastered_kp_count = sum(1 for v in metric_scores if float(v) >= 80.0)
  133. mastered_knowledge_point_rate = int(round(mastered_kp_count / metric_kp_count * 100)) if metric_kp_count else 0
  134. # 知识点薄弱列表:按平均掌握度升序(错题数降序作为 tie-breaker)
  135. kp_name_map = {int(kp_id): name for kp_id, name, _ in knowledge_points}
  136. weak_items = [
  137. {
  138. "knowledgePointId": kp_id,
  139. "name": kp_name_map.get(kp_id, ""),
  140. "avgMasteryScore": int(round(avg_mastery_by_kp.get(kp_id, 0.0))),
  141. "wrongCountTotal": int(sum_wrong_by_kp.get(kp_id, 0)),
  142. }
  143. for kp_id in avg_mastery_by_kp.keys()
  144. ]
  145. weak_items.sort(key=lambda x: (x["avgMasteryScore"], -x["wrongCountTotal"]))
  146. weak_knowledge_points = weak_items[: max(1, int(weak_top))]
  147. # 高频错题知识点排行:按错题数降序
  148. wrong_items = [
  149. {
  150. "knowledgePointId": kp_id,
  151. "name": kp_name_map.get(kp_id, ""),
  152. "wrongCountTotal": int(sum_wrong_by_kp.get(kp_id, 0)),
  153. "avgMasteryScore": int(round(avg_mastery_by_kp.get(kp_id, 0.0))),
  154. }
  155. for kp_id in avg_mastery_by_kp.keys()
  156. ]
  157. wrong_items.sort(key=lambda x: x["wrongCountTotal"], reverse=True)
  158. wrong_items = [x for x in wrong_items if x["wrongCountTotal"] > 0]
  159. wrong_knowledge_point_ranking = wrong_items[: max(1, int(wrong_top))]
  160. # 知识点掌握分层:对“每个知识点平均掌握度”分桶
  161. tier_buckets = [
  162. {"tier": "薄弱", "min": 0, "max": 40},
  163. {"tier": "待提升", "min": 41, "max": 60},
  164. {"tier": "良好", "min": 61, "max": 80},
  165. {"tier": "优秀", "min": 81, "max": 100},
  166. ]
  167. tier_counts = {b["tier"]: 0 for b in tier_buckets}
  168. for kp_id in avg_mastery_by_kp.keys():
  169. b = _bucket_tier(avg_mastery_by_kp.get(kp_id, 0.0))
  170. tier_counts[b["tier"]] = tier_counts.get(b["tier"], 0) + 1
  171. tier_distribution = []
  172. for b in tier_buckets:
  173. cnt = int(tier_counts.get(b["tier"], 0))
  174. pct = float(cnt) / total_kp * 100 if total_kp else 0.0
  175. tier_distribution.append(
  176. {
  177. "tier": b["tier"],
  178. "min": b["min"],
  179. "max": b["max"],
  180. "count": cnt,
  181. "percent": round(pct, 2),
  182. }
  183. )
  184. # 进度追踪:completionTrend(每日一练完成率)+ accuracyTrend(答题正确率)
  185. end_day = date.today()
  186. start_day = end_day - timedelta(days=max(1, int(days)) - 1)
  187. day_list = [start_day + timedelta(days=i) for i in range((end_day - start_day).days + 1)]
  188. # 1) daily_recommendations completionTrend
  189. completion_map: Dict[date, Dict[str, Any]] = {d: {"done": 0, "total": 0} for d in day_list}
  190. completion_rows = (
  191. db.query(
  192. DailyRecommendation.recommend_date,
  193. func.sum(case((DailyRecommendation.is_done == 1, 1), else_=0)).label("done_cnt"),
  194. func.count(DailyRecommendation.id).label("total_cnt"),
  195. )
  196. .filter(DailyRecommendation.user_id.in_(student_ids))
  197. .filter(DailyRecommendation.recommend_date >= start_day)
  198. .filter(DailyRecommendation.recommend_date <= end_day)
  199. .group_by(DailyRecommendation.recommend_date)
  200. .all()
  201. )
  202. for rec_date, done_cnt, total_cnt in completion_rows:
  203. rec_date = rec_date if isinstance(rec_date, date) else rec_date.date()
  204. completion_map[rec_date] = {"done": int(done_cnt or 0), "total": int(total_cnt or 0)}
  205. completion_trend = []
  206. for d in day_list:
  207. info = completion_map.get(d, {"done": 0, "total": 0})
  208. done = int(info.get("done", 0))
  209. total = int(info.get("total", 0))
  210. completion_rate = (done / total * 100.0) if total else None
  211. completion_trend.append(
  212. {
  213. "date": d.isoformat(),
  214. "done": done,
  215. "total": total,
  216. "completionRate": round(completion_rate, 2) if completion_rate is not None else None,
  217. }
  218. )
  219. # 2) answer_records accuracyTrend
  220. start_dt = datetime.combine(start_day, datetime.min.time())
  221. accuracy_map: Dict[date, Dict[str, Any]] = {d: {"correct": 0, "total": 0} for d in day_list}
  222. accuracy_rows = (
  223. db.query(
  224. func.date(AnswerRecord.answered_at).label("day"),
  225. func.sum(case((AnswerRecord.is_correct == True, 1), else_=0)).label("correct_cnt"),
  226. func.count(AnswerRecord.id).label("total_cnt"),
  227. )
  228. .filter(AnswerRecord.user_id.in_(student_ids))
  229. .filter(AnswerRecord.answered_at >= start_dt)
  230. .filter(AnswerRecord.answered_at <= datetime.combine(end_day, datetime.max.time()))
  231. .group_by(func.date(AnswerRecord.answered_at))
  232. .all()
  233. )
  234. for day_val, correct_cnt, total_cnt in accuracy_rows:
  235. # day_val 通常是 datetime/date 的一类
  236. day_obj = day_val if isinstance(day_val, date) else day_val.date()
  237. accuracy_map[day_obj] = {"correct": int(correct_cnt or 0), "total": int(total_cnt or 0)}
  238. accuracy_trend = []
  239. for d in day_list:
  240. info = accuracy_map.get(d, {"correct": 0, "total": 0})
  241. correct = int(info.get("correct", 0))
  242. total = int(info.get("total", 0))
  243. accuracy_rate = (correct / total * 100.0) if total else None
  244. accuracy_trend.append(
  245. {
  246. "date": d.isoformat(),
  247. "correct": correct,
  248. "total": total,
  249. "accuracyRate": round(accuracy_rate, 2) if accuracy_rate is not None else None,
  250. }
  251. )
  252. # 3) improvementSummary:last 7 days vs prev 7 days(加权平均)
  253. def _range_rates(range_start: date, range_end: date) -> Tuple[Optional[float], Optional[float]]:
  254. # accuracy
  255. acc = (
  256. db.query(
  257. func.sum(case((AnswerRecord.is_correct == True, 1), else_=0)).label("correct_cnt"),
  258. func.count(AnswerRecord.id).label("total_cnt"),
  259. )
  260. .filter(AnswerRecord.user_id.in_(student_ids))
  261. .filter(AnswerRecord.answered_at >= datetime.combine(range_start, datetime.min.time()))
  262. .filter(AnswerRecord.answered_at <= datetime.combine(range_end, datetime.max.time()))
  263. .one()
  264. )
  265. correct_cnt, total_cnt = int(acc.correct_cnt or 0), int(acc.total_cnt or 0)
  266. accuracy_rate = (correct_cnt / total_cnt * 100.0) if total_cnt else None
  267. # completion
  268. comp = (
  269. db.query(
  270. func.sum(case((DailyRecommendation.is_done == 1, 1), else_=0)).label("done_cnt"),
  271. func.count(DailyRecommendation.id).label("total_cnt"),
  272. )
  273. .filter(DailyRecommendation.user_id.in_(student_ids))
  274. .filter(DailyRecommendation.recommend_date >= range_start)
  275. .filter(DailyRecommendation.recommend_date <= range_end)
  276. .one()
  277. )
  278. done_cnt, comp_total_cnt = int(comp.done_cnt or 0), int(comp.total_cnt or 0)
  279. completion_rate = (done_cnt / comp_total_cnt * 100.0) if comp_total_cnt else None
  280. return accuracy_rate, completion_rate
  281. last_start = end_day - timedelta(days=6)
  282. prev_start = end_day - timedelta(days=13)
  283. prev_end = end_day - timedelta(days=7)
  284. last_acc_rate, last_comp_rate = _range_rates(last_start, end_day)
  285. prev_acc_rate, prev_comp_rate = _range_rates(prev_start, prev_end)
  286. def _delta(a: Optional[float], b: Optional[float]) -> Optional[float]:
  287. if a is None or b is None:
  288. return None
  289. return round(a - b, 2)
  290. improvement_summary = {
  291. "last7AvgAccuracyRate": round(last_acc_rate, 2) if last_acc_rate is not None else None,
  292. "prev7AvgAccuracyRate": round(prev_acc_rate, 2) if prev_acc_rate is not None else None,
  293. "deltaAccuracyRate": _delta(last_acc_rate, prev_acc_rate),
  294. "last7AvgCompletionRate": round(last_comp_rate, 2) if last_comp_rate is not None else None,
  295. "prev7AvgCompletionRate": round(prev_comp_rate, 2) if prev_comp_rate is not None else None,
  296. "deltaCompletionRate": _delta(last_comp_rate, prev_comp_rate),
  297. }
  298. return {
  299. "classInfo": {"className": class_name, "studentsCount": students_count},
  300. "overall": {
  301. "avgMasteryScore": overall_avg_mastery,
  302. "masteredKnowledgePointRate": mastered_knowledge_point_rate,
  303. "totalKnowledgePoints": total_kp,
  304. "activeKnowledgePoints": metric_kp_count,
  305. },
  306. "wrongKnowledgePointRanking": wrong_knowledge_point_ranking,
  307. "weakKnowledgePoints": weak_knowledge_points,
  308. "tierDistribution": {"tiers": tier_distribution},
  309. "progress": {
  310. "completionTrend": completion_trend,
  311. "accuracyTrend": accuracy_trend,
  312. "improvementSummary": improvement_summary,
  313. },
  314. }