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

409 lines
16 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 = '''
【絶対ルール】ラベルに印刷されている文字を一字一句そのまま読み取ること。
あなたの学習データ・知識でラベルの銘柄名や蔵元名を補完・変更・推測することは厳禁。
例: ラベルに「東魁」とだけ書かれていれば "name" は「東魁」(「東魁盛」ではない)
例: ラベルに「白鹿」とあれば「白鹿」(「灘菊」など類似銘柄に変えない)
---
あなたは日本酒の専門家(ソムリエ)です。
添付の画像日本酒のラベルを解析し、以下の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エラー
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;
}
}
// 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 (customPrompt が null のケースは通常発生しないが念のため同じ内容を保持)
final promptText = customPrompt ?? '''
【絶対ルール】ラベルに印刷されている文字を一字一句そのまま読み取ること。
あなたの学習データ・知識でラベルの銘柄名や蔵元名を補完・変更・推測することは厳禁。
例: ラベルに「東魁」とだけ書かれていれば "name" は「東魁」(「東魁盛」ではない)
---
あなたは日本酒の専門家(ソムリエ)です。
添付の画像日本酒のラベルを解析し、以下の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 または合理的な推測値。
''';
// 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;
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,
};
}
}