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

156 lines
4.7 KiB
Dart
Raw Normal View History

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