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

419 lines
15 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';
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 {
// Force use of client-side prompt to ensure Schema consistency (Phase 1 Fix)
const prompt = '''
あなたは日本酒の専門家(ソムリエ)です。
添付の画像日本酒のラベルを分析し、以下の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"
}
値が不明な場合は null または 適切な推測値を入れてください。特にtasteStatsは必ず1-5の数値で埋めてください。
''';
return _callProxyApi(
imagePaths: imagePaths,
customPrompt: prompt, // Override server default
forceRefresh: forceRefresh,
);
}
/// OCRテキストと画像のハイブリッド解析
Future<SakeAnalysisResult> analyzeSakeHybrid(String extractedText, List<String> imagePaths) async {
final prompt = '''
あなたは日本酒の専門家(ソムリエ)です。
以下のOCR抽出テキストは参考情報です誤字・脱落あり
OCRテキストはあくまで補助的なヒントとして扱い、添付の画像を優先して全項目を必ず埋めてください。
OCRテキスト参考のみ:
"""
$extractedText
"""
添付の日本酒ラベル画像を分析し、以下の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"
}
★重要な指示:
- tasteStats香り、甘味、酸味、苦味、ボディは必ず1-5の整数で埋めてください。不明な場合は3を設定してください。
- alcoholContent, polishingRatio, sakeMeterValue などの詳細項目も、画像から読み取れる場合は必ず設定してください。
- 値が不明な場合は null または 適切な推測値を入れてください。
''';
return _callProxyApi(imagePaths: imagePaths, customPrompt: prompt);
}
/// テキストのみの解析 (画像なし)
Future<SakeAnalysisResult> analyzeSakeText(String extractedText) async {
final prompt = '''
以下のOCRで抽出された日本酒ラベルのテキスト情報を分析してください。
誤字やノイズが含まれることが多いですが、文脈から積極的に正しい情報を推測・補完してください。
抽出テキスト:
"""
$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},
"alcoholContent": 15.5,
"polishingRatio": 50,
"sakeMeterValue": 3.0,
"riceVariety": "山田錦",
"yeast": "きょうかい9号",
"manufacturingYearMonth": "2023.10"
}
''';
return _callProxyApi(imagePaths: [], customPrompt: prompt);
}
/// 共通実装: 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変換 (Phase 4: Images already compressed at capture)
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);
// Phase 3 Validation: Check for Schema Compliance
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エラー
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('💰 API呼び出しをスキップキャッシュヒット');
return cached;
}
}
// 2. API Key確認
final apiKey = Secrets.geminiApiKey;
if (apiKey.isEmpty) {
throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
}
final model = GenerativeModel(
model: 'gemini-2.5-flash', // ⚠️ FIXED MODEL NAME - DO NOT CHANGE without explicit user approval (confirmed working on 2026-01-17)
apiKey: apiKey,
generationConfig: GenerationConfig(
responseMimeType: 'application/json',
temperature: 0.2, // チャート一貫性向上のため 0.4→0.2 に変更 (2026-02-09)
),
);
// Prepare Prompt
final promptText = customPrompt ?? '''
あなたは日本酒の専門家(ソムリエ)です。
添付の画像日本酒のラベルを分析し、以下の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"
}
値が不明な場合は null または 適切な推測値を入れてください。
''';
// Prepare Content
final contentParts = <Part>[TextPart(promptText)];
for (var path in imagePaths) {
// Phase 4: Images already compressed at capture time
final bytes = await File(path).readAsBytes();
contentParts.add(DataPart('image/jpeg', bytes));
}
try {
final response = await model.generateContent([Content.multi(contentParts)]);
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. 銘柄名インデックスに登録v1.0.15: チャート一貫性向上)
await AnalysisCacheService.registerBrandIndex(result.name, imageHash);
}
return result;
} catch (e) {
debugPrint('Direct API Error: $e');
throw Exception('AI解析エラー(Direct): $e');
}
}
}
// 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,
};
}
}