const express = require('express'); const router = express.Router(); const AiInsight = require('../models/AiInsight.js'); const { generateDashboardInsight, INSIGHT_VERSION, } = require('../services/dashboardInsightService.js'); // GET /api/ai-insights/:cycleId // Optional query params: kandangId router.get('/:cycleId', async (req, res) => { try { const { cycleId } = req.params; const { kandangId } = req.query; // Find the most recent non-expired insight const insight = await AiInsight.getByCycleAndKandang(cycleId, kandangId || null); if (!insight) { return res.status(404).json({ success: false, message: 'No insight found or insight expired', }); } res.json({ success: true, data: insight, }); } catch (error) { console.error('Error fetching AI insight:', error); res.status(500).json({ success: false, error: error.message, }); } }); // POST /api/ai-insights // Body: { cycleId, kandangId, insightText, version } router.post('/', async (req, res) => { try { const { cycleId, kandangId, insightText, version = 'v1' } = req.body; if (!cycleId || !insightText) { return res.status(400).json({ success: false, error: 'cycleId and insightText are required', }); } // Upsert (create or update) const insight = await AiInsight.upsert({ cycleId, kandangId: kandangId || null, insightText, version, }); res.json({ success: true, data: insight, }); } catch (error) { console.error('Error saving AI insight:', error); res.status(500).json({ success: false, error: error.message, }); } }); // DELETE /api/ai-insights/cleanup // Cleanup expired insights (can be called by a cron job) router.delete('/cleanup', async (req, res) => { try { const deleted = await AiInsight.cleanupExpired(); res.json({ success: true, deleted, }); } catch (error) { console.error('Error cleaning up insights:', error); res.status(500).json({ success: false, error: error.message, }); } }); // POST /api/ai-insights/dashboard // Body: { cycleId, kandangId, contextPack, version, forceRefresh } router.post('/dashboard', async (req, res) => { try { const { cycleId, kandangId = null, contextPack, version = INSIGHT_VERSION, forceRefresh = false, } = req.body; if (!cycleId || !contextPack) { return res.status(400).json({ success: false, error: 'cycleId and contextPack are required', }); } const result = await generateDashboardInsight({ cycleId, kandangId: kandangId || null, contextPack, version, forceRefresh: Boolean(forceRefresh), }); res.json({ success: true, source: result.source, data: result.insight, }); } catch (error) { console.error('Error generating AI dashboard insight:', error); res.status(500).json({ success: false, error: error.message, }); } }); // ─── RAG: Fetch relevant CP707 chunks from Python RAG service ──────────────── const RAG_SERVICE_URL = process.env.RAG_SERVICE_URL || 'http://localhost:5002'; async function fetchCp707Chunks(query, topic, nResults = 4) { try { const response = await fetch(`${RAG_SERVICE_URL}/query`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, topic, n_results: nResults }), signal: AbortSignal.timeout(5000) // max 5 detik untuk RAG query }); if (!response.ok) return []; const json = await response.json(); return json.chunks || []; } catch (err) { // RAG service tidak wajib — graceful fallback jika tidak aktif console.warn('[RAG] Service tidak tersedia, lanjut tanpa RAG context:', err.message); return []; } } // POST /api/ai-insights/proxy // Proxies chat requests to LM Studio, auto-detecting the loaded model router.post('/proxy', async (req, res) => { try { let { input, system_prompt, temperature = 0.2, topic = '', context_query = '', contextData = null } = req.body; let responseText = ''; let success = false; // Format contextData cleanly as JSON if present if (contextData && typeof contextData === 'object') { const dataStr = JSON.stringify(contextData, null, 2); // Append formatted context data to user input if not already present if (!input.includes(dataStr)) { input = `${input}\n\n[Data Halaman (JSON)]:\n${dataStr}`; } } // 1. Ambil chunk CP707 yang relevan dari RAG service const ragQuery = context_query || input.slice(0, 300); // gunakan konteks query atau 300 char pertama dari input const cp707Chunks = await fetchCp707Chunks(ragQuery, topic, 4); // 2. Inject CP707 chunks ke dalam system_prompt let enrichedSystemPrompt = system_prompt || 'You are a helpful assistant.'; if (cp707Chunks.length > 0) { const chunksText = cp707Chunks .map((chunk, i) => `[Referensi CP707 #${i + 1}]\n${chunk}`) .join('\n\n'); enrichedSystemPrompt = `${enrichedSystemPrompt} ═══════════════════════════════════════════════ REFERENSI BUKU: Manajemen Broiler CP 707 (PT Charoen Pokphand Indonesia, Tbk - Edisi Juli 2023) GUNAKAN informasi ini sebagai acuan utama analisis Anda. ═══════════════════════════════════════════════ ${chunksText} ═══════════════════════════════════════════════`; console.log(`[RAG] Injected ${cp707Chunks.length} CP707 chunks into system_prompt`); } let isOllama = false; // 3. Try LM Studio / Ollama try { const lmStudioBaseUrl = process.env.LM_STUDIO_BASE_URL || process.env.OLLAMA_BASE_URL || (process.env.DOCKER_ENV === 'true' ? 'http://host.docker.internal:11434' : 'http://127.0.0.1:11434'); isOllama = lmStudioBaseUrl.includes('11434') || lmStudioBaseUrl.includes('llm'); let modelName = process.env.LLM_MODEL_NAME || 'deepseek-r1:8b'; // Only auto-detect if LLM_MODEL_NAME is not explicitly configured if (!process.env.LLM_MODEL_NAME) { try { const modelsResponse = await fetch(`${lmStudioBaseUrl}/api/v1/models`, { signal: AbortSignal.timeout(2000) }); if (modelsResponse.ok) { const modelsJson = await modelsResponse.json(); if (modelsJson && modelsJson.data && modelsJson.data.length > 0) { modelName = modelsJson.data[0].id; } else if (modelsJson && Array.isArray(modelsJson.models) && modelsJson.models.length > 0) { modelName = modelsJson.models[0].key; } } } catch (err) { console.warn('[LM Studio Proxy] Failed to fetch loaded models:', err.message); } } const endpoint = isOllama ? '/api/chat' : '/v1/chat/completions'; const payload = isOllama ? { model: modelName, messages: [ { role: 'system', content: enrichedSystemPrompt }, { role: 'user', content: input } ], stream: false, options: { num_ctx: 8192, num_predict: 8000, temperature } } : { model: modelName, messages: [ { role: 'system', content: enrichedSystemPrompt }, { role: 'user', content: input } ], temperature, max_tokens: 8000 }; const response = await fetch(`${lmStudioBaseUrl}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(540000) }); if (response.ok) { const json = await response.json(); const msg = isOllama ? json.message : json.choices?.[0]?.message; const finishReason = isOllama ? json.done_reason : json.choices?.[0]?.finish_reason; console.log(`[LM Studio Proxy] finish_reason=${finishReason}, content_len=${msg?.content?.length || 0}`); if (msg?.content && typeof msg.content === 'string' && msg.content.trim()) { responseText = msg.content.trim(); } else if (msg?.reasoning_content && typeof msg.reasoning_content === 'string' && msg.reasoning_content.trim()) { responseText = msg.reasoning_content.trim(); console.log('[LM Studio Proxy] content empty, using reasoning_content as fallback'); } else if (!isOllama && json.content && typeof json.content === 'string') { responseText = json.content; } else if (!isOllama && json.response) { responseText = json.response; } else if (!isOllama && Array.isArray(json.output)) { const parts = json.output .map(item => (typeof item.content === 'string' ? item.content : '')) .filter(Boolean); responseText = parts.join('\n').trim(); } success = !!responseText; } else { const errBody = await response.text().catch(() => ''); console.warn(`[LM Studio Proxy] Returned status ${response.status}: ${errBody.slice(0, 200)}`); } } catch (lmStudioError) { console.error('[LM Studio Proxy] Failed/not running:', lmStudioError.message); } if (!success) { const errMsg = isOllama ? 'Gagal menghubungi Ollama lokal. Pastikan Ollama aktif di port 11434.' : 'Gagal menghubungi LM Studio lokal. Pastikan server LM Studio aktif di port 1234.'; throw new Error(errMsg); } const cleanedResponseText = responseText .replace(/[\s\S]*?<\/think>/g, '') .replace(/```json\s*/gi, '') .replace(/```\s*/g, '') .trim(); res.json({ success: true, response: cleanedResponseText }); } catch (error) { console.error('Error in proxy route:', error); res.status(500).json({ success: false, error: error.message }); } }); module.exports = router;