581 lines
16 KiB
Dart
581 lines
16 KiB
Dart
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;
|
|
}
|
|
}
|