"""知识点、错题本、练习记录、今日任务与课程资源。""" from __future__ import annotations import json from datetime import date, timedelta from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from database import SessionLocal, get_db from dependencies import require_learning_user from models import ( AttemptItem, AttemptSession, Chapter, ChapterKnowledge, DailyCompletion, ErrorEntry, Knowledge, KnowledgeRelation, Question, QuestionKnowledge, Textbook, User, UserKnowledge, ) from schemas import ( AttemptItemOut, DailyCompleteIn, DailyOut, ErrorOut, KnowledgeChapterOut, KnowledgeDetailOut, KnowledgeRelatedOut, KnowledgeResourceOut, KnowledgeOut, RecordDetailOut, RecordOut, ) from services.mastery_engine import snapshot, status_for from services.recommendation import build_daily_recommendation from services.knowledge_service import chapter_knowledge_names, knowledge_resources router = APIRouter(tags=["data"]) def _error_out(error: ErrorEntry) -> ErrorOut: options: list[str] = [] if error.options: try: parsed = json.loads(error.options) if isinstance(parsed, list): options = [str(option) for option in parsed] except (TypeError, ValueError): options = [] try: knowledge_names = json.loads(error.knowledge_names or "[]") if not isinstance(knowledge_names, list) or not knowledge_names: knowledge_names = [error.knowledge_name] except (TypeError, ValueError): knowledge_names = [error.knowledge_name] return ErrorOut( id=error.id, source_label=error.source_label, knowledge_name=error.knowledge_name, diagnosis=error.diagnosis, status=error.status, created_on=error.created_on, attempt_id=error.attempt_id, question_id=error.question_id, stem=error.stem, options=options, selected=error.selected, correct_index=error.correct_index, explanation=error.explanation, knowledge_names=[str(name) for name in knowledge_names], ) def _record_out(session: AttemptSession) -> RecordOut: return RecordOut( id=session.id, mode=session.mode, title=session.title, total=session.total, correct=session.correct, happened_on=session.happened_on, ) def _ebook_ref(db: Session, chapter_id: int | None) -> dict: """章节对应的电子书定位:章节或电子书缺失时返回空,前端据此隐藏入口。""" chapter = db.get(Chapter, chapter_id) if chapter_id else None textbook = db.get(Textbook, chapter.textbook_id) if chapter else None if textbook is None or not textbook.ebook_file: return {"has_ebook": False} return { "textbook_id": textbook.id, "ebook_page": chapter.ebook_page, "has_ebook": True, } def _daily_context(db: Session, user_id: int, today: date) -> dict: completion = ( db.query(DailyCompletion) .filter( DailyCompletion.user_id == user_id, DailyCompletion.done_on == today, ) .first() ) if completion and completion.chapter_id is not None: chapter = db.get(Chapter, completion.chapter_id) if chapter is not None: return _completed_context(db, chapter, completion.rating) recommendation = build_daily_recommendation(db, user_id) or {} return { "chapter_id": recommendation.get("chapter_id"), "chapter_name": recommendation.get("chapter_name", ""), "book_name": recommendation.get("book_name", ""), "rating": "", "knowledge_names": recommendation.get("knowledge_names", []), "reason": recommendation.get("reason", ""), **_ebook_ref(db, recommendation.get("chapter_id")), } def _completed_context(db: Session, chapter: Chapter, rating: str) -> dict: """今日已完成时的上下文。 直接把刚写完的章节与自评拼出来,不再依赖「写完立刻回读」—— 复用同一个连接时 SQLite 偶尔还会停在写入前的快照上, 响应里就会出现「已完成但自评是空的」这种自相矛盾的结果。 """ textbook = db.get(Textbook, chapter.textbook_id) return { "chapter_id": chapter.id, "chapter_name": chapter.name, "book_name": textbook.name if textbook else "", "rating": rating, "knowledge_names": chapter_knowledge_names(db, chapter.id), "reason": "今日章节已完成", **_ebook_ref(db, chapter.id), } def _streak(done_dates: set[date]) -> int: today = date.today() if today not in done_dates and today - timedelta(days=1) not in done_dates: return 0 anchor = today if today in done_dates else today - timedelta(days=1) count = 0 while anchor in done_dates: count += 1 anchor -= timedelta(days=1) return count @router.get("/knowledge", response_model=list[KnowledgeOut]) def list_knowledge( db: Session = Depends(get_db), user: User = Depends(require_learning_user), ): return [ KnowledgeOut( id=k.id, knowledge_id=k.knowledge_id, name=k.name, mastery=k.mastery, position=k.position, status=status_for(k.mastery), domain=k.domain, category=k.category, ) for k in snapshot(db, user.id) ] @router.get("/knowledge/detail/{knowledge_id}", response_model=KnowledgeDetailOut) def knowledge_detail( knowledge_id: int, db: Session = Depends(get_db), user: User = Depends(require_learning_user), ): """知识点详情:定义、掌握度、相关知识点与反向关联的学习资源。""" node = db.get(Knowledge, knowledge_id) if node is None: raise HTTPException(status_code=404, detail="知识点不存在") mastery_map = { user_knowledge.knowledge_id: user_knowledge.mastery for user_knowledge in db.query(UserKnowledge) .filter(UserKnowledge.user_id == user.id) .all() } mastery = mastery_map.get(node.id, 0) relation_rows = ( db.query(KnowledgeRelation, Knowledge) .join( Knowledge, (Knowledge.id == KnowledgeRelation.target_id) | (Knowledge.id == KnowledgeRelation.source_id), ) .filter( (KnowledgeRelation.source_id == node.id) | (KnowledgeRelation.target_id == node.id), Knowledge.id != node.id, ) .all() ) related: list[KnowledgeRelatedOut] = [] seen_related: set[int] = set() for relation, other in relation_rows: if other.id in seen_related: continue seen_related.add(other.id) related.append( KnowledgeRelatedOut( id=other.id, name=other.name, relation_type=relation.relation_type, direction="in" if relation.target_id == node.id else "out", mastery=mastery_map.get(other.id), ) ) question_count = ( db.query(QuestionKnowledge) .filter(QuestionKnowledge.knowledge_id == node.id) .count() ) chapter_rows = ( db.query(Chapter, Textbook) .join(ChapterKnowledge, ChapterKnowledge.chapter_id == Chapter.id) .join(Textbook, Textbook.id == Chapter.textbook_id) .filter(ChapterKnowledge.knowledge_id == node.id) .order_by(Textbook.position.asc(), Chapter.position.asc()) .all() ) chapters = [ KnowledgeChapterOut( chapter_id=chapter.id, chapter_name=chapter.name, textbook_id=textbook.id, textbook_name=textbook.name, ebook_page=chapter.ebook_page, has_ebook=bool(textbook.ebook_file), question_count=db.query(Question) .filter(Question.chapter_id == chapter.id) .count(), ) for chapter, textbook in chapter_rows ] resources = [ KnowledgeResourceOut( id=row.id, kind=row.kind, title=row.title, url=row.url, source_name=row.title, ) for row in knowledge_resources(db, node.id) ] return KnowledgeDetailOut( id=node.id, name=node.name, domain=node.domain, category=node.category, description=node.description or "", mastery=mastery, status=status_for(mastery) if node.id in mastery_map else "未学习", related_knowledge_count=len(related), chapter_count=len(chapters), question_count=question_count, related=related, chapters=chapters, resources=resources, ) @router.get("/errors", response_model=list[ErrorOut]) def list_errors( db: Session = Depends(get_db), user: User = Depends(require_learning_user), ): rows = ( db.query(ErrorEntry) .filter(ErrorEntry.user_id == user.id) .order_by(ErrorEntry.id.desc()) .all() ) return [ _error_out(e) for e in rows ] @router.get("/records", response_model=list[RecordOut]) def list_records( db: Session = Depends(get_db), user: User = Depends(require_learning_user), ): rows = ( db.query(AttemptSession) .filter(AttemptSession.user_id == user.id) .order_by(AttemptSession.id.desc()) .all() ) return [ _record_out(s) for s in rows ] @router.get("/records/{record_id}", response_model=RecordDetailOut) def record_detail( record_id: int, db: Session = Depends(get_db), user: User = Depends(require_learning_user), ): session = ( db.query(AttemptSession) .filter( AttemptSession.id == record_id, AttemptSession.user_id == user.id, ) .first() ) if session is None: raise HTTPException(status_code=404, detail="练习记录不存在") items = ( db.query(AttemptItem) .filter(AttemptItem.attempt_id == session.id) .order_by(AttemptItem.question_index.asc(), AttemptItem.id.asc()) .all() ) parsed_items: list[AttemptItemOut] = [] for item in items: try: options = json.loads(item.options) if not isinstance(options, list): options = [] except (TypeError, ValueError): options = [] try: parsed_names = json.loads(item.knowledge_names or "[]") if not isinstance(parsed_names, list) or not parsed_names: parsed_names = [item.knowledge_name] except (TypeError, ValueError): parsed_names = [item.knowledge_name] parsed_items.append( AttemptItemOut( question_index=item.question_index, question_id=item.question_id, stem=item.stem, options=[str(option) for option in options], selected=item.selected, correct_index=item.correct_index, correct=bool(item.correct), knowledge_name=item.knowledge_name, explanation=item.explanation, knowledge_names=[str(name) for name in parsed_names], ) ) return RecordDetailOut( **_record_out(session).model_dump(), items=parsed_items, ) @router.get("/daily", response_model=DailyOut) def get_daily( db: Session = Depends(get_db), user: User = Depends(require_learning_user), ): today = date.today() done_dates = { row.done_on for row in db.query(DailyCompletion) .filter(DailyCompletion.user_id == user.id) .all() } context = _daily_context(db, user.id, today) return DailyOut( today=today, completed=today in done_dates, streak=_streak(done_dates), **context, ) @router.post("/daily/complete", response_model=DailyOut) def complete_daily( payload: DailyCompleteIn, db: Session = Depends(get_db), user: User = Depends(require_learning_user), ): today = date.today() chapter = db.get(Chapter, payload.chapter_id) if chapter is None: raise HTTPException(status_code=404, detail="章节不存在") exists = ( db.query(DailyCompletion) .filter( DailyCompletion.user_id == user.id, DailyCompletion.done_on == today, ) .first() ) if exists is None: exists = DailyCompletion( user_id=user.id, chapter_id=chapter.id, done_on=today ) db.add(exists) exists.chapter_id = chapter.id exists.rating = payload.rating db.commit() # 写完用新会话回读连续天数:同一个 session 复用连接时可能读到写入前的快照, # 会让响应里的连续天数与自评对不上(前端就会显示成提交没生效)。 read_db = SessionLocal() try: done_dates = { row.done_on for row in read_db.query(DailyCompletion) .filter(DailyCompletion.user_id == user.id) .all() } finally: read_db.close() # 今天这一条就是本次写入的,不依赖回读结果 done_dates.add(today) return DailyOut( today=today, completed=True, streak=_streak(done_dates), **_completed_context(db, chapter, payload.rating), )