feat: 优化口语防重复问答、课程推进流转、键盘输入流畅度与竖屏锁定
- AI口语约束:在对话系统提示词与单轮指导中增加防重复问答约束,避免重复寒暄及索取已提供信息,根据历史自然向前推进 - 课程流转:修复多段课程学完后今日任务卡片流转下一课,及退出重进定位逻辑 - 竖屏锁定:在 Flutter、Android 及 iOS 平台配置仅支持竖屏显示 - 对话交互:优化对话界面文本输入重绘与词汇查询,解决键盘输入卡顿 - 测试用例:补充并更新单轮约束、状态流转与返回键退出测试,212项测试全绿
This commit is contained in:
@@ -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>[];
|
||||
|
||||
Reference in New Issue
Block a user