2026-03-25 15:43:00 +03:30
|
|
|
"""Farm AI Assistant API views."""
|
|
|
|
|
|
2026-03-27 18:18:31 +03:30
|
|
|
from copy import deepcopy
|
|
|
|
|
|
|
|
|
|
from django.db.models import Count
|
|
|
|
|
from django.http import Http404
|
2026-03-24 20:10:48 +03:30
|
|
|
from rest_framework import serializers, status
|
2026-03-25 15:43:00 +03:30
|
|
|
from rest_framework.permissions import IsAuthenticated
|
2026-03-24 20:10:48 +03:30
|
|
|
from rest_framework.response import Response
|
|
|
|
|
from rest_framework.views import APIView
|
|
|
|
|
from drf_spectacular.types import OpenApiTypes
|
2026-03-25 15:43:00 +03:30
|
|
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
2026-02-25 12:21:53 +03:30
|
|
|
|
2026-03-24 20:10:48 +03:30
|
|
|
from config.swagger import status_response
|
2026-03-25 00:51:04 +03:30
|
|
|
from external_api_adapter import request as external_api_request
|
2026-03-27 18:18:31 +03:30
|
|
|
from external_api_adapter.exceptions import ExternalAPIRequestError
|
2026-04-02 23:25:39 +03:30
|
|
|
from farm_hub.models import FarmHub
|
2026-03-27 18:18:31 +03:30
|
|
|
from .mock_data import CHAT_RESPONSE_DATA, CONTEXT_RESPONSE_DATA
|
2026-03-25 15:43:00 +03:30
|
|
|
from .models import Conversation, Message
|
2026-03-27 18:18:31 +03:30
|
|
|
from .serializers import (
|
|
|
|
|
ChatPostSerializer,
|
|
|
|
|
ChatResponseDataSerializer,
|
2026-03-27 18:32:49 +03:30
|
|
|
ChatTaskStatusDataSerializer,
|
|
|
|
|
ChatTaskSubmitDataSerializer,
|
2026-03-27 18:18:31 +03:30
|
|
|
ConversationCreateSerializer,
|
|
|
|
|
ConversationDeleteSerializer,
|
|
|
|
|
ConversationMessagesSerializer,
|
|
|
|
|
ConversationSummarySerializer,
|
|
|
|
|
)
|
2026-02-25 12:21:53 +03:30
|
|
|
|
|
|
|
|
|
2026-04-02 23:25:39 +03:30
|
|
|
class FarmAccessMixin:
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _get_farm(request, farm_uuid):
|
|
|
|
|
if not farm_uuid:
|
|
|
|
|
raise serializers.ValidationError({"farm_uuid": ["This field is required."]})
|
2026-04-23 15:53:59 +03:30
|
|
|
return FarmAccessMixin._get_optional_farm(request, farm_uuid)
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _get_optional_farm(request, farm_uuid):
|
|
|
|
|
if not farm_uuid:
|
|
|
|
|
return None
|
2026-04-02 23:25:39 +03:30
|
|
|
try:
|
|
|
|
|
return FarmHub.objects.get(farm_uuid=farm_uuid, owner=request.user)
|
|
|
|
|
except FarmHub.DoesNotExist as exc:
|
|
|
|
|
raise Http404("Farm not found") from exc
|
|
|
|
|
|
2026-04-23 15:53:59 +03:30
|
|
|
@staticmethod
|
|
|
|
|
def _farm_uuid_or_none(farm):
|
|
|
|
|
return str(farm.farm_uuid) if farm else None
|
|
|
|
|
|
2026-04-02 23:25:39 +03:30
|
|
|
|
|
|
|
|
class ContextView(FarmAccessMixin, APIView):
|
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
|
|
2026-03-24 20:10:48 +03:30
|
|
|
@extend_schema(
|
|
|
|
|
tags=["Farm AI Assistant"],
|
2026-04-02 23:25:39 +03:30
|
|
|
parameters=[
|
2026-04-23 15:53:59 +03:30
|
|
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
2026-04-02 23:25:39 +03:30
|
|
|
],
|
2026-03-24 20:10:48 +03:30
|
|
|
responses={200: status_response("FarmAiAssistantContextResponse", data=serializers.JSONField())},
|
|
|
|
|
)
|
2026-02-25 12:21:53 +03:30
|
|
|
def get(self, request):
|
2026-04-23 15:53:59 +03:30
|
|
|
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
2026-04-02 23:25:39 +03:30
|
|
|
data = deepcopy(CONTEXT_RESPONSE_DATA)
|
2026-04-23 15:53:59 +03:30
|
|
|
data["farm_uuid"] = self._farm_uuid_or_none(farm)
|
2026-03-24 20:10:48 +03:30
|
|
|
return Response(
|
2026-04-02 23:25:39 +03:30
|
|
|
{"status": "success", "data": data},
|
2026-03-24 20:10:48 +03:30
|
|
|
status=status.HTTP_200_OK,
|
2026-02-25 12:21:53 +03:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-02 23:25:39 +03:30
|
|
|
class ConversationAccessMixin(FarmAccessMixin):
|
2026-03-27 18:18:31 +03:30
|
|
|
@staticmethod
|
2026-04-02 23:25:39 +03:30
|
|
|
def _get_conversation(request, conversation_id, farm_uuid=None):
|
|
|
|
|
filters = {"uuid": conversation_id, "owner": request.user}
|
|
|
|
|
if farm_uuid:
|
|
|
|
|
filters["farm__farm_uuid"] = farm_uuid
|
2026-04-23 15:53:59 +03:30
|
|
|
else:
|
|
|
|
|
filters["farm__isnull"] = True
|
2026-03-27 18:18:31 +03:30
|
|
|
try:
|
2026-04-02 23:25:39 +03:30
|
|
|
return Conversation.objects.select_related("farm").get(**filters)
|
2026-03-27 18:18:31 +03:30
|
|
|
except Conversation.DoesNotExist as exc:
|
|
|
|
|
raise Http404("Conversation not found") from exc
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _normalize_sections(raw_sections):
|
|
|
|
|
if not isinstance(raw_sections, list):
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
allowed_keys = {
|
|
|
|
|
"type",
|
|
|
|
|
"title",
|
|
|
|
|
"content",
|
|
|
|
|
"items",
|
|
|
|
|
"icon",
|
|
|
|
|
"frequency",
|
|
|
|
|
"amount",
|
|
|
|
|
"timing",
|
|
|
|
|
"expandableExplanation",
|
|
|
|
|
}
|
|
|
|
|
normalized_sections = []
|
|
|
|
|
for section in raw_sections:
|
|
|
|
|
if not isinstance(section, dict) or not section.get("type"):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
normalized_section = {}
|
|
|
|
|
for key in allowed_keys:
|
|
|
|
|
value = section.get(key)
|
|
|
|
|
if value is None:
|
|
|
|
|
continue
|
|
|
|
|
if key == "items":
|
|
|
|
|
if not isinstance(value, list):
|
|
|
|
|
continue
|
|
|
|
|
normalized_section[key] = [str(item) for item in value]
|
|
|
|
|
continue
|
|
|
|
|
normalized_section[key] = str(value) if key != "type" else value
|
|
|
|
|
|
|
|
|
|
normalized_sections.append(normalized_section)
|
|
|
|
|
return normalized_sections
|
|
|
|
|
|
2026-04-02 23:25:39 +03:30
|
|
|
def _build_mock_assistant_payload(self, conversation):
|
2026-03-27 18:18:31 +03:30
|
|
|
payload = deepcopy(CHAT_RESPONSE_DATA)
|
2026-04-02 23:25:39 +03:30
|
|
|
payload["conversation_id"] = str(conversation.uuid)
|
2026-04-23 15:53:59 +03:30
|
|
|
payload["farm_uuid"] = self._farm_uuid_or_none(conversation.farm)
|
2026-03-27 18:18:31 +03:30
|
|
|
return payload
|
|
|
|
|
|
2026-03-27 18:32:49 +03:30
|
|
|
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()
|
2026-04-23 15:53:59 +03:30
|
|
|
farm = self._get_optional_farm(request, validated.get("farm_uuid"))
|
2026-03-27 18:32:49 +03:30
|
|
|
|
|
|
|
|
if conversation_id:
|
2026-04-23 15:53:59 +03:30
|
|
|
conversation = self._get_conversation(
|
|
|
|
|
request,
|
|
|
|
|
conversation_id,
|
|
|
|
|
farm.farm_uuid if farm else None,
|
|
|
|
|
)
|
2026-03-27 18:32:49 +03:30
|
|
|
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,
|
2026-04-02 23:25:39 +03:30
|
|
|
farm=farm,
|
2026-03-27 18:32:49 +03:30
|
|
|
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,
|
|
|
|
|
}
|
2026-04-23 15:53:59 +03:30
|
|
|
if conversation.farm:
|
|
|
|
|
payload["farm_uuid"] = str(conversation.farm.farm_uuid)
|
2026-03-27 18:32:49 +03:30
|
|
|
if "farm_context" in validated:
|
|
|
|
|
payload["farm_context"] = validated.get("farm_context") or {}
|
|
|
|
|
if "title" in validated:
|
|
|
|
|
payload["title"] = validated.get("title", "")
|
|
|
|
|
return payload
|
|
|
|
|
|
2026-04-02 23:25:39 +03:30
|
|
|
def _extract_assistant_payload(self, adapter_data, conversation):
|
2026-03-27 18:18:31 +03:30
|
|
|
payload_source = adapter_data
|
|
|
|
|
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
|
|
|
|
|
payload_source = adapter_data["data"]
|
|
|
|
|
|
|
|
|
|
content = ""
|
|
|
|
|
sections = []
|
|
|
|
|
|
|
|
|
|
if isinstance(payload_source, dict):
|
|
|
|
|
content = payload_source.get("content") or ""
|
|
|
|
|
sections = self._normalize_sections(payload_source.get("sections"))
|
|
|
|
|
|
|
|
|
|
if not sections and isinstance(adapter_data, dict):
|
|
|
|
|
sections = self._normalize_sections(adapter_data.get("sections"))
|
|
|
|
|
|
|
|
|
|
if not content and isinstance(adapter_data, dict):
|
|
|
|
|
content = adapter_data.get("body") or adapter_data.get("content") or ""
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"message_id": "",
|
2026-04-02 23:25:39 +03:30
|
|
|
"conversation_id": str(conversation.uuid),
|
2026-04-23 15:53:59 +03:30
|
|
|
"farm_uuid": self._farm_uuid_or_none(conversation.farm),
|
2026-03-27 18:18:31 +03:30
|
|
|
"content": content,
|
|
|
|
|
"sections": sections,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 18:32:49 +03:30
|
|
|
@staticmethod
|
2026-04-02 23:25:39 +03:30
|
|
|
def _extract_task_submit_payload(adapter_data, conversation, message_id):
|
2026-03-27 18:32:49 +03:30
|
|
|
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 ""),
|
2026-04-02 23:25:39 +03:30
|
|
|
"conversation_id": str(conversation.uuid),
|
2026-03-27 18:32:49 +03:30
|
|
|
"message_id": str(message_id),
|
2026-04-23 15:53:59 +03:30
|
|
|
"farm_uuid": ConversationAccessMixin._farm_uuid_or_none(conversation.farm),
|
2026-03-27 18:32:49 +03:30
|
|
|
}
|
|
|
|
|
|
2026-04-02 23:25:39 +03:30
|
|
|
def _extract_task_status_payload(self, adapter_data, task_id, conversation=None, farm_uuid=None):
|
2026-03-27 18:32:49 +03:30
|
|
|
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 ""),
|
|
|
|
|
}
|
2026-04-02 23:25:39 +03:30
|
|
|
if conversation:
|
|
|
|
|
task_status_payload["conversation_id"] = str(conversation.uuid)
|
2026-04-23 15:53:59 +03:30
|
|
|
task_status_payload["farm_uuid"] = self._farm_uuid_or_none(conversation.farm)
|
|
|
|
|
elif farm_uuid is not None:
|
2026-04-02 23:25:39 +03:30
|
|
|
task_status_payload["farm_uuid"] = str(farm_uuid)
|
2026-03-27 18:32:49 +03:30
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
2026-03-29 13:40:23 +03:30
|
|
|
def _extract_structured_task_result(self, adapter_data):
|
|
|
|
|
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):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
result = payload_source.get("result")
|
|
|
|
|
if isinstance(result, dict):
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
if payload_source.get("status") == "SUCCESS":
|
|
|
|
|
content = payload_source.get("content")
|
|
|
|
|
sections = payload_source.get("sections")
|
|
|
|
|
if content or sections:
|
|
|
|
|
return {
|
|
|
|
|
"content": content or "",
|
|
|
|
|
"sections": sections or [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
2026-03-27 18:18:31 +03:30
|
|
|
@staticmethod
|
|
|
|
|
def _serialize_chat_message(message):
|
|
|
|
|
raw_response = message.raw_response if isinstance(message.raw_response, dict) else {}
|
|
|
|
|
sections = raw_response.get("sections") if message.role == Message.ROLE_ASSISTANT else []
|
|
|
|
|
return {
|
|
|
|
|
"message_id": str(message.uuid),
|
|
|
|
|
"conversation_id": str(message.conversation.uuid),
|
2026-04-23 15:53:59 +03:30
|
|
|
"farm_uuid": ConversationAccessMixin._farm_uuid_or_none(message.farm),
|
2026-03-27 18:18:31 +03:30
|
|
|
"role": message.role,
|
|
|
|
|
"content": message.content,
|
|
|
|
|
"sections": ConversationAccessMixin._normalize_sections(sections),
|
|
|
|
|
"images": message.images if isinstance(message.images, list) else [],
|
|
|
|
|
"created_at": message.created_at,
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 18:32:49 +03:30
|
|
|
@staticmethod
|
2026-04-02 23:25:39 +03:30
|
|
|
def _find_user_message_for_task(request, task_id, farm_uuid):
|
2026-04-23 15:53:59 +03:30
|
|
|
filters = {
|
|
|
|
|
"conversation__owner": request.user,
|
|
|
|
|
"role": Message.ROLE_USER,
|
|
|
|
|
"raw_response__task_id": task_id,
|
|
|
|
|
}
|
|
|
|
|
if farm_uuid:
|
|
|
|
|
filters["farm__farm_uuid"] = farm_uuid
|
|
|
|
|
else:
|
|
|
|
|
filters["farm__isnull"] = True
|
2026-03-27 18:32:49 +03:30
|
|
|
return (
|
2026-04-02 23:25:39 +03:30
|
|
|
Message.objects.select_related("conversation", "farm")
|
2026-04-23 15:53:59 +03:30
|
|
|
.filter(**filters)
|
2026-03-27 18:32:49 +03:30
|
|
|
.order_by("-created_at")
|
|
|
|
|
.first()
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _persist_task_result(self, user_message, task_id, result):
|
2026-04-02 23:25:39 +03:30
|
|
|
assistant_payload = self._extract_assistant_payload(result, user_message.conversation)
|
2026-03-27 18:32:49 +03:30
|
|
|
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,
|
2026-04-02 23:25:39 +03:30
|
|
|
farm=user_message.farm,
|
2026-03-27 18:32:49 +03:30
|
|
|
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
|
|
|
|
|
|
2026-03-27 18:18:31 +03:30
|
|
|
|
|
|
|
|
class ChatListCreateView(ConversationAccessMixin, APIView):
|
2026-03-25 15:43:00 +03:30
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
|
|
|
|
|
|
@extend_schema(
|
|
|
|
|
tags=["Farm AI Assistant"],
|
2026-04-02 23:25:39 +03:30
|
|
|
parameters=[
|
2026-04-23 15:53:59 +03:30
|
|
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
2026-04-02 23:25:39 +03:30
|
|
|
],
|
2026-03-27 18:18:31 +03:30
|
|
|
responses={200: status_response("FarmAiAssistantConversationListResponse", data=ConversationSummarySerializer(many=True))},
|
2026-03-25 15:43:00 +03:30
|
|
|
)
|
|
|
|
|
def get(self, request):
|
2026-04-23 15:53:59 +03:30
|
|
|
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
2026-03-27 18:18:31 +03:30
|
|
|
conversations = (
|
2026-04-02 23:25:39 +03:30
|
|
|
Conversation.objects.filter(owner=request.user, farm=farm)
|
2026-03-27 18:18:31 +03:30
|
|
|
.annotate(message_count=Count("messages"))
|
|
|
|
|
.order_by("-updated_at", "-created_at")
|
|
|
|
|
)
|
|
|
|
|
serializer = ConversationSummarySerializer(conversations, many=True)
|
2026-03-25 15:43:00 +03:30
|
|
|
return Response({"status": "success", "data": serializer.data}, status=status.HTTP_200_OK)
|
|
|
|
|
|
2026-03-27 18:18:31 +03:30
|
|
|
@extend_schema(
|
|
|
|
|
tags=["Farm AI Assistant"],
|
|
|
|
|
request=ConversationCreateSerializer,
|
|
|
|
|
responses={201: status_response("FarmAiAssistantConversationCreateResponse", data=ConversationSummarySerializer())},
|
|
|
|
|
)
|
|
|
|
|
def post(self, request):
|
|
|
|
|
serializer = ConversationCreateSerializer(data=request.data)
|
|
|
|
|
serializer.is_valid(raise_exception=True)
|
2026-03-25 15:43:00 +03:30
|
|
|
|
2026-03-27 18:18:31 +03:30
|
|
|
validated = serializer.validated_data
|
2026-04-23 15:53:59 +03:30
|
|
|
farm = self._get_optional_farm(request, validated.get("farm_uuid"))
|
2026-03-27 18:18:31 +03:30
|
|
|
conversation = Conversation.objects.create(
|
|
|
|
|
owner=request.user,
|
2026-04-02 23:25:39 +03:30
|
|
|
farm=farm,
|
2026-03-27 18:18:31 +03:30
|
|
|
title=validated.get("title", "").strip() or "New chat",
|
|
|
|
|
farm_context=validated.get("farm_context") or {},
|
|
|
|
|
)
|
2026-03-25 15:43:00 +03:30
|
|
|
|
2026-03-27 18:18:31 +03:30
|
|
|
response_serializer = ConversationSummarySerializer(
|
|
|
|
|
{
|
|
|
|
|
"uuid": conversation.uuid,
|
2026-04-02 23:25:39 +03:30
|
|
|
"farm": farm,
|
2026-03-27 18:18:31 +03:30
|
|
|
"message_count": 0,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return Response({"status": "success", "data": response_serializer.data}, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChatMessagesView(ConversationAccessMixin, APIView):
|
|
|
|
|
permission_classes = [IsAuthenticated]
|
2026-03-25 15:43:00 +03:30
|
|
|
|
|
|
|
|
@extend_schema(
|
|
|
|
|
tags=["Farm AI Assistant"],
|
|
|
|
|
parameters=[
|
|
|
|
|
OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH),
|
2026-04-23 15:53:59 +03:30
|
|
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
2026-03-25 15:43:00 +03:30
|
|
|
],
|
2026-03-27 18:18:31 +03:30
|
|
|
responses={200: status_response("FarmAiAssistantMessageListResponse", data=ConversationMessagesSerializer())},
|
2026-03-25 15:43:00 +03:30
|
|
|
)
|
|
|
|
|
def get(self, request, conversation_id):
|
2026-04-23 15:53:59 +03:30
|
|
|
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
|
|
|
|
conversation = self._get_conversation(request, conversation_id, farm.farm_uuid if farm else None)
|
2026-04-02 23:25:39 +03:30
|
|
|
messages = conversation.messages.select_related("farm").all()
|
2026-03-27 18:18:31 +03:30
|
|
|
serialized_messages = [self._serialize_chat_message(message) for message in messages]
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
|
"status": "success",
|
|
|
|
|
"data": {
|
|
|
|
|
"conversation_id": str(conversation.uuid),
|
2026-04-23 15:53:59 +03:30
|
|
|
"farm_uuid": self._farm_uuid_or_none(farm),
|
2026-03-27 18:18:31 +03:30
|
|
|
"messages": serialized_messages,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
status=status.HTTP_200_OK,
|
|
|
|
|
)
|
2026-03-25 15:43:00 +03:30
|
|
|
|
|
|
|
|
|
2026-03-27 18:18:31 +03:30
|
|
|
class ChatDetailView(ConversationAccessMixin, APIView):
|
2026-03-25 15:43:00 +03:30
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
|
|
|
|
|
|
@extend_schema(
|
|
|
|
|
tags=["Farm AI Assistant"],
|
2026-03-27 18:18:31 +03:30
|
|
|
parameters=[
|
|
|
|
|
OpenApiParameter(name="conversation_id", type=OpenApiTypes.UUID, location=OpenApiParameter.PATH),
|
2026-04-23 15:53:59 +03:30
|
|
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
2026-03-27 18:18:31 +03:30
|
|
|
],
|
|
|
|
|
responses={200: status_response("FarmAiAssistantConversationDeleteResponse", data=ConversationDeleteSerializer())},
|
2026-03-25 15:43:00 +03:30
|
|
|
)
|
2026-03-27 18:18:31 +03:30
|
|
|
def delete(self, request, conversation_id):
|
2026-04-23 15:53:59 +03:30
|
|
|
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
|
|
|
|
conversation = self._get_conversation(request, conversation_id, farm.farm_uuid if farm else None)
|
2026-03-27 18:18:31 +03:30
|
|
|
deleted_conversation_id = str(conversation.uuid)
|
2026-04-23 15:53:59 +03:30
|
|
|
deleted_farm_uuid = self._farm_uuid_or_none(conversation.farm)
|
2026-03-27 18:18:31 +03:30
|
|
|
conversation.delete()
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
|
"status": "success",
|
|
|
|
|
"data": {
|
|
|
|
|
"conversation_id": deleted_conversation_id,
|
2026-04-02 23:25:39 +03:30
|
|
|
"farm_uuid": deleted_farm_uuid,
|
2026-03-27 18:18:31 +03:30
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
status=status.HTTP_200_OK,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ChatView(ConversationAccessMixin, APIView):
|
|
|
|
|
permission_classes = [IsAuthenticated]
|
2026-02-25 12:21:53 +03:30
|
|
|
|
2026-03-24 20:10:48 +03:30
|
|
|
@extend_schema(
|
|
|
|
|
tags=["Farm AI Assistant"],
|
2026-03-25 15:43:00 +03:30
|
|
|
request=ChatPostSerializer,
|
2026-03-27 18:18:31 +03:30
|
|
|
responses={200: status_response("FarmAiAssistantChatResponse", data=ChatResponseDataSerializer())},
|
2026-03-24 20:10:48 +03:30
|
|
|
)
|
2026-02-25 12:21:53 +03:30
|
|
|
def post(self, request):
|
2026-03-25 15:43:00 +03:30
|
|
|
serializer = ChatPostSerializer(data=request.data)
|
|
|
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
|
|
|
|
|
|
validated = serializer.validated_data
|
2026-03-27 18:32:49 +03:30
|
|
|
conversation = self._get_or_create_conversation(request, validated)
|
2026-03-25 15:43:00 +03:30
|
|
|
|
|
|
|
|
user_message = Message.objects.create(
|
|
|
|
|
conversation=conversation,
|
2026-04-02 23:25:39 +03:30
|
|
|
farm=conversation.farm,
|
2026-03-25 15:43:00 +03:30
|
|
|
role=Message.ROLE_USER,
|
|
|
|
|
content=validated.get("content", ""),
|
|
|
|
|
images=validated.get("images", []),
|
2026-04-23 15:53:59 +03:30
|
|
|
raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)},
|
2026-03-25 15:43:00 +03:30
|
|
|
)
|
|
|
|
|
|
2026-03-27 18:32:49 +03:30
|
|
|
adapter_payload = self._build_adapter_payload(request, validated, conversation)
|
2026-03-25 16:19:28 +03:30
|
|
|
|
2026-03-27 18:18:31 +03:30
|
|
|
try:
|
|
|
|
|
adapter_response = external_api_request(
|
|
|
|
|
"ai",
|
|
|
|
|
"/rag/chat",
|
|
|
|
|
method="POST",
|
|
|
|
|
payload=adapter_payload,
|
2026-03-25 00:51:04 +03:30
|
|
|
)
|
2026-03-27 18:18:31 +03:30
|
|
|
if adapter_response.status_code >= 400:
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
|
"status": "error",
|
|
|
|
|
"data": adapter_response.data,
|
|
|
|
|
},
|
|
|
|
|
status=adapter_response.status_code,
|
|
|
|
|
)
|
2026-04-02 23:25:39 +03:30
|
|
|
assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation)
|
2026-03-27 18:18:31 +03:30
|
|
|
response_status_code = adapter_response.status_code
|
|
|
|
|
except ExternalAPIRequestError:
|
2026-04-02 23:25:39 +03:30
|
|
|
assistant_payload = self._build_mock_assistant_payload(conversation)
|
2026-03-27 18:18:31 +03:30
|
|
|
response_status_code = status.HTTP_200_OK
|
2026-03-25 15:43:00 +03:30
|
|
|
|
|
|
|
|
assistant_message = Message.objects.create(
|
|
|
|
|
conversation=conversation,
|
2026-04-02 23:25:39 +03:30
|
|
|
farm=conversation.farm,
|
2026-03-25 15:43:00 +03:30
|
|
|
role=Message.ROLE_ASSISTANT,
|
2026-03-27 18:18:31 +03:30
|
|
|
content=assistant_payload.get("content", ""),
|
|
|
|
|
raw_response={},
|
2026-03-25 15:43:00 +03:30
|
|
|
)
|
2026-03-27 18:18:31 +03:30
|
|
|
assistant_payload["message_id"] = str(assistant_message.uuid)
|
|
|
|
|
assistant_message.raw_response = assistant_payload
|
|
|
|
|
assistant_message.save(update_fields=["raw_response"])
|
2026-03-25 15:43:00 +03:30
|
|
|
|
|
|
|
|
if not conversation.title:
|
2026-03-27 18:18:31 +03:30
|
|
|
conversation.title = (validated.get("content", "") or assistant_payload.get("content", "") or "New chat")[:255]
|
2026-03-25 15:43:00 +03:30
|
|
|
conversation.save(update_fields=["title", "updated_at"])
|
|
|
|
|
else:
|
|
|
|
|
conversation.save(update_fields=["updated_at"])
|
|
|
|
|
|
2026-03-27 18:18:31 +03:30
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
|
"status": "success",
|
|
|
|
|
"data": assistant_payload,
|
|
|
|
|
},
|
|
|
|
|
status=response_status_code,
|
|
|
|
|
)
|
2026-03-27 18:32:49 +03:30
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-02 23:25:39 +03:30
|
|
|
farm=conversation.farm,
|
2026-03-27 18:32:49 +03:30
|
|
|
role=Message.ROLE_USER,
|
|
|
|
|
content=validated.get("content", ""),
|
|
|
|
|
images=validated.get("images", []),
|
2026-04-23 15:53:59 +03:30
|
|
|
raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)},
|
2026-03-27 18:32:49 +03:30
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-02 23:25:39 +03:30
|
|
|
conversation,
|
2026-03-27 18:32:49 +03:30
|
|
|
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),
|
2026-04-23 15:53:59 +03:30
|
|
|
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
2026-03-27 18:32:49 +03:30
|
|
|
],
|
|
|
|
|
responses={200: status_response("FarmAiAssistantChatTaskStatusResponse", data=ChatTaskStatusDataSerializer())},
|
|
|
|
|
)
|
|
|
|
|
def get(self, request, task_id):
|
2026-04-23 15:53:59 +03:30
|
|
|
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
2026-03-27 18:32:49 +03:30
|
|
|
try:
|
2026-04-23 15:53:59 +03:30
|
|
|
query = {}
|
|
|
|
|
if farm:
|
|
|
|
|
query["farm_uuid"] = str(farm.farm_uuid)
|
2026-03-27 18:32:49 +03:30
|
|
|
adapter_response = external_api_request(
|
|
|
|
|
"ai",
|
|
|
|
|
f"/tasks/{task_id}/status",
|
|
|
|
|
method="GET",
|
2026-04-23 15:53:59 +03:30
|
|
|
query=query,
|
2026-03-27 18:32:49 +03:30
|
|
|
)
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-23 15:53:59 +03:30
|
|
|
farm_uuid = farm.farm_uuid if farm else None
|
|
|
|
|
user_message = self._find_user_message_for_task(request, task_id, farm_uuid)
|
2026-04-02 23:25:39 +03:30
|
|
|
conversation = user_message.conversation if user_message else None
|
2026-03-27 18:32:49 +03:30
|
|
|
task_status_payload = self._extract_task_status_payload(
|
|
|
|
|
adapter_response.data,
|
|
|
|
|
task_id,
|
2026-04-02 23:25:39 +03:30
|
|
|
conversation=conversation,
|
2026-04-23 15:53:59 +03:30
|
|
|
farm_uuid=farm_uuid,
|
2026-03-27 18:32:49 +03:30
|
|
|
)
|
|
|
|
|
|
2026-03-29 13:40:23 +03:30
|
|
|
result = self._extract_structured_task_result(adapter_response.data)
|
|
|
|
|
if result is not None:
|
|
|
|
|
task_status_payload["result"] = result
|
|
|
|
|
|
2026-03-27 18:32:49 +03:30
|
|
|
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,
|
|
|
|
|
)
|