This commit is contained in:
2026-04-27 23:31:16 +03:30
parent f1643b0a51
commit 5f0d94b8fd
5 changed files with 648 additions and 73 deletions
@@ -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 تغییر کرده است
+3 -6
View File
@@ -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):
+192
View File
@@ -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, "عنوان اولیه")
+116 -67
View File
@@ -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,
)