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:
Ponshu Developer 2026-04-12 08:13:18 +09:00
parent 3d934deb56
commit 2890b6cb6f
4 changed files with 70 additions and 207 deletions

View File

@ -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(

View File

@ -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(

View File

@ -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制限120
/// ///
/// : /// ###
/// 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..." (6416)
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();
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'}'); 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 = _brandIndexBox!.containsKey(normalized); final exists = _brandIndex.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)');
} }
} }

View File

@ -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