feat: 重做阅读找答案题与 AI 情境对话,并归拢本地识别、查词等既有改动

阅读"在对话里找到答案":
- 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答
- 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理
- 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对)

AI 情境对话:
- 提示词区分"AI 这一句要做什么"与"学习者随后要完成什么",并下发已教词句清单
- JSON 只强制 reply,translation/feedback 可选;不再索要用不上的 slots/evidence
- AI 不可用时页面明确提示当前回复来自内置示范脚本
- 删掉按 stage 下标猜中文翻译的兜底,避免译文与英文对不上
- 整课对话改用逐轮必需表达校验,替换"关键词沾边就算过";修正自由场景正则误伤
- 总结的"完成任务"按实际通过的轮次生成;模型点评只在结束页呈现一次
- 自由场景支持草稿续练(独立存储槽);修正回答轮数文案与永不解锁的场景标注

同时提交此前工作区中累积的改动:SenseVoice 本地识别、查词/句型解析卡、
复习与测评页调整等,并补充对话校验、选项分布和句子解析的测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 16:38:23 +09:00
co-authored by Claude Opus 5
parent ef2fce207a
commit 7e29c5449f
24 changed files with 2896 additions and 665 deletions
+300 -36
View File
@@ -233,16 +233,16 @@ class AiService {
'input': messages,
'reasoning': {'effort': reasoningEffort},
'reasoning_effort': reasoningEffort,
if (temperature != null) 'temperature': temperature,
if (maxTokens != null) 'max_output_tokens': maxTokens,
'temperature': ?temperature,
'max_output_tokens': ?maxTokens,
};
}
return {
'model': model,
'messages': messages,
'reasoning_effort': reasoningEffort,
if (temperature != null) 'temperature': temperature,
if (maxTokens != null) 'max_tokens': maxTokens,
'temperature': ?temperature,
'max_tokens': ?maxTokens,
};
}
@@ -253,9 +253,9 @@ class AiService {
int thinkingBudget = 1024,
}) {
return {
if (temperature != null) 'temperature': temperature,
if (maxOutputTokens != null) 'maxOutputTokens': maxOutputTokens,
if (responseMimeType != null) 'responseMimeType': responseMimeType,
'temperature': ?temperature,
'maxOutputTokens': ?maxOutputTokens,
'responseMimeType': ?responseMimeType,
'thinkingConfig': {
'thinkingBudget': thinkingBudget,
},
@@ -345,6 +345,249 @@ class AiService {
}
}
/// Performs structured multi-dimensional analysis on an English sentence or phrase.
/// Provides translation, sentence pattern, grammar breakdown, pronunciation tips, and extracted phrases.
Future<SentenceAnalysisResult?> analyzeSentence({
required AiProviderType provider,
required String endpoint,
required String model,
required String text,
}) async {
final cleanText = text.trim();
if (cleanText.isEmpty || cleanText.length > 800) {
return null;
}
if (provider == AiProviderType.mock) {
return _buildMockSentenceAnalysis(cleanText);
}
final key = await resolveApiKey();
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (key == null ||
key.isEmpty ||
model.trim().isEmpty ||
uri == null ||
(uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const instruction =
'You are an expert oral English coach for beginner adult learners (A0-A1). '
'Analyze the given English sentence into clear, encouraging, beginner-friendly Chinese explanations. '
'Focus on practical oral usage, sentence structure, and linking/pronunciation hints. '
'Return ONLY valid JSON matching this schema, with no markdown or other text:\n'
'{\n'
' "translation": "准确通顺的中文整句翻译",\n'
' "sentencePattern": "核心口语句型结构 (如:I would like + 名词/动词原形)",\n'
' "grammarNote": "通俗易懂的语法与时态要点 (1-2句话,面向初学者,不要学术术语)",\n'
' "pronunciationTips": "口语连读/失爆/弱读技巧提示 (如:check in 连读为 /tʃe-kɪn/)",\n'
' "phrases": [\n'
' {\n'
' "phrase": "句子中的重点短语或搭配",\n'
' "ipa": "/音标/",\n'
' "meaning": "在句中的准确释义",\n'
' "usageNote": "简要口语用法说明或常见搭配"\n'
' }\n'
' ]\n'
'}';
try {
final response = await http
.post(
uri,
headers: provider == AiProviderType.gemini
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
: {
'Authorization': 'Bearer $key',
'Content-Type': 'application/json',
},
body: jsonEncode(
provider == AiProviderType.gemini
? {
'contents': [
{
'parts': [
{'text': '$instruction\n\nSentence: $cleanText'},
],
},
],
'generationConfig': _buildGeminiGenerationConfig(
maxOutputTokens: 800,
responseMimeType: 'application/json',
),
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{
'role': 'user',
'content': '$instruction\n\nSentence: $cleanText',
},
],
maxTokens: 800,
),
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null || content.isEmpty) return null;
return _decodeSentenceAnalysis(
raw: content,
originalText: cleanText,
provider: provider.name,
model: model,
);
} catch (_) {
return null;
}
}
SentenceAnalysisResult? _decodeSentenceAnalysis({
required String raw,
required String originalText,
required String provider,
required String model,
}) {
try {
var sanitized = raw.trim();
if (sanitized.startsWith('```')) {
sanitized = sanitized.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
sanitized = sanitized.replaceFirst(RegExp(r'\s*```$'), '');
}
final jsonStart = sanitized.indexOf('{');
final jsonEnd = sanitized.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
sanitized = sanitized.substring(jsonStart, jsonEnd + 1);
}
final data = jsonDecode(sanitized);
if (data is! Map<String, dynamic>) return null;
final translation = data['translation'] as String? ?? '';
if (translation.trim().isEmpty) return null;
final phrasesList = <PhraseBreakdownItem>[];
if (data['phrases'] is List) {
for (final item in data['phrases'] as List) {
if (item is Map<String, dynamic>) {
final phrase = item['phrase'] as String? ?? '';
final meaning = item['meaning'] as String? ?? '';
if (phrase.trim().isNotEmpty && meaning.trim().isNotEmpty) {
phrasesList.add(
PhraseBreakdownItem(
phrase: phrase.trim(),
meaning: meaning.trim(),
ipa: item['ipa'] as String?,
usageNote: item['usageNote'] as String?,
),
);
}
}
}
}
return SentenceAnalysisResult(
originalText: originalText,
translation: translation.trim(),
sentencePattern: data['sentencePattern'] as String?,
grammarNote: data['grammarNote'] as String?,
pronunciationTips: data['pronunciationTips'] as String?,
phrases: phrasesList,
provider: provider,
model: model,
createdAt: DateTime.now(),
);
} catch (_) {
return null;
}
}
SentenceAnalysisResult _buildMockSentenceAnalysis(String text) {
final lower = text.toLowerCase();
String translation = '(演示翻译)这是句子的中文参考释义。';
String? pattern = '常见日常交流句型';
String? grammar = '此句为日常口语高频表达,结构清晰,适合在日常与工作场景中直接套用。';
String? pronunciation = '注意单词间的自然停顿,句末语调自然微降。';
final phrases = <PhraseBreakdownItem>[];
if (lower.contains('would like') || lower.contains("i'd like")) {
translation = '我想办理相关事项/我想要这个,麻烦了。';
pattern = 'I would like + 名词/动词原形(礼貌请求句型)';
grammar = 'would like 相当于礼貌委婉的 want,是服务和工作场景中最得体的表达方式。';
pronunciation = 'would like 发音为 /wʊd laɪk/,注意 d 的微弱爆破。';
phrases.add(
const PhraseBreakdownItem(
phrase: 'would like',
ipa: '/wʊd laɪk/',
meaning: '想要(礼貌委婉)',
usageNote: "比 I want 更得体,常缩写为 I'd like",
),
);
} else if (lower.contains('nice to meet you')) {
translation = '初次见面,很高兴认识你。';
pattern = 'It is + 形容词 + to do sth.(社交问候句型)';
grammar = '初次与新朋友或客户见面时的标准礼貌问候,通常省略了句首的 It is。';
pronunciation = 'meet 与 you 发生音变连读为 /miːtʃuː/。';
phrases.add(
const PhraseBreakdownItem(
phrase: 'nice to meet you',
ipa: '/naɪs tuː miːt juː/',
meaning: '初次见面很高兴认识你',
usageNote: '仅用于初次相识;熟悉后再次见面用 Nice to see you again',
),
);
} else if (lower.contains('where is') || lower.contains("where's")) {
translation = '请问……在哪里?';
pattern = 'Where is + 目的地/物品?(询问地点句型)';
grammar = "where 引导的特殊疑问句,口语中常用缩读 Where's。";
pronunciation = 'Where 与 is 发生连读,读作 /weər ɪz/,疑问句末尾用降调。';
phrases.add(
const PhraseBreakdownItem(
phrase: 'where is',
ipa: '/weər ɪz/',
meaning: '……在哪里',
usageNote: '问路与寻物核心句型,句首加上 Excuse me 更礼貌',
),
);
} else {
final words = text
.split(RegExp(r'\s+'))
.map((w) => w.replaceAll(RegExp(r'[^a-zA-Z]'), ''))
.where((w) => w.length > 3)
.take(2);
for (final w in words) {
phrases.add(
PhraseBreakdownItem(
phrase: w,
meaning: '重点词汇',
usageNote: '句子中的核心实词',
),
);
}
}
return SentenceAnalysisResult(
originalText: text,
translation: translation,
sentencePattern: pattern,
grammarNote: grammar,
pronunciationTips: pronunciation,
phrases: phrases,
provider: 'mock',
model: 'local-mock',
createdAt: DateTime.now(),
);
}
Future<AiConnectionResult> testConnection({
required AiProviderType provider,
required String endpoint,
@@ -458,12 +701,17 @@ 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.
Future<DialogueAiResponse?> dialogueReply({
required AiProviderType provider,
required String endpoint,
required String model,
required List<Map<String, String>> history,
required String requiredTask,
required String aiGoal,
required String learnerTask,
List<String> allowedLanguage = const [],
}) async {
if (provider == AiProviderType.mock) {
return null;
@@ -483,8 +731,23 @@ class AiService {
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const system =
'You are Mia, a patient A0 English conversation partner. Use only very simple English. Reply in one short sentence or question. Do not explain grammar. Return JSON only, with exactly these fields: reply (string, maximum 20 English words), translation (string, simplified Chinese translation of reply), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
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.';
try {
final response = await http
.post(
@@ -500,7 +763,7 @@ class AiService {
? {
'systemInstruction': {
'parts': [
{'text': '$system$requiredTask'},
{'text': system},
],
},
'contents': history
@@ -525,7 +788,7 @@ class AiService {
uri: uri,
model: model,
messages: [
{'role': 'system', 'content': '$system$requiredTask'},
{'role': 'system', 'content': system},
...history,
],
temperature: 0.3,
@@ -984,41 +1247,42 @@ Learner wrote: $answer''';
}
final data = jsonDecode(sanitized.trim()) as Map<String, dynamic>;
final reply = data['reply'] as String?;
final rawSlots = data['slots'];
final rawEvidence = data['evidence'];
final suggestsComplete = data['suggestsComplete'];
final feedback = data['feedback'];
if (reply == null ||
reply.trim().isEmpty ||
reply.length > 240 ||
rawSlots is! Map ||
rawEvidence is! List ||
suggestsComplete is! bool ||
(feedback != null && feedback is! String)) {
if (reply == null || reply.trim().isEmpty || reply.length > 240) {
return null;
}
// Only `reply` is mandatory. A model that omits or malforms an optional
// field used to make the whole turn fall back to the canned script.
final slots = <String, String>{};
for (final entry in rawSlots.entries) {
if (entry.key is! String || entry.value is! String) return null;
if ((entry.key as String).length > 40 ||
(entry.value as String).length > 80) {
return null;
final rawSlots = data['slots'];
if (rawSlots is Map) {
for (final entry in rawSlots.entries) {
final key = entry.key;
final value = entry.value;
if (key is! String || value is! String) continue;
if (key.length > 40 || value.length > 80) continue;
slots[key] = value;
}
slots[entry.key as String] = entry.value as String;
}
final evidence = <String>[];
for (final item in rawEvidence) {
if (item is! String || item.length > 240) return null;
evidence.add(item);
final rawEvidence = data['evidence'];
if (rawEvidence is List) {
for (final item in rawEvidence) {
if (item is String && item.length <= 240) evidence.add(item);
}
}
final translation = data['translation'] as String?;
final translation = data['translation'];
final feedback = data['feedback'];
return DialogueAiResponse(
reply: reply.trim(),
translation: translation?.trim(),
translation: translation is String && translation.trim().isNotEmpty
? translation.trim()
: null,
slots: slots,
evidence: evidence,
suggestsComplete: suggestsComplete,
feedback: feedback as String?,
suggestsComplete: data['suggestsComplete'] == true,
feedback: feedback is String && feedback.trim().isNotEmpty
? feedback.trim()
: null,
);
} catch (_) {
return null;
+131 -34
View File
@@ -45,6 +45,10 @@ class AppState extends ChangeNotifier {
final List<AssessmentRecord> assessments = [];
AssessmentDraft? assessmentDraft;
DialogueDraft? dialogueDraft;
/// The free "初次见面" scene keeps its own slot, so resuming a scene never
/// overwrites an unfinished lesson dialogue.
DialogueDraft? sceneDialogueDraft;
LessonStep lessonStep = LessonStep.preview;
int previewIndex = 0;
bool lessonListeningComplete = false;
@@ -80,6 +84,7 @@ class AppState extends ChangeNotifier {
String? adaptiveLessonDraftRecordingPath;
final Set<String> reportedAdaptiveLessonIds = {};
final Map<String, TemporaryLexiconEntry> temporaryLexicon = {};
final Map<String, SentenceAnalysisResult> sentenceAnalyses = {};
final List<ReviewItem> reviewQueue = [];
final List<AttemptEvidence> attemptEvidence = [];
@@ -255,6 +260,17 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
void saveSceneDialogueDraft(DialogueDraft draft) {
sceneDialogueDraft = draft;
notifyListeners();
}
void clearSceneDialogueDraft() {
if (sceneDialogueDraft == null) return;
sceneDialogueDraft = null;
notifyListeners();
}
int get usableMasteryCount => mastery.values
.where(
(item) =>
@@ -407,6 +423,24 @@ class AppState extends ChangeNotifier {
),
);
}
final savedSentenceAnalyses = data['sentenceAnalyses'] as List<dynamic>?;
if (savedSentenceAnalyses != null) {
sentenceAnalyses
..clear()
..addEntries(
savedSentenceAnalyses
.whereType<Map<String, dynamic>>()
.map((item) {
final query = item['query'] as String? ?? '';
final payload = item['payload'] as Map<String, dynamic>? ?? {};
return MapEntry(
_normalizeLexiconQuery(query),
SentenceAnalysisResult.fromJson(payload),
);
})
.where((entry) => entry.key.isNotEmpty && entry.value.translation.isNotEmpty),
);
}
aiProvider = _enumValue(
AiProviderType.values,
data['aiProvider'] as String?,
@@ -580,26 +614,51 @@ class AppState extends ChangeNotifier {
.map((key, value) => MapEntry(key, value == true)),
);
}
final savedDialogue = data['dialogueDraft'] as Map<String, dynamic>?;
if (savedDialogue != null) {
dialogueDraft = DialogueDraft(
lessonId: savedDialogue['lessonId'] as String,
stage: savedDialogue['stage'] as int? ?? 0,
usedHelp: savedDialogue['usedHelp'] as bool? ?? false,
turns: (savedDialogue['turns'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(
(turn) => DialogueTurn(
text: turn['text'] as String,
isLearner: turn['isLearner'] as bool? ?? false,
translation: turn['translation'] as String?,
),
)
.toList(),
);
}
dialogueDraft = _dialogueDraftFromJson(
data['dialogueDraft'] as Map<String, dynamic>?,
);
sceneDialogueDraft = _dialogueDraftFromJson(
data['sceneDialogueDraft'] as Map<String, dynamic>?,
);
}
DialogueDraft? _dialogueDraftFromJson(Map<String, dynamic>? saved) {
if (saved == null || saved['lessonId'] is! String) return null;
return DialogueDraft(
lessonId: saved['lessonId'] as String,
stage: saved['stage'] as int? ?? 0,
usedHelp: saved['usedHelp'] as bool? ?? false,
turns: (saved['turns'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(
(turn) => DialogueTurn(
text: turn['text'] as String,
isLearner: turn['isLearner'] as bool? ?? false,
translation: turn['translation'] as String?,
),
)
.toList(),
);
}
Map<String, dynamic>? _dialogueDraftToJson(DialogueDraft? draft) =>
draft == null
? null
: {
'lessonId': draft.lessonId,
'stage': draft.stage,
'usedHelp': draft.usedHelp,
'turns': draft.turns
.map(
(turn) => {
'text': turn.text,
'isLearner': turn.isLearner,
'translation': turn.translation,
},
)
.toList(),
};
T _enumValue<T extends Enum>(List<T> values, String? name, T fallback) =>
values.where((value) => value.name == name).firstOrNull ?? fallback;
@@ -663,6 +722,17 @@ class AppState extends ChangeNotifier {
},
)
.toList(),
'sentenceAnalyses': sentenceAnalyses.entries
.map(
(entry) => {
'query': entry.key,
'payload': entry.value.toJson(),
'provider': entry.value.provider,
'model': entry.value.model,
'createdAt': entry.value.createdAt.toIso8601String(),
},
)
.toList(),
'aiProvider': aiProvider.name,
'lessonStep': lessonStep.name,
'previewIndex': previewIndex,
@@ -754,22 +824,8 @@ class AppState extends ChangeNotifier {
'taskIndex': assessmentDraft!.taskIndex,
'results': assessmentDraft!.results,
},
'dialogueDraft': dialogueDraft == null
? null
: {
'lessonId': dialogueDraft!.lessonId,
'stage': dialogueDraft!.stage,
'usedHelp': dialogueDraft!.usedHelp,
'turns': dialogueDraft!.turns
.map(
(turn) => {
'text': turn.text,
'isLearner': turn.isLearner,
'translation': turn.translation,
},
)
.toList(),
},
'dialogueDraft': _dialogueDraftToJson(dialogueDraft),
'sceneDialogueDraft': _dialogueDraftToJson(sceneDialogueDraft),
}),
).catchError((_) => false).whenComplete(() {
_writing = false;
@@ -815,6 +871,46 @@ class AppState extends ChangeNotifier {
}
}
SentenceAnalysisResult? sentenceAnalysisFor(String query) =>
sentenceAnalyses[_normalizeLexiconQuery(query)];
void cacheSentenceAnalysis(SentenceAnalysisResult result) {
final key = _normalizeLexiconQuery(result.originalText);
if (key.isEmpty || result.translation.trim().isEmpty) return;
sentenceAnalyses[key] = result;
notifyListeners();
}
void removeSentenceAnalysis(String query) {
if (sentenceAnalyses.remove(_normalizeLexiconQuery(query)) != null) {
notifyListeners();
}
}
void addPhraseToReview({
required String phrase,
required String meaning,
String? ipa,
String? usageNote,
required String contextSentence,
}) {
final cleanPhrase = phrase.trim();
if (cleanPhrase.isEmpty) return;
final id = 'phrase_${cleanPhrase.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}';
final fullMeaning = usageNote != null && usageNote.trim().isNotEmpty
? '$meaning ($usageNote)'
: meaning;
final item = VocabularyItem(
id: id,
word: cleanPhrase,
meaning: fullMeaning,
example: contextSentence.trim(),
exampleMeaning: meaning,
ipa: ipa,
);
addSavedWord(item);
}
void finishOnboarding() {
onboardingComplete = true;
notifyListeners();
@@ -1631,6 +1727,7 @@ class AppState extends ChangeNotifier {
assessments.clear();
assessmentDraft = null;
dialogueDraft = null;
sceneDialogueDraft = null;
lessonStep = LessonStep.preview;
previewIndex = 0;
lessonListeningComplete = false;
+51
View File
@@ -105,6 +105,13 @@ class LocalSnapshotStore {
created_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS sentence_analyses (
query_key TEXT PRIMARY KEY NOT NULL, query TEXT NOT NULL,
payload TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS learner_profiles (
profile_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
@@ -233,6 +240,22 @@ class LocalSnapshotStore {
},
)
.toList();
result['sentenceAnalyses'] =
(await executor.runSelect(
'''SELECT query, payload, provider, model, created_at
FROM sentence_analyses ORDER BY created_at''',
const [],
))
.map(
(row) => <String, dynamic>{
'query': row['query'],
'payload': _decodeObject(row['payload']),
'provider': row['provider'],
'model': row['model'],
'createdAt': row['created_at'],
},
)
.toList();
result['assessments'] = (await executor.runSelect(
'SELECT payload FROM assessment_records ORDER BY completed_at',
const [],
@@ -246,6 +269,8 @@ class LocalSnapshotStore {
result['assessmentDraft'] = _decodeObject(session['payload']);
} else if (session['session_type'] == 'dialogue') {
result['dialogueDraft'] = _decodeObject(session['payload']);
} else if (session['session_type'] == 'sceneDialogue') {
result['sceneDialogueDraft'] = _decodeObject(session['payload']);
}
}
return result;
@@ -262,6 +287,12 @@ class LocalSnapshotStore {
return decoded is Map<String, dynamic> ? decoded : const {};
}
String _encodeObject(Object? value) {
if (value == null) return '{}';
if (value is String) return value;
return jsonEncode(value);
}
Future<void> write(String payload) async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
@@ -357,6 +388,7 @@ class LocalSnapshotStore {
},
'assessment': data['assessmentDraft'],
'dialogue': data['dialogueDraft'],
'sceneDialogue': data['sceneDialogueDraft'],
};
for (final entry in sessions.entries) {
if (entry.value == null) continue;
@@ -466,6 +498,24 @@ class LocalSnapshotStore {
],
);
}
for (final entry in (data['sentenceAnalyses'] as List? ?? const [])) {
if (entry is! Map) continue;
final query = entry['query'] as String? ?? '';
final key = query.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
final payload = _encodeObject(entry['payload'] ?? {});
if (key.isEmpty || payload == '{}') continue;
await executor.runCustom(
'INSERT INTO sentence_analyses VALUES (?, ?, ?, ?, ?, ?)',
[
key,
query,
payload,
entry['provider'] ?? 'unknown',
entry['model'] ?? '',
entry['createdAt'] ?? DateTime.now().toUtc().toIso8601String(),
],
);
}
}
Future<void> clear() async {
@@ -484,6 +534,7 @@ class LocalSnapshotStore {
'review_items',
'study_sessions',
'temporary_lexicon_entries',
'sentence_analyses',
'learner_profiles',
'lesson_progress',
'assessment_records',
+94 -3
View File
@@ -78,9 +78,9 @@ enum ContentSource { builtInOriginal, aiGenerated, importedReference }
class DialogueAiResponse {
const DialogueAiResponse({
required this.reply,
required this.slots,
required this.evidence,
required this.suggestsComplete,
this.slots = const {},
this.evidence = const [],
this.suggestsComplete = false,
this.translation,
this.feedback,
});
@@ -210,6 +210,92 @@ class TemporaryLexiconEntry {
final DateTime createdAt;
}
/// Represents an extracted key phrase or vocabulary item within a sentence.
class PhraseBreakdownItem {
const PhraseBreakdownItem({
required this.phrase,
required this.meaning,
this.ipa,
this.usageNote,
});
final String phrase;
final String meaning;
final String? ipa;
final String? usageNote;
Map<String, dynamic> toJson() => {
'phrase': phrase,
'meaning': meaning,
if (ipa != null) 'ipa': ipa,
if (usageNote != null) 'usageNote': usageNote,
};
factory PhraseBreakdownItem.fromJson(Map<String, dynamic> json) =>
PhraseBreakdownItem(
phrase: json['phrase'] as String? ?? '',
meaning: json['meaning'] as String? ?? '',
ipa: json['ipa'] as String?,
usageNote: json['usageNote'] as String?,
);
}
/// Structured multi-dimensional analysis for a sentence or phrase, including
/// translation, sentence pattern, grammar note, pronunciation tips, and extracted phrases.
class SentenceAnalysisResult {
const SentenceAnalysisResult({
required this.originalText,
required this.translation,
this.sentencePattern,
this.grammarNote,
this.pronunciationTips,
this.phrases = const [],
this.provider = 'unknown',
this.model = '',
required this.createdAt,
});
final String originalText;
final String translation;
final String? sentencePattern;
final String? grammarNote;
final String? pronunciationTips;
final List<PhraseBreakdownItem> phrases;
final String provider;
final String model;
final DateTime createdAt;
Map<String, dynamic> toJson() => {
'originalText': originalText,
'translation': translation,
if (sentencePattern != null) 'sentencePattern': sentencePattern,
if (grammarNote != null) 'grammarNote': grammarNote,
if (pronunciationTips != null) 'pronunciationTips': pronunciationTips,
'phrases': phrases.map((p) => p.toJson()).toList(),
'provider': provider,
'model': model,
'createdAt': createdAt.toIso8601String(),
};
factory SentenceAnalysisResult.fromJson(Map<String, dynamic> json) =>
SentenceAnalysisResult(
originalText: json['originalText'] as String? ?? '',
translation: json['translation'] as String? ?? '',
sentencePattern: json['sentencePattern'] as String?,
grammarNote: json['grammarNote'] as String?,
pronunciationTips: json['pronunciationTips'] as String?,
phrases: (json['phrases'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(PhraseBreakdownItem.fromJson)
.toList(),
provider: json['provider'] as String? ?? 'unknown',
model: json['model'] as String? ?? '',
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()
: DateTime.now(),
);
}
class ReviewItem {
const ReviewItem({
required this.id,
@@ -329,9 +415,14 @@ class DialogueSummaryData {
required this.completedTasks,
required this.personalSentence,
required this.usedHelp,
this.improvement,
});
final List<String> completedTasks;
final String personalSentence;
final bool usedHelp;
/// One improvement point collected during the dialogue and shown only at the
/// end, per "对话中不逐句打断" in the conversation spec.
final String? improvement;
}
+306 -58
View File
@@ -1,3 +1,4 @@
import 'a0_core.dart';
import 'models.dart';
/// Stable A0 core identities taught by each seed lesson. Wording can vary in
@@ -533,6 +534,7 @@ class LessonActivity {
required this.reading,
required this.readingQuestion,
required this.readingAnswer,
this.readingOptions = const [],
required this.writingPrompt,
required this.writingExample,
required this.independentPrompt,
@@ -545,12 +547,39 @@ class LessonActivity {
final String reading;
final String readingQuestion;
final String readingAnswer;
final List<String> readingOptions;
final String writingPrompt;
final String writingExample;
final String independentPrompt;
final String independentHelp;
}
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);
}
const a0Activities = <String, LessonActivity>{
'a0-01': LessonActivity(
listening: 'Hello. Im Mia. Nice to meet you.',
@@ -558,9 +587,14 @@ const a0Activities = <String, LessonActivity>{
answers: ['自我介绍', '买东西', '问时间'],
speaking: 'Hello. Im Shen. Nice to meet you.',
reading:
'Mia: Hello. Im Mia.\nShen: Hi. Im Shen.\nMia: Nice to meet you.\nShen: Nice to meet you, too.',
readingQuestion: '谁叫 Mia',
readingAnswer: '第一位说话的人',
'Mia: Hello. Im Mia. Whats your name?\nShen: Hi. Im Shen.\nMia: Nice to meet you.\nShen: Nice to meet you, too.',
readingQuestion: 'Shen 说的最后一句是什么',
readingAnswer: 'Nice to meet you, too.',
readingOptions: [
'Nice to meet you, too.',
'Whats your name?',
'Hi. Im Shen.',
],
writingPrompt: '写一句问候和姓名介绍。',
writingExample: 'Hello. Im Shen.',
independentPrompt: '不看句框,介绍你的名字并回应“Nice to meet you”。',
@@ -572,9 +606,10 @@ const a0Activities = <String, LessonActivity>{
answers: ['你的名字怎么拼?', '你的名字是什么?', '你来自哪里?'],
speaking: 'S H E N',
reading:
'Mia: Whats your name?\nShen: Shen.\nMia: How do you spell that?\nShen: S-H-E-N.',
readingQuestion: 'Shen 的名字怎么拼?',
'Mia: Im Mia. M-I-A.\nShen: Hi, Mia. Im Shen. S-H-E-N.\nMia: How do you spell Sam?\nShen: S-A-M.',
readingQuestion: 'Shen 自己的名字怎么拼',
readingAnswer: 'S-H-E-N',
readingOptions: ['S-H-E-N', 'M-I-A', 'S-A-M'],
writingPrompt: '把名字和拼写写完整。',
writingExample: 'My name is Shen.\nS-H-E-N.',
independentPrompt: '不看句框,用英文介绍名字并拼读它。',
@@ -586,9 +621,10 @@ const a0Activities = <String, LessonActivity>{
answers: ['你的状态', '你的号码', '你的地点'],
speaking: 'How are you? Im good, thanks.',
reading:
'Mia: Hi, Shen. How are you?\nShen: Im good, thanks. How are you?\nMia: Im okay.',
readingQuestion: 'Mia 状态如何',
readingAnswer: 'okay',
'Mia: Hi, Shen. How are you?\nShen: Im good, thanks. How are you?\nMia: Im tired today.',
readingQuestion: 'Mia 说自己今天怎么样',
readingAnswer: 'Im tired today.',
readingOptions: ['Im tired today.', 'Im good, thanks.', 'Im okay.'],
writingPrompt: '写一句今天的状态。',
writingExample: 'Im okay today.',
independentPrompt: '不看句框,问候并说出你的状态。',
@@ -600,9 +636,10 @@ const a0Activities = <String, LessonActivity>{
answers: ['138', '183', '318'],
speaking: 'My number is one-three-eight.',
reading:
'Mia: Whats your phone number?\nShen: One-three-eight.\nMia: Thank you.',
readingQuestion: 'Shen 的号码',
'Mia: Whats your phone number?\nShen: My number is one-three-eight.\nMia: One-eight-three?\nShen: No. One-three-eight.',
readingQuestion: 'Shen 的号码到底是哪一个',
readingAnswer: '138',
readingOptions: ['138', '183', '318'],
writingPrompt: '把虚拟号码 139 写成英文。',
writingExample: 'one-three-nine',
independentPrompt: '不看句框,说出一个三位虚拟号码。',
@@ -613,9 +650,10 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '这是什么?',
answers: ['钥匙', '', ''],
speaking: 'Whats this? Its a pen.',
reading: 'Mia: Whats this?\nShen: Its a book.',
readingQuestion: '是什么?',
readingAnswer: 'a book',
reading: 'Mia: Whats this? Is it a pen?\nShen: No. Its a book.',
readingQuestion: 'Shen 说那件东西是什么?',
readingAnswer: 'A book',
readingOptions: ['A book', 'A pen', 'A phone'],
writingPrompt: '写一句“这是一支笔”。',
writingExample: 'Its a pen.',
independentPrompt: '不看句框,说出一个身边物品。',
@@ -626,9 +664,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '对方在问什么?',
answers: ['来自哪里', '叫什么', '喜欢什么'],
speaking: 'Im from Hong Kong.',
reading: 'Mia: Where are you from?\nShen: Im from Hong Kong.',
reading:
'Mia: Im from London. Where are you from?\nShen: Im from Hong Kong.',
readingQuestion: 'Shen 来自哪里?',
readingAnswer: 'Hong Kong',
readingOptions: ['Hong Kong', 'London', 'Beijing'],
writingPrompt: '写一句你来自哪里。',
writingExample: 'Im from Hong Kong.',
independentPrompt: '不看句框,说来自哪里并反问对方。',
@@ -639,9 +679,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '这个人是谁?',
answers: ['妈妈', '朋友', '老师'],
speaking: 'This is my family.',
reading: 'Mia: Who is this?\nShen: This is my mother.',
readingQuestion: 'Shen 在介绍谁?',
readingAnswer: 'my mother',
reading:
'Mia: Who is this? Is this your father?\nShen: No. This is my mother.',
readingQuestion: 'Shen 介绍的是哪一位家人?',
readingAnswer: 'My mother',
readingOptions: ['My mother', 'My father', 'My sister'],
writingPrompt: '介绍一位家人或朋友。',
writingExample: 'This is my mother.',
independentPrompt: '不看句框,介绍一位家人或朋友。',
@@ -652,9 +694,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '是什么时间?',
answers: ['星期一三点', '星期三一点', '星期一一点'],
speaking: 'Its three oclock.',
reading: 'Mia: What day is it?\nShen: Its Monday.',
readingQuestion: '今天星期几?',
reading:
'Mia: What day is it today? Is it Tuesday?\nShen: No. Its Monday.',
readingQuestion: '对话中今天到底是星期几?',
readingAnswer: 'Monday',
readingOptions: ['Monday', 'Tuesday', 'Sunday'],
writingPrompt: '写一句今天和整点。',
writingExample: 'Its Monday. Its three oclock.',
independentPrompt: '不看句框,说今天星期几或现在几点。',
@@ -665,9 +709,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '对方喜欢茶吗?',
answers: ['喜欢', '不喜欢', '不知道'],
speaking: 'I like tea. Do you like tea?',
reading: 'Mia: I like tea. Do you like tea?\nShen: Yes, I do.',
readingQuestion: 'Shen 的回答是什么?',
readingAnswer: 'Yes, I do.',
reading:
'Mia: I like tea. Do you like tea?\nShen: No, I dont. I like coffee.',
readingQuestion: 'Shen 喜欢喝什么?',
readingAnswer: 'Coffee',
readingOptions: ['Coffee', 'Tea', 'Music'],
writingPrompt: '写一句你的喜好。',
writingExample: 'I like tea.',
independentPrompt: '不看句框,说一个喜好并反问。',
@@ -678,9 +724,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '对方希望什么?',
answers: ['再说一遍', '说慢一点', '写下来'],
speaking: 'Please speak slowly.',
reading: 'Mia: Please say that again.\nShen: Please speak slowly.',
readingQuestion: 'Shen 希望什么?',
readingAnswer: 'speak slowly',
reading:
'Alex: Hi! Im Alex. Whats your name?\nShen: Hi! Im Shen. Nice to meet you.\nAlex: Nice to meet you, too. Where are you from?\nShen: Im from Hong Kong. Where are you from?\nAlex: Im from London. Do you like coffee?\nShen: No, I dont. I like tea.',
readingQuestion: 'Shen 来自哪里、喜欢喝什么?',
readingAnswer: 'Hong Kong, tea',
readingOptions: ['Hong Kong, tea', 'London, coffee', 'Hong Kong, coffee'],
writingPrompt: '写姓名、地点和喜好三句。',
writingExample: 'Im Shen.\nIm from Hong Kong.\nI like tea.',
independentPrompt: '不看帮助,完成姓名、地点、喜好和反问。',
@@ -699,8 +747,9 @@ const a0SegmentActivities = <String, LessonActivity>{
answers: ['123', '132', '213'],
speaking: 'Zero, one, two, three.',
reading: 'Mia: One, two, three.\nShen: Four, five.',
readingQuestion: 'Shen 说了哪两个数字?',
readingAnswer: 'four, five',
readingQuestion: 'Shen 接着说了哪两个数字?',
readingAnswer: 'Four, five',
readingOptions: ['Four, five', 'One, two', 'Three, four'],
writingPrompt: '把 0、1、2、3 写成英文。',
writingExample: 'zero, one, two, three',
independentPrompt: '不看帮助,说出三个 0 到 5 的数字。',
@@ -711,9 +760,10 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '听到哪一组数字?',
answers: ['678', '687', '768'],
speaking: 'My phone is here.',
reading: 'Mia: What is this?\nShen: It is a phone.',
readingQuestion: '这是什么',
readingAnswer: 'a phone',
reading: 'Mia: Six, seven, eight.\nShen: Nine, ten.',
readingQuestion: 'Shen 最后说了哪两个数字',
readingAnswer: 'Nine, ten',
readingOptions: ['Nine, ten', 'Six, seven', 'Seven, eight'],
writingPrompt: '把 6、7、8 写成英文。',
writingExample: 'six, seven, eight',
independentPrompt: '不看帮助,说出三个 6 到 10 的数字。',
@@ -724,9 +774,11 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '号码是多少?',
answers: ['138', '183', '318'],
speaking: 'My number is one-three-eight.',
reading: 'Mia: Whats your phone number?\nShen: One-three-eight.',
readingQuestion: 'Shen 的号码是?',
readingAnswer: '138',
reading:
'Mia: Whats your phone number?\nShen: My number is five-zero-two.\nMia: Five-two-zero?\nShen: No. Five-zero-two.',
readingQuestion: 'Shen 最后确认的号码是哪一个?',
readingAnswer: '502',
readingOptions: ['502', '520', '205'],
writingPrompt: '把虚拟号码 139 写成英文。',
writingExample: 'one-three-nine',
independentPrompt: '不看句框,说出一个三位虚拟号码。',
@@ -737,9 +789,11 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '今天星期几?',
answers: ['星期一', '星期二', '星期三'],
speaking: 'It is Monday.',
reading: 'Mia: What day is it?\nShen: It is Tuesday.',
readingQuestion: '今天星期几?',
reading:
'Mia: What day is it today? Is it Monday?\nShen: No. It is Tuesday.',
readingQuestion: 'Mia 猜错了,今天其实是星期几?',
readingAnswer: 'Tuesday',
readingOptions: ['Tuesday', 'Monday', 'Wednesday'],
writingPrompt: '写一句今天是星期一、二或三。',
writingExample: 'It is Monday.',
independentPrompt: '不看帮助,说出星期一、二或三。',
@@ -750,9 +804,10 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '今天星期几?',
answers: ['星期五', '星期四', '星期日'],
speaking: 'It is Friday.',
reading: 'Mia: What day is it?\nShen: It is Sunday.',
readingQuestion: '今天星期几',
reading: 'Mia: Is it Saturday today?\nShen: No. It is Sunday.',
readingQuestion: 'Shen 说今天是哪一天',
readingAnswer: 'Sunday',
readingOptions: ['Sunday', 'Saturday', 'Friday'],
writingPrompt: '写一句今天是星期四到日。',
writingExample: 'It is Friday.',
independentPrompt: '不看帮助,说出星期四到日。',
@@ -763,9 +818,11 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '是什么时间?',
answers: ['三点', '一点', '星期三'],
speaking: 'It is three oclock.',
reading: 'Mia: What time is it?\nShen: It is three oclock.',
readingQuestion: '几点?',
readingAnswer: 'three oclock',
reading:
'Mia: What time is it? Is it two oclock?\nShen: No. It is three oclock.',
readingQuestion: 'Shen 说现在是几点?',
readingAnswer: 'Three oclock',
readingOptions: ['Three oclock', 'Two oclock', 'One oclock'],
writingPrompt: '写一句整点时间。',
writingExample: 'It is three oclock.',
independentPrompt: '不看帮助,说一个整点时间。',
@@ -1072,13 +1129,116 @@ class LessonDialogue {
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;
}
/// Matches whole words only: the old `text.contains('it')` accepted almost any
/// sentence, including ones that never performed the task.
bool _containsTerm(String text, String term) {
if (term.contains(' + ')) {
return term.split(' + ').every((part) => _containsTerm(text, part.trim()));
}
if (term.startsWith('#')) return _matchesStructure(text, term);
final escaped = RegExp.escape(term.toLowerCase());
return RegExp('(?<![a-z])$escaped(?![a-z])').hasMatch(text);
}
bool _matchesStructure(String text, String token) => switch (token) {
// Three or more letters said one by one, e.g. "S-H-E-N" or "s h e n".
'#spelling' => RegExp(r'(?:^|[^a-z])[a-z](?:[ -][a-z]){2,}').hasMatch(text),
// Any spoken or written digit.
'#digit' => RegExp(
r'(?<![a-z])(zero|one|two|three|four|five|six|seven|eight|nine|ten)(?![a-z])|[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,
};
String _normalizeDialogueInput(String response) =>
response.toLowerCase().replaceAll('\u2019', "'").replaceAll('\u2018', "'");
/// 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 = _normalizeDialogueInput(response);
if (stage < 0 || stage >= script.requiredTerms.length) {
// No declared requirement: accept anything that is actually a word.
return RegExp(r'[a-z]{2,}').hasMatch(text);
}
final terms = script.requiredTerms[stage];
if (terms.isEmpty) return RegExp(r'[a-z]{2,}').hasMatch(text);
return terms.any((term) => _containsTerm(text, term));
}
String dialogueTaskLabel(LessonDialogue script, int stage) =>
stage >= 0 && stage < script.taskLabels.length
? script.taskLabels[stage]
: '完成本轮任务';
/// The free "初次见面" scene. It lives next to the lesson dialogues so its
/// tasks are validated and summarised by the same rules.
const a0MeetDialogue = LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: [
'Hi! My name is Mia. What\u2019s your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'I\u2019m good, thanks. Now ask me one question!',
],
hints: [
'My name is Alex.',
'I\u2019m from Hong Kong.',
'I\u2019m good, thanks. / I like coffee.',
'What\u2019s your name? / Where are you from?',
],
translations: [
'嗨!我叫 Mia。你叫什么名字?',
'很高兴认识你。你来自哪里?',
'很好!你今天怎么样?或者你喜欢什么?',
'我很好,谢谢。现在请问我一个问题!',
],
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
["i'm from", 'i am from', 'from + #word'],
[
'good',
'okay',
'ok',
'fine',
'great',
'tired',
'i like',
'i love',
],
['#question'],
],
taskLabels: ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
);
const a0Dialogues = <String, LessonDialogue>{
'a0-01': LessonDialogue(
goal: '问候、介绍姓名并回应见面问候',
@@ -1100,6 +1260,13 @@ const a0Dialogues = <String, LessonDialogue>{
'太棒了!再打一次招呼吧。',
'现在请问我的名字!',
],
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
['nice to meet you'],
['hello', 'hi', 'hey'],
["what's your name", 'what is your name', 'your name', '#question'],
],
taskLabels: ['说出你的名字', '回应 Nice to meet you', '再打一次招呼', '反问对方的名字'],
),
'a0-02': LessonDialogue(
goal: '介绍姓名并完整拼读名字',
@@ -1110,12 +1277,14 @@ const a0Dialogues = <String, LessonDialogue>{
'Now ask me my name!',
],
hints: ['My name is Alex.', 'A-L-E-X.', 'A-L-E-X.', 'Whats your name?'],
translations: [
'嗨!你叫什么名字?',
'那个怎么拼写?',
'谢谢。请再拼写一次。',
'现在请问我的名字!',
translations: ['嗨!你叫什么名字?', '那个怎么拼写?', '谢谢。请再拼写一次。', '现在请问我的名字!'],
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
['#spelling'],
['#spelling'],
["what's your name", 'what is your name', 'your name', '#question'],
],
taskLabels: ['说出你的名字', '拼读你的名字', '再拼读一次', '反问对方的名字'],
),
'a0-03': LessonDialogue(
goal: '询问状态、回答并反问',
@@ -1132,6 +1301,13 @@ const a0Dialogues = <String, LessonDialogue>{
'我还好。请再说一次你的状态。',
'现在请说再见!',
],
requiredTerms: [
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
['how are you', '#question'],
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
['bye', 'goodbye', 'see you'],
],
taskLabels: ['说出你今天的状态', '反问对方的状态', '再说一次你的状态', '说再见'],
),
'a0-04': LessonDialogue(
goal: '报告一个虚拟三位号码并确认',
@@ -1153,6 +1329,13 @@ const a0Dialogues = <String, LessonDialogue>{
'请再说一遍这三个数字。',
'现在请问我的电话号码!',
],
requiredTerms: [
['my number is + #digit', "my number's + #digit", 'number is + #digit'],
['yes', 'no', "that's right", 'right', 'correct'],
['#digit'],
["what's your phone number", 'what is your phone number', 'your phone number', 'your number'],
],
taskLabels: ['报出一个三位号码', '确认或纠正听到的号码', '再说一次这三个数字', '反问对方的号码'],
),
'a0-05': LessonDialogue(
goal: '询问并说出一个物品',
@@ -1169,6 +1352,13 @@ const a0Dialogues = <String, LessonDialogue>{
'请问我:What’s this(这是什么)?',
'太棒了!再说一个物品吧。',
],
requiredTerms: [
["it's a", 'it is a', "it's an", 'it is an'],
["it's a", 'it is a', "it's an", 'it is an'],
["what's this", 'what is this'],
["it's a", 'it is a', "it's an", 'it is an'],
],
taskLabels: ['说出这个物品', '完整说出 Its a … 句型', '反问 Whats this', '再说一个物品'],
),
'a0-06': LessonDialogue(
goal: '说来自哪里并反问',
@@ -1190,6 +1380,13 @@ const a0Dialogues = <String, LessonDialogue>{
'请再说一次你来自哪里。',
'请说再见!',
],
requiredTerms: [
["i'm from", 'i am from', 'from + #word'],
['where are you from', "where're you from", 'where are you'],
["i'm from", 'i am from', 'from + #word'],
['bye', 'goodbye', 'see you'],
],
taskLabels: ['说出你来自哪里', '反问对方来自哪里', '再说一次你来自哪里', '说再见'],
),
'a0-07': LessonDialogue(
goal: '介绍一位家人或朋友',
@@ -1211,6 +1408,13 @@ const a0Dialogues = <String, LessonDialogue>{
'请问我:Who is this(这是谁)?',
'请再介绍一个人。',
],
requiredTerms: [
['this is my', 'this is'],
['this is my', 'this is'],
['who is this', "who's this"],
['this is my', 'this is'],
],
taskLabels: ['介绍一位家人或朋友', '完整说出 This is my … 句型', '反问 Who is this', '再介绍一个人'],
),
'a0-08': LessonDialogue(
goal: '说明星期或整点',
@@ -1226,12 +1430,14 @@ const a0Dialogues = <String, LessonDialogue>{
'Its three oclock.',
'What day is it?',
],
translations: [
'今天星期几?',
'现在几点了?',
'请说一个完整的时间句子。',
'现在请问我今天是星期几!',
translations: ['今天星期几?', '现在几点了?', '请说一个完整的时间句子。', '现在请问我今天是星期几!'],
requiredTerms: [
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
["o'clock", 'oclock', "#digit + o'clock"],
["it's + o'clock", "it is + o'clock", "it's + #digit", 'it is + #digit'],
['what day is it', 'what day', '#question'],
],
taskLabels: ['说出今天星期几', '说出现在几点', '说一个完整的时间句子', '反问今天星期几'],
),
'a0-09': LessonDialogue(
goal: '表达喜好、回答和反问',
@@ -1242,12 +1448,14 @@ const a0Dialogues = <String, LessonDialogue>{
'Say your like one more time.',
],
hints: ['I like tea.', 'Yes, I do.', 'Do you like tea?', 'I like tea.'],
translations: [
'你喜欢什么?',
'你喜欢茶吗?',
'现在请问我喜欢什么。',
'请再说一次你的喜好。',
translations: ['你喜欢什么?', '你喜欢茶吗?', '现在请问我喜欢什么。', '请再说一次你的喜好。'],
requiredTerms: [
['i like', 'i love'],
['yes', 'no', 'i do', "i don't", 'i do not'],
['do you like', '#question'],
['i like', 'i love'],
],
taskLabels: ['说出你喜欢什么', '回答是否喜欢', '反问对方的喜好', '再说一次你的喜好'],
),
'a0-10': LessonDialogue(
goal: '请求重复或放慢语速,并完成基础沟通',
@@ -1269,6 +1477,13 @@ const a0Dialogues = <String, LessonDialogue>{
'现在请问我的名字或我来自哪里。',
'请说一件你喜欢的事物。',
],
requiredTerms: [
['say that again', 'again', 'pardon'],
['slowly', 'slow'],
["what's your name", 'what is your name', 'where are you from', 'your name', '#question'],
['i like', 'i love'],
],
taskLabels: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出一件你喜欢的事物'],
),
};
@@ -1277,37 +1492,52 @@ const a0SegmentDialogues = <String, LessonDialogue>{
goal: '听辨并说出 0 到 5',
prompts: ['Say zero, one, two.', 'Now say three, four, five.'],
hints: ['zero, one, two', 'three, four, five'],
translations: ['请说 zero, one, two012)。', '现在请说 three, four, five345)。'],
translations: [
'请说 zero, one, two012)。',
'现在请说 three, four, five345)。',
],
taskLabels: ['说出 zero, one, two', '说出 three, four, five'],
),
'a0-04-b': LessonDialogue(
goal: '听辨 6 到 10 并认识 phone',
prompts: ['Say six, seven, eight.', 'What is this? Say: It is a phone.'],
hints: ['six, seven, eight', 'It is a phone.'],
translations: ['请说 six, seven, eight678)。', '这是什么?请说:It is a phone(这是一部手机)。'],
translations: [
'请说 six, seven, eight678)。',
'这是什么?请说:It is a phone(这是一部手机)。',
],
taskLabels: ['说出 six, seven, eight', '说出 It is a phone.'],
),
'a0-04-c': LessonDialogue(
goal: '询问并报告三位号码',
prompts: ['What is your phone number?', 'Say a three-digit number again.'],
hints: ['My number is one-three-eight.', 'one-three-eight'],
translations: ['你的电话号码是多少?', '请再说一次三位数字。'],
taskLabels: ['报出你的三位号码', '再说一次三位数字'],
),
'a0-08-a': LessonDialogue(
goal: '询问并说出星期一到三',
prompts: ['What day is it?', 'Say Monday, Tuesday, or Wednesday.'],
hints: ['What day is it?', 'It is Monday.'],
translations: ['今天星期几?', '请说 Monday, Tuesday, 或 Wednesday(周一、周二或周三)。'],
taskLabels: ['问今天星期几', '说出周一到周三中的一天'],
),
'a0-08-b': LessonDialogue(
goal: '说出星期四到日',
prompts: ['What day is it?', 'Say Thursday, Friday, Saturday, or Sunday.'],
hints: ['What day is it?', 'It is Friday.'],
translations: ['今天星期几?', '请说 Thursday, Friday, Saturday, 或 Sunday(周四、周五、周六或周日)。'],
translations: [
'今天星期几?',
'请说 Thursday, Friday, Saturday, 或 Sunday(周四、周五、周六或周日)。',
],
taskLabels: ['问今天星期几', '说出周四到周日中的一天'],
),
'a0-08-c': LessonDialogue(
goal: '询问并说出整点',
prompts: ['What time is it?', 'Say one full time sentence.'],
hints: ['What time is it?', 'It is three oclock.'],
translations: ['现在几点了?', '请说一个完整的时间句子。'],
taskLabels: ['问现在几点', '说一个完整的时间句子'],
),
};
@@ -1315,3 +1545,21 @@ LessonDialogue dialogueByLessonId(String id) => a0Dialogues[id]!;
LessonDialogue dialogueBySegmentId(String segmentId, String lessonId) =>
a0SegmentDialogues[segmentId] ?? dialogueByLessonId(lessonId);
/// The English a learner has actually met up to and including [lessonId].
/// The AI partner is told to stay inside this list so a beginner never gets an
/// answer built from words the course has not taught yet.
List<String> taughtLanguageUpTo(String lessonId) {
final result = <String>[];
for (final entry in a0TargetItemIdsByLesson.entries) {
for (final id in entry.value) {
final word = a0CoreItems[id];
if (word != null) result.add(word);
}
if (entry.key == lessonId) break;
}
return result;
}
/// Everything A0 teaches, for the free scene that mixes all topics.
List<String> get allTaughtLanguage => a0CoreItems.values.toList();
+147 -36
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -10,50 +11,137 @@ class SherpaSttService {
sherpa_onnx.OfflineRecognizer? _recognizer;
bool _isInitialized = false;
bool _isInitializing = false;
Future<bool>? _initFuture;
Future<void> _transcribeLock = Future.value();
bool get isReady => _isInitialized && _recognizer != null;
/// Initializes SenseVoice-Small ONNX bindings and unpacks bundled model assets to local disk if needed.
Future<bool> initialize({String? nativeLibDir}) async {
if (_isInitialized) return true;
if (_isInitializing) return false;
_isInitializing = true;
/// Resolves the filesystem path for the offline SenseVoice model files.
/// First checks direct asset filesystem locations (on macOS app bundle, development, tests)
/// to avoid copying 228MB into user Documents.
/// Falls back to unpacking from rootBundle into the app documents directory (on mobile / Android APK).
Future<Map<String, String>?> _resolveModelFiles() async {
const modelFilename = 'model.int8.onnx';
const tokensFilename = 'tokens.txt';
try {
// 1. Direct filesystem candidate paths
final candidateDirs = <String>[];
// Current working directory (unit test runner / local flutter dev)
candidateDirs.add('assets/models/sense_voice');
candidateDirs.add('kouyu_english/assets/models/sense_voice');
// Inside macOS App bundle
if (Platform.isMacOS) {
try {
sherpa_onnx.initBindings(nativeLibDir);
} catch (e) {
debugPrint('[SherpaSttService] initBindings warning: $e');
}
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');
} 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,
};
}
}
// 2. Unpack bundled asset to app documents directory (e.g. Android APK)
try {
final docDir = await getApplicationDocumentsDirectory();
final modelDir = Directory('${docDir.path}/sense_voice_models');
if (!await modelDir.exists()) {
await modelDir.create(recursive: true);
}
final modelFiles = [
'model.int8.onnx',
'tokens.txt',
];
final targetModelFile = File('${modelDir.path}/$modelFilename');
final targetTokensFile = File('${modelDir.path}/$tokensFilename');
for (final filename in modelFiles) {
final targetFile = File('${modelDir.path}/$filename');
if (!await targetFile.exists() || (await targetFile.length()) == 0) {
final ByteData data = await rootBundle.load('assets/models/sense_voice/$filename');
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await targetFile.writeAsBytes(bytes, flush: true);
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}...');
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);
await tmpFile.writeAsBytes(bytes, flush: true);
if (await tmpFile.length() > 200 * 1024 * 1024) {
if (await targetModelFile.exists()) await targetModelFile.delete();
await tmpFile.rename(targetModelFile.path);
} else {
throw StateError('Extracted model file is incomplete');
}
}
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);
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,
};
} catch (e, stack) {
debugPrint('[SherpaSttService] Failed to unpack bundled model: $e\n$stack');
return null;
}
}
/// Initializes SenseVoice-Small ONNX bindings and preloads the recognizer.
/// Deduplicates concurrent callers to wait on the same initialization.
Future<bool> initialize({String? nativeLibDir}) {
if (_isInitialized && _recognizer != null) return Future.value(true);
if (_initFuture != null) return _initFuture!;
_initFuture = _doInitialize(nativeLibDir: nativeLibDir);
return _initFuture!;
}
Future<bool> _doInitialize({String? nativeLibDir}) async {
try {
// Auto-detect native library directory on macOS desktop app bundle
String? resolvedLibDir = nativeLibDir;
if (resolvedLibDir == null && Platform.isMacOS) {
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()) {
resolvedLibDir = frameworksDir.path;
}
} catch (_) {}
}
try {
sherpa_onnx.initBindings(resolvedLibDir);
} catch (e) {
debugPrint('[SherpaSttService] initBindings notice: $e');
}
final resolved = await _resolveModelFiles();
if (resolved == null) {
debugPrint('[SherpaSttService] Model files could not be located or extracted.');
return false;
}
final modelConfig = sherpa_onnx.OfflineModelConfig(
senseVoice: sherpa_onnx.OfflineSenseVoiceModelConfig(
model: '${modelDir.path}/model.int8.onnx',
language: 'auto',
model: resolved['model']!,
language: 'en',
useInverseTextNormalization: true,
),
tokens: '${modelDir.path}/tokens.txt',
tokens: resolved['tokens']!,
numThreads: 2,
debug: false,
);
@@ -65,19 +153,35 @@ class SherpaSttService {
_recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
_isInitialized = true;
_isInitializing = false;
debugPrint('[SherpaSttService] SenseVoice-Small ONNX ASR engine initialized successfully.');
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');
_isInitializing = false;
_isInitialized = false;
return false;
} finally {
if (!_isInitialized) {
_initFuture = null;
}
}
}
/// Transcribes a local 16kHz mono WAV audio file using SenseVoice-Small.
/// Thread-safe: serializes native C++ decoding to prevent concurrency crashes.
Future<String?> transcribeWav(String wavPath) async {
final prevLock = _transcribeLock;
final completer = Completer<void>();
_transcribeLock = completer.future;
try {
await prevLock;
return await _doTranscribeWav(wavPath);
} finally {
completer.complete();
}
}
Future<String?> _doTranscribeWav(String wavPath) async {
try {
if (!_isInitialized) {
final ready = await initialize();
@@ -97,30 +201,36 @@ class SherpaSttService {
}
final stream = _recognizer!.createStream();
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
_recognizer!.decode(stream);
final result = _recognizer!.getResult(stream);
stream.free();
try {
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
_recognizer!.decode(stream);
final result = _recognizer!.getResult(stream);
final rawText = result.text.trim();
if (rawText.isEmpty) return null;
final rawText = result.text.trim();
if (rawText.isEmpty) return null;
return _cleanText(rawText);
return _cleanText(rawText);
} finally {
stream.free();
}
} catch (e) {
debugPrint('[SherpaSttService] Transcribe error: $e');
return null;
}
}
/// Cleans and formats raw recognized SenseVoice text (strips emotion/event tags, normalizes casing).
/// Cleans and formats raw recognized SenseVoice text.
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();
if (cleaned.isEmpty) return cleaned;
// Strip leading punctuation often inserted by Whisper/SenseVoice
cleaned = cleaned.replaceFirst(RegExp(r'^[\s,.:;!?~]+'), '').trim();
if (cleaned.isEmpty) return cleaned;
// Normalize consecutive spaces
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
// Capitalize first character if it's a letter
// Capitalize first character if it's an English letter
if (cleaned.isNotEmpty && cleaned[0].toLowerCase() != cleaned[0].toUpperCase()) {
cleaned = cleaned[0].toUpperCase() + cleaned.substring(1);
}
@@ -133,5 +243,6 @@ class SherpaSttService {
} catch (_) {}
_recognizer = null;
_isInitialized = false;
_initFuture = null;
}
}
+24 -1
View File
@@ -46,6 +46,7 @@ class VoiceService {
Future<void> speak(String text, {bool slow = false}) async {
try {
await stopRecordingPlayback();
await _initTts();
await _tts.stop();
try {
@@ -73,7 +74,22 @@ class VoiceService {
Future<bool> hasRecordPermission() => _recorder.hasPermission();
Future<bool> isRecording() async {
try {
return await _recorder.isRecording();
} catch (_) {
return false;
}
}
Future<bool> startRecording() async {
await stopSpeaking();
await stopRecordingPlayback();
try {
if (await _recorder.isRecording()) {
await _recorder.stop();
}
} catch (_) {}
if (!await _recorder.hasPermission()) return false;
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
@@ -93,7 +109,14 @@ class VoiceService {
return true;
}
Future<String?> stopRecording() => _recorder.stop();
Future<String?> stopRecording() async {
try {
if (await _recorder.isRecording()) {
return await _recorder.stop();
}
} catch (_) {}
return null;
}
Future<void> playRecording(String path, {VoidCallback? onComplete}) async {
await stopRecordingPlayback();