Files
Ai/farm_alerts/services.py
T
2026-04-25 17:22:41 +03:30

441 lines
17 KiB
Python

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,
}