ai insight rework #1
This commit is contained in:
1 parent
0d960c8153
commit
9d76f54d33
13 files changed
+730
-299
No files matched your search
@@ -16,7 +16,12 @@ def classify_chunk_tipe(text: str) -> str:
|
||||
lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
|
||||
if not lines:
|
||||
return "prosa"
|
||||
numeric = sum(1 for ln in lines if _NUMERIC_PIPE_ROW.match(ln))
|
||||
numeric = sum(
|
||||
1
|
||||
for ln in lines
|
||||
if _NUMERIC_PIPE_ROW.match(ln)
|
||||
or (ln.startswith("|") and ln.endswith("|") and ln.count("|") > 1)
|
||||
)
|
||||
if numeric >= max(2, len(lines) // 2):
|
||||
return "tabel"
|
||||
return "prosa"
|
||||
|
||||
@@ -27,18 +27,25 @@ def extract_docx(file_path):
|
||||
full_text.append(para.text)
|
||||
elif tag.endswith('tbl'):
|
||||
table = Table(element, doc)
|
||||
table_text = []
|
||||
table_rows = []
|
||||
for row in table.rows:
|
||||
row_text = []
|
||||
for cell in row.cells:
|
||||
text = cell.text.strip()
|
||||
text = cell.text.strip().replace("|", "/").replace("\n", " ")
|
||||
# Hindari duplikasi text sel gabungan (merged cells) secara berturut-turut
|
||||
if not row_text or row_text[-1] != text:
|
||||
row_text.append(text)
|
||||
if row_text:
|
||||
table_text.append(" | ".join(row_text))
|
||||
if table_text:
|
||||
full_text.append("\n".join(table_text))
|
||||
table_rows.append(row_text)
|
||||
if table_rows:
|
||||
# Plan Stage 2: GFM markdown table, header row preserved for RAG.
|
||||
header = table_rows[0]
|
||||
md_lines = [
|
||||
"| " + " | ".join(header) + " |",
|
||||
"| " + " | ".join(["---"] * len(header)) + " |",
|
||||
]
|
||||
md_lines += ["| " + " | ".join(r) + " |" for r in table_rows[1:]]
|
||||
full_text.append("\n" + "\n".join(md_lines) + "\n")
|
||||
|
||||
return "\n\n".join(full_text)
|
||||
|
||||
|
||||
@@ -69,3 +69,8 @@ RAG_SERVICE_URL=http://127.0.0.1:5002
|
||||
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
||||
LLM_MODEL_NAME=qwen2.5:3b
|
||||
LLM_TIMEOUT_SECONDS=1200
|
||||
# Plan §6 CPU inference tuning (Profile A 8GB: qwen2.5:1.5b; Profile B 16GB: qwen2.5:3b)
|
||||
LLM_NUM_CTX=2048
|
||||
LLM_NUM_THREAD=4
|
||||
LLM_SEED=42
|
||||
LLM_TEMPERATURE=0.2
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Drop orphan schema left by deleted migration 0019_aiinsight_structured_data_and_task.
|
||||
|
||||
0019 was applied to production Postgres (added ai_insight.structured_data NOT NULL +
|
||||
ai_insight_task table) but its file and the corresponding model fields were after-
|
||||
wards removed from the codebase. Django builds schema state from migration files,
|
||||
so INSERTs omit `structured_data` -> every AIInsight save fails with
|
||||
"null value in column structured_data violates not-null constraint".
|
||||
|
||||
Postgres-only: SQLite dev databases never had 0019 applied. Reverse is a no-op.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def drop_orphan_schema(apps, schema_editor):
|
||||
if schema_editor.connection.vendor != "postgresql":
|
||||
return
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute("ALTER TABLE ai_insight DROP COLUMN IF EXISTS structured_data")
|
||||
cursor.execute("DROP TABLE IF EXISTS ai_insight_task")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("operations", "0018_remove_aiinsight_source"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(drop_orphan_schema, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Phase 4 async job runner for POST /ai-insights/generate/ (202 + polling).
|
||||
|
||||
Jobs persist as one JSON file each under `logs/insight_jobs/` so any gunicorn
|
||||
worker can answer the poll (ponytail: single-host only — move to Redis/DB queue
|
||||
if API ever spans multiple machines). Only the owning worker writes a job file,
|
||||
so no cross-process locking is needed; writes are atomic (tmp + os.replace).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_JOBS_DIR = Path(settings.BASE_DIR) / "logs" / "insight_jobs"
|
||||
_MAX_AGE_SECONDS = 24 * 3600
|
||||
|
||||
|
||||
def _job_path(job_id: str) -> Path:
|
||||
return _JOBS_DIR / f"{job_id}.json"
|
||||
|
||||
|
||||
def _write(job_id: str, job: dict[str, Any]) -> None:
|
||||
tmp = _job_path(job_id).with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(job, ensure_ascii=False, default=str), encoding="utf-8")
|
||||
os.replace(tmp, _job_path(job_id))
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
"""Drop job files older than 24h so the dir stays bounded."""
|
||||
try:
|
||||
cutoff = time.time() - _MAX_AGE_SECONDS
|
||||
for f in _JOBS_DIR.glob("*.json"):
|
||||
if f.stat().st_mtime < cutoff:
|
||||
f.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
logger.debug("insight_jobs prune failed", exc_info=True)
|
||||
|
||||
|
||||
def create_job(params: dict[str, Any]) -> str:
|
||||
"""Persist job record and start worker thread. Returns job_id for polling."""
|
||||
job_id = str(uuid.uuid4())
|
||||
_JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_prune()
|
||||
_write(
|
||||
job_id,
|
||||
{
|
||||
"id": job_id,
|
||||
"status": "queued",
|
||||
"stage": "queued",
|
||||
"result": None,
|
||||
"error": None,
|
||||
},
|
||||
)
|
||||
threading.Thread(target=_run, args=(job_id, params), daemon=True).start()
|
||||
return job_id
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict[str, Any] | None:
|
||||
"""Read job from shared dir — works from any worker process."""
|
||||
try:
|
||||
raw = _job_path(job_id).read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError:
|
||||
logger.warning("insight_jobs read failed for %s", job_id, exc_info=True)
|
||||
return None
|
||||
try:
|
||||
job = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return job if isinstance(job, dict) else None
|
||||
|
||||
|
||||
def _set(job_id: str, **fields: Any) -> None:
|
||||
job = get_job(job_id)
|
||||
if job is None:
|
||||
return
|
||||
job.update(fields)
|
||||
try:
|
||||
_write(job_id, job)
|
||||
except OSError:
|
||||
logger.warning("insight_jobs write failed for %s", job_id, exc_info=True)
|
||||
|
||||
|
||||
def _run(job_id: str, params: dict[str, Any]) -> None:
|
||||
from apps.operations.services.insight_service import generate_insight
|
||||
|
||||
def stage_cb(stage: str) -> None:
|
||||
_set(job_id, status="running", stage=stage)
|
||||
|
||||
_set(job_id, status="running", stage="starting")
|
||||
try:
|
||||
result = generate_insight(**params, stage_cb=stage_cb)
|
||||
except Exception as exc: # noqa: BLE001 - surfaced to client via job status
|
||||
logger.exception("insight job %s failed", job_id)
|
||||
_set(job_id, status="error", stage="error", error=str(exc))
|
||||
return
|
||||
_set(job_id, status="done", stage="done", result=result)
|
||||
@@ -10,7 +10,11 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
@@ -23,6 +27,9 @@ from apps.operations.services import root_cause
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Plan §5: one LLM inference at a time (CPU server; prevents thread thrash).
|
||||
_INFERENCE_LOCK = threading.Lock()
|
||||
|
||||
_NUMERIC_PIPE_ROW = re.compile(r"^[\d.,]+(\s*\|\s*[\d.,]*)+$")
|
||||
|
||||
# Stored in AIInsight.alert — condition of graded data, not narrative text.
|
||||
@@ -434,7 +441,7 @@ def fetch_rag_chunks(query: str, topic: str, n_results: int = 4) -> tuple[list[s
|
||||
return [], []
|
||||
|
||||
|
||||
def call_ollama(system_prompt: str, user_prompt: str) -> str | None:
|
||||
def call_ollama(system_prompt: str, user_prompt: str) -> tuple[str | None, dict[str, Any]]:
|
||||
model = getattr(settings, "LLM_MODEL_NAME", "qwen2.5:3b") or "qwen2.5:3b"
|
||||
base = getattr(settings, "OLLAMA_BASE_URL", "http://127.0.0.1:11434") or "http://127.0.0.1:11434"
|
||||
url = f"{base.rstrip('/')}/api/chat"
|
||||
@@ -442,7 +449,15 @@ def call_ollama(system_prompt: str, user_prompt: str) -> str | None:
|
||||
payload = {
|
||||
"model": model,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.2},
|
||||
# format:"json" (grammar) keeps the model from rambling in prose; the schema
|
||||
# reminder appended AFTER the page-data JSON keeps it from copying that JSON.
|
||||
"format": "json",
|
||||
"options": {
|
||||
"temperature": float(getattr(settings, "LLM_TEMPERATURE", 0.2) or 0.2),
|
||||
"seed": int(getattr(settings, "LLM_SEED", 42) or 42),
|
||||
"num_ctx": int(getattr(settings, "LLM_NUM_CTX", 2048) or 2048),
|
||||
"num_thread": int(getattr(settings, "LLM_NUM_THREAD", 4) or 4),
|
||||
},
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
@@ -450,16 +465,23 @@ def call_ollama(system_prompt: str, user_prompt: str) -> str | None:
|
||||
}
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.post(url, json=payload)
|
||||
# Plan §5: serialize LLM calls so concurrent generates don't thrash CPU.
|
||||
with _INFERENCE_LOCK:
|
||||
resp = client.post(url, json=payload)
|
||||
if resp.status_code >= 400:
|
||||
logger.error("Ollama HTTP %s: %s", resp.status_code, resp.text[:500])
|
||||
return None
|
||||
return None, {}
|
||||
data = resp.json()
|
||||
message = data.get("message") or {}
|
||||
return message.get("content") or data.get("response")
|
||||
content = message.get("content") or data.get("response")
|
||||
usage = {
|
||||
"prompt_tokens": data.get("prompt_eval_count"),
|
||||
"completion_tokens": data.get("eval_count"),
|
||||
}
|
||||
return content, usage
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ollama call failed: %s", exc)
|
||||
return None
|
||||
return None, {}
|
||||
|
||||
|
||||
def parse_llm_json(text: str) -> dict[str, str] | None:
|
||||
@@ -530,23 +552,6 @@ def parse_end_cycle_llm_json(text: str) -> dict[str, Any] | None:
|
||||
return parsed
|
||||
|
||||
|
||||
def local_fallback_insight(graded: dict[str, Any], topic: str) -> dict[str, str]:
|
||||
analyses = graded.get("analyses") or {}
|
||||
lines = []
|
||||
for name, block in analyses.items():
|
||||
if isinstance(block, dict) and block.get("message"):
|
||||
lines.append(f"{name}: {block['message']}")
|
||||
if not lines:
|
||||
return {
|
||||
"kesimpulan": f"Data untuk topik {topic} tidak cukup untuk dianalisis (status unknown).",
|
||||
"insight": "Lengkapi data operasional kandang, lalu generate ulang. Jangan mengarang angka.",
|
||||
}
|
||||
return {
|
||||
"kesimpulan": lines[0],
|
||||
"insight": "\n".join(lines[1:]) if len(lines) > 1 else lines[0],
|
||||
}
|
||||
|
||||
|
||||
_SOFT_MORTALITY_WORD = re.compile(
|
||||
r"\b(rendah|normal|baik|aman|terkendali|sedikit)\b",
|
||||
re.IGNORECASE,
|
||||
@@ -616,6 +621,11 @@ def collapse_duplicate_kandang(text: str) -> str:
|
||||
return _DUP_KANDANG.sub(lambda m: "Kandang" if m.group(0)[0].isupper() else "kandang", text)
|
||||
|
||||
|
||||
def _placeholder_text(value: str) -> bool:
|
||||
"""True when a narrative field is empty / schema placeholder (e.g. '...')."""
|
||||
return len(value.strip().strip(".…<>- \t")) < 20
|
||||
|
||||
|
||||
def sanitize_insight_narrative(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Deterministic cleanup of narrative fields after the LLM."""
|
||||
out = dict(parsed)
|
||||
@@ -736,6 +746,50 @@ def serialize_insight(row: AIInsight) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# Plan Stage 1: graded status -> deterministic RAG keywords (no LLM query rewrite).
|
||||
_DIAGNOSTIC_KEYWORDS = {
|
||||
"mortality": "penanganan mortalitas kumulatif deplesi penyebab kematian brooding",
|
||||
"fcr": "target FCR pakan harian konsumsi pakan efisiensi ventilasi",
|
||||
"bw": "target bobot badan ADG pertumbuhan standar mingguan",
|
||||
"iot": "standar ventilasi suhu kelembapan amonia sekam basah kandang",
|
||||
"environment": "standar ventilasi suhu kelembapan amonia sekam basah kandang",
|
||||
}
|
||||
|
||||
|
||||
def diagnose_rag_keywords(graded: dict[str, Any]) -> str:
|
||||
"""Stage 1: anomaly keywords from graded blocks; '' when everything ok/unknown."""
|
||||
analyses = (graded or {}).get("analyses") or {}
|
||||
clues: list[str] = []
|
||||
for key, block in analyses.items():
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if str(block.get("status") or "").lower() in ("warning", "critical"):
|
||||
hint = _DIAGNOSTIC_KEYWORDS.get(key)
|
||||
if hint and hint not in clues:
|
||||
clues.append(hint)
|
||||
return " ".join(clues)
|
||||
|
||||
|
||||
def _notify(stage_cb: Callable[[str], None] | None, stage: str) -> None:
|
||||
if not stage_cb:
|
||||
return
|
||||
try:
|
||||
stage_cb(stage)
|
||||
except Exception: # noqa: BLE001 - progress UI must never break generation
|
||||
logger.debug("stage_cb(%s) failed", stage, exc_info=True)
|
||||
|
||||
|
||||
def _log_trace(record: dict[str, Any]) -> None:
|
||||
"""Plan Stage 5: append-only audit trail for replay/debug."""
|
||||
try:
|
||||
path = Path(settings.BASE_DIR) / "logs" / "rag_trace.jsonl"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(record, ensure_ascii=False, default=str) + "\n")
|
||||
except Exception: # noqa: BLE001 - logging must never fail the request
|
||||
logger.warning("rag_trace write failed", exc_info=True)
|
||||
|
||||
|
||||
def generate_insight(
|
||||
*,
|
||||
cycle_id: int,
|
||||
@@ -745,6 +799,7 @@ def generate_insight(
|
||||
report_type: str = "page",
|
||||
report_period: str = "current",
|
||||
force_refresh: bool = False,
|
||||
stage_cb: Callable[[str], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not kandang_id:
|
||||
raise ValueError("kandang_id wajib — insight tidak boleh untuk semua kandang")
|
||||
@@ -785,7 +840,11 @@ def generate_insight(
|
||||
ctx.setdefault("cycleId", cycle_id)
|
||||
ctx = prune_context_by_period(ctx, report_type, report_period)
|
||||
|
||||
t_start = time.monotonic()
|
||||
_notify(stage_cb, "grading")
|
||||
graded = grade_context(ctx)
|
||||
t_after_grade = time.monotonic()
|
||||
_notify(stage_cb, "retrieving")
|
||||
day = graded.get("hari_ke")
|
||||
standard_block = cp707.build_cp707_standard_block(ctx) or cp707.build_book_reference_context(day or 1)
|
||||
|
||||
@@ -805,7 +864,10 @@ def generate_insight(
|
||||
)
|
||||
else:
|
||||
rag_query = f"panduan manajemen broiler CP 707 untuk {topic} umur hari ke-{day or '?'}"
|
||||
rag_query = f"{rag_query} {diagnose_rag_keywords(graded)}".strip()
|
||||
chunks, citations = fetch_rag_chunks(rag_query, topic)
|
||||
t_after_rag = time.monotonic()
|
||||
_notify(stage_cb, "synthesizing")
|
||||
|
||||
system_prompt = (
|
||||
"Anda adalah asisten farm broiler on-premise. Tugas Anda HANYA menulis narasi "
|
||||
@@ -829,8 +891,12 @@ def generate_insight(
|
||||
f"\n[NEXT CYCLE ACTIONS]\n"
|
||||
f"{json.dumps(next_actions, ensure_ascii=False, default=str)}\n"
|
||||
)
|
||||
cited_chunks = [
|
||||
f"[cp707#chunk{(citations[i].get('chunk_id') if i < len(citations) else None) or i}] {chunk}"
|
||||
for i, chunk in enumerate(chunks[:4])
|
||||
]
|
||||
if chunks:
|
||||
system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(chunks[:4])
|
||||
system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(cited_chunks)
|
||||
|
||||
user_prompt = build_insight_user_prompt(
|
||||
topic=topic,
|
||||
@@ -839,44 +905,152 @@ def generate_insight(
|
||||
report_period=report_period,
|
||||
context=ctx,
|
||||
)
|
||||
# Page-data JSON ends the user message; small models copy the last JSON they
|
||||
# read. Re-state the required schema AFTER the data so recency wins.
|
||||
# Status/facts go right before it: book chunks (system tail) describe IDEAL
|
||||
# conditions, so without this last the model narrates ideals over actual status.
|
||||
# Skip "unknown" blocks when at least one block has a real status:
|
||||
# no-data lines are padding triggers (e.g. mortality spun into FCR
|
||||
# narrative). All-unknown case keeps them: nothing factual to lean on
|
||||
# and "data tidak tersedia" is then the only correct narration.
|
||||
known_lines = [
|
||||
f"{name} = {block.get('status')}: {block.get('message')}"
|
||||
for name, block in (graded.get("analyses") or {}).items()
|
||||
if isinstance(block, dict)
|
||||
and block.get("message")
|
||||
and block.get("status") != "unknown"
|
||||
]
|
||||
unknown_lines = [
|
||||
f"{name} = {block.get('status')}: {block.get('message')}"
|
||||
for name, block in (graded.get("analyses") or {}).items()
|
||||
if isinstance(block, dict)
|
||||
and block.get("message")
|
||||
and block.get("status") == "unknown"
|
||||
]
|
||||
status_lines = known_lines or unknown_lines
|
||||
actual_alert = alert_from_graded(graded)
|
||||
user_prompt = (user_prompt or "").rstrip() + (
|
||||
"\nKONTEKS: semua data adalah ternak AYAM BROILER (unggas) di kandang — "
|
||||
"BUKAN tanaman/pertanian. 'Panen' = panen ayam. 'Bobot' = gram per ekor "
|
||||
"(bukan per karung). 'ADG' = kenaikan bobot harian ayam."
|
||||
f"\nSTATUS AKTUAL: {actual_alert}. Kesimpulan dan insight HARUS konsisten "
|
||||
"dengan status ini: bila warning/critical, sebut masalahnya dan bandingkan "
|
||||
"dengan angka standar CP 707; dilarang menyebut ideal/sehat/baik/aman "
|
||||
"bila STATUS AKTUAL bukan healthy."
|
||||
+ ("\nFAKTA GRADED:\n- " + "\n- ".join(status_lines) if status_lines else "")
|
||||
# No length req anywhere -> 3B model sometimes stops after 1 sentence
|
||||
# (three EEF rows stored identical 136/127 chars). Demand structure.
|
||||
+ "\nPANJANG WAJIB: insight = narasi 3-6 kalimat berurutan: "
|
||||
"(1) angka aktual vs standar CP 707, (2) penyebab/implikasi, "
|
||||
"(3) tindakan konkret. kesimpulan = 1-3 kalimat. "
|
||||
"Satu kalimat singkat = gagal."
|
||||
# Placeholder <> (not "..."): model has copied schema "..." verbatim
|
||||
# into insight, which stored fine and rendered as an empty page.
|
||||
+ '\nBALAS HANYA JSON persis: {"kesimpulan":"<ringkasan>","insight":"<narasi panjang>"}'
|
||||
+ ' - JANGAN salin teks <> dari contoh; tulis kalimat lengkap sendiri.'
|
||||
)
|
||||
|
||||
raw = call_ollama(system_prompt, user_prompt)
|
||||
if is_end_cycle:
|
||||
parsed_raw = parse_end_cycle_llm_json(raw or "")
|
||||
if parsed_raw:
|
||||
# Apply mortality wording on narrative fields before sanitize.
|
||||
soft = {
|
||||
"kesimpulan": str(parsed_raw.get("kesimpulan") or ""),
|
||||
"insight": str(parsed_raw.get("insight") or ""),
|
||||
}
|
||||
soft = enforce_mortality_wording(soft, graded)
|
||||
soft = sanitize_insight_narrative(soft)
|
||||
parsed_raw["kesimpulan"] = soft.get("kesimpulan")
|
||||
parsed_raw["insight"] = soft.get("insight")
|
||||
payload = root_cause.sanitize_end_cycle_payload(
|
||||
parsed_raw,
|
||||
hypotheses=hypotheses,
|
||||
actions=next_actions,
|
||||
masalah=masalah,
|
||||
# Re-state output schema as the system prompt's final line (user_prompt
|
||||
# repeats it again after the page-data JSON).
|
||||
system_prompt += (
|
||||
"\nINGAT: keluaran HANYA JSON valid sesuai FORMAT OUTPUT di atas "
|
||||
"(wajib ada key kesimpulan dan insight). Tanpa teks lain."
|
||||
)
|
||||
# Degenerate output (schema placeholder like "..." copied verbatim) stored
|
||||
# fine as JSON and rendered as an empty insight page. Reject + retry once,
|
||||
# then fail loud (same policy as parse failure).
|
||||
degenerate_reason: str | None = None
|
||||
for attempt in range(2):
|
||||
raw, usage = call_ollama(system_prompt, user_prompt)
|
||||
t_after_llm = time.monotonic()
|
||||
if is_end_cycle:
|
||||
parsed_raw = parse_end_cycle_llm_json(raw or "")
|
||||
if parsed_raw:
|
||||
# Apply mortality wording on narrative fields before sanitize.
|
||||
soft = {
|
||||
"kesimpulan": str(parsed_raw.get("kesimpulan") or ""),
|
||||
"insight": str(parsed_raw.get("insight") or ""),
|
||||
}
|
||||
soft = enforce_mortality_wording(soft, graded)
|
||||
soft = sanitize_insight_narrative(soft)
|
||||
parsed_raw["kesimpulan"] = soft.get("kesimpulan")
|
||||
parsed_raw["insight"] = soft.get("insight")
|
||||
payload = root_cause.sanitize_end_cycle_payload(
|
||||
parsed_raw,
|
||||
hypotheses=hypotheses,
|
||||
actions=next_actions,
|
||||
masalah=masalah,
|
||||
)
|
||||
payload = sanitize_insight_narrative(payload)
|
||||
else:
|
||||
# Fallback removed (2026-09-25): fail loudly instead of serving fake insight.
|
||||
if not raw:
|
||||
raise RuntimeError("Ollama tidak mengembalikan output apa pun (cek layanan LLM).")
|
||||
raise RuntimeError(
|
||||
"Output LLM end-cycle tidak sesuai format JSON yang diminta: "
|
||||
+ repr(str(raw)[:200])
|
||||
)
|
||||
summary = collapse_duplicate_kandang(str(payload.get("kesimpulan") or ""))
|
||||
insight_text = collapse_duplicate_kandang(
|
||||
root_cause.format_end_cycle_insight_text(payload)
|
||||
)
|
||||
payload = sanitize_insight_narrative(payload)
|
||||
else:
|
||||
payload = root_cause.local_fallback_end_cycle(graded, ctx)
|
||||
summary = collapse_duplicate_kandang(str(payload.get("kesimpulan") or ""))
|
||||
insight_text = collapse_duplicate_kandang(
|
||||
root_cause.format_end_cycle_insight_text(payload)
|
||||
)
|
||||
else:
|
||||
parsed = parse_llm_json(raw or "")
|
||||
if not parsed:
|
||||
parsed = local_fallback_insight(graded, topic)
|
||||
else:
|
||||
parsed = parse_llm_json(raw or "")
|
||||
if not parsed:
|
||||
# Fallback removed (2026-09-25): fail loudly instead of serving fake insight.
|
||||
if not raw:
|
||||
raise RuntimeError("Ollama tidak mengembalikan output apa pun (cek layanan LLM).")
|
||||
raise RuntimeError(
|
||||
"Output LLM tidak sesuai format JSON yang diminta: " + repr(str(raw)[:200])
|
||||
)
|
||||
parsed = enforce_mortality_wording(parsed, graded)
|
||||
parsed = sanitize_insight_narrative(parsed)
|
||||
insight_text = parsed["insight"]
|
||||
summary = parsed["kesimpulan"]
|
||||
parsed = sanitize_insight_narrative(parsed)
|
||||
insight_text = parsed["insight"]
|
||||
summary = parsed["kesimpulan"]
|
||||
if _placeholder_text(summary) or _placeholder_text(insight_text):
|
||||
degenerate_reason = f"kesimpulan={summary!r} insight={insight_text!r}"
|
||||
user_prompt += (
|
||||
"\nCATATAN: keluaran sebelumnya hanya placeholder. "
|
||||
"Tulis kalimat lengkap sendiri untuk kesimpulan dan insight."
|
||||
)
|
||||
continue
|
||||
degenerate_reason = None
|
||||
break
|
||||
if degenerate_reason:
|
||||
raise RuntimeError(
|
||||
"Output LLM degenerate (placeholder bukan kalimat): "
|
||||
+ degenerate_reason[:200]
|
||||
)
|
||||
|
||||
alert = alert_from_graded(graded)
|
||||
_notify(stage_cb, "saving")
|
||||
_log_trace(
|
||||
{
|
||||
"timestamp": timezone.now().isoformat(),
|
||||
"request_id": str(uuid.uuid4()),
|
||||
"cycle_id": cycle_id,
|
||||
"kandang_name": kandang.kandang_name,
|
||||
"topic": topic,
|
||||
"report_type": report_type,
|
||||
"report_period": report_period,
|
||||
"model": getattr(settings, "LLM_MODEL_NAME", None),
|
||||
"prompt_version": "v2.1",
|
||||
"formulated_query": rag_query,
|
||||
"retrieved_chunk_ids": [
|
||||
f"cp707#{c.get('chunk_id')}" if c.get("chunk_id") is not None else f"cp707#{c.get('source')}"
|
||||
for c in citations
|
||||
],
|
||||
"graded_alert": alert,
|
||||
"llm_ok": raw is not None,
|
||||
"latency_breakdown": {
|
||||
"grade_ms": round((t_after_grade - t_start) * 1000),
|
||||
"retrieval_ms": round((t_after_rag - t_after_grade) * 1000),
|
||||
"llm_ms": round((t_after_llm - t_after_rag) * 1000),
|
||||
"total_ms": round((time.monotonic() - t_start) * 1000),
|
||||
},
|
||||
"token_usage": usage,
|
||||
}
|
||||
)
|
||||
|
||||
row, _created = AIInsight.objects.update_or_create(
|
||||
cycle=cycle,
|
||||
|
||||
@@ -323,6 +323,25 @@ class AIInsightViewSet(CycleScopedViewSet):
|
||||
{"detail": "context.kandangId must match kandang_id."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Phase 4: async mode returns 202 + job_id (client polls jobs/{id}/).
|
||||
if bool(data.get("async")):
|
||||
from apps.operations.services import insight_jobs
|
||||
|
||||
job_id = insight_jobs.create_job(
|
||||
{
|
||||
"cycle_id": int(cycle_id),
|
||||
"kandang_id": int(kandang_id),
|
||||
"topic": str(topic),
|
||||
"context": context if isinstance(context, dict) else {},
|
||||
"report_type": str(report_type),
|
||||
"report_period": str(report_period),
|
||||
"force_refresh": force_refresh,
|
||||
}
|
||||
)
|
||||
return Response(
|
||||
{"job_id": job_id, "status": "queued"},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
try:
|
||||
result = generate_insight(
|
||||
cycle_id=int(cycle_id),
|
||||
@@ -342,6 +361,15 @@ class AIInsightViewSet(CycleScopedViewSet):
|
||||
)
|
||||
return Response(result)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path=r"jobs/(?P<job_id>[^/.]+)")
|
||||
def job_status(self, request, job_id: str | None = None):
|
||||
from apps.operations.services import insight_jobs
|
||||
|
||||
job = insight_jobs.get_job(job_id or "")
|
||||
if job is None:
|
||||
return Response({"detail": "Job not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(job)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="cached")
|
||||
def cached(self, request):
|
||||
from apps.operations.services.insight_service import get_cached_insight
|
||||
|
||||
@@ -218,3 +218,8 @@ RAG_SERVICE_URL = (env("RAG_SERVICE_URL", "http://127.0.0.1:5002") or "").rstrip
|
||||
OLLAMA_BASE_URL = (env("OLLAMA_BASE_URL", "http://127.0.0.1:11434") or "").rstrip("/")
|
||||
LLM_MODEL_NAME = env("LLM_MODEL_NAME", "qwen2.5:3b") or "qwen2.5:3b"
|
||||
LLM_TIMEOUT_SECONDS = float(env("LLM_TIMEOUT_SECONDS", "1200") or "1200")
|
||||
# Plan §6 production config: deterministic, small context, CPU thread cap.
|
||||
LLM_NUM_CTX = int(env("LLM_NUM_CTX", "2048") or "2048")
|
||||
LLM_NUM_THREAD = int(env("LLM_NUM_THREAD", "4") or "4")
|
||||
LLM_SEED = int(env("LLM_SEED", "42") or "42")
|
||||
LLM_TEMPERATURE = float(env("LLM_TEMPERATURE", "0.2") or "0.2")
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { InsightJobStage } from '../../types/api.ts';
|
||||
|
||||
/** Phase 4 pipeline step indicator for async AI Insight generation. */
|
||||
const STEPS: { stage: InsightJobStage; label: string; icon: string }[] = [
|
||||
{ stage: 'grading', label: 'Evaluating CP 707 rules...', icon: 'fa-solid fa-scale-balanced' },
|
||||
{ stage: 'retrieving', label: 'Fetching SOP guidelines...', icon: 'fa-solid fa-book-open' },
|
||||
{
|
||||
stage: 'synthesizing',
|
||||
label: 'Synthesizing recommendations...',
|
||||
icon: 'fa-solid fa-wand-magic-sparkles',
|
||||
},
|
||||
{ stage: 'saving', label: 'Saving insight...', icon: 'fa-solid fa-floppy-disk' },
|
||||
];
|
||||
|
||||
const ORDER: InsightJobStage[] = ['queued', 'starting', ...STEPS.map((s) => s.stage), 'done'];
|
||||
|
||||
function stageIndex(stage: InsightJobStage | null): number {
|
||||
if (!stage) return 0;
|
||||
const i = ORDER.indexOf(stage);
|
||||
return i < 0 ? 0 : i;
|
||||
}
|
||||
|
||||
export default function InsightProgressSteps({ stage }: { stage: InsightJobStage | null }) {
|
||||
const current = stageIndex(stage);
|
||||
return (
|
||||
<ol className="space-y-1.5 text-xs text-gray-600" aria-live="polite">
|
||||
{STEPS.map((step, i) => {
|
||||
const state = current > i ? 'done' : current === i ? 'active' : 'pending';
|
||||
return (
|
||||
<li
|
||||
key={step.stage}
|
||||
className={`flex items-center gap-2 ${state === 'pending' ? 'opacity-40' : ''}`}
|
||||
>
|
||||
<i
|
||||
className={`${step.icon} ${
|
||||
state === 'done' ? 'text-green-600' : 'text-blue-600'
|
||||
} ${state === 'active' ? 'fa-spin' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{step.label}</span>
|
||||
{state === 'done' && (
|
||||
<i className="fa-solid fa-check text-green-600" aria-hidden="true" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
+215
-221
@@ -7,12 +7,14 @@
|
||||
* the DB row is the source of truth.
|
||||
* Distinct from `AiInsightCard` (Dashboard report modes).
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useFarm } from '../../context/FarmContext.tsx';
|
||||
import { api, errorMessage } from '../../services/apiClient.ts';
|
||||
import type {
|
||||
InsightApiResponse,
|
||||
InsightCitation,
|
||||
InsightContext,
|
||||
InsightJobStage,
|
||||
InsightTopic,
|
||||
} from '../../types/api.ts';
|
||||
import { formatRatio } from '../../utils/format.ts';
|
||||
@@ -28,9 +30,31 @@ import {
|
||||
import InsightTrendChart, { type InsightSeries } from './InsightTrendChart.tsx';
|
||||
import InsightConditionChart, { type ConditionBar } from './InsightConditionChart.tsx';
|
||||
import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx';
|
||||
import InsightProgressSteps from './InsightProgressSteps.tsx';
|
||||
|
||||
const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes
|
||||
|
||||
/** Phase 4: poll async generate job until done / error / timeout / cancel. */
|
||||
async function pollInsightJob(
|
||||
jobId: string,
|
||||
isCurrent: () => boolean,
|
||||
setStage: (s: InsightJobStage | null) => void
|
||||
): Promise<InsightApiResponse | null> {
|
||||
const deadline = Date.now() + CLIENT_TIMEOUT_MS;
|
||||
for (;;) {
|
||||
if (!isCurrent()) return null;
|
||||
const job = await api.insights.jobStatus(jobId);
|
||||
if (!isCurrent()) return null;
|
||||
setStage(job.stage);
|
||||
if (job.status === 'done') return job.result;
|
||||
if (job.status === 'error') throw new Error(job.error || 'Gagal menghasilkan AI Insight.');
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
type PageInsightResult = AiResult & {
|
||||
citations?: InsightCitation[];
|
||||
};
|
||||
@@ -144,6 +168,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
const [result, setResult] = useState<PageInsightResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState<InsightJobStage | null>(null);
|
||||
const supportsTimeframe = Boolean(scalarScope);
|
||||
const timeframe: 'harian' | 'mingguan' = 'harian';
|
||||
|
||||
@@ -157,18 +182,15 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const setSelectedDay = useCallback(
|
||||
(next: number | null) => {
|
||||
setSelectedDayState(next);
|
||||
try {
|
||||
if (next === null) sessionStorage.removeItem(selectedDayKey);
|
||||
else sessionStorage.setItem(selectedDayKey, String(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[selectedDayKey]
|
||||
);
|
||||
const setSelectedDay = (next: number | null) => {
|
||||
setSelectedDayState(next);
|
||||
try {
|
||||
if (next === null) sessionStorage.removeItem(selectedDayKey);
|
||||
else sessionStorage.setItem(selectedDayKey, String(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const availableDays = React.useMemo(() => {
|
||||
const key =
|
||||
@@ -308,7 +330,9 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
label: 'FCR Aktual',
|
||||
value: formatRatio(actual, 3),
|
||||
unit: undefined,
|
||||
context: std ? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}` : null,
|
||||
context: std
|
||||
? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}`
|
||||
: null,
|
||||
status: (std === null
|
||||
? 'unknown'
|
||||
: actual <= std
|
||||
@@ -353,9 +377,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
const hi = maxTemp ?? lastTemp!;
|
||||
// Grade against the farther extreme from setpoint so a bad stretch that day surfaces.
|
||||
const worstDiff =
|
||||
setpoint === null
|
||||
? null
|
||||
: Math.max(Math.abs(lo - setpoint), Math.abs(hi - setpoint));
|
||||
setpoint === null ? null : Math.max(Math.abs(lo - setpoint), Math.abs(hi - setpoint));
|
||||
const signedWorst =
|
||||
setpoint === null
|
||||
? null
|
||||
@@ -412,9 +434,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
value: Math.round(actual).toLocaleString('id-ID'),
|
||||
unit: 'ekor',
|
||||
context:
|
||||
awal === null
|
||||
? null
|
||||
: `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`,
|
||||
awal === null ? null : `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`,
|
||||
status: (mortPct === null
|
||||
? 'unknown'
|
||||
: mortPct < 5
|
||||
@@ -434,221 +454,192 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
|
||||
const meta = TOPIC_META[topic] ?? TOPIC_META.dashboard;
|
||||
|
||||
const generate = useCallback(
|
||||
async (force = false) => {
|
||||
const storageKey = `page-insight-v1-${scopeKey}`;
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () => requestIdRef.current === requestId;
|
||||
const generate = async (force = false) => {
|
||||
const storageKey = `page-insight-v1-${scopeKey}`;
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () => requestIdRef.current === requestId;
|
||||
|
||||
// Soft load: share one in-flight DB GET for the same scope (no client content cache).
|
||||
if (!force && _insightInFlight.has(storageKey)) {
|
||||
const shared = await _insightInFlight.get(storageKey);
|
||||
if (!isCurrent()) return;
|
||||
setResult(shared ?? null);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
// Soft load: share one in-flight DB GET for the same scope (no client content cache).
|
||||
if (!force && _insightInFlight.has(storageKey)) {
|
||||
const shared = await _insightInFlight.get(storageKey);
|
||||
if (!isCurrent()) return;
|
||||
setResult(shared ?? null);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return;
|
||||
}
|
||||
|
||||
if (inFlightRef.current && !force) return;
|
||||
inFlightRef.current = true;
|
||||
// Only show the LLM loading UI when the user clicks Generate.
|
||||
if (force) {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
}
|
||||
setError(null);
|
||||
if (inFlightRef.current && !force) return;
|
||||
inFlightRef.current = true;
|
||||
// Only show the LLM loading UI when the user clicks Generate.
|
||||
if (force) {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const fetchPromise = (async (): Promise<PageInsightResult | null> => {
|
||||
try {
|
||||
if (selectedKandangId == null && !(contextData as Record<string, unknown>).kandangId) {
|
||||
throw new Error(
|
||||
'Pilih kandang terlebih dahulu. AI Insight digenerate per kandang, bukan semua kandang.'
|
||||
);
|
||||
}
|
||||
if (!selectedCycle?.id) {
|
||||
throw new Error('Data siklus tidak tersedia. Pastikan ada siklus aktif.');
|
||||
}
|
||||
const fetchPromise = (async (): Promise<PageInsightResult | null> => {
|
||||
try {
|
||||
if (selectedKandangId == null && !(contextData as Record<string, unknown>).kandangId) {
|
||||
throw new Error(
|
||||
'Pilih kandang terlebih dahulu. AI Insight digenerate per kandang, bukan semua kandang.'
|
||||
);
|
||||
}
|
||||
if (!selectedCycle?.id) {
|
||||
throw new Error('Data siklus tidak tersedia. Pastikan ada siklus aktif.');
|
||||
}
|
||||
|
||||
const rawCtx = contextData as Record<string, unknown>;
|
||||
const kandangId = requireKandangId({
|
||||
...rawCtx,
|
||||
kandangId: rawCtx.kandangId ?? selectedKandangId,
|
||||
});
|
||||
const rawCtx = contextData as Record<string, unknown>;
|
||||
const kandangId = requireKandangId({
|
||||
...rawCtx,
|
||||
kandangId: rawCtx.kandangId ?? selectedKandangId,
|
||||
});
|
||||
|
||||
const rawCtxDay =
|
||||
effectiveDay ??
|
||||
rawCtx.hari_ke ??
|
||||
rawCtx.hari_terakhir ??
|
||||
rawCtx.currentDay ??
|
||||
currentDay;
|
||||
const maxDays = (rawCtx.totalDays as number | undefined) ?? selectedCycle.total_days;
|
||||
const currentDayVal =
|
||||
typeof rawCtxDay === 'number' && maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
|
||||
const rawCtxDay =
|
||||
effectiveDay ?? rawCtx.hari_ke ?? rawCtx.hari_terakhir ?? rawCtx.currentDay ?? currentDay;
|
||||
const maxDays = (rawCtx.totalDays as number | undefined) ?? selectedCycle.total_days;
|
||||
const currentDayVal =
|
||||
typeof rawCtxDay === 'number' && maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
|
||||
|
||||
const slicedContextData = scopeContextToTimeframe(contextData, timeframe, effectiveDay);
|
||||
const scopedContextData = scalarScope
|
||||
? scopeScalarsToTimeframe(
|
||||
slicedContextData as Record<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
)
|
||||
: slicedContextData;
|
||||
const slicedContextData = scopeContextToTimeframe(contextData, timeframe, effectiveDay);
|
||||
const scopedContextData = scalarScope
|
||||
? scopeScalarsToTimeframe(
|
||||
slicedContextData as Record<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
)
|
||||
: slicedContextData;
|
||||
|
||||
const dataPeriode = (scopedContextData as Record<string, unknown>)?.periode;
|
||||
// Prefer explicit day-0..N wording when a day is selected (matches dashboard reports).
|
||||
const timeframeLabel =
|
||||
effectiveDay !== null
|
||||
? describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay)
|
||||
: !supportsTimeframe
|
||||
? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)'
|
||||
: typeof dataPeriode === 'string' && dataPeriode.trim().length > 0
|
||||
? dataPeriode
|
||||
: describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay);
|
||||
const dataPeriode = (scopedContextData as Record<string, unknown>)?.periode;
|
||||
// Prefer explicit day-0..N wording when a day is selected (matches dashboard reports).
|
||||
const timeframeLabel =
|
||||
effectiveDay !== null
|
||||
? describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay)
|
||||
: !supportsTimeframe
|
||||
? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)'
|
||||
: typeof dataPeriode === 'string' && dataPeriode.trim().length > 0
|
||||
? dataPeriode
|
||||
: describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay);
|
||||
|
||||
const reportPeriod =
|
||||
timeframe === 'harian'
|
||||
? effectiveDay !== null
|
||||
? `Hari ${effectiveDay}`
|
||||
: currentDayVal
|
||||
? `Hari ${currentDayVal}`
|
||||
: null
|
||||
: null;
|
||||
const reportPeriod =
|
||||
timeframe === 'harian'
|
||||
? effectiveDay !== null
|
||||
? `Hari ${effectiveDay}`
|
||||
: currentDayVal
|
||||
? `Hari ${currentDayVal}`
|
||||
: null
|
||||
: null;
|
||||
|
||||
const contextPayload: InsightContext = {
|
||||
...(scopedContextData as InsightContext),
|
||||
kandangId,
|
||||
kandangName:
|
||||
(rawCtx.kandangName as string | undefined) ??
|
||||
selectedKandang?.kandang_name ??
|
||||
undefined,
|
||||
cycleId: selectedCycle.id,
|
||||
hari_ke:
|
||||
typeof effectiveDay === 'number'
|
||||
? effectiveDay
|
||||
: typeof currentDayVal === 'number'
|
||||
? currentDayVal
|
||||
: null,
|
||||
totalDays: maxDays ?? null,
|
||||
periode: timeframeLabel,
|
||||
};
|
||||
const contextPayload: InsightContext = {
|
||||
...(scopedContextData as InsightContext),
|
||||
kandangId,
|
||||
kandangName:
|
||||
(rawCtx.kandangName as string | undefined) ??
|
||||
selectedKandang?.kandang_name ??
|
||||
undefined,
|
||||
cycleId: selectedCycle.id,
|
||||
hari_ke:
|
||||
typeof effectiveDay === 'number'
|
||||
? effectiveDay
|
||||
: typeof currentDayVal === 'number'
|
||||
? currentDayVal
|
||||
: null,
|
||||
totalDays: maxDays ?? null,
|
||||
periode: timeframeLabel,
|
||||
};
|
||||
|
||||
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
|
||||
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | null;
|
||||
|
||||
if (!force) {
|
||||
try {
|
||||
apiResult = await api.insights.cached({
|
||||
cycle_id: selectedCycle.id,
|
||||
kandang_id: kandangId,
|
||||
topic,
|
||||
report_type: 'page',
|
||||
report_period: reportPeriod,
|
||||
});
|
||||
if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null;
|
||||
} catch {
|
||||
apiResult = null;
|
||||
}
|
||||
// Manual-generate only: never POST generate on mount / soft refresh.
|
||||
if (!apiResult) {
|
||||
if (!isCurrent()) return null;
|
||||
setResult(null);
|
||||
setLoading(false);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const generateCall = api.insights.generate({
|
||||
if (!force) {
|
||||
try {
|
||||
apiResult = await api.insights.cached({
|
||||
cycle_id: selectedCycle.id,
|
||||
kandang_id: kandangId,
|
||||
topic,
|
||||
report_type: 'page',
|
||||
report_period: reportPeriod,
|
||||
context: contextPayload,
|
||||
force_refresh: true,
|
||||
});
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
'TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.'
|
||||
)
|
||||
),
|
||||
CLIENT_TIMEOUT_MS
|
||||
)
|
||||
);
|
||||
apiResult = await Promise.race([generateCall, timeoutPromise]);
|
||||
if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null;
|
||||
} catch {
|
||||
apiResult = null;
|
||||
}
|
||||
|
||||
if (!isCurrent()) return null;
|
||||
|
||||
if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) {
|
||||
throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.');
|
||||
}
|
||||
|
||||
const text = apiResult.insight_text ?? '';
|
||||
const parsed =
|
||||
apiResult.summary || apiResult.insight
|
||||
? {
|
||||
summary: apiResult.summary || '',
|
||||
insight: apiResult.insight || '',
|
||||
}
|
||||
: parseAiResult(text);
|
||||
|
||||
if (!parsed || (!parsed.summary && !parsed.insight)) {
|
||||
throw new Error('Respons AI tidak dapat diproses.');
|
||||
}
|
||||
|
||||
const wrapped: PageInsightResult = {
|
||||
summary: parsed.summary,
|
||||
insight: parsed.insight,
|
||||
citations: apiResult.citations,
|
||||
};
|
||||
|
||||
if (!isCurrent()) return wrapped;
|
||||
setResult(wrapped);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return wrapped;
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return null;
|
||||
console.error(`[PageAiInsight:${topic}] request failed:`, err);
|
||||
// Soft DB misses/errors should not look like a failed generate.
|
||||
if (force) {
|
||||
setError(errorMessage(err));
|
||||
// Manual-generate only: never POST generate on mount / soft refresh.
|
||||
if (!apiResult) {
|
||||
if (!isCurrent()) return null;
|
||||
setResult(null);
|
||||
} else {
|
||||
setResult(null);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
setLoading(false);
|
||||
inFlightRef.current = false;
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return null;
|
||||
}
|
||||
_insightInFlight.delete(storageKey);
|
||||
} else {
|
||||
setStage(null);
|
||||
const job = await api.insights.generateAsync({
|
||||
cycle_id: selectedCycle.id,
|
||||
kandang_id: kandangId,
|
||||
topic,
|
||||
report_type: 'page',
|
||||
report_period: reportPeriod,
|
||||
context: contextPayload,
|
||||
force_refresh: true,
|
||||
});
|
||||
apiResult = await pollInsightJob(job.job_id, isCurrent, setStage);
|
||||
}
|
||||
})();
|
||||
|
||||
if (!force) {
|
||||
_insightInFlight.set(storageKey, fetchPromise);
|
||||
if (!isCurrent()) return null;
|
||||
|
||||
if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) {
|
||||
throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.');
|
||||
}
|
||||
|
||||
const text = apiResult.insight_text ?? '';
|
||||
const parsed =
|
||||
apiResult.summary || apiResult.insight
|
||||
? {
|
||||
summary: apiResult.summary || '',
|
||||
insight: apiResult.insight || '',
|
||||
}
|
||||
: parseAiResult(text);
|
||||
|
||||
if (!parsed || (!parsed.summary && !parsed.insight)) {
|
||||
throw new Error('Respons AI tidak dapat diproses.');
|
||||
}
|
||||
|
||||
const wrapped: PageInsightResult = {
|
||||
summary: parsed.summary,
|
||||
insight: parsed.insight,
|
||||
citations: apiResult.citations,
|
||||
};
|
||||
|
||||
if (!isCurrent()) return wrapped;
|
||||
setResult(wrapped);
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return wrapped;
|
||||
} catch (err) {
|
||||
if (!isCurrent()) return null;
|
||||
console.error(`[PageAiInsight:${topic}] request failed:`, err);
|
||||
// Soft DB misses/errors should not look like a failed generate.
|
||||
if (force) {
|
||||
setError(errorMessage(err));
|
||||
setResult(null);
|
||||
} else {
|
||||
setResult(null);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
setLoading(false);
|
||||
inFlightRef.current = false;
|
||||
}
|
||||
_insightInFlight.delete(storageKey);
|
||||
}
|
||||
await fetchPromise;
|
||||
},
|
||||
[
|
||||
topic,
|
||||
contextData,
|
||||
selectedCycle,
|
||||
selectedKandang,
|
||||
selectedKandangId,
|
||||
currentDay,
|
||||
timeframe,
|
||||
effectiveDay,
|
||||
scalarScope,
|
||||
supportsTimeframe,
|
||||
scopeKey,
|
||||
]
|
||||
);
|
||||
})();
|
||||
|
||||
if (!force) {
|
||||
_insightInFlight.set(storageKey, fetchPromise);
|
||||
}
|
||||
await fetchPromise;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
desiredKeyRef.current = scopeKey;
|
||||
@@ -679,7 +670,11 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{selectedKandang?.kandang_name ?? 'Kandang'}
|
||||
{selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}
|
||||
{effectiveDay != null ? ` · Hari ke-${effectiveDay}` : currentDay != null ? ` · Hari ke-${currentDay}` : ''}
|
||||
{effectiveDay != null
|
||||
? ` · Hari ke-${effectiveDay}`
|
||||
: currentDay != null
|
||||
? ` · Hari ke-${currentDay}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -725,6 +720,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
{loading && !result && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-4 border-gray-100 border-t-red-600 animate-spin" />
|
||||
<InsightProgressSteps stage={stage} />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm">
|
||||
Menganalisis data dengan model AI… proses ini bisa memakan waktu beberapa menit.
|
||||
</p>
|
||||
@@ -870,7 +866,9 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Umur Rata-rata Panen</span>
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
Umur Rata-rata Panen
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{typeof displayContext.umur_panen_rata_hari === 'number'
|
||||
? `${displayContext.umur_panen_rata_hari.toLocaleString('id-ID', {
|
||||
@@ -888,10 +886,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
<span className="text-sm text-gray-600 font-medium">Experience Suhu</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderDayRange(
|
||||
displayContext.experience_suhu_min_C ??
|
||||
displayContext.experience_suhu_C,
|
||||
displayContext.experience_suhu_max_C ??
|
||||
displayContext.experience_suhu_C,
|
||||
displayContext.experience_suhu_min_C ?? displayContext.experience_suhu_C,
|
||||
displayContext.experience_suhu_max_C ?? displayContext.experience_suhu_C,
|
||||
'°C'
|
||||
)}
|
||||
</span>
|
||||
@@ -900,10 +896,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
<span className="text-sm text-gray-600 font-medium">Kelembapan</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderDayRange(
|
||||
displayContext.kelembapan_min_persen ??
|
||||
displayContext.kelembapan_persen,
|
||||
displayContext.kelembapan_max_persen ??
|
||||
displayContext.kelembapan_persen,
|
||||
displayContext.kelembapan_min_persen ?? displayContext.kelembapan_persen,
|
||||
displayContext.kelembapan_max_persen ?? displayContext.kelembapan_persen,
|
||||
'%'
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -54,6 +54,11 @@ services:
|
||||
image: ollama/ollama:latest
|
||||
container_name: dashboard-cpsp-llm
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Plan §6: serialize CPU inference, keep model resident (prefix KV cache reuse).
|
||||
- OLLAMA_NUM_PARALLEL=1
|
||||
- OLLAMA_MAX_LOADED_MODELS=1
|
||||
- OLLAMA_KEEP_ALIVE=24h
|
||||
ports:
|
||||
- "127.0.0.1:11434:11434"
|
||||
volumes:
|
||||
|
||||
+22
-8
@@ -11,7 +11,9 @@ import type {
|
||||
InitialBalanceResult,
|
||||
InsightApiResponse,
|
||||
InsightCachedParams,
|
||||
InsightGenerateJob,
|
||||
InsightGenerateParams,
|
||||
InsightJobStatus,
|
||||
Karung,
|
||||
KarungRequestResult,
|
||||
Kandang,
|
||||
@@ -194,14 +196,11 @@ export const api = {
|
||||
remove: (id: number) => del(`/cycles/${id}/`),
|
||||
initialBalance: (id: number, body: { date: string; feed_in_manual: number }) =>
|
||||
post<InitialBalanceResult>(`/cycles/${id}/initial-balance/`, body),
|
||||
initialBalanceCompare: (
|
||||
id: number,
|
||||
query: { date: string; manual?: number }
|
||||
) => get<InitialBalanceResult>(`/cycles/${id}/initial-balance-compare/`, query),
|
||||
initialBalanceCompare: (id: number, query: { date: string; manual?: number }) =>
|
||||
get<InitialBalanceResult>(`/cycles/${id}/initial-balance-compare/`, query),
|
||||
requestClose: (id: number, body: { proposed_end_date: string }) =>
|
||||
post<Cycle>(`/cycles/${id}/request-close/`, body),
|
||||
cancelCloseRequest: (id: number) =>
|
||||
post<Cycle>(`/cycles/${id}/cancel-close-request/`, {}),
|
||||
cancelCloseRequest: (id: number) => post<Cycle>(`/cycles/${id}/cancel-close-request/`, {}),
|
||||
},
|
||||
pusat: {
|
||||
register: (body: { code: string; site_id: string }) =>
|
||||
@@ -263,14 +262,29 @@ export const api = {
|
||||
*/
|
||||
generate: (body: InsightGenerateParams) => {
|
||||
if (body.kandang_id == null) {
|
||||
return Promise.reject(new Error('kandang_id wajib — insight tidak boleh untuk semua kandang'));
|
||||
return Promise.reject(
|
||||
new Error('kandang_id wajib — insight tidak boleh untuk semua kandang')
|
||||
);
|
||||
}
|
||||
return post<InsightApiResponse>('/ai-insights/generate/', body);
|
||||
},
|
||||
/** Async generate (Phase 4): 202 + job_id; poll with jobStatus(). */
|
||||
generateAsync: (body: InsightGenerateParams) => {
|
||||
if (body.kandang_id == null) {
|
||||
return Promise.reject(
|
||||
new Error('kandang_id wajib — insight tidak boleh untuk semua kandang')
|
||||
);
|
||||
}
|
||||
return post<InsightGenerateJob>('/ai-insights/generate/', { ...body, async: true });
|
||||
},
|
||||
/** Poll async generate job (status + pipeline stage). */
|
||||
jobStatus: (jobId: string) => get<InsightJobStatus>(`/ai-insights/jobs/${jobId}/`),
|
||||
/** Load insight from DB for the same scope key (no LLM call). */
|
||||
cached: (params: InsightCachedParams) => {
|
||||
if (params.kandang_id == null) {
|
||||
return Promise.reject(new Error('kandang_id wajib — insight tidak boleh untuk semua kandang'));
|
||||
return Promise.reject(
|
||||
new Error('kandang_id wajib — insight tidak boleh untuk semua kandang')
|
||||
);
|
||||
}
|
||||
return get<InsightApiResponse>('/ai-insights/cached/', {
|
||||
cycle_id: params.cycle_id,
|
||||
|
||||
+15
-7
@@ -228,13 +228,7 @@ export type AIInsight = {
|
||||
export type InsightReportType = 'page' | 'daily' | 'weekly' | 'end_cycle';
|
||||
|
||||
export type InsightTopic =
|
||||
| 'hitung_ayam'
|
||||
| 'berat_ayam'
|
||||
| 'fcr'
|
||||
| 'eef'
|
||||
| 'iot_panel'
|
||||
| 'hitung_karung'
|
||||
| 'dashboard';
|
||||
'hitung_ayam' | 'berat_ayam' | 'fcr' | 'eef' | 'iot_panel' | 'hitung_karung' | 'dashboard';
|
||||
|
||||
export type InsightCitation = {
|
||||
id?: string;
|
||||
@@ -276,6 +270,20 @@ export type InsightCachedParams = {
|
||||
report_period?: string | null;
|
||||
};
|
||||
|
||||
export type InsightJobStage =
|
||||
'queued' | 'starting' | 'grading' | 'retrieving' | 'synthesizing' | 'saving' | 'done' | 'error';
|
||||
|
||||
export type InsightGenerateJob = {
|
||||
job_id: string;
|
||||
status: 'queued' | 'running' | 'done' | 'error';
|
||||
};
|
||||
|
||||
export type InsightJobStatus = InsightGenerateJob & {
|
||||
stage: InsightJobStage;
|
||||
result: InsightApiResponse | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type InsightStatusLabel = 'ok' | 'warning' | 'critical' | 'unknown';
|
||||
|
||||
export type InsightStructuredSection = {
|
||||
|
||||
Reference in new issue
Block a user