diff --git a/apps/backend/chatballs/ai/agent_card_urls.py b/apps/backend/chatballs/ai/agent_card_urls.py index d38ebaf..2810810 100644 --- a/apps/backend/chatballs/ai/agent_card_urls.py +++ b/apps/backend/chatballs/ai/agent_card_urls.py @@ -3,6 +3,7 @@ from django.urls import path from chatballs.ai import agent_card_views as views +from chatballs.ai.test_chat_views import AgentCardTestChatView urlpatterns = [ path("", views.AgentCardListView.as_view(), name="agent-card-list"), @@ -31,7 +32,7 @@ urlpatterns = [ ), path( "/test-chat/", - views.AgentCardTestChatView.as_view(), + AgentCardTestChatView.as_view(), name="agent-card-test-chat", ), ] diff --git a/apps/backend/chatballs/ai/agent_card_views.py b/apps/backend/chatballs/ai/agent_card_views.py index 448296a..5c4e9ee 100644 --- a/apps/backend/chatballs/ai/agent_card_views.py +++ b/apps/backend/chatballs/ai/agent_card_views.py @@ -17,13 +17,10 @@ from chatballs.ai.agent_card import ( set_agent_card_active, update_agent_card, ) -from chatballs.ai.provider.base import ProviderError from chatballs.api.pagination import page_payload, paginate from chatballs.api.permissions import HasCapability from chatballs.channels import services as channel_services from chatballs.channels.models import Channel -from chatballs.channels.runtime import run_channel_turn -from chatballs.channels.selectors import channel_for_context from chatballs.i18n import t from chatballs.identity.audit import record_audit_event @@ -244,44 +241,6 @@ class AgentCardDeactivateView(_AgentCardStatusView): target_active = False -class AgentCardTestChatView(APIView): - permission_classes = [HasCapability] - # Исполняет агента, а не изменяет канал: остаётся на ai.manage. - required_capability = "ai.manage" - - 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": t("ai.empty_message")}, status=400) - history = request.data.get("history") or [] - if not isinstance(history, list): - return Response({"detail": t("ai.history_must_be_list")}, status=400) - # Проверочный чат видит то же окно истории, что и живой диалог. - agent = getattr(channel, "ai_agent", None) - if agent is not None: - history = history[-agent.history_limit:] - try: - result = run_channel_turn(channel=channel, message=message, history=history) - except ProviderError as error: - return Response({"detail": t("ai.provider_error", error=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/chatballs/ai/site_context.py b/apps/backend/chatballs/ai/site_context.py index 352a8b8..20134e6 100644 --- a/apps/backend/chatballs/ai/site_context.py +++ b/apps/backend/chatballs/ai/site_context.py @@ -21,6 +21,7 @@ CUSTOMER_DATA_HEADER = ( MASKED = "masked" OPEN = "open" +CLIENT_CONTEXT_HEADER = CUSTOMER_DATA_HEADER def _display_value(field: dict, value: object) -> str: @@ -40,6 +41,19 @@ def _single_line(text: str) -> str: return json.dumps(text, ensure_ascii=False)[1:-1] +def client_context_prompt(fields: list[tuple[str, str]], pseudonymizer: Pseudonymizer) -> str: + """Маскировать до экранирования: кавычки и переносы не меняют известное значение. + + Уже обработанный картой хода блок добавляется к подготовленному ChatJob. + Повторное маскирование экранировало бы токены, выданные самой системой. + """ + lines = [ + f"{_single_line(pseudonymizer.mask(label))}: {_single_line(pseudonymizer.mask(value))}" + for label, value in fields + ] + return CLIENT_CONTEXT_HEADER + "\n" + "\n".join(lines) if lines else "" + + def _site_fields(conversation: Conversation | None) -> list[tuple[dict, str]]: """Свои поля, доступные модели, с непустыми значениями — в порядке схемы.""" diff --git a/apps/backend/chatballs/ai/test_chat_data.py b/apps/backend/chatballs/ai/test_chat_data.py new file mode 100644 index 0000000..aa96f93 --- /dev/null +++ b/apps/backend/chatballs/ai/test_chat_data.py @@ -0,0 +1,100 @@ +"""Тестовые данные проверочного чата: проверка схемы и контекст только в памяти.""" + +from dataclasses import dataclass, field + +from rest_framework.exceptions import ValidationError + +from chatballs.ai.pseudonymization import ( + KnownValue, + Pseudonymizer, + contact_known_values, +) +from chatballs.ai.site_context import _display_value +from chatballs.channels.models import Channel +from chatballs.conversations.models import Contact +from chatballs.i18n import t +from chatballs.integrations.http_tool import ClientData +from chatballs.integrations.models import Integration, IntegrationProvider +from chatballs.webchat.field_values import BUILTIN_TYPES, validate_field_value + + +@dataclass(frozen=True, repr=False) +class PreviewClientData: + client: ClientData = field(repr=False) + pseudonymizer: Pseudonymizer = field(repr=False) + context_fields: list[tuple[str, str]] = field(repr=False) + + +def _invalid() -> ValidationError: + # Не возвращаем значения, присланные клиентом, даже в ошибке. + return ValidationError({"clientData": t("ai.test_client_data_invalid")}) + + +def parse_test_client_data(channel: Channel, raw: object) -> PreviewClientData: + """webFields: ID подключения → ключ поля → значение; отсутствующее — пусто. + + Разрешены лишь текущие поля веб-подключений этого агента и организации. + Обязательность формы перед чатом здесь не применяется: неполный набор + позволяет проверить поведение агента без привязанных значений. + """ + if raw is None: + raw = {} + if not isinstance(raw, dict) or raw.keys() - {*BUILTIN_TYPES, "webFields"}: + raise _invalid() + builtins = {} + for key, kind in BUILTIN_TYPES.items(): + value = raw.get(key) + try: + value = "" if value is None or value == "" else validate_field_value(value, {"type": kind}) + except ValueError as error: + raise _invalid() from error + if len(value) > Contact._meta.get_field(key).max_length: + raise _invalid() + builtins[key] = value + submitted = raw.get("webFields", {}) + if not isinstance(submitted, dict): + raise _invalid() + connections = { + str(item.id): item + for item in Integration.objects.filter( + organization_id=channel.organization_id, + channel_id=channel.id, + provider=IntegrationProvider.WEB, + ).order_by("id") + } + if submitted.keys() - connections.keys(): + raise _invalid() + known = contact_known_values(**builtins) + lines = [(label, value) for key, label in ( + ("name", "Имя"), ("email", "E-mail"), ("phone", "Телефон") + ) if (value := builtins[key])] + web_fields: dict[int, dict[str, object]] = {} + for connection_id, connection in connections.items(): + values = submitted.get(connection_id, {}) + definitions = sorted(connection.config.get("fields", []), key=lambda item: item.get("order", 0)) + if not isinstance(values, dict) or values.keys() - {item["key"] for item in definitions}: + raise _invalid() + validated = {} + for definition in definitions: + key = definition["key"] + if key not in values: + continue + try: + value = validate_field_value(values[key], definition) + except ValueError as error: + raise _invalid() from error + if value is None: + continue + validated[key] = value + display = _display_value(definition, value) + if display.strip(): + known.append(KnownValue(key, str(value), is_phone=definition["type"] == "phone")) + known.append(KnownValue(key, display, is_phone=definition["type"] == "phone")) + if definition.get("ai_visible") is True: + lines.append((definition["label"], display)) + web_fields[connection.id] = validated + return PreviewClientData( + client=ClientData(**builtins, web_fields=web_fields), + pseudonymizer=Pseudonymizer(known), + context_fields=lines, + ) diff --git a/apps/backend/chatballs/ai/test_chat_views.py b/apps/backend/chatballs/ai/test_chat_views.py new file mode 100644 index 0000000..e8018ec --- /dev/null +++ b/apps/backend/chatballs/ai/test_chat_views.py @@ -0,0 +1,91 @@ +"""Проверочный чат использует шаги живого хода; клиентские данные не записывает.""" + +import time + +from django.conf import settings +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from chatballs.ai.provider.base import ProviderError +from chatballs.ai.test_chat_data import parse_test_client_data +from chatballs.ai.turn import ( + plan_chat, + plan_query_embedding, + record_turn, + run_query_embedding, + run_turn_chat, +) +from chatballs.api.permissions import HasCapability +from chatballs.channels.models import Channel +from chatballs.channels.selectors import channel_for_context +from chatballs.conversations.tool_call_events import tool_call_payload +from chatballs.i18n import t +from chatballs.tenancy.database import tenant_atomic + + +class AgentCardTestChatView(APIView): + permission_classes = [HasCapability] + required_capability = "ai.manage" + tenant_manages_own_transaction = True + + def post(self, request: Request, agent_id: int) -> Response: + started = time.monotonic() + calls = [] + body = request.data if isinstance(request.data, dict) else {} + message = body.get("message", "") + if not isinstance(message, str) or not message.strip(): + return Response({"detail": t("ai.empty_message")}, status=400) + history = body.get("history", []) + if not isinstance(history, list) or any( + not isinstance(item, dict) + or item.get("role") not in {"user", "assistant"} + or not isinstance(item.get("content"), str) + for item in history + ): + return Response({"detail": t("ai.history_must_be_list")}, status=400) + try: + with tenant_atomic(request.tenant_context): + try: + channel = channel_for_context( + context=request.tenant_context, channel_id=agent_id, capability="ai.view", + ) + except Channel.DoesNotExist: + return Response({"detail": t("ai.agent_not_found")}, status=404) + agent = getattr(channel, "ai_agent", None) + if agent is None or not agent.is_active: + raise ProviderError(t("channels.no_active_agent")) + data = parse_test_client_data(channel, body.get("clientData")) + history = history[-agent.history_limit:] if agent.history_limit else [] + embedding_job = plan_query_embedding( + agent=agent, query=message.strip(), pseudonymizer=data.pseudonymizer, + ) + embedding = run_query_embedding(embedding_job) + with tenant_atomic(request.tenant_context): + plan = plan_chat( + agent=agent, message=message.strip(), history=history, embedding=embedding, + pseudonymizer=data.pseudonymizer, client=data.client, client_context=data.context_fields, + ) + answer = run_turn_chat( + plan, time_left=settings.CHATBALLS_AI_TURN_TIMEOUT - (time.monotonic() - started), + ) + with tenant_atomic(request.tenant_context): + reply = record_turn(agent=agent, plan=plan, answer=answer) + calls = [ + {"name": call.name, **tool_call_payload({ + "tool": call.title, "error": call.error, "durationMs": call.duration_ms, + })} + for call in answer.tool_calls + ] + if answer.error is not None: + raise answer.error + except ProviderError as error: + return Response({"detail": t("ai.provider_error", error=error), "toolCalls": calls}, status=502) + result = answer.result + return Response({ + "reply": reply, + "model": result.model, + "promptTokens": sum(item.result.prompt_tokens for item in answer.rounds if item.result) if answer.rounds else result.prompt_tokens, + "completionTokens": sum(item.result.completion_tokens for item in answer.rounds if item.result) if answer.rounds else result.completion_tokens, + "toolCalls": calls, + }) diff --git a/apps/backend/chatballs/ai/test_test_chat.py b/apps/backend/chatballs/ai/test_test_chat.py new file mode 100644 index 0000000..b8f77df --- /dev/null +++ b/apps/backend/chatballs/ai/test_test_chat.py @@ -0,0 +1,274 @@ +"""Проверочный HTTP-чат: данные в памяти, привязки, маски и след вызова.""" + +import json +from unittest.mock import patch + +from django.db import connection +from django.test import TransactionTestCase + +from chatballs.ai.models import AgentTool, AIAgent, AIAgentStatus, LlmInvocation +from chatballs.ai.provider.demo import DemoProvider +from chatballs.ai.tool_loop_testing import ScriptedProvider, calls, says +from chatballs.channels.models import Channel +from chatballs.conversations.models import ( + Contact, + ContactFieldValue, + Conversation, + Message, +) +from chatballs.events.models import OutboxEvent +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.integrations.models import ( + Integration, + IntegrationKind, + IntegrationProvider, +) +from chatballs.integrations.tool_client import ToolResponse +from chatballs.testing import TenantAPIClient + + +class TestChatTests(TransactionTestCase): + def setUp(self): + # Только изолированная тестовая БД; штатный контур не получает seed. + self.organization = Organization.objects.create(name="Test chat", slug="test-chat") + self.user = HumanUser.objects.create(email="test-chat-admin@example.test") + OrganizationMembership.objects.create( + organization=self.organization, user=self.user, role=EmployeeRole.ADMIN, + ) + self.client = TenantAPIClient() + self.client.force_authenticate(self.user) + self.client.set_tenant(self.organization) + self.channel = Channel.objects.create( + organization=self.organization, code="preview", name="Preview", + ) + self.agent = AIAgent.objects.create( + organization=self.organization, channel=self.channel, name="Preview", + status=AIAgentStatus.ACTIVE, history_limit=1, + ) + self.web = self._web(self.channel) + self.http = Integration.objects.create( + organization=self.organization, name="Статус заказа", + kind=IntegrationKind.EXTERNAL_SERVER, provider=IntegrationProvider.HTTP, + config={ + "tool_name": "order_status", "description": "Статус заказа", + "method": "GET", "url": "https://shop.example.test/orders/{order_number}", + "is_enabled": True, + "parameters": [{ + "name": "order_number", "type": "string", "required": True, + "location": "path", "source": { + "type": "web_field", "integration_id": self.web.id, "key": "order_number", + }, + }], + }, + ) + AgentTool.objects.create( + organization=self.organization, agent=self.agent, + integration=self.http, tool_name="", + ) + self.url = f"/api/v1/agents/{self.channel.id}/test-chat/" + self.data = { + "name": "Анна", "email": "anna@example.test", "phone": "+79990001122", + "webFields": {str(self.web.id): {"order_number": "10482"}}, + } + + def _web(self, channel): + return Integration.objects.create( + organization=channel.organization, channel=channel, name=f"Web {channel.id}", + kind=IntegrationKind.MESSENGER, provider=IntegrationProvider.WEB, + config={"fields": [ + {"key": "order_number", "label": "Номер заказа", "type": "string", "ai_visible": True}, + {"key": "amount", "label": "Сумма", "type": "number"}, + {"key": "active", "label": "Активен", "type": "boolean"}, + {"key": "status", "label": "Статус", "type": "enum", "options": [{"value": "sent", "label": "В пути"}]}, + {"key": "date", "label": "Дата", "type": "datetime"}, + {"key": "other_email", "label": "Почта", "type": "email"}, + {"key": "other_phone", "label": "Телефон", "type": "phone"}, + {"key": "url", "label": "Ссылка", "type": "url"}, + ]}, + ) + + def _post(self, data=None, **overrides): + body = {"message": "Статус заказа?", "clientData": self.data if data is None else data} + return self.client.post(self.url, {**body, **overrides}, format="json") + + def test_order_number_is_bound_in_http_request_and_values_are_not_saved(self): + provider = DemoProvider() + requests = [] + + def fetch(url, **kwargs): + # Ход действительно вышел из tenant_atomic перед сетью. + self.assertFalse(connection.in_atomic_block) + requests.append((url, kwargs)) + return ToolResponse( + status=200, content_type="application/json", url=url, + body=json.dumps({"status": "В пути", "name": "Анна", "order": "10482"}, ensure_ascii=False).encode(), + ) + + with ( + patch("chatballs.ai.invocation.get_provider", return_value=provider), + patch.object(provider, "chat", wraps=provider.chat) as chat, + patch.object(provider, "embed", wraps=provider.embed) as embed, + patch("chatballs.integrations.http_tool.fetch", side_effect=fetch), + ): + with connection.cursor() as cursor: + cursor.execute("SET ROLE chatballs_runtime_app") + try: + response = self._post(message="Анна: статус заказа 10482?") + finally: + with connection.cursor() as cursor: + cursor.execute("RESET ROLE") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(requests[0][0], "https://shop.example.test/orders/10482") + self.assertEqual(len(requests), 1) + self.assertEqual(chat.call_count, 2) + sent = repr(chat.call_args_list) + repr(embed.call_args_list) + for value in ("Анна", "anna@example.test", "+79990001122", "10482"): + self.assertNotIn(value, sent) + self.assertIn("[[client_name]]", sent) + self.assertIn("[[order_number]]", sent) + tool = chat.call_args_list[0].kwargs["tools"][0] + self.assertNotIn("order_number", tool.parameters["properties"]) + payload = response.json() + self.assertIn("Анна", payload["reply"]) + self.assertIn("10482", payload["reply"]) + self.assertNotIn("[[", payload["reply"]) + self.assertEqual(len(payload["toolCalls"]), 1) + trace = payload["toolCalls"][0] + self.assertEqual((trace["name"], trace["tool"], trace["ok"], trace["errorCode"]), ("order_status", "Статус заказа", True, "")) + self.assertIsInstance(trace["durationMs"], int) + self.assertGreaterEqual(trace["durationMs"], 0) + self.assertEqual(set(trace), {"name", "tool", "ok", "errorCode", "error", "durationMs", "turnId"}) + for model in (Contact, ContactFieldValue, Conversation, Message, OutboxEvent): + self.assertEqual(model.objects.count(), 0, model.__name__) + invocations = list(LlmInvocation.objects.values()) + self.assertEqual(len(invocations), 3) # embedding + оба раунда + self.assertEqual(payload["promptTokens"], sum(row["prompt_tokens"] for row in invocations if row["operation"] == "chat")) + for value in ("Анна", "anna@example.test", "+79990001122", "10482", "[["): + self.assertNotIn(value, repr(invocations)) + + def test_invalid_data_is_rejected_before_any_provider_or_storage_call(self): + invalid = [[], {"unexpected": "value"}, {"name": 2}, {"name": "x" * 256}, + {"email": "broken"}, {"phone": "broken"}, {"webFields": []}, + {"webFields": {"999999": {}}}] + for key, value in (("amount", True), ("active", "yes"), ("status", "unknown"), + ("date", "broken"), ("other_email", "broken"), ("other_phone", "broken"), + ("url", "broken"), ("order_number", 2), ("deleted", "value")): + invalid.append({"webFields": {str(self.web.id): {key: value}}}) + with patch("chatballs.ai.invocation.get_provider") as provider: + for data in invalid: + with self.subTest(data=data): + response = self._post(data) + self.assertEqual(response.status_code, 400, response.content) + provider.assert_not_called() + self.assertEqual(LlmInvocation.objects.count(), 0) + self.assertEqual(Contact.objects.count(), 0) + + def test_fields_from_another_agent_or_organization_are_rejected(self): + other = Channel.objects.create(organization=self.organization, code="other", name="Other") + foreign_org = Organization.objects.create(name="Foreign", slug="foreign-chat") + foreign = Channel.objects.create(organization=foreign_org, code="foreign", name="Foreign") + for web in (self._web(other), self._web(foreign)): + response = self._post({"webFields": {str(web.id): {"order_number": "10482"}}}) + self.assertEqual(response.status_code, 400) + + def test_empty_data_omits_required_bound_tool_and_keeps_legacy_request(self): + provider = DemoProvider() + with ( + patch("chatballs.ai.invocation.get_provider", return_value=provider), + patch.object(provider, "chat", wraps=provider.chat) as chat, + patch("chatballs.integrations.http_tool.fetch") as fetch, + ): + for body in ({"message": "Привет"}, {"message": "Привет", "clientData": {}}): + response = self.client.post(self.url, body, format="json") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["toolCalls"], []) + self.assertIsNone(chat.call_args.kwargs.get("tools")) + fetch.assert_not_called() + + def test_tool_error_is_returned_without_foreign_body(self): + provider = DemoProvider() + with ( + patch("chatballs.ai.invocation.get_provider", return_value=provider), + patch("chatballs.integrations.http_tool.fetch", side_effect=TimeoutError("private-body")), + ): + response = self._post() + self.assertEqual(response.status_code, 200, response.content) + trace = response.json()["toolCalls"][0] + self.assertFalse(trace["ok"]) + self.assertEqual(trace["errorCode"], "timeout") + self.assertTrue(trace["error"]) + self.assertNotIn("private-body", response.content.decode()) + + def test_history_window_and_provider_failure_keep_tool_trace(self): + from chatballs.ai.provider.base import ProviderRejected + + provider = ScriptedProvider(calls(("order_status", {})), ProviderRejected("unavailable")) + with ( + patch("chatballs.ai.invocation.get_provider", return_value=provider), + patch("chatballs.integrations.http_tool.fetch", side_effect=TimeoutError), + ): + response = self._post(history=[ + {"role": "user", "content": "discarded"}, + {"role": "assistant", "content": "Анна"}, + ]) + self.assertEqual(response.status_code, 502, response.content) + self.assertEqual(response.json()["toolCalls"][0]["errorCode"], "timeout") + self.assertNotIn("discarded", provider.sent()) + self.assertNotIn("Анна", provider.sent()) + + def test_active_agent_and_management_permission_are_required(self): + self.agent.status = AIAgentStatus.DISABLED + self.agent.save(update_fields=["status"]) + self.assertEqual(self._post().status_code, 502) + OrganizationMembership.objects.filter(user=self.user).update(role=EmployeeRole.EMPLOYEE) + self.assertEqual(self._post().status_code, 403) + + def test_contact_bindings_and_valid_schema_types(self): + from urllib.parse import parse_qs, urlsplit + + self.http.config["parameters"] += [{ + "name": key, "type": "string", "required": True, "location": "query", + "source": {"type": "contact", "field": key}, + } for key in ("name", "email", "phone")] + self.http.save(update_fields=["config"]) + self.data["webFields"][str(self.web.id)].update({ + "amount": 0, "active": False, "status": "sent", "date": "2026-10-05T12:00:00Z", + "other_email": "another@example.test", "other_phone": "+79990002233", + "url": "https://example.test/order", + }) + with ( + patch("chatballs.ai.invocation.get_provider", return_value=DemoProvider()), + patch("chatballs.integrations.http_tool.fetch", side_effect=TimeoutError) as fetch, + ): + response = self._post() + self.assertEqual(response.status_code, 200, response.content) + query = parse_qs(urlsplit(fetch.call_args.args[0]).query) + self.assertEqual(query, {key: [self.data[key]] for key in ("name", "email", "phone")}) + + def test_reset_does_not_reuse_values_from_previous_request(self): + with ( + patch("chatballs.ai.invocation.get_provider", return_value=DemoProvider()), + patch("chatballs.integrations.http_tool.fetch", side_effect=TimeoutError) as fetch, + ): + self.assertEqual(self._post().status_code, 200) + fetch.reset_mock() + response = self._post({}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["toolCalls"], []) + fetch.assert_not_called() + + def test_escaped_values_are_masked_and_restored_without_escaping_artifacts(self): + self.data["name"] = 'Анна "Ким"\nТест' + self.data["webFields"][str(self.web.id)]["order_number"] = 'номер "А"\n42' + provider = ScriptedProvider(says("[[client_name]]: [[order_number]]")) + with patch("chatballs.ai.invocation.get_provider", return_value=provider): + response = self._post() + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response.json()["reply"], 'Анна "Ким"\nТест: номер "А"\n42') + self.assertNotIn("Анна", provider.sent()) + self.assertNotIn("номер", provider.sent()) diff --git a/apps/backend/chatballs/ai/turn.py b/apps/backend/chatballs/ai/turn.py index f981e0e..68972c9 100644 --- a/apps/backend/chatballs/ai/turn.py +++ b/apps/backend/chatballs/ai/turn.py @@ -30,7 +30,7 @@ from __future__ import annotations import time -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from django.conf import settings @@ -46,11 +46,16 @@ from chatballs.ai.invocation import ( run_embedding, ) from chatballs.ai.models import AIAgent -from chatballs.ai.provider.base import ChatResult, EmbeddingResult, ProviderError +from chatballs.ai.provider.base import ( + ChatMessage, + ChatResult, + EmbeddingResult, + ProviderError, +) from chatballs.ai.pseudonymization import Pseudonymizer, contact_known_values from chatballs.ai.retrieval import merge_hits from chatballs.ai.runtime import build_turn_messages -from chatballs.ai.site_context import masked_field_values +from chatballs.ai.site_context import client_context_prompt, masked_field_values from chatballs.ai.tool_calls import ToolCallRecord from chatballs.ai.tool_loop import ChatRound, run_tool_loop, with_tools from chatballs.ai.turn_tools import TurnTool, plan_turn_tools @@ -163,6 +168,7 @@ def plan_chat( conversation: Conversation | None = None, pseudonymizer: Pseudonymizer | None = None, client: ClientData | None = None, + client_context: list[tuple[str, str]] | None = None, ) -> TurnPlan: """Шаг в транзакции: поиск знаний, сборка промпта, выбор модели и инструментов. @@ -201,6 +207,15 @@ def plan_chat( params=agent.model_params or None, timeout=settings.CHATBALLS_AI_TURN_TIMEOUT, ) + if client_context: + # Контекст маскируется той же картой, затем экранируется построчно. + # Готовые системные токены повторно маскировать нельзя. + messages = list(job.messages) + position = next((i for i, item in enumerate(messages) if item.role != "system"), len(messages)) + messages.insert(position, ChatMessage( + role="system", content=client_context_prompt(client_context, pseudonymizer), + )) + job = replace(job, messages=messages) tools = plan_turn_tools(agent=agent, conversation=conversation, client=client) return TurnPlan( job=with_tools(job, tools), diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 8ae6140..3cae6f7 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -15,6 +15,7 @@ MESSAGES: dict[str, object] = { "admin.photo_not_found": "Photo not found", "ai.agent_not_found": "Agent not found", "ai.demo_no_transcription": "Voice transcription is not available on the demo provider: connect OpenRouter or a compatible provider under Settings.", + "ai.test_client_data_invalid": "Check the test client data: connections, fields and value formats must match the agent settings.", "ai.empty_message": "The message is empty", "ai.agent_tools_invalid": "Tools are passed as a list: the server and the tool name", "ai.agent_tool_not_found": "Tool not found: the server's tool list may have changed. Refresh the page.", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 3c3688a..5ac6566 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -19,6 +19,7 @@ MESSAGES: dict[str, object] = { "admin.photo_not_found": "Фото не найдено", "ai.agent_not_found": "Агент не найден", "ai.demo_no_transcription": "Расшифровка голосовых недоступна в демо-провайдере: подключите OpenRouter или совместимый провайдер в «Настройках».", + "ai.test_client_data_invalid": "Проверьте тестовые данные клиента: подключение, поля и формат значений должны соответствовать настройкам агента.", "ai.empty_message": "Пустое сообщение", "ai.agent_tools_invalid": "Инструменты передаются списком: сервер и имя инструмента", "ai.agent_tool_not_found": "Инструмент не найден: список инструментов сервера мог измениться. Обновите страницу.", diff --git a/apps/backend/chatballs/webchat/field_values.py b/apps/backend/chatballs/webchat/field_values.py new file mode 100644 index 0000000..91fad5c --- /dev/null +++ b/apps/backend/chatballs/webchat/field_values.py @@ -0,0 +1,56 @@ +"""Проверка значения поля без записи в контакт или базу.""" + +import math +from datetime import datetime + +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator, validate_email + +from chatballs.webchat.services import normalize_phone + +MAX_STRING_LENGTH = 500 +BUILTIN_TYPES = {"name": "string", "email": "email", "phone": "phone"} + + +def validate_field_value(value: object, definition: dict) -> object: + if value is None: + return None + field_type = definition["type"] + if field_type == "boolean": + if type(value) is bool: + return value + raise ValueError("type") + if field_type == "number": + if type(value) is int and value.bit_length() <= 1024: + return value + if type(value) is float and math.isfinite(value): + return value + raise ValueError("type") + if not isinstance(value, str): + raise ValueError("type") + if len(value) > MAX_STRING_LENGTH: + raise ValueError("length") + if field_type == "datetime": + try: + datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("format") from exc + elif field_type == "email": + try: + validate_email(value) + except ValidationError as exc: + raise ValueError("format") from exc + elif field_type == "phone": + phone = normalize_phone(value) + if not phone: + raise ValueError("format") + return phone + elif field_type == "url": + try: + URLValidator(schemes=["http", "https"])(value) + except ValidationError as exc: + raise ValueError("format") from exc + elif field_type == "enum": + if value not in {option["value"] for option in definition.get("options", [])}: + raise ValueError("option") + return value diff --git a/apps/backend/chatballs/webchat/site_fields.py b/apps/backend/chatballs/webchat/site_fields.py index 42387f9..3eeeff8 100644 --- a/apps/backend/chatballs/webchat/site_fields.py +++ b/apps/backend/chatballs/webchat/site_fields.py @@ -1,61 +1,15 @@ """Проверка и сохранение недоверенных значений, присланных сайтом.""" import logging -import math -from datetime import datetime -from django.core.exceptions import ValidationError -from django.core.validators import URLValidator, validate_email from django.db import transaction from chatballs.conversations.models import Contact, ContactFieldValue from chatballs.webchat.field_schema import RESERVED_KEYS -from chatballs.webchat.services import normalize_phone +from chatballs.webchat.field_values import BUILTIN_TYPES, validate_field_value from chatballs.webchat.site_field_events import record_site_field_changes logger = logging.getLogger(__name__) -MAX_STRING_LENGTH = 500 -BUILTIN_TYPES = {"name": "string", "email": "email", "phone": "phone"} - - -def _valid_value(value: object, field_type: str) -> object: - if value is None: - return None - if field_type == "boolean": - if type(value) is bool: - return value - raise ValueError("type") - if field_type == "number": - if type(value) is int and value.bit_length() <= 1024: - return value - if type(value) is float and math.isfinite(value): - return value - raise ValueError("type") - if not isinstance(value, str): - raise ValueError("type") - if len(value) > MAX_STRING_LENGTH: - raise ValueError("length") - if field_type == "datetime": - try: - datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError as exc: - raise ValueError("format") from exc - elif field_type == "email": - try: - validate_email(value) - except ValidationError as exc: - raise ValueError("format") from exc - elif field_type == "phone": - phone = normalize_phone(value) - if not phone: - raise ValueError("format") - return phone - elif field_type == "url": - try: - URLValidator(schemes=["http", "https"])(value) - except ValidationError as exc: - raise ValueError("format") from exc - return value def _schema(integration) -> dict[str, dict]: @@ -94,10 +48,7 @@ def save_site_fields(session, fields: object) -> None: logger.warning("webchat field ignored: unknown key") continue try: - value = _valid_value(raw_value, definition["type"]) - if definition["type"] == "enum" and value is not None: - if value not in {option["value"] for option in definition.get("options", [])}: - raise ValueError("option") + value = validate_field_value(raw_value, definition) previous = ContactFieldValue.objects.filter( contact=contact, integration=session.connection, key=key ).first() diff --git a/docs/agent-test-chat-api.md b/docs/agent-test-chat-api.md new file mode 100644 index 0000000..1c73f2f --- /dev/null +++ b/docs/agent-test-chat-api.md @@ -0,0 +1,64 @@ +# Проверочный чат агента + +`POST /api/v1/organizations/{organizationPublicId}/agents/{id}/test-chat/` +требует права управления агентом и активного AI-агента. Проверочный чат использует +планирование знаний, псевдонимизацию и цикл инструментов живого хода. Подготовка +и журнал выполняются в коротких tenant-транзакциях, сеть — между ними. + +```json +{ + "message": "Где мой заказ?", + "history": [{"role": "assistant", "content": "Здравствуйте"}], + "clientData": { + "name": "Анна", + "email": "anna@example.test", + "phone": "+79990001122", + "webFields": { + "42": {"order_number": "10482"} + } + } +} +``` + +`id` — идентификатор карточки (Channel), ключ `webFields` — строковый ID +веб-подключения этого агента. Поля проверяются по текущей схеме подключения тем +же валидатором, что значения веб-виджета. Неизвестные подключения и поля, +неверные типы, формат или варианты enum дают `400` до вызова провайдера. +Имя, e-mail и телефон также проверяются по типу и длине поля контакта. + +`clientData` необязателен; пустой объект сбрасывает тестовые данные. +Отсутствующие и `null`-значения означают отсутствие данных; для стандартных +полей допускается также пустая строка. Обязательность формы перед чатом не +применяется: можно проверить поведение без значения привязанного параметра. +Без обязательного привязанного значения инструмент не предлагается модели. +История допускает только роли `user` и `assistant`, обрезается до окна агента. + +Стандартные поля и свои тестовые значения становятся известными значениями +карты псевдонимизации, общей для эмбеддинга, истории, промпта и инструментов. +В промпт входят стандартные поля и свои поля с `ai_visible=true` в порядке +схемы; все переданные поля доступны серверным привязкам HTTP-параметров. +Данные, карта токенов, аргументы и тела ответов инструментов остаются в памяти. +Контакты, значения полей, диалоги и события проверочного чата не создаются. +В журнале LLM остаются только метаданные вызовов и учёт токенов каждого раунда. + +Ответ сохраняет `reply`, `model`, `promptTokens`, `completionTokens` и добавляет +`toolCalls`. Числа токенов суммируются по всем раундам ответа. Каждый вызов: + +```json +{ + "name": "order_status", + "tool": "Статус заказа", + "ok": true, + "errorCode": "", + "error": "", + "durationMs": 400, + "turnId": null +} +``` + +Это метаданные для плашки: итог успеха или ошибки, а не тело ответа инструмента. +`error` — переведённая подпись по тем же правилам, что в ленте диалога. +`turnId=null`: сохраняемого сообщения хода нет; UI группирует вызовы одного +HTTP-ответа. При отказе провайдера `502` содержит `detail` и уже выполненные +`toolCalls`. Ошибка инструмента возвращается модели кодом; она может закончить +ход ответом.