Add spell practice, memory analytics, auth persistence, and word library UX.

Includes per-word training stats and curves, quiz session auto-save, remember-login,
paginated word list with floating page arrows, and Obsidian-style relationship graph baseline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 14:57:39 -07:00
parent bd7635986a
commit 68c5efe573
30 changed files with 2560 additions and 63 deletions
+8 -3
View File
@@ -13,7 +13,11 @@ from models import User
SECRET_KEY = os.getenv("WORDLOOP_SECRET_KEY", "wordloop-dev-secret-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
# 未勾选「记住登录」:较短有效期;勾选后:长期有效(降低安全级别、减少重复登录)
SESSION_TOKEN_EXPIRE_MINUTES = int(os.getenv("WORDLOOP_SESSION_EXPIRE_MINUTES", str(60 * 24)))
REMEMBER_TOKEN_EXPIRE_MINUTES = int(
os.getenv("WORDLOOP_REMEMBER_EXPIRE_MINUTES", str(60 * 24 * 30))
)
security = HTTPBearer()
@@ -26,8 +30,9 @@ def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
def create_access_token(user_id: int, username: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
def create_access_token(user_id: int, username: str, *, remember: bool = True) -> str:
minutes = REMEMBER_TOKEN_EXPIRE_MINUTES if remember else SESSION_TOKEN_EXPIRE_MINUTES
expire = datetime.now(timezone.utc) + timedelta(minutes=minutes)
payload = {"sub": str(user_id), "username": username, "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
+52
View File
@@ -0,0 +1,52 @@
"""启动时补齐新增列(已有库无需手工迁移)。"""
from sqlalchemy import inspect, text
from database import engine
def run_migrations() -> None:
insp = inspect(engine)
with engine.begin() as conn:
if insp.has_table("words"):
cols = {c["name"] for c in insp.get_columns("words")}
if "total_train_seconds" not in cols:
conn.execute(
text(
"ALTER TABLE words ADD COLUMN total_train_seconds "
"INTEGER NOT NULL DEFAULT 0"
)
)
if "train_count" not in cols:
conn.execute(
text("ALTER TABLE words ADD COLUMN train_count INTEGER NOT NULL DEFAULT 0")
)
if insp.has_table("quiz_records"):
cols = {c["name"] for c in insp.get_columns("quiz_records")}
if "duration_seconds" not in cols:
conn.execute(
text(
"ALTER TABLE quiz_records ADD COLUMN duration_seconds "
"INTEGER NOT NULL DEFAULT 0"
)
)
if insp.has_table("words") and insp.has_table("quiz_records"):
conn.execute(
text(
"""
UPDATE words w SET
train_count = (
SELECT COUNT(*) FROM quiz_records q WHERE q.word_id = w.id
),
total_train_seconds = (
SELECT COALESCE(SUM(duration_seconds), 0)
FROM quiz_records q WHERE q.word_id = w.id
)
WHERE train_count = 0 AND EXISTS (
SELECT 1 FROM quiz_records q WHERE q.word_id = w.id
)
"""
)
)
+2
View File
@@ -2,9 +2,11 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import Base, engine
from db_migrate import run_migrations
from routers import auth_router, quiz_router, settings_router, translate_router, word_router
Base.metadata.create_all(bind=engine)
run_migrations()
app = FastAPI(title="WordLoop API", version="1.0.0")
+3
View File
@@ -29,6 +29,8 @@ class Word(Base):
wrong_count = Column(Integer, nullable=False, default=0)
consecutive_correct_count = Column(Integer, nullable=False, default=0)
mastery_score = Column(Integer, nullable=False, default=0)
train_count = Column(Integer, nullable=False, default=0)
total_train_seconds = Column(Integer, nullable=False, default=0)
review_due_date = Column(String(32), nullable=True)
last_reviewed_at = Column(String(32), nullable=True)
created_at = Column(String(32), nullable=False)
@@ -44,6 +46,7 @@ class QuizRecord(Base):
user_answer = Column(String(500), nullable=False)
correct_answer = Column(String(500), nullable=False)
is_correct = Column(Integer, nullable=False)
duration_seconds = Column(Integer, nullable=False, default=0)
created_at = Column(String(32), nullable=False)
+1 -1
View File
@@ -46,7 +46,7 @@ def login(data: UserLogin, db: Session = Depends(get_db)):
if not user or not verify_password(data.password, user.password_hash):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = create_access_token(user.id, user.username)
token = create_access_token(user.id, user.username, remember=data.remember)
return TokenResponse(access_token=token)
+10
View File
@@ -24,6 +24,15 @@ def daily_quiz(
return DailyQuizResponse(**result)
@router.get("/spell", response_model=DailyQuizResponse)
def spell_quiz(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = quiz_service.get_spell_quiz(db, current_user)
return DailyQuizResponse(**result)
@router.post("/answer", response_model=QuizAnswerResponse)
def submit_answer(
data: QuizAnswerRequest,
@@ -37,6 +46,7 @@ def submit_answer(
data.question_type,
data.user_answer,
data.correct_answer,
data.duration_seconds or 0,
)
return QuizAnswerResponse(**result)
+25 -1
View File
@@ -6,7 +6,14 @@ from sqlalchemy.orm import Session
from auth import get_current_user
from database import get_db
from models import User
from schemas import WordCreate, WordOut, WordUpdate
from schemas import (
MemoryVisualizationResponse,
WordCreate,
WordMemoryDetailResponse,
WordOut,
WordUpdate,
)
from services.memory_visual_service import memory_visual_service
from services.word_service import word_service
router = APIRouter(prefix="/api/words", tags=["words"])
@@ -31,6 +38,23 @@ def list_words(
return word_service.list_words(db, current_user, status)
@router.get("/memory-viz", response_model=MemoryVisualizationResponse)
def memory_visualization(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return memory_visual_service.get_visualization(db, current_user)
@router.get("/{word_id}/memory", response_model=WordMemoryDetailResponse)
def word_memory(
word_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return memory_visual_service.get_word_memory(db, current_user, word_id)
@router.get("/{word_id}", response_model=WordOut)
def get_word(
word_id: int,
+95 -2
View File
@@ -1,5 +1,5 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, computed_field
# Auth
@@ -11,6 +11,7 @@ class UserRegister(BaseModel):
class UserLogin(BaseModel):
username: str
password: str
remember: bool = True
class TokenResponse(BaseModel):
@@ -72,14 +73,104 @@ class WordOut(BaseModel):
wrong_count: int
consecutive_correct_count: int
mastery_score: int
train_count: int = 0
total_train_seconds: int = 0
review_due_date: Optional[str] = None
last_reviewed_at: Optional[str] = None
created_at: str
@computed_field # type: ignore[prop-decorator]
@property
def entered_at(self) -> str:
"""词库进入时间(与 created_at 一致)。"""
return self.created_at
class Config:
from_attributes = True
class MemoryCurvePoint(BaseModel):
day_index: int
date: str
forgetting: float
mastery: float
risk: float
class MemoryFutureRiskPoint(BaseModel):
day_offset: int
date: str
risk: float
class MemoryGraphNode(BaseModel):
id: str
label: str
zh: str
status: str
mastery: int
entered_at: str
size: int
class MemoryGraphLink(BaseModel):
source: str
target: str
kind: str
strength: float
class MemoryGraph(BaseModel):
nodes: list[MemoryGraphNode]
links: list[MemoryGraphLink]
class MemoryWordSummary(BaseModel):
id: int
en: str
zh: str
status: str
mastery_score: int
correct_count: int = 0
wrong_count: int = 0
train_count: int = 0
total_train_seconds: int = 0
entered_at: str
retention_now: float
risk_7d: float
class WordMemoryCurvePoint(BaseModel):
date: str
datetime: str
forgetting: float
mastery: float
risk: float
wrong_count: int
train_count: int
train_seconds: int
is_correct: Optional[bool] = None
class WordMemoryDetailResponse(BaseModel):
word_id: int
en: str
zh: str
correct_count: int
wrong_count: int
train_count: int
total_train_seconds: int
curve_points: list[WordMemoryCurvePoint]
future_risk: list[MemoryFutureRiskPoint]
class MemoryVisualizationResponse(BaseModel):
curve_points: list[MemoryCurvePoint]
future_risk: list[MemoryFutureRiskPoint]
words: list[MemoryWordSummary]
graph: MemoryGraph
# Quiz
class QuizOption(BaseModel):
label: str
@@ -90,8 +181,9 @@ class QuizQuestion(BaseModel):
word_id: int
question_type: str
prompt: str
options: list[QuizOption]
options: list[QuizOption] = []
correct_answer: str
phonetic: Optional[str] = None
class DailyQuizResponse(BaseModel):
@@ -104,6 +196,7 @@ class QuizAnswerRequest(BaseModel):
question_type: str
user_answer: str
correct_answer: str
duration_seconds: Optional[int] = Field(None, ge=0, le=3600)
class QuizAnswerResponse(BaseModel):
+143
View File
@@ -0,0 +1,143 @@
"""向用户词库批量导入单词(测试用)。
用法(backend 目录):
python -m scripts.seed_user_words --username john --count 200
python -m scripts.seed_user_words --username john --count 200 --with-quiz 80
"""
from __future__ import annotations
import argparse
import random
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BACKEND_DIR))
from database import SessionLocal # noqa: E402
from models import DictionaryEntry, QuizRecord, User, Word # noqa: E402
from services.word_service import calc_mastery_score, utc_now_iso # noqa: E402
def utc_now_iso_at(dt: datetime) -> str:
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
def seed_words(
db,
user: User,
count: int,
offset: int,
) -> tuple[int, int]:
existing_pairs = {
(w.source_text, w.target_text)
for w in db.query(Word).filter(Word.user_id == user.id).all()
}
entries = (
db.query(DictionaryEntry)
.order_by(DictionaryEntry.id)
.offset(offset)
.limit(count * 3)
.all()
)
added = 0
skipped = 0
now = utc_now_iso()
for e in entries:
if added >= count:
break
zh = (e.zh or "").strip()
en = (e.lemma_en or "").strip().lower()
if not zh or not en:
skipped += 1
continue
if (en, zh) in existing_pairs or (zh, en) in existing_pairs:
skipped += 1
continue
word = Word(
user_id=user.id,
source_text=zh,
target_text=en,
source_lang="zh",
target_lang="en",
phonetic=e.phonetic,
example_en=e.example_en,
example_cn=e.example_cn,
status=random.choice(["new", "new", "learning", "mastered", "weak"]),
correct_count=random.randint(0, 8),
wrong_count=random.randint(0, 4),
consecutive_correct_count=random.randint(0, 3),
mastery_score=0,
train_count=0,
total_train_seconds=0,
review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
created_at=now,
)
word.mastery_score = calc_mastery_score(word.correct_count, word.wrong_count)
db.add(word)
existing_pairs.add((zh, en))
added += 1
db.commit()
return added, skipped
def seed_quiz_records(db, user: User, max_records: int) -> int:
words = db.query(Word).filter(Word.user_id == user.id).all()
if not words:
return 0
now = datetime.now(timezone.utc)
created = 0
for i in range(max_records):
w = random.choice(words)
is_correct = random.random() > 0.35
t = now - timedelta(days=random.randint(0, 20), hours=random.randint(0, 23))
record = QuizRecord(
user_id=user.id,
word_id=w.id,
question_type=random.choice(["en_to_zh", "zh_to_en", "spell"]),
user_answer="test",
correct_answer="test",
is_correct=1 if is_correct else 0,
duration_seconds=random.randint(3, 45),
created_at=utc_now_iso_at(t),
)
db.add(record)
created += 1
db.commit()
return created
def main() -> None:
parser = argparse.ArgumentParser(description="批量导入用户单词")
parser.add_argument("--username", default="john")
parser.add_argument("--count", type=int, default=200)
parser.add_argument("--offset", type=int, default=5000, help="词典偏移,避免总取相同词")
parser.add_argument("--with-quiz", type=int, default=0, help="额外生成 N 条模拟训练记录")
args = parser.parse_args()
db = SessionLocal()
try:
user = db.query(User).filter(User.username == args.username).first()
if not user:
print(f"用户不存在: {args.username}", file=sys.stderr)
sys.exit(1)
before = db.query(Word).filter(Word.user_id == user.id).count()
added, skipped = seed_words(db, user, args.count, args.offset)
after = db.query(Word).filter(Word.user_id == user.id).count()
print(f"用户 {args.username}: 原有 {before} 词, 新增 {added}, 跳过 {skipped}, 现有 {after}")
if args.with_quiz > 0:
n = seed_quiz_records(db, user, args.with_quiz)
print(f"已生成模拟训练记录 {n}")
finally:
db.close()
if __name__ == "__main__":
main()
+355
View File
@@ -0,0 +1,355 @@
import math
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy.orm import Session
from models import QuizRecord, User, Word
def parse_iso(s: str) -> datetime:
s = s.replace("Z", "+00:00")
try:
return datetime.fromisoformat(s)
except ValueError:
return datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
def word_en(w: Word) -> str:
return w.target_text if w.source_lang == "zh" else w.source_text
def word_zh(w: Word) -> str:
return w.source_text if w.source_lang == "zh" else w.target_text
def stability_hours(word: Word) -> float:
"""记忆稳定性(小时),随练习增强。"""
base = 24.0
streak = min(word.consecutive_correct_count, 6)
mastery_factor = 1 + word.mastery_score / 200
return base * (1.6**streak) * mastery_factor
def retention_percent(hours_since: float, stability_h: float) -> float:
"""艾宾浩斯型遗忘:R = 100 * e^(-t/S)"""
s = max(stability_h, 6.0)
return max(0.0, min(100.0, 100 * math.exp(-hours_since / s)))
def mastery_at_time(word: Word, records: list[QuizRecord], at: datetime) -> float:
correct = 0
wrong = 0
for r in records:
if r.word_id != word.id:
continue
if parse_iso(r.created_at) > at:
break
if r.is_correct:
correct += 1
else:
wrong += 1
total = correct + wrong
if total == 0:
return float(word.mastery_score) if word.last_reviewed_at else 0.0
return round(correct / total * 100, 1)
class MemoryVisualService:
def get_visualization(self, db: Session, user: User, horizon_days: int = 30) -> dict:
words = db.query(Word).filter(Word.user_id == user.id).all()
records = (
db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id)
.order_by(QuizRecord.created_at.asc())
.all()
)
records_by_word: dict[int, list[QuizRecord]] = defaultdict(list)
for r in records:
records_by_word[r.word_id].append(r)
now = datetime.now(timezone.utc)
horizon_days = max(7, min(horizon_days, 60))
if not words:
return {
"curve_points": [],
"words": [],
"graph": {"nodes": [], "links": []},
}
earliest = min(parse_iso(w.created_at) for w in words)
span_days = max(
1,
int((now - earliest).total_seconds() // 86400) + 1,
)
horizon = min(horizon_days, span_days)
curve_points = []
for d in range(horizon + 1):
t = earliest + timedelta(days=d)
forgetting_vals: list[float] = []
mastery_vals: list[float] = []
risk_vals: list[float] = []
for w in words:
entered = parse_iso(w.created_at)
if t < entered:
continue
hours_after_enter = (t - entered).total_seconds() / 3600
stab = stability_hours(w)
wr = records_by_word.get(w.id, [])
# 遗忘曲线:自加入词库起的记忆保留率
reviews_before = sum(1 for r in wr if parse_iso(r.created_at) <= t)
boost = 1 + reviews_before * 0.12
forgetting_vals.append(
retention_percent(hours_after_enter, stab * boost)
)
# 熟练曲线:截至该日的掌握度
mastery_vals.append(mastery_at_time(w, wr, t))
# 可能遗忘:从该日视角若不再复习的预测保留率
last_at = entered
if w.last_reviewed_at:
lr = parse_iso(w.last_reviewed_at)
if lr <= t:
last_at = lr
hours_since_review = (t - last_at).total_seconds() / 3600
risk_vals.append(retention_percent(hours_since_review, stab * 0.85))
if not forgetting_vals:
continue
curve_points.append(
{
"day_index": d,
"date": t.strftime("%Y-%m-%d"),
"forgetting": round(sum(forgetting_vals) / len(forgetting_vals), 1),
"mastery": round(sum(mastery_vals) / len(mastery_vals), 1),
"risk": round(sum(risk_vals) / len(risk_vals), 1),
}
)
# 今日起未来 14 天风险预测
future_risk = []
for fd in range(15):
t = now + timedelta(days=fd)
vals = []
for w in words:
last_at = parse_iso(w.last_reviewed_at or w.created_at)
hours = (t - last_at).total_seconds() / 3600
vals.append(retention_percent(hours, stability_hours(w)))
future_risk.append(
{
"day_offset": fd,
"date": t.strftime("%Y-%m-%d"),
"risk": round(sum(vals) / len(vals), 1),
}
)
word_summaries = []
for w in words:
stab_h = stability_hours(w)
last_at = parse_iso(w.last_reviewed_at or w.created_at)
hours_since = (now - last_at).total_seconds() / 3600
word_summaries.append(
{
"id": w.id,
"en": word_en(w),
"zh": word_zh(w),
"status": w.status,
"mastery_score": w.mastery_score,
"correct_count": w.correct_count,
"wrong_count": w.wrong_count,
"train_count": w.train_count,
"total_train_seconds": w.total_train_seconds,
"entered_at": w.created_at,
"retention_now": round(retention_percent(hours_since, stab_h), 1),
"risk_7d": round(
retention_percent(hours_since + 7 * 24, stab_h), 1
),
}
)
graph = self._build_graph(words, records)
return {
"curve_points": curve_points,
"future_risk": future_risk,
"words": word_summaries,
"graph": graph,
}
def _build_graph(self, words: list[Word], records: list[QuizRecord]) -> dict:
nodes = []
id_set = set()
for w in words:
id_set.add(w.id)
nodes.append(
{
"id": str(w.id),
"label": word_en(w)[:16],
"zh": word_zh(w)[:8],
"status": w.status,
"mastery": w.mastery_score,
"entered_at": w.created_at,
"size": 8 + min(w.correct_count + w.wrong_count, 20),
}
)
links = []
seen_edges: set[tuple[str, str]] = set()
def add_link(a: int, b: int, kind: str, strength: float = 0.5) -> None:
if a == b or a not in id_set or b not in id_set:
return
key = (str(min(a, b)), str(max(a, b)))
if key in seen_edges:
return
seen_edges.add(key)
links.append(
{
"source": str(a),
"target": str(b),
"kind": kind,
"strength": strength,
}
)
# 同日练习关联
by_day: dict[str, list[int]] = defaultdict(list)
for r in records:
by_day[r.created_at[:10]].append(r.word_id)
for ids in by_day.values():
unique = list(set(ids))
for i in range(len(unique)):
for j in range(i + 1, len(unique)):
add_link(unique[i], unique[j], "co_review", 0.7)
# 相同学习状态
by_status: dict[str, list[int]] = defaultdict(list)
for w in words:
by_status[w.status].append(w.id)
for ids in by_status.values():
for i in range(len(ids)):
for j in range(i + 1, min(i + 4, len(ids))): # 限制边数量
add_link(ids[i], ids[j], "status", 0.35)
# 词形相近(英文前缀 / 包含关系)
en_map = {w.id: word_en(w).lower() for w in words}
ids = list(en_map.keys())
for i in range(len(ids)):
for j in range(i + 1, len(ids)):
a, b = en_map[ids[i]], en_map[ids[j]]
if len(a) >= 3 and len(b) >= 3:
if a[:3] == b[:3] or a in b or b in a:
add_link(ids[i], ids[j], "similar", 0.45)
return {"nodes": nodes, "links": links[:120]}
def build_word_curve_points(self, word: Word, records: list[QuizRecord]) -> list[dict]:
entered = parse_iso(word.created_at)
points: list[dict] = [
{
"date": entered.strftime("%Y-%m-%d"),
"datetime": word.created_at,
"forgetting": 100.0,
"mastery": 0.0,
"risk": 100.0,
"wrong_count": 0,
"train_count": 0,
"train_seconds": 0,
"is_correct": None,
}
]
if not records:
return points
correct = 0
wrong = 0
total_sec = 0
last_review = entered
for i, r in enumerate(records):
t = parse_iso(r.created_at)
if r.is_correct:
correct += 1
else:
wrong += 1
total_sec += r.duration_seconds or 0
reviews = i + 1
hours_enter = (t - entered).total_seconds() / 3600
stab = stability_hours(word)
forgetting = retention_percent(hours_enter, stab * (1 + reviews * 0.12))
total = correct + wrong
mastery = round(correct / total * 100, 1) if total else 0.0
hours_since = (t - last_review).total_seconds() / 3600
risk = retention_percent(hours_since, stab * 0.85)
last_review = t
points.append(
{
"date": t.strftime("%Y-%m-%d"),
"datetime": r.created_at,
"forgetting": round(forgetting, 1),
"mastery": mastery,
"risk": round(risk, 1),
"wrong_count": wrong,
"train_count": i + 1,
"train_seconds": total_sec,
"is_correct": bool(r.is_correct),
}
)
return points
def build_word_future_risk(self, word: Word) -> list[dict]:
now = datetime.now(timezone.utc)
last_at = parse_iso(word.last_reviewed_at or word.created_at)
hours_since = (now - last_at).total_seconds() / 3600
stab = stability_hours(word)
future = []
for fd in range(15):
t = now + timedelta(days=fd)
hours = hours_since + fd * 24
future.append(
{
"day_offset": fd,
"date": t.strftime("%Y-%m-%d"),
"risk": round(retention_percent(hours, stab), 1),
}
)
return future
def get_word_memory(self, db: Session, user: User, word_id: int) -> 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="单词不存在")
records = (
db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id, QuizRecord.word_id == word.id)
.order_by(QuizRecord.created_at.asc())
.all()
)
return {
"word_id": word.id,
"en": word_en(word),
"zh": word_zh(word),
"correct_count": word.correct_count,
"wrong_count": word.wrong_count,
"train_count": word.train_count,
"total_train_seconds": word.total_train_seconds,
"curve_points": self.build_word_curve_points(word, records),
"future_risk": self.build_word_future_risk(word),
}
memory_visual_service = MemoryVisualService()
+32 -1
View File
@@ -118,6 +118,27 @@ class QuizService:
correct_answer=correct,
)
def build_spell_question(self, word: Word) -> QuizQuestion:
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
return QuizQuestion(
word_id=word.id,
question_type="spell",
prompt=zh,
phonetic=word.phonetic,
options=[],
correct_answer=en,
)
def get_spell_quiz(self, db: Session, user: User) -> dict:
settings = self.get_settings(db, user)
words = self.select_daily_words(db, user, settings.daily_target)
questions = [self.build_spell_question(w) for w in words]
return {
"questions": questions,
"total": len(questions),
}
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)
@@ -143,6 +164,7 @@ class QuizService:
question_type: str,
user_answer: str,
correct_answer: str,
duration_seconds: int = 0,
) -> dict:
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
if not word:
@@ -150,9 +172,15 @@ class QuizService:
raise HTTPException(status_code=404, detail="单词不存在")
settings = self.get_settings(db, user)
is_correct = user_answer.strip() == correct_answer.strip()
if question_type == "spell":
is_correct = (
user_answer.strip().lower() == correct_answer.strip().lower()
)
else:
is_correct = user_answer.strip() == correct_answer.strip()
now = utc_now_iso()
today = today_str()
duration_seconds = max(0, min(int(duration_seconds or 0), 3600))
if is_correct:
word.correct_count += 1
@@ -175,6 +203,8 @@ class QuizService:
word.mastery_score = calc_mastery_score(word.correct_count, word.wrong_count)
word.last_reviewed_at = now
word.train_count += 1
word.total_train_seconds += duration_seconds
record = QuizRecord(
user_id=user.id,
@@ -183,6 +213,7 @@ class QuizService:
user_answer=user_answer,
correct_answer=correct_answer,
is_correct=1 if is_correct else 0,
duration_seconds=duration_seconds,
created_at=now,
)
db.add(record)