refactor: replace disk cache with in-memory session cache (v1.0.33)
Analysis cache redesign: - Remove Hive persistence from AnalysisCacheService entirely - Use Map<String, SakeAnalysisResult> in-memory instead of Hive boxes - Cache now lives only for the duration of an app session; restart always produces a fresh analysis — no stale misidentifications can persist - Remove Hive init(), TTL/cleanupExpired(), getCachedByBrand() dead code - API surface unchanged: callers (gemini_service, dev_menu) need no edits - main.dart: delete legacy Hive boxes (analysis_cache_v1, brand_index_v1) from disk on startup for existing users - dev_menu_screen: update cache description text to reflect new behavior Rationale: - Camera captures always produce unique files -> cache hit rate was ~0% - Each user supplies their own Gemini API key -> no shared cost benefit - Persistent wrong results (e.g. misrecognized brand names) could survive up to 30 days under the old design - Different sake editions photographed separately have different hashes and were never affected by the cache in the first place Made-with: Cursor
This commit is contained in:
parent
3d934deb56
commit
2890b6cb6f
|
|
@ -64,9 +64,15 @@ void main() async {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ AI解析キャッシュは使うときに初期化する(Lazy initialization)
|
// v1.0.33: AI 解析キャッシュをインメモリ化したため、旧 Hive ボックスをディスクから削除する
|
||||||
// AnalysisCacheService.init()はサービス内でLazy実装されているため、
|
// (既存ユーザーのデータ残骸クリーンアップ。ボックスが存在しない場合は何もしない)
|
||||||
// ここで呼び出すと起動が遅くなる。必要なときに自動初期化される。
|
for (final boxName in ['analysis_cache_v1', 'brand_index_v1']) {
|
||||||
|
try {
|
||||||
|
await Hive.deleteBoxFromDisk(boxName);
|
||||||
|
} catch (_) {
|
||||||
|
// 存在しない場合は無視
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
const ProviderScope(
|
const ProviderScope(
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ class DevMenuScreen extends ConsumerWidget {
|
||||||
content: const Text(
|
content: const Text(
|
||||||
'AI解析キャッシュをクリアしますか?\n'
|
'AI解析キャッシュをクリアしますか?\n'
|
||||||
'\n'
|
'\n'
|
||||||
'同じ日本酒を再解析する場合、APIを再度呼び出します。',
|
'セッション内キャッシュを削除します。アプリ再起動でも自動的にクリアされます。',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
|
||||||
|
|
@ -1,264 +1,121 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'gemini_service.dart';
|
import 'gemini_service.dart';
|
||||||
|
|
||||||
/// 画像ハッシュベースのAI解析キャッシュサービス
|
/// 画像ハッシュベースの AI 解析セッションキャッシュ
|
||||||
///
|
///
|
||||||
/// 目的:
|
/// ## 設計方針
|
||||||
/// - 同じ日本酒を複数回撮影してもAPI呼び出しは1回のみ
|
/// ディスクへの永続化(Hive)を行わず、アプリ起動中のみ有効なインメモリキャッシュを使用する。
|
||||||
/// - Gemini API制限(1日20回)の節約
|
|
||||||
///
|
///
|
||||||
/// 仕組み:
|
/// ### なぜインメモリか
|
||||||
/// 1. 画像のSHA-256ハッシュを計算
|
/// - カメラ撮影では毎回異なるファイルが生成されるため、実際にキャッシュがヒットする
|
||||||
/// 2. Hiveに{hash: 解析結果}を保存
|
/// ケースはごく限られる(同一セッション内でのドラフト二重処理など)
|
||||||
/// 3. 同じハッシュの画像は即座にキャッシュから返す
|
/// - 各ユーザーが自分の Gemini API キーを使うため、API コスト節約の恩恵が薄い
|
||||||
|
/// - 永続キャッシュは誤認識結果を最大30日間保持してしまうリスクがある
|
||||||
|
/// - 異なるエディション(獺祭23 vs 獺祭39 など)は異なる写真 = 異なるハッシュのため
|
||||||
|
/// 実質的にキャッシュの干渉は起きない
|
||||||
|
///
|
||||||
|
/// ### 効果が残るケース
|
||||||
|
/// - 同一セッション内で同じ画像ファイルを複数回解析しようとした場合の重複 API 防止
|
||||||
|
/// - アプリ再起動で常にクリアされるため、古い解析結果が残り続けることはない
|
||||||
class AnalysisCacheService {
|
class AnalysisCacheService {
|
||||||
static const String _cacheBoxName = 'analysis_cache_v1';
|
// in-memory analysis cache: imageHash -> result
|
||||||
static const String _brandIndexBoxName = 'brand_index_v1'; // 銘柄名→ハッシュのインデックス
|
static final Map<String, SakeAnalysisResult> _cache = {};
|
||||||
static Box<String>? _box;
|
|
||||||
static Box<String>? _brandIndexBox;
|
|
||||||
|
|
||||||
/// キャッシュボックスの初期化
|
// in-memory brand index: normalized brand name -> imageHash
|
||||||
static Future<void> init() async {
|
static final Map<String, String> _brandIndex = {};
|
||||||
if (_box != null && _brandIndexBox != null) return; // 既に初期化済み
|
|
||||||
|
|
||||||
try {
|
// ============================================================
|
||||||
_box = await Hive.openBox<String>(_cacheBoxName);
|
// Hash computation
|
||||||
_brandIndexBox = await Hive.openBox<String>(_brandIndexBoxName);
|
// ============================================================
|
||||||
debugPrint('Analysis Cache initialized (${_box!.length} entries, ${_brandIndexBox!.length} brands)');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Failed to open cache box: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 画像のSHA-256ハッシュを計算
|
/// 単一画像の SHA-256 ハッシュを計算
|
||||||
///
|
|
||||||
/// 同じ写真は同じハッシュになる(ビット完全一致)
|
|
||||||
/// 例: "abc123..." (64文字の16進数文字列)
|
|
||||||
static Future<String> computeImageHash(String imagePath) async {
|
static Future<String> computeImageHash(String imagePath) async {
|
||||||
try {
|
try {
|
||||||
final bytes = await File(imagePath).readAsBytes();
|
final bytes = await File(imagePath).readAsBytes();
|
||||||
final digest = sha256.convert(bytes);
|
return sha256.convert(bytes).toString();
|
||||||
return digest.toString();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Hash computation failed: $e');
|
debugPrint('Hash computation failed: $e');
|
||||||
// ハッシュ計算失敗時はパスをそのまま使用(キャッシュなし)
|
// ハッシュ計算失敗時はパスをそのまま使用(キャッシュなし扱い)
|
||||||
return imagePath;
|
return imagePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 複数画像の結合ハッシュを計算
|
/// 複数画像の結合ハッシュを計算(順序考慮)
|
||||||
///
|
|
||||||
/// 複数枚の写真を組み合わせた場合、順序も考慮してハッシュ化
|
|
||||||
/// 例: ["image1.jpg", "image2.jpg"] → "combined_hash_abc123..."
|
|
||||||
static Future<String> computeCombinedHash(List<String> imagePaths) async {
|
static Future<String> computeCombinedHash(List<String> imagePaths) async {
|
||||||
if (imagePaths.isEmpty) return '';
|
if (imagePaths.isEmpty) return '';
|
||||||
if (imagePaths.length == 1) return computeImageHash(imagePaths.first);
|
if (imagePaths.length == 1) return computeImageHash(imagePaths.first);
|
||||||
|
|
||||||
// 各画像のハッシュを連結してから再ハッシュ
|
|
||||||
final hashes = await Future.wait(
|
final hashes = await Future.wait(
|
||||||
imagePaths.map((path) => computeImageHash(path)),
|
imagePaths.map(computeImageHash),
|
||||||
);
|
);
|
||||||
final combined = hashes.join('_');
|
return sha256.convert(utf8.encode(hashes.join('_'))).toString();
|
||||||
return sha256.convert(utf8.encode(combined)).toString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// キャッシュから取得
|
// ============================================================
|
||||||
///
|
// Cache read / write
|
||||||
/// 戻り値: キャッシュがあれば解析結果、なければ null
|
// ============================================================
|
||||||
/// タイムスタンプ付き新形式と旧形式(直接 JSON)の両方に対応
|
|
||||||
|
/// キャッシュから取得(なければ null)
|
||||||
static Future<SakeAnalysisResult?> getCached(String imageHash) async {
|
static Future<SakeAnalysisResult?> getCached(String imageHash) async {
|
||||||
await init();
|
final result = _cache[imageHash];
|
||||||
if (_box == null) return null;
|
if (result != null) {
|
||||||
|
debugPrint('Cache HIT: ${result.name ?? 'Unknown'}');
|
||||||
try {
|
|
||||||
final jsonString = _box!.get(imageHash);
|
|
||||||
if (jsonString == null) {
|
|
||||||
debugPrint('Cache MISS: $imageHash');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(jsonString) as Map<String, dynamic>;
|
|
||||||
|
|
||||||
// 新形式: { data: {...}, savedAt: "..." }
|
|
||||||
if (decoded.containsKey('data') && decoded.containsKey('savedAt')) {
|
|
||||||
debugPrint('Cache HIT: ${decoded['data']['name'] ?? 'Unknown'}');
|
|
||||||
return SakeAnalysisResult.fromJson(decoded['data'] as Map<String, dynamic>);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 旧形式(後方互換): 直接 SakeAnalysisResult の JSON
|
|
||||||
debugPrint('Cache HIT (legacy): ${decoded['name'] ?? 'Unknown'}');
|
|
||||||
return SakeAnalysisResult.fromJson(decoded);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Cache read error: $e');
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// キャッシュに保存
|
/// キャッシュに保存
|
||||||
///
|
static Future<void> saveCache(
|
||||||
/// 解析結果を JSON 化してタイムスタンプ付きで Hive に永続化
|
String imageHash, SakeAnalysisResult result) async {
|
||||||
static Future<void> saveCache(String imageHash, SakeAnalysisResult result) async {
|
_cache[imageHash] = result;
|
||||||
await init();
|
debugPrint('Cache saved: ${result.name ?? 'Unknown'}');
|
||||||
if (_box == null) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final entry = {
|
|
||||||
'data': result.toJson(),
|
|
||||||
'savedAt': DateTime.now().toIso8601String(),
|
|
||||||
};
|
|
||||||
await _box!.put(imageHash, jsonEncode(entry));
|
|
||||||
debugPrint('Cache saved: ${result.name ?? 'Unknown'}');
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Cache save error: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// キャッシュをクリア(デバッグ用)
|
/// キャッシュを全消去(開発メニュー用)
|
||||||
static Future<void> clearAll() async {
|
static Future<void> clearAll() async {
|
||||||
await init();
|
final count = _cache.length;
|
||||||
if (_box == null) return;
|
_cache.clear();
|
||||||
|
_brandIndex.clear();
|
||||||
final count = _box!.length;
|
|
||||||
await _box!.clear();
|
|
||||||
debugPrint('Cache cleared ($count entries deleted)');
|
debugPrint('Cache cleared ($count entries deleted)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// キャッシュサイズを取得(統計用)
|
/// 現在のキャッシュ件数(開発メニュー表示用)
|
||||||
static Future<int> getCacheSize() async {
|
static Future<int> getCacheSize() async => _cache.length;
|
||||||
await init();
|
|
||||||
return _box?.length ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 30日以上経過したキャッシュエントリを削除
|
// ============================================================
|
||||||
///
|
// Brand index(銘柄名 → ハッシュ インメモリインデックス)
|
||||||
/// アプリ起動時またはバックグラウンドで呼び出す。
|
// ============================================================
|
||||||
/// 旧形式エントリ(タイムスタンプなし)は対象外として保持する。
|
|
||||||
/// _brandIndexBox の孤立エントリ(削除されたハッシュを参照するもの)も合わせて削除する。
|
|
||||||
static Future<void> cleanupExpired({int ttlDays = 30}) async {
|
|
||||||
await init();
|
|
||||||
if (_box == null) return;
|
|
||||||
|
|
||||||
final cutoff = DateTime.now().subtract(Duration(days: ttlDays));
|
|
||||||
final keysToDelete = <String>[];
|
|
||||||
|
|
||||||
for (final key in _box!.keys) {
|
|
||||||
try {
|
|
||||||
final jsonString = _box!.get(key as String);
|
|
||||||
if (jsonString == null) continue;
|
|
||||||
final decoded = jsonDecode(jsonString) as Map<String, dynamic>;
|
|
||||||
if (!decoded.containsKey('savedAt')) continue; // 旧形式は対象外
|
|
||||||
|
|
||||||
final savedAt = DateTime.tryParse(decoded['savedAt'] as String? ?? '');
|
|
||||||
if (savedAt != null && savedAt.isBefore(cutoff)) {
|
|
||||||
keysToDelete.add(key);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// 読み込みエラーのエントリは無視
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keysToDelete.isNotEmpty) {
|
|
||||||
await _box!.deleteAll(keysToDelete);
|
|
||||||
debugPrint('Cache cleanup: ${keysToDelete.length} expired entries removed');
|
|
||||||
|
|
||||||
// 削除したハッシュを参照している _brandIndexBox の孤立エントリも削除する
|
|
||||||
if (_brandIndexBox != null) {
|
|
||||||
final deletedHashes = keysToDelete.toSet();
|
|
||||||
final brandKeysToDelete = <String>[];
|
|
||||||
for (final brandKey in _brandIndexBox!.keys) {
|
|
||||||
final hash = _brandIndexBox!.get(brandKey as String);
|
|
||||||
if (hash != null && deletedHashes.contains(hash)) {
|
|
||||||
brandKeysToDelete.add(brandKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (brandKeysToDelete.isNotEmpty) {
|
|
||||||
await _brandIndexBox!.deleteAll(brandKeysToDelete);
|
|
||||||
debugPrint('Cache cleanup: ${brandKeysToDelete.length} orphaned brand index entries removed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 銘柄名ベースキャッシュ機能(v1.0.15追加)
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
/// 銘柄名でキャッシュを検索
|
|
||||||
///
|
|
||||||
/// 異なる写真でも同じ銘柄なら、以前の解析結果を返す
|
|
||||||
/// これによりチャート(tasteStats)の一貫性を保証
|
|
||||||
static Future<SakeAnalysisResult?> getCachedByBrand(String? brandName) async {
|
|
||||||
if (brandName == null || brandName.isEmpty) return null;
|
|
||||||
await init();
|
|
||||||
if (_brandIndexBox == null) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final normalized = _normalizeBrandName(brandName);
|
|
||||||
final imageHash = _brandIndexBox!.get(normalized);
|
|
||||||
if (imageHash == null) {
|
|
||||||
debugPrint('Brand Index MISS: $brandName');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('Brand Index HIT: $brandName -> $imageHash');
|
|
||||||
return getCached(imageHash);
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Brand index lookup error: $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 銘柄名をインデックスに登録
|
/// 銘柄名をインデックスに登録
|
||||||
///
|
///
|
||||||
/// [forceUpdate] = true のとき(再解析など)は既存エントリを上書きする。
|
/// [forceUpdate] = true のとき(再解析など)は既存エントリを上書きする。
|
||||||
/// false のときは最初の結果を優先し、上書きしない。
|
|
||||||
static Future<void> registerBrandIndex(
|
static Future<void> registerBrandIndex(
|
||||||
String? brandName,
|
String? brandName,
|
||||||
String imageHash, {
|
String imageHash, {
|
||||||
bool forceUpdate = false,
|
bool forceUpdate = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (brandName == null || brandName.isEmpty) return;
|
if (brandName == null || brandName.isEmpty) return;
|
||||||
await init();
|
|
||||||
if (_brandIndexBox == null) return;
|
|
||||||
|
|
||||||
try {
|
final normalized = _normalizeBrandName(brandName);
|
||||||
final normalized = _normalizeBrandName(brandName);
|
final exists = _brandIndex.containsKey(normalized);
|
||||||
final exists = _brandIndexBox!.containsKey(normalized);
|
|
||||||
|
|
||||||
if (!exists || forceUpdate) {
|
if (!exists || forceUpdate) {
|
||||||
await _brandIndexBox!.put(normalized, imageHash);
|
_brandIndex[normalized] = imageHash;
|
||||||
debugPrint('Brand index ${exists ? "updated" : "registered"}: $brandName');
|
debugPrint(
|
||||||
} else {
|
'Brand index ${exists ? "updated" : "registered"}: $brandName');
|
||||||
debugPrint('Brand index already exists: $brandName (skipped)');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Brand index registration error: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 銘柄名の正規化
|
/// 銘柄名の正規化
|
||||||
///
|
///
|
||||||
/// 全角・半角スペースを除去し、ASCII文字を小文字に統一する。
|
/// 全角・半角スペースを除去し、ASCII 文字を小文字に統一する。
|
||||||
/// 例: "獺祭 純米大吟醸" → "獺祭純米大吟醸"
|
/// 日本語文字には大文字・小文字の区別がないため .toLowerCase() は ASCII のみに作用する。
|
||||||
/// 例: "DASSAI" → "dassai"
|
|
||||||
/// 日本語文字には大文字・小文字の区別がないため、.toLowerCase() は ASCII のみに作用する。
|
|
||||||
static String _normalizeBrandName(String name) {
|
static String _normalizeBrandName(String name) {
|
||||||
return name
|
return name
|
||||||
.replaceAll(RegExp(r'\s+'), '') // 全角・半角スペース除去
|
.replaceAll(RegExp(r'\s+'), '')
|
||||||
.toLowerCase(); // ASCII ローマ字の表記ゆれを吸収(日本語には無効)
|
.toLowerCase();
|
||||||
}
|
|
||||||
|
|
||||||
/// 銘柄名インデックスをクリア(デバッグ用)
|
|
||||||
static Future<void> clearBrandIndex() async {
|
|
||||||
await init();
|
|
||||||
if (_brandIndexBox == null) return;
|
|
||||||
|
|
||||||
final count = _brandIndexBox!.length;
|
|
||||||
await _brandIndexBox!.clear();
|
|
||||||
debugPrint('Brand index cleared ($count entries deleted)');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.32+39
|
version: 1.0.33+40
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.1
|
sdk: ^3.10.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue