From cef1b5335a1e08dfa29d56f3053317bdd63bceaa Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Fri, 27 Mar 2026 18:32:49 +0330 Subject: [PATCH] UPDATE --- FARM_AI_ASSISTANT_API.md | 4 +- .../json/ai/rag/chat/generate/post_202.json | 9 + external_api_adapter/mock_loader.py | 12 + farm_ai_assistant/serializers.py | 17 + farm_ai_assistant/urls.py | 12 +- farm_ai_assistant/views.py | 291 ++++++++++++++++-- 6 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 external_api_adapter/json/ai/rag/chat/generate/post_202.json diff --git a/FARM_AI_ASSISTANT_API.md b/FARM_AI_ASSISTANT_API.md index 902ecb0..58dcace 100644 --- a/FARM_AI_ASSISTANT_API.md +++ b/FARM_AI_ASSISTANT_API.md @@ -181,7 +181,9 @@ | ردیف | API | متد | Endpoint | وضعیت | |------|-----|------|----------|--------| -| ۱ | Farm AI Chat | POST | `/api/farm-ai-assistant/chat/` (پیشنهادی) | **پیاده‌سازی نشده** | +| ۱ | Farm AI Chat | POST | `/api/farm-ai-assistant/chat/` | موجود | +| ۱.۱ | Farm AI Chat Task Create | POST | `/api/farm-ai-assistant/chat/task/` | موجود | +| ۱.۲ | Farm AI Chat Task Status | GET | `/api/farm-ai-assistant/chat/task/{task_id}/status/` | موجود | | ۲ | Farm Context | GET | `/api/farm-ai-assistant/context/` (پیشنهادی) | **پیاده‌سازی نشده**؛ استفاده از configهای آبیاری/کوددهی | | ۳ | توصیه آبیاری | GET | `/api/irrigation-recommendation/config/` | موجود (mock) | | ۳ | توصیه آبیاری | POST | `/api/irrigation-recommendation/recommend/` | موجود (mock) | diff --git a/external_api_adapter/json/ai/rag/chat/generate/post_202.json b/external_api_adapter/json/ai/rag/chat/generate/post_202.json new file mode 100644 index 0000000..93aee74 --- /dev/null +++ b/external_api_adapter/json/ai/rag/chat/generate/post_202.json @@ -0,0 +1,9 @@ +{ + "code": 202, + "msg": "تسک چت دستیار مزرعه در صف قرار گرفت.", + "data": { + "task_id": "farm-ai-chat-task-123", + "status": "PENDING", + "status_url": "/api/tasks/farm-ai-chat-task-123/status/" + } +} diff --git a/external_api_adapter/mock_loader.py b/external_api_adapter/mock_loader.py index 0a2d13b..e54e1b1 100644 --- a/external_api_adapter/mock_loader.py +++ b/external_api_adapter/mock_loader.py @@ -47,6 +47,18 @@ class MockLoader: if flat_files: return flat_files + normalized_parts = [part for part in str(path).strip().strip("/").split("/") if part] + for index in range(len(normalized_parts) - 1, -1, -1): + candidate_parts = normalized_parts[:index] + normalized_parts[index + 1 :] + if not candidate_parts: + continue + + candidate_directory = service_root / Path(*candidate_parts) + if candidate_directory.exists() and candidate_directory.is_dir(): + candidate_files = list(candidate_directory.glob(pattern)) + if candidate_files: + return candidate_files + raise MockDirectoryNotFound( f"Mock directory not found for service='{service_name}' path='{path}': {directory_path}" ) diff --git a/farm_ai_assistant/serializers.py b/farm_ai_assistant/serializers.py index 5d0f47a..0b466eb 100644 --- a/farm_ai_assistant/serializers.py +++ b/farm_ai_assistant/serializers.py @@ -51,6 +51,23 @@ class ConversationDeleteSerializer(serializers.Serializer): conversation_id = serializers.UUIDField(read_only=True) +class ChatTaskSubmitDataSerializer(serializers.Serializer): + task_id = serializers.CharField(required=False, allow_blank=True) + status = serializers.CharField(required=False, allow_blank=True) + status_url = serializers.CharField(required=False, allow_blank=True) + conversation_id = serializers.UUIDField(read_only=True) + message_id = serializers.UUIDField(read_only=True) + + +class ChatTaskStatusDataSerializer(serializers.Serializer): + task_id = serializers.CharField(required=False, allow_blank=True) + status = serializers.CharField(required=False, allow_blank=True) + conversation_id = serializers.UUIDField(read_only=True) + progress = serializers.JSONField(required=False) + result = serializers.JSONField(required=False) + error = serializers.CharField(required=False, allow_blank=True) + + class ChatPostSerializer(serializers.Serializer): content = serializers.CharField(required=False, allow_blank=True, default="") images = serializers.ListField( diff --git a/farm_ai_assistant/urls.py b/farm_ai_assistant/urls.py index e20a754..1b1af3f 100644 --- a/farm_ai_assistant/urls.py +++ b/farm_ai_assistant/urls.py @@ -1,10 +1,20 @@ from django.urls import path -from .views import ChatDetailView, ChatListCreateView, ChatMessagesView, ChatView, ContextView +from .views import ( + ChatDetailView, + ChatListCreateView, + ChatMessagesView, + ChatTaskCreateView, + ChatTaskStatusView, + ChatView, + ContextView, +) urlpatterns = [ path("context/", ContextView.as_view(), name="farm-ai-assistant-context"), path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"), + path("chat/task/", ChatTaskCreateView.as_view(), name="farm-ai-assistant-chat-task-create"), + path("chat/task//status/", ChatTaskStatusView.as_view(), name="farm-ai-assistant-chat-task-status"), path("chats/", ChatListCreateView.as_view(), name="farm-ai-assistant-chat-list-create"), path("chats//", ChatDetailView.as_view(), name="farm-ai-assistant-chat-detail"), path("chats//messages/", ChatMessagesView.as_view(), name="farm-ai-assistant-chat-messages"), diff --git a/farm_ai_assistant/views.py b/farm_ai_assistant/views.py index e2fbd66..e986af6 100644 --- a/farm_ai_assistant/views.py +++ b/farm_ai_assistant/views.py @@ -19,6 +19,8 @@ from .models import Conversation, Message from .serializers import ( ChatPostSerializer, ChatResponseDataSerializer, + ChatTaskStatusDataSerializer, + ChatTaskSubmitDataSerializer, ConversationCreateSerializer, ConversationDeleteSerializer, ConversationMessagesSerializer, @@ -87,6 +89,46 @@ class ConversationAccessMixin: payload["conversation_id"] = str(conversation_id) return payload + def _get_or_create_conversation(self, request, validated): + conversation_id = validated.get("conversation_id") + farm_context = validated.get("farm_context") + title = validated.get("title", "").strip() + + if conversation_id: + conversation = self._get_conversation(request, conversation_id) + updated_fields = [] + if farm_context is not None: + conversation.farm_context = farm_context + updated_fields.append("farm_context") + if title: + conversation.title = title + updated_fields.append("title") + if updated_fields: + updated_fields.append("updated_at") + conversation.save(update_fields=updated_fields) + return conversation + + return Conversation.objects.create( + owner=request.user, + title=title or (validated.get("content", "")[:255]) or "New chat", + farm_context=farm_context or {}, + ) + + @staticmethod + def _build_adapter_payload(request, validated, conversation): + payload = { + "content": validated.get("content", ""), + "query": validated.get("content", ""), + "images": validated.get("images", []), + "conversation_id": str(conversation.uuid), + "user_id": request.user.id, + } + if "farm_context" in validated: + payload["farm_context"] = validated.get("farm_context") or {} + if "title" in validated: + payload["title"] = validated.get("title", "") + return payload + def _extract_assistant_payload(self, adapter_data, conversation_id): payload_source = adapter_data if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): @@ -112,6 +154,53 @@ class ConversationAccessMixin: "sections": sections, } + @staticmethod + def _extract_task_submit_payload(adapter_data, conversation_id, message_id): + payload_source = adapter_data + if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): + payload_source = adapter_data["data"] + + if not isinstance(payload_source, dict): + payload_source = {} + + return { + "task_id": str(payload_source.get("task_id") or ""), + "status": str(payload_source.get("status") or ""), + "status_url": str(payload_source.get("status_url") or ""), + "conversation_id": str(conversation_id), + "message_id": str(message_id), + } + + def _extract_task_status_payload(self, adapter_data, task_id, conversation_id=None): + payload_source = adapter_data + if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict): + payload_source = adapter_data["data"] + + if not isinstance(payload_source, dict): + payload_source = {} + + task_status_payload = { + "task_id": str(payload_source.get("task_id") or task_id), + "status": str(payload_source.get("status") or ""), + } + if conversation_id: + task_status_payload["conversation_id"] = str(conversation_id) + + progress = payload_source.get("progress") + if progress is not None: + task_status_payload["progress"] = progress + elif payload_source.get("message") and task_status_payload["status"] != "SUCCESS": + task_status_payload["progress"] = {"message": payload_source.get("message")} + + if payload_source.get("error"): + task_status_payload["error"] = str(payload_source["error"]) + + result = payload_source.get("result") + if result is not None: + task_status_payload["result"] = result + + return task_status_payload + @staticmethod def _serialize_chat_message(message): raw_response = message.raw_response if isinstance(message.raw_response, dict) else {} @@ -126,6 +215,55 @@ class ConversationAccessMixin: "created_at": message.created_at, } + @staticmethod + def _find_user_message_for_task(request, task_id): + return ( + Message.objects.select_related("conversation") + .filter( + conversation__owner=request.user, + role=Message.ROLE_USER, + raw_response__task_id=task_id, + ) + .order_by("-created_at") + .first() + ) + + def _persist_task_result(self, user_message, task_id, result): + assistant_payload = self._extract_assistant_payload(result, user_message.conversation.uuid) + assistant_message = ( + user_message.conversation.messages.filter( + role=Message.ROLE_ASSISTANT, + raw_response__task_id=task_id, + ) + .order_by("-created_at") + .first() + ) + + if assistant_message is None: + assistant_message = Message.objects.create( + conversation=user_message.conversation, + role=Message.ROLE_ASSISTANT, + content=assistant_payload.get("content", ""), + raw_response={}, + ) + + assistant_payload["message_id"] = str(assistant_message.uuid) + assistant_payload["task_id"] = task_id + assistant_message.content = assistant_payload.get("content", "") + assistant_message.raw_response = assistant_payload + assistant_message.save(update_fields=["content", "raw_response"]) + + conversation = user_message.conversation + if not conversation.title: + conversation.title = ( + user_message.content or assistant_payload.get("content", "") or "New chat" + )[:255] + conversation.save(update_fields=["title", "updated_at"]) + else: + conversation.save(update_fields=["updated_at"]) + + return assistant_payload + class ChatListCreateView(ConversationAccessMixin, APIView): permission_classes = [IsAuthenticated] @@ -232,28 +370,7 @@ class ChatView(ConversationAccessMixin, APIView): serializer.is_valid(raise_exception=True) validated = serializer.validated_data - conversation_id = validated.get("conversation_id") - farm_context = validated.get("farm_context") - title = validated.get("title", "").strip() - - if conversation_id: - conversation = self._get_conversation(request, conversation_id) - updated_fields = [] - if farm_context is not None: - conversation.farm_context = farm_context - updated_fields.append("farm_context") - if title: - conversation.title = title - updated_fields.append("title") - if updated_fields: - updated_fields.append("updated_at") - conversation.save(update_fields=updated_fields) - else: - conversation = Conversation.objects.create( - owner=request.user, - title=title or (validated.get("content", "")[:255]) or "New chat", - farm_context=farm_context or {}, - ) + conversation = self._get_or_create_conversation(request, validated) user_message = Message.objects.create( conversation=conversation, @@ -263,8 +380,7 @@ class ChatView(ConversationAccessMixin, APIView): raw_response={}, ) - adapter_payload = dict(request.data) - adapter_payload["conversation_id"] = str(conversation.uuid) + adapter_payload = self._build_adapter_payload(request, validated, conversation) try: adapter_response = external_api_request( @@ -310,3 +426,130 @@ class ChatView(ConversationAccessMixin, APIView): }, status=response_status_code, ) + + +class ChatTaskCreateView(ConversationAccessMixin, APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Farm AI Assistant"], + request=ChatPostSerializer, + responses={202: status_response("FarmAiAssistantChatTaskCreateResponse", data=ChatTaskSubmitDataSerializer())}, + ) + def post(self, request): + serializer = ChatPostSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + validated = serializer.validated_data + conversation = self._get_or_create_conversation(request, validated) + user_message = Message.objects.create( + conversation=conversation, + role=Message.ROLE_USER, + content=validated.get("content", ""), + images=validated.get("images", []), + raw_response={}, + ) + + adapter_payload = self._build_adapter_payload(request, validated, conversation) + try: + adapter_response = external_api_request( + "ai", + "/rag/chat/generate", + method="POST", + payload=adapter_payload, + ) + except ExternalAPIRequestError: + return Response( + { + "status": "error", + "data": { + "message": "External AI service is unavailable.", + }, + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + if adapter_response.status_code >= 400: + return Response( + { + "status": "error", + "data": adapter_response.data, + }, + status=adapter_response.status_code, + ) + + task_payload = self._extract_task_submit_payload( + adapter_response.data, + conversation.uuid, + user_message.uuid, + ) + user_message.raw_response = task_payload + user_message.save(update_fields=["raw_response"]) + conversation.save(update_fields=["updated_at"]) + + return Response( + { + "status": "success", + "data": task_payload, + }, + status=adapter_response.status_code, + ) + + +class ChatTaskStatusView(ConversationAccessMixin, APIView): + permission_classes = [IsAuthenticated] + + @extend_schema( + tags=["Farm AI Assistant"], + parameters=[ + OpenApiParameter(name="task_id", type=OpenApiTypes.STR, location=OpenApiParameter.PATH), + ], + responses={200: status_response("FarmAiAssistantChatTaskStatusResponse", data=ChatTaskStatusDataSerializer())}, + ) + def get(self, request, task_id): + try: + adapter_response = external_api_request( + "ai", + f"/tasks/{task_id}/status", + method="GET", + ) + except ExternalAPIRequestError: + return Response( + { + "status": "error", + "data": { + "message": "External AI service is unavailable.", + }, + }, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + + if adapter_response.status_code >= 400: + return Response( + { + "status": "error", + "data": adapter_response.data, + }, + status=adapter_response.status_code, + ) + + user_message = self._find_user_message_for_task(request, task_id) + conversation_id = user_message.conversation.uuid if user_message else None + task_status_payload = self._extract_task_status_payload( + adapter_response.data, + task_id, + conversation_id=conversation_id, + ) + + result = task_status_payload.get("result") + if user_message and task_status_payload.get("status") == "SUCCESS" and isinstance(result, dict): + assistant_payload = self._persist_task_result(user_message, task_id, result) + task_status_payload["result"] = assistant_payload + + return Response( + { + "status": "success", + "data": task_status_payload, + }, + status=adapter_response.status_code, + )