Ship native iOS app, Wiki/TTS backend, and skeleton loading UX.

Replace the WebView shell with SwiftUI screens, add account-scoped Wiki and TTS APIs with adaptive review and photo scan support, and keep web/iOS pages usable while data loads asynchronously.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-08 15:13:59 +08:00
parent db5465c646
commit 05e173b293
115 changed files with 12709 additions and 1212 deletions
+336
View File
@@ -0,0 +1,336 @@
"""可解释的自适应复习调度,用真实回忆表现更新单词出现频率。"""
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from models import Word
TARGET_RETRIEVABILITY = 0.85
MIN_STABILITY_HOURS = 0.25
MAX_STABILITY_HOURS = 24.0 * 365.0
def parse_utc(value: str | None, fallback: datetime | None = None) -> datetime:
if not value:
return fallback or datetime.now(timezone.utc)
normalized = value.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
parsed = datetime.strptime(value[:19], "%Y-%m-%dT%H:%M:%S")
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def utc_iso(value: datetime) -> str:
return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def retrievability(word: Word, now: datetime | None = None) -> float:
now = now or datetime.now(timezone.utc)
last_review = parse_utc(word.last_reviewed_at or word.created_at, now)
elapsed_hours = max(0.0, (now - last_review).total_seconds() / 3600.0)
stability = max(MIN_STABILITY_HOURS, float(word.stability_hours or 24.0))
return max(0.0, min(1.0, math.exp(-elapsed_hours / stability)))
def response_quality(
*,
is_correct: bool,
attempts: int,
hints_used: int,
duration_seconds: int,
) -> float:
if not is_correct:
return 0.0
quality = 1.0
quality -= min(0.45, max(0, attempts - 1) * 0.18)
quality -= min(0.35, max(0, hints_used) * 0.12)
if duration_seconds > 30:
quality -= min(0.25, (duration_seconds - 30) / 180.0)
elif duration_seconds <= 8 and attempts == 1 and hints_used == 0:
quality += 0.1
return max(0.15, min(1.0, quality))
@dataclass(frozen=True)
class ReviewUpdate:
quality: float
difficulty: float
stability_hours: float
retrievability: float
next_review_at: str
interval_hours: float
message: str
class AdaptiveReviewService:
def update_word(
self,
word: Word,
*,
is_correct: bool,
attempts: int,
hints_used: int,
duration_seconds: int,
observed_retrievability: float | None = None,
now: datetime | None = None,
) -> ReviewUpdate:
now = now or datetime.now(timezone.utc)
before_r = (
retrievability(word, now)
if observed_retrievability is None
else max(0.0, min(1.0, observed_retrievability))
)
old_difficulty = float(word.difficulty or 5.0)
old_stability = max(
MIN_STABILITY_HOURS, float(word.stability_hours or 24.0)
)
quality = response_quality(
is_correct=is_correct,
attempts=attempts,
hints_used=hints_used,
duration_seconds=duration_seconds,
)
if is_correct:
difficulty = old_difficulty - (quality - 0.55) * 0.9
growth = 1.15 + 1.85 * quality + 0.35 * (1.0 - before_r)
stability = old_stability * growth
interval_hours = -stability * math.log(TARGET_RETRIEVABILITY)
interval_hours = max(1.0, interval_hours)
else:
difficulty = old_difficulty + 0.8
stability = old_stability * 0.45
interval_hours = 0.25
difficulty = max(1.0, min(10.0, difficulty))
stability = max(MIN_STABILITY_HOURS, min(MAX_STABILITY_HOURS, stability))
next_review = now + timedelta(hours=interval_hours)
word.difficulty = round(difficulty, 3)
word.stability_hours = round(stability, 3)
word.next_review_at = utc_iso(next_review)
word.review_due_date = next_review.strftime("%Y-%m-%d")
if not is_correct:
message = "本词记忆较弱,将在约 15 分钟后优先出现"
elif interval_hours < 24:
message = f"本词将在约 {max(1, round(interval_hours))} 小时后复习"
else:
message = f"本词将在约 {max(1, round(interval_hours / 24))} 天后复习"
return ReviewUpdate(
quality=round(quality, 3),
difficulty=word.difficulty,
stability_hours=word.stability_hours,
retrievability=round(before_r * 100.0, 1),
next_review_at=word.next_review_at,
interval_hours=round(interval_hours, 2),
message=message,
)
def selection_score(self, word: Word, now: datetime | None = None) -> float:
now = now or datetime.now(timezone.utc)
current_r = retrievability(word, now)
difficulty = float(word.difficulty or 5.0) / 10.0
score = (1.0 - current_r) * 70.0 + difficulty * 20.0
if word.status == "weak":
score += 35.0
elif word.status == "learning":
score += 18.0
elif word.status == "new":
score += 25.0
if word.next_review_at:
due_at = parse_utc(word.next_review_at, now)
overdue_hours = (now - due_at).total_seconds() / 3600.0
if overdue_hours >= 0:
score += 50.0 + min(50.0, overdue_hours / 24.0 * 5.0)
else:
score -= min(35.0, -overdue_hours / 24.0 * 4.0)
elif word.review_due_date:
if word.review_due_date <= now.strftime("%Y-%m-%d"):
score += 45.0
wrong = max(0, int(word.wrong_count or 0))
correct = max(0, int(word.correct_count or 0))
if wrong > 0:
score += min(40.0, wrong * 8.0)
if correct == 0 and wrong > 0:
score += 45.0
elif wrong > correct:
score += 25.0
mastery = max(0, min(100, int(word.mastery_score or 0)))
score += (100 - mastery) * 0.25
return score
@staticmethod
def error_bank_member(word: Word) -> bool:
return bool(int(getattr(word, "error_bank_member", 0) or 0))
@staticmethod
def error_reinforce_streak(word: Word) -> int:
return max(0, int(getattr(word, "error_reinforce_streak", 0) or 0))
@staticmethod
def is_review_due(word: Word, now: datetime | None = None) -> bool:
"""仅当已排期且到期时返回 True;未排期视为冷却中,不可提前复习。"""
now = now or datetime.now(timezone.utc)
if word.next_review_at:
due_at = parse_utc(word.next_review_at, now)
return due_at <= now
if word.review_due_date:
return word.review_due_date <= now.strftime("%Y-%m-%d")
return False
@staticmethod
def is_in_error_reinforcement(word: Word, clear_count: int) -> bool:
"""错题巩固·强化阶段:在错题库且连续答对未达设定次数。"""
if not AdaptiveReviewService.error_bank_member(word):
return False
return AdaptiveReviewService.error_reinforce_streak(word) < clear_count
@staticmethod
def is_in_error_bank_review(word: Word, clear_count: int, now: datetime | None = None) -> bool:
"""错题库·间隔复习:已移出错题巩固且到达复习时间。"""
if not AdaptiveReviewService.error_bank_member(word):
return False
if AdaptiveReviewService.error_reinforce_streak(word) < clear_count:
return False
return AdaptiveReviewService.is_review_due(word, now)
@staticmethod
def is_plan_completed(word: Word) -> bool:
"""已训练、有答对记录,且不在错题库。"""
if AdaptiveReviewService.error_bank_member(word):
return False
return (
int(word.train_count or 0) > 0 and int(word.correct_count or 0) > 0
)
@staticmethod
def is_plan_eligible(word: Word) -> bool:
return not AdaptiveReviewService.is_plan_completed(word)
@staticmethod
def is_untrained_word(word: Word) -> bool:
"""从未练过的词:无练习记录且不在错题库。"""
if AdaptiveReviewService.error_bank_member(word):
return False
return int(word.train_count or 0) == 0
@staticmethod
def is_error_priority(word: Word) -> bool:
"""练习表现偏差的词:应优先进入记忆对话。"""
if AdaptiveReviewService.is_plan_completed(word):
return False
if AdaptiveReviewService.error_bank_member(word):
return True
wrong = max(0, int(word.wrong_count or 0))
if wrong <= 0:
return int(word.train_count or 0) == 0
if word.status == "weak":
return True
correct = max(0, int(word.correct_count or 0))
if correct == 0:
return True
return wrong > correct
@staticmethod
def split_plan_pools(
words: list[Word], clear_count: int = 2
) -> tuple[list[Word], list[Word]]:
"""将计划内词拆为未练词池与错题巩固强化池。"""
eligible = [w for w in words if AdaptiveReviewService.is_plan_eligible(w)]
untrained = [
w for w in eligible if AdaptiveReviewService.is_untrained_word(w)
]
reinforce = [
w
for w in eligible
if AdaptiveReviewService.is_in_error_reinforcement(w, clear_count)
]
return untrained, reinforce
@staticmethod
def split_error_bank(words: list[Word], clear_count: int = 2, now: datetime | None = None) -> tuple[list[Word], list[Word]]:
"""错题库成员拆为待复习与冷却中。"""
now = now or datetime.now(timezone.utc)
members = [w for w in words if AdaptiveReviewService.error_bank_member(w)]
due = [
w
for w in members
if AdaptiveReviewService.is_in_error_bank_review(w, clear_count, now)
]
cooling = [w for w in members if w not in due]
return due, cooling
@staticmethod
def count_plan_pools(
words: list[Word], clear_count: int = 2, now: datetime | None = None
) -> tuple[int, int, int, int]:
untrained, reinforce = AdaptiveReviewService.split_plan_pools(words, clear_count)
due, _ = AdaptiveReviewService.split_error_bank(words, clear_count, now)
bank_total = sum(1 for w in words if AdaptiveReviewService.error_bank_member(w))
return len(untrained), len(reinforce), len(due), bank_total
def select_untrained_words(
self, words: list[Word], limit: int, now: datetime | None = None, clear_count: int = 2
) -> list[Word]:
untrained, _ = self.split_plan_pools(words, clear_count)
if not untrained or limit <= 0:
return []
return self.select_words(untrained, limit, now)
def select_error_words(
self, words: list[Word], limit: int, now: datetime | None = None, clear_count: int = 2
) -> list[Word]:
_, reinforce = self.split_plan_pools(words, clear_count)
if not reinforce or limit <= 0:
return []
return self.select_words(reinforce, limit, now)
def select_error_bank_words(
self, words: list[Word], limit: int, now: datetime | None = None, clear_count: int = 2
) -> list[Word]:
due, _ = self.split_error_bank(words, clear_count, now)
if not due or limit <= 0:
return []
return self.select_words(due, limit, now)
def select_practice_plan_words(
self, words: list[Word], limit: int, now: datetime | None = None, clear_count: int = 2
) -> list[Word]:
"""未训练词优先,其次错题巩固强化词。"""
if limit <= 0:
return []
untrained_ranked = self.select_untrained_words(words, limit, now, clear_count)
if len(untrained_ranked) >= limit:
return untrained_ranked[:limit]
remaining = limit - len(untrained_ranked)
error_ranked = self.select_error_words(words, remaining, now, clear_count)
return untrained_ranked + error_ranked
def select_words(
self, words: list[Word], limit: int, now: datetime | None = None
) -> list[Word]:
now = now or datetime.now(timezone.utc)
ranked = sorted(
words,
key=lambda word: (
self.selection_score(word, now),
-word.id,
),
reverse=True,
)
return ranked[:limit]
adaptive_review_service = AdaptiveReviewService()
+7
View File
@@ -75,6 +75,7 @@ class BookService:
"daily_target": row.daily_target,
"master_required_count": row.master_required_count,
"weak_wrong_threshold": row.weak_wrong_threshold,
"error_clear_correct_count": row.error_clear_correct_count,
"learn_mode": book.learn_mode,
}
@@ -89,6 +90,8 @@ class BookService:
row.master_required_count = data["master_required_count"]
if data.get("weak_wrong_threshold") is not None:
row.weak_wrong_threshold = data["weak_wrong_threshold"]
if data.get("error_clear_correct_count") is not None:
row.error_clear_correct_count = data["error_clear_correct_count"]
db.commit()
db.refresh(row)
return self.practice_settings_dict(db, user, book)
@@ -163,6 +166,9 @@ class BookService:
wrong_count=0,
consecutive_correct_count=0,
mastery_score=0,
difficulty=5.0,
stability_hours=24.0,
next_review_at=now,
review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
created_at=now,
)
@@ -226,6 +232,7 @@ class BookService:
"daily_target": user_settings.daily_target,
"master_required_count": user_settings.master_required_count,
"weak_wrong_threshold": user_settings.weak_wrong_threshold,
"error_clear_correct_count": user_settings.error_clear_correct_count,
"learn_mode": "adaptive",
}
+143
View File
@@ -0,0 +1,143 @@
from __future__ import annotations
import json
import urllib.error
import urllib.request
from config import get_settings
class LlmServiceError(Exception):
pass
class LlmService:
def is_configured(self) -> bool:
return bool(get_settings().deepseek_api_key.strip())
def model_name(self) -> str:
return get_settings().deepseek_model
def _chat_completion(
self,
*,
messages: list[dict],
temperature: float = 0.2,
max_tokens: int = 1800,
response_format: dict | None = None,
timeout: int = 15,
) -> str:
settings = get_settings()
api_key = settings.deepseek_api_key.strip()
if not api_key:
raise LlmServiceError("DeepSeek API key is not configured")
payload: dict = {
"model": settings.deepseek_model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if response_format:
payload["response_format"] = response_format
request = urllib.request.Request(
f"{settings.deepseek_base_url.rstrip('/')}/v1/chat/completions",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
body = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise LlmServiceError(f"DeepSeek HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise LlmServiceError(f"DeepSeek request failed: {exc.reason}") from exc
try:
content = body["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise LlmServiceError("DeepSeek response format is invalid") from exc
return str(content).strip()
@staticmethod
def _strip_code_fence(text: str) -> str:
cleaned = text.strip()
if not cleaned.startswith("```"):
return cleaned
cleaned = cleaned.strip("`").strip()
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:].lstrip()
elif cleaned.lower().startswith("markdown"):
cleaned = cleaned[8:].lstrip()
return cleaned.strip()
def enhance_wiki_page(self, *, title: str, draft_markdown: str) -> str:
prompt = (
"你是 WordLoop 个人学习 Wiki 的编译器,遵循 Karpathy Wiki 思路:"
"把原始学习事件整理成简洁、可检索的 Markdown 知识页。\n"
"要求:\n"
"1. 保留草稿中的事实数据(释义、掌握度、练习记录等),不要编造。\n"
"2. 优化结构、摘要与关联说明,便于日后复习。\n"
"3. 只输出 Markdown 正文,不要代码块包裹,不要额外解释。\n"
f"\n页面标题:{title}\n\n草稿:\n{draft_markdown}"
)
content = self._chat_completion(
messages=[
{"role": "system", "content": "你是严谨的学习笔记整理助手。"},
{"role": "user", "content": prompt},
],
temperature=0.2,
max_tokens=1800,
)
cleaned = self._strip_code_fence(content)
return cleaned or draft_markdown
def parse_word_pairs_from_text(self, text: str) -> list[dict[str, str]]:
prompt = (
"你是英语学习词表整理助手。从 OCR 识别文本中提取英汉单词对。\n"
"要求:\n"
"1. 只提取明确的单词/短语与其中文释义,不要编造。\n"
"2. 忽略页码、标题、序号、噪声行。\n"
"3. 输出 JSON 对象,格式为 {\"items\":[{\"en\":\"...\",\"zh\":\"...\"}]}。\n"
"4. en 为英文,zh 为中文;若原文是中文在前英文在后,也要正确归位。\n"
"5. 只输出 JSON,不要解释。\n"
f"\nOCR 文本:\n{text.strip()}"
)
content = self._chat_completion(
messages=[
{"role": "system", "content": "你只输出合法 JSON。"},
{"role": "user", "content": prompt},
],
temperature=0.1,
max_tokens=4000,
response_format={"type": "json_object"},
timeout=30,
)
cleaned = self._strip_code_fence(content)
try:
payload = json.loads(cleaned)
except json.JSONDecodeError as exc:
raise LlmServiceError("词表解析结果不是合法 JSON") from exc
raw_items = payload.get("items") if isinstance(payload, dict) else payload
if not isinstance(raw_items, list):
raise LlmServiceError("词表解析结果缺少 items 数组")
pairs: list[dict[str, str]] = []
for item in raw_items:
if not isinstance(item, dict):
continue
en = str(item.get("en") or item.get("source_text") or "").strip()
zh = str(item.get("zh") or item.get("target_text") or "").strip()
if en and zh:
pairs.append({"en": en, "zh": zh})
return pairs
llm_service = LlmService()
+60 -11
View File
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from models import QuizRecord, User, Word
from schemas import CoachSessionResponse, CoachTurnResponse, CoachWordBrief, MemoryToken
from services.adaptive_review_service import adaptive_review_service, retrievability
from services.memory_visual_service import (
memory_visual_service,
parse_iso,
@@ -15,6 +16,7 @@ from services.memory_visual_service import (
word_zh,
)
from services.quiz_service import quiz_service
from services.text_utils import en_answers_match
from services.word_service import word_service
@@ -225,12 +227,16 @@ class MemoryCoachService:
return CoachSessionResponse(words=[], total=0)
book_service.ensure_user_book_words(db, user, book)
practice = book_service.practice_settings_dict(db, user, book)
words = quiz_service.select_book_words(db, user, book, practice["daily_target"])
book_id = book.id
words = quiz_service.select_coach_words(
db, user, book_id, practice["daily_target"]
)
else:
settings = quiz_service.get_settings(db, user)
words = quiz_service.select_accumulation_words(db, user, settings.daily_target)
book_id = 0
words = quiz_service.select_coach_words(
db, user, book_id, settings.daily_target
)
if not words:
return CoachSessionResponse(words=[], total=0)
@@ -245,6 +251,8 @@ class MemoryCoachService:
retention_now=risk_map.get(w.id, _retention_now(w)),
mastery_score=w.mastery_score,
status=w.status,
retrievability=round(retrievability(w) * 100.0, 1),
next_review_at=w.next_review_at,
)
for w in words
]
@@ -272,6 +280,9 @@ class MemoryCoachService:
word_complete = False
next_stage = stage
input_hint = f"请输入「{zh}」的英文"
review_message: Optional[str] = None
review_retrievability: Optional[float] = None
next_review_at: Optional[str] = None
if stage == "intro":
next_stage = "derive"
@@ -283,8 +294,10 @@ class MemoryCoachService:
expect_input = True
next_stage = "derive"
else:
is_correct = answer.lower() == en.lower()
is_correct = en_answers_match(answer, en)
if is_correct:
attempts = hints_used + 1
before_r = retrievability(word)
tokens = self._reveal_all_v(tokens)
messages.append(f"正确:{en}")
quiz_service.submit_answer(
@@ -296,10 +309,35 @@ class MemoryCoachService:
en,
duration_seconds,
)
update = adaptive_review_service.update_word(
word,
is_correct=True,
attempts=attempts,
hints_used=hints_used,
duration_seconds=duration_seconds,
observed_retrievability=before_r,
)
db.commit()
if adaptive_review_service.is_plan_completed(word):
review_message = "本词已练会,已移出当前练习计划;可在学习设置中重置后继续复习"
else:
review_message = update.message
review_retrievability = update.retrievability
next_review_at = update.next_review_at
quiz_recorded = True
word_complete = True
next_stage = "done"
else:
before_r = retrievability(word)
quiz_service.record_attempt(
db,
user,
word.id,
"memory_coach",
answer,
en,
duration_seconds,
)
hints_used += 1
tokens = self.build_tokens(db, word, reveal_extra=hints_used)
tokens = self._reveal_q_tokens(tokens, 1)
@@ -308,15 +346,23 @@ class MemoryCoachService:
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,
update = adaptive_review_service.update_word(
word,
is_correct=False,
attempts=hints_used,
hints_used=hints_used,
duration_seconds=duration_seconds,
observed_retrievability=before_r,
)
word.consecutive_correct_count = 0
word.status = "weak"
word.last_reviewed_at = datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
db.commit()
review_message = update.message
review_retrievability = update.retrievability
next_review_at = update.next_review_at
quiz_recorded = True
word_complete = True
next_stage = "done"
@@ -346,6 +392,9 @@ class MemoryCoachService:
hints_used=hints_used,
target_en=en if word_complete else None,
target_zh=zh if word_complete else None,
review_message=review_message,
retrievability=review_retrievability,
next_review_at=next_review_at,
)
@@ -63,7 +63,7 @@ class MemoryTransformerService:
return self._model
def model_ready(self) -> bool:
return WEIGHTS_PATH.is_file() or DEFAULT_WEIGHTS_PATH.is_file()
return WEIGHTS_PATH.is_file()
def predict_for_word(
self,
@@ -86,9 +86,9 @@ class MemoryTransformerService:
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)
blend = min(1.0, n_events / 5.0) if self.model_ready() else 0.0
recall_now = round((blend * p_now + (1.0 - blend) * p_formula) * 100, 1)
if horizon_hours is None:
+409 -50
View File
@@ -6,9 +6,12 @@ from sqlalchemy import func
from sqlalchemy.orm import Session
from models import QuizRecord, User, UserSettings, Word, WordBook
from services.wiki_service import wiki_service
from schemas import QuizOption, QuizQuestion, QuizStatsResponse, WordOut
from services.book_service import ACCUMULATION_BOOK_ID, book_service
from services.dictionary_service import dictionary_service
from services.text_utils import en_answers_match
from services.adaptive_review_service import adaptive_review_service, response_quality
from services.word_service import calc_mastery_score, utc_now_iso
@@ -48,44 +51,86 @@ class QuizService:
.all()
)
def select_accumulation_words(self, db: Session, user: User, limit: int) -> list[Word]:
return self._select_adaptive_words(
self._track_words(db, user, ACCUMULATION_BOOK_ID), limit
def select_accumulation_words(
self, db: Session, user: User, limit: int, pool: str = "untrained"
) -> list[Word]:
words = self._track_words(db, user, ACCUMULATION_BOOK_ID)
settings = self.get_settings(db, user)
rule = book_service.book_settings(db, user, None, settings)
return self._select_words_by_pool(words, limit, pool, rule["error_clear_correct_count"])
def _select_words_by_pool(
self, words: list[Word], limit: int, pool: str, clear_count: int = 2
) -> list[Word]:
if pool == "errors":
return adaptive_review_service.select_error_words(words, limit, clear_count=clear_count)
if pool == "error_bank":
return adaptive_review_service.select_error_bank_words(
words, limit, clear_count=clear_count
)
if pool == "untrained":
return adaptive_review_service.select_untrained_words(words, limit, clear_count=clear_count)
return adaptive_review_service.select_practice_plan_words(words, limit, clear_count=clear_count)
def select_coach_words(
self, db: Session, user: User, book_id: int, limit: int
) -> list[Word]:
all_words = self._track_words(db, user, book_id)
settings = self.get_settings(db, user)
book = db.query(WordBook).filter(WordBook.id == book_id).first() if book_id > 0 else None
rule = book_service.book_settings(db, user, book, settings)
return adaptive_review_service.select_practice_plan_words(
all_words, limit, clear_count=rule["error_clear_correct_count"]
)
def select_book_words(self, db: Session, user: User, book: WordBook, limit: int) -> list[Word]:
def select_book_words(
self,
db: Session,
user: User,
book: WordBook,
limit: int,
pool: str = "untrained",
) -> list[Word]:
all_words = self._track_words(db, user, book.id)
pool = self._select_adaptive_words(all_words, limit)
if len(pool) < limit and book.learn_mode == "sequential":
need = limit - len(pool)
unlocked = book_service.unlock_more_new_words(db, user, book, need)
if unlocked:
all_words = self._track_words(db, user, book.id)
pool = self._select_adaptive_words(all_words, limit)
return pool[:limit]
settings = self.get_settings(db, user)
rule = book_service.book_settings(db, user, book, settings)
selected = self._select_words_by_pool(
all_words, limit, pool, rule["error_clear_correct_count"]
)
if pool != "untrained" or len(selected) >= limit or book.learn_mode != "sequential":
return selected[:limit]
need = limit - len(selected)
unlocked = book_service.unlock_more_new_words(db, user, book, need)
if unlocked:
all_words = self._track_words(db, user, book.id)
selected = self._select_words_by_pool(
all_words, limit, pool, rule["error_clear_correct_count"]
)
return selected[:limit]
def _select_adaptive_words(self, all_words: list[Word], limit: int) -> list[Word]:
today = today_str()
weak = [w for w in all_words if w.status == "weak"]
learning = [w for w in all_words if w.status == "learning"]
new = [w for w in all_words if w.status == "new"]
mastered_due = [
w
for w in all_words
if w.status == "mastered"
and w.review_due_date
and w.review_due_date <= today
]
def reset_practice_plan(
self, db: Session, user: User, book_id: int
) -> dict:
from services.adaptive_review_service import adaptive_review_service
pool: list[Word] = []
for group in (weak, learning, new, mastered_due):
random.shuffle(group)
for w in group:
if w not in pool:
pool.append(w)
if len(pool) >= limit:
return pool[:limit]
return pool[:limit]
words = self._track_words(db, user, book_id)
reset_count = 0
for word in words:
if not adaptive_review_service.is_plan_completed(word):
continue
word.correct_count = 0
word.consecutive_correct_count = 0
word.train_count = 0
word.mastery_score = 0
word.status = "new"
word.next_review_at = None
word.review_due_date = None
word.last_reviewed_at = None
word.difficulty = 5.0
word.stability_hours = 24.0
reset_count += 1
db.commit()
return {"reset_count": reset_count, "book_id": book_id}
def select_daily_words(
self, db: Session, user: User, limit: int, track: str = "accumulation"
@@ -101,7 +146,9 @@ class QuizService:
settings = self.get_settings(db, user)
return self.select_accumulation_words(db, user, settings.daily_target)
def build_question(self, db: Session, word: Word, all_words: list[Word]) -> QuizQuestion:
def build_question(
self, db: Session, word: Word, all_words: list[Word], rule: Optional[dict] = None
) -> QuizQuestion:
question_type = random.choice(["en_to_zh", "zh_to_en"])
if question_type == "en_to_zh":
@@ -146,17 +193,24 @@ class QuizService:
for i in range(min(4, len(options_text)))
]
clear_count = int((rule or {}).get("error_clear_correct_count", 2))
return QuizQuestion(
word_id=word.id,
question_type=question_type,
prompt=prompt,
options=options,
correct_answer=correct,
train_count=int(word.train_count or 0),
wrong_count=int(word.wrong_count or 0),
error_reinforce_streak=int(word.error_reinforce_streak or 0),
error_clear_correct_count=clear_count,
error_bank_member=int(word.error_bank_member or 0),
)
def build_spell_question(self, word: Word) -> QuizQuestion:
def build_spell_question(self, word: Word, rule: Optional[dict] = None) -> 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
clear_count = int((rule or {}).get("error_clear_correct_count", 2))
return QuizQuestion(
word_id=word.id,
question_type="spell",
@@ -164,6 +218,11 @@ class QuizService:
phonetic=word.phonetic,
options=[],
correct_answer=en,
train_count=int(word.train_count or 0),
wrong_count=int(word.wrong_count or 0),
error_reinforce_streak=int(word.error_reinforce_streak or 0),
error_clear_correct_count=clear_count,
error_bank_member=int(word.error_bank_member or 0),
)
def _resolve_track(self, db: Session, user: User, track: str) -> tuple[int, int]:
@@ -179,14 +238,106 @@ class QuizService:
settings = self.get_settings(db, user)
return ACCUMULATION_BOOK_ID, settings.daily_target
def _practice_rule(self, db: Session, user: User, book_id: int) -> dict:
settings = self.get_settings(db, user)
book = None
if book_id > 0:
book = db.query(WordBook).filter(WordBook.id == book_id).first()
return book_service.book_settings(db, user, book, settings)
def _resolve_answer_pool(
self,
word: Word,
pool: Optional[str],
clear_count: int,
now_dt: datetime,
) -> Optional[str]:
if pool in ("errors", "error_bank", "untrained"):
return pool
if adaptive_review_service.is_in_error_reinforcement(word, clear_count):
return "errors"
if adaptive_review_service.is_in_error_bank_review(word, clear_count, now_dt):
return "error_bank"
return pool
def _apply_error_bank_transition(
self,
word: Word,
*,
is_correct: bool,
pool: Optional[str],
rule: dict,
duration_seconds: int,
now_dt: datetime,
) -> None:
clear_count = int(rule["error_clear_correct_count"])
pool = self._resolve_answer_pool(word, pool, clear_count, now_dt)
if not is_correct:
if not int(word.error_bank_member or 0):
word.error_bank_member = 1
word.error_bank_entered_at = word.error_bank_entered_at or utc_now_iso()
word.error_reinforce_streak = 0
adaptive_review_service.update_word(
word,
is_correct=False,
attempts=1,
hints_used=0,
duration_seconds=duration_seconds,
now=now_dt,
)
return
if not int(word.error_bank_member or 0):
return
streak = int(word.error_reinforce_streak or 0)
if pool == "errors" and streak < clear_count:
word.error_reinforce_streak = streak + 1
word.error_bank_member = 1
if word.error_reinforce_streak >= clear_count:
adaptive_review_service.update_word(
word,
is_correct=True,
attempts=1,
hints_used=0,
duration_seconds=duration_seconds,
now=now_dt,
)
return
if pool == "error_bank":
if not adaptive_review_service.is_in_error_bank_review(
word, clear_count, now_dt
):
return
quality = response_quality(
is_correct=True,
attempts=1,
hints_used=0,
duration_seconds=duration_seconds,
)
if quality >= 0.85:
word.error_bank_member = 0
word.error_reinforce_streak = 0
adaptive_review_service.update_word(
word,
is_correct=True,
attempts=1,
hints_used=0,
duration_seconds=duration_seconds,
now=now_dt,
)
def get_spell_quiz(self, db: Session, user: User, track: str = "accumulation") -> dict:
book_id, limit = self._resolve_track(db, user, track)
rule = self._practice_rule(db, user, book_id)
if track == "book":
book = book_service.get_book(db, book_id)
words = self.select_book_words(db, user, book, limit)
words = self.select_book_words(db, user, book, limit, pool="mixed")
else:
words = self.select_accumulation_words(db, user, limit)
questions = [self.build_spell_question(w) for w in words]
words = self.select_accumulation_words(db, user, limit, pool="mixed")
questions = [self.build_spell_question(w, rule) for w in words]
return {
"questions": questions,
"total": len(questions),
@@ -194,27 +345,31 @@ class QuizService:
"book_id": book_id if track == "book" else None,
}
def get_daily_quiz(self, db: Session, user: User, track: str = "accumulation") -> dict:
def get_daily_quiz(
self, db: Session, user: User, track: str = "accumulation", pool: str = "untrained"
) -> dict:
book_id, limit = self._resolve_track(db, user, track)
if track == "book":
book = book_service.get_book(db, book_id)
words = self.select_book_words(db, user, book, limit)
words = self.select_book_words(db, user, book, limit, pool=pool)
else:
words = self.select_accumulation_words(db, user, limit)
words = self.select_accumulation_words(db, user, limit, pool=pool)
all_words = self._track_words(db, user, book_id)
rule = self._practice_rule(db, user, book_id)
if len(all_words) < 4:
questions = []
if words:
questions = [self.build_question(db, words[0], all_words)]
questions = [self.build_question(db, words[0], all_words, rule)]
else:
questions = [self.build_question(db, w, all_words) for w in words]
questions = [self.build_question(db, w, all_words, rule) for w in words]
return {
"questions": questions,
"total": len(questions),
"track": track,
"book_id": book_id if track == "book" else None,
"pool": pool,
}
def submit_answer(
@@ -226,6 +381,7 @@ class QuizService:
user_answer: str,
correct_answer: str,
duration_seconds: int = 0,
pool: Optional[str] = None,
) -> dict:
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
if not word:
@@ -238,14 +394,14 @@ class QuizService:
book = db.query(WordBook).filter(WordBook.id == word.book_id).first()
rule = book_service.book_settings(db, user, book, settings)
if question_type in ("spell", "memory_coach"):
is_correct = (
user_answer.strip().lower() == correct_answer.strip().lower()
)
is_correct = en_answers_match(user_answer, correct_answer)
else:
is_correct = user_answer.strip() == correct_answer.strip()
now = utc_now_iso()
today = today_str()
now_dt = datetime.now(timezone.utc)
duration_seconds = max(0, min(int(duration_seconds or 0), 3600))
in_error_flow = bool(int(word.error_bank_member or 0)) or not is_correct
if is_correct:
word.correct_count += 1
@@ -254,9 +410,10 @@ class QuizService:
word.status = "learning"
if word.consecutive_correct_count >= rule["master_required_count"]:
word.status = "mastered"
days = review_interval_days(word.consecutive_correct_count)
due = datetime.now(timezone.utc) + timedelta(days=days)
word.review_due_date = due.strftime("%Y-%m-%d")
if not in_error_flow:
days = review_interval_days(word.consecutive_correct_count)
due = now_dt + timedelta(days=days)
word.review_due_date = due.strftime("%Y-%m-%d")
else:
word.wrong_count += 1
word.consecutive_correct_count = 0
@@ -264,13 +421,23 @@ class QuizService:
word.status = "weak"
elif word.status == "new":
word.status = "learning"
word.review_due_date = today
if not int(word.error_bank_member or 0):
word.review_due_date = today
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
self._apply_error_bank_transition(
word,
is_correct=is_correct,
pool=pool,
rule=rule,
duration_seconds=duration_seconds,
now_dt=now_dt,
)
record = QuizRecord(
user_id=user.id,
word_id=word.id,
@@ -284,6 +451,8 @@ class QuizService:
db.add(record)
db.commit()
db.refresh(word)
db.refresh(record)
wiki_service.enqueue_quiz(user.id, word.id, record.id)
return {
"is_correct": is_correct,
@@ -291,6 +460,159 @@ class QuizService:
"word": WordOut.model_validate(word),
}
def record_attempt(
self,
db: Session,
user: User,
word_id: int,
question_type: str,
user_answer: str,
correct_answer: str,
duration_seconds: int = 0,
) -> QuizRecord:
"""记录一次未结束当前单词的错误尝试。"""
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
if not word:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="单词不存在")
settings = self.get_settings(db, user)
book = None
if word.book_id > 0:
book = db.query(WordBook).filter(WordBook.id == word.book_id).first()
rule = book_service.book_settings(db, user, book, settings)
record = QuizRecord(
user_id=user.id,
word_id=word.id,
question_type=question_type,
user_answer=user_answer,
correct_answer=correct_answer,
is_correct=0,
duration_seconds=max(0, min(int(duration_seconds or 0), 3600)),
created_at=utc_now_iso(),
)
word.wrong_count += 1
word.consecutive_correct_count = 0
if word.wrong_count >= rule["weak_wrong_threshold"]:
word.status = "weak"
elif word.status == "new":
word.status = "learning"
word.mastery_score = calc_mastery_score(word.correct_count, word.wrong_count)
word.train_count += 1
word.total_train_seconds += record.duration_seconds
self._apply_error_bank_transition(
word,
is_correct=False,
pool=None,
rule=rule,
duration_seconds=record.duration_seconds,
now_dt=datetime.now(timezone.utc),
)
db.add(record)
db.commit()
db.refresh(record)
db.refresh(word)
wiki_service.enqueue_quiz(user.id, word.id, record.id)
return record
@staticmethod
def _word_en_zh(word: Word) -> tuple[str, str]:
if word.source_lang == "zh":
return word.target_text, word.source_text
return word.source_text, word.target_text
def _ensure_error_bank_schedule(self, word: Word, clear_count: int, now_dt: datetime) -> None:
"""已移出错题巩固但尚未排期的词,按记忆曲线补排下次复习。"""
if int(word.error_reinforce_streak or 0) < clear_count:
return
if word.next_review_at or word.review_due_date:
return
adaptive_review_service.update_word(
word,
is_correct=True,
attempts=1,
hints_used=0,
duration_seconds=0,
now=now_dt,
)
def list_error_bank(
self, db: Session, user: User, track: str = "accumulation"
) -> dict:
settings = self.get_settings(db, user)
book_id = ACCUMULATION_BOOK_ID
if track == "book":
active = book_service.get_active_book(db, user)
if not active:
return {
"items": [],
"total": 0,
"due_count": 0,
"track": track,
"book_id": None,
}
book_id = active.id
clear_count = book_service.practice_settings_dict(db, user, active)[
"error_clear_correct_count"
]
else:
clear_count = settings.error_clear_correct_count
words = [
w
for w in self._track_words(db, user, book_id)
if int(w.error_bank_member or 0) > 0
]
now_dt = datetime.now(timezone.utc)
repaired = False
for word in words:
before = word.next_review_at, word.review_due_date
self._ensure_error_bank_schedule(word, clear_count, now_dt)
if (word.next_review_at, word.review_due_date) != before:
repaired = True
if repaired:
db.commit()
for word in words:
db.refresh(word)
items: list[dict] = []
due_count = 0
for word in words:
is_due = adaptive_review_service.is_in_error_bank_review(
word, clear_count, now_dt
)
if is_due:
due_count += 1
en, zh = self._word_en_zh(word)
status = "due" if is_due else "cooling"
items.append(
{
"word_id": word.id,
"en": en,
"zh": zh,
"wrong_count": int(word.wrong_count or 0),
"train_count": int(word.train_count or 0),
"error_reinforce_streak": int(word.error_reinforce_streak or 0),
"status": status,
"next_review_at": word.next_review_at,
"review_due_date": word.review_due_date,
}
)
items.sort(
key=lambda item: (
0 if item["status"] == "due" else 1,
item["next_review_at"] or item["review_due_date"] or "9999",
)
)
return {
"items": items,
"total": len(items),
"due_count": due_count,
"track": track,
"book_id": book_id if track == "book" else None,
}
def get_stats(self, db: Session, user: User, track: str = "accumulation") -> QuizStatsResponse:
settings = self.get_settings(db, user)
today = today_str()
@@ -311,6 +633,10 @@ class QuizService:
daily_target=15,
today_completed=0,
streak_days=0,
untrained_pending=0,
error_pending=0,
error_bank_due_pending=0,
error_bank_total=0,
track=track,
)
book_id = active.id
@@ -338,6 +664,21 @@ class QuizService:
)
streak = self._calc_streak(db, user, track_word_ids)
active_word_list = active_words.all()
if track == "book":
active_book = book_service.get_active_book(db, user)
clear_count = (
book_service.practice_settings_dict(db, user, active_book)[
"error_clear_correct_count"
]
if active_book
else settings.error_clear_correct_count
)
else:
clear_count = settings.error_clear_correct_count
untrained_pending, error_pending, error_bank_due, error_bank_total = (
adaptive_review_service.count_plan_pools(active_word_list, clear_count)
)
return QuizStatsResponse(
total_words=total,
@@ -351,10 +692,28 @@ class QuizService:
daily_target=daily_target,
today_completed=today_quiz_count,
streak_days=streak,
untrained_pending=untrained_pending,
error_pending=error_pending,
error_bank_due_pending=error_bank_due,
error_bank_total=error_bank_total,
track=track,
book_id=book_id if track == "book" else None,
)
def reset_practice_plan_for_track(
self, db: Session, user: User, track: str = "accumulation"
) -> dict:
if track == "book":
book = book_service.get_active_book(db, user)
if not book:
return {"reset_count": 0, "book_id": None, "track": track}
book_id = book.id
else:
book_id = ACCUMULATION_BOOK_ID
result = self.reset_practice_plan(db, user, book_id)
result["track"] = track
return result
def _calc_streak(self, db: Session, user: User, word_ids: Optional[set[int]] = None) -> int:
records = (
db.query(QuizRecord)
+10
View File
@@ -0,0 +1,10 @@
import re
def normalize_en_answer(text: str) -> str:
"""英文拼写/短语答案:去首尾空白、小写、连续空白压成单个空格。"""
return re.sub(r"\s+", " ", (text or "").strip().lower())
def en_answers_match(user_answer: str, correct_answer: str) -> bool:
return normalize_en_answer(user_answer) == normalize_en_answer(correct_answer)
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import base64
import hashlib
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from config import get_settings
_CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "tts_cache"
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
_TTS_USER_AGENT = "WordLoop-TTS/1.0"
def _open_tts_request(
request: urllib.request.Request,
*,
timeout: int,
):
# 统一绕过系统代理;公网网关会拒绝 Python-urllib 默认 UA403
return _NO_PROXY_OPENER.open(request, timeout=timeout)
class TtsServiceError(Exception):
pass
class TtsService:
def is_configured(self) -> bool:
settings = get_settings()
return settings.tkmind_tts_enabled and bool(settings.tkmind_tts_base_url.strip())
def synthesize(self, text: str) -> tuple[bytes, str]:
cleaned = text.strip()
if not cleaned:
raise TtsServiceError("朗读文本不能为空")
if len(cleaned) > 200:
raise TtsServiceError("朗读文本过长")
settings = get_settings()
if not settings.tkmind_tts_enabled:
raise TtsServiceError("单词朗读未启用")
base_url = settings.tkmind_tts_base_url.strip().rstrip("/")
if not base_url:
raise TtsServiceError("TTS 服务地址未配置")
cache_key = self._cache_key(cleaned, settings.tkmind_tts_spk_id)
cached = self._read_cache(cache_key)
if cached is not None:
return cached, "audio/wav"
audio_bytes, content_type = self._request_upstream(
base_url=base_url,
text=cleaned,
spk_id=settings.tkmind_tts_spk_id.strip(),
timeout=settings.tkmind_tts_timeout,
)
self._write_cache(cache_key, audio_bytes)
return audio_bytes, content_type
@staticmethod
def _cache_key(text: str, spk_id: str) -> str:
raw = f"{text.lower()}|{spk_id}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
@staticmethod
def _read_cache(cache_key: str) -> bytes | None:
path = _CACHE_DIR / f"{cache_key}.wav"
if not path.is_file():
return None
return path.read_bytes()
@staticmethod
def _write_cache(cache_key: str, audio_bytes: bytes) -> None:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
path = _CACHE_DIR / f"{cache_key}.wav"
path.write_bytes(audio_bytes)
def _request_upstream(
self,
*,
base_url: str,
text: str,
spk_id: str,
timeout: int,
) -> tuple[bytes, str]:
payload: dict[str, str] = {"tts_text": text}
if spk_id:
payload["spk_id"] = spk_id
request = urllib.request.Request(
f"{base_url}/inference_sft",
data=urllib.parse.urlencode(payload).encode("utf-8"),
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": _TTS_USER_AGENT,
},
method="POST",
)
try:
with _open_tts_request(request, timeout=timeout) as response:
content_type = response.headers.get_content_type() or "application/octet-stream"
body = response.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise TtsServiceError(f"TTS 服务异常(HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise TtsServiceError(f"TTS 服务不可达:{exc.reason}") from exc
if content_type.startswith("audio/"):
if not body:
raise TtsServiceError("TTS 返回空音频")
return body, content_type
return self._parse_json_response(body)
@staticmethod
def _parse_json_response(body: bytes) -> tuple[bytes, str]:
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
if body[:4] == b"RIFF":
return body, "audio/wav"
raise TtsServiceError("TTS 响应格式无效") from exc
code = payload.get("code", 0)
if code not in (0, "0", None):
message = str(payload.get("message") or "TTS 合成失败")
raise TtsServiceError(message)
data = payload.get("data")
audio_b64 = TtsService._extract_audio_base64(data)
if not audio_b64:
raise TtsServiceError("TTS 响应缺少音频数据")
try:
audio_bytes = base64.b64decode(audio_b64)
except (ValueError, TypeError) as exc:
raise TtsServiceError("TTS 音频解码失败") from exc
if not audio_bytes:
raise TtsServiceError("TTS 返回空音频")
return audio_bytes, "audio/wav"
@staticmethod
def _extract_audio_base64(data: object) -> str | None:
if isinstance(data, str) and data.strip():
return data.strip()
if isinstance(data, dict):
for key in ("audio", "wav", "speech", "data"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
tts_service = TtsService()
@@ -0,0 +1,88 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from config import get_settings
from models import User, WikiLlmUsage
class WikiLlmQuotaExceeded(Exception):
def __init__(self, *, daily_limit: int, used_today: int):
self.daily_limit = daily_limit
self.used_today = used_today
super().__init__(
f"今日 AI 整理额度已用完({used_today}/{daily_limit} 次),请充值后继续使用"
)
class WikiLlmQuotaService:
def daily_limit(self) -> int:
return max(1, int(get_settings().ai_wiki_daily_llm_limit))
def usage_date(self, now: datetime | None = None) -> str:
current = now or datetime.now(timezone.utc)
return current.strftime("%Y-%m-%d")
def get_usage_row(self, db: Session, user: User, usage_date: str | None = None) -> WikiLlmUsage:
day = usage_date or self.usage_date()
row = (
db.query(WikiLlmUsage)
.filter(WikiLlmUsage.user_id == user.id, WikiLlmUsage.usage_date == day)
.first()
)
if not row:
row = WikiLlmUsage(
user_id=user.id,
usage_date=day,
call_count=0,
)
db.add(row)
db.flush()
return row
def used_today(self, db: Session, user: User) -> int:
return int(self.get_usage_row(db, user).call_count)
def remaining_today(self, db: Session, user: User) -> int:
return max(0, self.daily_limit() - self.used_today(db, user))
def quota_dict(self, db: Session, user: User) -> dict:
limit = self.daily_limit()
used = self.used_today(db, user)
remaining = max(0, limit - used)
return {
"daily_limit": limit,
"used_today": used,
"remaining_today": remaining,
"quota_exceeded": remaining <= 0,
"recharge_message": (
"今日 AI 整理额度已用完,请充值后继续使用"
if remaining <= 0
else None
),
}
def consume(self, db: Session, user: User, *, count: int = 1) -> dict:
if count <= 0:
return self.quota_dict(db, user)
row = self.get_usage_row(db, user)
limit = self.daily_limit()
if row.call_count + count > limit:
raise WikiLlmQuotaExceeded(daily_limit=limit, used_today=row.call_count)
row.call_count += count
db.flush()
return self.quota_dict(db, user)
def require_available(self, db: Session, user: User) -> dict:
quota = self.quota_dict(db, user)
if quota["quota_exceeded"]:
raise WikiLlmQuotaExceeded(
daily_limit=quota["daily_limit"],
used_today=quota["used_today"],
)
return quota
wiki_llm_quota_service = WikiLlmQuotaService()
+691
View File
@@ -0,0 +1,691 @@
from __future__ import annotations
import json
import re
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from database import SessionLocal
from models import (
QuizRecord,
User,
UserSettings,
WikiLink,
WikiLog,
WikiPage,
WikiSource,
Word,
)
from services.llm_service import LlmServiceError, llm_service
from services.wiki_llm_quota_service import WikiLlmQuotaExceeded, wiki_llm_quota_service
def utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def markdown_escape(value: str | None) -> str:
return (value or "").replace("\r", " ").replace("\n", " ").strip()
def word_title(word: Word) -> str:
if word.source_lang == "en":
return word.source_text
if word.target_lang == "en":
return word.target_text
return word.source_text
def word_meaning(word: Word) -> str:
if word.source_lang == "zh":
return word.source_text
if word.target_lang == "zh":
return word.target_text
return word.target_text
def slug_part(value: str) -> str:
normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return normalized[:72] or "word"
class WikiService:
GRAPH_NODE_LIMIT = 120
def __init__(self) -> None:
# Serialize Wiki compilation so repeated answers for the same word cannot
# race on page/link upserts. API requests never wait for this queue.
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="wordloop-wiki")
def enqueue_word(self, user_id: int, word_id: int) -> None:
self._executor.submit(self._ingest_in_background, user_id, word_id, None)
def enqueue_quiz(self, user_id: int, word_id: int, record_id: int) -> None:
self._executor.submit(self._ingest_in_background, user_id, word_id, record_id)
def _ingest_in_background(
self,
user_id: int,
word_id: int,
record_id: int | None,
) -> None:
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
word = (
db.query(Word)
.filter(Word.id == word_id, Word.user_id == user_id)
.first()
)
if not user or not word:
return
if record_id is None:
self.ingest_word(db, user, word)
return
record = (
db.query(QuizRecord)
.filter(
QuizRecord.id == record_id,
QuizRecord.user_id == user_id,
QuizRecord.word_id == word_id,
)
.first()
)
if record:
self.ingest_quiz(db, user, word, record)
except Exception:
db.rollback()
finally:
db.close()
def get_settings(self, db: Session, user: User) -> UserSettings:
settings = db.query(UserSettings).filter(UserSettings.user_id == user.id).first()
if not settings:
settings = UserSettings(user_id=user.id)
db.add(settings)
db.flush()
return settings
def status(self, db: Session, user: User) -> dict:
settings = self.get_settings(db, user)
page_count = db.query(WikiPage).filter(WikiPage.user_id == user.id).count()
link_count = db.query(WikiLink).filter(WikiLink.user_id == user.id).count()
latest = (
db.query(WikiPage)
.filter(WikiPage.user_id == user.id, WikiPage.category == "word")
.order_by(WikiPage.updated_at.desc())
.first()
)
latest_log = (
db.query(WikiLog)
.filter(WikiLog.user_id == user.id)
.order_by(WikiLog.created_at.desc())
.first()
)
quota = wiki_llm_quota_service.quota_dict(db, user)
llm_ready = llm_service.is_configured()
return {
"enabled": bool(settings.ai_wiki_enabled),
"auto_organize": bool(settings.ai_wiki_auto_organize),
"page_count": page_count,
"link_count": link_count,
"last_organized_at": latest_log.created_at if latest_log else None,
"latest_page": self.page_dict(latest) if latest else None,
"llm_configured": llm_ready,
"llm_model": llm_service.model_name() if llm_ready else None,
"compiler_mode": "deepseek" if llm_ready else "deterministic",
"daily_llm_limit": quota["daily_limit"],
"llm_used_today": quota["used_today"],
"llm_remaining_today": quota["remaining_today"],
"quota_exceeded": quota["quota_exceeded"],
"recharge_message": quota["recharge_message"],
}
def update_settings(
self,
db: Session,
user: User,
*,
enabled: bool | None,
auto_organize: bool | None,
) -> dict:
settings = self.get_settings(db, user)
was_enabled = bool(settings.ai_wiki_enabled)
if enabled is not None:
settings.ai_wiki_enabled = 1 if enabled else 0
if not enabled:
settings.ai_wiki_auto_organize = 0
if auto_organize is not None:
settings.ai_wiki_auto_organize = (
1 if auto_organize and bool(settings.ai_wiki_enabled) else 0
)
db.commit()
if bool(settings.ai_wiki_enabled) and not was_enabled:
self.rebuild(db, user)
return self.status(db, user)
def graph(self, db: Session, user: User) -> dict:
pages = (
db.query(WikiPage)
.filter(WikiPage.user_id == user.id)
.order_by(WikiPage.updated_at.desc(), WikiPage.id.desc())
.limit(self.GRAPH_NODE_LIMIT)
.all()
)
if not pages:
return {"nodes": [], "links": []}
page_ids = {page.id for page in pages}
links = (
db.query(WikiLink)
.filter(
WikiLink.user_id == user.id,
WikiLink.source_page_id.in_(page_ids),
WikiLink.target_page_id.in_(page_ids),
)
.all()
)
degree: dict[int, int] = {page.id: 0 for page in pages}
for link in links:
degree[link.source_page_id] = degree.get(link.source_page_id, 0) + 1
degree[link.target_page_id] = degree.get(link.target_page_id, 0) + 1
return {
"nodes": [
{
"id": str(page.id),
"slug": page.slug,
"title": page.title,
"category": page.category,
"summary": page.summary,
"source_count": page.source_count,
"updated_at": page.updated_at,
"degree": degree.get(page.id, 0),
}
for page in pages
],
"links": [
{
"source": str(link.source_page_id),
"target": str(link.target_page_id),
"relation": link.relation,
}
for link in links
],
}
def ingest_word(self, db: Session, user: User, word: Word) -> None:
settings = self.get_settings(db, user)
if not bool(settings.ai_wiki_enabled):
return
self._capture_word_source(db, user, word)
if bool(settings.ai_wiki_auto_organize):
db.flush()
self._compile_word(db, user, word)
self._compile_index(db, user)
db.commit()
def ingest_quiz(
self,
db: Session,
user: User,
word: Word,
record: QuizRecord,
) -> None:
settings = self.get_settings(db, user)
if not bool(settings.ai_wiki_enabled):
return
self._capture_quiz_source(db, user, word, record)
if bool(settings.ai_wiki_auto_organize):
db.flush()
self._compile_word(db, user, word)
self._compile_index(db, user)
db.commit()
def rebuild(self, db: Session, user: User) -> dict:
settings = self.get_settings(db, user)
if not bool(settings.ai_wiki_enabled):
return self.status(db, user)
if llm_service.is_configured():
wiki_llm_quota_service.require_available(db, user)
words = (
db.query(Word)
.filter(Word.user_id == user.id)
.order_by(Word.created_at.asc())
.all()
)
for word in words:
self._capture_word_source(db, user, word)
records = (
db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id, QuizRecord.word_id == word.id)
.order_by(QuizRecord.created_at.asc())
.all()
)
for record in records:
self._capture_quiz_source(db, user, word, record)
db.flush()
self._compile_word(db, user, word, use_llm=False)
self._compile_index(db, user)
self._log(db, user, "rebuild", "index", f"重建 {len(words)} 个单词知识页")
db.commit()
return self.status(db, user)
def remove_word(self, db: Session, user: User, word: Word) -> None:
pages = (
db.query(WikiPage)
.filter(WikiPage.user_id == user.id, WikiPage.word_id == word.id)
.all()
)
page_ids = [page.id for page in pages]
if page_ids:
db.query(WikiLink).filter(
WikiLink.user_id == user.id,
(
WikiLink.source_page_id.in_(page_ids)
| WikiLink.target_page_id.in_(page_ids)
),
).delete(synchronize_session=False)
db.query(WikiPage).filter(
WikiPage.user_id == user.id, WikiPage.id.in_(page_ids)
).delete(synchronize_session=False)
db.query(WikiSource).filter(
WikiSource.user_id == user.id, WikiSource.word_id == word.id
).delete(synchronize_session=False)
def refresh_index(self, db: Session, user: User) -> None:
settings = self.get_settings(db, user)
if bool(settings.ai_wiki_enabled):
self._compile_index(db, user)
def page_dict(self, page: WikiPage) -> dict:
return {
"slug": page.slug,
"title": page.title,
"category": page.category,
"summary": page.summary,
"content": page.content,
"source_count": page.source_count,
"updated_at": page.updated_at,
}
def _capture_word_source(self, db: Session, user: User, word: Word) -> None:
key = f"word:{word.id}"
if (
db.query(WikiSource)
.filter(WikiSource.user_id == user.id, WikiSource.source_key == key)
.first()
):
return
payload = {
"word_id": word.id,
"source_text": word.source_text,
"target_text": word.target_text,
"phonetic": word.phonetic,
"example_en": word.example_en,
"example_cn": word.example_cn,
"book_id": word.book_id,
"created_at": word.created_at,
}
db.add(
WikiSource(
user_id=user.id,
word_id=word.id,
source_key=key,
source_type="word",
payload=json.dumps(payload, ensure_ascii=False),
created_at=word.created_at,
)
)
def _capture_quiz_source(
self,
db: Session,
user: User,
word: Word,
record: QuizRecord,
) -> None:
key = f"quiz:{record.id}"
if (
db.query(WikiSource)
.filter(WikiSource.user_id == user.id, WikiSource.source_key == key)
.first()
):
return
payload = {
"quiz_record_id": record.id,
"word_id": word.id,
"question_type": record.question_type,
"user_answer": record.user_answer,
"correct_answer": record.correct_answer,
"is_correct": bool(record.is_correct),
"duration_seconds": record.duration_seconds,
"created_at": record.created_at,
}
db.add(
WikiSource(
user_id=user.id,
word_id=word.id,
source_key=key,
source_type="quiz",
payload=json.dumps(payload, ensure_ascii=False),
created_at=record.created_at,
)
)
def _compile_word(
self,
db: Session,
user: User,
word: Word,
*,
require_llm_quota: bool = False,
use_llm: bool = True,
) -> WikiPage:
now = utc_now_iso()
title = markdown_escape(word_title(word))
meaning = markdown_escape(word_meaning(word))
slug = f"vocabulary/{word.id}-{slug_part(title)}"
sources = (
db.query(WikiSource)
.filter(WikiSource.user_id == user.id, WikiSource.word_id == word.id)
.order_by(WikiSource.created_at.desc())
.all()
)
records = (
db.query(QuizRecord)
.filter(QuizRecord.user_id == user.id, QuizRecord.word_id == word.id)
.order_by(QuizRecord.created_at.desc())
.limit(8)
.all()
)
status_page = self._compile_collection(
db,
user,
f"learning/{word.status}",
self._status_title(word.status),
f"当前处于“{self._status_title(word.status)}”状态的单词集合。",
)
book_slug = f"books/{word.book_id if word.book_id > 0 else 'accumulation'}"
book_page = self._compile_collection(
db,
user,
book_slug,
"词书练习" if word.book_id > 0 else "日常积累",
"按学习来源整理的词汇集合。",
)
attempts = [
(
f"- {record.created_at} · {record.question_type} · "
f"{'答对' if record.is_correct else '答错'} · "
f"回答:{markdown_escape(record.user_answer)}"
)
for record in records
]
content = "\n".join(
[
"---",
f"title: {title}",
f"word_id: {word.id}",
f"status: {word.status}",
f"mastery: {word.mastery_score}",
f"updated_at: {now}",
"tags: [wordloop, vocabulary]",
"---",
"",
f"# {title}",
"",
f"**释义:** {meaning}",
f"**音标:** {markdown_escape(word.phonetic) or '暂无'}",
f"**掌握度:** {word.mastery_score}% · 正确 {word.correct_count} 次 · "
f"错误 {word.wrong_count}",
"",
"## 例句",
"",
markdown_escape(word.example_en) or "暂无英文例句。",
"",
markdown_escape(word.example_cn) or "暂无中文例句。",
"",
"## 学习轨迹",
"",
*(attempts or ["- 暂无练习记录。"]),
"",
"## 关联",
"",
f"- [[{status_page.slug}|{status_page.title}]]",
f"- [[{book_page.slug}|{book_page.title}]]",
"",
"## 来源",
"",
f"- WordLoop 原始学习事件 {len(sources)}",
]
)
summary = f"{meaning};掌握度 {word.mastery_score}%,累计练习 {word.train_count} 次。"
if use_llm:
content, summary, llm_used = self._maybe_enhance_with_llm(
db,
user,
title=title,
draft_content=content,
draft_summary=summary,
require_quota=require_llm_quota,
)
else:
llm_used = False
page = self._upsert_page(
db,
user,
slug=slug,
title=title,
category="word",
summary=summary,
content=content,
source_count=len(sources),
word_id=word.id,
now=now,
)
db.flush()
db.query(WikiLink).filter(
WikiLink.user_id == user.id, WikiLink.source_page_id == page.id
).delete(synchronize_session=False)
for target, relation in ((status_page, "learning_status"), (book_page, "collection")):
db.add(
WikiLink(
user_id=user.id,
source_page_id=page.id,
target_page_id=target.id,
relation=relation,
created_at=now,
)
)
detail = f"更新词汇页 {title}"
if llm_used:
detail += "DeepSeek 整理)"
self._log(db, user, "ingest", page.slug, detail)
return page
def _maybe_enhance_with_llm(
self,
db: Session,
user: User,
*,
title: str,
draft_content: str,
draft_summary: str,
require_quota: bool,
) -> tuple[str, str, bool]:
if not llm_service.is_configured():
return draft_content, draft_summary, False
try:
if require_quota:
wiki_llm_quota_service.require_available(db, user)
elif wiki_llm_quota_service.remaining_today(db, user) <= 0:
return draft_content, draft_summary, False
enhanced = llm_service.enhance_wiki_page(title=title, draft_markdown=draft_content)
wiki_llm_quota_service.consume(db, user)
enhanced_summary = self._extract_summary(enhanced, draft_summary)
return enhanced, enhanced_summary, True
except WikiLlmQuotaExceeded:
if require_quota:
raise
return draft_content, draft_summary, False
except LlmServiceError:
return draft_content, draft_summary, False
def _extract_summary(self, content: str, fallback: str) -> str:
for line in content.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or stripped.startswith("---"):
continue
if stripped.startswith("**") and stripped.endswith("**"):
return stripped.strip("*").strip()
if len(stripped) <= 120:
return stripped
return fallback
def _compile_collection(
self,
db: Session,
user: User,
slug: str,
title: str,
summary: str,
) -> WikiPage:
now = utc_now_iso()
content = f"# {title}\n\n{summary}\n\n此页面由 WordLoop Wiki 自动维护。"
return self._upsert_page(
db,
user,
slug=slug,
title=title,
category="collection",
summary=summary,
content=content,
source_count=0,
word_id=None,
now=now,
)
def _compile_index(self, db: Session, user: User) -> WikiPage:
now = utc_now_iso()
self._prune_orphan_collections(db, user)
pages = (
db.query(WikiPage)
.filter(WikiPage.user_id == user.id, WikiPage.category == "word")
.order_by(WikiPage.updated_at.desc())
.all()
)
entries = [f"- [[{page.slug}|{page.title}]] — {page.summary}" for page in pages]
content = "\n".join(
[
"# WordLoop Wiki",
"",
"由学习记录持续编译的个人知识库。",
"",
"## 词汇页",
"",
*(entries or ["- 暂无知识页。"]),
]
)
return self._upsert_page(
db,
user,
slug="index",
title="WordLoop Wiki",
category="index",
summary=f"{len(pages)} 个词汇知识页。",
content=content,
source_count=sum(page.source_count for page in pages),
word_id=None,
now=now,
)
def _prune_orphan_collections(self, db: Session, user: User) -> None:
db.flush()
collections = (
db.query(WikiPage)
.filter(WikiPage.user_id == user.id, WikiPage.category == "collection")
.all()
)
for page in collections:
inbound = (
db.query(WikiLink)
.filter(
WikiLink.user_id == user.id,
WikiLink.target_page_id == page.id,
)
.count()
)
if inbound == 0:
db.delete(page)
db.flush()
def _upsert_page(
self,
db: Session,
user: User,
*,
slug: str,
title: str,
category: str,
summary: str,
content: str,
source_count: int,
word_id: int | None,
now: str,
) -> WikiPage:
page = (
db.query(WikiPage)
.filter(WikiPage.user_id == user.id, WikiPage.slug == slug)
.first()
)
if not page:
page = WikiPage(
user_id=user.id,
slug=slug,
created_at=now,
)
db.add(page)
page.word_id = word_id
page.title = title
page.category = category
page.summary = summary
page.content = content
page.source_count = source_count
page.updated_at = now
db.flush()
return page
def _log(
self,
db: Session,
user: User,
action: str,
page_slug: str | None,
detail: str,
) -> None:
db.add(
WikiLog(
user_id=user.id,
action=action,
page_slug=page_slug,
detail=detail,
created_at=utc_now_iso(),
)
)
def _status_title(self, status: str) -> str:
return {
"new": "新词",
"learning": "学习中",
"mastered": "已掌握",
"weak": "易错词",
}.get(status, status)
wiki_service = WikiService()
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
from pathlib import Path
from fastapi import HTTPException
from services.llm_service import LlmServiceError, llm_service
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
_LATIN_RE = re.compile(r"[A-Za-z]")
def _heuristic_parse_line(line: str) -> dict[str, str] | None:
text = line.strip()
if not text or len(text) < 2:
return None
if not _CJK_RE.search(text) or not _LATIN_RE.search(text):
return None
parts = re.split(r"[\t|/:\-—–]+", text)
parts = [p.strip() for p in parts if p.strip()]
if len(parts) < 2:
parts = re.split(r"\s{2,}", text)
parts = [p.strip() for p in parts if p.strip()]
if len(parts) < 2:
return None
left, right = parts[0], parts[1]
left_has_cjk = bool(_CJK_RE.search(left))
right_has_cjk = bool(_CJK_RE.search(right))
if left_has_cjk and not right_has_cjk:
return {"en": right, "zh": left}
if right_has_cjk and not left_has_cjk:
return {"en": left, "zh": right}
return None
def _heuristic_parse_text(text: str) -> list[dict[str, str]]:
pairs: list[dict[str, str]] = []
seen: set[tuple[str, str]] = set()
for line in text.splitlines():
parsed = _heuristic_parse_line(line)
if not parsed:
continue
key = (parsed["en"].lower(), parsed["zh"])
if key in seen:
continue
seen.add(key)
pairs.append(parsed)
return pairs
def _to_word_create_items(pairs: list[dict[str, str]]) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
for pair in pairs:
en = pair["en"].strip()
zh = pair["zh"].strip()
if not en or not zh:
continue
items.append(
{
"source_text": en,
"target_text": zh,
"source_lang": "en",
"target_lang": "zh",
}
)
return items
class WordScanService:
def _tesseract_bin(self) -> str | None:
return shutil.which("tesseract")
def ocr_image_bytes(self, image_bytes: bytes, *, suffix: str = ".png") -> str:
tesseract = self._tesseract_bin()
if not tesseract:
raise HTTPException(
status_code=503,
detail="服务器未安装 OCR(tesseract),暂无法识别照片",
)
if not image_bytes:
raise HTTPException(status_code=400, detail="图片为空")
safe_suffix = suffix if suffix.startswith(".") else ".png"
if safe_suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"}:
safe_suffix = ".png"
with tempfile.TemporaryDirectory() as tmp:
image_path = Path(tmp) / f"scan{safe_suffix}"
image_path.write_bytes(image_bytes)
try:
result = subprocess.run(
[tesseract, str(image_path), "stdout", "-l", "chi_sim+eng"],
capture_output=True,
timeout=60,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise HTTPException(status_code=504, detail="OCR 识别超时,请换一张更小的照片") from exc
if result.returncode != 0:
detail = result.stderr.decode("utf-8", errors="replace").strip()
raise HTTPException(status_code=422, detail=detail or "OCR 识别失败")
text = result.stdout.decode("utf-8", errors="replace").strip()
if not text:
raise HTTPException(status_code=422, detail="照片中未识别到文字")
return text
def scan_image(self, image_bytes: bytes, *, suffix: str = ".png") -> list[dict[str, str]]:
text = self.ocr_image_bytes(image_bytes, suffix=suffix)
return self.parse_scan_text(text)
def parse_scan_text(self, text: str) -> list[dict[str, str]]:
cleaned = text.strip()
if not cleaned:
raise HTTPException(status_code=400, detail="识别文本为空")
pairs: list[dict[str, str]] = []
if llm_service.is_configured():
try:
pairs = llm_service.parse_word_pairs_from_text(cleaned)
except LlmServiceError:
pairs = []
if not pairs:
pairs = _heuristic_parse_text(cleaned)
items = _to_word_create_items(pairs)
if not items:
raise HTTPException(
status_code=422,
detail="未能从照片中识别出单词,请换一张更清晰的照片或手动添加",
)
return items
word_scan_service = WordScanService()
+116 -8
View File
@@ -5,6 +5,7 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
from models import QuizRecord, Word, User
from services.wiki_service import wiki_service
ACCUMULATION_BOOK_ID = 0
@@ -52,14 +53,34 @@ class WordService:
wrong_count=0,
consecutive_correct_count=0,
mastery_score=0,
difficulty=5.0,
stability_hours=24.0,
next_review_at=utc_now_iso(),
review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
created_at=utc_now_iso(),
)
db.add(word)
db.commit()
db.refresh(word)
wiki_service.enqueue_word(user.id, word.id)
return word
def _list_words_query(
self,
db: Session,
user: User,
status: Optional[str] = None,
book_id: Optional[int] = None,
):
q = db.query(Word).filter(Word.user_id == user.id)
if book_id is None or book_id == ACCUMULATION_BOOK_ID:
q = q.filter(Word.book_id == ACCUMULATION_BOOK_ID)
else:
q = q.filter(Word.book_id == book_id)
if status:
q = q.filter(Word.status == status)
return q
def list_words(
self,
db: Session,
@@ -67,14 +88,33 @@ class WordService:
status: Optional[str] = None,
book_id: Optional[int] = None,
) -> list[Word]:
q = db.query(Word).filter(Word.user_id == user.id)
if book_id is None:
q = q.filter(Word.book_id == ACCUMULATION_BOOK_ID)
elif book_id > 0:
q = q.filter(Word.book_id == book_id)
if status:
q = q.filter(Word.status == status)
return q.order_by(Word.created_at.desc()).all()
return (
self._list_words_query(db, user, status, book_id)
.order_by(Word.created_at.desc())
.all()
)
def list_words_page(
self,
db: Session,
user: User,
*,
status: Optional[str] = None,
book_id: Optional[int] = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[Word], int]:
page = max(1, page)
page_size = max(1, min(page_size, 100))
q = self._list_words_query(db, user, status, book_id)
total = q.count()
items = (
q.order_by(Word.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return items, total
def get_word(self, db: Session, user: User, word_id: int) -> Word:
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
@@ -84,11 +124,14 @@ class WordService:
def delete_word(self, db: Session, user: User, word_id: int) -> None:
word = self.get_word(db, user, word_id)
wiki_service.remove_word(db, user, word)
db.query(QuizRecord).filter(QuizRecord.word_id == word.id).delete(
synchronize_session=False
)
db.flush()
db.delete(word)
db.flush()
wiki_service.refresh_index(db, user)
db.commit()
def update_word(self, db: Session, user: User, word_id: int, data: dict) -> Word:
@@ -99,5 +142,70 @@ class WordService:
db.refresh(word)
return word
def batch_create_words(self, db: Session, user: User, items: list[dict]) -> dict:
created: list[Word] = []
skipped = 0
book_id = ACCUMULATION_BOOK_ID
for data in items:
source_text = str(data.get("source_text", "")).strip()
target_text = str(data.get("target_text", "")).strip()
if not source_text or not target_text:
continue
item_book_id = data.get("book_id") or book_id
existing = (
db.query(Word)
.filter(
Word.user_id == user.id,
Word.book_id == item_book_id,
Word.source_text == source_text,
Word.target_text == target_text,
)
.first()
)
if existing:
skipped += 1
continue
word = Word(
user_id=user.id,
book_id=item_book_id,
book_entry_id=data.get("book_entry_id"),
source_text=source_text,
target_text=target_text,
source_lang=data.get("source_lang") or "en",
target_lang=data.get("target_lang") or "zh",
phonetic=data.get("phonetic"),
example_en=data.get("example_en"),
example_cn=data.get("example_cn"),
status="new",
correct_count=0,
wrong_count=0,
consecutive_correct_count=0,
mastery_score=0,
difficulty=5.0,
stability_hours=24.0,
next_review_at=utc_now_iso(),
review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
created_at=utc_now_iso(),
)
db.add(word)
created.append(word)
if not created and skipped == 0:
raise HTTPException(status_code=400, detail="没有可添加的单词")
db.commit()
for word in created:
db.refresh(word)
wiki_service.enqueue_word(user.id, word.id)
return {
"created": len(created),
"skipped": skipped,
"items": created,
}
word_service = WordService()