From 3c2d6ea511bde92fc01e3ddb3b7c83abbd0e9aea Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 5 Sep 2026 14:14:56 +0300 Subject: [PATCH] =?UTF-8?q?:fire:=20refactor(api)!:=20=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=20=D1=81=D1=82=D0=B0=D1=80=D1=8B=D1=85=20=D1=80=D0=BE?= =?UTF-8?q?=D1=83=D1=82=D0=BE=D0=B2=20/channels/=20=D0=B8=20/ai/agents/=20?= =?UTF-8?q?=E2=80=94=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=81=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20API=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=20/agents/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка агента (/agents/) — единственный HTTP-слой поверх Channel+AIAgent (ADR-HUB-0041 §4). Удалены дублирующие роуты channels/ (list/detail/ connections/counters) и ai/agents/ (list/detail/update/activate/deactivate) вместе с views/serializers/api_inputs и мёртвым сервисным кодом каналов (create_channel, delete_channel, filter_channels). Тест-чат перенесён в AgentCardTestChatView (/agents//test-chat/). Bulk-роут ai/agents//knowledge/select-category/ сохранён — он адресует AIAgent, как и knowledge/bulk/agent/. Фронт: bulk-attach знаний и статей портала берёт агентов из /agents/ (id для bulk — aiAgentId карточки), AgentLinkOption без channelName. Тесты старых роутов удалены; уникальное покрытие портировано на карточки (активация без провайдера, семантика knowledgeIds, атомарный откат PATCH, payload статей портала). Co-Authored-By: Claude Fable 5.1 --- apps/backend/hub_backend/urls_app.py | 4 - .../hub_platform/ai/agent_card_urls.py | 7 +- .../hub_platform/ai/agent_card_views.py | 38 ++ apps/backend/hub_platform/ai/agent_views.py | 204 ------- apps/backend/hub_platform/ai/selectors.py | 12 - apps/backend/hub_platform/ai/serializers.py | 44 -- .../hub_platform/ai/test_agent_attachments.py | 2 +- .../hub_platform/ai/test_agent_cards.py | 70 +++ .../ai/test_agent_knowledge_guards.py | 38 +- .../ai/test_knowledge_policy_api.py | 10 +- .../ai/test_knowledge_selectors.py | 15 - apps/backend/hub_platform/ai/tests.py | 174 +----- apps/backend/hub_platform/ai/urls.py | 20 +- .../hub_platform/channels/api_inputs.py | 98 --- .../hub_platform/channels/selectors.py | 42 -- .../hub_platform/channels/serializers.py | 77 --- .../backend/hub_platform/channels/services.py | 76 --- apps/backend/hub_platform/channels/tests.py | 569 ------------------ apps/backend/hub_platform/channels/urls.py | 24 - apps/backend/hub_platform/channels/views.py | 313 ---------- .../src/features/ai/agentOptions.ts | 12 +- apps/internal-ui/src/features/ai/model.ts | 26 - .../src/features/ai/useAiAgents.ts | 7 +- .../content-library/AgentLinkDialog.tsx | 3 +- 24 files changed, 151 insertions(+), 1734 deletions(-) delete mode 100644 apps/backend/hub_platform/ai/agent_views.py delete mode 100644 apps/backend/hub_platform/channels/api_inputs.py delete mode 100644 apps/backend/hub_platform/channels/serializers.py delete mode 100644 apps/backend/hub_platform/channels/tests.py delete mode 100644 apps/backend/hub_platform/channels/urls.py delete mode 100644 apps/backend/hub_platform/channels/views.py diff --git a/apps/backend/hub_backend/urls_app.py b/apps/backend/hub_backend/urls_app.py index 8e229ad..3d3459d 100644 --- a/apps/backend/hub_backend/urls_app.py +++ b/apps/backend/hub_backend/urls_app.py @@ -32,10 +32,6 @@ urlpatterns = [ "api/v1/organizations//integrations/", include("hub_platform.integrations.urls"), ), - path( - "api/v1/organizations//channels/", - include("hub_platform.channels.urls"), - ), path( "api/v1/organizations//conversations/", include("hub_platform.conversations.urls"), diff --git a/apps/backend/hub_platform/ai/agent_card_urls.py b/apps/backend/hub_platform/ai/agent_card_urls.py index 123d8a0..a311af4 100644 --- a/apps/backend/hub_platform/ai/agent_card_urls.py +++ b/apps/backend/hub_platform/ai/agent_card_urls.py @@ -3,7 +3,6 @@ from django.urls import path from hub_platform.ai import agent_card_views as views -from hub_platform.channels.views import ChannelTestChatView urlpatterns = [ path("", views.AgentCardListView.as_view(), name="agent-card-list"), @@ -28,11 +27,9 @@ urlpatterns = [ views.AgentCardConnectionDetailView.as_view(), name="agent-card-connection-detail", ), - # Тестовый чат исполняет агента канала; вьюха принимает channel_id — id - # карточки и канала совпадают по построению. path( - "/test-chat/", - ChannelTestChatView.as_view(), + "/test-chat/", + views.AgentCardTestChatView.as_view(), name="agent-card-test-chat", ), ] diff --git a/apps/backend/hub_platform/ai/agent_card_views.py b/apps/backend/hub_platform/ai/agent_card_views.py index 90dc3b8..11ea77b 100644 --- a/apps/backend/hub_platform/ai/agent_card_views.py +++ b/apps/backend/hub_platform/ai/agent_card_views.py @@ -16,9 +16,12 @@ from hub_platform.ai.agent_card import ( set_agent_card_active, update_agent_card, ) +from hub_platform.ai.provider.base import ProviderError from hub_platform.api.permissions import HasCapability from hub_platform.channels import services as channel_services from hub_platform.channels.models import Channel +from hub_platform.channels.runtime import run_channel_turn +from hub_platform.channels.selectors import channel_for_context from hub_platform.identity.audit import record_audit_event AGENT_NOT_FOUND = {"detail": "Агент не найден"} @@ -172,6 +175,41 @@ class AgentCardDeactivateView(_AgentCardStatusView): target_active = False +class AgentCardTestChatView(APIView): + permission_classes = [HasCapability] + # Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9). + required_capability = "ai.manage" + require_organization_scope = True + + def post(self, request: Request, agent_id: int) -> Response: + try: + channel = channel_for_context( + context=request.tenant_context, + channel_id=agent_id, + capability="ai.view", + ) + except Channel.DoesNotExist: + return Response(AGENT_NOT_FOUND, status=404) + message = str(request.data.get("message", "")).strip() + if not message: + return Response({"detail": "Пустое сообщение"}, status=400) + history = request.data.get("history") or [] + if not isinstance(history, list): + return Response({"detail": "history must be a list"}, status=400) + try: + result = run_channel_turn(channel=channel, message=message, history=history) + except ProviderError as error: + return Response({"detail": f"Ошибка провайдера: {error}"}, status=502) + return Response( + { + "reply": result.text, + "model": result.model, + "promptTokens": result.prompt_tokens, + "completionTokens": result.completion_tokens, + } + ) + + class AgentCardConnectionsView(APIView): permission_classes = [HasCapability] required_capability = "integrations.manage" diff --git a/apps/backend/hub_platform/ai/agent_views.py b/apps/backend/hub_platform/ai/agent_views.py deleted file mode 100644 index 51c5405..0000000 --- a/apps/backend/hub_platform/ai/agent_views.py +++ /dev/null @@ -1,204 +0,0 @@ -from django.core.exceptions import ValidationError -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from hub_platform.ai.models import AIAgent -from hub_platform.ai.selectors import ( - agent_for_employee, - agents_for_employee, - channel_for_ai_capability, -) -from hub_platform.ai.serializers import agent_payload -from hub_platform.ai.services import ( - AgentCreateInput, - AgentInput, - create_agent, - set_agent_active, - update_agent, -) -from hub_platform.api.permissions import HasCapability -from hub_platform.channels.models import Channel -from hub_platform.identity.audit import record_audit_event - - -def _agent_input(body: dict[str, object], *, current: AIAgent) -> AgentInput: - model_params = body.get("modelParams", current.model_params) - allowed_tools = body.get("allowedTools", current.allowed_tools) - limits = body.get("limits", current.limits) - knowledge_ids = body.get("knowledgeIds") - if not isinstance(model_params, dict): - raise ValidationError({"modelParams": "Object required"}) - if not isinstance(allowed_tools, list): - raise ValidationError({"allowedTools": "List required"}) - if not isinstance(limits, dict): - raise ValidationError({"limits": "Object required"}) - if knowledge_ids is not None and ( - not isinstance(knowledge_ids, list) - or not all(isinstance(item, int) for item in knowledge_ids) - ): - raise ValidationError({"knowledgeIds": "List of ids required"}) - provider_integration_id = body.get( - "providerIntegrationId", current.provider_integration_id - ) - if provider_integration_id is not None and not isinstance(provider_integration_id, int): - raise ValidationError({"providerIntegrationId": "Integer id required"}) - return AgentInput( - name=str(body.get("name", current.name)).strip() or current.name, - provider_integration_id=provider_integration_id, - model_params=model_params, - allowed_tools=allowed_tools, - limits=limits, - persona=str(body.get("persona", current.persona)), - tone=str(body.get("tone", current.tone)), - instructions=str(body.get("instructions", current.instructions)), - knowledge_ids=knowledge_ids, - ) - - -def _validation_error(error: ValidationError) -> Response: - detail = ( - "; ".join(message for messages in error.message_dict.values() for message in messages) - if hasattr(error, "message_dict") - else "; ".join(error.messages) - ) - return Response({"detail": detail}, status=400) - - -class AIAgentListView(APIView): - permission_classes = [HasCapability] - required_capabilities = {"GET": "ai.view", "POST": "ai.manage"} - def get(self, request: Request) -> Response: - agents = agents_for_employee( - context=request.tenant_context, - capability="ai.view", - ) - return Response({"items": [agent_payload(agent) for agent in agents]}) - - def post(self, request: Request) -> Response: - knowledge_ids = request.data.get("knowledgeIds", []) - if not isinstance(knowledge_ids, list) or not all( - isinstance(item, int) for item in knowledge_ids - ): - return Response({"detail": "knowledgeIds must be a list of ids"}, status=400) - channel_code = str(request.data.get("channel", "")).strip() - try: - channel_for_ai_capability( - context=request.tenant_context, - channel_code=channel_code, - capability="ai.manage", - ) - except Channel.DoesNotExist: - return Response({"detail": "Channel not found"}, status=404) - try: - agent = create_agent( - context=request.tenant_context, - data=AgentCreateInput( - channel_code=channel_code, - provider_integration_id=request.data.get("providerIntegrationId"), - persona=str(request.data.get("persona", "")), - tone=str(request.data.get("tone", "")), - instructions=str(request.data.get("instructions", "")), - knowledge_ids=knowledge_ids, - ), - ) - except ValidationError as error: - return _validation_error(error) - record_audit_event( - action="ai.agent_created", - actor=request.user, - organization=request.tenant_context.organization, - object_type="AIAgent", - object_id=str(agent.id), - request=request, - ) - return Response({"agent": agent_payload(agent)}, status=201) - - -class AIAgentDetailView(APIView): - permission_classes = [HasCapability] - required_capability = "ai.view" - def get(self, request: Request, agent_id: int) -> Response: - try: - agent = agent_for_employee( - context=request.tenant_context, - agent_id=agent_id, - capability="ai.view", - ) - except AIAgent.DoesNotExist: - return Response({"detail": "Agent not found"}, status=404) - return Response({"agent": agent_payload(agent)}) - - -class AIAgentUpdateView(APIView): - permission_classes = [HasCapability] - required_capability = "ai.manage" - def patch(self, request: Request, agent_id: int) -> Response: - try: - agent = agent_for_employee( - context=request.tenant_context, - agent_id=agent_id, - capability="ai.manage", - ) - except AIAgent.DoesNotExist: - return Response({"detail": "Agent not found"}, status=404) - try: - agent = update_agent( - context=request.tenant_context, - agent=agent, - data=_agent_input(request.data, current=agent), - ) - except ValidationError as error: - return _validation_error(error) - record_audit_event( - action="ai.agent_updated", - actor=request.user, - organization=request.tenant_context.organization, - object_type="AIAgent", - object_id=str(agent.id), - request=request, - ) - return Response( - { - "agent": agent_payload( - agent_for_employee( - context=request.tenant_context, - agent_id=agent_id, - capability="ai.manage", - ) - ) - } - ) - - -class _AIAgentStatusView(APIView): - permission_classes = [HasCapability] - required_capability = "ai.manage" - target_active: bool - - def post(self, request: Request, agent_id: int) -> Response: - try: - agent = agent_for_employee( - context=request.tenant_context, - agent_id=agent_id, - capability="ai.manage", - ) - except AIAgent.DoesNotExist: - return Response({"detail": "Agent not found"}, status=404) - try: - agent = set_agent_active( - context=request.tenant_context, - agent=agent, - is_active=self.target_active, - ) - except ValidationError as error: - return _validation_error(error) - return Response({"agent": agent_payload(agent)}) - - -class AIAgentActivateView(_AIAgentStatusView): - target_active = True - - -class AIAgentDeactivateView(_AIAgentStatusView): - target_active = False diff --git a/apps/backend/hub_platform/ai/selectors.py b/apps/backend/hub_platform/ai/selectors.py index 5a82954..55673e0 100644 --- a/apps/backend/hub_platform/ai/selectors.py +++ b/apps/backend/hub_platform/ai/selectors.py @@ -38,18 +38,6 @@ def agent_for_employee(*, context: TenantContext, agent_id: int, capability: str return agents_for_employee(context=context, capability=capability).get(id=agent_id) -def channel_for_ai_capability( - *, context: TenantContext, channel_code: str, capability: str -) -> Channel: - queryset = Channel.objects.filter( - organization_id=context.organization_id, - code=channel_code, - ) - if has_capability_any_scope(context.membership, capability): - return queryset.get() - return queryset.none().get() - - def knowledge_for_context(context: TenantContext) -> QuerySet[Knowledge]: return knowledge_for_employee(context=context) diff --git a/apps/backend/hub_platform/ai/serializers.py b/apps/backend/hub_platform/ai/serializers.py index b758ed8..32aad11 100644 --- a/apps/backend/hub_platform/ai/serializers.py +++ b/apps/backend/hub_platform/ai/serializers.py @@ -1,5 +1,4 @@ from hub_platform.ai.models import ( - AIAgent, Knowledge, KnowledgeAttachment, KnowledgeCategory, @@ -7,20 +6,6 @@ from hub_platform.ai.models import ( from hub_platform.support_portals.addressing import article_public_url -def _channel_ref(channel) -> dict[str, object]: - return { - "id": channel.id, - "code": channel.code, - "name": channel.name, - "product": {"code": channel.product.code, "name": channel.product.name} if channel.product_id else None, - "group": ( - {"id": channel.group_id, "name": channel.group.name} - if channel.group_id - else None - ), - } - - def attachment_payload(attachment: KnowledgeAttachment) -> dict[str, object]: return { "id": attachment.id, @@ -79,32 +64,3 @@ def agent_portal_article_payload(article) -> dict[str, object]: "portal": {"id": article.portal_id, "name": article.portal.name}, "publicUrl": article_public_url(article), } - - -def agent_payload(agent: AIAgent) -> dict[str, object]: - return { - "id": agent.id, - "channel": _channel_ref(agent.channel), - "name": agent.name, - "isActive": agent.is_active, - "status": agent.status, - "model": agent.model, - # BYOK-провайдер принадлежит агенту (SPEC-HUB-0027 §9). - "providerIntegrationId": agent.provider_integration_id, - "modelParams": agent.model_params, - "allowedTools": agent.allowed_tools, - "limits": agent.limits, - "persona": agent.persona, - "tone": agent.tone, - "instructions": agent.instructions, - "knowledge": [ - {"id": knowledge.id, "title": knowledge.title, "isEnabled": knowledge.is_enabled} - for knowledge in agent.knowledge_items.all() - ], - "portalArticles": [ - agent_portal_article_payload(article) - for article in agent.portal_articles.all() - ], - "createdAt": agent.created_at.isoformat(), - "updatedAt": agent.updated_at.isoformat(), - } diff --git a/apps/backend/hub_platform/ai/test_agent_attachments.py b/apps/backend/hub_platform/ai/test_agent_attachments.py index 897a1a1..f6f0036 100644 --- a/apps/backend/hub_platform/ai/test_agent_attachments.py +++ b/apps/backend/hub_platform/ai/test_agent_attachments.py @@ -213,7 +213,7 @@ class AgentPortalArticleLinkApiTests(AgentAttachmentTestCase): article = self.article(self.portal(), title="Refund policy") self.support_agent.portal_articles.add(article) - response = self.client.get(f"/api/v1/ai/agents/{self.support_agent.id}/") + response = self.client.get(f"/api/v1/agents/{self.support_channel.id}/") self.assertEqual(response.status_code, 200) articles = response.json()["agent"]["portalArticles"] diff --git a/apps/backend/hub_platform/ai/test_agent_cards.py b/apps/backend/hub_platform/ai/test_agent_cards.py index ae06b50..49c7b14 100644 --- a/apps/backend/hub_platform/ai/test_agent_cards.py +++ b/apps/backend/hub_platform/ai/test_agent_cards.py @@ -175,6 +175,76 @@ class AgentCardUpdateTests(AgentCardTestCase): agent = AIAgent.objects.get(id=self.card["aiAgentId"]) self.assertEqual(agent.persona, "") + def test_knowledge_selection_semantics(self) -> None: + # Пустой список снимает выбор; отсутствие ключа — не трогает. + from hub_platform.ai.knowledge_categories import ensure_uncategorized_category + from hub_platform.ai.models import Knowledge + + knowledge = Knowledge.objects.create( + organization=self.organization, + category=ensure_uncategorized_category(self.organization), + title="FAQ", + content="v1", + ) + agent = AIAgent.objects.get(id=self.card["aiAgentId"]) + + self.assertEqual(self.patch(knowledgeIds=[knowledge.id]).status_code, 200) + self.assertEqual(agent.knowledge_items.count(), 1) + self.patch(name="Renamed") + self.assertEqual(agent.knowledge_items.count(), 1) + self.patch(knowledgeIds=[]) + self.assertEqual(agent.knowledge_items.count(), 0) + + +class AgentCardActivationTests(AgentCardTestCase): + def setUp(self) -> None: + super().setUp() + self.card = self.create_agent().json()["agent"] + + def _byok_integration(self) -> Integration: + from hub_platform.integrations.models import IntegrationProvider + from hub_platform.integrations.services import IntegrationInput, create_integration + from hub_platform.testing import system_tenant_context + + return create_integration( + context=system_tenant_context(self.organization), + data=IntegrationInput( + provider=IntegrationProvider.OPENROUTER, + name="BYOK", + secret="sk-byok", + config={"baseUrl": "https://openrouter.ai/api/v1", "defaultModel": "byok-model"}, + ), + ) + + def test_activation_without_provider_integration_is_rejected(self) -> None: + # Активация требует выбранного провайдера организации (ADR-HUB-0042 §2); + # деактивация свободна. + response = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/") + + self.assertEqual(response.status_code, 400) + self.assertIn("провайдера", response.json()["detail"]) + agent = AIAgent.objects.get(id=self.card["aiAgentId"]) + self.assertNotEqual(agent.status, AIAgentStatus.ACTIVE) + + def test_activates_and_deactivates_with_provider(self) -> None: + integration = self._byok_integration() + patched = self.client.patch( + f"/api/v1/agents/{self.card['id']}/", + data=json.dumps({"providerIntegrationId": integration.id}), + content_type="application/json", + ) + self.assertEqual(patched.status_code, 200) + # Модель принадлежит интеграции: агент получает её default_model. + self.assertEqual(patched.json()["agent"]["model"], "byok-model") + + activated = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/") + self.assertEqual(activated.status_code, 200) + self.assertEqual(activated.json()["agent"]["aiStatus"], AIAgentStatus.ACTIVE) + + deactivated = self.client.post(f"/api/v1/agents/{self.card['id']}/deactivate/") + self.assertEqual(deactivated.status_code, 200) + self.assertEqual(deactivated.json()["agent"]["aiStatus"], AIAgentStatus.DISABLED) + class AgentCardDeleteTests(AgentCardTestCase): def test_deletes_card_with_agent(self) -> None: diff --git a/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py b/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py index 73a5e3c..7ecfa1d 100644 --- a/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py +++ b/apps/backend/hub_platform/ai/test_agent_knowledge_guards.py @@ -51,33 +51,34 @@ class AgentKnowledgeAssignmentTests(TestCase): password="temporary-password", ) - def _create(self, channel: Channel, knowledge_ids: list[int]): - return self.client.post( - "/api/v1/ai/agents/", - data=json.dumps({"channel": channel.code, "knowledgeIds": knowledge_ids}), - content_type="application/json", + def _create(self, channel: Channel, knowledge_ids: list[int]) -> AIAgent: + from hub_platform.ai.services import AgentCreateInput, create_agent + + return create_agent( + context=self.context, + data=AgentCreateInput( + channel_code=channel.code, + provider_integration_id=None, + persona="", + tone="", + instructions="", + knowledge_ids=knowledge_ids, + ), ) def test_create_accepts_organization_knowledge(self) -> None: - response = self._create( - self.channel, - [self.shared.id, self.second.id], - ) + agent = self._create(self.channel, [self.shared.id, self.second.id]) - self.assertEqual(response.status_code, 201) - agent = AIAgent.objects.get(channel=self.channel) self.assertEqual( set(agent.knowledge_items.values_list("id", flat=True)), {self.shared.id, self.second.id}, ) def test_create_rejects_selection_with_unknown_knowledge_id(self) -> None: - response = self._create( - self.channel, - [self.shared.id, 999999], - ) + from django.core.exceptions import ValidationError - self.assertEqual(response.status_code, 400) + with self.assertRaises(ValidationError): + self._create(self.channel, [self.shared.id, 999999]) self.assertFalse(AIAgent.objects.filter(channel=self.channel).exists()) def test_update_rolls_back_agent_fields_and_selection_on_invalid_id(self) -> None: @@ -88,8 +89,9 @@ class AgentKnowledgeAssignmentTests(TestCase): ) agent.knowledge_items.add(self.shared) + # PATCH карточки атомарен: канал и AI-поля откатываются вместе. response = self.client.patch( - f"/api/v1/ai/agents/{agent.id}/update/", + f"/api/v1/agents/{self.channel.id}/", data=json.dumps( { "name": "Changed", @@ -101,7 +103,9 @@ class AgentKnowledgeAssignmentTests(TestCase): self.assertEqual(response.status_code, 400) agent.refresh_from_db() + self.channel.refresh_from_db() self.assertEqual(agent.name, "Original") + self.assertEqual(self.channel.name, "Org") self.assertEqual( list(agent.knowledge_items.values_list("id", flat=True)), [self.shared.id], diff --git a/apps/backend/hub_platform/ai/test_knowledge_policy_api.py b/apps/backend/hub_platform/ai/test_knowledge_policy_api.py index e092b22..3267b4a 100644 --- a/apps/backend/hub_platform/ai/test_knowledge_policy_api.py +++ b/apps/backend/hub_platform/ai/test_knowledge_policy_api.py @@ -74,18 +74,18 @@ class KnowledgePolicyApiTests(KnowledgePolicyTestBase): ) agent = AIAgent.objects.create(channel=channel, name="Agent") - response = self.client.get("/api/v1/ai/agents/") + response = self.client.get("/api/v1/agents/") self.assertEqual(response.status_code, 200) self.assertEqual( - [item["id"] for item in response.json()["items"]], + [item["aiAgentId"] for item in response.json()["items"]], [agent.id], ) - detail = self.client.get(f"/api/v1/ai/agents/{agent.id}/") + detail = self.client.get(f"/api/v1/agents/{channel.id}/") self.assertEqual(detail.status_code, 200) self.client.force_authenticate(self.employee.user) - self.assertEqual(self.client.get("/api/v1/ai/agents/").status_code, 403) + self.assertEqual(self.client.get("/api/v1/agents/").status_code, 403) self.assertEqual( - self.client.get(f"/api/v1/ai/agents/{agent.id}/").status_code, 403 + self.client.get(f"/api/v1/agents/{channel.id}/").status_code, 403 ) diff --git a/apps/backend/hub_platform/ai/test_knowledge_selectors.py b/apps/backend/hub_platform/ai/test_knowledge_selectors.py index f37163f..4a8070c 100644 --- a/apps/backend/hub_platform/ai/test_knowledge_selectors.py +++ b/apps/backend/hub_platform/ai/test_knowledge_selectors.py @@ -8,7 +8,6 @@ from hub_platform.ai.selectors import ( agents_for_employee, apply_knowledge_filters, category_tree_for_employee, - channel_for_ai_capability, knowledge_available_to_agent, knowledge_for_employee, ) @@ -134,17 +133,3 @@ class KnowledgeSelectorTests(KnowledgePolicyTestBase): agent_id=agent.id, capability="ai.view", ) - self.assertEqual( - channel_for_ai_capability( - context=self.admin_context, - channel_code=channel.code, - capability="ai.manage", - ), - channel, - ) - with self.assertRaises(Channel.DoesNotExist): - channel_for_ai_capability( - context=self.employee_context, - channel_code=channel.code, - capability="ai.manage", - ) diff --git a/apps/backend/hub_platform/ai/tests.py b/apps/backend/hub_platform/ai/tests.py index 63e90a5..bfa2a23 100644 --- a/apps/backend/hub_platform/ai/tests.py +++ b/apps/backend/hub_platform/ai/tests.py @@ -28,30 +28,6 @@ def make_channel_with_agent(organization, *, code, name, product=None, model="op return channel, agent -def seed_sales_channels(organization): - firepage = Product.objects.get(organization=organization, code="firepage") - foxray = Product.objects.get(organization=organization, code="foxray") - make_channel_with_agent(organization, code="firepage-sales", name="FirePage — продажи", product=firepage) - make_channel_with_agent(organization, code="foxray-sales", name="FoxRay — продажи", product=foxray) - - -def _byok_integration(organization, *, default_model="byok-model"): - """LLM-интеграция организации: единственный источник провайдера агента - после удаления managed-режима (ADR-HUB-0042 §3).""" - from hub_platform.integrations.models import IntegrationProvider - from hub_platform.integrations.services import IntegrationInput, create_integration - - return create_integration( - context=system_tenant_context(organization), - data=IntegrationInput( - provider=IntegrationProvider.OPENROUTER, - name="BYOK", - secret="sk-byok", - config={"baseUrl": "https://openrouter.ai/api/v1", "defaultModel": default_model}, - ), - ) - - def make_knowledge(organization, *, title, content=""): return Knowledge.objects.create( organization=organization, @@ -93,148 +69,6 @@ class AIAgentInvariantTests(TestCase): self.assertFalse(AIAgent.objects.filter(channel__product=product).exists()) -class AIAgentApiTests(TestCase): - def setUp(self) -> None: - bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") - self.organization = Organization.objects.get(slug="edevs") - seed_sales_channels(self.organization) - self.client = APIClient() - self.client.login(username="owner@edevs.tech", password="temporary-password") - - def test_owner_lists_agents(self) -> None: - response = self.client.get("/api/v1/ai/agents/") - - self.assertEqual(response.status_code, 200) - codes = {item["channel"]["product"]["code"] for item in response.json()["items"]} - self.assertEqual(codes, {"firepage", "foxray"}) - - def test_owner_creates_agent_with_instructions_and_knowledge(self) -> None: - academy = Product.objects.create(organization=self.organization, code="academy", name="Academy") - Channel.objects.create(organization=self.organization, code="academy-sales", name="Academy", product=academy) - knowledge = make_knowledge(self.organization, title="FAQ", content="v1") - - response = self.client.post( - "/api/v1/ai/agents/", - data=json.dumps( - { - "channel": "academy-sales", - "persona": "Ты — ассистент Academy.", - "tone": "Коротко и по делу.", - "instructions": "Отвечай по делу.", - "knowledgeIds": [knowledge.id], - } - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - body = response.json()["agent"] - self.assertEqual(body["channel"]["code"], "academy-sales") - self.assertFalse(body["isActive"]) - self.assertEqual(body["persona"], "Ты — ассистент Academy.") - self.assertEqual([item["id"] for item in body["knowledge"]], [knowledge.id]) - - def test_owner_cannot_create_second_agent_for_channel(self) -> None: - response = self.client.post( - "/api/v1/ai/agents/", - data=json.dumps({"channel": "firepage-sales", "knowledgeIds": []}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - - def test_owner_updates_agent_provider_and_instructions(self) -> None: - agent = AIAgent.objects.get(channel__code="firepage-sales") - integration = _byok_integration(self.organization) - - response = self.client.patch( - f"/api/v1/ai/agents/{agent.id}/update/", - data=json.dumps( - { - "providerIntegrationId": integration.id, - "modelParams": {"temperature": 0.3}, - "tone": "Дружелюбно.", - } - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 200) - # Модель принадлежит интеграции: агент получает её default_model. - self.assertEqual(response.json()["agent"]["providerIntegrationId"], integration.id) - self.assertNotIn("credentialMode", response.json()["agent"]) - agent.refresh_from_db() - self.assertEqual(agent.provider_integration_id, integration.id) - self.assertEqual(agent.model, "byok-model") - self.assertEqual(agent.model_params, {"temperature": 0.3}) - self.assertEqual(agent.tone, "Дружелюбно.") - - def test_owner_updates_agent_knowledge_selection(self) -> None: - agent = AIAgent.objects.get(channel__code="firepage-sales") - knowledge = make_knowledge(self.organization, title="FAQ", content="v1") - - response = self.client.patch( - f"/api/v1/ai/agents/{agent.id}/update/", - data=json.dumps({"knowledgeIds": [knowledge.id]}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(list(agent.knowledge_items.values_list("id", flat=True)), [knowledge.id]) - - # Пустой список снимает выбор; отсутствие ключа — не трогает. - self.client.patch( - f"/api/v1/ai/agents/{agent.id}/update/", - data=json.dumps({"name": "Renamed"}), - content_type="application/json", - ) - self.assertEqual(agent.knowledge_items.count(), 1) - self.client.patch( - f"/api/v1/ai/agents/{agent.id}/update/", - data=json.dumps({"knowledgeIds": []}), - content_type="application/json", - ) - self.assertEqual(agent.knowledge_items.count(), 0) - - def test_owner_deactivates_and_activates_agent(self) -> None: - agent = AIAgent.objects.get(channel__code="foxray-sales") - integration = _byok_integration(self.organization) - agent.provider_integration = integration - agent.save(update_fields=["provider_integration"]) - - deactivated = self.client.post(f"/api/v1/ai/agents/{agent.id}/deactivate/") - self.assertEqual(deactivated.status_code, 200) - self.assertFalse(deactivated.json()["agent"]["isActive"]) - - activated = self.client.post(f"/api/v1/ai/agents/{agent.id}/activate/") - self.assertEqual(activated.status_code, 200) - self.assertTrue(activated.json()["agent"]["isActive"]) - - def test_activation_without_provider_integration_is_rejected(self) -> None: - # Активация требует выбранного провайдера организации (ADR-HUB-0042 §2); - # деактивация свободна. - agent = AIAgent.objects.get(channel__code="foxray-sales") - self.client.post(f"/api/v1/ai/agents/{agent.id}/deactivate/") - - response = self.client.post(f"/api/v1/ai/agents/{agent.id}/activate/") - - self.assertEqual(response.status_code, 400) - self.assertIn("провайдера", response.json()["detail"]) - agent.refresh_from_db() - self.assertFalse(agent.is_active) - - def test_update_rejects_invalid_model_params(self) -> None: - agent = AIAgent.objects.get(channel__code="firepage-sales") - - response = self.client.patch( - f"/api/v1/ai/agents/{agent.id}/update/", - data=json.dumps({"modelParams": "not-an-object"}), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - - class AIAgentPermissionTests(TestCase): def setUp(self) -> None: bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") @@ -248,10 +82,6 @@ class AIAgentPermissionTests(TestCase): self.client = APIClient() self.client.login(username="operator@edevs.tech", password="operator-password") - def test_operator_cannot_access_agents(self) -> None: - response = self.client.get("/api/v1/ai/agents/") - self.assertEqual(response.status_code, 403) - def test_operator_cannot_access_knowledge(self) -> None: response = self.client.get("/api/v1/ai/knowledge/") self.assertEqual(response.status_code, 403) @@ -713,12 +543,12 @@ class AgentRuntimeTests(TestCase): with self.assertRaises(ProviderError): run_channel_turn(channel=self.channel, message="hi") - def test_channel_test_chat_endpoint(self) -> None: + def test_agent_card_test_chat_endpoint(self) -> None: client = APIClient() client.login(username="owner@edevs.tech", password="temporary-password") response = client.post( - f"/api/v1/channels/{self.channel.id}/test-chat/", + f"/api/v1/agents/{self.channel.id}/test-chat/", data=json.dumps({"message": "refund"}), content_type="application/json", ) diff --git a/apps/backend/hub_platform/ai/urls.py b/apps/backend/hub_platform/ai/urls.py index 054fc45..e07c373 100644 --- a/apps/backend/hub_platform/ai/urls.py +++ b/apps/backend/hub_platform/ai/urls.py @@ -1,25 +1,9 @@ from django.urls import path -from hub_platform.ai import agent_views, bulk_views, category_views, views +from hub_platform.ai import bulk_views, category_views, views urlpatterns = [ - path("agents/", agent_views.AIAgentListView.as_view(), name="ai-agent-list"), - path("agents//", agent_views.AIAgentDetailView.as_view(), name="ai-agent-detail"), - path( - "agents//update/", - agent_views.AIAgentUpdateView.as_view(), - name="ai-agent-update", - ), - path( - "agents//activate/", - agent_views.AIAgentActivateView.as_view(), - name="ai-agent-activate", - ), - path( - "agents//deactivate/", - agent_views.AIAgentDeactivateView.as_view(), - name="ai-agent-deactivate", - ), + # agentId — id AIAgent, как и в knowledge/bulk/agent/ (CRUD агентов — /agents/). path( "agents//knowledge/select-category/", bulk_views.AgentCategoryKnowledgeSelectView.as_view(), diff --git a/apps/backend/hub_platform/channels/api_inputs.py b/apps/backend/hub_platform/channels/api_inputs.py deleted file mode 100644 index 3755656..0000000 --- a/apps/backend/hub_platform/channels/api_inputs.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Разбор тел запросов API каналов (SPEC-HUB-0027 §6.3, §6.5). - -Отделено от views, чтобы представления оставались тонкими: проверка формы — -здесь, доменные правила — в services, авторизация — в authorization. -""" - -from __future__ import annotations - -from django.core.exceptions import ValidationError - -from hub_platform.channels.policy import ( - POLICY_API_FIELDS, - ChannelPolicy, - PolicyPreset, -) -from hub_platform.channels.services import UNSET, ChannelUpdate - - -def _optional_id(data: dict, key: str, current=UNSET): - if key not in data: - return current - value = data[key] - if value is None: - return None - if isinstance(value, bool) or not isinstance(value, int): - raise ValidationError({key: f"{key} must be an integer or null"}) - return value - - -def _optional_bool(data: dict, key: str): - if key not in data: - return UNSET - value = data[key] - if not isinstance(value, bool): - raise ValidationError({key: f"{key} must be a boolean"}) - return value - - -def parse_policy_fields(raw: object) -> dict[str, bool]: - """camelCase-ключи политики -> поля модели. Неизвестные ключи отвергаются.""" - if not isinstance(raw, dict): - raise ValidationError({"policy": "policy must be an object"}) - unknown = set(raw) - set(POLICY_API_FIELDS) - if unknown: - raise ValidationError({"policy": f"Неизвестные флаги: {', '.join(sorted(unknown))}"}) - fields: dict[str, bool] = {} - for key, value in raw.items(): - if not isinstance(value, bool): - raise ValidationError({"policy": f"{key} must be a boolean"}) - fields[POLICY_API_FIELDS[key]] = value - return fields - - -def parse_create_policy(data: dict) -> ChannelPolicy: - """§6.3: policyPreset и policy взаимоисключающи, CUSTOM требует policy.""" - preset = data.get("policyPreset") - has_policy = "policy" in data - if preset is None and not has_policy: - raise ValidationError({"policy": "Укажите policyPreset или policy"}) - if preset in {PolicyPreset.SALES, PolicyPreset.SUPPORT}: - if has_policy: - raise ValidationError( - {"policy": "policyPreset и policy взаимоисключающи"} - ) - return ChannelPolicy.from_preset(preset) - if preset == PolicyPreset.CUSTOM and not has_policy: - raise ValidationError({"policy": "Пресет CUSTOM требует policy"}) - if preset is not None and preset != PolicyPreset.CUSTOM: - raise ValidationError({"policyPreset": f"Неизвестный пресет: {preset}"}) - - fields = parse_policy_fields(data.get("policy")) - missing = set(POLICY_API_FIELDS.values()) - set(fields) - if missing: - raise ValidationError({"policy": "Политика задаётся всеми пятью флагами"}) - return ChannelPolicy(**fields) - - -def parse_connection_ids(raw: object) -> list[int]: - if raw is None: - return [] - if not isinstance(raw, list) or any( - isinstance(item, bool) or not isinstance(item, int) for item in raw - ): - raise ValidationError({"connectionIds": "connectionIds must be a list of integers"}) - return list(dict.fromkeys(raw)) - - -def parse_update(data: dict, *, current_code: str) -> ChannelUpdate: - """§6.5: code в теле игнорируется, другое значение — 400.""" - if "code" in data and str(data["code"] or "").strip() != current_code: - raise ValidationError({"code": "Код канала не изменяется после создания"}) - return ChannelUpdate( - name=data["name"] if "name" in data else UNSET, - group_id=_optional_id(data, "groupId"), - product_id=_optional_id(data, "productId"), - is_active=_optional_bool(data, "isActive"), - policy=parse_policy_fields(data["policy"]) if "policy" in data else {}, - ) diff --git a/apps/backend/hub_platform/channels/selectors.py b/apps/backend/hub_platform/channels/selectors.py index 899d281..d6a7829 100644 --- a/apps/backend/hub_platform/channels/selectors.py +++ b/apps/backend/hub_platform/channels/selectors.py @@ -54,45 +54,3 @@ def channel_for_context( *, context: TenantContext, channel_id: int, capability: str = CHANNELS_VIEW ) -> Channel: return channels_for_context(context, capability=capability).get(id=channel_id) - - -def _parse_reference(raw: str) -> tuple[str, int | None]: - """`none` — явный фильтр «без связи», иначе идентификатор.""" - if raw == "none": - return "none", None - try: - return "id", int(raw) - except (TypeError, ValueError): - return "invalid", None - - -def filter_channels(queryset: QuerySet[Channel], params) -> QuerySet[Channel]: - """Фильтры §6.2. Scope уже применён селектором и здесь не расширяется.""" - group = params.get("group") - if group: - kind, value = _parse_reference(group) - if kind == "none": - queryset = queryset.filter(group__isnull=True) - elif kind == "id": - queryset = queryset.filter(group_id=value) - - product = params.get("product") - if product: - kind, value = _parse_reference(product) - if kind == "none": - queryset = queryset.filter(product__isnull=True) - elif kind == "id": - queryset = queryset.filter(product_id=value) - - is_active = params.get("isActive") - if is_active in {"true", "false"}: - queryset = queryset.filter(is_active=is_active == "true") - - has_agent = params.get("hasAgent") - if has_agent in {"true", "false"}: - queryset = queryset.filter(ai_agent__isnull=has_agent == "false") - - search = (params.get("q") or "").strip() - if search: - queryset = queryset.filter(Q(name__icontains=search) | Q(code__icontains=search)) - return queryset diff --git a/apps/backend/hub_platform/channels/serializers.py b/apps/backend/hub_platform/channels/serializers.py deleted file mode 100644 index b7c773a..0000000 --- a/apps/backend/hub_platform/channels/serializers.py +++ /dev/null @@ -1,77 +0,0 @@ -from hub_platform.channels.models import Channel -from hub_platform.channels.policy import ChannelPolicy - - -def _agent_payload(channel: Channel) -> dict[str, object] | None: - """Сводка для ссылки на существующую страницу агента (SPEC-HUB-0027 §6.1). - - Конфигурация агента (persona, tone, instructions, allowedTools, limits, - knowledgeIds) в payload канала не входит и через API канала не изменяется. - Знаний и их счётчика здесь нет: знания принадлежат агенту. - """ - agent = getattr(channel, "ai_agent", None) - if agent is None: - return None - return { - "id": agent.id, - "name": agent.name, - "status": agent.status, - "model": agent.model, - } - - -def _connections_payload(channel: Channel) -> list[dict[str, object]]: - return [ - { - "id": connection.id, - "provider": connection.provider, - "name": connection.name, - "status": connection.status, - } - for connection in sorted(channel.connections.all(), key=lambda item: item.id) - ] - - -def _open_conversations(channel: Channel) -> int: - # Селекторы аннотируют счётчик одним агрегатом на список (SPEC §6.1): - # запрос ниже — страховка для объектов, собранных в обход селектора. - count = getattr(channel, "open_conversations_count", None) - if count is None: - from hub_platform.conversations.models import LifecycleState - - return channel.conversations.filter(lifecycle=LifecycleState.OPEN).count() - return count - - -def channel_payload(channel: Channel) -> dict[str, object]: - policy = ChannelPolicy.from_channel(channel) - connections = _connections_payload(channel) - agent = _agent_payload(channel) - return { - "id": channel.id, - "code": channel.code, - "name": channel.name, - "isActive": channel.is_active, - "product": { - "id": channel.product.id, - "code": channel.product.code, - "name": channel.product.name, - } - if channel.product_id - else None, - "groupId": channel.group_id, - "groupName": channel.group.name if channel.group_id else None, - "agent": agent, - "connections": connections, - "policy": policy.as_payload(), - "counters": { - "openConversations": _open_conversations(channel), - "connections": len(connections), - }, - "createdAt": channel.created_at.isoformat(), - "updatedAt": channel.updated_at.isoformat(), - # Совместимость на один релиз: плоские ключи политики и agentId живут - # рядом с policy/agent и удаляются после перевода клиентов (SPEC §6.1). - "agentId": agent["id"] if agent else None, - **policy.as_payload(), - } diff --git a/apps/backend/hub_platform/channels/services.py b/apps/backend/hub_platform/channels/services.py index 294a224..d246d89 100644 --- a/apps/backend/hub_platform/channels/services.py +++ b/apps/backend/hub_platform/channels/services.py @@ -2,7 +2,6 @@ from __future__ import annotations -import re from dataclasses import dataclass, field from typing import Any @@ -18,7 +17,6 @@ from hub_platform.products.models import Product from hub_platform.tenancy.context import TenantContext # SPEC §3.1: slug 1-64, входит в embed-URL web-виджета и после создания immutable. -CODE_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*$") CODE_MAX_LENGTH = 64 NAME_MAX_LENGTH = 255 @@ -33,12 +31,6 @@ class Unset: UNSET = Unset() -class ChannelCodeConflict(Exception): - def __init__(self, code: str) -> None: - self.code = code - super().__init__(f"Канал с кодом {code} уже существует") - - class ChannelHasReferences(Exception): """§7.2. SET_NULL и CASCADE не считаются разрешением на удаление.""" @@ -81,15 +73,6 @@ class ChannelUpdate: policy: dict[str, bool] = field(default_factory=dict) -def _clean_code(raw: object) -> str: - code = str(raw or "").strip() - if not code or len(code) > CODE_MAX_LENGTH or not CODE_PATTERN.match(code): - raise ValidationError( - {"code": "Код канала: 1-64 символа, латиница в нижнем регистре, цифры и дефис"} - ) - return code - - def _clean_name(raw: object) -> str: name = str(raw or "").strip() if not name: @@ -126,43 +109,6 @@ def _product_for_channel( raise ValidationError({"productId": "Unknown product"}) from error -@transaction.atomic -def create_channel( - *, - context: TenantContext, - code: object, - name: object, - group_id: int | None, - product_id: int | None, - policy: ChannelPolicy, - connection_ids: list[int] | None = None, -) -> Channel: - authorization.require_organization_manage(context, operation="Создание канала") - clean_code = _clean_code(code) - clean_name = _clean_name(name) - group = _group_for_channel(context=context, group_id=group_id) - product = _product_for_channel(context=context, product_id=product_id) - # Инварианты проверяются до записи: частичное применение запрещено (§3.2). - require_valid_policy(policy=policy, has_product=product is not None) - - if Channel.objects.filter( - organization_id=context.organization_id, code=clean_code - ).exists(): - raise ChannelCodeConflict(clean_code) - - channel = Channel.objects.create( - organization_id=context.organization_id, - code=clean_code, - name=clean_name, - group=group, - product=product, - **policy.as_model_fields(), - ) - for integration_id in connection_ids or []: - bind_connection(context=context, channel=channel, integration_id=integration_id) - return channel - - @transaction.atomic def update_channel( *, context: TenantContext, channel: Channel, update: ChannelUpdate @@ -228,28 +174,6 @@ def update_channel( return locked -def deletion_blockers(channel: Channel) -> list[dict[str, Any]]: - """§7.2. Каскадное удаление агента запрещено, поэтому SET_NULL и CASCADE - тоже блокируют.""" - counts = ( - ("conversations", channel.conversations.count()), - ("connections", channel.connections.count()), - ("supportContracts", channel.allowed_support_contracts.count()), - ("agent", 1 if hasattr(channel, "ai_agent") else 0), - ("llmInvocations", channel.ai_invocations.count()), - ) - return [{"type": name, "count": count} for name, count in counts if count] - - -@transaction.atomic -def delete_channel(*, context: TenantContext, channel: Channel) -> None: - authorization.require_organization_manage(context, operation="Удаление канала") - blockers = deletion_blockers(channel) - if blockers: - raise ChannelHasReferences(blockers) - channel.delete() - - def _messenger_integration(*, context: TenantContext, integration_id: int) -> Integration: try: integration = Integration.objects.select_related("channel").get( diff --git a/apps/backend/hub_platform/channels/tests.py b/apps/backend/hub_platform/channels/tests.py deleted file mode 100644 index e4538da..0000000 --- a/apps/backend/hub_platform/channels/tests.py +++ /dev/null @@ -1,569 +0,0 @@ -import json - -from django.db import connection -from django.test import TestCase -from django.test.utils import CaptureQueriesContext -from hub_platform.testing import TenantAPIClient as APIClient - -from hub_platform.ai.models import AIAgent, AIAgentStatus -from hub_platform.channels.models import Channel -from hub_platform.identity.bootstrap import bootstrap_edevs_owner -from hub_platform.identity.group_models import EmployeeGroup -from hub_platform.identity.models import ( - EmployeeRole, - HumanUser, - Organization, - OrganizationMembership, -) -from hub_platform.integrations.models import ( - Integration, - IntegrationKind, - IntegrationProvider, -) -from hub_platform.products.models import Product - -SALES_POLICY = { - "requiresAuthenticatedProductIdentity": False, - "allowAnonymousSessions": True, - "allowSelfReportedContact": True, - "allowSalesAttribution": True, - "allowCheckoutActions": True, -} -OPERATOR_POLICY = { - "requiresAuthenticatedProductIdentity": False, - "allowAnonymousSessions": True, - "allowSelfReportedContact": True, - "allowSalesAttribution": False, - "allowCheckoutActions": False, -} - - -def _make_channel(organization, *, code, name, **extra): - extra.setdefault("allow_sales_attribution", False) - extra.setdefault("allow_checkout_actions", False) - return Channel.objects.create(organization=organization, code=code, name=name, **extra) - - -def _messenger(organization, *, name="Bot", channel=None): - return Integration.objects.create( - organization=organization, - kind=IntegrationKind.MESSENGER, - provider=IntegrationProvider.TELEGRAM, - name=name, - channel=channel, - ) - - -class ChannelApiTestCase(TestCase): - """Общий владелец и клиент: owner имеет все capability по роли.""" - - def setUp(self) -> None: - bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password") - self.organization = Organization.objects.get(slug="edevs") - self.operators = self.organization.employee_groups.get(name="Операторы") - self.support_group = self.organization.employee_groups.get(name="Поддержка") - self.product = Product.objects.get(organization=self.organization, code="foxray") - self.client = APIClient() - self.client.login(username="owner@edevs.tech", password="temporary-password") - - def post_channel(self, **overrides): - body = { - "code": "partners", - "name": "Партнёрская линия", - "groupId": None, - "productId": None, - "policyPreset": "CUSTOM", - "policy": OPERATOR_POLICY, - } - body.update(overrides) - return self.client.post( - "/api/v1/channels/", data=json.dumps(body), content_type="application/json" - ) - - def patch_channel(self, channel_id: int, **body): - return self.client.patch( - f"/api/v1/channels/{channel_id}/", - data=json.dumps(body), - content_type="application/json", - ) - - -class ChannelCreateTests(ChannelApiTestCase): - def test_creates_operator_channel_without_agent(self) -> None: - response = self.post_channel() - - self.assertEqual(response.status_code, 201) - channel = response.json()["channel"] - self.assertEqual(channel["code"], "partners") - # Канал без агента — валидное операторское состояние (ADR-HUB-0037 §5). - self.assertIsNone(channel["agent"]) - self.assertEqual(channel["connections"], []) - self.assertTrue(channel["isActive"]) - self.assertTrue(Channel.objects.filter(code="partners").exists()) - self.assertFalse(AIAgent.objects.exists()) - - def test_payload_carries_no_agent_configuration_or_knowledge(self) -> None: - response = self.post_channel() - - channel = response.json()["channel"] - forbidden = { - "persona", "tone", "instructions", "model", "credentialMode", - "allowedTools", "limits", "knowledgeIds", "knowledge", "knowledgeCount", - } - self.assertEqual(forbidden & set(channel), set()) - - def test_sales_preset_fills_policy(self) -> None: - response = self.post_channel( - code="foxray-sales", - name="FoxRay — продажи", - productId=self.product.id, - groupId=self.operators.id, - policyPreset="SALES", - policy=None, - ) - # policy=None остаётся ключом в теле, поэтому пресет и явная политика - # конфликтуют — проверяем именно чистый пресет. - self.assertEqual(response.status_code, 400) - - response = self.client.post( - "/api/v1/channels/", - data=json.dumps( - { - "code": "foxray-sales", - "name": "FoxRay — продажи", - "productId": self.product.id, - "groupId": self.operators.id, - "policyPreset": "SALES", - } - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json()["channel"]["policy"], SALES_POLICY) - - def test_rejects_duplicate_code(self) -> None: - self.post_channel() - response = self.post_channel(name="Другая линия") - - self.assertEqual(response.status_code, 409) - self.assertEqual(Channel.objects.filter(code="partners").count(), 1) - - def test_rejects_invalid_code(self) -> None: - for code in ("Partners", "-partners", "парт", "a" * 65, ""): - with self.subTest(code=code): - response = self.post_channel(code=code) - self.assertEqual(response.status_code, 400) - - def test_policy_invariants_reject_the_whole_request(self) -> None: - # P1/P2: непродуктовый канал не формирует коммерческих действий. - response = self.post_channel(productId=None, policy=SALES_POLICY) - - self.assertEqual(response.status_code, 400) - rules = {item["rule"] for item in response.json()["violations"]} - self.assertEqual(rules, {"P1", "P2"}) - self.assertFalse(Channel.objects.filter(code="partners").exists()) - - def test_authenticated_identity_conflicts_with_anonymous_sessions(self) -> None: - response = self.post_channel( - productId=self.product.id, - policy={**SALES_POLICY, "requiresAuthenticatedProductIdentity": True}, - ) - - self.assertEqual(response.status_code, 400) - rules = {item["rule"] for item in response.json()["violations"]} - self.assertEqual(rules, {"P4", "P5"}) - - def test_support_preset_requires_product(self) -> None: - response = self.client.post( - "/api/v1/channels/", - data=json.dumps( - {"code": "helpline", "name": "Поддержка", "policyPreset": "SUPPORT"} - ), - content_type="application/json", - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual( - {item["rule"] for item in response.json()["violations"]}, {"P3"} - ) - - -class ChannelUpdateTests(ChannelApiTestCase): - def setUp(self) -> None: - super().setUp() - self.channel = _make_channel( - self.organization, code="firepage-sales", name="FirePage — продажи" - ) - - def test_renames_channel_and_keeps_code(self) -> None: - response = self.patch_channel(self.channel.id, name="FirePage — продажи 2") - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["channel"]["name"], "FirePage — продажи 2") - self.channel.refresh_from_db() - self.assertEqual(self.channel.code, "firepage-sales") - - def test_rejects_empty_name(self) -> None: - self.assertEqual(self.patch_channel(self.channel.id, name=" ").status_code, 400) - - def test_rejects_code_change(self) -> None: - response = self.patch_channel(self.channel.id, code="renamed") - - self.assertEqual(response.status_code, 400) - self.channel.refresh_from_db() - self.assertEqual(self.channel.code, "firepage-sales") - - def test_ignores_unchanged_code(self) -> None: - response = self.patch_channel( - self.channel.id, code="firepage-sales", name="Ещё имя" - ) - self.assertEqual(response.status_code, 200) - - def test_changes_product_status_and_policy(self) -> None: - response = self.patch_channel( - self.channel.id, - productId=self.product.id, - isActive=False, - policy={"allowCheckoutActions": True}, - ) - - self.assertEqual(response.status_code, 200) - self.channel.refresh_from_db() - self.assertEqual(self.channel.product_id, self.product.id) - self.assertFalse(self.channel.is_active) - self.assertTrue(self.channel.allow_checkout_actions) - - def test_deactivation_warns_about_active_agent(self) -> None: - AIAgent.objects.create( - channel=self.channel, - name="FirePage Agent", - status=AIAgentStatus.ACTIVE, - model="openai/gpt-4o-mini", - ) - response = self.patch_channel(self.channel.id, isActive=False) - - self.assertEqual(response.status_code, 200) - warnings = response.json()["warnings"] - self.assertEqual(warnings[0]["code"], "agent_still_active") - - def test_rejects_unknown_policy_flag(self) -> None: - response = self.patch_channel(self.channel.id, policy={"allowEverything": True}) - self.assertEqual(response.status_code, 400) - - def test_other_organization_channel_is_not_found(self) -> None: - other = Organization.objects.create(slug="other", name="Other") - other_channel = _make_channel(other, code="other-sales", name="Other — продажи") - - response = self.patch_channel(other_channel.id, name="Взлом") - - self.assertEqual(response.status_code, 404) - other_channel.refresh_from_db() - self.assertEqual(other_channel.name, "Other — продажи") - - -class ChannelDeleteTests(ChannelApiTestCase): - def test_deletes_channel_without_references(self) -> None: - channel = _make_channel(self.organization, code="typo", name="Опечатка") - - response = self.client.delete(f"/api/v1/channels/{channel.id}/") - - self.assertEqual(response.status_code, 204) - self.assertFalse(Channel.objects.filter(id=channel.id).exists()) - - def test_blocks_deletion_with_references(self) -> None: - channel = _make_channel(self.organization, code="live", name="Живой") - _messenger(self.organization, channel=channel) - AIAgent.objects.create( - channel=channel, name="Agent", status=AIAgentStatus.DRAFT, - model="openai/gpt-4o-mini", - ) - - response = self.client.delete(f"/api/v1/channels/{channel.id}/") - - self.assertEqual(response.status_code, 409) - blockers = {item["type"]: item["count"] for item in response.json()["blockers"]} - self.assertEqual(blockers, {"connections": 1, "agent": 1}) - self.assertTrue(Channel.objects.filter(id=channel.id).exists()) - - -class ChannelConnectionTests(ChannelApiTestCase): - def setUp(self) -> None: - super().setUp() - self.channel = _make_channel(self.organization, code="main", name="Основной") - self.integration = _messenger(self.organization, name="Telegram Bot") - - def _bind(self, channel_id: int, integration_id: int, **body): - return self.client.post( - f"/api/v1/channels/{channel_id}/connections/", - data=json.dumps({"integrationId": integration_id, **body}), - content_type="application/json", - ) - - def test_binds_and_unbinds_connection(self) -> None: - response = self._bind(self.channel.id, self.integration.id) - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.json()["channel"]["connections"]), 1) - - response = self.client.delete( - f"/api/v1/channels/{self.channel.id}/connections/{self.integration.id}/" - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["channel"]["connections"], []) - self.integration.refresh_from_db() - self.assertIsNone(self.integration.channel_id) - - def test_rejects_llm_provider_as_connection(self) -> None: - provider = Integration.objects.create( - organization=self.organization, - kind=IntegrationKind.LLM_PROVIDER, - provider=IntegrationProvider.OPENROUTER, - name="OpenRouter", - ) - - self.assertEqual(self._bind(self.channel.id, provider.id).status_code, 400) - - def test_rejects_binding_to_archived_channel(self) -> None: - self.channel.is_active = False - self.channel.save(update_fields=["is_active"]) - - self.assertEqual(self._bind(self.channel.id, self.integration.id).status_code, 400) - - def test_move_between_channels_requires_force(self) -> None: - other = _make_channel(self.organization, code="second", name="Второй") - self._bind(self.channel.id, self.integration.id) - - response = self._bind(other.id, self.integration.id) - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["code"], "connection_already_bound") - self.integration.refresh_from_db() - self.assertEqual(self.integration.channel_id, self.channel.id) - - response = self._bind(other.id, self.integration.id, force=True) - self.assertEqual(response.status_code, 200) - self.integration.refresh_from_db() - self.assertEqual(self.integration.channel_id, other.id) - - -class ChannelListTests(ChannelApiTestCase): - def setUp(self) -> None: - super().setUp() - self.sales_channel = _make_channel( - self.organization, code="foxray-sales", name="FoxRay — продажи", - group=self.operators, product=self.product, - ) - self.orphan = _make_channel(self.organization, code="edevs", name="Edevs — сайт") - self.archived = _make_channel( - self.organization, code="old", name="Старый", is_active=False - ) - - def test_lists_all_channels_for_owner(self) -> None: - response = self.client.get("/api/v1/channels/") - - self.assertEqual(response.status_code, 200) - codes = {item["code"] for item in response.json()["items"]} - self.assertEqual(codes, {"foxray-sales", "edevs", "old"}) - - def test_filters_by_group_product_and_status(self) -> None: - cases = ( - ({"group": "none"}, {"edevs", "old"}), - ({"group": str(self.operators.id)}, {"foxray-sales"}), - ({"product": "none"}, {"edevs", "old"}), - ({"isActive": "false"}, {"old"}), - ({"hasAgent": "false"}, {"foxray-sales", "edevs", "old"}), - ({"q": "прода"}, {"foxray-sales"}), - ) - for params, expected in cases: - with self.subTest(params=params): - response = self.client.get("/api/v1/channels/", params) - codes = {item["code"] for item in response.json()["items"]} - self.assertEqual(codes, expected) - - def test_counters_do_not_issue_a_query_per_channel(self) -> None: - # §6.1: счётчик открытых диалогов — один агрегат на список. Проверяем - # само свойство, а не абсолютное число: рост каналов не должен менять - # количество запросов. - def count_queries() -> int: - with CaptureQueriesContext(connection) as captured: - response = self.client.get("/api/v1/channels/") - self.assertEqual(response.status_code, 200) - return len(captured) - - baseline = count_queries() - for index in range(5): - _make_channel( - self.organization, code=f"extra-{index}", name=f"Канал {index}" - ) - - self.assertEqual(len(self.client.get("/api/v1/channels/").json()["items"]), 8) - self.assertEqual(count_queries(), baseline) - - -class ChannelGroupTests(ChannelApiTestCase): - """Канал закрепляется за настраиваемой группой (ADR-HUB-0043 §3).""" - - def setUp(self) -> None: - super().setUp() - self.channel = _make_channel( - self.organization, code="foxray-sales", name="FoxRay — продажи", - group=self.operators, product=self.product, - ) - - def test_payload_exposes_group_reference(self) -> None: - response = self.client.get(f"/api/v1/channels/{self.channel.id}/") - - channel = response.json()["channel"] - self.assertEqual(channel["groupId"], self.operators.id) - self.assertEqual(channel["groupName"], "Операторы") - - def test_manager_moves_channel_between_groups_and_detaches(self) -> None: - moved = self.patch_channel(self.channel.id, groupId=self.support_group.id) - self.assertEqual(moved.status_code, 200) - self.channel.refresh_from_db() - self.assertEqual(self.channel.group_id, self.support_group.id) - - detached = self.patch_channel(self.channel.id, groupId=None) - self.assertEqual(detached.status_code, 200) - self.channel.refresh_from_db() - self.assertIsNone(self.channel.group_id) - - def test_foreign_organization_group_is_rejected(self) -> None: - other = Organization.objects.create(slug="other-group-org", name="Other") - foreign = EmployeeGroup.objects.create(organization=other, name="Чужая") - response = self.patch_channel(self.channel.id, groupId=foreign.id) - - self.assertEqual(response.status_code, 400) - self.channel.refresh_from_db() - self.assertEqual(self.channel.group_id, self.operators.id) - - -class ChannelPermissionTests(ChannelApiTestCase): - def setUp(self) -> None: - super().setUp() - self.channel = _make_channel( - self.organization, code="firepage-sales", name="FirePage — продажи" - ) - operator = HumanUser.objects.create_user( - email="operator@edevs.tech", password="operator-password" - ) - OrganizationMembership.objects.create( - user=operator, - organization=self.organization, - role=EmployeeRole.EMPLOYEE, - position_title="Оператор", - ) - self.operator_client = APIClient() - self.operator_client.login( - username="operator@edevs.tech", password="operator-password" - ) - - def test_operator_without_capabilities_is_denied(self) -> None: - self.assertEqual(self.operator_client.get("/api/v1/channels/").status_code, 403) - response = self.operator_client.patch( - f"/api/v1/channels/{self.channel.id}/", - data=json.dumps({"name": "Взлом"}), - content_type="application/json", - ) - self.assertEqual(response.status_code, 403) - - -class ChannelCountersTests(ChannelApiTestCase): - def test_returns_connection_breakdown_for_period(self) -> None: - channel = _make_channel(self.organization, code="main", name="Основной") - _messenger(self.organization, channel=channel) - - response = self.client.get(f"/api/v1/channels/{channel.id}/counters/?period=7d") - - self.assertEqual(response.status_code, 200) - body = response.json() - self.assertEqual(body["period"], "7d") - self.assertEqual(body["conversations"], {"open": 0, "total": 0}) - self.assertEqual(body["connections"], {"total": 1, "ok": 0, "error": 0}) - - def test_rejects_unknown_period(self) -> None: - channel = _make_channel(self.organization, code="main", name="Основной") - response = self.client.get(f"/api/v1/channels/{channel.id}/counters/?period=1y") - self.assertEqual(response.status_code, 400) - - -class ChannelPolicyInvariantTests(ChannelApiTestCase): - """§3.2 — P1-P5 на изменении (этап 3).""" - - def setUp(self) -> None: - super().setUp() - self.orphan = _make_channel( - self.organization, code="partners", name="Партнёрская линия" - ) - self.product_channel = _make_channel( - self.organization, - code="foxray-sales", - name="FoxRay — продажи", - product=self.product, - allow_sales_attribution=True, - allow_checkout_actions=True, - ) - - def test_rejects_commercial_flag_on_non_product_channel(self) -> None: - response = self.patch_channel( - self.orphan.id, policy={"allowCheckoutActions": True} - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual( - {item["rule"] for item in response.json()["violations"]}, {"P1"} - ) - self.orphan.refresh_from_db() - self.assertFalse(self.orphan.allow_checkout_actions) - - def test_rename_of_compliant_channel_is_not_blocked_by_policy(self) -> None: - # Ровно тот сценарий, ради которого этап 3 шёл после приведения данных. - response = self.patch_channel(self.orphan.id, name="Партнёры") - self.assertEqual(response.status_code, 200) - - def test_detaching_product_names_the_flags_to_turn_off(self) -> None: - response = self.patch_channel(self.product_channel.id, productId=None) - - self.assertEqual(response.status_code, 400) - fields = {item["field"] for item in response.json()["violations"]} - self.assertEqual(fields, {"allowCheckoutActions", "allowSalesAttribution"}) - self.product_channel.refresh_from_db() - self.assertEqual(self.product_channel.product_id, self.product.id) - - def test_detaching_product_with_flags_off_succeeds_in_one_request(self) -> None: - # Инварианты считаются по итоговому состоянию, а не по переданным полям. - response = self.patch_channel( - self.product_channel.id, - productId=None, - policy={"allowCheckoutActions": False, "allowSalesAttribution": False}, - ) - - self.assertEqual(response.status_code, 200) - self.product_channel.refresh_from_db() - self.assertIsNone(self.product_channel.product_id) - self.assertFalse(self.product_channel.allow_checkout_actions) - - def test_partial_application_is_impossible(self) -> None: - response = self.patch_channel( - self.orphan.id, - name="Новое имя", - policy={"allowSalesAttribution": True}, - ) - - self.assertEqual(response.status_code, 400) - self.orphan.refresh_from_db() - # Имя не сохранилось вместе с отклонённым флагом. - self.assertEqual(self.orphan.name, "Партнёрская линия") - self.assertFalse(self.orphan.allow_sales_attribution) - - def test_authenticated_identity_requires_disabling_anonymous_flags(self) -> None: - response = self.patch_channel( - self.product_channel.id, - policy={"requiresAuthenticatedProductIdentity": True}, - ) - - self.assertEqual(response.status_code, 400) - self.assertEqual( - {item["rule"] for item in response.json()["violations"]}, {"P4", "P5"} - ) diff --git a/apps/backend/hub_platform/channels/urls.py b/apps/backend/hub_platform/channels/urls.py deleted file mode 100644 index 208d36f..0000000 --- a/apps/backend/hub_platform/channels/urls.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.urls import path - -from hub_platform.channels import views - -urlpatterns = [ - path("", views.ChannelListView.as_view(), name="channel-list"), - path("/", views.ChannelDetailView.as_view(), name="channel-detail"), - path( - "/connections/", - views.ChannelConnectionsView.as_view(), - name="channel-connections", - ), - path( - "/connections//", - views.ChannelConnectionDetailView.as_view(), - name="channel-connection-detail", - ), - path( - "/counters/", - views.ChannelCountersView.as_view(), - name="channel-counters", - ), - path("/test-chat/", views.ChannelTestChatView.as_view(), name="channel-test-chat"), -] diff --git a/apps/backend/hub_platform/channels/views.py b/apps/backend/hub_platform/channels/views.py deleted file mode 100644 index 2814803..0000000 --- a/apps/backend/hub_platform/channels/views.py +++ /dev/null @@ -1,313 +0,0 @@ -from datetime import timedelta - -from django.core.exceptions import ValidationError -from django.db.models import Count, Q -from django.utils import timezone -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from hub_platform.ai.models import AIAgentStatus -from hub_platform.ai.provider.base import ProviderError -from hub_platform.api.permissions import HasCapability -from hub_platform.channels import api_inputs, services -from hub_platform.channels.models import Channel -from hub_platform.channels.policy import PolicyInvariantError -from hub_platform.channels.runtime import run_channel_turn -from hub_platform.channels.selectors import ( - channel_for_context, - channels_for_context, - filter_channels, -) -from hub_platform.channels.serializers import channel_payload -from hub_platform.conversations.models import Conversation, LifecycleState -from hub_platform.identity.audit import record_audit_event -from hub_platform.integrations.models import IntegrationStatus - -CHANNEL_NOT_FOUND = {"detail": "Канал не найден"} -PERIODS = {"today": None, "7d": 7, "30d": 30} - - -def _validation_detail(error: ValidationError) -> str: - if hasattr(error, "message_dict"): - return "; ".join( - message for messages in error.message_dict.values() for message in messages - ) - return "; ".join(error.messages) - - -def _audit(request: Request, action: str, channel: Channel, **payload) -> None: - record_audit_event( - action=action, - actor=request.user, - organization=request.tenant_context.organization, - object_type="Channel", - object_id=str(channel.id), - payload=payload or {}, - request=request, - ) - - -def _load(request: Request, channel_id: int) -> Channel: - return channel_for_context(context=request.tenant_context, channel_id=channel_id) - - -class ChannelListView(APIView): - permission_classes = [HasCapability] - required_capabilities = {"GET": "channels.view", "POST": "channels.manage"} - - def get(self, request: Request) -> Response: - items = filter_channels( - channels_for_context(request.tenant_context), request.query_params - ) - return Response({"items": [channel_payload(channel) for channel in items]}) - - def post(self, request: Request) -> Response: - data = request.data if isinstance(request.data, dict) else {} - try: - policy = api_inputs.parse_create_policy(data) - connection_ids = api_inputs.parse_connection_ids(data.get("connectionIds")) - channel = services.create_channel( - context=request.tenant_context, - code=data.get("code"), - name=data.get("name"), - group_id=data.get("groupId"), - product_id=data.get("productId"), - policy=policy, - connection_ids=connection_ids, - ) - except services.ChannelCodeConflict as error: - return Response({"detail": str(error)}, status=409) - except services.ConnectionAlreadyBound as error: - return Response(error.payload(), status=409) - except PolicyInvariantError as error: - return Response(error.payload(), status=400) - except ValidationError as error: - return Response({"detail": _validation_detail(error)}, status=400) - _audit(request, "channels.channel_created", channel, code=channel.code) - return Response({"channel": channel_payload(_load(request, channel.id))}, status=201) - - -class ChannelDetailView(APIView): - permission_classes = [HasCapability] - required_capabilities = { - "GET": "channels.view", - "PATCH": "channels.manage", - "DELETE": "channels.manage", - } - - def get(self, request: Request, channel_id: int) -> Response: - try: - channel = _load(request, channel_id) - except Channel.DoesNotExist: - return Response(CHANNEL_NOT_FOUND, status=404) - return Response({"channel": channel_payload(channel)}) - - def patch(self, request: Request, channel_id: int) -> Response: - try: - channel = _load(request, channel_id) - except Channel.DoesNotExist: - return Response(CHANNEL_NOT_FOUND, status=404) - data = request.data if isinstance(request.data, dict) else {} - before = channel_payload(channel) - try: - update = api_inputs.parse_update(data, current_code=channel.code) - channel = services.update_channel( - context=request.tenant_context, channel=channel, update=update - ) - except services.ChannelHasReferences as error: - return Response(error.payload(), status=409) - except PolicyInvariantError as error: - return Response(error.payload(), status=400) - except ValidationError as error: - return Response({"detail": _validation_detail(error)}, status=400) - - channel = _load(request, channel.id) - after = channel_payload(channel) - diff = { - key: {"from": before[key], "to": after[key]} - for key in ("name", "groupId", "product", "isActive", "policy") - if before[key] != after[key] - } - if diff: - _audit(request, "channels.channel_updated", channel, diff=diff) - if "isActive" in diff: - _audit( - request, - "channels.channel_activation_changed", - channel, - isActive=after["isActive"], - ) - - body: dict[str, object] = {"channel": after} - agent = after["agent"] - # §7.4: деактивация не останавливает агента — он продолжает занимать слот. - if diff.get("isActive", {}).get("to") is False and agent: - if agent["status"] == AIAgentStatus.ACTIVE: - body["warnings"] = [ - {"code": "agent_still_active", "agentId": agent["id"]} - ] - return Response(body) - - def delete(self, request: Request, channel_id: int) -> Response: - try: - channel = _load(request, channel_id) - except Channel.DoesNotExist: - return Response(CHANNEL_NOT_FOUND, status=404) - code = channel.code - try: - services.delete_channel(context=request.tenant_context, channel=channel) - except services.ChannelHasReferences as error: - return Response(error.payload(), status=409) - record_audit_event( - action="channels.channel_deleted", - actor=request.user, - organization=request.tenant_context.organization, - object_type="Channel", - object_id=str(channel_id), - payload={"code": code}, - request=request, - ) - return Response(status=204) - - -class ChannelConnectionsView(APIView): - permission_classes = [HasCapability] - required_capability = "integrations.manage" - require_organization_scope = True - - def post(self, request: Request, channel_id: int) -> Response: - try: - channel = _load(request, channel_id) - except Channel.DoesNotExist: - return Response(CHANNEL_NOT_FOUND, status=404) - data = request.data if isinstance(request.data, dict) else {} - integration_id = data.get("integrationId") - if isinstance(integration_id, bool) or not isinstance(integration_id, int): - return Response({"detail": "integrationId must be an integer"}, status=400) - try: - integration, previous_channel_id = services.bind_connection( - context=request.tenant_context, - channel=channel, - integration_id=integration_id, - force=bool(data.get("force")), - ) - except services.ConnectionAlreadyBound as error: - return Response(error.payload(), status=409) - except ValidationError as error: - return Response({"detail": _validation_detail(error)}, status=400) - if previous_channel_id is not None: - _audit( - request, - "channels.connection_moved", - channel, - integrationId=integration.id, - fromChannelId=previous_channel_id, - ) - else: - _audit( - request, - "channels.connection_bound", - channel, - integrationId=integration.id, - ) - return Response({"channel": channel_payload(_load(request, channel.id))}) - - -class ChannelConnectionDetailView(APIView): - permission_classes = [HasCapability] - required_capability = "integrations.manage" - require_organization_scope = True - - def delete(self, request: Request, channel_id: int, integration_id: int) -> Response: - try: - channel = _load(request, channel_id) - except Channel.DoesNotExist: - return Response(CHANNEL_NOT_FOUND, status=404) - try: - services.unbind_connection( - context=request.tenant_context, - channel=channel, - integration_id=integration_id, - ) - except ValidationError as error: - return Response({"detail": _validation_detail(error)}, status=400) - _audit( - request, - "channels.connection_unbound", - channel, - integrationId=integration_id, - ) - return Response({"channel": channel_payload(_load(request, channel.id))}) - - -class ChannelCountersView(APIView): - permission_classes = [HasCapability] - required_capability = "channels.view" - - def get(self, request: Request, channel_id: int) -> Response: - try: - channel = _load(request, channel_id) - except Channel.DoesNotExist: - return Response(CHANNEL_NOT_FOUND, status=404) - period = request.query_params.get("period", "7d") - if period not in PERIODS: - return Response({"detail": "period must be today, 7d or 30d"}, status=400) - now = timezone.now() - since = ( - now.replace(hour=0, minute=0, second=0, microsecond=0) - if period == "today" - else now - timedelta(days=PERIODS[period]) - ) - conversations = Conversation.objects.filter(channel=channel) - connections = channel.connections.aggregate( - total=Count("id"), - ok=Count("id", filter=Q(status=IntegrationStatus.OK)), - error=Count("id", filter=Q(status=IntegrationStatus.ERROR)), - ) - return Response( - { - "period": period, - "conversations": { - # Открытые считаются на сейчас, total — созданные за период. - "open": conversations.filter(lifecycle=LifecycleState.OPEN).count(), - "total": conversations.filter(created_at__gte=since).count(), - }, - "connections": connections, - } - ) - - -class ChannelTestChatView(APIView): - permission_classes = [HasCapability] - # Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9). - required_capability = "ai.manage" - require_organization_scope = True - - def post(self, request: Request, channel_id: int) -> Response: - try: - channel = channel_for_context( - context=request.tenant_context, - channel_id=channel_id, - capability="ai.view", - ) - except Channel.DoesNotExist: - return Response(CHANNEL_NOT_FOUND, status=404) - message = str(request.data.get("message", "")).strip() - if not message: - return Response({"detail": "Пустое сообщение"}, status=400) - history = request.data.get("history") or [] - if not isinstance(history, list): - return Response({"detail": "history must be a list"}, status=400) - try: - result = run_channel_turn(channel=channel, message=message, history=history) - except ProviderError as error: - return Response({"detail": f"Ошибка провайдера: {error}"}, status=502) - return Response( - { - "reply": result.text, - "model": result.model, - "promptTokens": result.prompt_tokens, - "completionTokens": result.completion_tokens, - } - ) diff --git a/apps/internal-ui/src/features/ai/agentOptions.ts b/apps/internal-ui/src/features/ai/agentOptions.ts index d2f5fa1..e4b81f1 100644 --- a/apps/internal-ui/src/features/ai/agentOptions.ts +++ b/apps/internal-ui/src/features/ai/agentOptions.ts @@ -1,13 +1,13 @@ import type { AgentLinkOption } from "../../shared/content-library/AgentLinkDialog"; -import type { AiAgent } from "./model"; +import type { AgentCard } from "../agents/model"; /** Агенты для массового прикрепления материалов (библиотека знаний общая, - * ADR-HUB-0041 §8 — фильтров по отделам больше нет). */ -export function agentLinkOptions(agents: AiAgent[]): AgentLinkOption[] { + * ADR-HUB-0041 §8). Bulk-эндпоинты знаний адресуют AIAgent, поэтому id — + * aiAgentId карточки. */ +export function agentLinkOptions(agents: AgentCard[]): AgentLinkOption[] { return agents.map((agent) => ({ - id: agent.id, + id: agent.aiAgentId, name: agent.name, - channelName: agent.channel.name, - groupName: agent.channel.group?.name ?? null, + groupName: agent.groupName, })); } diff --git a/apps/internal-ui/src/features/ai/model.ts b/apps/internal-ui/src/features/ai/model.ts index 5ba3f04..edd14db 100644 --- a/apps/internal-ui/src/features/ai/model.ts +++ b/apps/internal-ui/src/features/ai/model.ts @@ -1,11 +1,3 @@ -export type ChannelRef = { - id: number; - code: string; - name: string; - group: { id: number; name: string } | null; - product: { code: string; name: string } | null; -}; - export type AgentKnowledgeRef = { id: number; title: string; isEnabled: boolean }; export type AgentPortalArticleRef = { @@ -15,21 +7,3 @@ export type AgentPortalArticleRef = { portal: { id: number; name: string }; publicUrl: string; }; - -export type AiAgent = { - id: number; - channel: ChannelRef; - name: string; - isActive: boolean; - model: string; - // BYOK — единственный режим (ADR-HUB-0042); провайдер принадлежит агенту, а не каналу. - providerIntegrationId: number | null; - modelParams: Record; - allowedTools: unknown[]; - limits: Record; - persona: string; - tone: string; - instructions: string; - knowledge: AgentKnowledgeRef[]; - portalArticles: AgentPortalArticleRef[]; -}; diff --git a/apps/internal-ui/src/features/ai/useAiAgents.ts b/apps/internal-ui/src/features/ai/useAiAgents.ts index da9a24f..c55711e 100644 --- a/apps/internal-ui/src/features/ai/useAiAgents.ts +++ b/apps/internal-ui/src/features/ai/useAiAgents.ts @@ -1,10 +1,9 @@ import { useCallback, useEffect, useState } from "react"; -import { api } from "../../api/client"; -import type { AiAgent } from "./model"; +import { fetchAgents, type AgentCard } from "../agents/model"; export function useAiAgents() { - const [agents, setAgents] = useState([]); + const [agents, setAgents] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -12,7 +11,7 @@ export function useAiAgents() { setLoading(true); setError(false); try { - const agentsResponse = await api<{ items: AiAgent[] }>("/api/v1/ai/agents/"); + const agentsResponse = await fetchAgents(); setAgents(agentsResponse.items); } catch { setError(true); diff --git a/apps/internal-ui/src/shared/content-library/AgentLinkDialog.tsx b/apps/internal-ui/src/shared/content-library/AgentLinkDialog.tsx index 5279584..49f0c61 100644 --- a/apps/internal-ui/src/shared/content-library/AgentLinkDialog.tsx +++ b/apps/internal-ui/src/shared/content-library/AgentLinkDialog.tsx @@ -9,7 +9,6 @@ export type AgentLinkAction = "attach" | "detach"; export type AgentLinkOption = { id: number; name: string; - channelName: string; groupName: string | null; }; @@ -73,7 +72,7 @@ export function AgentLinkDialog({ {agents.map((agent) => ( ))}