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

156 lines
4.8 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 '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.displayBrewery == target.displayData.displayBrewery &&
target.displayData.displayBrewery != '不明' &&
candidate.id != target.id) {
score += 10;
}
// 2. 都道府県つながり
if (candidate.displayData.displayPrefecture == target.displayData.displayPrefecture &&
target.displayData.displayPrefecture != '不明' &&
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.displayBrewery == target.displayData.displayBrewery && target.displayData.displayBrewery != '不明') {
reasons.add('${target.displayData.displayBrewery}つながり');
}
// 都道府県つながり
if (candidate.displayData.displayPrefecture == target.displayData.displayPrefecture &&
target.displayData.displayPrefecture != '不明' &&
candidate.displayData.displayBrewery != target.displayData.displayBrewery) {
reasons.add('${target.displayData.displayPrefecture}つながり');
}
// タグつながり
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,
});
}