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

617 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'courses/courses.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;
}
/// Explains in Chinese why [matchesAdaptiveLessonAnswer] rejects [answer],
/// or returns null when it accepts it. Listening answers only get a count so
/// the explanation does not replace listening again.
String? adaptiveAnswerShortfall(GeneratedLessonTask task, String answer) {
if (matchesAdaptiveLessonAnswer(task, answer)) return null;
final actual = _normalizeAnswer(answer);
if (actual.isEmpty) return '请先输入或说出答案。';
final spec = task.localAnswerSpec;
final forbidden = spec.forbiddenPhrases
.where((phrase) => _containsPhrase(actual, phrase))
.toList();
if (forbidden.isNotEmpty) {
return '这里不能用:${forbidden.join('、')}。';
}
final missing = spec.requiredAnyPhrases
.where(
(alternatives) =>
!alternatives.any((phrase) => _containsPhrase(actual, phrase)),
)
.toList();
if (task.skill == 'listening') {
return '还缺 ${missing.isEmpty ? 1 : missing.length} 处关键信息。再听一次,或慢放后补全。';
}
if (missing.isEmpty || answerSpecIsReferenceOnly(spec, task.answer)) {
return '和参考表达还不一致,检查拼写和漏掉的词。';
}
return '还缺:${missing.map((group) => group.take(3).join(' / ')).join('')}。';
}
bool answerSpecIsReferenceOnly(GeneratedAnswerSpec spec, String answer) =>
spec.requiredAnyPhrases.length == 1 &&
spec.requiredAnyPhrases.single.length == 1 &&
spec.requiredAnyPhrases.single.single == answer;
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 ||
!isCoreItem(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 ||
!isCoreItem(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;
}
}