first commit
This commit is contained in:
+20
-3
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# فایل لحن / سبک پاسخهای RAG
|
||||||
|
|
||||||
|
لحن و سبک پاسخها:
|
||||||
|
- سطح: دوستانه و تخصصی؛ با کشاورز به زبان ساده و علمی صحبت کن.
|
||||||
|
- واژگان: از اصطلاحات رایج کشاورزی و خاکشناسی استفاده کن، در صورت نیاز معادل فارسی بیاور.
|
||||||
|
- طول: پاسخها مختصر و کاربردی؛ در صورت لزوم با بولت یا شماره ساختاربندی کن.
|
||||||
|
- هشدار: اگر موضوع ایمنی یا سلامتی گیاه/خاک باشد، صریحاً هشدار بده.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# لحن توصیه کودهی
|
||||||
|
|
||||||
|
سبک پاسخ:
|
||||||
|
- تخصصی و دقیق: نوع کود، مقدار و زمان مصرف را مشخص کن
|
||||||
|
- بر اساس دادههای NPK خاک، pH، و نوع محصول
|
||||||
|
- فرمت خروجی: JSON با فیلدهای fertilizer_needed (bool), fertilizer_type (str), amount_kg_per_hectare (float), reason (str), npk_status (dict)
|
||||||
|
- اگر سطح NPK خاک مناسب باشد، کودهی لازم نیست
|
||||||
|
- هشدارهای مهم درباره مصرف بیش از حد کود را ذکر کن
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# لحن توصیه آبیاری
|
||||||
|
|
||||||
|
سبک پاسخ:
|
||||||
|
- مستقیم و عملیاتی: زمان، مقدار و روش آبیاری را مشخص کن
|
||||||
|
- بر اساس دادههای هواشناسی (بارش، ET0، دما) و رطوبت خاک
|
||||||
|
- فرمت خروجی: JSON با فیلدهای irrigation_needed (bool), amount_mm (float), reason (str), next_check_date (str)
|
||||||
|
- اگر بارش پیشبینی شده باشد، آبیاری را به تعویق بینداز
|
||||||
|
- اگر رطوبت خاک کافی است، آبیاری لازم نیست
|
||||||
+13
-1
@@ -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")),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class IrrigationConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "irrigation"
|
||||||
|
verbose_name = "Irrigation"
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"),
|
||||||
|
]
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|
||||||
+2
-2
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PlantConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "plant"
|
||||||
|
verbose_name = "Plant"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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.")
|
||||||
|
)
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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)
|
||||||
+89
-23
@@ -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 chat_kb:
|
||||||
|
tone_path = base / chat_kb.tone_file
|
||||||
|
logger.debug("Loading default tone from path=%s", tone_path)
|
||||||
if tone_path.exists():
|
if tone_path.exists():
|
||||||
|
logger.debug("Default tone file found: %s", tone_path)
|
||||||
return tone_path.read_text(encoding="utf-8").strip()
|
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_kb_tone(detected_kb, cfg)
|
||||||
|
if not tone:
|
||||||
tone = _load_tone(cfg)
|
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
@@ -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,6 +106,17 @@ 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(
|
||||||
@@ -101,7 +124,6 @@ def load_rag_config(config_path: str | Path | None = None) -> RAGConfig:
|
|||||||
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
@@ -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
|
||||||
|
|
||||||
|
|||||||
+44
-27
@@ -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:
|
||||||
|
kbs_to_load = [(k, v) for k, v in kbs_to_load if k == kb_name]
|
||||||
|
|
||||||
|
for kbn, kb_cfg in kbs_to_load:
|
||||||
|
tone_path = _resolve_path(base, kb_cfg.tone_file)
|
||||||
content = _load_file(tone_path)
|
content = _load_file(tone_path)
|
||||||
if content:
|
if content:
|
||||||
sources.append(("tone", content, SENSOR_UUID_GLOBAL))
|
sources.append((f"tone:{kbn}", content, SENSOR_UUID_GLOBAL, kbn))
|
||||||
|
|
||||||
# ۲. پایگاه دانش
|
kb_path = _resolve_path(base, kb_cfg.path)
|
||||||
kb_path = _resolve_path(base, cfg.knowledge_base_path)
|
for sid, c in _load_files_from_dir(kb_path, prefix=f"kb:{kbn}"):
|
||||||
for sid, c in _load_files_from_dir(kb_path, prefix="kb"):
|
sources.append((sid, c, SENSOR_UUID_GLOBAL, kbn))
|
||||||
sources.append((sid, c, SENSOR_UUID_GLOBAL))
|
|
||||||
if kb_path.is_file():
|
if kb_path.is_file():
|
||||||
content = _load_file(kb_path)
|
content = _load_file(kb_path)
|
||||||
if content:
|
if content:
|
||||||
sources.append((f"kb:{kb_path.name}", content, SENSOR_UUID_GLOBAL))
|
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():
|
||||||
|
if sid.startswith("user:"):
|
||||||
sensor_uuid = sid.replace("user:", "")
|
sensor_uuid = sid.replace("user:", "")
|
||||||
sources.append((sid, content, sensor_uuid))
|
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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
+28
-2
@@ -96,15 +96,20 @@ 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(
|
||||||
|
qmodels.Filter(
|
||||||
should=[
|
should=[
|
||||||
qmodels.FieldCondition(
|
qmodels.FieldCondition(
|
||||||
key="sensor_uuid",
|
key="sensor_uuid",
|
||||||
@@ -116,6 +121,27 @@ class QdrantVectorStore:
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
|||||||
+297
-1
@@ -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 الزامی است."},
|
||||||
@@ -45,6 +83,7 @@ class ChatView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
try:
|
try:
|
||||||
for chunk in chat_rag_stream(message, sensor_uuid=sensor_uuid):
|
for chunk in chat_rag_stream(message, sensor_uuid=sensor_uuid):
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -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": "بارش فردا ناچیز یا صفر — آبیاری توصیه میشود.",
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user