379 lines
14 KiB
Dart
379 lines
14 KiB
Dart
|
|
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": 0から100の整数,
|
|||
|
|
"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 29」が「Daia 2Y」になるような誤字や脱落が含まれますが、
|
|||
|
|
**添付の画像で実際の表記を確認し、正しい情報を推測・補完**してください。
|
|||
|
|
|
|||
|
|
特に以下の点に注目して分析してください:
|
|||
|
|
1. **銘柄名・特定名称**: テキストで断片的な情報(例:"KIMOTO")があれば、画像で全体のバランスを見て正式な商品名(例:"KIMOTO 35")を特定してください。
|
|||
|
|
2. **信頼度**: テキストと画像の両方があるため、**高い信頼度(90以上)**を目指してください。矛盾がある場合は画像の情報を優先してください。
|
|||
|
|
|
|||
|
|
抽出テキスト:
|
|||
|
|
"""
|
|||
|
|
$extractedText
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
以下の情報をJSON形式で返してください:
|
|||
|
|
|
|||
|
|
{
|
|||
|
|
"name": "銘柄名(例:獺祭 純米大吟醸)",
|
|||
|
|
"brand": "蔵元名(例:旭酒造)",
|
|||
|
|
"prefecture": "都道府県名(例:山口県)",
|
|||
|
|
"type": "種類(例:純米大吟醸)",
|
|||
|
|
"description": "この日本酒の特徴を100文字程度で説明(裏ラベルの情報があれば活用してください)",
|
|||
|
|
"catchCopy": "この日本酒を一言で表現する詩的なキャッチコピー(20文字以内)",
|
|||
|
|
"confidenceScore": 0から100の整数,
|
|||
|
|
"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": 0から100の整数,
|
|||
|
|
"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,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|