mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-14 05:09:02 +03:00
✨ feat(ai): тестовые данные и инструменты в проверочном чате
This commit is contained in:
1 parent
fd74402728
commit
2263045ebd
12 files changed
+623
-96
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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]]:
|
||||
"""Свои поля, доступные модели, с непустыми значениями — в порядке схемы."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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`. Ошибка инструмента возвращается модели кодом; она может закончить
|
||||
ход ответом.
|
||||
Reference in new issue
Block a user