feat: improve dialogue speech recognition and AI intervention

This commit is contained in:
shen
2026-09-19 17:45:25 -07:00
parent 5b9cba793e
commit 335d0304b1
6 changed files with 704 additions and 12 deletions
+85
View File
@@ -989,6 +989,91 @@ Learner wrote: $answer''';
return decodeWritingAiFeedback(content, expectedLessonId: answerId);
}
/// Evaluates a learner's dialogue turn when it does not match local preset rules.
/// Decides whether the reply is semantically acceptable in context, or detects
/// speech-to-text (ASR) phonetic slips, typos, or minor grammar errors
/// (e.g. "I'm third today" intended for "I'm tired today").
Future<DialogueAiIntervention?> checkDialogueIntervention({
required AiProviderType provider,
required String endpoint,
required String model,
required String partnerLine,
required String taskLabel,
required String learnerText,
String? hint,
String level = 'A0',
}) async {
if (provider == AiProviderType.mock) return null;
final instruction =
'You are a supportive oral English coach evaluating an ESL beginner ($level) spoken line in a dialogue.\n'
'Context:\n'
'- Dialogue partner said: "$partnerLine"\n'
'- Current turn task/goal: "$taskLabel"\n'
'${hint != null && hint.isNotEmpty ? '- Example response: "$hint"\n' : ''}'
'- Learner spoke/wrote: "$learnerText"\n\n'
'Evaluation Instructions:\n'
'1. Semantic & Communicative Check: Does the learner\'s response make sense and fulfill the conversational goal, even if phrased differently from the example (e.g. "I feel great", "Pretty good", "Not bad at all", "I like tea")?\n'
'2. Speech-to-Text (ASR) & Typo Slip Detection: Detect common speech recognition confusions or acoustic slips (e.g. /taɪəd/ transcribed as "third", "tierd", "thx"). If the learner clearly attempted the task with a phonetic or spelling slip, identify their intended English sentence.\n'
'3. Return ONLY valid JSON with no markdown:\n'
'{\n'
' "accepted": true or false,\n'
' "suggestion": "corrected English sentence (or null if accepted as-is)",\n'
' "explanation": "concise, warm Chinese explanation (1-2 sentences)"\n'
'}\n'
'Rules:\n'
'- If it is a valid, natural reply (or minor casing/punctuation): set "accepted": true, "suggestion": null, "explanation": "表达自然得体,符合本轮交流目标。".\n'
'- If there is an ASR slip, typo, or word error (e.g. "I\'m third today" intended for "I\'m tired today"): set "accepted": false, "suggestion": "I\'m tired today.", "explanation": "识别为 third,你可能是想表达 tired(今天很累)吗?".\n'
'- If off-topic or empty: set "accepted": false, "suggestion": null, "explanation": "简要说明本轮对方在问什么,建议如何回答".';
final content = await _requestPrompt(
provider: provider,
endpoint: endpoint,
model: model,
prompt: instruction,
temperature: 0,
maxTokens: 250,
);
return _decodeDialogueIntervention(content);
}
DialogueAiIntervention? _decodeDialogueIntervention(String? content) {
if (content == null || content.trim().isEmpty) return null;
try {
var sanitized = content.trim();
if (sanitized.startsWith('```')) {
sanitized = sanitized.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
sanitized = sanitized.replaceFirst(RegExp(r'\s*```$'), '');
}
final jsonStart = sanitized.indexOf('{');
final jsonEnd = sanitized.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
sanitized = sanitized.substring(jsonStart, jsonEnd + 1);
}
final data = jsonDecode(sanitized);
if (data is! Map<String, dynamic>) return null;
final accepted = data['accepted'] == true;
final rawSuggestion = data['suggestion'] as String?;
final suggestion =
(rawSuggestion != null &&
rawSuggestion.trim().isNotEmpty &&
rawSuggestion.trim().toLowerCase() != 'null')
? rawSuggestion.trim()
: null;
final explanation =
(data['explanation'] as String?)?.trim() ??
(accepted ? '回答符合要求。' : '建议调整表达后再试。');
return DialogueAiIntervention(
accepted: accepted,
suggestion: suggestion,
explanation: explanation,
);
} catch (_) {
return null;
}
}
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
Future<GeneratedLesson?> generateAdaptiveLesson({
required AiProviderType provider,
+18
View File
@@ -111,6 +111,24 @@ class WritingAiFeedback {
final List<String> missing;
}
/// Result of AI intervention during a dialogue turn when preset rules do not match.
class DialogueAiIntervention {
const DialogueAiIntervention({
required this.accepted,
this.suggestion,
required this.explanation,
});
/// Whether the learner's response is semantically acceptable for the turn.
final bool accepted;
/// Inferred or corrected English sentence if there was an ASR slip, typo, or minor mistake.
final String? suggestion;
/// Encouraging, concise Chinese explanation of the situation and recommendation.
final String explanation;
}
class AssessmentRecord {
const AssessmentRecord({
required this.packId,
@@ -164,7 +164,7 @@ class SherpaSttService {
senseVoice: sherpa_onnx.OfflineSenseVoiceModelConfig(
model: resolved['model']!,
language: 'en',
useInverseTextNormalization: true,
useInverseTextNormalization: false,
),
tokens: resolved['tokens']!,
numThreads: 2,
+37 -1
View File
@@ -14,6 +14,42 @@ class VoiceService {
VoiceService._();
static final instance = VoiceService._();
/// Reverses the inverse text normalization (ITN) performed by system speech
/// recognisers, which convert spoken numbers like "one" into "1".
/// For an English-learning app the learner needs the spelled-out words.
static String reverseItn(String text) {
// Standalone digit → word. Uses word-boundary anchors so "12" or "100" are
// left alone (those are unlikely to be single-word utterances the learner
// intended as words).
const digitToWord = {
'0': 'zero',
'1': 'one',
'2': 'two',
'3': 'three',
'4': 'four',
'5': 'five',
'6': 'six',
'7': 'seven',
'8': 'eight',
'9': 'nine',
'10': 'ten',
'11': 'eleven',
'12': 'twelve',
'13': 'thirteen',
'14': 'fourteen',
'15': 'fifteen',
'16': 'sixteen',
'17': 'seventeen',
'18': 'eighteen',
'19': 'nineteen',
'20': 'twenty',
};
return text.replaceAllMapped(
RegExp(r'\b(\d{1,2})\b'),
(m) => digitToWord[m.group(1)!] ?? m.group(0)!,
);
}
final FlutterTts _tts = FlutterTts();
final SpeechToText _stt = SpeechToText();
final AudioRecorder _recorder = AudioRecorder();
@@ -228,7 +264,7 @@ class VoiceService {
await _stt.listen(
onResult: (result) =>
onResult(result.recognizedWords, result.finalResult),
onResult(reverseItn(result.recognizedWords), result.finalResult),
listenOptions: SpeechListenOptions(
localeId: targetLocaleId,
listenFor: const Duration(seconds: 30),
@@ -161,6 +161,8 @@ class _DialoguePageState extends State<DialoguePage>
bool checkingWithAi = false;
WritingAiFeedback? aiCheck;
String? aiCheckError;
bool interveningWithAi = false;
DialogueAiIntervention? aiIntervention;
/// Shown when the reply on screen came from the built-in script instead of
/// the AI, so a canned line is never mistaken for a real answer.
@@ -308,12 +310,66 @@ class _DialoguePageState extends State<DialoguePage>
});
}
Future<void> send() async {
final text = controller.text.trim();
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
Future<void> send({
bool overrideValidation = false,
String? overrideText,
}) async {
final text = (overrideText ?? controller.text).trim();
if (text.isEmpty ||
stage >= script.prompts.length ||
waitingForReply ||
interveningWithAi) {
return;
}
if (!_matchesCurrentTask(text)) {
if (!overrideValidation && !_matchesCurrentTask(text)) {
if (widget.state.aiProvider != AiProviderType.mock) {
setState(() {
interveningWithAi = true;
validationError = null;
aiIntervention = null;
});
_scrollToBottom();
final partnerLine =
turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? '';
final hintText =
stage < script.hints.length ? script.hints[stage] : null;
final intervention =
await AiService.instance.checkDialogueIntervention(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
partnerLine: partnerLine,
taskLabel: _currentTaskLabel(),
learnerText: text,
hint: hintText,
level: _languageLessonId == null
? 'A0'
: lessonLevel(_languageLessonId!),
);
if (!mounted) return;
setState(() => interveningWithAi = false);
if (intervention != null) {
if (intervention.accepted) {
// AI confirmed semantic acceptability: proceed directly!
await _executeSend(text, usedAiIntervention: true);
return;
} else {
// AI detected ASR slip / typo / off-topic: show intervention card
setState(() {
aiIntervention = intervention;
validationError = null;
});
_scrollToBottom();
return;
}
}
}
setState(
() => validationError =
'这一轮要“${_currentTaskLabel()}”,这句还没做到。'
@@ -321,6 +377,17 @@ class _DialoguePageState extends State<DialoguePage>
);
return;
}
await _executeSend(text, usedAiIntervention: overrideValidation);
}
Future<void> _executeSend(
String text, {
bool usedAiIntervention = false,
}) async {
if (usedAiIntervention) {
usedHelp = true;
}
widget.state.recordDialogueAttempt(
taskId: widget.isLessonDialogue
? 'dialogue-$_lessonSegmentId-$stage'
@@ -343,6 +410,7 @@ class _DialoguePageState extends State<DialoguePage>
waitingForReply = true;
hint = null;
validationError = null;
aiIntervention = null;
aiCheck = null;
aiCheckError = null;
});
@@ -750,6 +818,125 @@ class _DialoguePageState extends State<DialoguePage>
validationError!,
style: TextStyle(color: AppColors.warmInk),
),
if (interveningWithAi)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'AI 正在理解你的回答…',
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
],
),
),
if (aiIntervention != null)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Row(
children: [
Icon(
Icons.auto_awesome,
size: 18,
color: AppColors.warmInk,
),
const SizedBox(width: 6),
Text(
'AI 助手干预与建议',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.warmInk,
),
),
],
),
Text(
aiIntervention!.explanation,
style: TextStyle(
color: AppColors.warmInk,
fontSize: 13,
),
),
if (aiIntervention!.suggestion != null &&
aiIntervention!.suggestion!.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.line),
),
child: Row(
children: [
const Text(
'建议表达:',
style: TextStyle(fontSize: 12),
),
Expanded(
child: Text(
aiIntervention!.suggestion!,
style: TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.green,
),
),
),
],
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: () {
final fix = aiIntervention!.suggestion!;
controller.text = fix;
send(overrideValidation: true, overrideText: fix);
},
icon: const Icon(Icons.check, size: 16),
label: Text(
'修正为 "${aiIntervention!.suggestion}" 并发送',
),
style: FilledButton.styleFrom(
backgroundColor: AppColors.green,
visualDensity: VisualDensity.compact,
),
),
OutlinedButton(
onPressed: () => send(overrideValidation: true),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
child: const Text('仍按原样发送'),
),
],
),
] else ...[
OutlinedButton(
onPressed: () => send(overrideValidation: true),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
child: const Text('仍按原样发送'),
),
],
],
),
),
TextField(
controller: controller,
onChanged: (value) {
@@ -757,13 +944,19 @@ class _DialoguePageState extends State<DialoguePage>
usedVoice &&
value != lastTranscript &&
!transcriptEdited;
final needClearAi = aiCheck != null || aiCheckError != null;
final needClearAi =
aiCheck != null ||
aiCheckError != null ||
aiIntervention != null ||
validationError != null;
if (needResetVoice || needClearAi) {
setState(() {
if (needResetVoice) transcriptEdited = true;
if (needClearAi) {
aiCheck = null;
aiCheckError = null;
aiIntervention = null;
validationError = null;
}
});
}
@@ -790,8 +983,16 @@ class _DialoguePageState extends State<DialoguePage>
onPressed: transcribing ? null : _toggleListening,
),
suffixIcon: IconButton(
icon: const Icon(Icons.send),
onPressed: waitingForReply ? null : send,
icon: interveningWithAi
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
onPressed: (waitingForReply || interveningWithAi)
? null
: send,
),
border: const OutlineInputBorder(),
),
@@ -802,7 +1003,8 @@ class _DialoguePageState extends State<DialoguePage>
final canCheck =
value.text.trim().isNotEmpty &&
!checkingWithAi &&
!waitingForReply;
!waitingForReply &&
!interveningWithAi;
return OutlinedButton.icon(
onPressed: canCheck ? _checkWithAi : null,
icon: checkingWithAi
@@ -828,11 +1030,29 @@ class _DialoguePageState extends State<DialoguePage>
children: [
Text('AI 检查:${aiCheck!.feedback}'),
for (final note in aiCheck!.missing) Text('· $note'),
if (aiCheck!.suggestion != null)
if (aiCheck!.suggestion != null) ...[
Text('参考改正:${aiCheck!.suggestion}'),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
controller.text = aiCheck!.suggestion!;
setState(() {
usedHelp = true;
aiCheck = null;
});
},
icon: const Icon(Icons.done, size: 16),
label: const Text('采纳该表达'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
),
],
Text(
aiCheck!.verdict == 'accepted'
? 'AI 仅作参考;这一轮是否完成仍由本地检查判断'
? 'AI 辅助判定;若词汇超出预设,发送时 AI 会自动进行语义理解与干预'
: '看过改正后再发送,本次对话会记为使用过提示。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
@@ -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'},
);
}),
);
});
});
}