feat: 优化口语防重复问答、课程推进流转、键盘输入流畅度与竖屏锁定

- AI口语约束:在对话系统提示词与单轮指导中增加防重复问答约束,避免重复寒暄及索取已提供信息,根据历史自然向前推进
- 课程流转:修复多段课程学完后今日任务卡片流转下一课,及退出重进定位逻辑
- 竖屏锁定:在 Flutter、Android 及 iOS 平台配置仅支持竖屏显示
- 对话交互:优化对话界面文本输入重绘与词汇查询,解决键盘输入卡顿
- 测试用例:补充并更新单轮约束、状态流转与返回键退出测试,212项测试全绿
This commit is contained in:
shen
2026-09-18 07:38:54 -07:00
parent 0a4f97d105
commit 5b9cba793e
16 changed files with 568 additions and 242 deletions
+10 -3
View File
@@ -7,6 +7,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'models.dart';
import 'courses/courses.dart';
import 'generated_content.dart';
class AiConnectionResult {
@@ -796,7 +797,8 @@ class AiService {
);
final turnNote =
'[Turn] Your next line must do this: $aiGoal '
'After your line the learner has to: $learnerTask.';
'After your line the learner has to: $learnerTask. '
'Do not repeat any questions or greetings already asked or answered.';
// Earlier AI lines are stored as plain English. Sent that way they teach
// the model to answer in plain text (or, in JSON mode, with blanks), so
// they are replayed in the JSON shape the system prompt asks for.
@@ -854,6 +856,9 @@ class AiService {
'Each user message ends with a [Turn] note saying what your next line '
'must do and what the learner has to say after it. '
'Do not say the learner sentence for them, and do not ask for anything else. '
'Strictly do not repeat any question, greeting, or inquiry that has already been asked or answered in earlier turns. '
'Check the conversation history carefully: never ask for information the learner has already given (e.g. name, location, feelings, etc.). '
'If the turn goal asks about something already provided in history, acknowledge it naturally and advance the conversation forward instead of re-asking. '
'Reply with one short sentence or question, at most 20 English words, in English only. '
'Do not explain grammar. '
'Return JSON only: {"reply": "your English line", '
@@ -994,9 +999,11 @@ Learner wrote: $answer''';
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
final level = itemLevel(targetItemId);
final lessonId = 'ai-${level.toLowerCase()}-${targetItemId.toLowerCase()}-1';
final stageVersion = '$level-1.0';
final instruction =
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, the lessonId given below, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [target item id], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [target item id]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple A0 English for the target expression. No new vocabulary, markdown, real phone numbers, or personal data.\n'
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, the lessonId given below, revision 1, stageVersion $stageVersion, source aiGenerated, status validated, targetItemIds [target item id], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [target item id]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple $level English for the target expression. No new vocabulary, markdown, real phone numbers, or personal data.\n'
'lessonId: $lessonId\n'
'Target item id: $targetItemId\n'
'Target expression: $targetLabel'
+10 -1
View File
@@ -263,7 +263,9 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
}
void finishLesson() {
if (!lessonCanComplete) return;
if (!lessonCanComplete && !isLessonSegmentsAllComplete(activeLessonId)) {
return;
}
completedLessonIds.add(activeLessonId);
completedLessons = completedLessonIds.length;
final next = _nextIncompleteLessonId();
@@ -273,6 +275,11 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
_syncInBackground();
}
bool isLessonSegmentsAllComplete(String lessonId) {
final lesson = lessonById(lessonId);
return lesson.segments.every((seg) => completedSegmentIds.contains(seg.id));
}
/// Merges lesson progress pulled from the sync server.
///
/// Completed lessons/segments are unioned, and the learner's position only
@@ -343,6 +350,8 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
/// The first unfinished lesson from the placement start onward, falling back
/// to any earlier unfinished lesson. Null once every lesson is finished.
String? get nextIncompleteLessonId => _nextIncompleteLessonId();
String? _nextIncompleteLessonId() {
final lessons = allLessons;
final start = _lessonIndex(placementStartLessonId).clamp(0, lessons.length);
@@ -18,6 +18,67 @@ final Map<String, LessonDialogue> segmentDialogues = <String, LessonDialogue>{};
final Map<String, String> segmentGrammarNotes = <String, String>{};
final List<DialogueScene> practiceScenes = <DialogueScene>[];
/// All distinct English words introduced in each CEFR level, populated from
/// the course JSON content packs.
final Map<String, Set<String>> levelVocabularyWords = <String, Set<String>>{};
const List<String> cefrLevels = ['A0', 'A1', 'A2', 'B1'];
/// Registers words for [level] into the vocabulary index.
void registerLevelWords(String level, Iterable<String> words) {
final set = levelVocabularyWords.putIfAbsent(
level.toUpperCase(),
() => <String>{},
);
for (final w in words) {
final clean = w.toLowerCase().replaceAll("'", '').trim();
if (clean.isNotEmpty) set.add(clean);
}
}
/// Fixed demo names and places accepted across all levels as standard practice slots.
const Set<String> standardDemoEntities = {
'alex',
'mia',
'shen',
'bo',
'ava',
'tom',
'sam',
'beijing',
'shanghai',
'hong',
'kong',
'china',
'london',
};
/// Returns all English words taught in the course up to [level] (inclusive).
Set<String> taughtCourseWordsUpToLevel(String level) {
final targetIdx = cefrLevels.indexOf(level.toUpperCase());
final maxIdx = targetIdx < 0 ? 0 : targetIdx;
final result = Set<String>.from(standardDemoEntities);
for (var i = 0; i <= maxIdx && i < cefrLevels.length; i++) {
final words = levelVocabularyWords[cefrLevels[i]];
if (words != null) result.addAll(words);
}
return result;
}
/// Returns all English words taught in the course up to [targetItemId]'s level.
Set<String> taughtCourseWordsUpToItem(String targetItemId) {
return taughtCourseWordsUpToLevel(itemLevel(targetItemId));
}
/// All distinct English words across all loaded course packs.
Set<String> allLoadedCourseWords() {
final result = <String>{};
for (final words in levelVocabularyWords.values) {
result.addAll(words);
}
return result;
}
/// Empties every registry, for `CourseRepository.resetForTest`.
void clearCourseCatalog() {
courseLessons.clear();
@@ -28,6 +89,7 @@ void clearCourseCatalog() {
segmentGrammarNotes.clear();
practiceScenes.clear();
assessmentPacks.clear();
levelVocabularyWords.clear();
}
/// Every lesson the app can teach, in learning order: A0 first, then the
@@ -120,10 +120,57 @@ class CourseRepository {
_register(pack);
}
void _indexPackVocabulary(CoursePack pack) {
final words = <String>{};
void addText(String? text) {
if (text == null || text.isEmpty) return;
final matches = RegExp(r"[A-Za-z]+(?:'[A-Za-z]+)?").allMatches(text);
for (final m in matches) {
final word = m.group(0)!.replaceAll("'", '').toLowerCase();
if (word.isNotEmpty) words.add(word);
}
}
for (final item in pack.coreItems) {
addText(item.en);
addText(item.exampleEn);
for (final group in item.match) {
for (final term in group) {
addText(term);
}
}
}
for (final rw in pack.receptiveWords) {
addText(rw.en);
}
for (final seg in pack.segments) {
addText(seg.listeningText);
addText(seg.speakingText);
addText(seg.readingText);
addText(seg.writingExample);
if (seg.vocabulary != null) {
for (final v in seg.vocabulary!) {
addText(v.word);
}
}
}
for (final scene in pack.scenes) {
for (final turn in scene.turns) {
addText(turn.ai);
addText(turn.model);
}
}
for (final mat in pack.materials) {
addText(mat.text);
}
registerLevelWords(pack.level, words);
}
/// Adapts one pack into the shared runtime registries. Explicit rules and
/// texts in the pack win; anything left out is derived from the core items
/// the part teaches.
void _register(CoursePack pack) {
_indexPackVocabulary(pack);
final byId = {for (final item in pack.coreItems) item.id: item};
final scenesById = {for (final scene in pack.scenes) scene.id: scene};
+36 -92
View File
@@ -1,5 +1,7 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'courses/courses.dart';
import 'models.dart';
@@ -248,11 +250,13 @@ GeneratedLesson? decodeGeneratedLesson(
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-a0-[a-z0-9-]{1,50}$').hasMatch(lessonId) ||
!RegExp(r'^ai-[a-z0-9]{2}-[a-z0-9-]{1,50}$').hasMatch(lessonId) ||
revision is! int ||
revision < 1 ||
stageVersion != 'A0-1.0' ||
stageVersion != expectedStageVersion ||
estimatedMinutes is! int ||
estimatedMinutes < 8 ||
estimatedMinutes > 15 ||
@@ -314,6 +318,7 @@ GeneratedLesson? decodeGeneratedLesson(
rawTask['answerSpec'],
fallbackAnswer: answer is String ? answer : '',
required: data['schemaVersion'] == 'lesson-2',
targetItemId: expectedTargetItemId,
);
if (taskId is! String ||
taskId.length > 80 ||
@@ -330,8 +335,11 @@ GeneratedLesson? decodeGeneratedLesson(
answer is! String ||
answer.trim().isEmpty ||
answer.length > 160 ||
!_usesOnlyA0GeneratedWords(stimulus) ||
!_usesOnlyA0GeneratedWords(answer) ||
!usesAllowedCourseWords(
stimulus,
targetItemId: expectedTargetItemId,
) ||
!usesAllowedCourseWords(answer, targetItemId: expectedTargetItemId) ||
answerSpec == null ||
taskTargets == null ||
taskTargets.length != 1 ||
@@ -409,6 +417,7 @@ GeneratedAnswerSpec? _decodeAnswerSpec(
Object? value, {
required String fallbackAnswer,
required bool required,
String? targetItemId,
}) {
if (value == null && !required && fallbackAnswer.trim().isNotEmpty) {
return GeneratedAnswerSpec.referenceOnly(fallbackAnswer);
@@ -438,7 +447,11 @@ GeneratedAnswerSpec? _decodeAnswerSpec(
...forbidden,
...groups.expand((group) => group),
];
if (phrases.any((phrase) => !_usesOnlyA0GeneratedWords(phrase))) return null;
if (phrases.any(
(phrase) => !usesAllowedCourseWords(phrase, targetItemId: targetItemId),
)) {
return null;
}
final spec = GeneratedAnswerSpec(
requiredAnyPhrases: groups,
acceptedAnswers: accepted,
@@ -452,7 +465,7 @@ GeneratedAnswerSpec? _decodeAnswerSpec(
prompt: '验证',
stimulus: fallbackAnswer,
answer: fallbackAnswer,
targetItemIds: const ['A0-P01'],
targetItemIds: [targetItemId ?? 'A0-P01'],
answerSpec: spec,
),
fallbackAnswer,
@@ -461,98 +474,29 @@ GeneratedAnswerSpec? _decodeAnswerSpec(
: 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) {
/// 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("'", ''));
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',
};
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>[];