import 'dart:io'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'gemini_service.dart'; /// 画像ハッシュベースの AI 解析セッションキャッシュ /// /// ## 設計方針 /// ディスクへの永続化(Hive)を行わず、アプリ起動中のみ有効なインメモリキャッシュを使用する。 /// /// ### なぜインメモリか /// - カメラ撮影では毎回異なるファイルが生成されるため、実際にキャッシュがヒットする /// ケースはごく限られる(同一セッション内でのドラフト二重処理など) /// - 各ユーザーが自分の Gemini API キーを使うため、API コスト節約の恩恵が薄い /// - 永続キャッシュは誤認識結果を最大30日間保持してしまうリスクがある /// - 異なるエディション(獺祭23 vs 獺祭39 など)は異なる写真 = 異なるハッシュのため /// 実質的にキャッシュの干渉は起きない /// /// ### 効果が残るケース /// - 同一セッション内で同じ画像ファイルを複数回解析しようとした場合の重複 API 防止 /// - アプリ再起動で常にクリアされるため、古い解析結果が残り続けることはない class AnalysisCacheService { // in-memory analysis cache: imageHash -> result static final Map _cache = {}; // in-memory brand index: normalized brand name -> imageHash static final Map _brandIndex = {}; // ============================================================ // Hash computation // ============================================================ /// 単一画像の SHA-256 ハッシュを計算 static Future computeImageHash(String imagePath) async { try { final bytes = await File(imagePath).readAsBytes(); return sha256.convert(bytes).toString(); } catch (e) { debugPrint('Hash computation failed: $e'); // ハッシュ計算失敗時はパスをそのまま使用(キャッシュなし扱い) return imagePath; } } /// 複数画像の結合ハッシュを計算(順序考慮) static Future computeCombinedHash(List imagePaths) async { if (imagePaths.isEmpty) return ''; if (imagePaths.length == 1) return computeImageHash(imagePaths.first); final hashes = await Future.wait( imagePaths.map(computeImageHash), ); return sha256.convert(utf8.encode(hashes.join('_'))).toString(); } // ============================================================ // Cache read / write // ============================================================ /// キャッシュから取得(なければ null) static Future getCached(String imageHash) async { final result = _cache[imageHash]; if (result != null) { debugPrint('Cache HIT: ${result.name ?? 'Unknown'}'); } return result; } /// キャッシュに保存 static Future saveCache( String imageHash, SakeAnalysisResult result) async { _cache[imageHash] = result; debugPrint('Cache saved: ${result.name ?? 'Unknown'}'); } /// キャッシュを全消去(開発メニュー用) static Future clearAll() async { final count = _cache.length; _cache.clear(); _brandIndex.clear(); debugPrint('Cache cleared ($count entries deleted)'); } /// 現在のキャッシュ件数(開発メニュー表示用) static Future getCacheSize() async => _cache.length; // ============================================================ // Brand index(銘柄名 → ハッシュ インメモリインデックス) // ============================================================ /// 銘柄名をインデックスに登録 /// /// [forceUpdate] = true のとき(再解析など)は既存エントリを上書きする。 static Future registerBrandIndex( String? brandName, String imageHash, { bool forceUpdate = false, }) async { if (brandName == null || brandName.isEmpty) return; final normalized = _normalizeBrandName(brandName); final exists = _brandIndex.containsKey(normalized); if (!exists || forceUpdate) { _brandIndex[normalized] = imageHash; debugPrint( 'Brand index ${exists ? "updated" : "registered"}: $brandName'); } } /// 銘柄名の正規化 /// /// 全角・半角スペースを除去し、ASCII 文字を小文字に統一する。 /// 日本語文字には大文字・小文字の区別がないため .toLowerCase() は ASCII のみに作用する。 static String _normalizeBrandName(String name) { return name .replaceAll(RegExp(r'\s+'), '') .toLowerCase(); } }