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 {
|
||||
|
||||
Reference in New Issue
Block a user