recommendation.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832
  1. # -*- coding: utf-8 -*-
  2. """个性化推荐模块:习题测评推荐算法、错题集、学情分析、每日一练"""
  3. import json
  4. from datetime import date, timedelta
  5. from typing import List, Optional
  6. from sqlalchemy import func, case, desc, text
  7. from sqlalchemy.orm import Session
  8. from backend.models import (
  9. Question,
  10. AnswerRecord,
  11. KnowledgePoint,
  12. QuestionKnowledgePoint,
  13. StudentKnowledgeMastery,
  14. DailyRecommendation,
  15. WrongQuestion,
  16. QuestionVariant,
  17. )
  18. QUESTION_MIN_ID = 385
  19. def _difficulty_text_to_level(v: str) -> int:
  20. m = {"easy": 1, "medium": 2, "hard": 3}
  21. return m.get((v or "").lower(), 2)
  22. def _sync_questions_from_chemistry_bank(db: Session, min_question_id: int = QUESTION_MIN_ID) -> None:
  23. """
  24. 将 chemistry_question_bank 中可做选择题(question_id >= min_question_id)同步到 questions,
  25. 以保持现有 answer_records / 推荐 / 弱点更新链路不变。
  26. """
  27. rows = db.execute(
  28. text(
  29. """
  30. SELECT question_id, difficulty, knowledge_tags, content, answer_schema
  31. FROM chemistry_question_bank
  32. WHERE question_id >= :min_id AND type = 'choice'
  33. """
  34. ),
  35. {"min_id": min_question_id},
  36. ).mappings().all()
  37. if not rows:
  38. return
  39. existing_ids = {
  40. r[0] for r in db.query(Question.id).filter(Question.id >= min_question_id).all()
  41. }
  42. inserted = 0
  43. for row in rows:
  44. qid = int(row["question_id"])
  45. if qid in existing_ids:
  46. continue
  47. answer_schema = row.get("answer_schema")
  48. if isinstance(answer_schema, str):
  49. try:
  50. answer_schema = json.loads(answer_schema)
  51. except Exception:
  52. answer_schema = {}
  53. elif answer_schema is None:
  54. answer_schema = {}
  55. options = answer_schema.get("options") or {}
  56. correct = answer_schema.get("correct")
  57. if isinstance(correct, list):
  58. correct = correct[0] if correct else None
  59. if not (options.get("A") and options.get("B") and options.get("C") and options.get("D") and correct):
  60. continue
  61. db.add(
  62. Question(
  63. id=qid,
  64. content=row.get("content") or "",
  65. option_a=str(options.get("A")),
  66. option_b=str(options.get("B")),
  67. option_c=str(options.get("C")),
  68. option_d=str(options.get("D")),
  69. correct_option=str(correct).strip()[:1],
  70. knowledge_point=(row.get("knowledge_tags") or None),
  71. difficulty=_difficulty_text_to_level(row.get("difficulty")),
  72. )
  73. )
  74. existing_ids.add(qid)
  75. inserted += 1
  76. if inserted > 0:
  77. db.commit()
  78. def get_question_analysis(db: Session, question_id: int) -> str:
  79. """按 question_id 从 chemistry_question_bank 查询题目解析。"""
  80. row = db.execute(
  81. text(
  82. """
  83. SELECT analysis, hint
  84. FROM chemistry_question_bank
  85. WHERE question_id = :qid
  86. LIMIT 1
  87. """
  88. ),
  89. {"qid": question_id},
  90. ).mappings().first()
  91. if not row:
  92. return ""
  93. return (row.get("analysis") or row.get("hint") or "").strip()
  94. def get_user_proficiency(db: Session, user_id: int) -> dict:
  95. """知识点熟练度"""
  96. _sync_questions_from_chemistry_bank(db)
  97. rows = (
  98. db.query(
  99. Question.knowledge_point,
  100. func.sum(case((AnswerRecord.is_correct == True, 1), else_=0)).label("correct"),
  101. func.count(AnswerRecord.id).label("total"),
  102. )
  103. .join(AnswerRecord, Question.id == AnswerRecord.question_id)
  104. .filter(
  105. AnswerRecord.user_id == user_id,
  106. Question.knowledge_point.isnot(None),
  107. Question.knowledge_point != "",
  108. )
  109. .group_by(Question.knowledge_point)
  110. .all()
  111. )
  112. return {r.knowledge_point: int(r.correct / r.total * 100) if r.total else 0 for r in rows}
  113. def get_wrong_question_ids(db: Session, user_id: int, limit: int = 50) -> List[int]:
  114. """错题集 ID 列表"""
  115. records = (
  116. db.query(AnswerRecord.question_id)
  117. .join(Question, Question.id == AnswerRecord.question_id)
  118. .filter(AnswerRecord.user_id == user_id, AnswerRecord.is_correct == False)
  119. .filter(Question.id >= QUESTION_MIN_ID)
  120. .order_by(desc(AnswerRecord.answered_at))
  121. .all()
  122. )
  123. seen = set()
  124. ids = []
  125. for (qid,) in records:
  126. if qid not in seen:
  127. seen.add(qid)
  128. ids.append(qid)
  129. if len(ids) >= limit:
  130. break
  131. return ids
  132. def get_wrong_questions_for_user(db: Session, user_id: int, limit: int = 50) -> List[Question]:
  133. """
  134. 错题集(第二阶段):
  135. 1)优先从 wrong_questions 表取“最近答错原题”
  136. 2)再用 question_variants 做“错题→同知识点变式题”
  137. 3)如果变式数据为空,则退回“原题重做”
  138. """
  139. _sync_questions_from_chemistry_bank(db)
  140. # wrong_questions 里取最近错题(base question)
  141. base_qids_rows = (
  142. db.query(WrongQuestion.question_id)
  143. .filter(WrongQuestion.user_id == user_id)
  144. .filter(WrongQuestion.question_id >= QUESTION_MIN_ID)
  145. .order_by(desc(func.coalesce(WrongQuestion.last_wrong_at, WrongQuestion.created_at)))
  146. .limit(max(limit * 2, 20))
  147. .all()
  148. )
  149. base_qids = []
  150. seen_base = set()
  151. for (qid,) in base_qids_rows:
  152. if qid not in seen_base:
  153. seen_base.add(qid)
  154. base_qids.append(qid)
  155. if not base_qids:
  156. # 兼容:表未初始化时退回 answer_records 逻辑
  157. wrong_ids = get_wrong_question_ids(db, user_id, limit)
  158. questions = db.query(Question).filter(Question.id.in_(wrong_ids)).all() if wrong_ids else []
  159. id_order = {qid: i for i, qid in enumerate(wrong_ids)}
  160. return sorted(questions, key=lambda q: id_order.get(q.id, 999))
  161. result_qids: List[int] = []
  162. seen_qids = set()
  163. for base_qid in base_qids:
  164. # 从变式映射中优先拿 variant_question_id
  165. variant_rows = (
  166. db.query(QuestionVariant.variant_question_id)
  167. .filter(QuestionVariant.base_question_id == base_qid)
  168. .order_by(desc(QuestionVariant.weight))
  169. .limit(10)
  170. .all()
  171. )
  172. added_any = False
  173. for (vid,) in variant_rows:
  174. if vid == base_qid:
  175. continue
  176. if vid in seen_qids:
  177. continue
  178. result_qids.append(vid)
  179. seen_qids.add(vid)
  180. added_any = True
  181. if len(result_qids) >= limit:
  182. break
  183. if len(result_qids) >= limit:
  184. break
  185. # 不足则加入原错题
  186. if not added_any or (len(result_qids) < limit):
  187. if base_qid not in seen_qids:
  188. result_qids.append(base_qid)
  189. seen_qids.add(base_qid)
  190. if len(result_qids) >= limit:
  191. break
  192. # 拉取题目并保持 result_qids 顺序
  193. questions = db.query(Question).filter(Question.id.in_(result_qids)).all() if result_qids else []
  194. id_order = {qid: i for i, qid in enumerate(result_qids)}
  195. return sorted(questions, key=lambda q: id_order.get(q.id, 999))
  196. def get_recommended_questions(
  197. db: Session, user_id: int, count: int = 5
  198. ) -> List[Question]:
  199. """习题测评推荐算法:错题优先、薄弱知识点补充"""
  200. _sync_questions_from_chemistry_bank(db)
  201. wrong_ids = get_wrong_question_ids(db, user_id, 20)
  202. prof = get_user_proficiency(db, user_id)
  203. weak = [k for k, v in sorted(prof.items(), key=lambda x: x[1])[:3]]
  204. recommended = []
  205. if wrong_ids:
  206. qs = (
  207. db.query(Question)
  208. .filter(Question.id.in_(wrong_ids), Question.id >= QUESTION_MIN_ID)
  209. .order_by(func.rand())
  210. .limit(count)
  211. .all()
  212. )
  213. recommended = [q.id for q in qs]
  214. exclude = list(set(recommended + wrong_ids))
  215. if len(recommended) < count and weak:
  216. extra = db.query(Question).filter(Question.knowledge_point.in_(weak), Question.id >= QUESTION_MIN_ID)
  217. if exclude:
  218. extra = extra.filter(~Question.id.in_(exclude))
  219. extra = extra.order_by(func.rand()).limit(count - len(recommended)).all()
  220. recommended.extend([q.id for q in extra])
  221. exclude = list(set(recommended + wrong_ids))
  222. if len(recommended) < count:
  223. fallback = db.query(Question).filter(Question.id >= QUESTION_MIN_ID)
  224. if exclude:
  225. fallback = fallback.filter(~Question.id.in_(exclude))
  226. fallback = fallback.order_by(func.rand()).limit(count - len(recommended)).all()
  227. recommended.extend([q.id for q in fallback])
  228. if not recommended:
  229. return []
  230. return db.query(Question).filter(Question.id.in_(recommended[:count])).all()
  231. # --- 每日一练:基于弱点权重的推荐 ---
  232. TOP_WEAKNESS_N = 5
  233. MASTERY_EXCLUDE_THRESHOLD = 80
  234. def _get_question_ids_for_knowledge_point(
  235. db: Session,
  236. user_id: int,
  237. knowledge_point_id: int,
  238. exclude_ids: List[int],
  239. limit: int,
  240. preferred_difficulties: Optional[List[int]] = None,
  241. prefer_variants: bool = True,
  242. ) -> List[int]:
  243. """
  244. _sync_questions_from_chemistry_bank(db)
  245. 获取某知识点下的题目 ID:
  246. 1)优先用错题变式(question_variants + wrong_questions)
  247. 2)再用 question_knowledge_points
  248. 3)回退 Question.knowledge_point
  249. """
  250. kp = db.query(KnowledgePoint).filter(KnowledgePoint.id == knowledge_point_id).first()
  251. kp_name = kp.name if kp else None
  252. qids = []
  253. # 0) 优先取错题的变式题(可在诊断测评中关闭)
  254. if prefer_variants:
  255. try:
  256. base_wrong_subq = db.query(WrongQuestion.question_id).filter(
  257. WrongQuestion.user_id == user_id
  258. ).subquery()
  259. variant_q = db.query(
  260. QuestionVariant.variant_question_id, QuestionVariant.weight
  261. ).filter(
  262. QuestionVariant.base_question_id.in_(base_wrong_subq),
  263. QuestionVariant.variant_question_id >= QUESTION_MIN_ID,
  264. (QuestionVariant.knowledge_point_id == knowledge_point_id)
  265. | (QuestionVariant.knowledge_point_id.is_(None)),
  266. )
  267. if preferred_difficulties:
  268. variant_q = variant_q.join(
  269. Question, Question.id == QuestionVariant.variant_question_id
  270. ).filter(Question.difficulty.in_(preferred_difficulties))
  271. variant_rows = (
  272. variant_q.filter(~QuestionVariant.variant_question_id.in_(exclude_ids))
  273. .order_by(desc(QuestionVariant.weight))
  274. .limit(limit)
  275. .all()
  276. )
  277. for qid, _w in variant_rows:
  278. if qid in exclude_ids or qid in qids:
  279. continue
  280. qids.append(qid)
  281. if len(qids) >= limit:
  282. return qids
  283. except Exception:
  284. # 变式表为空/列不匹配等情况不阻塞主流程
  285. pass
  286. # 1) 从 question_knowledge_points
  287. qkps_q = (
  288. db.query(QuestionKnowledgePoint.question_id)
  289. .filter(QuestionKnowledgePoint.knowledge_point_id == knowledge_point_id)
  290. .filter(QuestionKnowledgePoint.question_id >= QUESTION_MIN_ID)
  291. .distinct()
  292. )
  293. if preferred_difficulties:
  294. qkps_q = qkps_q.join(
  295. Question, Question.id == QuestionKnowledgePoint.question_id
  296. ).filter(Question.difficulty.in_(preferred_difficulties))
  297. qkps = qkps_q.all()
  298. for (qid,) in qkps:
  299. if qid not in exclude_ids:
  300. qids.append(qid)
  301. if len(qids) >= limit:
  302. return qids
  303. # 2) 回退:Question.knowledge_point 匹配
  304. if kp_name:
  305. qs = (
  306. db.query(Question.id)
  307. .filter(Question.knowledge_point == kp_name)
  308. .filter(~Question.id.in_(exclude_ids + qids))
  309. .all()
  310. )
  311. # 注意:上面 .all() 已经把数据拉出来,这里为了保持原逻辑简单不再回填难度过滤
  312. # (真正需要时建议把这一段改成不 .all() 的链式查询)
  313. # 但为了保证准确,我们用 Python 再做难度过滤
  314. if preferred_difficulties:
  315. qs = (
  316. db.query(Question.id)
  317. .filter(Question.knowledge_point == kp_name)
  318. .filter(~Question.id.in_(exclude_ids + qids))
  319. .filter(Question.difficulty.in_(preferred_difficulties))
  320. .order_by(func.rand())
  321. .limit(limit - len(qids))
  322. .all()
  323. )
  324. else:
  325. qs = (
  326. db.query(Question.id)
  327. .filter(Question.knowledge_point == kp_name)
  328. .filter(~Question.id.in_(exclude_ids + qids))
  329. .order_by(func.rand())
  330. .limit(limit - len(qids))
  331. .all()
  332. )
  333. for (qid,) in qs:
  334. qids.append(qid)
  335. return qids
  336. def get_or_generate_daily_recommendations(
  337. db: Session,
  338. user_id: int,
  339. count: int = 10,
  340. today: date = None,
  341. ) -> List[Question]:
  342. """
  343. _sync_questions_from_chemistry_bank(db)
  344. 获取今日推荐题,若不存在则按弱点权重生成并写入 daily_recommendations。
  345. 策略:weakness_weight 降序取 Top N 知识点 → 每知识点选题 → 不足则随机补题。
  346. """
  347. if today is None:
  348. today = date.today()
  349. # 1) 已有今日推荐:
  350. # - 若数量足够:直接返回
  351. # - 若数量不足:在保留已存在记录的前提下补齐剩余题目
  352. recs = (
  353. db.query(DailyRecommendation)
  354. .filter(
  355. DailyRecommendation.user_id == user_id,
  356. DailyRecommendation.recommend_date == today,
  357. )
  358. .order_by(DailyRecommendation.id.asc())
  359. .all()
  360. )
  361. if recs and len(recs) >= count:
  362. qids = [r.question_id for r in recs]
  363. questions = (
  364. db.query(Question)
  365. .filter(Question.id.in_(qids), Question.id >= QUESTION_MIN_ID)
  366. .all()
  367. )
  368. id_order = {qid: i for i, qid in enumerate(qids)}
  369. return sorted(questions, key=lambda q: id_order.get(q.id, 999))
  370. existing_qids: List[int] = [r.question_id for r in recs] if recs else []
  371. missing_count = max(0, count - len(existing_qids))
  372. if missing_count == 0 and existing_qids:
  373. questions = db.query(Question).filter(Question.id.in_(existing_qids), Question.id >= QUESTION_MIN_ID).all()
  374. id_order = {qid: i for i, qid in enumerate(existing_qids)}
  375. return sorted(questions, key=lambda q: id_order.get(q.id, 999))
  376. # 2) 生成新推荐
  377. # 2a) 弱点知识点:weakness_weight 降序,排除 mastery_score > 80
  378. weakness_rows = (
  379. db.query(StudentKnowledgeMastery)
  380. .filter(StudentKnowledgeMastery.user_id == user_id)
  381. .filter(
  382. (StudentKnowledgeMastery.mastery_score.is_(None))
  383. | (StudentKnowledgeMastery.mastery_score <= MASTERY_EXCLUDE_THRESHOLD)
  384. )
  385. .order_by(desc(StudentKnowledgeMastery.weakness_weight))
  386. .limit(TOP_WEAKNESS_N)
  387. .all()
  388. )
  389. # 2b) 近期已做过的题(7 天内)避免重复
  390. from datetime import datetime, timedelta
  391. since = datetime.utcnow() - timedelta(days=7)
  392. recent_qids = [
  393. r[0]
  394. for r in db.query(AnswerRecord.question_id)
  395. .filter(AnswerRecord.user_id == user_id, AnswerRecord.answered_at >= since)
  396. .filter(AnswerRecord.question_id >= QUESTION_MIN_ID)
  397. .distinct()
  398. .all()
  399. ]
  400. # exclude 需要同时排除:已存在的今日题 + 近期做过的题
  401. exclude = list(set(existing_qids + recent_qids))
  402. recommended_qids = []
  403. # 仅补齐缺失数量
  404. per_kp = max(2, missing_count // max(1, len(weakness_rows))) # 每知识点至少 2 题
  405. target_count = missing_count
  406. for idx, row in enumerate(weakness_rows):
  407. # 难度匹配:按掌握度从中等难度起步,逐步向更高难度推进
  408. # 约定:Question.difficulty 1/2/3 对应 easy/medium/hard(若你的题库含义不同可再调)
  409. if row.mastery_score is None:
  410. preferred = [2, 3]
  411. elif row.mastery_score <= 35:
  412. preferred = [1, 2]
  413. elif row.mastery_score <= 65:
  414. preferred = [2]
  415. else:
  416. preferred = [2, 3]
  417. ids = _get_question_ids_for_knowledge_point(
  418. db,
  419. user_id,
  420. row.knowledge_point_id,
  421. exclude,
  422. per_kp,
  423. preferred_difficulties=preferred,
  424. prefer_variants=True,
  425. )
  426. for qid in ids:
  427. if qid not in recommended_qids:
  428. recommended_qids.append(qid)
  429. exclude.append(qid)
  430. if len(recommended_qids) >= target_count:
  431. break
  432. # 2c) 不足则随机补题
  433. if len(recommended_qids) < target_count:
  434. fallback = (
  435. db.query(Question)
  436. .filter(~Question.id.in_(exclude), Question.id >= QUESTION_MIN_ID)
  437. .order_by(func.rand())
  438. .limit(target_count - len(recommended_qids))
  439. .all()
  440. )
  441. for q in fallback:
  442. recommended_qids.append(q.id)
  443. exclude.append(q.id)
  444. # 2d) 兜底:题库较小时,若“近7天排除”导致候选不足,则允许重复旧题补齐
  445. if len(recommended_qids) < target_count:
  446. extra = (
  447. db.query(Question)
  448. .filter(Question.id >= QUESTION_MIN_ID, ~Question.id.in_(recommended_qids))
  449. .order_by(func.rand())
  450. .limit(target_count - len(recommended_qids))
  451. .all()
  452. )
  453. for q in extra:
  454. recommended_qids.append(q.id)
  455. recommended_qids = recommended_qids[:target_count]
  456. # 3) 写入 daily_recommendations
  457. strategy = "weak_kp" if weakness_rows else "mixed"
  458. for qid in recommended_qids:
  459. dr = DailyRecommendation(
  460. user_id=user_id,
  461. question_id=qid,
  462. recommend_date=today,
  463. strategy=strategy,
  464. is_done=0,
  465. )
  466. db.add(dr)
  467. db.commit()
  468. # 返回:今日所有推荐题(包含已存在的记录 + 本次新增的补齐题)
  469. all_recs = (
  470. db.query(DailyRecommendation)
  471. .filter(
  472. DailyRecommendation.user_id == user_id,
  473. DailyRecommendation.recommend_date == today,
  474. )
  475. .order_by(DailyRecommendation.id.asc())
  476. .all()
  477. )
  478. qids = [r.question_id for r in all_recs]
  479. questions = (
  480. db.query(Question)
  481. .filter(Question.id.in_(qids), Question.id >= QUESTION_MIN_ID)
  482. .all()
  483. )
  484. id_order = {qid: i for i, qid in enumerate(qids)}
  485. return sorted(questions, key=lambda q: id_order.get(q.id, 999))
  486. def _get_preferred_difficulties_for_session(session_kind: str) -> List[int]:
  487. """
  488. 诊断/再测评的难度偏好(先用适中难度覆盖,再逐步提升)。
  489. 约定 Question.difficulty 1/2/3 对应 easy/medium/hard。
  490. """
  491. if session_kind == "re_evaluate":
  492. return [2, 3]
  493. return [2]
  494. def get_diagnostic_questions(
  495. db: Session,
  496. user_id: int,
  497. count: int = 10,
  498. session_kind: str = "diagnostic",
  499. ) -> List[Question]:
  500. """
  501. 诊断测评/再测评选题:
  502. - 选取弱点知识点(weakness_weight 高优先),覆盖广
  503. - 诊断中关闭“错题变式优先”(避免变式题影响诊断覆盖)
  504. - 难度按 session_kind 偏好过滤
  505. """
  506. _sync_questions_from_chemistry_bank(db)
  507. preferred = _get_preferred_difficulties_for_session(session_kind=session_kind)
  508. from datetime import datetime, timedelta
  509. since = datetime.utcnow() - timedelta(days=7)
  510. recent_qids = [
  511. r[0]
  512. for r in db.query(AnswerRecord.question_id)
  513. .filter(AnswerRecord.user_id == user_id, AnswerRecord.answered_at >= since)
  514. .filter(AnswerRecord.question_id >= QUESTION_MIN_ID)
  515. .distinct()
  516. .all()
  517. ]
  518. exclude = list(set(recent_qids))
  519. kp_rows = (
  520. db.query(StudentKnowledgeMastery)
  521. .filter(StudentKnowledgeMastery.user_id == user_id)
  522. .order_by(desc(StudentKnowledgeMastery.weakness_weight))
  523. .limit(max(3, count // 2))
  524. .all()
  525. )
  526. # 用户全新/数据少:退回按难度随机抽
  527. if not kp_rows:
  528. fallback_q = db.query(Question).filter(Question.difficulty.in_(preferred), Question.id >= QUESTION_MIN_ID)
  529. if exclude:
  530. fallback_q = fallback_q.filter(~Question.id.in_(exclude))
  531. qs = fallback_q.order_by(func.rand()).limit(count).all()
  532. if qs:
  533. return qs
  534. # 兜底:如果题库缺少 preferred 难度,退回到全难度随机
  535. fallback_q = db.query(Question).filter(Question.id >= QUESTION_MIN_ID)
  536. if exclude:
  537. fallback_q = fallback_q.filter(~Question.id.in_(exclude))
  538. qs = fallback_q.order_by(func.rand()).limit(count).all()
  539. if qs:
  540. return qs
  541. # 再兜底:如果“近7天排除”后为空,允许重复题
  542. return (
  543. db.query(Question)
  544. .filter(Question.id >= QUESTION_MIN_ID)
  545. .order_by(func.rand())
  546. .limit(count)
  547. .all()
  548. )
  549. recommended_qids: List[int] = []
  550. per_kp = max(1, count // max(1, len(kp_rows)))
  551. for row in kp_rows:
  552. ids = _get_question_ids_for_knowledge_point(
  553. db,
  554. user_id,
  555. row.knowledge_point_id,
  556. exclude,
  557. per_kp,
  558. preferred_difficulties=preferred,
  559. prefer_variants=False,
  560. )
  561. for qid in ids:
  562. if qid in recommended_qids:
  563. continue
  564. recommended_qids.append(qid)
  565. exclude.append(qid)
  566. if len(recommended_qids) >= count:
  567. break
  568. if len(recommended_qids) >= count:
  569. break
  570. if len(recommended_qids) < count:
  571. fallback_q = db.query(Question).filter(Question.difficulty.in_(preferred), Question.id >= QUESTION_MIN_ID)
  572. if exclude:
  573. fallback_q = fallback_q.filter(~Question.id.in_(exclude))
  574. fallback = fallback_q.order_by(func.rand()).limit(count - len(recommended_qids)).all()
  575. for q in fallback:
  576. recommended_qids.append(q.id)
  577. exclude.append(q.id)
  578. # 兜底:若排除近7天后仍不足,允许重复旧题补齐
  579. if len(recommended_qids) < count:
  580. fallback = (
  581. db.query(Question)
  582. .filter(
  583. Question.id >= QUESTION_MIN_ID,
  584. ~Question.id.in_(recommended_qids),
  585. )
  586. .order_by(func.rand())
  587. .limit(count - len(recommended_qids))
  588. .all()
  589. )
  590. for q in fallback:
  591. recommended_qids.append(q.id)
  592. recommended_qids = recommended_qids[:count]
  593. if not recommended_qids:
  594. fallback_q = db.query(Question).filter(Question.id >= QUESTION_MIN_ID)
  595. if exclude:
  596. fallback_q = fallback_q.filter(~Question.id.in_(exclude))
  597. qs = fallback_q.order_by(func.rand()).limit(count).all()
  598. if qs:
  599. return qs
  600. # 最终兜底:完全不排除近7天
  601. return (
  602. db.query(Question)
  603. .filter(Question.id >= QUESTION_MIN_ID)
  604. .order_by(func.rand())
  605. .limit(count)
  606. .all()
  607. )
  608. questions = db.query(Question).filter(Question.id.in_(recommended_qids)).all()
  609. id_order = {qid: i for i, qid in enumerate(recommended_qids)}
  610. return sorted(questions, key=lambda q: id_order.get(q.id, 999))
  611. def _ensure_knowledge_points_from_questions(db: Session) -> None:
  612. """若 knowledge_points 为空,则从 Question.knowledge_point 同步创建"""
  613. if db.query(KnowledgePoint).first():
  614. return
  615. kp_names = (
  616. db.query(Question.knowledge_point)
  617. .filter(
  618. Question.knowledge_point.isnot(None),
  619. Question.knowledge_point != "",
  620. )
  621. .distinct()
  622. .all()
  623. )
  624. for (name,) in kp_names:
  625. if name and not db.query(KnowledgePoint).filter(KnowledgePoint.name == name).first():
  626. db.add(KnowledgePoint(name=name))
  627. db.commit()
  628. def get_mastery_matrix(db: Session, user_id: int) -> List[dict]:
  629. """
  630. 学生端知识点掌握度矩阵数据,用于可视化(颜色分级、雷达图)。
  631. 返回:[{ knowledgePointId, name, masteryScore, weaknessWeight, chapter }]
  632. """
  633. _ensure_knowledge_points_from_questions(db)
  634. rows = (
  635. db.query(
  636. KnowledgePoint.id,
  637. KnowledgePoint.name,
  638. KnowledgePoint.chapter,
  639. StudentKnowledgeMastery.mastery_score,
  640. StudentKnowledgeMastery.weakness_weight,
  641. StudentKnowledgeMastery.wrong_count,
  642. StudentKnowledgeMastery.last_practiced_at,
  643. )
  644. .outerjoin(
  645. StudentKnowledgeMastery,
  646. (StudentKnowledgeMastery.knowledge_point_id == KnowledgePoint.id)
  647. & (StudentKnowledgeMastery.user_id == user_id),
  648. )
  649. .all()
  650. )
  651. return [
  652. {
  653. "knowledgePointId": r.id,
  654. "name": r.name,
  655. "chapter": r.chapter or "",
  656. "masteryScore": r.mastery_score if r.mastery_score is not None else 0,
  657. "weaknessWeight": r.weakness_weight if r.weakness_weight is not None else 0,
  658. "wrongCount": r.wrong_count if r.wrong_count is not None else 0,
  659. "lastPracticedAt": (
  660. r.last_practiced_at.isoformat() if r.last_practiced_at else None
  661. ),
  662. }
  663. for r in rows
  664. ]
  665. def get_motivation_summary(db: Session, user_id: int) -> dict:
  666. """
  667. 学习激励汇总(不新增表):
  668. - streakDays: 连续打卡天数(存在 is_done=1 的 daily_recommendations 记为当日打卡)
  669. - greenModules: 全绿模块(章节下所有知识点 mastery_score >= 80)
  670. - moduleAverages: 章节平均掌握度(用于雷达图/柱状图)
  671. """
  672. matrix = get_mastery_matrix(db, user_id)
  673. from datetime import datetime, timedelta
  674. # 1) 按章节聚合
  675. by_chapter: dict[str, list[dict]] = {}
  676. for m in matrix:
  677. ch = (m.get("chapter") or "").strip() or "其他"
  678. by_chapter.setdefault(ch, []).append(m)
  679. module_averages = []
  680. green_modules = []
  681. for chapter, items in by_chapter.items():
  682. scores = [int(x.get("masteryScore") or 0) for x in items]
  683. if not scores:
  684. continue
  685. avg = int(round(sum(scores) / len(scores)))
  686. module_averages.append({"chapter": chapter, "avgScore": avg})
  687. if all(s >= 80 for s in scores):
  688. green_modules.append(chapter)
  689. module_averages.sort(key=lambda x: x["avgScore"], reverse=True)
  690. # 2) 连续打卡(从今天往前,只要当日有完成记录即算打卡)
  691. done_dates = {
  692. d[0]
  693. for d in db.query(DailyRecommendation.recommend_date)
  694. .filter(
  695. DailyRecommendation.user_id == user_id,
  696. DailyRecommendation.is_done == 1,
  697. DailyRecommendation.recommend_date.isnot(None),
  698. )
  699. .distinct()
  700. .all()
  701. }
  702. streak_days = 0
  703. cursor = date.today()
  704. while cursor in done_dates:
  705. streak_days += 1
  706. cursor = cursor - timedelta(days=1)
  707. # 3) 最近练习章节掌握度(过滤掉未练习知识点)
  708. # - “未练习”在这里等价于 last_practiced_at 为空
  709. # - 用最近 7 天作为口径(与其它推荐逻辑一致)
  710. since = datetime.utcnow() - timedelta(days=7)
  711. recent_rows = (
  712. db.query(
  713. KnowledgePoint.chapter,
  714. StudentKnowledgeMastery.mastery_score,
  715. )
  716. .join(
  717. StudentKnowledgeMastery,
  718. (StudentKnowledgeMastery.knowledge_point_id == KnowledgePoint.id)
  719. & (StudentKnowledgeMastery.user_id == user_id),
  720. )
  721. .filter(StudentKnowledgeMastery.last_practiced_at.isnot(None))
  722. .filter(StudentKnowledgeMastery.last_practiced_at >= since)
  723. .all()
  724. )
  725. recent_by_ch: dict[str, list[int]] = {}
  726. for ch, ms in recent_rows:
  727. ch_name = (ch or "").strip() or "其他"
  728. recent_by_ch.setdefault(ch_name, []).append(int(ms or 0))
  729. recent_module_averages = []
  730. recent_green_modules = []
  731. for chapter, items in recent_by_ch.items():
  732. if not items:
  733. continue
  734. avg = int(round(sum(items) / len(items)))
  735. recent_module_averages.append({"chapter": chapter, "avgScore": avg})
  736. if all(s >= 80 for s in items):
  737. recent_green_modules.append(chapter)
  738. recent_module_averages.sort(key=lambda x: x["avgScore"], reverse=True)
  739. return {
  740. "streakDays": streak_days,
  741. "greenModules": green_modules,
  742. "moduleAverages": module_averages,
  743. "moduleAveragesRecent": recent_module_averages,
  744. }