diff --git a/.gitignore b/.gitignore index f2811f8..d245fe0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ backend/**/*.pyc backend/wordloop.db backend/.env backend/data/cache/ +backend/data/tts_cache/ +frontend/.cache/ # Node frontend/node_modules/ diff --git a/backend/.env.example b/backend/.env.example index 03c52af..298ca79 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,3 +6,16 @@ MYSQL_PASSWORD=888888 MYSQL_DATABASE=wordloop WORDLOOP_SECRET_KEY=wordloop-dev-secret-change-in-production + +# Wiki(DeepSeek,OpenAI 兼容接口) +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_MODEL=deepseek-chat +AI_WIKI_DAILY_LLM_LIMIT=20 + +# 单词整词朗读(ThinkMind CosyVoice) +# 本地开发连公网网关;生产部署(同机 CosyVoice)用 http://127.0.0.1:19200 +TKMIND_TTS_BASE_URL=https://asr.tkmind.cn +TKMIND_TTS_ENABLED=true +TKMIND_TTS_SPK_ID=aitong +# TKMIND_TTS_TIMEOUT=20 diff --git a/backend/config.py b/backend/config.py index 82644d5..1477d5e 100644 --- a/backend/config.py +++ b/backend/config.py @@ -17,6 +17,16 @@ class Settings(BaseSettings): mysql_password: str = "888888" mysql_database: str = "wordloop" + deepseek_api_key: str = "" + deepseek_base_url: str = "https://api.deepseek.com" + deepseek_model: str = "deepseek-chat" + ai_wiki_daily_llm_limit: int = 20 + + tkmind_tts_base_url: str = "https://asr.tkmind.cn" + tkmind_tts_enabled: bool = True + tkmind_tts_spk_id: str = "aitong" + tkmind_tts_timeout: int = 20 + @property def database_url(self) -> str: user = quote_plus(self.mysql_user) diff --git a/backend/db_migrate.py b/backend/db_migrate.py index 78b0986..3f01dad 100644 --- a/backend/db_migrate.py +++ b/backend/db_migrate.py @@ -21,6 +21,49 @@ def run_migrations() -> None: conn.execute( text("ALTER TABLE words ADD COLUMN train_count INTEGER NOT NULL DEFAULT 0") ) + if "difficulty" not in cols: + conn.execute( + text("ALTER TABLE words ADD COLUMN difficulty DOUBLE NOT NULL DEFAULT 5.0") + ) + if "stability_hours" not in cols: + conn.execute( + text( + "ALTER TABLE words ADD COLUMN stability_hours " + "DOUBLE NOT NULL DEFAULT 24.0" + ) + ) + if "next_review_at" not in cols: + conn.execute(text("ALTER TABLE words ADD COLUMN next_review_at VARCHAR(32)")) + if "error_bank_member" not in cols: + conn.execute( + text( + "ALTER TABLE words ADD COLUMN error_bank_member " + "INTEGER NOT NULL DEFAULT 0" + ) + ) + if "error_reinforce_streak" not in cols: + conn.execute( + text( + "ALTER TABLE words ADD COLUMN error_reinforce_streak " + "INTEGER NOT NULL DEFAULT 0" + ) + ) + if "error_bank_entered_at" not in cols: + conn.execute( + text("ALTER TABLE words ADD COLUMN error_bank_entered_at VARCHAR(32)") + ) + conn.execute( + text( + "UPDATE words SET error_bank_member = 1 " + "WHERE wrong_count > 0 AND error_bank_member = 0" + ) + ) + conn.execute( + text( + "UPDATE words SET error_bank_member = 1 " + "WHERE error_reinforce_streak >= 2 AND error_bank_member = 0" + ) + ) if insp.has_table("quiz_records"): cols = {c["name"] for c in insp.get_columns("quiz_records")} @@ -36,6 +79,161 @@ def run_migrations() -> None: cols = {c["name"] for c in insp.get_columns("user_settings")} if "active_book_id" not in cols: conn.execute(text("ALTER TABLE user_settings ADD COLUMN active_book_id INTEGER")) + if "ai_wiki_enabled" not in cols: + conn.execute( + text( + "ALTER TABLE user_settings ADD COLUMN ai_wiki_enabled " + "INTEGER NOT NULL DEFAULT 0" + ) + ) + if "ai_wiki_auto_organize" not in cols: + conn.execute( + text( + "ALTER TABLE user_settings ADD COLUMN ai_wiki_auto_organize " + "INTEGER NOT NULL DEFAULT 0" + ) + ) + if "error_clear_correct_count" not in cols: + conn.execute( + text( + "ALTER TABLE user_settings ADD COLUMN error_clear_correct_count " + "INTEGER NOT NULL DEFAULT 2" + ) + ) + + if insp.has_table("user_book_settings"): + ubs_cols = {c["name"] for c in insp.get_columns("user_book_settings")} + if "error_clear_correct_count" not in ubs_cols: + conn.execute( + text( + "ALTER TABLE user_book_settings ADD COLUMN error_clear_correct_count " + "INTEGER NOT NULL DEFAULT 2" + ) + ) + + if not insp.has_table("wiki_sources"): + conn.execute( + text( + """ + CREATE TABLE wiki_sources ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + word_id INTEGER NULL, + source_key VARCHAR(96) NOT NULL, + source_type VARCHAR(32) NOT NULL, + payload TEXT NOT NULL, + created_at VARCHAR(32) NOT NULL, + CONSTRAINT uq_wiki_source_user_key UNIQUE (user_id, source_key), + FOREIGN KEY(user_id) REFERENCES users (id), + FOREIGN KEY(word_id) REFERENCES words (id) + ) + """ + ) + ) + conn.execute(text("CREATE INDEX ix_wiki_sources_user_id ON wiki_sources (user_id)")) + conn.execute(text("CREATE INDEX ix_wiki_sources_word_id ON wiki_sources (word_id)")) + + if not insp.has_table("wiki_pages"): + conn.execute( + text( + """ + CREATE TABLE wiki_pages ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + word_id INTEGER NULL, + slug VARCHAR(160) NOT NULL, + title VARCHAR(500) NOT NULL, + category VARCHAR(32) NOT NULL, + summary TEXT NOT NULL, + content TEXT NOT NULL, + source_count INTEGER NOT NULL DEFAULT 0, + created_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL, + CONSTRAINT uq_wiki_page_user_slug UNIQUE (user_id, slug), + FOREIGN KEY(user_id) REFERENCES users (id), + FOREIGN KEY(word_id) REFERENCES words (id) + ) + """ + ) + ) + conn.execute(text("CREATE INDEX ix_wiki_pages_user_id ON wiki_pages (user_id)")) + conn.execute(text("CREATE INDEX ix_wiki_pages_word_id ON wiki_pages (word_id)")) + + if not insp.has_table("wiki_links"): + conn.execute( + text( + """ + CREATE TABLE wiki_links ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + source_page_id INTEGER NOT NULL, + target_page_id INTEGER NOT NULL, + relation VARCHAR(32) NOT NULL, + created_at VARCHAR(32) NOT NULL, + CONSTRAINT uq_wiki_link_edge UNIQUE ( + user_id, source_page_id, target_page_id, relation + ), + FOREIGN KEY(user_id) REFERENCES users (id), + FOREIGN KEY(source_page_id) REFERENCES wiki_pages (id), + FOREIGN KEY(target_page_id) REFERENCES wiki_pages (id) + ) + """ + ) + ) + conn.execute(text("CREATE INDEX ix_wiki_links_user_id ON wiki_links (user_id)")) + conn.execute( + text("CREATE INDEX ix_wiki_links_source_page_id ON wiki_links (source_page_id)") + ) + conn.execute( + text("CREATE INDEX ix_wiki_links_target_page_id ON wiki_links (target_page_id)") + ) + + if not insp.has_table("wiki_logs"): + conn.execute( + text( + """ + CREATE TABLE wiki_logs ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + action VARCHAR(32) NOT NULL, + page_slug VARCHAR(160) NULL, + detail TEXT NOT NULL, + created_at VARCHAR(32) NOT NULL, + FOREIGN KEY(user_id) REFERENCES users (id) + ) + """ + ) + ) + conn.execute(text("CREATE INDEX ix_wiki_logs_user_id ON wiki_logs (user_id)")) + + if not insp.has_table("wiki_llm_usage"): + conn.execute( + text( + """ + CREATE TABLE wiki_llm_usage ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + usage_date VARCHAR(10) NOT NULL, + call_count INTEGER NOT NULL DEFAULT 0, + CONSTRAINT uq_wiki_llm_usage_day UNIQUE (user_id, usage_date), + FOREIGN KEY(user_id) REFERENCES users (id) + ) + """ + ) + ) + conn.execute(text("CREATE INDEX ix_wiki_llm_usage_user_id ON wiki_llm_usage (user_id)")) + + if insp.has_table("wiki_pages"): + conn.execute( + text( + """ + UPDATE wiki_pages SET + title = REPLACE(title, 'WordLoop LLM Wiki', 'WordLoop Wiki'), + content = REPLACE(content, 'WordLoop LLM Wiki', 'WordLoop Wiki') + WHERE title LIKE '%LLM Wiki%' OR content LIKE '%LLM Wiki%' + """ + ) + ) if not insp.has_table("user_book_settings"): conn.execute( diff --git a/backend/main.py b/backend/main.py index dfffa51..da48f48 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,8 @@ from routers import ( quiz_router, settings_router, translate_router, + tts_router, + wiki_router, word_router, ) @@ -33,6 +35,8 @@ app.include_router(word_router.router) app.include_router(quiz_router.router) app.include_router(settings_router.router) app.include_router(coach_router.router) +app.include_router(wiki_router.router) +app.include_router(tts_router.router) @app.get("/") diff --git a/backend/models.py b/backend/models.py index a3c84d6..aee0de2 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy import Column, Float, ForeignKey, Integer, String, Text, UniqueConstraint from database import Base @@ -73,7 +73,13 @@ class Word(Base): consecutive_correct_count = Column(Integer, nullable=False, default=0) mastery_score = Column(Integer, nullable=False, default=0) train_count = Column(Integer, nullable=False, default=0) + error_bank_member = Column(Integer, nullable=False, default=0) + error_reinforce_streak = Column(Integer, nullable=False, default=0) + error_bank_entered_at = Column(String(32), nullable=True) total_train_seconds = Column(Integer, nullable=False, default=0) + difficulty = Column(Float, nullable=False, default=5.0) + stability_hours = Column(Float, nullable=False, default=24.0) + next_review_at = Column(String(32), nullable=True) review_due_date = Column(String(32), nullable=True) last_reviewed_at = Column(String(32), nullable=True) created_at = Column(String(32), nullable=False) @@ -101,7 +107,10 @@ class UserSettings(Base): daily_target = Column(Integer, nullable=False, default=20) master_required_count = Column(Integer, nullable=False, default=3) weak_wrong_threshold = Column(Integer, nullable=False, default=3) + error_clear_correct_count = Column(Integer, nullable=False, default=2) active_book_id = Column(Integer, ForeignKey("word_books.id"), nullable=True) + ai_wiki_enabled = Column(Integer, nullable=False, default=0) + ai_wiki_auto_organize = Column(Integer, nullable=False, default=0) class UserBookSettings(Base): @@ -116,6 +125,7 @@ class UserBookSettings(Base): daily_target = Column(Integer, nullable=False, default=12) master_required_count = Column(Integer, nullable=False, default=3) weak_wrong_threshold = Column(Integer, nullable=False, default=2) + error_clear_correct_count = Column(Integer, nullable=False, default=2) class DictionaryEntry(Base): @@ -130,3 +140,84 @@ class DictionaryEntry(Base): example_en = Column(Text, nullable=True) example_cn = Column(Text, nullable=True) source = Column(String(64), nullable=True) + + +class WikiSource(Base): + """Immutable learning event ingested into a user's Wiki.""" + + __tablename__ = "wiki_sources" + __table_args__ = ( + UniqueConstraint("user_id", "source_key", name="uq_wiki_source_user_key"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + word_id = Column(Integer, ForeignKey("words.id"), nullable=True, index=True) + source_key = Column(String(96), nullable=False) + source_type = Column(String(32), nullable=False) + payload = Column(Text, nullable=False) + created_at = Column(String(32), nullable=False) + + +class WikiPage(Base): + """Compiled Markdown page owned by one user.""" + + __tablename__ = "wiki_pages" + __table_args__ = (UniqueConstraint("user_id", "slug", name="uq_wiki_page_user_slug"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + word_id = Column(Integer, ForeignKey("words.id"), nullable=True, index=True) + slug = Column(String(160), nullable=False) + title = Column(String(500), nullable=False) + category = Column(String(32), nullable=False) + summary = Column(Text, nullable=False) + content = Column(Text, nullable=False) + source_count = Column(Integer, nullable=False, default=0) + created_at = Column(String(32), nullable=False) + updated_at = Column(String(32), nullable=False) + + +class WikiLink(Base): + __tablename__ = "wiki_links" + __table_args__ = ( + UniqueConstraint( + "user_id", + "source_page_id", + "target_page_id", + "relation", + name="uq_wiki_link_edge", + ), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + source_page_id = Column(Integer, ForeignKey("wiki_pages.id"), nullable=False, index=True) + target_page_id = Column(Integer, ForeignKey("wiki_pages.id"), nullable=False, index=True) + relation = Column(String(32), nullable=False) + created_at = Column(String(32), nullable=False) + + +class WikiLog(Base): + __tablename__ = "wiki_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + action = Column(String(32), nullable=False) + page_slug = Column(String(160), nullable=True) + detail = Column(Text, nullable=False) + created_at = Column(String(32), nullable=False) + + +class WikiLlmUsage(Base): + """Per-account daily DeepSeek call counter for Wiki compilation.""" + + __tablename__ = "wiki_llm_usage" + __table_args__ = ( + UniqueConstraint("user_id", "usage_date", name="uq_wiki_llm_usage_day"), + ) + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + usage_date = Column(String(10), nullable=False) + call_count = Column(Integer, nullable=False, default=0) diff --git a/backend/routers/quiz_router.py b/backend/routers/quiz_router.py index 3c86adb..59f2d20 100644 --- a/backend/routers/quiz_router.py +++ b/backend/routers/quiz_router.py @@ -8,6 +8,8 @@ from database import get_db from models import User from schemas import ( DailyQuizResponse, + ErrorBankListResponse, + PracticePlanResetResponse, QuizAnswerRequest, QuizAnswerResponse, QuizStatsResponse, @@ -20,10 +22,11 @@ router = APIRouter(prefix="/api/quiz", tags=["quiz"]) @router.get("/daily", response_model=DailyQuizResponse) def daily_quiz( track: Literal["accumulation", "book"] = Query("accumulation"), + pool: Literal["untrained", "errors", "error_bank"] = Query("untrained"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - result = quiz_service.get_daily_quiz(db, current_user, track=track) + result = quiz_service.get_daily_quiz(db, current_user, track=track, pool=pool) return DailyQuizResponse(**result) @@ -51,10 +54,20 @@ def submit_answer( data.user_answer, data.correct_answer, data.duration_seconds or 0, + pool=data.pool, ) return QuizAnswerResponse(**result) +@router.get("/error-bank", response_model=ErrorBankListResponse) +def error_bank_list( + track: Literal["accumulation", "book"] = Query("accumulation"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return ErrorBankListResponse(**quiz_service.list_error_bank(db, current_user, track=track)) + + @router.get("/stats", response_model=QuizStatsResponse) def quiz_stats( track: Literal["accumulation", "book"] = Query("accumulation"), @@ -62,3 +75,14 @@ def quiz_stats( current_user: User = Depends(get_current_user), ): return quiz_service.get_stats(db, current_user, track=track) + + +@router.post("/reset-plan", response_model=PracticePlanResetResponse) +def reset_practice_plan( + track: Literal["accumulation", "book"] = Query("accumulation"), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return PracticePlanResetResponse( + **quiz_service.reset_practice_plan_for_track(db, current_user, track=track) + ) diff --git a/backend/routers/settings_router.py b/backend/routers/settings_router.py index 33a8d86..1120459 100644 --- a/backend/routers/settings_router.py +++ b/backend/routers/settings_router.py @@ -20,6 +20,7 @@ def get_settings( daily_target=s.daily_target, master_required_count=s.master_required_count, weak_wrong_threshold=s.weak_wrong_threshold, + error_clear_correct_count=s.error_clear_correct_count, ) @@ -36,10 +37,13 @@ def update_settings( s.master_required_count = data.master_required_count if data.weak_wrong_threshold is not None: s.weak_wrong_threshold = data.weak_wrong_threshold + if data.error_clear_correct_count is not None: + s.error_clear_correct_count = data.error_clear_correct_count db.commit() db.refresh(s) return SettingsOut( daily_target=s.daily_target, master_required_count=s.master_required_count, weak_wrong_threshold=s.weak_wrong_threshold, + error_clear_correct_count=s.error_clear_correct_count, ) diff --git a/backend/routers/tts_router.py b/backend/routers/tts_router.py new file mode 100644 index 0000000..597d764 --- /dev/null +++ b/backend/routers/tts_router.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response + +from auth import get_current_user +from models import User +from services.tts_service import TtsServiceError, tts_service + +router = APIRouter(prefix="/api", tags=["tts"]) + + +@router.get("/tts/speak") +def speak_word( + text: str = Query(..., min_length=1, max_length=200), + current_user: User = Depends(get_current_user), +): + try: + audio_bytes, content_type = tts_service.synthesize(text) + except TtsServiceError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + return Response( + content=audio_bytes, + media_type=content_type, + headers={"Cache-Control": "private, max-age=86400"}, + ) diff --git a/backend/routers/wiki_router.py b/backend/routers/wiki_router.py new file mode 100644 index 0000000..9c14e24 --- /dev/null +++ b/backend/routers/wiki_router.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from auth import get_current_user +from database import get_db +from models import User +from schemas import WikiGraphOut, WikiSettingsUpdate, WikiStatusOut +from services.wiki_llm_quota_service import WikiLlmQuotaExceeded +from services.wiki_service import wiki_service + +router = APIRouter(prefix="/api/wiki", tags=["wiki"]) + + +@router.get("/status", response_model=WikiStatusOut) +def get_wiki_status( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return wiki_service.status(db, current_user) + + +@router.get("/graph", response_model=WikiGraphOut) +def get_wiki_graph( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return wiki_service.graph(db, current_user) + + +@router.patch("/settings", response_model=WikiStatusOut) +def update_wiki_settings( + data: WikiSettingsUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + return wiki_service.update_settings( + db, + current_user, + enabled=data.enabled, + auto_organize=data.auto_organize, + ) + + +@router.post("/rebuild", response_model=WikiStatusOut) +def rebuild_wiki( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + try: + return wiki_service.rebuild(db, current_user) + except WikiLlmQuotaExceeded as exc: + raise HTTPException(status_code=429, detail=str(exc)) from exc diff --git a/backend/routers/word_router.py b/backend/routers/word_router.py index ab98b1a..92b311f 100644 --- a/backend/routers/word_router.py +++ b/backend/routers/word_router.py @@ -1,6 +1,8 @@ -from typing import Optional +from typing import Optional, Union -from fastapi import APIRouter, Depends +from pathlib import Path + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from sqlalchemy.orm import Session from auth import get_current_user @@ -9,13 +11,20 @@ from models import User from schemas import ( MemoryTransformerPredictResponse, MemoryVisualizationResponse, + WordBatchCreateRequest, + WordBatchCreateResponse, WordCreate, + WordListPageOut, WordMemoryDetailResponse, WordOut, + WordScanItem, + WordScanResponse, + WordScanTextRequest, WordUpdate, ) from services.memory_transformer import memory_transformer_service from services.memory_visual_service import memory_visual_service +from services.word_scan_service import word_scan_service from services.word_service import word_service router = APIRouter(prefix="/api/words", tags=["words"]) @@ -31,13 +40,70 @@ def create_word( return word -@router.get("", response_model=list[WordOut]) -def list_words( - status: Optional[str] = None, - book_id: Optional[int] = None, +@router.post("/scan-text", response_model=WordScanResponse) +def scan_word_text( + data: WordScanTextRequest, + current_user: User = Depends(get_current_user), +): + items = word_scan_service.parse_scan_text(data.text) + return WordScanResponse(items=[WordScanItem(**item) for item in items]) + + +@router.post("/scan-image", response_model=WordScanResponse) +async def scan_word_image( + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), +): + data = await file.read() + if len(data) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail="图片不能超过 10MB") + suffix = Path(file.filename or "scan.png").suffix or ".png" + items = word_scan_service.scan_image(data, suffix=suffix) + return WordScanResponse(items=[WordScanItem(**item) for item in items]) + + +@router.post("/batch", response_model=WordBatchCreateResponse) +def batch_create_words( + data: WordBatchCreateRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): + result = word_service.batch_create_words( + db, + current_user, + [item.model_dump() for item in data.items], + ) + return WordBatchCreateResponse( + created=result["created"], + skipped=result["skipped"], + items=result["items"], + ) + + +@router.get("", response_model=Union[list[WordOut], WordListPageOut]) +def list_words( + status: Optional[str] = None, + book_id: Optional[int] = None, + page: Optional[int] = None, + page_size: int = 20, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + if page is not None: + items, total = word_service.list_words_page( + db, + current_user, + status=status, + book_id=book_id, + page=page, + page_size=page_size, + ) + return WordListPageOut( + items=items, + total=total, + page=max(1, page), + page_size=max(1, min(page_size, 100)), + ) return word_service.list_words(db, current_user, status, book_id=book_id) diff --git a/backend/schemas.py b/backend/schemas.py index 080af3b..b4ce9f8 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -61,6 +61,25 @@ class WordCreate(BaseModel): example_cn: Optional[str] = None +class WordScanTextRequest(BaseModel): + text: str = Field(min_length=1) + + +class WordScanItem(BaseModel): + source_text: str + target_text: str + source_lang: str = "en" + target_lang: str = "zh" + + +class WordScanResponse(BaseModel): + items: list[WordScanItem] + + +class WordBatchCreateRequest(BaseModel): + items: list[WordCreate] = Field(min_length=1, max_length=200) + + class WordUpdate(BaseModel): status: Optional[str] = None @@ -83,7 +102,13 @@ class WordOut(BaseModel): consecutive_correct_count: int mastery_score: int train_count: int = 0 + error_bank_member: int = 0 + error_reinforce_streak: int = 0 + error_bank_entered_at: Optional[str] = None total_train_seconds: int = 0 + difficulty: float = 5.0 + stability_hours: float = 24.0 + next_review_at: Optional[str] = None review_due_date: Optional[str] = None last_reviewed_at: Optional[str] = None created_at: str @@ -98,6 +123,19 @@ class WordOut(BaseModel): from_attributes = True +class WordListPageOut(BaseModel): + items: list[WordOut] + total: int + page: int + page_size: int + + +class WordBatchCreateResponse(BaseModel): + created: int + skipped: int + items: list[WordOut] + + class MemoryCurvePoint(BaseModel): day_index: int date: str @@ -193,6 +231,11 @@ class QuizQuestion(BaseModel): options: list[QuizOption] = [] correct_answer: str phonetic: Optional[str] = None + train_count: int = 0 + wrong_count: int = 0 + error_reinforce_streak: int = 0 + error_clear_correct_count: int = 2 + error_bank_member: int = 0 class DailyQuizResponse(BaseModel): @@ -200,10 +243,12 @@ class DailyQuizResponse(BaseModel): total: int track: str = "accumulation" book_id: Optional[int] = None + pool: str = "untrained" class QuizAnswerRequest(BaseModel): word_id: int + pool: Optional[str] = None question_type: str user_answer: str correct_answer: str @@ -228,6 +273,36 @@ class QuizStatsResponse(BaseModel): daily_target: int today_completed: int streak_days: int + untrained_pending: int = 0 + error_pending: int = 0 + error_bank_due_pending: int = 0 + error_bank_total: int = 0 + track: str = "accumulation" + book_id: Optional[int] = None + + +class PracticePlanResetResponse(BaseModel): + reset_count: int + track: str = "accumulation" + book_id: Optional[int] = None + + +class ErrorBankWordOut(BaseModel): + word_id: int + en: str + zh: str + wrong_count: int + train_count: int + error_reinforce_streak: int + status: str + next_review_at: Optional[str] = None + review_due_date: Optional[str] = None + + +class ErrorBankListResponse(BaseModel): + items: list[ErrorBankWordOut] + total: int + due_count: int track: str = "accumulation" book_id: Optional[int] = None @@ -275,6 +350,7 @@ class BookPracticeSettingsOut(BaseModel): daily_target: int master_required_count: int weak_wrong_threshold: int + error_clear_correct_count: int class ActiveBookOut(BaseModel): @@ -292,6 +368,7 @@ class BookPracticeSettingsUpdate(BaseModel): daily_target: Optional[int] = Field(None, ge=5, le=50) master_required_count: Optional[int] = Field(None, ge=1, le=10) weak_wrong_threshold: Optional[int] = Field(None, ge=1, le=10) + error_clear_correct_count: Optional[int] = Field(None, ge=1, le=5) # Settings @@ -299,6 +376,7 @@ class SettingsOut(BaseModel): daily_target: int master_required_count: int weak_wrong_threshold: int + error_clear_correct_count: int class Config: from_attributes = True @@ -308,6 +386,62 @@ class SettingsUpdate(BaseModel): daily_target: Optional[int] = Field(None, ge=1, le=100) master_required_count: Optional[int] = Field(None, ge=1, le=20) weak_wrong_threshold: Optional[int] = Field(None, ge=1, le=20) + error_clear_correct_count: Optional[int] = Field(None, ge=1, le=5) + + +# Wiki +class WikiPagePreview(BaseModel): + slug: str + title: str + category: str + summary: str + content: str + source_count: int + updated_at: str + + +class WikiStatusOut(BaseModel): + enabled: bool + auto_organize: bool + page_count: int + link_count: int + last_organized_at: Optional[str] = None + latest_page: Optional[WikiPagePreview] = None + llm_configured: bool = False + llm_model: Optional[str] = None + compiler_mode: str = "deterministic" + daily_llm_limit: int = 20 + llm_used_today: int = 0 + llm_remaining_today: int = 20 + quota_exceeded: bool = False + recharge_message: Optional[str] = None + + +class WikiGraphNodeOut(BaseModel): + id: str + slug: str + title: str + category: str + summary: str + source_count: int + updated_at: str + degree: int = 0 + + +class WikiGraphLinkOut(BaseModel): + source: str + target: str + relation: str + + +class WikiGraphOut(BaseModel): + nodes: list[WikiGraphNodeOut] + links: list[WikiGraphLinkOut] + + +class WikiSettingsUpdate(BaseModel): + enabled: Optional[bool] = None + auto_organize: Optional[bool] = None # Memory coach (Q/K/V dialogue) @@ -326,6 +460,8 @@ class CoachWordBrief(BaseModel): retention_now: float mastery_score: int status: str + retrievability: float = 0.0 + next_review_at: Optional[str] = None class CoachSessionResponse(BaseModel): @@ -382,3 +518,6 @@ class CoachTurnResponse(BaseModel): hints_used: int = 0 target_en: Optional[str] = None target_zh: Optional[str] = None + review_message: Optional[str] = None + retrievability: Optional[float] = None + next_review_at: Optional[str] = None diff --git a/backend/scripts/test_photo_scan_accuracy.py b/backend/scripts/test_photo_scan_accuracy.py new file mode 100644 index 0000000..21f4a07 --- /dev/null +++ b/backend/scripts/test_photo_scan_accuracy.py @@ -0,0 +1,158 @@ +"""测试拍照录入在课本词表照片上的识别成功率。""" +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path + +BACKEND_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BACKEND_DIR)) + +from services.word_scan_service import word_scan_service # noqa: E402 + +IMAGE = Path( + "/Users/john/.cursor/projects/Users-john-PycharmProjects-wordloop/assets/" + "bf2e15b4e1a36836efb4e9a72b200d8e-f357a710-6d8d-4f5b-a52c-0bea4420aada.png" +) + +GROUND_TRUTH: list[tuple[str, str]] = [ + ("feed the cows", "喂奶牛"), + ("see a film", "看电影"), + ("visit Grandma and Grandpa", "看望爷爷奶奶"), + ("have a good time", "过得愉快"), + ("do the housework", "做家务"), + ("tidy my room", "整理我的房间"), + ("make the bed", "铺床"), + ("dish", "碟;盘"), + ("happy", "高兴的"), + ("excited", "激动的;兴奋的"), + ("upset", "难过的"), + ("angry", "愤怒的;生气的"), + ("play music", "放音乐"), + ("sing and dance", "唱歌跳舞"), + ("talk with the baby", "和婴儿说话"), + ("play a game", "玩游戏"), + ("share", "分享"), + ("have a go", "尝试"), + ("nice", "令人愉快的;吸引人的"), + ("cook", "厨师"), + ("great", "伟大的"), + ("teacher", "教师"), + ("tree", "树"), + ("leaf (pl. leaves)", "叶子"), + ("flower", "花"), + ("grass", "草"), + ("duck", "鸭"), + ("pond", "池塘;水池"), + ("frog", "青蛙"), + ("lake", "湖"), + ("fish", "鱼"), + ("river", "河;江"), + ("shark", "鲨鱼"), + ("sea", "海;海洋"), + ("wash my hands", "(我)洗手"), + ("before eating", "吃东西前"), + ("after using the toilet", "上厕所后"), + ("dirty", "脏的"), + ("clean", "干净的"), + ("drink some milk", "喝一些牛奶"), + ("say good night", "道晚安"), + ("sleep", "睡觉;入睡"), + ("read a story", "读故事"), +] + + +def normalize_en(text: str) -> str: + return re.sub(r"\s+", " ", text.strip().lower()) + + +def normalize_zh(text: str) -> str: + return re.sub(r"\s+", "", text.strip()) + + +def fuzzy_zh_match(pred: str, truth: str) -> bool: + p = normalize_zh(pred) + t = normalize_zh(truth) + if not p or not t: + return False + if p == t: + return True + # 允许 OCR/截断导致的中文释义部分匹配(>=70% 字符重合) + common = sum(1 for ch in t if ch in p) + return common / max(len(t), 1) >= 0.7 + + +def fuzzy_en_match(pred: str, truth: str) -> bool: + p = normalize_en(pred) + t = normalize_en(truth) + if p == t: + return True + # 短语轻微 OCR 错误:去掉空格后前缀匹配 + p2 = re.sub(r"[^a-z()\.]", "", p) + t2 = re.sub(r"[^a-z()\.]", "", t) + if not p2 or not t2: + return False + return t2 in p2 or p2 in t2 or p2.startswith(t2[: max(4, len(t2) - 2)]) + + +def run_tesseract(image_path: Path, psm: str = "6") -> str: + result = subprocess.run( + ["tesseract", str(image_path), "stdout", "-l", "chi_sim+eng", "--psm", psm], + capture_output=True, + text=True, + check=False, + ) + return result.stdout or "" + + +def evaluate(items: list[dict[str, str]], label: str) -> dict: + preds = [(i["source_text"], i["target_text"]) for i in items] + matched = [] + missed = [] + for en, zh in GROUND_TRUTH: + hit = any(fuzzy_en_match(p_en, en) and fuzzy_zh_match(p_zh, zh) for p_en, p_zh in preds) + if hit: + matched.append((en, zh)) + else: + missed.append((en, zh)) + + recall = len(matched) / len(GROUND_TRUTH) + precision = len(matched) / max(len(preds), 1) + print(f"\n=== {label} ===") + print(f"标准词条: {len(GROUND_TRUTH)}") + print(f"识别词条: {len(preds)}") + print(f"命中: {len(matched)} 漏识: {len(missed)}") + print(f"召回率(成功率): {recall * 100:.1f}%") + print(f"精确率: {precision * 100:.1f}%") + if missed: + print("漏识样例:", ", ".join(f"{en}" for en, _ in missed[:12])) + if len(missed) > 12: + print(f"... 另有 {len(missed) - 12} 条") + return {"recall": recall, "matched": len(matched), "pred_count": len(preds), "missed": missed} + + +def main() -> None: + if not IMAGE.exists(): + print(f"图片不存在: {IMAGE}") + sys.exit(1) + + ocr_raw = run_tesseract(IMAGE) + print("--- OCR 原文(前 1200 字)---") + print(ocr_raw[:1200]) + + try: + items = word_scan_service.parse_scan_text(ocr_raw) + result = evaluate(items, "当前流水线: 原始 OCR + 解析") + except Exception as exc: + print(f"\n解析失败: {exc}") + result = {"recall": 0.0} + + passed = result["recall"] >= 0.9 + print(f"\n结论: {'达到' if passed else '未达到'} 90% 成功率目标(召回 {result['recall']*100:.1f}%)") + sys.exit(0 if passed else 1) + + +if __name__ == "__main__": + main() diff --git a/backend/services/adaptive_review_service.py b/backend/services/adaptive_review_service.py new file mode 100644 index 0000000..90a5e97 --- /dev/null +++ b/backend/services/adaptive_review_service.py @@ -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() diff --git a/backend/services/book_service.py b/backend/services/book_service.py index 7197c45..ba74dd4 100644 --- a/backend/services/book_service.py +++ b/backend/services/book_service.py @@ -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", } diff --git a/backend/services/llm_service.py b/backend/services/llm_service.py new file mode 100644 index 0000000..61b8f42 --- /dev/null +++ b/backend/services/llm_service.py @@ -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() diff --git a/backend/services/memory_coach_service.py b/backend/services/memory_coach_service.py index ca9c6cd..1d940c2 100644 --- a/backend/services/memory_coach_service.py +++ b/backend/services/memory_coach_service.py @@ -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, ) diff --git a/backend/services/memory_transformer/service.py b/backend/services/memory_transformer/service.py index 2a2b9a7..9eadb75 100644 --- a/backend/services/memory_transformer/service.py +++ b/backend/services/memory_transformer/service.py @@ -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: diff --git a/backend/services/quiz_service.py b/backend/services/quiz_service.py index a397e78..3eb2f81 100644 --- a/backend/services/quiz_service.py +++ b/backend/services/quiz_service.py @@ -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) diff --git a/backend/services/text_utils.py b/backend/services/text_utils.py new file mode 100644 index 0000000..53e0b60 --- /dev/null +++ b/backend/services/text_utils.py @@ -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) diff --git a/backend/services/tts_service.py b/backend/services/tts_service.py new file mode 100644 index 0000000..a9886d6 --- /dev/null +++ b/backend/services/tts_service.py @@ -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() diff --git a/backend/services/wiki_llm_quota_service.py b/backend/services/wiki_llm_quota_service.py new file mode 100644 index 0000000..3001e6b --- /dev/null +++ b/backend/services/wiki_llm_quota_service.py @@ -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() diff --git a/backend/services/wiki_service.py b/backend/services/wiki_service.py new file mode 100644 index 0000000..140e556 --- /dev/null +++ b/backend/services/wiki_service.py @@ -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() diff --git a/backend/services/word_scan_service.py b/backend/services/word_scan_service.py new file mode 100644 index 0000000..d37358c --- /dev/null +++ b/backend/services/word_scan_service.py @@ -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() diff --git a/backend/services/word_service.py b/backend/services/word_service.py index 3406cf5..138c5bf 100644 --- a/backend/services/word_service.py +++ b/backend/services/word_service.py @@ -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() diff --git a/backend/tests/test_adaptive_review_service.py b/backend/tests/test_adaptive_review_service.py new file mode 100644 index 0000000..7721545 --- /dev/null +++ b/backend/tests/test_adaptive_review_service.py @@ -0,0 +1,347 @@ +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from services.adaptive_review_service import ( + adaptive_review_service, + response_quality, + retrievability, + utc_iso, +) + + +def make_word( + word_id: int, + *, + stability_hours: float = 24.0, + difficulty: float = 5.0, + elapsed_hours: float = 24.0, + next_review_offset_hours: float = 0.0, + status: str = "learning", + wrong_count: int = 0, + correct_count: int = 0, + mastery_score: int = 50, + train_count: int = 0, + error_bank_member: int = 0, + error_reinforce_streak: int = 0, +): + now = datetime(2026, 6, 6, 12, tzinfo=timezone.utc) + return SimpleNamespace( + id=word_id, + stability_hours=stability_hours, + difficulty=difficulty, + last_reviewed_at=utc_iso(now - timedelta(hours=elapsed_hours)), + created_at=utc_iso(now - timedelta(days=10)), + next_review_at=utc_iso(now + timedelta(hours=next_review_offset_hours)), + review_due_date=now.strftime("%Y-%m-%d"), + status=status, + wrong_count=wrong_count, + correct_count=correct_count, + mastery_score=mastery_score, + train_count=train_count, + error_bank_member=error_bank_member, + error_reinforce_streak=error_reinforce_streak, + ) + + +class AdaptiveReviewServiceTest(unittest.TestCase): + def setUp(self): + self.now = datetime(2026, 6, 6, 12, tzinfo=timezone.utc) + + def test_fast_unassisted_recall_extends_interval(self): + word = make_word(1) + update = adaptive_review_service.update_word( + word, + is_correct=True, + attempts=1, + hints_used=0, + duration_seconds=6, + now=self.now, + ) + self.assertEqual(update.quality, 1.0) + self.assertGreater(update.stability_hours, 70) + self.assertGreater(update.interval_hours, 10) + self.assertLess(update.difficulty, 5.0) + + def test_hints_and_slow_response_reduce_growth(self): + fast_word = make_word(1) + helped_word = make_word(2) + fast = adaptive_review_service.update_word( + fast_word, + is_correct=True, + attempts=1, + hints_used=0, + duration_seconds=6, + now=self.now, + ) + helped = adaptive_review_service.update_word( + helped_word, + is_correct=True, + attempts=3, + hints_used=2, + duration_seconds=75, + now=self.now, + ) + self.assertLess( + response_quality( + is_correct=True, + attempts=3, + hints_used=2, + duration_seconds=75, + ), + fast.quality, + ) + self.assertLess(helped.stability_hours, fast.stability_hours) + self.assertLess(helped.interval_hours, fast.interval_hours) + + def test_failed_recall_returns_soon(self): + word = make_word(1, stability_hours=72) + update = adaptive_review_service.update_word( + word, + is_correct=False, + attempts=4, + hints_used=4, + duration_seconds=30, + now=self.now, + ) + self.assertEqual(update.interval_hours, 0.25) + self.assertGreater(update.difficulty, 5.0) + self.assertLess(update.stability_hours, 72) + + def test_overdue_weak_word_is_selected_before_future_mastered_word(self): + weak = make_word( + 1, + difficulty=8, + elapsed_hours=72, + next_review_offset_hours=-24, + status="weak", + ) + mastered = make_word( + 2, + stability_hours=240, + difficulty=2, + elapsed_hours=2, + next_review_offset_hours=72, + status="mastered", + ) + selected = adaptive_review_service.select_words( + [mastered, weak], limit=2, now=self.now + ) + self.assertEqual([word.id for word in selected], [1, 2]) + self.assertLess(retrievability(weak, self.now), retrievability(mastered, self.now)) + + def test_zero_correct_wrong_word_outranks_recent_mastered(self): + never_correct = make_word( + 1, + status="weak", + wrong_count=3, + correct_count=0, + mastery_score=0, + next_review_offset_hours=-1, + ) + recent_ok = make_word( + 2, + status="mastered", + wrong_count=0, + correct_count=2, + mastery_score=100, + elapsed_hours=1, + stability_hours=240, + next_review_offset_hours=48, + ) + selected = adaptive_review_service.select_words( + [recent_ok, never_correct], limit=2, now=self.now + ) + self.assertEqual([word.id for word in selected], [1, 2]) + + def test_is_error_priority_covers_weak_and_wrong_heavy(self): + self.assertTrue( + adaptive_review_service.is_error_priority( + make_word(1, status="weak", wrong_count=1, correct_count=0) + ) + ) + self.assertTrue( + adaptive_review_service.is_error_priority( + make_word(2, status="mastered", wrong_count=2, correct_count=1) + ) + ) + self.assertFalse( + adaptive_review_service.is_error_priority( + make_word( + 3, + status="learning", + wrong_count=0, + correct_count=2, + mastery_score=100, + train_count=2, + ) + ) + ) + + def test_plan_completed_excludes_clean_success_from_practice_plan(self): + completed = make_word( + 1, + status="learning", + wrong_count=0, + correct_count=2, + mastery_score=100, + train_count=2, + ) + pending = make_word( + 2, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0 + ) + weak = make_word( + 3, + status="weak", + wrong_count=2, + correct_count=0, + mastery_score=0, + train_count=2, + error_bank_member=1, + ) + + self.assertTrue(adaptive_review_service.is_plan_completed(completed)) + self.assertFalse(adaptive_review_service.is_plan_eligible(completed)) + self.assertTrue(adaptive_review_service.is_plan_eligible(pending)) + self.assertTrue(adaptive_review_service.is_plan_eligible(weak)) + + selected = adaptive_review_service.select_practice_plan_words( + [completed, pending, weak], limit=3, now=self.now + ) + self.assertEqual([word.id for word in selected], [2, 3]) + + def test_untrained_excludes_error_bank_members_without_train_count(self): + """错题库成员不应出现在未练词池,即使 train_count 仍为 0。""" + pending = make_word( + 1, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0 + ) + migrated_error = make_word( + 2, + status="weak", + wrong_count=1, + correct_count=0, + mastery_score=0, + train_count=0, + error_bank_member=1, + error_reinforce_streak=0, + ) + + untrained, reinforce = adaptive_review_service.split_plan_pools( + [pending, migrated_error] + ) + self.assertEqual([w.id for w in untrained], [1]) + self.assertEqual([w.id for w in reinforce], [2]) + + selected = adaptive_review_service.select_untrained_words( + [pending, migrated_error], limit=5, now=self.now + ) + self.assertEqual([word.id for word in selected], [1]) + + def test_select_untrained_words_only_returns_new_words(self): + completed = make_word( + 1, + status="learning", + wrong_count=0, + correct_count=2, + mastery_score=100, + train_count=2, + ) + pending = make_word( + 2, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0 + ) + weak = make_word( + 3, + status="weak", + wrong_count=2, + correct_count=0, + mastery_score=0, + train_count=2, + error_bank_member=1, + ) + + selected = adaptive_review_service.select_untrained_words( + [completed, pending, weak], limit=5, now=self.now + ) + self.assertEqual([word.id for word in selected], [2]) + + def test_select_error_words_only_returns_trained_plan_words(self): + completed = make_word( + 1, + status="learning", + wrong_count=0, + correct_count=2, + mastery_score=100, + train_count=2, + ) + pending = make_word( + 2, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0 + ) + weak = make_word( + 3, + status="weak", + wrong_count=2, + correct_count=0, + mastery_score=0, + train_count=2, + error_bank_member=1, + error_reinforce_streak=0, + ) + + selected = adaptive_review_service.select_error_words( + [completed, pending, weak], limit=5, now=self.now + ) + self.assertEqual([word.id for word in selected], [3]) + + def test_count_plan_pools(self): + completed = make_word( + 1, + status="learning", + wrong_count=0, + correct_count=2, + mastery_score=100, + train_count=2, + ) + pending = make_word( + 2, status="new", wrong_count=0, correct_count=0, mastery_score=0, train_count=0 + ) + weak = make_word( + 3, + status="weak", + wrong_count=2, + correct_count=0, + mastery_score=0, + train_count=2, + error_bank_member=1, + error_reinforce_streak=0, + ) + untrained, errors, bank_due, bank_total = adaptive_review_service.count_plan_pools( + [completed, pending, weak] + ) + self.assertEqual(untrained, 1) + self.assertEqual(errors, 1) + self.assertEqual(bank_total, 1) + + + def test_is_review_due_false_without_schedule(self): + word = make_word(1, error_bank_member=1, error_reinforce_streak=2) + word.next_review_at = None + word.review_due_date = None + self.assertFalse(adaptive_review_service.is_review_due(word, self.now)) + + def test_cooling_error_bank_word_not_selectable_for_review(self): + cooling = make_word( + 1, + wrong_count=2, + train_count=3, + error_bank_member=1, + error_reinforce_streak=2, + next_review_offset_hours=48, + ) + selected = adaptive_review_service.select_error_bank_words( + [cooling], limit=5, now=self.now, clear_count=2 + ) + self.assertEqual(selected, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_llm_service.py b/backend/tests/test_llm_service.py new file mode 100644 index 0000000..eadf3f7 --- /dev/null +++ b/backend/tests/test_llm_service.py @@ -0,0 +1,78 @@ +import json +import os +import unittest +from unittest.mock import MagicMock, patch + +from services.llm_service import LlmServiceError, llm_service + + +class _FakeResponse: + def __init__(self, payload: dict): + self._payload = json.dumps(payload).encode("utf-8") + + def read(self): + return self._payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class LlmServiceTest(unittest.TestCase): + def tearDown(self): + get_settings = __import__("config", fromlist=["get_settings"]).get_settings + get_settings.cache_clear() + + def test_not_configured_without_api_key(self): + with patch.dict(os.environ, {"DEEPSEEK_API_KEY": ""}, clear=False): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + self.assertFalse(llm_service.is_configured()) + + @patch("services.llm_service.urllib.request.urlopen") + def test_enhance_wiki_page_returns_model_content(self, urlopen_mock: MagicMock): + with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "test-key"}, clear=False): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + urlopen_mock.return_value = _FakeResponse( + { + "choices": [ + {"message": {"content": "# demo\n\n整理后的知识页。"}}, + ] + } + ) + content = llm_service.enhance_wiki_page(title="demo", draft_markdown="# draft") + self.assertIn("整理后的知识页", content) + + def test_missing_api_key_raises(self): + with patch.dict(os.environ, {"DEEPSEEK_API_KEY": ""}, clear=False): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + with self.assertRaises(LlmServiceError): + llm_service.enhance_wiki_page(title="demo", draft_markdown="# draft") + + @patch("services.llm_service.urllib.request.urlopen") + def test_parse_word_pairs_from_text(self, urlopen_mock: MagicMock): + with patch.dict(os.environ, {"DEEPSEEK_API_KEY": "test-key"}, clear=False): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + urlopen_mock.return_value = _FakeResponse( + { + "choices": [ + { + "message": { + "content": ( + '{"items":[{"en":"apple","zh":"苹果"},' + '{"en":"banana","zh":"香蕉"}]}' + ) + } + }, + ] + } + ) + pairs = llm_service.parse_word_pairs_from_text("apple 苹果\nbanana 香蕉") + self.assertEqual(len(pairs), 2) + self.assertEqual(pairs[0]["en"], "apple") + self.assertEqual(pairs[0]["zh"], "苹果") + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_text_utils.py b/backend/tests/test_text_utils.py new file mode 100644 index 0000000..70a9806 --- /dev/null +++ b/backend/tests/test_text_utils.py @@ -0,0 +1,11 @@ +from services.text_utils import en_answers_match, normalize_en_answer + + +def test_normalize_en_answer_collapses_whitespace(): + assert normalize_en_answer(" A Lot Of ") == "a lot of" + + +def test_en_answers_match_phrase_with_extra_spaces(): + assert en_answers_match("a lot of", "a lot of") + assert en_answers_match("A Lot Of", "a lot of") + assert not en_answers_match("a lot", "a lot of") diff --git a/backend/tests/test_tts_service.py b/backend/tests/test_tts_service.py new file mode 100644 index 0000000..e2a15ee --- /dev/null +++ b/backend/tests/test_tts_service.py @@ -0,0 +1,115 @@ +import base64 +import json +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +from services.tts_service import TtsService, TtsServiceError, tts_service + + +class _FakeResponse: + def __init__(self, body: bytes, content_type: str = "application/json"): + self._body = body + self.headers = MagicMock() + self.headers.get_content_type.return_value = content_type + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class TtsServiceTest(unittest.TestCase): + def tearDown(self): + get_settings = __import__("config", fromlist=["get_settings"]).get_settings + get_settings.cache_clear() + + def test_not_configured_when_disabled(self): + with patch.dict( + os.environ, + {"TKMIND_TTS_ENABLED": "false", "TKMIND_TTS_BASE_URL": "http://127.0.0.1:19200"}, + clear=False, + ): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + self.assertFalse(tts_service.is_configured()) + + @patch("services.tts_service._open_tts_request") + def test_request_includes_user_agent(self, urlopen_mock: MagicMock): + wav = b"RIFFxxxx" + payload = {"code": 0, "data": base64.b64encode(wav).decode("ascii")} + urlopen_mock.return_value = _FakeResponse(json.dumps(payload).encode("utf-8")) + + with patch.dict( + os.environ, + {"TKMIND_TTS_BASE_URL": "https://asr.tkmind.cn", "TKMIND_TTS_ENABLED": "true"}, + clear=False, + ): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + with tempfile.TemporaryDirectory() as tmp: + with patch("services.tts_service._CACHE_DIR", __import__("pathlib").Path(tmp)): + tts_service.synthesize("apple") + request = urlopen_mock.call_args.args[0] + self.assertEqual(request.get_header("User-agent"), "WordLoop-TTS/1.0") + + @patch("services.tts_service._open_tts_request") + def test_synthesize_parses_json_base64(self, urlopen_mock: MagicMock): + wav = b"RIFFxxxx" + payload = {"code": 0, "message": "ok", "data": base64.b64encode(wav).decode("ascii")} + urlopen_mock.return_value = _FakeResponse(json.dumps(payload).encode("utf-8")) + + with patch.dict( + os.environ, + {"TKMIND_TTS_BASE_URL": "http://127.0.0.1:19200", "TKMIND_TTS_ENABLED": "true"}, + clear=False, + ): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + with tempfile.TemporaryDirectory() as tmp: + with patch("services.tts_service._CACHE_DIR", __import__("pathlib").Path(tmp)): + audio, content_type = tts_service.synthesize("apple") + self.assertEqual(audio, wav) + self.assertEqual(content_type, "audio/wav") + + @patch("services.tts_service._open_tts_request") + def test_synthesize_uses_cache_on_second_call(self, urlopen_mock: MagicMock): + wav = b"RIFFcached" + payload = {"code": 0, "data": base64.b64encode(wav).decode("ascii")} + urlopen_mock.return_value = _FakeResponse(json.dumps(payload).encode("utf-8")) + + with patch.dict( + os.environ, + {"TKMIND_TTS_BASE_URL": "http://127.0.0.1:19200", "TKMIND_TTS_ENABLED": "true"}, + clear=False, + ): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + with tempfile.TemporaryDirectory() as tmp: + with patch("services.tts_service._CACHE_DIR", __import__("pathlib").Path(tmp)): + first, _ = tts_service.synthesize("apple") + second, _ = tts_service.synthesize("apple") + self.assertEqual(first, second) + self.assertEqual(urlopen_mock.call_count, 1) + + @patch("services.tts_service._open_tts_request") + def test_upstream_error_message(self, urlopen_mock: MagicMock): + payload = {"code": 500, "message": "TTS 未启用(ENABLE_TTS=false)", "data": None} + urlopen_mock.return_value = _FakeResponse(json.dumps(payload).encode("utf-8")) + + with patch.dict( + os.environ, + {"TKMIND_TTS_BASE_URL": "http://127.0.0.1:19200", "TKMIND_TTS_ENABLED": "true"}, + clear=False, + ): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + with tempfile.TemporaryDirectory() as tmp: + with patch("services.tts_service._CACHE_DIR", __import__("pathlib").Path(tmp)): + with self.assertRaises(TtsServiceError) as ctx: + tts_service.synthesize("apple") + self.assertIn("TTS 未启用", str(ctx.exception)) + + def test_extract_audio_base64_from_dict(self): + value = TtsService._extract_audio_base64({"audio": "abc"}) + self.assertEqual(value, "abc") diff --git a/backend/tests/test_wiki_llm_quota_service.py b/backend/tests/test_wiki_llm_quota_service.py new file mode 100644 index 0000000..d5936ae --- /dev/null +++ b/backend/tests/test_wiki_llm_quota_service.py @@ -0,0 +1,60 @@ +import os +import unittest +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from database import Base +from models import User, WikiLlmUsage +from services.wiki_llm_quota_service import WikiLlmQuotaExceeded, wiki_llm_quota_service + + +class WikiLlmQuotaServiceTest(unittest.TestCase): + def setUp(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + self.db = sessionmaker(bind=engine)() + self.user = User( + username="alice", + password_hash="test", + created_at="2026-06-06T00:00:00Z", + ) + self.db.add(self.user) + self.db.commit() + + def tearDown(self): + self.db.close() + get_settings = __import__("config", fromlist=["get_settings"]).get_settings + get_settings.cache_clear() + + def test_consume_until_limit(self): + with patch.dict(os.environ, {"AI_WIKI_DAILY_LLM_LIMIT": "2"}, clear=False): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + for _ in range(2): + wiki_llm_quota_service.consume(self.db, self.user) + self.db.commit() + with self.assertRaises(WikiLlmQuotaExceeded): + wiki_llm_quota_service.consume(self.db, self.user) + + def test_quota_dict_tracks_usage(self): + with patch.dict(os.environ, {"AI_WIKI_DAILY_LLM_LIMIT": "20"}, clear=False): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + wiki_llm_quota_service.consume(self.db, self.user) + self.db.commit() + quota = wiki_llm_quota_service.quota_dict(self.db, self.user) + self.assertEqual(quota["used_today"], 1) + self.assertEqual(quota["remaining_today"], 19) + self.assertFalse(quota["quota_exceeded"]) + + def test_usage_row_is_unique_per_day(self): + row = wiki_llm_quota_service.get_usage_row(self.db, self.user, "2026-06-06") + row.call_count = 3 + self.db.commit() + rows = self.db.query(WikiLlmUsage).filter(WikiLlmUsage.user_id == self.user.id).all() + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].call_count, 3) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_wiki_service.py b/backend/tests/test_wiki_service.py new file mode 100644 index 0000000..f03fe9c --- /dev/null +++ b/backend/tests/test_wiki_service.py @@ -0,0 +1,138 @@ +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from database import Base +from models import QuizRecord, User, UserSettings, WikiPage, WikiSource, Word +from services.wiki_service import wiki_service + + +class WikiServiceTest(unittest.TestCase): + def setUp(self): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + self.db = sessionmaker(bind=engine)() + + def tearDown(self): + self.db.close() + + def create_user(self, username: str) -> User: + user = User( + username=username, + password_hash="test", + created_at="2026-06-06T00:00:00Z", + ) + self.db.add(user) + self.db.flush() + self.db.add( + UserSettings( + user_id=user.id, + ai_wiki_enabled=1, + ai_wiki_auto_organize=1, + ) + ) + self.db.commit() + return user + + def create_word(self, user: User, text: str) -> Word: + word = Word( + user_id=user.id, + book_id=0, + source_text=text, + target_text="测试", + source_lang="en", + target_lang="zh", + status="learning", + correct_count=1, + wrong_count=0, + consecutive_correct_count=1, + mastery_score=100, + train_count=1, + total_train_seconds=3, + difficulty=5.0, + stability_hours=24.0, + created_at="2026-06-06T00:00:00Z", + ) + self.db.add(word) + self.db.commit() + return word + + def test_ingest_builds_private_markdown_page(self): + user = self.create_user("alice") + word = self.create_word(user, "resilient") + wiki_service.ingest_word(self.db, user, word) + + page = ( + self.db.query(WikiPage) + .filter(WikiPage.user_id == user.id, WikiPage.category == "word") + .one() + ) + self.assertIn("# resilient", page.content) + self.assertIn("WordLoop 原始学习事件 1 条", page.content) + self.assertEqual( + self.db.query(WikiSource).filter(WikiSource.user_id == user.id).count(), + 1, + ) + + def test_quiz_ingest_updates_only_own_wiki(self): + alice = self.create_user("alice") + bob = self.create_user("bob") + word = self.create_word(alice, "resilient") + wiki_service.ingest_word(self.db, alice, word) + record = QuizRecord( + user_id=alice.id, + word_id=word.id, + question_type="spell", + user_answer="resilient", + correct_answer="resilient", + is_correct=1, + duration_seconds=2, + created_at="2026-06-06T00:01:00Z", + ) + self.db.add(record) + self.db.commit() + wiki_service.ingest_quiz(self.db, alice, word, record) + + alice_status = wiki_service.status(self.db, alice) + bob_status = wiki_service.status(self.db, bob) + self.assertGreater(alice_status["page_count"], 0) + self.assertEqual(alice_status["latest_page"]["source_count"], 2) + self.assertEqual(bob_status["page_count"], 0) + self.assertIsNone(bob_status["latest_page"]) + + def test_recompile_prunes_orphan_status_collection(self): + user = self.create_user("alice") + word = self.create_word(user, "resilient") + word.status = "new" + self.db.commit() + wiki_service.ingest_word(self.db, user, word) + + word.status = "learning" + self.db.commit() + wiki_service.rebuild(self.db, user) + + slugs = { + page.slug + for page in self.db.query(WikiPage).filter(WikiPage.user_id == user.id).all() + } + self.assertNotIn("learning/new", slugs) + self.assertIn("learning/learning", slugs) + + def test_graph_returns_wiki_pages_and_links(self): + user = self.create_user("alice") + word = self.create_word(user, "resilient") + wiki_service.ingest_word(self.db, user, word) + + graph = wiki_service.graph(self.db, user) + + node_slugs = {node["slug"] for node in graph["nodes"]} + relations = {link["relation"] for link in graph["links"]} + self.assertIn("index", node_slugs) + self.assertIn(f"vocabulary/{word.id}-resilient", node_slugs) + self.assertIn("learning_status", relations) + self.assertIn("collection", relations) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_word_scan_service.py b/backend/tests/test_word_scan_service.py new file mode 100644 index 0000000..633d15e --- /dev/null +++ b/backend/tests/test_word_scan_service.py @@ -0,0 +1,37 @@ +import os +import unittest +from unittest.mock import patch + +from fastapi import HTTPException + +from services.word_scan_service import word_scan_service + + +class WordScanServiceTest(unittest.TestCase): + def tearDown(self): + get_settings = __import__("config", fromlist=["get_settings"]).get_settings + get_settings.cache_clear() + + def test_heuristic_parse_tab_separated_pairs(self): + with patch.dict(os.environ, {"DEEPSEEK_API_KEY": ""}, clear=False): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + items = word_scan_service.parse_scan_text("apple\t苹果\nbanana\t香蕉") + self.assertEqual(len(items), 2) + self.assertEqual(items[0]["source_text"], "apple") + self.assertEqual(items[0]["target_text"], "苹果") + + def test_empty_text_raises(self): + with self.assertRaises(HTTPException) as ctx: + word_scan_service.parse_scan_text(" ") + self.assertEqual(ctx.exception.status_code, 400) + + def test_unparseable_text_raises(self): + with patch.dict(os.environ, {"DEEPSEEK_API_KEY": ""}, clear=False): + __import__("config", fromlist=["get_settings"]).get_settings.cache_clear() + with self.assertRaises(HTTPException) as ctx: + word_scan_service.parse_scan_text("hello world only english") + self.assertEqual(ctx.exception.status_code, 422) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/install-production.sh b/deploy/install-production.sh index 14f6601..b232692 100755 --- a/deploy/install-production.sh +++ b/deploy/install-production.sh @@ -10,7 +10,17 @@ MYSQL_PORT="${MYSQL_PORT:-3306}" MYSQL_USER="${MYSQL_USER:?MYSQL_USER required}" MYSQL_PASSWORD="${MYSQL_PASSWORD:?MYSQL_PASSWORD required}" MYSQL_DATABASE="${MYSQL_DATABASE:-wordloop}" +if [ -z "${WORDLOOP_SECRET_KEY:-}" ] && [ -f "$ROOT/backend/.env" ]; then + WORDLOOP_SECRET_KEY="$(grep -E '^WORDLOOP_SECRET_KEY=' "$ROOT/backend/.env" | cut -d= -f2- || true)" +fi WORDLOOP_SECRET_KEY="${WORDLOOP_SECRET_KEY:-$(openssl rand -hex 32)}" +DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-}" +DEEPSEEK_BASE_URL="${DEEPSEEK_BASE_URL:-https://api.deepseek.com}" +DEEPSEEK_MODEL="${DEEPSEEK_MODEL:-deepseek-chat}" +AI_WIKI_DAILY_LLM_LIMIT="${AI_WIKI_DAILY_LLM_LIMIT:-20}" +TKMIND_TTS_BASE_URL="${TKMIND_TTS_BASE_URL:-http://127.0.0.1:19200}" +TKMIND_TTS_ENABLED="${TKMIND_TTS_ENABLED:-true}" +TKMIND_TTS_SPK_ID="${TKMIND_TTS_SPK_ID:-aitong}" echo ">>> 写入 backend/.env" cat > "$ROOT/backend/.env" <>> OCR 依赖(iOS 拍照录入走服务端识别)" +if command -v tesseract >/dev/null 2>&1; then + echo "tesseract 已安装: $(tesseract --version 2>&1 | head -1)" +else + if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq + apt-get install -y -qq tesseract-ocr tesseract-ocr-chi-sim tesseract-ocr-eng + elif command -v yum >/dev/null 2>&1; then + yum install -y tesseract tesseract-langpack-chi-sim 2>/dev/null || true + elif command -v dnf >/dev/null 2>&1; then + dnf install -y tesseract tesseract-langpack-chi-sim 2>/dev/null || true + fi +fi +if ! command -v tesseract >/dev/null 2>&1; then + echo "警告: 未安装 tesseract,iOS 拍照录入将无法识别照片" >&2 +fi + echo ">>> 创建数据库(若不存在)并建表" python - <<'PY' import pymysql diff --git a/deploy/publish.sh b/deploy/publish.sh index a839c5b..1a70529 100755 --- a/deploy/publish.sh +++ b/deploy/publish.sh @@ -40,6 +40,12 @@ ssh "$SSH_TARGET" \ MYSQL_USER='${MYSQL_USER}' \ MYSQL_PASSWORD='${MYSQL_PASSWORD}' \ MYSQL_DATABASE='${MYSQL_DATABASE}' \ + DEEPSEEK_API_KEY='${DEEPSEEK_API_KEY:-}' \ + DEEPSEEK_BASE_URL='${DEEPSEEK_BASE_URL:-https://api.deepseek.com}' \ + DEEPSEEK_MODEL='${DEEPSEEK_MODEL:-deepseek-chat}' \ + AI_WIKI_DAILY_LLM_LIMIT='${AI_WIKI_DAILY_LLM_LIMIT:-20}' \ + TKMIND_TTS_BASE_URL='${TKMIND_TTS_BASE_URL:-http://127.0.0.1:19200}' \ + TKMIND_TTS_ENABLED='${TKMIND_TTS_ENABLED:-true}' \ bash /root/wordloop/deploy/install-production.sh" echo ">>> 发布完成: http://120.26.184.105/ (Cloudflare 请将 w 解析到该 IP)" diff --git a/deploy/secrets.env.example b/deploy/secrets.env.example index c9d52cd..ff4cf3b 100644 --- a/deploy/secrets.env.example +++ b/deploy/secrets.env.example @@ -5,3 +5,15 @@ MYSQL_USER=boot MYSQL_PASSWORD=@Abc888888 MYSQL_DATABASE=wordloop # SSH_TARGET=root@120.26.184.105 + +# Wiki(DeepSeek) +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_MODEL=deepseek-chat +AI_WIKI_DAILY_LLM_LIMIT=20 + +# 单词朗读(105 同机 TTS) +# 生产机与 CosyVoice 同机部署,走本机 19200 +TKMIND_TTS_BASE_URL=http://127.0.0.1:19200 +TKMIND_TTS_ENABLED=true +TKMIND_TTS_SPK_ID=aitong diff --git a/deploy/sync-db-to-production.sh b/deploy/sync-db-to-production.sh index 6ae18ca..44158be 100755 --- a/deploy/sync-db-to-production.sh +++ b/deploy/sync-db-to-production.sh @@ -18,7 +18,10 @@ RDS_USER="${RDS_USER:-boot}" RDS_PASS="${RDS_PASSWORD:-@Abc888888}" RDS_DB="${RDS_DATABASE:-wordloop}" -TABLES="users user_settings words quiz_records dictionary_entries" +# 仅同步全局离线词典(无用户外键)。 +# 词书目录请走 publish.sh -> install-production.sh 内的 import_word_books(按 slug upsert,避免 DROP 破坏学习关联)。 +# 绝不包含 users / user_settings / words / quiz_records / wiki_* 等账号学习数据表。 +TABLES="dictionary_entries" DUMP="$(mktemp /tmp/wordloop-sync.XXXXXX.sql.gz)" cleanup() { rm -f "${DUMP%.gz}" "$DUMP" 2>/dev/null || true; } @@ -42,7 +45,7 @@ ssh "$SSH_TARGET" "cd /root/wordloop/backend && source venv/bin/activate && pyth from sqlalchemy import text from database import engine with engine.connect() as c: - for t in ['users','words','dictionary_entries','user_settings','quiz_records']: + for t in ['users','words','word_books','word_book_entries','dictionary_entries','quiz_records']: print(f\"{t}:\", c.execute(text(f'SELECT COUNT(*) FROM {t}')).scalar()) PY" diff --git a/frontend/chi_sim.traineddata b/frontend/chi_sim.traineddata new file mode 100644 index 0000000..f30ea94 Binary files /dev/null and b/frontend/chi_sim.traineddata differ diff --git a/frontend/eng.traineddata b/frontend/eng.traineddata new file mode 100644 index 0000000..6d11002 Binary files /dev/null and b/frontend/eng.traineddata differ diff --git a/frontend/index.html b/frontend/index.html index 6c22815..26fcd33 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@ + WordLoop 单词循环记忆 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a5fdd9..6483844 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^1.7.9", "echarts": "^6.1.0", + "tesseract.js": "^5.1.1", "vue": "^3.5.13", "vue-router": "^4.5.0" }, @@ -1139,6 +1140,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", @@ -1531,6 +1538,24 @@ "node": ">= 6" } }, + "node_modules/idb-keyval": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz", + "integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==", + "license": "Apache-2.0" + }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1617,6 +1642,35 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1680,6 +1734,12 @@ "node": ">=10" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.61.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", @@ -1734,6 +1794,31 @@ "node": ">=0.10.0" } }, + "node_modules/tesseract.js": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-5.1.1.tgz", + "integrity": "sha512-lzVl/Ar3P3zhpUT31NjqeCo1f+D5+YfpZ5J62eo2S14QNVOmHBTtbchHm/YAbOOOzCegFnKf4B3Qih9LuldcYQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-electron": "^2.2.2", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^5.1.1", + "wasm-feature-detect": "^1.2.11", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-5.1.1.tgz", + "integrity": "sha512-KX3bYSU5iGcO1XJa+QGPbi+Zjo2qq6eBhNjSGR5E5q0JtzkoipJKOUQD7ph8kFyteCEfEQ0maWLu8MCXtvX5uQ==", + "license": "Apache-2.0" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1751,6 +1836,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", @@ -1906,6 +1997,37 @@ "typescript": ">=5.0.0" } }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/zrender": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 50d435a..61d8933 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "axios": "^1.7.9", "echarts": "^6.1.0", + "tesseract.js": "^5.1.1", "vue": "^3.5.13", "vue-router": "^4.5.0" }, diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..eecda2e --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index 0712c7c..e32cde4 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -46,6 +46,13 @@ request.interceptors.response.use( export default request +export interface WordScanItem { + source_text: string + target_text: string + source_lang?: string + target_lang?: string +} + export interface Word { id: number source_text: string @@ -168,9 +175,35 @@ export interface QuizQuestion { options: QuizOption[] correct_answer: string phonetic?: string + train_count?: number + wrong_count?: number + error_reinforce_streak?: number + error_clear_correct_count?: number + error_bank_member?: number } export type QuizTrack = 'accumulation' | 'book' +export type QuizPool = 'untrained' | 'errors' | 'error_bank' + +export interface ErrorBankWord { + word_id: number + en: string + zh: string + wrong_count: number + train_count: number + error_reinforce_streak: number + status: 'due' | 'cooling' + next_review_at?: string | null + review_due_date?: string | null +} + +export interface ErrorBankList { + items: ErrorBankWord[] + total: number + due_count: number + track: QuizTrack + book_id?: number | null +} export interface QuizStats { total_words: number @@ -184,6 +217,10 @@ export interface QuizStats { daily_target: number today_completed: number streak_days: number + untrained_pending?: number + error_pending?: number + error_bank_due_pending?: number + error_bank_total?: number track?: QuizTrack book_id?: number | null } @@ -217,6 +254,7 @@ export interface BookPracticeSettings { daily_target: number master_required_count: number weak_wrong_threshold: number + error_clear_correct_count: number } export interface ActiveBookState { @@ -229,6 +267,56 @@ export interface Settings { daily_target: number master_required_count: number weak_wrong_threshold: number + error_clear_correct_count: number +} + +export interface WikiPagePreview { + slug: string + title: string + category: string + summary: string + content: string + source_count: number + updated_at: string +} + +export interface WikiStatus { + enabled: boolean + auto_organize: boolean + page_count: number + link_count: number + last_organized_at?: string | null + latest_page?: WikiPagePreview | null + llm_configured?: boolean + llm_model?: string | null + compiler_mode?: string + daily_llm_limit?: number + llm_used_today?: number + llm_remaining_today?: number + quota_exceeded?: boolean + recharge_message?: string | null +} + +export interface WikiGraphNode { + id: string + slug: string + title: string + category: string + summary: string + source_count: number + updated_at: string + degree: number +} + +export interface WikiGraphLink { + source: string + target: string + relation: string +} + +export interface WikiGraph { + nodes: WikiGraphNode[] + links: WikiGraphLink[] } export interface UserProfile { @@ -258,6 +346,8 @@ export interface CoachWordBrief { retention_now: number mastery_score: number status: string + retrievability: number + next_review_at?: string | null } export interface CoachSessionResponse { @@ -306,6 +396,9 @@ export interface CoachTurnResponse { hints_used: number target_en?: string | null target_zh?: string | null + review_message?: string | null + retrievability?: number | null + next_review_at?: string | null } export const api = { @@ -317,6 +410,21 @@ export const api = { updateMe: (data: UserProfileUpdate) => request.patch('/auth/me', data), translate: (text: string) => request.post('/translate', { text }), createWord: (data: Partial) => request.post('/words', data), + scanWordText: (text: string) => + request.post<{ items: WordScanItem[] }>('/words/scan-text', { text }, { timeout: 45000 }), + scanWordImage: (file: File) => { + const form = new FormData() + form.append('file', file) + return request.post<{ items: WordScanItem[] }>('/words/scan-image', form, { + timeout: 90000, + }) + }, + batchCreateWords: (items: WordScanItem[]) => + request.post<{ created: number; skipped: number; items: Word[] }>( + '/words/batch', + { items }, + { timeout: 30000 } + ), listWords: (status?: string, bookId?: number) => request.get('/words', { params: { @@ -324,6 +432,20 @@ export const api = { ...(bookId !== undefined ? { book_id: bookId } : {}), }, }), + listWordsPage: ( + status: string | undefined, + bookId: number | undefined, + page: number, + pageSize = 20 + ) => + request.get<{ items: Word[]; total: number; page: number; page_size: number }>('/words', { + params: { + page, + page_size: pageSize, + ...(status ? { status } : {}), + ...(bookId !== undefined ? { book_id: bookId } : {}), + }, + }), getWord: (id: number) => request.get(`/words/${id}`), memoryViz: (bookId = 0) => request.get('/words/memory-viz', { params: { book_id: bookId } }), @@ -337,14 +459,20 @@ export const api = { daily_target?: number master_required_count?: number weak_wrong_threshold?: number + error_clear_correct_count?: number }) => request.patch('/books/settings', data), wordMemory: (id: number) => request.get(`/words/${id}/memory`), wordMemoryModel: (id: number) => request.get(`/words/${id}/memory-model`), deleteWord: (id: number) => request.delete(`/words/${id}`), - dailyQuiz: (track: QuizTrack = 'accumulation') => - request.get<{ questions: QuizQuestion[]; total: number; track: QuizTrack }>('/quiz/daily', { - params: { track }, + dailyQuiz: (track: QuizTrack = 'accumulation', pool: QuizPool = 'untrained') => + request.get<{ + questions: QuizQuestion[] + total: number + track: QuizTrack + pool: QuizPool + }>('/quiz/daily', { + params: { track, pool }, }), spellQuiz: (track: QuizTrack = 'accumulation') => request.get<{ questions: QuizQuestion[]; total: number; track: QuizTrack }>('/quiz/spell', { @@ -356,11 +484,25 @@ export const api = { user_answer: string correct_answer: string duration_seconds?: number + pool?: QuizPool }) => request.post('/quiz/answer', data), + errorBankList: (track: QuizTrack = 'accumulation') => + request.get('/quiz/error-bank', { params: { track } }), quizStats: (track: QuizTrack = 'accumulation') => request.get('/quiz/stats', { params: { track } }), + resetPracticePlan: (track: QuizTrack = 'accumulation') => + request.post<{ reset_count: number; track: QuizTrack; book_id: number | null }>( + '/quiz/reset-plan', + null, + { params: { track } } + ), getSettings: () => request.get('/settings'), updateSettings: (data: Partial) => request.patch('/settings', data), + getWikiStatus: () => request.get('/wiki/status'), + getWikiGraph: () => request.get('/wiki/graph'), + updateWikiSettings: (data: { enabled?: boolean; auto_organize?: boolean }) => + request.patch('/wiki/settings', data), + rebuildWiki: () => request.post('/wiki/rebuild'), coachSession: (track: QuizTrack = 'accumulation') => request.get('/coach/session', { params: { track } }), coachTurn: (data: { @@ -370,4 +512,10 @@ export const api = { hints_used?: number duration_seconds?: number }) => request.post('/coach/turn', data), + speakWord: (text: string) => + request.get('/tts/speak', { + params: { text }, + responseType: 'blob', + timeout: 30000, + }), } diff --git a/frontend/src/components/ConfirmDialog.vue b/frontend/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..7be15e6 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/frontend/src/components/MemoryCurveChart.vue b/frontend/src/components/MemoryCurveChart.vue index 6eb94ca..b7be8ab 100644 --- a/frontend/src/components/MemoryCurveChart.vue +++ b/frontend/src/components/MemoryCurveChart.vue @@ -1,6 +1,5 @@ diff --git a/frontend/src/components/PhotoWordImport.vue b/frontend/src/components/PhotoWordImport.vue new file mode 100644 index 0000000..3b13da7 --- /dev/null +++ b/frontend/src/components/PhotoWordImport.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/frontend/src/components/QuizCard.vue b/frontend/src/components/QuizCard.vue index 0c8c3ed..c66b4e7 100644 --- a/frontend/src/components/QuizCard.vue +++ b/frontend/src/components/QuizCard.vue @@ -1,23 +1,75 @@