amoriai/lib/services/gemini_service.dart

1234 lines
47 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:google_generative_ai/google_generative_ai.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user_model.dart';
import '../models/recommendation_model.dart';
import '../models/daily_question_model.dart';
import '../services/ocr_service.dart';
import '../core/config/app_config.dart';
import 'dart:convert';
import '../services/firebase_service.dart';
class GeminiService {
static final GeminiService _instance = GeminiService._internal();
factory GeminiService() => _instance;
GeminiService._internal();
late final GenerativeModel _model;
late final GenerativeModel _chatModel;
bool _isInitialized = false;
String? _currentApiKey;
static const String _apiKeyStorageKey = 'user_gemini_api_key';
// Load saved user API key
Future<String?> _loadSavedApiKey() async {
try {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_apiKeyStorageKey);
} catch (e) {
debugPrint('Error loading saved API key: $e');
return null;
}
}
// Save user API key
Future<void> _saveApiKey(String apiKey) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_apiKeyStorageKey, apiKey);
} catch (e) {
debugPrint('Error saving API key: $e');
}
}
// Remove saved API key
Future<void> _removeSavedApiKey() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_apiKeyStorageKey);
} catch (e) {
debugPrint('Error removing saved API key: $e');
}
}
// Initialize Gemini service with user's API key or fallback
Future<void> initialize({String? userApiKey}) async {
try {
// Priority: userApiKey parameter > saved user API key > app default API key
String? apiKey =
userApiKey ?? await _loadSavedApiKey() ?? AppConfig.geminiApiKey;
_currentApiKey = apiKey;
if (apiKey.isEmpty) {
throw Exception(
'No Gemini API key available. Please provide your own API key.',
);
}
debugPrint('🔄 Initializing with Gemini 2.0 Flash...');
_model = GenerativeModel(
model: 'gemini-3-flash-preview',
apiKey: apiKey,
generationConfig: GenerationConfig(
temperature: AppConfig.aiTemperature,
topK: 64,
topP: 0.95,
maxOutputTokens: AppConfig.maxTokensPerRequest,
),
safetySettings: [
SafetySetting(HarmCategory.harassment, HarmBlockThreshold.medium),
SafetySetting(HarmCategory.hateSpeech, HarmBlockThreshold.medium),
SafetySetting(
HarmCategory.sexuallyExplicit,
HarmBlockThreshold.medium,
),
SafetySetting(
HarmCategory.dangerousContent,
HarmBlockThreshold.medium,
),
],
);
_chatModel = GenerativeModel(
model: 'gemini-3-flash-preview',
apiKey: apiKey,
generationConfig: GenerationConfig(
temperature: 0.8,
topK: 40,
topP: 0.95,
maxOutputTokens: 2048,
),
tools: [
Tool(
functionDeclarations: [
FunctionDeclaration(
'toggle_menu_favorite',
'Tambahkan atau hapus item menu dari daftar favorit berdasarkan namanya.',
Schema.object(
properties: {
'menuName': Schema.string(
description:
'Nama menu yang ingin diubah status favoritnya.',
),
'isFavorite': Schema.boolean(
description:
'Status favorit baru (true untuk favorit, false untuk tidak).',
),
},
requiredProperties: ['menuName', 'isFavorite'],
),
),
FunctionDeclaration(
'recommend_specific_drink',
'Memberikan rekomendasi minuman spesifik berdasarkan mood atau cuaca.',
Schema.object(
properties: {
'drinkType': Schema.string(
description: 'Jenis minuman (kopi, teh, non-kopi).',
),
'isIce': Schema.boolean(
description: 'Apakah harus disajikan dingin.',
),
},
requiredProperties: ['drinkType'],
),
),
],
),
],
);
_isInitialized = true;
debugPrint('✅ Gemini 3 Flash Preview initialized successfully');
} catch (e) {
debugPrint('❌ Gemini initialization error: $e');
throw Exception('Failed to initialize Gemini service: $e');
}
}
/// Standalone chat tentang menu Amor Coffee — digunakan oleh AmorChatbotScreen.
/// Tidak memerlukan RecommendationModel, menggunakan _chatModel yang sudah terinisialisasi.
Future<String> chatWithMenu({
required String message,
required String menuContext,
List<Map<String, String>> history = const [],
}) async {
if (!_isInitialized) {
throw Exception('Gemini service not initialized');
}
// Bangun riwayat percakapan agar AI ingat konteks sebelumnya
final historyContent = history.map((h) {
if (h['role'] == 'user') {
return Content.text(h['text'] ?? '');
} else {
return Content.model([TextPart(h['text'] ?? '')]);
}
}).toList();
final systemPrompt = '''
Kamu adalah Amori, asisten virtual yang ramah dan ahli tentang kafe Amor Coffee.
Tugasmu adalah membantu pelanggan mengetahui informasi seputar menu Amor Coffee.
Berikut adalah daftar menu lengkap Amor Coffee yang kamu ketahui:
$menuContext
PANDUAN MENJAWAB:
1. Jawab HANYA pertanyaan yang berkaitan dengan menu, harga, rekomendasi, atau hal umum tentang Amor Coffee.
2. Jika ditanya soal rekomendasi, tanyakan dulu preferensi (suka manis/gurih? mau makan/minum? budget berapa?) sebelum merekomendasikan.
3. Gunakan bahasa Indonesia yang ramah, kasual, dan bersahabat. Boleh pakai emoji sesekali.
4. Jika ada pertanyaan di luar konteks menu Amor Coffee, jawab dengan sopan bahwa kamu hanya bisa membantu seputar menu Amor Coffee.
5. Jika ada yang bertanya tentang bundling/paket, sarankan kombinasi makanan + minuman yang cocok beserta total harganya.
6. Selalu sebutkan harga dengan format "Rp XX.000".
Pertanyaan dari pelanggan: $message''';
try {
final chat = _chatModel.startChat(history: historyContent);
final response = await chat.sendMessage(Content.text(systemPrompt));
if (response.text == null || response.text!.isEmpty) {
throw Exception('Empty response from AI');
}
return response.text!;
} catch (e) {
debugPrint('chatWithMenu error: $e');
throw Exception('Failed to get AI response: $e');
}
}
Future<RecommendationModel> generateRecommendations({
required List<MenuItemOCR> menuItems,
required UserPreferences userPreferences,
required String userId,
String? menuImageUrl,
String? originalMenuText,
UserModel? userModel,
List<String>? menuImagePaths,
}) async {
if (!_isInitialized) {
throw Exception('Gemini service not initialized');
}
try {
final prompt = _buildRecommendationPrompt(
menuItems,
userPreferences,
userModel,
originalMenuText,
);
// --- CACHE CHECK (KONSISTENSI HASIL SCAN) ---
// Jika menu yang sama discan pada hari yang sama dengan mood/cuaca/budget yang sama persis,
// maka berikan rekomendasi yang sama tanpa memanggil AI.
try {
final firebaseService = FirebaseService();
final history = await firebaseService.getUserRecommendations(userId, limit: 10);
final now = DateTime.now();
for (final rec in history) {
if (rec.createdAt.year == now.year &&
rec.createdAt.month == now.month &&
rec.createdAt.day == now.day) {
// Skip cache jika rekomendasi lama adalah fallback
final isFallback = rec.recommendations.any(
(r) => r.tags.contains('fallback') || r.menuName == 'Menu Rekomendasi',
);
if (isFallback) {
debugPrint('⏭️ CACHE SKIP: Rekomendasi sebelumnya adalah fallback, generate ulang.');
continue;
}
if (rec.prompt == prompt) {
debugPrint('🔄 CACHE HIT: Menu dan preferensi harian sama, menggunakan riwayat sebelumnya.');
return RecommendationModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
userId: rec.userId,
recommendations: rec.recommendations,
originalMenuText: rec.originalMenuText,
menuImageUrl: rec.menuImageUrl,
menuImagePaths: rec.menuImagePaths,
prompt: rec.prompt,
aiResponse: rec.aiResponse,
confidence: rec.confidence,
createdAt: DateTime.now(),
isFavorite: false,
sessionId: DateTime.now().millisecondsSinceEpoch.toString(),
selectedMenuName: null,
);
}
}
}
} catch (e) {
debugPrint('Error checking recommendation cache: $e');
}
// ---------------------------------------------
debugPrint('=== GEMINI GENERATE ===');
debugPrint('menuItems count: ${menuItems.length}');
debugPrint('originalMenuText length: ${originalMenuText?.length ?? 0}');
debugPrint('prompt length: ${prompt.length} chars');
final response = await _model.generateContent([Content.text(prompt)]);
if (response.text == null || response.text!.isEmpty) {
throw Exception('Empty response from Gemini');
}
debugPrint('AI response length: ${response.text!.length} chars');
debugPrint('AI response preview: ${response.text!.substring(0, response.text!.length.clamp(0, 300))}');
Map<String, dynamic> jsonResponse;
try {
String responseText = response.text!.trim();
// 1. Hapus markdown code block jika ada
if (responseText.contains('```')) {
responseText = responseText
.replaceAll(RegExp(r'```json\s*'), '')
.replaceAll(RegExp(r'```\s*'), '')
.trim();
}
// 2. Coba parse langsung
try {
jsonResponse = json.decode(responseText);
debugPrint('✅ Direct JSON parse OK');
} catch (_) {
// 3. Coba ekstrak dari blok { ... } terluar
final int jsonStart = responseText.indexOf('{');
final int jsonEnd = responseText.lastIndexOf('}') + 1;
if (jsonStart != -1 && jsonEnd > jsonStart) {
try {
jsonResponse = json.decode(responseText.substring(jsonStart, jsonEnd));
debugPrint('✅ Extracted JSON block OK');
} catch (_) {
// 4. JSON terpotong → ekstrak objek rekomendasi yang SUDAH LENGKAP saja
debugPrint('⚠️ JSON truncated — extracting complete objects...');
jsonResponse = _extractPartialRecommendations(responseText);
}
} else {
jsonResponse = _extractPartialRecommendations(responseText);
}
}
debugPrint('✅ Parsed ${(jsonResponse['recommendations'] as List?)?.length ?? 0} recommendation(s)');
} catch (e) {
debugPrint('❌ JSON parsing fatal error: $e');
debugPrint('❌ Full raw response: ${response.text}');
jsonResponse = _buildFallbackJson();
}
final recommendations = _parseRecommendations(jsonResponse, menuItems);
// Create recommendation model
final recommendationModel = RecommendationModel(
id: DateTime.now().millisecondsSinceEpoch.toString(),
userId: userId,
recommendations: recommendations,
originalMenuText: originalMenuText ?? _menuItemsToText(menuItems),
menuImageUrl: menuImageUrl,
menuImagePaths: menuImagePaths,
prompt: prompt,
aiResponse: response.text!,
confidence: _calculateRecommendationConfidence(recommendations),
createdAt: DateTime.now(),
sessionId: DateTime.now().millisecondsSinceEpoch.toString(),
);
return recommendationModel;
} catch (e) {
debugPrint('Generate recommendations error: $e');
throw Exception('Failed to generate recommendations: $e');
}
}
// Build recommendation prompt
String _buildRecommendationPrompt(
List<MenuItemOCR> menuItems,
UserPreferences prefs,
UserModel? userModel,
String? originalMenuText,
) {
final daily = userModel?.dailyPreferences;
// ── Profil ───────────────────────────────────────────────────────────────
final nickname =
userModel?.nickname ?? userModel?.displayName ?? 'Pelanggan';
final gender = userModel?.gender ?? '-';
final ageRange = prefs.ageRange ?? '-';
// ── Preferensi Profil (setup awal, bersifat permanen) ────────────────────
final allergies = prefs.allergies.isEmpty
? 'Tidak ada'
: prefs.allergies.join(', ');
final dietaryRestriction = prefs.dietaryRestriction;
final spiceLevel = _translateSpiceLevel(prefs.spiceLevel);
final favCategories = prefs.favoriteCategories.isEmpty
? 'Tidak ditentukan'
: prefs.favoriteCategories.join(', ');
// ── Preferensi Harian (check-in hari ini — PRIORITAS UTAMA) ─────────────
final rawBudget = daily?['budget'];
final int budget = rawBudget is int
? rawBudget
: rawBudget is double
? rawBudget.toInt()
: prefs.budgetRange;
final String mood = (daily?['mood'] as String?)?.isNotEmpty == true
? daily!['mood'] as String
: prefs.mood;
final String cuaca = (daily?['cuaca'] as String?) ?? '-';
final String suasana = (daily?['suasana'] as String?) ?? '-';
final String waktu = (daily?['waktu'] as String?) ?? '-';
final String keinginan = (daily?['keinginan'] as String?) ?? '';
String keinginanLabel = favCategories;
if (keinginan == 'minuman') keinginanLabel = 'Minuman saja';
if (keinginan == 'makanan') keinginanLabel = 'Makanan saja';
if (keinginan == 'keduanya') keinginanLabel = 'Minuman & Makanan';
final String budgetStr = budget.toString();
final nameRef = nickname;
// ── Terjemahkan kondisi ke aturan konkret untuk AI ───────────────────────
final lowerKeinginan = keinginanLabel.toLowerCase();
// Perbaikan Logika Deteksi Preferensi (Strict Mode)
final bool isExplicitlyDrinks = lowerKeinginan.contains('minuman') || lowerKeinginan.contains('kopi') || lowerKeinginan.contains('teh') || lowerKeinginan.contains('jus');
final bool isExplicitlyFood = lowerKeinginan.contains('makanan') || lowerKeinginan.contains('snack') || lowerKeinginan.contains('dessert') || lowerKeinginan.contains('pastry') || lowerKeinginan.contains('kue') || lowerKeinginan.contains('sarapan') || lowerKeinginan.contains('cemilan');
// Jika user explicitly memilih "keduanya" (misal dari daily check-in)
final bool isBoth = lowerKeinginan.contains('keduanya') || lowerKeinginan.contains('minuman & makanan');
bool wantsDrinks = true;
bool wantsFood = true;
if (isBoth) {
wantsDrinks = true;
wantsFood = true;
} else if (isExplicitlyDrinks && !isExplicitlyFood) {
wantsDrinks = true;
wantsFood = false;
} else if (isExplicitlyFood && !isExplicitlyDrinks) {
wantsDrinks = false;
wantsFood = true;
} else if (isExplicitlyDrinks && isExplicitlyFood) {
wantsDrinks = true;
wantsFood = true;
} else {
// Jika label hanya berisi tag generik seperti "Vegetarian" atau "Pedas"
// Kita asumsikan user bisa makan apa saja (keduanya)
wantsDrinks = true;
wantsFood = true;
}
// HARD FILTER: Sembunyikan item yang tidak sesuai preferensi dari mata AI
// Kirim seluruh raw text ke AI — jangan filter per baris karena bisa salah
// (contoh: "Roti Kopi" mengandung "kopi" tapi bukan minuman).
// Pembatasan kategori diserahkan ke instruksi prompt yang sudah ketat.
final String menuText = (originalMenuText != null && originalMenuText.trim().isNotEmpty)
? originalMenuText
: _menuItemsToText(menuItems);
final String waktuAturan = _buildWaktuRule(waktu, wantsDrinks, wantsFood);
final String cuacaAturan = _buildCuacaRule(cuaca, wantsDrinks, wantsFood);
final String moodAturan = _buildMoodRule(mood, wantsDrinks, wantsFood);
final String keinginanAturan = _buildKeinginanRule(keinginanLabel, wantsDrinks, wantsFood);
return 'Kamu adalah Amori, AI rekomendasi menu untuk kafe Amor Coffee.\n\n'
'MENU YANG TERSEDIA:\n$menuText\n\n'
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'
'DATA PELANGGAN (gunakan SEMUA informasi ini untuk rekomendasi personal)\n'
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
'📋 PROFIL:\n'
'- Nama: $nameRef\n'
'- Gender: $gender\n'
'- Usia: $ageRange\n\n'
'🔒 PREFERENSI TETAP (dari setup profil — bersifat permanen):\n'
'- Alergi: $allergies ← WAJIB DIHINDARI\n'
'- Pembatasan diet: $dietaryRestriction\n'
'- Level pedas: $spiceLevel\n'
'- Kategori favorit: $favCategories\n\n'
'☀️ KONDISI HARI INI (check-in harian — PRIORITAS TERTINGGI):\n'
'- Mood saat ini: $mood\n'
'- Cuaca hari ini: $cuaca\n'
'- Situasi / suasana: $suasana\n'
'- Yang diinginkan: $keinginanLabel\n'
'- Waktu kunjungan: $waktu\n'
'- ⚠️ BUDGET HARI INI: Rp $budgetStr (WAJIB DIPATUHI)\n\n'
'🚨 ATURAN KERAS — HARUS DIPATUHI TANPA PENGECUALIAN:\n\n'
'1. KATEGORI UTAMA "$keinginanLabel" — terapkan aturan ini SEBELUM yang lain:\n'
'$keinginanAturan\n\n'
'2. BUDGET: JANGAN rekomendasikan menu lebih mahal dari Rp $budgetStr\n'
'3. ALERGI: JANGAN rekomendasikan menu yang mengandung: $allergies\n'
'4. DIET: ${_buildDietRule(dietaryRestriction)}\n'
'5. PEDAS: Pilih menu sesuai level pedas "$spiceLevel"\n\n'
'6. WAKTU KUNJUNGAN "$waktu" — terapkan aturan berikut:\n'
'$waktuAturan\n\n'
'7. CUACA "$cuaca" — terapkan aturan berikut:\n'
'$cuacaAturan\n\n'
'8. MOOD "$mood" — terapkan aturan berikut:\n'
'$moodAturan\n\n'
'TUGAS — BUAT TEPAT 3 REKOMENDASI TERSTRUKTUR:\n'
'Pilih menu HANYA DARI "MENU YANG TERSEDIA" di atas. DILARANG MENGARANG.\n\n'
'${_buildSlotInstruction(keinginanLabel, wantsDrinks, wantsFood)}\n\n'
'Untuk setiap slot, tulis JSON SINGKAT:\n'
'1. menuName: nama PERSIS dari daftar menu\n'
'2. description: 5-8 kata saja\n'
'3. price: harga numerik\n'
'4. reason: TEPAT 1 kalimat singkat — sebutkan 1 alasan utama cocok untuk $nameRef ($mood, $cuaca, $waktu)\n'
'5. score: 0.0-1.0\n'
'6. tags: maksimal 2 tag singkat\n'
'7. category: kategori\n'
'8. slotType: "minuman", "makanan", atau "bundling"\n'
'9. isRecommended: true\n\n'
'HANYA JSON. TIDAK ADA TEKS LAIN. OUTPUT HARUS SELESAI LENGKAP.\n\n'
'FORMAT RESPONSE (JSON ONLY — TEPAT 3 ITEM):\n'
'{\n'
' "recommendations": [\n'
' {\n'
' "menuName": "nama menu exact",\n'
' "description": "deskripsi singkat",\n'
' "price": harga_numerik,\n'
' "reason": "alasan personal",\n'
' "score": 0.9,\n'
' "tags": ["tag1", "tag2"],\n'
' "category": "kategori",\n'
' "slotType": "makanan/minuman/bundling",\n'
' "isRecommended": true\n'
' }\n'
' ],\n'
' "summary": "ringkasan rekomendasi",\n'
' "additionalNotes": "catatan tambahan"\n'
'}\n\n'
'HANYA BERIKAN JSON, TIDAK ADA TEXT LAIN!';
}
// ── Helper: instruksi struktur slot 3 rekomendasi ─────────────────────────
String _buildSlotInstruction(String label, bool wantsDrinks, bool wantsFood) {
if (wantsDrinks && !wantsFood) {
return 'STRUKTUR 3 SLOT REKOMENDASI (MINUMAN SAJA):\n'
' Slot 1 — Pilihan Terbaik : 1 MINUMAN terbaik sesuai kondisi\n'
' Slot 2 — Pilihan Alternatif: 1 MINUMAN berbeda sebagai alternatif\n'
' Slot 3 — Pilihan Hemat : 1 MINUMAN dengan harga paling terjangkau\n'
' ⛔ DILARANG memasukkan makanan/cemilan di slot manapun.';
} else if (wantsFood && !wantsDrinks) {
return 'STRUKTUR 3 SLOT REKOMENDASI (MAKANAN SAJA):\n'
' Slot 1 — Pilihan Terbaik : 1 MAKANAN terbaik sesuai kondisi\n'
' Slot 2 — Pilihan Alternatif: 1 MAKANAN berbeda sebagai alternatif\n'
' Slot 3 — Pilihan Hemat : 1 MAKANAN dengan harga paling terjangkau\n'
' ⛔ DILARANG memasukkan minuman di slot manapun.';
} else {
return 'STRUKTUR 3 SLOT REKOMENDASI (BUNDLING MAKANAN & MINUMAN):\n'
' Slot 1 — Paket Utama: 1 MAKANAN + 1 MINUMAN terbaik (Tulis di menuName: "Paket Utama (Nama Makanan & Nama Minuman)")\n'
' Slot 2 — Kombinasi Alternatif: 1 MAKANAN + 1 MINUMAN lain (Tulis di menuName: "Kombinasi Alternatif (Nama Makanan & Nama Minuman)")\n'
' Slot 3 — Paket Hemat: 1 MAKANAN + 1 MINUMAN termurah (Tulis di menuName: "Paket Hemat (Nama Makanan & Nama Minuman)")\n'
' ⛔ SANGAT DILARANG menggabungkan 2 minuman (Contoh Salah: Kopi & Teh).\n'
' ⛔ SANGAT DILARANG menggabungkan 2 makanan (Contoh Salah: Roti & Nasi).\n'
' ✅ HARUS 1 Makanan/Snack/Cemilan + 1 Minuman di setiap slot.\n'
' ✅ FIELD price WAJIB BERISI PENJUMLAHAN TOTAL HARGA (Harga Makanan + Harga Minuman). Contoh: Jika Makanan 12000 dan Minuman 14000, isi price dengan 26000.\n'
' ✅ KHUSUS BUNDLING: WAJIB tulis rincian harga di field description dengan format seperti: "(Makanan: Rp X, Minuman: Rp Y, Total: Rp Z)".\n'
' ✅ WAJIB set slotType dengan "bundling".';
}
}
// ── Helper: terjemahkan keinginan ke instruksi konkret ───────────────────────
String _buildKeinginanRule(String keinginanLabel, bool wantsDrinks, bool wantsFood) {
if (wantsDrinks && !wantsFood) {
return ' - WAJIB HANYA merekomendasikan MINUMAN (seperti Kopi, Teh, Jus, Susu, dll). SANGAT DILARANG merekomendasikan makanan, camilan, atau dessert apa pun.';
} else if (wantsFood && !wantsDrinks) {
return ' - WAJIB HANYA merekomendasikan MAKANAN (Makanan Utama, Snack, Dessert, Pastry). SANGAT DILARANG merekomendasikan minuman apa pun.';
} else if (wantsDrinks && wantsFood) {
return ' - WAJIB buat 3 BUNDLING (Paket). Setiap slot harus berisi gabungan PASTI 1 MAKANAN (Roti/Nasi/Kue/Snack) dan 1 MINUMAN (Kopi/Teh/Jus/Susu) yang serasi. JANGAN PERNAH MENGGABUNGKAN 2 MINUMAN.';
} else {
return ' - Prioritaskan kategori berikut jika tersedia di menu: $keinginanLabel';
}
}
// ── Helper: terjemahkan waktu ke instruksi konkret ───────────────────────
String _buildWaktuRule(String waktu, bool wantsDrinks, bool wantsFood) {
switch (waktu.toLowerCase().trim()) {
case 'pagi':
final recs = [
if (wantsDrinks) 'kopi panas, teh hangat',
if (wantsFood) 'sarapan, roti, makanan ringan'
].join(', ');
return ' - PRIORITASKAN: $recs\n - HINDARI: makanan terlalu berat atau pedas berlebihan';
case 'siang':
final recs = [
if (wantsDrinks) 'minuman dingin (iced coffee, iced tea, cold brew, smoothie, jus)',
if (wantsFood) 'makanan utama segar'
].join(', ');
return ' - PRIORITASKAN: $recs\n - HINDARI: sup panas, minuman panas (karena cuaca umumnya terik)';
case 'sore':
final recs = [
if (wantsFood) 'camilan sore (pastry, kue, snack)',
if (wantsDrinks) 'kopi sore, teh santai'
].join(', ');
return ' - PRIORITASKAN: $recs\n - Sore hari cocok untuk bersantai';
case 'malam':
final recs = [
if (wantsDrinks) 'minuman hangat (kopi panas, teh panas, coklat panas)',
if (wantsFood) 'comfort food, makan malam'
].join(', ');
return ' - PRIORITASKAN: $recs\n - Malam hari cocok untuk menu yang menghangatkan';
default:
return ' - Sesuaikan pilihan dengan waktu kunjungan';
}
}
// ── Helper: terjemahkan cuaca ke instruksi konkret ───────────────────────
String _buildCuacaRule(String cuaca, bool wantsDrinks, bool wantsFood) {
final c = cuaca.toLowerCase();
if (c.contains('panas') || c.contains('cerah') || c.contains('terik') ||
c.contains('hot') || c.contains('sunny')) {
final recs = [
if (wantsDrinks) 'minuman dingin, iced',
if (wantsFood) 'dessert dingin, makanan menyegarkan'
].join(', ');
return ' - Cuaca panas: PRIORITASKAN $recs';
} else if (c.contains('hujan') || c.contains('dingin') ||
c.contains('mendung') || c.contains('rain') || c.contains('cold') || c.contains('cloudy')) {
final recs = [
if (wantsDrinks) 'minuman panas (hot coffee/tea)',
if (wantsFood) 'makanan berkuah hangat, comfort food'
].join(', ');
return ' - Cuaca dingin: PRIORITASKAN $recs';
}
return ' - Sesuaikan dengan kondisi cuaca yang ada';
}
// ── Helper: terjemahkan mood ke instruksi konkret ────────────────────────
String _buildMoodRule(String mood, bool wantsDrinks, bool wantsFood) {
final m = mood.toLowerCase();
if (m.contains('senang') || m.contains('bahagia') || m.contains('happy') ||
m.contains('energik') || m.contains('semangat')) {
final recs = [
if (wantsDrinks) 'signature drink',
if (wantsFood) 'dessert spesial'
].join(' atau ');
return ' - Mood positif: rekomendasikan menu celebratory ($recs)';
} else if (m.contains('lelah') || m.contains('capek') ||
m.contains('tired') || m.contains('ngantuk')) {
final recs = wantsDrinks ? 'minuman berkafein (kopi, espresso) untuk energi' : 'menu pengembali energi';
return ' - Mood lelah: PRIORITASKAN $recs';
} else if (m.contains('santai') || m.contains('relax') || m.contains('tenang')) {
final recs = [
if (wantsDrinks) 'teh, kopi santai',
if (wantsFood) 'camilan, dessert'
].join(', ');
return ' - Mood santai: rekomendasikan menu yang bisa dinikmati pelan ($recs)';
} else if (m.contains('stres') || m.contains('stress') ||
m.contains('sedih') || m.contains('bete')) {
final recs = [
if (wantsFood) 'comfort food',
if (wantsDrinks) 'minuman manis/cokelat untuk mood booster'
].join(' dan ');
return ' - Mood tidak baik: rekomendasikan $recs';
} else if (m.contains('fokus') || m.contains('kerja') ||
m.contains('belajar') || m.contains('produktif')) {
final recs = wantsDrinks ? 'kopi atau minuman berkafein untuk konsentrasi' : 'menu ringan agar tidak ngantuk';
return ' - Butuh fokus: PRIORITASKAN $recs';
}
return ' - Sesuaikan pilihan dengan mood yang ada';
}
// ── Helper: terjemahkan diet ke aturan ──────────────────────────────────
String _buildDietRule(String diet) {
switch (diet.toLowerCase().trim()) {
case 'vegetarian':
return 'JANGAN rekomendasikan menu yang mengandung daging (ayam, sapi, babi, seafood).';
case 'vegan':
return 'JANGAN rekomendasikan produk hewani apapun (daging, susu, telur, madu).';
case 'halal':
return 'JANGAN rekomendasikan menu mengandung babi atau alkohol.';
case 'keto':
return 'PRIORITASKAN menu rendah karbo/tinggi protein. HINDARI nasi, roti, pasta, gula tinggi.';
case 'low carb':
return 'PRIORITASKAN menu rendah karbohidrat. HINDARI menu dengan banyak gula atau tepung.';
default:
return 'Tidak ada pembatasan diet khusus.';
}
}
// ── Helper: terjemahkan spice level ─────────────────────────────────────
String _translateSpiceLevel(String level) {
switch (level.toLowerCase().trim()) {
case 'mild': return 'Tidak Pedas (mild)';
case 'medium': return 'Sedang (medium)';
case 'hot': return 'Pedas (hot)';
case 'very hot': return 'Sangat Pedas (very hot)';
default: return level;
}
}
// Parse recommendations from JSON response
List<MenuRecommendation> _parseRecommendations(
Map<String, dynamic> jsonResponse,
List<MenuItemOCR> menuItems,
) {
final recommendationsList =
jsonResponse['recommendations'] as List<dynamic>;
return recommendationsList.map((item) {
final recommendation = item as Map<String, dynamic>;
// Find matching menu item for accurate price
final menuName = recommendation['menuName'] as String;
final matchingItem = menuItems.firstWhere(
(menu) =>
menu.name.toLowerCase().contains(menuName.toLowerCase()) ||
menuName.toLowerCase().contains(menu.name.toLowerCase()),
orElse: () => MenuItemOCR(
name: menuName,
price: 0,
description: '',
category: 'Lainnya',
),
);
final String description = recommendation['description'] as String? ?? '';
final String slotType = recommendation['slotType'] as String? ?? '';
final bool isBundling = slotType.toLowerCase().contains('bundling');
// Hitung ulang harga bundling dari deskripsi — jangan andalkan AI untuk aritmetika
double finalPrice;
if (isBundling) {
finalPrice = _extractBundlingTotalPrice(description);
// Jika gagal ekstrak dari deskripsi, fallback ke harga AI
if (finalPrice <= 0) {
finalPrice = (recommendation['price'] as num?)?.toDouble() ?? 0.0;
}
} else {
// Untuk non-bundling: utamakan harga OCR yang akurat
finalPrice = matchingItem.price > 0
? matchingItem.price
: (recommendation['price'] as num?)?.toDouble() ?? 0.0;
}
return MenuRecommendation(
menuName: menuName,
description: description,
price: finalPrice,
reason: recommendation['reason'] as String? ?? '',
score: (recommendation['score'] as num?)?.toDouble() ?? 0.5,
tags: List<String>.from(recommendation['tags'] ?? []),
category:
recommendation['category'] as String? ?? matchingItem.category,
isRecommended: recommendation['isRecommended'] as bool? ?? true,
slotType: slotType.isNotEmpty ? slotType : null,
);
}).toList();
}
/// Ekstrak total harga bundling dari teks deskripsi.
/// Mencari pola "Total: Rp X" terlebih dahulu,
/// lalu fallback ke menjumlahkan "Makanan: Rp X" + "Minuman: Rp Y".
double _extractBundlingTotalPrice(String description) {
// Bersihkan titik pemisah ribuan: "14.000" → "14000"
final cleaned = description.replaceAll('.', '');
// Coba cari "Total: Rp XXXX"
final totalRegex = RegExp(r'Total:\s*Rp\s*(\d+)', caseSensitive: false);
final totalMatch = totalRegex.firstMatch(cleaned);
if (totalMatch != null) {
final total = double.tryParse(totalMatch.group(1) ?? '');
if (total != null && total > 0) {
debugPrint('💰 Bundling total extracted from description: $total');
return total;
}
}
// Fallback: jumlahkan "Makanan: Rp X" + "Minuman: Rp Y"
final makanRegex = RegExp(r'Makanan:\s*Rp\s*(\d+)', caseSensitive: false);
final minumRegex = RegExp(r'Minuman:\s*Rp\s*(\d+)', caseSensitive: false);
final makanMatch = makanRegex.firstMatch(cleaned);
final minumMatch = minumRegex.firstMatch(cleaned);
if (makanMatch != null && minumMatch != null) {
final food = double.tryParse(makanMatch.group(1) ?? '') ?? 0;
final drink = double.tryParse(minumMatch.group(1) ?? '') ?? 0;
if (food > 0 && drink > 0) {
final sum = food + drink;
debugPrint('💰 Bundling price summed from parts: $food + $drink = $sum');
return sum;
}
}
return 0; // Gagal ekstrak — caller akan fallback ke harga AI
}
Map<String, dynamic> _extractPartialRecommendations(String raw) {
final List<Map<String, dynamic>> complete = [];
// Cari posisi array "recommendations": [
final arrIdx = raw.indexOf('"recommendations"');
if (arrIdx == -1) return _buildFallbackJson();
final arrStart = raw.indexOf('[', arrIdx);
if (arrStart == -1) return _buildFallbackJson();
// Telusuri karakter satu per satu, ekstrak setiap objek {} yang LENGKAP
int depth = 0;
int objStart = -1;
for (int i = arrStart; i < raw.length; i++) {
final ch = raw[i];
if (ch == '{') {
if (depth == 0) objStart = i;
depth++;
} else if (ch == '}') {
depth--;
if (depth == 0 && objStart != -1) {
final objStr = raw.substring(objStart, i + 1);
try {
final obj = json.decode(objStr) as Map<String, dynamic>;
// Hanya ambil objek yang punya menuName (bukan objek lain)
if (obj.containsKey('menuName')) complete.add(obj);
} catch (_) { /* skip invalid object */ }
objStart = -1;
}
}
}
debugPrint('⚠️ Recovered ${complete.length} complete recommendation(s) from truncated JSON');
if (complete.isEmpty) return _buildFallbackJson();
return {
'recommendations': complete,
'summary': 'Rekomendasi dari respons parsial',
'additionalNotes': '',
};
}
// ── Fallback JSON jika semua metode parse gagal ───────────────────────────
Map<String, dynamic> _buildFallbackJson() {
return {
'recommendations': [
{
'menuName': 'Coba Lagi',
'description': 'Gagal mendapatkan rekomendasi dari AI',
'price': 0,
'reason': 'AI tidak dapat memberikan rekomendasi. Silakan scan ulang.',
'score': 0.0,
'tags': ['fallback'],
'category': 'Umum',
'isRecommended': false,
},
],
'summary': 'Terjadi kesalahan',
'additionalNotes': 'Coba scan ulang menu',
};
}
// Convert menu items to text
String _menuItemsToText(List<MenuItemOCR> menuItems) {
return menuItems
.map((item) {
String itemText = '${item.name} - ${item.formattedPrice}';
if (item.description.isNotEmpty) {
itemText += ' (${item.description})';
}
itemText += ' [${item.category}]';
return itemText;
})
.join('\n');
}
// Calculate recommendation confidence
double _calculateRecommendationConfidence(
List<MenuRecommendation> recommendations,
) {
if (recommendations.isEmpty) return 0.0;
double totalScore = recommendations.fold(
0.0,
(sum, rec) => sum + rec.score,
);
return (totalScore / recommendations.length).clamp(0.0, 1.0);
}
// Chat with AI for follow-up questions
Future<String> chatWithAI({
required String message,
required RecommendationModel context,
List<String> conversationHistory = const [],
}) async {
if (!_isInitialized) {
throw Exception('Gemini service not initialized');
}
try {
final chatPrompt = _buildChatPrompt(
message,
context,
conversationHistory,
);
// We use a chat session to handle function calling more easily
final chat = _chatModel.startChat();
var response = await chat.sendMessage(Content.text(chatPrompt));
// Handle potential function calls
while (response.functionCalls.isNotEmpty) {
final responses = <FunctionResponse>[];
for (final call in response.functionCalls) {
final result = await _handleFunctionCall(call, context);
responses.add(FunctionResponse(call.name, result));
}
response = await chat.sendMessage(Content.functionResponses(responses));
}
if (response.text == null || response.text!.isEmpty) {
throw Exception('Empty response from Gemini');
}
return response.text!;
} catch (e) {
debugPrint('Chat with AI error: $e');
throw Exception('Failed to get AI response: $e');
}
}
// Build chat prompt with context
String _buildChatPrompt(
String message,
RecommendationModel context,
List<String> history,
) {
final recommendationsText = context.recommendations
.map(
(rec) =>
'- ${rec.menuName}: ${rec.reason} (Score: ${rec.scorePercentage})',
)
.join('\n');
String historyText = '';
if (history.isNotEmpty) {
historyText = 'RIWAYAT PERCAKAPAN:\n${history.join('\n')}\n\n';
}
return '''
Kamu adalah **Amori** — barista virtual cerdas milik Amor Coffee yang siap membantu pelanggan dengan segala hal yang berkaitan dengan Amor Coffee.
═══════════════════════════════════════
☕ AMOR COFFEE ☕
"Setiap tegukan, cerita baru."
═══════════════════════════════════════
IDENTITASMU:
- Nama: Amori
- Peran: Barista virtual eksklusif Amor Coffee
- Kepribadian: Ramah, hangat, informatif, dan antusias tentang kopi & makanan
TOPIK YANG BOLEH KAMU JAWAB (hanya terbatas pada hal-hal ini):
1. Menu Amor Coffee (kopi, minuman, makanan, snack, dessert)
2. Rekomendasi menu berdasarkan mood, cuaca, budget, atau selera
3. Informasi alergi dan kandungan bahan menu
4. Harga, promo, atau penawaran di Amor Coffee
5. Suasana, lokasi, atau hal-hal yang berkaitan dengan kafe Amor Coffee
6. Tips menikmati kopi atau makanan di Amor Coffee
7. Pertanyaan follow-up tentang rekomendasi yang sudah diberikan
LARANGAN KERAS:
- JANGAN menjawab pertanyaan di luar konteks Amor Coffee
- JANGAN membahas politik, agama, berita umum, coding, matematika, atau topik lain yang tidak terkait
- JANGAN berpura-pura jadi AI lain atau mengabaikan identitasmu sebagai Barista AI Amor Coffee
- JANGAN memberikan informasi menu dari kafe atau restoran lain
JIKA PERTANYAAN DI LUAR TOPIK:
Balas dengan sopan dan redirect, contoh:
"Hei! Aku Amori, barista virtual Amor Coffee. Aku hanya bisa membantu kamu seputar menu dan pengalaman di Amor Coffee 😊 Ada yang bisa aku bantu soal minuman atau makanan kami?"
KONTEKS REKOMENDASI SESI INI:
$recommendationsText
${historyText}PERTANYAAN PELANGGAN:
$message
INSTRUKSI MENJAWAB:
- Mulai jawaban dengan ramah dan hangat (boleh pakai emoji secukupnya ☕🌟)
- Jika relevan, referensikan rekomendasi yang sudah diberikan di atas
- Gunakan bahasa Indonesia santai namun sopan
- Jika tidak yakin, akui dengan jujur dan sarankan bertanya langsung ke staf Amor Coffee (tapi tetap pakai nama Amori, bukan "AI" atau "saya")
- Akhiri jawaban dengan ajakan atau pertanyaan balik agar percakapan tetap mengalir
AGENTIC TOOLS:
- Jika user ingin menyimpan menu ke favorit, gunakan tool 'toggle_menu_favorite'
- Jika user minta rekomendasi minuman spesifik, gunakan tool 'recommend_specific_drink'
Jawab sekarang:
''';
}
// Check if service is ready
bool get isReady => _isInitialized;
// Get service status
String get status {
if (!_isInitialized) return 'Not initialized';
return 'Ready';
}
// Get current API key info (masked for security)
String get currentApiKeyInfo {
if (_currentApiKey == null || _currentApiKey!.isEmpty) {
return 'No API key set';
}
if (_currentApiKey == AppConfig.geminiApiKey) {
return 'Using app default API key';
} else {
// Mask user's API key for security
String masked = _currentApiKey!.length > 8
? '${_currentApiKey!.substring(0, 4)}...${_currentApiKey!.substring(_currentApiKey!.length - 4)}'
: '****';
return 'Using user API key: $masked';
}
}
// Update API key and reinitialize
Future<void> updateApiKey(String newApiKey) async {
if (newApiKey.isEmpty) {
throw Exception('API key cannot be empty');
}
// Save the new API key
await _saveApiKey(newApiKey);
_isInitialized = false;
await initialize(userApiKey: newApiKey);
}
// Generate daily check-in questions using AI
Future<List<DailyQuestion>> generateDailyQuestions({
required String userName,
required DateTime date,
}) async {
if (!_isInitialized) await initialize();
try {
final dayOfWeek = [
'Minggu',
'Senin',
'Selasa',
'Rabu',
'Kamis',
'Jumat',
'Sabtu',
][date.weekday % 7];
final prompt =
'''
Kamu adalah asisten AI yang membantu pengguna menentukan preferensi makanan harian mereka.
Hari ini adalah $dayOfWeek, ${date.day}/${date.month}/${date.year}.
Nama pengguna: $userName
Buatlah 6 pertanyaan menarik dan dinamis untuk mengetahui preferensi makanan pengguna hari ini.
Pertanyaan harus:
1. Menggunakan bahasa yang ramah dan personal (gunakan nama pengguna)
2. Berbeda setiap hari (kreatif dengan kata-kata)
3. Mencakup topik: mood, budget, tingkat pedas, jenis makanan, rasa yang diinginkan (cravings), dan gaya makan (dining style).
PENTING: Berikan response dalam format JSON yang VALID seperti ini:
{
"questions": [
{
"id": "mood",
"question": "Hai $userName! Bagaimana perasaanmu hari ini?",
"type": "choice",
"options": ["Energik", "Santai", "Bahagia", "Fokus", "Butuh Comfort Food"]
},
{
"id": "budget",
"question": "Berapa budget yang kamu siapkan untuk makan hari ini?",
"type": "slider",
"minValue": 10000,
"maxValue": 100000,
"step": 10000
},
{
"id": "spiceLevel",
"question": "Seberapa pedas yang kamu mau hari ini?",
"type": "choice",
"options": ["Tidak Pedas", "Sedikit Pedas", "Sedang", "Pedas", "Extra Pedas"]
},
{
"id": "mealType",
"question": "Kamu lagi pengen makan apa nih?",
"type": "choice",
"options": ["Nasi", "Mie", "Roti/Burger", "Snack", "Minuman", "Dessert"]
},
{
"id": "cravings",
"question": "Lagi pengen rasa yang gimana?",
"type": "choice",
"options": ["Asin/Gurih", "Manis", "Segar/Asam", "Berkuah", "Gorengan", "Sehat"]
},
{
"id": "diningStyle",
"question": "Mau makan gimana?",
"type": "choice",
"options": ["Makan Cepat", "Santai", "Bawa Pulang (Takeaway)"]
}
]
}
Pastikan:
- Gunakan bahasa Indonesia yang casual dan friendly
- Variasikan kata-kata pertanyaan agar tidak monoton
- Jangan gunakan markdown atau code block, langsung JSON saja
- ID harus tetap sama: mood, budget, spiceLevel, mealType, cravings, diningStyle
''';
final response = await _model.generateContent([Content.text(prompt)]);
final text = response.text?.trim() ?? '';
debugPrint('🤖 AI Response for daily questions: $text');
// Parse JSON response
final jsonResponse = jsonDecode(text) as Map<String, dynamic>;
final questions = (jsonResponse['questions'] as List)
.map((q) => DailyQuestion.fromJson(q))
.toList();
return questions;
} catch (e) {
debugPrint('❌ Error generating daily questions: $e');
// Return fallback questions
return _getFallbackQuestions(userName);
}
}
// Fallback questions if AI generation fails
List<DailyQuestion> _getFallbackQuestions(String userName) {
return [
DailyQuestion(
id: 'mood',
question: 'Hai $userName! Bagaimana perasaanmu hari ini?',
type: QuestionType.choice,
options: [
'Energik',
'Santai',
'Bahagia',
'Fokus',
'Butuh Comfort Food',
],
),
DailyQuestion(
id: 'budget',
question: 'Berapa budget yang kamu siapkan untuk makan hari ini?',
type: QuestionType.slider,
minValue: 10000,
maxValue: 100000,
step: 10000,
),
DailyQuestion(
id: 'spiceLevel',
question: 'Seberapa pedas yang kamu mau hari ini?',
type: QuestionType.choice,
options: [
'Tidak Pedas',
'Sedikit Pedas',
'Sedang',
'Pedas',
'Extra Pedas',
],
),
DailyQuestion(
id: 'mealType',
question: 'Kamu lagi pengen makan apa nih?',
type: QuestionType.choice,
options: ['Nasi', 'Mie', 'Roti/Burger', 'Snack', 'Minuman', 'Dessert'],
),
DailyQuestion(
id: 'cravings',
question: 'Lagi pengen rasa yang gimana?',
type: QuestionType.choice,
options: [
'Asin/Gurih',
'Manis',
'Segar',
'Berkuah',
'Gorengan',
'Sehat',
],
),
DailyQuestion(
id: 'diningStyle',
question: 'Mau makan gimana?',
type: QuestionType.choice,
options: ['Makan Cepat', 'Santai', 'Bawa Pulang'],
),
];
}
// Reset to default API key
Future<void> resetToDefaultApiKey() async {
// Remove saved user API key
await _removeSavedApiKey();
_isInitialized = false;
await initialize();
}
// Handle AI function calls
Future<Map<String, dynamic>> _handleFunctionCall(
FunctionCall call,
RecommendationModel context,
) async {
debugPrint('🤖 AI calling function: ${call.name} with ${call.args}');
switch (call.name) {
case 'toggle_menu_favorite':
final menuName = call.args['menuName'] as String;
final isFavorite = call.args['isFavorite'] as bool;
// Find the menu item in recommendations
final recommendation = context.recommendations.firstWhere(
(r) => r.menuName.toLowerCase().contains(menuName.toLowerCase()),
orElse: () => context.recommendations.first,
);
try {
await FirebaseService().toggleRecommendationFavorite(
context.id,
isFavorite,
);
return {
'success': true,
'message':
'Berhasil mengubah status favorit untuk ${recommendation.menuName}',
};
} catch (e) {
return {'success': false, 'error': e.toString()};
}
case 'recommend_specific_drink':
final drinkType = call.args['drinkType'] as String;
final isIce = call.args['isIce'] as bool? ?? true;
return {
'suggestion': isIce
? 'Es $drinkType adalah pilihan menyegarkan!'
: '$drinkType hangat sangat cocok untuk hari ini.',
'context':
'AI merekomendasikan $drinkType ($isIce ? "Dingin" : "Panas")',
};
default:
return {'error': 'Function not found'};
}
}
}