255 lines
7.4 KiB
Dart
255 lines
7.4 KiB
Dart
|
|
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<SakeItem> 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<List<SakenowaRecommendation>> getRecommendations({
|
|||
|
|
required UserFlavorProfile userProfile,
|
|||
|
|
required List<SakeItem> 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 = <SakenowaRecommendation>[];
|
|||
|
|
|
|||
|
|
for (final ranking in rankings.take(100)) {
|
|||
|
|
// ブランド情報を取得
|
|||
|
|
final brand = brands.cast<SakenowaBrand?>().firstWhere(
|
|||
|
|
(b) => b?.id == ranking.brandId,
|
|||
|
|
orElse: () => null,
|
|||
|
|
);
|
|||
|
|
if (brand == null) continue;
|
|||
|
|
|
|||
|
|
// 既に飲んだ銘柄は除外
|
|||
|
|
if (existingNames.contains(brand.name.toLowerCase())) continue;
|
|||
|
|
|
|||
|
|
// フレーバーチャートを取得
|
|||
|
|
final chart = flavorCharts.cast<SakenowaFlavorChart?>().firstWhere(
|
|||
|
|
(c) => c?.brandId == ranking.brandId,
|
|||
|
|
orElse: () => null,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 蔵元情報
|
|||
|
|
final brewery = breweries.cast<SakenowaBrewery?>().firstWhere(
|
|||
|
|
(b) => b?.id == brand.breweryId,
|
|||
|
|
orElse: () => null,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 地域情報
|
|||
|
|
final area = areas.cast<SakenowaArea?>().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<String> 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();
|
|||
|
|
}
|
|||
|
|
}
|