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

419 lines
15 KiB
Dart
Raw Normal View History

import 'dart:io';
import 'dart:convert';
2026-01-15 15:53:44 +00:00
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
2026-01-15 15:53:44 +00:00
import 'device_service.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import '../secrets.dart';
import 'analysis_cache_service.dart';
2026-01-15 15:53:44 +00:00
class GeminiService {
// AI Proxy Server Configuration
static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl;
2026-01-15 15:53:44 +00:00
// レート制限対策? Proxy側で管理されているが、念のためクライアント側でも連打防止
static DateTime? _lastApiCallTime;
2026-01-15 15:53:44 +00:00
static const Duration _minApiInterval = Duration(seconds: 2);
2026-01-15 15:53:44 +00:00
GeminiService();
2026-01-15 15:53:44 +00:00
/// 画像リストから日本酒ラベルを解析
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
''';
2026-01-15 15:53:44 +00:00
return _callProxyApi(
imagePaths: imagePaths,
customPrompt: prompt, // Override server default
forceRefresh: forceRefresh,
2026-01-15 15:53:44 +00:00
);
}
2026-01-15 15:53:44 +00:00
/// OCRテキストと画像のハイブリッド解析
Future<SakeAnalysisResult> analyzeSakeHybrid(String extractedText, List<String> imagePaths) async {
2026-01-15 15:53:44 +00:00
final prompt = '''
OCR抽出テキストは参考情報です
OCRテキストはあくまで補助的なヒントとして扱い
OCRテキスト:
"""
$extractedText
"""
JSON形式で情報を抽出してください
{
2026-01-15 15:53:44 +00:00
"name": "銘柄名",
"brand": "蔵元名",
"prefecture": "都道府県名",
"type": "特定名称(純米大吟醸など)",
"description": "味や特徴の魅力的な説明文(100文字程度)",
"catchCopy": "短いキャッチコピー(20文字以内)",
"confidenceScore": 80,
"flavorTags": ["フルーティー", "辛口", "華やか"],
2026-01-15 15:53:44 +00:00
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
"alcoholContent": 15.0,
2026-01-15 15:53:44 +00:00
"polishingRatio": 50,
"sakeMeterValue": 3.0,
"riceVariety": "山田錦",
"yeast": "きょうかい9号",
"manufacturingYearMonth": "2023.10"
}
:
- tasteStats1-53
- alcoholContent, polishingRatio, sakeMeterValue
- null
''';
2026-01-15 15:53:44 +00:00
return _callProxyApi(imagePaths: imagePaths, customPrompt: prompt);
}
2026-01-15 15:53:44 +00:00
/// テキストのみの解析 (画像なし)
Future<SakeAnalysisResult> analyzeSakeText(String extractedText) async {
2026-01-15 15:53:44 +00:00
final prompt = '''
OCRで抽出された日本酒ラベルのテキスト情報を分析してください
2026-01-15 15:53:44 +00:00
:
"""
$extractedText
"""
JSON形式で返してください
{
2026-01-15 15:53:44 +00:00
"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"
}
''';
2026-01-15 15:53:44 +00:00
return _callProxyApi(imagePaths: [], customPrompt: prompt);
}
2026-01-15 15:53:44 +00:00
/// 共通実装: ProxyへのAPIコール
Future<SakeAnalysisResult> _callProxyApi({
required List<String> imagePaths,
String? customPrompt,
bool forceRefresh = false,
2026-01-15 15:53:44 +00:00
}) async {
// Check Mode: Direct vs Proxy
if (!Secrets.useProxy) {
debugPrint('🚀 Direct Cloud Mode: Connecting to Gemini API directly...');
return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh);
}
2026-01-15 15:53:44 +00:00
try {
// 1. レート制限 (クライアント側連打防止)
if (_lastApiCallTime != null) {
final elapsed = DateTime.now().difference(_lastApiCallTime!);
if (elapsed < _minApiInterval) {
await Future.delayed(_minApiInterval - elapsed);
}
2026-01-15 15:53:44 +00:00
}
_lastApiCallTime = DateTime.now();
// 2. 画像をBase64変換 (Phase 4: Images already compressed at capture)
2026-01-15 15:53:44 +00:00
List<String> base64Images = [];
for (final path in imagePaths) {
// Read already-compressed images directly (compressed at capture time)
2026-01-15 15:53:44 +00:00
final bytes = await File(path).readAsBytes();
final base64String = base64Encode(bytes);
base64Images.add(base64String);
debugPrint('Encoded processed image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
}
2026-01-15 15:53:44 +00:00
// 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}",
};
2026-01-15 15:53:44 +00:00
final response = await http.post(
Uri.parse(_proxyUrl),
headers: headers,
2026-01-15 15:53:44 +00:00
body: requestBody,
).timeout(const Duration(seconds: 60)); // 拡張: 60秒 (画像サイズ最適化済み)
2026-01-15 15:53:44 +00:00
// 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 {
2026-01-15 15:53:44 +00:00
// Proxy側での論理エラー (レート制限超過など)
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
}
2026-01-15 15:53:44 +00:00
} 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明日またお試しください。');
}
2026-01-15 15:53:44 +00:00
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;
2026-01-15 15:53:44 +00:00
// 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 {},
2026-01-15 15:53:44 +00:00
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,
2026-01-15 15:53:44 +00:00
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,
};
}
}