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

379 lines
14 KiB
Dart
Raw Normal View History

import 'dart:io';
import 'dart:convert';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:flutter/foundation.dart';
import '../secrets.dart';
class GeminiService {
late final GenerativeModel _model;
// レート制限対策: 最後のAPI呼び出し時刻を記録
static DateTime? _lastApiCallTime;
static const Duration _minApiInterval = Duration(seconds: 5); // 最低5秒間隔
// モデル選択: gemini-2.5-flash (無料版) または gemini-2.5-pro (有料版)
// Google One Pro会員でも、API料金は別途発生します
// 有料版に変更する場合: Google AI Studio → Billing → Pay-as-you-go設定後、
// 下記モデル名を 'gemini-2.5-pro' に変更してください
// Lite is 503 Overloaded. Trying Standard Flash.
static const _modelName = 'gemini-2.5-flash'; // Pro版: 'gemini-2.5-pro'
GeminiService() {
_model = GenerativeModel(
model: _modelName,
apiKey: Secrets.geminiApiKey,
// 安全設定: 日本酒情報なので制限を緩和
safetySettings: [
SafetySetting(HarmCategory.harassment, HarmBlockThreshold.none),
SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.none),
],
);
}
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths) async {
try {
// レート制限対策
if (_lastApiCallTime != null) {
final elapsed = DateTime.now().difference(_lastApiCallTime!);
if (elapsed < _minApiInterval) {
await Future.delayed(_minApiInterval - elapsed);
}
}
if (imagePaths.isEmpty) throw Exception("画像が選択されていません");
debugPrint('Analyzing ${imagePaths.length} images...');
const prompt = '''
JSON形式で返してください
{
"name": "銘柄名(例:獺祭 純米大吟醸)",
"brand": "蔵元名(例:旭酒造)",
"prefecture": "都道府県名(例:山口県)",
"type": "種類(例:純米大吟醸)",
"description": "この日本酒の特徴を100文字程度で説明裏ラベルの情報があれば活用してください",
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー20文字以内",
"confidenceScore": 0100,
"flavorTags": ["フルーティ", "辛口"],
"tasteStats": {
"aroma": 3,
"sweetness": 3,
"acidity": 3,
"bitterness": 3,
"body": 3
}
}
**tasteStatsの説明 (1-5)**:
- aroma:
- sweetness:
- acidity:
- bitterness: /
- body:
null
JSONのみを返し
''';
final parts = <Part>[TextPart(prompt)];
for (final path in imagePaths) {
final bytes = await File(path).readAsBytes();
// Simple mime type assumption, Gemini is lenient
parts.add(DataPart('image/jpeg', bytes));
debugPrint('Loaded image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
}
final content = [Content.multi(parts)];
// API呼び出し
_lastApiCallTime = DateTime.now(); // 呼び出し時刻を記録
final response = await _model.generateContent(content);
final text = response.text ?? '';
// トークン使用量をログ出力(デバッグ用)
if (response.usageMetadata != null) {
debugPrint('Token usage - Prompt: ${response.usageMetadata!.promptTokenCount}, '
'Response: ${response.usageMetadata!.candidatesTokenCount}, '
'Total: ${response.usageMetadata!.totalTokenCount}');
}
// Parse JSON (remove markdown code blocks if present)
final jsonText = text.trim()
.replaceAll('```json', '')
.replaceAll('```', '')
.trim();
final Map<String, dynamic> json = jsonDecode(jsonText);
return SakeAnalysisResult.fromJson(json);
} catch (e) {
debugPrint('Gemini API error: $e');
// レート制限エラーの詳細な処理
final errorString = e.toString().toLowerCase();
if (errorString.contains('429') || errorString.contains('quota') || errorString.contains('resource_exhausted')) {
// 具体的なエラーメッセージを返す
if (errorString.contains('quota')) {
throw Exception('AI使用制限に達しました。\n'
'無料版は1分間に15回までの制限があります。\n'
'1〜2分後に再度お試しください。');
} else {
throw Exception('APIレート制限エラー(429)。\n'
'画像解析の頻度が高すぎます。\n'
'数分後に再度お試しください。');
}
}
// その他のエラー
rethrow;
}
}
Future<SakeAnalysisResult> analyzeSakeHybrid(String extractedText, List<String> imagePaths) async {
try {
// レート制限対策
if (_lastApiCallTime != null) {
final elapsed = DateTime.now().difference(_lastApiCallTime!);
if (elapsed < _minApiInterval) {
await Future.delayed(_minApiInterval - elapsed);
}
}
if (extractedText.isEmpty || imagePaths.isEmpty) throw Exception("テキストまたは画像がありません");
debugPrint('Analyzing hybrid (Text: ${extractedText.length} chars, Images: ${imagePaths.length})...');
final prompt = '''
OCR抽出テキストと添付の日本酒ラベル画像を組み合わせて分析してください
OCRの性質上Data 29Daia 2Y
****
1. ****: :"KIMOTO":"KIMOTO 35"
2. ****: **(90)**
:
"""
$extractedText
"""
JSON形式で返してください
{
"name": "銘柄名(例:獺祭 純米大吟醸)",
"brand": "蔵元名(例:旭酒造)",
"prefecture": "都道府県名(例:山口県)",
"type": "種類(例:純米大吟醸)",
"description": "この日本酒の特徴を100文字程度で説明裏ラベルの情報があれば活用してください",
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー20文字以内",
"confidenceScore": 0100,
"flavorTags": ["フルーティ", "辛口"],
"tasteStats": {
"aroma": 3,
"sweetness": 3,
"acidity": 3,
"bitterness": 3,
"body": 3
}
}
**tasteStatsの説明 (1-5)**:
- aroma:
- sweetness:
- acidity:
- bitterness: /
- body:
JSONのみを返し
''';
final parts = <Part>[TextPart(prompt)];
// 画像は「確認用」なので1枚目通常表ラベルだけでも効果的だが、
// 念のためすべて送る(トークン節約のためリサイズしてから送るのが理想だが今回はそのまま送る)
for (final path in imagePaths) {
final bytes = await File(path).readAsBytes();
parts.add(DataPart('image/jpeg', bytes));
}
final content = [Content.multi(parts)];
// API呼び出し
_lastApiCallTime = DateTime.now();
final response = await _model.generateContent(content);
final text = response.text ?? '';
// トークン使用量をログ出力
if (response.usageMetadata != null) {
debugPrint('Token usage (Hybrid) - Prompt: ${response.usageMetadata!.promptTokenCount}, '
'Response: ${response.usageMetadata!.candidatesTokenCount}, '
'Total: ${response.usageMetadata!.totalTokenCount}');
}
final jsonText = text.trim()
.replaceAll('```json', '')
.replaceAll('```', '')
.trim();
final Map<String, dynamic> json = jsonDecode(jsonText);
return SakeAnalysisResult.fromJson(json);
} catch (e) {
_handleError(e);
rethrow;
}
}
Future<SakeAnalysisResult> analyzeSakeText(String extractedText) async {
try {
// レート制限対策
if (_lastApiCallTime != null) {
final elapsed = DateTime.now().difference(_lastApiCallTime!);
if (elapsed < _minApiInterval) {
await Future.delayed(_minApiInterval - elapsed);
}
}
if (extractedText.isEmpty) throw Exception("テキストが抽出できませんでした");
debugPrint('Analyzing text (${extractedText.length} chars)...');
final prompt = '''
OCRで抽出された日本酒ラベルのテキスト情報を分析してください
OCRの性質上****
1. ****: : Kimoto **(80)**
2. **/**:
3. ****:
:
"""
$extractedText
"""
JSON形式で返してください
{
"name": "銘柄名(例:獺祭 純米大吟醸)",
"brand": "蔵元名(例:旭酒造)",
"prefecture": "都道府県名(例:山口県)",
"type": "種類(例:純米大吟醸)",
"description": "この日本酒の特徴を100文字程度で説明裏ラベルの情報があれば活用してください",
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー20文字以内",
"confidenceScore": 0100,
"flavorTags": ["フルーティ", "辛口"],
"tasteStats": {
"aroma": 3,
"sweetness": 3,
"acidity": 3,
"bitterness": 3,
"body": 3
}
}
**tasteStatsの説明 (1-5)**:
- aroma:
- sweetness:
- acidity:
- bitterness: /
- body:
JSONのみを返し
''';
final content = [Content.text(prompt)];
_lastApiCallTime = DateTime.now();
final response = await _model.generateContent(content);
final text = response.text ?? '';
if (response.usageMetadata != null) {
debugPrint('Token usage (Text) - Prompt: ${response.usageMetadata!.promptTokenCount}, '
'Response: ${response.usageMetadata!.candidatesTokenCount}, '
'Total: ${response.usageMetadata!.totalTokenCount}');
}
final jsonText = text.trim()
.replaceAll('```json', '')
.replaceAll('```', '')
.trim();
final Map<String, dynamic> json = jsonDecode(jsonText);
return SakeAnalysisResult.fromJson(json);
} catch (e) {
_handleError(e);
rethrow;
}
}
void _handleError(Object e) {
debugPrint('Gemini API Error: $e');
final errorString = e.toString().toLowerCase();
if (errorString.contains('429') || errorString.contains('quota') || errorString.contains('resource_exhausted')) {
if (errorString.contains('quota')) {
throw Exception('AI使用制限に達しました。\n'
'無料版は1分間に15回までの制限があります。\n'
'1〜2分後に再度お試しください。');
} else {
throw Exception('APIレート制限エラー(429)。\n'
'画像解析の頻度が高すぎます。\n'
'数分後に再度お試しください。');
}
}
}
}
// 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;
SakeAnalysisResult({
this.name,
this.brand,
this.prefecture,
this.type,
this.description,
this.catchCopy,
this.confidenceScore,
this.flavorTags = const [],
this.tasteStats = const {},
});
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,
);
}
}