Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
feat: avoid look-alike distractors, add setup docs and system voice s…
…cript

- Lesson builder never pairs items that share a content word
  (puddle / muddy puddle, jump / jump up and down, mud / muddy)
- backend/.env support and .env.example
- scripts/gen_system_voice.py to pre-generate English prompt audio
- cartoon-vocab/README.md with run instructions and v1 deviations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hih1UmpuHqBoPB9VDZpkhK
  • Loading branch information
claude committed Oct 14, 2026
commit a4a60d63fcbdb03cf2a98a8e0a67bef5f693bb4d
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
16 changes: 16 additions & 0 deletions cartoon-vocab/backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@
BACKEND_DIR = Path(__file__).resolve().parent.parent


def _load_dotenv(path: Path) -> None:
"""读取 backend/.env(不覆盖已存在的环境变量)。"""
if not path.is_file():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))


if not os.environ.get("PYTEST_CURRENT_TEST"):
_load_dotenv(BACKEND_DIR / ".env")


def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()

Expand Down
12 changes: 12 additions & 0 deletions cartoon-vocab/backend/app/lessons/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@ def _edit_distance(a: str, b: str) -> int:
return prev[-1]


def _tokens(text: str) -> set[str]:
return {t for t in text.lower().replace("'", " ").split() if len(t) > 2}


def overlaps(a: str, b: str) -> bool:
"""共享实词(如 puddle / muddy puddle、jump / jump up and down)的两个条目,图片往往相近,不能互为干扰项。"""
ta, tb = _tokens(a), _tokens(b)
return bool(ta & tb) or any(x.startswith(y) or y.startswith(x) for x in ta for y in tb if min(len(x), len(y)) >= 3)


def sounds_similar(a: str, b: str) -> bool:
a, b = a.lower(), b.lower()
return a != b and len(a) > 1 and len(b) > 1 and _edit_distance(a, b) <= 1
Expand Down Expand Up @@ -86,6 +96,8 @@ def _distractors(self, answer: LessonItem, k: int, *, phrase: bool, strict_sound
break
if strict_sound and sounds_similar(c.text, answer.text):
continue
if overlaps(c.text, answer.text):
continue
if c.image not in picked:
picked.append(c.image)
return picked
Expand Down
Empty file.
58 changes: 58 additions & 0 deletions cartoon-vocab/backend/scripts/gen_system_voice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""预生成英文系统提示语音(技术文档 6.3.4),输出到 MEDIA_DIR/voice/<key>.mp3。

用法:VOLC_TTS_API_KEY=... python -m scripts.gen_system_voice [--accent en-GB] [--force]
生成后请人工试听;不满意的删掉对应文件重新运行即可。
"""

import argparse
import asyncio
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from app.assets.postprocess import AudioQCError, ffmpeg_available, process_audio # noqa: E402
from app.assets.tts_seed import ACCENT_DESC, SeedTTS # noqa: E402
from app.config import settings # noqa: E402
from app.lessons.voices import SYSTEM_VOICES # noqa: E402


async def main(accent: str, force: bool) -> int:
if not settings.use_seed_tts:
print("请先设置环境变量 VOLC_TTS_API_KEY")
return 1
tts = SeedTTS(settings.volc_tts_api_key, settings.tts_model, settings.tts_concurrency)
out_dir = settings.media_dir / "voice"
out_dir.mkdir(parents=True, exist_ok=True)
voice = settings.voice_profile.format(accent=ACCENT_DESC.get(accent, "British"))

async def one(key: str, text: str) -> None:
path = out_dir / f"{key}.mp3"
if path.exists() and not force:
print(f"skip {key}")
return
prompt = (f"{voice}, speaking warmly and clearly to a six-year-old child, cheerful and encouraging. "
f"Studio quality, no background music, no sound effects, no other speakers. She says: \"{text}\"")
for _ in range(3):
raw = await tts.synthesize(prompt)
try:
data = (await process_audio(raw, "system"))[0] if ffmpeg_available() else raw
break
except AudioQCError as e:
print(f"retry {key}: {e}")
else:
print(f"FAILED {key}")
return
path.write_bytes(data)
print(f"ok {key}: {text}")

await asyncio.gather(*(one(k, t) for k, t in SYSTEM_VOICES.items()))
return 0


if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--accent", default="en-GB", choices=sorted(ACCENT_DESC))
ap.add_argument("--force", action="store_true")
a = ap.parse_args()
raise SystemExit(asyncio.run(main(a.accent, a.force)))
17 changes: 17 additions & 0 deletions cartoon-vocab/backend/tests/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,20 @@ def test_review_with_preview_uses_warmup_and_fits_time():
def test_single_item_has_no_choice_questions():
steps = LessonBuilder([item(0, "puddle")], seed=0).preview()
assert not _choice_steps(steps)


def test_overlapping_items_are_never_distractors_for_each_other():
from app.lessons.builder import overlaps

assert overlaps("puddle", "muddy puddle") and overlaps("jump", "jump up and down")
assert overlaps("mud", "muddy")
assert not overlaps("rain", "boots")
for seed in range(20):
lb = LessonBuilder(ITEMS, seed=seed)
for steps in (lb.preview(), lb.review(did_preview=True)):
for s in _choice_steps(steps):
answer = next(i for i in ITEMS if i.image == s["answer"])
for o in s["options"]:
other = next(i for i in ITEMS if i.image == o)
if other is not answer:
assert not overlaps(other.text, answer.text), (answer.text, other.text)