Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .claude/Claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 接口清单

Expand Down Expand Up @@ -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 过滤) |
Expand Down
3 changes: 3 additions & 0 deletions app/models/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 生成
Expand Down
19 changes: 12 additions & 7 deletions app/routers/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 推断


Expand Down Expand Up @@ -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)。

Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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")
Expand All @@ -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)}

Expand All @@ -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(如所在段落)


Expand All @@ -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():
Expand Down
28 changes: 17 additions & 11 deletions app/routers/bbc_eaw.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,38 @@
"""BBC English at Work 公共语料 API · 共享只读,不绑 user_id"""
"""文章系列公共语料 API · 共享只读,不绑 user_id

路由前缀:/articles/episodes(由 main.py 注册)
"""

import json
from typing import Optional

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,
Expand All @@ -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", ""),
Expand All @@ -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,
Expand Down
13 changes: 8 additions & 5 deletions app/routers/bbc_review.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,15 +28,15 @@ 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)
except LookupError as e:
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:
Expand All @@ -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,
Expand All @@ -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)}
4 changes: 3 additions & 1 deletion app/routers/vocab.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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))
Expand Down
12 changes: 6 additions & 6 deletions app/services/bbc_article_srs.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -13,7 +13,7 @@
engine,
BbcArticleCard,
BbcArticleQuestion,
BbcEawEpisode,
ArticleEpisode,
)
from app.services.fsrs_utils import (
create_card,
Expand All @@ -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,
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions app/services/bbc_eaw_seeder.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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__)

Expand Down Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions app/services/bbc_question_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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]:
Expand Down Expand Up @@ -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 也能用

Expand Down Expand Up @@ -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()]
Expand Down
1 change: 1 addition & 0 deletions app/services/model_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
import os
import time
from typing import AsyncGenerator, Optional
Expand Down
Loading
Loading