Skip to content
Open
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
8 changes: 8 additions & 0 deletions cartoon-vocab/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
dist/
dev-dist/
__pycache__/
*.pyc
.pytest_cache/
backend/data/
.env
70 changes: 70 additions & 0 deletions cartoon-vocab/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 动画词卡(Cartoon Word Pal)MVP

6 岁孩子看英文动画前后,用“听音识图”预习 / 复习当集重点词和短语。
需求见 [`docs/prd/cartoon-vocab-mvp-prd.md`](../docs/prd/cartoon-vocab-mvp-prd.md),技术设计见 [`docs/tech/cartoon-vocab-tech-design-v1.md`](../docs/tech/cartoon-vocab-tech-design-v1.md)。

## 目录

```
backend/ FastAPI + SQLAlchemy(SQLite),选词 / 素材生成 / 课程编排 / 学习记录
frontend/ React + Vite PWA:孩子端(零文字)+ 家长端
```

## 本地运行

需要 Python 3.11+、Node 20+;装了 ffmpeg 时会对 TTS 音频做去静音和响度统一。

```bash
# 后端
cd backend
pip install -r requirements-dev.txt
cp .env.example .env # 按需填写密钥,不填也能跑(见下)
uvicorn app.main:app --reload --port 8000

# 前端(另开终端)
cd frontend
npm install
npm run dev # http://localhost:5173 ,/api 和 /media 代理到 8000
```

生产构建:`cd frontend && npm run build`,之后后端会直接托管 `frontend/dist`,只需启动 uvicorn,访问 http://localhost:8000 。

## 外部服务与兜底

| 能力 | 配置 | 未配置时 |
| --- | --- | --- |
| 选词(Claude) | `ANTHROPIC_API_KEY` | 离线规则选词(词频 + 儿童可视化词表) |
| 语音(字节 seed-audio) | `VOLC_TTS_API_KEY` | 浏览器朗读(Web Speech),仅开发用 |
| 生图 | `IMAGE_PROVIDER`(服务商待定) | emoji 占位卡片 |

系统提示语音("Where is…?"、"Great!" 等)配置好语音密钥后运行一次:

```bash
cd backend && python -m scripts.gen_system_voice
```

> seed-audio 接口的响应格式还没有联调确认,`app/assets/tts_seed.py` 里兼容了几种常见形式,第一次接入时请确认后删掉多余分支。

## 使用流程

1. 孩子端首页右上角 ⚙️ **长按 3 秒** → 家长验证(两位数加法)→ 家长端。
2. 新建剧集(默认填好 *Peppa Pig* S1E1)→ 粘贴台词或上传 SRT/VTT → 自动选出 10 个条目。
可以用 `backend/scripts/samples/muddy_puddles_sample.txt`(自编的示例台词)试用。
3. 审核:移除 / 从备选加入 / 手动添加、修改原句和释义 → “确认并生成课程”。
4. 孩子端首页:🔭 看前(预习),🎁 看后(复习),两者不强制顺序;有到期旧词时出现 🔁 小复习。
5. 课程中左上角 🏠 **长按 2 秒**退出;完成一节得到一张贴纸(📒 贴纸册)。
6. 家长端“学习报告”查看完成情况、首次正确率、薄弱词。

## 测试

```bash
cd backend && python -m pytest -q
cd frontend && npm run typecheck && npm test
```

## 与技术设计 v1 的差异(第一迭代的简化)

- 数据库用 SQLite,后台任务用进程内 asyncio 代替 arq + Redis,素材存本地目录代替对象存储;接口形状不变,后续可替换。
- 暂无家长账号体系(单家庭单孩子),家长端只靠家长锁保护;**部署到公网前需要补上登录**。
- 词形还原用轻量规则 + `wordfreq` 代替 spaCy。
- 剧集开场语音暂用通用句 "Today we will watch a cartoon!"。
18 changes: 18 additions & 0 deletions cartoon-vocab/backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 复制为 .env 后按需填写;密钥不要提交到仓库
DATABASE_URL=sqlite:///./data/app.db
MEDIA_DIR=./data/media
MEDIA_BASE_URL=/media

