diff --git a/app/models/db.py b/app/models/db.py index 7679d4e..ee56c56 100644 --- a/app/models/db.py +++ b/app/models/db.py @@ -2,7 +2,7 @@ from typing import Optional, List from sqlalchemy import ( String, ForeignKey, func, - Column, Integer, Boolean, DateTime, UniqueConstraint, create_engine, + Column, Integer, Boolean, DateTime, Text, UniqueConstraint, create_engine, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -159,8 +159,9 @@ class Vocabulary(Base): status = Column(String, nullable=False, default="active") # active | deleted # ── V0.10 生词本 + FSRS(P1)────────────────────────────── item_type = Column(String, nullable=False, default="sentence") # word | phrase | sentence - source_type = Column(String, nullable=False, default="translate") # translate | bbc_eaw | practice | chat - source_ref = Column(String, nullable=True) # bbc slug / session_id / null + source_type = Column(String, nullable=False, default="translate") # translate | article | practice | chat + source_ref = Column(String, nullable=True) # episode slug / session_id / null + series = Column(String, nullable=True) # bbc_eaw | voa | ... (when source_type='article') explanation_json = Column(String, nullable=True) # 复用 ExplanationCache 输出 fsrs_card_data = Column(String, nullable=False, default="") last_reviewed_at = Column(DateTime, nullable=True) @@ -228,14 +229,15 @@ class AskMessage(Base): created_at = Column(DateTime, default=datetime.utcnow) -# ── 公共素材:BBC Learning English / English at Work ──────── -# 67 集,每集一行;不绑 user_id(全用户共享) +# ── 公共素材:文章系列(BBC EAW / VOA / ...)──────── +# 每集一行;不绑 user_id(全用户共享) # 灌库脚本:scripts/bbc_eaw_seed.py(数据来源 data/bbc_eaw/parsed/) -class BbcEawEpisode(Base): - __tablename__ = "bbc_eaw_episodes" +class ArticleEpisode(Base): + __tablename__ = "article_episodes" id = Column(Integer, primary_key=True, autoincrement=True) + series = Column(String, nullable=False, default="bbc_eaw") # bbc_eaw | voa | ... slug = Column(String, nullable=False, unique=True) # 01-the-interview url = Column(String, nullable=False) title = Column(String, nullable=True) # The Interview @@ -262,7 +264,7 @@ class BbcArticleCard(Base): id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(String, nullable=False, index=True) - slug = Column(String, nullable=False, index=True) # 关联 BbcEawEpisode.slug + slug = Column(String, nullable=False, index=True) # 关联 ArticleEpisode.slug fsrs_card_data = Column(String, nullable=False, default="") status = Column(String, nullable=False, default="active") # active | deleted first_studied_at = Column(DateTime, default=datetime.utcnow) @@ -328,6 +330,27 @@ class LlmCallLog(Base): created_at = Column(DateTime, default=datetime.utcnow) +# ── V0.8 用户文章库 ──────────────────────────────────────── + +class LibraryArticle(Base): + __tablename__ = "library_articles" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(String, nullable=False, index=True) + source_url = Column(String, nullable=True) + title = Column(String, nullable=False) + markdown = Column(Text, nullable=False) + word_count = Column(Integer, nullable=False, default=0) + source_meta = Column(Text, nullable=True) # JSON string + status = Column(String, nullable=False, default="active") # active | deleted + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + __table_args__ = ( + UniqueConstraint("user_id", "source_url", name="ux_library_articles_user_url"), + ) + + # Sync engine for ORM operations (tests, memory_service, review_service) import os as _os _DB_PATH = _os.environ.get("SPEAKEASY_DB_PATH", "./speakeasy.db") @@ -336,3 +359,8 @@ class LlmCallLog(Base): connect_args={"check_same_thread": False}, ) Base.metadata.create_all(engine) + + +def init_db(): + """Create all tables (idempotent). Convenience wrapper for tests.""" + Base.metadata.create_all(engine) diff --git a/app/routers/library.py b/app/routers/library.py new file mode 100644 index 0000000..f3248b9 --- /dev/null +++ b/app/routers/library.py @@ -0,0 +1,358 @@ +"""文章库路由 — /library/articles 及解读端点""" + +import json +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query, Depends +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from app.routers.auth import get_current_user_id, get_current_user_id_optional +from app.services import library_service +from app.services.explain_service import explain_text, quick_phonetic, stream_sentence_explanation +from app.services import vocab_service as _vocab_service +from app.logger import get_logger + +logger = get_logger("library") + +router = APIRouter(prefix="/library") + + +# ── Schemas ──────────────────────────────────────────────── + +class ImportArticleRequest(BaseModel): + url: Optional[str] = None + markdown: Optional[str] = None + title: Optional[str] = None + + +class ExplainRequest(BaseModel): + text: str + kind: str # 'sentence' | 'word' + context: Optional[str] = "" + refresh: bool = False + + +class QuickPhoneticRequest(BaseModel): + text: str + item_type: Optional[str] = None # 'word' | 'phrase' — 前端推断后传入 + + +class StreamExplainRequest(BaseModel): + text: str + refresh: bool = False + context: Optional[str] = "" + item_type: Optional[str] = None + + +# ── 工具函数 ─────────────────────────────────────────────── + +def _resolve_item_type(text: str, explicit: Optional[str], kind: str) -> str: + if explicit in ("word", "phrase", "sentence"): + return explicit + if kind == "word": + tokens = (text or "").strip().split() + return "word" if len(tokens) <= 1 else "phrase" + return "sentence" + + +def _auto_save_library_vocab( + user_id: str, + article_id: int, + text: str, + context: Optional[str], + item_type: str, +) -> None: + """解读发起时占位写入 vocabulary(explanation_json=None)。失败仅 warning,不向上抛。""" + if not text or not text.strip(): + return + try: + _vocab_service.save_item( + user_id=user_id, + source_text=text, + translated_text="", + direction="en2zh", + context=context or None, + item_type=item_type, + source_type="library", + source_ref=str(article_id), + explanation_json=None, + ) + logger.info( + "library_vocab_auto_save user_id=%s article_id=%d text=%s item_type=%s", + user_id, article_id, text[:40], item_type, + ) + except Exception as e: + logger.warning( + "library_vocab_auto_save_failed user_id=%s article_id=%d text=%s err=%s", + user_id, article_id, text[:40], e, + ) + + +def _backfill_library_vocab( + user_id: str, + article_id: int, + text: str, + explanation_json: str, + force: bool = False, +) -> None: + """解读完成后回填 explanation_json。失败仅 warning,不向上抛。""" + try: + _vocab_service.update_explanation( + user_id=user_id, + source_text=text, + source_ref=str(article_id), + explanation_json=explanation_json, + force=force, + ) + logger.info( + "library_vocab_backfill user_id=%s article_id=%d text=%s", + user_id, article_id, text[:40], + ) + except Exception as e: + logger.warning( + "library_vocab_backfill_failed user_id=%s article_id=%d text=%s err=%s", + user_id, article_id, text[:40], e, + ) + + +def _get_article_or_404(article_id: int, user_id: str): + """Get article or raise 404.""" + article = library_service.get_article(article_id, user_id) + if article is None: + raise HTTPException(404, "文章不存在或已删除") + return article + + +# ── CRUD 端点 ────────────────────────────────────────────── + +@router.post("/articles", status_code=201) +async def import_article( + req: ImportArticleRequest, + user_id: str = Depends(get_current_user_id), +): + """导入文章:URL 模式或粘贴 Markdown 模式。 + + URL 命中已存条目 → 200 + {existing: true, ...} + 新条目 → 201 + 完整记录 + """ + if not req.url and not req.markdown: + raise HTTPException(400, "url 或 markdown 必须提供其一") + + try: + result = library_service.import_article( + user_id=user_id, + url=req.url or None, + markdown=req.markdown or None, + title=req.title or None, + ) + except ValueError as e: + err_str = str(e) + if "fetch_failed" in err_str: + raise HTTPException( + 400, + detail={ + "error": "fetch_failed", + "hint": "抓取失败,请改用粘贴正文模式", + }, + ) + raise HTTPException(400, err_str) + except Exception as e: + logger.error("import_article error: %s", e, exc_info=True) + raise HTTPException(503, "导入失败,请稍后重试") + + # 已存在时返回 200 + if result.get("existing"): + from fastapi.responses import JSONResponse + return JSONResponse(status_code=200, content=result) + + return result + + +@router.get("/articles") +async def list_articles( + user_id: str = Depends(get_current_user_id), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), +): + articles = library_service.list_articles(user_id, page=page, page_size=page_size) + return {"articles": articles, "page": page, "page_size": page_size} + + +@router.get("/articles/{article_id}") +async def get_article( + article_id: int, + user_id: str = Depends(get_current_user_id), +): + return _get_article_or_404(article_id, user_id) + + +@router.delete("/articles/{article_id}") +async def delete_article( + article_id: int, + user_id: str = Depends(get_current_user_id), +): + ok = library_service.delete_article(article_id, user_id) + if not ok: + raise HTTPException(404, "文章不存在或已删除") + return {"deleted": True, "id": article_id} + + +@router.post("/articles/{article_id}/refetch") +async def refetch_article( + article_id: int, + user_id: str = Depends(get_current_user_id), +): + try: + result = library_service.refetch_article(article_id, user_id) + except LookupError as e: + raise HTTPException(404, str(e)) + except ValueError as e: + err_str = str(e) + if "paste_mode_no_url" in err_str: + raise HTTPException(400, "粘贴正文模式的文章无法重新抓取") + if "fetch_failed" in err_str: + raise HTTPException( + 400, + detail={ + "error": "fetch_failed", + "hint": "抓取失败,请改用粘贴正文模式", + }, + ) + raise HTTPException(400, err_str) + except Exception as e: + logger.error("refetch_article error: %s", e, exc_info=True) + raise HTTPException(503, "重新抓取失败,请稍后重试") + return result + + +# ── 解读端点 ─────────────────────────────────────────────── + +@router.post("/articles/{article_id}/explain") +async def library_explain( + article_id: int, + req: ExplainRequest, + user_id: str = Depends(get_current_user_id), +): + """词/句解读,发起即占位写入 vocabulary,完成后回填 explanation_json。""" + # 确认文章存在(归属校验) + _get_article_or_404(article_id, user_id) + + item_type = _resolve_item_type(req.text, None, req.kind) + + # 入口占位写入 + _auto_save_library_vocab( + user_id=user_id, + article_id=article_id, + text=req.text, + context=req.context or None, + item_type=item_type, + ) + + try: + result = await explain_text( + text=req.text, + kind=req.kind, + user_id=user_id, + context=req.context or "", + force=req.refresh, + ) + except ValueError as e: + raise HTTPException(400, str(e)) + except Exception as e: + logger.error("library_explain error: %s", e, exc_info=True) + raise HTTPException(503, "解读服务暂时不可用,请稍后重试") + + # 回填 + if result.get("explanation"): + _backfill_library_vocab( + user_id=user_id, + article_id=article_id, + text=req.text, + explanation_json=json.dumps(result["explanation"], ensure_ascii=False), + force=req.refresh, + ) + + return result + + +@router.post("/articles/{article_id}/explain/phonetic") +async def library_explain_phonetic( + article_id: int, + req: QuickPhoneticRequest, + user_id: Optional[str] = Depends(get_current_user_id_optional), +): + """单词 IPA 秒出;占位写入 vocabulary。""" + if not req.text or not req.text.strip(): + raise HTTPException(400, "text is required") + + if user_id: + item_type = _resolve_item_type(req.text, req.item_type, "word") + _auto_save_library_vocab( + user_id=user_id, + article_id=article_id, + text=req.text, + context=None, + item_type=item_type, + ) + + return {"phonetic": quick_phonetic(req.text)} + + +@router.post("/articles/{article_id}/explain/stream") +async def library_explain_stream( + article_id: int, + req: StreamExplainRequest, + user_id: str = Depends(get_current_user_id), +): + """句子解读流式接口(NDJSON)。入口占位写入,流完成后回填。""" + if not req.text or not req.text.strip(): + raise HTTPException(400, "text is required") + + # 确认文章存在 + _get_article_or_404(article_id, user_id) + + item_type = _resolve_item_type(req.text, req.item_type, "sentence") + + # 入口占位写入 + _auto_save_library_vocab( + user_id=user_id, + article_id=article_id, + text=req.text, + context=req.context or None, + item_type=item_type, + ) + + async def event_stream(): + collected: dict = {} + try: + async for line in stream_sentence_explanation(req.text, user_id, force=req.refresh): + try: + evt = json.loads(line) + except Exception: + evt = None + if isinstance(evt, dict): + if evt.get("_cached") and isinstance(evt.get("explanation"), dict): + collected = dict(evt["explanation"]) + elif "field" in evt and "value" in evt: + collected[evt["field"]] = evt["value"] + yield line + "\n" + except ValueError as e: + yield json.dumps({"_error": str(e)}, ensure_ascii=False) + "\n" + return + except Exception as e: + logger.error("library stream explain error: %s", e, exc_info=True) + yield json.dumps({"_error": "解读服务暂时不可用"}, ensure_ascii=False) + "\n" + return + + # 流末尾回填 + if collected: + _backfill_library_vocab( + user_id=user_id, + article_id=article_id, + text=req.text, + explanation_json=json.dumps(collected, ensure_ascii=False), + force=req.refresh, + ) + + return StreamingResponse(event_stream(), media_type="application/x-ndjson") diff --git a/app/services/library_service.py b/app/services/library_service.py new file mode 100644 index 0000000..fe2519e --- /dev/null +++ b/app/services/library_service.py @@ -0,0 +1,285 @@ +"""文章库服务 — 用户自带技术文档的导入、管理与列表 + +支持两种导入方式: + - URL 模式:trafilatura 提取正文 Markdown + meta + - Markdown 模式:直接粘贴正文 +""" +import json +import logging +from typing import Dict, List, Optional + +from sqlalchemy import func as sql_func +from sqlalchemy.orm import Session as OrmSession +from sqlalchemy.exc import IntegrityError + +from app.models.db import engine, LibraryArticle, Vocabulary +from app.logger import get_logger + +logger = get_logger("library_service") + +MAX_MARKDOWN_LEN = 100_000 + + +def _extract_from_url(url: str): + """Extract markdown + meta from URL using trafilatura. + + Returns (markdown: str, meta: dict). + Raises ValueError("fetch_failed") on any failure. + """ + try: + import trafilatura + except ImportError: + raise ValueError("trafilatura not installed") + + downloaded = trafilatura.fetch_url(url) + if not downloaded: + raise ValueError("fetch_failed") + + result = trafilatura.extract( + downloaded, + output_format="markdown", + include_links=True, + include_tables=True, + with_metadata=True, + ) + if not result: + raise ValueError("fetch_failed") + + meta = {} + try: + meta_obj = trafilatura.extract_metadata(downloaded) + if meta_obj: + meta = { + "author": meta_obj.author, + "site_name": meta_obj.sitename, + "title": meta_obj.title, + } + except Exception: + pass + + return result, meta + + +def _title_from_markdown(markdown: str) -> Optional[str]: + """Extract first # heading from markdown as title fallback.""" + for line in markdown.splitlines(): + stripped = line.strip() + if stripped.startswith("# "): + return stripped[2:].strip() + return None + + +def _article_to_dict(article: LibraryArticle, vocab_count: int = 0) -> Dict: + return { + "id": article.id, + "user_id": article.user_id, + "source_url": article.source_url, + "title": article.title, + "markdown": article.markdown, + "word_count": article.word_count, + "source_meta": json.loads(article.source_meta) if article.source_meta else {}, + "status": article.status, + "vocab_count": vocab_count, + "created_at": article.created_at.isoformat() if article.created_at else None, + "updated_at": article.updated_at.isoformat() if article.updated_at else None, + } + + +def import_article( + user_id: str, + url: Optional[str] = None, + markdown: Optional[str] = None, + title: Optional[str] = None, +) -> Dict: + """Import an article (URL or paste-markdown mode). + + URL dedup: if (user_id, source_url) already exists, return existing with existing=True. + Returns dict with extra key 'existing' (bool). + """ + if not url and not markdown: + raise ValueError("url 或 markdown 必须提供其一") + + source_meta: Dict = {} + + if url: + # Check dedup first (before fetching) + with OrmSession(engine) as s: + existing = ( + s.query(LibraryArticle) + .filter_by(user_id=user_id, source_url=url) + .first() + ) + if existing: + logger.info( + "library_article_dedup_hit user_id=%s url=%s id=%d", + user_id, url, existing.id, + ) + result = _article_to_dict(existing, _count_vocab(s, user_id, existing.id)) + result["existing"] = True + return result + + # Fetch URL + try: + markdown, source_meta = _extract_from_url(url) + except ValueError as e: + logger.warning("library_article_fetch_failed user_id=%s url=%s reason=%s", user_id, url, e) + raise + + # Title: meta > first # heading > Untitled + if not title: + title = source_meta.get("title") or _title_from_markdown(markdown) or "Untitled" + else: + # Paste mode: title required by caller, but provide fallback + if not title: + title = _title_from_markdown(markdown) or "Untitled" + + # Truncate if too long + truncated = False + if len(markdown) > MAX_MARKDOWN_LEN: + markdown = markdown[:MAX_MARKDOWN_LEN] + truncated = True + logger.warning( + "library_article_truncated user_id=%s url=%s char_count=%d", + user_id, url, MAX_MARKDOWN_LEN, + ) + source_meta["truncated"] = True + + word_count = len(markdown.split()) + + with OrmSession(engine) as s: + article = LibraryArticle( + user_id=user_id, + source_url=url or None, + title=title, + markdown=markdown, + word_count=word_count, + source_meta=json.dumps(source_meta, ensure_ascii=False) if source_meta else None, + status="active", + ) + s.add(article) + try: + s.commit() + except IntegrityError: + s.rollback() + # Race condition: another request inserted the same URL + existing = ( + s.query(LibraryArticle) + .filter_by(user_id=user_id, source_url=url) + .first() + ) + if existing: + result = _article_to_dict(existing, _count_vocab(s, user_id, existing.id)) + result["existing"] = True + return result + raise + s.refresh(article) + logger.info( + "library_article_imported user_id=%s id=%d mode=%s char_count=%d", + user_id, article.id, "url" if url else "markdown", len(markdown), + ) + result = _article_to_dict(article, 0) + result["existing"] = False + return result + + +def _count_vocab(session: OrmSession, user_id: str, article_id: int) -> int: + """Count vocabulary items for this article.""" + count = ( + session.query(sql_func.count(Vocabulary.id)) + .filter_by(user_id=user_id, source_type="library", source_ref=str(article_id)) + .scalar() + ) + return count or 0 + + +def list_articles( + user_id: str, + page: int = 1, + page_size: int = 20, +) -> List[Dict]: + """List active articles for user, ordered by created_at DESC, with vocab_count.""" + offset = (page - 1) * page_size + with OrmSession(engine) as s: + rows = ( + s.query(LibraryArticle) + .filter_by(user_id=user_id, status="active") + .order_by(LibraryArticle.created_at.desc()) + .offset(offset) + .limit(page_size) + .all() + ) + result = [] + for article in rows: + d = _article_to_dict(article, _count_vocab(s, user_id, article.id)) + # Don't include full markdown in list (bandwidth) + d.pop("markdown", None) + result.append(d) + return result + + +def get_article(article_id: int, user_id: str) -> Optional[Dict]: + """Get article detail (including full markdown) with vocab_count.""" + with OrmSession(engine) as s: + article = ( + s.query(LibraryArticle) + .filter_by(id=article_id, user_id=user_id, status="active") + .first() + ) + if not article: + return None + return _article_to_dict(article, _count_vocab(s, user_id, article.id)) + + +def delete_article(article_id: int, user_id: str) -> bool: + """Soft delete article. vocabulary items are NOT cascade-deleted.""" + with OrmSession(engine) as s: + article = ( + s.query(LibraryArticle) + .filter_by(id=article_id, user_id=user_id, status="active") + .first() + ) + if not article: + return False + article.status = "deleted" + s.commit() + return True + + +def refetch_article(article_id: int, user_id: str) -> Dict: + """Re-extract content from URL. Only valid for URL-based articles.""" + with OrmSession(engine) as s: + article = ( + s.query(LibraryArticle) + .filter_by(id=article_id, user_id=user_id, status="active") + .first() + ) + if not article: + raise LookupError("文章不存在或已删除") + if not article.source_url: + raise ValueError("paste_mode_no_url") + + try: + markdown, source_meta = _extract_from_url(article.source_url) + except ValueError as e: + logger.warning( + "library_article_fetch_failed user_id=%s url=%s reason=%s", + user_id, article.source_url, e, + ) + raise + + truncated = False + if len(markdown) > MAX_MARKDOWN_LEN: + markdown = markdown[:MAX_MARKDOWN_LEN] + source_meta["truncated"] = True + logger.warning( + "library_article_truncated user_id=%s url=%s", + user_id, article.source_url, + ) + + article.markdown = markdown + article.word_count = len(markdown.split()) + if source_meta: + article.source_meta = json.dumps(source_meta, ensure_ascii=False) + s.commit() + s.refresh(article) + return _article_to_dict(article, _count_vocab(s, user_id, article.id)) diff --git a/app/services/vocab_service.py b/app/services/vocab_service.py index 6cbf1fb..175f325 100644 --- a/app/services/vocab_service.py +++ b/app/services/vocab_service.py @@ -25,7 +25,7 @@ logger = get_logger("vocab_service") VALID_ITEM_TYPES = {"word", "phrase", "sentence"} -VALID_SOURCE_TYPES = {"translate", "article", "practice", "chat"} +VALID_SOURCE_TYPES = {"translate", "article", "practice", "chat", "library"} def _to_dict(v: Vocabulary) -> Dict: diff --git a/frontend/src/config.js b/frontend/src/config.js index 4a66662..c106df9 100644 --- a/frontend/src/config.js +++ b/frontend/src/config.js @@ -15,6 +15,7 @@ export const API = { MEMORY: '/memory', PRACTICE: '/practice', ARTICLES: '/articles', // V0.8:文章学习模块新名;后端 /practice/* alias 仍在 + LIBRARY: '/library', // V0.8:用户文章库 TRANSLATE: '/translate', VOCAB: '/vocab', POLISH: '/polish', diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index cb5f191..c632933 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -71,6 +71,18 @@ const router = createRouter({ component: BbcArticleReview, meta: { requiresAuth: true }, }, + { + path: '/library', + name: 'library', + component: () => import('@/views/Library.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/library/:id', + name: 'library-article', + component: () => import('@/views/LibraryArticle.vue'), + meta: { requiresAuth: true }, + }, { path: '/login', name: 'login', component: Login, meta: { requiresAuth: false } }, { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound }, ], diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index ef66094..a70c471 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -149,6 +149,10 @@ onMounted(() => { 🎧 文章学习 + + 📚 + 文章库 + 🌐 翻译 diff --git a/frontend/src/views/Library.vue b/frontend/src/views/Library.vue new file mode 100644 index 0000000..cd6bea2 --- /dev/null +++ b/frontend/src/views/Library.vue @@ -0,0 +1,524 @@ + + + + + + 我的文章库 + + 添加文章 + + + + + + 加载中... + + 暂无文章,点击右上角「添加文章」导入第一篇 + + + + {{ article.title }} + + {{ hostOf(article.source_url) }} + {{ article.word_count.toLocaleString() }} 词 + ~{{ readingMinutes(article.word_count) }} 分钟读 + + + + + + + + {{ loadingMore ? '加载中...' : '加载更多' }} + + + + + + + + + 添加文章 + × + + + + 粘贴 URL + 粘贴正文 + + + + + 文章 URL + + {{ importError }} + {{ fetchFailHint }} + + + + + 标题(必填) + + 正文(Markdown) + + {{ importError }} + + + + + + + + + + + diff --git a/frontend/src/views/LibraryArticle.vue b/frontend/src/views/LibraryArticle.vue new file mode 100644 index 0000000..16a362a --- /dev/null +++ b/frontend/src/views/LibraryArticle.vue @@ -0,0 +1,868 @@ + + + + + ← 返回 + {{ article?.title || '加载中...' }} + 删除 + + + 加载中... + 文章不存在或已删除 + + + + + + + + + + + {{ bubble.ipa || '...' }} + 展开解读 + + + + 翻译 + + + + + + + + + + + + + {{ explainModal.text }} + × + + 解读中... + {{ explainModal.error }} + + + /{{ explainModal.data.phonetic }}/ + + {{ explainModal.data.meaning }} + {{ explainModal.data.grammar }} + + {{ d }} + + + {{ e }} + + + + + + + + + + diff --git a/main.py b/main.py index e4e5f91..578bbbf 100644 --- a/main.py +++ b/main.py @@ -21,6 +21,7 @@ from app.routers.vocab import router as vocab_router from app.routers.polish import router as polish_router from app.routers.model import router as model_router +from app.routers.library import router as library_router # ── V0.8 前端重构挂载策略(Pass 3 plan §4 + Critic B1-B5)──────────── @@ -49,12 +50,13 @@ "translate/", "vocab/", "polish/", + "library/", "auth/", "ask/", "assessment/", "settings/", "stats/", - "bbc-eaw/", + # "bbc-eaw/" 已迁移到 articles/episodes/*,articles/ 前缀已覆盖 "health", "debug/", "legacy/", @@ -100,6 +102,7 @@ async def lifespan(app: FastAPI): app.include_router(vocab_router) app.include_router(polish_router) app.include_router(model_router) +app.include_router(library_router) # 数据目录挂载(audio_cache、tts_cache 等)——无论新旧前端都要读 app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/requirements.txt b/requirements.txt index b07cc0e..2974f8e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,3 +15,4 @@ yt-dlp>=2024.0.0 bcrypt==4.2.0 PyJWT==2.9.0 eng-to-ipa==0.0.2 +trafilatura>=2.0.0 diff --git a/tests/test_v08_library.py b/tests/test_v08_library.py new file mode 100644 index 0000000..60d4fc5 --- /dev/null +++ b/tests/test_v08_library.py @@ -0,0 +1,367 @@ +"""Library module tests — V0.8 + +Tests cover: + - test_import_article_markdown: paste mode creates article + - test_import_article_url_dedup: same URL twice returns existing + - test_list_articles: pagination, only active + - test_delete_article_soft: soft delete, vocab not affected + - test_explain_creates_vocab: explain endpoint auto-saves to vocab with source_type='library' +""" +import json +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session as OrmSession + +from main import app +from app.models.db import engine, LibraryArticle, Vocabulary, init_db +from app.routers.auth import get_current_user_id, get_current_user_id_optional + +USER = "test_user_v08_library_task1" +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def _override_user(): + app.dependency_overrides[get_current_user_id] = lambda: USER + app.dependency_overrides[get_current_user_id_optional] = lambda: USER + yield + app.dependency_overrides.pop(get_current_user_id, None) + app.dependency_overrides.pop(get_current_user_id_optional, None) + + +@pytest.fixture(autouse=True) +def _cleanup(): + init_db() + with OrmSession(engine) as s: + s.query(LibraryArticle).filter( + LibraryArticle.user_id.like("test_user_v08_library_%") + ).delete() + s.query(Vocabulary).filter( + Vocabulary.user_id.like("test_user_v08_library_%") + ).delete() + s.commit() + yield + with OrmSession(engine) as s: + s.query(LibraryArticle).filter( + LibraryArticle.user_id.like("test_user_v08_library_%") + ).delete() + s.query(Vocabulary).filter( + Vocabulary.user_id.like("test_user_v08_library_%") + ).delete() + s.commit() + + +@pytest.fixture +def stub_explain(monkeypatch): + """Stub out LLM calls to avoid real API requests.""" + from app.services import explain_service + import app.routers.library as lr + + async def _fake(text, kind, user_id, context="", force=False): + return { + "explanation": {"meaning": "stub-" + text, "phonetic": "stʌb"}, + "cefr_level": "B1", + "cached": False, + } + + monkeypatch.setattr(explain_service, "explain_text", _fake) + monkeypatch.setattr(lr, "explain_text", _fake) + + +# ── Tests ────────────────────────────────────────────────── + +def test_import_article_markdown(): + """Paste mode: creates article with correct fields.""" + resp = client.post( + "/library/articles", + json={ + "markdown": "# Hello World\n\nThis is a test article with some words.", + "title": "Hello World", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "Hello World" + assert data["status"] == "active" + assert data["word_count"] > 0 + assert data["source_url"] is None + assert data["existing"] is False + + +def test_import_article_markdown_title_from_heading(): + """Paste mode: title extracted from first # heading when not provided.""" + resp = client.post( + "/library/articles", + json={ + "markdown": "# Extracted Title\n\nContent here.", + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "Extracted Title" + + +def test_import_article_url_dedup(): + """Same URL imported twice returns existing article with existing=True.""" + # First import using mock trafilatura + fake_md = "# Spec Driven Development\n\nSome content about spec driven development." + fake_meta = {"title": "Spec Driven Development", "site_name": "example.com", "author": None} + + with patch("app.services.library_service._extract_from_url") as mock_extract: + mock_extract.return_value = (fake_md, fake_meta) + resp1 = client.post( + "/library/articles", + json={"url": "https://example.com/spec-driven"}, + ) + + assert resp1.status_code == 201 + data1 = resp1.json() + assert data1["existing"] is False + article_id = data1["id"] + + # Second import of same URL + with patch("app.services.library_service._extract_from_url") as mock_extract: + mock_extract.return_value = (fake_md, fake_meta) + resp2 = client.post( + "/library/articles", + json={"url": "https://example.com/spec-driven"}, + ) + + assert resp2.status_code == 200 + data2 = resp2.json() + assert data2["existing"] is True + assert data2["id"] == article_id + + +def test_import_article_url_fetch_failed(): + """Fetch failure returns 400 with fetch_failed error.""" + with patch("app.services.library_service._extract_from_url") as mock_extract: + mock_extract.side_effect = ValueError("fetch_failed") + resp = client.post( + "/library/articles", + json={"url": "https://example.com/fail"}, + ) + + assert resp.status_code == 400 + data = resp.json() + assert data["detail"]["error"] == "fetch_failed" + assert "粘贴正文模式" in data["detail"]["hint"] + + +def test_list_articles(): + """List returns only active articles with pagination.""" + # Insert 3 articles + ids = [] + for i in range(3): + resp = client.post( + "/library/articles", + json={ + "markdown": f"# Article {i}\n\nContent {i}.", + "title": f"Article {i}", + }, + ) + assert resp.status_code == 201 + ids.append(resp.json()["id"]) + + # All articles are listed + resp = client.get("/library/articles?page=1&page_size=10") + assert resp.status_code == 200 + data = resp.json() + assert data["page"] == 1 + assert data["page_size"] == 10 + assert len(data["articles"]) == 3 + + # Pagination works + resp = client.get("/library/articles?page=1&page_size=2") + assert resp.status_code == 200 + data = resp.json() + assert len(data["articles"]) == 2 + + resp2 = client.get("/library/articles?page=2&page_size=2") + assert resp2.status_code == 200 + data2 = resp2.json() + assert len(data2["articles"]) == 1 + + # All returned articles belong to current user + all_ids = [a["id"] for a in data["articles"]] + [a["id"] for a in data2["articles"]] + for aid in ids: + assert aid in all_ids + + +def test_list_excludes_deleted(): + """Deleted articles do not appear in list.""" + resp = client.post( + "/library/articles", + json={"markdown": "# Deleted Article\n\nContent.", "title": "Deleted Article"}, + ) + assert resp.status_code == 201 + article_id = resp.json()["id"] + + # Delete it + del_resp = client.delete(f"/library/articles/{article_id}") + assert del_resp.status_code == 200 + + # List should be empty + list_resp = client.get("/library/articles") + assert list_resp.status_code == 200 + assert len(list_resp.json()["articles"]) == 0 + + +def test_delete_article_soft(): + """Soft delete: article disappears from list but vocab remains.""" + # Create article + resp = client.post( + "/library/articles", + json={"markdown": "# Test\n\nSome words here.", "title": "Test"}, + ) + assert resp.status_code == 201 + article_id = resp.json()["id"] + + # Manually add a vocab item tied to this article + from app.services import vocab_service + vocab_service.save_item( + user_id=USER, + source_text="some words", + translated_text="一些词", + direction="en2zh", + item_type="phrase", + source_type="library", + source_ref=str(article_id), + ) + + # Soft delete article + del_resp = client.delete(f"/library/articles/{article_id}") + assert del_resp.status_code == 200 + assert del_resp.json()["deleted"] is True + + # Article gone from list + list_resp = client.get("/library/articles") + assert list_resp.status_code == 200 + articles = list_resp.json()["articles"] + ids = [a["id"] for a in articles] + assert article_id not in ids + + # Vocab still exists (not cascade deleted) + from app.services import vocab_service as vs + items = vs.list_items(user_id=USER, source_type="library", source_ref=str(article_id)) + assert len(items) == 1 + assert items[0]["source_text"] == "some words" + + +def test_get_article_detail(): + """GET /library/articles/{id} returns full markdown.""" + resp = client.post( + "/library/articles", + json={"markdown": "# Detail Test\n\nFull content here.", "title": "Detail Test"}, + ) + assert resp.status_code == 201 + article_id = resp.json()["id"] + + get_resp = client.get(f"/library/articles/{article_id}") + assert get_resp.status_code == 200 + data = get_resp.json() + assert data["id"] == article_id + assert "Full content here" in data["markdown"] + + +def test_get_article_not_found(): + """GET on non-existent article returns 404.""" + resp = client.get("/library/articles/999999") + assert resp.status_code == 404 + + +def test_explain_creates_vocab(stub_explain): + """explain endpoint auto-saves to vocab with source_type='library'.""" + # Create article + resp = client.post( + "/library/articles", + json={"markdown": "# Explain Test\n\nSome content.", "title": "Explain Test"}, + ) + assert resp.status_code == 201 + article_id = resp.json()["id"] + + # Call explain + explain_resp = client.post( + f"/library/articles/{article_id}/explain", + json={"text": "spec-driven", "kind": "word"}, + ) + assert explain_resp.status_code == 200 + + # Check vocab was created + from app.services import vocab_service as vs + items = vs.list_items(user_id=USER, source_type="library", source_ref=str(article_id)) + assert len(items) == 1 + item = items[0] + assert item["source_text"] == "spec-driven" + assert item["source_type"] == "library" + assert item["source_ref"] == str(article_id) + # explanation_json should be backfilled + assert item["explanation_json"] is not None + parsed = json.loads(item["explanation_json"]) + assert parsed["meaning"] == "stub-spec-driven" + + +def test_phonetic_creates_vocab(): + """phonetic endpoint saves placeholder to vocab.""" + resp = client.post( + "/library/articles", + json={"markdown": "# IPA Test\n\nContent.", "title": "IPA Test"}, + ) + assert resp.status_code == 201 + article_id = resp.json()["id"] + + phonetic_resp = client.post( + f"/library/articles/{article_id}/explain/phonetic", + json={"text": "development"}, + ) + assert phonetic_resp.status_code == 200 + + # Vocab placeholder should exist + from app.services import vocab_service as vs + items = vs.list_items(user_id=USER, source_type="library", source_ref=str(article_id)) + assert len(items) == 1 + assert items[0]["source_text"] == "development" + assert items[0]["source_type"] == "library" + + +def test_refetch_paste_mode_returns_400(): + """Refetch on paste-mode article (no URL) returns 400.""" + resp = client.post( + "/library/articles", + json={"markdown": "# Paste\n\nContent.", "title": "Paste"}, + ) + assert resp.status_code == 201 + article_id = resp.json()["id"] + + refetch_resp = client.post(f"/library/articles/{article_id}/refetch") + assert refetch_resp.status_code == 400 + + +def test_vocab_count_in_list(): + """Article list includes vocab_count per article.""" + resp = client.post( + "/library/articles", + json={"markdown": "# VocabCount\n\nWords.", "title": "VocabCount"}, + ) + assert resp.status_code == 201 + article_id = resp.json()["id"] + + # Add 2 vocab items + from app.services import vocab_service as vs + vs.save_item( + user_id=USER, source_text="word1", translated_text="词1", + direction="en2zh", item_type="word", source_type="library", + source_ref=str(article_id), + ) + vs.save_item( + user_id=USER, source_text="word2", translated_text="词2", + direction="en2zh", item_type="word", source_type="library", + source_ref=str(article_id), + ) + + list_resp = client.get("/library/articles") + assert list_resp.status_code == 200 + articles = list_resp.json()["articles"] + assert len(articles) == 1 + assert articles[0]["vocab_count"] == 2
暂无文章,点击右上角「添加文章」导入第一篇
{{ importError }}
{{ fetchFailHint }}