From 4d324903b456f1dcff5316c14a25dfd88323c71e Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 16 Sep 2026 17:29:56 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20AI=20=E5=A4=8D=E4=B9=A0=E5=8F=98?= =?UTF-8?q?=E5=BC=8F=E8=AF=B7=E6=B1=82=E5=AD=97=E6=AE=B5=E4=B8=8E=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=80=E8=87=B4=EF=BC=9B=E8=BD=AC=E5=86=99=E5=90=8E?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E4=B8=8D=E4=BF=9D=E7=95=99=E7=9A=84=E5=BD=95?= =?UTF-8?q?=E9=9F=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - generateReviewVariant 的指令要求 stimulus/answer/acceptedAnswers 等字段, 而 decodeGeneratedReviewVariant 只接受 schemaVersion、variantId、 targetItemId、prompt、expectedAnswer 五个字段,导致 AI 变式始终校验失败。 现在指令只要求这五个字段,并补充了请求与解码一致性的测试。 - finishVoiceInput(keepAudio: false) 转写完成(或页面已关闭)后删除音频, 补练页和测评页的语音输入不再在本机残留录音文件。 Co-Authored-By: Claude Opus 5 --- kouyu_english/lib/core/ai_service.dart | 10 +++- kouyu_english/lib/widgets/voice_answer.dart | 9 +++- .../test/generated_content_test.dart | 54 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/kouyu_english/lib/core/ai_service.dart b/kouyu_english/lib/core/ai_service.dart index d67a896..5a78344 100644 --- a/kouyu_english/lib/core/ai_service.dart +++ b/kouyu_english/lib/core/ai_service.dart @@ -780,7 +780,15 @@ class AiService { }) 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: 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( provider: provider, endpoint: endpoint, diff --git a/kouyu_english/lib/widgets/voice_answer.dart b/kouyu_english/lib/widgets/voice_answer.dart index 4f7fe6f..c32da6c 100644 --- a/kouyu_english/lib/widgets/voice_answer.dart +++ b/kouyu_english/lib/widgets/voice_answer.dart @@ -56,7 +56,7 @@ mixin VoiceAnswerMixin on State { /// [onTranscript] runs inside `setState` with the trimmed, non-empty text. /// [afterTranscribe] runs once transcription has finished, whether or not /// anything was recognized. With [keepAudio] the answer audio also becomes - /// the playback [recordingPath]. + /// the playback [recordingPath]; otherwise it is deleted once transcribed. Future finishVoiceInput({ required void Function(String text) onTranscript, VoidCallback? afterTranscribe, @@ -64,7 +64,10 @@ mixin VoiceAnswerMixin on State { String noSpeech = noSpeechMessage, }) async { final path = await VoiceService.instance.stopRecording(); - if (!mounted) return; + if (!mounted) { + if (!keepAudio) await VoiceService.instance.deleteRecording(path); + return; + } setState(() { aiVoiceRecording = false; listening = false; @@ -79,6 +82,8 @@ mixin VoiceAnswerMixin on State { endpoint: config.endpoint, model: config.model, ); + // Without [keepAudio] the audio only served transcription. + if (!keepAudio) await VoiceService.instance.deleteRecording(path); if (!mounted) return; final text = transcribed?.trim() ?? ''; setState(() { diff --git a/kouyu_english/test/generated_content_test.dart b/kouyu_english/test/generated_content_test.dart index ba1c117..5e52d25 100644 --- a/kouyu_english/test/generated_content_test.dart +++ b/kouyu_english/test/generated_content_test.dart @@ -1,5 +1,11 @@ +import 'dart:convert'; + 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/models.dart'; void main() { 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 = []; + 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; + final messages = body['messages'] as List; + prompts.add((messages.single as Map)['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 = '''{ "schemaVersion":"writing-feedback-1", "lessonId":"a0-06",