feat: complete kouyu_english app codebase, A0 specifications and .gitignore
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user