2026-01-11 08:17:29 +00:00
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
import 'dart:convert';
|
2026-01-15 15:53:44 +00:00
|
|
|
|
import 'package:http/http.dart' as http;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
import 'package:flutter/foundation.dart';
|
2026-01-15 15:53:44 +00:00
|
|
|
|
import 'device_service.dart';
|
2026-01-29 15:54:22 +00:00
|
|
|
|
import 'package:google_generative_ai/google_generative_ai.dart';
|
|
|
|
|
|
import '../secrets.dart';
|
|
|
|
|
|
import 'analysis_cache_service.dart';
|
2026-04-09 23:26:53 +00:00
|
|
|
|
import 'gemini_exceptions.dart';
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
class GeminiService {
|
|
|
|
|
|
// AI Proxy Server Configuration
|
2026-01-29 15:54:22 +00:00
|
|
|
|
static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl;
|
2026-01-15 15:53:44 +00:00
|
|
|
|
|
|
|
|
|
|
// レート制限対策? Proxy側で管理されているが、念のためクライアント側でも連打防止
|
2026-01-11 08:17:29 +00:00
|
|
|
|
static DateTime? _lastApiCallTime;
|
2026-01-15 15:53:44 +00:00
|
|
|
|
static const Duration _minApiInterval = Duration(seconds: 2);
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
GeminiService();
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
/// 画像リストから日本酒ラベルを解析
|
2026-01-29 15:54:22 +00:00
|
|
|
|
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths, {bool forceRefresh = false}) async {
|
2026-04-04 18:02:01 +00:00
|
|
|
|
// クライアント側プロンプトでスキーマの一貫性を保証
|
2026-01-29 15:54:22 +00:00
|
|
|
|
const prompt = '''
|
2026-04-11 06:46:44 +00:00
|
|
|
|
【絶対ルール】ラベルに印刷されている文字を一字一句そのまま読み取ること。
|
|
|
|
|
|
あなたの学習データ・知識でラベルの銘柄名や蔵元名を補完・変更・推測することは厳禁。
|
|
|
|
|
|
例: ラベルに「東魁」とだけ書かれていれば "name" は「東魁」(「東魁盛」ではない)
|
|
|
|
|
|
例: ラベルに「白鹿」とあれば「白鹿」(「灘菊」など類似銘柄に変えない)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-01-29 15:54:22 +00:00
|
|
|
|
あなたは日本酒の専門家(ソムリエ)です。
|
2026-04-11 06:46:44 +00:00
|
|
|
|
添付の画像(日本酒のラベル)を解析し、以下のJSON形式で情報を返してください。
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
|
|
|
|
|
{
|
2026-04-11 06:46:44 +00:00
|
|
|
|
"name": "ラベルに書かれた銘柄名をそのまま(変更・補完禁止)",
|
|
|
|
|
|
"brand": "ラベルに書かれた蔵元名をそのまま(変更・補完禁止)",
|
2026-01-29 15:54:22 +00:00
|
|
|
|
"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"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 06:46:44 +00:00
|
|
|
|
★必須ルール(再掲):
|
|
|
|
|
|
- "name"と"brand"はラベルから目視で読んだ文字のみ。知識での変更は絶対禁止。
|
|
|
|
|
|
- tasteStatsは必ず1〜5の整数で埋めること。
|
|
|
|
|
|
- 不明な値は null または合理的な推測値。
|
2026-01-29 15:54:22 +00:00
|
|
|
|
''';
|
|
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
return _callProxyApi(
|
|
|
|
|
|
imagePaths: imagePaths,
|
2026-01-29 15:54:22 +00:00
|
|
|
|
customPrompt: prompt, // Override server default
|
|
|
|
|
|
forceRefresh: forceRefresh,
|
2026-01-15 15:53:44 +00:00
|
|
|
|
);
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
/// 共通実装: ProxyへのAPIコール
|
|
|
|
|
|
Future<SakeAnalysisResult> _callProxyApi({
|
|
|
|
|
|
required List<String> imagePaths,
|
|
|
|
|
|
String? customPrompt,
|
2026-01-29 15:54:22 +00:00
|
|
|
|
bool forceRefresh = false,
|
2026-01-15 15:53:44 +00:00
|
|
|
|
}) async {
|
2026-01-29 15:54:22 +00:00
|
|
|
|
// Check Mode: Direct vs Proxy
|
|
|
|
|
|
if (!Secrets.useProxy) {
|
2026-04-11 22:25:24 +00:00
|
|
|
|
debugPrint('Direct Cloud Mode: Connecting to Gemini API directly...');
|
2026-01-29 15:54:22 +00:00
|
|
|
|
return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 04:30:00 +00:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
try {
|
2026-04-15 04:30:00 +00:00
|
|
|
|
// 2. レート制限 (クライアント側連打防止)
|
2026-01-15 15:53:44 +00:00
|
|
|
|
if (_lastApiCallTime != null) {
|
|
|
|
|
|
final elapsed = DateTime.now().difference(_lastApiCallTime!);
|
|
|
|
|
|
if (elapsed < _minApiInterval) {
|
|
|
|
|
|
await Future.delayed(_minApiInterval - elapsed);
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
2026-01-15 15:53:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
_lastApiCallTime = DateTime.now();
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
2026-04-04 18:02:01 +00:00
|
|
|
|
// 2. 画像をBase64変換(撮影時に圧縮済み)
|
2026-01-15 15:53:44 +00:00
|
|
|
|
List<String> base64Images = [];
|
|
|
|
|
|
for (final path in imagePaths) {
|
2026-01-29 15:54:22 +00:00
|
|
|
|
// Read already-compressed images directly (compressed at capture time)
|
2026-01-15 15:53:44 +00:00
|
|
|
|
final bytes = await File(path).readAsBytes();
|
|
|
|
|
|
final base64String = base64Encode(bytes);
|
|
|
|
|
|
base64Images.add(base64String);
|
2026-01-29 15:54:22 +00:00
|
|
|
|
debugPrint('Encoded processed image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 15:53:44 +00:00
|
|
|
|
// 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');
|
|
|
|
|
|
|
2026-02-16 02:34:00 +00:00
|
|
|
|
// 5. 送信(Bearer Token認証付き)
|
|
|
|
|
|
final headers = {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
if (Secrets.proxyAuthToken.isNotEmpty)
|
|
|
|
|
|
"Authorization": "Bearer ${Secrets.proxyAuthToken}",
|
|
|
|
|
|
};
|
2026-01-15 15:53:44 +00:00
|
|
|
|
final response = await http.post(
|
|
|
|
|
|
Uri.parse(_proxyUrl),
|
2026-02-16 02:34:00 +00:00
|
|
|
|
headers: headers,
|
2026-01-15 15:53:44 +00:00
|
|
|
|
body: requestBody,
|
2026-01-29 15:54:22 +00:00
|
|
|
|
).timeout(const Duration(seconds: 60)); // 拡張: 60秒 (画像サイズ最適化済み)
|
2026-01-15 15:53:44 +00:00
|
|
|
|
|
|
|
|
|
|
// 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']}');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:54:22 +00:00
|
|
|
|
final result = SakeAnalysisResult.fromJson(data);
|
|
|
|
|
|
|
2026-04-04 18:02:01 +00:00
|
|
|
|
// スキーマ準拠チェック
|
2026-04-15 04:30:00 +00:00
|
|
|
|
if (result.tasteStats.isEmpty ||
|
2026-01-29 15:54:22 +00:00
|
|
|
|
result.tasteStats.values.every((v) => v == 0)) {
|
2026-04-11 22:25:24 +00:00
|
|
|
|
debugPrint('WARNING: AI returned empty or zero taste stats. This item will not form a valid chart.');
|
2026-01-29 15:54:22 +00:00
|
|
|
|
} 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();
|
2026-04-15 04:30:00 +00:00
|
|
|
|
|
2026-01-29 15:54:22 +00:00
|
|
|
|
if (missing.isNotEmpty) {
|
2026-04-11 22:25:24 +00:00
|
|
|
|
debugPrint('WARNING: AI response missing keys: $missing. Old schema?');
|
2026-01-29 15:54:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 04:30:00 +00:00
|
|
|
|
// キャッシュに保存(次回同一画像はAPI不使用)
|
|
|
|
|
|
if (imagePaths.isNotEmpty) {
|
|
|
|
|
|
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
|
|
|
|
|
|
await AnalysisCacheService.saveCache(imageHash, result);
|
|
|
|
|
|
await AnalysisCacheService.registerBrandIndex(
|
|
|
|
|
|
result.name,
|
|
|
|
|
|
imageHash,
|
|
|
|
|
|
forceUpdate: forceRefresh,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 15:54:22 +00:00
|
|
|
|
return result;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
} else {
|
2026-01-15 15:53:44 +00:00
|
|
|
|
// Proxy側での論理エラー (レート制限超過など)
|
|
|
|
|
|
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
2026-01-15 15:53:44 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
// HTTPエラー
|
2026-04-11 22:25:24 +00:00
|
|
|
|
if (kDebugMode) {
|
|
|
|
|
|
debugPrint('Proxy Error: ${response.statusCode} ${response.body}');
|
|
|
|
|
|
}
|
2026-01-15 15:53:44 +00:00
|
|
|
|
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明日またお試しください。');
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
2026-01-15 15:53:44 +00:00
|
|
|
|
rethrow;
|
|
|
|
|
|
}
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
|
|
|
|
|
/// 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) {
|
2026-04-11 22:25:24 +00:00
|
|
|
|
debugPrint('Cache hit: skipping API call');
|
2026-01-29 15:54:22 +00:00
|
|
|
|
return cached;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. API Key確認
|
|
|
|
|
|
final apiKey = Secrets.geminiApiKey;
|
|
|
|
|
|
if (apiKey.isEmpty) {
|
|
|
|
|
|
throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 10:04:40 +00:00
|
|
|
|
// モデル候補: 503/UNAVAILABLE 時にフォールバック
|
2026-04-12 04:23:52 +00:00
|
|
|
|
// NOTE: Google は予告なしでモデルを廃止することがある。定期的に動作確認を行うこと。
|
|
|
|
|
|
// Phase 2(プロキシ移行)後はサーバー側から設定を取得する設計に変更する予定。
|
|
|
|
|
|
const primaryModel = 'gemini-2.5-flash';
|
|
|
|
|
|
const fallbackModel = 'gemini-2.0-flash';
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-04-11 06:46:44 +00:00
|
|
|
|
// Prepare Prompt (customPrompt が null のケースは通常発生しないが念のため同じ内容を保持)
|
2026-01-29 15:54:22 +00:00
|
|
|
|
final promptText = customPrompt ?? '''
|
2026-04-11 06:46:44 +00:00
|
|
|
|
【絶対ルール】ラベルに印刷されている文字を一字一句そのまま読み取ること。
|
|
|
|
|
|
あなたの学習データ・知識でラベルの銘柄名や蔵元名を補完・変更・推測することは厳禁。
|
|
|
|
|
|
例: ラベルに「東魁」とだけ書かれていれば "name" は「東魁」(「東魁盛」ではない)
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
2026-01-29 15:54:22 +00:00
|
|
|
|
あなたは日本酒の専門家(ソムリエ)です。
|
2026-04-11 06:46:44 +00:00
|
|
|
|
添付の画像(日本酒のラベル)を解析し、以下のJSON形式で情報を返してください。
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
|
|
|
|
|
{
|
2026-04-11 06:46:44 +00:00
|
|
|
|
"name": "ラベルに書かれた銘柄名をそのまま(変更・補完禁止)",
|
|
|
|
|
|
"brand": "ラベルに書かれた蔵元名をそのまま(変更・補完禁止)",
|
2026-01-29 15:54:22 +00:00
|
|
|
|
"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"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 06:46:44 +00:00
|
|
|
|
★必須ルール:
|
|
|
|
|
|
- "name"と"brand"はラベルから目視で読んだ文字のみ。知識での変更は絶対禁止。
|
|
|
|
|
|
- tasteStatsは必ず1〜5の整数で埋めること。
|
|
|
|
|
|
- 不明な値は null または合理的な推測値。
|
2026-01-29 15:54:22 +00:00
|
|
|
|
''';
|
|
|
|
|
|
|
2026-04-09 10:04:40 +00:00
|
|
|
|
// Prepare Content parts (画像バイト読み込みは一度だけ)
|
2026-01-29 15:54:22 +00:00
|
|
|
|
final contentParts = <Part>[TextPart(promptText)];
|
|
|
|
|
|
for (var path in imagePaths) {
|
|
|
|
|
|
final bytes = await File(path).readAsBytes();
|
|
|
|
|
|
contentParts.add(DataPart('image/jpeg', bytes));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 10:04:40 +00:00
|
|
|
|
// 503 時: リトライ(指数バックオフ)→ フォールバックモデル
|
|
|
|
|
|
const maxRetries = 3;
|
|
|
|
|
|
final modelsToTry = [primaryModel, primaryModel, primaryModel, fallbackModel];
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-04-09 10:04:40 +00:00
|
|
|
|
for (int attempt = 0; attempt <= maxRetries; attempt++) {
|
|
|
|
|
|
final modelName = modelsToTry[attempt];
|
|
|
|
|
|
final isLastAttempt = attempt == maxRetries;
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-04-09 10:04:40 +00:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-04-09 10:04:40 +00:00
|
|
|
|
final model = GenerativeModel(
|
|
|
|
|
|
model: modelName,
|
|
|
|
|
|
apiKey: apiKey,
|
2026-04-11 06:46:44 +00:00
|
|
|
|
systemInstruction: Content.system(
|
|
|
|
|
|
'あなたは画像内のテキストを一字一句正確に読み取る専門家です。'
|
|
|
|
|
|
'ラベルに記載された銘柄名・蔵元名は絶対に変更・補完しないでください。'
|
|
|
|
|
|
'あなたの知識でラベルの文字を上書きすることは厳禁です。'
|
|
|
|
|
|
'ラベルに「東魁」とあれば、「東魁盛」を知っていても必ず「東魁」と出力してください。',
|
|
|
|
|
|
),
|
2026-04-09 10:04:40 +00:00
|
|
|
|
generationConfig: GenerationConfig(
|
|
|
|
|
|
responseMimeType: 'application/json',
|
2026-04-11 06:46:44 +00:00
|
|
|
|
temperature: 0,
|
2026-04-09 10:04:40 +00:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-04-11 22:25:24 +00:00
|
|
|
|
final response = await model
|
|
|
|
|
|
.generateContent([Content.multi(contentParts)])
|
|
|
|
|
|
.timeout(const Duration(seconds: 60));
|
2026-04-09 10:04:40 +00:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-04-11 15:09:09 +00:00
|
|
|
|
// 4. 銘柄名インデックスに登録(forceRefresh 時は誤認識結果を上書き)
|
|
|
|
|
|
await AnalysisCacheService.registerBrandIndex(
|
|
|
|
|
|
result.name,
|
|
|
|
|
|
imageHash,
|
|
|
|
|
|
forceUpdate: forceRefresh,
|
|
|
|
|
|
);
|
2026-04-09 10:04:40 +00:00
|
|
|
|
}
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-04-09 10:04:40 +00:00
|
|
|
|
if (attempt > 0) debugPrint('Succeeded on attempt $attempt (model: $modelName)');
|
|
|
|
|
|
return result;
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
2026-04-09 10:04:40 +00:00
|
|
|
|
} 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) {
|
2026-04-09 23:26:53 +00:00
|
|
|
|
throw const GeminiCongestionException();
|
2026-04-09 10:04:40 +00:00
|
|
|
|
}
|
|
|
|
|
|
throw Exception('AI解析エラー(Direct): $e');
|
|
|
|
|
|
}
|
|
|
|
|
|
// 503 → 次のリトライへ
|
|
|
|
|
|
}
|
2026-01-29 15:54:22 +00:00
|
|
|
|
}
|
2026-04-09 10:04:40 +00:00
|
|
|
|
|
|
|
|
|
|
// ここには到達しない
|
|
|
|
|
|
throw Exception('AI解析に失敗しました。再試行してください。');
|
2026-01-29 15:54:22 +00:00
|
|
|
|
}
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-01-15 15:53:44 +00:00
|
|
|
|
|
|
|
|
|
|
// New Fields
|
|
|
|
|
|
final double? alcoholContent;
|
|
|
|
|
|
final int? polishingRatio;
|
|
|
|
|
|
final double? sakeMeterValue;
|
|
|
|
|
|
final String? riceVariety;
|
|
|
|
|
|
final String? yeast;
|
|
|
|
|
|
final String? manufacturingYearMonth;
|
2026-01-11 08:17:29 +00:00
|
|
|
|
|
|
|
|
|
|
SakeAnalysisResult({
|
|
|
|
|
|
this.name,
|
|
|
|
|
|
this.brand,
|
|
|
|
|
|
this.prefecture,
|
|
|
|
|
|
this.type,
|
|
|
|
|
|
this.description,
|
|
|
|
|
|
this.catchCopy,
|
|
|
|
|
|
this.confidenceScore,
|
|
|
|
|
|
this.flavorTags = const [],
|
|
|
|
|
|
this.tasteStats = const {},
|
2026-01-15 15:53:44 +00:00
|
|
|
|
this.alcoholContent,
|
|
|
|
|
|
this.polishingRatio,
|
|
|
|
|
|
this.sakeMeterValue,
|
|
|
|
|
|
this.riceVariety,
|
|
|
|
|
|
this.yeast,
|
|
|
|
|
|
this.manufacturingYearMonth,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-01-15 15:53:44 +00:00
|
|
|
|
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?,
|
2026-01-11 08:17:29 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-01-29 15:54:22 +00:00
|
|
|
|
|
|
|
|
|
|
/// 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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|