Files
dashboard/backend/routes/aiInsights.js
T
Alberto-Audrix 32a36cceff
CI / lint-and-test (push) Canceled after 0s
first commit
2026-07-28 08:55:05 +07:00

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;