ponshu-room-lite/lib/services/mbti_diagnosis_service.dart

189 lines
6.4 KiB
Dart

import '../models/sake_item.dart';
import '../models/mbti_result.dart';
import 'mbti_types.dart';
class MBTIDiagnosisService {
MBTIResult diagnose(List<SakeItem> items) {
if (items.isEmpty) {
return MBTIResult.insufficient(0);
}
// 1. Calculate Scores for each Axis (0.0 to 1.0)
// > 0.5 leans towards the Left (E, S, T, J)
// <= 0.5 leans towards the Right (I, N, F, P)
// We can adjust thresholds if needed.
final double eScore = _calculateEScore(items); // E vs I (Social)
final double sScore = _calculateSScore(items); // S vs N (Recognition)
final double tScore = _calculateTScore(items); // T vs F (Judgment)
final double jScore = _calculateJScore(items); // J vs P (Tactics)
// 2. Determine Axis Values
final bool isE = eScore >= 0.5;
final bool isS = sScore >= 0.5;
final bool isT = tScore >= 0.5;
final bool isJ = jScore >= 0.5;
// 3. Construct 4-letter code
final String code = [
isE ? 'E' : 'I',
isS ? 'S' : 'N',
isT ? 'T' : 'F',
isJ ? 'J' : 'P',
].join();
// 4. Retrieve Type
final type = MBTIType.types[code] ?? MBTIType.unknown();
// 5. Calculate Confidence (Simple Average of "distance from 0.5")
// e.g. Score 0.9 -> distance 0.4. Score 0.51 -> distance 0.01.
// Max distance is 0.5. So (dist / 0.5) * 100 is % confidence for that axis.
final double avgDist = (
((eScore - 0.5).abs()) +
((sScore - 0.5).abs()) +
((tScore - 0.5).abs()) +
((jScore - 0.5).abs())
) / 4.0;
// Normalize: Max possible sum of diffs is 0.5 * 4 = 2.0.
// So avgDist max is 0.5.
// Confidence = avgDist * 2. (0.5 * 2 = 1.0)
final double confidence = avgDist * 2.0;
return MBTIResult(
type: type,
sampleSize: items.length,
confidence: confidence,
axisScores: {
'E/I': isE,
'S/N': isS,
'T/F': isT,
'J/P': isJ,
},
);
}
// --- Axis Calculation Logic ---
/// E (Extrovert) vs I (Introvert)
/// Logic: Ratio of 'Set/Menu' items to total items.
/// If user drinks "Sets" (Drinking comparison), they are likely 'Social/Party' (E).
/// If user drinks "Single" items mainly, they are 'Solitary' (I).
/// Threshold: Sets are rarer, so if > 10% are sets, lean E.
double _calculateEScore(List<SakeItem> items) {
if (items.isEmpty) return 0.5; // Phase B fix: neutral default
int setOrderCount = items.where((item) => item.itemType == ItemType.set).length;
double ratio = setOrderCount / items.length;
// Phase B fix: Neutral baseline (0.5) instead of I-biased (0.3)
// Map 0.0 -> 0.5 (Neutral)
// Map 0.1 -> 0.6 (Slight E)
// Map 0.2 -> 0.7 (Strong E)
// Formula: score = 0.5 + (ratio * 1.0)
double score = 0.5 + ratio;
return score.clamp(0.0, 1.0);
}
/// S (Sensing) vs N (Intuition)
/// Logic: "Specs" vs "Vibes".
/// S: Detailed numeric specs (SMV, Polishing, Alcohol, Rice, Yeast).
/// N: Photos, Emotional Tags, Empty specs.
double _calculateSScore(List<SakeItem> items) {
if (items.isEmpty) return 0.5;
// Logic combined into the loop below.
// Average fill rate of technical specs.
// If user fills 3/5 avg, they are S.
// If user fills 0/5 avg, they are N.
double debugTotalSpecFills = 0;
int maxPossibleSpecs = items.length * 5;
for (var item in items) {
final h = item.hiddenSpecs;
if (h.sakeMeterValue != null) debugTotalSpecFills++;
if (h.polishingRatio != null) debugTotalSpecFills++;
if (h.alcoholContent != null) debugTotalSpecFills++;
if (h.yeast != null && h.yeast!.isNotEmpty) debugTotalSpecFills++;
if (h.riceVariety != null && h.riceVariety!.isNotEmpty) debugTotalSpecFills++;
}
double fillRatio = maxPossibleSpecs == 0 ? 0 : debugTotalSpecFills / maxPossibleSpecs;
// Phase B fix: Neutral baseline (0.5) instead of N-biased (0.2)
// Map 0.0 -> 0.5 (Neutral)
// Map 0.3 -> 0.65 (Slight S)
// Map 0.6 -> 0.8 (Strong S)
// Map 1.0 -> 1.0 (Max S)
// Formula: score = 0.5 + (fillRatio * 0.5)
double score = 0.5 + (fillRatio * 0.5);
return score.clamp(0.0, 1.0);
}
/// T (Thinking) vs F (Feeling)
/// Logic: "Logic" vs "Emotion".
/// T: Strict rating, more critical? Or just low favorites?
/// F: High favorite ratio. "Love everything".
double _calculateTScore(List<SakeItem> items) {
if (items.isEmpty) return 0.5;
int favoriteCount = items.where((i) => i.userData.isFavorite).length;
double favoriteRatio = favoriteCount / items.length;
// Phase B fix: Neutral baseline (0.5) instead of extreme T-bias (1.0)
// Map 0.0 -> 0.5 (Neutral)
// Map 0.5 -> 0.25 (Slight F)
// Map 1.0 -> 0.0 (Strong F)
// High Favorite -> F (Low T score)
// Low Favorite -> Neutral (T score 0.5)
// Formula: score = 0.5 - (favoriteRatio * 0.5)
return 0.5 - (favoriteRatio * 0.5);
}
/// J (Judging) vs P (Prospecting)
/// Logic: "Planned/Completeness" vs "Random".
/// J: High data completeness (user input fields), Organized.
/// P: Low completeness (just photo and name), Spontaneous.
/// Using "Input Completeness" as a proxy for J.
/// Similar to S/N but includes subjective fields like Memo/Price/Location?
/// Or "Prefecture Coverage"?
/// Let's use "Input Completeness of REQUIRED BASIC fields" (Name, Brand, Prefecture)
/// Almost everyone fills Name/Brand.
/// Let's use "Has Memo" and "Has Price" and "Has Date".
/// J types likely fill Price and Memo.
double _calculateJScore(List<SakeItem> items) {
if (items.isEmpty) return 0.5;
int detailedItemCount = 0;
for (var item in items) {
bool hasMemo = item.userData.memo != null && item.userData.memo!.isNotEmpty;
bool hasPrice = item.userData.price != null || item.userData.costPrice != null;
// bool hasPrefecture = item.displayData.displayPrefecture != 'Unknown'; // Default is often unknown
if (hasMemo && hasPrice) {
detailedItemCount++;
}
}
double detailedRatio = detailedItemCount / items.length;
// Phase B fix: Neutral baseline (0.5) instead of P-biased (0.2)
// Map 0.0 -> 0.5 (Neutral)
// Map 0.5 -> 0.75 (Slight J)
// Map 1.0 -> 1.0 (Strong J)
// If user inputs price AND memo for > 50% items -> J.
// Else -> Neutral.
// Formula: score = 0.5 + (detailedRatio * 0.5)
double score = 0.5 + (detailedRatio * 0.5);
return score.clamp(0.0, 1.0);
}
}