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
// AnalysisCacheService.init()Lazy実装されているため
//
// v1.0.33: AI Hive
//
for (final boxName in ['analysis_cache_v1', 'brand_index_v1']) {
try {
await Hive.deleteBoxFromDisk(boxName);
} catch (_) {
//
}
}
runApp(
const ProviderScope(

View File

@ -110,7 +110,7 @@ class DevMenuScreen extends ConsumerWidget {
content: const Text(
'AI解析キャッシュをクリアしますか\n'
'\n'
'同じ日本酒を再解析する場合、APIを再度呼び出します。',
'セッション内キャッシュを削除します。アプリ再起動でも自動的にクリアされます。',
),
actions: [
TextButton(

View File

@ -1,264 +1,121 @@
import 'dart:io';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart';
import 'gemini_service.dart';
/// AI解析キャッシュサービス
/// AI
///
/// :
/// - API呼び出しは1回のみ
/// - Gemini API制限120
/// ##
/// Hive使
///
/// :
/// 1. SHA-256
/// 2. Hiveに{hash: }
/// 3.
/// ###
/// -
///
/// - Gemini API 使API
/// - 30
/// - 23 vs 39 =
///
///
/// ###
/// - API
/// -
class AnalysisCacheService {
static const String _cacheBoxName = 'analysis_cache_v1';
static const String _brandIndexBoxName = 'brand_index_v1'; //
static Box<String>? _box;
static Box<String>? _brandIndexBox;
// in-memory analysis cache: imageHash -> result
static final Map<String, SakeAnalysisResult> _cache = {};
///
static Future<void> init() async {
if (_box != null && _brandIndexBox != null) return; //
// in-memory brand index: normalized brand name -> imageHash
static final Map<String, String> _brandIndex = {};
try {
_box = await Hive.openBox<String>(_cacheBoxName);
_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');
}
}
// ============================================================
// Hash computation
// ============================================================
/// SHA-256
///
///
/// : "abc123..." (6416)
/// SHA-256
static Future<String> computeImageHash(String imagePath) async {
try {
final bytes = await File(imagePath).readAsBytes();
final digest = sha256.convert(bytes);
return digest.toString();
return sha256.convert(bytes).toString();
} catch (e) {
debugPrint('Hash computation failed: $e');
// 使
// 使
return imagePath;
}
}
///
///
///
/// : ["image1.jpg", "image2.jpg"] "combined_hash_abc123..."
///
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((path) => computeImageHash(path)),
imagePaths.map(computeImageHash),
);
final combined = hashes.join('_');
return sha256.convert(utf8.encode(combined)).toString();
return sha256.convert(utf8.encode(hashes.join('_'))).toString();
}
///
///
/// : null
/// JSON
// ============================================================
// Cache read / write
// ============================================================
/// null
static Future<SakeAnalysisResult?> getCached(String imageHash) async {
await init();
if (_box == null) return null;
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;
final result = _cache[imageHash];
if (result != null) {
debugPrint('Cache HIT: ${result.name ?? 'Unknown'}');
}
return result;
}
///
///
/// JSON Hive
static Future<void> saveCache(String imageHash, SakeAnalysisResult result) async {
await init();
if (_box == null) return;
try {
final entry = {
'data': result.toJson(),
'savedAt': DateTime.now().toIso8601String(),
};
await _box!.put(imageHash, jsonEncode(entry));
static Future<void> saveCache(
String imageHash, SakeAnalysisResult result) async {
_cache[imageHash] = result;
debugPrint('Cache saved: ${result.name ?? 'Unknown'}');
} catch (e) {
debugPrint('Cache save error: $e');
}
}
///
///
static Future<void> clearAll() async {
await init();
if (_box == null) return;
final count = _box!.length;
await _box!.clear();
final count = _cache.length;
_cache.clear();
_brandIndex.clear();
debugPrint('Cache cleared ($count entries deleted)');
}
///
static Future<int> getCacheSize() async {
await init();
return _box?.length ?? 0;
}
///
static Future<int> getCacheSize() async => _cache.length;
/// 30
///
///
///
/// _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;
}
}
// ============================================================
// Brand index
// ============================================================
///
///
/// [forceUpdate] = true
/// false
static Future<void> registerBrandIndex(
String? brandName,
String imageHash, {
bool forceUpdate = false,
}) async {
if (brandName == null || brandName.isEmpty) return;
await init();
if (_brandIndexBox == null) return;
try {
final normalized = _normalizeBrandName(brandName);
final exists = _brandIndexBox!.containsKey(normalized);
final exists = _brandIndex.containsKey(normalized);
if (!exists || forceUpdate) {
await _brandIndexBox!.put(normalized, imageHash);
debugPrint('Brand index ${exists ? "updated" : "registered"}: $brandName');
} else {
debugPrint('Brand index already exists: $brandName (skipped)');
}
} catch (e) {
debugPrint('Brand index registration error: $e');
_brandIndex[normalized] = imageHash;
debugPrint(
'Brand index ${exists ? "updated" : "registered"}: $brandName');
}
}
///
///
/// ASCII文字を小文字に統一する
/// : "獺祭 純米大吟醸" "獺祭純米大吟醸"
/// : "DASSAI" "dassai"
/// .toLowerCase() ASCII
/// ASCII
/// .toLowerCase() ASCII
static String _normalizeBrandName(String name) {
return name
.replaceAll(RegExp(r'\s+'), '') //
.toLowerCase(); // ASCII
}
///
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)');
.replaceAll(RegExp(r'\s+'), '')
.toLowerCase();
}
}

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
# 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.
version: 1.0.32+39
version: 1.0.33+40
environment:
sdk: ^3.10.1