324 lines
11 KiB
Dart
324 lines
11 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../models/sake_item.dart';
|
|
import '../models/schema/sake_taste_stats.dart';
|
|
|
|
final shukoDiagnosisServiceProvider = Provider((ref) => ShukoDiagnosisService());
|
|
|
|
class ShukoDiagnosisService {
|
|
ShukoProfile diagnose(List<SakeItem> items) {
|
|
if (items.isEmpty) {
|
|
return ShukoProfile.empty();
|
|
}
|
|
|
|
// 1. Calculate Average Stats (only from items with valid data)
|
|
double totalAroma = 0;
|
|
double totalBitterness = 0;
|
|
double totalSweetness = 0;
|
|
double totalAcidity = 0;
|
|
double totalBody = 0;
|
|
int count = 0;
|
|
|
|
debugPrint('🍶🍶🍶 SHUKO DIAGNOSIS START: Total items = ${items.length}');
|
|
|
|
for (var item in items) {
|
|
final stats = item.hiddenSpecs.sakeTasteStats;
|
|
|
|
// Skip items with empty tasteStats (all zeros)
|
|
if (stats.aroma == 0 && stats.bitterness == 0 && stats.sweetness == 0 &&
|
|
stats.acidity == 0 && stats.body == 0) {
|
|
debugPrint('🍶 SKIPPED item (all zeros)');
|
|
continue;
|
|
}
|
|
|
|
totalAroma += stats.aroma;
|
|
totalBitterness += stats.bitterness;
|
|
totalSweetness += stats.sweetness;
|
|
totalAcidity += stats.acidity;
|
|
totalBody += stats.body;
|
|
count++;
|
|
}
|
|
|
|
debugPrint('🍶🍶🍶 Analyzed $count out of ${items.length} items');
|
|
|
|
if (count == 0) {
|
|
debugPrint('🍶🍶🍶 WARNING: No items to analyze, returning empty profile');
|
|
return ShukoProfile.empty();
|
|
}
|
|
|
|
final avgStats = SakeTasteStats(
|
|
aroma: totalAroma / count,
|
|
bitterness: totalBitterness / count,
|
|
sweetness: totalSweetness / count,
|
|
acidity: totalAcidity / count,
|
|
body: totalBody / count,
|
|
);
|
|
|
|
// 2. Determine Title based on dominant traits
|
|
final title = _determineTitle(avgStats);
|
|
|
|
return ShukoProfile(
|
|
title: title.title,
|
|
description: title.description,
|
|
avgStats: avgStats,
|
|
totalSakeCount: items.length,
|
|
analyzedCount: count,
|
|
);
|
|
}
|
|
|
|
ShukoTitle _determineTitle(SakeTasteStats stats) {
|
|
// DEBUG: Print average stats
|
|
debugPrint('🔍 DEBUG avgStats: aroma=${stats.aroma.toStringAsFixed(2)}, bitterness=${stats.bitterness.toStringAsFixed(2)}, sweetness=${stats.sweetness.toStringAsFixed(2)}, acidity=${stats.acidity.toStringAsFixed(2)}, body=${stats.body.toStringAsFixed(2)}');
|
|
|
|
// Scoring-based logic to handle overlapping traits
|
|
final Map<String, double> scores = {};
|
|
|
|
// 1. 辛口サムライ (Dry Samurai)
|
|
// High Bitterness (Sharpness) + Low Sweetness
|
|
// Old: alcoholFeeling + Low sweetness
|
|
scores['辛口サムライ'] = _calculateDryScore(stats);
|
|
|
|
// 2. フルーティーマスター (Fruity Master)
|
|
// High Aroma + High Sweetness (Modern Fruity Ginjo Style)
|
|
// Old: fruitiness + High sweetness
|
|
scores['フルーティーマスター'] = _calculateFruityScore(stats);
|
|
|
|
// 3. 旨口探求者 (Umami Explorer)
|
|
// High Body (Richness)
|
|
// Old: richness
|
|
scores['旨口探求者'] = _calculateRichnessScore(stats);
|
|
|
|
// 4. 香りの貴族 (Aroma Noble)
|
|
// High Aroma (dominant trait)
|
|
scores['香りの貴族'] = _calculateAromaScore(stats);
|
|
|
|
// 5. バランスの賢者 (Balance Sage)
|
|
// All stats moderate and balanced
|
|
scores['バランスの賢者'] = _calculateBalanceScore(stats);
|
|
|
|
// DEBUG: Print all scores
|
|
debugPrint('🔍 DEBUG scores: ${scores.entries.map((e) => '${e.key}=${e.value.toStringAsFixed(2)}').join(', ')}');
|
|
|
|
// Find the title with the highest score
|
|
final maxEntry = scores.entries.reduce((a, b) => a.value > b.value ? a : b);
|
|
|
|
debugPrint('🔍 DEBUG winner: ${maxEntry.key} with score ${maxEntry.value.toStringAsFixed(2)}');
|
|
|
|
// Threshold: require minimum score to avoid false positives
|
|
// Lowered to 1.5 to be more forgiving for "Standard" sake
|
|
if (maxEntry.value < 1.0) {
|
|
debugPrint('🔍 DEBUG: Score too low (${maxEntry.value.toStringAsFixed(2)} < 1.0), returning default title');
|
|
// Proposed New Default Titles
|
|
return const ShukoTitle(
|
|
title: '酒道の旅人',
|
|
description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。',
|
|
);
|
|
}
|
|
|
|
// Return the winning title
|
|
return _getTitleByName(maxEntry.key);
|
|
}
|
|
|
|
// Scoring functions for each type
|
|
double _calculateDryScore(SakeTasteStats stats) {
|
|
double score = 0;
|
|
// Dry = Sharp/Bitter + Not Sweet
|
|
if (stats.bitterness > 0.1) {
|
|
if (stats.bitterness > 3.0) score += (stats.bitterness - 3.0) * 2; // Lowered from 3.2
|
|
|
|
// Also verify Acidity contributions (Acid + Bitter = Dry)
|
|
if (stats.acidity > 3.0) score += (stats.acidity - 3.0);
|
|
|
|
// Penalize if too sweet
|
|
if (stats.sweetness < 2.5) {
|
|
score += (2.5 - stats.sweetness) * 2;
|
|
} else if (stats.sweetness > 3.5) {
|
|
score -= (stats.sweetness - 3.5) * 2;
|
|
}
|
|
}
|
|
return score;
|
|
}
|
|
|
|
double _calculateFruityScore(SakeTasteStats stats) {
|
|
double score = 0;
|
|
// Fruity = High Aroma + Moderate/High Sweetness
|
|
if (stats.aroma > 0.1) {
|
|
if (stats.aroma > 2.8) score += (stats.aroma - 2.8) * 1.5; // Lowered from 3.0
|
|
|
|
// Verify Sweetness support
|
|
if (stats.sweetness > 2.8) score += (stats.sweetness - 2.8) * 1.5;
|
|
|
|
// Bonus if Body is not too heavy (Light + Sweet + Aroma = Fruity)
|
|
if (stats.body < 3.5) score += 0.5;
|
|
}
|
|
return score;
|
|
}
|
|
|
|
double _calculateRichnessScore(SakeTasteStats stats) {
|
|
double score = 0;
|
|
// Richness = High Body (Kokumi) + Sweetness or Bitterness
|
|
if (stats.body > 0.1) {
|
|
// Body is the primary driver
|
|
if (stats.body > 3.0) score += (stats.body - 3.0) * 2.5; // Lowered from 3.3
|
|
|
|
// Bonus for complexity
|
|
if (stats.bitterness > 3.0) score += 0.5;
|
|
}
|
|
return score;
|
|
}
|
|
|
|
double _calculateAromaScore(SakeTasteStats stats) {
|
|
double score = 0;
|
|
// Pure Aroma focus (Daiginjo style)
|
|
// Lowered threshold significantly to capture "Aroma Type" even if not extreme
|
|
if (stats.aroma > 3.0) {
|
|
score += (stats.aroma - 3.0) * 3;
|
|
}
|
|
// Boost score if it is the dominant trait
|
|
if (stats.aroma > stats.body && stats.aroma > stats.bitterness) {
|
|
score += 1.0;
|
|
}
|
|
return score;
|
|
}
|
|
|
|
double _calculateBalanceScore(SakeTasteStats stats) {
|
|
double score = 0;
|
|
|
|
// Check range (Max - Min)
|
|
final values = [stats.aroma, stats.sweetness, stats.acidity, stats.bitterness, stats.body];
|
|
final maxVal = values.reduce((a, b) => a > b ? a : b);
|
|
final minVal = values.reduce((a, b) => a < b ? a : b);
|
|
final spread = maxVal - minVal;
|
|
|
|
// Strict requirement for "Balance":
|
|
// The difference between the highest and lowest trait must be small.
|
|
if (spread > 1.5) {
|
|
return 0; // Not balanced if there's a spike
|
|
}
|
|
|
|
int validStats = 0;
|
|
double sumDiffFrom3 = 0;
|
|
|
|
// Check deviation from 3.0 (Center)
|
|
void checkStat(double val) {
|
|
if (val > 0.1) {
|
|
validStats++;
|
|
sumDiffFrom3 += (val - 3.0).abs();
|
|
}
|
|
}
|
|
|
|
checkStat(stats.aroma);
|
|
checkStat(stats.sweetness);
|
|
checkStat(stats.acidity);
|
|
checkStat(stats.bitterness);
|
|
checkStat(stats.body);
|
|
|
|
if (validStats >= 3) {
|
|
double avgDev = sumDiffFrom3 / validStats;
|
|
// If average deviation is small (< 0.7), it's balanced
|
|
if (avgDev < 0.7) {
|
|
score = (0.8 - avgDev) * 5; // Higher score for tighter balance
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
ShukoTitle _getTitleByName(String name) {
|
|
switch (name) {
|
|
case '辛口サムライ':
|
|
return const ShukoTitle(
|
|
title: '辛口サムライ',
|
|
description: 'キレのある辛口を好む、硬派な日本酒ファン。', // Updated Description?
|
|
);
|
|
case 'フルーティーマスター':
|
|
return const ShukoTitle(
|
|
title: 'フルーティーマスター',
|
|
description: '果実のような香りと甘みを愛する、華やかな飲み手。',
|
|
);
|
|
case '旨口探求者':
|
|
return const ShukoTitle(
|
|
title: '旨口探求者',
|
|
description: 'お米本来の旨みやコクを重視する、通な舌の持ち主。',
|
|
);
|
|
case '香りの貴族':
|
|
return const ShukoTitle(
|
|
title: '香りの貴族',
|
|
description: '吟醸香など、鼻に抜ける香りを何より楽しむタイプ。',
|
|
);
|
|
case 'バランスの賢者':
|
|
return const ShukoTitle(
|
|
title: 'バランスの賢者',
|
|
description: '偏りなく様々な酒を楽しむ、オールラウンダー。',
|
|
);
|
|
default:
|
|
// New Default Title
|
|
return const ShukoTitle(
|
|
title: '酒道の旅人',
|
|
description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。',
|
|
);
|
|
}
|
|
}
|
|
|
|
// v1.1: Personalization Logic
|
|
String getGreeting(String? nickname) {
|
|
if (nickname == null || nickname.trim().isEmpty) {
|
|
return 'ようこそ!';
|
|
}
|
|
return 'ようこそ、$nicknameさん';
|
|
}
|
|
|
|
ShukoTitle personalizeTitle(ShukoTitle original, String? gender) {
|
|
if (gender == null) return original;
|
|
|
|
String newTitle = original.title;
|
|
|
|
// Simple customization logic
|
|
if (gender == 'female') {
|
|
if (newTitle.contains('サムライ')) newTitle = newTitle.replaceAll('サムライ', '麗人');
|
|
if (newTitle.contains('貴族')) newTitle = newTitle.replaceAll('貴族', 'プリンセス');
|
|
if (newTitle.contains('賢者')) newTitle = newTitle.replaceAll('賢者', 'ミューズ');
|
|
if (newTitle.contains('愛好家')) newTitle = newTitle.replaceAll('愛好家', '目利き'); // Feminine variant
|
|
} else if (gender == 'male') {
|
|
if (newTitle.contains('貴族')) newTitle = newTitle.replaceAll('貴族', '貴公子');
|
|
}
|
|
|
|
return ShukoTitle(
|
|
title: newTitle,
|
|
description: original.description,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ShukoProfile {
|
|
final String title;
|
|
final String description;
|
|
final SakeTasteStats avgStats;
|
|
final int totalSakeCount;
|
|
final int analyzedCount;
|
|
|
|
ShukoProfile({
|
|
required this.title,
|
|
required this.description,
|
|
required this.avgStats,
|
|
required this.totalSakeCount,
|
|
required this.analyzedCount,
|
|
});
|
|
|
|
factory ShukoProfile.empty() {
|
|
return ShukoProfile(
|
|
title: '旅の始まり',
|
|
description: 'まずは日本酒を記録して、\n自分の好みを発見しましょう。',
|
|
avgStats: SakeTasteStats(aroma: 0, bitterness: 0, sweetness: 0, acidity: 0, body: 0),
|
|
totalSakeCount: 0,
|
|
analyzedCount: 0,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ShukoTitle {
|
|
final String title;
|
|
final String description;
|
|
const ShukoTitle({required this.title, required this.description});
|
|
}
|