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);
|
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('解析もドラフト保存も失敗しました。再試行してください。')),
|
||||||
|
|
|
||||||
|
|
@ -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 で補完、範囲外(1〜5)をクランプ
|
||||||
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue