fix: 输入校验忽略多个连续空格与大小写;统一中英文引号与空白规范化
This commit is contained in:
@@ -3,6 +3,6 @@
|
|||||||
"endpoint": "https://kmwq8ckvr0ehsgqyudcer1.slcydia.fun/v1/responses",
|
"endpoint": "https://kmwq8ckvr0ehsgqyudcer1.slcydia.fun/v1/responses",
|
||||||
"model": "gemini-3.7-flash-high",
|
"model": "gemini-3.7-flash-high",
|
||||||
"reasoningEffort": "low",
|
"reasoningEffort": "low",
|
||||||
"apiKey": "sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf",
|
"apiKey": "sk-gZKpQQ5ybcL6WFersPKMiDDEZFxjC8xCASHzc08SNFTwa3LwRr8SaNNuvPzal5Tg",
|
||||||
"description": "默认 AI 对话服务配置。provider 可选: compatible (OpenAI 兼容/CLIProxyAPI/OneAPI), openAi, gemini, mock"
|
"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
|
/// Local checks for the frozen A0 exit tasks. They accept variable names and
|
||||||
/// places, but require the communicative information named by each task.
|
/// places, but require the communicative information named by each task.
|
||||||
bool checkOpenAssessmentAnswer(AssessmentTask task, String input) {
|
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(
|
final words = RegExp(
|
||||||
r"[a-z]+(?:'[a-z]+)?",
|
r"[a-z]+(?:'[a-z]+)?",
|
||||||
).allMatches(text).map((match) => match.group(0)!).toSet();
|
).allMatches(text).map((match) => match.group(0)!).toSet();
|
||||||
bool has(String word) => words.contains(word);
|
bool has(String word) => words.contains(
|
||||||
bool phrase(String value) => text.contains(value);
|
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);
|
bool hasAny(Iterable<String> values) => values.any(has);
|
||||||
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
|
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
|
||||||
final itIs = phrase("it's") || phrase('it is');
|
final itIs = phrase("it's") || phrase('it is');
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ bool matchesAdaptiveLessonAnswer(GeneratedLessonTask task, String answer) {
|
|||||||
|
|
||||||
String _normalizeAnswer(String value) => value
|
String _normalizeAnswer(String value) => value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
.replaceAll('’', "'")
|
||||||
|
.replaceAll('‘', "'")
|
||||||
.replaceAll(RegExp(r"[^a-z0-9']+"), ' ')
|
.replaceAll(RegExp(r"[^a-z0-9']+"), ' ')
|
||||||
.trim()
|
.trim()
|
||||||
.replaceAll(RegExp(r'\s+'), ' ');
|
.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
|||||||
@@ -13,13 +13,20 @@ class ReviewCheckResult {
|
|||||||
class ReviewFeedback {
|
class ReviewFeedback {
|
||||||
const ReviewFeedback._();
|
const ReviewFeedback._();
|
||||||
|
|
||||||
|
static String _normalize(String input) => input
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll('’', "'")
|
||||||
|
.replaceAll('‘', "'")
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
static ReviewCheckResult check(ReviewItem item, String input) {
|
static ReviewCheckResult check(ReviewItem item, String input) {
|
||||||
final text = input.toLowerCase().replaceAll('’', "'");
|
final text = _normalize(input);
|
||||||
final tokens = RegExp(
|
final tokens = RegExp(
|
||||||
r"[a-z]+(?:'[a-z]+)?",
|
r"[a-z]+(?:'[a-z]+)?",
|
||||||
).allMatches(text).map((match) => match.group(0)!).toSet();
|
).allMatches(text).map((match) => match.group(0)!).toSet();
|
||||||
bool has(String token) => tokens.contains(token);
|
bool has(String token) => tokens.contains(_normalize(token));
|
||||||
bool phrase(String value) => text.contains(value);
|
bool phrase(String value) => text.contains(_normalize(value));
|
||||||
bool hasAny(Iterable<String> values) => values.any(has);
|
bool hasAny(Iterable<String> values) => values.any(has);
|
||||||
final introduction =
|
final introduction =
|
||||||
phrase("i'm") || phrase('i am') || phrase('my name is');
|
phrase("i'm") || phrase('i am') || phrase('my name is');
|
||||||
@@ -68,9 +75,8 @@ class ReviewFeedback {
|
|||||||
'A0-P19' => phrase('do you like'),
|
'A0-P19' => phrase('do you like'),
|
||||||
'A0-P20' =>
|
'A0-P20' =>
|
||||||
phrase('please say that again') || phrase('please speak slowly'),
|
phrase('please say that again') || phrase('please speak slowly'),
|
||||||
_ when item.id.startsWith('A0-W') => tokens.contains(
|
_ when item.id.startsWith('A0-W') =>
|
||||||
item.target.toLowerCase(),
|
tokens.contains(_normalize(item.target)) || phrase(item.target),
|
||||||
),
|
|
||||||
_ => tokens.length >= 2,
|
_ => tokens.length >= 2,
|
||||||
};
|
};
|
||||||
return ReviewCheckResult(
|
return ReviewCheckResult(
|
||||||
|
|||||||
@@ -1081,8 +1081,17 @@ const a0SegmentGrammarNotes = <String, String>{
|
|||||||
String grammarNoteForSegment(String segmentId, String lessonId) =>
|
String grammarNoteForSegment(String segmentId, String lessonId) =>
|
||||||
a0SegmentGrammarNotes[segmentId] ?? grammarNoteForLesson(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) {
|
bool matchesSegmentDialogue(String segmentId, int stage, String response) {
|
||||||
final text = response.toLowerCase().replaceAll('’', "'");
|
final text = _normalizeDialogueInput(response);
|
||||||
final segment = a0LessonSegments.values
|
final segment = a0LessonSegments.values
|
||||||
.expand((segments) => segments)
|
.expand((segments) => segments)
|
||||||
.where((item) => item.id == segmentId)
|
.where((item) => item.id == segmentId)
|
||||||
@@ -1091,7 +1100,7 @@ bool matchesSegmentDialogue(String segmentId, int stage, String response) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
bool hasAny(Iterable<String> terms) =>
|
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
|
// A phone-number report needs both the reporting frame and at least one
|
||||||
// spoken digit. Other segment turns need one reviewed, task-specific term.
|
// spoken digit. Other segment turns need one reviewed, task-specific term.
|
||||||
if (segmentId == 'a0-04-c' && stage == 0) {
|
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) {
|
bool matchesSegmentIndependent(String segmentId, String response) {
|
||||||
final text = response.toLowerCase().replaceAll('’', "'");
|
final text = _normalizeDialogueInput(response);
|
||||||
final segment = a0LessonSegments.values
|
final segment = a0LessonSegments.values
|
||||||
.expand((segments) => segments)
|
.expand((segments) => segments)
|
||||||
.where((item) => item.id == segmentId)
|
.where((item) => item.id == segmentId)
|
||||||
@@ -1110,17 +1119,18 @@ bool matchesSegmentIndependent(String segmentId, String response) {
|
|||||||
if (segment == null) return true;
|
if (segment == null) return true;
|
||||||
if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') {
|
if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') {
|
||||||
final count = segment.independentRequiredTerms
|
final count = segment.independentRequiredTerms
|
||||||
.where((term) => text.contains(term))
|
.where((term) => text.contains(_normalizeDialogueInput(term)))
|
||||||
.length;
|
.length;
|
||||||
return count >= 3;
|
return count >= 3;
|
||||||
}
|
}
|
||||||
if (segmentId == 'a0-04-c') {
|
if (segmentId == 'a0-04-c') {
|
||||||
return segment.independentRequiredTerms
|
return segment.independentRequiredTerms
|
||||||
.where((term) => text.contains(term))
|
.where((term) => text.contains(_normalizeDialogueInput(term)))
|
||||||
.length >=
|
.length >=
|
||||||
3;
|
3;
|
||||||
}
|
}
|
||||||
return segment.independentRequiredTerms.any((term) => text.contains(term));
|
return segment.independentRequiredTerms
|
||||||
|
.any((term) => text.contains(_normalizeDialogueInput(term)));
|
||||||
}
|
}
|
||||||
|
|
||||||
class LessonDialogue {
|
class LessonDialogue {
|
||||||
@@ -1156,7 +1166,8 @@ bool _containsTerm(String text, String term) {
|
|||||||
return term.split(' + ').every((part) => _containsTerm(text, part.trim()));
|
return term.split(' + ').every((part) => _containsTerm(text, part.trim()));
|
||||||
}
|
}
|
||||||
if (term.startsWith('#')) return _matchesStructure(text, term);
|
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);
|
return RegExp('(?<![a-z])$escaped(?![a-z])').hasMatch(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,9 +1189,6 @@ bool _matchesStructure(String text, String token) => switch (token) {
|
|||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
String _normalizeDialogueInput(String response) =>
|
|
||||||
response.toLowerCase().replaceAll('\u2019', "'").replaceAll('\u2018', "'");
|
|
||||||
|
|
||||||
/// Whole-lesson and free-scene dialogues validate the same way segment
|
/// Whole-lesson and free-scene dialogues validate the same way segment
|
||||||
/// dialogues do: the turn has to contain the language the turn is teaching.
|
/// dialogues do: the turn has to contain the language the turn is teaching.
|
||||||
bool matchesDialogueStage(LessonDialogue script, int stage, String response) {
|
bool matchesDialogueStage(LessonDialogue script, int stage, String response) {
|
||||||
|
|||||||
@@ -11,18 +11,25 @@ class WritingCheckResult {
|
|||||||
class WritingFeedback {
|
class WritingFeedback {
|
||||||
const WritingFeedback._();
|
const WritingFeedback._();
|
||||||
|
|
||||||
|
static String _normalize(String input) => input
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll('’', "'")
|
||||||
|
.replaceAll('‘', "'")
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
static WritingCheckResult check(
|
static WritingCheckResult check(
|
||||||
String lessonId,
|
String lessonId,
|
||||||
String input, {
|
String input, {
|
||||||
String? segmentId,
|
String? segmentId,
|
||||||
}) {
|
}) {
|
||||||
final text = input.toLowerCase().replaceAll('’', "'");
|
final text = _normalize(input);
|
||||||
final words = RegExp(
|
final words = RegExp(
|
||||||
r"[a-z]+(?:'[a-z]+)?",
|
r"[a-z]+(?:'[a-z]+)?",
|
||||||
).allMatches(text).map((match) => match.group(0)!).toSet();
|
).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);
|
bool hasAny(Iterable<String> choices) => choices.any(has);
|
||||||
final hasIntroduction =
|
final hasIntroduction =
|
||||||
@@ -40,7 +47,7 @@ class WritingFeedback {
|
|||||||
'a0-08-a' => hasItIs && hasAny(['monday', 'tuesday', 'wednesday']),
|
'a0-08-a' => hasItIs && hasAny(['monday', 'tuesday', 'wednesday']),
|
||||||
'a0-08-b' =>
|
'a0-08-b' =>
|
||||||
hasItIs && hasAny(['thursday', 'friday', 'saturday', 'sunday']),
|
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) {
|
_ => switch (lessonId) {
|
||||||
'a0-01' => hasAny(['hello', 'hi']) && hasIntroduction,
|
'a0-01' => hasAny(['hello', 'hi']) && hasIntroduction,
|
||||||
'a0-02' =>
|
'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.
|
/// Finds a course item by exact query, then by the longest known phrase in it.
|
||||||
VocabularyItem? findCourseLexicon(String text) {
|
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;
|
if (normalized.isEmpty) return null;
|
||||||
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
|
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;
|
if (normalized == word) return true;
|
||||||
final pattern = RegExp(
|
final pattern = RegExp(
|
||||||
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
|
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].
|
/// Extracts all distinct course lexicon phrases/words that appear within [text].
|
||||||
List<VocabularyItem> extractCourseLexiconPhrases(String 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 [];
|
if (normalized.isEmpty) return const [];
|
||||||
final matched = <VocabularyItem>[];
|
final matched = <VocabularyItem>[];
|
||||||
final seen = <String>{};
|
final seen = <String>{};
|
||||||
for (final item in courseLexiconEntries) {
|
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;
|
if (word.isEmpty || word == normalized) continue;
|
||||||
final pattern = RegExp(
|
final pattern = RegExp(
|
||||||
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
|
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
|
||||||
|
|||||||
@@ -84,6 +84,25 @@ void main() {
|
|||||||
expect(dialogueTaskLabel(a0MeetDialogue, 9), '完成本轮任务');
|
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 的可用词表随课程递增且不越界', () {
|
test('给 AI 的可用词表随课程递增且不越界', () {
|
||||||
final first = taughtLanguageUpTo('a0-01');
|
final first = taughtLanguageUpTo('a0-01');
|
||||||
final later = taughtLanguageUpTo('a0-09');
|
final later = taughtLanguageUpTo('a0-09');
|
||||||
|
|||||||
@@ -79,4 +79,31 @@ void main() {
|
|||||||
isTrue,
|
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,
|
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