This commit is contained in:
2026-05-05 21:02:12 +03:30
parent 5301071df5
commit 1679825ae2
47 changed files with 1347 additions and 1403 deletions
+4 -4
View File
@@ -24,11 +24,11 @@ WEATHER_API_BASE_URL=https://api.open-meteo.com/v1/forecast
WEATHER_API_KEY= WEATHER_API_KEY=
# Soil data provider: mock | soilgrids # Soil data provider: soilgrids | mock
SOIL_DATA_PROVIDER=mock SOIL_DATA_PROVIDER=soilgrids
SOIL_MOCK_DELAY_SECONDS=0.8 SOIL_MOCK_DELAY_SECONDS=0.8
SOILGRIDS_TIMEOUT_SECONDS=60 SOILGRIDS_TIMEOUT_SECONDS=60
WEATHER_DATA_PROVIDER=mock WEATHER_DATA_PROVIDER=open-meteo
WEATHER_MOCK_DELAY_SECONDS=0.8 WEATHER_MOCK_DELAY_SECONDS=0.8
WEATHER_TIMEOUT_SECONDS=60 WEATHER_TIMEOUT_SECONDS=60
+69 -522
View File
@@ -1,522 +1,69 @@
# ممیزی ضعف‌ها و میزان اعتماد APIها # ممیزی وضعیت واقعی APIها
این سند بر اساس بررسی کد فعلی پروژه نوشته شده است و هدفش این است که برای هر API مشخص کند: این سند فقط درباره reliability نیست؛ به‌عنوان یک مرجع فشرده برای `وضعیت واقعی routeها` و semantics فعلی هم استفاده می‌شود.
- خروجی روی چه داده یا فرمولی تکیه دارد ## قانون runtime در برابر seed
- کجاها خروجی صرفا تقریبی، heuristic، mock یا وابسته به LLM است
- برای چه نوع استفاده‌ای قابل اتکا هست و برای چه نوع تصمیمی نیست - seed/fixture/bootstrap data مجاز است و باید برای bootstrap، dev و test باقی بماند.
- سطح اعتماد عملی به پاسخ خروجی چقدر است - mock/sample/demo data نباید در runtime application code به عنوان fallback موفق استفاده شود.
- اگر داده واقعی موجود نیست، پاسخ باید `empty state` یا `failure contract` صریح باشد.
توجه:
## جدول مرجع وضعیت
- این سند درباره `قابلیت اتکای عملیاتی` است، نه فقط درست بودن ساختار JSON.
- ممکن است یک API از نظر فنی همیشه JSON درست برگرداند، اما از نظر agronomy یا تصمیم‌سازی هنوز کم‌اعتماد باشد. | Endpoint | وضعیت | semantics | توضیح کوتاه |
- سطح اعتماد به معنی اعتماد به `محتوای پاسخ` است، نه availability سرویس. |---|---:|---|---|
| `POST /api/rag/chat/` | `implemented` | `live AI` | route واقعی AI |
## معیار سطح اعتماد | `POST /api/farm-alerts/tracker/` | `implemented` | `live AI` | route واقعی AI؛ معادل backend آن cached است |
| `GET|POST /api/soil-data/` | `implemented` | `provider-backed / task-backed` | route واقعی AI |
- `زیاد`: عمدتا داده واقعی یا ذخیره شده برمی‌گرداند؛ فرمول یا منطق ساده ولی قابل توضیح است؛ برای نمایش و گزارش مناسب است. | `GET /api/soil-data/tasks/{task_id}/status/` | `implemented` | `async status` | route واقعی AI |
- `متوسط`: داده واقعی دارد اما بخش مهمی از نتیجه وابسته به تقریب، پیش‌فرض، شبیه‌سازی، یا LLM است. | `POST /api/soil-data/ndvi-health/` | `implemented` | `provider-backed` | route واقعی AI |
- `کم`: نتیجه بیشتر heuristic، fallback، برآورد تقریبی، یا تلفیق داده ناقص است. | `POST /api/soile/*` | `implemented` | `AI-owned derived output` | routeهای واقعی AI |
- `خیلی کم`: داده mock، stub، یا خروجی عمدتا غیرقابل اتکا برای تصمیم واقعی. | `POST /api/farm-data/` | `implemented` | `AI-owned derived write-model` | route واقعی AI |
| `GET /api/farm-data/{farm_uuid}/detail/` | `implemented` | `AI-owned derived read-model` | route واقعی AI |
## خلاصه سریع APIهای پرریسک | `POST /api/farm-data/parameters/` | `implemented` | `AI-owned config` | route واقعی AI |
| `POST /api/weather/farm-card/` | `implemented` | `provider-backed` | route واقعی AI |
- `POST /api/economy/overview/`: داده کاملا mock است؛ برای تصمیم واقعی نباید استفاده شود. | `POST /api/weather/water-need-prediction/` | `implemented` | `derived output` | route واقعی AI |
- `POST /api/plants/fetch-info/`: عملا پیاده‌سازی نشده و `503` برمی‌گرداند. | `POST /api/economy/overview/` | `implemented` | `provider-backed / persisted` | route واقعی AI |
- `POST /api/irrigation/water-stress/`: اگر engine شبیه‌سازی خطا بدهد هنوز به فرمول ساده سنسور fallback می‌کند. | `GET|POST /api/plants/` | `implemented` | `canonical AI plant service` | route واقعی AI |
- `POST /api/pest-disease/detect/` و `POST /api/pest-disease/risk/`: وابستگی مستقیم به RAG/LLM دارند؛ برای KPI قطعی یا تصمیم سم‌پاشی خودکار مناسب نیستند. | `GET|PUT|PATCH|DELETE /api/plants/{pk}/` | `implemented` | `canonical AI plant service` | route واقعی AI |
- `POST /api/farm-alerts/tracker/` و `POST /api/farm-alerts/timeline/`: ترکیب rule-based + LLM هستند؛ برای آگاهی خوب‌اند ولی برای اتوماسیون بحرانی نه. | `POST /api/plants/fetch-info/` | `implemented` | `provider-backed enrichment` | route واقعی AI |
- `POST /api/soile/moisture-heatmap/`: visualization خوبی می‌دهد اما هنوز geostatistics دقیق یا history واقعی سنسورها را مدل نمی‌کند. | `POST /api/pest-disease/detect/` | `implemented` | `live AI` | route واقعی AI |
| `POST /api/pest-disease/risk/` | `implemented` | `derived output` | route واقعی AI |
--- | `GET|POST /api/irrigation/` | `implemented` | `AI-owned config + live recommendation support` | route واقعی AI |
| `GET|PUT|PATCH|DELETE /api/irrigation/{pk}/` | `implemented` | `AI-owned config` | route واقعی AI |
## 1) Soil Data API | `POST /api/irrigation/recommend/` | `implemented` | `live AI + deterministic context` | route واقعی AI |
| `POST /api/irrigation/plan-from-text/` | `implemented` | `live AI parsing` | route واقعی AI |
### `GET|POST /api/soil-data/` | `POST /api/irrigation/water-stress/` | `implemented` | `AI-owned derived output` | route واقعی AI |
| `POST /api/fertilization/recommend/` | `implemented` | `live AI + optimizer context` | route واقعی AI |
- منبع داده: | `POST /api/fertilization/plan-from-text/` | `implemented` | `live AI parsing` | route واقعی AI |
- اگر رکورد کامل موجود باشد از DB برمی‌گردد. | `POST /api/crop-simulation/current-farm-chart/` | `implemented` | `live AI inference` | route واقعی AI |
- اگر موجود نباشد فقط task صف می‌شود و `202` می‌دهد. | `POST /api/crop-simulation/harvest-prediction/` | `implemented` | `live AI inference` | route واقعی AI |
- ضعف‌ها: | `GET /api/crop-simulation/yield-harvest-summary/` | `implemented` | `AI-owned derived output` | route واقعی AI |
- در حالت `202` هنوز خود داده خاک برنگشته، فقط شروع فرآیند واکشی اعلام می‌شود. | `POST /api/crop-simulation/yield-prediction/` | `implemented` | `live AI inference` | route واقعی AI |
- تطابق location بر اساس `lat/lon` گرد شده تا 6 رقم است؛ برای نقاط خیلی نزدیک ممکن است حساسیت مکانی محدود شود. | `POST /api/crop-simulation/growth/` | `implemented` | `async live AI inference` | route واقعی AI |
- کیفیت نهایی کاملا وابسته به سرویس بیرونی خاک و کامل شدن depthهاست. | `GET /api/crop-simulation/growth/{task_id}/status/` | `implemented` | `async status` | route واقعی AI |
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این API خودش مدل پیش‌بینی ندارد؛ ریسک اصلی از ناقص بودن داده منبع یا incomplete بودن رکورد است. ## مواردی که نباید به‌عنوان route واقعی AI معرفی شوند
- مناسب برای:
- واکشی داده خام خاک و persistence. | Endpoint | تصمیم |
- نامناسب برای: |---|---|
- استنتاج agronomy بدون بررسی منبع و completeness. | `POST /api/farm-alerts/timeline/` | `missing` |
- سطح اعتماد: | `GET /api/fertilization/recommend/{task_id}/status/` | `stub/contract-only` |
- `زیاد` وقتی `source=database` و location کامل است. | `GET /api/irrigation/recommend/{task_id}/status/` | `stub/contract-only` |
- `کم` وقتی فقط `task_id` گرفته‌اید و هنوز داده واقعی ندارید. | هر route موجود فقط در `Backend/external_api_adapter/json/ai/index.json` و بدون registration واقعی | `stub/contract-only` |
### `GET /api/soil-data/tasks/{task_id}/status/` ## توضیح مهم درباره mock/spec
- منبع داده: وضعیت Celery + نتیجه ذخیره شده در DB. فایل `Backend/external_api_adapter/json/ai/index.json` باید به‌عنوان `contract/mock catalog` دیده شود، نه لیست endpointهای تضمین‌شده‌ی production.
- ضعف‌ها: اگر endpoint فقط در آن فایل وجود دارد ولی در `Ai/config/urls.py` و routeهای اپ‌ها ثبت نشده، وضعیت آن `stub/contract-only` است.
- ممکن است task موفق شده باشد ولی رکورد location هنوز کامل نباشد.
- خروجی `SUCCESS` لزوما به معنی کیفیت agronomic داده نیست؛ فقط completion فنی را نشان می‌دهد. ## Ownership مهم
- سطح اعتماد:
- `متوسط` برای وضعیت فنی task. - plant catalog canonical در Backend شروع می‌شود و AI snapshot/read-model آن را ingest می‌کند.
- `کم تا متوسط` برای اعتماد به خود محتوای خاک، بسته به کامل بودن رکورد. - `farm_data` در AI facade canonical برای مصرف AI روی farm/sensor/plant assignment است.
- relation قدیمی `SensorData.plants` transitional است و نباید به‌عنوان source-of-truth جدید مستند شود.
### `POST /api/soil-data/ndvi-health/`
## Known Gaps / Follow-up
- منبع داده: observation ماهواره‌ای NDVI در `location_data.ndvi`.
- ضعف‌ها: - schema UI غیرفعال است؛ audit docs منبع فعلی truth هستند.
- اگر observation موجود نباشد، کارت عملا به وضعیت `Unavailable` می‌رسد. - بعضی endpointها در backend و AI هر دو وجود دارند اما semantics آن‌ها متفاوت است؛ همیشه live/cached/proxy بودن را جداگانه مستند کنید.
- تفسیر NDVI ساده است و به تنهایی برای تشخیص علت تنش کافی نیست.
- NDVI میانگین مزرعه است؛ heterogeneity داخل مزرعه را کامل نشان نمی‌دهد.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- NDVI فقط شاخص پوشش گیاهی است، نه تشخیص علت؛ کمبود آب، بیماری، تغذیه یا خطای سنجش ممکن است با هم اشتباه شوند.
- سطح اعتماد:
- `متوسط` برای پایش کلی وضعیت پوشش گیاهی.
- `کم` برای تصمیم‌گیری علّی یا درمانی.
---
## 2) Soile API
### `POST /api/soile/moisture-heatmap/`
- منبع داده:
- latest measurement هر سنسور
- وزن‌دهی تازگی داده
- جریمه outlier
- interpolation از نوع weighted IDW
- mask مرز مزرعه
- ضعف‌ها:
- history واقعی سنسورها هنوز مدل نمی‌شود.
- drift سنسور، calibration uncertainty و quality assurance واقعی لحاظ نشده‌اند.
- depth-specific map به صورت measured واقعی نیست و بخشی از لایه‌ها از soil profile مشتق می‌شوند.
- uncertainty به صورت heuristic برآورد می‌شود.
- با تراکم سنسور پایین یا farm نامتقارن، heatmap بیشتر visualization است تا اندازه‌گیری دقیق.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- مدل interpolation علمی پیشرفته مثل kriging یا مدل uncertainty مکانی ندارد.
- خروجی برای zoning دقیق agronomy کافی نیست.
- سطح اعتماد:
- `متوسط` برای visualization و درک trend کلی.
- `کم` برای تصمیم آبیاری دقیق نقطه‌ای یا نسخه agronomy.
### `POST /api/soile/health-summary/`
- منبع داده: داده سنسور + خلاصه‌ساز سلامت خاک.
- ضعف‌ها:
- خلاصه سلامت ذاتا compression از چند متریک است و همه context مزرعه را در بر نمی‌گیرد.
- اگر سنسورهای مزرعه کم‌تعداد یا داده‌هایشان قدیمی باشد، summary گمراه‌کننده می‌شود.
- سطح اعتماد:
- `متوسط` برای dashboard summary.
- `کم تا متوسط` برای تصمیم اصلاح خاک بدون نمونه‌برداری میدانی.
### `POST /api/soile/anomaly-detection/`
- منبع داده: anomaly آماری + تفسیر RAG.
- ضعف‌ها:
- anomaly detection بر اساس context موجود است و به history و baseline کامل چندفصلی متکی نیست.
- تفسیر نهایی توسط RAG تولید می‌شود و deterministic نیست.
- اگر anomaly آماری واقعی نباشد، LLM ممکن است فقط آن را با متن تخصصی بسط دهد.
- سطح اعتماد:
- `متوسط` برای پیدا کردن موارد مشکوک.
- `کم` برای diagnosis نهایی بدون تایید کارشناس.
---
## 3) Weather API
### `POST /api/weather/farm-card/`
- منبع داده: forecastهای ذخیره‌شده در DB از سرویس هواشناسی خارجی.
- ضعف‌ها:
- docstring سرویس هنوز misleading است، ولی کد واقعا `requests.get` می‌زند.
- اگر forecast تازه نباشد، کارت weather کهنه می‌شود.
- کارت فقط summary ساده از forecast است و uncertainty پیش‌بینی هوا را منتقل نمی‌کند.
- اگر forecast موجود نباشد، خروجی عملا صفر/نامشخص می‌شود.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- مشکل اصلی فرمول نیست؛ dependency به forecast خارجی و freshness داده است.
- سطح اعتماد:
- `متوسط تا زیاد` وقتی forecast تازه و کامل موجود باشد.
- `کم` وقتی داده weather ناقص یا قدیمی باشد.
### `POST /api/weather/water-need-prediction/`
- منبع داده:
- محاسبات ET/crop profile از `irrigation.evapotranspiration`
- forecast هفت‌روزه
- تفسیر تکمیلی از RAG
- ضعف‌ها:
- دقت خروجی به کیفیت crop profile، growth stage و راندمان روش آبیاری وابسته است.
- horizon فقط کوتاه‌مدت است.
- insight متنی توسط LLM تولید می‌شود و fallback هم دارد.
- اگر forecast ضعیف باشد، کل محاسبه نیاز آبی ضعیف می‌شود.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای برنامه‌ریزی کوتاه‌مدت خوب است، اما irrigation scheduling دقیق در مزرعه واقعی هنوز به سنسور و بازخورد عملی نیاز دارد.
- سطح اعتماد:
- `متوسط` برای برنامه‌ریزی 3 تا 7 روزه.
- `کم تا متوسط` برای تصمیم اجرایی بدون کنترل میدانی.
---
## 4) Economy API
### `POST /api/economy/overview/`
- منبع داده: `mock` صریح در سرویس.
- ضعف‌ها:
- کل خروجی ساختگی است.
- اعداد هزینه آب، صرفه‌جویی، درآمد و هزینه کود از مزرعه واقعی استخراج نمی‌شوند.
- trend chart نیز mock است.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این API فعلا برای عملیات واقعی قابل اتکا نیست.
- سطح اعتماد:
- `خیلی کم`
---
## 5) Plant API
### `GET|POST /api/plants/` و `GET|PUT|PATCH|DELETE /api/plants/{id}/`
- منبع داده: رکوردهای DB.
- ضعف‌ها:
- اعتماد به محتوای agronomic رکوردها بستگی به کسی دارد که داده را وارد کرده است.
- validation دامنه‌ای یا علمی عمیق روی محتوا دیده نمی‌شود.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- مشکل اصلی از جنس data governance است، نه الگوریتم.
- سطح اعتماد:
- `زیاد` برای این‌که رکوردی که ذخیره شده همان برگردد.
- `متوسط` برای اعتماد علمی به محتوای متنی رکوردها.
### `POST /api/plants/fetch-info/`
- منبع داده: قرار بوده API خارجی باشد، ولی فعلا `None` برمی‌گرداند.
- ضعف‌ها:
- عملا پیاده‌سازی نشده است.
- در حالت فعلی `503` می‌دهد.
- سطح اعتماد:
- `خیلی کم`
---
## 6) Farm Data API
### `POST /api/farm-data/`
- منبع داده:
- boundary مزرعه
- sensor payload
- واکشی خودکار location و weather
- نقاط قوت فعلی:
- مرکز مزرعه با centroid هندسی polygon محاسبه می‌شود، نه average ساده.
- merge سنسورها flat و overwrite خاموش ندارد؛ payload هر sensor جدا می‌ماند.
- ضعف‌ها:
- centroid هندسی هنوز برای همه use-caseها بهترین نقطه نمونه‌برداری نیست؛ مخصوصا در مزرعه‌های خیلی کشیده یا چندبخشی.
- health داده به شدت به درست بودن boundary ورودی وابسته است.
- location و weather فقط برای یک نقطه مرکزی resolve می‌شوند؛ variability در سطح مزرعه از بین می‌رود.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این API بیشتر data-ingestion است؛ ضعف اصلی spatial representativeness است.
- سطح اعتماد:
- `زیاد` برای ذخیره‌سازی و linkage داده.
- `متوسط` برای این فرض که یک center point نماینده کل مزرعه است.
### `GET /api/farm-data/{farm_uuid}/detail/`
- منبع داده:
- sensor payload
- soil depth data
- weather
- plants و irrigation method
- resolved_metrics با aggregation deterministic
- نقاط قوت فعلی:
- conflict چند سنسور silent overwrite نمی‌شود.
- source هر metric در `metric_sources` مشخص می‌شود.
- ضعف‌ها:
- aggregation متریک‌های عددی در تعارض، به average ختم می‌شود؛ این روش همیشه از نظر agronomy بهترین strategy نیست.
- sensor locality و timestamp تفکیک‌شده در resolved_metrics نهایی از بین می‌رود.
- برای farm چندناحیه‌ای، یک resolved metric ممکن است variance مهم را پنهان کند.
- سطح اعتماد:
- `متوسط تا زیاد` برای unified context.
- `متوسط` برای تصمیم نقطه‌ای داخل مزرعه.
### `POST /api/farm-data/parameters/`
- منبع داده: DB و log تغییرات.
- ضعف‌ها:
- افزودن پارامتر جدید به معنی معتبر بودن agronomic آن نیست.
- کیفیت metadata وابسته به داده ورودی کاربر است.
- سطح اعتماد:
- `زیاد` برای ثبت و نگهداری metadata.
- `متوسط` برای معنی علمی metadata.
---
## 7) Irrigation API
### `GET|POST /api/irrigation/` و `GET /api/irrigation/{id}/`
- منبع داده: رکوردهای روش آبیاری در DB.
- ضعف‌ها:
- درصد راندمان و توضیحات روش آبیاری اگر دستی وارد شده باشند، لزوما calibrated برای مزرعه خاص نیستند.
- سطح اعتماد:
- `زیاد` برای بازگرداندن داده ذخیره‌شده.
- `متوسط` برای کاربرد agronomic عمومی.
### `POST /api/irrigation/recommend/`
- منبع داده:
- sensor/farm context
- plant data
- irrigation method
- محاسبات FAO-56 و optimizer
- متن نهایی از RAG
- نقاط قوت فعلی:
- پاسخ نهایی provenance دارد و مشخص می‌کند کدام فیلد از LLM و کدام از fallback آمده است.
- ضعف‌ها:
- merge نهایی هنوز deterministic است و برای پایداری UI fallback را نگه می‌دارد.
- اگر crop profile، weather یا irrigation method درست نباشد، recommendation قابل اتکا نیست.
- optimizer و LLM هر دو وابسته به کیفیت ورودی هستند.
- خروجی ممکن است از نظر ظاهر کامل باشد، حتی وقتی بخشی از آن fallback است.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای تصمیم نهایی آبیاری در مزرعه، باید provenance و fallback بودن بخش‌ها دیده شود.
- سطح اعتماد:
- `متوسط` وقتی داده مزرعه کامل باشد و fallback محدود باشد.
- `کم تا متوسط` وقتی بخش‌های زیادی fallback شده باشند.
### `POST /api/irrigation/water-stress/`
- منبع داده:
- ابتدا سرویس crop simulation
- در صورت خطا fallback به فرمول ساده `clamp(round(35 - (soil_moisture / 2)), 0, 100)`
- ضعف‌ها:
- اگر engine اصلی خطا بدهد هنوز به فرمول ساده سنسوری fallback می‌شود.
- fallback فقط از `soil_moisture` استفاده می‌کند و ET0، بارش، root depth، نوع گیاه، مرحله رشد و روند زمانی را نادیده می‌گیرد.
- بنابراین ظاهر endpoint ممکن است simulation-based باشد اما در failure عملا heuristic برگرداند.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- هر خروجی‌ای که `sourceMetric.engine = sensor_fallback` داشته باشد برای KPI دقیق تنش آبی قابل اتکا نیست.
- سطح اعتماد:
- `متوسط` اگر واقعا از simulation engine آمده باشد.
- `کم` اگر fallback سنسوری فعال شده باشد.
---
## 8) Fertilization API
### `POST /api/fertilization/recommend/`
- منبع داده:
- sensor/farm context
- plant data
- optimizer
- RAG
- نقاط قوت فعلی:
- مانند irrigation، provenance بخش‌ها اضافه شده و تشخیص fallback بهتر شده است.
- ضعف‌ها:
- merge نهایی هنوز deterministic است و ساختار UI را حتی در خروجی ضعیف حفظ می‌کند.
- وضعیت واقعی عناصر غذایی مزرعه به sample quality و coverage محدود است.
- مدل crop-specific کامل و region-specific صریح دیده نمی‌شود.
- پاسخ متنی نهایی LLM-based است.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای نسخه کود نهایی بدون آزمون خاک و تایید agronomist کافی نیست.
- سطح اعتماد:
- `متوسط` برای پیشنهاد اولیه.
- `کم تا متوسط` برای prescription نهایی.
---
## 9) Pest & Disease API
### `POST /api/pest-disease/detect/`
- منبع داده:
- تصویر ارسالی
- context مزرعه
- knowledge base
- LLM vision / RAG
- ضعف‌ها:
- دقت شدیدا به کیفیت، زاویه، نور و فاصله تصویر وابسته است.
- خروجی deterministic نیست.
- confidence هم از خود مدل می‌آید و لزوما calibrated نیست.
- ممکن است علائم تغذیه‌ای، abiotic stress و بیماری با هم اشتباه شوند.
- سطح اعتماد:
- `کم تا متوسط` برای triage اولیه.
- `کم` برای تصمیم سم‌پاشی یا تشخیص قطعی.
### `POST /api/pest-disease/risk/`
- منبع داده:
- context مزرعه
- weather
- knowledge base
- RAG
- ضعف‌ها:
- هرچند intent آن RAG-based است، در لایه سرویس هنوز fallback heuristic نیز وجود دارد.
- risk forecast region-specific و crop-specific calibrated model ندارد.
- خروجی نهایی به retrieval quality و model behavior وابسته است.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این endpoint باید فعلا به عنوان expert-assist دیده شود، نه predictive model قطعی.
- سطح اعتماد:
- `متوسط` برای آگاهی از ریسک کلی.
- `کم` برای عملیات خودکار یا قطعی.
## 10) Crop Simulation API
### `POST /api/crop-simulation/growth/`
- منبع داده:
- weather
- soil
- crop parameters
- agromanagement
- PCSE engine
- ضعف‌ها:
- اگر پارامترهای واقعی مزرعه ناقص باشند، سیستم از defaultهای زیادی استفاده می‌کند.
- بخشی از پارامترها مثل `ELEV`, `IRRAD`, `RDMSOL`, `WAV`, `NAVAILI` پیش‌فرض‌محور هستند.
- کیفیت شبیه‌سازی به کیفیت profile گیاه و agromanagement وابسته است.
- نکته مهم:
- در failure فعلی، خطا propagate می‌شود و دیگر fallback projection در مسیر اصلی برگردانده نمی‌شود؛ این از نظر شفافیت خوب است.
- سطح اعتماد:
- `متوسط` وقتی ورودی‌ها خوب باشند و PCSE درست اجرا شود.
- `کم تا متوسط` وقتی بخش زیادی از ورودی‌ها default باشند.
### `GET /api/crop-simulation/growth/{task_id}/status/`
- منبع داده: status تسک + نتیجه شبیه‌سازی.
- ضعف‌ها:
- وضعیت `SUCCESS` به معنی خوب بودن ورودی‌های مدل نیست.
- page‌بندی timeline فقط presentation است و تضمین کیفیت علمی مدل نمی‌دهد.
- سطح اعتماد:
- `زیاد` برای وضعیت فنی task.
- `متوسط` برای محتوای simulation output.
### `POST /api/crop-simulation/current-farm-chart/`
- منبع داده: simulation engine + context فعلی مزرعه.
- ضعف‌ها:
- chart از شبیه‌سازی می‌آید، نه مشاهده واقعی برگ/بیوماس/weight.
- `leaf_count_estimate` و برخی شاخص‌ها derived هستند، نه measured.
- اگر weather forecast یا plant profile ضعیف باشد، chart گمراه‌کننده می‌شود.
- سطح اعتماد:
- `متوسط` برای KPI بصری و trend.
- `کم تا متوسط` برای تصمیم agronomy دقیق.
### `POST /api/crop-simulation/harvest-prediction/`
- منبع داده:
- growth simulation
- GDD profile
- extrapolation تا رسیدگی
- ضعف‌ها:
- اگر maturity داخل بازه forecast رخ ندهد، تاریخ برداشت با extrapolation از میانگین رشد برآورد می‌شود.
- بنابراین بخش‌هایی از تاریخ برداشت پیش‌بینی‌شده ممکن است فراتر از خروجی واقعی engine باشند.
- وابستگی زیاد به `required_gdd_for_maturity` و current profile گیاه دارد.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای برنامه‌ریزی برداشت به عنوان estimate مناسب است، نه تعهد تقویمی قطعی.
- سطح اعتماد:
- `متوسط`
### `POST /api/crop-simulation/yield-prediction/`
- منبع داده: خروجی شبیه‌سازی رشد و تبدیل آن به yield KPI.
- ضعف‌ها:
- عملکرد نهایی به کیفیت شبیه‌سازی upstream وابسته است.
- اگر داده soil fertility، weather یا management واقعی نباشند، yield estimate فقط تقریبی است.
- حمایت از uncertainty interval یا confidence band دیده نمی‌شود.
- سطح اعتماد:
- `متوسط رو به کم`
---
## 11) Farm Alerts API
### `POST /api/farm-alerts/tracker/`
- منبع داده:
- rule-based tracker
- thresholdهای hard-coded
- خلاصه و notification با RAG/LLM
- ضعف‌ها:
- alert tracker پایه، heuristic و threshold-driven است.
- آستانه‌های moisture، pH، EC، fungal risk و ... hard-coded هستند.
- region-specific و crop-specific calibration محدود است.
- اگر LLM JSON بد برگرداند، fallback rule-based جای آن را می‌گیرد.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- برای هشدارآگاهی مناسب است، نه برای auto-actuation.
- سطح اعتماد:
- `متوسط` برای شناسایی موارد مشکوک.
- `کم تا متوسط` برای اقدام خودکار.
### `POST /api/farm-alerts/timeline/`
- منبع داده: همان tracker + LLM timeline.
- ضعف‌ها:
- timeline بیشتر narration است تا سنجش مستقل.
- اگر مبنای alertها heuristic باشد، timeline فقط همان heuristic را بازبسته‌بندی می‌کند.
- سطح اعتماد:
- `کم تا متوسط`
---
## 12) RAG API
### `POST /api/rag/chat/`
- منبع داده:
- farm context
- history
- images احتمالی
- retrieval از knowledge base
- LLM
- ضعف‌ها:
- پاسخ stream می‌شود و structure قراردادی سختی ندارد.
- hallucination، retrieval miss، و dependence به prompt/tone وجود دارد.
- برای سوال‌های policy/diagnosis/operation ممکن است confident but wrong باشد.
- اگر فرمول/اطلاعات قابل اتکا نباشد:
- این endpoint باید دستیار تحلیلی در نظر گرفته شود، نه source of truth.
- سطح اعتماد:
- `کم تا متوسط`
---
## 13) جمع‌بندی نهایی بر اساس کاربرد
### APIهای مناسب برای استفاده عملی‌تر
- `POST /api/farm-data/`
- `GET /api/farm-data/{farm_uuid}/detail/`
- `GET|POST /api/soil-data/`
- `POST /api/weather/farm-card/`
این‌ها بیشتر data-centric هستند و اگر داده منبع خوب باشد، قابل اتکاترند.
### APIهای مناسب برای dashboard و insight، نه تصمیم قطعی
- `POST /api/soile/moisture-heatmap/`
- `POST /api/soile/health-summary/`
- `POST /api/weather/water-need-prediction/`
- `POST /api/crop-simulation/current-farm-chart/`
- `POST /api/crop-simulation/harvest-prediction/`
- `POST /api/crop-simulation/yield-prediction/`
- `POST /api/farm-alerts/tracker/`
- `POST /api/farm-alerts/timeline/`
### APIهای کم‌اعتماد یا نیازمند بازطراحی
- `POST /api/economy/overview/`
- `POST /api/plants/fetch-info/`
- `POST /api/irrigation/water-stress/` در حالت fallback
- `POST /api/pest-disease/detect/` برای diagnosis قطعی
- `POST /api/pest-disease/risk/`
- `POST /api/rag/chat/` برای پاسخ‌های حساس و اجرایی
---
## 14) پیشنهاد اولویت اصلاح
1. حذف fallback ساده از `water-stress` یا حداقل expose کردن صریح نوع engine در top-level response
2. جایگزینی mock در `economy` با data pipeline واقعی
3. پیاده‌سازی واقعی `plants/fetch-info`
4. افزودن uncertainty و freshness صریح به heatmap و water-need outputs
5. crop-specific و region-specific کردن alert/risk thresholds
6. اضافه کردن confidence واقعی و source provenance استاندارد به تمام endpointهای RAG-based
+55 -347
View File
@@ -1,347 +1,55 @@
# گزارش کامل اپ‌ها، URLها و بررسی ضعف‌های پیاده‌سازی # AI Apps URL Audit
## خلاصه اجرایی This document lists the actual AI-service routes registered today and labels their readiness accurately.
این مخزن یک پروژه Django با چند اپ API مستقل است. از نظر مسیرها، ساختار کلی مناسب است، اما چند نقطه مهم در پیاده‌سازی دیده می‌شود: ## Canonical AI Route Inventory
- بعضی endpointها مستقیما `mock` یا `stub` هستند و خروجی واقعی تولید نمی‌کنند. | App | Method | Route | Status | Notes |
- بعضی endpointها در صورت خطای LLM یا نبود سرویس بیرونی، خروجی fallback می‌دهند که واقعی است ولی کاملا مدل‌محور/تقریبی است. |---|---|---|---:|---|
- چند ضعف ساختاری وجود دارد که می‌تواند باعث خروجی ناقص، 405 ناخواسته، یا داده ظاهرا واقعی اما غیرقابل اتکا شود. | `rag` | `POST` | `/api/rag/chat/` | `implemented` | Live AI chat route. |
- بخش Weather و Plant هنوز به سرویس بیرونی واقعی کامل وصل نشده‌اند. | `farm_alerts` | `POST` | `/api/farm-alerts/tracker/` | `implemented` | Live AI tracker route. |
- در چند بخش، محاسبات ساده‌سازی شده‌اند و برای KPI عملیاتی یا تصمیم‌گیری دقیق کشاورزی کافی نیستند. | `location_data` | `GET` | `/api/soil-data/` | `implemented` | Live soil-data fetch route. |
| `location_data` | `POST` | `/api/soil-data/` | `implemented` | Live soil-data fetch route. |
--- | `location_data` | `GET` | `/api/soil-data/tasks/<task_id>/status/` | `implemented` | Live task status route. |
| `location_data` | `POST` | `/api/soil-data/ndvi-health/` | `implemented` | Live AI NDVI route. |
## URLهای اصلی پروژه | `soile` | `POST` | `/api/soile/anomaly-detection/` | `implemented` | Live AI route. |
| `soile` | `POST` | `/api/soile/health-summary/` | `implemented` | Live AI route. |
### URLهای عمومی | `soile` | `POST` | `/api/soile/moisture-heatmap/` | `implemented` | Live AI route. |
| `farm_data` | `POST` | `/api/farm-data/` | `implemented` | Upsert route. |
| Method | URL | توضیح | | `farm_data` | `GET` | `/api/farm-data/<farm_uuid>/detail/` | `implemented` | Farm detail route. |
|---|---|---| | `farm_data` | `POST` | `/api/farm-data/parameters/` | `implemented` | Sensor parameter create route. |
| GET | `/admin/` | پنل ادمین Django | | `farm_data` | `POST` | `/api/farm-data/plants/sync/` | `implemented` | Internal sync route. |
| GET | `/api/schema/` | OpenAPI schema | | `weather` | `POST` | `/api/weather/farm-card/` | `implemented` | Live weather card route. |
| GET | `/api/docs/` | Swagger UI | | `weather` | `POST` | `/api/weather/water-need-prediction/` | `implemented` | Live water need prediction route. |
| GET | `/api/redoc/` | ReDoc | | `economy` | `POST` | `/api/economy/overview/` | `implemented` | Live economy route. |
| `plant` | `GET` | `/api/plants/` | `implemented` | Live route. |
### App: RAG | `plant` | `POST` | `/api/plants/` | `implemented` | Live route. |
| `plant` | `GET` | `/api/plants/names/` | `implemented` | Extra route not always reflected in older audits. |
Base: `/api/rag/` | `plant` | `GET` | `/api/plants/<pk>/` | `implemented` | Live route. |
| `plant` | `POST` | `/api/plants/fetch-info/` | `implemented` | Live route, but operational reliability may still be limited. |
| Method | URL | توضیح | | `pest_disease` | `POST` | `/api/pest-disease/detect/` | `implemented` | Live route. |
|---|---|---| | `pest_disease` | `POST` | `/api/pest-disease/risk/` | `implemented` | Live route. |
| POST | `/api/rag/chat/` | چت RAG به صورت stream | | `irrigation` | `GET` | `/api/irrigation/` | `implemented` | Live route. |
| `irrigation` | `POST` | `/api/irrigation/` | `implemented` | Live route on AI service. |
### App: Farm Alerts | `irrigation` | `GET` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
| `irrigation` | `PUT` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
Base: `/api/farm-alerts/` | `irrigation` | `PATCH` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
| `irrigation` | `DELETE` | `/api/irrigation/<pk>/` | `implemented` | Live route on AI service. |
| Method | URL | توضیح | | `irrigation` | `POST` | `/api/irrigation/recommend/` | `implemented` | Live route. |
|---|---|---| | `irrigation` | `POST` | `/api/irrigation/plan-from-text/` | `implemented` | Live route. |
| POST | `/api/farm-alerts/tracker/` | تحلیل tracker هشدارها | | `irrigation` | `POST` | `/api/irrigation/water-stress/` | `implemented` | Live route. |
| POST | `/api/farm-alerts/timeline/` | ساخت timeline هشدارها | | `fertilization` | `POST` | `/api/fertilization/recommend/` | `implemented` | Live route. |
| `fertilization` | `POST` | `/api/fertilization/plan-from-text/` | `implemented` | Live route. |
### App: Location Data / Soil Data | `crop_simulation` | `POST` | `/api/crop-simulation/current-farm-chart/` | `implemented` | Live route. |
| `crop_simulation` | `POST` | `/api/crop-simulation/harvest-prediction/` | `implemented` | Live route. |
Base: `/api/soil-data/` | `crop_simulation` | `GET` | `/api/crop-simulation/yield-harvest-summary/` | `implemented` | Live route. |
| `crop_simulation` | `POST` | `/api/crop-simulation/yield-prediction/` | `implemented` | Live route. |
| Method | URL | توضیح | | `crop_simulation` | `POST` | `/api/crop-simulation/growth/` | `implemented` | Live route. |
|---|---|---| | `crop_simulation` | `GET` | `/api/crop-simulation/growth/<task_id>/status/` | `implemented` | Live route. |
| GET | `/api/soil-data/` | واکشی داده خاک با `lat` و `lon` |
| POST | `/api/soil-data/` | واکشی داده خاک با body | ## Important Corrections
| GET | `/api/soil-data/tasks/<task_id>/status/` | وضعیت تسک خاک |
| POST | `/api/soil-data/ndvi-health/` | کارت NDVI مزرعه | - `farm-alerts/timeline` is not an AI route and must not be listed as one.
- `risk-summary` belongs to backend aliasing in `Backend/pest_detection`, not to the AI `pest_disease` app.
### App: Soile - `plant` and `irrigation` have richer real route coverage than older audits claimed.
- `location_data`, `farm_data`, and `crop_simulation` routes are real service routes and should not be described as mock-only.
Base: `/api/soile/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/soile/anomaly-detection/` | تحلیل ناهنجاری خاک |
| POST | `/api/soile/health-summary/` | خلاصه سلامت خاک |
| POST | `/api/soile/moisture-heatmap/` | heatmap رطوبت خاک |
### App: Farm Data
Base: `/api/farm-data/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/farm-data/` | ایجاد/آپدیت داده مزرعه |
| GET | `/api/farm-data/<farm_uuid>/detail/` | جزئیات تجمیعی مزرعه |
| POST | `/api/farm-data/parameters/` | ایجاد/ویرایش پارامتر سنسور |
### App: Weather
Base: `/api/weather/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/weather/farm-card/` | کارت وضعیت آب‌وهوا |
| POST | `/api/weather/water-need-prediction/` | پیش‌بینی نیاز آبی 7 روز آینده |
### App: Economy
Base: `/api/economy/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/economy/overview/` | نمای اقتصادی مزرعه |
### App: Plant
Base: `/api/plants/`
| Method | URL | توضیح |
|---|---|---|
| GET | `/api/plants/` | لیست گیاهان |
| POST | `/api/plants/` | ایجاد گیاه |
| GET | `/api/plants/<pk>/` | جزئیات گیاه |
| PUT | `/api/plants/<pk>/` | ویرایش کامل گیاه |
| PATCH | `/api/plants/<pk>/` | ویرایش جزئی گیاه |
| DELETE | `/api/plants/<pk>/` | حذف گیاه |
| POST | `/api/plants/fetch-info/` | دریافت اطلاعات گیاه از API بیرونی |
### App: Pest & Disease
Base: `/api/pest-disease/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/pest-disease/detect/` | تشخیص آفت/بیماری از تصویر |
| POST | `/api/pest-disease/risk/` | پیش‌بینی ریسک آفات و بیماری |
### App: Irrigation
Base: `/api/irrigation/`
| Method | URL | توضیح |
|---|---|---|
| GET | `/api/irrigation/` | لیست روش‌های آبیاری |
| POST | `/api/irrigation/` | ایجاد روش آبیاری |
| GET | `/api/irrigation/<pk>/` | جزئیات روش آبیاری |
| POST | `/api/irrigation/recommend/` | توصیه آبیاری |
| POST | `/api/irrigation/water-stress/` | شاخص تنش آبی |
> نکته: در کد متدهای `PUT/PATCH/DELETE` برای روش آبیاری نوشته شده‌اند، اما به کلاس اشتباه وصل شده‌اند و عملا روی route جزئیات اعمال نمی‌شوند.
### App: Fertilization
Base: `/api/fertilization/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/fertilization/recommend/` | توصیه کودهی |
### App: Crop Simulation
Base: `/api/crop-simulation/`
| Method | URL | توضیح |
|---|---|---|
| POST | `/api/crop-simulation/current-farm-chart/` | نمودار شبیه‌سازی وضعیت فعلی مزرعه |
| POST | `/api/crop-simulation/harvest-prediction/` | پیش‌بینی برداشت |
| POST | `/api/crop-simulation/yield-prediction/` | پیش‌بینی عملکرد |
| POST | `/api/crop-simulation/growth/` | شروع شبیه‌سازی رشد |
| GET | `/api/crop-simulation/growth/<task_id>/status/` | وضعیت شبیه‌سازی رشد |
---
## خروجی‌های Mock / Stub / Sample قطعی
### 1) Economy کاملا mock است
- سرویس `EconomicOverviewService` مستقیم `source: "mock"` برمی‌گرداند و همه داده‌ها ثابت هستند: `economy/services.py:7`
- خود description ویو هم صراحتا می‌گوید فعلا mock است: `economy/views.py:31`
- نتیجه: endpoint `POST /api/economy/overview/` فعلا برای استفاده واقعی قابل اتکا نیست.
### 2) Plant Fetch Info هنوز پیاده‌سازی نشده
- تابع اتصال بیرونی عملا `None` برمی‌گرداند: `plant/services.py:10`
- endpoint هم در این حالت `503` می‌دهد: `plant/views.py:292`
- نتیجه: `POST /api/plants/fetch-info/` فعلا هیچ داده واقعی از سرویس خارجی نمی‌گیرد.
### 3) Weather داده نمونه seeded دارد
- migration داده آب‌وهوای نمونه 7 روزه برای همه `SoilLocation`ها می‌سازد: `weather/migrations/0003_seed_weather_forecasts.py:1`
- نتیجه: در محیطی که migration اجرا شده باشد، بخشی از خروجی‌های Weather ممکن است از sample data بیایند نه API واقعی.
---
## خروجی‌هایی که mock مستقیم نیستند ولی fallback / تقریبی می‌دهند
### 1) Weather API از نظر مستندات و رفتار واقعی کد ناهماهنگ است
- داک‌استرینگ هنوز نوشته `TODO: پیاده‌سازی اتصال واقعی به API`: `weather/services.py:23`
- اما خود تابع در عمل `requests.get(...)` می‌زند و `response.json()` برمی‌گرداند: `weather/services.py:67`
- مسیر `no_data` در کد وجود دارد، ولی با پیاده‌سازی فعلی بیشتر یک branch دفاعی/قدیمی است تا رفتار اصلی: `weather/services.py:149`
- در `farm_data` اگر نتیجه weather برابر `no_data` باشد، خطا محسوب نمی‌شود و فرایند ادامه پیدا می‌کند: `farm_data/services.py:143`
- نتیجه: طراحی فعلی هنوز اجازه می‌دهد مزرعه بدون weather قابل اتکا ثبت/آپدیت شود، و این ابهام با وجود داده‌های seed شده شدیدتر می‌شود.
### 2) NDVI در نبود تنظیمات ماهواره‌ای خروجی unavailable می‌دهد
- اگر `SATELLITE_NDVI_ENDPOINT` و `SATELLITE_NDVI_API_KEY` تنظیم نشده باشد، client عملا داده نمی‌آورد: `location_data/remote_sensing.py:77`
- در این حالت کارت NDVI با `vegetation_health_class = "Unavailable"` و پیام نبود داده ماهواره‌ای برمی‌گردد: `location_data/ndvi.py:33`
- نتیجه: `POST /api/soil-data/ndvi-health/` ممکن است پاسخ موفق بدهد ولی داده واقعی NDVI نداشته باشد.
### 3) Farm Alerts در خطای LLM fallback می‌سازد
- اگر LLM خطا بدهد، خروجی خالی برمی‌گردد: `farm_alerts/services.py:353`
- سپس tracker و timeline از fallback داخلی ساخته می‌شوند: `farm_alerts/services.py:376`, `farm_alerts/services.py:413`
- نتیجه: خروجی این endpointها همیشه ممکن است LLM-native نباشد و از هشدارهای ساختاریافته داخلی ساخته شده باشد.
### 4) Soil Anomaly در خطای LLM fallback تحلیلی می‌دهد
- در exception خروجی fallback بازگردانده می‌شود: `rag/services/soil_anomaly.py:181`
- حتی اگر JSON مدل نامعتبر باشد، fallback جایگزین می‌شود: `rag/services/soil_anomaly.py:192`
- نتیجه: `POST /api/soile/anomaly-detection/` ممکن است تحلیل واقعی مدل زبانی نباشد.
### 5) Pest & Disease detect/risk در خطای LLM fallback دارند
- تشخیص تصویر در failure به fallback برمی‌گردد: `rag/services/pest_disease.py:321`
- ریسک آفات/بیماری هم در failure به fallback برمی‌گردد: `rag/services/pest_disease.py:388`
- نتیجه: پاسخ ممکن است ساختاری و معتبر باشد، اما برآمده از rule/fallback باشد نه inference کامل مدل.
### 6) Water Need Prediction insight در failure fallback می‌دهد
- در خطای LLM fallback summary برمی‌گردد: `rag/services/water_need_prediction.py:165`
- نتیجه: لایه insight توضیحی همیشه تضمین نمی‌کند که از مدل آمده باشد.
### 7) توصیه‌های آبیاری و کودهی merge با fallback می‌شوند
- پاسخ آبیاری با fallback merge می‌شود: `rag/services/irrigation.py:147`
- پاسخ کودهی هم با fallback merge می‌شود: `rag/services/fertilization.py:130`
- نتیجه: حتی وقتی LLM جواب می‌دهد، بخش‌هایی از خروجی ممکن است از template/fallback آمده باشد.
### 8) Crop Simulation در failure از projection fallback استفاده می‌کند
- اگر engine اصلی خطا بدهد، `_run_projection_engine` استفاده می‌شود: `crop_simulation/growth_simulation.py:404`
- نتیجه: بعضی نتایج crop simulation ممکن است تقریبی باشند نه خروجی engine اصلی.
---
## ضعف‌های مهم پیاده‌سازی
### 1) باگ واضح در route جزئیات Irrigation
- route جزئیات به `IrrigationMethodDetailView` وصل است: `irrigation/urls.py:12`
- اما متدهای `put/patch/delete` داخل `WaterStressView` تعریف شده‌اند، نه داخل `IrrigationMethodDetailView`: `irrigation/views.py:231`, `irrigation/views.py:287`, `irrigation/views.py:326`, `irrigation/views.py:360`
- علاوه بر این، `WaterStressView` اصلا `_get_method` ندارد و این کد از نظر ساختاری اشتباه است.
- اثر عملی: `PUT/PATCH/DELETE /api/irrigation/<pk>/` به احتمال زیاد `405 Method Not Allowed` می‌دهند و CRUD کامل عملا شکسته است.
### 2) محاسبه تنش آبی بیش از حد ساده‌سازی شده
- فرمول فقط از `soil_moisture` استفاده می‌کند: `irrigation/indicators.py:8`
- فرمول هم یک clamp ساده است: `clamp(round(35 - (soil_moisture / 2)), 0, 100)`: `irrigation/indicators.py:16`
- عوامل مهمی مثل ET0، نوع گیاه، مرحله رشد، ظرفیت مزرعه، بافت خاک، بارش، عمق ریشه و روند زمانی لحاظ نشده‌اند.
- اثر عملی: `POST /api/irrigation/water-stress/` برای KPI واقعی یا تصمیم آبیاری دقیق کافی نیست.
### 3) مرکز مزرعه با average ساده محاسبه می‌شود، نه centroid هندسی دقیق
- مرکز boundary با میانگین نقاط محاسبه می‌شود: `farm_data/services.py:100`
- برای polygonهای نامتقارن یا concave، این روش می‌تواند مرکز واقعی زمین را اشتباه بدهد.
- اثر عملی: داده خاک و هوا ممکن است برای نقطه‌ای غیرواقعی از مزرعه واکشی شوند.
### 4) ادغام داده چند سنسور باعث overwrite خاموش می‌شود
- در `farm_data`, متریک‌های همه sensorها flat می‌شوند و کلیدهای تکراری روی هم overwrite می‌شوند: `farm_data/services.py:155`
- هیچ تفکیک زمانی/مکانی/اولویت‌بندی بین سنسورها وجود ندارد.
- اثر عملی: در مزرعه چند سنسوره، `resolved_metrics` ممکن است فقط آخرین سنسور iterate شده را منعکس کند.
### 5) Weather card و Weather need کاملا وابسته به داده forecast موجود هستند
- اگر forecast نباشد، card خروجی صفر/نامشخص می‌دهد: `weather/farm_weather.py:42`
- build payload پیش‌بینی نیاز آبی هم در نبود forecast خروجی صفر می‌دهد: `weather/water_need_prediction.py:19`
- اثر عملی: endpoint ممکن است 200 بدهد اما محتوای عملیاتی نداشته باشد.
### 6) NDVI بدون boundary واقعی از bbox پیش‌فرض استفاده می‌کند
- اگر boundary وجود نداشته باشد، bbox کوچک پیش‌فرض تولید می‌شود: `location_data/remote_sensing.py:57`
- اثر عملی: NDVI ممکن است برای محدوده تقریبی اطراف center محاسبه شود، نه مرز واقعی مزرعه.
### 7) Heatmap رطوبت خاک مدل مکانی ساده دارد
- فقط latest measurement هر sensor استفاده می‌شود: `soile/services.py:22`, `soile/services.py:32`
- درون‌یابی از نوع IDW ساده است: `soile/services.py:46`
- history واقعی، drift سنسور، عدم قطعیت، zoning مزرعه یا depth-specific map در آن لحاظ نشده‌اند.
- اثر عملی: heatmap برای visualization خوب است ولی برای تصمیم agronomy دقیق کافی نیست.
### 8) توصیه‌های RAG در لایه نهایی deterministic merge می‌شوند
- برای irrigation/fertilization، fallback همیشه ساختار نهایی را پر می‌کند: `rag/services/irrigation.py:153`, `rag/services/fertilization.py:135`
- اثر عملی: خروجی از نظر UI پایدار است، اما تشخیص اینکه کدام بخش واقعا از LLM آمده سخت می‌شود.
### 9) Crop Simulation ممکن است از engine اصلی به projection تقریبی سقوط کند
- fallback projection در خطا فعال می‌شود: `crop_simulation/growth_simulation.py:404`
- اگر consumer فقط status 200 ببیند و `simulation_warning` را ignore کند، ممکن است خروجی تقریبی را واقعی فرض کند.
---
## وضعیت هر اپ از نظر اتکا به داده واقعی
| App | وضعیت کلی | توضیح |
|---|---|---|
| `economy` | ضعیف | کاملا mock |
| `plant` | متوسط رو به ضعیف | CRUD واقعی است، ولی `fetch-info` پیاده‌سازی نشده |
| `weather` | متوسط | ساختار خوب است، ولی API بیرونی/seed sample هنوز ریسک دارد |
| `farm_data` | متوسط | هسته aggregation خوب است، ولی center/merge چند سنسور ساده‌سازی شده |
| `location_data` | متوسط | SoilGrids واقعی است، NDVI وابسته به تنظیمات بیرونی است |
| `soile` | متوسط | داده واقعی دارد، اما مدل تحلیلی و interpolation ساده است |
| `pest_disease` | متوسط | fallback زیاد در endpointهای تشخیص و ریسک دارد |
| `farm_alerts` | متوسط | خروجی قابل استفاده است، ولی در failure به fallback داخلی می‌رود |
| `irrigation` | متوسط رو به ضعیف | recommendation خوب، اما water-stress ساده و CRUD detail معیوب |
| `fertilization` | متوسط | recommendation موجود است ولی heavily fallback-assisted |
| `rag` | متوسط | کارکرد خوب، اما بخشی از خروجی‌ها merge/fallback هستند |
| `crop_simulation` | متوسط | ساختار خوب، ولی fallback projection باید شفاف‌تر expose شود |
---
## endpointهایی که فعلا نباید به عنوان «داده قطعی واقعی» در نظر گرفته شوند
1. `POST /api/economy/overview/`
2. `POST /api/plants/fetch-info/`
3. `POST /api/weather/farm-card/` در محیطی که forecast نمونه/seed شده باشد
4. `POST /api/weather/water-need-prediction/` وقتی forecast واقعی موجود نیست
5. `POST /api/soil-data/ndvi-health/` وقتی satellite config ست نشده
6. `POST /api/farm-alerts/tracker/` و `POST /api/farm-alerts/timeline/` در failureهای LLM
7. `POST /api/soile/anomaly-detection/` در failureهای LLM
8. `POST /api/pest-disease/detect/` و `POST /api/pest-disease/risk/` در fallback mode
9. `POST /api/irrigation/water-stress/` برای تصمیم agronomy دقیق
10. endpointهای crop simulation وقتی `simulation_warning` نشان‌دهنده fallback engine باشد
---
## اولویت اصلاح پیشنهادی
### اولویت خیلی بالا
1. اصلاح باگ `PUT/PATCH/DELETE` در `irrigation/views.py`
2. حذف/mock-flag شفاف برای `economy`
3. تکمیل واقعی `plant/services.py`
4. شفاف‌سازی منبع forecast در weather (real / seeded / unavailable)
### اولویت بالا
1. جلوگیری از پذیرش silent `no_data` برای weather در بعضی flowهای حساس
2. اصلاح aggregation چند سنسور در `farm_data`
3. استفاده از centroid واقعی polygon به‌جای average ساده نقاط
4. اضافه‌کردن source flag به خروجی‌های fallback در RAG/Farm Alerts/Pest Disease/Soil Anomaly
### اولویت متوسط
1. بهبود مدل water stress
2. بهبود IDW/heatmap و استفاده از time-series
3. اضافه‌کردن flag صریح برای crop-simulation fallback
---
## فایل‌های کلیدی که این گزارش بر اساس آن‌ها ساخته شده
- `config/urls.py`
- `rag/urls.py`
- `farm_alerts/urls.py`
- `location_data/urls.py`
- `soile/urls.py`
- `farm_data/urls.py`
- `weather/urls.py`
- `economy/urls.py`
- `plant/urls.py`
- `pest_disease/urls.py`
- `irrigation/urls.py`
- `fertilization/urls.py`
- `crop_simulation/urls.py`
- `economy/services.py:7`
- `plant/services.py:10`
- `weather/services.py:19`
- `weather/migrations/0003_seed_weather_forecasts.py:1`
- `farm_data/services.py:123`
- `location_data/remote_sensing.py:75`
- `location_data/ndvi.py:21`
- `soile/services.py:98`
- `pest_disease/services.py:25`
- `irrigation/views.py:196`
- `irrigation/indicators.py:8`
- `rag/services/soil_anomaly.py:137`
- `rag/services/pest_disease.py:350`
- `rag/services/water_need_prediction.py:129`
- `rag/services/irrigation.py:147`
- `rag/services/fertilization.py:130`
- `crop_simulation/growth_simulation.py:404`
-7
View File
@@ -26,14 +26,7 @@ COPY requirements.txt constraints.txt ./
RUN PIP_CONSTRAINT=/app/constraints.txt \ RUN PIP_CONSTRAINT=/app/constraints.txt \
pip install \ pip install \
--prefer-binary \ --prefer-binary \
--index-url https://mirror-pypi.runflare.com/simple \
--extra-index-url https://package-mirror.liara.ir/repository/pypi/simple \
--extra-index-url https://mirror.cdn.ir/repository/pypi/simple \
--extra-index-url https://mirror2.chabokan.net/pypi/simple \
--trusted-host mirror-pypi.runflare.com \ --trusted-host mirror-pypi.runflare.com \
--trusted-host package-mirror.liara.ir \
--trusted-host mirror.cdn.ir \
--trusted-host mirror2.chabokan.net \
-r requirements.txt -r requirements.txt
COPY entrypoint.sh /app/entrypoint.sh COPY entrypoint.sh /app/entrypoint.sh
+1 -1
Submodule Schemas updated: 78acb5510d...536deea4b2
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
def _isoformat(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
return value
def build_integration_meta(
*,
flow_type: str,
source_type: str,
source_service: str,
ownership: str,
live: bool,
cached: bool,
generated_at: Any = None,
snapshot_at: Any = None,
notes: list[str] | None = None,
) -> dict[str, Any]:
meta = {
"flow_type": flow_type,
"source_type": source_type,
"source_service": source_service,
"ownership": ownership,
"live": live,
"cached": cached,
}
if generated_at is not None:
meta["generated_at"] = _isoformat(generated_at)
if snapshot_at is not None:
meta["snapshot_at"] = _isoformat(snapshot_at)
if notes:
meta["notes"] = notes
return meta
+1 -1
View File
@@ -226,7 +226,7 @@ services:
use_user_embeddings: true use_user_embeddings: true
description: "سرویس روایت داشبورد عملکرد و برداشت" description: "سرویس روایت داشبورد عملکرد و برداشت"
fallback_behavior: fallback_behavior:
on_invalid_json: "return_mocked_narrative" on_invalid_json: "raise_validation_error"
on_missing_context: "use_only_deterministic_data" on_missing_context: "use_only_deterministic_data"
on_number_conflict: "prefer_deterministic_data" on_number_conflict: "prefer_deterministic_data"
prompt_template: "config/tones/yield_harvest_tone.txt" prompt_template: "config/tones/yield_harvest_tone.txt"
+34 -2
View File
@@ -1,6 +1,7 @@
import os import os
import importlib.util import importlib.util
from pathlib import Path from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
try: try:
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -30,6 +31,7 @@ FILE_LOGGING_ENABLED = _can_use_file_logging(LOG_DIR)
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"
DEVELOP = os.environ.get("DEVELOP", "false").strip().lower() in {"1", "true", "yes", "on"}
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
INSTALLED_APPS = [ INSTALLED_APPS = [
@@ -56,6 +58,8 @@ INSTALLED_APPS = [
for optional_app in [ for optional_app in [
"rest_framework", "rest_framework",
"corsheaders", "corsheaders",
"drf_spectacular",
"drf_spectacular_sidecar",
]: ]:
if importlib.util.find_spec(optional_app): if importlib.util.find_spec(optional_app):
INSTALLED_APPS.insert(6, optional_app) INSTALLED_APPS.insert(6, optional_app)
@@ -73,6 +77,9 @@ MIDDLEWARE = [
if importlib.util.find_spec("corsheaders"): if importlib.util.find_spec("corsheaders"):
MIDDLEWARE.insert(1, "corsheaders.middleware.CorsMiddleware") MIDDLEWARE.insert(1, "corsheaders.middleware.CorsMiddleware")
if importlib.util.find_spec("whitenoise"):
MIDDLEWARE.insert(1, "whitenoise.middleware.WhiteNoiseMiddleware")
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
@@ -121,6 +128,9 @@ USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
if importlib.util.find_spec("whitenoise"):
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
CACHES = { CACHES = {
@@ -134,6 +144,22 @@ 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": "Swagger/OpenAPI documentation for all CropLogic AI API endpoints.",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"SWAGGER_UI_DIST": "SIDECAR",
"SWAGGER_UI_FAVICON_HREF": "SIDECAR",
"REDOC_DIST": "SIDECAR",
"SCHEMA_PATH_PREFIX": r"/api/",
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
"SWAGGER_UI_SETTINGS": {
"persistAuthorization": True,
},
} }
CORS_ALLOW_ALL_ORIGINS = DEBUG CORS_ALLOW_ALL_ORIGINS = DEBUG
@@ -161,16 +187,22 @@ WEATHER_API_BASE_URL = os.environ.get(
"WEATHER_API_BASE_URL", "https://api.open-meteo.com/v1/forecast" "WEATHER_API_BASE_URL", "https://api.open-meteo.com/v1/forecast"
) )
WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY", "") WEATHER_API_KEY = os.environ.get("WEATHER_API_KEY", "")
WEATHER_DATA_PROVIDER = os.environ.get("WEATHER_DATA_PROVIDER", "mock").strip().lower() WEATHER_DATA_PROVIDER = os.environ.get("WEATHER_DATA_PROVIDER", "open-meteo").strip().lower()
WEATHER_MOCK_DELAY_SECONDS = float(os.environ.get("WEATHER_MOCK_DELAY_SECONDS", "0.8")) WEATHER_MOCK_DELAY_SECONDS = float(os.environ.get("WEATHER_MOCK_DELAY_SECONDS", "0.8"))
WEATHER_TIMEOUT_SECONDS = float(os.environ.get("WEATHER_TIMEOUT_SECONDS", "60")) WEATHER_TIMEOUT_SECONDS = float(os.environ.get("WEATHER_TIMEOUT_SECONDS", "60"))
SOIL_DATA_PROVIDER = os.environ.get("SOIL_DATA_PROVIDER", "mock").strip().lower() SOIL_DATA_PROVIDER = os.environ.get("SOIL_DATA_PROVIDER", "soilgrids").strip().lower()
SOIL_MOCK_DELAY_SECONDS = float(os.environ.get("SOIL_MOCK_DELAY_SECONDS", "0.8")) SOIL_MOCK_DELAY_SECONDS = float(os.environ.get("SOIL_MOCK_DELAY_SECONDS", "0.8"))
SOILGRIDS_TIMEOUT_SECONDS = float(os.environ.get("SOILGRIDS_TIMEOUT_SECONDS", "60")) SOILGRIDS_TIMEOUT_SECONDS = float(os.environ.get("SOILGRIDS_TIMEOUT_SECONDS", "60"))
BACKEND_PLANT_SYNC_BASE_URL = os.environ.get("BACKEND_PLANT_SYNC_BASE_URL", "") BACKEND_PLANT_SYNC_BASE_URL = os.environ.get("BACKEND_PLANT_SYNC_BASE_URL", "")
BACKEND_PLANT_SYNC_API_KEY = os.environ.get("BACKEND_PLANT_SYNC_API_KEY", "") BACKEND_PLANT_SYNC_API_KEY = os.environ.get("BACKEND_PLANT_SYNC_API_KEY", "")
BACKEND_PLANT_SYNC_TIMEOUT = int(os.environ.get("BACKEND_PLANT_SYNC_TIMEOUT", "20")) BACKEND_PLANT_SYNC_TIMEOUT = int(os.environ.get("BACKEND_PLANT_SYNC_TIMEOUT", "20"))
if not (DEBUG or DEVELOP):
if WEATHER_DATA_PROVIDER == "mock":
raise ImproperlyConfigured("WEATHER_DATA_PROVIDER=mock is allowed only in dev/test environments.")
if SOIL_DATA_PROVIDER == "mock":
raise ImproperlyConfigured("SOIL_DATA_PROVIDER=mock is allowed only in dev/test environments.")
LOGGING = { LOGGING = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
+4
View File
@@ -1,8 +1,12 @@
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, SpectacularSwaggerView, SpectacularRedocView
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
path("api/docs/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
# --- App APIs --- # --- App APIs ---
path("api/rag/", include("rag.urls")), path("api/rag/", include("rag.urls")),
path("api/farm-alerts/", include("farm_alerts.urls")), path("api/farm-alerts/", include("farm_alerts.urls")),
+3 -6
View File
@@ -10,6 +10,7 @@ import logging
from django.core.paginator import EmptyPage, Paginator from django.core.paginator import EmptyPage, Paginator
from farm_data.models import SensorData from farm_data.models import SensorData
from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm
from plant.gdd import calculate_daily_gdd, resolve_growth_profile from plant.gdd import calculate_daily_gdd, resolve_growth_profile
from weather.models import WeatherForecast from weather.models import WeatherForecast
@@ -775,14 +776,10 @@ class CurrentFarmChartSimulator:
resolved_plant_name = plant_name resolved_plant_name = plant_name
if not resolved_plant_name: if not resolved_plant_name:
sensor = ( sensor = get_canonical_farm_record(farm_uuid)
SensorData.objects.prefetch_related("plants")
.filter(farm_uuid=farm_uuid)
.first()
)
if sensor is None: if sensor is None:
raise GrowthSimulationError("مزرعه پیدا نشد.") raise GrowthSimulationError("مزرعه پیدا نشد.")
plant = sensor.plants.first() plant = get_runtime_plant_for_farm(sensor)
if plant is None: if plant is None:
raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.") raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.")
resolved_plant_name = plant.name resolved_plant_name = plant.name
+4 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import date, timedelta from datetime import date, timedelta
from typing import Any from typing import Any
from farm_data.models import SensorData from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm
from plant.gdd import resolve_growth_profile from plant.gdd import resolve_growth_profile
from .growth_simulation import ( from .growth_simulation import (
@@ -55,10 +55,10 @@ def build_harvest_prediction_payload(
) -> dict[str, Any]: ) -> dict[str, Any]:
resolved_plant_name = plant_name resolved_plant_name = plant_name
if not resolved_plant_name: if not resolved_plant_name:
sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first() farm = get_canonical_farm_record(farm_uuid)
if sensor is None: if farm is None:
raise GrowthSimulationError("مزرعه پیدا نشد.") raise GrowthSimulationError("مزرعه پیدا نشد.")
plant = sensor.plants.first() plant = get_runtime_plant_for_farm(farm)
if plant is None: if plant is None:
raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.") raise GrowthSimulationError("گیاهی برای مزرعه انتخاب شده پیدا نشد.")
resolved_plant_name = plant.name resolved_plant_name = plant.name
+9 -13
View File
@@ -445,23 +445,18 @@ def build_simulation_payload_from_farm(
agromanagement: Any | None = None, agromanagement: Any | None = None,
site_parameters: dict[str, Any] | None = None, site_parameters: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from farm_data.models import SensorData from farm_data.services import (
get_canonical_farm_record,
get_runtime_plant_for_farm,
list_runtime_plants_for_farm,
)
from weather.models import WeatherForecast from weather.models import WeatherForecast
farm = ( farm = get_canonical_farm_record(farm_uuid)
SensorData.objects.select_related("center_location", "irrigation_method")
.prefetch_related("plants", "center_location__depths")
.filter(farm_uuid=farm_uuid)
.first()
)
if farm is None: if farm is None:
raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.") raise CropSimulationError(f"Farm with uuid={farm_uuid} not found.")
plant = None plant = get_runtime_plant_for_farm(farm, plant_name=plant_name)
if plant_name:
plant = farm.plants.filter(name=plant_name).first()
if plant is None:
plant = farm.plants.first()
if weather is not None: if weather is not None:
resolved_weather = _normalize_weather_records(weather) resolved_weather = _normalize_weather_records(weather)
@@ -569,6 +564,7 @@ def build_simulation_payload_from_farm(
return { return {
"farm": farm, "farm": farm,
"runtime_plants": list_runtime_plants_for_farm(farm),
"plant": plant, "plant": plant,
"weather": resolved_weather, "weather": resolved_weather,
"soil": resolved_soil, "soil": resolved_soil,
@@ -1052,7 +1048,7 @@ class CropSimulationService:
if not crops and farm_uuid: if not crops and farm_uuid:
base = build_simulation_payload_from_farm(farm_uuid=str(farm_uuid)) base = build_simulation_payload_from_farm(farm_uuid=str(farm_uuid))
crops = [] crops = []
for plant in base["farm"].plants.all(): for plant in base["runtime_plants"]:
simulation_profile = _extract_plant_simulation_profile(plant) simulation_profile = _extract_plant_simulation_profile(plant)
crop_payload = ( crop_payload = (
deepcopy(simulation_profile.get("crop_parameters")) deepcopy(simulation_profile.get("crop_parameters"))
+57 -1
View File
@@ -8,9 +8,11 @@ from unittest.mock import patch
from unittest import skipUnless from unittest import skipUnless
from django.test import TestCase from django.test import TestCase
from rest_framework.test import APIRequestFactory
from .models import SimulationRun, SimulationScenario from .models import SimulationRun, SimulationScenario
from .services import CropSimulationService, CropSimulationError, PcseSimulationManager from .services import CropSimulationService, CropSimulationError, PcseSimulationManager
from .views import PlantGrowthSimulationView
def build_weather(days: int = 5) -> list[dict]: def build_weather(days: int = 5) -> list[dict]:
@@ -108,7 +110,61 @@ class CropSimulationServiceTests(TestCase):
crop_parameters=self.crop, crop_parameters=self.crop,
strategies=[{"label": "only", "agromanagement": build_agromanagement()}], strategies=[{"label": "only", "agromanagement": build_agromanagement()}],
site_parameters=self.site, site_parameters=self.site,
) )
class CropSimulationViewContractTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
@patch("crop_simulation.views.run_growth_simulation_task.delay")
def test_growth_queue_response_includes_live_ai_metadata(self, mock_delay):
mock_delay.return_value.id = "task-123"
request = self.factory.post(
"/api/crop-simulation/growth/",
{
"plant_name": "wheat",
"dynamic_parameters": ["DVS"],
"weather": [
{
"DAY": "2026-04-01",
"LAT": 35.7,
"LON": 51.4,
"TMIN": 12,
"TMAX": 24,
"RAIN": 0.0,
"ET0": 0.32,
}
],
"soil_parameters": {"SMFCF": 0.34, "SMW": 0.14, "RDMSOL": 120.0},
"site_parameters": {"WAV": 40.0},
"agromanagement": [
{
"2026-04-01": {
"CropCalendar": {
"crop_name": "wheat",
"variety_name": "winter-wheat",
"crop_start_date": "2026-04-05",
"crop_start_type": "sowing",
"crop_end_date": "2026-09-01",
"crop_end_type": "harvest",
"max_duration": 180,
},
"TimedEvents": [],
"StateEvents": [],
}
},
{},
],
},
format="json",
)
response = PlantGrowthSimulationView.as_view()(request)
self.assertEqual(response.status_code, 202)
self.assertEqual(response.data["meta"]["flow_type"], "live_ai_inference")
self.assertEqual(response.data["meta"]["source_service"], "ai_crop_simulation")
def test_recommend_best_crop_returns_best_candidate(self): def test_recommend_best_crop_returns_best_candidate(self):
with patch.object( with patch.object(
+82 -6
View File
@@ -7,6 +7,7 @@ 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 config.integration_contract import build_integration_meta
from config.openapi import ( from config.openapi import (
build_envelope_serializer, build_envelope_serializer,
build_response, build_response,
@@ -151,6 +152,14 @@ class PlantGrowthSimulationView(APIView):
"status_url": f"/api/crop-simulation/growth/{task.id}/status/", "status_url": f"/api/crop-simulation/growth/{task.id}/status/",
"plant_name": serializer.validated_data["plant_name"], "plant_name": serializer.validated_data["plant_name"],
}, },
"meta": build_integration_meta(
flow_type="live_ai_inference",
source_type="provider",
source_service="ai_crop_simulation",
ownership="ai",
live=True,
cached=False,
),
}, },
status=status.HTTP_202_ACCEPTED, status=status.HTTP_202_ACCEPTED,
) )
@@ -202,7 +211,19 @@ class PlantGrowthSimulationStatusView(APIView):
payload["error"] = str(result.result) payload["error"] = str(result.result)
return Response( return Response(
{"code": 200, "msg": "موفق", "data": payload}, {
"code": 200,
"msg": "موفق",
"data": payload,
"meta": build_integration_meta(
flow_type="live_ai_inference",
source_type="provider",
source_service="ai_crop_simulation",
ownership="ai",
live=result.state in {"PENDING", "PROGRESS", "SUCCESS"},
cached=False,
),
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -281,7 +302,19 @@ class CurrentFarmSimulationChartView(APIView):
) )
return Response( return Response(
{"code": 200, "msg": "موفق", "data": result}, {
"code": 200,
"msg": "موفق",
"data": result,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="provider",
source_service="ai_crop_simulation_chart",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -342,7 +375,19 @@ class HarvestPredictionView(APIView):
) )
return Response( return Response(
{"code": 200, "msg": "موفق", "data": result}, {
"code": 200,
"msg": "موفق",
"data": result,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="provider",
source_service="ai_crop_simulation_harvest_prediction",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -388,7 +433,22 @@ class YieldPredictionView(APIView):
{"code": 500, "msg": f"خطا در پیش بینی عملکرد: {exc}", "data": None}, {"code": 500, "msg": f"خطا در پیش بینی عملکرد: {exc}", "data": None},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )
return Response({"code": 200, "msg": "موفق", "data": result}, status=status.HTTP_200_OK) return Response(
{
"code": 200,
"msg": "موفق",
"data": result,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="provider",
source_service="ai_crop_simulation_yield_prediction",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
class YieldHarvestSummaryView(APIView): class YieldHarvestSummaryView(APIView):
@@ -397,7 +457,7 @@ class YieldHarvestSummaryView(APIView):
summary="خلاصه عملکرد و برداشت", summary="خلاصه عملکرد و برداشت",
description=( description=(
"خروجی داشبورد Yield & Harvest Summary را با اتکا به داده های قطعی شبیه سازی برمی گرداند. " "خروجی داشبورد Yield & Harvest Summary را با اتکا به داده های قطعی شبیه سازی برمی گرداند. "
"فعلا پاسخ به صورت mock با کارت های خالی بازگردانده می شود." "این endpoint خروجی derived واقعی تولید می کند و پاسخ آن mock نیست."
), ),
parameters=[ parameters=[
OpenApiParameter( OpenApiParameter(
@@ -492,4 +552,20 @@ class YieldHarvestSummaryView(APIView):
irrigation_recommendation=validated.get("irrigation_recommendation"), irrigation_recommendation=validated.get("irrigation_recommendation"),
fertilization_recommendation=validated.get("fertilization_recommendation"), fertilization_recommendation=validated.get("fertilization_recommendation"),
) )
return Response({"code": 200, "msg": "موفق", "data": payload}, status=status.HTTP_200_OK) return Response(
{
"code": 200,
"msg": "موفق",
"data": payload,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="provider",
source_service="ai_crop_simulation_yield_harvest_summary",
ownership="ai",
live=True,
cached=False,
),
},
status=status.HTTP_200_OK,
)
+4 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from statistics import mean from statistics import mean
from typing import Any from typing import Any
from farm_data.models import SensorData from farm_data.services import get_canonical_farm_record, get_runtime_plant_for_farm
from .growth_simulation import GrowthSimulationError, _run_simulation, _safe_float, build_growth_context from .growth_simulation import GrowthSimulationError, _run_simulation, _safe_float, build_growth_context
@@ -94,11 +94,11 @@ class WaterStressSimulationService:
if plant_name: if plant_name:
return plant_name return plant_name
sensor = SensorData.objects.prefetch_related("plants").filter(farm_uuid=farm_uuid).first() farm = get_canonical_farm_record(farm_uuid)
if sensor is None: if farm is None:
raise GrowthSimulationError("Farm not found.") raise GrowthSimulationError("Farm not found.")
plant = sensor.plants.first() plant = get_runtime_plant_for_farm(farm)
if plant is None: if plant is None:
raise GrowthSimulationError("Plant not found for the selected farm.") raise GrowthSimulationError("Plant not found for the selected farm.")
return plant.name return plant.name
+13 -3
View File
@@ -13,6 +13,7 @@ from farm_data.models import SensorData
from farm_data.services import get_farm_details from farm_data.services import get_farm_details
from location_data.models import NdviObservation, SoilLocation from location_data.models import NdviObservation, SoilLocation
from rag.failure_contract import RAGServiceError
from rag.services.yield_harvest import YieldHarvestRAGService from rag.services.yield_harvest import YieldHarvestRAGService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -119,13 +120,17 @@ class YieldHarvestSummaryService:
try: try:
rag_service = YieldHarvestRAGService() rag_service = YieldHarvestRAGService()
narrative_data = rag_service.generate_narrative(context_payload) narrative_data = rag_service.generate_narrative(context_payload)
except Exception as exc: except RAGServiceError as exc:
logger.warning( logger.warning(
"Yield harvest narrative generation failed for farm_uuid=%s: %s", "Yield harvest narrative generation failed for farm_uuid=%s: %s",
farm_uuid, farm_uuid,
exc, exc,
) )
narrative_data = {} narrative_data = {
"status": "error",
"source": "llm",
"narrative_error": exc.to_dict(),
}
return self._merge_narrative(deterministic_payload, narrative_data) return self._merge_narrative(deterministic_payload, narrative_data)
def _build_yield_prediction( def _build_yield_prediction(
@@ -703,7 +708,7 @@ class YieldHarvestSummaryService:
) -> dict[str, Any]: ) -> dict[str, Any]:
farm = ( farm = (
SensorData.objects.select_related("center_location", "weather_forecast") SensorData.objects.select_related("center_location", "weather_forecast")
.prefetch_related("center_location__depths", "plants") .prefetch_related("center_location__depths", "plant_assignments__plant")
.filter(farm_uuid=farm_uuid) .filter(farm_uuid=farm_uuid)
.first() .first()
) )
@@ -949,6 +954,11 @@ class YieldHarvestSummaryService:
fallback_note, fallback_note,
) )
merged["narrative_status"] = narratives.get("status", "success")
merged["narrative_source"] = narratives.get("source", "deterministic")
if isinstance(narratives.get("narrative_error"), dict):
merged["narrative_error"] = narratives["narrative_error"]
return merged return merged
def _coalesce_text(self, *values: Any) -> str: def _coalesce_text(self, *values: Any) -> str:
-19
View File
@@ -1,19 +0,0 @@
from .types import OpenApiTypes
from .utils import (
OpenApiExample,
OpenApiParameter,
OpenApiResponse,
extend_schema,
extend_schema_view,
inline_serializer,
)
__all__ = [
"OpenApiExample",
"OpenApiParameter",
"OpenApiResponse",
"OpenApiTypes",
"extend_schema",
"extend_schema_view",
"inline_serializer",
]
-8
View File
@@ -1,8 +0,0 @@
class OpenApiTypes:
STR = str
INT = int
BOOL = bool
UUID = str
DATE = str
DATETIME = str
OBJECT = dict
-60
View File
@@ -1,60 +0,0 @@
from dataclasses import dataclass
from rest_framework import serializers
@dataclass
class OpenApiExample:
name: str
value: object = None
request_only: bool = False
response_only: bool = False
summary: str | None = None
description: str | None = None
@dataclass
class OpenApiResponse:
response: object = None
description: str = ""
class OpenApiParameter:
QUERY = "query"
PATH = "path"
HEADER = "header"
def __init__(
self,
name,
type=None,
location=None,
required=False,
description="",
default=None,
):
self.name = name
self.type = type
self.location = location
self.required = required
self.description = description
self.default = default
def extend_schema(*args, **kwargs):
def decorator(target):
return target
return decorator
def extend_schema_view(**kwargs):
def decorator(target):
return target
return decorator
def inline_serializer(*, name, fields):
serializer_fields = {"__module__": __name__, **fields}
return type(name, (serializers.Serializer,), serializer_fields)
-19
View File
@@ -1,19 +0,0 @@
from django.http import HttpResponseNotFound
from django.views import View
class _DisabledSchemaView(View):
def dispatch(self, request, *args, **kwargs):
return HttpResponseNotFound("Schema endpoint is disabled.")
class SpectacularAPIView(_DisabledSchemaView):
pass
class SpectacularSwaggerView(_DisabledSchemaView):
pass
class SpectacularRedocView(_DisabledSchemaView):
pass
+4
View File
@@ -43,5 +43,9 @@ if [ -n "${DEVELOP}" ] && [ "${SKIP_MIGRATE}" != "1" ]; then
echo "Demo seeders done." echo "Demo seeders done."
fi fi
echo "Collecting static files..."
python manage.py collectstatic --noinput
echo "Static files ready."
echo "Starting command: $*" echo "Starting command: $*"
exec "$@" exec "$@"
+1 -1
View File
@@ -126,7 +126,7 @@ class SensorData(SensorPayloadMixin, models.Model):
blank=True, blank=True,
db_table="farm_data_sensordata_plants", db_table="farm_data_sensordata_plants",
related_name="farm_data", related_name="farm_data",
help_text="گیاهان مرتبط با این farm", help_text="مسیر legacy برای گیاهان farm. برای خواندن canonical از plant_assignments/plant_snapshots استفاده شود.",
) )
irrigation_method = models.ForeignKey( irrigation_method = models.ForeignKey(
"irrigation.IrrigationMethod", "irrigation.IrrigationMethod",
+63 -21
View File
@@ -3,8 +3,10 @@ from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP from decimal import Decimal, ROUND_HALF_UP
from numbers import Number from numbers import Number
import logging import logging
import warnings
from django.conf import settings from django.conf import settings
from django.apps import apps
from django.db import transaction from django.db import transaction
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
@@ -40,6 +42,10 @@ class BackendSyncError(Exception):
"""خطا در همگام سازی کاتالوگ گیاه و assignmentها از Backend.""" """خطا در همگام سازی کاتالوگ گیاه و assignmentها از Backend."""
class LegacyFarmPlantRelationWarning(DeprecationWarning):
"""هشدار برای relation قدیمی SensorData.plants."""
PARAMETER_LABEL_OVERRIDES = { PARAMETER_LABEL_OVERRIDES = {
"soil_moisture": "رطوبت خاک", "soil_moisture": "رطوبت خاک",
"soil_temperature": "دمای خاک", "soil_temperature": "دمای خاک",
@@ -187,7 +193,9 @@ def assign_farm_plants_from_backend_ids(farm: SensorData, backend_plant_ids: lis
plant=snapshot_by_backend_id[backend_plant_id], plant=snapshot_by_backend_id[backend_plant_id],
defaults={"position": position}, defaults={"position": position},
) )
return [snapshot_by_backend_id[backend_plant_id] for backend_plant_id in normalized_ids] snapshots_in_order = [snapshot_by_backend_id[backend_plant_id] for backend_plant_id in normalized_ids]
reconcile_legacy_farm_plants_relation(farm, snapshots_in_order)
return snapshots_in_order
def get_farm_plant_assignments(farm: SensorData) -> list[FarmPlantAssignment]: def get_farm_plant_assignments(farm: SensorData) -> list[FarmPlantAssignment]:
@@ -200,6 +208,44 @@ def get_farm_plant_snapshots(farm: SensorData) -> list[PlantCatalogSnapshot]:
return [assignment.plant for assignment in get_farm_plant_assignments(farm)] return [assignment.plant for assignment in get_farm_plant_assignments(farm)]
def reconcile_legacy_farm_plants_relation(
farm: SensorData,
snapshots: list[PlantCatalogSnapshot] | None = None,
) -> None:
snapshots = list(snapshots if snapshots is not None else get_farm_plant_snapshots(farm))
Plant = apps.get_model("plant", "Plant")
if Plant is None:
return
names = [snapshot.name for snapshot in snapshots if snapshot and snapshot.name]
if not names:
farm.plants.clear()
return
legacy_plants = list(Plant.objects.filter(name__in=names).order_by("name", "id"))
farm.plants.set(legacy_plants)
def get_canonical_farm_record(farm_uuid: str) -> SensorData | None:
return (
SensorData.objects.select_related(
"center_location",
"weather_forecast",
"irrigation_method",
)
.prefetch_related("plant_assignments__plant", "center_location__depths")
.filter(farm_uuid=farm_uuid)
.first()
)
def get_legacy_farm_plants(farm: SensorData):
warnings.warn(
"SensorData.plants is deprecated; use farm_data.services canonical plant snapshot helpers instead.",
LegacyFarmPlantRelationWarning,
stacklevel=2,
)
return farm.plants.all()
def get_primary_plant_snapshot(farm: SensorData) -> PlantCatalogSnapshot | None: def get_primary_plant_snapshot(farm: SensorData) -> PlantCatalogSnapshot | None:
assignments = get_farm_plant_assignments(farm) assignments = get_farm_plant_assignments(farm)
return assignments[0].plant if assignments else None return assignments[0].plant if assignments else None
@@ -259,6 +305,20 @@ def clone_snapshot_as_runtime_plant(
return runtime return runtime
def get_runtime_plant_for_farm(
farm: SensorData,
*,
plant_name: str | None = None,
growth_stage: str | None = None,
):
snapshot = get_farm_plant_snapshot_by_name(farm, plant_name)
return clone_snapshot_as_runtime_plant(snapshot, growth_stage=growth_stage)
def list_runtime_plants_for_farm(farm: SensorData) -> list[object]:
return [clone_snapshot_as_runtime_plant(snapshot) for snapshot in get_farm_plant_snapshots(farm)]
def build_plant_text_from_snapshot( def build_plant_text_from_snapshot(
plant: PlantCatalogSnapshot | None, plant: PlantCatalogSnapshot | None,
growth_stage: str, growth_stage: str,
@@ -290,16 +350,7 @@ def build_plant_text_from_snapshot(
def build_farm_plant_context(farm_uuid: str) -> dict | None: def build_farm_plant_context(farm_uuid: str) -> dict | None:
farm = ( farm = get_canonical_farm_record(farm_uuid)
SensorData.objects.select_related(
"center_location",
"weather_forecast",
"irrigation_method",
)
.prefetch_related("plant_assignments__plant", "center_location__depths")
.filter(farm_uuid=farm_uuid)
.first()
)
if farm is None: if farm is None:
return None return None
assignments = get_farm_plant_assignments(farm) assignments = get_farm_plant_assignments(farm)
@@ -397,16 +448,7 @@ def get_sensor_parameter_catalog(sensor_payload: dict | None = None) -> dict[str
def get_farm_details(farm_uuid: str): def get_farm_details(farm_uuid: str):
farm = ( farm = get_canonical_farm_record(farm_uuid)
SensorData.objects.select_related(
"center_location",
"weather_forecast",
"irrigation_method",
)
.prefetch_related("plant_assignments__plant", "center_location__depths")
.filter(farm_uuid=farm_uuid)
.first()
)
if farm is None: if farm is None:
return None return None
+25 -1
View File
@@ -7,7 +7,12 @@ from rest_framework.test import APIClient
from location_data.models import SoilDepthData, SoilLocation from location_data.models import SoilDepthData, SoilLocation
from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter from farm_data.models import PlantCatalogSnapshot, SensorData, SensorParameter
from farm_data.services import assign_farm_plants_from_backend_ids from farm_data.services import (
assign_farm_plants_from_backend_ids,
get_canonical_farm_record,
get_runtime_plant_for_farm,
list_runtime_plants_for_farm,
)
from irrigation.models import IrrigationMethod from irrigation.models import IrrigationMethod
from weather.models import WeatherForecast from weather.models import WeatherForecast
@@ -77,6 +82,25 @@ class FarmDetailApiTests(TestCase):
) )
assign_farm_plants_from_backend_ids(self.farm, [self.plant2.backend_plant_id, self.plant1.backend_plant_id]) assign_farm_plants_from_backend_ids(self.farm, [self.plant2.backend_plant_id, self.plant1.backend_plant_id])
def test_canonical_plant_runtime_path_uses_assignments_not_legacy_relation(self):
farm = get_canonical_farm_record(str(self.farm_uuid))
self.assertIsNotNone(farm)
self.assertEqual([plant.name for plant in list_runtime_plants_for_farm(farm)], ["خیار", "گوجه‌فرنگی"])
self.assertEqual(get_runtime_plant_for_farm(farm).name, "خیار")
def test_assignment_sync_reconciles_legacy_relation_for_transition(self):
self.assertEqual(list(self.farm.plants.values_list("name", flat=True)), ["خیار", "گوجه‌فرنگی"])
def test_runtime_plant_lookup_resolves_by_name_from_canonical_assignments(self):
farm = get_canonical_farm_record(str(self.farm_uuid))
resolved = get_runtime_plant_for_farm(farm, plant_name="گوجه‌فرنگی")
self.assertIsNotNone(resolved)
self.assertEqual(resolved.name, "گوجه‌فرنگی")
self.assertEqual(resolved.id, self.plant1.backend_plant_id)
def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self): def test_returns_farm_detail_and_prioritizes_sensor_metrics_over_soil(self):
response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/") response = self.client.get(f"/api/farm-data/{self.farm_uuid}/detail/")
+44 -1
View File
@@ -12,6 +12,7 @@ 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 config.integration_contract import build_integration_meta
from config.openapi import build_envelope_serializer, build_response from config.openapi import build_envelope_serializer, build_response
from .models import ParameterUpdateLog, SensorData, SensorParameter from .models import ParameterUpdateLog, SensorData, SensorParameter
from .serializers import ( from .serializers import (
@@ -248,6 +249,16 @@ class FarmDataUpsertView(APIView):
"code": 201 if created else 200, "code": 201 if created else 200,
"msg": "success", "msg": "success",
"data": SensorDataResponseSerializer(farm_data).data, "data": SensorDataResponseSerializer(farm_data).data,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="provider",
source_service="ai_farm_data",
ownership="ai",
live=True,
cached=False,
generated_at=farm_data.updated_at,
notes=["AI farm_data stores a derived read-model enriched with location and weather data."],
),
}, },
status=response_status, status=response_status,
) )
@@ -282,7 +293,20 @@ class FarmDetailView(APIView):
) )
return Response( return Response(
{"code": 200, "msg": "success", "data": data}, {
"code": 200,
"msg": "success",
"data": data,
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="db",
source_service="ai_farm_data",
ownership="ai",
live=False,
cached=True,
snapshot_at=getattr(data, "get", lambda *_: None)("updated_at") if isinstance(data, dict) else None,
),
},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -327,6 +351,16 @@ class PlantCatalogSyncView(APIView):
"count": len(snapshots), "count": len(snapshots),
"plant_ids": [snapshot.backend_plant_id for snapshot in snapshots], "plant_ids": [snapshot.backend_plant_id for snapshot in snapshots],
}, },
"meta": build_integration_meta(
flow_type="backend_owned_data_with_ai_enrichment",
source_type="db",
source_service="ai_farm_data_plant_catalog",
ownership="backend",
live=False,
cached=False,
generated_at=snapshots[-1].updated_at if snapshots else None,
notes=["Backend is canonical for plant catalog; AI stores snapshots for derived services."],
),
}, },
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
@@ -426,6 +460,15 @@ class SensorParameterCreateView(APIView):
"created_at": parameter.created_at, "created_at": parameter.created_at,
"action": action, "action": action,
}, },
"meta": build_integration_meta(
flow_type="ai_owned_derived_output",
source_type="db",
source_service="ai_farm_parameters",
ownership="ai",
live=False,
cached=False,
generated_at=parameter.created_at,
),
}, },
status=status.HTTP_201_CREATED, status=status.HTTP_201_CREATED,
) )
+3 -1
View File
@@ -19,12 +19,14 @@ class SoilDataConfig(AppConfig):
def soil_data_adapter(self): def soil_data_adapter(self):
from .soil_adapters import MockSoilDataAdapter, SoilGridsAdapter from .soil_adapters import MockSoilDataAdapter, SoilGridsAdapter
provider = getattr(settings, "SOIL_DATA_PROVIDER", "mock") provider = getattr(settings, "SOIL_DATA_PROVIDER", "soilgrids")
if provider == "soilgrids": if provider == "soilgrids":
return SoilGridsAdapter( return SoilGridsAdapter(
timeout=getattr(settings, "SOILGRIDS_TIMEOUT_SECONDS", 60) timeout=getattr(settings, "SOILGRIDS_TIMEOUT_SECONDS", 60)
) )
if provider == "mock": if provider == "mock":
if not (getattr(settings, "DEBUG", False) or getattr(settings, "DEVELOP", False)):
raise RuntimeError("Mock soil provider is disabled outside dev/test environments.")
return MockSoilDataAdapter( return MockSoilDataAdapter(
delay_seconds=getattr(settings, "SOIL_MOCK_DELAY_SECONDS", 0.8) delay_seconds=getattr(settings, "SOIL_MOCK_DELAY_SECONDS", 0.8)
) )
-67
View File
@@ -1,67 +0,0 @@
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
-179
View File
@@ -1,179 +0,0 @@
2026-03-19 12:58:54,231 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 12:59:02,728 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 12:59:02,735 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK"
2026-03-19 12:59:02,839 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 12:59:02,889 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 12:59:03,731 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden"
2026-03-19 13:02:27,928 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:02:31,441 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:02:40,266 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:02:42,892 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:02:47,501 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:02:49,706 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:02:58,315 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:03:00,615 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:03:03,127 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:03:06,357 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:03:09,002 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:03:10,997 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:03:13,516 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:03:16,538 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:03:24,247 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:03:27,864 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:03:31,680 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:03:34,971 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:03:36,655 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:03:39,784 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:03:41,312 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:03:44,523 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:03:49,330 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:03:49,339 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK"
2026-03-19 13:03:49,343 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:03:49,380 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:03:50,420 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden"
2026-03-19 13:05:10,067 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:05:13,538 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:05:17,196 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:05:20,435 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:05:30,221 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:05:33,418 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:05:33,719 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:05:33,731 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK"
2026-03-19 13:05:33,735 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:05:33,768 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:05:33,781 [INFO] rag.api_provider: gapgpt
2026-03-19 13:05:34,560 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden"
2026-03-19 13:06:36,348 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:06:39,607 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:06:42,287 [INFO] django.utils.autoreload: /app/rag/api_provider.py changed, reloading.
2026-03-19 13:06:45,923 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:06:51,918 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:06:51,929 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK"
2026-03-19 13:06:51,941 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:06:51,994 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:06:52,021 [INFO] rag.api_provider: gapgpt
2026-03-19 13:06:52,021 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1
2026-03-19 13:06:52,588 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 403 Forbidden"
2026-03-19 13:09:58,707 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:09:58,715 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK"
2026-03-19 13:09:58,725 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:09:58,781 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:09:58,815 [INFO] rag.api_provider: gapgpt
2026-03-19 13:09:58,816 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1
2026-03-19 13:11:29,596 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 13:11:29,596 [INFO] openai._base_client: Retrying request to /embeddings in 0.442594 seconds
2026-03-19 13:13:00,294 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 13:13:00,294 [INFO] openai._base_client: Retrying request to /embeddings in 0.814020 seconds
2026-03-19 13:14:32,330 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 13:25:14,695 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:25:14,706 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK"
2026-03-19 13:25:14,719 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:25:14,783 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:25:14,807 [INFO] rag.api_provider: gapgpt
2026-03-19 13:25:14,808 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1
2026-03-19 13:26:48,817 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 13:26:48,818 [INFO] openai._base_client: Retrying request to /embeddings in 0.387632 seconds
2026-03-19 13:28:17,856 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading.
2026-03-19 13:28:19,642 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 13:28:19,642 [INFO] openai._base_client: Retrying request to /embeddings in 0.771092 seconds
2026-03-19 13:28:19,697 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:28:23,262 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading.
2026-03-19 13:28:26,274 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:29:16,030 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading.
2026-03-19 13:29:19,297 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 13:29:21,261 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:29:21,275 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK"
2026-03-19 13:29:21,298 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:29:21,350 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 13:29:21,376 [INFO] rag.api_provider: gapgpt
2026-03-19 13:29:21,376 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1
2026-03-19 13:30:52,295 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 13:30:52,296 [INFO] openai._base_client: Retrying request to /embeddings in 0.465685 seconds
2026-03-19 13:32:23,011 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 13:32:23,011 [INFO] openai._base_client: Retrying request to /embeddings in 0.894773 seconds
2026-03-19 13:33:55,186 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 14:21:23,732 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 14:21:23,742 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK"
2026-03-19 14:21:23,749 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 14:21:23,814 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 14:21:23,841 [INFO] rag.api_provider: gapgpt
2026-03-19 14:21:23,842 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1
2026-03-19 14:22:54,762 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 14:22:54,763 [INFO] openai._base_client: Retrying request to /embeddings in 0.440965 seconds
2026-03-19 14:24:25,495 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 14:24:25,496 [INFO] openai._base_client: Retrying request to /embeddings in 0.861269 seconds
2026-03-19 14:25:27,028 [INFO] root: jhh
2026-03-19 14:25:27,030 [INFO] rag.chat: chat_rag_stream started sensor_uuid=00000000-0000-0000-0000-000000000000 kb_name=None limit=5 query_len=19
2026-03-19 14:25:27,035 [INFO] rag.api_provider: gapgpt
2026-03-19 14:25:27,145 [INFO] rag.chat: Detected KB intent=chat
2026-03-19 14:25:27,145 [INFO] rag.chat: Using knowledge base=chat
2026-03-19 14:25:27,145 [INFO] rag.chat: Building RAG context sensor_uuid=00000000-0000-0000-0000-000000000000 kb_name=chat limit=5 query_len=19
2026-03-19 14:25:27,171 [INFO] rag.api_provider: gapgpt
2026-03-19 14:25:27,171 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1
2026-03-19 14:25:56,630 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 14:26:58,070 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 14:26:58,070 [INFO] openai._base_client: Retrying request to /embeddings in 0.432490 seconds
2026-03-19 14:28:28,794 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 14:28:28,795 [INFO] openai._base_client: Retrying request to /embeddings in 0.991839 seconds
2026-03-19 14:29:49,207 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading.
2026-03-19 14:29:52,551 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 14:30:38,193 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading.
2026-03-19 14:30:39,922 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 14:30:42,504 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading.
2026-03-19 14:30:45,509 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 14:30:49,190 [INFO] httpx: HTTP Request: GET http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 14:30:49,198 [INFO] httpx: HTTP Request: GET http://qdrant:6333 "HTTP/1.1 200 OK"
2026-03-19 14:30:49,199 [INFO] httpx: HTTP Request: DELETE http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 14:30:49,256 [INFO] httpx: HTTP Request: PUT http://qdrant:6333/collections/croplogic_kb "HTTP/1.1 200 OK"
2026-03-19 14:30:49,285 [INFO] rag.api_provider: gapgpt
2026-03-19 14:30:49,285 [INFO] rag.api_provider: sk-ZeFmDwROcQ2rYOFmUxHLjIwMTSUdo2qNc3Uraug9dOK2ihn5 https://api.gapgpt.app/v1
2026-03-19 14:32:20,003 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 14:32:20,003 [INFO] openai._base_client: Retrying request to /embeddings in 0.419902 seconds
2026-03-19 14:33:50,727 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 14:33:50,728 [INFO] openai._base_client: Retrying request to /embeddings in 0.883938 seconds
2026-03-19 14:35:21,862 [INFO] httpx: HTTP Request: POST https://api.gapgpt.app/v1/embeddings "HTTP/1.1 500 Internal Server Error"
2026-03-19 14:44:25,066 [INFO] django.utils.autoreload: /app/rag/config.py changed, reloading.
2026-03-19 14:44:28,557 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 14:59:19,518 [INFO] django.utils.autoreload: /app/config/settings.py changed, reloading.
2026-03-19 14:59:23,255 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 14:59:26,946 [INFO] django.utils.autoreload: /app/config/urls.py changed, reloading.
2026-03-19 14:59:30,776 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:03:30,274 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:10:06,813 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading.
2026-03-19 15:10:10,500 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:11:14,729 [INFO] django.utils.autoreload: /app/sensor_data/serializers.py changed, reloading.
2026-03-19 15:11:18,263 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:11:31,277 [INFO] django.utils.autoreload: /app/sensor_data/views.py changed, reloading.
2026-03-19 15:11:34,478 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:11:41,179 [INFO] django.utils.autoreload: /app/sensor_data/views.py changed, reloading.
2026-03-19 15:11:44,628 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:11:53,369 [INFO] django.utils.autoreload: /app/sensor_data/admin.py changed, reloading.
2026-03-19 15:11:56,528 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:22:11,378 [INFO] django.utils.autoreload: /app/config/settings.py changed, reloading.
2026-03-19 15:22:14,686 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:22:35,729 [INFO] django.utils.autoreload: /app/rag/user_data.py changed, reloading.
2026-03-19 15:22:39,043 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:22:44,642 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading.
2026-03-19 15:22:47,788 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:22:49,335 [INFO] django.utils.autoreload: /app/weather/models.py changed, reloading.
2026-03-19 15:22:52,507 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:22:58,238 [INFO] django.utils.autoreload: /app/location_data/migrations/0002_soildepthdata_refactor.py changed, reloading.
2026-03-19 15:23:01,389 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:23:07,088 [INFO] django.utils.autoreload: /app/location_data/migrations/0001_initial.py changed, reloading.
2026-03-19 15:23:10,608 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:23:44,947 [INFO] django.utils.autoreload: /app/weather/migrations/0001_initial.py changed, reloading.
2026-03-19 15:23:48,160 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:23:51,742 [INFO] django.utils.autoreload: /app/sensor_data/models.py changed, reloading.
2026-03-19 15:23:55,138 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:24:09,044 [INFO] django.utils.autoreload: /app/rag/user_data.py changed, reloading.
2026-03-19 15:24:12,254 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:36:50,309 [INFO] django.utils.autoreload: /app/rag/user_data.py changed, reloading.
2026-03-19 15:36:53,462 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:40:11,372 [INFO] django.utils.autoreload: /app/rag/urls.py changed, reloading.
2026-03-19 15:40:14,580 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 15:40:57,072 [INFO] django.utils.autoreload: /app/rag/views.py changed, reloading.
2026-03-19 15:41:00,143 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 17:55:08,865 [INFO] django.utils.autoreload: /app/config/settings.py changed, reloading.
2026-03-19 17:55:12,285 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
2026-03-19 20:14:05,386 [INFO] django.utils.autoreload: /app/rag/__init__.py changed, reloading.
2026-03-19 20:14:09,007 [INFO] django.utils.autoreload: Watching for file changes with StatReloader
+11
View File
@@ -7,6 +7,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from config.openapi import build_envelope_serializer, build_response from config.openapi import build_envelope_serializer, build_response
from rag.failure_contract import RAGServiceError
from rag.chat import encode_uploaded_image from rag.chat import encode_uploaded_image
from rag.services import get_pest_disease_detection, get_pest_disease_risk from rag.services import get_pest_disease_detection, get_pest_disease_risk
@@ -100,6 +101,11 @@ class PestDiseaseDetectionView(_ImageMixin, APIView):
query=validated.get("query"), query=validated.get("query"),
images=images, images=images,
) )
except RAGServiceError as exc:
return Response(
{"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()},
status=exc.http_status,
)
except Exception as exc: except Exception as exc:
return Response( return Response(
{"code": 500, "msg": f"خطا در تحلیل تصویر گیاه: {exc}", "data": None}, {"code": 500, "msg": f"خطا در تحلیل تصویر گیاه: {exc}", "data": None},
@@ -146,6 +152,11 @@ class PestDiseaseRiskView(APIView):
growth_stage=validated.get("growth_stage"), growth_stage=validated.get("growth_stage"),
query=validated.get("query"), query=validated.get("query"),
) )
except RAGServiceError as exc:
return Response(
{"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()},
status=exc.http_status,
)
except Exception as exc: except Exception as exc:
return Response( return Response(
{"code": 500, "msg": f"خطا در پیش بینی ریسک آفات و بیماری: {exc}", "data": None}, {"code": 500, "msg": f"خطا در پیش بینی ریسک آفات و بیماری: {exc}", "data": None},
+44 -11
View File
@@ -1,9 +1,12 @@
""" """
سرویس تعبیهسازی متن از Adapter Pattern برای سوئیچ بین providers استفاده میکند سرویس تعبیهسازی متن از Adapter Pattern برای سوئیچ بین providers استفاده میکند
""" """
import logging
import time
from .api_provider import get_embedding_client from .api_provider import get_embedding_client
from .config import RAGConfig, load_rag_config from .config import RAGConfig, load_rag_config
import logging from .observability import classify_exception, log_event, observe_operation, record_metric
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,12 +29,13 @@ def embed_texts(
لیست وکتورها لیست وکتورها
""" """
if not texts: if not texts:
record_metric("rag.embedding.empty_input", operation="embed_texts")
return [] return []
cfg = config or load_rag_config() cfg = config or load_rag_config()
client = get_embedding_client(cfg) client = get_embedding_client(cfg)
model_name = model or cfg.embedding.model model_name = model or cfg.embedding.model
logger.info(model_name) provider = cfg.embedding.provider or "unknown"
batch_size = cfg.embedding.batch_size batch_size = cfg.embedding.batch_size
all_embeddings: list[list[float]] = [] all_embeddings: list[list[float]] = []
@@ -39,15 +43,44 @@ def embed_texts(
if dimensions is not None: if dimensions is not None:
extra["dimensions"] = dimensions extra["dimensions"] = dimensions
for i in range(0, len(texts), batch_size): with observe_operation(source="rag.embedding", provider=provider, operation="embed_texts"):
batch = texts[i : i + batch_size] for i in range(0, len(texts), batch_size):
resp = client.embeddings.create( batch = texts[i : i + batch_size]
model=model_name, started_at = time.monotonic()
input=batch, try:
**extra, resp = client.embeddings.create(
) model=model_name,
for item in sorted(resp.data, key=lambda x: x.index): input=batch,
all_embeddings.append(item.embedding) **extra,
)
except Exception as exc:
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="embedding batch request failed",
source="rag.embedding",
provider=provider,
operation="embed_batch",
result_status="error",
duration_ms=(time.monotonic() - started_at) * 1000,
error_code=failure.error_code,
batch_size=len(batch),
model=model_name,
)
raise
for item in sorted(resp.data, key=lambda x: x.index):
all_embeddings.append(item.embedding)
log_event(
level=logging.INFO,
message="embedding batch request completed",
source="rag.embedding",
provider=provider,
operation="embed_batch",
result_status="success",
duration_ms=(time.monotonic() - started_at) * 1000,
batch_size=len(batch),
model=model_name,
)
return all_embeddings return all_embeddings
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class FailureContract:
status: str = "error"
error_code: str = "internal_error"
message: str = ""
source: str = "application"
warnings: list[str] = field(default_factory=list)
retriable: bool = False
details: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
payload = {
"status": self.status,
"error_code": self.error_code,
"message": self.message,
"source": self.source,
"warnings": list(self.warnings),
"retriable": self.retriable,
}
if self.details:
payload["details"] = self.details
return payload
class RAGServiceError(Exception):
def __init__(
self,
*,
error_code: str,
message: str,
source: str,
warnings: list[str] | None = None,
retriable: bool = False,
details: dict[str, Any] | None = None,
http_status: int = 500,
) -> None:
super().__init__(message)
self.http_status = http_status
self.contract = FailureContract(
error_code=error_code,
message=message,
source=source,
warnings=warnings or [],
retriable=retriable,
details=details or {},
)
def to_dict(self) -> dict[str, Any]:
return self.contract.to_dict()
+41 -23
View File
@@ -12,6 +12,7 @@ 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 .observability import classify_exception, log_event, observe_operation, record_metric
from .user_data import load_user_sources, build_user_weather_text from .user_data import load_user_sources, build_user_weather_text
from .vector_store import QdrantVectorStore from .vector_store import QdrantVectorStore
@@ -36,7 +37,19 @@ def _load_file(path: Path) -> str | None:
return None return None
try: try:
return path.read_text(encoding="utf-8").strip() return path.read_text(encoding="utf-8").strip()
except Exception: except Exception as exc:
failure = classify_exception(exc)
log_event(
level=40,
message="rag ingest file load failed",
source="rag.ingest",
provider=None,
operation="load_file",
result_status="error",
error_code=failure.error_code,
path=str(path),
)
record_metric("rag.ingest.file_load_failure", error_code=failure.error_code)
return None return None
@@ -122,12 +135,14 @@ def ingest(
""" """
cfg = config or load_rag_config() cfg = config or load_rag_config()
store = QdrantVectorStore(config=cfg) store = QdrantVectorStore(config=cfg)
if recreate: with observe_operation(source="rag.ingest", provider=cfg.embedding.provider, operation="ingest"):
store.ensure_collection(recreate=True) if recreate:
store.ensure_collection(recreate=True)
sources = load_sources(config=cfg, kb_name=kb_name) sources = load_sources(config=cfg, kb_name=kb_name)
if not sources: if not sources:
return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"} record_metric("rag.ingest.empty_sources", kb_name=kb_name)
return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"}
all_chunks: list[str] = [] all_chunks: list[str] = []
all_metas: list[dict] = [] all_metas: list[dict] = []
@@ -146,24 +161,27 @@ def ingest(
"kb_name": src_kb, "kb_name": src_kb,
}) })
if not all_chunks: if not all_chunks:
return {"chunks_added": 0, "sources": [s[0] for s in sources], "error": "هیچ چانکی ساخته نشد"} record_metric("rag.ingest.empty_chunks", kb_name=kb_name)
return {"chunks_added": 0, "sources": [s[0] for s in sources], "error": "هیچ چانکی ساخته نشد"}
embeddings = embed_texts(all_chunks, config=cfg) embeddings = embed_texts(all_chunks, config=cfg)
if len(embeddings) != len(all_chunks): if len(embeddings) != len(all_chunks):
record_metric("rag.ingest.embedding_mismatch", kb_name=kb_name)
return {
"chunks_added": 0,
"sources": [s[0] for s in sources],
"error": f"تعداد embed با چانک‌ها مطابقت ندارد: {len(embeddings)} vs {len(all_chunks)}",
}
store.add_documents(
ids=all_ids,
embeddings=embeddings,
documents=all_chunks,
metadatas=all_metas,
)
record_metric("rag.ingest.success", kb_name=kb_name, chunks=len(all_chunks))
return { return {
"chunks_added": 0, "chunks_added": len(all_chunks),
"sources": [s[0] for s in sources], "sources": [s[0] for s in sources],
"error": f"تعداد embed با چانک‌ها مطابقت ندارد: {len(embeddings)} vs {len(all_chunks)}",
} }
store.add_documents(
ids=all_ids,
embeddings=embeddings,
documents=all_chunks,
metadatas=all_metas,
)
return {
"chunks_added": len(all_chunks),
"sources": [s[0] for s in sources],
}
+129
View File
@@ -0,0 +1,129 @@
from __future__ import annotations
import logging
import time
from collections import Counter
from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any
logger = logging.getLogger(__name__)
_request_id_ctx: ContextVar[str | None] = ContextVar("rag_request_id", default=None)
METRICS: Counter[str] = Counter()
def set_request_id(request_id: str | None) -> None:
_request_id_ctx.set(request_id)
def get_request_id() -> str | None:
return _request_id_ctx.get()
def record_metric(name: str, value: int = 1, **tags: Any) -> None:
suffix = ",".join(f"{key}={tags[key]}" for key in sorted(tags) if tags[key] is not None)
metric_key = f"{name}|{suffix}" if suffix else name
METRICS[metric_key] += value
@dataclass
class ClassifiedFailure:
error_code: str
failure_type: str
retriable: bool
def classify_exception(exc: Exception) -> ClassifiedFailure:
exc_name = exc.__class__.__name__.lower()
message = str(exc).lower()
if "timeout" in exc_name or "timeout" in message:
return ClassifiedFailure("timeout", "timeout", True)
if "json" in exc_name or "json" in message:
return ClassifiedFailure("parse_error", "parse_error", False)
if "validation" in exc_name or "invalid" in message:
return ClassifiedFailure("validation_failure", "validation_failure", False)
if "connection" in exc_name or "unavailable" in message:
return ClassifiedFailure("dependency_unavailable", "dependency_unavailable", True)
return ClassifiedFailure("provider_error", "provider_error", True)
def log_event(
*,
level: int,
message: str,
source: str,
provider: str | None,
operation: str,
result_status: str,
duration_ms: float | None = None,
error_code: str | None = None,
**extra: Any,
) -> None:
payload = {
"source": source,
"provider": provider,
"operation": operation,
"result_status": result_status,
"duration_ms": round(duration_ms, 2) if duration_ms is not None else None,
"error_code": error_code,
"request_id": get_request_id(),
}
payload.update({key: value for key, value in extra.items() if value is not None})
logger.log(level, message, extra={"event": payload})
class observe_operation:
def __init__(self, *, source: str, provider: str | None, operation: str):
self.source = source
self.provider = provider
self.operation = operation
self.started_at = 0.0
def __enter__(self):
self.started_at = time.monotonic()
log_event(
level=logging.INFO,
message="rag operation started",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="started",
)
return self
def __exit__(self, exc_type, exc, _tb):
duration_ms = (time.monotonic() - self.started_at) * 1000
if exc is None:
log_event(
level=logging.INFO,
message="rag operation completed",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="success",
duration_ms=duration_ms,
)
record_metric("rag.operation.success", source=self.source, provider=self.provider, operation=self.operation)
return False
failure = classify_exception(exc)
log_event(
level=logging.ERROR,
message="rag operation failed",
source=self.source,
provider=self.provider,
operation=self.operation,
result_status="error",
duration_ms=duration_ms,
error_code=failure.error_code,
failure_type=failure.failure_type,
)
record_metric(
"rag.operation.failure",
source=self.source,
provider=self.provider,
operation=self.operation,
error_code=failure.error_code,
)
return False
+37 -28
View File
@@ -3,6 +3,7 @@
""" """
from .config import load_rag_config, RAGConfig, get_service_config from .config import load_rag_config, RAGConfig, get_service_config
from .embedding import embed_single, embed_texts from .embedding import embed_single, embed_texts
from .observability import observe_operation, record_metric
from .vector_store import QdrantVectorStore from .vector_store import QdrantVectorStore
@@ -63,15 +64,19 @@ def search_with_query(
use_user_embeddings=use_user_embeddings, use_user_embeddings=use_user_embeddings,
) )
query_vector = embed_single(query, config=cfg) with observe_operation(source="rag.retrieve", provider=cfg.embedding.provider, operation="search_with_query"):
store = QdrantVectorStore(config=cfg) query_vector = embed_single(query, config=cfg)
return store.search( store = QdrantVectorStore(config=cfg)
query_vector=query_vector, results = store.search(
limit=limit, query_vector=query_vector,
score_threshold=score_threshold, limit=limit,
sensor_uuids=sensor_filters, score_threshold=score_threshold,
kb_names=kb_filters, sensor_uuids=sensor_filters,
) kb_names=kb_filters,
)
if not results:
record_metric("rag.retrieve.empty_result", operation="search_with_query", service_id=service_id)
return results
def search_with_texts( def search_with_texts(
@@ -102,24 +107,28 @@ def search_with_texts(
) )
store = QdrantVectorStore(config=cfg) store = QdrantVectorStore(config=cfg)
vectors = embed_texts(normalized_texts, config=cfg) with observe_operation(source="rag.retrieve", provider=cfg.embedding.provider, operation="search_with_texts"):
merged_results: dict[str, dict] = {} vectors = embed_texts(normalized_texts, config=cfg)
merged_results: dict[str, dict] = {}
for vector in vectors: for vector in vectors:
results = store.search( results = store.search(
query_vector=vector, query_vector=vector,
limit=per_text_limit, limit=per_text_limit,
score_threshold=score_threshold, score_threshold=score_threshold,
sensor_uuids=sensor_filters, sensor_uuids=sensor_filters,
kb_names=kb_filters, kb_names=kb_filters,
) )
for item in results: for item in results:
current = merged_results.get(item["id"]) current = merged_results.get(item["id"])
if current is None or item["score"] > current["score"]: if current is None or item["score"] > current["score"]:
merged_results[item["id"]] = item merged_results[item["id"]] = item
return sorted( final_results = sorted(
merged_results.values(), merged_results.values(),
key=lambda item: item["score"], key=lambda item: item["score"],
reverse=True, reverse=True,
)[:limit] )[:limit]
if not final_results:
record_metric("rag.retrieve.empty_result", operation="search_with_texts", service_id=service_id)
return final_results
+81 -14
View File
@@ -18,6 +18,7 @@ from rag.chat import (
build_rag_context, build_rag_context,
) )
from rag.config import RAGConfig, get_service_config, load_rag_config from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.failure_contract import RAGServiceError
from rag.user_data import build_plant_text from rag.user_data import build_plant_text
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,18 +74,47 @@ def _clean_json(raw: str) -> dict[str, Any]:
cleaned = cleaned[4:] cleaned = cleaned[4:]
cleaned = cleaned.strip() cleaned = cleaned.strip()
if not cleaned: if not cleaned:
return {} raise RAGServiceError(
error_code="empty_response",
message="Pest disease LLM response was empty.",
source="llm",
retriable=True,
details={"service_id": SERVICE_ID},
http_status=502,
)
try: try:
return json.loads(cleaned) parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError) as exc:
logger.warning("Invalid JSON returned by pest_disease LLM: %s", cleaned[:500]) logger.warning("Invalid JSON returned by pest_disease LLM: %s", cleaned[:500])
return {} raise RAGServiceError(
error_code="invalid_json",
message="Pest disease LLM response was not valid JSON.",
source="llm",
retriable=True,
details={"service_id": SERVICE_ID},
http_status=502,
) from exc
if not isinstance(parsed, dict):
raise RAGServiceError(
error_code="invalid_schema",
message="Pest disease LLM response root must be a JSON object.",
source="llm",
details={"service_id": SERVICE_ID},
http_status=502,
)
return parsed
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
farm_details = get_farm_details(farm_uuid) farm_details = get_farm_details(farm_uuid)
if farm_details is None: if farm_details is None:
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") raise RAGServiceError(
error_code="farm_not_found",
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
source="farm_data",
details={"farm_uuid": farm_uuid},
http_status=404,
)
return farm_details return farm_details
@@ -213,9 +243,12 @@ def _validate_detection_result(parsed: dict[str, Any]) -> dict[str, Any]:
} }
missing = [key for key in required_keys if key not in parsed] missing = [key for key in required_keys if key not in parsed]
if missing: if missing:
raise ValueError( raise RAGServiceError(
"Pest disease detection response is missing required fields: " error_code="invalid_schema",
+ ", ".join(missing) message="Pest disease detection response is missing required fields: " + ", ".join(missing),
source="llm",
details={"missing_fields": missing, "service_id": SERVICE_ID},
http_status=502,
) )
return parsed return parsed
@@ -232,9 +265,12 @@ def _validate_risk_result(parsed: dict[str, Any]) -> dict[str, Any]:
} }
missing = [key for key in required_keys if key not in parsed] missing = [key for key in required_keys if key not in parsed]
if missing: if missing:
raise ValueError( raise RAGServiceError(
"Pest disease risk response is missing required fields: " error_code="invalid_schema",
+ ", ".join(missing) message="Pest disease risk response is missing required fields: " + ", ".join(missing),
source="llm",
details={"missing_fields": missing, "service_id": SERVICE_ID},
http_status=502,
) )
return parsed return parsed
@@ -301,7 +337,12 @@ def get_pest_disease_detection(
) -> dict[str, Any]: ) -> dict[str, Any]:
normalized_images = _normalize_images(images) normalized_images = _normalize_images(images)
if not normalized_images: if not normalized_images:
raise ValueError("حداقل یک تصویر برای تشخیص لازم است.") raise RAGServiceError(
error_code="missing_images",
message="حداقل یک تصویر برای تشخیص لازم است.",
source="request",
http_status=400,
)
cfg = load_rag_config() cfg = load_rag_config()
service, client, model = _build_service_client(cfg) service, client, model = _build_service_client(cfg)
@@ -338,12 +379,25 @@ def get_pest_disease_detection(
raw = response.choices[0].message.content.strip() raw = response.choices[0].message.content.strip()
parsed = _clean_json(raw) parsed = _clean_json(raw)
_complete_audit_log(audit_log, raw) _complete_audit_log(audit_log, raw)
except RAGServiceError as exc:
logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc))
raise
except Exception as exc: except Exception as exc:
logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc) logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc)) _fail_audit_log(audit_log, str(exc))
raise RuntimeError(f"Pest disease detection failed for farm {farm_uuid}.") from exc raise RAGServiceError(
error_code="upstream_failure",
message=f"Pest disease detection failed for farm {farm_uuid}.",
source="llm",
retriable=True,
details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID},
http_status=503,
) from exc
parsed = _validate_detection_result(parsed) parsed = _validate_detection_result(parsed)
parsed["status"] = "success"
parsed["source"] = "llm"
parsed["farm_uuid"] = farm_uuid parsed["farm_uuid"] = farm_uuid
parsed["raw_response"] = raw parsed["raw_response"] = raw
return parsed return parsed
@@ -392,12 +446,25 @@ def get_pest_disease_risk(
raw = response.choices[0].message.content.strip() raw = response.choices[0].message.content.strip()
parsed = _clean_json(raw) parsed = _clean_json(raw)
_complete_audit_log(audit_log, raw) _complete_audit_log(audit_log, raw)
except RAGServiceError as exc:
logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc))
raise
except Exception as exc: except Exception as exc:
logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc) logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc)) _fail_audit_log(audit_log, str(exc))
raise RuntimeError(f"Pest disease risk prediction failed for farm {farm_uuid}.") from exc raise RAGServiceError(
error_code="upstream_failure",
message=f"Pest disease risk prediction failed for farm {farm_uuid}.",
source="llm",
retriable=True,
details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID},
http_status=503,
) from exc
parsed = _validate_risk_result(parsed) parsed = _validate_risk_result(parsed)
parsed["status"] = "success"
parsed["source"] = "llm"
parsed["farm_uuid"] = farm_uuid parsed["farm_uuid"] = farm_uuid
parsed["raw_response"] = raw parsed["raw_response"] = raw
return parsed return parsed
+56 -9
View File
@@ -14,6 +14,7 @@ from rag.chat import (
build_rag_context, build_rag_context,
) )
from rag.config import RAGConfig, get_service_config, load_rag_config from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.failure_contract import RAGServiceError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,18 +40,48 @@ def _clean_json(raw: str) -> dict[str, Any]:
cleaned = cleaned[4:] cleaned = cleaned[4:]
cleaned = cleaned.strip() cleaned = cleaned.strip()
if not cleaned: if not cleaned:
return {} raise RAGServiceError(
error_code="empty_response",
message="Soil anomaly LLM response was empty.",
source="llm",
retriable=True,
details={"service_id": SERVICE_ID},
http_status=502,
)
try: try:
return json.loads(cleaned) parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError) as exc:
logger.warning("Invalid JSON returned by soil_anomaly LLM: %s", cleaned[:500]) logger.warning("Invalid JSON returned by soil_anomaly LLM: %s", cleaned[:500])
return {} raise RAGServiceError(
error_code="invalid_json",
message="Soil anomaly LLM response was not valid JSON.",
source="llm",
retriable=True,
details={"service_id": SERVICE_ID},
http_status=502,
) from exc
if not isinstance(parsed, dict):
raise RAGServiceError(
error_code="invalid_schema",
message="Soil anomaly LLM response root must be a JSON object.",
source="llm",
retriable=False,
details={"service_id": SERVICE_ID},
http_status=502,
)
return parsed
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
farm_details = get_farm_details(farm_uuid) farm_details = get_farm_details(farm_uuid)
if farm_details is None: if farm_details is None:
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") raise RAGServiceError(
error_code="farm_not_found",
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
source="farm_data",
details={"farm_uuid": farm_uuid},
http_status=404,
)
return farm_details return farm_details
@@ -80,9 +111,12 @@ def _validate_anomaly_insight(parsed: dict[str, Any]) -> dict[str, Any]:
} }
missing = [key for key in required_keys if key not in parsed] missing = [key for key in required_keys if key not in parsed]
if missing: if missing:
raise ValueError( raise RAGServiceError(
"Soil anomaly insight response is missing required fields: " error_code="invalid_schema",
+ ", ".join(missing) message="Soil anomaly insight response is missing required fields: " + ", ".join(missing),
source="llm",
details={"missing_fields": missing, "service_id": SERVICE_ID},
http_status=502,
) )
return parsed return parsed
@@ -156,12 +190,25 @@ def get_soil_anomaly_insight(
raw = response.choices[0].message.content.strip() raw = response.choices[0].message.content.strip()
parsed = _clean_json(raw) parsed = _clean_json(raw)
_complete_audit_log(audit_log, raw) _complete_audit_log(audit_log, raw)
except RAGServiceError as exc:
logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc))
raise
except Exception as exc: except Exception as exc:
logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc) logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc)) _fail_audit_log(audit_log, str(exc))
raise RuntimeError(f"Soil anomaly insight failed for farm {farm_uuid}.") from exc raise RAGServiceError(
error_code="upstream_failure",
message=f"Soil anomaly insight failed for farm {farm_uuid}.",
source="llm",
retriable=True,
details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID},
http_status=503,
) from exc
parsed = _validate_anomaly_insight(parsed) parsed = _validate_anomaly_insight(parsed)
parsed["status"] = "success"
parsed["source"] = "llm"
parsed["farm_uuid"] = farm_uuid parsed["farm_uuid"] = farm_uuid
parsed["raw_response"] = raw parsed["raw_response"] = raw
return parsed return parsed
+55 -9
View File
@@ -14,6 +14,7 @@ from rag.chat import (
build_rag_context, build_rag_context,
) )
from rag.config import RAGConfig, get_service_config, load_rag_config from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.failure_contract import RAGServiceError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -38,18 +39,47 @@ def _clean_json(raw: str) -> dict[str, Any]:
cleaned = cleaned[4:] cleaned = cleaned[4:]
cleaned = cleaned.strip() cleaned = cleaned.strip()
if not cleaned: if not cleaned:
return {} raise RAGServiceError(
error_code="empty_response",
message="Water need prediction LLM response was empty.",
source="llm",
retriable=True,
details={"service_id": SERVICE_ID},
http_status=502,
)
try: try:
return json.loads(cleaned) parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError): except (json.JSONDecodeError, ValueError) as exc:
logger.warning("Invalid JSON returned by water_need_prediction LLM: %s", cleaned[:500]) logger.warning("Invalid JSON returned by water_need_prediction LLM: %s", cleaned[:500])
return {} raise RAGServiceError(
error_code="invalid_json",
message="Water need prediction LLM response was not valid JSON.",
source="llm",
retriable=True,
details={"service_id": SERVICE_ID},
http_status=502,
) from exc
if not isinstance(parsed, dict):
raise RAGServiceError(
error_code="invalid_schema",
message="Water need prediction LLM response root must be a JSON object.",
source="llm",
details={"service_id": SERVICE_ID},
http_status=502,
)
return parsed
def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]: def _load_farm_or_error(farm_uuid: str) -> dict[str, Any]:
farm_details = get_farm_details(farm_uuid) farm_details = get_farm_details(farm_uuid)
if farm_details is None: if farm_details is None:
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.") raise RAGServiceError(
error_code="farm_not_found",
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
source="farm_data",
details={"farm_uuid": farm_uuid},
http_status=404,
)
return farm_details return farm_details
@@ -78,9 +108,12 @@ def _validate_prediction_insight(parsed: dict[str, Any]) -> dict[str, Any]:
} }
missing = [key for key in required_keys if key not in parsed] missing = [key for key in required_keys if key not in parsed]
if missing: if missing:
raise ValueError( raise RAGServiceError(
"Water need prediction insight response is missing required fields: " error_code="invalid_schema",
+ ", ".join(missing) message="Water need prediction insight response is missing required fields: " + ", ".join(missing),
source="llm",
details={"missing_fields": missing, "service_id": SERVICE_ID},
http_status=502,
) )
return parsed return parsed
@@ -154,12 +187,25 @@ def get_water_need_prediction_insight(
raw = response.choices[0].message.content.strip() raw = response.choices[0].message.content.strip()
parsed = _clean_json(raw) parsed = _clean_json(raw)
_complete_audit_log(audit_log, raw) _complete_audit_log(audit_log, raw)
except RAGServiceError as exc:
logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc))
raise
except Exception as exc: except Exception as exc:
logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc) logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc)
_fail_audit_log(audit_log, str(exc)) _fail_audit_log(audit_log, str(exc))
raise RuntimeError(f"Water need prediction insight failed for farm {farm_uuid}.") from exc raise RAGServiceError(
error_code="upstream_failure",
message=f"Water need prediction insight failed for farm {farm_uuid}.",
source="llm",
retriable=True,
details={"farm_uuid": farm_uuid, "service_id": SERVICE_ID},
http_status=503,
) from exc
parsed = _validate_prediction_insight(parsed) parsed = _validate_prediction_insight(parsed)
parsed["status"] = "success"
parsed["source"] = "llm"
parsed["farm_uuid"] = farm_uuid parsed["farm_uuid"] = farm_uuid
parsed["raw_response"] = raw parsed["raw_response"] = raw
return parsed return parsed
+41 -5
View File
@@ -14,6 +14,7 @@ from rag.chat import (
_load_service_tone, _load_service_tone,
) )
from rag.config import RAGConfig, get_service_config, load_rag_config from rag.config import RAGConfig, get_service_config, load_rag_config
from rag.failure_contract import RAGServiceError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -90,6 +91,8 @@ class YieldHarvestRAGService:
if audit_log is not None: if audit_log is not None:
_complete_audit_log(audit_log, raw) _complete_audit_log(audit_log, raw)
return { return {
"status": "success",
"source": "llm",
"season_highlights_subtitle": validated.season_highlights_subtitle, "season_highlights_subtitle": validated.season_highlights_subtitle,
"yield_prediction_explanation": validated.yield_prediction_explanation, "yield_prediction_explanation": validated.yield_prediction_explanation,
"harvest_readiness_summary": validated.harvest_readiness_summary, "harvest_readiness_summary": validated.harvest_readiness_summary,
@@ -99,12 +102,25 @@ class YieldHarvestRAGService:
logger.warning("Yield harvest narrative parsing failed for farm_uuid=%s: %s", farm_uuid, exc) logger.warning("Yield harvest narrative parsing failed for farm_uuid=%s: %s", farm_uuid, exc)
if audit_log is not None: if audit_log is not None:
_fail_audit_log(audit_log, str(exc)) _fail_audit_log(audit_log, str(exc))
return {} raise RAGServiceError(
error_code="invalid_payload",
message=f"Yield harvest narrative parsing failed for farm_uuid={farm_uuid or 'unknown'}.",
source="llm",
details={"farm_uuid": farm_uuid or "unknown", "service_id": SERVICE_ID},
http_status=502,
) from exc
except Exception as exc: except Exception as exc:
logger.error("Yield harvest narrative LLM call failed for farm_uuid=%s: %s", farm_uuid, exc) logger.error("Yield harvest narrative LLM call failed for farm_uuid=%s: %s", farm_uuid, exc)
if audit_log is not None: if audit_log is not None:
_fail_audit_log(audit_log, str(exc)) _fail_audit_log(audit_log, str(exc))
return {} raise RAGServiceError(
error_code="upstream_failure",
message=f"Yield harvest narrative generation failed for farm_uuid={farm_uuid or 'unknown'}.",
source="llm",
retriable=True,
details={"farm_uuid": farm_uuid or "unknown", "service_id": SERVICE_ID},
http_status=503,
) from exc
def _build_service_client(self, cfg: RAGConfig): def _build_service_client(self, cfg: RAGConfig):
service = get_service_config(SERVICE_ID, cfg) service = get_service_config(SERVICE_ID, cfg)
@@ -217,11 +233,31 @@ class YieldHarvestRAGService:
cleaned = cleaned[4:] cleaned = cleaned[4:]
cleaned = cleaned.strip() cleaned = cleaned.strip()
if not cleaned: if not cleaned:
raise ValueError("Yield harvest narrative response was empty.") raise RAGServiceError(
error_code="empty_response",
message="Yield harvest narrative response was empty.",
source="llm",
retriable=True,
details={"service_id": SERVICE_ID},
http_status=502,
)
try: try:
parsed = json.loads(cleaned) parsed = json.loads(cleaned)
except (json.JSONDecodeError, ValueError) as exc: except (json.JSONDecodeError, ValueError) as exc:
raise ValueError("Yield harvest narrative response was not valid JSON.") from exc raise RAGServiceError(
error_code="invalid_json",
message="Yield harvest narrative response was not valid JSON.",
source="llm",
retriable=True,
details={"service_id": SERVICE_ID},
http_status=502,
) from exc
if not isinstance(parsed, dict): if not isinstance(parsed, dict):
raise ValueError("Yield harvest narrative response root must be a JSON object.") raise RAGServiceError(
error_code="invalid_schema",
message="Yield harvest narrative response root must be a JSON object.",
source="llm",
details={"service_id": SERVICE_ID},
http_status=502,
)
return parsed return parsed
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import Mock, patch
from django.test import SimpleTestCase
from rag.failure_contract import RAGServiceError
from rag.services.pest_disease import get_pest_disease_detection
from rag.services.soil_anomaly import get_soil_anomaly_insight
from rag.services.water_need_prediction import get_water_need_prediction_insight
from rag.services.yield_harvest import YieldHarvestRAGService
class RAGFailureContractTests(SimpleTestCase):
@patch("rag.services.soil_anomaly._create_audit_log", return_value=object())
@patch("rag.services.soil_anomaly._fail_audit_log")
@patch("rag.services.soil_anomaly._build_service_client")
@patch("rag.services.soil_anomaly.build_rag_context", return_value="")
@patch("rag.services.soil_anomaly._load_farm_or_error", return_value={"farm_uuid": "farm-1"})
def test_soil_anomaly_invalid_json_raises_structured_error(
self,
_mock_load_farm,
_mock_context,
mock_build_client,
_mock_fail,
_mock_audit,
):
client = Mock()
client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))]
)
mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test")
with self.assertRaises(RAGServiceError) as exc_info:
get_soil_anomaly_insight(farm_uuid="farm-1", anomaly_payload={})
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
@patch("rag.services.water_need_prediction._create_audit_log", return_value=object())
@patch("rag.services.water_need_prediction._fail_audit_log")
@patch("rag.services.water_need_prediction._build_service_client")
@patch("rag.services.water_need_prediction.build_rag_context", return_value="")
@patch("rag.services.water_need_prediction._load_farm_or_error", return_value={"farm_uuid": "farm-1"})
def test_water_need_invalid_json_raises_structured_error(
self,
_mock_load_farm,
_mock_context,
mock_build_client,
_mock_fail,
_mock_audit,
):
client = Mock()
client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))]
)
mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test")
with self.assertRaises(RAGServiceError) as exc_info:
get_water_need_prediction_insight(farm_uuid="farm-1", prediction_payload={})
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
def test_pest_detection_requires_image_with_structured_error(self):
with self.assertRaises(RAGServiceError) as exc_info:
get_pest_disease_detection(farm_uuid="farm-1", images=[])
self.assertEqual(exc_info.exception.contract.error_code, "missing_images")
@patch("rag.services.yield_harvest._create_audit_log", return_value=object())
@patch("rag.services.yield_harvest._fail_audit_log")
@patch("rag.services.yield_harvest.YieldHarvestRAGService._build_service_client")
def test_yield_harvest_invalid_json_raises_structured_error(
self,
mock_build_client,
_mock_fail,
_mock_audit,
):
client = Mock()
client.chat.completions.create.return_value = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="not-json"))]
)
mock_build_client.return_value = (SimpleNamespace(system_prompt=""), client, "gpt-test")
with self.assertRaises(RAGServiceError) as exc_info:
YieldHarvestRAGService().generate_narrative({"farm_uuid": "farm-1"})
self.assertEqual(exc_info.exception.contract.error_code, "invalid_json")
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import Mock, patch
from django.test import SimpleTestCase
from rag.embedding import embed_texts
from rag.ingest import ingest
from rag.observability import METRICS
from rag.retrieve import search_with_query
class RAGObservabilityTests(SimpleTestCase):
def tearDown(self):
METRICS.clear()
def test_embed_texts_records_empty_input_metric(self):
result = embed_texts([])
self.assertEqual(result, [])
self.assertEqual(METRICS["rag.embedding.empty_input|operation=embed_texts"], 1)
@patch("rag.retrieve.QdrantVectorStore")
@patch("rag.retrieve.embed_single", return_value=[0.1, 0.2])
@patch("rag.retrieve.load_rag_config")
def test_search_with_query_records_empty_result_metric(self, mock_load_config, _mock_embed, mock_store_cls):
mock_load_config.return_value = SimpleNamespace(
embedding=SimpleNamespace(provider="gapgpt"),
)
mock_store = Mock()
mock_store.search.return_value = []
mock_store_cls.return_value = mock_store
result = search_with_query("query")
self.assertEqual(result, [])
self.assertEqual(METRICS["rag.retrieve.empty_result|operation=search_with_query,service_id=None"], 1)
@patch("rag.ingest.load_sources", return_value=[])
@patch("rag.ingest.QdrantVectorStore")
@patch("rag.ingest.load_rag_config")
def test_ingest_records_empty_sources_metric(self, mock_load_config, _mock_store_cls, _mock_sources):
mock_load_config.return_value = SimpleNamespace(
embedding=SimpleNamespace(provider="gapgpt"),
)
result = ingest()
self.assertEqual(result["chunks_added"], 0)
self.assertEqual(METRICS["rag.ingest.empty_sources|kb_name=None"], 1)
+66 -5
View File
@@ -4,10 +4,10 @@ from unittest.mock import Mock, patch
from django.test import TestCase from django.test import TestCase
from farm_data.models import SensorData from farm_data.models import PlantCatalogSnapshot, SensorData
from farm_data.services import assign_farm_plants_from_backend_ids
from irrigation.models import IrrigationMethod from irrigation.models import IrrigationMethod
from location_data.models import SoilLocation from location_data.models import SoilLocation
from plant.models import Plant
from rag.services.fertilization import get_fertilization_recommendation from rag.services.fertilization import get_fertilization_recommendation
from rag.services.irrigation import get_irrigation_recommendation from rag.services.irrigation import get_irrigation_recommendation
from weather.models import WeatherForecast from weather.models import WeatherForecast
@@ -27,8 +27,8 @@ class RecommendationServiceDefaultsTests(TestCase):
temperature_max=23.0, temperature_max=23.0,
temperature_mean=18.0, temperature_mean=18.0,
) )
self.plant = Plant.objects.create(name="گوجه‌فرنگی") self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجه‌فرنگی")
self.onion = Plant.objects.create(name="پیاز") self.onion = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="پیاز")
self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای") self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطره‌ای")
self.farm_uuid = uuid.uuid4() self.farm_uuid = uuid.uuid4()
self.farm = SensorData.objects.create( self.farm = SensorData.objects.create(
@@ -45,7 +45,7 @@ class RecommendationServiceDefaultsTests(TestCase):
} }
}, },
) )
self.farm.plants.set([self.plant]) assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id])
def build_irrigation_optimizer_result(self): def build_irrigation_optimizer_result(self):
return { return {
@@ -162,6 +162,39 @@ class RecommendationServiceDefaultsTests(TestCase):
self.assertEqual(result["sections"][1]["type"], "tip") self.assertEqual(result["sections"][1]["type"], "tip")
self.assertEqual(result["water_balance"]["active_kc"], 0.9) self.assertEqual(result["water_balance"]["active_kc"], 0.9)
@patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[])
@patch("rag.services.irrigation.resolve_kc", return_value=0.9)
@patch("rag.services.irrigation.resolve_crop_profile", return_value={})
@patch("rag.services.irrigation.build_irrigation_method_text", return_value="method text")
@patch("rag.services.irrigation.build_plant_text", return_value="plant text")
@patch("rag.services.irrigation.build_rag_context", return_value="")
@patch("rag.services.irrigation._get_optimizer")
@patch("rag.services.irrigation.get_chat_client")
def test_irrigation_recommendation_reads_from_canonical_farm_data_assignments(
self,
mock_get_chat_client,
mock_get_optimizer,
_mock_build_rag_context,
mock_build_plant_text,
_mock_build_irrigation_method_text,
_mock_resolve_crop_profile,
_mock_resolve_kc,
_mock_calculate_forecast_water_needs,
):
assign_farm_plants_from_backend_ids(self.farm, [self.onion.backend_plant_id, self.plant.backend_plant_id])
mock_get_optimizer.return_value.optimize_irrigation.return_value = self.build_irrigation_optimizer_result()
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content=self.build_irrigation_llm_result()))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_irrigation_recommendation(
farm_uuid=str(self.farm_uuid),
growth_stage="میوه‌دهی",
)
self.assertEqual(result["selected_plant"]["name"], "پیاز")
mock_build_plant_text.assert_called_once_with("پیاز", "میوه‌دهی")
@patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[]) @patch("rag.services.irrigation.calculate_forecast_water_needs", return_value=[])
@patch("rag.services.irrigation.resolve_kc", return_value=0.9) @patch("rag.services.irrigation.resolve_kc", return_value=0.9)
@patch("rag.services.irrigation.resolve_crop_profile", return_value={}) @patch("rag.services.irrigation.resolve_crop_profile", return_value={})
@@ -299,6 +332,34 @@ class RecommendationServiceDefaultsTests(TestCase):
mock_build_plant_text.assert_called_once_with("پیاز", "flowering") mock_build_plant_text.assert_called_once_with("پیاز", "flowering")
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20") self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
@patch("rag.services.fertilization.build_plant_text", return_value="plant text")
@patch("rag.services.fertilization.build_rag_context", return_value="")
@patch("rag.services.fertilization._get_optimizer")
@patch("rag.services.fertilization.get_chat_client")
def test_fertilization_recommendation_uses_canonical_assignment_lookup_for_requested_catalog_plant(
self,
mock_get_chat_client,
mock_get_optimizer,
_mock_build_rag_context,
mock_build_plant_text,
):
assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id, self.onion.backend_plant_id])
mock_get_optimizer.return_value.optimize_fertilization.return_value = self.build_fertilization_optimizer_result()
mock_response = Mock()
mock_response.choices = [Mock(message=Mock(content="not-json"))]
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
result = get_fertilization_recommendation(
farm_uuid=str(self.farm_uuid),
plant_name="پیاز",
growth_stage="گلدهی",
)
optimizer_call = mock_get_optimizer.return_value.optimize_fertilization.call_args.kwargs
self.assertEqual(getattr(optimizer_call["plant"], "name", None), "پیاز")
mock_build_plant_text.assert_called_once_with("پیاز", "flowering")
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
@patch("rag.services.fertilization.build_plant_text", return_value="plant text") @patch("rag.services.fertilization.build_plant_text", return_value="plant text")
@patch("rag.services.fertilization.build_rag_context", return_value="") @patch("rag.services.fertilization.build_rag_context", return_value="")
@patch("rag.services.fertilization._get_optimizer") @patch("rag.services.fertilization._get_optimizer")
+3
View File
@@ -9,8 +9,11 @@ mysqlclient>=2.2,<2.3
# === Server === # === Server ===
gunicorn>=22,<23 gunicorn>=22,<23
whitenoise>=6.7,<6.8
# === API Docs === # === API Docs ===
drf-spectacular>=0.27,<0.28
drf-spectacular-sidecar>=2024.5,<2025.0
# === Config === # === Config ===
python-dotenv>=1.0,<1.1 python-dotenv>=1.0,<1.1
+27
View File
@@ -8,6 +8,7 @@ from django.test import TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rag.failure_contract import RAGServiceError
from soile.services import SoilMoistureHeatmapService from soile.services import SoilMoistureHeatmapService
@@ -128,6 +129,32 @@ class SoilAnomalyDetectionApiTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["msg"], "Farm not found.") self.assertEqual(response.json()["msg"], "Farm not found.")
@patch("soile.views.apps.get_app_config")
def test_anomaly_api_returns_structured_failure_for_invalid_llm_json(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_anomaly_detection=lambda **_kwargs: (_ for _ in ()).throw(
RAGServiceError(
error_code="invalid_json",
message="Soil anomaly LLM response was not valid JSON.",
source="llm",
retriable=True,
http_status=502,
)
)
)
mock_get_app_config.return_value = SimpleNamespace(
get_soil_anomaly_service=lambda: mock_service
)
response = self.client.post(
"/anomaly-detection/",
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
format="json",
)
self.assertEqual(response.status_code, 502)
self.assertEqual(response.json()["data"]["error_code"], "invalid_json")
class SoilMoistureHeatmapServiceTests(TestCase): class SoilMoistureHeatmapServiceTests(TestCase):
@patch("soile.services.SensorData.objects") @patch("soile.services.SensorData.objects")
+6
View File
@@ -6,6 +6,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from config.openapi import build_envelope_serializer, build_response from config.openapi import build_envelope_serializer, build_response
from rag.failure_contract import RAGServiceError
from .serializers import ( from .serializers import (
SoilAnomalyDetectionRequestSerializer, SoilAnomalyDetectionRequestSerializer,
@@ -179,6 +180,11 @@ class SoilAnomalyDetectionView(APIView):
{"code": 404, "msg": str(exc), "data": None}, {"code": 404, "msg": str(exc), "data": None},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
except RAGServiceError as exc:
return Response(
{"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()},
status=exc.http_status,
)
except Exception as exc: except Exception as exc:
return Response( return Response(
{"code": 500, "msg": f"خطا در تحلیل ناهنجاری خاک: {exc}", "data": None}, {"code": 500, "msg": f"خطا در تحلیل ناهنجاری خاک: {exc}", "data": None},
+3 -1
View File
@@ -265,7 +265,7 @@ class MockWeatherAdapter(BaseWeatherAdapter):
def get_weather_adapter() -> BaseWeatherAdapter: def get_weather_adapter() -> BaseWeatherAdapter:
from django.conf import settings from django.conf import settings
provider = getattr(settings, "WEATHER_DATA_PROVIDER", "mock") provider = getattr(settings, "WEATHER_DATA_PROVIDER", "open-meteo")
if provider == "open-meteo": if provider == "open-meteo":
return OpenMeteoWeatherAdapter( return OpenMeteoWeatherAdapter(
base_url=settings.WEATHER_API_BASE_URL, base_url=settings.WEATHER_API_BASE_URL,
@@ -273,6 +273,8 @@ def get_weather_adapter() -> BaseWeatherAdapter:
timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60), timeout=getattr(settings, "WEATHER_TIMEOUT_SECONDS", 60),
) )
if provider == "mock": if provider == "mock":
if not (getattr(settings, "DEBUG", False) or getattr(settings, "DEVELOP", False)):
raise RuntimeError("Mock weather provider is disabled outside dev/test environments.")
return MockWeatherAdapter( return MockWeatherAdapter(
delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8) delay_seconds=getattr(settings, "WEATHER_MOCK_DELAY_SECONDS", 0.8)
) )
+28
View File
@@ -6,6 +6,8 @@ from unittest.mock import patch
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rag.failure_contract import RAGServiceError
@override_settings(ROOT_URLCONF="weather.urls") @override_settings(ROOT_URLCONF="weather.urls")
class FarmWeatherApiTests(TestCase): class FarmWeatherApiTests(TestCase):
@@ -122,3 +124,29 @@ class WaterNeedPredictionApiTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertEqual(response.json()["msg"], "Farm not found.") self.assertEqual(response.json()["msg"], "Farm not found.")
@patch("weather.views.apps.get_app_config")
def test_water_need_api_returns_structured_failure_for_invalid_llm_json(self, mock_get_app_config):
mock_service = SimpleNamespace(
get_water_need_prediction=lambda **_kwargs: (_ for _ in ()).throw(
RAGServiceError(
error_code="invalid_json",
message="Water need prediction LLM response was not valid JSON.",
source="llm",
retriable=True,
http_status=502,
)
)
)
mock_get_app_config.return_value = SimpleNamespace(
get_water_need_service=lambda: mock_service
)
response = self.client.post(
"/water-need-prediction/",
data={"farm_uuid": "550e8400-e29b-41d4-a716-446655440000"},
format="json",
)
self.assertEqual(response.status_code, 502)
self.assertEqual(response.json()["data"]["error_code"], "invalid_json")
+6
View File
@@ -6,6 +6,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from config.openapi import build_envelope_serializer, build_response from config.openapi import build_envelope_serializer, build_response
from rag.failure_contract import RAGServiceError
from .serializers import ( from .serializers import (
FarmWeatherRequestSerializer, FarmWeatherRequestSerializer,
@@ -133,6 +134,11 @@ class WaterNeedPredictionView(APIView):
{"code": 404, "msg": str(exc), "data": None}, {"code": 404, "msg": str(exc), "data": None},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
except RAGServiceError as exc:
return Response(
{"code": exc.http_status, "msg": exc.contract.message, "data": exc.to_dict()},
status=exc.http_status,
)
except Exception as exc: except Exception as exc:
return Response( return Response(
{"code": 500, "msg": f"خطا در تحلیل نیاز آبی مزرعه: {exc}", "data": None}, {"code": 500, "msg": f"خطا در تحلیل نیاز آبی مزرعه: {exc}", "data": None},