diff --git a/lib/screens/camera_analysis_mixin.dart b/lib/screens/camera_analysis_mixin.dart index 12ad421..d9c39fe 100644 --- a/lib/screens/camera_analysis_mixin.dart +++ b/lib/screens/camera_analysis_mixin.dart @@ -124,18 +124,27 @@ mixin CameraAnalysisMixin on ConsumerState // オンライン時: 通常の解析フロー if (!mounted) return; + + final stageNotifier = ValueNotifier(1); + var stageNotifierDisposed = false; + // ignore: use_build_context_synchronously // 直前の mounted チェックにより BuildContext の有効性は保証されている showDialog( context: context, barrierDismissible: false, - builder: (context) => const AnalyzingDialog(), + builder: (context) => AnalyzingDialog(stageNotifier: stageNotifier), ); try { - debugPrint('Starting Gemini Vision Direct Analysis for ${capturedImages.length} images'); + debugPrint('Starting Gemini 2-stage analysis for ${capturedImages.length} images'); final geminiService = ref.read(geminiServiceProvider); - final result = await geminiService.analyzeSakeLabel(capturedImages); + final result = await geminiService.analyzeSakeLabel( + capturedImages, + onStep1Complete: () { + if (!stageNotifierDisposed) stageNotifier.value = 2; + }, + ); // Create SakeItem (Schema v2.0) final sakeItem = SakeItem( @@ -380,6 +389,9 @@ mixin CameraAnalysisMixin on ConsumerState ), ); } + } finally { + stageNotifierDisposed = true; + stageNotifier.dispose(); } } diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart index dfc3eed..08b0273 100644 --- a/lib/services/gemini_service.dart +++ b/lib/services/gemini_service.dart @@ -12,17 +12,275 @@ import 'gemini_exceptions.dart'; class GeminiService { // AI Proxy Server Configuration static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl; - - // レート制限対策? Proxy側で管理されているが、念のためクライアント側でも連打防止 + static DateTime? _lastApiCallTime; - static const Duration _minApiInterval = Duration(seconds: 2); + static const Duration _minApiInterval = Duration(seconds: 2); GeminiService(); - /// 画像リストから日本酒ラベルを解析 - Future analyzeSakeLabel(List imagePaths, {bool forceRefresh = false}) async { - // クライアント側プロンプトでスキーマの一貫性を保証 - const prompt = ''' + // ============================================================ + // Public API + // ============================================================ + + /// 画像リストから日本酒ラベルを解析(2段階解析: OCR → フル解析) + /// + /// [onStep1Complete]: Stage 1 完了時に呼ばれるコールバック。 + /// UI 側でダイアログのメッセージをステージ2用に切り替えるために使う。 + /// 直接APIモード(consumer APK)のみ有効。プロキシモードは1段階のまま。 + Future analyzeSakeLabel( + List imagePaths, { + bool forceRefresh = false, + VoidCallback? onStep1Complete, + }) async { + if (Secrets.useProxy) { + return _callProxyApi( + imagePaths: imagePaths, + customPrompt: _mainAnalysisPrompt, + forceRefresh: forceRefresh, + ); + } + return _runTwoStageAnalysis( + imagePaths, + forceRefresh: forceRefresh, + onStep1Complete: onStep1Complete, + ); + } + + /// 再解析専用メソッド: 前回の結果を「疑わしい」として渡し、モデルに再考させる + Future reanalyzeSakeLabel( + List imagePaths, { + String? previousName, + String? previousBrand, + }) async { + final prevNameStr = previousName != null ? '「$previousName」' : '不明'; + final prevBrandStr = previousBrand != null ? '「$previousBrand」' : '不明'; + + final challengePrompt = ''' +【再解析モード — 前回の回答を検証してください】 + +前回の解析では以下の結果が返されました: +- name(銘柄名): $prevNameStr +- brand(蔵元名): $prevBrandStr + +この回答をユーザーが確認し、誤りの可能性があると指摘しました。 +添付画像を最初から丁寧に見直してください。 + +## 【必須確認ステップ】 +1. ラベル内の文字を1文字ずつ目で追ってください +2. 前回の name=$prevNameStr の各漢字がラベルに実際に存在するか確認してください +3. 存在しない文字が含まれていれば、ラベルに見えている文字のみに修正してください +4. 「ラベルに N 文字しか見えないなら N 文字のみ返す」を厳守してください + +## 【絶対ルール】name・brand・prefectureの読み取り(OCR厳守) +- ラベルに印刷されている文字だけを一字一句そのまま出力してください +- あなたが知っている「正式名称」への変換・補完は禁止 +- 【禁止例】「東魁」→「東魁盛」禁止 / 「男山」→「男山本醸造」禁止 +- ラベルに都道府県名がなければ prefecture は null(推測禁止) + +## その他のフィールド(推定可) +ラベル情報+日本酒の一般知識を使って推定してください。 + +## 出力形式 +以下のJSONのみ返す(説明文不要): +{ + "name": "ラベルに写っている銘柄名(補完禁止)", + "brand": "ラベルに写っている蔵元名(補完禁止)", + "prefecture": "ラベルに書かれた都道府県名(なければnull)", + "type": "特定名称(なければnull)", + "description": "説明文(100文字程度)", + "catchCopy": "20文字以内のキャッチコピー", + "confidenceScore": 80, + "flavorTags": ["フルーティー", "辛口"], + "tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3}, + "alcoholContent": 15.0, + "polishingRatio": 50, + "sakeMeterValue": 3.0, + "riceVariety": null, + "yeast": null, + "manufacturingYearMonth": null +} +'''; + + return _callDirectApi( + imagePaths, + challengePrompt, + forceRefresh: true, + temperature: 0.3, + ); + } + + // ============================================================ + // 2段階解析(直接APIモード専用) + // ============================================================ + + /// Stage1(OCR) → Stage2(フル解析) の2段階フロー + Future _runTwoStageAnalysis( + List imagePaths, { + bool forceRefresh = false, + VoidCallback? onStep1Complete, + }) async { + // Stage1実行前にキャッシュ確認(ヒットすれば API 呼び出しなし) + if (!forceRefresh && imagePaths.isNotEmpty) { + final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths); + final cached = await AnalysisCacheService.getCached(imageHash); + if (cached != null) { + debugPrint('2-stage: cache hit, skipping API calls'); + return cached.asCached(); + } + } + + final apiKey = Secrets.geminiApiKey; + if (apiKey.isEmpty) throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.'); + + // 画像を一度だけ読み込み、Stage1/2で共用(ファイルI/O節約) + final imageParts = []; + for (final path in imagePaths) { + final bytes = await File(path).readAsBytes(); + imageParts.add(DataPart('image/jpeg', bytes)); + debugPrint('Loaded image for 2-stage: ${(bytes.length / 1024).toStringAsFixed(1)}KB'); + } + + // --- Stage 1: OCR専念(30秒タイムアウト・失敗時は1段階フォールバック)--- + Map ocr; + try { + ocr = await _performOcrStep(apiKey, imageParts); + debugPrint('Stage1 OCR: name="${ocr['name']}" brand="${ocr['brand']}" pref="${ocr['prefecture']}"'); + } catch (e) { + debugPrint('Stage1 OCR failed ($e), falling back to single-stage'); + return _callDirectApi(imagePaths, null, forceRefresh: forceRefresh); + } + + // Stage 1 完了を UI に通知(AnalyzingDialog のメッセージを Stage2 用に切り替え) + onStep1Complete?.call(); + + // --- Stage 2: OCR結果を制約として渡し、残りフィールドを推定 --- + // _callDirectApi は内部でキャッシュ保存・リトライを行う(Stage2 も同じ堅牢性を持つ) + // forceRefresh=false で呼ぶと内部でキャッシュ再チェックが走るが、 + // 上の確認でミス済みのため実害なし(ハッシュ計算のみ) + final stage2Prompt = _buildStage2Prompt(ocr); + return _callDirectApi(imagePaths, stage2Prompt, forceRefresh: forceRefresh); + } + + /// Stage 1: ラベルのOCRのみ実行(name / brand / prefecture を確定させる) + /// + /// 軽量プロンプトで素早く文字起こし。補完・変換は完全禁止。 + /// 失敗時は呼び出し元がフォールバックを担当するため、ここでは rethrow。 + Future> _performOcrStep( + String apiKey, + List imageParts, + ) async { + const ocrPrompt = ''' +日本酒ラベルの画像から、銘柄名・蔵元名・都道府県名の3つだけをOCRしてください。 + +【絶対ルール】 +- ラベルに印刷された文字だけを一字一句そのまま出力する +- 補完・変換・拡張は厳禁(例: 「東魁」→「東魁盛」禁止) +- ラベルにN文字しかなければN文字のみ出力する + +以下のJSONのみ返す(説明文不要): +{"name": "銘柄名", "brand": "蔵元名", "prefecture": "都道府県名またはnull"} +'''; + + final model = GenerativeModel( + model: 'gemini-2.5-flash', + apiKey: apiKey, + systemInstruction: Content.system( + 'あなたはOCR専用システムです。ラベルの文字を一字一句正確に書き起こすだけです。' + '銘柄名の補完・変換・拡張は厳禁。見えている文字数と出力文字数を一致させること。', + ), + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + temperature: 0, + ), + ); + + final parts = [TextPart(ocrPrompt), ...imageParts]; + final response = await model + .generateContent([Content.multi(parts)]) + .timeout(const Duration(seconds: 30)); + + final jsonStr = response.text; + if (jsonStr == null || jsonStr.isEmpty) { + throw Exception('Stage1: empty response'); + } + + final map = jsonDecode(jsonStr) as Map; + return { + 'name': map['name'] as String?, + 'brand': map['brand'] as String?, + 'prefecture': map['prefecture'] as String?, + }; + } + + /// Stage 2 用プロンプト: Stage 1 の OCR 結果を「確定済み制約」として埋め込む + /// + /// Gemini は name/brand/prefecture をそのまま出力し、 + /// 残りフィールドのみ推定に集中することで hallucination を低減する。 + String _buildStage2Prompt(Map ocr) { + final name = ocr['name']; + final brand = ocr['brand']; + final prefecture = ocr['prefecture']; + + final nameConstraint = name != null ? '「$name」(確定済み — 変更禁止)' : 'null(確定済み)'; + final brandConstraint = brand != null ? '「$brand」(確定済み — 変更禁止)' : 'null(確定済み)'; + final prefConstraint = prefecture != null ? '「$prefecture」(確定済み — 変更禁止)' : 'null(確定済み)'; + + final nameJson = name != null ? '"$name"' : 'null'; + final brandJson = brand != null ? '"$brand"' : 'null'; + final prefJson = prefecture != null ? '"$prefecture"' : 'null'; + + return ''' +あなたは日本酒ラベル解析の専門家です。 + +【ステップ1のOCR結果 — 以下3フィールドは変更厳禁】 +別ステップで画像から厳密にOCRした確定結果です。あなたの知識で書き換えることは絶対に禁止です。 +- name: $nameConstraint +- brand: $brandConstraint +- prefecture: $prefConstraint + +上記3フィールドをそのままJSONに含め、残りのフィールドをラベル情報と日本酒知識から推定してください。 + +## 推定フィールド(ラベル+一般知識から推定可) +- type: ラベルに書かれた特定名称(純米大吟醸など)。なければ null +- description: ラベル情報と type から推定した味・特徴の説明(100文字程度) +- catchCopy: 20文字以内のキャッチコピー +- flavorTags: 味のタグ(フルーティー・辛口・華やか など) +- tasteStats: 1〜5の整数。不明なら 3 +- alcoholContent: ラベルに記載があれば読む。なければ type から推定 +- polishingRatio: ラベルに記載があれば読む。なければ type から推定 +- sakeMeterValue: ラベルに記載があれば読む。なければ推定 +- riceVariety: ラベルに記載があれば読む。なければ null +- yeast: ラベルに記載があれば読む。なければ null +- manufacturingYearMonth: ラベルに記載があれば読む。なければ null +- confidenceScore: 画像の鮮明度・情報量から 0〜100 で評価 + +## 出力形式 +以下のJSONのみ返す(説明文不要): +{ + "name": $nameJson, + "brand": $brandJson, + "prefecture": $prefJson, + "type": "特定名称(なければnull)", + "description": "説明文(100文字程度)", + "catchCopy": "20文字以内のキャッチコピー", + "confidenceScore": 80, + "flavorTags": ["フルーティー", "辛口"], + "tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3}, + "alcoholContent": 15.0, + "polishingRatio": 50, + "sakeMeterValue": 3.0, + "riceVariety": null, + "yeast": null, + "manufacturingYearMonth": null +} +'''; + } + + // ============================================================ + // 既存の1段階プロンプト(プロキシモード・フォールバック用) + // ============================================================ + + static const String _mainAnalysisPrompt = ''' あなたは日本酒ラベル解析の専門家です。 添付画像から情報を読み取り、以下のJSONを返してください。 @@ -86,93 +344,15 @@ name・brand を出力する直前に以下を確認してください: } '''; - return _callProxyApi( - imagePaths: imagePaths, - customPrompt: prompt, // Override server default - forceRefresh: forceRefresh, - ); - } + // ============================================================ + // プロキシ経由APIコール(iOSビルド用 / USE_PROXY=true 時) + // ============================================================ - /// 再解析専用メソッド: 前回の結果を「疑わしい」として渡し、モデルに再考させる - /// - /// 通常の analyzeSakeLabel と異なる点: - /// - 前回の name/brand を明示的に伝え「本当にこれが正しいか?」と問い直す - /// - temperature=0.3 で確定論的でなくする(同じ入力でも違う出力の余地を作る) - /// - これにより 東魁→東魁盛 のような hallucination が再解析でも繰り返されにくくなる - Future reanalyzeSakeLabel( - List imagePaths, { - String? previousName, - String? previousBrand, - }) async { - final prevNameStr = previousName != null ? '「$previousName」' : '不明'; - final prevBrandStr = previousBrand != null ? '「$previousBrand」' : '不明'; - - final challengePrompt = ''' -【再解析モード — 前回の回答を検証してください】 - -前回の解析では以下の結果が返されました: -- name(銘柄名): $prevNameStr -- brand(蔵元名): $prevBrandStr - -この回答をユーザーが確認し、誤りの可能性があると指摘しました。 -添付画像を最初から丁寧に見直してください。 - -## 【必須確認ステップ】 -1. ラベル内の文字を1文字ずつ目で追ってください -2. 前回の name=$prevNameStr の各漢字がラベルに実際に存在するか確認してください -3. 存在しない文字が含まれていれば、ラベルに見えている文字のみに修正してください -4. 「ラベルに N 文字しか見えないなら N 文字のみ返す」を厳守してください - -## 【絶対ルール】name・brand・prefectureの読み取り(OCR厳守) -- ラベルに印刷されている文字だけを一字一句そのまま出力してください -- あなたが知っている「正式名称」への変換・補完は禁止 -- 【禁止例】「東魁」→「東魁盛」禁止 / 「男山」→「男山本醸造」禁止 -- ラベルに都道府県名がなければ prefecture は null(推測禁止) - -## その他のフィールド(推定可) -ラベル情報+日本酒の一般知識を使って推定してください。 - -## 出力形式 -以下のJSONのみ返す(説明文不要): -{ - "name": "ラベルに写っている銘柄名(補完禁止)", - "brand": "ラベルに写っている蔵元名(補完禁止)", - "prefecture": "ラベルに書かれた都道府県名(なければnull)", - "type": "特定名称(なければnull)", - "description": "説明文(100文字程度)", - "catchCopy": "20文字以内のキャッチコピー", - "confidenceScore": 80, - "flavorTags": ["フルーティー", "辛口"], - "tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3}, - "alcoholContent": 15.0, - "polishingRatio": 50, - "sakeMeterValue": 3.0, - "riceVariety": null, - "yeast": null, - "manufacturingYearMonth": null -} -'''; - - return _callDirectApi( - imagePaths, - challengePrompt, - forceRefresh: true, - temperature: 0.3, - ); - } - - /// 共通実装: ProxyへのAPIコール Future _callProxyApi({ required List imagePaths, String? customPrompt, bool forceRefresh = false, }) async { - // Check Mode: Direct vs Proxy - if (!Secrets.useProxy) { - debugPrint('Direct Cloud Mode: Connecting to Gemini API directly...'); - return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh); - } - // 1. キャッシュチェック(forceRefresh=false のときのみ) if (!forceRefresh && imagePaths.isNotEmpty) { final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths); @@ -193,21 +373,20 @@ name・brand を出力する直前に以下を確認してください: } _lastApiCallTime = DateTime.now(); - // 2. 画像をBase64変換(撮影時に圧縮済み) + // 3. 画像をBase64変換(撮影時に圧縮済み) List base64Images = []; for (final path in imagePaths) { - // Read already-compressed images directly (compressed at capture time) final bytes = await File(path).readAsBytes(); final base64String = base64Encode(bytes); base64Images.add(base64String); debugPrint('Encoded processed image: ${(bytes.length / 1024).toStringAsFixed(1)}KB'); } - // 3. デバイスID取得 + // 4. デバイスID取得 final deviceId = await DeviceService.getDeviceId(); if (kDebugMode) debugPrint('Device ID: $deviceId'); - // 4. リクエスト作成 + // 5. リクエスト作成 final requestBody = jsonEncode({ "device_id": deviceId, "images": base64Images, @@ -216,7 +395,7 @@ name・brand を出力する直前に以下を確認してください: debugPrint('Calling Proxy: $_proxyUrl'); - // 5. 送信(Bearer Token認証付き) + // 6. 送信(Bearer Token認証付き) final headers = { "Content-Type": "application/json", if (Secrets.proxyAuthToken.isNotEmpty) @@ -226,18 +405,16 @@ name・brand を出力する直前に以下を確認してください: Uri.parse(_proxyUrl), headers: headers, body: requestBody, - ).timeout(const Duration(seconds: 60)); // 拡張: 60秒 (画像サイズ最適化済み) + ).timeout(const Duration(seconds: 60)); - // 6. レスポンス処理 + // 7. レスポンス処理 if (response.statusCode == 200) { - // 成功時のレスポンス形式: { "success": true, "data": {...}, "usage": {...} } final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes)); - + if (jsonResponse['success'] == true) { final data = jsonResponse['data']; if (data == null) throw Exception("サーバーからのデータが空です"); - - // 使用状況ログ + if (jsonResponse['usage'] != null) { final usage = jsonResponse['usage']; debugPrint('API Usage: ${usage['today']}/${usage['limit']}'); @@ -245,9 +422,6 @@ name・brand を出力する直前に以下を確認してください: final result = SakeAnalysisResult.fromJson(data); - // tasteStats の補完・バリデーションは SakeAnalysisResult.fromJson 内で実施済み - - // キャッシュに保存(次回同一画像はAPI不使用) if (imagePaths.isNotEmpty) { final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths); await AnalysisCacheService.saveCache(imageHash, result); @@ -260,11 +434,9 @@ name・brand を出力する直前に以下を確認してください: return result; } else { - // Proxy側での論理エラー (レート制限超過など) throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました'); } } else { - // HTTPエラー if (kDebugMode) { debugPrint('Proxy Error: ${response.statusCode} ${response.body}'); } @@ -273,7 +445,6 @@ name・brand を出力する直前に以下を確認してください: } catch (e) { debugPrint('Proxy Call Failed: $e'); - // エラーメッセージを整形 final errorMsg = e.toString().toLowerCase(); if (errorMsg.contains('limit') || errorMsg.contains('上限')) { throw Exception('本日のAI解析リクエスト上限に達しました。\n明日またお試しください。'); @@ -282,10 +453,16 @@ name・brand を出力する直前に以下を確認してください: } } - /// Direct Cloud API Implementation (No Proxy) - Future _callDirectApi(List imagePaths, String? customPrompt, {bool forceRefresh = false, double temperature = 0}) async { - // 1. キャッシュチェック(同じ画像なら即座に返す) - // forceRefresh=trueの場合はキャッシュをスキップ + // ============================================================ + // 直接APIコール(consumer APK 用 / USE_PROXY=false 時) + // ============================================================ + + Future _callDirectApi( + List imagePaths, + String? customPrompt, { + bool forceRefresh = false, + double temperature = 0, + }) async { if (!forceRefresh && imagePaths.isNotEmpty) { final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths); final cached = await AnalysisCacheService.getCached(imageHash); @@ -295,65 +472,22 @@ name・brand を出力する直前に以下を確認してください: } } - // 2. API Key確認 final apiKey = Secrets.geminiApiKey; if (apiKey.isEmpty) { throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.'); } - // モデル候補: 503/UNAVAILABLE 時にフォールバック - // NOTE: Google は予告なしでモデルを廃止することがある。定期的に動作確認を行うこと。 - // Phase 2(プロキシ移行)後はサーバー側から設定を取得する設計に変更する予定。 const primaryModel = 'gemini-2.5-flash'; const fallbackModel = 'gemini-2.0-flash'; - // customPrompt は analyzeSakeLabel から常に渡される。null になるケースは通常存在しない。 - final promptText = customPrompt ?? ''' -あなたは日本酒ラベル解析の専門家です。 -添付画像から情報を読み取り、以下のJSONを返してください。 + final promptText = customPrompt ?? _mainAnalysisPrompt; -## 【絶対ルール】name・brand・prefectureの読み取り(OCR厳守) -これら3フィールドは、ラベルに印刷されている文字だけを一字一句そのまま出力してください。 -あなたが知っている銘柄知識でラベルの文字を補完・変換・拡張することは厳禁です。 - -【禁止例】「東魁」→「東魁盛」禁止 / 「男山」→「男山本醸造」禁止 / 「白鹿」→「白鹿本醸造」禁止 -【一般原則】ラベルに N 文字しか見えない場合は N 文字のみ出力する(文字数を増やすことは禁止) -- prefecture: ラベルに都道府県名が書かれていればそのまま出力、なければ null(推測禁止) - -## その他のフィールド(推定可) -ラベル情報+日本酒の一般知識を使って推定してください。 -- tasteStats: 1〜5の整数。不明なら 3 -- alcoholContent・polishingRatio: ラベルに記載があれば読む。なければ type から推定 - -## 出力形式 -以下のJSONのみ返す(説明文不要): -{ - "name": "ラベルに写っている銘柄名の文字(一字一句そのまま・補完禁止)", - "brand": "ラベルに写っている蔵元名の文字(一字一句そのまま・補完禁止)", - "prefecture": "ラベルに書かれた都道府県名(なければnull・推測禁止)", - "type": "特定名称(ラベルから読む。なければnull)", - "description": "ラベル情報とtypeから推定した説明文(100文字程度)", - "catchCopy": "20文字以内のキャッチコピー", - "confidenceScore": 80, - "flavorTags": ["フルーティー", "辛口"], - "tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3}, - "alcoholContent": 15.0, - "polishingRatio": 50, - "sakeMeterValue": 3.0, - "riceVariety": "山田錦", - "yeast": "きょうかい9号", - "manufacturingYearMonth": "2023.10" -} -'''; - - // Prepare Content parts (画像バイト読み込みは一度だけ) final contentParts = [TextPart(promptText)]; for (var path in imagePaths) { final bytes = await File(path).readAsBytes(); contentParts.add(DataPart('image/jpeg', bytes)); } - // 503 時: リトライ(指数バックオフ)→ フォールバックモデル const maxRetries = 3; final modelsToTry = [primaryModel, primaryModel, primaryModel, fallbackModel]; @@ -398,11 +532,9 @@ name・brand を出力する直前に以下を確認してください: final jsonMap = jsonDecode(jsonString); final result = SakeAnalysisResult.fromJson(jsonMap); - // 3. キャッシュに保存(次回は即座に返せる) if (imagePaths.isNotEmpty) { final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths); await AnalysisCacheService.saveCache(imageHash, result); - // 4. 銘柄名インデックスに登録(forceRefresh 時は誤認識結果を上書き) await AnalysisCacheService.registerBrandIndex( result.name, imageHash, @@ -419,22 +551,20 @@ name・brand を出力する直前に以下を確認してください: debugPrint('Direct API Error (attempt $attempt, model: $modelName): $e'); if (isLastAttempt || !is503) { - // 最終試行 or 503以外のエラーはそのまま投げる - if (is503) { - throw const GeminiCongestionException(); - } + if (is503) throw const GeminiCongestionException(); throw Exception('AI解析エラー(Direct): $e'); } - // 503 → 次のリトライへ } } - // ここには到達しない throw Exception('AI解析に失敗しました。再試行してください。'); } } -// Analysis Result Model +// ============================================================ +// Data Models +// ============================================================ + class SakeAnalysisResult { final String? name; final String? brand; @@ -446,7 +576,6 @@ class SakeAnalysisResult { final List flavorTags; final Map tasteStats; - // New Fields final double? alcoholContent; final int? polishingRatio; final double? sakeMeterValue; @@ -455,7 +584,6 @@ class SakeAnalysisResult { final String? manufacturingYearMonth; /// キャッシュから返された結果かどうか(EXP付与・新規登録の判定に使用) - /// JSON には含まない(キャッシュ保存・復元時は常に false) final bool isFromCache; SakeAnalysisResult({ @@ -477,7 +605,6 @@ class SakeAnalysisResult { this.isFromCache = false, }); - /// キャッシュヒット用コピー SakeAnalysisResult asCached() => SakeAnalysisResult( name: name, brand: brand, prefecture: prefecture, type: type, description: description, catchCopy: catchCopy, confidenceScore: confidenceScore, @@ -489,7 +616,6 @@ class SakeAnalysisResult { ); factory SakeAnalysisResult.fromJson(Map json) { - // tasteStats: 欠損キーを 3 で補完、範囲外(1〜5)をクランプ const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']; Map stats = {}; if (json['tasteStats'] is Map) { @@ -522,7 +648,6 @@ class SakeAnalysisResult { ); } - /// JSON形式に変換(キャッシュ保存用) Map toJson() { return { 'name': name, diff --git a/lib/widgets/analyzing_dialog.dart b/lib/widgets/analyzing_dialog.dart index 18686bc..1fce392 100644 --- a/lib/widgets/analyzing_dialog.dart +++ b/lib/widgets/analyzing_dialog.dart @@ -2,7 +2,12 @@ import 'package:flutter/material.dart'; import 'dart:async'; class AnalyzingDialog extends StatefulWidget { - const AnalyzingDialog({super.key}); + /// Stage 通知用 ValueNotifier。 + /// null のとき(プロキシモード・キャッシュヒット後など)は Stage1 メッセージのみを表示する。 + /// value が 2 に変わると Stage2 メッセージセットに切り替わる。 + final ValueNotifier? stageNotifier; + + const AnalyzingDialog({super.key, this.stageNotifier}); @override State createState() => _AnalyzingDialogState(); @@ -10,32 +15,70 @@ class AnalyzingDialog extends StatefulWidget { class _AnalyzingDialogState extends State { int _messageIndex = 0; - - final List _messages = [ - 'ラベルを読んでいます...', - '銘柄を確認しています...', + Timer? _timer; + + static const _stage1Messages = [ + 'ラベルを読み取っています...', + '文字を一字一句確認中...', + ]; + + static const _stage2Messages = [ 'この日本酒の個性を分析中...', 'フレーバーチャートを描画しています...', '素敵なキャッチコピーを考えています...', ]; + List get _currentMessages => + (_stage == 2) ? _stage2Messages : _stage1Messages; + + int _stage = 1; + @override void initState() { super.initState(); + widget.stageNotifier?.addListener(_onStageChanged); _startMessageRotation(); } + void _onStageChanged() { + final newStage = widget.stageNotifier?.value ?? 1; + if (newStage != _stage) { + _timer?.cancel(); + setState(() { + _stage = newStage; + _messageIndex = 0; + }); + _startMessageRotation(); + } + } + void _startMessageRotation() { - Future.delayed(const Duration(milliseconds: 1500), () { - if (mounted && _messageIndex < _messages.length - 1) { + _timer = Timer.periodic(const Duration(milliseconds: 1800), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + final messages = _currentMessages; + if (_messageIndex < messages.length - 1) { setState(() => _messageIndex++); - _startMessageRotation(); + } else { + timer.cancel(); } }); } + @override + void dispose() { + _timer?.cancel(); + widget.stageNotifier?.removeListener(_onStageChanged); + super.dispose(); + } + @override Widget build(BuildContext context) { + final messages = _currentMessages; + final safeIndex = _messageIndex.clamp(0, messages.length - 1); + return Dialog( child: Padding( padding: const EdgeInsets.all(24.0), @@ -45,10 +88,19 @@ class _AnalyzingDialogState extends State { const CircularProgressIndicator(), const SizedBox(height: 24), Text( - _messages[_messageIndex], + messages[safeIndex], style: Theme.of(context).textTheme.titleMedium, textAlign: TextAlign.center, ), + if (widget.stageNotifier != null) ...[ + const SizedBox(height: 12), + Text( + 'ステップ $_stage / 2', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), + ), + ), + ], ], ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 38b79b3..9513065 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.47+54 +version: 1.0.48+55 environment: sdk: ^3.10.1