ponshu-room-lite/lib/services/gemini_service.dart

379 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths, {bool forceRefresh = false}) async {
// クライアント側プロンプトでスキーマの一貫性を保証
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"
}
★重要な指示:
- "name"(銘柄名)と "brand"(蔵元名)は、ラベルに明記されている文字を**そのまま**使用してください。知識から補完・推測・変更しないでください。例:ラベルに「東魁」とあれば「東魁盛」に変えない。
- tasteStatsは必ず1-5の数値で埋めてください。
- その他の値が不明な場合は null または 適切な推測値を入れてください。
''';
return _callProxyApi(
imagePaths: imagePaths,
customPrompt: prompt, // Override server default
forceRefresh: forceRefresh,
);
}
/// 共通実装: ProxyへのAPIコール
Future<SakeAnalysisResult> _callProxyApi({
required List<String> 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変換撮影時に圧縮済み
List<String> 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);
// スキーマ準拠チェック
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<SakeAnalysisResult> _callDirectApi(List<String> 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.');
}
// モデル候補: 503/UNAVAILABLE 時にフォールバック
const primaryModel = 'gemini-2.5-flash'; // ⚠️ FIXED - confirmed 2026-01-17
const fallbackModel = 'gemini-2.0-flash'; // 503 連続時のフォールバック
// 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 parts (画像バイト読み込みは一度だけ)
final contentParts = <Part>[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,
generationConfig: GenerationConfig(
responseMimeType: 'application/json',
temperature: 0.2,
),
);
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);
}
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<String> flavorTags;
final Map<String, int> 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<String, dynamic> json) {
// Helper to extract int from map safely
Map<String, int> 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<dynamic>?)?.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<String, dynamic> 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,
};
}
}