import '../models/sake_item.dart'; import '../models/sakenowa/sakenowa_models.dart'; import 'sakenowa_service.dart'; /// さけのわAPIを使用した外部レコメンデーションサービス /// /// ユーザーがまだ飲んだことがない日本酒を、 /// 五味チャートの好みに基づいておすすめする class SakenowaRecommendationService { final SakenowaService _sakenowaService; SakenowaRecommendationService(this._sakenowaService); /// ユーザーの好みプロファイルを計算 UserFlavorProfile calculateUserProfile(List items) { if (items.isEmpty) { return UserFlavorProfile.neutral(); } double totalAroma = 0; double totalSweetness = 0; double totalAcidity = 0; double totalBitterness = 0; double totalBody = 0; int count = 0; for (final item in items) { final stats = item.hiddenSpecs.tasteStats; if (stats.isNotEmpty) { totalAroma += (stats['aroma'] ?? 3).toDouble(); totalSweetness += (stats['sweetness'] ?? 3).toDouble(); totalAcidity += (stats['acidity'] ?? 3).toDouble(); totalBitterness += (stats['bitterness'] ?? 3).toDouble(); totalBody += (stats['body'] ?? 3).toDouble(); count++; } } if (count == 0) { return UserFlavorProfile.neutral(); } return UserFlavorProfile( aroma: totalAroma / count, sweetness: totalSweetness / count, acidity: totalAcidity / count, bitterness: totalBitterness / count, body: totalBody / count, ); } /// さけのわTOP100から類似銘柄を取得 Future> getRecommendations({ required UserFlavorProfile userProfile, required List existingItems, int limit = 10, }) async { try { // ランキングとフレーバーチャートを取得 final rankings = await _sakenowaService.getRankings(); final flavorCharts = await _sakenowaService.getFlavorCharts(); final brands = await _sakenowaService.getBrands(); final breweries = await _sakenowaService.getBreweries(); final areas = await _sakenowaService.getAreas(); // 既存の銘柄名をセットに変換(重複チェック用) final existingNames = existingItems .map((i) => i.displayData.displayName.toLowerCase()) .toSet(); final recommendations = []; for (final ranking in rankings.take(100)) { // ブランド情報を取得 final brand = brands.cast().firstWhere( (b) => b?.id == ranking.brandId, orElse: () => null, ); if (brand == null) continue; // 既に飲んだ銘柄は除外 if (existingNames.contains(brand.name.toLowerCase())) continue; // フレーバーチャートを取得 final chart = flavorCharts.cast().firstWhere( (c) => c?.brandId == ranking.brandId, orElse: () => null, ); // 蔵元情報 final brewery = breweries.cast().firstWhere( (b) => b?.id == brand.breweryId, orElse: () => null, ); // 地域情報 final area = areas.cast().firstWhere( (a) => a?.id == brewery?.areaId, orElse: () => null, ); // マッチ度を計算 final matchScore = _calculateMatchScore(userProfile, chart); recommendations.add(SakenowaRecommendation( brand: brand, brewery: brewery, area: area, flavorChart: chart, rank: ranking.rank, matchScore: matchScore, )); } // マッチ度でソートして上位を返す recommendations.sort((a, b) => b.matchScore.compareTo(a.matchScore)); return recommendations.take(limit).toList(); } catch (e) { // エラー時は空リストを返す return []; } } /// ユーザー好みとさけのわフレーバーのマッチ度を計算 double _calculateMatchScore( UserFlavorProfile user, SakenowaFlavorChart? chart, ) { if (chart == null) return 0.5; // デフォルト中程度 // 六軸を五味にマッピング // 華やか・芳醇 → 香り // 芳醇 → 甘み // 軽快 → 酸味/キレ // 重厚 → コク // 各軸は0.0〜1.0の値 final f1 = chart.f1; // 華やか final f2 = chart.f2; // 芳醇 // final f3 = chart.f3; // 重厚 // final f4 = chart.f4; // 穏やか // final f5 = chart.f5; // ドライ final f6 = chart.f6; // 軽快 // ユーザー好みを0.0〜1.0に正規化(1〜5を0〜1に) double normalize(double value) => (value - 1) / 4; final userAroma = normalize(user.aroma); final userSweetness = normalize(user.sweetness); final userAcidity = normalize(user.acidity); // 各要素の差分を計算 double diff = 0; diff += (userAroma - (f1 + f2) / 2).abs(); // 香り ↔ 華やか・芳醇 diff += (userSweetness - f2).abs(); // 甘み ↔ 芳醇 diff += (userAcidity - f6).abs(); // 酸味 ↔ 軽快 // 差分をスコアに変換(差が小さいほど高スコア) // 最大差は3.0(各要素1.0ずつ) final similarity = 1.0 - (diff / 3.0); // 0.4〜1.0の範囲にスケール return 0.4 + (similarity * 0.6); } } /// ユーザーの好みプロファイル class UserFlavorProfile { final double aroma; final double sweetness; final double acidity; final double bitterness; final double body; UserFlavorProfile({ required this.aroma, required this.sweetness, required this.acidity, required this.bitterness, required this.body, }); /// デフォルト(ニュートラル)プロファイル factory UserFlavorProfile.neutral() { return UserFlavorProfile( aroma: 3.0, sweetness: 3.0, acidity: 3.0, bitterness: 3.0, body: 3.0, ); } } /// さけのわおすすめ結果 class SakenowaRecommendation { final SakenowaBrand brand; final SakenowaBrewery? brewery; final SakenowaArea? area; final SakenowaFlavorChart? flavorChart; final int rank; final double matchScore; SakenowaRecommendation({ required this.brand, this.brewery, this.area, this.flavorChart, required this.rank, required this.matchScore, }); /// マッチパーセンテージ int get matchPercent => (matchScore * 100).round(); /// 地域名(都道府県) String get areaName => area?.name ?? '不明'; /// 蔵元名 String get breweryName => brewery?.name ?? '不明'; /// フレーバー特徴(上位2つ) List get flavorFeatures { if (flavorChart == null) return []; final labels = { 'f1': '華やか', 'f2': '芳醇', 'f3': '重厚', 'f4': '穏やか', 'f5': 'ドライ', 'f6': '軽快', }; final values = { 'f1': flavorChart!.f1, 'f2': flavorChart!.f2, 'f3': flavorChart!.f3, 'f4': flavorChart!.f4, 'f5': flavorChart!.f5, 'f6': flavorChart!.f6, }; // 上位2つを取得 final sorted = values.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); return sorted .take(2) .where((e) => e.value > 0.3) // 閾値以上のみ .map((e) => labels[e.key]!) .toList(); } }