feat: improve AI dialogue evaluation and capability boundaries
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/ai_service.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
void main() {
|
||||
test('AI capability registry exposes stable bounded contracts', () {
|
||||
final descriptors = AiService.instance.capabilities.descriptors;
|
||||
|
||||
expect(descriptors.map((item) => item.id).toSet(), {
|
||||
'speech-transcription',
|
||||
'lexicon-explanation',
|
||||
'dialogue-coach',
|
||||
'answer-evaluation',
|
||||
'review-generation',
|
||||
});
|
||||
expect(
|
||||
descriptors.map((item) => item.id).toSet(),
|
||||
hasLength(descriptors.length),
|
||||
);
|
||||
expect(descriptors, everyElement(isA<AiCapabilityDescriptor>()));
|
||||
expect(
|
||||
descriptors,
|
||||
everyElement(
|
||||
predicate<AiCapabilityDescriptor>(
|
||||
(item) => item.promptVersion > 0 && item.outputContract.isNotEmpty,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'lexicon capability routes through the existing validated service',
|
||||
() async {
|
||||
final result = await AiService.instance.capabilities.lexicon
|
||||
.analyzeSentence(
|
||||
provider: AiProviderType.mock,
|
||||
endpoint: '',
|
||||
model: '',
|
||||
text: "I'd like to check in, please.",
|
||||
);
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.translation, isNotEmpty);
|
||||
expect(result.provider, 'mock');
|
||||
},
|
||||
);
|
||||
|
||||
test('dialogue capability preserves mock fallback behavior', () async {
|
||||
final result = await AiService.instance.capabilities.dialogue.reply(
|
||||
provider: AiProviderType.mock,
|
||||
endpoint: '',
|
||||
model: '',
|
||||
history: const [],
|
||||
aiGoal: 'Ask the learner how they are.',
|
||||
learnerTask: 'Say how they feel.',
|
||||
);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
}
|
||||
@@ -808,6 +808,27 @@ void main() {
|
||||
expect(state.mastery['A0-P03']!.status, MasteryStatus.newItem);
|
||||
});
|
||||
|
||||
test('controlled dialogue cannot create independent evidence', () {
|
||||
final state = AppState();
|
||||
state.completePreview();
|
||||
|
||||
state.recordDialogueAttempt(
|
||||
taskId: 'dialogue-a0-01-a-0',
|
||||
rawAnswer: 'My name is Mia.',
|
||||
assisted: false,
|
||||
evidenceOutcome: EvidenceKind.independentSuccess,
|
||||
);
|
||||
|
||||
final attempts = state.attemptEvidence.where(
|
||||
(entry) => entry.taskId == 'dialogue-a0-01-a-0',
|
||||
);
|
||||
expect(attempts, isNotEmpty);
|
||||
expect(
|
||||
attempts.every((entry) => entry.outcome == EvidenceKind.pending),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'mastery rebuild derives first teaching and teaching level from evidence',
|
||||
() {
|
||||
|
||||
@@ -52,282 +52,299 @@ void main() {
|
||||
expect(result.explanation, contains('表达非常自然'));
|
||||
});
|
||||
|
||||
test('parses ASR acoustic slip (e.g. third -> tired) with suggestion', () async {
|
||||
AiService.instance.setFallbackApiKey('test-key');
|
||||
final result = await http.runWithClient(
|
||||
() => AiService.instance.checkDialogueIntervention(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://api.deepseek.com',
|
||||
model: 'deepseek-flash',
|
||||
partnerLine: 'How are you today, Shen?',
|
||||
taskLabel: '表达状态或喜好',
|
||||
learnerText: "I'm third today.",
|
||||
hint: "I'm good, thanks. / I like coffee.",
|
||||
),
|
||||
() => MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'content': jsonEncode({
|
||||
'accepted': false,
|
||||
'suggestion': "I'm tired today.",
|
||||
'explanation': '识别为 third,你可能是想表达 tired(今天很累)吗?',
|
||||
}),
|
||||
test(
|
||||
'parses ASR acoustic slip (e.g. third -> tired) with suggestion',
|
||||
() async {
|
||||
AiService.instance.setFallbackApiKey('test-key');
|
||||
final result = await http.runWithClient(
|
||||
() => AiService.instance.checkDialogueIntervention(
|
||||
provider: AiProviderType.compatible,
|
||||
endpoint: 'https://api.deepseek.com',
|
||||
model: 'deepseek-flash',
|
||||
partnerLine: 'How are you today, Shen?',
|
||||
taskLabel: '表达状态或喜好',
|
||||
learnerText: "I'm third today.",
|
||||
hint: "I'm good, thanks. / I like coffee.",
|
||||
),
|
||||
() => MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'content': jsonEncode({
|
||||
'accepted': false,
|
||||
'suggestion': "I'm tired today.",
|
||||
'explanation': '识别为 third,你可能是想表达 tired(今天很累)吗?',
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.accepted, isFalse);
|
||||
expect(result.suggestion, "I'm tired today.");
|
||||
expect(result.explanation, contains('tired'));
|
||||
});
|
||||
expect(result, isNotNull);
|
||||
expect(result!.accepted, isFalse);
|
||||
expect(result.suggestion, "I'm tired today.");
|
||||
expect(result.explanation, contains('tired'));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('DialoguePage AI Intervention Flow', () {
|
||||
testWidgets('shows intervention card when input has ASR slip and allows one-click fix and send', (
|
||||
tester,
|
||||
) async {
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
for (final name in const [
|
||||
'com.llfbandit.record/messages',
|
||||
'xyz.luan/audioplayers',
|
||||
'xyz.luan/audioplayers.global',
|
||||
]) {
|
||||
messenger.setMockMethodCallHandler(
|
||||
MethodChannel(name),
|
||||
(_) async => null,
|
||||
);
|
||||
}
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('xyz.luan/audioplayers.global/events'),
|
||||
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||
);
|
||||
|
||||
final reportError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exception is! MissingPluginException) {
|
||||
reportError?.call(details);
|
||||
testWidgets(
|
||||
'shows intervention card when input has ASR slip and allows one-click fix and send',
|
||||
(tester) async {
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
for (final name in const [
|
||||
'com.llfbandit.record/messages',
|
||||
'xyz.luan/audioplayers',
|
||||
'xyz.luan/audioplayers.global',
|
||||
]) {
|
||||
messenger.setMockMethodCallHandler(
|
||||
MethodChannel(name),
|
||||
(_) async => null,
|
||||
);
|
||||
}
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = reportError);
|
||||
|
||||
AiService.instance.setFallbackApiKey('test-key');
|
||||
final state = AppState()
|
||||
..aiProvider = AiProviderType.compatible
|
||||
..aiEndpoint = 'https://api.deepseek.com'
|
||||
..aiModel = 'deepseek-flash';
|
||||
|
||||
final requests = <String>[];
|
||||
final interventionJson = jsonEncode({
|
||||
'accepted': false,
|
||||
'suggestion': "I'm tired today.",
|
||||
'explanation': '语音识别为 third,你可能想表达的是 tired(今天很累)哦。',
|
||||
});
|
||||
final replyJson = jsonEncode({
|
||||
'reply': 'Oh, take a good rest today!',
|
||||
'translation': '噢,今天好好休息一下吧!',
|
||||
'feedback': null,
|
||||
});
|
||||
|
||||
await http.runWithClient(
|
||||
() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: DialoguePage(state: state, onFinished: (_) {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Stage 0: meet -> "What's your name?" -> answer "My name is Alex."
|
||||
await tester.enterText(find.byType(TextField), 'My name is Alex.');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Stage 1: meet -> "Where are you from?" -> answer "I'm from Beijing."
|
||||
await tester.enterText(find.byType(TextField), "I'm from Beijing.");
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Stage 2: meet -> "How are you today?"
|
||||
// Enter "I'm third today." which fails local preset regex
|
||||
await tester.enterText(find.byType(TextField), "I'm third today.");
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Expect AI Intervention Card to appear
|
||||
expect(find.text('AI 助手干预与建议'), findsOneWidget);
|
||||
expect(find.textContaining('语音识别为 third'), findsOneWidget);
|
||||
expect(find.text('建议表达:'), findsOneWidget);
|
||||
expect(find.text("I'm tired today."), findsOneWidget);
|
||||
|
||||
// Tap "修正为 ... 并发送"
|
||||
final fixButton = find.textContaining('修正为');
|
||||
expect(fixButton, findsOneWidget);
|
||||
await tester.ensureVisible(fixButton);
|
||||
await tester.tap(fixButton);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Turns now should include the fixed line and Mia's reply
|
||||
expect(find.text("I'm tired today."), findsOneWidget);
|
||||
expect(find.text('Oh, take a good rest today!'), findsAtLeastNWidgets(1));
|
||||
},
|
||||
() => MockClient((request) async {
|
||||
requests.add(request.body);
|
||||
if (request.body.contains('ASR') || request.body.contains('oral English coach')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': interventionJson},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': replyJson},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('automatically proceeds when AI evaluates alternative reply as semantically acceptable', (
|
||||
tester,
|
||||
) async {
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
for (final name in const [
|
||||
'com.llfbandit.record/messages',
|
||||
'xyz.luan/audioplayers',
|
||||
'xyz.luan/audioplayers.global',
|
||||
]) {
|
||||
messenger.setMockMethodCallHandler(
|
||||
MethodChannel(name),
|
||||
(_) async => null,
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('xyz.luan/audioplayers.global/events'),
|
||||
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||
);
|
||||
}
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('xyz.luan/audioplayers.global/events'),
|
||||
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||
);
|
||||
|
||||
AiService.instance.setFallbackApiKey('test-key');
|
||||
final state = AppState()
|
||||
..aiProvider = AiProviderType.compatible
|
||||
..aiEndpoint = 'https://api.deepseek.com'
|
||||
..aiModel = 'deepseek-flash';
|
||||
final reportError = FlutterError.onError;
|
||||
FlutterError.onError = (details) {
|
||||
if (details.exception is! MissingPluginException) {
|
||||
reportError?.call(details);
|
||||
}
|
||||
};
|
||||
addTearDown(() => FlutterError.onError = reportError);
|
||||
|
||||
final acceptJson = jsonEncode({
|
||||
'accepted': true,
|
||||
'suggestion': null,
|
||||
'explanation': '回答自然得体,符合交流目标。',
|
||||
});
|
||||
final replyJson = jsonEncode({
|
||||
'reply': 'Wonderful to hear that!',
|
||||
'translation': '很高兴听到这个!',
|
||||
'feedback': null,
|
||||
});
|
||||
AiService.instance.setFallbackApiKey('test-key');
|
||||
final state = AppState()
|
||||
..aiProvider = AiProviderType.compatible
|
||||
..aiEndpoint = 'https://api.deepseek.com'
|
||||
..aiModel = 'deepseek-flash';
|
||||
|
||||
await http.runWithClient(
|
||||
() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: DialoguePage(state: state, onFinished: (_) {}),
|
||||
final requests = <String>[];
|
||||
final interventionJson = jsonEncode({
|
||||
'accepted': false,
|
||||
'suggestion': "I'm tired today.",
|
||||
'explanation': '语音识别为 third,你可能想表达的是 tired(今天很累)哦。',
|
||||
});
|
||||
final replyJson = jsonEncode({
|
||||
'reply': 'Oh, take a good rest today!',
|
||||
'translation': '噢,今天好好休息一下吧!',
|
||||
'feedback': null,
|
||||
});
|
||||
|
||||
await http.runWithClient(
|
||||
() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: DialoguePage(state: state, onFinished: (_) {}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Stage 0: "What's your name?"
|
||||
await tester.enterText(find.byType(TextField), 'My name is Alex.');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
// Stage 0: meet -> "What's your name?" -> answer "My name is Alex."
|
||||
await tester.enterText(find.byType(TextField), 'My name is Alex.');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Stage 1: "Where are you from?"
|
||||
await tester.enterText(find.byType(TextField), "I'm from Beijing.");
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
// Stage 1: meet -> "Where are you from?" -> answer "I'm from Beijing."
|
||||
await tester.enterText(find.byType(TextField), "I'm from Beijing.");
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Stage 2: "How are you today?"
|
||||
// Enter alternative expression "I feel wonderful today." which is NOT in preset accept list
|
||||
await tester.enterText(find.byType(TextField), "I feel wonderful today.");
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
// Stage 2: meet -> "How are you today?"
|
||||
// Enter "I'm third today." which fails local preset regex
|
||||
await tester.enterText(find.byType(TextField), "I'm third today.");
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Turns now should directly include "I feel wonderful today." and Mia's reply without error
|
||||
expect(find.text("I feel wonderful today."), findsOneWidget);
|
||||
expect(find.text('Wonderful to hear that!'), findsAtLeastNWidgets(1));
|
||||
expect(find.textContaining('这一轮要'), findsNothing);
|
||||
},
|
||||
() => MockClient((request) async {
|
||||
if (request.body.contains('ASR') || request.body.contains('oral English coach')) {
|
||||
// Expect AI Intervention Card to appear
|
||||
expect(find.text('AI 助手干预与建议'), findsOneWidget);
|
||||
expect(find.textContaining('语音识别为 third'), findsOneWidget);
|
||||
expect(find.text('建议表达:'), findsOneWidget);
|
||||
expect(find.text("I'm tired today."), findsOneWidget);
|
||||
|
||||
// Tap "修正为 ... 并发送"
|
||||
final fixButton = find.textContaining('修正为');
|
||||
expect(fixButton, findsOneWidget);
|
||||
await tester.ensureVisible(fixButton);
|
||||
await tester.tap(fixButton);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Turns now should include the fixed line and Mia's reply
|
||||
expect(find.text("I'm tired today."), findsOneWidget);
|
||||
expect(
|
||||
find.text('Oh, take a good rest today!'),
|
||||
findsAtLeastNWidgets(1),
|
||||
);
|
||||
},
|
||||
() => MockClient((request) async {
|
||||
requests.add(request.body);
|
||||
if (request.body.contains('ASR') ||
|
||||
request.body.contains('oral English coach')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': interventionJson},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': acceptJson},
|
||||
'message': {'content': replyJson},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': replyJson},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'automatically proceeds when AI evaluates alternative reply as semantically acceptable',
|
||||
(tester) async {
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
for (final name in const [
|
||||
'com.llfbandit.record/messages',
|
||||
'xyz.luan/audioplayers',
|
||||
'xyz.luan/audioplayers.global',
|
||||
]) {
|
||||
messenger.setMockMethodCallHandler(
|
||||
MethodChannel(name),
|
||||
(_) async => null,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
messenger.setMockStreamHandler(
|
||||
const EventChannel('xyz.luan/audioplayers.global/events'),
|
||||
MockStreamHandler.inline(onListen: (_, _) {}),
|
||||
);
|
||||
|
||||
AiService.instance.setFallbackApiKey('test-key');
|
||||
final state = AppState()
|
||||
..aiProvider = AiProviderType.compatible
|
||||
..aiEndpoint = 'https://api.deepseek.com'
|
||||
..aiModel = 'deepseek-flash';
|
||||
|
||||
final acceptJson = jsonEncode({
|
||||
'accepted': true,
|
||||
'suggestion': null,
|
||||
'explanation': '回答自然得体,符合交流目标。',
|
||||
});
|
||||
final replyJson = jsonEncode({
|
||||
'reply': 'Wonderful to hear that!',
|
||||
'translation': '很高兴听到这个!',
|
||||
'feedback': null,
|
||||
});
|
||||
|
||||
await http.runWithClient(
|
||||
() async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: DialoguePage(state: state, onFinished: (_) {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Stage 0: "What's your name?"
|
||||
await tester.enterText(find.byType(TextField), 'My name is Alex.');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Stage 1: "Where are you from?"
|
||||
await tester.enterText(find.byType(TextField), "I'm from Beijing.");
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Stage 2: "How are you today?"
|
||||
// Enter alternative expression "I feel wonderful today." which is NOT in preset accept list
|
||||
await tester.enterText(
|
||||
find.byType(TextField),
|
||||
"I feel wonderful today.",
|
||||
);
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Turns now should directly include "I feel wonderful today." and Mia's reply without error
|
||||
expect(find.text("I feel wonderful today."), findsOneWidget);
|
||||
expect(
|
||||
find.text('Wonderful to hear that!'),
|
||||
findsAtLeastNWidgets(1),
|
||||
);
|
||||
expect(find.textContaining('这一轮要'), findsNothing);
|
||||
expect(state.sceneDialogueDraft?.usedHelp, isFalse);
|
||||
},
|
||||
() => MockClient((request) async {
|
||||
if (request.body.contains('ASR') ||
|
||||
request.body.contains('oral English coach')) {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': acceptJson},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {'content': replyJson},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/dialogue_decision.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
DialogueAiIntervention acceptedAi() => const DialogueAiIntervention(
|
||||
accepted: true,
|
||||
goalSatisfied: true,
|
||||
verdict: DialogueAiVerdict.accepted,
|
||||
explanation: '表达自然。',
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('resolveDialogueTurnDecision', () {
|
||||
test('requires local and AI agreement for independent scene evidence', () {
|
||||
final decision = resolveDialogueTurnDecision(
|
||||
local: LocalDialogueVerdict.accepted,
|
||||
ai: acceptedAi(),
|
||||
aiWasAttempted: true,
|
||||
isFreeScene: true,
|
||||
supportLevel: DialogueSupportLevel.none,
|
||||
);
|
||||
|
||||
expect(decision.canAdvance, isTrue);
|
||||
expect(decision.evidenceOutcome, EvidenceKind.independentSuccess);
|
||||
expect(decision.validationSource, DialogueValidationSource.localAndAi);
|
||||
});
|
||||
|
||||
test(
|
||||
'AI semantic acceptance advances an unknown local expression safely',
|
||||
() {
|
||||
final decision = resolveDialogueTurnDecision(
|
||||
local: LocalDialogueVerdict.uncertain,
|
||||
ai: acceptedAi(),
|
||||
aiWasAttempted: true,
|
||||
isFreeScene: true,
|
||||
supportLevel: DialogueSupportLevel.none,
|
||||
);
|
||||
|
||||
expect(decision.canAdvance, isTrue);
|
||||
expect(decision.evidenceOutcome, EvidenceKind.pending);
|
||||
expect(decision.validationSource, DialogueValidationSource.ai);
|
||||
expect(decision.assisted, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test('a correction can advance but is always assisted', () {
|
||||
final decision = resolveDialogueTurnDecision(
|
||||
local: LocalDialogueVerdict.accepted,
|
||||
ai: acceptedAi(),
|
||||
aiWasAttempted: true,
|
||||
isFreeScene: true,
|
||||
supportLevel: DialogueSupportLevel.correction,
|
||||
);
|
||||
|
||||
expect(decision.canAdvance, isTrue);
|
||||
expect(decision.evidenceOutcome, EvidenceKind.assisted);
|
||||
expect(decision.assisted, isTrue);
|
||||
});
|
||||
|
||||
test('invalid local input never advances', () {
|
||||
final decision = resolveDialogueTurnDecision(
|
||||
local: LocalDialogueVerdict.rejected,
|
||||
ai: acceptedAi(),
|
||||
aiWasAttempted: true,
|
||||
isFreeScene: true,
|
||||
supportLevel: DialogueSupportLevel.none,
|
||||
);
|
||||
|
||||
expect(decision.canAdvance, isFalse);
|
||||
});
|
||||
|
||||
test('AI outage degrades strict local acceptance to pending evidence', () {
|
||||
final decision = resolveDialogueTurnDecision(
|
||||
local: LocalDialogueVerdict.accepted,
|
||||
ai: null,
|
||||
aiWasAttempted: true,
|
||||
isFreeScene: true,
|
||||
supportLevel: DialogueSupportLevel.none,
|
||||
);
|
||||
|
||||
expect(decision.canAdvance, isTrue);
|
||||
expect(decision.evidenceOutcome, EvidenceKind.pending);
|
||||
expect(decision.validationSource, DialogueValidationSource.unavailable);
|
||||
});
|
||||
|
||||
test('explicit AI correction stops a local false positive', () {
|
||||
const correction = DialogueAiIntervention(
|
||||
accepted: false,
|
||||
goalSatisfied: false,
|
||||
verdict: DialogueAiVerdict.correctable,
|
||||
suggestion: 'My name is Mia.',
|
||||
explanation: '这句话需要修改。',
|
||||
);
|
||||
final decision = resolveDialogueTurnDecision(
|
||||
local: LocalDialogueVerdict.accepted,
|
||||
ai: correction,
|
||||
aiWasAttempted: true,
|
||||
isFreeScene: true,
|
||||
supportLevel: DialogueSupportLevel.none,
|
||||
);
|
||||
|
||||
expect(decision.canAdvance, isFalse);
|
||||
expect(decision.evidenceOutcome, EvidenceKind.pending);
|
||||
expect(decision.validationSource, DialogueValidationSource.disagreement);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:kouyu_english/core/courses/courses.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
|
||||
/// The free "初次见面" scene, which is always open.
|
||||
LessonDialogue get meet => sceneById('a0-meet').script;
|
||||
@@ -40,6 +41,21 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('本地判断区分明确错误和需要 AI 理解的自然表达', () {
|
||||
expect(
|
||||
evaluateDialogueStageLocally(meet, 0, '123 ---'),
|
||||
LocalDialogueVerdict.rejected,
|
||||
);
|
||||
expect(
|
||||
evaluateDialogueStageLocally(meet, 0, 'People call me Shen.'),
|
||||
LocalDialogueVerdict.uncertain,
|
||||
);
|
||||
expect(
|
||||
evaluateDialogueStageLocally(meet, 0, 'My name is Shen.'),
|
||||
LocalDialogueVerdict.accepted,
|
||||
);
|
||||
});
|
||||
|
||||
test('跑题回答不再因为关键词沾边而通过', () {
|
||||
// 旧实现用 text.contains('it'),下面这些句子全部会被判为完成任务。
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user