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:
@@ -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()
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 默认 UA(403)
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user