Files
English/kouyu_english/lib/core/ai_service.dart
T
2026-09-19 19:21:41 -07:00

1386 lines
52 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'sherpa_stt_service.dart';
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
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;
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._();
late final AiCapabilities capabilities = AiCapabilities._(this);
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 _postJson(
provider: provider,
key: key,
uri: uri,
timeout: const Duration(seconds: 25),
body: {
'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},
},
},
);
if (!_isSuccess(response)) return null;
return _extractResponseContent(provider, response.body);
} else {
final response = await _postJson(
provider: provider,
key: key,
uri: uri,
timeout: const Duration(seconds: 25),
body: {
'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,
},
);
if (!_isSuccess(response)) 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 bool _isHttpUri(Uri uri) =>
uri.scheme == 'https' || uri.scheme == 'http';
static bool _isDeepSeek(Uri uri) {
final host = uri.host.toLowerCase();
return host == 'deepseek.com' || host.endsWith('.deepseek.com');
}
static bool _isSuccess(http.Response response) =>
response.statusCode >= 200 && response.statusCode < 300;
static Future<http.Response> _postJson({
required AiProviderType provider,
required String key,
required Uri uri,
required Map<String, dynamic> body,
required Duration timeout,
}) {
return 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(body),
)
.timeout(timeout);
}
/// Sends a JSON-mode text request and returns the model's content.
///
/// Returns null when the API key, model or endpoint is unusable, when the
/// request fails, or when the status is not 2xx. With [system] the messages
/// are a role-tagged conversation; without it they are a single prompt.
Future<String?> _requestContent({
required AiProviderType provider,
required String endpoint,
required String model,
String? system,
required List<Map<String, String>> messages,
double? temperature,
required int maxTokens,
Duration timeout = const Duration(seconds: 30),
}) async {
final key = await resolveApiKey();
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (key == null ||
key.isEmpty ||
model.trim().isEmpty ||
uri == null ||
!_isHttpUri(uri)) {
return null;
}
try {
final response = await _postJson(
provider: provider,
key: key,
uri: uri,
timeout: timeout,
body: _buildTextPayload(
provider: provider,
uri: uri,
model: model,
system: system,
messages: messages,
temperature: temperature,
maxTokens: maxTokens,
),
);
if (!_isSuccess(response)) return null;
if (kDebugMode) _logUsage(response.body);
return _extractResponseContent(provider, response.body);
} catch (_) {
return null;
}
}
/// Debug-only prompt cache report. DeepSeek reports hits and misses
/// directly; OpenAI-style endpoints report cached tokens in the details.
static void _logUsage(String body) {
try {
final usage = (jsonDecode(body) as Map<String, dynamic>)['usage'];
if (usage is! Map) return;
final hit =
usage['prompt_cache_hit_tokens'] ??
(usage['prompt_tokens_details'] as Map?)?['cached_tokens'];
final miss = usage['prompt_cache_miss_tokens'];
final prompt = usage['prompt_tokens'] ?? usage['input_tokens'];
final hitCount = hit is num ? hit.toDouble() : null;
final missCount = miss is num
? miss.toDouble()
: prompt is num && hitCount != null
? (prompt.toDouble() - hitCount).clamp(0, double.infinity)
: null;
final cacheTotal = (hitCount ?? 0) + (missCount ?? 0);
final cacheRate = cacheTotal > 0
? '${(100 * (hitCount ?? 0) / cacheTotal).toStringAsFixed(1)}%'
: 'n/a';
debugPrint(
'AI usage: prompt=$prompt cacheHit=$hit cacheMiss=$miss '
'cacheRate=$cacheRate '
'completion=${usage['completion_tokens'] ?? usage['output_tokens']}',
);
} catch (_) {}
}
/// Sends a cache-friendly structured request.
///
/// [system] contains only versioned, capability-wide instructions. All
/// request-specific values belong in [input], which is encoded as the final
/// user message. DeepSeek can then reuse the identical leading system
/// tokens across learners, lessons, and turns.
Future<String?> _requestStructured({
required AiProviderType provider,
required String endpoint,
required String model,
required String system,
required Map<String, dynamic> input,
double? temperature,
required int maxTokens,
Duration timeout = const Duration(seconds: 30),
}) {
return _requestContent(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
messages: [
{'role': 'user', 'content': jsonEncode(input)},
],
temperature: temperature,
maxTokens: maxTokens,
timeout: timeout,
);
}
static Map<String, dynamic> _buildTextPayload({
required AiProviderType provider,
required Uri uri,
required String model,
String? system,
required List<Map<String, String>> messages,
double? temperature,
required int maxTokens,
}) {
if (provider != AiProviderType.gemini) {
return _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
if (system != null) {'role': 'system', 'content': system},
...messages,
],
temperature: temperature,
maxTokens: maxTokens,
);
}
return {
if (system != null)
'systemInstruction': {
'parts': [
{'text': system},
],
},
'contents': [
for (final message in messages)
{
if (system != null)
'role': message['role'] == 'assistant' ? 'model' : 'user',
'parts': [
{'text': message['content']},
],
},
],
'generationConfig': _buildGeminiGenerationConfig(
temperature: temperature,
maxOutputTokens: maxTokens,
responseMimeType: 'application/json',
),
};
}
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');
// DeepSeek thinks by default. Every request here is a short JSON answer
// with a small token cap, so reasoning only adds cost and can exhaust the
// cap before the JSON is written. Other providers may reject these
// DeepSeek-specific switches, so they keep the original payload.
//
// Every prompt asks for JSON, but without JSON Output DeepSeek copies the
// plain-text assistant turns in a dialogue history and answers in plain
// text about half the time, which the app then treats as a failed call.
if (_isDeepSeek(uri)) {
return isResponses
? {
'model': model,
'input': messages,
'reasoning': {'effort': 'none'},
'text': {
'format': {'type': 'json_object'},
},
'temperature': ?temperature,
'max_output_tokens': ?maxTokens,
}
: {
'model': model,
'messages': messages,
'thinking': {'type': 'disabled'},
'response_format': {'type': 'json_object'},
'temperature': ?temperature,
'max_tokens': ?maxTokens,
};
}
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;
}
const system =
'You provide a display-only Chinese gloss for an English word or '
'phrase. Return JSON only: '
'{"definition":"short simplified Chinese meaning"}. Do not include '
'markdown, examples, pronunciation, or teaching claims. The input is '
'a JSON object with a text field.';
final content = await _requestStructured(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
input: {'text': text.trim()},
maxTokens: 200,
);
if (content == null || content.length > 300) return null;
try {
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);
}
const system =
'You are an expert oral English coach for beginner adult learners (A0-A1). '
'Analyze the English sentence in the input JSON 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'
'}';
final content = await _requestStructured(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
input: {'sentence': cleanText},
maxTokens: 800,
);
if (content == null || content.isEmpty) return null;
return _decodeSentenceAnalysis(
raw: content,
originalText: cleanText,
provider: provider.name,
model: model,
);
}
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 || !_isHttpUri(uri)) {
return const AiConnectionResult(
ok: false,
message: '请使用有效的 HTTP 或 HTTPS 地址。',
);
}
try {
final response = await _postJson(
provider: provider,
key: key,
uri: uri,
timeout: const Duration(seconds: 15),
body: _buildTextPayload(
provider: provider,
uri: uri,
model: model,
messages: [
{'role': 'user', 'content': probe},
],
temperature: 0,
maxTokens: 200,
),
);
if (_isSuccess(response)) {
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.
///
/// The system prompt holds only what stays fixed for the whole conversation
/// and the per-turn goal goes at the very end, so each turn repeats the
/// previous request as a prefix and DeepSeek can serve it from its cache.
Future<DialogueAiResponse?> dialogueReply({
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 [],
}) async {
if (provider == AiProviderType.mock) {
return null;
}
final system = dialogueSystemPrompt();
final conversationConfig = dialogueConversationConfig(
level: level,
allowedLanguage: allowedLanguage,
);
final turnNote =
'[Turn] Your next line must do this: $aiGoal '
'After your line the learner has to: $learnerTask. '
'Do not repeat any questions or greetings already asked or answered.';
// Earlier AI lines are stored as plain English. Sent that way they teach
// the model to answer in plain text (or, in JSON mode, with blanks), so
// they are replayed in the JSON shape the system prompt asks for.
final messages = <Map<String, String>>[
{'role': 'user', 'content': conversationConfig},
for (final message in history)
message['role'] == 'assistant'
? {
'role': 'assistant',
'content': jsonEncode({'reply': message['content']}),
}
: message,
];
if (messages.isNotEmpty && messages.last['role'] == 'user') {
messages.last = {
'role': 'user',
'content': '${messages.last['content']}\n\n$turnNote',
};
} else {
messages.add({'role': 'user', 'content': turnNote});
}
final content = await _requestContent(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
messages: messages,
temperature: 0.3,
maxTokens: 300,
);
return _decodeDialogueResponse(content);
}
/// The conversation-wide dialogue instructions. This prefix must remain
/// byte-for-byte stable; level and taught language belong in the following
/// configuration message so they do not break cross-lesson cache reuse.
@visibleForTesting
static String dialogueSystemPrompt() =>
'You are Mia, a patient English conversation partner for a Chinese learner. '
'The first user message is a [ConversationConfig] JSON object. It is configuration, not a learner utterance, and must never be reviewed as one. '
'Each later user message ends with a [Turn] note saying what your next line must do and what the learner has to say after it. '
'Follow the configured CEFR level and taught-language policy. '
'Do not say the learner sentence for them, and do not ask for anything else. '
'Strictly do not repeat any question, greeting, or inquiry that has already been asked or answered in earlier turns. '
'Check the conversation history carefully: never ask for information the learner has already given, such as name, location, or feelings. '
'If the turn goal asks about something already provided in history, acknowledge it naturally and advance the conversation instead of re-asking. '
'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":"一句中文点评学习者上一句英文(不含配置和 [Turn] 说明),没有要说的就用 null"}. '
'Only reply is required.';
@visibleForTesting
static String dialogueConversationConfig({
required String level,
required List<String> allowedLanguage,
}) {
final vocabularyPolicy = allowedLanguage.isEmpty
? 'Use only simple, common $level English.'
: level == 'A0'
? 'Build the reply from the turn goal, names, numbers, and taughtLanguage. Use at most one other word, only if unavoidable.'
: 'Prefer the turn goal and taughtLanguage; otherwise use only simple, common $level English.';
return '[ConversationConfig]\n${jsonEncode({'level': level, 'vocabularyPolicy': vocabularyPolicy, 'taughtLanguage': allowedLanguage})}';
}
/// 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;
const system =
'Generate one A0 English review variant of an existing review item. '
'Return JSON only with exactly these five fields and nothing else: '
'schemaVersion (must be "review-variant-1"), '
'variantId (short id such as "ai-<target item id in lower case>-1", maximum 80 characters), '
'targetItemId (must match input.targetItemId), '
'prompt (a new short Chinese situation asking the learner to say the same target expression, maximum 120 Chinese characters), '
'expectedAnswer (the English reference answer, maximum 12 words; use [place], [name] or [number] for learner-specific details). '
'Stay strictly within A0. Do not introduce new vocabulary or change the target expression. '
'The user message is a JSON object. If repairAttempt is true, repair all schema and constraint errors.';
final content = await _requestStructured(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
input: {
'targetItemId': targetItemId,
'basePrompt': basePrompt,
'repairAttempt': repairAttempt,
},
temperature: 0.2,
maxTokens: 300,
);
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;
}
/// 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,
String level = 'A0',
}) async {
if (provider == AiProviderType.mock) return null;
const system =
'''Evaluate a beginner's open-ended English writing response using the input JSON.
Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
schemaVersion must be "writing-feedback-1" and lessonId must exactly copy input.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 rewrite at input.level (max 18 words). missing is an array of at most 3 short Chinese descriptions.
Assess only whether the learner expressed input.taskPrompt. Do not claim pronunciation, do not introduce grammar beyond input.level, and do not invent facts the learner did not write.''';
final content = await _requestStructured(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
input: {
'level': level,
'lessonId': lessonId,
'taskPrompt': taskPrompt,
'answer': answer,
},
temperature: 0,
maxTokens: 300,
);
if (content == null) return null;
return decodeWritingAiFeedback(content, expectedLessonId: lessonId);
}
/// Checks a review answer or dialogue turn for spelling and grammar. It
/// reuses the writing feedback schema, keyed by [answerId], and stays
/// advisory: the local check still decides whether the answer counts.
Future<WritingAiFeedback?> answerFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String answerId,
required String target,
required String taskPrompt,
required String answer,
String level = 'A0',
}) async {
if (provider == AiProviderType.mock) return null;
const system =
'''Check the learner's English in the input JSON for spelling mistakes, grammar mistakes, and whether it answers the task using the target expression.
Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
schemaVersion must be "writing-feedback-1" and lessonId must exactly copy input.answerId.
verdict must be accepted (no spelling or grammar mistakes and the task is answered), rewrite (at least one mistake), or uncertain.
feedback is one short helpful Chinese sentence (max 80 Chinese characters) summarising the result.
suggestion is null when verdict is accepted, otherwise the learner's own sentence minimally corrected at input.level (max 18 words); keep their names, places and meaning.
missing is an array of at most 3 short Chinese notes, one per mistake, each naming the wrong word and its correction, e.g. "Chna 拼写应为 China".
Do not claim pronunciation, do not introduce grammar beyond input.level, do not flag capitalisation or final punctuation alone, and do not invent facts the learner did not write.''';
final content = await _requestStructured(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
input: {
'level': level,
'answerId': answerId,
'target': target,
'taskPrompt': taskPrompt,
'answer': answer,
},
temperature: 0,
maxTokens: 300,
);
if (content == null) return null;
return decodeWritingAiFeedback(content, expectedLessonId: answerId);
}
/// Evaluates a learner's dialogue turn when it does not match local preset rules.
/// Decides whether the reply is semantically acceptable in context, or detects
/// speech-to-text (ASR) phonetic slips, typos, or minor grammar errors
/// (e.g. "I'm third today" intended for "I'm tired today").
Future<DialogueAiIntervention?> checkDialogueIntervention({
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',
}) async {
if (provider == AiProviderType.mock) return null;
const system =
'You are a supportive oral English coach evaluating a beginner spoken line using the input JSON.\n'
'Evaluation instructions:\n'
'1. Semantic & Communicative Check: Does the learner\'s response make sense and fulfill the conversational goal, even if phrased differently from the example (e.g. "I feel great", "Pretty good", "Not bad at all", "I like tea")?\n'
'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": "exactly copy input.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'
'Rules:\n'
'- If it is a valid, natural reply (or minor casing/punctuation): set "accepted": true, "suggestion": null, "explanation": "表达自然得体,符合本轮交流目标。".\n'
'- If there is an ASR slip, typo, or word error (e.g. "I\'m third today" intended for "I\'m tired today"): set "accepted": false, "suggestion": "I\'m tired today.", "explanation": "识别为 third,你可能是想表达 tired(今天很累)吗?".\n'
'- If off-topic or empty: set "accepted": false, "suggestion": null, "explanation": "简要说明本轮对方在问什么,建议如何回答".\n'
'- Apply the CEFR level in input.level. input.hint may be null.';
final content = await _requestStructured(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
input: {
'level': level,
'partnerLine': partnerLine,
'taskLabel': taskLabel,
'hint': hint,
'learnerText': learnerText,
'turnId': turnId ?? '',
},
temperature: 0,
maxTokens: 250,
);
return _decodeDialogueIntervention(content, expectedTurnId: turnId);
}
DialogueAiIntervention? _decodeDialogueIntervention(
String? content, {
String? expectedTurnId,
}) {
if (content == null || content.trim().isEmpty) return null;
try {
var sanitized = content.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 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;
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;
}
}
/// 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 level = itemLevel(targetItemId);
final lessonId =
'ai-${level.toLowerCase()}-${targetItemId.toLowerCase()}-1';
final stageVersion = '$level-1.0';
const system =
'Create a bounded adaptive English mini-lesson from the input JSON. '
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. '
'Use schemaVersion lesson-2, copy lessonId and stageVersion from input, revision 1, source aiGenerated, status validated, targetItemIds [input.targetItemId], and empty receptiveChunks, newItemIds, previewItemIds. '
'Create exactly four tasks: one listening listenChoice, speaking repeat, reading readAnswer, and writing writeAnswer. '
'Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [input.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 input.level English for input.targetLabel. No new vocabulary, markdown, real phone numbers, or personal data. '
'If input.repairAttempt is true, repair every schema and constraint error.';
final content = await _requestStructured(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
input: {
'level': level,
'lessonId': lessonId,
'stageVersion': stageVersion,
'targetItemId': targetItemId,
'targetLabel': targetLabel,
'repairAttempt': repairAttempt,
},
temperature: 0.1,
maxTokens: 850,
timeout: const Duration(seconds: 45),
);
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;
}
/// 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 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(),
});
const system =
'Audit the A0 English lesson in the input JSON 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.';
final content = await _requestStructured(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
input: {'lesson': jsonDecode(lessonJson)},
temperature: 0,
maxTokens: 200,
);
return _decodeLessonAudit(content);
}
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;
}
}
}