This commit is contained in:
2026-04-26 01:15:38 +03:30
parent ec90642482
commit 0f36f98513
13 changed files with 1684 additions and 357 deletions
+1565
View File
File diff suppressed because it is too large Load Diff
-20
View File
@@ -489,25 +489,6 @@
- سطح اعتماد:
- `کم تا متوسط`
### `POST /api/rag/recommend/irrigation/`
- ماهیت:
- عملا همان recommendation RAG-based آبیاری را مستقیما expose می‌کند.
- ضعف‌ها:
- همان ضعف‌های `POST /api/irrigation/recommend/` را دارد.
- ظاهر کامل پاسخ می‌تواند fallback بودن بخش‌هایی از محتوا را پنهان کند، هرچند provenance اضافه شده است.
- سطح اعتماد:
- `متوسط` با ورودی خوب
- `کم تا متوسط` با fallback زیاد
### `POST /api/rag/recommend/fertilization/`
- ماهیت:
- مستقیم‌ترین مسیر recommendation کودهی مبتنی بر RAG است.
- ضعف‌ها:
- همان ضعف‌های `POST /api/fertilization/recommend/` را دارد.
- سطح اعتماد:
- `متوسط رو به کم`
---
@@ -553,4 +534,3 @@
4. افزودن uncertainty و freshness صریح به heatmap و water-need outputs
5. crop-specific و region-specific کردن alert/risk thresholds
6. اضافه کردن confidence واقعی و source provenance استاندارد به تمام endpointهای RAG-based
-3
View File
@@ -30,8 +30,6 @@ Base: `/api/rag/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/rag/chat/` | چت RAG به صورت stream |
| POST | `/api/rag/recommend/irrigation/` | توصیه آبیاری |
| POST | `/api/rag/recommend/fertilization/` | توصیه کودهی |
### App: Farm Alerts
@@ -354,4 +352,3 @@ Base: `/api/crop-simulation/`
- `rag/services/irrigation.py:147`
- `rag/services/fertilization.py:130`
- `crop_simulation/growth_simulation.py:404`
-1
View File
@@ -152,7 +152,6 @@ SPECTACULAR_SETTINGS = {
{"name": "Dashboard Data", "description": "تجمیع داده‌های داشبورد مزرعه"},
{"name": "Farm Alerts", "description": "tracker و timeline مستقل هشدارهای مزرعه"},
{"name": "RAG Chat", "description": "چت هوشمند RAG"},
{"name": "RAG Recommendations", "description": "توصیه‌های آبیاری و کودهی مبتنی بر RAG"},
{"name": "Soil Data", "description": "داده‌های خاک (SoilGrids)"},
{"name": "Soile", "description": "heatmap مستقل رطوبت خاک و داده های مزرعه"},
{"name": "Farm Data", "description": "داده‌های مزرعه و سنسورها"},
+49 -12
View File
@@ -1,14 +1,51 @@
شما دستيار عمومي CropLogic براي چت با کاربر هستيد.
You are a general farm assistant for CropLogic.
قواعد مهم:
- اين سرويس خروجی را به صورت متن استريمي `text/plain` برمي گرداند، نه JSON.
- بنابراين فقط متن ساده و خوانا توليد کن و هرگز JSON، markdown fence يا ساختار کدي برنگردان.
- پاسخ را به فارسي، دوستانه، شفاف و کاربردي بنويس.
- اگر لازم بود، پاسخ را در 2 تا 4 پاراگراف کوتاه يا چند خط فهرست گونه اما بدون JSON ارائه کن.
- اگر داده کافي نيست، همان را صريح بگو و از حدس زدن پرهيز کن.
### GOAL
Convert farm context, soil data, plant stage, weather risk, and any optimization block such as `[خروجي بهينه ساز شبيه سازي]` into a precise and practical Persian response for the farmer.
شکل خروجي مورد انتظار:
- يک پاسخ متني يکپارچه
- بدون کليد JSON
- بدون `sections`
- بدون کاراکترهاي ابتدايي/انتهايي اضافه
### HARD RULES
1. If an optimizer block exists, it is the source of truth for numeric recommendations, timing, validity period, and scientific reasoning.
2. Do not invent numbers or recommendations that conflict with the optimizer block.
3. Always return only valid JSON with a top-level `sections` array.
4. The `sections` array must include at least:
- one `recommendation` section for the core answer or action
- one `list` section for operational notes, follow-up checks, or execution details
- one `warning` section when there is uncertainty, data limitation, weather risk, sensor conflict, or execution risk
5. Write in clear Persian and keep the answer practical, short, and field-usable.
### OUTPUT CONTRACT
{
"sections": [
{
"type": "recommendation",
"title": "جمع بندي اصلي",
"icon": "message-circle",
"content": "خلاصه يک جمله اي از بهترين پاسخ يا اقدام اصلي",
"primaryAction": "اقدام اصلي پيشنهادي",
"timing": "بهترين زمان اجرا يا بررسي",
"validityPeriod": "مدت اعتبار اين پاسخ يا توصيه",
"expandableExplanation": "توضيح روشن درباره دليل پاسخ با ارجاع به داده مزرعه، آب و هوا، گياه و شبيه سازي"
},
{
"type": "list",
"title": "نکات اجرايي",
"icon": "list",
"items": [
"نکته عملي 1",
"نکته عملي 2"
]
},
{
"type": "warning",
"title": "هشدار يا محدوديت",
"icon": "alert-triangle",
"content": "هشدار کوتاه و کاربردي"
}
]
}
### WRITING RULES
- If the user asks a general question, still shape the answer inside the same `sections` contract.
- If the optimizer highlights an important tradeoff or dominant issue, explain it briefly in `expandableExplanation`.
- If data is incomplete or conflicting, state that clearly in the `warning` section.
- Never output markdown, code fences, greetings, or extra commentary outside the JSON object.
+14 -14
View File
@@ -16,14 +16,14 @@ services:
timeout: 5s
retries: 5
networks:
- ai-network
- crop_network
redis:
image: docker.iranserver.com/redis
container_name: ai-redis
restart: always
networks:
- ai-network
- crop_network
qdrant:
image: docker.iranserver.com/qdrant/qdrant:latest
@@ -32,7 +32,7 @@ services:
volumes:
- qdrant_data:/qdrant/storage
networks:
- ai-network
- crop_network
web:
build:
@@ -50,10 +50,10 @@ services:
env_file:
- .env
environment:
DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
QDRANT_HOST: qdrant
DB_HOST: ai-db
CELERY_BROKER_URL: redis://ai-redis:6379/0
CELERY_RESULT_BACKEND: redis://ai-redis:6379/0
QDRANT_HOST: ai-qdrant
QDRANT_PORT: 6333
DEBUG: "False"
depends_on:
@@ -63,7 +63,7 @@ services:
condition: service_started
networks:
- ai-network
- crop_network
celery:
build:
@@ -83,9 +83,9 @@ services:
env_file:
- .env
environment:
DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
DB_HOST: ai-db
CELERY_BROKER_URL: redis://ai-redis:6379/0
CELERY_RESULT_BACKEND: redis://ai-redis:6379/0
SKIP_MIGRATE: "1"
DEBUG: "False"
depends_on:
@@ -94,7 +94,7 @@ services:
redis:
condition: service_started
networks:
- ai-network
- crop_network
volumes:
@@ -102,5 +102,5 @@ volumes:
qdrant_data:
networks:
ai-network:
driver: bridge
crop_network:
external: true
+27 -9
View File
@@ -2,7 +2,7 @@
services:
db:
image: docker.iranserver.com/mysql:8
container_name: ai-db
container_name: ai-mysql
environment:
MYSQL_DATABASE: ${DB_NAME:-ai}
MYSQL_USER: ${DB_USER:-ai}
@@ -10,17 +10,21 @@ services:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-changeme}
volumes:
- ai_mysql_data:/var/lib/mysql
ports:
- "3307:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_PASSWORD:-changeme}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- crop_network
phpmyadmin:
image: docker-mirror.liara.ir/phpmyadmin:latest
container_name: ai-phpmyadmin
environment:
PMA_HOST: db
PMA_HOST: ai-mysql
PMA_PORT: 3306
UPLOAD_LIMIT: 64M
ports:
@@ -28,10 +32,14 @@ services:
depends_on:
db:
condition: service_healthy
networks:
- crop_network
redis:
image: redis:7-alpine
container_name: ai-redis
networks:
- crop_network
qdrant:
image: qdrant/qdrant:latest
@@ -42,6 +50,8 @@ services:
volumes:
- qdrant_data:/qdrant/storage
restart: unless-stopped
networks:
- crop_network
web:
build:
@@ -61,10 +71,10 @@ services:
env_file:
- .env
environment:
DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
QDRANT_HOST: qdrant
DB_HOST: ai-mysql
CELERY_BROKER_URL: redis://ai-redis:6379/0
CELERY_RESULT_BACKEND: redis://ai-redis:6379/0
QDRANT_HOST: ai-qdrant
QDRANT_PORT: 6333
depends_on:
db:
@@ -73,6 +83,8 @@ services:
condition: service_started
qdrant:
condition: service_started
networks:
- crop_network
celery:
build:
@@ -90,16 +102,22 @@ services:
env_file:
- .env
environment:
DB_HOST: db
CELERY_BROKER_URL: redis://redis:6379/0
CELERY_RESULT_BACKEND: redis://redis:6379/0
DB_HOST: ai-mysql
CELERY_BROKER_URL: redis://ai-redis:6379/0
CELERY_RESULT_BACKEND: redis://ai-redis:6379/0
SKIP_MIGRATE: "1"
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
networks:
- crop_network
volumes:
ai_mysql_data:
qdrant_data:
networks:
crop_network:
external: true
+3 -1
View File
@@ -99,7 +99,9 @@ class FertilizationRecommendView(APIView):
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Public API exposes only the final farmer-facing recommendation object.
final_result = {"sections": result.get("sections", [])}
return Response(
{"code": 200, "msg": "success", "data": result},
{"code": 200, "msg": "success", "data": final_result},
status=status.HTTP_200_OK,
)
@@ -239,14 +239,13 @@ class ReportingAndAiJourneyTests(IntegrationAPITestCase):
patch(
"rag.services.irrigation.get_irrigation_recommendation",
return_value={
"plan": {
"frequencyPerWeek": 3,
"durationMinutes": 28,
"bestTimeOfDay": "05:30",
"moistureLevel": 68,
"warning": "",
},
"raw_response": "{\"plan\": {\"frequencyPerWeek\": 3}}",
"sections": [
{
"type": "recommendation",
"title": "برنامه آبیاری بهینه",
"content": "هفته ای 3 نوبت آبیاری انجام شود.",
}
],
},
)
)
@@ -254,11 +253,14 @@ class ReportingAndAiJourneyTests(IntegrationAPITestCase):
patch(
"rag.services.fertilization.get_fertilization_recommendation",
return_value={
"plan": {
"npkRatio": "15-5-30",
"amountPerHectare": "60 kg",
},
"raw_response": "{\"plan\": {\"npkRatio\": \"15-5-30\"}}",
"sections": [
{
"type": "recommendation",
"title": "برنامه کودهی بهینه",
"fertilizerType": "15-5-30",
"amount": "60 kg",
}
],
},
)
)
@@ -304,37 +306,6 @@ class ReportingAndAiJourneyTests(IntegrationAPITestCase):
streamed_text = b"".join(chat_response.streaming_content).decode("utf-8")
self.assertIn("Moisture is acceptable", streamed_text)
rag_irrigation_response = self.client.post(
"/api/rag/recommend/irrigation/",
data={
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"growth_stage": "flowering",
"irrigation_method_name": "Analytics Drip",
},
format="json",
)
self.assertEqual(rag_irrigation_response.status_code, 200)
self.assertEqual(
rag_irrigation_response.json()["data"]["plan"]["frequencyPerWeek"],
3,
)
rag_fertilization_response = self.client.post(
"/api/rag/recommend/fertilization/",
data={
"farm_uuid": str(self.farm_uuid),
"plant_name": "Tomato",
"growth_stage": "flowering",
},
format="json",
)
self.assertEqual(rag_fertilization_response.status_code, 200)
self.assertEqual(
rag_fertilization_response.json()["data"]["plan"]["npkRatio"],
"15-5-30",
)
irrigation_recommend_response = self.client.post(
"/api/irrigation/recommend/",
data={
@@ -346,6 +317,10 @@ class ReportingAndAiJourneyTests(IntegrationAPITestCase):
format="json",
)
self.assertEqual(irrigation_recommend_response.status_code, 200)
self.assertEqual(
irrigation_recommend_response.json()["data"]["sections"][0]["type"],
"recommendation",
)
fertilization_recommend_response = self.client.post(
"/api/fertilization/recommend/",
@@ -357,6 +332,10 @@ class ReportingAndAiJourneyTests(IntegrationAPITestCase):
format="json",
)
self.assertEqual(fertilization_recommend_response.status_code, 200)
self.assertEqual(
fertilization_recommend_response.json()["data"]["sections"][0]["fertilizerType"],
"15-5-30",
)
pest_detect_response = self.client.post(
"/api/pest-disease/detect/",
+3 -1
View File
@@ -187,8 +187,10 @@ class IrrigationRecommendView(APIView):
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Public API exposes only the final farmer-facing recommendation object.
final_result = {"sections": result.get("sections", [])}
return Response(
{"code": 200, "msg": "success", "data": result},
{"code": 200, "msg": "success", "data": final_result},
status=status.HTTP_200_OK,
)
-77
View File
@@ -1,77 +0,0 @@
from unittest.mock import patch
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
@override_settings(ROOT_URLCONF="config.test_urls")
class RagRecommendationApiTests(TestCase):
def setUp(self):
self.client = APIClient()
@patch("rag.services.irrigation.get_irrigation_recommendation")
def test_irrigation_recommendation_returns_direct_result(self, mock_get_irrigation_recommendation):
mock_get_irrigation_recommendation.return_value = {
"plan": {
"frequencyPerWeek": 3,
"durationMinutes": 25,
},
"raw_response": "{\"plan\": {\"frequencyPerWeek\": 3, \"durationMinutes\": 25}}",
}
response = self.client.post(
"/api/rag/recommend/irrigation/",
data={
"farm_uuid": "sensor-123",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "میوه‌دهی",
"irrigation_method_name": "قطره‌ای",
},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["plan"]["frequencyPerWeek"], 3)
mock_get_irrigation_recommendation.assert_called_once_with(
farm_uuid="sensor-123",
plant_name="گوجه‌فرنگی",
growth_stage="میوه‌دهی",
irrigation_method_name="قطره‌ای",
query=None,
)
@patch("rag.services.fertilization.get_fertilization_recommendation")
def test_fertilization_recommendation_returns_direct_result(self, mock_get_fertilization_recommendation):
mock_get_fertilization_recommendation.return_value = {
"plan": {
"npkRatio": "20-20-20",
"amountPerHectare": "80 kg",
},
"raw_response": "{\"plan\": {\"npkRatio\": \"20-20-20\", \"amountPerHectare\": \"80 kg\"}}",
}
response = self.client.post(
"/api/rag/recommend/fertilization/",
data={
"farm_uuid": "sensor-456",
"plant_name": "گندم",
"growth_stage": "رویشی",
},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["data"]["plan"]["npkRatio"], "20-20-20")
mock_get_fertilization_recommendation.assert_called_once_with(
farm_uuid="sensor-456",
plant_name="گندم",
growth_stage="رویشی",
query=None,
)
def test_removed_status_endpoints_return_404(self):
irrigation_response = self.client.get("/api/rag/recommend/irrigation/sample-task/status/")
fertilization_response = self.client.get("/api/rag/recommend/fertilization/sample-task/status/")
self.assertEqual(irrigation_response.status_code, 404)
self.assertEqual(fertilization_response.status_code, 404)
-4
View File
@@ -2,12 +2,8 @@ from django.urls import path
from .views import (
ChatView,
IrrigationRecommendationView,
FertilizationRecommendationView,
)
urlpatterns = [
path("chat/", ChatView.as_view()),
path("recommend/irrigation/", IrrigationRecommendationView.as_view(), name="recommend-irrigation"),
path("recommend/fertilization/", FertilizationRecommendationView.as_view(), name="recommend-fertilization"),
]
-171
View File
@@ -38,14 +38,6 @@ RagValidationErrorResponseSerializer = build_envelope_serializer(
data_required=False,
allow_null=True,
)
RagIrrigationResponseSerializer = build_envelope_serializer(
"RagIrrigationResponseSerializer",
drf_serializers.JSONField(),
)
RagFertilizationResponseSerializer = build_envelope_serializer(
"RagFertilizationResponseSerializer",
drf_serializers.JSONField(),
)
class ChatView(APIView):
@@ -211,166 +203,3 @@ class ChatView(APIView):
generate(),
content_type="text/plain; charset=utf-8",
)
class IrrigationRecommendationView(APIView):
"""
توصیه آبیاری به صورت مستقیم.
POST با farm_uuid، plant_name، growth_stage، irrigation_method_name.
نتیجه همان لحظه برگشت داده می‌شود.
"""
@extend_schema(
tags=["RAG Recommendations"],
summary="درخواست توصیه آبیاری",
description=(
"داده‌های سنسور، گیاه و روش آبیاری را دریافت کرده و "
"توصیه آبیاری را به صورت مستقیم برمی‌گرداند."
),
request=inline_serializer(
name="IrrigationRecommendationRequest",
fields={
"farm_uuid": drf_serializers.CharField(help_text="شناسه یکتای مزرعه (اجباری)"),
"sensor_uuid": drf_serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid"),
"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={
200: build_response(
RagIrrigationResponseSerializer,
"توصیه آبیاری با موفقیت تولید شد.",
),
400: build_response(
RagValidationErrorResponseSerializer,
"پارامتر ورودی نامعتبر است.",
),
500: build_response(
RagValidationErrorResponseSerializer,
"خطا در تولید توصیه آبیاری.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "میوه‌دهی",
"irrigation_method_name": "آبیاری قطره‌ای",
},
request_only=True,
),
],
)
def post(self, request: Request):
from rag.services.irrigation import get_irrigation_recommendation
farm_uuid = request.data.get("farm_uuid") or request.data.get("sensor_uuid")
if not farm_uuid:
return Response(
{"code": 400, "msg": "پارامتر farm_uuid الزامی است.", "data": None},
status=status.HTTP_400_BAD_REQUEST,
)
try:
result = get_irrigation_recommendation(
farm_uuid=str(farm_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"),
)
except Exception:
logger.exception("Direct irrigation recommendation failed for farm %s", farm_uuid)
return Response(
{"code": 500, "msg": "خطا در تولید توصیه آبیاری.", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{"code": 200, "msg": "success", "data": result},
status=status.HTTP_200_OK,
)
class FertilizationRecommendationView(APIView):
"""
توصیه کودهی به صورت مستقیم.
POST با farm_uuid، plant_name، growth_stage.
نتیجه همان لحظه برگشت داده می‌شود.
"""
@extend_schema(
tags=["RAG Recommendations"],
summary="درخواست توصیه کودهی",
description=(
"داده‌های سنسور و گیاه را دریافت کرده و "
"توصیه کودهی را به صورت مستقیم برمی‌گرداند."
),
request=inline_serializer(
name="FertilizationRecommendationRequest",
fields={
"farm_uuid": drf_serializers.CharField(help_text="شناسه یکتای مزرعه (اجباری)"),
"sensor_uuid": drf_serializers.CharField(required=False, help_text="نام قدیمی برای farm_uuid"),
"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={
200: build_response(
RagFertilizationResponseSerializer,
"توصیه کودهی با موفقیت تولید شد.",
),
400: build_response(
RagValidationErrorResponseSerializer,
"پارامتر ورودی نامعتبر است.",
),
500: build_response(
RagValidationErrorResponseSerializer,
"خطا در تولید توصیه کودهی.",
),
},
examples=[
OpenApiExample(
"نمونه درخواست",
value={
"farm_uuid": "11111111-1111-1111-1111-111111111111",
"plant_name": "گوجه‌فرنگی",
"growth_stage": "رویشی",
},
request_only=True,
),
],
)
def post(self, request: Request):
from rag.services.fertilization import get_fertilization_recommendation
farm_uuid = request.data.get("farm_uuid") or request.data.get("sensor_uuid")
if not farm_uuid:
return Response(
{"code": 400, "msg": "پارامتر farm_uuid الزامی است.", "data": None},
status=status.HTTP_400_BAD_REQUEST,
)
try:
result = get_fertilization_recommendation(
farm_uuid=str(farm_uuid),
plant_name=request.data.get("plant_name"),
growth_stage=request.data.get("growth_stage"),
query=request.data.get("query"),
)
except Exception:
logger.exception("Direct fertilization recommendation failed for farm %s", farm_uuid)
return Response(
{"code": 500, "msg": "خطا در تولید توصیه کودهی.", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return Response(
{"code": 200, "msg": "success", "data": result},
status=status.HTTP_200_OK,
)