Files
dashboard/backend/routes/aiInsights.js
T

497 lines
19 KiB
JavaScript

const express = require('express');
const router = express.Router();
const AiInsight = require('../models/AiInsight.js');
const {
generateDashboardInsight,
INSIGHT_VERSION,
} = require('../services/dashboardInsightService.js');
const cp707 = require('../services/cp707Knowledge.js');
const { llmDispatcher, llmFetch } = require('../services/llmDispatcher.js');
// GET /api/ai-insights/:cycleId
// Optional query params: kandangId, reportType, reportPeriod
router.get('/:cycleId', async (req, res) => {
try {
const { cycleId } = req.params;
const { kandangId, reportType = 'daily', reportPeriod = null } = req.query;
// Find the most recent non-expired insight for this report scope
const insight = await AiInsight.getByCycleAndKandang(
cycleId,
kandangId || null,
reportType,
reportPeriod
);
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, reportType, reportPeriod }
router.post('/', async (req, res) => {
try {
const {
cycleId,
kandangId,
insightText,
version = 'v1',
reportType = 'daily',
reportPeriod = null,
} = 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,
reportType,
reportPeriod,
});
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, reportType, reportPeriod }
router.post('/dashboard', async (req, res) => {
try {
const {
cycleId,
kandangId = null,
contextPack,
version = INSIGHT_VERSION,
forceRefresh = false,
reportType = 'daily',
reportPeriod = null,
} = 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),
reportType,
reportPeriod,
});
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 [];
}
}
/**
* Strip headerless numeric table rows out of a RAG chunk, keeping its prose.
*
* The book's tables survive extraction with their column headers severed:
*
* CR
* 1 | 195 | 34 | 164,5 | 0,844
* 2 | 499 | 50 | 530,5 | 1,063
*
* Nothing there says which column is which, so any figure can be attached to
* any metric — an EEF insight quoted "standar CP 707 maksimal 164,5", which is
* the Feed-Used-CUM column in grams, while the book's IP standard is 327-380.
*
* Chunks usually mix such a table with genuinely useful prose, so dropping the
* whole chunk would throw away guidance. Only the ambiguous rows go; the
* authoritative figures come from buildCp707StandardBlock() instead.
*/
function stripHeaderlessTables(chunk) {
const lines = String(chunk || '').split('\n');
let removed = 0;
const kept = lines.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
// A row of pipe-separated numbers and nothing else.
const isNumericRow = /^[\d.,]+(\s*\|\s*[\d.,]*)+$/.test(trimmed);
if (isNumericRow) removed += 1;
return !isNumericRow;
});
if (removed === 0) return { text: chunk, removed: 0 };
return {
text:
kept.join('\n').trim() +
'\n[Tabel angka tanpa judul kolom dihapus dari kutipan ini karena tidak dapat ' +
'dibaca dengan benar. Gunakan blok STANDAR CP 707 di atas untuk angka standar.]',
removed,
};
}
/**
* Build an explicit, unit-labelled CP 707 standard block for the day in view.
*
* The RAG chunks carry the book's tables as pipe-separated text with the column
* headers severed from the values, so a number can be attached to any metric:
* an EEF insight quoted "standar CP 707 maksimal 164,5", which is the
* "Feed Used CUM" column in GRAMS, while the book's actual IP standard for that
* table is 327-380. Handing the model the right figures, named and with units,
* removes the guess that produced that.
*/
function buildCp707StandardBlock(contextData) {
const raw =
contextData?.hari_ke ??
contextData?.hari_terakhir ??
contextData?.currentDay ??
contextData?.dayAge;
const day = Number(raw);
if (!Number.isFinite(day) || day <= 0) return '';
const ip = cp707.getIpStandardByDay(day);
const ipRange = cp707.getIpStandardRange();
const bw = cp707.getBwStandardByDay(day);
const fcr = cp707.getFcrStandardByDay(day);
const mort = cp707.getMortalityCumStdByDay(day);
const lines = [
`- Bobot badan standar hari ke-${day}: ${bw !== null ? bw + ' gram' : 'tidak tercantum di buku'}`,
`- FCR standar hari ke-${day}: ${fcr !== null ? fcr + ' (rasio, tanpa satuan)' : 'tidak tercantum di buku'}`,
`- Mortalitas kumulatif standar hari ke-${day}: ${mort !== null ? mort + ' %' : 'tidak tercantum di buku'}`,
mort !== null
? `- Persen hidup standar hari ke-${day}: ${Math.round((100 - mort) * 100) / 100} % (turunan langsung dari mortalitas standar di atas — pakai angka ini, jangan menghitung sendiri)`
: `- Persen hidup standar hari ke-${day}: tidak tersedia`,
ip !== null
? `- IP/EEF standar hari ke-${day}: ${ip} (indeks, TANPA satuan)`
: `- IP/EEF standar hari ke-${day}: TIDAK tercantum di buku. Kolom IP pada Lampiran 2 hanya terisi hari ke-7 s/d ke-${ipRange ? ipRange.lastDay : 37} dengan rentang ${ipRange ? ipRange.min + '-' + ipRange.max : '327-380'}. Jangan mengarang standar untuk hari ini.`,
];
return `
═══════════════════════════════════════════════
STANDAR CP 707 UNTUK HARI INI (angka resmi dari Lampiran 2 buku)
${lines.join('\n')}
ATURAN WAJIB saat membandingkan dengan standar:
- Pakai HANYA angka di blok ini sebagai standar. DILARANG mengambil angka
standar dari tabel mentah di kutipan buku di bawah — kolomnya tidak berjudul,
dan angka pakan (gram) sering tertukar menjadi standar IP/EEF.
- Sebutkan satuan dengan benar: gram untuk bobot dan pakan, persen untuk
mortalitas, dan IP/EEF adalah indeks TANPA satuan.
- Jika standar untuk suatu metrik tidak tercantum, tulis "standar tidak
tersedia di buku untuk hari ini" — jangan mengganti dengan angka lain.
- Semua angka aktual harus berasal dari [Data Halaman (JSON)]. Dilarang
menghitung sendiri atau mengarang angka yang tidak ada di sana.
═══════════════════════════════════════════════`;
}
// 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 rawChunks = await fetchCp707Chunks(ragQuery, topic, 4);
let strippedRows = 0;
const cp707Chunks = rawChunks.map((c) => {
const { text, removed } = stripHeaderlessTables(c);
strippedRows += removed;
return text;
});
if (strippedRows > 0) {
console.log(
`[RAG] Stripped ${strippedRows} headerless table row(s); standards come from the structured block instead`
);
}
// 2. Inject CP707 chunks ke dalam system_prompt
let enrichedSystemPrompt = system_prompt || 'You are a helpful assistant.';
// Structured book standards FIRST, so they outrank anything the RAG chunks
// below may appear to say.
const standardBlock = buildCp707StandardBlock(contextData);
if (standardBlock) enrichedSystemPrompt = `${enrichedSystemPrompt}\n${standardBlock}`;
// Same anti-fabrication contract the dashboard path already enforces.
enrichedSystemPrompt = `${enrichedSystemPrompt}
ATURAN MORTALITAS (WAJIB DIPATUHI — TIDAK BOLEH DILANGGAR):
- Standar CP 707: mortalitas kumulatif NORMAL adalah < 5%.
- Mortalitas >= 5% dan <= 7% = TINGGI (warning) — WAJIB disebut "TINGGI", bukan "rendah" atau "normal".
- Mortalitas > 7% = SANGAT TINGGI (critical) — WAJIB disebut "SANGAT TINGGI".
- Mortalitas < 5% = rendah/normal.
- DILARANG KERAS menyebut mortalitas sebagai "rendah" atau "normal" jika nilainya >= 5%.
ATURAN ANGKA (WAJIB DIPATUHI):
- Setiap angka yang Anda tulis HARUS ada di [Data Halaman (JSON)] atau di blok
STANDAR CP 707. DILARANG menghitung sendiri, memperkirakan, atau menurunkan
angka baru dari angka lain.
- SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD: _gram, _persen, _rasio,
_karung, _ekor, _kg, _hari, _indeksTanpaSatuan. Pakai satuan itu persis dan
DILARANG menukarnya. Field berakhiran _rasio dan _indeksTanpaSatuan tidak
punya satuan — jangan menambahkan "%" atau "gram" di belakangnya.
- Jangan menyamakan metrik yang berbeda. "persenHidup_persen" adalah persentase
AYAM HIDUP, bukan mortalitas; jangan mengurangkannya dari 100 lalu
menyebutnya standar.
- Field yang berisi "tidak tersedia" memang tidak ada datanya. Tulis
"data tidak tersedia" untuk field itu dan JANGAN mengisinya dengan angka.
- Jika sebuah metrik tidak punya data sama sekali, katakan demikian. Jangan
menyimpulkan kondisi baik hanya karena tidak ada angka yang buruk.
- Perhatikan arah tren: FCR yang MENURUN berarti efisiensi MEMBAIK; bobot yang
menurun berarti performa memburuk. Periksa ulang arahnya sebelum menyimpulkan.`;
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 || 'qwen2.5:7b';
// 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: {
// Kept identical to the dashboard path on purpose: Ollama reloads
// the model whenever num_ctx changes, so two different values on
// the same model would pay a reload on every alternating request.
num_ctx: 16384,
// Measured completions on this route run 106-771 tokens. 8000 was
// unreachable anyway and only served to let a runaway model burn
// the full timeout.
num_predict: 1536,
temperature,
},
}
: {
model: modelName,
messages: [
{ role: 'system', content: enrichedSystemPrompt },
{ role: 'user', content: input },
],
temperature,
max_tokens: 8000,
};
const response = await llmFetch(`${lmStudioBaseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
// Same reason as the dashboard path: undici's 300 s headersTimeout
// fires long before this signal unless the dispatcher raises it.
dispatcher: llmDispatcher,
signal: AbortSignal.timeout(1800000),
});
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;
// prompt_eval_count is the only honest measure of how much of num_ctx
// the prompt eats — everything else here is a guess from char counts.
// If it approaches num_ctx, Ollama has silently dropped the oldest part
// of the prompt and the model answered without seeing all the data.
console.log(
`[LM Studio Proxy] finish_reason=${finishReason}, content_len=${msg?.content?.length || 0}, prompt_tokens=${json.prompt_eval_count ?? '?'}, completion_tokens=${json.eval_count ?? '?'}`
);
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;