| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832 |
- # -*- coding: utf-8 -*-
- """个性化推荐模块:习题测评推荐算法、错题集、学情分析、每日一练"""
- import json
- from datetime import date, timedelta
- from typing import List, Optional
- from sqlalchemy import func, case, desc, text
- from sqlalchemy.orm import Session
- from backend.models import (
- Question,
- AnswerRecord,
- KnowledgePoint,
- QuestionKnowledgePoint,
- StudentKnowledgeMastery,
- DailyRecommendation,
- WrongQuestion,
- QuestionVariant,
- )
- QUESTION_MIN_ID = 385
- def _difficulty_text_to_level(v: str) -> int:
- m = {"easy": 1, "medium": 2, "hard": 3}
- return m.get((v or "").lower(), 2)
- def _sync_questions_from_chemistry_bank(db: Session, min_question_id: int = QUESTION_MIN_ID) -> None:
- """
- 将 chemistry_question_bank 中可做选择题(question_id >= min_question_id)同步到 questions,
- 以保持现有 answer_records / 推荐 / 弱点更新链路不变。
- """
- rows = db.execute(
- text(
- """
- SELECT question_id, difficulty, knowledge_tags, content, answer_schema
- FROM chemistry_question_bank
- WHERE question_id >= :min_id AND type = 'choice'
- """
- ),
- {"min_id": min_question_id},
- ).mappings().all()
- if not rows:
- return
- existing_ids = {
- r[0] for r in db.query(Question.id).filter(Question.id >= min_question_id).all()
- }
- inserted = 0
- for row in rows:
- qid = int(row["question_id"])
- if qid in existing_ids:
- continue
- answer_schema = row.get("answer_schema")
- if isinstance(answer_schema, str):
- try:
- answer_schema = json.loads(answer_schema)
- except Exception:
- answer_schema = {}
- elif answer_schema is None:
- answer_schema = {}
- options = answer_schema.get("options") or {}
- correct = answer_schema.get("correct")
- if isinstance(correct, list):
- correct = correct[0] if correct else None
- if not (options.get("A") and options.get("B") and options.get("C") and options.get("D") and correct):
- continue
- db.add(
- Question(
- id=qid,
- content=row.get("content") or "",
- option_a=str(options.get("A")),
- option_b=str(options.get("B")),
- option_c=str(options.get("C")),
- option_d=str(options.get("D")),
- correct_option=str(correct).strip()[:1],
- knowledge_point=(row.get("knowledge_tags") or None),
- difficulty=_difficulty_text_to_level(row.get("difficulty")),
- )
- )
- existing_ids.add(qid)
- inserted += 1
- if inserted > 0:
- db.commit()
- def get_question_analysis(db: Session, question_id: int) -> str:
- """按 question_id 从 chemistry_question_bank 查询题目解析。"""
- row = db.execute(
- text(
- """
- SELECT analysis, hint
- FROM chemistry_question_bank
- WHERE question_id = :qid
- LIMIT 1
- """
- ),
- {"qid": question_id},
- ).mappings().first()
- if not row:
- return ""
- return (row.get("analysis") or row.get("hint") or "").strip()
- def get_user_proficiency(db: Session, user_id: int) -> dict:
- """知识点熟练度"""
- _sync_questions_from_chemistry_bank(db)
- rows = (
- db.query(
- Question.knowledge_point,
- func.sum(case((AnswerRecord.is_correct == True, 1), else_=0)).label("correct"),
- func.count(AnswerRecord.id).label("total"),
- )
- .join(AnswerRecord, Question.id == AnswerRecord.question_id)
- .filter(
- AnswerRecord.user_id == user_id,
- Question.knowledge_point.isnot(None),
- Question.knowledge_point != "",
- )
- .group_by(Question.knowledge_point)
- .all()
- )
- return {r.knowledge_point: int(r.correct / r.total * 100) if r.total else 0 for r in rows}
- def get_wrong_question_ids(db: Session, user_id: int, limit: int = 50) -> List[int]:
- """错题集 ID 列表"""
- records = (
- db.query(AnswerRecord.question_id)
- .join(Question, Question.id == AnswerRecord.question_id)
- .filter(AnswerRecord.user_id == user_id, AnswerRecord.is_correct == False)
- .filter(Question.id >= QUESTION_MIN_ID)
- .order_by(desc(AnswerRecord.answered_at))
- .all()
- )
- seen = set()
- ids = []
- for (qid,) in records:
- if qid not in seen:
- seen.add(qid)
- ids.append(qid)
- if len(ids) >= limit:
- break
- return ids
- def get_wrong_questions_for_user(db: Session, user_id: int, limit: int = 50) -> List[Question]:
- """
- 错题集(第二阶段):
- 1)优先从 wrong_questions 表取“最近答错原题”
- 2)再用 question_variants 做“错题→同知识点变式题”
- 3)如果变式数据为空,则退回“原题重做”
- """
- _sync_questions_from_chemistry_bank(db)
- # wrong_questions 里取最近错题(base question)
- base_qids_rows = (
- db.query(WrongQuestion.question_id)
- .filter(WrongQuestion.user_id == user_id)
- .filter(WrongQuestion.question_id >= QUESTION_MIN_ID)
- .order_by(desc(func.coalesce(WrongQuestion.last_wrong_at, WrongQuestion.created_at)))
- .limit(max(limit * 2, 20))
- .all()
- )
- base_qids = []
- seen_base = set()
- for (qid,) in base_qids_rows:
- if qid not in seen_base:
- seen_base.add(qid)
- base_qids.append(qid)
- if not base_qids:
- # 兼容:表未初始化时退回 answer_records 逻辑
- wrong_ids = get_wrong_question_ids(db, user_id, limit)
- questions = db.query(Question).filter(Question.id.in_(wrong_ids)).all() if wrong_ids else []
- id_order = {qid: i for i, qid in enumerate(wrong_ids)}
- return sorted(questions, key=lambda q: id_order.get(q.id, 999))
- result_qids: List[int] = []
- seen_qids = set()
- for base_qid in base_qids:
- # 从变式映射中优先拿 variant_question_id
- variant_rows = (
- db.query(QuestionVariant.variant_question_id)
- .filter(QuestionVariant.base_question_id == base_qid)
- .order_by(desc(QuestionVariant.weight))
- .limit(10)
- .all()
- )
- added_any = False
- for (vid,) in variant_rows:
- if vid == base_qid:
- continue
- if vid in seen_qids:
- continue
- result_qids.append(vid)
- seen_qids.add(vid)
- added_any = True
- if len(result_qids) >= limit:
- break
- if len(result_qids) >= limit:
- break
- # 不足则加入原错题
- if not added_any or (len(result_qids) < limit):
- if base_qid not in seen_qids:
- result_qids.append(base_qid)
- seen_qids.add(base_qid)
- if len(result_qids) >= limit:
- break
- # 拉取题目并保持 result_qids 顺序
- questions = db.query(Question).filter(Question.id.in_(result_qids)).all() if result_qids else []
- id_order = {qid: i for i, qid in enumerate(result_qids)}
- return sorted(questions, key=lambda q: id_order.get(q.id, 999))
- def get_recommended_questions(
- db: Session, user_id: int, count: int = 5
- ) -> List[Question]:
- """习题测评推荐算法:错题优先、薄弱知识点补充"""
- _sync_questions_from_chemistry_bank(db)
- wrong_ids = get_wrong_question_ids(db, user_id, 20)
- prof = get_user_proficiency(db, user_id)
- weak = [k for k, v in sorted(prof.items(), key=lambda x: x[1])[:3]]
- recommended = []
- if wrong_ids:
- qs = (
- db.query(Question)
- .filter(Question.id.in_(wrong_ids), Question.id >= QUESTION_MIN_ID)
- .order_by(func.rand())
- .limit(count)
- .all()
- )
- recommended = [q.id for q in qs]
- exclude = list(set(recommended + wrong_ids))
- if len(recommended) < count and weak:
- extra = db.query(Question).filter(Question.knowledge_point.in_(weak), Question.id >= QUESTION_MIN_ID)
- if exclude:
- extra = extra.filter(~Question.id.in_(exclude))
- extra = extra.order_by(func.rand()).limit(count - len(recommended)).all()
- recommended.extend([q.id for q in extra])
- exclude = list(set(recommended + wrong_ids))
- if len(recommended) < count:
- fallback = db.query(Question).filter(Question.id >= QUESTION_MIN_ID)
- if exclude:
- fallback = fallback.filter(~Question.id.in_(exclude))
- fallback = fallback.order_by(func.rand()).limit(count - len(recommended)).all()
- recommended.extend([q.id for q in fallback])
- if not recommended:
- return []
- return db.query(Question).filter(Question.id.in_(recommended[:count])).all()
- # --- 每日一练:基于弱点权重的推荐 ---
- TOP_WEAKNESS_N = 5
- MASTERY_EXCLUDE_THRESHOLD = 80
- def _get_question_ids_for_knowledge_point(
- db: Session,
- user_id: int,
- knowledge_point_id: int,
- exclude_ids: List[int],
- limit: int,
- preferred_difficulties: Optional[List[int]] = None,
- prefer_variants: bool = True,
- ) -> List[int]:
- """
- _sync_questions_from_chemistry_bank(db)
- 获取某知识点下的题目 ID:
- 1)优先用错题变式(question_variants + wrong_questions)
- 2)再用 question_knowledge_points
- 3)回退 Question.knowledge_point
- """
- kp = db.query(KnowledgePoint).filter(KnowledgePoint.id == knowledge_point_id).first()
- kp_name = kp.name if kp else None
- qids = []
- # 0) 优先取错题的变式题(可在诊断测评中关闭)
- if prefer_variants:
- try:
- base_wrong_subq = db.query(WrongQuestion.question_id).filter(
- WrongQuestion.user_id == user_id
- ).subquery()
- variant_q = db.query(
- QuestionVariant.variant_question_id, QuestionVariant.weight
- ).filter(
- QuestionVariant.base_question_id.in_(base_wrong_subq),
- QuestionVariant.variant_question_id >= QUESTION_MIN_ID,
- (QuestionVariant.knowledge_point_id == knowledge_point_id)
- | (QuestionVariant.knowledge_point_id.is_(None)),
- )
- if preferred_difficulties:
- variant_q = variant_q.join(
- Question, Question.id == QuestionVariant.variant_question_id
- ).filter(Question.difficulty.in_(preferred_difficulties))
- variant_rows = (
- variant_q.filter(~QuestionVariant.variant_question_id.in_(exclude_ids))
- .order_by(desc(QuestionVariant.weight))
- .limit(limit)
- .all()
- )
- for qid, _w in variant_rows:
- if qid in exclude_ids or qid in qids:
- continue
- qids.append(qid)
- if len(qids) >= limit:
- return qids
- except Exception:
- # 变式表为空/列不匹配等情况不阻塞主流程
- pass
- # 1) 从 question_knowledge_points
- qkps_q = (
- db.query(QuestionKnowledgePoint.question_id)
- .filter(QuestionKnowledgePoint.knowledge_point_id == knowledge_point_id)
- .filter(QuestionKnowledgePoint.question_id >= QUESTION_MIN_ID)
- .distinct()
- )
- if preferred_difficulties:
- qkps_q = qkps_q.join(
- Question, Question.id == QuestionKnowledgePoint.question_id
- ).filter(Question.difficulty.in_(preferred_difficulties))
- qkps = qkps_q.all()
- for (qid,) in qkps:
- if qid not in exclude_ids:
- qids.append(qid)
- if len(qids) >= limit:
- return qids
- # 2) 回退:Question.knowledge_point 匹配
- if kp_name:
- qs = (
- db.query(Question.id)
- .filter(Question.knowledge_point == kp_name)
- .filter(~Question.id.in_(exclude_ids + qids))
- .all()
- )
- # 注意:上面 .all() 已经把数据拉出来,这里为了保持原逻辑简单不再回填难度过滤
- # (真正需要时建议把这一段改成不 .all() 的链式查询)
- # 但为了保证准确,我们用 Python 再做难度过滤
- if preferred_difficulties:
- qs = (
- db.query(Question.id)
- .filter(Question.knowledge_point == kp_name)
- .filter(~Question.id.in_(exclude_ids + qids))
- .filter(Question.difficulty.in_(preferred_difficulties))
- .order_by(func.rand())
- .limit(limit - len(qids))
- .all()
- )
- else:
- qs = (
- db.query(Question.id)
- .filter(Question.knowledge_point == kp_name)
- .filter(~Question.id.in_(exclude_ids + qids))
- .order_by(func.rand())
- .limit(limit - len(qids))
- .all()
- )
- for (qid,) in qs:
- qids.append(qid)
- return qids
- def get_or_generate_daily_recommendations(
- db: Session,
- user_id: int,
- count: int = 10,
- today: date = None,
- ) -> List[Question]:
- """
- _sync_questions_from_chemistry_bank(db)
- 获取今日推荐题,若不存在则按弱点权重生成并写入 daily_recommendations。
- 策略:weakness_weight 降序取 Top N 知识点 → 每知识点选题 → 不足则随机补题。
- """
- if today is None:
- today = date.today()
- # 1) 已有今日推荐:
- # - 若数量足够:直接返回
- # - 若数量不足:在保留已存在记录的前提下补齐剩余题目
- recs = (
- db.query(DailyRecommendation)
- .filter(
- DailyRecommendation.user_id == user_id,
- DailyRecommendation.recommend_date == today,
- )
- .order_by(DailyRecommendation.id.asc())
- .all()
- )
- if recs and len(recs) >= count:
- qids = [r.question_id for r in recs]
- questions = (
- db.query(Question)
- .filter(Question.id.in_(qids), Question.id >= QUESTION_MIN_ID)
- .all()
- )
- id_order = {qid: i for i, qid in enumerate(qids)}
- return sorted(questions, key=lambda q: id_order.get(q.id, 999))
- existing_qids: List[int] = [r.question_id for r in recs] if recs else []
- missing_count = max(0, count - len(existing_qids))
- if missing_count == 0 and existing_qids:
- questions = db.query(Question).filter(Question.id.in_(existing_qids), Question.id >= QUESTION_MIN_ID).all()
- id_order = {qid: i for i, qid in enumerate(existing_qids)}
- return sorted(questions, key=lambda q: id_order.get(q.id, 999))
- # 2) 生成新推荐
- # 2a) 弱点知识点:weakness_weight 降序,排除 mastery_score > 80
- weakness_rows = (
- db.query(StudentKnowledgeMastery)
- .filter(StudentKnowledgeMastery.user_id == user_id)
- .filter(
- (StudentKnowledgeMastery.mastery_score.is_(None))
- | (StudentKnowledgeMastery.mastery_score <= MASTERY_EXCLUDE_THRESHOLD)
- )
- .order_by(desc(StudentKnowledgeMastery.weakness_weight))
- .limit(TOP_WEAKNESS_N)
- .all()
- )
- # 2b) 近期已做过的题(7 天内)避免重复
- from datetime import datetime, timedelta
- since = datetime.utcnow() - timedelta(days=7)
- recent_qids = [
- r[0]
- for r in db.query(AnswerRecord.question_id)
- .filter(AnswerRecord.user_id == user_id, AnswerRecord.answered_at >= since)
- .filter(AnswerRecord.question_id >= QUESTION_MIN_ID)
- .distinct()
- .all()
- ]
- # exclude 需要同时排除:已存在的今日题 + 近期做过的题
- exclude = list(set(existing_qids + recent_qids))
- recommended_qids = []
- # 仅补齐缺失数量
- per_kp = max(2, missing_count // max(1, len(weakness_rows))) # 每知识点至少 2 题
- target_count = missing_count
- for idx, row in enumerate(weakness_rows):
- # 难度匹配:按掌握度从中等难度起步,逐步向更高难度推进
- # 约定:Question.difficulty 1/2/3 对应 easy/medium/hard(若你的题库含义不同可再调)
- if row.mastery_score is None:
- preferred = [2, 3]
- elif row.mastery_score <= 35:
- preferred = [1, 2]
- elif row.mastery_score <= 65:
- preferred = [2]
- else:
- preferred = [2, 3]
- ids = _get_question_ids_for_knowledge_point(
- db,
- user_id,
- row.knowledge_point_id,
- exclude,
- per_kp,
- preferred_difficulties=preferred,
- prefer_variants=True,
- )
- for qid in ids:
- if qid not in recommended_qids:
- recommended_qids.append(qid)
- exclude.append(qid)
- if len(recommended_qids) >= target_count:
- break
- # 2c) 不足则随机补题
- if len(recommended_qids) < target_count:
- fallback = (
- db.query(Question)
- .filter(~Question.id.in_(exclude), Question.id >= QUESTION_MIN_ID)
- .order_by(func.rand())
- .limit(target_count - len(recommended_qids))
- .all()
- )
- for q in fallback:
- recommended_qids.append(q.id)
- exclude.append(q.id)
- # 2d) 兜底:题库较小时,若“近7天排除”导致候选不足,则允许重复旧题补齐
- if len(recommended_qids) < target_count:
- extra = (
- db.query(Question)
- .filter(Question.id >= QUESTION_MIN_ID, ~Question.id.in_(recommended_qids))
- .order_by(func.rand())
- .limit(target_count - len(recommended_qids))
- .all()
- )
- for q in extra:
- recommended_qids.append(q.id)
- recommended_qids = recommended_qids[:target_count]
- # 3) 写入 daily_recommendations
- strategy = "weak_kp" if weakness_rows else "mixed"
- for qid in recommended_qids:
- dr = DailyRecommendation(
- user_id=user_id,
- question_id=qid,
- recommend_date=today,
- strategy=strategy,
- is_done=0,
- )
- db.add(dr)
- db.commit()
- # 返回:今日所有推荐题(包含已存在的记录 + 本次新增的补齐题)
- all_recs = (
- db.query(DailyRecommendation)
- .filter(
- DailyRecommendation.user_id == user_id,
- DailyRecommendation.recommend_date == today,
- )
- .order_by(DailyRecommendation.id.asc())
- .all()
- )
- qids = [r.question_id for r in all_recs]
- questions = (
- db.query(Question)
- .filter(Question.id.in_(qids), Question.id >= QUESTION_MIN_ID)
- .all()
- )
- id_order = {qid: i for i, qid in enumerate(qids)}
- return sorted(questions, key=lambda q: id_order.get(q.id, 999))
- def _get_preferred_difficulties_for_session(session_kind: str) -> List[int]:
- """
- 诊断/再测评的难度偏好(先用适中难度覆盖,再逐步提升)。
- 约定 Question.difficulty 1/2/3 对应 easy/medium/hard。
- """
- if session_kind == "re_evaluate":
- return [2, 3]
- return [2]
- def get_diagnostic_questions(
- db: Session,
- user_id: int,
- count: int = 10,
- session_kind: str = "diagnostic",
- ) -> List[Question]:
- """
- 诊断测评/再测评选题:
- - 选取弱点知识点(weakness_weight 高优先),覆盖广
- - 诊断中关闭“错题变式优先”(避免变式题影响诊断覆盖)
- - 难度按 session_kind 偏好过滤
- """
- _sync_questions_from_chemistry_bank(db)
- preferred = _get_preferred_difficulties_for_session(session_kind=session_kind)
- from datetime import datetime, timedelta
- since = datetime.utcnow() - timedelta(days=7)
- recent_qids = [
- r[0]
- for r in db.query(AnswerRecord.question_id)
- .filter(AnswerRecord.user_id == user_id, AnswerRecord.answered_at >= since)
- .filter(AnswerRecord.question_id >= QUESTION_MIN_ID)
- .distinct()
- .all()
- ]
- exclude = list(set(recent_qids))
- kp_rows = (
- db.query(StudentKnowledgeMastery)
- .filter(StudentKnowledgeMastery.user_id == user_id)
- .order_by(desc(StudentKnowledgeMastery.weakness_weight))
- .limit(max(3, count // 2))
- .all()
- )
- # 用户全新/数据少:退回按难度随机抽
- if not kp_rows:
- fallback_q = db.query(Question).filter(Question.difficulty.in_(preferred), Question.id >= QUESTION_MIN_ID)
- if exclude:
- fallback_q = fallback_q.filter(~Question.id.in_(exclude))
- qs = fallback_q.order_by(func.rand()).limit(count).all()
- if qs:
- return qs
- # 兜底:如果题库缺少 preferred 难度,退回到全难度随机
- fallback_q = db.query(Question).filter(Question.id >= QUESTION_MIN_ID)
- if exclude:
- fallback_q = fallback_q.filter(~Question.id.in_(exclude))
- qs = fallback_q.order_by(func.rand()).limit(count).all()
- if qs:
- return qs
- # 再兜底:如果“近7天排除”后为空,允许重复题
- return (
- db.query(Question)
- .filter(Question.id >= QUESTION_MIN_ID)
- .order_by(func.rand())
- .limit(count)
- .all()
- )
- recommended_qids: List[int] = []
- per_kp = max(1, count // max(1, len(kp_rows)))
- for row in kp_rows:
- ids = _get_question_ids_for_knowledge_point(
- db,
- user_id,
- row.knowledge_point_id,
- exclude,
- per_kp,
- preferred_difficulties=preferred,
- prefer_variants=False,
- )
- for qid in ids:
- if qid in recommended_qids:
- continue
- recommended_qids.append(qid)
- exclude.append(qid)
- if len(recommended_qids) >= count:
- break
- if len(recommended_qids) >= count:
- break
- if len(recommended_qids) < count:
- fallback_q = db.query(Question).filter(Question.difficulty.in_(preferred), Question.id >= QUESTION_MIN_ID)
- if exclude:
- fallback_q = fallback_q.filter(~Question.id.in_(exclude))
- fallback = fallback_q.order_by(func.rand()).limit(count - len(recommended_qids)).all()
- for q in fallback:
- recommended_qids.append(q.id)
- exclude.append(q.id)
- # 兜底:若排除近7天后仍不足,允许重复旧题补齐
- if len(recommended_qids) < count:
- fallback = (
- db.query(Question)
- .filter(
- Question.id >= QUESTION_MIN_ID,
- ~Question.id.in_(recommended_qids),
- )
- .order_by(func.rand())
- .limit(count - len(recommended_qids))
- .all()
- )
- for q in fallback:
- recommended_qids.append(q.id)
- recommended_qids = recommended_qids[:count]
- if not recommended_qids:
- fallback_q = db.query(Question).filter(Question.id >= QUESTION_MIN_ID)
- if exclude:
- fallback_q = fallback_q.filter(~Question.id.in_(exclude))
- qs = fallback_q.order_by(func.rand()).limit(count).all()
- if qs:
- return qs
- # 最终兜底:完全不排除近7天
- return (
- db.query(Question)
- .filter(Question.id >= QUESTION_MIN_ID)
- .order_by(func.rand())
- .limit(count)
- .all()
- )
- questions = db.query(Question).filter(Question.id.in_(recommended_qids)).all()
- id_order = {qid: i for i, qid in enumerate(recommended_qids)}
- return sorted(questions, key=lambda q: id_order.get(q.id, 999))
- def _ensure_knowledge_points_from_questions(db: Session) -> None:
- """若 knowledge_points 为空,则从 Question.knowledge_point 同步创建"""
- if db.query(KnowledgePoint).first():
- return
- kp_names = (
- db.query(Question.knowledge_point)
- .filter(
- Question.knowledge_point.isnot(None),
- Question.knowledge_point != "",
- )
- .distinct()
- .all()
- )
- for (name,) in kp_names:
- if name and not db.query(KnowledgePoint).filter(KnowledgePoint.name == name).first():
- db.add(KnowledgePoint(name=name))
- db.commit()
- def get_mastery_matrix(db: Session, user_id: int) -> List[dict]:
- """
- 学生端知识点掌握度矩阵数据,用于可视化(颜色分级、雷达图)。
- 返回:[{ knowledgePointId, name, masteryScore, weaknessWeight, chapter }]
- """
- _ensure_knowledge_points_from_questions(db)
- rows = (
- db.query(
- KnowledgePoint.id,
- KnowledgePoint.name,
- KnowledgePoint.chapter,
- StudentKnowledgeMastery.mastery_score,
- StudentKnowledgeMastery.weakness_weight,
- StudentKnowledgeMastery.wrong_count,
- StudentKnowledgeMastery.last_practiced_at,
- )
- .outerjoin(
- StudentKnowledgeMastery,
- (StudentKnowledgeMastery.knowledge_point_id == KnowledgePoint.id)
- & (StudentKnowledgeMastery.user_id == user_id),
- )
- .all()
- )
- return [
- {
- "knowledgePointId": r.id,
- "name": r.name,
- "chapter": r.chapter or "",
- "masteryScore": r.mastery_score if r.mastery_score is not None else 0,
- "weaknessWeight": r.weakness_weight if r.weakness_weight is not None else 0,
- "wrongCount": r.wrong_count if r.wrong_count is not None else 0,
- "lastPracticedAt": (
- r.last_practiced_at.isoformat() if r.last_practiced_at else None
- ),
- }
- for r in rows
- ]
- def get_motivation_summary(db: Session, user_id: int) -> dict:
- """
- 学习激励汇总(不新增表):
- - streakDays: 连续打卡天数(存在 is_done=1 的 daily_recommendations 记为当日打卡)
- - greenModules: 全绿模块(章节下所有知识点 mastery_score >= 80)
- - moduleAverages: 章节平均掌握度(用于雷达图/柱状图)
- """
- matrix = get_mastery_matrix(db, user_id)
- from datetime import datetime, timedelta
- # 1) 按章节聚合
- by_chapter: dict[str, list[dict]] = {}
- for m in matrix:
- ch = (m.get("chapter") or "").strip() or "其他"
- by_chapter.setdefault(ch, []).append(m)
- module_averages = []
- green_modules = []
- for chapter, items in by_chapter.items():
- scores = [int(x.get("masteryScore") or 0) for x in items]
- if not scores:
- continue
- avg = int(round(sum(scores) / len(scores)))
- module_averages.append({"chapter": chapter, "avgScore": avg})
- if all(s >= 80 for s in scores):
- green_modules.append(chapter)
- module_averages.sort(key=lambda x: x["avgScore"], reverse=True)
- # 2) 连续打卡(从今天往前,只要当日有完成记录即算打卡)
- done_dates = {
- d[0]
- for d in db.query(DailyRecommendation.recommend_date)
- .filter(
- DailyRecommendation.user_id == user_id,
- DailyRecommendation.is_done == 1,
- DailyRecommendation.recommend_date.isnot(None),
- )
- .distinct()
- .all()
- }
- streak_days = 0
- cursor = date.today()
- while cursor in done_dates:
- streak_days += 1
- cursor = cursor - timedelta(days=1)
- # 3) 最近练习章节掌握度(过滤掉未练习知识点)
- # - “未练习”在这里等价于 last_practiced_at 为空
- # - 用最近 7 天作为口径(与其它推荐逻辑一致)
- since = datetime.utcnow() - timedelta(days=7)
- recent_rows = (
- db.query(
- KnowledgePoint.chapter,
- StudentKnowledgeMastery.mastery_score,
- )
- .join(
- StudentKnowledgeMastery,
- (StudentKnowledgeMastery.knowledge_point_id == KnowledgePoint.id)
- & (StudentKnowledgeMastery.user_id == user_id),
- )
- .filter(StudentKnowledgeMastery.last_practiced_at.isnot(None))
- .filter(StudentKnowledgeMastery.last_practiced_at >= since)
- .all()
- )
- recent_by_ch: dict[str, list[int]] = {}
- for ch, ms in recent_rows:
- ch_name = (ch or "").strip() or "其他"
- recent_by_ch.setdefault(ch_name, []).append(int(ms or 0))
- recent_module_averages = []
- recent_green_modules = []
- for chapter, items in recent_by_ch.items():
- if not items:
- continue
- avg = int(round(sum(items) / len(items)))
- recent_module_averages.append({"chapter": chapter, "avgScore": avg})
- if all(s >= 80 for s in items):
- recent_green_modules.append(chapter)
- recent_module_averages.sort(key=lambda x: x["avgScore"], reverse=True)
- return {
- "streakDays": streak_days,
- "greenModules": green_modules,
- "moduleAverages": module_averages,
- "moduleAveragesRecent": recent_module_averages,
- }
|