From 5f0d94b8fd7027cb4dc9466d419f31e65883984b Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Mon, 27 Apr 2026 23:31:16 +0330 Subject: [PATCH] UPDATE --- api_changes_last_6_commits_combined.md | 232 ++++++++++++++++++ .../chat_api_changes_last_6_commits.md | 105 ++++++++ farm_ai_assistant/serializers.py | 9 +- farm_ai_assistant/tests.py | 192 +++++++++++++++ farm_ai_assistant/views.py | 183 +++++++++----- 5 files changed, 648 insertions(+), 73 deletions(-) create mode 100644 api_changes_last_6_commits_combined.md create mode 100644 farm_ai_assistant/chat_api_changes_last_6_commits.md diff --git a/api_changes_last_6_commits_combined.md b/api_changes_last_6_commits_combined.md new file mode 100644 index 0000000..8c68f76 --- /dev/null +++ b/api_changes_last_6_commits_combined.md @@ -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//` -> `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//` +- 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 گسترش یافته است diff --git a/farm_ai_assistant/chat_api_changes_last_6_commits.md b/farm_ai_assistant/chat_api_changes_last_6_commits.md new file mode 100644 index 0000000..67e9ce0 --- /dev/null +++ b/farm_ai_assistant/chat_api_changes_last_6_commits.md @@ -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//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//` -> `ChatDetailView` +- `chats//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 تغییر کرده است diff --git a/farm_ai_assistant/serializers.py b/farm_ai_assistant/serializers.py index 6ae3d1b..f6f2d5f 100644 --- a/farm_ai_assistant/serializers.py +++ b/farm_ai_assistant/serializers.py @@ -19,6 +19,7 @@ class ChatSectionSerializer(serializers.Serializer): class ConversationSummarySerializer(serializers.Serializer): 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) message_count = serializers.IntegerField(read_only=True) @@ -46,12 +47,8 @@ class ConversationMessagesSerializer(serializers.Serializer): messages = ChatHistoryMessageSerializer(many=True, read_only=True) -class ChatResponseDataSerializer(serializers.Serializer): - message_id = serializers.UUIDField(read_only=True) - 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 ChatResponseDataSerializer(serializers.JSONField): + pass class ConversationDeleteSerializer(serializers.Serializer): diff --git a/farm_ai_assistant/tests.py b/farm_ai_assistant/tests.py index d4dee3e..fd9c4f4 100644 --- a/farm_ai_assistant/tests.py +++ b/farm_ai_assistant/tests.py @@ -1,7 +1,9 @@ from django.contrib.auth import get_user_model from django.test import TestCase, override_settings 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 .models import Conversation, Message @@ -12,6 +14,7 @@ from .views import ( ChatTaskCreateView, ChatTaskStatusView, ContextView, + ChatView, ) @@ -218,3 +221,192 @@ class FarmAiAssistantOptionalFarmUuidTests(TestCase): self.assertEqual(delete_response.data["data"]["conversation_id"], str(landing_conversation.uuid)) self.assertIsNone(delete_response.data["data"]["farm_uuid"]) 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, "عنوان اولیه") diff --git a/farm_ai_assistant/views.py b/farm_ai_assistant/views.py index 8d7028a..a2d2a24 100644 --- a/farm_ai_assistant/views.py +++ b/farm_ai_assistant/views.py @@ -75,6 +75,39 @@ class ContextView(FarmAccessMixin, APIView): 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 def _generate_conversation_title(query): normalized_query = (query or "").strip() @@ -237,10 +270,19 @@ class ConversationAccessMixin(FarmAccessMixin): { "role": message.role, "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 - 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): @@ -267,76 +309,62 @@ class ConversationAccessMixin(FarmAccessMixin): 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): - payload_source = adapter_data - if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): - payload_source = adapter_data["data"] + adapter_data = self._parse_adapter_text_payload(adapter_data) 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), type(adapter_data).__name__, 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( - "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), - len(content or ""), - len(sections), + type(adapter_data).__name__, + self._is_non_empty_payload(adapter_data), ) - - return { - "message_id": "", - "conversation_id": str(conversation.uuid), - "farm_uuid": self._farm_uuid_or_none(conversation.farm), - "content": content, - "sections": sections, - } + return adapter_data @staticmethod def _serialize_chat_message(message): @@ -486,6 +514,7 @@ class ChatView(ConversationAccessMixin, APIView): validated = serializer.validated_data conversation = self._get_or_create_conversation(request, validated) + is_first_chat_turn = not conversation.messages.exists() history = self._merge_history(validated, conversation) uploaded_images = self._collect_uploaded_images(request) @@ -528,6 +557,22 @@ class ChatView(ConversationAccessMixin, APIView): status=adapter_response.status_code, ) 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 except ExternalAPIRequestError as exc: return Response( @@ -544,14 +589,15 @@ class ChatView(ConversationAccessMixin, APIView): conversation=conversation, farm=conversation.farm, role=Message.ROLE_ASSISTANT, - content=assistant_payload.get("content", ""), - raw_response={}, + content=self._extract_message_content(assistant_payload), + 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.save(update_fields=["title", "updated_at"]) else: @@ -560,7 +606,10 @@ class ChatView(ConversationAccessMixin, APIView): return Response( { "status": "success", + "conversation_id": str(conversation.uuid), + "farm_uuid": self._farm_uuid_or_none(conversation.farm), "data": assistant_payload, + "conversation_title": conversation.title, }, status=response_status_code, )