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

181 lines
5.6 KiB
Dart
Raw 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';
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();
}