整个课程和数据UI重新设计版本
This commit is contained in:
+59
-4
@@ -1,6 +1,6 @@
|
||||
# 课程内容包 JSON 格式
|
||||
|
||||
> 对应《学习引擎规格》第 5 节。本文说明 A1–B1 单元内容包的文件位置、字段和程序校验规则。A0 仍按《A0 初始核心课程包》,不在此格式内。
|
||||
> 对应《学习引擎规格》第 5 节。本文说明 A0–B1 单元内容包的文件位置、字段和程序校验规则。所有等级共用这一套结构,由 `CourseRepository` 用同一套规则转换成学习和复习数据;A0 的内容标准仍以《A0 初始核心课程包》为准(见 4.10)。
|
||||
|
||||
## 1. 文件与工具
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|---|---|
|
||||
| `kouyu_english/assets/courses/course-map.json` | 主题地图:等级、单元顺序、每级的数量范围、名称白名单 |
|
||||
| `kouyu_english/assets/courses/<等级>/<单元ID>.json` | 单元内容包,一个单元一个文件 |
|
||||
| `kouyu_english/assets/courses/A0/assessment.json` | A0 阶段测评题(见 4.11) |
|
||||
| `kouyu_english/tool/courses/lexicon/` | 锁定的 CEFR-J 数据与 `SOURCES.md` |
|
||||
| `kouyu_english/tool/courses/pools/` | 等级词池(`build_word_pools.py` 生成) |
|
||||
| `kouyu_english/tool/courses/validate.dart` | 校验全部内容包,报告词池覆盖率 |
|
||||
@@ -20,17 +21,18 @@ dart run tool/courses/validate.dart --uncovered B1 # 按主题列出未覆盖
|
||||
dart run tool/courses/validate.dart --levels B1-U14 # 打印条目的计算等级
|
||||
```
|
||||
|
||||
内容包目前只是资源文件,尚未加入 `pubspec.yaml`,运行时也还不读取。
|
||||
运行时由 `lib/core/courses/course_repository.dart` 读取:`main` 启动前先加载 A0,`AppState.load` 再加载其余等级。学习(教学段、对话、写作、独立表达)和复习(产出核心的 `match` 与 `review`)都只读这些 JSON,代码里不再写死课程数据。`test/course_self_consistency_test.dart` 要求每个示范答案都能通过它自己的判定规则。
|
||||
|
||||
## 2. 当前内容(2026-09-17,全部为 `draft`)
|
||||
## 2. 当前内容(2026-09-18,A1–B1 为 `draft`)
|
||||
|
||||
| 等级 | 单元 | 产出核心 | 理解词 | 听读材料 | 词池覆盖 |
|
||||
|---|---|---|---|---|---|
|
||||
| A0 | 10 | 见 A0 标准 | — | — | —(`approved`,不参与词池校验) |
|
||||
| A1 | 21 | 265 | 598 | 42 | 840/933(90.0%) |
|
||||
| A2 | 15 | 217 | 862 | 44 | 1040/1078(96.5%) |
|
||||
| B1 | 14 | 206 | 1121 | 42 | 1345/1552(86.7%) |
|
||||
|
||||
全部由 AI 起草、程序校验通过,尚未经过独立审核和用户确认,不能冻结。
|
||||
A1–B1 全部由 AI 起草、程序校验通过,尚未经过独立审核和用户确认,不能冻结。
|
||||
|
||||
## 3. course-map.json
|
||||
|
||||
@@ -48,6 +50,7 @@ dart run tool/courses/validate.dart --levels B1-U14 # 打印条目的计算等
|
||||
"coreRange": [10, 15],
|
||||
"receptiveRange": [70, 110],
|
||||
"materials": {"count": [2, 3], "listeningWords": [250, 550], "readingWords": [300, 500]},
|
||||
"assessment": "A0/assessment.json", // 可选:本级阶段测评题,目前只有 A0 有
|
||||
// A1 的材料较短,且可选 "unknownTolerance"(默认 0.02)放宽超纲词占比
|
||||
"units": [{"id": "B1-U01", "file": "B1/B1-U01.json", "title": "…", "categories": ["旅行"], "canDo": "…"}]
|
||||
}]
|
||||
@@ -202,6 +205,58 @@ A1 材料短、面向零基础,`unknownTolerance` 设为 0.08(听读材料
|
||||
|
||||
至少 2 个维度,每维至少 3 档;`pass` 是及格档在 `bands` 中的下标(从 0 起),须大于 0。
|
||||
|
||||
### 4.9 可选字段:显式规则优先
|
||||
|
||||
以下字段都可省略;省略时由 `itemIds` 引用的产出核心推导(`match` 合并成一组“或”,示范取 `en`),写了就以写的为准。规则写法见 `lib/core/courses/answer_rules.dart`:外层“且”、内层“或”,内层首项可写 `#min:N` 表示至少用上 N 项;词条可写 `a + b`、`/正则/`,以及 `#spelling`、`#digit`、`#word`、`#question`、`#numbers:N`、`#words:N` 这些结构。
|
||||
|
||||
| 位置 | 字段 | 作用 | 省略时 |
|
||||
|---|---|---|---|
|
||||
| 单元 | `status` | `approved` / `draft` 等 | `draft` |
|
||||
| 单元 | `vocabulary` | 整课词表(离线查词) | 产出核心 + 理解词 |
|
||||
| 教学段 | `grammarNote` | 本段语法提示 | 不显示 |
|
||||
| 教学段 | `vocabulary` | 预习词表 `[{id, word, meaning, example, exampleMeaning}]` | 本段产出核心 |
|
||||
| 教学段 | `sceneId` | 本段对话用的场景 | 第 N 段用第 N 个场景,没有则用 main 场景 |
|
||||
| 教学段 | `writing.accept` / `writing.hint` | 写作判定规则 / 未通过时的提示 | 由 `writing.itemIds` 推导 |
|
||||
| 教学段 | `independent.accept` / `independent.help` | 独立表达的完整规则 / 帮助 | 由 `independent.itemIds` 推导;为空则任何真实尝试都算 |
|
||||
| 场景 | `goal` | 对话目标 | `setting`,再没有则 `title` |
|
||||
| 场景 | `practice` | 同时作为自由练习场景:`{title, summary, recapPrompt, alwaysOpen}`,都可省略;写 `{}` 即可 | 不进入自由练习 |
|
||||
| 轮次 | `aiZh` | `ai` 这句的中文(`zh` 是示范回答的中文) | 不提供翻译 |
|
||||
| 轮次 | `accept` | 本轮判定用的一组“或” | 由本轮 `itemIds` 推导 |
|
||||
|
||||
自由练习场景在其所属单元完成后解锁,`alwaysOpen: true` 则一开始就开放。
|
||||
|
||||
### 4.10 A0 单元
|
||||
|
||||
A0 用同一结构,文件为 `A0/a0-01.json` … `a0-10.json`,在 `course-map.json` 中排在最前。为兼容已保存的学习进度,A0 保留原有 ID:单元 `a0-01`,教学段 `a0-01-a`(拆段课为 `a0-04-a/b/c`、`a0-08-a/b/c`),产出核心 `A0-W01` / `A0-P01`。A0 的规则、提示和对话都用上面的显式字段写明;`validate.dart` 只把 A0 的产出核心和听写句当作“已教的词”,不按 A1–B1 的数量范围和词池校验 A0。
|
||||
|
||||
### 4.11 阶段测评(assessment.json)
|
||||
|
||||
等级在 `course-map.json` 里写了 `assessment`,就由 `CourseRepository` 加载对应文件,运行时代码在 `lib/core/courses/assessment.dart`。目前只有 A0 的 `A0/assessment.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"packs": [
|
||||
{"id": "A0-E1", "level": "A0", "tasks": [
|
||||
{"id": "A0-A-L1", "skill": "listening", "prompt": "选择正确答案",
|
||||
"audio": "Hello. I’m Mia.", "choices": ["自我介绍", "买东西", "问时间"], "answerIndex": 0},
|
||||
{"id": "A0-A-W1", "skill": "writing", "prompt": "用英语介绍自己。",
|
||||
"accept": [["hello", "hi"], ["i am", "my name is"]],
|
||||
"guide": "试着同时写一句问候和姓名,例如:Hello. I’m …"}
|
||||
]},
|
||||
{"id": "A0-E1R", "level": "A0", "replaces": "A0-E1", "tasks": […]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `skill` 取 `listening` / `reading` / `writing` / `speaking`。听力、阅读是选择题,写 `choices` 和 `answerIndex`(从 0 开始);听力另写 `audio`。
|
||||
- 写作、口语是开放题,`accept` 用 4.9 的规则写法,`guide` 是答错后展示的要求。
|
||||
- 没有 `replaces` 的套题按文件顺序解锁:第一套直接开放,之后每套要在上一套通过 24 小时后才开放。
|
||||
- 有 `replaces` 的是替换题,用不同的人名、地点、数字考同样的能力,成绩记在原题 ID 上。
|
||||
- 题目和套题 ID 写进学习记录,发布后不要改。
|
||||
|
||||
`test/course_self_consistency_test.dart` 检查:每道开放题都有 `accept` 和 `guide`,每道选择题的 `answerIndex` 有效,`replaces` 指向存在的套题。
|
||||
|
||||
## 5. 文本用词检查
|
||||
|
||||
“已知词”包括:`alwaysAllowedWords`、功能词、A0 已教的词、推荐顺序在前的单元的产出核心和理解词、本单元的产出核心和理解词(均按词形还原),以及全局与本单元的 `names`。数字不计。
|
||||
|
||||
+149
-30
@@ -24,6 +24,7 @@
|
||||
#learning-app-prototype .screen-list { display:flex; flex-direction:column; gap:3px; margin-top:15px; }
|
||||
#learning-app-prototype .screen-link { text-align:left; border:0; border-radius:10px; color:var(--ka-ink); background:transparent; padding:9px 10px; font-size:13px; }
|
||||
#learning-app-prototype .screen-link[aria-pressed="true"] { background:var(--ka-soft-green); color:var(--ka-green); font-weight:500; }
|
||||
#learning-app-prototype .screen-link.sub { padding-left:22px; font-size:12px; }
|
||||
#learning-app-prototype .legend-title { font-size:13px; font-weight:500; margin:0 0 10px; }
|
||||
#learning-app-prototype .flow { display:flex; flex-direction:column; gap:8px; }
|
||||
#learning-app-prototype .flow-row { border-left:2px solid var(--ka-line); padding:3px 0 3px 9px; font-size:12px; line-height:1.35; }
|
||||
@@ -115,6 +116,62 @@
|
||||
#learning-app-prototype .mac-main h1 { font-size:23px; }
|
||||
#learning-app-prototype .mac-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:16px; }
|
||||
#learning-app-prototype .mac-grid .slot { margin:0; min-height:104px; }
|
||||
#learning-app-prototype .me-card { background:var(--ka-surface); border:1px solid var(--ka-line); border-radius:18px; padding:14px; }
|
||||
#learning-app-prototype .me-head { display:flex; align-items:center; gap:12px; }
|
||||
#learning-app-prototype .avatar { width:48px; height:48px; border-radius:50%; display:grid; place-items:center; background:var(--ka-soft-green); color:var(--ka-green); font-size:19px; font-weight:500; }
|
||||
#learning-app-prototype .me-name { font-size:17px; font-weight:500; }
|
||||
#learning-app-prototype .level-badge { display:inline-block; margin-left:6px; padding:2px 7px; border-radius:999px; background:var(--ka-soft-green); color:var(--ka-green); font-size:11px; vertical-align:2px; }
|
||||
#learning-app-prototype .stats { display:grid; grid-template-columns:repeat(4,1fr); margin-top:13px; padding-top:12px; border-top:1px solid var(--ka-line); }
|
||||
#learning-app-prototype .stat { border:0; background:transparent; color:var(--ka-ink); text-align:center; padding:0; }
|
||||
#learning-app-prototype .stat b { display:block; font-size:19px; font-weight:500; }
|
||||
#learning-app-prototype .stat span { color:var(--ka-muted); font-size:11px; }
|
||||
#learning-app-prototype .stat.due b { color:var(--ka-warm-ink); }
|
||||
#learning-app-prototype .goal-card { width:100%; display:block; text-align:left; border:0; border-radius:16px; padding:13px 14px; margin-top:11px; background:var(--ka-soft-green); color:var(--ka-ink); }
|
||||
#learning-app-prototype .goal-card .meter { margin:9px 0 8px; background:light-dark(#c9e4d3, #2a4a37); }
|
||||
#learning-app-prototype .group-title { color:var(--ka-muted); font-size:12px; margin:17px 4px 6px; }
|
||||
#learning-app-prototype .menu { background:var(--ka-surface); border:1px solid var(--ka-line); border-radius:15px; overflow:hidden; }
|
||||
#learning-app-prototype .menu-row { width:100%; display:flex; align-items:center; gap:11px; border:0; border-bottom:1px solid var(--ka-line); background:transparent; color:var(--ka-ink); padding:11px 12px; text-align:left; font-size:14px; }
|
||||
#learning-app-prototype .menu-row:last-child { border-bottom:0; }
|
||||
#learning-app-prototype .menu-row .ico { width:28px; height:28px; flex:none; display:grid; place-items:center; border-radius:8px; background:var(--ka-surface-2); font-size:14px; }
|
||||
#learning-app-prototype .menu-row .label { flex:1; }
|
||||
#learning-app-prototype .menu-row .label small { display:block; color:var(--ka-muted); font-size:11px; margin-top:1px; }
|
||||
#learning-app-prototype .menu-row .val { color:var(--ka-muted); font-size:12px; }
|
||||
#learning-app-prototype .menu-row .chev { color:var(--ka-muted); font-size:16px; }
|
||||
#learning-app-prototype .menu-row.danger { color:light-dark(#b3261e, #ffb4ab); justify-content:center; }
|
||||
#learning-app-prototype .version { text-align:center; color:var(--ka-muted); font-size:11px; margin:12px 0 0; }
|
||||
#learning-app-prototype .scroll { height:593px; overflow-y:auto; }
|
||||
#learning-app-prototype .ability { padding:11px 0; border-bottom:1px solid var(--ka-line); }
|
||||
#learning-app-prototype .ability:last-child { border-bottom:0; }
|
||||
#learning-app-prototype .ability .meter { margin:7px 0 0; }
|
||||
#learning-app-prototype details { margin-top:11px; padding:11px 12px; background:var(--ka-surface); border:1px solid var(--ka-line); border-radius:15px; font-size:13px; }
|
||||
#learning-app-prototype details p { color:var(--ka-muted); font-size:12px; line-height:1.55; margin:8px 0 0; }
|
||||
#learning-app-prototype .tag { display:inline-block; padding:2px 7px; border-radius:999px; font-size:11px; font-weight:400; background:var(--ka-surface-2); color:var(--ka-muted); }
|
||||
#learning-app-prototype .tag.ok { background:var(--ka-soft-green); color:var(--ka-green); }
|
||||
#learning-app-prototype .tag.help { background:var(--ka-warm); color:var(--ka-warm-ink); }
|
||||
#learning-app-prototype .day { color:var(--ka-muted); font-size:12px; margin:14px 4px 6px; }
|
||||
#learning-app-prototype .field { padding:10px 12px; border-bottom:1px solid var(--ka-line); font-size:13px; }
|
||||
#learning-app-prototype .field span { display:block; color:var(--ka-muted); font-size:11px; margin-bottom:3px; }
|
||||
#learning-app-prototype .segmented { display:flex; padding:3px; border-radius:12px; background:var(--ka-surface-2); margin-top:9px; }
|
||||
#learning-app-prototype .segmented button { flex:1; border:0; border-radius:9px; padding:8px 0; background:transparent; color:var(--ka-muted); font-size:13px; }
|
||||
#learning-app-prototype .segmented button.on { background:var(--ka-surface); color:var(--ka-ink); font-weight:500; box-shadow:0 1px 3px rgba(0,0,0,.12); }
|
||||
#learning-app-prototype .row2 { display:flex; gap:8px; } #learning-app-prototype .row2 > * { flex:1; }
|
||||
#learning-app-prototype .state-switch { display:flex; flex-wrap:wrap; align-items:center; gap:5px; margin:10px 0 2px; padding:7px 8px; border:1px dashed var(--ka-line); border-radius:11px; font-size:11px; color:var(--ka-muted); }
|
||||
#learning-app-prototype .state-switch button { border:1px solid var(--ka-line); border-radius:999px; padding:3px 8px; background:var(--ka-surface); color:var(--ka-muted); font-size:11px; }
|
||||
#learning-app-prototype .state-switch button.on { border-color:var(--ka-green); color:var(--ka-green); }
|
||||
#learning-app-prototype .slot[hidden] { display:none; }
|
||||
#learning-app-prototype .path-head { display:flex; justify-content:space-between; align-items:baseline; margin-top:20px; font-size:15px; }
|
||||
#learning-app-prototype .path-head b { font-weight:500; }
|
||||
#learning-app-prototype .path { margin-top:8px; background:var(--ka-surface); border:1px solid var(--ka-line); border-radius:15px; overflow:hidden; }
|
||||
#learning-app-prototype .lesson-row { display:flex; align-items:center; gap:11px; padding:11px 12px; border-bottom:1px solid var(--ka-line); }
|
||||
#learning-app-prototype .lesson-row:last-child { border-bottom:0; }
|
||||
#learning-app-prototype .lesson-row .label { flex:1; }
|
||||
#learning-app-prototype .lesson-row b { display:block; font-size:14px; font-weight:500; }
|
||||
#learning-app-prototype .lesson-row small { color:var(--ka-muted); font-size:11px; }
|
||||
#learning-app-prototype .lesson-ico { width:26px; height:26px; flex:none; display:grid; place-items:center; border-radius:50%; font-style:normal; font-size:12px; background:var(--ka-surface-2); color:var(--ka-muted); }
|
||||
#learning-app-prototype .lesson-row.done .lesson-ico { background:var(--ka-green); color:var(--ka-green-ink); }
|
||||
#learning-app-prototype .lesson-row.current { background:var(--ka-soft-green); }
|
||||
#learning-app-prototype .lesson-row.current .lesson-ico { background:var(--ka-green); color:var(--ka-green-ink); }
|
||||
#learning-app-prototype .lesson-row.locked b { color:var(--ka-muted); }
|
||||
@media (max-width: 880px) { #learning-app-prototype .studio { grid-template-columns:180px minmax(300px, 1fr); } #learning-app-prototype .flow-panel { display:none; } }
|
||||
@media (max-width: 590px) { #learning-app-prototype .studio { display:block; } #learning-app-prototype .panel { margin-bottom:15px; } #learning-app-prototype .screen-list { display:grid; grid-template-columns:1fr 1fr; } #learning-app-prototype .phone { width:100%; max-width:352px; } #learning-app-prototype .phone.mac { width:100%; max-width:430px; } }
|
||||
</style>
|
||||
@@ -125,8 +182,7 @@
|
||||
<div class="screen-list" role="group" aria-label="选择页面">
|
||||
<button class="screen-link" data-screen-target="welcome" aria-pressed="false" type="button">01 欢迎与每日时长</button>
|
||||
<button class="screen-link" data-screen-target="placement" aria-pressed="false" type="button">02 起点定位</button>
|
||||
<button class="screen-link" data-screen-target="home" aria-pressed="true" type="button">03 首页</button>
|
||||
<button class="screen-link" data-screen-target="map" aria-pressed="false" type="button">04 学习地图</button>
|
||||
<button class="screen-link" data-screen-target="home" aria-pressed="true" type="button">03 学习(首页 + 地图合并)</button>
|
||||
<button class="screen-link" data-screen-target="listen" aria-pressed="false" type="button">05 听力练习</button>
|
||||
<button class="screen-link" data-screen-target="speak" aria-pressed="false" type="button">06 跟读练习</button>
|
||||
<button class="screen-link" data-screen-target="write" aria-pressed="false" type="button">07 造句练习</button>
|
||||
@@ -135,8 +191,15 @@
|
||||
<button class="screen-link" data-screen-target="confirm" aria-pressed="false" type="button">10 转写确认</button>
|
||||
<button class="screen-link" data-screen-target="summary" aria-pressed="false" type="button">11 练习总结</button>
|
||||
<button class="screen-link" data-screen-target="review" aria-pressed="false" type="button">12 今日复习</button>
|
||||
<button class="screen-link" data-screen-target="progress" aria-pressed="false" type="button">13 我的进步</button>
|
||||
<button class="screen-link" data-screen-target="settings" aria-pressed="false" type="button">14 设置</button>
|
||||
<button class="screen-link" data-screen-target="progress" aria-pressed="false" type="button">13 我的</button>
|
||||
<button class="screen-link sub" data-screen-target="me-level" aria-pressed="false" type="button">13a 升级进度</button>
|
||||
<button class="screen-link sub" data-screen-target="me-assess" aria-pressed="false" type="button">13b 四技能评估</button>
|
||||
<button class="screen-link sub" data-screen-target="me-records" aria-pressed="false" type="button">13c 学习记录</button>
|
||||
<button class="screen-link sub" data-screen-target="me-audio" aria-pressed="false" type="button">13d 我的录音</button>
|
||||
<button class="screen-link" data-screen-target="settings" aria-pressed="false" type="button">14 学习偏好</button>
|
||||
<button class="screen-link sub" data-screen-target="set-theme" aria-pressed="false" type="button">14a 外观</button>
|
||||
<button class="screen-link sub" data-screen-target="set-sync" aria-pressed="false" type="button">14b 云同步</button>
|
||||
<button class="screen-link sub" data-screen-target="set-ai" aria-pressed="false" type="button">14c AI 服务</button>
|
||||
<button class="screen-link" data-screen-target="mac" aria-pressed="false" type="button">15 Mac 适配</button>
|
||||
<button class="screen-link" data-screen-target="dictionary" aria-pressed="false" type="button">16 查词卡</button>
|
||||
<button class="screen-link" data-screen-target="preview" aria-pressed="false" type="button">17 课前词汇预热</button>
|
||||
@@ -167,23 +230,25 @@
|
||||
</section>
|
||||
<section class="screen" data-screen="home">
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div>
|
||||
<div class="appbody">
|
||||
<div class="eyebrow">早上好,Shen</div><h1>今天,说 3 句英语。</h1><p class="bodycopy">第 1 课已完成。今天把姓名介绍说得更自然。</p>
|
||||
<div class="meter" aria-label="当前掌握程度"><span style="width:30%"></span></div><p class="small">A0 起步 · 已完成 3 个核心任务</p>
|
||||
<div class="slot selected"><div class="slot-title"><span>第 2 课 · 拼读姓名</span><span>12 分钟</span></div><div class="slot-note">先认识 4 个词 · 听 · 说 · 读 · 写</div><button class="primary" data-go="preview" type="button">开始今天的学习</button></div>
|
||||
<div class="slot"><div class="slot-title">快速练习 · 初次见面</div><div class="slot-note">3 分钟 AI 对话 · 介绍姓名与来自哪里</div><button class="secondary" data-go="dialogue" type="button">开始对话</button></div>
|
||||
<div class="appbody scroll">
|
||||
<div class="eyebrow">早上好,Shen</div><h1>今天,说几句能用的英语。</h1>
|
||||
<div class="state-switch" role="group" aria-label="原型:切换今日任务状态"><span>原型状态:</span><button class="on" data-today="review" type="button">有到期复习</button><button data-today="lesson" type="button">上新课</button><button data-today="stage" type="button">A0 学完</button></div>
|
||||
<div class="slot selected today" data-today-card="review"><div class="slot-title"><span>今天先复习 3 项</span><span class="small">6 分钟</span></div><div class="slot-note">昨天练过的关键句,今天换个情境再用一次。复习完再上新课。</div><button class="primary" data-go="review" type="button">开始复习</button></div>
|
||||
<div class="slot selected today" data-today-card="lesson" hidden><div class="slot-title"><span>第 3 课 · 来自哪里 · 第 2/3 段</span><span class="small">12 分钟</span></div><div class="slot-note">预热词汇 → 听说读写 → 对话 → 独立尝试</div><button class="primary" data-go="preview" type="button">开始今天的学习</button></div>
|
||||
<div class="slot selected today" data-today-card="stage" hidden><div class="slot-title"><span>A0 阶段评估 · A0-E1</span><span class="small">15 分钟</span></div><div class="slot-note">可使用 48/48 · 已掌握 30/30。两套评估间隔至少 24 小时。</div><button class="primary" type="button">开始阶段评估</button></div>
|
||||
<div class="path-head"><b>A0 课程</b><span class="small">2 / 8 完成</span></div>
|
||||
<div class="meter" style="margin:6px 0 4px"><span style="width:25%"></span></div>
|
||||
<div class="path">
|
||||
<div class="lesson-row done"><i class="lesson-ico">✓</i><div class="label"><b>第 1 课 · 你好,我是……</b><small>介绍姓名并回应问候</small></div></div>
|
||||
<div class="lesson-row done"><i class="lesson-ico">✓</i><div class="label"><b>第 2 课 · 拼读姓名</b><small>听懂拼写,说出名字字母</small></div></div>
|
||||
<div class="lesson-row current"><i class="lesson-ico">▶</i><div class="label"><b>第 3 课 · 来自哪里</b><small>说出来自哪里 · 小段 1/3</small></div></div>
|
||||
<div class="lesson-row locked"><i class="lesson-ico">🔒</i><div class="label"><b>第 4 课 · 数字与电话号码</b><small>说 0–10 与简单号码</small></div></div>
|
||||
<div class="lesson-row locked"><i class="lesson-ico">🔒</i><div class="label"><b>第 5 课 · 你好吗?</b><small>问候并表达简单状态</small></div></div>
|
||||
<div class="lesson-row locked"><i class="lesson-ico">🔒</i><div class="label"><b>…… 第 8 课</b><small>A0 课程结束后进入巩固与阶段评估</small></div></div>
|
||||
</div>
|
||||
<nav class="bottom" aria-label="主导航"><button class="navitem active" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem" data-go="map" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
|
||||
</section>
|
||||
<section class="screen" data-screen="map" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="home" type="button">‹</button><h2>A0 起步</h2></div>
|
||||
<div class="appbody"><div class="eyebrow">学习地图 · 已完成 3 个任务</div><h1>从认识到开口</h1><p class="bodycopy">核心课建立能力;达到出口任务才进入下一阶段。</p>
|
||||
<div class="slot"><div class="slot-title">✓ 第 1 课 · 你好,我是……</div><div class="slot-note">介绍姓名并回应问候</div></div>
|
||||
<div class="slot selected"><div class="slot-title">第 2 课 · 拼读姓名 <span>进行中</span></div><div class="slot-note">听懂拼写,并说出自己的名字字母</div></div>
|
||||
<div class="slot"><div class="slot-title">第 3 课 · 你好吗?</div><div class="slot-note">问候并表达简单状态</div></div>
|
||||
<div class="slot"><div class="slot-title">第 4 课 · 数字与电话号码</div><div class="slot-note">说 0–10 与简单号码</div></div>
|
||||
<p class="small" style="margin-top:10px">已完成的课可以随时点开重练;有复习积压时,新课会暂时锁住。</p>
|
||||
</div>
|
||||
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem active" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
|
||||
<nav class="bottom" aria-label="主导航"><button class="navitem active" data-go="home" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
|
||||
</section>
|
||||
<section class="screen" data-screen="preview" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="home" type="button">‹</button><h2>第 2 课 · 拼读姓名</h2></div>
|
||||
@@ -221,7 +286,7 @@
|
||||
<div class="slot selected"><div class="slot-title">初次见面 <span>A0</span></div><div class="slot-note">介绍姓名和来自哪里 · 4 轮</div><button class="primary" data-go="dialogue" type="button">开始对话</button></div>
|
||||
<div class="slot"><div class="slot-title">认识新同学 <span>A0</span></div><div class="slot-note">问候并问对方姓名</div></div><div class="slot"><div class="slot-title">咖啡店 <span>A1</span></div><div class="slot-note">点一杯饮料 · 尚未解锁</div></div>
|
||||
</div>
|
||||
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem" data-go="map" type="button"><i class="nav-dot"></i>学习</button><button class="navitem active" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
|
||||
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>学习</button><button class="navitem active" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
|
||||
</section>
|
||||
<section class="screen" data-screen="dialogue" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="scenes" type="button">‹</button><h2>初次见面 · 2 / 4</h2></div>
|
||||
@@ -244,19 +309,69 @@
|
||||
<section class="screen" data-screen="review" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><h2>今天复习 · 3 项</h2></div>
|
||||
<div class="appbody"><div class="eyebrow">昨天的对话</div><h1>再回答一次。</h1><p class="bodycopy">这次换成新的情境,也能用上同一句话。</p><div class="review-prompt">AI:Hi! Where are you from?</div><div class="assist"><button type="button">提示</button><button type="button">慢一点</button><button type="button">翻译</button></div><div class="record">按住说话 ● 或输入文字</div><p class="small" style="margin-top:13px">完成后还有 2 项 · 每项约 1 分钟</p></div>
|
||||
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem" data-go="map" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem active" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
|
||||
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem active" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
|
||||
</section>
|
||||
<section class="screen" data-screen="progress" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><h2>我的进步</h2></div>
|
||||
<div class="appbody"><div class="eyebrow">当前:A0 起步</div><h1>你已经能做到</h1><p class="bodycopy">进步以“能完成什么”呈现,而非单纯的分数。</p>
|
||||
<div class="levelmap"><div class="level"><i class="on"></i><div><b>介绍姓名</b><span>完成 2 次对话</span></div><span>✓</span></div><div class="level"><i class="on"></i><div><b>说来自哪里</b><span>已加入复习</span></div><span>✓</span></div><div class="level"><i></i><div><b>拼读电话号码</b><span>下一项核心练习</span></div><span>→</span></div></div>
|
||||
<div class="slot"><div class="slot-title">A0 当前掌握 <span>准备出口挑战</span></div><div class="meter"><span style="width:30%"></span></div></div><button class="secondary" data-go="settings" type="button">调整学习设置</button>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar" style="justify-content:space-between"><h2>我的</h2><button class="back" data-go="settings" aria-label="设置" type="button">⚙</button></div>
|
||||
<div class="appbody scroll">
|
||||
<div class="me-card"><div class="me-head"><div class="avatar">S</div><div><div class="me-name">Shen<span class="level-badge">A0 起步</span></div><p class="small">每天 20 分钟 · 已坚持 6 天</p></div></div>
|
||||
<div class="stats"><button class="stat" data-go="me-records" type="button"><b>12</b><span>已学课</span></button><button class="stat" data-go="me-level" type="button"><b>31</b><span>可使用</span></button><button class="stat" data-go="me-level" type="button"><b>9</b><span>已掌握</span></button><button class="stat due" data-go="review" type="button"><b>3</b><span>待复习</span></button></div></div>
|
||||
<button class="goal-card" data-go="me-level" type="button"><div class="slot-title">距离 A1 <span class="small">52% ›</span></div><div class="meter"><span style="width:52%"></span></div><p class="small">还需:17 项可使用 · 21 项掌握 · 2 套评估</p></button>
|
||||
<div class="group-title">学习</div>
|
||||
<div class="menu"><button class="menu-row" data-go="me-level" type="button"><i class="ico">📈</i><span class="label">升级进度</span><span class="val">52%</span><span class="chev">›</span></button><button class="menu-row" data-go="me-assess" type="button"><i class="ico">📝</i><span class="label">四技能评估</span><span class="val">0/2 通过</span><span class="chev">›</span></button><button class="menu-row" data-go="me-records" type="button"><i class="ico">🕘</i><span class="label">学习记录</span><span class="val"></span><span class="chev">›</span></button><button class="menu-row" data-go="me-audio" type="button"><i class="ico">🎙</i><span class="label">我的录音</span><span class="val">8 条</span><span class="chev">›</span></button></div>
|
||||
<div class="group-title">设置</div>
|
||||
<div class="menu"><button class="menu-row" data-go="settings" type="button"><i class="ico">📚</i><span class="label">学习偏好</span><span class="val">20 分钟</span><span class="chev">›</span></button><button class="menu-row" data-go="set-theme" type="button"><i class="ico">🎨</i><span class="label">外观</span><span class="val">跟随系统</span><span class="chev">›</span></button><button class="menu-row" data-go="set-sync" type="button"><i class="ico">☁️</i><span class="label">云同步</span><span class="val">已登录</span><span class="chev">›</span></button><button class="menu-row" data-go="set-ai" type="button"><i class="ico">🤖</i><span class="label">AI 服务</span><span class="val">DeepSeek</span><span class="chev">›</span></button></div>
|
||||
<div class="menu" style="margin-top:17px"><button class="menu-row danger" type="button">清除本机学习数据</button></div>
|
||||
<p class="version">开口英语 1.0.0</p>
|
||||
</div>
|
||||
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>首页</button><button class="navitem" data-go="map" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem active" type="button"><i class="nav-dot"></i>我的</button></nav>
|
||||
<nav class="bottom" aria-label="主导航"><button class="navitem" data-go="home" type="button"><i class="nav-dot"></i>学习</button><button class="navitem" data-go="scenes" type="button"><i class="nav-dot"></i>对话</button><button class="navitem" data-go="review" type="button"><i class="nav-dot"></i>复习</button><button class="navitem active" data-go="progress" type="button"><i class="nav-dot"></i>我的</button></nav>
|
||||
</section>
|
||||
<section class="screen" data-screen="me-level" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button">‹</button><h2>升级进度</h2></div>
|
||||
<div class="appbody scroll"><div class="eyebrow">A0 → A1</div><h1>已完成 52%</h1><p class="bodycopy">进度来自掌握证据,不是上完固定课数就升级。</p>
|
||||
<div class="me-card" style="margin-top:13px"><div class="ability"><div class="slot-title">可使用 <span class="small">31 / 48</span></div><p class="small">核心内容已获得独立使用证据</p><div class="meter"><span style="width:65%"></span></div></div><div class="ability"><div class="slot-title">已掌握(间隔复习) <span class="small">9 / 30</span></div><p class="small">达到四次间隔复习要求</p><div class="meter"><span style="width:30%"></span></div></div><div class="ability"><div class="slot-title">两套四技能评估 <span class="small">0 / 2</span></div><p class="small">两套题组需间隔至少 24 小时</p><div class="meter"><span style="width:0%"></span></div></div></div>
|
||||
<details><summary>进入下一阶段的条件</summary><p>60 项固定核心内容中至少 48 项可使用、30 项已掌握,且两套不同题组的听说读写评估都通过并间隔至少 24 小时。</p></details>
|
||||
<button class="primary" data-go="review" type="button">去复习 3 项,推进掌握</button></div>
|
||||
</section>
|
||||
<section class="screen" data-screen="me-assess" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button">‹</button><h2>四技能评估</h2></div>
|
||||
<div class="appbody scroll"><p class="bodycopy">评估不提供翻译或句框。两套都通过,且间隔 24 小时以上,才计入升级。</p>
|
||||
<div class="slot"><div class="slot-title">A0-E1 <span class="tag">未开始</span></div><p class="slot-note">听 · 说 · 读 · 写,各 2 题 · 约 12 分钟</p><button class="primary" type="button">开始评估</button></div>
|
||||
<div class="slot"><div class="slot-title">A0-E2 <span class="tag">未解锁</span></div><p class="slot-note">A0-E1 通过 24 小时后可开始</p></div>
|
||||
<p class="small" style="margin-top:12px">完成后可在这里查看得分和错题回顾。</p></div>
|
||||
</section>
|
||||
<section class="screen" data-screen="me-records" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button">‹</button><h2>学习记录</h2></div>
|
||||
<div class="appbody scroll"><div class="segmented"><button class="on" type="button">全部</button><button type="button">独立完成</button><button type="button">带帮助</button></div>
|
||||
<div class="day">今天</div><div class="menu"><div class="menu-row"><span class="label">I'm from Hong Kong.<small>复习 · 口语</small></span><span class="tag ok">独立完成</span></div><div class="menu-row"><span class="label">My number is 1-2-3.<small>听写</small></span><span class="tag help">带提示</span></div></div>
|
||||
<div class="day">昨天</div><div class="menu"><div class="menu-row"><span class="label">第 5 课 · 来自哪里<small>课程 · 6 步完成</small></span><span class="tag ok">完成</span></div><div class="menu-row"><span class="label">初次见面<small>AI 对话 · 4 轮</small></span><span class="tag help">带帮助</span></div><div class="menu-row"><span class="label">Nice to meet you.<small>复习 · 写作</small></span><span class="tag ok">独立完成</span></div></div>
|
||||
<p class="small" style="margin-top:12px">记录只保存在本机,用于解释进度。</p></div>
|
||||
</section>
|
||||
<section class="screen" data-screen="me-audio" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button">‹</button><h2>我的录音</h2></div>
|
||||
<div class="appbody scroll"><div class="menu"><div class="menu-row"><i class="ico">▶</i><span class="label">I'm from Hong Kong.<small>今天 9:12 · 0:03</small></span><span class="val">删除</span></div><div class="menu-row"><i class="ico">▶</i><span class="label">My name is Shen.<small>昨天 20:40 · 0:02</small></span><span class="val">删除</span></div><div class="menu-row"><i class="ico">▶</i><span class="label">Nice to meet you.<small>昨天 20:35 · 0:02</small></span><span class="val">删除</span></div></div>
|
||||
<div class="menu" style="margin-top:12px"><div class="menu-row"><span class="label">保存原始录音<small>仅保存在本机</small></span><div class="toggle"><i></i></div></div></div>
|
||||
<button class="secondary" type="button">清除全部录音</button></div>
|
||||
</section>
|
||||
<section class="screen" data-screen="settings" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button">‹</button><h2>学习设置</h2></div>
|
||||
<div class="appbody"><div class="setting"><div>AI 对话服务<br><span>Gemini API · 可切换兼容反代</span></div><b>›</b></div><div class="setting"><div>每日学习时间<br><span>20 分钟</span></div><b>›</b></div><div class="setting"><div>英文朗读速度<br><span>慢速</span></div><b>›</b></div><div class="setting"><div>默认显示中文提示<br><span>A0 阶段开启</span></div><div class="toggle"><i></i></div></div><div class="setting"><div>保存原始录音<br><span>仅保存在本机</span></div><div class="toggle"><i></i></div></div><button class="secondary" data-go="home" type="button">清空本机练习记录</button></div>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button">‹</button><h2>学习偏好</h2></div>
|
||||
<div class="appbody scroll"><div class="menu"><button class="menu-row" type="button"><i class="ico">⏱</i><span class="label">每日学习时间</span><span class="val">20 分钟</span><span class="chev">›</span></button><div class="menu-row"><i class="ico">中</i><span class="label">默认显示中文提示<small>A0 阶段建议开启</small></span><div class="toggle"><i></i></div></div><button class="menu-row" type="button"><i class="ico">🔊</i><span class="label">英文朗读速度</span><span class="val">慢速</span><span class="chev">›</span></button><button class="menu-row" type="button"><i class="ico">🗣</i><span class="label">离线语音识别</span><span class="val">SenseVoice</span><span class="chev">›</span></button></div></div>
|
||||
</section>
|
||||
<section class="screen" data-screen="set-theme" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button">‹</button><h2>外观</h2></div>
|
||||
<div class="appbody scroll"><div class="group-title">主题</div><div class="segmented"><button class="on" type="button">跟随系统</button><button type="button">浅色</button><button type="button">深色</button></div><p class="small" style="margin:10px 4px 0">选择“跟随系统”时,随手机的深色模式自动切换。</p></div>
|
||||
</section>
|
||||
<section class="screen" data-screen="set-sync" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button">‹</button><h2>云同步</h2></div>
|
||||
<div class="appbody scroll"><div class="me-card"><div class="me-head"><div class="avatar">☁</div><div><div class="me-name">shen</div><p class="small">上次同步:今天 9:15</p></div></div></div>
|
||||
<div class="menu" style="margin-top:12px"><div class="menu-row"><span class="label">自动同步<small>学习后在后台同步</small></span><div class="toggle"><i></i></div></div><button class="menu-row" type="button"><i class="ico">🌐</i><span class="label">服务器地址</span><span class="val">sync.example.com</span><span class="chev">›</span></button></div>
|
||||
<button class="primary" type="button">立即同步</button><button class="secondary" type="button">退出登录</button></div>
|
||||
</section>
|
||||
<section class="screen" data-screen="set-ai" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="progress" type="button">‹</button><h2>AI 服务</h2></div>
|
||||
<div class="appbody scroll"><div class="menu"><button class="menu-row" type="button"><i class="ico">🤖</i><span class="label">服务类型</span><span class="val">OpenAI 兼容</span><span class="chev">›</span></button><div class="field"><span>接口地址</span>https://api.deepseek.com</div><div class="field"><span>模型</span>deepseek-flash</div><div class="field" style="border-bottom:0"><span>API Key</span>••••••••••••</div></div>
|
||||
<div class="row2"><button class="secondary" type="button">测试连接</button><button class="primary" style="margin-top:8px" type="button">保存</button></div>
|
||||
<p class="small" style="margin:12px 4px 0">AI 只提供建议,不会直接决定你的掌握程度。</p></div>
|
||||
</section>
|
||||
<section class="screen" data-screen="dictionary" hidden>
|
||||
<div class="status"><span>9:41</span><span>● ● ●</span></div><div class="appbar"><button class="back" data-go="dialogue" type="button">‹</button><h2>初次见面 · 2 / 4</h2></div>
|
||||
@@ -264,12 +379,12 @@
|
||||
</section>
|
||||
<section class="screen" data-screen="mac" hidden>
|
||||
<div class="status"><span>开口英语</span><span>◉ Shen</span></div>
|
||||
<div class="mac-body"><aside class="mac-side"><b>开口英语</b><span class="on">首页</span><span>学习</span><span>对话</span><span>复习</span><span>我的</span></aside><main class="mac-main"><div class="eyebrow">早上好,Shen</div><h1>今天,说 3 句英语。</h1><p class="bodycopy">Mac 复用手机端的课程与进度,本机也能独立练习。</p><div class="mac-grid"><div class="slot selected"><div class="slot-title">第 2 课 · 拼读姓名</div><div class="slot-note">12 分钟 · 先认识 4 个词 · 听说读写</div><button class="primary" data-go="preview" type="button">继续学习</button></div><div class="slot"><div class="slot-title">AI 对话 · 初次见面</div><div class="slot-note">3 分钟 · 介绍姓名与地点</div><button class="secondary" data-go="dialogue" type="button">开始对话</button></div></div><div class="slot" style="margin-top:12px"><div class="slot-title">今天复习 · 3 项</div><div class="slot-note">昨天的关键句将在新情境中再次练习。</div></div></main></div>
|
||||
<div class="mac-body"><aside class="mac-side"><b>开口英语</b><span class="on">学习</span><span>对话</span><span>复习</span><span>我的</span></aside><main class="mac-main"><div class="eyebrow">早上好,Shen</div><h1>今天,说 3 句英语。</h1><p class="bodycopy">Mac 复用手机端的课程与进度,本机也能独立练习。</p><div class="mac-grid"><div class="slot selected"><div class="slot-title">第 2 课 · 拼读姓名</div><div class="slot-note">12 分钟 · 先认识 4 个词 · 听说读写</div><button class="primary" data-go="preview" type="button">继续学习</button></div><div class="slot"><div class="slot-title">AI 对话 · 初次见面</div><div class="slot-note">3 分钟 · 介绍姓名与地点</div><button class="secondary" data-go="dialogue" type="button">开始对话</button></div></div><div class="slot" style="margin-top:12px"><div class="slot-title">今天复习 · 3 项</div><div class="slot-note">昨天的关键句将在新情境中再次练习。</div></div></main></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<aside class="panel flow-panel" aria-label="原型流程说明">
|
||||
<p class="legend-title">M1 流程</p><div class="flow"><div class="flow-row"><strong>首次使用</strong>每日时长 → 起点定位 → 首页</div><div class="flow-row active"><strong>日常学习</strong>首页 → 词汇预热 → 课程 → AI 对话 → 总结</div><div class="flow-row"><strong>查词帮助</strong>点词/短语 → 查词卡 → 回到原步骤</div><div class="flow-row"><strong>纠错保护</strong>低置信转写 → 确认 / 修改 / 重说</div><div class="flow-row"><strong>巩固</strong>总结 → 次日微对话复习</div><div class="flow-row"><strong>设备</strong>手机优先;Mac 使用更宽的同一学习结构</div></div><p class="legend-title" style="margin-top:20px">设计原则</p><span class="chip">一个主任务</span><span class="chip">预热不算掌握</span><span class="chip">短语优先查词</span><span class="chip">口音可理解即接受</span><span class="chip">一次只纠正一个点</span><span class="chip">语音失败可改文字</span>
|
||||
<p class="legend-title">M1 流程</p><div class="flow"><div class="flow-row"><strong>首次使用</strong>每日时长 → 起点定位 → 学习</div><div class="flow-row active"><strong>日常学习</strong>学习页今日任务 → 词汇预热 → 课程 → AI 对话 → 总结</div><div class="flow-row"><strong>查词帮助</strong>点词/短语 → 查词卡 → 回到原步骤</div><div class="flow-row"><strong>纠错保护</strong>低置信转写 → 确认 / 修改 / 重说</div><div class="flow-row"><strong>巩固</strong>总结 → 次日微对话复习</div><div class="flow-row"><strong>设备</strong>手机优先;Mac 使用更宽的同一学习结构</div></div><p class="legend-title" style="margin-top:20px">设计原则</p><span class="chip">一个主任务</span><span class="chip">预热不算掌握</span><span class="chip">短语优先查词</span><span class="chip">口音可理解即接受</span><span class="chip">一次只纠正一个点</span><span class="chip">语音失败可改文字</span>
|
||||
</aside>
|
||||
</div>
|
||||
<script>
|
||||
@@ -285,6 +400,10 @@
|
||||
};
|
||||
links.forEach(link => link.addEventListener('click', () => show(link.dataset.screenTarget)));
|
||||
root.querySelectorAll('[data-go]').forEach(button => button.addEventListener('click', () => show(button.dataset.go)));
|
||||
root.querySelectorAll('[data-today]').forEach(button => button.addEventListener('click', () => {
|
||||
root.querySelectorAll('[data-today]').forEach(b => b.classList.toggle('on', b === button));
|
||||
root.querySelectorAll('[data-today-card]').forEach(card => { card.hidden = card.dataset.todayCard !== button.dataset.today; });
|
||||
}));
|
||||
show('home');
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -43,3 +43,6 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Local AI service config — contains a real API key, never commit
|
||||
assets/config/ai_config.json
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"description": "复制此文件为 ai_config.json 并填入真实 apiKey;ai_config.json 已被 .gitignore 忽略,切勿提交。",
|
||||
"provider": "compatible",
|
||||
"endpoint": "https://api.deepseek.com/v1/chat/completions",
|
||||
"model": "deepseek-chat",
|
||||
"reasoningEffort": "low",
|
||||
"apiKey": "sk-REPLACE_WITH_YOUR_OWN_KEY"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"provider": "compatible",
|
||||
"endpoint": "https://kmwq8ckvr0ehsgqyudcer1.slcydia.fun/v1/responses",
|
||||
"model": "gemini-3.7-flash-high",
|
||||
"reasoningEffort": "low",
|
||||
"apiKey": "***REMOVED***",
|
||||
"description": "默认 AI 对话服务配置。provider 可选: compatible (OpenAI 兼容/CLIProxyAPI/OneAPI), openAi, gemini, mock"
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"id": "a0-01",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "你好,我是……",
|
||||
"canDo": "介绍姓名并回应问候",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-W36",
|
||||
"type": "word",
|
||||
"en": "hello",
|
||||
"zh": "你好",
|
||||
"match": [
|
||||
[
|
||||
"hello"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“你好”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "Hello, how are you?",
|
||||
"meaning": "你好,你怎么样?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W37",
|
||||
"type": "word",
|
||||
"en": "hi",
|
||||
"zh": "嗨",
|
||||
"match": [
|
||||
[
|
||||
"hi"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“嗨”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "Hi, what’s your name?",
|
||||
"meaning": "嗨,你叫什么名字?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W40",
|
||||
"type": "word",
|
||||
"en": "thanks",
|
||||
"zh": "谢谢",
|
||||
"match": [
|
||||
[
|
||||
"thanks"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“谢谢”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "I’m okay, thanks.",
|
||||
"meaning": "我还可以,谢谢。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P01",
|
||||
"type": "phrase",
|
||||
"en": "I'm [name].",
|
||||
"match": [
|
||||
[
|
||||
"my name is",
|
||||
"/\\bi am (?!from\\b|good\\b|okay\\b|ok\\b|fine\\b|great\\b|tired\\b)[a-z]/"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "向新同学介绍你的名字。",
|
||||
"hint": "用 I’m / I am 或 My name is 介绍一个名字。",
|
||||
"dictation": {
|
||||
"sentence": "I’m Tom.",
|
||||
"meaning": "我是 Tom。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P02",
|
||||
"type": "phrase",
|
||||
"en": "What's your name?",
|
||||
"match": [
|
||||
[
|
||||
"what is your name"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "问对方叫什么名字。",
|
||||
"hint": "试着问:What’s your name?",
|
||||
"dictation": {
|
||||
"sentence": "What’s your name?",
|
||||
"meaning": "你叫什么名字?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P03",
|
||||
"type": "phrase",
|
||||
"en": "Nice to meet you.",
|
||||
"match": [
|
||||
[
|
||||
"nice to meet you"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "对初次见面的人说“很高兴认识你”。",
|
||||
"dictation": {
|
||||
"sentence": "Nice to meet you.",
|
||||
"meaning": "很高兴认识你。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-01-a",
|
||||
"title": "你好,我是……",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W36",
|
||||
"A0-W37",
|
||||
"A0-W40",
|
||||
"A0-P01",
|
||||
"A0-P02",
|
||||
"A0-P03"
|
||||
],
|
||||
"grammarNote": "介绍自己时,把名字放在 I’m 后面:I’m Mia. 不需要先学复杂时态。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-01-hello",
|
||||
"word": "Hello",
|
||||
"meaning": "你好",
|
||||
"example": "Hello. I’m Mia.",
|
||||
"exampleMeaning": "你好。我是 Mia。",
|
||||
"ipa": "/həˈləʊ/"
|
||||
},
|
||||
{
|
||||
"id": "a0-01-name",
|
||||
"word": "name",
|
||||
"meaning": "名字",
|
||||
"example": "My name is Shen.",
|
||||
"exampleMeaning": "我的名字是 Shen。",
|
||||
"ipa": "/neɪm/"
|
||||
},
|
||||
{
|
||||
"id": "a0-01-nice",
|
||||
"word": "Nice to meet you",
|
||||
"meaning": "很高兴认识你",
|
||||
"example": "Nice to meet you, too.",
|
||||
"exampleMeaning": "我也很高兴认识你。"
|
||||
},
|
||||
{
|
||||
"id": "a0-01-thanks",
|
||||
"word": "thanks",
|
||||
"meaning": "谢谢",
|
||||
"example": "Thanks. Nice to meet you, too.",
|
||||
"exampleMeaning": "谢谢。我也很高兴认识你。",
|
||||
"ipa": "/θæŋks/"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-01-a",
|
||||
"listening": {
|
||||
"text": "Hello. I’m Mia. Nice to meet you.",
|
||||
"question": "Mia 在做什么?",
|
||||
"options": [
|
||||
"自我介绍",
|
||||
"买东西",
|
||||
"问时间"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "Hello. I’m Shen. Nice to meet you.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: Hello. I’m Mia. What’s your name?\nShen: Hi. I’m Shen.\nMia: Nice to meet you.\nShen: Thanks. Nice to meet you, too.",
|
||||
"question": "Shen 说的最后一句是什么?",
|
||||
"options": [
|
||||
"Nice to meet you, too.",
|
||||
"What’s your name?",
|
||||
"Hi. I’m Shen."
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "写一句问候和姓名介绍。",
|
||||
"example": "Hello. I’m Shen.",
|
||||
"accept": [
|
||||
[
|
||||
"hello",
|
||||
"hi"
|
||||
],
|
||||
[
|
||||
"i am",
|
||||
"my name is"
|
||||
]
|
||||
],
|
||||
"hint": "试着同时写一句问候和姓名,例如:Hello. I’m …"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看句框,介绍你的名字并回应“Nice to meet you”。",
|
||||
"help": "Hi. I’m … / Nice to meet you, too.",
|
||||
"accept": [
|
||||
[
|
||||
"i'm + #word",
|
||||
"i am + #word",
|
||||
"my name is",
|
||||
"my name's"
|
||||
],
|
||||
[
|
||||
"nice to meet you"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-01-a",
|
||||
"kind": "main",
|
||||
"title": "你好,我是……",
|
||||
"goal": "问候、介绍姓名并回应见面问候",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Hi! I’m Mia. What’s your name?",
|
||||
"aiZh": "嗨!我是 Mia。你叫什么名字?",
|
||||
"goal": "说出你的名字",
|
||||
"model": "I’m Alex.",
|
||||
"accept": [
|
||||
"i'm + #word",
|
||||
"i am + #word",
|
||||
"my name is",
|
||||
"my name's",
|
||||
"name is"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Nice to meet you. Say: Nice to meet you, too.",
|
||||
"aiZh": "很高兴认识你。请说:Nice to meet you, too(我也很高兴认识你)。",
|
||||
"goal": "回应 Nice to meet you",
|
||||
"model": "Nice to meet you, too.",
|
||||
"accept": [
|
||||
"nice to meet you"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Great! Say hello one more time.",
|
||||
"aiZh": "太棒了!再打一次招呼吧。",
|
||||
"goal": "再打一次招呼",
|
||||
"model": "Hello!",
|
||||
"accept": [
|
||||
"hello",
|
||||
"hi",
|
||||
"hey"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Now ask me my name!",
|
||||
"aiZh": "现在请问我的名字!",
|
||||
"goal": "反问对方的名字",
|
||||
"model": "What’s your name?",
|
||||
"accept": [
|
||||
"what's your name",
|
||||
"what is your name",
|
||||
"your name",
|
||||
"#question"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a0-meet",
|
||||
"kind": "variant",
|
||||
"title": "初次见面",
|
||||
"goal": "姓名、地点、状态或喜好,并反问",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Hi! My name is Mia. What’s your name?",
|
||||
"aiZh": "嗨!我叫 Mia。你叫什么名字?",
|
||||
"goal": "介绍姓名",
|
||||
"model": "My name is Alex.",
|
||||
"accept": [
|
||||
"i'm + #word",
|
||||
"i am + #word",
|
||||
"my name is",
|
||||
"my name's",
|
||||
"name is"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Nice to meet you. Where are you from?",
|
||||
"aiZh": "很高兴认识你。你来自哪里?",
|
||||
"goal": "说明来自哪里",
|
||||
"model": "I’m from Hong Kong.",
|
||||
"accept": [
|
||||
"i'm from",
|
||||
"i am from",
|
||||
"from + #word"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Great! How are you today? Or what do you like?",
|
||||
"aiZh": "很好!你今天怎么样?或者你喜欢什么?",
|
||||
"goal": "表达状态或喜好",
|
||||
"model": "I’m good, thanks. / I like coffee.",
|
||||
"accept": [
|
||||
"good",
|
||||
"okay",
|
||||
"ok",
|
||||
"fine",
|
||||
"great",
|
||||
"tired",
|
||||
"i like",
|
||||
"i love"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "I’m good, thanks. Now ask me one question!",
|
||||
"aiZh": "我很好,谢谢。现在请问我一个问题!",
|
||||
"goal": "反问对方",
|
||||
"model": "What’s your name? / Where are you from?",
|
||||
"accept": [
|
||||
"#question"
|
||||
]
|
||||
}
|
||||
],
|
||||
"practice": {
|
||||
"title": "初次见面",
|
||||
"summary": "介绍姓名、地点、状态或喜好,并反问对方",
|
||||
"recapPrompt": "再用英语介绍一次自己。",
|
||||
"alwaysOpen": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"id": "a0-02",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "拼读我的名字",
|
||||
"canDo": "听懂拼写,并说出名字字母",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-P04",
|
||||
"type": "phrase",
|
||||
"en": "How do you spell that?",
|
||||
"match": [
|
||||
[
|
||||
"how do you spell"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "问对方一个词怎样拼写。",
|
||||
"hint": "问对方怎样拼写:How do you spell …?",
|
||||
"dictation": {
|
||||
"sentence": "How do you spell that?",
|
||||
"meaning": "那个怎么拼写?"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-02-a",
|
||||
"title": "拼读我的名字",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-P04"
|
||||
],
|
||||
"grammarNote": "My name is 后面接名字;拼读时每个字母之间稍微停顿。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-02-spell",
|
||||
"word": "spell",
|
||||
"meaning": "拼写",
|
||||
"example": "How do you spell that?",
|
||||
"exampleMeaning": "那个怎么拼?",
|
||||
"ipa": "/spel/"
|
||||
},
|
||||
{
|
||||
"id": "a0-02-name",
|
||||
"word": "name",
|
||||
"meaning": "名字",
|
||||
"example": "My name is Shen.",
|
||||
"exampleMeaning": "我的名字是 Shen。",
|
||||
"ipa": "/neɪm/"
|
||||
},
|
||||
{
|
||||
"id": "a0-02-question",
|
||||
"word": "How do you spell that?",
|
||||
"meaning": "那个怎么拼?",
|
||||
"example": "How do you spell your name?",
|
||||
"exampleMeaning": "你的名字怎么拼?"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-02-a",
|
||||
"listening": {
|
||||
"text": "How do you spell your name?",
|
||||
"question": "这句话是什么意思?",
|
||||
"options": [
|
||||
"你的名字怎么拼?",
|
||||
"你的名字是什么?",
|
||||
"你来自哪里?"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "S H E N",
|
||||
"tip": "字母之间留一个短停顿:S /es/ - H /eɪtʃ/ - E /iː/ - N /en/。先清楚,不必快。"
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: I’m Mia. M-I-A.\nShen: Hi, Mia. I’m Shen. S-H-E-N.\nMia: How do you spell Sam?\nShen: S-A-M.",
|
||||
"question": "Shen 自己的名字怎么拼写?",
|
||||
"options": [
|
||||
"S-H-E-N",
|
||||
"M-I-A",
|
||||
"S-A-M"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "把名字和拼写写完整。",
|
||||
"example": "My name is Shen.\nS-H-E-N.",
|
||||
"accept": [
|
||||
[
|
||||
"my name",
|
||||
"i am"
|
||||
]
|
||||
],
|
||||
"hint": "写出姓名并拼写一遍,例如 My name is Shen. S-H-E-N."
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看句框,用英文介绍名字并拼读它。",
|
||||
"help": "My name is … / A-B-C.",
|
||||
"accept": [
|
||||
[
|
||||
"i'm + #word",
|
||||
"i am + #word",
|
||||
"my name is",
|
||||
"my name's"
|
||||
],
|
||||
[
|
||||
"#spelling"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-02-a",
|
||||
"kind": "main",
|
||||
"title": "拼读我的名字",
|
||||
"goal": "介绍姓名并完整拼读名字",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Hi! What’s your name?",
|
||||
"aiZh": "嗨!你叫什么名字?",
|
||||
"goal": "说出你的名字",
|
||||
"model": "My name is Alex.",
|
||||
"accept": [
|
||||
"i'm + #word",
|
||||
"i am + #word",
|
||||
"my name is",
|
||||
"my name's",
|
||||
"name is"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "How do you spell that?",
|
||||
"aiZh": "那个怎么拼写?",
|
||||
"goal": "拼读你的名字",
|
||||
"model": "A-L-E-X.",
|
||||
"accept": [
|
||||
"#spelling"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Thank you. Spell it one more time.",
|
||||
"aiZh": "谢谢。请再拼写一次。",
|
||||
"goal": "再拼读一次",
|
||||
"model": "A-L-E-X.",
|
||||
"accept": [
|
||||
"#spelling"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Now ask me my name!",
|
||||
"aiZh": "现在请问我的名字!",
|
||||
"goal": "反问对方的名字",
|
||||
"model": "What’s your name?",
|
||||
"accept": [
|
||||
"what's your name",
|
||||
"what is your name",
|
||||
"your name",
|
||||
"#question"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
{
|
||||
"id": "a0-03",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "你好吗?",
|
||||
"canDo": "问候并表达简单状态",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-W33",
|
||||
"type": "word",
|
||||
"en": "good",
|
||||
"zh": "好",
|
||||
"match": [
|
||||
[
|
||||
"good"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“好”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "I’m good.",
|
||||
"meaning": "我很好。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W34",
|
||||
"type": "word",
|
||||
"en": "okay",
|
||||
"zh": "还可以",
|
||||
"match": [
|
||||
[
|
||||
"okay"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“还可以”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "I’m okay.",
|
||||
"meaning": "我还可以。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W35",
|
||||
"type": "word",
|
||||
"en": "tired",
|
||||
"zh": "累",
|
||||
"match": [
|
||||
[
|
||||
"tired"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“累”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "I’m tired.",
|
||||
"meaning": "我累了。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P05",
|
||||
"type": "phrase",
|
||||
"en": "How are you?",
|
||||
"match": [
|
||||
[
|
||||
"how are you"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "问对方今天怎么样。",
|
||||
"dictation": {
|
||||
"sentence": "How are you?",
|
||||
"meaning": "你怎么样?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P06",
|
||||
"type": "phrase",
|
||||
"en": "I'm [state].",
|
||||
"match": [
|
||||
[
|
||||
"i am",
|
||||
"my name is"
|
||||
],
|
||||
[
|
||||
"good",
|
||||
"okay",
|
||||
"tired"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "说出你今天的状态。",
|
||||
"dictation": {
|
||||
"sentence": "I’m tired today.",
|
||||
"meaning": "我今天很累。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-03-a",
|
||||
"title": "你好吗?",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W33",
|
||||
"A0-W34",
|
||||
"A0-W35",
|
||||
"A0-P05",
|
||||
"A0-P06"
|
||||
],
|
||||
"grammarNote": "说状态用 I’m + 状态:I’m good / okay / tired。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-03-good",
|
||||
"word": "good",
|
||||
"meaning": "好的",
|
||||
"example": "I’m good, thanks.",
|
||||
"exampleMeaning": "我很好,谢谢。",
|
||||
"ipa": "/ɡʊd/"
|
||||
},
|
||||
{
|
||||
"id": "a0-03-tired",
|
||||
"word": "tired",
|
||||
"meaning": "累的",
|
||||
"example": "I’m tired today.",
|
||||
"exampleMeaning": "我今天有点累。",
|
||||
"ipa": "/ˈtaɪəd/"
|
||||
},
|
||||
{
|
||||
"id": "a0-03-how",
|
||||
"word": "How are you?",
|
||||
"meaning": "你好吗?",
|
||||
"example": "How are you today?",
|
||||
"exampleMeaning": "你今天怎么样?"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-03-a",
|
||||
"listening": {
|
||||
"text": "How are you today?",
|
||||
"question": "对方在问什么?",
|
||||
"options": [
|
||||
"你的状态",
|
||||
"你的号码",
|
||||
"你的地点"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "How are you? I’m good, thanks.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: Hi, Shen. How are you?\nShen: I’m good, thanks. How are you?\nMia: I’m tired today.",
|
||||
"question": "Mia 说自己今天怎么样?",
|
||||
"options": [
|
||||
"I’m tired today.",
|
||||
"I’m good, thanks.",
|
||||
"I’m okay."
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "写一句今天的状态。",
|
||||
"example": "I’m okay today.",
|
||||
"accept": [
|
||||
[
|
||||
"i am",
|
||||
"my name is"
|
||||
],
|
||||
[
|
||||
"good",
|
||||
"okay",
|
||||
"tired"
|
||||
]
|
||||
],
|
||||
"hint": "用 I’m / I am 加上你的状态,例如 good 或 tired。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看句框,问候并说出你的状态。",
|
||||
"help": "How are you? / I’m …",
|
||||
"accept": [
|
||||
[
|
||||
"hello",
|
||||
"hi",
|
||||
"hey",
|
||||
"how are you"
|
||||
],
|
||||
[
|
||||
"good",
|
||||
"okay",
|
||||
"ok",
|
||||
"fine",
|
||||
"great",
|
||||
"tired"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-03-a",
|
||||
"kind": "main",
|
||||
"title": "你好吗?",
|
||||
"goal": "询问状态、回答并反问",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Hi! How are you today?",
|
||||
"aiZh": "嗨!你今天好吗?",
|
||||
"goal": "说出你今天的状态",
|
||||
"model": "I’m good, thanks.",
|
||||
"accept": [
|
||||
"good",
|
||||
"okay",
|
||||
"ok",
|
||||
"fine",
|
||||
"great",
|
||||
"tired"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Good! Ask me: How are you?",
|
||||
"aiZh": "很好!请问我:How are you(你好吗)?",
|
||||
"goal": "反问对方的状态",
|
||||
"model": "How are you?",
|
||||
"accept": [
|
||||
"how are you",
|
||||
"#question"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "I’m okay. Say your state one more time.",
|
||||
"aiZh": "我还好。请再说一次你的状态。",
|
||||
"goal": "再说一次你的状态",
|
||||
"model": "I’m okay.",
|
||||
"accept": [
|
||||
"good",
|
||||
"okay",
|
||||
"ok",
|
||||
"fine",
|
||||
"great",
|
||||
"tired"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Now say goodbye!",
|
||||
"aiZh": "现在请说再见!",
|
||||
"goal": "说再见",
|
||||
"model": "Bye!",
|
||||
"accept": [
|
||||
"bye",
|
||||
"goodbye",
|
||||
"see you"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
{
|
||||
"id": "a0-04",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "数字与电话号码",
|
||||
"canDo": "说 0–10 与虚拟号码",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-W01",
|
||||
"type": "word",
|
||||
"en": "zero",
|
||||
"zh": "零",
|
||||
"match": [
|
||||
[
|
||||
"zero"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“零”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "Zero, one, two.",
|
||||
"meaning": "零、一、二。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W02",
|
||||
"type": "word",
|
||||
"en": "one",
|
||||
"zh": "一",
|
||||
"match": [
|
||||
[
|
||||
"one"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“一”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "My number is one-two-three.",
|
||||
"meaning": "我的号码是一二三。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W03",
|
||||
"type": "word",
|
||||
"en": "two",
|
||||
"zh": "二",
|
||||
"match": [
|
||||
[
|
||||
"two"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“二”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s two o’clock.",
|
||||
"meaning": "现在两点。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W04",
|
||||
"type": "word",
|
||||
"en": "three",
|
||||
"zh": "三",
|
||||
"match": [
|
||||
[
|
||||
"three"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“三”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s three o’clock.",
|
||||
"meaning": "现在三点。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W05",
|
||||
"type": "word",
|
||||
"en": "four",
|
||||
"zh": "四",
|
||||
"match": [
|
||||
[
|
||||
"four"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“四”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s four o’clock.",
|
||||
"meaning": "现在四点。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W06",
|
||||
"type": "word",
|
||||
"en": "five",
|
||||
"zh": "五",
|
||||
"match": [
|
||||
[
|
||||
"five"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“五”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s five o’clock.",
|
||||
"meaning": "现在五点。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W07",
|
||||
"type": "word",
|
||||
"en": "six",
|
||||
"zh": "六",
|
||||
"match": [
|
||||
[
|
||||
"six"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“六”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s six o’clock.",
|
||||
"meaning": "现在六点。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W08",
|
||||
"type": "word",
|
||||
"en": "seven",
|
||||
"zh": "七",
|
||||
"match": [
|
||||
[
|
||||
"seven"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“七”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s seven o’clock.",
|
||||
"meaning": "现在七点。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W09",
|
||||
"type": "word",
|
||||
"en": "eight",
|
||||
"zh": "八",
|
||||
"match": [
|
||||
[
|
||||
"eight"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“八”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s eight o’clock.",
|
||||
"meaning": "现在八点。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W10",
|
||||
"type": "word",
|
||||
"en": "nine",
|
||||
"zh": "九",
|
||||
"match": [
|
||||
[
|
||||
"nine"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“九”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s nine o’clock.",
|
||||
"meaning": "现在九点。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W11",
|
||||
"type": "word",
|
||||
"en": "ten",
|
||||
"zh": "十",
|
||||
"match": [
|
||||
[
|
||||
"ten"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“十”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s ten o’clock.",
|
||||
"meaning": "现在十点。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W20",
|
||||
"type": "word",
|
||||
"en": "phone",
|
||||
"zh": "电话",
|
||||
"match": [
|
||||
[
|
||||
"phone"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“电话”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s a phone.",
|
||||
"meaning": "这是一部电话。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P07",
|
||||
"type": "phrase",
|
||||
"en": "What's your phone number?",
|
||||
"match": [
|
||||
[
|
||||
"what is your phone number"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "问对方的电话号码。",
|
||||
"dictation": {
|
||||
"sentence": "What’s your phone number?",
|
||||
"meaning": "你的电话号码是多少?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P08",
|
||||
"type": "phrase",
|
||||
"en": "My number is [digits].",
|
||||
"match": [
|
||||
[
|
||||
"#numbers:3"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "报出一个虚构的三位号码。",
|
||||
"hint": "说出至少三个英文数字,也可用 My number is … 开头。",
|
||||
"dictation": {
|
||||
"sentence": "My number is one-three-eight.",
|
||||
"meaning": "我的号码是一三八。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-04-three",
|
||||
"word": "three",
|
||||
"meaning": "三",
|
||||
"example": "One, three, eight.",
|
||||
"exampleMeaning": "一、三、八。",
|
||||
"ipa": "/θriː/"
|
||||
},
|
||||
{
|
||||
"id": "a0-04-number",
|
||||
"word": "number",
|
||||
"meaning": "号码",
|
||||
"example": "My number is 1-3-8.",
|
||||
"exampleMeaning": "我的号码是 138。",
|
||||
"ipa": "/ˈnʌmbə(r)/"
|
||||
},
|
||||
{
|
||||
"id": "a0-04-phone",
|
||||
"word": "phone",
|
||||
"meaning": "电话",
|
||||
"example": "What’s your phone number?",
|
||||
"exampleMeaning": "你的电话号码是多少?"
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-04-a",
|
||||
"title": "数字与电话号码",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W01",
|
||||
"A0-W02",
|
||||
"A0-W03",
|
||||
"A0-W04",
|
||||
"A0-W05",
|
||||
"A0-W06"
|
||||
],
|
||||
"grammarNote": "数字单独说就可以:zero, one, two。每个词之间稍微停顿。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "A0-W01",
|
||||
"word": "zero",
|
||||
"meaning": "零",
|
||||
"example": "Zero, one, two.",
|
||||
"exampleMeaning": "零、一、二。",
|
||||
"ipa": "/ˈzɪrəʊ/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W02",
|
||||
"word": "one",
|
||||
"meaning": "一",
|
||||
"example": "One, two, three.",
|
||||
"exampleMeaning": "一、二、三。",
|
||||
"ipa": "/wʌn/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W03",
|
||||
"word": "two",
|
||||
"meaning": "二",
|
||||
"example": "Two and three.",
|
||||
"exampleMeaning": "二和三。",
|
||||
"ipa": "/tuː/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W04",
|
||||
"word": "three",
|
||||
"meaning": "三",
|
||||
"example": "Three, four, five.",
|
||||
"exampleMeaning": "三、四、五。",
|
||||
"ipa": "/θriː/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W05",
|
||||
"word": "four",
|
||||
"meaning": "四",
|
||||
"example": "Four and five.",
|
||||
"exampleMeaning": "四和五。",
|
||||
"ipa": "/fɔː/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W06",
|
||||
"word": "five",
|
||||
"meaning": "五",
|
||||
"example": "Five, please.",
|
||||
"exampleMeaning": "五,请。",
|
||||
"ipa": "/faɪv/"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-04-a",
|
||||
"listening": {
|
||||
"text": "One, two, three.",
|
||||
"question": "听到哪一组数字?",
|
||||
"options": [
|
||||
"123",
|
||||
"132",
|
||||
"213"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "Zero, one, two, three.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: One, two, three.\nShen: Four, five.",
|
||||
"question": "Shen 接着说了哪两个数字?",
|
||||
"options": [
|
||||
"Four, five",
|
||||
"One, two",
|
||||
"Three, four"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "把 0、1、2、3 写成英文。",
|
||||
"example": "zero, one, two, three",
|
||||
"accept": [
|
||||
[
|
||||
"zero"
|
||||
],
|
||||
[
|
||||
"one"
|
||||
],
|
||||
[
|
||||
"two"
|
||||
],
|
||||
[
|
||||
"three"
|
||||
]
|
||||
],
|
||||
"hint": "请写出 zero、one、two、three 四个数字。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看帮助,说出三个 0 到 5 的数字。",
|
||||
"help": "zero / one / two / three / four / five",
|
||||
"accept": [
|
||||
[
|
||||
"#min:3",
|
||||
"zero",
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
"four",
|
||||
"five"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a0-04-b",
|
||||
"title": "数字与电话号码",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W07",
|
||||
"A0-W08",
|
||||
"A0-W09",
|
||||
"A0-W10",
|
||||
"A0-W11",
|
||||
"A0-W20"
|
||||
],
|
||||
"grammarNote": "认物品可以说 It is a phone. 口语里也常说 It’s a phone.",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "A0-W07",
|
||||
"word": "six",
|
||||
"meaning": "六",
|
||||
"example": "Six, seven, eight.",
|
||||
"exampleMeaning": "六、七、八。",
|
||||
"ipa": "/sɪks/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W08",
|
||||
"word": "seven",
|
||||
"meaning": "七",
|
||||
"example": "Seven, eight, nine.",
|
||||
"exampleMeaning": "七、八、九。",
|
||||
"ipa": "/ˈsevən/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W09",
|
||||
"word": "eight",
|
||||
"meaning": "八",
|
||||
"example": "Eight, nine, ten.",
|
||||
"exampleMeaning": "八、九、十。",
|
||||
"ipa": "/eɪt/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W10",
|
||||
"word": "nine",
|
||||
"meaning": "九",
|
||||
"example": "Nine and ten.",
|
||||
"exampleMeaning": "九和十。",
|
||||
"ipa": "/naɪn/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W11",
|
||||
"word": "ten",
|
||||
"meaning": "十",
|
||||
"example": "Ten, please.",
|
||||
"exampleMeaning": "十,请。",
|
||||
"ipa": "/ten/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W20",
|
||||
"word": "phone",
|
||||
"meaning": "电话;手机",
|
||||
"example": "It is a phone.",
|
||||
"exampleMeaning": "它是一部手机。",
|
||||
"ipa": "/fəʊn/"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-04-b",
|
||||
"listening": {
|
||||
"text": "Six, seven, eight.",
|
||||
"question": "听到哪一组数字?",
|
||||
"options": [
|
||||
"678",
|
||||
"687",
|
||||
"768"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "My phone is here.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: Six, seven, eight.\nShen: Nine, ten.",
|
||||
"question": "Shen 最后说了哪两个数字?",
|
||||
"options": [
|
||||
"Nine, ten",
|
||||
"Six, seven",
|
||||
"Seven, eight"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "把 6、7、8 写成英文。",
|
||||
"example": "six, seven, eight",
|
||||
"accept": [
|
||||
[
|
||||
"six"
|
||||
],
|
||||
[
|
||||
"seven"
|
||||
],
|
||||
[
|
||||
"eight"
|
||||
]
|
||||
],
|
||||
"hint": "请写出 six、seven、eight 三个数字。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看帮助,说出三个 6 到 10 的数字。",
|
||||
"help": "six / seven / eight / nine / ten",
|
||||
"accept": [
|
||||
[
|
||||
"#min:3",
|
||||
"six",
|
||||
"seven",
|
||||
"eight",
|
||||
"nine",
|
||||
"ten"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a0-04-c",
|
||||
"title": "数字与电话号码",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-P07",
|
||||
"A0-P08"
|
||||
],
|
||||
"grammarNote": "报号码时一个数字一个数字说:one-three-eight,不把它读成一百三十八。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "A0-P07",
|
||||
"word": "What's your phone number?",
|
||||
"meaning": "你的电话号码是多少?",
|
||||
"example": "What's your phone number?",
|
||||
"exampleMeaning": "你的电话号码是多少?"
|
||||
},
|
||||
{
|
||||
"id": "A0-P08",
|
||||
"word": "My number is one-three-eight.",
|
||||
"meaning": "我的号码是 138。",
|
||||
"example": "My number is one-three-eight.",
|
||||
"exampleMeaning": "我的号码是 138。"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-04-c",
|
||||
"listening": {
|
||||
"text": "My number is one-three-eight.",
|
||||
"question": "号码是多少?",
|
||||
"options": [
|
||||
"138",
|
||||
"183",
|
||||
"318"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "My number is one-three-eight.",
|
||||
"tip": "号码一个数字一个数字地读,数字之间稍停:one - three - eight。"
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: What’s your phone number?\nShen: My number is five-zero-two.\nMia: Five-two-zero?\nShen: No. Five-zero-two.",
|
||||
"question": "Shen 最后确认的号码是哪一个?",
|
||||
"options": [
|
||||
"502",
|
||||
"520",
|
||||
"205"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "把虚拟号码 139 写成英文。",
|
||||
"example": "one-three-nine",
|
||||
"accept": [
|
||||
[
|
||||
"#numbers:3"
|
||||
]
|
||||
],
|
||||
"hint": "用英文一个一个写出三个数字,例如 one-three-nine。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看句框,说出一个三位虚拟号码。",
|
||||
"help": "My number is one-two-three.",
|
||||
"accept": [
|
||||
[
|
||||
"#min:3",
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
"four",
|
||||
"five",
|
||||
"six",
|
||||
"seven",
|
||||
"eight",
|
||||
"nine",
|
||||
"zero"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-04-a",
|
||||
"kind": "main",
|
||||
"title": "数字与电话号码",
|
||||
"goal": "听辨并说出 0 到 5",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Say zero, one, two.",
|
||||
"aiZh": "请说 zero, one, two(0,1,2)。",
|
||||
"goal": "说出 zero, one, two",
|
||||
"model": "zero, one, two",
|
||||
"accept": [
|
||||
"zero",
|
||||
"one",
|
||||
"two"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Now say three, four, five.",
|
||||
"aiZh": "现在请说 three, four, five(3,4,5)。",
|
||||
"goal": "说出 three, four, five",
|
||||
"model": "three, four, five",
|
||||
"accept": [
|
||||
"three",
|
||||
"four",
|
||||
"five"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a0-04-b",
|
||||
"kind": "main",
|
||||
"title": "数字与电话号码",
|
||||
"goal": "听辨 6 到 10 并认识 phone",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Say six, seven, eight.",
|
||||
"aiZh": "请说 six, seven, eight(6,7,8)。",
|
||||
"goal": "说出 six, seven, eight",
|
||||
"model": "six, seven, eight",
|
||||
"accept": [
|
||||
"six",
|
||||
"seven",
|
||||
"eight"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "What is this? Say: It is a phone.",
|
||||
"aiZh": "这是什么?请说:It is a phone(这是一部手机)。",
|
||||
"goal": "说出 It is a phone.",
|
||||
"model": "It is a phone.",
|
||||
"accept": [
|
||||
"it is a phone",
|
||||
"it's a phone"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a0-04-c",
|
||||
"kind": "main",
|
||||
"title": "数字与电话号码",
|
||||
"goal": "询问并报告三位号码",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "What is your phone number?",
|
||||
"aiZh": "你的电话号码是多少?",
|
||||
"goal": "报出你的三位号码",
|
||||
"model": "My number is one-three-eight.",
|
||||
"accept": [
|
||||
"my number is + #digit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Say a three-digit number again.",
|
||||
"aiZh": "请再说一次三位数字。",
|
||||
"goal": "再说一次三位数字",
|
||||
"model": "one-three-eight",
|
||||
"accept": [
|
||||
"one",
|
||||
"two",
|
||||
"three",
|
||||
"four",
|
||||
"five",
|
||||
"six",
|
||||
"seven",
|
||||
"eight",
|
||||
"nine",
|
||||
"zero"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a0-number",
|
||||
"kind": "variant",
|
||||
"title": "留个电话",
|
||||
"goal": "问候并交换一个虚拟电话号码",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Hi! How are you today?",
|
||||
"aiZh": "嗨!你今天好吗?",
|
||||
"goal": "说出你今天的状态",
|
||||
"model": "I’m good, thanks.",
|
||||
"accept": [
|
||||
"good",
|
||||
"okay",
|
||||
"ok",
|
||||
"fine",
|
||||
"great",
|
||||
"tired"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Good. What’s your phone number? Use a fake number.",
|
||||
"aiZh": "好的。你的电话号码是多少?用一个虚拟号码。",
|
||||
"goal": "报出一个号码",
|
||||
"model": "My number is one-three-eight.",
|
||||
"accept": [
|
||||
"#digit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Sorry, please say it again.",
|
||||
"aiZh": "抱歉,请再说一遍。",
|
||||
"goal": "再说一次号码",
|
||||
"model": "One-three-eight.",
|
||||
"accept": [
|
||||
"#digit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Thanks! Now ask me one question.",
|
||||
"aiZh": "谢谢!现在问我一个问题。",
|
||||
"goal": "反问对方",
|
||||
"model": "What’s your phone number?",
|
||||
"accept": [
|
||||
"#question"
|
||||
]
|
||||
}
|
||||
],
|
||||
"practice": {
|
||||
"title": "留个电话",
|
||||
"summary": "问候、报出号码、再说一遍,并反问对方",
|
||||
"recapPrompt": "用英语报一次你的虚拟电话号码。"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
{
|
||||
"id": "a0-05",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "这是什么?",
|
||||
"canDo": "询问并说出常见物品",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-W19",
|
||||
"type": "word",
|
||||
"en": "book",
|
||||
"zh": "书",
|
||||
"match": [
|
||||
[
|
||||
"book"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“书”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s a book.",
|
||||
"meaning": "这是一本书。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W21",
|
||||
"type": "word",
|
||||
"en": "pen",
|
||||
"zh": "笔",
|
||||
"match": [
|
||||
[
|
||||
"pen"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“笔”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s a pen.",
|
||||
"meaning": "这是一支笔。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W22",
|
||||
"type": "word",
|
||||
"en": "bag",
|
||||
"zh": "包",
|
||||
"match": [
|
||||
[
|
||||
"bag"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“包”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s a bag.",
|
||||
"meaning": "这是一个包。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W23",
|
||||
"type": "word",
|
||||
"en": "key",
|
||||
"zh": "钥匙",
|
||||
"match": [
|
||||
[
|
||||
"key"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“钥匙”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s a key.",
|
||||
"meaning": "这是一把钥匙。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P09",
|
||||
"type": "phrase",
|
||||
"en": "What's this?",
|
||||
"match": [
|
||||
[
|
||||
"what is this"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "指着身边的东西问“这是什么”。",
|
||||
"dictation": {
|
||||
"sentence": "What’s this?",
|
||||
"meaning": "这是什么?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P10",
|
||||
"type": "phrase",
|
||||
"en": "It's a [object].",
|
||||
"match": [
|
||||
[
|
||||
"it is"
|
||||
],
|
||||
[
|
||||
"book",
|
||||
"pen",
|
||||
"bag",
|
||||
"key",
|
||||
"phone"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "说出“这是一件物品”。",
|
||||
"hint": "用 It’s / It is 加上一个物品。",
|
||||
"dictation": {
|
||||
"sentence": "It’s a pen.",
|
||||
"meaning": "这是一支笔。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-05-a",
|
||||
"title": "这是什么?",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W19",
|
||||
"A0-W21",
|
||||
"A0-W22",
|
||||
"A0-W23",
|
||||
"A0-P09",
|
||||
"A0-P10"
|
||||
],
|
||||
"grammarNote": "问物品用 What’s this?;回答用 It’s a + 物品。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-05-book",
|
||||
"word": "book",
|
||||
"meaning": "书",
|
||||
"example": "It’s a book.",
|
||||
"exampleMeaning": "这是一本书。",
|
||||
"ipa": "/bʊk/"
|
||||
},
|
||||
{
|
||||
"id": "a0-05-pen",
|
||||
"word": "pen",
|
||||
"meaning": "笔",
|
||||
"example": "It’s a pen.",
|
||||
"exampleMeaning": "这是一支笔。",
|
||||
"ipa": "/pen/"
|
||||
},
|
||||
{
|
||||
"id": "a0-05-this",
|
||||
"word": "What’s this?",
|
||||
"meaning": "这是什么?",
|
||||
"example": "What’s this? It’s a key.",
|
||||
"exampleMeaning": "这是什么?这是钥匙。"
|
||||
},
|
||||
{
|
||||
"id": "a0-05-bag",
|
||||
"word": "bag",
|
||||
"meaning": "包",
|
||||
"example": "It’s a bag.",
|
||||
"exampleMeaning": "这是一个包。",
|
||||
"ipa": "/bæɡ/"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-05-a",
|
||||
"listening": {
|
||||
"text": "What’s this? It’s a key.",
|
||||
"question": "这是什么?",
|
||||
"options": [
|
||||
"钥匙",
|
||||
"书",
|
||||
"水"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "What’s this? It’s a pen.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: What’s this? Is it a pen?\nShen: No. It’s a book.\nMia: And what’s this?\nShen: It’s a bag.",
|
||||
"question": "Shen 说第一件东西是什么?",
|
||||
"options": [
|
||||
"A book",
|
||||
"A pen",
|
||||
"A bag"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "写一句“这是一支笔”。",
|
||||
"example": "It’s a pen.",
|
||||
"accept": [
|
||||
[
|
||||
"it is"
|
||||
],
|
||||
[
|
||||
"book",
|
||||
"pen",
|
||||
"bag",
|
||||
"key"
|
||||
]
|
||||
],
|
||||
"hint": "用 It’s / It is 加一个物品,例如 a pen。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看句框,说出一个身边物品。",
|
||||
"help": "It’s a …",
|
||||
"accept": [
|
||||
[
|
||||
"it's a",
|
||||
"it is a",
|
||||
"it's an",
|
||||
"it is an"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-05-a",
|
||||
"kind": "main",
|
||||
"title": "这是什么?",
|
||||
"goal": "询问并说出一个物品",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "What’s this? It is a pen.",
|
||||
"aiZh": "这是什么?这是一支笔。",
|
||||
"goal": "说出这个物品",
|
||||
"model": "It’s a pen.",
|
||||
"accept": [
|
||||
"it's a",
|
||||
"it is a",
|
||||
"it's an",
|
||||
"it is an"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Now say: It’s a pen.",
|
||||
"aiZh": "现在请说:It’s a pen(这是一支笔)。",
|
||||
"goal": "完整说出 It’s a … 句型",
|
||||
"model": "It’s a pen.",
|
||||
"accept": [
|
||||
"it's a",
|
||||
"it is a",
|
||||
"it's an",
|
||||
"it is an"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Ask me: What’s this?",
|
||||
"aiZh": "请问我:What’s this(这是什么)?",
|
||||
"goal": "反问 What’s this",
|
||||
"model": "What’s this?",
|
||||
"accept": [
|
||||
"what's this",
|
||||
"what is this"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Great! Say one more object.",
|
||||
"aiZh": "太棒了!再说一个物品吧。",
|
||||
"goal": "再说一个物品",
|
||||
"model": "It’s a book.",
|
||||
"accept": [
|
||||
"it's a",
|
||||
"it is a",
|
||||
"it's an",
|
||||
"it is an"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"id": "a0-06",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "我来自哪里?",
|
||||
"canDo": "说明来自哪里并反问",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-P11",
|
||||
"type": "phrase",
|
||||
"en": "Where are you from?",
|
||||
"match": [
|
||||
[
|
||||
"where are you from"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "问对方来自哪里。",
|
||||
"dictation": {
|
||||
"sentence": "Where are you from?",
|
||||
"meaning": "你来自哪里?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P12",
|
||||
"type": "phrase",
|
||||
"en": "I'm from [place].",
|
||||
"match": [
|
||||
[
|
||||
"i am",
|
||||
"my name is"
|
||||
],
|
||||
[
|
||||
"/\\bfrom [a-z]{2,}/"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "说出你来自哪里。",
|
||||
"hint": "用 I’m from … 说出一个地点。",
|
||||
"dictation": {
|
||||
"sentence": "I’m from China.",
|
||||
"meaning": "我来自中国。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-06-a",
|
||||
"title": "我来自哪里?",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-P11",
|
||||
"A0-P12"
|
||||
],
|
||||
"grammarNote": "说地点用 I’m from + 地点:I’m from Hong Kong。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-06-from",
|
||||
"word": "from",
|
||||
"meaning": "来自",
|
||||
"example": "I’m from Hong Kong.",
|
||||
"exampleMeaning": "我来自香港。",
|
||||
"ipa": "/frəm/"
|
||||
},
|
||||
{
|
||||
"id": "a0-06-place",
|
||||
"word": "place",
|
||||
"meaning": "地点",
|
||||
"example": "Where are you from?",
|
||||
"exampleMeaning": "你来自哪里?"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-06-a",
|
||||
"listening": {
|
||||
"text": "Where are you from?",
|
||||
"question": "对方在问什么?",
|
||||
"options": [
|
||||
"来自哪里",
|
||||
"叫什么",
|
||||
"喜欢什么"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "I’m from Hong Kong.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: I’m from London. Where are you from?\nShen: I’m from Hong Kong.",
|
||||
"question": "Shen 来自哪里?",
|
||||
"options": [
|
||||
"Hong Kong",
|
||||
"London",
|
||||
"Beijing"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "写一句你来自哪里。",
|
||||
"example": "I’m from Hong Kong.",
|
||||
"accept": [
|
||||
[
|
||||
"i am",
|
||||
"my name is"
|
||||
],
|
||||
[
|
||||
"/\\bfrom [a-z]{2,}/"
|
||||
]
|
||||
],
|
||||
"hint": "用 I’m from … 写成一个完整句子。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看句框,说来自哪里并反问对方。",
|
||||
"help": "I’m from … / Where are you from?",
|
||||
"accept": [
|
||||
[
|
||||
"i'm from",
|
||||
"i am from"
|
||||
],
|
||||
[
|
||||
"where are you from",
|
||||
"#question"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-06-a",
|
||||
"kind": "main",
|
||||
"title": "我来自哪里?",
|
||||
"goal": "说来自哪里并反问",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Where are you from?",
|
||||
"aiZh": "你来自哪里?",
|
||||
"goal": "说出你来自哪里",
|
||||
"model": "I’m from Hong Kong.",
|
||||
"accept": [
|
||||
"i'm from",
|
||||
"i am from",
|
||||
"from + #word"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Nice! Ask me: Where are you from?",
|
||||
"aiZh": "很好!请问我:Where are you from(你来自哪里)?",
|
||||
"goal": "反问对方来自哪里",
|
||||
"model": "Where are you from?",
|
||||
"accept": [
|
||||
"where are you from",
|
||||
"where're you from",
|
||||
"where are you"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Say where you are from one more time.",
|
||||
"aiZh": "请再说一次你来自哪里。",
|
||||
"goal": "再说一次你来自哪里",
|
||||
"model": "I’m from Hong Kong.",
|
||||
"accept": [
|
||||
"i'm from",
|
||||
"i am from",
|
||||
"from + #word"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Say goodbye!",
|
||||
"aiZh": "请说再见!",
|
||||
"goal": "说再见",
|
||||
"model": "Bye!",
|
||||
"accept": [
|
||||
"bye",
|
||||
"goodbye",
|
||||
"see you"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
{
|
||||
"id": "a0-07",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "我的家人",
|
||||
"canDo": "介绍一位家人",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-W25",
|
||||
"type": "word",
|
||||
"en": "mother",
|
||||
"zh": "妈妈",
|
||||
"match": [
|
||||
[
|
||||
"mother"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“妈妈”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "This is my mother.",
|
||||
"meaning": "这是我妈妈。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W26",
|
||||
"type": "word",
|
||||
"en": "father",
|
||||
"zh": "爸爸",
|
||||
"match": [
|
||||
[
|
||||
"father"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“爸爸”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "This is my father.",
|
||||
"meaning": "这是我爸爸。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W27",
|
||||
"type": "word",
|
||||
"en": "sister",
|
||||
"zh": "姐妹",
|
||||
"match": [
|
||||
[
|
||||
"sister"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“姐妹”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "This is my sister.",
|
||||
"meaning": "这是我的姐妹。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W28",
|
||||
"type": "word",
|
||||
"en": "brother",
|
||||
"zh": "兄弟",
|
||||
"match": [
|
||||
[
|
||||
"brother"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“兄弟”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "This is my brother.",
|
||||
"meaning": "这是我的兄弟。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P13",
|
||||
"type": "phrase",
|
||||
"en": "This is my [person].",
|
||||
"match": [
|
||||
[
|
||||
"/\\bthis is my [a-z]{2,}/"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "介绍一位家人。",
|
||||
"dictation": {
|
||||
"sentence": "This is my sister.",
|
||||
"meaning": "这是我的姐妹。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-07-a",
|
||||
"title": "我的家人",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W25",
|
||||
"A0-W26",
|
||||
"A0-W27",
|
||||
"A0-W28",
|
||||
"A0-P13"
|
||||
],
|
||||
"grammarNote": "介绍人用 This is my + 人:This is my mother。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-07-family",
|
||||
"word": "family",
|
||||
"meaning": "家人",
|
||||
"example": "This is my family.",
|
||||
"exampleMeaning": "这是我的家人。",
|
||||
"ipa": "/ˈfæməli/"
|
||||
},
|
||||
{
|
||||
"id": "a0-07-mother",
|
||||
"word": "mother",
|
||||
"meaning": "母亲",
|
||||
"example": "This is my mother.",
|
||||
"exampleMeaning": "这是我的妈妈。"
|
||||
},
|
||||
{
|
||||
"id": "a0-07-sister",
|
||||
"word": "sister",
|
||||
"meaning": "姐妹",
|
||||
"example": "This is my sister.",
|
||||
"exampleMeaning": "这是我的姐姐(或妹妹)。",
|
||||
"ipa": "/ˈsɪstə(r)/"
|
||||
},
|
||||
{
|
||||
"id": "a0-07-brother",
|
||||
"word": "brother",
|
||||
"meaning": "兄弟",
|
||||
"example": "This is my brother.",
|
||||
"exampleMeaning": "这是我的哥哥(或弟弟)。",
|
||||
"ipa": "/ˈbrʌðə(r)/"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-07-a",
|
||||
"listening": {
|
||||
"text": "Who is this? This is my mother.",
|
||||
"question": "这个人是谁?",
|
||||
"options": [
|
||||
"妈妈",
|
||||
"朋友",
|
||||
"老师"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "This is my family.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: Who is this? Is this your father?\nShen: No. This is my mother.\nMia: And who is this? Your sister?\nShen: No. This is my brother.",
|
||||
"question": "Shen 先介绍的是哪一位家人?",
|
||||
"options": [
|
||||
"My mother",
|
||||
"My brother",
|
||||
"My father"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "介绍一位家人或朋友。",
|
||||
"example": "This is my mother.",
|
||||
"accept": [
|
||||
[
|
||||
"this is my"
|
||||
],
|
||||
[
|
||||
"mother",
|
||||
"father",
|
||||
"sister",
|
||||
"brother",
|
||||
"friend"
|
||||
]
|
||||
],
|
||||
"hint": "用 This is my … 介绍一位家人或朋友。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看句框,介绍一位家人或朋友。",
|
||||
"help": "This is my …",
|
||||
"accept": [
|
||||
[
|
||||
"this is my"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-07-a",
|
||||
"kind": "main",
|
||||
"title": "我的家人",
|
||||
"goal": "介绍一位家人或朋友",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Who is this?",
|
||||
"aiZh": "这是谁?",
|
||||
"goal": "介绍一位家人或朋友",
|
||||
"model": "This is my mother.",
|
||||
"accept": [
|
||||
"this is my",
|
||||
"this is"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Good. Say: This is my mother.",
|
||||
"aiZh": "很好。请说:This is my mother(这是我的妈妈)。",
|
||||
"goal": "完整说出 This is my … 句型",
|
||||
"model": "This is my mother.",
|
||||
"accept": [
|
||||
"this is my",
|
||||
"this is"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Ask me: Who is this?",
|
||||
"aiZh": "请问我:Who is this(这是谁)?",
|
||||
"goal": "反问 Who is this",
|
||||
"model": "Who is this?",
|
||||
"accept": [
|
||||
"who is this",
|
||||
"who's this"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Introduce one person again.",
|
||||
"aiZh": "请再介绍一个人。",
|
||||
"goal": "再介绍一个人",
|
||||
"model": "This is my friend.",
|
||||
"accept": [
|
||||
"this is my",
|
||||
"this is"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a0-photo",
|
||||
"kind": "variant",
|
||||
"title": "看照片聊天",
|
||||
"goal": "介绍照片里的人和物品",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Nice photo! Who is this?",
|
||||
"aiZh": "照片真好!这是谁?",
|
||||
"goal": "介绍照片里的人",
|
||||
"model": "This is my sister.",
|
||||
"accept": [
|
||||
"this is my",
|
||||
"this is"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "And what’s this in the photo?",
|
||||
"aiZh": "照片里这个是什么?",
|
||||
"goal": "说出照片里的物品",
|
||||
"model": "It’s a bag.",
|
||||
"accept": [
|
||||
"it's a",
|
||||
"it is a",
|
||||
"it's an",
|
||||
"it is an"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Where are you from?",
|
||||
"aiZh": "你来自哪里?",
|
||||
"goal": "说出你来自哪里",
|
||||
"model": "I’m from Hong Kong.",
|
||||
"accept": [
|
||||
"i'm from",
|
||||
"i am from",
|
||||
"from + #word"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Cool! Now ask me one question.",
|
||||
"aiZh": "真酷!现在问我一个问题。",
|
||||
"goal": "反问对方",
|
||||
"model": "Who is this? / What’s this?",
|
||||
"accept": [
|
||||
"#question"
|
||||
]
|
||||
}
|
||||
],
|
||||
"practice": {
|
||||
"title": "看照片聊天",
|
||||
"summary": "介绍照片里的人和东西,说喜好,并反问对方",
|
||||
"recapPrompt": "用英语介绍一位家人或朋友。"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
{
|
||||
"id": "a0-08",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "星期与时间",
|
||||
"canDo": "说今天和整点",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-W12",
|
||||
"type": "word",
|
||||
"en": "Monday",
|
||||
"zh": "星期一",
|
||||
"match": [
|
||||
[
|
||||
"monday"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“星期一”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s Monday.",
|
||||
"meaning": "今天星期一。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W13",
|
||||
"type": "word",
|
||||
"en": "Tuesday",
|
||||
"zh": "星期二",
|
||||
"match": [
|
||||
[
|
||||
"tuesday"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“星期二”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s Tuesday.",
|
||||
"meaning": "今天星期二。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W14",
|
||||
"type": "word",
|
||||
"en": "Wednesday",
|
||||
"zh": "星期三",
|
||||
"match": [
|
||||
[
|
||||
"wednesday"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“星期三”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s Wednesday.",
|
||||
"meaning": "今天星期三。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W15",
|
||||
"type": "word",
|
||||
"en": "Thursday",
|
||||
"zh": "星期四",
|
||||
"match": [
|
||||
[
|
||||
"thursday"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“星期四”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s Thursday.",
|
||||
"meaning": "今天星期四。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W16",
|
||||
"type": "word",
|
||||
"en": "Friday",
|
||||
"zh": "星期五",
|
||||
"match": [
|
||||
[
|
||||
"friday"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“星期五”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s Friday.",
|
||||
"meaning": "今天星期五。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W17",
|
||||
"type": "word",
|
||||
"en": "Saturday",
|
||||
"zh": "星期六",
|
||||
"match": [
|
||||
[
|
||||
"saturday"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“星期六”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s Saturday.",
|
||||
"meaning": "今天星期六。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W18",
|
||||
"type": "word",
|
||||
"en": "Sunday",
|
||||
"zh": "星期日",
|
||||
"match": [
|
||||
[
|
||||
"sunday"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“星期日”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "It’s Sunday.",
|
||||
"meaning": "今天星期日。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P14",
|
||||
"type": "phrase",
|
||||
"en": "What day is it today?",
|
||||
"match": [
|
||||
[
|
||||
"what day is it"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "问今天星期几。",
|
||||
"dictation": {
|
||||
"sentence": "What day is it today?",
|
||||
"meaning": "今天星期几?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P15",
|
||||
"type": "phrase",
|
||||
"en": "It's [weekday].",
|
||||
"match": [
|
||||
[
|
||||
"it is"
|
||||
],
|
||||
[
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "说出今天星期几。",
|
||||
"dictation": {
|
||||
"sentence": "It’s Friday.",
|
||||
"meaning": "今天星期五。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P16",
|
||||
"type": "phrase",
|
||||
"en": "What time is it?",
|
||||
"match": [
|
||||
[
|
||||
"what time is it"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "问现在几点。",
|
||||
"dictation": {
|
||||
"sentence": "What time is it?",
|
||||
"meaning": "现在几点?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P17",
|
||||
"type": "phrase",
|
||||
"en": "It's [hour] o'clock.",
|
||||
"match": [
|
||||
[
|
||||
"it is"
|
||||
],
|
||||
[
|
||||
"o'clock",
|
||||
"oclock",
|
||||
"clock"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "说出一个整点时间。",
|
||||
"hint": "用 It’s … o’clock 说整点时间。",
|
||||
"dictation": {
|
||||
"sentence": "It’s three o’clock.",
|
||||
"meaning": "现在三点。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-08-monday",
|
||||
"word": "Monday",
|
||||
"meaning": "星期一",
|
||||
"example": "It is Monday.",
|
||||
"exampleMeaning": "今天是星期一。"
|
||||
},
|
||||
{
|
||||
"id": "a0-08-oclock",
|
||||
"word": "o’clock",
|
||||
"meaning": "整点",
|
||||
"example": "It’s three o’clock.",
|
||||
"exampleMeaning": "现在三点整。"
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-08-a",
|
||||
"title": "星期与时间",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W12",
|
||||
"A0-W13",
|
||||
"A0-W14",
|
||||
"A0-P14",
|
||||
"A0-P15"
|
||||
],
|
||||
"grammarNote": "问星期用 What day is it?;回答用 It is / It’s + 星期。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "A0-W12",
|
||||
"word": "Monday",
|
||||
"meaning": "星期一",
|
||||
"example": "It is Monday.",
|
||||
"exampleMeaning": "今天是星期一。",
|
||||
"ipa": "/ˈmʌndeɪ/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W13",
|
||||
"word": "Tuesday",
|
||||
"meaning": "星期二",
|
||||
"example": "It is Tuesday.",
|
||||
"exampleMeaning": "今天是星期二。",
|
||||
"ipa": "/ˈtjuːzdeɪ/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W14",
|
||||
"word": "Wednesday",
|
||||
"meaning": "星期三",
|
||||
"example": "It is Wednesday.",
|
||||
"exampleMeaning": "今天是星期三。",
|
||||
"ipa": "/ˈwenzdeɪ/"
|
||||
},
|
||||
{
|
||||
"id": "A0-P14",
|
||||
"word": "What day is it today?",
|
||||
"meaning": "今天星期几?",
|
||||
"example": "What day is it today?",
|
||||
"exampleMeaning": "今天星期几?"
|
||||
},
|
||||
{
|
||||
"id": "A0-P15",
|
||||
"word": "It's Monday.",
|
||||
"meaning": "今天是星期一。",
|
||||
"example": "It's Monday.",
|
||||
"exampleMeaning": "今天是星期一。"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-08-a",
|
||||
"listening": {
|
||||
"text": "It is Monday.",
|
||||
"question": "今天星期几?",
|
||||
"options": [
|
||||
"星期一",
|
||||
"星期二",
|
||||
"星期三"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "It is Monday.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: What day is it today? Is it Monday?\nShen: No. It is Tuesday.",
|
||||
"question": "Mia 猜错了,今天其实是星期几?",
|
||||
"options": [
|
||||
"Tuesday",
|
||||
"Monday",
|
||||
"Wednesday"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "写一句今天是星期一、二或三。",
|
||||
"example": "It is Monday.",
|
||||
"accept": [
|
||||
[
|
||||
"it is"
|
||||
],
|
||||
[
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday"
|
||||
]
|
||||
],
|
||||
"hint": "用 It is / It’s 加 Monday、Tuesday 或 Wednesday。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看帮助,说出星期一、二或三。",
|
||||
"help": "It is Monday / Tuesday / Wednesday.",
|
||||
"accept": [
|
||||
[
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a0-08-b",
|
||||
"title": "星期与时间",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W15",
|
||||
"A0-W16",
|
||||
"A0-W17",
|
||||
"A0-W18"
|
||||
],
|
||||
"grammarNote": "星期四到星期日也沿用 It is / It’s + 星期这个句型。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "A0-W15",
|
||||
"word": "Thursday",
|
||||
"meaning": "星期四",
|
||||
"example": "It is Thursday.",
|
||||
"exampleMeaning": "今天是星期四。",
|
||||
"ipa": "/ˈθɜːzdeɪ/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W16",
|
||||
"word": "Friday",
|
||||
"meaning": "星期五",
|
||||
"example": "It is Friday.",
|
||||
"exampleMeaning": "今天是星期五。",
|
||||
"ipa": "/ˈfraɪdeɪ/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W17",
|
||||
"word": "Saturday",
|
||||
"meaning": "星期六",
|
||||
"example": "It is Saturday.",
|
||||
"exampleMeaning": "今天是星期六。",
|
||||
"ipa": "/ˈsætədeɪ/"
|
||||
},
|
||||
{
|
||||
"id": "A0-W18",
|
||||
"word": "Sunday",
|
||||
"meaning": "星期日",
|
||||
"example": "It is Sunday.",
|
||||
"exampleMeaning": "今天是星期日。",
|
||||
"ipa": "/ˈsʌndeɪ/"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-08-b",
|
||||
"listening": {
|
||||
"text": "It is Friday.",
|
||||
"question": "今天星期几?",
|
||||
"options": [
|
||||
"星期五",
|
||||
"星期四",
|
||||
"星期日"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "It is Friday.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: Is it Saturday today?\nShen: No. It is Sunday.",
|
||||
"question": "Shen 说今天是哪一天?",
|
||||
"options": [
|
||||
"Sunday",
|
||||
"Saturday",
|
||||
"Friday"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "写一句今天是星期四到日。",
|
||||
"example": "It is Friday.",
|
||||
"accept": [
|
||||
[
|
||||
"it is"
|
||||
],
|
||||
[
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday"
|
||||
]
|
||||
],
|
||||
"hint": "用 It is / It’s 加 Thursday、Friday、Saturday 或 Sunday。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看帮助,说出星期四到日。",
|
||||
"help": "It is Thursday / Friday / Saturday / Sunday.",
|
||||
"accept": [
|
||||
[
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday"
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a0-08-c",
|
||||
"title": "星期与时间",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-P16",
|
||||
"A0-P17"
|
||||
],
|
||||
"grammarNote": "说整点用 It is / It’s + 数字 + o’clock,例如 It’s three o’clock。",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "A0-P16",
|
||||
"word": "What time is it?",
|
||||
"meaning": "现在几点?",
|
||||
"example": "What time is it?",
|
||||
"exampleMeaning": "现在几点?"
|
||||
},
|
||||
{
|
||||
"id": "A0-P17",
|
||||
"word": "It's three o'clock.",
|
||||
"meaning": "现在三点。",
|
||||
"example": "It's three o'clock.",
|
||||
"exampleMeaning": "现在三点。"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-08-c",
|
||||
"listening": {
|
||||
"text": "It is three o’clock.",
|
||||
"question": "是什么时间?",
|
||||
"options": [
|
||||
"三点",
|
||||
"一点",
|
||||
"星期三"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "It is three o’clock.",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: What time is it? Is it two o’clock?\nShen: No. It is three o’clock.",
|
||||
"question": "Shen 说现在是几点?",
|
||||
"options": [
|
||||
"Three o’clock",
|
||||
"Two o’clock",
|
||||
"One o’clock"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "写一句整点时间。",
|
||||
"example": "It is three o’clock.",
|
||||
"accept": [
|
||||
[
|
||||
"it is"
|
||||
],
|
||||
[
|
||||
"o'clock",
|
||||
"oclock"
|
||||
]
|
||||
],
|
||||
"hint": "用 It is / It’s 加数字和 o’clock,例如 It’s three o’clock。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看帮助,说一个整点时间。",
|
||||
"help": "It is … o’clock.",
|
||||
"accept": [
|
||||
[
|
||||
"o'clock",
|
||||
"oclock"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-08-a",
|
||||
"kind": "main",
|
||||
"title": "星期与时间",
|
||||
"goal": "询问并说出星期一到三",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "What day is it?",
|
||||
"aiZh": "今天星期几?",
|
||||
"goal": "问今天星期几",
|
||||
"model": "What day is it?",
|
||||
"accept": [
|
||||
"what day"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Say Monday, Tuesday, or Wednesday.",
|
||||
"aiZh": "请说 Monday, Tuesday, 或 Wednesday(周一、周二或周三)。",
|
||||
"goal": "说出周一到周三中的一天",
|
||||
"model": "It is Monday.",
|
||||
"accept": [
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a0-08-b",
|
||||
"kind": "main",
|
||||
"title": "星期与时间",
|
||||
"goal": "说出星期四到日",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "What day is it?",
|
||||
"aiZh": "今天星期几?",
|
||||
"goal": "问今天星期几",
|
||||
"model": "What day is it?",
|
||||
"accept": [
|
||||
"what day"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Say Thursday, Friday, Saturday, or Sunday.",
|
||||
"aiZh": "请说 Thursday, Friday, Saturday, 或 Sunday(周四、周五、周六或周日)。",
|
||||
"goal": "说出周四到周日中的一天",
|
||||
"model": "It is Friday.",
|
||||
"accept": [
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a0-08-c",
|
||||
"kind": "main",
|
||||
"title": "星期与时间",
|
||||
"goal": "询问并说出整点",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "What time is it?",
|
||||
"aiZh": "现在几点了?",
|
||||
"goal": "问现在几点",
|
||||
"model": "What time is it?",
|
||||
"accept": [
|
||||
"what time"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Say one full time sentence.",
|
||||
"aiZh": "请说一个完整的时间句子。",
|
||||
"goal": "说一个完整的时间句子",
|
||||
"model": "It is three o’clock.",
|
||||
"accept": [
|
||||
"o'clock",
|
||||
"oclock"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
{
|
||||
"id": "a0-09",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "我喜欢……",
|
||||
"canDo": "表达喜好并回答",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-W29",
|
||||
"type": "word",
|
||||
"en": "coffee",
|
||||
"zh": "咖啡",
|
||||
"match": [
|
||||
[
|
||||
"coffee"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“咖啡”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "I like coffee.",
|
||||
"meaning": "我喜欢咖啡。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W30",
|
||||
"type": "word",
|
||||
"en": "tea",
|
||||
"zh": "茶",
|
||||
"match": [
|
||||
[
|
||||
"tea"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“茶”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "Do you like tea?",
|
||||
"meaning": "你喜欢茶吗?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W31",
|
||||
"type": "word",
|
||||
"en": "music",
|
||||
"zh": "音乐",
|
||||
"match": [
|
||||
[
|
||||
"music"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“音乐”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "I like music.",
|
||||
"meaning": "我喜欢音乐。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W32",
|
||||
"type": "word",
|
||||
"en": "movies",
|
||||
"zh": "电影",
|
||||
"match": [
|
||||
[
|
||||
"movies"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“电影”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "Do you like movies?",
|
||||
"meaning": "你喜欢电影吗?"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W38",
|
||||
"type": "word",
|
||||
"en": "yes",
|
||||
"zh": "是",
|
||||
"match": [
|
||||
[
|
||||
"yes"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“是”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "Yes, I do.",
|
||||
"meaning": "是的,我喜欢。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-W39",
|
||||
"type": "word",
|
||||
"en": "no",
|
||||
"zh": "不",
|
||||
"match": [
|
||||
[
|
||||
"no"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“不”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "No, I don’t.",
|
||||
"meaning": "不,我不喜欢。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P18",
|
||||
"type": "phrase",
|
||||
"en": "I like [thing].",
|
||||
"match": [
|
||||
[
|
||||
"/\\bi (like|love) [a-z]{2,}/"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "说出你喜欢的一样东西。",
|
||||
"hint": "用 I like … 说一项喜好。",
|
||||
"dictation": {
|
||||
"sentence": "I like music.",
|
||||
"meaning": "我喜欢音乐。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P19",
|
||||
"type": "phrase",
|
||||
"en": "Do you like [thing]?",
|
||||
"match": [
|
||||
[
|
||||
"do you like"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "问对方是否喜欢一样东西。",
|
||||
"dictation": {
|
||||
"sentence": "Do you like coffee?",
|
||||
"meaning": "你喜欢咖啡吗?"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-09-a",
|
||||
"title": "我喜欢……",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W29",
|
||||
"A0-W30",
|
||||
"A0-W31",
|
||||
"A0-W32",
|
||||
"A0-W38",
|
||||
"A0-W39",
|
||||
"A0-P18",
|
||||
"A0-P19"
|
||||
],
|
||||
"grammarNote": "说喜好用 I like + 东西;一般问句用 Do you like + 东西?",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-09-like",
|
||||
"word": "like",
|
||||
"meaning": "喜欢",
|
||||
"example": "I like tea.",
|
||||
"exampleMeaning": "我喜欢茶。",
|
||||
"ipa": "/laɪk/"
|
||||
},
|
||||
{
|
||||
"id": "a0-09-tea",
|
||||
"word": "tea",
|
||||
"meaning": "茶",
|
||||
"example": "Do you like tea?",
|
||||
"exampleMeaning": "你喜欢茶吗?"
|
||||
},
|
||||
{
|
||||
"id": "a0-09-music",
|
||||
"word": "music",
|
||||
"meaning": "音乐",
|
||||
"example": "I like music.",
|
||||
"exampleMeaning": "我喜欢音乐。",
|
||||
"ipa": "/ˈmjuːzɪk/"
|
||||
},
|
||||
{
|
||||
"id": "a0-09-movies",
|
||||
"word": "movies",
|
||||
"meaning": "电影",
|
||||
"example": "Do you like movies?",
|
||||
"exampleMeaning": "你喜欢看电影吗?",
|
||||
"ipa": "/ˈmuːviz/"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-09-a",
|
||||
"listening": {
|
||||
"text": "Do you like tea? Yes, I do.",
|
||||
"question": "对方喜欢茶吗?",
|
||||
"options": [
|
||||
"喜欢",
|
||||
"不喜欢",
|
||||
"不知道"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "I like tea. Do you like tea?",
|
||||
"tip": ""
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Mia: I like tea. Do you like tea?\nShen: No, I don’t. I like coffee and music.\nMia: Do you like movies?\nShen: Yes, I do.",
|
||||
"question": "Shen 喜欢喝什么?",
|
||||
"options": [
|
||||
"Coffee",
|
||||
"Tea",
|
||||
"Music"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "写一句你的喜好。",
|
||||
"example": "I like tea.",
|
||||
"accept": [
|
||||
[
|
||||
"like"
|
||||
],
|
||||
[
|
||||
"#words:3"
|
||||
]
|
||||
],
|
||||
"hint": "用 I like … 写出一种喜好。"
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看句框,说一个喜好并反问。",
|
||||
"help": "I like … / Do you like …?",
|
||||
"accept": [
|
||||
[
|
||||
"i like",
|
||||
"i love"
|
||||
],
|
||||
[
|
||||
"do you like",
|
||||
"#question"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-09-a",
|
||||
"kind": "main",
|
||||
"title": "我喜欢……",
|
||||
"goal": "表达喜好、回答和反问",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "What do you like?",
|
||||
"aiZh": "你喜欢什么?",
|
||||
"goal": "说出你喜欢什么",
|
||||
"model": "I like tea.",
|
||||
"accept": [
|
||||
"i like",
|
||||
"i love"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Do you like tea?",
|
||||
"aiZh": "你喜欢茶吗?",
|
||||
"goal": "回答是否喜欢",
|
||||
"model": "Yes, I do.",
|
||||
"accept": [
|
||||
"yes",
|
||||
"no",
|
||||
"i do",
|
||||
"i don't",
|
||||
"i do not"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Now ask me what I like.",
|
||||
"aiZh": "现在请问我喜欢什么。",
|
||||
"goal": "反问对方的喜好",
|
||||
"model": "Do you like tea?",
|
||||
"accept": [
|
||||
"do you like",
|
||||
"#question"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Say your like one more time.",
|
||||
"aiZh": "请再说一次你的喜好。",
|
||||
"goal": "再说一次你的喜好",
|
||||
"model": "I like tea.",
|
||||
"accept": [
|
||||
"i like",
|
||||
"i love"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "a0-plan",
|
||||
"kind": "variant",
|
||||
"title": "约个时间",
|
||||
"goal": "说明星期、时间和喜好",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Hi! What day is it today?",
|
||||
"aiZh": "嗨!今天星期几?",
|
||||
"goal": "说出今天星期几",
|
||||
"model": "It’s Friday.",
|
||||
"accept": [
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "What time is it now?",
|
||||
"aiZh": "现在几点?",
|
||||
"goal": "说出现在几点",
|
||||
"model": "It’s three o’clock.",
|
||||
"accept": [
|
||||
"o'clock",
|
||||
"oclock",
|
||||
"it's + #digit",
|
||||
"it is + #digit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Do you like coffee or tea?",
|
||||
"aiZh": "你喜欢咖啡还是茶?",
|
||||
"goal": "说出你的喜好",
|
||||
"model": "I like tea.",
|
||||
"accept": [
|
||||
"i like",
|
||||
"i love",
|
||||
"yes",
|
||||
"no",
|
||||
"i don't"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Me too! Now ask me one question.",
|
||||
"aiZh": "我也是!现在问我一个问题。",
|
||||
"goal": "反问对方",
|
||||
"model": "Do you like music?",
|
||||
"accept": [
|
||||
"#question"
|
||||
]
|
||||
}
|
||||
],
|
||||
"practice": {
|
||||
"title": "约个时间",
|
||||
"summary": "说星期和整点,回答喜好,并反问对方",
|
||||
"recapPrompt": "用英语说今天星期几和现在几点。"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"id": "a0-10",
|
||||
"level": "A0",
|
||||
"status": "approved",
|
||||
"title": "A0 综合任务",
|
||||
"canDo": "没听清时请对方重复,并完成自我介绍、喜好和反问",
|
||||
"categories": [],
|
||||
"coreItems": [
|
||||
{
|
||||
"id": "A0-W24",
|
||||
"type": "word",
|
||||
"en": "water",
|
||||
"zh": "水",
|
||||
"match": [
|
||||
[
|
||||
"water"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "把“水”写成英文。",
|
||||
"dictation": {
|
||||
"sentence": "I like water.",
|
||||
"meaning": "我喜欢喝水。"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "A0-P20",
|
||||
"type": "phrase",
|
||||
"en": "Please say that again.",
|
||||
"match": [
|
||||
[
|
||||
"please say that again",
|
||||
"please speak slowly"
|
||||
]
|
||||
],
|
||||
"review": {
|
||||
"prompt": "请对方再说一次。",
|
||||
"dictation": {
|
||||
"sentence": "Please say that again.",
|
||||
"meaning": "请再说一遍。"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"id": "a0-10-a",
|
||||
"title": "A0 综合任务",
|
||||
"minutes": 12,
|
||||
"itemIds": [
|
||||
"A0-W24",
|
||||
"A0-P20"
|
||||
],
|
||||
"grammarNote": "礼貌请求以 Please 开头:Please say that again.",
|
||||
"vocabulary": [
|
||||
{
|
||||
"id": "a0-10-water",
|
||||
"word": "water",
|
||||
"meaning": "水",
|
||||
"example": "I like water.",
|
||||
"exampleMeaning": "我喜欢喝水。",
|
||||
"ipa": "/ˈwɔːtər/"
|
||||
},
|
||||
{
|
||||
"id": "a0-10-again",
|
||||
"word": "Please say that again.",
|
||||
"meaning": "请再说一遍。",
|
||||
"example": "Please say that again.",
|
||||
"exampleMeaning": "请再说一遍。"
|
||||
},
|
||||
{
|
||||
"id": "a0-10-slowly",
|
||||
"word": "Please speak slowly.",
|
||||
"meaning": "请说慢一点。",
|
||||
"example": "Please speak slowly.",
|
||||
"exampleMeaning": "请说慢一点。"
|
||||
}
|
||||
],
|
||||
"sceneId": "a0-10-a",
|
||||
"listening": {
|
||||
"text": "Do you like water? Sorry, please say that again.",
|
||||
"question": "第二个人希望对方做什么?",
|
||||
"options": [
|
||||
"再说一遍",
|
||||
"说慢一点",
|
||||
"给他一杯水"
|
||||
]
|
||||
},
|
||||
"speaking": [
|
||||
{
|
||||
"text": "Sorry, please say that again. I like water.",
|
||||
"tip": "water 的 t 常读得很轻,像轻轻的 d。先清楚,不必快。"
|
||||
}
|
||||
],
|
||||
"reading": {
|
||||
"text": "Alex: Hi! I’m Alex. Where are you from?\nShen: Sorry? Please say that again.\nAlex: Where are you from?\nShen: I’m from Hong Kong. Do you like coffee?\nAlex: No, I don’t. I like water.",
|
||||
"question": "Alex 喜欢喝什么?",
|
||||
"options": [
|
||||
"Water",
|
||||
"Coffee",
|
||||
"Tea"
|
||||
]
|
||||
},
|
||||
"writing": {
|
||||
"prompt": "写姓名、地点和喜好三句,喜好用 water。",
|
||||
"example": "I’m Shen.\nI’m from Hong Kong.\nI like water.",
|
||||
"accept": [
|
||||
[
|
||||
"i am",
|
||||
"my name is"
|
||||
],
|
||||
[
|
||||
"from"
|
||||
],
|
||||
[
|
||||
"like"
|
||||
],
|
||||
[
|
||||
"water"
|
||||
]
|
||||
],
|
||||
"hint": "分别写姓名、来自哪里和喜好三部分,喜好用 water:I like water."
|
||||
},
|
||||
"independent": {
|
||||
"prompt": "不看帮助:先请对方再说一遍,再说姓名、喜欢喝什么,并反问对方。",
|
||||
"help": "Please say that again. / I’m … / I like water. / Do you like …?",
|
||||
"accept": [
|
||||
[
|
||||
"say that again",
|
||||
"speak slowly",
|
||||
"pardon"
|
||||
],
|
||||
[
|
||||
"i'm + #word",
|
||||
"i am + #word",
|
||||
"my name is",
|
||||
"my name's"
|
||||
],
|
||||
[
|
||||
"i like",
|
||||
"i love"
|
||||
],
|
||||
[
|
||||
"#question"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"id": "a0-10-a",
|
||||
"kind": "main",
|
||||
"title": "A0 综合任务",
|
||||
"goal": "请求重复或放慢语速,并完成基础沟通",
|
||||
"turns": [
|
||||
{
|
||||
"ai": "Say: Please say that again.",
|
||||
"aiZh": "请说:Please say that again(请再说一遍)。",
|
||||
"goal": "请求对方重复",
|
||||
"model": "Please say that again.",
|
||||
"accept": [
|
||||
"say that again",
|
||||
"again",
|
||||
"pardon"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Say: Please speak slowly.",
|
||||
"aiZh": "请说:Please speak slowly(请说慢一点)。",
|
||||
"goal": "请求对方放慢语速",
|
||||
"model": "Please speak slowly.",
|
||||
"accept": [
|
||||
"slowly",
|
||||
"slow"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Now ask me your name or where I am from.",
|
||||
"aiZh": "现在请问我的名字或我来自哪里。",
|
||||
"goal": "反问名字或来自哪里",
|
||||
"model": "What’s your name?",
|
||||
"accept": [
|
||||
"what's your name",
|
||||
"what is your name",
|
||||
"where are you from",
|
||||
"your name",
|
||||
"#question"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ai": "Tell me what you like to drink.",
|
||||
"aiZh": "告诉我你喜欢喝什么。",
|
||||
"goal": "说出你喜欢喝什么",
|
||||
"model": "I like water.",
|
||||
"accept": [
|
||||
"i like",
|
||||
"i love"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tasks": [],
|
||||
"materials": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -682,7 +682,8 @@
|
||||
"A1-U01-P08"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U01-C2",
|
||||
|
||||
@@ -614,7 +614,8 @@
|
||||
"A1-U02-P06"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U02-C2",
|
||||
|
||||
@@ -653,7 +653,8 @@
|
||||
"A1-U03-P06"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U03-C2",
|
||||
|
||||
@@ -698,7 +698,8 @@
|
||||
"zh": "太好了,非常感谢!",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U04-C2",
|
||||
|
||||
@@ -673,7 +673,8 @@
|
||||
"A1-U05-P05"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U05-C2",
|
||||
|
||||
@@ -736,7 +736,8 @@
|
||||
"A1-U06-W07"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U06-C2",
|
||||
|
||||
@@ -683,7 +683,8 @@
|
||||
"zh": "谢谢!",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U07-C2",
|
||||
|
||||
@@ -682,7 +682,8 @@
|
||||
"A1-U08-P07"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U08-C2",
|
||||
|
||||
@@ -721,7 +721,8 @@
|
||||
"A1-U09-W06"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U09-C2",
|
||||
|
||||
@@ -657,7 +657,8 @@
|
||||
"A1-U10-P07"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U10-C2",
|
||||
|
||||
@@ -706,7 +706,8 @@
|
||||
"A1-U11-W03"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U11-C2",
|
||||
|
||||
@@ -723,7 +723,8 @@
|
||||
"zh": "我要了。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U12-C2",
|
||||
|
||||
@@ -728,7 +728,8 @@
|
||||
"A1-U13-P09"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U13-C2",
|
||||
|
||||
@@ -704,7 +704,8 @@
|
||||
"A1-U14-P11"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U14-C2",
|
||||
|
||||
@@ -666,7 +666,8 @@
|
||||
"A1-U15-P09"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U15-C2",
|
||||
|
||||
@@ -704,7 +704,8 @@
|
||||
"A1-U16-P07"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U16-C2",
|
||||
|
||||
@@ -666,7 +666,8 @@
|
||||
"A1-U17-P11"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U17-C2",
|
||||
|
||||
@@ -681,7 +681,8 @@
|
||||
"A1-U18-P09"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U18-C2",
|
||||
|
||||
@@ -661,7 +661,8 @@
|
||||
"A1-U19-P04"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U19-C2",
|
||||
|
||||
@@ -612,7 +612,8 @@
|
||||
"A1-U20-P11"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U20-C2",
|
||||
|
||||
@@ -704,7 +704,8 @@
|
||||
"A1-U21-P10"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A1-U21-C2",
|
||||
|
||||
@@ -864,7 +864,8 @@
|
||||
"A2-U01-W01"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U01-C2",
|
||||
|
||||
@@ -896,7 +896,8 @@
|
||||
"A2-U02-P11"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U02-C2",
|
||||
|
||||
@@ -867,7 +867,8 @@
|
||||
"A2-U03-P09"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U03-C2",
|
||||
|
||||
@@ -907,7 +907,8 @@
|
||||
"A2-U04-P04"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U04-C2",
|
||||
|
||||
@@ -898,7 +898,8 @@
|
||||
"zh": "那我们不要沙拉了,谢谢。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U05-C2",
|
||||
|
||||
@@ -875,7 +875,8 @@
|
||||
"A2-U06-P08"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U06-C2",
|
||||
|
||||
@@ -909,7 +909,8 @@
|
||||
"zh": "太好了,谢谢。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U07-C2",
|
||||
|
||||
@@ -853,7 +853,8 @@
|
||||
"zh": "太好了,谢谢。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U08-C2",
|
||||
|
||||
@@ -883,7 +883,8 @@
|
||||
"A2-U09-W03"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U09-C2",
|
||||
|
||||
@@ -895,7 +895,8 @@
|
||||
"A2-U10-W01"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U10-C2",
|
||||
|
||||
@@ -905,7 +905,8 @@
|
||||
"A2-U11-P10"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U11-C2",
|
||||
|
||||
@@ -847,7 +847,8 @@
|
||||
"A2-U12-W03"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U12-C2",
|
||||
|
||||
@@ -893,7 +893,8 @@
|
||||
"A2-U13-P07"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U13-C2",
|
||||
|
||||
@@ -875,7 +875,8 @@
|
||||
"A2-U14-P09"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U14-C2",
|
||||
|
||||
@@ -810,7 +810,8 @@
|
||||
"A2-U15-P10"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "A2-U15-C2",
|
||||
|
||||
@@ -1085,7 +1085,8 @@
|
||||
"B1-U01-P09"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U01-C2",
|
||||
|
||||
@@ -1054,7 +1054,8 @@
|
||||
"B1-U02-P04"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U02-C2",
|
||||
|
||||
@@ -1044,7 +1044,8 @@
|
||||
"zh": "谢谢你的帮助。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U03-C2",
|
||||
|
||||
@@ -1096,7 +1096,8 @@
|
||||
"zh": "太好了。谢谢你的帮助。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U04-C2",
|
||||
|
||||
@@ -1013,7 +1013,8 @@
|
||||
"zh": "非常感谢。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U05-C2",
|
||||
|
||||
@@ -1015,7 +1015,8 @@
|
||||
"zh": "好的,谢谢医生。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U06-C2",
|
||||
|
||||
@@ -1005,7 +1005,8 @@
|
||||
"zh": "谢谢,感谢你的帮助。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U07-C2",
|
||||
|
||||
@@ -1012,7 +1012,8 @@
|
||||
"zh": "周六见,再见。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U08-C2",
|
||||
|
||||
@@ -978,7 +978,8 @@
|
||||
"zh": "好的,谢谢你的帮助。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U09-C2",
|
||||
|
||||
@@ -1003,7 +1003,8 @@
|
||||
"zh": "太好了,我相信你会喜欢的。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U10-C2",
|
||||
|
||||
@@ -1016,7 +1016,8 @@
|
||||
"zh": "好的,我给百分之十。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U11-C2",
|
||||
|
||||
@@ -1010,7 +1010,8 @@
|
||||
"zh": "真羡慕你!多给我讲讲。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U12-C2",
|
||||
|
||||
@@ -1167,7 +1167,8 @@
|
||||
"zh": "好,走吧。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U13-C2",
|
||||
|
||||
@@ -967,7 +967,8 @@
|
||||
"zh": "好的,我今天就发。",
|
||||
"itemIds": []
|
||||
}
|
||||
]
|
||||
],
|
||||
"practice": {}
|
||||
},
|
||||
{
|
||||
"id": "B1-U14-C2",
|
||||
|
||||
@@ -98,6 +98,83 @@
|
||||
"atm"
|
||||
],
|
||||
"levels": [
|
||||
{
|
||||
"id": "A0",
|
||||
"title": "零基础起步",
|
||||
"assessment": "A0/assessment.json",
|
||||
"units": [
|
||||
{
|
||||
"id": "a0-01",
|
||||
"file": "A0/a0-01.json",
|
||||
"title": "你好,我是……",
|
||||
"categories": [],
|
||||
"canDo": "介绍姓名并回应问候"
|
||||
},
|
||||
{
|
||||
"id": "a0-02",
|
||||
"file": "A0/a0-02.json",
|
||||
"title": "拼读我的名字",
|
||||
"categories": [],
|
||||
"canDo": "听懂拼写,并说出名字字母"
|
||||
},
|
||||
{
|
||||
"id": "a0-03",
|
||||
"file": "A0/a0-03.json",
|
||||
"title": "你好吗?",
|
||||
"categories": [],
|
||||
"canDo": "问候并表达简单状态"
|
||||
},
|
||||
{
|
||||
"id": "a0-04",
|
||||
"file": "A0/a0-04.json",
|
||||
"title": "数字与电话号码",
|
||||
"categories": [],
|
||||
"canDo": "说 0–10 与虚拟号码"
|
||||
},
|
||||
{
|
||||
"id": "a0-05",
|
||||
"file": "A0/a0-05.json",
|
||||
"title": "这是什么?",
|
||||
"categories": [],
|
||||
"canDo": "询问并说出常见物品"
|
||||
},
|
||||
{
|
||||
"id": "a0-06",
|
||||
"file": "A0/a0-06.json",
|
||||
"title": "我来自哪里?",
|
||||
"categories": [],
|
||||
"canDo": "说明来自哪里并反问"
|
||||
},
|
||||
{
|
||||
"id": "a0-07",
|
||||
"file": "A0/a0-07.json",
|
||||
"title": "我的家人",
|
||||
"categories": [],
|
||||
"canDo": "介绍一位家人"
|
||||
},
|
||||
{
|
||||
"id": "a0-08",
|
||||
"file": "A0/a0-08.json",
|
||||
"title": "星期与时间",
|
||||
"categories": [],
|
||||
"canDo": "说今天和整点"
|
||||
},
|
||||
{
|
||||
"id": "a0-09",
|
||||
"file": "A0/a0-09.json",
|
||||
"title": "我喜欢……",
|
||||
"categories": [],
|
||||
"canDo": "表达喜好并回答"
|
||||
},
|
||||
{
|
||||
"id": "a0-10",
|
||||
"file": "A0/a0-10.json",
|
||||
"title": "A0 综合任务",
|
||||
"categories": [],
|
||||
"canDo": "没听清时请对方重复,并完成自我介绍、喜好和反问"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "A1",
|
||||
"title": "固定的生活与旅行互动",
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/// Frozen A0 v1 upgrade denominator from A0-STAGE-STANDARD.md.
|
||||
/// IDs are intentionally separate from generated wording and never change
|
||||
/// when a course uses an accepted slot value or contraction.
|
||||
final Map<String, String> a0CoreItems = Map.unmodifiable({
|
||||
for (final entry in _numbered('A0-W', const [
|
||||
'zero',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
'ten',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
'Sunday',
|
||||
'book',
|
||||
'phone',
|
||||
'pen',
|
||||
'bag',
|
||||
'key',
|
||||
'water',
|
||||
'mother',
|
||||
'father',
|
||||
'sister',
|
||||
'brother',
|
||||
'coffee',
|
||||
'tea',
|
||||
'music',
|
||||
'movies',
|
||||
'good',
|
||||
'okay',
|
||||
'tired',
|
||||
'hello',
|
||||
'hi',
|
||||
'yes',
|
||||
'no',
|
||||
'thanks',
|
||||
]))
|
||||
entry.key: entry.value,
|
||||
for (final entry in _numbered('A0-P', const [
|
||||
"I'm [name].",
|
||||
"What's your name?",
|
||||
'Nice to meet you.',
|
||||
'How do you spell that?',
|
||||
'How are you?',
|
||||
"I'm [state].",
|
||||
"What's your phone number?",
|
||||
'My number is [digits].',
|
||||
"What's this?",
|
||||
"It's a [object].",
|
||||
'Where are you from?',
|
||||
"I'm from [place].",
|
||||
'This is my [person].',
|
||||
'What day is it today?',
|
||||
"It's [weekday].",
|
||||
'What time is it?',
|
||||
"It's [hour] o'clock.",
|
||||
'I like [thing].',
|
||||
'Do you like [thing]?',
|
||||
'Please say that again.',
|
||||
]))
|
||||
entry.key: entry.value,
|
||||
});
|
||||
|
||||
Iterable<MapEntry<String, String>> _numbered(
|
||||
String prefix,
|
||||
List<String> values,
|
||||
) sync* {
|
||||
for (var index = 0; index < values.length; index++) {
|
||||
yield MapEntry(
|
||||
'$prefix${(index + 1).toString().padLeft(2, '0')}',
|
||||
values[index],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CoreReviewTemplate {
|
||||
const CoreReviewTemplate({
|
||||
required this.prompt,
|
||||
required this.hint,
|
||||
required this.skill,
|
||||
});
|
||||
|
||||
final String prompt;
|
||||
final String hint;
|
||||
final String skill;
|
||||
}
|
||||
|
||||
/// Offline, reviewed prompts keep initial reviews usable without an AI service.
|
||||
/// The prompt supplies a meaning or situation, never the English target.
|
||||
CoreReviewTemplate coreReviewTemplate(String id) {
|
||||
final target = a0CoreItems[id] ?? id;
|
||||
const meanings = {
|
||||
'A0-W01': '零',
|
||||
'A0-W02': '一',
|
||||
'A0-W03': '二',
|
||||
'A0-W04': '三',
|
||||
'A0-W05': '四',
|
||||
'A0-W06': '五',
|
||||
'A0-W07': '六',
|
||||
'A0-W08': '七',
|
||||
'A0-W09': '八',
|
||||
'A0-W10': '九',
|
||||
'A0-W11': '十',
|
||||
'A0-W12': '星期一',
|
||||
'A0-W13': '星期二',
|
||||
'A0-W14': '星期三',
|
||||
'A0-W15': '星期四',
|
||||
'A0-W16': '星期五',
|
||||
'A0-W17': '星期六',
|
||||
'A0-W18': '星期日',
|
||||
'A0-W19': '书',
|
||||
'A0-W20': '电话',
|
||||
'A0-W21': '笔',
|
||||
'A0-W22': '包',
|
||||
'A0-W23': '钥匙',
|
||||
'A0-W24': '水',
|
||||
'A0-W25': '妈妈',
|
||||
'A0-W26': '爸爸',
|
||||
'A0-W27': '姐妹',
|
||||
'A0-W28': '兄弟',
|
||||
'A0-W29': '咖啡',
|
||||
'A0-W30': '茶',
|
||||
'A0-W31': '音乐',
|
||||
'A0-W32': '电影',
|
||||
'A0-W33': '好',
|
||||
'A0-W34': '还可以',
|
||||
'A0-W35': '累',
|
||||
'A0-W36': '你好',
|
||||
'A0-W37': '嗨',
|
||||
'A0-W38': '是',
|
||||
'A0-W39': '不',
|
||||
'A0-W40': '谢谢',
|
||||
};
|
||||
final phrasePrompt = switch (id) {
|
||||
'A0-P01' => '向新同学介绍你的名字。',
|
||||
'A0-P02' => '问对方叫什么名字。',
|
||||
'A0-P03' => '对初次见面的人说“很高兴认识你”。',
|
||||
'A0-P04' => '问对方一个词怎样拼写。',
|
||||
'A0-P05' => '问对方今天怎么样。',
|
||||
'A0-P06' => '说出你今天的状态。',
|
||||
'A0-P07' => '问对方的电话号码。',
|
||||
'A0-P08' => '报出一个虚构的三位号码。',
|
||||
'A0-P09' => '指着身边的东西问“这是什么”。',
|
||||
'A0-P10' => '说出“这是一件物品”。',
|
||||
'A0-P11' => '问对方来自哪里。',
|
||||
'A0-P12' => '说出你来自哪里。',
|
||||
'A0-P13' => '介绍一位家人。',
|
||||
'A0-P14' => '问今天星期几。',
|
||||
'A0-P15' => '说出今天星期几。',
|
||||
'A0-P16' => '问现在几点。',
|
||||
'A0-P17' => '说出一个整点时间。',
|
||||
'A0-P18' => '说出你喜欢的一样东西。',
|
||||
'A0-P19' => '问对方是否喜欢一样东西。',
|
||||
'A0-P20' => '请对方再说一次。',
|
||||
_ => null,
|
||||
};
|
||||
if (phrasePrompt != null) {
|
||||
return CoreReviewTemplate(
|
||||
prompt: phrasePrompt,
|
||||
hint: target,
|
||||
skill: '回忆表达',
|
||||
);
|
||||
}
|
||||
return CoreReviewTemplate(
|
||||
prompt: '把“${meanings[id] ?? '这个已学词'}”写成英文。',
|
||||
hint: target,
|
||||
skill: '词汇回忆',
|
||||
);
|
||||
}
|
||||
|
||||
/// Rotates an approved offline prompt family without changing the core target
|
||||
/// ID. The rotation changes the skill, not just the wording: recall in
|
||||
/// writing, dictation from audio, then saying it aloud.
|
||||
CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
|
||||
final base = coreReviewTemplate(id);
|
||||
final dictation = a0DictationSentences[id];
|
||||
return switch (variantIndex % 3) {
|
||||
1 when dictation != null => CoreReviewTemplate(
|
||||
prompt: '听一句话,把听到的整句英文写下来。',
|
||||
hint: dictation.meaning,
|
||||
skill: dictationSkill,
|
||||
),
|
||||
2 => CoreReviewTemplate(
|
||||
prompt: '用英文说出来:${base.prompt}',
|
||||
hint: base.hint,
|
||||
skill: spokenRecallSkill,
|
||||
),
|
||||
_ => base,
|
||||
};
|
||||
}
|
||||
|
||||
const dictationSkill = '听写';
|
||||
const spokenRecallSkill = '口头回忆';
|
||||
|
||||
/// A concrete sentence built only from A0 language, used to play a core item
|
||||
/// in context and to check dictation.
|
||||
typedef DictationSentence = ({String sentence, String meaning});
|
||||
|
||||
const Map<String, DictationSentence> a0DictationSentences = {
|
||||
'A0-W01': (sentence: 'Zero, one, two.', meaning: '零、一、二。'),
|
||||
'A0-W02': (sentence: 'My number is one-two-three.', meaning: '我的号码是一二三。'),
|
||||
'A0-W03': (sentence: 'It’s two o’clock.', meaning: '现在两点。'),
|
||||
'A0-W04': (sentence: 'It’s three o’clock.', meaning: '现在三点。'),
|
||||
'A0-W05': (sentence: 'It’s four o’clock.', meaning: '现在四点。'),
|
||||
'A0-W06': (sentence: 'It’s five o’clock.', meaning: '现在五点。'),
|
||||
'A0-W07': (sentence: 'It’s six o’clock.', meaning: '现在六点。'),
|
||||
'A0-W08': (sentence: 'It’s seven o’clock.', meaning: '现在七点。'),
|
||||
'A0-W09': (sentence: 'It’s eight o’clock.', meaning: '现在八点。'),
|
||||
'A0-W10': (sentence: 'It’s nine o’clock.', meaning: '现在九点。'),
|
||||
'A0-W11': (sentence: 'It’s ten o’clock.', meaning: '现在十点。'),
|
||||
'A0-W12': (sentence: 'It’s Monday.', meaning: '今天星期一。'),
|
||||
'A0-W13': (sentence: 'It’s Tuesday.', meaning: '今天星期二。'),
|
||||
'A0-W14': (sentence: 'It’s Wednesday.', meaning: '今天星期三。'),
|
||||
'A0-W15': (sentence: 'It’s Thursday.', meaning: '今天星期四。'),
|
||||
'A0-W16': (sentence: 'It’s Friday.', meaning: '今天星期五。'),
|
||||
'A0-W17': (sentence: 'It’s Saturday.', meaning: '今天星期六。'),
|
||||
'A0-W18': (sentence: 'It’s Sunday.', meaning: '今天星期日。'),
|
||||
'A0-W19': (sentence: 'It’s a book.', meaning: '这是一本书。'),
|
||||
'A0-W20': (sentence: 'It’s a phone.', meaning: '这是一部电话。'),
|
||||
'A0-W21': (sentence: 'It’s a pen.', meaning: '这是一支笔。'),
|
||||
'A0-W22': (sentence: 'It’s a bag.', meaning: '这是一个包。'),
|
||||
'A0-W23': (sentence: 'It’s a key.', meaning: '这是一把钥匙。'),
|
||||
'A0-W24': (sentence: 'I like water.', meaning: '我喜欢喝水。'),
|
||||
'A0-W25': (sentence: 'This is my mother.', meaning: '这是我妈妈。'),
|
||||
'A0-W26': (sentence: 'This is my father.', meaning: '这是我爸爸。'),
|
||||
'A0-W27': (sentence: 'This is my sister.', meaning: '这是我的姐妹。'),
|
||||
'A0-W28': (sentence: 'This is my brother.', meaning: '这是我的兄弟。'),
|
||||
'A0-W29': (sentence: 'I like coffee.', meaning: '我喜欢咖啡。'),
|
||||
'A0-W30': (sentence: 'Do you like tea?', meaning: '你喜欢茶吗?'),
|
||||
'A0-W31': (sentence: 'I like music.', meaning: '我喜欢音乐。'),
|
||||
'A0-W32': (sentence: 'Do you like movies?', meaning: '你喜欢电影吗?'),
|
||||
'A0-W33': (sentence: 'I’m good.', meaning: '我很好。'),
|
||||
'A0-W34': (sentence: 'I’m okay.', meaning: '我还可以。'),
|
||||
'A0-W35': (sentence: 'I’m tired.', meaning: '我累了。'),
|
||||
'A0-W36': (sentence: 'Hello, how are you?', meaning: '你好,你怎么样?'),
|
||||
'A0-W37': (sentence: 'Hi, what’s your name?', meaning: '嗨,你叫什么名字?'),
|
||||
'A0-W38': (sentence: 'Yes, I do.', meaning: '是的,我喜欢。'),
|
||||
'A0-W39': (sentence: 'No, I don’t.', meaning: '不,我不喜欢。'),
|
||||
'A0-W40': (sentence: 'I’m okay, thanks.', meaning: '我还可以,谢谢。'),
|
||||
'A0-P01': (sentence: 'I’m Tom.', meaning: '我是 Tom。'),
|
||||
'A0-P02': (sentence: 'What’s your name?', meaning: '你叫什么名字?'),
|
||||
'A0-P03': (sentence: 'Nice to meet you.', meaning: '很高兴认识你。'),
|
||||
'A0-P04': (sentence: 'How do you spell that?', meaning: '那个怎么拼写?'),
|
||||
'A0-P05': (sentence: 'How are you?', meaning: '你怎么样?'),
|
||||
'A0-P06': (sentence: 'I’m tired today.', meaning: '我今天很累。'),
|
||||
'A0-P07': (sentence: 'What’s your phone number?', meaning: '你的电话号码是多少?'),
|
||||
'A0-P08': (sentence: 'My number is one-three-eight.', meaning: '我的号码是一三八。'),
|
||||
'A0-P09': (sentence: 'What’s this?', meaning: '这是什么?'),
|
||||
'A0-P10': (sentence: 'It’s a pen.', meaning: '这是一支笔。'),
|
||||
'A0-P11': (sentence: 'Where are you from?', meaning: '你来自哪里?'),
|
||||
'A0-P12': (sentence: 'I’m from China.', meaning: '我来自中国。'),
|
||||
'A0-P13': (sentence: 'This is my sister.', meaning: '这是我的姐妹。'),
|
||||
'A0-P14': (sentence: 'What day is it today?', meaning: '今天星期几?'),
|
||||
'A0-P15': (sentence: 'It’s Friday.', meaning: '今天星期五。'),
|
||||
'A0-P16': (sentence: 'What time is it?', meaning: '现在几点?'),
|
||||
'A0-P17': (sentence: 'It’s three o’clock.', meaning: '现在三点。'),
|
||||
'A0-P18': (sentence: 'I like music.', meaning: '我喜欢音乐。'),
|
||||
'A0-P19': (sentence: 'Do you like coffee?', meaning: '你喜欢咖啡吗?'),
|
||||
'A0-P20': (sentence: 'Please say that again.', meaning: '请再说一遍。'),
|
||||
};
|
||||
|
||||
/// What review audio should play for [item]: the dictation sentence for a
|
||||
/// core item, otherwise the target itself when it has no slot to fill.
|
||||
String? reviewAudioText(String id, String target) =>
|
||||
a0DictationSentences[id]?.sentence ??
|
||||
(target.contains('[') ? null : target);
|
||||
@@ -27,7 +27,8 @@ class AiConfigFile {
|
||||
(p) => p.name.toLowerCase() == providerStr.toLowerCase(),
|
||||
orElse: () => AiProviderType.compatible,
|
||||
);
|
||||
final effort = (json['reasoningEffort'] as String? ??
|
||||
final effort =
|
||||
(json['reasoningEffort'] as String? ??
|
||||
json['reasoning_effort'] as String? ??
|
||||
'low')
|
||||
.trim();
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'sherpa_stt_service.dart';
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
@@ -139,12 +140,17 @@ class AiService {
|
||||
if (key == null || key.isEmpty || uri == null) return null;
|
||||
|
||||
final ext = filePath.split('.').last.toLowerCase();
|
||||
final format = (ext == 'wav' || ext == 'mp3' || ext == 'm4a' || ext == 'aac') ? ext : 'm4a';
|
||||
final format =
|
||||
(ext == 'wav' || ext == 'mp3' || ext == 'm4a' || ext == 'aac')
|
||||
? ext
|
||||
: 'm4a';
|
||||
final base64Data = base64Encode(bytes);
|
||||
|
||||
try {
|
||||
if (provider == AiProviderType.gemini) {
|
||||
final mimeType = format == 'wav' ? 'audio/wav' : (format == 'mp3' ? 'audio/mp3' : 'audio/mp4');
|
||||
final mimeType = format == 'wav'
|
||||
? 'audio/wav'
|
||||
: (format == 'mp3' ? 'audio/mp3' : 'audio/mp4');
|
||||
final response = await _postJson(
|
||||
provider: provider,
|
||||
key: key,
|
||||
@@ -155,21 +161,17 @@ class AiService {
|
||||
{
|
||||
'parts': [
|
||||
{
|
||||
'text': 'Transcribe the spoken English speech in this audio file accurately. Return ONLY the transcribed English words. If silence or unintelligible, output nothing.',
|
||||
'text':
|
||||
'Transcribe the spoken English speech in this audio file accurately. Return ONLY the transcribed English words. If silence or unintelligible, output nothing.',
|
||||
},
|
||||
{
|
||||
'inline_data': {
|
||||
'mime_type': mimeType,
|
||||
'data': base64Data,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
'inline_data': {'mime_type': mimeType, 'data': base64Data},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'generationConfig': {
|
||||
'thinkingConfig': {
|
||||
'thinkingBudget': 1024,
|
||||
},
|
||||
'thinkingConfig': {'thinkingBudget': 1024},
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -189,17 +191,15 @@ class AiService {
|
||||
'content': [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'Transcribe the spoken English speech in this audio file accurately. Output ONLY the raw transcribed English words without quotes, punctuation tags, or commentary. If silence or noise, return nothing.',
|
||||
'text':
|
||||
'Transcribe the spoken English speech in this audio file accurately. Output ONLY the raw transcribed English words without quotes, punctuation tags, or commentary. If silence or noise, return nothing.',
|
||||
},
|
||||
{
|
||||
'type': 'input_audio',
|
||||
'input_audio': {
|
||||
'data': base64Data,
|
||||
'format': format,
|
||||
'input_audio': {'data': base64Data, 'format': format},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
],
|
||||
'reasoning_effort': 'low',
|
||||
'temperature': 0.1,
|
||||
@@ -219,7 +219,8 @@ class AiService {
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isHttpUri(Uri uri) => uri.scheme == 'https' || uri.scheme == 'http';
|
||||
static bool _isHttpUri(Uri uri) =>
|
||||
uri.scheme == 'https' || uri.scheme == 'http';
|
||||
|
||||
static bool _isDeepSeek(Uri uri) {
|
||||
final host = uri.host.toLowerCase();
|
||||
@@ -295,12 +296,31 @@ class AiService {
|
||||
),
|
||||
);
|
||||
if (!_isSuccess(response)) return null;
|
||||
if (kDebugMode) _logUsage(response.body);
|
||||
return _extractResponseContent(provider, response.body);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug-only prompt cache report. DeepSeek reports hits and misses
|
||||
/// directly; OpenAI-style endpoints report cached tokens in the details.
|
||||
static void _logUsage(String body) {
|
||||
try {
|
||||
final usage = (jsonDecode(body) as Map<String, dynamic>)['usage'];
|
||||
if (usage is! Map) return;
|
||||
final hit =
|
||||
usage['prompt_cache_hit_tokens'] ??
|
||||
(usage['prompt_tokens_details'] as Map?)?['cached_tokens'];
|
||||
final miss = usage['prompt_cache_miss_tokens'];
|
||||
final prompt = usage['prompt_tokens'] ?? usage['input_tokens'];
|
||||
debugPrint(
|
||||
'AI usage: prompt=$prompt cacheHit=$hit cacheMiss=$miss '
|
||||
'completion=${usage['completion_tokens'] ?? usage['output_tokens']}',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<String?> _requestPrompt({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
@@ -436,9 +456,7 @@ class AiService {
|
||||
'temperature': ?temperature,
|
||||
'maxOutputTokens': ?maxOutputTokens,
|
||||
'responseMimeType': ?responseMimeType,
|
||||
'thinkingConfig': {
|
||||
'thinkingBudget': thinkingBudget,
|
||||
},
|
||||
'thinkingConfig': {'thinkingBudget': thinkingBudget},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -718,15 +736,9 @@ class AiService {
|
||||
if (_isSuccess(response)) {
|
||||
final content = _extractResponseContent(provider, response.body);
|
||||
if (_decodeDialogueResponse(content) != null) {
|
||||
return const AiConnectionResult(
|
||||
ok: true,
|
||||
message: '连接成功,AI 对话服务可用!',
|
||||
);
|
||||
return const AiConnectionResult(ok: true, message: '连接成功,AI 对话服务可用!');
|
||||
}
|
||||
return const AiConnectionResult(
|
||||
ok: true,
|
||||
message: '连接成功,接口响应正常。',
|
||||
);
|
||||
return const AiConnectionResult(ok: true, message: '连接成功,接口响应正常。');
|
||||
}
|
||||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||
return AiConnectionResult(
|
||||
@@ -761,6 +773,10 @@ class AiService {
|
||||
/// [aiGoal] is what Mia's own next line has to do; [learnerTask] is what the
|
||||
/// learner has to say afterwards. They used to be the same string, so the
|
||||
/// model was told to perform the learner's job.
|
||||
///
|
||||
/// The system prompt holds only what stays fixed for the whole conversation
|
||||
/// and the per-turn goal goes at the very end, so each turn repeats the
|
||||
/// previous request as a prefix and DeepSeek can serve it from its cache.
|
||||
Future<DialogueAiResponse?> dialogueReply({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
@@ -768,37 +784,23 @@ class AiService {
|
||||
required List<Map<String, String>> history,
|
||||
required String aiGoal,
|
||||
required String learnerTask,
|
||||
String level = 'A0',
|
||||
List<String> allowedLanguage = const [],
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) {
|
||||
return null;
|
||||
}
|
||||
final vocabularyRule = allowedLanguage.isEmpty
|
||||
? ''
|
||||
: 'Build your reply from your goal wording, names, numbers and this '
|
||||
'taught language: ${allowedLanguage.join('; ')}. '
|
||||
'At most one word outside it per reply, and only if unavoidable. ';
|
||||
final system =
|
||||
'You are Mia, a patient A0 English conversation partner for a Chinese beginner. '
|
||||
'Your own next line must do this: $aiGoal '
|
||||
'After your line the learner has to: $learnerTask. '
|
||||
'Do not say the learner sentence for them, and do not ask for anything else. '
|
||||
'$vocabularyRule'
|
||||
'Reply with one short sentence or question, at most 20 English words, in English only. '
|
||||
'Do not explain grammar. '
|
||||
'Return JSON only: {"reply": "your English line", '
|
||||
'"translation": "reply 的简体中文翻译", '
|
||||
'"feedback": "一句中文点评学习者上一句英文,没有要说的就用 null"}. '
|
||||
'Only reply is required.';
|
||||
final content = await _requestContent(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
system: system,
|
||||
final system = dialogueSystemPrompt(
|
||||
level: level,
|
||||
allowedLanguage: allowedLanguage,
|
||||
);
|
||||
final turnNote =
|
||||
'[Turn] Your next line must do this: $aiGoal '
|
||||
'After your line the learner has to: $learnerTask.';
|
||||
// Earlier AI lines are stored as plain English. Sent that way they teach
|
||||
// the model to answer in plain text (or, in JSON mode, with blanks), so
|
||||
// they are replayed in the JSON shape the system prompt asks for.
|
||||
messages: [
|
||||
final messages = <Map<String, String>>[
|
||||
for (final message in history)
|
||||
message['role'] == 'assistant'
|
||||
? {
|
||||
@@ -806,13 +808,61 @@ class AiService {
|
||||
'content': jsonEncode({'reply': message['content']}),
|
||||
}
|
||||
: message,
|
||||
],
|
||||
];
|
||||
if (messages.isNotEmpty && messages.last['role'] == 'user') {
|
||||
messages.last = {
|
||||
'role': 'user',
|
||||
'content': '${messages.last['content']}\n\n$turnNote',
|
||||
};
|
||||
} else {
|
||||
messages.add({'role': 'user', 'content': turnNote});
|
||||
}
|
||||
final content = await _requestContent(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
system: system,
|
||||
messages: messages,
|
||||
temperature: 0.3,
|
||||
maxTokens: 300,
|
||||
);
|
||||
return _decodeDialogueResponse(content);
|
||||
}
|
||||
|
||||
/// The conversation-wide dialogue instructions. The long word list comes
|
||||
/// last so the rules before it are shared across units as well.
|
||||
@visibleForTesting
|
||||
static String dialogueSystemPrompt({
|
||||
required String level,
|
||||
required List<String> allowedLanguage,
|
||||
}) {
|
||||
final learner = level == 'A0'
|
||||
? 'a patient A0 English conversation partner for a Chinese beginner'
|
||||
: 'a patient English conversation partner for a Chinese learner at '
|
||||
'CEFR $level';
|
||||
final vocabularyRule = allowedLanguage.isEmpty
|
||||
? ''
|
||||
: level == 'A0'
|
||||
? 'Build your reply from your goal wording, names, numbers and this '
|
||||
'taught language. At most one word outside it per reply, and '
|
||||
'only if unavoidable. Taught language: '
|
||||
'${allowedLanguage.join('; ')}'
|
||||
: 'Prefer your goal wording and this recently taught language; '
|
||||
'beyond it use only simple, common $level English. '
|
||||
'Taught language: ${allowedLanguage.join('; ')}';
|
||||
return 'You are Mia, $learner. '
|
||||
'Each user message ends with a [Turn] note saying what your next line '
|
||||
'must do and what the learner has to say after it. '
|
||||
'Do not say the learner sentence for them, and do not ask for anything else. '
|
||||
'Reply with one short sentence or question, at most 20 English words, in English only. '
|
||||
'Do not explain grammar. '
|
||||
'Return JSON only: {"reply": "your English line", '
|
||||
'"translation": "reply 的简体中文翻译", '
|
||||
'"feedback": "一句中文点评学习者上一句英文(不含 [Turn] 说明),没有要说的就用 null"}. '
|
||||
'Only reply is required. '
|
||||
'$vocabularyRule';
|
||||
}
|
||||
|
||||
/// Generates only a bounded variant of an existing review target. A network
|
||||
/// response is never exposed unless [decodeGeneratedReviewVariant] accepts it.
|
||||
Future<GeneratedReviewVariant?> generateReviewVariant({
|
||||
@@ -824,16 +874,20 @@ class AiService {
|
||||
bool repairAttempt = false,
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
// Fixed rules first, the item-specific part last, so repeated requests
|
||||
// share a cacheable prefix.
|
||||
final instruction =
|
||||
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". '
|
||||
'Generate one A0 English review variant of an existing review item. '
|
||||
'Return JSON only with exactly these five fields and nothing else: '
|
||||
'schemaVersion (must be "review-variant-1"), '
|
||||
'variantId (short id such as "ai-${targetItemId.toLowerCase()}-1", maximum 80 characters), '
|
||||
'targetItemId (must be "$targetItemId"), '
|
||||
'variantId (short id such as "ai-<target item id in lower case>-1", maximum 80 characters), '
|
||||
'targetItemId (must be the target item id below), '
|
||||
'prompt (a new short Chinese situation asking the learner to say the same target expression, maximum 120 Chinese characters), '
|
||||
'expectedAnswer (the English reference answer, maximum 12 words; use [place], [name] or [number] for learner-specific details). '
|
||||
'Stay strictly within A0. Do not introduce new vocabulary or change the target expression.'
|
||||
'${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
|
||||
'Stay strictly within A0. Do not introduce new vocabulary or change the target expression.\n'
|
||||
'Target item id: $targetItemId\n'
|
||||
'Base prompt: $basePrompt'
|
||||
'${repairAttempt ? '\nPrevious response failed schema or constraint validation: repair all errors.' : ''}';
|
||||
final content = await _requestPrompt(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
@@ -868,13 +922,15 @@ class AiService {
|
||||
required String lessonId,
|
||||
required String taskPrompt,
|
||||
required String answer,
|
||||
String level = 'A0',
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final instruction =
|
||||
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
||||
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
|
||||
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
|
||||
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
|
||||
schemaVersion must be "writing-feedback-1" and lessonId must be the lesson id given below.
|
||||
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple $level English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
|
||||
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond $level, and do not invent facts the learner did not write.
|
||||
Lesson id: $lessonId
|
||||
Task: $taskPrompt
|
||||
Learner wrote: $answer''';
|
||||
final content = await _requestPrompt(
|
||||
@@ -886,10 +942,46 @@ Learner wrote: $answer''';
|
||||
maxTokens: 300,
|
||||
);
|
||||
if (content == null) return null;
|
||||
return decodeWritingAiFeedback(
|
||||
content,
|
||||
expectedLessonId: lessonId,
|
||||
return decodeWritingAiFeedback(content, expectedLessonId: lessonId);
|
||||
}
|
||||
|
||||
/// Checks a review answer or dialogue turn for spelling and grammar. It
|
||||
/// reuses the writing feedback schema, keyed by [answerId], and stays
|
||||
/// advisory: the local check still decides whether the answer counts.
|
||||
Future<WritingAiFeedback?> answerFeedback({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required String answerId,
|
||||
required String target,
|
||||
required String taskPrompt,
|
||||
required String answer,
|
||||
String level = 'A0',
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final instruction =
|
||||
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
||||
schemaVersion must be "writing-feedback-1" and lessonId must be the answer id given below.
|
||||
Check the learner's English for spelling mistakes, grammar mistakes, and whether it answers the task using the target expression.
|
||||
verdict must be accepted (no spelling or grammar mistakes and the task is answered), rewrite (at least one mistake), or uncertain.
|
||||
feedback is one short helpful Chinese sentence (max 80 Chinese characters) summarising the result.
|
||||
suggestion is null when verdict is accepted, otherwise the learner's own sentence minimally corrected at $level level (max 18 words); keep their names, places and meaning.
|
||||
missing is an array of at most 3 short Chinese notes, one per mistake, each naming the wrong word and its correction, e.g. "Chna 拼写应为 China".
|
||||
Do not claim pronunciation, do not introduce grammar beyond $level, do not flag capitalisation or final punctuation alone, and do not invent facts the learner did not write.
|
||||
Answer id: $answerId
|
||||
Target expression: $target
|
||||
Task: $taskPrompt
|
||||
Learner wrote: $answer''';
|
||||
final content = await _requestPrompt(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
prompt: instruction,
|
||||
temperature: 0,
|
||||
maxTokens: 300,
|
||||
);
|
||||
if (content == null) return null;
|
||||
return decodeWritingAiFeedback(content, expectedLessonId: answerId);
|
||||
}
|
||||
|
||||
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
|
||||
@@ -904,7 +996,11 @@ Learner wrote: $answer''';
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
|
||||
final instruction =
|
||||
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, lessonId $lessonId, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [$targetItemId], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [$targetItemId]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple A0 English for $targetLabel. No new vocabulary, markdown, real phone numbers, or personal data.${repairAttempt ? ' Previous response was invalid: repair all constraints.' : ''}';
|
||||
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, the lessonId given below, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [target item id], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [target item id]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple A0 English for the target expression. No new vocabulary, markdown, real phone numbers, or personal data.\n'
|
||||
'lessonId: $lessonId\n'
|
||||
'Target item id: $targetItemId\n'
|
||||
'Target expression: $targetLabel'
|
||||
'${repairAttempt ? '\nPrevious response was invalid: repair all constraints.' : ''}';
|
||||
final content = await _requestPrompt(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
@@ -1001,11 +1097,13 @@ Learner wrote: $answer''';
|
||||
rawContent = (parts?.firstOrNull as Map?)?['text'] as String?;
|
||||
} else {
|
||||
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
|
||||
final choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
|
||||
final choiceContent =
|
||||
(choice?['message'] as Map?)?['content'] as String? ??
|
||||
choice?['text'] as String?;
|
||||
if (choiceContent != null && choiceContent.isNotEmpty) {
|
||||
rawContent = choiceContent;
|
||||
} else if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
|
||||
} else if (data['output_text'] is String &&
|
||||
(data['output_text'] as String).isNotEmpty) {
|
||||
rawContent = data['output_text'] as String;
|
||||
} else {
|
||||
final outputList = data['output'] as List?;
|
||||
@@ -1028,11 +1126,14 @@ Learner wrote: $answer''';
|
||||
}
|
||||
}
|
||||
if (rawContent == null) {
|
||||
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
|
||||
if (data['response'] is String &&
|
||||
(data['response'] as String).isNotEmpty) {
|
||||
rawContent = data['response'] as String;
|
||||
} else if (data['text'] is String && (data['text'] as String).isNotEmpty) {
|
||||
} else if (data['text'] is String &&
|
||||
(data['text'] as String).isNotEmpty) {
|
||||
rawContent = data['text'] as String;
|
||||
} else if (data['content'] is String && (data['content'] as String).isNotEmpty) {
|
||||
} else if (data['content'] is String &&
|
||||
(data['content'] as String).isNotEmpty) {
|
||||
rawContent = data['content'] as String;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'models.dart';
|
||||
import 'a0_core.dart';
|
||||
import 'courses/courses.dart';
|
||||
import 'app_theme.dart';
|
||||
import 'generated_content.dart';
|
||||
import 'local_store.dart';
|
||||
import 'review_feedback.dart';
|
||||
import 'seed_courses.dart';
|
||||
import 'courses/course_repository.dart';
|
||||
import 'ai_config.dart';
|
||||
import 'ai_service.dart';
|
||||
import 'sherpa_stt_service.dart';
|
||||
@@ -93,7 +92,7 @@ abstract class _AppStateData extends ChangeNotifier {
|
||||
}
|
||||
|
||||
class AppState extends _AppStateData
|
||||
with _ReviewAndMastery, _LessonProgress, _AssessmentProgress, _AiContent {
|
||||
with _ReviewAndMastery, _AssessmentProgress, _LessonProgress, _AiContent {
|
||||
static const _storageKey = 'learning_state_v1';
|
||||
bool isLoaded = false;
|
||||
bool _writing = false;
|
||||
@@ -112,6 +111,9 @@ class AppState extends _AppStateData
|
||||
Future<void> load() async {
|
||||
try {
|
||||
unawaited(SherpaSttService.instance.initialize());
|
||||
// Adapt the A1–B1 JSON packs into the runtime registries before the saved
|
||||
// snapshot is restored, so progress that points at those lessons resolves.
|
||||
await CourseRepository.instance.load();
|
||||
final config = await AiConfigFile.loadFromAsset();
|
||||
if (config != null) {
|
||||
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
|
||||
@@ -219,10 +221,10 @@ class AppState extends _AppStateData
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Applies the placement result. Only A0 starting points exist until A1
|
||||
/// content is frozen, and placement never counts as passing a lesson.
|
||||
/// Applies the placement result. The start may land on any loaded lesson
|
||||
/// across A0–B1, and placement never counts as passing a lesson.
|
||||
void setPlacementStartLesson(String lessonId) {
|
||||
if (!a0SeedLessons.any((lesson) => lesson.id == lessonId)) return;
|
||||
if (!allLessons.any((lesson) => lesson.id == lessonId)) return;
|
||||
placementStartLessonId = lessonId;
|
||||
activeLessonId = lessonId;
|
||||
_resetLessonFlow();
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
part of 'app_state.dart';
|
||||
|
||||
/// A0 exit criteria: core items that are usable, and that are mastered.
|
||||
const a0UsableGoal = 48;
|
||||
const a0MasteredGoal = 30;
|
||||
|
||||
/// Stage assessments and the A0 pass criteria.
|
||||
mixin _AssessmentProgress on _AppStateData, _ReviewAndMastery {
|
||||
bool get hasTwoValidAssessmentPasses {
|
||||
@@ -25,35 +29,58 @@ mixin _AssessmentProgress on _AppStateData, _ReviewAndMastery {
|
||||
|
||||
/// Enough core items are usable and mastered to sit the stage assessment.
|
||||
bool get a0AssessmentReady =>
|
||||
coreUsableCount >= 48 && coreMasteredCount >= 30;
|
||||
coreUsableCount >= a0UsableGoal && coreMasteredCount >= a0MasteredGoal;
|
||||
|
||||
bool get a0Passed => a0AssessmentReady && hasTwoValidAssessmentPasses;
|
||||
|
||||
/// Distinct assessment packs passed so far, counted toward the A0 exit.
|
||||
int get passedAssessmentPackCount => hasTwoValidAssessmentPasses
|
||||
? 2
|
||||
: assessments
|
||||
.where((record) => record.passed)
|
||||
.map((record) => record.packId)
|
||||
.toSet()
|
||||
.length
|
||||
.clamp(0, 1);
|
||||
|
||||
/// Overall progress toward leaving A0, averaging the three exit criteria.
|
||||
double get a0StageProgress {
|
||||
double part(int value, int goal) => (value / goal).clamp(0, 1).toDouble();
|
||||
return (part(coreUsableCount, a0UsableGoal) +
|
||||
part(coreMasteredCount, a0MasteredGoal) +
|
||||
part(passedAssessmentPackCount, 2)) /
|
||||
3;
|
||||
}
|
||||
|
||||
/// The first assessment pack the learner can start and has not passed yet.
|
||||
String? get nextAssessmentPackId {
|
||||
for (final packId in const ['A0-E1', 'A0-E2']) {
|
||||
for (final pack in a0AssessmentPacks) {
|
||||
final passed = assessments.any(
|
||||
(record) => record.packId == packId && record.passed,
|
||||
(record) => record.packId == pack.id && record.passed,
|
||||
);
|
||||
if (!passed && canStartAssessmentPack(packId)) return packId;
|
||||
if (!passed && canStartAssessmentPack(pack.id)) return pack.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The first pack is always open; each later one opens 24 hours after the
|
||||
/// pack before it was passed.
|
||||
bool canStartAssessmentPack(String packId) {
|
||||
if (packId != 'A0-E2') return true;
|
||||
final first = assessments
|
||||
.where((record) => record.packId == 'A0-E1' && record.passed)
|
||||
final packs = a0AssessmentPacks;
|
||||
final index = packs.indexWhere((pack) => pack.id == packId);
|
||||
if (index <= 0) return true;
|
||||
final previous = assessments
|
||||
.where(
|
||||
(record) => record.packId == packs[index - 1].id && record.passed,
|
||||
)
|
||||
.firstOrNull;
|
||||
return first != null &&
|
||||
DateTime.now().difference(first.completedAt) >=
|
||||
return previous != null &&
|
||||
DateTime.now().difference(previous.completedAt) >=
|
||||
const Duration(hours: 24);
|
||||
}
|
||||
|
||||
AssessmentRecord recordAssessment(AssessmentRecord record) {
|
||||
final canonicalPackId = record.packId.endsWith('R')
|
||||
? record.packId.substring(0, record.packId.length - 1)
|
||||
: record.packId;
|
||||
final canonicalPackId = canonicalAssessmentPackId(record.packId);
|
||||
final normalized = AssessmentRecord(
|
||||
packId: canonicalPackId,
|
||||
completedAt: record.completedAt,
|
||||
|
||||
@@ -2,7 +2,7 @@ part of 'app_state.dart';
|
||||
|
||||
/// Position in the seed course, the in-lesson step flow, and the evidence
|
||||
/// lesson tasks produce.
|
||||
mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
bool get hasResumableLessonDialogue =>
|
||||
dialogueDraft != null &&
|
||||
dialogueDraft!.lessonId == activeLessonId &&
|
||||
@@ -83,21 +83,35 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
}
|
||||
|
||||
int _lessonIndex(String id) =>
|
||||
a0SeedLessons.indexWhere((lesson) => lesson.id == id);
|
||||
allLessons.indexWhere((lesson) => lesson.id == id);
|
||||
|
||||
bool isLessonUnlocked(String id) {
|
||||
final index = _lessonIndex(id);
|
||||
final lessons = allLessons;
|
||||
final index = lessons.indexWhere((lesson) => lesson.id == id);
|
||||
if (index < 0) return false;
|
||||
// The placement start and everything before it are open, so a learner
|
||||
// placed further ahead can still go back to easier lessons.
|
||||
return index == 0 ||
|
||||
index <= _lessonIndex(placementStartLessonId) ||
|
||||
completedLessonIds.contains(a0SeedLessons[index - 1].id);
|
||||
if (index == 0 || index <= _lessonIndex(placementStartLessonId)) {
|
||||
return true;
|
||||
}
|
||||
// Normal progression: the previous lesson has to be finished.
|
||||
if (!completedLessonIds.contains(lessons[index - 1].id)) return false;
|
||||
// Crossing out of A0 into A1 additionally requires passing the A0 stage
|
||||
// assessment; higher levels then chain linearly.
|
||||
if (lessons[index - 1].level == 'A0' && lessons[index].level != 'A0') {
|
||||
return a0Passed;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get allLessonsComplete =>
|
||||
/// Every A0 seed lesson is finished, which opens the A0 stage assessment.
|
||||
/// Distinct from [allLessonsComplete], which spans all loaded levels.
|
||||
bool get a0LessonsComplete =>
|
||||
a0SeedLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||
|
||||
bool get allLessonsComplete =>
|
||||
allLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||
|
||||
void openLesson(String id) {
|
||||
if (!isLessonUnlocked(id)) return;
|
||||
if (id != activeLessonId) {
|
||||
@@ -153,7 +167,12 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
|
||||
void completeReading() {
|
||||
lessonReadingComplete = true;
|
||||
lessonStep = LessonStep.writing;
|
||||
// The unit's listening/reading materials are a comprehension step shown
|
||||
// once, at the end of the last segment. Units without materials (A0) skip
|
||||
// straight to writing.
|
||||
lessonStep = _shouldShowMaterialStep
|
||||
? LessonStep.material
|
||||
: LessonStep.writing;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _activeTargetItemIds,
|
||||
taskSuffix: 'reading',
|
||||
@@ -163,6 +182,30 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// True when the active lesson is a JSON unit with materials and the learner
|
||||
/// is on its final segment, so the comprehension step is shown just once.
|
||||
bool get _shouldShowMaterialStep {
|
||||
final lesson = lessonById(activeLessonId);
|
||||
final isLastSegment =
|
||||
activeSegmentIndexFor(activeLessonId) == lesson.segments.length - 1;
|
||||
if (!isLastSegment) return false;
|
||||
final unit = CourseRepository.instance.unitById(activeLessonId);
|
||||
return unit != null && unit.materials.isNotEmpty;
|
||||
}
|
||||
|
||||
/// The listening/reading comprehension step is exposure to the unit's
|
||||
/// receptive language; it never changes produced-item mastery.
|
||||
void completeMaterial() {
|
||||
lessonStep = LessonStep.writing;
|
||||
_recordLessonTaskEvidence(
|
||||
targetIds: _activeTargetItemIds,
|
||||
taskSuffix: 'material',
|
||||
skill: '听读理解',
|
||||
outcome: EvidenceKind.exposure,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void completeWriting({bool assisted = false, String? rawAnswer}) {
|
||||
lessonWritingComplete = true;
|
||||
lessonWritingDraft = '';
|
||||
@@ -253,8 +296,9 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
final lessons = allLessons;
|
||||
int lessonIndex(String id) =>
|
||||
a0SeedLessons.indexWhere((lesson) => lesson.id == id);
|
||||
lessons.indexWhere((lesson) => lesson.id == id);
|
||||
|
||||
var targetIndex = lessonIndex(activeLessonId);
|
||||
final remoteIndex = lessonIndex(remoteActiveLessonId);
|
||||
@@ -265,19 +309,19 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
// became complete through this pull, but leave a lesson alone when the
|
||||
// learner deliberately reopened it locally after finishing it.
|
||||
while (targetIndex >= 0 &&
|
||||
targetIndex < a0SeedLessons.length - 1 &&
|
||||
!completedBefore.contains(a0SeedLessons[targetIndex].id) &&
|
||||
completedLessonIds.contains(a0SeedLessons[targetIndex].id)) {
|
||||
targetIndex < lessons.length - 1 &&
|
||||
!completedBefore.contains(lessons[targetIndex].id) &&
|
||||
completedLessonIds.contains(lessons[targetIndex].id)) {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
var positionChanged = false;
|
||||
if (targetIndex >= 0 && a0SeedLessons[targetIndex].id != activeLessonId) {
|
||||
activeLessonId = a0SeedLessons[targetIndex].id;
|
||||
if (targetIndex >= 0 && lessons[targetIndex].id != activeLessonId) {
|
||||
activeLessonId = lessons[targetIndex].id;
|
||||
positionChanged = true;
|
||||
}
|
||||
|
||||
for (final lesson in a0SeedLessons) {
|
||||
for (final lesson in lessons) {
|
||||
final segments = lesson.segments;
|
||||
var firstOpen = segments.indexWhere(
|
||||
(segment) => !completedSegmentIds.contains(segment.id),
|
||||
@@ -300,13 +344,9 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
/// The first unfinished lesson from the placement start onward, falling back
|
||||
/// to any earlier unfinished lesson. Null once every lesson is finished.
|
||||
String? _nextIncompleteLessonId() {
|
||||
final start = _lessonIndex(
|
||||
placementStartLessonId,
|
||||
).clamp(0, a0SeedLessons.length);
|
||||
final ordered = [
|
||||
...a0SeedLessons.skip(start),
|
||||
...a0SeedLessons.take(start),
|
||||
];
|
||||
final lessons = allLessons;
|
||||
final start = _lessonIndex(placementStartLessonId).clamp(0, lessons.length);
|
||||
final ordered = [...lessons.skip(start), ...lessons.take(start)];
|
||||
return ordered
|
||||
.where((lesson) => !completedLessonIds.contains(lesson.id))
|
||||
.firstOrNull
|
||||
@@ -359,7 +399,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
final inScene = sceneId != null;
|
||||
final itemIds = inScene
|
||||
? [
|
||||
for (final id in a0CoreItems.keys)
|
||||
for (final id in allCoreItemIds)
|
||||
if (mastery[id]?.firstTaughtAt != null &&
|
||||
coreItemUsedIn(id, rawAnswer))
|
||||
id,
|
||||
@@ -400,7 +440,8 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
|
||||
/// The open scene practised least recently, judged by its pending recaps.
|
||||
DialogueScene get recommendedScene {
|
||||
final open = a0Scenes.where(isSceneUnlocked).toList();
|
||||
final scenes = allScenes;
|
||||
final open = scenes.where(isSceneUnlocked).toList();
|
||||
int recaps(DialogueScene scene) => reviewQueue
|
||||
.where((item) => item.id.startsWith('dialogue-${scene.id}-'))
|
||||
.length;
|
||||
@@ -409,7 +450,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
// Prefer the newest scene when both have been practised equally.
|
||||
return byRecaps != 0
|
||||
? byRecaps
|
||||
: a0Scenes.indexOf(right).compareTo(a0Scenes.indexOf(left));
|
||||
: scenes.indexOf(right).compareTo(scenes.indexOf(left));
|
||||
});
|
||||
return open.first;
|
||||
}
|
||||
@@ -484,7 +525,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: id,
|
||||
target: a0CoreItems[id] ?? id,
|
||||
target: coreItemLabel(id),
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
dueAt: DateTime.now().add(const Duration(days: 1)),
|
||||
|
||||
@@ -25,10 +25,13 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
|
||||
int get knownItemCount => mastery.length;
|
||||
|
||||
// Scoped to A0: these feed the A0 stage-assessment thresholds (48 usable /
|
||||
// 30 mastered) and the matching "/48 · /30" labels. Loaded A1+ items are
|
||||
// tracked separately and must not shift the A0 graduation gate.
|
||||
int get coreUsableCount => mastery.entries
|
||||
.where(
|
||||
(entry) =>
|
||||
a0CoreItems.containsKey(entry.key) &&
|
||||
isA0CoreItem(entry.key) &&
|
||||
(entry.value.status == MasteryStatus.use ||
|
||||
entry.value.status == MasteryStatus.master) &&
|
||||
!entry.value.needsReview,
|
||||
@@ -38,7 +41,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
int get coreMasteredCount => mastery.entries
|
||||
.where(
|
||||
(entry) =>
|
||||
a0CoreItems.containsKey(entry.key) &&
|
||||
isA0CoreItem(entry.key) &&
|
||||
entry.value.status == MasteryStatus.master &&
|
||||
!entry.value.needsReview,
|
||||
)
|
||||
@@ -124,8 +127,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
// The next check of a core item uses a different reviewed situation, so
|
||||
// later successes also show the item works outside the lesson's context.
|
||||
// An AI variant keeps its index so it can still be reported afterwards.
|
||||
final nextTemplate =
|
||||
a0CoreItems.containsKey(current.id) && !current.isAiGenerated
|
||||
final nextTemplate = isCoreItem(current.id) && !current.isAiGenerated
|
||||
? coreReviewVariant(current.id, current.variantIndex + 1)
|
||||
: null;
|
||||
reviewQueue[index] = current.copyWith(
|
||||
@@ -308,7 +310,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: id,
|
||||
target: a0CoreItems[id]!,
|
||||
target: coreItemEnglish(id),
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
dueAt: DateTime.now(),
|
||||
@@ -330,7 +332,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
prompt: variant.prompt,
|
||||
hint: variant.expectedAnswer,
|
||||
// An AI prompt is a written/spoken recall task, never dictation.
|
||||
skill: a0CoreItems.containsKey(current.id)
|
||||
skill: isCoreItem(current.id)
|
||||
? coreReviewTemplate(current.id).skill
|
||||
: null,
|
||||
variantIndex: current.variantIndex + 1,
|
||||
@@ -429,7 +431,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
}
|
||||
mastery[id] = MasteryItem(
|
||||
id: id,
|
||||
label: existing?.label ?? a0CoreItems[id] ?? id,
|
||||
label: existing?.label ?? coreItemLabel(id),
|
||||
status: _statusFromEvidence(id, checkpoint),
|
||||
checkpoint: checkpoint,
|
||||
needsReview: needsReview,
|
||||
@@ -443,7 +445,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
mastery[id] ??
|
||||
MasteryItem(
|
||||
id: id,
|
||||
label: a0CoreItems[id] ?? id,
|
||||
label: coreItemLabel(id),
|
||||
status: MasteryStatus.newItem,
|
||||
evidence: const [],
|
||||
);
|
||||
@@ -461,7 +463,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
||||
mastery[id] ??
|
||||
MasteryItem(
|
||||
id: id,
|
||||
label: a0CoreItems[id] ?? id,
|
||||
label: coreItemLabel(id),
|
||||
status: MasteryStatus.newItem,
|
||||
evidence: const [],
|
||||
);
|
||||
|
||||
@@ -93,10 +93,7 @@ abstract final class AppColors {
|
||||
ThemeData buildAppTheme(Brightness brightness) {
|
||||
final p = brightness == Brightness.dark ? _dark : _light;
|
||||
final scheme =
|
||||
ColorScheme.fromSeed(
|
||||
seedColor: p.green,
|
||||
brightness: brightness,
|
||||
).copyWith(
|
||||
ColorScheme.fromSeed(seedColor: p.green, brightness: brightness).copyWith(
|
||||
surface: p.surface,
|
||||
onSurface: p.ink,
|
||||
primary: p.green,
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import 'models.dart';
|
||||
import 'writing_feedback.dart';
|
||||
|
||||
class AssessmentTask {
|
||||
const AssessmentTask({
|
||||
required this.id,
|
||||
required this.skill,
|
||||
required this.prompt,
|
||||
this.audio,
|
||||
this.choices = const [],
|
||||
this.answerIndex,
|
||||
this.requiredWords = const [],
|
||||
required this.abilityId,
|
||||
});
|
||||
final String id;
|
||||
final AssessmentSkill skill;
|
||||
final String prompt;
|
||||
final String? audio;
|
||||
final List<String> choices;
|
||||
final int? answerIndex;
|
||||
final List<String> requiredWords;
|
||||
final String abilityId;
|
||||
}
|
||||
|
||||
class AssessmentPack {
|
||||
const AssessmentPack({required this.id, required this.tasks});
|
||||
final String id;
|
||||
final List<AssessmentTask> tasks;
|
||||
List<AssessmentTask> forSkill(AssessmentSkill skill) =>
|
||||
tasks.where((task) => task.skill == skill).toList();
|
||||
}
|
||||
|
||||
final a0AssessmentPacks = [
|
||||
AssessmentPack(
|
||||
id: 'A0-E1',
|
||||
tasks: _tasks(
|
||||
'A',
|
||||
const [
|
||||
'Hello. I’m Mia.|自我介绍|买东西|问时间|0',
|
||||
'How do you spell your name?|问姓名拼写|问地点|问时间|0',
|
||||
'I’m tired today.|很好|累|来自哪里|1',
|
||||
'seven|6|7|8|1',
|
||||
'one-three-eight|138|183|318|0',
|
||||
'It’s a key.|书|钥匙|包|1',
|
||||
'I’m from Hong Kong.|香港|伦敦|北京|0',
|
||||
'This is my mother.|妈妈|姐姐|朋友|0',
|
||||
'It is Monday.|星期一|星期二|星期三|0',
|
||||
'I like tea.|茶|咖啡|电影|0',
|
||||
],
|
||||
const [
|
||||
'Mia: I’m from Hong Kong.\nShen: I’m good.\nMia: My number is one-three-eight.|Mia 来自哪里?|Hong Kong|London|0',
|
||||
'Mia: It is Monday.\nShen: It is three o’clock.|几点?|three|one|0',
|
||||
'Mia: This is my brother.\nShen: Nice to meet you.|谁是家人?|brother|mother|0',
|
||||
'Mia: I like music.\nShen: I like tea.|Shen 喜欢什么?|tea|music|0',
|
||||
'Mia: What’s this?\nShen: It’s a pen.|这是什么?|pen|bag|0',
|
||||
],
|
||||
const [
|
||||
'用英语介绍自己。|i,m',
|
||||
'用英语说明你来自哪里。|i,m,from',
|
||||
'用英语说一个喜好。|i,like',
|
||||
'用英语说“这是一支笔”。|it,s,pen',
|
||||
'用英语说明星期或整点。|it,s',
|
||||
],
|
||||
const [
|
||||
'介绍姓名、地点、状态/喜好并反问。|hello,i,m,from',
|
||||
'完整拼读一个四字母姓名。|a,l,e,x',
|
||||
'说一个三位虚拟号码。|one,three,eight',
|
||||
'介绍一位家人。|this,my',
|
||||
'说明星期。|it,s,monday',
|
||||
'说明整点。|it,s,o,clock',
|
||||
'请求对方重复或放慢。|please',
|
||||
'说出一个物品。|it,s',
|
||||
],
|
||||
),
|
||||
),
|
||||
AssessmentPack(
|
||||
id: 'A0-E2',
|
||||
tasks: _tasks(
|
||||
'B',
|
||||
const [
|
||||
'Hi. My name is Leo.|自我介绍|买东西|问号码|0',
|
||||
'How do you spell that?|问姓名拼写|问喜好|问家人|0',
|
||||
'I’m okay.|很好|还可以|累|1',
|
||||
'four|3|4|5|1',
|
||||
'two-nine-six|296|269|926|0',
|
||||
'It’s a bag.|笔|包|水|1',
|
||||
'I’m from London.|伦敦|香港|北京|0',
|
||||
'This is my sister.|姐姐|妈妈|朋友|0',
|
||||
'It is Friday.|星期五|星期一|星期日|0',
|
||||
'I like coffee.|茶|咖啡|音乐|1',
|
||||
],
|
||||
const [
|
||||
'Mia: I’m from London.\nLeo: I’m okay.\nMia: My number is two-nine-six.|号码是多少?|296|269|0',
|
||||
'Mia: It is Friday.\nLeo: It is six o’clock.|几点?|six|three|0',
|
||||
'Mia: This is my father.\nLeo: Hi!|谁是家人?|father|brother|0',
|
||||
'Mia: I like movies.\nLeo: I like coffee.|Leo 喜欢什么?|coffee|movies|0',
|
||||
'Mia: What’s this?\nLeo: It’s water.|这是什么?|water|book|0',
|
||||
],
|
||||
const [
|
||||
'用英语介绍自己。|my,name,is',
|
||||
'用英语说明你来自哪里。|i,m,from',
|
||||
'用英语说一个喜好。|i,like',
|
||||
'用英语说“这是一个包”。|it,s,bag',
|
||||
'用英语说明星期或整点。|it,s',
|
||||
],
|
||||
const [
|
||||
'介绍姓名、地点、状态/喜好并反问。|hi,i,m,from',
|
||||
'完整拼读一个四字母姓名。|l,e,o,n',
|
||||
'说一个三位虚拟号码。|two,nine,six',
|
||||
'介绍一位家人。|this,my',
|
||||
'说明星期。|it,s,friday',
|
||||
'说明整点。|it,s,o,clock',
|
||||
'请求对方重复或放慢。|please',
|
||||
'说出一个物品。|it,s',
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
/// Replacement packs use different people, places, numbers, days and likes.
|
||||
/// They keep the same assessed ability but are not immediate replays.
|
||||
final a0ReplacementPacks = [
|
||||
_replacementPack(a0AssessmentPacks[0], 'A0-E1R', const {
|
||||
'Mia': 'Nora',
|
||||
'Shen': 'Kai',
|
||||
'Hong Kong': 'London',
|
||||
'Monday': 'Friday',
|
||||
'one-three-eight': 'two-four-seven',
|
||||
'138': '247',
|
||||
'three o’clock': 'six o’clock',
|
||||
'tea': 'coffee',
|
||||
'key': 'bag',
|
||||
'mother': 'father',
|
||||
}),
|
||||
_replacementPack(a0AssessmentPacks[1], 'A0-E2R', const {
|
||||
'Mia': 'Ava',
|
||||
'Leo': 'Bo',
|
||||
'London': 'Hong Kong',
|
||||
'Friday': 'Tuesday',
|
||||
'two-nine-six': 'five-one-four',
|
||||
'296': '514',
|
||||
'six o’clock': 'two o’clock',
|
||||
'coffee': 'music',
|
||||
'bag': 'book',
|
||||
'sister': 'brother',
|
||||
}),
|
||||
];
|
||||
|
||||
AssessmentPack? replacementFor(String packId) => switch (packId) {
|
||||
'A0-E1' => a0ReplacementPacks[0],
|
||||
'A0-E2' => a0ReplacementPacks[1],
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Local checks for the frozen A0 exit tasks. They accept variable names and
|
||||
/// places, but require the communicative information named by each task.
|
||||
bool checkOpenAssessmentAnswer(AssessmentTask task, String input) {
|
||||
final text = input
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r"\s+"), " ")
|
||||
.trim();
|
||||
final words = RegExp(
|
||||
r"[a-z]+(?:'[a-z]+)?",
|
||||
).allMatches(text).map((match) => match.group(0)!).toSet();
|
||||
bool has(String word) => words.contains(
|
||||
word
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.trim(),
|
||||
);
|
||||
bool phrase(String value) {
|
||||
final normVal = value
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r"\s+"), " ")
|
||||
.trim();
|
||||
return text.contains(normVal);
|
||||
}
|
||||
bool hasAny(Iterable<String> values) => values.any(has);
|
||||
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
|
||||
final itIs = phrase("it's") || phrase('it is');
|
||||
final numberWords = RegExp(
|
||||
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
|
||||
).allMatches(text).length;
|
||||
final position = int.tryParse(
|
||||
RegExp(r'(\d+)$').firstMatch(task.id)?.group(1) ?? '',
|
||||
);
|
||||
if (position == null) return false;
|
||||
|
||||
if (task.skill == AssessmentSkill.writing) {
|
||||
const lessonIds = ['a0-01', 'a0-06', 'a0-09', 'a0-05', 'a0-08'];
|
||||
return position >= 1 &&
|
||||
position <= lessonIds.length &&
|
||||
WritingFeedback.check(lessonIds[position - 1], input).complete;
|
||||
}
|
||||
if (task.skill != AssessmentSkill.speaking) return false;
|
||||
return switch (position) {
|
||||
1 =>
|
||||
hasAny(['hello', 'hi']) &&
|
||||
introduction &&
|
||||
has('from') &&
|
||||
(has('like') || hasAny(['good', 'okay', 'tired'])) &&
|
||||
hasAny(['what', 'where', 'how', 'do']),
|
||||
2 => RegExp(r'[a-z](?:[ -]?[a-z]){3,}').hasMatch(text),
|
||||
3 => numberWords >= 3,
|
||||
4 =>
|
||||
phrase('this is my') && hasAny(['mother', 'father', 'sister', 'brother']),
|
||||
5 =>
|
||||
itIs &&
|
||||
hasAny([
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]),
|
||||
6 =>
|
||||
itIs &&
|
||||
(has('clock') ||
|
||||
has("o'clock") ||
|
||||
has('oclock') ||
|
||||
phrase("o'clock")),
|
||||
7 => has('please') && hasAny(['say', 'speak', 'repeat', 'slow', 'slowly']),
|
||||
8 => itIs && hasAny(['book', 'pen', 'bag', 'key', 'water']),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
AssessmentPack _replacementPack(
|
||||
AssessmentPack source,
|
||||
String id,
|
||||
Map<String, String> values,
|
||||
) {
|
||||
String replace(String value) {
|
||||
var result = value;
|
||||
for (final entry in values.entries) {
|
||||
result = result.replaceAll(entry.key, entry.value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return AssessmentPack(
|
||||
id: id,
|
||||
tasks: source.tasks
|
||||
.map(
|
||||
(task) => AssessmentTask(
|
||||
id: task.id.replaceFirst(
|
||||
source.id.split('-').last,
|
||||
id.split('-').last,
|
||||
),
|
||||
skill: task.skill,
|
||||
prompt: replace(task.prompt),
|
||||
audio: task.audio == null ? null : replace(task.audio!),
|
||||
choices: task.choices.map(replace).toList(),
|
||||
answerIndex: task.answerIndex,
|
||||
requiredWords: task.requiredWords.map(replace).toList(),
|
||||
abilityId: task.abilityId,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
List<AssessmentTask> _tasks(
|
||||
String version,
|
||||
List<String> listening,
|
||||
List<String> reading,
|
||||
List<String> writing,
|
||||
List<String> speaking,
|
||||
) {
|
||||
AssessmentTask choice(String raw, AssessmentSkill skill, int index) {
|
||||
final fields = raw.split('|');
|
||||
return AssessmentTask(
|
||||
id: 'A0-$version-${skill.name[0].toUpperCase()}${index + 1}',
|
||||
skill: skill,
|
||||
audio: skill == AssessmentSkill.listening ? fields[0] : null,
|
||||
prompt: skill == AssessmentSkill.listening ? '选择正确答案' : fields[0],
|
||||
choices: skill == AssessmentSkill.listening
|
||||
? fields.sublist(1, 4)
|
||||
: fields.sublist(1, 3),
|
||||
answerIndex: int.parse(fields.last),
|
||||
abilityId: 'A0-C${(index % 10 + 1).toString().padLeft(2, '0')}',
|
||||
);
|
||||
}
|
||||
|
||||
AssessmentTask open(String raw, AssessmentSkill skill, int index) {
|
||||
final fields = raw.split('|');
|
||||
return AssessmentTask(
|
||||
id: 'A0-$version-${skill.name[0].toUpperCase()}${index + 1}',
|
||||
skill: skill,
|
||||
prompt: fields[0],
|
||||
requiredWords: fields[1].split(','),
|
||||
abilityId: 'A0-C${(index % 10 + 1).toString().padLeft(2, '0')}',
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
...List.generate(
|
||||
listening.length,
|
||||
(i) => choice(listening[i], AssessmentSkill.listening, i),
|
||||
),
|
||||
...List.generate(
|
||||
reading.length,
|
||||
(i) => choice(reading[i], AssessmentSkill.reading, i),
|
||||
),
|
||||
...List.generate(
|
||||
writing.length,
|
||||
(i) => open(writing[i], AssessmentSkill.writing, i),
|
||||
),
|
||||
...List.generate(
|
||||
speaking.length,
|
||||
(i) => open(speaking[i], AssessmentSkill.speaking, i),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'answer_rules.dart';
|
||||
import 'core_items.dart';
|
||||
import 'course_catalog.dart';
|
||||
import 'course_models.dart';
|
||||
|
||||
/// A reply that is at least one real word; turns without a declared rule
|
||||
/// accept it.
|
||||
bool _isRealReply(String text) => RegExp(r'[a-z]{2,}').hasMatch(text);
|
||||
|
||||
/// Whether [input] actually contains core item [id], by the item's `match`
|
||||
/// rule. Lessons, reviews and dialogues use the same rule, so evidence only
|
||||
/// goes to the items a learner really produced, never to every target a task
|
||||
/// happens to be attached to.
|
||||
bool coreItemUsedIn(String id, String input) {
|
||||
final rule = coreItemMatch[id];
|
||||
if (rule == null || rule.isEmpty) return false;
|
||||
return matchesRule(input, rule);
|
||||
}
|
||||
|
||||
bool matchesSegmentDialogue(String segmentId, int stage, String response) {
|
||||
final segment = findLessonSegment(segmentId);
|
||||
if (segment == null || stage >= segment.dialogueRequiredTerms.length) {
|
||||
return false;
|
||||
}
|
||||
final text = normalizeAnswer(response);
|
||||
final terms = segment.dialogueRequiredTerms[stage];
|
||||
// A turn that declares no reviewed term (adapted units) just needs a real
|
||||
// reply, matching how free-scene turns are validated.
|
||||
if (terms.isEmpty) return _isRealReply(text);
|
||||
return groupSatisfied(text, terms);
|
||||
}
|
||||
|
||||
bool matchesSegmentIndependent(String segmentId, String response) {
|
||||
final rule = findLessonSegment(segmentId)?.independentRequiredTerms;
|
||||
if (rule == null) return false;
|
||||
// A segment without a reviewed rule accepts any real attempt.
|
||||
if (rule.isEmpty) return _isRealReply(normalizeAnswer(response));
|
||||
return matchesRule(response, rule);
|
||||
}
|
||||
|
||||
/// Explains in Chinese what an independent attempt still lacks, or returns
|
||||
/// null when [matchesSegmentIndependent] accepts it.
|
||||
String? independentShortfall(String segmentId, String response) {
|
||||
if (matchesSegmentIndependent(segmentId, response)) return null;
|
||||
final rule = findLessonSegment(segmentId)?.independentRequiredTerms;
|
||||
if (rule == null) return '这次还没有用上本段要练的内容。查看帮助后补充一次。';
|
||||
if (rule.isEmpty) return '请写一个完整的英文句子。';
|
||||
final text = normalizeAnswer(response);
|
||||
if (rule.length == 1) {
|
||||
final group = rule.single;
|
||||
final minimum = groupMinimum(group);
|
||||
final terms = groupTerms(group);
|
||||
if (minimum != null) {
|
||||
return '需要用上至少 $minimum 个本段的表达(${terms.take(6).join(' / ')}),'
|
||||
'目前用上 ${groupTermsUsed(text, group)} 个。';
|
||||
}
|
||||
return '需要用上本段的表达之一:${terms.take(4).join(' / ')}。';
|
||||
}
|
||||
final missing = rule
|
||||
.where((group) => !groupSatisfied(text, group))
|
||||
.map(termGroupLabel);
|
||||
return '还缺:${missing.join(';')}。';
|
||||
}
|
||||
|
||||
/// Whole-lesson and free-scene dialogues validate the same way segment
|
||||
/// dialogues do: the turn has to contain the language the turn is teaching.
|
||||
bool matchesDialogueStage(LessonDialogue script, int stage, String response) {
|
||||
final text = normalizeAnswer(response);
|
||||
if (stage < 0 || stage >= script.requiredTerms.length) {
|
||||
// No declared requirement: accept anything that is actually a word.
|
||||
return _isRealReply(text);
|
||||
}
|
||||
final terms = script.requiredTerms[stage];
|
||||
if (terms.isEmpty) return _isRealReply(text);
|
||||
return groupSatisfied(text, terms);
|
||||
}
|
||||
|
||||
String dialogueTaskLabel(LessonDialogue script, int stage) =>
|
||||
stage >= 0 && stage < script.taskLabels.length
|
||||
? script.taskLabels[stage]
|
||||
: '完成本轮任务';
|
||||
@@ -0,0 +1,174 @@
|
||||
/// The one rule language every offline answer check uses: lesson writing,
|
||||
/// independent attempts, controlled dialogues and core-item reviews. Rules
|
||||
/// live in the course JSON; this file only interprets them.
|
||||
///
|
||||
/// A rule is a list of groups. Every group must be satisfied (AND); a group
|
||||
/// is satisfied when any of its terms appears (OR). A group that starts with
|
||||
/// `#min:N` instead needs N different terms of the group.
|
||||
///
|
||||
/// Terms:
|
||||
/// * `text` — whole words, after [normalizeAnswer] on both sides, so
|
||||
/// "I'm" and "I am" are the same term.
|
||||
/// * `a + b` — every part has to appear.
|
||||
/// * `/regex/` — a regular expression over the normalized answer.
|
||||
/// * `#spelling`, `#digit`, `#word`, `#question` — structures, see
|
||||
/// [_matchesStructure]; `#numbers:N` needs N English number words and
|
||||
/// `#words:N` needs N different words.
|
||||
library;
|
||||
|
||||
/// Lowercases, unifies quotes and spaces, and expands contractions, so an
|
||||
/// answer and a rule term compare the same way however either was typed.
|
||||
String normalizeAnswer(String input) {
|
||||
var text = input
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
for (final (pattern, replacement) in _contractions) {
|
||||
text = text.replaceAll(pattern, replacement);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
final List<(RegExp, String)> _contractions = [
|
||||
(RegExp(r"\bwon't\b"), 'will not'),
|
||||
(RegExp(r"\bcan't\b"), 'can not'),
|
||||
(RegExp(r'\bcannot\b'), 'can not'),
|
||||
(RegExp(r"n't\b"), ' not'),
|
||||
(RegExp(r"'m\b"), ' am'),
|
||||
(RegExp(r"'re\b"), ' are'),
|
||||
(RegExp(r"'s\b"), ' is'),
|
||||
(RegExp(r"'ll\b"), ' will'),
|
||||
(RegExp(r"'ve\b"), ' have'),
|
||||
(RegExp(r"'d\b"), ' would'),
|
||||
];
|
||||
|
||||
/// Whether [input] satisfies every group of [rule].
|
||||
bool matchesRule(String input, List<List<String>> rule) {
|
||||
final text = normalizeAnswer(input);
|
||||
return rule.every((group) => groupSatisfied(text, group));
|
||||
}
|
||||
|
||||
/// Whether the normalized [text] satisfies [group].
|
||||
bool groupSatisfied(String text, List<String> group) {
|
||||
final minimum = groupMinimum(group);
|
||||
if (minimum == null) return group.any((term) => containsTerm(text, term));
|
||||
return groupTermsUsed(text, group) >= minimum;
|
||||
}
|
||||
|
||||
/// N for a `#min:N` group, otherwise null.
|
||||
int? groupMinimum(List<String> group) => group.isNotEmpty
|
||||
? int.tryParse(
|
||||
RegExp(r'^#min:(\d+)$').firstMatch(group.first)?.group(1) ?? '',
|
||||
)
|
||||
: null;
|
||||
|
||||
/// The terms of [group] a learner can pick from, without a `#min:N` marker.
|
||||
List<String> groupTerms(List<String> group) =>
|
||||
groupMinimum(group) == null ? group : group.sublist(1);
|
||||
|
||||
/// How many different terms of [group] appear in the normalized [text].
|
||||
int groupTermsUsed(String text, List<String> group) =>
|
||||
groupTerms(group).where((term) => containsTerm(text, term)).length;
|
||||
|
||||
/// Whether the normalized [text] contains [term] (see the library docs).
|
||||
bool containsTerm(String text, String term) {
|
||||
if (term.contains(' + ')) {
|
||||
// Structures are checked on what is left after the literal parts, so in
|
||||
// "i'm + #word" the "am" of the frame is not taken for the name.
|
||||
var rest = text;
|
||||
final parts = term.split(' + ').map((part) => part.trim()).toList();
|
||||
for (final part in parts.where((part) => !part.startsWith('#'))) {
|
||||
if (!containsTerm(rest, part)) return false;
|
||||
rest = rest.replaceFirst(_termPattern(part), ' ');
|
||||
}
|
||||
return parts
|
||||
.where((part) => part.startsWith('#'))
|
||||
.every((part) => _matchesStructure(rest, part));
|
||||
}
|
||||
if (term.startsWith('#')) return _matchesStructure(text, term);
|
||||
return _termPattern(term).hasMatch(text);
|
||||
}
|
||||
|
||||
RegExp _termPattern(String term) {
|
||||
if (term.length > 2 && term.startsWith('/') && term.endsWith('/')) {
|
||||
return RegExp(term.substring(1, term.length - 1));
|
||||
}
|
||||
final escaped = RegExp.escape(normalizeAnswer(term));
|
||||
return RegExp('(?<![a-z])$escaped(?![a-z])');
|
||||
}
|
||||
|
||||
final _numberWord = RegExp(
|
||||
r'(?<![a-z])(zero|one|two|three|four|five|six|seven|eight|nine|ten)(?![a-z])',
|
||||
);
|
||||
final _wordToken = RegExp(r"[a-z]+(?:'[a-z]+)?");
|
||||
|
||||
bool _matchesStructure(String text, String token) {
|
||||
final counted = RegExp(r'^#(numbers|words):(\d+)$').firstMatch(token);
|
||||
if (counted != null) {
|
||||
final needed = int.parse(counted.group(2)!);
|
||||
return switch (counted.group(1)) {
|
||||
'numbers' => _numberWord.allMatches(text).length >= needed,
|
||||
_ =>
|
||||
_wordToken.allMatches(text).map((m) => m.group(0)).toSet().length >=
|
||||
needed,
|
||||
};
|
||||
}
|
||||
return switch (token) {
|
||||
// Letters said one by one ("S-H-E-N", "s h e n", "L-I"), or the spelled
|
||||
// name typed as one word ("Shen", "aaa"). Case and hyphens are ignored.
|
||||
'#spelling' =>
|
||||
RegExp(
|
||||
r'(?:^|[^a-z])[a-z](?:\s*[-‐-―]\s*[a-z]|\s[a-z])+(?![a-z])',
|
||||
).hasMatch(text) ||
|
||||
RegExp(r'^[a-z]{2,}$').hasMatch(
|
||||
text
|
||||
.replaceAll(RegExp(r'[-‐-―]'), '')
|
||||
.replaceAll(RegExp(r'^[^a-z]+|[^a-z]+$'), ''),
|
||||
),
|
||||
// Any spoken or written digit.
|
||||
'#digit' => _numberWord.hasMatch(text) || RegExp('[0-9]').hasMatch(text),
|
||||
// A real word after the frame, so "I'm" alone is not a name.
|
||||
'#word' => RegExp(r'[a-z]{2,}').hasMatch(text),
|
||||
// Any question addressed to the partner.
|
||||
'#question' =>
|
||||
text.contains('?') ||
|
||||
RegExp(
|
||||
r"(?<![a-z])(what|where|who|when|how|why|do you|are you|can you)(?![a-z])",
|
||||
).hasMatch(text),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// A short Chinese label for [group], used when telling a learner what an
|
||||
/// attempt still lacks.
|
||||
String termGroupLabel(List<String> group) {
|
||||
final labels = <String>[];
|
||||
for (final term in groupTerms(group)) {
|
||||
final label = _termLabel(term);
|
||||
if (!labels.contains(label)) labels.add(label);
|
||||
}
|
||||
return labels.take(4).join(' / ');
|
||||
}
|
||||
|
||||
String _termLabel(String term) => term
|
||||
.split(' + ')
|
||||
.map((part) {
|
||||
final trimmed = part.trim();
|
||||
final counted = RegExp(r'^#(numbers|words):(\d+)$').firstMatch(trimmed);
|
||||
if (counted != null) {
|
||||
return counted.group(1) == 'numbers'
|
||||
? '至少 ${counted.group(2)} 个英文数字'
|
||||
: '至少 ${counted.group(2)} 个词';
|
||||
}
|
||||
if (trimmed.startsWith('/')) return '…';
|
||||
return switch (trimmed) {
|
||||
'#spelling' => '把名字逐个字母拼出来(如 A-L-E-X)',
|
||||
'#digit' => '一个数字',
|
||||
'#word' => '…',
|
||||
'#question' => '一句反问(如 What about you?)',
|
||||
final word => word,
|
||||
};
|
||||
})
|
||||
.join(' ');
|
||||
@@ -0,0 +1,132 @@
|
||||
import '../models.dart';
|
||||
import 'answer_rules.dart';
|
||||
|
||||
/// One stage-assessment task, from the level's `assessment` JSON. Choice
|
||||
/// tasks carry `choices` and `answerIndex`; open tasks (writing, speaking)
|
||||
/// carry an `accept` rule in the language of `answer_rules.dart` and the
|
||||
/// `guide` shown after a wrong answer.
|
||||
class AssessmentTask {
|
||||
const AssessmentTask({
|
||||
required this.id,
|
||||
required this.skill,
|
||||
required this.prompt,
|
||||
this.audio,
|
||||
this.choices = const [],
|
||||
this.answerIndex,
|
||||
this.accept = const [],
|
||||
this.guide = '',
|
||||
});
|
||||
|
||||
factory AssessmentTask.fromJson(Map<String, dynamic> json) => AssessmentTask(
|
||||
id: json['id'] as String,
|
||||
skill: AssessmentSkill.values.byName(json['skill'] as String),
|
||||
prompt: json['prompt'] as String? ?? '',
|
||||
audio: json['audio'] as String?,
|
||||
choices: [for (final choice in json['choices'] as List? ?? const []) '$choice'],
|
||||
answerIndex: json['answerIndex'] as int?,
|
||||
accept: [
|
||||
for (final group in json['accept'] as List? ?? const [])
|
||||
[for (final term in group as List) '$term'],
|
||||
],
|
||||
guide: json['guide'] as String? ?? '',
|
||||
);
|
||||
|
||||
final String id;
|
||||
final AssessmentSkill skill;
|
||||
final String prompt;
|
||||
final String? audio;
|
||||
final List<String> choices;
|
||||
final int? answerIndex;
|
||||
final List<List<String>> accept;
|
||||
final String guide;
|
||||
}
|
||||
|
||||
extension AssessmentSkillLabel on AssessmentSkill {
|
||||
String get label => switch (this) {
|
||||
AssessmentSkill.listening => '听力',
|
||||
AssessmentSkill.speaking => '口语',
|
||||
AssessmentSkill.reading => '阅读',
|
||||
AssessmentSkill.writing => '写作',
|
||||
};
|
||||
}
|
||||
|
||||
/// A stage-assessment pack. A replacement pack ([replaces] set) assesses the
|
||||
/// same abilities as its original with different people, places and values,
|
||||
/// and its results count toward the original.
|
||||
class AssessmentPack {
|
||||
const AssessmentPack({
|
||||
required this.id,
|
||||
required this.level,
|
||||
required this.tasks,
|
||||
this.replaces,
|
||||
});
|
||||
|
||||
factory AssessmentPack.fromJson(Map<String, dynamic> json) => AssessmentPack(
|
||||
id: json['id'] as String,
|
||||
level: json['level'] as String? ?? '',
|
||||
replaces: json['replaces'] as String?,
|
||||
tasks: [
|
||||
for (final task in json['tasks'] as List? ?? const [])
|
||||
AssessmentTask.fromJson(task as Map<String, dynamic>),
|
||||
],
|
||||
);
|
||||
|
||||
final String id;
|
||||
final String level;
|
||||
final String? replaces;
|
||||
final List<AssessmentTask> tasks;
|
||||
|
||||
List<AssessmentTask> forSkill(AssessmentSkill skill) =>
|
||||
tasks.where((task) => task.skill == skill).toList();
|
||||
}
|
||||
|
||||
/// Every loaded assessment pack, in file order; filled by `CourseRepository`.
|
||||
final List<AssessmentPack> assessmentPacks = <AssessmentPack>[];
|
||||
|
||||
/// The A0 stage assessments, in the order they unlock.
|
||||
List<AssessmentPack> get a0AssessmentPacks => [
|
||||
for (final pack in assessmentPacks)
|
||||
if (pack.level == 'A0' && pack.replaces == null) pack,
|
||||
];
|
||||
|
||||
/// The A0 replacement packs.
|
||||
List<AssessmentPack> get a0ReplacementPacks => [
|
||||
for (final pack in assessmentPacks)
|
||||
if (pack.level == 'A0' && pack.replaces != null) pack,
|
||||
];
|
||||
|
||||
/// The replacement pack of [packId], if it has one.
|
||||
AssessmentPack? replacementFor(String packId) =>
|
||||
assessmentPacks.where((pack) => pack.replaces == packId).firstOrNull;
|
||||
|
||||
/// The pack whose results [packId] counts toward: the original of a
|
||||
/// replacement pack, otherwise [packId] itself.
|
||||
String canonicalAssessmentPackId(String packId) =>
|
||||
assessmentPacks
|
||||
.where((pack) => pack.id == packId)
|
||||
.firstOrNull
|
||||
?.replaces ??
|
||||
packId;
|
||||
|
||||
/// Local check of an open task with the same rule engine as the lessons: it
|
||||
/// accepts variable names and places, but requires the communicative
|
||||
/// information the task names.
|
||||
bool checkOpenAssessmentAnswer(AssessmentTask task, String input) =>
|
||||
task.accept.isNotEmpty && matchesRule(input, task.accept);
|
||||
|
||||
/// What a correct answer to [task] looks like, shown when reviewing a
|
||||
/// wrong answer after the assessment. [answer] is what the learner gave.
|
||||
String assessmentAnswerGuide(AssessmentTask task, String answer) {
|
||||
final index = task.answerIndex;
|
||||
if (index != null && index >= 0 && index < task.choices.length) {
|
||||
final correct = task.choices[index];
|
||||
if (task.skill == AssessmentSkill.listening && answer == correct) {
|
||||
return '正确答案:$correct(选对了,但作答前没有播放音频,所以不计分)';
|
||||
}
|
||||
return '正确答案:$correct';
|
||||
}
|
||||
if (task.guide.isNotEmpty) return task.guide;
|
||||
return task.skill == AssessmentSkill.writing
|
||||
? '需要写一个完整的英文句子。'
|
||||
: '需要用完整英文回答题目要求。';
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'course_pack.dart';
|
||||
|
||||
/// A produced core item as the registries see it: the level of the pack that
|
||||
/// teaches it and its English and Chinese forms.
|
||||
typedef RegisteredCoreItem = ({String level, String en, String zh});
|
||||
|
||||
/// Every produced core item across A0–B1, in course order, filled from the
|
||||
/// packs' `coreItems` by `CourseRepository`. IDs are separate from the
|
||||
/// wording and never change when a course accepts a slot value or
|
||||
/// contraction, because saved progress refers to them.
|
||||
final Map<String, RegisteredCoreItem> coreItemRegistry =
|
||||
<String, RegisteredCoreItem>{};
|
||||
|
||||
/// Whether [id] is an A0 core item. A0 items make up the frozen A0 v1
|
||||
/// upgrade denominator from A0-STAGE-STANDARD.md.
|
||||
bool isA0CoreItem(String id) => coreItemRegistry[id]?.level == 'A0';
|
||||
|
||||
/// The A0 core items, id → English, in course order.
|
||||
Map<String, String> get a0CoreItems => {
|
||||
for (final entry in coreItemRegistry.entries)
|
||||
if (entry.value.level == 'A0') entry.key: entry.value.en,
|
||||
};
|
||||
|
||||
class CoreReviewTemplate {
|
||||
const CoreReviewTemplate({
|
||||
required this.prompt,
|
||||
required this.hint,
|
||||
required this.skill,
|
||||
});
|
||||
|
||||
final String prompt;
|
||||
final String hint;
|
||||
final String skill;
|
||||
}
|
||||
|
||||
/// Review data of every core item, straight from the course JSON: the acceptance rule (see `answer_rules.dart`), the offline prompt,
|
||||
/// the hint after a miss and an optional dictation sentence.
|
||||
final Map<String, List<List<String>>> coreItemMatch =
|
||||
<String, List<List<String>>>{};
|
||||
final Map<String, CoreReviewTemplate> coreReviewTemplates =
|
||||
<String, CoreReviewTemplate>{};
|
||||
final Map<String, String> coreReviewHints = <String, String>{};
|
||||
final Map<String, DictationSentence> coreDictationSentences =
|
||||
<String, DictationSentence>{};
|
||||
|
||||
/// Registers [item], taught at [level], with its review data.
|
||||
void registerCoreItem(CoreItem item, {required String level}) {
|
||||
coreItemRegistry[item.id] = (level: level, en: item.en, zh: item.zh);
|
||||
coreItemMatch[item.id] = item.match;
|
||||
coreReviewTemplates[item.id] = CoreReviewTemplate(
|
||||
prompt: item.reviewPrompt.isNotEmpty
|
||||
? item.reviewPrompt
|
||||
: '用英文表达“${item.zh}”。',
|
||||
hint: item.en,
|
||||
skill: item.type == 'word' ? '词汇回忆' : '回忆表达',
|
||||
);
|
||||
if (item.reviewHint.isNotEmpty) coreReviewHints[item.id] = item.reviewHint;
|
||||
if (item.dictationSentence.isNotEmpty) {
|
||||
coreDictationSentences[item.id] = (
|
||||
sentence: item.dictationSentence,
|
||||
meaning: item.dictationMeaning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Empties every core-item registry, for `CourseRepository.resetForTest`.
|
||||
void clearCoreItems() {
|
||||
coreItemRegistry.clear();
|
||||
coreItemMatch.clear();
|
||||
coreReviewTemplates.clear();
|
||||
coreReviewHints.clear();
|
||||
coreDictationSentences.clear();
|
||||
}
|
||||
|
||||
/// The label a learner sees for a core item, falling back to its id. A0
|
||||
/// items are labelled by their English (a beginner recognises the phrase);
|
||||
/// later levels by their Chinese meaning.
|
||||
String coreItemLabel(String id) {
|
||||
final item = coreItemRegistry[id];
|
||||
if (item == null) return id;
|
||||
return item.level == 'A0' ? item.en : item.zh;
|
||||
}
|
||||
|
||||
/// The English form of any core item; prompts that constrain the AI's
|
||||
/// English use this.
|
||||
String coreItemEnglish(String id) => coreItemRegistry[id]?.en ?? id;
|
||||
|
||||
/// Whether [id] is a produced core item the course tracks for mastery.
|
||||
bool isCoreItem(String id) => coreItemRegistry.containsKey(id);
|
||||
|
||||
/// Every produced core item id across A0–B1, in course order.
|
||||
Iterable<String> get allCoreItemIds => coreItemRegistry.keys;
|
||||
|
||||
/// Offline, reviewed prompts keep initial reviews usable without an AI service.
|
||||
/// The prompt supplies a meaning or situation, never the English target.
|
||||
CoreReviewTemplate coreReviewTemplate(String id) =>
|
||||
coreReviewTemplates[id] ??
|
||||
CoreReviewTemplate(prompt: '把“这个已学词”写成英文。', hint: id, skill: '词汇回忆');
|
||||
|
||||
/// Rotates an approved offline prompt family without changing the core target
|
||||
/// ID. The rotation changes the skill, not just the wording: recall in
|
||||
/// writing, dictation from audio, then saying it aloud.
|
||||
CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
|
||||
final base = coreReviewTemplate(id);
|
||||
final dictation = coreDictationSentences[id];
|
||||
return switch (variantIndex % 3) {
|
||||
1 when dictation != null => CoreReviewTemplate(
|
||||
prompt: '听一句话,把听到的整句英文写下来。',
|
||||
hint: dictation.meaning,
|
||||
skill: dictationSkill,
|
||||
),
|
||||
2 => CoreReviewTemplate(
|
||||
prompt: '用英文说出来:${base.prompt}',
|
||||
hint: base.hint,
|
||||
skill: spokenRecallSkill,
|
||||
),
|
||||
_ => base,
|
||||
};
|
||||
}
|
||||
|
||||
const dictationSkill = '听写';
|
||||
const spokenRecallSkill = '口头回忆';
|
||||
|
||||
/// A concrete sentence built only from taught language, used to play a core
|
||||
/// item in context and to check dictation.
|
||||
typedef DictationSentence = ({String sentence, String meaning});
|
||||
|
||||
/// What review audio should play for [item]: the dictation sentence for a
|
||||
/// core item, otherwise the target itself when it has no slot to fill.
|
||||
String? reviewAudioText(String id, String target) =>
|
||||
coreDictationSentences[id]?.sentence ??
|
||||
(target.contains('[') ? null : target);
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'dart:math';
|
||||
|
||||
import '../models.dart';
|
||||
import 'assessment.dart';
|
||||
import 'core_items.dart';
|
||||
import 'course_models.dart';
|
||||
|
||||
// Course registries, filled by `CourseRepository` from the content packs in
|
||||
// assets/courses. Every level, A0 included, goes through the same adapter, so
|
||||
// these hold one kind of data per key whatever level it came from.
|
||||
final List<SeedLesson> courseLessons = <SeedLesson>[];
|
||||
final Map<String, LessonSegment> courseSegments = <String, LessonSegment>{};
|
||||
final Map<String, LessonActivity> segmentActivities =
|
||||
<String, LessonActivity>{};
|
||||
final Map<String, List<VocabularyItem>> segmentVocabulary =
|
||||
<String, List<VocabularyItem>>{};
|
||||
final Map<String, LessonDialogue> segmentDialogues = <String, LessonDialogue>{};
|
||||
final Map<String, String> segmentGrammarNotes = <String, String>{};
|
||||
final List<DialogueScene> practiceScenes = <DialogueScene>[];
|
||||
|
||||
/// Empties every registry, for `CourseRepository.resetForTest`.
|
||||
void clearCourseCatalog() {
|
||||
courseLessons.clear();
|
||||
courseSegments.clear();
|
||||
segmentActivities.clear();
|
||||
segmentVocabulary.clear();
|
||||
segmentDialogues.clear();
|
||||
segmentGrammarNotes.clear();
|
||||
practiceScenes.clear();
|
||||
assessmentPacks.clear();
|
||||
}
|
||||
|
||||
/// Every lesson the app can teach, in learning order: A0 first, then the
|
||||
/// A1→A2→B1 units.
|
||||
List<SeedLesson> get allLessons => courseLessons;
|
||||
|
||||
/// The A0 lessons, which lead to the A0 stage assessment.
|
||||
List<SeedLesson> get a0SeedLessons =>
|
||||
courseLessons.where((lesson) => lesson.level == 'A0').toList();
|
||||
|
||||
/// Every free-practice scene, in course order.
|
||||
List<DialogueScene> get allScenes => practiceScenes;
|
||||
|
||||
/// Finds a teaching segment by id.
|
||||
LessonSegment? findLessonSegment(String segmentId) => courseSegments[segmentId];
|
||||
|
||||
SeedLesson lessonById(String id) => allLessons.firstWhere(
|
||||
(lesson) => lesson.id == id,
|
||||
orElse: () => allLessons.first,
|
||||
);
|
||||
|
||||
DialogueScene sceneById(String id) =>
|
||||
allScenes.where((scene) => scene.id == id).firstOrNull ?? allScenes.first;
|
||||
|
||||
LessonActivity activityBySegmentId(String segmentId) =>
|
||||
segmentActivities[segmentId] ?? _emptyActivity;
|
||||
|
||||
const LessonActivity _emptyActivity = LessonActivity(
|
||||
listening: '',
|
||||
listeningQuestion: '',
|
||||
answers: [''],
|
||||
speaking: '',
|
||||
reading: '',
|
||||
readingQuestion: '',
|
||||
readingAnswer: '',
|
||||
writingPrompt: '',
|
||||
writingExample: '',
|
||||
independentPrompt: '',
|
||||
independentHelp: '',
|
||||
);
|
||||
|
||||
List<VocabularyItem> vocabularyBySegmentId(String segmentId) =>
|
||||
segmentVocabulary[segmentId] ?? const <VocabularyItem>[];
|
||||
|
||||
/// One practical rule per segment: a reminder for using a sentence in
|
||||
/// context, not an abstract grammar lecture.
|
||||
String grammarNoteForSegment(String segmentId) =>
|
||||
segmentGrammarNotes[segmentId] ?? '';
|
||||
|
||||
LessonDialogue dialogueBySegmentId(String segmentId) =>
|
||||
segmentDialogues[segmentId] ?? _emptyDialogue;
|
||||
|
||||
const LessonDialogue _emptyDialogue = LessonDialogue(
|
||||
goal: '',
|
||||
prompts: [],
|
||||
hints: [],
|
||||
);
|
||||
|
||||
/// How many A1+ units, the current one included, the AI partner's word list
|
||||
/// spans. Earlier units stay usable as ordinary easy English; listing every
|
||||
/// unit would resend hundreds of items with each dialogue turn.
|
||||
const int taughtLanguageUnitWindow = 3;
|
||||
|
||||
/// The English a learner has actually met up to and including [lessonId].
|
||||
/// The AI partner is told to build its lines from this list so a beginner
|
||||
/// never gets an answer built from words the course has not taught yet. In A0
|
||||
/// it is everything taught so far; from A1 on it is the recent units only.
|
||||
/// The order is fixed so repeated prompts share a cacheable prefix.
|
||||
List<String> taughtLanguageUpTo(String lessonId) {
|
||||
final lessons = allLessons;
|
||||
final found = lessons.indexWhere((lesson) => lesson.id == lessonId);
|
||||
final end = found < 0 ? lessons.length - 1 : found;
|
||||
final start = lessons[end].level == 'A0'
|
||||
? 0
|
||||
: max(end - taughtLanguageUnitWindow + 1, a0SeedLessons.length);
|
||||
return <String>{
|
||||
for (final lesson in lessons.sublist(start, end + 1))
|
||||
for (final id in lesson.targetItemIds) coreItemEnglish(id),
|
||||
}.toList();
|
||||
}
|
||||
|
||||
/// Every A0 item, for the always-open free scene that mixes all A0 topics.
|
||||
List<String> get a0TaughtLanguage => a0CoreItems.values.toList();
|
||||
|
||||
/// The CEFR level of the lesson that teaches [itemId], A0 when it is unknown.
|
||||
String itemLevel(String itemId) {
|
||||
for (final lesson in allLessons) {
|
||||
if (lesson.targetItemIds.contains(itemId)) return lesson.level;
|
||||
}
|
||||
return 'A0';
|
||||
}
|
||||
|
||||
/// The CEFR level of [lessonId], A0 when it is unknown.
|
||||
String lessonLevel(String lessonId) {
|
||||
for (final lesson in allLessons) {
|
||||
if (lesson.id == lessonId) return lesson.level;
|
||||
}
|
||||
return 'A0';
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import '../models.dart';
|
||||
|
||||
/// A topic may contain multiple independently resumable teaching segments.
|
||||
/// Segment IDs are stable even if later wording or exercises change.
|
||||
class LessonSegment {
|
||||
const LessonSegment({
|
||||
required this.id,
|
||||
required this.targetItemIds,
|
||||
required this.previewItemIds,
|
||||
this.dialogueRequiredTerms = const [],
|
||||
this.independentRequiredTerms = const [],
|
||||
this.estimatedMinutes = 12,
|
||||
});
|
||||
|
||||
|
||||
final String id;
|
||||
final List<String> targetItemIds;
|
||||
final List<String> previewItemIds;
|
||||
|
||||
/// One accepted term group per controlled dialogue turn. A learner only
|
||||
/// needs to use one term from the current group; the surrounding wording
|
||||
/// may vary naturally.
|
||||
final List<List<String>> dialogueRequiredTerms;
|
||||
|
||||
/// The rule an independent attempt has to satisfy (see
|
||||
/// `answer_rules.dart`); empty accepts any real attempt.
|
||||
final List<List<String>> independentRequiredTerms;
|
||||
final int estimatedMinutes;
|
||||
}
|
||||
|
||||
class SeedLesson {
|
||||
const SeedLesson({
|
||||
required this.id,
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.outcome,
|
||||
required this.vocabulary,
|
||||
required this.targetItemIds,
|
||||
this.schemaVersion = '1.0',
|
||||
this.revision = 1,
|
||||
this.stageVersion = 'A0-1.0',
|
||||
this.source = ContentSource.builtInOriginal,
|
||||
this.status = ContentStatus.approved,
|
||||
this.estimatedMinutes = 12,
|
||||
this.level = 'A0',
|
||||
this.explicitSegments,
|
||||
});
|
||||
final String id;
|
||||
final int number;
|
||||
final String title;
|
||||
final String outcome;
|
||||
final List<VocabularyItem> vocabulary;
|
||||
final List<String> targetItemIds;
|
||||
final String schemaVersion;
|
||||
final int revision;
|
||||
final String stageVersion;
|
||||
final ContentSource source;
|
||||
final ContentStatus status;
|
||||
final int estimatedMinutes;
|
||||
|
||||
/// CEFR level this lesson belongs to (`A0`, `A1`, `A2`, `B1`).
|
||||
final String level;
|
||||
|
||||
/// Set when the lesson is split into several teaching segments; otherwise
|
||||
/// the whole lesson is taught as one implicit segment.
|
||||
final List<LessonSegment>? explicitSegments;
|
||||
|
||||
List<String> get abilityIds => ['A0-C${number.toString().padLeft(2, '0')}'];
|
||||
List<String> get prerequisiteIds => number == 1
|
||||
? const []
|
||||
: ['a0-${(number - 1).toString().padLeft(2, '0')}'];
|
||||
List<LessonSegment> get segments =>
|
||||
explicitSegments ??
|
||||
[
|
||||
LessonSegment(
|
||||
id: '$id-a',
|
||||
targetItemIds: targetItemIds,
|
||||
previewItemIds: targetItemIds,
|
||||
estimatedMinutes: estimatedMinutes,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
class LessonActivity {
|
||||
const LessonActivity({
|
||||
required this.listening,
|
||||
required this.listeningQuestion,
|
||||
required this.answers,
|
||||
required this.speaking,
|
||||
this.speakingTip = '',
|
||||
required this.reading,
|
||||
required this.readingQuestion,
|
||||
required this.readingAnswer,
|
||||
this.readingOptions = const [],
|
||||
required this.writingPrompt,
|
||||
required this.writingExample,
|
||||
this.writingRequiredTerms = const [],
|
||||
this.writingHint = '',
|
||||
required this.independentPrompt,
|
||||
required this.independentHelp,
|
||||
});
|
||||
|
||||
|
||||
final String listening;
|
||||
final String listeningQuestion;
|
||||
final List<String> answers;
|
||||
final String speaking;
|
||||
|
||||
/// 这句跟读的发音提示;为空时不显示,避免给每一句套用同一条提示。
|
||||
final String speakingTip;
|
||||
final String reading;
|
||||
final String readingQuestion;
|
||||
final String readingAnswer;
|
||||
final List<String> readingOptions;
|
||||
final String writingPrompt;
|
||||
final String writingExample;
|
||||
|
||||
/// The rule a written answer has to satisfy (see `answer_rules.dart`);
|
||||
/// empty means any sentence of two or more words.
|
||||
final List<List<String>> writingRequiredTerms;
|
||||
|
||||
/// What to tell a learner whose written answer misses the rule.
|
||||
final String writingHint;
|
||||
final String independentPrompt;
|
||||
final String independentHelp;
|
||||
}
|
||||
|
||||
class LessonDialogue {
|
||||
const LessonDialogue({
|
||||
required this.goal,
|
||||
required this.prompts,
|
||||
required this.hints,
|
||||
this.translations = const [],
|
||||
this.requiredTerms = const [],
|
||||
this.taskLabels = const [],
|
||||
});
|
||||
|
||||
|
||||
final String goal;
|
||||
final List<String> prompts;
|
||||
final List<String> hints;
|
||||
final List<String> translations;
|
||||
|
||||
/// One accepted term group per learner turn. The learner only needs to hit
|
||||
/// one term in the current group, so natural wording still passes. A term
|
||||
/// may be a plain word or phrase, an `a + b` conjunction (both parts are
|
||||
/// required), or a structural token starting with `#`.
|
||||
final List<List<String>> requiredTerms;
|
||||
|
||||
/// Chinese label of what each learner turn has to do. It drives the
|
||||
/// "还没完成本轮任务" message and the summary of completed tasks, so it must
|
||||
/// describe the task without giving away the model answer.
|
||||
final List<String> taskLabels;
|
||||
}
|
||||
|
||||
/// A free practice scene. It opens once [unlockAfterLessonId] is complete
|
||||
/// (always open when null) and only uses language taught up to that lesson.
|
||||
class DialogueScene {
|
||||
const DialogueScene({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.summary,
|
||||
required this.script,
|
||||
required this.recapPrompt,
|
||||
this.unlockAfterLessonId,
|
||||
});
|
||||
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String summary;
|
||||
final LessonDialogue script;
|
||||
|
||||
/// The follow-up review prompt added after the scene is finished.
|
||||
final String recapPrompt;
|
||||
final String? unlockAfterLessonId;
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
/// Runtime models for the course content packs (`assets/courses/`), A0
|
||||
/// through B1: every level uses the same structure.
|
||||
///
|
||||
/// These mirror the JSON described in `Doc/COURSE-PACK-JSON.md`. They are pure
|
||||
/// data with tolerant `fromJson` factories: a missing or malformed field never
|
||||
/// throws, it degrades to an empty value so a single bad unit can be skipped
|
||||
/// without taking down the loader.
|
||||
///
|
||||
/// Answer rules (`accept`, `match`) use the language of `answer_rules.dart`.
|
||||
/// Where a pack gives no explicit rule, the loader derives one from the core
|
||||
/// items the part teaches (`itemIds`).
|
||||
library;
|
||||
|
||||
import '../models.dart';
|
||||
|
||||
/// One multiple-choice question on a listening/reading material or segment text.
|
||||
/// The first option is always the correct answer (the UI shuffles positions).
|
||||
class PackQuestion {
|
||||
const PackQuestion({
|
||||
required this.type,
|
||||
required this.question,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
/// `gist` / `detail` / `inference`.
|
||||
final String type;
|
||||
final String question;
|
||||
final List<String> options;
|
||||
|
||||
String get answer => options.isNotEmpty ? options.first : '';
|
||||
|
||||
factory PackQuestion.fromJson(Map<String, dynamic> json) => PackQuestion(
|
||||
type: json['type'] as String? ?? 'detail',
|
||||
question: json['question'] as String? ?? '',
|
||||
options: _stringList(json['options']),
|
||||
);
|
||||
}
|
||||
|
||||
/// A short listening or reading comprehension text with glosses and questions.
|
||||
class PackMaterial {
|
||||
const PackMaterial({
|
||||
required this.id,
|
||||
required this.mode,
|
||||
required this.title,
|
||||
required this.text,
|
||||
required this.glosses,
|
||||
required this.questions,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// `listening` or `reading`.
|
||||
final String mode;
|
||||
final String title;
|
||||
final String text;
|
||||
|
||||
/// Untaught word -> Chinese gloss shown alongside the text.
|
||||
final Map<String, String> glosses;
|
||||
final List<PackQuestion> questions;
|
||||
|
||||
factory PackMaterial.fromJson(Map<String, dynamic> json) => PackMaterial(
|
||||
id: json['id'] as String? ?? '',
|
||||
mode: json['mode'] as String? ?? 'reading',
|
||||
title: json['title'] as String? ?? '',
|
||||
text: json['text'] as String? ?? '',
|
||||
glosses: _stringMap(json['glosses']),
|
||||
questions: _objectList(
|
||||
json['questions'],
|
||||
).map(PackQuestion.fromJson).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// One turn of a controlled scene. `model` is the learner's demonstrated line.
|
||||
class SceneTurn {
|
||||
const SceneTurn({
|
||||
required this.ai,
|
||||
required this.goal,
|
||||
required this.model,
|
||||
required this.zh,
|
||||
required this.itemIds,
|
||||
this.aiZh = '',
|
||||
this.accept,
|
||||
});
|
||||
|
||||
final String ai;
|
||||
final String goal;
|
||||
final String model;
|
||||
|
||||
/// Chinese of [model].
|
||||
final String zh;
|
||||
final List<String> itemIds;
|
||||
|
||||
/// Chinese of [ai], offered as the translation of the partner's line.
|
||||
final String aiZh;
|
||||
|
||||
/// The terms that pass this turn (one term group); null derives them from
|
||||
/// [itemIds].
|
||||
final List<String>? accept;
|
||||
|
||||
factory SceneTurn.fromJson(Map<String, dynamic> json) => SceneTurn(
|
||||
ai: json['ai'] as String? ?? '',
|
||||
goal: json['goal'] as String? ?? '',
|
||||
model: json['model'] as String? ?? '',
|
||||
zh: json['zh'] as String? ?? '',
|
||||
itemIds: _stringList(json['itemIds']),
|
||||
aiZh: json['aiZh'] as String? ?? '',
|
||||
accept: json['accept'] is List ? _stringList(json['accept']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Marks a scene as a free-practice scene on the practice page. Empty fields
|
||||
/// fall back to values built from the scene and its unit.
|
||||
class ScenePractice {
|
||||
const ScenePractice({
|
||||
this.title = '',
|
||||
this.summary = '',
|
||||
this.recapPrompt = '',
|
||||
this.alwaysOpen = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String summary;
|
||||
|
||||
/// The review prompt added after the scene is finished.
|
||||
final String recapPrompt;
|
||||
|
||||
/// Open from the start instead of after the unit is finished.
|
||||
final bool alwaysOpen;
|
||||
|
||||
factory ScenePractice.fromJson(Map<String, dynamic> json) => ScenePractice(
|
||||
title: json['title'] as String? ?? '',
|
||||
summary: json['summary'] as String? ?? '',
|
||||
recapPrompt: json['recapPrompt'] as String? ?? '',
|
||||
alwaysOpen: json['alwaysOpen'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
/// A role-play scene (`kind` is `main` or `variant`).
|
||||
class PackScene {
|
||||
const PackScene({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.title,
|
||||
required this.learnerRole,
|
||||
required this.aiRole,
|
||||
required this.setting,
|
||||
required this.turns,
|
||||
this.goal = '',
|
||||
this.practice,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String kind;
|
||||
final String title;
|
||||
final String learnerRole;
|
||||
final String aiRole;
|
||||
final String setting;
|
||||
final List<SceneTurn> turns;
|
||||
|
||||
/// What the whole dialogue practises; falls back to [setting] or [title].
|
||||
final String goal;
|
||||
|
||||
/// Set when the scene is also offered as free practice.
|
||||
final ScenePractice? practice;
|
||||
|
||||
factory PackScene.fromJson(Map<String, dynamic> json) => PackScene(
|
||||
id: json['id'] as String? ?? '',
|
||||
kind: json['kind'] as String? ?? 'variant',
|
||||
title: json['title'] as String? ?? '',
|
||||
learnerRole: json['learnerRole'] as String? ?? '',
|
||||
aiRole: json['aiRole'] as String? ?? '',
|
||||
setting: json['setting'] as String? ?? '',
|
||||
turns: _objectList(json['turns']).map(SceneTurn.fromJson).toList(),
|
||||
goal: json['goal'] as String? ?? '',
|
||||
practice: json['practice'] is Map<String, dynamic>
|
||||
? ScenePractice.fromJson(json['practice'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// An open production task attached to a scene.
|
||||
class PackTask {
|
||||
const PackTask({
|
||||
required this.id,
|
||||
required this.sceneId,
|
||||
required this.goal,
|
||||
required this.itemIds,
|
||||
required this.slots,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String sceneId;
|
||||
final String goal;
|
||||
final List<String> itemIds;
|
||||
final List<String> slots;
|
||||
|
||||
factory PackTask.fromJson(Map<String, dynamic> json) => PackTask(
|
||||
id: json['id'] as String? ?? '',
|
||||
sceneId: json['sceneId'] as String? ?? '',
|
||||
goal: json['goal'] as String? ?? '',
|
||||
itemIds: _stringList(json['itemIds']),
|
||||
slots: _stringList(json['slots']),
|
||||
);
|
||||
}
|
||||
|
||||
/// A resumable teaching segment with its listening/speaking/reading/writing/
|
||||
/// independent activities.
|
||||
class PackSegment {
|
||||
const PackSegment({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.minutes,
|
||||
required this.itemIds,
|
||||
required this.listeningText,
|
||||
required this.listeningQuestion,
|
||||
required this.listeningOptions,
|
||||
required this.speakingText,
|
||||
required this.speakingTip,
|
||||
required this.readingText,
|
||||
required this.readingQuestion,
|
||||
required this.readingOptions,
|
||||
required this.writingPrompt,
|
||||
required this.writingExample,
|
||||
required this.writingItemIds,
|
||||
required this.independentPrompt,
|
||||
required this.independentItemIds,
|
||||
this.grammarNote = '',
|
||||
this.vocabulary,
|
||||
this.sceneId = '',
|
||||
this.writingAccept,
|
||||
this.writingHint = '',
|
||||
this.independentAccept,
|
||||
this.independentHelp = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final int minutes;
|
||||
final List<String> itemIds;
|
||||
|
||||
final String listeningText;
|
||||
final String listeningQuestion;
|
||||
final List<String> listeningOptions;
|
||||
|
||||
final String speakingText;
|
||||
final String speakingTip;
|
||||
|
||||
final String readingText;
|
||||
final String readingQuestion;
|
||||
final List<String> readingOptions;
|
||||
|
||||
final String writingPrompt;
|
||||
final String writingExample;
|
||||
final List<String> writingItemIds;
|
||||
|
||||
final String independentPrompt;
|
||||
final List<String> independentItemIds;
|
||||
|
||||
/// One practical usage reminder shown while writing.
|
||||
final String grammarNote;
|
||||
|
||||
/// The preview words; null derives them from the core items in [itemIds].
|
||||
final List<VocabularyItem>? vocabulary;
|
||||
|
||||
/// The scene practised in this segment; empty picks the scene at the same
|
||||
/// position (or the main scene).
|
||||
final String sceneId;
|
||||
|
||||
/// Explicit writing / independent rules and messages. Null or empty values
|
||||
/// are derived from [writingItemIds] / [independentItemIds].
|
||||
final List<List<String>>? writingAccept;
|
||||
final String writingHint;
|
||||
final List<List<String>>? independentAccept;
|
||||
final String independentHelp;
|
||||
|
||||
factory PackSegment.fromJson(Map<String, dynamic> json) {
|
||||
final listening = _objectOrEmpty(json['listening']);
|
||||
final reading = _objectOrEmpty(json['reading']);
|
||||
final writing = _objectOrEmpty(json['writing']);
|
||||
final independent = _objectOrEmpty(json['independent']);
|
||||
final speaking = _objectList(json['speaking']);
|
||||
final firstSpeaking = speaking.isNotEmpty ? speaking.first : const {};
|
||||
return PackSegment(
|
||||
id: json['id'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
minutes: (json['minutes'] as num?)?.toInt() ?? 12,
|
||||
itemIds: _stringList(json['itemIds']),
|
||||
listeningText: listening['text'] as String? ?? '',
|
||||
listeningQuestion: listening['question'] as String? ?? '',
|
||||
listeningOptions: _stringList(listening['options']),
|
||||
speakingText: firstSpeaking['text'] as String? ?? '',
|
||||
speakingTip: firstSpeaking['tip'] as String? ?? '',
|
||||
readingText: reading['text'] as String? ?? '',
|
||||
readingQuestion: reading['question'] as String? ?? '',
|
||||
readingOptions: _stringList(reading['options']),
|
||||
writingPrompt: writing['prompt'] as String? ?? '',
|
||||
writingExample: writing['example'] as String? ?? '',
|
||||
writingItemIds: _stringList(writing['itemIds']),
|
||||
independentPrompt: independent['prompt'] as String? ?? '',
|
||||
independentItemIds: _stringList(independent['itemIds']),
|
||||
grammarNote: json['grammarNote'] as String? ?? '',
|
||||
vocabulary: _vocabulary(json['vocabulary']),
|
||||
sceneId: json['sceneId'] as String? ?? '',
|
||||
writingAccept: writing['accept'] is List
|
||||
? _stringMatrix(writing['accept'])
|
||||
: null,
|
||||
writingHint: writing['hint'] as String? ?? '',
|
||||
independentAccept: independent['accept'] is List
|
||||
? _stringMatrix(independent['accept'])
|
||||
: null,
|
||||
independentHelp: independent['help'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A produced core item (word / phrase / pattern) with its acceptance rule.
|
||||
class CoreItem {
|
||||
const CoreItem({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.en,
|
||||
required this.zh,
|
||||
required this.match,
|
||||
required this.exampleEn,
|
||||
required this.exampleZh,
|
||||
required this.reviewPrompt,
|
||||
this.reviewHint = '',
|
||||
this.dictationSentence = '',
|
||||
this.dictationMeaning = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String type;
|
||||
final String en;
|
||||
final String zh;
|
||||
|
||||
/// Outer list = AND groups; inner list = OR alternatives.
|
||||
final List<List<String>> match;
|
||||
final String exampleEn;
|
||||
final String exampleZh;
|
||||
final String reviewPrompt;
|
||||
|
||||
/// What to tell a learner whose review answer missed the item.
|
||||
final String reviewHint;
|
||||
|
||||
/// A sentence built only from taught language, played in context and used
|
||||
/// as a dictation review. Empty when the item has none.
|
||||
final String dictationSentence;
|
||||
final String dictationMeaning;
|
||||
|
||||
factory CoreItem.fromJson(Map<String, dynamic> json) {
|
||||
final example = _objectOrEmpty(json['example']);
|
||||
final review = _objectOrEmpty(json['review']);
|
||||
final dictation = _objectOrEmpty(review['dictation']);
|
||||
return CoreItem(
|
||||
id: json['id'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'word',
|
||||
en: json['en'] as String? ?? '',
|
||||
zh: json['zh'] as String? ?? '',
|
||||
match: _stringMatrix(json['match']),
|
||||
exampleEn: example['en'] as String? ?? '',
|
||||
exampleZh: example['zh'] as String? ?? '',
|
||||
reviewPrompt: review['prompt'] as String? ?? '',
|
||||
reviewHint: review['hint'] as String? ?? '',
|
||||
dictationSentence: dictation['sentence'] as String? ?? '',
|
||||
dictationMeaning: dictation['meaning'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A recognition-only word.
|
||||
class ReceptiveWord {
|
||||
const ReceptiveWord({required this.id, required this.en, required this.zh});
|
||||
|
||||
final String id;
|
||||
final String en;
|
||||
final String zh;
|
||||
|
||||
factory ReceptiveWord.fromJson(Map<String, dynamic> json) => ReceptiveWord(
|
||||
id: json['id'] as String? ?? '',
|
||||
en: json['en'] as String? ?? '',
|
||||
zh: json['zh'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// One unit content pack (a0-01, A1-U01 …).
|
||||
class CoursePack {
|
||||
const CoursePack({
|
||||
required this.id,
|
||||
required this.level,
|
||||
this.status = 'draft',
|
||||
required this.title,
|
||||
required this.canDo,
|
||||
required this.categories,
|
||||
required this.coreItems,
|
||||
required this.receptiveWords,
|
||||
required this.segments,
|
||||
required this.scenes,
|
||||
required this.tasks,
|
||||
required this.materials,
|
||||
this.vocabulary,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String level;
|
||||
|
||||
/// `approved` for reviewed content, otherwise `draft`.
|
||||
final String status;
|
||||
final String title;
|
||||
final String canDo;
|
||||
final List<String> categories;
|
||||
final List<CoreItem> coreItems;
|
||||
final List<ReceptiveWord> receptiveWords;
|
||||
final List<PackSegment> segments;
|
||||
final List<PackScene> scenes;
|
||||
final List<PackTask> tasks;
|
||||
final List<PackMaterial> materials;
|
||||
|
||||
/// The unit's word list for lookup; null derives it from [coreItems] and
|
||||
/// [receptiveWords].
|
||||
final List<VocabularyItem>? vocabulary;
|
||||
|
||||
PackScene? get mainScene =>
|
||||
scenes.where((scene) => scene.kind == 'main').firstOrNull ??
|
||||
(scenes.isNotEmpty ? scenes.first : null);
|
||||
|
||||
factory CoursePack.fromJson(Map<String, dynamic> json) => CoursePack(
|
||||
id: json['id'] as String? ?? '',
|
||||
level: json['level'] as String? ?? '',
|
||||
status: json['status'] as String? ?? 'draft',
|
||||
title: json['title'] as String? ?? '',
|
||||
canDo: json['canDo'] as String? ?? '',
|
||||
categories: _stringList(json['categories']),
|
||||
coreItems: _objectList(json['coreItems']).map(CoreItem.fromJson).toList(),
|
||||
receptiveWords: _objectList(
|
||||
json['receptiveWords'],
|
||||
).map(ReceptiveWord.fromJson).toList(),
|
||||
segments: _objectList(json['segments']).map(PackSegment.fromJson).toList(),
|
||||
scenes: _objectList(json['scenes']).map(PackScene.fromJson).toList(),
|
||||
tasks: _objectList(json['tasks']).map(PackTask.fromJson).toList(),
|
||||
materials: _objectList(
|
||||
json['materials'],
|
||||
).map(PackMaterial.fromJson).toList(),
|
||||
vocabulary: _vocabulary(json['vocabulary']),
|
||||
);
|
||||
}
|
||||
|
||||
// --- tolerant parsing helpers -------------------------------------------------
|
||||
|
||||
List<String> _stringList(dynamic value) => value is List
|
||||
? [
|
||||
for (final item in value)
|
||||
if (item != null) item.toString(),
|
||||
]
|
||||
: const <String>[];
|
||||
|
||||
List<List<String>> _stringMatrix(dynamic value) => value is List
|
||||
? [for (final row in value) _stringList(row)]
|
||||
: const <List<String>>[];
|
||||
|
||||
Map<String, String> _stringMap(dynamic value) => value is Map
|
||||
? {
|
||||
for (final entry in value.entries)
|
||||
entry.key.toString(): entry.value?.toString() ?? '',
|
||||
}
|
||||
: const <String, String>{};
|
||||
|
||||
List<Map<String, dynamic>> _objectList(dynamic value) => value is List
|
||||
? [
|
||||
for (final item in value)
|
||||
if (item is Map<String, dynamic>) item,
|
||||
]
|
||||
: const <Map<String, dynamic>>[];
|
||||
|
||||
List<VocabularyItem>? _vocabulary(dynamic value) => value is List
|
||||
? _objectList(value).map(VocabularyItem.fromJson).toList()
|
||||
: null;
|
||||
|
||||
Map<String, dynamic> _objectOrEmpty(dynamic value) =>
|
||||
value is Map<String, dynamic> ? value : const <String, dynamic>{};
|
||||
@@ -0,0 +1,336 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle, AssetBundle;
|
||||
|
||||
import 'assessment.dart';
|
||||
import 'core_items.dart';
|
||||
import 'course_catalog.dart';
|
||||
import 'course_models.dart';
|
||||
import '../models.dart';
|
||||
import 'course_pack.dart';
|
||||
|
||||
/// Loads the JSON content packs of every level, A0 through B1, and adapts
|
||||
/// each unit into the runtime models the learning flow consumes
|
||||
/// (`SeedLesson`, `LessonActivity`, `LessonDialogue`, `DialogueScene`,
|
||||
/// `VocabularyItem`).
|
||||
///
|
||||
/// Every level has the same pack structure and goes through the same
|
||||
/// adapter, so learning and review data follow one set of rules. The adapted
|
||||
/// data is registered into the registries in `course_catalog.dart` /
|
||||
/// `core_items.dart`, so the rest of the app keeps calling synchronous
|
||||
/// accessors (`lessonById`, `activityBySegmentId`, …).
|
||||
///
|
||||
/// `main` loads A0 before `runApp`, so the offline baseline is always there;
|
||||
/// the later levels load with the app state. Loading is best-effort: a
|
||||
/// malformed unit is skipped.
|
||||
class CourseRepository {
|
||||
CourseRepository._();
|
||||
|
||||
static final CourseRepository instance = CourseRepository._();
|
||||
|
||||
static const _mapAsset = 'assets/courses/course-map.json';
|
||||
static const _assetRoot = 'assets/courses/';
|
||||
|
||||
final List<CoursePack> _units = [];
|
||||
final Map<String, CoursePack> _unitsById = {};
|
||||
final Set<String> _assessmentLevels = {};
|
||||
bool _loadedAll = false;
|
||||
|
||||
List<CoursePack> get units => List.unmodifiable(_units);
|
||||
CoursePack? unitById(String id) => _unitsById[id];
|
||||
|
||||
/// Parses the course map and the units it lists, then registers the adapted
|
||||
/// content. [levels] limits the load to those levels (`main` loads `A0`
|
||||
/// first). Units already loaded are skipped, so the call is safe to repeat.
|
||||
/// Units are read in parallel but registered in course order, because
|
||||
/// lessons are numbered in the order they are registered; levels therefore
|
||||
/// have to load in course order too.
|
||||
Future<void> load({Set<String>? levels, AssetBundle? bundle}) async {
|
||||
if (_loadedAll) return;
|
||||
final loader = bundle ?? rootBundle;
|
||||
try {
|
||||
final mapRaw = await loader.loadString(_mapAsset);
|
||||
final map = jsonDecode(mapRaw) as Map<String, dynamic>;
|
||||
await _loadAssessments(map, levels, loader);
|
||||
final paths = <String>[
|
||||
for (final level in (map['levels'] as List? ?? const []))
|
||||
if (level is Map && (levels == null || levels.contains(level['id'])))
|
||||
for (final unit in (level['units'] as List? ?? const []))
|
||||
if (unit is Map &&
|
||||
unit['file'] is String &&
|
||||
!_unitsById.containsKey(unit['id']))
|
||||
'$_assetRoot${unit['file']}',
|
||||
];
|
||||
final packs = await Future.wait(
|
||||
paths.map((path) => _readUnit(path, loader)),
|
||||
);
|
||||
for (final pack in packs) {
|
||||
if (pack == null || _unitsById.containsKey(pack.id)) continue;
|
||||
_add(pack);
|
||||
}
|
||||
if (levels == null) _loadedAll = true;
|
||||
} catch (error, stack) {
|
||||
// A missing or broken map leaves whatever units parsed before the
|
||||
// failure in place.
|
||||
debugPrint('CourseRepository: failed to load course map: $error\n$stack');
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the stage assessments of the levels being loaded, from each
|
||||
/// level's `assessment` file in the course map.
|
||||
Future<void> _loadAssessments(
|
||||
Map<String, dynamic> map,
|
||||
Set<String>? levels,
|
||||
AssetBundle loader,
|
||||
) async {
|
||||
for (final level in (map['levels'] as List? ?? const [])) {
|
||||
if (level is! Map || level['assessment'] is! String) continue;
|
||||
if (levels != null && !levels.contains(level['id'])) continue;
|
||||
if (!_assessmentLevels.add(level['id'] as String)) continue;
|
||||
final path = '$_assetRoot${level['assessment']}';
|
||||
try {
|
||||
final json = jsonDecode(await loader.loadString(path)) as Map;
|
||||
for (final pack in json['packs'] as List? ?? const []) {
|
||||
assessmentPacks.add(
|
||||
AssessmentPack.fromJson(pack as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('CourseRepository: skipped assessment $path: $error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one unit; null when it is missing or malformed.
|
||||
Future<CoursePack?> _readUnit(String assetPath, AssetBundle loader) async {
|
||||
try {
|
||||
final raw = await loader.loadString(assetPath);
|
||||
final pack = CoursePack.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
return pack.id.isEmpty ? null : pack;
|
||||
} catch (error) {
|
||||
debugPrint('CourseRepository: skipped unit $assetPath: $error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _add(CoursePack pack) {
|
||||
_units.add(pack);
|
||||
_unitsById[pack.id] = pack;
|
||||
_register(pack);
|
||||
}
|
||||
|
||||
/// Adapts one pack into the shared runtime registries. Explicit rules and
|
||||
/// texts in the pack win; anything left out is derived from the core items
|
||||
/// the part teaches.
|
||||
void _register(CoursePack pack) {
|
||||
final byId = {for (final item in pack.coreItems) item.id: item};
|
||||
final scenesById = {for (final scene in pack.scenes) scene.id: scene};
|
||||
|
||||
// Core items: labels, acceptance rules and offline review prompts.
|
||||
for (final item in pack.coreItems) {
|
||||
registerCoreItem(item, level: pack.level);
|
||||
}
|
||||
|
||||
final segments = <LessonSegment>[];
|
||||
for (var i = 0; i < pack.segments.length; i++) {
|
||||
final seg = pack.segments[i];
|
||||
final scene = seg.sceneId.isNotEmpty
|
||||
? scenesById[seg.sceneId]
|
||||
: i < pack.scenes.length
|
||||
? pack.scenes[i]
|
||||
: pack.mainScene;
|
||||
final dialogue = scene == null ? null : _dialogueFromScene(scene, byId);
|
||||
final independentTerms = _acceptedTerms(seg.independentItemIds, byId);
|
||||
final writingTerms = _acceptedTerms(seg.writingItemIds, byId);
|
||||
|
||||
final lessonSegment = LessonSegment(
|
||||
id: seg.id,
|
||||
targetItemIds: seg.itemIds,
|
||||
previewItemIds: seg.itemIds,
|
||||
dialogueRequiredTerms: dialogue?.requiredTerms ?? const [],
|
||||
independentRequiredTerms:
|
||||
seg.independentAccept ??
|
||||
[if (independentTerms.isNotEmpty) independentTerms],
|
||||
estimatedMinutes: seg.minutes,
|
||||
);
|
||||
segments.add(lessonSegment);
|
||||
courseSegments[seg.id] = lessonSegment;
|
||||
|
||||
// Preview vocabulary: the pack's own list, or the core items this
|
||||
// segment teaches.
|
||||
segmentVocabulary[seg.id] =
|
||||
seg.vocabulary ??
|
||||
[
|
||||
for (final id in seg.itemIds)
|
||||
if (byId[id] case final item?) _vocabularyOf(item),
|
||||
];
|
||||
if (seg.grammarNote.isNotEmpty) {
|
||||
segmentGrammarNotes[seg.id] = seg.grammarNote;
|
||||
}
|
||||
|
||||
segmentActivities[seg.id] = LessonActivity(
|
||||
listening: seg.listeningText,
|
||||
listeningQuestion: seg.listeningQuestion,
|
||||
answers: seg.listeningOptions.isNotEmpty
|
||||
? seg.listeningOptions
|
||||
: const [''],
|
||||
speaking: seg.speakingText,
|
||||
speakingTip: seg.speakingTip,
|
||||
reading: seg.readingText,
|
||||
readingQuestion: seg.readingQuestion,
|
||||
readingAnswer: seg.readingOptions.isNotEmpty
|
||||
? seg.readingOptions.first
|
||||
: '',
|
||||
readingOptions: seg.readingOptions,
|
||||
writingPrompt: seg.writingPrompt,
|
||||
writingExample: seg.writingExample,
|
||||
writingRequiredTerms:
|
||||
seg.writingAccept ?? [if (writingTerms.isNotEmpty) writingTerms],
|
||||
writingHint: seg.writingHint.isNotEmpty
|
||||
? seg.writingHint
|
||||
: writingTerms.isEmpty
|
||||
? ''
|
||||
: '用上本段的表达之一:${_joinModels(seg.writingItemIds, byId)}。',
|
||||
independentPrompt: seg.independentPrompt,
|
||||
independentHelp: seg.independentHelp.isNotEmpty
|
||||
? seg.independentHelp
|
||||
: _joinModels(seg.independentItemIds, byId),
|
||||
);
|
||||
|
||||
if (dialogue != null) segmentDialogues[seg.id] = dialogue;
|
||||
}
|
||||
|
||||
courseLessons.add(
|
||||
SeedLesson(
|
||||
id: pack.id,
|
||||
number: courseLessons.length + 1,
|
||||
title: pack.title,
|
||||
outcome: pack.canDo,
|
||||
// Lesson-level words feed the offline lexicon lookup.
|
||||
vocabulary:
|
||||
pack.vocabulary ??
|
||||
[
|
||||
for (final item in pack.coreItems) _vocabularyOf(item),
|
||||
for (final word in pack.receptiveWords)
|
||||
VocabularyItem(
|
||||
id: word.id,
|
||||
word: word.en,
|
||||
meaning: word.zh,
|
||||
example: '',
|
||||
exampleMeaning: '',
|
||||
),
|
||||
],
|
||||
level: pack.level,
|
||||
status: pack.status == 'approved'
|
||||
? ContentStatus.approved
|
||||
: ContentStatus.draft,
|
||||
estimatedMinutes: pack.segments.fold(
|
||||
0,
|
||||
(sum, seg) => sum + seg.minutes,
|
||||
),
|
||||
explicitSegments: segments,
|
||||
targetItemIds: [for (final seg in pack.segments) ...seg.itemIds],
|
||||
),
|
||||
);
|
||||
|
||||
// Scenes marked `practice` are also free-practice scenes, unlocked once
|
||||
// the unit is finished unless they are always open.
|
||||
for (final scene in pack.scenes) {
|
||||
final practice = scene.practice;
|
||||
if (practice == null) continue;
|
||||
practiceScenes.add(
|
||||
DialogueScene(
|
||||
id: scene.id,
|
||||
title: practice.title.isNotEmpty
|
||||
? practice.title
|
||||
: '${pack.title} · ${scene.title}',
|
||||
summary: practice.summary.isNotEmpty
|
||||
? practice.summary
|
||||
: scene.setting.isNotEmpty
|
||||
? scene.setting
|
||||
: scene.title,
|
||||
script: _dialogueFromScene(scene, byId),
|
||||
recapPrompt: practice.recapPrompt.isNotEmpty
|
||||
? practice.recapPrompt
|
||||
: '再用英语完成一次「${scene.title}」的对话。',
|
||||
unlockAfterLessonId: practice.alwaysOpen ? null : pack.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
VocabularyItem _vocabularyOf(CoreItem item) => VocabularyItem(
|
||||
id: item.id,
|
||||
word: item.en,
|
||||
meaning: item.zh,
|
||||
example: item.exampleEn,
|
||||
exampleMeaning: item.exampleZh,
|
||||
);
|
||||
|
||||
/// One accepted-term group per turn: the turn's own `accept`, or the terms
|
||||
/// of the core items the turn teaches.
|
||||
LessonDialogue _dialogueFromScene(
|
||||
PackScene scene,
|
||||
Map<String, CoreItem> byId,
|
||||
) => LessonDialogue(
|
||||
goal: scene.goal.isNotEmpty
|
||||
? scene.goal
|
||||
: scene.setting.isNotEmpty
|
||||
? scene.setting
|
||||
: scene.title,
|
||||
prompts: [for (final turn in scene.turns) turn.ai],
|
||||
hints: [for (final turn in scene.turns) turn.model],
|
||||
translations: scene.turns.every((turn) => turn.aiZh.isEmpty)
|
||||
? const []
|
||||
: [for (final turn in scene.turns) turn.aiZh],
|
||||
requiredTerms: [
|
||||
for (final turn in scene.turns)
|
||||
turn.accept ?? _acceptedTerms(turn.itemIds, byId),
|
||||
],
|
||||
taskLabels: [for (final turn in scene.turns) turn.goal],
|
||||
);
|
||||
|
||||
List<String> _acceptedTerms(
|
||||
Iterable<String> itemIds,
|
||||
Map<String, CoreItem> byId,
|
||||
) {
|
||||
final terms = <String>[];
|
||||
for (final id in itemIds) {
|
||||
final item = byId[id];
|
||||
if (item == null) continue;
|
||||
for (final group in item.match) {
|
||||
terms.addAll(group);
|
||||
}
|
||||
if (item.match.isEmpty && item.en.isNotEmpty) terms.add(item.en);
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
String _joinModels(Iterable<String> itemIds, Map<String, CoreItem> byId) {
|
||||
final phrases = [
|
||||
for (final id in itemIds)
|
||||
if (byId[id] != null && byId[id]!.en.isNotEmpty) byId[id]!.en,
|
||||
];
|
||||
return phrases.join(' / ');
|
||||
}
|
||||
|
||||
/// Test-only reset back to A0 alone, so a test can start without the later
|
||||
/// levels or load them again.
|
||||
@visibleForTesting
|
||||
void resetForTest() {
|
||||
final a0 = _units.where((pack) => pack.level == 'A0').toList();
|
||||
final a0Assessments = [
|
||||
for (final pack in assessmentPacks)
|
||||
if (pack.level == 'A0') pack,
|
||||
];
|
||||
_units.clear();
|
||||
_unitsById.clear();
|
||||
_loadedAll = false;
|
||||
clearCourseCatalog();
|
||||
clearCoreItems();
|
||||
a0.forEach(_add);
|
||||
assessmentPacks.addAll(a0Assessments);
|
||||
_assessmentLevels.removeWhere((level) => level != 'A0');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/// Course content and lookups, A0 through B1, from the JSON packs loaded by
|
||||
/// `CourseRepository`.
|
||||
library;
|
||||
|
||||
export 'answer_matching.dart';
|
||||
export 'answer_rules.dart';
|
||||
export 'assessment.dart';
|
||||
export 'core_items.dart';
|
||||
export 'course_catalog.dart';
|
||||
export 'course_models.dart';
|
||||
export 'option_order.dart';
|
||||
@@ -0,0 +1,25 @@
|
||||
int _seedHash(String seed) {
|
||||
var hash = 0x811c9dc5;
|
||||
for (final code in seed.codeUnits) {
|
||||
hash = ((hash ^ code) * 0x01000193) & 0x7fffffff;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
/// 选项顺序固定会让学习者养成“永远选第一个”的习惯。[options] 的第一项是标准
|
||||
/// 答案,这里按题目内容确定性地把它挪到某个位置,并打乱其余干扰项:同一道题
|
||||
/// 每次进入顺序都一样,但答案在三个位置上分布均匀。
|
||||
List<String> shuffledOptions(List<String> options, String seed) {
|
||||
if (options.length < 2) return options;
|
||||
final distractors = [...options.skip(1)];
|
||||
var hash = _seedHash(seed);
|
||||
for (var i = distractors.length - 1; i > 0; i--) {
|
||||
hash = (hash * 1103515245 + 12345) & 0x7fffffff;
|
||||
final j = hash % (i + 1);
|
||||
final swap = distractors[i];
|
||||
distractors[i] = distractors[j];
|
||||
distractors[j] = swap;
|
||||
}
|
||||
return distractors
|
||||
..insert(_seedHash('$seed#slot') % options.length, options.first);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'a0_core.dart';
|
||||
import 'courses/courses.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// A bounded AI-authored variation of an existing review item. It never
|
||||
@@ -120,6 +120,40 @@ bool matchesAdaptiveLessonAnswer(GeneratedLessonTask task, String answer) {
|
||||
return accepted || slots;
|
||||
}
|
||||
|
||||
/// Explains in Chinese why [matchesAdaptiveLessonAnswer] rejects [answer],
|
||||
/// or returns null when it accepts it. Listening answers only get a count so
|
||||
/// the explanation does not replace listening again.
|
||||
String? adaptiveAnswerShortfall(GeneratedLessonTask task, String answer) {
|
||||
if (matchesAdaptiveLessonAnswer(task, answer)) return null;
|
||||
final actual = _normalizeAnswer(answer);
|
||||
if (actual.isEmpty) return '请先输入或说出答案。';
|
||||
final spec = task.localAnswerSpec;
|
||||
final forbidden = spec.forbiddenPhrases
|
||||
.where((phrase) => _containsPhrase(actual, phrase))
|
||||
.toList();
|
||||
if (forbidden.isNotEmpty) {
|
||||
return '这里不能用:${forbidden.join('、')}。';
|
||||
}
|
||||
final missing = spec.requiredAnyPhrases
|
||||
.where(
|
||||
(alternatives) =>
|
||||
!alternatives.any((phrase) => _containsPhrase(actual, phrase)),
|
||||
)
|
||||
.toList();
|
||||
if (task.skill == 'listening') {
|
||||
return '还缺 ${missing.isEmpty ? 1 : missing.length} 处关键信息。再听一次,或慢放后补全。';
|
||||
}
|
||||
if (missing.isEmpty || answerSpecIsReferenceOnly(spec, task.answer)) {
|
||||
return '和参考表达还不一致,检查拼写和漏掉的词。';
|
||||
}
|
||||
return '还缺:${missing.map((group) => group.take(3).join(' / ')).join(';')}。';
|
||||
}
|
||||
|
||||
bool answerSpecIsReferenceOnly(GeneratedAnswerSpec spec, String answer) =>
|
||||
spec.requiredAnyPhrases.length == 1 &&
|
||||
spec.requiredAnyPhrases.single.length == 1 &&
|
||||
spec.requiredAnyPhrases.single.single == answer;
|
||||
|
||||
String _normalizeAnswer(String value) => value
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
@@ -157,7 +191,7 @@ GeneratedReviewVariant? decodeGeneratedReviewVariant(
|
||||
expectedAnswer.trim().isEmpty ||
|
||||
expectedAnswer.length > 120 ||
|
||||
targetItemId != expectedTargetItemId ||
|
||||
!a0CoreItems.containsKey(targetItemId)) {
|
||||
!isCoreItem(targetItemId)) {
|
||||
return null;
|
||||
}
|
||||
return GeneratedReviewVariant(
|
||||
@@ -231,7 +265,7 @@ GeneratedLesson? decodeGeneratedLesson(
|
||||
rawTasks is! List ||
|
||||
targets.length != 1 ||
|
||||
targets.single != expectedTargetItemId ||
|
||||
!a0CoreItems.containsKey(expectedTargetItemId) ||
|
||||
!isCoreItem(expectedTargetItemId) ||
|
||||
newItems.isNotEmpty ||
|
||||
preview.isNotEmpty ||
|
||||
chunks.isNotEmpty ||
|
||||
|
||||
@@ -319,6 +319,7 @@ class LocalSnapshotStore {
|
||||
'review_items',
|
||||
'study_sessions',
|
||||
'temporary_lexicon_entries',
|
||||
'sentence_analyses',
|
||||
'learner_profiles',
|
||||
'lesson_progress',
|
||||
'assessment_records',
|
||||
@@ -488,7 +489,7 @@ class LocalSnapshotStore {
|
||||
final definition = entry['definition'] as String? ?? '';
|
||||
if (key.isEmpty || definition.trim().isEmpty) continue;
|
||||
await executor.runCustom(
|
||||
'INSERT INTO temporary_lexicon_entries VALUES (?, ?, ?, ?, ?, ?)',
|
||||
'INSERT OR REPLACE INTO temporary_lexicon_entries VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
key,
|
||||
query,
|
||||
@@ -506,7 +507,7 @@ class LocalSnapshotStore {
|
||||
final payload = _encodeObject(entry['payload'] ?? {});
|
||||
if (key.isEmpty || payload == '{}') continue;
|
||||
await executor.runCustom(
|
||||
'INSERT INTO sentence_analyses VALUES (?, ?, ?, ?, ?, ?)',
|
||||
'INSERT OR REPLACE INTO sentence_analyses VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
key,
|
||||
query,
|
||||
|
||||
@@ -2,13 +2,14 @@ enum LearningGoal { dailyLife, travel, workStarter }
|
||||
|
||||
enum PlacementLevel { beginner, someBasics, simpleConversation }
|
||||
|
||||
enum AppTab { home, learn, dialogue, review, progress }
|
||||
enum AppTab { learn, dialogue, review, profile }
|
||||
|
||||
enum LessonStep {
|
||||
preview,
|
||||
listening,
|
||||
speaking,
|
||||
reading,
|
||||
material,
|
||||
writing,
|
||||
dialogue,
|
||||
independent,
|
||||
@@ -183,6 +184,15 @@ class VocabularyItem {
|
||||
this.ipa,
|
||||
});
|
||||
|
||||
factory VocabularyItem.fromJson(Map<String, dynamic> json) => VocabularyItem(
|
||||
id: json['id'] as String,
|
||||
word: json['word'] as String,
|
||||
meaning: json['meaning'] as String,
|
||||
example: json['example'] as String? ?? '',
|
||||
exampleMeaning: json['exampleMeaning'] as String? ?? '',
|
||||
ipa: json['ipa'] as String?,
|
||||
);
|
||||
|
||||
final String id;
|
||||
final String word;
|
||||
final String meaning;
|
||||
@@ -395,11 +405,7 @@ class DialogueTurn {
|
||||
final bool isLearner;
|
||||
final String? translation;
|
||||
|
||||
DialogueTurn copyWith({
|
||||
String? text,
|
||||
bool? isLearner,
|
||||
String? translation,
|
||||
}) {
|
||||
DialogueTurn copyWith({String? text, bool? isLearner, String? translation}) {
|
||||
return DialogueTurn(
|
||||
text: text ?? this.text,
|
||||
isLearner: isLearner ?? this.isLearner,
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import 'a0_core.dart';
|
||||
import 'courses/courses.dart';
|
||||
import 'models.dart';
|
||||
import 'speech_compare.dart';
|
||||
|
||||
class ReviewCheckResult {
|
||||
const ReviewCheckResult({required this.complete, required this.message});
|
||||
const ReviewCheckResult({
|
||||
required this.complete,
|
||||
required this.message,
|
||||
this.dictation,
|
||||
});
|
||||
|
||||
final bool complete;
|
||||
final String message;
|
||||
|
||||
/// Word-by-word comparison with the dictated sentence; null otherwise.
|
||||
final SpokenComparison? dictation;
|
||||
}
|
||||
|
||||
/// Checks only the minimum information a learner needs to produce in an A0
|
||||
/// review. It intentionally accepts common contractions and variable slot
|
||||
/// values instead of comparing an answer with one fixed sentence.
|
||||
/// Checks only the minimum information a learner needs to produce in a
|
||||
/// review. The rule and the hint come from the core item's course JSON, which
|
||||
/// accepts contractions and variable slot values instead of one fixed
|
||||
/// sentence.
|
||||
class ReviewFeedback {
|
||||
const ReviewFeedback._();
|
||||
|
||||
static String _normalize(String input) => input
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
static ReviewCheckResult check(ReviewItem item, String input) {
|
||||
final dictation = item.skill == dictationSkill
|
||||
? a0DictationSentences[item.id]
|
||||
? coreDictationSentences[item.id]
|
||||
: null;
|
||||
if (dictation != null) {
|
||||
final comparison = compareSpoken(dictation.sentence, input);
|
||||
@@ -35,94 +36,22 @@ class ReviewFeedback {
|
||||
message: comparison.matches
|
||||
? '听写正确。'
|
||||
: '有 $wrong 处和听到的句子不一致。再听一次,检查拼写和漏掉的词。',
|
||||
dictation: comparison,
|
||||
);
|
||||
}
|
||||
final complete = a0CoreItems.containsKey(item.id)
|
||||
// A word review checks the word it actually shows.
|
||||
? coreItemUsedIn(item.id, input, word: item.target)
|
||||
: _tokens(_normalize(input)).length >= 2;
|
||||
final complete = isCoreItem(item.id)
|
||||
? coreItemUsedIn(item.id, input)
|
||||
: RegExp(r"[a-z]+(?:'[a-z]+)?")
|
||||
.allMatches(normalizeAnswer(input))
|
||||
.map((match) => match.group(0))
|
||||
.toSet()
|
||||
.length >=
|
||||
2;
|
||||
return ReviewCheckResult(
|
||||
complete: complete,
|
||||
message: complete ? '表达已完成,可以进入下一次复习安排。' : _hint(item.id),
|
||||
message: complete
|
||||
? '表达已完成,可以进入下一次复习安排。'
|
||||
: coreReviewHints[item.id] ?? '再补充本次目标中的关键英文词或句型。',
|
||||
);
|
||||
}
|
||||
|
||||
static Set<String> _tokens(String text) => RegExp(
|
||||
r"[a-z]+(?:'[a-z]+)?",
|
||||
).allMatches(text).map((match) => match.group(0)!).toSet();
|
||||
|
||||
static String _hint(String id) => switch (id) {
|
||||
'A0-P01' => '用 I’m / I am 或 My name is 介绍一个名字。',
|
||||
'A0-P02' => '试着问:What’s your name?',
|
||||
'A0-P04' => '问对方怎样拼写:How do you spell …?',
|
||||
'A0-P08' => '说出至少三个英文数字,也可用 My number is … 开头。',
|
||||
'A0-P10' => '用 It’s / It is 加上一个物品。',
|
||||
'A0-P12' => '用 I’m from … 说出一个地点。',
|
||||
'A0-P17' => '用 It’s … o’clock 说整点时间。',
|
||||
'A0-P18' => '用 I like … 说一项喜好。',
|
||||
_ => '再补充本次目标中的关键英文词或句型。',
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether [input] actually contains A0 core item [id]. Lessons, reviews and
|
||||
/// dialogues use the same rule, so evidence only goes to the items a learner
|
||||
/// really produced, never to every target a task happens to be attached to.
|
||||
bool coreItemUsedIn(String id, String input, {String? word}) {
|
||||
final text = ReviewFeedback._normalize(input);
|
||||
final tokens = ReviewFeedback._tokens(text);
|
||||
bool has(String token) => tokens.contains(ReviewFeedback._normalize(token));
|
||||
bool phrase(String value) => text.contains(ReviewFeedback._normalize(value));
|
||||
bool hasAny(Iterable<String> values) => values.any(has);
|
||||
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
|
||||
final itIs = phrase("it's") || phrase('it is');
|
||||
final numberWords = RegExp(
|
||||
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
|
||||
).allMatches(text).length;
|
||||
const weekdays = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
];
|
||||
|
||||
return switch (id) {
|
||||
// "I'm from …" and "I'm good" are other items, not a name.
|
||||
'A0-P01' =>
|
||||
phrase('my name is') ||
|
||||
RegExp(
|
||||
r"\bi(?:'m| am) (?!from\b|good\b|okay\b|ok\b|fine\b|great\b|tired\b)[a-z]",
|
||||
).hasMatch(text),
|
||||
'A0-P02' => phrase("what's your name") || phrase('what is your name'),
|
||||
'A0-P03' => phrase('nice to meet you'),
|
||||
'A0-P04' => phrase('how do you spell'),
|
||||
'A0-P05' => phrase('how are you'),
|
||||
'A0-P06' => introduction && hasAny(['good', 'okay', 'tired']),
|
||||
'A0-P07' =>
|
||||
phrase("what's your phone number") || phrase('what is your phone number'),
|
||||
'A0-P08' => numberWords >= 3,
|
||||
'A0-P09' => phrase("what's this") || phrase('what is this'),
|
||||
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key', 'phone']),
|
||||
'A0-P11' => phrase('where are you from'),
|
||||
'A0-P12' => introduction && has('from') && tokens.length >= 3,
|
||||
'A0-P13' => phrase('this is my') && tokens.length >= 4,
|
||||
'A0-P14' => phrase('what day is it'),
|
||||
'A0-P15' => itIs && hasAny(weekdays),
|
||||
'A0-P16' => phrase('what time is it'),
|
||||
'A0-P17' =>
|
||||
itIs &&
|
||||
(has('clock') ||
|
||||
has("o'clock") ||
|
||||
has('oclock') ||
|
||||
phrase("o'clock")),
|
||||
'A0-P18' => (phrase('i like') || phrase('i love')) && tokens.length >= 3,
|
||||
'A0-P19' => phrase('do you like'),
|
||||
'A0-P20' =>
|
||||
phrase('please say that again') || phrase('please speak slowly'),
|
||||
_ when a0CoreItems.containsKey(id) && id.startsWith('A0-W') =>
|
||||
has(word ?? a0CoreItems[id]!) || phrase(word ?? a0CoreItems[id]!),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,20 +36,25 @@ class SherpaSttService {
|
||||
try {
|
||||
final execDir = File(Platform.resolvedExecutable).parent;
|
||||
final contentsDir = execDir.parent;
|
||||
candidateDirs.add('${contentsDir.path}/Frameworks/App.framework/Resources/flutter_assets/assets/models/sense_voice');
|
||||
candidateDirs.add('${contentsDir.path}/Frameworks/App.framework/Versions/A/Resources/flutter_assets/assets/models/sense_voice');
|
||||
candidateDirs.add(
|
||||
'${contentsDir.path}/Frameworks/App.framework/Resources/flutter_assets/assets/models/sense_voice',
|
||||
);
|
||||
candidateDirs.add(
|
||||
'${contentsDir.path}/Frameworks/App.framework/Versions/A/Resources/flutter_assets/assets/models/sense_voice',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
for (final dirPath in candidateDirs) {
|
||||
final mFile = File('$dirPath/$modelFilename');
|
||||
final tFile = File('$dirPath/$tokensFilename');
|
||||
if (mFile.existsSync() && tFile.existsSync() && mFile.lengthSync() > 100000000) {
|
||||
debugPrint('[SherpaSttService] Using bundled SenseVoice model directly at: $dirPath');
|
||||
return {
|
||||
'model': mFile.path,
|
||||
'tokens': tFile.path,
|
||||
};
|
||||
if (mFile.existsSync() &&
|
||||
tFile.existsSync() &&
|
||||
mFile.lengthSync() > 100000000) {
|
||||
debugPrint(
|
||||
'[SherpaSttService] Using bundled SenseVoice model directly at: $dirPath',
|
||||
);
|
||||
return {'model': mFile.path, 'tokens': tFile.path};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,14 +69,25 @@ class SherpaSttService {
|
||||
final targetModelFile = File('${modelDir.path}/$modelFilename');
|
||||
final targetTokensFile = File('${modelDir.path}/$tokensFilename');
|
||||
|
||||
final modelValid = targetModelFile.existsSync() && targetModelFile.lengthSync() > 200 * 1024 * 1024;
|
||||
final tokensValid = targetTokensFile.existsSync() && targetTokensFile.lengthSync() > 100 * 1024;
|
||||
final modelValid =
|
||||
targetModelFile.existsSync() &&
|
||||
targetModelFile.lengthSync() > 200 * 1024 * 1024;
|
||||
final tokensValid =
|
||||
targetTokensFile.existsSync() &&
|
||||
targetTokensFile.lengthSync() > 100 * 1024;
|
||||
|
||||
if (!modelValid) {
|
||||
debugPrint('[SherpaSttService] Unpacking bundled model to ${targetModelFile.path}...');
|
||||
debugPrint(
|
||||
'[SherpaSttService] Unpacking bundled model to ${targetModelFile.path}...',
|
||||
);
|
||||
final tmpFile = File('${targetModelFile.path}.tmp');
|
||||
final ByteData data = await rootBundle.load('assets/models/sense_voice/$modelFilename');
|
||||
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
final ByteData data = await rootBundle.load(
|
||||
'assets/models/sense_voice/$modelFilename',
|
||||
);
|
||||
final Uint8List bytes = data.buffer.asUint8List(
|
||||
data.offsetInBytes,
|
||||
data.lengthInBytes,
|
||||
);
|
||||
await tmpFile.writeAsBytes(bytes, flush: true);
|
||||
if (await tmpFile.length() > 200 * 1024 * 1024) {
|
||||
if (await targetModelFile.exists()) await targetModelFile.delete();
|
||||
@@ -83,19 +99,23 @@ class SherpaSttService {
|
||||
|
||||
if (!tokensValid) {
|
||||
final tmpTokens = File('${targetTokensFile.path}.tmp');
|
||||
final ByteData data = await rootBundle.load('assets/models/sense_voice/$tokensFilename');
|
||||
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
|
||||
final ByteData data = await rootBundle.load(
|
||||
'assets/models/sense_voice/$tokensFilename',
|
||||
);
|
||||
final Uint8List bytes = data.buffer.asUint8List(
|
||||
data.offsetInBytes,
|
||||
data.lengthInBytes,
|
||||
);
|
||||
await tmpTokens.writeAsBytes(bytes, flush: true);
|
||||
if (await targetTokensFile.exists()) await targetTokensFile.delete();
|
||||
await tmpTokens.rename(targetTokensFile.path);
|
||||
}
|
||||
|
||||
return {
|
||||
'model': targetModelFile.path,
|
||||
'tokens': targetTokensFile.path,
|
||||
};
|
||||
return {'model': targetModelFile.path, 'tokens': targetTokensFile.path};
|
||||
} catch (e, stack) {
|
||||
debugPrint('[SherpaSttService] Failed to unpack bundled model: $e\n$stack');
|
||||
debugPrint(
|
||||
'[SherpaSttService] Failed to unpack bundled model: $e\n$stack',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -117,7 +137,10 @@ class SherpaSttService {
|
||||
try {
|
||||
final execDir = File(Platform.resolvedExecutable).parent;
|
||||
final frameworksDir = Directory('${execDir.parent.path}/Frameworks');
|
||||
if (frameworksDir.existsSync() && File('${frameworksDir.path}/libsherpa-onnx-c-api.dylib').existsSync()) {
|
||||
if (frameworksDir.existsSync() &&
|
||||
File(
|
||||
'${frameworksDir.path}/libsherpa-onnx-c-api.dylib',
|
||||
).existsSync()) {
|
||||
resolvedLibDir = frameworksDir.path;
|
||||
}
|
||||
} catch (_) {}
|
||||
@@ -131,7 +154,9 @@ class SherpaSttService {
|
||||
|
||||
final resolved = await _resolveModelFiles();
|
||||
if (resolved == null) {
|
||||
debugPrint('[SherpaSttService] Model files could not be located or extracted.');
|
||||
debugPrint(
|
||||
'[SherpaSttService] Model files could not be located or extracted.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -148,15 +173,22 @@ class SherpaSttService {
|
||||
|
||||
final recognizerConfig = sherpa_onnx.OfflineRecognizerConfig(
|
||||
model: modelConfig,
|
||||
feat: const sherpa_onnx.FeatureConfig(sampleRate: 16000, featureDim: 80),
|
||||
feat: const sherpa_onnx.FeatureConfig(
|
||||
sampleRate: 16000,
|
||||
featureDim: 80,
|
||||
),
|
||||
);
|
||||
|
||||
_recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
|
||||
_isInitialized = true;
|
||||
debugPrint('[SherpaSttService] SenseVoice-Small ONNX ASR engine initialized successfully (language=en).');
|
||||
debugPrint(
|
||||
'[SherpaSttService] SenseVoice-Small ONNX ASR engine initialized successfully (language=en).',
|
||||
);
|
||||
return true;
|
||||
} catch (e, stack) {
|
||||
debugPrint('[SherpaSttService] Failed to initialize SenseVoice ASR engine: $e\n$stack');
|
||||
debugPrint(
|
||||
'[SherpaSttService] Failed to initialize SenseVoice ASR engine: $e\n$stack',
|
||||
);
|
||||
_isInitialized = false;
|
||||
return false;
|
||||
} finally {
|
||||
@@ -202,7 +234,10 @@ class SherpaSttService {
|
||||
|
||||
final stream = _recognizer!.createStream();
|
||||
try {
|
||||
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
|
||||
stream.acceptWaveform(
|
||||
samples: wave.samples,
|
||||
sampleRate: wave.sampleRate,
|
||||
);
|
||||
_recognizer!.decode(stream);
|
||||
final result = _recognizer!.getResult(stream);
|
||||
|
||||
@@ -223,7 +258,9 @@ class SherpaSttService {
|
||||
String _cleanText(String text) {
|
||||
if (text.isEmpty) return text;
|
||||
// Strip SenseVoice special tags like <|zh|>, <|en|>, <|NEUTRAL|>, <|HAPPY|>, <|Speech|>, <|withitn|>, <|woitn|>, etc.
|
||||
var cleaned = text.replaceAll(RegExp(r'<\|[a-zA-Z0-9_\-\s]+\|>'), '').trim();
|
||||
var cleaned = text
|
||||
.replaceAll(RegExp(r'<\|[a-zA-Z0-9_\-\s]+\|>'), '')
|
||||
.trim();
|
||||
if (cleaned.isEmpty) return cleaned;
|
||||
// Strip leading punctuation often inserted by Whisper/SenseVoice
|
||||
cleaned = cleaned.replaceFirst(RegExp(r'^[\s,.:;!?~]+'), '').trim();
|
||||
@@ -231,7 +268,8 @@ class SherpaSttService {
|
||||
// Normalize consecutive spaces
|
||||
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
// Capitalize first character if it's an English letter
|
||||
if (cleaned.isNotEmpty && cleaned[0].toLowerCase() != cleaned[0].toUpperCase()) {
|
||||
if (cleaned.isNotEmpty &&
|
||||
cleaned[0].toLowerCase() != cleaned[0].toUpperCase()) {
|
||||
cleaned = cleaned[0].toUpperCase() + cleaned.substring(1);
|
||||
}
|
||||
return cleaned;
|
||||
|
||||
@@ -20,7 +20,8 @@ class SyncCoordinator extends ChangeNotifier {
|
||||
static const _mergeVersionKey = 'mergeVersion';
|
||||
|
||||
static final SyncCoordinator instance = SyncCoordinator._();
|
||||
SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService();
|
||||
SyncCoordinator._({SyncService? service})
|
||||
: _service = service ?? SyncService();
|
||||
|
||||
@visibleForTesting
|
||||
factory SyncCoordinator.createForTesting({SyncService? service}) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../a0_core.dart';
|
||||
import '../courses/courses.dart';
|
||||
import '../models.dart';
|
||||
import '../app_state.dart';
|
||||
import 'sync_models.dart';
|
||||
@@ -6,7 +6,10 @@ import 'sync_models.dart';
|
||||
/// 负责本地 AppState 与云端 DTO 之间的序列化与智能合并
|
||||
class SyncMerger {
|
||||
/// 将本地 AppState 打包为增量推送请求
|
||||
static SyncPushRequest buildPushRequest(AppState state, {String? deviceName}) {
|
||||
static SyncPushRequest buildPushRequest(
|
||||
AppState state, {
|
||||
String? deviceName,
|
||||
}) {
|
||||
final nowIso = DateTime.now().toUtc().toIso8601String();
|
||||
|
||||
final progressPayload = SyncProgressPayload(
|
||||
@@ -21,7 +24,8 @@ class SyncMerger {
|
||||
final masteryUpdates = state.mastery.values.map((m) {
|
||||
// 查找对应复习到期时间
|
||||
final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull;
|
||||
final dueAt = (review?.dueAt ??
|
||||
final dueAt =
|
||||
(review?.dueAt ??
|
||||
m.firstTaughtAt?.add(_firstReviewDelay) ??
|
||||
DateTime.now())
|
||||
.toUtc()
|
||||
@@ -56,9 +60,7 @@ class SyncMerger {
|
||||
aiEndpoint: state.aiEndpoint,
|
||||
aiModel: state.aiModel,
|
||||
aiProvider: state.aiProvider.name,
|
||||
settingsPayload: {
|
||||
'keepRecordings': state.keepRecordings,
|
||||
},
|
||||
settingsPayload: {'keepRecordings': state.keepRecordings},
|
||||
updatedAt: nowIso,
|
||||
);
|
||||
|
||||
@@ -174,7 +176,7 @@ class SyncMerger {
|
||||
state.reviewQueue.add(
|
||||
ReviewItem(
|
||||
id: m.itemId,
|
||||
target: a0CoreItems[m.itemId] ?? m.itemId,
|
||||
target: coreItemLabel(m.itemId),
|
||||
prompt: template.prompt,
|
||||
hint: template.hint,
|
||||
dueAt: dueAt.toLocal(),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
|
||||
/// 同步状态枚举
|
||||
enum SyncState {
|
||||
idle,
|
||||
syncing,
|
||||
success,
|
||||
error,
|
||||
}
|
||||
enum SyncState { idle, syncing, success, error }
|
||||
|
||||
/// 客户端同步配置与认证状态
|
||||
class SyncConfig {
|
||||
@@ -53,7 +47,8 @@ class SyncConfig {
|
||||
};
|
||||
|
||||
factory SyncConfig.fromJson(Map<String, dynamic> json) => SyncConfig(
|
||||
serverUrl: json['serverUrl'] as String? ?? 'https://syncenglish.slcydia.fun',
|
||||
serverUrl:
|
||||
json['serverUrl'] as String? ?? 'https://syncenglish.slcydia.fun',
|
||||
token: json['token'] as String?,
|
||||
username: json['username'] as String?,
|
||||
userId: json['userId'] as String?,
|
||||
@@ -78,7 +73,8 @@ class SyncAuthResponse {
|
||||
required this.expiresIn,
|
||||
});
|
||||
|
||||
factory SyncAuthResponse.fromJson(Map<String, dynamic> json) => SyncAuthResponse(
|
||||
factory SyncAuthResponse.fromJson(Map<String, dynamic> json) =>
|
||||
SyncAuthResponse(
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
token: json['token'] as String? ?? '',
|
||||
@@ -113,7 +109,8 @@ class SyncProgressPayload {
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory SyncProgressPayload.fromJson(Map<String, dynamic> json) => SyncProgressPayload(
|
||||
factory SyncProgressPayload.fromJson(Map<String, dynamic> json) =>
|
||||
SyncProgressPayload(
|
||||
activeLessonId: json['active_lesson_id'] as String? ?? 'a0-01',
|
||||
completedLessonIds: (json['completed_lesson_ids'] as List? ?? [])
|
||||
.map((e) => e.toString())
|
||||
@@ -123,7 +120,9 @@ class SyncProgressPayload {
|
||||
.toList(),
|
||||
activeStep: json['active_step'] as String? ?? 'preview',
|
||||
streakDays: json['streak_days'] as int? ?? 0,
|
||||
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
updatedAt:
|
||||
json['updated_at'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,17 +159,22 @@ class SyncMasteryItemPayload {
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory SyncMasteryItemPayload.fromJson(Map<String, dynamic> json) => SyncMasteryItemPayload(
|
||||
factory SyncMasteryItemPayload.fromJson(Map<String, dynamic> json) =>
|
||||
SyncMasteryItemPayload(
|
||||
itemId: json['item_id'] as String? ?? '',
|
||||
checkpoint: json['checkpoint'] as int? ?? 0,
|
||||
status: json['status'] as String? ?? 'learning',
|
||||
dueAt: json['due_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
dueAt:
|
||||
json['due_at'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
successfulReviews: json['successful_reviews'] as int? ?? 0,
|
||||
attempts: json['attempts'] as int? ?? 0,
|
||||
payload: json['payload'] is Map<String, dynamic>
|
||||
? json['payload'] as Map<String, dynamic>
|
||||
: {},
|
||||
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
updatedAt:
|
||||
json['updated_at'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,7 +217,8 @@ class SyncProfilePayload {
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory SyncProfilePayload.fromJson(Map<String, dynamic> json) => SyncProfilePayload(
|
||||
factory SyncProfilePayload.fromJson(Map<String, dynamic> json) =>
|
||||
SyncProfilePayload(
|
||||
onboardingComplete: json['onboarding_complete'] as bool? ?? true,
|
||||
goal: json['goal'] as String? ?? 'travel',
|
||||
placement: json['placement'] as String? ?? 'A0',
|
||||
@@ -225,7 +230,9 @@ class SyncProfilePayload {
|
||||
settingsPayload: json['settings_payload'] is Map<String, dynamic>
|
||||
? json['settings_payload'] as Map<String, dynamic>
|
||||
: {},
|
||||
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
updatedAt:
|
||||
json['updated_at'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -273,9 +280,13 @@ class SyncPullResponse {
|
||||
? json['data'] as Map<String, dynamic>
|
||||
: json;
|
||||
return SyncPullResponse(
|
||||
serverTime: data['server_time'] as String? ?? DateTime.now().toUtc().toIso8601String(),
|
||||
serverTime:
|
||||
data['server_time'] as String? ??
|
||||
DateTime.now().toUtc().toIso8601String(),
|
||||
progress: data['progress'] != null
|
||||
? SyncProgressPayload.fromJson(data['progress'] as Map<String, dynamic>)
|
||||
? SyncProgressPayload.fromJson(
|
||||
data['progress'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
masteryUpdates: (data['mastery_updates'] as List? ?? [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
|
||||
@@ -42,7 +42,8 @@ class SyncService {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/auth/register');
|
||||
|
||||
final resp = await _client.post(
|
||||
final resp = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
@@ -50,13 +51,16 @@ class SyncService {
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
return SyncAuthResponse.fromJson(data['data'] as Map<String, dynamic>);
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '注册失败: HTTP ${resp.statusCode}');
|
||||
throw HttpException(
|
||||
data['detail'] ?? data['message'] ?? '注册失败: HTTP ${resp.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +74,8 @@ class SyncService {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/auth/login');
|
||||
|
||||
final resp = await _client.post(
|
||||
final resp = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
@@ -78,13 +83,16 @@ class SyncService {
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
return SyncAuthResponse.fromJson(data['data'] as Map<String, dynamic>);
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '登录失败: HTTP ${resp.statusCode}');
|
||||
throw HttpException(
|
||||
data['detail'] ?? data['message'] ?? '登录失败: HTTP ${resp.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,23 +105,28 @@ class SyncService {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
var urlStr = '$base/api/v1/sync/pull';
|
||||
if (since != null) {
|
||||
urlStr += '?since=${Uri.encodeQueryComponent(since.toUtc().toIso8601String())}';
|
||||
urlStr +=
|
||||
'?since=${Uri.encodeQueryComponent(since.toUtc().toIso8601String())}';
|
||||
}
|
||||
final uri = Uri.parse(urlStr);
|
||||
|
||||
final resp = await _client.get(
|
||||
final resp = await _client
|
||||
.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
).timeout(const Duration(seconds: 15));
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
return SyncPullResponse.fromJson(data);
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '拉取进度失败: HTTP ${resp.statusCode}');
|
||||
throw HttpException(
|
||||
data['detail'] ?? data['message'] ?? '拉取进度失败: HTTP ${resp.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,21 +139,25 @@ class SyncService {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/sync/push');
|
||||
|
||||
final resp = await _client.post(
|
||||
final resp = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(request.toJson()),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
|
||||
final data = jsonDecode(utf8.decode(resp.bodyBytes));
|
||||
if (resp.statusCode == 200 && data['code'] == 0) {
|
||||
final sTime = data['data']?['server_time'] as String?;
|
||||
return sTime ?? DateTime.now().toUtc().toIso8601String();
|
||||
} else {
|
||||
throw HttpException(data['detail'] ?? data['message'] ?? '推送进度失败: HTTP ${resp.statusCode}');
|
||||
throw HttpException(
|
||||
data['detail'] ?? data['message'] ?? '推送进度失败: HTTP ${resp.statusCode}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,14 +29,11 @@ class VoiceService {
|
||||
if (_ttsInitialized) return;
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
await _tts.setIosAudioCategory(
|
||||
IosTextToSpeechAudioCategory.playback,
|
||||
[
|
||||
await _tts.setIosAudioCategory(IosTextToSpeechAudioCategory.playback, [
|
||||
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
|
||||
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
|
||||
IosTextToSpeechAudioCategoryOptions.mixWithOthers,
|
||||
],
|
||||
);
|
||||
]);
|
||||
}
|
||||
await _tts.setVolume(1.0);
|
||||
await _tts.setPitch(1.0);
|
||||
@@ -162,7 +159,11 @@ class VoiceService {
|
||||
if (!await recordings.exists()) return const [];
|
||||
final files = await recordings
|
||||
.list()
|
||||
.where((item) => item is File && (item.path.endsWith('.wav') || item.path.endsWith('.m4a')))
|
||||
.where(
|
||||
(item) =>
|
||||
item is File &&
|
||||
(item.path.endsWith('.wav') || item.path.endsWith('.m4a')),
|
||||
)
|
||||
.cast<File>()
|
||||
.toList();
|
||||
files.sort((left, right) => right.path.compareTo(left.path));
|
||||
@@ -206,7 +207,10 @@ class VoiceService {
|
||||
_errorListener = onError;
|
||||
try {
|
||||
if (!_speechReady || !_stt.isAvailable) {
|
||||
final ready = await initializeSpeech(onStatus: onStatus, onError: onError);
|
||||
final ready = await initializeSpeech(
|
||||
onStatus: onStatus,
|
||||
onError: onError,
|
||||
);
|
||||
if (!ready) return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'courses/courses.dart';
|
||||
|
||||
class WritingCheckResult {
|
||||
const WritingCheckResult({required this.complete, required this.message});
|
||||
|
||||
@@ -5,84 +7,24 @@ class WritingCheckResult {
|
||||
final String message;
|
||||
}
|
||||
|
||||
/// A deliberately small offline checker for teaching tasks. It checks only
|
||||
/// the essential A0 information, accepts variable names/places, and never
|
||||
/// claims to grade pronunciation or nuanced grammar.
|
||||
/// A deliberately small offline checker for teaching tasks. The rule and the
|
||||
/// hint come from the segment's writing task in the course JSON; it
|
||||
/// checks only the essential information and never claims to grade
|
||||
/// pronunciation or nuanced grammar.
|
||||
class WritingFeedback {
|
||||
const WritingFeedback._();
|
||||
|
||||
static String _normalize(String input) => input
|
||||
.toLowerCase()
|
||||
.replaceAll('’', "'")
|
||||
.replaceAll('‘', "'")
|
||||
.replaceAll(RegExp(r'\s+'), ' ')
|
||||
.trim();
|
||||
|
||||
static WritingCheckResult check(
|
||||
String lessonId,
|
||||
String input, {
|
||||
String? segmentId,
|
||||
}) {
|
||||
final text = _normalize(input);
|
||||
final words = RegExp(
|
||||
r"[a-z]+(?:'[a-z]+)?",
|
||||
).allMatches(text).map((match) => match.group(0)!).toSet();
|
||||
bool has(String word) => words.contains(_normalize(word));
|
||||
|
||||
bool hasPhrase(String phrase) => text.contains(_normalize(phrase));
|
||||
|
||||
bool hasAny(Iterable<String> choices) => choices.any(has);
|
||||
final hasIntroduction =
|
||||
hasPhrase("i'm") || hasPhrase('i am') || hasPhrase('my name is');
|
||||
final hasItIs = hasPhrase("it's") || hasPhrase('it is');
|
||||
|
||||
final complete = switch (segmentId) {
|
||||
'a0-04-a' => hasAll(words, ['zero', 'one', 'two', 'three']),
|
||||
'a0-04-b' => hasAll(words, ['six', 'seven', 'eight']),
|
||||
'a0-04-c' =>
|
||||
RegExp(
|
||||
r'\b(one|two|three|four|five|six|seven|eight|nine|zero)\b',
|
||||
).allMatches(text).length >=
|
||||
3,
|
||||
'a0-08-a' => hasItIs && hasAny(['monday', 'tuesday', 'wednesday']),
|
||||
'a0-08-b' =>
|
||||
hasItIs && hasAny(['thursday', 'friday', 'saturday', 'sunday']),
|
||||
'a0-08-c' => hasItIs && (has('oclock') || hasPhrase("o'clock")),
|
||||
_ => switch (lessonId) {
|
||||
'a0-01' => hasAny(['hello', 'hi']) && hasIntroduction,
|
||||
'a0-02' =>
|
||||
(hasPhrase('my name') || hasIntroduction) &&
|
||||
RegExp(r'[a-z](?:[ -]?[a-z]){2,}').hasMatch(text),
|
||||
'a0-03' => hasIntroduction && hasAny(['good', 'okay', 'tired']),
|
||||
'a0-04' =>
|
||||
RegExp(
|
||||
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
|
||||
).allMatches(text).length >=
|
||||
3,
|
||||
'a0-05' => hasItIs && hasAny(['book', 'pen', 'bag', 'key']),
|
||||
'a0-06' => hasIntroduction && has('from') && words.length >= 3,
|
||||
'a0-07' =>
|
||||
hasPhrase('this is my') &&
|
||||
hasAny(['mother', 'father', 'sister', 'brother', 'friend']),
|
||||
'a0-08' =>
|
||||
hasItIs &&
|
||||
(hasAny([
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]) ||
|
||||
has('clock')),
|
||||
'a0-09' => hasIntroduction && has('like') && words.length >= 3,
|
||||
'a0-10' =>
|
||||
hasIntroduction && has('from') && has('like') && has('water'),
|
||||
_ => words.length >= 2,
|
||||
},
|
||||
};
|
||||
|
||||
static WritingCheckResult check(String segmentId, String input) {
|
||||
final activity = activityBySegmentId(segmentId);
|
||||
final rule = activity.writingRequiredTerms;
|
||||
final complete = rule.isEmpty
|
||||
? RegExp(r"[a-z]+(?:'[a-z]+)?")
|
||||
.allMatches(normalizeAnswer(input))
|
||||
.map((match) => match.group(0))
|
||||
.toSet()
|
||||
.length >=
|
||||
2
|
||||
: matchesRule(input, rule);
|
||||
if (complete) {
|
||||
return const WritingCheckResult(
|
||||
complete: true,
|
||||
@@ -91,30 +33,9 @@ class WritingFeedback {
|
||||
}
|
||||
return WritingCheckResult(
|
||||
complete: false,
|
||||
message: _hintFor(segmentId ?? lessonId),
|
||||
message: activity.writingHint.isNotEmpty
|
||||
? activity.writingHint
|
||||
: '再补充一个完整英文句子。',
|
||||
);
|
||||
}
|
||||
|
||||
static bool hasAll(Set<String> words, Iterable<String> required) =>
|
||||
required.every(words.contains);
|
||||
|
||||
static String _hintFor(String lessonId) => switch (lessonId) {
|
||||
'a0-04-a' => '请写出 zero、one、two、three 四个数字。',
|
||||
'a0-04-b' => '请写出 six、seven、eight 三个数字。',
|
||||
'a0-04-c' => '用英文一个一个写出三个数字,例如 one-three-nine。',
|
||||
'a0-08-a' => '用 It is / It’s 加 Monday、Tuesday 或 Wednesday。',
|
||||
'a0-08-b' => '用 It is / It’s 加 Thursday、Friday、Saturday 或 Sunday。',
|
||||
'a0-08-c' => '用 It is / It’s 加数字和 o’clock,例如 It’s three o’clock。',
|
||||
'a0-01' => '试着同时写一句问候和姓名,例如:Hello. I’m …',
|
||||
'a0-02' => '写出姓名并拼写一遍,例如 My name is Shen. S-H-E-N.',
|
||||
'a0-03' => '用 I’m / I am 加上你的状态,例如 good 或 tired。',
|
||||
'a0-04' => '用英文写出三个数字,例如 one-three-nine。',
|
||||
'a0-05' => '用 It’s / It is 加一个物品,例如 a pen。',
|
||||
'a0-06' => '用 I’m from … 写成一个完整句子。',
|
||||
'a0-07' => '用 This is my … 介绍一位家人或朋友。',
|
||||
'a0-08' => '用 It’s … 写一个星期或整点时间。',
|
||||
'a0-09' => '用 I like … 写出一种喜好。',
|
||||
'a0-10' => '分别写姓名、来自哪里和喜好三部分,喜好用 water:I like water.',
|
||||
_ => '再补充一个完整英文句子。',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/assessment_bank.dart';
|
||||
import '../../core/courses/courses.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
@@ -221,7 +221,7 @@ class _AssessmentPageState extends State<AssessmentPage>
|
||||
results[task.id] = correct;
|
||||
widget.state.recordAssessmentAttempt(
|
||||
taskId: task.id,
|
||||
skill: _skillLabel(task.skill),
|
||||
skill: task.skill.label,
|
||||
correct: correct,
|
||||
rawAnswer: answer == null ? controller.text.trim() : task.choices[answer],
|
||||
spoken: task.skill == AssessmentSkill.speaking,
|
||||
@@ -254,7 +254,7 @@ class _AssessmentPageState extends State<AssessmentPage>
|
||||
results[speaking.id] = false;
|
||||
widget.state.recordAssessmentPending(
|
||||
taskId: speaking.id,
|
||||
skill: _skillLabel(AssessmentSkill.speaking),
|
||||
skill: AssessmentSkill.speaking.label,
|
||||
reason: '设备麦克风或语音识别不可用,等待补测。',
|
||||
);
|
||||
}
|
||||
@@ -327,13 +327,20 @@ class _AssessmentPageState extends State<AssessmentPage>
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'待评估:${record.pendingSkills.map(_skillLabel).join('、')}。技术问题不会计为语言错误。',
|
||||
'待评估:${record.pendingSkills.map((skill) => skill.label).join('、')}。技术问题不会计为语言错误。',
|
||||
),
|
||||
),
|
||||
if (record.failedSkills.isNotEmpty) const Text('建议补练:'),
|
||||
for (final skill in record.failedSkills)
|
||||
SectionCard(child: Text(_remediation(skill))),
|
||||
],
|
||||
if (_wrongTasks.isNotEmpty) ...[
|
||||
const Text(
|
||||
'错题回顾',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
for (final wrong in _wrongTasks) _WrongAnswerCard(wrong: wrong),
|
||||
],
|
||||
if (!record.passed && replacementFor(widget.pack.id) != null)
|
||||
SecondaryButton(
|
||||
label: '开始替换题补测',
|
||||
@@ -359,7 +366,7 @@ class _AssessmentPageState extends State<AssessmentPage>
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(
|
||||
_skillLabel(task.skill),
|
||||
task.skill.label,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('评估中不提供翻译、句框或答案。需要帮助请退出后先做补练。'),
|
||||
@@ -435,16 +442,50 @@ class _AssessmentPageState extends State<AssessmentPage>
|
||||
);
|
||||
}
|
||||
|
||||
/// Tasks answered wrong in this attempt, with the learner's latest answer.
|
||||
List<({AssessmentTask task, String answer})> get _wrongTasks => [
|
||||
for (final task in widget.pack.tasks)
|
||||
if (results[task.id] == false)
|
||||
if (widget.state.attemptEvidence.reversed
|
||||
.where((item) => item.taskId == task.id)
|
||||
.firstOrNull
|
||||
case final evidence? when evidence.outcome != EvidenceKind.pending)
|
||||
(task: task, answer: evidence.rawAnswer ?? ''),
|
||||
];
|
||||
|
||||
String _remediation(AssessmentSkill skill) => switch (skill) {
|
||||
AssessmentSkill.listening => '听力:回到数字、星期/时间和场景听辨复习;下一次会使用不同音频。',
|
||||
AssessmentSkill.speaking => '口语:练姓名、号码、物品、家人、星期、整点和请求重复;确认未编辑转写后再补测。',
|
||||
AssessmentSkill.reading => '阅读:复习人物、地点、数字和时间信息定位,再阅读不同短对话。',
|
||||
AssessmentSkill.writing => '写作:分别练完整的姓名、地点、喜好、物品、星期/时间句,不使用句框。',
|
||||
};
|
||||
String _skillLabel(AssessmentSkill skill) => switch (skill) {
|
||||
AssessmentSkill.listening => '听力',
|
||||
AssessmentSkill.speaking => '口语',
|
||||
AssessmentSkill.reading => '阅读',
|
||||
AssessmentSkill.writing => '写作',
|
||||
};
|
||||
}
|
||||
|
||||
class _WrongAnswerCard extends StatelessWidget {
|
||||
const _WrongAnswerCard({required this.wrong});
|
||||
final ({AssessmentTask task, String answer}) wrong;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final task = wrong.task;
|
||||
return SectionCard(
|
||||
child: SpacedColumn(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text(
|
||||
'${task.skill.label} · ${task.audio ?? task.prompt}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'你的答案:${wrong.answer.isEmpty ? '(未作答)' : wrong.answer}',
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
Text(
|
||||
assessmentAnswerGuide(task, wrong.answer),
|
||||
style: TextStyle(color: AppColors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import '../../core/app_state.dart';
|
||||
import '../../core/ai_service.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/seed_courses.dart';
|
||||
import '../../core/courses/courses.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/lexicon_lookup.dart';
|
||||
@@ -46,7 +46,7 @@ class DialogueScenePage extends StatelessWidget {
|
||||
'每轮 4 次回答,完成明确任务后结束。',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
for (final scene in a0Scenes)
|
||||
for (final scene in allScenes)
|
||||
if (state.isSceneUnlocked(scene))
|
||||
_OpenScene(
|
||||
scene: scene,
|
||||
@@ -158,6 +158,9 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
String lastTranscript = '';
|
||||
bool waitingForReply = false;
|
||||
String? validationError;
|
||||
bool checkingWithAi = false;
|
||||
WritingAiFeedback? aiCheck;
|
||||
String? aiCheckError;
|
||||
|
||||
/// Shown when the reply on screen came from the built-in script instead of
|
||||
/// the AI, so a canned line is never mistaken for a real answer.
|
||||
@@ -169,13 +172,11 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
|
||||
DialogueScene get _scene => sceneById(widget.sceneId);
|
||||
|
||||
/// A free scene may use everything the learner has finished so far.
|
||||
String get _furthestCompletedLessonId => a0SeedLessons
|
||||
.lastWhere(
|
||||
(lesson) => widget.state.completedLessonIds.contains(lesson.id),
|
||||
orElse: () => a0SeedLessons.first,
|
||||
)
|
||||
.id;
|
||||
/// The lesson whose language the AI partner speaks: the active lesson, or
|
||||
/// the unit a free scene belongs to. Null for the always-open A0 scene.
|
||||
String? get _languageLessonId => widget.isLessonDialogue
|
||||
? widget.state.activeLessonId
|
||||
: _scene.unlockAfterLessonId;
|
||||
|
||||
/// Each free scene stores its draft under its own id.
|
||||
String get _sceneDraftId => 'scene-${_scene.id}';
|
||||
@@ -194,7 +195,6 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
widget.state.activeLessonId,
|
||||
)]
|
||||
.id,
|
||||
widget.state.activeLessonId,
|
||||
)
|
||||
: _scene.script;
|
||||
|
||||
@@ -211,17 +211,11 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Check the free scene script
|
||||
for (var i = 0; i < a0MeetDialogue.prompts.length; i++) {
|
||||
if (a0MeetDialogue.prompts[i].trim().toLowerCase() ==
|
||||
cleanText.toLowerCase()) {
|
||||
if (i < a0MeetDialogue.translations.length) {
|
||||
return a0MeetDialogue.translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Check all lesson dialogues
|
||||
for (final dialogue in a0Dialogues.values) {
|
||||
// 2. Check every free scene and lesson dialogue
|
||||
for (final dialogue in [
|
||||
for (final scene in allScenes) scene.script,
|
||||
...segmentDialogues.values,
|
||||
]) {
|
||||
for (var i = 0; i < dialogue.prompts.length; i++) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() ==
|
||||
cleanText.toLowerCase()) {
|
||||
@@ -231,18 +225,7 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. Check all segment dialogues
|
||||
for (final dialogue in a0SegmentDialogues.values) {
|
||||
for (var i = 0; i < dialogue.prompts.length; i++) {
|
||||
if (dialogue.prompts[i].trim().toLowerCase() ==
|
||||
cleanText.toLowerCase()) {
|
||||
if (i < dialogue.translations.length) {
|
||||
return dialogue.translations[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 5. Common fallback phrases
|
||||
// 3. Common fallback phrases
|
||||
if (cleanText == _closingLine) return _closingTranslation;
|
||||
if (cleanText.toLowerCase().contains("wonderful") &&
|
||||
cleanText.toLowerCase().contains("nice meeting you")) {
|
||||
@@ -360,6 +343,8 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
waitingForReply = true;
|
||||
hint = null;
|
||||
validationError = null;
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
});
|
||||
_saveDraft();
|
||||
_scrollToBottom();
|
||||
@@ -373,12 +358,11 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
learnerTask: nextStage < script.hints.length
|
||||
? 'answer with something like "${script.hints[nextStage]}"'
|
||||
: 'nothing more, the conversation is finished',
|
||||
allowedLanguage: widget.isLessonDialogue
|
||||
? taughtLanguageUpTo(widget.state.activeLessonId)
|
||||
: _scene.unlockAfterLessonId == null
|
||||
level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!),
|
||||
// The always-open scene mixes every A0 topic, as its script does.
|
||||
? allTaughtLanguage
|
||||
: taughtLanguageUpTo(_furthestCompletedLessonId),
|
||||
allowedLanguage: _languageLessonId == null
|
||||
? a0TaughtLanguage
|
||||
: taughtLanguageUpTo(_languageLessonId!),
|
||||
history: turns
|
||||
.map(
|
||||
(turn) => <String, String>{
|
||||
@@ -440,6 +424,54 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
|
||||
String _currentTaskLabel() => dialogueTaskLabel(script, stage);
|
||||
|
||||
/// Optional spelling and grammar check of the turn before it is sent. It
|
||||
/// never decides whether the turn passes; [_matchesCurrentTask] does.
|
||||
Future<void> _checkWithAi() async {
|
||||
final answer = controller.text.trim();
|
||||
if (answer.isEmpty || checkingWithAi || stage >= script.prompts.length) {
|
||||
return;
|
||||
}
|
||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续对话。');
|
||||
return;
|
||||
}
|
||||
final checkedStage = stage;
|
||||
setState(() {
|
||||
checkingWithAi = true;
|
||||
aiCheckError = null;
|
||||
});
|
||||
final partnerLine = turns.where((turn) => !turn.isLearner).lastOrNull;
|
||||
final feedback = await AiService.instance.answerFeedback(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
answerId: 'dialogue-$checkedStage',
|
||||
target: checkedStage < script.hints.length
|
||||
? script.hints[checkedStage]
|
||||
: _currentTaskLabel(),
|
||||
taskPrompt: '对方说:${partnerLine?.text ?? ''} 本轮任务:${_currentTaskLabel()}',
|
||||
answer: answer,
|
||||
level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!),
|
||||
);
|
||||
if (!mounted) return;
|
||||
// Drop a result for a turn already sent or an answer since changed.
|
||||
if (stage != checkedStage || controller.text.trim() != answer) {
|
||||
setState(() => checkingWithAi = false);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
checkingWithAi = false;
|
||||
aiCheck = feedback;
|
||||
aiCheckError = feedback == null
|
||||
? '暂时无法获得 AI 检查结果。你的回答保留在这里,可稍后重试或直接发送。'
|
||||
: null;
|
||||
// A shown correction is help, like the hint and translation chips.
|
||||
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
|
||||
});
|
||||
_saveDraft();
|
||||
_scrollToBottom();
|
||||
}
|
||||
|
||||
bool _matchesCurrentTask(String response) {
|
||||
// Every dialogue now checks the language the turn is teaching. The old
|
||||
// whole-lesson branch matched bare keywords such as 'it' anywhere in the
|
||||
@@ -603,6 +635,8 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
},
|
||||
afterTranscribe: _scrollToBottom,
|
||||
);
|
||||
@@ -684,10 +718,7 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
if (hint != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
hint!,
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
child: Text(hint!, style: TextStyle(color: AppColors.warmInk)),
|
||||
),
|
||||
if (aiNotice != null)
|
||||
SectionCard(
|
||||
@@ -723,6 +754,8 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
if (usedVoice && value != lastTranscript) {
|
||||
transcriptEdited = true;
|
||||
}
|
||||
aiCheck = null;
|
||||
aiCheckError = null;
|
||||
}),
|
||||
onSubmitted: (_) => send(),
|
||||
decoration: InputDecoration(
|
||||
@@ -752,6 +785,51 @@ class _DialoguePageState extends State<DialoguePage>
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed:
|
||||
controller.text.trim().isEmpty ||
|
||||
checkingWithAi ||
|
||||
waitingForReply
|
||||
? null
|
||||
: _checkWithAi,
|
||||
icon: checkingWithAi
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.spellcheck),
|
||||
label: Text(checkingWithAi ? '正在检查…' : '发送前 AI 检查语法和拼写(可选)'),
|
||||
),
|
||||
if (aiCheck != null)
|
||||
SectionCard(
|
||||
tint: aiCheck!.verdict == 'accepted'
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text('AI 检查:${aiCheck!.feedback}'),
|
||||
for (final note in aiCheck!.missing) Text('· $note'),
|
||||
if (aiCheck!.suggestion != null)
|
||||
Text('参考改正:${aiCheck!.suggestion}'),
|
||||
Text(
|
||||
aiCheck!.verdict == 'accepted'
|
||||
? 'AI 仅作参考;这一轮是否完成仍由本地检查判断。'
|
||||
: '看过改正后再发送,本次对话会记为使用过提示。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (aiCheckError != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
aiCheckError!,
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (usedVoice)
|
||||
Text(
|
||||
transcriptEdited
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user