331 lines
13 KiB
JavaScript
331 lines
13 KiB
JavaScript
const axios = require('axios');
|
|
require("dotenv").config();
|
|
const logger = require('./logger.services');
|
|
|
|
const API_URL = process.env.GEMINI_API_URL;
|
|
const MAX_RETRIES = 3;
|
|
const INITIAL_RETRY_DELAY = 1000;
|
|
const MAX_RETRY_DELAY = 10000;
|
|
|
|
exports.getAIResponse = async (prompt) => {
|
|
let limitedPrompt = prompt;
|
|
if (Array.isArray(prompt)) {
|
|
limitedPrompt = prompt.slice(0, 7);
|
|
}
|
|
const promptString = typeof limitedPrompt === "string" ? limitedPrompt : JSON.stringify(limitedPrompt, null, 2);
|
|
const systemInstruction = `
|
|
Anda adalah AI marketing expert yang bertugas menganalisis data aktivitas pengguna dan membuat notifikasi persuasif untuk re-engagement.
|
|
|
|
Instruksi:
|
|
1. Analisis pola dan minat pengguna dari data aktivitas JSON yang diberikan.
|
|
2. Identifikasi kategori atau fitur yang paling sering diakses (hotel, restoran, transportasi, dll).
|
|
3. Buat satu notifikasi yang MENARIK dan PERSUASIF agar pengguna kembali membuka aplikasi.
|
|
4. Gunakan gaya bahasa Indonesia yang casual, friendly, dan menggugah rasa penasaran.
|
|
5. Fokus pada benefit atau value proposition untuk pengguna, bukan sekadar ringkasan.
|
|
6. Notifikasi harus fokus pada re-engagement.
|
|
7. Notifikasi harus sesuai dengan skema JSON yang ditentukan (title maks 50 karakter, description maks 100 karakter). JANGAN tambahkan teks, markdown, atau penjelasan apapun di luar JSON murni.
|
|
`;
|
|
const responseSchema = {
|
|
type: "OBJECT",
|
|
properties: {
|
|
"title": {
|
|
"type": "STRING",
|
|
"description": "Judul yang menggugah & menarik (maksimal 50 karakter)"
|
|
},
|
|
"description": {
|
|
"type": "STRING",
|
|
"description": "Deskripsi persuasif dengan CTA yang jelas (maksimal 100 karakter)"
|
|
}
|
|
},
|
|
required: ["title", "description"]
|
|
};
|
|
const payload = {
|
|
contents: [{
|
|
role: "user",
|
|
parts: [{ text: `Data aktivitas pengguna untuk dianalisis:\n${promptString}` }]
|
|
}],
|
|
systemInstruction: { parts: [{ text: systemInstruction }] },
|
|
generationConfig: {
|
|
responseMimeType: "application/json",
|
|
responseSchema: responseSchema,
|
|
temperature: 0.8,
|
|
maxOutputTokens: 2048,
|
|
}
|
|
};
|
|
|
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
try {
|
|
const response = await axios.post(API_URL, payload, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
const result = response.data;
|
|
|
|
const jsonText = result.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
|
|
if (!jsonText) {
|
|
return {
|
|
error: true,
|
|
message: "Gemini API returned an unexpected response structure or no text content.",
|
|
raw: result
|
|
};
|
|
}
|
|
|
|
let responseJson;
|
|
try {
|
|
responseJson = JSON.parse(jsonText);
|
|
} catch (parseErr) {
|
|
return {
|
|
error: true,
|
|
message: "Failed to parse AI response as JSON.",
|
|
raw: jsonText
|
|
};
|
|
}
|
|
|
|
if (
|
|
typeof responseJson !== "object" ||
|
|
!responseJson.title ||
|
|
!responseJson.description ||
|
|
typeof responseJson.title !== "string" ||
|
|
typeof responseJson.description !== "string"
|
|
) {
|
|
return {
|
|
error: true,
|
|
message: "AI response does not match expected schema.",
|
|
raw: responseJson
|
|
};
|
|
}
|
|
|
|
if (responseJson.title.length > 50 || responseJson.description.length > 100) {
|
|
return {
|
|
error: true,
|
|
message: "AI response fields exceed max length.",
|
|
raw: responseJson
|
|
};
|
|
}
|
|
|
|
return responseJson;
|
|
} catch (error) {
|
|
const isLastAttempt = attempt === MAX_RETRIES - 1;
|
|
const status = error.response?.status;
|
|
|
|
const shouldRetry = !isLastAttempt && (
|
|
!status ||
|
|
status === 429 ||
|
|
status === 503 ||
|
|
status >= 500
|
|
);
|
|
|
|
if (shouldRetry) {
|
|
const exponentialDelay = Math.min(
|
|
INITIAL_RETRY_DELAY * Math.pow(2, attempt),
|
|
MAX_RETRY_DELAY
|
|
);
|
|
const jitter = Math.random() * 1000;
|
|
const delay = exponentialDelay + jitter;
|
|
|
|
logger.warn(`AI Service: Attempt ${attempt + 1}/${MAX_RETRIES} failed${status ? ` (HTTP ${status})` : ''}. Retrying in ${Math.round(delay / 1000)}s...`);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
continue;
|
|
}
|
|
|
|
const errorMessage = status
|
|
? `API call failed with status ${status}: ${error.response?.data?.error?.message || 'Unknown error'}`
|
|
: `Network error: ${error.message}`;
|
|
|
|
logger.error(`AI Service: Failed after ${attempt + 1} attempts: ${errorMessage}`);
|
|
|
|
return {
|
|
error: true,
|
|
message: errorMessage,
|
|
raw: error.response?.data || error.message,
|
|
attempts: attempt + 1
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
error: true,
|
|
message: "Exhausted all retries without a successful API response.",
|
|
raw: null,
|
|
attempts: MAX_RETRIES
|
|
};
|
|
};
|
|
|
|
exports.predictOptimalDeliveryTime = async (userActivityData) => {
|
|
const activityString = JSON.stringify(userActivityData, null, 2);
|
|
|
|
const systemInstruction = `
|
|
Anda adalah AI specialist yang menganalisis pola perilaku pengguna untuk menentukan waktu OPTIMAL pengiriman notifikasi.
|
|
|
|
TUGAS ANDA:
|
|
1. Analisis data aktivitas pengguna (waktu login, frekuensi penggunaan, pola harian/mingguan)
|
|
2. Identifikasi jam-jam PEAK engagement pengguna (kapan user paling aktif)
|
|
3. Prediksi waktu terbaik untuk mengirim notifikasi agar dibaca dan di-klik
|
|
4. Pertimbangkan:
|
|
- Pola aktivitas historis (jam berapa user biasa online)
|
|
- Hari dalam seminggu (weekday vs weekend berbeda)
|
|
- Interval ideal antar notifikasi (jangan spam)
|
|
- Timezone dan kebiasaan lokal (pagi, siang, sore, malam)
|
|
|
|
5. Berikan rekomendasi waktu dalam format ISO 8601 dengan timezone
|
|
6. Sertakan confidence score (0-100) dan reasoning singkat
|
|
|
|
ATURAN PENTING:
|
|
- Jika user aktif pagi (06:00-10:00) → kirim pagi hari berikutnya
|
|
- Jika user aktif siang (11:00-15:00) → kirim saat lunch break
|
|
- Jika user aktif sore/malam (16:00-22:00) → kirim sore hari
|
|
- Jika tidak ada pola jelas → default ke jam 09:00 atau 19:00 (high engagement hours)
|
|
- Minimum delay: 30 menit dari sekarang
|
|
- Maximum delay: 24 jam dari sekarang
|
|
- Hindari jam tidur (23:00-05:00)
|
|
|
|
OUTPUT harus JSON murni tanpa markdown atau teks tambahan.
|
|
`;
|
|
|
|
const currentTime = new Date();
|
|
const responseSchema = {
|
|
type: "OBJECT",
|
|
properties: {
|
|
"optimalDeliveryTime": {
|
|
"type": "STRING",
|
|
"description": "Waktu optimal pengiriman dalam ISO 8601 format (contoh: 2024-12-08T19:00:00+07:00)"
|
|
},
|
|
"confidenceScore": {
|
|
"type": "NUMBER",
|
|
"description": "Confidence score prediksi (0-100)"
|
|
},
|
|
"reasoning": {
|
|
"type": "STRING",
|
|
"description": "Penjelasan singkat mengapa waktu ini dipilih (maks 150 karakter)"
|
|
},
|
|
"userEngagementPattern": {
|
|
"type": "STRING",
|
|
"description": "Pola engagement user: 'morning_active', 'afternoon_active', 'evening_active', 'night_active', atau 'irregular'"
|
|
},
|
|
"delayMinutes": {
|
|
"type": "NUMBER",
|
|
"description": "Delay dalam menit dari sekarang (minimal 30, maksimal 1440)"
|
|
}
|
|
},
|
|
required: ["optimalDeliveryTime", "confidenceScore", "reasoning", "userEngagementPattern", "delayMinutes"]
|
|
};
|
|
|
|
const payload = {
|
|
contents: [{
|
|
role: "user",
|
|
parts: [{
|
|
text: `Waktu sekarang: ${currentTime.toISOString()}\n\nData aktivitas pengguna:\n${activityString}\n\nTentukan waktu OPTIMAL untuk mengirim notifikasi re-engagement.`
|
|
}]
|
|
}],
|
|
systemInstruction: { parts: [{ text: systemInstruction }] },
|
|
generationConfig: {
|
|
responseMimeType: "application/json",
|
|
responseSchema: responseSchema,
|
|
temperature: 0.7,
|
|
maxOutputTokens: 2048,
|
|
}
|
|
};
|
|
|
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
try {
|
|
const response = await axios.post(API_URL, payload, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
const result = response.data;
|
|
const jsonText = result.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
|
|
if (!jsonText) {
|
|
return {
|
|
error: true,
|
|
message: "Gemini API returned unexpected response for predictive timing.",
|
|
raw: result
|
|
};
|
|
}
|
|
|
|
let responseJson;
|
|
try {
|
|
responseJson = JSON.parse(jsonText);
|
|
} catch (parseErr) {
|
|
return {
|
|
error: true,
|
|
message: "Failed to parse predictive timing response as JSON.",
|
|
raw: jsonText
|
|
};
|
|
}
|
|
|
|
if (
|
|
!responseJson.optimalDeliveryTime ||
|
|
typeof responseJson.confidenceScore !== "number" ||
|
|
!responseJson.reasoning ||
|
|
!responseJson.userEngagementPattern ||
|
|
typeof responseJson.delayMinutes !== "number"
|
|
) {
|
|
return {
|
|
error: true,
|
|
message: "Predictive timing response missing required fields.",
|
|
raw: responseJson
|
|
};
|
|
}
|
|
|
|
if (responseJson.delayMinutes < 30 || responseJson.delayMinutes > 1440) {
|
|
responseJson.delayMinutes = Math.max(30, Math.min(1440, responseJson.delayMinutes));
|
|
}
|
|
|
|
const deliveryTime = new Date(responseJson.optimalDeliveryTime);
|
|
if (deliveryTime <= currentTime) {
|
|
const fallbackTime = new Date(currentTime.getTime() + 60 * 60 * 1000);
|
|
responseJson.optimalDeliveryTime = fallbackTime.toISOString();
|
|
responseJson.delayMinutes = 60;
|
|
responseJson.reasoning = "Adjusted to future time (1 hour from now)";
|
|
}
|
|
|
|
logger.info(`AI Predictive Timing: Scheduled for ${responseJson.optimalDeliveryTime} (${responseJson.delayMinutes} min delay, ${responseJson.confidenceScore}% confidence)`);
|
|
|
|
return responseJson;
|
|
|
|
} catch (error) {
|
|
const isLastAttempt = attempt === MAX_RETRIES - 1;
|
|
const status = error.response?.status;
|
|
|
|
const shouldRetry = !isLastAttempt && (
|
|
!status ||
|
|
status === 429 ||
|
|
status === 503 ||
|
|
status >= 500
|
|
);
|
|
|
|
if (shouldRetry) {
|
|
const delay = Math.min(
|
|
INITIAL_RETRY_DELAY * Math.pow(2, attempt) + Math.random() * 1000,
|
|
MAX_RETRY_DELAY
|
|
);
|
|
logger.warn(`Predictive Timing: Retry ${attempt + 1}/${MAX_RETRIES} in ${Math.round(delay/1000)}s`);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
continue;
|
|
}
|
|
|
|
logger.error(`Predictive Timing failed: ${error.message}`);
|
|
|
|
const fallbackTime = new Date(currentTime.getTime() + 2 * 60 * 60 * 1000);
|
|
return {
|
|
optimalDeliveryTime: fallbackTime.toISOString(),
|
|
confidenceScore: 50,
|
|
reasoning: "AI unavailable, using default 2-hour delay",
|
|
userEngagementPattern: "unknown",
|
|
delayMinutes: 120,
|
|
aiError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
const fallbackTime = new Date(currentTime.getTime() + 2 * 60 * 60 * 1000);
|
|
return {
|
|
optimalDeliveryTime: fallbackTime.toISOString(),
|
|
confidenceScore: 50,
|
|
reasoning: "Fallback scheduling after retries exhausted",
|
|
userEngagementPattern: "unknown",
|
|
delayMinutes: 120,
|
|
aiError: true
|
|
};
|
|
};
|