nex_math/backend/routers/data.py

428 lines
13 KiB
Python

"""知识点、错题本、练习记录、今日任务与课程资源。"""
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 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)
textbook = (
db.get(Textbook, chapter.textbook_id) if chapter is not None else None
)
return {
"chapter_id": chapter.id if chapter else None,
"chapter_name": chapter.name if chapter else "",
"book_name": textbook.name if textbook else "",
"rating": completion.rating,
"knowledge_names": chapter_knowledge_names(db, chapter.id)
if chapter
else [],
"reason": "今日章节已完成",
**_ebook_ref(db, chapter.id if chapter else None),
}
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 _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()
done_dates = {
row.done_on
for row in db.query(DailyCompletion)
.filter(DailyCompletion.user_id == user.id)
.all()
}
return DailyOut(
today=today,
completed=True,
streak=_streak(done_dates),
**_daily_context(db, user.id, today),
)