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

677 lines
28 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';
import 'gemini_exceptions.dart';
2026-01-15 15:53:44 +00:00
class GeminiService {
// AI Proxy Server Configuration
static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl;
static DateTime? _lastApiCallTime;
static const Duration _minApiInterval = Duration(seconds: 2);
2026-01-15 15:53:44 +00:00
GeminiService();
// ============================================================
// Public API
// ============================================================
/// 画像リストから日本酒ラベルを解析2段階解析: OCR → フル解析)
///
/// [onStep1Complete]: Stage 1 完了時に呼ばれるコールバック。
/// UI 側でダイアログのメッセージをステージ2用に切り替えるために使う。
/// 直接APIモードconsumer APKのみ有効。プロキシモードは1段階のまま。
Future<SakeAnalysisResult> analyzeSakeLabel(
List<String> imagePaths, {
bool forceRefresh = false,
VoidCallback? onStep1Complete,
}) async {
if (Secrets.useProxy) {
return _callProxyApi(
imagePaths: imagePaths,
customPrompt: _mainAnalysisPrompt,
forceRefresh: forceRefresh,
);
}
return _runTwoStageAnalysis(
imagePaths,
forceRefresh: forceRefresh,
onStep1Complete: onStep1Complete,
2026-01-15 15:53:44 +00:00
);
}
/// 再解析専用メソッド: 前回の結果を「疑わしい」として渡し、モデルに再考させる
Future<SakeAnalysisResult> reanalyzeSakeLabel(
List<String> imagePaths, {
String? previousName,
String? previousBrand,
}) async {
final prevNameStr = previousName != null ? '$previousName' : '不明';
final prevBrandStr = previousBrand != null ? '$previousBrand' : '不明';
final challengePrompt = '''
:
- name: $prevNameStr
- brand: $prevBrandStr
##
1. 1
2. name=$prevNameStr
3.
4. N N
## namebrandprefectureの読み取りOCR厳守
-
-
- /
- prefecture null
##
使
##
JSONのみ返す:
{
"name": "ラベルに写っている銘柄名(補完禁止)",
"brand": "ラベルに写っている蔵元名(補完禁止)",
"prefecture": "ラベルに書かれた都道府県名なければnull",
"type": "特定名称なければnull",
"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": null,
"yeast": null,
"manufacturingYearMonth": null
}
''';
return _callDirectApi(
imagePaths,
challengePrompt,
forceRefresh: true,
temperature: 0.3,
);
}
// ============================================================
// 2段階解析直接APIモード専用
// ============================================================
/// Stage1(OCR) → Stage2(フル解析) の2段階フロー
Future<SakeAnalysisResult> _runTwoStageAnalysis(
List<String> imagePaths, {
bool forceRefresh = false,
VoidCallback? onStep1Complete,
}) async {
// Stage1実行前にキャッシュ確認ヒットすれば API 呼び出しなし)
if (!forceRefresh && imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
final cached = await AnalysisCacheService.getCached(imageHash);
if (cached != null) {
debugPrint('2-stage: cache hit, skipping API calls');
return cached.asCached();
}
}
final apiKey = Secrets.geminiApiKey;
if (apiKey.isEmpty) throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
// 画像を一度だけ読み込み、Stage1/2で共用ファイルI/O節約
final imageParts = <DataPart>[];
for (final path in imagePaths) {
final bytes = await File(path).readAsBytes();
imageParts.add(DataPart('image/jpeg', bytes));
debugPrint('Loaded image for 2-stage: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
}
// --- Stage 1: OCR専念30秒タイムアウト・失敗時は1段階フォールバック---
Map<String, String?> ocr;
try {
ocr = await _performOcrStep(apiKey, imageParts);
debugPrint('Stage1 OCR: name="${ocr['name']}" brand="${ocr['brand']}" pref="${ocr['prefecture']}"');
} catch (e) {
debugPrint('Stage1 OCR failed ($e), falling back to single-stage');
return _callDirectApi(imagePaths, null, forceRefresh: forceRefresh);
}
// Stage1 で name/brand が両方 null = ラベルを読めなかった → 2段階の意味なし
if (ocr['name'] == null && ocr['brand'] == null) {
debugPrint('Stage1 returned no text, falling back to single-stage');
return _callDirectApi(imagePaths, null, forceRefresh: forceRefresh);
}
// Stage 1 完了を UI に通知AnalyzingDialog のメッセージを Stage2 用に切り替え)
onStep1Complete?.call();
// --- Stage 2: OCR結果を制約として渡し、残りフィールドを推定 ---
// _callDirectApi は内部でキャッシュ保存・リトライを行うStage2 も同じ堅牢性を持つ)
// forceRefresh=false で呼ぶと内部でキャッシュ再チェックが走るが、
// 上の確認でミス済みのため実害なし(ハッシュ計算のみ)
final stage2Prompt = _buildStage2Prompt(ocr);
return _callDirectApi(imagePaths, stage2Prompt, forceRefresh: forceRefresh);
}
/// Stage 1: ラベルのOCRのみ実行name / brand / prefecture を確定させる)
///
/// 軽量プロンプトで素早く文字起こし。補完・変換は完全禁止。
/// 失敗時は呼び出し元がフォールバックを担当するため、ここでは rethrow。
Future<Map<String, String?>> _performOcrStep(
String apiKey,
List<DataPart> imageParts,
) async {
const ocrPrompt = '''
3OCRしてください
-
- :
- N文字しかなければN文字のみ出力する
JSONのみ返す:
{"name": "銘柄名", "brand": "蔵元名", "prefecture": "都道府県名またはnull"}
''';
final model = GenerativeModel(
model: 'gemini-2.5-flash',
apiKey: apiKey,
systemInstruction: Content.system(
'あなたはOCR専用システムです。ラベルの文字を一字一句正確に書き起こすだけです。'
'銘柄名の補完・変換・拡張は厳禁。見えている文字数と出力文字数を一致させること。',
),
generationConfig: GenerationConfig(
responseMimeType: 'application/json',
temperature: 0,
),
);
final parts = <Part>[TextPart(ocrPrompt), ...imageParts];
final response = await model
.generateContent([Content.multi(parts)])
.timeout(const Duration(seconds: 30));
final jsonStr = response.text;
if (jsonStr == null || jsonStr.isEmpty) {
throw Exception('Stage1: empty response');
}
final map = jsonDecode(jsonStr) as Map<String, dynamic>;
return {
'name': map['name'] as String?,
'brand': map['brand'] as String?,
'prefecture': map['prefecture'] as String?,
};
}
/// Stage 2 用プロンプト: Stage 1 の OCR 結果を「確定済み制約」として埋め込む
///
/// Gemini は name/brand/prefecture をそのまま出力し、
/// 残りフィールドのみ推定に集中することで hallucination を低減する。
String _buildStage2Prompt(Map<String, String?> ocr) {
final name = ocr['name'];
final brand = ocr['brand'];
final prefecture = ocr['prefecture'];
final nameConstraint = name != null ? '$name」(確定済み — 変更禁止)' : 'null確定済み';
final brandConstraint = brand != null ? '$brand」(確定済み — 変更禁止)' : 'null確定済み';
final prefConstraint = prefecture != null ? '$prefecture」(確定済み — 変更禁止)' : 'null確定済み';
final nameJson = name != null ? jsonEncode(name) : 'null';
final brandJson = brand != null ? jsonEncode(brand) : 'null';
final prefJson = prefecture != null ? jsonEncode(prefecture) : 'null';
return '''
1OCR結果 3
OCRした確定結果です
- name: $nameConstraint
- brand: $brandConstraint
- prefecture: $prefConstraint
3JSONに含め
##
- type: null
- description: type 100
- catchCopy: 20
- flavorTags:
- tasteStats: 15 3
- alcoholContent: type
- polishingRatio: type
- sakeMeterValue:
- riceVariety: null
- yeast: null
- manufacturingYearMonth: null
- confidenceScore: 0100
##
JSONのみ返す:
{
"name": $nameJson,
"brand": $brandJson,
"prefecture": $prefJson,
"type": "特定名称なければnull",
"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": null,
"yeast": null,
"manufacturingYearMonth": null
}
''';
}
// ============================================================
// 既存の1段階プロンプトプロキシモード・フォールバック用
// ============================================================
static const String _mainAnalysisPrompt = '''
JSONを返してください
## namebrandprefectureの読み取りOCR厳守
3
- (2) "東魁"
- 鹿(2) "白鹿" 鹿
- (3) "久保田" 寿
- (2) "男山"
- (2) "白鶴"
- (3) "松竹梅"
N N
- prefecture: null
##
namebrand
-
-
##
使
- type: null
- description: type 100
- catchCopy: 20
- flavorTags:
- tasteStats: 15 type 3
- alcoholContent: type : 15.0
- polishingRatio: type : 50
- sakeMeterValue:
- riceVariety: null
- yeast: null
- manufacturingYearMonth: null
- confidenceScore: 0100
##
JSONのみ返す:
{
"name": "ラベルに写っている銘柄名の文字(一字一句そのまま・補完禁止)",
"brand": "ラベルに写っている蔵元名の文字(一字一句そのまま・補完禁止)",
"prefecture": "ラベルに書かれた都道府県名なければnull・推測禁止",
"type": "特定名称ラベルから読む。なければnull",
"description": "ラベル情報とtypeから推定した説明文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"
}
''';
// ============================================================
// プロキシ経由APIコールiOSビルド用 / USE_PROXY=true 時)
// ============================================================
2026-01-15 15:53:44 +00:00
Future<SakeAnalysisResult> _callProxyApi({
required List<String> imagePaths,
String? customPrompt,
bool forceRefresh = false,
2026-01-15 15:53:44 +00:00
}) async {
// 1. キャッシュチェックforceRefresh=false のときのみ)
if (!forceRefresh && imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
final cached = await AnalysisCacheService.getCached(imageHash);
if (cached != null) {
debugPrint('Proxy cache hit: skipping API call');
return cached.asCached();
}
}
2026-01-15 15:53:44 +00:00
try {
// 2. レート制限 (クライアント側連打防止)
2026-01-15 15:53:44 +00:00
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();
// 3. 画像をBase64変換撮影時に圧縮済み
2026-01-15 15:53:44 +00:00
List<String> base64Images = [];
for (final path in imagePaths) {
final bytes = await File(path).readAsBytes();
final base64String = base64Encode(bytes);
base64Images.add(base64String);
debugPrint('Encoded processed image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
}
// 4. デバイスID取得
2026-01-15 15:53:44 +00:00
final deviceId = await DeviceService.getDeviceId();
if (kDebugMode) debugPrint('Device ID: $deviceId');
2026-01-15 15:53:44 +00:00
// 5. リクエスト作成
2026-01-15 15:53:44 +00:00
final requestBody = jsonEncode({
"device_id": deviceId,
"images": base64Images,
"prompt": customPrompt,
});
debugPrint('Calling Proxy: $_proxyUrl');
// 6. 送信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));
2026-01-15 15:53:44 +00:00
// 7. レスポンス処理
2026-01-15 15:53:44 +00:00
if (response.statusCode == 200) {
final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes));
2026-01-15 15:53:44 +00:00
if (jsonResponse['success'] == true) {
final data = jsonResponse['data'];
if (data == null) throw Exception("サーバーからのデータが空です");
2026-01-15 15:53:44 +00:00
if (jsonResponse['usage'] != null) {
final usage = jsonResponse['usage'];
debugPrint('API Usage: ${usage['today']}/${usage['limit']}');
}
final result = SakeAnalysisResult.fromJson(data);
if (imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
await AnalysisCacheService.saveCache(imageHash, result);
await AnalysisCacheService.registerBrandIndex(
result.name,
imageHash,
forceUpdate: forceRefresh,
);
}
return result;
} else {
2026-01-15 15:53:44 +00:00
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
}
2026-01-15 15:53:44 +00:00
} else {
if (kDebugMode) {
debugPrint('Proxy Error: ${response.statusCode} ${response.body}');
}
2026-01-15 15:53:44 +00:00
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;
}
}
// ============================================================
// 直接APIコールconsumer APK 用 / USE_PROXY=false 時)
// ============================================================
Future<SakeAnalysisResult> _callDirectApi(
List<String> imagePaths,
String? customPrompt, {
bool forceRefresh = false,
double temperature = 0,
}) async {
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.asCached();
}
}
final apiKey = Secrets.geminiApiKey;
if (apiKey.isEmpty) {
throw Exception('Gemini API Key is missing. Please set GEMINI_API_KEY.');
}
const primaryModel = 'gemini-2.5-flash';
const fallbackModel = 'gemini-2.0-flash';
final promptText = customPrompt ?? _mainAnalysisPrompt;
final contentParts = <Part>[TextPart(promptText)];
for (var path in imagePaths) {
final bytes = await File(path).readAsBytes();
contentParts.add(DataPart('image/jpeg', bytes));
}
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(
'あなたは画像内のテキストを一字一句正確に書き起こすOCR光学文字認識専門システムです。\n'
'【絶対的制約 — name・brand・prefecture フィールドに適用】\n'
'1. ラベルに印刷されている文字だけを出力する。ラベルにない文字を1文字も追加してはならない。\n'
'2. ラベルに N 文字の銘柄名があれば N 文字のまま出力する。文字数を増やすことは禁止。\n'
'3. あなたが知っている「正式名称」「有名銘柄名」への変換・補完は禁止。\n'
' 例: 「東魁」→「東魁」(「東魁盛」禁止)、「男山」→「男山」(「男山本醸造」禁止)、\n'
' 「白鹿」→「白鹿」(「白鹿本醸造」禁止)、「久保田」→「久保田」(「久保田 千寿」禁止)\n'
'4. ラベルに都道府県名がなければ prefecture は null。銘柄名から産地を推測して埋めることは禁止。\n'
'5. 日本酒知識は description・flavorTags・tasteStats 等の推定フィールドにのみ使用すること。',
),
generationConfig: GenerationConfig(
responseMimeType: 'application/json',
temperature: temperature,
),
);
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);
if (imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
await AnalysisCacheService.saveCache(imageHash, result);
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) {
if (is503) throw const GeminiCongestionException();
throw Exception('AI解析エラー(Direct): $e');
}
}
}
throw Exception('AI解析に失敗しました。再試行してください。');
}
}
// ============================================================
// Data Models
// ============================================================
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
final double? alcoholContent;
final int? polishingRatio;
final double? sakeMeterValue;
final String? riceVariety;
final String? yeast;
final String? manufacturingYearMonth;
/// キャッシュから返された結果かどうかEXP付与・新規登録の判定に使用
final bool isFromCache;
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,
this.isFromCache = false,
});
SakeAnalysisResult asCached() => SakeAnalysisResult(
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,
isFromCache: true,
);
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
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).clamp(1, 5),
));
}
for (final key in requiredStatKeys) {
stats.putIfAbsent(key, () => 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?,
);
}
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,
};
}
}