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
@@ -5,6 +5,7 @@ import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/courses/courses.dart';
import '../../core/dialogue_decision.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
@@ -163,6 +164,10 @@ class _DialoguePageState extends State<DialoguePage>
String? aiCheckError;
bool interveningWithAi = false;
DialogueAiIntervention? aiIntervention;
int _validationGeneration = 0;
bool _turnHintUsed = false;
bool _turnTranslationUsed = false;
bool _turnCorrectionUsed = false;
/// 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.
@@ -258,6 +263,9 @@ class _DialoguePageState extends State<DialoguePage>
if (canRestore) {
stage = draft.stage;
usedHelp = draft.usedHelp;
_turnHintUsed = draft.currentTurnHintUsed;
_turnTranslationUsed = draft.currentTurnTranslationUsed;
_turnCorrectionUsed = draft.currentTurnCorrectionUsed;
for (var i = 0; i < draft.turns.length; i++) {
final t = draft.turns[i];
if (!t.isLearner && (t.translation == null || t.translation!.isEmpty)) {
@@ -310,11 +318,8 @@ class _DialoguePageState extends State<DialoguePage>
});
}
Future<void> send({
bool overrideValidation = false,
String? overrideText,
}) async {
final text = (overrideText ?? controller.text).trim();
Future<void> send() async {
final text = controller.text.trim();
if (text.isEmpty ||
stage >= script.prompts.length ||
waitingForReply ||
@@ -322,79 +327,100 @@ class _DialoguePageState extends State<DialoguePage>
return;
}
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()}”,这句还没做到。'
'可以点“提示”看示范,再补充一次。',
);
final local = _evaluateCurrentTaskLocally(text);
if (local == LocalDialogueVerdict.rejected) {
setState(() {
validationError = '请输入一个有效的英文回答。';
aiIntervention = null;
});
return;
}
await _executeSend(text, usedAiIntervention: overrideValidation);
final requestedStage = stage;
final requestGeneration = ++_validationGeneration;
final turnId =
'${widget.isLessonDialogue ? _lessonSegmentId : _scene.id}'
'-$requestedStage-$requestGeneration';
final aiWasAttempted = widget.state.aiProvider != AiProviderType.mock;
DialogueAiIntervention? intervention;
if (aiWasAttempted) {
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;
intervention = await AiService.instance.capabilities.dialogue
.evaluateTurn(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
partnerLine: partnerLine,
taskLabel: _currentTaskLabel(),
learnerText: text,
turnId: turnId,
hint: hintText,
level: _languageLessonId == null
? 'A0'
: lessonLevel(_languageLessonId!),
);
if (!mounted) return;
final stale =
requestGeneration != _validationGeneration ||
requestedStage != stage ||
controller.text.trim() != text;
if (stale) {
if (requestGeneration == _validationGeneration) {
setState(() => interveningWithAi = false);
}
return;
}
setState(() => interveningWithAi = false);
}
final decision = resolveDialogueTurnDecision(
local: local,
ai: intervention,
aiWasAttempted: aiWasAttempted,
isFreeScene: !widget.isLessonDialogue,
supportLevel: _currentSupportLevel,
);
if (!decision.canAdvance) {
setState(() {
aiIntervention = intervention;
validationError = intervention == null ? decision.feedback : null;
if (intervention?.suggestion != null) {
_turnCorrectionUsed = true;
usedHelp = true;
}
});
_saveDraft();
_scrollToBottom();
return;
}
await _executeSend(text, decision: decision);
}
Future<void> _executeSend(
String text, {
bool usedAiIntervention = false,
required DialogueTurnDecision decision,
}) async {
if (usedAiIntervention) {
usedHelp = true;
}
if (decision.assisted) usedHelp = true;
widget.state.recordDialogueAttempt(
taskId: widget.isLessonDialogue
? 'dialogue-$_lessonSegmentId-$stage'
: 'dialogue-scene-${_scene.id}-$stage',
sceneId: widget.isLessonDialogue ? null : _scene.id,
rawAnswer: text,
assisted: usedHelp,
assisted: decision.assisted,
evidenceOutcome: decision.evidenceOutcome,
spoken: usedVoice && !transcriptEdited,
recordingPath: widget.state.keepRecordings ? recordingPath : null,
);
@@ -413,10 +439,21 @@ class _DialoguePageState extends State<DialoguePage>
aiIntervention = null;
aiCheck = null;
aiCheckError = null;
final feedback = decision.feedback;
if (feedback != null && feedback.trim().isNotEmpty) {
latestFeedback = feedback.trim();
}
_turnHintUsed = false;
_turnTranslationUsed = false;
_turnCorrectionUsed = false;
usedVoice = false;
transcriptEdited = false;
lastTranscript = '';
recordingPath = null;
});
_saveDraft();
_scrollToBottom();
final aiResponse = await AiService.instance.dialogueReply(
final aiResponse = await AiService.instance.capabilities.dialogue.reply(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
@@ -482,6 +519,9 @@ class _DialoguePageState extends State<DialoguePage>
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
currentTurnHintUsed: _turnHintUsed,
currentTurnTranslationUsed: _turnTranslationUsed,
currentTurnCorrectionUsed: _turnCorrectionUsed,
);
if (widget.isLessonDialogue) {
widget.state.saveDialogueDraft(draft);
@@ -493,7 +533,7 @@ class _DialoguePageState extends State<DialoguePage>
String _currentTaskLabel() => dialogueTaskLabel(script, stage);
/// Optional spelling and grammar check of the turn before it is sent. It
/// never decides whether the turn passes; [_matchesCurrentTask] does.
/// never decides whether the turn passes; the combined local/AI decision does.
Future<void> _checkWithAi() async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi || stage >= script.prompts.length) {
@@ -509,18 +549,22 @@ class _DialoguePageState extends State<DialoguePage>
aiCheckError = null;
});
final partnerLine = turns.where((turn) => !turn.isLearner).lastOrNull;
final feedback = await AiService.instance.answerFeedback(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
answerId: 'dialogue-$checkedStage',
target: checkedStage < script.hints.length
? script.hints[checkedStage]
: _currentTaskLabel(),
taskPrompt: '对方说:${partnerLine?.text ?? ''} 本轮任务:${_currentTaskLabel()}',
answer: answer,
level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!),
);
final feedback = await AiService.instance.capabilities.evaluation
.evaluateAnswer(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
answerId: 'dialogue-$checkedStage',
target: checkedStage < script.hints.length
? script.hints[checkedStage]
: _currentTaskLabel(),
taskPrompt:
'对方说:${partnerLine?.text ?? ''} 本轮任务:${_currentTaskLabel()}',
answer: answer,
level: _languageLessonId == null
? 'A0'
: lessonLevel(_languageLessonId!),
);
if (!mounted) return;
// Drop a result for a turn already sent or an answer since changed.
if (stage != checkedStage || controller.text.trim() != answer) {
@@ -534,21 +578,28 @@ class _DialoguePageState extends State<DialoguePage>
? '暂时无法获得 AI 检查结果。你的回答保留在这里,可稍后重试或直接发送。'
: null;
// A shown correction is help, like the hint and translation chips.
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
if (feedback != null && feedback.verdict != 'accepted') {
usedHelp = true;
_turnCorrectionUsed = true;
}
});
_saveDraft();
_scrollToBottom();
}
bool _matchesCurrentTask(String response) {
// Every dialogue now checks the language the turn is teaching. The old
// whole-lesson branch matched bare keywords such as 'it' anywhere in the
// sentence, so an off-task answer passed every stage.
LocalDialogueVerdict _evaluateCurrentTaskLocally(String response) {
if (widget.isLessonDialogue &&
lessonById(widget.state.activeLessonId).segments.length > 1) {
return matchesSegmentDialogue(_lessonSegmentId, stage, response);
return evaluateSegmentDialogueLocally(_lessonSegmentId, stage, response);
}
return matchesDialogueStage(script, stage, response);
return evaluateDialogueStageLocally(script, stage, response);
}
DialogueSupportLevel get _currentSupportLevel {
if (_turnCorrectionUsed) return DialogueSupportLevel.correction;
if (_turnTranslationUsed) return DialogueSupportLevel.translation;
if (_turnHintUsed) return DialogueSupportLevel.hint;
return DialogueSupportLevel.none;
}
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
@@ -608,6 +659,8 @@ class _DialoguePageState extends State<DialoguePage>
if (trans != null && trans.isNotEmpty) {
setState(() {
usedHelp = true;
_turnTranslationUsed = true;
turns[index] = turn.copyWith(translation: trans);
_shownTranslations.add(index);
});
@@ -617,10 +670,12 @@ class _DialoguePageState extends State<DialoguePage>
}
setState(() {
usedHelp = true;
_turnTranslationUsed = true;
_shownTranslations.add(index);
turns[index] = turn.copyWith(translation: "正在翻译…");
});
final fetched = await AiService.instance.temporaryDefinition(
final fetched = await AiService.instance.capabilities.lexicon.define(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
@@ -650,6 +705,7 @@ class _DialoguePageState extends State<DialoguePage>
if (trans != null && trans.isNotEmpty) {
setState(() {
usedHelp = true;
_turnTranslationUsed = true;
hint = "对方说:$trans";
_shownTranslations.add(latestAiIndex);
turns[latestAiIndex] = latestAi.copyWith(translation: trans);
@@ -661,11 +717,12 @@ class _DialoguePageState extends State<DialoguePage>
setState(() {
usedHelp = true;
_turnTranslationUsed = true;
hint = "正在获取对方英文翻译…";
_shownTranslations.add(latestAiIndex);
});
final fetched = await AiService.instance.temporaryDefinition(
final fetched = await AiService.instance.capabilities.lexicon.define(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
@@ -688,7 +745,10 @@ class _DialoguePageState extends State<DialoguePage>
if (latest == null) return;
await VoiceService.instance.speak(latest.text, slow: slow);
if (!slow || !mounted) return;
setState(() => usedHelp = true);
setState(() {
usedHelp = true;
_turnHintUsed = true;
});
_saveDraft();
}
@@ -716,6 +776,7 @@ class _DialoguePageState extends State<DialoguePage>
void _showHint() {
setState(() {
usedHelp = true;
_turnHintUsed = true;
final hintIdx = stage < script.hints.length
? stage
: (script.hints.isNotEmpty ? script.hints.length - 1 : 0);
@@ -731,6 +792,37 @@ class _DialoguePageState extends State<DialoguePage>
initialText: turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? '',
);
Future<void> _acceptAiCorrection() async {
final intervention = aiIntervention;
final fix = intervention?.suggestion?.trim();
if (intervention == null || fix == null || fix.isEmpty) return;
setState(() {
controller.text = fix;
_turnCorrectionUsed = true;
usedHelp = true;
aiIntervention = null;
validationError = null;
});
await _executeSend(
fix,
decision: DialogueTurnDecision(
canAdvance: true,
evidenceOutcome: EvidenceKind.assisted,
validationSource: DialogueValidationSource.ai,
supportLevel: DialogueSupportLevel.correction,
feedback: intervention.explanation,
suggestion: fix,
),
);
}
void _continueEditing() {
setState(() {
aiIntervention = null;
validationError = null;
});
}
@override
Widget build(BuildContext context) {
final finished = stage == script.prompts.length;
@@ -862,10 +954,7 @@ class _DialoguePageState extends State<DialoguePage>
),
Text(
aiIntervention!.explanation,
style: TextStyle(
color: AppColors.warmInk,
fontSize: 13,
),
style: TextStyle(color: AppColors.warmInk, fontSize: 13),
),
if (aiIntervention!.suggestion != null &&
aiIntervention!.suggestion!.isNotEmpty) ...[
@@ -881,10 +970,7 @@ class _DialoguePageState extends State<DialoguePage>
),
child: Row(
children: [
const Text(
'建议表达:',
style: TextStyle(fontSize: 12),
),
const Text('建议表达:', style: TextStyle(fontSize: 12)),
Expanded(
child: Text(
aiIntervention!.suggestion!,
@@ -902,11 +988,7 @@ class _DialoguePageState extends State<DialoguePage>
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: () {
final fix = aiIntervention!.suggestion!;
controller.text = fix;
send(overrideValidation: true, overrideText: fix);
},
onPressed: _acceptAiCorrection,
icon: const Icon(Icons.check, size: 16),
label: Text(
'修正为 "${aiIntervention!.suggestion}" 并发送',
@@ -917,21 +999,21 @@ class _DialoguePageState extends State<DialoguePage>
),
),
OutlinedButton(
onPressed: () => send(overrideValidation: true),
onPressed: _continueEditing,
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
child: const Text('仍按原样发送'),
child: const Text('继续修改'),
),
],
),
] else ...[
OutlinedButton(
onPressed: () => send(overrideValidation: true),
onPressed: _continueEditing,
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
child: const Text('仍按原样发送'),
child: const Text('继续修改'),
),
],
],
@@ -941,9 +1023,7 @@ class _DialoguePageState extends State<DialoguePage>
controller: controller,
onChanged: (value) {
final needResetVoice =
usedVoice &&
value != lastTranscript &&
!transcriptEdited;
usedVoice && value != lastTranscript && !transcriptEdited;
final needClearAi =
aiCheck != null ||
aiCheckError != null ||
@@ -1014,9 +1094,7 @@ class _DialoguePageState extends State<DialoguePage>
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.spellcheck),
label: Text(
checkingWithAi ? '正在检查…' : '发送前 AI 检查语法和拼写(可选)',
),
label: Text(checkingWithAi ? '正在检查…' : '发送前 AI 检查语法和拼写(可选)'),
);
},
),
@@ -1039,6 +1117,7 @@ class _DialoguePageState extends State<DialoguePage>
controller.text = aiCheck!.suggestion!;
setState(() {
usedHelp = true;
_turnCorrectionUsed = true;
aiCheck = null;
});
},