UPDATE
This commit is contained in:
+5
-4
@@ -2,12 +2,12 @@
|
|||||||
SECRET_KEY=your-secret-key-change-in-production
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
DEBUG=1
|
DEBUG=1
|
||||||
DOCKER_VERSION=develop
|
DOCKER_VERSION=develop
|
||||||
ALLOWED_HOSTS=node.crop-logic.ir,crop-logic.ir,localhost,127.0.0.1,0.0.0.0
|
ALLOWED_HOSTS=node.crop-logic.ir,crop-logic.ir,localhost,127.0.0.1,0.0.0.0,web,backend-web
|
||||||
|
|
||||||
# Database (MySQL)
|
# Database (MySQL)
|
||||||
DB_ENGINE=django.db.backends.mysql
|
DB_ENGINE=django.db.backends.mysql
|
||||||
DB_NAME=backend
|
DB_NAME=croplogic
|
||||||
DB_USER=backend
|
DB_USER=croplogic
|
||||||
DB_PASSWORD=changeme
|
DB_PASSWORD=changeme
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
@@ -27,7 +27,8 @@ ACCESS_CONTROL_AUTHZ_BATCH_PATH=/v1/data/croplogic/authz/batch_decision
|
|||||||
ACCESS_CONTROL_AUTHZ_TIMEOUT=30
|
ACCESS_CONTROL_AUTHZ_TIMEOUT=30
|
||||||
ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300
|
ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT=300
|
||||||
|
|
||||||
AI_SERVICE_BASE_URL=https://ai.example.com
|
AI_SERVICE_BASE_URL=http://ai-web:8000
|
||||||
|
AI_SERVICE_HOST_HEADER=localhost
|
||||||
AI_SERVICE_API_KEY=
|
AI_SERVICE_API_KEY=
|
||||||
|
|
||||||
FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com
|
FARM_HUB_SERVICE_BASE_URL=https://farm-hub.example.com
|
||||||
|
|||||||
+13
-2
@@ -8,9 +8,18 @@ load_dotenv()
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _get_csv_env(name, default=""):
|
||||||
|
return [item.strip() for item in os.environ.get(name, default).split(",") if item.strip()]
|
||||||
|
|
||||||
SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only")
|
SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-dev-only")
|
||||||
DEBUG = os.environ.get("DEBUG", "0") == "1"
|
DEBUG = os.environ.get("DEBUG", "0") == "1"
|
||||||
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
ALLOWED_HOSTS = list(
|
||||||
|
dict.fromkeys(
|
||||||
|
_get_csv_env("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0")
|
||||||
|
+ ["web", "backend-web", os.environ.get("HOSTNAME", "")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
AUTH_USER_MODEL = "account.User"
|
AUTH_USER_MODEL = "account.User"
|
||||||
|
|
||||||
@@ -178,12 +187,14 @@ ACCESS_CONTROL_AUTHZ_CACHE_TIMEOUT = int(os.getenv("ACCESS_CONTROL_AUTHZ_CACHE_T
|
|||||||
|
|
||||||
EXTERNAL_SERVICES = {
|
EXTERNAL_SERVICES = {
|
||||||
"ai": {
|
"ai": {
|
||||||
"base_url": os.getenv("AI_SERVICE_BASE_URL", ""),
|
"base_url": os.getenv("AI_SERVICE_BASE_URL", "http://ai-web:8000"),
|
||||||
"api_key": os.getenv("AI_SERVICE_API_KEY", ""),
|
"api_key": os.getenv("AI_SERVICE_API_KEY", ""),
|
||||||
|
"host_header": os.getenv("AI_SERVICE_HOST_HEADER", "localhost"),
|
||||||
},
|
},
|
||||||
"farm_hub": {
|
"farm_hub": {
|
||||||
"base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""),
|
"base_url": os.getenv("FARM_HUB_SERVICE_BASE_URL", ""),
|
||||||
"api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""),
|
"api_key": os.getenv("FARM_HUB_SERVICE_API_KEY", ""),
|
||||||
|
"host_header": os.getenv("FARM_HUB_SERVICE_HOST_HEADER", ""),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.15 on 2026-04-25 21:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='farmdashboardconfig',
|
||||||
|
name='row_order',
|
||||||
|
field=models.JSONField(blank=True, default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: docker.iranserver.com/mysql:8
|
image: docker.iranserver.com/mysql:8
|
||||||
container_name: backend-db
|
container_name: croplogic-db
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: ${DB_NAME}
|
MYSQL_DATABASE: ${DB_NAME}
|
||||||
MYSQL_USER: ${DB_USER}
|
MYSQL_USER: ${DB_USER}
|
||||||
@@ -9,6 +9,8 @@ services:
|
|||||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- backend_mysql_data:/var/lib/mysql
|
- backend_mysql_data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
|
||||||
@@ -51,9 +53,12 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DOCKER_VERSION: ${DOCKER_VERSION:-production}
|
DOCKER_VERSION: ${DOCKER_VERSION:-production}
|
||||||
DB_HOST: db
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
|
||||||
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
|
AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000}
|
||||||
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
AI_SERVICE_HOST_HEADER: ${AI_SERVICE_HOST_HEADER:-localhost}
|
||||||
|
DB_HOST: croplogic-db
|
||||||
|
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://backend-redis:6379/0}
|
||||||
|
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://backend-redis:6379/0}
|
||||||
QDRANT_HOST: ${QDRANT_HOST:-qdrant}
|
QDRANT_HOST: ${QDRANT_HOST:-qdrant}
|
||||||
QDRANT_PORT: ${QDRANT_PORT:-6333}
|
QDRANT_PORT: ${QDRANT_PORT:-6333}
|
||||||
SKIP_MIGRATE: "0"
|
SKIP_MIGRATE: "0"
|
||||||
@@ -77,9 +82,12 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DOCKER_VERSION: ${DOCKER_VERSION:-production}
|
DOCKER_VERSION: ${DOCKER_VERSION:-production}
|
||||||
DB_HOST: db
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
|
||||||
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://redis:6379/0}
|
AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000}
|
||||||
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://redis:6379/0}
|
AI_SERVICE_HOST_HEADER: ${AI_SERVICE_HOST_HEADER:-localhost}
|
||||||
|
DB_HOST: croplogic-db
|
||||||
|
CELERY_BROKER_URL: ${CELERY_BROKER_URL:-redis://backend-redis:6379/0}
|
||||||
|
CELERY_RESULT_BACKEND: ${CELERY_RESULT_BACKEND:-redis://backend-redis:6379/0}
|
||||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
|
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
|
||||||
SKIP_MIGRATE: "1"
|
SKIP_MIGRATE: "1"
|
||||||
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
|
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
|
||||||
|
|||||||
+17
-12
@@ -1,16 +1,17 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: docker.iranserver.com/mysql:8
|
image: docker.iranserver.com/mysql:8
|
||||||
container_name: backend-db
|
container_name: croplogic-db
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: ${DB_NAME:-backend}
|
MYSQL_DATABASE: ${DB_NAME:-croplogic}
|
||||||
MYSQL_USER: ${DB_USER:-backend}
|
MYSQL_USER: ${DB_USER:-croplogic}
|
||||||
MYSQL_PASSWORD: ${DB_PASSWORD:-changeme}
|
MYSQL_PASSWORD: ${DB_PASSWORD:-changeme}
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-changeme}
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root}
|
||||||
volumes:
|
volumes:
|
||||||
- backend_mysql_data:/var/lib/mysql
|
- backend_mysql_data:/var/lib/mysql
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-changeme}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD:-root}"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -21,7 +22,7 @@ services:
|
|||||||
image: docker-mirror.liara.ir/phpmyadmin:latest
|
image: docker-mirror.liara.ir/phpmyadmin:latest
|
||||||
container_name: backend-phpmyadmin
|
container_name: backend-phpmyadmin
|
||||||
environment:
|
environment:
|
||||||
PMA_HOST: db
|
PMA_HOST: croplogic-db
|
||||||
PMA_PORT: 3306
|
PMA_PORT: 3306
|
||||||
UPLOAD_LIMIT: 64M
|
UPLOAD_LIMIT: 64M
|
||||||
ports:
|
ports:
|
||||||
@@ -68,9 +69,11 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DOCKER_VERSION: ${DOCKER_VERSION:-develop}
|
DOCKER_VERSION: ${DOCKER_VERSION:-develop}
|
||||||
DB_HOST: db
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
|
||||||
CELERY_BROKER_URL: redis://redis:6379/0
|
AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000}
|
||||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
DB_HOST: croplogic-db
|
||||||
|
CELERY_BROKER_URL: redis://backend-redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND: redis://backend-redis:6379/0
|
||||||
QDRANT_HOST: qdrant
|
QDRANT_HOST: qdrant
|
||||||
QDRANT_PORT: 6333
|
QDRANT_PORT: 6333
|
||||||
SKIP_MIGRATE: "0"
|
SKIP_MIGRATE: "0"
|
||||||
@@ -101,9 +104,11 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DOCKER_VERSION: ${DOCKER_VERSION:-develop}
|
DOCKER_VERSION: ${DOCKER_VERSION:-develop}
|
||||||
DB_HOST: db
|
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0,web,backend-web}
|
||||||
CELERY_BROKER_URL: redis://redis:6379/0
|
AI_SERVICE_BASE_URL: ${AI_SERVICE_BASE_URL:-http://ai-web:8000}
|
||||||
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
DB_HOST: croplogic-db
|
||||||
|
CELERY_BROKER_URL: redis://backend-redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND: redis://backend-redis:6379/0
|
||||||
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
|
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP: "true"
|
||||||
SKIP_MIGRATE: "1"
|
SKIP_MIGRATE: "1"
|
||||||
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
|
ACCESS_CONTROL_AUTHZ_BASE_URL: http://croplogic-accsess-opa:8181
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import requests
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from .exceptions import ExternalAPIRequestError
|
from .exceptions import ExternalAPIRequestError
|
||||||
|
from .exceptions import MockDirectoryNotFound, MockFileNotFound
|
||||||
from .mock_loader import MockLoader
|
from .mock_loader import MockLoader
|
||||||
from .services import ServiceRegistry
|
from .services import ServiceRegistry
|
||||||
|
|
||||||
@@ -26,7 +27,9 @@ class ExternalAPIAdapter:
|
|||||||
self._validate_method(request_method)
|
self._validate_method(request_method)
|
||||||
service = self.service_registry.get(service_name)
|
service = self.service_registry.get(service_name)
|
||||||
|
|
||||||
if getattr(settings, "USE_EXTERNAL_API_MOCK", False):
|
use_mock = getattr(settings, "USE_EXTERNAL_API_MOCK", False) and service_name != "ai"
|
||||||
|
if use_mock:
|
||||||
|
try:
|
||||||
mock_response = self.mock_loader.load(service_name=service_name, path=path, method=request_method)
|
mock_response = self.mock_loader.load(service_name=service_name, path=path, method=request_method)
|
||||||
return AdapterResponse(
|
return AdapterResponse(
|
||||||
status_code=mock_response.status_code,
|
status_code=mock_response.status_code,
|
||||||
@@ -34,6 +37,8 @@ class ExternalAPIAdapter:
|
|||||||
headers={"X-Mock-File": mock_response.file_path},
|
headers={"X-Mock-File": mock_response.file_path},
|
||||||
is_mock=True,
|
is_mock=True,
|
||||||
)
|
)
|
||||||
|
except (MockDirectoryNotFound, MockFileNotFound):
|
||||||
|
pass
|
||||||
|
|
||||||
return self._call_real_api(
|
return self._call_real_api(
|
||||||
service=service,
|
service=service,
|
||||||
@@ -47,25 +52,47 @@ class ExternalAPIAdapter:
|
|||||||
def _call_real_api(self, service, path, method, payload=None, query=None, headers=None):
|
def _call_real_api(self, service, path, method, payload=None, query=None, headers=None):
|
||||||
base_url = service.get("base_url", "").rstrip("/")
|
base_url = service.get("base_url", "").rstrip("/")
|
||||||
api_key = service.get("api_key", "")
|
api_key = service.get("api_key", "")
|
||||||
|
host_header = service.get("host_header", "").strip()
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise ExternalAPIRequestError("External service base_url is not configured.")
|
raise ExternalAPIRequestError("External service base_url is not configured.")
|
||||||
url = f"{base_url}/{str(path).lstrip('/')}"
|
url = f"{base_url}/{str(path).lstrip('/')}"
|
||||||
|
|
||||||
|
files = None
|
||||||
|
request_payload = payload
|
||||||
request_headers = {
|
request_headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
if host_header:
|
||||||
|
request_headers["Host"] = host_header
|
||||||
if headers:
|
if headers:
|
||||||
request_headers.update(headers)
|
request_headers.update(headers)
|
||||||
|
|
||||||
|
if isinstance(payload, dict) and payload.get("__files__"):
|
||||||
|
files = payload["__files__"]
|
||||||
|
request_payload = {
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key != "__files__"
|
||||||
|
}
|
||||||
|
request_headers.pop("Content-Type", None)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
request_kwargs = {
|
||||||
|
"method": method,
|
||||||
|
"url": url,
|
||||||
|
"params": query,
|
||||||
|
"headers": request_headers,
|
||||||
|
"timeout": getattr(settings, "EXTERNAL_API_TIMEOUT", 30),
|
||||||
|
}
|
||||||
|
if files:
|
||||||
|
request_kwargs["data"] = request_payload
|
||||||
|
request_kwargs["files"] = files
|
||||||
|
else:
|
||||||
|
request_kwargs["json"] = request_payload
|
||||||
|
|
||||||
response = requests.request(
|
response = requests.request(
|
||||||
method=method,
|
**request_kwargs,
|
||||||
url=url,
|
|
||||||
json=payload,
|
|
||||||
params=query,
|
|
||||||
headers=request_headers,
|
|
||||||
timeout=getattr(settings, "EXTERNAL_API_TIMEOUT", 30),
|
|
||||||
)
|
)
|
||||||
except requests.RequestException as exc:
|
except requests.RequestException as exc:
|
||||||
raise ExternalAPIRequestError(f"External API request failed for '{url}': {exc}") from exc
|
raise ExternalAPIRequestError(f"External API request failed for '{url}': {exc}") from exc
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class ChatSectionSerializer(serializers.Serializer):
|
|||||||
frequency = serializers.CharField(required=False, allow_blank=True)
|
frequency = serializers.CharField(required=False, allow_blank=True)
|
||||||
amount = serializers.CharField(required=False, allow_blank=True)
|
amount = serializers.CharField(required=False, allow_blank=True)
|
||||||
timing = serializers.CharField(required=False, allow_blank=True)
|
timing = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
primaryAction = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
validityPeriod = serializers.CharField(required=False, allow_blank=True)
|
||||||
expandableExplanation = serializers.CharField(required=False, allow_blank=True)
|
expandableExplanation = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,40 +59,42 @@ class ConversationDeleteSerializer(serializers.Serializer):
|
|||||||
farm_uuid = serializers.UUIDField(read_only=True, allow_null=True)
|
farm_uuid = serializers.UUIDField(read_only=True, allow_null=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)
|
|
||||||
farm_uuid = serializers.UUIDField(read_only=True, allow_null=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)
|
|
||||||
farm_uuid = serializers.UUIDField(read_only=True, allow_null=True)
|
|
||||||
progress = serializers.JSONField(required=False)
|
|
||||||
result = serializers.JSONField(required=False)
|
|
||||||
error = serializers.CharField(required=False, allow_blank=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ChatPostSerializer(serializers.Serializer):
|
class ChatPostSerializer(serializers.Serializer):
|
||||||
farm_uuid = serializers.UUIDField(required=False, allow_null=True)
|
farm_uuid = serializers.UUIDField(required=True)
|
||||||
content = serializers.CharField(required=False, allow_blank=True, default="")
|
query = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
|
history = serializers.JSONField(required=False)
|
||||||
|
image_urls = serializers.ListField(
|
||||||
|
child=serializers.CharField(),
|
||||||
|
required=False,
|
||||||
|
default=list,
|
||||||
|
)
|
||||||
images = serializers.ListField(
|
images = serializers.ListField(
|
||||||
child=serializers.CharField(),
|
child=serializers.CharField(),
|
||||||
required=False,
|
required=False,
|
||||||
default=list,
|
default=list,
|
||||||
)
|
)
|
||||||
conversation_id = serializers.UUIDField(required=False)
|
conversation_id = serializers.UUIDField(required=False)
|
||||||
title = serializers.CharField(required=False, allow_blank=True, max_length=255)
|
|
||||||
farm_context = serializers.JSONField(required=False)
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
content = attrs.get("content", "").strip()
|
query = (attrs.get("query") or "").strip()
|
||||||
|
image_urls = attrs.get("image_urls") or []
|
||||||
images = attrs.get("images") or []
|
images = attrs.get("images") or []
|
||||||
if not content and not images:
|
history = attrs.get("history", [])
|
||||||
raise serializers.ValidationError("Either content or images is required.")
|
|
||||||
|
if isinstance(history, str):
|
||||||
|
try:
|
||||||
|
history = serializers.JSONField().to_internal_value(history)
|
||||||
|
except serializers.ValidationError as exc:
|
||||||
|
raise serializers.ValidationError({"history": exc.detail}) from exc
|
||||||
|
|
||||||
|
if history in (None, ""):
|
||||||
|
history = []
|
||||||
|
if not isinstance(history, list):
|
||||||
|
raise serializers.ValidationError({"history": ["History must be an array or a valid JSON array string."]})
|
||||||
|
|
||||||
|
if not query and not image_urls and not images:
|
||||||
|
raise serializers.ValidationError({"query": ["This field may not be blank unless an image is sent."]})
|
||||||
|
|
||||||
|
attrs["query"] = query
|
||||||
|
attrs["history"] = history
|
||||||
return attrs
|
return attrs
|
||||||
|
|||||||
@@ -4,17 +4,13 @@ from .views import (
|
|||||||
ChatDetailView,
|
ChatDetailView,
|
||||||
ChatListCreateView,
|
ChatListCreateView,
|
||||||
ChatMessagesView,
|
ChatMessagesView,
|
||||||
ChatTaskCreateView,
|
|
||||||
ChatTaskStatusView,
|
|
||||||
ChatView,
|
ChatView,
|
||||||
ContextView,
|
ContextView,
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("context/", ContextView.as_view(), name="farm-ai-assistant-context"),
|
path("context/", ContextView.as_view(), name="farm-ai-assistant-context"),
|
||||||
# path("chat/", ChatView.as_view(), name="farm-ai-assistant-chat"),
|
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/<str:task_id>/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/", ChatListCreateView.as_view(), name="farm-ai-assistant-chat-list-create"),
|
||||||
path("chats/<uuid:conversation_id>/", ChatDetailView.as_view(), name="farm-ai-assistant-chat-detail"),
|
path("chats/<uuid:conversation_id>/", ChatDetailView.as_view(), name="farm-ai-assistant-chat-detail"),
|
||||||
path("chats/<uuid:conversation_id>/messages/", ChatMessagesView.as_view(), name="farm-ai-assistant-chat-messages"),
|
path("chats/<uuid:conversation_id>/messages/", ChatMessagesView.as_view(), name="farm-ai-assistant-chat-messages"),
|
||||||
|
|||||||
+157
-309
@@ -1,10 +1,12 @@
|
|||||||
"""Farm AI Assistant API views."""
|
"""Farm AI Assistant API views."""
|
||||||
|
|
||||||
|
import json
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from rest_framework import serializers, status
|
from rest_framework import serializers, status
|
||||||
|
from rest_framework.exceptions import ParseError
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
@@ -15,13 +17,11 @@ from config.swagger import status_response
|
|||||||
from external_api_adapter import request as external_api_request
|
from external_api_adapter import request as external_api_request
|
||||||
from external_api_adapter.exceptions import ExternalAPIRequestError
|
from external_api_adapter.exceptions import ExternalAPIRequestError
|
||||||
from farm_hub.models import FarmHub
|
from farm_hub.models import FarmHub
|
||||||
from .mock_data import CHAT_RESPONSE_DATA, CONTEXT_RESPONSE_DATA
|
from .mock_data import CONTEXT_RESPONSE_DATA
|
||||||
from .models import Conversation, Message
|
from .models import Conversation, Message
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
ChatPostSerializer,
|
ChatPostSerializer,
|
||||||
ChatResponseDataSerializer,
|
ChatResponseDataSerializer,
|
||||||
ChatTaskStatusDataSerializer,
|
|
||||||
ChatTaskSubmitDataSerializer,
|
|
||||||
ConversationCreateSerializer,
|
ConversationCreateSerializer,
|
||||||
ConversationDeleteSerializer,
|
ConversationDeleteSerializer,
|
||||||
ConversationMessagesSerializer,
|
ConversationMessagesSerializer,
|
||||||
@@ -71,6 +71,14 @@ class ContextView(FarmAccessMixin, APIView):
|
|||||||
|
|
||||||
|
|
||||||
class ConversationAccessMixin(FarmAccessMixin):
|
class ConversationAccessMixin(FarmAccessMixin):
|
||||||
|
@staticmethod
|
||||||
|
def _generate_conversation_title(query):
|
||||||
|
normalized_query = (query or "").strip()
|
||||||
|
if not normalized_query:
|
||||||
|
return "Image"
|
||||||
|
first_word = normalized_query.split()[0].strip()
|
||||||
|
return (first_word or normalized_query or "New chat")[:255]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_conversation(request, conversation_id, farm_uuid=None):
|
def _get_conversation(request, conversation_id, farm_uuid=None):
|
||||||
filters = {"uuid": conversation_id, "owner": request.user}
|
filters = {"uuid": conversation_id, "owner": request.user}
|
||||||
@@ -94,9 +102,11 @@ class ConversationAccessMixin(FarmAccessMixin):
|
|||||||
"content",
|
"content",
|
||||||
"items",
|
"items",
|
||||||
"icon",
|
"icon",
|
||||||
|
"primaryAction",
|
||||||
"frequency",
|
"frequency",
|
||||||
"amount",
|
"amount",
|
||||||
"timing",
|
"timing",
|
||||||
|
"validityPeriod",
|
||||||
"expandableExplanation",
|
"expandableExplanation",
|
||||||
}
|
}
|
||||||
normalized_sections = []
|
normalized_sections = []
|
||||||
@@ -119,16 +129,8 @@ class ConversationAccessMixin(FarmAccessMixin):
|
|||||||
normalized_sections.append(normalized_section)
|
normalized_sections.append(normalized_section)
|
||||||
return normalized_sections
|
return normalized_sections
|
||||||
|
|
||||||
def _build_mock_assistant_payload(self, conversation):
|
|
||||||
payload = deepcopy(CHAT_RESPONSE_DATA)
|
|
||||||
payload["conversation_id"] = str(conversation.uuid)
|
|
||||||
payload["farm_uuid"] = self._farm_uuid_or_none(conversation.farm)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def _get_or_create_conversation(self, request, validated):
|
def _get_or_create_conversation(self, request, validated):
|
||||||
conversation_id = validated.get("conversation_id")
|
conversation_id = validated.get("conversation_id")
|
||||||
farm_context = validated.get("farm_context")
|
|
||||||
title = validated.get("title", "").strip()
|
|
||||||
farm = self._get_optional_farm(request, validated.get("farm_uuid"))
|
farm = self._get_optional_farm(request, validated.get("farm_uuid"))
|
||||||
|
|
||||||
if conversation_id:
|
if conversation_id:
|
||||||
@@ -137,42 +139,130 @@ class ConversationAccessMixin(FarmAccessMixin):
|
|||||||
conversation_id,
|
conversation_id,
|
||||||
farm.farm_uuid if farm else None,
|
farm.farm_uuid if farm else None,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
|
||||||
return Conversation.objects.create(
|
return Conversation.objects.create(
|
||||||
owner=request.user,
|
owner=request.user,
|
||||||
farm=farm,
|
farm=farm,
|
||||||
title=title or (validated.get("content", "")[:255]) or "New chat",
|
title=self._generate_conversation_title(validated.get("query", "")),
|
||||||
farm_context=farm_context or {},
|
farm_context={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _serialize_history_messages(history):
|
||||||
|
normalized_history = []
|
||||||
|
for item in history or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
role = str(item.get("role") or "").strip()
|
||||||
|
content = str(item.get("content") or item.get("message") or "").strip()
|
||||||
|
if not role and not content:
|
||||||
|
continue
|
||||||
|
normalized_item = {}
|
||||||
|
if role:
|
||||||
|
normalized_item["role"] = role
|
||||||
|
if content:
|
||||||
|
normalized_item["content"] = content
|
||||||
|
if item.get("sections") is not None:
|
||||||
|
normalized_item["sections"] = item.get("sections")
|
||||||
|
normalized_history.append(normalized_item)
|
||||||
|
return normalized_history
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_adapter_payload(request, validated, conversation):
|
def _build_adapter_payload(request, validated, conversation):
|
||||||
payload = {
|
payload = {
|
||||||
"content": validated.get("content", ""),
|
"farm_uuid": str(conversation.farm.farm_uuid) if conversation.farm else "",
|
||||||
"query": validated.get("content", ""),
|
"query": validated.get("query", ""),
|
||||||
|
"history": ConversationAccessMixin._serialize_history_messages(validated.get("history", [])),
|
||||||
|
"image_urls": validated.get("image_urls", []),
|
||||||
"images": validated.get("images", []),
|
"images": validated.get("images", []),
|
||||||
"conversation_id": str(conversation.uuid),
|
"conversation_id": str(conversation.uuid),
|
||||||
"user_id": request.user.id,
|
"user_id": request.user.id,
|
||||||
}
|
}
|
||||||
if conversation.farm:
|
|
||||||
payload["farm_uuid"] = str(conversation.farm.farm_uuid)
|
|
||||||
if "farm_context" in validated:
|
|
||||||
payload["farm_context"] = validated.get("farm_context") or {}
|
|
||||||
if "title" in validated:
|
|
||||||
payload["title"] = validated.get("title", "")
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _attach_uploaded_files(payload, uploaded_images):
|
||||||
|
if not uploaded_images:
|
||||||
|
return payload
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for uploaded_image in uploaded_images:
|
||||||
|
files.append(
|
||||||
|
(
|
||||||
|
"images",
|
||||||
|
(
|
||||||
|
uploaded_image.name,
|
||||||
|
uploaded_image,
|
||||||
|
getattr(uploaded_image, "content_type", "application/octet-stream"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
multipart_payload = dict(payload)
|
||||||
|
multipart_payload["history"] = json.dumps(payload.get("history", []), ensure_ascii=False)
|
||||||
|
multipart_payload["image_urls"] = json.dumps(payload.get("image_urls", []), ensure_ascii=False)
|
||||||
|
multipart_payload["__files__"] = files
|
||||||
|
return multipart_payload
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_json_array(value):
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return parsed if isinstance(parsed, list) else None
|
||||||
|
|
||||||
|
def _collect_uploaded_images(self, request):
|
||||||
|
uploaded_images = []
|
||||||
|
single_image = request.FILES.get("image")
|
||||||
|
if single_image is not None:
|
||||||
|
uploaded_images.append(single_image)
|
||||||
|
uploaded_images.extend(request.FILES.getlist("images"))
|
||||||
|
return uploaded_images
|
||||||
|
|
||||||
|
def _merge_history(self, validated, conversation):
|
||||||
|
provided_history = validated.get("history", [])
|
||||||
|
if provided_history:
|
||||||
|
return self._serialize_history_messages(provided_history)
|
||||||
|
|
||||||
|
existing_messages = conversation.messages.order_by("created_at")
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"role": message.role,
|
||||||
|
"content": message.content,
|
||||||
|
**({"sections": message.raw_response.get("sections", [])} if message.role == Message.ROLE_ASSISTANT else {}),
|
||||||
|
}
|
||||||
|
for message in existing_messages
|
||||||
|
if message.content or (message.role == Message.ROLE_ASSISTANT and message.raw_response.get("sections"))
|
||||||
|
]
|
||||||
|
|
||||||
|
def _prepare_chat_input(self, request):
|
||||||
|
mutable_data = request.data.copy()
|
||||||
|
|
||||||
|
for field_name in ("message", "content", "title", "farm_context"):
|
||||||
|
if field_name in mutable_data:
|
||||||
|
mutable_data.pop(field_name)
|
||||||
|
|
||||||
|
if "history" in mutable_data:
|
||||||
|
parsed_history = self._parse_json_array(mutable_data.get("history"))
|
||||||
|
if parsed_history is not None:
|
||||||
|
mutable_data["history"] = parsed_history
|
||||||
|
|
||||||
|
if "image_urls" in mutable_data and isinstance(mutable_data.get("image_urls"), str):
|
||||||
|
parsed_urls = self._parse_json_array(mutable_data.get("image_urls"))
|
||||||
|
if parsed_urls is not None:
|
||||||
|
mutable_data.setlist("image_urls", parsed_urls) if hasattr(mutable_data, "setlist") else mutable_data.__setitem__("image_urls", parsed_urls)
|
||||||
|
|
||||||
|
if "images" in mutable_data and isinstance(mutable_data.get("images"), str):
|
||||||
|
parsed_images = self._parse_json_array(mutable_data.get("images"))
|
||||||
|
if parsed_images is not None:
|
||||||
|
mutable_data.setlist("images", parsed_images) if hasattr(mutable_data, "setlist") else mutable_data.__setitem__("images", parsed_images)
|
||||||
|
|
||||||
|
return mutable_data
|
||||||
|
|
||||||
def _extract_assistant_payload(self, adapter_data, conversation):
|
def _extract_assistant_payload(self, adapter_data, conversation):
|
||||||
payload_source = adapter_data
|
payload_source = adapter_data
|
||||||
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
|
if isinstance(adapter_data, dict) and isinstance(adapter_data.get("data"), dict):
|
||||||
@@ -199,80 +289,6 @@ class ConversationAccessMixin(FarmAccessMixin):
|
|||||||
"sections": sections,
|
"sections": sections,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_task_submit_payload(adapter_data, conversation, 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.uuid),
|
|
||||||
"message_id": str(message_id),
|
|
||||||
"farm_uuid": ConversationAccessMixin._farm_uuid_or_none(conversation.farm),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _extract_task_status_payload(self, adapter_data, task_id, conversation=None, farm_uuid=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:
|
|
||||||
task_status_payload["conversation_id"] = str(conversation.uuid)
|
|
||||||
task_status_payload["farm_uuid"] = self._farm_uuid_or_none(conversation.farm)
|
|
||||||
elif farm_uuid is not None:
|
|
||||||
task_status_payload["farm_uuid"] = str(farm_uuid)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _serialize_chat_message(message):
|
def _serialize_chat_message(message):
|
||||||
raw_response = message.raw_response if isinstance(message.raw_response, dict) else {}
|
raw_response = message.raw_response if isinstance(message.raw_response, dict) else {}
|
||||||
@@ -288,62 +304,6 @@ class ConversationAccessMixin(FarmAccessMixin):
|
|||||||
"created_at": message.created_at,
|
"created_at": message.created_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _find_user_message_for_task(request, task_id, farm_uuid):
|
|
||||||
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
|
|
||||||
return (
|
|
||||||
Message.objects.select_related("conversation", "farm")
|
|
||||||
.filter(**filters)
|
|
||||||
.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)
|
|
||||||
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,
|
|
||||||
farm=user_message.farm,
|
|
||||||
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):
|
class ChatListCreateView(ConversationAccessMixin, APIView):
|
||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
@@ -459,27 +419,47 @@ class ChatView(ConversationAccessMixin, APIView):
|
|||||||
responses={200: status_response("FarmAiAssistantChatResponse", data=ChatResponseDataSerializer())},
|
responses={200: status_response("FarmAiAssistantChatResponse", data=ChatResponseDataSerializer())},
|
||||||
)
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = ChatPostSerializer(data=request.data)
|
try:
|
||||||
|
chat_input = self._prepare_chat_input(request)
|
||||||
|
except ParseError:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"data": {
|
||||||
|
"message": "Invalid JSON body. Use valid JSON and remove extra trailing characters.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = ChatPostSerializer(data=chat_input)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
validated = serializer.validated_data
|
validated = serializer.validated_data
|
||||||
conversation = self._get_or_create_conversation(request, validated)
|
conversation = self._get_or_create_conversation(request, validated)
|
||||||
|
history = self._merge_history(validated, conversation)
|
||||||
|
uploaded_images = self._collect_uploaded_images(request)
|
||||||
|
|
||||||
user_message = Message.objects.create(
|
user_message = Message.objects.create(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
farm=conversation.farm,
|
farm=conversation.farm,
|
||||||
role=Message.ROLE_USER,
|
role=Message.ROLE_USER,
|
||||||
content=validated.get("content", ""),
|
content=validated.get("query", ""),
|
||||||
images=validated.get("images", []),
|
images=validated.get("image_urls", []) + validated.get("images", []),
|
||||||
raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)},
|
raw_response={
|
||||||
|
"farm_uuid": self._farm_uuid_or_none(conversation.farm),
|
||||||
|
"history": history,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
adapter_payload = self._build_adapter_payload(request, validated, conversation)
|
adapter_payload = self._build_adapter_payload(request, validated, conversation)
|
||||||
|
adapter_payload["history"] = history
|
||||||
|
adapter_payload = self._attach_uploaded_files(adapter_payload, uploaded_images)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
adapter_response = external_api_request(
|
adapter_response = external_api_request(
|
||||||
"ai",
|
"ai",
|
||||||
"/rag/chat",
|
"/api/rag/chat/",
|
||||||
method="POST",
|
method="POST",
|
||||||
payload=adapter_payload,
|
payload=adapter_payload,
|
||||||
)
|
)
|
||||||
@@ -493,9 +473,16 @@ class ChatView(ConversationAccessMixin, APIView):
|
|||||||
)
|
)
|
||||||
assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation)
|
assistant_payload = self._extract_assistant_payload(adapter_response.data, conversation)
|
||||||
response_status_code = adapter_response.status_code
|
response_status_code = adapter_response.status_code
|
||||||
except ExternalAPIRequestError:
|
except ExternalAPIRequestError as exc:
|
||||||
assistant_payload = self._build_mock_assistant_payload(conversation)
|
return Response(
|
||||||
response_status_code = status.HTTP_200_OK
|
{
|
||||||
|
"status": "error",
|
||||||
|
"data": {
|
||||||
|
"message": str(exc) or "External AI service is unavailable.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
assistant_message = Message.objects.create(
|
assistant_message = Message.objects.create(
|
||||||
conversation=conversation,
|
conversation=conversation,
|
||||||
@@ -509,7 +496,7 @@ class ChatView(ConversationAccessMixin, APIView):
|
|||||||
assistant_message.save(update_fields=["raw_response"])
|
assistant_message.save(update_fields=["raw_response"])
|
||||||
|
|
||||||
if not conversation.title:
|
if not conversation.title:
|
||||||
conversation.title = (validated.get("content", "") or assistant_payload.get("content", "") or "New chat")[:255]
|
conversation.title = self._generate_conversation_title(validated.get("query", ""))
|
||||||
conversation.save(update_fields=["title", "updated_at"])
|
conversation.save(update_fields=["title", "updated_at"])
|
||||||
else:
|
else:
|
||||||
conversation.save(update_fields=["updated_at"])
|
conversation.save(update_fields=["updated_at"])
|
||||||
@@ -521,142 +508,3 @@ class ChatView(ConversationAccessMixin, APIView):
|
|||||||
},
|
},
|
||||||
status=response_status_code,
|
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,
|
|
||||||
farm=conversation.farm,
|
|
||||||
role=Message.ROLE_USER,
|
|
||||||
content=validated.get("content", ""),
|
|
||||||
images=validated.get("images", []),
|
|
||||||
raw_response={"farm_uuid": self._farm_uuid_or_none(conversation.farm)},
|
|
||||||
)
|
|
||||||
|
|
||||||
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,
|
|
||||||
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),
|
|
||||||
OpenApiParameter(name="farm_uuid", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, default="11111111-1111-1111-1111-111111111111"),
|
|
||||||
],
|
|
||||||
responses={200: status_response("FarmAiAssistantChatTaskStatusResponse", data=ChatTaskStatusDataSerializer())},
|
|
||||||
)
|
|
||||||
def get(self, request, task_id):
|
|
||||||
farm = self._get_optional_farm(request, request.query_params.get("farm_uuid"))
|
|
||||||
try:
|
|
||||||
query = {}
|
|
||||||
if farm:
|
|
||||||
query["farm_uuid"] = str(farm.farm_uuid)
|
|
||||||
adapter_response = external_api_request(
|
|
||||||
"ai",
|
|
||||||
f"/tasks/{task_id}/status",
|
|
||||||
method="GET",
|
|
||||||
query=query,
|
|
||||||
)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
farm_uuid = farm.farm_uuid if farm else None
|
|
||||||
user_message = self._find_user_message_for_task(request, task_id, farm_uuid)
|
|
||||||
conversation = user_message.conversation if user_message else None
|
|
||||||
task_status_payload = self._extract_task_status_payload(
|
|
||||||
adapter_response.data,
|
|
||||||
task_id,
|
|
||||||
conversation=conversation,
|
|
||||||
farm_uuid=farm_uuid,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = self._extract_structured_task_result(adapter_response.data)
|
|
||||||
if result is not None:
|
|
||||||
task_status_payload["result"] = 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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.15 on 2026-04-25 21:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('farm_alerts', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='anomalydetection',
|
||||||
|
name='severity',
|
||||||
|
field=models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='warning', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='farmalert',
|
||||||
|
name='color',
|
||||||
|
field=models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='info', max_length=32),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.15 on 2026-04-25 21:19
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sensor_catalog', '0003_sensorcatalog_code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='sensorcatalog',
|
||||||
|
options={'ordering': ['code']},
|
||||||
|
),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user