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 _validate_tracker_response(payload: dict[str, Any]) -> dict[str, Any]: required_keys = {"headline", "overview", "status_level", "notifications"} missing = [key for key in required_keys if key not in payload] if missing: raise ValueError( "Farm alerts tracker response is missing required fields: " + ", ".join(missing) ) if not isinstance(payload.get("notifications"), list): raise ValueError("Farm alerts tracker notifications must be a list.") return payload def _validate_timeline_response(payload: dict[str, Any]) -> dict[str, Any]: required_keys = {"headline", "overview", "timeline", "notifications"} missing = [key for key in required_keys if key not in payload] if missing: raise ValueError( "Farm alerts timeline response is missing required fields: " + ", ".join(missing) ) if not isinstance(payload.get("timeline"), list): raise ValueError("Farm alerts timeline must be a list.") if not isinstance(payload.get("notifications"), list): raise ValueError("Farm alerts timeline notifications must be a list.") return payload 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) if not parsed: raise ValueError("farm_alerts LLM returned an empty or invalid JSON payload.") _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)) raise RuntimeError(f"Farm alerts generation failed for farm {farm_uuid}.") from exc 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, ) llm_result = _validate_tracker_response(llm_result) notifications_input = llm_result["notifications"] saved_notifications = _save_notifications( farm_uuid=farm_uuid, endpoint=FarmAlertNotification.ENDPOINT_TRACKER, notifications=notifications_input, ) return { "farm_uuid": farm_uuid, "service_id": SERVICE_ID, "tracker": tracker, "headline": llm_result["headline"], "overview": llm_result["overview"], "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, ) llm_result = _validate_timeline_response(llm_result) timeline = llm_result["timeline"] notifications_input = llm_result["notifications"] saved_notifications = _save_notifications( farm_uuid=farm_uuid, endpoint=FarmAlertNotification.ENDPOINT_TIMELINE, notifications=notifications_input, ) return { "farm_uuid": farm_uuid, "service_id": SERVICE_ID, "tracker": tracker, "headline": llm_result["headline"], "overview": llm_result["overview"], "timeline": timeline, "notifications": [_serialize_notification(item) for item in saved_notifications], "raw_llm_response": raw_response or None, "structured_context": structured_context, }