2026-01-11 08:17:29 +00:00
|
|
|
|
import 'dart:math';
|
|
|
|
|
|
import '../models/sake_item.dart';
|
|
|
|
|
|
|
|
|
|
|
|
/// 日本酒レコメンデーションサービス
|
|
|
|
|
|
/// AIを使わずローカルで類似度計算(トークン消費0)
|
|
|
|
|
|
class SakeRecommendationService {
|
|
|
|
|
|
/// 五味チャートのコサイン類似度を計算
|
|
|
|
|
|
/// 戻り値: 0.0(全く異なる)〜 1.0(完全一致)
|
|
|
|
|
|
static double calculateTasteSimilarity(
|
|
|
|
|
|
Map<String, int> tasteA,
|
|
|
|
|
|
Map<String, int> tasteB,
|
|
|
|
|
|
) {
|
|
|
|
|
|
final keys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
|
|
|
|
|
|
|
|
|
|
|
|
double dotProduct = 0;
|
|
|
|
|
|
double normA = 0;
|
|
|
|
|
|
double normB = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (var key in keys) {
|
|
|
|
|
|
final valA = (tasteA[key] ?? 3).toDouble();
|
|
|
|
|
|
final valB = (tasteB[key] ?? 3).toDouble();
|
|
|
|
|
|
|
|
|
|
|
|
dotProduct += valA * valB;
|
|
|
|
|
|
normA += valA * valA;
|
|
|
|
|
|
normB += valB * valB;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (normA == 0 || normB == 0) return 0;
|
|
|
|
|
|
|
|
|
|
|
|
return dotProduct / (sqrt(normA) * sqrt(normB));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// レコメンドスコアを計算
|
|
|
|
|
|
///
|
|
|
|
|
|
/// スコアリング基準:
|
|
|
|
|
|
/// - 同じ酒蔵: +10点
|
|
|
|
|
|
/// - 同じ都道府県: +5点
|
|
|
|
|
|
/// - 共通タグ: +2点/タグ
|
|
|
|
|
|
/// - 五味類似度: +15点 × 類似度(0-1)
|
|
|
|
|
|
static double calculateScore(SakeItem target, SakeItem candidate) {
|
|
|
|
|
|
double score = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 酒蔵つながり
|
2026-02-15 15:13:12 +00:00
|
|
|
|
if (candidate.displayData.displayBrewery == target.displayData.displayBrewery &&
|
|
|
|
|
|
target.displayData.displayBrewery != '不明' &&
|
2026-01-11 08:17:29 +00:00
|
|
|
|
candidate.id != target.id) {
|
|
|
|
|
|
score += 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 都道府県つながり
|
2026-02-15 15:13:12 +00:00
|
|
|
|
if (candidate.displayData.displayPrefecture == target.displayData.displayPrefecture &&
|
|
|
|
|
|
target.displayData.displayPrefecture != '不明' &&
|
2026-01-11 08:17:29 +00:00
|
|
|
|
candidate.id != target.id) {
|
|
|
|
|
|
score += 5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. タグつながり
|
|
|
|
|
|
final commonTags = candidate.hiddenSpecs.flavorTags
|
|
|
|
|
|
.where((tag) => target.hiddenSpecs.flavorTags.contains(tag))
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
score += commonTags.length * 2;
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 五味チャート類似度
|
|
|
|
|
|
if (target.hiddenSpecs.tasteStats.isNotEmpty && candidate.hiddenSpecs.tasteStats.isNotEmpty) {
|
|
|
|
|
|
final similarity = calculateTasteSimilarity(
|
|
|
|
|
|
target.hiddenSpecs.tasteStats,
|
|
|
|
|
|
candidate.hiddenSpecs.tasteStats,
|
|
|
|
|
|
);
|
|
|
|
|
|
score += similarity * 15;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return score;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// レコメンド理由を生成
|
|
|
|
|
|
static String getRecommendationReason(SakeItem target, SakeItem candidate) {
|
|
|
|
|
|
final reasons = <String>[];
|
|
|
|
|
|
|
|
|
|
|
|
// 酒蔵つながり
|
2026-02-15 15:13:12 +00:00
|
|
|
|
if (candidate.displayData.displayBrewery == target.displayData.displayBrewery && target.displayData.displayBrewery != '不明') {
|
|
|
|
|
|
reasons.add('${target.displayData.displayBrewery}つながり');
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 都道府県つながり
|
2026-02-15 15:13:12 +00:00
|
|
|
|
if (candidate.displayData.displayPrefecture == target.displayData.displayPrefecture &&
|
|
|
|
|
|
target.displayData.displayPrefecture != '不明' &&
|
|
|
|
|
|
candidate.displayData.displayBrewery != target.displayData.displayBrewery) {
|
|
|
|
|
|
reasons.add('${target.displayData.displayPrefecture}つながり');
|
2026-01-11 08:17:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// タグつながり
|
|
|
|
|
|
final commonTags = candidate.hiddenSpecs.flavorTags
|
|
|
|
|
|
.where((tag) => target.hiddenSpecs.flavorTags.contains(tag))
|
|
|
|
|
|
.take(2)
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
if (commonTags.isNotEmpty) {
|
|
|
|
|
|
reasons.add(commonTags.join('・'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 五味類似度
|
|
|
|
|
|
if (target.hiddenSpecs.tasteStats.isNotEmpty && candidate.hiddenSpecs.tasteStats.isNotEmpty) {
|
|
|
|
|
|
final similarity = calculateTasteSimilarity(
|
|
|
|
|
|
target.hiddenSpecs.tasteStats,
|
|
|
|
|
|
candidate.hiddenSpecs.tasteStats,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (similarity > 0.8) {
|
|
|
|
|
|
reasons.add('似た味わい');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return reasons.isEmpty ? 'おすすめ' : reasons.join(' / ');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// スマートレコメンド(スコア順にソート)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// [target] 基準となる日本酒
|
|
|
|
|
|
/// [allItems] 全日本酒リスト
|
|
|
|
|
|
/// [limit] 返す最大件数(デフォルト: 10)
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 戻り値: (日本酒, スコア, 理由) のリスト
|
|
|
|
|
|
static List<RecommendedSake> getRecommendations({
|
|
|
|
|
|
required SakeItem target,
|
|
|
|
|
|
required List<SakeItem> allItems,
|
|
|
|
|
|
int limit = 10,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
final scored = allItems
|
|
|
|
|
|
.where((item) => item.id != target.id) // 自分自身を除外
|
|
|
|
|
|
.map((item) {
|
|
|
|
|
|
final score = calculateScore(target, item);
|
|
|
|
|
|
final reason = getRecommendationReason(target, item);
|
|
|
|
|
|
return RecommendedSake(
|
|
|
|
|
|
item: item,
|
|
|
|
|
|
score: score,
|
|
|
|
|
|
reason: reason,
|
|
|
|
|
|
);
|
|
|
|
|
|
}).where((rec) => rec.score > 0) // スコア0より大きいもののみ
|
|
|
|
|
|
.toList()
|
|
|
|
|
|
..sort((a, b) => b.score.compareTo(a.score)); // 降順ソート
|
|
|
|
|
|
|
|
|
|
|
|
return scored.take(limit).toList();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// レコメンド結果
|
|
|
|
|
|
class RecommendedSake {
|
|
|
|
|
|
final SakeItem item;
|
|
|
|
|
|
final double score;
|
|
|
|
|
|
final String reason;
|
|
|
|
|
|
|
|
|
|
|
|
RecommendedSake({
|
|
|
|
|
|
required this.item,
|
|
|
|
|
|
required this.score,
|
|
|
|
|
|
required this.reason,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|