Files
English/kouyu_english/lib/core/ai_service.dart
T
shenleiandClaude Opus 5 9d30cd681e feat: 调用 DeepSeek 时关闭思考模式
DeepSeek 默认开启思考,思考 token 按输出计费,且可能耗尽较小的
max_tokens 导致 JSON 截断。对 deepseek.com 地址:chat/completions
发送 thinking.type=disabled,Responses 发送 reasoning.effort=none;
其他服务商请求体保持不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:32:25 +09:00

1086 lines
38 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_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 _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;
return _extractResponseContent(provider, response.body);
} catch (_) {
return null;
}
}
Future<String?> _requestPrompt({
required AiProviderType provider,
required String endpoint,
required String model,
required String prompt,
double? temperature,
required int maxTokens,
Duration timeout = const Duration(seconds: 30),
}) {
return _requestContent(
provider: provider,
endpoint: endpoint,
model: model,
messages: [
{'role': 'user', 'content': prompt},
],
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.
if (_isDeepSeek(uri)) {
return isResponses
? {
'model': model,
'input': messages,
'reasoning': {'effort': 'none'},
'temperature': ?temperature,
'max_output_tokens': ?maxTokens,
}
: {
'model': model,
'messages': messages,
'thinking': {'type': 'disabled'},
'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 instruction =
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
final content = await _requestPrompt(
provider: provider,
endpoint: endpoint,
model: model,
prompt: '$instruction\nText: $text',
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 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'
'}';
final content = await _requestPrompt(
provider: provider,
endpoint: endpoint,
model: model,
prompt: '$instruction\n\nSentence: $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.
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 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.';
final content = await _requestContent(
provider: provider,
endpoint: endpoint,
model: model,
system: system,
messages: history,
temperature: 0.3,
maxTokens: 300,
);
return _decodeDialogueResponse(content);
}
/// 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 instruction =
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". '
'Return JSON only with exactly these five fields and nothing else: '
'schemaVersion (must be "review-variant-1"), '
'variantId (short id such as "ai-${targetItemId.toLowerCase()}-1", maximum 80 characters), '
'targetItemId (must be "$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.'
'${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
final content = await _requestPrompt(
provider: provider,
endpoint: endpoint,
model: model,
prompt: instruction,
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,
}) async {
if (provider == AiProviderType.mock) 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''';
final content = await _requestPrompt(
provider: provider,
endpoint: endpoint,
model: model,
prompt: instruction,
temperature: 0,
maxTokens: 300,
);
if (content == null) return null;
return decodeWritingAiFeedback(
content,
expectedLessonId: lessonId,
);
}
/// 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 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.' : ''}';
final content = await _requestPrompt(
provider: provider,
endpoint: endpoint,
model: model,
prompt: instruction,
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(),
});
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';
final content = await _requestPrompt(
provider: provider,
endpoint: endpoint,
model: model,
prompt: instruction,
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;
}
}
}