428 lines
13 KiB
Python
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),
|
|
)
|