update ai insight migration for all page
This commit is contained in:
1 parent
07d36081ce
commit
18790ab8f6
69 files changed
+9457
-399
No files matched your search
@@ -64,3 +64,9 @@ CHICKEN_COUNTING_EDGE_TIMEOUT_SECONDS=30
|
||||
CHICKEN_COUNTING_EDGE_COUNTING_SYNC_ENABLED=true
|
||||
CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED=true
|
||||
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED=true
|
||||
|
||||
# AI Insight (Ollama on host + RAG service)
|
||||
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
|
||||
@@ -58,11 +58,13 @@ class PusatExportTests(TestCase):
|
||||
)
|
||||
AIInsight.objects.create(
|
||||
cycle=self.cycle,
|
||||
kandang=self.kandang,
|
||||
date=today,
|
||||
insight_text="ok",
|
||||
alert="none",
|
||||
section="fcr",
|
||||
session="morning",
|
||||
alert="healthy",
|
||||
topic="fcr",
|
||||
report_type="page",
|
||||
report_period="Hari 1",
|
||||
)
|
||||
IotPanel.objects.create(
|
||||
flock=self.flock,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Generated for AI Insight cache scope (multi-kandang generate/cache)
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("farms", "0014_kandang_feed_in_button_urls"),
|
||||
("operations", "0011_aiinsight_unique_cycle_date_section_session"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="aiinsight",
|
||||
name="uniq_ai_insight_cycle_date_section_session",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="kandang",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ai_insights",
|
||||
to="farms.kandang",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="topic",
|
||||
field=models.CharField(blank=True, db_index=True, default="", max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="report_type",
|
||||
field=models.CharField(blank=True, db_index=True, default="page", max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="report_period",
|
||||
field=models.CharField(blank=True, db_index=True, default="current", max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="version",
|
||||
field=models.CharField(blank=True, default="v1", max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="source",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("generated", "Generated"),
|
||||
("cache", "Cache"),
|
||||
("local_fallback", "Local fallback"),
|
||||
],
|
||||
default="generated",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="kesimpulan",
|
||||
field=models.TextField(blank=True, default=""),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="graded_facts",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="citations",
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="aiinsight",
|
||||
name="expires_at",
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="aiinsight",
|
||||
name="alert",
|
||||
field=models.CharField(blank=True, default="", max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="aiinsight",
|
||||
name="section",
|
||||
field=models.CharField(blank=True, default="", max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="aiinsight",
|
||||
name="session",
|
||||
field=models.CharField(blank=True, default="", max_length=100),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="aiinsight",
|
||||
options={"ordering": ["-date", "-created_at"]},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="aiinsight",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("cycle", "kandang", "topic", "report_type", "report_period", "version"),
|
||||
name="uniq_ai_insight_cache_scope",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("operations", "0012_ai_insight_cache_scope"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="aiinsight",
|
||||
name="expires_at",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("operations", "0013_remove_aiinsight_expires_at"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="aiinsight",
|
||||
name="section",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="aiinsight",
|
||||
name="session",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("operations", "0014_remove_aiinsight_section_session"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="aiinsight",
|
||||
name="uniq_ai_insight_cache_scope",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="aiinsight",
|
||||
name="version",
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="aiinsight",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("cycle", "kandang", "topic", "report_type", "report_period"),
|
||||
name="uniq_ai_insight_cache_scope",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("operations", "0015_remove_aiinsight_version"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="aiinsight",
|
||||
old_name="kesimpulan",
|
||||
new_name="summary",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="aiinsight",
|
||||
name="graded_facts",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("operations", "0016_aiinsight_summary_drop_graded_facts"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="aiinsight",
|
||||
name="citations",
|
||||
field=models.TextField(blank=True, default="[]"),
|
||||
),
|
||||
]
|
||||
@@ -27,22 +27,47 @@ class IotPanel(models.Model):
|
||||
|
||||
|
||||
class AIInsight(models.Model):
|
||||
SOURCE_GENERATED = "generated"
|
||||
SOURCE_CACHE = "cache"
|
||||
SOURCE_LOCAL_FALLBACK = "local_fallback"
|
||||
SOURCE_CHOICES = [
|
||||
(SOURCE_GENERATED, "Generated"),
|
||||
(SOURCE_CACHE, "Cache"),
|
||||
(SOURCE_LOCAL_FALLBACK, "Local fallback"),
|
||||
]
|
||||
|
||||
date = models.DateField()
|
||||
insight_text = models.TextField()
|
||||
alert = models.CharField(max_length=100)
|
||||
section = models.CharField(max_length=100)
|
||||
session = models.CharField(max_length=100)
|
||||
alert = models.CharField(max_length=100, blank=True, default="")
|
||||
cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="ai_insights")
|
||||
kandang = models.ForeignKey(
|
||||
"farms.Kandang",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ai_insights",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
topic = models.CharField(max_length=64, blank=True, default="", db_index=True)
|
||||
report_type = models.CharField(max_length=32, blank=True, default="page", db_index=True)
|
||||
report_period = models.CharField(max_length=64, blank=True, default="current", db_index=True)
|
||||
source = models.CharField(
|
||||
max_length=32,
|
||||
choices=SOURCE_CHOICES,
|
||||
default=SOURCE_GENERATED,
|
||||
blank=True,
|
||||
)
|
||||
summary = models.TextField(blank=True, default="")
|
||||
citations = models.TextField(blank=True, default="[]")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "ai_insight"
|
||||
ordering = ["-date"]
|
||||
ordering = ["-date", "-created_at"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["cycle", "date", "section", "session"],
|
||||
name="uniq_ai_insight_cycle_date_section_session",
|
||||
fields=["cycle", "kandang", "topic", "report_type", "report_period"],
|
||||
name="uniq_ai_insight_cache_scope",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -138,3 +138,10 @@ class AIInsightSerializer(PkAsIdMixin, CycleContextMixin, serializers.ModelSeria
|
||||
model = AIInsight
|
||||
fields = "__all__"
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
from apps.operations.services.insight_service import decode_citations
|
||||
|
||||
data = super().to_representation(instance)
|
||||
data["citations"] = decode_citations(instance.citations)
|
||||
return data
|
||||
@@ -0,0 +1,746 @@
|
||||
"""
|
||||
CP 707 Knowledge Base
|
||||
|
||||
Sumber: Buku "Manajemen Broiler CP 707" oleh PT Charoen Pokphand Indonesia, Tbk.
|
||||
Edisi Juli 2023
|
||||
|
||||
Seluruh standar teknis dari buku panduan CP 707 untuk referensi on-premise AI Insight.
|
||||
TIDAK ada data dari sumber luar — hanya dari buku ini.
|
||||
|
||||
Port murni Python 3.11+ dari cp707Knowledge.js (tanpa dependensi Django).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# ─── Standar Performa Mingguan CP 707 ───────────────────────────────────────
|
||||
PERFORMANCE_STANDARD_WEEKLY: list[dict[str, Any]] = [
|
||||
{"week": 1, "targetBW_g": 195, "adg_g": 34, "cumFeedConsumption_g": 164.5, "fcr": 0.844},
|
||||
{"week": 2, "targetBW_g": 499, "adg_g": 50, "cumFeedConsumption_g": 530.5, "fcr": 1.063},
|
||||
{"week": 3, "targetBW_g": 954, "adg_g": 80, "cumFeedConsumption_g": 1181.5, "fcr": 1.238},
|
||||
{"week": 4, "targetBW_g": 1543, "adg_g": 88, "cumFeedConsumption_g": 2198.5, "fcr": 1.425},
|
||||
{"week": 5, "targetBW_g": 2191, "adg_g": 94, "cumFeedConsumption_g": 3461, "fcr": 1.580},
|
||||
]
|
||||
|
||||
# ─── Standar Performa Harian CP 707 (Lampiran 2) ────────────────────────────
|
||||
PERFORMANCE_STANDARD_DAILY: list[dict[str, Any]] = [
|
||||
{"day": 1, "bw_g": 57, "adg_g": 15, "mortalityCum_pct": 0.40, "feedDaily_g": 13, "feedCum_g": 13, "fcr": 0.228, "ip": None},
|
||||
{"day": 2, "bw_g": 73, "adg_g": 16, "mortalityCum_pct": 0.50, "feedDaily_g": 17, "feedCum_g": 30, "fcr": 0.411, "ip": None},
|
||||
{"day": 3, "bw_g": 90, "adg_g": 17, "mortalityCum_pct": 0.60, "feedDaily_g": 20.5, "feedCum_g": 50.5, "fcr": 0.561, "ip": None},
|
||||
{"day": 4, "bw_g": 110, "adg_g": 20, "mortalityCum_pct": 0.70, "feedDaily_g": 23, "feedCum_g": 73.5, "fcr": 0.668, "ip": None},
|
||||
{"day": 5, "bw_g": 134, "adg_g": 24, "mortalityCum_pct": 0.80, "feedDaily_g": 26, "feedCum_g": 99.5, "fcr": 0.743, "ip": None},
|
||||
{"day": 6, "bw_g": 161, "adg_g": 27, "mortalityCum_pct": 0.90, "feedDaily_g": 31, "feedCum_g": 130.5, "fcr": 0.811, "ip": None},
|
||||
{"day": 7, "bw_g": 195, "adg_g": 34, "mortalityCum_pct": 1.00, "feedDaily_g": 34, "feedCum_g": 164.5, "fcr": 0.844, "ip": 327},
|
||||
{"day": 8, "bw_g": 231, "adg_g": 36, "mortalityCum_pct": 1.10, "feedDaily_g": 35, "feedCum_g": 199.5, "fcr": 0.864, "ip": 331},
|
||||
{"day": 9, "bw_g": 269, "adg_g": 38, "mortalityCum_pct": 1.20, "feedDaily_g": 41, "feedCum_g": 240.5, "fcr": 0.894, "ip": 330},
|
||||
{"day": 10, "bw_g": 311, "adg_g": 42, "mortalityCum_pct": 1.30, "feedDaily_g": 46, "feedCum_g": 286.5, "fcr": 0.921, "ip": 333},
|
||||
{"day": 11, "bw_g": 355, "adg_g": 44, "mortalityCum_pct": 1.40, "feedDaily_g": 52, "feedCum_g": 338.5, "fcr": 0.954, "ip": 334},
|
||||
{"day": 12, "bw_g": 401, "adg_g": 46, "mortalityCum_pct": 1.50, "feedDaily_g": 58, "feedCum_g": 396.5, "fcr": 0.989, "ip": 333},
|
||||
{"day": 13, "bw_g": 449, "adg_g": 48, "mortalityCum_pct": 1.60, "feedDaily_g": 64, "feedCum_g": 460.5, "fcr": 1.026, "ip": 331},
|
||||
{"day": 14, "bw_g": 499, "adg_g": 50, "mortalityCum_pct": 1.70, "feedDaily_g": 70, "feedCum_g": 530.5, "fcr": 1.063, "ip": 330},
|
||||
{"day": 15, "bw_g": 552, "adg_g": 53, "mortalityCum_pct": 1.80, "feedDaily_g": 73, "feedCum_g": 603.5, "fcr": 1.093, "ip": 331},
|
||||
{"day": 16, "bw_g": 609, "adg_g": 57, "mortalityCum_pct": 1.90, "feedDaily_g": 80, "feedCum_g": 683.5, "fcr": 1.122, "ip": 333},
|
||||
{"day": 17, "bw_g": 669, "adg_g": 60, "mortalityCum_pct": 2.00, "feedDaily_g": 86, "feedCum_g": 769.5, "fcr": 1.150, "ip": 335},
|
||||
{"day": 18, "bw_g": 734, "adg_g": 65, "mortalityCum_pct": 2.10, "feedDaily_g": 92, "feedCum_g": 861.5, "fcr": 1.174, "ip": 340},
|
||||
{"day": 19, "bw_g": 802, "adg_g": 68, "mortalityCum_pct": 2.20, "feedDaily_g": 100, "feedCum_g": 961.5, "fcr": 1.199, "ip": 344},
|
||||
{"day": 20, "bw_g": 874, "adg_g": 72, "mortalityCum_pct": 2.30, "feedDaily_g": 107, "feedCum_g": 1068.5, "fcr": 1.223, "ip": 349},
|
||||
{"day": 21, "bw_g": 954, "adg_g": 80, "mortalityCum_pct": 2.40, "feedDaily_g": 113, "feedCum_g": 1181.5, "fcr": 1.238, "ip": 358},
|
||||
{"day": 22, "bw_g": 1035, "adg_g": 81, "mortalityCum_pct": 2.52, "feedDaily_g": 128, "feedCum_g": 1309.5, "fcr": 1.265, "ip": 362},
|
||||
{"day": 23, "bw_g": 1117, "adg_g": 82, "mortalityCum_pct": 2.64, "feedDaily_g": 133, "feedCum_g": 1442.5, "fcr": 1.291, "ip": 366},
|
||||
{"day": 24, "bw_g": 1200, "adg_g": 83, "mortalityCum_pct": 2.76, "feedDaily_g": 139, "feedCum_g": 1581.5, "fcr": 1.318, "ip": 369},
|
||||
{"day": 25, "bw_g": 1284, "adg_g": 84, "mortalityCum_pct": 2.88, "feedDaily_g": 145, "feedCum_g": 1726.5, "fcr": 1.345, "ip": 371},
|
||||
{"day": 26, "bw_g": 1369, "adg_g": 85, "mortalityCum_pct": 3.00, "feedDaily_g": 151, "feedCum_g": 1877.5, "fcr": 1.371, "ip": 372},
|
||||
{"day": 27, "bw_g": 1455, "adg_g": 86, "mortalityCum_pct": 3.12, "feedDaily_g": 157, "feedCum_g": 2034.5, "fcr": 1.398, "ip": 373},
|
||||
{"day": 28, "bw_g": 1543, "adg_g": 88, "mortalityCum_pct": 3.25, "feedDaily_g": 164, "feedCum_g": 2198.5, "fcr": 1.425, "ip": 374},
|
||||
{"day": 29, "bw_g": 1633, "adg_g": 90, "mortalityCum_pct": 3.38, "feedDaily_g": 173, "feedCum_g": 2371.5, "fcr": 1.452, "ip": 375},
|
||||
{"day": 30, "bw_g": 1725, "adg_g": 92, "mortalityCum_pct": 3.52, "feedDaily_g": 176.5, "feedCum_g": 2548, "fcr": 1.477, "ip": 376},
|
||||
{"day": 31, "bw_g": 1817, "adg_g": 92, "mortalityCum_pct": 3.66, "feedDaily_g": 178, "feedCum_g": 2726, "fcr": 1.500, "ip": 376},
|
||||
{"day": 32, "bw_g": 1910, "adg_g": 93, "mortalityCum_pct": 3.80, "feedDaily_g": 180, "feedCum_g": 2906, "fcr": 1.521, "ip": 377},
|
||||
{"day": 33, "bw_g": 2003, "adg_g": 93, "mortalityCum_pct": 3.95, "feedDaily_g": 183, "feedCum_g": 3089, "fcr": 1.542, "ip": 378},
|
||||
{"day": 34, "bw_g": 2097, "adg_g": 94, "mortalityCum_pct": 4.10, "feedDaily_g": 185, "feedCum_g": 3274, "fcr": 1.561, "ip": 379},
|
||||
{"day": 35, "bw_g": 2191, "adg_g": 94, "mortalityCum_pct": 4.25, "feedDaily_g": 187, "feedCum_g": 3461, "fcr": 1.580, "ip": 379},
|
||||
{"day": 36, "bw_g": 2285, "adg_g": 94, "mortalityCum_pct": 4.45, "feedDaily_g": 190, "feedCum_g": 3651, "fcr": 1.598, "ip": 380},
|
||||
{"day": 37, "bw_g": 2380, "adg_g": 95, "mortalityCum_pct": 4.65, "feedDaily_g": 193, "feedCum_g": 3844, "fcr": 1.615, "ip": 380},
|
||||
]
|
||||
|
||||
# ─── Target Suhu Pemeliharaan per Umur (Buku CP 707) ────────────────────────
|
||||
TEMPERATURE_STANDARD: list[dict[str, Any]] = [
|
||||
{"ageDay_from": 1, "ageDay_to": 2, "temp_C": 32, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||||
{"ageDay_from": 3, "ageDay_to": 4, "temp_C": 31, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||||
{"ageDay_from": 5, "ageDay_to": 7, "temp_C": 30, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||||
{"ageDay_from": 8, "ageDay_to": 14, "temp_C": 29, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||||
{"ageDay_from": 15, "ageDay_to": 21, "temp_C": 28, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||||
{"ageDay_from": 22, "ageDay_to": 28, "temp_C": 26, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||||
{"ageDay_from": 29, "ageDay_to": 35, "temp_C": 23, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||||
{"ageDay_from": 36, "ageDay_to": 99, "temp_C": 22, "humidity_pct_min": 50, "humidity_pct_max": 70},
|
||||
]
|
||||
|
||||
# ─── Target Efektif Temperatur (TET) per Umur ───────────────────────────────
|
||||
TARGET_EFFECTIVE_TEMPERATURE: list[dict[str, Any]] = [
|
||||
{"ageDay_from": 1, "ageDay_to": 2, "tet_C": 32},
|
||||
{"ageDay_from": 3, "ageDay_to": 4, "tet_C": 31},
|
||||
{"ageDay_from": 5, "ageDay_to": 7, "tet_C": 30},
|
||||
{"ageDay_from": 8, "ageDay_to": 14, "tet_C": 29},
|
||||
{"ageDay_from": 15, "ageDay_to": 21, "tet_C": 27},
|
||||
{"ageDay_from": 22, "ageDay_to": 28, "tet_C": 25},
|
||||
{"ageDay_from": 29, "ageDay_to": 35, "tet_C": 22},
|
||||
{"ageDay_from": 36, "ageDay_to": 99, "tet_C": 21},
|
||||
]
|
||||
|
||||
# ─── Standar Kualitas Udara (Lampiran 3) ────────────────────────────────────
|
||||
AIR_QUALITY_STANDARD: dict[str, Any] = {
|
||||
"ammonia": {
|
||||
"ideal_pct": "<10 ppm",
|
||||
"warning": 10, # >10 ppm: merusak permukaan paru-paru
|
||||
"critical": 25, # >25 ppm: pertumbuhan menurun
|
||||
"severe": 50, # >50 ppm: pertumbuhan menurun signifikan
|
||||
"note": ">20 ppm lebih rentan terhadap penyakit pernapasan",
|
||||
},
|
||||
"co2": {
|
||||
"ideal": "<3000 ppm",
|
||||
"critical": 3500, # >3500 ppm: ascites dan kematian tinggi
|
||||
},
|
||||
"co": {
|
||||
"ideal": "10 ppm",
|
||||
"warning": 50, # >50 ppm mempengaruhi kesehatan
|
||||
"critical": 100, # 100 ppm: meningkatkan angka kematian
|
||||
},
|
||||
"humidity": {
|
||||
"ideal_after_brooding": "50-60%",
|
||||
"warning_high": 70, # >70% pada suhu >29°C berpengaruh pada pertumbuhan
|
||||
"warning_low": 50, # RH <50% selama brooding berpengaruh pada pertumbuhan
|
||||
},
|
||||
}
|
||||
|
||||
# ─── Standar Mortalitas CP 707 ───────────────────────────────────────────────
|
||||
MORTALITY_THRESHOLDS: dict[str, Any] = {
|
||||
"normal_pct": 5, # <5% mortalitas dianggap normal
|
||||
"warning_pct": 7, # 5-7% perlu perhatian
|
||||
"critical_pct": 7, # >7% kritis
|
||||
# Standar kumulatif per umur (dari tabel harian lampiran 2)
|
||||
"byDayStandard": [
|
||||
{"day": d["day"], "mortalityCumStd_pct": d["mortalityCum_pct"]}
|
||||
for d in PERFORMANCE_STANDARD_DAILY
|
||||
],
|
||||
}
|
||||
|
||||
# ─── Kepadatan Kandang (Buku CP 707) ─────────────────────────────────────────
|
||||
DENSITY_STANDARD: dict[str, Any] = {
|
||||
"openHouse": {
|
||||
"minKgPerM2": 12,
|
||||
"maxKgPerM2": 13,
|
||||
"description": "Kandang terbuka dengan ventilasi alami",
|
||||
},
|
||||
"closedHouse": {
|
||||
"minKgPerM2": 24,
|
||||
"maxKgPerM2": 30,
|
||||
"description": "Kandang tertutup dapat mencapai 24-30 kg/m2",
|
||||
},
|
||||
"byHarvestWeight": [
|
||||
{"minBW_kg": 0.80, "maxBW_kg": 0.99, "density_ekorPerM2_min": 11.0, "density_ekorPerM2_max": 11.1},
|
||||
{"minBW_kg": 1.00, "maxBW_kg": 1.19, "density_ekorPerM2_min": 10.0, "density_ekorPerM2_max": 10.5},
|
||||
{"minBW_kg": 1.20, "maxBW_kg": 1.39, "density_ekorPerM2_min": 9.0, "density_ekorPerM2_max": 9.5},
|
||||
{"minBW_kg": 1.40, "maxBW_kg": 1.59, "density_ekorPerM2_min": 8.0, "density_ekorPerM2_max": 8.5},
|
||||
{"minBW_kg": 1.60, "maxBW_kg": 1.89, "density_ekorPerM2_min": 7.5, "density_ekorPerM2_max": 8.0},
|
||||
{"minBW_kg": 1.90, "maxBW_kg": 99, "density_ekorPerM2_min": 7.0, "density_ekorPerM2_max": 7.5},
|
||||
],
|
||||
}
|
||||
|
||||
# ─── Konsumsi Air per 1000 ekor per hari (suhu 21°C) ─────────────────────────
|
||||
WATER_CONSUMPTION_STANDARD: list[dict[str, Any]] = [
|
||||
{"week": 1, "minLiter": 58, "maxLiter": 65},
|
||||
{"week": 2, "minLiter": 102, "maxLiter": 115},
|
||||
{"week": 3, "minLiter": 149, "maxLiter": 167},
|
||||
{"week": 4, "minLiter": 192, "maxLiter": 216},
|
||||
{"week": 5, "minLiter": 232, "maxLiter": 261},
|
||||
{"week": 6, "minLiter": 274, "maxLiter": 308},
|
||||
{"week": 7, "minLiter": 309, "maxLiter": 347},
|
||||
{"week": 8, "minLiter": 342, "maxLiter": 385},
|
||||
]
|
||||
# Catatan: Di atas 21°C, kebutuhan air meningkat rata-rata 6.5% per kenaikan 1°C
|
||||
WATER_INCREASE_PER_DEGREE_ABOVE_21C_PCT = 6.5
|
||||
|
||||
# ─── Program Pencahayaan CP 707 ───────────────────────────────────────────────
|
||||
LIGHTING_PROGRAM: list[dict[str, Any]] = [
|
||||
{"ageDay_from": 2, "ageDay_to": 7, "onHours": 23, "darkFrom": "20:00", "darkTo": "21:00"},
|
||||
{"ageDay_from": 8, "ageDay_to": 14, "onHours": 22, "darkFrom": "20:00", "darkTo": "22:00"},
|
||||
{"ageDay_from": 15, "ageDay_to": 20, "onHours": 20, "darkFrom": "20:00", "darkTo": "23:00"},
|
||||
{"ageDay_from": 21, "ageDay_to": 28, "onHours": 20, "darkFrom": "20:00", "darkTo": "24:00"},
|
||||
{"ageDay_from": 29, "ageDay_to": 99, "onHours": 23, "darkFrom": "20:00", "darkTo": "22:00"},
|
||||
]
|
||||
LIGHTING_MIN_INTENSITY_LUX = 25
|
||||
LIGHTING_BROODING_OPTIMAL_LUX = "40-60"
|
||||
LIGHTING_AFTER_15DAYS_LUX = 5
|
||||
|
||||
|
||||
def _daily_row(day_age: Any) -> dict[str, Any] | None:
|
||||
try:
|
||||
day = int(day_age)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
for row in PERFORMANCE_STANDARD_DAILY:
|
||||
if row["day"] == day:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _as_float(value: Any) -> float | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
n = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if n != n: # NaN
|
||||
return None
|
||||
return n
|
||||
|
||||
|
||||
def _direction_from_delta(delta_pct: float, *, band: float = 5.0) -> str:
|
||||
if delta_pct > band:
|
||||
return "di_atas_standar"
|
||||
if delta_pct < -band:
|
||||
return "di_bawah_standar"
|
||||
return "sesuai_standar"
|
||||
|
||||
|
||||
def get_fcr_standard_by_day(day_age: Any) -> float | None:
|
||||
std = _daily_row(day_age)
|
||||
if std:
|
||||
return float(std["fcr"])
|
||||
try:
|
||||
day = int(day_age)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if day > 37:
|
||||
return 1.65
|
||||
return None
|
||||
|
||||
|
||||
def get_bw_standard_by_day(day_age: Any) -> float | None:
|
||||
std = _daily_row(day_age)
|
||||
if std:
|
||||
return float(std["bw_g"])
|
||||
try:
|
||||
day = int(day_age)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if day > 37:
|
||||
return 2500.0
|
||||
return None
|
||||
|
||||
|
||||
def get_temp_standard_by_day(day_age: Any) -> dict[str, Any]:
|
||||
try:
|
||||
day = int(day_age)
|
||||
except (TypeError, ValueError):
|
||||
return dict(TEMPERATURE_STANDARD[-1])
|
||||
for row in TEMPERATURE_STANDARD:
|
||||
if row["ageDay_from"] <= day <= row["ageDay_to"]:
|
||||
return dict(row)
|
||||
return dict(TEMPERATURE_STANDARD[-1])
|
||||
|
||||
|
||||
def get_tet_by_day(day_age: Any) -> float:
|
||||
try:
|
||||
day = int(day_age)
|
||||
except (TypeError, ValueError):
|
||||
return 21.0
|
||||
for row in TARGET_EFFECTIVE_TEMPERATURE:
|
||||
if row["ageDay_from"] <= day <= row["ageDay_to"]:
|
||||
return float(row["tet_C"])
|
||||
return 21.0
|
||||
|
||||
|
||||
def get_ip_standard_by_day(day_age: Any) -> int | None:
|
||||
"""
|
||||
Standar IP (Indeks Performans / EEF) menurut Lampiran 2 buku CP 707.
|
||||
|
||||
Kolom IP di tabel buku hanya terisi mulai hari ke-7 dan berhenti di hari
|
||||
ke-37 (nilai 380). Di luar rentang itu buku TIDAK menyatakan standar, jadi
|
||||
fungsi ini mengembalikan None — jangan menggantinya dengan tebakan.
|
||||
"""
|
||||
std = _daily_row(day_age)
|
||||
if std is None:
|
||||
return None
|
||||
ip = std.get("ip")
|
||||
return int(ip) if isinstance(ip, (int, float)) else None
|
||||
|
||||
|
||||
def get_ip_standard_range() -> dict[str, int] | None:
|
||||
"""Rentang IP yang tercantum di buku, untuk konteks saat hari di luar tabel."""
|
||||
values = [int(d["ip"]) for d in PERFORMANCE_STANDARD_DAILY if isinstance(d.get("ip"), (int, float))]
|
||||
if not values:
|
||||
return None
|
||||
return {"min": min(values), "max": max(values), "lastDay": 37}
|
||||
|
||||
|
||||
def get_mortality_cum_std_by_day(day_age: Any) -> float | None:
|
||||
std = _daily_row(day_age)
|
||||
if std:
|
||||
return float(std["mortalityCum_pct"])
|
||||
try:
|
||||
day = int(day_age)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if day > 37:
|
||||
return 4.65 + ((day - 37) * 0.1)
|
||||
return None
|
||||
|
||||
|
||||
def analyze_fcr(actual_fcr: Any, day_age: Any) -> dict[str, Any]:
|
||||
actual = _as_float(actual_fcr)
|
||||
std_fcr = get_fcr_standard_by_day(day_age)
|
||||
if actual is None or std_fcr is None:
|
||||
return {
|
||||
"actual": actual,
|
||||
"standard": std_fcr,
|
||||
"delta_pct": None,
|
||||
"direction": "unknown",
|
||||
"status": "unknown",
|
||||
"message": "Umur di luar standar tabel CP 707." if std_fcr is None else "FCR aktual tidak tersedia.",
|
||||
}
|
||||
|
||||
delta_pct = round(((actual - std_fcr) / std_fcr) * 100, 1)
|
||||
direction = _direction_from_delta(delta_pct)
|
||||
|
||||
if delta_pct > 10:
|
||||
status = "critical"
|
||||
message = (
|
||||
f"FCR {actual:.2f} melebihi standar CP 707 ({std_fcr:.3f}) sebesar {delta_pct:.1f}% "
|
||||
f"pada umur hari ke-{day_age}. Periksa potensi pakan tercecer dan kualitas pakan."
|
||||
)
|
||||
elif delta_pct > 5:
|
||||
status = "warning"
|
||||
message = (
|
||||
f"FCR {actual:.2f} sedikit di atas standar CP 707 ({std_fcr:.3f}) "
|
||||
f"pada umur hari ke-{day_age}. Atur ketinggian piringan pakan dan evaluasi fines dalam pakan."
|
||||
)
|
||||
elif delta_pct < -5:
|
||||
status = "ok"
|
||||
message = (
|
||||
f"FCR {actual:.2f} lebih baik dari standar CP 707 ({std_fcr:.3f}) "
|
||||
f"pada umur hari ke-{day_age}. Pertahankan manajemen pakan."
|
||||
)
|
||||
else:
|
||||
status = "ok"
|
||||
message = f"FCR {actual:.2f} sesuai standar CP 707 ({std_fcr:.3f}) pada umur hari ke-{day_age}."
|
||||
|
||||
return {
|
||||
"actual": actual,
|
||||
"standard": std_fcr,
|
||||
"delta_pct": float(delta_pct),
|
||||
"direction": direction,
|
||||
"status": status,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
def analyze_bw(actual_bw_g: Any, day_age: Any) -> dict[str, Any]:
|
||||
actual = _as_float(actual_bw_g)
|
||||
std_bw = get_bw_standard_by_day(day_age)
|
||||
if actual is None or std_bw is None:
|
||||
return {
|
||||
"actual": actual,
|
||||
"standard": std_bw,
|
||||
"delta_pct": None,
|
||||
"direction": "unknown",
|
||||
"status": "unknown",
|
||||
"message": "Umur di luar standar tabel CP 707." if std_bw is None else "Bobot aktual tidak tersedia.",
|
||||
}
|
||||
|
||||
delta_pct = round(((actual - std_bw) / std_bw) * 100, 1)
|
||||
direction = _direction_from_delta(delta_pct)
|
||||
|
||||
if delta_pct < -15:
|
||||
status = "critical"
|
||||
message = (
|
||||
f"Bobot badan aktual {actual:g}g jauh di bawah standar CP 707 ({std_bw:g}g) "
|
||||
f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Lakukan grading segera."
|
||||
)
|
||||
elif delta_pct < -5:
|
||||
status = "warning"
|
||||
message = (
|
||||
f"Bobot badan aktual {actual:g}g sedikit di bawah standar CP 707 ({std_bw:g}g) "
|
||||
f"pada hari ke-{day_age}. Deviasi {abs(delta_pct):.1f}%. Tingkatkan stimulasi pakan."
|
||||
)
|
||||
elif delta_pct > 10:
|
||||
status = "ok"
|
||||
message = (
|
||||
f"Bobot badan aktual {actual:g}g melampaui standar CP 707 ({std_bw:g}g) "
|
||||
f"pada hari ke-{day_age}. Pertumbuhan sangat baik."
|
||||
)
|
||||
else:
|
||||
status = "ok"
|
||||
message = f"Bobot badan aktual {actual:g}g sesuai standar CP 707 ({std_bw:g}g) pada hari ke-{day_age}."
|
||||
|
||||
return {
|
||||
"actual": actual,
|
||||
"standard": std_bw,
|
||||
"delta_pct": float(delta_pct),
|
||||
"direction": direction,
|
||||
"status": status,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
def analyze_mortality(mortality_rate_pct: Any, day_age: Any) -> dict[str, Any]:
|
||||
rate = _as_float(mortality_rate_pct)
|
||||
std_mortality = get_mortality_cum_std_by_day(day_age)
|
||||
|
||||
if rate is None:
|
||||
return {
|
||||
"status": "unknown",
|
||||
"direction": "unknown",
|
||||
"stdMortality": std_mortality,
|
||||
"message": "Data mortalitas tidak tersedia.",
|
||||
}
|
||||
|
||||
if rate > MORTALITY_THRESHOLDS["critical_pct"]:
|
||||
status = "critical"
|
||||
direction = "di_atas_standar"
|
||||
message = (
|
||||
f"Mortalitas kumulatif {rate:.2f}% melebihi batas kritis CP 707 (>7%). "
|
||||
"Lakukan nekropsi darurat dan perketat biosekuriti."
|
||||
)
|
||||
elif rate >= MORTALITY_THRESHOLDS["warning_pct"]:
|
||||
status = "warning"
|
||||
direction = "di_atas_standar"
|
||||
message = f"Mortalitas kumulatif {rate:.2f}% perlu diwaspadai. Standar CP 707 <5%."
|
||||
elif std_mortality is not None and rate > std_mortality * 1.5:
|
||||
status = "warning"
|
||||
direction = "di_atas_standar"
|
||||
message = (
|
||||
f"Mortalitas {rate:.2f}% melebihi standar kumulatif harian CP 707 "
|
||||
f"({std_mortality:.2f}%) pada hari ke-{day_age}."
|
||||
)
|
||||
else:
|
||||
status = "ok"
|
||||
direction = "sesuai_standar" if (std_mortality is None or rate <= std_mortality) else "di_atas_standar"
|
||||
message = f"Mortalitas {rate:.2f}% dalam batas normal CP 707 (<5%)."
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"direction": direction,
|
||||
"stdMortality": std_mortality,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
def analyze_environment(
|
||||
temp_c: Any,
|
||||
humidity_pct: Any,
|
||||
ammonia_ppm: Any,
|
||||
day_age: Any,
|
||||
) -> dict[str, Any]:
|
||||
temp = _as_float(temp_c)
|
||||
humidity = _as_float(humidity_pct)
|
||||
ammonia = _as_float(ammonia_ppm)
|
||||
|
||||
if temp is None:
|
||||
return {
|
||||
"status": "unknown",
|
||||
"direction": "unknown",
|
||||
"issues": [],
|
||||
"tempStd": get_temp_standard_by_day(day_age),
|
||||
"tet": get_tet_by_day(day_age),
|
||||
"message": "Data suhu tidak tersedia.",
|
||||
}
|
||||
|
||||
if humidity is None:
|
||||
humidity = 65.0
|
||||
if ammonia is None:
|
||||
ammonia = 0.0
|
||||
|
||||
temp_std = get_temp_standard_by_day(day_age)
|
||||
tet = get_tet_by_day(day_age)
|
||||
issues: list[str] = []
|
||||
overall_status = "ok"
|
||||
|
||||
if temp > temp_std["temp_C"] + 4:
|
||||
issues.append(
|
||||
f"Suhu {temp:.1f}°C jauh di atas target CP 707 ({temp_std['temp_C']}°C) "
|
||||
f"untuk umur hari ke-{day_age}. Nyalakan cooling pad/exhaust fan segera."
|
||||
)
|
||||
overall_status = "critical"
|
||||
elif temp > temp_std["temp_C"] + 2:
|
||||
issues.append(
|
||||
f"Suhu {temp:.1f}°C di atas target CP 707 ({temp_std['temp_C']}°C) "
|
||||
f"untuk umur hari ke-{day_age}. Tingkatkan ventilasi."
|
||||
)
|
||||
if overall_status != "critical":
|
||||
overall_status = "warning"
|
||||
elif temp < temp_std["temp_C"] - 3:
|
||||
issues.append(
|
||||
f"Suhu {temp:.1f}°C di bawah target CP 707 ({temp_std['temp_C']}°C). Nyalakan heater."
|
||||
)
|
||||
if overall_status != "critical":
|
||||
overall_status = "warning"
|
||||
|
||||
if humidity < temp_std["humidity_pct_min"]:
|
||||
issues.append(
|
||||
f"Kelembapan {humidity:.1f}% di bawah standar CP 707 "
|
||||
f"({temp_std['humidity_pct_min']}-{temp_std['humidity_pct_max']}%)."
|
||||
)
|
||||
if overall_status != "critical":
|
||||
overall_status = "warning"
|
||||
elif humidity > temp_std["humidity_pct_max"]:
|
||||
issues.append(
|
||||
f"Kelembapan {humidity:.1f}% di atas standar CP 707 "
|
||||
f"({temp_std['humidity_pct_min']}-{temp_std['humidity_pct_max']}%). "
|
||||
"Periksa kebocoran nipple dan sekam basah."
|
||||
)
|
||||
if overall_status != "critical":
|
||||
overall_status = "warning"
|
||||
|
||||
if ammonia > AIR_QUALITY_STANDARD["ammonia"]["critical"]:
|
||||
issues.append(
|
||||
f"Amonia {ammonia:.1f} ppm di atas batas kritis CP 707 (>25 ppm). "
|
||||
"Pertumbuhan ayam menurun. Tingkatkan ventilasi segera dan gemburkan sekam basah."
|
||||
)
|
||||
overall_status = "critical"
|
||||
elif ammonia > AIR_QUALITY_STANDARD["ammonia"]["warning"]:
|
||||
issues.append(
|
||||
f"Amonia {ammonia:.1f} ppm melebihi batas aman CP 707 (<10 ppm). Tingkatkan sirkulasi udara."
|
||||
)
|
||||
if overall_status != "critical":
|
||||
overall_status = "warning"
|
||||
|
||||
return {
|
||||
"status": overall_status,
|
||||
"direction": "sesuai_standar" if overall_status == "ok" else "di_atas_standar",
|
||||
"issues": issues,
|
||||
"tempStd": temp_std,
|
||||
"tet": tet,
|
||||
"message": (
|
||||
" ".join(issues)
|
||||
if issues
|
||||
else f"Lingkungan kandang sesuai standar CP 707 untuk umur hari ke-{day_age}."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_book_reference_context(day_age: Any) -> str:
|
||||
std_bw = get_bw_standard_by_day(day_age)
|
||||
std_fcr = get_fcr_standard_by_day(day_age)
|
||||
std_mortality = get_mortality_cum_std_by_day(day_age)
|
||||
temp_std = get_temp_standard_by_day(day_age)
|
||||
tet = get_tet_by_day(day_age)
|
||||
|
||||
return f"""
|
||||
=== REFERENSI STANDAR BUKU CP 707 (SUMBER TUNGGAL - PT CHAROEN POKPHAND INDONESIA) ===
|
||||
Umur ayam hari ke-{day_age}:
|
||||
- Target bobot badan: {std_bw if std_bw is not None else 'N/A'} gram
|
||||
- Target FCR: {std_fcr if std_fcr is not None else 'N/A'}
|
||||
- Target mortalitas kumulatif normal: <5% (standar CP 707)
|
||||
- Target mortalitas kumulatif standar hari ke-{day_age}: {str(std_mortality) + '%' if std_mortality is not None else 'N/A'}
|
||||
- Target suhu kandang: {temp_std['temp_C']}°C
|
||||
- Target kelembapan: {temp_std['humidity_pct_min']}%-{temp_std['humidity_pct_max']}%
|
||||
- Target Efektif Temperatur (TET): {tet}°C
|
||||
- Batas amonia aman: <10 ppm (ideal), >25 ppm = pertumbuhan menurun (dari Lampiran 3 CP 707)
|
||||
- Batas CO2 aman: <3000 ppm (>3500 ppm = ascites & kematian tinggi)
|
||||
- Konsumsi air normal: 2-2.5x konsumsi pakan
|
||||
|
||||
CATATAN PENTING:
|
||||
- Analisis HANYA berdasarkan standar buku CP 707
|
||||
- Jika ada data yang tidak ada di buku CP 707, nyatakan "data tidak tersedia di buku CP 707"
|
||||
- Risiko yang disebutkan HARUS berdasarkan data aktual vs standar CP 707
|
||||
=== END REFERENSI ===
|
||||
"""
|
||||
|
||||
|
||||
def build_cp707_standard_block(context_data: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
Blok standar CP 707 berlabel satuan untuk hari yang sedang dilihat
|
||||
(setara buildCp707StandardBlock di aiInsights.js lama).
|
||||
"""
|
||||
if not isinstance(context_data, dict):
|
||||
return ""
|
||||
|
||||
raw = None
|
||||
for key in ("hari_ke", "hari_terakhir", "currentDay", "dayAge"):
|
||||
if context_data.get(key) is not None:
|
||||
raw = context_data.get(key)
|
||||
break
|
||||
day_f = _as_float(raw)
|
||||
if day_f is None or day_f <= 0:
|
||||
return ""
|
||||
day = int(day_f)
|
||||
|
||||
ip = get_ip_standard_by_day(day)
|
||||
ip_range = get_ip_standard_range()
|
||||
bw = get_bw_standard_by_day(day)
|
||||
fcr = get_fcr_standard_by_day(day)
|
||||
mort = get_mortality_cum_std_by_day(day)
|
||||
|
||||
lines = [
|
||||
f"- Bobot badan standar hari ke-{day}: {str(bw) + ' gram' if bw is not None else 'tidak tercantum di buku'}",
|
||||
f"- FCR standar hari ke-{day}: {str(fcr) + ' (rasio, tanpa satuan)' if fcr is not None else 'tidak tercantum di buku'}",
|
||||
f"- Mortalitas kumulatif standar hari ke-{day}: {str(mort) + ' %' if mort is not None else 'tidak tercantum di buku'}",
|
||||
]
|
||||
if mort is not None:
|
||||
hidup = round((100 - mort) * 100) / 100
|
||||
lines.append(
|
||||
f"- Persen hidup standar hari ke-{day}: {hidup} % "
|
||||
"(turunan langsung dari mortalitas standar di atas — pakai angka ini, jangan menghitung sendiri)"
|
||||
)
|
||||
else:
|
||||
lines.append(f"- Persen hidup standar hari ke-{day}: tidak tersedia")
|
||||
|
||||
if ip is not None:
|
||||
lines.append(f"- IP/EEF standar hari ke-{day}: {ip} (indeks, TANPA satuan)")
|
||||
else:
|
||||
last_day = ip_range["lastDay"] if ip_range else 37
|
||||
rng = f"{ip_range['min']}-{ip_range['max']}" if ip_range else "327-380"
|
||||
lines.append(
|
||||
f"- IP/EEF standar hari ke-{day}: TIDAK tercantum di buku. "
|
||||
f"Kolom IP pada Lampiran 2 hanya terisi hari ke-7 s/d ke-{last_day} dengan rentang {rng}. "
|
||||
"Jangan mengarang standar untuk hari ini."
|
||||
)
|
||||
|
||||
joined = "\n".join(lines)
|
||||
return f"""
|
||||
═══════════════════════════════════════════════
|
||||
STANDAR CP 707 UNTUK HARI INI (angka resmi dari Lampiran 2 buku)
|
||||
{joined}
|
||||
|
||||
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.
|
||||
═══════════════════════════════════════════════"""
|
||||
|
||||
|
||||
def _nested_get(obj: Any, *path: str) -> Any:
|
||||
cur = obj
|
||||
for key in path:
|
||||
if not isinstance(cur, dict):
|
||||
return None
|
||||
cur = cur.get(key)
|
||||
return cur
|
||||
|
||||
|
||||
def _first_number(*candidates: Any) -> float | None:
|
||||
for value in candidates:
|
||||
n = _as_float(value)
|
||||
if n is not None:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_day_age(context_pack: dict[str, Any]) -> int | None:
|
||||
day = _first_number(
|
||||
context_pack.get("hari_ke"),
|
||||
_nested_get(context_pack, "metadata", "currentDay"),
|
||||
_nested_get(context_pack, "cycle", "currentAgeDay"),
|
||||
context_pack.get("ageDay"),
|
||||
context_pack.get("umurHari"),
|
||||
context_pack.get("currentDay"),
|
||||
context_pack.get("dayAge"),
|
||||
)
|
||||
if day is None or day <= 0:
|
||||
return None
|
||||
return int(day)
|
||||
|
||||
|
||||
def _resolve_bw_grams(context_pack: dict[str, Any]) -> float | None:
|
||||
"""Bobot dalam gram. Field `_kg` hanya untuk legacy payload (dikonversi ×1000)."""
|
||||
grams = _first_number(
|
||||
context_pack.get("bobot_rata_rata_gram"),
|
||||
context_pack.get("bobot_iot_gram_terakhir"),
|
||||
context_pack.get("bobot_iot_gram"),
|
||||
_nested_get(context_pack, "weightStats", "averageWeight", "value"),
|
||||
_nested_get(context_pack, "weight", "current", "averageWeight"),
|
||||
context_pack.get("berat_rata_rata"),
|
||||
)
|
||||
if grams is not None:
|
||||
return grams
|
||||
|
||||
kg = _first_number(
|
||||
context_pack.get("bobot_iot_kg_terakhir"),
|
||||
context_pack.get("bobot_iot_kg"),
|
||||
)
|
||||
if kg is not None:
|
||||
return kg * 1000.0
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_mortality(context_pack: dict[str, Any]) -> float | None:
|
||||
return _first_number(
|
||||
context_pack.get("mortalitas_kumulatif_pct"),
|
||||
context_pack.get("mortalitas_persen"),
|
||||
context_pack.get("mortalityRate"),
|
||||
context_pack.get("mortality_rate_pct"),
|
||||
_nested_get(context_pack, "chickenCounting", "dashboardSummary", "mortalityRate"),
|
||||
_nested_get(context_pack, "chickenCounting", "mortality", "mortalityCount"),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_iot(context_pack: dict[str, Any]) -> dict[str, Any]:
|
||||
for key in ("iotPanel", "panel_iot", "telemetry", "iotData", "iot"):
|
||||
raw = context_pack.get(key)
|
||||
if isinstance(raw, dict):
|
||||
display = raw.get("display")
|
||||
if isinstance(display, dict):
|
||||
return display
|
||||
return raw
|
||||
# Flat InsightContext fields (rebuild)
|
||||
return context_pack
|
||||
|
||||
|
||||
def analyze_with_cp707_standards(context_pack: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Analisis komprehensif; toleran terhadap field InsightContext rebuild dan pack lama."""
|
||||
pack = context_pack if isinstance(context_pack, dict) else {}
|
||||
day_age = _resolve_day_age(pack)
|
||||
result: dict[str, Any] = {"dayAge": day_age, "analyses": {}}
|
||||
|
||||
actual_bw = _resolve_bw_grams(pack)
|
||||
if actual_bw is not None and day_age is not None:
|
||||
result["analyses"]["bw"] = analyze_bw(actual_bw, day_age)
|
||||
|
||||
actual_fcr = _first_number(
|
||||
pack.get("fcr_terakhir"),
|
||||
pack.get("fcr"),
|
||||
_nested_get(pack, "fcr_eef", "fcr", "actual"),
|
||||
_nested_get(pack, "weight", "current", "fcr"),
|
||||
_nested_get(pack, "fcrSummary", "current"),
|
||||
)
|
||||
if actual_fcr is not None and day_age is not None:
|
||||
result["analyses"]["fcr"] = analyze_fcr(actual_fcr, day_age)
|
||||
|
||||
mortality_rate = _resolve_mortality(pack)
|
||||
if mortality_rate is not None:
|
||||
result["analyses"]["mortality"] = analyze_mortality(mortality_rate, day_age)
|
||||
|
||||
iot = _resolve_iot(pack)
|
||||
temp = _first_number(
|
||||
iot.get("suhu_rata_rata_C"),
|
||||
iot.get("avgTemp"),
|
||||
pack.get("suhu_rata_rata_C"),
|
||||
)
|
||||
hum = _first_number(
|
||||
iot.get("kelembapan_persen"),
|
||||
iot.get("humidity"),
|
||||
pack.get("kelembapan_persen"),
|
||||
)
|
||||
ammonia = _first_number(
|
||||
iot.get("amonia_ppm"),
|
||||
iot.get("ammonia"),
|
||||
pack.get("amonia_ppm"),
|
||||
)
|
||||
if temp is not None and day_age is not None:
|
||||
result["analyses"]["environment"] = analyze_environment(
|
||||
temp,
|
||||
hum if hum is not None else 65.0,
|
||||
ammonia if ammonia is not None else 0.0,
|
||||
day_age,
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,525 @@
|
||||
"""Unified AI Insight generate pipeline: grade → RAG → narrate → cache.
|
||||
|
||||
Called from `AIInsightViewSet` (`POST generate/`, `GET cached/`).
|
||||
Numbers/status come from `cp707_knowledge`; Ollama only narrates.
|
||||
Architecture map: docs/ai-insight/README.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.farms.models import Cycle, Kandang
|
||||
from apps.operations.models import AIInsight
|
||||
from apps.operations.services import cp707_knowledge as cp707
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_NUMERIC_PIPE_ROW = re.compile(r"^[\d.,]+(\s*\|\s*[\d.,]*)+$")
|
||||
|
||||
# Stored in AIInsight.alert — condition of graded data, not narrative text.
|
||||
ALERT_HEALTHY = "healthy"
|
||||
ALERT_WARNING = "warning"
|
||||
ALERT_CRITICAL = "critical"
|
||||
ALERT_UNKNOWN = "unknown"
|
||||
_ALERT_RANK = {
|
||||
ALERT_UNKNOWN: 0,
|
||||
ALERT_HEALTHY: 1,
|
||||
ALERT_WARNING: 2,
|
||||
ALERT_CRITICAL: 3,
|
||||
}
|
||||
_GRADER_STATUS_TO_ALERT = {
|
||||
"ok": ALERT_HEALTHY,
|
||||
"healthy": ALERT_HEALTHY,
|
||||
"warning": ALERT_WARNING,
|
||||
"critical": ALERT_CRITICAL,
|
||||
"unknown": ALERT_UNKNOWN,
|
||||
}
|
||||
|
||||
|
||||
def alert_from_graded(graded: dict[str, Any] | None) -> str:
|
||||
"""Worst condition among graded analysis blocks (critical > warning > healthy)."""
|
||||
analyses = (graded or {}).get("analyses") or {}
|
||||
if not isinstance(analyses, dict) or not analyses:
|
||||
return ALERT_UNKNOWN
|
||||
|
||||
worst = ALERT_UNKNOWN
|
||||
saw_status = False
|
||||
for block in analyses.values():
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
raw = block.get("status")
|
||||
if raw is None:
|
||||
continue
|
||||
mapped = _GRADER_STATUS_TO_ALERT.get(str(raw).strip().lower())
|
||||
if mapped is None:
|
||||
continue
|
||||
saw_status = True
|
||||
if _ALERT_RANK[mapped] > _ALERT_RANK[worst]:
|
||||
worst = mapped
|
||||
return worst if saw_status else ALERT_UNKNOWN
|
||||
|
||||
ANTI_HALLUCINATION_RULES = """
|
||||
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 & ARAH (WAJIB DIPATUHI):
|
||||
- Setiap angka yang Anda tulis HARUS ada di [Data Halaman (JSON)] atau di blok STANDAR CP 707
|
||||
atau di [GRADED FACTS]. DILARANG menghitung sendiri, memperkirakan, atau membalik arah tren.
|
||||
- Jika [GRADED FACTS] menyatakan direction/status, ULANGI arah itu — jangan dibalik.
|
||||
- SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD: _gram, _persen, _rasio, _karung, _ekor, _kg,
|
||||
_hari, _indeksTanpaSatuan. Pakai satuan itu persis.
|
||||
- Field yang berisi "tidak tersedia" atau null memang tidak ada datanya. Tulis "data tidak tersedia"
|
||||
dan JANGAN mengarang angkanya.
|
||||
- Status topik tanpa data = unknown, bukan ok.
|
||||
- Panen ≠ kematian; jangan hitung mortalitas dari selisih populasi awal − kini.
|
||||
|
||||
FORMAT OUTPUT (JSON SAJA):
|
||||
{"kesimpulan":"...","insight":"..."}
|
||||
"""
|
||||
|
||||
|
||||
def strip_headerless_tables(chunk: str) -> tuple[str, int]:
|
||||
lines = str(chunk or "").split("\n")
|
||||
removed = 0
|
||||
kept: list[str] = []
|
||||
for line in lines:
|
||||
trimmed = line.strip()
|
||||
if trimmed and _NUMERIC_PIPE_ROW.match(trimmed):
|
||||
removed += 1
|
||||
continue
|
||||
kept.append(line)
|
||||
if removed == 0:
|
||||
return chunk, 0
|
||||
text = (
|
||||
"\n".join(kept).strip()
|
||||
+ "\n[Tabel angka tanpa judul kolom dihapus dari kutipan ini karena tidak dapat "
|
||||
"dibaca dengan benar. Gunakan blok STANDAR CP 707 / GRADED FACTS untuk angka standar.]"
|
||||
)
|
||||
return text, removed
|
||||
|
||||
|
||||
def _day_age(context: dict[str, Any]) -> int | None:
|
||||
raw = (
|
||||
context.get("hari_ke")
|
||||
or context.get("hari_terakhir")
|
||||
or context.get("currentDay")
|
||||
or context.get("dayAge")
|
||||
)
|
||||
try:
|
||||
day = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return day if day > 0 else None
|
||||
|
||||
|
||||
def prune_context_by_period(context: dict[str, Any], report_type: str, report_period: str) -> dict[str, Any]:
|
||||
"""Light prune for history arrays; keep scalars. Full FE scope happens client-side."""
|
||||
out = dict(context)
|
||||
if report_type == "end_cycle":
|
||||
# Deterministic weekly KPI rollup when weekly_summaries absent.
|
||||
if "weekly_summaries" not in out:
|
||||
out["weekly_summaries"] = _build_weekly_summaries(out)
|
||||
out["catatan_end_cycle"] = (
|
||||
"Ringkasan KPI per minggu dihitung di backend. Narasikan dari weekly_summaries + graded_facts; "
|
||||
"jangan menghitung ulang."
|
||||
)
|
||||
out["report_type"] = report_type
|
||||
out["report_period"] = report_period
|
||||
return out
|
||||
|
||||
|
||||
def _build_weekly_summaries(context: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
histories = []
|
||||
for key in ("tren_fcr_harian", "tren_harian", "tren_7_hari_terakhir", "history"):
|
||||
val = context.get(key)
|
||||
if isinstance(val, list) and val:
|
||||
histories = val
|
||||
break
|
||||
by_week: dict[int, list[dict[str, Any]]] = {}
|
||||
for row in histories:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
day = row.get("hari") or row.get("day")
|
||||
try:
|
||||
day_i = int(day)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
week = max(1, (day_i + 6) // 7)
|
||||
by_week.setdefault(week, []).append(row)
|
||||
|
||||
summaries = []
|
||||
for week, rows in sorted(by_week.items()):
|
||||
fcrs = [r.get("fcr_aktual") or r.get("fcr") for r in rows if isinstance(r.get("fcr_aktual") or r.get("fcr"), (int, float))]
|
||||
summaries.append(
|
||||
{
|
||||
"minggu_ke": week,
|
||||
"jumlah_hari_data": len(rows),
|
||||
"fcr_rata_rasio": round(sum(fcrs) / len(fcrs), 3) if fcrs else None,
|
||||
"fcr_akhir_rasio": fcrs[-1] if fcrs else None,
|
||||
}
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def grade_context(context: dict[str, Any]) -> dict[str, Any]:
|
||||
analysis = cp707.analyze_with_cp707_standards(context)
|
||||
graded: dict[str, Any] = {
|
||||
"kandangId": context.get("kandangId") or context.get("kandang_id"),
|
||||
"hari_ke": analysis.get("dayAge") or _day_age(context),
|
||||
"analyses": analysis.get("analyses") or {},
|
||||
}
|
||||
# Normalize analyzer outputs into explicit direction blocks for the prompt.
|
||||
for key, block in list(graded["analyses"].items()):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if "direction" not in block and block.get("deviation") is not None:
|
||||
try:
|
||||
dev = float(block["deviation"])
|
||||
if abs(dev) <= 5:
|
||||
block["direction"] = "sesuai_standar"
|
||||
elif key == "fcr":
|
||||
block["direction"] = "di_atas_standar" if dev > 0 else "di_bawah_standar"
|
||||
else:
|
||||
block["direction"] = "di_atas_standar" if dev > 0 else "di_bawah_standar"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return graded
|
||||
|
||||
|
||||
def fetch_rag_chunks(query: str, topic: str, n_results: int = 4) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
base = getattr(settings, "RAG_SERVICE_URL", "") or ""
|
||||
if not base:
|
||||
return [], []
|
||||
url = f"{base.rstrip('/')}/query"
|
||||
try:
|
||||
with httpx.Client(timeout=8.0) as client:
|
||||
resp = client.post(
|
||||
url,
|
||||
json={"query": query, "topic": topic or "", "n_results": n_results, "tipe": "prosa"},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("RAG query HTTP %s", resp.status_code)
|
||||
return [], []
|
||||
data = resp.json()
|
||||
chunks = data.get("chunks") or []
|
||||
metas = data.get("metadatas") or []
|
||||
sources = data.get("sources") or []
|
||||
citations = []
|
||||
cleaned = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
text, _ = strip_headerless_tables(chunk)
|
||||
if text.strip():
|
||||
cleaned.append(text)
|
||||
meta = metas[i] if i < len(metas) else {}
|
||||
citations.append(
|
||||
{
|
||||
"source": (meta or {}).get("source") or (sources[i] if i < len(sources) else "cp707"),
|
||||
"bab": (meta or {}).get("bab") or "",
|
||||
"chunk_id": (meta or {}).get("chunk_index"),
|
||||
"tipe": (meta or {}).get("tipe") or "prosa",
|
||||
"excerpt": text[:240],
|
||||
}
|
||||
)
|
||||
return cleaned, citations
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("RAG unavailable: %s", exc)
|
||||
return [], []
|
||||
|
||||
|
||||
def call_ollama(system_prompt: str, user_prompt: str) -> str | None:
|
||||
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"
|
||||
timeout = float(getattr(settings, "LLM_TIMEOUT_SECONDS", 1200) or 1200)
|
||||
payload = {
|
||||
"model": model,
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.2},
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
}
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
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
|
||||
data = resp.json()
|
||||
message = data.get("message") or {}
|
||||
return message.get("content") or data.get("response")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Ollama call failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def parse_llm_json(text: str) -> dict[str, str] | None:
|
||||
if not text:
|
||||
return None
|
||||
cleaned = re.sub(r"<think>[\s\S]*?</think>", "", text, flags=re.I)
|
||||
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned.strip(), flags=re.I)
|
||||
cleaned = re.sub(r"\s*```\s*$", "", cleaned)
|
||||
first = cleaned.find("{")
|
||||
last = cleaned.rfind("}")
|
||||
if first < 0 or last <= first:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(cleaned[first : last + 1])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
kesimpulan = (
|
||||
parsed.get("kesimpulan")
|
||||
or parsed.get("ringkasan")
|
||||
or parsed.get("summary")
|
||||
or ""
|
||||
)
|
||||
insight = (
|
||||
parsed.get("insight")
|
||||
or parsed.get("rekomendasi")
|
||||
or parsed.get("insights")
|
||||
or ""
|
||||
)
|
||||
if not kesimpulan and not insight:
|
||||
return None
|
||||
return {
|
||||
"kesimpulan": str(kesimpulan) or "Model tidak mengembalikan kesimpulan eksplisit.",
|
||||
"insight": str(insight) or "Model tidak mengembalikan rekomendasi eksplisit.",
|
||||
}
|
||||
|
||||
|
||||
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],
|
||||
}
|
||||
|
||||
|
||||
def lookup_cached(
|
||||
*,
|
||||
cycle_id: int,
|
||||
kandang_id: int,
|
||||
topic: str,
|
||||
report_type: str,
|
||||
report_period: str,
|
||||
) -> AIInsight | None:
|
||||
return (
|
||||
AIInsight.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
kandang_id=kandang_id,
|
||||
topic=topic,
|
||||
report_type=report_type,
|
||||
report_period=report_period or "current",
|
||||
)
|
||||
.order_by("-updated_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_cached_insight(
|
||||
*,
|
||||
cycle_id: int,
|
||||
kandang_id: int,
|
||||
topic: str,
|
||||
report_type: str = "page",
|
||||
report_period: str = "current",
|
||||
) -> dict[str, Any] | None:
|
||||
row = (
|
||||
AIInsight.objects.filter(
|
||||
cycle_id=cycle_id,
|
||||
kandang_id=kandang_id,
|
||||
topic=topic,
|
||||
report_type=report_type,
|
||||
report_period=report_period or "current",
|
||||
)
|
||||
.order_by("-updated_at")
|
||||
.first()
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return serialize_insight(row, source_override=AIInsight.SOURCE_CACHE)
|
||||
|
||||
|
||||
def encode_citations(citations: list[Any] | None) -> str:
|
||||
"""Persist citations as a JSON array string in TEXT column."""
|
||||
if not citations:
|
||||
return "[]"
|
||||
return json.dumps(citations, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def decode_citations(raw: Any) -> list[dict[str, Any]]:
|
||||
"""Load citations from TEXT (JSON string) or legacy list."""
|
||||
if raw is None or raw == "":
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
data = raw
|
||||
elif isinstance(raw, str):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
else:
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
# Normalize citation keys for FE (chapter alias).
|
||||
norm_citations: list[dict[str, Any]] = []
|
||||
for c in data:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
item = dict(c)
|
||||
if "chapter" not in item and item.get("bab"):
|
||||
item["chapter"] = item["bab"]
|
||||
norm_citations.append(item)
|
||||
return norm_citations
|
||||
|
||||
|
||||
def serialize_insight(row: AIInsight, source_override: str | None = None) -> dict[str, Any]:
|
||||
norm_citations = decode_citations(row.citations)
|
||||
return {
|
||||
"success": True,
|
||||
"id": row.pk,
|
||||
"cycle": row.cycle_id,
|
||||
"kandang": row.kandang_id,
|
||||
"topic": row.topic,
|
||||
"report_type": row.report_type,
|
||||
"report_period": row.report_period,
|
||||
"source": source_override or row.source or AIInsight.SOURCE_GENERATED,
|
||||
"summary": row.summary or "",
|
||||
"insight": row.insight_text or "",
|
||||
"insight_text": "\n\n".join(
|
||||
part for part in [row.summary or "", row.insight_text or ""] if part
|
||||
)
|
||||
or row.insight_text
|
||||
or "",
|
||||
"alert": row.alert or ALERT_UNKNOWN,
|
||||
"citations": norm_citations,
|
||||
"date": row.date.isoformat() if row.date else None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def generate_insight(
|
||||
*,
|
||||
cycle_id: int,
|
||||
kandang_id: int,
|
||||
topic: str,
|
||||
context: dict[str, Any],
|
||||
report_type: str = "page",
|
||||
report_period: str = "current",
|
||||
force_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not kandang_id:
|
||||
raise ValueError("kandang_id wajib — insight tidak boleh untuk semua kandang")
|
||||
if not cycle_id:
|
||||
raise ValueError("cycle_id wajib")
|
||||
if not topic:
|
||||
raise ValueError("topic wajib")
|
||||
|
||||
try:
|
||||
cycle = Cycle.objects.select_related("kandang").get(pk=cycle_id)
|
||||
except Cycle.DoesNotExist as exc:
|
||||
raise ValueError("Cycle not found") from exc
|
||||
try:
|
||||
kandang = Kandang.objects.get(pk=kandang_id)
|
||||
except Kandang.DoesNotExist as exc:
|
||||
raise ValueError("Kandang not found") from exc
|
||||
if cycle.kandang_id != kandang_id:
|
||||
raise ValueError("kandang_id tidak cocok dengan cycle")
|
||||
|
||||
report_period = report_period or "current"
|
||||
report_type = report_type or "page"
|
||||
|
||||
if not force_refresh:
|
||||
cached = get_cached_insight(
|
||||
cycle_id=cycle_id,
|
||||
kandang_id=kandang_id,
|
||||
topic=topic,
|
||||
report_type=report_type,
|
||||
report_period=report_period,
|
||||
)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
ctx = dict(context or {})
|
||||
ctx.setdefault("kandangId", kandang_id)
|
||||
ctx.setdefault("kandang_id", kandang_id)
|
||||
ctx.setdefault("kandangName", kandang.kandang_name)
|
||||
ctx.setdefault("cycleId", cycle_id)
|
||||
ctx = prune_context_by_period(ctx, report_type, report_period)
|
||||
|
||||
graded = grade_context(ctx)
|
||||
day = graded.get("hari_ke")
|
||||
standard_block = cp707.build_cp707_standard_block(ctx) or cp707.build_book_reference_context(day or 1)
|
||||
|
||||
rag_query = f"panduan manajemen broiler CP 707 untuk {topic} umur hari ke-{day or '?'}"
|
||||
chunks, citations = fetch_rag_chunks(rag_query, topic)
|
||||
|
||||
system_prompt = (
|
||||
"Anda adalah asisten farm broiler on-premise. Tugas Anda HANYA menulis narasi "
|
||||
"dari GRADED FACTS + standar CP 707 + cuplikan SOP. Jangan menghitung ulang.\n"
|
||||
f"{ANTI_HALLUCINATION_RULES}\n"
|
||||
f"{standard_block}\n"
|
||||
f"[GRADED FACTS]\n{json.dumps(graded, ensure_ascii=False, default=str)}\n"
|
||||
)
|
||||
if chunks:
|
||||
system_prompt += "\n[CUPLIKAN SOP CP 707 — prosa]\n" + "\n---\n".join(chunks[:4])
|
||||
|
||||
user_prompt = (
|
||||
f"Buat insight topik `{topic}` untuk kandang `{kandang.kandang_name}` "
|
||||
f"(id={kandang_id}), periode `{report_type}/{report_period}`.\n"
|
||||
f"[Data Halaman (JSON)]\n{json.dumps(ctx, ensure_ascii=False, default=str)}"
|
||||
)
|
||||
|
||||
raw = call_ollama(system_prompt, user_prompt)
|
||||
parsed = parse_llm_json(raw or "")
|
||||
source = AIInsight.SOURCE_GENERATED
|
||||
if not parsed:
|
||||
parsed = local_fallback_insight(graded, topic)
|
||||
source = AIInsight.SOURCE_LOCAL_FALLBACK
|
||||
|
||||
insight_text = parsed["insight"]
|
||||
summary = parsed["kesimpulan"]
|
||||
alert = alert_from_graded(graded)
|
||||
|
||||
row, _created = AIInsight.objects.update_or_create(
|
||||
cycle=cycle,
|
||||
kandang=kandang,
|
||||
topic=topic,
|
||||
report_type=report_type,
|
||||
report_period=report_period,
|
||||
defaults={
|
||||
"date": timezone.localdate(),
|
||||
"insight_text": insight_text,
|
||||
"summary": summary,
|
||||
"alert": alert,
|
||||
"source": source,
|
||||
"citations": encode_citations(citations),
|
||||
},
|
||||
)
|
||||
return serialize_insight(row, source_override=source)
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.operations.services.insight_service import alert_from_graded
|
||||
|
||||
|
||||
class AlertFromGradedTests(SimpleTestCase):
|
||||
def test_empty_analyses_is_unknown(self):
|
||||
self.assertEqual(alert_from_graded({}), "unknown")
|
||||
self.assertEqual(alert_from_graded({"analyses": {}}), "unknown")
|
||||
|
||||
def test_ok_maps_to_healthy(self):
|
||||
graded = {"analyses": {"fcr": {"status": "ok"}}}
|
||||
self.assertEqual(alert_from_graded(graded), "healthy")
|
||||
|
||||
def test_warning_and_critical_unchanged(self):
|
||||
self.assertEqual(
|
||||
alert_from_graded({"analyses": {"fcr": {"status": "warning"}}}),
|
||||
"warning",
|
||||
)
|
||||
self.assertEqual(
|
||||
alert_from_graded({"analyses": {"mortality": {"status": "critical"}}}),
|
||||
"critical",
|
||||
)
|
||||
|
||||
def test_unknown_stays_unknown(self):
|
||||
graded = {"analyses": {"bw": {"status": "unknown"}}}
|
||||
self.assertEqual(alert_from_graded(graded), "unknown")
|
||||
|
||||
def test_worst_wins_critical_over_warning_and_healthy(self):
|
||||
graded = {
|
||||
"analyses": {
|
||||
"fcr": {"status": "ok"},
|
||||
"mortality": {"status": "warning"},
|
||||
"environment": {"status": "critical"},
|
||||
}
|
||||
}
|
||||
self.assertEqual(alert_from_graded(graded), "critical")
|
||||
|
||||
def test_worst_wins_warning_over_healthy_and_unknown(self):
|
||||
graded = {
|
||||
"analyses": {
|
||||
"fcr": {"status": "ok"},
|
||||
"bw": {"status": "unknown"},
|
||||
"mortality": {"status": "warning"},
|
||||
}
|
||||
}
|
||||
self.assertEqual(alert_from_graded(graded), "warning")
|
||||
|
||||
def test_ignores_non_dict_blocks(self):
|
||||
graded = {"analyses": {"fcr": "ok", "bw": {"status": "ok"}}}
|
||||
self.assertEqual(alert_from_graded(graded), "healthy")
|
||||
@@ -35,7 +35,7 @@ from apps.operations.services.chicken_counting_edge import (
|
||||
)
|
||||
from apps.operations.services.visibility import dashboard_publish_time, visible_through_date
|
||||
|
||||
READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates"})
|
||||
READ_ACTIONS = frozenset({"list", "retrieve", "latest_average", "latest", "dates", "cached"})
|
||||
|
||||
|
||||
class VisibilityFilteredMixin:
|
||||
@@ -252,9 +252,89 @@ class IotPanelViewSet(viewsets.ModelViewSet):
|
||||
|
||||
|
||||
class AIInsightViewSet(CycleScopedViewSet):
|
||||
queryset = AIInsight.objects.select_related("cycle").all()
|
||||
queryset = AIInsight.objects.select_related("cycle", "kandang").all()
|
||||
serializer_class = AIInsightSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
params = self.request.query_params
|
||||
if params.get("kandang_id"):
|
||||
qs = qs.filter(kandang_id=params["kandang_id"])
|
||||
if params.get("topic"):
|
||||
qs = qs.filter(topic=params["topic"])
|
||||
if params.get("report_type"):
|
||||
qs = qs.filter(report_type=params["report_type"])
|
||||
if params.get("report_period"):
|
||||
qs = qs.filter(report_period=params["report_period"])
|
||||
return qs
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="generate")
|
||||
def generate(self, request):
|
||||
from apps.operations.services.insight_service import generate_insight
|
||||
|
||||
data = request.data or {}
|
||||
cycle_id = data.get("cycle_id")
|
||||
kandang_id = data.get("kandang_id")
|
||||
topic = data.get("topic")
|
||||
context = data.get("context") or {}
|
||||
report_type = data.get("report_type") or "page"
|
||||
report_period = data.get("report_period") or "current"
|
||||
force_refresh = bool(data.get("force_refresh"))
|
||||
if not cycle_id or not kandang_id or not topic:
|
||||
return Response(
|
||||
{"detail": "cycle_id, kandang_id, and topic are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Reject mixed-farm prompts: context must match selected kandang.
|
||||
ctx_kid = context.get("kandangId") or context.get("kandang_id")
|
||||
if ctx_kid is not None and int(ctx_kid) != int(kandang_id):
|
||||
return Response(
|
||||
{"detail": "context.kandangId must match kandang_id."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
result = generate_insight(
|
||||
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,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return Response(
|
||||
{"detail": f"Insight generate failed: {exc}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
return Response(result)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="cached")
|
||||
def cached(self, request):
|
||||
from apps.operations.services.insight_service import get_cached_insight
|
||||
|
||||
params = request.query_params
|
||||
cycle_id = params.get("cycle_id")
|
||||
kandang_id = params.get("kandang_id")
|
||||
topic = params.get("topic")
|
||||
if not cycle_id or not kandang_id or not topic:
|
||||
return Response(
|
||||
{"detail": "cycle_id, kandang_id, and topic are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
result = get_cached_insight(
|
||||
cycle_id=int(cycle_id),
|
||||
kandang_id=int(kandang_id),
|
||||
topic=str(topic),
|
||||
report_type=params.get("report_type") or "page",
|
||||
report_period=params.get("report_period") or "current",
|
||||
)
|
||||
if result is None:
|
||||
return Response({"detail": "No cached insight."}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(result)
|
||||
|
||||
|
||||
class HealthView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@@ -219,3 +219,9 @@ CHICKEN_COUNTING_EDGE_MORTALITY_SYNC_ENABLED = env_bool(
|
||||
CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED = env_bool(
|
||||
"CHICKEN_COUNTING_EDGE_WEIGHT_SYNC_ENABLED", True
|
||||
)
|
||||
|
||||
# AI Insight: Ollama + RAG (generate intended for NUC / host Ollama).
|
||||
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")
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 331 KiB |
@@ -16,8 +16,9 @@
|
||||
-- cycles.status: active | pending_close | closed (GM approves end_date)
|
||||
-- cycles.close_requested_by_id → user_access (optional)
|
||||
-- kandang → flock → iot_panel
|
||||
-- cycles → ai_insight, feed_sacks, chicken_counting, chicken_weight,
|
||||
-- manual_input, kpi
|
||||
-- cycles → ai_insight (scoped by kandang/topic/report), feed_sacks,
|
||||
-- chicken_counting, chicken_weight, manual_input, kpi
|
||||
-- kandang.feed_in_button_urls: JSON map of feed-in button URLs per slot
|
||||
-- kpi → chicken_counting, chicken_weight, feed_sacks, manual_input
|
||||
-- =============================================================================
|
||||
|
||||
@@ -99,11 +100,12 @@ CREATE INDEX IF NOT EXISTS idx_sites_pusat_active_site_id ON sites (pusat_active
|
||||
-- 3. Kandang (Coop / Pen)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS kandang (
|
||||
kandang_id SERIAL PRIMARY KEY,
|
||||
kandang_name VARCHAR(30) NOT NULL,
|
||||
site_id VARCHAR(64) NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
kandang_id SERIAL PRIMARY KEY,
|
||||
kandang_name VARCHAR(30) NOT NULL,
|
||||
site_id VARCHAR(64) NOT NULL REFERENCES sites (site_id) ON DELETE CASCADE,
|
||||
feed_in_button_urls JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_kandang_site_id ON kandang (site_id);
|
||||
@@ -180,23 +182,35 @@ CREATE UNIQUE INDEX IF NOT EXISTS uniq_iot_panel_flock_timestamp ON iot_panel (f
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 7. AI Insight
|
||||
-- Persisted per cache scope: cycle + kandang + topic + report_type +
|
||||
-- report_period. Regenerate overwrites the same row.
|
||||
-- alert: condition from graded CP707 facts (healthy|warning|critical|unknown).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ai_insight (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
date DATE NOT NULL,
|
||||
insight_text TEXT NOT NULL,
|
||||
alert VARCHAR(100) NOT NULL,
|
||||
section VARCHAR(100) NOT NULL,
|
||||
session VARCHAR(100) NOT NULL,
|
||||
alert VARCHAR(100) NOT NULL DEFAULT '',
|
||||
cycle_id BIGINT NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE,
|
||||
kandang_id BIGINT REFERENCES kandang (kandang_id) ON DELETE CASCADE,
|
||||
topic VARCHAR(64) NOT NULL DEFAULT '',
|
||||
report_type VARCHAR(32) NOT NULL DEFAULT 'page',
|
||||
report_period VARCHAR(64) NOT NULL DEFAULT 'current',
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'generated', -- generated | cache | local_fallback
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
citations TEXT NOT NULL DEFAULT '[]', -- JSON array string of RAG citations
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
cycle_id INTEGER NOT NULL REFERENCES cycles (cycle_id) ON DELETE CASCADE
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_insight_cycle_id ON ai_insight (cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_insight_kandang_id ON ai_insight (kandang_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_insight_topic ON ai_insight (topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_insight_report_type ON ai_insight (report_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_insight_report_period ON ai_insight (report_period);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_insight_date ON ai_insight (date);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_ai_insight_cycle_date_section_session
|
||||
ON ai_insight (cycle_id, date, section, session);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_ai_insight_cache_scope
|
||||
ON ai_insight (cycle_id, kandang_id, topic, report_type, report_period);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 8. Feed Sacks
|
||||
@@ -255,7 +269,7 @@ CREATE TABLE IF NOT EXISTS chicken_weight (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chicken_weight_cycle_id ON chicken_weight (cycle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chicken_weight_date ON chicken_weight (date);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_chicken_weight_cycle_date ON chicken_weight (cycle_id, date);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_cw_cycle_date ON chicken_weight (cycle_id, date);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 11. Manual Input
|
||||
@@ -341,7 +355,7 @@ DECLARE
|
||||
tbl TEXT;
|
||||
BEGIN
|
||||
FOREACH tbl IN ARRAY ARRAY[
|
||||
'user_access', 'sites', 'kandang', 'cycles', 'flock',
|
||||
'user_access', 'api_keys', 'sites', 'kandang', 'cycles', 'flock',
|
||||
'iot_panel', 'ai_insight', 'feed_sacks', 'chicken_counting',
|
||||
'chicken_weight', 'manual_input', 'kpi'
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user