diff --git a/config/knowledge_base/fertilization/fertilization_knowledge.txt b/config/knowledge_base/fertilization/fertilization_knowledge.txt index 33b0221..94ea5af 100644 --- a/config/knowledge_base/fertilization/fertilization_knowledge.txt +++ b/config/knowledge_base/fertilization/fertilization_knowledge.txt @@ -77,4 +77,66 @@ pH 6.8 6.8 بهترین جذب مواد مغذی را دارد. -فاصله کوددهی کلسیم و فسفر: کودهای حاوی کلسیم را هرگز با کودهای حاوی فسفر یا سولفات همزمان مخلوط نکنید (رسوب می‌کنند). \ No newline at end of file +فاصله کوددهی کلسیم و فسفر: کودهای حاوی کلسیم را هرگز با کودهای حاوی فسفر یا سولفات همزمان مخلوط نکنید (رسوب می‌کنند). + +راهنمای کوددهی هویج +قاعده کلی برای هویج: هویج به نیتروژن ( +𝑁 +N +) کم تا متوسط، اما به فسفر ( +𝑃 +P +) و به ویژه پتاسیم ( +𝐾 +K +) بسیار بالایی نیاز دارد. +مراحل کوددهی: +آماده‌سازی خاک (قبل از کاشت): استفاده از کودهای پایه فسفر و پتاسیم. هشدار مهم: به هیچ وجه از کود دامی تازه استفاده نکنید! کود دامی باید کاملاً پوسیده باشد. کود حیوانی تازه باعث دو یا چند شاخه شدن هویج و ایجاد ریشه‌های مویی زائد می‌شود. +رشد رویشی (اوایل رشد): استفاده محدود از نیتروژن برای رشد برگ‌ها. نیتروژن بیش از حد باعث می‌شود گیاه تمام انرژی خود را صرف تولید برگ کند و ریشه (بخش خوراکی) نازک و کوچک بماند. +رشد و حجم گرفتن ریشه (اواسط تا اواخر رشد): استفاده از کودهای پتاس‌بالا (مانند سولوپتاس یا کودهای +12 +− +12 +− +36 +12−12−36 +) برای افزایش سایز، بهبود رنگ، طعم شیرین‌تر و تردی هویج. +عناصر ریزمغذی کلیدی: +بُر ( +𝐵 +B +): یکی از مهم‌ترین عناصر برای هویج است. کمبود بُر باعث ایجاد شکاف در ریشه، سیاه شدن مغز هویج و کاهش بازارپسندی می‌شود. +کلسیم ( +𝐶 +𝑎 +Ca +): برای استحکام بافت ریشه و جلوگیری از بیماری‌ها در انبار مهم است. +خلاصه نکات طلایی و مشکلات رایج +دو یا چند شاخه شدن هویج (Forking): ناشی از استفاده از کود دامی تازه، وجود سنگ و کلوخ در خاک، یا خاک‌های بسیار سفت و رسی است. خاک هویج باید تا عمق حداقل +25 +25 + سانتی‌متری پوک و سبک باشد. +ترک‌خوردگی ریشه: ناشی از نوسانات آبیاری (خاک خشک شود و ناگهان غرقاب گردد) یا دریافت بیش از حد نیتروژن در اواخر رشد. +ریشه‌های مویی فراوان روی هویج: ناشی از مصرف بیش از حد کودهای نیتروژنه ( +𝑁 +N +) یا رطوبت دائمی و بیش از حد خاک است. +تنظیم +𝑝 +𝐻 +pH + خاک: هویج در خاک‌هایی با +𝑝 +𝐻 +pH + بین +6.0 +6.0 + تا +6.8 +6.8 + بهترین رشد را دارد. در +𝑝 +𝐻 +pH + پایین‌تر (خاک اسیدی)، رشد ریشه متوقف می‌شود. \ No newline at end of file diff --git a/config/knowledge_base/irrigation/irrigation_knowledge.txt b/config/knowledge_base/irrigation/irrigation_knowledge.txt index 8b25390..985d665 100644 --- a/config/knowledge_base/irrigation/irrigation_knowledge.txt +++ b/config/knowledge_base/irrigation/irrigation_knowledge.txt @@ -12,4 +12,15 @@ روش آبیاری: بهترین روش، آبیاری قطره‌ای است. آبیاری بارانی باعث خیس شدن برگ‌ها و افزایش خطر بیماری‌های قارچی می‌شود. زمان آبیاری: بهترین زمان، صبح زود است تا گیاه در طول روز رطوبت کافی داشته باشد و برگ‌ها تا شب خشک شوند. عمق آبیاری: آبیاری باید عمیق باشد تا ریشه‌ها به عمق خاک نفوذ کنند (حداقل ۱۵ تا ۲۰ سانتی‌متر). -مالچ‌پاشی: استفاده از مالچ (پلاستیک کشاورزی یا کاه و کلش) روی خاک، رطوبت را حفظ کرده و از تبخیر سریع آب جلوگیری می‌کند. \ No newline at end of file +مالچ‌پاشی: استفاده از مالچ (پلاستیک کشاورزی یا کاه و کلش) روی خاک، رطوبت را حفظ کرده و از تبخیر سریع آب جلوگیری می‌کند. + +راهنمای آبیاری هویج +اهمیت رطوبت در هویج: هویج یک گیاه ریشه‌ای است و کیفیت ریشه آن ارتباط مستقیمی با نحوه آبیاری دارد. نوسانات رطوبتی باعث افت شدید کیفیت محصول می‌شود. +نیاز آبی در مراحل مختلف رشد: +کاشت و جوانه‌زنی: بذر هویج بسیار ریز است و در عمق کم کاشته می‌شود. در این مرحله خاک باید دائماً مرطوب (اما نه غرقاب) باشد تا بذرها خشک نشوند. خشکی در این مرحله باعث عدم سبز شدن بذرها می‌شود. +رشد اولیه و توسعه ریشه: پس از سبز شدن، آبیاری باید عمیق‌تر و با فواصل بیشتر انجام شود تا ریشه گیاه برای پیدا کردن آب به عمق خاک نفوذ کند. آبیاری سطحی باعث کوتاه ماندن هویج می‌شود. +حجم گرفتن ریشه (غده‌بندی): نیاز آبی در این مرحله بالاست. رطوبت باید یکنواخت باشد. +نزدیک به برداشت: کاهش آبیاری در اواخر دوره رشد ضروری است. آبیاری زیاد در این مرحله باعث ترک‌خوردگی هویج‌ها می‌شود. +روش‌های آبیاری: +بهترین روش: آبیاری قطره‌ای (نوار تیپ) زیرا رطوبت را به صورت یکنواخت در اختیار ریشه قرار می‌دهد و از بیماری‌های برگی جلوگیری می‌کند. +تنش آبی: خشک و خیس شدن پیاپی خاک، عامل اصلی دو شاخه شدن و ترک خوردن هویج است. diff --git a/farm_data/migrations/0011_sensordata_irrigation_method.py b/farm_data/migrations/0011_sensordata_irrigation_method.py new file mode 100644 index 0000000..e6ddd0f --- /dev/null +++ b/farm_data/migrations/0011_sensordata_irrigation_method.py @@ -0,0 +1,26 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("irrigation", "0001_initial"), + ("sensor_data", "0010_rename_tables_to_farm_data"), + ] + + operations = [ + migrations.AddField( + model_name="sensordata", + name="irrigation_method", + field=models.ForeignKey( + blank=True, + db_column="irrigation_method_id", + help_text="روش آبیاری انتخاب‌شده برای این farm", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="farm_data", + to="irrigation.irrigationmethod", + ), + ), + ] diff --git a/farm_data/models.py b/farm_data/models.py index f2b4dff..8680cd8 100644 --- a/farm_data/models.py +++ b/farm_data/models.py @@ -115,6 +115,15 @@ class SensorData(SensorPayloadMixin, models.Model): related_name="farm_data", help_text="گیاهان مرتبط با این farm", ) + irrigation_method = models.ForeignKey( + "irrigation.IrrigationMethod", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="farm_data", + db_column="irrigation_method_id", + help_text="روش آبیاری انتخاب‌شده برای این farm", + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/farm_data/serializers.py b/farm_data/serializers.py index 5b913f3..1a58dc3 100644 --- a/farm_data/serializers.py +++ b/farm_data/serializers.py @@ -1,6 +1,8 @@ from rest_framework import serializers from location_data.serializers import SoilDepthDataSerializer +from irrigation.models import IrrigationMethod +from irrigation.serializers import IrrigationMethodSerializer from plant.serializers import PlantSerializer from weather.models import WeatherForecast @@ -19,6 +21,11 @@ class SensorDataUpdateSerializer(serializers.Serializer): required=False, help_text="لیست شناسه گیاهان مرتبط", ) + irrigation_method_id = serializers.IntegerField( + required=False, + allow_null=True, + help_text="شناسه روش آبیاری مرتبط", + ) def to_internal_value(self, data): if not isinstance(data, dict): @@ -31,6 +38,7 @@ class SensorDataUpdateSerializer(serializers.Serializer): "sensor_key", "sensor_payload", "plant_ids", + "irrigation_method_id", } flat_metrics = { key: value @@ -71,10 +79,21 @@ class SensorDataUpdateSerializer(serializers.Serializer): ) return value + def validate_irrigation_method_id(self, value): + if value is None: + return value + if not IrrigationMethod.objects.filter(pk=value).exists(): + raise serializers.ValidationError("روش آبیاری معتبر نیست.") + return value + def validate(self, attrs): - if "sensor_payload" not in attrs and "plant_ids" not in attrs: + if ( + "sensor_payload" not in attrs + and "plant_ids" not in attrs + and "irrigation_method_id" not in attrs + ): raise serializers.ValidationError( - "حداقل یکی از sensor_payload یا plant_ids باید ارسال شود." + "حداقل یکی از sensor_payload یا plant_ids یا irrigation_method_id باید ارسال شود." ) return attrs @@ -87,6 +106,11 @@ class SensorDataResponseSerializer(serializers.ModelSerializer): many=True, read_only=True, ) + irrigation_method_id = serializers.IntegerField( + source="irrigation_method.id", + read_only=True, + allow_null=True, + ) class Meta: model = SensorData @@ -96,6 +120,7 @@ class SensorDataResponseSerializer(serializers.ModelSerializer): "weather_forecast_id", "sensor_payload", "plant_ids", + "irrigation_method_id", "created_at", "updated_at", ] @@ -148,12 +173,13 @@ class FarmSoilPayloadSerializer(serializers.Serializer): class FarmDetailSerializer(serializers.Serializer): - farm_uuid = serializers.UUIDField() center_location = FarmCenterLocationSerializer() weather = WeatherForecastDetailSerializer(allow_null=True) sensor_payload = serializers.JSONField() soil = FarmSoilPayloadSerializer() plant_ids = serializers.ListField(child=serializers.IntegerField()) plants = PlantSerializer(many=True) + irrigation_method_id = serializers.IntegerField(allow_null=True) + irrigation_method = IrrigationMethodSerializer(allow_null=True) created_at = serializers.DateTimeField() updated_at = serializers.DateTimeField() diff --git a/farm_data/services.py b/farm_data/services.py index 7c4e99e..b01c06f 100644 --- a/farm_data/services.py +++ b/farm_data/services.py @@ -7,6 +7,7 @@ from django.db import transaction from location_data.models import SoilLocation from location_data.serializers import SoilDepthDataSerializer from location_data.tasks import fetch_soil_data_for_coordinates +from irrigation.serializers import IrrigationMethodSerializer from plant.serializers import PlantSerializer from weather.services import update_weather_for_location from weather.models import WeatherForecast @@ -25,7 +26,11 @@ class ExternalDataSyncError(Exception): def get_farm_details(farm_uuid: str): farm = ( - SensorData.objects.select_related("center_location", "weather_forecast") + SensorData.objects.select_related( + "center_location", + "weather_forecast", + "irrigation_method", + ) .prefetch_related("plants", "center_location__depths") .filter(farm_uuid=farm_uuid) .first() @@ -53,7 +58,6 @@ def get_farm_details(farm_uuid: str): metric_sources[key] = "sensor" return { - "farm_uuid": farm.farm_uuid, "center_location": { "id": center_location.id, "lat": center_location.latitude, @@ -69,6 +73,12 @@ def get_farm_details(farm_uuid: str): }, "plant_ids": list(farm.plants.values_list("id", flat=True)), "plants": PlantSerializer(farm.plants.all(), many=True).data, + "irrigation_method_id": farm.irrigation_method_id, + "irrigation_method": ( + IrrigationMethodSerializer(farm.irrigation_method).data + if farm.irrigation_method + else None + ), "created_at": farm.created_at, "updated_at": farm.updated_at, } diff --git a/farm_data/tests/test_farm_detail_api.py b/farm_data/tests/test_farm_detail_api.py index e07510b..98c5756 100644 --- a/farm_data/tests/test_farm_detail_api.py +++ b/farm_data/tests/test_farm_detail_api.py @@ -7,6 +7,7 @@ from rest_framework.test import APIClient from location_data.models import SoilDepthData, SoilLocation from farm_data.models import SensorData +from irrigation.models import IrrigationMethod from plant.models import Plant from weather.models import WeatherForecast @@ -58,11 +59,13 @@ class FarmDetailApiTests(TestCase): ) self.plant1 = Plant.objects.create(name="گوجه‌فرنگی") self.plant2 = Plant.objects.create(name="خیار") + self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") self.farm_uuid = uuid.uuid4() self.farm = SensorData.objects.create( farm_uuid=self.farm_uuid, center_location=self.location, weather_forecast=self.weather, + irrigation_method=self.irrigation_method, sensor_payload={ "sensor-7-1": { "soil_moisture": 33.5, @@ -78,7 +81,7 @@ class FarmDetailApiTests(TestCase): self.assertEqual(response.status_code, 200) payload = response.json()["data"] - self.assertEqual(payload["farm_uuid"], str(self.farm_uuid)) + self.assertNotIn("farm_uuid", payload) self.assertEqual(payload["center_location"]["id"], self.location.id) self.assertEqual(payload["weather"]["id"], self.weather.id) self.assertEqual( @@ -100,6 +103,8 @@ class FarmDetailApiTests(TestCase): self.assertEqual(returned_plants[self.plant1.id]["name"], self.plant1.name) self.assertEqual(returned_plants[self.plant2.id]["name"], self.plant2.name) self.assertIn("light", returned_plants[self.plant1.id]) + self.assertEqual(payload["irrigation_method_id"], self.irrigation_method.id) + self.assertEqual(payload["irrigation_method"]["name"], self.irrigation_method.name) def test_returns_404_when_farm_is_missing(self): response = self.client.get(f"/api/farm-data/{uuid.uuid4()}/detail/") @@ -123,6 +128,7 @@ class FarmDataUpsertApiTests(TestCase): temperature_max=24.0, temperature_mean=17.5, ) + self.irrigation_method = IrrigationMethod.objects.create(name="بارانی") def test_post_creates_farm_data_with_explicit_farm_uuid(self): farm_uuid = uuid.uuid4() @@ -138,6 +144,7 @@ class FarmDataUpsertApiTests(TestCase): "nitrogen": 18.0, } }, + "irrigation_method_id": self.irrigation_method.id, }, format="json", ) @@ -150,6 +157,7 @@ class FarmDataUpsertApiTests(TestCase): farm = SensorData.objects.get(farm_uuid=farm_uuid) self.assertEqual(farm.center_location_id, self.location.id) self.assertEqual(farm.weather_forecast_id, self.weather.id) + self.assertEqual(farm.irrigation_method_id, self.irrigation_method.id) self.assertEqual( farm.sensor_payload["sensor-7-1"]["soil_moisture"], 31.2, diff --git a/farm_data/views.py b/farm_data/views.py index 8d66b56..5f6d0c3 100644 --- a/farm_data/views.py +++ b/farm_data/views.py @@ -168,6 +168,7 @@ class FarmDataUpsertView(APIView): farm_uuid = serializer.validated_data["farm_uuid"] farm_boundary = serializer.validated_data["farm_boundary"] plant_ids = serializer.validated_data.get("plant_ids") + irrigation_method_id = serializer.validated_data.get("irrigation_method_id") sensor_payload = serializer.validated_data.get("sensor_payload", {}) try: center_location = resolve_center_location_from_boundary(farm_boundary) @@ -210,12 +211,15 @@ class FarmDataUpsertView(APIView): farm_data.center_location = center_location farm_data.weather_forecast = weather_forecast + if "irrigation_method_id" in serializer.validated_data: + farm_data.irrigation_method_id = irrigation_method_id if not created: farm_data.save( update_fields=[ "center_location", "weather_forecast", "sensor_payload", + "irrigation_method", "updated_at", ] ) diff --git a/fertilization/urls.py b/fertilization/urls.py index b882f16..0b2d0d1 100644 --- a/fertilization/urls.py +++ b/fertilization/urls.py @@ -1,8 +1,7 @@ from django.urls import path -from .views import FertilizationRecommendView, FertilizationRecommendStatusView +from .views import FertilizationRecommendView urlpatterns = [ path("recommend/", FertilizationRecommendView.as_view(), name="fertilization-recommend"), - path("recommend//status/", FertilizationRecommendStatusView.as_view(), name="fertilization-recommend-status"), ] diff --git a/fertilization/views.py b/fertilization/views.py index 4654df1..b538281 100644 --- a/fertilization/views.py +++ b/fertilization/views.py @@ -10,31 +10,25 @@ from rest_framework.views import APIView from config.openapi import ( build_envelope_serializer, build_response, - build_task_queue_data_serializer, - build_task_status_data_serializer, ) from .serializers import FertilizationRecommendRequestSerializer -FertilizationQueueResponseSerializer = build_envelope_serializer( - "FertilizationQueueResponseSerializer", - build_task_queue_data_serializer("FertilizationQueueDataSerializer"), -) FertilizationValidationErrorSerializer = build_envelope_serializer( "FertilizationValidationErrorSerializer", data_required=False, allow_null=True, ) -FertilizationStatusResponseSerializer = build_envelope_serializer( - "FertilizationStatusResponseSerializer", - build_task_status_data_serializer("FertilizationStatusDataSerializer"), +FertilizationResponseSerializer = build_envelope_serializer( + "FertilizationResponseSerializer", + data_schema=None, ) class FertilizationRecommendView(APIView): """ - توصیه کودهی با Celery. + توصیه کودهی به صورت مستقیم. POST با sensor_uuid، plant_name، growth_stage. اطلاعات گیاه از plant app دریافت می‌شود. نیازی به دریافت نوع آبیاری نیست. @@ -44,21 +38,25 @@ class FertilizationRecommendView(APIView): tags=["Fertilization Recommendation"], summary="درخواست توصیه کودهی", description=( - "داده‌های سنسور و گیاه را دریافت کرده و یک تسک Celery " - "برای تولید توصیه کودهی در صف قرار می‌دهد. " + "داده‌های سنسور و گیاه را دریافت کرده و " + "توصیه کودهی را مستقیم برمی‌گرداند. " "اطلاعات گیاه از جدول Plant بارگذاری می‌شود. " "محاسبات مربوط به نیاز آبی در این endpoint انجام نمی‌شود و مستقل از توصیه کودهی است." ), request=FertilizationRecommendRequestSerializer, responses={ - 202: build_response( - FertilizationQueueResponseSerializer, - "تسک توصیه کودهی در صف قرار گرفت.", + 200: build_response( + FertilizationResponseSerializer, + "توصیه کودهی با موفقیت تولید شد.", ), 400: build_response( FertilizationValidationErrorSerializer, "پارامتر ورودی نامعتبر است.", ), + 500: build_response( + FertilizationValidationErrorSerializer, + "خطا در تولید توصیه کودهی.", + ), }, examples=[ OpenApiExample( @@ -73,7 +71,7 @@ class FertilizationRecommendView(APIView): ], ) def post(self, request): - from rag.tasks import fertilization_recommendation_task + from rag.services.fertilization import get_fertilization_recommendation serializer = FertilizationRecommendRequestSerializer(data=request.data) if not serializer.is_valid(): @@ -88,53 +86,20 @@ class FertilizationRecommendView(APIView): growth_stage = validated.get("growth_stage") query = validated.get("query") - task = fertilization_recommendation_task.delay( - sensor_uuid=sensor_uuid, - plant_name=plant_name, - growth_stage=growth_stage, - query=query, - ) + try: + result = get_fertilization_recommendation( + sensor_uuid=sensor_uuid, + plant_name=plant_name, + growth_stage=growth_stage, + query=query, + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در تولید توصیه کودهی: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + return Response( - { - "code": 202, - "msg": "تسک توصیه کودهی در صف قرار گرفت.", - "data": { - "task_id": task.id, - "status_url": f"/api/fertilization/recommend/{task.id}/status/", - }, - }, - status=status.HTTP_202_ACCEPTED, - ) - - -class FertilizationRecommendStatusView(APIView): - """وضعیت تسک توصیه کودهی.""" - - @extend_schema( - tags=["Fertilization Recommendation"], - summary="وضعیت تسک توصیه کودهی", - description="وضعیت تسک Celery توصیه کودهی را برمی‌گرداند.", - responses={ - 200: build_response( - FertilizationStatusResponseSerializer, - "وضعیت فعلی تسک توصیه کودهی.", - ), - }, - ) - def get(self, request, task_id): - from celery.result import AsyncResult - - result = AsyncResult(task_id) - data = {"task_id": task_id, "status": result.state} - if result.state == "PENDING": - data["message"] = "تسک در صف یا یافت نشد." - elif result.state == "PROGRESS": - data["progress"] = result.info - elif result.state == "SUCCESS": - data["result"] = result.result - elif result.state == "FAILURE": - data["error"] = str(result.result) - return Response( - {"code": 200, "msg": "success", "data": data}, + {"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK, ) diff --git a/irrigation/urls.py b/irrigation/urls.py index a013015..0b63a7e 100644 --- a/irrigation/urls.py +++ b/irrigation/urls.py @@ -4,12 +4,10 @@ from .views import ( IrrigationMethodDetailView, IrrigationMethodListCreateView, IrrigationRecommendView, - IrrigationRecommendStatusView, ) urlpatterns = [ path("", IrrigationMethodListCreateView.as_view(), name="irrigation-list-create"), path("/", IrrigationMethodDetailView.as_view(), name="irrigation-detail"), path("recommend/", IrrigationRecommendView.as_view(), name="irrigation-recommend"), - path("recommend//status/", IrrigationRecommendStatusView.as_view(), name="irrigation-recommend-status"), ] diff --git a/irrigation/views.py b/irrigation/views.py index 126ef27..822025d 100644 --- a/irrigation/views.py +++ b/irrigation/views.py @@ -6,8 +6,6 @@ from rest_framework.views import APIView from config.openapi import ( build_envelope_serializer, build_response, - build_task_queue_data_serializer, - build_task_status_data_serializer, ) from .models import IrrigationMethod @@ -31,13 +29,9 @@ IrrigationValidationErrorSerializer = build_envelope_serializer( data_required=False, allow_null=True, ) -IrrigationQueueResponseSerializer = build_envelope_serializer( - "IrrigationQueueResponseSerializer", - build_task_queue_data_serializer("IrrigationQueueDataSerializer"), -) -IrrigationStatusResponseSerializer = build_envelope_serializer( - "IrrigationStatusResponseSerializer", - build_task_status_data_serializer("IrrigationStatusDataSerializer"), +IrrigationRecommendResponseSerializer = build_envelope_serializer( + "IrrigationRecommendResponseSerializer", + data_schema=None, ) @@ -112,7 +106,7 @@ class IrrigationMethodListCreateView(APIView): class IrrigationRecommendView(APIView): """ - توصیه آبیاری با Celery. + توصیه آبیاری به صورت مستقیم. POST با sensor_uuid، plant_name، growth_stage، irrigation_method_name. اطلاعات گیاه از plant app و روش آبیاری از irrigation app دریافت می‌شود. """ @@ -121,21 +115,25 @@ class IrrigationRecommendView(APIView): tags=["Irrigation Recommendation"], summary="درخواست توصیه آبیاری", description=( - "داده‌های سنسور، گیاه و روش آبیاری را دریافت کرده و یک تسک Celery " - "برای تولید توصیه آبیاری در صف قرار می‌دهد. " + "داده‌های سنسور، گیاه و روش آبیاری را دریافت کرده و " + "توصیه آبیاری را مستقیم برمی‌گرداند. " "اطلاعات گیاه از جدول Plant و روش آبیاری از جدول IrrigationMethod بارگذاری می‌شود. " "محاسبات ET₀ و ETc با مدل FAO-56 در بک‌اند انجام می‌شود و مدل زبانی فقط توضیح برنامه آبیاری را تولید می‌کند." ), request=IrrigationRecommendRequestSerializer, responses={ - 202: build_response( - IrrigationQueueResponseSerializer, - "تسک توصیه آبیاری در صف قرار گرفت.", + 200: build_response( + IrrigationRecommendResponseSerializer, + "توصیه آبیاری با موفقیت تولید شد.", ), 400: build_response( IrrigationValidationErrorSerializer, "پارامتر ورودی نامعتبر است.", ), + 500: build_response( + IrrigationValidationErrorSerializer, + "خطا در تولید توصیه آبیاری.", + ), }, examples=[ OpenApiExample( @@ -151,7 +149,7 @@ class IrrigationRecommendView(APIView): ], ) def post(self, request): - from rag.tasks import irrigation_recommendation_task + from rag.services.irrigation import get_irrigation_recommendation serializer = IrrigationRecommendRequestSerializer(data=request.data) if not serializer.is_valid(): @@ -167,55 +165,22 @@ class IrrigationRecommendView(APIView): irrigation_method_name = validated.get("irrigation_method_name") query = validated.get("query") - task = irrigation_recommendation_task.delay( - sensor_uuid=sensor_uuid, - plant_name=plant_name, - growth_stage=growth_stage, - irrigation_method_name=irrigation_method_name, - query=query, - ) + try: + result = get_irrigation_recommendation( + sensor_uuid=sensor_uuid, + plant_name=plant_name, + growth_stage=growth_stage, + irrigation_method_name=irrigation_method_name, + query=query, + ) + except Exception as exc: + return Response( + {"code": 500, "msg": f"خطا در تولید توصیه آبیاری: {exc}", "data": None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + return Response( - { - "code": 202, - "msg": "تسک توصیه آبیاری در صف قرار گرفت.", - "data": { - "task_id": task.id, - "status_url": f"/api/irrigation/recommend/{task.id}/status/", - }, - }, - status=status.HTTP_202_ACCEPTED, - ) - - -class IrrigationRecommendStatusView(APIView): - """وضعیت تسک توصیه آبیاری.""" - - @extend_schema( - tags=["Irrigation Recommendation"], - summary="وضعیت تسک توصیه آبیاری", - description="وضعیت تسک Celery توصیه آبیاری را برمی‌گرداند.", - responses={ - 200: build_response( - IrrigationStatusResponseSerializer, - "وضعیت فعلی تسک توصیه آبیاری.", - ), - }, - ) - def get(self, request, task_id): - from celery.result import AsyncResult - - result = AsyncResult(task_id) - data = {"task_id": task_id, "status": result.state} - if result.state == "PENDING": - data["message"] = "تسک در صف یا یافت نشد." - elif result.state == "PROGRESS": - data["progress"] = result.info - elif result.state == "SUCCESS": - data["result"] = result.result - elif result.state == "FAILURE": - data["error"] = str(result.result) - return Response( - {"code": 200, "msg": "success", "data": data}, + {"code": 200, "msg": "success", "data": result}, status=status.HTTP_200_OK, ) diff --git a/plant/migrations/0005_plant_growth_stage.py b/plant/migrations/0005_plant_growth_stage.py new file mode 100644 index 0000000..fa88b23 --- /dev/null +++ b/plant/migrations/0005_plant_growth_stage.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("plant", "0004_plant_growth_profile"), + ] + + operations = [ + migrations.AddField( + model_name="plant", + name="growth_stage", + field=models.CharField( + blank=True, + help_text="مرحله رشد", + max_length=255, + ), + ), + ] diff --git a/plant/models.py b/plant/models.py index 1f912f4..f1fed9a 100644 --- a/plant/models.py +++ b/plant/models.py @@ -32,6 +32,11 @@ class Plant(models.Model): blank=True, help_text="دمای مناسب", ) + growth_stage = models.CharField( + max_length=255, + blank=True, + help_text="مرحله رشد", + ) planting_season = models.CharField( max_length=255, blank=True, diff --git a/plant/serializers.py b/plant/serializers.py index 7e334db..bedbfb5 100644 --- a/plant/serializers.py +++ b/plant/serializers.py @@ -15,6 +15,7 @@ class PlantSerializer(serializers.ModelSerializer): "watering", "soil", "temperature", + "growth_stage", "planting_season", "harvest_time", "spacing", diff --git a/plant/views.py b/plant/views.py index fbb73fd..cab6a73 100644 --- a/plant/views.py +++ b/plant/views.py @@ -81,6 +81,7 @@ class PlantListCreateView(APIView): "watering": "منظم، هفته‌ای ۲-۳ بار", "soil": "لومی، غنی از مواد آلی", "temperature": "۲۰-۳۰ درجه سانتی‌گراد", + "growth_stage": "رشد رویشی", "planting_season": "بهار", "harvest_time": "۷۰-۹۰ روز پس از کاشت", "spacing": "۴۵-۶۰ سانتی‌متر", diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py index 8509a43..06eb91f 100644 --- a/rag/services/fertilization.py +++ b/rag/services/fertilization.py @@ -5,6 +5,7 @@ import json import logging +from farm_data.models import SensorData from rag.api_provider import get_chat_client from rag.chat import ( _complete_audit_log, @@ -78,13 +79,24 @@ def get_fertilization_recommendation( user_query = query or "توصیه کودهی برای مزرعه من چیست؟" + sensor = ( + SensorData.objects.prefetch_related("plants") + .filter(farm_uuid=sensor_uuid) + .first() + ) + resolved_plant_name = plant_name + if not resolved_plant_name and sensor is not None: + plant = sensor.plants.first() + if plant is not None: + resolved_plant_name = plant.name + context = build_rag_context( user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME, service_id=SERVICE_ID, ) extra_parts: list[str] = [] - if plant_name and growth_stage: - plant_text = build_plant_text(plant_name, growth_stage) + 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 extra_parts: diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py index 7300667..46f6456 100644 --- a/rag/services/irrigation.py +++ b/rag/services/irrigation.py @@ -83,12 +83,20 @@ def get_irrigation_recommendation( user_query = query or "توصیه آبیاری برای مزرعه من چیست؟" - sensor = SensorData.objects.select_related("center_location").prefetch_related("plants").filter(farm_uuid=sensor_uuid).first() + sensor = ( + SensorData.objects.select_related("center_location", "irrigation_method") + .prefetch_related("plants") + .filter(farm_uuid=sensor_uuid) + .first() + ) plant = None + resolved_plant_name = plant_name if sensor is not None and plant_name: plant = sensor.plants.filter(name=plant_name).first() elif sensor is not None: plant = sensor.plants.first() + if plant is not None: + 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 = [] @@ -99,11 +107,18 @@ def get_irrigation_recommendation( .order_by("forecast_date")[:7] ) efficiency_percent = None + resolved_irrigation_method_name = irrigation_method_name + method = None if irrigation_method_name: from irrigation.models import IrrigationMethod method = IrrigationMethod.objects.filter(name=irrigation_method_name).first() - efficiency_percent = getattr(method, "water_efficiency_percent", None) if method else None + elif sensor is not None: + method = sensor.irrigation_method + if method is not None: + resolved_irrigation_method_name = method.name + + efficiency_percent = getattr(method, "water_efficiency_percent", None) if method else None daily_water_needs = calculate_forecast_water_needs( forecasts=forecasts, latitude_deg=float(sensor.center_location.latitude), @@ -117,12 +132,15 @@ def get_irrigation_recommendation( ) extra_parts: list[str] = [] - if plant_name and growth_stage: - plant_text = build_plant_text(plant_name, growth_stage) + resolved_irrigation_method_name = irrigation_method_name or ( + sensor.irrigation_method.name if sensor is not None and sensor.irrigation_method 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 irrigation_method_name: - method_text = build_irrigation_method_text(irrigation_method_name) + 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: diff --git a/rag/tests/test_chat_context.py b/rag/tests/test_chat_context.py new file mode 100644 index 0000000..f2209d0 --- /dev/null +++ b/rag/tests/test_chat_context.py @@ -0,0 +1,52 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase + +from rag.chat import build_chat_context + + +class ChatContextTests(SimpleTestCase): + @patch("rag.chat.search_with_query") + @patch("rag.chat._rank_text_chunks_by_query") + @patch("rag.chat.chunk_text") + def test_build_chat_context_combines_farm_and_kb_context( + self, + mock_chunk_text, + mock_rank_text_chunks_by_query, + mock_search_with_query, + ): + mock_chunk_text.return_value = ["chunk-a", "chunk-b"] + mock_rank_text_chunks_by_query.return_value = ["chunk-b"] + mock_search_with_query.return_value = [ + {"text": "kb text 1"}, + {"text": "kb text 2"}, + ] + + context = build_chat_context( + query="وضعیت مزرعه چطور است؟", + farm_uuid="farm-123", + farm_details={"sensor_payload": {"sensor-7-1": {"soil_moisture": 30}}}, + ) + + self.assertIn("[بخش‌های مرتبط بازیابی‌شده از اطلاعات مزرعه]", context) + self.assertIn("chunk-b", context) + self.assertIn("[اطلاعات بازیابی‌شده از پایگاه دانش]", context) + self.assertIn("kb text 1", context) + self.assertIn("kb text 2", context) + + @patch("rag.chat.search_with_query", return_value=[]) + @patch("rag.chat._rank_text_chunks_by_query", return_value=[]) + @patch("rag.chat.chunk_text", return_value=["farm chunk"]) + def test_build_chat_context_falls_back_to_full_farm_context( + self, + _mock_chunk_text, + _mock_rank_text_chunks_by_query, + _mock_search_with_query, + ): + context = build_chat_context( + query="رطوبت چقدر است؟", + farm_uuid="farm-123", + farm_details={"sensor_payload": {"sensor-7-1": {"soil_moisture": 30}}}, + ) + + self.assertEqual(context, "") diff --git a/rag/tests/test_recommendation_services.py b/rag/tests/test_recommendation_services.py new file mode 100644 index 0000000..8f8a143 --- /dev/null +++ b/rag/tests/test_recommendation_services.py @@ -0,0 +1,91 @@ +import uuid +from datetime import date +from unittest.mock import Mock, patch + +from django.test import TestCase + +from farm_data.models import SensorData +from irrigation.models import IrrigationMethod +from location_data.models import SoilLocation +from plant.models import Plant +from rag.services.fertilization import get_fertilization_recommendation +from rag.services.irrigation import get_irrigation_recommendation +from weather.models import WeatherForecast + + +class RecommendationServiceDefaultsTests(TestCase): + def setUp(self): + self.location = SoilLocation.objects.create( + latitude="35.700000", + longitude="51.400000", + farm_boundary={"type": "Polygon", "coordinates": []}, + ) + WeatherForecast.objects.create( + location=self.location, + forecast_date=date(2026, 4, 10), + temperature_min=12.0, + temperature_max=23.0, + temperature_mean=18.0, + ) + self.plant = Plant.objects.create(name="گوجه‌فرنگی") + self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") + self.farm_uuid = uuid.uuid4() + self.farm = SensorData.objects.create( + farm_uuid=self.farm_uuid, + center_location=self.location, + irrigation_method=self.irrigation_method, + sensor_payload={"sensor-7-1": {"soil_moisture": 30.0}}, + ) + self.farm.plants.set([self.plant]) + + @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) + @patch("rag.services.irrigation.resolve_kc", return_value=0.9) + @patch("rag.services.irrigation.resolve_crop_profile", return_value={}) + @patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text") + @patch("rag.services.irrigation.build_plant_text", return_value="plant text") + @patch("rag.services.irrigation.build_rag_context", return_value="") + @patch("rag.services.irrigation.get_chat_client") + def test_irrigation_recommendation_uses_farm_relations_when_request_omits_names( + self, + mock_get_chat_client, + mock_build_rag_context, + mock_build_plant_text, + mock_build_irrigation_method_text, + _mock_resolve_crop_profile, + _mock_resolve_kc, + _mock_calculate_forecast_water_needs, + ): + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content='{"plan": {"frequencyPerWeek": 2}}'))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_irrigation_recommendation( + sensor_uuid=str(self.farm_uuid), + growth_stage="میوه‌دهی", + ) + + self.assertEqual(result["plan"]["frequencyPerWeek"], 2) + mock_build_rag_context.assert_called_once() + mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "میوه‌دهی") + mock_build_irrigation_method_text.assert_called_once_with("آبیاری قطره‌ای") + + @patch("rag.services.fertilization.build_plant_text", return_value="plant text") + @patch("rag.services.fertilization.build_rag_context", return_value="") + @patch("rag.services.fertilization.get_chat_client") + def test_fertilization_recommendation_uses_farm_plant_when_request_omits_name( + self, + mock_get_chat_client, + _mock_build_rag_context, + mock_build_plant_text, + ): + mock_response = Mock() + mock_response.choices = [Mock(message=Mock(content='{"plan": {"npkRatio": "20-20-20"}}'))] + mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response + + result = get_fertilization_recommendation( + sensor_uuid=str(self.farm_uuid), + growth_stage="رویشی", + ) + + self.assertEqual(result["plan"]["npkRatio"], "20-20-20") + mock_build_plant_text.assert_called_once_with("گوجه‌فرنگی", "رویشی")