Initial commit: WordLoop 单词学习应用
Vue 前端 + FastAPI 后端,含部署脚本与词典数据。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import DictionaryEntry
|
||||
|
||||
|
||||
class DictionaryService:
|
||||
def lookup_en(self, db: Session, text: str) -> Optional[DictionaryEntry]:
|
||||
key = text.lower().strip()
|
||||
if not key:
|
||||
return None
|
||||
return db.query(DictionaryEntry).filter(DictionaryEntry.lemma_en == key).first()
|
||||
|
||||
def lookup_zh(self, db: Session, text: str) -> Optional[DictionaryEntry]:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
entry = db.query(DictionaryEntry).filter(DictionaryEntry.zh == text).first()
|
||||
if entry:
|
||||
return entry
|
||||
return (
|
||||
db.query(DictionaryEntry)
|
||||
.filter(DictionaryEntry.zh.contains(text))
|
||||
.first()
|
||||
)
|
||||
|
||||
def random_zh_values(self, db: Session, exclude: str, limit: int) -> list[str]:
|
||||
rows = (
|
||||
db.query(DictionaryEntry.zh)
|
||||
.filter(DictionaryEntry.zh != exclude)
|
||||
.order_by(DictionaryEntry.id)
|
||||
.all()
|
||||
)
|
||||
values = [r[0] for r in rows]
|
||||
random.shuffle(values)
|
||||
return values[:limit]
|
||||
|
||||
def random_en_lemmas(self, db: Session, exclude: str, limit: int) -> list[str]:
|
||||
rows = (
|
||||
db.query(DictionaryEntry.lemma_en)
|
||||
.filter(DictionaryEntry.lemma_en != exclude.lower())
|
||||
.order_by(DictionaryEntry.id)
|
||||
.all()
|
||||
)
|
||||
values = [r[0] for r in rows]
|
||||
random.shuffle(values)
|
||||
return values[:limit]
|
||||
|
||||
def count(self, db: Session) -> int:
|
||||
return db.query(DictionaryEntry).count()
|
||||
|
||||
|
||||
dictionary_service = DictionaryService()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,85 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.dictionary_service import dictionary_service
|
||||
|
||||
|
||||
def contains_chinese(text: str) -> bool:
|
||||
return bool(re.search(r"[\u4e00-\u9fff]", text))
|
||||
|
||||
|
||||
class TranslationService:
|
||||
"""翻译服务:优先查离线词典表,未命中时返回占位结果(暂不接入外部 API)。"""
|
||||
|
||||
def translate(self, db: Session, text: str) -> dict:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("输入不能为空")
|
||||
|
||||
if contains_chinese(text):
|
||||
return self._zh_to_en(db, text)
|
||||
return self._en_to_zh(db, text)
|
||||
|
||||
def _en_to_zh(self, db: Session, text: str) -> dict:
|
||||
entry = dictionary_service.lookup_en(db, text)
|
||||
if entry:
|
||||
return self._build_response(
|
||||
text,
|
||||
entry.zh,
|
||||
"en",
|
||||
"zh",
|
||||
entry.phonetic,
|
||||
entry.example_en,
|
||||
entry.example_cn,
|
||||
)
|
||||
return self._placeholder(text, text, "en", "zh")
|
||||
|
||||
def _zh_to_en(self, db: Session, text: str) -> dict:
|
||||
entry = dictionary_service.lookup_zh(db, text)
|
||||
if entry:
|
||||
return self._build_response(
|
||||
text,
|
||||
entry.lemma_en,
|
||||
"zh",
|
||||
"en",
|
||||
entry.phonetic,
|
||||
entry.example_en,
|
||||
entry.example_cn,
|
||||
)
|
||||
return self._placeholder(text, f"[{text}]", "zh", "en")
|
||||
|
||||
def _build_response(
|
||||
self,
|
||||
source: str,
|
||||
target: str,
|
||||
source_lang: str,
|
||||
target_lang: str,
|
||||
phonetic: Optional[str],
|
||||
example_en: Optional[str],
|
||||
example_cn: Optional[str],
|
||||
) -> dict:
|
||||
return {
|
||||
"source_text": source,
|
||||
"target_text": target,
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang,
|
||||
"phonetic": phonetic,
|
||||
"example_en": example_en,
|
||||
"example_cn": example_cn,
|
||||
}
|
||||
|
||||
def _placeholder(self, source: str, target: str, source_lang: str, target_lang: str) -> dict:
|
||||
return {
|
||||
"source_text": source,
|
||||
"target_text": target,
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang,
|
||||
"phonetic": None,
|
||||
"example_en": f"Example with {target}." if target_lang == "en" else None,
|
||||
"example_cn": f"包含「{source}」的例句。" if source_lang == "zh" else None,
|
||||
}
|
||||
|
||||
|
||||
translation_service = TranslationService()
|
||||
@@ -0,0 +1,83 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import Word, User
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def calc_mastery_score(correct: int, wrong: int) -> int:
|
||||
total = correct + wrong
|
||||
if total == 0:
|
||||
return 0
|
||||
return round(correct / total * 100)
|
||||
|
||||
|
||||
class WordService:
|
||||
def create_word(self, db: Session, user: User, data: dict) -> Word:
|
||||
existing = (
|
||||
db.query(Word)
|
||||
.filter(
|
||||
Word.user_id == user.id,
|
||||
Word.source_text == data["source_text"],
|
||||
Word.target_text == data["target_text"],
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="该单词已存在")
|
||||
|
||||
word = Word(
|
||||
user_id=user.id,
|
||||
source_text=data["source_text"],
|
||||
target_text=data["target_text"],
|
||||
source_lang=data["source_lang"],
|
||||
target_lang=data["target_lang"],
|
||||
phonetic=data.get("phonetic"),
|
||||
example_en=data.get("example_en"),
|
||||
example_cn=data.get("example_cn"),
|
||||
status="new",
|
||||
correct_count=0,
|
||||
wrong_count=0,
|
||||
consecutive_correct_count=0,
|
||||
mastery_score=0,
|
||||
review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
created_at=utc_now_iso(),
|
||||
)
|
||||
db.add(word)
|
||||
db.commit()
|
||||
db.refresh(word)
|
||||
return word
|
||||
|
||||
def list_words(self, db: Session, user: User, status: Optional[str] = None) -> list[Word]:
|
||||
q = db.query(Word).filter(Word.user_id == user.id)
|
||||
if status:
|
||||
q = q.filter(Word.status == status)
|
||||
return q.order_by(Word.created_at.desc()).all()
|
||||
|
||||
def get_word(self, db: Session, user: User, word_id: int) -> Word:
|
||||
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
|
||||
if not word:
|
||||
raise HTTPException(status_code=404, detail="单词不存在")
|
||||
return word
|
||||
|
||||
def delete_word(self, db: Session, user: User, word_id: int) -> None:
|
||||
word = self.get_word(db, user, word_id)
|
||||
db.delete(word)
|
||||
db.commit()
|
||||
|
||||
def update_word(self, db: Session, user: User, word_id: int, data: dict) -> Word:
|
||||
word = self.get_word(db, user, word_id)
|
||||
if "status" in data and data["status"]:
|
||||
word.status = data["status"]
|
||||
db.commit()
|
||||
db.refresh(word)
|
||||
return word
|
||||
|
||||
|
||||
word_service = WordService()
|
||||
Reference in New Issue
Block a user