300 lines
10 KiB
JavaScript
300 lines
10 KiB
JavaScript
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(/<think>[\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;
|