import 'dart:io'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; import 'device_service.dart'; import 'package:google_generative_ai/google_generative_ai.dart'; import '../secrets.dart'; import 'analysis_cache_service.dart'; 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); GeminiService(); /// 画像リストから日本酒ラベルを解析 Future analyzeSakeLabel(List imagePaths, {bool forceRefresh = false}) async { // クライアント側プロンプトでスキーマの一貫性を保証 const prompt = ''' あなたは日本酒ラベル解析の専門家です。 添付画像から情報を読み取り、以下のJSONを返してください。 ## 【絶対ルール】name・brand・prefectureの読み取り これら3フィールドのみ、ラベルに印刷された文字を一字一句そのまま出力してください。 - あなたの知識でラベルの文字を補完・訂正・変更することは禁止 - ラベルに「東魁」→ "name": "東魁"(「東魁盛」に変えない) - ラベルに「白鹿」→ "name": "白鹿"(「白鹿本醸造」に変えない) - ラベルに「久保田」→ "name": "久保田"(「久保田 千寿」に変えない) - prefecture: ラベルに都道府県名が書かれていればそのまま出力、書かれていなければ null (銘柄名から産地を推測して埋めることは禁止) ## その他のフィールド(推定・推定可) 以下はラベルから読み取れる情報+日本酒の一般知識を使って推定してください。 - type: ラベルに書かれた特定名称(純米大吟醸など)。なければ null - description: ラベル情報と type から推定した味・特徴の説明(100文字程度) - catchCopy: 20文字以内のキャッチコピー - flavorTags: 味のタグ(フルーティー・辛口・華やか など) - tasteStats: 1〜5の整数。ラベルや type から推定。不明なら 3 - alcoholContent: ラベルに記載があれば読む。なければ type から一般的な値を推定(例: 純米大吟醸→15.0) - polishingRatio: ラベルに記載があれば読む。なければ type から推定(例: 大吟醸→50) - sakeMeterValue: ラベルに記載があれば読む。なければ推定 - riceVariety: ラベルに記載があれば読む。なければ null - yeast: ラベルに記載があれば読む。なければ null - manufacturingYearMonth: ラベルに記載があれば読む。なければ null - confidenceScore: 画像の鮮明度・情報量から 0〜100 で評価 ## 出力形式 以下の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" } '''; return _callProxyApi( imagePaths: imagePaths, customPrompt: prompt, // Override server default forceRefresh: forceRefresh, ); } /// 共通実装: 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); final cached = await AnalysisCacheService.getCached(imageHash); if (cached != null) { debugPrint('Proxy cache hit: skipping API call'); return cached.asCached(); } } try { // 2. レート制限 (クライアント側連打防止) if (_lastApiCallTime != null) { final elapsed = DateTime.now().difference(_lastApiCallTime!); if (elapsed < _minApiInterval) { await Future.delayed(_minApiInterval - elapsed); } } _lastApiCallTime = DateTime.now(); // 2. 画像を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取得 final deviceId = await DeviceService.getDeviceId(); debugPrint('Device ID: $deviceId'); // 4. リクエスト作成 final requestBody = jsonEncode({ "device_id": deviceId, "images": base64Images, "prompt": customPrompt, }); debugPrint('Calling Proxy: $_proxyUrl'); // 5. 送信(Bearer Token認証付き) final headers = { "Content-Type": "application/json", if (Secrets.proxyAuthToken.isNotEmpty) "Authorization": "Bearer ${Secrets.proxyAuthToken}", }; final response = await http.post( Uri.parse(_proxyUrl), headers: headers, body: requestBody, ).timeout(const Duration(seconds: 60)); // 拡張: 60秒 (画像サイズ最適化済み) // 6. レスポンス処理 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']}'); } final result = SakeAnalysisResult.fromJson(data); // tasteStats の補完・バリデーションは SakeAnalysisResult.fromJson 内で実施済み // キャッシュに保存(次回同一画像はAPI不使用) if (imagePaths.isNotEmpty) { final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths); await AnalysisCacheService.saveCache(imageHash, result); await AnalysisCacheService.registerBrandIndex( result.name, imageHash, forceUpdate: forceRefresh, ); } return result; } else { // Proxy側での論理エラー (レート制限超過など) throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました'); } } else { // HTTPエラー if (kDebugMode) { debugPrint('Proxy Error: ${response.statusCode} ${response.body}'); } throw Exception('サーバーエラー (${response.statusCode}): ${response.body}'); } } catch (e) { debugPrint('Proxy Call Failed: $e'); // エラーメッセージを整形 final errorMsg = e.toString().toLowerCase(); if (errorMsg.contains('limit') || errorMsg.contains('上限')) { throw Exception('本日のAI解析リクエスト上限に達しました。\n明日またお試しください。'); } rethrow; } } /// Direct Cloud API Implementation (No Proxy) Future _callDirectApi(List imagePaths, String? customPrompt, {bool forceRefresh = false}) async { // 1. キャッシュチェック(同じ画像なら即座に返す) // forceRefresh=trueの場合はキャッシュをスキップ if (!forceRefresh && imagePaths.isNotEmpty) { final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths); final cached = await AnalysisCacheService.getCached(imageHash); if (cached != null) { debugPrint('Cache hit: skipping API call'); return cached.asCached(); } } // 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を返してください。 ## 【絶対ルール】name・brand・prefectureの読み取り これら3フィールドのみ、ラベルに印刷された文字を一字一句そのまま出力してください。 - あなたの知識でラベルの文字を補完・訂正・変更することは禁止 - ラベルに「東魁」→ "name": "東魁"(「東魁盛」に変えない) - 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]; for (int attempt = 0; attempt <= maxRetries; attempt++) { final modelName = modelsToTry[attempt]; final isLastAttempt = attempt == maxRetries; try { if (attempt > 0) { final waitSec = attempt == maxRetries ? 2 : (attempt * 3); debugPrint('Retry $attempt/$maxRetries (model: $modelName, wait: ${waitSec}s)...'); await Future.delayed(Duration(seconds: waitSec)); } final model = GenerativeModel( model: modelName, apiKey: apiKey, systemInstruction: Content.system( 'あなたは画像内のテキストを一字一句正確に読み取る専門家です。' 'ラベルに記載された銘柄名・蔵元名は絶対に変更・補完しないでください。' 'あなたの知識でラベルの文字を上書きすることは厳禁です。' 'ラベルに「東魁」とあれば、「東魁盛」を知っていても必ず「東魁」と出力してください。', ), generationConfig: GenerationConfig( responseMimeType: 'application/json', temperature: 0, ), ); final response = await model .generateContent([Content.multi(contentParts)]) .timeout(const Duration(seconds: 60)); final jsonString = response.text; if (jsonString == null) throw Exception('Empty response from Gemini'); 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, forceUpdate: forceRefresh, ); } if (attempt > 0) debugPrint('Succeeded on attempt $attempt (model: $modelName)'); return result; } catch (e) { final errStr = e.toString(); final is503 = errStr.contains('503') || errStr.contains('UNAVAILABLE') || errStr.contains('high demand'); debugPrint('Direct API Error (attempt $attempt, model: $modelName): $e'); if (isLastAttempt || !is503) { // 最終試行 or 503以外のエラーはそのまま投げる if (is503) { throw const GeminiCongestionException(); } throw Exception('AI解析エラー(Direct): $e'); } // 503 → 次のリトライへ } } // ここには到達しない throw Exception('AI解析に失敗しました。再試行してください。'); } } // Analysis Result Model class SakeAnalysisResult { final String? name; final String? brand; final String? prefecture; final String? type; final String? description; final String? catchCopy; final int? confidenceScore; final List flavorTags; final Map tasteStats; // New Fields final double? alcoholContent; final int? polishingRatio; final double? sakeMeterValue; final String? riceVariety; final String? yeast; final String? manufacturingYearMonth; /// キャッシュから返された結果かどうか(EXP付与・新規登録の判定に使用) /// JSON には含まない(キャッシュ保存・復元時は常に false) final bool isFromCache; SakeAnalysisResult({ this.name, this.brand, this.prefecture, this.type, this.description, this.catchCopy, this.confidenceScore, this.flavorTags = const [], this.tasteStats = const {}, this.alcoholContent, this.polishingRatio, this.sakeMeterValue, this.riceVariety, this.yeast, this.manufacturingYearMonth, this.isFromCache = false, }); /// キャッシュヒット用コピー SakeAnalysisResult asCached() => SakeAnalysisResult( name: name, brand: brand, prefecture: prefecture, type: type, description: description, catchCopy: catchCopy, confidenceScore: confidenceScore, flavorTags: flavorTags, tasteStats: tasteStats, alcoholContent: alcoholContent, polishingRatio: polishingRatio, sakeMeterValue: sakeMeterValue, riceVariety: riceVariety, yeast: yeast, manufacturingYearMonth: manufacturingYearMonth, isFromCache: true, ); factory SakeAnalysisResult.fromJson(Map json) { // tasteStats: 欠損キーを 3 で補完、範囲外(1〜5)をクランプ const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']; Map stats = {}; if (json['tasteStats'] is Map) { final map = json['tasteStats'] as Map; stats = map.map((key, value) => MapEntry( key.toString(), ((value as num?)?.toInt() ?? 3).clamp(1, 5), )); } for (final key in requiredStatKeys) { stats.putIfAbsent(key, () => 3); } return SakeAnalysisResult( name: json['name'] as String?, brand: json['brand'] as String?, prefecture: json['prefecture'] as String?, type: json['type'] as String?, description: json['description'] as String?, catchCopy: json['catchCopy'] as String?, confidenceScore: json['confidenceScore'] as int?, flavorTags: (json['flavorTags'] as List?)?.map((e) => e.toString()).toList() ?? [], tasteStats: stats, alcoholContent: (json['alcoholContent'] as num?)?.toDouble(), polishingRatio: (json['polishingRatio'] as num?)?.toInt(), sakeMeterValue: (json['sakeMeterValue'] as num?)?.toDouble(), riceVariety: json['riceVariety'] as String?, yeast: json['yeast'] as String?, manufacturingYearMonth: json['manufacturingYearMonth'] as String?, ); } /// JSON形式に変換(キャッシュ保存用) Map toJson() { return { 'name': name, 'brand': brand, 'prefecture': prefecture, 'type': type, 'description': description, 'catchCopy': catchCopy, 'confidenceScore': confidenceScore, 'flavorTags': flavorTags, 'tasteStats': tasteStats, 'alcoholContent': alcoholContent, 'polishingRatio': polishingRatio, 'sakeMeterValue': sakeMeterValue, 'riceVariety': riceVariety, 'yeast': yeast, 'manufacturingYearMonth': manufacturingYearMonth, }; } }