import 'dart:math'; import '../models/sake_item.dart'; /// 日本酒レコメンデーションサービス /// AIを使わずローカルで類似度計算(トークン消費0) class SakeRecommendationService { /// 五味チャートのコサイン類似度を計算 /// 戻り値: 0.0(全く異なる)〜 1.0(完全一致) static double calculateTasteSimilarity( Map tasteA, Map tasteB, ) { final keys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']; double dotProduct = 0; double normA = 0; double normB = 0; for (var key in keys) { final valA = (tasteA[key] ?? 3).toDouble(); final valB = (tasteB[key] ?? 3).toDouble(); dotProduct += valA * valB; normA += valA * valA; normB += valB * valB; } if (normA == 0 || normB == 0) return 0; return dotProduct / (sqrt(normA) * sqrt(normB)); } /// レコメンドスコアを計算 /// /// スコアリング基準: /// - 同じ酒蔵: +10点 /// - 同じ都道府県: +5点 /// - 共通タグ: +2点/タグ /// - 五味類似度: +15点 × 類似度(0-1) static double calculateScore(SakeItem target, SakeItem candidate) { double score = 0; // 1. 酒蔵つながり if (candidate.displayData.displayBrewery == target.displayData.displayBrewery && target.displayData.displayBrewery != '不明' && candidate.id != target.id) { score += 10; } // 2. 都道府県つながり if (candidate.displayData.displayPrefecture == target.displayData.displayPrefecture && target.displayData.displayPrefecture != '不明' && candidate.id != target.id) { score += 5; } // 3. タグつながり final commonTags = candidate.hiddenSpecs.flavorTags .where((tag) => target.hiddenSpecs.flavorTags.contains(tag)) .toList(); score += commonTags.length * 2; // 4. 五味チャート類似度 if (target.hiddenSpecs.tasteStats.isNotEmpty && candidate.hiddenSpecs.tasteStats.isNotEmpty) { final similarity = calculateTasteSimilarity( target.hiddenSpecs.tasteStats, candidate.hiddenSpecs.tasteStats, ); score += similarity * 15; } return score; } /// レコメンド理由を生成 static String getRecommendationReason(SakeItem target, SakeItem candidate) { final reasons = []; // 酒蔵つながり if (candidate.displayData.displayBrewery == target.displayData.displayBrewery && target.displayData.displayBrewery != '不明') { reasons.add('${target.displayData.displayBrewery}つながり'); } // 都道府県つながり if (candidate.displayData.displayPrefecture == target.displayData.displayPrefecture && target.displayData.displayPrefecture != '不明' && candidate.displayData.displayBrewery != target.displayData.displayBrewery) { reasons.add('${target.displayData.displayPrefecture}つながり'); } // タグつながり final commonTags = candidate.hiddenSpecs.flavorTags .where((tag) => target.hiddenSpecs.flavorTags.contains(tag)) .take(2) .toList(); if (commonTags.isNotEmpty) { reasons.add(commonTags.join('・')); } // 五味類似度 if (target.hiddenSpecs.tasteStats.isNotEmpty && candidate.hiddenSpecs.tasteStats.isNotEmpty) { final similarity = calculateTasteSimilarity( target.hiddenSpecs.tasteStats, candidate.hiddenSpecs.tasteStats, ); if (similarity > 0.8) { reasons.add('似た味わい'); } } return reasons.isEmpty ? 'おすすめ' : reasons.join(' / '); } /// スマートレコメンド(スコア順にソート) /// /// [target] 基準となる日本酒 /// [allItems] 全日本酒リスト /// [limit] 返す最大件数(デフォルト: 10) /// /// 戻り値: (日本酒, スコア, 理由) のリスト static List getRecommendations({ required SakeItem target, required List allItems, int limit = 10, }) { final scored = allItems .where((item) => item.id != target.id) // 自分自身を除外 .map((item) { final score = calculateScore(target, item); final reason = getRecommendationReason(target, item); return RecommendedSake( item: item, score: score, reason: reason, ); }).where((rec) => rec.score > 0) // スコア0より大きいもののみ .toList() ..sort((a, b) => b.score.compareTo(a.score)); // 降順ソート return scored.take(limit).toList(); } } /// レコメンド結果 class RecommendedSake { final SakeItem item; final double score; final String reason; RecommendedSake({ required this.item, required this.score, required this.reason, }); }