181 lines
5.6 KiB
Dart
181 lines
5.6 KiB
Dart
import '../models/sake_item.dart';
|
||
import '../models/sakenowa/sakenowa_models.dart';
|
||
import 'sakenowa_service.dart';
|
||
import 'dart:math' as math;
|
||
|
||
/// さけのわ類似推薦サービス
|
||
///
|
||
/// ユーザーの特定の日本酒に類似したさけのわランキング銘柄を推薦
|
||
/// 「この銘柄が好きなら、こちらもおすすめ」形式
|
||
class SakenowaSimilarRecommendationService {
|
||
final SakenowaService _sakenowaService;
|
||
|
||
SakenowaSimilarRecommendationService(this._sakenowaService);
|
||
|
||
/// 類似銘柄を推薦
|
||
///
|
||
/// [baseSake]: 基準となる日本酒
|
||
/// [limit]: 推薦数(デフォルト: 5)
|
||
/// [excludeOwned]: ユーザーが既に持っている銘柄を除外するか(デフォルト: true)
|
||
/// [userItems]: ユーザーの全日本酒リスト(除外判定用)
|
||
Future<List<SimilarSakeRecommendation>> getSimilarFromSakenowa({
|
||
required SakeItem baseSake,
|
||
int limit = 5,
|
||
bool excludeOwned = true,
|
||
List<SakeItem>? userItems,
|
||
}) async {
|
||
// さけのわデータ取得
|
||
final rankings = await _sakenowaService.getRankings();
|
||
final brands = await _sakenowaService.getBrands();
|
||
final flavorCharts = await _sakenowaService.getFlavorCharts();
|
||
|
||
// マップ化
|
||
final brandMap = {for (var b in brands) b.id: b};
|
||
final chartMap = {for (var c in flavorCharts) c.brandId: c};
|
||
|
||
// 既飲銘柄名セット(小文字変換)
|
||
final ownedNames = userItems != null
|
||
? userItems.map((i) => i.displayData.displayName.toLowerCase()).toSet()
|
||
: <String>{};
|
||
|
||
// baseSakeの味覚データ取得(6軸優先)
|
||
final baseTaste = baseSake.hiddenSpecs.activeTasteData;
|
||
|
||
// ランキングとの類似度計算
|
||
final scoredRankings = <SimilarSakeRecommendation>[];
|
||
|
||
for (final ranking in rankings.take(100)) {
|
||
final brand = brandMap[ranking.brandId];
|
||
final chart = chartMap[ranking.brandId];
|
||
|
||
if (brand == null || chart == null) continue;
|
||
|
||
// 除外判定
|
||
if (excludeOwned && ownedNames.contains(brand.name.toLowerCase())) {
|
||
continue; // 既に持っている銘柄はスキップ
|
||
}
|
||
|
||
// 基準銘柄自身もスキップ
|
||
if (brand.name.toLowerCase() == baseSake.displayData.displayName.toLowerCase()) {
|
||
continue;
|
||
}
|
||
|
||
// さけのわフレーバーを五味に変換
|
||
final targetTaste = chart.toFiveAxisTaste();
|
||
|
||
// 類似度計算(コサイン類似度)
|
||
final similarity = _calculateCosineSimilarity(baseTaste, targetTaste);
|
||
|
||
scoredRankings.add(SimilarSakeRecommendation(
|
||
ranking: ranking,
|
||
brand: brand,
|
||
flavorChart: chart,
|
||
similarityScore: similarity,
|
||
reason: _generateReason(baseSake, chart, similarity),
|
||
));
|
||
}
|
||
|
||
// 類似度でソート(降順)
|
||
scoredRankings.sort((a, b) => b.similarityScore.compareTo(a.similarityScore));
|
||
|
||
// 上位N件を返す
|
||
return scoredRankings.take(limit).toList();
|
||
}
|
||
|
||
/// コサイン類似度計算
|
||
///
|
||
/// 2つの味覚ベクトルの類似度を0.0-1.0で返す
|
||
/// 1.0に近いほど類似
|
||
double _calculateCosineSimilarity(
|
||
Map<String, double> taste1,
|
||
Map<String, double> taste2,
|
||
) {
|
||
// 共通の軸のみ使用
|
||
final keys = taste1.keys.toSet().intersection(taste2.keys.toSet());
|
||
if (keys.isEmpty) return 0.0;
|
||
|
||
double dotProduct = 0.0;
|
||
double magnitude1 = 0.0;
|
||
double magnitude2 = 0.0;
|
||
|
||
for (final key in keys) {
|
||
final v1 = taste1[key] ?? 0.0;
|
||
final v2 = taste2[key] ?? 0.0;
|
||
|
||
dotProduct += v1 * v2;
|
||
magnitude1 += v1 * v1;
|
||
magnitude2 += v2 * v2;
|
||
}
|
||
|
||
final magnitude = math.sqrt(magnitude1) * math.sqrt(magnitude2);
|
||
if (magnitude == 0) return 0.0;
|
||
|
||
return (dotProduct / magnitude).clamp(0.0, 1.0);
|
||
}
|
||
|
||
/// 推薦理由生成
|
||
String _generateReason(
|
||
SakeItem baseSake,
|
||
SakenowaFlavorChart targetChart,
|
||
double similarity,
|
||
) {
|
||
// baseSakeの五味チャート
|
||
final baseTaste = baseSake.hiddenSpecs.activeTasteData;
|
||
|
||
// targetの五味チャート
|
||
final targetTaste = targetChart.toFiveAxisTaste();
|
||
|
||
// 最も類似している軸を2つ抽出
|
||
final similarities = <String, double>{};
|
||
for (final key in baseTaste.keys) {
|
||
final diff = (baseTaste[key]! - (targetTaste[key] ?? 0.5)).abs();
|
||
similarities[key] = 1.0 - diff; // 差が小さいほど類似度高い
|
||
}
|
||
|
||
final sortedAxes = similarities.entries.toList()
|
||
..sort((a, b) => b.value.compareTo(a.value));
|
||
|
||
final topAxes = sortedAxes.take(2).toList();
|
||
|
||
// 日本語ラベル
|
||
const axisLabels = {
|
||
'aroma': '香り',
|
||
'sweetness': '甘み',
|
||
'acidity': '酸味',
|
||
'bitterness': 'キレ',
|
||
'body': 'コク',
|
||
};
|
||
|
||
if (topAxes.length >= 2) {
|
||
final label1 = axisLabels[topAxes[0].key] ?? topAxes[0].key;
|
||
final label2 = axisLabels[topAxes[1].key] ?? topAxes[1].key;
|
||
return '$label1と$label2が似ています';
|
||
} else if (topAxes.length == 1) {
|
||
final label1 = axisLabels[topAxes[0].key] ?? topAxes[0].key;
|
||
return '$label1が似ています';
|
||
}
|
||
|
||
return '味わいが似ています';
|
||
}
|
||
}
|
||
|
||
/// 類似銘柄推薦結果
|
||
class SimilarSakeRecommendation {
|
||
final SakenowaRanking ranking;
|
||
final SakenowaBrand brand;
|
||
final SakenowaFlavorChart flavorChart;
|
||
final double similarityScore; // 0.0-1.0
|
||
final String reason;
|
||
|
||
SimilarSakeRecommendation({
|
||
required this.ranking,
|
||
required this.brand,
|
||
required this.flavorChart,
|
||
required this.similarityScore,
|
||
required this.reason,
|
||
});
|
||
|
||
/// パーセント表示
|
||
int get similarityPercent => (similarityScore * 100).round();
|
||
}
|