122 lines
4.7 KiB
Dart
122 lines
4.7 KiB
Dart
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<String, SakeAnalysisResult> _cache = {};
|
||
|
||
// in-memory brand index: normalized brand name -> imageHash
|
||
static final Map<String, String> _brandIndex = {};
|
||
|
||
// ============================================================
|
||
// Hash computation
|
||
// ============================================================
|
||
|
||
/// 単一画像の SHA-256 ハッシュを計算
|
||
static Future<String> 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<String> computeCombinedHash(List<String> 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<SakeAnalysisResult?> getCached(String imageHash) async {
|
||
final result = _cache[imageHash];
|
||
if (result != null) {
|
||
debugPrint('Cache HIT: ${result.name ?? 'Unknown'}');
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// キャッシュに保存
|
||
static Future<void> saveCache(
|
||
String imageHash, SakeAnalysisResult result) async {
|
||
_cache[imageHash] = result;
|
||
debugPrint('Cache saved: ${result.name ?? 'Unknown'}');
|
||
}
|
||
|
||
/// キャッシュを全消去(開発メニュー用)
|
||
static Future<void> clearAll() async {
|
||
final count = _cache.length;
|
||
_cache.clear();
|
||
_brandIndex.clear();
|
||
debugPrint('Cache cleared ($count entries deleted)');
|
||
}
|
||
|
||
/// 現在のキャッシュ件数(開発メニュー表示用)
|
||
static Future<int> getCacheSize() async => _cache.length;
|
||
|
||
// ============================================================
|
||
// Brand index(銘柄名 → ハッシュ インメモリインデックス)
|
||
// ============================================================
|
||
|
||
/// 銘柄名をインデックスに登録
|
||
///
|
||
/// [forceUpdate] = true のとき(再解析など)は既存エントリを上書きする。
|
||
static Future<void> 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();
|
||
}
|
||
}
|