fix: prevent EXP farming on cache hits + fix tasteStats validation
- SakeAnalysisResult.isFromCache flag added (not serialized to JSON) - Both cache-hit paths return result.asCached() to signal caller - camera_screen: EXP +10 only awarded on fresh API calls, not cache hits - camera_screen: show '解析済みの結果を使用(経験値なし)' on cache hit - camera_screen: clear _capturedImages after successful analysis - camera_screen: catch(_) -> catch(e) with debugPrint logging - SakeAnalysisResult.fromJson: auto-fill missing tasteStats keys with 3, clamp all values to 1-5 range to prevent broken charts - Bump version 1.0.37+44 -> 1.0.38+45 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ee7e3b2646
commit
e82f66e44e
|
|
@ -491,54 +491,71 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
await settingsBox.put('sake_sort_order', currentOrder);
|
||||
|
||||
// --- v1.3 Gamification Hook ---
|
||||
// Award EXP
|
||||
final userProfileState = ref.read(userProfileProvider);
|
||||
final prevLevel = userProfileState.level;
|
||||
// キャッシュヒット結果にはEXP付与しない(同一画像の重複スキャン対策)
|
||||
int expGained = 0;
|
||||
int newLevel = 0;
|
||||
bool isLevelUp = false;
|
||||
List<dynamic> newBadges = [];
|
||||
|
||||
await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + 10);
|
||||
if (!result.isFromCache) {
|
||||
final userProfileState = ref.read(userProfileProvider);
|
||||
final prevLevel = userProfileState.level;
|
||||
expGained = 10;
|
||||
|
||||
// Check and unlock badges
|
||||
final newBadges = await GamificationService.checkAndUnlockBadges(ref);
|
||||
await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + expGained);
|
||||
newBadges = await GamificationService.checkAndUnlockBadges(ref);
|
||||
|
||||
// Refetch updated state for level comparison
|
||||
final updatedProfile = ref.read(userProfileProvider);
|
||||
final newLevel = updatedProfile.level;
|
||||
final isLevelUp = newLevel > prevLevel;
|
||||
final updatedProfile = ref.read(userProfileProvider);
|
||||
newLevel = updatedProfile.level;
|
||||
isLevelUp = newLevel > prevLevel;
|
||||
|
||||
debugPrint('Gamification: EXP +$expGained (Total: ${updatedProfile.totalExp}) Level: $prevLevel -> $newLevel');
|
||||
} else {
|
||||
debugPrint('Cache hit: EXP not awarded (same image re-scanned)');
|
||||
}
|
||||
|
||||
// Debug: Verify save
|
||||
debugPrint('Saved to Hive: ${sakeItem.displayData.displayName} (ID: ${sakeItem.id})');
|
||||
debugPrint('Total items in box: ${box.length}');
|
||||
debugPrint('Prepended to sort order (now ${currentOrder.length} items)');
|
||||
debugPrint('Gamification: EXP +10 (Total: ${updatedProfile.totalExp}) Level: $prevLevel -> $newLevel');
|
||||
|
||||
// 解析完了後に画像リストをクリア(次の撮影セッション用)
|
||||
setState(() => _capturedImages.clear());
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
navigator.pop(); // Close AnalyzingDialog
|
||||
navigator.pop(); // Close Camera Screen (Return to Home)
|
||||
|
||||
// Success Message (with EXP/Level Up/Badge info)
|
||||
// Success Message
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final List<Widget> messageWidgets = [
|
||||
Text('${sakeItem.displayData.displayName} を登録しました!'),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
];
|
||||
|
||||
if (result.isFromCache) {
|
||||
messageWidgets.add(const SizedBox(height: 4));
|
||||
messageWidgets.add(const Text(
|
||||
'※ 解析済みの結果を使用(経験値なし)',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
));
|
||||
} else {
|
||||
messageWidgets.add(const SizedBox(height: 4));
|
||||
messageWidgets.add(Row(
|
||||
children: [
|
||||
Icon(LucideIcons.sparkles,
|
||||
color: isDark ? Colors.yellow.shade300 : Colors.yellow,
|
||||
size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'経験値 +10 GET! ${isLevelUp ? " (Level UP!)" : ""}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
|
||||
),
|
||||
'経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
));
|
||||
}
|
||||
|
||||
// Add badge notifications
|
||||
if (newBadges.isNotEmpty) {
|
||||
messageWidgets.add(const SizedBox(height: 8));
|
||||
for (var badge in newBadges) {
|
||||
|
|
@ -603,7 +620,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
|
|||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
debugPrint('Draft save also failed: $e');
|
||||
if (!mounted) return;
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class GeminiService {
|
|||
final cached = await AnalysisCacheService.getCached(imageHash);
|
||||
if (cached != null) {
|
||||
debugPrint('Proxy cache hit: skipping API call');
|
||||
return cached;
|
||||
return cached.asCached();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -162,20 +162,7 @@ class GeminiService {
|
|||
|
||||
final result = SakeAnalysisResult.fromJson(data);
|
||||
|
||||
// スキーマ準拠チェック
|
||||
if (result.tasteStats.isEmpty ||
|
||||
result.tasteStats.values.every((v) => v == 0)) {
|
||||
debugPrint('WARNING: AI returned empty or zero taste stats. This item will not form a valid chart.');
|
||||
} else {
|
||||
// Simple check
|
||||
final requiredKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
|
||||
final actualKeys = result.tasteStats.keys.toList();
|
||||
final missing = requiredKeys.where((k) => !actualKeys.contains(k)).toList();
|
||||
|
||||
if (missing.isNotEmpty) {
|
||||
debugPrint('WARNING: AI response missing keys: $missing. Old schema?');
|
||||
}
|
||||
}
|
||||
// tasteStats の補完・バリデーションは SakeAnalysisResult.fromJson 内で実施済み
|
||||
|
||||
// キャッシュに保存(次回同一画像はAPI不使用)
|
||||
if (imagePaths.isNotEmpty) {
|
||||
|
|
@ -221,7 +208,7 @@ class GeminiService {
|
|||
final cached = await AnalysisCacheService.getCached(imageHash);
|
||||
if (cached != null) {
|
||||
debugPrint('Cache hit: skipping API call');
|
||||
return cached;
|
||||
return cached.asCached();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +355,7 @@ class SakeAnalysisResult {
|
|||
final int? confidenceScore;
|
||||
final List<String> flavorTags;
|
||||
final Map<String, int> tasteStats;
|
||||
|
||||
|
||||
// New Fields
|
||||
final double? alcoholContent;
|
||||
final int? polishingRatio;
|
||||
|
|
@ -377,6 +364,10 @@ class SakeAnalysisResult {
|
|||
final String? yeast;
|
||||
final String? manufacturingYearMonth;
|
||||
|
||||
/// キャッシュから返された結果かどうか(EXP付与・新規登録の判定に使用)
|
||||
/// JSON には含まない(キャッシュ保存・復元時は常に false)
|
||||
final bool isFromCache;
|
||||
|
||||
SakeAnalysisResult({
|
||||
this.name,
|
||||
this.brand,
|
||||
|
|
@ -393,14 +384,33 @@ class SakeAnalysisResult {
|
|||
this.riceVariety,
|
||||
this.yeast,
|
||||
this.manufacturingYearMonth,
|
||||
this.isFromCache = false,
|
||||
});
|
||||
|
||||
/// キャッシュヒット用コピー
|
||||
SakeAnalysisResult asCached() => SakeAnalysisResult(
|
||||
name: name, brand: brand, prefecture: prefecture, type: type,
|
||||
description: description, catchCopy: catchCopy, confidenceScore: confidenceScore,
|
||||
flavorTags: flavorTags, tasteStats: tasteStats, alcoholContent: alcoholContent,
|
||||
polishingRatio: polishingRatio, sakeMeterValue: sakeMeterValue,
|
||||
riceVariety: riceVariety, yeast: yeast,
|
||||
manufacturingYearMonth: manufacturingYearMonth,
|
||||
isFromCache: true,
|
||||
);
|
||||
|
||||
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
|
||||
// Helper to extract int from map safely
|
||||
// tasteStats: 欠損キーを 3 で補完、範囲外(1〜5)をクランプ
|
||||
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
|
||||
Map<String, int> stats = {};
|
||||
if (json['tasteStats'] is Map) {
|
||||
final map = json['tasteStats'] as Map;
|
||||
stats = map.map((key, value) => MapEntry(key.toString(), (value as num?)?.toInt() ?? 3));
|
||||
stats = map.map((key, value) => MapEntry(
|
||||
key.toString(),
|
||||
((value as num?)?.toInt() ?? 3).clamp(1, 5),
|
||||
));
|
||||
}
|
||||
for (final key in requiredStatKeys) {
|
||||
stats.putIfAbsent(key, () => 3);
|
||||
}
|
||||
|
||||
return SakeAnalysisResult(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.37+44
|
||||
version: 1.0.38+45
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.1
|
||||
|
|
|
|||
Loading…
Reference in New Issue