feat: improve AI dialogue evaluation and capability boundaries

This commit is contained in:
shen
2026-09-19 18:58:14 -07:00
parent 335d0304b1
commit 7306dc0f85
23 changed files with 1308 additions and 452 deletions
+51 -8
View File
@@ -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'