learning_analytics.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. """
  2. 学情分析路由:个人/全班统计、按年级/类别/知识点 breakdown、错题本查询。
  3. """
  4. from collections import defaultdict
  5. from uuid import UUID
  6. from fastapi import APIRouter, Depends
  7. from sqlmodel import Session, select
  8. from app.db import get_session
  9. from app.deps import get_current_user, require_role
  10. from app.models import Exercise, ExerciseAttempt, User, UserRole, WrongBookItem
  11. from app.utils.knowledge_codes import split_knowledge_codes
  12. from app.schemas import (
  13. CategoryBreakdownItem,
  14. GradeBreakdownItem,
  15. LearningBreakdownResponse,
  16. LearningStatsResponse,
  17. StudentLearningStatsItem,
  18. TopicBreakdownItem,
  19. WrongBookItemPublic,
  20. WrongBookSummaryItem,
  21. )
  22. router = APIRouter(prefix="/learning-analytics", tags=["learning-analytics"])
  23. def _exercise_map_by_id(exercises: list[Exercise]) -> dict[str, Exercise]:
  24. """按习题 id 字符串建索引,避免 UUID 与驱动类型不一致导致查找失败。"""
  25. m: dict[str, Exercise] = {}
  26. for ex in exercises:
  27. m[str(ex.id)] = ex
  28. return m
  29. def _resolve_exercise(session: Session, ex_map: dict[str, Exercise], exercise_id: UUID) -> Exercise | None:
  30. ex = ex_map.get(str(exercise_id))
  31. if ex is not None:
  32. return ex
  33. ex2 = session.get(Exercise, exercise_id)
  34. return ex2
  35. # 更具体的路径须先于 /me 注册,避免被通配路由遮蔽
  36. @router.get("/me/knowledge-breakdown", response_model=LearningBreakdownResponse)
  37. def my_knowledge_breakdown(session: Session = Depends(get_session), current_user: User = Depends(get_current_user)):
  38. attempts = list(session.exec(select(ExerciseAttempt).where(ExerciseAttempt.user_id == current_user.id)).all())
  39. if not attempts:
  40. return LearningBreakdownResponse(by_grade=[], by_category=[], by_topic=[])
  41. ex_ids = list({a.exercise_id for a in attempts})
  42. exercises = list(session.exec(select(Exercise).where(Exercise.id.in_(ex_ids))).all())
  43. ex_map = _exercise_map_by_id(exercises)
  44. grade_acc: dict[str, dict[str, int]] = defaultdict(lambda: {"attempts": 0, "correct": 0, "wrong": 0})
  45. cat_acc: dict[tuple[str, str], dict[str, int]] = defaultdict(lambda: {"attempts": 0, "correct": 0, "wrong": 0})
  46. topic_acc: dict[str, dict[str, int]] = defaultdict(lambda: {"attempts": 0, "correct": 0, "wrong": 0})
  47. topic_meta: dict[str, dict[str, str | None]] = {}
  48. for a in attempts:
  49. ex = _resolve_exercise(session, ex_map, a.exercise_id)
  50. g = (ex.knowledge_grade if ex else None) or "未标注"
  51. c = (ex.knowledge_category if ex else None) or "未标注"
  52. ic = a.is_correct is True
  53. iw = a.is_correct is False
  54. grade_acc[g]["attempts"] += 1
  55. if ic:
  56. grade_acc[g]["correct"] += 1
  57. elif iw:
  58. grade_acc[g]["wrong"] += 1
  59. cat_acc[(g, c)]["attempts"] += 1
  60. if ic:
  61. cat_acc[(g, c)]["correct"] += 1
  62. elif iw:
  63. cat_acc[(g, c)]["wrong"] += 1
  64. codes = split_knowledge_codes(ex.knowledge_code) if ex else []
  65. code = codes[0] if codes else "未标注"
  66. if code not in topic_meta:
  67. topic_meta[code] = {
  68. "name": (ex.knowledge_point_name if ex else None) or None,
  69. "grade": g,
  70. "category": c,
  71. }
  72. topic_acc[code]["attempts"] += 1
  73. if ic:
  74. topic_acc[code]["correct"] += 1
  75. elif iw:
  76. topic_acc[code]["wrong"] += 1
  77. def acc_float(row: dict[str, int]) -> float:
  78. n = row["attempts"]
  79. if not n:
  80. return 0.0
  81. return row["correct"] / n
  82. by_grade = [
  83. GradeBreakdownItem(
  84. grade=k,
  85. attempts=v["attempts"],
  86. correct=v["correct"],
  87. wrong=v["wrong"],
  88. accuracy=acc_float(v),
  89. )
  90. for k, v in sorted(grade_acc.items(), key=lambda x: (-x[1]["attempts"], x[0]))
  91. ]
  92. by_category = [
  93. CategoryBreakdownItem(
  94. grade=g,
  95. category=c,
  96. attempts=v["attempts"],
  97. correct=v["correct"],
  98. wrong=v["wrong"],
  99. accuracy=acc_float(v),
  100. )
  101. for (g, c), v in sorted(cat_acc.items(), key=lambda x: (-x[1]["attempts"], x[0][0], x[0][1]))
  102. ]
  103. by_topic = [
  104. TopicBreakdownItem(
  105. knowledge_code=code,
  106. knowledge_point_name=(topic_meta.get(code) or {}).get("name"),
  107. knowledge_grade=(topic_meta.get(code) or {}).get("grade"),
  108. knowledge_category=(topic_meta.get(code) or {}).get("category"),
  109. attempts=v["attempts"],
  110. correct=v["correct"],
  111. wrong=v["wrong"],
  112. accuracy=acc_float(v),
  113. )
  114. for code, v in sorted(topic_acc.items(), key=lambda x: (-x[1]["attempts"], x[0]))
  115. ]
  116. return LearningBreakdownResponse(by_grade=by_grade, by_category=by_category, by_topic=by_topic)
  117. @router.get("/me", response_model=LearningStatsResponse)
  118. def my_stats(session: Session = Depends(get_session), current_user: User = Depends(get_current_user)):
  119. attempts = session.exec(select(ExerciseAttempt).where(ExerciseAttempt.user_id == current_user.id)).all()
  120. attempts_total = len(attempts)
  121. correct_total = sum(1 for a in attempts if a.is_correct is True)
  122. accuracy = (correct_total / attempts_total) if attempts_total else 0.0
  123. return LearningStatsResponse(
  124. user_id=current_user.id,
  125. attempts_total=attempts_total,
  126. correct_total=correct_total,
  127. accuracy=accuracy,
  128. )
  129. @router.get("/students", response_model=list[StudentLearningStatsItem])
  130. def stats_for_all_students(
  131. session: Session = Depends(get_session),
  132. _teacher=Depends(require_role(UserRole.teacher)),
  133. ):
  134. students = list(session.exec(select(User).where(User.role == UserRole.student)).all())
  135. if not students:
  136. return []
  137. attempts = list(session.exec(select(ExerciseAttempt)).all())
  138. by_user: dict[str, list[ExerciseAttempt]] = {}
  139. for a in attempts:
  140. by_user.setdefault(str(a.user_id), []).append(a)
  141. out: list[StudentLearningStatsItem] = []
  142. for s in students:
  143. s_attempts = by_user.get(str(s.id), [])
  144. attempts_total = len(s_attempts)
  145. correct_total = sum(1 for a in s_attempts if a.is_correct is True)
  146. accuracy = (correct_total / attempts_total) if attempts_total else 0.0
  147. out.append(
  148. StudentLearningStatsItem(
  149. user_id=s.id,
  150. username=s.username,
  151. attempts_total=attempts_total,
  152. correct_total=correct_total,
  153. accuracy=accuracy,
  154. )
  155. )
  156. out.sort(key=lambda x: (-x.attempts_total, x.username))
  157. return out
  158. @router.get("/wrong-book/me", response_model=list[WrongBookItemPublic])
  159. def my_wrong_book(session: Session = Depends(get_session), current_user: User = Depends(get_current_user)):
  160. items = list(
  161. session.exec(
  162. select(WrongBookItem)
  163. .where(WrongBookItem.user_id == current_user.id)
  164. .order_by(WrongBookItem.created_at.desc())
  165. ).all()
  166. )
  167. if not items:
  168. return []
  169. ex_ids = list({it.exercise_id for it in items})
  170. exercises = list(session.exec(select(Exercise).where(Exercise.id.in_(ex_ids))).all())
  171. ex_map = _exercise_map_by_id(exercises)
  172. out: list[WrongBookItemPublic] = []
  173. for it in items:
  174. ex = _resolve_exercise(session, ex_map, it.exercise_id)
  175. res = None
  176. if ex:
  177. res = (ex.resolution or "").strip() or "暂无"
  178. ca = (str(it.correct_answer).strip() if it.correct_answer else "") or None
  179. if not ca and ex and getattr(ex, "correct_answer", None):
  180. ca = str(ex.correct_answer).strip() or None
  181. out.append(
  182. WrongBookItemPublic(
  183. id=it.id,
  184. user_id=it.user_id,
  185. exercise_id=it.exercise_id,
  186. knowledge_code=it.knowledge_code,
  187. wrong_answer=it.wrong_answer,
  188. correct_answer=ca,
  189. created_at=it.created_at,
  190. title=ex.title if ex else None,
  191. body=ex.body if ex else None,
  192. question_type=ex.question_type if ex else None,
  193. resolution=res,
  194. knowledge_grade=ex.knowledge_grade if ex else None,
  195. knowledge_category=ex.knowledge_category if ex else None,
  196. knowledge_point_name=ex.knowledge_point_name if ex else None,
  197. )
  198. )
  199. return out
  200. @router.get("/wrong-book/summary", response_model=list[WrongBookSummaryItem])
  201. def wrong_book_summary(
  202. session: Session = Depends(get_session),
  203. _teacher=Depends(require_role(UserRole.teacher)),
  204. ):
  205. items = session.exec(select(WrongBookItem)).all()
  206. by_code: dict[str, int] = {}
  207. for it in items:
  208. by_code[it.knowledge_code] = by_code.get(it.knowledge_code, 0) + 1
  209. out = [WrongBookSummaryItem(knowledge_code=k, wrong_count=v) for k, v in by_code.items()]
  210. out.sort(key=lambda x: (-x.wrong_count, x.knowledge_code))
  211. return out