fix: address code review findings - crash fixes, proxy OCR accuracy, lint cleanup

- tools/proxy/server.js: add systemInstruction + temperature 0 (fixes brand name hallucination e.g. Tokai->Tokaisou)
- gemini_service.dart: add cache read/write to proxy path (was missing, cache was dead code in production)
- camera_screen.dart: guard cameras.first crash when no camera available, add mounted checks in gallery loop
- sake_detail_screen.dart: remove unused gemini_service import, add ignore comment for showDialog context lint
- sake_basic_info_section.dart: remove redundant null-assert operators flagged by dart analyze
- dev_menu_screen.dart: remove unused gemini_service import
- 6 service files: remove emoji from log strings (project rule compliance, 60+ instances)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ponshu Developer 2026-04-15 13:30:00 +09:00
parent b3e1f5d0a3
commit 69b446ee17
11 changed files with 682 additions and 634 deletions

View File

@ -9,7 +9,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:gal/gal.dart'; import 'package:gal/gal.dart';
import '../services/gemini_service.dart';
import '../providers/gemini_provider.dart'; import '../providers/gemini_provider.dart';
import '../services/gemini_exceptions.dart'; import '../services/gemini_exceptions.dart';
import '../services/image_compression_service.dart'; import '../services/image_compression_service.dart';
@ -43,6 +42,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
Future<void>? _initializeControllerFuture; Future<void>? _initializeControllerFuture;
bool _isTakingPicture = false; bool _isTakingPicture = false;
DateTime? _quotaLockoutTime; DateTime? _quotaLockoutTime;
String? _cameraError;
double _minZoom = 1.0; double _minZoom = 1.0;
double _maxZoom = 1.0; double _maxZoom = 1.0;
@ -66,11 +66,17 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
Future<void> _initializeCamera() async { Future<void> _initializeCamera() async {
final cameras = await availableCameras(); final cameras = await availableCameras();
if (cameras.isEmpty) {
if (mounted) {
setState(() => _cameraError = 'カメラが見つかりません。カメラのアクセス権限を確認してください。');
}
return;
}
final firstCamera = cameras.first; final firstCamera = cameras.first;
_controller = CameraController( _controller = CameraController(
firstCamera, firstCamera,
ResolutionPreset.high, ResolutionPreset.high,
enableAudio: false, enableAudio: false,
imageFormatGroup: ImageFormatGroup.jpeg, imageFormatGroup: ImageFormatGroup.jpeg,
); );
@ -288,6 +294,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
); );
// 3. Add compressed permanent path to capture list // 3. Add compressed permanent path to capture list
if (!mounted) return;
setState(() { setState(() {
_capturedImages.add(compressedPath); _capturedImages.add(compressedPath);
}); });
@ -296,6 +303,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
} catch (e) { } catch (e) {
debugPrint('Gallery image compression error: $e'); debugPrint('Gallery image compression error: $e');
// Fallback: Use original path (legacy behavior) // Fallback: Use original path (legacy behavior)
if (!mounted) return;
setState(() { setState(() {
_capturedImages.add(img.path); _capturedImages.add(img.path);
}); });
@ -626,6 +634,23 @@ class _CameraScreenState extends ConsumerState<CameraScreen> with SingleTickerPr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_cameraError != null) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(backgroundColor: Colors.black, foregroundColor: Colors.white),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_cameraError!,
style: const TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
),
),
);
}
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: FutureBuilder<void>( body: FutureBuilder<void>(

View File

@ -6,7 +6,6 @@ import '../services/analysis_cache_service.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import '../models/sake_item.dart'; import '../models/sake_item.dart';
import '../providers/sake_list_provider.dart'; import '../providers/sake_list_provider.dart';
import '../services/gemini_service.dart';
import '../providers/gemini_provider.dart'; import '../providers/gemini_provider.dart';
class DevMenuScreen extends ConsumerWidget { class DevMenuScreen extends ConsumerWidget {

View File

@ -91,11 +91,11 @@ class SakeBasicInfoSection extends ConsumerWidget {
), ),
if (showMbti) if (showMbti)
GestureDetector( GestureDetector(
onTap: () => onTapMbtiCompatibility(context, mbtiResult!, appColors), onTap: () => onTapMbtiCompatibility(context, mbtiResult, appColors),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: badgeColor!.withValues(alpha: 0.1), color: badgeColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: badgeColor.withValues(alpha: 0.4)), border: Border.all(color: badgeColor.withValues(alpha: 0.4)),
), ),

View File

@ -4,7 +4,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../models/sake_item.dart'; import '../models/sake_item.dart';
import '../services/gemini_service.dart';
import '../providers/gemini_provider.dart'; import '../providers/gemini_provider.dart';
import '../services/sake_recommendation_service.dart'; import '../services/sake_recommendation_service.dart';
import '../widgets/analyzing_dialog.dart'; import '../widgets/analyzing_dialog.dart';
@ -37,30 +36,30 @@ class SakeDetailScreen extends ConsumerStatefulWidget {
} }
class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> { class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
// To trigger rebuilds if we don't switch to a stream // To trigger rebuilds if we don't switch to a stream
late SakeItem _sake; late SakeItem _sake;
int _currentImageIndex = 0; int _currentImageIndex = 0;
// Memo logic moved to SakeDetailMemo // Memo logic moved to SakeDetailMemo
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_sake = widget.sake; _sake = widget.sake;
// Memo init removed // Memo init removed
// AI分析情報の編集用コントローラーを初期化 // AI分析情報の編集用コントローラーを初期化
} }
@override @override
void dispose() { void dispose() {
// Memo dispose removed // Memo dispose removed
// AI分析情報の編集用コントローラーを破棄 // AI分析情報の編集用コントローラーを破棄
super.dispose(); super.dispose();
} }
/// Hiveに永続化 /// Hiveに永続化
Future<void> _updateTasteStats(Map<String, int> newStats) async { Future<void> _updateTasteStats(Map<String, int> newStats) async {
final updatedSake = _sake.copyWith( final updatedSake = _sake.copyWith(
tasteStats: newStats, tasteStats: newStats,
@ -86,11 +85,11 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
final appColors = Theme.of(context).extension<AppColors>()!; final appColors = Theme.of(context).extension<AppColors>()!;
final isPro = ref.watch(isProProvider); final isPro = ref.watch(isProProvider);
// //
final allSakeAsync = ref.watch(allSakeItemsProvider); final allSakeAsync = ref.watch(allSakeItemsProvider);
final allSake = allSakeAsync.asData?.value ?? []; final allSake = allSakeAsync.asData?.value ?? [];
// 使 // 使
final recommendations = SakeRecommendationService.getRecommendations( final recommendations = SakeRecommendationService.getRecommendations(
target: _sake, target: _sake,
allItems: allSake, allItems: allSake,
@ -135,7 +134,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Basic Info: AI確信度MBTI相性 (Extracted) // Basic Info: AI確信度MBTI相性 (Extracted)
SakeBasicInfoSection( SakeBasicInfoSection(
sake: _sake, sake: _sake,
onTapName: () => _showTextEditDialog( onTapName: () => _showTextEditDialog(
@ -156,7 +155,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// Taste Radar Chart // Taste Radar Chart
SakeDetailChart( SakeDetailChart(
sake: _sake, sake: _sake,
onTasteStatsEdited: (newStats) => _updateTasteStats(newStats), onTasteStatsEdited: (newStats) => _updateTasteStats(newStats),
@ -164,7 +163,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// Description // Description
if (_sake.hiddenSpecs.description != null && _sake.itemType != ItemType.set) if (_sake.hiddenSpecs.description != null && _sake.itemType != ItemType.set)
Text( Text(
_sake.hiddenSpecs.description!, _sake.hiddenSpecs.description!,
@ -176,7 +175,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
const SizedBox(height: 32), const SizedBox(height: 32),
// AI Specs Accordion // AI Specs Accordion
SakeDetailSpecs( SakeDetailSpecs(
sake: _sake, sake: _sake,
onUpdate: (updatedSake) { onUpdate: (updatedSake) {
@ -186,7 +185,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
// Memo Field // Memo Field
SakeDetailMemo( SakeDetailMemo(
initialMemo: _sake.userData.memo, initialMemo: _sake.userData.memo,
onUpdate: (value) async { onUpdate: (value) async {
@ -199,7 +198,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
const SizedBox(height: 48), const SizedBox(height: 48),
// Related Items 3D Carousel // Related Items 3D Carousel
if (_sake.itemType != ItemType.set) ...[ if (_sake.itemType != ItemType.set) ...[
Row( Row(
children: [ children: [
@ -245,7 +244,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// //
SakenowaDetailRecommendationSection( SakenowaDetailRecommendationSection(
currentSakeName: _sake.displayData.displayName, currentSakeName: _sake.displayData.displayName,
currentTasteData: _sake.hiddenSpecs.activeTasteData, currentTasteData: _sake.hiddenSpecs.activeTasteData,
@ -255,7 +254,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
const SizedBox(height: 48), const SizedBox(height: 48),
], ],
// MBTI Diagnostic Stamp Section (Pro only) // MBTI Diagnostic Stamp Section (Pro only)
if (isPro) ...[ if (isPro) ...[
SakeMbtiStampSection(sake: _sake), SakeMbtiStampSection(sake: _sake),
const SizedBox(height: 24), const SizedBox(height: 24),
@ -265,7 +264,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
), ),
), ),
// Business Pricing Section // Business Pricing Section
SliverToBoxAdapter( SliverToBoxAdapter(
child: SakePricingSection( child: SakePricingSection(
sake: _sake, sake: _sake,
@ -273,7 +272,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
), ),
), ),
// Gap with Safe Area // Gap with Safe Area
SliverPadding( SliverPadding(
padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom), padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom),
), ),
@ -307,10 +306,10 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
} }
Future<void> _reanalyze(BuildContext context) async { Future<void> _reanalyze(BuildContext context) async {
// 1. Check Locks // 1. Check Locks
if (_isAnalyzing) return; if (_isAnalyzing) return;
// 2. Check Quota Lockout // 2. Check Quota Lockout
if (_quotaLockoutTime != null) { if (_quotaLockoutTime != null) {
final remaining = _quotaLockoutTime!.difference(DateTime.now()); final remaining = _quotaLockoutTime!.difference(DateTime.now());
if (remaining.isNegative) { if (remaining.isNegative) {
@ -325,7 +324,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
if (_sake.displayData.imagePaths.isEmpty) return; if (_sake.displayData.imagePaths.isEmpty) return;
// APIエラーになるのを防ぐ // APIエラーになるのを防ぐ
final existingPaths = <String>[]; final existingPaths = <String>[];
for (final path in _sake.displayData.imagePaths) { for (final path in _sake.displayData.imagePaths) {
if (await File(path).exists()) { if (await File(path).exists()) {
@ -346,6 +345,8 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
setState(() => _isAnalyzing = true); setState(() => _isAnalyzing = true);
try { try {
// ignore: use_build_context_synchronously
// mounted 334 await
showDialog( showDialog(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@ -364,7 +365,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
confidenceScore: result.confidenceScore, confidenceScore: result.confidenceScore,
flavorTags: result.flavorTags.isNotEmpty ? result.flavorTags : _sake.hiddenSpecs.flavorTags, flavorTags: result.flavorTags.isNotEmpty ? result.flavorTags : _sake.hiddenSpecs.flavorTags,
tasteStats: result.tasteStats.isNotEmpty ? result.tasteStats : _sake.hiddenSpecs.tasteStats, tasteStats: result.tasteStats.isNotEmpty ? result.tasteStats : _sake.hiddenSpecs.tasteStats,
// New Fields // New Fields
specificDesignation: result.type ?? _sake.hiddenSpecs.type, specificDesignation: result.type ?? _sake.hiddenSpecs.type,
alcoholContent: result.alcoholContent ?? _sake.hiddenSpecs.alcoholContent, alcoholContent: result.alcoholContent ?? _sake.hiddenSpecs.alcoholContent,
polishingRatio: result.polishingRatio ?? _sake.hiddenSpecs.polishingRatio, polishingRatio: result.polishingRatio ?? _sake.hiddenSpecs.polishingRatio,
@ -393,7 +394,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
if (mounted) { if (mounted) {
nav.pop(); // Close dialog nav.pop(); // Close dialog
// Check for Quota Error to set Lockout // Check for Quota Error to set Lockout
if (e.toString().contains('Quota') || e.toString().contains('429')) { if (e.toString().contains('Quota') || e.toString().contains('429')) {
setState(() { setState(() {
_quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1)); _quotaLockoutTime = DateTime.now().add(const Duration(minutes: 1));
@ -544,22 +545,22 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
); );
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
// nav/messenger captured above // nav/messenger captured above
// Day 5: // Day 5:
for (final imagePath in _sake.displayData.imagePaths) { for (final imagePath in _sake.displayData.imagePaths) {
try { try {
final imageFile = File(imagePath); final imageFile = File(imagePath);
if (await imageFile.exists()) { if (await imageFile.exists()) {
await imageFile.delete(); await imageFile.delete();
debugPrint('🗑️ Deleted image file: $imagePath'); debugPrint(' Deleted image file: $imagePath');
} }
} catch (e) { } catch (e) {
debugPrint('⚠️ Failed to delete image file: $imagePath - $e'); debugPrint(' Failed to delete image file: $imagePath - $e');
} }
} }
// Hiveから削除 // Hiveから削除
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
await box.delete(_sake.key); await box.delete(_sake.key);
@ -572,7 +573,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
} }
} }
/// ///
Future<void> _showTextEditDialog( Future<void> _showTextEditDialog(
BuildContext context, { BuildContext context, {
required String title, required String title,
@ -609,7 +610,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
); );
} }
/// MBTI相性詳細ダイアログを表示 /// MBTI相性詳細ダイアログを表示
void _showMbtiCompatibilityDialog( void _showMbtiCompatibilityDialog(
BuildContext context, BuildContext context,
CompatibilityResult result, CompatibilityResult result,
@ -642,7 +643,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Star Rating // Star Rating
Text( Text(
result.starDisplay, result.starDisplay,
style: TextStyle( style: TextStyle(
@ -652,7 +653,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Percentage & Level // Percentage & Level
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
@ -683,7 +684,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// Match Reasons // Match Reasons
if (result.reasons.isNotEmpty) ...[ if (result.reasons.isNotEmpty) ...[
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
@ -727,7 +728,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
); );
} }
/// ///
Future<void> _showBreweryEditDialog(BuildContext context) async { Future<void> _showBreweryEditDialog(BuildContext context) async {
final breweryController = TextEditingController(text: _sake.displayData.displayBrewery); final breweryController = TextEditingController(text: _sake.displayData.displayBrewery);
final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture); final prefectureController = TextEditingController(text: _sake.displayData.displayPrefecture);
@ -779,7 +780,7 @@ class _SakeDetailScreenState extends ConsumerState<SakeDetailScreen> {
); );
} }
/// ///
Future<void> _showPhotoEditModal(BuildContext context) async { Future<void> _showPhotoEditModal(BuildContext context) async {
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,

View File

@ -14,256 +14,256 @@ import 'gemini_service.dart';
/// 2. : getPendingDrafts() /// 2. : getPendingDrafts()
/// 3. analyzeDraft() analyzeAllDrafts() /// 3. analyzeDraft() analyzeAllDrafts()
class DraftService { class DraftService {
static const _uuid = Uuid(); static const _uuid = Uuid();
/// Draft /// Draft
/// ///
/// ///
/// AI解析時に使用されます /// AI解析時に使用されます
/// ///
/// [photoPaths] /// [photoPaths]
/// ///
/// Returns: Draft SakeItemのkey /// Returns: Draft SakeItemのkey
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart
/// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]); /// final draftKey = await DraftService.saveDraft([imagePath1, imagePath2]);
/// ``` /// ```
static Future<String> saveDraft(List<String> photoPaths) async { static Future<String> saveDraft(List<String> photoPaths) async {
try { try {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
// 🔧 FIX: draftPhotoPathにimagePathsに保存 // FIX: draftPhotoPathにimagePathsに保存
final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : ''; final firstPhotoPath = photoPaths.isNotEmpty ? photoPaths.first : '';
// Draft用の仮データを作成 // Draft用の仮データを作成
final draftItem = SakeItem( final draftItem = SakeItem(
id: _uuid.v4(), id: _uuid.v4(),
isPendingAnalysis: true, isPendingAnalysis: true,
draftPhotoPath: firstPhotoPath, draftPhotoPath: firstPhotoPath,
displayData: DisplayData( displayData: DisplayData(
name: '解析待ち', name: '解析待ち',
brewery: '---', brewery: '---',
prefecture: '---', prefecture: '---',
imagePaths: photoPaths, // 🔧 FIX: imagePaths: photoPaths, // FIX:
rating: null, rating: null,
), ),
hiddenSpecs: HiddenSpecs( hiddenSpecs: HiddenSpecs(
description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。', description: 'オフライン時に撮影された写真です。オンライン復帰後に自動解析されます。',
tasteStats: {}, tasteStats: {},
flavorTags: [], flavorTags: [],
), ),
userData: UserData( userData: UserData(
isFavorite: false, isFavorite: false,
isUserEdited: false, isUserEdited: false,
markup: 3.0, markup: 3.0,
), ),
gamification: Gamification(ponPoints: 0), gamification: Gamification(ponPoints: 0),
metadata: Metadata( metadata: Metadata(
createdAt: DateTime.now(), createdAt: DateTime.now(),
aiConfidence: null, aiConfidence: null,
), ),
); );
await box.add(draftItem); await box.add(draftItem);
debugPrint('Draft saved: ${draftItem.id}'); debugPrint('Draft saved: ${draftItem.id}');
return draftItem.id; return draftItem.id;
} catch (e) { } catch (e) {
debugPrint('Draft save error: $e'); debugPrint('Draft save error: $e');
rethrow; rethrow;
} }
} }
/// Draftを取得 /// Draftを取得
/// ///
/// Returns: /// Returns:
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart
/// final drafts = await DraftService.getPendingDrafts(); /// final drafts = await DraftService.getPendingDrafts();
/// print('未解析: ${drafts.length}'); /// print('未解析: ${drafts.length}');
/// ``` /// ```
static Future<List<SakeItem>> getPendingDrafts() async { static Future<List<SakeItem>> getPendingDrafts() async {
try { try {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
final allItems = box.values.toList(); final allItems = box.values.toList();
final pendingDrafts = allItems.where((item) => item.isPendingAnalysis).toList(); final pendingDrafts = allItems.where((item) => item.isPendingAnalysis).toList();
debugPrint('Pending drafts: ${pendingDrafts.length}'); debugPrint('Pending drafts: ${pendingDrafts.length}');
return pendingDrafts; return pendingDrafts;
} catch (e) { } catch (e) {
debugPrint('Get pending drafts error: $e'); debugPrint('Get pending drafts error: $e');
return []; return [];
} }
} }
/// Draftの件数を取得 /// Draftの件数を取得
/// ///
/// Returns: /// Returns:
static Future<int> getPendingCount() async { static Future<int> getPendingCount() async {
final drafts = await getPendingDrafts(); final drafts = await getPendingDrafts();
return drafts.length; return drafts.length;
} }
/// Draftを解析しSakeItemに変換 /// Draftを解析しSakeItemに変換
/// ///
/// [itemKey] DraftアイテムのHive key /// [itemKey] DraftアイテムのHive key
/// ///
/// Returns: SakeAnalysisResult /// Returns: SakeAnalysisResult
/// ///
/// Throws: AI解析エラー /// Throws: AI解析エラー
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart
/// try { /// try {
/// final result = await DraftService.analyzeDraft(itemKey); /// final result = await DraftService.analyzeDraft(itemKey);
/// print('解析完了: ${result.name}'); /// print('解析完了: ${result.name}');
/// } catch (e) { /// } catch (e) {
/// print('解析失敗: $e'); /// print('解析失敗: $e');
/// } /// }
/// ``` /// ```
static Future<SakeAnalysisResult> analyzeDraft( static Future<SakeAnalysisResult> analyzeDraft(
dynamic itemKey, { dynamic itemKey, {
required GeminiService geminiService, required GeminiService geminiService,
}) async { }) async {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
final item = box.get(itemKey); final item = box.get(itemKey);
if (item == null) { if (item == null) {
throw Exception('Draft item not found: $itemKey'); throw Exception('Draft item not found: $itemKey');
} }
if (!item.isPendingAnalysis) { if (!item.isPendingAnalysis) {
throw Exception('Item is not a draft: $itemKey'); throw Exception('Item is not a draft: $itemKey');
} }
final photoPath = item.draftPhotoPath; final photoPath = item.draftPhotoPath;
if (photoPath == null || photoPath.isEmpty) { if (photoPath == null || photoPath.isEmpty) {
throw Exception('Draft has no photo path: $itemKey'); throw Exception('Draft has no photo path: $itemKey');
} }
debugPrint('Analyzing draft: $itemKey (${item.displayData.displayName})'); debugPrint('Analyzing draft: $itemKey (${item.displayData.displayName})');
final imagePaths = item.displayData.imagePaths; final imagePaths = item.displayData.imagePaths;
final pathsToAnalyze = imagePaths.isNotEmpty ? imagePaths : [photoPath]; final pathsToAnalyze = imagePaths.isNotEmpty ? imagePaths : [photoPath];
final result = await geminiService.analyzeSakeLabel(pathsToAnalyze); final result = await geminiService.analyzeSakeLabel(pathsToAnalyze);
debugPrint(' Analysis completed: ${result.name}'); debugPrint(' Analysis completed: ${result.name}');
// Draftを正式なアイテムに更新 // Draftを正式なアイテムに更新
final updatedItem = item.copyWith( final updatedItem = item.copyWith(
name: result.name, name: result.name,
brand: result.brand, brand: result.brand,
prefecture: result.prefecture, prefecture: result.prefecture,
description: result.description, description: result.description,
catchCopy: result.catchCopy, catchCopy: result.catchCopy,
flavorTags: result.flavorTags, flavorTags: result.flavorTags,
tasteStats: result.tasteStats, tasteStats: result.tasteStats,
confidenceScore: result.confidenceScore, confidenceScore: result.confidenceScore,
// //
specificDesignation: result.type, specificDesignation: result.type,
alcoholContent: result.alcoholContent, alcoholContent: result.alcoholContent,
polishingRatio: result.polishingRatio, polishingRatio: result.polishingRatio,
sakeMeterValue: result.sakeMeterValue, sakeMeterValue: result.sakeMeterValue,
riceVariety: result.riceVariety, riceVariety: result.riceVariety,
yeast: result.yeast, yeast: result.yeast,
manufacturingYearMonth: result.manufacturingYearMonth, manufacturingYearMonth: result.manufacturingYearMonth,
// Draft状態を解除 // Draft状態を解除
isPendingAnalysis: false, isPendingAnalysis: false,
draftPhotoPath: null, draftPhotoPath: null,
); );
// await updatedItem.save(); // Error: This object is currently not in a box. // await updatedItem.save(); // Error: This object is currently not in a box.
await box.put(itemKey, updatedItem); await box.put(itemKey, updatedItem);
debugPrint('💾 Draft updated to normal item: $itemKey'); debugPrint(' Draft updated to normal item: $itemKey');
return result; return result;
} }
/// Draftを一括解析 /// Draftを一括解析
/// ///
/// Returns: {, , } /// Returns: {, , }
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart
/// final result = await DraftService.analyzeAllDrafts((progress, total) { /// final result = await DraftService.analyzeAllDrafts((progress, total) {
/// print('進捗: $progress / $total'); /// print('進捗: $progress / $total');
/// }); /// });
/// print('成功: ${result['success']}, 失敗: ${result['failed']}'); /// print('成功: ${result['success']}, 失敗: ${result['failed']}');
/// ``` /// ```
static Future<Map<String, dynamic>> analyzeAllDrafts({ static Future<Map<String, dynamic>> analyzeAllDrafts({
required GeminiService geminiService, required GeminiService geminiService,
Function(int progress, int total)? onProgress, Function(int progress, int total)? onProgress,
}) async { }) async {
final drafts = await getPendingDrafts(); final drafts = await getPendingDrafts();
final total = drafts.length; final total = drafts.length;
if (total == 0) { if (total == 0) {
debugPrint('No pending drafts to analyze'); debugPrint('No pending drafts to analyze');
return {'success': 0, 'failed': 0, 'errors': []}; return {'success': 0, 'failed': 0, 'errors': []};
} }
int successCount = 0; int successCount = 0;
int failedCount = 0; int failedCount = 0;
final List<String> errors = []; final List<String> errors = [];
for (int i = 0; i < total; i++) { for (int i = 0; i < total; i++) {
final draft = drafts[i]; final draft = drafts[i];
final itemKey = draft.key; final itemKey = draft.key;
try { try {
onProgress?.call(i + 1, total); onProgress?.call(i + 1, total);
await analyzeDraft(itemKey, geminiService: geminiService); await analyzeDraft(itemKey, geminiService: geminiService);
successCount++; successCount++;
debugPrint('[${i+1}/$total] Success: ${draft.displayData.displayName}'); debugPrint('[${i+1}/$total] Success: ${draft.displayData.displayName}');
} catch (e) { } catch (e) {
failedCount++; failedCount++;
final errorMsg = '[${i+1}/$total] ${draft.displayData.displayName}: $e'; final errorMsg = '[${i+1}/$total] ${draft.displayData.displayName}: $e';
errors.add(errorMsg); errors.add(errorMsg);
debugPrint(errorMsg); debugPrint(errorMsg);
} }
} }
debugPrint('Batch analysis completed: $successCount success, $failedCount failed'); debugPrint('Batch analysis completed: $successCount success, $failedCount failed');
return { return {
'success': successCount, 'success': successCount,
'failed': failedCount, 'failed': failedCount,
'errors': errors, 'errors': errors,
}; };
} }
/// Draft /// Draft
/// ///
/// [itemKey] DraftアイテムのHive key /// [itemKey] DraftアイテムのHive key
/// ///
/// Usage: /// Usage:
/// ```dart /// ```dart
/// await DraftService.deleteDraft(itemKey); /// await DraftService.deleteDraft(itemKey);
/// ``` /// ```
static Future<void> deleteDraft(dynamic itemKey) async { static Future<void> deleteDraft(dynamic itemKey) async {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
final item = box.get(itemKey); final item = box.get(itemKey);
if (item != null && item.isPendingAnalysis) { if (item != null && item.isPendingAnalysis) {
await box.delete(itemKey); await box.delete(itemKey);
debugPrint('Draft deleted: $itemKey'); debugPrint('Draft deleted: $itemKey');
} }
} }
/// Draftを削除 /// Draftを削除
/// ///
/// Returns: /// Returns:
static Future<int> deleteAllDrafts() async { static Future<int> deleteAllDrafts() async {
final drafts = await getPendingDrafts(); final drafts = await getPendingDrafts();
final count = drafts.length; final count = drafts.length;
for (final draft in drafts) { for (final draft in drafts) {
await deleteDraft(draft.key); await deleteDraft(draft.key);
} }
debugPrint('All drafts deleted: $count'); debugPrint('All drafts deleted: $count');
return count; return count;
} }
} }

View File

@ -76,8 +76,18 @@ class GeminiService {
return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh); return _callDirectApi(imagePaths, customPrompt, forceRefresh: forceRefresh);
} }
// 1. forceRefresh=false
if (!forceRefresh && imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
final cached = await AnalysisCacheService.getCached(imageHash);
if (cached != null) {
debugPrint('Proxy cache hit: skipping API call');
return cached;
}
}
try { try {
// 1. () // 2. ()
if (_lastApiCallTime != null) { if (_lastApiCallTime != null) {
final elapsed = DateTime.now().difference(_lastApiCallTime!); final elapsed = DateTime.now().difference(_lastApiCallTime!);
if (elapsed < _minApiInterval) { if (elapsed < _minApiInterval) {
@ -139,7 +149,7 @@ class GeminiService {
final result = SakeAnalysisResult.fromJson(data); final result = SakeAnalysisResult.fromJson(data);
// //
if (result.tasteStats.isEmpty || if (result.tasteStats.isEmpty ||
result.tasteStats.values.every((v) => v == 0)) { result.tasteStats.values.every((v) => v == 0)) {
debugPrint('WARNING: AI returned empty or zero taste stats. This item will not form a valid chart.'); debugPrint('WARNING: AI returned empty or zero taste stats. This item will not form a valid chart.');
} else { } else {
@ -147,14 +157,23 @@ class GeminiService {
final requiredKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body']; final requiredKeys = ['aroma', 'sweetness', 'acidity', 'bitterness', 'body'];
final actualKeys = result.tasteStats.keys.toList(); final actualKeys = result.tasteStats.keys.toList();
final missing = requiredKeys.where((k) => !actualKeys.contains(k)).toList(); final missing = requiredKeys.where((k) => !actualKeys.contains(k)).toList();
if (missing.isNotEmpty) { if (missing.isNotEmpty) {
debugPrint('WARNING: AI response missing keys: $missing. Old schema?'); debugPrint('WARNING: AI response missing keys: $missing. Old schema?');
// We could throw here, but for now let's just log.
// In strict mode, we might want to fail the analysis to force retry.
} }
} }
// API不使用
if (imagePaths.isNotEmpty) {
final imageHash = await AnalysisCacheService.computeCombinedHash(imagePaths);
await AnalysisCacheService.saveCache(imageHash, result);
await AnalysisCacheService.registerBrandIndex(
result.name,
imageHash,
forceUpdate: forceRefresh,
);
}
return result; return result;
} else { } else {
// Proxy側での論理エラー () // Proxy側での論理エラー ()

View File

@ -5,7 +5,7 @@ import 'package:path_provider/path_provider.dart';
import '../models/sake_item.dart'; import '../models/sake_item.dart';
import 'image_compression_service.dart'; import 'image_compression_service.dart';
// Critical Fix (Day 5.5): cleanupTempFiles() // Critical Fix (Day 5.5): cleanupTempFiles()
// : getApplicationDocumentsDirectory() _compressed, _gallery // : getApplicationDocumentsDirectory() _compressed, _gallery
// : // :
// : getTemporaryDirectory() // : getTemporaryDirectory()
@ -14,15 +14,15 @@ import 'image_compression_service.dart';
/// ///
/// : /// :
class ImageBatchCompressionService { class ImageBatchCompressionService {
/// ///
/// ///
/// : /// :
/// 1. SakeItem /// 1. SakeItem
/// 2. 1024px, JPEG 85% /// 2. 1024px, JPEG 85%
/// 3. /// 3.
/// 4. SakeItem imagePaths /// 4. SakeItem imagePaths
/// ///
/// : (, , ) /// : (, , )
static Future<(int, int, int)> compressAllImages({ static Future<(int, int, int)> compressAllImages({
required Function(int current, int total, String fileName) onProgress, required Function(int current, int total, String fileName) onProgress,
}) async { }) async {
@ -34,7 +34,7 @@ class ImageBatchCompressionService {
int savedBytes = 0; int savedBytes = 0;
int totalImages = 0; int totalImages = 0;
// //
for (final item in allItems) { for (final item in allItems) {
totalImages += item.displayData.imagePaths.length; totalImages += item.displayData.imagePaths.length;
} }
@ -50,62 +50,62 @@ class ImageBatchCompressionService {
final file = File(originalPath); final file = File(originalPath);
try { try {
// //
if (!await file.exists()) { if (!await file.exists()) {
debugPrint('⚠️ File not found: $originalPath'); debugPrint(' File not found: $originalPath');
newPaths.add(originalPath); // newPaths.add(originalPath); //
failedCount++; failedCount++;
continue; continue;
} }
// //
final originalSize = await file.length(); final originalSize = await file.length();
// //
final fileName = originalPath.split('/').last; final fileName = originalPath.split('/').last;
onProgress(processedCount, totalImages, fileName); onProgress(processedCount, totalImages, fileName);
// //
if (originalSize < 500 * 1024) { // 500KB以下なら既に圧縮済み if (originalSize < 500 * 1024) { // 500KB以下なら既に圧縮済み
debugPrint(' Already compressed: $fileName (${(originalSize / 1024).toStringAsFixed(1)}KB)'); debugPrint(' Already compressed: $fileName (${(originalSize / 1024).toStringAsFixed(1)}KB)');
newPaths.add(originalPath); newPaths.add(originalPath);
successCount++; successCount++;
continue; continue;
} }
// Day 5: // Day 5:
debugPrint('🗜️ Compressing: $fileName (${(originalSize / 1024 / 1024).toStringAsFixed(1)}MB)'); debugPrint(' Compressing: $fileName (${(originalSize / 1024 / 1024).toStringAsFixed(1)}MB)');
// 1. targetPathを指定しない // 1. targetPathを指定しない
final tempCompressedPath = await ImageCompressionService.compressForGemini(originalPath); final tempCompressedPath = await ImageCompressionService.compressForGemini(originalPath);
// 2. // 2.
final compressedSize = await File(tempCompressedPath).length(); final compressedSize = await File(tempCompressedPath).length();
final saved = originalSize - compressedSize; final saved = originalSize - compressedSize;
savedBytes += saved; savedBytes += saved;
debugPrint(' Compressed: $fileName - ${(originalSize / 1024).toStringAsFixed(1)}KB → ${(compressedSize / 1024).toStringAsFixed(1)}KB (${(saved / 1024).toStringAsFixed(1)}KB saved)'); debugPrint(' Compressed: $fileName - ${(originalSize / 1024).toStringAsFixed(1)}KB → ${(compressedSize / 1024).toStringAsFixed(1)}KB (${(saved / 1024).toStringAsFixed(1)}KB saved)');
// 3. // 3.
try { try {
await file.delete(); await file.delete();
debugPrint('🗑️ Deleted original: $originalPath'); debugPrint(' Deleted original: $originalPath');
} catch (e) { } catch (e) {
debugPrint('⚠️ Failed to delete original: $e'); debugPrint(' Failed to delete original: $e');
// //
await File(tempCompressedPath).delete(); await File(tempCompressedPath).delete();
newPaths.add(originalPath); newPaths.add(originalPath);
failedCount++; failedCount++;
continue; continue;
} }
// 4. // 4.
try { try {
await File(tempCompressedPath).rename(originalPath); await File(tempCompressedPath).rename(originalPath);
debugPrint('📦 Moved compressed file to: $originalPath'); debugPrint(' Moved compressed file to: $originalPath');
} catch (e) { } catch (e) {
debugPrint('⚠️ Failed to rename file: $e'); debugPrint(' Failed to rename file: $e');
// 使 // 使
newPaths.add(tempCompressedPath); newPaths.add(tempCompressedPath);
failedCount++; failedCount++;
continue; continue;
@ -115,13 +115,13 @@ class ImageBatchCompressionService {
successCount++; successCount++;
} catch (e) { } catch (e) {
debugPrint(' Failed to compress: $originalPath - $e'); debugPrint(' Failed to compress: $originalPath - $e');
newPaths.add(originalPath); // newPaths.add(originalPath); //
failedCount++; failedCount++;
} }
} }
// SakeItem imagePaths // SakeItem imagePaths
if (newPaths.isNotEmpty) { if (newPaths.isNotEmpty) {
final updatedItem = item.copyWith( final updatedItem = item.copyWith(
imagePaths: newPaths, imagePaths: newPaths,
@ -133,9 +133,9 @@ class ImageBatchCompressionService {
return (successCount, failedCount, savedBytes); return (successCount, failedCount, savedBytes);
} }
/// 使 /// 使
/// ///
/// : (, ) /// : (, )
static Future<(int, int)> getStorageUsage() async { static Future<(int, int)> getStorageUsage() async {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
final allItems = box.values.toList(); final allItems = box.values.toList();
@ -156,45 +156,45 @@ class ImageBatchCompressionService {
return (totalFiles, totalBytes); return (totalFiles, totalBytes);
} }
/// ///
/// ///
/// 🔒 Safe: getTemporaryDirectory() /// Safe: getTemporaryDirectory()
/// ///
/// : (, ) /// : (, )
static Future<(int, int)> cleanupTempFiles() async { static Future<(int, int)> cleanupTempFiles() async {
try { try {
// : getTemporaryDirectory() 使getApplicationDocumentsDirectory() // : getTemporaryDirectory() 使getApplicationDocumentsDirectory()
final directory = await getTemporaryDirectory(); final directory = await getTemporaryDirectory();
final dir = Directory(directory.path); final dir = Directory(directory.path);
int deletedCount = 0; int deletedCount = 0;
int deletedBytes = 0; int deletedBytes = 0;
// //
await for (final entity in dir.list()) { await for (final entity in dir.list()) {
if (entity is File) { if (entity is File) {
final fileName = entity.path.split('/').last; final fileName = entity.path.split('/').last;
// //
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) { if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
try { try {
final fileSize = await entity.length(); final fileSize = await entity.length();
await entity.delete(); await entity.delete();
deletedCount++; deletedCount++;
deletedBytes += fileSize; deletedBytes += fileSize;
debugPrint('🗑️ Deleted temp file: $fileName (${(fileSize / 1024).toStringAsFixed(1)}KB)'); debugPrint(' Deleted temp file: $fileName (${(fileSize / 1024).toStringAsFixed(1)}KB)');
} catch (e) { } catch (e) {
debugPrint('⚠️ Failed to delete temp file: $fileName - $e'); debugPrint(' Failed to delete temp file: $fileName - $e');
} }
} }
} }
} }
debugPrint(' Cleanup complete: $deletedCount files, ${(deletedBytes / 1024 / 1024).toStringAsFixed(1)}MB'); debugPrint(' Cleanup complete: $deletedCount files, ${(deletedBytes / 1024 / 1024).toStringAsFixed(1)}MB');
return (deletedCount, deletedBytes); return (deletedCount, deletedBytes);
} catch (e) { } catch (e) {
debugPrint(' Cleanup error: $e'); debugPrint(' Cleanup error: $e');
return (0, 0); return (0, 0);
} }
} }

View File

@ -9,217 +9,217 @@ import '../models/sake_item.dart';
/// ///
/// ///
class ImagePathRepairService { class ImagePathRepairService {
/// ///
/// ///
/// : (, , ) /// : (, , )
static Future<(int, int, int)> diagnose() async { static Future<(int, int, int)> diagnose() async {
try { try {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
final items = box.values.toList(); final items = box.values.toList();
int totalItems = items.length; int totalItems = items.length;
int problematicItems = 0; int problematicItems = 0;
int missingFiles = 0; int missingFiles = 0;
debugPrint('🔍 画像パス診断開始: $totalItems アイテム'); debugPrint(' 画像パス診断開始: $totalItems アイテム');
for (final item in items) { for (final item in items) {
bool hasIssue = false; bool hasIssue = false;
for (final imagePath in item.displayData.imagePaths) { for (final imagePath in item.displayData.imagePaths) {
final file = File(imagePath); final file = File(imagePath);
if (!await file.exists()) { if (!await file.exists()) {
debugPrint(' Missing: $imagePath (${item.displayData.displayName})'); debugPrint(' Missing: $imagePath (${item.displayData.displayName})');
missingFiles++; missingFiles++;
hasIssue = true; hasIssue = true;
} }
} }
if (hasIssue) { if (hasIssue) {
problematicItems++; problematicItems++;
} }
} }
debugPrint('📊 診断結果: $totalItems アイテム中 $problematicItems に問題あり ($missingFiles ファイル欠損)'); debugPrint(' 診断結果: $totalItems アイテム中 $problematicItems に問題あり ($missingFiles ファイル欠損)');
return (totalItems, problematicItems, missingFiles); return (totalItems, problematicItems, missingFiles);
} catch (e) { } catch (e) {
debugPrint(' 診断エラー: $e'); debugPrint(' 診断エラー: $e');
return (0, 0, 0); return (0, 0, 0);
} }
} }
/// ///
/// ///
/// : /// :
/// 1. /// 1.
/// 2. getApplicationDocumentsDirectory() /// 2. getApplicationDocumentsDirectory()
/// 3. UUID /// 3. UUID
/// 4. /// 4.
/// ///
/// : (, ) /// : (, )
static Future<(int, int)> repair() async { static Future<(int, int)> repair() async {
try { try {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
final items = box.values.toList(); final items = box.values.toList();
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
// //
final availableFiles = <String>[]; final availableFiles = <String>[];
final dir = Directory(appDir.path); final dir = Directory(appDir.path);
await for (final entity in dir.list()) { await for (final entity in dir.list()) {
if (entity is File) { if (entity is File) {
final fileName = path.basename(entity.path); final fileName = path.basename(entity.path);
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) { if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
availableFiles.add(entity.path); availableFiles.add(entity.path);
} }
} }
} }
debugPrint('📁 利用可能な画像ファイル: ${availableFiles.length}'); debugPrint(' 利用可能な画像ファイル: ${availableFiles.length}');
int repairedItems = 0; int repairedItems = 0;
int repairedPaths = 0; int repairedPaths = 0;
for (final item in items) { for (final item in items) {
bool needsRepair = false; bool needsRepair = false;
List<String> newPaths = []; List<String> newPaths = [];
for (final oldPath in item.displayData.imagePaths) { for (final oldPath in item.displayData.imagePaths) {
final file = File(oldPath); final file = File(oldPath);
if (await file.exists()) { if (await file.exists()) {
// //
newPaths.add(oldPath); newPaths.add(oldPath);
} else { } else {
// //
final oldFileName = path.basename(oldPath); final oldFileName = path.basename(oldPath);
// //
String? matchedPath; String? matchedPath;
for (final availablePath in availableFiles) { for (final availablePath in availableFiles) {
if (path.basename(availablePath) == oldFileName) { if (path.basename(availablePath) == oldFileName) {
matchedPath = availablePath; matchedPath = availablePath;
break; break;
} }
} }
if (matchedPath != null) { if (matchedPath != null) {
newPaths.add(matchedPath); newPaths.add(matchedPath);
repairedPaths++; repairedPaths++;
needsRepair = true; needsRepair = true;
debugPrint('🔧 Repaired: $oldFileName -> $matchedPath'); debugPrint(' Repaired: $oldFileName -> $matchedPath');
} else { } else {
// //
debugPrint('⚠️ No match for: $oldFileName (${item.displayData.displayName})'); debugPrint(' No match for: $oldFileName (${item.displayData.displayName})');
} }
} }
} }
if (needsRepair && newPaths.isNotEmpty) { if (needsRepair && newPaths.isNotEmpty) {
// //
final updatedItem = item.copyWith( final updatedItem = item.copyWith(
imagePaths: newPaths, imagePaths: newPaths,
); );
await box.put(item.key, updatedItem); // 🔧 Fixed: updatedItem await box.put(item.key, updatedItem); // Fixed: updatedItem
repairedItems++; repairedItems++;
debugPrint(' Updated: ${item.displayData.displayName} (${newPaths.length} paths)'); debugPrint(' Updated: ${item.displayData.displayName} (${newPaths.length} paths)');
} }
} }
debugPrint(' 修復完了: $repairedItems アイテム、$repairedPaths パス'); debugPrint(' 修復完了: $repairedItems アイテム、$repairedPaths パス');
return (repairedItems, repairedPaths); return (repairedItems, repairedPaths);
} catch (e) { } catch (e) {
debugPrint(' 修復エラー: $e'); debugPrint(' 修復エラー: $e');
return (0, 0); return (0, 0);
} }
} }
/// Hiveに参照されていない画像ファイル /// Hiveに参照されていない画像ファイル
/// ///
/// : (, (bytes)) /// : (, (bytes))
static Future<(int, int)> findOrphanedFiles() async { static Future<(int, int)> findOrphanedFiles() async {
try { try {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
final items = box.values.toList(); final items = box.values.toList();
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
// Hiveに登録されているすべての画像パスを収集 // Hiveに登録されているすべての画像パスを収集
final registeredPaths = <String>{}; final registeredPaths = <String>{};
for (final item in items) { for (final item in items) {
registeredPaths.addAll(item.displayData.imagePaths); registeredPaths.addAll(item.displayData.imagePaths);
} }
// //
int orphanedCount = 0; int orphanedCount = 0;
int totalSize = 0; int totalSize = 0;
final dir = Directory(appDir.path); final dir = Directory(appDir.path);
await for (final entity in dir.list()) { await for (final entity in dir.list()) {
if (entity is File) { if (entity is File) {
final fileName = path.basename(entity.path); final fileName = path.basename(entity.path);
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) { if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
if (!registeredPaths.contains(entity.path)) { if (!registeredPaths.contains(entity.path)) {
final size = await entity.length(); final size = await entity.length();
orphanedCount++; orphanedCount++;
totalSize += size; totalSize += size;
debugPrint('🗑️ Orphaned: $fileName (${(size / 1024).toStringAsFixed(1)}KB)'); debugPrint(' Orphaned: $fileName (${(size / 1024).toStringAsFixed(1)}KB)');
} }
} }
} }
} }
debugPrint('📊 孤立ファイル: $orphanedCount 個 (${(totalSize / 1024 / 1024).toStringAsFixed(1)}MB)'); debugPrint(' 孤立ファイル: $orphanedCount 個 (${(totalSize / 1024 / 1024).toStringAsFixed(1)}MB)');
return (orphanedCount, totalSize); return (orphanedCount, totalSize);
} catch (e) { } catch (e) {
debugPrint(' 孤立ファイル検出エラー: $e'); debugPrint(' 孤立ファイル検出エラー: $e');
return (0, 0); return (0, 0);
} }
} }
/// ///
/// ///
/// : (, (bytes)) /// : (, (bytes))
static Future<(int, int)> cleanOrphanedFiles() async { static Future<(int, int)> cleanOrphanedFiles() async {
try { try {
final box = Hive.box<SakeItem>('sake_items'); final box = Hive.box<SakeItem>('sake_items');
final items = box.values.toList(); final items = box.values.toList();
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
// Hiveに登録されているすべての画像パスを収集 // Hiveに登録されているすべての画像パスを収集
final registeredPaths = <String>{}; final registeredPaths = <String>{};
for (final item in items) { for (final item in items) {
registeredPaths.addAll(item.displayData.imagePaths); registeredPaths.addAll(item.displayData.imagePaths);
} }
// //
int deletedCount = 0; int deletedCount = 0;
int deletedSize = 0; int deletedSize = 0;
final dir = Directory(appDir.path); final dir = Directory(appDir.path);
await for (final entity in dir.list()) { await for (final entity in dir.list()) {
if (entity is File) { if (entity is File) {
final fileName = path.basename(entity.path); final fileName = path.basename(entity.path);
if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) { if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg') || fileName.endsWith('.png')) {
if (!registeredPaths.contains(entity.path)) { if (!registeredPaths.contains(entity.path)) {
final size = await entity.length(); final size = await entity.length();
await entity.delete(); await entity.delete();
deletedCount++; deletedCount++;
deletedSize += size; deletedSize += size;
debugPrint('🗑️ Deleted: $fileName (${(size / 1024).toStringAsFixed(1)}KB)'); debugPrint(' Deleted: $fileName (${(size / 1024).toStringAsFixed(1)}KB)');
} }
} }
} }
} }
debugPrint(' 孤立ファイル削除完了: $deletedCount 個 (${(deletedSize / 1024 / 1024).toStringAsFixed(1)}MB)'); debugPrint(' 孤立ファイル削除完了: $deletedCount 個 (${(deletedSize / 1024 / 1024).toStringAsFixed(1)}MB)');
return (deletedCount, deletedSize); return (deletedCount, deletedSize);
} catch (e) { } catch (e) {
debugPrint(' 孤立ファイル削除エラー: $e'); debugPrint(' 孤立ファイル削除エラー: $e');
return (0, 0); return (0, 0);
} }
} }
} }

View File

@ -34,35 +34,35 @@ class SakenowaAutoMatchingService {
SakenowaAutoMatchingService(this._sakenowaService); SakenowaAutoMatchingService(this._sakenowaService);
/// ///
/// ///
/// [sakeItem]: /// [sakeItem]:
/// [minScore]: : 0.7 /// [minScore]: : 0.7
/// [autoApply]: : false /// [autoApply]: : false
/// ///
/// Returns: scoreが0 /// Returns: scoreが0
Future<MatchResult> matchSake({ Future<MatchResult> matchSake({
required SakeItem sakeItem, required SakeItem sakeItem,
double minScore = 0.7, double minScore = 0.7,
bool autoApply = false, bool autoApply = false,
}) async { }) async {
try { try {
debugPrint('🔍 [SakenowaAutoMatching] 開始: ${sakeItem.displayData.displayName}'); debugPrint(' [SakenowaAutoMatching] 開始: ${sakeItem.displayData.displayName}');
// //
final brands = await _sakenowaService.getBrands(); final brands = await _sakenowaService.getBrands();
final breweries = await _sakenowaService.getBreweries(); final breweries = await _sakenowaService.getBreweries();
final areas = await _sakenowaService.getAreas(); final areas = await _sakenowaService.getAreas();
final flavorCharts = await _sakenowaService.getFlavorCharts(); final flavorCharts = await _sakenowaService.getFlavorCharts();
debugPrint('📊 [SakenowaAutoMatching] データ取得完了: ${brands.length} brands'); debugPrint(' [SakenowaAutoMatching] データ取得完了: ${brands.length} brands');
// //
final breweryMap = {for (var b in breweries) b.id: b}; final breweryMap = {for (var b in breweries) b.id: b};
final areaMap = {for (var a in areas) a.id: a}; final areaMap = {for (var a in areas) a.id: a};
final chartMap = {for (var c in flavorCharts) c.brandId: c}; final chartMap = {for (var c in flavorCharts) c.brandId: c};
// //
SakenowaBrand? bestBrand; SakenowaBrand? bestBrand;
double bestScore = 0.0; double bestScore = 0.0;
@ -78,24 +78,24 @@ class SakenowaAutoMatchingService {
} }
} }
// //
if (bestScore < minScore) { if (bestScore < minScore) {
debugPrint(' [SakenowaAutoMatching] スコア不足: $bestScore < $minScore'); debugPrint(' [SakenowaAutoMatching] スコア不足: $bestScore < $minScore');
return MatchResult( return MatchResult(
score: bestScore, score: bestScore,
isConfident: false, isConfident: false,
); );
} }
// //
final brewery = bestBrand != null ? breweryMap[bestBrand.breweryId] : null; final brewery = bestBrand != null ? breweryMap[bestBrand.breweryId] : null;
final area = brewery != null ? areaMap[brewery.areaId] : null; final area = brewery != null ? areaMap[brewery.areaId] : null;
final chart = bestBrand != null ? chartMap[bestBrand.id] : null; final chart = bestBrand != null ? chartMap[bestBrand.id] : null;
debugPrint(' [SakenowaAutoMatching] マッチング成功!'); debugPrint(' [SakenowaAutoMatching] マッチング成功!');
debugPrint(' 銘柄: ${bestBrand?.name} (スコア: $bestScore)'); debugPrint(' 銘柄: ${bestBrand?.name} (スコア: $bestScore)');
debugPrint(' 酒蔵: ${brewery?.name}'); debugPrint(' 酒蔵: ${brewery?.name}');
debugPrint(' 地域: ${area?.name}'); debugPrint(' 地域: ${area?.name}');
final result = MatchResult( final result = MatchResult(
brand: bestBrand, brand: bestBrand,
@ -106,15 +106,15 @@ class SakenowaAutoMatchingService {
isConfident: bestScore >= 0.8, isConfident: bestScore >= 0.8,
); );
// //
if (autoApply && result.hasMatch) { if (autoApply && result.hasMatch) {
await applyMatch(sakeItem, result); await applyMatch(sakeItem, result);
} }
return result; return result;
} catch (e, stackTrace) { } catch (e, stackTrace) {
debugPrint('💥 [SakenowaAutoMatching] エラー: $e'); debugPrint(' [SakenowaAutoMatching] エラー: $e');
debugPrint('Stack trace: $stackTrace'); debugPrint('Stack trace: $stackTrace');
return MatchResult( return MatchResult(
score: 0.0, score: 0.0,
isConfident: false, isConfident: false,
@ -122,26 +122,26 @@ class SakenowaAutoMatchingService {
} }
} }
/// SakeItemに適用 /// SakeItemに適用
/// ///
/// DisplayDataのsakenowaフィールドとHiddenSpecsを更新 /// DisplayDataのsakenowaフィールドとHiddenSpecsを更新
Future<void> applyMatch(SakeItem sakeItem, MatchResult result) async { Future<void> applyMatch(SakeItem sakeItem, MatchResult result) async {
if (!result.hasMatch) { if (!result.hasMatch) {
debugPrint('⚠️ [SakenowaAutoMatching] マッチなし、適用スキップ'); debugPrint(' [SakenowaAutoMatching] マッチなし、適用スキップ');
return; return;
} }
try { try {
debugPrint('💾 [SakenowaAutoMatching] マッチング結果を適用中...'); debugPrint(' [SakenowaAutoMatching] マッチング結果を適用中...');
// DisplayData更新 // DisplayData更新
final updatedDisplayData = sakeItem.displayData.copyWith( final updatedDisplayData = sakeItem.displayData.copyWith(
sakenowaName: result.brand?.name, sakenowaName: result.brand?.name,
sakenowaBrewery: result.brewery?.name, sakenowaBrewery: result.brewery?.name,
sakenowaPrefecture: result.area?.name, sakenowaPrefecture: result.area?.name,
); );
// HiddenSpecs更新6 // HiddenSpecs更新6
Map<String, double>? flavorChartMap; Map<String, double>? flavorChartMap;
if (result.flavorChart != null) { if (result.flavorChart != null) {
flavorChartMap = { flavorChartMap = {
@ -159,36 +159,36 @@ class SakenowaAutoMatchingService {
sakenowaFlavorChart: flavorChartMap, sakenowaFlavorChart: flavorChartMap,
); );
// SakeItem更新 // SakeItem更新
sakeItem.displayData = updatedDisplayData; sakeItem.displayData = updatedDisplayData;
sakeItem.hiddenSpecs = updatedHiddenSpecs; sakeItem.hiddenSpecs = updatedHiddenSpecs;
// Hiveに保存 // Hiveに保存
await sakeItem.save(); await sakeItem.save();
debugPrint(' [SakenowaAutoMatching] 適用完了!'); debugPrint(' [SakenowaAutoMatching] 適用完了!');
debugPrint(' displayName: ${sakeItem.displayData.displayName}'); debugPrint(' displayName: ${sakeItem.displayData.displayName}');
debugPrint(' displayBrewery: ${sakeItem.displayData.displayBrewery}'); debugPrint(' displayBrewery: ${sakeItem.displayData.displayBrewery}');
debugPrint(' displayPrefecture: ${sakeItem.displayData.displayPrefecture}'); debugPrint(' displayPrefecture: ${sakeItem.displayData.displayPrefecture}');
} catch (e, stackTrace) { } catch (e, stackTrace) {
debugPrint('💥 [SakenowaAutoMatching] 適用エラー: $e'); debugPrint(' [SakenowaAutoMatching] 適用エラー: $e');
debugPrint('Stack trace: $stackTrace'); debugPrint('Stack trace: $stackTrace');
} }
} }
/// ///
/// ///
/// [sakeItems]: /// [sakeItems]:
/// [minScore]: /// [minScore]:
/// [autoApply]: /// [autoApply]:
/// ///
/// Returns: /// Returns:
Future<int> matchBatch({ Future<int> matchBatch({
required List<SakeItem> sakeItems, required List<SakeItem> sakeItems,
double minScore = 0.7, double minScore = 0.7,
bool autoApply = false, bool autoApply = false,
}) async { }) async {
debugPrint('🔄 [SakenowaAutoMatching] バッチ処理開始: ${sakeItems.length}'); debugPrint(' [SakenowaAutoMatching] バッチ処理開始: ${sakeItems.length}');
int successCount = 0; int successCount = 0;
@ -204,16 +204,16 @@ class SakenowaAutoMatchingService {
} }
} }
debugPrint(' [SakenowaAutoMatching] バッチ処理完了: $successCount/${sakeItems.length} 成功'); debugPrint(' [SakenowaAutoMatching] バッチ処理完了: $successCount/${sakeItems.length} 成功');
return successCount; return successCount;
} }
/// ///
/// ///
/// ///
Future<void> clearSakenowaData(SakeItem sakeItem) async { Future<void> clearSakenowaData(SakeItem sakeItem) async {
debugPrint('🧹 [SakenowaAutoMatching] さけのわデータクリア: ${sakeItem.displayData.displayName}'); debugPrint(' [SakenowaAutoMatching] さけのわデータクリア: ${sakeItem.displayData.displayName}');
final clearedDisplayData = sakeItem.displayData.copyWith( final clearedDisplayData = sakeItem.displayData.copyWith(
sakenowaName: null, sakenowaName: null,
@ -231,6 +231,6 @@ class SakenowaAutoMatchingService {
await sakeItem.save(); await sakeItem.save();
debugPrint(' [SakenowaAutoMatching] クリア完了'); debugPrint(' [SakenowaAutoMatching] クリア完了');
} }
} }

View File

@ -11,7 +11,7 @@ class ShukoDiagnosisService {
return ShukoProfile.empty(); return ShukoProfile.empty();
} }
// 1. Calculate Average Stats (only from items with valid data) // 1. Calculate Average Stats (only from items with valid data)
double totalAroma = 0; double totalAroma = 0;
double totalBitterness = 0; double totalBitterness = 0;
double totalSweetness = 0; double totalSweetness = 0;
@ -19,15 +19,15 @@ class ShukoDiagnosisService {
double totalBody = 0; double totalBody = 0;
int count = 0; int count = 0;
debugPrint('🍶🍶🍶 SHUKO DIAGNOSIS START: Total items = ${items.length}'); debugPrint(' SHUKO DIAGNOSIS START: Total items = ${items.length}');
for (var item in items) { for (var item in items) {
final stats = item.hiddenSpecs.sakeTasteStats; final stats = item.hiddenSpecs.sakeTasteStats;
// Skip items with empty tasteStats (all zeros) // Skip items with empty tasteStats (all zeros)
if (stats.aroma == 0 && stats.bitterness == 0 && stats.sweetness == 0 && if (stats.aroma == 0 && stats.bitterness == 0 && stats.sweetness == 0 &&
stats.acidity == 0 && stats.body == 0) { stats.acidity == 0 && stats.body == 0) {
debugPrint('🍶 SKIPPED item (all zeros)'); debugPrint(' SKIPPED item (all zeros)');
continue; continue;
} }
@ -39,10 +39,10 @@ class ShukoDiagnosisService {
count++; count++;
} }
debugPrint('🍶🍶🍶 Analyzed $count out of ${items.length} items'); debugPrint(' Analyzed $count out of ${items.length} items');
if (count == 0) { if (count == 0) {
debugPrint('🍶🍶🍶 WARNING: No items to analyze, returning empty profile'); debugPrint(' WARNING: No items to analyze, returning empty profile');
return ShukoProfile.empty(); return ShukoProfile.empty();
} }
@ -54,7 +54,7 @@ class ShukoDiagnosisService {
body: totalBody / count, body: totalBody / count,
); );
// 2. Determine Title based on dominant traits // 2. Determine Title based on dominant traits
final title = _determineTitle(avgStats); final title = _determineTitle(avgStats);
return ShukoProfile( return ShukoProfile(
@ -67,69 +67,69 @@ class ShukoDiagnosisService {
} }
ShukoTitle _determineTitle(SakeTasteStats stats) { ShukoTitle _determineTitle(SakeTasteStats stats) {
// DEBUG: Print average 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)}'); 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 // Scoring-based logic to handle overlapping traits
final Map<String, double> scores = {}; final Map<String, double> scores = {};
// 1. (Dry Samurai) // 1. (Dry Samurai)
// High Bitterness (Sharpness) + Low Sweetness // High Bitterness (Sharpness) + Low Sweetness
// Old: alcoholFeeling + Low sweetness // Old: alcoholFeeling + Low sweetness
scores['辛口サムライ'] = _calculateDryScore(stats); scores['辛口サムライ'] = _calculateDryScore(stats);
// 2. (Fruity Master) // 2. (Fruity Master)
// High Aroma + High Sweetness (Modern Fruity Ginjo Style) // High Aroma + High Sweetness (Modern Fruity Ginjo Style)
// Old: fruitiness + High sweetness // Old: fruitiness + High sweetness
scores['フルーティーマスター'] = _calculateFruityScore(stats); scores['フルーティーマスター'] = _calculateFruityScore(stats);
// 3. (Umami Explorer) // 3. (Umami Explorer)
// High Body (Richness) // High Body (Richness)
// Old: richness // Old: richness
scores['旨口探求者'] = _calculateRichnessScore(stats); scores['旨口探求者'] = _calculateRichnessScore(stats);
// 4. (Aroma Noble) // 4. (Aroma Noble)
// High Aroma (dominant trait) // High Aroma (dominant trait)
scores['香りの貴族'] = _calculateAromaScore(stats); scores['香りの貴族'] = _calculateAromaScore(stats);
// 5. (Balance Sage) // 5. (Balance Sage)
// All stats moderate and balanced // All stats moderate and balanced
scores['バランスの賢者'] = _calculateBalanceScore(stats); scores['バランスの賢者'] = _calculateBalanceScore(stats);
// DEBUG: Print all scores // DEBUG: Print all scores
debugPrint('🔍 DEBUG scores: ${scores.entries.map((e) => '${e.key}=${e.value.toStringAsFixed(2)}').join(', ')}'); debugPrint(' DEBUG scores: ${scores.entries.map((e) => '${e.key}=${e.value.toStringAsFixed(2)}').join(', ')}');
// Find the title with the highest score // Find the title with the highest score
final maxEntry = scores.entries.reduce((a, b) => a.value > b.value ? a : b); final maxEntry = scores.entries.reduce((a, b) => a.value > b.value ? a : b);
debugPrint('🔍 DEBUG winner: ${maxEntry.key} with score ${maxEntry.value.toStringAsFixed(2)}'); debugPrint(' DEBUG winner: ${maxEntry.key} with score ${maxEntry.value.toStringAsFixed(2)}');
// Threshold: require minimum score to avoid false positives // Threshold: require minimum score to avoid false positives
// Lowered to 1.5 to be more forgiving for "Standard" sake // Lowered to 1.5 to be more forgiving for "Standard" sake
if (maxEntry.value < 1.0) { if (maxEntry.value < 1.0) {
debugPrint('🔍 DEBUG: Score too low (${maxEntry.value.toStringAsFixed(2)} < 1.0), returning default title'); debugPrint(' DEBUG: Score too low (${maxEntry.value.toStringAsFixed(2)} < 1.0), returning default title');
// Proposed New Default Titles // Proposed New Default Titles
return const ShukoTitle( return const ShukoTitle(
title: '酒道の旅人', title: '酒道の旅人',
description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。', description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。',
); );
} }
// Return the winning title // Return the winning title
return _getTitleByName(maxEntry.key); return _getTitleByName(maxEntry.key);
} }
// Scoring functions for each type // Scoring functions for each type
double _calculateDryScore(SakeTasteStats stats) { double _calculateDryScore(SakeTasteStats stats) {
double score = 0; double score = 0;
// Dry = Sharp/Bitter + Not Sweet // Dry = Sharp/Bitter + Not Sweet
if (stats.bitterness > 0.1) { if (stats.bitterness > 0.1) {
if (stats.bitterness > 3.0) score += (stats.bitterness - 3.0) * 2; // Lowered from 3.2 if (stats.bitterness > 3.0) score += (stats.bitterness - 3.0) * 2; // Lowered from 3.2
// Also verify Acidity contributions (Acid + Bitter = Dry) // Also verify Acidity contributions (Acid + Bitter = Dry)
if (stats.acidity > 3.0) score += (stats.acidity - 3.0); if (stats.acidity > 3.0) score += (stats.acidity - 3.0);
// Penalize if too sweet // Penalize if too sweet
if (stats.sweetness < 2.5) { if (stats.sweetness < 2.5) {
score += (2.5 - stats.sweetness) * 2; score += (2.5 - stats.sweetness) * 2;
} else if (stats.sweetness > 3.5) { } else if (stats.sweetness > 3.5) {
@ -141,14 +141,14 @@ class ShukoDiagnosisService {
double _calculateFruityScore(SakeTasteStats stats) { double _calculateFruityScore(SakeTasteStats stats) {
double score = 0; double score = 0;
// Fruity = High Aroma + Moderate/High Sweetness // Fruity = High Aroma + Moderate/High Sweetness
if (stats.aroma > 0.1) { if (stats.aroma > 0.1) {
if (stats.aroma > 2.8) score += (stats.aroma - 2.8) * 1.5; // Lowered from 3.0 if (stats.aroma > 2.8) score += (stats.aroma - 2.8) * 1.5; // Lowered from 3.0
// Verify Sweetness support // Verify Sweetness support
if (stats.sweetness > 2.8) score += (stats.sweetness - 2.8) * 1.5; if (stats.sweetness > 2.8) score += (stats.sweetness - 2.8) * 1.5;
// Bonus if Body is not too heavy (Light + Sweet + Aroma = Fruity) // Bonus if Body is not too heavy (Light + Sweet + Aroma = Fruity)
if (stats.body < 3.5) score += 0.5; if (stats.body < 3.5) score += 0.5;
} }
return score; return score;
@ -156,12 +156,12 @@ class ShukoDiagnosisService {
double _calculateRichnessScore(SakeTasteStats stats) { double _calculateRichnessScore(SakeTasteStats stats) {
double score = 0; double score = 0;
// Richness = High Body (Kokumi) + Sweetness or Bitterness // Richness = High Body (Kokumi) + Sweetness or Bitterness
if (stats.body > 0.1) { if (stats.body > 0.1) {
// Body is the primary driver // Body is the primary driver
if (stats.body > 3.0) score += (stats.body - 3.0) * 2.5; // Lowered from 3.3 if (stats.body > 3.0) score += (stats.body - 3.0) * 2.5; // Lowered from 3.3
// Bonus for complexity // Bonus for complexity
if (stats.bitterness > 3.0) score += 0.5; if (stats.bitterness > 3.0) score += 0.5;
} }
return score; return score;
@ -169,12 +169,12 @@ class ShukoDiagnosisService {
double _calculateAromaScore(SakeTasteStats stats) { double _calculateAromaScore(SakeTasteStats stats) {
double score = 0; double score = 0;
// Pure Aroma focus (Daiginjo style) // Pure Aroma focus (Daiginjo style)
// Lowered threshold significantly to capture "Aroma Type" even if not extreme // Lowered threshold significantly to capture "Aroma Type" even if not extreme
if (stats.aroma > 3.0) { if (stats.aroma > 3.0) {
score += (stats.aroma - 3.0) * 3; score += (stats.aroma - 3.0) * 3;
} }
// Boost score if it is the dominant trait // Boost score if it is the dominant trait
if (stats.aroma > stats.body && stats.aroma > stats.bitterness) { if (stats.aroma > stats.body && stats.aroma > stats.bitterness) {
score += 1.0; score += 1.0;
} }
@ -184,14 +184,14 @@ class ShukoDiagnosisService {
double _calculateBalanceScore(SakeTasteStats stats) { double _calculateBalanceScore(SakeTasteStats stats) {
double score = 0; double score = 0;
// Check range (Max - Min) // Check range (Max - Min)
final values = [stats.aroma, stats.sweetness, stats.acidity, stats.bitterness, stats.body]; final values = [stats.aroma, stats.sweetness, stats.acidity, stats.bitterness, stats.body];
final maxVal = values.reduce((a, b) => a > b ? a : b); final maxVal = values.reduce((a, b) => a > b ? a : b);
final minVal = values.reduce((a, b) => a < b ? a : b); final minVal = values.reduce((a, b) => a < b ? a : b);
final spread = maxVal - minVal; final spread = maxVal - minVal;
// Strict requirement for "Balance": // Strict requirement for "Balance":
// The difference between the highest and lowest trait must be small. // The difference between the highest and lowest trait must be small.
if (spread > 1.5) { if (spread > 1.5) {
return 0; // Not balanced if there's a spike return 0; // Not balanced if there's a spike
} }
@ -199,7 +199,7 @@ class ShukoDiagnosisService {
int validStats = 0; int validStats = 0;
double sumDiffFrom3 = 0; double sumDiffFrom3 = 0;
// Check deviation from 3.0 (Center) // Check deviation from 3.0 (Center)
void checkStat(double val) { void checkStat(double val) {
if (val > 0.1) { if (val > 0.1) {
validStats++; validStats++;
@ -215,7 +215,7 @@ class ShukoDiagnosisService {
if (validStats >= 3) { if (validStats >= 3) {
double avgDev = sumDiffFrom3 / validStats; double avgDev = sumDiffFrom3 / validStats;
// If average deviation is small (< 0.7), it's balanced // If average deviation is small (< 0.7), it's balanced
if (avgDev < 0.7) { if (avgDev < 0.7) {
score = (0.8 - avgDev) * 5; // Higher score for tighter balance score = (0.8 - avgDev) * 5; // Higher score for tighter balance
} }
@ -252,7 +252,7 @@ class ShukoDiagnosisService {
description: '偏りなく様々な酒を楽しむ、オールラウンダー。', description: '偏りなく様々な酒を楽しむ、オールラウンダー。',
); );
default: default:
// New Default Title // New Default Title
return const ShukoTitle( return const ShukoTitle(
title: '酒道の旅人', title: '酒道の旅人',
description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。', description: '未知なる味を求めて各地を巡る、終わりのない旅の途中。',
@ -260,7 +260,7 @@ class ShukoDiagnosisService {
} }
} }
// v1.1: Personalization Logic // v1.1: Personalization Logic
String getGreeting(String? nickname) { String getGreeting(String? nickname) {
if (nickname == null || nickname.trim().isEmpty) { if (nickname == null || nickname.trim().isEmpty) {
return 'ようこそ!'; return 'ようこそ!';
@ -273,7 +273,7 @@ class ShukoDiagnosisService {
String newTitle = original.title; String newTitle = original.title;
// Simple customization logic // Simple customization logic
if (gender == 'female') { 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('貴族', 'プリンセス');

View File

@ -46,9 +46,13 @@ redisClient.on('connect', () => {
const genAI = new GoogleGenerativeAI(API_KEY); const genAI = new GoogleGenerativeAI(API_KEY);
const model = genAI.getGenerativeModel({ const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash", model: "gemini-2.5-flash",
systemInstruction: "あなたは画像内のテキストを一字一句正確に読み取る専門家です。" +
"ラベルに記載された銘柄名・蔵元名は絶対に変更・補完しないでください。" +
"あなたの知識でラベルの文字を上書きすることは厳禁です。" +
"ラベルに「東魁」とあれば、「東魁盛」を知っていても必ず「東魁」と出力してください。",
generationConfig: { generationConfig: {
responseMimeType: "application/json", responseMimeType: "application/json",
temperature: 0.2, temperature: 0,
} }
}); });