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
+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>[];