""" 个性化推荐路由:按知识点+权重组卷,或按错题优先组卷。 """ from __future__ import annotations import random from itertools import groupby from uuid import UUID from fastapi import APIRouter, Depends from sqlmodel import Session, select from app.db import get_session from app.deps import get_current_user from app.models import Exercise, ExerciseAttempt, User, WrongBookItem from app.schemas import RecommendItem, RecommendRequest, RecommendResponse from app.utils.exercise_knowledge import ( exercise_in_grade_category_scope, exercise_matches_any_selected, ) from app.utils.knowledge_codes import split_knowledge_codes router = APIRouter(prefix="/recommendation", tags=["recommendation"]) def _ex_weight(ex: Exercise) -> int: return int(getattr(ex, "weight", 0) or 0) def _sort_by_weight_shuffle_ties(pool: list[Exercise]) -> list[Exercise]: pool_sorted = sorted(pool, key=_ex_weight, reverse=True) out: list[Exercise] = [] for _, grp in groupby(pool_sorted, key=_ex_weight): chunk = list(grp) random.shuffle(chunk) out.extend(chunk) return out def _recommend_topic_then_weight( all_exs: list[Exercise], selected_codes: list[str], limit: int, knowledge_grade: str | None, knowledge_category: str | None, ) -> list[RecommendItem]: """ 开始组卷:先限定在所选知识点(含同族编码),再按错题 weight 从高到低优先,同权重随机; 不足时仅在「同年级+同类」内随机补足,避免全库混入其他大类。 """ # 第一步:只从“勾选知识点命中”的题里选。 pool = [ex for ex in all_exs if exercise_matches_any_selected(ex, selected_codes)] ordered = _sort_by_weight_shuffle_ties(pool) picked: list[Exercise] = [] picked_ids: set[UUID] = set() for ex in ordered: if len(picked) >= limit: break if ex.id not in picked_ids: picked.append(ex) picked_ids.add(ex.id) supplement_ids: set[UUID] = set() if len(picked) < limit: # 第二步:题量不足时,仅在同年级/同类别范围补足,防止跨类混题。 g = (knowledge_grade or "").strip() or None c = (knowledge_category or "").strip() or None if g or c: rest = [ ex for ex in all_exs if ex.id not in picked_ids and exercise_in_grade_category_scope(ex, g, c) ] random.shuffle(rest) for ex in rest: if len(picked) >= limit: break picked.append(ex) picked_ids.add(ex.id) supplement_ids.add(ex.id) # 未传年级/类别时不再从全库随机补足(旧行为会混入几何等无关题) items: list[RecommendItem] = [] for ex in picked: wv = _ex_weight(ex) if ex.id in supplement_ids: reason = "题量补足:同年级/同类随机(未混入其他类别)" else: reason = f"所选知识点;错题权重优先(weight={wv})" items.append(RecommendItem(item_type="exercise", item_ref=str(ex.id), reason=reason)) return items def _recommend_personalized_wrong_then_random( session: Session, current_user: User, all_exs: list[Exercise], attempts: list[ExerciseAttempt], limit: int, ) -> list[RecommendItem]: """ 个性推荐组卷:不参考勾选知识点;优先 weight 高的错题,不足则随机补满。 """ # 错题来源 = 错题本 + 作答记录中判错题,综合反映“易错程度”。 wrong_items = session.exec(select(WrongBookItem).where(WrongBookItem.user_id == current_user.id)).all() wrong_counts: dict[UUID, int] = {} for wb in wrong_items: wrong_counts[wb.exercise_id] = wrong_counts.get(wb.exercise_id, 0) + 1 wrong_ex_ids: set[UUID] = set(wrong_counts.keys()) wrong_ex_ids.update(a.exercise_id for a in attempts if a.is_correct is False) ex_by_id = {ex.id: ex for ex in all_exs} wrong_pool = [ex_by_id[eid] for eid in wrong_ex_ids if eid in ex_by_id] def sort_key(ex: Exercise) -> tuple[int, int]: return (-_ex_weight(ex), -wrong_counts.get(ex.id, 0)) wrong_pool.sort(key=sort_key) # 同 (weight, wrong_count) 档内打乱,避免顺序固定 out_wrong: list[Exercise] = [] for _, grp in groupby(wrong_pool, key=sort_key): chunk = list(grp) random.shuffle(chunk) out_wrong.extend(chunk) picked: list[Exercise] = [] picked_ids: set[UUID] = set() for ex in out_wrong: if len(picked) >= limit: break if ex.id not in picked_ids: picked.append(ex) picked_ids.add(ex.id) supplement_ids: set[UUID] = set() if len(picked) < limit: # 错题数量不够时,随机补足整卷题量。 rest = [ex for ex in all_exs if ex.id not in picked_ids] random.shuffle(rest) for ex in rest: if len(picked) >= limit: break picked.append(ex) picked_ids.add(ex.id) supplement_ids.add(ex.id) items: list[RecommendItem] = [] for ex in picked: wv = _ex_weight(ex) wc = wrong_counts.get(ex.id, 0) if ex.id in supplement_ids: reason = "个性推荐:题量随机补足" elif wc: reason = f"个性推荐:错题优先(weight={wv},错题本{wc}条)" else: reason = f"个性推荐:错题优先(weight={wv},曾答错)" items.append(RecommendItem(item_type="exercise", item_ref=str(ex.id), reason=reason)) return items @router.post("/recommend", response_model=RecommendResponse) def recommend(payload: RecommendRequest, session: Session = Depends(get_session), current_user: User = Depends(get_current_user)): """组卷推荐:mode=topic_weight 按知识点+权重;personalized 按错题优先。""" limit = max(1, int(payload.limit or 10)) raw_mode = (payload.mode or "").strip().lower() if raw_mode in ("topic_weight", "topic", "manual"): mode = "topic_weight" elif raw_mode in ("personalized", "personal"): mode = "personalized" else: # 兼容旧客户端:有知识点则按组卷,否则个性推荐 mode = "topic_weight" if (payload.knowledge_code and payload.knowledge_code.strip()) else "personalized" all_exs = session.exec(select(Exercise)).all() attempts = session.exec(select(ExerciseAttempt).where(ExerciseAttempt.user_id == current_user.id)).all() if mode == "topic_weight": codes = split_knowledge_codes(payload.knowledge_code) if not codes: return RecommendResponse(items=[]) items = _recommend_topic_then_weight( all_exs, codes, limit, payload.knowledge_grade, payload.knowledge_category, ) return RecommendResponse(items=items) items = _recommend_personalized_wrong_then_random(session, current_user, all_exs, attempts, limit) return RecommendResponse(items=items)