+ 收藏的译文可在
+
{{ error }}
- -
- 收藏的译文可在
-
diff --git a/.claude/Claude.md b/.claude/Claude.md
index ed66e9b..0b2a6be 100644
--- a/.claude/Claude.md
+++ b/.claude/Claude.md
@@ -101,10 +101,10 @@ speakeasy/
| user_facts | V0.3 | LLM 提取的跨会话事实记忆 |
| pronunciation_cards | V0.4 | FSRS 管理的发音练习卡片 |
| subtitle_sources | V0.4 | B站字幕缓存 |
-| vocabulary | V0.5 | 翻译生词本收藏(软删除,无 FSRS)(V0.8 起 explain 接口发起即自动入库,explanation_json 由解读完成回填) |
+| vocabulary | V0.5 | 翻译生词本收藏(软删除)(V0.8 起 explain 自动入库;source_type='article' + series 字段区分来源系列) |
| ask_threads | V0.7 | 跨页面通用追问 thread(scope + ref_type/ref_id) |
| ask_messages | V0.7 | 追问 thread 的消息列表 |
-| bbc_eaw_episodes | V0.7.x | BBC English at Work 67 集语料(共享,不绑 user_id),见 `docs/datasets/bbc_eaw.md` |
+| article_episodes | V0.7.x | 文章系列语料(共享,不绑 user_id),series 字段区分 bbc_eaw/voa 等(原 bbc_eaw_episodes,Issue #42 重命名) |
### API 接口清单
@@ -140,6 +140,12 @@ speakeasy/
| `/articles/explain` | POST | V0.8 | 单次词/句解读,发起即自动写入 vocabulary |
| `/articles/tts` | POST | V0.8 | 文章 TTS 语音合成 |
| `/vocabulary` | GET | V0.8 | 生词本独立页(支持 source_type 过滤) |
+| `/articles/episodes` | GET | #42 | 文章系列列表(原 /bbc-eaw/episodes,支持 series 过滤) |
+| `/articles/episodes/{slug}` | GET | #42 | 单集详情(原 /bbc-eaw/episodes/{slug}) |
+| `/articles/episodes/{slug}/start` | POST | #42 | 开始学习(原 /bbc-eaw/{slug}/start) |
+| `/articles/episodes/{slug}/review` | GET | #42 | 文章复习题(原 /bbc-eaw/{slug}/review) |
+| `/articles/episodes/{slug}/rate` | POST | #42 | FSRS 评分(原 /bbc-eaw/{slug}/rate) |
+| `/articles/due` | GET | #42 | 到期文章列表(原 /bbc-eaw/due) |
| `/ask/threads` | POST | V0.7 | 通用追问:新建 thread + 首轮问答 |
| `/ask/threads/{id}/messages` | POST | V0.7 | 追问某 thread(带历史) |
| `/ask/threads` | GET | V0.7 | 列出 thread(按 scope/ref_type/ref_id 过滤) |
diff --git a/app/models/db.py b/app/models/db.py
index ee56c56..4cae023 100644
--- a/app/models/db.py
+++ b/app/models/db.py
@@ -255,6 +255,9 @@ class ArticleEpisode(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+BbcEawEpisode = ArticleEpisode # 向后兼容别名
+
+
# ── V0.10 BBC 文章级 SRS ──────────────────────────────────
# 每个 (user, slug) 一张 FSRS 卡,文章作为复习单元
# 题目按 slug 共享,懒加载首次复习时由 LLM 生成
diff --git a/app/routers/articles.py b/app/routers/articles.py
index 69f473b..cc13164 100644
--- a/app/routers/articles.py
+++ b/app/routers/articles.py
@@ -81,8 +81,9 @@ class ExplainRequest(BaseModel):
context: Optional[str] = "" # word 模式下可提供所在句子
refresh: bool = False # V0.11 #8 · 跳过缓存强制重新生成
# V0.8 自动入库:可选,传了就在解读入口自动写 vocabulary
- source_type: Optional[str] = None # 'bbc_eaw' | 'translate' | ...
- source_ref: Optional[str] = None # 例: BBC episode slug
+ source_type: Optional[str] = None # 'article' | 'translate' | ...
+ source_ref: Optional[str] = None # 例: episode slug
+ series: Optional[str] = None # bbc_eaw | voa | ... (when source_type='article')
item_type: Optional[str] = None # 'word' | 'phrase' | 'sentence',缺省由 kind 推断
@@ -346,6 +347,7 @@ def _auto_save_vocab(
source_type: Optional[str],
source_ref: Optional[str],
item_type: str,
+ series: Optional[str] = None,
) -> bool:
"""解读发起时占位写入 vocabulary(explanation_json=None)。
@@ -369,10 +371,11 @@ def _auto_save_vocab(
source_type=source_type,
source_ref=source_ref,
explanation_json=None,
+ series=series,
)
logger.info(
- "vocab_auto_save_attempt user_id=%s text=%s source_type=%s source_ref=%s item_type=%s",
- user_id, text[:40], source_type, source_ref, item_type,
+ "vocab_auto_save_attempt user_id=%s text=%s source_type=%s series=%s source_ref=%s item_type=%s",
+ user_id, text[:40], source_type, series, source_ref, item_type,
)
return True
except Exception as e:
@@ -395,7 +398,7 @@ async def practice_explain(
_auto_save_vocab(
user_id=user_id, text=req.text, context=req.context,
source_type=req.source_type, source_ref=req.source_ref,
- item_type=item_type,
+ item_type=item_type, series=req.series,
)
try:
@@ -433,6 +436,7 @@ class QuickPhoneticRequest(BaseModel):
source_type: Optional[str] = None
source_ref: Optional[str] = None
item_type: Optional[str] = None # 前端推断后传入,避免与后续 explain 不一致
+ series: Optional[str] = None # bbc_eaw | voa | ...
@router.post("/explain/phonetic")
@@ -449,7 +453,7 @@ async def practice_explain_phonetic(
_auto_save_vocab(
user_id=user_id, text=req.text, context=None,
source_type=req.source_type, source_ref=req.source_ref,
- item_type=item_type,
+ item_type=item_type, series=req.series,
)
return {"phonetic": quick_phonetic(req.text)}
@@ -461,6 +465,7 @@ class StreamExplainRequest(BaseModel):
source_type: Optional[str] = None
source_ref: Optional[str] = None
item_type: Optional[str] = None
+ series: Optional[str] = None # bbc_eaw | voa | ...
context: Optional[str] = "" # 句子级 context(如所在段落)
@@ -485,7 +490,7 @@ async def practice_explain_stream(
_auto_save_vocab(
user_id=user_id, text=req.text, context=req.context,
source_type=req.source_type, source_ref=req.source_ref,
- item_type=item_type,
+ item_type=item_type, series=req.series,
)
async def event_stream():
diff --git a/app/routers/bbc_eaw.py b/app/routers/bbc_eaw.py
index 1b596ff..32a7418 100644
--- a/app/routers/bbc_eaw.py
+++ b/app/routers/bbc_eaw.py
@@ -1,4 +1,7 @@
-"""BBC English at Work 公共语料 API · 共享只读,不绑 user_id"""
+"""文章系列公共语料 API · 共享只读,不绑 user_id
+
+路由前缀:/articles/episodes(由 main.py 注册)
+"""
import json
from typing import Optional
@@ -6,26 +9,30 @@
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy.orm import Session as OrmSession
-from app.models.db import BbcEawEpisode, engine
+from app.models.db import ArticleEpisode, engine
router = APIRouter()
-@router.get("/bbc-eaw/episodes")
+@router.get("/articles/episodes")
async def list_episodes(
limit: int = Query(100, ge=1, le=200),
topic: Optional[str] = None,
+ series: Optional[str] = None,
):
- """列出 EAW 集 · 公共数据,无需登录"""
+ """列出文章集 · 公共数据,无需登录"""
with OrmSession(engine) as s:
- q = s.query(BbcEawEpisode).order_by(BbcEawEpisode.id.asc())
+ q = s.query(ArticleEpisode).order_by(ArticleEpisode.id.asc())
if topic:
- q = q.filter(BbcEawEpisode.topic == topic)
+ q = q.filter(ArticleEpisode.topic == topic)
+ if series:
+ q = q.filter(ArticleEpisode.series == series)
rows = q.limit(limit).all()
return {
"items": [
{
"slug": r.slug,
+ "series": r.series,
"title": r.title,
"episode_id": r.episode_id,
"topic": r.topic,
@@ -39,19 +46,17 @@ async def list_episodes(
}
-@router.get("/bbc-eaw/episodes/{slug}")
+@router.get("/articles/episodes/{slug}")
async def get_episode(slug: str):
"""取单集详情 · 把 transcript 转成 PracticePlayer 期待的 segments 形态"""
with OrmSession(engine) as s:
- row = s.query(BbcEawEpisode).filter_by(slug=slug).first()
+ row = s.query(ArticleEpisode).filter_by(slug=slug).first()
if not row:
raise HTTPException(404, "episode not found")
transcript = json.loads(row.transcript_json or "[]")
phrases = json.loads(row.phrases_json or "[]")
- # transcript: [{speaker, text}] → segments: [{content, speaker}]
- # PracticePlayer 用 seg.content 做主键,无时间戳就用顺序 idx 做兜底
segments = [
{
"content": item.get("text", ""),
@@ -68,7 +73,8 @@ async def get_episode(slug: str):
"title": row.title,
"topic": row.topic,
"description": row.description,
- "source_type": "bbc_eaw",
+ "series": row.series,
+ "source_type": "article",
"segments": segments,
"phrases": phrases,
"listening_question": row.listening_question,
diff --git a/app/routers/bbc_review.py b/app/routers/bbc_review.py
index bfcf896..0edea9a 100644
--- a/app/routers/bbc_review.py
+++ b/app/routers/bbc_review.py
@@ -1,4 +1,7 @@
-"""BBC 文章级 SRS 路由 — V0.10"""
+"""文章级 SRS 路由 — V0.10
+
+路由前缀:/articles/episodes(由 main.py 注册)
+"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
@@ -25,7 +28,7 @@ class RateRequest(BaseModel):
wrong_segments: Optional[List[WrongSegment]] = None
-@router.post("/bbc-eaw/{slug}/start", status_code=201)
+@router.post("/articles/episodes/{slug}/start", status_code=201)
async def start(slug: str, user_id: str = Depends(get_current_user_id)):
try:
return bbc_article_srs.start_studying(user_id, slug)
@@ -33,7 +36,7 @@ async def start(slug: str, user_id: str = Depends(get_current_user_id)):
raise HTTPException(404, str(e))
-@router.get("/bbc-eaw/{slug}/review")
+@router.get("/articles/episodes/{slug}/review")
async def review(slug: str, user_id: str = Depends(get_current_user_id)):
"""取该文章的复习题;未生成则懒加载首次生成。"""
try:
@@ -45,7 +48,7 @@ async def review(slug: str, user_id: str = Depends(get_current_user_id)):
return {"slug": slug, "questions": questions, "count": len(questions)}
-@router.post("/bbc-eaw/{slug}/rate")
+@router.post("/articles/episodes/{slug}/rate")
async def rate(
slug: str,
req: RateRequest,
@@ -60,7 +63,7 @@ async def rate(
raise HTTPException(404, str(e))
-@router.get("/bbc-eaw/due")
+@router.get("/articles/due")
async def due(user_id: str = Depends(get_current_user_id)):
items = bbc_article_srs.list_due_articles(user_id)
return {"items": items, "count": len(items)}
diff --git a/app/routers/vocab.py b/app/routers/vocab.py
index d4af64f..42dcd7e 100644
--- a/app/routers/vocab.py
+++ b/app/routers/vocab.py
@@ -32,9 +32,10 @@ class VocabCreate(BaseModel):
direction: str = "en2zh"
context: Optional[str] = None
item_type: str = "sentence" # word | phrase | sentence
- source_type: str = "translate" # translate | bbc_eaw | practice | chat
+ source_type: str = "translate" # translate | article | practice | chat
source_ref: Optional[str] = None
explanation_json: Optional[str] = None
+ series: Optional[str] = None # bbc_eaw | voa | ... (when source_type='article')
class VocabRate(BaseModel):
@@ -54,6 +55,7 @@ async def create_vocab(req: VocabCreate, user_id: str = Depends(get_current_user
source_type=req.source_type,
source_ref=req.source_ref,
explanation_json=req.explanation_json,
+ series=req.series,
)
except ValueError as e:
raise HTTPException(400, str(e))
diff --git a/app/services/bbc_article_srs.py b/app/services/bbc_article_srs.py
index fec5f97..da3784c 100644
--- a/app/services/bbc_article_srs.py
+++ b/app/services/bbc_article_srs.py
@@ -1,7 +1,7 @@
"""BBC 文章级 SRS 服务 — V0.10
每个 (user_id, slug) 一张 FSRS 卡。题目按 slug 共享缓存。
-答错的句子会落入 Vocabulary(item_type=sentence、source=bbc_eaw)。
+答错的句子会落入 Vocabulary(item_type=sentence、source_type=article、series=bbc_eaw)。
"""
import json
from datetime import datetime
@@ -13,7 +13,7 @@
engine,
BbcArticleCard,
BbcArticleQuestion,
- BbcEawEpisode,
+ ArticleEpisode,
)
from app.services.fsrs_utils import (
create_card,
@@ -28,7 +28,7 @@
logger = get_logger("bbc_article_srs")
-def _card_to_dict(c: BbcArticleCard, episode: Optional[BbcEawEpisode] = None) -> Dict:
+def _card_to_dict(c: BbcArticleCard, episode: Optional[ArticleEpisode] = None) -> Dict:
return {
"id": c.id,
"user_id": c.user_id,
@@ -59,7 +59,7 @@ def _question_to_dict(q: BbcArticleQuestion) -> Dict:
def start_studying(user_id: str, slug: str) -> Dict:
"""首次学习 / 已有则返回当前卡。"""
with OrmSession(engine) as s:
- episode = s.query(BbcEawEpisode).filter_by(slug=slug).first()
+ episode = s.query(ArticleEpisode).filter_by(slug=slug).first()
if not episode:
raise LookupError(f"BBC episode not found: {slug}")
@@ -160,7 +160,7 @@ def rate_card(
s.commit()
s.refresh(card)
- episode = s.query(BbcEawEpisode).filter_by(slug=slug).first()
+ episode = s.query(ArticleEpisode).filter_by(slug=slug).first()
result = _card_to_dict(card, episode)
# 答错联动:在事务外做(vocab_service 自管 session)
@@ -186,7 +186,7 @@ def list_due_articles(user_id: str) -> List[Dict]:
slugs = {c.slug for c in cards}
episodes = {
e.slug: e
- for e in s.query(BbcEawEpisode).filter(BbcEawEpisode.slug.in_(slugs)).all()
+ for e in s.query(ArticleEpisode).filter(ArticleEpisode.slug.in_(slugs)).all()
}
result = []
for c in cards:
diff --git a/app/services/bbc_eaw_seeder.py b/app/services/bbc_eaw_seeder.py
index 2fa6fe2..dfcd60e 100644
--- a/app/services/bbc_eaw_seeder.py
+++ b/app/services/bbc_eaw_seeder.py
@@ -1,4 +1,4 @@
-"""Seed bbc_eaw_episodes from data/bbc_eaw/parsed/*.json.
+"""Seed article_episodes from data/bbc_eaw/parsed/*.json.
Idempotent upsert by `slug`. Used by:
- scripts/bbc_eaw_seed.py (CLI / one-shot)
@@ -14,7 +14,7 @@
from sqlalchemy.orm import Session as OrmSession
-from app.models.db import BbcEawEpisode, engine
+from app.models.db import ArticleEpisode, engine
log = logging.getLogger(__name__)
@@ -55,12 +55,12 @@ def seed(parsed_dir: Path = DEFAULT_PARSED_DIR,
for f in files:
parsed = json.loads(f.read_text(encoding="utf-8"))
slug = parsed["slug"]
- row = sess.query(BbcEawEpisode).filter_by(slug=slug).one_or_none()
+ row = sess.query(ArticleEpisode).filter_by(slug=slug).one_or_none()
fields = _row_fields(parsed, Path(raw_dir))
if row is None:
inserted += 1
if not dry_run:
- sess.add(BbcEawEpisode(**fields))
+ sess.add(ArticleEpisode(**fields))
else:
updated += 1
if not dry_run:
diff --git a/app/services/bbc_question_gen.py b/app/services/bbc_question_gen.py
index ce1a903..9a63e71 100644
--- a/app/services/bbc_question_gen.py
+++ b/app/services/bbc_question_gen.py
@@ -18,7 +18,7 @@
from sqlalchemy.orm import Session as OrmSession
-from app.models.db import engine, BbcEawEpisode
+from app.models.db import engine, ArticleEpisode
from app.prompts.bbc_questions import BBC_QUESTION_GEN_PROMPT
from app.logger import get_logger
@@ -27,9 +27,9 @@
VALID_QTYPES = {"cloze", "back_translate", "recall_prompt"}
-def _load_episode(slug: str) -> Optional[BbcEawEpisode]:
+def _load_episode(slug: str) -> Optional[ArticleEpisode]:
with OrmSession(engine) as s:
- return s.query(BbcEawEpisode).filter_by(slug=slug).first()
+ return s.query(ArticleEpisode).filter_by(slug=slug).first()
def _build_cloze(transcript: List[Dict], phrases: List[str]) -> Optional[Dict]:
@@ -120,7 +120,7 @@ def _validate_question(q: Dict) -> Optional[Dict]:
}
-async def _generate_via_llm(episode: BbcEawEpisode) -> List[Dict]:
+async def _generate_via_llm(episode: ArticleEpisode) -> List[Dict]:
"""LLM 出题;任何异常向上抛,由 generate_questions 捕获后回退。"""
from app.services.model_client import get_client # 延迟导入以便测试时无 LLM 也能用
@@ -158,7 +158,7 @@ async def _generate_via_llm(episode: BbcEawEpisode) -> List[Dict]:
return validated[:6]
-def _generate_deterministic(episode: BbcEawEpisode) -> List[Dict]:
+def _generate_deterministic(episode: ArticleEpisode) -> List[Dict]:
transcript = json.loads(episode.transcript_json or "[]")
phrases = json.loads(episode.phrases_json or "[]")
transcript = [t for t in transcript if (t.get("text") or "").strip()]
diff --git a/app/services/model_client.py b/app/services/model_client.py
index f822361..08ade8b 100644
--- a/app/services/model_client.py
+++ b/app/services/model_client.py
@@ -1,3 +1,4 @@
+from __future__ import annotations
import os
import time
from typing import AsyncGenerator, Optional
diff --git a/frontend/src/components/ExplanationModal.vue b/frontend/src/components/ExplanationModal.vue
index 761d1b0..ee0795e 100644
--- a/frontend/src/components/ExplanationModal.vue
+++ b/frontend/src/components/ExplanationModal.vue
@@ -204,10 +204,11 @@ const _refStr = (v) => (v == null || v === '' ? null : String(v))
function deriveVocabSource() {
// 来源由父组件透传到 target.context;下面的 fallback 兼容老调用
const ctx = props.target?.context || {}
- let source_type, raw
+ let source_type, raw, series = null
if (ctx.vocab_source_type) {
source_type = ctx.vocab_source_type
raw = ctx.vocab_source_ref
+ series = ctx.vocab_series || null
} else if (ctx.source === 'chat') {
source_type = 'chat'
raw = ctx.session_id
@@ -219,7 +220,7 @@ function deriveVocabSource() {
source_type = 'translate'
raw = null
}
- return { source_type, source_ref: _refStr(raw) }
+ return { source_type, source_ref: _refStr(raw), series }
}
const data = ref({
@@ -272,7 +273,7 @@ function reset() {
}
async function fetchWord(refresh = false) {
- const { source_type, source_ref } = deriveVocabSource()
+ const { source_type, source_ref, series } = deriveVocabSource()
// 1. IPA 快拿(本地秒出 · 不阻塞主请求)
try {
const p = await authFetchJson(`${API.ARTICLES}/explain/phonetic`, {
@@ -280,6 +281,7 @@ async function fetchWord(refresh = false) {
source_type: source_type || null,
source_ref: source_ref || null,
item_type: inferredItemType.value,
+ series: series || null,
})
data.value.phonetic = p.phonetic || ''
} catch {
@@ -297,6 +299,7 @@ async function fetchWord(refresh = false) {
source_type: source_type || null,
source_ref: source_ref || null,
item_type: inferredItemType.value,
+ series: series || null,
})
const exp = resp.explanation || {}
Object.assign(data.value, {
@@ -339,7 +342,7 @@ function _scheduleUpdate(field, value) {
async function fetchSentence(refresh = false) {
loading.value = true
- const { source_type, source_ref } = deriveVocabSource()
+ const { source_type, source_ref, series } = deriveVocabSource()
try {
await sseStream(
`${API.ARTICLES}/explain/stream`,
@@ -349,6 +352,7 @@ async function fetchSentence(refresh = false) {
source_type: source_type || null,
source_ref: source_ref || null,
item_type: inferredItemType.value,
+ series: series || null,
context: props.target.sentence || '',
},
{
diff --git a/frontend/src/components/translate/TopBar.vue b/frontend/src/components/translate/TopBar.vue
new file mode 100644
index 0000000..bb4cbe5
--- /dev/null
+++ b/frontend/src/components/translate/TopBar.vue
@@ -0,0 +1,144 @@
+
+
+
+
+ 收藏的译文可在
+
{{ error }}
- -
- 收藏的译文可在
-