recommendation.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. """
  2. 个性化推荐路由:按知识点+权重组卷,或按错题优先组卷。
  3. """
  4. from __future__ import annotations
  5. import random
  6. from itertools import groupby
  7. from uuid import UUID
  8. from fastapi import APIRouter, Depends
  9. from sqlmodel import Session, select
  10. from app.db import get_session
  11. from app.deps import get_current_user
  12. from app.models import Exercise, ExerciseAttempt, User, WrongBookItem
  13. from app.schemas import RecommendItem, RecommendRequest, RecommendResponse
  14. from app.utils.exercise_knowledge import (
  15. exercise_in_grade_category_scope,
  16. exercise_matches_any_selected,
  17. )
  18. from app.utils.knowledge_codes import split_knowledge_codes
  19. router = APIRouter(prefix="/recommendation", tags=["recommendation"])
  20. def _ex_weight(ex: Exercise) -> int:
  21. return int(getattr(ex, "weight", 0) or 0)
  22. def _sort_by_weight_shuffle_ties(pool: list[Exercise]) -> list[Exercise]:
  23. pool_sorted = sorted(pool, key=_ex_weight, reverse=True)
  24. out: list[Exercise] = []
  25. for _, grp in groupby(pool_sorted, key=_ex_weight):
  26. chunk = list(grp)
  27. random.shuffle(chunk)
  28. out.extend(chunk)
  29. return out
  30. def _recommend_topic_then_weight(
  31. all_exs: list[Exercise],
  32. selected_codes: list[str],
  33. limit: int,
  34. knowledge_grade: str | None,
  35. knowledge_category: str | None,
  36. ) -> list[RecommendItem]:
  37. """
  38. 开始组卷:先限定在所选知识点(含同族编码),再按错题 weight 从高到低优先,同权重随机;
  39. 不足时仅在「同年级+同类」内随机补足,避免全库混入其他大类。
  40. """
  41. # 第一步:只从“勾选知识点命中”的题里选。
  42. pool = [ex for ex in all_exs if exercise_matches_any_selected(ex, selected_codes)]
  43. ordered = _sort_by_weight_shuffle_ties(pool)
  44. picked: list[Exercise] = []
  45. picked_ids: set[UUID] = set()
  46. for ex in ordered:
  47. if len(picked) >= limit:
  48. break
  49. if ex.id not in picked_ids:
  50. picked.append(ex)
  51. picked_ids.add(ex.id)
  52. supplement_ids: set[UUID] = set()
  53. if len(picked) < limit:
  54. # 第二步:题量不足时,仅在同年级/同类别范围补足,防止跨类混题。
  55. g = (knowledge_grade or "").strip() or None
  56. c = (knowledge_category or "").strip() or None
  57. if g or c:
  58. rest = [
  59. ex
  60. for ex in all_exs
  61. if ex.id not in picked_ids and exercise_in_grade_category_scope(ex, g, c)
  62. ]
  63. random.shuffle(rest)
  64. for ex in rest:
  65. if len(picked) >= limit:
  66. break
  67. picked.append(ex)
  68. picked_ids.add(ex.id)
  69. supplement_ids.add(ex.id)
  70. # 未传年级/类别时不再从全库随机补足(旧行为会混入几何等无关题)
  71. items: list[RecommendItem] = []
  72. for ex in picked:
  73. wv = _ex_weight(ex)
  74. if ex.id in supplement_ids:
  75. reason = "题量补足:同年级/同类随机(未混入其他类别)"
  76. else:
  77. reason = f"所选知识点;错题权重优先(weight={wv})"
  78. items.append(RecommendItem(item_type="exercise", item_ref=str(ex.id), reason=reason))
  79. return items
  80. def _recommend_personalized_wrong_then_random(
  81. session: Session,
  82. current_user: User,
  83. all_exs: list[Exercise],
  84. attempts: list[ExerciseAttempt],
  85. limit: int,
  86. ) -> list[RecommendItem]:
  87. """
  88. 个性推荐组卷:不参考勾选知识点;优先 weight 高的错题,不足则随机补满。
  89. """
  90. # 错题来源 = 错题本 + 作答记录中判错题,综合反映“易错程度”。
  91. wrong_items = session.exec(select(WrongBookItem).where(WrongBookItem.user_id == current_user.id)).all()
  92. wrong_counts: dict[UUID, int] = {}
  93. for wb in wrong_items:
  94. wrong_counts[wb.exercise_id] = wrong_counts.get(wb.exercise_id, 0) + 1
  95. wrong_ex_ids: set[UUID] = set(wrong_counts.keys())
  96. wrong_ex_ids.update(a.exercise_id for a in attempts if a.is_correct is False)
  97. ex_by_id = {ex.id: ex for ex in all_exs}
  98. wrong_pool = [ex_by_id[eid] for eid in wrong_ex_ids if eid in ex_by_id]
  99. def sort_key(ex: Exercise) -> tuple[int, int]:
  100. return (-_ex_weight(ex), -wrong_counts.get(ex.id, 0))
  101. wrong_pool.sort(key=sort_key)
  102. # 同 (weight, wrong_count) 档内打乱,避免顺序固定
  103. out_wrong: list[Exercise] = []
  104. for _, grp in groupby(wrong_pool, key=sort_key):
  105. chunk = list(grp)
  106. random.shuffle(chunk)
  107. out_wrong.extend(chunk)
  108. picked: list[Exercise] = []
  109. picked_ids: set[UUID] = set()
  110. for ex in out_wrong:
  111. if len(picked) >= limit:
  112. break
  113. if ex.id not in picked_ids:
  114. picked.append(ex)
  115. picked_ids.add(ex.id)
  116. supplement_ids: set[UUID] = set()
  117. if len(picked) < limit:
  118. # 错题数量不够时,随机补足整卷题量。
  119. rest = [ex for ex in all_exs if ex.id not in picked_ids]
  120. random.shuffle(rest)
  121. for ex in rest:
  122. if len(picked) >= limit:
  123. break
  124. picked.append(ex)
  125. picked_ids.add(ex.id)
  126. supplement_ids.add(ex.id)
  127. items: list[RecommendItem] = []
  128. for ex in picked:
  129. wv = _ex_weight(ex)
  130. wc = wrong_counts.get(ex.id, 0)
  131. if ex.id in supplement_ids:
  132. reason = "个性推荐:题量随机补足"
  133. elif wc:
  134. reason = f"个性推荐:错题优先(weight={wv},错题本{wc}条)"
  135. else:
  136. reason = f"个性推荐:错题优先(weight={wv},曾答错)"
  137. items.append(RecommendItem(item_type="exercise", item_ref=str(ex.id), reason=reason))
  138. return items
  139. @router.post("/recommend", response_model=RecommendResponse)
  140. def recommend(payload: RecommendRequest, session: Session = Depends(get_session), current_user: User = Depends(get_current_user)):
  141. """组卷推荐:mode=topic_weight 按知识点+权重;personalized 按错题优先。"""
  142. limit = max(1, int(payload.limit or 10))
  143. raw_mode = (payload.mode or "").strip().lower()
  144. if raw_mode in ("topic_weight", "topic", "manual"):
  145. mode = "topic_weight"
  146. elif raw_mode in ("personalized", "personal"):
  147. mode = "personalized"
  148. else:
  149. # 兼容旧客户端:有知识点则按组卷,否则个性推荐
  150. mode = "topic_weight" if (payload.knowledge_code and payload.knowledge_code.strip()) else "personalized"
  151. all_exs = session.exec(select(Exercise)).all()
  152. attempts = session.exec(select(ExerciseAttempt).where(ExerciseAttempt.user_id == current_user.id)).all()
  153. if mode == "topic_weight":
  154. codes = split_knowledge_codes(payload.knowledge_code)
  155. if not codes:
  156. return RecommendResponse(items=[])
  157. items = _recommend_topic_then_weight(
  158. all_exs,
  159. codes,
  160. limit,
  161. payload.knowledge_grade,
  162. payload.knowledge_category,
  163. )
  164. return RecommendResponse(items=items)
  165. items = _recommend_personalized_wrong_then_random(session, current_user, all_exs, attempts, limit)
  166. return RecommendResponse(items=items)