feat: improve dialogue speech recognition and AI intervention
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:kouyu_english/core/ai_service.dart';
|
||||
import 'package:kouyu_english/core/app_state.dart';
|
||||
import 'package:kouyu_english/core/models.dart';
|
||||
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('AiService.checkDialogueIntervention', () {
|
||||
test('parses accepted semantic match cleanly', () 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 feel wonderful today.',
|
||||
),
|
||||
() => MockClient((request) async {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'choices': [
|
||||
{
|
||||
'message': {
|
||||
'content': jsonEncode({
|
||||
'accepted': true,
|
||||
'suggestion': null,
|
||||
'explanation': '表达非常自然,符合本轮交流目标。',
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result, isNotNull);
|
||||
expect(result!.accepted, isTrue);
|
||||
expect(result.suggestion, isNull);
|
||||
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(今天很累)吗?',
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
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: (_, _) {}),
|
||||
);
|
||||
|
||||
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);
|
||||
},
|
||||
() => 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'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user