Files
English/kouyu_english/lib/core/generated_content.dart
T

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;
}
}