Add memory coach, transformer recall model, and training FAB.
Introduce Q/K/V memory dialogue with coach APIs, a lightweight NumPy transformer for per-word forgetting prediction, and a floating training menu linking daily quiz, spell, and coach flows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -160,5 +160,7 @@ wordloop/
|
||||
- 记忆曲线与 Obsidian 式单词关系力导向图
|
||||
- 每日选择题训练
|
||||
- 拼写练习(中文释义 + 音标,输入英文单词)
|
||||
- 记忆对话(Q/K/V token 引导推导英文,显示中文释义与记忆保持,计入练习记录)
|
||||
- Transformer 记忆预测(轻量序列模型 + 遗忘曲线,见 [backend/docs/MEMORY_TRANSFORMER.md](backend/docs/MEMORY_TRANSFORMER.md))
|
||||
- 掌握规则与复习间隔
|
||||
- 学习统计与设置
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,50 @@
|
||||
# Transformer 记忆模型(可落地版)
|
||||
|
||||
## 目标
|
||||
|
||||
用轻量 Transformer 读取「词项 CLS + 练习事件序列」,预测**当前回忆成功率**与未来遗忘曲线,并与艾宾浩斯公式融合,保证冷启动可用。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
[CLS 词项特征] + [事件1] + [事件2] + … → Linear → 2×(MHA+FFN) → CLS → sigmoid → P(回忆成功)
|
||||
```
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| **CLS(词项 token)** | 掌握率、连对、对错次数、词长、状态 one-hot |
|
||||
| **事件 token** | 对错、距上次间隔(log)、耗时、题型 one-hot |
|
||||
| **注意力** | 解释哪些历史尝试影响当前预测(`attention_hint`) |
|
||||
| **融合** | `blend = min(1, 事件数/5)`;`recall = blend×模型 + (1-blend)×公式` |
|
||||
|
||||
## 目录
|
||||
|
||||
- `services/memory_transformer/encoding.py` — 序列特征
|
||||
- `services/memory_transformer/network.py` — NumPy 推理
|
||||
- `services/memory_transformer/service.py` — 预测与曲线
|
||||
- `scripts/train_memory_transformer.py` — PyTorch 训练并导出 JSON
|
||||
- `data/memory_transformer/default_weights.json` — 默认权重(未训练时回退)
|
||||
|
||||
## API
|
||||
|
||||
`GET /api/words/{word_id}/memory-model`
|
||||
|
||||
返回:`recall_now_percent`、`curve[]`、`recommended_review_days`、`attention_hint[]` 等。
|
||||
|
||||
## 训练
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
pip install numpy torch
|
||||
python -m scripts.train_memory_transformer --epochs 30 # 合成数据
|
||||
python -m scripts.train_memory_transformer --epochs 30 --from-db # 真实 quiz_records
|
||||
```
|
||||
|
||||
导出:`backend/data/memory_transformer/weights.json`(存在则优先于 default)。
|
||||
|
||||
生产 API **仅需 NumPy**,无需安装 PyTorch。
|
||||
|
||||
## 与复习调度对接(下一步)
|
||||
|
||||
可将 `recommended_review_days` 写回 `words.review_due_date`,与 `quiz_service.review_interval_days` 二选一或加权融合。
|
||||
+9
-1
@@ -3,7 +3,14 @@ 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
|
||||
from routers import (
|
||||
auth_router,
|
||||
coach_router,
|
||||
quiz_router,
|
||||
settings_router,
|
||||
translate_router,
|
||||
word_router,
|
||||
)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
run_migrations()
|
||||
@@ -23,6 +30,7 @@ app.include_router(translate_router.router)
|
||||
app.include_router(word_router.router)
|
||||
app.include_router(quiz_router.router)
|
||||
app.include_router(settings_router.router)
|
||||
app.include_router(coach_router.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -7,3 +7,4 @@ pydantic==2.10.3
|
||||
pydantic-settings==2.6.1
|
||||
python-multipart==0.0.17
|
||||
pymysql==1.1.1
|
||||
numpy==2.0.2
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User
|
||||
from schemas import CoachSessionResponse, CoachTurnRequest, CoachTurnResponse
|
||||
from services.memory_coach_service import memory_coach_service
|
||||
|
||||
router = APIRouter(prefix="/api/coach", tags=["coach"])
|
||||
|
||||
|
||||
@router.get("/session", response_model=CoachSessionResponse)
|
||||
def coach_session(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return memory_coach_service.start_session(db, current_user)
|
||||
|
||||
|
||||
@router.post("/turn", response_model=CoachTurnResponse)
|
||||
def coach_turn(
|
||||
data: CoachTurnRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return memory_coach_service.handle_turn(
|
||||
db,
|
||||
current_user,
|
||||
data.word_id,
|
||||
data.stage,
|
||||
data.user_message,
|
||||
data.hints_used,
|
||||
data.duration_seconds or 0,
|
||||
)
|
||||
@@ -7,12 +7,14 @@ from auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User
|
||||
from schemas import (
|
||||
MemoryTransformerPredictResponse,
|
||||
MemoryVisualizationResponse,
|
||||
WordCreate,
|
||||
WordMemoryDetailResponse,
|
||||
WordOut,
|
||||
WordUpdate,
|
||||
)
|
||||
from services.memory_transformer import memory_transformer_service
|
||||
from services.memory_visual_service import memory_visual_service
|
||||
from services.word_service import word_service
|
||||
|
||||
@@ -55,6 +57,16 @@ def word_memory(
|
||||
return memory_visual_service.get_word_memory(db, current_user, word_id)
|
||||
|
||||
|
||||
@router.get("/{word_id}/memory-model", response_model=MemoryTransformerPredictResponse)
|
||||
def word_memory_model(
|
||||
word_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
word = word_service.get_word(db, current_user, word_id)
|
||||
return memory_transformer_service.predict_for_word(db, current_user, word)
|
||||
|
||||
|
||||
@router.get("/{word_id}", response_model=WordOut)
|
||||
def get_word(
|
||||
word_id: int,
|
||||
|
||||
@@ -233,3 +233,77 @@ class SettingsUpdate(BaseModel):
|
||||
daily_target: Optional[int] = Field(None, ge=1, le=100)
|
||||
master_required_count: Optional[int] = Field(None, ge=1, le=20)
|
||||
weak_wrong_threshold: Optional[int] = Field(None, ge=1, le=20)
|
||||
|
||||
|
||||
# Memory coach (Q/K/V dialogue)
|
||||
class MemoryToken(BaseModel):
|
||||
role: str
|
||||
key: str
|
||||
label: str
|
||||
value: str
|
||||
revealed: bool = False
|
||||
|
||||
|
||||
class CoachWordBrief(BaseModel):
|
||||
word_id: int
|
||||
zh: str
|
||||
phonetic: Optional[str] = None
|
||||
retention_now: float
|
||||
mastery_score: int
|
||||
status: str
|
||||
|
||||
|
||||
class CoachSessionResponse(BaseModel):
|
||||
words: list[CoachWordBrief]
|
||||
total: int
|
||||
|
||||
|
||||
class CoachTurnRequest(BaseModel):
|
||||
word_id: int
|
||||
stage: str = "intro"
|
||||
user_message: str = ""
|
||||
hints_used: int = Field(0, ge=0, le=20)
|
||||
duration_seconds: Optional[int] = Field(None, ge=0, le=3600)
|
||||
|
||||
|
||||
class MemoryTransformerCurvePoint(BaseModel):
|
||||
hours_ahead: float
|
||||
recall_percent: float
|
||||
forgetting_percent: float
|
||||
|
||||
|
||||
class MemoryTransformerAttention(BaseModel):
|
||||
index: int
|
||||
label: str
|
||||
weight: float
|
||||
|
||||
|
||||
class MemoryTransformerPredictResponse(BaseModel):
|
||||
word_id: int
|
||||
en: str
|
||||
recall_now_percent: float
|
||||
formula_recall_percent: float
|
||||
model_recall_percent: float
|
||||
blend_weight: float
|
||||
event_count: int
|
||||
model_trained: bool
|
||||
recommended_review_days: int
|
||||
half_life_hours: Optional[float] = None
|
||||
curve: list[MemoryTransformerCurvePoint]
|
||||
attention_hint: list[MemoryTransformerAttention]
|
||||
|
||||
|
||||
class CoachTurnResponse(BaseModel):
|
||||
assistant_messages: list[str]
|
||||
tokens: list[MemoryToken] = []
|
||||
stage: str
|
||||
expect_input: bool
|
||||
prompt_zh: str = ""
|
||||
prompt_phonetic: Optional[str] = None
|
||||
input_hint: str = "请输入对应的英文单词"
|
||||
is_correct: Optional[bool] = None
|
||||
quiz_recorded: bool = False
|
||||
word_complete: bool = False
|
||||
hints_used: int = 0
|
||||
target_en: Optional[str] = None
|
||||
target_zh: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
训练轻量 Transformer 记忆模型,导出 weights.json(NumPy 推理)。
|
||||
|
||||
用法:
|
||||
cd backend && source venv/bin/activate
|
||||
pip install torch numpy # 训练仅需本机安装
|
||||
python -m scripts.train_memory_transformer
|
||||
python -m scripts.train_memory_transformer --epochs 30 --from-db
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
BACKEND = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(BACKEND))
|
||||
|
||||
from database import SessionLocal # noqa: E402
|
||||
from models import QuizRecord, Word # noqa: E402
|
||||
from services.memory_transformer.encoding import ( # noqa: E402
|
||||
FEATURE_DIM,
|
||||
MAX_SEQ_LEN,
|
||||
build_sequence_matrix,
|
||||
)
|
||||
from services.memory_transformer.network import MiniTransformer, init_random_weights # noqa: E402
|
||||
from services.memory_transformer.service import ( # noqa: E402
|
||||
DEFAULT_WEIGHTS_PATH,
|
||||
WEIGHTS_PATH,
|
||||
)
|
||||
|
||||
try:
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
except ImportError:
|
||||
torch = None # type: ignore
|
||||
nn = None # type: ignore
|
||||
|
||||
|
||||
def _build_torch_model():
|
||||
class TorchMiniTransformer(nn.Module):
|
||||
def __init__(self, d_model=48, n_heads=2, d_ff=96, n_layers=2):
|
||||
super().__init__()
|
||||
self.d_model = d_model
|
||||
self.n_heads = n_heads
|
||||
self.n_layers = n_layers
|
||||
self.in_proj = nn.Linear(FEATURE_DIM, d_model)
|
||||
self.ln_in = nn.LayerNorm(d_model)
|
||||
self.layers = nn.ModuleList(
|
||||
[
|
||||
nn.TransformerEncoderLayer(
|
||||
d_model=d_model,
|
||||
nhead=n_heads,
|
||||
dim_feedforward=d_ff,
|
||||
batch_first=True,
|
||||
dropout=0.1,
|
||||
)
|
||||
for _ in range(n_layers)
|
||||
]
|
||||
)
|
||||
self.head = nn.Linear(d_model, 1)
|
||||
|
||||
def forward(self, x, valid_lens):
|
||||
h = self.ln_in(self.in_proj(x))
|
||||
key_padding = torch.zeros(
|
||||
x.size(0), MAX_SEQ_LEN, dtype=torch.bool, device=x.device
|
||||
)
|
||||
for i, vl in enumerate(valid_lens):
|
||||
if vl < MAX_SEQ_LEN:
|
||||
key_padding[i, vl:] = True
|
||||
for layer in self.layers:
|
||||
h = layer(h, src_key_padding_mask=key_padding)
|
||||
cls = h[:, 0, :]
|
||||
return self.head(cls).squeeze(-1)
|
||||
|
||||
return TorchMiniTransformer()
|
||||
|
||||
|
||||
def export_torch_to_numpy(torch_model) -> MiniTransformer:
|
||||
"""将 PyTorch 权重映射到 NumPy MiniTransformer 命名。"""
|
||||
m = MiniTransformer(
|
||||
d_model=torch_model.d_model,
|
||||
n_heads=torch_model.n_heads,
|
||||
d_ff=96,
|
||||
n_layers=torch_model.n_layers,
|
||||
)
|
||||
w = m.weights
|
||||
w["in_proj"] = torch_model.in_proj.weight.detach().cpu().numpy().T
|
||||
w["in_bias"] = torch_model.in_proj.bias.detach().cpu().numpy()
|
||||
w["ln_in_g"] = torch_model.ln_in.weight.detach().cpu().numpy()
|
||||
w["ln_in_b"] = torch_model.ln_in.bias.detach().cpu().numpy()
|
||||
|
||||
for li, layer in enumerate(torch_model.layers):
|
||||
attn = layer.self_attn
|
||||
d = m.d_model
|
||||
# PyTorch MHA: in_proj_weight stacks Q,K,V
|
||||
in_w = attn.in_proj_weight.detach().cpu().numpy()
|
||||
Wq, Wk, Wv = in_w[:d], in_w[d : 2 * d], in_w[2 * d :]
|
||||
w[f"L{li}.Wq"] = Wq.T
|
||||
w[f"L{li}.Wk"] = Wk.T
|
||||
w[f"L{li}.Wv"] = Wv.T
|
||||
in_b = attn.in_proj_bias.detach().cpu().numpy()
|
||||
w[f"L{li}.Bq"] = in_b[:d]
|
||||
w[f"L{li}.Bk"] = in_b[d : 2 * d]
|
||||
w[f"L{li}.Bv"] = in_b[2 * d :]
|
||||
w[f"L{li}.Wo"] = attn.out_proj.weight.detach().cpu().numpy().T
|
||||
w[f"L{li}.Bo"] = attn.out_proj.bias.detach().cpu().numpy()
|
||||
w[f"L{li}.ln1_g"] = layer.norm1.weight.detach().cpu().numpy()
|
||||
w[f"L{li}.ln1_b"] = layer.norm1.bias.detach().cpu().numpy()
|
||||
w[f"L{li}.W1"] = layer.linear1.weight.detach().cpu().numpy().T
|
||||
w[f"L{li}.b1"] = layer.linear1.bias.detach().cpu().numpy()
|
||||
w[f"L{li}.W2"] = layer.linear2.weight.detach().cpu().numpy().T
|
||||
w[f"L{li}.b2"] = layer.linear2.bias.detach().cpu().numpy()
|
||||
w[f"L{li}.ln2_g"] = layer.norm2.weight.detach().cpu().numpy()
|
||||
w[f"L{li}.ln2_b"] = layer.norm2.bias.detach().cpu().numpy()
|
||||
|
||||
w["head_w"] = torch_model.head.weight.detach().cpu().numpy()[0]
|
||||
w["head_b"] = torch_model.head.bias.detach().cpu().numpy()[0]
|
||||
return m
|
||||
|
||||
|
||||
def load_samples_from_db(limit: int = 50000) -> tuple[list, list]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
records = (
|
||||
db.query(QuizRecord)
|
||||
.order_by(QuizRecord.created_at.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
word_cache: dict[int, Word] = {}
|
||||
xs, ys = [], []
|
||||
for r in records:
|
||||
if r.word_id not in word_cache:
|
||||
word_cache[r.word_id] = db.query(Word).filter(Word.id == r.word_id).first()
|
||||
word = word_cache.get(r.word_id)
|
||||
if not word:
|
||||
continue
|
||||
hist = (
|
||||
db.query(QuizRecord)
|
||||
.filter(QuizRecord.word_id == r.word_id, QuizRecord.created_at < r.created_at)
|
||||
.order_by(QuizRecord.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
seq, vl = build_sequence_matrix(word, hist, parse_iso(r.created_at))
|
||||
xs.append(seq)
|
||||
ys.append(1.0 if r.is_correct else 0.0)
|
||||
return xs, ys
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
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 synthetic_samples(n: int = 4000) -> tuple[list, list]:
|
||||
rng = np.random.default_rng(0)
|
||||
xs, ys = [], []
|
||||
for _ in range(n):
|
||||
vl = int(rng.integers(2, 12))
|
||||
seq = rng.normal(0, 0.5, (MAX_SEQ_LEN, FEATURE_DIM)).tolist()
|
||||
seq[0][0] = rng.uniform(0, 1)
|
||||
seq[0][1] = rng.uniform(0, 1)
|
||||
last_ok = seq[-1][0] if vl > 1 else 0.5
|
||||
y = 1.0 if rng.random() < 0.4 + 0.4 * last_ok + 0.1 * seq[0][0] else 0.0
|
||||
xs.append(seq)
|
||||
ys.append(y)
|
||||
return xs, ys
|
||||
|
||||
|
||||
def train_and_export(
|
||||
epochs: int = 20,
|
||||
batch_size: int = 64,
|
||||
from_db: bool = False,
|
||||
out_path: Path = WEIGHTS_PATH,
|
||||
) -> None:
|
||||
if torch is None:
|
||||
print("请先安装 PyTorch: pip install torch")
|
||||
print("将写入随机初始化 default_weights 供推理回退…")
|
||||
m = MiniTransformer()
|
||||
m.weights = init_random_weights()
|
||||
DEFAULT_WEIGHTS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
m.save_json(DEFAULT_WEIGHTS_PATH)
|
||||
print(f"已保存 {DEFAULT_WEIGHTS_PATH}")
|
||||
return
|
||||
|
||||
xs, ys = load_samples_from_db() if from_db else synthetic_samples()
|
||||
if len(xs) < 32:
|
||||
print("样本不足,使用合成数据")
|
||||
xs, ys = synthetic_samples()
|
||||
|
||||
X = torch.tensor(xs, dtype=torch.float32)
|
||||
y = torch.tensor(ys, dtype=torch.float32)
|
||||
valid_lens = [min(MAX_SEQ_LEN, sum(1 for row in s if any(v != 0 for v in row))) for s in xs]
|
||||
for i, s in enumerate(xs):
|
||||
vl = 1
|
||||
for j in range(1, MAX_SEQ_LEN):
|
||||
if any(v != 0 for v in s[j]):
|
||||
vl = j + 1
|
||||
valid_lens[i] = max(1, vl)
|
||||
|
||||
model = _build_torch_model()
|
||||
opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
|
||||
loss_fn = nn.BCEWithLogitsLoss()
|
||||
|
||||
n = len(xs)
|
||||
for ep in range(epochs):
|
||||
perm = torch.randperm(n)
|
||||
total_loss = 0.0
|
||||
steps = 0
|
||||
for start in range(0, n, batch_size):
|
||||
idx = perm[start : start + batch_size]
|
||||
bx = X[idx]
|
||||
by = y[idx]
|
||||
vl_batch = [valid_lens[i] for i in idx.tolist()]
|
||||
opt.zero_grad()
|
||||
logits = model(bx, vl_batch)
|
||||
loss = loss_fn(logits, by)
|
||||
loss.backward()
|
||||
opt.step()
|
||||
total_loss += loss.item()
|
||||
steps += 1
|
||||
acc = 0.0
|
||||
with torch.no_grad():
|
||||
pred = (torch.sigmoid(model(X, valid_lens)) > 0.5).float()
|
||||
acc = (pred == y).float().mean().item()
|
||||
print(f"epoch {ep + 1}/{epochs} loss={total_loss / max(steps, 1):.4f} acc={acc:.3f}")
|
||||
|
||||
numpy_model = export_torch_to_numpy(model)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
numpy_model.save_json(out_path)
|
||||
print(f"已导出 {out_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--epochs", type=int, default=20)
|
||||
parser.add_argument("--from-db", action="store_true")
|
||||
parser.add_argument("--out", type=str, default="")
|
||||
args = parser.parse_args()
|
||||
out = Path(args.out) if args.out else WEIGHTS_PATH
|
||||
train_and_export(epochs=args.epochs, from_db=args.from_db, out_path=out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,340 @@
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import QuizRecord, User, Word
|
||||
from schemas import CoachSessionResponse, CoachTurnResponse, CoachWordBrief, MemoryToken
|
||||
from services.memory_visual_service import (
|
||||
memory_visual_service,
|
||||
parse_iso,
|
||||
retention_percent,
|
||||
stability_hours,
|
||||
word_en,
|
||||
word_zh,
|
||||
)
|
||||
from services.quiz_service import quiz_service
|
||||
from services.word_service import word_service
|
||||
|
||||
|
||||
def _mask_zh(zh: str) -> str:
|
||||
zh = zh.strip()
|
||||
if len(zh) <= 1:
|
||||
return zh
|
||||
return zh[0] + "※" * (len(zh) - 1)
|
||||
|
||||
|
||||
def _blank_example(example: str, en: str) -> str:
|
||||
if not example:
|
||||
return ""
|
||||
pattern = re.compile(re.escape(en), re.IGNORECASE)
|
||||
return pattern.sub("______", example, count=1)
|
||||
|
||||
|
||||
def _retention_now(word: Word) -> float:
|
||||
now = datetime.now(timezone.utc)
|
||||
last_at = parse_iso(word.last_reviewed_at or word.created_at)
|
||||
hours = (now - last_at).total_seconds() / 3600
|
||||
return round(retention_percent(hours, stability_hours(word)), 1)
|
||||
|
||||
|
||||
def _last_quiz_hint(db: Session, word_id: int) -> Optional[str]:
|
||||
r = (
|
||||
db.query(QuizRecord)
|
||||
.filter(QuizRecord.word_id == word_id)
|
||||
.order_by(QuizRecord.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if not r:
|
||||
return None
|
||||
return "上次答对" if r.is_correct else "上次答错"
|
||||
|
||||
|
||||
class MemoryCoachService:
|
||||
def build_tokens(self, db: Session, word: Word, reveal_extra: int = 0) -> list[MemoryToken]:
|
||||
en = word_en(word).strip()
|
||||
zh = word_zh(word).strip()
|
||||
retention = _retention_now(word)
|
||||
hint = _last_quiz_hint(db, word.id)
|
||||
|
||||
tokens: list[MemoryToken] = [
|
||||
MemoryToken(
|
||||
role="K",
|
||||
key="retention",
|
||||
label="记忆保持",
|
||||
value=f"{retention}%",
|
||||
revealed=True,
|
||||
),
|
||||
MemoryToken(
|
||||
role="K",
|
||||
key="mastery",
|
||||
label="掌握率",
|
||||
value=f"{word.mastery_score}%",
|
||||
revealed=True,
|
||||
),
|
||||
MemoryToken(
|
||||
role="K",
|
||||
key="status",
|
||||
label="词库状态",
|
||||
value=word.status,
|
||||
revealed=True,
|
||||
),
|
||||
]
|
||||
if hint:
|
||||
tokens.append(
|
||||
MemoryToken(
|
||||
role="K",
|
||||
key="last_quiz",
|
||||
label="练习记录",
|
||||
value=hint,
|
||||
revealed=True,
|
||||
)
|
||||
)
|
||||
|
||||
q_tokens: list[MemoryToken] = [
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="zh_full",
|
||||
label="中文释义",
|
||||
value=zh,
|
||||
revealed=True,
|
||||
),
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="length",
|
||||
label="字母数",
|
||||
value=str(len(en)),
|
||||
revealed=True,
|
||||
),
|
||||
]
|
||||
if zh:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="zh_hint",
|
||||
label="释义提示",
|
||||
value=_mask_zh(zh),
|
||||
revealed=reveal_extra > 0,
|
||||
)
|
||||
)
|
||||
if word.phonetic:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="phonetic",
|
||||
label="音标",
|
||||
value=word.phonetic,
|
||||
revealed=reveal_extra > 0,
|
||||
)
|
||||
)
|
||||
if len(en) >= 2:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="prefix",
|
||||
label="英文前缀",
|
||||
value=en[:2] + "…",
|
||||
revealed=reveal_extra > 1,
|
||||
)
|
||||
)
|
||||
if len(en) >= 4:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="suffix",
|
||||
label="英文尾缀",
|
||||
value="…" + en[-2:],
|
||||
revealed=reveal_extra > 2,
|
||||
)
|
||||
)
|
||||
if word.example_en:
|
||||
blanked = _blank_example(word.example_en, en)
|
||||
if blanked and blanked != word.example_en:
|
||||
q_tokens.append(
|
||||
MemoryToken(
|
||||
role="Q",
|
||||
key="example",
|
||||
label="例句挖空",
|
||||
value=blanked,
|
||||
revealed=reveal_extra > 3,
|
||||
)
|
||||
)
|
||||
|
||||
tokens.extend(q_tokens)
|
||||
tokens.extend(
|
||||
[
|
||||
MemoryToken(
|
||||
role="V",
|
||||
key="en",
|
||||
label="英文",
|
||||
value=en,
|
||||
revealed=False,
|
||||
),
|
||||
MemoryToken(
|
||||
role="V",
|
||||
key="zh",
|
||||
label="中文",
|
||||
value=zh,
|
||||
revealed=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
if word.example_en:
|
||||
tokens.append(
|
||||
MemoryToken(
|
||||
role="V",
|
||||
key="example_en",
|
||||
label="例句",
|
||||
value=word.example_en,
|
||||
revealed=False,
|
||||
)
|
||||
)
|
||||
if word.example_cn:
|
||||
tokens.append(
|
||||
MemoryToken(
|
||||
role="V",
|
||||
key="example_cn",
|
||||
label="例句译文",
|
||||
value=word.example_cn,
|
||||
revealed=False,
|
||||
)
|
||||
)
|
||||
return tokens
|
||||
|
||||
def _reveal_q_tokens(self, tokens: list[MemoryToken], count: int) -> list[MemoryToken]:
|
||||
hidden_q = [t for t in tokens if t.role == "Q" and not t.revealed]
|
||||
for t in hidden_q[:count]:
|
||||
t.revealed = True
|
||||
return tokens
|
||||
|
||||
def _reveal_all_v(self, tokens: list[MemoryToken]) -> list[MemoryToken]:
|
||||
for t in tokens:
|
||||
if t.role == "V":
|
||||
t.revealed = True
|
||||
if t.role == "Q":
|
||||
t.revealed = True
|
||||
return tokens
|
||||
|
||||
def start_session(self, db: Session, user: User) -> CoachSessionResponse:
|
||||
settings = quiz_service.get_settings(db, user)
|
||||
words = quiz_service.select_daily_words(db, user, settings.daily_target)
|
||||
if not words:
|
||||
return CoachSessionResponse(words=[], total=0)
|
||||
|
||||
summaries = memory_visual_service.get_visualization(db, user)["words"]
|
||||
risk_map = {w["id"]: w.get("retention_now", 0) for w in summaries}
|
||||
|
||||
items = [
|
||||
CoachWordBrief(
|
||||
word_id=w.id,
|
||||
zh=word_zh(w),
|
||||
phonetic=w.phonetic,
|
||||
retention_now=risk_map.get(w.id, _retention_now(w)),
|
||||
mastery_score=w.mastery_score,
|
||||
status=w.status,
|
||||
)
|
||||
for w in words
|
||||
]
|
||||
return CoachSessionResponse(words=items, total=len(items))
|
||||
|
||||
def handle_turn(
|
||||
self,
|
||||
db: Session,
|
||||
user: User,
|
||||
word_id: int,
|
||||
stage: str,
|
||||
user_message: str = "",
|
||||
hints_used: int = 0,
|
||||
duration_seconds: int = 0,
|
||||
) -> CoachTurnResponse:
|
||||
word = word_service.get_word(db, user, word_id)
|
||||
en = word_en(word).strip()
|
||||
zh = word_zh(word).strip()
|
||||
phonetic = word.phonetic
|
||||
tokens = self.build_tokens(db, word, reveal_extra=hints_used)
|
||||
messages: list[str] = []
|
||||
expect_input = False
|
||||
is_correct: Optional[bool] = None
|
||||
quiz_recorded = False
|
||||
word_complete = False
|
||||
next_stage = stage
|
||||
input_hint = f"请输入「{zh}」的英文"
|
||||
|
||||
if stage == "intro":
|
||||
next_stage = "derive"
|
||||
expect_input = True
|
||||
|
||||
elif stage == "derive":
|
||||
answer = user_message.strip()
|
||||
if not answer:
|
||||
expect_input = True
|
||||
next_stage = "derive"
|
||||
else:
|
||||
is_correct = answer.lower() == en.lower()
|
||||
if is_correct:
|
||||
tokens = self._reveal_all_v(tokens)
|
||||
messages.append(f"正确:{en}")
|
||||
quiz_service.submit_answer(
|
||||
db,
|
||||
user,
|
||||
word.id,
|
||||
"memory_coach",
|
||||
answer,
|
||||
en,
|
||||
duration_seconds,
|
||||
)
|
||||
quiz_recorded = True
|
||||
word_complete = True
|
||||
next_stage = "done"
|
||||
else:
|
||||
hints_used += 1
|
||||
tokens = self.build_tokens(db, word, reveal_extra=hints_used)
|
||||
tokens = self._reveal_q_tokens(tokens, 1)
|
||||
hidden_left = sum(1 for t in tokens if t.role == "Q" and not t.revealed)
|
||||
messages.append("不对,再试一次。")
|
||||
if hidden_left == 0:
|
||||
tokens = self._reveal_all_v(tokens)
|
||||
messages.append(f"答案:{en}")
|
||||
quiz_service.submit_answer(
|
||||
db,
|
||||
user,
|
||||
word.id,
|
||||
"memory_coach",
|
||||
answer,
|
||||
en,
|
||||
duration_seconds,
|
||||
)
|
||||
quiz_recorded = True
|
||||
word_complete = True
|
||||
next_stage = "done"
|
||||
else:
|
||||
expect_input = True
|
||||
next_stage = "derive"
|
||||
|
||||
elif stage == "done":
|
||||
word_complete = True
|
||||
next_stage = "done"
|
||||
|
||||
else:
|
||||
next_stage = "intro"
|
||||
expect_input = True
|
||||
|
||||
return CoachTurnResponse(
|
||||
assistant_messages=messages,
|
||||
tokens=tokens,
|
||||
stage=next_stage,
|
||||
expect_input=expect_input,
|
||||
prompt_zh=zh,
|
||||
prompt_phonetic=phonetic,
|
||||
input_hint=input_hint,
|
||||
is_correct=is_correct,
|
||||
quiz_recorded=quiz_recorded,
|
||||
word_complete=word_complete,
|
||||
hints_used=hints_used,
|
||||
target_en=en if word_complete else None,
|
||||
target_zh=zh if word_complete else None,
|
||||
)
|
||||
|
||||
|
||||
memory_coach_service = MemoryCoachService()
|
||||
@@ -0,0 +1,3 @@
|
||||
from services.memory_transformer.service import memory_transformer_service
|
||||
|
||||
__all__ = ["memory_transformer_service"]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""将 QuizRecord 序列编码为 Transformer 输入特征。"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from models import QuizRecord, Word
|
||||
|
||||
FEATURE_DIM = 16
|
||||
MAX_SEQ_LEN = 32
|
||||
|
||||
QUESTION_TYPES = ("en_to_zh", "zh_to_en", "spell", "memory_coach")
|
||||
STATUS_ORDER = ("new", "learning", "mastered", "weak")
|
||||
|
||||
|
||||
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 _log_hours(delta_hours: float) -> float:
|
||||
return math.log1p(max(0.0, delta_hours)) / math.log1p(24 * 30)
|
||||
|
||||
|
||||
def _one_hot(index: int, size: int) -> list[float]:
|
||||
v = [0.0] * size
|
||||
if 0 <= index < size:
|
||||
v[index] = 1.0
|
||||
return v
|
||||
|
||||
|
||||
def _pad_features(feats: list[float]) -> list[float]:
|
||||
row = feats[:FEATURE_DIM]
|
||||
while len(row) < FEATURE_DIM:
|
||||
row.append(0.0)
|
||||
return row
|
||||
|
||||
|
||||
def word_static_features(word: Word) -> list[float]:
|
||||
en = word_en(word)
|
||||
status_idx = STATUS_ORDER.index(word.status) if word.status in STATUS_ORDER else 1
|
||||
feats = [
|
||||
word.mastery_score / 100.0,
|
||||
min(word.consecutive_correct_count, 10) / 10.0,
|
||||
min(word.correct_count, 50) / 50.0,
|
||||
min(word.wrong_count, 50) / 50.0,
|
||||
min(len(en), 24) / 24.0,
|
||||
]
|
||||
feats.extend(_one_hot(status_idx, len(STATUS_ORDER)))
|
||||
return _pad_features(feats)
|
||||
|
||||
|
||||
def event_features(
|
||||
record: QuizRecord,
|
||||
prev_at: Optional[datetime],
|
||||
at: datetime,
|
||||
) -> list[float]:
|
||||
q_idx = (
|
||||
QUESTION_TYPES.index(record.question_type)
|
||||
if record.question_type in QUESTION_TYPES
|
||||
else 0
|
||||
)
|
||||
if prev_at is None:
|
||||
delta_h = 0.0
|
||||
else:
|
||||
delta_h = max(0.0, (at - prev_at).total_seconds() / 3600.0)
|
||||
dur = min(record.duration_seconds or 0, 600) / 600.0
|
||||
feats = [
|
||||
1.0 if record.is_correct else 0.0,
|
||||
_log_hours(delta_h),
|
||||
dur,
|
||||
]
|
||||
feats.extend(_one_hot(q_idx, len(QUESTION_TYPES)))
|
||||
return _pad_features(feats)
|
||||
|
||||
|
||||
def build_sequence_matrix(
|
||||
word: Word,
|
||||
records: list[QuizRecord],
|
||||
now: Optional[datetime] = None,
|
||||
) -> tuple[list[list[float]], int]:
|
||||
"""
|
||||
返回 (seq_features, valid_len)。
|
||||
第 0 位为词项 CLS(静态),其后为按时间排序的练习事件(最多 MAX_SEQ_LEN-1)。
|
||||
"""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
ordered = sorted(records, key=lambda r: r.created_at)
|
||||
seq: list[list[float]] = [word_static_features(word)]
|
||||
|
||||
prev_at: Optional[datetime] = parse_iso(word.created_at)
|
||||
for r in ordered[-(MAX_SEQ_LEN - 1) :]:
|
||||
at = parse_iso(r.created_at)
|
||||
seq.append(event_features(r, prev_at, at))
|
||||
prev_at = at
|
||||
|
||||
valid_len = len(seq)
|
||||
while len(seq) < MAX_SEQ_LEN:
|
||||
seq.append([0.0] * FEATURE_DIM)
|
||||
return seq, valid_len
|
||||
@@ -0,0 +1,166 @@
|
||||
"""轻量 NumPy Transformer:单条序列 → 回忆成功概率 logit。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from services.memory_transformer.encoding import FEATURE_DIM, MAX_SEQ_LEN
|
||||
|
||||
|
||||
def _gelu(x: np.ndarray) -> np.ndarray:
|
||||
return 0.5 * x * (1.0 + np.tanh(math.sqrt(2.0 / math.pi) * (x + 0.044715 * x**3)))
|
||||
|
||||
|
||||
def _softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
|
||||
e = np.exp(x - np.max(x, axis=axis, keepdims=True))
|
||||
return e / np.sum(e, axis=axis, keepdims=True)
|
||||
|
||||
|
||||
def _layer_norm(x: np.ndarray, gamma: np.ndarray, beta: np.ndarray) -> np.ndarray:
|
||||
mean = x.mean(axis=-1, keepdims=True)
|
||||
var = x.var(axis=-1, keepdims=True) + 1e-6
|
||||
return gamma * (x - mean) / np.sqrt(var) + beta
|
||||
|
||||
|
||||
class MiniTransformer:
|
||||
"""
|
||||
结构:Linear(F→D) + 2×(MHA + FFN) + CLS 读出。
|
||||
仅推理;训练在 train_memory_transformer.py 中用 PyTorch 导出权重。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
d_model: int = 48,
|
||||
n_heads: int = 2,
|
||||
d_ff: int = 96,
|
||||
n_layers: int = 2,
|
||||
):
|
||||
self.d_model = d_model
|
||||
self.n_heads = n_heads
|
||||
self.d_k = d_model // n_heads
|
||||
self.d_ff = d_ff
|
||||
self.n_layers = n_layers
|
||||
self.weights: dict[str, np.ndarray] = {}
|
||||
|
||||
def load_numpy_dict(self, state: dict[str, Any]) -> None:
|
||||
self.d_model = int(state["d_model"])
|
||||
self.n_heads = int(state["n_heads"])
|
||||
self.d_ff = int(state["d_ff"])
|
||||
self.n_layers = int(state["n_layers"])
|
||||
self.d_k = self.d_model // self.n_heads
|
||||
self.weights = {k: np.array(v, dtype=np.float64) for k, v in state["weights"].items()}
|
||||
|
||||
def save_json(self, path: Path) -> None:
|
||||
payload = {
|
||||
"d_model": self.d_model,
|
||||
"n_heads": self.n_heads,
|
||||
"d_ff": self.d_ff,
|
||||
"n_layers": self.n_layers,
|
||||
"weights": {k: v.tolist() for k, v in self.weights.items()},
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
def load_json(cls, path: Path) -> MiniTransformer:
|
||||
state = json.loads(path.read_text(encoding="utf-8"))
|
||||
m = cls()
|
||||
m.load_numpy_dict(state)
|
||||
return m
|
||||
|
||||
def _mha(self, x: np.ndarray, li: int) -> np.ndarray:
|
||||
w = self.weights
|
||||
Wq, Wk, Wv = w[f"L{li}.Wq"], w[f"L{li}.Wk"], w[f"L{li}.Wv"]
|
||||
Wo = w[f"L{li}.Wo"]
|
||||
Bq, Bk, Bv = w[f"L{li}.Bq"], w[f"L{li}.Bk"], w[f"L{li}.Bv"]
|
||||
|
||||
seq, d = x.shape
|
||||
Q = x @ Wq + Bq
|
||||
K = x @ Wk + Bk
|
||||
V = x @ Wv + Bv
|
||||
|
||||
heads = []
|
||||
for h in range(self.n_heads):
|
||||
sl = slice(h * self.d_k, (h + 1) * self.d_k)
|
||||
q, k, v = Q[:, sl], K[:, sl], V[:, sl]
|
||||
scores = (q @ k.T) / math.sqrt(self.d_k)
|
||||
attn = _softmax(scores, axis=-1)
|
||||
heads.append(attn @ v)
|
||||
concat = np.concatenate(heads, axis=-1)
|
||||
return concat @ Wo + w[f"L{li}.Bo"]
|
||||
|
||||
def _ffn(self, x: np.ndarray, li: int) -> np.ndarray:
|
||||
w = self.weights
|
||||
h = _gelu(x @ w[f"L{li}.W1"] + w[f"L{li}.b1"])
|
||||
return h @ w[f"L{li}.W2"] + w[f"L{li}.b2"]
|
||||
|
||||
def forward_logits(self, seq_features: list[list[float]], valid_len: int) -> float:
|
||||
x = np.array(seq_features[:MAX_SEQ_LEN], dtype=np.float64)
|
||||
mask = np.zeros(MAX_SEQ_LEN, dtype=np.float64)
|
||||
mask[:valid_len] = 1.0
|
||||
|
||||
w = self.weights
|
||||
x = x @ w["in_proj"] + w["in_bias"]
|
||||
x = _layer_norm(x, w["ln_in_g"], w["ln_in_b"])
|
||||
|
||||
for li in range(self.n_layers):
|
||||
attn_out = self._mha(x, li)
|
||||
x = _layer_norm(x + attn_out, w[f"L{li}.ln1_g"], w[f"L{li}.ln1_b"])
|
||||
ff = self._ffn(x, li)
|
||||
x = _layer_norm(x + ff, w[f"L{li}.ln2_g"], w[f"L{li}.ln2_b"])
|
||||
|
||||
cls = x[0]
|
||||
return float(cls @ w["head_w"] + w["head_b"])
|
||||
|
||||
def predict_proba(self, seq_features: list[list[float]], valid_len: int) -> float:
|
||||
logit = self.forward_logits(seq_features, valid_len)
|
||||
return float(1.0 / (1.0 + np.exp(-logit)))
|
||||
|
||||
|
||||
def init_random_weights(
|
||||
d_model: int = 48,
|
||||
n_heads: int = 2,
|
||||
d_ff: int = 96,
|
||||
n_layers: int = 2,
|
||||
seed: int = 42,
|
||||
) -> dict[str, np.ndarray]:
|
||||
rng = np.random.default_rng(seed)
|
||||
d_k = d_model // n_heads
|
||||
w: dict[str, np.ndarray] = {}
|
||||
|
||||
def glorot(shape):
|
||||
fan_in, fan_out = shape[0], shape[1] if len(shape) > 1 else shape[0]
|
||||
limit = math.sqrt(6.0 / (fan_in + fan_out))
|
||||
return rng.uniform(-limit, limit, shape)
|
||||
|
||||
w["in_proj"] = glorot((FEATURE_DIM, d_model))
|
||||
w["in_bias"] = np.zeros(d_model)
|
||||
w["ln_in_g"] = np.ones(d_model)
|
||||
w["ln_in_b"] = np.zeros(d_model)
|
||||
|
||||
for li in range(n_layers):
|
||||
w[f"L{li}.Wq"] = glorot((d_model, d_model))
|
||||
w[f"L{li}.Wk"] = glorot((d_model, d_model))
|
||||
w[f"L{li}.Wv"] = glorot((d_model, d_model))
|
||||
w[f"L{li}.Wo"] = glorot((d_model, d_model))
|
||||
w[f"L{li}.Bq"] = np.zeros(d_model)
|
||||
w[f"L{li}.Bk"] = np.zeros(d_model)
|
||||
w[f"L{li}.Bv"] = np.zeros(d_model)
|
||||
w[f"L{li}.Bo"] = np.zeros(d_model)
|
||||
w[f"L{li}.ln1_g"] = np.ones(d_model)
|
||||
w[f"L{li}.ln1_b"] = np.zeros(d_model)
|
||||
w[f"L{li}.W1"] = glorot((d_model, d_ff))
|
||||
w[f"L{li}.b1"] = np.zeros(d_ff)
|
||||
w[f"L{li}.W2"] = glorot((d_ff, d_model))
|
||||
w[f"L{li}.b2"] = np.zeros(d_model)
|
||||
w[f"L{li}.ln2_g"] = np.ones(d_model)
|
||||
w[f"L{li}.ln2_b"] = np.zeros(d_model)
|
||||
|
||||
w["head_w"] = glorot((d_model,)) * 0.1
|
||||
w["head_b"] = np.array(0.0)
|
||||
return w
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Transformer 记忆预测服务:回忆概率、遗忘曲线、复习间隔建议。"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import QuizRecord, User, Word
|
||||
from services.memory_transformer.encoding import (
|
||||
build_sequence_matrix,
|
||||
parse_iso,
|
||||
word_en,
|
||||
)
|
||||
from services.memory_transformer.network import MiniTransformer, init_random_weights
|
||||
from services.memory_visual_service import retention_percent, stability_hours
|
||||
|
||||
WEIGHTS_PATH = Path(__file__).resolve().parents[2] / "data" / "memory_transformer" / "weights.json"
|
||||
DEFAULT_WEIGHTS_PATH = Path(__file__).resolve().parents[2] / "data" / "memory_transformer" / "default_weights.json"
|
||||
|
||||
|
||||
def _hours_since_review(word: Word, now: datetime) -> float:
|
||||
last_at = parse_iso(word.last_reviewed_at or word.created_at)
|
||||
return max(0.0, (now - last_at).total_seconds() / 3600.0)
|
||||
|
||||
|
||||
def _formula_recall(word: Word, hours_since: float) -> float:
|
||||
return retention_percent(hours_since, stability_hours(word)) / 100.0
|
||||
|
||||
|
||||
def _stability_days_from_target_r(target_r: float, hours_since: float) -> int:
|
||||
"""达到目标回忆率所需的额外稳定化间隔(天),用于推荐复习。"""
|
||||
target_r = max(0.55, min(0.95, target_r))
|
||||
if hours_since <= 0:
|
||||
return 1
|
||||
s_hours = -hours_since / np.log(target_r)
|
||||
days = int(max(1, min(30, round(s_hours / 24.0))))
|
||||
return days
|
||||
|
||||
|
||||
class MemoryTransformerService:
|
||||
def __init__(self) -> None:
|
||||
self._model: Optional[MiniTransformer] = None
|
||||
self._loaded_path: Optional[Path] = None
|
||||
|
||||
def _load_model(self) -> MiniTransformer:
|
||||
path = WEIGHTS_PATH if WEIGHTS_PATH.is_file() else DEFAULT_WEIGHTS_PATH
|
||||
if self._model is not None and self._loaded_path == path:
|
||||
return self._model
|
||||
if path.is_file():
|
||||
self._model = MiniTransformer.load_json(path)
|
||||
else:
|
||||
m = MiniTransformer()
|
||||
m.weights = init_random_weights(
|
||||
d_model=m.d_model,
|
||||
n_heads=m.n_heads,
|
||||
d_ff=m.d_ff,
|
||||
n_layers=m.n_layers,
|
||||
)
|
||||
self._model = m
|
||||
self._loaded_path = path
|
||||
return self._model
|
||||
|
||||
def model_ready(self) -> bool:
|
||||
return WEIGHTS_PATH.is_file() or DEFAULT_WEIGHTS_PATH.is_file()
|
||||
|
||||
def predict_for_word(
|
||||
self,
|
||||
db: Session,
|
||||
user: User,
|
||||
word: Word,
|
||||
horizon_hours: Optional[list[float]] = None,
|
||||
) -> dict:
|
||||
now = datetime.now(timezone.utc)
|
||||
records = (
|
||||
db.query(QuizRecord)
|
||||
.filter(QuizRecord.user_id == user.id, QuizRecord.word_id == word.id)
|
||||
.order_by(QuizRecord.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
seq, valid_len = build_sequence_matrix(word, records, now)
|
||||
model = self._load_model()
|
||||
|
||||
p_now = model.predict_proba(seq, valid_len)
|
||||
hours_since = _hours_since_review(word, now)
|
||||
p_formula = _formula_recall(word, hours_since)
|
||||
|
||||
# 事件少时与艾宾浩斯公式融合,冷启动更稳
|
||||
n_events = max(0, valid_len - 1)
|
||||
blend = min(1.0, n_events / 5.0)
|
||||
recall_now = round((blend * p_now + (1.0 - blend) * p_formula) * 100, 1)
|
||||
|
||||
if horizon_hours is None:
|
||||
horizon_hours = [0, 6, 12, 24, 48, 72, 120, 168, 240, 336]
|
||||
curve = []
|
||||
for dh in horizon_hours:
|
||||
future = now + timedelta(hours=dh)
|
||||
seq_f, valid_f = build_sequence_matrix(word, records, future)
|
||||
p_f = model.predict_proba(seq_f, valid_f)
|
||||
h_total = hours_since + dh
|
||||
p_form_f = _formula_recall(word, h_total)
|
||||
recall = round((blend * p_f + (1.0 - blend) * p_form_f) * 100, 1)
|
||||
curve.append(
|
||||
{
|
||||
"hours_ahead": dh,
|
||||
"recall_percent": recall,
|
||||
"forgetting_percent": round(100.0 - recall, 1),
|
||||
}
|
||||
)
|
||||
|
||||
target_r = 0.75
|
||||
recommended_days = _stability_days_from_target_r(
|
||||
target_r, hours_since * max(0.3, recall_now / 100.0)
|
||||
)
|
||||
half_life_hours = None
|
||||
for pt in curve:
|
||||
if pt["recall_percent"] <= 50.0:
|
||||
half_life_hours = pt["hours_ahead"]
|
||||
break
|
||||
|
||||
return {
|
||||
"word_id": word.id,
|
||||
"en": word_en(word),
|
||||
"recall_now_percent": recall_now,
|
||||
"formula_recall_percent": round(p_formula * 100, 1),
|
||||
"model_recall_percent": round(p_now * 100, 1),
|
||||
"blend_weight": round(blend, 2),
|
||||
"event_count": n_events,
|
||||
"model_trained": self.model_ready(),
|
||||
"recommended_review_days": recommended_days,
|
||||
"half_life_hours": half_life_hours,
|
||||
"curve": curve,
|
||||
"attention_hint": self._attention_hint(model, seq, valid_len),
|
||||
}
|
||||
|
||||
def _attention_hint(
|
||||
self,
|
||||
model: MiniTransformer,
|
||||
seq: list[list[float]],
|
||||
valid_len: int,
|
||||
) -> list[dict]:
|
||||
"""返回 CLS 对序列位置的关注度(便于解释「哪些 token 影响预测」)。"""
|
||||
if valid_len <= 1:
|
||||
return [{"index": 0, "label": "词项", "weight": 1.0}]
|
||||
x = np.array(seq[:valid_len], dtype=np.float64)
|
||||
w = model.weights
|
||||
x = x @ w["in_proj"] + w["in_bias"]
|
||||
Q = x[0:1] @ w["L0.Wq"] + w["L0.Bq"]
|
||||
K = x @ w["L0.Wk"] + w["L0.Bk"]
|
||||
sl = slice(0, model.d_k)
|
||||
scores = (Q[:, sl] @ K[:, sl].T)[0] / np.sqrt(model.d_k)
|
||||
attn = np.exp(scores - scores.max())
|
||||
attn = attn / attn.sum()
|
||||
labels = ["词项(CLS)"] + [f"事件{i}" for i in range(1, valid_len)]
|
||||
return [
|
||||
{"index": i, "label": labels[i], "weight": round(float(attn[i]), 3)}
|
||||
for i in range(valid_len)
|
||||
]
|
||||
|
||||
|
||||
memory_transformer_service = MemoryTransformerService()
|
||||
@@ -172,7 +172,7 @@ class QuizService:
|
||||
raise HTTPException(status_code=404, detail="单词不存在")
|
||||
|
||||
settings = self.get_settings(db, user)
|
||||
if question_type == "spell":
|
||||
if question_type in ("spell", "memory_coach"):
|
||||
is_correct = (
|
||||
user_answer.strip().lower() == correct_answer.strip().lower()
|
||||
)
|
||||
|
||||
@@ -172,6 +172,71 @@ export interface Settings {
|
||||
weak_wrong_threshold: number
|
||||
}
|
||||
|
||||
export interface MemoryToken {
|
||||
role: string
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
revealed: boolean
|
||||
}
|
||||
|
||||
export interface CoachWordBrief {
|
||||
word_id: number
|
||||
zh: string
|
||||
phonetic?: string | null
|
||||
retention_now: number
|
||||
mastery_score: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface CoachSessionResponse {
|
||||
words: CoachWordBrief[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface MemoryTransformerCurvePoint {
|
||||
hours_ahead: number
|
||||
recall_percent: number
|
||||
forgetting_percent: number
|
||||
}
|
||||
|
||||
export interface MemoryTransformerAttention {
|
||||
index: number
|
||||
label: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
export interface MemoryTransformerPredict {
|
||||
word_id: number
|
||||
en: string
|
||||
recall_now_percent: number
|
||||
formula_recall_percent: number
|
||||
model_recall_percent: number
|
||||
blend_weight: number
|
||||
event_count: number
|
||||
model_trained: boolean
|
||||
recommended_review_days: number
|
||||
half_life_hours?: number | null
|
||||
curve: MemoryTransformerCurvePoint[]
|
||||
attention_hint: MemoryTransformerAttention[]
|
||||
}
|
||||
|
||||
export interface CoachTurnResponse {
|
||||
assistant_messages: string[]
|
||||
tokens?: MemoryToken[]
|
||||
stage: string
|
||||
expect_input: boolean
|
||||
prompt_zh?: string
|
||||
prompt_phonetic?: string | null
|
||||
input_hint?: string
|
||||
is_correct?: boolean | null
|
||||
quiz_recorded: boolean
|
||||
word_complete: boolean
|
||||
hints_used: number
|
||||
target_en?: string | null
|
||||
target_zh?: string | null
|
||||
}
|
||||
|
||||
export const api = {
|
||||
register: (username: string, password: string) =>
|
||||
request.post('/auth/register', { username, password }),
|
||||
@@ -185,6 +250,8 @@ export const api = {
|
||||
getWord: (id: number) => request.get<Word>(`/words/${id}`),
|
||||
memoryViz: () => request.get<MemoryVisualization>('/words/memory-viz'),
|
||||
wordMemory: (id: number) => request.get<WordMemoryDetail>(`/words/${id}/memory`),
|
||||
wordMemoryModel: (id: number) =>
|
||||
request.get<MemoryTransformerPredict>(`/words/${id}/memory-model`),
|
||||
deleteWord: (id: number) => request.delete(`/words/${id}`),
|
||||
dailyQuiz: () =>
|
||||
request.get<{ questions: QuizQuestion[]; total: number }>('/quiz/daily'),
|
||||
@@ -200,4 +267,12 @@ export const api = {
|
||||
quizStats: () => request.get<QuizStats>('/quiz/stats'),
|
||||
getSettings: () => request.get<Settings>('/settings'),
|
||||
updateSettings: (data: Partial<Settings>) => request.patch<Settings>('/settings', data),
|
||||
coachSession: () => request.get<CoachSessionResponse>('/coach/session'),
|
||||
coachTurn: (data: {
|
||||
word_id: number
|
||||
stage?: string
|
||||
user_message?: string
|
||||
hints_used?: number
|
||||
duration_seconds?: number
|
||||
}) => request.post<CoachTurnResponse>('/coach/turn', data),
|
||||
}
|
||||
|
||||
@@ -22,8 +22,10 @@ const typeLabel: Record<string, string> = {
|
||||
|
||||
<template>
|
||||
<div class="card quiz-card">
|
||||
<div class="quiz-progress">{{ index + 1 }} / {{ total }}</div>
|
||||
<div class="quiz-type">{{ typeLabel[question.question_type] || question.question_type }}</div>
|
||||
<div class="quiz-meta">
|
||||
<span>{{ index + 1 }}/{{ total }}</span>
|
||||
<span>{{ typeLabel[question.question_type] || question.question_type }}</span>
|
||||
</div>
|
||||
<div class="quiz-prompt">{{ question.prompt }}</div>
|
||||
<div class="options">
|
||||
<button
|
||||
@@ -41,29 +43,27 @@ const typeLabel: Record<string, string> = {
|
||||
<span class="opt-label">{{ opt.label }}.</span> {{ opt.text }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showResult" class="result" :class="isCorrect ? 'ok' : 'fail'">
|
||||
{{ isCorrect ? '回答正确 ✅' : '回答错误 ❌' }}
|
||||
<template v-if="!isCorrect"> — 正确答案:{{ question.correct_answer }}</template>
|
||||
</div>
|
||||
<p v-if="showResult" class="result" :class="isCorrect ? 'ok' : 'fail'">
|
||||
<template v-if="isCorrect">正确</template>
|
||||
<template v-else>错误,答案 {{ question.correct_answer }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quiz-progress {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quiz-type {
|
||||
.quiz-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.quiz-prompt {
|
||||
font-size: 28px;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
margin: 8px 0 24px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.options {
|
||||
display: flex;
|
||||
@@ -96,12 +96,10 @@ const typeLabel: Record<string, string> = {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.result {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
margin: 16px 0 0;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
.result.ok { background: #d1fae5; color: #047857; }
|
||||
.result.fail { background: #fee2e2; color: #b91c1c; }
|
||||
.result.ok { color: var(--success); }
|
||||
.result.fail { color: var(--danger); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const open = ref(false)
|
||||
|
||||
const trainPaths = ['/quiz', '/spell', '/coach', '/graph-practice']
|
||||
|
||||
const isTrainRoute = computed(() =>
|
||||
trainPaths.some((p) => route.path === p || route.path.startsWith(p + '/'))
|
||||
)
|
||||
|
||||
const items = [
|
||||
{ to: '/quiz', label: '每日训练', icon: '✏️' },
|
||||
{ to: '/spell', label: '拼写练习', icon: '⌨️' },
|
||||
{ to: '/coach', label: '记忆对话', icon: '💬' },
|
||||
]
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
function go(to: string) {
|
||||
open.value = false
|
||||
if (route.path !== to) router.push(to)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
open.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="train-fab-wrap">
|
||||
<div v-if="open" class="train-fab-backdrop" @click="open = false" />
|
||||
|
||||
<transition name="train-menu">
|
||||
<ul v-if="open" class="train-fab-menu" role="menu">
|
||||
<li v-for="item in items" :key="item.to" role="none">
|
||||
<button
|
||||
type="button"
|
||||
class="train-fab-menu-item"
|
||||
role="menuitem"
|
||||
@click="go(item.to)"
|
||||
>
|
||||
<span class="menu-icon">{{ item.icon }}</span>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</transition>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="train-fab-btn"
|
||||
:class="{ active: isTrainRoute, open }"
|
||||
aria-label="训练菜单"
|
||||
:aria-expanded="open"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="fab-icon">{{ open ? '×' : '✏️' }}</span>
|
||||
<span class="fab-label">训练</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.train-fab-wrap {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: calc(64px + env(safe-area-inset-bottom));
|
||||
z-index: 110;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.train-fab-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: rgba(15, 23, 42, 0.25);
|
||||
}
|
||||
|
||||
.train-fab-menu {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 28px rgba(79, 110, 247, 0.2);
|
||||
border: 1px solid var(--border);
|
||||
min-width: 148px;
|
||||
}
|
||||
|
||||
.train-fab-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.train-fab-menu-item:hover {
|
||||
background: rgba(79, 110, 247, 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.train-fab-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(79, 110, 247, 0.45);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.train-fab-btn:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.train-fab-btn.active {
|
||||
box-shadow: 0 4px 20px rgba(79, 110, 247, 0.55);
|
||||
}
|
||||
|
||||
.train-fab-btn.open {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fab-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.train-menu-enter-active,
|
||||
.train-menu-leave-active {
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.train-menu-enter-from,
|
||||
.train-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<router-view />
|
||||
<TrainFab />
|
||||
<nav class="nav-bottom">
|
||||
<router-link to="/" class="nav-item">
|
||||
<span class="nav-icon">📊</span>
|
||||
@@ -14,10 +15,6 @@
|
||||
<span class="nav-icon">📚</span>
|
||||
词库
|
||||
</router-link>
|
||||
<router-link to="/quiz" class="nav-item">
|
||||
<span class="nav-icon">✏️</span>
|
||||
训练
|
||||
</router-link>
|
||||
<router-link to="/settings" class="nav-item">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
设置
|
||||
@@ -25,3 +22,7 @@
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TrainFab from '../components/TrainFab.vue'
|
||||
</script>
|
||||
|
||||
@@ -131,32 +131,27 @@ const accuracy = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">每日训练</h1>
|
||||
<router-link to="/spell" class="spell-link">拼写练习 →</router-link>
|
||||
</div>
|
||||
<div class="page quiz-page">
|
||||
<h1 class="page-title">每日训练</h1>
|
||||
|
||||
<p v-if="restored && !loading && !finished && !empty" class="restore-hint">
|
||||
已恢复上次未完成的训练进度
|
||||
<p v-if="restored && !loading && !finished && !empty" class="quiz-hint">
|
||||
已恢复上次进度
|
||||
</p>
|
||||
|
||||
<p v-if="loading" style="color: var(--muted)">加载题目...</p>
|
||||
<p v-if="loading" class="quiz-muted">加载中…</p>
|
||||
|
||||
<div v-else-if="empty" class="card" style="text-align: center">
|
||||
<p>词库单词不足,请先通过翻译添加至少 4 个单词。</p>
|
||||
<router-link to="/translate" class="btn btn-primary" style="margin-top: 12px; display: inline-block">
|
||||
去翻译
|
||||
</router-link>
|
||||
<div v-else-if="empty" class="quiz-empty">
|
||||
<p class="quiz-muted">词库至少需 4 个单词</p>
|
||||
<router-link to="/translate" class="quiz-link">去添加</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="finished" class="card summary">
|
||||
<h2>本次训练完成 🎉</h2>
|
||||
<p>总题数:{{ sessionCorrect + sessionWrong }}</p>
|
||||
<p>答对:{{ sessionCorrect }}</p>
|
||||
<p>答错:{{ sessionWrong }}</p>
|
||||
<p>正确率:{{ accuracy() }}%</p>
|
||||
<router-link to="/" class="btn btn-primary" style="margin-top: 16px">返回首页</router-link>
|
||||
<div v-else-if="finished" class="quiz-done">
|
||||
<p class="quiz-done-rate">{{ accuracy() }}%</p>
|
||||
<p class="quiz-done-meta">
|
||||
{{ sessionCorrect }} 对 · {{ sessionWrong }} 错 · 共
|
||||
{{ sessionCorrect + sessionWrong }} 题
|
||||
</p>
|
||||
<router-link to="/" class="btn btn-primary quiz-done-btn">完成</router-link>
|
||||
</div>
|
||||
|
||||
<template v-else-if="current()">
|
||||
@@ -171,44 +166,60 @@ const accuracy = () => {
|
||||
/>
|
||||
<button
|
||||
v-if="showResult"
|
||||
class="btn btn-primary"
|
||||
style="margin-top: 12px"
|
||||
class="btn btn-primary quiz-next"
|
||||
@click="nextQuestion"
|
||||
>
|
||||
{{ currentIndex >= questions.length - 1 ? '查看结果' : '下一题' }}
|
||||
{{ currentIndex >= questions.length - 1 ? '结果' : '下一题' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.page-header .page-title {
|
||||
margin: 0;
|
||||
}
|
||||
.spell-link {
|
||||
font-size: 14px;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.restore-hint {
|
||||
.quiz-hint,
|
||||
.quiz-muted {
|
||||
font-size: 13px;
|
||||
color: var(--primary);
|
||||
margin-bottom: 12px;
|
||||
color: var(--muted);
|
||||
margin: -12px 0 16px;
|
||||
}
|
||||
.summary h2 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 20px;
|
||||
|
||||
.quiz-empty {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
.summary p {
|
||||
margin: 6px 0;
|
||||
|
||||
.quiz-link {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
font-size: 15px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.quiz-done {
|
||||
text-align: center;
|
||||
padding: 40px 0 16px;
|
||||
}
|
||||
|
||||
.quiz-done-rate {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.quiz-done-meta {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin: 12px 0 28px;
|
||||
}
|
||||
|
||||
.quiz-done-btn {
|
||||
max-width: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.quiz-next {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -62,6 +62,7 @@ onMounted(async () => {
|
||||
<router-link to="/words" class="btn btn-outline">单词库</router-link>
|
||||
<router-link to="/quiz" class="btn btn-primary">开始每日训练</router-link>
|
||||
<router-link to="/spell" class="btn btn-outline">拼写练习</router-link>
|
||||
<router-link to="/coach" class="btn btn-outline">记忆对话</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import {
|
||||
api,
|
||||
type CoachTurnResponse,
|
||||
type CoachWordBrief,
|
||||
type MemoryToken,
|
||||
} from '../api/request'
|
||||
import { useQuizTimer } from '../composables/useQuizTimer'
|
||||
|
||||
interface ChatLine {
|
||||
role: 'assistant' | 'user'
|
||||
content: string
|
||||
}
|
||||
|
||||
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
|
||||
|
||||
const loading = ref(true)
|
||||
const empty = ref(false)
|
||||
const words = ref<CoachWordBrief[]>([])
|
||||
const wordIndex = ref(0)
|
||||
const stage = ref('intro')
|
||||
const hintsUsed = ref(0)
|
||||
const tokens = ref<MemoryToken[]>([])
|
||||
const promptZh = ref('')
|
||||
const promptPhonetic = ref<string | null>(null)
|
||||
const chatLines = ref<ChatLine[]>([])
|
||||
const userInput = ref('')
|
||||
const sending = ref(false)
|
||||
const expectInput = ref(false)
|
||||
const sessionDone = ref(false)
|
||||
const showTokens = ref(false)
|
||||
const chatEndRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const currentWord = computed(() => words.value[wordIndex.value])
|
||||
|
||||
const promptLabel = computed(() => promptZh.value || currentWord.value?.zh || '')
|
||||
|
||||
const phoneticLabel = computed(
|
||||
() => promptPhonetic.value || currentWord.value?.phonetic || ''
|
||||
)
|
||||
|
||||
const inputPlaceholder = computed(() => {
|
||||
const zh = promptLabel.value
|
||||
return zh ? `输入「${zh}」的英文` : '输入英文'
|
||||
})
|
||||
|
||||
const kTokens = computed(() => tokens.value.filter((t) => t.role === 'K'))
|
||||
const qTokens = computed(() => tokens.value.filter((t) => t.role === 'Q'))
|
||||
const vTokens = computed(() => tokens.value.filter((t) => t.role === 'V' && t.revealed))
|
||||
|
||||
function tokenClass(role: string) {
|
||||
if (role === 'Q') return 'pill-q'
|
||||
if (role === 'K') return 'pill-k'
|
||||
return 'pill-v'
|
||||
}
|
||||
|
||||
function scrollChat() {
|
||||
nextTick(() => {
|
||||
chatEndRef.value?.scrollIntoView({ behavior: 'smooth' })
|
||||
})
|
||||
}
|
||||
|
||||
function appendAssistant(msgs: string[]) {
|
||||
for (const m of msgs) {
|
||||
if (m.trim()) chatLines.value.push({ role: 'assistant', content: m })
|
||||
}
|
||||
scrollChat()
|
||||
}
|
||||
|
||||
function applyTurn(res: CoachTurnResponse) {
|
||||
tokens.value = res.tokens ?? []
|
||||
stage.value = res.stage
|
||||
expectInput.value = res.expect_input
|
||||
hintsUsed.value = res.hints_used
|
||||
if (res.prompt_zh) promptZh.value = res.prompt_zh
|
||||
if (res.prompt_phonetic !== undefined) promptPhonetic.value = res.prompt_phonetic
|
||||
appendAssistant(res.assistant_messages)
|
||||
}
|
||||
|
||||
async function runTurn(message: string) {
|
||||
const w = currentWord.value
|
||||
if (!w || sending.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const duration =
|
||||
stage.value === 'derive' && message
|
||||
? consumeDurationSeconds()
|
||||
: undefined
|
||||
const { data } = await api.coachTurn({
|
||||
word_id: w.word_id,
|
||||
stage: stage.value,
|
||||
user_message: message,
|
||||
hints_used: hintsUsed.value,
|
||||
duration_seconds: duration,
|
||||
})
|
||||
applyTurn(data)
|
||||
if (data.word_complete && data.stage === 'done') {
|
||||
expectInput.value = false
|
||||
}
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startCurrentWord() {
|
||||
const w = currentWord.value
|
||||
chatLines.value = []
|
||||
stage.value = 'intro'
|
||||
hintsUsed.value = 0
|
||||
userInput.value = ''
|
||||
showTokens.value = false
|
||||
promptZh.value = w?.zh ?? ''
|
||||
promptPhonetic.value = w?.phonetic ?? null
|
||||
startQuestionTimer()
|
||||
await runTurn('')
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = userInput.value.trim()
|
||||
if (!text) return
|
||||
chatLines.value.push({ role: 'user', content: text })
|
||||
userInput.value = ''
|
||||
scrollChat()
|
||||
await runTurn(text)
|
||||
}
|
||||
|
||||
function nextWord() {
|
||||
if (wordIndex.value >= words.value.length - 1) {
|
||||
sessionDone.value = true
|
||||
return
|
||||
}
|
||||
wordIndex.value += 1
|
||||
startCurrentWord()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await api.coachSession()
|
||||
words.value = data.words
|
||||
empty.value = data.total === 0
|
||||
if (!empty.value) await startCurrentWord()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page coach-page">
|
||||
<header class="coach-header">
|
||||
<h1 class="page-title">记忆对话</h1>
|
||||
<span v-if="!empty && !sessionDone" class="coach-count">
|
||||
{{ wordIndex + 1 }} / {{ words.length }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<p v-if="loading" class="muted">加载中...</p>
|
||||
<p v-else-if="empty" class="muted">
|
||||
暂无待练单词,<router-link to="/translate">去加词</router-link>
|
||||
</p>
|
||||
|
||||
<template v-else-if="!sessionDone">
|
||||
<section class="coach-main card">
|
||||
<p v-if="promptLabel" class="coach-zh">{{ promptLabel }}</p>
|
||||
<p v-if="phoneticLabel" class="coach-phonetic">{{ phoneticLabel }}</p>
|
||||
<p v-if="currentWord" class="coach-meta">
|
||||
记忆 {{ currentWord.retention_now }}% · 掌握 {{ currentWord.mastery_score }}%
|
||||
<span class="qkv-hint">· K/Q/V 推导</span>
|
||||
</p>
|
||||
|
||||
<button type="button" class="token-toggle" @click="showTokens = !showTokens">
|
||||
{{ showTokens ? '收起线索' : '展开 Q/K/V 线索' }}
|
||||
<span v-if="hintsUsed" class="hint-badge">{{ hintsUsed }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="showTokens && tokens.length" class="token-groups">
|
||||
<div v-if="kTokens.length" class="token-group">
|
||||
<span class="group-tag pill-k">K</span>
|
||||
<span
|
||||
v-for="t in kTokens"
|
||||
:key="t.key"
|
||||
class="pill"
|
||||
:class="tokenClass('K')"
|
||||
>
|
||||
{{ t.label }} {{ t.value }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="qTokens.length" class="token-group">
|
||||
<span class="group-tag pill-q">Q</span>
|
||||
<span
|
||||
v-for="t in qTokens"
|
||||
:key="t.key"
|
||||
class="pill"
|
||||
:class="[tokenClass('Q'), { dim: !t.revealed }]"
|
||||
>
|
||||
{{ t.label }} {{ t.revealed ? t.value : '…' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="vTokens.length" class="token-group">
|
||||
<span class="group-tag pill-v">V</span>
|
||||
<span
|
||||
v-for="t in vTokens"
|
||||
:key="t.key"
|
||||
class="pill"
|
||||
:class="tokenClass('V')"
|
||||
>
|
||||
{{ t.label }} {{ t.value }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chatLines.length" class="chat-feed">
|
||||
<div
|
||||
v-for="(line, i) in chatLines"
|
||||
:key="i"
|
||||
class="chat-line"
|
||||
:class="line.role"
|
||||
>
|
||||
{{ line.content }}
|
||||
</div>
|
||||
<div ref="chatEndRef" />
|
||||
</div>
|
||||
|
||||
<div v-if="expectInput" class="input-bar">
|
||||
<input
|
||||
v-model="userInput"
|
||||
class="input"
|
||||
type="text"
|
||||
:placeholder="inputPlaceholder"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary send-btn"
|
||||
:disabled="sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
提交
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else-if="stage === 'done'"
|
||||
type="button"
|
||||
class="btn btn-primary next-btn"
|
||||
@click="nextWord"
|
||||
>
|
||||
{{ wordIndex >= words.length - 1 ? '完成' : '下一个' }}
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section v-else class="card done-card">
|
||||
<p>本轮已完成</p>
|
||||
<router-link to="/" class="btn btn-outline">回首页</router-link>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.coach-page {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
.coach-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.coach-header .page-title {
|
||||
margin: 0;
|
||||
}
|
||||
.coach-count {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.coach-main {
|
||||
padding: 20px 16px;
|
||||
}
|
||||
.coach-zh {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1.25;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.coach-phonetic {
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.coach-meta {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.qkv-hint {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.token-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.hint-badge {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.token-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.token-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.group-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pill {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
.pill.dim {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
.pill-k,
|
||||
.group-tag.pill-k {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
border-color: #ddd6fe;
|
||||
}
|
||||
.pill-q,
|
||||
.group-tag.pill-q {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
.pill-v,
|
||||
.group-tag.pill-v {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
border-color: #a7f3d0;
|
||||
}
|
||||
.chat-feed {
|
||||
max-height: 28vh;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 14px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.chat-line {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.chat-line.user {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.chat-line.assistant {
|
||||
color: var(--text);
|
||||
}
|
||||
.input-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.input-bar .input {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
.send-btn {
|
||||
width: auto;
|
||||
min-width: 64px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.next-btn {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.done-card {
|
||||
text-align: center;
|
||||
padding: 28px 20px;
|
||||
}
|
||||
.done-card p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type MemoryWordSummary,
|
||||
type Word,
|
||||
type WordMemoryDetail,
|
||||
type MemoryTransformerPredict,
|
||||
} from '../api/request'
|
||||
import { formatTrainSeconds } from '../composables/useQuizTimer'
|
||||
import {
|
||||
@@ -72,6 +73,8 @@ const selectedWord = ref<MemoryWordSummary | null>(null)
|
||||
const detailExpanded = ref(true)
|
||||
const wordMemory = ref<WordMemoryDetail | null>(null)
|
||||
const wordMemoryLoading = ref(false)
|
||||
const memoryModel = ref<MemoryTransformerPredict | null>(null)
|
||||
const memoryModelLoading = ref(false)
|
||||
|
||||
const {
|
||||
graphStatusFilter,
|
||||
@@ -166,13 +169,20 @@ async function handleDelete(id: number) {
|
||||
|
||||
async function loadWordMemory(wordId: number) {
|
||||
wordMemoryLoading.value = true
|
||||
memoryModelLoading.value = true
|
||||
try {
|
||||
const { data } = await api.wordMemory(wordId)
|
||||
wordMemory.value = data
|
||||
const [memRes, modelRes] = await Promise.all([
|
||||
api.wordMemory(wordId),
|
||||
api.wordMemoryModel(wordId),
|
||||
])
|
||||
wordMemory.value = memRes.data
|
||||
memoryModel.value = modelRes.data
|
||||
} catch {
|
||||
wordMemory.value = null
|
||||
memoryModel.value = null
|
||||
} finally {
|
||||
wordMemoryLoading.value = false
|
||||
memoryModelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +474,24 @@ onMounted(() => {
|
||||
<span>当前记忆保留 {{ selectedWord.retention_now }}%</span>
|
||||
<span>7 日后预测 {{ selectedWord.risk_7d }}%</span>
|
||||
</div>
|
||||
<div v-if="memoryModel && !memoryModelLoading" class="model-panel">
|
||||
<h4 class="word-curve-title">Transformer 记忆预测</h4>
|
||||
<p class="section-desc">
|
||||
模型回忆率 {{ memoryModel.model_recall_percent }}% · 公式
|
||||
{{ memoryModel.formula_recall_percent }}% · 融合
|
||||
{{ memoryModel.recall_now_percent }}% · 建议
|
||||
{{ memoryModel.recommended_review_days }} 天后复习
|
||||
</p>
|
||||
<div class="attn-chips">
|
||||
<span
|
||||
v-for="a in memoryModel.attention_hint"
|
||||
:key="a.index"
|
||||
class="attn-chip"
|
||||
>
|
||||
{{ a.label }} {{ (a.weight * 100).toFixed(0) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="wordMemoryLoading" class="curve-loading">加载该词记忆曲线...</p>
|
||||
<template v-else-if="wordCurvePoints.length">
|
||||
<h4 class="word-curve-title">该单词记忆曲线</h4>
|
||||
@@ -503,6 +531,26 @@ onMounted(() => {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.model-panel {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px;
|
||||
background: rgba(79, 110, 247, 0.06);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid rgba(79, 110, 247, 0.15);
|
||||
}
|
||||
.attn-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.attn-chip {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.page-summary {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -21,6 +21,11 @@ const router = createRouter({
|
||||
name: 'GraphPractice',
|
||||
component: () => import('../pages/GraphPractice.vue'),
|
||||
},
|
||||
{
|
||||
path: 'coach',
|
||||
name: 'MemoryCoach',
|
||||
component: () => import('../pages/MemoryCoach.vue'),
|
||||
},
|
||||
{ path: 'settings', name: 'Settings', component: () => import('../pages/Settings.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -103,7 +103,7 @@ a {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
padding-bottom: 80px;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/request.ts","./src/router/index.ts","./src/app.vue","./src/components/quizcard.vue","./src/components/wordcard.vue","./src/layouts/mainlayout.vue","./src/pages/dailyquiz.vue","./src/pages/dashboard.vue","./src/pages/login.vue","./src/pages/register.vue","./src/pages/settings.vue","./src/pages/translate.vue","./src/pages/wordlibrary.vue"],"version":"5.6.3"}
|
||||
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/request.ts","./src/composables/usegraphfilter.ts","./src/composables/usequizsession.ts","./src/composables/usequiztimer.ts","./src/router/index.ts","./src/utils/auth.ts","./src/utils/buildpracticequestion.ts","./src/utils/practicefocus.ts","./src/utils/practicegraphdisplay.ts","./src/utils/practicegraphsession.ts","./src/utils/spellquestion.ts","./src/app.vue","./src/components/memorycurvechart.vue","./src/components/quizcard.vue","./src/components/spellcard.vue","./src/components/trainfab.vue","./src/components/wordcard.vue","./src/components/wordgraphcanvas.vue","./src/layouts/mainlayout.vue","./src/pages/dailyquiz.vue","./src/pages/dashboard.vue","./src/pages/graphpractice.vue","./src/pages/login.vue","./src/pages/memorycoach.vue","./src/pages/register.vue","./src/pages/settings.vue","./src/pages/spellquiz.vue","./src/pages/translate.vue","./src/pages/wordlibrary.vue"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user