ponshu-room-lite/lib/services/shuko_diagnosis_service.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});
}