Files
English/kouyu_english/tool/words/enrich_wordbank.py
T
shenleiandClaude Opus 5 6b42b7abc3 feat: 独立词库、三向认词与当日快闪复习
## 独立词库

理解词原先只能跟着课程单元走,学完 A0 十课词汇量只增加约 22 个实词,
不足以解决"记不住单词"。新增一份独立词库 assets/words/wordbank.json
(2748 词,A1–B1),挂进 receptiveWordRegistry 的合成单元 bank-A1/A2/B1,
完全复用理解词已有的状态机,不依赖课程进度,第一天就能用。

数据来源、许可与合成规则记在 tool/words/DATA-NOTE.md:CEFR-J 定等级、
公开词书提供音标、AI 重写全部释义并生成例句、OpenSubtitles 提供口语词频。
词书部分为 CC BY-NC-SA 4.0 且上游权利不明,仅供个人非商用;
若要分发或上架,须替换音标那一列。

## 背单词机制

- 间隔阶梯 1/3/7/15/30/60/120 天,连续答对上一级,答错回第一级。
  原先首次答对后要等 7 天才复习,正是"第二天就忘"的成因。
- 每日新词上限(10 分钟 8 个 / 20 分钟 15 个 / 30 分钟 20 个)。
  阶梯第一级是次日,今天引入的新词就是明天的工作量。
- 新词按口语频率发放,不再按字母序 —— A1 从 a.m./ability 变成 no/not/know/just。
- 三个方向按层级轮转:看词(英→中)→ 听词(音→中)→ 想词(中→英)。
  想词题仍是选择题,不要求产出,理解词定位不变,不进升级分母。
- 单词页独立成 tab,首页今日任务卡下方给一张认词入口卡。

## 用法对照

课程 JSON 增加 usage 字段(when/reply/swap/confuse):一个句型用在什么场合、
对方通常怎么答、还能怎么说、跟哪个学过的句型容易混。
知道 How are you? 的意思,不等于知道它不是用来问名字的。

## 复习流

- 当日快闪(recap)独立成队列,不占复习预算,也不计入积压。
- 只发放当日预算内的量,其余保持到期状态等下次,不悄悄丢弃或改期。
- 答错的项隔几题后回来,而不是立刻重问。

测试 296 通过,flutter analyze 干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 23:54:17 +09:00

