156 lines
4.7 KiB
Dart
156 lines
4.7 KiB
Dart
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. 酒蔵つながり
|
||
if (candidate.displayData.brewery == target.displayData.brewery &&
|
||
target.displayData.brewery != '不明' &&
|
||
candidate.id != target.id) {
|
||
score += 10;
|
||
}
|
||
|
||
// 2. 都道府県つながり
|
||
if (candidate.displayData.prefecture == target.displayData.prefecture &&
|
||
target.displayData.prefecture != '不明' &&
|
||
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>[];
|
||
|
||
// 酒蔵つながり
|
||
if (candidate.displayData.brewery == target.displayData.brewery && target.displayData.brewery != '不明') {
|
||
reasons.add('${target.displayData.brewery}つながり');
|
||
}
|
||
|
||
// 都道府県つながり
|
||
if (candidate.displayData.prefecture == target.displayData.prefecture &&
|
||
target.displayData.prefecture != '不明' &&
|
||
candidate.displayData.brewery != target.displayData.brewery) {
|
||
reasons.add('${target.displayData.prefecture}つながり');
|
||
}
|
||
|
||
// タグつながり
|
||
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,
|
||
});
|
||
}
|