# Claude 选词(留空则使用离线规则选词)
ANTHROPIC_API_KEY=
LLM_MODEL=claude-opus-5-5

# 字节 seed-audio 语音(留空则浏览器朗读兜底,仅开发用)
VOLC_TTS_API_KEY=
TTS_MODEL=seed-audio-1.0
TTS_CONCURRENCY=8
TTS_SLOW_SPEECH_RATE=-25
VOICE_PROFILE="A warm, cheerful young {accent} female voice"

# 生图:placeholder(emoji 卡片);正式服务商待定
IMAGE_PROVIDER=placeholder
Empty file.
Empty file.
158 changes: 158 additions & 0 deletions cartoon-vocab/backend/app/api/child.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""孩子端接口(技术文档 5.2)。"""

from datetime import timedelta

from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session

from ..db import get_db
from ..lessons import package
from ..models import Attempt, Child, Episode, EpisodeStatus, LearningSession, Mastery, StickerAward, VocabItem, utcnow
from ..progress.scheduler import update_mastery
from .deps import current_child, get_episode, today

router = APIRouter(prefix="/api/v1")


def todays_episode(db: Session, child: Child) -> Episode | None:
d = today()
q = (select(Episode).where(Episode.child_id == child.id, Episode.status == EpisodeStatus.READY,
Episode.planned_date <= d)
.order_by(Episode.planned_date.desc(), Episode.updated_at.desc()).limit(1))
return db.scalars(q).first()


@router.get("/home")
def home(db: Session = Depends(get_db), child: Child = Depends(current_child)):
ep = todays_episode(db, child)
d = today()
spaced_due = len(package.due_items(db, child.id, d, exclude={i.vocab_item_id for i in ep.items} if ep else set()))
stickers = db.scalars(select(StickerAward).where(StickerAward.child_id == child.id)).all()
return {
"child": {"nickname": child.nickname, "avatar": child.avatar},
"episode": None if ep is None else {
"id": ep.id, "show_name": ep.show_name,
"theme_color": package.theme_color(ep.show_name),
"preview_done": package.did_finish(db, child.id, ep.id, "preview"),
"review_done": package.did_finish(db, child.id, ep.id, "review"),
},
"spaced_available": spaced_due >= 2,
"sticker_count": len(stickers),
"voices": package.system_voice_entries(package.storage),
}


@router.get("/lessons/spaced")
def spaced_lesson(db: Session = Depends(get_db), child: Child = Depends(current_child)):
pkg = package.build_spaced_lesson(db, child.id, child.accent_pref, today())
if pkg is None:
raise HTTPException(404, "今天没有需要复习的词")
return pkg


@router.get("/lessons/{episode_id}")
def episode_lesson(mode: str, ep: Episode = Depends(get_episode), db: Session = Depends(get_db),
child: Child = Depends(current_child)):
if mode not in ("preview", "review"):
raise HTTPException(422, "mode 只能是 preview 或 review")
if ep.status != EpisodeStatus.READY:
raise HTTPException(409, "课程还没准备好")
return package.build_episode_lesson(db, ep, mode, child.id, child.accent_pref, today())


class SessionIn(BaseModel):
episode_id: str | None = None
mode: str = Field(pattern="^(preview|review|spaced)$")


@router.post("/sessions", status_code=201)
def start_session(body: SessionIn, db: Session = Depends(get_db), child: Child = Depends(current_child)):
if body.episode_id:
ep = db.get(Episode, body.episode_id)
if ep is None or ep.child_id != child.id:
raise HTTPException(404, "剧集不存在")
s = LearningSession(child_id=child.id, episode_id=body.episode_id, mode=body.mode)
db.add(s)
db.commit()
return {"id": s.id}


class AttemptIn(BaseModel):
client_attempt_id: str = Field(min_length=8, max_length=64)
vocab_item_id: str
question_type: str = Field(pattern="^Q[2-5]$")
option_ids: list[str] = []
chosen_id: str
is_correct: bool
is_first_try: bool
latency_ms: int = Field(default=0, ge=0)


def _session(db: Session, child: Child, session_id: str) -> LearningSession:
s = db.get(LearningSession, session_id)
if s is None or s.child_id != child.id:
raise HTTPException(404, "学习记录不存在")
return s


@router.post("/sessions/{session_id}/attempts")
def post_attempts(session_id: str, body: list[AttemptIn], db: Session = Depends(get_db),
child: Child = Depends(current_child)):
s = _session(db, child, session_id)
existing = set(db.scalars(select(Attempt.client_attempt_id).where(
Attempt.client_attempt_id.in_([a.client_attempt_id for a in body]))).all())
valid_vocab = set(db.scalars(select(VocabItem.id).where(VocabItem.id.in_({a.vocab_item_id for a in body}))).all())
added = 0
for a in body:
if a.client_attempt_id in existing or a.vocab_item_id not in valid_vocab:
continue # 幂等:离线补传的重复记录直接跳过
db.add(Attempt(session_id=s.id, **a.model_dump()))
existing.add(a.client_attempt_id)
added += 1
db.commit()
return {"accepted": added}


@router.post("/sessions/{session_id}/finish")
def finish_session(session_id: str, db: Session = Depends(get_db), child: Child = Depends(current_child)):
s = _session(db, child, session_id)
award = db.scalars(select(StickerAward).where(StickerAward.session_id == s.id)).first()
if s.finished_at is None:
s.finished_at = utcnow()
d = today()
per_item: dict[str, bool] = {}
for a in db.scalars(select(Attempt).where(Attempt.session_id == s.id, Attempt.is_first_try.is_(True))
.order_by(Attempt.created_at)).all():
per_item[a.vocab_item_id] = per_item.get(a.vocab_item_id, True) and a.is_correct
for vocab_id, ok in per_item.items():
m = db.get(Mastery, (child.id, vocab_id))
if m is None:
m = Mastery(child_id=child.id, vocab_item_id=vocab_id, streak_days=[])
db.add(m)
update_mastery(m, ok, d)
ep = db.get(Episode, s.episode_id) if s.episode_id else None
seed = package.stable_int(ep.show_name if ep else "spaced")
award = StickerAward(child_id=child.id, session_id=s.id,
sticker_key=package.STICKERS[(seed + len(per_item)) % len(package.STICKERS)])
db.add(award)
db.commit()
return {"sticker_key": award.sticker_key if award else None, "streak_days": _streak(db, child)}


def _streak(db: Session, child: Child) -> int:
days = {f.date() for f in db.scalars(select(LearningSession.finished_at).where(
LearningSession.child_id == child.id, LearningSession.finished_at.is_not(None))).all()}
d, n = today(), 0
while d in days:
n += 1
d -= timedelta(days=1)
return n


@router.get("/stickers")
def stickers(db: Session = Depends(get_db), child: Child = Depends(current_child)):
rows = db.scalars(select(StickerAward).where(StickerAward.child_id == child.id)
.order_by(StickerAward.created_at)).all()
return [{"sticker_key": r.sticker_key, "created_at": r.created_at.isoformat()} for r in rows]
29 changes: 29 additions & 0 deletions cartoon-vocab/backend/app/api/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from datetime import date

from fastapi import Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

from ..db import get_db
from ..models import Child, Episode


def current_child(db: Session = Depends(get_db)) -> Child:
"""MVP 单家庭单孩子:没有就自动创建一个。"""
child = db.scalars(select(Child).order_by(Child.created_at).limit(1)).first()
if child is None:
child = Child()
db.add(child)
db.commit()
return child


def get_episode(episode_id: str, db: Session = Depends(get_db), child: Child = Depends(current_child)) -> Episode:
ep = db.get(Episode, episode_id)
if ep is None or ep.child_id != child.id:
raise HTTPException(404, "剧集不存在")
return ep


def today() -> date:
return date.today()
Loading