import re from datetime import datetime, timezone from typing import Optional from sqlalchemy.orm import Session from models import QuizRecord, User, Word from schemas import CoachSessionResponse, CoachTurnResponse, CoachWordBrief, MemoryToken from services.adaptive_review_service import adaptive_review_service, retrievability from services.memory_visual_service import ( memory_visual_service, parse_iso, retention_percent, stability_hours, word_en, word_zh, ) from services.quiz_service import quiz_service from services.text_utils import en_answers_match from services.word_service import word_service def _mask_zh(zh: str) -> str: zh = zh.strip() if len(zh) <= 1: return zh return zh[0] + "※" * (len(zh) - 1) def _blank_example(example: str, en: str) -> str: if not example: return "" pattern = re.compile(re.escape(en), re.IGNORECASE) return pattern.sub("______", example, count=1) def _retention_now(word: Word) -> float: now = datetime.now(timezone.utc) last_at = parse_iso(word.last_reviewed_at or word.created_at) hours = (now - last_at).total_seconds() / 3600 return round(retention_percent(hours, stability_hours(word)), 1) def _last_quiz_hint(db: Session, word_id: int) -> Optional[str]: r = ( db.query(QuizRecord) .filter(QuizRecord.word_id == word_id) .order_by(QuizRecord.created_at.desc()) .first() ) if not r: return None return "上次答对" if r.is_correct else "上次答错" class MemoryCoachService: def build_tokens(self, db: Session, word: Word, reveal_extra: int = 0) -> list[MemoryToken]: en = word_en(word).strip() zh = word_zh(word).strip() retention = _retention_now(word) hint = _last_quiz_hint(db, word.id) tokens: list[MemoryToken] = [ MemoryToken( role="K", key="retention", label="记忆保持", value=f"{retention}%", revealed=True, ), MemoryToken( role="K", key="mastery", label="掌握率", value=f"{word.mastery_score}%", revealed=True, ), MemoryToken( role="K", key="status", label="词库状态", value=word.status, revealed=True, ), ] if hint: tokens.append( MemoryToken( role="K", key="last_quiz", label="练习记录", value=hint, revealed=True, ) ) q_tokens: list[MemoryToken] = [ MemoryToken( role="Q", key="zh_full", label="中文释义", value=zh, revealed=True, ), MemoryToken( role="Q", key="length", label="字母数", value=str(len(en)), revealed=True, ), ] if zh: q_tokens.append( MemoryToken( role="Q", key="zh_hint", label="释义提示", value=_mask_zh(zh), revealed=reveal_extra > 0, ) ) if word.phonetic: q_tokens.append( MemoryToken( role="Q", key="phonetic", label="音标", value=word.phonetic, revealed=reveal_extra > 0, ) ) if len(en) >= 2: q_tokens.append( MemoryToken( role="Q", key="prefix", label="英文前缀", value=en[:2] + "…", revealed=reveal_extra > 1, ) ) if len(en) >= 4: q_tokens.append( MemoryToken( role="Q", key="suffix", label="英文尾缀", value="…" + en[-2:], revealed=reveal_extra > 2, ) ) if word.example_en: blanked = _blank_example(word.example_en, en) if blanked and blanked != word.example_en: q_tokens.append( MemoryToken( role="Q", key="example", label="例句挖空", value=blanked, revealed=reveal_extra > 3, ) ) tokens.extend(q_tokens) tokens.extend( [ MemoryToken( role="V", key="en", label="英文", value=en, revealed=False, ), MemoryToken( role="V", key="zh", label="中文", value=zh, revealed=False, ), ] ) if word.example_en: tokens.append( MemoryToken( role="V", key="example_en", label="例句", value=word.example_en, revealed=False, ) ) if word.example_cn: tokens.append( MemoryToken( role="V", key="example_cn", label="例句译文", value=word.example_cn, revealed=False, ) ) return tokens def _reveal_q_tokens(self, tokens: list[MemoryToken], count: int) -> list[MemoryToken]: hidden_q = [t for t in tokens if t.role == "Q" and not t.revealed] for t in hidden_q[:count]: t.revealed = True return tokens def _reveal_all_v(self, tokens: list[MemoryToken]) -> list[MemoryToken]: for t in tokens: if t.role == "V": t.revealed = True if t.role == "Q": t.revealed = True return tokens def start_session(self, db: Session, user: User, track: str = "accumulation") -> CoachSessionResponse: from services.book_service import book_service if track == "book": book = book_service.get_active_book(db, user) if not book: return CoachSessionResponse(words=[], total=0) book_service.ensure_user_book_words(db, user, book) practice = book_service.practice_settings_dict(db, user, book) 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) book_id = 0 words = quiz_service.select_coach_words( db, user, book_id, settings.daily_target ) if not words: return CoachSessionResponse(words=[], total=0) summaries = memory_visual_service.get_visualization(db, user, book_id=book_id)["words"] risk_map = {w["id"]: w.get("retention_now", 0) for w in summaries} items = [ CoachWordBrief( word_id=w.id, zh=word_zh(w), phonetic=w.phonetic, retention_now=risk_map.get(w.id, _retention_now(w)), mastery_score=w.mastery_score, status=w.status, retrievability=round(retrievability(w) * 100.0, 1), next_review_at=w.next_review_at, ) for w in words ] return CoachSessionResponse(words=items, total=len(items)) def handle_turn( self, db: Session, user: User, word_id: int, stage: str, user_message: str = "", hints_used: int = 0, duration_seconds: int = 0, ) -> CoachTurnResponse: word = word_service.get_word(db, user, word_id) en = word_en(word).strip() zh = word_zh(word).strip() phonetic = word.phonetic tokens = self.build_tokens(db, word, reveal_extra=hints_used) messages: list[str] = [] expect_input = False is_correct: Optional[bool] = None quiz_recorded = False word_complete = False next_stage = stage input_hint = f"请输入「{zh}」的英文" review_message: Optional[str] = None review_retrievability: Optional[float] = None next_review_at: Optional[str] = None if stage == "intro": next_stage = "derive" expect_input = True elif stage == "derive": answer = user_message.strip() if not answer: expect_input = True next_stage = "derive" else: is_correct = 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( db, user, word.id, "memory_coach", answer, 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) hidden_left = sum(1 for t in tokens if t.role == "Q" and not t.revealed) messages.append("不对,再试一次。") if hidden_left == 0: tokens = self._reveal_all_v(tokens) messages.append(f"答案:{en}") 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" else: expect_input = True next_stage = "derive" elif stage == "done": word_complete = True next_stage = "done" else: next_stage = "intro" expect_input = True return CoachTurnResponse( assistant_messages=messages, tokens=tokens, stage=next_stage, expect_input=expect_input, prompt_zh=zh, prompt_phonetic=phonetic, input_hint=input_hint, is_correct=is_correct, quiz_recorded=quiz_recorded, word_complete=word_complete, hints_used=hints_used, target_en=en if word_complete else None, target_zh=zh if word_complete else None, review_message=review_message, retrievability=review_retrievability, next_review_at=next_review_at, ) memory_coach_service = MemoryCoachService()