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();
|
||
}
|
||
}
|