This commit is contained in:
2026-04-25 17:22:41 +03:30
parent 569d520a5c
commit aa24fc22b0
124 changed files with 8491 additions and 2582 deletions
View File
+562
View File
@@ -0,0 +1,562 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime
from typing import Any
from django.utils import timezone
def safe_number(value, default=0):
return default if value is None else value
SEVERITY_ORDER = {"low": 1, "medium": 2, "high": 3, "critical": 4}
SEVERITY_UI = {
"low": {"avatarColor": "info", "chipColor": "info"},
"medium": {"avatarColor": "warning", "chipColor": "warning"},
"high": {"avatarColor": "error", "chipColor": "error"},
"critical": {"avatarColor": "error", "chipColor": "error"},
}
METRIC_META = {
"moisture": {
"title": "تنش رطوبتی",
"icon": "tabler-droplet-half-2",
"unit": "%",
"domain": "water_balance",
"threshold": 45.0,
"danger_span": 20.0,
"direction": "below",
},
"temperature": {
"title": "تنش دمایی",
"icon": "tabler-snowflake",
"unit": "°C",
"domain": "temperature_stress",
"threshold": 0.0,
"danger_span": 8.0,
"direction": "below",
},
"ph": {
"title": "عدم تعادل pH",
"icon": "tabler-flask",
"unit": "pH",
"domain": "root_chemistry",
"threshold_low": 6.0,
"threshold_high": 7.5,
"danger_span": 1.5,
},
"ec": {
"title": "شوری / EC بالا",
"icon": "tabler-bolt",
"unit": "dS/m",
"domain": "root_chemistry",
"threshold": 3.0,
"danger_span": 2.0,
"direction": "above",
},
"fungal_risk": {
"title": "ریسک قارچی",
"icon": "tabler-mushroom",
"unit": "%",
"domain": "disease_pressure",
"threshold": 70.0,
"danger_span": 20.0,
"direction": "above",
},
}
SUMMARY_TEMPLATES = {
"moisture": {
"low": "افت رطوبت خاک ثبت شده و نیاز به پایش نزدیک‌تر دارد.",
"medium": "رطوبت خاک پایین‌تر از محدوده مطلوب است و برنامه آبیاری باید بازبینی شود.",
"high": "تنش آبی قابل‌توجه شناسایی شده و مزرعه به اقدام آبیاری سریع نیاز دارد.",
"critical": "کمبود شدید رطوبت فعال است و خطر افت رشد یا آسیب ریشه بالا رفته است.",
},
"temperature": {
"low": "دمای پایین ثبت شده و باید روند شبانه پایش شود.",
"medium": "ریسک سرمازدگی ایجاد شده و اقدامات محافظتی باید آماده شود.",
"high": "سرما به محدوده پرخطر رسیده و حفاظت دمایی باید در اولویت باشد.",
"critical": "یخبندان بحرانی پیش‌بینی یا مشاهده شده و اقدام فوری حفاظتی لازم است.",
},
"ph": {
"low": "pH از محدوده مطلوب فاصله گرفته و نیاز به بررسی اصلاحی دارد.",
"medium": "عدم تعادل pH می‌تواند جذب عناصر را مختل کند و باید اصلاح شود.",
"high": "انحراف pH شدید است و ریسک اختلال تغذیه گیاه بالا رفته است.",
"critical": "pH در وضعیت بحرانی قرار دارد و مداخله سریع برای جلوگیری از تنش تغذیه‌ای لازم است.",
},
"ec": {
"low": "EC بالاتر از حد مرجع است و باید روند شوری پیگیری شود.",
"medium": "شوری خاک می‌تواند رشد را محدود کند و نیاز به تعدیل دارد.",
"high": "EC بالا به سطح پرخطر رسیده و مدیریت شوری باید انجام شود.",
"critical": "شوری بحرانی فعال است و احتمال آسیب ریشه و افت جذب آب بسیار بالاست.",
},
"fungal_risk": {
"low": "شرایط اولیه برای فشار بیماری قارچی مشاهده شده است.",
"medium": "رطوبت و خیس‌ماندگی بستر، ریسک بیماری قارچی را افزایش داده است.",
"high": "فشار بیماری قارچی بالا است و عملیات پیشگیرانه باید در اولویت قرار گیرد.",
"critical": "الگوی بسیار پرخطر بیماری قارچی فعال است و اقدام فوری محافظتی لازم است.",
},
}
ACTION_TEMPLATES = {
"moisture": {
"low": "روند رطوبت را در نوبت بعدی کنترل و یکنواختی آبیاری را بررسی کنید.",
"medium": "یک نوبت آبیاری اصلاحی برنامه‌ریزی و افت رطوبت در عمق‌های مختلف پایش شود.",
"high": "آبیاری جبرانی کوتاه‌مدت اجرا و راندمان روش آبیاری بازبینی شود.",
"critical": "آبیاری اضطراری، بررسی انسداد سامانه و پایش مجدد سنسور فوراً انجام شود.",
},
"temperature": {
"low": "پوشش یا برنامه محافظتی شبانه آماده نگه داشته شود.",
"medium": "زمان‌بندی آبیاری و پوشش حفاظتی برای ساعات سرد تنظیم شود.",
"high": "اقدامات ضدیخبندان مانند آبیاری حفاظتی یا پوشش فوری اجرا شود.",
"critical": "پروتکل کامل حفاظت سرما فوراً فعال و وضعیت مزرعه در چند ساعت بعدی بازبینی شود.",
},
"ph": {
"low": "نمونه‌برداری تکمیلی انجام و روند pH برای چند قرائت بعدی کنترل شود.",
"medium": "برنامه اصلاح pH با توجه به نوع خاک و کود مصرفی بازتنظیم شود.",
"high": "اصلاح‌کننده مناسب خاک در اولویت قرار گیرد و تغذیه گیاه بازبینی شود.",
"critical": "مداخله اصلاحی فوری برای pH انجام و مصرف نهاده‌های تشدیدکننده متوقف شود.",
},
"ec": {
"low": "منبع آب و روند EC در روزهای آینده کنترل شود.",
"medium": "شست‌وشوی محدود خاک یا اصلاح برنامه کوددهی بررسی شود.",
"high": "کاهش بار نمکی، بازبینی کوددهی و ارزیابی زهکشی در اولویت قرار گیرد.",
"critical": "اقدام فوری برای کاهش شوری و توقف نهاده‌های شورکننده انجام شود.",
},
"fungal_risk": {
"low": "تهویه و رطوبت بستر پایش شود و نشانه‌های اولیه بیماری بررسی گردد.",
"medium": "فاصله آبیاری و تهویه مزرعه تنظیم و بازدید بیماری انجام شود.",
"high": "اقدامات پیشگیرانه بیماری و کاهش رطوبت ماندگار فوراً اجرا شود.",
"critical": "پروتکل فوری مدیریت بیماری فعال و مزرعه از نظر آلودگی کانونی بررسی شود.",
},
}
EXPLANATION_TEMPLATES = {
"moisture": {
"low": "رطوبت فعلی {current_value}{unit} به زیر آستانه {threshold_value}{unit} رسیده است و این وضعیت {duration_text} ادامه داشته است.",
"medium": "رطوبت خاک {current_value}{unit} است؛ فاصله از آستانه {threshold_value}{unit} و تداوم {duration_text} نشان‌دهنده تنش آبی است.",
"high": "رطوبت خاک در {current_value}{unit} ثبت شده که به‌طور معنی‌دار پایین‌تر از آستانه {threshold_value}{unit} است و {duration_text} پایدار مانده است.",
"critical": "رطوبت خاک به {current_value}{unit} سقوط کرده و با عبور شدید از آستانه {threshold_value}{unit}، {duration_text} در وضعیت بحرانی باقی مانده است.",
},
"temperature": {
"low": "دما به {current_value}{unit} رسیده که از حد هشدار {threshold_value}{unit} پایین‌تر است و {duration_text} تداوم داشته است.",
"medium": "دمای ثبت‌شده {current_value}{unit} کمتر از آستانه {threshold_value}{unit} است و تداوم {duration_text} ریسک تنش سرما را بالا برده است.",
"high": "افت دما تا {current_value}{unit} همراه با ماندگاری {duration_text} شرایط پرخطر سرما را ایجاد کرده است.",
"critical": "دمای {current_value}{unit} با ماندگاری {duration_text} نشان می‌دهد مزرعه در معرض یخبندان بحرانی قرار دارد.",
},
"ph": {
"low": "pH فعلی {current_value}{unit} از محدوده مرجع {threshold_value} خارج شده و این انحراف {duration_text} ادامه داشته است.",
"medium": "انحراف pH تا {current_value}{unit} نسبت به حد مجاز {threshold_value} همراه با تداوم {duration_text} می‌تواند جذب عناصر را مختل کند.",
"high": "pH {current_value}{unit} با فاصله زیاد از محدوده مرجع و پایداری {duration_text} یک تنش شیمیایی مهم ایجاد کرده است.",
"critical": "وضعیت بحرانی pH در سطح {current_value}{unit} و با تداوم {duration_text} نیاز به اصلاح فوری دارد.",
},
"ec": {
"low": "EC فعلی {current_value}{unit} از آستانه {threshold_value}{unit} عبور کرده و {duration_text} پایدار مانده است.",
"medium": "EC برابر {current_value}{unit} است؛ عبور از حد {threshold_value}{unit} با ماندگاری {duration_text} فشار شوری را افزایش داده است.",
"high": "شوری ثبت‌شده در {current_value}{unit} با تداوم {duration_text} به سطح پرخطر رسیده است.",
"critical": "EC در {current_value}{unit} و با پایداری {duration_text} نشان‌دهنده شوری بحرانی خاک است.",
},
"fungal_risk": {
"low": "رطوبت هوا و خاک شرایط اولیه فشار قارچی را ایجاد کرده و این الگو {duration_text} ادامه داشته است.",
"medium": "ترکیب رطوبت {current_value}{unit} و ماندگاری {duration_text} از آستانه {threshold_value}{unit} عبور کرده و ریسک قارچی را بالا برده است.",
"high": "شرایط مرطوب پایدار در {current_value}{unit} و تداوم {duration_text} فشار قارچی جدی ایجاد کرده است.",
"critical": "ماندگاری طولانی شرایط بسیار مرطوب ({current_value}{unit}) در برابر حد {threshold_value}{unit} نشان‌دهنده ریسک بحرانی بیماری قارچی است.",
},
}
CLUSTER_TITLES = {
"water_balance": "تعادل آب",
"temperature_stress": "تنش دمایی",
"root_chemistry": "شیمی ناحیه ریشه",
"disease_pressure": "فشار بیماری",
}
def _now() -> datetime:
return timezone.now()
def _timestamp_for(obj: Any, fallback: datetime) -> datetime:
for attr in ("recorded_at", "updated_at", "created_at", "forecast_date"):
value = getattr(obj, attr, None)
if value is not None:
if isinstance(value, datetime):
return value
return datetime.combine(value, datetime.min.time(), tzinfo=fallback.tzinfo)
return fallback
def _format_timestamp(value: datetime) -> str:
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return value.isoformat()
def _format_duration(hours: float) -> str:
rounded = max(1, round(hours))
if rounded >= 24:
days = rounded // 24
rem_hours = rounded % 24
if rem_hours == 0:
return f"{days} روز"
return f"{days} روز و {rem_hours} ساعت"
return f"{rounded} ساعت"
def _severity_from_score(score: float) -> str:
if score >= 0.85:
return "critical"
if score >= 0.55:
return "high"
if score >= 0.3:
return "medium"
return "low"
def _build_severity(distance_ratio: float, duration_hours: float) -> str:
duration_ratio = min(duration_hours / 72.0, 1.0)
score = min((distance_ratio * 0.7) + (duration_ratio * 0.3), 1.0)
return _severity_from_score(score)
def _collect_active_history_duration(
current_value: float,
history: list[Any],
field_name: str,
threshold: float,
direction: str,
fallback_timestamp: datetime,
) -> tuple[float, datetime]:
if direction == "below":
is_violating = lambda value: value < threshold
else:
is_violating = lambda value: value > threshold
if not is_violating(current_value):
return 0.0, fallback_timestamp
violating_times = [fallback_timestamp]
for item in history:
value = getattr(item, field_name, None)
if value is None:
break
if not is_violating(value):
break
violating_times.append(_timestamp_for(item, fallback_timestamp))
oldest_violation = min(violating_times)
duration_hours = max((_now() - oldest_violation).total_seconds() / 3600, 1.0)
return duration_hours, oldest_violation
def _make_alert(
metric_type: str,
current_value: float,
threshold_value: float | str,
severity: str,
duration_hours: float,
timestamp: datetime,
sensor_id: str,
zone_id: str | None = None,
direction: str | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
meta = METRIC_META[metric_type]
unit = meta["unit"]
threshold_display = threshold_value
if isinstance(threshold_value, float):
threshold_display = round(threshold_value, 2)
explanation = EXPLANATION_TEMPLATES[metric_type][severity].format(
current_value=round(current_value, 2),
threshold_value=threshold_display,
unit=unit,
duration_text=_format_duration(duration_hours),
)
return {
"metric_type": metric_type,
"title": meta["title"],
"current_value": round(current_value, 2),
"threshold_value": threshold_display,
"severity": severity,
"duration_hours": round(duration_hours, 1),
"duration": _format_duration(duration_hours),
"timestamp": _format_timestamp(timestamp),
"sensor_id": sensor_id,
"zone_id": zone_id,
"domain": meta["domain"],
"direction": direction,
"unit": unit,
"icon": meta["icon"],
"summary": SUMMARY_TEMPLATES[metric_type][severity],
"recommended_action": ACTION_TEMPLATES[metric_type][severity],
"explanation": explanation,
"metadata": metadata or {},
}
def _detect_moisture_alert(sensor: Any, history: list[Any], sensor_id: str) -> list[dict[str, Any]]:
current_value = safe_number(getattr(sensor, "soil_moisture", None), 0)
meta = METRIC_META["moisture"]
threshold = meta["threshold"]
if current_value >= threshold:
return []
timestamp = _timestamp_for(sensor, _now())
duration_hours, started_at = _collect_active_history_duration(
current_value=current_value,
history=history,
field_name="soil_moisture",
threshold=threshold,
direction=meta["direction"],
fallback_timestamp=timestamp,
)
distance_ratio = min((threshold - current_value) / meta["danger_span"], 1.0)
severity = _build_severity(distance_ratio, duration_hours)
return [
_make_alert(
metric_type="moisture",
current_value=current_value,
threshold_value=threshold,
severity=severity,
duration_hours=duration_hours,
timestamp=started_at,
sensor_id=sensor_id,
direction=meta["direction"],
)
]
def _detect_ph_alert(sensor: Any, history: list[Any], sensor_id: str) -> list[dict[str, Any]]:
current_value = safe_number(getattr(sensor, "soil_ph", None), 7)
meta = METRIC_META["ph"]
low = meta["threshold_low"]
high = meta["threshold_high"]
if low <= current_value <= high:
return []
direction = "below" if current_value < low else "above"
threshold = low if direction == "below" else high
timestamp = _timestamp_for(sensor, _now())
duration_hours, started_at = _collect_active_history_duration(
current_value=current_value,
history=history,
field_name="soil_ph",
threshold=threshold,
direction=direction,
fallback_timestamp=timestamp,
)
distance_ratio = min(abs(current_value - threshold) / meta["danger_span"], 1.0)
severity = _build_severity(distance_ratio, duration_hours)
threshold_display = f"{low}-{high}"
return [
_make_alert(
metric_type="ph",
current_value=current_value,
threshold_value=threshold_display,
severity=severity,
duration_hours=duration_hours,
timestamp=started_at,
sensor_id=sensor_id,
direction=direction,
metadata={"boundary_threshold": threshold},
)
]
def _detect_ec_alert(sensor: Any, history: list[Any], sensor_id: str) -> list[dict[str, Any]]:
current_value = safe_number(getattr(sensor, "electrical_conductivity", None), 0)
meta = METRIC_META["ec"]
threshold = meta["threshold"]
if current_value <= threshold:
return []
timestamp = _timestamp_for(sensor, _now())
duration_hours, started_at = _collect_active_history_duration(
current_value=current_value,
history=history,
field_name="electrical_conductivity",
threshold=threshold,
direction=meta["direction"],
fallback_timestamp=timestamp,
)
distance_ratio = min((current_value - threshold) / meta["danger_span"], 1.0)
severity = _build_severity(distance_ratio, duration_hours)
return [
_make_alert(
metric_type="ec",
current_value=current_value,
threshold_value=threshold,
severity=severity,
duration_hours=duration_hours,
timestamp=started_at,
sensor_id=sensor_id,
direction=meta["direction"],
)
]
def _detect_frost_alert(forecasts: list[Any], sensor_id: str) -> list[dict[str, Any]]:
violating = [forecast for forecast in forecasts[:3] if safe_number(getattr(forecast, "temperature_min", None), 10) < 0]
if not violating:
return []
first = violating[0]
coldest = min(safe_number(getattr(item, "temperature_min", None), 0) for item in violating)
duration_hours = max(len(violating) * 24.0, 24.0)
meta = METRIC_META["temperature"]
distance_ratio = min((meta["threshold"] - coldest) / meta["danger_span"], 1.0)
severity = _build_severity(distance_ratio, duration_hours)
timestamp = _timestamp_for(first, _now())
return [
_make_alert(
metric_type="temperature",
current_value=coldest,
threshold_value=meta["threshold"],
severity=severity,
duration_hours=duration_hours,
timestamp=timestamp,
sensor_id=sensor_id,
direction=meta["direction"],
metadata={"forecast_days_impacted": len(violating)},
)
]
def _detect_fungal_risk(sensor: Any, forecasts: list[Any], history: list[Any], sensor_id: str) -> list[dict[str, Any]]:
humidity_values = [safe_number(getattr(forecast, "humidity_mean", None), None) for forecast in forecasts[:3]]
humidity_values = [value for value in humidity_values if value is not None]
if not humidity_values:
return []
humidity = sum(humidity_values) / len(humidity_values)
moisture = safe_number(getattr(sensor, "soil_moisture", None), 0)
meta = METRIC_META["fungal_risk"]
threshold = meta["threshold"]
if humidity <= threshold or moisture <= 60:
return []
timestamp = _timestamp_for(sensor, _now())
duration_hours, started_at = _collect_active_history_duration(
current_value=moisture,
history=history,
field_name="soil_moisture",
threshold=60.0,
direction="above",
fallback_timestamp=timestamp,
)
duration_hours = max(duration_hours, len(forecasts[:3]) * 12.0)
humidity_ratio = min((humidity - threshold) / meta["danger_span"], 1.0)
moisture_ratio = min((moisture - 60.0) / 20.0, 1.0)
severity = _build_severity((humidity_ratio * 0.6) + (moisture_ratio * 0.4), duration_hours)
return [
_make_alert(
metric_type="fungal_risk",
current_value=humidity,
threshold_value=threshold,
severity=severity,
duration_hours=duration_hours,
timestamp=started_at,
sensor_id=sensor_id,
direction=meta["direction"],
metadata={"soil_moisture": round(moisture, 2)},
)
]
def _sort_alerts(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
return sorted(
alerts,
key=lambda alert: (
SEVERITY_ORDER[alert["severity"]],
alert["duration_hours"],
abs(float(alert["current_value"])) if isinstance(alert["current_value"], (int, float)) else 0,
),
reverse=True,
)
def _build_clusters(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for alert in alerts:
grouped[alert["domain"]].append(alert)
clusters: list[dict[str, Any]] = []
for domain, items in grouped.items():
ordered = _sort_alerts(items)
top = ordered[0]
clusters.append(
{
"domain": domain,
"title": CLUSTER_TITLES.get(domain, domain),
"alert_count": len(items),
"highest_severity": top["severity"],
"primary_metric": top["metric_type"],
"summary": top["summary"],
"alert_ids": [f"{item['metric_type']}:{item['timestamp']}" for item in ordered],
}
)
return sorted(clusters, key=lambda cluster: SEVERITY_ORDER[cluster["highest_severity"]], reverse=True)
def _build_alert_stats(alerts: list[dict[str, Any]]) -> list[dict[str, Any]]:
stats: list[dict[str, Any]] = []
for metric_type, meta in METRIC_META.items():
matches = [alert for alert in alerts if alert["metric_type"] == metric_type]
if not matches:
continue
top = _sort_alerts(matches)[0]
ui = SEVERITY_UI[top["severity"]]
stats.append(
{
"title": meta["title"],
"count": str(len(matches)),
"avatarColor": ui["avatarColor"],
"avatarIcon": meta["icon"],
"severity": top["severity"],
"topSummary": top["summary"],
}
)
return stats
def build_farm_alerts_tracker(sensor_id: str, context: dict | None = None, ai_bundle: dict | None = None) -> dict:
context = context or {}
sensor = context.get("sensor")
forecasts = context.get("forecasts", [])
history = context.get("history", [])
if sensor is None:
return {
"totalAlerts": 0,
"alerts": [],
"alertStats": [],
"alertClusters": [],
"mostCriticalIssue": None,
"prioritizedAlertSummaries": [],
"recommendedOperationalActions": [],
"humanReadableExplanations": [],
}
alerts = []
alerts.extend(_detect_moisture_alert(sensor, history, sensor_id))
alerts.extend(_detect_ph_alert(sensor, history, sensor_id))
alerts.extend(_detect_ec_alert(sensor, history, sensor_id))
alerts.extend(_detect_frost_alert(forecasts, sensor_id))
alerts.extend(_detect_fungal_risk(sensor, forecasts, history, sensor_id))
ordered_alerts = _sort_alerts(alerts)
clusters = _build_clusters(ordered_alerts)
top_alert = ordered_alerts[0] if ordered_alerts else None
return {
"totalAlerts": len(ordered_alerts),
"alerts": ordered_alerts,
"alertStats": _build_alert_stats(ordered_alerts),
"alertClusters": clusters,
"mostCriticalIssue": top_alert,
"prioritizedAlertSummaries": [alert["summary"] for alert in ordered_alerts],
"recommendedOperationalActions": [alert["recommended_action"] for alert in ordered_alerts],
"humanReadableExplanations": [alert["explanation"] for alert in ordered_alerts],
}
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class FarmAlertsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "farm_alerts"
verbose_name = "Farm Alerts"
+38
View File
@@ -0,0 +1,38 @@
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="FarmAlertNotification",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("farm_uuid", models.UUIDField(db_index=True)),
("endpoint", models.CharField(choices=[("tracker", "Tracker"), ("timeline", "Timeline")], db_index=True, max_length=32)),
("level", models.CharField(choices=[("info", "اطلاع رسانی"), ("warning", "هشدار"), ("danger", "خطر")], db_index=True, max_length=16)),
("title", models.CharField(max_length=255)),
("message", models.TextField(blank=True)),
("suggested_action", models.TextField(blank=True)),
("source_alert_id", models.CharField(blank=True, db_index=True, max_length=255)),
("source_metric_type", models.CharField(blank=True, db_index=True, max_length=64)),
("fingerprint", models.CharField(max_length=64, unique=True)),
("payload", models.JSONField(blank=True, default=dict)),
("created_at", models.DateTimeField(auto_now_add=True, db_index=True)),
("updated_at", models.DateTimeField(auto_now=True)),
],
options={
"db_table": "farm_alerts_notification",
"ordering": ["-updated_at", "-created_at"],
"verbose_name": "Farm Alert Notification",
"verbose_name_plural": "Farm Alert Notifications",
"indexes": [
models.Index(fields=["farm_uuid", "endpoint", "-updated_at"], name="farm_alerts_farm_ep_updated_idx"),
models.Index(fields=["farm_uuid", "level", "-updated_at"], name="farm_alerts_farm_level_updated_idx"),
],
},
),
]
View File
+45
View File
@@ -0,0 +1,45 @@
from django.db import models
class FarmAlertNotification(models.Model):
LEVEL_INFO = "info"
LEVEL_WARNING = "warning"
LEVEL_DANGER = "danger"
LEVEL_CHOICES = [
(LEVEL_INFO, "اطلاع رسانی"),
(LEVEL_WARNING, "هشدار"),
(LEVEL_DANGER, "خطر"),
]
ENDPOINT_TRACKER = "tracker"
ENDPOINT_TIMELINE = "timeline"
ENDPOINT_CHOICES = [
(ENDPOINT_TRACKER, "Tracker"),
(ENDPOINT_TIMELINE, "Timeline"),
]
farm_uuid = models.UUIDField(db_index=True)
endpoint = models.CharField(max_length=32, choices=ENDPOINT_CHOICES, db_index=True)
level = models.CharField(max_length=16, choices=LEVEL_CHOICES, db_index=True)
title = models.CharField(max_length=255)
message = models.TextField(blank=True)
suggested_action = models.TextField(blank=True)
source_alert_id = models.CharField(max_length=255, blank=True, db_index=True)
source_metric_type = models.CharField(max_length=64, blank=True, db_index=True)
fingerprint = models.CharField(max_length=64, unique=True)
payload = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "farm_alerts_notification"
ordering = ["-updated_at", "-created_at"]
indexes = [
models.Index(fields=["farm_uuid", "endpoint", "-updated_at"]),
models.Index(fields=["farm_uuid", "level", "-updated_at"]),
]
verbose_name = "Farm Alert Notification"
verbose_name_plural = "Farm Alert Notifications"
def __str__(self):
return f"{self.farm_uuid} - {self.endpoint} - {self.level} - {self.title}"
+36
View File
@@ -0,0 +1,36 @@
from rest_framework import serializers
from .models import FarmAlertNotification
class FarmAlertsRequestSerializer(serializers.Serializer):
farm_uuid = serializers.CharField(required=False, help_text="شناسه مزرعه")
sensor_uuid = serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid")
query = serializers.CharField(required=False, allow_blank=True, help_text="سوال اختیاری")
def validate(self, attrs):
farm_uuid = attrs.get("farm_uuid") or attrs.get("sensor_uuid")
if not farm_uuid:
raise serializers.ValidationError({"farm_uuid": "farm_uuid الزامی است."})
attrs["farm_uuid"] = farm_uuid
return attrs
class FarmAlertNotificationSerializer(serializers.ModelSerializer):
class Meta:
model = FarmAlertNotification
fields = [
"id",
"farm_uuid",
"endpoint",
"level",
"title",
"message",
"suggested_action",
"source_alert_id",
"source_metric_type",
"payload",
"created_at",
"updated_at",
]
read_only_fields = fields
+440
View File
@@ -0,0 +1,440 @@
from __future__ import annotations
import hashlib
import json
import logging
from typing import Any
from django.apps import apps
from django.core.serializers.json import DjangoJSONEncoder
from farm_data.services import get_farm_details
from farm_data.context import load_farm_context
from rag.api_provider import get_chat_client
from rag.chat import (
_complete_audit_log,
_create_audit_log,
_fail_audit_log,
_load_service_tone,
build_rag_context,
)
from rag.config import RAGConfig, get_service_config, load_rag_config
from .models import FarmAlertNotification
from .alerts_tracker import build_farm_alerts_tracker
logger = logging.getLogger(__name__)
KB_NAME = "farm_alerts"
SERVICE_ID = "farm_alerts"
TRACKER_PROMPT = (
"وضعیت هشدارهای مزرعه را فقط بر اساس داده های ساختاریافته، اطلاعات مزرعه، و متون بازیابی شده از پایگاه دانش تحلیل کن. "
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: headline, overview, status_level, notifications. "
"status_level فقط یکی از danger, warning, info باشد. "
"notifications باید آرایه ای از آبجکت ها با کلیدهای level, title, message, suggested_action, source_alert_id, source_metric_type باشد. "
"سطوح level فقط یکی از danger, warning, info باشند. "
"اگر هشدار مهمی وجود ندارد، notifications را خالی برگردان."
)
TIMELINE_PROMPT = (
"بر اساس داده های هشدار مزرعه، یک timeline عملیاتی بساز. "
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: headline, overview, timeline, notifications. "
"timeline باید آرایه ای از آبجکت ها با کلیدهای timestamp, level, title, description, source_alert_id, source_metric_type باشد. "
"level فقط danger, warning, info باشد. "
"notifications باید آرایه ای از آبجکت ها با کلیدهای level, title, message, suggested_action, source_alert_id, source_metric_type باشد."
)
def _json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, indent=2, cls=DjangoJSONEncoder)
def _clean_json_response(raw: str) -> dict[str, Any]:
cleaned = (raw or "").strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[4:]
cleaned = cleaned.strip()
if not cleaned:
return {}
try:
return json.loads(cleaned)
except (json.JSONDecodeError, ValueError):
logger.warning("Invalid JSON returned by farm_alerts LLM: %s", cleaned[:500])
return {}
def _severity_to_level(severity: str) -> str:
normalized = (severity or "").strip().lower()
if normalized in {"critical", "high", "danger"}:
return FarmAlertNotification.LEVEL_DANGER
if normalized in {"medium", "warning"}:
return FarmAlertNotification.LEVEL_WARNING
return FarmAlertNotification.LEVEL_INFO
def _normalize_level(level: str | None) -> str:
normalized = (level or "").strip().lower()
if normalized in {
FarmAlertNotification.LEVEL_DANGER,
FarmAlertNotification.LEVEL_WARNING,
FarmAlertNotification.LEVEL_INFO,
}:
return normalized
if normalized in {"high", "critical"}:
return FarmAlertNotification.LEVEL_DANGER
if normalized in {"medium", "alert"}:
return FarmAlertNotification.LEVEL_WARNING
return FarmAlertNotification.LEVEL_INFO
def _alert_identifier(alert: dict[str, Any]) -> str:
metric_type = alert.get("metric_type", "alert")
timestamp = alert.get("timestamp", "")
return f"{metric_type}:{timestamp}"
def _forecast_summary(context: dict[str, Any]) -> list[dict[str, Any]]:
forecasts = context.get("forecasts", [])
return [
{
"date": getattr(item, "forecast_date", None),
"temperature_min": getattr(item, "temperature_min", None),
"temperature_max": getattr(item, "temperature_max", None),
"humidity_mean": getattr(item, "humidity_mean", None),
"precipitation": getattr(item, "precipitation", None),
"et0": getattr(item, "et0", None),
}
for item in forecasts[:7]
]
def _farm_profile(context: dict[str, Any], farm_uuid: str) -> dict[str, Any]:
sensor = context.get("sensor")
location = context.get("location")
plants = context.get("plants", [])
irrigation_method = getattr(sensor, "irrigation_method", None) if sensor else None
return {
"farm_uuid": farm_uuid,
"location": {
"latitude": float(location.latitude) if location else None,
"longitude": float(location.longitude) if location else None,
},
"plant_names": [getattr(plant, "name", "") for plant in plants],
"irrigation_method": getattr(irrigation_method, "name", None),
"last_sensor_update": getattr(sensor, "updated_at", None),
}
def _build_structured_context(farm_uuid: str) -> tuple[dict[str, Any], dict[str, Any]]:
context = load_farm_context(farm_uuid)
if context is None:
raise ValueError("farm_uuid نامعتبر است یا اطلاعات هشدار مزرعه پیدا نشد.")
tracker = build_farm_alerts_tracker(sensor_id=farm_uuid, context=context, ai_bundle=None)
structured = {
"farm_profile": _farm_profile(context, farm_uuid),
"tracker": tracker,
"forecasts": _forecast_summary(context),
}
return context, structured
def _build_fallback_notifications(tracker: dict[str, Any], endpoint: str) -> list[dict[str, Any]]:
notifications: list[dict[str, Any]] = []
for alert in tracker.get("alerts", [])[:5]:
notifications.append(
{
"level": _severity_to_level(alert.get("severity")),
"title": alert.get("title") or "هشدار مزرعه",
"message": alert.get("summary") or alert.get("explanation") or "",
"suggested_action": alert.get("recommended_action") or "",
"source_alert_id": _alert_identifier(alert),
"source_metric_type": alert.get("metric_type") or "",
"payload": {
"endpoint": endpoint,
"alert": alert,
},
}
)
return notifications
def _build_fallback_tracker_response(tracker: dict[str, Any]) -> dict[str, Any]:
top_alert = tracker.get("mostCriticalIssue") or {}
status_level = _severity_to_level(top_alert.get("severity")) if top_alert else FarmAlertNotification.LEVEL_INFO
if tracker.get("totalAlerts", 0) <= 0:
overview = "در حال حاضر هشدار فعالی برای مزرعه شناسایی نشده است."
else:
overview = top_alert.get("summary") or "چند هشدار فعال برای مزرعه شناسایی شده است."
return {
"headline": "ارزیابی فعلی هشدارهای مزرعه",
"overview": overview,
"status_level": status_level,
"notifications": _build_fallback_notifications(tracker, FarmAlertNotification.ENDPOINT_TRACKER),
}
def _build_fallback_timeline_response(tracker: dict[str, Any]) -> dict[str, Any]:
timeline = []
for alert in tracker.get("alerts", [])[:6]:
timeline.append(
{
"timestamp": alert.get("timestamp"),
"level": _severity_to_level(alert.get("severity")),
"title": alert.get("title") or "رویداد هشدار",
"description": alert.get("explanation") or alert.get("summary") or "",
"source_alert_id": _alert_identifier(alert),
"source_metric_type": alert.get("metric_type") or "",
}
)
return {
"headline": "خط زمانی هشدارهای مزرعه",
"overview": "timeline بر اساس هشدارهای محاسبه شده مزرعه ساخته شد.",
"timeline": timeline,
"notifications": _build_fallback_notifications(tracker, FarmAlertNotification.ENDPOINT_TIMELINE),
}
def _notification_fingerprint(
*,
farm_uuid: str,
endpoint: str,
level: str,
title: str,
source_alert_id: str,
source_metric_type: str,
) -> str:
raw = "|".join([
str(farm_uuid),
endpoint,
level,
source_alert_id or "-",
source_metric_type or "-",
title.strip(),
])
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _save_notifications(
*,
farm_uuid: str,
endpoint: str,
notifications: list[dict[str, Any]],
) -> list[FarmAlertNotification]:
saved: list[FarmAlertNotification] = []
for item in notifications:
level = _normalize_level(item.get("level"))
title = (item.get("title") or "هشدار مزرعه").strip()
source_alert_id = (item.get("source_alert_id") or "").strip()
source_metric_type = (item.get("source_metric_type") or "").strip()
fingerprint = _notification_fingerprint(
farm_uuid=farm_uuid,
endpoint=endpoint,
level=level,
title=title,
source_alert_id=source_alert_id,
source_metric_type=source_metric_type,
)
payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
notification, _ = FarmAlertNotification.objects.update_or_create(
fingerprint=fingerprint,
defaults={
"farm_uuid": farm_uuid,
"endpoint": endpoint,
"level": level,
"title": title,
"message": item.get("message") or "",
"suggested_action": item.get("suggested_action") or "",
"source_alert_id": source_alert_id,
"source_metric_type": source_metric_type,
"payload": payload,
},
)
saved.append(notification)
return saved
def _serialize_notification(notification: FarmAlertNotification) -> dict[str, Any]:
return {
"id": notification.id,
"farm_uuid": str(notification.farm_uuid),
"endpoint": notification.endpoint,
"level": notification.level,
"title": notification.title,
"message": notification.message,
"suggested_action": notification.suggested_action,
"source_alert_id": notification.source_alert_id,
"source_metric_type": notification.source_metric_type,
"payload": notification.payload,
"created_at": notification.created_at.isoformat(),
"updated_at": notification.updated_at.isoformat(),
}
def _build_service_config(cfg: RAGConfig, service_id: str) -> tuple[Any, Any, str, Any]:
service = get_service_config(service_id, cfg)
service_cfg = RAGConfig(
embedding=cfg.embedding,
qdrant=cfg.qdrant,
chunking=cfg.chunking,
llm=service.llm,
knowledge_bases=cfg.knowledge_bases,
services=cfg.services,
chromadb=cfg.chromadb,
)
client = get_chat_client(service_cfg)
model = service.llm.model
return service, service_cfg, model, client
def _build_messages(
*,
prompt: str,
service: Any,
cfg: RAGConfig,
query: str,
rag_context: str,
structured_context: dict[str, Any],
) -> tuple[str, list[dict[str, str]]]:
tone = _load_service_tone(service, cfg)
system_parts = [tone] if tone else []
if service.system_prompt:
system_parts.append(service.system_prompt)
system_parts.append(prompt)
system_parts.append("[کانتکست ساختاریافته هشدار مزرعه]\n" + _json_dumps(structured_context))
if rag_context:
system_parts.append(rag_context)
system_prompt = "\n\n".join(part for part in system_parts if part)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": query},
]
return system_prompt, messages
def _llm_response(
*,
farm_uuid: str,
service_id: str,
prompt: str,
query: str,
structured_context: dict[str, Any],
) -> tuple[dict[str, Any], str, str]:
cfg = load_rag_config()
service, service_cfg, model, client = _build_service_config(cfg, service_id)
farm_details = get_farm_details(farm_uuid)
rag_context = build_rag_context(
query=query,
sensor_uuid=farm_uuid,
config=cfg,
kb_name=KB_NAME,
service_id=service_id,
farm_details=farm_details,
)
system_prompt, messages = _build_messages(
prompt=prompt,
service=service,
cfg=cfg,
query=query,
rag_context=rag_context,
structured_context=structured_context,
)
audit_log = _create_audit_log(
farm_uuid=farm_uuid,
service_id=service_id,
model=model,
query=query,
system_prompt=system_prompt,
messages=messages,
)
try:
response = client.chat.completions.create(model=model, messages=messages)
raw = response.choices[0].message.content.strip()
parsed = _clean_json_response(raw)
_complete_audit_log(audit_log, raw)
return parsed, raw, service.tone_file or ""
except Exception as exc:
logger.error("farm_alerts llm error for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc))
return {}, "", service.tone_file or ""
def get_farm_alerts_tracker(*, farm_uuid: str, query: str | None = None) -> dict[str, Any]:
_, structured_context = _build_structured_context(farm_uuid)
tracker = structured_context["tracker"]
user_query = query or "وضعیت فعلی هشدارهای مزرعه را ارزیابی کن و اگر لازم است notification بساز."
llm_result, raw_response, tone_file = _llm_response(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
prompt=TRACKER_PROMPT,
query=user_query,
structured_context=structured_context,
)
if not llm_result:
llm_result = _build_fallback_tracker_response(tracker)
notifications_input = llm_result.get("notifications")
if not isinstance(notifications_input, list):
notifications_input = _build_fallback_notifications(tracker, FarmAlertNotification.ENDPOINT_TRACKER)
saved_notifications = _save_notifications(
farm_uuid=farm_uuid,
endpoint=FarmAlertNotification.ENDPOINT_TRACKER,
notifications=notifications_input,
)
return {
"farm_uuid": farm_uuid,
"service_id": SERVICE_ID,
"knowledge_base": KB_NAME,
"tone_file": tone_file,
"tracker": tracker,
"headline": llm_result.get("headline") or "ارزیابی فعلی هشدارهای مزرعه",
"overview": llm_result.get("overview") or "",
"status_level": _normalize_level(llm_result.get("status_level")),
"notifications": [_serialize_notification(item) for item in saved_notifications],
"raw_llm_response": raw_response or None,
"structured_context": structured_context,
}
def get_farm_alerts_timeline(*, farm_uuid: str, query: str | None = None) -> dict[str, Any]:
_, structured_context = _build_structured_context(farm_uuid)
tracker = structured_context["tracker"]
user_query = query or "برای هشدارهای مزرعه یک timeline عملیاتی بساز و اگر لازم است notification ثبت کن."
llm_result, raw_response, tone_file = _llm_response(
farm_uuid=farm_uuid,
service_id=SERVICE_ID,
prompt=TIMELINE_PROMPT,
query=user_query,
structured_context=structured_context,
)
if not llm_result:
llm_result = _build_fallback_timeline_response(tracker)
timeline = llm_result.get("timeline")
if not isinstance(timeline, list):
timeline = _build_fallback_timeline_response(tracker).get("timeline", [])
notifications_input = llm_result.get("notifications")
if not isinstance(notifications_input, list):
notifications_input = _build_fallback_notifications(tracker, FarmAlertNotification.ENDPOINT_TIMELINE)
saved_notifications = _save_notifications(
farm_uuid=farm_uuid,
endpoint=FarmAlertNotification.ENDPOINT_TIMELINE,
notifications=notifications_input,
)
return {
"farm_uuid": farm_uuid,
"service_id": SERVICE_ID,
"knowledge_base": KB_NAME,
"tone_file": tone_file,
"tracker": tracker,
"headline": llm_result.get("headline") or "خط زمانی هشدارهای مزرعه",
"overview": llm_result.get("overview") or "",
"timeline": timeline,
"notifications": [_serialize_notification(item) for item in saved_notifications],
"raw_llm_response": raw_response or None,
"structured_context": structured_context,
}
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import FarmAlertsTimelineView, FarmAlertsTrackerView
urlpatterns = [
path("tracker/", FarmAlertsTrackerView.as_view(), name="farm-alerts-tracker"),
path("timeline/", FarmAlertsTimelineView.as_view(), name="farm-alerts-timeline"),
]
+114
View File
@@ -0,0 +1,114 @@
from drf_spectacular.utils import OpenApiExample, extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from config.openapi import build_envelope_serializer, build_response
from .serializers import FarmAlertsRequestSerializer
from .services import get_farm_alerts_timeline, get_farm_alerts_tracker
FarmAlertsValidationErrorSerializer = build_envelope_serializer(
"FarmAlertsValidationErrorSerializer",
data_required=False,
allow_null=True,
)
FarmAlertsTrackerResponseSerializer = build_envelope_serializer(
"FarmAlertsTrackerResponseSerializer",
data_schema=None,
)
FarmAlertsTimelineResponseSerializer = build_envelope_serializer(
"FarmAlertsTimelineResponseSerializer",
data_schema=None,
)
class FarmAlertsTrackerView(APIView):
@extend_schema(
tags=["Farm Alerts"],
summary="ارزیابی tracker هشدارهای مزرعه",
description=(
"با دریافت farm_uuid، هشدارهای مزرعه را تحلیل می کند، "
"کانتکست مزرعه را به RAG می فرستد، و notificationهای سطح خطر/هشدار/اطلاع رسانی را در دیتابیس ذخیره می کند."
),
request=FarmAlertsRequestSerializer,
responses={
200: build_response(FarmAlertsTrackerResponseSerializer, "خروجی tracker هشدارهای مزرعه."),
400: build_response(FarmAlertsValidationErrorSerializer, "پارامتر ورودی نامعتبر است."),
500: build_response(FarmAlertsValidationErrorSerializer, "خطا در تولید خروجی tracker هشدارها."),
},
examples=[
OpenApiExample(
"نمونه درخواست tracker",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
},
request_only=True,
),
],
)
def post(self, request):
serializer = FarmAlertsRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
validated = serializer.validated_data
try:
result = get_farm_alerts_tracker(
farm_uuid=validated["farm_uuid"],
query=validated.get("query"),
)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در تولید tracker هشدارها: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)
class FarmAlertsTimelineView(APIView):
@extend_schema(
tags=["Farm Alerts"],
summary="دریافت timeline هشدارهای مزرعه",
description=(
"با دریافت farm_uuid، timeline هشدارهای مزرعه را با کمک RAG می سازد "
"و notificationهای استخراج شده را در دیتابیس ذخیره می کند."
),
request=FarmAlertsRequestSerializer,
responses={
200: build_response(FarmAlertsTimelineResponseSerializer, "خروجی timeline هشدارهای مزرعه."),
400: build_response(FarmAlertsValidationErrorSerializer, "پارامتر ورودی نامعتبر است."),
500: build_response(FarmAlertsValidationErrorSerializer, "خطا در تولید timeline هشدارها."),
},
examples=[
OpenApiExample(
"نمونه درخواست timeline",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
},
request_only=True,
),
],
)
def post(self, request):
serializer = FarmAlertsRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
validated = serializer.validated_data
try:
result = get_farm_alerts_timeline(
farm_uuid=validated["farm_uuid"],
query=validated.get("query"),
)
except Exception as exc:
return Response(
{"code": 500, "msg": f"خطا در تولید timeline هشدارها: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)