This commit is contained in:
2026-05-11 03:27:21 +03:30
parent cf7cbb937c
commit d0e68a1a56
854 changed files with 102985 additions and 76 deletions
+24
View File
@@ -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",
]
+738
View File
@@ -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
+539
View File
@@ -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
+470
View File
@@ -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
+214
View File
@@ -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
+263
View File
@@ -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