first commit

This commit is contained in:
2026-03-19 22:54:29 +03:30
parent 1a178f39b7
commit 035bc6f74d
91 changed files with 3821 additions and 130 deletions
+20 -3
View File
@@ -1,10 +1,18 @@
FROM python:3.12-slim FROM mirror2.chabokan.net/python
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
WORKDIR /app 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) # System deps for MySQL client (pkg-config required by mysqlclient to find libs)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
default-libmysqlclient-dev \ default-libmysqlclient-dev \
@@ -13,11 +21,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . 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 EXPOSE 8000
ENTRYPOINT ["sh", "/app/entrypoint.sh"] ENTRYPOINT ["sh", "/app/entrypoint.sh"]
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"] CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
+3
View File
@@ -0,0 +1,3 @@
# پایگاه دانش CropLogic
فایل‌های `.txt` و `.md` این پوشه به‌صورت خودکار embed و به Qdrant اضافه می‌شوند.
@@ -0,0 +1,19 @@
# دانش پایه خاک برای کشاورزی
## انواع خاک
خاک‌ها بر اساس بافت (نسبت رس، سیلت و شن) دسته‌بندی می‌شوند. خاک رسی زهکشی ضعیف‌تری دارد و خاک شنی زهکشی سریع. خاک لومی ترکیبی متعادل از هر سه است و برای اغلب گیاهان مناسب است.
## pH خاک
مقیاس pH از ۰ تا ۱۴ است؛ مقدار ۷ خنثی است. خاک‌های اسیدی (زیر ۷) و قلیایی (بالای ۷) بر جذب عناصر غذایی تأثیر می‌گذارند. بیشتر گیاهان زراعی pH حدود ۶ تا ۷.۵ را ترجیح می‌دهند.
## رطوبت خاک
رطوبت خاک بر رشد ریشه و جذب آب و مواد غذایی تأثیر مستقیم دارد. رطوبت بیش از حد باعث خفگی ریشه و کمبود اکسیژن می‌شود؛ رطوبت کم باعث تنش آبی و کاهش عملکرد می‌شود.
## NPK و عناصر غذایی
نیتروژن (N) برای رشد سبزینه و برگ‌ها ضروری است. فسفر (P) برای ریشه‌زایی و گلدهی مهم است. پتاسیم (K) مقاومت به خشکی و بیماری را افزایش می‌دهد. مقادیر این عناصر در خاک با آزمون خاک قابل اندازه‌گیری است.
## هدایت الکتریکی (EC)
EC نشان‌دهنده شوری خاک است. EC بالا یعنی نمک زیاد و می‌تواند به ریشه گیاه آسیب برساند. واحد آن معمولاً dS/m یا mS/cm است.
## عمق خاک
داده‌های خاک معمولاً در اعماق ۰–۵، ۵–۱۵ و ۱۵–۳۰ سانتی‌متر اندازه‌گیری می‌شوند. لایه سطحی برای جوانه‌زنی و ریشه‌های سطحی مهم است؛ لایه‌های عمیق‌تر برای گیاهان ریشه‌عمیق اهمیت دارند.
@@ -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 باشد، کودهی باید با احتیاط انجام شود.
@@ -0,0 +1,18 @@
# دانش پایه آبیاری
## تبخیر-تعرق مرجع (ET0)
ET0 نشان‌دهنده میزان آب مورد نیاز گیاه مرجع (چمن) در یک روز است. واحد آن mm/day است.
ET0 بالا یعنی هوا گرم و خشک است و گیاه آب بیشتری نیاز دارد.
## رابطه بارش و آبیاری
اگر بارش پیش‌بینی شده از ET0 بیشتر باشد، معمولاً آبیاری لازم نیست.
بارش مؤثر حدود ۷۰-۸۰ درصد بارش واقعی است (بخشی تبخیر و رواناب می‌شود).
## رطوبت خاک
رطوبت مناسب خاک بسته به نوع خاک و محصول متفاوت است.
خاک رسی رطوبت بیشتری نگه می‌دارد. خاک شنی سریع‌تر خشک می‌شود.
آبیاری باید وقتی انجام شود که رطوبت خاک به حد بحرانی (MAD) رسیده باشد.
## دمای هوا و آبیاری
در دماهای بالای ۳۵ درجه، تبخیر سطحی زیاد است و آبیاری صبح زود یا عصر توصیه می‌شود.
در دماهای زیر ۵ درجه، آبیاری ممکن است به ریشه آسیب بزند.
+26 -7
View File
@@ -1,11 +1,14 @@
# تنظیمات RAG برای پایگاه دانش CropLogic # تنظیمات RAG برای پایگاه دانش CropLogic
embedding: embedding:
provider: "avalai" # Avalai API (OpenAI-compatible) provider: "gapgpt" # gapgpt یا avalai
model: "text-embedding-3-small" model: "text-embedding-3-small"
base_url: "https://api.avalai.ir/v1" base_url: "https://api.gapgpt.app/v1"
api_key_env: "AVALAI_API_KEY" api_key_env: "GAPGPT_API_KEY"
batch_size: 32 batch_size: 32
# تنظیمات Avalai (برای fallback)
avalai_base_url: "https://api.avalai.ir/v1"
avalai_api_key_env: "AVALAI_API_KEY"
# فاز یک: Qdrant به‌عنوان vector store # فاز یک: Qdrant به‌عنوان vector store
qdrant: qdrant:
@@ -21,8 +24,24 @@ chunking:
# تنظیمات مدل چت (LLM) — Avalai # تنظیمات مدل چت (LLM) — Avalai
llm: llm:
model: "gpt-4o" model: "gpt-4o"
base_url: "https://api.avalai.ir/v1" base_url: "https://api.gapgpt.app/v1"
api_key_env: "AVALAI_API_KEY" 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: "پایگاه دانش توصیه کودهی"
+77 -1
View File
@@ -6,6 +6,8 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent 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") SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only")
DEBUG = os.environ.get("DEBUG", "0") == "1" DEBUG = os.environ.get("DEBUG", "0") == "1"
@@ -20,10 +22,15 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
"rest_framework", "rest_framework",
"corsheaders", "corsheaders",
"drf_spectacular",
"drf_spectacular_sidecar",
"rag", "rag",
"tasks", "tasks",
"soil_data", "location_data",
"sensor_data", "sensor_data",
"weather",
"plant",
"irrigation",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -98,6 +105,25 @@ REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [ "DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.AllowAny", "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 CORS_ALLOW_ALL_ORIGINS = DEBUG
@@ -114,4 +140,54 @@ CELERY_BEAT_SCHEDULE = {
"task": "rag.tasks.rag_ingest_task", "task": "rag.tasks.rag_ingest_task",
"schedule": 6 * 60 * 60, # ۶ ساعت "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"),
},
} }
+7
View File
@@ -0,0 +1,7 @@
# فایل لحن / سبک پاسخ‌های RAG
لحن و سبک پاسخ‌ها:
- سطح: دوستانه و تخصصی؛ با کشاورز به زبان ساده و علمی صحبت کن.
- واژگان: از اصطلاحات رایج کشاورزی و خاک‌شناسی استفاده کن، در صورت نیاز معادل فارسی بیاور.
- طول: پاسخ‌ها مختصر و کاربردی؛ در صورت لزوم با بولت یا شماره ساختاربندی کن.
- هشدار: اگر موضوع ایمنی یا سلامتی گیاه/خاک باشد، صریحاً هشدار بده.
+8
View File
@@ -0,0 +1,8 @@
# لحن توصیه کودهی
سبک پاسخ:
- تخصصی و دقیق: نوع کود، مقدار و زمان مصرف را مشخص کن
- بر اساس داده‌های NPK خاک، pH، و نوع محصول
- فرمت خروجی: JSON با فیلدهای fertilizer_needed (bool), fertilizer_type (str), amount_kg_per_hectare (float), reason (str), npk_status (dict)
- اگر سطح NPK خاک مناسب باشد، کودهی لازم نیست
- هشدارهای مهم درباره مصرف بیش از حد کود را ذکر کن
+8
View File
@@ -0,0 +1,8 @@
# لحن توصیه آبیاری
سبک پاسخ:
- مستقیم و عملیاتی: زمان، مقدار و روش آبیاری را مشخص کن
- بر اساس داده‌های هواشناسی (بارش، ET0، دما) و رطوبت خاک
- فرمت خروجی: JSON با فیلدهای irrigation_needed (bool), amount_mm (float), reason (str), next_check_date (str)
- اگر بارش پیش‌بینی شده باشد، آبیاری را به تعویق بینداز
- اگر رطوبت خاک کافی است، آبیاری لازم نیست
+13 -1
View File
@@ -1,10 +1,22 @@
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
SpectacularSwaggerView,
)
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), 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/rag/", include("rag.urls")),
path("api/tasks/", include("tasks.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/sensor-data/", include("sensor_data.urls")),
path("api/plants/", include("plant.urls")),
path("api/irrigation/", include("irrigation.urls")),
] ]
+4
View File
@@ -69,6 +69,8 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "8020:8000" - "8020:8000"
volumes:
- ./logs:/app/logs
celery: celery:
build: . build: .
@@ -86,6 +88,8 @@ services:
redis: redis:
condition: service_started condition: service_started
restart: unless-stopped restart: unless-stopped
volumes:
- ./logs:/app/logs
volumes: volumes:
ai_mysql_data: ai_mysql_data:
+11 -3
View File
@@ -19,7 +19,7 @@ services:
retries: 5 retries: 5
phpmyadmin: phpmyadmin:
image: phpmyadmin:latest image: docker-mirror.liara.ir/phpmyadmin:latest
container_name: ai-phpmyadmin container_name: ai-phpmyadmin
environment: environment:
PMA_HOST: db PMA_HOST: db
@@ -48,11 +48,15 @@ services:
restart: unless-stopped restart: unless-stopped
web: web:
build: . build:
context: .
args:
APT_MIRROR: mirror2.chabokan.net
container_name: ai-web container_name: ai-web
command: ["python", "manage.py", "runserver", "0.0.0.0:8000"] command: ["python", "manage.py", "runserver", "0.0.0.0:8000"]
volumes: volumes:
- .:/app - .:/app
- ./logs:/app/logs
ports: ports:
- "8020:8000" - "8020:8000"
env_file: env_file:
@@ -72,11 +76,15 @@ services:
condition: service_started condition: service_started
celery: celery:
build: . build:
context: .
args:
APT_MIRROR: mirror.cdn.ir
container_name: ai-celery container_name: ai-celery
command: celery -A config worker -l info command: celery -A config worker -l info
volumes: volumes:
- .:/app - .:/app
- ./logs:/app/logs
env_file: env_file:
- .env - .env
environment: environment:
+1
View File
@@ -0,0 +1 @@
+19
View File
@@ -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")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class IrrigationConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "irrigation"
verbose_name = "Irrigation"
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -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."
)
)
+36
View File
@@ -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'],
},
),
]
+1
View File
@@ -0,0 +1 @@
+63
View File
@@ -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
+25
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .views import IrrigationMethodDetailView, IrrigationMethodListCreateView
urlpatterns = [
path("", IrrigationMethodListCreateView.as_view(), name="irrigation-list-create"),
path("<int:pk>/", IrrigationMethodDetailView.as_view(), name="irrigation-detail"),
]
+180
View File
@@ -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,
)
+1 -1
View File
@@ -3,5 +3,5 @@ from django.apps import AppConfig
class SoilDataConfig(AppConfig): class SoilDataConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "soil_data" name = "location_data"
verbose_name = "Soil Data (SoilGrids)" verbose_name = "Soil Data (SoilGrids)"
@@ -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}"
)
)
@@ -1,4 +1,4 @@
# Generated manually for soil_data # Generated manually for location_data
from django.db import migrations, models from django.db import migrations, models
@@ -7,7 +7,7 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("soil_data", "0001_initial"), ("location_data", "0001_initial"),
] ]
operations = [ operations = [
@@ -47,7 +47,7 @@ class Migration(migrations.Migration):
models.ForeignKey( models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
related_name="depths", related_name="depths",
to="soil_data.soillocation", to="location_data.soillocation",
), ),
), ),
], ],
@@ -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 = []
@@ -1,4 +1,11 @@
from rest_framework import status 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.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -21,9 +28,53 @@ class SoilDataView(APIView):
def _get_request_data(self, request): def _get_request_data(self, request):
return request.data if request.method == "POST" else request.query_params 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): def get(self, request):
return self._process(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): def post(self, request):
return self._process(request) return self._process(request)
@@ -85,6 +136,31 @@ class SoilDataView(APIView):
class SoilDataTaskStatusView(APIView): class SoilDataTaskStatusView(APIView):
"""وضعیت تسک واکشی خاک. در صورت SUCCESS لیست اطلاعات هر سه عمق برگردانده می‌شود.""" """وضعیت تسک واکشی خاک. در صورت 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): def get(self, request, task_id):
from celery.result import AsyncResult from celery.result import AsyncResult
+67
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+19
View File
@@ -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")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PlantConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "plant"
verbose_name = "Plant"
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+109
View File
@@ -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.")
)
+36
View File
@@ -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'],
},
),
]
+1
View File
@@ -0,0 +1 @@
+64
View File
@@ -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
+25
View File
@@ -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"]
+34
View File
@@ -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
+9
View File
@@ -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("<int:pk>/", PlantDetailView.as_view(), name="plant-detail"),
path("fetch-info/", PlantFetchInfoView.as_view(), name="plant-fetch-info"),
]
+234
View File
@@ -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,
)
+393
View File
@@ -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)
+58
View File
@@ -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)
+92 -26
View File
@@ -1,63 +1,108 @@
""" """
چت RAG با استریم — استفاده از دیتای embed شده کاربر و Avalai API چت RAG با استریم — استفاده از دیتای embed شده کاربر و Adapter API (GapGPT / Avalai)
""" """
import os import logging
from pathlib import Path from pathlib import Path
from openai import OpenAI
from .config import load_rag_config, RAGConfig from .config import load_rag_config, RAGConfig
from .api_provider import get_chat_client
from .retrieve import search_with_query 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
logger = logging.getLogger(__name__)
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)
def _load_tone(config: RAGConfig | None) -> str: def _load_tone(config: RAGConfig | None) -> str:
"""بارگذاری فایل لحن.""" """بارگذاری فایل لحن پیش‌فرض (chat KB)."""
cfg = config or load_rag_config() cfg = config or load_rag_config()
base = Path(__file__).resolve().parent.parent base = Path(__file__).resolve().parent.parent
tone_path = base / cfg.tone_file chat_kb = cfg.knowledge_bases.get("chat")
if tone_path.exists(): if chat_kb:
return tone_path.read_text(encoding="utf-8").strip() 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 "" 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( def build_rag_context(
query: str, query: str,
sensor_uuid: str, sensor_uuid: str,
config: RAGConfig | None = None, config: RAGConfig | None = None,
limit: int = 8, limit: int = 8,
kb_name: str | None = None,
) -> str: ) -> str:
""" """
ساخت context برای LLM: دیتای فعلی خاک کاربر + متن‌های مرتبط از RAG. ساخت context برای LLM: دیتای فعلی خاک کاربر + متن‌های مرتبط از RAG.
دیتای کاربر همیشه اول می‌آید تا LLM مقادیر واقعی (مثل pH) را ببیند. دیتای کاربر همیشه اول می‌آید تا 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] = [] parts: list[str] = []
# ۱. دیتای فعلی خاک کاربر از DB — همیشه اول (برای سوالاتی مثل «pH خاک من چند است»)
user_soil = build_user_soil_text(sensor_uuid) user_soil = build_user_soil_text(sensor_uuid)
if user_soil and user_soil.strip(): if user_soil and user_soil.strip():
parts.append("[داده‌های فعلی خاک شما]\n" + 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( 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: 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")] rag_texts = [r.get("text", "").strip() for r in results if r.get("text")]
if rag_texts: if rag_texts:
parts.append("[متن‌های مرجع]\n" + "\n\n---\n\n".join(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 "" return "\n\n---\n\n".join(parts) if parts else ""
@@ -68,7 +113,15 @@ def chat_rag_stream(
config: RAGConfig | None = None, config: RAGConfig | None = None,
limit: int = 5, limit: int = 5,
system_override: str | None = None, 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 جواب می‌دهد. چت RAG با استریم: دیتای embed شده را بازیابی می‌کند و با LLM جواب می‌دهد.
فقط دیتای همان کاربر (sensor_uuid) قابل دسترسی است. فقط دیتای همان کاربر (sensor_uuid) قابل دسترسی است.
@@ -84,19 +137,28 @@ def chat_rag_stream(
تک‌تک deltaهای content به‌صورت رشته تک‌تک deltaهای content به‌صورت رشته
""" """
cfg = config or load_rag_config() cfg = config or load_rag_config()
client = _get_chat_client(cfg) client = get_chat_client(cfg)
model = cfg.llm.model 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: if system_override is not None:
system_content = system_override system_content = system_override
else: 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 = [tone] if tone else []
system_parts.append( system_parts.append(
"با استفاده از بخش «داده‌های فعلی خاک شما» و «متن‌های مرجع» زیر به سوال کاربر پاسخ بده. " "با استفاده از بخش «داده‌های فعلی خاک شما» و «متن‌های مرجع» زیر به سوال کاربر پاسخ بده. "
"برای سوالاتی درباره خاک کاربر (مثل pH، رطوبت، NPK) حتماً از داده‌های فعلی استفاده کن. " "برای سوالاتی درباره خاک کاربر (مثل pH، رطوبت، NPK) حتماً از داده‌های فعلی استفاده کن. "
"اطلاعات هواشناسی در بخش «پیش‌بینی هواشناسی» آمده. "
"پاسخ را به زبان کاربر بنویس." "پاسخ را به زبان کاربر بنویس."
) )
if context: if context:
@@ -107,15 +169,19 @@ def chat_rag_stream(
{"role": "system", "content": system_content}, {"role": "system", "content": system_content},
{"role": "user", "content": query}, {"role": "user", "content": query},
] ]
logger.info("Prepared messages for model=%s message=%s", model,messages)
stream = client.chat.completions.create( stream = client.chat.completions.create(
model=model, model=model,
messages=messages, messages=messages,
stream=True, stream=True,
) )
logger.info("Started streaming response from model=%s", model)
for chunk in stream: for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None delta = chunk.choices[0].delta if chunk.choices else None
content = delta.content if delta else "" content = delta.content if delta else ""
if content: if content:
logger.debug("Streaming chunk len=%s", len(content))
yield content yield content
logger.info("chat_rag_stream completed sensor_uuid=%s", sensor_uuid)
+27 -5
View File
@@ -1,5 +1,5 @@
""" """
بارگذاری تنظیمات RAG از rag_config.yaml بارگذاری تنظیمات RAG از rag_config.yaml — با پشتیبانی از چند provider و چند پایگاه دانش
""" """
import os import os
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -16,6 +16,8 @@ class EmbeddingConfig:
batch_size: int = 32 batch_size: int = 32
api_key_env: str | None = None api_key_env: str | None = None
base_url: str | None = None base_url: str | None = None
avalai_base_url: str | None = None
avalai_api_key_env: str | None = None
@dataclass @dataclass
@@ -37,6 +39,15 @@ class LLMConfig:
model: str = "gpt-4o" model: str = "gpt-4o"
base_url: str | None = None base_url: str | None = None
api_key_env: 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 @dataclass
@@ -45,8 +56,7 @@ class RAGConfig:
qdrant: QdrantConfig qdrant: QdrantConfig
chunking: ChunkingConfig chunking: ChunkingConfig
llm: LLMConfig = field(default_factory=LLMConfig) llm: LLMConfig = field(default_factory=LLMConfig)
tone_file: str = "config/tone.txt" knowledge_bases: dict[str, KnowledgeBaseConfig] = field(default_factory=dict)
knowledge_base_path: str = "config/knowledge_base"
chromadb: dict[str, Any] = 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), batch_size=emb.get("batch_size", 32),
api_key_env=emb.get("api_key_env"), api_key_env=emb.get("api_key_env"),
base_url=emb.get("base_url"), 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", {}) 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"), model=llm_data.get("model", "gpt-4o"),
base_url=llm_data.get("base_url"), base_url=llm_data.get("base_url"),
api_key_env=llm_data.get("api_key_env"), 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( return RAGConfig(
embedding=embedding, embedding=embedding,
qdrant=qdrant, qdrant=qdrant,
chunking=chunking, chunking=chunking,
llm=llm, llm=llm,
tone_file=data.get("tone_file", "config/tone.txt"), knowledge_bases=knowledge_bases,
knowledge_base_path=data.get("knowledge_base_path", "config/knowledge_base"),
chromadb=data.get("chromadb", {}), chromadb=data.get("chromadb", {}),
) )
+4 -20
View File
@@ -1,24 +1,8 @@
""" """
سرویس تعبیه‌سازی متن با Avalai API (OpenAI-compatible) سرویس تعبیه‌سازی متن — از Adapter Pattern برای سوئیچ بین providers استفاده می‌کند
""" """
import os from .api_provider import get_embedding_client
from typing import overload from .config import RAGConfig, load_rag_config
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)
def embed_texts( def embed_texts(
@@ -43,7 +27,7 @@ def embed_texts(
return [] return []
cfg = config or load_rag_config() cfg = config or load_rag_config()
client = _get_avalai_client(cfg) client = get_embedding_client(cfg)
model_name = model or cfg.embedding.model model_name = model or cfg.embedding.model
batch_size = cfg.embedding.batch_size batch_size = cfg.embedding.batch_size
+49 -32
View File
@@ -1,10 +1,10 @@
""" """
پایپ‌لاین ورودی RAG: خواندن، چانک، embed و ذخیره در vector store پایپ‌لاین ورودی RAG: خواندن، چانک، embed و ذخیره در vector store — با پشتیبانی از چند پایگاه دانش
سه منبع: منابع:
۱. لحن (tone) — sensor_uuid=__global__ ۱. لحن هر پایگاه دانش (tone) — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization
۲. پایگاه دانش (knowledge base) — sensor_uuid=__global__ ۲. پایگاه‌های دانش سه‌گانه — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization
۳. دیتای خاک هر کاربر از DB (sensor_data + soil_data) — sensor_uuid=uuid ۳. دیتای خاک + هواشناسی هر کاربر از DB — sensor_uuid=uuid, kb_name=__all__
""" """
import uuid import uuid
from pathlib import Path from pathlib import Path
@@ -12,14 +12,15 @@ from pathlib import Path
from .chunker import chunk_text, chunk_texts from .chunker import chunk_text, chunk_texts
from .config import load_rag_config, RAGConfig from .config import load_rag_config, RAGConfig
from .embedding import embed_texts 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 from .vector_store import QdrantVectorStore
# پسوندهای قابل خواندن
TEXT_EXTENSIONS = {".txt", ".md", ".rst", ".json"} TEXT_EXTENSIONS = {".txt", ".md", ".rst", ".json"}
SENSOR_UUID_GLOBAL = "__global__" SENSOR_UUID_GLOBAL = "__global__"
KB_NAME_ALL = "__all__"
def _resolve_path(base: Path, p: str) -> Path: def _resolve_path(base: Path, p: str) -> Path:
"""تبدیل مسیر نسبی به مطلق نسبت به base پروژه.""" """تبدیل مسیر نسبی به مطلق نسبت به base پروژه."""
@@ -57,49 +58,64 @@ def _load_files_from_dir(dir_path: Path, prefix: str = "kb") -> list[tuple[str,
return out 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: Returns:
[(source_id, content, sensor_uuid), ...] [(source_id, content, sensor_uuid, kb_name), ...]
sensor_uuid: __global__ برای tone/kb، uuid سنسور برای user
""" """
cfg = config or load_rag_config() cfg = config or load_rag_config()
base = Path(__file__).resolve().parent.parent base = Path(__file__).resolve().parent.parent
sources: list[tuple[str, str, str]] = [] sources: list[tuple[str, str, str, str]] = []
# ۱. لحن kbs_to_load = cfg.knowledge_bases.items()
tone_path = _resolve_path(base, cfg.tone_file) if kb_name:
content = _load_file(tone_path) kbs_to_load = [(k, v) for k, v in kbs_to_load if k == kb_name]
if content:
sources.append(("tone", content, SENSOR_UUID_GLOBAL))
# ۲. پایگاه دانش for kbn, kb_cfg in kbs_to_load:
kb_path = _resolve_path(base, cfg.knowledge_base_path) tone_path = _resolve_path(base, kb_cfg.tone_file)
for sid, c in _load_files_from_dir(kb_path, prefix="kb"): content = _load_file(tone_path)
sources.append((sid, c, SENSOR_UUID_GLOBAL))
if kb_path.is_file():
content = _load_file(kb_path)
if content: 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(): for sid, content in load_user_sources():
sensor_uuid = sid.replace("user:", "") if sid.startswith("user:"):
sources.append((sid, content, sensor_uuid)) 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 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 می‌فرستد. ورودی کامل: منابع را می‌خواند، چانک، embed و به vector store می‌فرستد.
دیتای هر کاربر (sensor_uuid) جدا embed و با metadata ذخیره می‌شود. kb_name اختیاری: اگر مشخص شود فقط آن پایگاه دانش ingest می‌شود.
Args: Args:
recreate: اگر True باشد، collection را از نو می‌سازد recreate: اگر True باشد، collection را از نو می‌سازد
config: تنظیمات RAG config: تنظیمات RAG
kb_name: نام پایگاه دانش (chat/irrigation/fertilization) — اختیاری
Returns: Returns:
آمار ورودی (تعداد چانک، منبع‌ها، خطاها) آمار ورودی (تعداد چانک، منبع‌ها، خطاها)
@@ -109,7 +125,7 @@ def ingest(recreate: bool = False, config: RAGConfig | None = None) -> dict:
if recreate: if recreate:
store.ensure_collection(recreate=True) store.ensure_collection(recreate=True)
sources = load_sources(config=cfg) sources = load_sources(config=cfg, kb_name=kb_name)
if not sources: if not sources:
return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"} 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_metas: list[dict] = []
all_ids: list[str] = [] 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) chunks = chunk_text(content, config=cfg)
for i, ch in enumerate(chunks): for i, ch in enumerate(chunks):
uid = str(uuid.uuid4()) uid = str(uuid.uuid4())
@@ -127,6 +143,7 @@ def ingest(recreate: bool = False, config: RAGConfig | None = None) -> dict:
"source": source_id, "source": source_id,
"chunk_index": i, "chunk_index": i,
"sensor_uuid": sensor_uuid, "sensor_uuid": sensor_uuid,
"kb_name": src_kb,
}) })
if not all_chunks: if not all_chunks:
+4
View File
@@ -12,13 +12,16 @@ def search_with_query(
limit: int = 5, limit: int = 5,
score_threshold: float | None = None, score_threshold: float | None = None,
config: RAGConfig | None = None, config: RAGConfig | None = None,
kb_name: str | None = None,
) -> list[dict]: ) -> list[dict]:
""" """
کوئری را embed میکند و در vector store جستجو میکند. کوئری را embed میکند و در vector store جستجو میکند.
فقط chunks مربوط به sensor_uuid یا __global__ برمیگردد (ایزولهسازی کاربر). فقط chunks مربوط به sensor_uuid یا __global__ برمیگردد (ایزولهسازی کاربر).
kb_name: اختیاری فیلتر بر اساس پایگاه دانش.
Args: Args:
sensor_uuid: شناسه سنسور کاربر اجباری برای امنیت sensor_uuid: شناسه سنسور کاربر اجباری برای امنیت
kb_name: نام پایگاه دانش (chat/irrigation/fertilization)
Returns: Returns:
لیست نتایج با id, score, text, metadata لیست نتایج با id, score, text, metadata
@@ -31,4 +34,5 @@ def search_with_query(
limit=limit, limit=limit,
score_threshold=score_threshold, score_threshold=score_threshold,
sensor_uuid=sensor_uuid, sensor_uuid=sensor_uuid,
kb_name=kb_name,
) )
+11
View File
@@ -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",
]
+112
View File
@@ -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
+115
View File
@@ -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
+60
View File
@@ -15,3 +15,63 @@ def rag_ingest_task(recreate: bool = True):
""" """
result = ingest(recreate=recreate) result = ingest(recreate=recreate)
return result 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
+11 -1
View File
@@ -1,7 +1,17 @@
from django.urls import path from django.urls import path
from .views import ChatView from .views import (
ChatView,
IrrigationRecommendationView,
IrrigationRecommendationStatusView,
FertilizationRecommendationView,
FertilizationRecommendationStatusView,
)
urlpatterns = [ urlpatterns = [
path("chat/", ChatView.as_view()), path("chat/", ChatView.as_view()),
path("recommend/irrigation/", IrrigationRecommendationView.as_view(), name="recommend-irrigation"),
path("recommend/irrigation/<str:task_id>/status/", IrrigationRecommendationStatusView.as_view(), name="recommend-irrigation-status"),
path("recommend/fertilization/", FertilizationRecommendationView.as_view(), name="recommend-fertilization"),
path("recommend/fertilization/<str:task_id>/status/", FertilizationRecommendationStatusView.as_view(), name="recommend-fertilization-status"),
] ]
+119 -7
View File
@@ -1,14 +1,15 @@
""" """
ساخت دیتای خاک کاربر از sensor_data و soil_data Schema-agnostic ساخت دیتای خاک و هواشناسی کاربر از sensor_data، location_data و weather Schema-agnostic
هر سنسور = یک کاربر. شناسایی با uuid_sensor. هر سنسور = یک کاربر. شناسایی با uuid_sensor.
مدلهای Django داخل توابع import میشوند تا از AppRegistryNotReady جلوگیری شود. مدلهای Django داخل توابع import میشوند تا از AppRegistryNotReady جلوگیری شود.
""" """
from datetime import date
from django.db.models import Model from django.db.models import Model
# فیلدهایی که در متن embed نباید بیایند (شناسه‌ها، رابطه‌ها) EXCLUDE_FIELD_NAMES = {"id", "created_at", "updated_at", "task_id", "recorded_at", "fetched_at"}
EXCLUDE_FIELD_NAMES = {"id", "created_at", "updated_at", "task_id", "recorded_at"}
def _model_to_data_fields(instance: Model, exclude: set[str] | None = None) -> dict: 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 اگر سنسور یافت نشد. متن متنی قابل چانک، یا None اگر سنسور یافت نشد.
""" """
from sensor_data.models import SensorData from sensor_data.models import SensorData
from soil_data.models import SoilDepthData from location_data.models import SoilDepthData
try: try:
sensor = SensorData.objects.select_related("location").get( sensor = SensorData.objects.select_related("location").get(
@@ -89,7 +90,7 @@ def build_user_soil_text(sensor_uuid: str) -> str | None:
if depth_parts: if depth_parts:
parts.append("داده‌های خاک:\n" + "\n".join(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]: 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]]: def load_user_sources() -> list[tuple[str, str]]:
""" """
بارگذاری منابع دیتای کاربران از DB. بارگذاری منابع دیتای کاربران از DB (خاک + هواشناسی).
Returns: [(source_id, content), ...] Returns: [(source_id, content), ...]
source_id = user:{sensor_uuid} source_id = user:{uuid} یا weather:{uuid}
""" """
uuids = get_all_sensor_uuids() uuids = get_all_sensor_uuids()
sources: list[tuple[str, str]] = [] sources: list[tuple[str, str]] = []
@@ -114,4 +158,72 @@ def load_user_sources() -> list[tuple[str, str]]:
text = build_user_soil_text(str(uid)) text = build_user_soil_text(str(uid))
if text and text.strip(): if text and text.strip():
sources.append((f"user:{uid}", text)) 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 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)
+38 -12
View File
@@ -96,27 +96,53 @@ class QdrantVectorStore:
limit: int = 5, limit: int = 5,
score_threshold: float | None = None, score_threshold: float | None = None,
sensor_uuid: str | None = None, sensor_uuid: str | None = None,
kb_name: str | None = None,
) -> list[dict]: ) -> list[dict]:
""" """
جستجوی شباهت بر اساس query vector. جستجوی شباهت بر اساس query vector.
از query_points استفاده میکند (qdrant-client >= 2.0). از query_points استفاده میکند (qdrant-client >= 2.0).
sensor_uuid: اجباری فقط chunks مربوط به این سنسور یا __global__ برگردانده میشود. sensor_uuid: اجباری فقط chunks مربوط به این سنسور یا __global__ برگردانده میشود.
kb_name: اختیاری فیلتر بر اساس پایگاه دانش (chat/irrigation/fertilization).
اگر مشخص شود، فقط chunks همان KB و __all__ برگردانده میشود.
""" """
query_filter = None must_conditions = []
if sensor_uuid: if sensor_uuid:
query_filter = qmodels.Filter( must_conditions.append(
should=[ qmodels.Filter(
qmodels.FieldCondition( should=[
key="sensor_uuid", qmodels.FieldCondition(
match=qmodels.MatchValue(value=sensor_uuid), key="sensor_uuid",
), match=qmodels.MatchValue(value=sensor_uuid),
qmodels.FieldCondition( ),
key="sensor_uuid", qmodels.FieldCondition(
match=qmodels.MatchValue(value="__global__"), 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( response = self.client.query_points(
collection_name=self.qdrant.collection_name, collection_name=self.qdrant.collection_name,
query=query_vector, query=query_vector,
+297 -1
View File
@@ -2,14 +2,25 @@
ویوهای RAG چت با استریم ویوهای RAG چت با استریم
""" """
from django.http import StreamingHttpResponse 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 status
from rest_framework import serializers as drf_serializers
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
import logging
from .chat import chat_rag_stream from .chat import chat_rag_stream
logger = logging.getLogger(__name__)
class ChatView(APIView): class ChatView(APIView):
""" """
چت RAG با استریم. چت RAG با استریم.
@@ -17,11 +28,38 @@ class ChatView(APIView):
sensor_uuid اجباری هر کاربر فقط به دیتای خودش دسترسی دارد. 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): def post(self, request: Request):
data = request.data if request.method == "POST" else request.query_params data = request.data if request.method == "POST" else request.query_params
message = data.get("message") message = data.get("message")
sensor_uuid = data.get("sensor_uuid") sensor_uuid = data.get("sensor_uuid")
logging.info("jhh")
if not message or not isinstance(message, str): if not message or not isinstance(message, str):
return Response( return Response(
{"code": 400, "msg": "پارامتر message الزامی است."}, {"code": 400, "msg": "پارامتر message الزامی است."},
@@ -44,6 +82,7 @@ class ChatView(APIView):
{"code": 400, "msg": "sensor_uuid نباید خالی باشد."}, {"code": 400, "msg": "sensor_uuid نباید خالی باشد."},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
def generate(): def generate():
try: try:
@@ -56,3 +95,260 @@ class ChatView(APIView):
generate(), generate(),
content_type="text/plain; charset=utf-8", 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,
)
+2
View File
@@ -4,6 +4,8 @@ djangorestframework-simplejwt>=5.3,<6
django-cors-headers>=4.3,<5 django-cors-headers>=4.3,<5
mysqlclient>=2.2,<3 mysqlclient>=2.2,<3
gunicorn>=22,<25 gunicorn>=22,<25
drf-spectacular>=0.27,<1
drf-spectacular-sidecar>=2024.7.1
python-dotenv>=1.0,<2 python-dotenv>=1.0,<2
celery[redis]>=5.4,<6 celery[redis]>=5.4,<6
redis>=5.0,<6 redis>=5.0,<6
+1
View File
@@ -19,6 +19,7 @@ class SensorDataAdmin(admin.ModelAdmin):
) )
list_filter = ("updated_at",) list_filter = ("updated_at",)
search_fields = ("uuid_sensor", "location_id") search_fields = ("uuid_sensor", "location_id")
filter_horizontal = ("plants",)
@admin.register(SensorDataHistory) @admin.register(SensorDataHistory)
+3 -3
View File
@@ -10,7 +10,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('soil_data', '0002_soildepthdata_refactor'), ('location_data', '0002_soildepthdata_refactor'),
] ]
operations = [ operations = [
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid_sensor', models.UUIDField(help_text='شناسه سنسور')), ('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_moisture', models.FloatField(blank=True, null=True)),
('soil_temperature', models.FloatField(blank=True, null=True)), ('soil_temperature', models.FloatField(blank=True, null=True)),
('soil_ph', 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)), ('potassium', models.FloatField(blank=True, help_text='پتاسیم', null=True)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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={ options={
'verbose_name': 'داده سنسور', 'verbose_name': 'داده سنسور',
@@ -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'),
),
]
+9 -3
View File
@@ -16,11 +16,11 @@ class SensorData(models.Model):
help_text="شناسه یکتای سنسور", help_text="شناسه یکتای سنسور",
) )
location = models.ForeignKey( location = models.ForeignKey(
"soil_data.SoilLocation", "location_data.SoilLocation",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="sensor_data", related_name="sensor_data",
db_column="location_id", 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_moisture = models.FloatField(null=True, blank=True, help_text="رطوبت خاک")
soil_temperature = 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)") nitrogen = models.FloatField(null=True, blank=True, help_text="ازت (N)")
phosphorus = models.FloatField(null=True, blank=True, help_text="فسفر") phosphorus = models.FloatField(null=True, blank=True, help_text="فسفر")
potassium = 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -49,7 +55,7 @@ class SensorDataHistory(models.Model):
""" """
uuid_sensor = models.UUIDField(help_text="شناسه سنسور") 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_moisture = models.FloatField(null=True, blank=True)
soil_temperature = models.FloatField(null=True, blank=True) soil_temperature = models.FloatField(null=True, blank=True)
soil_ph = models.FloatField(null=True, blank=True) soil_ph = models.FloatField(null=True, blank=True)
+12
View File
@@ -14,11 +14,22 @@ class SensorDataUpdateSerializer(serializers.Serializer):
nitrogen = serializers.FloatField(required=False, allow_null=True) nitrogen = serializers.FloatField(required=False, allow_null=True)
phosphorus = serializers.FloatField(required=False, allow_null=True) phosphorus = serializers.FloatField(required=False, allow_null=True)
potassium = 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): class SensorDataResponseSerializer(serializers.ModelSerializer):
"""سریالایزر خروجی برای SensorData.""" """سریالایزر خروجی برای SensorData."""
plant_ids = serializers.PrimaryKeyRelatedField(
source="plants",
many=True,
read_only=True,
)
class Meta: class Meta:
model = SensorData model = SensorData
fields = [ fields = [
@@ -31,6 +42,7 @@ class SensorDataResponseSerializer(serializers.ModelSerializer):
"nitrogen", "nitrogen",
"phosphorus", "phosphorus",
"potassium", "potassium",
"plant_ids",
"created_at", "created_at",
"updated_at", "updated_at",
] ]
+84 -1
View File
@@ -1,9 +1,16 @@
from django.db import transaction 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 import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from soil_data.models import SoilLocation from location_data.models import SoilLocation
from .models import ParameterUpdateLog, SensorData, SensorDataHistory, SensorParameter from .models import ParameterUpdateLog, SensorData, SensorDataHistory, SensorParameter
from .serializers import ( from .serializers import (
@@ -18,9 +25,47 @@ class SensorDataUpdateView(APIView):
آپدیت داده سنسور. هنگام آپدیت، نسخه فعلی در SensorDataHistory ذخیره میشود. آپدیت داده سنسور. هنگام آپدیت، نسخه فعلی در 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): def put(self, request, uuid_sensor):
return self._update(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): def patch(self, request, uuid_sensor):
return self._update(request, uuid_sensor, partial=True) return self._update(request, uuid_sensor, partial=True)
@@ -35,6 +80,7 @@ class SensorDataUpdateView(APIView):
) )
location_id = serializer.validated_data.pop("location_id") 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() location = SoilLocation.objects.filter(pk=location_id).first()
if not location: if not location:
return Response( return Response(
@@ -67,6 +113,9 @@ class SensorDataUpdateView(APIView):
potassium=sensor_data.potassium, potassium=sensor_data.potassium,
) )
if plant_ids is not None:
sensor_data.plants.set(plant_ids)
return Response( return Response(
{ {
"code": 200, "code": 200,
@@ -82,6 +131,40 @@ class SensorParameterCreateView(APIView):
اضافه کردن پارامتر جدید و ثبت در ParameterUpdateLog. اضافه کردن پارامتر جدید و ثبت در 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): def post(self, request):
serializer = SensorParameterSerializer(data=request.data) serializer = SensorParameterSerializer(data=request.data)
if not serializer.is_valid(): if not serializer.is_valid():
+79
View File
@@ -1,6 +1,13 @@
from celery.result import AsyncResult 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 status
from rest_framework import serializers as drf_serializers
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -13,6 +20,46 @@ class TaskTriggerView(APIView):
POST با بدنه اختیاری: {"duration": 3} - مدت زمان تسک به ثانیه. 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): def post(self, request):
duration = request.data.get("duration", 1) duration = request.data.get("duration", 1)
try: try:
@@ -33,6 +80,38 @@ class TaskStatusView(APIView):
GET /api/tasks/<task_id>/status/ GET /api/tasks/<task_id>/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): def get(self, request, task_id):
result = AsyncResult(task_id) result = AsyncResult(task_id)
state = result.state state = result.state
+1
View File
@@ -0,0 +1 @@
+24
View File
@@ -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")
+7
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -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."
)
)
+184
View File
@@ -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",
),
),
]
@@ -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),
]
@@ -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),
]
+1
View File
@@ -0,0 +1 @@
+107
View File
@@ -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
+224
View File
@@ -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": "بارش فردا ناچیز یا صفر — آبیاری توصیه می‌شود.",
}
+34
View File
@@ -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()