497 lines
19 KiB
JavaScript
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;
|