677 lines
28 KiB
Dart
677 lines
28 KiB
Dart
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';
|
||
import 'gemini_exceptions.dart';
|
||
|
||
|
||
class GeminiService {
|
||
// AI Proxy Server Configuration
|
||
static final String _proxyUrl = Secrets.aiProxyAnalyzeUrl;
|
||
|
||
static DateTime? _lastApiCallTime;
|
||
static const Duration _minApiInterval = Duration(seconds: 2);
|
||
|
||
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,
|
||
);
|
||
}
|
||
|
||
/// 再解析専用メソッド: 前回の結果を「疑わしい」として渡し、モデルに再考させる
|
||
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 文字のみ返す」を厳守してください
|
||
|
||
## 【絶対ルール】name・brand・prefectureの読み取り(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 = '''
|
||
日本酒ラベルの画像から、銘柄名・蔵元名・都道府県名の3つだけをOCRしてください。
|
||
|
||
【絶対ルール】
|
||
- ラベルに印刷された文字だけを一字一句そのまま出力する
|
||
- 補完・変換・拡張は厳禁(例: 「東魁」→「東魁盛」禁止)
|
||
- ラベルに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 '''
|
||
あなたは日本酒ラベル解析の専門家です。
|
||
|
||
【ステップ1のOCR結果 — 以下3フィールドは変更厳禁】
|
||
別ステップで画像から厳密にOCRした確定結果です。あなたの知識で書き換えることは絶対に禁止です。
|
||
- name: $nameConstraint
|
||
- brand: $brandConstraint
|
||
- prefecture: $prefConstraint
|
||
|
||
上記3フィールドをそのままJSONに含め、残りのフィールドをラベル情報と日本酒知識から推定してください。
|
||
|
||
## 推定フィールド(ラベル+一般知識から推定可)
|
||
- type: ラベルに書かれた特定名称(純米大吟醸など)。なければ null
|
||
- description: ラベル情報と type から推定した味・特徴の説明(100文字程度)
|
||
- catchCopy: 20文字以内のキャッチコピー
|
||
- flavorTags: 味のタグ(フルーティー・辛口・華やか など)
|
||
- tasteStats: 1〜5の整数。不明なら 3
|
||
- alcoholContent: ラベルに記載があれば読む。なければ type から推定
|
||
- polishingRatio: ラベルに記載があれば読む。なければ type から推定
|
||
- sakeMeterValue: ラベルに記載があれば読む。なければ推定
|
||
- riceVariety: ラベルに記載があれば読む。なければ null
|
||
- yeast: ラベルに記載があれば読む。なければ null
|
||
- manufacturingYearMonth: ラベルに記載があれば読む。なければ null
|
||
- confidenceScore: 画像の鮮明度・情報量から 0〜100 で評価
|
||
|
||
## 出力形式
|
||
以下の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を返してください。
|
||
|
||
## 【絶対ルール】name・brand・prefectureの読み取り(OCR厳守)
|
||
これら3フィールドは、ラベルに印刷されている文字だけを一字一句そのまま出力してください。
|
||
あなたが知っている銘柄知識でラベルの文字を補完・変換・拡張することは厳禁です。
|
||
|
||
【具体的な禁止例】
|
||
- ラベルに「東魁」(2文字) → "東魁" のまま出力(「東魁盛」への変換禁止)
|
||
- ラベルに「白鹿」(2文字) → "白鹿" のまま出力(「白鹿本醸造」への変換禁止)
|
||
- ラベルに「久保田」(3文字) → "久保田" のまま出力(「久保田 千寿」への変換禁止)
|
||
- ラベルに「男山」(2文字) → "男山" のまま出力(「男山本醸造」への変換禁止)
|
||
- ラベルに「白鶴」(2文字) → "白鶴" のまま出力(「白鶴まる」への変換禁止)
|
||
- ラベルに「松竹梅」(3文字) → "松竹梅" のまま出力(「松竹梅 白壁蔵」への変換禁止)
|
||
|
||
【一般原則】ラベルに N 文字しか見えない場合は N 文字のみ出力する。
|
||
文字数を増やすことは、たとえあなたが正式名称を知っていても禁止。
|
||
|
||
- prefecture: ラベルに都道府県名が書かれていればそのまま出力、なければ null
|
||
(銘柄名・蔵元名から産地を推測して埋めることは禁止)
|
||
|
||
## 【出力前セルフチェック】
|
||
name・brand を出力する直前に以下を確認してください:
|
||
- ラベル画像で実際に見えている文字数と、出力しようとしている文字数が一致するか?
|
||
- あなたの知識による「補完」が入っていないか?
|
||
不一致の場合は、ラベルに見えている文字数に合わせて修正してください。
|
||
|
||
## その他のフィールド(推定可)
|
||
以下はラベル情報+日本酒の一般知識を使って推定してください。
|
||
- type: ラベルに書かれた特定名称(純米大吟醸など)。なければ null
|
||
- description: ラベル情報と type から推定した味・特徴の説明(100文字程度)
|
||
- catchCopy: 20文字以内のキャッチコピー
|
||
- flavorTags: 味のタグ(フルーティー・辛口・華やか など)
|
||
- tasteStats: 1〜5の整数。ラベルや type から推定。不明なら 3
|
||
- alcoholContent: ラベルに記載があれば読む。なければ type から一般的な値を推定(例: 純米大吟醸→15.0)
|
||
- polishingRatio: ラベルに記載があれば読む。なければ type から推定(例: 大吟醸→50)
|
||
- sakeMeterValue: ラベルに記載があれば読む。なければ推定
|
||
- riceVariety: ラベルに記載があれば読む。なければ null
|
||
- yeast: ラベルに記載があれば読む。なければ null
|
||
- manufacturingYearMonth: ラベルに記載があれば読む。なければ null
|
||
- confidenceScore: 画像の鮮明度・情報量から 0〜100 で評価
|
||
|
||
## 出力形式
|
||
以下の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 時)
|
||
// ============================================================
|
||
|
||
Future<SakeAnalysisResult> _callProxyApi({
|
||
required List<String> imagePaths,
|
||
String? customPrompt,
|
||
bool forceRefresh = false,
|
||
}) 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();
|
||
}
|
||
}
|
||
|
||
try {
|
||
// 2. レート制限 (クライアント側連打防止)
|
||
if (_lastApiCallTime != null) {
|
||
final elapsed = DateTime.now().difference(_lastApiCallTime!);
|
||
if (elapsed < _minApiInterval) {
|
||
await Future.delayed(_minApiInterval - elapsed);
|
||
}
|
||
}
|
||
_lastApiCallTime = DateTime.now();
|
||
|
||
// 3. 画像をBase64変換(撮影時に圧縮済み)
|
||
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取得
|
||
final deviceId = await DeviceService.getDeviceId();
|
||
if (kDebugMode) debugPrint('Device ID: $deviceId');
|
||
|
||
// 5. リクエスト作成
|
||
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}",
|
||
};
|
||
final response = await http.post(
|
||
Uri.parse(_proxyUrl),
|
||
headers: headers,
|
||
body: requestBody,
|
||
).timeout(const Duration(seconds: 60));
|
||
|
||
// 7. レスポンス処理
|
||
if (response.statusCode == 200) {
|
||
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);
|
||
|
||
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 {
|
||
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
|
||
}
|
||
} else {
|
||
if (kDebugMode) {
|
||
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;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 直接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;
|
||
|
||
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 {},
|
||
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,
|
||
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,
|
||
};
|
||
}
|
||
}
|