fix: 输入校验忽略多个连续空格与大小写;统一中英文引号与空白规范化

This commit is contained in:
shenlei
2026-09-16 17:55:08 +09:00
parent 4d324903b4
commit d72959df70
10 changed files with 163 additions and 28 deletions
+1 -1
View File
@@ -3,6 +3,6 @@
"endpoint": "https://kmwq8ckvr0ehsgqyudcer1.slcydia.fun/v1/responses",
"model": "gemini-3.7-flash-high",
"reasoningEffort": "low",
"apiKey": "sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf",
"apiKey": "sk-gZKpQQ5ybcL6WFersPKMiDDEZFxjC8xCASHzc08SNFTwa3LwRr8SaNNuvPzal5Tg",
"description": "默认 AI 对话服务配置。provider 可选: compatible (OpenAI 兼容/CLIProxyAPI/OneAPI), openAi, gemini, mock"
}
+22 -3
View File
@@ -155,12 +155,31 @@ AssessmentPack? replacementFor(String packId) => switch (packId) {
/// Local checks for the frozen A0 exit tasks. They accept variable names and
/// places, but require the communicative information named by each task.
bool checkOpenAssessmentAnswer(AssessmentTask task, String input) {
final text = input.toLowerCase().replaceAll('', "'");
final text = input
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r"\s+"), " ")
.trim();
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool phrase(String value) => text.contains(value);
bool has(String word) => words.contains(
word
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.trim(),
);
bool phrase(String value) {
final normVal = value
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r"\s+"), " ")
.trim();
return text.contains(normVal);
}
bool hasAny(Iterable<String> values) => values.any(has);
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
@@ -122,6 +122,8 @@ bool matchesAdaptiveLessonAnswer(GeneratedLessonTask task, String answer) {
String _normalizeAnswer(String value) => value
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r"[^a-z0-9']+"), ' ')
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
+12 -6
View File
@@ -13,13 +13,20 @@ class ReviewCheckResult {
class ReviewFeedback {
const ReviewFeedback._();
static String _normalize(String input) => input
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
static ReviewCheckResult check(ReviewItem item, String input) {
final text = input.toLowerCase().replaceAll('', "'");
final text = _normalize(input);
final tokens = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String token) => tokens.contains(token);
bool phrase(String value) => text.contains(value);
bool has(String token) => tokens.contains(_normalize(token));
bool phrase(String value) => text.contains(_normalize(value));
bool hasAny(Iterable<String> values) => values.any(has);
final introduction =
phrase("i'm") || phrase('i am') || phrase('my name is');
@@ -68,9 +75,8 @@ class ReviewFeedback {
'A0-P19' => phrase('do you like'),
'A0-P20' =>
phrase('please say that again') || phrase('please speak slowly'),
_ when item.id.startsWith('A0-W') => tokens.contains(
item.target.toLowerCase(),
),
_ when item.id.startsWith('A0-W') =>
tokens.contains(_normalize(item.target)) || phrase(item.target),
_ => tokens.length >= 2,
};
return ReviewCheckResult(
+18 -10
View File
@@ -1081,8 +1081,17 @@ const a0SegmentGrammarNotes = <String, String>{
String grammarNoteForSegment(String segmentId, String lessonId) =>
a0SegmentGrammarNotes[segmentId] ?? grammarNoteForLesson(lessonId);
String _normalizeDialogueInput(String response) => response
.toLowerCase()
.replaceAll('\u2019', "'")
.replaceAll('\u2018', "'")
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
bool matchesSegmentDialogue(String segmentId, int stage, String response) {
final text = response.toLowerCase().replaceAll('', "'");
final text = _normalizeDialogueInput(response);
final segment = a0LessonSegments.values
.expand((segments) => segments)
.where((item) => item.id == segmentId)
@@ -1091,7 +1100,7 @@ bool matchesSegmentDialogue(String segmentId, int stage, String response) {
return false;
}
bool hasAny(Iterable<String> terms) =>
terms.any((term) => text.contains(term));
terms.any((term) => text.contains(_normalizeDialogueInput(term)));
// A phone-number report needs both the reporting frame and at least one
// spoken digit. Other segment turns need one reviewed, task-specific term.
if (segmentId == 'a0-04-c' && stage == 0) {
@@ -1102,7 +1111,7 @@ bool matchesSegmentDialogue(String segmentId, int stage, String response) {
}
bool matchesSegmentIndependent(String segmentId, String response) {
final text = response.toLowerCase().replaceAll('', "'");
final text = _normalizeDialogueInput(response);
final segment = a0LessonSegments.values
.expand((segments) => segments)
.where((item) => item.id == segmentId)
@@ -1110,17 +1119,18 @@ bool matchesSegmentIndependent(String segmentId, String response) {
if (segment == null) return true;
if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') {
final count = segment.independentRequiredTerms
.where((term) => text.contains(term))
.where((term) => text.contains(_normalizeDialogueInput(term)))
.length;
return count >= 3;
}
if (segmentId == 'a0-04-c') {
return segment.independentRequiredTerms
.where((term) => text.contains(term))
.where((term) => text.contains(_normalizeDialogueInput(term)))
.length >=
3;
}
return segment.independentRequiredTerms.any((term) => text.contains(term));
return segment.independentRequiredTerms
.any((term) => text.contains(_normalizeDialogueInput(term)));
}
class LessonDialogue {
@@ -1156,7 +1166,8 @@ bool _containsTerm(String text, String term) {
return term.split(' + ').every((part) => _containsTerm(text, part.trim()));
}
if (term.startsWith('#')) return _matchesStructure(text, term);
final escaped = RegExp.escape(term.toLowerCase());
final normTerm = _normalizeDialogueInput(term);
final escaped = RegExp.escape(normTerm);
return RegExp('(?<![a-z])$escaped(?![a-z])').hasMatch(text);
}
@@ -1178,9 +1189,6 @@ bool _matchesStructure(String text, String token) => switch (token) {
_ => false,
};
String _normalizeDialogueInput(String response) =>
response.toLowerCase().replaceAll('\u2019', "'").replaceAll('\u2018', "'");
/// Whole-lesson and free-scene dialogues validate the same way segment
/// dialogues do: the turn has to contain the language the turn is teaching.
bool matchesDialogueStage(LessonDialogue script, int stage, String response) {
+11 -4
View File
@@ -11,18 +11,25 @@ class WritingCheckResult {
class WritingFeedback {
const WritingFeedback._();
static String _normalize(String input) => input
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
static WritingCheckResult check(
String lessonId,
String input, {
String? segmentId,
}) {
final text = input.toLowerCase().replaceAll('', "'");
final text = _normalize(input);
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool has(String word) => words.contains(_normalize(word));
bool hasPhrase(String phrase) => text.contains(phrase);
bool hasPhrase(String phrase) => text.contains(_normalize(phrase));
bool hasAny(Iterable<String> choices) => choices.any(has);
final hasIntroduction =
@@ -40,7 +47,7 @@ class WritingFeedback {
'a0-08-a' => hasItIs && hasAny(['monday', 'tuesday', 'wednesday']),
'a0-08-b' =>
hasItIs && hasAny(['thursday', 'friday', 'saturday', 'sunday']),
'a0-08-c' => hasItIs && (has('oclock') || text.contains("o'clock")),
'a0-08-c' => hasItIs && (has('oclock') || hasPhrase("o'clock")),
_ => switch (lessonId) {
'a0-01' => hasAny(['hello', 'hi']) && hasIntroduction,
'a0-02' =>
+24 -4
View File
@@ -21,10 +21,20 @@ List<VocabularyItem> get courseLexiconEntries {
/// Finds a course item by exact query, then by the longest known phrase in it.
VocabularyItem? findCourseLexicon(String text) {
final normalized = text.toLowerCase().replaceAll('', "'").trim();
final normalized = text
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized.isEmpty) return null;
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
final word = entry!.word.toLowerCase().replaceAll('', "'");
final word = entry!.word
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized == word) return true;
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
@@ -36,12 +46,22 @@ VocabularyItem? findCourseLexicon(String text) {
/// Extracts all distinct course lexicon phrases/words that appear within [text].
List<VocabularyItem> extractCourseLexiconPhrases(String text) {
final normalized = text.toLowerCase().replaceAll('', "'").trim();
final normalized = text
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized.isEmpty) return const [];
final matched = <VocabularyItem>[];
final seen = <String>{};
for (final item in courseLexiconEntries) {
final word = item.word.toLowerCase().replaceAll('', "'");
final word = item.word
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (word.isEmpty || word == normalized) continue;
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
@@ -84,6 +84,25 @@ void main() {
expect(dialogueTaskLabel(a0MeetDialogue, 9), '完成本轮任务');
});
test('输入校验忽略多余空格与大小写', () {
expect(
matchesDialogueStage(a0MeetDialogue, 0, " MY NAME IS SHEN "),
isTrue,
);
expect(
matchesDialogueStage(a0Dialogues["a0-02"]!, 1, " S H E N "),
isTrue,
);
expect(
matchesDialogueStage(a0Dialogues["a0-04"]!, 0, "MY NUMBER IS ONE-THREE-EIGHT"),
isTrue,
);
expect(
matchesDialogueStage(a0Dialogues["a0-08"]!, 2, "IT'S THREE O'CLOCK"),
isTrue,
);
});
test('给 AI 的可用词表随课程递增且不越界', () {
final first = taughtLanguageUpTo('a0-01');
final later = taughtLanguageUpTo('a0-09');
@@ -79,4 +79,31 @@ void main() {
isTrue,
);
});
test('ignores multiple spaces and casing across review items', () {
// Multi-space and uppercase on A0-P03
expect(
ReviewFeedback.check(
review('A0-P03', 'Nice to meet you.'),
' NICE TO MEET YOU! ',
).complete,
isTrue,
);
// Multi-space and uppercase on A0-P08
expect(
ReviewFeedback.check(
review('A0-P08', 'My number is ...'),
'MY NUMBER IS ONE TWO THREE',
).complete,
isTrue,
);
// Multi-space on word item
expect(
ReviewFeedback.check(
review('A0-W08', 'three'),
' THREE ',
).complete,
isTrue,
);
});
}
@@ -31,4 +31,31 @@ void main() {
isTrue,
);
});
test('ignores multiple spaces, mixed casing and curly apostrophes', () {
// a0-01 greeting and name with multiple spaces and uppercase
expect(
WritingFeedback.check('a0-01', ' HELLO I AM ALEX ').complete,
isTrue,
);
// a0-01 with curly quote and multiple spaces
expect(
WritingFeedback.check('a0-01', 'HI IM MIA').complete,
isTrue,
);
// a0-07 with multiple spaces in phrase
expect(
WritingFeedback.check('a0-07', 'THIS IS MY MOTHER').complete,
isTrue,
);
// a0-08-c with multiple spaces and curly apostrophe
expect(
WritingFeedback.check(
'a0-08-c',
'ITS THREE OCLOCK',
segmentId: 'a0-08-c',
).complete,
isTrue,
);
});
}