478 lines
21 KiB
Dart
478 lines
21 KiB
Dart
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の読み取り(OCR厳守)
|
||
これら3フィールドは、ラベルに印刷されている文字だけを一字一句そのまま出力してください。
|
||
あなたが知っている銘柄知識でラベルの文字を補完・変換・拡張することは厳禁です。
|
||
|
||
【具体的な禁止例】
|
||
- ラベルに「東魁」(2文字) → "東魁" のまま出力(「東魁盛」への変換禁止)
|
||
- ラベルに「白鹿」(2文字) → "白鹿" のまま出力(「白鹿本醸造」への変換禁止)
|
||
- ラベルに「久保田」(3文字) → "久保田" のまま出力(「久保田 千寿」への変換禁止)
|
||
- ラベルに「男山」(2文字) → "男山" のまま出力(「男山本醸造」への変換禁止)
|
||
- ラベルに「白鶴」(2文字) → "白鶴" のまま出力(「白鶴まる」への変換禁止)
|
||
- ラベルに「松竹梅」(3文字) → "松竹梅" のまま出力(「松竹梅 白壁蔵」への変換禁止)
|
||
|
||
【一般原則】ラベルに N 文字しか見えない場合は N 文字のみ出力する。
|
||
文字数を増やすことは、たとえあなたが正式名称を知っていても禁止。
|
||
|
||
- prefecture: ラベルに都道府県名が書かれていればそのまま出力、なければ null
|
||
(銘柄名・蔵元名から産地を推測して埋めることは禁止)
|
||
|
||
## 【出力前セルフチェック】
|
||
name・brand を出力する直前に以下を確認してください:
|
||
- ラベル画像で実際に見えている文字数と、出力しようとしている文字数が一致するか?
|
||
- あなたの知識による「補完」が入っていないか?
|
||
不一致の場合は、ラベルに見えている文字数に合わせて修正してください。
|
||
|
||
## その他のフィールド(推定可)
|
||
以下はラベル情報+日本酒の一般知識を使って推定してください。
|
||
- 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<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);
|
||
}
|
||
|
||
// 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<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();
|
||
if (kDebugMode) 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<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('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の読み取り(OCR厳守)
|
||
これら3フィールドは、ラベルに印刷されている文字だけを一字一句そのまま出力してください。
|
||
あなたが知っている銘柄知識でラベルの文字を補完・変換・拡張することは厳禁です。
|
||
|
||
【禁止例】「東魁」→「東魁盛」禁止 / 「男山」→「男山本醸造」禁止 / 「白鹿」→「白鹿本醸造」禁止
|
||
【一般原則】ラベルに N 文字しか見えない場合は N 文字のみ出力する(文字数を増やすことは禁止)
|
||
- 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 = <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,
|
||
systemInstruction: Content.system(
|
||
'あなたは画像内のテキストを一字一句正確に書き起こすOCR(光学文字認識)専門システムです。\n'
|
||
'【絶対的制約 — name・brand・prefecture フィールドに適用】\n'
|
||
'1. ラベルに印刷されている文字だけを出力する。ラベルにない文字を1文字も追加してはならない。\n'
|
||
'2. ラベルに N 文字の銘柄名があれば N 文字のまま出力する。文字数を増やすことは禁止。\n'
|
||
'3. あなたが知っている「正式名称」「有名銘柄名」への変換・補完は禁止。\n'
|
||
' 例: 「東魁」→「東魁」(「東魁盛」禁止)、「男山」→「男山」(「男山本醸造」禁止)、\n'
|
||
' 「白鹿」→「白鹿」(「白鹿本醸造」禁止)、「久保田」→「久保田」(「久保田 千寿」禁止)\n'
|
||
'4. ラベルに都道府県名がなければ prefecture は null。銘柄名から産地を推測して埋めることは禁止。\n'
|
||
'5. 日本酒知識は description・flavorTags・tasteStats 等の推定フィールドにのみ使用すること。',
|
||
),
|
||
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<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;
|
||
|
||
/// キャッシュから返された結果かどうか(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<String, dynamic> json) {
|
||
// tasteStats: 欠損キーを 3 で補完、範囲外(1〜5)をクランプ
|
||
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
|
||
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).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<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,
|
||
};
|
||
}
|
||
}
|