ponshu-room-lite/lib/services/analysis_cache_service.dart

265 lines
9.5 KiB
Dart
Raw Normal View History

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解析キャッシュサービス
///
/// 目的:
/// - 同じ日本酒を複数回撮影してもAPI呼び出しは1回のみ
/// - Gemini API制限1日20回の節約
///
/// 仕組み:
/// 1. 画像のSHA-256ハッシュを計算
/// 2. Hiveに{hash: 解析結果}を保存
/// 3. 同じハッシュの画像は即座にキャッシュから返す
class AnalysisCacheService {
static const String _cacheBoxName = 'analysis_cache_v1';
static const String _brandIndexBoxName = 'brand_index_v1'; // 銘柄名→ハッシュのインデックス
static Box<String>? _box;
static Box<String>? _brandIndexBox;
/// キャッシュボックスの初期化
static Future<void> init() async {
if (_box != null && _brandIndexBox != null) return; // 既に初期化済み
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');
}
}
/// 画像のSHA-256ハッシュを計算
///
/// 同じ写真は同じハッシュになる(ビット完全一致)
/// 例: "abc123..." (64文字の16進数文字列)
static Future<String> computeImageHash(String imagePath) async {
try {
final bytes = await File(imagePath).readAsBytes();
final digest = sha256.convert(bytes);
return digest.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)),
);
final combined = hashes.join('_');
return sha256.convert(utf8.encode(combined)).toString();
}
/// キャッシュから取得
///
/// 戻り値: キャッシュがあれば解析結果、なければ null
/// タイムスタンプ付き新形式と旧形式(直接 JSONの両方に対応
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;
}
}
/// キャッシュに保存
///
/// 解析結果を 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));
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();
debugPrint('Cache cleared ($count entries deleted)');
}
/// キャッシュサイズを取得(統計用)
static Future<int> getCacheSize() async {
await init();
return _box?.length ?? 0;
}
/// 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;
}
}
/// 銘柄名をインデックスに登録
///
/// [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);
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');
}
}
/// 銘柄名の正規化
///
/// 全角・半角スペースを除去し、ASCII文字を小文字に統一する。
/// 例: "獺祭 純米大吟醸" → "獺祭純米大吟醸"
/// 例: "DASSAI" → "dassai"
/// 日本語文字には大文字・小文字の区別がないため、.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)');
}
}