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

265 lines
9.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)');
}
}