fix: AI 复习变式请求字段与校验一致;转写后删除不保留的录音
- generateReviewVariant 的指令要求 stimulus/answer/acceptedAnswers 等字段, 而 decodeGeneratedReviewVariant 只接受 schemaVersion、variantId、 targetItemId、prompt、expectedAnswer 五个字段,导致 AI 变式始终校验失败。 现在指令只要求这五个字段,并补充了请求与解码一致性的测试。 - finishVoiceInput(keepAudio: false) 转写完成(或页面已关闭)后删除音频, 补练页和测评页的语音输入不再在本机残留录音文件。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -780,7 +780,15 @@ class AiService {
|
|||||||
}) async {
|
}) async {
|
||||||
if (provider == AiProviderType.mock) return null;
|
if (provider == AiProviderType.mock) return null;
|
||||||
final instruction =
|
final instruction =
|
||||||
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". Return JSON only with exactly: schemaVersion (must be "review-variant-1"), targetItemId (must be "$targetItemId"), prompt (short Chinese instruction), stimulus (English sentence, maximum 12 words), answer (exact expected English answer, maximum 10 words), acceptedAnswers (array of 1 to 4 strings), requiredAnyPhrases (array of 1 to 3 arrays of strings), forbiddenPhrases (array of up to 4 strings). Stay strictly within A0. Do not introduce new vocabulary. The answer must satisfy the spec.${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
|
'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(
|
final content = await _requestPrompt(
|
||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ mixin VoiceAnswerMixin<T extends StatefulWidget> on State<T> {
|
|||||||
/// [onTranscript] runs inside `setState` with the trimmed, non-empty text.
|
/// [onTranscript] runs inside `setState` with the trimmed, non-empty text.
|
||||||
/// [afterTranscribe] runs once transcription has finished, whether or not
|
/// [afterTranscribe] runs once transcription has finished, whether or not
|
||||||
/// anything was recognized. With [keepAudio] the answer audio also becomes
|
/// anything was recognized. With [keepAudio] the answer audio also becomes
|
||||||
/// the playback [recordingPath].
|
/// the playback [recordingPath]; otherwise it is deleted once transcribed.
|
||||||
Future<void> finishVoiceInput({
|
Future<void> finishVoiceInput({
|
||||||
required void Function(String text) onTranscript,
|
required void Function(String text) onTranscript,
|
||||||
VoidCallback? afterTranscribe,
|
VoidCallback? afterTranscribe,
|
||||||
@@ -64,7 +64,10 @@ mixin VoiceAnswerMixin<T extends StatefulWidget> on State<T> {
|
|||||||
String noSpeech = noSpeechMessage,
|
String noSpeech = noSpeechMessage,
|
||||||
}) async {
|
}) async {
|
||||||
final path = await VoiceService.instance.stopRecording();
|
final path = await VoiceService.instance.stopRecording();
|
||||||
if (!mounted) return;
|
if (!mounted) {
|
||||||
|
if (!keepAudio) await VoiceService.instance.deleteRecording(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
aiVoiceRecording = false;
|
aiVoiceRecording = false;
|
||||||
listening = false;
|
listening = false;
|
||||||
@@ -79,6 +82,8 @@ mixin VoiceAnswerMixin<T extends StatefulWidget> on State<T> {
|
|||||||
endpoint: config.endpoint,
|
endpoint: config.endpoint,
|
||||||
model: config.model,
|
model: config.model,
|
||||||
);
|
);
|
||||||
|
// Without [keepAudio] the audio only served transcription.
|
||||||
|
if (!keepAudio) await VoiceService.instance.deleteRecording(path);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final text = transcribed?.trim() ?? '';
|
final text = transcribed?.trim() ?? '';
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:kouyu_english/core/ai_service.dart';
|
||||||
import 'package:kouyu_english/core/generated_content.dart';
|
import 'package:kouyu_english/core/generated_content.dart';
|
||||||
|
import 'package:kouyu_english/core/models.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
const valid = '''{
|
const valid = '''{
|
||||||
@@ -35,6 +41,54 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'review variant request asks for exactly the fields it accepts',
|
||||||
|
() async {
|
||||||
|
AiService.instance.setFallbackApiKey('test-key');
|
||||||
|
final prompts = <String>[];
|
||||||
|
final variant = await http.runWithClient(
|
||||||
|
() => AiService.instance.generateReviewVariant(
|
||||||
|
provider: AiProviderType.openAi,
|
||||||
|
endpoint: 'https://example.test/v1',
|
||||||
|
model: 'test-model',
|
||||||
|
targetItemId: 'A0-P12',
|
||||||
|
basePrompt: '请用英语说你来自哪里。',
|
||||||
|
),
|
||||||
|
() => MockClient((request) async {
|
||||||
|
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||||
|
final messages = body['messages'] as List<dynamic>;
|
||||||
|
prompts.add((messages.single as Map<String, dynamic>)['content']);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'message': {'content': valid},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(variant, isNotNull);
|
||||||
|
expect(prompts, hasLength(1));
|
||||||
|
for (final field in [
|
||||||
|
'schemaVersion',
|
||||||
|
'variantId',
|
||||||
|
'targetItemId',
|
||||||
|
'prompt',
|
||||||
|
'expectedAnswer',
|
||||||
|
]) {
|
||||||
|
expect(prompts.single, contains(field));
|
||||||
|
}
|
||||||
|
for (final field in ['stimulus', 'acceptedAnswers', 'forbiddenPhrases']) {
|
||||||
|
expect(prompts.single, isNot(contains(field)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const writing = '''{
|
const writing = '''{
|
||||||
"schemaVersion":"writing-feedback-1",
|
"schemaVersion":"writing-feedback-1",
|
||||||
"lessonId":"a0-06",
|
"lessonId":"a0-06",
|
||||||
|
|||||||
Reference in New Issue
Block a user