UPDATE
This commit is contained in:
@@ -0,0 +1,232 @@
|
|||||||
|
# گزارش تغییرات API در ۶ کامیت اخیر
|
||||||
|
|
||||||
|
این فایل تغییرات مربوط به سه فایل زیر را نسبت به **۶ کامیت قبل** (`HEAD~6`) مستند میکند:
|
||||||
|
|
||||||
|
- `irrigation_recommendation/urls.py`
|
||||||
|
- `fertilization_recommendation/apps.py`
|
||||||
|
- `farm_ai_assistant/views.py`
|
||||||
|
|
||||||
|
## بازه مقایسه
|
||||||
|
- مبدا: `HEAD~6`
|
||||||
|
- مقصد: `HEAD`
|
||||||
|
|
||||||
|
## نتیجه سریع
|
||||||
|
- در `irrigation_recommendation/urls.py`، API آبیاری از مدل دارای endpoint بررسی وضعیت task فاصله گرفته و دو endpoint جدید برای لیست روشهای آبیاری و water stress اضافه شده است.
|
||||||
|
- در `fertilization_recommendation/apps.py`، در این بازه **هیچ تغییری** ثبت نشده است.
|
||||||
|
- در `farm_ai_assistant/views.py`، API چت از flow مبتنی بر task/polling به flow مستقیم request/response تغییر کرده و پشتیبانی از `history`، `image_urls` و ورودیهای multipart/JSON بهتر شده است.
|
||||||
|
|
||||||
|
## 1) تغییرات `irrigation_recommendation/urls.py`
|
||||||
|
|
||||||
|
### وضعیت قبلی
|
||||||
|
مسیرهای زیر وجود داشتند:
|
||||||
|
- `config/` -> `ConfigView`
|
||||||
|
- `recommend/` -> `RecommendView`
|
||||||
|
- `recommend/status/<str:task_id>/` -> `RecommendTaskStatusView`
|
||||||
|
|
||||||
|
### وضعیت فعلی
|
||||||
|
مسیرهای فعلی:
|
||||||
|
- `` -> `IrrigationMethodListView`
|
||||||
|
- `config/` -> `ConfigView`
|
||||||
|
- `recommend/` -> `RecommendView`
|
||||||
|
- `water-stress/` -> `WaterStressView`
|
||||||
|
|
||||||
|
### تغییرات دقیق
|
||||||
|
#### الف) اضافه شدن endpoint ریشه برای لیست روشهای آبیاری
|
||||||
|
مسیر جدید:
|
||||||
|
- `GET irrigation_recommendation/`
|
||||||
|
- view: `IrrigationMethodListView`
|
||||||
|
- name: `irrigation-method-list`
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- حالا این app علاوه بر recommendation، یک endpoint مستقل برای دریافت لیست روشهای آبیاری هم دارد.
|
||||||
|
|
||||||
|
#### ب) حذف endpoint بررسی وضعیت task
|
||||||
|
مسیر حذفشده:
|
||||||
|
- `recommend/status/<str:task_id>/`
|
||||||
|
- view: `RecommendTaskStatusView`
|
||||||
|
- name: `irrigation-recommendation-task-status`
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- دیگر API رسمیای در `urls.py` برای polling وضعیت task recommendation تعریف نشده است.
|
||||||
|
- این تغییر از نظر معماری شبیه حذف flow تسکمحور در بخش AI assistant است.
|
||||||
|
|
||||||
|
#### ج) اضافه شدن endpoint جدید water stress
|
||||||
|
مسیر جدید:
|
||||||
|
- `POST irrigation_recommendation/water-stress/`
|
||||||
|
- view: `WaterStressView`
|
||||||
|
- name: `irrigation-water-stress`
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- یک قابلیت جدید در API آبیاری اضافه شده که بهصورت جداگانه water stress را محاسبه/برمیگرداند.
|
||||||
|
|
||||||
|
### چیزهایی که تغییر نکردهاند
|
||||||
|
- `config/`
|
||||||
|
- `recommend/`
|
||||||
|
|
||||||
|
## 2) تغییرات `fertilization_recommendation/apps.py`
|
||||||
|
|
||||||
|
در بازه `HEAD~6..HEAD` برای این فایل **هیچ diffای وجود ندارد**.
|
||||||
|
|
||||||
|
### وضعیت فعلی و قبلی یکسان است
|
||||||
|
مقدارهای مهم بدون تغییر ماندهاند:
|
||||||
|
- `default_auto_field = "django.db.models.BigAutoField"`
|
||||||
|
- `name = "fertilization_recommendation"`
|
||||||
|
- `verbose_name = "Fertilization Recommendation"`
|
||||||
|
|
||||||
|
### نتیجه
|
||||||
|
- از نظر ثبت app در Django، در این ۶ کامیت اخیر تغییری در `fertilization_recommendation/apps.py` اعمال نشده است.
|
||||||
|
- اگر منظورت بررسی APIهای recommendation بوده، این فایل خودش route یا view API ندارد و فقط تنظیمات app را نگه میدارد.
|
||||||
|
|
||||||
|
## 3) تغییرات `farm_ai_assistant/views.py`
|
||||||
|
|
||||||
|
بزرگترین تغییرات این بازه در این فایل اتفاق افتاده است.
|
||||||
|
|
||||||
|
### خلاصه معماری
|
||||||
|
API چت از این مدل:
|
||||||
|
1. ثبت درخواست چت
|
||||||
|
2. ساخت task در سرویس AI
|
||||||
|
3. دریافت `task_id`
|
||||||
|
4. polling برای status
|
||||||
|
|
||||||
|
به این مدل تغییر کرده:
|
||||||
|
1. ارسال مستقیم درخواست چت
|
||||||
|
2. دریافت مستقیم پاسخ assistant
|
||||||
|
3. ذخیره همزمان پیام کاربر و پیام assistant
|
||||||
|
|
||||||
|
### تغییرات مهم
|
||||||
|
#### الف) حذف flow مبتنی بر task
|
||||||
|
کلاسهای زیر حذف شدهاند:
|
||||||
|
- `ChatTaskCreateView`
|
||||||
|
- `ChatTaskStatusView`
|
||||||
|
|
||||||
|
رفتار قبلی:
|
||||||
|
- `ChatTaskCreateView` درخواست را به endpoint زیر در سرویس بیرونی میفرستاد:
|
||||||
|
- `/rag/chat/generate`
|
||||||
|
- سپس `ChatTaskStatusView` وضعیت task را از endpoint زیر میگرفت:
|
||||||
|
- `/tasks/{task_id}/status`
|
||||||
|
|
||||||
|
رفتار جدید:
|
||||||
|
- این دو کلاس حذف شدهاند و flow task-based از این فایل کنار رفته است.
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- دیگر پاسخ چت در دو مرحله create/status مدیریت نمیشود.
|
||||||
|
- polling مبتنی بر `task_id` از منطق این viewها حذف شده است.
|
||||||
|
|
||||||
|
#### ب) مستقیم شدن درخواست چت در `ChatView`
|
||||||
|
در `ChatView.post`، اکنون درخواست مستقیم به سرویس AI ارسال میشود:
|
||||||
|
- endpoint جدید آداپتر: `/api/rag/chat/`
|
||||||
|
|
||||||
|
این یعنی:
|
||||||
|
- بهجای submit task و پیگیری وضعیت آن، پاسخ assistant در همان request برگردانده میشود.
|
||||||
|
|
||||||
|
#### ج) تغییر مدل ورودی از `content` به `query`
|
||||||
|
در payload ارسالی به adapter، حالا فیلد اصلی متن کاربر این است:
|
||||||
|
- `query`
|
||||||
|
|
||||||
|
در نسخه قبلی، از `content` برای ساخت payload استفاده میشد.
|
||||||
|
الان:
|
||||||
|
- `content` در منطق اصلی جای خود را به `query` داده است.
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- کلاینتی که هنوز payload قدیمی مبتنی بر `content` میفرستد، باید با قرارداد جدید view/serializer هماهنگ شود.
|
||||||
|
|
||||||
|
#### د) پشتیبانی از `history`
|
||||||
|
قابلیت جدید:
|
||||||
|
- history پیامها بهصورت ساختاریافته دریافت، نرمالسازی و به adapter ارسال میشود.
|
||||||
|
|
||||||
|
تغییرات مرتبط:
|
||||||
|
- اضافه شدن `_serialize_history_messages`
|
||||||
|
- اضافه شدن `_merge_history`
|
||||||
|
- اضافه شدن `history` به payload ارسالی به سرویس بیرونی
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- API حالا میتواند context مکالمه را شفافتر و مستقل از صرفاً conversation id به سرویس AI بفرستد.
|
||||||
|
- اگر history در ورودی باشد، استفاده میشود؛ در غیر این صورت از پیامهای conversation برای ساخت history استفاده میشود.
|
||||||
|
|
||||||
|
#### ه) پشتیبانی از `image_urls` و فایل آپلودی
|
||||||
|
قابلیتهای جدید:
|
||||||
|
- `image_urls` به payload اضافه شده است.
|
||||||
|
- فایلهای آپلودشده نیز جمعآوری و به payload الصاق میشوند.
|
||||||
|
|
||||||
|
تغییرات مرتبط:
|
||||||
|
- اضافه شدن `_attach_uploaded_files`
|
||||||
|
- تبدیل `history` و `image_urls` به JSON string هنگام multipart submission
|
||||||
|
- ادغام `image_urls` و `images` در ذخیره پیام کاربر
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- API چت حالا هم لینک تصویر و هم فایل تصویر آپلودی را پشتیبانی میکند.
|
||||||
|
- درخواستهای multipart برای سناریوهای image-based chat بهتر پشتیبانی میشوند.
|
||||||
|
|
||||||
|
#### و) مدیریت بهتر JSON و خطای Parse
|
||||||
|
قابلیتهای جدید:
|
||||||
|
- import شدن `ParseError`
|
||||||
|
- اضافه شدن `_parse_json_array`
|
||||||
|
- اضافه شدن `_prepare_chat_input`
|
||||||
|
- پاسخ ۴۰۰ اختصاصی برای JSON نامعتبر
|
||||||
|
|
||||||
|
رفتار جدید:
|
||||||
|
- اگر body نامعتبر باشد، API این پیام را برمیگرداند:
|
||||||
|
- invalid JSON / extra trailing characters
|
||||||
|
- فیلدهایی مثل `history` و `image_urls` اگر به شکل string JSON بیایند، parse میشوند.
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- endpoint چت در برابر فرمتهای مختلف درخواست مقاومتر شده است.
|
||||||
|
- احتمال خطا برای کلاینتهایی که multipart یا JSON string میفرستند کمتر شده است.
|
||||||
|
|
||||||
|
#### ز) تغییر در ساخت conversation جدید
|
||||||
|
رفتار جدید:
|
||||||
|
- عنوان conversation با `_generate_conversation_title(query)` ساخته میشود.
|
||||||
|
- اگر query خالی باشد، عنوان پیشفرض `Image` میشود.
|
||||||
|
- برای conversation جدید، `farm_context` بهصورت خالی `{}` ست میشود.
|
||||||
|
|
||||||
|
رفتار حذفشده:
|
||||||
|
- دیگر `farm_context` و `title` مستقیماً از payload برای update/create conversation استفاده نمیشوند.
|
||||||
|
- منطق قبلی که conversation موجود را با `farm_context` یا `title` آپدیت میکرد حذف شده است.
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- کنترل عنوان مکالمه بیشتر به منطق داخلی view منتقل شده است.
|
||||||
|
- payload کلاینت اختیار کمتری روی title/farm_context conversation دارد.
|
||||||
|
|
||||||
|
#### ح) بهبود نرمالسازی پاسخ assistant
|
||||||
|
در نرمالسازی sectionها، این کلیدها هم پشتیبانی میشوند:
|
||||||
|
- `primaryAction`
|
||||||
|
- `validityPeriod`
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- ساختار response assistant برای UIهای غنیتر آمادهتر شده است.
|
||||||
|
|
||||||
|
#### ط) حذف وابستگی به mock chat response
|
||||||
|
حذف شده:
|
||||||
|
- `CHAT_RESPONSE_DATA`
|
||||||
|
- متد `_build_mock_assistant_payload`
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- منطق چت بیشتر به پاسخ واقعی adapter متکی شده و از mock response داخلی فاصله گرفته است.
|
||||||
|
|
||||||
|
#### ی) logging بیشتر برای دیباگ integration
|
||||||
|
موارد جدید:
|
||||||
|
- import شدن `logging`
|
||||||
|
- تعریف `logger`
|
||||||
|
- ثبت log برای response adapter و برخی وضعیتهای payload parsing/extraction
|
||||||
|
|
||||||
|
اثر:
|
||||||
|
- عیبیابی integration با سرویس AI سادهتر شده است.
|
||||||
|
|
||||||
|
## جمعبندی نهایی
|
||||||
|
در این ۶ کامیت اخیر:
|
||||||
|
|
||||||
|
- `irrigation_recommendation/urls.py`
|
||||||
|
- endpoint بررسی وضعیت task حذف شده
|
||||||
|
- endpoint ریشه برای لیست روشهای آبیاری اضافه شده
|
||||||
|
- endpoint جدید `water-stress/` اضافه شده
|
||||||
|
|
||||||
|
- `fertilization_recommendation/apps.py`
|
||||||
|
- هیچ تغییری نداشته است
|
||||||
|
|
||||||
|
- `farm_ai_assistant/views.py`
|
||||||
|
- flow چت task-based حذف شده
|
||||||
|
- درخواست مستقیم به `/api/rag/chat/` جایگزین شده
|
||||||
|
- پشتیبانی از `history`، `image_urls` و فایل آپلودی اضافه شده
|
||||||
|
- مدیریت JSON/multipart بهتر شده
|
||||||
|
- title conversation از `query` ساخته میشود
|
||||||
|
- نرمالسازی response assistant گسترش یافته است
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# تغییرات API چت در `farm_ai_assistant/urls.py`
|
||||||
|
|
||||||
|
این فایل تغییرات مربوط به API چت را در `farm_ai_assistant/urls.py` نسبت به **۶ کامیت قبل** (`HEAD~6`) توضیح میدهد.
|
||||||
|
|
||||||
|
## بازه مقایسه
|
||||||
|
- مبدا مقایسه: `HEAD~6`
|
||||||
|
- مقصد مقایسه: `HEAD`
|
||||||
|
|
||||||
|
کامیت مبنا:
|
||||||
|
- `2a77f90` - `Update Docker Compose ports to 8081 and add new apps and URL routes for crop zoning, plant simulator, pest detection, irrigation recommendation, fertilization recommendation, and farm AI assistant.`
|
||||||
|
|
||||||
|
کامیتهای داخل این بازه:
|
||||||
|
- `2846db1` - `UPDATE`
|
||||||
|
- `bf24404` - `UPDATE`
|
||||||
|
- `cef1b53` - `UPDATE`
|
||||||
|
- `24cb87d` - `UPDATE`
|
||||||
|
- `2cd96ce` - `UPDATE`
|
||||||
|
|
||||||
|
## خلاصه تغییر اصلی
|
||||||
|
در این بازه، ساختار API چت از حالت **task-based / async polling** به حالت **direct chat endpoint** تغییر کرده است.
|
||||||
|
|
||||||
|
به زبان ساده:
|
||||||
|
- قبلاً endpoint اصلی `chat/` غیرفعال بود.
|
||||||
|
- قبلاً برای ارسال درخواست چت، یک task ساخته میشد.
|
||||||
|
- سپس وضعیت آن task با یک endpoint جداگانه بررسی میشد.
|
||||||
|
- الان این مدل حذف شده و بهجای آن endpoint مستقیم `chat/` فعال شده است.
|
||||||
|
|
||||||
|
## تغییرات دقیق در مسیرها
|
||||||
|
|
||||||
|
### 1) فعال شدن endpoint مستقیم چت
|
||||||
|
مسیر زیر فعال شده است:
|
||||||
|
|
||||||
|
- `POST/GET farm_ai_assistant/chat/`
|
||||||
|
- view متناظر: `ChatView`
|
||||||
|
- name: `farm-ai-assistant-chat`
|
||||||
|
|
||||||
|
وضعیت قبلی:
|
||||||
|
- این خط در فایل وجود داشت اما کامنت شده بود:
|
||||||
|
- `# path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"),`
|
||||||
|
|
||||||
|
وضعیت جدید:
|
||||||
|
- این endpoint از حالت comment خارج شده و فعال شده است:
|
||||||
|
- `path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"),`
|
||||||
|
|
||||||
|
### 2) حذف endpoint ساخت task برای چت
|
||||||
|
این مسیر حذف شده است:
|
||||||
|
|
||||||
|
- `chat/task/`
|
||||||
|
- view: `ChatTaskCreateView`
|
||||||
|
- name: `farm-ai-assistant-chat-task-create`
|
||||||
|
|
||||||
|
هدف قبلی این endpoint:
|
||||||
|
- ایجاد یک task برای پردازش درخواست چت
|
||||||
|
|
||||||
|
### 3) حذف endpoint بررسی وضعیت task
|
||||||
|
این مسیر هم حذف شده است:
|
||||||
|
|
||||||
|
- `chat/task/<str:task_id>/status/`
|
||||||
|
- view: `ChatTaskStatusView`
|
||||||
|
- name: `farm-ai-assistant-chat-task-status`
|
||||||
|
|
||||||
|
هدف قبلی این endpoint:
|
||||||
|
- بررسی وضعیت پردازش task چت با استفاده از `task_id`
|
||||||
|
|
||||||
|
### 4) حذف import های مربوط به task-based flow
|
||||||
|
این importها از فایل حذف شدهاند:
|
||||||
|
|
||||||
|
- `ChatTaskCreateView`
|
||||||
|
- `ChatTaskStatusView`
|
||||||
|
|
||||||
|
این یعنی دیگر routeای در `urls.py` برای این دو view تعریف نشده است.
|
||||||
|
|
||||||
|
## چیزهایی که تغییری نکردهاند
|
||||||
|
این endpointها در بازه مقایسه بدون تغییر باقی ماندهاند:
|
||||||
|
|
||||||
|
- `context/` -> `ContextView`
|
||||||
|
- `chats/` -> `ChatListCreateView`
|
||||||
|
- `chats/<uuid:conversation_id>/` -> `ChatDetailView`
|
||||||
|
- `chats/<uuid:conversation_id>/messages/` -> `ChatMessagesView`
|
||||||
|
|
||||||
|
## نتیجه فنی تغییر
|
||||||
|
این تغییر نشان میدهد طراحی API چت از این الگو:
|
||||||
|
|
||||||
|
1. ساخت task
|
||||||
|
2. دریافت `task_id`
|
||||||
|
3. polling برای status
|
||||||
|
|
||||||
|
به این الگو تغییر کرده است:
|
||||||
|
|
||||||
|
1. ارسال مستقیم درخواست به `chat/`
|
||||||
|
2. دریافت مستقیم پاسخ از `ChatView`
|
||||||
|
|
||||||
|
## اثر احتمالی روی فرانت یا کلاینتها
|
||||||
|
اگر فرانت یا کلاینت قبلاً با flow تسکمحور کار میکرده، باید این تغییرات را اعمال کند:
|
||||||
|
|
||||||
|
- دیگر نباید به `chat/task/` درخواست بزند.
|
||||||
|
- دیگر نباید `task_id` دریافت و status را polling کند.
|
||||||
|
- باید مستقیماً از `chat/` برای عملیات چت استفاده کند.
|
||||||
|
|
||||||
|
## جمعبندی
|
||||||
|
مهمترین تغییر در ۶ کامیت اخیر برای `farm_ai_assistant/urls.py` این است که:
|
||||||
|
|
||||||
|
- endpoint مستقیم `chat/` فعال شده
|
||||||
|
- endpointهای task-based حذف شدهاند
|
||||||
|
- معماری API چت از حالت asynchronous polling به حالت direct request/response تغییر کرده است
|
||||||
@@ -19,6 +19,7 @@ class ChatSectionSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class ConversationSummarySerializer(serializers.Serializer):
|
class ConversationSummarySerializer(serializers.Serializer):
|
||||||
id = serializers.UUIDField(source="uuid", read_only=True)
|
id = serializers.UUIDField(source="uuid", read_only=True)
|
||||||
|
title = serializers.CharField(read_only=True)
|
||||||
farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True, allow_null=True)
|
farm_uuid = serializers.UUIDField(source="farm.farm_uuid", read_only=True, allow_null=True)
|
||||||
message_count = serializers.IntegerField(read_only=True)
|
message_count = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
@@ -46,12 +47,8 @@ class ConversationMessagesSerializer(serializers.Serializer):
|
|||||||
messages = ChatHistoryMessageSerializer(many=True, read_only=True)
|
messages = ChatHistoryMessageSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
|
||||||
class ChatResponseDataSerializer(serializers.Serializer):
|
class ChatResponseDataSerializer(serializers.JSONField):
|
||||||
message_id = serializers.UUIDField(read_only=True)
|
pass
|
||||||
conversation_id = serializers.UUIDField(read_only=True)
|
|
||||||
farm_uuid = serializers.UUIDField(read_only=True, allow_null=True)
|
|
||||||
content = serializers.CharField(read_only=True, allow_blank=True)
|
|
||||||
sections = ChatSectionSerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ConversationDeleteSerializer(serializers.Serializer):
|
class ConversationDeleteSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from rest_framework.test import APIRequestFactory, force_authenticate
|
from rest_framework.test import APIRequestFactory, force_authenticate
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from external_api_adapter.adapter import AdapterResponse
|
||||||
from farm_hub.models import FarmHub, FarmType
|
from farm_hub.models import FarmHub, FarmType
|
||||||
|
|
||||||
from .models import Conversation, Message
|
from .models import Conversation, Message
|
||||||
@@ -12,6 +14,7 @@ from .views import (
|
|||||||
ChatTaskCreateView,
|
ChatTaskCreateView,
|
||||||
ChatTaskStatusView,
|
ChatTaskStatusView,
|
||||||
ContextView,
|
ContextView,
|
||||||
|
ChatView,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -218,3 +221,192 @@ class FarmAiAssistantOptionalFarmUuidTests(TestCase):
|
|||||||
self.assertEqual(delete_response.data["data"]["conversation_id"], str(landing_conversation.uuid))
|
self.assertEqual(delete_response.data["data"]["conversation_id"], str(landing_conversation.uuid))
|
||||||
self.assertIsNone(delete_response.data["data"]["farm_uuid"])
|
self.assertIsNone(delete_response.data["data"]["farm_uuid"])
|
||||||
self.assertFalse(Conversation.objects.filter(uuid=landing_conversation.uuid).exists())
|
self.assertFalse(Conversation.objects.filter(uuid=landing_conversation.uuid).exists())
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(USE_EXTERNAL_API_MOCK=True)
|
||||||
|
class FarmAiAssistantChatViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = APIRequestFactory()
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username="chat-user",
|
||||||
|
password="secret123",
|
||||||
|
email="chat-user@example.com",
|
||||||
|
phone_number="09120000001",
|
||||||
|
)
|
||||||
|
self.farm_type, _ = FarmType.objects.get_or_create(name="زراعی")
|
||||||
|
self.farm = FarmHub.objects.create(
|
||||||
|
owner=self.user,
|
||||||
|
farm_type=self.farm_type,
|
||||||
|
name="Farm Chat",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("farm_ai_assistant.views.external_api_request")
|
||||||
|
def test_chat_reads_content_and_sections_from_nested_result_payload(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"content": "برای خاک شما گندم و کلزا مناسب هستند.",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "chatTitle",
|
||||||
|
"title": "تناسب خاک برای محصولات مختلف",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"title": "محصولات مناسب",
|
||||||
|
"items": ["گندم", "کلزا"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extra_field": {"confidence": 0.92},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/farm-ai-assistant/chat/",
|
||||||
|
{
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"query": "خاک من واسه چه محصولاتی مناسبه",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = ChatView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["status"], "success")
|
||||||
|
self.assertEqual(response.data["data"]["content"], "برای خاک شما گندم و کلزا مناسب هستند.")
|
||||||
|
self.assertEqual(response.data["data"]["extra_field"], {"confidence": 0.92})
|
||||||
|
self.assertEqual(response.data["conversation_title"], "تناسب خاک برای محصولات مختلف")
|
||||||
|
self.assertEqual(response.data["data"]["sections"][1]["title"], "محصولات مناسب")
|
||||||
|
|
||||||
|
assistant_message = Message.objects.filter(role=Message.ROLE_ASSISTANT).latest("created_at")
|
||||||
|
self.assertEqual(assistant_message.content, "برای خاک شما گندم و کلزا مناسب هستند.")
|
||||||
|
self.assertEqual(assistant_message.raw_response["sections"][1]["items"], ["گندم", "کلزا"])
|
||||||
|
assistant_message.refresh_from_db()
|
||||||
|
self.assertEqual(assistant_message.conversation.title, "تناسب خاک برای محصولات مختلف")
|
||||||
|
|
||||||
|
@patch("farm_ai_assistant.views.external_api_request")
|
||||||
|
def test_chat_returns_error_when_ai_payload_is_empty(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/farm-ai-assistant/chat/",
|
||||||
|
{
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"query": "خاک من واسه چه محصولاتی مناسبه",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = ChatView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 502)
|
||||||
|
self.assertEqual(response.data["status"], "error")
|
||||||
|
self.assertIn("empty or invalid", response.data["data"]["message"])
|
||||||
|
self.assertEqual(Message.objects.filter(role=Message.ROLE_ASSISTANT).count(), 0)
|
||||||
|
|
||||||
|
@patch("farm_ai_assistant.views.external_api_request")
|
||||||
|
def test_chat_reads_sections_from_fenced_json_text_response(self, mock_external_api_request):
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data="""```json
|
||||||
|
{
|
||||||
|
"answer": "بله، خاک شما برای کاشت گل رز مناسب است.",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "recommendation",
|
||||||
|
"title": "جمعبندی اصلی",
|
||||||
|
"content": "بله، خاک شما برای کاشت گل رز مناسب است."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"title": "نکات اجرایی",
|
||||||
|
"items": ["زهکشی خاک را بررسی کنید."]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```""",
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/farm-ai-assistant/chat/",
|
||||||
|
{
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"query": "خاک من برای گل رز مناسبه؟",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = ChatView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["status"], "success")
|
||||||
|
self.assertEqual(response.data["data"]["answer"], "بله، خاک شما برای کاشت گل رز مناسب است.")
|
||||||
|
self.assertEqual(response.data["conversation_title"], "خاک")
|
||||||
|
self.assertEqual(len(response.data["data"]["sections"]), 2)
|
||||||
|
self.assertEqual(response.data["data"]["sections"][0]["title"], "جمعبندی اصلی")
|
||||||
|
self.assertEqual(response.data["data"]["sections"][1]["items"], ["زهکشی خاک را بررسی کنید."])
|
||||||
|
|
||||||
|
@patch("farm_ai_assistant.views.external_api_request")
|
||||||
|
def test_chat_does_not_change_existing_conversation_title_on_later_turns(self, mock_external_api_request):
|
||||||
|
conversation = Conversation.objects.create(
|
||||||
|
owner=self.user,
|
||||||
|
farm=self.farm,
|
||||||
|
title="عنوان اولیه",
|
||||||
|
farm_context={},
|
||||||
|
)
|
||||||
|
Message.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
farm=self.farm,
|
||||||
|
role=Message.ROLE_USER,
|
||||||
|
content="پیام اول",
|
||||||
|
raw_response={},
|
||||||
|
)
|
||||||
|
Message.objects.create(
|
||||||
|
conversation=conversation,
|
||||||
|
farm=self.farm,
|
||||||
|
role=Message.ROLE_ASSISTANT,
|
||||||
|
content="پاسخ اول",
|
||||||
|
raw_response={"sections": [{"type": "chatTitle", "title": "عنوان اولیه"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_external_api_request.return_value = AdapterResponse(
|
||||||
|
status_code=200,
|
||||||
|
data={
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"type": "chatTitle",
|
||||||
|
"title": "عنوان جدید که نباید ذخیره شود",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "recommendation",
|
||||||
|
"title": "پاسخ جدید",
|
||||||
|
"content": "این فقط پاسخ جدید است.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.factory.post(
|
||||||
|
"/api/farm-ai-assistant/chat/",
|
||||||
|
{
|
||||||
|
"farm_uuid": str(self.farm.farm_uuid),
|
||||||
|
"conversation_id": str(conversation.uuid),
|
||||||
|
"query": "سوال دوم",
|
||||||
|
},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
force_authenticate(request, user=self.user)
|
||||||
|
|
||||||
|
response = ChatView.as_view()(request)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["conversation_title"], "عنوان اولیه")
|
||||||
|
conversation.refresh_from_db()
|
||||||
|
self.assertEqual(conversation.title, "عنوان اولیه")
|
||||||
|
|||||||
+116
-67
@@ -75,6 +75,39 @@ class ContextView(FarmAccessMixin, APIView):
|
|||||||
|
|
||||||
|
|
||||||
class ConversationAccessMixin(FarmAccessMixin):
|
class ConversationAccessMixin(FarmAccessMixin):
|
||||||
|
@staticmethod
|
||||||
|
def _is_non_empty_payload(payload):
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return bool(payload)
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return bool(payload)
|
||||||
|
if isinstance(payload, str):
|
||||||
|
return bool(payload.strip())
|
||||||
|
return payload is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_adapter_text_payload(adapter_data):
|
||||||
|
if not isinstance(adapter_data, str):
|
||||||
|
return adapter_data
|
||||||
|
|
||||||
|
text = adapter_data.strip()
|
||||||
|
if not text:
|
||||||
|
return adapter_data
|
||||||
|
|
||||||
|
if text.startswith("```"):
|
||||||
|
lines = text.splitlines()
|
||||||
|
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].strip() == "```":
|
||||||
|
text = "\n".join(lines[1:-1]).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"Farm AI assistant text response could not be parsed as JSON: preview=%s",
|
||||||
|
text[:200],
|
||||||
|
)
|
||||||
|
return adapter_data
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _generate_conversation_title(query):
|
def _generate_conversation_title(query):
|
||||||
normalized_query = (query or "").strip()
|
normalized_query = (query or "").strip()
|
||||||
@@ -237,10 +270,19 @@ class ConversationAccessMixin(FarmAccessMixin):
|
|||||||
{
|
{
|
||||||
"role": message.role,
|
"role": message.role,
|
||||||
"content": message.content,
|
"content": message.content,
|
||||||
**({"sections": message.raw_response.get("sections", [])} if message.role == Message.ROLE_ASSISTANT else {}),
|
**(
|
||||||
|
{"sections": message.raw_response.get("sections", [])}
|
||||||
|
if message.role == Message.ROLE_ASSISTANT and isinstance(message.raw_response, dict)
|
||||||
|
else {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
for message in existing_messages
|
for message in existing_messages
|
||||||
if message.content or (message.role == Message.ROLE_ASSISTANT and message.raw_response.get("sections"))
|
if message.content
|
||||||
|
or (
|
||||||
|
message.role == Message.ROLE_ASSISTANT
|
||||||
|
and isinstance(message.raw_response, dict)
|
||||||
|
and message.raw_response.get("sections")
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
def _prepare_chat_input(self, request):
|
def _prepare_chat_input(self, request):
|
||||||
@@ -267,76 +309,62 @@ class ConversationAccessMixin(FarmAccessMixin):
|
|||||||
|
|
||||||
return mutable_data
|
return mutable_data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_message_content(payload):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
for key in ("content", "body", "message", "answer", "text"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
sections = payload.get("sections")
|
||||||
|
if isinstance(sections, list):
|
||||||
|
for section in sections:
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
continue
|
||||||
|
value = section.get("content")
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_chat_title(payload):
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
sections = payload.get("sections")
|
||||||
|
if not isinstance(sections, list):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
for section in sections:
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
continue
|
||||||
|
if section.get("type") != "chatTitle":
|
||||||
|
continue
|
||||||
|
title = section.get("title")
|
||||||
|
if isinstance(title, str) and title.strip():
|
||||||
|
return title.strip()[:255]
|
||||||
|
return ""
|
||||||
|
|
||||||
def _extract_assistant_payload(self, adapter_data, conversation):
|
def _extract_assistant_payload(self, adapter_data, conversation):
|
||||||
payload_source = adapter_data
|
adapter_data = self._parse_adapter_text_payload(adapter_data)
|
||||||
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
|
|
||||||
payload_source = adapter_data["data"]
|
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Farm AI assistant parsing response: conversation_id=%s adapter_type=%s adapter_keys=%s payload_source_type=%s payload_source_keys=%s",
|
"Farm AI assistant parsing response: conversation_id=%s adapter_type=%s adapter_keys=%s",
|
||||||
str(conversation.uuid),
|
str(conversation.uuid),
|
||||||
type(adapter_data).__name__,
|
type(adapter_data).__name__,
|
||||||
sorted(adapter_data.keys()) if isinstance(adapter_data, dict) else None,
|
sorted(adapter_data.keys()) if isinstance(adapter_data, dict) else None,
|
||||||
type(payload_source).__name__,
|
|
||||||
sorted(payload_source.keys()) if isinstance(payload_source, dict) else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
content = ""
|
|
||||||
sections = []
|
|
||||||
|
|
||||||
if isinstance(payload_source, dict):
|
|
||||||
content = payload_source.get("content") or ""
|
|
||||||
sections = self._normalize_sections(payload_source.get("sections"))
|
|
||||||
logger.warning(
|
|
||||||
"Farm AI assistant payload_source parsed: conversation_id=%s raw_content_present=%s raw_sections_type=%s normalized_sections_count=%s",
|
|
||||||
str(conversation.uuid),
|
|
||||||
bool(content),
|
|
||||||
type(payload_source.get("sections")).__name__ if payload_source.get("sections") is not None else None,
|
|
||||||
len(sections),
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.warning("%s %s", isinstance(payload_source, dict), not sections and isinstance(adapter_data, dict))
|
|
||||||
if not sections and isinstance(adapter_data, dict):
|
|
||||||
sections = self._normalize_sections(adapter_data.get("sections"))
|
|
||||||
logger.warning(
|
|
||||||
"Farm AI assistant root-level sections fallback: conversation_id=%s raw_sections_type=%s normalized_sections_count=%s",
|
|
||||||
str(conversation.uuid),
|
|
||||||
type(adapter_data.get("sections")).__name__ if adapter_data.get("sections") is not None else None,
|
|
||||||
len(sections),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not content and isinstance(adapter_data, dict):
|
|
||||||
content = adapter_data.get("body") or adapter_data.get("content") or ""
|
|
||||||
logger.warning(
|
|
||||||
"Farm AI assistant content fallback: conversation_id=%s body_present=%s content_present=%s final_content_present=%s",
|
|
||||||
str(conversation.uuid),
|
|
||||||
bool(adapter_data.get("body")),
|
|
||||||
bool(adapter_data.get("content")),
|
|
||||||
bool(content),
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(adapter_data, dict) and adapter_data.get("result") is not None:
|
|
||||||
logger.warning(
|
|
||||||
"Farm AI assistant unparsed result detected: conversation_id=%s result_type=%s result_keys=%s",
|
|
||||||
str(conversation.uuid),
|
|
||||||
type(adapter_data.get("result")).__name__,
|
|
||||||
sorted(adapter_data["result"].keys()) if isinstance(adapter_data.get("result"), dict) else None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Farm AI assistant final parsed payload: conversation_id=%s content_length=%s sections_count=%s",
|
"Farm AI assistant final parsed payload: conversation_id=%s payload_type=%s is_non_empty=%s",
|
||||||
str(conversation.uuid),
|
str(conversation.uuid),
|
||||||
len(content or ""),
|
type(adapter_data).__name__,
|
||||||
len(sections),
|
self._is_non_empty_payload(adapter_data),
|
||||||
)
|
)
|
||||||
|
return adapter_data
|
||||||
return {
|
|
||||||
"message_id": "",
|
|
||||||
"conversation_id": str(conversation.uuid),
|
|
||||||
"farm_uuid": self._farm_uuid_or_none(conversation.farm),
|
|
||||||
"content": content,
|
|
||||||
"sections": sections,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_chat_message(message):
|
def _serialize_chat_message(message):
|
||||||
@@ -486,6 +514,7 @@ class ChatView(ConversationAccessMixin, APIView):
|
|||||||
|
|
||||||
validated = serializer.validated_data
|
validated = serializer.validated_data
|
||||||
conversation = self._get_or_create_conversation(request, validated)
|
conversation = self._get_or_create_conversation(request, validated)
|
||||||
|
is_first_chat_turn = not conversation.messages.exists()
|
||||||
history = self._merge_history(validated, conversation)
|
history = self._merge_history(validated, conversation)
|
||||||
uploaded_images = self._collect_uploaded_images(request)
|
uploaded_images = self._collect_uploaded_images(request)
|
||||||
|
|
||||||
@@ -528,6 +557,22 @@ class ChatView(ConversationAccessMixin, APIView):
|
|||||||
status=adapter_response.status_code,
|
status=adapter_response.status_code,
|
||||||
)
|
)
|
||||||
assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation)
|
assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation)
|
||||||
|
if not self._is_non_empty_payload(assistant_payload):
|
||||||
|
logger.error(
|
||||||
|
"Farm AI assistant returned an empty payload: conversation_id=%s response_type=%s response_keys=%s",
|
||||||
|
str(conversation.uuid),
|
||||||
|
type(adapter_response.data).__name__,
|
||||||
|
sorted(adapter_response.data.keys()) if isinstance(adapter_response.data, dict) else None,
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"data": {
|
||||||
|
"message": "AI service returned an empty or invalid response.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
)
|
||||||
response_status_code = adapter_response.status_code
|
response_status_code = adapter_response.status_code
|
||||||
except ExternalAPIRequestError as exc:
|
except ExternalAPIRequestError as exc:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -544,14 +589,15 @@ class ChatView(ConversationAccessMixin, APIView):
|
|||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
farm=conversation.farm,
|
farm=conversation.farm,
|
||||||
role=Message.ROLE_ASSISTANT,
|
role=Message.ROLE_ASSISTANT,
|
||||||
content=assistant_payload.get("content", ""),
|
content=self._extract_message_content(assistant_payload),
|
||||||
raw_response={},
|
raw_response=assistant_payload if isinstance(assistant_payload, (dict, list)) else {},
|
||||||
)
|
)
|
||||||
assistant_payload["message_id"] = str(assistant_message.uuid)
|
|
||||||
assistant_message.raw_response = assistant_payload
|
|
||||||
assistant_message.save(update_fields=["raw_response"])
|
|
||||||
|
|
||||||
if not conversation.title:
|
chat_title = self._extract_chat_title(assistant_payload)
|
||||||
|
if is_first_chat_turn and chat_title:
|
||||||
|
conversation.title = chat_title
|
||||||
|
conversation.save(update_fields=["title", "updated_at"])
|
||||||
|
elif not conversation.title:
|
||||||
conversation.title = self._generate_conversation_title(validated.get("query", ""))
|
conversation.title = self._generate_conversation_title(validated.get("query", ""))
|
||||||
conversation.save(update_fields=["title", "updated_at"])
|
conversation.save(update_fields=["title", "updated_at"])
|
||||||
else:
|
else:
|
||||||
@@ -560,7 +606,10 @@ class ChatView(ConversationAccessMixin, APIView):
|
|||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
"conversation_id": str(conversation.uuid),
|
||||||
|
"farm_uuid": self._farm_uuid_or_none(conversation.farm),
|
||||||
"data": assistant_payload,
|
"data": assistant_payload,
|
||||||
|
"conversation_title": conversation.title,
|
||||||
},
|
},
|
||||||
status=response_status_code,
|
status=response_status_code,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user