| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- """
- 个性化推荐路由:按知识点+权重组卷,或按错题优先组卷。
- """
- 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)
|