diff --git a/Dockerfile b/Dockerfile index 58f7b6b..0cf50b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,18 @@ -FROM python:3.12-slim +FROM mirror2.chabokan.net/python ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 WORKDIR /app +# Debian/Ubuntu mirrors for apt +RUN rm -f /etc/apt/sources.list.d/debian.sources && \ + printf '%s\n' \ + 'deb [trusted=yes] https://mirror2.chabokan.net/debian bookworm main contrib non-free' \ + 'deb [trusted=yes] https://mirror2.chabokan.net/debian bookworm-updates main' \ + 'deb [trusted=yes] https://mirror2.chabokan.net/debian-security bookworm-security main' \ + > /etc/apt/sources.list + # System deps for MySQL client (pkg-config required by mysqlclient to find libs) RUN apt-get update && apt-get install -y --no-install-recommends \ default-libmysqlclient-dev \ @@ -13,11 +21,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt -COPY . . +# Python mirrors +RUN pip config --user set global.index-url https://package-mirror.liara.ir/repository/pypi/simple && \ + pip config --user set global.extra-index-url https://mirror.cdn.ir/repository/pypi/simple && \ + pip config --user set global.extra-index-url https://mirror2.chabokan.net/pypi/simple && \ + pip config --user set global.trusted-host package-mirror.liara.ir && \ + pip config --user set global.trusted-host mirror.cdn.ir && \ + pip config --user set global.trusted-host mirror-pypi.runflare.com + +RUN pip install -r requirements.txt + EXPOSE 8000 + ENTRYPOINT ["sh", "/app/entrypoint.sh"] CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] diff --git a/config/knowledge_base/chat/README.md b/config/knowledge_base/chat/README.md new file mode 100644 index 0000000..5b0c001 --- /dev/null +++ b/config/knowledge_base/chat/README.md @@ -0,0 +1,3 @@ +# پایگاه دانش CropLogic + +فایل‌های `.txt` و `.md` این پوشه به‌صورت خودکار embed و به Qdrant اضافه می‌شوند. diff --git a/config/knowledge_base/chat/soil_knowledge.txt b/config/knowledge_base/chat/soil_knowledge.txt new file mode 100644 index 0000000..cf03622 --- /dev/null +++ b/config/knowledge_base/chat/soil_knowledge.txt @@ -0,0 +1,19 @@ +# دانش پایه خاک برای کشاورزی + +## انواع خاک +خاک‌ها بر اساس بافت (نسبت رس، سیلت و شن) دسته‌بندی می‌شوند. خاک رسی زهکشی ضعیف‌تری دارد و خاک شنی زهکشی سریع. خاک لومی ترکیبی متعادل از هر سه است و برای اغلب گیاهان مناسب است. + +## pH خاک +مقیاس pH از ۰ تا ۱۴ است؛ مقدار ۷ خنثی است. خاک‌های اسیدی (زیر ۷) و قلیایی (بالای ۷) بر جذب عناصر غذایی تأثیر می‌گذارند. بیشتر گیاهان زراعی pH حدود ۶ تا ۷.۵ را ترجیح می‌دهند. + +## رطوبت خاک +رطوبت خاک بر رشد ریشه و جذب آب و مواد غذایی تأثیر مستقیم دارد. رطوبت بیش از حد باعث خفگی ریشه و کمبود اکسیژن می‌شود؛ رطوبت کم باعث تنش آبی و کاهش عملکرد می‌شود. + +## NPK و عناصر غذایی +نیتروژن (N) برای رشد سبزینه و برگ‌ها ضروری است. فسفر (P) برای ریشه‌زایی و گلدهی مهم است. پتاسیم (K) مقاومت به خشکی و بیماری را افزایش می‌دهد. مقادیر این عناصر در خاک با آزمون خاک قابل اندازه‌گیری است. + +## هدایت الکتریکی (EC) +EC نشان‌دهنده شوری خاک است. EC بالا یعنی نمک زیاد و می‌تواند به ریشه گیاه آسیب برساند. واحد آن معمولاً dS/m یا mS/cm است. + +## عمق خاک +داده‌های خاک معمولاً در اعماق ۰–۵، ۵–۱۵ و ۱۵–۳۰ سانتی‌متر اندازه‌گیری می‌شوند. لایه سطحی برای جوانه‌زنی و ریشه‌های سطحی مهم است؛ لایه‌های عمیق‌تر برای گیاهان ریشه‌عمیق اهمیت دارند. diff --git a/config/knowledge_base/fertilization/fertilization_knowledge.txt b/config/knowledge_base/fertilization/fertilization_knowledge.txt new file mode 100644 index 0000000..f731123 --- /dev/null +++ b/config/knowledge_base/fertilization/fertilization_knowledge.txt @@ -0,0 +1,24 @@ +# دانش پایه کودهی + +## نیتروژن (N) +نیتروژن برای رشد سبزینه و برگ‌ها ضروری است. کمبود آن باعث زردی برگ‌ها و کاهش رشد می‌شود. +منابع نیتروژن: اوره (46% N)، نیترات آمونیوم (34% N)، سولفات آمونیوم (21% N). +مصرف بیش از حد نیتروژن باعث رشد رویشی بیش از حد و کاهش مقاومت به بیماری می‌شود. + +## فسفر (P) +فسفر برای ریشه‌زایی، گلدهی و میوه‌دهی مهم است. کمبود آن رشد ریشه را محدود می‌کند. +منابع فسفر: سوپرفسفات تریپل (46% P2O5)، DAP (18-46-0). +فسفر در خاک‌های قلیایی (pH > 7.5) به‌سختی جذب می‌شود. + +## پتاسیم (K) +پتاسیم مقاومت به خشکی، سرما و بیماری را افزایش می‌دهد. در کیفیت میوه نقش دارد. +منابع پتاسیم: سولفات پتاسیم (50% K2O)، کلرید پتاسیم (60% K2O). + +## pH و جذب عناصر +pH خاک بر جذب عناصر غذایی تأثیر مستقیم دارد. pH مناسب برای اغلب محصولات ۶ تا ۷ است. +در pH پایین (اسیدی): آهن و منگنز زیاد جذب می‌شوند ولی فسفر و کلسیم کم. +در pH بالا (قلیایی): آهن، روی و منگنز کم جذب می‌شوند. + +## EC و کودهی +EC بالا نشان‌دهنده شوری خاک است. قبل از کودهی باید EC بررسی شود. +اگر EC بالای ۴ dS/m باشد، کودهی باید با احتیاط انجام شود. diff --git a/config/knowledge_base/irrigation/irrigation_knowledge.txt b/config/knowledge_base/irrigation/irrigation_knowledge.txt new file mode 100644 index 0000000..96465ce --- /dev/null +++ b/config/knowledge_base/irrigation/irrigation_knowledge.txt @@ -0,0 +1,18 @@ +# دانش پایه آبیاری + +## تبخیر-تعرق مرجع (ET0) +ET0 نشان‌دهنده میزان آب مورد نیاز گیاه مرجع (چمن) در یک روز است. واحد آن mm/day است. +ET0 بالا یعنی هوا گرم و خشک است و گیاه آب بیشتری نیاز دارد. + +## رابطه بارش و آبیاری +اگر بارش پیش‌بینی شده از ET0 بیشتر باشد، معمولاً آبیاری لازم نیست. +بارش مؤثر حدود ۷۰-۸۰ درصد بارش واقعی است (بخشی تبخیر و رواناب می‌شود). + +## رطوبت خاک +رطوبت مناسب خاک بسته به نوع خاک و محصول متفاوت است. +خاک رسی رطوبت بیشتری نگه می‌دارد. خاک شنی سریع‌تر خشک می‌شود. +آبیاری باید وقتی انجام شود که رطوبت خاک به حد بحرانی (MAD) رسیده باشد. + +## دمای هوا و آبیاری +در دماهای بالای ۳۵ درجه، تبخیر سطحی زیاد است و آبیاری صبح زود یا عصر توصیه می‌شود. +در دماهای زیر ۵ درجه، آبیاری ممکن است به ریشه آسیب بزند. diff --git a/config/rag_config.yaml b/config/rag_config.yaml index d39fa1b..a24108f 100644 --- a/config/rag_config.yaml +++ b/config/rag_config.yaml @@ -1,11 +1,14 @@ # تنظیمات RAG برای پایگاه دانش CropLogic embedding: - provider: "avalai" # Avalai API (OpenAI-compatible) + provider: "gapgpt" # gapgpt یا avalai model: "text-embedding-3-small" - base_url: "https://api.avalai.ir/v1" - api_key_env: "AVALAI_API_KEY" + base_url: "https://api.gapgpt.app/v1" + api_key_env: "GAPGPT_API_KEY" batch_size: 32 + # تنظیمات Avalai (برای fallback) + avalai_base_url: "https://api.avalai.ir/v1" + avalai_api_key_env: "AVALAI_API_KEY" # فاز یک: Qdrant به‌عنوان vector store qdrant: @@ -21,8 +24,24 @@ chunking: # تنظیمات مدل چت (LLM) — Avalai llm: model: "gpt-4o" - base_url: "https://api.avalai.ir/v1" - api_key_env: "AVALAI_API_KEY" + 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" -tone_file: "config/tone.txt" -knowledge_base_path: "config/knowledge_base" +# سه پایگاه دانش مجزا +knowledge_bases: + chat: + path: "config/knowledge_base/chat" + tone_file: "config/tones/chat_tone.txt" + description: "پایگاه دانش عمومی برای چت با کاربران" + + irrigation: + path: "config/knowledge_base/irrigation" + tone_file: "config/tones/irrigation_tone.txt" + description: "پایگاه دانش توصیه آبیاری" + + fertilization: + path: "config/knowledge_base/fertilization" + tone_file: "config/tones/fertilization_tone.txt" + description: "پایگاه دانش توصیه کودهی" diff --git a/config/settings.py b/config/settings.py index 3437c1c..5036317 100644 --- a/config/settings.py +++ b/config/settings.py @@ -6,6 +6,8 @@ from dotenv import load_dotenv load_dotenv() BASE_DIR = Path(__file__).resolve().parent.parent +LOG_DIR = Path(os.environ.get("LOG_DIR", BASE_DIR / "logs")) +LOG_DIR.mkdir(parents=True, exist_ok=True) SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only") DEBUG = os.environ.get("DEBUG", "0") == "1" @@ -20,10 +22,15 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "rest_framework", "corsheaders", + "drf_spectacular", + "drf_spectacular_sidecar", "rag", "tasks", - "soil_data", + "location_data", "sensor_data", + "weather", + "plant", + "irrigation", ] MIDDLEWARE = [ @@ -98,6 +105,25 @@ REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.AllowAny", ], + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "CropLogic AI API", + "DESCRIPTION": "API‌های هوش مصنوعی CropLogic — داده خاک، سنسور، هواشناسی و چت RAG", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SWAGGER_UI_DIST": "SIDECAR", + "SWAGGER_UI_FAVICON_HREF": "SIDECAR", + "REDOC_DIST": "SIDECAR", + "COMPONENT_SPLIT_REQUEST": True, + "TAGS": [ + {"name": "RAG Chat", "description": "چت هوشمند RAG"}, + {"name": "Tasks", "description": "مدیریت تسک‌های Celery"}, + {"name": "Soil Data", "description": "داده‌های خاک (SoilGrids)"}, + {"name": "Sensor Data", "description": "داده‌های سنسور"}, + {"name": "Sensor Parameters", "description": "پارامترهای سنسور"}, + ], } CORS_ALLOW_ALL_ORIGINS = DEBUG @@ -114,4 +140,54 @@ CELERY_BEAT_SCHEDULE = { "task": "rag.tasks.rag_ingest_task", "schedule": 6 * 60 * 60, # ۶ ساعت }, + "weather-fetch-periodic": { + "task": "weather.tasks.fetch_weather_all_locations_task", + "schedule": 6 * 60 * 60, # ۶ ساعت + }, +} + +# Weather API +WEATHER_API_BASE_URL = os.environ.get( + "WEATHER_API_BASE_URL", "https://api.open-meteo.com/v1/forecast" +) +WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY", "") + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "standard", + }, + "file": { + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": str(LOG_DIR / "app.log"), + "when": "midnight", + "backupCount": 14, + "encoding": "utf-8", + "formatter": "standard", + }, + }, + "loggers": { + "django": { + "handlers": ["console", "file"], + "level": os.environ.get("DJANGO_LOG_LEVEL", "INFO"), + "propagate": False, + }, + "rag": { + "handlers": ["console", "file"], + "level": os.environ.get("RAG_LOG_LEVEL", "INFO"), + "propagate": False, + }, + }, + "root": { + "handlers": ["console", "file"], + "level": os.environ.get("ROOT_LOG_LEVEL", "INFO"), + }, } diff --git a/config/tones/chat_tone.txt b/config/tones/chat_tone.txt new file mode 100644 index 0000000..5471eac --- /dev/null +++ b/config/tones/chat_tone.txt @@ -0,0 +1,7 @@ +# فایل لحن / سبک پاسخ‌های RAG + +لحن و سبک پاسخ‌ها: +- سطح: دوستانه و تخصصی؛ با کشاورز به زبان ساده و علمی صحبت کن. +- واژگان: از اصطلاحات رایج کشاورزی و خاک‌شناسی استفاده کن، در صورت نیاز معادل فارسی بیاور. +- طول: پاسخ‌ها مختصر و کاربردی؛ در صورت لزوم با بولت یا شماره ساختاربندی کن. +- هشدار: اگر موضوع ایمنی یا سلامتی گیاه/خاک باشد، صریحاً هشدار بده. diff --git a/config/tones/fertilization_tone.txt b/config/tones/fertilization_tone.txt new file mode 100644 index 0000000..66b63a1 --- /dev/null +++ b/config/tones/fertilization_tone.txt @@ -0,0 +1,8 @@ +# لحن توصیه کودهی + +سبک پاسخ: +- تخصصی و دقیق: نوع کود، مقدار و زمان مصرف را مشخص کن +- بر اساس داده‌های NPK خاک، pH، و نوع محصول +- فرمت خروجی: JSON با فیلدهای fertilizer_needed (bool), fertilizer_type (str), amount_kg_per_hectare (float), reason (str), npk_status (dict) +- اگر سطح NPK خاک مناسب باشد، کودهی لازم نیست +- هشدارهای مهم درباره مصرف بیش از حد کود را ذکر کن diff --git a/config/tones/irrigation_tone.txt b/config/tones/irrigation_tone.txt new file mode 100644 index 0000000..16db07f --- /dev/null +++ b/config/tones/irrigation_tone.txt @@ -0,0 +1,8 @@ +# لحن توصیه آبیاری + +سبک پاسخ: +- مستقیم و عملیاتی: زمان، مقدار و روش آبیاری را مشخص کن +- بر اساس داده‌های هواشناسی (بارش، ET0، دما) و رطوبت خاک +- فرمت خروجی: JSON با فیلدهای irrigation_needed (bool), amount_mm (float), reason (str), next_check_date (str) +- اگر بارش پیش‌بینی شده باشد، آبیاری را به تعویق بینداز +- اگر رطوبت خاک کافی است، آبیاری لازم نیست diff --git a/config/urls.py b/config/urls.py index 0894916..da3134e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,10 +1,22 @@ from django.contrib import admin from django.urls import include, path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) urlpatterns = [ path("admin/", admin.site.urls), + # --- OpenAPI / Swagger --- + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), + # --- App APIs --- path("api/rag/", include("rag.urls")), path("api/tasks/", include("tasks.urls")), - path("api/soil-data/", include("soil_data.urls")), + path("api/soil-data/", include("location_data.urls")), path("api/sensor-data/", include("sensor_data.urls")), + path("api/plants/", include("plant.urls")), + path("api/irrigation/", include("irrigation.urls")), ] diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index d1a2fd8..faa7465 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -69,6 +69,8 @@ services: restart: unless-stopped ports: - "8020:8000" + volumes: + - ./logs:/app/logs celery: build: . @@ -86,6 +88,8 @@ services: redis: condition: service_started restart: unless-stopped + volumes: + - ./logs:/app/logs volumes: ai_mysql_data: diff --git a/docker-compose.yaml b/docker-compose.yaml index 5656366..c876e84 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,7 @@ services: retries: 5 phpmyadmin: - image: phpmyadmin:latest + image: docker-mirror.liara.ir/phpmyadmin:latest container_name: ai-phpmyadmin environment: PMA_HOST: db @@ -48,11 +48,15 @@ services: restart: unless-stopped web: - build: . + build: + context: . + args: + APT_MIRROR: mirror2.chabokan.net container_name: ai-web command: ["python", "manage.py", "runserver", "0.0.0.0:8000"] volumes: - .:/app + - ./logs:/app/logs ports: - "8020:8000" env_file: @@ -72,11 +76,15 @@ services: condition: service_started celery: - build: . + build: + context: . + args: + APT_MIRROR: mirror.cdn.ir container_name: ai-celery command: celery -A config worker -l info volumes: - .:/app + - ./logs:/app/logs env_file: - .env environment: diff --git a/irrigation/__init__.py b/irrigation/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/irrigation/__init__.py @@ -0,0 +1 @@ + diff --git a/irrigation/admin.py b/irrigation/admin.py new file mode 100644 index 0000000..1c3cede --- /dev/null +++ b/irrigation/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import IrrigationMethod + + +@admin.register(IrrigationMethod) +class IrrigationMethodAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "category", + "water_efficiency_percent", + "soil_type", + "climate_suitability", + "created_at", + ) + list_filter = ("category", "climate_suitability") + search_fields = ("name", "category") + readonly_fields = ("created_at", "updated_at") diff --git a/irrigation/apps.py b/irrigation/apps.py new file mode 100644 index 0000000..e75d5d3 --- /dev/null +++ b/irrigation/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class IrrigationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "irrigation" + verbose_name = "Irrigation" diff --git a/irrigation/management/__init__.py b/irrigation/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/irrigation/management/__init__.py @@ -0,0 +1 @@ + diff --git a/irrigation/management/commands/__init__.py b/irrigation/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/irrigation/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/irrigation/management/commands/seed_irrigation_methods.py b/irrigation/management/commands/seed_irrigation_methods.py new file mode 100644 index 0000000..a8ca7a8 --- /dev/null +++ b/irrigation/management/commands/seed_irrigation_methods.py @@ -0,0 +1,100 @@ +""" +Management command to seed initial irrigation methods. +Run: python manage.py seed_irrigation_methods +""" + +from django.core.management.base import BaseCommand + +from irrigation.models import IrrigationMethod + + +INITIAL_METHODS = [ + { + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آب با دبی کم و به‌صورت قطره‌ای مستقیماً به ریشه گیاه رسانده می‌شود. مناسب‌ترین روش برای مناطق خشک و کم‌آب.", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "تمام انواع خاک", + "climate_suitability": "گرم و خشک", + }, + { + "name": "آبیاری بارانی", + "category": "تحت فشار", + "description": "آب تحت فشار از طریق آبپاش‌ها به‌صورت قطرات ریز مانند باران پخش می‌شود.", + "water_efficiency_percent": 75.0, + "water_pressure_required": "۲-۴ اتمسفر", + "flow_rate": "۵-۲۰ لیتر در دقیقه", + "coverage_area": "۱۰-۳۰ متر شعاع پاشش", + "soil_type": "لومی، لومی شنی", + "climate_suitability": "معتدل، مرطوب", + }, + { + "name": "آبیاری سطحی (غرقابی)", + "category": "سطحی", + "description": "آب در سطح زمین جاری شده و به‌صورت ثقلی زمین را آبیاری می‌کند. ساده‌ترین و قدیمی‌ترین روش.", + "water_efficiency_percent": 50.0, + "water_pressure_required": "نیاز به فشار ندارد (ثقلی)", + "flow_rate": "متغیر بر اساس شیب زمین", + "coverage_area": "وابسته به اندازه کرت", + "soil_type": "رسی، لومی رسی", + "climate_suitability": "تمام اقلیم‌ها (مناطق پرآب)", + }, + { + "name": "آبیاری نشتی (تیپ)", + "category": "موضعی", + "description": "آب از طریق نوارهای تیپ با منافذ ریز به‌صورت نشتی به خاک رسانده می‌شود.", + "water_efficiency_percent": 85.0, + "water_pressure_required": "۰.۵-۱.۵ اتمسفر", + "flow_rate": "۱-۴ لیتر در ساعت به ازای هر متر", + "coverage_area": "ردیفی، مناسب زراعت", + "soil_type": "لومی، لومی شنی", + "climate_suitability": "گرم و خشک", + }, + { + "name": "آبیاری زیرسطحی", + "category": "موضعی", + "description": "لوله‌های آبیاری در زیر سطح خاک کار گذاشته شده و آب مستقیماً به منطقه ریشه می‌رسد.", + "water_efficiency_percent": 95.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۱-۴ لیتر در ساعت", + "coverage_area": "بسته به طراحی", + "soil_type": "لومی، لومی رسی", + "climate_suitability": "تمام اقلیم‌ها", + }, + { + "name": "آبیاری بابلر", + "category": "موضعی", + "description": "آب با دبی بیشتر از قطره‌ای ولی کمتر از بارانی، به‌صورت حبابی در پای درخت پخش می‌شود. مناسب درختان میوه.", + "water_efficiency_percent": 80.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۸-۶۰ لیتر در ساعت", + "coverage_area": "شعاع ۱-۲ متر اطراف درخت", + "soil_type": "لومی، لومی رسی", + "climate_suitability": "گرم و خشک", + }, +] + + +class Command(BaseCommand): + help = "Seed initial irrigation methods (6 common methods)" + + def handle(self, *args, **options): + created_count = 0 + for method_data in INITIAL_METHODS: + _, created = IrrigationMethod.objects.get_or_create( + name=method_data["name"], + defaults=method_data, + ) + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f" Created: {method_data['name']}") + ) + self.stdout.write( + self.style.SUCCESS( + f"\nDone. Created {created_count} new irrigation methods." + ) + ) diff --git a/irrigation/migrations/0001_initial.py b/irrigation/migrations/0001_initial.py new file mode 100644 index 0000000..725177f --- /dev/null +++ b/irrigation/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.12 on 2026-03-19 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='IrrigationMethod', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, help_text='نام روش آبیاری (قطره\u200cای، بارانی، سطحی و …)', max_length=255, unique=True)), + ('category', models.CharField(blank=True, help_text='نوع روش (موضعی، تحت فشار، سطحی)', max_length=255)), + ('description', models.TextField(blank=True, help_text='توضیحات کامل روش')), + ('water_efficiency_percent', models.FloatField(blank=True, help_text='راندمان مصرف آب (%)', null=True)), + ('water_pressure_required', models.CharField(blank=True, help_text='فشار مورد نیاز آب', max_length=255)), + ('flow_rate', models.CharField(blank=True, help_text='دبی یا میزان جریان آب', max_length=255)), + ('coverage_area', models.CharField(blank=True, help_text='مساحت قابل پوشش', max_length=255)), + ('soil_type', models.CharField(blank=True, help_text='نوع خاک مناسب', max_length=255)), + ('climate_suitability', models.CharField(blank=True, help_text='اقلیم مناسب', max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'روش آبیاری', + 'verbose_name_plural': 'روش\u200cهای آبیاری', + 'ordering': ['name'], + }, + ), + ] diff --git a/irrigation/migrations/__init__.py b/irrigation/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/irrigation/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/irrigation/models.py b/irrigation/models.py new file mode 100644 index 0000000..4f9e743 --- /dev/null +++ b/irrigation/models.py @@ -0,0 +1,63 @@ +from django.db import models + + +class IrrigationMethod(models.Model): + """ + روش‌های آبیاری شامل مشخصات فنی. + """ + + name = models.CharField( + max_length=255, + unique=True, + db_index=True, + help_text="نام روش آبیاری (قطره‌ای، بارانی، سطحی و …)", + ) + category = models.CharField( + max_length=255, + blank=True, + help_text="نوع روش (موضعی، تحت فشار، سطحی)", + ) + description = models.TextField( + blank=True, + help_text="توضیحات کامل روش", + ) + water_efficiency_percent = models.FloatField( + null=True, + blank=True, + help_text="راندمان مصرف آب (%)", + ) + water_pressure_required = models.CharField( + max_length=255, + blank=True, + help_text="فشار مورد نیاز آب", + ) + flow_rate = models.CharField( + max_length=255, + blank=True, + help_text="دبی یا میزان جریان آب", + ) + coverage_area = models.CharField( + max_length=255, + blank=True, + help_text="مساحت قابل پوشش", + ) + soil_type = models.CharField( + max_length=255, + blank=True, + help_text="نوع خاک مناسب", + ) + climate_suitability = models.CharField( + max_length=255, + blank=True, + help_text="اقلیم مناسب", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + verbose_name = "روش آبیاری" + verbose_name_plural = "روش‌های آبیاری" + + def __str__(self): + return self.name diff --git a/irrigation/serializers.py b/irrigation/serializers.py new file mode 100644 index 0000000..f11f4b5 --- /dev/null +++ b/irrigation/serializers.py @@ -0,0 +1,25 @@ +from rest_framework import serializers + +from .models import IrrigationMethod + + +class IrrigationMethodSerializer(serializers.ModelSerializer): + """سریالایزر خروجی / ورودی برای IrrigationMethod.""" + + class Meta: + model = IrrigationMethod + fields = [ + "id", + "name", + "category", + "description", + "water_efficiency_percent", + "water_pressure_required", + "flow_rate", + "coverage_area", + "soil_type", + "climate_suitability", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] diff --git a/irrigation/urls.py b/irrigation/urls.py new file mode 100644 index 0000000..97308ce --- /dev/null +++ b/irrigation/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import IrrigationMethodDetailView, IrrigationMethodListCreateView + +urlpatterns = [ + path("", IrrigationMethodListCreateView.as_view(), name="irrigation-list-create"), + path("/", IrrigationMethodDetailView.as_view(), name="irrigation-detail"), +] diff --git a/irrigation/views.py b/irrigation/views.py new file mode 100644 index 0000000..72682f6 --- /dev/null +++ b/irrigation/views.py @@ -0,0 +1,180 @@ +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, +) +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import IrrigationMethod +from .serializers import IrrigationMethodSerializer + + +class IrrigationMethodListCreateView(APIView): + """لیست تمام روش‌های آبیاری و ایجاد روش جدید.""" + + @extend_schema( + tags=["Irrigation"], + summary="لیست روش‌های آبیاری", + description="لیست تمام روش‌های آبیاری ذخیره‌شده را برمی‌گرداند.", + responses={200: IrrigationMethodSerializer(many=True)}, + ) + def get(self, request): + methods = IrrigationMethod.objects.all() + serializer = IrrigationMethodSerializer(methods, many=True) + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Irrigation"], + summary="ایجاد روش آبیاری جدید", + description="یک روش آبیاری جدید ایجاد می‌کند.", + request=IrrigationMethodSerializer, + responses={ + 201: IrrigationMethodSerializer, + 400: OpenApiResponse(description="داده نامعتبر"), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "name": "آبیاری قطره‌ای", + "category": "موضعی", + "description": "آبیاری با دبی کم و فشار مناسب", + "water_efficiency_percent": 90.0, + "water_pressure_required": "۱-۲ اتمسفر", + "flow_rate": "۲-۸ لیتر در ساعت", + "coverage_area": "بسته به طراحی سیستم", + "soil_type": "تمام انواع خاک", + "climate_suitability": "گرم و خشک", + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = IrrigationMethodSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 201, "msg": "success", "data": serializer.data}, + status=status.HTTP_201_CREATED, + ) + + +class IrrigationMethodDetailView(APIView): + """دریافت، ویرایش و حذف یک روش آبیاری.""" + + def _get_method(self, pk): + return IrrigationMethod.objects.filter(pk=pk).first() + + @extend_schema( + tags=["Irrigation"], + summary="جزئیات روش آبیاری", + description="مشخصات یک روش آبیاری را بر اساس شناسه برمی‌گرداند.", + responses={ + 200: IrrigationMethodSerializer, + 404: OpenApiResponse(description="روش آبیاری یافت نشد"), + }, + ) + def get(self, request, pk): + method = self._get_method(pk) + if not method: + return Response( + {"code": 404, "msg": "روش آبیاری یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IrrigationMethodSerializer(method) + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Irrigation"], + summary="ویرایش کامل روش آبیاری", + description="تمام فیلدهای یک روش آبیاری را آپدیت می‌کند.", + request=IrrigationMethodSerializer, + responses={ + 200: IrrigationMethodSerializer, + 400: OpenApiResponse(description="داده نامعتبر"), + 404: OpenApiResponse(description="روش آبیاری یافت نشد"), + }, + ) + def put(self, request, pk): + method = self._get_method(pk) + if not method: + return Response( + {"code": 404, "msg": "روش آبیاری یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IrrigationMethodSerializer(method, data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Irrigation"], + summary="ویرایش جزئی روش آبیاری", + description="فقط فیلدهای ارسال‌شده آپدیت می‌شوند.", + request=IrrigationMethodSerializer, + responses={ + 200: IrrigationMethodSerializer, + 400: OpenApiResponse(description="داده نامعتبر"), + 404: OpenApiResponse(description="روش آبیاری یافت نشد"), + }, + ) + def patch(self, request, pk): + method = self._get_method(pk) + if not method: + return Response( + {"code": 404, "msg": "روش آبیاری یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IrrigationMethodSerializer(method, data=request.data, partial=True) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Irrigation"], + summary="حذف روش آبیاری", + description="یک روش آبیاری را حذف می‌کند.", + responses={ + 200: OpenApiResponse(description="حذف موفق"), + 404: OpenApiResponse(description="روش آبیاری یافت نشد"), + }, + ) + def delete(self, request, pk): + method = self._get_method(pk) + if not method: + return Response( + {"code": 404, "msg": "روش آبیاری یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + method.delete() + return Response( + {"code": 200, "msg": "روش آبیاری با موفقیت حذف شد.", "data": None}, + status=status.HTTP_200_OK, + ) diff --git a/soil_data/__init__.py b/location_data/__init__.py similarity index 100% rename from soil_data/__init__.py rename to location_data/__init__.py diff --git a/soil_data/admin.py b/location_data/admin.py similarity index 100% rename from soil_data/admin.py rename to location_data/admin.py diff --git a/soil_data/apps.py b/location_data/apps.py similarity index 86% rename from soil_data/apps.py rename to location_data/apps.py index 71a697d..621ebbb 100644 --- a/soil_data/apps.py +++ b/location_data/apps.py @@ -3,5 +3,5 @@ from django.apps import AppConfig class SoilDataConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "soil_data" + name = "location_data" verbose_name = "Soil Data (SoilGrids)" diff --git a/soil_data/migrations/__init__.py b/location_data/management/__init__.py similarity index 100% rename from soil_data/migrations/__init__.py rename to location_data/management/__init__.py diff --git a/location_data/management/commands/__init__.py b/location_data/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/location_data/management/commands/rename_soil_data_label.py b/location_data/management/commands/rename_soil_data_label.py new file mode 100644 index 0000000..7e447a8 --- /dev/null +++ b/location_data/management/commands/rename_soil_data_label.py @@ -0,0 +1,32 @@ +""" +Management command: اجرای یک‌بار rename اپ label از soil_data به location_data در DB. +این دستور را یک بار قبل از اجرای migrate اجرا کنید: + python manage.py rename_soil_data_label + python manage.py migrate +""" +from django.core.management.base import BaseCommand +from django.db import connection + + +class Command(BaseCommand): + help = "Rename app label from soil_data to location_data in django_migrations and django_content_type" + + def handle(self, *args, **options): + with connection.cursor() as cursor: + cursor.execute( + "UPDATE django_migrations SET app = %s WHERE app = %s", + ["location_data", "soil_data"], + ) + migrations_updated = cursor.rowcount + cursor.execute( + "UPDATE django_content_type SET app_label = %s WHERE app_label = %s", + ["location_data", "soil_data"], + ) + content_types_updated = cursor.rowcount + + self.stdout.write( + self.style.SUCCESS( + f"Done. django_migrations rows updated: {migrations_updated}, " + f"django_content_type rows updated: {content_types_updated}" + ) + ) diff --git a/soil_data/migrations/0001_initial.py b/location_data/migrations/0001_initial.py similarity index 97% rename from soil_data/migrations/0001_initial.py rename to location_data/migrations/0001_initial.py index 7225a2d..e391044 100644 --- a/soil_data/migrations/0001_initial.py +++ b/location_data/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated manually for soil_data +# Generated manually for location_data from django.db import migrations, models diff --git a/soil_data/migrations/0002_soildepthdata_refactor.py b/location_data/migrations/0002_soildepthdata_refactor.py similarity index 96% rename from soil_data/migrations/0002_soildepthdata_refactor.py rename to location_data/migrations/0002_soildepthdata_refactor.py index 711bbb7..9e07645 100644 --- a/soil_data/migrations/0002_soildepthdata_refactor.py +++ b/location_data/migrations/0002_soildepthdata_refactor.py @@ -7,7 +7,7 @@ import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ("soil_data", "0001_initial"), + ("location_data", "0001_initial"), ] operations = [ @@ -47,7 +47,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="depths", - to="soil_data.soillocation", + to="location_data.soillocation", ), ), ], diff --git a/location_data/migrations/0003_rename_app_label.py b/location_data/migrations/0003_rename_app_label.py new file mode 100644 index 0000000..281057c --- /dev/null +++ b/location_data/migrations/0003_rename_app_label.py @@ -0,0 +1,17 @@ +from django.db import migrations +from django.db import migrations +from django.db import migrations + + +class Migration(migrations.Migration): + """ + نشانگر تغییر اپ label از soil_data به location_data. + پیش از اجرای این migration، دستور زیر را اجرا کنید: + python manage.py rename_soil_data_label + """ + + dependencies = [ + ("location_data", "0002_soildepthdata_refactor"), + ] + + operations = [] diff --git a/location_data/migrations/__init__.py b/location_data/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/soil_data/models.py b/location_data/models.py similarity index 100% rename from soil_data/models.py rename to location_data/models.py diff --git a/soil_data/postman/soil_data.json b/location_data/postman/soil_data.json similarity index 100% rename from soil_data/postman/soil_data.json rename to location_data/postman/soil_data.json diff --git a/soil_data/serializers.py b/location_data/serializers.py similarity index 100% rename from soil_data/serializers.py rename to location_data/serializers.py diff --git a/soil_data/tasks.py b/location_data/tasks.py similarity index 100% rename from soil_data/tasks.py rename to location_data/tasks.py diff --git a/soil_data/urls.py b/location_data/urls.py similarity index 100% rename from soil_data/urls.py rename to location_data/urls.py diff --git a/soil_data/views.py b/location_data/views.py similarity index 57% rename from soil_data/views.py rename to location_data/views.py index ffbe701..876aea9 100644 --- a/soil_data/views.py +++ b/location_data/views.py @@ -1,4 +1,11 @@ from rest_framework import status +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import serializers as drf_serializers from rest_framework.response import Response from rest_framework.views import APIView @@ -21,9 +28,53 @@ class SoilDataView(APIView): def _get_request_data(self, request): return request.data if request.method == "POST" else request.query_params + @extend_schema( + tags=["Soil Data"], + summary="دریافت داده خاک (GET)", + description="با ارسال lat و lon، داده خاک از DB یا از طریق تسک Celery برگردانده می‌شود.", + parameters=[ + { + "name": "lat", + "in": "query", + "required": True, + "schema": {"type": "number"}, + "description": "عرض جغرافیایی", + }, + { + "name": "lon", + "in": "query", + "required": True, + "schema": {"type": "number"}, + "description": "طول جغرافیایی", + }, + ], + responses={ + 200: OpenApiResponse(description="داده خاک از دیتابیس"), + 202: OpenApiResponse(description="تسک در صف قرار گرفت"), + 400: OpenApiResponse(description="داده نامعتبر"), + }, + ) def get(self, request): return self._process(request) + @extend_schema( + tags=["Soil Data"], + summary="دریافت داده خاک (POST)", + description="با ارسال lat و lon در بدنه، داده خاک از DB یا از طریق تسک Celery برگردانده می‌شود.", + request=SoilDataRequestSerializer, + responses={ + 200: OpenApiResponse(description="داده خاک از دیتابیس"), + 202: OpenApiResponse(description="تسک در صف قرار گرفت"), + 400: OpenApiResponse(description="داده نامعتبر"), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={"lat": 35.6892, "lon": 51.3890}, + request_only=True, + ), + ], + ) def post(self, request): return self._process(request) @@ -85,6 +136,31 @@ class SoilDataView(APIView): class SoilDataTaskStatusView(APIView): """وضعیت تسک واکشی خاک. در صورت SUCCESS لیست اطلاعات هر سه عمق برگردانده می‌شود.""" + @extend_schema( + tags=["Soil Data"], + summary="وضعیت تسک داده خاک", + description="وضعیت تسک Celery واکشی داده خاک را برمی‌گرداند.", + responses={ + 200: inline_serializer( + name="SoilTaskStatusResponse", + fields={ + "code": drf_serializers.IntegerField(), + "msg": drf_serializers.CharField(), + "data": inline_serializer( + name="SoilTaskStatusData", + fields={ + "task_id": drf_serializers.CharField(), + "status": drf_serializers.CharField(), + "message": drf_serializers.CharField(required=False), + "progress": drf_serializers.DictField(required=False), + "result": drf_serializers.JSONField(required=False), + "error": drf_serializers.CharField(required=False), + }, + ), + }, + ), + }, + ) def get(self, request, task_id): from celery.result import AsyncResult diff --git a/logs/app.log.2026-03-18 b/logs/app.log.2026-03-18 new file mode 100644 index 0000000..245aa87 --- /dev/null +++ b/logs/app.log.2026-03-18 @@ -0,0 +1,67 @@ +2026-03-18 22:04:32,786 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:04:54,364 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. +2026-03-18 22:04:56,265 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:11:25,490 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:11:26,312 [INFO] django.server: "GET /api/docs/ HTTP/1.1" 200 4633 +2026-03-18 22:11:26,363 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui.css HTTP/1.1" 200 178591 +2026-03-18 22:11:26,364 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-standalone-preset.js HTTP/1.1" 200 251697 +2026-03-18 22:11:26,367 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-bundle.js HTTP/1.1" 200 1525208 +2026-03-18 22:11:26,369 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-standalone-preset.js HTTP/1.1" 200 251697 +2026-03-18 22:11:26,586 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/favicon-32x32.png HTTP/1.1" 200 628 +2026-03-18 22:11:26,611 [INFO] django.server: "GET /api/schema/ HTTP/1.1" 200 36389 +2026-03-18 22:11:27,014 [INFO] django.server: "GET /static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-bundle.js HTTP/1.1" 200 1525208 +2026-03-18 22:12:01,496 [WARNING] django.request: Bad Request: /api/rag/chat/ +2026-03-18 22:12:01,496 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 +2026-03-18 22:12:46,012 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:12:47,916 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:12:56,327 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:12:58,210 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:13:01,536 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:13:04,493 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:13:25,217 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:13:27,187 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:13:31,580 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:13:34,011 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:15:13,685 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:15:15,628 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:15:17,930 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:15:19,914 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:15:23,293 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:15:25,815 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:15:36,399 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:15:38,979 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:15:48,593 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:15:51,190 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:15:53,726 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:15:55,973 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:15:59,401 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:16:02,173 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:16:04,712 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:16:07,147 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:16:08,615 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading. +2026-03-18 22:16:11,764 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 22:16:15,499 [WARNING] django.request: Bad Request: /api/rag/chat/ +2026-03-18 22:16:15,499 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 +2026-03-18 22:20:23,200 [WARNING] django.request: Bad Request: /api/rag/chat/ +2026-03-18 22:20:23,200 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 +2026-03-18 22:24:46,060 [WARNING] django.request: Bad Request: /api/rag/chat/ +2026-03-18 22:24:46,060 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 +2026-03-18 23:04:06,049 [INFO] root: jhh +2026-03-18 23:04:06,049 [INFO] rag.chat: chat_rag_stream started sensor_uuid=00000000-0000-0000-0000-000000000000 kb_name=None limit=5 query_len=19 +2026-03-18 23:04:06,121 [INFO] rag.chat: Detected KB intent=chat +2026-03-18 23:04:06,121 [INFO] rag.chat: Using knowledge base=chat +2026-03-18 23:04:06,121 [INFO] rag.chat: Building RAG context sensor_uuid=00000000-0000-0000-0000-000000000000 kb_name=chat limit=5 query_len=19 +2026-03-18 23:04:06,736 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden" +2026-03-18 23:04:06,738 [INFO] django.server: "POST /api/rag/chat/ HTTP/1.1" 200 228 +2026-03-18 23:09:42,790 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. +2026-03-18 23:09:44,745 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 23:09:46,283 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. +2026-03-18 23:09:49,297 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 23:09:53,012 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. +2026-03-18 23:09:56,613 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 23:09:58,271 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. +2026-03-18 23:10:01,739 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 23:10:03,325 [INFO] django.utils.autoreload: /app/rag/chat.py changed, reloading. +2026-03-18 23:10:06,629 [INFO] django.utils.autoreload: Watching for file changes with StatReloader +2026-03-18 23:10:11,679 [WARNING] django.request: Bad Request: /api/rag/chat/ +2026-03-18 23:10:11,679 [WARNING] django.server: "POST /api/rag/chat/ HTTP/1.1" 400 88 diff --git a/plant/__init__.py b/plant/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/plant/__init__.py @@ -0,0 +1 @@ + diff --git a/plant/admin.py b/plant/admin.py new file mode 100644 index 0000000..1f5842c --- /dev/null +++ b/plant/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin + +from .models import Plant + + +@admin.register(Plant) +class PlantAdmin(admin.ModelAdmin): + list_display = ( + "id", + "name", + "light", + "soil", + "temperature", + "planting_season", + "created_at", + ) + list_filter = ("planting_season",) + search_fields = ("name",) + readonly_fields = ("created_at", "updated_at") diff --git a/plant/apps.py b/plant/apps.py new file mode 100644 index 0000000..9fb4a3c --- /dev/null +++ b/plant/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PlantConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "plant" + verbose_name = "Plant" diff --git a/plant/management/__init__.py b/plant/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/plant/management/__init__.py @@ -0,0 +1 @@ + diff --git a/plant/management/commands/__init__.py b/plant/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/plant/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/plant/management/commands/seed_plants.py b/plant/management/commands/seed_plants.py new file mode 100644 index 0000000..95a6c21 --- /dev/null +++ b/plant/management/commands/seed_plants.py @@ -0,0 +1,109 @@ +""" +Management command to seed initial plant data. +Run: python manage.py seed_plants +""" + +from django.core.management.base import BaseCommand + +from plant.models import Plant + + +INITIAL_PLANTS = [ + { + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل (۶-۸ ساعت)", + "watering": "منظم، هفته‌ای ۲-۳ بار", + "soil": "لومی، غنی از مواد آلی، pH بین ۶-۶.۸", + "temperature": "۲۰-۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰-۹۰ روز پس از کاشت", + "spacing": "۴۵-۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل، کمپوست", + }, + { + "name": "خیار", + "light": "آفتاب کامل", + "watering": "روزانه در فصل گرم", + "soil": "لومی شنی، غنی از هوموس", + "temperature": "۱۸-۳۰ درجه سانتی‌گراد", + "planting_season": "بهار تا اوایل تابستان", + "harvest_time": "۵۰-۷۰ روز پس از کاشت", + "spacing": "۳۰-۴۵ سانتی‌متر", + "fertilizer": "کود ازته، کمپوست", + }, + { + "name": "فلفل دلمه‌ای", + "light": "آفتاب کامل (۶-۸ ساعت)", + "watering": "منظم، هفته‌ای ۲-۳ بار", + "soil": "لومی، زهکشی مناسب", + "temperature": "۲۰-۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۶۰-۹۰ روز پس از کاشت", + "spacing": "۴۰-۵۰ سانتی‌متر", + "fertilizer": "کود فسفره و پتاسه", + }, + { + "name": "هویج", + "light": "آفتاب کامل تا نیمه‌سایه", + "watering": "منظم، خاک مرطوب", + "soil": "شنی لومی، عمیق، بدون سنگ", + "temperature": "۱۵-۲۵ درجه سانتی‌گراد", + "planting_season": "اوایل بهار یا پاییز", + "harvest_time": "۷۰-۸۰ روز پس از کاشت", + "spacing": "۵-۸ سانتی‌متر", + "fertilizer": "کود پتاسه، کمپوست پوسیده", + }, + { + "name": "کاهو", + "light": "نیمه‌سایه تا آفتاب کامل", + "watering": "منظم، خاک مرطوب", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۱۰-۲۰ درجه سانتی‌گراد", + "planting_season": "بهار و پاییز", + "harvest_time": "۴۵-۶۰ روز پس از کاشت", + "spacing": "۲۰-۳۰ سانتی‌متر", + "fertilizer": "کود ازته، کمپوست", + }, + { + "name": "سیب‌زمینی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲ بار", + "soil": "لومی شنی، اسیدی ملایم، pH بین ۵-۶", + "temperature": "۱۵-۲۲ درجه سانتی‌گراد", + "planting_season": "اواخر زمستان تا اوایل بهار", + "harvest_time": "۹۰-۱۲۰ روز پس از کاشت", + "spacing": "۳۰-۴۰ سانتی‌متر", + "fertilizer": "کود NPK، کمپوست", + }, + { + "name": "پیاز", + "light": "آفتاب کامل", + "watering": "منظم، خاک مرطوب ولی نه غرقابی", + "soil": "لومی، زهکشی خوب", + "temperature": "۱۲-۲۴ درجه سانتی‌گراد", + "planting_season": "پاییز یا اوایل بهار", + "harvest_time": "۹۰-۱۵۰ روز پس از کاشت", + "spacing": "۱۰-۱۵ سانتی‌متر", + "fertilizer": "کود فسفره، سولفات پتاسیم", + }, +] + + +class Command(BaseCommand): + help = "Seed initial plant data (7 common vegetables)" + + def handle(self, *args, **options): + created_count = 0 + for plant_data in INITIAL_PLANTS: + _, created = Plant.objects.get_or_create( + name=plant_data["name"], + defaults=plant_data, + ) + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f" Created: {plant_data['name']}") + ) + self.stdout.write( + self.style.SUCCESS(f"\nDone. Created {created_count} new plants.") + ) diff --git a/plant/migrations/0001_initial.py b/plant/migrations/0001_initial.py new file mode 100644 index 0000000..e4d799d --- /dev/null +++ b/plant/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.12 on 2026-03-19 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Plant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(db_index=True, help_text='نام گیاه', max_length=255, unique=True)), + ('light', models.CharField(blank=True, help_text='نور مورد نیاز', max_length=255)), + ('watering', models.CharField(blank=True, help_text='آبیاری', max_length=255)), + ('soil', models.CharField(blank=True, help_text='خاک مناسب', max_length=255)), + ('temperature', models.CharField(blank=True, help_text='دمای مناسب', max_length=255)), + ('planting_season', models.CharField(blank=True, help_text='فصل کاشت', max_length=255)), + ('harvest_time', models.CharField(blank=True, help_text='زمان برداشت', max_length=255)), + ('spacing', models.CharField(blank=True, help_text='فاصله کاشت', max_length=255)), + ('fertilizer', models.CharField(blank=True, help_text='کود مناسب', max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'گیاه', + 'verbose_name_plural': 'گیاهان', + 'ordering': ['name'], + }, + ), + ] diff --git a/plant/migrations/__init__.py b/plant/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/plant/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/plant/models.py b/plant/models.py new file mode 100644 index 0000000..31aa900 --- /dev/null +++ b/plant/models.py @@ -0,0 +1,64 @@ +from django.db import models + + +class Plant(models.Model): + """ + اطلاعات گیاهان شامل شرایط نگهداری و کاشت. + """ + + name = models.CharField( + max_length=255, + unique=True, + db_index=True, + help_text="نام گیاه", + ) + light = models.CharField( + max_length=255, + blank=True, + help_text="نور مورد نیاز", + ) + watering = models.CharField( + max_length=255, + blank=True, + help_text="آبیاری", + ) + soil = models.CharField( + max_length=255, + blank=True, + help_text="خاک مناسب", + ) + temperature = models.CharField( + max_length=255, + blank=True, + help_text="دمای مناسب", + ) + planting_season = models.CharField( + max_length=255, + blank=True, + help_text="فصل کاشت", + ) + harvest_time = models.CharField( + max_length=255, + blank=True, + help_text="زمان برداشت", + ) + spacing = models.CharField( + max_length=255, + blank=True, + help_text="فاصله کاشت", + ) + fertilizer = models.CharField( + max_length=255, + blank=True, + help_text="کود مناسب", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + verbose_name = "گیاه" + verbose_name_plural = "گیاهان" + + def __str__(self): + return self.name diff --git a/plant/serializers.py b/plant/serializers.py new file mode 100644 index 0000000..7e334db --- /dev/null +++ b/plant/serializers.py @@ -0,0 +1,25 @@ +from rest_framework import serializers + +from .models import Plant + + +class PlantSerializer(serializers.ModelSerializer): + """سریالایزر خروجی / ورودی برای Plant.""" + + class Meta: + model = Plant + fields = [ + "id", + "name", + "light", + "watering", + "soil", + "temperature", + "planting_season", + "harvest_time", + "spacing", + "fertilizer", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "created_at", "updated_at"] diff --git a/plant/services.py b/plant/services.py new file mode 100644 index 0000000..4095dae --- /dev/null +++ b/plant/services.py @@ -0,0 +1,34 @@ +""" +سرویس‌های گیاه — دریافت مشخصات گیاه از API خارجی بر اساس نام. +""" + +import logging + +logger = logging.getLogger(__name__) + + +def fetch_plant_info_from_api(plant_name: str) -> dict | None: + """ + اتصال به API خارجی و دریافت مشخصات گیاه بر اساس نام. + + TODO: پیاده‌سازی اتصال واقعی به API. + در حال حاضر این تابع خالی است و None برمی‌گرداند. + + پارامترها: + plant_name: نام گیاه + + خروجی مورد انتظار (وقتی پیاده‌سازی شود): + { + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲-۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰-۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰-۹۰ روز پس از کاشت", + "spacing": "۴۵-۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + } + """ + # TODO: اتصال واقعی به API + return None diff --git a/plant/urls.py b/plant/urls.py new file mode 100644 index 0000000..91556a3 --- /dev/null +++ b/plant/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import PlantDetailView, PlantFetchInfoView, PlantListCreateView + +urlpatterns = [ + path("", PlantListCreateView.as_view(), name="plant-list-create"), + path("/", PlantDetailView.as_view(), name="plant-detail"), + path("fetch-info/", PlantFetchInfoView.as_view(), name="plant-fetch-info"), +] diff --git a/plant/views.py b/plant/views.py new file mode 100644 index 0000000..6d17200 --- /dev/null +++ b/plant/views.py @@ -0,0 +1,234 @@ +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import serializers as drf_serializers +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import Plant +from .serializers import PlantSerializer +from .services import fetch_plant_info_from_api + + +class PlantListCreateView(APIView): + """لیست تمام گیاهان و ایجاد گیاه جدید.""" + + @extend_schema( + tags=["Plant"], + summary="لیست گیاهان", + description="لیست تمام گیاهان ذخیره‌شده را برمی‌گرداند.", + responses={200: PlantSerializer(many=True)}, + ) + def get(self, request): + plants = Plant.objects.all() + serializer = PlantSerializer(plants, many=True) + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Plant"], + summary="ایجاد گیاه جدید", + description="یک گیاه جدید با مشخصات داده‌شده ایجاد می‌کند.", + request=PlantSerializer, + responses={ + 201: PlantSerializer, + 400: OpenApiResponse(description="داده نامعتبر"), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "name": "گوجه‌فرنگی", + "light": "آفتاب کامل", + "watering": "منظم، هفته‌ای ۲-۳ بار", + "soil": "لومی، غنی از مواد آلی", + "temperature": "۲۰-۳۰ درجه سانتی‌گراد", + "planting_season": "بهار", + "harvest_time": "۷۰-۹۰ روز پس از کاشت", + "spacing": "۴۵-۶۰ سانتی‌متر", + "fertilizer": "کود NPK متعادل", + }, + request_only=True, + ), + ], + ) + def post(self, request): + serializer = PlantSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 201, "msg": "success", "data": serializer.data}, + status=status.HTTP_201_CREATED, + ) + + +class PlantDetailView(APIView): + """دریافت، ویرایش و حذف یک گیاه.""" + + def _get_plant(self, pk): + return Plant.objects.filter(pk=pk).first() + + @extend_schema( + tags=["Plant"], + summary="جزئیات گیاه", + description="مشخصات یک گیاه را بر اساس شناسه برمی‌گرداند.", + responses={ + 200: PlantSerializer, + 404: OpenApiResponse(description="گیاه یافت نشد"), + }, + ) + def get(self, request, pk): + plant = self._get_plant(pk) + if not plant: + return Response( + {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = PlantSerializer(plant) + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Plant"], + summary="ویرایش کامل گیاه", + description="تمام فیلدهای یک گیاه را آپدیت می‌کند.", + request=PlantSerializer, + responses={ + 200: PlantSerializer, + 400: OpenApiResponse(description="داده نامعتبر"), + 404: OpenApiResponse(description="گیاه یافت نشد"), + }, + ) + def put(self, request, pk): + plant = self._get_plant(pk) + if not plant: + return Response( + {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = PlantSerializer(plant, data=request.data) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Plant"], + summary="ویرایش جزئی گیاه", + description="فقط فیلدهای ارسال‌شده آپدیت می‌شوند.", + request=PlantSerializer, + responses={ + 200: PlantSerializer, + 400: OpenApiResponse(description="داده نامعتبر"), + 404: OpenApiResponse(description="گیاه یافت نشد"), + }, + ) + def patch(self, request, pk): + plant = self._get_plant(pk) + if not plant: + return Response( + {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = PlantSerializer(plant, data=request.data, partial=True) + if not serializer.is_valid(): + return Response( + {"code": 400, "msg": "داده نامعتبر.", "data": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + serializer.save() + return Response( + {"code": 200, "msg": "success", "data": serializer.data}, + status=status.HTTP_200_OK, + ) + + @extend_schema( + tags=["Plant"], + summary="حذف گیاه", + description="یک گیاه را حذف می‌کند.", + responses={ + 200: OpenApiResponse(description="حذف موفق"), + 404: OpenApiResponse(description="گیاه یافت نشد"), + }, + ) + def delete(self, request, pk): + plant = self._get_plant(pk) + if not plant: + return Response( + {"code": 404, "msg": "گیاه یافت نشد.", "data": None}, + status=status.HTTP_404_NOT_FOUND, + ) + plant.delete() + return Response( + {"code": 200, "msg": "گیاه با موفقیت حذف شد.", "data": None}, + status=status.HTTP_200_OK, + ) + + +class PlantFetchInfoView(APIView): + """دریافت مشخصات گیاه از API خارجی بر اساس نام.""" + + @extend_schema( + tags=["Plant"], + summary="دریافت مشخصات گیاه از API خارجی", + description="بر اساس نام گیاه، مشخصات آن را از API خارجی دریافت می‌کند. (فعلاً خالی)", + request=inline_serializer( + name="PlantFetchInfoRequest", + fields={ + "name": drf_serializers.CharField(help_text="نام گیاه"), + }, + ), + responses={ + 200: PlantSerializer, + 400: OpenApiResponse(description="نام گیاه ارسال نشده"), + 503: OpenApiResponse(description="سرویس در دسترس نیست"), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={"name": "گوجه‌فرنگی"}, + request_only=True, + ), + ], + ) + def post(self, request): + plant_name = request.data.get("name") + if not plant_name: + return Response( + {"code": 400, "msg": "نام گیاه الزامی است.", "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + + result = fetch_plant_info_from_api(plant_name) + if result is None: + return Response( + { + "code": 503, + "msg": "سرویس API هنوز پیاده‌سازی نشده است.", + "data": None, + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + return Response( + {"code": 200, "msg": "success", "data": result}, + status=status.HTTP_200_OK, + ) diff --git a/rag/README.md b/rag/README.md new file mode 100644 index 0000000..6654355 --- /dev/null +++ b/rag/README.md @@ -0,0 +1,393 @@ +# مستند سیستم RAG — پایگاه دانش CropLogic + +## فهرست + +1. [معرفی کلی](#معرفی-کلی) +2. [معماری و ساختار](#معماری-و-ساختار) +3. [منابع داده](#منابع-داده) +4. [پایپ‌لاین Embedding](#پایپلاین-embedding) +5. [نحوه اجرا](#نحوه-اجرا) +6. [فلوی پیام کاربر](#فلوی-پیام-کاربر) +7. [API Endpoint](#api-endpoint) +8. [تنظیمات](#تنظیمات) +9. [ایزوله‌سازی کاربران](#ایزولهسازی-کاربران) +10. [سرویس‌های توصیه](#سرویسهای-توصیه) + +--- + +## معرفی کلی + +سیستم RAG در CropLogic یک چت هوشمند کشاورزی است که: + +- **دانش پایه کشاورزی** را embed و ذخیره می‌کند +- **داده‌های خاک و هواشناسی هر کاربر** را از DB می‌خواند و embed می‌کند +- وقتی کاربر سوال می‌پرسد، **اطلاعات مرتبط** را بازیابی و به **LLM** ارسال می‌کند + +**Vector Store:** Qdrant +**API Provider:** GapGPT (با fallback به Avalai) — Adapter Pattern + +### پایگاه‌های دانش مجزا + +سیستم از **سه پایگاه دانش** مجزا استفاده می‌کند: + +| KB | توضیح | فایل Tone | +|----|-------|-----------| +| `chat` | چت عمومی و پاسخ به سوالات متنوع | `config/tones/chat_tone.txt` | +| `irrigation` | توصیه‌های آبیاری (فرمت JSON) | `config/tones/irrigation_tone.txt` | +| `fertilization` | توصیه‌های کودهی (فرمت JSON) | `config/tones/fertilization_tone.txt` | + +تشخیص هوشمند KB از روی کلمات کلیدی سوال (آبیاری، آب، کود، NPK). + +--- + +## معماری و ساختار + +``` +rag/ +├── config.py # بارگذاری تنظیمات از rag_config.yaml +├── api_provider.py # Adapter Pattern برای GapGPT/Avalai +├── client.py # ساخت کلاینت Qdrant +├── chunker.py # تکه‌تکه کردن متن +├── embedding.py # تعبیه‌سازی متن +├── vector_store.py # ذخیره و جستجو در Qdrant (با فیلتر kb_name) +├── user_data.py # خواندن داده‌های خاک/سنسور/هواشناسی از DB +├── ingest.py # پایپ‌لاین: خواندن → چانک → embed → ذخیره +├── retrieve.py # بازیابی: embed کوئری → جستجو +├── chat.py # ساخت context و چت استریمی با LLM +├── views.py # API endpoint +├── urls.py # مسیریابی +├── tasks.py # تسک Celery +├── services/ # سرویس‌های توصیه (بدون API) +│ ├── irrigation.py # توصیه آبیاری +│ └── fertilization.py # توصیه کودهی +└── management/commands/ + └── rag_ingest.py +``` + +فایل‌های تنظیمات: + +``` +config/ +├── rag_config.yaml +├── tones/ +│ ├── chat_tone.txt +│ ├── irrigation_tone.txt +│ └── fertilization_tone.txt +└── knowledge_base/ + ├── chat/ + ├── irrigation/ + └── fertilization/ +``` + +--- + +## منابع داده + +سیستم از **چهار منبع** داده تغذیه می‌شود: + +### 1. لحن‌های مجزا — `config/tones/` + +هر KB یک فایل لحن مخصوص دارد که سبک خروجی LLM را تعریف می‌کند. + +ذخیره با: `sensor_uuid = __global__`, `kb_name = chat|irrigation|fertilization` + +### 2. پایگاه‌های دانش — `config/knowledge_base/` + +- `chat/`: دانش عمومی کشاورزی +- `irrigation/`: دانش تخصصی آبیاری (ET0، بارش، رطوبت) +- `fertilization/`: دانش تخصصی کودهی (NPK، pH، نوع خاک) + +ذخیره با: `sensor_uuid = __global__`, `kb_name = chat|irrigation|fertilization` + +### 3. داده‌های خاک کاربر — از DB + +برای هر سنسور: +- `SensorData`: رطوبت، دما، pH، EC، NPK +- `SoilLocation`: مختصات جغرافیایی +- `SoilDepthData`: داده‌های خاک در سه عمق + +تابع `build_user_soil_text()` این داده‌ها را به متن فارسی تبدیل می‌کند. + +ذخیره با: `sensor_uuid = {uuid واقعی}`, `kb_name = __all__` + +### 4. داده‌های هواشناسی کاربر — از DB + +- `WeatherForecast`: پیش‌بینی ۷ روز آینده (دما، بارش، رطوبت، باد، ET0) + +تابع `build_user_weather_text()` این داده‌ها را به متن فارسی تبدیل می‌کند. + +ذخیره با: `sensor_uuid = {uuid واقعی}`, `kb_name = __all__` + +--- + +## پایپلاین Embedding + +``` +منابع → load_sources() → chunk_text() → embed_texts() → Qdrant +``` + +1. **بارگذاری منابع** (`ingest.py:load_sources`): + - لحن‌ها از `config/tones/` + - KB‌ها از `config/knowledge_base/` + - داده‌های کاربران از DB (`user_data.py`) + +2. **چانک کردن** (`chunker.py`): + - حداکثر ۵۰۰ توکن هر چانک + - ۵۰ توکن همپوشانی + +3. **Embedding** (`embedding.py`): + - استفاده از `api_provider.get_embedding_client()` + - مدل: `text-embedding-3-small` + - بچ‌سایز: ۳۲ + +4. **ذخیره در Qdrant** (`vector_store.py`): + - هر point: `{id, vector[1536], payload{text, source, sensor_uuid, kb_name, chunk_index}}` + +--- + +## نحوه اجرا + +### دستی + +```bash +python manage.py rag_ingest --recreate +``` + +### دوره‌ای (Celery Beat) + +تسک `rag_ingest_task` هر ۶ ساعت اجرا می‌شود و داده‌های جدید را embed می‌کند. + +--- + +## فلوی پیام کاربر + +``` +POST /api/rag/chat/ {message, sensor_uuid} + ↓ +1. تشخیص KB از روی کلمات کلیدی (_detect_kb_intent) + ↓ +2. بارگذاری داده‌های فعلی کاربر از DB: + - build_user_soil_text(sensor_uuid) + - build_user_weather_text(sensor_uuid) + ↓ +3. Embed کردن سوال (embed_single) + ↓ +4. جستجو در Qdrant با فیلتر: + - sensor_uuid = {uuid کاربر} OR __global__ + - kb_name = {detected_kb} OR __all__ + ↓ +5. ساخت context: + [داده‌های فعلی خاک] + [پیش‌بینی هواشناسی] + [متن‌های مرجع از RAG] + ↓ +6. ارسال به LLM (GapGPT): + system_prompt = tone + دستورالعمل + context + ↓ +7. StreamingHttpResponse → کاربر +``` + +--- + +## API Endpoint + +### POST `/api/rag/chat/` + +**Request:** +```json +{ + "message": "وضعیت خاک من چطوره؟", + "sensor_uuid": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +**Response:** Stream متنی (text/plain) + +--- + +## تنظیمات + +### `config/rag_config.yaml` + +```yaml +embedding: + provider: "gapgpt" # gapgpt یا avalai + model: "text-embedding-3-small" + 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" + +qdrant: + host: "localhost" + port: 6333 + collection_name: "croplogic_kb" + vector_size: 1536 + +chunking: + max_chunk_tokens: 500 + overlap_tokens: 50 + +llm: + 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" + +knowledge_bases: + chat: + path: "config/knowledge_base/chat" + tone_file: "config/tones/chat_tone.txt" + irrigation: + path: "config/knowledge_base/irrigation" + tone_file: "config/tones/irrigation_tone.txt" + fertilization: + path: "config/knowledge_base/fertilization" + tone_file: "config/tones/fertilization_tone.txt" +``` + +### متغیرهای محیطی + +| متغیر | توضیح | +|-------|-------| +| `GAPGPT_API_KEY` | کلید API برای GapGPT | +| `AVALAI_API_KEY` | کلید API برای Avalai (fallback) | +| `QDRANT_HOST` | آدرس Qdrant | +| `QDRANT_PORT` | پورت Qdrant | + +--- + +## ایزوله‌سازی کاربران + +- هر چانک یک فیلد `sensor_uuid` در metadata دارد +- داده‌های عمومی: `sensor_uuid = __global__` +- داده‌های کاربر: `sensor_uuid = {uuid واقعی}` +- هنگام جستجو، فیلتر `should` اعمال می‌شود: + - `sensor_uuid = {uuid کاربر}` OR `__global__` + - `kb_name = {detected_kb}` OR `__all__` +- نتیجه: هر کاربر فقط داده‌های خودش + دانش عمومی را می‌بیند + +--- + +## سرویس‌های توصیه + +سرویس‌های آبیاری و کودهی **بدون API** هستند و از RAG استفاده می‌کنند. + +### توصیه آبیاری + +```python +from rag.services import get_irrigation_recommendation + +result = get_irrigation_recommendation( + sensor_uuid="550e8400-...", + query="توصیه آبیاری برای مزرعه من چیست؟" # اختیاری +) +``` + +**خروجی:** +```python +{ + "irrigation_needed": True, + "amount_mm": 25.0, + "reason": "رطوبت خاک پایین و بارش پیش‌بینی نشده", + "next_check_date": "2026-03-20", + "raw_response": "..." +} +``` + +### توصیه کودهی + +```python +from rag.services import get_fertilization_recommendation + +result = get_fertilization_recommendation( + sensor_uuid="550e8400-...", + query="توصیه کودهی برای مزرعه من چیست؟" # اختیاری +) +``` + +**خروجی:** +```python +{ + "fertilizer_needed": True, + "fertilizer_type": "NPK 20-10-10", + "amount_kg_per_hectare": 150.0, + "reason": "سطح ازت پایین", + "npk_status": { + "nitrogen": "low", + "phosphorus": "normal", + "potassium": "normal" + }, + "raw_response": "..." +} +``` + +--- + +## نمودار معماری + +``` +┌─────────────────────────────────────────────────────────┐ +│ منابع داده │ +│ │ +│ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │ +│ │ tones/ │ │ knowledge_ │ │ Django DB │ │ +│ │ 3 files │ │ base/ │ │ SensorData │ │ +│ │ │ │ chat/irrig/ │ │ SoilLocation │ │ +│ │ │ │ fertiliz/ │ │ SoilDepthData │ │ +│ │ │ │ │ │ WeatherForecast │ │ +│ └────┬─────┘ └──────┬───────┘ └────────┬──────────┘ │ +│ │ │ │ │ +│ └───────────┬────┘ │ │ +│ __global__ sensor_uuid │ +│ kb_name=chat/ kb_name=__all__ │ +│ irrigation/ │ +│ fertilization │ +└───────────────┬────────────────────────┬────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────┐ +│ ingest pipeline │ +│ │ +│ load_sources() → chunk_text() → embed_texts() │ +│ (با Adapter Pattern: GapGPT/Avalai) │ +│ │ +│ کامند: python manage.py rag_ingest --recreate │ +│ تسک: rag_ingest_task.delay(recreate=True) │ +└────────────────────────┬────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Qdrant │ +│ collection: croplogic_kb │ +│ │ +│ هر point = {id, vector[1536], payload{text, │ +│ source, sensor_uuid, kb_name, │ +│ chunk_index}} │ +└────────────────────────┬────────────────────────────────┘ + │ + (هنگام سوال کاربر) + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ فلوی پاسخ به کاربر │ +│ │ +│ 1. POST /api/rag/chat/ {message, sensor_uuid} │ +│ 2. تشخیص KB از کلمات کلیدی (_detect_kb_intent) │ +│ 3. build_user_soil_text() + build_user_weather_text() │ +│ 4. embed_single(message) → query vector │ +│ 5. Qdrant search با فیلتر sensor_uuid + kb_name │ +│ 6. system_prompt = tone + دستورالعمل + context │ +│ 7. GapGPT LLM (gpt-4o) → streaming response │ +│ 8. StreamingHttpResponse → کاربر │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +**تغییرات اخیر:** + +- ✅ Adapter Pattern برای سوئیچ بین GapGPT و Avalai +- ✅ سه پایگاه دانش مجزا (chat/irrigation/fertilization) +- ✅ داده‌های هواشناسی embed می‌شوند +- ✅ فیلتر `kb_name` در جستجوی Qdrant +- ✅ سرویس‌های توصیه آبیاری و کودهی (بدون API) diff --git a/rag/api_provider.py b/rag/api_provider.py new file mode 100644 index 0000000..08b7675 --- /dev/null +++ b/rag/api_provider.py @@ -0,0 +1,58 @@ +""" +Adapter Pattern برای API providers — سوئیچ بین GapGPT و Avalai +تنظیمات فعلی: GapGPT به‌عنوان provider اصلی +Avalai به‌عنوان fallback نگه داشته شده. +""" +import logging +import os + +from openai import OpenAI + +from .config import RAGConfig, load_rag_config + +logger = logging.getLogger(__name__) + +def get_embedding_client(config: RAGConfig | None = None) -> OpenAI: + """ + ساخت کلاینت OpenAI برای Embedding بر اساس provider فعال. + provider از config.embedding.provider خوانده می‌شود: "gapgpt" یا "avalai" + """ + cfg = config or load_rag_config() + emb = cfg.embedding + logger.info(emb.provider) + + if emb.provider == "avalai": + env_var = emb.avalai_api_key_env or emb.api_key_env or "AVALAI_API_KEY" + api_key = os.environ.get(env_var) + base_url = emb.avalai_base_url or emb.base_url or "https://api.avalai.ir/v1" + else: + env_var = emb.api_key_env or "GAPGPT_API_KEY" + api_key = os.environ.get(env_var) + base_url = emb.base_url or "https://api.gapgpt.app/v1" + logger.info(api_key+" "+base_url) + + return OpenAI(api_key=api_key, base_url=base_url) + + +def get_chat_client(config: RAGConfig | None = None) -> OpenAI: + """ + ساخت کلاینت OpenAI برای Chat/LLM بر اساس provider فعال. + provider از config.embedding.provider خوانده می‌شود (مشترک بین embedding و chat). + """ + cfg = config or load_rag_config() + llm = cfg.llm + provider = cfg.embedding.provider + + + logger.info(provider) + if provider == "avalai": + env_var = llm.avalai_api_key_env or llm.api_key_env or "AVALAI_API_KEY" + api_key = os.environ.get(env_var) + base_url = llm.avalai_base_url or llm.base_url or "https://api.avalai.ir/v1" + else: + env_var = llm.api_key_env or "GAPGPT_API_KEY" + api_key = os.environ.get(env_var) + base_url = llm.base_url or "https://api.gapgpt.app/v1" + logger.info(api_key,base_url) + + return OpenAI(api_key=api_key, base_url=base_url) diff --git a/rag/chat.py b/rag/chat.py index 8d2628e..5705238 100644 --- a/rag/chat.py +++ b/rag/chat.py @@ -1,63 +1,108 @@ """ -چت RAG با استریم — استفاده از دیتای embed شده کاربر و Avalai API +چت RAG با استریم — استفاده از دیتای embed شده کاربر و Adapter API (GapGPT / Avalai) """ -import os +import logging from pathlib import Path -from openai import OpenAI - from .config import load_rag_config, RAGConfig +from .api_provider import get_chat_client from .retrieve import search_with_query -from .user_data import build_user_soil_text +from .user_data import build_user_soil_text, build_user_weather_text - -def _get_chat_client(config: RAGConfig | None) -> OpenAI: - """ساخت کلاینت OpenAI برای Avalai Chat API.""" - cfg = config or load_rag_config() - llm = cfg.llm - env_var = llm.api_key_env or "AVALAI_API_KEY" - api_key = os.environ.get(env_var) - base_url = llm.base_url or os.environ.get( - "AVALAI_BASE_URL", "https://api.avalai.ir/v1" - ) - return OpenAI(api_key=api_key, base_url=base_url) +logger = logging.getLogger(__name__) def _load_tone(config: RAGConfig | None) -> str: - """بارگذاری فایل لحن.""" + """بارگذاری فایل لحن پیش‌فرض (chat KB).""" cfg = config or load_rag_config() base = Path(__file__).resolve().parent.parent - tone_path = base / cfg.tone_file - if tone_path.exists(): - return tone_path.read_text(encoding="utf-8").strip() + chat_kb = cfg.knowledge_bases.get("chat") + if chat_kb: + tone_path = base / chat_kb.tone_file + logger.debug("Loading default tone from path=%s", tone_path) + if tone_path.exists(): + logger.debug("Default tone file found: %s", tone_path) + return tone_path.read_text(encoding="utf-8").strip() + logger.warning("Default tone file not found: %s", tone_path) return "" +def _load_kb_tone(kb_name: str, config: RAGConfig | None = None) -> str: + """بارگذاری فایل لحن مخصوص یک پایگاه دانش.""" + cfg = config or load_rag_config() + kb_cfg = cfg.knowledge_bases.get(kb_name) + if not kb_cfg: + return "" + base = Path(__file__).resolve().parent.parent + tone_path = base / kb_cfg.tone_file + logger.debug("Loading kb tone for kb=%s path=%s", kb_name, tone_path) + if tone_path.exists(): + logger.debug("KB tone file found for kb=%s", kb_name) + return tone_path.read_text(encoding="utf-8").strip() + logger.warning("KB tone file not found for kb=%s path=%s", kb_name, tone_path) + return "" + + +def _detect_kb_intent(query: str) -> str: + """تشخیص ساده نوع پایگاه دانش مورد نیاز از روی متن سوال.""" + q = query.lower() + irrigation_keywords = {"آبیاری", "آب", "رطوبت", "irrigation", "water", "et0", "بارش", "خشکی"} + fertilization_keywords = {"کود", "کودهی", "fertiliz", "npk", "ازت", "فسفر", "پتاسیم", "nitrogen", "phosphorus", "potassium"} + if any(kw in q for kw in irrigation_keywords): + return "irrigation" + if any(kw in q for kw in fertilization_keywords): + logger.info("Detected KB intent=fertilization") + return "fertilization" + logger.info("Detected KB intent=chat") + return "chat" + + def build_rag_context( query: str, sensor_uuid: str, config: RAGConfig | None = None, limit: int = 8, + kb_name: str | None = None, ) -> str: """ ساخت context برای LLM: دیتای فعلی خاک کاربر + متن‌های مرتبط از RAG. دیتای کاربر همیشه اول می‌آید تا LLM مقادیر واقعی (مثل pH) را ببیند. """ + logger.info( + "Building RAG context sensor_uuid=%s kb_name=%s limit=%s query_len=%s", + sensor_uuid, + kb_name, + limit, + len(query or ""), + ) parts: list[str] = [] - # ۱. دیتای فعلی خاک کاربر از DB — همیشه اول (برای سوالاتی مثل «pH خاک من چند است») user_soil = build_user_soil_text(sensor_uuid) if user_soil and user_soil.strip(): parts.append("[داده‌های فعلی خاک شما]\n" + user_soil.strip()) + logger.debug("Included user soil section sensor_uuid=%s", sensor_uuid) + else: + logger.info("No user soil data found sensor_uuid=%s", sensor_uuid) + + weather_text = build_user_weather_text(sensor_uuid) + if weather_text and weather_text.strip(): + parts.append("[پیش‌بینی هواشناسی]\n" + weather_text.strip()) + logger.debug("Included weather section sensor_uuid=%s", sensor_uuid) + else: + logger.info("No weather data found sensor_uuid=%s", sensor_uuid) - # ۲. متن‌های مرتبط از RAG results = search_with_query( - query, sensor_uuid=sensor_uuid, limit=limit, config=config + query, sensor_uuid=sensor_uuid, limit=limit, config=config, + kb_name=kb_name, ) if results: + logger.info("Retrieved RAG results count=%s sensor_uuid=%s", len(results), sensor_uuid) rag_texts = [r.get("text", "").strip() for r in results if r.get("text")] if rag_texts: parts.append("[متن‌های مرجع]\n" + "\n\n---\n\n".join(rag_texts)) + logger.debug("Included RAG reference texts count=%s", len(rag_texts)) + else: + logger.info("No RAG results found sensor_uuid=%s kb_name=%s", sensor_uuid, kb_name) return "\n\n---\n\n".join(parts) if parts else "" @@ -68,7 +113,15 @@ def chat_rag_stream( config: RAGConfig | None = None, limit: int = 5, system_override: str | None = None, + kb_name: str | None = None, ): + logger.info( + "chat_rag_stream started sensor_uuid=%s kb_name=%s limit=%s query_len=%s", + sensor_uuid, + kb_name, + limit, + len(query or ""), + ) """ چت RAG با استریم: دیتای embed شده را بازیابی می‌کند و با LLM جواب می‌دهد. فقط دیتای همان کاربر (sensor_uuid) قابل دسترسی است. @@ -84,19 +137,28 @@ def chat_rag_stream( تک‌تک deltaهای content به‌صورت رشته """ cfg = config or load_rag_config() - client = _get_chat_client(cfg) + client = get_chat_client(cfg) model = cfg.llm.model + logger.debug("Loaded RAG config with model=%s", model) - context = build_rag_context(query, sensor_uuid, config=cfg, limit=limit) + detected_kb = kb_name or _detect_kb_intent(query) + logger.info("Using knowledge base=%s", detected_kb) + context = build_rag_context( + query, sensor_uuid, config=cfg, limit=limit, kb_name=detected_kb, + ) + logger.debug("Built context length=%s", len(context)) if system_override is not None: system_content = system_override else: - tone = _load_tone(cfg) + tone = _load_kb_tone(detected_kb, cfg) + if not tone: + tone = _load_tone(cfg) system_parts = [tone] if tone else [] system_parts.append( "با استفاده از بخش «داده‌های فعلی خاک شما» و «متن‌های مرجع» زیر به سوال کاربر پاسخ بده. " "برای سوالاتی درباره خاک کاربر (مثل pH، رطوبت، NPK) حتماً از داده‌های فعلی استفاده کن. " + "اطلاعات هواشناسی در بخش «پیش‌بینی هواشناسی» آمده. " "پاسخ را به زبان کاربر بنویس." ) if context: @@ -107,15 +169,19 @@ def chat_rag_stream( {"role": "system", "content": system_content}, {"role": "user", "content": query}, ] + logger.info("Prepared messages for model=%s message=%s", model,messages) stream = client.chat.completions.create( model=model, messages=messages, stream=True, ) + logger.info("Started streaming response from model=%s", model) for chunk in stream: delta = chunk.choices[0].delta if chunk.choices else None content = delta.content if delta else "" if content: + logger.debug("Streaming chunk len=%s", len(content)) yield content + logger.info("chat_rag_stream completed sensor_uuid=%s", sensor_uuid) diff --git a/rag/config.py b/rag/config.py index c1d3584..2a5ea47 100644 --- a/rag/config.py +++ b/rag/config.py @@ -1,5 +1,5 @@ """ -بارگذاری تنظیمات RAG از rag_config.yaml +بارگذاری تنظیمات RAG از rag_config.yaml — با پشتیبانی از چند provider و چند پایگاه دانش """ import os from dataclasses import dataclass, field @@ -16,6 +16,8 @@ class EmbeddingConfig: batch_size: int = 32 api_key_env: str | None = None base_url: str | None = None + avalai_base_url: str | None = None + avalai_api_key_env: str | None = None @dataclass @@ -37,6 +39,15 @@ class LLMConfig: model: str = "gpt-4o" base_url: str | None = None api_key_env: str | None = None + avalai_base_url: str | None = None + avalai_api_key_env: str | None = None + + +@dataclass +class KnowledgeBaseConfig: + path: str + tone_file: str + description: str = "" @dataclass @@ -45,8 +56,7 @@ class RAGConfig: qdrant: QdrantConfig chunking: ChunkingConfig llm: LLMConfig = field(default_factory=LLMConfig) - tone_file: str = "config/tone.txt" - knowledge_base_path: str = "config/knowledge_base" + knowledge_bases: dict[str, KnowledgeBaseConfig] = field(default_factory=dict) chromadb: dict[str, Any] = field(default_factory=dict) @@ -73,6 +83,8 @@ def load_rag_config(config_path: str | Path | None = None) -> RAGConfig: batch_size=emb.get("batch_size", 32), api_key_env=emb.get("api_key_env"), base_url=emb.get("base_url"), + avalai_base_url=emb.get("avalai_base_url"), + avalai_api_key_env=emb.get("avalai_api_key_env"), ) qd = data.get("qdrant", {}) @@ -94,14 +106,24 @@ def load_rag_config(config_path: str | Path | None = None) -> RAGConfig: model=llm_data.get("model", "gpt-4o"), base_url=llm_data.get("base_url"), api_key_env=llm_data.get("api_key_env"), + avalai_base_url=llm_data.get("avalai_base_url"), + avalai_api_key_env=llm_data.get("avalai_api_key_env"), ) + kb_data = data.get("knowledge_bases", {}) + knowledge_bases: dict[str, KnowledgeBaseConfig] = {} + for kb_name, kb_conf in kb_data.items(): + knowledge_bases[kb_name] = KnowledgeBaseConfig( + path=kb_conf.get("path", f"config/knowledge_base/{kb_name}"), + tone_file=kb_conf.get("tone_file", f"config/tones/{kb_name}_tone.txt"), + description=kb_conf.get("description", ""), + ) + return RAGConfig( embedding=embedding, qdrant=qdrant, chunking=chunking, llm=llm, - tone_file=data.get("tone_file", "config/tone.txt"), - knowledge_base_path=data.get("knowledge_base_path", "config/knowledge_base"), + knowledge_bases=knowledge_bases, chromadb=data.get("chromadb", {}), ) diff --git a/rag/embedding.py b/rag/embedding.py index 4b0a720..f84683c 100644 --- a/rag/embedding.py +++ b/rag/embedding.py @@ -1,24 +1,8 @@ """ -سرویس تعبیه‌سازی متن با Avalai API (OpenAI-compatible) +سرویس تعبیه‌سازی متن — از Adapter Pattern برای سوئیچ بین providers استفاده می‌کند """ -import os -from typing import overload - -from openai import OpenAI - -from .config import load_rag_config, RAGConfig - - -def _get_avalai_client(config: RAGConfig | None) -> OpenAI: - """ساخت کلاینت OpenAI برای Avalai API.""" - cfg = config or load_rag_config() - emb = cfg.embedding - env_var = emb.api_key_env or "AVALAI_API_KEY" - api_key = os.environ.get(env_var) - base_url = emb.base_url or os.environ.get( - "AVALAI_BASE_URL", "https://api.avalai.ir/v1" - ) - return OpenAI(api_key=api_key, base_url=base_url) +from .api_provider import get_embedding_client +from .config import RAGConfig, load_rag_config def embed_texts( @@ -43,7 +27,7 @@ def embed_texts( return [] cfg = config or load_rag_config() - client = _get_avalai_client(cfg) + client = get_embedding_client(cfg) model_name = model or cfg.embedding.model batch_size = cfg.embedding.batch_size diff --git a/rag/ingest.py b/rag/ingest.py index e668dd3..cf9f8b3 100644 --- a/rag/ingest.py +++ b/rag/ingest.py @@ -1,10 +1,10 @@ """ -پایپ‌لاین ورودی RAG: خواندن، چانک، embed و ذخیره در vector store +پایپ‌لاین ورودی RAG: خواندن، چانک، embed و ذخیره در vector store — با پشتیبانی از چند پایگاه دانش -سه منبع: -۱. لحن (tone) — sensor_uuid=__global__ -۲. پایگاه دانش (knowledge base) — sensor_uuid=__global__ -۳. دیتای خاک هر کاربر از DB (sensor_data + soil_data) — sensor_uuid=uuid +منابع: +۱. لحن هر پایگاه دانش (tone) — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization +۲. پایگاه‌های دانش سه‌گانه — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization +۳. دیتای خاک + هواشناسی هر کاربر از DB — sensor_uuid=uuid, kb_name=__all__ """ import uuid from pathlib import Path @@ -12,14 +12,15 @@ from pathlib import Path from .chunker import chunk_text, chunk_texts from .config import load_rag_config, RAGConfig from .embedding import embed_texts -from .user_data import load_user_sources +from .user_data import load_user_sources, build_user_weather_text from .vector_store import QdrantVectorStore -# پسوندهای قابل خواندن TEXT_EXTENSIONS = {".txt", ".md", ".rst", ".json"} SENSOR_UUID_GLOBAL = "__global__" +KB_NAME_ALL = "__all__" + def _resolve_path(base: Path, p: str) -> Path: """تبدیل مسیر نسبی به مطلق نسبت به base پروژه.""" @@ -57,49 +58,64 @@ def _load_files_from_dir(dir_path: Path, prefix: str = "kb") -> list[tuple[str, return out -def load_sources(config: RAGConfig | None = None) -> list[tuple[str, str, str]]: +def load_sources( + config: RAGConfig | None = None, + kb_name: str | None = None, +) -> list[tuple[str, str, str, str]]: """ - بارگذاری سه منبع: لحن، پایگاه دانش، دیتای کاربر از DB. + بارگذاری منابع: لحن‌ها، پایگاه‌های دانش سه‌گانه، دیتای کاربران. + اگر kb_name مشخص شود، فقط آن پایگاه دانش لود می‌شود. Returns: - [(source_id, content, sensor_uuid), ...] - sensor_uuid: __global__ برای tone/kb، uuid سنسور برای user + [(source_id, content, sensor_uuid, kb_name), ...] """ cfg = config or load_rag_config() base = Path(__file__).resolve().parent.parent - sources: list[tuple[str, str, str]] = [] + sources: list[tuple[str, str, str, str]] = [] - # ۱. لحن - tone_path = _resolve_path(base, cfg.tone_file) - content = _load_file(tone_path) - if content: - sources.append(("tone", content, SENSOR_UUID_GLOBAL)) + kbs_to_load = cfg.knowledge_bases.items() + if kb_name: + kbs_to_load = [(k, v) for k, v in kbs_to_load if k == kb_name] - # ۲. پایگاه دانش - kb_path = _resolve_path(base, cfg.knowledge_base_path) - for sid, c in _load_files_from_dir(kb_path, prefix="kb"): - sources.append((sid, c, SENSOR_UUID_GLOBAL)) - if kb_path.is_file(): - content = _load_file(kb_path) + for kbn, kb_cfg in kbs_to_load: + tone_path = _resolve_path(base, kb_cfg.tone_file) + content = _load_file(tone_path) if content: - sources.append((f"kb:{kb_path.name}", content, SENSOR_UUID_GLOBAL)) + sources.append((f"tone:{kbn}", content, SENSOR_UUID_GLOBAL, kbn)) + + kb_path = _resolve_path(base, kb_cfg.path) + for sid, c in _load_files_from_dir(kb_path, prefix=f"kb:{kbn}"): + sources.append((sid, c, SENSOR_UUID_GLOBAL, kbn)) + if kb_path.is_file(): + content = _load_file(kb_path) + if content: + sources.append((f"kb:{kbn}:{kb_path.name}", content, SENSOR_UUID_GLOBAL, kbn)) - # ۳. دیتای کاربران از sensor_data + soil_data for sid, content in load_user_sources(): - sensor_uuid = sid.replace("user:", "") - sources.append((sid, content, sensor_uuid)) + if sid.startswith("user:"): + sensor_uuid = sid.replace("user:", "") + elif sid.startswith("weather:"): + sensor_uuid = sid.replace("weather:", "") + else: + sensor_uuid = sid + sources.append((sid, content, sensor_uuid, KB_NAME_ALL)) return sources -def ingest(recreate: bool = False, config: RAGConfig | None = None) -> dict: +def ingest( + recreate: bool = False, + config: RAGConfig | None = None, + kb_name: str | None = None, +) -> dict: """ - ورودی کامل: منابع را می‌خواند، چانک می‌کند، embed می‌کند و به vector store می‌فرستد. - دیتای هر کاربر (sensor_uuid) جدا embed و با metadata ذخیره می‌شود. + ورودی کامل: منابع را می‌خواند، چانک، embed و به vector store می‌فرستد. + kb_name اختیاری: اگر مشخص شود فقط آن پایگاه دانش ingest می‌شود. Args: recreate: اگر True باشد، collection را از نو می‌سازد config: تنظیمات RAG + kb_name: نام پایگاه دانش (chat/irrigation/fertilization) — اختیاری Returns: آمار ورودی (تعداد چانک، منبع‌ها، خطاها) @@ -109,7 +125,7 @@ def ingest(recreate: bool = False, config: RAGConfig | None = None) -> dict: if recreate: store.ensure_collection(recreate=True) - sources = load_sources(config=cfg) + sources = load_sources(config=cfg, kb_name=kb_name) if not sources: return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"} @@ -117,7 +133,7 @@ def ingest(recreate: bool = False, config: RAGConfig | None = None) -> dict: all_metas: list[dict] = [] all_ids: list[str] = [] - for source_id, content, sensor_uuid in sources: + for source_id, content, sensor_uuid, src_kb in sources: chunks = chunk_text(content, config=cfg) for i, ch in enumerate(chunks): uid = str(uuid.uuid4()) @@ -127,6 +143,7 @@ def ingest(recreate: bool = False, config: RAGConfig | None = None) -> dict: "source": source_id, "chunk_index": i, "sensor_uuid": sensor_uuid, + "kb_name": src_kb, }) if not all_chunks: diff --git a/rag/retrieve.py b/rag/retrieve.py index c64fce0..89a200b 100644 --- a/rag/retrieve.py +++ b/rag/retrieve.py @@ -12,13 +12,16 @@ def search_with_query( limit: int = 5, score_threshold: float | None = None, config: RAGConfig | None = None, + kb_name: str | None = None, ) -> list[dict]: """ کوئری را embed می‌کند و در vector store جستجو می‌کند. فقط chunks مربوط به sensor_uuid یا __global__ برمی‌گردد (ایزوله‌سازی کاربر). + kb_name: اختیاری — فیلتر بر اساس پایگاه دانش. Args: sensor_uuid: شناسه سنسور کاربر — اجباری برای امنیت + kb_name: نام پایگاه دانش (chat/irrigation/fertilization) Returns: لیست نتایج با id, score, text, metadata @@ -31,4 +34,5 @@ def search_with_query( limit=limit, score_threshold=score_threshold, sensor_uuid=sensor_uuid, + kb_name=kb_name, ) diff --git a/rag/services/__init__.py b/rag/services/__init__.py new file mode 100644 index 0000000..1d64c4a --- /dev/null +++ b/rag/services/__init__.py @@ -0,0 +1,11 @@ +""" +سرویس‌های RAG — آبیاری و کودهی +بدون API — قابل استفاده از سایر سرویس‌ها +""" +from .irrigation import get_irrigation_recommendation +from .fertilization import get_fertilization_recommendation + +__all__ = [ + "get_irrigation_recommendation", + "get_fertilization_recommendation", +] diff --git a/rag/services/fertilization.py b/rag/services/fertilization.py new file mode 100644 index 0000000..d5a878f --- /dev/null +++ b/rag/services/fertilization.py @@ -0,0 +1,112 @@ +""" +سرویس توصیه کودهی — بدون API، قابل فراخوانی از سایر سرویس‌ها +از RAG با پایگاه دانش fertilization و لحن مخصوص کودهی استفاده می‌کند. +""" +import json +import logging + +from rag.api_provider import get_chat_client +from rag.chat import build_rag_context, _load_kb_tone +from rag.config import load_rag_config, RAGConfig +from rag.user_data import build_plant_text + +logger = logging.getLogger(__name__) + +KB_NAME = "fertilization" + +DEFAULT_FERTILIZATION_PROMPT = ( + "بر اساس داده‌های خاک (NPK، pH)، مشخصات گیاه، مرحله رشد و پایگاه دانش کودهی، " + "یک توصیه کودهی دقیق بده. " + "پاسخ حتماً به فرمت JSON با فیلدهای زیر باشد:\n" + "fertilizer_needed (bool), fertilizer_type (str), amount_kg_per_hectare (float), " + "reason (str), npk_status (dict با کلیدهای nitrogen, phosphorus, potassium و مقادیر low/normal/high)\n" + "فقط JSON خروجی بده، بدون توضیح اضافی." +) + + +def get_fertilization_recommendation( + sensor_uuid: str, + plant_name: str | None = None, + growth_stage: str | None = None, + query: str | None = None, + config: RAGConfig | None = None, + limit: int = 8, +) -> dict: + """ + توصیه کودهی برای یک سنسور (کاربر). + از RAG با پایگاه دانش fertilization استفاده می‌کند. + + Args: + sensor_uuid: شناسه سنسور کاربر + plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant) + growth_stage: مرحله رشد گیاه + query: سوال اختیاری + config: تنظیمات RAG + limit: تعداد چانک‌های بازیابی‌شده + + Returns: + dict با کلیدهای fertilizer_needed, fertilizer_type, amount_kg_per_hectare, reason, npk_status, raw_response + """ + cfg = config or load_rag_config() + client = get_chat_client(cfg) + model = cfg.llm.model + + user_query = query or "توصیه کودهی برای مزرعه من چیست؟" + + context = build_rag_context( + user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME, + ) + + extra_parts: list[str] = [] + if plant_name and growth_stage: + plant_text = build_plant_text(plant_name, growth_stage) + if plant_text: + extra_parts.append("[اطلاعات گیاه]\n" + plant_text) + if extra_parts: + context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "") + + tone = _load_kb_tone(KB_NAME, cfg) + system_parts = [tone] if tone else [] + system_parts.append(DEFAULT_FERTILIZATION_PROMPT) + if context: + system_parts.append("\n\n" + context) + system_content = "\n".join(system_parts) + + messages = [ + {"role": "system", "content": system_content}, + {"role": "user", "content": user_query}, + ] + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + ) + raw = response.choices[0].message.content.strip() + except Exception as exc: + logger.error("Fertilization recommendation error for %s: %s", sensor_uuid, exc) + return { + "fertilizer_needed": None, + "fertilizer_type": None, + "amount_kg_per_hectare": None, + "reason": f"خطا در دریافت توصیه: {exc}", + "npk_status": None, + "raw_response": None, + } + + try: + cleaned = raw + if cleaned.startswith("```"): + cleaned = cleaned.strip("`").removeprefix("json").strip() + result = json.loads(cleaned) + except (json.JSONDecodeError, ValueError): + result = { + "fertilizer_needed": None, + "fertilizer_type": None, + "amount_kg_per_hectare": None, + "reason": raw, + "npk_status": None, + } + + result["raw_response"] = raw + return result diff --git a/rag/services/irrigation.py b/rag/services/irrigation.py new file mode 100644 index 0000000..dfd1566 --- /dev/null +++ b/rag/services/irrigation.py @@ -0,0 +1,115 @@ +""" +سرویس توصیه آبیاری — بدون API، قابل فراخوانی از سایر سرویس‌ها +از RAG با پایگاه دانش irrigation و لحن مخصوص آبیاری استفاده می‌کند. +""" +import json +import logging + +from rag.api_provider import get_chat_client +from rag.chat import build_rag_context, _load_kb_tone +from rag.config import load_rag_config, RAGConfig +from rag.user_data import build_plant_text, build_irrigation_method_text + +logger = logging.getLogger(__name__) + +KB_NAME = "irrigation" + +DEFAULT_IRRIGATION_PROMPT = ( + "بر اساس داده‌های خاک، هواشناسی، مشخصات گیاه، روش آبیاری و پایگاه دانش آبیاری، " + "یک توصیه آبیاری دقیق بده. " + "پاسخ حتماً به فرمت JSON با فیلدهای زیر باشد:\n" + "irrigation_needed (bool), amount_mm (float), reason (str), next_check_date (str)\n" + "فقط JSON خروجی بده، بدون توضیح اضافی." +) + + +def get_irrigation_recommendation( + sensor_uuid: str, + plant_name: str | None = None, + growth_stage: str | None = None, + irrigation_method_name: str | None = None, + query: str | None = None, + config: RAGConfig | None = None, + limit: int = 8, +) -> dict: + """ + توصیه آبیاری برای یک سنسور (کاربر). + از RAG با پایگاه دانش irrigation استفاده می‌کند. + + Args: + sensor_uuid: شناسه سنسور کاربر + plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant) + growth_stage: مرحله رشد گیاه + irrigation_method_name: نام روش آبیاری (برای بارگذاری از جدول IrrigationMethod) + query: سوال اختیاری + config: تنظیمات RAG + limit: تعداد چانک‌های بازیابی‌شده + + Returns: + dict با کلیدهای irrigation_needed, amount_mm, reason, next_check_date, raw_response + """ + cfg = config or load_rag_config() + client = get_chat_client(cfg) + model = cfg.llm.model + + user_query = query or "توصیه آبیاری برای مزرعه من چیست؟" + + context = build_rag_context( + user_query, sensor_uuid, config=cfg, limit=limit, kb_name=KB_NAME, + ) + + extra_parts: list[str] = [] + if plant_name and growth_stage: + plant_text = build_plant_text(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 method_text: + extra_parts.append("[روش آبیاری انتخابی]\n" + method_text) + if extra_parts: + context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "") + + tone = _load_kb_tone(KB_NAME, cfg) + system_parts = [tone] if tone else [] + system_parts.append(DEFAULT_IRRIGATION_PROMPT) + if context: + system_parts.append("\n\n" + context) + system_content = "\n".join(system_parts) + + messages = [ + {"role": "system", "content": system_content}, + {"role": "user", "content": user_query}, + ] + + try: + response = client.chat.completions.create( + model=model, + messages=messages, + ) + raw = response.choices[0].message.content.strip() + except Exception as exc: + logger.error("Irrigation recommendation error for %s: %s", sensor_uuid, exc) + return { + "irrigation_needed": None, + "amount_mm": None, + "reason": f"خطا در دریافت توصیه: {exc}", + "next_check_date": None, + "raw_response": None, + } + + try: + cleaned = raw + if cleaned.startswith("```"): + cleaned = cleaned.strip("`").removeprefix("json").strip() + result = json.loads(cleaned) + except (json.JSONDecodeError, ValueError): + result = { + "irrigation_needed": None, + "amount_mm": None, + "reason": raw, + "next_check_date": None, + } + + result["raw_response"] = raw + return result diff --git a/rag/tasks.py b/rag/tasks.py index a7cf177..3dbc958 100644 --- a/rag/tasks.py +++ b/rag/tasks.py @@ -15,3 +15,63 @@ def rag_ingest_task(recreate: bool = True): """ result = ingest(recreate=recreate) return result + + +@app.task(bind=True) +def irrigation_recommendation_task( + self, + sensor_uuid: str, + plant_name: str | None = None, + growth_stage: str | None = None, + irrigation_method_name: str | None = None, + query: str | None = None, +) -> dict: + """ + تسک Celery برای تولید توصیه آبیاری. + داده‌های سنسور، گیاه و روش آبیاری را از DB بارگذاری کرده + و از سرویس RAG توصیه می‌گیرد. + """ + from rag.services.irrigation import get_irrigation_recommendation + + self.update_state( + state="PROGRESS", + meta={"message": "در حال پردازش توصیه آبیاری..."}, + ) + result = get_irrigation_recommendation( + sensor_uuid=sensor_uuid, + plant_name=plant_name, + growth_stage=growth_stage, + irrigation_method_name=irrigation_method_name, + query=query, + ) + result["status"] = "completed" + return result + + +@app.task(bind=True) +def fertilization_recommendation_task( + self, + sensor_uuid: str, + plant_name: str | None = None, + growth_stage: str | None = None, + query: str | None = None, +) -> dict: + """ + تسک Celery برای تولید توصیه کودهی. + داده‌های سنسور و گیاه را از DB بارگذاری کرده + و از سرویس RAG توصیه می‌گیرد. + """ + from rag.services.fertilization import get_fertilization_recommendation + + self.update_state( + state="PROGRESS", + meta={"message": "در حال پردازش توصیه کودهی..."}, + ) + result = get_fertilization_recommendation( + sensor_uuid=sensor_uuid, + plant_name=plant_name, + growth_stage=growth_stage, + query=query, + ) + result["status"] = "completed" + return result diff --git a/rag/urls.py b/rag/urls.py index 0058a15..7bce258 100644 --- a/rag/urls.py +++ b/rag/urls.py @@ -1,7 +1,17 @@ from django.urls import path -from .views import ChatView +from .views import ( + ChatView, + IrrigationRecommendationView, + IrrigationRecommendationStatusView, + FertilizationRecommendationView, + FertilizationRecommendationStatusView, +) urlpatterns = [ path("chat/", ChatView.as_view()), + path("recommend/irrigation/", IrrigationRecommendationView.as_view(), name="recommend-irrigation"), + path("recommend/irrigation//status/", IrrigationRecommendationStatusView.as_view(), name="recommend-irrigation-status"), + path("recommend/fertilization/", FertilizationRecommendationView.as_view(), name="recommend-fertilization"), + path("recommend/fertilization//status/", FertilizationRecommendationStatusView.as_view(), name="recommend-fertilization-status"), ] diff --git a/rag/user_data.py b/rag/user_data.py index d184b04..5ba9498 100644 --- a/rag/user_data.py +++ b/rag/user_data.py @@ -1,14 +1,15 @@ """ -ساخت دیتای خاک کاربر از sensor_data و soil_data — Schema-agnostic +ساخت دیتای خاک و هواشناسی کاربر از sensor_data، location_data و weather — Schema-agnostic هر سنسور = یک کاربر. شناسایی با uuid_sensor. مدل‌های Django داخل توابع import می‌شوند تا از AppRegistryNotReady جلوگیری شود. """ +from datetime import date + from django.db.models import Model -# فیلدهایی که در متن embed نباید بیایند (شناسه‌ها، رابطه‌ها) -EXCLUDE_FIELD_NAMES = {"id", "created_at", "updated_at", "task_id", "recorded_at"} +EXCLUDE_FIELD_NAMES = {"id", "created_at", "updated_at", "task_id", "recorded_at", "fetched_at"} def _model_to_data_fields(instance: Model, exclude: set[str] | None = None) -> dict: @@ -43,7 +44,7 @@ def build_user_soil_text(sensor_uuid: str) -> str | None: متن متنی قابل چانک، یا None اگر سنسور یافت نشد. """ from sensor_data.models import SensorData - from soil_data.models import SoilDepthData + from location_data.models import SoilDepthData try: sensor = SensorData.objects.select_related("location").get( @@ -89,7 +90,7 @@ def build_user_soil_text(sensor_uuid: str) -> str | None: if depth_parts: parts.append("داده‌های خاک:\n" + "\n".join(depth_parts)) - return "\n\n".join(parts) if parts else None + return "\n\n".join(parts) if len(parts) > 1 else None def get_all_sensor_uuids() -> list[str]: @@ -102,11 +103,54 @@ def get_all_sensor_uuids() -> list[str]: ] +def build_user_weather_text(sensor_uuid: str) -> str | None: + """ + ساخت متن هواشناسی قابل embed برای یک سنسور (کاربر). + پیش‌بینی ۷ روز آینده از WeatherForecast خوانده می‌شود. + + Returns: + متن فارسی ساختاریافته، یا None اگر داده‌ای نباشد. + """ + from sensor_data.models import SensorData + from weather.models import WeatherForecast + + try: + sensor = SensorData.objects.select_related("location").get( + uuid_sensor=sensor_uuid + ) + except SensorData.DoesNotExist: + return None + + loc = sensor.location + forecasts = ( + WeatherForecast.objects.filter( + location=loc, + forecast_date__gte=date.today(), + ) + .order_by("forecast_date")[:7] + ) + if not forecasts: + return None + + parts: list[str] = [] + parts.append(f"پیش‌بینی هواشناسی سنسور {sensor_uuid} (موقعیت: {loc.latitude}, {loc.longitude})") + + for fc in forecasts: + fc_data = _model_to_data_fields( + fc, exclude={"location", "location_id", "forecast_date"} + ) + lines = [f" {k}: {v}" for k, v in sorted(fc_data.items())] + day_text = f" تاریخ {fc.forecast_date}:\n" + "\n".join(lines) + parts.append(day_text) + + return "\n\n".join(parts) if len(parts) > 1 else None + + def load_user_sources() -> list[tuple[str, str]]: """ - بارگذاری منابع دیتای کاربران از DB. + بارگذاری منابع دیتای کاربران از DB (خاک + هواشناسی). Returns: [(source_id, content), ...] - source_id = user:{sensor_uuid} + source_id = user:{uuid} یا weather:{uuid} """ uuids = get_all_sensor_uuids() sources: list[tuple[str, str]] = [] @@ -114,4 +158,72 @@ def load_user_sources() -> list[tuple[str, str]]: text = build_user_soil_text(str(uid)) if text and text.strip(): sources.append((f"user:{uid}", text)) + weather_text = build_user_weather_text(str(uid)) + if weather_text and weather_text.strip(): + sources.append((f"weather:{uid}", weather_text)) return sources + + +def build_plant_text(plant_name: str, growth_stage: str) -> str | None: + """ + ساخت متن اطلاعات گیاه از جدول Plant برای استفاده در context LLM. + """ + from plant.models import Plant + + plant = Plant.objects.filter(name=plant_name).first() + if not plant: + return None + + lines = [ + f"نام گیاه: {plant.name}", + f"مرحله رشد: {growth_stage}", + ] + if plant.light: + lines.append(f"نور مورد نیاز: {plant.light}") + if plant.watering: + lines.append(f"آبیاری: {plant.watering}") + if plant.soil: + lines.append(f"خاک مناسب: {plant.soil}") + if plant.temperature: + lines.append(f"دمای مناسب: {plant.temperature}") + if plant.planting_season: + lines.append(f"فصل کاشت: {plant.planting_season}") + if plant.harvest_time: + lines.append(f"زمان برداشت: {plant.harvest_time}") + if plant.spacing: + lines.append(f"فاصله کاشت: {plant.spacing}") + if plant.fertilizer: + lines.append(f"کود مناسب: {plant.fertilizer}") + + return "\n".join(lines) + + +def build_irrigation_method_text(method_name: str) -> str | None: + """ + ساخت متن مشخصات روش آبیاری از جدول IrrigationMethod برای استفاده در context LLM. + """ + from irrigation.models import IrrigationMethod + + method = IrrigationMethod.objects.filter(name=method_name).first() + if not method: + return None + + lines = [f"روش آبیاری: {method.name}"] + if method.category: + lines.append(f"دسته‌بندی: {method.category}") + if method.description: + lines.append(f"توضیحات: {method.description}") + if method.water_efficiency_percent is not None: + lines.append(f"راندمان مصرف آب: {method.water_efficiency_percent}%") + if method.water_pressure_required: + lines.append(f"فشار مورد نیاز: {method.water_pressure_required}") + if method.flow_rate: + lines.append(f"دبی جریان: {method.flow_rate}") + if method.coverage_area: + lines.append(f"مساحت پوشش: {method.coverage_area}") + if method.soil_type: + lines.append(f"نوع خاک مناسب: {method.soil_type}") + if method.climate_suitability: + lines.append(f"اقلیم مناسب: {method.climate_suitability}") + + return "\n".join(lines) diff --git a/rag/vector_store.py b/rag/vector_store.py index 01c2259..ddcbc53 100644 --- a/rag/vector_store.py +++ b/rag/vector_store.py @@ -96,27 +96,53 @@ class QdrantVectorStore: limit: int = 5, score_threshold: float | None = None, sensor_uuid: str | None = None, + kb_name: str | None = None, ) -> list[dict]: """ جستجوی شباهت بر اساس query vector. از query_points استفاده می‌کند (qdrant-client >= 2.0). sensor_uuid: اجباری — فقط chunks مربوط به این سنسور یا __global__ برگردانده می‌شود. + kb_name: اختیاری — فیلتر بر اساس پایگاه دانش (chat/irrigation/fertilization). + اگر مشخص شود، فقط chunks همان KB و __all__ برگردانده می‌شود. """ - query_filter = None + must_conditions = [] + if sensor_uuid: - query_filter = qmodels.Filter( - should=[ - qmodels.FieldCondition( - key="sensor_uuid", - match=qmodels.MatchValue(value=sensor_uuid), - ), - qmodels.FieldCondition( - key="sensor_uuid", - match=qmodels.MatchValue(value="__global__"), - ), - ] + must_conditions.append( + qmodels.Filter( + should=[ + qmodels.FieldCondition( + key="sensor_uuid", + match=qmodels.MatchValue(value=sensor_uuid), + ), + qmodels.FieldCondition( + key="sensor_uuid", + match=qmodels.MatchValue(value="__global__"), + ), + ] + ) ) + if kb_name: + must_conditions.append( + qmodels.Filter( + should=[ + qmodels.FieldCondition( + key="kb_name", + match=qmodels.MatchValue(value=kb_name), + ), + qmodels.FieldCondition( + key="kb_name", + match=qmodels.MatchValue(value="__all__"), + ), + ] + ) + ) + + query_filter = None + if must_conditions: + query_filter = qmodels.Filter(must=must_conditions) + response = self.client.query_points( collection_name=self.qdrant.collection_name, query=query_vector, diff --git a/rag/views.py b/rag/views.py index 8c66a51..9d149ee 100644 --- a/rag/views.py +++ b/rag/views.py @@ -2,14 +2,25 @@ ویوهای RAG — چت با استریم """ from django.http import StreamingHttpResponse +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) from rest_framework import status +from rest_framework import serializers as drf_serializers from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +import logging from .chat import chat_rag_stream +logger = logging.getLogger(__name__) + + class ChatView(APIView): """ چت RAG با استریم. @@ -17,11 +28,38 @@ class ChatView(APIView): sensor_uuid اجباری — هر کاربر فقط به دیتای خودش دسترسی دارد. """ + @extend_schema( + tags=["RAG Chat"], + summary="چت RAG با استریم", + description="پیام کاربر را دریافت و پاسخ را به صورت استریم برمی‌گرداند.", + request=inline_serializer( + name="ChatRequest", + fields={ + "message": drf_serializers.CharField(help_text="متن سوال کاربر"), + "sensor_uuid": drf_serializers.CharField(help_text="شناسه یکتای سنسور"), + }, + ), + responses={ + 200: OpenApiResponse( + description="پاسخ استریم متنی (text/plain)", + ), + 400: OpenApiResponse( + description="پارامتر ورودی نامعتبر", + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={"message": "وضعیت خاک من چطوره؟", "sensor_uuid": "550e8400-e29b-41d4-a716-446655440000"}, + request_only=True, + ), + ], + ) def post(self, request: Request): data = request.data if request.method == "POST" else request.query_params message = data.get("message") sensor_uuid = data.get("sensor_uuid") - + logging.info("jhh") if not message or not isinstance(message, str): return Response( {"code": 400, "msg": "پارامتر message الزامی است."}, @@ -44,6 +82,7 @@ class ChatView(APIView): {"code": 400, "msg": "sensor_uuid نباید خالی باشد."}, status=status.HTTP_400_BAD_REQUEST, ) + def generate(): try: @@ -56,3 +95,260 @@ class ChatView(APIView): generate(), content_type="text/plain; charset=utf-8", ) + + +class IrrigationRecommendationView(APIView): + """ + توصیه آبیاری با Celery. + POST با sensor_uuid، plant_name، growth_stage، irrigation_method_name. + تسک در صف قرار می‌گیرد و task_id برگشت داده می‌شود. + """ + + @extend_schema( + tags=["RAG Recommendations"], + summary="درخواست توصیه آبیاری", + description=( + "داده‌های سنسور، گیاه و روش آبیاری را دریافت کرده و یک تسک Celery " + "برای تولید توصیه آبیاری در صف قرار می‌دهد." + ), + request=inline_serializer( + name="IrrigationRecommendationRequest", + fields={ + "sensor_uuid": drf_serializers.CharField(help_text="شناسه یکتای سنسور (اجباری)"), + "plant_name": drf_serializers.CharField(required=False, help_text="نام گیاه"), + "growth_stage": drf_serializers.CharField(required=False, help_text="مرحله رشد گیاه"), + "irrigation_method_name": drf_serializers.CharField(required=False, help_text="نام روش آبیاری"), + "query": drf_serializers.CharField(required=False, help_text="سوال اختیاری"), + }, + ), + responses={ + 202: inline_serializer( + name="IrrigationRecommendationResponse", + fields={ + "code": drf_serializers.IntegerField(), + "msg": drf_serializers.CharField(), + "data": inline_serializer( + name="IrrigationRecommendationData", + fields={ + "task_id": drf_serializers.CharField(), + "status_url": drf_serializers.CharField(), + }, + ), + }, + ), + 400: OpenApiResponse(description="پارامتر ورودی نامعتبر"), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "sensor_uuid": "550e8400-e29b-41d4-a716-446655440000", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "میوه‌دهی", + "irrigation_method_name": "آبیاری قطره‌ای", + }, + request_only=True, + ), + ], + ) + def post(self, request: Request): + from rag.tasks import irrigation_recommendation_task + + sensor_uuid = request.data.get("sensor_uuid") + if not sensor_uuid: + return Response( + {"code": 400, "msg": "پارامتر sensor_uuid الزامی است.", "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + + task = irrigation_recommendation_task.delay( + sensor_uuid=str(sensor_uuid), + plant_name=request.data.get("plant_name"), + growth_stage=request.data.get("growth_stage"), + irrigation_method_name=request.data.get("irrigation_method_name"), + query=request.data.get("query"), + ) + return Response( + { + "code": 202, + "msg": "تسک توصیه آبیاری در صف قرار گرفت.", + "data": { + "task_id": task.id, + "status_url": f"/api/rag/recommend/irrigation/{task.id}/status/", + }, + }, + status=status.HTTP_202_ACCEPTED, + ) + + +class IrrigationRecommendationStatusView(APIView): + """وضعیت تسک توصیه آبیاری.""" + + @extend_schema( + tags=["RAG Recommendations"], + summary="وضعیت تسک توصیه آبیاری", + description="وضعیت تسک Celery توصیه آبیاری را برمی‌گرداند.", + responses={ + 200: inline_serializer( + name="IrrigationRecommendationStatusResponse", + fields={ + "code": drf_serializers.IntegerField(), + "msg": drf_serializers.CharField(), + "data": inline_serializer( + name="IrrigationRecommendationStatusData", + fields={ + "task_id": drf_serializers.CharField(), + "status": drf_serializers.CharField(), + "result": drf_serializers.JSONField(required=False), + "progress": drf_serializers.DictField(required=False), + "error": drf_serializers.CharField(required=False), + }, + ), + }, + ), + }, + ) + 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}, + status=status.HTTP_200_OK, + ) + + +class FertilizationRecommendationView(APIView): + """ + توصیه کودهی با Celery. + POST با sensor_uuid، plant_name، growth_stage. + تسک در صف قرار می‌گیرد و task_id برگشت داده می‌شود. + """ + + @extend_schema( + tags=["RAG Recommendations"], + summary="درخواست توصیه کودهی", + description=( + "داده‌های سنسور و گیاه را دریافت کرده و یک تسک Celery " + "برای تولید توصیه کودهی در صف قرار می‌دهد." + ), + request=inline_serializer( + name="FertilizationRecommendationRequest", + fields={ + "sensor_uuid": drf_serializers.CharField(help_text="شناسه یکتای سنسور (اجباری)"), + "plant_name": drf_serializers.CharField(required=False, help_text="نام گیاه"), + "growth_stage": drf_serializers.CharField(required=False, help_text="مرحله رشد گیاه"), + "query": drf_serializers.CharField(required=False, help_text="سوال اختیاری"), + }, + ), + responses={ + 202: inline_serializer( + name="FertilizationRecommendationResponse", + fields={ + "code": drf_serializers.IntegerField(), + "msg": drf_serializers.CharField(), + "data": inline_serializer( + name="FertilizationRecommendationData", + fields={ + "task_id": drf_serializers.CharField(), + "status_url": drf_serializers.CharField(), + }, + ), + }, + ), + 400: OpenApiResponse(description="پارامتر ورودی نامعتبر"), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "sensor_uuid": "550e8400-e29b-41d4-a716-446655440000", + "plant_name": "گوجه‌فرنگی", + "growth_stage": "رویشی", + }, + request_only=True, + ), + ], + ) + def post(self, request: Request): + from rag.tasks import fertilization_recommendation_task + + sensor_uuid = request.data.get("sensor_uuid") + if not sensor_uuid: + return Response( + {"code": 400, "msg": "پارامتر sensor_uuid الزامی است.", "data": None}, + status=status.HTTP_400_BAD_REQUEST, + ) + + task = fertilization_recommendation_task.delay( + sensor_uuid=str(sensor_uuid), + plant_name=request.data.get("plant_name"), + growth_stage=request.data.get("growth_stage"), + query=request.data.get("query"), + ) + return Response( + { + "code": 202, + "msg": "تسک توصیه کودهی در صف قرار گرفت.", + "data": { + "task_id": task.id, + "status_url": f"/api/rag/recommend/fertilization/{task.id}/status/", + }, + }, + status=status.HTTP_202_ACCEPTED, + ) + + +class FertilizationRecommendationStatusView(APIView): + """وضعیت تسک توصیه کودهی.""" + + @extend_schema( + tags=["RAG Recommendations"], + summary="وضعیت تسک توصیه کودهی", + description="وضعیت تسک Celery توصیه کودهی را برمی‌گرداند.", + responses={ + 200: inline_serializer( + name="FertilizationRecommendationStatusResponse", + fields={ + "code": drf_serializers.IntegerField(), + "msg": drf_serializers.CharField(), + "data": inline_serializer( + name="FertilizationRecommendationStatusData", + fields={ + "task_id": drf_serializers.CharField(), + "status": drf_serializers.CharField(), + "result": drf_serializers.JSONField(required=False), + "progress": drf_serializers.DictField(required=False), + "error": drf_serializers.CharField(required=False), + }, + ), + }, + ), + }, + ) + 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}, + status=status.HTTP_200_OK, + ) diff --git a/requirements.txt b/requirements.txt index e66ab16..71b41fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ djangorestframework-simplejwt>=5.3,<6 django-cors-headers>=4.3,<5 mysqlclient>=2.2,<3 gunicorn>=22,<25 +drf-spectacular>=0.27,<1 +drf-spectacular-sidecar>=2024.7.1 python-dotenv>=1.0,<2 celery[redis]>=5.4,<6 redis>=5.0,<6 diff --git a/sensor_data/admin.py b/sensor_data/admin.py index 761b4da..462e210 100644 --- a/sensor_data/admin.py +++ b/sensor_data/admin.py @@ -19,6 +19,7 @@ class SensorDataAdmin(admin.ModelAdmin): ) list_filter = ("updated_at",) search_fields = ("uuid_sensor", "location_id") + filter_horizontal = ("plants",) @admin.register(SensorDataHistory) diff --git a/sensor_data/migrations/0001_initial.py b/sensor_data/migrations/0001_initial.py index abb16d3..b0334f1 100644 --- a/sensor_data/migrations/0001_initial.py +++ b/sensor_data/migrations/0001_initial.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('soil_data', '0002_soildepthdata_refactor'), + ('location_data', '0002_soildepthdata_refactor'), ] operations = [ @@ -19,7 +19,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid_sensor', models.UUIDField(help_text='شناسه سنسور')), - ('location_id', models.IntegerField(help_text='location_id از soil_data')), + ('location_id', models.IntegerField(help_text='location_id از location_data')), ('soil_moisture', models.FloatField(blank=True, null=True)), ('soil_temperature', models.FloatField(blank=True, null=True)), ('soil_ph', models.FloatField(blank=True, null=True)), @@ -63,7 +63,7 @@ class Migration(migrations.Migration): ('potassium', models.FloatField(blank=True, help_text='پتاسیم', null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('location', models.ForeignKey(db_column='location_id', help_text='همان location_id در soil_data', on_delete=django.db.models.deletion.CASCADE, related_name='sensor_data', to='soil_data.soillocation')), + ('location', models.ForeignKey(db_column='location_id', help_text='همان location_id در location_data', on_delete=django.db.models.deletion.CASCADE, related_name='sensor_data', to='location_data.soillocation')), ], options={ 'verbose_name': 'داده سنسور', diff --git a/sensor_data/migrations/0003_sensordata_plants.py b/sensor_data/migrations/0003_sensordata_plants.py new file mode 100644 index 0000000..f6535b4 --- /dev/null +++ b/sensor_data/migrations/0003_sensordata_plants.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.12 on 2026-03-19 15:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('plant', '0001_initial'), + ('sensor_data', '0002_seed_initial_parameters'), + ] + + operations = [ + migrations.AddField( + model_name='sensordata', + name='plants', + field=models.ManyToManyField(blank=True, help_text='گیاهان مرتبط با این سنسور', related_name='sensor_data', to='plant.plant'), + ), + ] diff --git a/sensor_data/models.py b/sensor_data/models.py index 6ff0f18..b91fe66 100644 --- a/sensor_data/models.py +++ b/sensor_data/models.py @@ -16,11 +16,11 @@ class SensorData(models.Model): help_text="شناسه یکتای سنسور", ) location = models.ForeignKey( - "soil_data.SoilLocation", + "location_data.SoilLocation", on_delete=models.CASCADE, related_name="sensor_data", db_column="location_id", - help_text="همان location_id در soil_data", + help_text="همان location_id از location_data", ) soil_moisture = models.FloatField(null=True, blank=True, help_text="رطوبت خاک") soil_temperature = models.FloatField(null=True, blank=True, help_text="دما خاک") @@ -31,6 +31,12 @@ class SensorData(models.Model): nitrogen = models.FloatField(null=True, blank=True, help_text="ازت (N)") phosphorus = models.FloatField(null=True, blank=True, help_text="فسفر") potassium = models.FloatField(null=True, blank=True, help_text="پتاسیم") + plants = models.ManyToManyField( + "plant.Plant", + blank=True, + related_name="sensor_data", + help_text="گیاهان مرتبط با این سنسور", + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -49,7 +55,7 @@ class SensorDataHistory(models.Model): """ uuid_sensor = models.UUIDField(help_text="شناسه سنسور") - location_id = models.IntegerField(help_text="location_id از soil_data") + location_id = models.IntegerField(help_text="location_id از location_data") soil_moisture = models.FloatField(null=True, blank=True) soil_temperature = models.FloatField(null=True, blank=True) soil_ph = models.FloatField(null=True, blank=True) diff --git a/sensor_data/serializers.py b/sensor_data/serializers.py index 50ac472..a6b1a2a 100644 --- a/sensor_data/serializers.py +++ b/sensor_data/serializers.py @@ -14,11 +14,22 @@ class SensorDataUpdateSerializer(serializers.Serializer): nitrogen = serializers.FloatField(required=False, allow_null=True) phosphorus = serializers.FloatField(required=False, allow_null=True) potassium = serializers.FloatField(required=False, allow_null=True) + plant_ids = serializers.ListField( + child=serializers.IntegerField(), + required=False, + help_text="لیست شناسه گیاهان مرتبط", + ) class SensorDataResponseSerializer(serializers.ModelSerializer): """سریالایزر خروجی برای SensorData.""" + plant_ids = serializers.PrimaryKeyRelatedField( + source="plants", + many=True, + read_only=True, + ) + class Meta: model = SensorData fields = [ @@ -31,6 +42,7 @@ class SensorDataResponseSerializer(serializers.ModelSerializer): "nitrogen", "phosphorus", "potassium", + "plant_ids", "created_at", "updated_at", ] diff --git a/sensor_data/views.py b/sensor_data/views.py index c80e038..3b75682 100644 --- a/sensor_data/views.py +++ b/sensor_data/views.py @@ -1,9 +1,16 @@ from django.db import transaction +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) +from rest_framework import serializers as drf_serializers from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView -from soil_data.models import SoilLocation +from location_data.models import SoilLocation from .models import ParameterUpdateLog, SensorData, SensorDataHistory, SensorParameter from .serializers import ( @@ -18,9 +25,47 @@ class SensorDataUpdateView(APIView): آپدیت داده سنسور. هنگام آپدیت، نسخه فعلی در SensorDataHistory ذخیره می‌شود. """ + @extend_schema( + tags=["Sensor Data"], + summary="آپدیت کامل داده سنسور", + description="داده سنسور را بر اساس uuid_sensor آپدیت (یا ایجاد) می‌کند. نسخه قبلی در تاریخچه ذخیره می‌شود.", + request=SensorDataUpdateSerializer, + responses={ + 200: SensorDataResponseSerializer, + 400: OpenApiResponse(description="داده نامعتبر"), + 404: OpenApiResponse(description="location_id یافت نشد"), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={ + "location_id": 1, + "soil_moisture": 45.2, + "soil_temperature": 22.5, + "soil_ph": 6.8, + "electrical_conductivity": 1.2, + "nitrogen": 30.0, + "phosphorus": 15.0, + "potassium": 20.0, + }, + request_only=True, + ), + ], + ) def put(self, request, uuid_sensor): return self._update(request, uuid_sensor) + @extend_schema( + tags=["Sensor Data"], + summary="آپدیت جزئی داده سنسور", + description="فقط فیلدهای ارسال‌شده آپدیت می‌شوند.", + request=SensorDataUpdateSerializer, + responses={ + 200: SensorDataResponseSerializer, + 400: OpenApiResponse(description="داده نامعتبر"), + 404: OpenApiResponse(description="location_id یافت نشد"), + }, + ) def patch(self, request, uuid_sensor): return self._update(request, uuid_sensor, partial=True) @@ -35,6 +80,7 @@ class SensorDataUpdateView(APIView): ) location_id = serializer.validated_data.pop("location_id") + plant_ids = serializer.validated_data.pop("plant_ids", None) location = SoilLocation.objects.filter(pk=location_id).first() if not location: return Response( @@ -67,6 +113,9 @@ class SensorDataUpdateView(APIView): potassium=sensor_data.potassium, ) + if plant_ids is not None: + sensor_data.plants.set(plant_ids) + return Response( { "code": 200, @@ -82,6 +131,40 @@ class SensorParameterCreateView(APIView): اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog. """ + @extend_schema( + tags=["Sensor Parameters"], + summary="افزودن/ویرایش پارامتر سنسور", + description="پارامتر جدید اضافه یا پارامتر موجود را ویرایش می‌کند و در لاگ ثبت می‌شود.", + request=SensorParameterSerializer, + responses={ + 201: inline_serializer( + name="SensorParameterResponse", + fields={ + "code": drf_serializers.IntegerField(), + "msg": drf_serializers.CharField(), + "data": inline_serializer( + name="SensorParameterData", + fields={ + "id": drf_serializers.IntegerField(), + "code": drf_serializers.CharField(), + "name_fa": drf_serializers.CharField(), + "unit": drf_serializers.CharField(), + "created_at": drf_serializers.DateTimeField(), + "action": drf_serializers.CharField(), + }, + ), + }, + ), + 400: OpenApiResponse(description="داده نامعتبر"), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={"code": "soil_moisture", "name_fa": "رطوبت خاک", "unit": "%"}, + request_only=True, + ), + ], + ) def post(self, request): serializer = SensorParameterSerializer(data=request.data) if not serializer.is_valid(): diff --git a/tasks/views.py b/tasks/views.py index 4abf251..78c6565 100644 --- a/tasks/views.py +++ b/tasks/views.py @@ -1,6 +1,13 @@ from celery.result import AsyncResult +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiResponse, + extend_schema, + inline_serializer, +) from rest_framework import status +from rest_framework import serializers as drf_serializers from rest_framework.response import Response from rest_framework.views import APIView @@ -13,6 +20,46 @@ class TaskTriggerView(APIView): POST با بدنه اختیاری: {"duration": 3} - مدت زمان تسک به ثانیه. """ + @extend_schema( + tags=["Tasks"], + summary="ثبت و اجرای تسک", + description="یک تسک نمونه را در صف Celery قرار می‌دهد.", + request=inline_serializer( + name="TaskTriggerRequest", + fields={ + "duration": drf_serializers.IntegerField( + required=False, default=1, help_text="مدت زمان تسک به ثانیه (۱–۶۰)" + ), + }, + ), + responses={ + 200: inline_serializer( + name="TaskTriggerResponse", + fields={ + "code": drf_serializers.IntegerField(), + "msg": drf_serializers.CharField(), + "data": inline_serializer( + name="TaskTriggerData", + fields={ + "task_id": drf_serializers.CharField(), + }, + ), + }, + ), + }, + examples=[ + OpenApiExample( + "نمونه درخواست", + value={"duration": 3}, + request_only=True, + ), + OpenApiExample( + "نمونه پاسخ", + value={"code": 200, "msg": "success", "data": {"task_id": "abc123-def456"}}, + response_only=True, + ), + ], + ) def post(self, request): duration = request.data.get("duration", 1) try: @@ -33,6 +80,38 @@ class TaskStatusView(APIView): GET /api/tasks//status/ """ + @extend_schema( + tags=["Tasks"], + summary="وضعیت تسک", + description="وضعیت یک تسک Celery را بر اساس task_id برمی‌گرداند.", + responses={ + 200: inline_serializer( + name="TaskStatusResponse", + fields={ + "code": drf_serializers.IntegerField(), + "msg": drf_serializers.CharField(), + "data": inline_serializer( + name="TaskStatusData", + fields={ + "task_id": drf_serializers.CharField(), + "status": drf_serializers.CharField(), + "message": drf_serializers.CharField(required=False), + "progress": drf_serializers.DictField(required=False), + "result": drf_serializers.JSONField(required=False), + "error": drf_serializers.CharField(required=False), + }, + ), + }, + ), + }, + examples=[ + OpenApiExample( + "تسک موفق", + value={"code": 200, "msg": "success", "data": {"task_id": "abc123", "status": "SUCCESS", "result": "done"}}, + response_only=True, + ), + ], + ) def get(self, request, task_id): result = AsyncResult(task_id) state = result.state diff --git a/weather/__init__.py b/weather/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/weather/__init__.py @@ -0,0 +1 @@ + diff --git a/weather/admin.py b/weather/admin.py new file mode 100644 index 0000000..a3fce71 --- /dev/null +++ b/weather/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin + +from .models import WeatherForecast, WeatherParameter + + +@admin.register(WeatherParameter) +class WeatherParameterAdmin(admin.ModelAdmin): + list_display = ("code", "name_fa", "unit", "created_at") + search_fields = ("code", "name_fa") + + +@admin.register(WeatherForecast) +class WeatherForecastAdmin(admin.ModelAdmin): + list_display = ( + "location", + "forecast_date", + "temperature_min", + "temperature_max", + "precipitation", + "et0", + "fetched_at", + ) + list_filter = ("forecast_date",) + search_fields = ("location__latitude", "location__longitude") diff --git a/weather/apps.py b/weather/apps.py new file mode 100644 index 0000000..e9ea881 --- /dev/null +++ b/weather/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class WeatherConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "weather" + verbose_name = "Weather Forecast" diff --git a/weather/management/__init__.py b/weather/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/weather/management/__init__.py @@ -0,0 +1 @@ + diff --git a/weather/management/commands/__init__.py b/weather/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/weather/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/weather/management/commands/seed_weather_parameters.py b/weather/management/commands/seed_weather_parameters.py new file mode 100644 index 0000000..a08062c --- /dev/null +++ b/weather/management/commands/seed_weather_parameters.py @@ -0,0 +1,42 @@ +""" +Management command to seed weather parameters. +Run: python manage.py seed_weather_parameters +""" +from django.core.management.base import BaseCommand + +from weather.models import WeatherParameter + + +INITIAL_PARAMETERS = [ + ("temperature_min", "حداقل دمای هوا", "°C"), + ("temperature_max", "حداکثر دمای هوا", "°C"), + ("temperature_mean", "میانگین دمای هوا", "°C"), + ("precipitation", "مجموع بارش", "mm"), + ("precipitation_probability", "احتمال بارش", "%"), + ("humidity_mean", "میانگین رطوبت نسبی", "%"), + ("wind_speed_max", "حداکثر سرعت باد", "km/h"), + ("et0", "تبخیر-تعرق مرجع (ET₀)", "mm/day"), + ("weather_code", "کد وضعیت آب‌وهوا (WMO)", ""), +] + + +class Command(BaseCommand): + help = "Seed weather parameters (temperature, precipitation, ET0, etc.)" + + def handle(self, *args, **options): + created_count = 0 + for code, name_fa, unit in INITIAL_PARAMETERS: + _, created = WeatherParameter.objects.get_or_create( + code=code, + defaults={"name_fa": name_fa, "unit": unit}, + ) + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS(f" Created: {code} ({name_fa})") + ) + self.stdout.write( + self.style.SUCCESS( + f"\nDone. Created {created_count} new weather parameters." + ) + ) diff --git a/weather/migrations/0001_initial.py b/weather/migrations/0001_initial.py new file mode 100644 index 0000000..0b955da --- /dev/null +++ b/weather/migrations/0001_initial.py @@ -0,0 +1,184 @@ +# Generated manually for weather + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("location_data", "0002_soildepthdata_refactor"), + ] + + operations = [ + # ── WeatherParameter ── + migrations.CreateModel( + name="WeatherParameter", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "code", + models.CharField( + db_index=True, + help_text="کد یکتا (مثلاً temperature_max)", + max_length=64, + unique=True, + ), + ), + ( + "name_fa", + models.CharField( + help_text="نام فارسی", + max_length=128, + ), + ), + ( + "unit", + models.CharField( + blank=True, + help_text="واحد اندازه‌گیری", + max_length=32, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "ordering": ["code"], + "verbose_name": "پارامتر هواشناسی", + "verbose_name_plural": "پارامترهای هواشناسی", + }, + ), + # ── WeatherForecast ── + migrations.CreateModel( + name="WeatherForecast", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "location", + models.ForeignKey( + help_text="موقعیت مکانی مرتبط از جدول SoilLocation", + on_delete=django.db.models.deletion.CASCADE, + related_name="weather_forecasts", + to="location_data.soillocation", + ), + ), + ( + "forecast_date", + models.DateField( + db_index=True, + help_text="تاریخ پیش‌بینی", + ), + ), + ( + "temperature_min", + models.FloatField( + blank=True, + help_text="حداقل دمای هوا (°C)", + null=True, + ), + ), + ( + "temperature_max", + models.FloatField( + blank=True, + help_text="حداکثر دمای هوا (°C)", + null=True, + ), + ), + ( + "temperature_mean", + models.FloatField( + blank=True, + help_text="میانگین دمای هوا (°C)", + null=True, + ), + ), + ( + "precipitation", + models.FloatField( + blank=True, + help_text="مجموع بارش (mm)", + null=True, + ), + ), + ( + "precipitation_probability", + models.FloatField( + blank=True, + help_text="احتمال بارش (%)", + null=True, + ), + ), + ( + "humidity_mean", + models.FloatField( + blank=True, + help_text="میانگین رطوبت نسبی (%)", + null=True, + ), + ), + ( + "wind_speed_max", + models.FloatField( + blank=True, + help_text="حداکثر سرعت باد (km/h)", + null=True, + ), + ), + ( + "et0", + models.FloatField( + blank=True, + help_text="تبخیر-تعرق مرجع (ET₀) — mm/day", + null=True, + ), + ), + ( + "weather_code", + models.IntegerField( + blank=True, + help_text="کد وضعیت آب‌وهوا (WMO code)", + null=True, + ), + ), + ( + "fetched_at", + models.DateTimeField( + auto_now=True, + help_text="آخرین زمان واکشی از API", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + options={ + "ordering": ["location", "forecast_date"], + "verbose_name": "پیش‌بینی هواشناسی", + "verbose_name_plural": "پیش‌بینی‌های هواشناسی", + }, + ), + migrations.AddConstraint( + model_name="weatherforecast", + constraint=models.UniqueConstraint( + fields=("location", "forecast_date"), + name="weather_unique_location_date", + ), + ), + ] diff --git a/weather/migrations/0002_seed_weather_parameters.py b/weather/migrations/0002_seed_weather_parameters.py new file mode 100644 index 0000000..9a2b1fe --- /dev/null +++ b/weather/migrations/0002_seed_weather_parameters.py @@ -0,0 +1,42 @@ +# Seed migration: populate initial weather parameters. + +from django.db import migrations + + +INITIAL_PARAMETERS = [ + ("temperature_min", "حداقل دمای هوا", "°C"), + ("temperature_max", "حداکثر دمای هوا", "°C"), + ("temperature_mean", "میانگین دمای هوا", "°C"), + ("precipitation", "مجموع بارش", "mm"), + ("precipitation_probability", "احتمال بارش", "%"), + ("humidity_mean", "میانگین رطوبت نسبی", "%"), + ("wind_speed_max", "حداکثر سرعت باد", "km/h"), + ("et0", "تبخیر-تعرق مرجع (ET₀)", "mm/day"), + ("weather_code", "کد وضعیت آب‌وهوا (WMO)", ""), +] + + +def seed_parameters(apps, schema_editor): + WeatherParameter = apps.get_model("weather", "WeatherParameter") + for code, name_fa, unit in INITIAL_PARAMETERS: + WeatherParameter.objects.get_or_create( + code=code, + defaults={"name_fa": name_fa, "unit": unit}, + ) + + +def unseed_parameters(apps, schema_editor): + WeatherParameter = apps.get_model("weather", "WeatherParameter") + codes = [p[0] for p in INITIAL_PARAMETERS] + WeatherParameter.objects.filter(code__in=codes).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("weather", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_parameters, unseed_parameters), + ] diff --git a/weather/migrations/0003_seed_weather_forecasts.py b/weather/migrations/0003_seed_weather_forecasts.py new file mode 100644 index 0000000..2353b95 --- /dev/null +++ b/weather/migrations/0003_seed_weather_forecasts.py @@ -0,0 +1,137 @@ +# Seed migration: populate sample 7-day weather forecasts for existing SoilLocations. + +from datetime import timedelta + +from django.db import migrations +from django.utils import timezone + + +SAMPLE_DAILY_DATA = [ + { + "day_offset": 0, + "temperature_min": 18.5, + "temperature_max": 33.2, + "temperature_mean": 25.8, + "precipitation": 0.0, + "precipitation_probability": 5.0, + "humidity_mean": 28.0, + "wind_speed_max": 12.0, + "et0": 6.8, + "weather_code": 0, + }, + { + "day_offset": 1, + "temperature_min": 19.0, + "temperature_max": 34.5, + "temperature_mean": 26.7, + "precipitation": 0.0, + "precipitation_probability": 10.0, + "humidity_mean": 30.0, + "wind_speed_max": 14.0, + "et0": 7.1, + "weather_code": 1, + }, + { + "day_offset": 2, + "temperature_min": 20.2, + "temperature_max": 32.0, + "temperature_mean": 26.1, + "precipitation": 3.5, + "precipitation_probability": 65.0, + "humidity_mean": 52.0, + "wind_speed_max": 18.0, + "et0": 5.2, + "weather_code": 61, + }, + { + "day_offset": 3, + "temperature_min": 17.8, + "temperature_max": 28.5, + "temperature_mean": 23.1, + "precipitation": 12.0, + "precipitation_probability": 85.0, + "humidity_mean": 70.0, + "wind_speed_max": 22.0, + "et0": 3.8, + "weather_code": 63, + }, + { + "day_offset": 4, + "temperature_min": 16.5, + "temperature_max": 27.0, + "temperature_mean": 21.7, + "precipitation": 5.0, + "precipitation_probability": 55.0, + "humidity_mean": 60.0, + "wind_speed_max": 16.0, + "et0": 4.5, + "weather_code": 61, + }, + { + "day_offset": 5, + "temperature_min": 18.0, + "temperature_max": 31.0, + "temperature_mean": 24.5, + "precipitation": 0.0, + "precipitation_probability": 8.0, + "humidity_mean": 35.0, + "wind_speed_max": 10.0, + "et0": 6.2, + "weather_code": 2, + }, + { + "day_offset": 6, + "temperature_min": 19.5, + "temperature_max": 34.0, + "temperature_mean": 26.7, + "precipitation": 0.0, + "precipitation_probability": 3.0, + "humidity_mean": 25.0, + "wind_speed_max": 8.0, + "et0": 7.0, + "weather_code": 0, + }, +] + + +def seed_forecasts(apps, schema_editor): + SoilLocation = apps.get_model("location_data", "SoilLocation") + WeatherForecast = apps.get_model("weather", "WeatherForecast") + + today = timezone.now().date() + + for location in SoilLocation.objects.all(): + for daily in SAMPLE_DAILY_DATA: + forecast_date = today + timedelta(days=daily["day_offset"]) + WeatherForecast.objects.get_or_create( + location=location, + forecast_date=forecast_date, + defaults={ + "temperature_min": daily["temperature_min"], + "temperature_max": daily["temperature_max"], + "temperature_mean": daily["temperature_mean"], + "precipitation": daily["precipitation"], + "precipitation_probability": daily["precipitation_probability"], + "humidity_mean": daily["humidity_mean"], + "wind_speed_max": daily["wind_speed_max"], + "et0": daily["et0"], + "weather_code": daily["weather_code"], + }, + ) + + +def unseed_forecasts(apps, schema_editor): + WeatherForecast = apps.get_model("weather", "WeatherForecast") + WeatherForecast.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("weather", "0002_seed_weather_parameters"), + ("location_data", "0002_soildepthdata_refactor"), + ] + + operations = [ + migrations.RunPython(seed_forecasts, unseed_forecasts), + ] diff --git a/weather/migrations/__init__.py b/weather/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/weather/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/weather/models.py b/weather/models.py new file mode 100644 index 0000000..dcf09d0 --- /dev/null +++ b/weather/models.py @@ -0,0 +1,107 @@ +from django.db import models + + +class WeatherParameter(models.Model): + """ + تعریف پارامترهای هواشناسی (مثلاً دما، بارش، تبخیر-تعرق، ...). + """ + + code = models.CharField( + max_length=64, + unique=True, + db_index=True, + help_text="کد یکتا (مثلاً temperature_max)", + ) + name_fa = models.CharField(max_length=128, help_text="نام فارسی") + unit = models.CharField(max_length=32, blank=True, help_text="واحد اندازه‌گیری") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["code"] + verbose_name = "پارامتر هواشناسی" + verbose_name_plural = "پارامترهای هواشناسی" + + def __str__(self): + return f"{self.code} ({self.name_fa})" + + +class WeatherForecast(models.Model): + """ + پیش‌بینی هواشناسی روزانه (تا ۷ روز آینده) برای یک SoilLocation. + داده‌ها شامل دما، بارش، رطوبت، باد و تبخیر-تعرق مرجع (ET0) هستند. + """ + + location = models.ForeignKey( + "location_data.SoilLocation", + on_delete=models.CASCADE, + related_name="weather_forecasts", + help_text="موقعیت مکانی مرتبط از جدول SoilLocation", + ) + forecast_date = models.DateField( + db_index=True, + help_text="تاریخ پیش‌بینی", + ) + + temperature_min = models.FloatField( + null=True, blank=True, help_text="حداقل دمای هوا (°C)" + ) + temperature_max = models.FloatField( + null=True, blank=True, help_text="حداکثر دمای هوا (°C)" + ) + temperature_mean = models.FloatField( + null=True, blank=True, help_text="میانگین دمای هوا (°C)" + ) + + precipitation = models.FloatField( + null=True, blank=True, help_text="مجموع بارش (mm)" + ) + precipitation_probability = models.FloatField( + null=True, blank=True, help_text="احتمال بارش (%)" + ) + + humidity_mean = models.FloatField( + null=True, blank=True, help_text="میانگین رطوبت نسبی (%)" + ) + + wind_speed_max = models.FloatField( + null=True, blank=True, help_text="حداکثر سرعت باد (km/h)" + ) + + et0 = models.FloatField( + null=True, + blank=True, + help_text="تبخیر-تعرق مرجع (ET₀) — mm/day", + ) + + weather_code = models.IntegerField( + null=True, + blank=True, + help_text="کد وضعیت آب‌وهوا (WMO code)", + ) + + fetched_at = models.DateTimeField( + auto_now=True, + help_text="آخرین زمان واکشی از API", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["location", "forecast_date"], + name="weather_unique_location_date", + ) + ] + ordering = ["location", "forecast_date"] + verbose_name = "پیش‌بینی هواشناسی" + verbose_name_plural = "پیش‌بینی‌های هواشناسی" + + def __str__(self): + return f"WeatherForecast({self.location_id}, {self.forecast_date})" + + @property + def will_rain(self): + """آیا بارندگی پیش‌بینی شده است؟""" + if self.precipitation is not None: + return self.precipitation > 0.0 + return None diff --git a/weather/services.py b/weather/services.py new file mode 100644 index 0000000..741e823 --- /dev/null +++ b/weather/services.py @@ -0,0 +1,224 @@ +""" +سرویس‌های هواشناسی — واکشی پیش‌بینی ۷ روزه و ذخیره در دیتابیس. +""" + +import logging +from datetime import date, timedelta + +from django.conf import settings +from django.db import transaction + +from location_data.models import SoilLocation + +from .models import WeatherForecast + +logger = logging.getLogger(__name__) + + +def fetch_weather_from_api(latitude: float, longitude: float) -> dict | None: + """ + اتصال به API هواشناسی و دریافت پیش‌بینی ۷ روزه. + + TODO: پیاده‌سازی اتصال واقعی به API (مثلاً Open-Meteo). + در حال حاضر این تابع خالی است و None برمی‌گرداند. + + پارامترها: + latitude: عرض جغرافیایی + longitude: طول جغرافیایی + + خروجی مورد انتظار (وقتی پیاده‌سازی شود): + { + "daily": { + "time": ["2025-07-01", "2025-07-02", ...], + "temperature_2m_max": [35.2, 36.1, ...], + "temperature_2m_min": [22.1, 23.0, ...], + "temperature_2m_mean": [28.6, 29.5, ...], + "precipitation_sum": [0.0, 2.5, ...], + "precipitation_probability_max": [0, 60, ...], + "relative_humidity_2m_mean": [30.0, 45.0, ...], + "wind_speed_10m_max": [15.0, 20.0, ...], + "et0_fao_evapotranspiration": [6.5, 5.8, ...], + "weather_code": [0, 61, ...], + } + } + """ + # TODO: اتصال واقعی به API هواشناسی + # api_url = settings.WEATHER_API_BASE_URL + # api_key = settings.WEATHER_API_KEY + return None + + +def parse_weather_response(data: dict) -> list[dict]: + """ + تبدیل پاسخ API به لیست dict برای ذخیره در WeatherForecast. + فرمت ورودی: Open-Meteo daily format. + """ + daily = data.get("daily", {}) + times = daily.get("time", []) + forecasts = [] + + for i, date_str in enumerate(times): + forecasts.append( + { + "forecast_date": date_str, + "temperature_max": _safe_index( + daily.get("temperature_2m_max"), i + ), + "temperature_min": _safe_index( + daily.get("temperature_2m_min"), i + ), + "temperature_mean": _safe_index( + daily.get("temperature_2m_mean"), i + ), + "precipitation": _safe_index( + daily.get("precipitation_sum"), i + ), + "precipitation_probability": _safe_index( + daily.get("precipitation_probability_max"), i + ), + "humidity_mean": _safe_index( + daily.get("relative_humidity_2m_mean"), i + ), + "wind_speed_max": _safe_index( + daily.get("wind_speed_10m_max"), i + ), + "et0": _safe_index( + daily.get("et0_fao_evapotranspiration"), i + ), + "weather_code": _safe_index( + daily.get("weather_code"), i + ), + } + ) + return forecasts + + +def _safe_index(lst: list | None, index: int): + """مقدار index را از لیست برمی‌گرداند یا None.""" + if lst is None or index >= len(lst): + return None + return lst[index] + + +def update_weather_for_location(location: SoilLocation) -> dict: + """ + واکشی و ذخیره پیش‌بینی هواشناسی ۷ روزه برای یک SoilLocation. + + خروجی: + {"status": "success"|"no_data"|"error", "location_id": int, ...} + """ + lat = float(location.latitude) + lon = float(location.longitude) + + try: + data = fetch_weather_from_api(lat, lon) + except Exception as exc: + logger.error("Weather API error for location %s: %s", location.id, exc) + return { + "status": "error", + "location_id": location.id, + "error": str(exc), + } + + if data is None: + logger.info( + "Weather API returned no data for location %s (stub mode).", + location.id, + ) + return { + "status": "no_data", + "location_id": location.id, + "message": "API connection not implemented yet.", + } + + forecasts = parse_weather_response(data) + + with transaction.atomic(): + for fc in forecasts: + WeatherForecast.objects.update_or_create( + location=location, + forecast_date=fc.pop("forecast_date"), + defaults=fc, + ) + + return { + "status": "success", + "location_id": location.id, + "days_updated": len(forecasts), + } + + +def update_weather_for_all_locations() -> list[dict]: + """ + واکشی پیش‌بینی هواشناسی برای تمام SoilLocation‌های موجود. + """ + results = [] + for location in SoilLocation.objects.all(): + result = update_weather_for_location(location) + results.append(result) + return results + + +def get_forecast_for_location( + location: SoilLocation, + days: int = 7, +) -> list[WeatherForecast]: + """ + دریافت پیش‌بینی‌های ذخیره‌شده برای یک location (تا N روز آینده). + """ + today = date.today() + end_date = today + timedelta(days=days) + return list( + WeatherForecast.objects.filter( + location=location, + forecast_date__gte=today, + forecast_date__lte=end_date, + ).order_by("forecast_date") + ) + + +def should_irrigate_today(location: SoilLocation) -> dict: + """ + بررسی ساده: آیا فردا باران می‌بارد؟ + اگر بارش فردا بیشتر از آستانه باشد → آبیاری لازم نیست. + + خروجی: + { + "needs_irrigation": bool | None, + "tomorrow_precipitation": float | None, + "tomorrow_date": str, + "reason": str, + } + """ + tomorrow = date.today() + timedelta(days=1) + forecast = WeatherForecast.objects.filter( + location=location, + forecast_date=tomorrow, + ).first() + + if forecast is None: + return { + "needs_irrigation": None, + "tomorrow_precipitation": None, + "tomorrow_date": str(tomorrow), + "reason": "داده پیش‌بینی فردا موجود نیست.", + } + + rain_threshold_mm = 2.0 + if forecast.precipitation is not None and forecast.precipitation >= rain_threshold_mm: + return { + "needs_irrigation": False, + "tomorrow_precipitation": forecast.precipitation, + "tomorrow_date": str(tomorrow), + "reason": ( + f"فردا {forecast.precipitation} mm بارش پیش‌بینی شده — " + "نیاز به آبیاری نیست." + ), + } + + return { + "needs_irrigation": True, + "tomorrow_precipitation": forecast.precipitation, + "tomorrow_date": str(tomorrow), + "reason": "بارش فردا ناچیز یا صفر — آبیاری توصیه می‌شود.", + } diff --git a/weather/tasks.py b/weather/tasks.py new file mode 100644 index 0000000..59c002e --- /dev/null +++ b/weather/tasks.py @@ -0,0 +1,34 @@ +""" +تسک‌های Celery برای واکشی داده‌های هواشناسی. +""" + +from config.celery import app + +from location_data.models import SoilLocation + +from .services import update_weather_for_location, update_weather_for_all_locations + + +@app.task(bind=True) +def fetch_weather_task(self, location_id: int): + """ + واکشی پیش‌بینی هواشناسی ۷ روزه برای یک location مشخص. + """ + try: + location = SoilLocation.objects.get(pk=location_id) + except SoilLocation.DoesNotExist: + return { + "status": "error", + "error": f"SoilLocation with id={location_id} not found.", + } + + return update_weather_for_location(location) + + +@app.task(bind=True) +def fetch_weather_all_locations_task(self): + """ + واکشی پیش‌بینی هواشناسی برای تمام location‌ها. + مناسب برای Celery Beat (مثلاً هر ۶ ساعت). + """ + return update_weather_for_all_locations()