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'; 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 { // Force use of client-side prompt to ensure Schema consistency (Phase 1 Fix) const prompt = ''' あなたは日本酒の専門家(ソムリエ)です。 添付の画像(日本酒のラベル)を分析し、以下のJSON形式で情報を抽出してください。 { "name": "銘柄名", "brand": "蔵元名", "prefecture": "都道府県名", "type": "特定名称(純米大吟醸など)", "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": "山田錦", "yeast": "きょうかい9号", "manufacturingYearMonth": "2023.10" } 値が不明な場合は null または 適切な推測値を入れてください。特にtasteStatsは必ず1-5の数値で埋めてください。 '''; return _callProxyApi( imagePaths: imagePaths, customPrompt: prompt, // Override server default forceRefresh: forceRefresh, ); } /// OCRテキストと画像のハイブリッド解析 Future analyzeSakeHybrid(String extractedText, List imagePaths) async { final prompt = ''' あなたは日本酒の専門家(ソムリエ)です。 以下のOCR抽出テキストは参考情報です(誤字・脱落あり)。 OCRテキストはあくまで補助的なヒントとして扱い、添付の画像を優先して全項目を必ず埋めてください。 OCRテキスト(参考のみ): """ $extractedText """ 添付の日本酒ラベル画像を分析し、以下のJSON形式で情報を抽出してください。 { "name": "銘柄名", "brand": "蔵元名", "prefecture": "都道府県名", "type": "特定名称(純米大吟醸など)", "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": "山田錦", "yeast": "きょうかい9号", "manufacturingYearMonth": "2023.10" } ★重要な指示: - tasteStats(香り、甘味、酸味、苦味、ボディ)は必ず1-5の整数で埋めてください。不明な場合は3を設定してください。 - alcoholContent, polishingRatio, sakeMeterValue などの詳細項目も、画像から読み取れる場合は必ず設定してください。 - 値が不明な場合は null または 適切な推測値を入れてください。 '''; return _callProxyApi(imagePaths: imagePaths, customPrompt: prompt); } /// テキストのみの解析 (画像なし) Future analyzeSakeText(String extractedText) async { final prompt = ''' 以下のOCRで抽出された日本酒ラベルのテキスト情報を分析してください。 誤字やノイズが含まれることが多いですが、文脈から積極的に正しい情報を推測・補完してください。 抽出テキスト: """ $extractedText """ 以下の情報をJSON形式で返してください: { "name": "銘柄名", "brand": "蔵元名", "prefecture": "都道府県名", "type": "特定名称", "description": "特徴(100文字)", "catchCopy": "キャッチコピー(20文字)", "confidenceScore": 0-100, "flavorTags": ["タグ"], "tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3}, "alcoholContent": 15.5, "polishingRatio": 50, "sakeMeterValue": 3.0, "riceVariety": "山田錦", "yeast": "きょうかい9号", "manufacturingYearMonth": "2023.10" } '''; return _callProxyApi(imagePaths: [], customPrompt: prompt); } /// 共通実装: 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); } try { // 1. レート制限 (クライアント側連打防止) if (_lastApiCallTime != null) { final elapsed = DateTime.now().difference(_lastApiCallTime!); if (elapsed < _minApiInterval) { await Future.delayed(_minApiInterval - elapsed); } } _lastApiCallTime = DateTime.now(); // 2. 画像をBase64変換 (Phase 4: Images already compressed at capture) 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); // Phase 3 Validation: Check for Schema Compliance if (result.tasteStats.isEmpty || result.tasteStats.values.every((v) => v == 0)) { debugPrint('⚠️ WARNING: AI returned empty or zero taste stats. This item will not form a valid chart.'); } else { // Simple check final requiredKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']; final actualKeys = result.tasteStats.keys.toList(); final missing = requiredKeys.where((k) => !actualKeys.contains(k)).toList(); if (missing.isNotEmpty) { debugPrint('⚠️ WARNING: AI response missing keys: $missing. Old schema?'); // We could throw here, but for now let's just log. // In strict mode, we might want to fail the analysis to force retry. } } return result; } else { // Proxy側での論理エラー (レート制限超過など) throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました'); } } else { // HTTPエラー 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('💰 API呼び出しをスキップ(キャッシュヒット)'); return cached; } } // 2. API Key確認 final apiKey = Secrets.geminiApiKey; if (apiKey.isEmpty) { throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.'); } final model = GenerativeModel( model: 'gemini-2.5-flash', // ⚠️ FIXED MODEL NAME - DO NOT CHANGE without explicit user approval (confirmed working on 2026-01-17) apiKey: apiKey, generationConfig: GenerationConfig( responseMimeType: 'application/json', temperature: 0.2, // チャート一貫性向上のため 0.4→0.2 に変更 (2026-02-09) ), ); // Prepare Prompt final promptText = customPrompt ?? ''' あなたは日本酒の専門家(ソムリエ)です。 添付の画像(日本酒のラベル)を分析し、以下のJSON形式で情報を抽出してください。 { "name": "銘柄名", "brand": "蔵元名", "prefecture": "都道府県名", "type": "特定名称(純米大吟醸など)", "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": "山田錦", "yeast": "きょうかい9号", "manufacturingYearMonth": "2023.10" } 値が不明な場合は null または 適切な推測値を入れてください。 '''; // Prepare Content final contentParts = [TextPart(promptText)]; for (var path in imagePaths) { // Phase 4: Images already compressed at capture time final bytes = await File(path).readAsBytes(); contentParts.add(DataPart('image/jpeg', bytes)); } try { final response = await model.generateContent([Content.multi(contentParts)]); 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. 銘柄名インデックスに登録(v1.0.15: チャート一貫性向上) await AnalysisCacheService.registerBrandIndex(result.name, imageHash); } return result; } catch (e) { debugPrint('Direct API Error: $e'); throw Exception('AI解析エラー(Direct): $e'); } } } // 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; 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, }); factory SakeAnalysisResult.fromJson(Map json) { // Helper to extract int from map safely 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)); } 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, }; } }