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

133 lines
4.4 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 Box<String>? _box;
/// キャッシュボックスの初期化
static Future<void> init() async {
if (_box != null) return; // 既に初期化済み
try {
_box = await Hive.openBox<String>(_cacheBoxName);
debugPrint('✅ Analysis Cache initialized (${_box!.length} entries)');
} 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日以上古いエントリを削除
}
}