fix: 输入校验忽略多个连续空格与大小写;统一中英文引号与空白规范化
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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+'), ' ');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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' =>
|
||||
|
||||
@@ -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 I’M 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',
|
||||
'IT’S THREE O’CLOCK',
|
||||
segmentId: 'a0-08-c',
|
||||
).complete,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user