UPDATE
This commit is contained in:
@@ -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],
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user