feat: improve AI dialogue evaluation and capability boundaries
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
part of '../ai_service.dart';
|
||||
|
||||
/// Advisory evaluation for open-ended learner answers. Local rules still
|
||||
/// decide whether an attempt counts as evidence or changes learning state.
|
||||
class AiAnswerEvaluationCapability {
|
||||
const AiAnswerEvaluationCapability._(this._service);
|
||||
|
||||
final AiService _service;
|
||||
|
||||
static const descriptor = AiCapabilityDescriptor(
|
||||
id: 'answer-evaluation',
|
||||
promptVersion: 1,
|
||||
outputContract: 'WritingAiFeedback',
|
||||
);
|
||||
|
||||
Future<WritingAiFeedback?> evaluateWriting({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required String lessonId,
|
||||
required String taskPrompt,
|
||||
required String answer,
|
||||
String level = 'A0',
|
||||
}) => _service.writingFeedback(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
lessonId: lessonId,
|
||||
taskPrompt: taskPrompt,
|
||||
answer: answer,
|
||||
level: level,
|
||||
);
|
||||
|
||||
Future<WritingAiFeedback?> evaluateAnswer({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required String answerId,
|
||||
required String target,
|
||||
required String taskPrompt,
|
||||
required String answer,
|
||||
String level = 'A0',
|
||||
}) => _service.answerFeedback(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
answerId: answerId,
|
||||
target: target,
|
||||
taskPrompt: taskPrompt,
|
||||
answer: answer,
|
||||
level: level,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
part of '../ai_service.dart';
|
||||
|
||||
/// Stable metadata for an AI capability boundary.
|
||||
///
|
||||
/// [promptVersion] is bumped whenever prompt semantics or the expected model
|
||||
/// contract changes. [outputContract] names the validated application model;
|
||||
/// model output must never flow directly into learning state.
|
||||
class AiCapabilityDescriptor {
|
||||
const AiCapabilityDescriptor({
|
||||
required this.id,
|
||||
required this.promptVersion,
|
||||
required this.outputContract,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int promptVersion;
|
||||
final String outputContract;
|
||||
}
|
||||
|
||||
/// Typed entry points for bounded AI features.
|
||||
///
|
||||
/// This is deliberately a capability registry, not an autonomous agent. The
|
||||
/// application remains responsible for sequencing, fallback, completion and
|
||||
/// mastery decisions.
|
||||
class AiCapabilities {
|
||||
AiCapabilities._(this._service);
|
||||
|
||||
final AiService _service;
|
||||
|
||||
late final speech = AiSpeechTranscriptionCapability._(_service);
|
||||
late final lexicon = AiLexiconExplanationCapability._(_service);
|
||||
late final dialogue = AiDialogueCoachCapability._(_service);
|
||||
late final evaluation = AiAnswerEvaluationCapability._(_service);
|
||||
late final review = AiReviewGenerationCapability._(_service);
|
||||
|
||||
List<AiCapabilityDescriptor> get descriptors => [
|
||||
AiSpeechTranscriptionCapability.descriptor,
|
||||
AiLexiconExplanationCapability.descriptor,
|
||||
AiDialogueCoachCapability.descriptor,
|
||||
AiAnswerEvaluationCapability.descriptor,
|
||||
AiReviewGenerationCapability.descriptor,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
part of '../ai_service.dart';
|
||||
|
||||
/// Supplies bounded dialogue suggestions while the application owns the turn
|
||||
/// state machine and all completion decisions.
|
||||
class AiDialogueCoachCapability {
|
||||
const AiDialogueCoachCapability._(this._service);
|
||||
|
||||
final AiService _service;
|
||||
|
||||
static const descriptor = AiCapabilityDescriptor(
|
||||
id: 'dialogue-coach',
|
||||
promptVersion: 2,
|
||||
outputContract: 'DialogueAiResponse or DialogueAiIntervention',
|
||||
);
|
||||
|
||||
Future<DialogueAiResponse?> reply({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required List<Map<String, String>> history,
|
||||
required String aiGoal,
|
||||
required String learnerTask,
|
||||
String level = 'A0',
|
||||
List<String> allowedLanguage = const [],
|
||||
}) => _service.dialogueReply(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
history: history,
|
||||
aiGoal: aiGoal,
|
||||
learnerTask: learnerTask,
|
||||
level: level,
|
||||
allowedLanguage: allowedLanguage,
|
||||
);
|
||||
|
||||
Future<DialogueAiIntervention?> evaluateTurn({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required String partnerLine,
|
||||
required String taskLabel,
|
||||
required String learnerText,
|
||||
String? turnId,
|
||||
String? hint,
|
||||
String level = 'A0',
|
||||
}) => _service.checkDialogueIntervention(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
partnerLine: partnerLine,
|
||||
taskLabel: taskLabel,
|
||||
learnerText: learnerText,
|
||||
turnId: turnId,
|
||||
hint: hint,
|
||||
level: level,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
part of '../ai_service.dart';
|
||||
|
||||
/// Read-only language explanations. Results may be cached for display but do
|
||||
/// not affect review scheduling, evidence or mastery.
|
||||
class AiLexiconExplanationCapability {
|
||||
const AiLexiconExplanationCapability._(this._service);
|
||||
|
||||
final AiService _service;
|
||||
|
||||
static const descriptor = AiCapabilityDescriptor(
|
||||
id: 'lexicon-explanation',
|
||||
promptVersion: 1,
|
||||
outputContract: 'Temporary definition or SentenceAnalysisResult',
|
||||
);
|
||||
|
||||
Future<String?> define({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required String text,
|
||||
}) => _service.temporaryDefinition(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
text: text,
|
||||
);
|
||||
|
||||
Future<SentenceAnalysisResult?> analyzeSentence({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required String text,
|
||||
}) => _service.analyzeSentence(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
text: text,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
part of '../ai_service.dart';
|
||||
|
||||
/// Generates bounded review material. Generation and audit remain explicit
|
||||
/// workflow steps so the model cannot autonomously publish a lesson.
|
||||
class AiReviewGenerationCapability {
|
||||
const AiReviewGenerationCapability._(this._service);
|
||||
|
||||
final AiService _service;
|
||||
|
||||
static const descriptor = AiCapabilityDescriptor(
|
||||
id: 'review-generation',
|
||||
promptVersion: 1,
|
||||
outputContract: 'GeneratedReviewVariant or audited GeneratedLesson',
|
||||
);
|
||||
|
||||
Future<GeneratedReviewVariant?> generateVariant({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required String targetItemId,
|
||||
required String basePrompt,
|
||||
}) => _service.generateReviewVariant(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
targetItemId: targetItemId,
|
||||
basePrompt: basePrompt,
|
||||
);
|
||||
|
||||
Future<GeneratedLesson?> generateAdaptiveLesson({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required String targetItemId,
|
||||
required String targetLabel,
|
||||
}) => _service.generateAdaptiveLesson(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
targetItemId: targetItemId,
|
||||
targetLabel: targetLabel,
|
||||
);
|
||||
|
||||
Future<bool> auditAdaptiveLesson({
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
required GeneratedLesson lesson,
|
||||
}) => _service.auditGeneratedLesson(
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
lesson: lesson,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
part of '../ai_service.dart';
|
||||
|
||||
/// Converts learner audio into a transcript. It does not award speech
|
||||
/// evidence; transcript confirmation remains an application decision.
|
||||
class AiSpeechTranscriptionCapability {
|
||||
const AiSpeechTranscriptionCapability._(this._service);
|
||||
|
||||
final AiService _service;
|
||||
|
||||
static const descriptor = AiCapabilityDescriptor(
|
||||
id: 'speech-transcription',
|
||||
promptVersion: 1,
|
||||
outputContract: 'String transcript',
|
||||
);
|
||||
|
||||
Future<String?> transcribe({
|
||||
required String filePath,
|
||||
required AiProviderType provider,
|
||||
required String endpoint,
|
||||
required String model,
|
||||
}) => _service.transcribeAudio(
|
||||
filePath: filePath,
|
||||
provider: provider,
|
||||
endpoint: endpoint,
|
||||
model: model,
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,13 @@ import 'models.dart';
|
||||
import 'courses/courses.dart';
|
||||
import 'generated_content.dart';
|
||||
|
||||
part 'ai_capabilities/capability_contract.dart';
|
||||
part 'ai_capabilities/speech_transcription_capability.dart';
|
||||
part 'ai_capabilities/lexicon_explanation_capability.dart';
|
||||
part 'ai_capabilities/dialogue_coach_capability.dart';
|
||||
part 'ai_capabilities/answer_evaluation_capability.dart';
|
||||
part 'ai_capabilities/review_generation_capability.dart';
|
||||
|
||||
class AiConnectionResult {
|
||||
const AiConnectionResult({required this.ok, required this.message});
|
||||
final bool ok;
|
||||
@@ -22,6 +29,7 @@ class AiConnectionResult {
|
||||
class AiService {
|
||||
AiService._();
|
||||
static final instance = AiService._();
|
||||
late final AiCapabilities capabilities = AiCapabilities._(this);
|
||||
static const _keyName = 'ai_api_key';
|
||||
final _secureStorage = const FlutterSecureStorage();
|
||||
String? _fallbackApiKey;
|
||||
@@ -1000,6 +1008,7 @@ Learner wrote: $answer''';
|
||||
required String partnerLine,
|
||||
required String taskLabel,
|
||||
required String learnerText,
|
||||
String? turnId,
|
||||
String? hint,
|
||||
String level = 'A0',
|
||||
}) async {
|
||||
@@ -1016,7 +1025,12 @@ Learner wrote: $answer''';
|
||||
'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'
|
||||
' "schemaVersion": "dialogue-intervention-2",\n'
|
||||
' "turnId": "${turnId ?? ''}",\n'
|
||||
' "accepted": true or false,\n'
|
||||
' "goalSatisfied": true or false,\n'
|
||||
' "verdict": "accepted, correctable, off_topic, or uncertain",\n'
|
||||
' "reasonCode": "short stable reason",\n'
|
||||
' "suggestion": "corrected English sentence (or null if accepted as-is)",\n'
|
||||
' "explanation": "concise, warm Chinese explanation (1-2 sentences)"\n'
|
||||
'}\n'
|
||||
@@ -1033,10 +1047,13 @@ Learner wrote: $answer''';
|
||||
temperature: 0,
|
||||
maxTokens: 250,
|
||||
);
|
||||
return _decodeDialogueIntervention(content);
|
||||
return _decodeDialogueIntervention(content, expectedTurnId: turnId);
|
||||
}
|
||||
|
||||
DialogueAiIntervention? _decodeDialogueIntervention(String? content) {
|
||||
DialogueAiIntervention? _decodeDialogueIntervention(
|
||||
String? content, {
|
||||
String? expectedTurnId,
|
||||
}) {
|
||||
if (content == null || content.trim().isEmpty) return null;
|
||||
try {
|
||||
var sanitized = content.trim();
|
||||
@@ -1052,22 +1069,47 @@ Learner wrote: $answer''';
|
||||
final data = jsonDecode(sanitized);
|
||||
if (data is! Map<String, dynamic>) return null;
|
||||
|
||||
final accepted = data['accepted'] == true;
|
||||
final rawVerdict = data['verdict'];
|
||||
if (data['accepted'] is! bool && rawVerdict is! String) return null;
|
||||
final accepted = data['accepted'] == true || rawVerdict == 'accepted';
|
||||
final rawSuggestion = data['suggestion'] as String?;
|
||||
final suggestion =
|
||||
(rawSuggestion != null &&
|
||||
rawSuggestion.trim().isNotEmpty &&
|
||||
rawSuggestion.trim().toLowerCase() != 'null')
|
||||
? rawSuggestion.trim()
|
||||
: null;
|
||||
rawSuggestion.trim().isNotEmpty &&
|
||||
rawSuggestion.trim().toLowerCase() != 'null')
|
||||
? rawSuggestion.trim()
|
||||
: null;
|
||||
final explanation =
|
||||
(data['explanation'] as String?)?.trim() ??
|
||||
(accepted ? '回答符合要求。' : '建议调整表达后再试。');
|
||||
if (explanation.length > 240 || (suggestion?.length ?? 0) > 120) {
|
||||
return null;
|
||||
}
|
||||
final responseTurnId = data['turnId'] as String?;
|
||||
if (expectedTurnId != null &&
|
||||
responseTurnId != null &&
|
||||
responseTurnId != expectedTurnId) {
|
||||
return null;
|
||||
}
|
||||
final verdict = switch (rawVerdict) {
|
||||
'accepted' => DialogueAiVerdict.accepted,
|
||||
'correctable' => DialogueAiVerdict.correctable,
|
||||
'off_topic' => DialogueAiVerdict.offTopic,
|
||||
_ when accepted => DialogueAiVerdict.accepted,
|
||||
_ when suggestion != null => DialogueAiVerdict.correctable,
|
||||
_ => DialogueAiVerdict.uncertain,
|
||||
};
|
||||
|
||||
return DialogueAiIntervention(
|
||||
accepted: accepted,
|
||||
suggestion: suggestion,
|
||||
explanation: explanation,
|
||||
schemaVersion:
|
||||
data['schemaVersion'] as String? ?? 'dialogue-intervention-1',
|
||||
turnId: responseTurnId,
|
||||
verdict: verdict,
|
||||
goalSatisfied: data['goalSatisfied'] as bool? ?? accepted,
|
||||
reasonCode: data['reasonCode'] as String? ?? 'legacy-response',
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
@@ -1085,7 +1127,8 @@ Learner wrote: $answer''';
|
||||
}) async {
|
||||
if (provider == AiProviderType.mock) return null;
|
||||
final level = itemLevel(targetItemId);
|
||||
final lessonId = 'ai-${level.toLowerCase()}-${targetItemId.toLowerCase()}-1';
|
||||
final lessonId =
|
||||
'ai-${level.toLowerCase()}-${targetItemId.toLowerCase()}-1';
|
||||
final stageVersion = '$level-1.0';
|
||||
final instruction =
|
||||
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, the lessonId given below, revision 1, stageVersion $stageVersion, source aiGenerated, status validated, targetItemIds [target item id], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [target item id]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple $level English for the target expression. No new vocabulary, markdown, real phone numbers, or personal data.\n'
|
||||
|
||||
@@ -403,6 +403,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
bool spoken = false,
|
||||
String? recordingPath,
|
||||
String? sceneId,
|
||||
EvidenceKind? evidenceOutcome,
|
||||
}) {
|
||||
final now = DateTime.now();
|
||||
final inScene = sceneId != null;
|
||||
@@ -418,9 +419,19 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
|
||||
if (coreItemUsedIn(id, rawAnswer)) id,
|
||||
];
|
||||
if (itemIds.isEmpty && !inScene) itemIds.add(_primaryTargetId);
|
||||
final outcome = assisted
|
||||
? EvidenceKind.assisted
|
||||
: (inScene ? EvidenceKind.independentSuccess : EvidenceKind.pending);
|
||||
final requestedOutcome =
|
||||
evidenceOutcome ??
|
||||
(assisted
|
||||
? EvidenceKind.assisted
|
||||
: (inScene
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.pending));
|
||||
// A controlled lesson dialogue can never become independent evidence,
|
||||
// regardless of what a UI or AI caller requests.
|
||||
final outcome =
|
||||
!inScene && requestedOutcome == EvidenceKind.independentSuccess
|
||||
? EvidenceKind.pending
|
||||
: requestedOutcome;
|
||||
for (var index = 0; index < itemIds.length; index++) {
|
||||
final id = itemIds[index];
|
||||
attemptEvidence.add(
|
||||
|
||||
@@ -431,6 +431,11 @@ DialogueDraft? _dialogueDraftFromJson(Map<String, dynamic>? saved) {
|
||||
lessonId: saved['lessonId'] as String,
|
||||
stage: saved['stage'] as int? ?? 0,
|
||||
usedHelp: saved['usedHelp'] as bool? ?? false,
|
||||
currentTurnHintUsed: saved['currentTurnHintUsed'] as bool? ?? false,
|
||||
currentTurnTranslationUsed:
|
||||
saved['currentTurnTranslationUsed'] as bool? ?? false,
|
||||
currentTurnCorrectionUsed:
|
||||
saved['currentTurnCorrectionUsed'] as bool? ?? false,
|
||||
turns: (saved['turns'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(
|
||||
@@ -451,6 +456,9 @@ Map<String, dynamic>? _dialogueDraftToJson(DialogueDraft? draft) =>
|
||||
'lessonId': draft.lessonId,
|
||||
'stage': draft.stage,
|
||||
'usedHelp': draft.usedHelp,
|
||||
'currentTurnHintUsed': draft.currentTurnHintUsed,
|
||||
'currentTurnTranslationUsed': draft.currentTurnTranslationUsed,
|
||||
'currentTurnCorrectionUsed': draft.currentTurnCorrectionUsed,
|
||||
'turns': draft.turns
|
||||
.map(
|
||||
(turn) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'answer_rules.dart';
|
||||
import 'core_items.dart';
|
||||
import 'course_catalog.dart';
|
||||
import 'course_models.dart';
|
||||
import '../models.dart';
|
||||
|
||||
/// A reply that is at least one real word; turns without a declared rule
|
||||
/// accept it.
|
||||
@@ -17,6 +18,24 @@ bool coreItemUsedIn(String id, String input) {
|
||||
return matchesRule(input, rule);
|
||||
}
|
||||
|
||||
LocalDialogueVerdict evaluateSegmentDialogueLocally(
|
||||
String segmentId,
|
||||
int stage,
|
||||
String response,
|
||||
) {
|
||||
final segment = findLessonSegment(segmentId);
|
||||
if (segment == null || stage >= segment.dialogueRequiredTerms.length) {
|
||||
return LocalDialogueVerdict.rejected;
|
||||
}
|
||||
final text = normalizeAnswer(response);
|
||||
final terms = segment.dialogueRequiredTerms[stage];
|
||||
if (terms.isNotEmpty && groupSatisfied(text, terms)) {
|
||||
return LocalDialogueVerdict.accepted;
|
||||
}
|
||||
if (!_isRealReply(text)) return LocalDialogueVerdict.rejected;
|
||||
return LocalDialogueVerdict.uncertain;
|
||||
}
|
||||
|
||||
bool matchesSegmentDialogue(String segmentId, int stage, String response) {
|
||||
final segment = findLessonSegment(segmentId);
|
||||
if (segment == null || stage >= segment.dialogueRequiredTerms.length) {
|
||||
@@ -24,10 +43,7 @@ bool matchesSegmentDialogue(String segmentId, int stage, String response) {
|
||||
}
|
||||
final text = normalizeAnswer(response);
|
||||
final terms = segment.dialogueRequiredTerms[stage];
|
||||
// A turn that declares no reviewed term (adapted units) just needs a real
|
||||
// reply, matching how free-scene turns are validated.
|
||||
if (terms.isEmpty) return _isRealReply(text);
|
||||
return groupSatisfied(text, terms);
|
||||
return terms.isEmpty ? _isRealReply(text) : groupSatisfied(text, terms);
|
||||
}
|
||||
|
||||
bool matchesSegmentIndependent(String segmentId, String response) {
|
||||
@@ -64,15 +80,32 @@ String? independentShortfall(String segmentId, String response) {
|
||||
|
||||
/// Whole-lesson and free-scene dialogues validate the same way segment
|
||||
/// dialogues do: the turn has to contain the language the turn is teaching.
|
||||
LocalDialogueVerdict evaluateDialogueStageLocally(
|
||||
LessonDialogue script,
|
||||
int stage,
|
||||
String response,
|
||||
) {
|
||||
final text = normalizeAnswer(response);
|
||||
if (stage < 0 || stage >= script.requiredTerms.length) {
|
||||
return _isRealReply(text)
|
||||
? LocalDialogueVerdict.uncertain
|
||||
: LocalDialogueVerdict.rejected;
|
||||
}
|
||||
final terms = script.requiredTerms[stage];
|
||||
if (terms.isNotEmpty && groupSatisfied(text, terms)) {
|
||||
return LocalDialogueVerdict.accepted;
|
||||
}
|
||||
if (!_isRealReply(text)) return LocalDialogueVerdict.rejected;
|
||||
return LocalDialogueVerdict.uncertain;
|
||||
}
|
||||
|
||||
bool matchesDialogueStage(LessonDialogue script, int stage, String response) {
|
||||
final text = normalizeAnswer(response);
|
||||
if (stage < 0 || stage >= script.requiredTerms.length) {
|
||||
// No declared requirement: accept anything that is actually a word.
|
||||
return _isRealReply(text);
|
||||
}
|
||||
final terms = script.requiredTerms[stage];
|
||||
if (terms.isEmpty) return _isRealReply(text);
|
||||
return groupSatisfied(text, terms);
|
||||
return terms.isEmpty ? _isRealReply(text) : groupSatisfied(text, terms);
|
||||
}
|
||||
|
||||
String dialogueTaskLabel(LessonDialogue script, int stage) =>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'models.dart';
|
||||
|
||||
/// Combines strict local matching with an advisory semantic AI result.
|
||||
///
|
||||
/// Progress is deliberately permissive: a strict local match can continue if
|
||||
/// AI is unavailable or disagrees. Mastery evidence is conservative: a free
|
||||
/// scene produces independent evidence only when local and AI agree and the
|
||||
/// learner received no answer-level help.
|
||||
DialogueTurnDecision resolveDialogueTurnDecision({
|
||||
required LocalDialogueVerdict local,
|
||||
required DialogueAiIntervention? ai,
|
||||
required bool aiWasAttempted,
|
||||
required bool isFreeScene,
|
||||
required DialogueSupportLevel supportLevel,
|
||||
}) {
|
||||
if (local == LocalDialogueVerdict.rejected) {
|
||||
return const DialogueTurnDecision(
|
||||
canAdvance: false,
|
||||
evidenceOutcome: EvidenceKind.languageError,
|
||||
validationSource: DialogueValidationSource.local,
|
||||
supportLevel: DialogueSupportLevel.none,
|
||||
feedback: '请输入一个有效的英文回答。',
|
||||
);
|
||||
}
|
||||
|
||||
final aiAccepted =
|
||||
ai?.accepted == true &&
|
||||
ai?.goalSatisfied == true &&
|
||||
ai?.verdict == DialogueAiVerdict.accepted;
|
||||
final assisted = switch (supportLevel) {
|
||||
DialogueSupportLevel.hint ||
|
||||
DialogueSupportLevel.translation ||
|
||||
DialogueSupportLevel.correction ||
|
||||
DialogueSupportLevel.skipped => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (local == LocalDialogueVerdict.accepted && aiAccepted) {
|
||||
return DialogueTurnDecision(
|
||||
canAdvance: true,
|
||||
evidenceOutcome: !isFreeScene
|
||||
? (assisted ? EvidenceKind.assisted : EvidenceKind.pending)
|
||||
: (assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.independentSuccess),
|
||||
validationSource: DialogueValidationSource.localAndAi,
|
||||
supportLevel: supportLevel,
|
||||
feedback: ai?.explanation,
|
||||
);
|
||||
}
|
||||
|
||||
if (aiAccepted) {
|
||||
return DialogueTurnDecision(
|
||||
canAdvance: true,
|
||||
evidenceOutcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending,
|
||||
validationSource: DialogueValidationSource.ai,
|
||||
supportLevel: supportLevel == DialogueSupportLevel.none
|
||||
? DialogueSupportLevel.validationOnly
|
||||
: supportLevel,
|
||||
feedback: ai?.explanation,
|
||||
);
|
||||
}
|
||||
|
||||
if (local == LocalDialogueVerdict.accepted) {
|
||||
final aiExplicitlyRejected =
|
||||
ai?.verdict == DialogueAiVerdict.correctable ||
|
||||
ai?.verdict == DialogueAiVerdict.offTopic;
|
||||
if (aiExplicitlyRejected) {
|
||||
return DialogueTurnDecision(
|
||||
canAdvance: false,
|
||||
evidenceOutcome: assisted
|
||||
? EvidenceKind.assisted
|
||||
: EvidenceKind.pending,
|
||||
validationSource: DialogueValidationSource.disagreement,
|
||||
supportLevel: supportLevel,
|
||||
feedback: ai?.explanation,
|
||||
suggestion: ai?.suggestion,
|
||||
);
|
||||
}
|
||||
final source = aiWasAttempted
|
||||
? (ai == null
|
||||
? DialogueValidationSource.unavailable
|
||||
: DialogueValidationSource.disagreement)
|
||||
: DialogueValidationSource.local;
|
||||
return DialogueTurnDecision(
|
||||
canAdvance: true,
|
||||
evidenceOutcome: assisted
|
||||
? EvidenceKind.assisted
|
||||
: aiWasAttempted
|
||||
? EvidenceKind.pending
|
||||
: isFreeScene
|
||||
? EvidenceKind.independentSuccess
|
||||
: EvidenceKind.pending,
|
||||
validationSource: source,
|
||||
supportLevel: supportLevel,
|
||||
feedback: ai?.explanation,
|
||||
);
|
||||
}
|
||||
|
||||
return DialogueTurnDecision(
|
||||
canAdvance: false,
|
||||
evidenceOutcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending,
|
||||
validationSource: aiWasAttempted
|
||||
? (ai == null
|
||||
? DialogueValidationSource.unavailable
|
||||
: DialogueValidationSource.localAndAi)
|
||||
: DialogueValidationSource.local,
|
||||
supportLevel: supportLevel,
|
||||
feedback: ai?.explanation ?? '暂时无法确认这句是否完成本轮任务,请换一种说法。',
|
||||
suggestion: ai?.suggestion,
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,58 @@ enum EvidenceKind {
|
||||
pending,
|
||||
}
|
||||
|
||||
/// Local dialogue matching is intentionally tri-state. A natural English
|
||||
/// reply that does not match a strict taught-language rule is unknown, not
|
||||
/// automatically wrong.
|
||||
enum LocalDialogueVerdict { accepted, rejected, uncertain }
|
||||
|
||||
enum DialogueAiVerdict { accepted, correctable, offTopic, uncertain }
|
||||
|
||||
enum DialogueValidationSource {
|
||||
local,
|
||||
ai,
|
||||
localAndAi,
|
||||
disagreement,
|
||||
unavailable,
|
||||
}
|
||||
|
||||
enum DialogueSupportLevel {
|
||||
none,
|
||||
validationOnly,
|
||||
hint,
|
||||
translation,
|
||||
correction,
|
||||
skipped,
|
||||
}
|
||||
|
||||
/// The application-owned result of combining local rules with advisory AI.
|
||||
/// Conversation progress and mastery evidence are deliberately separate.
|
||||
class DialogueTurnDecision {
|
||||
const DialogueTurnDecision({
|
||||
required this.canAdvance,
|
||||
required this.evidenceOutcome,
|
||||
required this.validationSource,
|
||||
required this.supportLevel,
|
||||
this.feedback,
|
||||
this.suggestion,
|
||||
});
|
||||
|
||||
final bool canAdvance;
|
||||
final EvidenceKind evidenceOutcome;
|
||||
final DialogueValidationSource validationSource;
|
||||
final DialogueSupportLevel supportLevel;
|
||||
final String? feedback;
|
||||
final String? suggestion;
|
||||
|
||||
bool get assisted => switch (supportLevel) {
|
||||
DialogueSupportLevel.hint ||
|
||||
DialogueSupportLevel.translation ||
|
||||
DialogueSupportLevel.correction ||
|
||||
DialogueSupportLevel.skipped => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
class AttemptEvidence {
|
||||
const AttemptEvidence({
|
||||
required this.id,
|
||||
@@ -117,7 +169,12 @@ class DialogueAiIntervention {
|
||||
required this.accepted,
|
||||
this.suggestion,
|
||||
required this.explanation,
|
||||
});
|
||||
this.schemaVersion = 'dialogue-intervention-1',
|
||||
this.turnId,
|
||||
this.verdict = DialogueAiVerdict.uncertain,
|
||||
bool? goalSatisfied,
|
||||
this.reasonCode = 'unspecified',
|
||||
}) : goalSatisfied = goalSatisfied ?? accepted;
|
||||
|
||||
/// Whether the learner's response is semantically acceptable for the turn.
|
||||
final bool accepted;
|
||||
@@ -127,6 +184,11 @@ class DialogueAiIntervention {
|
||||
|
||||
/// Encouraging, concise Chinese explanation of the situation and recommendation.
|
||||
final String explanation;
|
||||
final String schemaVersion;
|
||||
final String? turnId;
|
||||
final DialogueAiVerdict verdict;
|
||||
final bool goalSatisfied;
|
||||
final String reasonCode;
|
||||
}
|
||||
|
||||
class AssessmentRecord {
|
||||
@@ -169,11 +231,17 @@ class DialogueDraft {
|
||||
required this.stage,
|
||||
required this.turns,
|
||||
required this.usedHelp,
|
||||
this.currentTurnHintUsed = false,
|
||||
this.currentTurnTranslationUsed = false,
|
||||
this.currentTurnCorrectionUsed = false,
|
||||
});
|
||||
final String lessonId;
|
||||
final int stage;
|
||||
final List<DialogueTurn> turns;
|
||||
final bool usedHelp;
|
||||
final bool currentTurnHintUsed;
|
||||
final bool currentTurnTranslationUsed;
|
||||
final bool currentTurnCorrectionUsed;
|
||||
}
|
||||
|
||||
class LessonSummary {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -99,16 +99,17 @@ class _IndependentStepState extends State<_IndependentStep>
|
||||
checkingWithAi = true;
|
||||
aiCheckError = null;
|
||||
});
|
||||
final feedback = await AiService.instance.answerFeedback(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
answerId: widget.segmentId,
|
||||
target: widget.activity.independentHelp,
|
||||
taskPrompt: widget.activity.independentPrompt,
|
||||
answer: answer,
|
||||
level: lessonLevel(widget.lessonId),
|
||||
);
|
||||
final feedback = await AiService.instance.capabilities.evaluation
|
||||
.evaluateAnswer(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
answerId: widget.segmentId,
|
||||
target: widget.activity.independentHelp,
|
||||
taskPrompt: widget.activity.independentPrompt,
|
||||
answer: answer,
|
||||
level: lessonLevel(widget.lessonId),
|
||||
);
|
||||
// The learner may have kept typing while the request ran.
|
||||
if (!mounted || widget.controller.text.trim() != answer) {
|
||||
if (mounted) setState(() => checkingWithAi = false);
|
||||
|
||||
@@ -57,15 +57,16 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
requestingAiFeedback = true;
|
||||
aiFeedbackError = null;
|
||||
});
|
||||
final feedback = await AiService.instance.writingFeedback(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
lessonId: widget.lessonId,
|
||||
taskPrompt: widget.activity.writingPrompt,
|
||||
answer: widget.controller.text.trim(),
|
||||
level: lessonLevel(widget.lessonId),
|
||||
);
|
||||
final feedback = await AiService.instance.capabilities.evaluation
|
||||
.evaluateWriting(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
lessonId: widget.lessonId,
|
||||
taskPrompt: widget.activity.writingPrompt,
|
||||
answer: widget.controller.text.trim(),
|
||||
level: lessonLevel(widget.lessonId),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
requestingAiFeedback = false;
|
||||
@@ -88,9 +89,7 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text(
|
||||
'小提示:${grammarNoteForSegment(widget.segmentId)}',
|
||||
),
|
||||
child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'),
|
||||
),
|
||||
if (widget.showHelp)
|
||||
SectionCard(
|
||||
|
||||
@@ -87,16 +87,17 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
checkingWithAi = true;
|
||||
aiFeedbackError = null;
|
||||
});
|
||||
final feedback = await AiService.instance.answerFeedback(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
answerId: item.id,
|
||||
target: item.target,
|
||||
taskPrompt: item.prompt,
|
||||
answer: answer,
|
||||
level: itemLevel(item.id),
|
||||
);
|
||||
final feedback = await AiService.instance.capabilities.evaluation
|
||||
.evaluateAnswer(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
answerId: item.id,
|
||||
target: item.target,
|
||||
taskPrompt: item.prompt,
|
||||
answer: answer,
|
||||
level: itemLevel(item.id),
|
||||
);
|
||||
// The learner may have moved on or kept typing while the request ran.
|
||||
if (!mounted || controller.text.trim() != answer) {
|
||||
if (mounted) setState(() => checkingWithAi = false);
|
||||
@@ -243,13 +244,14 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
|
||||
Future<void> _generateVariant(ReviewItem item) async {
|
||||
setState(() => generatingVariant = true);
|
||||
final variant = await AiService.instance.generateReviewVariant(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
targetItemId: item.id,
|
||||
basePrompt: item.prompt,
|
||||
);
|
||||
final variant = await AiService.instance.capabilities.review
|
||||
.generateVariant(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
targetItemId: item.id,
|
||||
basePrompt: item.prompt,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => generatingVariant = false);
|
||||
if (variant == null) {
|
||||
@@ -268,13 +270,14 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
if (!isCoreItem(item.id)) return;
|
||||
final label = coreItemEnglish(item.id);
|
||||
setState(() => generatingLesson = true);
|
||||
final lesson = await AiService.instance.generateAdaptiveLesson(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
targetItemId: item.id,
|
||||
targetLabel: label,
|
||||
);
|
||||
final lesson = await AiService.instance.capabilities.review
|
||||
.generateAdaptiveLesson(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
targetItemId: item.id,
|
||||
targetLabel: label,
|
||||
);
|
||||
if (lesson == null) {
|
||||
if (mounted) setState(() => generatingLesson = false);
|
||||
if (mounted) {
|
||||
@@ -284,12 +287,13 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
}
|
||||
return;
|
||||
}
|
||||
final approved = await AiService.instance.auditGeneratedLesson(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
lesson: lesson,
|
||||
);
|
||||
final approved = await AiService.instance.capabilities.review
|
||||
.auditAdaptiveLesson(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
lesson: lesson,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => generatingLesson = false);
|
||||
if (!approved) {
|
||||
@@ -772,15 +776,16 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
||||
checkingWithAi = true;
|
||||
aiCheckError = null;
|
||||
});
|
||||
final feedback = await AiService.instance.answerFeedback(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
answerId: task.taskId,
|
||||
target: task.answer,
|
||||
taskPrompt: '${task.prompt} ${task.stimulus}',
|
||||
answer: answer,
|
||||
);
|
||||
final feedback = await AiService.instance.capabilities.evaluation
|
||||
.evaluateAnswer(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
answerId: task.taskId,
|
||||
target: task.answer,
|
||||
taskPrompt: '${task.prompt} ${task.stimulus}',
|
||||
answer: answer,
|
||||
);
|
||||
// The learner may have kept typing or moved on while the request ran.
|
||||
if (!mounted || controller.text.trim() != answer) {
|
||||
if (mounted) setState(() => checkingWithAi = false);
|
||||
|
||||
@@ -28,8 +28,9 @@ List<VocabularyItem> get courseLexiconEntries {
|
||||
...allLessons.expand((lesson) => lesson.vocabulary),
|
||||
];
|
||||
final seen = <String>{};
|
||||
_cachedEntries = entries.where((item) => seen.add(item.word.toLowerCase())).toList()
|
||||
..sort((a, b) => b.word.length.compareTo(a.word.length));
|
||||
_cachedEntries =
|
||||
entries.where((item) => seen.add(item.word.toLowerCase())).toList()
|
||||
..sort((a, b) => b.word.length.compareTo(a.word.length));
|
||||
return _cachedEntries!;
|
||||
}
|
||||
|
||||
@@ -385,12 +386,13 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
sentenceAnalysisError = null;
|
||||
});
|
||||
|
||||
final result = await AiService.instance.analyzeSentence(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
text: text,
|
||||
);
|
||||
final result = await AiService.instance.capabilities.lexicon
|
||||
.analyzeSentence(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
text: text,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -418,7 +420,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
requestingTemporaryDefinition = true;
|
||||
temporaryError = null;
|
||||
});
|
||||
final definition = await AiService.instance.temporaryDefinition(
|
||||
final definition = await AiService.instance.capabilities.lexicon.define(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
|
||||
@@ -76,7 +76,7 @@ mixin VoiceAnswerMixin<T extends StatefulWidget> on State<T> {
|
||||
});
|
||||
if (path == null) return;
|
||||
final config = voiceState.aiConfig;
|
||||
final transcribed = await AiService.instance.transcribeAudio(
|
||||
final transcribed = await AiService.instance.capabilities.speech.transcribe(
|
||||
filePath: path,
|
||||
provider: config.provider,
|
||||
endpoint: config.endpoint,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user