UPDATE
This commit is contained in:
@@ -0,0 +1,527 @@
|
||||
# توضیح خیلی ساده منطق RAG در پروژه
|
||||
|
||||
این فایل قرار است خیلی ساده بگوید RAG در این پروژه چطور کار میکند.
|
||||
|
||||
## اول: RAG یعنی چه؟
|
||||
|
||||
RAG یعنی:
|
||||
|
||||
1. سوال کاربر را میگیریم
|
||||
2. متنهای مرتبط را از حافظه دانشی پیدا میکنیم
|
||||
3. آن متنها را کنار سوال میگذاریم
|
||||
4. بعد از مدل زبانی میخواهیم جواب بدهد
|
||||
|
||||
یعنی مدل فقط از حافظه خودش جواب نمیدهد؛
|
||||
قبل از جواب دادن، اطلاعات مرتبط پروژه را هم میبیند.
|
||||
|
||||
---
|
||||
|
||||
## نقش فایل `rag/apps.py`
|
||||
|
||||
فایل `rag/apps.py` فقط اپ Django مربوط به RAG را ثبت میکند.
|
||||
|
||||
کار اصلیاش این است:
|
||||
|
||||
- اسم اپ را مشخص میکند: `rag`
|
||||
- نام نمایشی اپ را مشخص میکند
|
||||
|
||||
پس:
|
||||
|
||||
- `rag/apps.py` منطق اصلی RAG را پیادهسازی نمیکند
|
||||
- فقط میگوید این اپ در پروژه وجود دارد
|
||||
|
||||
منطق اصلی RAG بیشتر در این فایلهاست:
|
||||
|
||||
- `rag/views.py`
|
||||
- `rag/chat.py`
|
||||
- `rag/retrieve.py`
|
||||
- `rag/ingest.py`
|
||||
- `rag/embedding.py`
|
||||
- `rag/vector_store.py`
|
||||
- `rag/user_data.py`
|
||||
- `rag/config.py`
|
||||
|
||||
---
|
||||
|
||||
## تصویر خیلی ساده از کل جریان
|
||||
|
||||
RAG در این پروژه دو بخش اصلی دارد:
|
||||
|
||||
### 1) آمادهسازی دانش
|
||||
|
||||
در این بخش سیستم اطلاعات را جمع میکند و داخل دیتابیس برداری ذخیره میکند.
|
||||
|
||||
مراحل:
|
||||
|
||||
1. فایلهای دانش را میخواند
|
||||
2. متنها را خرد میکند
|
||||
3. هر تکه را تبدیل به embedding میکند
|
||||
4. embeddingها را داخل Qdrant ذخیره میکند
|
||||
|
||||
این کار بیشتر در `rag/ingest.py` انجام میشود.
|
||||
|
||||
### 2) جواب دادن به سوال کاربر
|
||||
|
||||
در این بخش وقتی کاربر سوال میپرسد:
|
||||
|
||||
1. سوال embedding میشود
|
||||
2. متنهای نزدیک و مرتبط پیدا میشوند
|
||||
3. دادههای کاربر هم اضافه میشود
|
||||
4. همه اینها به مدل زبانی داده میشود
|
||||
5. مدل جواب را به صورت stream برمیگرداند
|
||||
|
||||
این کار بیشتر در `rag/chat.py` و `rag/retrieve.py` انجام میشود.
|
||||
|
||||
---
|
||||
|
||||
## بخش اول: سیستم چطور دانش را آماده میکند؟
|
||||
|
||||
### فایل اصلی: `rag/ingest.py`
|
||||
|
||||
این فایل کارش این است که اطلاعات را وارد سیستم RAG کند.
|
||||
|
||||
### از کجا اطلاعات میآید؟
|
||||
|
||||
سیستم این منابع را میخواند:
|
||||
|
||||
- فایلهای پایگاه دانش
|
||||
- فایل لحن یا tone
|
||||
- دادههای خاک هر کاربر
|
||||
- دادههای هواشناسی هر کاربر
|
||||
|
||||
### پایگاه دانش یعنی چه؟
|
||||
|
||||
پایگاه دانش یعنی متنهایی که پروژه از قبل دارد.
|
||||
|
||||
مثلا:
|
||||
|
||||
- اطلاعات عمومی چت
|
||||
- اطلاعات آبیاری
|
||||
- اطلاعات کودهی
|
||||
|
||||
در تنظیمات، برای هر بخش یک knowledge base تعریف شده است.
|
||||
|
||||
---
|
||||
|
||||
## مرحله 1: خواندن منابع
|
||||
|
||||
تابع `load_sources()` در `rag/ingest.py` منابع را جمع میکند.
|
||||
|
||||
خروجی این تابع تقریبا این شکلی است:
|
||||
|
||||
- شناسه منبع
|
||||
- متن منبع
|
||||
- شناسه سنسور یا کاربر
|
||||
- نام پایگاه دانش
|
||||
|
||||
نکته مهم:
|
||||
|
||||
- دادههای عمومی با `__global__` ذخیره میشوند
|
||||
- دادههای شخصی هر کاربر با `sensor_uuid` خودش ذخیره میشوند
|
||||
- دادههای کاربری معمولا با `__all__` در `kb_name` علامت میخورند
|
||||
|
||||
این کار باعث میشود بعدا سیستم بداند هر متن برای چه کسی یا چه بخشی بوده است.
|
||||
|
||||
---
|
||||
|
||||
## مرحله 2: خرد کردن متن
|
||||
|
||||
### فایل: `rag/chunker.py`
|
||||
|
||||
متنهای طولانی مستقیم وارد جستجو نمیشوند.
|
||||
اول آنها را به تکههای کوچکتر تبدیل میکنیم.
|
||||
|
||||
چرا؟
|
||||
|
||||
چون:
|
||||
|
||||
- جستجو دقیقتر میشود
|
||||
- embedding بهتر میشود
|
||||
- مدل فقط بخشهای لازم را میبیند
|
||||
|
||||
مثلا یک فایل بلند به چند chunk تبدیل میشود.
|
||||
|
||||
---
|
||||
|
||||
## مرحله 3: ساخت embedding
|
||||
|
||||
### فایل: `rag/embedding.py`
|
||||
|
||||
هر chunk متنی به یک لیست عددی تبدیل میشود.
|
||||
به این لیست عددی میگوییم embedding.
|
||||
|
||||
خیلی ساده:
|
||||
|
||||
- متن شبیه به هم -> embedding شبیه به هم
|
||||
- متن متفاوت -> embedding متفاوت
|
||||
|
||||
پس بعدا اگر کاربر سوالی شبیه یک متن بپرسد،
|
||||
سیستم میتواند آن متن را پیدا کند.
|
||||
|
||||
---
|
||||
|
||||
## مرحله 4: ذخیره در Qdrant
|
||||
|
||||
### فایل: `rag/vector_store.py`
|
||||
|
||||
بعد از ساخت embedding، دادهها داخل Qdrant ذخیره میشوند.
|
||||
|
||||
Qdrant در این پروژه نقش حافظه برداری را دارد.
|
||||
|
||||
برای هر chunk این چیزها ذخیره میشود:
|
||||
|
||||
- خود متن
|
||||
- embedding
|
||||
- منبع متن
|
||||
- شماره chunk
|
||||
- `sensor_uuid`
|
||||
- `kb_name`
|
||||
|
||||
این metadata خیلی مهم است؛
|
||||
چون کمک میکند بعدا فقط دادههای مرتبط برگردند.
|
||||
|
||||
---
|
||||
|
||||
## دستور ورود اطلاعات
|
||||
|
||||
### فایل: `rag/management/commands/rag_ingest.py`
|
||||
|
||||
این فایل یک command جنگو دارد که ingestion را اجرا میکند.
|
||||
|
||||
یعنی اگر این دستور اجرا شود:
|
||||
|
||||
```bash
|
||||
python manage.py rag_ingest
|
||||
```
|
||||
|
||||
سیستم:
|
||||
|
||||
- منابع را میخواند
|
||||
- chunk میکند
|
||||
- embedding میسازد
|
||||
- داخل Qdrant ذخیره میکند
|
||||
|
||||
---
|
||||
|
||||
## بخش دوم: وقتی کاربر سوال میپرسد چه میشود؟
|
||||
|
||||
### ورودی API
|
||||
|
||||
### فایل: `rag/views.py`
|
||||
|
||||
در این فایل endpoint چت وجود دارد.
|
||||
|
||||
کارش این است که از کاربر این اطلاعات را بگیرد:
|
||||
|
||||
- `service_id`
|
||||
- `query`
|
||||
- `user_id` یا `sensor_uuid`
|
||||
|
||||
بعد چند بررسی انجام میشود:
|
||||
|
||||
- آیا سوال خالی نیست؟
|
||||
- آیا `service_id` معتبر است؟
|
||||
- اگر سرویس نیاز به داده کاربر دارد، آیا `user_id` داده شده؟
|
||||
|
||||
اگر همه چیز درست باشد،
|
||||
در نهایت `chat_rag_stream()` صدا زده میشود.
|
||||
|
||||
---
|
||||
|
||||
## `service_id` چرا مهم است؟
|
||||
|
||||
چون سیستم چند نوع سرویس دارد.
|
||||
|
||||
مثلا:
|
||||
|
||||
- سرویس چت عمومی
|
||||
- سرویس آبیاری
|
||||
- سرویس کودهی
|
||||
|
||||
هر سرویس میتواند اینها را مشخص کند:
|
||||
|
||||
- از کدام knowledge base استفاده شود
|
||||
- از چه مدل زبانی استفاده شود
|
||||
- آیا دادههای شخصی کاربر لازم است یا نه
|
||||
- چه tone یا system prompt استفاده شود
|
||||
|
||||
این تنظیمات در `rag/config.py` و فایل `config/rag_config.yaml` مدیریت میشوند.
|
||||
|
||||
---
|
||||
|
||||
## ساخت context
|
||||
|
||||
### فایل اصلی: `rag/chat.py`
|
||||
|
||||
مهمترین بخش پاسخگویی همینجاست.
|
||||
|
||||
تابع مهم: `build_rag_context()`
|
||||
|
||||
این تابع یک context برای مدل میسازد.
|
||||
|
||||
این context از چند بخش ساخته میشود:
|
||||
|
||||
1. داده فعلی خاک کاربر
|
||||
2. داده هواشناسی کاربر
|
||||
3. متنهای مرتبط پیدا شده از RAG
|
||||
|
||||
یعنی مدل فقط سوال را نمیبیند؛
|
||||
بلکه این اطلاعات کمکی را هم میبیند.
|
||||
|
||||
---
|
||||
|
||||
## داده خاک و هواشناسی کاربر از کجا میآید؟
|
||||
|
||||
### فایل: `rag/user_data.py`
|
||||
|
||||
این فایل اطلاعات کاربر را از دیتابیس پروژه میسازد.
|
||||
|
||||
دو تابع مهم:
|
||||
|
||||
- `build_user_soil_text(sensor_uuid)`
|
||||
- `build_user_weather_text(sensor_uuid)`
|
||||
|
||||
کار این توابع:
|
||||
|
||||
- دادههای مدلهای پروژه را میخوانند
|
||||
- آنها را به متن ساده تبدیل میکنند
|
||||
|
||||
چرا به متن؟
|
||||
|
||||
چون سیستم RAG در نهایت با متن کار میکند.
|
||||
|
||||
پس حتی دادههای دیتابیس هم به متن تبدیل میشوند تا:
|
||||
|
||||
- embed شوند
|
||||
- یا مستقیم داخل context قرار بگیرند
|
||||
|
||||
---
|
||||
|
||||
## پیدا کردن متنهای مرتبط
|
||||
|
||||
### فایل: `rag/retrieve.py`
|
||||
|
||||
در اینجا تابع `search_with_query()` کار اصلی بازیابی را انجام میدهد.
|
||||
|
||||
مراحلش ساده است:
|
||||
|
||||
1. سوال کاربر embedding میشود
|
||||
2. یک جستجوی شباهت در Qdrant انجام میشود
|
||||
3. فقط متنهای مجاز برگردانده میشوند
|
||||
|
||||
---
|
||||
|
||||
## چرا گفتیم "متنهای مجاز"؟
|
||||
|
||||
چون این پروژه داده کاربر دارد و نباید اطلاعات یک کاربر به کاربر دیگر برسد.
|
||||
|
||||
برای همین موقع جستجو فیلتر گذاشته میشود.
|
||||
|
||||
فیلترها معمولا اینها هستند:
|
||||
|
||||
- `sensor_uuid`
|
||||
- `kb_name`
|
||||
|
||||
یعنی سیستم فقط اینها را برمیگرداند:
|
||||
|
||||
- دادههای عمومی (`__global__`)
|
||||
- دادههای همان کاربر
|
||||
- دادههای همان knowledge base
|
||||
|
||||
پس این بخش برای امنیت و جداسازی اطلاعات خیلی مهم است.
|
||||
|
||||
---
|
||||
|
||||
## جستجو در Qdrant چطور انجام میشود؟
|
||||
|
||||
### فایل: `rag/vector_store.py`
|
||||
|
||||
تابع `search()` در این فایل:
|
||||
|
||||
- query vector را میگیرد
|
||||
- فیلترها را میسازد
|
||||
- از Qdrant نتیجه میگیرد
|
||||
|
||||
بعد نتیجهها را به شکل ساده برمیگرداند:
|
||||
|
||||
- `id`
|
||||
- `score`
|
||||
- `text`
|
||||
- `metadata`
|
||||
|
||||
`score` یعنی میزان شباهت.
|
||||
هرچه بیشتر باشد، یعنی متن به سوال نزدیکتر است.
|
||||
|
||||
---
|
||||
|
||||
## بعد از بازیابی چه میشود؟
|
||||
|
||||
### دوباره در `rag/chat.py`
|
||||
|
||||
بعد از این که متنهای مرتبط پیدا شدند:
|
||||
|
||||
- متنهای مرجع جمع میشوند
|
||||
- داده کاربر هم کنار آنها قرار میگیرد
|
||||
- tone و system prompt هم اضافه میشود
|
||||
|
||||
در آخر یک پیام system ساخته میشود که به مدل میگوید:
|
||||
|
||||
- از دادههای خاک استفاده کن
|
||||
- از متنهای مرجع استفاده کن
|
||||
- با زبان کاربر جواب بده
|
||||
|
||||
---
|
||||
|
||||
## تولید جواب نهایی
|
||||
|
||||
### تابع: `chat_rag_stream()`
|
||||
|
||||
این تابع:
|
||||
|
||||
1. تنظیمات سرویس را میخواند
|
||||
2. context را میسازد
|
||||
3. پیام system و user را آماده میکند
|
||||
4. به مدل زبانی درخواست میفرستد
|
||||
5. جواب را به صورت stream برمیگرداند
|
||||
|
||||
پس جواب نهایی فقط از خود مدل نیست؛
|
||||
بلکه از ترکیب اینها ساخته میشود:
|
||||
|
||||
- سوال کاربر
|
||||
- دادههای فعلی کاربر
|
||||
- متنهای مرجع RAG
|
||||
- لحن و دستور سیستم
|
||||
|
||||
---
|
||||
|
||||
## tone چیست؟
|
||||
|
||||
tone یعنی لحن پاسخ.
|
||||
|
||||
مثلا سیستم میتواند مشخص کند:
|
||||
|
||||
- رسمی جواب بده
|
||||
- ساده جواب بده
|
||||
- تخصصی جواب بده
|
||||
|
||||
فایلهای tone از روی knowledge base یا service خوانده میشوند.
|
||||
|
||||
پس tone روی سبک جواب اثر دارد،
|
||||
نه روی اصل جستجو.
|
||||
|
||||
---
|
||||
|
||||
## نقش `rag/config.py`
|
||||
|
||||
این فایل تنظیمات را بارگذاری میکند.
|
||||
|
||||
مثلا:
|
||||
|
||||
- مدل embedding چیست
|
||||
- Qdrant کجاست
|
||||
- اندازه vector چقدر است
|
||||
- chunking چگونه باشد
|
||||
- سرویسها چه هستند
|
||||
- هر سرویس از کدام knowledge base استفاده کند
|
||||
|
||||
یعنی این فایل مغز تنظیمات سیستم است.
|
||||
|
||||
---
|
||||
|
||||
## خلاصه خیلی ساده کل مسیر
|
||||
|
||||
اگر بخواهیم خیلی خلاصه بگوییم:
|
||||
|
||||
### مرحله آمادهسازی
|
||||
|
||||
1. فایلها و دادههای کاربر خوانده میشوند
|
||||
2. متنها chunk میشوند
|
||||
3. embedding ساخته میشود
|
||||
4. داخل Qdrant ذخیره میشوند
|
||||
|
||||
### مرحله پاسخگویی
|
||||
|
||||
1. کاربر سوال میپرسد
|
||||
2. سوال embedding میشود
|
||||
3. متنهای مشابه پیدا میشوند
|
||||
4. داده خاک و هواشناسی کاربر هم اضافه میشود
|
||||
5. همه اینها به LLM داده میشود
|
||||
6. LLM جواب نهایی را میسازد
|
||||
|
||||
---
|
||||
|
||||
## فرق این پروژه با یک چت ساده
|
||||
|
||||
اگر چت ساده بود:
|
||||
|
||||
- مدل فقط با دانستههای خودش جواب میداد
|
||||
|
||||
ولی اینجا:
|
||||
|
||||
- مدل به دادههای واقعی پروژه دسترسی دارد
|
||||
- دادههای همان کاربر را میبیند
|
||||
- از متنهای مرجع واقعی استفاده میکند
|
||||
|
||||
پس جوابها:
|
||||
|
||||
- دقیقتر میشوند
|
||||
- شخصیتر میشوند
|
||||
- به دادههای واقعی نزدیکتر میشوند
|
||||
|
||||
---
|
||||
|
||||
## فایلها را خیلی ساده به خاطر بسپار
|
||||
|
||||
- `rag/apps.py` -> فقط ثبت اپ
|
||||
- `rag/views.py` -> گرفتن درخواست کاربر
|
||||
- `rag/chat.py` -> ساخت context و گرفتن جواب از مدل
|
||||
- `rag/retrieve.py` -> جستجوی متن مرتبط
|
||||
- `rag/ingest.py` -> وارد کردن دانش به سیستم
|
||||
- `rag/embedding.py` -> تبدیل متن به embedding
|
||||
- `rag/vector_store.py` -> ذخیره و جستجو در Qdrant
|
||||
- `rag/user_data.py` -> ساخت متن از دادههای کاربر
|
||||
- `rag/config.py` -> تنظیمات کل RAG
|
||||
|
||||
---
|
||||
|
||||
## یک مثال خیلی ساده
|
||||
|
||||
فرض کن کاربر بپرسد:
|
||||
|
||||
`آیا خاک من برای آبیاری مناسب است؟`
|
||||
|
||||
سیستم این کارها را میکند:
|
||||
|
||||
1. سوال را میگیرد
|
||||
2. میفهمد باید از سرویس یا دانش آبیاری استفاده کند
|
||||
3. داده خاک همان کاربر را از دیتابیس میگیرد
|
||||
4. داده هواشناسی را هم میگیرد
|
||||
5. متنهای مرتبط آبیاری را از Qdrant پیدا میکند
|
||||
6. همه را به مدل میدهد
|
||||
7. مدل جواب میدهد
|
||||
|
||||
پس جواب نهایی فقط یک حدس عمومی نیست؛
|
||||
بلکه بر اساس:
|
||||
|
||||
- اطلاعات خاک
|
||||
- اطلاعات هوا
|
||||
- متنهای مرجع آبیاری
|
||||
|
||||
ساخته میشود.
|
||||
|
||||
---
|
||||
|
||||
## نتیجه نهایی
|
||||
|
||||
منطق RAG این پروژه به زبان خیلی ساده این است:
|
||||
|
||||
- اول دانش را آماده میکند
|
||||
- بعد موقع سوال، دانش مرتبط را پیدا میکند
|
||||
- دادههای واقعی کاربر را هم اضافه میکند
|
||||
- و در آخر از مدل میخواهد با این اطلاعات جواب بدهد
|
||||
|
||||
و یادت باشد:
|
||||
|
||||
- `rag/apps.py` فقط فایل ثبت اپ است
|
||||
- منطق واقعی RAG در فایلهای `chat`, `retrieve`, `ingest`, `vector_store`, `user_data` و `views` قرار دارد
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
# مستند سیستم RAG — پایگاه دانش CropLogic
|
||||
|
||||
## فهرست
|
||||
|
||||
1. [معرفی کلی](#معرفی-کلی)
|
||||
2. [معماری و ساختار](#معماری-و-ساختار)
|
||||
3. [منابع داده](#منابع-داده)
|
||||
4. [پایپلاین Embedding](#پایپلاین-embedding)
|
||||
5. [نحوه اجرا](#نحوه-اجرا)
|
||||
6. [فلوی پیام کاربر](#فلوی-پیام-کاربر)
|
||||
7. [API Endpoint](#api-endpoint)
|
||||
8. [تنظیمات](#تنظیمات)
|
||||
9. [ایزولهسازی کاربران](#ایزولهسازی-کاربران)
|
||||
10. [سرویسهای توصیه](#سرویسهای-توصیه)
|
||||
|
||||
---
|
||||
|
||||
## معرفی کلی
|
||||
|
||||
سیستم RAG در CropLogic یک چت هوشمند کشاورزی است که:
|
||||
|
||||
- **دانش پایه کشاورزی** را embed و ذخیره میکند
|
||||
- **دادههای خاک و هواشناسی هر کاربر** را از DB میخواند و embed میکند
|
||||
- وقتی کاربر سوال میپرسد، **اطلاعات مرتبط** را بازیابی و به **LLM** ارسال میکند
|
||||
|
||||
**Vector Store:** Qdrant
|
||||
**API Provider:** GapGPT (با fallback به Avalai) — Adapter Pattern
|
||||
|
||||
### پایگاههای دانش مجزا
|
||||
|
||||
سیستم از **سه پایگاه دانش** مجزا استفاده میکند:
|
||||
|
||||
| KB | توضیح | فایل Tone |
|
||||
|----|-------|-----------|
|
||||
| `chat` | چت عمومی و پاسخ به سوالات متنوع | `config/tones/chat_tone.txt` |
|
||||
| `irrigation` | توصیههای آبیاری (فرمت JSON) | `config/tones/irrigation_tone.txt` |
|
||||
| `fertilization` | توصیههای کودهی (فرمت JSON) | `config/tones/fertilization_tone.txt` |
|
||||
|
||||
تشخیص هوشمند KB از روی کلمات کلیدی سوال (آبیاری، آب، کود، NPK).
|
||||
|
||||
---
|
||||
|
||||
## معماری و ساختار
|
||||
|
||||
```
|
||||
rag/
|
||||
├── config.py # بارگذاری تنظیمات از rag_config.yaml
|
||||
├── api_provider.py # Adapter Pattern برای GapGPT/Avalai
|
||||
├── client.py # ساخت کلاینت Qdrant
|
||||
├── chunker.py # تکهتکه کردن متن
|
||||
├── embedding.py # تعبیهسازی متن
|
||||
├── vector_store.py # ذخیره و جستجو در Qdrant (با فیلتر kb_name)
|
||||
├── user_data.py # خواندن دادههای خاک/سنسور/هواشناسی از DB
|
||||
├── ingest.py # پایپلاین: خواندن → چانک → embed → ذخیره
|
||||
├── retrieve.py # بازیابی: embed کوئری → جستجو
|
||||
├── chat.py # ساخت context و چت استریمی با LLM
|
||||
├── views.py # API endpoint
|
||||
├── urls.py # مسیریابی
|
||||
├── tasks.py # تسک Celery
|
||||
├── services/ # سرویسهای توصیه (بدون API)
|
||||
│ ├── irrigation.py # توصیه آبیاری
|
||||
│ └── fertilization.py # توصیه کودهی
|
||||
└── management/commands/
|
||||
└── rag_ingest.py
|
||||
```
|
||||
|
||||
فایلهای تنظیمات:
|
||||
|
||||
```
|
||||
config/
|
||||
├── rag_config.yaml
|
||||
├── tones/
|
||||
│ ├── chat_tone.txt
|
||||
│ ├── irrigation_tone.txt
|
||||
│ └── fertilization_tone.txt
|
||||
└── knowledge_base/
|
||||
├── chat/
|
||||
├── irrigation/
|
||||
└── fertilization/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## منابع داده
|
||||
|
||||
سیستم از **چهار منبع** داده تغذیه میشود:
|
||||
|
||||
### 1. لحنهای مجزا — `config/tones/`
|
||||
|
||||
هر KB یک فایل لحن مخصوص دارد که سبک خروجی LLM را تعریف میکند.
|
||||
|
||||
ذخیره با: `sensor_uuid = __global__`, `kb_name = chat|irrigation|fertilization`
|
||||
|
||||
### 2. پایگاههای دانش — `config/knowledge_base/`
|
||||
|
||||
- `chat/`: دانش عمومی کشاورزی
|
||||
- `irrigation/`: دانش تخصصی آبیاری (ET0، بارش، رطوبت)
|
||||
- `fertilization/`: دانش تخصصی کودهی (NPK، pH، نوع خاک)
|
||||
|
||||
ذخیره با: `sensor_uuid = __global__`, `kb_name = chat|irrigation|fertilization`
|
||||
|
||||
### 3. دادههای خاک کاربر — از DB
|
||||
|
||||
برای هر سنسور:
|
||||
- `SensorData`: رطوبت، دما، pH، EC، NPK
|
||||
- `SoilLocation`: مختصات جغرافیایی
|
||||
- `SoilDepthData`: دادههای خاک در سه عمق
|
||||
|
||||
تابع `build_user_soil_text()` این دادهها را به متن فارسی تبدیل میکند.
|
||||
|
||||
ذخیره با: `sensor_uuid = {uuid واقعی}`, `kb_name = __all__`
|
||||
|
||||
### 4. دادههای هواشناسی کاربر — از DB
|
||||
|
||||
- `WeatherForecast`: پیشبینی ۷ روز آینده (دما، بارش، رطوبت، باد، ET0)
|
||||
|
||||
تابع `build_user_weather_text()` این دادهها را به متن فارسی تبدیل میکند.
|
||||
|
||||
ذخیره با: `sensor_uuid = {uuid واقعی}`, `kb_name = __all__`
|
||||
|
||||
---
|
||||
|
||||
## پایپلاین Embedding
|
||||
|
||||
```
|
||||
منابع → load_sources() → chunk_text() → embed_texts() → Qdrant
|
||||
```
|
||||
|
||||
1. **بارگذاری منابع** (`ingest.py:load_sources`):
|
||||
- لحنها از `config/tones/`
|
||||
- KBها از `config/knowledge_base/`
|
||||
- دادههای کاربران از DB (`user_data.py`)
|
||||
|
||||
2. **چانک کردن** (`chunker.py`):
|
||||
- حداکثر ۵۰۰ توکن هر چانک
|
||||
- ۵۰ توکن همپوشانی
|
||||
|
||||
3. **Embedding** (`embedding.py`):
|
||||
- استفاده از `api_provider.get_embedding_client()`
|
||||
- مدل: `text-embedding-3-small`
|
||||
- بچسایز: ۳۲
|
||||
|
||||
4. **ذخیره در Qdrant** (`vector_store.py`):
|
||||
- هر point: `{id, vector[1536], payload{text, source, sensor_uuid, kb_name, chunk_index}}`
|
||||
|
||||
---
|
||||
|
||||
## نحوه اجرا
|
||||
|
||||
### دستی
|
||||
|
||||
```bash
|
||||
python manage.py rag_ingest --recreate
|
||||
```
|
||||
|
||||
### دورهای (Celery Beat)
|
||||
|
||||
تسک `rag_ingest_task` هر ۶ ساعت اجرا میشود و دادههای جدید را embed میکند.
|
||||
|
||||
---
|
||||
|
||||
## فلوی پیام کاربر
|
||||
|
||||
```
|
||||
POST /api/rag/chat/ {message, sensor_uuid}
|
||||
↓
|
||||
1. تشخیص KB از روی کلمات کلیدی (_detect_kb_intent)
|
||||
↓
|
||||
2. بارگذاری دادههای فعلی کاربر از DB:
|
||||
- build_user_soil_text(sensor_uuid)
|
||||
- build_user_weather_text(sensor_uuid)
|
||||
↓
|
||||
3. Embed کردن سوال (embed_single)
|
||||
↓
|
||||
4. جستجو در Qdrant با فیلتر:
|
||||
- sensor_uuid = {uuid کاربر} OR __global__
|
||||
- kb_name = {detected_kb} OR __all__
|
||||
↓
|
||||
5. ساخت context:
|
||||
[دادههای فعلی خاک] + [پیشبینی هواشناسی] + [متنهای مرجع از RAG]
|
||||
↓
|
||||
6. ارسال به LLM (GapGPT):
|
||||
system_prompt = tone + دستورالعمل + context
|
||||
↓
|
||||
7. StreamingHttpResponse → کاربر
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoint
|
||||
|
||||
### POST `/api/rag/chat/`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"message": "وضعیت خاک من چطوره؟",
|
||||
"sensor_uuid": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Stream متنی (text/plain)
|
||||
|
||||
---
|
||||
|
||||
## تنظیمات
|
||||
|
||||
### `config/rag_config.yaml`
|
||||
|
||||
```yaml
|
||||
embedding:
|
||||
provider: "gapgpt" # gapgpt یا avalai
|
||||
model: "text-embedding-3-small"
|
||||
base_url: "https://api.gapgpt.app/v1"
|
||||
api_key_env: "GAPGPT_API_KEY"
|
||||
avalai_base_url: "https://api.avalai.ir/v1"
|
||||
avalai_api_key_env: "AVALAI_API_KEY"
|
||||
|
||||
qdrant:
|
||||
host: "localhost"
|
||||
port: 6333
|
||||
collection_name: "croplogic_kb"
|
||||
vector_size: 1536
|
||||
|
||||
chunking:
|
||||
max_chunk_tokens: 500
|
||||
overlap_tokens: 50
|
||||
|
||||
llm:
|
||||
model: "gpt-4o"
|
||||
base_url: "https://api.gapgpt.app/v1"
|
||||
api_key_env: "GAPGPT_API_KEY"
|
||||
avalai_base_url: "https://api.avalai.ir/v1"
|
||||
avalai_api_key_env: "AVALAI_API_KEY"
|
||||
|
||||
knowledge_bases:
|
||||
chat:
|
||||
path: "config/knowledge_base/chat"
|
||||
tone_file: "config/tones/chat_tone.txt"
|
||||
irrigation:
|
||||
path: "config/knowledge_base/irrigation"
|
||||
tone_file: "config/tones/irrigation_tone.txt"
|
||||
fertilization:
|
||||
path: "config/knowledge_base/fertilization"
|
||||
tone_file: "config/tones/fertilization_tone.txt"
|
||||
```
|
||||
|
||||
### متغیرهای محیطی
|
||||
|
||||
| متغیر | توضیح |
|
||||
|-------|-------|
|
||||
| `GAPGPT_API_KEY` | کلید API برای GapGPT |
|
||||
| `AVALAI_API_KEY` | کلید API برای Avalai (fallback) |
|
||||
| `QDRANT_HOST` | آدرس Qdrant |
|
||||
| `QDRANT_PORT` | پورت Qdrant |
|
||||
|
||||
---
|
||||
|
||||
## ایزولهسازی کاربران
|
||||
|
||||
- هر چانک یک فیلد `sensor_uuid` در metadata دارد
|
||||
- دادههای عمومی: `sensor_uuid = __global__`
|
||||
- دادههای کاربر: `sensor_uuid = {uuid واقعی}`
|
||||
- هنگام جستجو، فیلتر `should` اعمال میشود:
|
||||
- `sensor_uuid = {uuid کاربر}` OR `__global__`
|
||||
- `kb_name = {detected_kb}` OR `__all__`
|
||||
- نتیجه: هر کاربر فقط دادههای خودش + دانش عمومی را میبیند
|
||||
|
||||
---
|
||||
|
||||
## سرویسهای توصیه
|
||||
|
||||
سرویسهای آبیاری و کودهی **بدون API** هستند و از RAG استفاده میکنند.
|
||||
|
||||
### توصیه آبیاری
|
||||
|
||||
```python
|
||||
from rag.services import get_irrigation_recommendation
|
||||
|
||||
result = get_irrigation_recommendation(
|
||||
sensor_uuid="550e8400-...",
|
||||
query="توصیه آبیاری برای مزرعه من چیست؟" # اختیاری
|
||||
)
|
||||
```
|
||||
|
||||
**خروجی:**
|
||||
```python
|
||||
{
|
||||
"irrigation_needed": True,
|
||||
"amount_mm": 25.0,
|
||||
"reason": "رطوبت خاک پایین و بارش پیشبینی نشده",
|
||||
"next_check_date": "2026-03-20",
|
||||
"raw_response": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### توصیه کودهی
|
||||
|
||||
```python
|
||||
from rag.services import get_fertilization_recommendation
|
||||
|
||||
result = get_fertilization_recommendation(
|
||||
sensor_uuid="550e8400-...",
|
||||
query="توصیه کودهی برای مزرعه من چیست؟" # اختیاری
|
||||
)
|
||||
```
|
||||
|
||||
**خروجی:**
|
||||
```python
|
||||
{
|
||||
"fertilizer_needed": True,
|
||||
"fertilizer_type": "NPK 20-10-10",
|
||||
"amount_kg_per_hectare": 150.0,
|
||||
"reason": "سطح ازت پایین",
|
||||
"npk_status": {
|
||||
"nitrogen": "low",
|
||||
"phosphorus": "normal",
|
||||
"potassium": "normal"
|
||||
},
|
||||
"raw_response": "..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## نمودار معماری
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ منابع داده │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────────┐ ┌───────────────────┐ │
|
||||
│ │ tones/ │ │ knowledge_ │ │ Django DB │ │
|
||||
│ │ 3 files │ │ base/ │ │ SensorData │ │
|
||||
│ │ │ │ chat/irrig/ │ │ SoilLocation │ │
|
||||
│ │ │ │ fertiliz/ │ │ SoilDepthData │ │
|
||||
│ │ │ │ │ │ WeatherForecast │ │
|
||||
│ └────┬─────┘ └──────┬───────┘ └────────┬──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └───────────┬────┘ │ │
|
||||
│ __global__ sensor_uuid │
|
||||
│ kb_name=chat/ kb_name=__all__ │
|
||||
│ irrigation/ │
|
||||
│ fertilization │
|
||||
└───────────────┬────────────────────────┬────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ingest pipeline │
|
||||
│ │
|
||||
│ load_sources() → chunk_text() → embed_texts() │
|
||||
│ (با Adapter Pattern: GapGPT/Avalai) │
|
||||
│ │
|
||||
│ کامند: python manage.py rag_ingest --recreate │
|
||||
│ تسک: rag_ingest_task.delay(recreate=True) │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Qdrant │
|
||||
│ collection: croplogic_kb │
|
||||
│ │
|
||||
│ هر point = {id, vector[1536], payload{text, │
|
||||
│ source, sensor_uuid, kb_name, │
|
||||
│ chunk_index}} │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│
|
||||
(هنگام سوال کاربر)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ فلوی پاسخ به کاربر │
|
||||
│ │
|
||||
│ 1. POST /api/rag/chat/ {message, sensor_uuid} │
|
||||
│ 2. تشخیص KB از کلمات کلیدی (_detect_kb_intent) │
|
||||
│ 3. build_user_soil_text() + build_user_weather_text() │
|
||||
│ 4. embed_single(message) → query vector │
|
||||
│ 5. Qdrant search با فیلتر sensor_uuid + kb_name │
|
||||
│ 6. system_prompt = tone + دستورالعمل + context │
|
||||
│ 7. GapGPT LLM (gpt-4o) → streaming response │
|
||||
│ 8. StreamingHttpResponse → کاربر │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**تغییرات اخیر:**
|
||||
|
||||
- ✅ Adapter Pattern برای سوئیچ بین GapGPT و Avalai
|
||||
- ✅ سه پایگاه دانش مجزا (chat/irrigation/fertilization)
|
||||
- ✅ دادههای هواشناسی embed میشوند
|
||||
- ✅ فیلتر `kb_name` در جستجوی Qdrant
|
||||
- ✅ سرویسهای توصیه آبیاری و کودهی (بدون API)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
ماژول RAG — برای جلوگیری از AppRegistryNotReady این فایل import سنگین انجام نمیدهد.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Adapter Pattern برای API providers — سوئیچ بین GapGPT، Avalai و ArvanCloud AI.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError: # pragma: no cover - optional for stripped test envs
|
||||
class OpenAI: # type: ignore[override]
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise ImportError("openai package is required for RAG clients.")
|
||||
|
||||
from .config import RAGConfig, load_rag_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _mask_secret(value: str | None) -> str:
|
||||
if not value:
|
||||
return "<missing>"
|
||||
if len(value) <= 8:
|
||||
return "****"
|
||||
return f"{value[:4]}...{value[-4:]}"
|
||||
|
||||
|
||||
def _get_env_or_value(env_var: str | None, direct_value: str | None) -> str | None:
|
||||
if env_var:
|
||||
return os.environ.get(env_var) or direct_value
|
||||
return direct_value
|
||||
|
||||
|
||||
def get_embedding_client(config: RAGConfig | None = None) -> OpenAI:
|
||||
"""
|
||||
ساخت کلاینت OpenAI برای Embedding بر اساس provider فعال.
|
||||
provider از config.embedding.provider خوانده میشود.
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
emb = cfg.embedding
|
||||
provider = emb.provider or "gapgpt"
|
||||
logger.info("embedding provider=%s", provider)
|
||||
|
||||
if provider == "avalai":
|
||||
env_var = emb.avalai_api_key_env or emb.api_key_env or "AVALAI_API_KEY"
|
||||
api_key = _get_env_or_value(env_var, emb.avalai_api_key or emb.api_key)
|
||||
base_url = emb.avalai_base_url or emb.base_url or "https://api.avalai.ir/v1"
|
||||
elif provider == "arvancloud":
|
||||
env_var = emb.arvancloud_api_key_env or "ARVANCLOUD_EMBEDDING_API_KEY"
|
||||
api_key = _get_env_or_value(env_var, emb.arvancloud_api_key)
|
||||
base_url = (
|
||||
emb.arvancloud_base_url
|
||||
or "https://arvancloudai.ir/gateway/models/Bge-m3/rBA2PgcTC2sfhXwamupI4NvQ8crddUGTYXOsuKVye91PoNuGhbRgpHHNY8sMHBVQWWerZSAi4a0AijUL6YBqY9EW-Y1LhW_0ec6Mxr85GQy41lXiV6M8Od4mvLIeDF-wLRUHIervod0O5ZqGj2MOX8z1zdUpXkCrIS2uDjHlfHBZofledZjsOVDmFZU7IYfvkA__ljQqNeKXSFgpwUR7SmsbRUXGTDB2moLdeRq9zBpQIw/v1"
|
||||
)
|
||||
else:
|
||||
env_var = emb.api_key_env or "GAPGPT_API_KEY"
|
||||
api_key = _get_env_or_value(env_var, emb.api_key)
|
||||
base_url = emb.base_url or "https://api.gapgpt.app/v1"
|
||||
logger.info(
|
||||
"embedding base_url=%s api_key=%s",
|
||||
base_url,
|
||||
_mask_secret(api_key),
|
||||
)
|
||||
|
||||
return OpenAI(api_key=api_key, base_url=base_url)
|
||||
|
||||
|
||||
def get_chat_client(config: RAGConfig | None = None) -> OpenAI:
|
||||
"""
|
||||
ساخت کلاینت OpenAI برای Chat/LLM بر اساس provider فعال.
|
||||
provider از config.llm.provider خوانده میشود.
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
llm = cfg.llm
|
||||
provider = llm.provider or cfg.embedding.provider
|
||||
|
||||
|
||||
logger.info("chat provider=%s", provider)
|
||||
if provider == "avalai":
|
||||
env_var = llm.avalai_api_key_env or llm.api_key_env or "AVALAI_API_KEY"
|
||||
api_key = os.environ.get(env_var)
|
||||
base_url = llm.avalai_base_url or llm.base_url or "https://api.avalai.ir/v1"
|
||||
else:
|
||||
env_var = llm.api_key_env or "GAPGPT_API_KEY"
|
||||
api_key = os.environ.get(env_var)
|
||||
base_url = llm.base_url or "https://api.gapgpt.app/v1"
|
||||
logger.info(
|
||||
"chat base_url=%s api_key=%s",
|
||||
base_url,
|
||||
_mask_secret(api_key),
|
||||
)
|
||||
|
||||
return OpenAI(api_key=api_key, base_url=base_url)
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RagConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "rag"
|
||||
verbose_name = "RAG - پایگاه دانش"
|
||||
@@ -0,0 +1,434 @@
|
||||
"""
|
||||
چت RAG برای API چت عمومی — با ارسال کامل داده مزرعه و retrieval تکمیلی از KB.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .api_provider import get_chat_client
|
||||
from .chunker import chunk_text
|
||||
from .config import RAGConfig, ServiceConfig, get_service_config, load_rag_config
|
||||
from .retrieve import search_with_texts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _coerce_text_content(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
parts: list[str] = []
|
||||
for item in value:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
text_value = item.get("text")
|
||||
if isinstance(text_value, str) and text_value.strip():
|
||||
parts.append(text_value.strip())
|
||||
elif isinstance(item, str) and item.strip():
|
||||
parts.append(item.strip())
|
||||
return "\n".join(parts)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _normalize_image_inputs(images: list[Any] | None) -> list[dict[str, str]]:
|
||||
normalized: list[dict[str, str]] = []
|
||||
for item in images or []:
|
||||
if isinstance(item, str):
|
||||
value = item.strip()
|
||||
if value:
|
||||
normalized.append({"url": value})
|
||||
continue
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
url = item.get("url") or item.get("image_url") or item.get("data_url")
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
continue
|
||||
entry = {"url": url.strip()}
|
||||
detail = item.get("detail")
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
entry["detail"] = detail.strip()
|
||||
normalized.append(entry)
|
||||
return normalized
|
||||
|
||||
|
||||
def _build_content_parts(text: str, images: list[dict[str, str]] | None = None) -> str | list[dict[str, Any]]:
|
||||
normalized_text = (text or "").strip()
|
||||
normalized_images = _normalize_image_inputs(images)
|
||||
if not normalized_images:
|
||||
return normalized_text
|
||||
|
||||
parts: list[dict[str, Any]] = []
|
||||
if normalized_text:
|
||||
parts.append({"type": "text", "text": normalized_text})
|
||||
for image in normalized_images:
|
||||
image_payload: dict[str, Any] = {"url": image["url"]}
|
||||
if image.get("detail"):
|
||||
image_payload["detail"] = image["detail"]
|
||||
parts.append({"type": "image_url", "image_url": image_payload})
|
||||
return parts
|
||||
|
||||
|
||||
def _normalize_history_messages(history: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in history or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = str(item.get("role") or "").strip().lower()
|
||||
if role not in {"user", "assistant"}:
|
||||
continue
|
||||
text = _coerce_text_content(
|
||||
item.get("content", item.get("message", item.get("text")))
|
||||
).strip()
|
||||
images = _normalize_image_inputs(item.get("images") or item.get("image_urls"))
|
||||
if not text and not images:
|
||||
continue
|
||||
content = _build_content_parts(text, images if role == "user" else None)
|
||||
normalized.append({"role": role, "content": content})
|
||||
return normalized
|
||||
|
||||
|
||||
def encode_uploaded_image(uploaded_file: Any) -> dict[str, str]:
|
||||
content_type = getattr(uploaded_file, "content_type", None) or mimetypes.guess_type(
|
||||
getattr(uploaded_file, "name", "")
|
||||
)[0] or "application/octet-stream"
|
||||
raw = uploaded_file.read()
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
raise ValueError("Uploaded image payload is invalid.")
|
||||
encoded = base64.b64encode(raw).decode("ascii")
|
||||
return {
|
||||
"url": f"data:{content_type};base64,{encoded}",
|
||||
"detail": "auto",
|
||||
}
|
||||
|
||||
|
||||
def _load_tone(config: RAGConfig | None) -> str:
|
||||
"""بارگذاری فایل لحن پیشفرض (chat KB)."""
|
||||
cfg = config or load_rag_config()
|
||||
base = Path(__file__).resolve().parent.parent
|
||||
chat_kb = cfg.knowledge_bases.get("chat")
|
||||
if chat_kb:
|
||||
tone_path = base / chat_kb.tone_file
|
||||
if tone_path.exists():
|
||||
return tone_path.read_text(encoding="utf-8").strip()
|
||||
logger.warning("Default tone file not found: %s", tone_path)
|
||||
return ""
|
||||
|
||||
|
||||
def _load_service_tone(service: ServiceConfig, config: RAGConfig | None = None) -> str:
|
||||
cfg = config or load_rag_config()
|
||||
if service.tone_file:
|
||||
base = Path(__file__).resolve().parent.parent
|
||||
tone_path = base / service.tone_file
|
||||
if tone_path.exists():
|
||||
return tone_path.read_text(encoding="utf-8").strip()
|
||||
logger.warning("Service tone file not found: %s", tone_path)
|
||||
return _load_tone(cfg)
|
||||
|
||||
|
||||
def _format_farm_context(farm_uuid: str) -> str:
|
||||
from farm_data.services import get_farm_details
|
||||
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
if not farm_details:
|
||||
raise ValueError("farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.")
|
||||
|
||||
serialized = json.dumps(
|
||||
farm_details,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
return "[اطلاعات کامل مزرعه]\n" + serialized
|
||||
|
||||
|
||||
def _format_farm_context_from_details(farm_details: dict) -> str:
|
||||
serialized = json.dumps(
|
||||
farm_details,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
return "[اطلاعات کامل مزرعه]\n" + serialized
|
||||
|
||||
|
||||
def _load_farm_details_context(
|
||||
sensor_uuid: str | None,
|
||||
farm_details: dict | None = None,
|
||||
) -> str:
|
||||
if not sensor_uuid:
|
||||
return ""
|
||||
if farm_details is not None:
|
||||
return _format_farm_context_from_details(farm_details)
|
||||
return _format_farm_context(sensor_uuid)
|
||||
|
||||
|
||||
def _build_system_prompt(
|
||||
service: ServiceConfig,
|
||||
query: str,
|
||||
farm_context: str,
|
||||
config: RAGConfig | None = None,
|
||||
) -> str:
|
||||
tone = _load_service_tone(service, config)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(
|
||||
"با استفاده از اطلاعات کامل مزرعه و اطلاعات بازیابیشده از پایگاه دانش که در ادامه آمده "
|
||||
"به سوال کاربر پاسخ بده. "
|
||||
"اگر دادهای در اطلاعات مزرعه وجود دارد، همان را مبنای پاسخ قرار بده و چیزی حدس نزن. "
|
||||
"نتایج بازیابیشده از پایگاه دانش را برای تکمیل یا توضیح پاسخ استفاده کن. "
|
||||
"اگر داده کافی نبود، این کمبود را شفاف بگو. "
|
||||
"پاسخ را به زبان کاربر بنویس."
|
||||
)
|
||||
system_parts.append(farm_context)
|
||||
system_parts.append(f"[سوال کاربر]\n{query}")
|
||||
return "\n\n".join(part for part in system_parts if part)
|
||||
|
||||
|
||||
def _create_audit_log(
|
||||
farm_uuid: str,
|
||||
service_id: str,
|
||||
model: str,
|
||||
query: str,
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
) -> "ChatAuditLog":
|
||||
from .models import ChatAuditLog
|
||||
|
||||
log = ChatAuditLog.objects.create(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=service_id,
|
||||
model=model,
|
||||
user_query=query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
status=ChatAuditLog.STATUS_STARTED,
|
||||
)
|
||||
logger.info(
|
||||
"Created chat audit log id=%s service_id=%s farm_uuid=%s model=%s",
|
||||
log.id,
|
||||
service_id,
|
||||
farm_uuid,
|
||||
model,
|
||||
)
|
||||
return log
|
||||
|
||||
|
||||
def _complete_audit_log(audit_log: "ChatAuditLog", response_text: str) -> None:
|
||||
from .models import ChatAuditLog
|
||||
|
||||
audit_log.response_text = response_text
|
||||
audit_log.status = ChatAuditLog.STATUS_COMPLETED
|
||||
audit_log.save(update_fields=["response_text", "status", "updated_at"])
|
||||
|
||||
|
||||
def _fail_audit_log(
|
||||
audit_log: "ChatAuditLog",
|
||||
error_message: str,
|
||||
response_text: str = "",
|
||||
) -> None:
|
||||
from .models import ChatAuditLog
|
||||
|
||||
audit_log.response_text = response_text
|
||||
audit_log.error_message = error_message
|
||||
audit_log.status = ChatAuditLog.STATUS_FAILED
|
||||
audit_log.save(
|
||||
update_fields=["response_text", "error_message", "status", "updated_at"]
|
||||
)
|
||||
|
||||
|
||||
def build_rag_context(
|
||||
query: str,
|
||||
sensor_uuid: str | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
limit: int = 8,
|
||||
kb_name: str | None = None,
|
||||
service_id: str | None = None,
|
||||
farm_details: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
ساخت context مشترک برای همه سرویسهای RAG.
|
||||
شامل:
|
||||
- اطلاعات کامل مزرعه از farm_data/services.py
|
||||
- جستجوی KB بر اساس پیام کاربر
|
||||
- جستجوی KB بر اساس chunk های کامل داده مزرعه
|
||||
"""
|
||||
|
||||
logger.info(
|
||||
"Building RAG context sensor_uuid=%s kb_name=%s limit=%s query_len=%s",
|
||||
sensor_uuid,
|
||||
kb_name,
|
||||
limit,
|
||||
len(query or ""),
|
||||
)
|
||||
parts: list[str] = []
|
||||
cfg = config or load_rag_config()
|
||||
service = get_service_config(service_id, cfg) if service_id else None
|
||||
include_user_embeddings = service.use_user_embeddings if service else True
|
||||
resolved_kb_name = kb_name or (service.knowledge_base if service else None)
|
||||
farm_context = _load_farm_details_context(
|
||||
sensor_uuid=sensor_uuid,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
|
||||
if farm_context:
|
||||
parts.append(farm_context)
|
||||
|
||||
search_texts = [query]
|
||||
if farm_context:
|
||||
search_texts.extend(chunk_text(farm_context, config=cfg))
|
||||
|
||||
results = search_with_texts(
|
||||
search_texts,
|
||||
sensor_uuid=sensor_uuid,
|
||||
limit=limit,
|
||||
per_text_limit=3,
|
||||
config=cfg,
|
||||
kb_name=resolved_kb_name,
|
||||
service_id=service_id,
|
||||
use_user_embeddings=include_user_embeddings,
|
||||
)
|
||||
if results:
|
||||
rag_texts = [r.get("text", "").strip() for r in results if r.get("text")]
|
||||
if rag_texts:
|
||||
parts.append("[متنهای مرجع]\n" + "\n\n---\n\n".join(rag_texts))
|
||||
|
||||
return "\n\n---\n\n".join(parts) if parts else ""
|
||||
|
||||
|
||||
def chat_rag_stream(
|
||||
query: str,
|
||||
farm_uuid: str,
|
||||
config: RAGConfig | None = None,
|
||||
system_override: str | None = None,
|
||||
farm_details: dict | None = None,
|
||||
history: list[dict[str, Any]] | None = None,
|
||||
images: list[dict[str, str]] | None = None,
|
||||
):
|
||||
"""
|
||||
چت استریمی با سرویس ثابت `chat` و context مستقیم مزرعه.
|
||||
|
||||
Args:
|
||||
query: پیام کاربر
|
||||
farm_uuid: شناسه مزرعه
|
||||
config: تنظیمات RAG
|
||||
system_override: جایگزین system prompt (اختیاری)
|
||||
history: لیست پیام های قبلی کاربر/هوش مصنوعی
|
||||
images: تصاویر مربوط به پیام فعلی کاربر
|
||||
|
||||
Yields:
|
||||
chunk های استریم پاسخ مدل
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
service_id = "chat"
|
||||
service = get_service_config(service_id, cfg)
|
||||
service_llm_config = service.llm
|
||||
service_cfg = RAGConfig(
|
||||
embedding=cfg.embedding,
|
||||
qdrant=cfg.qdrant,
|
||||
chunking=cfg.chunking,
|
||||
llm=service_llm_config,
|
||||
knowledge_bases=cfg.knowledge_bases,
|
||||
services=cfg.services,
|
||||
chromadb=cfg.chromadb,
|
||||
)
|
||||
client = get_chat_client(service_cfg)
|
||||
model = service_llm_config.model
|
||||
|
||||
logger.info(
|
||||
"chat_rag_stream started service_id=%s farm_uuid=%s query_len=%s",
|
||||
service_id,
|
||||
farm_uuid,
|
||||
len(query or ""),
|
||||
)
|
||||
|
||||
context = build_rag_context(
|
||||
query=query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
service_id=service_id,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
logger.info(
|
||||
"Loaded augmented context for farm_uuid=%s context_len=%s",
|
||||
farm_uuid,
|
||||
len(context),
|
||||
)
|
||||
|
||||
if system_override is not None:
|
||||
system_prompt = system_override
|
||||
else:
|
||||
system_prompt = _build_system_prompt(
|
||||
service,
|
||||
query,
|
||||
context,
|
||||
config=cfg,
|
||||
)
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
messages.extend(_normalize_history_messages(history))
|
||||
messages.append({"role": "user", "content": _build_content_parts(query, images)})
|
||||
|
||||
logger.info(
|
||||
"Final prompt prepared service_id=%s farm_uuid=%s model=%s messages_count=%s",
|
||||
service_id,
|
||||
farm_uuid,
|
||||
model,
|
||||
len(messages),
|
||||
)
|
||||
logger.info("Final system prompt for farm_uuid=%s:\n%s", farm_uuid, system_prompt)
|
||||
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=service_id,
|
||||
model=model,
|
||||
query=query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
response_chunks: list[str] = []
|
||||
try:
|
||||
stream = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
stream=True,
|
||||
)
|
||||
logger.info(
|
||||
"Started streaming response id=%s service_id=%s farm_uuid=%s",
|
||||
audit_log.id,
|
||||
service_id,
|
||||
farm_uuid,
|
||||
)
|
||||
|
||||
for chunk in stream:
|
||||
delta = chunk.choices[0].delta if chunk.choices else None
|
||||
content = delta.content if delta else ""
|
||||
if content:
|
||||
response_chunks.append(content)
|
||||
yield content
|
||||
|
||||
full_response = "".join(response_chunks)
|
||||
_complete_audit_log(audit_log, full_response)
|
||||
logger.info(
|
||||
"Completed chat response id=%s farm_uuid=%s response_len=%s response=\n%s",
|
||||
audit_log.id,
|
||||
farm_uuid,
|
||||
len(full_response),
|
||||
full_response,
|
||||
)
|
||||
except Exception as exc:
|
||||
partial_response = "".join(response_chunks)
|
||||
_fail_audit_log(audit_log, str(exc), partial_response)
|
||||
logger.exception(
|
||||
"Chat request failed id=%s service_id=%s farm_uuid=%s partial_response_len=%s",
|
||||
audit_log.id,
|
||||
service_id,
|
||||
farm_uuid,
|
||||
len(partial_response),
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
تکهتکه کردن متن (Chunking) برای RAG
|
||||
"""
|
||||
from .config import load_rag_config, RAGConfig
|
||||
|
||||
|
||||
# تقریب: هر توکن حدود ۳–۴ نویسه برای فارسی/انگلیسی
|
||||
CHARS_PER_TOKEN = 3.5
|
||||
|
||||
|
||||
def chunk_text(
|
||||
text: str,
|
||||
config: RAGConfig | None = None,
|
||||
max_chunk_tokens: int | None = None,
|
||||
overlap_tokens: int | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
تکهتکه کردن متن بر اساس توکن (تقریبی با نویسه).
|
||||
|
||||
Args:
|
||||
text: متن ورودی
|
||||
config: تنظیمات RAG
|
||||
max_chunk_tokens: حداکثر توکن هر چانک (override)
|
||||
overlap_tokens: تعداد توکن همپوشانی بین چانکها (override)
|
||||
|
||||
Returns:
|
||||
لیست چانکها
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
max_tok = max_chunk_tokens if max_chunk_tokens is not None else cfg.chunking.max_chunk_tokens
|
||||
overlap = overlap_tokens if overlap_tokens is not None else cfg.chunking.overlap_tokens
|
||||
|
||||
max_chars = int(max_tok * CHARS_PER_TOKEN)
|
||||
overlap_chars = int(overlap * CHARS_PER_TOKEN)
|
||||
step = max_chars - overlap_chars
|
||||
|
||||
if step <= 0:
|
||||
step = max_chars
|
||||
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
chunks: list[str] = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = start + max_chars
|
||||
chunk = text[start:end].strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
start += step
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def chunk_texts(
|
||||
texts: list[str],
|
||||
config: RAGConfig | None = None,
|
||||
**kwargs,
|
||||
) -> list[str]:
|
||||
"""چند متن را تکهتکه میکند و همه چانکها را برمیگرداند."""
|
||||
all_chunks: list[str] = []
|
||||
for t in texts:
|
||||
all_chunks.extend(chunk_text(t, config=config, **kwargs))
|
||||
return all_chunks
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
کلاینت Qdrant — اتصال به دیتابیس وکتور
|
||||
"""
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qmodels
|
||||
|
||||
from .config import QdrantConfig, load_rag_config
|
||||
|
||||
|
||||
def get_qdrant_client(config: QdrantConfig | None = None) -> QdrantClient:
|
||||
"""
|
||||
ایجاد کلاینت Qdrant.
|
||||
اگر config داده نشود، از rag_config.yaml بارگذاری میشود.
|
||||
"""
|
||||
if config is None:
|
||||
rag = load_rag_config()
|
||||
config = rag.qdrant
|
||||
|
||||
return QdrantClient(host=config.host, port=config.port)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
بارگذاری تنظیمات RAG از rag_config.yaml — با پشتیبانی از چند provider،
|
||||
چند پایگاه دانش و چند سرویس.
|
||||
"""
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingConfig:
|
||||
provider: str
|
||||
model: str
|
||||
batch_size: int = 32
|
||||
api_key: str | None = None
|
||||
api_key_env: str | None = None
|
||||
base_url: str | None = None
|
||||
avalai_api_key: str | None = None
|
||||
avalai_base_url: str | None = None
|
||||
avalai_api_key_env: str | None = None
|
||||
arvancloud_api_key: str | None = None
|
||||
arvancloud_base_url: str | None = None
|
||||
arvancloud_api_key_env: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class QdrantConfig:
|
||||
host: str = "localhost"
|
||||
port: int = 6333
|
||||
collection_name: str = "croplogic_kb"
|
||||
vector_size: int = 384
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkingConfig:
|
||||
max_chunk_tokens: int = 500
|
||||
overlap_tokens: int = 50
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
provider: str = "gapgpt"
|
||||
model: str = "gpt-4o"
|
||||
base_url: str | None = None
|
||||
api_key_env: str | None = None
|
||||
avalai_base_url: str | None = None
|
||||
avalai_api_key_env: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeBaseConfig:
|
||||
path: str
|
||||
tone_file: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceConfig:
|
||||
service_id: str
|
||||
knowledge_base: str
|
||||
llm: LLMConfig = field(default_factory=LLMConfig)
|
||||
tone_file: str | None = None
|
||||
system_prompt: str | None = None
|
||||
use_user_embeddings: bool = True
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RAGConfig:
|
||||
embedding: EmbeddingConfig
|
||||
qdrant: QdrantConfig
|
||||
chunking: ChunkingConfig
|
||||
llm: LLMConfig = field(default_factory=LLMConfig)
|
||||
knowledge_bases: dict[str, KnowledgeBaseConfig] = field(default_factory=dict)
|
||||
services: dict[str, ServiceConfig] = field(default_factory=dict)
|
||||
chromadb: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _build_llm_config(data: dict[str, Any] | None, default: LLMConfig | None = None) -> LLMConfig:
|
||||
llm_data = data or {}
|
||||
fallback = default or LLMConfig()
|
||||
return LLMConfig(
|
||||
provider=llm_data.get("provider", fallback.provider),
|
||||
model=llm_data.get("model", fallback.model),
|
||||
base_url=llm_data.get("base_url", fallback.base_url),
|
||||
api_key_env=llm_data.get("api_key_env", fallback.api_key_env),
|
||||
avalai_base_url=llm_data.get("avalai_base_url", fallback.avalai_base_url),
|
||||
avalai_api_key_env=llm_data.get("avalai_api_key_env", fallback.avalai_api_key_env),
|
||||
)
|
||||
|
||||
|
||||
def get_service_config(service_id: str, config: RAGConfig | None = None) -> ServiceConfig:
|
||||
cfg = config or load_rag_config()
|
||||
service = cfg.services.get(service_id)
|
||||
if service is None:
|
||||
raise KeyError(f"Unknown service_id: {service_id}")
|
||||
return service
|
||||
|
||||
|
||||
def load_rag_config(config_path: str | Path | None = None) -> RAGConfig:
|
||||
"""
|
||||
بارگذاری تنظیمات از YAML و env.
|
||||
QDRANT_HOST و QDRANT_PORT از متغیرهای محیطی override میشوند.
|
||||
"""
|
||||
if config_path is None:
|
||||
base = Path(__file__).resolve().parent.parent
|
||||
config_path = base / "config" / "rag_config.yaml"
|
||||
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"RAG config not found: {path}")
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
emb = data.get("embedding", {})
|
||||
embedding = EmbeddingConfig(
|
||||
provider=emb.get("provider", "sentence_transformers"),
|
||||
model=emb.get("model", "text-embedding-3-small"),
|
||||
batch_size=emb.get("batch_size", 32),
|
||||
api_key=emb.get("api_key"),
|
||||
api_key_env=emb.get("api_key_env"),
|
||||
base_url=emb.get("base_url"),
|
||||
avalai_api_key=emb.get("avalai_api_key"),
|
||||
avalai_base_url=emb.get("avalai_base_url"),
|
||||
avalai_api_key_env=emb.get("avalai_api_key_env"),
|
||||
arvancloud_api_key=emb.get("arvancloud_api_key"),
|
||||
arvancloud_base_url=emb.get("arvancloud_base_url"),
|
||||
arvancloud_api_key_env=emb.get("arvancloud_api_key_env"),
|
||||
)
|
||||
|
||||
qd = data.get("qdrant", {})
|
||||
qdrant = QdrantConfig(
|
||||
host=os.environ.get("QDRANT_HOST", qd.get("host", "localhost")),
|
||||
port=int(os.environ.get("QDRANT_PORT", qd.get("port", 6333))),
|
||||
collection_name=qd.get("collection_name", "croplogic_kb"),
|
||||
vector_size=qd.get("vector_size", 1536),
|
||||
)
|
||||
|
||||
ch = data.get("chunking", {})
|
||||
chunking = ChunkingConfig(
|
||||
max_chunk_tokens=ch.get("max_chunk_tokens", 500),
|
||||
overlap_tokens=ch.get("overlap_tokens", 50),
|
||||
)
|
||||
|
||||
llm = _build_llm_config(data.get("llm", {}))
|
||||
|
||||
kb_data = data.get("knowledge_bases", {})
|
||||
knowledge_bases: dict[str, KnowledgeBaseConfig] = {}
|
||||
for kb_name, kb_conf in kb_data.items():
|
||||
knowledge_bases[kb_name] = KnowledgeBaseConfig(
|
||||
path=kb_conf.get("path", f"config/knowledge_base/{kb_name}"),
|
||||
tone_file=kb_conf.get("tone_file", f"config/tones/{kb_name}_tone.txt"),
|
||||
description=kb_conf.get("description", ""),
|
||||
)
|
||||
|
||||
services_data = data.get("services", {})
|
||||
services: dict[str, ServiceConfig] = {}
|
||||
for service_id, service_conf in services_data.items():
|
||||
kb_name = service_conf.get("knowledge_base", service_id)
|
||||
kb_conf = knowledge_bases.get(kb_name)
|
||||
services[service_id] = ServiceConfig(
|
||||
service_id=service_id,
|
||||
knowledge_base=kb_name,
|
||||
llm=_build_llm_config(service_conf.get("llm"), default=llm),
|
||||
tone_file=service_conf.get("tone_file") or (kb_conf.tone_file if kb_conf else None),
|
||||
system_prompt=service_conf.get("system_prompt"),
|
||||
use_user_embeddings=service_conf.get("use_user_embeddings", True),
|
||||
description=service_conf.get("description", ""),
|
||||
)
|
||||
|
||||
if not services:
|
||||
for kb_name, kb_conf in knowledge_bases.items():
|
||||
services[kb_name] = ServiceConfig(
|
||||
service_id=kb_name,
|
||||
knowledge_base=kb_name,
|
||||
llm=llm,
|
||||
tone_file=kb_conf.tone_file,
|
||||
use_user_embeddings=True,
|
||||
description=kb_conf.description,
|
||||
)
|
||||
|
||||
return RAGConfig(
|
||||
embedding=embedding,
|
||||
qdrant=qdrant,
|
||||
chunking=chunking,
|
||||
llm=llm,
|
||||
knowledge_bases=knowledge_bases,
|
||||
services=services,
|
||||
chromadb=data.get("chromadb", {}),
|
||||
)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
سرویس تعبیهسازی متن — از Adapter Pattern برای سوئیچ بین providers استفاده میکند
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from .api_provider import get_embedding_client
|
||||
from .config import RAGConfig, load_rag_config
|
||||
from .observability import classify_exception, log_event, observe_operation, record_metric
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def embed_texts(
|
||||
texts: list[str],
|
||||
config: RAGConfig | None = None,
|
||||
model: str | None = None,
|
||||
dimensions: int | None = None,
|
||||
) -> list[list[float]]:
|
||||
"""
|
||||
تعبیهسازی لیست متنها با Avalai.
|
||||
|
||||
Args:
|
||||
texts: لیست رشتههای ورودی
|
||||
config: تنظیمات RAG (پیشفرض: load_rag_config)
|
||||
model: نام مدل (override از config)
|
||||
dimensions: تعداد ابعاد (فقط برای مدلهای پشتیبانیکننده)
|
||||
|
||||
Returns:
|
||||
لیست وکتورها
|
||||
"""
|
||||
if not texts:
|
||||
record_metric("rag.embedding.empty_input", operation="embed_texts")
|
||||
return []
|
||||
|
||||
cfg = config or load_rag_config()
|
||||
client = get_embedding_client(cfg)
|
||||
model_name = model or cfg.embedding.model
|
||||
provider = cfg.embedding.provider or "unknown"
|
||||
batch_size = cfg.embedding.batch_size
|
||||
|
||||
all_embeddings: list[list[float]] = []
|
||||
extra = {}
|
||||
if dimensions is not None:
|
||||
extra["dimensions"] = dimensions
|
||||
|
||||
with observe_operation(source="rag.embedding", provider=provider, operation="embed_texts"):
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
started_at = time.monotonic()
|
||||
try:
|
||||
resp = client.embeddings.create(
|
||||
model=model_name,
|
||||
input=batch,
|
||||
**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
|
||||
|
||||
|
||||
def embed_single(text: str, config: RAGConfig | None = None, **kwargs) -> list[float]:
|
||||
"""تعبیهسازی یک متن. خروجی مستقیماً یک وکتور است."""
|
||||
vecs = embed_texts([text], config=config, **kwargs)
|
||||
return vecs[0] if vecs else []
|
||||
@@ -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()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
پایپلاین ورودی RAG: خواندن، چانک، embed و ذخیره در vector store — با پشتیبانی از چند پایگاه دانش
|
||||
|
||||
منابع:
|
||||
۱. لحن هر پایگاه دانش (tone) — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization
|
||||
۲. پایگاههای دانش سهگانه — sensor_uuid=__global__, kb_name=chat|irrigation|fertilization
|
||||
۳. دیتای خاک + هواشناسی هر کاربر از DB — sensor_uuid=uuid, kb_name=__all__
|
||||
"""
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from .chunker import chunk_text, chunk_texts
|
||||
from .config import load_rag_config, RAGConfig
|
||||
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 .vector_store import QdrantVectorStore
|
||||
|
||||
TEXT_EXTENSIONS = {".txt", ".md", ".rst", ".json"}
|
||||
|
||||
SENSOR_UUID_GLOBAL = "__global__"
|
||||
|
||||
KB_NAME_ALL = "__all__"
|
||||
|
||||
|
||||
def _resolve_path(base: Path, p: str) -> Path:
|
||||
"""تبدیل مسیر نسبی به مطلق نسبت به base پروژه."""
|
||||
path = Path(p)
|
||||
if not path.is_absolute():
|
||||
path = base / path
|
||||
return path
|
||||
|
||||
|
||||
def _load_file(path: Path) -> str | None:
|
||||
"""خواندن یک فایل متنی."""
|
||||
if not path.exists() or not path.is_file():
|
||||
return None
|
||||
try:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
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
|
||||
|
||||
|
||||
def _load_files_from_dir(dir_path: Path, prefix: str = "kb") -> list[tuple[str, str]]:
|
||||
"""
|
||||
خواندن همه فایلهای متنی از یک دایرکتوری.
|
||||
Returns: [(source_id, content), ...]
|
||||
"""
|
||||
if not dir_path.exists() or not dir_path.is_dir():
|
||||
return []
|
||||
out: list[tuple[str, str]] = []
|
||||
for f in sorted(dir_path.rglob("*")):
|
||||
if f.is_file() and f.suffix.lower() in TEXT_EXTENSIONS:
|
||||
rel = f.relative_to(dir_path)
|
||||
source_id = f"{prefix}:{rel}"
|
||||
content = _load_file(f)
|
||||
if content:
|
||||
out.append((source_id, content))
|
||||
return out
|
||||
|
||||
|
||||
def load_sources(
|
||||
config: RAGConfig | None = None,
|
||||
kb_name: str | None = None,
|
||||
) -> list[tuple[str, str, str, str]]:
|
||||
"""
|
||||
بارگذاری منابع: لحنها، پایگاههای دانش سهگانه، دیتای کاربران.
|
||||
اگر kb_name مشخص شود، فقط آن پایگاه دانش لود میشود.
|
||||
|
||||
Returns:
|
||||
[(source_id, content, sensor_uuid, kb_name), ...]
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
base = Path(__file__).resolve().parent.parent
|
||||
sources: list[tuple[str, str, str, str]] = []
|
||||
|
||||
kbs_to_load = cfg.knowledge_bases.items()
|
||||
if kb_name:
|
||||
kbs_to_load = [(k, v) for k, v in kbs_to_load if k == kb_name]
|
||||
|
||||
for kbn, kb_cfg in kbs_to_load:
|
||||
tone_path = _resolve_path(base, kb_cfg.tone_file)
|
||||
content = _load_file(tone_path)
|
||||
if content:
|
||||
sources.append((f"tone:{kbn}", content, SENSOR_UUID_GLOBAL, kbn))
|
||||
|
||||
kb_path = _resolve_path(base, kb_cfg.path)
|
||||
for sid, c in _load_files_from_dir(kb_path, prefix=f"kb:{kbn}"):
|
||||
sources.append((sid, c, SENSOR_UUID_GLOBAL, kbn))
|
||||
if kb_path.is_file():
|
||||
content = _load_file(kb_path)
|
||||
if content:
|
||||
sources.append((f"kb:{kbn}:{kb_path.name}", content, SENSOR_UUID_GLOBAL, kbn))
|
||||
|
||||
for sid, content in load_user_sources():
|
||||
if sid.startswith("user:"):
|
||||
sensor_uuid = sid.replace("user:", "")
|
||||
elif sid.startswith("weather:"):
|
||||
sensor_uuid = sid.replace("weather:", "")
|
||||
else:
|
||||
sensor_uuid = sid
|
||||
sources.append((sid, content, sensor_uuid, KB_NAME_ALL))
|
||||
|
||||
return sources
|
||||
|
||||
|
||||
def ingest(
|
||||
recreate: bool = False,
|
||||
config: RAGConfig | None = None,
|
||||
kb_name: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
ورودی کامل: منابع را میخواند، چانک، embed و به vector store میفرستد.
|
||||
kb_name اختیاری: اگر مشخص شود فقط آن پایگاه دانش ingest میشود.
|
||||
|
||||
Args:
|
||||
recreate: اگر True باشد، collection را از نو میسازد
|
||||
config: تنظیمات RAG
|
||||
kb_name: نام پایگاه دانش (chat/irrigation/fertilization) — اختیاری
|
||||
|
||||
Returns:
|
||||
آمار ورودی (تعداد چانک، منبعها، خطاها)
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
store = QdrantVectorStore(config=cfg)
|
||||
with observe_operation(source="rag.ingest", provider=cfg.embedding.provider, operation="ingest"):
|
||||
if recreate:
|
||||
store.ensure_collection(recreate=True)
|
||||
|
||||
sources = load_sources(config=cfg, kb_name=kb_name)
|
||||
if not sources:
|
||||
record_metric("rag.ingest.empty_sources", kb_name=kb_name)
|
||||
return {"chunks_added": 0, "sources": [], "error": "هیچ منبعی یافت نشد"}
|
||||
|
||||
all_chunks: list[str] = []
|
||||
all_metas: list[dict] = []
|
||||
all_ids: list[str] = []
|
||||
|
||||
for source_id, content, sensor_uuid, src_kb in sources:
|
||||
chunks = chunk_text(content, config=cfg)
|
||||
for i, ch in enumerate(chunks):
|
||||
uid = str(uuid.uuid4())
|
||||
all_ids.append(uid)
|
||||
all_chunks.append(ch)
|
||||
all_metas.append({
|
||||
"source": source_id,
|
||||
"chunk_index": i,
|
||||
"sensor_uuid": sensor_uuid,
|
||||
"kb_name": src_kb,
|
||||
})
|
||||
|
||||
if not all_chunks:
|
||||
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)
|
||||
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 {
|
||||
"chunks_added": len(all_chunks),
|
||||
"sources": [s[0] for s in sources],
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
ورودی RAG: لحن، پایگاه دانش و اطلاعات کاربر را embed و به Qdrant میفرستد.
|
||||
اجرا: python manage.py rag_ingest [--recreate]
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from rag.ingest import ingest
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Embed لحن، پایگاه دانش و اطلاعات کاربر و ذخیره در Qdrant"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--recreate",
|
||||
action="store_true",
|
||||
help="collection را از نو بساز (حذف و ایجاد مجدد)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
recreate = options.get("recreate", False)
|
||||
result = ingest(recreate=recreate)
|
||||
if "error" in result:
|
||||
self.stderr.write(self.style.ERROR(result["error"]))
|
||||
return
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"✓ {result['chunks_added']} چانک از منابع {result['sources']} ذخیره شد."
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ChatAuditLog",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("farm_uuid", models.UUIDField(blank=True, help_text="شناسه مزرعه مرتبط با درخواست چت", null=True)),
|
||||
("service_id", models.CharField(default="chat", help_text="شناسه سرویس RAG استفاده شده برای این درخواست", max_length=64)),
|
||||
("model", models.CharField(blank=True, help_text="مدل LLM استفاده شده برای پاسخ", max_length=128)),
|
||||
("user_query", models.TextField(help_text="متن پرسش کاربر")),
|
||||
("system_prompt", models.TextField(blank=True, help_text="system prompt نهایی ارسال شده به مدل")),
|
||||
("messages", models.JSONField(blank=True, default=list, help_text="لیست کامل پیامهای ارسال شده به مدل")),
|
||||
("response_text", models.TextField(blank=True, help_text="متن کامل پاسخ دریافتی از مدل")),
|
||||
("error_message", models.TextField(blank=True, help_text="خطای رخ داده هنگام فراخوانی مدل یا استریم")),
|
||||
("status", models.CharField(choices=[("started", "شروع شده"), ("completed", "تکمیل شده"), ("failed", "ناموفق")], default="started", max_length=16)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "rag_chatauditlog",
|
||||
"ordering": ["-created_at"],
|
||||
"verbose_name": "لاگ چت RAG",
|
||||
"verbose_name_plural": "لاگ\u200cهای چت RAG",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ChatAuditLog(models.Model):
|
||||
STATUS_STARTED = "started"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_STARTED, "شروع شده"),
|
||||
(STATUS_COMPLETED, "تکمیل شده"),
|
||||
(STATUS_FAILED, "ناموفق"),
|
||||
]
|
||||
|
||||
farm_uuid = models.UUIDField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="شناسه مزرعه مرتبط با درخواست چت",
|
||||
)
|
||||
service_id = models.CharField(
|
||||
max_length=64,
|
||||
default="chat",
|
||||
help_text="شناسه سرویس RAG استفاده شده برای این درخواست",
|
||||
)
|
||||
model = models.CharField(
|
||||
max_length=128,
|
||||
blank=True,
|
||||
help_text="مدل LLM استفاده شده برای پاسخ",
|
||||
)
|
||||
user_query = models.TextField(help_text="متن پرسش کاربر")
|
||||
system_prompt = models.TextField(
|
||||
blank=True,
|
||||
help_text="system prompt نهایی ارسال شده به مدل",
|
||||
)
|
||||
messages = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="لیست کامل پیامهای ارسال شده به مدل",
|
||||
)
|
||||
response_text = models.TextField(
|
||||
blank=True,
|
||||
help_text="متن کامل پاسخ دریافتی از مدل",
|
||||
)
|
||||
error_message = models.TextField(
|
||||
blank=True,
|
||||
help_text="خطای رخ داده هنگام فراخوانی مدل یا استریم",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=16,
|
||||
choices=STATUS_CHOICES,
|
||||
default=STATUS_STARTED,
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "rag_chatauditlog"
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "لاگ چت RAG"
|
||||
verbose_name_plural = "لاگهای چت RAG"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.service_id} - {self.farm_uuid or 'no-farm'} - {self.status}"
|
||||
@@ -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
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
بازیابی RAG: embed کوئری و جستجو در vector store
|
||||
"""
|
||||
from .config import load_rag_config, RAGConfig, get_service_config
|
||||
from .embedding import embed_single, embed_texts
|
||||
from .observability import observe_operation, record_metric
|
||||
from .vector_store import QdrantVectorStore
|
||||
|
||||
|
||||
def _resolve_search_options(
|
||||
sensor_uuid: str | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
kb_name: str | None = None,
|
||||
service_id: str | None = None,
|
||||
use_user_embeddings: bool | None = None,
|
||||
) -> tuple[RAGConfig, list[str], list[str]]:
|
||||
cfg = config or load_rag_config()
|
||||
service = get_service_config(service_id, cfg) if service_id else None
|
||||
resolved_kb_name = kb_name or (service.knowledge_base if service else None)
|
||||
include_user_embeddings = (
|
||||
use_user_embeddings
|
||||
if use_user_embeddings is not None
|
||||
else (service.use_user_embeddings if service else True)
|
||||
)
|
||||
|
||||
sensor_filters = ["__global__"]
|
||||
if include_user_embeddings and sensor_uuid:
|
||||
sensor_filters.insert(0, sensor_uuid)
|
||||
|
||||
kb_filters = [resolved_kb_name] if resolved_kb_name else []
|
||||
if include_user_embeddings:
|
||||
kb_filters.append("__all__")
|
||||
|
||||
return cfg, sensor_filters, kb_filters
|
||||
|
||||
|
||||
def search_with_query(
|
||||
query: str,
|
||||
sensor_uuid: str | None = None,
|
||||
limit: int = 5,
|
||||
score_threshold: float | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
kb_name: str | None = None,
|
||||
service_id: str | None = None,
|
||||
use_user_embeddings: bool | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
کوئری را embed میکند و در vector store جستجو میکند.
|
||||
فقط chunks مربوط به sensor_uuid یا __global__ برمیگردد (ایزولهسازی کاربر).
|
||||
kb_name: اختیاری — فیلتر بر اساس پایگاه دانش.
|
||||
|
||||
Args:
|
||||
sensor_uuid: شناسه سنسور کاربر — اجباری برای امنیت
|
||||
kb_name: نام پایگاه دانش (chat/irrigation/fertilization)
|
||||
|
||||
Returns:
|
||||
لیست نتایج با id, score, text, metadata
|
||||
"""
|
||||
cfg, sensor_filters, kb_filters = _resolve_search_options(
|
||||
sensor_uuid=sensor_uuid,
|
||||
config=config,
|
||||
kb_name=kb_name,
|
||||
service_id=service_id,
|
||||
use_user_embeddings=use_user_embeddings,
|
||||
)
|
||||
|
||||
with observe_operation(source="rag.retrieve", provider=cfg.embedding.provider, operation="search_with_query"):
|
||||
query_vector = embed_single(query, config=cfg)
|
||||
store = QdrantVectorStore(config=cfg)
|
||||
results = store.search(
|
||||
query_vector=query_vector,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
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(
|
||||
texts: list[str],
|
||||
sensor_uuid: str | None = None,
|
||||
limit: int = 8,
|
||||
per_text_limit: int = 3,
|
||||
score_threshold: float | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
kb_name: str | None = None,
|
||||
service_id: str | None = None,
|
||||
use_user_embeddings: bool | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
چند متن را embed میکند و نتیجه جستجوها را به صورت dedupe شده برمیگرداند.
|
||||
برای حالتی مناسب است که هم پیام کاربر و هم دادههای مزرعه را علیه KB جستجو کنیم.
|
||||
"""
|
||||
normalized_texts = [text.strip() for text in texts if text and text.strip()]
|
||||
if not normalized_texts:
|
||||
return []
|
||||
|
||||
cfg, sensor_filters, kb_filters = _resolve_search_options(
|
||||
sensor_uuid=sensor_uuid,
|
||||
config=config,
|
||||
kb_name=kb_name,
|
||||
service_id=service_id,
|
||||
use_user_embeddings=use_user_embeddings,
|
||||
)
|
||||
|
||||
store = QdrantVectorStore(config=cfg)
|
||||
with observe_operation(source="rag.retrieve", provider=cfg.embedding.provider, operation="search_with_texts"):
|
||||
vectors = embed_texts(normalized_texts, config=cfg)
|
||||
merged_results: dict[str, dict] = {}
|
||||
|
||||
for vector in vectors:
|
||||
results = store.search(
|
||||
query_vector=vector,
|
||||
limit=per_text_limit,
|
||||
score_threshold=score_threshold,
|
||||
sensor_uuids=sensor_filters,
|
||||
kb_names=kb_filters,
|
||||
)
|
||||
for item in results:
|
||||
current = merged_results.get(item["id"])
|
||||
if current is None or item["score"] > current["score"]:
|
||||
merged_results[item["id"]] = item
|
||||
|
||||
final_results = sorted(
|
||||
merged_results.values(),
|
||||
key=lambda item: item["score"],
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
if not final_results:
|
||||
record_metric("rag.retrieve.empty_result", operation="search_with_texts", service_id=service_id)
|
||||
return final_results
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
سرویسهای RAG — آبیاری و کودهی
|
||||
بدون API — قابل استفاده از سایر سرویسها
|
||||
"""
|
||||
from .irrigation import get_irrigation_recommendation
|
||||
from .irrigation_plan_parser import IrrigationPlanParserService
|
||||
from .fertilization import get_fertilization_recommendation
|
||||
from .fertilization_plan_parser import FertilizationPlanParserService
|
||||
from .pest_disease import get_pest_disease_detection, get_pest_disease_risk
|
||||
from .soil_anomaly import get_soil_anomaly_insight
|
||||
from .water_need_prediction import get_water_need_prediction_insight
|
||||
from .yield_harvest import YieldHarvestRAGService
|
||||
|
||||
__all__ = [
|
||||
"get_irrigation_recommendation",
|
||||
"IrrigationPlanParserService",
|
||||
"get_fertilization_recommendation",
|
||||
"FertilizationPlanParserService",
|
||||
"get_pest_disease_detection",
|
||||
"get_pest_disease_risk",
|
||||
"get_soil_anomaly_insight",
|
||||
"get_water_need_prediction_insight",
|
||||
"YieldHarvestRAGService",
|
||||
]
|
||||
@@ -0,0 +1,738 @@
|
||||
"""
|
||||
سرویس توصیه کودهی — بدون API، قابل فراخوانی از سایر سرویسها.
|
||||
از RAG با پایگاه دانش fertilization و خروجی optimizer برای ساخت پاسخ ساختاریافته استفاده میکند.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import clone_snapshot_as_runtime_plant, get_farm_plant_snapshot_by_name
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
_create_audit_log,
|
||||
_fail_audit_log,
|
||||
_load_service_tone,
|
||||
build_rag_context,
|
||||
)
|
||||
from rag.config import RAGConfig, get_service_config, load_rag_config
|
||||
from rag.user_data import build_plant_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "fertilization"
|
||||
SERVICE_ID = "fertilization"
|
||||
HECTARE_TO_SQUARE_METER = 10000.0
|
||||
|
||||
DEFAULT_FERTILIZATION_PROMPT = (
|
||||
"از RAG و خروجی بهینه ساز شبیه سازی برای ساخت پاسخ ساختاریافته کودهی استفاده کن. "
|
||||
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان مرجع قطعی اعداد، فرمول، روش مصرف و زمان بندی است. "
|
||||
"پاسخ فقط JSON معتبر بر اساس قرارداد status/data برگردان."
|
||||
)
|
||||
|
||||
DEFAULT_MACRO_DESCRIPTIONS = {
|
||||
"n": "نیتروژن برای حفظ رشد رویشی، رنگ سبز برگ و بازسازی سریع بوته مهم است.",
|
||||
"p": "فسفر به توسعه ریشه، انتقال انرژی و پشتیبانی از گلدهی و استقرار کمک می کند.",
|
||||
"k": "پتاسیم به تنظیم آب، کیفیت محصول و مقاومت گیاه در برابر تنش محیطی کمک می کند.",
|
||||
}
|
||||
|
||||
DEFAULT_MICRO_NAMES = {
|
||||
"fe": "آهن",
|
||||
"zn": "روی",
|
||||
"mn": "منگنز",
|
||||
"b": "بر",
|
||||
"cu": "مس",
|
||||
"mg": "منیزیم",
|
||||
"ca": "کلسیم",
|
||||
"mo": "مولیبدن",
|
||||
}
|
||||
|
||||
DEFAULT_MICRO_DESCRIPTIONS = {
|
||||
"fe": "آهن در ساخت کلروفیل و کاهش زردی بین رگبرگی نقش دارد.",
|
||||
"zn": "روی در رشد متعادل، تشکیل هورمون ها و فعالیت آنزیمی موثر است.",
|
||||
"mn": "منگنز در فتوسنتز و فعالیت آنزیم های متابولیکی نقش پشتیبان دارد.",
|
||||
"b": "بر در گرده افشانی، تشکیل گل و انتقال قندها اهمیت دارد.",
|
||||
"cu": "مس به فعالیت آنزیمی و استحکام نسبی بافت های گیاه کمک می کند.",
|
||||
"mg": "منیزیم بخش مرکزی کلروفیل است و در فتوسنتز اهمیت دارد.",
|
||||
"ca": "کلسیم در استحکام دیواره سلولی و کیفیت رشد بافت های جوان موثر است.",
|
||||
"mo": "مولیبدن در متابولیسم نیتروژن و کارایی جذب آن نقش دارد.",
|
||||
}
|
||||
|
||||
DEFAULT_STAGE_LABELS = {
|
||||
"initial": "استقرار",
|
||||
"vegetative": "رشد رویشی",
|
||||
"flowering": "گلدهی",
|
||||
"fruiting": "میوه دهی",
|
||||
}
|
||||
|
||||
|
||||
def _get_optimizer():
|
||||
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float | None = None) -> float | None:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _stage_key(growth_stage: str | None) -> str:
|
||||
text = (growth_stage or "").strip().lower()
|
||||
if any(token in text for token in ("flower", "گل", "anthesis")):
|
||||
return "flowering"
|
||||
if any(token in text for token in ("fruit", "میوه", "برداشت", "ripen", "harvest")):
|
||||
return "fruiting"
|
||||
if any(token in text for token in ("initial", "seed", "جوانه", "نشا", "استقرار")):
|
||||
return "initial"
|
||||
return "vegetative"
|
||||
|
||||
|
||||
def _clean_json_response(raw: str) -> dict[str, Any]:
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _normalize_label(value: float) -> str:
|
||||
if float(value).is_integer():
|
||||
return str(int(value))
|
||||
return f"{value:.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
|
||||
def _parse_npk_ratio(formula: str | None) -> dict[str, float | str]:
|
||||
if not formula:
|
||||
return {"n": 0.0, "p": 0.0, "k": 0.0, "label": "0-0-0"}
|
||||
|
||||
parts = re.findall(r"\d+(?:\.\d+)?", formula)
|
||||
if len(parts) < 3:
|
||||
return {"n": 0.0, "p": 0.0, "k": 0.0, "label": formula}
|
||||
|
||||
n, p, k = (_safe_float(part, 0.0) or 0.0 for part in parts[:3])
|
||||
return {
|
||||
"n": round(n, 3),
|
||||
"p": round(p, 3),
|
||||
"k": round(k, 3),
|
||||
"label": f"{_normalize_label(n)}-{_normalize_label(p)}-{_normalize_label(k)}",
|
||||
}
|
||||
|
||||
|
||||
def _method_id(label: str) -> str:
|
||||
text = (label or "").strip()
|
||||
if "محلول" in text and ("آبیاری" in text or "کودآبیاری" in text):
|
||||
return "foliar_fertigation"
|
||||
if "محلول" in text:
|
||||
return "foliar_spray"
|
||||
if "آبیاری" in text or "کودآبیاری" in text:
|
||||
return "fertigation"
|
||||
if "سرک" in text or "خاک" in text or "نواری" in text:
|
||||
return "soil_application"
|
||||
return "custom_application"
|
||||
|
||||
|
||||
def _slug_value(value: str) -> str:
|
||||
token = re.sub(r"[^a-zA-Z0-9]+", "-", (value or "").strip().lower()).strip("-")
|
||||
return token or "fertilizer"
|
||||
|
||||
|
||||
def _fertilizer_display_name(formula: str | None) -> str:
|
||||
ratio = _parse_npk_ratio(formula)
|
||||
label = ratio["label"] if ratio["label"] else (formula or "کود پیشنهادی")
|
||||
if label and label != "0-0-0":
|
||||
return f"کود کامل {label}"
|
||||
return formula or "کود پیشنهادی"
|
||||
|
||||
|
||||
def _fertilizer_type_label(formula: str | None) -> str:
|
||||
ratio = _parse_npk_ratio(formula)
|
||||
if ratio["label"] and ratio["label"] != "0-0-0":
|
||||
return "NPK"
|
||||
return formula or "Fertilizer"
|
||||
|
||||
|
||||
def _first_text(*values: Any) -> str:
|
||||
for value in values:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _default_application_steps(application_method: str) -> list[dict[str, Any]]:
|
||||
if "محلول" in application_method:
|
||||
return [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "آماده سازی",
|
||||
"description": "دوز توصیه شده را در مقدار کمی آب تمیز حل کنید تا محلول یکنواخت به دست آید.",
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "اختلاط",
|
||||
"description": "محلول را به مخزن اصلی اضافه کنید و همزمان هم بزنید تا ته نشینی رخ ندهد.",
|
||||
},
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "مصرف",
|
||||
"description": "در ساعات خنک روز به صورت یکنواخت محلول پاشی کنید و پس از اجرا بوته را پایش کنید.",
|
||||
},
|
||||
]
|
||||
|
||||
return [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "آماده سازی",
|
||||
"description": "مقدار توصیه شده را بر اساس مساحت مزرعه اندازه گیری و پیش از اجرا یکنواخت تقسیم کنید.",
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "تزریق یا پخش",
|
||||
"description": "کود را از طریق کودآبیاری یا مصرف خاکی سبک مطابق روش پیشنهادی وارد مزرعه کنید.",
|
||||
},
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "پایش",
|
||||
"description": "پس از اجرا رطوبت خاک، وضعیت برگ و پاسخ بوته را تا نوبت بعدی بررسی کنید.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _warning_from_weather(forecasts: list[Any], application_method: str) -> str:
|
||||
if not forecasts:
|
||||
return "هنگام مصرف از دستکش و ماسک استفاده کنید و قبل از اختلاط آزمون سازگاری در مقیاس کوچک انجام دهید."
|
||||
|
||||
rainy = next(
|
||||
(
|
||||
item
|
||||
for item in forecasts
|
||||
if (_safe_float(getattr(item, "precipitation", None), 0.0) or 0.0) >= 3.0
|
||||
),
|
||||
None,
|
||||
)
|
||||
hot = next(
|
||||
(
|
||||
item
|
||||
for item in forecasts
|
||||
if (_safe_float(getattr(item, "temperature_max", None), 0.0) or 0.0) >= 32.0
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if rainy is not None and "محلول" in application_method:
|
||||
return (
|
||||
f"به دلیل احتمال بارش موثر در {rainy.forecast_date} محلول پاشی را به پنجره خشک منتقل کنید و "
|
||||
"در زمان اجرا از ماسک و دستکش استفاده شود."
|
||||
)
|
||||
if hot is not None:
|
||||
return (
|
||||
"به دلیل گرمای پیش رو، مصرف را فقط در صبح زود یا نزدیک غروب انجام دهید و از اختلاط غلیظ خودداری کنید."
|
||||
)
|
||||
return "هنگام مصرف از دستکش و ماسک استفاده کنید و پیش از اختلاط با سایر نهاده ها آزمون سازگاری انجام دهید."
|
||||
|
||||
|
||||
def _fallback_optimizer_result(growth_stage: str | None) -> dict[str, Any]:
|
||||
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
||||
stage_key = _stage_key(growth_stage)
|
||||
target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
|
||||
base_amount = round(max(40.0, (target["n"] * 1.25)), 2)
|
||||
return {
|
||||
"engine": "defaults",
|
||||
"recommended_strategy": {
|
||||
"code": stage_key,
|
||||
"label": DEFAULT_STAGE_LABELS.get(stage_key, stage_key),
|
||||
"score": 0.0,
|
||||
"expected_yield_index": 0.0,
|
||||
"fertilizer_type": target["formula"],
|
||||
"amount_kg_per_ha": base_amount,
|
||||
"application_method": target["application_method"],
|
||||
"timing": target["timing"],
|
||||
"validity_period": f"معتبر برای {defaults['validity_days']} روز آینده",
|
||||
"reasoning": [
|
||||
"پیشنهاد از تنظیمات پایه مرحله رشد ساخته شد زیرا خروجی کامل optimizer در دسترس نبود.",
|
||||
f"فرمول هدف مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} برابر با {target['formula']} در نظر گرفته شد.",
|
||||
],
|
||||
},
|
||||
"alternatives": [],
|
||||
"context_text": "fallback fertilization context",
|
||||
}
|
||||
|
||||
|
||||
def _build_legacy_sections(
|
||||
structured_data: dict[str, Any],
|
||||
recommended_strategy: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
primary = structured_data.get("primary_recommendation", {})
|
||||
guide = structured_data.get("application_guide", {})
|
||||
recommended_strategy = recommended_strategy or {}
|
||||
return [
|
||||
{
|
||||
"type": "recommendation",
|
||||
"title": primary.get("display_title") or "برنامه کودهی",
|
||||
"icon": "leaf",
|
||||
"content": primary.get("summary", ""),
|
||||
"fertilizerType": primary.get("npk_ratio", {}).get("label") or primary.get("fertilizer_type", ""),
|
||||
"amount": primary.get("dosage", {}).get("label", ""),
|
||||
"applicationMethod": primary.get("application_method", {}).get("label", ""),
|
||||
"timing": recommended_strategy.get("timing", ""),
|
||||
"validityPeriod": recommended_strategy.get("validity_period", ""),
|
||||
"expandableExplanation": primary.get("reasoning", ""),
|
||||
},
|
||||
{
|
||||
"type": "list",
|
||||
"title": "مراحل مصرف",
|
||||
"icon": "list",
|
||||
"items": [step.get("title", "") for step in guide.get("steps", []) if step.get("title")],
|
||||
},
|
||||
{
|
||||
"type": "warning",
|
||||
"title": "هشدار کودهی",
|
||||
"icon": "alert-triangle",
|
||||
"content": guide.get("safety_warning", ""),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _coerce_steps(value: Any, application_method: str) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return _default_application_steps(application_method)
|
||||
|
||||
steps = []
|
||||
for index, item in enumerate(value, start=1):
|
||||
if isinstance(item, dict):
|
||||
title = _first_text(item.get("title"), f"مرحله {index}")
|
||||
description = _first_text(item.get("description"), item.get("content"))
|
||||
if not description:
|
||||
continue
|
||||
steps.append(
|
||||
{
|
||||
"step_number": int(item.get("step_number") or index),
|
||||
"title": title,
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
elif isinstance(item, str) and item.strip():
|
||||
steps.append(
|
||||
{
|
||||
"step_number": index,
|
||||
"title": f"مرحله {index}",
|
||||
"description": item.strip(),
|
||||
}
|
||||
)
|
||||
return steps or _default_application_steps(application_method)
|
||||
|
||||
|
||||
def _normalize_micro_items(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
|
||||
items = []
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = _first_text(item.get("key")).lower()
|
||||
if not key:
|
||||
continue
|
||||
nutrient_value = _safe_float(item.get("value"))
|
||||
if nutrient_value is None:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": _first_text(item.get("name"), DEFAULT_MICRO_NAMES.get(key, key.upper())),
|
||||
"value": round(nutrient_value, 3),
|
||||
"unit": "percent",
|
||||
"description": _first_text(item.get("description"), DEFAULT_MICRO_DESCRIPTIONS.get(key, "")),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _build_nutrient_analysis(llm_analysis: dict[str, Any] | None, npk_ratio: dict[str, Any]) -> dict[str, Any]:
|
||||
llm_analysis = llm_analysis if isinstance(llm_analysis, dict) else {}
|
||||
macro_by_key: dict[str, dict[str, Any]] = {}
|
||||
for item in llm_analysis.get("macro", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = _first_text(item.get("key")).lower()
|
||||
if key:
|
||||
macro_by_key[key] = item
|
||||
|
||||
macro = []
|
||||
for key, name in (("n", "نیتروژن (N)"), ("p", "فسفر (P)"), ("k", "پتاسیم (K)")):
|
||||
source = macro_by_key.get(key, {})
|
||||
macro.append(
|
||||
{
|
||||
"key": key,
|
||||
"name": name,
|
||||
"value": round(_safe_float(npk_ratio.get(key), 0.0) or 0.0, 3),
|
||||
"unit": "percent",
|
||||
"description": _first_text(source.get("description"), DEFAULT_MACRO_DESCRIPTIONS[key]),
|
||||
}
|
||||
)
|
||||
|
||||
return {"macro": macro, "micro": _normalize_micro_items(llm_analysis.get("micro"))}
|
||||
|
||||
|
||||
def _build_application_guide(
|
||||
llm_guide: dict[str, Any] | None,
|
||||
*,
|
||||
application_method: str,
|
||||
warning_text: str,
|
||||
) -> dict[str, Any]:
|
||||
llm_guide = llm_guide if isinstance(llm_guide, dict) else {}
|
||||
return {
|
||||
"safety_warning": _first_text(llm_guide.get("safety_warning"), warning_text),
|
||||
"steps": _coerce_steps(llm_guide.get("steps"), application_method),
|
||||
}
|
||||
|
||||
|
||||
def _build_alternative_recommendations(
|
||||
llm_alternatives: Any,
|
||||
optimizer_alternatives: list[dict[str, Any]],
|
||||
recommended_strategy: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
llm_items = llm_alternatives if isinstance(llm_alternatives, list) else []
|
||||
alternatives = []
|
||||
|
||||
for index, optimizer_item in enumerate(optimizer_alternatives[:3]):
|
||||
llm_item = llm_items[index] if index < len(llm_items) and isinstance(llm_items[index], dict) else {}
|
||||
formula = _first_text(
|
||||
llm_item.get("fertilizer_code"),
|
||||
optimizer_item.get("fertilizer_type"),
|
||||
recommended_strategy.get("fertilizer_type"),
|
||||
)
|
||||
display_name = _first_text(llm_item.get("fertilizer_name"), _fertilizer_display_name(formula), optimizer_item.get("label"))
|
||||
description = _first_text(
|
||||
llm_item.get("description"),
|
||||
*(optimizer_item.get("reasoning") or []),
|
||||
f"این گزینه با امتیاز {optimizer_item.get('score', 0)} برای شرایط مشابه قابل استفاده است.",
|
||||
)
|
||||
alternatives.append(
|
||||
{
|
||||
"fertilizer_code": _slug_value(formula or optimizer_item.get("code", f"alt-{index + 1}")),
|
||||
"fertilizer_name": display_name,
|
||||
"fertilizer_type": _first_text(llm_item.get("fertilizer_type"), _fertilizer_type_label(formula)),
|
||||
"usage_method": _first_text(
|
||||
llm_item.get("usage_method"),
|
||||
optimizer_item.get("application_method"),
|
||||
recommended_strategy.get("application_method"),
|
||||
),
|
||||
"description": description,
|
||||
}
|
||||
)
|
||||
|
||||
for llm_item in llm_items[len(alternatives):3]:
|
||||
if not isinstance(llm_item, dict):
|
||||
continue
|
||||
fertilizer_name = _first_text(llm_item.get("fertilizer_name"))
|
||||
fertilizer_code = _first_text(llm_item.get("fertilizer_code"), fertilizer_name)
|
||||
if not fertilizer_name or not fertilizer_code:
|
||||
continue
|
||||
alternatives.append(
|
||||
{
|
||||
"fertilizer_code": _slug_value(fertilizer_code),
|
||||
"fertilizer_name": fertilizer_name,
|
||||
"fertilizer_type": _first_text(llm_item.get("fertilizer_type"), "Fertilizer"),
|
||||
"usage_method": _first_text(llm_item.get("usage_method"), recommended_strategy.get("application_method", "")),
|
||||
"description": _first_text(llm_item.get("description"), "گزینه جایگزین در صورت محدودیت تامین یا تغییر شرایط مزرعه."),
|
||||
}
|
||||
)
|
||||
|
||||
return alternatives
|
||||
|
||||
|
||||
def _normalize_llm_payload(parsed_result: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(parsed_result, dict):
|
||||
return {"status": "success", "data": {}}
|
||||
|
||||
if isinstance(parsed_result.get("data"), dict):
|
||||
status = parsed_result.get("status") or "success"
|
||||
return {"status": status, "data": parsed_result["data"]}
|
||||
|
||||
if any(key in parsed_result for key in ("primary_recommendation", "nutrient_analysis", "application_guide")):
|
||||
status = parsed_result.get("status") or "success"
|
||||
return {"status": status, "data": parsed_result}
|
||||
|
||||
sections = parsed_result.get("sections")
|
||||
if isinstance(sections, list):
|
||||
recommendation = next((item for item in sections if isinstance(item, dict) and item.get("type") == "recommendation"), {})
|
||||
list_section = next((item for item in sections if isinstance(item, dict) and item.get("type") == "list"), {})
|
||||
warning = next((item for item in sections if isinstance(item, dict) and item.get("type") == "warning"), {})
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"primary_recommendation": {
|
||||
"display_title": _first_text(recommendation.get("title"), recommendation.get("fertilizerType")),
|
||||
"reasoning": _first_text(recommendation.get("expandableExplanation"), recommendation.get("content")),
|
||||
"summary": _first_text(recommendation.get("content"), recommendation.get("title")),
|
||||
},
|
||||
"application_guide": {
|
||||
"safety_warning": _first_text(warning.get("content")),
|
||||
"steps": list_section.get("items", []),
|
||||
},
|
||||
"alternative_recommendations": [],
|
||||
},
|
||||
}
|
||||
|
||||
return {"status": "success", "data": {}}
|
||||
|
||||
|
||||
def _build_final_response(
|
||||
*,
|
||||
llm_payload: dict[str, Any],
|
||||
optimized_result: dict[str, Any] | None,
|
||||
plant_name: str | None,
|
||||
crop_id: str | None,
|
||||
growth_stage: str | None,
|
||||
forecasts: list[Any],
|
||||
) -> dict[str, Any]:
|
||||
normalized_llm = _normalize_llm_payload(llm_payload)
|
||||
advisory = normalized_llm.get("data", {}) if isinstance(normalized_llm.get("data"), dict) else {}
|
||||
optimizer_payload = optimized_result or _fallback_optimizer_result(growth_stage)
|
||||
recommended = optimizer_payload.get("recommended_strategy", {})
|
||||
|
||||
defaults = apps.get_app_config("fertilization").get_optimizer_defaults()
|
||||
stage_key = _stage_key(growth_stage)
|
||||
stage_target = defaults["stage_targets"].get(stage_key, defaults["stage_targets"]["vegetative"])
|
||||
|
||||
formula = _first_text(recommended.get("fertilizer_type"), stage_target.get("formula"))
|
||||
npk_ratio = _parse_npk_ratio(formula)
|
||||
application_method_label = _first_text(recommended.get("application_method"), stage_target.get("application_method"))
|
||||
amount_kg_per_ha = round(_safe_float(recommended.get("amount_kg_per_ha"), 0.0) or 0.0, 3)
|
||||
amount_per_square_meter = round(amount_kg_per_ha / HECTARE_TO_SQUARE_METER, 6)
|
||||
interval_days = int(
|
||||
stage_target.get(
|
||||
"application_interval_days",
|
||||
defaults.get("default_application_interval_days", 14),
|
||||
)
|
||||
)
|
||||
|
||||
primary_advisory = advisory.get("primary_recommendation") if isinstance(advisory.get("primary_recommendation"), dict) else {}
|
||||
reasoning = _first_text(primary_advisory.get("reasoning"), " ".join(recommended.get("reasoning", [])))
|
||||
if not reasoning:
|
||||
reasoning = "این توصیه با اتکا به مرحله رشد، وضعیت خاک و خروجی بهینه ساز شبیه سازی تنظیم شده است."
|
||||
|
||||
summary = _first_text(primary_advisory.get("summary"))
|
||||
if not summary:
|
||||
summary = f"{_fertilizer_display_name(formula)} برای مرحله {DEFAULT_STAGE_LABELS.get(stage_key, stage_key)} مناسب ارزیابی شده است."
|
||||
|
||||
warning_text = _warning_from_weather(forecasts, application_method_label)
|
||||
nutrient_analysis = _build_nutrient_analysis(advisory.get("nutrient_analysis"), npk_ratio)
|
||||
application_guide = _build_application_guide(
|
||||
advisory.get("application_guide"),
|
||||
application_method=application_method_label,
|
||||
warning_text=warning_text,
|
||||
)
|
||||
alternatives = _build_alternative_recommendations(
|
||||
advisory.get("alternative_recommendations"),
|
||||
optimizer_payload.get("alternatives", []),
|
||||
recommended,
|
||||
)
|
||||
|
||||
structured_data = {
|
||||
"primary_recommendation": {
|
||||
"fertilizer_code": _slug_value(formula),
|
||||
"fertilizer_name": _first_text(primary_advisory.get("fertilizer_name"), _fertilizer_display_name(formula)),
|
||||
"display_title": _first_text(primary_advisory.get("display_title"), _fertilizer_display_name(formula)),
|
||||
"fertilizer_type": _first_text(primary_advisory.get("fertilizer_type"), _fertilizer_type_label(formula)),
|
||||
"npk_ratio": npk_ratio,
|
||||
"application_method": {
|
||||
"id": _method_id(application_method_label),
|
||||
"label": application_method_label,
|
||||
},
|
||||
"application_interval": {
|
||||
"value": interval_days,
|
||||
"unit": "day",
|
||||
"label": f"هر {interval_days} روز",
|
||||
},
|
||||
"dosage": {
|
||||
"base_amount_per_hectare": amount_kg_per_ha,
|
||||
"base_amount_per_square_meter": amount_per_square_meter,
|
||||
"unit": "kg",
|
||||
"label": f"{_normalize_label(amount_kg_per_ha)} کیلوگرم در هکتار",
|
||||
"calculation_basis": optimizer_payload.get("engine", "product"),
|
||||
},
|
||||
"reasoning": reasoning,
|
||||
"summary": summary,
|
||||
},
|
||||
"nutrient_analysis": nutrient_analysis,
|
||||
"application_guide": application_guide,
|
||||
"alternative_recommendations": alternatives,
|
||||
}
|
||||
|
||||
structured_data["sections"] = _build_legacy_sections(structured_data, recommended)
|
||||
return {"status": normalized_llm.get("status") or "success", "data": structured_data}
|
||||
|
||||
|
||||
def _validate_fertilization_response(parsed_result: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(parsed_result, dict):
|
||||
raise ValueError("Fertilization recommendation response is not a JSON object.")
|
||||
data = parsed_result.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Fertilization recommendation response is missing data.")
|
||||
if not isinstance(data.get("primary_recommendation"), dict):
|
||||
raise ValueError("Fertilization recommendation response is missing primary_recommendation.")
|
||||
return parsed_result
|
||||
|
||||
|
||||
def get_fertilization_recommendation(
|
||||
farm_uuid: str | None = None,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
crop_id: str | None = None,
|
||||
query: str | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
limit: int = 8,
|
||||
sensor_uuid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
توصیه کودهی برای یک مزرعه.
|
||||
از RAG با پایگاه دانش fertilization استفاده می کند و خروجی نهایی را با optimizer ترکیب می کند.
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
service = get_service_config(SERVICE_ID, cfg)
|
||||
service_cfg = RAGConfig(
|
||||
embedding=cfg.embedding,
|
||||
qdrant=cfg.qdrant,
|
||||
chunking=cfg.chunking,
|
||||
llm=service.llm,
|
||||
knowledge_bases=cfg.knowledge_bases,
|
||||
services=cfg.services,
|
||||
chromadb=cfg.chromadb,
|
||||
)
|
||||
client = get_chat_client(service_cfg)
|
||||
model = service.llm.model
|
||||
|
||||
resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip()
|
||||
if not resolved_farm_uuid:
|
||||
raise ValueError("farm_uuid is required.")
|
||||
|
||||
user_query = query or "توصیه کودهی بهینه برای مزرعه من چیست؟"
|
||||
|
||||
sensor = (
|
||||
SensorData.objects.select_related("center_location")
|
||||
.prefetch_related("plant_assignments__plant")
|
||||
.filter(farm_uuid=resolved_farm_uuid)
|
||||
.first()
|
||||
)
|
||||
|
||||
plant_config = apps.get_app_config("plant")
|
||||
resolved_plant_name = plant_config.resolve_plant_name(plant_name)
|
||||
if not resolved_plant_name and crop_id:
|
||||
resolved_plant_name = plant_config.resolve_plant_name(crop_id)
|
||||
resolved_growth_stage = plant_config.resolve_growth_stage(growth_stage)
|
||||
|
||||
plant = None
|
||||
if sensor is not None:
|
||||
selected_snapshot = get_farm_plant_snapshot_by_name(sensor, resolved_plant_name)
|
||||
plant = clone_snapshot_as_runtime_plant(
|
||||
selected_snapshot,
|
||||
growth_stage=resolved_growth_stage,
|
||||
)
|
||||
if selected_snapshot is not None:
|
||||
resolved_plant_name = selected_snapshot.name
|
||||
|
||||
forecasts = []
|
||||
optimized_result = None
|
||||
if sensor is not None and getattr(sensor, "center_location", None) is not None:
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
forecasts = list(
|
||||
WeatherForecast.objects.filter(
|
||||
location=sensor.center_location,
|
||||
forecast_date__isnull=False,
|
||||
).order_by("forecast_date")[:7]
|
||||
)
|
||||
if sensor is not None and plant is not None:
|
||||
optimized_result = _get_optimizer().optimize_fertilization(
|
||||
sensor=sensor,
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
growth_stage=resolved_growth_stage,
|
||||
)
|
||||
|
||||
context = build_rag_context(
|
||||
user_query,
|
||||
resolved_farm_uuid,
|
||||
config=cfg,
|
||||
limit=limit,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
)
|
||||
|
||||
extra_parts: list[str] = []
|
||||
if resolved_plant_name and resolved_growth_stage:
|
||||
plant_text = build_plant_text(resolved_plant_name, resolved_growth_stage)
|
||||
if plant_text:
|
||||
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
if optimized_result is not None:
|
||||
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
|
||||
if extra_parts:
|
||||
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
|
||||
|
||||
tone = _load_service_tone(service, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(DEFAULT_FERTILIZATION_PROMPT)
|
||||
if context:
|
||||
system_parts.append("\n\n" + context)
|
||||
system_content = "\n".join(system_parts)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": user_query},
|
||||
]
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=resolved_farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_query,
|
||||
system_prompt=system_content,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
except Exception as exc:
|
||||
logger.error("Fertilization recommendation error for %s: %s", resolved_farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RuntimeError(
|
||||
f"Fertilization recommendation failed for farm {resolved_farm_uuid}."
|
||||
) from exc
|
||||
|
||||
llm_payload = _clean_json_response(raw)
|
||||
result = _build_final_response(
|
||||
llm_payload=llm_payload,
|
||||
optimized_result=optimized_result,
|
||||
plant_name=resolved_plant_name,
|
||||
crop_id=crop_id,
|
||||
growth_stage=resolved_growth_stage,
|
||||
forecasts=forecasts,
|
||||
)
|
||||
result = _validate_fertilization_response(result)
|
||||
result["raw_response"] = raw
|
||||
result["simulation_optimizer"] = optimized_result
|
||||
result["sections"] = result["data"].get("sections", [])
|
||||
_complete_audit_log(
|
||||
audit_log,
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,398 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
_create_audit_log,
|
||||
_fail_audit_log,
|
||||
_load_service_tone,
|
||||
build_rag_context,
|
||||
)
|
||||
from rag.config import RAGConfig, get_service_config, load_rag_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ID = "fertilization_plan_parser"
|
||||
KB_NAME = "fertilization_plan_parser"
|
||||
CORE_FIELDS = [
|
||||
"crop_name",
|
||||
"growth_stage",
|
||||
"fertilizer_name",
|
||||
"formula",
|
||||
"amount",
|
||||
"application_method",
|
||||
"timing",
|
||||
"interval_days",
|
||||
]
|
||||
|
||||
FERTILIZATION_PLAN_PROMPT = (
|
||||
"شما یک تحلیل گر برنامه کودهی هستی. "
|
||||
"کاربر ممکن است برنامه کودهی را کامل یا ناقص توضیح دهد. "
|
||||
"فقط JSON معتبر برگردان و هرگز متن خارج از JSON، markdown یا کلید اضافه تولید نکن. "
|
||||
"اگر اطلاعات کافی بود status را completed بگذار و final_plan را تکمیل کن. "
|
||||
"اگر اطلاعات ناقص بود status را needs_clarification بگذار، missing_fields را پر کن و در questions سوال های کوتاه و دقیق برگردان. "
|
||||
"اگر چند کود در متن بود، همه را در applications لیست کن. "
|
||||
"اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. "
|
||||
"در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. "
|
||||
"از حدس زدن مقدار، زمان یا روش مصرف خودداری کن. "
|
||||
"Schema: "
|
||||
"{"
|
||||
'"status": "completed" | "needs_clarification", '
|
||||
'"summary": string, '
|
||||
'"missing_fields": [string], '
|
||||
'"questions": [{"id": string, "field": string, "question": string, "rationale": string}], '
|
||||
'"collected_data": {'
|
||||
'"crop_name": string|null, '
|
||||
'"growth_stage": string|null, '
|
||||
'"objective": string|null, '
|
||||
'"applications": ['
|
||||
"{"
|
||||
'"fertilizer_name": string|null, '
|
||||
'"formula": string|null, '
|
||||
'"amount": string|null, '
|
||||
'"application_method": string|null, '
|
||||
'"timing": string|null, '
|
||||
'"interval_days": integer|null, '
|
||||
'"purpose": string|null'
|
||||
"}"
|
||||
"], "
|
||||
'"notes": [string]'
|
||||
"}, "
|
||||
'"final_plan": {same shape as collected_data} | null'
|
||||
"}."
|
||||
)
|
||||
|
||||
|
||||
class ClarificationQuestionSchema(BaseModel):
|
||||
id: str
|
||||
field: str
|
||||
question: str
|
||||
rationale: str = ""
|
||||
|
||||
|
||||
class FertilizerApplicationSchema(BaseModel):
|
||||
fertilizer_name: str | None = None
|
||||
formula: str | None = None
|
||||
amount: str | None = None
|
||||
application_method: str | None = None
|
||||
timing: str | None = None
|
||||
interval_days: int | None = None
|
||||
purpose: str | None = None
|
||||
|
||||
|
||||
class FertilizationPlanSchema(BaseModel):
|
||||
crop_name: str | None = None
|
||||
growth_stage: str | None = None
|
||||
objective: str | None = None
|
||||
applications: list[FertilizerApplicationSchema] = Field(default_factory=list)
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FertilizationPlanParseResultSchema(BaseModel):
|
||||
status: Literal["completed", "needs_clarification"]
|
||||
summary: str
|
||||
missing_fields: list[str] = Field(default_factory=list)
|
||||
questions: list[ClarificationQuestionSchema] = Field(default_factory=list)
|
||||
collected_data: FertilizationPlanSchema = Field(default_factory=FertilizationPlanSchema)
|
||||
final_plan: FertilizationPlanSchema | None = None
|
||||
|
||||
|
||||
class FertilizationPlanParserService:
|
||||
def parse_plan(
|
||||
self,
|
||||
*,
|
||||
message: str = "",
|
||||
answers: dict[str, Any] | None = None,
|
||||
partial_plan: dict[str, Any] | None = None,
|
||||
farm_uuid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = self._build_service_client(cfg)
|
||||
|
||||
normalized_message = (message or "").strip()
|
||||
normalized_answers = answers if isinstance(answers, dict) else {}
|
||||
normalized_partial = partial_plan if isinstance(partial_plan, dict) else {}
|
||||
structured_context = {
|
||||
"message": normalized_message,
|
||||
"answers": normalized_answers,
|
||||
"partial_plan": normalized_partial,
|
||||
"required_core_fields": CORE_FIELDS,
|
||||
"service": "fertilization_plan_parser",
|
||||
}
|
||||
|
||||
rag_query = self._build_retrieval_query(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
)
|
||||
rag_context = build_rag_context(
|
||||
query=rag_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
)
|
||||
system_prompt, messages = self._build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
structured_context=structured_context,
|
||||
rag_context=rag_context,
|
||||
)
|
||||
|
||||
audit_log = None
|
||||
if farm_uuid:
|
||||
try:
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=rag_query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Fertilization plan parser audit log creation failed for %s: %s", farm_uuid, exc)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw = (response.choices[0].message.content or "").strip()
|
||||
parsed = self._clean_json(raw)
|
||||
validated = FertilizationPlanParseResultSchema.model_validate(parsed)
|
||||
normalized = self._normalize_result(validated)
|
||||
if audit_log is not None:
|
||||
_complete_audit_log(audit_log, raw)
|
||||
return normalized
|
||||
except (ValidationError, ValueError, KeyError, IndexError) as exc:
|
||||
logger.warning("Fertilization plan parser parsing failed: %s", exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
return self._fallback_result(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
partial_plan=normalized_partial,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Fertilization plan parser failed: %s", exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
return self._fallback_result(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
partial_plan=normalized_partial,
|
||||
)
|
||||
|
||||
def _build_service_client(self, cfg: RAGConfig):
|
||||
service = get_service_config(SERVICE_ID, cfg)
|
||||
service_cfg = RAGConfig(
|
||||
embedding=cfg.embedding,
|
||||
qdrant=cfg.qdrant,
|
||||
chunking=cfg.chunking,
|
||||
llm=service.llm,
|
||||
knowledge_bases=cfg.knowledge_bases,
|
||||
services=cfg.services,
|
||||
chromadb=cfg.chromadb,
|
||||
)
|
||||
client = get_chat_client(service_cfg)
|
||||
return service, client, service.llm.model
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
structured_context: dict[str, Any],
|
||||
rag_context: str,
|
||||
) -> tuple[str, list[dict[str, str]]]:
|
||||
tone = _load_service_tone(service, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(FERTILIZATION_PLAN_PROMPT)
|
||||
system_parts.append(
|
||||
"[structured_context]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
if rag_context:
|
||||
system_parts.append(rag_context)
|
||||
system_prompt = "\n\n".join(part for part in system_parts if part)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": "برنامه کودهی را استخراج یا برای تکمیل آن سوال بپرس."},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
def _build_retrieval_query(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
answers: dict[str, Any],
|
||||
) -> str:
|
||||
answer_lines = [f"{key}: {value}" for key, value in answers.items()]
|
||||
parts = [part for part in [message, "\n".join(answer_lines)] if part]
|
||||
return "\n".join(parts) or "استخراج برنامه کودهی از متن کاربر"
|
||||
|
||||
def _normalize_result(self, validated: FertilizationPlanParseResultSchema) -> dict[str, Any]:
|
||||
collected = validated.collected_data.model_dump()
|
||||
final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None
|
||||
missing_fields = list(dict.fromkeys(validated.missing_fields))
|
||||
computed_missing = self._find_missing_fields(final_plan or collected)
|
||||
for field in computed_missing:
|
||||
if field not in missing_fields:
|
||||
missing_fields.append(field)
|
||||
|
||||
can_complete = validated.status == "completed" and not missing_fields
|
||||
|
||||
if can_complete:
|
||||
final_plan = final_plan or collected
|
||||
questions: list[dict[str, Any]] = []
|
||||
status_fa = "تکمیل شد"
|
||||
else:
|
||||
questions = [item.model_dump() for item in validated.questions]
|
||||
if not questions and missing_fields:
|
||||
questions = self._build_generic_questions(missing_fields)
|
||||
final_plan = None
|
||||
validated.status = "needs_clarification"
|
||||
status_fa = "نیازمند پرسش تکمیلی"
|
||||
|
||||
return {
|
||||
"status": "completed" if can_complete else "needs_clarification",
|
||||
"status_fa": status_fa,
|
||||
"summary": validated.summary,
|
||||
"missing_fields": missing_fields,
|
||||
"questions": questions,
|
||||
"collected_data": collected,
|
||||
"final_plan": final_plan,
|
||||
}
|
||||
|
||||
def _fallback_result(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
answers: dict[str, Any],
|
||||
partial_plan: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
applications = partial_plan.get("applications")
|
||||
if not isinstance(applications, list):
|
||||
applications = []
|
||||
notes = list(partial_plan.get("notes") or [])
|
||||
if message:
|
||||
notes.append(f"متن اولیه کاربر: {message}")
|
||||
if answers:
|
||||
notes.append("پاسخ های تکمیلی کاربر دریافت شده است.")
|
||||
|
||||
return {
|
||||
"status": "needs_clarification",
|
||||
"status_fa": "نیازمند پرسش تکمیلی",
|
||||
"summary": "اطلاعات برنامه کودهی برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.",
|
||||
"missing_fields": CORE_FIELDS,
|
||||
"questions": self._build_generic_questions(CORE_FIELDS),
|
||||
"collected_data": {
|
||||
"crop_name": partial_plan.get("crop_name"),
|
||||
"growth_stage": partial_plan.get("growth_stage"),
|
||||
"objective": partial_plan.get("objective"),
|
||||
"applications": applications,
|
||||
"notes": notes,
|
||||
},
|
||||
"final_plan": None,
|
||||
}
|
||||
|
||||
def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]:
|
||||
catalog = {
|
||||
"crop_name": {
|
||||
"id": "crop_name",
|
||||
"field": "crop_name",
|
||||
"question": "این برنامه کودهی برای کدام محصول است؟",
|
||||
"rationale": "نام محصول برای ثبت برنامه لازم است.",
|
||||
},
|
||||
"growth_stage": {
|
||||
"id": "growth_stage",
|
||||
"field": "growth_stage",
|
||||
"question": "محصول الان در چه مرحله رشدی قرار دارد؟",
|
||||
"rationale": "مرحله رشد برای تکمیل برنامه لازم است.",
|
||||
},
|
||||
"fertilizer_name": {
|
||||
"id": "fertilizer_name",
|
||||
"field": "fertilizer_name",
|
||||
"question": "نام کود یا ترکیب کودی چیست؟",
|
||||
"rationale": "بدون نام کود نمی توان برنامه را نهایی کرد.",
|
||||
},
|
||||
"formula": {
|
||||
"id": "formula",
|
||||
"field": "formula",
|
||||
"question": "فرمول یا آنالیز کود چیست؟ مثلا 20-20-20.",
|
||||
"rationale": "ترکیب دقیق کود هنوز مشخص نشده است.",
|
||||
},
|
||||
"amount": {
|
||||
"id": "amount",
|
||||
"field": "amount",
|
||||
"question": "مقدار مصرف هر نوبت کود چقدر است؟",
|
||||
"rationale": "دوز مصرف در متن مشخص نشده است.",
|
||||
},
|
||||
"application_method": {
|
||||
"id": "application_method",
|
||||
"field": "application_method",
|
||||
"question": "روش مصرف کود چیست؟ مثلا کودآبیاری، سرک یا محلول پاشی.",
|
||||
"rationale": "روش اجرا هنوز معلوم نیست.",
|
||||
},
|
||||
"timing": {
|
||||
"id": "timing",
|
||||
"field": "timing",
|
||||
"question": "زمان مصرف کود چه موقع است؟ مثلا هر 10 روز یا بعد از آبیاری.",
|
||||
"rationale": "زمان بندی برنامه نیاز به شفاف سازی دارد.",
|
||||
},
|
||||
"interval_days": {
|
||||
"id": "interval_days",
|
||||
"field": "interval_days",
|
||||
"question": "فاصله بین نوبت های مصرف کود چند روز است؟",
|
||||
"rationale": "عدد فاصله بین نوبت ها برای JSON نهایی لازم است.",
|
||||
},
|
||||
}
|
||||
return [catalog[field] for field in missing_fields if field in catalog][:5]
|
||||
|
||||
def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]:
|
||||
missing: list[str] = []
|
||||
if not isinstance(plan, dict):
|
||||
return CORE_FIELDS[:]
|
||||
|
||||
if plan.get("crop_name") in (None, ""):
|
||||
missing.append("crop_name")
|
||||
if plan.get("growth_stage") in (None, ""):
|
||||
missing.append("growth_stage")
|
||||
|
||||
applications = plan.get("applications")
|
||||
if not isinstance(applications, list) or not applications:
|
||||
return missing + [
|
||||
field
|
||||
for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"]
|
||||
if field not in missing
|
||||
]
|
||||
|
||||
first_application = applications[0] if isinstance(applications[0], dict) else {}
|
||||
for field in ["fertilizer_name", "formula", "amount", "application_method", "timing", "interval_days"]:
|
||||
value = first_application.get(field)
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
missing.append(field)
|
||||
return missing
|
||||
|
||||
def _clean_json(self, raw: str) -> dict[str, Any]:
|
||||
cleaned = (raw or "").strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Fertilization plan parser response was empty.")
|
||||
parsed = json.loads(cleaned)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Fertilization plan parser response root must be an object.")
|
||||
return parsed
|
||||
@@ -0,0 +1,539 @@
|
||||
"""
|
||||
سرویس توصیه آبیاری — بدون API، قابل فراخوانی از سایر سرویسها
|
||||
از RAG با پایگاه دانش irrigation و لحن مخصوص آبیاری استفاده میکند.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import transaction
|
||||
|
||||
from farm_data.models import SensorData
|
||||
from farm_data.services import (
|
||||
clone_snapshot_as_runtime_plant,
|
||||
get_farm_plant_snapshot_by_name,
|
||||
)
|
||||
from irrigation.evapotranspiration import (
|
||||
calculate_forecast_water_needs,
|
||||
resolve_crop_profile,
|
||||
resolve_kc,
|
||||
)
|
||||
from irrigation.models import IrrigationMethod
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
_create_audit_log,
|
||||
_fail_audit_log,
|
||||
_load_service_tone,
|
||||
build_rag_context,
|
||||
)
|
||||
from rag.config import RAGConfig, get_service_config, load_rag_config
|
||||
from rag.user_data import build_irrigation_method_text, build_plant_text
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "irrigation"
|
||||
SERVICE_ID = "irrigation"
|
||||
|
||||
DEFAULT_IRRIGATION_PROMPT = (
|
||||
"از محاسبات FAO-56 و خروجی بهینه ساز شبیه سازی برای ساخت توصیه آبیاری استفاده کن. "
|
||||
"اگر بلوک [خروجی بهینه ساز شبیه سازی] وجود داشت، همان را مرجع اصلی اعداد قرار بده. "
|
||||
"پاسخ را در قالب JSON معتبر با کلیدهای plan، timeline و sections برگردان و عدد جدید متناقض نساز."
|
||||
)
|
||||
|
||||
|
||||
def _get_optimizer():
|
||||
return apps.get_app_config("crop_simulation").get_recommendation_optimizer()
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _sensor_metric(sensor: SensorData | None, metric: str) -> float | None:
|
||||
if sensor is None or not isinstance(sensor.sensor_payload, dict):
|
||||
return None
|
||||
for payload in sensor.sensor_payload.values():
|
||||
if isinstance(payload, dict) and payload.get(metric) is not None:
|
||||
return _safe_float(payload.get(metric), default=0.0)
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_list(value: Any) -> list[Any]:
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
|
||||
def _coerce_dict(value: Any) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _estimate_duration_minutes(amount_per_event_mm: float, efficiency_percent: float | None) -> int:
|
||||
normalized_efficiency = max(_safe_float(efficiency_percent, 75.0), 30.0)
|
||||
estimated_minutes = round(max(amount_per_event_mm, 1.0) * (2400 / normalized_efficiency))
|
||||
return max(10, min(estimated_minutes, 240))
|
||||
|
||||
|
||||
def _default_warning(
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
soil_moisture: float | None,
|
||||
) -> str:
|
||||
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
|
||||
reasoning = _coerce_list(strategy.get("reasoning"))
|
||||
if reasoning:
|
||||
return str(reasoning[0])
|
||||
if soil_moisture is not None and soil_moisture < 25:
|
||||
return "رطوبت خاک پایین است و نباید آبیاری به تعویق بیفتد."
|
||||
if soil_moisture is not None and soil_moisture > 80:
|
||||
return "رطوبت خاک بالاست و باید از آبیاری اضافی خودداری شود."
|
||||
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
|
||||
return "با توجه به بارش موثر پیش بینی شده، برنامه آبیاری را قبل از اجرا دوباره بررسی کنید."
|
||||
return "در ساعات گرم روز آبیاری انجام نشود."
|
||||
|
||||
|
||||
def _normalize_plan(
|
||||
llm_result: dict[str, Any],
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
irrigation_method: IrrigationMethod | None,
|
||||
soil_moisture: float | None,
|
||||
) -> dict[str, Any]:
|
||||
llm_plan = _coerce_dict(llm_result.get("plan"))
|
||||
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
|
||||
|
||||
frequency = llm_plan.get("frequencyPerWeek")
|
||||
if frequency is None:
|
||||
frequency = strategy.get("frequency_per_week") or strategy.get("events") or len(daily_water_needs) or 1
|
||||
|
||||
duration = llm_plan.get("durationMinutes")
|
||||
if duration is None:
|
||||
duration = _estimate_duration_minutes(
|
||||
_safe_float(strategy.get("amount_per_event_mm"), 6.0),
|
||||
getattr(irrigation_method, "water_efficiency_percent", None),
|
||||
)
|
||||
|
||||
best_time = llm_plan.get("bestTimeOfDay")
|
||||
if not best_time:
|
||||
best_time = strategy.get("timing") or (
|
||||
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "05:30 تا 08:00 صبح"
|
||||
)
|
||||
|
||||
moisture_level = llm_plan.get("moistureLevel")
|
||||
if moisture_level is None:
|
||||
moisture_level = round(
|
||||
soil_moisture
|
||||
if soil_moisture is not None
|
||||
else _safe_float(strategy.get("moisture_target_percent"), 70.0)
|
||||
)
|
||||
|
||||
warning = llm_plan.get("warning")
|
||||
if not warning:
|
||||
warning = _default_warning(optimizer_result, daily_water_needs, soil_moisture)
|
||||
|
||||
return {
|
||||
"frequencyPerWeek": int(max(_safe_float(frequency, 1), 1)),
|
||||
"durationMinutes": int(max(_safe_float(duration, 10), 10)),
|
||||
"bestTimeOfDay": str(best_time),
|
||||
"moistureLevel": int(max(min(_safe_float(moisture_level, 70), 100), 0)),
|
||||
"warning": str(warning),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_timeline(
|
||||
llm_result: dict[str, Any],
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
raw_timeline = _coerce_list(llm_result.get("timeline"))
|
||||
timeline: list[dict[str, Any]] = []
|
||||
|
||||
for index, item in enumerate(raw_timeline, start=1):
|
||||
item_dict = _coerce_dict(item)
|
||||
title = item_dict.get("title")
|
||||
description = item_dict.get("description")
|
||||
if title and description:
|
||||
timeline.append(
|
||||
{
|
||||
"step_number": int(item_dict.get("step_number") or index),
|
||||
"title": str(title),
|
||||
"description": str(description),
|
||||
}
|
||||
)
|
||||
|
||||
if timeline:
|
||||
return timeline
|
||||
|
||||
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
|
||||
event_dates = _coerce_list(strategy.get("event_dates"))
|
||||
best_timing = strategy.get("timing") or (
|
||||
daily_water_needs[0].get("irrigation_timing") if daily_water_needs else "صبح زود"
|
||||
)
|
||||
|
||||
generated = [
|
||||
{
|
||||
"step_number": 1,
|
||||
"title": "بررسی فشار",
|
||||
"description": "فشار ابتدا و انتهای لاین کنترل شود.",
|
||||
},
|
||||
{
|
||||
"step_number": 2,
|
||||
"title": "اجرای آبیاری",
|
||||
"description": f"آبیاری در بازه {best_timing} انجام شود.",
|
||||
},
|
||||
]
|
||||
if event_dates:
|
||||
generated.append(
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "پیگیری برنامه",
|
||||
"description": f"نوبت های پیشنهادی برای تاریخ های {', '.join(map(str, event_dates))} بررسی شوند.",
|
||||
}
|
||||
)
|
||||
else:
|
||||
generated.append(
|
||||
{
|
||||
"step_number": 3,
|
||||
"title": "بازبینی رطوبت",
|
||||
"description": "بعد از هر نوبت، رطوبت خاک و یکنواختی توزیع آب کنترل شود.",
|
||||
}
|
||||
)
|
||||
return generated
|
||||
|
||||
|
||||
def _normalize_sections(
|
||||
llm_result: dict[str, Any],
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
plan_warning: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
raw_sections = _coerce_list(llm_result.get("sections"))
|
||||
sections: list[dict[str, Any]] = []
|
||||
|
||||
for section in raw_sections:
|
||||
item = _coerce_dict(section)
|
||||
section_type = str(item.get("type") or "").strip().lower()
|
||||
if section_type not in {"warning", "tip"}:
|
||||
continue
|
||||
content = item.get("content")
|
||||
title = item.get("title")
|
||||
if not content or not title:
|
||||
continue
|
||||
icon = item.get("icon") or (
|
||||
"tabler-alert-triangle" if section_type == "warning" else "tabler-bulb"
|
||||
)
|
||||
sections.append(
|
||||
{
|
||||
"title": str(title),
|
||||
"icon": str(icon),
|
||||
"type": section_type,
|
||||
"content": str(content),
|
||||
}
|
||||
)
|
||||
|
||||
if not any(item["type"] == "warning" for item in sections):
|
||||
sections.insert(
|
||||
0,
|
||||
{
|
||||
"title": "هشدار آبیاری",
|
||||
"icon": "tabler-alert-triangle",
|
||||
"type": "warning",
|
||||
"content": plan_warning,
|
||||
},
|
||||
)
|
||||
|
||||
if not any(item["type"] == "tip" for item in sections):
|
||||
strategy = _coerce_dict((optimizer_result or {}).get("recommended_strategy"))
|
||||
reasoning = _coerce_list(strategy.get("reasoning"))
|
||||
tip_content = (
|
||||
str(reasoning[-1])
|
||||
if reasoning
|
||||
else "شست وشوی فیلترها و بازبینی یکنواختی پخش آب به پایداری برنامه آبیاری کمک می کند."
|
||||
)
|
||||
if any(_safe_float(item.get("effective_rainfall_mm")) > 0 for item in daily_water_needs):
|
||||
tip_content = "قبل از نوبت بعدی، مقدار بارش موثر و رطوبت خاک را دوباره با برنامه تطبیق دهید."
|
||||
sections.append(
|
||||
{
|
||||
"title": "نکته بهره وری",
|
||||
"icon": "tabler-bulb",
|
||||
"type": "tip",
|
||||
"content": tip_content,
|
||||
}
|
||||
)
|
||||
|
||||
return sections[:4]
|
||||
|
||||
|
||||
def _build_irrigation_ui_payload(
|
||||
llm_result: dict[str, Any],
|
||||
optimizer_result: dict[str, Any] | None,
|
||||
daily_water_needs: list[dict[str, Any]],
|
||||
crop_profile: dict[str, Any],
|
||||
active_kc: float,
|
||||
irrigation_method: IrrigationMethod | None,
|
||||
sensor: SensorData | None,
|
||||
) -> dict[str, Any]:
|
||||
soil_moisture = _sensor_metric(sensor, "soil_moisture")
|
||||
plan = _normalize_plan(
|
||||
llm_result,
|
||||
optimizer_result,
|
||||
daily_water_needs,
|
||||
irrigation_method,
|
||||
soil_moisture,
|
||||
)
|
||||
payload = {
|
||||
"plan": plan,
|
||||
"water_balance": {
|
||||
"daily": daily_water_needs,
|
||||
"crop_profile": crop_profile,
|
||||
"active_kc": active_kc,
|
||||
},
|
||||
"timeline": _normalize_timeline(llm_result, optimizer_result, daily_water_needs),
|
||||
"sections": _normalize_sections(
|
||||
llm_result,
|
||||
optimizer_result,
|
||||
daily_water_needs,
|
||||
plan["warning"],
|
||||
),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _resolve_irrigation_method(
|
||||
sensor: SensorData | None,
|
||||
irrigation_method_name: str | None,
|
||||
) -> IrrigationMethod | None:
|
||||
if irrigation_method_name:
|
||||
return IrrigationMethod.objects.filter(name=irrigation_method_name).first()
|
||||
if sensor is not None:
|
||||
return sensor.irrigation_method
|
||||
return None
|
||||
|
||||
|
||||
def _persist_irrigation_method_on_farm(
|
||||
sensor: SensorData | None,
|
||||
irrigation_method: IrrigationMethod | None,
|
||||
) -> None:
|
||||
if sensor is None or irrigation_method is None:
|
||||
return
|
||||
if sensor.irrigation_method_id == irrigation_method.id:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
sensor.irrigation_method = irrigation_method
|
||||
sensor.save(update_fields=["irrigation_method", "updated_at"])
|
||||
|
||||
|
||||
def get_irrigation_recommendation(
|
||||
farm_uuid: str | None = None,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
irrigation_method_name: str | None = None,
|
||||
query: str | None = None,
|
||||
config: RAGConfig | None = None,
|
||||
limit: int = 8,
|
||||
sensor_uuid: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
توصیه آبیاری برای یک مزرعه.
|
||||
از RAG با پایگاه دانش irrigation استفاده میکند.
|
||||
|
||||
Args:
|
||||
farm_uuid: شناسه مزرعه
|
||||
plant_name: نام گیاه (برای بارگذاری مشخصات از جدول Plant)
|
||||
growth_stage: مرحله رشد گیاه
|
||||
irrigation_method_name: نام روش آبیاری (برای بارگذاری از جدول IrrigationMethod)
|
||||
query: سوال اختیاری
|
||||
config: تنظیمات RAG
|
||||
limit: تعداد چانکهای بازیابیشده
|
||||
|
||||
Returns:
|
||||
dict ساختاریافته برای توصیه آبیاری
|
||||
"""
|
||||
cfg = config or load_rag_config()
|
||||
service = get_service_config(SERVICE_ID, cfg)
|
||||
service_cfg = RAGConfig(
|
||||
embedding=cfg.embedding,
|
||||
qdrant=cfg.qdrant,
|
||||
chunking=cfg.chunking,
|
||||
llm=service.llm,
|
||||
knowledge_bases=cfg.knowledge_bases,
|
||||
services=cfg.services,
|
||||
chromadb=cfg.chromadb,
|
||||
)
|
||||
client = get_chat_client(service_cfg)
|
||||
model = service.llm.model
|
||||
|
||||
resolved_farm_uuid = str(farm_uuid or sensor_uuid or "").strip()
|
||||
if not resolved_farm_uuid:
|
||||
raise ValueError("farm_uuid is required.")
|
||||
|
||||
user_query = query or "توصیه آبیاری برای مزرعه من چیست؟"
|
||||
|
||||
sensor = (
|
||||
SensorData.objects.select_related("center_location", "irrigation_method")
|
||||
.prefetch_related("plant_assignments__plant")
|
||||
.filter(farm_uuid=resolved_farm_uuid)
|
||||
.first()
|
||||
)
|
||||
irrigation_method = _resolve_irrigation_method(sensor, irrigation_method_name)
|
||||
_persist_irrigation_method_on_farm(sensor, irrigation_method)
|
||||
|
||||
plant = None
|
||||
resolved_plant_name = plant_name
|
||||
if sensor is not None:
|
||||
selected_snapshot = get_farm_plant_snapshot_by_name(sensor, plant_name)
|
||||
plant = clone_snapshot_as_runtime_plant(
|
||||
selected_snapshot,
|
||||
growth_stage=growth_stage,
|
||||
)
|
||||
if selected_snapshot is not None:
|
||||
resolved_plant_name = selected_snapshot.name
|
||||
elif plant_name:
|
||||
resolved_plant_name = plant_name
|
||||
|
||||
crop_profile = resolve_crop_profile(plant, growth_stage=growth_stage)
|
||||
active_kc = resolve_kc(crop_profile, growth_stage=growth_stage)
|
||||
forecasts = []
|
||||
daily_water_needs = []
|
||||
optimized_result = None
|
||||
if sensor is not None:
|
||||
forecasts = list(
|
||||
WeatherForecast.objects.filter(
|
||||
location=sensor.center_location,
|
||||
forecast_date__isnull=False,
|
||||
).order_by("forecast_date")[:7]
|
||||
)
|
||||
efficiency_percent = (
|
||||
getattr(irrigation_method, "water_efficiency_percent", None)
|
||||
if irrigation_method
|
||||
else None
|
||||
)
|
||||
daily_water_needs = calculate_forecast_water_needs(
|
||||
forecasts=forecasts,
|
||||
latitude_deg=float(sensor.center_location.latitude),
|
||||
crop_profile=crop_profile,
|
||||
growth_stage=growth_stage,
|
||||
irrigation_efficiency_percent=efficiency_percent,
|
||||
)
|
||||
if plant is not None and forecasts:
|
||||
optimized_result = _get_optimizer().optimize_irrigation(
|
||||
sensor=sensor,
|
||||
plant=plant,
|
||||
forecasts=forecasts,
|
||||
daily_water_needs=daily_water_needs,
|
||||
growth_stage=growth_stage,
|
||||
irrigation_method=irrigation_method,
|
||||
)
|
||||
|
||||
context = build_rag_context(
|
||||
user_query,
|
||||
resolved_farm_uuid,
|
||||
config=cfg,
|
||||
limit=limit,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
)
|
||||
|
||||
extra_parts: list[str] = []
|
||||
resolved_irrigation_method_name = irrigation_method.name if irrigation_method is not None else None
|
||||
if resolved_plant_name and growth_stage:
|
||||
plant_text = build_plant_text(resolved_plant_name, growth_stage)
|
||||
if plant_text:
|
||||
extra_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
if resolved_irrigation_method_name:
|
||||
method_text = build_irrigation_method_text(resolved_irrigation_method_name)
|
||||
if method_text:
|
||||
extra_parts.append("[روش آبیاری انتخابی]\n" + method_text)
|
||||
if daily_water_needs:
|
||||
total_mm = round(sum(item["gross_irrigation_mm"] for item in daily_water_needs), 2)
|
||||
schedule_lines = [
|
||||
f"- {item['forecast_date']}: ET0={item['et0_mm']} mm, ETc={item['etc_mm']} mm, "
|
||||
f"بارش مؤثر={item['effective_rainfall_mm']} mm, نیاز آبی={item['gross_irrigation_mm']} mm, "
|
||||
f"زمان پیشنهادی={item['irrigation_timing']}"
|
||||
for item in daily_water_needs
|
||||
]
|
||||
extra_parts.append(
|
||||
"[خروجی قطعی محاسبات FAO-56]\n"
|
||||
f"کل نیاز آبی ۷ روز آینده: {total_mm} mm\n"
|
||||
f"Kc مورد استفاده: {active_kc}\n"
|
||||
+ "\n".join(schedule_lines)
|
||||
)
|
||||
if optimized_result is not None:
|
||||
extra_parts.append("[خروجی بهینه ساز شبیه سازی]\n" + optimized_result["context_text"])
|
||||
if extra_parts:
|
||||
context = "\n\n---\n\n".join(extra_parts) + ("\n\n---\n\n" + context if context else "")
|
||||
|
||||
tone = _load_service_tone(service, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(DEFAULT_IRRIGATION_PROMPT)
|
||||
if context:
|
||||
system_parts.append("\n\n" + context)
|
||||
system_content = "\n".join(system_parts)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": system_content},
|
||||
{"role": "user", "content": user_query},
|
||||
]
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=resolved_farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_query,
|
||||
system_prompt=system_content,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
except Exception as exc:
|
||||
logger.error("Irrigation recommendation error for %s: %s", resolved_farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
raise RuntimeError(
|
||||
f"Irrigation recommendation failed for farm {resolved_farm_uuid}."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
cleaned = raw
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`").removeprefix("json").strip()
|
||||
llm_result = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
llm_result = {}
|
||||
|
||||
result = _build_irrigation_ui_payload(
|
||||
_coerce_dict(llm_result),
|
||||
optimized_result,
|
||||
daily_water_needs,
|
||||
crop_profile,
|
||||
active_kc,
|
||||
irrigation_method,
|
||||
sensor,
|
||||
)
|
||||
result["raw_response"] = raw
|
||||
result["simulation_optimizer"] = optimized_result
|
||||
result["selected_irrigation_method"] = (
|
||||
{
|
||||
"id": irrigation_method.id,
|
||||
"name": irrigation_method.name,
|
||||
"category": irrigation_method.category,
|
||||
"water_efficiency_percent": irrigation_method.water_efficiency_percent,
|
||||
}
|
||||
if irrigation_method is not None
|
||||
else None
|
||||
)
|
||||
_complete_audit_log(
|
||||
audit_log,
|
||||
json.dumps(result, ensure_ascii=False, default=str),
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
_create_audit_log,
|
||||
_fail_audit_log,
|
||||
_load_service_tone,
|
||||
build_rag_context,
|
||||
)
|
||||
from rag.config import RAGConfig, get_service_config, load_rag_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ID = "irrigation_plan_parser"
|
||||
KB_NAME = "irrigation_plan_parser"
|
||||
CORE_FIELDS = [
|
||||
"crop_name",
|
||||
"growth_stage",
|
||||
"irrigation_method",
|
||||
"water_amount_per_event",
|
||||
"duration_minutes",
|
||||
"frequency_text",
|
||||
"interval_days",
|
||||
"preferred_time_of_day",
|
||||
"start_date",
|
||||
"target_area",
|
||||
]
|
||||
|
||||
IRRIGATION_PLAN_PROMPT = (
|
||||
"شما یک تحلیل گر برنامه آبیاری هستی. "
|
||||
"کاربر ممکن است برنامه آبیاری را کامل یا ناقص توضیح دهد. "
|
||||
"وظیفه شما این است که فقط JSON معتبر برگردانی و متن اضافه، markdown، توضیح بیرون از JSON یا کلید اضافه تولید نکنی. "
|
||||
"اگر اطلاعات کافی بود status را completed بگذار و final_plan را کامل کن. "
|
||||
"اگر اطلاعات کافی نبود status را needs_clarification بگذار، missing_fields را پر کن و 1 تا 5 سوال کوتاه و دقیق در questions برگردان. "
|
||||
"اگر هرکدام از فیلدهای اصلی خالی، null یا نامشخص بود، حق نداری status را completed بگذاری. "
|
||||
"در حالت completed هیچ فیلد null در collected_data و final_plan نباید وجود داشته باشد. "
|
||||
"از حدس زدن جزئیات برنامه خودداری کن. "
|
||||
"اگر کاربر فقط بخشی از سوالات قبلی را جواب داد، داده های جدید را با partial_plan ادغام کن و فقط سوالات باقی مانده را بپرس. "
|
||||
"Schema: "
|
||||
"{"
|
||||
'"status": "completed" | "needs_clarification", '
|
||||
'"summary": string, '
|
||||
'"missing_fields": [string], '
|
||||
'"questions": [{"id": string, "field": string, "question": string, "rationale": string}], '
|
||||
'"collected_data": {'
|
||||
'"crop_name": string|null, '
|
||||
'"growth_stage": string|null, '
|
||||
'"irrigation_method": string|null, '
|
||||
'"water_amount_per_event": string|null, '
|
||||
'"duration_minutes": integer|null, '
|
||||
'"frequency_text": string|null, '
|
||||
'"interval_days": integer|null, '
|
||||
'"preferred_time_of_day": string|null, '
|
||||
'"start_date": string|null, '
|
||||
'"target_area": string|null, '
|
||||
'"trigger_conditions": [string], '
|
||||
'"notes": [string]'
|
||||
"}, "
|
||||
'"final_plan": {same shape as collected_data} | null'
|
||||
"}."
|
||||
)
|
||||
|
||||
|
||||
class ClarificationQuestionSchema(BaseModel):
|
||||
id: str
|
||||
field: str
|
||||
question: str
|
||||
rationale: str = ""
|
||||
|
||||
|
||||
class IrrigationPlanSchema(BaseModel):
|
||||
crop_name: str | None = None
|
||||
growth_stage: str | None = None
|
||||
irrigation_method: str | None = None
|
||||
water_amount_per_event: str | None = None
|
||||
duration_minutes: int | None = None
|
||||
frequency_text: str | None = None
|
||||
interval_days: int | None = None
|
||||
preferred_time_of_day: str | None = None
|
||||
start_date: str | None = None
|
||||
target_area: str | None = None
|
||||
trigger_conditions: list[str] = Field(default_factory=list)
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class IrrigationPlanParseResultSchema(BaseModel):
|
||||
status: Literal["completed", "needs_clarification"]
|
||||
summary: str
|
||||
missing_fields: list[str] = Field(default_factory=list)
|
||||
questions: list[ClarificationQuestionSchema] = Field(default_factory=list)
|
||||
collected_data: IrrigationPlanSchema = Field(default_factory=IrrigationPlanSchema)
|
||||
final_plan: IrrigationPlanSchema | None = None
|
||||
|
||||
|
||||
class IrrigationPlanParserService:
|
||||
def parse_plan(
|
||||
self,
|
||||
*,
|
||||
message: str = "",
|
||||
answers: dict[str, Any] | None = None,
|
||||
partial_plan: dict[str, Any] | None = None,
|
||||
farm_uuid: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = self._build_service_client(cfg)
|
||||
|
||||
normalized_message = (message or "").strip()
|
||||
normalized_answers = answers if isinstance(answers, dict) else {}
|
||||
normalized_partial = partial_plan if isinstance(partial_plan, dict) else {}
|
||||
structured_context = {
|
||||
"message": normalized_message,
|
||||
"answers": normalized_answers,
|
||||
"partial_plan": normalized_partial,
|
||||
"required_core_fields": CORE_FIELDS,
|
||||
"service": "irrigation_plan_parser",
|
||||
}
|
||||
|
||||
rag_query = self._build_retrieval_query(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
)
|
||||
rag_context = build_rag_context(
|
||||
query=rag_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
)
|
||||
system_prompt, messages = self._build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
structured_context=structured_context,
|
||||
rag_context=rag_context,
|
||||
)
|
||||
|
||||
audit_log = None
|
||||
if farm_uuid:
|
||||
try:
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=rag_query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Irrigation plan parser audit log creation failed for %s: %s", farm_uuid, exc)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw = (response.choices[0].message.content or "").strip()
|
||||
parsed = self._clean_json(raw)
|
||||
validated = IrrigationPlanParseResultSchema.model_validate(parsed)
|
||||
normalized = self._normalize_result(validated)
|
||||
if audit_log is not None:
|
||||
_complete_audit_log(audit_log, raw)
|
||||
return normalized
|
||||
except (ValidationError, ValueError, KeyError, IndexError) as exc:
|
||||
logger.warning("Irrigation plan parser parsing failed: %s", exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
return self._fallback_result(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
partial_plan=normalized_partial,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Irrigation plan parser failed: %s", exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
return self._fallback_result(
|
||||
message=normalized_message,
|
||||
answers=normalized_answers,
|
||||
partial_plan=normalized_partial,
|
||||
)
|
||||
|
||||
def _build_service_client(self, cfg: RAGConfig):
|
||||
service = get_service_config(SERVICE_ID, cfg)
|
||||
service_cfg = RAGConfig(
|
||||
embedding=cfg.embedding,
|
||||
qdrant=cfg.qdrant,
|
||||
chunking=cfg.chunking,
|
||||
llm=service.llm,
|
||||
knowledge_bases=cfg.knowledge_bases,
|
||||
services=cfg.services,
|
||||
chromadb=cfg.chromadb,
|
||||
)
|
||||
client = get_chat_client(service_cfg)
|
||||
return service, client, service.llm.model
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
structured_context: dict[str, Any],
|
||||
rag_context: str,
|
||||
) -> tuple[str, list[dict[str, str]]]:
|
||||
tone = _load_service_tone(service, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(IRRIGATION_PLAN_PROMPT)
|
||||
system_parts.append(
|
||||
"[structured_context]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
if rag_context:
|
||||
system_parts.append(rag_context)
|
||||
system_prompt = "\n\n".join(part for part in system_parts if part)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": "برنامه آبیاری را استخراج یا برای تکمیل آن سوال بپرس."},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
def _build_retrieval_query(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
answers: dict[str, Any],
|
||||
) -> str:
|
||||
answer_lines = [f"{key}: {value}" for key, value in answers.items()]
|
||||
parts = [part for part in [message, "\n".join(answer_lines)] if part]
|
||||
return "\n".join(parts) or "استخراج برنامه آبیاری از متن کاربر"
|
||||
|
||||
def _normalize_result(self, validated: IrrigationPlanParseResultSchema) -> dict[str, Any]:
|
||||
collected = validated.collected_data.model_dump()
|
||||
final_plan = validated.final_plan.model_dump() if validated.final_plan is not None else None
|
||||
missing_fields = list(dict.fromkeys(validated.missing_fields))
|
||||
computed_missing = self._find_missing_fields(final_plan or collected)
|
||||
for field in computed_missing:
|
||||
if field not in missing_fields:
|
||||
missing_fields.append(field)
|
||||
|
||||
can_complete = validated.status == "completed" and not missing_fields
|
||||
|
||||
if can_complete:
|
||||
final_plan = final_plan or collected
|
||||
questions: list[dict[str, Any]] = []
|
||||
status_fa = "تکمیل شد"
|
||||
else:
|
||||
questions = [item.model_dump() for item in validated.questions]
|
||||
if not questions and missing_fields:
|
||||
questions = self._build_generic_questions(missing_fields)
|
||||
final_plan = None
|
||||
validated.status = "needs_clarification"
|
||||
status_fa = "نیازمند پرسش تکمیلی"
|
||||
|
||||
return {
|
||||
"status": "completed" if can_complete else "needs_clarification",
|
||||
"status_fa": status_fa,
|
||||
"summary": validated.summary,
|
||||
"missing_fields": missing_fields,
|
||||
"questions": questions,
|
||||
"collected_data": collected,
|
||||
"final_plan": final_plan,
|
||||
}
|
||||
|
||||
def _fallback_result(
|
||||
self,
|
||||
*,
|
||||
message: str,
|
||||
answers: dict[str, Any],
|
||||
partial_plan: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
merged = dict(partial_plan)
|
||||
notes = list(merged.get("notes") or [])
|
||||
if message:
|
||||
notes.append(f"متن اولیه کاربر: {message}")
|
||||
for key, value in answers.items():
|
||||
merged.setdefault(key, value)
|
||||
|
||||
return {
|
||||
"status": "needs_clarification",
|
||||
"status_fa": "نیازمند پرسش تکمیلی",
|
||||
"summary": "اطلاعات برنامه آبیاری برای ساخت JSON نهایی کافی نیست و به چند پاسخ تکمیلی نیاز است.",
|
||||
"missing_fields": CORE_FIELDS,
|
||||
"questions": self._build_generic_questions(CORE_FIELDS),
|
||||
"collected_data": {
|
||||
"crop_name": merged.get("crop_name"),
|
||||
"growth_stage": merged.get("growth_stage"),
|
||||
"irrigation_method": merged.get("irrigation_method"),
|
||||
"water_amount_per_event": merged.get("water_amount_per_event"),
|
||||
"duration_minutes": merged.get("duration_minutes"),
|
||||
"frequency_text": merged.get("frequency_text"),
|
||||
"interval_days": merged.get("interval_days"),
|
||||
"preferred_time_of_day": merged.get("preferred_time_of_day"),
|
||||
"start_date": merged.get("start_date"),
|
||||
"target_area": merged.get("target_area"),
|
||||
"trigger_conditions": merged.get("trigger_conditions") or [],
|
||||
"notes": notes,
|
||||
},
|
||||
"final_plan": None,
|
||||
}
|
||||
|
||||
def _build_generic_questions(self, missing_fields: list[str]) -> list[dict[str, str]]:
|
||||
catalog = {
|
||||
"crop_name": {
|
||||
"id": "crop_name",
|
||||
"field": "crop_name",
|
||||
"question": "این برنامه آبیاری برای کدام محصول است؟",
|
||||
"rationale": "نام محصول برای ثبت برنامه لازم است.",
|
||||
},
|
||||
"growth_stage": {
|
||||
"id": "growth_stage",
|
||||
"field": "growth_stage",
|
||||
"question": "محصول الان در چه مرحله رشدی قرار دارد؟",
|
||||
"rationale": "مرحله رشد برای کامل شدن برنامه لازم است.",
|
||||
},
|
||||
"irrigation_method": {
|
||||
"id": "irrigation_method",
|
||||
"field": "irrigation_method",
|
||||
"question": "روش آبیاری چیست؟ مثلا قطره ای، بارانی یا غرقابی.",
|
||||
"rationale": "روش اجرا روی شکل برنامه تاثیر دارد.",
|
||||
},
|
||||
"water_amount_per_event": {
|
||||
"id": "water_amount_per_event",
|
||||
"field": "water_amount_per_event",
|
||||
"question": "در هر نوبت آبیاری چه مقدار آب داده می شود؟",
|
||||
"rationale": "حجم یا عمق آب هر نوبت مشخص نشده است.",
|
||||
},
|
||||
"duration_minutes": {
|
||||
"id": "duration_minutes",
|
||||
"field": "duration_minutes",
|
||||
"question": "مدت زمان هر نوبت آبیاری چند دقیقه است؟",
|
||||
"rationale": "مدت اجرای هر نوبت هنوز مشخص نیست.",
|
||||
},
|
||||
"frequency_text": {
|
||||
"id": "frequency_text",
|
||||
"field": "frequency_text",
|
||||
"question": "فاصله یا تعداد نوبت های آبیاری چگونه است؟ مثلا هر 3 روز یک بار.",
|
||||
"rationale": "الگوی تکرار آبیاری باید مشخص باشد.",
|
||||
},
|
||||
"interval_days": {
|
||||
"id": "interval_days",
|
||||
"field": "interval_days",
|
||||
"question": "فاصله بین دو آبیاری چند روز است؟",
|
||||
"rationale": "عدد فاصله آبیاری برای JSON نهایی لازم است.",
|
||||
},
|
||||
"preferred_time_of_day": {
|
||||
"id": "preferred_time_of_day",
|
||||
"field": "preferred_time_of_day",
|
||||
"question": "بهترین زمان اجرای آبیاری چه موقع از روز است؟",
|
||||
"rationale": "زمان اجرای برنامه هنوز معلوم نیست.",
|
||||
},
|
||||
"start_date": {
|
||||
"id": "start_date",
|
||||
"field": "start_date",
|
||||
"question": "این برنامه از چه تاریخی یا از چه زمانی باید شروع شود؟",
|
||||
"rationale": "زمان شروع برنامه هنوز مشخص نشده است.",
|
||||
},
|
||||
"target_area": {
|
||||
"id": "target_area",
|
||||
"field": "target_area",
|
||||
"question": "این برنامه برای کل مزرعه است یا بخش/ناحیه خاصی از مزرعه؟",
|
||||
"rationale": "محدوده اجرای برنامه باید مشخص باشد.",
|
||||
},
|
||||
}
|
||||
return [catalog[field] for field in missing_fields if field in catalog][:5]
|
||||
|
||||
def _find_missing_fields(self, plan: dict[str, Any]) -> list[str]:
|
||||
missing: list[str] = []
|
||||
for field in CORE_FIELDS:
|
||||
value = plan.get(field)
|
||||
if value is None:
|
||||
missing.append(field)
|
||||
continue
|
||||
if isinstance(value, str) and not value.strip():
|
||||
missing.append(field)
|
||||
return missing
|
||||
|
||||
def _clean_json(self, raw: str) -> dict[str, Any]:
|
||||
cleaned = (raw or "").strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Irrigation plan parser response was empty.")
|
||||
parsed = json.loads(cleaned)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError("Irrigation plan parser response root must be an object.")
|
||||
return parsed
|
||||
@@ -0,0 +1,470 @@
|
||||
"""
|
||||
سرویس RAG برای تشخیص تصویری و پیش بینی ریسک آفات و بیماری گیاه.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_build_content_parts,
|
||||
_complete_audit_log,
|
||||
_create_audit_log,
|
||||
_fail_audit_log,
|
||||
_load_service_tone,
|
||||
build_rag_context,
|
||||
)
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "pest_disease"
|
||||
SERVICE_ID = "pest_disease"
|
||||
|
||||
DETECTION_PROMPT = (
|
||||
"شما یک دستیار تخصصی تشخیص آفات و بیماری گیاهی هستی. "
|
||||
"با استفاده از تصویر، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش تحلیل کن. "
|
||||
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: "
|
||||
"has_issue, category, confidence, severity, summary, detected_signs, possible_causes, immediate_actions, reasoning. "
|
||||
"category فقط یکی از no_issue, pest, disease, nutrient_stress, abiotic_stress, unknown باشد. "
|
||||
"severity فقط یکی از low, medium, high باشد."
|
||||
)
|
||||
|
||||
RISK_PROMPT = (
|
||||
"شما یک دستیار تخصصی پیش بینی ریسک آفات و بیماری گیاهی هستی. "
|
||||
"با استفاده از داده های مزرعه، آب و هوا، مرحله رشد، و متن های بازیابی شده از پایگاه دانش تحلیل کن. "
|
||||
"پاسخ فقط JSON معتبر باشد و این کلیدها را داشته باشد: "
|
||||
"summary, forecast_window, overall_risk, disease_risk, pest_risk, key_drivers, recommended_actions. "
|
||||
"overall_risk فقط یکی از low, medium, high باشد. "
|
||||
"disease_risk و pest_risk باید آبجکت هایی با کلیدهای score, level, likely_conditions, reasoning باشند و level فقط یکی از low, medium, high باشد."
|
||||
)
|
||||
|
||||
|
||||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value in (None, ""):
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _normalize_images(images: list[dict[str, str]] | None) -> list[dict[str, str]]:
|
||||
output: list[dict[str, str]] = []
|
||||
for item in images or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
url = item.get("url")
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
continue
|
||||
output.append({"url": url.strip(), "detail": item.get("detail", "auto")})
|
||||
return output
|
||||
|
||||
|
||||
def _clean_json(raw: str) -> dict[str, Any]:
|
||||
cleaned = (raw or "").strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
if not cleaned:
|
||||
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:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("Invalid JSON returned by pest_disease LLM: %s", cleaned[:500])
|
||||
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]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
|
||||
source="farm_data",
|
||||
details={"farm_uuid": farm_uuid},
|
||||
http_status=404,
|
||||
)
|
||||
return farm_details
|
||||
|
||||
|
||||
def _build_service_client(cfg: RAGConfig):
|
||||
service = get_service_config(SERVICE_ID, cfg)
|
||||
service_cfg = RAGConfig(
|
||||
embedding=cfg.embedding,
|
||||
qdrant=cfg.qdrant,
|
||||
chunking=cfg.chunking,
|
||||
llm=service.llm,
|
||||
knowledge_bases=cfg.knowledge_bases,
|
||||
services=cfg.services,
|
||||
chromadb=cfg.chromadb,
|
||||
)
|
||||
client = get_chat_client(service_cfg)
|
||||
return service, client, service.llm.model
|
||||
|
||||
|
||||
def _weather_risk_summary(farm_details: dict[str, Any]) -> dict[str, Any]:
|
||||
weather = farm_details.get("weather") or {}
|
||||
soil = (farm_details.get("soil") or {}).get("resolved_metrics") or {}
|
||||
humidity = _safe_float(weather.get("humidity_mean"), 55.0)
|
||||
temp = _safe_float(weather.get("temperature_mean"), 24.0)
|
||||
rain = _safe_float(weather.get("precipitation"), 0.0)
|
||||
moisture = _safe_float(soil.get("soil_moisture"), _safe_float(soil.get("wv0033"), 35.0))
|
||||
ec = _safe_float(soil.get("electrical_conductivity"), 0.0)
|
||||
ph = _safe_float(soil.get("soil_ph") or soil.get("phh2o"), 7.0)
|
||||
|
||||
fungal_score = min(max(round((humidity * 0.45) + (moisture * 0.35) + (rain * 2.5) - 25, 2), 0.0), 100.0)
|
||||
pest_score = min(max(round((temp * 2.2) + max(0.0, 45.0 - moisture) + (ec * 3.0) - 20, 2), 0.0), 100.0)
|
||||
abiotic_stress = min(max(round((abs(ph - 6.8) * 18.0) + (ec * 8.0), 2), 0.0), 100.0)
|
||||
return {
|
||||
"humidity_mean": humidity,
|
||||
"temperature_mean": temp,
|
||||
"precipitation": rain,
|
||||
"soil_moisture": moisture,
|
||||
"ec": ec,
|
||||
"ph": ph,
|
||||
"fungal_score": fungal_score,
|
||||
"pest_score": pest_score,
|
||||
"abiotic_stress_score": abiotic_stress,
|
||||
}
|
||||
|
||||
|
||||
def _risk_level(score: float) -> str:
|
||||
if score >= 70:
|
||||
return "high"
|
||||
if score >= 40:
|
||||
return "medium"
|
||||
return "low"
|
||||
|
||||
|
||||
def _build_risk_context(farm_details: dict[str, Any], plant_name: str | None, growth_stage: str | None) -> dict[str, Any]:
|
||||
risk = _weather_risk_summary(farm_details)
|
||||
disease_level = _risk_level(risk["fungal_score"])
|
||||
pest_level = _risk_level(risk["pest_score"])
|
||||
overall_score = max(risk["fungal_score"], risk["pest_score"], risk["abiotic_stress_score"])
|
||||
overall_level = _risk_level(overall_score)
|
||||
drivers = []
|
||||
if risk["humidity_mean"] >= 70:
|
||||
drivers.append("رطوبت بالا")
|
||||
if risk["soil_moisture"] >= 60:
|
||||
drivers.append("رطوبت خاک بالا")
|
||||
if risk["temperature_mean"] >= 30:
|
||||
drivers.append("دمای بالا")
|
||||
if risk["precipitation"] > 2:
|
||||
drivers.append("بارش موثر")
|
||||
if risk["ec"] > 2.5:
|
||||
drivers.append("EC بالا")
|
||||
if abs(risk["ph"] - 6.8) > 0.8:
|
||||
drivers.append("خروج pH از محدوده مطلوب")
|
||||
if not drivers:
|
||||
drivers.append("شرایط فعلی مزرعه نسبتا پایدار است")
|
||||
|
||||
return {
|
||||
"summary": "برآورد ریسک آفات و بیماری بر اساس داده های فعلی مزرعه ساخته شد.",
|
||||
"forecast_window": "24 تا 72 ساعت آینده",
|
||||
"overall_risk": overall_level,
|
||||
"disease_risk": {
|
||||
"score": risk["fungal_score"],
|
||||
"level": disease_level,
|
||||
"likely_conditions": [
|
||||
"فشار قارچی و بیماری برگی" if disease_level != "low" else "ریسک بیماری فعلا پایین است",
|
||||
],
|
||||
"reasoning": [
|
||||
f"رطوبت میانگین حدود {risk['humidity_mean']} درصد است.",
|
||||
f"رطوبت خاک حدود {risk['soil_moisture']} درصد برآورد شده است.",
|
||||
],
|
||||
},
|
||||
"pest_risk": {
|
||||
"score": risk["pest_score"],
|
||||
"level": pest_level,
|
||||
"likely_conditions": [
|
||||
"فشار آفات مکنده یا تنش زا" if pest_level != "low" else "ریسک آفت فعلا پایین است",
|
||||
],
|
||||
"reasoning": [
|
||||
f"دمای میانگین حدود {risk['temperature_mean']} درجه است.",
|
||||
f"EC فعلی حدود {risk['ec']} و pH حدود {risk['ph']} است.",
|
||||
],
|
||||
},
|
||||
"key_drivers": drivers,
|
||||
"recommended_actions": [
|
||||
"بازدید مزرعه و بررسی برگ ها و پشت برگ انجام شود.",
|
||||
"در صورت مشاهده علائم مشکوک، نمونه برداری تصویری نزدیک تر انجام شود.",
|
||||
"رطوبت ماندگار و یکنواختی آبیاری پایش شود.",
|
||||
],
|
||||
"farm_context": {
|
||||
"plant_name": plant_name,
|
||||
"growth_stage": growth_stage,
|
||||
"risk_summary": risk,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _validate_detection_result(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
required_keys = {
|
||||
"has_issue",
|
||||
"category",
|
||||
"confidence",
|
||||
"severity",
|
||||
"summary",
|
||||
"detected_signs",
|
||||
"possible_causes",
|
||||
"immediate_actions",
|
||||
"reasoning",
|
||||
}
|
||||
missing = [key for key in required_keys if key not in parsed]
|
||||
if missing:
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
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
|
||||
|
||||
|
||||
def _validate_risk_result(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
required_keys = {
|
||||
"summary",
|
||||
"forecast_window",
|
||||
"overall_risk",
|
||||
"disease_risk",
|
||||
"pest_risk",
|
||||
"key_drivers",
|
||||
"recommended_actions",
|
||||
}
|
||||
missing = [key for key in required_keys if key not in parsed]
|
||||
if missing:
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
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
|
||||
|
||||
|
||||
def _build_detection_messages(
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
query: str,
|
||||
rag_context: str,
|
||||
plant_text: str,
|
||||
images: list[dict[str, str]],
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
tone = _load_service_tone(service, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(DETECTION_PROMPT)
|
||||
if plant_text:
|
||||
system_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
if rag_context:
|
||||
system_parts.append(rag_context)
|
||||
system_prompt = "\n\n".join(part for part in system_parts if part)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": _build_content_parts(query, images)},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
|
||||
def _build_risk_messages(
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
query: str,
|
||||
rag_context: str,
|
||||
structured_context: dict[str, Any],
|
||||
plant_text: str,
|
||||
) -> tuple[str, list[dict[str, str]]]:
|
||||
tone = _load_service_tone(service, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(RISK_PROMPT)
|
||||
if plant_text:
|
||||
system_parts.append("[اطلاعات گیاه]\n" + plant_text)
|
||||
system_parts.append("[کانتکست ساختاریافته ریسک]\n" + json.dumps(structured_context, ensure_ascii=False, indent=2, default=str))
|
||||
if rag_context:
|
||||
system_parts.append(rag_context)
|
||||
system_prompt = "\n\n".join(part for part in system_parts if part)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": query},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
|
||||
def get_pest_disease_detection(
|
||||
*,
|
||||
farm_uuid: str,
|
||||
plant_name: str | None = None,
|
||||
query: str | None = None,
|
||||
images: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
normalized_images = _normalize_images(images)
|
||||
if not normalized_images:
|
||||
raise RAGServiceError(
|
||||
error_code="missing_images",
|
||||
message="حداقل یک تصویر برای تشخیص لازم است.",
|
||||
source="request",
|
||||
http_status=400,
|
||||
)
|
||||
|
||||
cfg = load_rag_config()
|
||||
service, client, model = _build_service_client(cfg)
|
||||
farm_details = _load_farm_or_error(farm_uuid)
|
||||
resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name")
|
||||
user_query = query or "این تصویر را بررسی کن و بگو آیا گیاه دچار آفت یا بیماری شده است یا نه."
|
||||
plant_text = build_plant_text(resolved_plant_name, "") if resolved_plant_name else ""
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
system_prompt, messages = _build_detection_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
query=user_query,
|
||||
rag_context=rag_context,
|
||||
plant_text=plant_text or "",
|
||||
images=normalized_images,
|
||||
)
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
try:
|
||||
response = client.chat.completions.create(model=model, messages=messages)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
parsed = _clean_json(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:
|
||||
logger.error("Pest disease detection failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(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["status"] = "success"
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
return parsed
|
||||
|
||||
|
||||
def get_pest_disease_risk(
|
||||
*,
|
||||
farm_uuid: str,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
query: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = _build_service_client(cfg)
|
||||
farm_details = _load_farm_or_error(farm_uuid)
|
||||
resolved_plant_name = plant_name or (farm_details.get("plants") or [{}])[0].get("name")
|
||||
risk_context = _build_risk_context(farm_details, resolved_plant_name, growth_stage)
|
||||
user_query = query or "ریسک آفات و بیماری این مزرعه را برای چند روز آینده پیش بینی کن."
|
||||
plant_text = build_plant_text(resolved_plant_name, growth_stage or "") if resolved_plant_name else ""
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
system_prompt, messages = _build_risk_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
query=user_query,
|
||||
rag_context=rag_context,
|
||||
structured_context=risk_context,
|
||||
plant_text=plant_text or "",
|
||||
)
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
try:
|
||||
response = client.chat.completions.create(model=model, messages=messages)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
parsed = _clean_json(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:
|
||||
logger.error("Pest disease risk prediction failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(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["status"] = "success"
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
return parsed
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
_create_audit_log,
|
||||
_fail_audit_log,
|
||||
_load_service_tone,
|
||||
build_rag_context,
|
||||
)
|
||||
from rag.config import RAGConfig, get_service_config, load_rag_config
|
||||
from rag.failure_contract import RAGServiceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "soil_anomaly"
|
||||
SERVICE_ID = "soil_anomaly"
|
||||
|
||||
SOIL_ANOMALY_PROMPT = (
|
||||
"شما یک دستیار تخصصی تحلیل ناهنجاری داده های خاک و سنسور مزرعه هستی. "
|
||||
"ورودی شامل داده های ساختاریافته ناهنجاری، اطلاعات مزرعه، و متن های بازیابی شده از پایگاه دانش است. "
|
||||
"فقط JSON معتبر برگردان و فقط این کلیدها را تولید کن: "
|
||||
"summary, explanation, likely_cause, recommended_action, monitoring_priority, confidence. "
|
||||
"monitoring_priority فقط یکی از low, medium, high, urgent باشد. "
|
||||
"confidence عددی بین 0 و 1 باشد. "
|
||||
"اگر ناهنجاری معناداری وجود ندارد، این موضوع را شفاف و بدون اغراق بیان کن."
|
||||
)
|
||||
|
||||
|
||||
def _clean_json(raw: str) -> dict[str, Any]:
|
||||
cleaned = (raw or "").strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
if not cleaned:
|
||||
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:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("Invalid JSON returned by soil_anomaly LLM: %s", cleaned[:500])
|
||||
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]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
|
||||
source="farm_data",
|
||||
details={"farm_uuid": farm_uuid},
|
||||
http_status=404,
|
||||
)
|
||||
return farm_details
|
||||
|
||||
|
||||
def _build_service_client(cfg: RAGConfig):
|
||||
service = get_service_config(SERVICE_ID, cfg)
|
||||
service_cfg = RAGConfig(
|
||||
embedding=cfg.embedding,
|
||||
qdrant=cfg.qdrant,
|
||||
chunking=cfg.chunking,
|
||||
llm=service.llm,
|
||||
knowledge_bases=cfg.knowledge_bases,
|
||||
services=cfg.services,
|
||||
chromadb=cfg.chromadb,
|
||||
)
|
||||
client = get_chat_client(service_cfg)
|
||||
return service, client, service.llm.model
|
||||
|
||||
|
||||
def _validate_anomaly_insight(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
required_keys = {
|
||||
"summary",
|
||||
"explanation",
|
||||
"likely_cause",
|
||||
"recommended_action",
|
||||
"monitoring_priority",
|
||||
"confidence",
|
||||
}
|
||||
missing = [key for key in required_keys if key not in parsed]
|
||||
if missing:
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
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
|
||||
|
||||
|
||||
def _build_messages(
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
query: str,
|
||||
rag_context: str,
|
||||
structured_context: dict[str, Any],
|
||||
) -> tuple[str, list[dict[str, str]]]:
|
||||
tone = _load_service_tone(service, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(SOIL_ANOMALY_PROMPT)
|
||||
system_parts.append(
|
||||
"[کانتکست ساختاریافته ناهنجاري خاک]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
if rag_context:
|
||||
system_parts.append(rag_context)
|
||||
system_prompt = "\n\n".join(part for part in system_parts if part)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": query},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
|
||||
def get_soil_anomaly_insight(
|
||||
*,
|
||||
farm_uuid: str,
|
||||
anomaly_payload: dict[str, Any],
|
||||
query: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = _build_service_client(cfg)
|
||||
farm_details = _load_farm_or_error(farm_uuid)
|
||||
user_query = query or "ناهنجاري هاي داده هاي خاک اين مزرعه را تفسير کن و اقدام مناسب پيشنهاد بده."
|
||||
structured_context = {
|
||||
"farm_uuid": farm_uuid,
|
||||
"anomaly_payload": anomaly_payload,
|
||||
}
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
system_prompt, messages = _build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
query=user_query,
|
||||
rag_context=rag_context,
|
||||
structured_context=structured_context,
|
||||
)
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
try:
|
||||
response = client.chat.completions.create(model=model, messages=messages)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
parsed = _clean_json(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:
|
||||
logger.error("Soil anomaly insight failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(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["status"] = "success"
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
return parsed
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from farm_data.services import get_farm_details
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
_create_audit_log,
|
||||
_fail_audit_log,
|
||||
_load_service_tone,
|
||||
build_rag_context,
|
||||
)
|
||||
from rag.config import RAGConfig, get_service_config, load_rag_config
|
||||
from rag.failure_contract import RAGServiceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KB_NAME = "water_need_prediction"
|
||||
SERVICE_ID = "water_need_prediction"
|
||||
|
||||
WATER_NEED_PROMPT = (
|
||||
"شما یک دستیار تخصصی تحليل نياز آبي کوتاه مدت مزرعه هستي. "
|
||||
"ورودي شامل محاسبات ساختاريافته نياز آبي، اطلاعات مزرعه و متن هاي بازيابي شده از پايگاه دانش است. "
|
||||
"فقط JSON معتبر با اين کليدها برگردان: "
|
||||
"summary, irrigation_outlook, recommended_action, risk_note, confidence. "
|
||||
"confidence عددي بين 0 و 1 باشد. "
|
||||
"اعداد اصلي را از داده ورودي بگير و عدد متناقض جديد نساز."
|
||||
)
|
||||
|
||||
|
||||
def _clean_json(raw: str) -> dict[str, Any]:
|
||||
cleaned = (raw or "").strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
if not cleaned:
|
||||
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:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
logger.warning("Invalid JSON returned by water_need_prediction LLM: %s", cleaned[:500])
|
||||
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]:
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
if farm_details is None:
|
||||
raise RAGServiceError(
|
||||
error_code="farm_not_found",
|
||||
message="farm_uuid نامعتبر است یا اطلاعات مزرعه پیدا نشد.",
|
||||
source="farm_data",
|
||||
details={"farm_uuid": farm_uuid},
|
||||
http_status=404,
|
||||
)
|
||||
return farm_details
|
||||
|
||||
|
||||
def _build_service_client(cfg: RAGConfig):
|
||||
service = get_service_config(SERVICE_ID, cfg)
|
||||
service_cfg = RAGConfig(
|
||||
embedding=cfg.embedding,
|
||||
qdrant=cfg.qdrant,
|
||||
chunking=cfg.chunking,
|
||||
llm=service.llm,
|
||||
knowledge_bases=cfg.knowledge_bases,
|
||||
services=cfg.services,
|
||||
chromadb=cfg.chromadb,
|
||||
)
|
||||
client = get_chat_client(service_cfg)
|
||||
return service, client, service.llm.model
|
||||
|
||||
|
||||
def _validate_prediction_insight(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
required_keys = {
|
||||
"summary",
|
||||
"irrigation_outlook",
|
||||
"recommended_action",
|
||||
"risk_note",
|
||||
"confidence",
|
||||
}
|
||||
missing = [key for key in required_keys if key not in parsed]
|
||||
if missing:
|
||||
raise RAGServiceError(
|
||||
error_code="invalid_schema",
|
||||
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
|
||||
|
||||
|
||||
def _build_messages(
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
query: str,
|
||||
rag_context: str,
|
||||
structured_context: dict[str, Any],
|
||||
) -> tuple[str, list[dict[str, str]]]:
|
||||
tone = _load_service_tone(service, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(WATER_NEED_PROMPT)
|
||||
system_parts.append(
|
||||
"[کانتکست ساختاريافته نياز آبي]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
if rag_context:
|
||||
system_parts.append(rag_context)
|
||||
system_prompt = "\n\n".join(part for part in system_parts if part)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": query},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
|
||||
def get_water_need_prediction_insight(
|
||||
*,
|
||||
farm_uuid: str,
|
||||
prediction_payload: dict[str, Any],
|
||||
query: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = _build_service_client(cfg)
|
||||
farm_details = _load_farm_or_error(farm_uuid)
|
||||
user_query = query or "نياز آبي کوتاه مدت اين مزرعه را تفسير کن و اقدام عملياتي پيشنهاد بده."
|
||||
structured_context = {
|
||||
"farm_uuid": farm_uuid,
|
||||
"prediction_payload": prediction_payload,
|
||||
}
|
||||
rag_context = build_rag_context(
|
||||
query=user_query,
|
||||
sensor_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
kb_name=KB_NAME,
|
||||
service_id=SERVICE_ID,
|
||||
farm_details=farm_details,
|
||||
)
|
||||
system_prompt, messages = _build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
query=user_query,
|
||||
rag_context=rag_context,
|
||||
structured_context=structured_context,
|
||||
)
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_query,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
try:
|
||||
response = client.chat.completions.create(model=model, messages=messages)
|
||||
raw = response.choices[0].message.content.strip()
|
||||
parsed = _clean_json(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:
|
||||
logger.error("Water need prediction insight failed for %s: %s", farm_uuid, exc)
|
||||
_fail_audit_log(audit_log, str(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["status"] = "success"
|
||||
parsed["source"] = "llm"
|
||||
parsed["farm_uuid"] = farm_uuid
|
||||
parsed["raw_response"] = raw
|
||||
return parsed
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from rag.api_provider import get_chat_client
|
||||
from rag.chat import (
|
||||
_complete_audit_log,
|
||||
_create_audit_log,
|
||||
_fail_audit_log,
|
||||
_load_service_tone,
|
||||
)
|
||||
from rag.config import RAGConfig, get_service_config, load_rag_config
|
||||
from rag.failure_contract import RAGServiceError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ID = "yield_harvest"
|
||||
|
||||
YIELD_HARVEST_PROMPT = (
|
||||
"You are an expert agronomist writing concise dashboard narratives for farmers. "
|
||||
"Return only valid JSON matching this schema exactly: "
|
||||
"{"
|
||||
'"season_highlights_subtitle": string, '
|
||||
'"yield_prediction_explanation": string, '
|
||||
'"harvest_readiness_summary": string, '
|
||||
'"operation_notes": [string, ...]'
|
||||
"}. "
|
||||
"Do not add markdown, explanations, or extra keys. "
|
||||
"Strict Golden Rule: do not invent numbers, dates, prices, revenues, percentages, KPIs, scores, or measurements. "
|
||||
"Use only values already present in the deterministic context. "
|
||||
"If a fact is missing from the context, say less rather than guessing."
|
||||
)
|
||||
|
||||
|
||||
class YieldHarvestNarrativeSchema(BaseModel):
|
||||
season_highlights_subtitle: str
|
||||
yield_prediction_explanation: str
|
||||
harvest_readiness_summary: str
|
||||
operation_notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class YieldHarvestRAGService:
|
||||
def generate_narrative(
|
||||
self,
|
||||
deterministic_context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_rag_config()
|
||||
service, client, model = self._build_service_client(cfg)
|
||||
structured_context = self._build_structured_context(
|
||||
deterministic_context=deterministic_context,
|
||||
)
|
||||
user_prompt = (
|
||||
"Generate short user-friendly narrative fields for the Yield & Harvest Summary dashboard "
|
||||
"using only the deterministic context. Keep the language practical and agronomy-focused."
|
||||
)
|
||||
system_prompt, messages = self._build_messages(
|
||||
service=service,
|
||||
cfg=cfg,
|
||||
structured_context=structured_context,
|
||||
query=user_prompt,
|
||||
)
|
||||
|
||||
farm_uuid = str(deterministic_context.get("farm_uuid") or "")
|
||||
audit_log = None
|
||||
if farm_uuid:
|
||||
try:
|
||||
audit_log = _create_audit_log(
|
||||
farm_uuid=farm_uuid,
|
||||
service_id=SERVICE_ID,
|
||||
model=model,
|
||||
query=user_prompt,
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Yield harvest audit log creation failed for %s: %s", farm_uuid, exc)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
raw = (response.choices[0].message.content or "").strip()
|
||||
parsed = self._clean_json(raw)
|
||||
validated = YieldHarvestNarrativeSchema.model_validate(parsed)
|
||||
if audit_log is not None:
|
||||
_complete_audit_log(audit_log, raw)
|
||||
return {
|
||||
"status": "success",
|
||||
"source": "llm",
|
||||
"season_highlights_subtitle": validated.season_highlights_subtitle,
|
||||
"yield_prediction_explanation": validated.yield_prediction_explanation,
|
||||
"harvest_readiness_summary": validated.harvest_readiness_summary,
|
||||
"operation_notes": validated.operation_notes,
|
||||
}
|
||||
except (ValidationError, ValueError, KeyError, IndexError) as exc:
|
||||
logger.warning("Yield harvest narrative parsing failed for farm_uuid=%s: %s", farm_uuid, exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
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:
|
||||
logger.error("Yield harvest narrative LLM call failed for farm_uuid=%s: %s", farm_uuid, exc)
|
||||
if audit_log is not None:
|
||||
_fail_audit_log(audit_log, str(exc))
|
||||
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):
|
||||
service = get_service_config(SERVICE_ID, cfg)
|
||||
service_cfg = RAGConfig(
|
||||
embedding=cfg.embedding,
|
||||
qdrant=cfg.qdrant,
|
||||
chunking=cfg.chunking,
|
||||
llm=service.llm,
|
||||
knowledge_bases=cfg.knowledge_bases,
|
||||
services=cfg.services,
|
||||
chromadb=cfg.chromadb,
|
||||
)
|
||||
client = get_chat_client(service_cfg)
|
||||
return service, client, service.llm.model
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
*,
|
||||
service: Any,
|
||||
cfg: RAGConfig,
|
||||
structured_context: dict[str, Any],
|
||||
query: str,
|
||||
) -> tuple[str, list[dict[str, str]]]:
|
||||
tone = _load_service_tone(service, cfg)
|
||||
system_parts = [tone] if tone else []
|
||||
if service.system_prompt:
|
||||
system_parts.append(service.system_prompt)
|
||||
system_parts.append(YIELD_HARVEST_PROMPT)
|
||||
system_parts.append(
|
||||
"[deterministic_context]\n"
|
||||
+ json.dumps(structured_context, ensure_ascii=False, indent=2, default=str)
|
||||
)
|
||||
system_prompt = "\n\n".join(part for part in system_parts if part)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": query},
|
||||
]
|
||||
return system_prompt, messages
|
||||
|
||||
def _build_structured_context(
|
||||
self,
|
||||
*,
|
||||
deterministic_context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
season = deterministic_context.get("season_highlights_card") or {}
|
||||
harvest = deterministic_context.get("harvest_prediction_card") or {}
|
||||
operations = deterministic_context.get("harvest_operations_card") or {}
|
||||
yield_prediction = deterministic_context.get("yield_prediction") or {}
|
||||
readiness = deterministic_context.get("harvest_readiness_zones") or {}
|
||||
|
||||
operation_steps = []
|
||||
for step in operations.get("steps") or []:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
operation_steps.append(
|
||||
{
|
||||
"key": step.get("key"),
|
||||
"title": step.get("title"),
|
||||
"status": step.get("status"),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"farm_context": deterministic_context.get("farm_context") or {},
|
||||
"yield_prediction": {
|
||||
"predicted_yield_tons": yield_prediction.get("predicted_yield_tons"),
|
||||
"unit": yield_prediction.get("unit"),
|
||||
"simulation_warning": yield_prediction.get("simulation_warning"),
|
||||
"supporting_metrics": yield_prediction.get("supporting_metrics"),
|
||||
},
|
||||
"season_highlights_card": {
|
||||
"title": season.get("title"),
|
||||
"subtitle": season.get("subtitle"),
|
||||
"total_predicted_yield": season.get("total_predicted_yield"),
|
||||
"yield_unit": season.get("yield_unit"),
|
||||
"target_harvest_date": season.get("target_harvest_date"),
|
||||
"days_until_harvest": season.get("days_until_harvest"),
|
||||
"average_readiness": season.get("average_readiness"),
|
||||
"primary_quality_grade": season.get("primary_quality_grade"),
|
||||
"estimated_revenue": season.get("estimated_revenue"),
|
||||
},
|
||||
"harvest_prediction_card": {
|
||||
"harvest_date": harvest.get("harvest_date"),
|
||||
"harvest_date_formatted": harvest.get("harvest_date_formatted"),
|
||||
"days_until": harvest.get("days_until"),
|
||||
"optimal_window_start": harvest.get("optimal_window_start"),
|
||||
"optimal_window_end": harvest.get("optimal_window_end"),
|
||||
"description": harvest.get("description"),
|
||||
},
|
||||
"harvest_readiness_zones": {
|
||||
"average_readiness": readiness.get("averageReadiness"),
|
||||
"mean_ndvi": readiness.get("meanNdvi"),
|
||||
"ndvi_trend": readiness.get("ndviTrend"),
|
||||
"zones": readiness.get("zones"),
|
||||
},
|
||||
"harvest_operations_card": {
|
||||
"stage_label": operations.get("stage_label"),
|
||||
"days_until_harvest": operations.get("days_until_harvest"),
|
||||
"current_dvs": operations.get("current_dvs"),
|
||||
"summary": operations.get("summary"),
|
||||
"steps": operation_steps,
|
||||
},
|
||||
}
|
||||
|
||||
def _clean_json(self, raw: str) -> dict[str, Any]:
|
||||
cleaned = (raw or "").strip()
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = cleaned.strip("`")
|
||||
if cleaned.startswith("json"):
|
||||
cleaned = cleaned[4:]
|
||||
cleaned = cleaned.strip()
|
||||
if not cleaned:
|
||||
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:
|
||||
parsed = json.loads(cleaned)
|
||||
except (json.JSONDecodeError, ValueError) as 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):
|
||||
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
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
تسکهای Celery برای RAG
|
||||
"""
|
||||
from config.celery import app
|
||||
|
||||
from .ingest import ingest
|
||||
|
||||
|
||||
@app.task
|
||||
def rag_ingest_task(recreate: bool = True):
|
||||
"""
|
||||
embed و ذخیره دیتای همه کاربران در Qdrant.
|
||||
هر چند ساعت یکبار اجرا شود (از طریق Celery Beat).
|
||||
recreate=True: collection از نو ساخته میشود تا دیتای قدیمی حذف شود.
|
||||
"""
|
||||
result = ingest(recreate=recreate)
|
||||
return result
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def irrigation_recommendation_task(
|
||||
self,
|
||||
farm_uuid: str,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
irrigation_method_name: str | None = None,
|
||||
query: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
تسک Celery برای تولید توصیه آبیاری.
|
||||
دادههای سنسور، گیاه و روش آبیاری را از DB بارگذاری کرده
|
||||
و از سرویس RAG توصیه میگیرد.
|
||||
"""
|
||||
from rag.services.irrigation import get_irrigation_recommendation
|
||||
|
||||
self.update_state(
|
||||
state="PROGRESS",
|
||||
meta={"message": "در حال پردازش توصیه آبیاری..."},
|
||||
)
|
||||
result = get_irrigation_recommendation(
|
||||
farm_uuid=farm_uuid,
|
||||
plant_name=plant_name,
|
||||
growth_stage=growth_stage,
|
||||
irrigation_method_name=irrigation_method_name,
|
||||
query=query,
|
||||
)
|
||||
result["status"] = "completed"
|
||||
return result
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def fertilization_recommendation_task(
|
||||
self,
|
||||
farm_uuid: str,
|
||||
plant_name: str | None = None,
|
||||
growth_stage: str | None = None,
|
||||
query: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
تسک Celery برای تولید توصیه کودهی.
|
||||
دادههای سنسور و گیاه را از DB بارگذاری کرده
|
||||
و از سرویس RAG توصیه میگیرد.
|
||||
"""
|
||||
from rag.services.fertilization import get_fertilization_recommendation
|
||||
|
||||
self.update_state(
|
||||
state="PROGRESS",
|
||||
meta={"message": "در حال پردازش توصیه کودهی..."},
|
||||
)
|
||||
result = get_fertilization_recommendation(
|
||||
farm_uuid=farm_uuid,
|
||||
plant_name=plant_name,
|
||||
growth_stage=growth_stage,
|
||||
query=query,
|
||||
)
|
||||
result["status"] = "completed"
|
||||
return result
|
||||
@@ -0,0 +1,71 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from rag.chat import _normalize_history_messages, build_rag_context
|
||||
|
||||
|
||||
class ChatContextTests(SimpleTestCase):
|
||||
@patch("rag.chat.search_with_texts")
|
||||
@patch("rag.chat.chunk_text")
|
||||
def test_build_rag_context_includes_full_farm_and_kb_results(
|
||||
self,
|
||||
mock_chunk_text,
|
||||
mock_search_with_texts,
|
||||
):
|
||||
mock_chunk_text.return_value = ["farm chunk 1", "farm chunk 2"]
|
||||
mock_search_with_texts.return_value = [
|
||||
{"id": "kb-1", "score": 0.8, "text": "kb text 1", "metadata": {}},
|
||||
{"id": "kb-2", "score": 0.7, "text": "kb text 2", "metadata": {}},
|
||||
]
|
||||
|
||||
context = build_rag_context(
|
||||
query="وضعیت مزرعه چطور است؟",
|
||||
sensor_uuid="farm-123",
|
||||
service_id="chat",
|
||||
farm_details={"sensor_payload": {"sensor-7-1": {"soil_moisture": 30}}},
|
||||
)
|
||||
|
||||
self.assertIn("[اطلاعات کامل مزرعه]", context)
|
||||
self.assertIn("soil_moisture", context)
|
||||
self.assertIn("[متنهای مرجع]", context)
|
||||
self.assertIn("kb text 1", context)
|
||||
self.assertIn("kb text 2", context)
|
||||
mock_search_with_texts.assert_called_once()
|
||||
sent_texts = mock_search_with_texts.call_args.kwargs["texts"]
|
||||
self.assertEqual(sent_texts[0], "وضعیت مزرعه چطور است؟")
|
||||
self.assertIn("farm chunk 1", sent_texts)
|
||||
self.assertIn("farm chunk 2", sent_texts)
|
||||
|
||||
def test_normalize_history_messages_supports_user_images(self):
|
||||
messages = _normalize_history_messages(
|
||||
[
|
||||
{"role": "user", "content": "این تصویر مزرعه است", "image_urls": ["https://example.com/a.jpg"]},
|
||||
{"role": "assistant", "content": "تصویر دریافت شد."},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(len(messages), 2)
|
||||
self.assertEqual(messages[0]["role"], "user")
|
||||
self.assertIsInstance(messages[0]["content"], list)
|
||||
self.assertEqual(messages[0]["content"][0]["type"], "text")
|
||||
self.assertEqual(messages[0]["content"][1]["type"], "image_url")
|
||||
self.assertEqual(messages[1]["role"], "assistant")
|
||||
self.assertEqual(messages[1]["content"], "تصویر دریافت شد.")
|
||||
|
||||
@patch("rag.chat.search_with_texts", return_value=[])
|
||||
@patch("rag.chat.chunk_text", return_value=["farm chunk"])
|
||||
def test_build_rag_context_returns_full_farm_when_kb_empty(
|
||||
self,
|
||||
_mock_chunk_text,
|
||||
_mock_search_with_texts,
|
||||
):
|
||||
context = build_rag_context(
|
||||
query="رطوبت چقدر است؟",
|
||||
sensor_uuid="farm-123",
|
||||
service_id="chat",
|
||||
farm_details={"sensor_payload": {"sensor-7-1": {"soil_moisture": 30}}},
|
||||
)
|
||||
|
||||
self.assertIn("[اطلاعات کامل مزرعه]", context)
|
||||
self.assertIn("soil_moisture", context)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,387 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from farm_data.models import PlantCatalogSnapshot, SensorData
|
||||
from farm_data.services import assign_farm_plants_from_backend_ids
|
||||
from irrigation.models import IrrigationMethod
|
||||
from location_data.models import SoilLocation
|
||||
from rag.services.fertilization import get_fertilization_recommendation
|
||||
from rag.services.irrigation import get_irrigation_recommendation
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
|
||||
class RecommendationServiceDefaultsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.location = SoilLocation.objects.create(
|
||||
latitude="35.700000",
|
||||
longitude="51.400000",
|
||||
farm_boundary={"type": "Polygon", "coordinates": []},
|
||||
)
|
||||
WeatherForecast.objects.create(
|
||||
location=self.location,
|
||||
forecast_date=date(2026, 4, 10),
|
||||
temperature_min=12.0,
|
||||
temperature_max=23.0,
|
||||
temperature_mean=18.0,
|
||||
)
|
||||
self.plant = PlantCatalogSnapshot.objects.create(backend_plant_id=101, name="گوجهفرنگی")
|
||||
self.onion = PlantCatalogSnapshot.objects.create(backend_plant_id=102, name="پیاز")
|
||||
self.irrigation_method = IrrigationMethod.objects.create(name="آبیاری قطرهای")
|
||||
self.farm_uuid = uuid.uuid4()
|
||||
self.farm = SensorData.objects.create(
|
||||
farm_uuid=self.farm_uuid,
|
||||
center_location=self.location,
|
||||
irrigation_method=self.irrigation_method,
|
||||
sensor_payload={
|
||||
"sensor-7-1": {
|
||||
"soil_moisture": 30.0,
|
||||
"nitrogen": 18.0,
|
||||
"phosphorus": 12.0,
|
||||
"potassium": 14.0,
|
||||
"soil_ph": 6.9,
|
||||
}
|
||||
},
|
||||
)
|
||||
assign_farm_plants_from_backend_ids(self.farm, [self.plant.backend_plant_id])
|
||||
|
||||
def build_irrigation_optimizer_result(self):
|
||||
return {
|
||||
"engine": "crop_simulation_heuristic",
|
||||
"context_text": "optimizer irrigation context",
|
||||
"recommended_strategy": {
|
||||
"code": "balanced",
|
||||
"label": "آبیاری متعادل",
|
||||
"score": 88.0,
|
||||
"expected_yield_index": 91.0,
|
||||
"total_irrigation_mm": 24.0,
|
||||
"amount_per_event_mm": 8.0,
|
||||
"events": 3,
|
||||
"frequency_per_week": 3,
|
||||
"event_dates": ["2026-04-10"],
|
||||
"timing": "اوایل صبح",
|
||||
"moisture_target_percent": 70,
|
||||
"validity_period": "معتبر برای 3 روز آینده",
|
||||
"reasoning": ["شبیه ساز این سناریو را برتر ارزیابی کرد."],
|
||||
},
|
||||
"alternatives": [
|
||||
{
|
||||
"code": "protective",
|
||||
"label": "آبیاری حمایتی",
|
||||
"score": 80.0,
|
||||
"expected_yield_index": 85.0,
|
||||
"total_irrigation_mm": 28.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def build_irrigation_llm_result(self):
|
||||
return (
|
||||
'{"plan": {"frequencyPerWeek": 3, "durationMinutes": 42, "bestTimeOfDay": "اوایل صبح", '
|
||||
'"moistureLevel": 68, "warning": "بررسی شود"}, '
|
||||
'"timeline": [{"step_number": 1, "title": "بازبینی", "description": "لاین ها بررسی شوند"}], '
|
||||
'"sections": [{"type": "warning", "title": "هشدار", "icon": "alert-triangle", "content": "بررسی شود"}, '
|
||||
'{"type": "tip", "title": "نکته", "icon": "bulb", "content": "مورد سفارشی"}]}'
|
||||
)
|
||||
|
||||
def build_fertilization_optimizer_result(self):
|
||||
return {
|
||||
"engine": "crop_simulation_heuristic",
|
||||
"context_text": "optimizer fertilization context",
|
||||
"recommended_strategy": {
|
||||
"code": "balanced",
|
||||
"label": "تغذیه متعادل",
|
||||
"score": 84.0,
|
||||
"expected_yield_index": 88.0,
|
||||
"fertilizer_type": "20-20-20",
|
||||
"amount_kg_per_ha": 65.0,
|
||||
"application_method": "کودآبیاری",
|
||||
"timing": "صبح زود",
|
||||
"validity_period": "معتبر برای 7 روز آینده",
|
||||
"reasoning": ["کسری عناصر با این سناریو بهتر پوشش داده می شود."],
|
||||
},
|
||||
"alternatives": [
|
||||
{
|
||||
"code": "maintenance",
|
||||
"label": "تغذیه نگهدارنده",
|
||||
"score": 72.0,
|
||||
"expected_yield_index": 78.0,
|
||||
"fertilizer_type": "20-20-20",
|
||||
"amount_kg_per_ha": 45.0,
|
||||
"application_method": "کودآبیاری",
|
||||
"timing": "صبح زود",
|
||||
"reasoning": ["برای نگهداری تعادل تغذیه ای گزینه سبک تری است."],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@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_uses_farm_relations_when_request_omits_names(
|
||||
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,
|
||||
):
|
||||
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["plan"]["frequencyPerWeek"], 3)
|
||||
self.assertEqual(result["plan"]["bestTimeOfDay"], "اوایل صبح")
|
||||
mock_build_rag_context.assert_called_once()
|
||||
mock_build_plant_text.assert_called_once_with("گوجهفرنگی", "میوهدهی")
|
||||
mock_build_irrigation_method_text.assert_called_once_with("آبیاری قطرهای")
|
||||
self.assertEqual(
|
||||
result["selected_irrigation_method"]["name"],
|
||||
"آبیاری قطرهای",
|
||||
)
|
||||
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
|
||||
self.assertEqual(result["timeline"][0]["title"], "بازبینی")
|
||||
self.assertEqual(result["sections"][1]["type"], "tip")
|
||||
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.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_persists_selected_method_on_farm(
|
||||
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,
|
||||
):
|
||||
sprinkler = IrrigationMethod.objects.create(name="بارانی")
|
||||
self.farm.irrigation_method = None
|
||||
self.farm.save(update_fields=["irrigation_method", "updated_at"])
|
||||
|
||||
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="میوهدهی",
|
||||
irrigation_method_name="بارانی",
|
||||
)
|
||||
|
||||
self.farm.refresh_from_db()
|
||||
self.assertEqual(self.farm.irrigation_method_id, sprinkler.id)
|
||||
self.assertEqual(result["selected_irrigation_method"]["id"], sprinkler.id)
|
||||
mock_build_irrigation_method_text.assert_called_once_with("بارانی")
|
||||
self.assertEqual(result["plan"]["warning"], "بررسی شود")
|
||||
|
||||
@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_falls_back_to_optimizer_when_llm_returns_invalid_payload(
|
||||
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,
|
||||
):
|
||||
mock_get_optimizer.return_value.optimize_irrigation.return_value = (
|
||||
self.build_irrigation_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_irrigation_recommendation(
|
||||
farm_uuid=str(self.farm_uuid),
|
||||
growth_stage="میوهدهی",
|
||||
)
|
||||
|
||||
self.assertEqual(result["plan"]["frequencyPerWeek"], 3)
|
||||
self.assertEqual(result["timeline"][0]["step_number"], 1)
|
||||
self.assertEqual(result["sections"][0]["type"], "warning")
|
||||
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
|
||||
|
||||
@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_farm_plant_when_request_omits_name(
|
||||
self,
|
||||
mock_get_chat_client,
|
||||
mock_get_optimizer,
|
||||
_mock_build_rag_context,
|
||||
mock_build_plant_text,
|
||||
):
|
||||
mock_get_optimizer.return_value.optimize_fertilization.return_value = (
|
||||
self.build_fertilization_optimizer_result()
|
||||
)
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock(message=Mock(content='{"status": "success", "data": {"primary_recommendation": {"display_title": "کود کامل 20-20-20", "reasoning": "توضیح", "summary": "مصرف انجام شود"}, "nutrient_analysis": {"macro": [{"key": "n", "name": "نیتروژن (N)", "value": 20, "unit": "percent", "description": "..."}, {"key": "p", "name": "فسفر (P)", "value": 20, "unit": "percent", "description": "..."}, {"key": "k", "name": "پتاسیم (K)", "value": 20, "unit": "percent", "description": "..."}], "micro": []}, "application_guide": {"safety_warning": "از اختلاط نامناسب خودداری شود.", "steps": [{"step_number": 1, "title": "آماده سازی", "description": "مورد 1"}]}, "alternative_recommendations": []}}'))]
|
||||
mock_get_chat_client.return_value.chat.completions.create.return_value = mock_response
|
||||
|
||||
result = get_fertilization_recommendation(
|
||||
farm_uuid=str(self.farm_uuid),
|
||||
growth_stage="رویشی",
|
||||
)
|
||||
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
|
||||
mock_build_plant_text.assert_called_once_with("گوجهفرنگی", "رویشی")
|
||||
self.assertEqual(result["simulation_optimizer"]["engine"], "crop_simulation_heuristic")
|
||||
self.assertEqual(result["data"]["application_guide"]["safety_warning"], "از اختلاط نامناسب خودداری شود.")
|
||||
|
||||
@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_resolves_requested_plant_from_catalog(
|
||||
self,
|
||||
mock_get_chat_client,
|
||||
mock_get_optimizer,
|
||||
_mock_build_rag_context,
|
||||
mock_build_plant_text,
|
||||
):
|
||||
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), "پیاز")
|
||||
self.assertEqual(optimizer_call["growth_stage"], "flowering")
|
||||
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_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_rag_context", return_value="")
|
||||
@patch("rag.services.fertilization._get_optimizer")
|
||||
@patch("rag.services.fertilization.get_chat_client")
|
||||
def test_fertilization_recommendation_falls_back_to_optimizer_when_llm_returns_invalid_payload(
|
||||
self,
|
||||
mock_get_chat_client,
|
||||
mock_get_optimizer,
|
||||
_mock_build_rag_context,
|
||||
_mock_build_plant_text,
|
||||
):
|
||||
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),
|
||||
growth_stage="رویشی",
|
||||
)
|
||||
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["npk_ratio"]["label"], "20-20-20")
|
||||
self.assertEqual(result["data"]["primary_recommendation"]["dosage"]["base_amount_per_hectare"], 65.0)
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
ChatView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("chat/", ChatView.as_view()),
|
||||
]
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
ساخت دیتای خاک و هواشناسی کاربر از farm_data، location_data و weather — Schema-agnostic
|
||||
هر سنسور = یک کاربر. شناسایی با farm_uuid.
|
||||
|
||||
مدلهای Django داخل توابع import میشوند تا از AppRegistryNotReady جلوگیری شود.
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
from django.db.models import Model
|
||||
|
||||
|
||||
EXCLUDE_FIELD_NAMES = {"id", "created_at", "updated_at", "task_id", "recorded_at", "fetched_at"}
|
||||
|
||||
|
||||
def _model_to_data_fields(instance: Model, exclude: set[str] | None = None) -> dict:
|
||||
"""
|
||||
استخراج فیلدهای داده از یک instance با استفاده از introspection.
|
||||
تغییرات بعدی در مدل باعث شکستن نمیشود.
|
||||
"""
|
||||
exclude = exclude or set()
|
||||
out: dict = {}
|
||||
for f in instance._meta.get_fields():
|
||||
if f.many_to_many or f.one_to_many or f.one_to_one and f.auto_created:
|
||||
continue
|
||||
if f.name in exclude or f.name in EXCLUDE_FIELD_NAMES:
|
||||
continue
|
||||
if hasattr(f, "related_model") and f.related_model:
|
||||
continue # FK
|
||||
try:
|
||||
val = getattr(instance, f.name, None)
|
||||
if val is not None:
|
||||
out[f.name] = val
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def build_user_soil_text(sensor_uuid: str) -> str | None:
|
||||
"""
|
||||
ساخت متن قابل embed برای یک سنسور (کاربر).
|
||||
از SensorData → SoilLocation → latest remote sensing snapshots خوانده میشود.
|
||||
|
||||
Returns:
|
||||
متن متنی قابل چانک، یا None اگر سنسور یافت نشد.
|
||||
"""
|
||||
from farm_data.models import SensorData
|
||||
from location_data.satellite_snapshot import build_location_block_satellite_snapshots
|
||||
|
||||
try:
|
||||
sensor = SensorData.objects.select_related("center_location").get(
|
||||
farm_uuid=sensor_uuid
|
||||
)
|
||||
except SensorData.DoesNotExist:
|
||||
return None
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# شناسه سنسور
|
||||
parts.append(f"سنسور: {sensor.farm_uuid}")
|
||||
|
||||
# موقعیت مزرعه
|
||||
loc = sensor.center_location
|
||||
parts.append(
|
||||
f"موقعیت مزرعه: عرض {loc.latitude}، طول {loc.longitude}"
|
||||
)
|
||||
|
||||
# خوانشهای سنسور (schema-agnostic)
|
||||
sensor_fields = _model_to_data_fields(
|
||||
sensor, exclude={"farm_uuid", "center_location_id", "center_location", "location"}
|
||||
)
|
||||
if sensor_fields:
|
||||
sensor_lines = [f" {k}: {v}" for k, v in sorted(sensor_fields.items())]
|
||||
parts.append("خوانشهای سنسور:\n" + "\n".join(sensor_lines))
|
||||
|
||||
snapshots = build_location_block_satellite_snapshots(loc)
|
||||
if snapshots:
|
||||
snapshot_lines = []
|
||||
for snapshot in snapshots:
|
||||
metrics = snapshot.get("resolved_metrics") or {}
|
||||
if not metrics:
|
||||
continue
|
||||
lines = [f" {k}: {v}" for k, v in sorted(metrics.items())]
|
||||
snapshot_lines.append(
|
||||
f" بلوک {snapshot.get('block_code') or 'farm'}:\n" + "\n".join(lines)
|
||||
)
|
||||
if snapshot_lines:
|
||||
parts.append("دادههای ماهوارهای:\n" + "\n".join(snapshot_lines))
|
||||
|
||||
return "\n\n".join(parts) if len(parts) > 1 else None
|
||||
|
||||
|
||||
def get_all_sensor_uuids() -> list[str]:
|
||||
"""لیست همه farm_uuid های موجود."""
|
||||
from farm_data.models import SensorData
|
||||
|
||||
return [
|
||||
str(u) for u in
|
||||
SensorData.objects.values_list("farm_uuid", flat=True).distinct()
|
||||
]
|
||||
|
||||
|
||||
def build_user_weather_text(sensor_uuid: str) -> str | None:
|
||||
"""
|
||||
ساخت متن هواشناسی قابل embed برای یک سنسور (کاربر).
|
||||
پیشبینی ۷ روز آینده از WeatherForecast خوانده میشود.
|
||||
|
||||
Returns:
|
||||
متن فارسی ساختاریافته، یا None اگر دادهای نباشد.
|
||||
"""
|
||||
from farm_data.models import SensorData
|
||||
from weather.models import WeatherForecast
|
||||
|
||||
try:
|
||||
sensor = SensorData.objects.select_related("center_location").get(
|
||||
farm_uuid=sensor_uuid
|
||||
)
|
||||
except SensorData.DoesNotExist:
|
||||
return None
|
||||
|
||||
loc = sensor.center_location
|
||||
forecasts = (
|
||||
WeatherForecast.objects.filter(
|
||||
location=loc,
|
||||
forecast_date__gte=date.today(),
|
||||
)
|
||||
.order_by("forecast_date")[:7]
|
||||
)
|
||||
if not forecasts:
|
||||
return None
|
||||
|
||||
parts: list[str] = []
|
||||
parts.append(f"پیشبینی هواشناسی سنسور {sensor_uuid} (موقعیت: {loc.latitude}, {loc.longitude})")
|
||||
|
||||
for fc in forecasts:
|
||||
fc_data = _model_to_data_fields(
|
||||
fc, exclude={"location", "location_id", "forecast_date"}
|
||||
)
|
||||
lines = [f" {k}: {v}" for k, v in sorted(fc_data.items())]
|
||||
day_text = f" تاریخ {fc.forecast_date}:\n" + "\n".join(lines)
|
||||
parts.append(day_text)
|
||||
|
||||
return "\n\n".join(parts) if len(parts) > 1 else None
|
||||
|
||||
|
||||
def load_user_sources() -> list[tuple[str, str]]:
|
||||
"""
|
||||
بارگذاری منابع دیتای کاربران از DB (خاک + هواشناسی).
|
||||
Returns: [(source_id, content), ...]
|
||||
source_id = user:{uuid} یا weather:{uuid}
|
||||
"""
|
||||
uuids = get_all_sensor_uuids()
|
||||
sources: list[tuple[str, str]] = []
|
||||
for uid in uuids:
|
||||
text = build_user_soil_text(str(uid))
|
||||
if text and text.strip():
|
||||
sources.append((f"user:{uid}", text))
|
||||
weather_text = build_user_weather_text(str(uid))
|
||||
if weather_text and weather_text.strip():
|
||||
sources.append((f"weather:{uid}", weather_text))
|
||||
return sources
|
||||
|
||||
|
||||
def build_plant_text(plant_name: str, growth_stage: str) -> str | None:
|
||||
"""
|
||||
ساخت متن اطلاعات گیاه از snapshotهای `farm_data` برای استفاده در context LLM.
|
||||
"""
|
||||
from farm_data.models import PlantCatalogSnapshot
|
||||
from farm_data.services import build_plant_text_from_snapshot
|
||||
|
||||
plant = PlantCatalogSnapshot.objects.filter(name=plant_name).first()
|
||||
if not plant:
|
||||
return None
|
||||
|
||||
return build_plant_text_from_snapshot(plant, growth_stage)
|
||||
|
||||
|
||||
def build_irrigation_method_text(method_name: str) -> str | None:
|
||||
"""
|
||||
ساخت متن مشخصات روش آبیاری از جدول IrrigationMethod برای استفاده در context LLM.
|
||||
"""
|
||||
from irrigation.models import IrrigationMethod
|
||||
|
||||
method = IrrigationMethod.objects.filter(name=method_name).first()
|
||||
if not method:
|
||||
return None
|
||||
|
||||
lines = [f"روش آبیاری: {method.name}"]
|
||||
if method.category:
|
||||
lines.append(f"دستهبندی: {method.category}")
|
||||
if method.description:
|
||||
lines.append(f"توضیحات: {method.description}")
|
||||
if method.water_efficiency_percent is not None:
|
||||
lines.append(f"راندمان مصرف آب: {method.water_efficiency_percent}%")
|
||||
if method.water_pressure_required:
|
||||
lines.append(f"فشار مورد نیاز: {method.water_pressure_required}")
|
||||
if method.flow_rate:
|
||||
lines.append(f"دبی جریان: {method.flow_rate}")
|
||||
if method.coverage_area:
|
||||
lines.append(f"مساحت پوشش: {method.coverage_area}")
|
||||
if method.soil_type:
|
||||
lines.append(f"نوع خاک مناسب: {method.soil_type}")
|
||||
if method.climate_suitability:
|
||||
lines.append(f"اقلیم مناسب: {method.climate_suitability}")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Qdrant Vector Store — ذخیره و جستجوی وکتورها
|
||||
"""
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http import models as qmodels
|
||||
|
||||
from .client import get_qdrant_client
|
||||
from .config import load_rag_config, RAGConfig
|
||||
|
||||
|
||||
class QdrantVectorStore:
|
||||
"""
|
||||
ذخیره و جستجوی documents در Qdrant.
|
||||
"""
|
||||
|
||||
def __init__(self, config: RAGConfig | None = None):
|
||||
self.config = config or load_rag_config()
|
||||
self.qdrant = self.config.qdrant
|
||||
self._client: QdrantClient | None = None
|
||||
|
||||
@property
|
||||
def client(self) -> QdrantClient:
|
||||
if self._client is None:
|
||||
self._client = get_qdrant_client(self.qdrant)
|
||||
return self._client
|
||||
|
||||
def ensure_collection(self, recreate: bool = False) -> None:
|
||||
"""
|
||||
اطمینان از وجود collection با نام و اندازه مناسب.
|
||||
"""
|
||||
name = self.qdrant.collection_name
|
||||
size = self.qdrant.vector_size
|
||||
|
||||
try:
|
||||
self.client.get_collection(name)
|
||||
if recreate:
|
||||
self.client.delete_collection(name)
|
||||
self.client.create_collection(
|
||||
collection_name=name,
|
||||
vectors_config=qmodels.VectorParams(
|
||||
size=size,
|
||||
distance=qmodels.Distance.COSINE,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
self.client.create_collection(
|
||||
collection_name=name,
|
||||
vectors_config=qmodels.VectorParams(
|
||||
size=size,
|
||||
distance=qmodels.Distance.COSINE,
|
||||
),
|
||||
)
|
||||
|
||||
def add_documents(
|
||||
self,
|
||||
ids: list[str],
|
||||
embeddings: list[list[float]],
|
||||
documents: list[str],
|
||||
metadatas: list[dict] | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
افزودن documents به collection.
|
||||
metadata فقط str, int, float, bool پشتیبانی میشود.
|
||||
"""
|
||||
self.ensure_collection()
|
||||
metas = metadatas or [{}] * len(ids)
|
||||
|
||||
def _serialize(m: dict) -> dict:
|
||||
out = {}
|
||||
for k, v in m.items():
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, (str, int, float, bool)):
|
||||
out[k] = v
|
||||
else:
|
||||
out[k] = str(v)
|
||||
return out
|
||||
|
||||
payloads = [
|
||||
{"text": doc, "doc_id": sid, **_serialize(m)}
|
||||
for doc, m, sid in zip(documents, metas, ids)
|
||||
]
|
||||
|
||||
self.client.upsert(
|
||||
collection_name=self.qdrant.collection_name,
|
||||
points=[
|
||||
qmodels.PointStruct(id=pid, vector=emb, payload=pl)
|
||||
for pid, emb, pl in zip(ids, embeddings, payloads)
|
||||
],
|
||||
)
|
||||
return len(ids)
|
||||
|
||||
def search(
|
||||
self,
|
||||
query_vector: list[float],
|
||||
limit: int = 5,
|
||||
score_threshold: float | None = None,
|
||||
sensor_uuid: str | None = None,
|
||||
kb_name: str | None = None,
|
||||
sensor_uuids: list[str] | None = None,
|
||||
kb_names: list[str] | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
جستجوی شباهت بر اساس query vector.
|
||||
روی نسخههای جدید از query_points و روی نسخههای قدیمیتر از search استفاده میکند.
|
||||
sensor_uuid: اجباری — فقط chunks مربوط به این سنسور یا __global__ برگردانده میشود.
|
||||
kb_name: اختیاری — فیلتر بر اساس پایگاه دانش (chat/irrigation/fertilization).
|
||||
اگر مشخص شود، فقط chunks همان KB و __all__ برگردانده میشود.
|
||||
"""
|
||||
must_conditions = []
|
||||
|
||||
sensor_values = [value for value in (sensor_uuids or ([sensor_uuid] if sensor_uuid else [])) if value]
|
||||
if sensor_values:
|
||||
must_conditions.append(
|
||||
qmodels.Filter(
|
||||
should=[
|
||||
qmodels.FieldCondition(
|
||||
key="sensor_uuid",
|
||||
match=qmodels.MatchValue(value=value),
|
||||
)
|
||||
for value in sensor_values
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
kb_values = [value for value in (kb_names or ([kb_name] if kb_name else [])) if value]
|
||||
if kb_values:
|
||||
must_conditions.append(
|
||||
qmodels.Filter(
|
||||
should=[
|
||||
qmodels.FieldCondition(
|
||||
key="kb_name",
|
||||
match=qmodels.MatchValue(value=value),
|
||||
)
|
||||
for value in kb_values
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
query_filter = None
|
||||
if must_conditions:
|
||||
query_filter = qmodels.Filter(must=must_conditions)
|
||||
|
||||
if hasattr(self.client, "query_points"):
|
||||
response = self.client.query_points(
|
||||
collection_name=self.qdrant.collection_name,
|
||||
query=query_vector,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
query_filter=query_filter,
|
||||
)
|
||||
points = getattr(response, "points", []) or []
|
||||
else:
|
||||
points = self.client.search(
|
||||
collection_name=self.qdrant.collection_name,
|
||||
query_vector=query_vector,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
query_filter=query_filter,
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": str(r.id),
|
||||
"score": float(r.score) if r.score is not None else 0.0,
|
||||
"text": (r.payload or {}).get("text", ""),
|
||||
"metadata": {
|
||||
k: v for k, v in (r.payload or {}).items() if k != "text"
|
||||
},
|
||||
}
|
||||
for r in points
|
||||
]
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
ویوهای RAG — چت با استریم
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.http import StreamingHttpResponse
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiExample,
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
inline_serializer,
|
||||
)
|
||||
from rest_framework import status
|
||||
from rest_framework import serializers as drf_serializers
|
||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from config.openapi import (
|
||||
build_envelope_serializer,
|
||||
build_message_response_serializer,
|
||||
build_response,
|
||||
)
|
||||
from .chat import chat_rag_stream, encode_uploaded_image
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
RagChatErrorResponseSerializer = build_message_response_serializer(
|
||||
"RagChatErrorResponseSerializer"
|
||||
)
|
||||
RagValidationErrorResponseSerializer = build_envelope_serializer(
|
||||
"RagValidationErrorResponseSerializer",
|
||||
data_required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
|
||||
class ChatView(APIView):
|
||||
parser_classes = [JSONParser, MultiPartParser, FormParser]
|
||||
|
||||
def _parse_history(self, raw_history):
|
||||
if raw_history in (None, "", []):
|
||||
return []
|
||||
if isinstance(raw_history, list):
|
||||
return raw_history
|
||||
if isinstance(raw_history, str):
|
||||
try:
|
||||
parsed = json.loads(raw_history)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
raise ValueError("history باید JSON معتبر باشد.")
|
||||
if not isinstance(parsed, list):
|
||||
raise ValueError("history باید آرایه باشد.")
|
||||
return parsed
|
||||
raise ValueError("history فرمت پشتیبانی شده ندارد.")
|
||||
|
||||
def _collect_uploaded_images(self, request: Request):
|
||||
images = []
|
||||
for uploaded in request.FILES.getlist("images"):
|
||||
images.append(encode_uploaded_image(uploaded))
|
||||
single_image = request.FILES.get("image")
|
||||
if single_image is not None:
|
||||
images.append(encode_uploaded_image(single_image))
|
||||
image_urls = request.data.get("image_urls")
|
||||
if isinstance(image_urls, str) and image_urls.strip():
|
||||
try:
|
||||
parsed_urls = json.loads(image_urls)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed_urls = [image_urls]
|
||||
image_urls = parsed_urls
|
||||
if isinstance(image_urls, list):
|
||||
for item in image_urls:
|
||||
if isinstance(item, str) and item.strip():
|
||||
images.append({"url": item.strip(), "detail": "auto"})
|
||||
elif isinstance(item, dict) and isinstance(item.get("url"), str):
|
||||
image_payload = {"url": item["url"].strip(), "detail": item.get("detail", "auto")}
|
||||
images.append(image_payload)
|
||||
return images
|
||||
|
||||
@extend_schema(
|
||||
tags=["RAG Chat"],
|
||||
summary="چت RAG با استریم",
|
||||
description="پیام کاربر را دریافت و پاسخ را به صورت استریم برمیگرداند.",
|
||||
request=inline_serializer(
|
||||
name="ChatRequest",
|
||||
fields={
|
||||
"query": drf_serializers.CharField(required=False, help_text="متن سوال کاربر"),
|
||||
"message": drf_serializers.CharField(required=False, help_text="نام قبلی فیلد query"),
|
||||
"farm_uuid": drf_serializers.CharField(help_text="شناسه مزرعه"),
|
||||
"history": drf_serializers.JSONField(required=False, help_text="آرایه پیام های قبلی با role=user/assistant"),
|
||||
"image_urls": drf_serializers.JSONField(required=False, help_text="آرایه URL تصاویر برای پیام فعلی"),
|
||||
"image": drf_serializers.FileField(required=False, help_text="یک تصویر برای پیام فعلی"),
|
||||
"images": drf_serializers.ListField(
|
||||
child=drf_serializers.FileField(),
|
||||
required=False,
|
||||
help_text="چند تصویر برای پیام فعلی",
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
response=OpenApiTypes.STR,
|
||||
description="پاسخ استریم متنی (text/plain)",
|
||||
),
|
||||
400: build_response(
|
||||
RagChatErrorResponseSerializer,
|
||||
"پارامترهای ورودی نامعتبر هستند.",
|
||||
),
|
||||
404: build_response(
|
||||
RagChatErrorResponseSerializer,
|
||||
"مزرعه پیدا نشد.",
|
||||
),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"نمونه درخواست",
|
||||
value={
|
||||
"farm_uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"query": "وضعیت مزرعه من چطور است؟",
|
||||
"history": [
|
||||
{"role": "user", "content": "رطوبت خاک من پایین بود؟"},
|
||||
{"role": "assistant", "content": "بله، رطوبت خاک کمتر از محدوده مطلوب بود."},
|
||||
],
|
||||
"image_urls": ["https://example.com/farm-photo.jpg"],
|
||||
},
|
||||
request_only=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def post(self, request: Request):
|
||||
from farm_data.services import get_farm_details
|
||||
from .config import load_rag_config
|
||||
|
||||
data = request.data if request.method == "POST" else request.query_params
|
||||
message = data.get("query", data.get("message"))
|
||||
farm_uuid = data.get("farm_uuid")
|
||||
raw_history = data.get("history")
|
||||
try:
|
||||
images = self._collect_uploaded_images(request)
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"code": 400, "msg": str(exc)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if message is None and images:
|
||||
message = "لطفا تصویر ارسالی را در کنار اطلاعات مزرعه بررسی کن."
|
||||
if not message or not isinstance(message, str):
|
||||
return Response(
|
||||
{"code": 400, "msg": "پارامتر query الزامی است، مگر اینکه تصویر ارسال شده باشد."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
message = str(message).strip()
|
||||
if not message:
|
||||
return Response(
|
||||
{"code": 400, "msg": "پیام نباید خالی باشد."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not farm_uuid or not isinstance(farm_uuid, str):
|
||||
return Response(
|
||||
{"code": 400, "msg": "پارامتر farm_uuid الزامی است."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
farm_uuid = str(farm_uuid).strip()
|
||||
if not farm_uuid:
|
||||
return Response(
|
||||
{"code": 400, "msg": "farm_uuid نباید خالی باشد."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
history = self._parse_history(raw_history)
|
||||
except ValueError as exc:
|
||||
return Response(
|
||||
{"code": 400, "msg": str(exc)},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
cfg = load_rag_config()
|
||||
farm_details = get_farm_details(farm_uuid)
|
||||
if farm_details is None:
|
||||
return Response(
|
||||
{"code": 404, "msg": "farm پیدا نشد."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
for chunk in chat_rag_stream(
|
||||
message,
|
||||
farm_uuid=farm_uuid,
|
||||
config=cfg,
|
||||
farm_details=farm_details,
|
||||
history=history,
|
||||
images=images,
|
||||
):
|
||||
yield chunk
|
||||
except Exception as e:
|
||||
yield f"\n[خطا: {e}]"
|
||||
|
||||
return StreamingHttpResponse(
|
||||
generate(),
|
||||
content_type="text/plain; charset=utf-8",
|
||||
)
|
||||
Reference in New Issue
Block a user