- AI口语约束:在对话系统提示词与单轮指导中增加防重复问答约束,避免重复寒暄及索取已提供信息,根据历史自然向前推进 - 课程流转:修复多段课程学完后今日任务卡片流转下一课,及退出重进定位逻辑 - 竖屏锁定:在 Flutter、Android 及 iOS 平台配置仅支持竖屏显示 - 对话交互:优化对话界面文本输入重绘与词汇查询,解决键盘输入卡顿 - 测试用例:补充并更新单轮约束、状态流转与返回键退出测试,212项测试全绿
561 lines
18 KiB
Dart
561 lines
18 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
|
||
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'];
|
||
final expectedLevel = itemLevel(expectedTargetItemId);
|
||
final expectedStageVersion = '$expectedLevel-1.0';
|
||
if (lessonId is! String ||
|
||
!RegExp(r'^ai-[a-z0-9]{2}-[a-z0-9-]{1,50}$').hasMatch(lessonId) ||
|
||
revision is! int ||
|
||
revision < 1 ||
|
||
stageVersion != expectedStageVersion ||
|
||
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',
|
||
targetItemId: expectedTargetItemId,
|
||
);
|
||
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 ||
|
||
!usesAllowedCourseWords(
|
||
stimulus,
|
||
targetItemId: expectedTargetItemId,
|
||
) ||
|
||
!usesAllowedCourseWords(answer, targetItemId: expectedTargetItemId) ||
|
||
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,
|
||
String? targetItemId,
|
||
}) {
|
||
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) => !usesAllowedCourseWords(phrase, targetItemId: targetItemId),
|
||
)) {
|
||
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: [targetItemId ?? 'A0-P01'],
|
||
answerSpec: spec,
|
||
),
|
||
fallbackAnswer,
|
||
)
|
||
? spec
|
||
: null;
|
||
}
|
||
|
||
/// Validates that learner-facing generated English (stimulus, answer, answerSpec)
|
||
/// only uses English vocabulary introduced in the course JSON content packs up to
|
||
/// the target item's level.
|
||
bool usesAllowedCourseWords(String value, {String? targetItemId}) {
|
||
final words = RegExp(r"[A-Za-z]+(?:'[A-Za-z]+)?")
|
||
.allMatches(value.toLowerCase())
|
||
.map((match) => match.group(0)!.replaceAll("'", ''));
|
||
if (words.isEmpty) return true;
|
||
|
||
final allowed = targetItemId != null && targetItemId.isNotEmpty
|
||
? taughtCourseWordsUpToItem(targetItemId)
|
||
: allLoadedCourseWords();
|
||
|
||
// If no course packs have been loaded into the registry yet, fall back gracefully.
|
||
if (allowed.isEmpty) return true;
|
||
|
||
return words.every(allowed.contains);
|
||
}
|
||
|
||
@visibleForTesting
|
||
bool usesOnlyA0GeneratedWords(String value) =>
|
||
usesAllowedCourseWords(value, targetItemId: 'A0-P01');
|
||
|
||
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;
|
||
}
|
||
}
|