import '../models/sake_item.dart'; import '../models/sakenowa/sakenowa_models.dart'; import 'sakenowa_service.dart'; import 'dart:math' as math; /// さけのわ類似推薦サービス /// /// ユーザーの特定の日本酒に類似したさけのわランキング銘柄を推薦 /// 「この銘柄が好きなら、こちらもおすすめ」形式 class SakenowaSimilarRecommendationService { final SakenowaService _sakenowaService; SakenowaSimilarRecommendationService(this._sakenowaService); /// 類似銘柄を推薦 /// /// [baseSake]: 基準となる日本酒 /// [limit]: 推薦数(デフォルト: 5) /// [excludeOwned]: ユーザーが既に持っている銘柄を除外するか(デフォルト: true) /// [userItems]: ユーザーの全日本酒リスト(除外判定用) Future> getSimilarFromSakenowa({ required SakeItem baseSake, int limit = 5, bool excludeOwned = true, List? userItems, }) async { // さけのわデータ取得 final rankings = await _sakenowaService.getRankings(); final brands = await _sakenowaService.getBrands(); final flavorCharts = await _sakenowaService.getFlavorCharts(); // マップ化 final brandMap = {for (var b in brands) b.id: b}; final chartMap = {for (var c in flavorCharts) c.brandId: c}; // 既飲銘柄名セット(小文字変換) final ownedNames = userItems != null ? userItems.map((i) => i.displayData.displayName.toLowerCase()).toSet() : {}; // baseSakeの味覚データ取得(6軸優先) final baseTaste = baseSake.hiddenSpecs.activeTasteData; // ランキングとの類似度計算 final scoredRankings = []; for (final ranking in rankings.take(100)) { final brand = brandMap[ranking.brandId]; final chart = chartMap[ranking.brandId]; if (brand == null || chart == null) continue; // 除外判定 if (excludeOwned && ownedNames.contains(brand.name.toLowerCase())) { continue; // 既に持っている銘柄はスキップ } // 基準銘柄自身もスキップ if (brand.name.toLowerCase() == baseSake.displayData.displayName.toLowerCase()) { continue; } // さけのわフレーバーを五味に変換 final targetTaste = chart.toFiveAxisTaste(); // 類似度計算(コサイン類似度) final similarity = _calculateCosineSimilarity(baseTaste, targetTaste); scoredRankings.add(SimilarSakeRecommendation( ranking: ranking, brand: brand, flavorChart: chart, similarityScore: similarity, reason: _generateReason(baseSake, chart, similarity), )); } // 類似度でソート(降順) scoredRankings.sort((a, b) => b.similarityScore.compareTo(a.similarityScore)); // 上位N件を返す return scoredRankings.take(limit).toList(); } /// コサイン類似度計算 /// /// 2つの味覚ベクトルの類似度を0.0-1.0で返す /// 1.0に近いほど類似 double _calculateCosineSimilarity( Map taste1, Map taste2, ) { // 共通の軸のみ使用 final keys = taste1.keys.toSet().intersection(taste2.keys.toSet()); if (keys.isEmpty) return 0.0; double dotProduct = 0.0; double magnitude1 = 0.0; double magnitude2 = 0.0; for (final key in keys) { final v1 = taste1[key] ?? 0.0; final v2 = taste2[key] ?? 0.0; dotProduct += v1 * v2; magnitude1 += v1 * v1; magnitude2 += v2 * v2; } final magnitude = math.sqrt(magnitude1) * math.sqrt(magnitude2); if (magnitude == 0) return 0.0; return (dotProduct / magnitude).clamp(0.0, 1.0); } /// 推薦理由生成 String _generateReason( SakeItem baseSake, SakenowaFlavorChart targetChart, double similarity, ) { // baseSakeの五味チャート final baseTaste = baseSake.hiddenSpecs.activeTasteData; // targetの五味チャート final targetTaste = targetChart.toFiveAxisTaste(); // 最も類似している軸を2つ抽出 final similarities = {}; for (final key in baseTaste.keys) { final diff = (baseTaste[key]! - (targetTaste[key] ?? 0.5)).abs(); similarities[key] = 1.0 - diff; // 差が小さいほど類似度高い } final sortedAxes = similarities.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); final topAxes = sortedAxes.take(2).toList(); // 日本語ラベル const axisLabels = { 'aroma': '香り', 'sweetness': '甘み', 'acidity': '酸味', 'bitterness': 'キレ', 'body': 'コク', }; if (topAxes.length >= 2) { final label1 = axisLabels[topAxes[0].key] ?? topAxes[0].key; final label2 = axisLabels[topAxes[1].key] ?? topAxes[1].key; return '$label1と$label2が似ています'; } else if (topAxes.length == 1) { final label1 = axisLabels[topAxes[0].key] ?? topAxes[0].key; return '${label1}が似ています'; } return '味わいが似ています'; } } /// 類似銘柄推薦結果 class SimilarSakeRecommendation { final SakenowaRanking ranking; final SakenowaBrand brand; final SakenowaFlavorChart flavorChart; final double similarityScore; // 0.0-1.0 final String reason; SimilarSakeRecommendation({ required this.ranking, required this.brand, required this.flavorChart, required this.similarityScore, required this.reason, }); /// パーセント表示 int get similarityPercent => (similarityScore * 100).round(); }