import 'dart:io'; import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter/foundation.dart'; import 'device_service.dart'; // import '../secrets.dart'; // No longer needed class GeminiService { // AI Proxy Server Configuration static const String _proxyUrl = 'http://192.168.31.89:8080/analyze'; // レート制限対策? Proxy側で管理されているが、念のためクライアント側でも連打防止 static DateTime? _lastApiCallTime; static const Duration _minApiInterval = Duration(seconds: 2); GeminiService(); /// 画像リストから日本酒ラベルを解析 Future analyzeSakeLabel(List imagePaths) async { // サーバー側のデフォルトプロンプトを使用するため、customPromptはnullを送信 return _callProxyApi( imagePaths: imagePaths, customPrompt: null, ); } /// OCRテキストと画像のハイブリッド解析 Future analyzeSakeHybrid(String extractedText, List imagePaths) async { final prompt = ''' 以下のOCR抽出テキストと添付の日本酒ラベル画像を組み合わせて分析してください。 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: 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, }) async { try { // 1. レート制限 (クライアント側連打防止) 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) { final bytes = await File(path).readAsBytes(); final base64String = base64Encode(bytes); base64Images.add(base64String); debugPrint('Encoded 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. 送信 final response = await http.post( Uri.parse(_proxyUrl), headers: {"Content-Type": "application/json"}, body: requestBody, ).timeout(const Duration(seconds: 45)); // 画像アップロード含むため長めに // 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']}'); } return SakeAnalysisResult.fromJson(data); } 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; } } } // 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?, ); } }