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

456 lines
19 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の読み取り
これら3フィールドのみ、ラベルに印刷された文字を一字一句そのまま出力してください。
- あなたの知識でラベルの文字を補完・訂正・変更することは禁止
- ラベルに「東魁」→ "name": "東魁"(「東魁盛」に変えない)
- ラベルに「白鹿」→ "name": "白鹿"(「白鹿本醸造」に変えない)
- ラベルに「久保田」→ "name": "久保田"(「久保田 千寿」に変えない)
- prefecture: ラベルに都道府県名が書かれていればそのまま出力、書かれていなければ null
(銘柄名から産地を推測して埋めることは禁止)
## その他のフィールド(推定・推定可)
以下はラベルから読み取れる情報+日本酒の一般知識を使って推定してください。
- 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();
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の読み取り
これら3フィールドのみ、ラベルに印刷された文字を一字一句そのまま出力してください。
- あなたの知識でラベルの文字を補完・訂正・変更することは禁止
- ラベルに「東魁」→ "name": "東魁"(「東魁盛」に変えない)
- 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(
'あなたは画像内のテキストを一字一句正確に読み取る専門家です。'
'ラベルに記載された銘柄名・蔵元名は絶対に変更・補完しないでください。'
'あなたの知識でラベルの文字を上書きすることは厳禁です。'
'ラベルに「東魁」とあれば、「東魁盛」を知っていても必ず「東魁」と出力してください。',
),
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,
};
}
}