172 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Rewrites the Chinese gloss of every bank word and gives it an example
sentence, using the AI service configured in assets/config/ai_config.json.
The downloaded word books are a dictionary dump: some entries carry only a rare
sense (`ticket` as a verb and nothing else), some are unusable as a quiz answer.
Cleaning rules cannot invent the missing sense, so the model rewrites the gloss
outright, with the book's version passed only as a hint it may overrule.
Results are cached one JSON object per line, so an interrupted run resumes
instead of paying for the same words twice.
"""
import json
import os
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BANK = os.path.join(ROOT, 'assets/words/wordbank.json')
CONFIG = os.path.join(ROOT, 'assets/config/ai_config.json')
# A word the model reasons about at length can truncate its whole batch; set
# ENRICH_BATCH smaller to pick those stragglers up on a second run.
BATCH = int(os.environ.get('ENRICH_BATCH', '12'))
WORKERS = 8
RETRIES = 3
SYSTEM = """你在为一个中国成年人用的英语学习 App 整理词库。对每个词输出:
- zh:最常用的那一个中文释义。2 到 6 个字,不要词性标注,不要括号,不要罗列同义词。
- more:其他常用义,最多 2 个,用「;」分隔;没有别的常用义就给空字符串。
- ex:一个英文例句。日常口语场景,6 到 12 个词,必须用到 zh 所指的那个义项,
句中其他词不要超过这个词的 CEFR 等级。
- ex_zh:例句的中文翻译,说人话,不要翻译腔。
输入里的「参考」来自一份质量不稳定的词书:有的只收了罕见义,有的缺了最常用的义项。
不要盲从,以你自己对这个词最常用义的判断为准。
只输出 JSON 数组,每个元素形如
{"id":"...","zh":"...","more":"...","ex":"...","ex_zh":"..."}
不要输出任何别的文字,不要用 markdown 代码块。"""
def load_config():
with open(CONFIG) as handle:
return json.load(handle)
def call(config, prompt):
"""One chat completion, through curl: this machine's Python does not trust
the local certificate chain but curl does."""
body = {
'model': config['model'],
'messages': [
{'role': 'system', 'content': SYSTEM},
{'role': 'user', 'content': prompt},
],
'temperature': 0.3,
# The configured model reasons before it answers, and those tokens come
# out of the same budget -- a tight cap truncates the JSON mid-array.
'max_tokens': 8000,
}
with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False) as payload:
json.dump(body, payload, ensure_ascii=False)
path = payload.name
# The key goes through stdin, never argv, so it stays out of the process list.
conf = (
'url = "%s/v1/chat/completions"\n'
'header = "Authorization: Bearer %s"\n'
'header = "Content-Type: application/json"\n'
'data-binary = "@%s"\n'
'max-time = 300\n'
) % (config['endpoint'].rstrip('/'), config['apiKey'], path)
try:
done = subprocess.run(
['curl', '-sS', '--config', '-'], input=conf, capture_output=True, text=True
)
finally:
os.unlink(path)
if done.returncode != 0:
raise RuntimeError(done.stderr[:200])
answer = json.loads(done.stdout)
if 'choices' not in answer:
raise RuntimeError(done.stdout[:200])
choice = answer['choices'][0]
if choice.get('finish_reason') == 'length':
raise RuntimeError('truncated at max_tokens')
return choice['message']['content'], answer.get('usage', {})
def parse(text):
"""The model is told to return bare JSON; strip a code fence when it adds one."""
text = text.strip()
if text.startswith('```'):
text = text.split('\n', 1)[1].rsplit('```', 1)[0]
start, end = text.find('['), text.rfind(']')
if start < 0 or end < 0:
raise ValueError(text[:200])
return json.loads(text[start : end + 1])
def ask(config, batch):
lines = [
'%s | %s | %s | 参考:%s'
% (word['id'], word['en'], word['level'], ';'.join(
filter(None, [word.get('zh', ''), word.get('more', '')])) or '(无)')
for word in batch
]
text, usage = call(config, '\n'.join(lines))
wanted = {word['id'] for word in batch}
rows = [row for row in parse(text) if row.get('id') in wanted]
return rows, usage
def main():
limit = int(sys.argv[1]) if len(sys.argv) > 1 else 0
cache_path = os.environ.get('ENRICH_CACHE', os.path.join(tempfile.gettempdir(), 'enrich_cache.jsonl'))
done = {}
if os.path.exists(cache_path):
with open(cache_path) as handle:
for line in handle:
row = json.loads(line)
done[row['id']] = row
with open(BANK) as handle:
bank = json.load(handle)
todo = [word for word in bank['words'] if word['id'] not in done]
if limit:
todo = todo[:limit]
print('cached %d, to do %d' % (len(done), len(todo)), flush=True)
config = load_config()
batches = [todo[i : i + BATCH] for i in range(0, len(todo), BATCH)]
spent = {'in': 0, 'out': 0, 'fail': 0}
with open(cache_path, 'a') as cache:
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
for index, result in enumerate(pool.map(lambda b: safe(config, b), batches)):
rows, usage, error = result
if error:
spent['fail'] += 1
print(' batch %d failed: %s' % (index, error), flush=True)
continue
for row in rows:
cache.write(json.dumps(row, ensure_ascii=False) + '\n')
cache.flush()
spent['in'] += usage.get('prompt_tokens', 0)
spent['out'] += usage.get('completion_tokens', 0)
if index % 10 == 0:
print(' %d/%d batches' % (index + 1, len(batches)), flush=True)
print('tokens in %d out %d, failed batches %d' % (spent['in'], spent['out'], spent['fail']))
print('cache: %s' % cache_path)
def safe(config, batch):
"""Retries a batch a few times; a truncated or malformed answer is common
enough that giving up on the first one would leave holes everywhere."""
last = 'no attempt'
for attempt in range(RETRIES):
try:
rows, usage = ask(config, batch)
if rows:
return rows, usage, None
last = 'empty result'
except Exception as error:
last = '%s: %s' % (type(error).__name__, error)
return [], {}, last
if __name__ == '__main__':
main()