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 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> requiredAnyPhrases; final List acceptedAnswers; final List 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 abilityIds; final List prerequisiteIds; final List targetItemIds; final List receptiveChunks; final List previewItemIds; final int estimatedMinutes; final List 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 || 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) 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 = []; const skills = {'listening', 'speaking', 'reading', 'writing'}; const types = {'listenChoice', 'repeat', 'readAnswer', 'writeAnswer'}; for (final rawTask in rawTasks) { if (rawTask is! Map) 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 _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 || 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 = >[]; 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? _stringList(Object? value, {required int max}) { if (value is! List || value.length > max) return null; final result = []; 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 || 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 = []; 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; } }