Files
English/kouyu_english/lib/core/ai_service.dart
T

731 lines
27 KiB
Dart

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';
import 'a0_core.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
/// use the OpenAI chat-completions shape, including a user-run CLIProxyAPI.
class AiService {
AiService._();
static final instance = AiService._();
static const _keyName = 'ai_api_key';
final _secureStorage = const FlutterSecureStorage();
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<bool> hasApiKey() async =>
(await _secureStorage.read(key: _keyName))?.isNotEmpty ?? false;
/// 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 _secureStorage.read(key: _keyName);
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (key == null ||
key.isEmpty ||
model.trim().isEmpty ||
uri == null ||
uri.scheme != 'https') {
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': {
'maxOutputTokens': 100,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{
'role': 'user',
'content': '$instruction\nText: $text',
},
],
'max_tokens': 100,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final raw = _extractResponseContent(provider, response.body);
final data = raw == null ? null : jsonDecode(raw);
final definition = data is Map ? data['definition'] : null;
return definition is String &&
definition.trim().isNotEmpty &&
definition.length <= 160
? definition.trim()
: null;
} catch (_) {
return null;
}
}
Future<AiConnectionResult> testConnection({
required AiProviderType provider,
required String endpoint,
required String model,
}) 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 _secureStorage.read(key: _keyName);
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 base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') {
return const AiConnectionResult(ok: false, message: '请使用有效的 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': {
'maxOutputTokens': 80,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': probe},
],
'max_tokens': 80,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode >= 200 && response.statusCode < 300) {
final content = _extractResponseContent(provider, response.body);
if (_decodeDialogueResponse(content) != null) {
return const AiConnectionResult(ok: true, message: '连接成功,结构化对话可用。');
}
return const AiConnectionResult(
ok: false,
message: '服务可连接,但未返回应用需要的结构化对话格式。',
);
}
if (response.statusCode == 401 || response.statusCode == 403) {
return const AiConnectionResult(
ok: false,
message: '鉴权失败,请检查 API Key。',
);
}
return AiConnectionResult(
ok: false,
message: '服务返回 ${response.statusCode},请检查地址和模型。',
);
} catch (_) {
return const AiConnectionResult(
ok: false,
message: '无法连接服务。请检查网络、地址或局域网连通性。',
);
}
}
Future<DialogueAiResponse?> dialogueReply({
required AiProviderType provider,
required String endpoint,
required String model,
required List<Map<String, String>> history,
required String requiredTask,
}) async {
if (provider == AiProviderType.mock) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') {
return null;
}
const system =
'You are Mia, a patient A0 English conversation partner. Use only very simple English. Reply in one short sentence or question. Do not explain grammar. Return JSON only, with exactly these fields: reply (string, maximum 20 English words), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
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$requiredTask'},
],
},
'contents': history
.map(
(turn) => {
'role': turn['role'] == 'assistant'
? 'model'
: 'user',
'parts': [
{'text': turn['content']},
],
},
)
.toList(),
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 60,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'system', 'content': '$system$requiredTask'},
...history,
],
'temperature': 0.3,
'max_tokens': 60,
},
),
)
.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 _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return null;
final instruction =
'''Return JSON only with exactly: schemaVersion, variantId, targetItemId, prompt, expectedAnswer.
schemaVersion must be "review-variant-1". targetItemId must be "$targetItemId".
Make one beginner A0 English review prompt. Do not add explanations, translations, markdown, or fields.
${repairAttempt ? 'The previous response was invalid. Fix the JSON schema exactly.' : ''}
Base task: $basePrompt''';
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': {
'temperature': 0.3,
'maxOutputTokens': 180,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.3,
'max_tokens': 180,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final raw = _extractResponseContent(provider, response.body);
final decoded = raw == null
? null
: decodeGeneratedReviewVariant(
raw,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateReviewVariant(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
basePrompt: basePrompt,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Requests a short, teaching-oriented writing suggestion. The caller must
/// still run its local task validator; this response has no authority to
/// mark an answer correct or change mastery.
Future<WritingAiFeedback?> writingFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String lessonId,
required String taskPrompt,
required String answer,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') 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.
${repairAttempt ? 'The previous response was invalid. Return the exact JSON schema now.' : ''}
Task: $taskPrompt
Learner answer: $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': {
'temperature': 0.2,
'maxOutputTokens': 240,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 240,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final decoded = decodeWritingAiFeedback(
_extractResponseContent(provider, response.body) ?? '',
expectedLessonId: lessonId,
);
if (decoded != null || repairAttempt) return decoded;
return writingFeedback(
provider: provider,
endpoint: endpoint,
model: model,
lessonId: lessonId,
taskPrompt: taskPrompt,
answer: answer,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Generates one bounded A0 reinforcement lesson for a stable core target.
/// It remains unpublished until local schema validation accepts it.
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 ||
!a0CoreItems.containsKey(targetItemId)) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') 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': {
'temperature': 0.2,
'maxOutputTokens': 1200,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 1200,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final decoded = decodeGeneratedLesson(
_extractResponseContent(provider, response.body) ?? '',
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateAdaptiveLesson(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
targetLabel: targetLabel,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Separate audit request: it does not receive the generation prompt and
/// can only approve/reject a previously client-validated lesson.
Future<bool> auditGeneratedLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required GeneratedLesson lesson,
}) async {
if (provider == AiProviderType.mock) return false;
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return false;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
);
if (uri == null || uri.scheme != 'https') return false;
final lessonJson = jsonEncode({
'lessonId': lesson.lessonId,
'stageVersion': lesson.stageVersion,
'targetItemIds': lesson.targetItemIds,
'tasks': lesson.tasks
.map(
(task) => {
'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': {
'temperature': 0,
'maxOutputTokens': 120,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0,
'max_tokens': 120,
},
),
)
.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>;
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?;
return (parts?.firstOrNull as Map?)?['text'] as String?;
}
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
return (choice?['message'] as Map?)?['content'] as String?;
} catch (_) {
return null;
}
}
DialogueAiResponse? _decodeDialogueResponse(String? raw) {
if (raw == null || raw.trim().isEmpty || raw.length > 1200) return null;
try {
final data = jsonDecode(raw) as Map<String, dynamic>;
final reply = data['reply'] as String?;
final rawSlots = data['slots'];
final rawEvidence = data['evidence'];
final suggestsComplete = data['suggestsComplete'];
final feedback = data['feedback'];
if (reply == null ||
reply.trim().isEmpty ||
reply.length > 240 ||
rawSlots is! Map ||
rawEvidence is! List ||
suggestsComplete is! bool ||
(feedback != null && feedback is! String)) {
return null;
}
final slots = <String, String>{};
for (final entry in rawSlots.entries) {
if (entry.key is! String || entry.value is! String) return null;
if ((entry.key as String).length > 40 ||
(entry.value as String).length > 80) {
return null;
}
slots[entry.key as String] = entry.value as String;
}
final evidence = <String>[];
for (final item in rawEvidence) {
if (item is! String || item.length > 240) return null;
evidence.add(item);
}
return DialogueAiResponse(
reply: reply.trim(),
slots: slots,
evidence: evidence,
suggestsComplete: suggestsComplete,
feedback: feedback as String?,
);
} catch (_) {
return null;
}
}
}