✨ feat(ai): тестовые данные и инструменты в проверочном чате

This commit is contained in:
Andrey committed 2026-10-05 22:27:20 +03:00
1 parent fd74402728
commit 2263045ebd
12 files changed
+623 -96

No files matched your search

+2 -1
View File
@@ -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(
"<int:agent_id>/test-chat/",
views.AgentCardTestChatView.as_view(),
AgentCardTestChatView.as_view(),
name="agent-card-test-chat",
),
]
@@ -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"
+14
View File
@@ -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]]:
"""Свои поля, доступные модели, с непустыми значениями — в порядке схемы."""
+100
View File
@@ -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,
)
@@ -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,
})
+274
View File
@@ -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())
+18 -3
View File
@@ -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),
@@ -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.",
@@ -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": "Инструмент не найден: список инструментов сервера мог измениться. Обновите страницу.",
@@ -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
+2 -51
View File
@@ -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()
+64
View File
@@ -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`. Ошибка инструмента возвращается модели кодом; она может закончить
ход ответом.