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:
+8
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user