recommendation_router.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. # -*- coding: utf-8 -*-
  2. """个性化推荐模块:习题测评、错题集、学情分析"""
  3. from datetime import datetime
  4. from fastapi import APIRouter, Depends
  5. from pydantic import BaseModel
  6. from sqlalchemy.orm import Session
  7. from backend.database import get_db
  8. from backend.models import Question, AnswerRecord
  9. from backend.services.auth import get_current_user_id
  10. from backend.services.recommendation import (
  11. get_user_proficiency,
  12. get_recommended_questions,
  13. get_or_generate_daily_recommendations,
  14. get_mastery_matrix,
  15. get_motivation_summary,
  16. get_question_analysis,
  17. get_wrong_questions_for_user,
  18. get_diagnostic_questions,
  19. )
  20. from backend.services.mastery_service import (
  21. update_mastery_from_answers,
  22. mark_daily_recommendations_done,
  23. )
  24. from backend.models import WrongQuestion
  25. from backend.models import DailyRecommendation
  26. router = APIRouter(prefix="/recommendation", tags=["个性化推荐"])
  27. @router.get("/proficiency")
  28. def proficiency(
  29. db: Session = Depends(get_db),
  30. user_id: int = Depends(get_current_user_id),
  31. ):
  32. """知识点熟练度(学情报表)"""
  33. return get_user_proficiency(db, user_id)
  34. @router.get("/wrong_questions")
  35. def wrong_questions(
  36. limit: int = 50,
  37. db: Session = Depends(get_db),
  38. user_id: int = Depends(get_current_user_id),
  39. ):
  40. """错题集"""
  41. questions = get_wrong_questions_for_user(db, user_id, limit=limit)
  42. return [
  43. {
  44. "id": q.id,
  45. "content": q.content,
  46. "option_a": q.option_a,
  47. "option_b": q.option_b,
  48. "option_c": q.option_c,
  49. "option_d": q.option_d,
  50. "knowledge_point": q.knowledge_point,
  51. }
  52. for q in questions
  53. ]
  54. @router.get("/recommended_questions")
  55. def recommended_questions(
  56. count: int = 5,
  57. db: Session = Depends(get_db),
  58. user_id: int = Depends(get_current_user_id),
  59. ):
  60. """习题测评推荐算法(错题优先 + 薄弱知识点)"""
  61. qs = get_recommended_questions(db, user_id, count)
  62. return [{"id": q.id, "content": q.content, "option_a": q.option_a, "option_b": q.option_b,
  63. "option_c": q.option_c, "option_d": q.option_d, "knowledge_point": q.knowledge_point}
  64. for q in qs]
  65. @router.get("/daily_practice")
  66. def daily_practice(
  67. count: int = 10,
  68. db: Session = Depends(get_db),
  69. user_id: int = Depends(get_current_user_id),
  70. ):
  71. """每日一练:按弱点权重生成今日推荐题,若今日已有则直接返回"""
  72. qs = get_or_generate_daily_recommendations(db, user_id, count=count)
  73. return [{"id": q.id, "content": q.content, "option_a": q.option_a, "option_b": q.option_b,
  74. "option_c": q.option_c, "option_d": q.option_d, "knowledge_point": q.knowledge_point}
  75. for q in qs]
  76. @router.get("/mastery_matrix")
  77. def mastery_matrix(
  78. db: Session = Depends(get_db),
  79. user_id: int = Depends(get_current_user_id),
  80. ):
  81. """知识点掌握度矩阵:用于前端颜色分级、雷达图等可视化"""
  82. return get_mastery_matrix(db, user_id)
  83. @router.get("/motivation_summary")
  84. def motivation_summary(
  85. db: Session = Depends(get_db),
  86. user_id: int = Depends(get_current_user_id),
  87. ):
  88. """激励信息汇总:连续打卡天数、全绿模块、章节平均掌握度"""
  89. return get_motivation_summary(db, user_id)
  90. @router.get("/question_analysis")
  91. def question_analysis(
  92. question_id: int,
  93. db: Session = Depends(get_db),
  94. user_id: int = Depends(get_current_user_id),
  95. ):
  96. """题目解析:从 chemistry_question_bank.analysis 读取。"""
  97. _ = user_id
  98. analysis = get_question_analysis(db, question_id)
  99. return {"question_id": question_id, "analysis": analysis}
  100. class SubmitAnswerRequest(BaseModel):
  101. question_id: int
  102. selected_option: str
  103. @router.post("/submit_answer")
  104. def submit_answer(
  105. req: SubmitAnswerRequest,
  106. db: Session = Depends(get_db),
  107. user_id: int = Depends(get_current_user_id),
  108. ):
  109. """提交单题答案(答题记录)"""
  110. q = db.query(Question).get(req.question_id)
  111. if not q:
  112. return {"ok": False, "error": "题目不存在"}
  113. is_correct = req.selected_option == q.correct_option
  114. rec = AnswerRecord(user_id=user_id, question_id=q.id, selected_option=req.selected_option, is_correct=is_correct)
  115. db.add(rec)
  116. db.commit()
  117. return {"ok": True, "is_correct": is_correct}
  118. class SubmitBatchRequest(BaseModel):
  119. answers: list[dict] # [{"question_id": 1, "selected_option": "A"}, ...]
  120. class DiagnosticSubmitRequest(BaseModel):
  121. answers: list[dict] # [{"question_id": 1, "selected_option": "A"}, ...]
  122. train_count: int = 10
  123. regenerate_daily: bool = False
  124. @router.post("/submit_batch")
  125. def submit_batch(
  126. req: SubmitBatchRequest,
  127. db: Session = Depends(get_db),
  128. user_id: int = Depends(get_current_user_id),
  129. ):
  130. """批量提交(每日训练),同时更新弱点权重与掌握度"""
  131. correct, total = 0, 0
  132. processed = []
  133. answer_record_ids = {} # {question_id: answer_records.id}
  134. for item in req.answers:
  135. q = db.query(Question).get(item.get("question_id"))
  136. if not q:
  137. continue
  138. sel = item.get("selected_option", "")
  139. is_correct = sel == q.correct_option
  140. total += 1
  141. if is_correct:
  142. correct += 1
  143. rec = AnswerRecord(
  144. user_id=user_id,
  145. question_id=q.id,
  146. selected_option=sel,
  147. is_correct=is_correct,
  148. )
  149. db.add(rec)
  150. # 获取 rec.id,后续写回 daily_recommendations.answer_record_id
  151. db.flush()
  152. answer_record_ids[q.id] = rec.id
  153. # 第二阶段:记录错题集到 wrong_questions(用于错题→变式题闭环)
  154. if not is_correct:
  155. try:
  156. wq = (
  157. db.query(WrongQuestion)
  158. .filter(
  159. WrongQuestion.user_id == user_id,
  160. WrongQuestion.question_id == q.id,
  161. )
  162. .first()
  163. )
  164. if not wq:
  165. wq = WrongQuestion(
  166. user_id=user_id,
  167. question_id=q.id,
  168. wrong_count=1,
  169. last_wrong_at=datetime.utcnow(),
  170. )
  171. db.add(wq)
  172. else:
  173. wq.wrong_count = (wq.wrong_count or 0) + 1
  174. wq.last_wrong_at = datetime.utcnow()
  175. except Exception:
  176. # 不影响主流程:错题集写入失败时仍允许学习闭环继续
  177. pass
  178. processed.append({"question_id": q.id, "selected_option": sel})
  179. if total > 0:
  180. try:
  181. update_mastery_from_answers(db, user_id, processed, session_kind="practice")
  182. from datetime import date
  183. mark_daily_recommendations_done(
  184. db, user_id,
  185. [a["question_id"] for a in processed],
  186. today=date.today(),
  187. answer_record_ids=answer_record_ids,
  188. )
  189. except Exception:
  190. pass
  191. db.commit()
  192. acc = int(round(correct / total * 100)) if total else 0
  193. return {"ok": True, "total": total, "correct": correct, "accuracy": acc}
  194. @router.get("/diagnostic_questions")
  195. def diagnostic_questions(
  196. count: int = 10,
  197. db: Session = Depends(get_db),
  198. user_id: int = Depends(get_current_user_id),
  199. ):
  200. """诊断测评:覆盖广、难度适中(用于弱点更新)"""
  201. qs = get_diagnostic_questions(db, user_id, count=count, session_kind="diagnostic")
  202. return [
  203. {
  204. "id": q.id,
  205. "content": q.content,
  206. "option_a": q.option_a,
  207. "option_b": q.option_b,
  208. "option_c": q.option_c,
  209. "option_d": q.option_d,
  210. "knowledge_point": q.knowledge_point,
  211. }
  212. for q in qs
  213. ]
  214. @router.get("/re_evaluate_questions")
  215. def re_evaluate_questions(
  216. count: int = 10,
  217. db: Session = Depends(get_db),
  218. user_id: int = Depends(get_current_user_id),
  219. ):
  220. """再测评:在测评后/训练后重新抽取题目"""
  221. qs = get_diagnostic_questions(db, user_id, count=count, session_kind="re_evaluate")
  222. return [
  223. {
  224. "id": q.id,
  225. "content": q.content,
  226. "option_a": q.option_a,
  227. "option_b": q.option_b,
  228. "option_c": q.option_c,
  229. "option_d": q.option_d,
  230. "knowledge_point": q.knowledge_point,
  231. }
  232. for q in qs
  233. ]
  234. @router.post("/diagnostic_submit_batch")
  235. def diagnostic_submit_batch(
  236. req: DiagnosticSubmitRequest,
  237. db: Session = Depends(get_db),
  238. user_id: int = Depends(get_current_user_id),
  239. ):
  240. """
  241. 诊断测评提交:
  242. - 写入 answer_records
  243. - 按弱点更新规则更新 student_knowledge_mastery
  244. - 生成(或复用)每日一练 daily_recommendations(train_count)
  245. """
  246. correct, total = 0, 0
  247. processed: list[dict] = []
  248. # 1) 写入答题记录(同时更新 wrong_questions 聚合)
  249. for item in req.answers:
  250. try:
  251. qid = int(item.get("question_id"))
  252. except (TypeError, ValueError):
  253. continue
  254. selected = (item.get("selected_option") or "").strip()
  255. if not selected:
  256. continue
  257. q = db.query(Question).get(qid)
  258. if not q:
  259. continue
  260. is_correct = selected == q.correct_option
  261. total += 1
  262. if is_correct:
  263. correct += 1
  264. db.add(
  265. AnswerRecord(
  266. user_id=user_id,
  267. question_id=q.id,
  268. selected_option=selected,
  269. is_correct=is_correct,
  270. )
  271. )
  272. if not is_correct:
  273. try:
  274. wq = (
  275. db.query(WrongQuestion)
  276. .filter(WrongQuestion.user_id == user_id, WrongQuestion.question_id == q.id)
  277. .first()
  278. )
  279. if not wq:
  280. db.add(
  281. WrongQuestion(
  282. user_id=user_id,
  283. question_id=q.id,
  284. wrong_count=1,
  285. last_wrong_at=datetime.utcnow(),
  286. )
  287. )
  288. else:
  289. wq.wrong_count = (wq.wrong_count or 0) + 1
  290. wq.last_wrong_at = datetime.utcnow()
  291. except Exception:
  292. pass
  293. processed.append({"question_id": q.id, "selected_option": selected})
  294. if total == 0:
  295. return {"ok": False, "detail": "未提交有效答题"}
  296. db.commit()
  297. # 2) 更新弱点/掌握度(批量测评结束后统一更新)
  298. update_mastery_from_answers(db, user_id, processed, session_kind="diagnostic")
  299. db.commit()
  300. # 3) 生成每日一练(闭环:测评→推荐)
  301. from datetime import date
  302. today = date.today()
  303. if req.regenerate_daily:
  304. db.query(DailyRecommendation).filter(
  305. DailyRecommendation.user_id == user_id,
  306. DailyRecommendation.recommend_date == today,
  307. DailyRecommendation.is_done == 0,
  308. ).delete(synchronize_session=False)
  309. db.commit()
  310. daily_qs = get_or_generate_daily_recommendations(
  311. db, user_id, count=req.train_count, today=today
  312. )
  313. # 返回:测评结果 + 当天每日一练题目
  314. acc = int(round(correct / total * 100))
  315. return {
  316. "ok": True,
  317. "diagnostic": {"total": total, "correct": correct, "accuracy": acc},
  318. "daily_practice": [
  319. {
  320. "id": q.id,
  321. "content": q.content,
  322. "option_a": q.option_a,
  323. "option_b": q.option_b,
  324. "option_c": q.option_c,
  325. "option_d": q.option_d,
  326. "knowledge_point": q.knowledge_point,
  327. }
  328. for q in daily_qs
  329. ],
  330. }
  331. @router.post("/re_evaluate_submit_batch")
  332. def re_evaluate_submit_batch(
  333. req: DiagnosticSubmitRequest,
  334. db: Session = Depends(get_db),
  335. user_id: int = Depends(get_current_user_id),
  336. ):
  337. """再测评提交:同诊断提交逻辑,然后给出下一轮每日一练"""
  338. resp = diagnostic_submit_batch(
  339. req=req,
  340. db=db,
  341. user_id=user_id,
  342. )
  343. return resp