refactor: 合并 AiService 各文本接口重复的请求流程
8 个文本接口原本各自重复一套:取 API Key、校验模型与地址、按服务商拼请求头和请求体、 超时、判断 2xx、提取回复内容。现在统一到 _requestContent / _requestPrompt, 底层发送逻辑为 _postJson,请求体由 _buildTextPayload 构造(testConnection 和 transcribeAudio 也改用它们)。各接口只保留提示词和结果解析。 纯重构,不改变行为。已用临时测试录下 3 种服务商、4 种响应(成功、500、401、 网络异常)以及配置无效时的全部请求:共 134 个请求的地址、请求头、请求体和 所有返回值,重构前后逐字节一致。ai_service.dart 从 1291 行减为 1051 行。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -145,10 +145,12 @@ class AiService {
|
|||||||
try {
|
try {
|
||||||
if (provider == AiProviderType.gemini) {
|
if (provider == AiProviderType.gemini) {
|
||||||
final mimeType = format == 'wav' ? 'audio/wav' : (format == 'mp3' ? 'audio/mp3' : 'audio/mp4');
|
final mimeType = format == 'wav' ? 'audio/wav' : (format == 'mp3' ? 'audio/mp3' : 'audio/mp4');
|
||||||
final response = await http.post(
|
final response = await _postJson(
|
||||||
uri,
|
provider: provider,
|
||||||
headers: {'x-goog-api-key': key, 'Content-Type': 'application/json'},
|
key: key,
|
||||||
body: jsonEncode({
|
uri: uri,
|
||||||
|
timeout: const Duration(seconds: 25),
|
||||||
|
body: {
|
||||||
'contents': [
|
'contents': [
|
||||||
{
|
{
|
||||||
'parts': [
|
'parts': [
|
||||||
@@ -169,18 +171,17 @@ class AiService {
|
|||||||
'thinkingBudget': 1024,
|
'thinkingBudget': 1024,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
).timeout(const Duration(seconds: 25));
|
);
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
if (!_isSuccess(response)) return null;
|
||||||
return _extractResponseContent(provider, response.body);
|
return _extractResponseContent(provider, response.body);
|
||||||
} else {
|
} else {
|
||||||
final response = await http.post(
|
final response = await _postJson(
|
||||||
uri,
|
provider: provider,
|
||||||
headers: {
|
key: key,
|
||||||
'Authorization': 'Bearer $key',
|
uri: uri,
|
||||||
'Content-Type': 'application/json',
|
timeout: const Duration(seconds: 25),
|
||||||
},
|
body: {
|
||||||
body: jsonEncode({
|
|
||||||
'model': model,
|
'model': model,
|
||||||
'messages': [
|
'messages': [
|
||||||
{
|
{
|
||||||
@@ -202,9 +203,9 @@ class AiService {
|
|||||||
],
|
],
|
||||||
'reasoning_effort': 'low',
|
'reasoning_effort': 'low',
|
||||||
'temperature': 0.1,
|
'temperature': 0.1,
|
||||||
}),
|
},
|
||||||
).timeout(const Duration(seconds: 25));
|
);
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) return null;
|
if (!_isSuccess(response)) return null;
|
||||||
final raw = _extractResponseContent(provider, response.body);
|
final raw = _extractResponseContent(provider, response.body);
|
||||||
if (raw == null) return null;
|
if (raw == null) return null;
|
||||||
var text = raw.trim();
|
var text = raw.trim();
|
||||||
@@ -218,6 +219,151 @@ class AiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isHttpUri(Uri uri) => uri.scheme == 'https' || uri.scheme == 'http';
|
||||||
|
|
||||||
|
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({
|
static Map<String, dynamic> _buildOpenAiPayload({
|
||||||
required Uri uri,
|
required Uri uri,
|
||||||
required String model,
|
required String model,
|
||||||
@@ -275,65 +421,17 @@ class AiService {
|
|||||||
text.trim().isEmpty) {
|
text.trim().isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final key = await resolveApiKey();
|
const instruction =
|
||||||
final uri = resolveEndpointUri(
|
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
|
||||||
|
final content = await _requestPrompt(
|
||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
|
prompt: '$instruction\nText: $text',
|
||||||
|
maxTokens: 200,
|
||||||
);
|
);
|
||||||
if (key == null ||
|
if (content == null || content.length > 300) return null;
|
||||||
key.isEmpty ||
|
|
||||||
model.trim().isEmpty ||
|
|
||||||
uri == null ||
|
|
||||||
(uri.scheme != 'https' && uri.scheme != 'http')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const instruction =
|
|
||||||
'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
|
|
||||||
try {
|
try {
|
||||||
final response = await http
|
|
||||||
.post(
|
|
||||||
uri,
|
|
||||||
headers: provider == AiProviderType.gemini
|
|
||||||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
|
||||||
: {
|
|
||||||
'Authorization': 'Bearer $key',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: jsonEncode(
|
|
||||||
provider == AiProviderType.gemini
|
|
||||||
? {
|
|
||||||
'contents': [
|
|
||||||
{
|
|
||||||
'parts': [
|
|
||||||
{'text': '$instruction\nText: $text'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'generationConfig': _buildGeminiGenerationConfig(
|
|
||||||
maxOutputTokens: 200,
|
|
||||||
responseMimeType: 'application/json',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: _buildOpenAiPayload(
|
|
||||||
uri: uri,
|
|
||||||
model: model,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
'role': 'user',
|
|
||||||
'content': '$instruction\nText: $text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
maxTokens: 200,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 30));
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final content = _extractResponseContent(provider, response.body);
|
|
||||||
if (content == null || content.length > 300) return null;
|
|
||||||
final parsed = jsonDecode(content);
|
final parsed = jsonDecode(content);
|
||||||
if (parsed is! Map<String, dynamic>) return null;
|
if (parsed is! Map<String, dynamic>) return null;
|
||||||
final definition = parsed['definition'];
|
final definition = parsed['definition'];
|
||||||
@@ -362,20 +460,6 @@ class AiService {
|
|||||||
return _buildMockSentenceAnalysis(cleanText);
|
return _buildMockSentenceAnalysis(cleanText);
|
||||||
}
|
}
|
||||||
|
|
||||||
final key = await resolveApiKey();
|
|
||||||
final uri = resolveEndpointUri(
|
|
||||||
provider: provider,
|
|
||||||
endpoint: endpoint,
|
|
||||||
model: model,
|
|
||||||
);
|
|
||||||
if (key == null ||
|
|
||||||
key.isEmpty ||
|
|
||||||
model.trim().isEmpty ||
|
|
||||||
uri == null ||
|
|
||||||
(uri.scheme != 'https' && uri.scheme != 'http')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const instruction =
|
const instruction =
|
||||||
'You are an expert oral English coach for beginner adult learners (A0-A1). '
|
'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. '
|
'Analyze the given English sentence into clear, encouraging, beginner-friendly Chinese explanations. '
|
||||||
@@ -396,60 +480,20 @@ class AiService {
|
|||||||
' ]\n'
|
' ]\n'
|
||||||
'}';
|
'}';
|
||||||
|
|
||||||
try {
|
final content = await _requestPrompt(
|
||||||
final response = await http
|
provider: provider,
|
||||||
.post(
|
endpoint: endpoint,
|
||||||
uri,
|
model: model,
|
||||||
headers: provider == AiProviderType.gemini
|
prompt: '$instruction\n\nSentence: $cleanText',
|
||||||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
maxTokens: 800,
|
||||||
: {
|
);
|
||||||
'Authorization': 'Bearer $key',
|
if (content == null || content.isEmpty) return null;
|
||||||
'Content-Type': 'application/json',
|
return _decodeSentenceAnalysis(
|
||||||
},
|
raw: content,
|
||||||
body: jsonEncode(
|
originalText: cleanText,
|
||||||
provider == AiProviderType.gemini
|
provider: provider.name,
|
||||||
? {
|
model: model,
|
||||||
'contents': [
|
);
|
||||||
{
|
|
||||||
'parts': [
|
|
||||||
{'text': '$instruction\n\nSentence: $cleanText'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'generationConfig': _buildGeminiGenerationConfig(
|
|
||||||
maxOutputTokens: 800,
|
|
||||||
responseMimeType: 'application/json',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: _buildOpenAiPayload(
|
|
||||||
uri: uri,
|
|
||||||
model: model,
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
'role': 'user',
|
|
||||||
'content': '$instruction\n\nSentence: $cleanText',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
maxTokens: 800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 30));
|
|
||||||
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final content = _extractResponseContent(provider, response.body);
|
|
||||||
if (content == null || content.isEmpty) return null;
|
|
||||||
return _decodeSentenceAnalysis(
|
|
||||||
raw: content,
|
|
||||||
originalText: cleanText,
|
|
||||||
provider: provider.name,
|
|
||||||
model: model,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SentenceAnalysisResult? _decodeSentenceAnalysis({
|
SentenceAnalysisResult? _decodeSentenceAnalysis({
|
||||||
@@ -614,51 +658,30 @@ class AiService {
|
|||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
);
|
);
|
||||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
if (uri == null || !_isHttpUri(uri)) {
|
||||||
return const AiConnectionResult(
|
return const AiConnectionResult(
|
||||||
ok: false,
|
ok: false,
|
||||||
message: '请使用有效的 HTTP 或 HTTPS 地址。',
|
message: '请使用有效的 HTTP 或 HTTPS 地址。',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final response = await http
|
final response = await _postJson(
|
||||||
.post(
|
provider: provider,
|
||||||
uri,
|
key: key,
|
||||||
headers: provider == AiProviderType.gemini
|
uri: uri,
|
||||||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
timeout: const Duration(seconds: 15),
|
||||||
: {
|
body: _buildTextPayload(
|
||||||
'Authorization': 'Bearer $key',
|
provider: provider,
|
||||||
'Content-Type': 'application/json',
|
uri: uri,
|
||||||
},
|
model: model,
|
||||||
body: jsonEncode(
|
messages: [
|
||||||
provider == AiProviderType.gemini
|
{'role': 'user', 'content': probe},
|
||||||
? {
|
],
|
||||||
'contents': [
|
temperature: 0,
|
||||||
{
|
maxTokens: 200,
|
||||||
'parts': [
|
),
|
||||||
{'text': probe},
|
);
|
||||||
],
|
if (_isSuccess(response)) {
|
||||||
},
|
|
||||||
],
|
|
||||||
'generationConfig': _buildGeminiGenerationConfig(
|
|
||||||
temperature: 0,
|
|
||||||
maxOutputTokens: 200,
|
|
||||||
responseMimeType: 'application/json',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: _buildOpenAiPayload(
|
|
||||||
uri: uri,
|
|
||||||
model: model,
|
|
||||||
messages: [
|
|
||||||
{'role': 'user', 'content': probe},
|
|
||||||
],
|
|
||||||
temperature: 0,
|
|
||||||
maxTokens: 200,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 15));
|
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
||||||
final content = _extractResponseContent(provider, response.body);
|
final content = _extractResponseContent(provider, response.body);
|
||||||
if (_decodeDialogueResponse(content) != null) {
|
if (_decodeDialogueResponse(content) != null) {
|
||||||
return const AiConnectionResult(
|
return const AiConnectionResult(
|
||||||
@@ -716,21 +739,6 @@ class AiService {
|
|||||||
if (provider == AiProviderType.mock) {
|
if (provider == AiProviderType.mock) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final key = await resolveApiKey();
|
|
||||||
if (key == null ||
|
|
||||||
key.isEmpty ||
|
|
||||||
endpoint.trim().isEmpty ||
|
|
||||||
model.trim().isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final uri = resolveEndpointUri(
|
|
||||||
provider: provider,
|
|
||||||
endpoint: endpoint,
|
|
||||||
model: model,
|
|
||||||
);
|
|
||||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final vocabularyRule = allowedLanguage.isEmpty
|
final vocabularyRule = allowedLanguage.isEmpty
|
||||||
? ''
|
? ''
|
||||||
: 'Build your reply from your goal wording, names, numbers and this '
|
: 'Build your reply from your goal wording, names, numbers and this '
|
||||||
@@ -748,64 +756,16 @@ class AiService {
|
|||||||
'"translation": "reply 的简体中文翻译", '
|
'"translation": "reply 的简体中文翻译", '
|
||||||
'"feedback": "一句中文点评学习者上一句英文,没有要说的就用 null"}. '
|
'"feedback": "一句中文点评学习者上一句英文,没有要说的就用 null"}. '
|
||||||
'Only reply is required.';
|
'Only reply is required.';
|
||||||
try {
|
final content = await _requestContent(
|
||||||
final response = await http
|
provider: provider,
|
||||||
.post(
|
endpoint: endpoint,
|
||||||
uri,
|
model: model,
|
||||||
headers: provider == AiProviderType.gemini
|
system: system,
|
||||||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
messages: history,
|
||||||
: {
|
temperature: 0.3,
|
||||||
'Authorization': 'Bearer $key',
|
maxTokens: 300,
|
||||||
'Content-Type': 'application/json',
|
);
|
||||||
},
|
return _decodeDialogueResponse(content);
|
||||||
body: jsonEncode(
|
|
||||||
provider == AiProviderType.gemini
|
|
||||||
? {
|
|
||||||
'systemInstruction': {
|
|
||||||
'parts': [
|
|
||||||
{'text': system},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'contents': history
|
|
||||||
.map(
|
|
||||||
(turn) => {
|
|
||||||
'role': turn['role'] == 'assistant'
|
|
||||||
? 'model'
|
|
||||||
: 'user',
|
|
||||||
'parts': [
|
|
||||||
{'text': turn['content']},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
'generationConfig': _buildGeminiGenerationConfig(
|
|
||||||
temperature: 0.3,
|
|
||||||
maxOutputTokens: 300,
|
|
||||||
responseMimeType: 'application/json',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: _buildOpenAiPayload(
|
|
||||||
uri: uri,
|
|
||||||
model: model,
|
|
||||||
messages: [
|
|
||||||
{'role': 'system', 'content': system},
|
|
||||||
...history,
|
|
||||||
],
|
|
||||||
temperature: 0.3,
|
|
||||||
maxTokens: 300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 30));
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return _decodeDialogueResponse(
|
|
||||||
_extractResponseContent(provider, response.body),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates only a bounded variant of an existing review target. A network
|
/// Generates only a bounded variant of an existing review target. A network
|
||||||
@@ -819,82 +779,32 @@ class AiService {
|
|||||||
bool repairAttempt = false,
|
bool repairAttempt = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (provider == AiProviderType.mock) return null;
|
if (provider == AiProviderType.mock) return null;
|
||||||
final key = await resolveApiKey();
|
final instruction =
|
||||||
if (key == null ||
|
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". Return JSON only with exactly: schemaVersion (must be "review-variant-1"), targetItemId (must be "$targetItemId"), prompt (short Chinese instruction), stimulus (English sentence, maximum 12 words), answer (exact expected English answer, maximum 10 words), acceptedAnswers (array of 1 to 4 strings), requiredAnyPhrases (array of 1 to 3 arrays of strings), forbiddenPhrases (array of up to 4 strings). Stay strictly within A0. Do not introduce new vocabulary. The answer must satisfy the spec.${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
|
||||||
key.isEmpty ||
|
final content = await _requestPrompt(
|
||||||
endpoint.trim().isEmpty ||
|
|
||||||
model.trim().isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final uri = resolveEndpointUri(
|
|
||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
|
prompt: instruction,
|
||||||
|
temperature: 0.2,
|
||||||
|
maxTokens: 300,
|
||||||
);
|
);
|
||||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
if (content == null) return null;
|
||||||
final instruction =
|
final variant = decodeGeneratedReviewVariant(
|
||||||
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". Return JSON only with exactly: schemaVersion (must be "review-variant-1"), targetItemId (must be "$targetItemId"), prompt (short Chinese instruction), stimulus (English sentence, maximum 12 words), answer (exact expected English answer, maximum 10 words), acceptedAnswers (array of 1 to 4 strings), requiredAnyPhrases (array of 1 to 3 arrays of strings), forbiddenPhrases (array of up to 4 strings). Stay strictly within A0. Do not introduce new vocabulary. The answer must satisfy the spec.${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
|
content,
|
||||||
try {
|
expectedTargetItemId: targetItemId,
|
||||||
final response = await http
|
);
|
||||||
.post(
|
if (variant == null && !repairAttempt) {
|
||||||
uri,
|
return generateReviewVariant(
|
||||||
headers: provider == AiProviderType.gemini
|
provider: provider,
|
||||||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
endpoint: endpoint,
|
||||||
: {
|
model: model,
|
||||||
'Authorization': 'Bearer $key',
|
targetItemId: targetItemId,
|
||||||
'Content-Type': 'application/json',
|
basePrompt: basePrompt,
|
||||||
},
|
repairAttempt: true,
|
||||||
body: jsonEncode(
|
|
||||||
provider == AiProviderType.gemini
|
|
||||||
? {
|
|
||||||
'contents': [
|
|
||||||
{
|
|
||||||
'parts': [
|
|
||||||
{'text': instruction},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'generationConfig': _buildGeminiGenerationConfig(
|
|
||||||
temperature: 0.2,
|
|
||||||
maxOutputTokens: 300,
|
|
||||||
responseMimeType: 'application/json',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: _buildOpenAiPayload(
|
|
||||||
uri: uri,
|
|
||||||
model: model,
|
|
||||||
messages: [
|
|
||||||
{'role': 'user', 'content': instruction},
|
|
||||||
],
|
|
||||||
temperature: 0.2,
|
|
||||||
maxTokens: 300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 30));
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final content = _extractResponseContent(provider, response.body);
|
|
||||||
if (content == null) return null;
|
|
||||||
final variant = decodeGeneratedReviewVariant(
|
|
||||||
content,
|
|
||||||
expectedTargetItemId: targetItemId,
|
|
||||||
);
|
);
|
||||||
if (variant == null && !repairAttempt) {
|
|
||||||
return generateReviewVariant(
|
|
||||||
provider: provider,
|
|
||||||
endpoint: endpoint,
|
|
||||||
model: model,
|
|
||||||
targetItemId: targetItemId,
|
|
||||||
basePrompt: basePrompt,
|
|
||||||
repairAttempt: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return variant;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Evaluates an open-ended writing response against a bounded schema.
|
/// Evaluates an open-ended writing response against a bounded schema.
|
||||||
@@ -907,19 +817,6 @@ class AiService {
|
|||||||
required String answer,
|
required String answer,
|
||||||
}) async {
|
}) async {
|
||||||
if (provider == AiProviderType.mock) return null;
|
if (provider == AiProviderType.mock) return null;
|
||||||
final key = await resolveApiKey();
|
|
||||||
if (key == null ||
|
|
||||||
key.isEmpty ||
|
|
||||||
endpoint.trim().isEmpty ||
|
|
||||||
model.trim().isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final uri = resolveEndpointUri(
|
|
||||||
provider: provider,
|
|
||||||
endpoint: endpoint,
|
|
||||||
model: model,
|
|
||||||
);
|
|
||||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
|
||||||
final instruction =
|
final instruction =
|
||||||
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
||||||
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
|
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
|
||||||
@@ -927,56 +824,19 @@ verdict must be accepted, rewrite, or uncertain. feedback is one short helpful C
|
|||||||
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.
|
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
|
Task: $taskPrompt
|
||||||
Learner wrote: $answer''';
|
Learner wrote: $answer''';
|
||||||
try {
|
final content = await _requestPrompt(
|
||||||
final response = await http
|
provider: provider,
|
||||||
.post(
|
endpoint: endpoint,
|
||||||
uri,
|
model: model,
|
||||||
headers: provider == AiProviderType.gemini
|
prompt: instruction,
|
||||||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
temperature: 0,
|
||||||
: {
|
maxTokens: 300,
|
||||||
'Authorization': 'Bearer $key',
|
);
|
||||||
'Content-Type': 'application/json',
|
if (content == null) return null;
|
||||||
},
|
return decodeWritingAiFeedback(
|
||||||
body: jsonEncode(
|
content,
|
||||||
provider == AiProviderType.gemini
|
expectedLessonId: lessonId,
|
||||||
? {
|
);
|
||||||
'contents': [
|
|
||||||
{
|
|
||||||
'parts': [
|
|
||||||
{'text': instruction},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'generationConfig': _buildGeminiGenerationConfig(
|
|
||||||
temperature: 0,
|
|
||||||
maxOutputTokens: 300,
|
|
||||||
responseMimeType: 'application/json',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: _buildOpenAiPayload(
|
|
||||||
uri: uri,
|
|
||||||
model: model,
|
|
||||||
messages: [
|
|
||||||
{'role': 'user', 'content': instruction},
|
|
||||||
],
|
|
||||||
temperature: 0,
|
|
||||||
maxTokens: 300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 30));
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final content = _extractResponseContent(provider, response.body);
|
|
||||||
if (content == null) return null;
|
|
||||||
return decodeWritingAiFeedback(
|
|
||||||
content,
|
|
||||||
expectedLessonId: lessonId,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
|
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
|
||||||
@@ -989,83 +849,34 @@ Learner wrote: $answer''';
|
|||||||
bool repairAttempt = false,
|
bool repairAttempt = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (provider == AiProviderType.mock) return null;
|
if (provider == AiProviderType.mock) return null;
|
||||||
final key = await resolveApiKey();
|
|
||||||
if (key == null ||
|
|
||||||
key.isEmpty ||
|
|
||||||
endpoint.trim().isEmpty ||
|
|
||||||
model.trim().isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final uri = resolveEndpointUri(
|
|
||||||
provider: provider,
|
|
||||||
endpoint: endpoint,
|
|
||||||
model: model,
|
|
||||||
);
|
|
||||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
|
|
||||||
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
|
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
|
||||||
final instruction =
|
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.' : ''}';
|
'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 content = await _requestPrompt(
|
||||||
final response = await http
|
provider: provider,
|
||||||
.post(
|
endpoint: endpoint,
|
||||||
uri,
|
model: model,
|
||||||
headers: provider == AiProviderType.gemini
|
prompt: instruction,
|
||||||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
temperature: 0.1,
|
||||||
: {
|
maxTokens: 850,
|
||||||
'Authorization': 'Bearer $key',
|
timeout: const Duration(seconds: 45),
|
||||||
'Content-Type': 'application/json',
|
);
|
||||||
},
|
if (content == null) return null;
|
||||||
body: jsonEncode(
|
final lesson = decodeGeneratedLesson(
|
||||||
provider == AiProviderType.gemini
|
content,
|
||||||
? {
|
expectedTargetItemId: targetItemId,
|
||||||
'contents': [
|
);
|
||||||
{
|
if (lesson == null && !repairAttempt) {
|
||||||
'parts': [
|
return generateAdaptiveLesson(
|
||||||
{'text': instruction},
|
provider: provider,
|
||||||
],
|
endpoint: endpoint,
|
||||||
},
|
model: model,
|
||||||
],
|
targetItemId: targetItemId,
|
||||||
'generationConfig': _buildGeminiGenerationConfig(
|
targetLabel: targetLabel,
|
||||||
temperature: 0.1,
|
repairAttempt: true,
|
||||||
maxOutputTokens: 850,
|
|
||||||
responseMimeType: 'application/json',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: _buildOpenAiPayload(
|
|
||||||
uri: uri,
|
|
||||||
model: model,
|
|
||||||
messages: [
|
|
||||||
{'role': 'user', 'content': instruction},
|
|
||||||
],
|
|
||||||
temperature: 0.1,
|
|
||||||
maxTokens: 850,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 45));
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final content = _extractResponseContent(provider, response.body);
|
|
||||||
if (content == null) return null;
|
|
||||||
final lesson = decodeGeneratedLesson(
|
|
||||||
content,
|
|
||||||
expectedTargetItemId: targetItemId,
|
|
||||||
);
|
);
|
||||||
if (lesson == null && !repairAttempt) {
|
|
||||||
return generateAdaptiveLesson(
|
|
||||||
provider: provider,
|
|
||||||
endpoint: endpoint,
|
|
||||||
model: model,
|
|
||||||
targetItemId: targetItemId,
|
|
||||||
targetLabel: targetLabel,
|
|
||||||
repairAttempt: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return lesson;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return lesson;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends the entire generated lesson structure to an independent LLM audit.
|
/// Sends the entire generated lesson structure to an independent LLM audit.
|
||||||
@@ -1076,19 +887,6 @@ Learner wrote: $answer''';
|
|||||||
required GeneratedLesson lesson,
|
required GeneratedLesson lesson,
|
||||||
}) async {
|
}) async {
|
||||||
if (provider == AiProviderType.mock) return true;
|
if (provider == AiProviderType.mock) return true;
|
||||||
final key = await resolveApiKey();
|
|
||||||
if (key == null ||
|
|
||||||
key.isEmpty ||
|
|
||||||
endpoint.trim().isEmpty ||
|
|
||||||
model.trim().isEmpty) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final uri = resolveEndpointUri(
|
|
||||||
provider: provider,
|
|
||||||
endpoint: endpoint,
|
|
||||||
model: model,
|
|
||||||
);
|
|
||||||
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return false;
|
|
||||||
final lessonJson = jsonEncode({
|
final lessonJson = jsonEncode({
|
||||||
'lessonId': lesson.lessonId,
|
'lessonId': lesson.lessonId,
|
||||||
'stageVersion': lesson.stageVersion,
|
'stageVersion': lesson.stageVersion,
|
||||||
@@ -1113,53 +911,15 @@ Learner wrote: $answer''';
|
|||||||
});
|
});
|
||||||
final instruction =
|
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';
|
'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 content = await _requestPrompt(
|
||||||
final response = await http
|
provider: provider,
|
||||||
.post(
|
endpoint: endpoint,
|
||||||
uri,
|
model: model,
|
||||||
headers: provider == AiProviderType.gemini
|
prompt: instruction,
|
||||||
? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
|
temperature: 0,
|
||||||
: {
|
maxTokens: 200,
|
||||||
'Authorization': 'Bearer $key',
|
);
|
||||||
'Content-Type': 'application/json',
|
return _decodeLessonAudit(content);
|
||||||
},
|
|
||||||
body: jsonEncode(
|
|
||||||
provider == AiProviderType.gemini
|
|
||||||
? {
|
|
||||||
'contents': [
|
|
||||||
{
|
|
||||||
'parts': [
|
|
||||||
{'text': instruction},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'generationConfig': _buildGeminiGenerationConfig(
|
|
||||||
temperature: 0,
|
|
||||||
maxOutputTokens: 200,
|
|
||||||
responseMimeType: 'application/json',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: _buildOpenAiPayload(
|
|
||||||
uri: uri,
|
|
||||||
model: model,
|
|
||||||
messages: [
|
|
||||||
{'role': 'user', 'content': instruction},
|
|
||||||
],
|
|
||||||
temperature: 0,
|
|
||||||
maxTokens: 200,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.timeout(const Duration(seconds: 30));
|
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return _decodeLessonAudit(
|
|
||||||
_extractResponseContent(provider, response.body),
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _decodeLessonAudit(String? raw) {
|
bool _decodeLessonAudit(String? raw) {
|
||||||
|
|||||||
Reference in New Issue
Block a user