feat: improve AI dialogue evaluation and capability boundaries
This commit is contained in:
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user