feat: complete kouyu_english app codebase, A0 specifications and .gitignore

This commit is contained in:
shen
2026-09-15 15:58:33 +08:00
parent b8072673d8
commit 37c86f7ecb
136 changed files with 19042 additions and 0 deletions
+194
View File
@@ -0,0 +1,194 @@
/// 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. These are a safe fallback when a dynamic AI variant is unavailable.
CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
final base = coreReviewTemplate(id);
final lead = switch (variantIndex % 3) {
0 => '换一个人物或地点,',
1 => '在新的生活情境中,',
_ => '不看上次答案,',
};
return CoreReviewTemplate(
prompt: '$lead${base.prompt}',
hint: base.hint,
skill: base.skill,
);
}
+730
View File
@@ -0,0 +1,730 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'models.dart';
import 'generated_content.dart';
import 'a0_core.dart';
class AiConnectionResult {
const AiConnectionResult({required this.ok, required this.message});
final bool ok;
final String message;
}
/// Stores the secret separately from normal app settings. Compatible endpoints
/// use the OpenAI chat-completions shape, including a user-run CLIProxyAPI.
class AiService {
AiService._();
static final instance = AiService._();
static const _keyName = 'ai_api_key';
final _secureStorage = const FlutterSecureStorage();
Future<void> saveApiKey(String value) async {
if (value.trim().isEmpty) {
await _secureStorage.delete(key: _keyName);
} else {
await _secureStorage.write(key: _keyName, value: value.trim());
}
}
Future<bool> hasApiKey() async =>
(await _secureStorage.read(key: _keyName))?.isNotEmpty ?? false;
/// Returns a display-only Chinese gloss for an unknown word or phrase.
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
Future<String?> temporaryDefinition({
required AiProviderType provider,
required String endpoint,
required String model,
required String text,
}) async {
if (provider == AiProviderType.mock ||
text.length > 120 ||
text.trim().isEmpty) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (key == null ||
key.isEmpty ||
model.trim().isEmpty ||
uri == null ||
uri.scheme != 'https') {
return null;
}
const instruction =
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
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\nText: $text'},
],
},
],
'generationConfig': {
'maxOutputTokens': 100,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{
'role': 'user',
'content': '$instruction\nText: $text',
},
],
'max_tokens': 100,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final raw = _extractResponseContent(provider, response.body);
final data = raw == null ? null : jsonDecode(raw);
final definition = data is Map ? data['definition'] : null;
return definition is String &&
definition.trim().isNotEmpty &&
definition.length <= 160
? definition.trim()
: null;
} catch (_) {
return null;
}
}
Future<AiConnectionResult> testConnection({
required AiProviderType provider,
required String endpoint,
required String model,
}) async {
if (provider == AiProviderType.mock) {
return const AiConnectionResult(ok: true, message: '内置练习模式可用,无需网络。');
}
const probe =
'Return JSON only: {"reply":"Hi!","slots":{},"evidence":[],"suggestsComplete":false,"feedback":null}';
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty) {
return const AiConnectionResult(ok: false, message: '请先保存 API Key。');
}
if (endpoint.trim().isEmpty || model.trim().isEmpty) {
return const AiConnectionResult(
ok: false,
message: '请填写 Base URL 和模型名称。',
);
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') {
return const AiConnectionResult(ok: false, message: '请使用有效的 HTTPS 地址。');
}
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': probe},
],
},
],
'generationConfig': {
'maxOutputTokens': 80,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': probe},
],
'max_tokens': 80,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode >= 200 && response.statusCode < 300) {
final content = _extractResponseContent(provider, response.body);
if (_decodeDialogueResponse(content) != null) {
return const AiConnectionResult(ok: true, message: '连接成功,结构化对话可用。');
}
return const AiConnectionResult(
ok: false,
message: '服务可连接,但未返回应用需要的结构化对话格式。',
);
}
if (response.statusCode == 401 || response.statusCode == 403) {
return const AiConnectionResult(
ok: false,
message: '鉴权失败,请检查 API Key。',
);
}
return AiConnectionResult(
ok: false,
message: '服务返回 ${response.statusCode},请检查地址和模型。',
);
} catch (_) {
return const AiConnectionResult(
ok: false,
message: '无法连接服务。请检查网络、地址或局域网连通性。',
);
}
}
Future<DialogueAiResponse?> dialogueReply({
required AiProviderType provider,
required String endpoint,
required String model,
required List<Map<String, String>> history,
required String requiredTask,
}) async {
if (provider == AiProviderType.mock) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') {
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), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
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
? {
'systemInstruction': {
'parts': [
{'text': '$system$requiredTask'},
],
},
'contents': history
.map(
(turn) => {
'role': turn['role'] == 'assistant'
? 'model'
: 'user',
'parts': [
{'text': turn['content']},
],
},
)
.toList(),
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 60,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'system', 'content': '$system$requiredTask'},
...history,
],
'temperature': 0.3,
'max_tokens': 60,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
return _decodeDialogueResponse(
_extractResponseContent(provider, response.body),
);
} catch (_) {
return null;
}
}
/// Generates only a bounded variant of an existing review target. A network
/// response is never exposed unless [decodeGeneratedReviewVariant] accepts it.
Future<GeneratedReviewVariant?> generateReviewVariant({
required AiProviderType provider,
required String endpoint,
required String model,
required String targetItemId,
required String basePrompt,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return null;
final instruction =
'''Return JSON only with exactly: schemaVersion, variantId, targetItemId, prompt, expectedAnswer.
schemaVersion must be "review-variant-1". targetItemId must be "$targetItemId".
Make one beginner A0 English review prompt. Do not add explanations, translations, markdown, or fields.
${repairAttempt ? 'The previous response was invalid. Fix the JSON schema exactly.' : ''}
Base task: $basePrompt''';
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},
],
},
],
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 180,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.3,
'max_tokens': 180,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final raw = _extractResponseContent(provider, response.body);
final decoded = raw == null
? null
: decodeGeneratedReviewVariant(
raw,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateReviewVariant(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
basePrompt: basePrompt,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Requests a short, teaching-oriented writing suggestion. The caller must
/// still run its local task validator; this response has no authority to
/// mark an answer correct or change mastery.
Future<WritingAiFeedback?> writingFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String lessonId,
required String taskPrompt,
required String answer,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') 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.
${repairAttempt ? 'The previous response was invalid. Return the exact JSON schema now.' : ''}
Task: $taskPrompt
Learner answer: $answer''';
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},
],
},
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 240,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 240,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final decoded = decodeWritingAiFeedback(
_extractResponseContent(provider, response.body) ?? '',
expectedLessonId: lessonId,
);
if (decoded != null || repairAttempt) return decoded;
return writingFeedback(
provider: provider,
endpoint: endpoint,
model: model,
lessonId: lessonId,
taskPrompt: taskPrompt,
answer: answer,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Generates one bounded A0 reinforcement lesson for a stable core target.
/// It remains unpublished until local schema validation accepts it.
Future<GeneratedLesson?> generateAdaptiveLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required String targetItemId,
required String targetLabel,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock ||
!a0CoreItems.containsKey(targetItemId)) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') 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.' : ''}';
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},
],
},
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 1200,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 1200,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final decoded = decodeGeneratedLesson(
_extractResponseContent(provider, response.body) ?? '',
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateAdaptiveLesson(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
targetLabel: targetLabel,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Separate audit request: it does not receive the generation prompt and
/// can only approve/reject a previously client-validated lesson.
Future<bool> auditGeneratedLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required GeneratedLesson lesson,
}) async {
if (provider == AiProviderType.mock) return false;
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return false;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return false;
final lessonJson = jsonEncode({
'lessonId': lesson.lessonId,
'stageVersion': lesson.stageVersion,
'targetItemIds': lesson.targetItemIds,
'tasks': lesson.tasks
.map(
(task) => {
'skill': task.skill,
'type': task.type,
'prompt': task.prompt,
'stimulus': task.stimulus,
'answer': task.answer,
'answerSpec': {
'requiredAnyPhrases': task.localAnswerSpec.requiredAnyPhrases,
'acceptedAnswers': task.localAnswerSpec.acceptedAnswers,
'forbiddenPhrases': task.localAnswerSpec.forbiddenPhrases,
},
},
)
.toList(),
});
final instruction =
'Audit this A0 English lesson independently. Check naturalness, that every answer follows its stimulus, that it stays A0, and that each task is solvable without giving the answer. Return JSON only with exactly schemaVersion, approved, reason. schemaVersion must be lesson-audit-1. approved is boolean and reason is a short Chinese string. Lesson: $lessonJson';
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},
],
},
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 120,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0,
'max_tokens': 120,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return false;
}
return _decodeLessonAudit(
_extractResponseContent(provider, response.body),
);
} catch (_) {
return false;
}
}
bool _decodeLessonAudit(String? raw) {
if (raw == null || raw.length > 600) return false;
try {
final data = jsonDecode(raw);
return data is Map<String, dynamic> &&
data.length == 3 &&
data['schemaVersion'] == 'lesson-audit-1' &&
data['approved'] == true &&
data['reason'] is String &&
(data['reason'] as String).length <= 160;
} catch (_) {
return false;
}
}
String? _extractResponseContent(AiProviderType provider, String body) {
try {
final data = jsonDecode(body) as Map<String, dynamic>;
if (provider == AiProviderType.gemini) {
final candidate = (data['candidates'] as List?)?.firstOrNull as Map?;
final candidateContent = candidate?['content'] as Map?;
final parts = candidateContent?['parts'] as List?;
return (parts?.firstOrNull as Map?)?['text'] as String?;
}
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
return (choice?['message'] as Map?)?['content'] as String?;
} catch (_) {
return null;
}
}
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
try {
final data = jsonDecode(raw) 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)) {
return null;
}
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;
}
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);
}
return DialogueAiResponse(
reply: reply.trim(),
slots: slots,
evidence: evidence,
suggestsComplete: suggestsComplete,
feedback: feedback as String?,
);
} catch (_) {
return null;
}
}
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
abstract final class AppColors {
static const paper = Color(0xFFF5F7F3);
static const surface = Colors.white;
static const surfaceMuted = Color(0xFFF1F5F0);
static const ink = Color(0xFF19211B);
static const muted = Color(0xFF647268);
static const line = Color(0xFFD8E1D9);
static const green = Color(0xFF176B46);
static const softGreen = Color(0xFFE2F3E8);
static const warm = Color(0xFFFFF0E3);
static const warmInk = Color(0xFF9E4C18);
}
ThemeData buildAppTheme() {
final scheme =
ColorScheme.fromSeed(
seedColor: AppColors.green,
brightness: Brightness.light,
).copyWith(
surface: AppColors.surface,
onSurface: AppColors.ink,
primary: AppColors.green,
onPrimary: Colors.white,
outline: AppColors.line,
);
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
scaffoldBackgroundColor: AppColors.paper,
fontFamily: 'PingFang SC',
appBarTheme: const AppBarTheme(
backgroundColor: AppColors.surface,
foregroundColor: AppColors.ink,
elevation: 0,
centerTitle: false,
),
textTheme: const TextTheme(
headlineMedium: TextStyle(
color: AppColors.ink,
fontSize: 26,
height: 1.25,
fontWeight: FontWeight.w600,
letterSpacing: -0.6,
),
titleLarge: TextStyle(
color: AppColors.ink,
fontSize: 18,
fontWeight: FontWeight.w600,
),
bodyLarge: TextStyle(color: AppColors.ink, fontSize: 16, height: 1.5),
bodyMedium: TextStyle(color: AppColors.muted, fontSize: 14, height: 1.5),
),
);
}
+302
View File
@@ -0,0 +1,302 @@
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. Im Mia.|自我介绍|买东西|问时间|0',
'How do you spell your name?|问姓名拼写|问地点|问时间|0',
'Im tired today.|很好|累|来自哪里|1',
'seven|6|7|8|1',
'one-three-eight|138|183|318|0',
'Its a key.|书|钥匙|包|1',
'Im from Hong Kong.|香港|伦敦|北京|0',
'This is my mother.|妈妈|姐姐|朋友|0',
'It is Monday.|星期一|星期二|星期三|0',
'I like tea.|茶|咖啡|电影|0',
],
const [
'Mia: Im from Hong Kong.\nShen: Im good.\nMia: My number is one-three-eight.|Mia 来自哪里?|Hong Kong|London|0',
'Mia: It is Monday.\nShen: It is three oclock.|几点?|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: Whats this?\nShen: Its 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',
'Im okay.|很好|还可以|累|1',
'four|3|4|5|1',
'two-nine-six|296|269|926|0',
'Its a bag.|笔|包|水|1',
'Im from London.|伦敦|香港|北京|0',
'This is my sister.|姐姐|妈妈|朋友|0',
'It is Friday.|星期五|星期一|星期日|0',
'I like coffee.|茶|咖啡|音乐|1',
],
const [
'Mia: Im from London.\nLeo: Im okay.\nMia: My number is two-nine-six.|号码是多少?|296|269|0',
'Mia: It is Friday.\nLeo: It is six oclock.|几点?|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: Whats this?\nLeo: Its 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 oclock': 'six oclock',
'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 oclock': 'two oclock',
'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('', "'");
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool phrase(String value) => text.contains(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;
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,580 @@
import 'dart:convert';
import 'a0_core.dart';
import 'models.dart';
/// A bounded AI-authored variation of an existing review item. It never
/// creates a core identity and is only usable after local schema validation.
class GeneratedReviewVariant {
const GeneratedReviewVariant({
required this.variantId,
required this.targetItemId,
required this.prompt,
required this.expectedAnswer,
});
final String variantId;
final String targetItemId;
final String prompt;
final String expectedAnswer;
}
/// A bounded, AI-authored adaptive lesson. It is deliberately a data object,
/// not a free-form chat response: the client can validate every target and
/// task before deciding whether to cache or display it.
class GeneratedLessonTask {
const GeneratedLessonTask({
required this.taskId,
required this.skill,
required this.type,
required this.prompt,
required this.stimulus,
required this.answer,
required this.targetItemIds,
this.answerSpec,
});
final String taskId;
final String skill;
final String type;
final String prompt;
final String stimulus;
final String answer;
final List<String> targetItemIds;
final GeneratedAnswerSpec? answerSpec;
GeneratedAnswerSpec get localAnswerSpec =>
answerSpec ?? GeneratedAnswerSpec.referenceOnly(answer);
}
/// A finite, client-verifiable boundary for a generated answer. It supports
/// specific accepted variants and required information slots without handing
/// an open-ended semantic judgement to the AI provider.
class GeneratedAnswerSpec {
const GeneratedAnswerSpec({
required this.requiredAnyPhrases,
required this.acceptedAnswers,
this.forbiddenPhrases = const [],
});
factory GeneratedAnswerSpec.referenceOnly(String answer) =>
GeneratedAnswerSpec(
requiredAnyPhrases: [
[answer],
],
acceptedAnswers: [answer],
);
final List<List<String>> requiredAnyPhrases;
final List<String> acceptedAnswers;
final List<String> forbiddenPhrases;
}
class GeneratedLesson {
const GeneratedLesson({
required this.lessonId,
required this.revision,
required this.stageVersion,
required this.abilityIds,
required this.prerequisiteIds,
required this.targetItemIds,
required this.receptiveChunks,
required this.previewItemIds,
required this.estimatedMinutes,
required this.tasks,
});
final String lessonId;
final int revision;
final String stageVersion;
final List<String> abilityIds;
final List<String> prerequisiteIds;
final List<String> targetItemIds;
final List<String> receptiveChunks;
final List<String> previewItemIds;
final int estimatedMinutes;
final List<GeneratedLessonTask> tasks;
}
/// Conservative client-side check for audited, bounded adaptive tasks. It
/// accepts explicitly approved phrases or all required information slots,
/// ignoring harmless case/punctuation variation and allowing extra words.
/// This is intentionally unavailable for free-form AI content and never
/// trusts a model's own "correct" claim.
bool matchesAdaptiveLessonAnswer(GeneratedLessonTask task, String answer) {
final actual = _normalizeAnswer(answer);
if (actual.isEmpty) return false;
final spec = task.localAnswerSpec;
if (spec.forbiddenPhrases.any((phrase) => _containsPhrase(actual, phrase))) {
return false;
}
final accepted = spec.acceptedAnswers.any(
(phrase) => _containsPhrase(actual, phrase),
);
final slots =
spec.requiredAnyPhrases.isNotEmpty &&
spec.requiredAnyPhrases.every(
(alternatives) =>
alternatives.any((phrase) => _containsPhrase(actual, phrase)),
);
return accepted || slots;
}
String _normalizeAnswer(String value) => value
.toLowerCase()
.replaceAll(RegExp(r"[^a-z0-9']+"), ' ')
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
bool _containsPhrase(String normalizedAnswer, String phrase) {
final normalizedPhrase = _normalizeAnswer(phrase);
return normalizedPhrase.isNotEmpty &&
' $normalizedAnswer '.contains(' $normalizedPhrase ');
}
GeneratedReviewVariant? decodeGeneratedReviewVariant(
String raw, {
required String expectedTargetItemId,
}) {
if (raw.length > 1600) return null;
try {
final data = jsonDecode(raw);
if (data is! Map<String, dynamic> || data.length != 5) return null;
if (data['schemaVersion'] != 'review-variant-1') return null;
final variantId = data['variantId'];
final targetItemId = data['targetItemId'];
final prompt = data['prompt'];
final expectedAnswer = data['expectedAnswer'];
if (variantId is! String ||
targetItemId is! String ||
prompt is! String ||
expectedAnswer is! String ||
variantId.length > 80 ||
prompt.trim().isEmpty ||
prompt.length > 220 ||
expectedAnswer.trim().isEmpty ||
expectedAnswer.length > 120 ||
targetItemId != expectedTargetItemId ||
!a0CoreItems.containsKey(targetItemId)) {
return null;
}
return GeneratedReviewVariant(
variantId: variantId,
targetItemId: targetItemId,
prompt: prompt.trim(),
expectedAnswer: expectedAnswer.trim(),
);
} catch (_) {
return null;
}
}
GeneratedLesson? decodeGeneratedLesson(
String raw, {
required String expectedTargetItemId,
}) {
if (raw.length > 12000) return null;
try {
final data = jsonDecode(raw);
if (data is! Map<String, dynamic>) return null;
const required = {
'schemaVersion',
'lessonId',
'revision',
'stageVersion',
'source',
'status',
'abilityIds',
'prerequisiteIds',
'targetItemIds',
'receptiveChunks',
'newItemIds',
'previewItemIds',
'estimatedMinutes',
'tasks',
};
if (data.length != required.length ||
!data.keys.every(required.contains) ||
!(data['schemaVersion'] == 'lesson-1' ||
data['schemaVersion'] == 'lesson-2') ||
data['source'] != 'aiGenerated' ||
data['status'] != 'validated') {
return null;
}
final lessonId = data['lessonId'];
final revision = data['revision'];
final stageVersion = data['stageVersion'];
final estimatedMinutes = data['estimatedMinutes'];
final abilityIds = _stringList(data['abilityIds'], max: 4);
final prerequisites = _stringList(data['prerequisiteIds'], max: 8);
final targets = _stringList(data['targetItemIds'], max: 8);
final chunks = _stringList(data['receptiveChunks'], max: 2);
final newItems = _stringList(data['newItemIds'], max: 8);
final preview = _stringList(data['previewItemIds'], max: 8);
final rawTasks = data['tasks'];
if (lessonId is! String ||
!RegExp(r'^ai-a0-[a-z0-9-]{1,50}$').hasMatch(lessonId) ||
revision is! int ||
revision < 1 ||
stageVersion != 'A0-1.0' ||
estimatedMinutes is! int ||
estimatedMinutes < 8 ||
estimatedMinutes > 15 ||
abilityIds == null ||
prerequisites == null ||
targets == null ||
chunks == null ||
newItems == null ||
preview == null ||
rawTasks is! List ||
targets.length != 1 ||
targets.single != expectedTargetItemId ||
!a0CoreItems.containsKey(expectedTargetItemId) ||
newItems.isNotEmpty ||
preview.isNotEmpty ||
chunks.isNotEmpty ||
rawTasks.length != 4) {
return null;
}
final tasks = <GeneratedLessonTask>[];
const skills = {'listening', 'speaking', 'reading', 'writing'};
const types = {'listenChoice', 'repeat', 'readAnswer', 'writeAnswer'};
for (final rawTask in rawTasks) {
if (rawTask is! Map<String, dynamic>) return null;
const taskKeys = {
'taskId',
'skill',
'type',
'prompt',
'stimulus',
'answer',
'targetItemIds',
'answerSpec',
};
const legacyTaskKeys = {
'taskId',
'skill',
'type',
'prompt',
'stimulus',
'answer',
'targetItemIds',
};
final allowedTaskKeys = data['schemaVersion'] == 'lesson-2'
? taskKeys
: legacyTaskKeys;
if (rawTask.length != allowedTaskKeys.length ||
!rawTask.keys.every(allowedTaskKeys.contains)) {
return null;
}
final taskId = rawTask['taskId'];
final skill = rawTask['skill'];
final type = rawTask['type'];
final prompt = rawTask['prompt'];
final stimulus = rawTask['stimulus'];
final answer = rawTask['answer'];
final taskTargets = _stringList(rawTask['targetItemIds'], max: 1);
final answerSpec = _decodeAnswerSpec(
rawTask['answerSpec'],
fallbackAnswer: answer is String ? answer : '',
required: data['schemaVersion'] == 'lesson-2',
);
if (taskId is! String ||
taskId.length > 80 ||
skill is! String ||
!skills.contains(skill) ||
type is! String ||
!types.contains(type) ||
prompt is! String ||
prompt.trim().isEmpty ||
prompt.length > 260 ||
stimulus is! String ||
stimulus.trim().isEmpty ||
stimulus.length > 260 ||
answer is! String ||
answer.trim().isEmpty ||
answer.length > 160 ||
!_usesOnlyA0GeneratedWords(stimulus) ||
!_usesOnlyA0GeneratedWords(answer) ||
answerSpec == null ||
taskTargets == null ||
taskTargets.length != 1 ||
taskTargets.single != expectedTargetItemId) {
return null;
}
tasks.add(
GeneratedLessonTask(
taskId: taskId,
skill: skill,
type: type,
prompt: prompt.trim(),
stimulus: stimulus.trim(),
answer: answer.trim(),
targetItemIds: taskTargets,
answerSpec: answerSpec,
),
);
}
if (tasks.map((task) => task.skill).toSet().length != 4) return null;
return GeneratedLesson(
lessonId: lessonId,
revision: revision,
stageVersion: stageVersion,
abilityIds: abilityIds,
prerequisiteIds: prerequisites,
targetItemIds: targets,
receptiveChunks: chunks,
previewItemIds: preview,
estimatedMinutes: estimatedMinutes,
tasks: tasks,
);
} catch (_) {
return null;
}
}
String encodeGeneratedLesson(GeneratedLesson lesson) => jsonEncode({
'schemaVersion': 'lesson-2',
'lessonId': lesson.lessonId,
'revision': lesson.revision,
'stageVersion': lesson.stageVersion,
'source': 'aiGenerated',
'status': 'validated',
'abilityIds': lesson.abilityIds,
'prerequisiteIds': lesson.prerequisiteIds,
'targetItemIds': lesson.targetItemIds,
'receptiveChunks': lesson.receptiveChunks,
'newItemIds': const [],
'previewItemIds': lesson.previewItemIds,
'estimatedMinutes': lesson.estimatedMinutes,
'tasks': lesson.tasks
.map(
(task) => {
'taskId': task.taskId,
'skill': task.skill,
'type': task.type,
'prompt': task.prompt,
'stimulus': task.stimulus,
'answer': task.answer,
'targetItemIds': task.targetItemIds,
'answerSpec': _encodeAnswerSpec(task.localAnswerSpec),
},
)
.toList(),
});
Map<String, dynamic> _encodeAnswerSpec(GeneratedAnswerSpec spec) => {
'requiredAnyPhrases': spec.requiredAnyPhrases,
'acceptedAnswers': spec.acceptedAnswers,
'forbiddenPhrases': spec.forbiddenPhrases,
};
GeneratedAnswerSpec? _decodeAnswerSpec(
Object? value, {
required String fallbackAnswer,
required bool required,
}) {
if (value == null && !required && fallbackAnswer.trim().isNotEmpty) {
return GeneratedAnswerSpec.referenceOnly(fallbackAnswer);
}
if (value is! Map<String, dynamic> || value.length != 3) return null;
const keys = {'requiredAnyPhrases', 'acceptedAnswers', 'forbiddenPhrases'};
if (!value.keys.every(keys.contains)) return null;
final accepted = _stringList(value['acceptedAnswers'], max: 4);
final forbidden = _stringList(value['forbiddenPhrases'], max: 4);
final rawGroups = value['requiredAnyPhrases'];
if (accepted == null ||
accepted.isEmpty ||
forbidden == null ||
rawGroups is! List ||
rawGroups.isEmpty ||
rawGroups.length > 4) {
return null;
}
final groups = <List<String>>[];
for (final rawGroup in rawGroups) {
final group = _stringList(rawGroup, max: 4);
if (group == null || group.isEmpty) return null;
groups.add(group);
}
final phrases = [
...accepted,
...forbidden,
...groups.expand((group) => group),
];
if (phrases.any((phrase) => !_usesOnlyA0GeneratedWords(phrase))) return null;
final spec = GeneratedAnswerSpec(
requiredAnyPhrases: groups,
acceptedAnswers: accepted,
forbiddenPhrases: forbidden,
);
return matchesAdaptiveLessonAnswer(
GeneratedLessonTask(
taskId: 'validation',
skill: 'writing',
type: 'writeAnswer',
prompt: '验证',
stimulus: fallbackAnswer,
answer: fallbackAnswer,
targetItemIds: const ['A0-P01'],
answerSpec: spec,
),
fallbackAnswer,
)
? spec
: null;
}
/// Dynamic A0 reinforcement must not smuggle in a harder English word via a
/// stimulus or answer. Prompts may be Chinese; only learner-facing English is
/// constrained here. Names and places are fixed, non-personal demo values.
bool _usesOnlyA0GeneratedWords(String value) {
final words = RegExp(r"[A-Za-z]+(?:'[A-Za-z]+)?")
.allMatches(value.toLowerCase())
.map((match) => match.group(0)!.replaceAll("'", ''));
const allowed = {
'i',
'im',
'am',
'my',
'name',
'is',
'what',
'your',
'nice',
'to',
'meet',
'you',
'how',
'do',
'spell',
'that',
'hello',
'hi',
'good',
'okay',
'tired',
'thanks',
'yes',
'no',
'zero',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'ten',
'phone',
'number',
'it',
'a',
'book',
'pen',
'bag',
'key',
'where',
'from',
'this',
'mother',
'father',
'sister',
'brother',
'friend',
'day',
'today',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'time',
'oclock',
'like',
'water',
'coffee',
'tea',
'music',
'movies',
'please',
'say',
'again',
'speak',
'slowly',
'alex',
'mia',
'shen',
'hong',
'kong',
'beijing',
'shanghai',
};
return words.every(allowed.contains);
}
List<String>? _stringList(Object? value, {required int max}) {
if (value is! List || value.length > max) return null;
final result = <String>[];
for (final item in value) {
if (item is! String || item.trim().isEmpty || item.length > 120) {
return null;
}
result.add(item.trim());
}
return result.toSet().length == result.length ? result : null;
}
/// Validates the small feedback object before any AI wording reaches a
/// learner. The app treats an uncertain or malformed response as no feedback,
/// never as a language error.
WritingAiFeedback? decodeWritingAiFeedback(
String raw, {
required String expectedLessonId,
}) {
if (raw.length > 1800) return null;
try {
final data = jsonDecode(raw);
if (data is! Map<String, dynamic> || data.length != 6) return null;
if (data['schemaVersion'] != 'writing-feedback-1' ||
data['lessonId'] != expectedLessonId) {
return null;
}
final verdict = data['verdict'];
final feedback = data['feedback'];
final suggestion = data['suggestion'];
final missing = data['missing'];
if (verdict is! String ||
!const {'accepted', 'rewrite', 'uncertain'}.contains(verdict) ||
feedback is! String ||
feedback.trim().isEmpty ||
feedback.length > 240 ||
(suggestion != null &&
(suggestion is! String ||
suggestion.trim().isEmpty ||
suggestion.length > 180)) ||
missing is! List ||
missing.length > 3) {
return null;
}
final normalizedMissing = <String>[];
for (final item in missing) {
if (item is! String || item.trim().isEmpty || item.length > 80) {
return null;
}
normalizedMissing.add(item.trim());
}
return WritingAiFeedback(
verdict: verdict,
feedback: feedback.trim(),
suggestion: suggestion?.trim(),
missing: normalizedMissing,
);
} catch (_) {
return null;
}
}
+513
View File
@@ -0,0 +1,513 @@
import 'dart:io';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
/// Durable local storage for learner-owned state. Evidence, mastery, reviews,
/// sessions, assessments and caches are persisted as separate SQLite entities.
/// A legacy versioned snapshot may be read as a migration/recovery fallback,
/// but normal writes never create or replace one. No provider key, audio bytes,
/// or cloud data is stored here.
class LocalSnapshotStore {
LocalSnapshotStore._();
LocalSnapshotStore.forTesting(QueryExecutor executor) : _executor = executor;
static final instance = LocalSnapshotStore._();
QueryExecutor? _executor;
Future<QueryExecutor>? _opening;
Future<void>? _initializing;
Future<QueryExecutor> _open() => _opening ??= _create();
Future<QueryExecutor> _create() async {
final directory = await getApplicationSupportDirectory();
final database = NativeDatabase.createInBackground(
File('${directory.path}/kouyu_learning.sqlite'),
);
_executor = database;
await _ensureSchema(database);
return database;
}
Future<void> _ensureSchema(QueryExecutor executor) =>
_initializing ??= _openAndCreateSchema(executor);
Future<void> _openAndCreateSchema(QueryExecutor executor) async {
await executor.ensureOpen(_snapshotStoreUser);
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS learning_snapshots (
snapshot_id TEXT PRIMARY KEY NOT NULL,
schema_version INTEGER NOT NULL,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS attempt_evidence (
attempt_id TEXT PRIMARY KEY NOT NULL, item_id TEXT NOT NULL,
task_id TEXT NOT NULL, skill TEXT NOT NULL, input_mode TEXT NOT NULL,
outcome TEXT NOT NULL, created_at TEXT NOT NULL, raw_answer TEXT,
recording_path TEXT, assisted INTEGER NOT NULL, variant_index INTEGER NOT NULL,
original_transcript TEXT, transcript_confirmed INTEGER NOT NULL DEFAULT 0,
transcript_edited INTEGER NOT NULL DEFAULT 0
)
''');
await _ensureColumn(
executor,
table: 'attempt_evidence',
name: 'original_transcript',
definition: 'TEXT',
);
await _ensureColumn(
executor,
table: 'attempt_evidence',
name: 'transcript_confirmed',
definition: 'INTEGER NOT NULL DEFAULT 0',
);
await _ensureColumn(
executor,
table: 'attempt_evidence',
name: 'transcript_edited',
definition: 'INTEGER NOT NULL DEFAULT 0',
);
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS mastery_items (
item_id TEXT PRIMARY KEY NOT NULL, label TEXT NOT NULL, status TEXT NOT NULL,
checkpoint INTEGER NOT NULL, needs_review INTEGER NOT NULL, first_taught_at TEXT,
payload TEXT NOT NULL DEFAULT '{}'
)
''');
await _ensureColumn(
executor,
table: 'mastery_items',
name: 'payload',
definition: "TEXT NOT NULL DEFAULT '{}'",
);
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS review_items (
item_id TEXT PRIMARY KEY NOT NULL, due_at TEXT NOT NULL, attempts INTEGER NOT NULL,
successful_reviews INTEGER NOT NULL, variant_index INTEGER NOT NULL, payload TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS study_sessions (
session_id TEXT PRIMARY KEY NOT NULL, session_type TEXT NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS temporary_lexicon_entries (
query_key TEXT PRIMARY KEY NOT NULL, query TEXT NOT NULL,
definition 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,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS lesson_progress (
progress_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS assessment_records (
pack_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
completed_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS generated_content_cache (
cache_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
}
Future<void> _ensureColumn(
QueryExecutor executor, {
required String table,
required String name,
required String definition,
}) async {
final columns = await executor.runSelect(
'PRAGMA table_info($table)',
const [],
);
if (columns.any((column) => column['name'] == name)) return;
await executor.runCustom('ALTER TABLE $table ADD COLUMN $name $definition');
}
Future<String?> read() async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
final canonical = await _readCanonical(executor);
if (canonical != null) return jsonEncode(canonical);
final rows = await executor.runSelect(
'SELECT payload FROM learning_snapshots WHERE snapshot_id = ?',
const ['current'],
);
return rows.isEmpty ? null : rows.single['payload'] as String?;
}
/// Rebuilds the application state from normalized local entities. The old
/// snapshot is intentionally only a migration/recovery fallback: once a
/// profile exists, a partially written or stale snapshot cannot win.
Future<Map<String, dynamic>?> _readCanonical(QueryExecutor executor) async {
final profileRows = await executor.runSelect(
'SELECT payload FROM learner_profiles WHERE profile_id = ?',
const ['current'],
);
final lessonRows = await executor.runSelect(
'SELECT payload FROM lesson_progress WHERE progress_id = ?',
const ['current'],
);
if (profileRows.isEmpty || lessonRows.isEmpty) return null;
try {
final result = <String, dynamic>{
..._decodeObject(profileRows.single['payload']),
..._decodeObject(lessonRows.single['payload']),
};
final contentRows = await executor.runSelect(
'SELECT payload FROM generated_content_cache WHERE cache_id = ?',
const ['adaptive'],
);
if (contentRows.isNotEmpty) {
result.addAll(_decodeObject(contentRows.single['payload']));
}
result['attemptEvidence'] =
(await executor.runSelect(
'''SELECT attempt_id, item_id, task_id, skill, input_mode, outcome,
created_at, raw_answer, recording_path, assisted, variant_index,
original_transcript, transcript_confirmed, transcript_edited
FROM attempt_evidence ORDER BY created_at''',
const [],
))
.map(
(row) => <String, dynamic>{
'id': row['attempt_id'],
'itemId': row['item_id'],
'taskId': row['task_id'],
'skill': row['skill'],
'inputMode': row['input_mode'],
'outcome': row['outcome'],
'createdAt': row['created_at'],
'rawAnswer': row['raw_answer'],
'recordingPath': row['recording_path'],
'assisted': row['assisted'] == 1,
'variantIndex': row['variant_index'],
'originalTranscript': row['original_transcript'],
'transcriptConfirmed': row['transcript_confirmed'] == 1,
'transcriptEdited': row['transcript_edited'] == 1,
},
)
.toList();
result['mastery'] = (await executor.runSelect(
'SELECT payload FROM mastery_items ORDER BY item_id',
const [],
)).map((row) => _decodeObject(row['payload'])).toList();
result['reviews'] = (await executor.runSelect(
'SELECT payload FROM review_items ORDER BY due_at',
const [],
)).map((row) => _decodeObject(row['payload'])).toList();
result['temporaryLexicon'] =
(await executor.runSelect(
'''SELECT query, definition, provider, model, created_at
FROM temporary_lexicon_entries ORDER BY created_at''',
const [],
))
.map(
(row) => <String, dynamic>{
'query': row['query'],
'definition': row['definition'],
'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 [],
)).map((row) => _decodeObject(row['payload'])).toList();
final sessions = await executor.runSelect(
'SELECT session_type, payload FROM study_sessions',
const [],
);
for (final session in sessions) {
if (session['session_type'] == 'assessment') {
result['assessmentDraft'] = _decodeObject(session['payload']);
} else if (session['session_type'] == 'dialogue') {
result['dialogueDraft'] = _decodeObject(session['payload']);
}
}
return result;
} catch (_) {
// Keep a previous version usable if an interrupted legacy migration
// leaves any individual canonical payload unreadable.
return null;
}
}
Map<String, dynamic> _decodeObject(Object? raw) {
if (raw is! String) return const {};
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : const {};
}
Future<void> write(String payload) async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
final transaction = executor.beginTransaction();
try {
await transaction.ensureOpen(_snapshotStoreUser);
// `learning_snapshots` belongs to the old storage format. Do not keep
// mirroring the complete in-memory state into it: canonical state is the
// normalized entity set below. Existing snapshots remain readable only
// until a successful entity restore makes them unnecessary.
await _writeEntities(transaction, payload);
await transaction.send();
} catch (_) {
await transaction.rollback();
rethrow;
}
}
Future<void> _writeEntities(QueryExecutor executor, String payload) async {
final data = jsonDecode(payload) as Map<String, dynamic>;
for (final table in const [
'attempt_evidence',
'mastery_items',
'review_items',
'study_sessions',
'temporary_lexicon_entries',
'learner_profiles',
'lesson_progress',
'assessment_records',
'generated_content_cache',
]) {
await executor.runCustom('DELETE FROM $table');
}
for (final entry in (data['attemptEvidence'] as List? ?? const [])) {
if (entry is! Map) continue;
await executor.runCustom(
'''INSERT INTO attempt_evidence(
attempt_id, item_id, task_id, skill, input_mode, outcome, created_at,
raw_answer, recording_path, assisted, variant_index,
original_transcript, transcript_confirmed, transcript_edited
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
[
entry['id'],
entry['itemId'],
entry['taskId'],
entry['skill'],
entry['inputMode'],
entry['outcome'],
entry['createdAt'],
entry['rawAnswer'],
entry['recordingPath'],
entry['assisted'] == true ? 1 : 0,
entry['variantIndex'] ?? 0,
entry['originalTranscript'],
entry['transcriptConfirmed'] == true ? 1 : 0,
entry['transcriptEdited'] == true ? 1 : 0,
],
);
}
for (final item in (data['mastery'] as List? ?? const [])) {
if (item is! Map) continue;
await executor.runCustom(
'''INSERT INTO mastery_items(
item_id, label, status, checkpoint, needs_review, first_taught_at, payload
) VALUES (?, ?, ?, ?, ?, ?, ?)''',
[
item['id'],
item['label'],
item['status'],
item['checkpoint'] ?? 0,
item['needsReview'] == true ? 1 : 0,
item['firstTaughtAt'],
jsonEncode(item),
],
);
}
for (final item in (data['reviews'] as List? ?? const [])) {
if (item is! Map) continue;
await executor
.runCustom('INSERT INTO review_items VALUES (?, ?, ?, ?, ?, ?)', [
item['id'],
item['dueAt'],
item['attempts'] ?? 0,
item['successfulReviews'] ?? 0,
item['variantIndex'] ?? 0,
jsonEncode(item),
]);
}
final sessions = {
'lesson': {
'step': data['lessonStep'],
'lessonId': data['activeLessonId'],
},
'assessment': data['assessmentDraft'],
'dialogue': data['dialogueDraft'],
};
for (final entry in sessions.entries) {
if (entry.value == null) continue;
await executor
.runCustom('INSERT INTO study_sessions VALUES (?, ?, ?, ?)', [
entry.key,
entry.key,
jsonEncode(entry.value),
DateTime.now().toUtc().toIso8601String(),
]);
}
final now = DateTime.now().toUtc().toIso8601String();
await executor.runCustom('INSERT INTO learner_profiles VALUES (?, ?, ?)', [
'current',
jsonEncode({
for (final key in const [
'onboardingComplete',
'goal',
'placement',
'dailyMinutes',
'showChineseHints',
'keepRecordings',
'aiEndpoint',
'aiModel',
'aiProvider',
'reportedAiVariantKeys',
'reportedAdaptiveLessonIds',
])
key: data[key],
}),
now,
]);
await executor.runCustom('INSERT INTO lesson_progress VALUES (?, ?, ?)', [
'current',
jsonEncode({
for (final key in const [
'lessonStep',
'previewIndex',
'completedLessons',
'activeLessonId',
'completedLessonIds',
'completedSegmentIds',
'activeSegmentIndexes',
'lessonListeningComplete',
'lessonSpeakingComplete',
'lessonReadingComplete',
'lessonWritingComplete',
'lessonDialogueComplete',
'independentAttemptComplete',
'independentAttemptAssisted',
'independentAttemptSpoken',
'lessonWritingDraft',
'independentAttemptDraft',
])
key: data[key],
}),
now,
]);
for (final assessment in (data['assessments'] as List? ?? const [])) {
if (assessment is! Map || assessment['packId'] is! String) continue;
await executor
.runCustom('INSERT INTO assessment_records VALUES (?, ?, ?)', [
assessment['packId'],
jsonEncode(assessment),
assessment['completedAt'] ?? now,
]);
}
await executor.runCustom(
'INSERT INTO generated_content_cache VALUES (?, ?, ?)',
[
'adaptive',
jsonEncode({
for (final key in const [
'cachedAdaptiveLessonRaw',
'cachedAdaptiveLessonAuditedAt',
'cachedAdaptiveLessonAuditor',
'adaptiveLessonDraftId',
'adaptiveLessonDraftIndex',
'adaptiveLessonDraftAnswer',
'adaptiveLessonDraftReferenceShown',
'adaptiveLessonDraftUsedVoice',
'adaptiveLessonDraftTranscriptEdited',
'adaptiveLessonDraftTranscriptConfirmed',
'adaptiveLessonDraftOriginalTranscript',
'adaptiveLessonDraftRecordingPath',
])
key: data[key],
}),
now,
],
);
for (final entry in (data['temporaryLexicon'] as List? ?? const [])) {
if (entry is! Map) continue;
final query = entry['query'] as String? ?? '';
final key = query.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
final definition = entry['definition'] as String? ?? '';
if (key.isEmpty || definition.trim().isEmpty) continue;
await executor.runCustom(
'INSERT INTO temporary_lexicon_entries VALUES (?, ?, ?, ?, ?, ?)',
[
key,
query,
definition,
entry['provider'] ?? 'unknown',
entry['model'] ?? '',
entry['createdAt'] ?? DateTime.now().toUtc().toIso8601String(),
],
);
}
}
Future<void> clear() async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
final transaction = executor.beginTransaction();
try {
await transaction.ensureOpen(_snapshotStoreUser);
await transaction.runCustom(
'DELETE FROM learning_snapshots WHERE snapshot_id = ?',
const ['current'],
);
for (final table in const [
'attempt_evidence',
'mastery_items',
'review_items',
'study_sessions',
'temporary_lexicon_entries',
'learner_profiles',
'lesson_progress',
'assessment_records',
'generated_content_cache',
]) {
await transaction.runCustom('DELETE FROM $table');
}
await transaction.send();
} catch (_) {
await transaction.rollback();
rethrow;
}
}
}
final _snapshotStoreUser = _SnapshotStoreUser();
class _SnapshotStoreUser implements QueryExecutorUser {
@override
int get schemaVersion => 1;
@override
Future<void> beforeOpen(
QueryExecutor executor,
OpeningDetails details,
) async {}
}
+318
View File
@@ -0,0 +1,318 @@
enum LearningGoal { dailyLife, travel, workStarter }
enum PlacementLevel { beginner, someBasics, simpleConversation }
enum AppTab { home, learn, dialogue, review, progress }
enum LessonStep {
preview,
listening,
speaking,
reading,
writing,
dialogue,
independent,
complete,
}
enum MasteryStatus { newItem, recognize, recall, use, master, needsReview }
enum EvidenceKind {
exposure,
assisted,
independentSuccess,
languageError,
pending,
}
class AttemptEvidence {
const AttemptEvidence({
required this.id,
required this.itemId,
required this.taskId,
required this.skill,
required this.inputMode,
required this.outcome,
required this.createdAt,
this.rawAnswer,
this.recordingPath,
this.assisted = false,
this.variantIndex = 0,
this.originalTranscript,
this.transcriptConfirmed = false,
this.transcriptEdited = false,
});
final String id;
final String itemId;
final String taskId;
final String skill;
final String inputMode;
final EvidenceKind outcome;
final DateTime createdAt;
final String? rawAnswer;
/// Local-only file reference. It is absent unless the learner opted to keep
/// recordings; no path or audio is sent to an AI provider.
final String? recordingPath;
final bool assisted;
final int variantIndex;
/// The device STT result before the learner edits it. This local-only field
/// distinguishes confirmed spoken input from a subsequent typed rewrite.
final String? originalTranscript;
final bool transcriptConfirmed;
final bool transcriptEdited;
}
enum AiProviderType { mock, gemini, openAi, compatible }
enum AssessmentSkill { listening, speaking, reading, writing }
enum ContentStatus { draft, validated, approved, rejected }
enum ContentSource { builtInOriginal, aiGenerated, importedReference }
/// A validated, bounded suggestion from an AI conversation provider.
/// The client still owns task completion and mastery decisions.
class DialogueAiResponse {
const DialogueAiResponse({
required this.reply,
required this.slots,
required this.evidence,
required this.suggestsComplete,
this.feedback,
});
final String reply;
final Map<String, String> slots;
final List<String> evidence;
final bool suggestsComplete;
final String? feedback;
}
/// A bounded suggestion only. It never changes completion, mastery, or a
/// stage result; those decisions remain on-device and evidence-based.
class WritingAiFeedback {
const WritingAiFeedback({
required this.verdict,
required this.feedback,
required this.suggestion,
required this.missing,
});
/// `accepted`, `rewrite`, or `uncertain`.
final String verdict;
final String feedback;
final String? suggestion;
final List<String> missing;
}
class AssessmentRecord {
const AssessmentRecord({
required this.packId,
required this.completedAt,
required this.results,
this.pendingSkills = const {},
});
final String packId;
final DateTime completedAt;
final Map<AssessmentSkill, bool> results;
final Set<AssessmentSkill> pendingSkills;
bool get passed =>
AssessmentSkill.values.every((skill) => results[skill] == true);
List<AssessmentSkill> get failedSkills => AssessmentSkill.values
.where(
(skill) => results[skill] != true && !pendingSkills.contains(skill),
)
.toList();
}
class AssessmentDraft {
const AssessmentDraft({
required this.packId,
required this.taskIndex,
required this.results,
});
final String packId;
final int taskIndex;
final Map<String, bool> results;
}
class DialogueDraft {
const DialogueDraft({
required this.lessonId,
required this.stage,
required this.turns,
required this.usedHelp,
});
final String lessonId;
final int stage;
final List<DialogueTurn> turns;
final bool usedHelp;
}
class LessonSummary {
const LessonSummary({
required this.number,
required this.title,
required this.outcome,
this.isComplete = false,
this.isCurrent = false,
});
final int number;
final String title;
final String outcome;
final bool isComplete;
final bool isCurrent;
}
class VocabularyItem {
const VocabularyItem({
required this.id,
required this.word,
required this.meaning,
required this.example,
required this.exampleMeaning,
this.ipa,
});
final String id;
final String word;
final String meaning;
final String example;
final String exampleMeaning;
final String? ipa;
}
/// A non-core explanation returned by a configured text provider for a word
/// or phrase that is not present in the reviewed course lexicon. These
/// entries deliberately never participate in review scheduling or mastery.
class TemporaryLexiconEntry {
const TemporaryLexiconEntry({
required this.query,
required this.definition,
required this.provider,
required this.model,
required this.createdAt,
});
final String query;
final String definition;
final String provider;
final String model;
final DateTime createdAt;
}
class ReviewItem {
const ReviewItem({
required this.id,
required this.target,
required this.prompt,
required this.hint,
required this.dueAt,
required this.skill,
this.attempts = 0,
this.successfulReviews = 0,
this.variantIndex = 0,
this.lastProgressedAt,
this.isAiGenerated = false,
});
final String id;
final String target;
final String prompt;
final String hint;
final DateTime dueAt;
final String skill;
final int attempts;
final int successfulReviews;
final int variantIndex;
final DateTime? lastProgressedAt;
final bool isAiGenerated;
ReviewItem copyWith({
DateTime? dueAt,
int? attempts,
int? successfulReviews,
int? variantIndex,
String? prompt,
String? hint,
String? skill,
DateTime? lastProgressedAt,
bool? isAiGenerated,
}) => ReviewItem(
id: id,
target: target,
prompt: prompt ?? this.prompt,
hint: hint ?? this.hint,
dueAt: dueAt ?? this.dueAt,
skill: skill ?? this.skill,
attempts: attempts ?? this.attempts,
successfulReviews: successfulReviews ?? this.successfulReviews,
variantIndex: variantIndex ?? this.variantIndex,
lastProgressedAt: lastProgressedAt ?? this.lastProgressedAt,
isAiGenerated: isAiGenerated ?? this.isAiGenerated,
);
}
class MasteryItem {
const MasteryItem({
required this.id,
required this.label,
required this.status,
required this.evidence,
this.needsReview = false,
this.checkpoint = 0,
this.firstTaughtAt,
});
final String id;
final String label;
final MasteryStatus status;
final List<EvidenceKind> evidence;
final bool needsReview;
final int checkpoint;
final DateTime? firstTaughtAt;
MasteryItem copyWith({
MasteryStatus? status,
List<EvidenceKind>? evidence,
bool? needsReview,
int? checkpoint,
DateTime? firstTaughtAt,
}) => MasteryItem(
id: id,
label: label,
status: status ?? this.status,
evidence: evidence ?? this.evidence,
needsReview: needsReview ?? this.needsReview,
checkpoint: checkpoint ?? this.checkpoint,
firstTaughtAt: firstTaughtAt ?? this.firstTaughtAt,
);
}
class DialogueTurn {
const DialogueTurn({required this.text, required this.isLearner});
final String text;
final bool isLearner;
}
/// Ephemeral presentation data for a completed controlled dialogue. The
/// related recap review is separately persisted in AppState.
class DialogueSummaryData {
const DialogueSummaryData({
required this.completedTasks,
required this.personalSentence,
required this.usedHelp,
});
final List<String> completedTasks;
final String personalSentence;
final bool usedHelp;
}
@@ -0,0 +1,93 @@
import 'models.dart';
class ReviewCheckResult {
const ReviewCheckResult({required this.complete, required this.message});
final bool complete;
final String message;
}
/// 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.
class ReviewFeedback {
const ReviewFeedback._();
static ReviewCheckResult check(ReviewItem item, String input) {
final text = input.toLowerCase().replaceAll('', "'");
final tokens = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String token) => tokens.contains(token);
bool phrase(String value) => text.contains(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;
final complete = switch (item.id) {
'A0-P01' => introduction && tokens.length >= 2,
'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' =>
(phrase('my number is') || numberWords >= 3) && numberWords >= 3,
'A0-P09' => phrase("what's this") || phrase('what is this'),
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key']),
'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([
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]),
'A0-P16' => phrase('what time is it'),
'A0-P17' =>
itIs &&
(has('clock') ||
has("o'clock") ||
has('oclock') ||
phrase("o'clock")),
'A0-P18' => introduction && has('like') && tokens.length >= 3,
'A0-P19' => phrase('do you like'),
'A0-P20' =>
phrase('please say that again') || phrase('please speak slowly'),
_ when item.id.startsWith('A0-W') => tokens.contains(
item.target.toLowerCase(),
),
_ => tokens.length >= 2,
};
return ReviewCheckResult(
complete: complete,
message: complete ? '表达已完成,可以进入下一次复习安排。' : _hint(item.id),
);
}
static String _hint(String id) => switch (id) {
'A0-P01' => '用 Im / I am 或 My name is 介绍一个名字。',
'A0-P02' => '试着问:Whats 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 … 说一项喜好。',
_ => '再补充本次目标中的关键英文词或句型。',
};
}
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import 'package:speech_to_text/speech_to_text.dart';
/// Device speech facilities are optional. Callers must keep a text fallback
/// because availability depends on device language packs and permissions.
class VoiceService {
VoiceService._();
static final instance = VoiceService._();
final FlutterTts _tts = FlutterTts();
final SpeechToText _stt = SpeechToText();
final AudioRecorder _recorder = AudioRecorder();
final AudioPlayer _player = AudioPlayer();
bool _speechReady = false;
Future<void> speak(String text, {bool slow = false}) async {
await _tts.stop();
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
await _tts.speak(text);
}
Future<void> stopSpeaking() => _tts.stop();
Future<bool> startRecording() async {
if (!await _recorder.hasPermission()) return false;
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
if (!await recordings.exists()) await recordings.create(recursive: true);
final timestamp = DateTime.now().microsecondsSinceEpoch;
await _recorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc),
path: '${recordings.path}/practice_$timestamp.m4a',
);
return true;
}
Future<String?> stopRecording() => _recorder.stop();
Future<void> playRecording(String path) async {
await _player.stop();
await _player.play(DeviceFileSource(path));
}
Future<void> stopRecordingPlayback() => _player.stop();
Future<void> deleteRecording(String? path) async {
if (path == null || path.isEmpty) return;
final file = File(path);
if (await file.exists()) await file.delete();
}
Future<int> deleteAllRecordings() async {
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
if (!await recordings.exists()) return 0;
final files = await recordings
.list()
.where((item) => item is File)
.toList();
for (final file in files.cast<File>()) {
await file.delete();
}
return files.length;
}
Future<List<String>> listRecordingPaths() async {
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
if (!await recordings.exists()) return const [];
final files = await recordings
.list()
.where((item) => item is File && item.path.endsWith('.m4a'))
.cast<File>()
.toList();
files.sort((left, right) => right.path.compareTo(left.path));
return files.map((file) => file.path).toList();
}
Future<void> disposeRecording() async {
await _recorder.dispose();
await _player.dispose();
}
Future<bool> initializeSpeech() async {
_speechReady = await _stt.initialize();
return _speechReady;
}
Future<bool> startListening(
void Function(String text, bool finalResult) onResult,
) async {
if (!_speechReady && !await initializeSpeech()) {
return false;
}
await _stt.listen(
onResult: (result) =>
onResult(result.recognizedWords, result.finalResult),
listenOptions: SpeechListenOptions(
localeId: 'en_US',
listenFor: const Duration(seconds: 30),
pauseFor: const Duration(seconds: 4),
),
);
return true;
}
Future<void> stopListening() => _stt.stop();
bool get isListening => _stt.isListening;
}
@@ -0,0 +1,112 @@
class WritingCheckResult {
const WritingCheckResult({required this.complete, required this.message});
final bool complete;
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.
class WritingFeedback {
const WritingFeedback._();
static WritingCheckResult check(
String lessonId,
String input, {
String? segmentId,
}) {
final text = input.toLowerCase().replaceAll('', "'");
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool hasPhrase(String phrase) => text.contains(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') || text.contains("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'),
_ => words.length >= 2,
},
};
if (complete) {
return const WritingCheckResult(
complete: true,
message: '这句已经表达完整。继续在对话里用一次吧。',
);
}
return WritingCheckResult(
complete: false,
message: _hintFor(segmentId ?? lessonId),
);
}
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 / Its 加 Monday、Tuesday 或 Wednesday。',
'a0-08-b' => '用 It is / Its 加 Thursday、Friday、Saturday 或 Sunday。',
'a0-08-c' => '用 It is / Its 加数字和 oclock,例如 Its three oclock。',
'a0-01' => '试着同时写一句问候和姓名,例如:Hello. I’m …',
'a0-02' => '写出姓名,并把至少三个字母用空格或连字符拼出来。',
'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' => '分别写姓名、来自哪里和一项喜好三部分。',
_ => '再补充一个完整英文句子。',
};
}