UPDATE
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
سرویسهای RAG — آبیاری و کودهی
|
||||
بدون API — قابل استفاده از سایر سرویسها
|
||||
"""
|
||||
from .irrigation import get_irrigation_recommendation
|
||||
from .irrigation_plan_parser import IrrigationPlanParserService
|
||||
from .fertilization import get_fertilization_recommendation
|
||||
from .fertilization_plan_parser import FertilizationPlanParserService
|
||||
from .pest_disease import get_pest_disease_detection, get_pest_disease_risk
|
||||
from .soil_anomaly import get_soil_anomaly_insight
|
||||
from .water_need_prediction import get_water_need_prediction_insight
|
||||
from .yield_harvest import YieldHarvestRAGService
|
||||
|
||||
__all__ = [
|
||||
"get_irrigation_recommendation",
|
||||
"IrrigationPlanParserService",
|
||||
"get_fertilization_recommendation",
|
||||
"FertilizationPlanParserService",
|
||||
"get_pest_disease_detection",
|
||||
"get_pest_disease_risk",
|
||||
"get_soil_anomaly_insight",
|
||||
"get_water_need_prediction_insight",
|
||||
"YieldHarvestRAGService",
|
||||
]
|
||||
@@ -0,0 +1,738 @@
|
||||
"""
|
||||
سرویس توصیه کودهی — بدون API، قابل فراخوانی از سایر سرویسها.
|
||||
از RAG با پایگاه دانش fertilization و خروجی optimizer برای ساخت پاسخ ساختاریافته استفاده میکند.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import clone_snapshot_as_runtime_plant, get_farm_plant_snapshot_by_name
|
||||
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 rag.user_data import build_plant_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "fertilization"
|
||||
SERVICE_ID = "fertilization"
|
||||
HECTARE_TO_SQUARE_METER = 10000.0
|
||||
|
||||
DEFAULT_FERTILIZATION_PROMPT = (
|
||||
"از RAG و خروجی بهینه ساز شبیه سازی برای ساخت پاسخ ساختاریافته کودهی استفاده کن. "
|
||||
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان مرجع قطعی اعداد، فرمول، روش مصرف و زمان بندی است. "
|
||||
"پاسخ فقط JSON معتبر بر اساس قرارداد status/data برگردان."
|
||||
)
|
||||
|
||||
DEFAULT_MACRO_DESCRIPTIONS = {
|
||||
"n": "نیتروژن برای حفظ رشد رویشی، رنگ سبز برگ و بازسازی سریع بوته مهم است.",
|
||||
"p": "فسفر به توسعه ریشه، انتقال انرژی و پشتیبانی از گلدهی و استقرار کمک می کند.",
|
||||
"k": "پتاسیم به تنظیم آب، کیفیت محصول و مقاومت گیاه در برابر تنش محیطی کمک می کند.",
|
||||
}
|
||||
|
||||
DEFAULT_MICRO_NAMES = {
|
||||
"fe": "آهن",
|
||||
"zn": "روی",
|
||||
"mn": "منگنز",
|
||||
"b": "بر",
|
||||
"cu": "مس",
|
||||
"mg": "منیزیم",
|
||||
"ca": "کلسیم",
|
||||
"mo": "مولیبدن",
|
||||
}
|
||||
|
||||
DEFAULT_MICRO_DESCRIPTIONS = {
|
||||
"fe": "آهن در ساخت کلروفیل و کاهش زردی بین رگبرگی نقش دارد.",
|
||||
"zn": "روی در رشد متعادل، تشکیل هورمون ها و فعالیت آنزیمی موثر است.",
|
||||
"mn": "منگنز در فتوسنتز و فعالیت آنزیم های متابولیکی نقش پشتیبان دارد.",
|
||||
"b": "بر در گرده افشانی، تشکیل گل و انتقال قندها اهمیت دارد.",
|
||||
"cu": "مس به فعالیت آنزیمی و استحکام نسبی بافت های گیاه کمک می کند.",
|
||||
"mg": "منیزیم بخش مرکزی کلروفیل است و در فتوسنتز اهمیت دارد.",
|
||||
"ca": "کلسیم در استحکام دیواره سلولی و کیفیت رشد بافت های جوان موثر است.",
|
||||
"mo": "مولیبدن در متابولیسم نیتروژن و کارایی جذب آن نقش دارد.",
|
||||
}
|
||||
|
||||
DEFAULT_STAGE_LABELS = {
|
||||
"initial": "استقرار",
|
||||
"vegetative": "رشد رویشی",
|
||||
"flowering": "گلدهی",
|
||||
"fruiting": "میوه دهی",
|
||||
}
|
||||
|
||||
|
||||
def _get_optimizer():
|
||||
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float | None = None) -> float | None:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _stage_key(growth_stage: str | None) -> str:
|
||||
text = (growth_stage or "").strip().lower()
|
||||
if any(token in text for token in ("flower", "گل", "anthesis")):
|
||||
return "flowering"
|
||||
if any(token in text for token in ("fruit", "میوه", "برداشت", "ripen", "harvest")):
|
||||
return "fruiting"
|
||||
if any(token in text for token in ("initial", "seed", "جوانه", "نشا", "استقرار")):
|
||||
return "initial"
|
||||
return "vegetative"
|
||||
|
||||
|
||||
def _clean_json_response(raw: str) -> dict[str, Any]:
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_label(value: float) -> str:
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return f"{value:.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _parse_npk_ratio(formula: str | None) -> dict[str, float | str]:
|
||||
if not formula:
|
||||
return {"n": 0.0, "p": 0.0, "k": 0.0, "label": "0-0-0"}
|
||||
|
||||
parts = re.findall(r"\d+(?:\.\d+)?", formula)
|
||||
if len(parts) < 3:
|
||||
return {"n": 0.0, "p": 0.0, "k": 0.0, "label": formula}
|
||||
|
||||
n, p, k = (_safe_float(part, 0.0) or 0.0 for part in parts[:3])
|
||||
return {
|
||||
"n": round(n, 3),
|
||||
"p": round(p, 3),
|
||||
"k": round(k, 3),
|
||||
"label": f"{_normalize_label(n)}-{_normalize_label(p)}-{_normalize_label(k)}",
|
||||
}
|
||||
|
||||
|
||||
def _method_id(label: str) -> str:
|
||||
text = (label or "").strip()
|
||||
if "محلول" in text and ("آبیاری" in text or "کودآبیاری" in text):
|
||||
return "foliar_fertigation"
|
||||
if "محلول" in text:
|
||||
return "foliar_spray"
|
||||
if "آبیاری" in text or "کودآبیاری" in text:
|
||||
return "fertigation"
|
||||
if "سرک" in text or "خاک" in text or "نواری" in text:
|
||||
return "soil_application"
|
||||
return "custom_application"
|
||||
|
||||
|
||||
def _slug_value(value: str) -> str:
|
||||
token = re.sub(r"[^a-zA-Z0-9]+", "-", (value or "").strip().lower()).strip("-")
|
||||
return token or "fertilizer"
|
||||
|
||||
|
||||
def _fertilizer_display_name(formula: str | None) -> str:
|
||||
ratio = _parse_npk_ratio(formula)
|
||||
label = ratio["label"] if ratio["label"] else (formula or "کود پیشنهادی")
|
||||
if label and label != "0-0-0":
|
||||
return f"کود کامل {label}"
|
||||
return formula or "کود پیشنهادی"
|
||||
|
||||
|
||||
def _fertilizer_type_label(formula: str | None) -> str:
|
||||
ratio = _parse_npk_ratio(formula)
|
||||
if ratio["label"] and ratio["label"] != "0-0-0":
|
||||
return "NPK"
|
||||
return formula or "Fertilizer"
|
||||
|
||||
|
||||
def _first_text(*values: Any) -> str:
|
||||
for value in values:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _default_application_steps(application_method: str) -> list[dict[str, Any]]:
|
||||
if "محلول" in application_method:
|
||||
return [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "آماده سازی",
|
||||
"description": "دوز توصیه شده را در مقدار کمی آب تمیز حل کنید تا محلول یکنواخت به دست آید.",
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "اختلاط",
|
||||
"description": "محلول را به مخزن اصلی اضافه کنید و همزمان هم بزنید تا ته نشینی رخ ندهد.",
|
||||
},
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "مصرف",
|
||||
"description": "در ساعات خنک روز به صورت یکنواخت محلول پاشی کنید و پس از اجرا بوته را پایش کنید.",
|
||||
},
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "آماده سازی",
|
||||
"description": "مقدار توصیه شده را بر اساس مساحت مزرعه اندازه گیری و پیش از اجرا یکنواخت تقسیم کنید.",
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "تزریق یا پخش",
|
||||
"description": "کود را از طریق کودآبیاری یا مصرف خاکی سبک مطابق روش پیشنهادی وارد مزرعه کنید.",
|
||||
},
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "پایش",
|
||||
"description": "پس از اجرا رطوبت خاک، وضعیت برگ و پاسخ بوته را تا نوبت بعدی بررسی کنید.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _warning_from_weather(forecasts: list[Any], application_method: str) -> str:
|
||||
if not forecasts:
|
||||
return "هنگام مصرف از دستکش و ماسک استفاده کنید و قبل از اختلاط آزمون سازگاری در مقیاس کوچک انجام دهید."
|
||||
|
||||
rainy = next(
|
||||
(
|
||||
item
|
||||
for item in forecasts
|
||||
if (_safe_float(getattr(item, "precipitation", None), 0.0) or 0.0) >= 3.0
|
||||
),
|
||||
None,
|
||||
)
|
||||
hot = next(
|
||||
(
|
||||
item
|
||||
for item in forecasts
|
||||
if (_safe_float(getattr(item, "temperature_max", None), 0.0) or 0.0) >= 32.0
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if rainy is not None and "محلول" in application_method:
|
||||
return (
|
||||
f"به دلیل احتمال بارش موثر در {rainy.forecast_date} محلول پاشی را به پنجره خشک منتقل کنید و "
|
||||
"در زمان اجرا از ماسک و دستکش استفاده شود."
|
||||
)
|
||||
if hot is not None:
|
||||
return (
|
||||
"به دلیل گرمای پیش رو، مصرف را فقط در صبح زود یا نزدیک غروب انجام دهید و از اختلاط غلیظ خودداری کنید."
|
||||
)
|
||||
return "هنگام مصرف از دستکش و ماسک استفاده کنید و پیش از اختلاط با سایر نهاده ها آزمون سازگاری انجام دهید."
|
||||
|
||||
|
||||
def _fallback_optimizer_result(growth_stage: str | None) -> dict[str, Any]:
|
||||
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
||||
stage_key = _stage_key(growth_stage)
|
||||
target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
|
||||
base_amount = round(max(40.0, (target["n"] * 1.25)), 2)
|
||||
return {
|
||||
"engine": "defaults",
|
||||
"recommended_strategy": {
|
||||
"code": stage_key,
|
||||
"label": DEFAULT_STAGE_LABELS.get(stage_key, stage_key),
|
||||
"score": 0.0,
|
||||
"expected_yield_index": 0.0,
|
||||
"fertilizer_type": target["formula"],
|
||||
"amount_kg_per_ha": base_amount,
|
||||
"application_method": target["application_method"],
|
||||
"timing": target["timing"],
|
||||
"validity_period": f"معتبر برای {defaults['validity_days']} روز آینده",
|
||||
"reasoning": [
|
||||
"پیشنهاد از تنظیمات پایه مرحله رشد ساخته شد زیرا خروجی کامل optimizer در دسترس نبود.",
|
||||
f"فرمول هدف مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} برابر با {target['formula']} در نظر گرفته شد.",
|
||||
],
|
||||
},
|
||||
"alternatives": [],
|
||||
"context_text": "fallback fertilization context",
|
||||
}
|
||||
|
||||
|
||||
def _build_legacy_sections(
|
||||
structured_data: dict[str, Any],
|
||||
recommended_strategy: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
primary = structured_data.get("primary_recommendation", {})
|
||||
guide = structured_data.get("application_guide", {})
|
||||
recommended_strategy = recommended_strategy or {}
|
||||
return [
|
||||
{
|
||||
"type": "recommendation",
|
||||
"title": primary.get("display_title") or "برنامه کودهی",
|
||||
"icon": "leaf",
|
||||
"content": primary.get("summary", ""),
|
||||
"fertilizerType": primary.get("npk_ratio", {}).get("label") or primary.get("fertilizer_type", ""),
|
||||
"amount": primary.get("dosage", {}).get("label", ""),
|
||||
"applicationMethod": primary.get("application_method", {}).get("label", ""),
|
||||
"timing": recommended_strategy.get("timing", ""),
|
||||
"validityPeriod": recommended_strategy.get("validity_period", ""),
|
||||
"expandableExplanation": primary.get("reasoning", ""),
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"title": "مراحل مصرف",
|
||||
"icon": "list",
|
||||
"items": [step.get("title", "") for step in guide.get("steps", []) if step.get("title")],
|
||||
},
|
||||
{
|
||||
"type": "warning",
|
||||
"title": "هشدار کودهی",
|
||||
"icon": "alert-triangle",
|
||||
"content": guide.get("safety_warning", ""),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _coerce_steps(value: Any, application_method: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return _default_application_steps(application_method)
|
||||
|
||||
steps = []
|
||||
for index, item in enumerate(value, start=1):
|
||||
if isinstance(item, dict):
|
||||
title = _first_text(item.get("title"), f"مرحله {index}")
|
||||
description = _first_text(item.get("description"), item.get("content"))
|
||||
if not description:
|
||||
continue
|
||||
steps.append(
|
||||
{
|
||||
"step_number": int(item.get("step_number") or index),
|
||||
"title": title,
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
elif isinstance(item, str) and item.strip():
|
||||
steps.append(
|
||||
{
|
||||
"step_number": index,
|
||||
"title": f"مرحله {index}",
|
||||
"description": item.strip(),
|
||||
}
|
||||
)
|
||||
return steps or _default_application_steps(application_method)
|
||||
|
||||
|
||||
def _normalize_micro_items(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
items = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = _first_text(item.get("key")).lower()
|
||||
if not key:
|
||||
continue
|
||||
nutrient_value = _safe_float(item.get("value"))
|
||||
if nutrient_value is None:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": _first_text(item.get("name"), DEFAULT_MICRO_NAMES.get(key, key.upper())),
|
||||
"value": round(nutrient_value, 3),
|
||||
"unit": "percent",
|
||||
"description": _first_text(item.get("description"), DEFAULT_MICRO_DESCRIPTIONS.get(key, "")),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _build_nutrient_analysis(llm_analysis: dict[str, Any] | None, npk_ratio: dict[str, Any]) -> dict[str, Any]:
|
||||
llm_analysis = llm_analysis if isinstance(llm_analysis, dict) else {}
|
||||
macro_by_key: dict[str, dict[str, Any]] = {}
|
||||
for item in llm_analysis.get("macro", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = _first_text(item.get("key")).lower()
|
||||
if key:
|
||||
macro_by_key[key] = item
|
||||
|
||||
macro = []
|
||||
for key, name in (("n", "نیتروژن (N)"), ("p", "فسفر (P)"), ("k", "پتاسیم (K)")):
|
||||
source = macro_by_key.get(key, {})
|
||||
macro.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": name,
|
||||
"value": round(_safe_float(npk_ratio.get(key), 0.0) or 0.0, 3),
|
||||
"unit": "percent",
|
||||
"description": _first_text(source.get("description"), DEFAULT_MACRO_DESCRIPTIONS[key]),
|
||||
}
|
||||
)
|
||||
|
||||
return {"macro": macro, "micro": _normalize_micro_items(llm_analysis.get("micro"))}
|
||||
|
||||
|
||||
def _build_application_guide(
|
||||
llm_guide: dict[str, Any] | None,
|
||||
*,
|
||||
application_method: str,
|
||||
warning_text: str,
|
||||
) -> dict[str, Any]:
|
||||
llm_guide = llm_guide if isinstance(llm_guide, dict) else {}
|
||||
return {
|
||||
"safety_warning": _first_text(llm_guide.get("safety_warning"), warning_text),
|
||||
"steps": _coerce_steps(llm_guide.get("steps"), application_method),
|
||||
}
|
||||
|
||||
|
||||
def _build_alternative_recommendations(
|
||||
llm_alternatives: Any,
|
||||
optimizer_alternatives: list[dict[str, Any]],
|
||||
recommended_strategy: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
llm_items = llm_alternatives if isinstance(llm_alternatives, list) else []
|
||||
alternatives = []
|
||||
|
||||
for index, optimizer_item in enumerate(optimizer_alternatives[:3]):
|
||||
llm_item = llm_items[index] if index < len(llm_items) and isinstance(llm_items[index], dict) else {}
|
||||
formula = _first_text(
|
||||
llm_item.get("fertilizer_code"),
|
||||
optimizer_item.get("fertilizer_type"),
|
||||
recommended_strategy.get("fertilizer_type"),
|
||||
)
|
||||
display_name = _first_text(llm_item.get("fertilizer_name"), _fertilizer_display_name(formula), optimizer_item.get("label"))
|
||||
description = _first_text(
|
||||
llm_item.get("description"),
|
||||
*(optimizer_item.get("reasoning") or []),
|
||||
f"این گزینه با امتیاز {optimizer_item.get('score', 0)} برای شرایط مشابه قابل استفاده است.",
|
||||
)
|
||||
alternatives.append(
|
||||
{
|
||||
"fertilizer_code": _slug_value(formula or optimizer_item.get("code", f"alt-{index + 1}")),
|
||||
"fertilizer_name": display_name,
|
||||
"fertilizer_type": _first_text(llm_item.get("fertilizer_type"), _fertilizer_type_label(formula)),
|
||||
"usage_method": _first_text(
|
||||
llm_item.get("usage_method"),
|
||||
optimizer_item.get("application_method"),
|
||||
recommended_strategy.get("application_method"),
|
||||
),
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
|
||||
for llm_item in llm_items[len(alternatives):3]:
|
||||
if not isinstance(llm_item, dict):
|
||||
continue
|
||||
fertilizer_name = _first_text(llm_item.get("fertilizer_name"))
|
||||
fertilizer_code = _first_text(llm_item.get("fertilizer_code"), fertilizer_name)
|
||||
if not fertilizer_name or not fertilizer_code:
|
||||
continue
|
||||
alternatives.append(
|
||||
{
|
||||
"fertilizer_code": _slug_value(fertilizer_code),
|
||||
"fertilizer_name": fertilizer_name,
|
||||
"fertilizer_type": _first_text(llm_item.get("fertilizer_type"), "Fertilizer"),
|
||||
"usage_method": _first_text(llm_item.get("usage_method"), recommended_strategy.get("application_method", "")),
|
||||
"description": _first_text(llm_item.get("description"), "گزینه جایگزین در صورت محدودیت تامین یا تغییر شرایط مزرعه."),
|
||||
}
|
||||
)
|
||||
|
||||
return alternatives
|
||||
|
||||
|
||||
def _normalize_llm_payload(parsed_result: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(parsed_result, dict):
|
||||
return {"status": "success", "data": {}}
|
||||
|
||||
if isinstance(parsed_result.get("data"), dict):
|
||||
status = parsed_result.get("status") or "success"
|
||||
return {"status": status, "data": parsed_result["data"]}
|
||||
|
||||
if any(key in parsed_result for key in ("primary_recommendation", "nutrient_analysis", "application_guide")):
|
||||
status = parsed_result.get("status") or "success"
|
||||
return {"status": status, "data": parsed_result}
|
||||
|
||||
sections = parsed_result.get("sections")
|
||||
if isinstance(sections, list):
|
||||
recommendation = next((item for item in sections if isinstance(item, dict) and item.get("type") == "recommendation"), {})
|
||||
list_section = next((item for item in sections if isinstance(item, dict) and item.get("type") == "list"), {})
|
||||
warning = next((item for item in sections if isinstance(item, dict) and item.get("type") == "warning"), {})
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"primary_recommendation": {
|
||||
"display_title": _first_text(recommendation.get("title"), recommendation.get("fertilizerType")),
|
||||
"reasoning": _first_text(recommendation.get("expandableExplanation"), recommendation.get("content")),
|
||||
"summary": _first_text(recommendation.get("content"), recommendation.get("title")),
|
||||
},
|
||||
"application_guide": {
|
||||
"safety_warning": _first_text(warning.get("content")),
|
||||
"steps": list_section.get("items", []),
|
||||
},
|
||||
"alternative_recommendations": [],
|
||||
},
|
||||
}
|
||||
|
||||
return {"status": "success", "data": {}}
|
||||
|
||||
|
||||
def _build_final_response(
|
||||
*,
|
||||
llm_payload: dict[str, Any],
|
||||
optimized_result: dict[str, Any] | None,
|
||||
plant_name: str | None,
|
||||
crop_id: str | None,
|
||||
growth_stage: str | None,
|
||||
forecasts: list[Any],
|
||||
) -> dict[str, Any]:
|
||||
normalized_llm = _normalize_llm_payload(llm_payload)
|
||||
advisory = normalized_llm.get("data", {}) if isinstance(normalized_llm.get("data"), dict) else {}
|
||||
optimizer_payload = optimized_result or _fallback_optimizer_result(growth_stage)
|
||||
recommended = optimizer_payload.get("recommended_strategy", {})
|
||||
|
||||
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
||||
stage_key = _stage_key(growth_stage)
|
||||
stage_target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
|
||||
|
||||
formula = _first_text(recommended.get("fertilizer_type"), stage_target.get("formula"))
|
||||
npk_ratio = _parse_npk_ratio(formula)
|
||||
application_method_label = _first_text(recommended.get("application_method"), stage_target.get("application_method"))
|
||||
amount_kg_per_ha = round(_safe_float(recommended.get("amount_kg_per_ha"), 0.0) or 0.0, 3)
|
||||
amount_per_square_meter = round(amount_kg_per_ha / HECTARE_TO_SQUARE_METER, 6)
|
||||
interval_days = int(
|
||||
stage_target.get(
|
||||
"application_interval_days",
|
||||
defaults.get("default_application_interval_days", 14),
|
||||
)
|
||||
)
|
||||
|
||||
primary_advisory = advisory.get("primary_recommendation") if isinstance(advisory.get("primary_recommendation"), dict) else {}
|
||||
reasoning = _first_text(primary_advisory.get("reasoning"), " ".join(recommended.get("reasoning", [])))
|
||||
if not reasoning:
|
||||
reasoning = "این توصیه با اتکا به مرحله رشد، وضعیت خاک و خروجی بهینه ساز شبیه سازی تنظیم شده است."
|
||||
|
||||
summary = _first_text(primary_advisory.get("summary"))
|
||||
if not summary:
|
||||
summary = f"{_fertilizer_display_name(formula)} برای مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} مناسب ارزیابی شده است."
|
||||
|
||||
warning_text = _warning_from_weather(forecasts, application_method_label)
|
||||
nutrient_analysis = _build_nutrient_analysis(advisory.get("nutrient_analysis"), npk_ratio)
|
||||
application_guide = _build_application_guide(
|
||||
advisory.get("application_guide"),
|
||||
application_method=application_method_label,
|
||||
warning_text=warning_text,
|
||||
)
|
||||
alternatives = _build_alternative_recommendations(
|
||||
advisory.get("alternative_recommendations"),
|
||||
optimizer_payload.get("alternatives", []),
|
||||
recommended,
|
||||
)
|
||||
|
||||
structured_data = {
|
||||
"primary_recommendation": {
|
||||
"fertilizer_code": _slug_value(formula),
|
||||
"fertilizer_name": _first_text(primary_advisory.get("fertilizer_name"), _fertilizer_display_name(formula)),
|
||||
"display_title": _first_text(primary_advisory.get("display_title"), _fertilizer_display_name(formula)),
|
||||
"fertilizer_type": _first_text(primary_advisory.get("fertilizer_type"), _fertilizer_type_label(formula)),
|
||||
"npk_ratio": npk_ratio,
|
||||
"application_method": {
|
||||
"id": _method_id(application_method_label),
|
||||
"label": application_method_label,
|
||||
},
|
||||
"application_interval": {
|
||||
"value": interval_days,
|
||||
"unit": "day",
|
||||
"label": f"هر {interval_days} روز",
|
||||
},
|
||||
"dosage": {
|
||||
"base_amount_per_hectare": amount_kg_per_ha,
|
||||
"base_amount_per_square_meter": amount_per_square_meter,
|
||||
"unit": "kg",
|
||||
"label": f"{_normalize_label(amount_kg_per_ha)} کیلوگرم در هکتار",
|
||||
"calculation_basis": optimizer_payload.get("engine", "product"),
|
||||
},
|
||||
"reasoning": reasoning,
|
||||
"summary": summary,
|
||||
},
|
||||
"nutrient_analysis": nutrient_analysis,
|
||||
"application_guide": application_guide,
|
||||
"alternative_recommendations": alternatives,
|
||||
}
|
||||
|
||||
structured_data["sections"] = _build_legacy_sections(structured_data, recommended)
|
||||
return {"status": normalized_llm.get("status") or "success", "data": structured_data}
|
||||
|
||||
|
||||
def _validate_fertilization_response(parsed_result: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(parsed_result, dict):
|
||||
raise ValueError("Fertilization recommendation response is not a JSON object.")
|
||||
data = parsed_result.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Fertilization recommendation response is missing data.")
|
||||
if not isinstance(data.get("primary_recommendation"), dict):
|
||||
raise ValueError("Fertilization recommendation response is missing primary_recommendation.")
|
||||
return parsed_result
|
||||
|
||||
|
||||
def get_fertilization_recommendation(
|
||||
farm_uuid: str | None = None,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
crop_id: str | None = None,
|
||||
query: str | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
limit: int = 8,
|
||||
sensor_uuid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
توصیه کودهی برای یک مزرعه.
|
||||
از RAG با پایگاه دانش fertilization استفاده می کند و خروجی نهایی را با optimizer ترکیب می کند.
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
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
|
||||
|
||||
resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip()
|
||||
if not resolved_farm_uuid:
|
||||
raise ValueError("farm_uuid is required.")
|
||||
|
||||
user_query = query or "توصیه کودهی بهینه برای مزرعه من چیست؟"
|
||||
|
||||
sensor = (
|
||||
SensorData.objects.select_related("center_location")
|
||||
.prefetch_related("plant_assignments__plant")
|
||||
.filter(farm_uuid=resolved_farm_uuid)
|
||||
.first()
|
||||
)
|
||||
|
||||
plant_config = apps.get_app_config("plant")
|
||||
resolved_plant_name = plant_config.resolve_plant_name(plant_name)
|
||||
if not resolved_plant_name and crop_id:
|
||||
resolved_plant_name = plant_config.resolve_plant_name(crop_id)
|
||||
resolved_growth_stage = plant_config.resolve_growth_stage(growth_stage)
|
||||
|
||||
plant = None
|
||||
if sensor is not None:
|
||||
selected_snapshot = get_farm_plant_snapshot_by_name(sensor, resolved_plant_name)
|
||||
plant = clone_snapshot_as_runtime_plant(
|
||||
selected_snapshot,
|
||||
growth_stage=resolved_growth_stage,
|
||||
)
|
||||
if selected_snapshot is not None:
|
||||
resolved_plant_name = selected_snapshot.name
|
||||
|
||||
forecasts = []
|
||||
optimized_result = None
|
||||
if sensor is not None and getattr(sensor, "center_location", None) is not None:
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
forecasts = list(
|
||||
WeatherForecast.objects.filter(
|
||||
location=sensor.center_location,
|
||||
forecast_date__isnull=False,
|
||||
).order_by("forecast_date")[:7]
|
||||
)
|
||||
if sensor is not None and plant is not None:
|
||||
optimized_result = _get_optimizer().optimize_fertilization(
|
||||
sensor=sensor,
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
growth_stage=resolved_growth_stage,
|
||||
)
|
||||
|
||||
context = build_rag_context(
|
||||
user_query,
|
||||
resolved_farm_uuid,
|
||||
config=cfg,
|
||||
limit=limit,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
)
|
||||
|
||||
extra_parts: list[str] = []
|
||||
if resolved_plant_name and resolved_growth_stage:
|
||||
plant_text = build_plant_text(resolved_plant_name, resolved_growth_stage)
|
||||
if plant_text:
|
||||
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
if optimized_result is not None:
|
||||
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
|
||||
if extra_parts:
|
||||
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
|
||||
|
||||
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(DEFAULT_FERTILIZATION_PROMPT)
|
||||
if context:
|
||||
system_parts.append("\n\n" + context)
|
||||
system_content = "\n".join(system_parts)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": user_query},
|
||||
]
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=resolved_farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_query,
|
||||
system_prompt=system_content,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
except Exception as exc:
|
||||
logger.error("Fertilization recommendation error for %s: %s", resolved_farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RuntimeError(
|
||||
f"Fertilization recommendation failed for farm {resolved_farm_uuid}."
|
||||
) from exc
|
||||
|
||||
llm_payload = _clean_json_response(raw)
|
||||
result = _build_final_response(
|
||||
llm_payload=llm_payload,
|
||||
optimized_result=optimized_result,
|
||||
plant_name=resolved_plant_name,
|
||||
crop_id=crop_id,
|
||||
growth_stage=resolved_growth_stage,
|
||||
forecasts=forecasts,
|
||||
)
|
||||
result = _validate_fertilization_response(result)
|
||||
result["raw_response"] = raw
|
||||
result["simulation_optimizer"] = optimized_result
|
||||
result["sections"] = result["data"].get("sections", [])
|
||||
_complete_audit_log(
|
||||
audit_log,
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,398 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ID = "fertilization_plan_parser"
|
||||
KB_NAME = "fertilization_plan_parser"
|
||||
CORE_FIELDS = [
|
||||
"crop_name",
|
||||
"growth_stage",
|
||||
"fertilizer_name",
|
||||
"formula",
|
||||
"amount",
|
||||
"application_method",
|
||||
"timing",
|
||||
"interval_days",
|
||||
]
|
||||
|
||||
FERTILIZATION_PLAN_PROMPT = (
|
||||
"شما یک تحلیل گر برنامه کودهی هستی. "
|
||||
"کاربر ممکن است برنامه کودهی را کامل یا ناقص توضیح دهد. "
|
||||
"فقط JSON معتبر برگردان و هرگز متن خارج از JSON، markdown یا کلید اضافه تولید نکن. "
|
||||
"اگر اطلاعات کافی بود status را completed بگذار و final_plan را تکمیل کن. "
|
||||
"اگر اطلاعات ناقص بود status را needs_clarification بگذار، missing_fields را پر کن و در questions سوال های کوتاه و دقیق برگردان. "
|
||||
"اگر چند کود در متن بود، همه را در applications لیست کن. "
|
||||
"اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. "
|
||||
"در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. "
|
||||
"از حدس زدن مقدار، زمان یا روش مصرف خودداری کن. "
|
||||
"Schema: "
|
||||
"{"
|
||||
'"status": "completed" | "needs_clarification", '
|
||||
'"summary": string, '
|
||||
'"missing_fields": [string], '
|
||||
'"questions": [{"id": string, "field": string, "question": string, "rationale": string}], '
|
||||
'"collected_data": {'
|
||||
'"crop_name": string|null, '
|
||||
'"growth_stage": string|null, '
|
||||
'"objective": string|null, '
|
||||
'"applications": ['
|
||||
"{"
|
||||
'"fertilizer_name": string|null, '
|
||||
'"formula": string|null, '
|
||||
'"amount": string|null, '
|
||||
'"application_method": string|null, '
|
||||
'"timing": string|null, '
|
||||
'"interval_days": integer|null, '
|
||||
'"purpose": string|null'
|
||||
"}"
|
||||
"], "
|
||||
'"notes": [string]'
|
||||
"}, "
|
||||
'"final_plan": {same shape as collected_data} | null'
|
||||
"}."
|
||||
)
|
||||
|
||||
|
||||
class ClarificationQuestionSchema(BaseModel):
|
||||
id: str
|
||||
field: str
|
||||
question: str
|
||||
rationale: str = ""
|
||||
|
||||
|
||||
class FertilizerApplicationSchema(BaseModel):
|
||||
fertilizer_name: str | None = None
|
||||
formula: str | None = None
|
||||
amount: str | None = None
|
||||
application_method: str | None = None
|
||||
timing: str | None = None
|
||||
interval_days: int | None = None
|
||||
purpose: str | None = None
|
||||
|
||||
|
||||
class FertilizationPlanSchema(BaseModel):
|
||||
crop_name: str | None = None
|
||||
growth_stage: str | None = None
|
||||
objective: str | None = None
|
||||
applications: list[FertilizerApplicationSchema] = Field(default_factory=list)
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FertilizationPlanParseResultSchema(BaseModel):
|
||||
status: Literal["completed", "needs_clarification"]
|
||||
summary: str
|
||||
missing_fields: list[str] = Field(default_factory=list)
|
||||
questions: list[ClarificationQuestionSchema] = Field(default_factory=list)
|
||||
collected_data: FertilizationPlanSchema = Field(default_factory=FertilizationPlanSchema)
|
||||
final_plan: FertilizationPlanSchema | None = None
|
||||
|
||||
|
||||
class FertilizationPlanParserService:
|
||||
def parse_plan(
|
||||
self,
|
||||
*,
|
||||
message: str = "",
|
||||
answers: dict[str, Any] | None = None,
|
||||
partial_plan: dict[str, Any] | None = None,
|
||||
farm_uuid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = self._build_service_client(cfg)
|
||||
|
||||
normalized_message = (message or "").strip()
|
||||
normalized_answers = answers if isinstance(answers, dict) else {}
|
||||
normalized_partial = partial_plan if isinstance(partial_plan, dict) else {}
|
||||
structured_context = {
|
||||
"message": normalized_message,
|
||||
"answers": normalized_answers,
|
||||
"partial_plan": normalized_partial,
|
||||
"required_core_fields": CORE_FIELDS,
|
||||
"service": "fertilization_plan_parser",
|
||||
}
|
||||
|
||||
rag_query = self._build_retrieval_query(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
)
|
||||
rag_context = build_rag_context(
|
||||
query=rag_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
)
|
||||
system_prompt, messages = self._build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
structured_context=structured_context,
|
||||
rag_context=rag_context,
|
||||
)
|
||||
|
||||
audit_log = None
|
||||
if farm_uuid:
|
||||
try:
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=rag_query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Fertilization plan parser audit log creation failed for %s: %s", farm_uuid, exc)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw = (response.choices[0].message.content or "").strip()
|
||||
parsed = self._clean_json(raw)
|
||||
validated = FertilizationPlanParseResultSchema.model_validate(parsed)
|
||||
normalized = self._normalize_result(validated)
|
||||
if audit_log is not None:
|
||||
_complete_audit_log(audit_log, raw)
|
||||
return normalized
|
||||
except (ValidationError, ValueError, KeyError, IndexError) as exc:
|
||||
logger.warning("Fertilization plan parser parsing failed: %s", exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
return self._fallback_result(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
partial_plan=normalized_partial,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Fertilization plan parser failed: %s", exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
return self._fallback_result(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
partial_plan=normalized_partial,
|
||||
)
|
||||
|
||||
def _build_service_client(self, cfg: RAGConfig):
|
||||
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)
|
||||
return service, client, service.llm.model
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
structured_context: dict[str, Any],
|
||||
rag_context: str,
|
||||
) -> 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(FERTILIZATION_PLAN_PROMPT)
|
||||
system_parts.append(
|
||||
"[structured_context]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
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": "برنامه کودهی را استخراج یا برای تکمیل آن سوال بپرس."},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
def _build_retrieval_query(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
answers: dict[str, Any],
|
||||
) -> str:
|
||||
answer_lines = [f"{key}: {value}" for key, value in answers.items()]
|
||||
parts = [part for part in [message, "\n".join(answer_lines)] if part]
|
||||
return "\n".join(parts) or "استخراج برنامه کودهی از متن کاربر"
|
||||
|
||||
def _normalize_result(self, validated: FertilizationPlanParseResultSchema) -> dict[str, Any]:
|
||||
collected = validated.collected_data.model_dump()
|
||||
final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None
|
||||
missing_fields = list(dict.fromkeys(validated.missing_fields))
|
||||
computed_missing = self._find_missing_fields(final_plan or collected)
|
||||
for field in computed_missing:
|
||||
if field not in missing_fields:
|
||||
missing_fields.append(field)
|
||||
|
||||
can_complete = validated.status == "completed" and not missing_fields
|
||||
|
||||
if can_complete:
|
||||
final_plan = final_plan or collected
|
||||
questions: list[dict[str, Any]] = []
|
||||
status_fa = "تکمیل شد"
|
||||
else:
|
||||
questions = [item.model_dump() for item in validated.questions]
|
||||
if not questions and missing_fields:
|
||||
questions = self._build_generic_questions(missing_fields)
|
||||
final_plan = None
|
||||
validated.status = "needs_clarification"
|
||||
status_fa = "نیازمند پرسش تکمیلی"
|
||||
|
||||
return {
|
||||
"status": "completed" if can_complete else "needs_clarification",
|
||||
"status_fa": status_fa,
|
||||
"summary": validated.summary,
|
||||
"missing_fields": missing_fields,
|
||||
"questions": questions,
|
||||
"collected_data": collected,
|
||||
"final_plan": final_plan,
|
||||
}
|
||||
|
||||
def _fallback_result(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
answers: dict[str, Any],
|
||||
partial_plan: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
applications = partial_plan.get("applications")
|
||||
if not isinstance(applications, list):
|
||||
applications = []
|
||||
notes = list(partial_plan.get("notes") or [])
|
||||
if message:
|
||||
notes.append(f"متن اولیه کاربر: {message}")
|
||||
if answers:
|
||||
notes.append("پاسخ های تکمیلی کاربر دریافت شده است.")
|
||||
|
||||
return {
|
||||
"status": "needs_clarification",
|
||||
"status_fa": "نیازمند پرسش تکمیلی",
|
||||
"summary": "اطلاعات برنامه کودهی برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.",
|
||||
"missing_fields": CORE_FIELDS,
|
||||
"questions": self._build_generic_questions(CORE_FIELDS),
|
||||
"collected_data": {
|
||||
"crop_name": partial_plan.get("crop_name"),
|
||||
"growth_stage": partial_plan.get("growth_stage"),
|
||||
"objective": partial_plan.get("objective"),
|
||||
"applications": applications,
|
||||
"notes": notes,
|
||||
},
|
||||
"final_plan": None,
|
||||
}
|
||||
|
||||
def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]:
|
||||
catalog = {
|
||||
"crop_name": {
|
||||
"id": "crop_name",
|
||||
"field": "crop_name",
|
||||
"question": "این برنامه کودهی برای کدام محصول است؟",
|
||||
"rationale": "نام محصول برای ثبت برنامه لازم است.",
|
||||
},
|
||||
"growth_stage": {
|
||||
"id": "growth_stage",
|
||||
"field": "growth_stage",
|
||||
"question": "محصول الان در چه مرحله رشدی قرار دارد؟",
|
||||
"rationale": "مرحله رشد برای تکمیل برنامه لازم است.",
|
||||
},
|
||||
"fertilizer_name": {
|
||||
"id": "fertilizer_name",
|
||||
"field": "fertilizer_name",
|
||||
"question": "نام کود یا ترکیب کودی چیست؟",
|
||||
"rationale": "بدون نام کود نمی توان برنامه را نهایی کرد.",
|
||||
},
|
||||
"formula": {
|
||||
"id": "formula",
|
||||
"field": "formula",
|
||||
"question": "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20.",
|
||||
"rationale": "ترکیب دقیق کود هنوز مشخص نشده است.",
|
||||
},
|
||||
"amount": {
|
||||
"id": "amount",
|
||||
"field": "amount",
|
||||
"question": "مقدار مصرف هر نوبت کود چقدر است؟",
|
||||
"rationale": "دوز مصرف در متن مشخص نشده است.",
|
||||
},
|
||||
"application_method": {
|
||||
"id": "application_method",
|
||||
"field": "application_method",
|
||||
"question": "روش مصرف کود چیست؟ مثلا کودآبیاری، سرک یا محلول پاشی.",
|
||||
"rationale": "روش اجرا هنوز معلوم نیست.",
|
||||
},
|
||||
"timing": {
|
||||
"id": "timing",
|
||||
"field": "timing",
|
||||
"question": "زمان مصرف کود چه موقع است؟ مثلا هر 10 روز یا بعد از آبیاری.",
|
||||
"rationale": "زمان بندی برنامه نیاز به شفاف سازی دارد.",
|
||||
},
|
||||
"interval_days": {
|
||||
"id": "interval_days",
|
||||
"field": "interval_days",
|
||||
"question": "فاصله بین نوبت های مصرف کود چند روز است؟",
|
||||
"rationale": "عدد فاصله بین نوبت ها برای JSON نهایی لازم است.",
|
||||
},
|
||||
}
|
||||
return [catalog[field] for field in missing_fields if field in catalog][:5]
|
||||
|
||||
def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not isinstance(plan, dict):
|
||||
return CORE_FIELDS[:]
|
||||
|
||||
if plan.get("crop_name") in (None, ""):
|
||||
missing.append("crop_name")
|
||||
if plan.get("growth_stage") in (None, ""):
|
||||
missing.append("growth_stage")
|
||||
|
||||
applications = plan.get("applications")
|
||||
if not isinstance(applications, list) or not applications:
|
||||
return missing + [
|
||||
field
|
||||
for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"]
|
||||
if field not in missing
|
||||
]
|
||||
|
||||
first_application = applications[0] if isinstance(applications[0], dict) else {}
|
||||
for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"]:
|
||||
value = first_application.get(field)
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
missing.append(field)
|
||||
return missing
|
||||
|
||||
def _clean_json(self, 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:
|
||||
raise ValueError("Fertilization plan parser response was empty.")
|
||||
parsed = json.loads(cleaned)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Fertilization plan parser response root must be an object.")
|
||||
return parsed
|
||||
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
سرویس توصیه آبیاری — بدون API، قابل فراخوانی از سایر سرویسها
|
||||
از RAG با پایگاه دانش irrigation و لحن مخصوص آبیاری استفاده میکند.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import transaction
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import (
|
||||
clone_snapshot_as_runtime_plant,
|
||||
get_farm_plant_snapshot_by_name,
|
||||
)
|
||||
from irrigation.evapotranspiration import (
|
||||
calculate_forecast_water_needs,
|
||||
resolve_crop_profile,
|
||||
resolve_kc,
|
||||
)
|
||||
from irrigation.models import IrrigationMethod
|
||||
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 rag.user_data import build_irrigation_method_text, build_plant_text
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "irrigation"
|
||||
SERVICE_ID = "irrigation"
|
||||
|
||||
DEFAULT_IRRIGATION_PROMPT = (
|
||||
"از محاسبات FAO-56 و خروجی بهینه ساز شبیه سازی برای ساخت توصیه آبیاری استفاده کن. "
|
||||
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان را مرجع اصلی اعداد قرار بده. "
|
||||
"پاسخ را در قالب JSON معتبر با کلیدهای plan، timeline و sections برگردان و عدد جدید متناقض نساز."
|
||||
)
|
||||
|
||||
|
||||
def _get_optimizer():
|
||||
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None:
|
||||
if sensor is None or not isinstance(sensor.sensor_payload, dict):
|
||||
return None
|
||||
for payload in sensor.sensor_payload.values():
|
||||
if isinstance(payload, dict) and payload.get(metric) is not None:
|
||||
return _safe_float(payload.get(metric), default=0.0)
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_list(value: Any) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _coerce_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _estimate_duration_minutes(amount_per_event_mm: float, efficiency_percent: float | None) -> int:
|
||||
normalized_efficiency = max(_safe_float(efficiency_percent, 75.0), 30.0)
|
||||
estimated_minutes = round(max(amount_per_event_mm, 1.0) * (2400 / normalized_efficiency))
|
||||
return max(10, min(estimated_minutes, 240))
|
||||
|
||||
|
||||
def _default_warning(
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
soil_moisture: float | None,
|
||||
) -> str:
|
||||
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
|
||||
reasoning = _coerce_list(strategy.get("reasoning"))
|
||||
if reasoning:
|
||||
return str(reasoning[0])
|
||||
if soil_moisture is not None and soil_moisture < 25:
|
||||
return "رطوبت خاک پایین است و نباید آبیاری به تعویق بیفتد."
|
||||
if soil_moisture is not None and soil_moisture > 80:
|
||||
return "رطوبت خاک بالاست و باید از آبیاری اضافی خودداری شود."
|
||||
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
|
||||
return "با توجه به بارش موثر پیش بینی شده، برنامه آبیاری را قبل از اجرا دوباره بررسی کنید."
|
||||
return "در ساعات گرم روز آبیاری انجام نشود."
|
||||
|
||||
|
||||
def _normalize_plan(
|
||||
llm_result: dict[str, Any],
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
irrigation_method: IrrigationMethod | None,
|
||||
soil_moisture: float | None,
|
||||
) -> dict[str, Any]:
|
||||
llm_plan = _coerce_dict(llm_result.get("plan"))
|
||||
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
|
||||
|
||||
frequency = llm_plan.get("frequencyPerWeek")
|
||||
if frequency is None:
|
||||
frequency = strategy.get("frequency_per_week") or strategy.get("events") or len(daily_water_needs) or 1
|
||||
|
||||
duration = llm_plan.get("durationMinutes")
|
||||
if duration is None:
|
||||
duration = _estimate_duration_minutes(
|
||||
_safe_float(strategy.get("amount_per_event_mm"), 6.0),
|
||||
getattr(irrigation_method, "water_efficiency_percent", None),
|
||||
)
|
||||
|
||||
best_time = llm_plan.get("bestTimeOfDay")
|
||||
if not best_time:
|
||||
best_time = strategy.get("timing") or (
|
||||
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "05:30 تا 08:00 صبح"
|
||||
)
|
||||
|
||||
moisture_level = llm_plan.get("moistureLevel")
|
||||
if moisture_level is None:
|
||||
moisture_level = round(
|
||||
soil_moisture
|
||||
if soil_moisture is not None
|
||||
else _safe_float(strategy.get("moisture_target_percent"), 70.0)
|
||||
)
|
||||
|
||||
warning = llm_plan.get("warning")
|
||||
if not warning:
|
||||
warning = _default_warning(optimizer_result, daily_water_needs, soil_moisture)
|
||||
|
||||
return {
|
||||
"frequencyPerWeek": int(max(_safe_float(frequency, 1), 1)),
|
||||
"durationMinutes": int(max(_safe_float(duration, 10), 10)),
|
||||
"bestTimeOfDay": str(best_time),
|
||||
"moistureLevel": int(max(min(_safe_float(moisture_level, 70), 100), 0)),
|
||||
"warning": str(warning),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_timeline(
|
||||
llm_result: dict[str, Any],
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
raw_timeline = _coerce_list(llm_result.get("timeline"))
|
||||
timeline: list[dict[str, Any]] = []
|
||||
|
||||
for index, item in enumerate(raw_timeline, start=1):
|
||||
item_dict = _coerce_dict(item)
|
||||
title = item_dict.get("title")
|
||||
description = item_dict.get("description")
|
||||
if title and description:
|
||||
timeline.append(
|
||||
{
|
||||
"step_number": int(item_dict.get("step_number") or index),
|
||||
"title": str(title),
|
||||
"description": str(description),
|
||||
}
|
||||
)
|
||||
|
||||
if timeline:
|
||||
return timeline
|
||||
|
||||
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
|
||||
event_dates = _coerce_list(strategy.get("event_dates"))
|
||||
best_timing = strategy.get("timing") or (
|
||||
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "صبح زود"
|
||||
)
|
||||
|
||||
generated = [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "بررسی فشار",
|
||||
"description": "فشار ابتدا و انتهای لاین کنترل شود.",
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "اجرای آبیاری",
|
||||
"description": f"آبیاری در بازه {best_timing} انجام شود.",
|
||||
},
|
||||
]
|
||||
if event_dates:
|
||||
generated.append(
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "پیگیری برنامه",
|
||||
"description": f"نوبت های پیشنهادی برای تاریخ های {', '.join(map(str, event_dates))} بررسی شوند.",
|
||||
}
|
||||
)
|
||||
else:
|
||||
generated.append(
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "بازبینی رطوبت",
|
||||
"description": "بعد از هر نوبت، رطوبت خاک و یکنواختی توزیع آب کنترل شود.",
|
||||
}
|
||||
)
|
||||
return generated
|
||||
|
||||
|
||||
def _normalize_sections(
|
||||
llm_result: dict[str, Any],
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
plan_warning: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
raw_sections = _coerce_list(llm_result.get("sections"))
|
||||
sections: list[dict[str, Any]] = []
|
||||
|
||||
for section in raw_sections:
|
||||
item = _coerce_dict(section)
|
||||
section_type = str(item.get("type") or "").strip().lower()
|
||||
if section_type not in {"warning", "tip"}:
|
||||
continue
|
||||
content = item.get("content")
|
||||
title = item.get("title")
|
||||
if not content or not title:
|
||||
continue
|
||||
icon = item.get("icon") or (
|
||||
"tabler-alert-triangle" if section_type == "warning" else "tabler-bulb"
|
||||
)
|
||||
sections.append(
|
||||
{
|
||||
"title": str(title),
|
||||
"icon": str(icon),
|
||||
"type": section_type,
|
||||
"content": str(content),
|
||||
}
|
||||
)
|
||||
|
||||
if not any(item["type"] == "warning" for item in sections):
|
||||
sections.insert(
|
||||
0,
|
||||
{
|
||||
"title": "هشدار آبیاری",
|
||||
"icon": "tabler-alert-triangle",
|
||||
"type": "warning",
|
||||
"content": plan_warning,
|
||||
},
|
||||
)
|
||||
|
||||
if not any(item["type"] == "tip" for item in sections):
|
||||
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
|
||||
reasoning = _coerce_list(strategy.get("reasoning"))
|
||||
tip_content = (
|
||||
str(reasoning[-1])
|
||||
if reasoning
|
||||
else "شست وشوی فیلترها و بازبینی یکنواختی پخش آب به پایداری برنامه آبیاری کمک می کند."
|
||||
)
|
||||
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
|
||||
tip_content = "قبل از نوبت بعدی، مقدار بارش موثر و رطوبت خاک را دوباره با برنامه تطبیق دهید."
|
||||
sections.append(
|
||||
{
|
||||
"title": "نکته بهره وری",
|
||||
"icon": "tabler-bulb",
|
||||
"type": "tip",
|
||||
"content": tip_content,
|
||||
}
|
||||
)
|
||||
|
||||
return sections[:4]
|
||||
|
||||
|
||||
def _build_irrigation_ui_payload(
|
||||
llm_result: dict[str, Any],
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
crop_profile: dict[str, Any],
|
||||
active_kc: float,
|
||||
irrigation_method: IrrigationMethod | None,
|
||||
sensor: SensorData | None,
|
||||
) -> dict[str, Any]:
|
||||
soil_moisture = _sensor_metric(sensor, "soil_moisture")
|
||||
plan = _normalize_plan(
|
||||
llm_result,
|
||||
optimizer_result,
|
||||
daily_water_needs,
|
||||
irrigation_method,
|
||||
soil_moisture,
|
||||
)
|
||||
payload = {
|
||||
"plan": plan,
|
||||
"water_balance": {
|
||||
"daily": daily_water_needs,
|
||||
"crop_profile": crop_profile,
|
||||
"active_kc": active_kc,
|
||||
},
|
||||
"timeline": _normalize_timeline(llm_result, optimizer_result, daily_water_needs),
|
||||
"sections": _normalize_sections(
|
||||
llm_result,
|
||||
optimizer_result,
|
||||
daily_water_needs,
|
||||
plan["warning"],
|
||||
),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _resolve_irrigation_method(
|
||||
sensor: SensorData | None,
|
||||
irrigation_method_name: str | None,
|
||||
) -> IrrigationMethod | None:
|
||||
if irrigation_method_name:
|
||||
return IrrigationMethod.objects.filter(name=irrigation_method_name).first()
|
||||
if sensor is not None:
|
||||
return sensor.irrigation_method
|
||||
return None
|
||||
|
||||
|
||||
def _persist_irrigation_method_on_farm(
|
||||
sensor: SensorData | None,
|
||||
irrigation_method: IrrigationMethod | None,
|
||||
) -> None:
|
||||
if sensor is None or irrigation_method is None:
|
||||
return
|
||||
if sensor.irrigation_method_id == irrigation_method.id:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
sensor.irrigation_method = irrigation_method
|
||||
sensor.save(update_fields=["irrigation_method", "updated_at"])
|
||||
|
||||
|
||||
def get_irrigation_recommendation(
|
||||
farm_uuid: str | None = None,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
irrigation_method_name: str | None = None,
|
||||
query: str | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
limit: int = 8,
|
||||
sensor_uuid: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
توصیه آبیاری برای یک مزرعه.
|
||||
از RAG با پایگاه دانش irrigation استفاده میکند.
|
||||
|
||||
Args:
|
||||
farm_uuid: شناسه مزرعه
|
||||
plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant)
|
||||
growth_stage: مرحله رشد گیاه
|
||||
irrigation_method_name: نام روش آبیاری (برای بارگذاری از جدول IrrigationMethod)
|
||||
query: سوال اختیاری
|
||||
config: تنظیمات RAG
|
||||
limit: تعداد چانکهای بازیابیشده
|
||||
|
||||
Returns:
|
||||
dict ساختاریافته برای توصیه آبیاری
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
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
|
||||
|
||||
resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip()
|
||||
if not resolved_farm_uuid:
|
||||
raise ValueError("farm_uuid is required.")
|
||||
|
||||
user_query = query or "توصیه آبیاری برای مزرعه من چیست؟"
|
||||
|
||||
sensor = (
|
||||
SensorData.objects.select_related("center_location", "irrigation_method")
|
||||
.prefetch_related("plant_assignments__plant")
|
||||
.filter(farm_uuid=resolved_farm_uuid)
|
||||
.first()
|
||||
)
|
||||
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
|
||||
_persist_irrigation_method_on_farm(sensor, irrigation_method)
|
||||
|
||||
plant = None
|
||||
resolved_plant_name = plant_name
|
||||
if sensor is not None:
|
||||
selected_snapshot = get_farm_plant_snapshot_by_name(sensor, plant_name)
|
||||
plant = clone_snapshot_as_runtime_plant(
|
||||
selected_snapshot,
|
||||
growth_stage=growth_stage,
|
||||
)
|
||||
if selected_snapshot is not None:
|
||||
resolved_plant_name = selected_snapshot.name
|
||||
elif plant_name:
|
||||
resolved_plant_name = plant_name
|
||||
|
||||
crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage)
|
||||
active_kc = resolve_kc(crop_profile, growth_stage=growth_stage)
|
||||
forecasts = []
|
||||
daily_water_needs = []
|
||||
optimized_result = None
|
||||
if sensor is not None:
|
||||
forecasts = list(
|
||||
WeatherForecast.objects.filter(
|
||||
location=sensor.center_location,
|
||||
forecast_date__isnull=False,
|
||||
).order_by("forecast_date")[:7]
|
||||
)
|
||||
efficiency_percent = (
|
||||
getattr(irrigation_method, "water_efficiency_percent", None)
|
||||
if irrigation_method
|
||||
else None
|
||||
)
|
||||
daily_water_needs = calculate_forecast_water_needs(
|
||||
forecasts=forecasts,
|
||||
latitude_deg=float(sensor.center_location.latitude),
|
||||
crop_profile=crop_profile,
|
||||
growth_stage=growth_stage,
|
||||
irrigation_efficiency_percent=efficiency_percent,
|
||||
)
|
||||
if plant is not None and forecasts:
|
||||
optimized_result = _get_optimizer().optimize_irrigation(
|
||||
sensor=sensor,
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
daily_water_needs=daily_water_needs,
|
||||
growth_stage=growth_stage,
|
||||
irrigation_method=irrigation_method,
|
||||
)
|
||||
|
||||
context = build_rag_context(
|
||||
user_query,
|
||||
resolved_farm_uuid,
|
||||
config=cfg,
|
||||
limit=limit,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
)
|
||||
|
||||
extra_parts: list[str] = []
|
||||
resolved_irrigation_method_name = irrigation_method.name if irrigation_method is not None else None
|
||||
if resolved_plant_name and growth_stage:
|
||||
plant_text = build_plant_text(resolved_plant_name, growth_stage)
|
||||
if plant_text:
|
||||
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
if resolved_irrigation_method_name:
|
||||
method_text = build_irrigation_method_text(resolved_irrigation_method_name)
|
||||
if method_text:
|
||||
extra_parts.append("[روش آبیاری انتخابی]\n" + method_text)
|
||||
if daily_water_needs:
|
||||
total_mm = round(sum(item["gross_irrigation_mm"] for item in daily_water_needs), 2)
|
||||
schedule_lines = [
|
||||
f"- {item['forecast_date']}: ET0={item['et0_mm']} mm, ETc={item['etc_mm']} mm, "
|
||||
f"بارش مؤثر={item['effective_rainfall_mm']} mm, نیاز آبی={item['gross_irrigation_mm']} mm, "
|
||||
f"زمان پیشنهادی={item['irrigation_timing']}"
|
||||
for item in daily_water_needs
|
||||
]
|
||||
extra_parts.append(
|
||||
"[خروجی قطعی محاسبات FAO-56]\n"
|
||||
f"کل نیاز آبی ۷ روز آینده: {total_mm} mm\n"
|
||||
f"Kc مورد استفاده: {active_kc}\n"
|
||||
+ "\n".join(schedule_lines)
|
||||
)
|
||||
if optimized_result is not None:
|
||||
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
|
||||
if extra_parts:
|
||||
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
|
||||
|
||||
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(DEFAULT_IRRIGATION_PROMPT)
|
||||
if context:
|
||||
system_parts.append("\n\n" + context)
|
||||
system_content = "\n".join(system_parts)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": user_query},
|
||||
]
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=resolved_farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_query,
|
||||
system_prompt=system_content,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
except Exception as exc:
|
||||
logger.error("Irrigation recommendation error for %s: %s", resolved_farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RuntimeError(
|
||||
f"Irrigation recommendation failed for farm {resolved_farm_uuid}."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
cleaned = raw
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||
llm_result = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
llm_result = {}
|
||||
|
||||
result = _build_irrigation_ui_payload(
|
||||
_coerce_dict(llm_result),
|
||||
optimized_result,
|
||||
daily_water_needs,
|
||||
crop_profile,
|
||||
active_kc,
|
||||
irrigation_method,
|
||||
sensor,
|
||||
)
|
||||
result["raw_response"] = raw
|
||||
result["simulation_optimizer"] = optimized_result
|
||||
result["selected_irrigation_method"] = (
|
||||
{
|
||||
"id": irrigation_method.id,
|
||||
"name": irrigation_method.name,
|
||||
"category": irrigation_method.category,
|
||||
"water_efficiency_percent": irrigation_method.water_efficiency_percent,
|
||||
}
|
||||
if irrigation_method is not None
|
||||
else None
|
||||
)
|
||||
_complete_audit_log(
|
||||
audit_log,
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ID = "irrigation_plan_parser"
|
||||
KB_NAME = "irrigation_plan_parser"
|
||||
CORE_FIELDS = [
|
||||
"crop_name",
|
||||
"growth_stage",
|
||||
"irrigation_method",
|
||||
"water_amount_per_event",
|
||||
"duration_minutes",
|
||||
"frequency_text",
|
||||
"interval_days",
|
||||
"preferred_time_of_day",
|
||||
"start_date",
|
||||
"target_area",
|
||||
]
|
||||
|
||||
IRRIGATION_PLAN_PROMPT = (
|
||||
"شما یک تحلیل گر برنامه آبیاری هستی. "
|
||||
"کاربر ممکن است برنامه آبیاری را کامل یا ناقص توضیح دهد. "
|
||||
"وظیفه شما این است که فقط JSON معتبر برگردانی و متن اضافه، markdown، توضیح بیرون از JSON یا کلید اضافه تولید نکنی. "
|
||||
"اگر اطلاعات کافی بود status را completed بگذار و final_plan را کامل کن. "
|
||||
"اگر اطلاعات کافی نبود status را needs_clarification بگذار، missing_fields را پر کن و 1 تا 5 سوال کوتاه و دقیق در questions برگردان. "
|
||||
"اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. "
|
||||
"در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. "
|
||||
"از حدس زدن جزئیات برنامه خودداری کن. "
|
||||
"اگر کاربر فقط بخشی از سوالات قبلی را جواب داد، داده های جدید را با partial_plan ادغام کن و فقط سوالات باقی مانده را بپرس. "
|
||||
"Schema: "
|
||||
"{"
|
||||
'"status": "completed" | "needs_clarification", '
|
||||
'"summary": string, '
|
||||
'"missing_fields": [string], '
|
||||
'"questions": [{"id": string, "field": string, "question": string, "rationale": string}], '
|
||||
'"collected_data": {'
|
||||
'"crop_name": string|null, '
|
||||
'"growth_stage": string|null, '
|
||||
'"irrigation_method": string|null, '
|
||||
'"water_amount_per_event": string|null, '
|
||||
'"duration_minutes": integer|null, '
|
||||
'"frequency_text": string|null, '
|
||||
'"interval_days": integer|null, '
|
||||
'"preferred_time_of_day": string|null, '
|
||||
'"start_date": string|null, '
|
||||
'"target_area": string|null, '
|
||||
'"trigger_conditions": [string], '
|
||||
'"notes": [string]'
|
||||
"}, "
|
||||
'"final_plan": {same shape as collected_data} | null'
|
||||
"}."
|
||||
)
|
||||
|
||||
|
||||
class ClarificationQuestionSchema(BaseModel):
|
||||
id: str
|
||||
field: str
|
||||
question: str
|
||||
rationale: str = ""
|
||||
|
||||
|
||||
class IrrigationPlanSchema(BaseModel):
|
||||
crop_name: str | None = None
|
||||
growth_stage: str | None = None
|
||||
irrigation_method: str | None = None
|
||||
water_amount_per_event: str | None = None
|
||||
duration_minutes: int | None = None
|
||||
frequency_text: str | None = None
|
||||
interval_days: int | None = None
|
||||
preferred_time_of_day: str | None = None
|
||||
start_date: str | None = None
|
||||
target_area: str | None = None
|
||||
trigger_conditions: list[str] = Field(default_factory=list)
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class IrrigationPlanParseResultSchema(BaseModel):
|
||||
status: Literal["completed", "needs_clarification"]
|
||||
summary: str
|
||||
missing_fields: list[str] = Field(default_factory=list)
|
||||
questions: list[ClarificationQuestionSchema] = Field(default_factory=list)
|
||||
collected_data: IrrigationPlanSchema = Field(default_factory=IrrigationPlanSchema)
|
||||
final_plan: IrrigationPlanSchema | None = None
|
||||
|
||||
|
||||
class IrrigationPlanParserService:
|
||||
def parse_plan(
|
||||
self,
|
||||
*,
|
||||
message: str = "",
|
||||
answers: dict[str, Any] | None = None,
|
||||
partial_plan: dict[str, Any] | None = None,
|
||||
farm_uuid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = self._build_service_client(cfg)
|
||||
|
||||
normalized_message = (message or "").strip()
|
||||
normalized_answers = answers if isinstance(answers, dict) else {}
|
||||
normalized_partial = partial_plan if isinstance(partial_plan, dict) else {}
|
||||
structured_context = {
|
||||
"message": normalized_message,
|
||||
"answers": normalized_answers,
|
||||
"partial_plan": normalized_partial,
|
||||
"required_core_fields": CORE_FIELDS,
|
||||
"service": "irrigation_plan_parser",
|
||||
}
|
||||
|
||||
rag_query = self._build_retrieval_query(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
)
|
||||
rag_context = build_rag_context(
|
||||
query=rag_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
)
|
||||
system_prompt, messages = self._build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
structured_context=structured_context,
|
||||
rag_context=rag_context,
|
||||
)
|
||||
|
||||
audit_log = None
|
||||
if farm_uuid:
|
||||
try:
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=rag_query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Irrigation plan parser audit log creation failed for %s: %s", farm_uuid, exc)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw = (response.choices[0].message.content or "").strip()
|
||||
parsed = self._clean_json(raw)
|
||||
validated = IrrigationPlanParseResultSchema.model_validate(parsed)
|
||||
normalized = self._normalize_result(validated)
|
||||
if audit_log is not None:
|
||||
_complete_audit_log(audit_log, raw)
|
||||
return normalized
|
||||
except (ValidationError, ValueError, KeyError, IndexError) as exc:
|
||||
logger.warning("Irrigation plan parser parsing failed: %s", exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
return self._fallback_result(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
partial_plan=normalized_partial,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Irrigation plan parser failed: %s", exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
return self._fallback_result(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
partial_plan=normalized_partial,
|
||||
)
|
||||
|
||||
def _build_service_client(self, cfg: RAGConfig):
|
||||
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)
|
||||
return service, client, service.llm.model
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
structured_context: dict[str, Any],
|
||||
rag_context: str,
|
||||
) -> 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(IRRIGATION_PLAN_PROMPT)
|
||||
system_parts.append(
|
||||
"[structured_context]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
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": "برنامه آبیاری را استخراج یا برای تکمیل آن سوال بپرس."},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
def _build_retrieval_query(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
answers: dict[str, Any],
|
||||
) -> str:
|
||||
answer_lines = [f"{key}: {value}" for key, value in answers.items()]
|
||||
parts = [part for part in [message, "\n".join(answer_lines)] if part]
|
||||
return "\n".join(parts) or "استخراج برنامه آبیاری از متن کاربر"
|
||||
|
||||
def _normalize_result(self, validated: IrrigationPlanParseResultSchema) -> dict[str, Any]:
|
||||
collected = validated.collected_data.model_dump()
|
||||
final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None
|
||||
missing_fields = list(dict.fromkeys(validated.missing_fields))
|
||||
computed_missing = self._find_missing_fields(final_plan or collected)
|
||||
for field in computed_missing:
|
||||
if field not in missing_fields:
|
||||
missing_fields.append(field)
|
||||
|
||||
can_complete = validated.status == "completed" and not missing_fields
|
||||
|
||||
if can_complete:
|
||||
final_plan = final_plan or collected
|
||||
questions: list[dict[str, Any]] = []
|
||||
status_fa = "تکمیل شد"
|
||||
else:
|
||||
questions = [item.model_dump() for item in validated.questions]
|
||||
if not questions and missing_fields:
|
||||
questions = self._build_generic_questions(missing_fields)
|
||||
final_plan = None
|
||||
validated.status = "needs_clarification"
|
||||
status_fa = "نیازمند پرسش تکمیلی"
|
||||
|
||||
return {
|
||||
"status": "completed" if can_complete else "needs_clarification",
|
||||
"status_fa": status_fa,
|
||||
"summary": validated.summary,
|
||||
"missing_fields": missing_fields,
|
||||
"questions": questions,
|
||||
"collected_data": collected,
|
||||
"final_plan": final_plan,
|
||||
}
|
||||
|
||||
def _fallback_result(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
answers: dict[str, Any],
|
||||
partial_plan: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
merged = dict(partial_plan)
|
||||
notes = list(merged.get("notes") or [])
|
||||
if message:
|
||||
notes.append(f"متن اولیه کاربر: {message}")
|
||||
for key, value in answers.items():
|
||||
merged.setdefault(key, value)
|
||||
|
||||
return {
|
||||
"status": "needs_clarification",
|
||||
"status_fa": "نیازمند پرسش تکمیلی",
|
||||
"summary": "اطلاعات برنامه آبیاری برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.",
|
||||
"missing_fields": CORE_FIELDS,
|
||||
"questions": self._build_generic_questions(CORE_FIELDS),
|
||||
"collected_data": {
|
||||
"crop_name": merged.get("crop_name"),
|
||||
"growth_stage": merged.get("growth_stage"),
|
||||
"irrigation_method": merged.get("irrigation_method"),
|
||||
"water_amount_per_event": merged.get("water_amount_per_event"),
|
||||
"duration_minutes": merged.get("duration_minutes"),
|
||||
"frequency_text": merged.get("frequency_text"),
|
||||
"interval_days": merged.get("interval_days"),
|
||||
"preferred_time_of_day": merged.get("preferred_time_of_day"),
|
||||
"start_date": merged.get("start_date"),
|
||||
"target_area": merged.get("target_area"),
|
||||
"trigger_conditions": merged.get("trigger_conditions") or [],
|
||||
"notes": notes,
|
||||
},
|
||||
"final_plan": None,
|
||||
}
|
||||
|
||||
def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]:
|
||||
catalog = {
|
||||
"crop_name": {
|
||||
"id": "crop_name",
|
||||
"field": "crop_name",
|
||||
"question": "این برنامه آبیاری برای کدام محصول است؟",
|
||||
"rationale": "نام محصول برای ثبت برنامه لازم است.",
|
||||
},
|
||||
"growth_stage": {
|
||||
"id": "growth_stage",
|
||||
"field": "growth_stage",
|
||||
"question": "محصول الان در چه مرحله رشدی قرار دارد؟",
|
||||
"rationale": "مرحله رشد برای کامل شدن برنامه لازم است.",
|
||||
},
|
||||
"irrigation_method": {
|
||||
"id": "irrigation_method",
|
||||
"field": "irrigation_method",
|
||||
"question": "روش آبیاری چیست؟ مثلا قطره ای، بارانی یا غرقابی.",
|
||||
"rationale": "روش اجرا روی شکل برنامه تاثیر دارد.",
|
||||
},
|
||||
"water_amount_per_event": {
|
||||
"id": "water_amount_per_event",
|
||||
"field": "water_amount_per_event",
|
||||
"question": "در هر نوبت آبیاری چه مقدار آب داده می شود؟",
|
||||
"rationale": "حجم یا عمق آب هر نوبت مشخص نشده است.",
|
||||
},
|
||||
"duration_minutes": {
|
||||
"id": "duration_minutes",
|
||||
"field": "duration_minutes",
|
||||
"question": "مدت زمان هر نوبت آبیاری چند دقیقه است؟",
|
||||
"rationale": "مدت اجرای هر نوبت هنوز مشخص نیست.",
|
||||
},
|
||||
"frequency_text": {
|
||||
"id": "frequency_text",
|
||||
"field": "frequency_text",
|
||||
"question": "فاصله یا تعداد نوبت های آبیاری چگونه است؟ مثلا هر 3 روز یک بار.",
|
||||
"rationale": "الگوی تکرار آبیاری باید مشخص باشد.",
|
||||
},
|
||||
"interval_days": {
|
||||
"id": "interval_days",
|
||||
"field": "interval_days",
|
||||
"question": "فاصله بین دو آبیاری چند روز است؟",
|
||||
"rationale": "عدد فاصله آبیاری برای JSON نهایی لازم است.",
|
||||
},
|
||||
"preferred_time_of_day": {
|
||||
"id": "preferred_time_of_day",
|
||||
"field": "preferred_time_of_day",
|
||||
"question": "بهترین زمان اجرای آبیاری چه موقع از روز است؟",
|
||||
"rationale": "زمان اجرای برنامه هنوز معلوم نیست.",
|
||||
},
|
||||
"start_date": {
|
||||
"id": "start_date",
|
||||
"field": "start_date",
|
||||
"question": "این برنامه از چه تاریخی یا از چه زمانی باید شروع شود؟",
|
||||
"rationale": "زمان شروع برنامه هنوز مشخص نشده است.",
|
||||
},
|
||||
"target_area": {
|
||||
"id": "target_area",
|
||||
"field": "target_area",
|
||||
"question": "این برنامه برای کل مزرعه است یا بخش/ناحیه خاصی از مزرعه؟",
|
||||
"rationale": "محدوده اجرای برنامه باید مشخص باشد.",
|
||||
},
|
||||
}
|
||||
return [catalog[field] for field in missing_fields if field in catalog][:5]
|
||||
|
||||
def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]:
|
||||
missing: list[str] = []
|
||||
for field in CORE_FIELDS:
|
||||
value = plan.get(field)
|
||||
if value is None:
|
||||
missing.append(field)
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
missing.append(field)
|
||||
return missing
|
||||
|
||||
def _clean_json(self, 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:
|
||||
raise ValueError("Irrigation plan parser response was empty.")
|
||||
parsed = json.loads(cleaned)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Irrigation plan parser response root must be an object.")
|
||||
return parsed
|
||||
@@ -0,0 +1,470 @@
|
||||
"""
|
||||
سرویس RAG برای تشخیص تصویری و پیش بینی ریسک آفات و بیماری گیاه.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_build_content_parts,
|
||||
_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 rag.failure_contract import RAGServiceError
|
||||
from rag.user_data import build_plant_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "pest_disease"
|
||||
SERVICE_ID = "pest_disease"
|
||||
|
||||
DETECTION_PROMPT = (
|
||||
"شما یک دستیار تخصصی تشخیص آفات و بیماری گیاهی هستی. "
|
||||
"با استفاده از تصویر، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش تحلیل کن. "
|
||||
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: "
|
||||
"has_issue, category, confidence, severity, summary, detected_signs, possible_causes, immediate_actions, reasoning. "
|
||||
"category فقط یکی از no_issue, pest, disease, nutrient_stress, abiotic_stress, unknown باشد. "
|
||||
"severity فقط یکی از low, medium, high باشد."
|
||||
)
|
||||
|
||||
RISK_PROMPT = (
|
||||
"شما یک دستیار تخصصی پیش بینی ریسک آفات و بیماری گیاهی هستی. "
|
||||
"با استفاده از داده های مزرعه، آب و هوا، مرحله رشد، و متن های بازیابی شده از پایگاه دانش تحلیل کن. "
|
||||
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: "
|
||||
"summary, forecast_window, overall_risk, disease_risk, pest_risk, key_drivers, recommended_actions. "
|
||||
"overall_risk فقط یکی از low, medium, high باشد. "
|
||||
"disease_risk و pest_risk باید آبجکت هایی با کلیدهای score, level, likely_conditions, reasoning باشند و level فقط یکی از low, medium, high باشد."
|
||||
)
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_images(images: list[dict[str, str]] | None) -> list[dict[str, str]]:
|
||||
output: list[dict[str, str]] = []
|
||||
for item in images or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
url = item.get("url")
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
continue
|
||||
output.append({"url": url.strip(), "detail": item.get("detail", "auto")})
|
||||
return output
|
||||
|
||||
|
||||
def _clean_json(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:
|
||||
raise RAGServiceError(
|
||||
error_code="empty_response",
|
||||
message="Pest disease LLM response was empty.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("Invalid JSON returned by pest_disease LLM: %s", cleaned[:500])
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_json",
|
||||
message="Pest disease LLM response was not valid JSON.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
) from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
message="Pest disease LLM response root must be a JSON object.",
|
||||
source="llm",
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
|
||||
source="farm_data",
|
||||
details={"farm_uuid": farm_uuid},
|
||||
http_status=404,
|
||||
)
|
||||
return farm_details
|
||||
|
||||
|
||||
def _build_service_client(cfg: RAGConfig):
|
||||
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)
|
||||
return service, client, service.llm.model
|
||||
|
||||
|
||||
def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]:
|
||||
weather = farm_details.get("weather") or {}
|
||||
soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
|
||||
humidity = _safe_float(weather.get("humidity_mean"), 55.0)
|
||||
temp = _safe_float(weather.get("temperature_mean"), 24.0)
|
||||
rain = _safe_float(weather.get("precipitation"), 0.0)
|
||||
moisture = _safe_float(soil.get("soil_moisture"), _safe_float(soil.get("wv0033"), 35.0))
|
||||
ec = _safe_float(soil.get("electrical_conductivity"), 0.0)
|
||||
ph = _safe_float(soil.get("soil_ph") or soil.get("phh2o"), 7.0)
|
||||
|
||||
fungal_score = min(max(round((humidity * 0.45) + (moisture * 0.35) + (rain * 2.5) - 25, 2), 0.0), 100.0)
|
||||
pest_score = min(max(round((temp * 2.2) + max(0.0, 45.0 - moisture) + (ec * 3.0) - 20, 2), 0.0), 100.0)
|
||||
abiotic_stress = min(max(round((abs(ph - 6.8) * 18.0) + (ec * 8.0), 2), 0.0), 100.0)
|
||||
return {
|
||||
"humidity_mean": humidity,
|
||||
"temperature_mean": temp,
|
||||
"precipitation": rain,
|
||||
"soil_moisture": moisture,
|
||||
"ec": ec,
|
||||
"ph": ph,
|
||||
"fungal_score": fungal_score,
|
||||
"pest_score": pest_score,
|
||||
"abiotic_stress_score": abiotic_stress,
|
||||
}
|
||||
|
||||
|
||||
def _risk_level(score: float) -> str:
|
||||
if score >= 70:
|
||||
return "high"
|
||||
if score >= 40:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def _build_risk_context(farm_details: dict[str, Any], plant_name: str | None, growth_stage: str | None) -> dict[str, Any]:
|
||||
risk = _weather_risk_summary(farm_details)
|
||||
disease_level = _risk_level(risk["fungal_score"])
|
||||
pest_level = _risk_level(risk["pest_score"])
|
||||
overall_score = max(risk["fungal_score"], risk["pest_score"], risk["abiotic_stress_score"])
|
||||
overall_level = _risk_level(overall_score)
|
||||
drivers = []
|
||||
if risk["humidity_mean"] >= 70:
|
||||
drivers.append("رطوبت بالا")
|
||||
if risk["soil_moisture"] >= 60:
|
||||
drivers.append("رطوبت خاک بالا")
|
||||
if risk["temperature_mean"] >= 30:
|
||||
drivers.append("دمای بالا")
|
||||
if risk["precipitation"] > 2:
|
||||
drivers.append("بارش موثر")
|
||||
if risk["ec"] > 2.5:
|
||||
drivers.append("EC بالا")
|
||||
if abs(risk["ph"] - 6.8) > 0.8:
|
||||
drivers.append("خروج pH از محدوده مطلوب")
|
||||
if not drivers:
|
||||
drivers.append("شرایط فعلی مزرعه نسبتا پایدار است")
|
||||
|
||||
return {
|
||||
"summary": "برآورد ریسک آفات و بیماری بر اساس داده های فعلی مزرعه ساخته شد.",
|
||||
"forecast_window": "24 تا 72 ساعت آینده",
|
||||
"overall_risk": overall_level,
|
||||
"disease_risk": {
|
||||
"score": risk["fungal_score"],
|
||||
"level": disease_level,
|
||||
"likely_conditions": [
|
||||
"فشار قارچی و بیماری برگی" if disease_level != "low" else "ریسک بیماری فعلا پایین است",
|
||||
],
|
||||
"reasoning": [
|
||||
f"رطوبت میانگین حدود {risk['humidity_mean']} درصد است.",
|
||||
f"رطوبت خاک حدود {risk['soil_moisture']} درصد برآورد شده است.",
|
||||
],
|
||||
},
|
||||
"pest_risk": {
|
||||
"score": risk["pest_score"],
|
||||
"level": pest_level,
|
||||
"likely_conditions": [
|
||||
"فشار آفات مکنده یا تنش زا" if pest_level != "low" else "ریسک آفت فعلا پایین است",
|
||||
],
|
||||
"reasoning": [
|
||||
f"دمای میانگین حدود {risk['temperature_mean']} درجه است.",
|
||||
f"EC فعلی حدود {risk['ec']} و pH حدود {risk['ph']} است.",
|
||||
],
|
||||
},
|
||||
"key_drivers": drivers,
|
||||
"recommended_actions": [
|
||||
"بازدید مزرعه و بررسی برگ ها و پشت برگ انجام شود.",
|
||||
"در صورت مشاهده علائم مشکوک، نمونه برداری تصویری نزدیک تر انجام شود.",
|
||||
"رطوبت ماندگار و یکنواختی آبیاری پایش شود.",
|
||||
],
|
||||
"farm_context": {
|
||||
"plant_name": plant_name,
|
||||
"growth_stage": growth_stage,
|
||||
"risk_summary": risk,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _validate_detection_result(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
required_keys = {
|
||||
"has_issue",
|
||||
"category",
|
||||
"confidence",
|
||||
"severity",
|
||||
"summary",
|
||||
"detected_signs",
|
||||
"possible_causes",
|
||||
"immediate_actions",
|
||||
"reasoning",
|
||||
}
|
||||
missing = [key for key in required_keys if key not in parsed]
|
||||
if missing:
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
message="Pest disease detection response is missing required fields: " + ", ".join(missing),
|
||||
source="llm",
|
||||
details={"missing_fields": missing, "service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _validate_risk_result(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
required_keys = {
|
||||
"summary",
|
||||
"forecast_window",
|
||||
"overall_risk",
|
||||
"disease_risk",
|
||||
"pest_risk",
|
||||
"key_drivers",
|
||||
"recommended_actions",
|
||||
}
|
||||
missing = [key for key in required_keys if key not in parsed]
|
||||
if missing:
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
message="Pest disease risk response is missing required fields: " + ", ".join(missing),
|
||||
source="llm",
|
||||
details={"missing_fields": missing, "service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _build_detection_messages(
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
query: str,
|
||||
rag_context: str,
|
||||
plant_text: str,
|
||||
images: list[dict[str, str]],
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
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(DETECTION_PROMPT)
|
||||
if plant_text:
|
||||
system_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
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": _build_content_parts(query, images)},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
|
||||
def _build_risk_messages(
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
query: str,
|
||||
rag_context: str,
|
||||
structured_context: dict[str, Any],
|
||||
plant_text: str,
|
||||
) -> 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(RISK_PROMPT)
|
||||
if plant_text:
|
||||
system_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
system_parts.append("[کانتکست ساختاریافته ریسک]\n" + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str))
|
||||
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 get_pest_disease_detection(
|
||||
*,
|
||||
farm_uuid: str,
|
||||
plant_name: str | None = None,
|
||||
query: str | None = None,
|
||||
images: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_images = _normalize_images(images)
|
||||
if not normalized_images:
|
||||
raise RAGServiceError(
|
||||
error_code="missing_images",
|
||||
message="حداقل یک تصویر برای تشخیص لازم است.",
|
||||
source="request",
|
||||
http_status=400,
|
||||
)
|
||||
|
||||
cfg = load_rag_config()
|
||||
service, client, model = _build_service_client(cfg)
|
||||
farm_details = _load_farm_or_error(farm_uuid)
|
||||
resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name")
|
||||
user_query = query or "این تصویر را بررسی کن و بگو آیا گیاه دچار آفت یا بیماری شده است یا نه."
|
||||
plant_text = build_plant_text(resolved_plant_name, "") if resolved_plant_name else ""
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
system_prompt, messages = _build_detection_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
query=user_query,
|
||||
rag_context=rag_context,
|
||||
plant_text=plant_text or "",
|
||||
images=normalized_images,
|
||||
)
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_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(raw)
|
||||
_complete_audit_log(audit_log, raw)
|
||||
except RAGServiceError as exc:
|
||||
logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RAGServiceError(
|
||||
error_code="upstream_failure",
|
||||
message=f"Pest disease detection failed for farm {farm_uuid}.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID},
|
||||
http_status=503,
|
||||
) from exc
|
||||
|
||||
parsed = _validate_detection_result(parsed)
|
||||
parsed["status"] = "success"
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
return parsed
|
||||
|
||||
|
||||
def get_pest_disease_risk(
|
||||
*,
|
||||
farm_uuid: str,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
query: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = _build_service_client(cfg)
|
||||
farm_details = _load_farm_or_error(farm_uuid)
|
||||
resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name")
|
||||
risk_context = _build_risk_context(farm_details, resolved_plant_name, growth_stage)
|
||||
user_query = query or "ریسک آفات و بیماری این مزرعه را برای چند روز آینده پیش بینی کن."
|
||||
plant_text = build_plant_text(resolved_plant_name, growth_stage or "") if resolved_plant_name else ""
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
system_prompt, messages = _build_risk_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
query=user_query,
|
||||
rag_context=rag_context,
|
||||
structured_context=risk_context,
|
||||
plant_text=plant_text or "",
|
||||
)
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_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(raw)
|
||||
_complete_audit_log(audit_log, raw)
|
||||
except RAGServiceError as exc:
|
||||
logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RAGServiceError(
|
||||
error_code="upstream_failure",
|
||||
message=f"Pest disease risk prediction failed for farm {farm_uuid}.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID},
|
||||
http_status=503,
|
||||
) from exc
|
||||
|
||||
parsed = _validate_risk_result(parsed)
|
||||
parsed["status"] = "success"
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
return parsed
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
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 rag.failure_contract import RAGServiceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "soil_anomaly"
|
||||
SERVICE_ID = "soil_anomaly"
|
||||
|
||||
SOIL_ANOMALY_PROMPT = (
|
||||
"شما یک دستیار تخصصی تحلیل ناهنجاری داده های خاک و سنسور مزرعه هستی. "
|
||||
"ورودی شامل داده های ساختاریافته ناهنجاری، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش است. "
|
||||
"فقط JSON معتبر برگردان و فقط این کلیدها را تولید کن: "
|
||||
"summary, explanation, likely_cause, recommended_action, monitoring_priority, confidence. "
|
||||
"monitoring_priority فقط یکی از low, medium, high, urgent باشد. "
|
||||
"confidence عددی بین 0 و 1 باشد. "
|
||||
"اگر ناهنجاری معناداری وجود ندارد، این موضوع را شفاف و بدون اغراق بیان کن."
|
||||
)
|
||||
|
||||
|
||||
def _clean_json(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:
|
||||
raise RAGServiceError(
|
||||
error_code="empty_response",
|
||||
message="Soil anomaly LLM response was empty.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("Invalid JSON returned by soil_anomaly LLM: %s", cleaned[:500])
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_json",
|
||||
message="Soil anomaly LLM response was not valid JSON.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
) from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
message="Soil anomaly LLM response root must be a JSON object.",
|
||||
source="llm",
|
||||
retriable=False,
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
|
||||
source="farm_data",
|
||||
details={"farm_uuid": farm_uuid},
|
||||
http_status=404,
|
||||
)
|
||||
return farm_details
|
||||
|
||||
|
||||
def _build_service_client(cfg: RAGConfig):
|
||||
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)
|
||||
return service, client, service.llm.model
|
||||
|
||||
|
||||
def _validate_anomaly_insight(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
required_keys = {
|
||||
"summary",
|
||||
"explanation",
|
||||
"likely_cause",
|
||||
"recommended_action",
|
||||
"monitoring_priority",
|
||||
"confidence",
|
||||
}
|
||||
missing = [key for key in required_keys if key not in parsed]
|
||||
if missing:
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
message="Soil anomaly insight response is missing required fields: " + ", ".join(missing),
|
||||
source="llm",
|
||||
details={"missing_fields": missing, "service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _build_messages(
|
||||
*,
|
||||
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(SOIL_ANOMALY_PROMPT)
|
||||
system_parts.append(
|
||||
"[کانتکست ساختاریافته ناهنجاري خاک]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
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 get_soil_anomaly_insight(
|
||||
*,
|
||||
farm_uuid: str,
|
||||
anomaly_payload: dict[str, Any],
|
||||
query: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = _build_service_client(cfg)
|
||||
farm_details = _load_farm_or_error(farm_uuid)
|
||||
user_query = query or "ناهنجاري هاي داده هاي خاک اين مزرعه را تفسير کن و اقدام مناسب پيشنهاد بده."
|
||||
structured_context = {
|
||||
"farm_uuid": farm_uuid,
|
||||
"anomaly_payload": anomaly_payload,
|
||||
}
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
system_prompt, messages = _build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
query=user_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=user_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(raw)
|
||||
_complete_audit_log(audit_log, raw)
|
||||
except RAGServiceError as exc:
|
||||
logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RAGServiceError(
|
||||
error_code="upstream_failure",
|
||||
message=f"Soil anomaly insight failed for farm {farm_uuid}.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID},
|
||||
http_status=503,
|
||||
) from exc
|
||||
|
||||
parsed = _validate_anomaly_insight(parsed)
|
||||
parsed["status"] = "success"
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
return parsed
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
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 rag.failure_contract import RAGServiceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "water_need_prediction"
|
||||
SERVICE_ID = "water_need_prediction"
|
||||
|
||||
WATER_NEED_PROMPT = (
|
||||
"شما یک دستیار تخصصی تحليل نياز آبي کوتاه مدت مزرعه هستي. "
|
||||
"ورودي شامل محاسبات ساختاريافته نياز آبي، اطلاعات مزرعه و متن هاي بازيابي شده از پايگاه دانش است. "
|
||||
"فقط JSON معتبر با اين کليدها برگردان: "
|
||||
"summary, irrigation_outlook, recommended_action, risk_note, confidence. "
|
||||
"confidence عددي بين 0 و 1 باشد. "
|
||||
"اعداد اصلي را از داده ورودي بگير و عدد متناقض جديد نساز."
|
||||
)
|
||||
|
||||
|
||||
def _clean_json(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:
|
||||
raise RAGServiceError(
|
||||
error_code="empty_response",
|
||||
message="Water need prediction LLM response was empty.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("Invalid JSON returned by water_need_prediction LLM: %s", cleaned[:500])
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_json",
|
||||
message="Water need prediction LLM response was not valid JSON.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
) from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
message="Water need prediction LLM response root must be a JSON object.",
|
||||
source="llm",
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
|
||||
source="farm_data",
|
||||
details={"farm_uuid": farm_uuid},
|
||||
http_status=404,
|
||||
)
|
||||
return farm_details
|
||||
|
||||
|
||||
def _build_service_client(cfg: RAGConfig):
|
||||
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)
|
||||
return service, client, service.llm.model
|
||||
|
||||
|
||||
def _validate_prediction_insight(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
required_keys = {
|
||||
"summary",
|
||||
"irrigation_outlook",
|
||||
"recommended_action",
|
||||
"risk_note",
|
||||
"confidence",
|
||||
}
|
||||
missing = [key for key in required_keys if key not in parsed]
|
||||
if missing:
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
message="Water need prediction insight response is missing required fields: " + ", ".join(missing),
|
||||
source="llm",
|
||||
details={"missing_fields": missing, "service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
return parsed
|
||||
|
||||
|
||||
def _build_messages(
|
||||
*,
|
||||
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(WATER_NEED_PROMPT)
|
||||
system_parts.append(
|
||||
"[کانتکست ساختاريافته نياز آبي]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
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 get_water_need_prediction_insight(
|
||||
*,
|
||||
farm_uuid: str,
|
||||
prediction_payload: dict[str, Any],
|
||||
query: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = _build_service_client(cfg)
|
||||
farm_details = _load_farm_or_error(farm_uuid)
|
||||
user_query = query or "نياز آبي کوتاه مدت اين مزرعه را تفسير کن و اقدام عملياتي پيشنهاد بده."
|
||||
structured_context = {
|
||||
"farm_uuid": farm_uuid,
|
||||
"prediction_payload": prediction_payload,
|
||||
}
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
system_prompt, messages = _build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
query=user_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=user_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(raw)
|
||||
_complete_audit_log(audit_log, raw)
|
||||
except RAGServiceError as exc:
|
||||
logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RAGServiceError(
|
||||
error_code="upstream_failure",
|
||||
message=f"Water need prediction insight failed for farm {farm_uuid}.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID},
|
||||
http_status=503,
|
||||
) from exc
|
||||
|
||||
parsed = _validate_prediction_insight(parsed)
|
||||
parsed["status"] = "success"
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
return parsed
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
_create_audit_log,
|
||||
_fail_audit_log,
|
||||
_load_service_tone,
|
||||
)
|
||||
from rag.config import RAGConfig, get_service_config, load_rag_config
|
||||
from rag.failure_contract import RAGServiceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ID = "yield_harvest"
|
||||
|
||||
YIELD_HARVEST_PROMPT = (
|
||||
"You are an expert agronomist writing concise dashboard narratives for farmers. "
|
||||
"Return only valid JSON matching this schema exactly: "
|
||||
"{"
|
||||
'"season_highlights_subtitle": string, '
|
||||
'"yield_prediction_explanation": string, '
|
||||
'"harvest_readiness_summary": string, '
|
||||
'"operation_notes": [string, ...]'
|
||||
"}. "
|
||||
"Do not add markdown, explanations, or extra keys. "
|
||||
"Strict Golden Rule: do not invent numbers, dates, prices, revenues, percentages, KPIs, scores, or measurements. "
|
||||
"Use only values already present in the deterministic context. "
|
||||
"If a fact is missing from the context, say less rather than guessing."
|
||||
)
|
||||
|
||||
|
||||
class YieldHarvestNarrativeSchema(BaseModel):
|
||||
season_highlights_subtitle: str
|
||||
yield_prediction_explanation: str
|
||||
harvest_readiness_summary: str
|
||||
operation_notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class YieldHarvestRAGService:
|
||||
def generate_narrative(
|
||||
self,
|
||||
deterministic_context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = self._build_service_client(cfg)
|
||||
structured_context = self._build_structured_context(
|
||||
deterministic_context=deterministic_context,
|
||||
)
|
||||
user_prompt = (
|
||||
"Generate short user-friendly narrative fields for the Yield & Harvest Summary dashboard "
|
||||
"using only the deterministic context. Keep the language practical and agronomy-focused."
|
||||
)
|
||||
system_prompt, messages = self._build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
structured_context=structured_context,
|
||||
query=user_prompt,
|
||||
)
|
||||
|
||||
farm_uuid = str(deterministic_context.get("farm_uuid") or "")
|
||||
audit_log = None
|
||||
if farm_uuid:
|
||||
try:
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Yield harvest audit log creation failed for %s: %s", farm_uuid, exc)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw = (response.choices[0].message.content or "").strip()
|
||||
parsed = self._clean_json(raw)
|
||||
validated = YieldHarvestNarrativeSchema.model_validate(parsed)
|
||||
if audit_log is not None:
|
||||
_complete_audit_log(audit_log, raw)
|
||||
return {
|
||||
"status": "success",
|
||||
"source": "llm",
|
||||
"season_highlights_subtitle": validated.season_highlights_subtitle,
|
||||
"yield_prediction_explanation": validated.yield_prediction_explanation,
|
||||
"harvest_readiness_summary": validated.harvest_readiness_summary,
|
||||
"operation_notes": validated.operation_notes,
|
||||
}
|
||||
except (ValidationError, ValueError, KeyError, IndexError) as exc:
|
||||
logger.warning("Yield harvest narrative parsing failed for farm_uuid=%s: %s", farm_uuid, exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_payload",
|
||||
message=f"Yield harvest narrative parsing failed for farm_uuid={farm_uuid or 'unknown'}.",
|
||||
source="llm",
|
||||
details={"farm_uuid": farm_uuid or "unknown", "service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("Yield harvest narrative LLM call failed for farm_uuid=%s: %s", farm_uuid, exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RAGServiceError(
|
||||
error_code="upstream_failure",
|
||||
message=f"Yield harvest narrative generation failed for farm_uuid={farm_uuid or 'unknown'}.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"farm_uuid": farm_uuid or "unknown", "service_id": SERVICE_ID},
|
||||
http_status=503,
|
||||
) from exc
|
||||
|
||||
def _build_service_client(self, cfg: RAGConfig):
|
||||
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)
|
||||
return service, client, service.llm.model
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
structured_context: dict[str, Any],
|
||||
query: str,
|
||||
) -> 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(YIELD_HARVEST_PROMPT)
|
||||
system_parts.append(
|
||||
"[deterministic_context]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
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 _build_structured_context(
|
||||
self,
|
||||
*,
|
||||
deterministic_context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
season = deterministic_context.get("season_highlights_card") or {}
|
||||
harvest = deterministic_context.get("harvest_prediction_card") or {}
|
||||
operations = deterministic_context.get("harvest_operations_card") or {}
|
||||
yield_prediction = deterministic_context.get("yield_prediction") or {}
|
||||
readiness = deterministic_context.get("harvest_readiness_zones") or {}
|
||||
|
||||
operation_steps = []
|
||||
for step in operations.get("steps") or []:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
operation_steps.append(
|
||||
{
|
||||
"key": step.get("key"),
|
||||
"title": step.get("title"),
|
||||
"status": step.get("status"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"farm_context": deterministic_context.get("farm_context") or {},
|
||||
"yield_prediction": {
|
||||
"predicted_yield_tons": yield_prediction.get("predicted_yield_tons"),
|
||||
"unit": yield_prediction.get("unit"),
|
||||
"simulation_warning": yield_prediction.get("simulation_warning"),
|
||||
"supporting_metrics": yield_prediction.get("supporting_metrics"),
|
||||
},
|
||||
"season_highlights_card": {
|
||||
"title": season.get("title"),
|
||||
"subtitle": season.get("subtitle"),
|
||||
"total_predicted_yield": season.get("total_predicted_yield"),
|
||||
"yield_unit": season.get("yield_unit"),
|
||||
"target_harvest_date": season.get("target_harvest_date"),
|
||||
"days_until_harvest": season.get("days_until_harvest"),
|
||||
"average_readiness": season.get("average_readiness"),
|
||||
"primary_quality_grade": season.get("primary_quality_grade"),
|
||||
"estimated_revenue": season.get("estimated_revenue"),
|
||||
},
|
||||
"harvest_prediction_card": {
|
||||
"harvest_date": harvest.get("harvest_date"),
|
||||
"harvest_date_formatted": harvest.get("harvest_date_formatted"),
|
||||
"days_until": harvest.get("days_until"),
|
||||
"optimal_window_start": harvest.get("optimal_window_start"),
|
||||
"optimal_window_end": harvest.get("optimal_window_end"),
|
||||
"description": harvest.get("description"),
|
||||
},
|
||||
"harvest_readiness_zones": {
|
||||
"average_readiness": readiness.get("averageReadiness"),
|
||||
"mean_ndvi": readiness.get("meanNdvi"),
|
||||
"ndvi_trend": readiness.get("ndviTrend"),
|
||||
"zones": readiness.get("zones"),
|
||||
},
|
||||
"harvest_operations_card": {
|
||||
"stage_label": operations.get("stage_label"),
|
||||
"days_until_harvest": operations.get("days_until_harvest"),
|
||||
"current_dvs": operations.get("current_dvs"),
|
||||
"summary": operations.get("summary"),
|
||||
"steps": operation_steps,
|
||||
},
|
||||
}
|
||||
|
||||
def _clean_json(self, 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:
|
||||
raise RAGServiceError(
|
||||
error_code="empty_response",
|
||||
message="Yield harvest narrative response was empty.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_json",
|
||||
message="Yield harvest narrative response was not valid JSON.",
|
||||
source="llm",
|
||||
retriable=True,
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
) from exc
|
||||
if not isinstance(parsed, dict):
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
message="Yield harvest narrative response root must be a JSON object.",
|
||||
source="llm",
|
||||
details={"service_id": SERVICE_ID},
|
||||
http_status=502,
|
||||
)
|
||||
return parsed
|
||||
Reference in New Issue
Block a user