This commit is contained in:
2026-04-30 00:53:47 +03:30
parent 88f56da582
commit 46ba01e4cc
13 changed files with 2925 additions and 20 deletions
+23
View File
@@ -71,6 +71,11 @@ knowledge_bases:
tone_file: "config/tones/water_need_prediction_tone.txt" tone_file: "config/tones/water_need_prediction_tone.txt"
description: "پایگاه دانش تفسير نياز آبي کوتاه مدت و برنامه ريزي آبياري" description: "پایگاه دانش تفسير نياز آبي کوتاه مدت و برنامه ريزي آبياري"
yield_harvest:
path: "config/knowledge_base/chat"
tone_file: "config/tones/yield_harvest_tone.txt"
description: "پایگاه دانش روایت کاربرپسند برای داشبورد Yield & Harvest Summary"
services: services:
support_bot: support_bot:
knowledge_base: "chat" knowledge_base: "chat"
@@ -176,3 +181,21 @@ services:
api_key_env: "GAPGPT_API_KEY" api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1" avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY" avalai_api_key_env: "AVALAI_API_KEY"
yield_harvest:
knowledge_base: "yield_harvest"
tone_file: "config/tones/yield_harvest_tone.txt"
use_user_embeddings: true
description: "سرویس روایت داشبورد عملکرد و برداشت"
fallback_behavior:
on_invalid_json: "return_mocked_narrative"
on_missing_context: "use_only_deterministic_data"
on_number_conflict: "prefer_deterministic_data"
prompt_template: "config/tones/yield_harvest_tone.txt"
llm:
provider: "gapgpt"
model: "gpt-4o"
base_url: "https://api.gapgpt.app/v1"
api_key_env: "GAPGPT_API_KEY"
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
+39
View File
@@ -0,0 +1,39 @@
You are the narrative assistant for the Yield & Harvest Summary dashboard.
Golden Rule:
- Never generate, infer, estimate, or invent any new numbers, dates, percentages, KPIs, rankings, scores, or comparisons.
- Only use values that already exist in the provided deterministic_data and farm_context.
- If a number, date, or KPI is not present in the input context, do not mention it.
- Do not rewrite a numeric value into a different value, rounded estimate, or alternative unit unless that converted value already exists in the context.
Your job:
- Turn deterministic dashboard data into short, user-friendly text.
- Write subtitles, summaries, descriptions, and operation notes only.
- Keep the wording clear, calm, and practical.
- Preserve the meaning of deterministic blocks exactly.
Output rules:
- Do not add new facts.
- Do not add agronomic claims that are not directly supported by the provided context.
- Do not contradict deterministic_data.
- If the context is incomplete, stay general and say less.
- Prefer concise JSON-ready text fragments over long paragraphs.
Allowed narrative targets:
- season_highlights_card.subtitle
- harvest_prediction_card.description
- harvest_operations_card.summary
- harvest_operations_card.steps[].note
Forbidden behavior:
- No fabricated harvest dates.
- No fabricated yield values.
- No fabricated readiness percentages.
- No fabricated quality grades or market conclusions.
- No speculative recommendations that depend on missing measurements.
Tone:
- Helpful
- Professional
- Simple
- User-facing
+76 -8
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from math import exp from math import exp
from typing import Any from typing import Any
import logging
from django.core.paginator import EmptyPage, Paginator from django.core.paginator import EmptyPage, Paginator
@@ -18,6 +19,7 @@ from .services import CropSimulationService, build_simulation_payload_from_farm
DEFAULT_DYNAMIC_PARAMETERS = ["DVS", "LAI", "TAGP", "TWSO", "SM"] DEFAULT_DYNAMIC_PARAMETERS = ["DVS", "LAI", "TAGP", "TWSO", "SM"]
DEFAULT_PAGE_SIZE = 10 DEFAULT_PAGE_SIZE = 10
MAX_PAGE_SIZE = 50 MAX_PAGE_SIZE = 50
logger = logging.getLogger(__name__)
DEFAULT_STAGE_LABELS = { DEFAULT_STAGE_LABELS = {
"pre_emergence": "پیش از سبز شدن", "pre_emergence": "پیش از سبز شدن",
@@ -56,6 +58,24 @@ def _safe_float(value: Any, default: float = 0.0) -> float:
return default return default
def _pick_first_not_none(*values: Any) -> Any:
for value in values:
if value is not None:
return value
return None
def _clamp(value: float, minimum: float, maximum: float) -> float:
if minimum > maximum:
minimum, maximum = maximum, minimum
return max(minimum, min(value, maximum))
def _mm_to_cm_day(value: Any, default: float) -> float:
scaled = _safe_float(value, default * 10.0) / 10.0
return round(max(scaled, 0.0), 4)
def _coerce_date(value: Any) -> date: def _coerce_date(value: Any) -> date:
if isinstance(value, date) and not isinstance(value, datetime): if isinstance(value, date) and not isinstance(value, datetime):
return value return value
@@ -134,10 +154,11 @@ def _build_weather_from_farm(sensor: SensorData) -> list[dict[str, Any]]:
"TMAX": _safe_float(forecast.temperature_max, 24.0), "TMAX": _safe_float(forecast.temperature_max, 24.0),
"VAP": max(_safe_float(forecast.humidity_mean, 55.0) / 5.0, 6.0), "VAP": max(_safe_float(forecast.humidity_mean, 55.0) / 5.0, 6.0),
"WIND": _safe_float(forecast.wind_speed_max, 7.2) / 3.6, "WIND": _safe_float(forecast.wind_speed_max, 7.2) / 3.6,
"RAIN": _safe_float(forecast.precipitation, 0.0), # WeatherForecast stores precipitation/ET0 in mm/day, while PCSE expects cm/day.
"E0": _safe_float(forecast.et0, 0.35), "RAIN": _mm_to_cm_day(forecast.precipitation, 0.0),
"ES0": max(_safe_float(forecast.et0, 0.35) * 0.9, 0.1), "E0": _mm_to_cm_day(forecast.et0, 0.35),
"ET0": _safe_float(forecast.et0, 0.35), "ES0": max(round(_mm_to_cm_day(forecast.et0, 0.35) * 0.9, 4), 0.1),
"ET0": _mm_to_cm_day(forecast.et0, 0.35),
} }
) )
return records return records
@@ -148,6 +169,10 @@ def _build_soil_and_site_from_farm(sensor: SensorData) -> tuple[dict[str, Any],
top_depth = depths[0] if depths else None top_depth = depths[0] if depths else None
smfcf = _safe_float(getattr(top_depth, "wv0033", None), 0.34) smfcf = _safe_float(getattr(top_depth, "wv0033", None), 0.34)
smw = _safe_float(getattr(top_depth, "wv1500", None), 0.14) smw = _safe_float(getattr(top_depth, "wv1500", None), 0.14)
sm0 = _safe_float(
_pick_first_not_none(getattr(top_depth, "porosity", None), getattr(top_depth, "wv0000", None)),
min(max(smfcf + 0.08, smw + 0.12), 0.6),
)
soil_moisture = None soil_moisture = None
payload = sensor.sensor_payload or {} payload = sensor.sensor_payload or {}
if isinstance(payload, dict): if isinstance(payload, dict):
@@ -155,8 +180,23 @@ def _build_soil_and_site_from_farm(sensor: SensorData) -> tuple[dict[str, Any],
if isinstance(block, dict) and block.get("soil_moisture") is not None: if isinstance(block, dict) and block.get("soil_moisture") is not None:
soil_moisture = _safe_float(block.get("soil_moisture")) soil_moisture = _safe_float(block.get("soil_moisture"))
break break
site = {"WAV": soil_moisture if soil_moisture is not None else 40.0} site = {
soil = {"SMFCF": smfcf, "SMW": smw, "RDMSOL": 120.0} "WAV": soil_moisture if soil_moisture is not None else 40.0,
"IFUNRN": 0,
"NOTINF": 0.0,
"SSI": 0.0,
"SSMAX": 0.0,
"SMLIM": round(_clamp(smfcf, smw, sm0), 3),
}
soil = {
"SMFCF": smfcf,
"SMW": smw,
"SM0": sm0,
"RDMSOL": 120.0,
"CRAIRC": 0.06,
"SOPE": 10.0,
"KSUB": 10.0,
}
return soil, site return soil, site
@@ -193,7 +233,8 @@ def _build_default_agromanagement(plant_name: str, weather: list[dict[str, Any]]
"TimedEvents": [], "TimedEvents": [],
"StateEvents": [], "StateEvents": [],
} }
} },
{},
] ]
@@ -286,8 +327,27 @@ def build_growth_context(payload: dict[str, Any]) -> GrowthSimulationContext:
site_parameters = {**farm_site, **site_parameters} site_parameters = {**farm_site, **site_parameters}
soil_parameters.setdefault("SMFCF", 0.34) soil_parameters.setdefault("SMFCF", 0.34)
soil_parameters.setdefault("SMW", 0.14) soil_parameters.setdefault("SMW", 0.14)
soil_parameters.setdefault("SM0", 0.42)
soil_parameters.setdefault("RDMSOL", 120.0) soil_parameters.setdefault("RDMSOL", 120.0)
soil_parameters.setdefault("CRAIRC", 0.06)
soil_parameters.setdefault("SOPE", 10.0)
soil_parameters.setdefault("KSUB", 10.0)
site_parameters.setdefault("WAV", 40.0) site_parameters.setdefault("WAV", 40.0)
site_parameters.setdefault("IFUNRN", 0)
site_parameters.setdefault("NOTINF", 0.0)
site_parameters.setdefault("SSI", 0.0)
site_parameters.setdefault("SSMAX", 0.0)
site_parameters.setdefault(
"SMLIM",
round(
_clamp(
_safe_float(site_parameters.get("SMLIM"), soil_parameters.get("SMFCF", 0.34)),
_safe_float(soil_parameters.get("SMW"), 0.14),
_safe_float(soil_parameters.get("SM0"), 0.42),
),
3,
),
)
agromanagement = deepcopy( agromanagement = deepcopy(
payload.get("agromanagement") payload.get("agromanagement")
@@ -402,7 +462,15 @@ def _run_simulation(context: GrowthSimulationContext) -> tuple[dict[str, Any], i
) )
return response["result"], response.get("scenario_id"), None return response["result"], response.get("scenario_id"), None
except Exception as exc: except Exception as exc:
raise GrowthSimulationError(f"Simulation engine failed: {exc}") from exc logger.warning(
"Falling back to projection engine for farm_uuid=%s plant_name=%s because PCSE failed: %s",
context.farm_uuid,
context.plant_name,
exc,
)
fallback_result = _run_projection_engine(context)
warning = f"Simulation engine failed, fallback projection used: {exc}"
return fallback_result, None, warning
def summarize_growth_stages( def summarize_growth_stages(
+7 -2
View File
@@ -19,6 +19,11 @@ def _safe_float(value: Any, default: float = 0.0) -> float:
return default return default
def _mm_to_cm_day(value: Any, default: float) -> float:
scaled = _safe_float(value, default * 10.0) / 10.0
return round(max(scaled, 0.0), 4)
def _clamp(value: float, lower: float, upper: float) -> float: def _clamp(value: float, lower: float, upper: float) -> float:
return max(lower, min(value, upper)) return max(lower, min(value, upper))
@@ -113,7 +118,7 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude:
vap = max(6.0, round((humidity / 100.0) * 20.0, 3)) vap = max(6.0, round((humidity / 100.0) * 20.0, 3))
wind_kmh = _safe_float(getattr(forecast, "wind_speed_max", None), 7.2) wind_kmh = _safe_float(getattr(forecast, "wind_speed_max", None), 7.2)
wind_ms = round(wind_kmh / 3.6, 3) wind_ms = round(wind_kmh / 3.6, 3)
et0 = _safe_float(getattr(forecast, "et0", None), 0.35) et0 = _mm_to_cm_day(getattr(forecast, "et0", None), 0.35)
records.append( records.append(
{ {
"DAY": forecast.forecast_date, "DAY": forecast.forecast_date,
@@ -125,7 +130,7 @@ def _build_weather_records(forecasts: list[Any], *, latitude: float, longitude:
"TMAX": tmax, "TMAX": tmax,
"VAP": vap, "VAP": vap,
"WIND": wind_ms, "WIND": wind_ms,
"RAIN": _safe_float(getattr(forecast, "precipitation", None), 0.0), "RAIN": _mm_to_cm_day(getattr(forecast, "precipitation", None), 0.0),
"E0": et0, "E0": et0,
"ES0": max(et0 * 0.9, 0.1), "ES0": max(et0 * 0.9, 0.1),
"ET0": et0, "ET0": et0,
+22
View File
@@ -127,3 +127,25 @@ class YieldPredictionResponseSerializer(serializers.Serializer):
scenarioId = serializers.IntegerField(allow_null=True) scenarioId = serializers.IntegerField(allow_null=True)
simulationWarning = serializers.CharField(allow_null=True, allow_blank=True) simulationWarning = serializers.CharField(allow_null=True, allow_blank=True)
supportingMetrics = serializers.JSONField() supportingMetrics = serializers.JSONField()
class YieldHarvestSummaryQuerySerializer(serializers.Serializer):
farm_uuid = serializers.UUIDField(required=True, help_text="شناسه یکتای مزرعه")
season_year = serializers.IntegerField(required=False, help_text="سال زراعی")
crop_name = serializers.CharField(required=False, allow_blank=True, help_text="نام محصول")
include_narrative = serializers.BooleanField(
required=False,
default=False,
help_text="در صورت true بودن، بخش روایت نیز در آینده اضافه می شود.",
)
class YieldHarvestSummaryResponseSerializer(serializers.Serializer):
farm_uuid = serializers.CharField()
season_highlights_card = serializers.JSONField()
yield_prediction = serializers.JSONField()
harvest_prediction_card = serializers.JSONField()
harvest_readiness_zones = serializers.JSONField()
yield_quality_bands = serializers.JSONField()
harvest_operations_card = serializers.JSONField()
yield_prediction_chart = serializers.JSONField()
+95 -9
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import importlib import importlib
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime from datetime import date, datetime, timedelta
from typing import Any from typing import Any
from django.db import transaction from django.db import transaction
@@ -82,6 +82,11 @@ def _normalize_weather_records(weather: Any) -> list[dict[str, Any]]:
return normalized return normalized
def _mm_to_cm_day(value: Any, default: float) -> float:
scaled = _safe_float(value, default * 10.0) / 10.0
return round(max(scaled, 0.0), 4)
def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]: def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]:
if isinstance(agromanagement, dict) and "AgroManagement" in agromanagement: if isinstance(agromanagement, dict) and "AgroManagement" in agromanagement:
campaigns = agromanagement["AgroManagement"] campaigns = agromanagement["AgroManagement"]
@@ -94,7 +99,58 @@ def _normalize_agromanagement(agromanagement: Any) -> list[dict[str, Any]]:
if not campaigns: if not campaigns:
raise CropSimulationError("Agromanagement input cannot be empty.") raise CropSimulationError("Agromanagement input cannot be empty.")
return campaigns return _ensure_trailing_empty_campaign(campaigns)
def _ensure_trailing_empty_campaign(campaigns: list[dict[str, Any]]) -> list[dict[str, Any]]:
normalized = list(campaigns)
if not normalized:
return normalized
last_campaign = normalized[-1]
if _is_explicit_empty_campaign(last_campaign):
return normalized
trailing = _build_trailing_empty_campaign(normalized)
if last_campaign == {}:
normalized[-1] = trailing
else:
normalized.append(trailing)
return normalized
def _is_explicit_empty_campaign(campaign: dict[str, Any]) -> bool:
if not isinstance(campaign, dict) or len(campaign) != 1:
return False
start_date, payload = next(iter(campaign.items()))
return isinstance(start_date, date) and payload is None
def _build_trailing_empty_campaign(campaigns: list[dict[str, Any]]) -> dict[date, None]:
last_campaign = next((item for item in reversed(campaigns) if isinstance(item, dict) and item), None)
if not last_campaign:
return {date.today(): None}
campaign_start, campaign_payload = next(iter(last_campaign.items()))
candidate_dates = [_coerce_date(campaign_start)]
if isinstance(campaign_payload, dict):
crop_calendar = campaign_payload.get("CropCalendar") or {}
for field_name in ("crop_end_date", "crop_start_date"):
value = crop_calendar.get(field_name)
if value:
candidate_dates.append(_coerce_date(value))
for bucket_name in ("TimedEvents",):
for event_group in campaign_payload.get(bucket_name, []) or []:
if not isinstance(event_group, dict):
continue
for event in event_group.get("events_table", []) or []:
if not isinstance(event, dict) or not event:
continue
event_date = next(iter(event.keys()))
candidate_dates.append(_coerce_date(event_date))
return {max(candidate_dates) + timedelta(days=1): None}
def _deep_copy_json_like(value: Any) -> Any: def _deep_copy_json_like(value: Any) -> Any:
@@ -296,7 +352,7 @@ def _build_default_agromanagement(crop_name: str, weather: list[dict[str, Any]])
first_day = weather[0]["DAY"] first_day = weather[0]["DAY"]
last_day = weather[-1]["DAY"] last_day = weather[-1]["DAY"]
crop_end = max(last_day, first_day + (last_day - first_day)) crop_end = max(last_day, first_day + (last_day - first_day))
return [ return _ensure_trailing_empty_campaign([
{ {
first_day: { first_day: {
"CropCalendar": { "CropCalendar": {
@@ -312,7 +368,7 @@ def _build_default_agromanagement(crop_name: str, weather: list[dict[str, Any]])
"StateEvents": [], "StateEvents": [],
} }
} }
] ])
def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, longitude: float) -> list[dict[str, Any]]: def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, longitude: float) -> list[dict[str, Any]]:
@@ -333,10 +389,11 @@ def _build_weather_from_forecasts(forecasts: list[Any], *, latitude: float, long
), ),
"VAP": max(_safe_float(forecast.humidity_mean, 55.0) / 5.0, 6.0), "VAP": max(_safe_float(forecast.humidity_mean, 55.0) / 5.0, 6.0),
"WIND": _safe_float(forecast.wind_speed_max, 7.2) / 3.6, "WIND": _safe_float(forecast.wind_speed_max, 7.2) / 3.6,
"RAIN": _safe_float(forecast.precipitation, 0.0), # WeatherForecast stores precipitation/ET0 in mm/day, while PCSE expects cm/day.
"E0": _safe_float(forecast.et0, 0.35), "RAIN": _mm_to_cm_day(forecast.precipitation, 0.0),
"ES0": max(_safe_float(forecast.et0, 0.35) * 0.9, 0.1), "E0": _mm_to_cm_day(forecast.et0, 0.35),
"ET0": _safe_float(forecast.et0, 0.35), "ES0": max(round(_mm_to_cm_day(forecast.et0, 0.35) * 0.9, 4), 0.1),
"ET0": _mm_to_cm_day(forecast.et0, 0.35),
} }
for forecast in forecasts for forecast in forecasts
] ]
@@ -352,6 +409,17 @@ def _normalize_site_parameters_for_model(
soil = soil_parameters or {} soil = soil_parameters or {}
site.setdefault("WAV", _safe_float(site.get("WAV"), DEFAULT_WAV)) site.setdefault("WAV", _safe_float(site.get("WAV"), DEFAULT_WAV))
smw = _safe_float(soil.get("SMW"), 0.14)
smfcf = _safe_float(soil.get("SMFCF"), 0.34)
sm0 = _safe_float(
_pick_first_not_none(soil.get("SM0"), soil.get("SMMAX")),
min(max(smfcf + 0.08, smw + 0.12), 0.6),
)
site.setdefault("IFUNRN", 0)
site.setdefault("NOTINF", 0.0)
site.setdefault("SSI", 0.0)
site.setdefault("SSMAX", 0.0)
site.setdefault("SMLIM", round(_clamp(_safe_float(site.get("SMLIM"), smfcf), smw, sm0), 3))
if model_name.startswith("Wofost81_NWLP"): if model_name.startswith("Wofost81_NWLP"):
navaili = _pick_first_not_none( navaili = _pick_first_not_none(
site.get("NAVAILI"), site.get("NAVAILI"),
@@ -416,6 +484,14 @@ def build_simulation_payload_from_farm(
top_depth = depths[0] if depths else None top_depth = depths[0] if depths else None
smfcf = _clamp(_safe_float(getattr(top_depth, "wv0033", None), 0.34), 0.2, 0.55) smfcf = _clamp(_safe_float(getattr(top_depth, "wv0033", None), 0.34), 0.2, 0.55)
smw = _clamp(_safe_float(getattr(top_depth, "wv1500", None), 0.14), 0.05, max(smfcf - 0.02, 0.06)) smw = _clamp(_safe_float(getattr(top_depth, "wv1500", None), 0.14), 0.05, max(smfcf - 0.02, 0.06))
sm0 = _clamp(
_safe_float(
_pick_first_not_none(getattr(top_depth, "porosity", None), getattr(top_depth, "wv0000", None)),
min(max(smfcf + 0.08, smw + 0.12), 0.6),
),
max(smfcf + 0.02, smw + 0.05),
0.8,
)
soil_moisture = _sensor_metric(farm, "soil_moisture") soil_moisture = _sensor_metric(farm, "soil_moisture")
wav = ( wav = (
round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3) round(_clamp((soil_moisture or DEFAULT_WAV) / 100.0, smw, smfcf) * 100.0, 3)
@@ -431,7 +507,11 @@ def build_simulation_payload_from_farm(
resolved_soil = { resolved_soil = {
"SMFCF": round(smfcf, 3), "SMFCF": round(smfcf, 3),
"SMW": round(smw, 3), "SMW": round(smw, 3),
"SM0": round(sm0, 3),
"RDMSOL": 120.0, "RDMSOL": 120.0,
"CRAIRC": 0.06,
"SOPE": 10.0,
"KSUB": 10.0,
"soil_moisture": soil_moisture, "soil_moisture": soil_moisture,
"nitrogen": _safe_float(nitrogen, DEFAULT_NAVAILI), "nitrogen": _safe_float(nitrogen, DEFAULT_NAVAILI),
"phosphorus": _safe_float(phosphorus, 0.0), "phosphorus": _safe_float(phosphorus, 0.0),
@@ -454,6 +534,11 @@ def build_simulation_payload_from_farm(
"K_STATUS": _safe_float(potassium, 0.0), "K_STATUS": _safe_float(potassium, 0.0),
"SOIL_PH": _safe_float(soil_ph, 7.0), "SOIL_PH": _safe_float(soil_ph, 7.0),
"EC": _safe_float(ec, 0.0), "EC": _safe_float(ec, 0.0),
"IFUNRN": 0,
"NOTINF": 0.0,
"SSI": 0.0,
"SSMAX": 0.0,
"SMLIM": round(_clamp(_safe_float(_pick_first_not_none(site_parameters and site_parameters.get("SMLIM"), smfcf), smfcf), smw, sm0), 3),
} }
if site_parameters: if site_parameters:
resolved_site.update(site_parameters) resolved_site.update(site_parameters)
@@ -469,10 +554,11 @@ def build_simulation_payload_from_farm(
resolved_crop.update(crop_parameters) resolved_crop.update(crop_parameters)
resolved_crop.setdefault("crop_name", plant_name or getattr(plant, "name", "crop")) resolved_crop.setdefault("crop_name", plant_name or getattr(plant, "name", "crop"))
resolved_crop.setdefault("farm_uuid", str(farm_uuid)) resolved_crop.setdefault("farm_uuid", str(farm_uuid))
resolved_crop.setdefault("soil_ph", _safe_float(soil_ph, 7.0))
resolved_crop.setdefault("soil_nitrogen", _safe_float(nitrogen, DEFAULT_NAVAILI)) resolved_crop.setdefault("soil_nitrogen", _safe_float(nitrogen, DEFAULT_NAVAILI))
resolved_crop.setdefault("soil_phosphorus", _safe_float(phosphorus, 0.0)) resolved_crop.setdefault("soil_phosphorus", _safe_float(phosphorus, 0.0))
resolved_crop.setdefault("soil_potassium", _safe_float(potassium, 0.0)) resolved_crop.setdefault("soil_potassium", _safe_float(potassium, 0.0))
# Keep pH in soil/site payloads only; duplicating it in cropdata breaks some PCSE parameter providers.
resolved_crop.pop("soil_ph", None)
default_agromanagement = ( default_agromanagement = (
deepcopy(simulation_profile.get("agromanagement")) deepcopy(simulation_profile.get("agromanagement"))
@@ -102,6 +102,19 @@ class PlantGrowthSimulationApiTests(TestCase):
self.assertEqual(response.status_code, 202) self.assertEqual(response.status_code, 202)
self.assertEqual(response.json()["data"]["task_id"], "growth-task-1") self.assertEqual(response.json()["data"]["task_id"], "growth-task-1")
def test_queue_api_returns_400_for_missing_weather_and_farm_uuid(self):
response = self.client.post(
"/growth/",
data={
"plant_name": self.plant.name,
"dynamic_parameters": ["DVS", "LAI"],
},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
@patch("crop_simulation.views._get_async_result") @patch("crop_simulation.views._get_async_result")
def test_status_api_returns_paginated_stages(self, mock_get_async_result): def test_status_api_returns_paginated_stages(self, mock_get_async_result):
stage_timeline = [ stage_timeline = [
@@ -159,6 +172,31 @@ class PlantGrowthSimulationApiTests(TestCase):
self.assertEqual(len(payload["stages_page"]), 1) self.assertEqual(len(payload["stages_page"]), 1)
self.assertEqual(payload["stages_page"][0]["stage_code"], "vegetative") self.assertEqual(payload["stages_page"][0]["stage_code"], "vegetative")
@patch("crop_simulation.views._get_async_result")
def test_status_api_returns_pending_state(self, mock_get_async_result):
mock_get_async_result.return_value = SimpleNamespace(state="PENDING")
response = self.client.get("/growth/growth-task-1/status/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["status"], "PENDING")
self.assertIn("message", payload)
@patch("crop_simulation.views._get_async_result")
def test_status_api_returns_failure_state(self, mock_get_async_result):
mock_get_async_result.return_value = SimpleNamespace(
state="FAILURE",
result=RuntimeError("task crashed"),
)
response = self.client.get("/growth/growth-task-1/status/")
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["status"], "FAILURE")
self.assertEqual(payload["error"], "task crashed")
@patch("crop_simulation.views.apps.get_app_config") @patch("crop_simulation.views.apps.get_app_config")
def test_current_farm_chart_api_returns_simulation_payload(self, mock_get_app_config): def test_current_farm_chart_api_returns_simulation_payload(self, mock_get_app_config):
mock_simulator = SimpleNamespace( mock_simulator = SimpleNamespace(
@@ -218,6 +256,37 @@ class PlantGrowthSimulationApiTests(TestCase):
self.assertEqual(payload["current_state"]["leaf_count_estimate"], 140.0) self.assertEqual(payload["current_state"]["leaf_count_estimate"], 140.0)
self.assertEqual(payload["series"][0]["key"], "leaf_count_estimate") self.assertEqual(payload["series"][0]["key"], "leaf_count_estimate")
def test_current_farm_chart_api_returns_400_for_missing_farm_uuid(self):
response = self.client.post(
"/current-farm-chart/",
data={"plant_name": self.plant.name},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
@patch("crop_simulation.views.apps.get_app_config")
def test_current_farm_chart_api_returns_500_when_simulator_fails(self, mock_get_app_config):
mock_simulator = SimpleNamespace(
simulate=lambda **_kwargs: (_ for _ in ()).throw(RuntimeError("simulator offline"))
)
mock_get_app_config.return_value = SimpleNamespace(
get_current_farm_chart_simulator=lambda: mock_simulator
)
response = self.client.post(
"/current-farm-chart/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json()["code"], 500)
@patch("crop_simulation.views.apps.get_app_config") @patch("crop_simulation.views.apps.get_app_config")
def test_harvest_prediction_api_returns_payload(self, mock_get_app_config): def test_harvest_prediction_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace( mock_service = SimpleNamespace(
@@ -254,6 +323,38 @@ class PlantGrowthSimulationApiTests(TestCase):
self.assertEqual(payload["daysUntil"], 43) self.assertEqual(payload["daysUntil"], 43)
self.assertEqual(payload["gddDetails"]["simulation_engine"], "growth_projection") self.assertEqual(payload["gddDetails"]["simulation_engine"], "growth_projection")
def test_harvest_prediction_api_returns_400_for_missing_farm_uuid(self):
response = self.client.post(
"/harvest-prediction/",
data={"plant_name": self.plant.name},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
@patch("crop_simulation.views.apps.get_app_config")
def test_harvest_prediction_api_returns_500_when_service_fails(self, mock_get_app_config):
class BrokenService:
def get_harvest_prediction(self, **_kwargs):
raise RuntimeError("harvest offline")
mock_get_app_config.return_value = SimpleNamespace(
get_harvest_prediction_service=lambda: BrokenService()
)
response = self.client.post(
"/harvest-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json()["code"], 500)
@patch("crop_simulation.views.apps.get_app_config") @patch("crop_simulation.views.apps.get_app_config")
def test_yield_prediction_api_returns_payload(self, mock_get_app_config): def test_yield_prediction_api_returns_payload(self, mock_get_app_config):
mock_service = SimpleNamespace( mock_service = SimpleNamespace(
@@ -288,3 +389,70 @@ class PlantGrowthSimulationApiTests(TestCase):
payload = response.json()["data"] payload = response.json()["data"]
self.assertEqual(payload["predictedYieldTons"], 5.4) self.assertEqual(payload["predictedYieldTons"], 5.4)
self.assertEqual(payload["sourceUnit"], "kg/ha") self.assertEqual(payload["sourceUnit"], "kg/ha")
def test_yield_prediction_api_returns_400_for_missing_farm_uuid(self):
response = self.client.post(
"/yield-prediction/",
data={"plant_name": self.plant.name},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
@patch("crop_simulation.views.apps.get_app_config")
def test_yield_prediction_api_returns_500_when_service_fails(self, mock_get_app_config):
class BrokenService:
def get_yield_prediction(self, **_kwargs):
raise RuntimeError("yield offline")
mock_get_app_config.return_value = SimpleNamespace(
get_yield_prediction_service=lambda: BrokenService()
)
response = self.client.post(
"/yield-prediction/",
data={
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"plant_name": self.plant.name,
},
format="json",
)
self.assertEqual(response.status_code, 500)
self.assertEqual(response.json()["code"], 500)
@patch("crop_simulation.views.YieldHarvestSummaryService")
def test_yield_harvest_summary_api_returns_payload(self, mock_service_cls):
mock_service_cls.return_value.get_summary.return_value = {
"farm_uuid": "550e8400-e29b-41d4-a716-446655440000",
"season_highlights_card": {"title": "Season highlights", "subtitle": "Good season."},
"yield_prediction": {"predicted_yield_tons": 5.4, "explanation": "Stable projection."},
"harvest_prediction_card": {"harvest_date": "2026-05-14"},
"harvest_readiness_zones": {"averageReadiness": 74, "summary": "Readiness improving."},
"yield_quality_bands": {"primary_quality_grade": "A"},
"harvest_operations_card": {"steps": [{"key": "harvesting", "note": "Prepare combine."}]},
"yield_prediction_chart": {"series": [], "xAxis": {"type": "datetime"}},
}
response = self.client.get(
"/yield-harvest-summary/?farm_uuid=550e8400-e29b-41d4-a716-446655440000"
"&season_year=1404&crop_name=wheat&include_narrative=true"
)
self.assertEqual(response.status_code, 200)
payload = response.json()["data"]
self.assertEqual(payload["farm_uuid"], "550e8400-e29b-41d4-a716-446655440000")
self.assertEqual(payload["yield_quality_bands"]["primary_quality_grade"], "A")
mock_service_cls.return_value.get_summary.assert_called_once_with(
farm_uuid="550e8400-e29b-41d4-a716-446655440000",
season_year="1404",
crop_name="wheat",
include_narrative=True,
)
def test_yield_harvest_summary_api_returns_400_for_missing_farm_uuid(self):
response = self.client.get("/yield-harvest-summary/?season_year=1404&crop_name=wheat")
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["code"], 400)
+2
View File
@@ -5,6 +5,7 @@ from .views import (
HarvestPredictionView, HarvestPredictionView,
PlantGrowthSimulationStatusView, PlantGrowthSimulationStatusView,
PlantGrowthSimulationView, PlantGrowthSimulationView,
YieldHarvestSummaryView,
YieldPredictionView, YieldPredictionView,
) )
@@ -12,6 +13,7 @@ from .views import (
urlpatterns = [ urlpatterns = [
path("current-farm-chart/", CurrentFarmSimulationChartView.as_view(), name="current-farm-chart"), path("current-farm-chart/", CurrentFarmSimulationChartView.as_view(), name="current-farm-chart"),
path("harvest-prediction/", HarvestPredictionView.as_view(), name="harvest-prediction"), path("harvest-prediction/", HarvestPredictionView.as_view(), name="harvest-prediction"),
path("yield-harvest-summary/", YieldHarvestSummaryView.as_view(), name="yield-harvest-summary"),
path("yield-prediction/", YieldPredictionView.as_view(), name="yield-prediction"), path("yield-prediction/", YieldPredictionView.as_view(), name="yield-prediction"),
path("growth/", PlantGrowthSimulationView.as_view(), name="growth-simulation"), path("growth/", PlantGrowthSimulationView.as_view(), name="growth-simulation"),
path( path(
+96 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from django.apps import apps from django.apps import apps
from drf_spectacular.utils import OpenApiExample, extend_schema from drf_spectacular.utils import OpenApiExample, OpenApiParameter, extend_schema
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -22,10 +22,13 @@ from .serializers import (
GrowthSimulationResultSerializer, GrowthSimulationResultSerializer,
HarvestPredictionRequestSerializer, HarvestPredictionRequestSerializer,
HarvestPredictionResponseSerializer, HarvestPredictionResponseSerializer,
YieldHarvestSummaryQuerySerializer,
YieldHarvestSummaryResponseSerializer,
YieldPredictionRequestSerializer, YieldPredictionRequestSerializer,
YieldPredictionResponseSerializer, YieldPredictionResponseSerializer,
) )
from .tasks import run_growth_simulation_task from .tasks import run_growth_simulation_task
from .yield_harvest_summary import YieldHarvestSummaryService
GrowthSimulationQueuedResponseSerializer = build_envelope_serializer( GrowthSimulationQueuedResponseSerializer = build_envelope_serializer(
@@ -196,6 +199,10 @@ YieldPredictionEnvelopeSerializer = build_envelope_serializer(
"YieldPredictionEnvelopeSerializer", "YieldPredictionEnvelopeSerializer",
YieldPredictionResponseSerializer, YieldPredictionResponseSerializer,
) )
YieldHarvestSummaryEnvelopeSerializer = build_envelope_serializer(
"YieldHarvestSummaryEnvelopeSerializer",
YieldHarvestSummaryResponseSerializer,
)
class CurrentFarmSimulationChartView(APIView): class CurrentFarmSimulationChartView(APIView):
@@ -349,3 +356,91 @@ class YieldPredictionView(APIView):
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )
return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK) return Response({"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK)
class YieldHarvestSummaryView(APIView):
@extend_schema(
tags=["Crop Simulation"],
summary="خلاصه عملکرد و برداشت",
description=(
"خروجی داشبورد Yield & Harvest Summary را با اتکا به داده های قطعی شبیه سازی برمی گرداند. "
"فعلا پاسخ به صورت mock با کارت های خالی بازگردانده می شود."
),
parameters=[
OpenApiParameter(
name="farm_uuid",
type=str,
location=OpenApiParameter.QUERY,
required=True,
description="شناسه یکتای مزرعه",
),
OpenApiParameter(
name="season_year",
type=int,
location=OpenApiParameter.QUERY,
required=False,
description="سال زراعی",
),
OpenApiParameter(
name="crop_name",
type=str,
location=OpenApiParameter.QUERY,
required=False,
description="نام محصول",
),
OpenApiParameter(
name="include_narrative",
type=bool,
location=OpenApiParameter.QUERY,
required=False,
description="در آینده روایت متنی را نیز اضافه می کند.",
),
],
responses={
200: build_response(
YieldHarvestSummaryEnvelopeSerializer,
"خروجی خلاصه عملکرد و برداشت مزرعه.",
),
400: build_response(
GrowthSimulationErrorSerializer,
"پارامترهای query نامعتبر است.",
),
},
examples=[
OpenApiExample(
"نمونه پاسخ yield harvest summary",
value={
"code": 200,
"msg": "success",
"data": {
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"season_highlights_card": {},
"yield_prediction": {},
"harvest_prediction_card": {},
"harvest_readiness_zones": {},
"yield_quality_bands": {},
"harvest_operations_card": {},
"yield_prediction_chart": {},
},
},
response_only=True,
),
],
)
def get(self, request):
serializer = YieldHarvestSummaryQuerySerializer(data=request.query_params)
if not serializer.is_valid():
return Response(
{"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
validated = serializer.validated_data
service = YieldHarvestSummaryService()
payload = service.get_summary(
farm_uuid=str(validated["farm_uuid"]),
season_year=str(validated.get("season_year") or ""),
crop_name=validated.get("crop_name") or "",
include_narrative=validated.get("include_narrative", False),
)
return Response({"code": 200, "msg": "success", "data": payload}, status=status.HTTP_200_OK)
+985
View File
@@ -0,0 +1,985 @@
from __future__ import annotations
import copy
import logging
import math
from datetime import date, datetime
from typing import Any, Callable
from django.apps import apps
from django.conf import settings
from farm_data.models import SensorData
from farm_data.services import get_farm_details
from location_data.models import NdviObservation, SoilLocation
from rag.services.yield_harvest import YieldHarvestRAGService
logger = logging.getLogger(__name__)
class YieldHarvestSummaryService:
def get_summary(
self,
farm_uuid: str,
season_year: str,
crop_name: str,
include_narrative: bool = True,
) -> dict[str, Any]:
farm_context = self._get_farm_context(farm_uuid)
farm_context["season_year"] = season_year
farm_context["crop_name"] = crop_name or farm_context.get("crop_name") or ""
yield_prediction = self._build_yield_prediction(
farm_uuid=farm_uuid,
season_year=season_year,
crop_name=crop_name,
include_narrative=include_narrative,
farm_context=farm_context,
)
harvest_prediction_card = self._build_harvest_prediction_card(
farm_uuid=farm_uuid,
season_year=season_year,
crop_name=crop_name,
include_narrative=include_narrative,
farm_context=farm_context,
)
harvest_readiness_zones = self._build_harvest_readiness_zones(
farm_uuid=farm_uuid,
season_year=season_year,
crop_name=crop_name,
include_narrative=include_narrative,
farm_context=farm_context,
)
yield_quality_bands = self._build_yield_quality_bands(
farm_uuid=farm_uuid,
season_year=season_year,
crop_name=crop_name,
include_narrative=include_narrative,
farm_context=farm_context,
)
harvest_operations_card = self._build_harvest_operations_card(
farm_context=farm_context,
harvest_prediction_card=harvest_prediction_card,
pcse_dvs_stage=self._extract_pcse_dvs_stage(harvest_prediction_card),
)
yield_prediction_chart = self._build_yield_prediction_chart(
farm_uuid=farm_uuid,
season_year=season_year,
crop_name=crop_name,
include_narrative=include_narrative,
farm_context=farm_context,
)
season_highlights_card = self._build_season_highlights_card(
farm_uuid=farm_uuid,
season_year=season_year,
crop_name=crop_name,
include_narrative=include_narrative,
farm_context=farm_context,
yield_prediction=yield_prediction,
harvest_prediction_card=harvest_prediction_card,
harvest_readiness_zones=harvest_readiness_zones,
yield_quality_bands=yield_quality_bands,
)
deterministic_payload = {
"farm_uuid": farm_uuid,
"season_highlights_card": season_highlights_card,
"yield_prediction": yield_prediction,
"harvest_prediction_card": harvest_prediction_card,
"harvest_readiness_zones": harvest_readiness_zones,
"yield_quality_bands": yield_quality_bands,
"harvest_operations_card": harvest_operations_card,
"yield_prediction_chart": yield_prediction_chart,
}
context_payload = {
**copy.deepcopy(deterministic_payload),
"farm_context": farm_context,
}
if not include_narrative:
return deterministic_payload
try:
rag_service = YieldHarvestRAGService()
narrative_data = rag_service.generate_narrative(context_payload)
except Exception as exc:
logger.warning(
"Yield harvest narrative generation failed for farm_uuid=%s: %s",
farm_uuid,
exc,
)
narrative_data = {}
return self._merge_narrative(deterministic_payload, narrative_data)
def _build_yield_prediction(
self,
*,
farm_uuid: str,
season_year: str,
crop_name: str,
include_narrative: bool,
farm_context: dict[str, Any],
) -> dict[str, Any]:
service = apps.get_app_config("crop_simulation").get_yield_prediction_service()
result = service.get_yield_prediction(
farm_uuid=farm_uuid,
plant_name=crop_name or None,
)
supporting_metrics = dict(result.get("supportingMetrics") or {})
# Secondary KPIs are placeholders until dedicated deterministic formulas land.
supporting_metrics.setdefault(
"estimatedKpis",
{
"season_year": season_year,
"applied_rule": "simple_placeholder_rules",
"is_estimated": True,
},
)
return {
"farm_uuid": result.get("farm_uuid", farm_uuid),
"crop_name": result.get("plant_name") or crop_name,
"season_year": season_year,
"predicted_yield_tons": result.get("predictedYieldTons"),
"predicted_yield_raw": result.get("predictedYieldRaw"),
"unit": result.get("unit"),
"source_unit": result.get("sourceUnit"),
"simulation_engine": result.get("simulationEngine"),
"simulation_model": result.get("simulationModel"),
"scenario_id": result.get("scenarioId"),
"simulation_warning": result.get("simulationWarning"),
"secondary_kpis_estimated": True,
"descriptionSource": "deterministic",
"farm_context": {
"soil_type": farm_context.get("soil", {}).get("soil_type"),
"soil_data_provider": farm_context.get("soil", {}).get("provider"),
},
"supporting_metrics": supporting_metrics,
}
def _build_harvest_prediction_card(
self,
*,
farm_uuid: str,
season_year: str,
crop_name: str,
include_narrative: bool,
farm_context: dict[str, Any],
) -> dict[str, Any]:
service = apps.get_app_config("crop_simulation").get_harvest_prediction_service()
result = service.get_harvest_prediction(
farm_uuid=farm_uuid,
plant_name=crop_name or None,
)
fallback_description = (
f"Deterministic harvest forecast for {crop_name or 'the selected crop'} "
f"in season {season_year}."
)
return {
"farm_uuid": farm_uuid,
"crop_name": crop_name,
"season_year": season_year,
"harvest_date": result.get("date"),
"harvest_date_formatted": result.get("dateFormatted"),
"days_until": result.get("daysUntil"),
"optimal_window_start": result.get("optimalWindowStart"),
"optimal_window_end": result.get("optimalWindowEnd"),
"description": result.get("description") or fallback_description,
"descriptionSource": "deterministic",
"field_conditions": {
"soil_moisture": farm_context.get("recent_sensor_averages", {}).get("soil_moisture"),
"soil_temperature": farm_context.get("recent_sensor_averages", {}).get("soil_temperature"),
},
"readiness_metrics": result.get("gddDetails") or {},
}
def _build_yield_prediction_chart(
self,
*,
farm_uuid: str,
season_year: str,
crop_name: str,
include_narrative: bool,
farm_context: dict[str, Any],
) -> dict[str, Any]:
simulator = apps.get_app_config("crop_simulation").get_current_farm_chart_simulator()
result = simulator.simulate(
farm_uuid=farm_uuid,
plant_name=crop_name or None,
)
pcse_timeseries = list(result.get("daily_output") or [])
yield_series: list[list[float]] = []
biomass_series: list[list[float]] = []
for item in pcse_timeseries:
timestamp = self._to_unix_timestamp(item.get("DAY"))
if timestamp is None:
continue
twso = self._safe_chart_value(item.get("TWSO"))
if twso is not None:
yield_series.append([timestamp, twso])
tagp = self._safe_chart_value(item.get("TAGP"))
if tagp is not None:
biomass_series.append([timestamp, tagp])
return {
"farm_uuid": farm_uuid,
"crop_name": result.get("plant_name") or crop_name,
"season_year": season_year,
"series": [
{
"name": "Predicted Yield",
"type": "line",
"data": yield_series,
},
{
"name": "Biomass",
"type": "area",
"data": biomass_series,
},
],
"xAxis": {"type": "datetime"},
"meta": {
"unit": "kg/ha",
"simulation_engine": result.get("engine"),
"simulation_model": result.get("model_name"),
"scenario_id": result.get("scenario_id"),
"simulation_warning": result.get("simulation_warning"),
"field_context": {
"soil_type": farm_context.get("soil", {}).get("soil_type"),
"center_coordinates": farm_context.get("center_coordinates"),
},
},
}
def _build_harvest_operations_card(
self,
*,
farm_context: dict[str, Any],
harvest_prediction_card: dict[str, Any],
pcse_dvs_stage: float,
) -> dict[str, Any]:
days_until = int(harvest_prediction_card.get("days_until") or 0)
stage_label, phase_name = self._map_dvs_to_phase(pcse_dvs_stage)
steps = self._build_operations_steps(
phase_name=phase_name,
days_until=days_until,
soil_moisture=farm_context.get("recent_sensor_averages", {}).get("soil_moisture"),
)
return {
"farm_uuid": farm_context.get("farm_uuid"),
"crop_name": farm_context.get("crop_name"),
"season_year": farm_context.get("season_year"),
"stage_label": stage_label,
"phase_name": phase_name,
"days_until_harvest": days_until,
"current_dvs": round(pcse_dvs_stage, 4),
"summary": (
f"Operations are prioritized for {farm_context.get('crop_name') or 'the selected crop'} "
f"with {days_until} days remaining until the predicted harvest window."
),
"rules_source": "deterministic_dvs_rules",
"field_context": {
"soil_type": farm_context.get("soil", {}).get("soil_type"),
"soil_moisture": farm_context.get("recent_sensor_averages", {}).get("soil_moisture"),
"soil_temperature": farm_context.get("recent_sensor_averages", {}).get("soil_temperature"),
},
"steps": steps,
}
def _build_season_highlights_card(
self,
*,
farm_uuid: str,
season_year: str,
crop_name: str,
include_narrative: bool,
farm_context: dict[str, Any],
yield_prediction: dict[str, Any],
harvest_prediction_card: dict[str, Any],
harvest_readiness_zones: dict[str, Any],
yield_quality_bands: dict[str, Any],
) -> dict[str, Any]:
primary_quality_grade = (
yield_quality_bands.get("primary_quality_grade")
or yield_quality_bands.get("top_band")
or yield_quality_bands.get("summary")
)
average_readiness = harvest_readiness_zones.get("averageReadiness")
total_predicted_yield = yield_prediction.get("predicted_yield_tons")
target_harvest_date = (
harvest_prediction_card.get("harvest_date_formatted")
or harvest_prediction_card.get("harvest_date")
)
estimated_revenue = self._get_estimated_revenue(
farm_uuid=farm_uuid,
total_predicted_yield=total_predicted_yield,
)
return {
"farm_uuid": farm_uuid,
"crop_name": crop_name,
"season_year": season_year,
"title": "Season highlights",
# Left blank for narrative merge unless a non-LLM fallback is needed later.
"subtitle": "",
"total_predicted_yield": total_predicted_yield,
"yield_unit": yield_prediction.get("unit"),
"target_harvest_date": target_harvest_date,
"days_until_harvest": harvest_prediction_card.get("days_until"),
"average_readiness": average_readiness,
"primary_quality_grade": primary_quality_grade,
"estimated_revenue": estimated_revenue,
"soil_type": farm_context.get("soil", {}).get("soil_type"),
}
def _build_harvest_readiness_zones(
self,
*,
farm_uuid: str,
season_year: str,
crop_name: str,
include_narrative: bool,
farm_context: dict[str, Any],
) -> dict[str, Any]:
sensor = (
SensorData.objects.select_related("center_location")
.filter(farm_uuid=farm_uuid)
.first()
)
if sensor is None or sensor.center_location is None:
return {
"farm_uuid": farm_uuid,
"averageReadiness": None,
"zones": [],
"source": "ndvi_health_service",
}
location = sensor.center_location
ndvi_service = apps.get_app_config("location_data").get_ndvi_health_service()
health_card = ndvi_service.get_ndvi_health(farm_uuid=farm_uuid)
observations = list(
location.ndvi_observations.order_by("-observation_date", "-created_at")[:2]
)
latest_observation = observations[0] if observations else None
previous_observation = observations[1] if len(observations) > 1 else None
latest_ndvi = self._safe_float(health_card.get("mean_ndvi"), None)
previous_ndvi = self._safe_float(
previous_observation.mean_ndvi if previous_observation else None,
None,
)
ndvi_trend = None
if latest_ndvi is not None and previous_ndvi is not None:
ndvi_trend = round(latest_ndvi - previous_ndvi, 4)
grid = {}
if latest_observation and isinstance(latest_observation.ndvi_map, dict):
grid = latest_observation.ndvi_map
ndvi_grid = grid.get("grid") if isinstance(grid, dict) else None
zones: list[dict[str, Any]] = []
if isinstance(ndvi_grid, list) and ndvi_grid:
zone_index = 1
for row_index, row in enumerate(ndvi_grid):
if not isinstance(row, list):
continue
for col_index, cell in enumerate(row):
cell_ndvi = self._safe_chart_value(cell)
if cell_ndvi is None:
continue
readiness = self._ndvi_to_readiness(cell_ndvi, ndvi_trend)
zones.append(
{
"zoneId": f"zone-{zone_index}",
"zoneLabel": f"Zone {zone_index}",
"gridPosition": {"row": row_index, "col": col_index},
"meanNdvi": cell_ndvi,
"readiness": readiness,
"daysUntil": self._estimate_days_until_from_readiness(readiness),
"status": self._readiness_status(readiness),
}
)
zone_index += 1
if not zones and latest_ndvi is not None:
readiness = self._ndvi_to_readiness(latest_ndvi, ndvi_trend)
zones.append(
{
"zoneId": "zone-center",
"zoneLabel": "Center field zone",
"gridPosition": None,
"meanNdvi": latest_ndvi,
"readiness": readiness,
"daysUntil": self._estimate_days_until_from_readiness(readiness),
"status": self._readiness_status(readiness),
}
)
average_readiness = None
if zones:
average_readiness = round(
sum(zone["readiness"] for zone in zones) / len(zones),
2,
)
return {
"farm_uuid": farm_uuid,
"observationDate": (
latest_observation.observation_date.isoformat()
if latest_observation
else health_card.get("observation_date")
),
"vegetationHealthClass": health_card.get("vegetation_health_class"),
"meanNdvi": latest_ndvi,
"ndviTrend": ndvi_trend,
"averageReadiness": average_readiness,
"zones": zones,
"source": "ndvi_health_service",
}
def _to_unix_timestamp(self, value: Any) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp() * 1000)
if isinstance(value, date):
return int(datetime.combine(value, datetime.min.time()).timestamp() * 1000)
if isinstance(value, str):
try:
if "T" in value:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
else:
parsed = datetime.combine(date.fromisoformat(value), datetime.min.time())
return int(parsed.timestamp() * 1000)
except ValueError:
return None
return None
def _safe_chart_value(self, value: Any) -> float | None:
parsed = self._safe_float(value, None)
if parsed is None or math.isnan(parsed) or math.isinf(parsed):
return None
return round(parsed, 4)
def _ndvi_to_readiness(self, mean_ndvi: float, trend_delta: float | None) -> int:
base_score = ((0.75 - mean_ndvi) / 0.55) * 100.0
if trend_delta is not None and trend_delta < 0:
# A falling NDVI near season end suggests drying and harvest readiness.
base_score += min(abs(trend_delta) * 120.0, 18.0)
if trend_delta is not None and trend_delta > 0.05:
base_score -= min(trend_delta * 80.0, 12.0)
return int(round(max(0.0, min(base_score, 100.0))))
def _estimate_days_until_from_readiness(self, readiness: int) -> int:
return max(int(round((100 - readiness) / 12.0)), 0)
def _readiness_status(self, readiness: int) -> str:
if readiness >= 80:
return "ready"
if readiness >= 55:
return "approaching"
if readiness >= 30:
return "monitoring"
return "not_ready"
def _build_yield_quality_bands(
self,
*,
farm_uuid: str,
season_year: str,
crop_name: str,
include_narrative: bool,
farm_context: dict[str, Any],
) -> dict[str, Any]:
crop_key = (crop_name or farm_context.get("crop_name") or "").strip().lower()
yield_service = apps.get_app_config("crop_simulation").get_yield_prediction_service()
yield_payload = yield_service.get_yield_prediction(
farm_uuid=farm_uuid,
plant_name=crop_name or None,
)
predicted_yield_raw = self._safe_float(yield_payload.get("predictedYieldRaw"), 0.0) or 0.0
soil_metrics = farm_context.get("soil", {}).get("resolved_metrics") or {}
sensor_metrics = farm_context.get("recent_sensor_averages") or {}
try:
service = self._resolve_service(
getter_names=(
"get_yield_quality_bands_service",
"get_quality_grading_service",
"get_quality_model_service",
)
)
method = self._resolve_service_method(
service,
method_names=(
"get_yield_quality_bands",
"get_quality_bands",
"grade_yield_quality",
),
)
return method(
farm_uuid=farm_uuid,
season_year=season_year,
crop_name=crop_name or None,
include_narrative=include_narrative,
)
except AttributeError:
pass
protein_content = self._estimate_protein_content(
crop_key=crop_key,
nitrogen_value=self._safe_float(soil_metrics.get("nitrogen"), None),
predicted_yield_raw=predicted_yield_raw,
)
moisture_percent = self._estimate_moisture_percent(
crop_key=crop_key,
soil_moisture=sensor_metrics.get("soil_moisture"),
)
quality_score = self._estimate_quality_score(
protein_content=protein_content,
moisture_percent=moisture_percent,
predicted_yield_raw=predicted_yield_raw,
)
grade_distribution = self._build_grade_distribution(quality_score)
primary_quality_grade = max(
grade_distribution,
key=lambda item: item.get("share_percent", 0),
)["grade"]
return {
"farm_uuid": farm_uuid,
"crop_name": crop_name,
"season_year": season_year,
"source": "deterministic_grading_rules",
"is_estimated": True,
"protein_content": {
"value": protein_content,
"unit": "%",
},
"moisture_percentage": {
"value": moisture_percent,
"unit": "%",
},
"grade_distribution": grade_distribution,
"primary_quality_grade": primary_quality_grade,
"quality_score": quality_score,
"summary": f"Primary quality grade is {primary_quality_grade}.",
}
def _get_estimated_revenue(
self,
*,
farm_uuid: str,
total_predicted_yield: float | None,
) -> float | None:
try:
service = apps.get_app_config("economy").get_economic_overview_service()
overview = service.get_economic_overview(farm_uuid=farm_uuid)
except Exception:
return None
if not isinstance(overview, dict):
return None
price_per_ton = None
for item in overview.get("economicData") or []:
if not isinstance(item, dict):
continue
title = str(item.get("title") or "").lower()
value = item.get("value")
if "price" in title or "قیمت" in title:
price_per_ton = self._extract_numeric(value)
break
if price_per_ton is None or total_predicted_yield is None:
return None
return round(total_predicted_yield * price_per_ton, 2)
def _estimate_protein_content(
self,
*,
crop_key: str,
nitrogen_value: float | None,
predicted_yield_raw: float,
) -> float:
nitrogen_factor = 0.0 if nitrogen_value is None else min(nitrogen_value / 2500.0, 2.0)
yield_factor = min(predicted_yield_raw / 10000.0, 1.5)
if "wheat" in crop_key or "گندم" in crop_key:
base = 11.8
return round(base + (nitrogen_factor * 1.2) - (yield_factor * 0.35), 2)
if "barley" in crop_key or "جو" in crop_key:
base = 10.4
return round(base + (nitrogen_factor * 0.9) - (yield_factor * 0.25), 2)
return round(9.5 + (nitrogen_factor * 0.8), 2)
def _estimate_moisture_percent(
self,
*,
crop_key: str,
soil_moisture: float | None,
) -> float:
soil_component = 0.0 if soil_moisture is None else min(max((soil_moisture - 20.0) / 10.0, -2.0), 4.0)
if "wheat" in crop_key or "barley" in crop_key or "گندم" in crop_key or "جو" in crop_key:
return round(12.6 + soil_component, 2)
return round(11.8 + soil_component, 2)
def _estimate_quality_score(
self,
*,
protein_content: float,
moisture_percent: float,
predicted_yield_raw: float,
) -> int:
protein_score = min(max((protein_content / 14.0) * 50.0, 0.0), 50.0)
moisture_penalty = min(abs(moisture_percent - 12.5) * 4.5, 22.0)
yield_bonus = min(predicted_yield_raw / 1500.0, 18.0)
score = protein_score + yield_bonus + 32.0 - moisture_penalty
return int(round(max(0.0, min(score, 100.0))))
def _build_grade_distribution(self, quality_score: int) -> list[dict[str, Any]]:
if quality_score >= 85:
return [
{"grade": "A", "share_percent": 62},
{"grade": "B", "share_percent": 28},
{"grade": "C", "share_percent": 10},
]
if quality_score >= 70:
return [
{"grade": "A", "share_percent": 38},
{"grade": "B", "share_percent": 44},
{"grade": "C", "share_percent": 18},
]
return [
{"grade": "A", "share_percent": 16},
{"grade": "B", "share_percent": 41},
{"grade": "C", "share_percent": 43},
]
def _extract_numeric(self, value: Any) -> float | None:
if isinstance(value, (int, float)):
return float(value)
if not isinstance(value, str):
return None
cleaned = "".join(ch for ch in value if ch.isdigit() or ch in {".", "-"})
return self._safe_float(cleaned, None)
def _get_farm_context(
self,
farm_uuid: str,
) -> dict[str, Any]:
farm = (
SensorData.objects.select_related("center_location", "weather_forecast")
.prefetch_related("center_location__depths", "plants")
.filter(farm_uuid=farm_uuid)
.first()
)
if farm is None:
return {
"farm_uuid": farm_uuid,
"center_coordinates": None,
"soil": {"provider": getattr(settings, "SOIL_DATA_PROVIDER", "unknown")},
"recent_sensor_averages": {},
}
farm_details = get_farm_details(str(farm_uuid)) or {}
center_location = farm.center_location
soil_details = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
weather_details = farm_details.get("weather") or {}
recent_sensor_averages = {
"soil_moisture": self._safe_float(soil_details.get("soil_moisture", farm.soil_moisture), None),
"soil_temperature": self._safe_float(soil_details.get("soil_temperature", farm.soil_temperature), None),
"air_temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None),
}
crop_name = ""
plant_names = farm_details.get("plants") or []
if plant_names:
first_plant = plant_names[0]
if isinstance(first_plant, dict):
crop_name = str(first_plant.get("name") or "")
return {
"farm_uuid": farm_uuid,
"crop_name": crop_name,
"center_coordinates": {
"lat": float(center_location.latitude),
"lon": float(center_location.longitude),
},
"farm_boundary": farm_details.get("center_location", {}).get("farm_boundary"),
"soil": {
"provider": getattr(settings, "SOIL_DATA_PROVIDER", "unknown"),
"soil_type": self._infer_soil_type(soil_details),
"resolved_metrics": soil_details,
},
"recent_sensor_averages": recent_sensor_averages,
"weather": {
"temperature_mean": self._safe_float(weather_details.get("temperature_mean"), None),
"temperature_min": self._safe_float(weather_details.get("temperature_min"), None),
"temperature_max": self._safe_float(weather_details.get("temperature_max"), None),
},
"source_models": {
"sensor_data": SensorData.__name__,
"soil_location": SoilLocation.__name__,
},
}
def _extract_pcse_dvs_stage(self, harvest_prediction_card: dict[str, Any]) -> float:
readiness_metrics = harvest_prediction_card.get("readiness_metrics") or {}
forecast = readiness_metrics.get("daily_gdd_forecast") or [{}]
return self._safe_float(forecast[-1].get("development_stage"), 0.0) or 0.0
def _map_dvs_to_phase(self, dvs: float) -> tuple[str, str]:
if dvs >= 2.0:
return "ready", "maturity"
if dvs >= 1.7:
return "final_pre_harvest", "late_reproductive"
if dvs >= 1.2:
return "mid_pre_harvest", "grain_fill"
if dvs >= 0.8:
return "monitoring", "reproductive_transition"
return "early_pre_harvest", "vegetative"
def _build_operations_steps(
self,
*,
phase_name: str,
days_until: int,
soil_moisture: float | None,
) -> list[dict[str, Any]]:
field_ready = soil_moisture is None or soil_moisture <= 35.0
if phase_name == "maturity":
return [
{
"key": "desiccation",
"title": "Desiccation check",
"status": "ready",
"is_completed": False,
"estimated_days": 0,
},
{
"key": "harvesting",
"title": "Harvesting",
"status": "ready" if field_ready else "watch_field_conditions",
"is_completed": False,
"estimated_days": max(min(days_until, 2), 0),
},
{
"key": "transportation",
"title": "Transportation",
"status": "ready",
"is_completed": False,
"estimated_days": max(min(days_until + 1, 3), 1),
},
]
if phase_name == "late_reproductive":
return [
{
"key": "equipment_check",
"title": "Inspect harvest equipment",
"status": "priority",
"is_completed": False,
"estimated_days": 1,
},
{
"key": "labor_plan",
"title": "Confirm labor and transport plan",
"status": "priority",
"is_completed": False,
"estimated_days": 2,
},
{
"key": "field_entry",
"title": "Verify field access and dry windows",
"status": "ready" if field_ready else "monitor",
"is_completed": False,
"estimated_days": max(min(days_until, 5), 1),
},
]
if phase_name == "grain_fill":
return [
{
"key": "monitor_maturity",
"title": "Track maturity and storage organ growth",
"status": "active",
"is_completed": False,
"estimated_days": 7,
},
{
"key": "review_readiness",
"title": "Review zone readiness differences",
"status": "active",
"is_completed": False,
"estimated_days": 10,
},
{
"key": "prepare_logistics",
"title": "Prepare harvest logistics plan",
"status": "upcoming",
"is_completed": False,
"estimated_days": 14,
},
]
return [
{
"key": "weekly_monitoring",
"title": "Run weekly crop maturity checks",
"status": "active",
"is_completed": False,
"estimated_days": 14,
},
{
"key": "update_forecast",
"title": "Refresh harvest timing forecast",
"status": "active",
"is_completed": False,
"estimated_days": 10,
},
{
"key": "draft_operations",
"title": "Draft harvest operation checklist",
"status": "upcoming",
"is_completed": False,
"estimated_days": 21,
},
]
def _infer_soil_type(self, soil_metrics: dict[str, Any]) -> str | None:
sand = self._safe_float(soil_metrics.get("sand"), None)
clay = self._safe_float(soil_metrics.get("clay"), None)
silt = self._safe_float(soil_metrics.get("silt"), None)
if sand is None or clay is None or silt is None:
return None
if clay >= 40:
return "clay"
if sand >= 70 and clay <= 15:
return "sandy"
if silt >= 50 and clay < 27:
return "silty_loam"
return "loam"
def _safe_float(self, value: Any, default: float | None = 0.0) -> float | None:
try:
if value in (None, ""):
return default
return float(value)
except (TypeError, ValueError):
return default
def _merge_narrative(
self,
final_payload: dict[str, Any],
narratives: dict[str, Any],
) -> dict[str, Any]:
merged = copy.deepcopy(final_payload)
if not isinstance(narratives, dict):
narratives = {}
season_card = merged.setdefault("season_highlights_card", {})
fallback_subtitle = self._default_season_highlights_subtitle(merged)
season_card["subtitle"] = self._coalesce_text(
narratives.get("season_highlights_subtitle"),
season_card.get("subtitle"),
fallback_subtitle,
)
yield_card = merged.setdefault("yield_prediction", {})
fallback_yield_explanation = self._default_yield_prediction_explanation(merged)
yield_card["explanation"] = self._coalesce_text(
narratives.get("yield_prediction_explanation"),
yield_card.get("explanation"),
fallback_yield_explanation,
)
readiness_card = merged.setdefault("harvest_readiness_zones", {})
fallback_readiness_summary = self._default_harvest_readiness_summary(merged)
readiness_card["summary"] = self._coalesce_text(
narratives.get("harvest_readiness_summary"),
readiness_card.get("summary"),
fallback_readiness_summary,
)
operations_card = merged.setdefault("harvest_operations_card", {})
deterministic_steps = operations_card.get("steps")
operation_notes = narratives.get("operation_notes")
if isinstance(deterministic_steps, list):
note_items = operation_notes if isinstance(operation_notes, list) else []
for index, step in enumerate(deterministic_steps):
if not isinstance(step, dict):
continue
fallback_note = self._default_operation_note(step)
candidate_note = note_items[index] if index < len(note_items) else None
step["note"] = self._coalesce_text(
candidate_note,
step.get("note"),
fallback_note,
)
return merged
def _coalesce_text(self, *values: Any) -> str:
for value in values:
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _default_season_highlights_subtitle(self, payload: dict[str, Any]) -> str:
highlights = payload.get("season_highlights_card") or {}
total_yield = highlights.get("total_predicted_yield")
unit = highlights.get("yield_unit") or ""
harvest_date = highlights.get("target_harvest_date") or "the predicted harvest window"
if total_yield is None:
return f"Harvest is targeted for {harvest_date} based on the deterministic season outlook."
return f"Predicted yield is {total_yield} {unit} and harvest is targeted for {harvest_date}.".strip()
def _default_yield_prediction_explanation(self, payload: dict[str, Any]) -> str:
yield_card = payload.get("yield_prediction") or {}
predicted = yield_card.get("predicted_yield_tons")
unit = yield_card.get("unit") or ""
if predicted is None:
return "Yield forecast is based on the deterministic crop simulation output."
return f"Yield forecast is based on the deterministic crop simulation and currently projects {predicted} {unit}.".strip()
def _default_harvest_readiness_summary(self, payload: dict[str, Any]) -> str:
readiness = payload.get("harvest_readiness_zones") or {}
average = readiness.get("averageReadiness")
if average is None:
return "Harvest readiness is derived from the latest deterministic zone signals."
return f"Average harvest readiness is {average} based on the latest deterministic zone signals.".strip()
def _default_operation_note(self, step: dict[str, Any]) -> str:
title = step.get("title") or "This operation"
status = step.get("status") or "planned"
estimate = step.get("estimated_days")
if estimate is None:
return f"{title} is currently marked as {status}."
return f"{title} is {status} with an estimated timing of {estimate} days.".strip()
def _resolve_service(self, *, getter_names: tuple[str, ...]) -> Any:
app_config = apps.get_app_config("crop_simulation")
for getter_name in getter_names:
getter = getattr(app_config, getter_name, None)
if callable(getter):
return getter()
raise AttributeError(
f"None of the expected service getters were found on crop_simulation app config: {getter_names}"
)
def _resolve_service_method(
self,
service: Any,
*,
method_names: tuple[str, ...],
) -> Callable[..., dict[str, Any]]:
for method_name in method_names:
method = getattr(service, method_name, None)
if callable(method):
return method
raise AttributeError(
f"None of the expected service methods were found on {service.__class__.__name__}: {method_names}"
)
File diff suppressed because it is too large Load Diff
+2
View File
@@ -7,6 +7,7 @@ from .fertilization import get_fertilization_recommendation
from .pest_disease import get_pest_disease_detection, get_pest_disease_risk from .pest_disease import get_pest_disease_detection, get_pest_disease_risk
from .soil_anomaly import get_soil_anomaly_insight from .soil_anomaly import get_soil_anomaly_insight
from .water_need_prediction import get_water_need_prediction_insight from .water_need_prediction import get_water_need_prediction_insight
from .yield_harvest import YieldHarvestRAGService
__all__ = [ __all__ = [
"get_irrigation_recommendation", "get_irrigation_recommendation",
@@ -15,4 +16,5 @@ __all__ = [
"get_pest_disease_risk", "get_pest_disease_risk",
"get_soil_anomaly_insight", "get_soil_anomaly_insight",
"get_water_need_prediction_insight", "get_water_need_prediction_insight",
"YieldHarvestRAGService",
] ]
+227
View File
@@ -0,0 +1,227 @@
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
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 {
"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))
return {}
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))
return {}
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 ValueError("Yield harvest narrative response was empty.")
try:
parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError) as exc:
raise ValueError("Yield harvest narrative response was not valid JSON.") from exc
if not isinstance(parsed, dict):
raise ValueError("Yield harvest narrative response root must be a JSON object.")
return parsed