import { rawApiKpiData } from '../mockData/rawApiData.ts'; import { initialEnvironmentData } from '../mockData/dashboardData.ts'; import { initialFeedSackHistory } from '../mockData/feedSackData.ts'; import { initialLocations, initialCoops, initialFloors } from '../mockData/locationData.ts'; import { initialWeightDistribution } from '../mockData/weightData.ts'; import { EnvironmentData, FeedSackLogEntry, Location, Coop, Floor, WeightDistributionApiResponse, WeightDistributionApiData, SensorWeightSummaryResponse, ScalePredictionRangeResponse, } from '../types.ts'; import type { DashboardReportPayload, DashboardReportSnapshotResponse, } from '../types/dashboardReport.ts'; // --- Configuration --- const API_BASE_URL = 'https://dashboard.cpsp.id'; const IOT_API_BASE = `${API_BASE_URL}/api/iot`; const FEED_SACK_API_BASE = `${API_BASE_URL}/api/counting/sacks`; const CAGE_SITES_API = `${API_BASE_URL}/api/cage_sites/get_all/`; const FCR_API_BASE = `${API_BASE_URL}/api/data-support/fcrtable`; // Use environment variable for Docker deployment, fallback to localhost for local dev const DB_API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001/api'; const readErrorMessage = async (response: Response, fallback: string) => { try { const bodyText = await response.text(); if (!bodyText) { return fallback; } try { const parsed = JSON.parse(bodyText) as Record; const nestedError = parsed.error; if (nestedError && typeof nestedError === 'object' && !Array.isArray(nestedError)) { const nested = nestedError as Record; const nestedMessage = nested.message ?? nested.error ?? parsed.message ?? parsed.error ?? bodyText; if (typeof nestedMessage === 'string' && nestedMessage.trim()) { return nestedMessage.trim(); } } const topLevelMessage = parsed.message ?? parsed.error ?? bodyText; if (typeof topLevelMessage === 'string' && topLevelMessage.trim()) { return topLevelMessage.trim(); } return JSON.stringify(parsed); } catch { const trimmed = bodyText.trim(); return trimmed.length > 0 ? trimmed : fallback; } } catch { return fallback; } }; const throwApiError = async (response: Response, fallbackPrefix = 'HTTP error!') => { const fallback = `${fallbackPrefix} status: ${response.status}`; throw new Error(await readErrorMessage(response, fallback)); }; // Build cage API endpoint dynamically based on cage UUID const buildCageApiEndpoint = (cageUuid: string): string => { return `${API_BASE_URL}/api/scales/cage/${cageUuid}`; }; // Hardcoded values per user preference // const DEFAULT_SITE_ID = 1; // Reserved for future use const FCR_CACHE_KEY_PREFIX = 'fcr-cache-'; const FCR_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours const WEIGHT_DIST_CACHE_KEY_PREFIX = 'weight-dist-1hour-'; const WEIGHT_DIST_CACHE_TTL = 60 * 60 * 1000; // 1 hour // --- API Functions --- /** * Fetches multi-coop weight data from the live API. * @param cageUuid - The cage UUID to fetch data for. If null/empty, returns null. */ async function fetchLiveMultiCoopData(cageUuid?: string | null) { // If no cage UUID provided, return null (will trigger empty state) if (!cageUuid) { console.warn('No cage UUID provided for weight data fetch'); return null; } try { const apiEndpoint = buildCageApiEndpoint(cageUuid); const response = await fetch(apiEndpoint); if (!response.ok) { console.error(`Failed to fetch from ${apiEndpoint}:`, response.status); return null; } const jsonData = await response.json(); // Check if kpi object exists AND has at least one property if ( jsonData && jsonData.success && jsonData.data && jsonData.data.kpi && Object.keys(jsonData.data.kpi).length > 0 ) { return jsonData.data.kpi; } else { throw new Error('Invalid data structure from API or empty KPI object'); } } catch (error) { console.error('Failed to fetch live API data:', error); return null; } } /** * Returns a promise that resolves with the mock data. */ async function fetchMockMultiCoopData() { // Simulate network delay await new Promise((resolve) => setTimeout(resolve, 300)); return Promise.resolve(rawApiKpiData); } // --- IoT Sensor API Functions --- /** * Transforms IoT API response to EnvironmentData format */ function transformIotSensorData(apiData: any): EnvironmentData { // API returns an array, get the first item const sensorData = Array.isArray(apiData) ? apiData[0] : apiData; return { time: new Date(sensorData.update_at).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', }), Suhu: sensorData.suhu, Kelembapan: sensorData.kelembaban, Amonia: sensorData.amonia, }; } /** * Fetches latest IoT sensor data from the live API */ async function fetchLiveIotSensorData() { const response = await fetch(`${IOT_API_BASE}/flocks/sensors/latest/`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const json = await response.json(); if (json.success && json.data) { return transformIotSensorData(json.data); } throw new Error('Invalid data structure from API'); } /** * Returns mock IoT sensor data */ async function fetchMockIotData() { await new Promise((resolve) => setTimeout(resolve, 300)); // Return the last item from mock data return Promise.resolve(initialEnvironmentData[initialEnvironmentData.length - 1]); } /** * Fetches IoT sensor data for a date range from the live API * @param startDate - Start date in YYYY-MM-DD format * @param endDate - End date in YYYY-MM-DD format */ async function fetchLiveIotSensorRange( startDate: string, endDate: string ): Promise { const response = await fetch( `${IOT_API_BASE}/flocks/sensors/range/?start_date=${startDate}&end_date=${endDate}` ); // Handle 404 - no data found for date range if (response.status === 404) { return []; // Return empty array instead of throwing error } if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const json = await response.json(); // Handle API returning success: false if (json.success === false) { return []; // Return empty array } if (json.success && Array.isArray(json.data)) { // Filter to last 12 hours const now = new Date(); const twelveHoursAgo = new Date(now.getTime() - 12 * 60 * 60 * 1000); // Transform API data to EnvironmentData format, filtering by time and invalid readings const transformedData = json.data .filter((item: any) => { const timestamp = new Date(item.update_at); // Filter out data older than 12 hours and sensor failures (zero values) return timestamp >= twelveHoursAgo && item.suhu > 0 && item.kelembaban > 0; }) .map((item: any) => { // API returns UTC time (e.g., "2025-12-01T00:00:04.147000Z") // Convert to WIB (Jakarta, UTC+7) for display const timestampUTC = new Date(item.update_at); // Use en-US locale to ensure consistent HH:MM format const wibTimeString = timestampUTC.toLocaleString('en-US', { timeZone: 'Asia/Jakarta', hour: '2-digit', minute: '2-digit', hour12: false, }); // Extract time part (format is "HH:MM" or "MM/DD/YYYY, HH:MM") const timePart = (wibTimeString.includes(',') ? wibTimeString.split(', ')[1] : wibTimeString) ?? ''; // Round to 5-minute intervals for aggregation const [hourStr = '0', minuteStr = '0'] = timePart.split(':'); const hour = parseInt(hourStr, 10); const minute = Math.floor(parseInt(minuteStr, 10) / 5) * 5; return { time: `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`, Suhu: item.suhu, Kelembapan: item.kelembaban, Amonia: item.amonia, }; }); // Aggregate data by 5-minute time buckets (average values for same time slot) const aggregated: { [key: string]: { Suhu: number[]; Kelembapan: number[]; Amonia: number[] }; } = {}; transformedData.forEach((item: (typeof transformedData)[number]) => { const bucket = aggregated[item.time] ?? (aggregated[item.time] = { Suhu: [], Kelembapan: [], Amonia: [] }); bucket.Suhu.push(item.Suhu); bucket.Kelembapan.push(item.Kelembapan); bucket.Amonia.push(item.Amonia); }); // Calculate averages for each time slot const result: EnvironmentData[] = Object.entries(aggregated).map(([time, values]) => ({ time, Suhu: values.Suhu.reduce((a, b) => a + b, 0) / values.Suhu.length, Kelembapan: values.Kelembapan.reduce((a, b) => a + b, 0) / values.Kelembapan.length, Amonia: values.Amonia.reduce((a, b) => a + b, 0) / values.Amonia.length, })); // Sort by time result.sort((a, b) => { const [aHour = 0, aMin = 0] = a.time.split(':').map(Number); const [bHour = 0, bMin = 0] = b.time.split(':').map(Number); return aHour * 60 + aMin - (bHour * 60 + bMin); }); return result; } throw new Error('Invalid data structure from API'); } /** One cycle day of environment readings, averaged from every sensor sample. */ export interface DailyEnvironmentPoint { hari: number; tanggal: string; suhu_C: number; suhuMin_C: number; suhuMax_C: number; kelembapan_persen: number; co2_ppm: number | null; jumlahSampel: number; } /** * Environment history aggregated per CYCLE DAY, for root-cause analysis. * * `fetchLiveIotSensorRange` cannot serve this: it throws away everything older * than 12 hours and reduces the timestamp to "HH:MM", which is right for the * live chart and useless for asking why the birds died on day 13. This one * keeps the API's own `day` field — verified to be the cycle day, and already * cut at the WIB midnight boundary rather than UTC's. */ async function fetchIotDailyEnvironment( startDate: string, endDate: string ): Promise { const response = await fetch( `${IOT_API_BASE}/flocks/sensors/range/?start_date=${startDate}&end_date=${endDate}` ); if (response.status === 404) return []; if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); const json = await response.json(); if (!json?.success || !Array.isArray(json.data)) return []; const buckets = new Map< number, { tanggal: string; suhu: number[]; rh: number[]; co2: number[] } >(); for (const item of json.data) { const hari = Number(item?.day); const suhu = Number(item?.suhu); const rh = Number(item?.kelembaban); // A dead sensor reports 0 for both; averaging those in drags the day's mean // below the standard and manufactures a "kandang kedinginan" finding. if (!Number.isFinite(hari) || hari <= 0 || !(suhu > 0) || !(rh > 0)) continue; const bucket = buckets.get(hari) ?? (buckets.set(hari, { tanggal: String(item?.update_at ?? '').slice(0, 10), suhu: [], rh: [], co2: [], }), buckets.get(hari)!); bucket.suhu.push(suhu); bucket.rh.push(rh); const co2 = Number(item?.co2); if (Number.isFinite(co2) && co2 > 0) bucket.co2.push(co2); } const mean = (values: number[]) => values.length === 0 ? 0 : values.reduce((sum, v) => sum + v, 0) / values.length; const round1 = (value: number) => Math.round(value * 10) / 10; return [...buckets.entries()] .map(([hari, bucket]) => ({ hari, tanggal: bucket.tanggal, suhu_C: round1(mean(bucket.suhu)), suhuMin_C: round1(Math.min(...bucket.suhu)), suhuMax_C: round1(Math.max(...bucket.suhu)), kelembapan_persen: round1(mean(bucket.rh)), co2_ppm: bucket.co2.length > 0 ? round1(mean(bucket.co2)) : null, jumlahSampel: bucket.suhu.length, })) .sort((a, b) => a.hari - b.hari); } /** * Returns mock IoT sensor range data */ async function fetchMockIotRangeData(): Promise { await new Promise((resolve) => setTimeout(resolve, 300)); return Promise.resolve(initialEnvironmentData); } /** * Fetches IoT activity logs from the live API */ async function fetchLiveIotActivityLogs(limit: number = 10) { const response = await fetch(`${IOT_API_BASE}/log-act/last/?limit=${limit}`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const json = await response.json(); if (json.success && json.data) { return json.data; // Return raw data for now, transformation will be done in context } throw new Error('Invalid data structure from API'); } // --- Feed Sack Counting API Functions --- /** * Transforms feed sack API response to FeedSackLogEntry format */ function transformFeedSackData(apiData: any[]): { entries: FeedSackLogEntry[]; availableCameras: string[]; } { const entries: FeedSackLogEntry[] = []; const availableCameras: string[] = []; apiData.forEach((cameraData) => { availableCameras.push(cameraData.camera_name); // Infer type for backward compatibility const type: 'inbound' | 'usage' | 'outbound' = cameraData.camera_name.includes('karung_masuk') ? 'inbound' : cameraData.camera_name.includes('karung_keluar') ? 'outbound' : 'usage'; // Extract location from camera name (e.g., "kandang_1_karung_masuk" → "Kandang 1") const location = cameraData.camera_name .split('_') .slice(0, 2) .join(' ') .replace(/^\w/, (c: string) => c.toUpperCase()); cameraData.records.forEach((record: any) => { entries.push({ id: `${cameraData.camera_name}-${record.date}`, timestamp: new Date(record.date), location: location, cameraName: cameraData.camera_name, count: record.value, type: type, }); }); }); return { entries: entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()), availableCameras, }; } /** * Fetches feed sack counting data from the live API */ async function fetchLiveFeedSackData( siteId: number, cameraName?: string ): Promise<{ entries: FeedSackLogEntry[]; availableCameras: string[] }> { const url = cameraName ? `${FEED_SACK_API_BASE}/${siteId}/${cameraName}/` : `${FEED_SACK_API_BASE}/${siteId}/`; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const json = await response.json(); if (json.success && json.data) { return transformFeedSackData(json.data); } throw new Error('Invalid data structure from API'); } /** * Returns mock feed sack data */ async function fetchMockFeedSackData(): Promise<{ entries: FeedSackLogEntry[]; availableCameras: string[]; }> { await new Promise((resolve) => setTimeout(resolve, 300)); return { entries: initialFeedSackHistory, availableCameras: [ 'kandang_1_karung_masuk-check_pintu', 'kandang_1_karung_pakan', 'kandang_2_karung_pakan', 'kandang_1_karung_masuk', 'kandang_atas_feeder_kiri', 'kandang_bawah_feeder_kanan', ], }; } // --- Location/Cage Sites API Functions --- /** * Transforms cage site API response to Location, Coop, Floor format */ function transformCageSiteData(apiData: any[]) { const locations: Location[] = []; const coops: Coop[] = []; const floors: Floor[] = []; apiData.forEach((site, idx) => { const locationId = `loc-${idx}`; locations.push({ id: locationId, name: site.name, }); site.iot_scales?.forEach((scale: any) => { coops.push({ id: scale.id, name: scale.name, locationId: locationId, }); scale.devices?.forEach((device: any) => { floors.push({ id: device.id, name: device.name, coopId: scale.id, }); }); }); }); return { locations, coops, floors }; } /** * Fetches cage sites data from the live API */ async function fetchLiveCageSites() { const response = await fetch(CAGE_SITES_API); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const json = await response.json(); if (json.success && json.data) { return transformCageSiteData(json.data); } throw new Error('Invalid data structure from API'); } /** * Returns mock location data */ async function fetchMockLocationData() { await new Promise((resolve) => setTimeout(resolve, 300)); return Promise.resolve({ locations: initialLocations, coops: initialCoops, floors: initialFloors, }); } // --- FCR Calculation API Functions --- /** * Gets cached FCR data from localStorage */ function getCachedFcrData(dow: number, population: number, day: number) { const key = `${FCR_CACHE_KEY_PREFIX}${dow}-${population}-${day}`; const cached = localStorage.getItem(key); if (!cached) return null; try { const { data, timestamp } = JSON.parse(cached); if (Date.now() - timestamp < FCR_CACHE_TTL) { return data; } } catch { localStorage.removeItem(key); } return null; } /** * Caches FCR data to localStorage */ function cacheFcrData(dow: number, population: number, day: number, data: any) { const key = `${FCR_CACHE_KEY_PREFIX}${dow}-${population}-${day}`; localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() })); } // --- Weight Distribution 1-hour Caching Functions --- /** * Calculate which 14-day group a date belongs to * Groups are aligned to cycle start date (used for batch fetching) * @param date - Date to check * @param cycleStartDate - Cycle start date for alignment * @returns Group number (0-indexed) */ function get14DayGroup(date: Date, cycleStartDate: Date): number { const targetDate = new Date(date); const startDate = new Date(cycleStartDate); targetDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0); const daysSinceStart = Math.floor( (targetDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24) ); return Math.floor(daysSinceStart / 14); } /** * Gets cached weight distribution data for a 14-day group from localStorage * Cache TTL: 1 hour * @param groupNumber - 14-day group number * @returns Cached data or null if not found/expired */ function getCachedWeightDistribution(groupNumber: number): WeightDistributionApiData[] | null { const key = `${WEIGHT_DIST_CACHE_KEY_PREFIX}${groupNumber}`; const cached = localStorage.getItem(key); if (!cached) return null; try { const { data, timestamp } = JSON.parse(cached); if (Date.now() - timestamp < WEIGHT_DIST_CACHE_TTL) { return data; } // Expired, remove from cache localStorage.removeItem(key); } catch { localStorage.removeItem(key); } return null; } /** * Caches weight distribution data for a 14-day group to localStorage * @param groupNumber - 14-day group number * @param data - Weight distribution data array for the entire 14-day period */ function cacheWeightDistribution(groupNumber: number, data: WeightDistributionApiData[]) { const key = `${WEIGHT_DIST_CACHE_KEY_PREFIX}${groupNumber}`; localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() })); } /** * Clears all weight distribution cache from localStorage */ function clearWeightDistributionCache() { const keys = Object.keys(localStorage); keys.forEach((key) => { // Clear both old (14-day) and new (1-hour) cache keys if (key.startsWith(WEIGHT_DIST_CACHE_KEY_PREFIX) || key.startsWith('weight-dist-14day-')) { localStorage.removeItem(key); } }); } /** * Fetches FCR table data for a specific day from the live API */ async function fetchLiveFcrData(dow: number, population: number, day: number) { // Check cache first const cached = getCachedFcrData(dow, population, day); if (cached) return cached; const response = await fetch(`${FCR_API_BASE}/${dow}/${population}/${day}/`); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const json = await response.json(); if (json.success && json.data) { // Cache successful result cacheFcrData(dow, population, day, json.data); return json.data; } throw new Error('Invalid data structure from API'); } /** * Batch fetches FCR data for multiple days */ async function fetchLiveFcrDataBatch(dow: number, population: number, days: number[]) { const promises = days.map((day) => fetchLiveFcrData(dow, population, day)); const results = await Promise.allSettled(promises); return results .filter((r) => r.status === 'fulfilled') .map((r) => (r as PromiseFulfilledResult).value) .filter((data) => data !== null); } /** * Returns mock FCR data for a specific day */ async function fetchMockFcrData(day: number) { await new Promise((resolve) => setTimeout(resolve, 300)); // Generate mock FCR data based on day return Promise.resolve({ day: day, weight: 35 + day * 15, adg: 15, 'daily-gain': 0.5, 'daily-feed': 20, 'feeds-use': 100, fcr: 1.2, 'total-feed': day * 100, 'bags-feed': day * 2, }); } /** * Returns mock FCR data batch */ async function fetchMockFcrDataBatch(maxDay: number) { const days = Array.from({ length: maxDay }, (_, i) => i + 1); const promises = days.map((day) => fetchMockFcrData(day)); return Promise.all(promises); } // --- API Client --- export const apiClient = { /** * Gets the multi-coop weight comparison data. * Switches between the live API and mock data based on the mode flag. */ getMultiCoopWeightData: async (mode: 'LIVE' | 'DEMO', cageUuid?: string | null) => { if (mode === 'LIVE') { return fetchLiveMultiCoopData(cageUuid); } else { return fetchMockMultiCoopData(); } }, /** * Gets latest IoT sensor data. * In LIVE mode, throws errors (no fallback to mock). */ getIotSensorLatest: async (mode: 'LIVE' | 'DEMO') => { if (mode === 'LIVE') { return fetchLiveIotSensorData(); } else { return fetchMockIotData(); } }, /** * Gets IoT activity logs. * In LIVE mode, throws errors (no fallback to mock). */ getIotActivityLogs: async (limit: number = 10, mode: 'LIVE' | 'DEMO') => { if (mode === 'LIVE') { return fetchLiveIotActivityLogs(limit); } else { return Promise.resolve([]); // Return empty array for mock mode } }, /** * Gets IoT sensor data for a date range. * In LIVE mode, fetches from API. In DEMO mode, returns mock data. */ getIotSensorRange: async (startDate: string, endDate: string, mode: 'LIVE' | 'DEMO') => { if (mode === 'LIVE') { return fetchLiveIotSensorRange(startDate, endDate); } else { return fetchMockIotRangeData(); } }, /** * Environment readings averaged per cycle day, for the AI Insight * root-cause analysis. DEMO mode has no dated history to aggregate, so it * returns nothing rather than mock days that would invent correlations. */ getIotDailyEnvironment: async ( startDate: string, endDate: string, mode: 'LIVE' | 'DEMO' ): Promise => { if (mode !== 'LIVE') return []; return fetchIotDailyEnvironment(startDate, endDate); }, /** * Gets feed sack counting data. * In LIVE mode, throws errors (no fallback to mock). */ getFeedSackCounting: async ( siteId: number, cameraName: string | undefined, mode: 'LIVE' | 'DEMO' ): Promise<{ entries: FeedSackLogEntry[]; availableCameras: string[] }> => { if (mode === 'LIVE') { return fetchLiveFeedSackData(siteId, cameraName); } else { return fetchMockFeedSackData(); } }, getFeedSackColumnConfig: async () => { const response = await fetch(`${DB_API_BASE_URL}/feed-sack-column-config`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success) return json.data; throw new Error('Invalid data structure from API'); }, saveFeedSackColumnConfig: async (config: any[]) => { const response = await fetch(`${DB_API_BASE_URL}/feed-sack-column-config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config }), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success) return json.data; throw new Error('Invalid data structure from API'); }, /** * Gets initial feed balance for a specific cycle */ getCycleFeedInitialBalance: async (cycleId: string) => { const response = await fetch(`${DB_API_BASE_URL}/cycle-feed-initial-balance/${cycleId}`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) { // Convert date strings to Date objects return { ...json.data, createdAt: new Date(json.data.createdAt), updatedAt: new Date(json.data.updatedAt), }; } return json.data; // null case }, /** * Saves or updates initial feed balance for a cycle */ saveCycleFeedInitialBalance: async (cycleId: string, saldoAwal: number, createdBy: string) => { const response = await fetch(`${DB_API_BASE_URL}/cycle-feed-initial-balance`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cycleId, saldoAwal, createdBy }), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) { // Convert date strings to Date objects return { ...json.data, createdAt: new Date(json.data.createdAt), updatedAt: new Date(json.data.updatedAt), }; } throw new Error('Invalid data structure from API'); }, /** * Deletes initial feed balance for a cycle */ deleteCycleFeedInitialBalance: async (cycleId: string) => { const response = await fetch(`${DB_API_BASE_URL}/cycle-feed-initial-balance/${cycleId}`, { method: 'DELETE', }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success) return json.data; throw new Error('Invalid data structure from API'); }, /** * Gets cage sites/location data. * In LIVE mode, throws errors (no fallback to mock). */ getCageSites: async (mode: 'LIVE' | 'DEMO') => { if (mode === 'LIVE') { return fetchLiveCageSites(); } else { return fetchMockLocationData(); } }, /** * Gets FCR table data for a specific day. * In LIVE mode, throws errors (no fallback to mock). Uses localStorage caching. */ getFcrTableForDay: async ( dow: number, population: number, day: number, mode: 'LIVE' | 'DEMO' ) => { if (mode === 'LIVE') { return fetchLiveFcrData(dow, population, day); } else { return fetchMockFcrData(day); } }, /** * Gets FCR table data for multiple days (batch fetch). * In LIVE mode, throws errors (no fallback to mock). Uses localStorage caching. */ getFcrTableBatch: async ( dow: number, population: number, maxDay: number, mode: 'LIVE' | 'DEMO' ) => { const days = Array.from({ length: maxDay }, (_, i) => i + 1); if (mode === 'LIVE') { return fetchLiveFcrDataBatch(dow, population, days); } else { return fetchMockFcrDataBatch(maxDay); } }, /** * Gets weight distribution data for yesterday. * In LIVE mode, fetches from API. In DEMO mode, returns mock data. */ getWeightDistributionYesterday: async ( mode: 'LIVE' | 'DEMO' ): Promise => { if (mode === 'LIVE') { const response = await fetch( `${API_BASE_URL}/api/iot/flocks/distribusi_bobot/?mode=yesterday` ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } else { // Return mock data for DEMO mode based on initialWeightDistribution return { success: true, mode: 'yesterday', range_size: 50.0, data: { tanggal: new Date().toISOString().split('T')[0] ?? '', total_predictions: 150, total_chicken_count: initialWeightDistribution.reduce((sum, item) => sum + item.count, 0), distribusi: initialWeightDistribution.map((item) => ({ range_bobot: item.range, range_start: 0, range_end: 0, frekuensi: Math.floor(item.count / 10), total_ayam: item.count, rata_rata: 2200, })), statistik: { min: 1900, max: 2600, avg: 2280, Standard_deviasi: 150, uniformity: 85, }, }, }; } }, /** * Gets weight distribution data for a date range with 1-hour caching. * In LIVE mode, fetches from API or uses cached data. In DEMO mode, returns mock data. * Cache is grouped by 14-day periods and expires after 1 hour. * @param startDate - Start date in YYYY-MM-DD format * @param endDate - End date in YYYY-MM-DD format * @param mode - 'LIVE' or 'DEMO' * @param cycleStartDate - Optional cycle start date for cache alignment (required for LIVE mode) */ getWeightDistributionRange: async ( startDate: string, endDate: string, mode: 'LIVE' | 'DEMO', cycleStartDate?: string ): Promise => { if (mode === 'LIVE') { // For single-date requests (startDate === endDate), check if we have cached 14-day group data if (startDate === endDate && cycleStartDate) { const requestDate = new Date(startDate); const cycleStart = new Date(cycleStartDate); const groupNumber = get14DayGroup(requestDate, cycleStart); // Check if we have cached data for this 14-day group const cachedGroup = getCachedWeightDistribution(groupNumber); if (cachedGroup) { // Find the specific date in cached data const cachedDayData = cachedGroup.find((item) => item.tanggal === startDate); if (cachedDayData) { // Return cached single-day data return { success: true, mode: 'range', range_size: 50.0, data: [cachedDayData], }; } } // No cache hit, fetch the entire 14-day group from API const groupStartDate = new Date(cycleStart); groupStartDate.setDate(cycleStart.getDate() + groupNumber * 14); const groupEndDate = new Date(groupStartDate); groupEndDate.setDate(groupStartDate.getDate() + 13); // 14 days total (0-13) // Don't fetch beyond today const today = new Date(); today.setHours(0, 0, 0, 0); const fetchEndDate = groupEndDate > today ? today : groupEndDate; const groupStartStr = groupStartDate.toISOString().split('T')[0]; const groupEndStr = fetchEndDate.toISOString().split('T')[0]; // Fetch entire 14-day group const response = await fetch( `${API_BASE_URL}/api/iot/flocks/distribusi_bobot/?mode=range&start_date=${groupStartStr}&end_date=${groupEndStr}` ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const apiResponse: WeightDistributionApiResponse = await response.json(); // Cache the entire group if (apiResponse.success && Array.isArray(apiResponse.data)) { cacheWeightDistribution(groupNumber, apiResponse.data); // Return only the requested date const requestedDayData = apiResponse.data.find((item) => item.tanggal === startDate); if (requestedDayData) { return { success: true, mode: 'range', range_size: apiResponse.range_size, data: [requestedDayData], }; } } return apiResponse; } // For date ranges or if no cycleStartDate provided, fetch directly without caching const response = await fetch( `${API_BASE_URL}/api/iot/flocks/distribusi_bobot/?mode=range&start_date=${startDate}&end_date=${endDate}` ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } else { // Return mock data for DEMO mode with date range const start = new Date(startDate); const end = new Date(endDate); const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1; const mockDataArray: WeightDistributionApiData[] = []; for (let i = 0; i < days; i++) { const currentDate = new Date(start); currentDate.setDate(start.getDate() + i); const totalChickenCount = initialWeightDistribution.reduce( (sum, item) => sum + item.count, 0 ); mockDataArray.push({ tanggal: currentDate.toISOString().split('T')[0] ?? '', total_predictions: 100 + Math.floor(Math.random() * 100), total_chicken_count: totalChickenCount + Math.floor(Math.random() * 200 - 100), distribusi: initialWeightDistribution.map((item) => ({ range_bobot: item.range, range_start: 0, range_end: 0, frekuensi: Math.floor(item.count / 10) + Math.floor(Math.random() * 5), total_ayam: item.count + Math.floor(Math.random() * 100 - 50), rata_rata: 2200 + Math.random() * 200 - 100, })), statistik: { min: 1900 + Math.random() * 100, max: 2500 + Math.random() * 100, avg: 2200 + Math.random() * 100, Standard_deviasi: 140 + Math.random() * 20, uniformity: 80 + Math.random() * 10, }, }); } return { success: true, mode: 'range', range_size: 50.0, data: mockDataArray, }; } }, /** * Gets sensor weight summary for a specific date. * In LIVE mode, fetches from API. In DEMO mode, returns mock data. * @param date - Date in YYYY-MM-DD format * @param mode - 'LIVE' or 'DEMO' */ getSensorWeightSummary: async ( date: string, mode: 'LIVE' | 'DEMO' ): Promise => { if (mode === 'LIVE') { const response = await fetch( `${API_BASE_URL}/api/scales/predictions/summary?start_date=${date}&end_date=${date}` ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } else { // Return mock data for DEMO mode return { success: true, data: [ { device_id: 'iot-5', device_name: 'Timbangan A-2-W1', total_weight_prediction: 0.0, total_weight_actual: 450000.0, avg_weight_prediction: 0.0, avg_weight_actual: 750.0, chicken_count: 600, }, { device_id: 'iot-13', device_name: 'Timbangan C-1-W1', total_weight_prediction: 0.0, total_weight_actual: 482500.0, avg_weight_prediction: 0.0, avg_weight_actual: 805.0, chicken_count: 600, }, { device_id: 'iot-14', device_name: 'Timbangan K-01-W1', total_weight_prediction: 0.0, total_weight_actual: 320000.0, avg_weight_prediction: 0.0, avg_weight_actual: 800.0, chicken_count: 400, }, ], }; } }, /** * Gets scale prediction data for a date range. * Returns detailed prediction items grouped by date. */ getScalePredictionsRange: async ( startDate: string, endDate: string, mode: 'LIVE' | 'DEMO' ): Promise => { if (mode === 'LIVE') { const response = await fetch( `${API_BASE_URL}/api/scales/predictions/range?start_date=${startDate}&end_date=${endDate}` ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } else { // Return mock data for DEMO mode return { success: true, data: [], }; } }, // --- Database API Functions --- /** * Fetches all cycles from database */ getCycles: async () => { const response = await fetch(`${DB_API_BASE_URL}/cycles`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) { return json.data; } throw new Error('Invalid data structure from API'); }, /** * Fetches active cycle from database */ getActiveCycle: async () => { const response = await fetch(`${DB_API_BASE_URL}/cycles/active`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) { return json.data; } throw new Error('Invalid data structure from API'); }, /** * Fetches a specific cycle by ID from database */ getCycleById: async (id: string) => { const response = await fetch(`${DB_API_BASE_URL}/cycles/${id}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) { return json.data; } throw new Error('Invalid data structure from API'); }, /** * Updates a cycle in database */ updateCycle: async (id: string, cycleData: any) => { const response = await fetch(`${DB_API_BASE_URL}/cycles/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(cycleData), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) { return json.data; } throw new Error('Invalid data structure from API'); }, /** * Creates a new cycle in database */ createCycle: async (cycleData: { id: string; totalDays: number; currentDay: number; startDate: string; endDate?: string; chickInWeight?: number | null; docInCount?: number | null; status: string; }) => { const response = await fetch(`${DB_API_BASE_URL}/cycles`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(cycleData), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) { return json.data; } throw new Error('Invalid data structure from API'); }, /** * Fetches all mortality records for a cycle from database (optionally filtered by kandang) */ getMortalityRecords: async (cycleId: string, kandangId?: number) => { const url = kandangId ? `${DB_API_BASE_URL}/mortality/${cycleId}?kandangId=${kandangId}` : `${DB_API_BASE_URL}/mortality/${cycleId}`; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) { return json.data; } throw new Error('Invalid data structure from API'); }, /** * Fetches aggregated mortality totals across all kandangs for a cycle */ getMortalityTotals: async (cycleId: string) => { const response = await fetch(`${DB_API_BASE_URL}/mortality/${cycleId}/totals`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) { return json.data; } throw new Error('Invalid data structure from API'); }, /** * Fetches a specific mortality record for a cycle and day from database */ getMortalityRecord: async (cycleId: string, day: number) => { const response = await fetch(`${DB_API_BASE_URL}/mortality/${cycleId}/${day}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) { return json.data; } throw new Error('Invalid data structure from API'); }, /** * Updates a mortality record in database (with optional kandangId, panen, keterangan, and afkir) */ updateMortalityRecord: async ( cycleId: string, day: number, mortalityCount: number, chickenCount?: number, kandangId?: number, panen?: number, keterangan?: string, afkir?: number, beratPanen?: number ) => { const response = await fetch(`${DB_API_BASE_URL}/mortality/${cycleId}/${day}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ mortalityCount, chickenCount, kandangId, panen: panen || 0, keterangan: keterangan || null, afkir: afkir || 0, beratPanen: Number.isFinite(beratPanen) ? beratPanen : 0, }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) { return json.data; } throw new Error('Invalid data structure from API'); }, /** * Deletes a mortality record from database (resets to default) */ deleteMortalityRecord: async (cycleId: string, day: number) => { const response = await fetch(`${DB_API_BASE_URL}/mortality/${cycleId}/${day}`, { method: 'DELETE', }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success) { return json; } throw new Error('Invalid data structure from API'); }, // --- Chicken Counting API Functions --- getChickenCounting: async (startDate: string, endDate: string, kandangId?: number) => { let url = `${DB_API_BASE_URL}/chicken-counting?startDate=${startDate}&endDate=${endDate}`; if (kandangId) { url += `&kandangId=${kandangId}`; } const response = await fetch(url); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) return json.data; throw new Error('Invalid data structure from API'); }, submitChickenCounting: async ( date: string, kandang: string, totalCount: number, isManual: boolean = false ) => { const apiKey = import.meta.env.VITE_CHICKEN_COUNTING_API_KEY || ''; const url = isManual ? `${DB_API_BASE_URL}/chicken-counting?source=manual` : `${DB_API_BASE_URL}/chicken-counting`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey, }, body: JSON.stringify({ date, kandang, total_count: totalCount }), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) return json.data; throw new Error('Invalid data structure from API'); }, // --- Kandang API Functions --- getKandangs: async () => { const response = await fetch(`${DB_API_BASE_URL}/kandangs`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) return json.data; throw new Error('Invalid data structure from API'); }, getKandangsByCycle: async (cycleId: string) => { const response = await fetch(`${DB_API_BASE_URL}/kandangs/by-cycle/${cycleId}`, { headers: { 'Cache-Control': 'no-cache' }, }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) return json.data; throw new Error('Invalid data structure from API'); }, createKandang: async (name: string) => { const response = await fetch(`${DB_API_BASE_URL}/kandangs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) return json.data; throw new Error('Invalid data structure from API'); }, updateKandang: async (id: number, name: string) => { const response = await fetch(`${DB_API_BASE_URL}/kandangs/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) return json.data; throw new Error('Invalid data structure from API'); }, deleteKandang: async (id: number) => { const response = await fetch(`${DB_API_BASE_URL}/kandangs/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success) return json; throw new Error('Invalid data structure from API'); }, setKandangDocInCount: async (kandangId: number, cycleId: string, docInCount: number) => { const response = await fetch(`${DB_API_BASE_URL}/kandangs/${kandangId}/cycle/${cycleId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ docInCount }), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success) return json; throw new Error('Invalid data structure from API'); }, updateKandangCageUuid: async (kandangId: number, cageUuid: string | null) => { const response = await fetch(`${DB_API_BASE_URL}/kandangs/${kandangId}/cage-uuid`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cageUuid }), }); if (!response.ok) { const json = await response.json(); throw new Error(json.error || `HTTP error! status: ${response.status}`); } const json = await response.json(); if (json.success && json.data) return json.data; throw new Error('Invalid data structure from API'); }, // --- Manual Feed Sack API Functions --- getManualFeedSackEntries: async (startDate: string, endDate: string) => { const response = await fetch( `${DB_API_BASE_URL}/manual-feed-sack?startDate=${startDate}&endDate=${endDate}` ); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) return json.data; throw new Error('Invalid data structure from API'); }, saveManualFeedSackEntry: async ( date: string, cameraName: string, count: number, keterangan?: string, createdBy?: string ) => { const response = await fetch(`${DB_API_BASE_URL}/manual-feed-sack`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date, cameraName, count, keterangan: keterangan || null, createdBy, }), }); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (json.success && json.data) return json.data; throw new Error('Failed to save manual entry'); }, deleteManualFeedSackEntry: async (date: string, cameraName: string) => { const response = await fetch( `${DB_API_BASE_URL}/manual-feed-sack/${date}/${encodeURIComponent(cameraName)}`, { method: 'DELETE' } ); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const json = await response.json(); if (!json.success) throw new Error('Failed to delete manual entry'); }, /** * Clears weight distribution cache from localStorage */ clearWeightDistributionCache: () => { clearWeightDistributionCache(); }, // --- Audit Log API Functions --- /** * Get audit logs with optional filtering * @param params - Query parameters for filtering * @returns Promise<{success: boolean, data: any[], pagination: any}> */ getAuditLogs: async (params?: { table?: string; recordId?: string; startDate?: string; endDate?: string; limit?: number; offset?: number; }) => { const queryParams = new URLSearchParams(); if (params?.table) queryParams.append('table', params.table); if (params?.recordId) queryParams.append('recordId', params.recordId); if (params?.startDate) queryParams.append('startDate', params.startDate); if (params?.endDate) queryParams.append('endDate', params.endDate); if (params?.limit) queryParams.append('limit', params.limit.toString()); if (params?.offset) queryParams.append('offset', params.offset.toString()); const response = await fetch(`${DB_API_BASE_URL}/audit-logs?${queryParams.toString()}`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return await response.json(); }, /** * Get count of audit logs for a table * @param table - Table name * @returns Promise<{success: boolean, count: number}> */ getAuditLogsCount: async (table: string) => { const response = await fetch(`${DB_API_BASE_URL}/audit-logs/count?table=${table}`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return await response.json(); }, // --- Dashboard daily report snapshots --- getDashboardReportSnapshot: async ( cycleId: string, kandangId: number, reportType: DashboardReportPayload['reportType'] = 'daily' ): Promise => { const response = await fetch( `${DB_API_BASE_URL}/dashboard-report-snapshots/${cycleId}?kandangId=${kandangId}&reportType=${reportType || 'daily'}` ); if (response.status === 404) { return { success: false, data: null, message: 'No snapshot found' }; } if (!response.ok) { await throwApiError(response); } return await response.json(); }, upsertDashboardReportSnapshot: async (input: { cycleId: string; kandangId: number; upToDay: number; payload: DashboardReportPayload; generatedAt?: string; reportType?: DashboardReportPayload['reportType']; }): Promise => { const response = await fetch(`${DB_API_BASE_URL}/dashboard-report-snapshots`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (!response.ok) { await throwApiError(response); } return await response.json(); }, // --- AI Insights API Functions --- /** * Get AI insight from database cache * @param cycleId - Cycle ID * @param kandangId - Kandang ID (optional, null means all kandangs) * @returns Promise<{success: boolean, data?: any, message?: string}> */ /** * Reads a STORED insight. Never generates one. * * `reportType`/`reportPeriod` scope the lookup the same way the generate * endpoint stores it — without them a card asking for "Hari 12" gets whatever * report happens to be newest for the cycle. */ getAiInsight: async ( cycleId: string, kandangId: string | null = null, reportType: 'daily' | 'weekly' | 'end_cycle' | null = null, reportPeriod: string | null = null ) => { const params = new URLSearchParams(); if (kandangId) params.set('kandangId', kandangId); if (reportType) params.set('reportType', reportType); if (reportPeriod) params.set('reportPeriod', reportPeriod); const queryParams = params.toString() ? `?${params.toString()}` : ''; const response = await fetch(`${DB_API_BASE_URL}/ai-insights/${cycleId}${queryParams}`); if (response.status === 404) { // Not found is expected when no cached insight exists return { success: false, message: 'No cached insight found' }; } if (!response.ok) { await throwApiError(response); } return await response.json(); }, /** * Save AI insight to database cache * @param cycleId - Cycle ID * @param kandangId - Kandang ID (optional, null means all kandangs) * @param insightText - Generated insight text * @param version - Version string (for cache invalidation) * @returns Promise<{success: boolean, data: any}> */ saveAiInsight: async ( cycleId: string, kandangId: string | null, insightText: string, version: string = 'v1' ) => { const response = await fetch(`${DB_API_BASE_URL}/ai-insights`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cycleId, kandangId, insightText, version, }), }); if (!response.ok) { await throwApiError(response); } return await response.json(); }, /** * Get or generate dashboard AI insight via backend cache-first endpoint * @param cycleId - Cycle ID * @param kandangId - Kandang ID (optional, null means all kandangs) * @param contextPack - Sanitized dashboard context for AI model * @param version - Version string (for cache invalidation) * @param forceRefresh - When true, bypasses cache and regenerates * @returns Promise<{success: boolean, source: string, data: any}> */ getOrGenerateDashboardInsight: async ( cycleId: string, kandangId: string | null, contextPack: unknown, version: string = 'v1', forceRefresh = false, reportType: 'daily' | 'weekly' | 'end_cycle' = 'daily', reportPeriod: string | null = null ) => { const response = await fetch(`${DB_API_BASE_URL}/ai-insights/dashboard`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cycleId, kandangId, contextPack, version, forceRefresh, reportType, reportPeriod, }), }); if (!response.ok) { await throwApiError(response); } return await response.json(); }, /** * Proxies a chat prompt to the local LM Studio server via the backend proxy. * The backend automatically enriches the system_prompt with relevant CP707 book * chunks retrieved from the RAG service (ChromaDB). * @param input - The user prompt (contextData as JSON text) * @param system_prompt - The system prompt * @param temperature - Generation temperature (optional, defaults to 0.2) * @param topic - Topic slug for RAG hint: 'berat_ayam' | 'fcr' | 'iot_panel' | 'eef' | 'hitung_ayam' | 'hitung_karung' * @param context_query - Short query text for RAG semantic search (optional, falls back to first 300 chars of input) * @returns Promise */ proxyLmStudio: async ( input: string, system_prompt: string, temperature = 0.2, topic = '', context_query = '', contextData: Record | null = null, /** * Teks insight yang sedang tampil, dikirim HANYA ketika operator menekan * generate lagi untuk periode data yang sama. Jalur proxy tidak punya * cache, jadi backend tidak bisa tahu sendiri ini permintaan ulang — * kehadiran field inilah penandanya. Backend memakainya untuk meminta * sudut pandang dan tindakan yang berbeda, dengan angka yang tetap sama. */ previousInsight: string | null = null ) => { const response = await fetch(`${DB_API_BASE_URL}/ai-insights/proxy`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input, system_prompt, temperature, topic, context_query, contextData, previous_insight: previousInsight, }), }); if (!response.ok) { await throwApiError(response); } return await response.json(); }, };