update ai insight migration for all page

This commit is contained in:
Alberto-Audrix committed 2026-09-17 10:11:14 +07:00
1 parent 07d36081ce
commit 18790ab8f6
69 files changed
+9457 -399

No files matched your search

+6
View File
@@ -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
+5 -3
View File
@@ -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="[]"),
),
]
+31 -6
View File
@@ -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",
)
]
+7
View File
@@ -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")
+82 -2
View File
@@ -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]
+6
View File
@@ -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

+31 -17
View File
@@ -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'
]