Initial commit: WordLoop 单词学习应用

Vue 前端 + FastAPI 后端,含部署脚本与词典数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 14:30:53 -07:00
commit bd7635986a
66 changed files with 5495 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
import random
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import func
from sqlalchemy.orm import Session
from models import QuizRecord, User, UserSettings, Word
from schemas import QuizOption, QuizQuestion, QuizStatsResponse, WordOut
from services.dictionary_service import dictionary_service
from services.word_service import calc_mastery_score, utc_now_iso
def today_str() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def review_interval_days(consecutive: int) -> int:
"""根据连续答对次数计算下次复习间隔(天)。"""
if consecutive <= 0:
return 0
if consecutive == 1:
return 1
if consecutive == 2:
return 3
if consecutive == 3:
return 7
if consecutive >= 5:
return 15
return 7
class QuizService:
def get_settings(self, db: Session, user: User) -> UserSettings:
settings = db.query(UserSettings).filter(UserSettings.user_id == user.id).first()
if not settings:
settings = UserSettings(user_id=user.id)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
def select_daily_words(self, db: Session, user: User, limit: int) -> list[Word]:
today = today_str()
all_words = db.query(Word).filter(Word.user_id == user.id).all()
weak = [w for w in all_words if w.status == "weak"]
learning = [w for w in all_words if w.status == "learning"]
new = [w for w in all_words if w.status == "new"]
mastered_due = [
w
for w in all_words
if w.status == "mastered"
and w.review_due_date
and w.review_due_date <= today
]
pool: list[Word] = []
for group in (weak, learning, new, mastered_due):
random.shuffle(group)
for w in group:
if w not in pool:
pool.append(w)
if len(pool) >= limit:
return pool[:limit]
return pool[:limit]
def build_question(self, db: Session, word: Word, all_words: list[Word]) -> QuizQuestion:
question_type = random.choice(["en_to_zh", "zh_to_en"])
if question_type == "en_to_zh":
en = word.target_text if word.source_lang == "zh" else word.source_text
zh = word.source_text if word.source_lang == "zh" else word.target_text
prompt = en
correct = zh
distractor_pool = [
(w.source_text if w.source_lang == "zh" else w.target_text)
for w in all_words
if w.id != word.id
]
else:
zh = word.source_text if word.source_lang == "zh" else word.target_text
en = word.target_text if word.source_lang == "zh" else word.source_text
prompt = zh
correct = en
distractor_pool = [
(w.target_text if w.source_lang == "zh" else w.source_text)
for w in all_words
if w.id != word.id
]
distractors = list({d for d in distractor_pool if d != correct})
random.shuffle(distractors)
if len(distractors) < 3:
if question_type == "en_to_zh":
extra = dictionary_service.random_zh_values(db, correct, 10)
else:
extra = dictionary_service.random_en_lemmas(db, correct, 10)
for e in extra:
if e not in distractors:
distractors.append(e)
if len(distractors) >= 3:
break
options_text = [correct] + distractors[:3]
random.shuffle(options_text)
labels = ["A", "B", "C", "D"]
options = [
QuizOption(label=labels[i], text=options_text[i])
for i in range(min(4, len(options_text)))
]
return QuizQuestion(
word_id=word.id,
question_type=question_type,
prompt=prompt,
options=options,
correct_answer=correct,
)
def get_daily_quiz(self, db: Session, user: User) -> dict:
settings = self.get_settings(db, user)
words = self.select_daily_words(db, user, settings.daily_target)
all_words = db.query(Word).filter(Word.user_id == user.id).all()
if len(all_words) < 4:
questions = []
if words:
questions = [self.build_question(db, words[0], all_words)]
else:
questions = [self.build_question(db, w, all_words) for w in words]
return {
"questions": questions,
"total": len(questions),
}
def submit_answer(
self,
db: Session,
user: User,
word_id: int,
question_type: str,
user_answer: str,
correct_answer: str,
) -> dict:
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
if not word:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="单词不存在")
settings = self.get_settings(db, user)
is_correct = user_answer.strip() == correct_answer.strip()
now = utc_now_iso()
today = today_str()
if is_correct:
word.correct_count += 1
word.consecutive_correct_count += 1
if word.status == "new":
word.status = "learning"
if word.consecutive_correct_count >= settings.master_required_count:
word.status = "mastered"
days = review_interval_days(word.consecutive_correct_count)
due = datetime.now(timezone.utc) + timedelta(days=days)
word.review_due_date = due.strftime("%Y-%m-%d")
else:
word.wrong_count += 1
word.consecutive_correct_count = 0
if word.wrong_count >= settings.weak_wrong_threshold:
word.status = "weak"
elif word.status == "new":
word.status = "learning"
word.review_due_date = today
word.mastery_score = calc_mastery_score(word.correct_count, word.wrong_count)
word.last_reviewed_at = now
record = QuizRecord(
user_id=user.id,
word_id=word.id,
question_type=question_type,
user_answer=user_answer,
correct_answer=correct_answer,
is_correct=1 if is_correct else 0,
created_at=now,
)
db.add(record)
db.commit()
db.refresh(word)
return {
"is_correct": is_correct,
"correct_answer": correct_answer,
"word": WordOut.model_validate(word),
}
def get_stats(self, db: Session, user: User) -> QuizStatsResponse:
settings = self.get_settings(db, user)
today = today_str()
total = db.query(Word).filter(Word.user_id == user.id).count()
new_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "new").count()
learning_count = (
db.query(Word).filter(Word.user_id == user.id, Word.status == "learning").count()
)
mastered_count = (
db.query(Word).filter(Word.user_id == user.id, Word.status == "mastered").count()
)
weak_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "weak").count()
today_records = (
db.query(QuizRecord)
.filter(
QuizRecord.user_id == user.id,
func.date(QuizRecord.created_at) == today,
)
.all()
)
# SQLite date compare fallback: filter by prefix
if not today_records:
today_records = [
r
for r in db.query(QuizRecord).filter(QuizRecord.user_id == user.id).all()
if r.created_at.startswith(today)
]
today_quiz_count = len(today_records)
today_correct = sum(1 for r in today_records if r.is_correct == 1)
accuracy = (
round(today_correct / today_quiz_count * 100, 1) if today_quiz_count > 0 else 0.0
)
streak = self._calc_streak(db, user)
return QuizStatsResponse(
total_words=total,
new_count=new_count,
learning_count=learning_count,
mastered_count=mastered_count,
weak_count=weak_count,
today_quiz_count=today_quiz_count,
today_correct_count=today_correct,
today_accuracy=accuracy,
daily_target=settings.daily_target,
today_completed=today_quiz_count,
streak_days=streak,
)
def _calc_streak(self, db: Session, user: User) -> int:
records = (
db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id)
.order_by(QuizRecord.created_at.desc())
.all()
)
days_with_quiz = set()
for r in records:
days_with_quiz.add(r.created_at[:10])
streak = 0
d = datetime.now(timezone.utc).date()
while d.isoformat() in days_with_quiz:
streak += 1
d -= timedelta(days=1)
return streak
quiz_service = QuizService()