feat: improve AI dialogue evaluation and capability boundaries

This commit is contained in:
shen
2026-09-19 18:58:14 -07:00
parent 335d0304b1
commit 7306dc0f85
23 changed files with 1308 additions and 452 deletions
@@ -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);
});
}
+21
View File
@@ -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(