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:
Ponshu Developer 2026-04-16 10:46:42 +09:00
parent ee7e3b2646
commit e82f66e44e
3 changed files with 73 additions and 45 deletions

View File

@ -491,54 +491,71 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
await settingsBox.put('sake_sort_order', currentOrder); await settingsBox.put('sake_sort_order', currentOrder);
// --- v1.3 Gamification Hook --- // --- v1.3 Gamification Hook ---
// Award EXP // EXP付与しない
int expGained = 0;
int newLevel = 0;
bool isLevelUp = false;
List<dynamic> newBadges = [];
if (!result.isFromCache) {
final userProfileState = ref.read(userProfileProvider); final userProfileState = ref.read(userProfileProvider);
final prevLevel = userProfileState.level; final prevLevel = userProfileState.level;
expGained = 10;
await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + 10); await ref.read(userProfileProvider.notifier).updateTotalExp(userProfileState.totalExp + expGained);
newBadges = await GamificationService.checkAndUnlockBadges(ref);
// Check and unlock badges
final newBadges = await GamificationService.checkAndUnlockBadges(ref);
// Refetch updated state for level comparison
final updatedProfile = ref.read(userProfileProvider); final updatedProfile = ref.read(userProfileProvider);
final newLevel = updatedProfile.level; newLevel = updatedProfile.level;
final isLevelUp = newLevel > prevLevel; 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('Saved to Hive: ${sakeItem.displayData.displayName} (ID: ${sakeItem.id})');
debugPrint('Total items in box: ${box.length}'); 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; if (!mounted) return;
navigator.pop(); // Close AnalyzingDialog navigator.pop(); // Close AnalyzingDialog
navigator.pop(); // Close Camera Screen (Return to Home) 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 isDark = Theme.of(context).brightness == Brightness.dark;
final List<Widget> messageWidgets = [ final List<Widget> messageWidgets = [
Text('${sakeItem.displayData.displayName} を登録しました!'), 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: [ children: [
Icon(LucideIcons.sparkles, Icon(LucideIcons.sparkles,
color: isDark ? Colors.yellow.shade300 : Colors.yellow, color: isDark ? Colors.yellow.shade300 : Colors.yellow,
size: 16), size: 16),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'経験値 +10 GET! ${isLevelUp ? " (Level UP!)" : ""}', '経験値 +$expGained GET!${isLevelUp ? " (Level UP!)" : ""}',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent, color: isDark ? Colors.yellow.shade200 : Colors.yellowAccent,
), ),
), ),
], ],
), ));
]; }
// Add badge notifications
if (newBadges.isNotEmpty) { if (newBadges.isNotEmpty) {
messageWidgets.add(const SizedBox(height: 8)); messageWidgets.add(const SizedBox(height: 8));
for (var badge in newBadges) { for (var badge in newBadges) {
@ -603,7 +620,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
), ),
); );
} catch (_) { } catch (e) {
debugPrint('Draft save also failed: $e');
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')), const SnackBar(content: Text('解析もドラフト保存も失敗しました。再試行してください。')),

View File

@ -96,7 +96,7 @@ class GeminiService {
final cached = await AnalysisCacheService.getCached(imageHash); final cached = await AnalysisCacheService.getCached(imageHash);
if (cached != null) { if (cached != null) {
debugPrint('Proxy cache hit: skipping API call'); debugPrint('Proxy cache hit: skipping API call');
return cached; return cached.asCached();
} }
} }
@ -162,20 +162,7 @@ class GeminiService {
final result = SakeAnalysisResult.fromJson(data); final result = SakeAnalysisResult.fromJson(data);
// // tasteStats SakeAnalysisResult.fromJson
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?');
}
}
// API不使用 // API不使用
if (imagePaths.isNotEmpty) { if (imagePaths.isNotEmpty) {
@ -221,7 +208,7 @@ class GeminiService {
final cached = await AnalysisCacheService.getCached(imageHash); final cached = await AnalysisCacheService.getCached(imageHash);
if (cached != null) { if (cached != null) {
debugPrint('Cache hit: skipping API call'); debugPrint('Cache hit: skipping API call');
return cached; return cached.asCached();
} }
} }
@ -377,6 +364,10 @@ class SakeAnalysisResult {
final String? yeast; final String? yeast;
final String? manufacturingYearMonth; final String? manufacturingYearMonth;
/// EXP付与使
/// JSON false
final bool isFromCache;
SakeAnalysisResult({ SakeAnalysisResult({
this.name, this.name,
this.brand, this.brand,
@ -393,14 +384,33 @@ class SakeAnalysisResult {
this.riceVariety, this.riceVariety,
this.yeast, this.yeast,
this.manufacturingYearMonth, 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) { factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
// Helper to extract int from map safely // tasteStats: 3 (15)
const requiredStatKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
Map<String, int> stats = {}; Map<String, int> stats = {};
if (json['tasteStats'] is Map) { if (json['tasteStats'] is Map) {
final map = json['tasteStats'] as 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( return SakeAnalysisResult(

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.10.1 sdk: ^3.10.1