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

255 lines
7.4 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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