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

181 lines
5.6 KiB
Dart
Raw Normal View History

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