阅读"在对话里找到答案": - 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答 - 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理 - 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对) AI 情境对话: - 提示词区分"AI 这一句要做什么"与"学习者随后要完成什么",并下发已教词句清单 - JSON 只强制 reply,translation/feedback 可选;不再索要用不上的 slots/evidence - AI 不可用时页面明确提示当前回复来自内置示范脚本 - 删掉按 stage 下标猜中文翻译的兜底,避免译文与英文对不上 - 整课对话改用逐轮必需表达校验,替换"关键词沾边就算过";修正自由场景正则误伤 - 总结的"完成任务"按实际通过的轮次生成;模型点评只在结束页呈现一次 - 自由场景支持草稿续练(独立存储槽);修正回答轮数文案与永不解锁的场景标注 同时提交此前工作区中累积的改动:SenseVoice 本地识别、查词/句型解析卡、 复习与测评页调整等,并补充对话校验、选项分布和句子解析的测试。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1292 lines
47 KiB
Dart
1292 lines
47 KiB
Dart
import 'sherpa_stt_service.dart';
|
||
import 'dart:io';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||
import 'package:http/http.dart' as http;
|
||
|
||
import 'models.dart';
|
||
import 'generated_content.dart';
|
||
|
||
class AiConnectionResult {
|
||
const AiConnectionResult({required this.ok, required this.message});
|
||
final bool ok;
|
||
final String message;
|
||
}
|
||
|
||
/// Stores the secret separately from normal app settings. Compatible endpoints
|
||
/// support both OpenAI chat-completions shape (/v1/chat/completions) and
|
||
/// responses shape (/v1/responses), including a user-run CLIProxyAPI.
|
||
class AiService {
|
||
AiService._();
|
||
static final instance = AiService._();
|
||
static const _keyName = 'ai_api_key';
|
||
final _secureStorage = const FlutterSecureStorage();
|
||
String? _fallbackApiKey;
|
||
|
||
void setFallbackApiKey(String? key) {
|
||
_fallbackApiKey = key?.trim();
|
||
}
|
||
|
||
Future<void> saveApiKey(String value) async {
|
||
if (value.trim().isEmpty) {
|
||
await _secureStorage.delete(key: _keyName);
|
||
} else {
|
||
await _secureStorage.write(key: _keyName, value: value.trim());
|
||
}
|
||
}
|
||
|
||
Future<String?> resolveApiKey([String? explicit]) async {
|
||
if (explicit != null && explicit.trim().isNotEmpty) {
|
||
return explicit.trim();
|
||
}
|
||
try {
|
||
final stored = await _secureStorage.read(key: _keyName);
|
||
if (stored != null && stored.trim().isNotEmpty) {
|
||
return stored.trim();
|
||
}
|
||
} catch (_) {}
|
||
if (_fallbackApiKey != null && _fallbackApiKey!.trim().isNotEmpty) {
|
||
return _fallbackApiKey!.trim();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<bool> hasApiKey() async =>
|
||
(await resolveApiKey())?.isNotEmpty ?? false;
|
||
|
||
Future<String?> getApiKey() async => await resolveApiKey();
|
||
|
||
/// Resolves the target endpoint URI.
|
||
/// If explicitly set to /responses or /chat/completions, it respects that path.
|
||
/// If ending in /v1 or base URL, it defaults to /v1/chat/completions.
|
||
static Uri? resolveEndpointUri({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
}) {
|
||
final base = endpoint.trim().replaceFirst(RegExp(r'/+$'), '');
|
||
if (base.isEmpty) return null;
|
||
if (provider == AiProviderType.gemini) {
|
||
if (base.contains(':generateContent')) {
|
||
return Uri.tryParse(base);
|
||
}
|
||
return Uri.tryParse('$base/models/$model:generateContent');
|
||
}
|
||
if (base.endsWith('/chat/completions')) {
|
||
return Uri.tryParse(base);
|
||
}
|
||
if (base.endsWith('/responses')) {
|
||
return Uri.tryParse(base);
|
||
}
|
||
if (base.endsWith('/v1')) {
|
||
return Uri.tryParse('$base/chat/completions');
|
||
}
|
||
return Uri.tryParse('$base/v1/chat/completions');
|
||
}
|
||
|
||
static Uri? resolveChatCompletionsUri({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
}) {
|
||
final base = endpoint.trim().replaceFirst(RegExp(r'/+$'), '');
|
||
if (base.isEmpty) return null;
|
||
if (provider == AiProviderType.gemini) {
|
||
if (base.contains(':generateContent')) {
|
||
return Uri.tryParse(base);
|
||
}
|
||
return Uri.tryParse('$base/models/$model:generateContent');
|
||
}
|
||
if (base.endsWith('/responses')) {
|
||
final root = base.substring(0, base.length - '/responses'.length);
|
||
return Uri.tryParse('$root/chat/completions');
|
||
}
|
||
if (base.endsWith('/chat/completions')) {
|
||
return Uri.tryParse(base);
|
||
}
|
||
if (base.endsWith('/v1')) {
|
||
return Uri.tryParse('$base/chat/completions');
|
||
}
|
||
return Uri.tryParse('$base/v1/chat/completions');
|
||
}
|
||
|
||
/// Transcribes spoken audio file to English text using the configured AI multimodal model.
|
||
Future<String?> transcribeAudio({
|
||
required String filePath,
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
}) async {
|
||
if (filePath.toLowerCase().endsWith('.wav')) {
|
||
final localText = await SherpaSttService.instance.transcribeWav(filePath);
|
||
if (localText != null && localText.trim().isNotEmpty) {
|
||
return localText.trim();
|
||
}
|
||
}
|
||
if (provider == AiProviderType.mock) return null;
|
||
final file = File(filePath);
|
||
if (!await file.exists()) return null;
|
||
final bytes = await file.readAsBytes();
|
||
if (bytes.isEmpty) return null;
|
||
|
||
final key = await resolveApiKey();
|
||
final uri = resolveChatCompletionsUri(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
);
|
||
if (key == null || key.isEmpty || uri == null) return null;
|
||
|
||
final ext = filePath.split('.').last.toLowerCase();
|
||
final format = (ext == 'wav' || ext == 'mp3' || ext == 'm4a' || ext == 'aac') ? ext : 'm4a';
|
||
final base64Data = base64Encode(bytes);
|
||
|
||
try {
|
||
if (provider == AiProviderType.gemini) {
|
||
final mimeType = format == 'wav' ? 'audio/wav' : (format == 'mp3' ? 'audio/mp3' : 'audio/mp4');
|
||
final response = await http.post(
|
||
uri,
|
||
headers: {'x-goog-api-key': key, 'Content-Type': 'application/json'},
|
||
body: jsonEncode({
|
||
'contents': [
|
||
{
|
||
'parts': [
|
||
{
|
||
'text': 'Transcribe the spoken English speech in this audio file accurately. Return ONLY the transcribed English words. If silence or unintelligible, output nothing.',
|
||
},
|
||
{
|
||
'inline_data': {
|
||
'mime_type': mimeType,
|
||
'data': base64Data,
|
||
}
|
||
}
|
||
]
|
||
}
|
||
],
|
||
'generationConfig': {
|
||
'thinkingConfig': {
|
||
'thinkingBudget': 1024,
|
||
},
|
||
},
|
||
}),
|
||
).timeout(const Duration(seconds: 25));
|
||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
||
return _extractResponseContent(provider, response.body);
|
||
} else {
|
||
final response = await http.post(
|
||
uri,
|
||
headers: {
|
||
'Authorization': 'Bearer $key',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: jsonEncode({
|
||
'model': model,
|
||
'messages': [
|
||
{
|
||
'role': 'user',
|
||
'content': [
|
||
{
|
||
'type': 'text',
|
||
'text': 'Transcribe the spoken English speech in this audio file accurately. Output ONLY the raw transcribed English words without quotes, punctuation tags, or commentary. If silence or noise, return nothing.',
|
||
},
|
||
{
|
||
'type': 'input_audio',
|
||
'input_audio': {
|
||
'data': base64Data,
|
||
'format': format,
|
||
},
|
||
}
|
||
],
|
||
}
|
||
],
|
||
'reasoning_effort': 'low',
|
||
'temperature': 0.1,
|
||
}),
|
||
).timeout(const Duration(seconds: 25));
|
||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
||
final raw = _extractResponseContent(provider, response.body);
|
||
if (raw == null) return null;
|
||
var text = raw.trim();
|
||
if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) {
|
||
text = text.substring(1, text.length - 1).trim();
|
||
}
|
||
return text;
|
||
}
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
static Map<String, dynamic> _buildOpenAiPayload({
|
||
required Uri uri,
|
||
required String model,
|
||
required List<Map<String, String>> messages,
|
||
double? temperature,
|
||
int? maxTokens,
|
||
String reasoningEffort = 'low',
|
||
}) {
|
||
final isResponses = uri.path.endsWith('/responses');
|
||
if (isResponses) {
|
||
return {
|
||
'model': model,
|
||
'input': messages,
|
||
'reasoning': {'effort': reasoningEffort},
|
||
'reasoning_effort': reasoningEffort,
|
||
'temperature': ?temperature,
|
||
'max_output_tokens': ?maxTokens,
|
||
};
|
||
}
|
||
return {
|
||
'model': model,
|
||
'messages': messages,
|
||
'reasoning_effort': reasoningEffort,
|
||
'temperature': ?temperature,
|
||
'max_tokens': ?maxTokens,
|
||
};
|
||
}
|
||
|
||
static Map<String, dynamic> _buildGeminiGenerationConfig({
|
||
double? temperature,
|
||
int? maxOutputTokens,
|
||
String? responseMimeType,
|
||
int thinkingBudget = 1024,
|
||
}) {
|
||
return {
|
||
'temperature': ?temperature,
|
||
'maxOutputTokens': ?maxOutputTokens,
|
||
'responseMimeType': ?responseMimeType,
|
||
'thinkingConfig': {
|
||
'thinkingBudget': thinkingBudget,
|
||
},
|
||
};
|
||
}
|
||
|
||
/// Returns a display-only Chinese gloss for an unknown word or phrase.
|
||
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
|
||
Future<String?> temporaryDefinition({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
required String text,
|
||
}) async {
|
||
if (provider == AiProviderType.mock ||
|
||
text.length > 120 ||
|
||
text.trim().isEmpty) {
|
||
return null;
|
||
}
|
||
final key = await resolveApiKey();
|
||
final uri = resolveEndpointUri(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
);
|
||
if (key == null ||
|
||
key.isEmpty ||
|
||
model.trim().isEmpty ||
|
||
uri == null ||
|
||
(uri.scheme != 'https' && uri.scheme != 'http')) {
|
||
return null;
|
||
}
|
||
const instruction =
|
||
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
|
||
try {
|
||
final response = await http
|
||
.post(
|
||
uri,
|
||
headers: provider == AiProviderType.gemini
|
||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
||
: {
|
||
'Authorization': 'Bearer $key',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: jsonEncode(
|
||
provider == AiProviderType.gemini
|
||
? {
|
||
'contents': [
|
||
{
|
||
'parts': [
|
||
{'text': '$instruction\nText: $text'},
|
||
],
|
||
},
|
||
],
|
||
'generationConfig': _buildGeminiGenerationConfig(
|
||
maxOutputTokens: 200,
|
||
responseMimeType: 'application/json',
|
||
),
|
||
}
|
||
: _buildOpenAiPayload(
|
||
uri: uri,
|
||
model: model,
|
||
messages: [
|
||
{
|
||
'role': 'user',
|
||
'content': '$instruction\nText: $text',
|
||
},
|
||
],
|
||
maxTokens: 200,
|
||
),
|
||
),
|
||
)
|
||
.timeout(const Duration(seconds: 30));
|
||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||
return null;
|
||
}
|
||
final content = _extractResponseContent(provider, response.body);
|
||
if (content == null || content.length > 300) return null;
|
||
final parsed = jsonDecode(content);
|
||
if (parsed is! Map<String, dynamic>) return null;
|
||
final definition = parsed['definition'];
|
||
return definition is String && definition.trim().isNotEmpty
|
||
? definition.trim()
|
||
: null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Performs structured multi-dimensional analysis on an English sentence or phrase.
|
||
/// Provides translation, sentence pattern, grammar breakdown, pronunciation tips, and extracted phrases.
|
||
Future<SentenceAnalysisResult?> analyzeSentence({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
required String text,
|
||
}) async {
|
||
final cleanText = text.trim();
|
||
if (cleanText.isEmpty || cleanText.length > 800) {
|
||
return null;
|
||
}
|
||
|
||
if (provider == AiProviderType.mock) {
|
||
return _buildMockSentenceAnalysis(cleanText);
|
||
}
|
||
|
||
final key = await resolveApiKey();
|
||
final uri = resolveEndpointUri(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
);
|
||
if (key == null ||
|
||
key.isEmpty ||
|
||
model.trim().isEmpty ||
|
||
uri == null ||
|
||
(uri.scheme != 'https' && uri.scheme != 'http')) {
|
||
return null;
|
||
}
|
||
|
||
const instruction =
|
||
'You are an expert oral English coach for beginner adult learners (A0-A1). '
|
||
'Analyze the given English sentence into clear, encouraging, beginner-friendly Chinese explanations. '
|
||
'Focus on practical oral usage, sentence structure, and linking/pronunciation hints. '
|
||
'Return ONLY valid JSON matching this schema, with no markdown or other text:\n'
|
||
'{\n'
|
||
' "translation": "准确通顺的中文整句翻译",\n'
|
||
' "sentencePattern": "核心口语句型结构 (如:I would like + 名词/动词原形)",\n'
|
||
' "grammarNote": "通俗易懂的语法与时态要点 (1-2句话,面向初学者,不要学术术语)",\n'
|
||
' "pronunciationTips": "口语连读/失爆/弱读技巧提示 (如:check in 连读为 /tʃe-kɪn/)",\n'
|
||
' "phrases": [\n'
|
||
' {\n'
|
||
' "phrase": "句子中的重点短语或搭配",\n'
|
||
' "ipa": "/音标/",\n'
|
||
' "meaning": "在句中的准确释义",\n'
|
||
' "usageNote": "简要口语用法说明或常见搭配"\n'
|
||
' }\n'
|
||
' ]\n'
|
||
'}';
|
||
|
||
try {
|
||
final response = await http
|
||
.post(
|
||
uri,
|
||
headers: provider == AiProviderType.gemini
|
||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
||
: {
|
||
'Authorization': 'Bearer $key',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: jsonEncode(
|
||
provider == AiProviderType.gemini
|
||
? {
|
||
'contents': [
|
||
{
|
||
'parts': [
|
||
{'text': '$instruction\n\nSentence: $cleanText'},
|
||
],
|
||
},
|
||
],
|
||
'generationConfig': _buildGeminiGenerationConfig(
|
||
maxOutputTokens: 800,
|
||
responseMimeType: 'application/json',
|
||
),
|
||
}
|
||
: _buildOpenAiPayload(
|
||
uri: uri,
|
||
model: model,
|
||
messages: [
|
||
{
|
||
'role': 'user',
|
||
'content': '$instruction\n\nSentence: $cleanText',
|
||
},
|
||
],
|
||
maxTokens: 800,
|
||
),
|
||
),
|
||
)
|
||
.timeout(const Duration(seconds: 30));
|
||
|
||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||
return null;
|
||
}
|
||
final content = _extractResponseContent(provider, response.body);
|
||
if (content == null || content.isEmpty) return null;
|
||
return _decodeSentenceAnalysis(
|
||
raw: content,
|
||
originalText: cleanText,
|
||
provider: provider.name,
|
||
model: model,
|
||
);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
SentenceAnalysisResult? _decodeSentenceAnalysis({
|
||
required String raw,
|
||
required String originalText,
|
||
required String provider,
|
||
required String model,
|
||
}) {
|
||
try {
|
||
var sanitized = raw.trim();
|
||
if (sanitized.startsWith('```')) {
|
||
sanitized = sanitized.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
|
||
sanitized = sanitized.replaceFirst(RegExp(r'\s*```$'), '');
|
||
}
|
||
final jsonStart = sanitized.indexOf('{');
|
||
final jsonEnd = sanitized.lastIndexOf('}');
|
||
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
||
sanitized = sanitized.substring(jsonStart, jsonEnd + 1);
|
||
}
|
||
final data = jsonDecode(sanitized);
|
||
if (data is! Map<String, dynamic>) return null;
|
||
|
||
final translation = data['translation'] as String? ?? '';
|
||
if (translation.trim().isEmpty) return null;
|
||
|
||
final phrasesList = <PhraseBreakdownItem>[];
|
||
if (data['phrases'] is List) {
|
||
for (final item in data['phrases'] as List) {
|
||
if (item is Map<String, dynamic>) {
|
||
final phrase = item['phrase'] as String? ?? '';
|
||
final meaning = item['meaning'] as String? ?? '';
|
||
if (phrase.trim().isNotEmpty && meaning.trim().isNotEmpty) {
|
||
phrasesList.add(
|
||
PhraseBreakdownItem(
|
||
phrase: phrase.trim(),
|
||
meaning: meaning.trim(),
|
||
ipa: item['ipa'] as String?,
|
||
usageNote: item['usageNote'] as String?,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return SentenceAnalysisResult(
|
||
originalText: originalText,
|
||
translation: translation.trim(),
|
||
sentencePattern: data['sentencePattern'] as String?,
|
||
grammarNote: data['grammarNote'] as String?,
|
||
pronunciationTips: data['pronunciationTips'] as String?,
|
||
phrases: phrasesList,
|
||
provider: provider,
|
||
model: model,
|
||
createdAt: DateTime.now(),
|
||
);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
SentenceAnalysisResult _buildMockSentenceAnalysis(String text) {
|
||
final lower = text.toLowerCase();
|
||
String translation = '(演示翻译)这是句子的中文参考释义。';
|
||
String? pattern = '常见日常交流句型';
|
||
String? grammar = '此句为日常口语高频表达,结构清晰,适合在日常与工作场景中直接套用。';
|
||
String? pronunciation = '注意单词间的自然停顿,句末语调自然微降。';
|
||
final phrases = <PhraseBreakdownItem>[];
|
||
|
||
if (lower.contains('would like') || lower.contains("i'd like")) {
|
||
translation = '我想办理相关事项/我想要这个,麻烦了。';
|
||
pattern = 'I would like + 名词/动词原形(礼貌请求句型)';
|
||
grammar = 'would like 相当于礼貌委婉的 want,是服务和工作场景中最得体的表达方式。';
|
||
pronunciation = 'would like 发音为 /wʊd laɪk/,注意 d 的微弱爆破。';
|
||
phrases.add(
|
||
const PhraseBreakdownItem(
|
||
phrase: 'would like',
|
||
ipa: '/wʊd laɪk/',
|
||
meaning: '想要(礼貌委婉)',
|
||
usageNote: "比 I want 更得体,常缩写为 I'd like",
|
||
),
|
||
);
|
||
} else if (lower.contains('nice to meet you')) {
|
||
translation = '初次见面,很高兴认识你。';
|
||
pattern = 'It is + 形容词 + to do sth.(社交问候句型)';
|
||
grammar = '初次与新朋友或客户见面时的标准礼貌问候,通常省略了句首的 It is。';
|
||
pronunciation = 'meet 与 you 发生音变连读为 /miːtʃuː/。';
|
||
phrases.add(
|
||
const PhraseBreakdownItem(
|
||
phrase: 'nice to meet you',
|
||
ipa: '/naɪs tuː miːt juː/',
|
||
meaning: '初次见面很高兴认识你',
|
||
usageNote: '仅用于初次相识;熟悉后再次见面用 Nice to see you again',
|
||
),
|
||
);
|
||
} else if (lower.contains('where is') || lower.contains("where's")) {
|
||
translation = '请问……在哪里?';
|
||
pattern = 'Where is + 目的地/物品?(询问地点句型)';
|
||
grammar = "where 引导的特殊疑问句,口语中常用缩读 Where's。";
|
||
pronunciation = 'Where 与 is 发生连读,读作 /weər ɪz/,疑问句末尾用降调。';
|
||
phrases.add(
|
||
const PhraseBreakdownItem(
|
||
phrase: 'where is',
|
||
ipa: '/weər ɪz/',
|
||
meaning: '……在哪里',
|
||
usageNote: '问路与寻物核心句型,句首加上 Excuse me 更礼貌',
|
||
),
|
||
);
|
||
} else {
|
||
final words = text
|
||
.split(RegExp(r'\s+'))
|
||
.map((w) => w.replaceAll(RegExp(r'[^a-zA-Z]'), ''))
|
||
.where((w) => w.length > 3)
|
||
.take(2);
|
||
for (final w in words) {
|
||
phrases.add(
|
||
PhraseBreakdownItem(
|
||
phrase: w,
|
||
meaning: '重点词汇',
|
||
usageNote: '句子中的核心实词',
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
return SentenceAnalysisResult(
|
||
originalText: text,
|
||
translation: translation,
|
||
sentencePattern: pattern,
|
||
grammarNote: grammar,
|
||
pronunciationTips: pronunciation,
|
||
phrases: phrases,
|
||
provider: 'mock',
|
||
model: 'local-mock',
|
||
createdAt: DateTime.now(),
|
||
);
|
||
}
|
||
|
||
Future<AiConnectionResult> testConnection({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
String? explicitApiKey,
|
||
}) async {
|
||
if (provider == AiProviderType.mock) {
|
||
return const AiConnectionResult(ok: true, message: '内置练习模式可用,无需网络。');
|
||
}
|
||
const probe =
|
||
'Return JSON only: {"reply":"Hi!","slots":{},"evidence":[],"suggestsComplete":false,"feedback":null}';
|
||
final key = await resolveApiKey(explicitApiKey);
|
||
if (key == null || key.isEmpty) {
|
||
return const AiConnectionResult(ok: false, message: '请先填写或保存 API Key。');
|
||
}
|
||
if (endpoint.trim().isEmpty || model.trim().isEmpty) {
|
||
return const AiConnectionResult(
|
||
ok: false,
|
||
message: '请填写 Base URL 和模型名称。',
|
||
);
|
||
}
|
||
final uri = resolveEndpointUri(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
);
|
||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||
return const AiConnectionResult(
|
||
ok: false,
|
||
message: '请使用有效的 HTTP 或 HTTPS 地址。',
|
||
);
|
||
}
|
||
try {
|
||
final response = await http
|
||
.post(
|
||
uri,
|
||
headers: provider == AiProviderType.gemini
|
||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
||
: {
|
||
'Authorization': 'Bearer $key',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: jsonEncode(
|
||
provider == AiProviderType.gemini
|
||
? {
|
||
'contents': [
|
||
{
|
||
'parts': [
|
||
{'text': probe},
|
||
],
|
||
},
|
||
],
|
||
'generationConfig': _buildGeminiGenerationConfig(
|
||
temperature: 0,
|
||
maxOutputTokens: 200,
|
||
responseMimeType: 'application/json',
|
||
),
|
||
}
|
||
: _buildOpenAiPayload(
|
||
uri: uri,
|
||
model: model,
|
||
messages: [
|
||
{'role': 'user', 'content': probe},
|
||
],
|
||
temperature: 0,
|
||
maxTokens: 200,
|
||
),
|
||
),
|
||
)
|
||
.timeout(const Duration(seconds: 15));
|
||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||
final content = _extractResponseContent(provider, response.body);
|
||
if (_decodeDialogueResponse(content) != null) {
|
||
return const AiConnectionResult(
|
||
ok: true,
|
||
message: '连接成功,AI 对话服务可用!',
|
||
);
|
||
}
|
||
return const AiConnectionResult(
|
||
ok: true,
|
||
message: '连接成功,接口响应正常。',
|
||
);
|
||
}
|
||
if (response.statusCode == 401 || response.statusCode == 403) {
|
||
return AiConnectionResult(
|
||
ok: false,
|
||
message: '鉴权失败 (HTTP ${response.statusCode}),请检查 API Key 是否正确。',
|
||
);
|
||
}
|
||
if (response.statusCode == 404) {
|
||
return const AiConnectionResult(
|
||
ok: false,
|
||
message: '服务返回 404,请检查 Base URL(如是否缺少 /v1)或模型名称。',
|
||
);
|
||
}
|
||
if (response.statusCode == 429) {
|
||
return const AiConnectionResult(
|
||
ok: false,
|
||
message: '请求受限 (HTTP 429),API 额度不足或达到并发限制。',
|
||
);
|
||
}
|
||
return AiConnectionResult(
|
||
ok: false,
|
||
message: '服务返回 HTTP ${response.statusCode},请检查地址和模型配置。',
|
||
);
|
||
} catch (e) {
|
||
return AiConnectionResult(
|
||
ok: false,
|
||
message: '无法连接服务 ($e)。请检查网络、地址或代理连通性。',
|
||
);
|
||
}
|
||
}
|
||
|
||
/// [aiGoal] is what Mia's own next line has to do; [learnerTask] is what the
|
||
/// learner has to say afterwards. They used to be the same string, so the
|
||
/// model was told to perform the learner's job.
|
||
Future<DialogueAiResponse?> dialogueReply({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
required List<Map<String, String>> history,
|
||
required String aiGoal,
|
||
required String learnerTask,
|
||
List<String> allowedLanguage = const [],
|
||
}) async {
|
||
if (provider == AiProviderType.mock) {
|
||
return null;
|
||
}
|
||
final key = await resolveApiKey();
|
||
if (key == null ||
|
||
key.isEmpty ||
|
||
endpoint.trim().isEmpty ||
|
||
model.trim().isEmpty) {
|
||
return null;
|
||
}
|
||
final uri = resolveEndpointUri(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
);
|
||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
||
return null;
|
||
}
|
||
final vocabularyRule = allowedLanguage.isEmpty
|
||
? ''
|
||
: 'Build your reply from your goal wording, names, numbers and this '
|
||
'taught language: ${allowedLanguage.join('; ')}. '
|
||
'At most one word outside it per reply, and only if unavoidable. ';
|
||
final system =
|
||
'You are Mia, a patient A0 English conversation partner for a Chinese beginner. '
|
||
'Your own next line must do this: $aiGoal '
|
||
'After your line the learner has to: $learnerTask. '
|
||
'Do not say the learner sentence for them, and do not ask for anything else. '
|
||
'$vocabularyRule'
|
||
'Reply with one short sentence or question, at most 20 English words, in English only. '
|
||
'Do not explain grammar. '
|
||
'Return JSON only: {"reply": "your English line", '
|
||
'"translation": "reply 的简体中文翻译", '
|
||
'"feedback": "一句中文点评学习者上一句英文,没有要说的就用 null"}. '
|
||
'Only reply is required.';
|
||
try {
|
||
final response = await http
|
||
.post(
|
||
uri,
|
||
headers: provider == AiProviderType.gemini
|
||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
||
: {
|
||
'Authorization': 'Bearer $key',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: jsonEncode(
|
||
provider == AiProviderType.gemini
|
||
? {
|
||
'systemInstruction': {
|
||
'parts': [
|
||
{'text': system},
|
||
],
|
||
},
|
||
'contents': history
|
||
.map(
|
||
(turn) => {
|
||
'role': turn['role'] == 'assistant'
|
||
? 'model'
|
||
: 'user',
|
||
'parts': [
|
||
{'text': turn['content']},
|
||
],
|
||
},
|
||
)
|
||
.toList(),
|
||
'generationConfig': _buildGeminiGenerationConfig(
|
||
temperature: 0.3,
|
||
maxOutputTokens: 300,
|
||
responseMimeType: 'application/json',
|
||
),
|
||
}
|
||
: _buildOpenAiPayload(
|
||
uri: uri,
|
||
model: model,
|
||
messages: [
|
||
{'role': 'system', 'content': system},
|
||
...history,
|
||
],
|
||
temperature: 0.3,
|
||
maxTokens: 300,
|
||
),
|
||
),
|
||
)
|
||
.timeout(const Duration(seconds: 30));
|
||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||
return null;
|
||
}
|
||
return _decodeDialogueResponse(
|
||
_extractResponseContent(provider, response.body),
|
||
);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Generates only a bounded variant of an existing review target. A network
|
||
/// response is never exposed unless [decodeGeneratedReviewVariant] accepts it.
|
||
Future<GeneratedReviewVariant?> generateReviewVariant({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
required String targetItemId,
|
||
required String basePrompt,
|
||
bool repairAttempt = false,
|
||
}) async {
|
||
if (provider == AiProviderType.mock) return null;
|
||
final key = await resolveApiKey();
|
||
if (key == null ||
|
||
key.isEmpty ||
|
||
endpoint.trim().isEmpty ||
|
||
model.trim().isEmpty) {
|
||
return null;
|
||
}
|
||
final uri = resolveEndpointUri(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
);
|
||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
||
final instruction =
|
||
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". Return JSON only with exactly: schemaVersion (must be "review-variant-1"), targetItemId (must be "$targetItemId"), prompt (short Chinese instruction), stimulus (English sentence, maximum 12 words), answer (exact expected English answer, maximum 10 words), acceptedAnswers (array of 1 to 4 strings), requiredAnyPhrases (array of 1 to 3 arrays of strings), forbiddenPhrases (array of up to 4 strings). Stay strictly within A0. Do not introduce new vocabulary. The answer must satisfy the spec.${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
|
||
try {
|
||
final response = await http
|
||
.post(
|
||
uri,
|
||
headers: provider == AiProviderType.gemini
|
||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
||
: {
|
||
'Authorization': 'Bearer $key',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: jsonEncode(
|
||
provider == AiProviderType.gemini
|
||
? {
|
||
'contents': [
|
||
{
|
||
'parts': [
|
||
{'text': instruction},
|
||
],
|
||
},
|
||
],
|
||
'generationConfig': _buildGeminiGenerationConfig(
|
||
temperature: 0.2,
|
||
maxOutputTokens: 300,
|
||
responseMimeType: 'application/json',
|
||
),
|
||
}
|
||
: _buildOpenAiPayload(
|
||
uri: uri,
|
||
model: model,
|
||
messages: [
|
||
{'role': 'user', 'content': instruction},
|
||
],
|
||
temperature: 0.2,
|
||
maxTokens: 300,
|
||
),
|
||
),
|
||
)
|
||
.timeout(const Duration(seconds: 30));
|
||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||
return null;
|
||
}
|
||
final content = _extractResponseContent(provider, response.body);
|
||
if (content == null) return null;
|
||
final variant = decodeGeneratedReviewVariant(
|
||
content,
|
||
expectedTargetItemId: targetItemId,
|
||
);
|
||
if (variant == null && !repairAttempt) {
|
||
return generateReviewVariant(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
targetItemId: targetItemId,
|
||
basePrompt: basePrompt,
|
||
repairAttempt: true,
|
||
);
|
||
}
|
||
return variant;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Evaluates an open-ended writing response against a bounded schema.
|
||
Future<WritingAiFeedback?> writingFeedback({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
required String lessonId,
|
||
required String taskPrompt,
|
||
required String answer,
|
||
}) async {
|
||
if (provider == AiProviderType.mock) return null;
|
||
final key = await resolveApiKey();
|
||
if (key == null ||
|
||
key.isEmpty ||
|
||
endpoint.trim().isEmpty ||
|
||
model.trim().isEmpty) {
|
||
return null;
|
||
}
|
||
final uri = resolveEndpointUri(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
);
|
||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
||
final instruction =
|
||
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
||
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
|
||
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
|
||
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
|
||
Task: $taskPrompt
|
||
Learner wrote: $answer''';
|
||
try {
|
||
final response = await http
|
||
.post(
|
||
uri,
|
||
headers: provider == AiProviderType.gemini
|
||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
||
: {
|
||
'Authorization': 'Bearer $key',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: jsonEncode(
|
||
provider == AiProviderType.gemini
|
||
? {
|
||
'contents': [
|
||
{
|
||
'parts': [
|
||
{'text': instruction},
|
||
],
|
||
},
|
||
],
|
||
'generationConfig': _buildGeminiGenerationConfig(
|
||
temperature: 0,
|
||
maxOutputTokens: 300,
|
||
responseMimeType: 'application/json',
|
||
),
|
||
}
|
||
: _buildOpenAiPayload(
|
||
uri: uri,
|
||
model: model,
|
||
messages: [
|
||
{'role': 'user', 'content': instruction},
|
||
],
|
||
temperature: 0,
|
||
maxTokens: 300,
|
||
),
|
||
),
|
||
)
|
||
.timeout(const Duration(seconds: 30));
|
||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||
return null;
|
||
}
|
||
final content = _extractResponseContent(provider, response.body);
|
||
if (content == null) return null;
|
||
return decodeWritingAiFeedback(
|
||
content,
|
||
expectedLessonId: lessonId,
|
||
);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
|
||
Future<GeneratedLesson?> generateAdaptiveLesson({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
required String targetItemId,
|
||
required String targetLabel,
|
||
bool repairAttempt = false,
|
||
}) async {
|
||
if (provider == AiProviderType.mock) return null;
|
||
final key = await resolveApiKey();
|
||
if (key == null ||
|
||
key.isEmpty ||
|
||
endpoint.trim().isEmpty ||
|
||
model.trim().isEmpty) {
|
||
return null;
|
||
}
|
||
final uri = resolveEndpointUri(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
);
|
||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
||
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
|
||
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, lessonId $lessonId, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [$targetItemId], 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 [$targetItemId]. 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 A0 English for $targetLabel. No new vocabulary, markdown, real phone numbers, or personal data.${repairAttempt ? ' Previous response was invalid: repair all constraints.' : ''}';
|
||
try {
|
||
final response = await http
|
||
.post(
|
||
uri,
|
||
headers: provider == AiProviderType.gemini
|
||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
||
: {
|
||
'Authorization': 'Bearer $key',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: jsonEncode(
|
||
provider == AiProviderType.gemini
|
||
? {
|
||
'contents': [
|
||
{
|
||
'parts': [
|
||
{'text': instruction},
|
||
],
|
||
},
|
||
],
|
||
'generationConfig': _buildGeminiGenerationConfig(
|
||
temperature: 0.1,
|
||
maxOutputTokens: 850,
|
||
responseMimeType: 'application/json',
|
||
),
|
||
}
|
||
: _buildOpenAiPayload(
|
||
uri: uri,
|
||
model: model,
|
||
messages: [
|
||
{'role': 'user', 'content': instruction},
|
||
],
|
||
temperature: 0.1,
|
||
maxTokens: 850,
|
||
),
|
||
),
|
||
)
|
||
.timeout(const Duration(seconds: 45));
|
||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||
return null;
|
||
}
|
||
final content = _extractResponseContent(provider, response.body);
|
||
if (content == null) return null;
|
||
final lesson = decodeGeneratedLesson(
|
||
content,
|
||
expectedTargetItemId: targetItemId,
|
||
);
|
||
if (lesson == null && !repairAttempt) {
|
||
return generateAdaptiveLesson(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
targetItemId: targetItemId,
|
||
targetLabel: targetLabel,
|
||
repairAttempt: true,
|
||
);
|
||
}
|
||
return lesson;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/// Sends the entire generated lesson structure to an independent LLM audit.
|
||
Future<bool> auditGeneratedLesson({
|
||
required AiProviderType provider,
|
||
required String endpoint,
|
||
required String model,
|
||
required GeneratedLesson lesson,
|
||
}) async {
|
||
if (provider == AiProviderType.mock) return true;
|
||
final key = await resolveApiKey();
|
||
if (key == null ||
|
||
key.isEmpty ||
|
||
endpoint.trim().isEmpty ||
|
||
model.trim().isEmpty) {
|
||
return false;
|
||
}
|
||
final uri = resolveEndpointUri(
|
||
provider: provider,
|
||
endpoint: endpoint,
|
||
model: model,
|
||
);
|
||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return false;
|
||
final lessonJson = jsonEncode({
|
||
'lessonId': lesson.lessonId,
|
||
'stageVersion': lesson.stageVersion,
|
||
'targetItemIds': lesson.targetItemIds,
|
||
'tasks': lesson.tasks
|
||
.map(
|
||
(task) => {
|
||
'taskId': task.taskId,
|
||
'skill': task.skill,
|
||
'type': task.type,
|
||
'prompt': task.prompt,
|
||
'stimulus': task.stimulus,
|
||
'answer': task.answer,
|
||
'answerSpec': {
|
||
'requiredAnyPhrases': task.localAnswerSpec.requiredAnyPhrases,
|
||
'acceptedAnswers': task.localAnswerSpec.acceptedAnswers,
|
||
'forbiddenPhrases': task.localAnswerSpec.forbiddenPhrases,
|
||
},
|
||
},
|
||
)
|
||
.toList(),
|
||
});
|
||
final instruction =
|
||
'Audit this A0 English lesson independently. Check naturalness, that every answer follows its stimulus, that it stays A0, and that each task is solvable without giving the answer. Return JSON only with exactly schemaVersion, approved, reason. schemaVersion must be lesson-audit-1. approved is boolean and reason is a short Chinese string. Lesson: $lessonJson';
|
||
try {
|
||
final response = await http
|
||
.post(
|
||
uri,
|
||
headers: provider == AiProviderType.gemini
|
||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
||
: {
|
||
'Authorization': 'Bearer $key',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: jsonEncode(
|
||
provider == AiProviderType.gemini
|
||
? {
|
||
'contents': [
|
||
{
|
||
'parts': [
|
||
{'text': instruction},
|
||
],
|
||
},
|
||
],
|
||
'generationConfig': _buildGeminiGenerationConfig(
|
||
temperature: 0,
|
||
maxOutputTokens: 200,
|
||
responseMimeType: 'application/json',
|
||
),
|
||
}
|
||
: _buildOpenAiPayload(
|
||
uri: uri,
|
||
model: model,
|
||
messages: [
|
||
{'role': 'user', 'content': instruction},
|
||
],
|
||
temperature: 0,
|
||
maxTokens: 200,
|
||
),
|
||
),
|
||
)
|
||
.timeout(const Duration(seconds: 30));
|
||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||
return false;
|
||
}
|
||
return _decodeLessonAudit(
|
||
_extractResponseContent(provider, response.body),
|
||
);
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
bool _decodeLessonAudit(String? raw) {
|
||
if (raw == null || raw.length > 600) return false;
|
||
try {
|
||
final data = jsonDecode(raw);
|
||
return data is Map<String, dynamic> &&
|
||
data.length == 3 &&
|
||
data['schemaVersion'] == 'lesson-audit-1' &&
|
||
data['approved'] == true &&
|
||
data['reason'] is String &&
|
||
(data['reason'] as String).length <= 160;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
String? _extractResponseContent(AiProviderType provider, String body) {
|
||
try {
|
||
final data = jsonDecode(body) as Map<String, dynamic>;
|
||
String? rawContent;
|
||
if (provider == AiProviderType.gemini) {
|
||
final candidate = (data['candidates'] as List?)?.firstOrNull as Map?;
|
||
final candidateContent = candidate?['content'] as Map?;
|
||
final parts = candidateContent?['parts'] as List?;
|
||
rawContent = (parts?.firstOrNull as Map?)?['text'] as String?;
|
||
} else {
|
||
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
|
||
final choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
|
||
choice?['text'] as String?;
|
||
if (choiceContent != null && choiceContent.isNotEmpty) {
|
||
rawContent = choiceContent;
|
||
} else if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
|
||
rawContent = data['output_text'] as String;
|
||
} else {
|
||
final outputList = data['output'] as List?;
|
||
if (outputList != null && outputList.isNotEmpty) {
|
||
for (final item in outputList) {
|
||
if (item is Map) {
|
||
if (item['content'] is List) {
|
||
for (final sub in item['content'] as List) {
|
||
if (sub is Map && sub['text'] is String) {
|
||
rawContent = sub['text'] as String;
|
||
break;
|
||
}
|
||
}
|
||
} else if (item['text'] is String) {
|
||
rawContent = item['text'] as String;
|
||
break;
|
||
}
|
||
}
|
||
if (rawContent != null) break;
|
||
}
|
||
}
|
||
if (rawContent == null) {
|
||
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
|
||
rawContent = data['response'] as String;
|
||
} else if (data['text'] is String && (data['text'] as String).isNotEmpty) {
|
||
rawContent = data['text'] as String;
|
||
} else if (data['content'] is String && (data['content'] as String).isNotEmpty) {
|
||
rawContent = data['content'] as String;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (rawContent == null) return null;
|
||
var trimmed = rawContent.trim();
|
||
if (trimmed.startsWith('```')) {
|
||
trimmed = trimmed.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
|
||
trimmed = trimmed.replaceFirst(RegExp(r'\s*```$'), '');
|
||
}
|
||
return trimmed.trim();
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
|
||
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
|
||
try {
|
||
var sanitized = raw.trim();
|
||
if (sanitized.startsWith('```')) {
|
||
sanitized = sanitized.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
|
||
sanitized = sanitized.replaceFirst(RegExp(r'\s*```$'), '');
|
||
}
|
||
final data = jsonDecode(sanitized.trim()) as Map<String, dynamic>;
|
||
final reply = data['reply'] as String?;
|
||
if (reply == null || reply.trim().isEmpty || reply.length > 240) {
|
||
return null;
|
||
}
|
||
// Only `reply` is mandatory. A model that omits or malforms an optional
|
||
// field used to make the whole turn fall back to the canned script.
|
||
final slots = <String, String>{};
|
||
final rawSlots = data['slots'];
|
||
if (rawSlots is Map) {
|
||
for (final entry in rawSlots.entries) {
|
||
final key = entry.key;
|
||
final value = entry.value;
|
||
if (key is! String || value is! String) continue;
|
||
if (key.length > 40 || value.length > 80) continue;
|
||
slots[key] = value;
|
||
}
|
||
}
|
||
final evidence = <String>[];
|
||
final rawEvidence = data['evidence'];
|
||
if (rawEvidence is List) {
|
||
for (final item in rawEvidence) {
|
||
if (item is String && item.length <= 240) evidence.add(item);
|
||
}
|
||
}
|
||
final translation = data['translation'];
|
||
final feedback = data['feedback'];
|
||
return DialogueAiResponse(
|
||
reply: reply.trim(),
|
||
translation: translation is String && translation.trim().isNotEmpty
|
||
? translation.trim()
|
||
: null,
|
||
slots: slots,
|
||
evidence: evidence,
|
||
suggestsComplete: data['suggestsComplete'] == true,
|
||
feedback: feedback is String && feedback.trim().isNotEmpty
|
||
? feedback.trim()
|
||
: null,
|
||
);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|