583 lines
16 KiB
Dart
583 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('’', "'")
|
||
.replaceAll('‘', "'")
|
||
.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;
|
||
}
|
||
}
|