整个课程和数据UI重新设计版本
This commit is contained in:
@@ -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,10 +27,11 @@ class AiConfigFile {
|
||||
(p) => p.name.toLowerCase() == providerStr.toLowerCase(),
|
||||
orElse: () => AiProviderType.compatible,
|
||||
);
|
||||
final effort = (json['reasoningEffort'] as String? ??
|
||||
json['reasoning_effort'] as String? ??
|
||||
'low')
|
||||
.trim();
|
||||
final effort =
|
||||
(json['reasoningEffort'] as String? ??
|
||||
json['reasoning_effort'] as String? ??
|
||||
'low')
|
||||
.trim();
|
||||
return AiConfigFile(
|
||||
provider: provider,
|
||||
endpoint: (json['endpoint'] as String? ?? '').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,51 +784,85 @@ 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 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.
|
||||
final messages = <Map<String, String>>[
|
||||
for (final message in history)
|
||||
message['role'] == 'assistant'
|
||||
? {
|
||||
'role': 'assistant',
|
||||
'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,
|
||||
// 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: [
|
||||
for (final message in history)
|
||||
message['role'] == 'assistant'
|
||||
? {
|
||||
'role': 'assistant',
|
||||
'content': jsonEncode({'reply': message['content']}),
|
||||
}
|
||||
: message,
|
||||
],
|
||||
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,11 +24,12 @@ class SyncMerger {
|
||||
final masteryUpdates = state.mastery.values.map((m) {
|
||||
// 查找对应复习到期时间
|
||||
final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull;
|
||||
final dueAt = (review?.dueAt ??
|
||||
m.firstTaughtAt?.add(_firstReviewDelay) ??
|
||||
DateTime.now())
|
||||
.toUtc()
|
||||
.toIso8601String();
|
||||
final dueAt =
|
||||
(review?.dueAt ??
|
||||
m.firstTaughtAt?.add(_firstReviewDelay) ??
|
||||
DateTime.now())
|
||||
.toUtc()
|
||||
.toIso8601String();
|
||||
final attempts = review?.attempts ?? 0;
|
||||
final successfulReviews = review?.successfulReviews ?? 0;
|
||||
|
||||
@@ -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,12 +73,13 @@ class SyncAuthResponse {
|
||||
required this.expiresIn,
|
||||
});
|
||||
|
||||
factory SyncAuthResponse.fromJson(Map<String, dynamic> json) => SyncAuthResponse(
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
token: json['token'] as String? ?? '',
|
||||
expiresIn: json['expires_in'] as int? ?? 0,
|
||||
);
|
||||
factory SyncAuthResponse.fromJson(Map<String, dynamic> json) =>
|
||||
SyncAuthResponse(
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
token: json['token'] as String? ?? '',
|
||||
expiresIn: json['expires_in'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 课程关卡进度 DTO
|
||||
@@ -113,18 +109,21 @@ class SyncProgressPayload {
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
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())
|
||||
.toList(),
|
||||
completedSegmentIds: (json['completed_segment_ids'] as List? ?? [])
|
||||
.map((e) => e.toString())
|
||||
.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(),
|
||||
);
|
||||
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())
|
||||
.toList(),
|
||||
completedSegmentIds: (json['completed_segment_ids'] as List? ?? [])
|
||||
.map((e) => e.toString())
|
||||
.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(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 艾宾浩斯与掌握度 DTO
|
||||
@@ -160,18 +159,23 @@ class SyncMasteryItemPayload {
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
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(),
|
||||
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(),
|
||||
);
|
||||
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(),
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户画像与偏好 DTO
|
||||
@@ -213,20 +217,23 @@ class SyncProfilePayload {
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
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',
|
||||
dailyMinutes: json['daily_minutes'] as int? ?? 20,
|
||||
showChineseHints: json['show_chinese_hints'] as bool? ?? true,
|
||||
aiEndpoint: json['ai_endpoint'] as String? ?? '',
|
||||
aiModel: json['ai_model'] as String? ?? '',
|
||||
aiProvider: json['ai_provider'] as String? ?? '',
|
||||
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(),
|
||||
);
|
||||
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',
|
||||
dailyMinutes: json['daily_minutes'] as int? ?? 20,
|
||||
showChineseHints: json['show_chinese_hints'] as bool? ?? true,
|
||||
aiEndpoint: json['ai_endpoint'] as String? ?? '',
|
||||
aiModel: json['ai_model'] as String? ?? '',
|
||||
aiProvider: json['ai_provider'] as String? ?? '',
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 增量推送请求
|
||||
@@ -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>>()
|
||||
|
||||
@@ -41,22 +41,26 @@ class SyncService {
|
||||
}) async {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/auth/register');
|
||||
|
||||
final resp = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
final resp = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
)
|
||||
.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}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,22 +73,26 @@ class SyncService {
|
||||
}) async {
|
||||
final base = _cleanUrl(serverUrl);
|
||||
final uri = Uri.parse('$base/api/v1/auth/login');
|
||||
|
||||
final resp = await _client.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
|
||||
final resp = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'username': username.trim(),
|
||||
'password': password,
|
||||
'device_name': deviceName ?? _getPlatformDeviceName(),
|
||||
}),
|
||||
)
|
||||
.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(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
).timeout(const Duration(seconds: 15));
|
||||
final resp = await _client
|
||||
.get(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
)
|
||||
.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(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(request.toJson()),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
final resp = await _client
|
||||
.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode(request.toJson()),
|
||||
)
|
||||
.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,
|
||||
[
|
||||
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
|
||||
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
|
||||
IosTextToSpeechAudioCategoryOptions.mixWithOthers,
|
||||
],
|
||||
);
|
||||
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.',
|
||||
_ => '再补充一个完整英文句子。',
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user