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

209 lines
7.2 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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 jsonMap = jsonDecode(jsonString) as Map<String, dynamic>;
debugPrint('✅ Cache HIT: ${jsonMap['name'] ?? 'Unknown'} ($imageHash)');
return SakeAnalysisResult.fromJson(jsonMap);
} 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 jsonMap = result.toJson();
final jsonString = jsonEncode(jsonMap);
await _box!.put(imageHash, jsonString);
debugPrint('💾 Cache SAVED: ${result.name ?? 'Unknown'} ($imageHash)');
} 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日経過したキャッシュは削除
/// - 日本酒の仕様変更(リニューアル)に対応
static Future<void> cleanupExpired() async {
// TODO: 実装Phase 3
// - キャッシュにタイムスタンプを追加
// - 30日以上古いエントリを削除
}
// ============================================
// 銘柄名ベースキャッシュ機能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;
}
}
/// 銘柄名をインデックスに登録
///
/// 解析結果のキャッシュ保存後に呼び出す
/// 既にインデックスがある場合は上書きしない(最初の結果を優先)
static Future<void> registerBrandIndex(String? brandName, String imageHash) async {
if (brandName == null || brandName.isEmpty) return;
await init();
if (_brandIndexBox == null) return;
try {
final normalized = _normalizeBrandName(brandName);
// 既にインデックスがある場合は上書きしない(最初の結果を優先)
if (!_brandIndexBox!.containsKey(normalized)) {
await _brandIndexBox!.put(normalized, imageHash);
debugPrint('📝 Brand index registered: $brandName$imageHash');
} else {
debugPrint(' Brand index already exists: $brandName (skipped)');
}
} catch (e) {
debugPrint('⚠️ Brand index registration error: $e');
}
}
/// 銘柄名の正規化
///
/// スペース除去、小文字化で表記ゆれを吸収
/// 例: "獺祭 純米大吟醸" → "獺祭純米大吟醸"
static String _normalizeBrandName(String name) {
return name
.replaceAll(RegExp(r'\s+'), '') // 全角・半角スペース除去
.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)');
}
}