ponshu-room-lite/lib/services/sakenowa_recommendation_ser...

255 lines
7.4 KiB
Dart
Raw Permalink Normal View History

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