From 0e6a3db9a6f9bcf294e4a72dbb83f81817ff475c Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 12 Sep 2026 03:12:18 +0300 Subject: [PATCH] =?UTF-8?q?:globe=5Fwith=5Fmeridians:=20feat(i18n):=20?= =?UTF-8?q?=D1=8F=D0=B7=D1=8B=D0=BA=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0,=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=B3=D0=BB=D0=B8=D0=B9=D1=81=D0=BA=D0=BE=D0=B5=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=BC=D0=BE=20=D0=B8=20=D1=8F=D0=B7=D1=8B=D0=BA=20=D0=B2=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=81=D1=82=D0=B5=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Язык ответов агента. Поле AIAgent.answer_language: «как у клиента» (по умолчанию), «язык организации» или конкретный язык. Системный промпт при этом не переводится — его читает модель, а не человек; язык задаёт отдельная директива, потому что русский промпт сам по себе тянет ответ в русский язык. База знаний остаётся одноязычной: поиск ведёт семантическая ветка, а эмбеддинги кроссязычные. Язык уходящего наружу текста. Приглашение на звонок, просьба поделиться контактом, подписи кнопок мессенджеров, тема письма, приветствие виджета и ответы бота уведомлений берут язык организации (i18n.customer_language), а не язык запроса: клиент не должен получать письмо на языке того сотрудника, кто нажал кнопку, и половина такого текста рождается в воркере, где запроса нет. Английское демо. Манифесты разложены по demo_seed/data/<язык>/, набор выбирается языком организации. Английский набор повторяет русский по составу — тот же каст, те же тринадцать диалогов, — переводился только текст. Свои документы знаний и каталог тканей; аватары и голосовые общие. Демо-провайдер стал двуязычным: наборы стоп-слов и ключевых слов на оба языка, язык определяется по письму клиента. Выбор языка в мастере первого запуска. Переключатель меняет экран без перезагрузки: форму уже заполняют, а пароль в хранилище браузера класть нельзя. Выбранный язык становится языком установки, и на нём же идёт вся установка — должность владельца, демо-данные, ошибки формы. Попутно исправлено: - Демо-стенд нельзя было редактировать. configure_agent_provider принимал только OpenRouter и Custom, а демо ставит агентов на встроенный DEMO. providerIntegrationId уходит с каждым PATCH карточки, поэтому падало любое сохранение агента — даже правка инструкций. - Список типов уведомлений отдавал ярлык TextChoices — захардкоженную русскую строку. Ярлык переехал ключом в реестр TYPE_META. - Результат «Проверить» у подключений (20 строк) и ошибки политики исходящих адресов уходили владельцу по-русски. - Правило data/ в .gitignore ловило demo_seed/data: русские манифесты лежали в индексе с прежних времён, а новые файлы туда молча не попадали бы. Каталог бэкенда — 519 ключей, наборы ru и en совпадают. Полный прогон бэкенда: 759 passed, 1 skipped. Co-Authored-By: Claude Opus 5 --- .gitignore | 5 +- AGENTS.md | 3 + VERSION | 2 +- .../backend/chatballs/ai/agent_attachments.py | 11 +- apps/backend/chatballs/ai/agent_card.py | 33 +- apps/backend/chatballs/ai/agent_card_views.py | 10 +- apps/backend/chatballs/ai/bulk_views.py | 13 +- apps/backend/chatballs/ai/category_views.py | 11 +- .../chatballs/ai/knowledge_api_inputs.py | 11 +- apps/backend/chatballs/ai/knowledge_bulk.py | 13 +- .../chatballs/ai/knowledge_categories.py | 11 +- apps/backend/chatballs/ai/knowledge_import.py | 15 +- apps/backend/chatballs/ai/knowledge_models.py | 17 +- .../chatballs/ai/knowledge_services.py | 19 +- .../migrations/0017_agent_answer_language.py | 23 + apps/backend/chatballs/ai/models.py | 20 + apps/backend/chatballs/ai/provider/base.py | 4 +- apps/backend/chatballs/ai/provider/demo.py | 68 +- .../chatballs/ai/provider/openai_http.py | 5 +- .../chatballs/ai/provider/openrouter.py | 4 +- apps/backend/chatballs/ai/provider/routing.py | 5 +- .../chatballs/ai/provider_selection.py | 15 +- apps/backend/chatballs/ai/runtime.py | 37 +- apps/backend/chatballs/ai/services.py | 17 +- .../chatballs/ai/test_answer_language.py | 64 ++ .../chatballs/ai/test_demo_provider.py | 42 + .../chatballs/ai/test_provider_modes.py | 22 + apps/backend/chatballs/ai/views.py | 19 +- apps/backend/chatballs/api/pagination.py | 8 +- .../backend/chatballs/calls/event_handlers.py | 8 +- apps/backend/chatballs/calls/lifecycle.py | 2 +- apps/backend/chatballs/calls/models.py | 3 +- apps/backend/chatballs/calls/services.py | 2 +- .../chatballs/channels/authorization.py | 4 +- apps/backend/chatballs/channels/runtime.py | 3 +- apps/backend/chatballs/channels/services.py | 20 +- .../conversations/attachment_views.py | 6 +- .../conversations/chat_extras_views.py | 6 +- .../chatballs/conversations/clients.py | 13 +- .../backend/chatballs/conversations/models.py | 10 +- .../conversations/reporting_views.py | 2 +- .../chatballs/conversations/services.py | 12 +- apps/backend/chatballs/conversations/stats.py | 3 +- .../conversations/test_search_language.py | 59 ++ .../conversations/transports/__init__.py | 5 +- .../conversations/transports/email.py | 10 +- .../chatballs/conversations/transports/max.py | 11 +- .../conversations/transports/telegram.py | 15 +- apps/backend/chatballs/conversations/views.py | 6 +- apps/backend/chatballs/i18n/__init__.py | 2 + apps/backend/chatballs/i18n/audience.py | 24 + apps/backend/chatballs/i18n/messages/en.py | 234 ++++- apps/backend/chatballs/i18n/messages/ru.py | 230 +++++ apps/backend/chatballs/i18n/tests.py | 34 + .../identity/administration_payloads.py | 3 +- .../identity/administration_services.py | 4 +- .../identity/administration_views.py | 4 +- .../chatballs/identity/auth/invitations.py | 3 +- .../chatballs/identity/auth/profile.py | 14 +- .../chatballs/identity/auth/sessions.py | 4 +- apps/backend/chatballs/identity/auth/totp.py | 11 +- apps/backend/chatballs/identity/avatars.py | 4 +- .../demo_seed/data/en/channels_ai.json | 384 +++++++ .../demo_seed/data/en/conversations.json | 954 ++++++++++++++++++ .../demo_seed/data/en/operations.json | 161 +++ .../demo_seed/data/en/organization.json | 134 +++ .../identity/demo_seed/data/en/support.json | 122 +++ .../data/media/delivery-and-payment.txt | 13 + .../data/media/fabric-catalogue-2026.pdf | 32 + .../data/media/fabric-catalogue-2026.txt | 14 + .../demo_seed/data/media/price-estimate.md | 32 + .../demo_seed/data/{ => ru}/channels_ai.json | 0 .../data/{ => ru}/conversations.json | 0 .../demo_seed/data/{ => ru}/operations.json | 0 .../demo_seed/data/{ => ru}/organization.json | 0 .../demo_seed/data/{ => ru}/support.json | 0 .../identity/demo_seed/loaders/channels_ai.py | 2 +- .../demo_seed/loaders/conversations.py | 2 +- .../identity/demo_seed/loaders/foundation.py | 2 +- .../identity/demo_seed/loaders/operations.py | 2 +- .../identity/demo_seed/loaders/support.py | 2 +- .../chatballs/identity/demo_seed/manifest.py | 17 +- .../identity/demo_seed/orchestrator.py | 4 + .../chatballs/identity/demo_seed/refs.py | 2 + .../chatballs/identity/demo_seed/service.py | 4 +- .../identity/employee_security_views.py | 10 +- .../chatballs/identity/employee_validation.py | 5 +- .../chatballs/identity/employee_views.py | 28 +- .../chatballs/identity/group_models.py | 3 +- .../backend/chatballs/identity/group_views.py | 6 +- .../chatballs/identity/instance_settings.py | 14 + .../chatballs/identity/instance_views.py | 9 +- .../chatballs/identity/invitation_service.py | 7 +- .../chatballs/identity/ownership_views.py | 7 +- .../chatballs/identity/password_validation.py | 2 +- apps/backend/chatballs/identity/sessions.py | 4 +- apps/backend/chatballs/identity/setup.py | 38 +- .../backend/chatballs/identity/setup_views.py | 3 +- .../chatballs/identity/test_seed_demo.py | 42 + apps/backend/chatballs/integrations/checks.py | 35 +- .../chatballs/integrations/outbound.py | 8 +- apps/backend/chatballs/integrations/proxy.py | 6 +- .../chatballs/integrations/services.py | 51 +- .../chatballs/integrations/test_outbound.py | 7 +- apps/backend/chatballs/integrations/tests.py | 3 +- .../chatballs/notifications/binding.py | 16 +- .../chatballs/notifications/services.py | 27 +- apps/backend/chatballs/notifications/views.py | 8 +- apps/backend/chatballs/platform/views.py | 3 +- .../chatballs/support_portals/addressing.py | 10 +- .../support_portals/article_import.py | 17 +- .../support_portals/article_import_views.py | 2 +- .../support_portals/content_services.py | 20 +- .../support_portals/domain_services.py | 11 +- .../support_portals/portal_services.py | 9 +- .../chatballs/support_portals/portal_views.py | 2 +- .../chatballs/support_portals/themes.py | 14 +- .../support_portals/widget_validation.py | 8 +- .../chatballs/tenancy/storage_settings.py | 12 +- .../chatballs/tenancy/storage_views.py | 4 +- apps/backend/chatballs/webchat/services.py | 33 +- .../features/administration/AuditTable.tsx | 4 +- .../OrganizationSettingsForm.tsx | 43 +- .../src/features/agents/AgentDetailPage.tsx | 51 +- apps/internal-ui/src/features/agents/model.ts | 3 + .../src/features/agents/styles.css | 12 + .../ai/knowledge/knowledgeLibraryModel.ts | 6 +- .../src/features/auth/AuthChangePassword.tsx | 4 +- .../src/features/auth/AuthResetPassword.tsx | 4 +- .../src/features/auth/AuthSetup.tsx | 32 +- .../internal-ui/src/features/auth/password.ts | 6 +- apps/internal-ui/src/features/auth/styles.css | 6 + .../conversations/ConversationThread.tsx | 6 +- .../features/conversations/DialogControls.tsx | 6 +- .../src/features/conversations/model.ts | 6 +- .../features/help-center/HelpCenterApp.tsx | 12 +- .../notifications/NotificationDrawer.tsx | 11 +- .../src/features/sales/clients/model.ts | 4 +- .../features/support-portals/portalText.ts | 5 +- apps/internal-ui/src/i18n/en.ts | 4 + apps/internal-ui/src/i18n/index.ts | 19 + apps/internal-ui/src/i18n/ru.ts | 4 + apps/internal-ui/src/i18n/useLanguage.ts | 16 + apps/internal-ui/src/shared/form-controls.css | 8 - apps/internal-ui/src/shared/form-controls.tsx | 6 +- apps/web-chat/src/ChatView.tsx | 2 +- packages/shared/src/i18n/formats.ts | 7 + 147 files changed, 3510 insertions(+), 442 deletions(-) create mode 100644 apps/backend/chatballs/ai/migrations/0017_agent_answer_language.py create mode 100644 apps/backend/chatballs/ai/test_answer_language.py create mode 100644 apps/backend/chatballs/conversations/test_search_language.py create mode 100644 apps/backend/chatballs/i18n/audience.py create mode 100644 apps/backend/chatballs/identity/demo_seed/data/en/channels_ai.json create mode 100644 apps/backend/chatballs/identity/demo_seed/data/en/conversations.json create mode 100644 apps/backend/chatballs/identity/demo_seed/data/en/operations.json create mode 100644 apps/backend/chatballs/identity/demo_seed/data/en/organization.json create mode 100644 apps/backend/chatballs/identity/demo_seed/data/en/support.json create mode 100644 apps/backend/chatballs/identity/demo_seed/data/media/delivery-and-payment.txt create mode 100644 apps/backend/chatballs/identity/demo_seed/data/media/fabric-catalogue-2026.pdf create mode 100644 apps/backend/chatballs/identity/demo_seed/data/media/fabric-catalogue-2026.txt create mode 100644 apps/backend/chatballs/identity/demo_seed/data/media/price-estimate.md rename apps/backend/chatballs/identity/demo_seed/data/{ => ru}/channels_ai.json (100%) rename apps/backend/chatballs/identity/demo_seed/data/{ => ru}/conversations.json (100%) rename apps/backend/chatballs/identity/demo_seed/data/{ => ru}/operations.json (100%) rename apps/backend/chatballs/identity/demo_seed/data/{ => ru}/organization.json (100%) rename apps/backend/chatballs/identity/demo_seed/data/{ => ru}/support.json (100%) create mode 100644 apps/internal-ui/src/i18n/useLanguage.ts diff --git a/.gitignore b/.gitignore index 6596a18..b6fa06e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,10 @@ test-results/ .DS_Store # Перенесено из корневого .gitignore при переносе корня репозитория в code/chatballs -data/ +# Тома docker-стенда (postgres, redis, caddy, media) — только в корне репозитория. +# Без якоря правило ловило и demo_seed/data: русские манифесты в индексе уже +# лежали, а новые файлы туда молча не попадали. +/data/ .env.bak-* **/staticfiles/ diff --git a/AGENTS.md b/AGENTS.md index 6d322fb..5e8ca44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,9 @@ Engineering rules / обязательные практики: - Что записано в базу, переводится по коду, а не текстом: системные события диалога, уведомления и подписи журнала аудита хранят код, а фразу собирает бэкенд на языке читателя. Писать в историю готовую русскую фразу нельзя — её потом не перевести. - Язык запроса выбирается цепочкой: профиль сотрудника → организация → установка → браузер. Письмо получает язык адресата, а не отправителя. - На бэкенде `t(...)` не вызывается на уровне модуля: язык там свой на каждый запрос, а константа посчиталась бы один раз при импорте. +- Текст, уходящий наружу — клиенту в мессенджер, письмо, кнопку виджета, — берёт язык не запроса, а организации: `t(..., language=customer_language(organization))`. Язык оператора, нажавшего кнопку, к речи компании с её клиентом отношения не имеет, и половина такого текста вообще рождается в воркере, где запроса нет. +- Системный промпт агента не переводится: его читает модель. Язык ответа задаёт отдельная директива по полю `AIAgent.answer_language` — по умолчанию «как у клиента». +- Демо-набор ставится на языке организации: манифесты лежат в `demo_seed/data/<язык>/`, наборы ключей в них совпадают. Бинарные вложения (аватары, голосовые) общие, текстовые документы — свои на каждый язык. Definition of Done: - Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца. diff --git a/VERSION b/VERSION index 9084fa2..26aaba0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 +1.2.0 diff --git a/apps/backend/chatballs/ai/agent_attachments.py b/apps/backend/chatballs/ai/agent_attachments.py index c739ee6..03998ec 100644 --- a/apps/backend/chatballs/ai/agent_attachments.py +++ b/apps/backend/chatballs/ai/agent_attachments.py @@ -19,6 +19,7 @@ from chatballs.ai.agent_knowledge import ( from chatballs.ai.knowledge_policy import readable_knowledge from chatballs.ai.models import AIAgent, Knowledge from chatballs.channels.models import Channel +from chatballs.i18n import t from chatballs.support_portals.models import PortalArticle from chatballs.tenancy.context import TenantContext @@ -37,17 +38,17 @@ def normalized_ids(raw_ids: Iterable[int], field: str) -> list[int]: normalized: list[int] = [] for value in raw_ids: if isinstance(value, bool) or not isinstance(value, int) or value <= 0: - raise ValidationError({field: "Positive integer ID required"}) + raise ValidationError({field: t("api.positive_id_required")}) if value not in normalized: normalized.append(value) if not normalized: - raise ValidationError({field: "At least one ID is required"}) + raise ValidationError({field: t("api.at_least_one_id")}) return normalized def _locked_agent(*, context: TenantContext, agent: AIAgent) -> tuple[AIAgent, Channel]: if agent.channel.organization_id != context.organization_id: - raise ValidationError({"agent": "Agent belongs to another organization"}) + raise ValidationError({"agent": t("ai.agent_other_organization")}) channel = Channel.objects.select_for_update().get( id=agent.channel_id, organization_id=context.organization_id, @@ -99,7 +100,7 @@ def link_knowledge_to_agent( ) found_ids = set(readable.values_list("id", flat=True)) if len(found_ids) != len(requested_ids): - raise ValidationError({"knowledgeIds": "Unknown knowledge item"}) + raise ValidationError({"knowledgeIds": t("ai.unknown_knowledge_item")}) available_ids = set( knowledge_available_to_channel( Knowledge.objects.filter(id__in=found_ids), @@ -130,7 +131,7 @@ def link_portal_articles_to_agent( ) found_ids = set(articles.values_list("id", flat=True)) if len(found_ids) != len(requested_ids): - raise ValidationError({"articleIds": "Unknown portal article"}) + raise ValidationError({"articleIds": t("ai.unknown_portal_article")}) available_ids = set( portal_articles_available_to_channel( PortalArticle.objects.filter(id__in=found_ids), diff --git a/apps/backend/chatballs/ai/agent_card.py b/apps/backend/chatballs/ai/agent_card.py index 35f37dd..5e81f55 100644 --- a/apps/backend/chatballs/ai/agent_card.py +++ b/apps/backend/chatballs/ai/agent_card.py @@ -12,7 +12,7 @@ from django.db import transaction from django.db.models import Case, Count, IntegerField, Q, QuerySet, Value, When from django.utils.text import slugify -from chatballs.ai.models import AIAgent, AIAgentStatus +from chatballs.ai.models import AIAgent, AIAgentStatus, AnswerLanguage from chatballs.ai.serializers import agent_portal_article_payload from chatballs.channels.models import Channel from chatballs.channels.services import ( @@ -22,7 +22,7 @@ from chatballs.channels.services import ( update_channel, ) from chatballs.conversations.models import LifecycleState -from chatballs.i18n import t +from chatballs.i18n import normalize_language, t from chatballs.tenancy.context import TenantContext @@ -129,6 +129,7 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None) "providerIntegrationId": agent.provider_integration_id, "modelParams": agent.model_params, "limits": agent.limits, + "answerLanguage": agent.answer_language, "persona": agent.persona, "tone": agent.tone, "instructions": agent.instructions, @@ -201,7 +202,7 @@ def create_agent_card( from chatballs.channels import authorization from chatballs.channels.services import _clean_name, _group_for_channel - authorization.require_organization_manage(context, operation="Создание агента") + authorization.require_organization_manage(context, operation="channels.operation_agent_create") clean_name = _clean_name(name) group = _group_for_channel(context=context, group_id=group_id) channel = Channel.objects.create( @@ -235,6 +236,7 @@ def update_agent_card( "persona", "tone", "instructions", + "answerLanguage", "knowledgeIds", } if ai_fields & set(body): @@ -245,20 +247,20 @@ def update_agent_card( not isinstance(knowledge_ids, list) or not all(isinstance(item, int) for item in knowledge_ids) ): - raise ValidationError({"knowledgeIds": "List of ids required"}) + raise ValidationError({"knowledgeIds": t("api.list_of_ids_required")}) model_params = body.get("modelParams", agent.model_params) limits = body.get("limits", agent.limits) if not isinstance(model_params, dict): - raise ValidationError({"modelParams": "Object required"}) + raise ValidationError({"modelParams": t("api.object_required")}) if not isinstance(limits, dict): - raise ValidationError({"limits": "Object required"}) + raise ValidationError({"limits": t("api.object_required")}) provider_integration_id = body.get( "providerIntegrationId", agent.provider_integration_id ) if provider_integration_id is not None and not isinstance( provider_integration_id, int ): - raise ValidationError({"providerIntegrationId": "Integer id required"}) + raise ValidationError({"providerIntegrationId": t("api.integer_id_required")}) update_agent( context=context, agent=agent, @@ -272,6 +274,9 @@ def update_agent_card( persona=str(body.get("persona", agent.persona)), tone=str(body.get("tone", agent.tone)), instructions=str(body.get("instructions", agent.instructions)), + answer_language=_clean_answer_language( + body.get("answerLanguage", agent.answer_language) + ), knowledge_ids=knowledge_ids, ), ) @@ -281,6 +286,18 @@ def update_agent_card( return agent_card_for_context(context=context, agent_id=channel.id) +def _clean_answer_language(value: object) -> str: + """Режим ответа агента: MIRROR, ORGANIZATION или код поддерживаемого языка.""" + + raw = str(value or "").strip() + if raw in AnswerLanguage.values: + return raw + code = normalize_language(raw) + if code: + return code + raise ValidationError({"answerLanguage": t("ai.unknown_answer_language")}) + + def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]: """Агент удаляется вместе с каналом; блокируют только внешние связи.""" counts = ( @@ -296,7 +313,7 @@ def delete_agent_card(*, context: TenantContext, channel: Channel) -> None: from chatballs.channels import authorization from chatballs.channels.services import ChannelHasReferences - authorization.require_organization_manage(context, operation="Удаление агента") + authorization.require_organization_manage(context, operation="channels.operation_agent_delete") blockers = agent_deletion_blockers(channel) if blockers: raise ChannelHasReferences(blockers) diff --git a/apps/backend/chatballs/ai/agent_card_views.py b/apps/backend/chatballs/ai/agent_card_views.py index e5ba1bf..cd007c3 100644 --- a/apps/backend/chatballs/ai/agent_card_views.py +++ b/apps/backend/chatballs/ai/agent_card_views.py @@ -110,7 +110,7 @@ class AgentCardListView(APIView): try: cards = cards.filter(group_id=int(group)) except ValueError: - return Response({"detail": "group must be an id or none"}, status=400) + return Response({"detail": t("ai.group_id_or_none")}, status=400) from chatballs.ai.agent_card import ( ensure_channel_agent, knowledge_total_for_organization, @@ -135,7 +135,7 @@ class AgentCardListView(APIView): if group_id is not None and ( isinstance(group_id, bool) or not isinstance(group_id, int) ): - return Response({"detail": "groupId must be an integer or null"}, status=400) + return Response({"detail": t("ai.group_id_integer_or_null")}, status=400) try: channel = create_agent_card( context=request.tenant_context, @@ -245,11 +245,11 @@ class AgentCardTestChatView(APIView): return Response({"detail": t("ai.empty_message")}, status=400) history = request.data.get("history") or [] if not isinstance(history, list): - return Response({"detail": "history must be a list"}, status=400) + return Response({"detail": t("ai.history_must_be_list")}, status=400) try: result = run_channel_turn(channel=channel, message=message, history=history) except ProviderError as error: - return Response({"detail": f"Ошибка провайдера: {error}"}, status=502) + return Response({"detail": t("ai.provider_error", error=error)}, status=502) return Response( { "reply": result.text, @@ -272,7 +272,7 @@ class AgentCardConnectionsView(APIView): data = request.data if isinstance(request.data, dict) else {} integration_id = data.get("integrationId") if isinstance(integration_id, bool) or not isinstance(integration_id, int): - return Response({"detail": "integrationId must be an integer"}, status=400) + return Response({"detail": t("ai.integration_id_integer")}, status=400) try: integration, previous_channel_id = channel_services.bind_connection( context=request.tenant_context, diff --git a/apps/backend/chatballs/ai/bulk_views.py b/apps/backend/chatballs/ai/bulk_views.py index a5aa810..04fbd20 100644 --- a/apps/backend/chatballs/ai/bulk_views.py +++ b/apps/backend/chatballs/ai/bulk_views.py @@ -16,19 +16,20 @@ from chatballs.ai.knowledge_bulk import ( from chatballs.ai.models import AIAgent from chatballs.ai.selectors import agent_for_employee from chatballs.api.permissions import HasCapability +from chatballs.i18n import t from chatballs.identity.audit import record_audit_event def _positive_id(value: object, field: str) -> int: if isinstance(value, bool) or not isinstance(value, int) or value <= 0: - raise ValidationError({field: "Positive integer ID required"}) + raise ValidationError({field: t("api.positive_id_required")}) return value def _knowledge_ids(body: dict[str, object]) -> list[int]: raw_ids = body.get("knowledgeIds") if not isinstance(raw_ids, list): - raise ValidationError({"knowledgeIds": "List of knowledge IDs required"}) + raise ValidationError({"knowledgeIds": t("ai.list_of_knowledge_ids")}) return [_positive_id(item, "knowledgeIds") for item in raw_ids] @@ -68,7 +69,7 @@ class KnowledgeBulkMoveView(_KnowledgeBulkView): def _attach_action(body: dict[str, object]) -> bool: action = str(body.get("action", "attach")) if action not in {"attach", "detach"}: - raise ValidationError({"action": "Expected attach or detach"}) + raise ValidationError({"action": t("ai.expected_attach_or_detach")}) return action == "attach" @@ -91,7 +92,7 @@ class _AgentLinkView(APIView): agent_id = _positive_id(request.data.get("agentId"), "agentId") raw_ids = request.data.get(self.id_field) if not isinstance(raw_ids, list): - raise ValidationError({self.id_field: "List of IDs required"}) + raise ValidationError({self.id_field: t("ai.list_of_ids")}) ids = [_positive_id(item, self.id_field) for item in raw_ids] attach = _attach_action(request.data) except ValidationError as error: @@ -103,7 +104,7 @@ class _AgentLinkView(APIView): capability="ai.manage", ) except AIAgent.DoesNotExist: - return Response({"detail": "Agent not found"}, status=404) + return Response({"detail": t("ai.agent_not_found")}, status=404) try: result = self.link(request, agent, ids, attach) except ValidationError as error: @@ -171,7 +172,7 @@ class AgentCategoryKnowledgeSelectView(APIView): capability="ai.manage", ) except AIAgent.DoesNotExist: - return Response({"detail": "Agent not found"}, status=404) + return Response({"detail": t("ai.agent_not_found")}, status=404) try: category_id = _positive_id(request.data.get("categoryId"), "categoryId") result = add_category_knowledge_to_agent( diff --git a/apps/backend/chatballs/ai/category_views.py b/apps/backend/chatballs/ai/category_views.py index f7c9e7b..18ae179 100644 --- a/apps/backend/chatballs/ai/category_views.py +++ b/apps/backend/chatballs/ai/category_views.py @@ -14,16 +14,17 @@ from chatballs.ai.models import KnowledgeCategory from chatballs.ai.selectors import category_tree_for_employee from chatballs.ai.serializers import category_payload from chatballs.api.permissions import HasCapability +from chatballs.i18n import t from chatballs.identity.audit import record_audit_event def _integer(value: object, field: str) -> int: if isinstance(value, bool): - raise ValidationError({field: "Integer required"}) + raise ValidationError({field: t("api.integer_required")}) try: return int(value) except (TypeError, ValueError) as error: - raise ValidationError({field: "Integer required"}) from error + raise ValidationError({field: t("api.integer_required")}) from error def _category(request: Request, category_id: int) -> KnowledgeCategory: @@ -42,7 +43,7 @@ def _parent( try: return _category(request, parent_id) except KnowledgeCategory.DoesNotExist as error: - raise ValidationError({"parentId": "Parent category not found"}) from error + raise ValidationError({"parentId": t("ai.parent_category_not_found")}) from error def _audit( @@ -110,7 +111,7 @@ class KnowledgeCategoryDetailView(APIView): require_category_manage(context=request.tenant_context) category = _category(request, category_id) except KnowledgeCategory.DoesNotExist: - return Response({"detail": "Category not found"}, status=404) + return Response({"detail": t("ai.category_not_found")}, status=404) try: parent = ( _parent(request, request.data.get("parentId"), field_present=True) @@ -136,7 +137,7 @@ class KnowledgeCategoryDetailView(APIView): require_category_manage(context=request.tenant_context) category = _category(request, category_id) except KnowledgeCategory.DoesNotExist: - return Response({"detail": "Category not found"}, status=404) + return Response({"detail": t("ai.category_not_found")}, status=404) try: deleted_id = category.id delete_category(context=request.tenant_context, category=category) diff --git a/apps/backend/chatballs/ai/knowledge_api_inputs.py b/apps/backend/chatballs/ai/knowledge_api_inputs.py index 0c451f9..1073367 100644 --- a/apps/backend/chatballs/ai/knowledge_api_inputs.py +++ b/apps/backend/chatballs/ai/knowledge_api_inputs.py @@ -4,17 +4,18 @@ from rest_framework.request import Request from chatballs.ai.knowledge_services import KnowledgeInput from chatballs.ai.models import Knowledge from chatballs.ai.selectors import KnowledgeFilters +from chatballs.i18n import t def _positive_id(value: object, field: str) -> int: if isinstance(value, bool): - raise ValidationError({field: "Positive integer id required"}) + raise ValidationError({field: t("api.positive_id_required")}) try: parsed = int(value) except (TypeError, ValueError) as error: - raise ValidationError({field: "Integer id required"}) from error + raise ValidationError({field: t("api.integer_id_required")}) from error if parsed <= 0: - raise ValidationError({field: "Positive integer id required"}) + raise ValidationError({field: t("api.positive_id_required")}) return parsed @@ -38,7 +39,7 @@ def knowledge_input( ) -> KnowledgeInput: raw_enabled = body.get("isEnabled", current.is_enabled if current else True) if not isinstance(raw_enabled, bool): - raise ValidationError({"isEnabled": "Boolean required"}) + raise ValidationError({"isEnabled": t("api.boolean_required")}) return KnowledgeInput( title=str(body.get("title", current.title if current else "")), description=str( @@ -59,7 +60,7 @@ def knowledge_filters(request: Request) -> KnowledgeFilters: elif raw_enabled.lower() == "false": is_enabled = False else: - raise ValidationError({"isEnabled": "Boolean required"}) + raise ValidationError({"isEnabled": t("api.boolean_required")}) agents = tuple( int(value) for value in request.query_params.getlist("agent") if value.isdigit() ) diff --git a/apps/backend/chatballs/ai/knowledge_bulk.py b/apps/backend/chatballs/ai/knowledge_bulk.py index 4a93915..862b16f 100644 --- a/apps/backend/chatballs/ai/knowledge_bulk.py +++ b/apps/backend/chatballs/ai/knowledge_bulk.py @@ -14,6 +14,7 @@ from chatballs.ai.models import ( ) from chatballs.ai.services import knowledge_for_agent_ids from chatballs.channels.models import Channel +from chatballs.i18n import t from chatballs.tenancy.context import TenantContext @@ -21,11 +22,11 @@ def _normalized_knowledge_ids(knowledge_ids: Iterable[int]) -> list[int]: normalized: list[int] = [] for knowledge_id in knowledge_ids: if isinstance(knowledge_id, bool) or not isinstance(knowledge_id, int): - raise ValidationError({"knowledgeIds": "Knowledge IDs must be integers"}) + raise ValidationError({"knowledgeIds": t("ai.knowledge_ids_integers")}) if knowledge_id not in normalized: normalized.append(knowledge_id) if not normalized: - raise ValidationError({"knowledgeIds": "At least one knowledge ID is required"}) + raise ValidationError({"knowledgeIds": t("ai.at_least_one_knowledge_id")}) return normalized @@ -40,7 +41,7 @@ def _locked_knowledge(*, context: TenantContext, knowledge_ids: Iterable[int]) - .order_by("id") ) if len(items) != len(normalized_ids): - raise ValidationError({"knowledgeIds": "Unknown knowledge item"}) + raise ValidationError({"knowledgeIds": t("ai.unknown_knowledge_item")}) return items @@ -64,7 +65,7 @@ def bulk_move_knowledge( organization_id=context.organization_id, ) except KnowledgeCategory.DoesNotExist as error: - raise ValidationError({"categoryId": "Category not found"}) from error + raise ValidationError({"categoryId": t("ai.category_not_found")}) from error items = _locked_knowledge(context=context, knowledge_ids=knowledge_ids) _require_bulk_write(context=context, items=items) now = timezone.now() @@ -87,7 +88,7 @@ def add_category_knowledge_to_agent( *, context: TenantContext, agent: AIAgent, category_id: int ) -> CategorySelectionResult: if agent.channel.organization_id != context.organization_id: - raise ValidationError({"agent": "Agent belongs to another organization"}) + raise ValidationError({"agent": t("ai.agent_other_organization")}) channel = Channel.objects.select_for_update().get( id=agent.channel_id, organization_id=context.organization_id, @@ -96,7 +97,7 @@ def add_category_knowledge_to_agent( id=category_id, organization_id=context.organization_id, ).exists(): - raise ValidationError({"categoryId": "Category not found"}) + raise ValidationError({"categoryId": t("ai.category_not_found")}) current_ids = set(agent.knowledge_items.values_list("id", flat=True)) category_ids = set( diff --git a/apps/backend/chatballs/ai/knowledge_categories.py b/apps/backend/chatballs/ai/knowledge_categories.py index 5ab7699..fd26202 100644 --- a/apps/backend/chatballs/ai/knowledge_categories.py +++ b/apps/backend/chatballs/ai/knowledge_categories.py @@ -3,6 +3,7 @@ from django.db import transaction from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME from chatballs.ai.models import KnowledgeCategory +from chatballs.i18n import t from chatballs.identity.models import Organization from chatballs.tenancy.context import TenantContext @@ -24,14 +25,14 @@ def _validate_category_context( *, context: TenantContext, category: KnowledgeCategory ) -> None: if category.organization_id != context.organization_id: - raise ValidationError({"category": "Category belongs to another organization"}) + raise ValidationError({"category": t("ai.category_other_organization")}) def _validate_parent( *, context: TenantContext, parent: KnowledgeCategory | None ) -> None: if parent is not None and parent.organization_id != context.organization_id: - raise ValidationError({"parent": "Parent category belongs to another organization"}) + raise ValidationError({"parent": t("ai.parent_category_other_organization")}) @transaction.atomic @@ -57,7 +58,7 @@ def rename_category( ) -> KnowledgeCategory: _validate_category_context(context=context, category=category) if category.is_system: - raise ValidationError({"category": "System category is immutable"}) + raise ValidationError({"category": t("ai.system_category_immutable")}) locked = KnowledgeCategory.objects.select_for_update().get(pk=category.pk) locked.name = name locked.save(update_fields=["name"]) @@ -75,7 +76,7 @@ def move_category( _validate_category_context(context=context, category=category) _validate_parent(context=context, parent=parent) if category.is_system: - raise ValidationError({"category": "System category is immutable"}) + raise ValidationError({"category": t("ai.system_category_immutable")}) locked_categories = { item.pk: item for item in KnowledgeCategory.objects.select_for_update().filter( @@ -104,7 +105,7 @@ def update_category( _validate_category_context(context=context, category=category) _validate_parent(context=context, parent=parent) if category.is_system: - raise ValidationError({"category": "System category is immutable"}) + raise ValidationError({"category": t("ai.system_category_immutable")}) locked_categories = { item.pk: item for item in KnowledgeCategory.objects.select_for_update().filter( diff --git a/apps/backend/chatballs/ai/knowledge_import.py b/apps/backend/chatballs/ai/knowledge_import.py index 0432a56..d6ab6de 100644 --- a/apps/backend/chatballs/ai/knowledge_import.py +++ b/apps/backend/chatballs/ai/knowledge_import.py @@ -13,6 +13,7 @@ from chatballs.ai.knowledge_services import ( update_knowledge, ) from chatballs.ai.models import Knowledge, KnowledgeCategory +from chatballs.i18n import t from chatballs.tenancy.context import TenantContext @@ -35,14 +36,14 @@ class KnowledgeImportResult: def _title(document: dict[str, object]) -> str: value = document.get("title") if not isinstance(value, str) or not value.strip(): - raise ValidationError({"title": "Title is required"}) + raise ValidationError({"title": t("ai.title_required")}) return value.strip() def _content(document: dict[str, object]) -> str: value = document.get("content") if not isinstance(value, str): - raise ValidationError({"content": "Content string is required"}) + raise ValidationError({"content": t("ai.content_required")}) return value @@ -53,18 +54,18 @@ def _description(document: dict[str, object], *, default: str) -> str: if value is None: return "" if not isinstance(value, str): - raise ValidationError({"description": "Description must be a string"}) + raise ValidationError({"description": t("ai.description_string")}) return value.strip() def _category_for_path(*, context: TenantContext, raw_path: object) -> KnowledgeCategory: if not isinstance(raw_path, list) or not raw_path: - raise ValidationError({"categoryPath": "Non-empty category path required"}) + raise ValidationError({"categoryPath": t("ai.category_path_required")}) parent_id = None category = None for raw_name in raw_path: if not isinstance(raw_name, str) or not raw_name.strip(): - raise ValidationError({"categoryPath": "Category names must be non-empty strings"}) + raise ValidationError({"categoryPath": t("ai.category_names_strings")}) try: category = KnowledgeCategory.objects.get( organization_id=context.organization_id, @@ -72,7 +73,7 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge name=raw_name.strip(), ) except KnowledgeCategory.DoesNotExist as error: - raise ValidationError({"categoryPath": "Category path not found"}) from error + raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error parent_id = category.id assert category is not None return category @@ -153,7 +154,7 @@ def import_knowledge_documents( title = raw_title.strip() if isinstance(raw_title, str) else "" try: if not isinstance(raw_document, dict): - raise ValidationError({"document": "Document must be an object"}) + raise ValidationError({"document": t("ai.document_object_required")}) outcome = _import_document(context=context, document=raw_document) except ValidationError as error: result.failed.append({"title": title, "detail": _validation_detail(error)}) diff --git a/apps/backend/chatballs/ai/knowledge_models.py b/apps/backend/chatballs/ai/knowledge_models.py index a5f649c..b9bd310 100644 --- a/apps/backend/chatballs/ai/knowledge_models.py +++ b/apps/backend/chatballs/ai/knowledge_models.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME +from chatballs.i18n import t from chatballs.tenancy.models import TenantRelationModel @@ -42,23 +43,23 @@ class KnowledgeCategory(TenantRelationModel): super().clean() self.name = self.name.strip() if not self.name: - raise ValidationError({"name": "Category name is required"}) + raise ValidationError({"name": t("ai.category_name_required")}) persisted = None if self.pk is not None: persisted = type(self).objects.filter(pk=self.pk).values("is_system").first() if persisted and persisted["is_system"] and not self.is_system: - raise ValidationError({"category": "System category is immutable"}) + raise ValidationError({"category": t("ai.system_category_immutable")}) if self.is_system and self.name != UNCATEGORIZED_CATEGORY_NAME: - raise ValidationError({"name": "System category name is immutable"}) + raise ValidationError({"name": t("ai.system_category_name_immutable")}) ancestor = self.parent visited: set[int] = set() while ancestor is not None: if self.pk is not None and ancestor.pk == self.pk: - raise ValidationError({"parent": "Category cycle is not allowed"}) + raise ValidationError({"parent": t("ai.category_cycle")}) if ancestor.pk is not None: if ancestor.pk in visited: - raise ValidationError({"parent": "Category cycle is not allowed"}) + raise ValidationError({"parent": t("ai.category_cycle")}) visited.add(ancestor.pk) ancestor = ancestor.parent @@ -68,11 +69,11 @@ class KnowledgeCategory(TenantRelationModel): def delete(self, *args: object, **kwargs: object): if self.is_system: - raise ValidationError({"category": "System category cannot be deleted"}) + raise ValidationError({"category": t("ai.system_category_undeletable")}) if self.children.exists(): - raise ValidationError({"category": "Category with children cannot be deleted"}) + raise ValidationError({"category": t("ai.category_with_children")}) if self.knowledge_items.exists(): - raise ValidationError({"category": "Category with knowledge cannot be deleted"}) + raise ValidationError({"category": t("ai.category_with_knowledge")}) return super().delete(*args, **kwargs) def __str__(self) -> str: diff --git a/apps/backend/chatballs/ai/knowledge_services.py b/apps/backend/chatballs/ai/knowledge_services.py index 1cd9b06..432ce98 100644 --- a/apps/backend/chatballs/ai/knowledge_services.py +++ b/apps/backend/chatballs/ai/knowledge_services.py @@ -12,6 +12,7 @@ from chatballs.ai.models import ( KnowledgeAttachment, KnowledgeCategory, ) +from chatballs.i18n import t from chatballs.tenancy.context import TenantContext from chatballs.tenancy.storage import adjust_storage_usage from chatballs.tenancy.storage_quota import ( @@ -39,13 +40,13 @@ def _knowledge_category(*, context: TenantContext, category_id: int | None) -> K id=category_id, ) except KnowledgeCategory.DoesNotExist as error: - raise ValidationError({"category": "Category not found"}) from error + raise ValidationError({"category": t("ai.category_not_found")}) from error @transaction.atomic def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowledge: if not data.title.strip(): - raise ValidationError({"title": "Title is required"}) + raise ValidationError({"title": t("ai.title_required")}) knowledge = Knowledge.objects.create( organization=context.organization, category=_knowledge_category(context=context, category_id=data.category_id), @@ -63,9 +64,9 @@ def update_knowledge( *, context: TenantContext, knowledge: Knowledge, data: KnowledgeInput ) -> Knowledge: if knowledge.organization_id != context.organization_id: - raise ValidationError({"knowledge": "Knowledge belongs to another organization"}) + raise ValidationError({"knowledge": t("ai.knowledge_other_organization")}) if not data.title.strip(): - raise ValidationError({"title": "Title is required"}) + raise ValidationError({"title": t("ai.title_required")}) locked = Knowledge.objects.select_for_update().get(pk=knowledge.pk) content_changed = locked.content != data.content locked.title = data.title.strip() @@ -94,7 +95,7 @@ def update_knowledge( def delete_knowledge(*, context: TenantContext, knowledge: Knowledge) -> None: if knowledge.organization_id != context.organization_id: - raise ValidationError({"knowledge": "Knowledge belongs to another organization"}) + raise ValidationError({"knowledge": t("ai.knowledge_other_organization")}) attachments = list(knowledge.attachments.all()) released_bytes = sum(attachment.size for attachment in attachments) for attachment in attachments: @@ -112,12 +113,12 @@ def add_attachment( *, context: TenantContext, knowledge: Knowledge, upload: UploadedFile ) -> KnowledgeAttachment: if knowledge.organization_id != context.organization_id: - raise ValidationError({"knowledge": "Knowledge belongs to another organization"}) + raise ValidationError({"knowledge": t("ai.knowledge_other_organization")}) original_name = (upload.name or "").strip() if not original_name: - raise ValidationError({"file": "File name is required"}) + raise ValidationError({"file": t("ai.file_name_required")}) if upload.size and upload.size > _MAX_ATTACHMENT_BYTES: - raise ValidationError({"file": "File is too large (max 25 MB)"}) + raise ValidationError({"file": t("ai.file_too_large_25")}) existing = knowledge.attachments.filter(original_name=original_name).first() existing_size = existing.size if existing is not None else 0 data = upload.read() @@ -153,7 +154,7 @@ def add_attachment( def delete_attachment(*, context: TenantContext, attachment: KnowledgeAttachment) -> None: if attachment.knowledge.organization_id != context.organization_id: - raise ValidationError({"attachment": "Attachment belongs to another organization"}) + raise ValidationError({"attachment": t("ai.attachment_other_organization")}) knowledge = attachment.knowledge released_bytes = attachment.size attachment.file.delete(save=False) diff --git a/apps/backend/chatballs/ai/migrations/0017_agent_answer_language.py b/apps/backend/chatballs/ai/migrations/0017_agent_answer_language.py new file mode 100644 index 0000000..6a9662a --- /dev/null +++ b/apps/backend/chatballs/ai/migrations/0017_agent_answer_language.py @@ -0,0 +1,23 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + """Язык ответов агента. + + Существующие агенты получают MIRROR — ответ на языке обращения. Для + одноязычной установки это ничего не меняет: клиенты пишут на её языке, и + агент отвечает так же. Для двуязычной — сразу перестаёт отвечать не на том + языке, на котором спросили. + """ + + dependencies = [ + ("ai", "0016_remove_credential_mode"), + ] + + operations = [ + migrations.AddField( + model_name="aiagent", + name="answer_language", + field=models.CharField(default="MIRROR", max_length=16), + ), + ] diff --git a/apps/backend/chatballs/ai/models.py b/apps/backend/chatballs/ai/models.py index 7b0b2da..c97598a 100644 --- a/apps/backend/chatballs/ai/models.py +++ b/apps/backend/chatballs/ai/models.py @@ -14,6 +14,13 @@ DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6" +class AnswerLanguage(models.TextChoices): + """Режимы поля ``AIAgent.answer_language``, кроме кодов самих языков.""" + + MIRROR = "MIRROR", "Как у клиента" + ORGANIZATION = "ORGANIZATION", "Язык организации" + + class AIAgentStatus(models.TextChoices): DRAFT = "DRAFT", "Draft" @@ -369,6 +376,19 @@ class AIAgent(TenantRelationModel): model_params = models.JSONField(default=dict, blank=True) + # Язык ответов клиенту. По умолчанию агент отвечает на языке, на котором + # к нему обратились: сигнал точный, лежит прямо в сообщении и не требует + # настройки. База знаний при этом остаётся одноязычной — поиск ведёт + # семантическая ветка (ai.retrieval), а эмбеддинги кроссязычные. + # + # Хранится либо режим (MIRROR, ORGANIZATION), либо код языка из + # chatballs.i18n.LANGUAGE_CODES — для тех, кому нужен ровно один язык + # независимо от того, на каком языке пришло сообщение. + answer_language = models.CharField( + max_length=16, + default=AnswerLanguage.MIRROR, + ) + # Инструкции из трёх частей; системный промпт собирается в этом порядке. persona = models.TextField(blank=True) # кто он и что он diff --git a/apps/backend/chatballs/ai/provider/base.py b/apps/backend/chatballs/ai/provider/base.py index 8ea450b..475d108 100644 --- a/apps/backend/chatballs/ai/provider/base.py +++ b/apps/backend/chatballs/ai/provider/base.py @@ -3,6 +3,8 @@ from __future__ import annotations import abc from dataclasses import dataclass +from chatballs.i18n import t + @dataclass(frozen=True) class ChatMessage: @@ -48,4 +50,4 @@ class LLMProvider(abc.ABC): def transcribe(self, *, audio: bytes, filename: str, content_type: str, model: str) -> str: """Расшифровка аудио (дизайн-базлайн v2). Реализуется OpenAI-совместимыми адаптерами (POST /audio/transcriptions); остальные явно отказывают.""" - raise ProviderError(f"Провайдер {self.name} не поддерживает расшифровку аудио") + raise ProviderError(t("ai.provider_no_transcription", provider=self.name)) diff --git a/apps/backend/chatballs/ai/provider/demo.py b/apps/backend/chatballs/ai/provider/demo.py index 7d2e7fa..7991b3e 100644 --- a/apps/backend/chatballs/ai/provider/demo.py +++ b/apps/backend/chatballs/ai/provider/demo.py @@ -27,27 +27,63 @@ from chatballs.i18n import t EMBEDDING_DIM = 16 KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:" HANDOFF_TOKEN = "<>" -HUMAN_REQUEST_WORDS = ("человек", "оператор", "сотрудник", "менеджер", "живой", "жалоб", "верн", "возврат") +# Язык демо-агента определяется по письму клиента — тем же правилом, что и у +# настоящего агента (AnswerLanguage.MIRROR). Здесь оно грубое, по алфавиту: +# провайдер детерминированный и без сети, распознавать язык ему нечем. +CYRILLIC = re.compile(r"[а-яё]", re.IGNORECASE) + +# Слова, по которым видно, что клиент просит живого человека. +HUMAN_REQUEST_WORDS = { + "ru": ("человек", "оператор", "сотрудник", "менеджер", "живой", "жалоб", "верн", "возврат"), + "en": ("human", "operator", "agent", "manager", "person", "complain", "refund", "return"), +} _WORD = re.compile(r"[а-яёa-z0-9]+", re.IGNORECASE) _HEADER = re.compile(r"^\s*\[[^\]]{1,120}\]\s*") _STOP = { - "и", "в", "на", "с", "по", "у", "а", "но", "не", "что", "как", "это", "для", "до", "от", - "за", "из", "к", "о", "же", "ли", "бы", "вы", "мы", "я", "он", "она", "они", "мне", "вас", - "есть", "можно", "нужно", "хочу", "подскажите", "здравствуйте", "добрый", "день", "the", + "ru": { + "и", "в", "на", "с", "по", "у", "а", "но", "не", "что", "как", "это", "для", "до", "от", + "за", "из", "к", "о", "же", "ли", "бы", "вы", "мы", "я", "он", "она", "они", "мне", "вас", + "есть", "можно", "нужно", "хочу", "подскажите", "здравствуйте", "добрый", "день", + }, + "en": { + "the", "and", "for", "you", "your", "with", "from", "that", "this", "are", "was", "can", + "could", "would", "have", "has", "not", "but", "our", "what", "how", "when", "where", + "please", "hello", "hi", "there", "want", "need", "tell", "about", "will", "its", + }, +} + +# Грубая морфология: смысл в том, чтобы «доставка» ≈ «доставку», а +# «deliveries» ≈ «delivery». Настоящий стеммер тут был бы зависимостью ради +# демо-стенда. +_SUFFIXES = { + "ru": ( + "ами", "ями", "ого", "его", "ому", "ему", "ыми", "ими", "ах", "ях", "ов", "ев", "ам", + "ям", "ой", "ей", "ую", "юю", "ая", "яя", "ые", "ие", "ть", "ся", "а", "я", "ы", "и", + "у", "ю", "е", "о", + ), + "en": ("ing", "ies", "ied", "ers", "es", "ed", "er", "ly", "s"), } -def _tokens(text: str) -> set[str]: +def _language_of(text: str) -> str: + """Язык письма клиента: кириллица — русский, иначе английский.""" + + return "ru" if CYRILLIC.search(text) else "en" + + +def _tokens(text: str, language: str) -> set[str]: + stop = _STOP[language] return { _stem(word.lower()) for word in _WORD.findall(text) - if len(word) > 2 and word.lower() not in _STOP + if len(word) > 2 and word.lower() not in stop } def _stem(word: str) -> str: - # Грубая морфология: обрезаем частые русские окончания, чтобы «доставка» ≈ «доставку». - for suffix in ("ами", "ями", "ого", "его", "ому", "ему", "ыми", "ими", "ах", "ях", "ов", "ев", "ам", "ям", "ой", "ей", "ую", "юю", "ая", "яя", "ые", "ие", "ть", "ся", "а", "я", "ы", "и", "у", "ю", "е", "о"): + # Слово может быть на другом языке, чем письмо (знания одноязычные), поэтому + # окончания режем по алфавиту самого слова, а не по языку диалога. + for suffix in _SUFFIXES[_language_of(word)]: if len(word) > 4 and word.endswith(suffix): return word[: -len(suffix)] return word @@ -86,12 +122,13 @@ def _knowledge_sentences(messages: list[ChatMessage]) -> list[str]: def compose_reply(messages: list[ChatMessage]) -> tuple[str, bool]: """Ответ по знаниям и признак передачи оператору.""" question = next((m.content for m in reversed(messages) if m.role == "user"), "") + language = _language_of(question) lowered = question.lower() - wants_human = any(word in lowered for word in HUMAN_REQUEST_WORDS) - query = _tokens(question) + wants_human = any(word in lowered for word in HUMAN_REQUEST_WORDS[language]) + query = _tokens(question, language) scored = [] for index, sentence in enumerate(_knowledge_sentences(messages)): - overlap = len(query & _tokens(sentence)) + overlap = len(query & _tokens(sentence, language)) if overlap: scored.append((-overlap, index, sentence)) scored.sort() @@ -99,13 +136,8 @@ def compose_reply(messages: list[ChatMessage]) -> tuple[str, bool]: if best and not wants_human: return " ".join(best), False if best: - return ( - " ".join(best) - + " Передаю диалог сотруднику — он поможет дальше." - ), True - return ( - "Уточню этот вопрос у коллег и передам диалог сотруднику — он ответит в рабочее время." - ), True + return " ".join(best) + t("ai.demo_handover_suffix", language=language), True + return t("ai.demo_handover", language=language), True class DemoProvider(LLMProvider): diff --git a/apps/backend/chatballs/ai/provider/openai_http.py b/apps/backend/chatballs/ai/provider/openai_http.py index 358de71..cc3c96f 100644 --- a/apps/backend/chatballs/ai/provider/openai_http.py +++ b/apps/backend/chatballs/ai/provider/openai_http.py @@ -38,6 +38,7 @@ import urllib.error import urllib.request from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError +from chatballs.i18n import t from chatballs.integrations.proxy import build_opener @@ -155,7 +156,7 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage] except (KeyError, IndexError, TypeError) as error: - raise ProviderError(f"Unexpected provider response: {error}") from error + raise ProviderError(t("ai.unexpected_provider_response", error=error)) from error usage = data.get("usage") or {} @@ -195,7 +196,7 @@ def embeddings(*, base_url: str, api_key: str, texts: list[str], model: str, except (KeyError, TypeError) as error: - raise ProviderError(f"Unexpected provider response: {error}") from error + raise ProviderError(t("ai.unexpected_provider_response", error=error)) from error usage = data.get("usage") or {} diff --git a/apps/backend/chatballs/ai/provider/openrouter.py b/apps/backend/chatballs/ai/provider/openrouter.py index 7b5852f..666777b 100644 --- a/apps/backend/chatballs/ai/provider/openrouter.py +++ b/apps/backend/chatballs/ai/provider/openrouter.py @@ -66,9 +66,9 @@ class OpenRouterProvider(LLMProvider): payload = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as error: detail = error.read().decode("utf-8", "replace")[:300] - raise ProviderError(f"Расшифровка не удалась: HTTP {error.code} {detail}") from error + raise ProviderError(t("ai.transcription_failed_http", code=error.code, detail=detail)) from error except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error: - raise ProviderError(f"Расшифровка не удалась: {error}") from error + raise ProviderError(t("ai.transcription_failed", error=error)) from error text = str(payload.get("text") or "").strip() if not text: raise ProviderError(t("ai.empty_transcript")) diff --git a/apps/backend/chatballs/ai/provider/routing.py b/apps/backend/chatballs/ai/provider/routing.py index 5fb4d1f..dfb633e 100644 --- a/apps/backend/chatballs/ai/provider/routing.py +++ b/apps/backend/chatballs/ai/provider/routing.py @@ -21,6 +21,7 @@ at runtime and overrides `AIAgent.model`. from __future__ import annotations +from chatballs.i18n import t from chatballs.ai.provider.base import LLMProvider, ProviderError from chatballs.ai.provider.custom import CustomProvider from chatballs.ai.provider.demo import DemoProvider @@ -79,7 +80,7 @@ def _channel_integration(channel) -> Integration: integration = getattr(channel, "provider_integration", None) if integration is None or not integration.secret: raise IntegrationNotConfigured( - "Агент не привязан к LLM-интеграции BYOK; выберите провайдера в настройках агента" + t("ai.agent_without_llm_integration") ) return integration @@ -104,5 +105,5 @@ def _provider_from_integration(integration: Integration) -> LLMProvider: proxy_url=integration.config.get("proxy_url", ""), ) raise IntegrationNotConfigured( - f"Интеграция «{integration.provider}» не является LLM-провайдером BYOK" + t("ai.integration_is_not_llm", provider=integration.provider) ) diff --git a/apps/backend/chatballs/ai/provider_selection.py b/apps/backend/chatballs/ai/provider_selection.py index 7803af5..ce3aae2 100644 --- a/apps/backend/chatballs/ai/provider_selection.py +++ b/apps/backend/chatballs/ai/provider_selection.py @@ -11,6 +11,7 @@ from dataclasses import dataclass from django.core.exceptions import ValidationError +from chatballs.i18n import t from chatballs.integrations.models import ( Integration, IntegrationKind, @@ -35,15 +36,23 @@ def configure_agent_provider( id=integration_id, organization_id=context.organization_id, kind=IntegrationKind.LLM_PROVIDER, - provider__in=[IntegrationProvider.OPENROUTER, IntegrationProvider.CUSTOM], + # DEMO — полноценный провайдер агента, а не заглушка настроек: + # на нём работает демо-стенд сразу после установки, без ключей. + # Без него любое сохранение агента демо-организации падало на + # «Неизвестная интеграция», хотя менялись инструкции, а не провайдер. + provider__in=[ + IntegrationProvider.OPENROUTER, + IntegrationProvider.CUSTOM, + IntegrationProvider.DEMO, + ], ) except (Integration.DoesNotExist, TypeError, ValueError) as error: raise ValidationError( - {"providerIntegrationId": "Unknown OpenRouter or Custom integration"} + {"providerIntegrationId": t("ai.unknown_provider_integration")} ) from error model = str(integration.config.get("default_model", "")).strip() if not model: raise ValidationError( - {"providerIntegrationId": "Integration default model is required"} + {"providerIntegrationId": t("ai.integration_model_required")} ) return ProviderSelection(model, integration) diff --git a/apps/backend/chatballs/ai/runtime.py b/apps/backend/chatballs/ai/runtime.py index d121352..cd13fce 100644 --- a/apps/backend/chatballs/ai/runtime.py +++ b/apps/backend/chatballs/ai/runtime.py @@ -5,11 +5,15 @@ from chatballs.ai.agent_knowledge import ( runtime_portal_articles_for_agent, ) from chatballs.ai.invocation import invoke_chat -from chatballs.ai.models import AIAgent, KnowledgeFragment +from chatballs.ai.models import AIAgent, AnswerLanguage, KnowledgeFragment from chatballs.ai.provider.base import ChatMessage, ChatResult from chatballs.ai.retrieval import KnowledgeRetriever +from chatballs.i18n import LANGUAGES, customer_language, normalize_language from chatballs.support_portals.addressing import article_public_url +# Название языка — на нём самом: модели так однозначнее, чем «английский». +LANGUAGE_LABELS = dict(LANGUAGES) + # Гард стиля для мессенджеров: гарантирует простой текст вне зависимости от # того, что написано в авторских инструкциях. MESSENGER_STYLE_GUARD = ( @@ -31,6 +35,36 @@ HANDOFF_PROTOCOL = ( ) +# Язык ответа. Директива стоит отдельной строкой и последней среди системных: +# промпт написан по-русски и сам по себе тянет ответ в русский язык, а явное +# указание это перебивает. Переводить сам промпт не нужно — его читает модель, +# а не человек. +ANSWER_IN_CUSTOMER_LANGUAGE = ( + "Отвечай на том языке, на котором написано последнее сообщение клиента. " + "Если язык определить не удалось, отвечай на языке предыдущей переписки." +) +ANSWER_IN_FIXED_LANGUAGE = ( + "Отвечай всегда на языке «{language}», независимо от того, на каком языке " + "написал клиент." +) + + +def answer_language_directive(agent: AIAgent) -> str: + """Строка системного промпта, задающая язык ответа агента.""" + + if agent.answer_language == AnswerLanguage.MIRROR: + return ANSWER_IN_CUSTOMER_LANGUAGE + if agent.answer_language == AnswerLanguage.ORGANIZATION: + code = customer_language(agent.channel.organization) + else: + code = normalize_language(agent.answer_language) + if not code: + # Код испортили руками или язык убрали из сборки: зеркало клиента + # безопаснее молчания — ответ всё равно попадёт в язык обращения. + return ANSWER_IN_CUSTOMER_LANGUAGE + return ANSWER_IN_FIXED_LANGUAGE.format(language=LANGUAGE_LABELS[code]) + + @dataclass(frozen=True) class AgentTurnResult: result: ChatResult @@ -102,6 +136,7 @@ def run_agent_turn( messages.append( ChatMessage(role="system", content=MESSENGER_STYLE_GUARD + "\n\n" + HANDOFF_PROTOCOL) ) + messages.append(ChatMessage(role="system", content=answer_language_directive(agent))) catalog = knowledge_catalog(agent) if catalog: messages.append(ChatMessage(role="system", content=catalog)) diff --git a/apps/backend/chatballs/ai/services.py b/apps/backend/chatballs/ai/services.py index d0b3b48..7f29767 100644 --- a/apps/backend/chatballs/ai/services.py +++ b/apps/backend/chatballs/ai/services.py @@ -25,6 +25,7 @@ class AgentInput: persona: str tone: str instructions: str + answer_language: str knowledge_ids: list[int] | None # None -> выбор знаний не меняется @@ -72,7 +73,7 @@ def knowledge_for_agent_ids( .order_by("id") ) if len(items) != len(requested_ids): - raise ValidationError({"knowledgeIds": "Unknown or unavailable knowledge item"}) + raise ValidationError({"knowledgeIds": t("ai.knowledge_unknown_or_unavailable")}) return items @@ -80,16 +81,16 @@ def knowledge_for_agent_ids( def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent: organization = context.organization if not data.channel_code: - raise ValidationError({"channel": "Channel is required"}) + raise ValidationError({"channel": t("ai.channel_required")}) try: channel = Channel.objects.select_for_update().get( organization=organization, code=data.channel_code, ) except Channel.DoesNotExist as error: - raise ValidationError({"channel": "Channel not found"}) from error + raise ValidationError({"channel": t("channels.not_found")}) from error if AIAgent.objects.filter(channel=channel).exists(): - raise ValidationError({"channel": "Channel already has an AI agent"}) + raise ValidationError({"channel": t("channels.agent_exists")}) knowledge_items = knowledge_for_agent_ids( context=context, @@ -117,7 +118,7 @@ def create_agent(*, context: TenantContext, data: AgentCreateInput) -> AIAgent: @transaction.atomic def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> AIAgent: if agent.channel.organization_id != context.organization_id: - raise ValidationError({"agent": "Agent belongs to another organization"}) + raise ValidationError({"agent": t("ai.agent_other_organization")}) channel = Channel.objects.select_for_update().get( id=agent.channel_id, organization_id=context.organization_id, @@ -149,6 +150,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> locked.persona = data.persona locked.tone = data.tone locked.instructions = data.instructions + locked.answer_language = data.answer_language locked.save( update_fields=[ "name", @@ -160,6 +162,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> "persona", "tone", "instructions", + "answer_language", "updated_at", ] ) @@ -173,7 +176,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool) """Смена статуса AI без тарифных слотов (ADR-CHATBALLS-0042 §2): количество активных агентов не ограничено; активация требует настроенного провайдера.""" if agent.channel.organization_id != context.organization_id: - raise ValidationError({"agent": "Agent belongs to another organization"}) + raise ValidationError({"agent": t("ai.agent_other_organization")}) locked = AIAgent.objects.select_for_update().get( pk=agent.id, organization_id=context.organization_id ) @@ -181,7 +184,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool) if locked.status == target_status: return locked if locked.status == AIAgentStatus.ARCHIVED: - raise ValidationError({"agent": "Archived AI agent cannot change state"}) + raise ValidationError({"agent": t("ai.archived_agent_state")}) if is_active and locked.provider_integration_id is None: raise ValidationError( {"providerIntegrationId": t("ai.pick_provider_first")} diff --git a/apps/backend/chatballs/ai/test_answer_language.py b/apps/backend/chatballs/ai/test_answer_language.py new file mode 100644 index 0000000..903f13d --- /dev/null +++ b/apps/backend/chatballs/ai/test_answer_language.py @@ -0,0 +1,64 @@ +"""Язык ответов агента: директива системного промпта и её режимы. + +Язык ответа задаётся не переводом промпта, а отдельной строкой: промпт читает +модель, и переводить его незачем, — но написан он по-русски и сам по себе +тянет ответ в русский язык. Явное указание это перебивает. +""" + +from __future__ import annotations + +from django.test import TestCase + +from chatballs.ai.models import AIAgent, AnswerLanguage +from chatballs.ai.runtime import ( + ANSWER_IN_CUSTOMER_LANGUAGE, + answer_language_directive, +) +from chatballs.channels.models import Channel +from chatballs.identity.bootstrap import bootstrap_owner +from chatballs.identity.models import Organization + + +class AnswerLanguageDirectiveTests(TestCase): + def setUp(self) -> None: + bootstrap_owner(email="owner@example.com", password="temporary-password") + self.organization = Organization.objects.get(slug="demo") + self.channel = Channel.objects.create( + organization=self.organization, code="main", name="Main" + ) + + def _agent(self, answer_language: str) -> AIAgent: + return AIAgent( + organization=self.organization, + channel=self.channel, + name="Agent", + answer_language=answer_language, + ) + + def test_default_is_the_language_of_the_client(self) -> None: + self.assertEqual(AIAgent().answer_language, AnswerLanguage.MIRROR) + self.assertEqual( + answer_language_directive(self._agent(AnswerLanguage.MIRROR)), + ANSWER_IN_CUSTOMER_LANGUAGE, + ) + + def test_fixed_language_names_it_in_the_language_itself(self) -> None: + # Название языка на нём самом («English», а не «английский»): модели так + # однозначнее, и перевода названия не требуется. + directive = answer_language_directive(self._agent("en")) + self.assertIn("English", directive) + self.assertNotEqual(directive, ANSWER_IN_CUSTOMER_LANGUAGE) + + def test_organization_mode_follows_the_organization(self) -> None: + self.organization.language = "en" + self.organization.save(update_fields=["language"]) + directive = answer_language_directive(self._agent(AnswerLanguage.ORGANIZATION)) + self.assertIn("English", directive) + + def test_unknown_value_falls_back_to_the_client_language(self) -> None: + # Значение испортили руками или язык убрали из сборки: зеркало клиента + # безопаснее молчания — ответ всё равно попадёт в язык обращения. + self.assertEqual( + answer_language_directive(self._agent("klingon")), + ANSWER_IN_CUSTOMER_LANGUAGE, + ) diff --git a/apps/backend/chatballs/ai/test_demo_provider.py b/apps/backend/chatballs/ai/test_demo_provider.py index 5619bc6..7bc006a 100644 --- a/apps/backend/chatballs/ai/test_demo_provider.py +++ b/apps/backend/chatballs/ai/test_demo_provider.py @@ -89,3 +89,45 @@ class DemoIntegrationTests(TestCase): checked = run_integration_test(context=self.context, integration=integration) self.assertEqual(checked.status, IntegrationStatus.OK) self.assertIsInstance(_provider_from_integration(Integration.objects.get(pk=integration.pk)), DemoProvider) + + +ENGLISH_KNOWLEDGE = """Отвечай только на основе этих знаний: +Making takes 5-7 working days after payment. Delivery across the country is DPD, \ +3-5 working days. The London courier calls an hour ahead. +Ready-made items can be returned within 14 days.""" + + +class DemoProviderLanguageTests(TestCase): + """Демо-агент отвечает на языке обращения — как настоящий в режиме MIRROR.""" + + def test_english_question_is_answered_from_english_knowledge(self) -> None: + result = DemoProvider().chat( + messages=[ + ChatMessage(role="system", content=ENGLISH_KNOWLEDGE), + ChatMessage(role="user", content="How long does delivery take?"), + ], + model="demo", + ) + self.assertIn("DPD", result.text) + self.assertNotIn(HANDOFF_TOKEN, result.text) + + def test_english_request_for_human_hands_off_in_english(self) -> None: + result = DemoProvider().chat( + messages=[ + ChatMessage(role="system", content=ENGLISH_KNOWLEDGE), + ChatMessage(role="user", content="I want to speak to a human"), + ], + model="demo", + ) + self.assertIn(HANDOFF_TOKEN, result.text) + self.assertNotRegex(result.text, r"[А-Яа-яЁё]") + + def test_russian_question_still_answers_in_russian(self) -> None: + result = DemoProvider().chat( + messages=[ + ChatMessage(role="system", content=KNOWLEDGE), + ChatMessage(role="user", content="Сколько идёт доставка?"), + ], + model="demo", + ) + self.assertIn("СДЭК", result.text) diff --git a/apps/backend/chatballs/ai/test_provider_modes.py b/apps/backend/chatballs/ai/test_provider_modes.py index 0a49a2e..d0a9211 100644 --- a/apps/backend/chatballs/ai/test_provider_modes.py +++ b/apps/backend/chatballs/ai/test_provider_modes.py @@ -159,6 +159,28 @@ class AgentProviderOwnershipTests(TestCase): with self.assertRaises(ValidationError): configure_agent_provider(context=self.context, integration_id=999999) + def test_demo_provider_is_a_valid_agent_provider(self) -> None: + """Регрессия: демо-стенд нельзя было редактировать. + + Демо ставит агентов на встроенный DEMO-провайдер, а выбор провайдера + его не принимал. Поле providerIntegrationId уходит с каждым PATCH + карточки, поэтому падало любое сохранение агента — даже правка + инструкций, где провайдер не меняли. + """ + + demo = create_integration( + context=self.context, + data=IntegrationInput( + provider=IntegrationProvider.DEMO, + name="Демо-провайдер", + secret="", + config={}, + ), + ) + selection = configure_agent_provider(context=self.context, integration_id=demo.id) + self.assertEqual(selection.integration, demo) + self.assertEqual(selection.model, "demo") + def test_selection_never_writes_to_the_channel(self) -> None: integration = self._integration() selection = configure_agent_provider( diff --git a/apps/backend/chatballs/ai/views.py b/apps/backend/chatballs/ai/views.py index dcd1723..fe1d77c 100644 --- a/apps/backend/chatballs/ai/views.py +++ b/apps/backend/chatballs/ai/views.py @@ -30,6 +30,7 @@ from chatballs.ai.selectors import ( from chatballs.ai.serializers import attachment_payload, knowledge_payload from chatballs.api.pagination import page_payload, paginate from chatballs.api.permissions import HasCapability +from chatballs.i18n import t from chatballs.identity.audit import record_audit_event from chatballs.identity.models import AuditEvent @@ -122,7 +123,7 @@ class KnowledgeDetailView(_KnowledgeBaseView): try: knowledge = self._read_knowledge(request, knowledge_id) except Knowledge.DoesNotExist: - return Response({"detail": "Knowledge not found"}, status=404) + return Response({"detail": t("ai.knowledge_not_found")}, status=404) payload = knowledge_payload(knowledge) created_event = ( AuditEvent.objects.filter( @@ -162,7 +163,7 @@ class KnowledgeDetailView(_KnowledgeBaseView): try: knowledge = self._write_knowledge(request, knowledge_id) except Knowledge.DoesNotExist: - return Response({"detail": "Knowledge not found"}, status=404) + return Response({"detail": t("ai.knowledge_not_found")}, status=404) try: data = knowledge_input(request.data, current=knowledge) if not employee_can_write_knowledge( @@ -185,7 +186,7 @@ class KnowledgeDetailView(_KnowledgeBaseView): try: knowledge = self._write_knowledge(request, knowledge_id) except Knowledge.DoesNotExist: - return Response({"detail": "Knowledge not found"}, status=404) + return Response({"detail": t("ai.knowledge_not_found")}, status=404) self._audit(request, "deleted", knowledge) delete_knowledge(context=request.tenant_context, knowledge=knowledge) return Response(status=204) @@ -195,7 +196,7 @@ class KnowledgeImportView(_KnowledgeBaseView): def post(self, request: Request) -> Response: documents = request.data.get("documents") if not isinstance(documents, list) or not documents: - return Response({"detail": "documents must be a non-empty list"}, status=400) + return Response({"detail": t("ai.documents_non_empty")}, status=400) result = import_knowledge_documents( context=request.tenant_context, documents=documents, @@ -222,7 +223,7 @@ class KnowledgeReindexView(_KnowledgeBaseView): try: knowledge = self._write_knowledge(request, knowledge_id) except Knowledge.DoesNotExist: - return Response({"detail": "Knowledge not found"}, status=404) + return Response({"detail": t("ai.knowledge_not_found")}, status=404) reindex_knowledge(knowledge) self._audit(request, "reindexed", knowledge) knowledge = self._write_knowledge(request, knowledge_id) @@ -236,10 +237,10 @@ class KnowledgeAttachmentUploadView(_KnowledgeBaseView): try: knowledge = self._write_knowledge(request, knowledge_id) except Knowledge.DoesNotExist: - return Response({"detail": "Knowledge not found"}, status=404) + return Response({"detail": t("ai.knowledge_not_found")}, status=404) upload = request.FILES.get("file") if upload is None: - return Response({"detail": "file is required (multipart/form-data)"}, status=400) + return Response({"detail": t("ai.file_required")}, status=400) try: attachment = add_attachment( context=request.tenant_context, knowledge=knowledge, upload=upload @@ -255,10 +256,10 @@ class KnowledgeAttachmentDeleteView(_KnowledgeBaseView): try: knowledge = self._write_knowledge(request, knowledge_id) except Knowledge.DoesNotExist: - return Response({"detail": "Knowledge not found"}, status=404) + return Response({"detail": t("ai.knowledge_not_found")}, status=404) attachment = knowledge.attachments.filter(id=attachment_id).first() if attachment is None: - return Response({"detail": "Attachment not found"}, status=404) + return Response({"detail": t("ai.attachment_not_found")}, status=404) delete_attachment(context=request.tenant_context, attachment=attachment) self._audit(request, "attachment_deleted", knowledge) return Response(status=204) diff --git a/apps/backend/chatballs/api/pagination.py b/apps/backend/chatballs/api/pagination.py index 9080643..fd04040 100644 --- a/apps/backend/chatballs/api/pagination.py +++ b/apps/backend/chatballs/api/pagination.py @@ -43,9 +43,9 @@ def _bounded_int(params, name: str, default: int, maximum: int) -> int: try: value = int(raw) except (TypeError, ValueError): - raise ValidationError(f"{name}: ожидается целое число") from None + raise ValidationError(t("api.expected_integer", name=name)) from None if value < 1: - raise ValidationError(f"{name}: ожидается число больше нуля") + raise ValidationError(t("api.expected_positive", name=name)) return min(value, maximum) @@ -57,9 +57,9 @@ def cursor_id(params, name: str = "cursor") -> int | None: try: value = int(raw) except (TypeError, ValueError): - raise ValidationError(f"{name}: ожидается идентификатор записи") from None + raise ValidationError(t("api.expected_record_id", name=name)) from None if value < 1: - raise ValidationError(f"{name}: ожидается идентификатор записи") + raise ValidationError(t("api.expected_record_id", name=name)) return value diff --git a/apps/backend/chatballs/calls/event_handlers.py b/apps/backend/chatballs/calls/event_handlers.py index 99c95cc..216ce27 100644 --- a/apps/backend/chatballs/calls/event_handlers.py +++ b/apps/backend/chatballs/calls/event_handlers.py @@ -14,6 +14,7 @@ from chatballs.calls.models import CallInvite, CallSession, CallStatus, InviteDe from chatballs.calls.services import CALL_INVITE_SEND from chatballs.calls.tokens import issue_invite_token from chatballs.conversations import transports +from chatballs.i18n import customer_language, t from chatballs.events.handlers import register from chatballs.identity.instance_settings import public_base_url from chatballs.tenancy.context import TenantContext @@ -49,8 +50,11 @@ def handle_call_invite_send(payload: dict, context: TenantContext | None) -> Non token, token_hash = issue_invite_token() invite.token_hash = token_hash invite.save(update_fields=["token_hash"]) - call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок" - invite_text = f"Приглашаем вас на {call_label}. Нажмите кнопку, чтобы перейти к звонку." + # Приглашение читает клиент, а не оператор, и рождается оно в воркере, + # где запроса нет: язык берём у организации. + language = customer_language(call.conversation.organization) + call_label = t("calls.kind_audio" if call.kind == "AUDIO" else "calls.kind_video", language=language) + invite_text = t("calls.invite_text", kind=call_label, language=language) url = f"{public_base_url()}/calls/{token}?kind={call.kind}" sent = transports.send_call_invite( call.delivery_connection, diff --git a/apps/backend/chatballs/calls/lifecycle.py b/apps/backend/chatballs/calls/lifecycle.py index e71f706..1c09708 100644 --- a/apps/backend/chatballs/calls/lifecycle.py +++ b/apps/backend/chatballs/calls/lifecycle.py @@ -105,7 +105,7 @@ def transition_call( ) normalized_failure_code = failure_code.strip() if target_status == CallStatus.FAILED and not FAILURE_CODE_PATTERN.fullmatch(normalized_failure_code): - raise CallInvalidTransition("Для FAILED требуется нормализованный failure_code") + raise CallInvalidTransition(t("calls.failure_code_required")) now = timezone.now() update_fields = ["status", "updated_at"] diff --git a/apps/backend/chatballs/calls/models.py b/apps/backend/chatballs/calls/models.py index a2ca5e9..576d1df 100644 --- a/apps/backend/chatballs/calls/models.py +++ b/apps/backend/chatballs/calls/models.py @@ -3,6 +3,7 @@ import uuid from django.conf import settings from django.db import models +from chatballs.i18n import t from chatballs.tenancy.models import TenantRelationModel @@ -40,7 +41,7 @@ TERMINAL_CALL_STATUSES = ( class CallEndedBy(models.TextChoices): STAFF = "STAFF", "Сотрудник" CUSTOMER = "CUSTOMER", "Клиент" - SYSTEM = "SYSTEM", "Система" + SYSTEM = "SYSTEM", t("admin.actor_system") TIMEOUT = "TIMEOUT", "Таймаут" diff --git a/apps/backend/chatballs/calls/services.py b/apps/backend/chatballs/calls/services.py index d0b246a..145a534 100644 --- a/apps/backend/chatballs/calls/services.py +++ b/apps/backend/chatballs/calls/services.py @@ -131,7 +131,7 @@ def create_call_request( ensure_conversation_call_access(user=context.membership, conversation=conversation) if not call_allowed(conversation.connection, kind): raise CallAccessDenied( - "Видеозвонки отключены для этой точки входа" if kind == CallKind.VIDEO else "Звонки отключены для этой точки входа" + t("calls.video_off_entry_point") if kind == CallKind.VIDEO else t("calls.calls_off_entry_point") ) identity = _conversation_identity(conversation) _check_call_creation_conflicts(conversation=conversation, initiator=initiator) diff --git a/apps/backend/chatballs/channels/authorization.py b/apps/backend/chatballs/channels/authorization.py index 9c4e859..570391b 100644 --- a/apps/backend/chatballs/channels/authorization.py +++ b/apps/backend/chatballs/channels/authorization.py @@ -33,8 +33,10 @@ def has_organization_capability(context: TenantContext, capability: str) -> bool def require_organization_manage(context: TenantContext, *, operation: str) -> None: + """``operation`` — ключ каталога, а не готовый текст: отказ читает человек.""" + if not has_organization_capability(context, CHANNELS_MANAGE): - raise PermissionDenied(f"{operation} требует channels.manage") + raise PermissionDenied(t("channels.operation_needs_manage", operation=t(operation))) def require_channel_manage(context: TenantContext) -> None: diff --git a/apps/backend/chatballs/channels/runtime.py b/apps/backend/chatballs/channels/runtime.py index bf4fd17..1b3ea34 100644 --- a/apps/backend/chatballs/channels/runtime.py +++ b/apps/backend/chatballs/channels/runtime.py @@ -1,5 +1,6 @@ from chatballs.ai.provider.base import ChatResult, ProviderError from chatballs.ai.runtime import run_agent_turn +from chatballs.i18n import t def run_channel_turn(*, channel, message: str, history: list[dict] | None = None) -> ChatResult: @@ -11,5 +12,5 @@ def run_channel_turn(*, channel, message: str, history: list[dict] | None = None """ agent = getattr(channel, "ai_agent", None) if agent is None or not agent.is_active: - raise ProviderError("Channel has no active AI agent") + raise ProviderError(t("channels.no_active_agent")) return run_agent_turn(agent=agent, message=message, history=history, style_guard=True).result diff --git a/apps/backend/chatballs/channels/services.py b/apps/backend/chatballs/channels/services.py index cb07a94..71db150 100644 --- a/apps/backend/chatballs/channels/services.py +++ b/apps/backend/chatballs/channels/services.py @@ -47,7 +47,7 @@ class ConnectionAlreadyBound(Exception): def __init__(self, *, integration: Integration) -> None: self.integration = integration super().__init__( - "Подключение уже привязано к другому каналу: перенос требует force" + t("channels.binding_conflict") ) def payload(self) -> dict[str, Any]: @@ -74,9 +74,9 @@ class ChannelUpdate: def _clean_name(raw: object) -> str: name = str(raw or "").strip() if not name: - raise ValidationError({"name": "Название канала не может быть пустым"}) + raise ValidationError({"name": t("channels.name_required")}) if len(name) > NAME_MAX_LENGTH: - raise ValidationError({"name": "Название канала длиннее 255 символов"}) + raise ValidationError({"name": t("channels.name_too_long")}) return name @@ -91,7 +91,7 @@ def _group_for_channel( organization_id=context.organization_id, ) except EmployeeGroup.DoesNotExist as error: - raise ValidationError({"groupId": "Unknown group"}) from error + raise ValidationError({"groupId": t("channels.unknown_group")}) from error @transaction.atomic @@ -114,11 +114,11 @@ def update_channel( authorization.require_channel_manage(context) if update.is_active is not UNSET and update.is_active != locked.is_active: authorization.require_organization_manage( - context, operation="Изменение статуса канала" + context, operation="channels.operation_status_change" ) if update.policy: authorization.require_organization_manage( - context, operation="Изменение политики канала" + context, operation="channels.operation_policy_change" ) changed: list[str] = [] @@ -149,10 +149,10 @@ def _messenger_integration(*, context: TenantContext, integration_id: int) -> In id=integration_id, organization_id=context.organization_id ) except Integration.DoesNotExist as error: - raise ValidationError({"integrationId": "Подключение не найдено"}) from error + raise ValidationError({"integrationId": t("channels.connection_not_found")}) from error if integration.kind != IntegrationKind.MESSENGER: raise ValidationError( - {"integrationId": "LLM-провайдер не является подключением канала"} + {"integrationId": t("channels.llm_is_not_a_connection")} ) return integration @@ -169,7 +169,7 @@ def bind_connection( authorization.require_connections_manage(context) if not channel.is_active: raise ValidationError( - {"channelId": "Нельзя привязать подключение к архивному каналу"} + {"channelId": t("channels.archived_channel_binding")} ) integration = _messenger_integration(context=context, integration_id=integration_id) previous_channel_id = integration.channel_id @@ -189,7 +189,7 @@ def unbind_connection( authorization.require_connections_manage(context) integration = _messenger_integration(context=context, integration_id=integration_id) if integration.channel_id != channel.id: - raise ValidationError({"integrationId": "Подключение не привязано к каналу"}) + raise ValidationError({"integrationId": t("channels.connection_not_bound")}) # Диалоги не затрагиваются: Conversation.connection объявлен PROTECT. integration.channel = None integration.save(update_fields=["channel", "updated_at"]) diff --git a/apps/backend/chatballs/conversations/attachment_views.py b/apps/backend/chatballs/conversations/attachment_views.py index b19d03d..ec42c2c 100644 --- a/apps/backend/chatballs/conversations/attachment_views.py +++ b/apps/backend/chatballs/conversations/attachment_views.py @@ -39,12 +39,12 @@ INLINE_TYPES = ("image/jpeg", "image/png", "image/gif", "image/webp", "applicati def validate_upload(upload) -> str: """Причина отказа или пустая строка.""" if upload.size > MAX_FILE_BYTES: - return "Файл больше 20 МБ" + return t("conversations.attachment_too_large") if not upload.size: - return "Пустой файл" + return t("conversations.attachment_empty") name = safe_filename(upload.name or "") if name.lower().endswith(BLOCKED_SUFFIXES): - return "Такой тип файла отправить нельзя" + return t("conversations.attachment_type_blocked") return "" diff --git a/apps/backend/chatballs/conversations/chat_extras_views.py b/apps/backend/chatballs/conversations/chat_extras_views.py index 145b0d3..1453523 100644 --- a/apps/backend/chatballs/conversations/chat_extras_views.py +++ b/apps/backend/chatballs/conversations/chat_extras_views.py @@ -93,7 +93,7 @@ class ConversationContactView(ConversationViewBase): continue value = str(request.data.get(field) or "").strip() if len(value) > limit: - return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400) + return Response({"detail": t("conversations.field_too_long", field=field, limit=limit)}, status=400) if field == "name" and not value: return Response({"detail": t("conversations.contact_name_empty")}, status=400) setattr(contact, field, value) @@ -152,7 +152,7 @@ class ConversationLabelsView(ConversationViewBase): if not isinstance(label_ids, list) or not all( isinstance(item, int) for item in label_ids ): - return Response({"detail": "labelIds must be a list of ids"}, status=400) + return Response({"detail": t("conversations.label_ids_list")}, status=400) labels = list( ConversationLabel.objects.filter( organization_id=request.tenant_context.organization_id, @@ -186,7 +186,7 @@ class ConversationArchiveView(ConversationViewBase): return Response({"detail": t("conversations.not_found")}, status=404) archived = request.data.get("archived") if not isinstance(archived, bool): - return Response({"detail": "archived must be a boolean"}, status=400) + return Response({"detail": t("conversations.archived_boolean")}, status=400) if not archived and not can_administer_access(request.tenant_context.membership): return Response( {"detail": t("conversations.restore_admin_only")}, status=403 diff --git a/apps/backend/chatballs/conversations/clients.py b/apps/backend/chatballs/conversations/clients.py index 17e8586..0a0abff 100644 --- a/apps/backend/chatballs/conversations/clients.py +++ b/apps/backend/chatballs/conversations/clients.py @@ -186,7 +186,12 @@ def client_row(contact: Contact) -> dict: def _dialog_status(conversation: Conversation) -> str: - return {"closed": "Закрыт", "operator": "Оператор", "ai": "AI", "wait": "Ждёт оператора"}[_mode(conversation)] + return { + "closed": t("conversations.dialog_status_closed"), + "operator": t("conversations.dialog_status_operator"), + "ai": "AI", + "wait": t("conversations.dialog_status_wait"), + }[_mode(conversation)] def client_detail(organization_id: int, contact_id: int) -> dict: @@ -257,9 +262,9 @@ def client_detail(organization_id: int, contact_id: int) -> dict: # Активность из жизненного цикла диалогов (created/closed) — реальные события. activity: list[dict] = [] for conversation in conversations: - activity.append({"type": "created", "title": f"Диалог · {conversation.channel.name}", "at": conversation.created_at.isoformat()}) + activity.append({"type": "created", "title": t("conversations.activity_started", channel=conversation.channel.name), "at": conversation.created_at.isoformat()}) if conversation.lifecycle == LifecycleState.CLOSED: - activity.append({"type": "closed", "title": f"Диалог закрыт · {conversation.channel.name}", "at": conversation.last_activity_at.isoformat()}) + activity.append({"type": "closed", "title": t("conversations.activity_closed", channel=conversation.channel.name), "at": conversation.last_activity_at.isoformat()}) activity.sort(key=lambda item: item["at"], reverse=True) conversation_ids = [str(conversation.id) for conversation in conversations] @@ -282,7 +287,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict: # тогда показываем код — как в журнале. "action": audit_action_label(event.action) or event.action, "object": audit_object_label(event.object_type, event.object_id), - "actor": (event.actor.full_name or event.actor.email) if event.actor_id else "Система", + "actor": (event.actor.full_name or event.actor.email) if event.actor_id else t("admin.actor_system"), "result": audit_result_label(event.result), } ) diff --git a/apps/backend/chatballs/conversations/models.py b/apps/backend/chatballs/conversations/models.py index 5a8e8c5..6478cdc 100644 --- a/apps/backend/chatballs/conversations/models.py +++ b/apps/backend/chatballs/conversations/models.py @@ -4,6 +4,7 @@ from django.contrib.postgres.search import SearchVector from django.db import models from django.utils import timezone +from chatballs.i18n import t from chatballs.tenancy.models import TenantRelationModel # Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога @@ -254,7 +255,7 @@ class MessageAuthor(models.TextChoices): CONTACT = "CONTACT", "Клиент" AI = "AI", "AI" OPERATOR = "OPERATOR", "Оператор" - SYSTEM = "SYSTEM", "Система" + SYSTEM = "SYSTEM", t("admin.actor_system") class SystemEvent(models.TextChoices): @@ -355,6 +356,13 @@ class Message(TenantRelationModel): ordering = ["created_at"] indexes = [ # Полнотекстовый поиск по сообщениям (поиск в списке диалогов). + # + # Конфигурация «russian» покрывает обе переписки, и второй индекс + # под английский был бы тратой места: в ней asciiword отдан + # english_stem, а word — russian_stem, поэтому английские слова + # стеммятся английским стеммером, а русские русским. Обратное + # неверно: «english» оставляет кириллицу без основы. Проверено + # тестом conversations/test_search_language.py. GinIndex( SearchVector("text", config="russian"), name="conv_message_text_fts", diff --git a/apps/backend/chatballs/conversations/reporting_views.py b/apps/backend/chatballs/conversations/reporting_views.py index 6d44b7f..c2ddac9 100644 --- a/apps/backend/chatballs/conversations/reporting_views.py +++ b/apps/backend/chatballs/conversations/reporting_views.py @@ -56,7 +56,7 @@ class ClientDetailView(ConversationViewBase): continue value = str(request.data.get(field) or "").strip() if len(value) > limit: - return Response({"detail": f"Поле {field}: не длиннее {limit} символов"}, status=400) + return Response({"detail": t("conversations.field_too_long", field=field, limit=limit)}, status=400) if field == "name" and not value: return Response({"detail": t("conversations.contact_name_empty")}, status=400) setattr(contact, field, value) diff --git a/apps/backend/chatballs/conversations/services.py b/apps/backend/chatballs/conversations/services.py index c29d8a9..982c6fd 100644 --- a/apps/backend/chatballs/conversations/services.py +++ b/apps/backend/chatballs/conversations/services.py @@ -13,14 +13,12 @@ from chatballs.conversations.models import ( MessageKind, SystemEvent, ) -from chatballs.i18n import t +from chatballs.i18n import customer_language, t from chatballs.identity.models import EmployeeRole from chatballs.integrations.models import IntegrationProvider from chatballs.tenancy.context import TenantContext -CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже." -CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона." @@ -116,6 +114,10 @@ def claim_locked_conversation(*, context: TenantContext, conversation: Conversat author_type=MessageAuthor.SYSTEM, + system_event=SystemEvent.OPERATOR_TOOK, + + system_params={"operator": _operator_label(operator)}, + text=f"Оператор {_operator_label(operator)} перехватил диалог", ) @@ -270,7 +272,9 @@ def request_contact(*, context: TenantContext, conversation: Conversation) -> Me is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB - text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT + key = "conversations.contact_request_web" if is_web else "conversations.contact_request" + + text = t(key, language=customer_language(conversation.organization)) message = Message.objects.create( diff --git a/apps/backend/chatballs/conversations/stats.py b/apps/backend/chatballs/conversations/stats.py index 105a39f..ce804ab 100644 --- a/apps/backend/chatballs/conversations/stats.py +++ b/apps/backend/chatballs/conversations/stats.py @@ -21,6 +21,7 @@ from chatballs.conversations.models import ( LifecycleState, Message, ) +from chatballs.i18n import t _ACTIVE_WINDOW = timedelta(minutes=15) _WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] @@ -126,7 +127,7 @@ def sales_overview_stats(context, period: str) -> dict: ): minutes = int((now - conversation.last_activity_at).total_seconds() // 60) meta = conversation.channel.name - title = (conversation.contact.name if conversation.contact_id else "") or "Гость" + title = (conversation.contact.name if conversation.contact_id else "") or t("conversations.guest") problems.append( { "conversationId": conversation.id, diff --git a/apps/backend/chatballs/conversations/test_search_language.py b/apps/backend/chatballs/conversations/test_search_language.py new file mode 100644 index 0000000..9ed2d19 --- /dev/null +++ b/apps/backend/chatballs/conversations/test_search_language.py @@ -0,0 +1,59 @@ +"""Поиск по переписке на обоих языках идёт одной конфигурацией — «russian». + +Выглядит как недосмотр: англоязычная установка ищет по своим письмам русской +конфигурацией. На деле «russian» в PostgreSQL — не «русский стеммер на всё», а +карта по типу токена: латиница уходит в english_stem, кириллица — в +russian_stem. То есть английские слова уже стеммятся английским стеммером. + +Обратное неверно, и это здесь же проверяется: «english» отдаёт кириллицу +english_stem и оставляет её без основы. Поэтому второй индекс под английский не +нужен, а замена конфигурации на «english» была бы регрессом для русской +переписки. Тест стоит затем, чтобы это не «починили» ещё раз. +""" + +from __future__ import annotations + +from django.db import connection +from django.test import TestCase + +from chatballs.conversations.models import Message + + +class SearchConfigTests(TestCase): + def _matches(self, config: str, text: str, query: str) -> bool: + with connection.cursor() as cursor: + cursor.execute( + "SELECT to_tsvector(%s, %s) @@ websearch_to_tsquery(%s, %s)", + [config, text, config, query], + ) + return cursor.fetchone()[0] + + def test_russian_config_stems_english_words(self) -> None: + self.assertTrue(self._matches("russian", "the deliveries arrived", "delivery")) + self.assertTrue(self._matches("russian", "we are shipping orders", "shipped")) + + def test_russian_config_stems_russian_words(self) -> None: + self.assertTrue(self._matches("russian", "заказы доставлены", "заказ")) + + def test_english_config_leaves_russian_words_unstemmed(self) -> None: + # Ровно причина, по которой конфигурация не переключается по языку + # организации: на английской конфигурации «заказы» не находит «заказ». + self.assertFalse(self._matches("english", "заказы доставлены", "заказ")) + + def test_messages_are_indexed_with_the_same_configuration(self) -> None: + # Конфигурация входит в выражение индекса: разойдись она с запросом — + # поиск ушёл бы в полный скан. Читаем определение из самой базы, а не + # из питоновского объекта: в базе лежит то, что реально применится. + with connection.cursor() as cursor: + cursor.execute( + "SELECT indexdef FROM pg_indexes WHERE indexname = %s", + ["conv_message_text_fts"], + ) + row = cursor.fetchone() + self.assertIsNotNone(row, "индекс полнотекстового поиска не создан") + self.assertIn("'russian'", row[0]) + self.assertEqual( + [i.name for i in Message._meta.indexes if "fts" in i.name], + ["conv_message_text_fts"], + "второй индекс под язык не нужен: «russian» уже покрывает латиницу", + ) diff --git a/apps/backend/chatballs/conversations/transports/__init__.py b/apps/backend/chatballs/conversations/transports/__init__.py index ad1e0b3..643a2a3 100644 --- a/apps/backend/chatballs/conversations/transports/__init__.py +++ b/apps/backend/chatballs/conversations/transports/__init__.py @@ -1,6 +1,7 @@ from chatballs.conversations.transports import email as _email from chatballs.conversations.transports import max as _max from chatballs.conversations.transports import telegram as _telegram +from chatballs.i18n import t from chatballs.integrations.models import IntegrationProvider _POLL = { @@ -114,7 +115,7 @@ def download_file(integration, inbound_file) -> tuple[bytes, str]: return content, inbound_file.content_type or guessed if integration.provider == IntegrationProvider.MAX and inbound_file.url: return _max.download_file(integration, inbound_file.url, inbound_file.content_type) - raise ValueError("File download is not supported for this provider") + raise ValueError(t("conversations.file_download_unsupported")) def supports_file_send(integration) -> bool: @@ -143,7 +144,7 @@ def download_voice(integration, inbound) -> tuple[bytes, str]: return _telegram.download_voice(integration, inbound.voice_file_id) if integration.provider == IntegrationProvider.MAX and inbound.voice_url: return _max.download_voice(integration, inbound.voice_url) - raise ValueError("Voice download is not supported for this provider") + raise ValueError(t("conversations.voice_download_unsupported")) def supports_voice_send(integration) -> bool: diff --git a/apps/backend/chatballs/conversations/transports/email.py b/apps/backend/chatballs/conversations/transports/email.py index 6f99b24..556a04c 100644 --- a/apps/backend/chatballs/conversations/transports/email.py +++ b/apps/backend/chatballs/conversations/transports/email.py @@ -26,6 +26,7 @@ from chatballs.conversations.transports.base import ( guess_content_type, safe_filename, ) +from chatballs.i18n import customer_language, t logger = logging.getLogger(__name__) @@ -194,7 +195,8 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str, attachments outgoing["From"] = _address(integration) outgoing["To"] = target subject = str(meta.get("subject", "")).strip() - outgoing["Subject"] = f"Re: {subject}" if subject and not subject.lower().startswith("re:") else (subject or "Re: Ваше обращение") + default_subject = t("conversations.email_default_subject", language=customer_language(integration.organization)) + outgoing["Subject"] = f"Re: {subject}" if subject and not subject.lower().startswith("re:") else f"Re: {subject or default_subject}" last_message_id = str(meta.get("last_message_id", "")).strip() if last_message_id: outgoing["In-Reply-To"] = last_message_id @@ -223,8 +225,10 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str, attachments def send_voice(integration, *, chat_id: str, user_id: str, content: bytes, content_type: str, duration: int) -> bool: """Голосовое оператора уходит письмом с аудио-вложением.""" suffix = "ogg" if "ogg" in content_type else (content_type.rsplit("/", 1)[-1] or "webm").split(";")[0] - return send_text(integration, chat_id=chat_id, user_id=user_id, text="Голосовое сообщение", attachments=[(content, f"voice.{suffix}", content_type)]) + text = t("conversations.voice_message", language=customer_language(integration.organization)) + return send_text(integration, chat_id=chat_id, user_id=user_id, text=text, attachments=[(content, f"voice.{suffix}", content_type)]) def send_file(integration, *, chat_id: str, user_id: str, content: bytes, filename: str, content_type: str, caption: str = "") -> bool: - return send_text(integration, chat_id=chat_id, user_id=user_id, text=caption or f"Файл: {filename}", attachments=[(content, filename, content_type)]) + text = caption or t("conversations.file_caption", name=filename, language=customer_language(integration.organization)) + return send_text(integration, chat_id=chat_id, user_id=user_id, text=text, attachments=[(content, filename, content_type)]) diff --git a/apps/backend/chatballs/conversations/transports/max.py b/apps/backend/chatballs/conversations/transports/max.py index b87a115..b15d2fd 100644 --- a/apps/backend/chatballs/conversations/transports/max.py +++ b/apps/backend/chatballs/conversations/transports/max.py @@ -27,6 +27,7 @@ from chatballs.conversations.transports.base import ( request_json_multipart, safe_filename, ) +from chatballs.i18n import customer_language, t from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL from chatballs.integrations.outbound import host_of @@ -271,11 +272,17 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool: return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text}) +def _caption(integration, key: str) -> str: + """Подпись кнопки читает клиент — язык организации, а не язык запроса.""" + + return t(key, language=customer_language(integration.organization)) + + def send_contact_request(integration, *, chat_id: str, user_id: str, text: str) -> bool: # Inline-клавиатура с кнопкой request_contact (Bot API MAX/TamTam). keyboard = { "type": "inline_keyboard", - "payload": {"buttons": [[{"type": "request_contact", "text": "Поделиться контактом"}]]}, + "payload": {"buttons": [[{"type": "request_contact", "text": _caption(integration, "conversations.button_share_contact")}]]}, } return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "attachments": [keyboard]}) @@ -284,7 +291,7 @@ def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url: # Приглашение на онлайн-звонок: inline-кнопка со ссылкой /calls/. keyboard = { "type": "inline_keyboard", - "payload": {"buttons": [[{"type": "link", "text": "Перейти к звонку", "url": url}]]}, + "payload": {"buttons": [[{"type": "link", "text": _caption(integration, "conversations.button_join_call"), "url": url}]]}, } return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "attachments": [keyboard]}) diff --git a/apps/backend/chatballs/conversations/transports/telegram.py b/apps/backend/chatballs/conversations/transports/telegram.py index c8ce2a2..411a2cc 100644 --- a/apps/backend/chatballs/conversations/transports/telegram.py +++ b/apps/backend/chatballs/conversations/transports/telegram.py @@ -23,6 +23,7 @@ from chatballs.conversations.transports.base import ( request_json_multipart, safe_filename, ) +from chatballs.i18n import customer_language, t from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL from chatballs.integrations.outbound import host_of @@ -152,10 +153,16 @@ def send_text(integration, *, chat_id: str, user_id: str, text: str) -> bool: return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text}) +def _caption(integration, key: str) -> str: + """Подпись кнопки читает клиент — язык организации, а не язык запроса.""" + + return t(key, language=customer_language(integration.organization)) + + def send_contact_request(integration, *, chat_id: str, user_id: str, text: str) -> bool: # Reply-клавиатура с request_contact: телефон бот получает только так. keyboard = { - "keyboard": [[{"text": "Поделиться контактом", "request_contact": True}]], + "keyboard": [[{"text": _caption(integration, "conversations.button_share_contact"), "request_contact": True}]], "one_time_keyboard": True, "resize_keyboard": True, } @@ -169,7 +176,7 @@ def send_contact_ack(integration, *, chat_id: str, user_id: str, text: str) -> b def send_call_invite(integration, *, chat_id: str, user_id: str, text: str, url: str) -> bool: # Приглашение на онлайн-звонок: inline-кнопка со ссылкой /calls/. - keyboard = {"inline_keyboard": [[{"text": "Перейти к звонку", "url": url}]]} + keyboard = {"inline_keyboard": [[{"text": _caption(integration, "conversations.button_join_call"), "url": url}]]} return _send(integration, chat_id=chat_id, user_id=user_id, body={"text": text, "reply_markup": keyboard}) @@ -182,7 +189,7 @@ def download_file(integration, file_id: str) -> tuple[bytes, str]: ) file_path = ((data.get("result") or {}).get("file_path") or "") if not data.get("ok") or not file_path: - raise ValueError("Telegram getFile failed") + raise ValueError(t("conversations.telegram_getfile_failed")) content = download_bytes( f"{_base(integration)}/file/bot{token}/{file_path}", proxy_url=_proxy(integration), @@ -226,7 +233,7 @@ def download_voice(integration, file_id: str) -> tuple[bytes, str]: ) file_path = ((data.get("result") or {}).get("file_path") or "") if not data.get("ok") or not file_path: - raise ValueError("Telegram getFile failed") + raise ValueError(t("conversations.telegram_getfile_failed")) content = download_bytes( f"{_base(integration)}/file/bot{token}/{file_path}", proxy_url=_proxy(integration), diff --git a/apps/backend/chatballs/conversations/views.py b/apps/backend/chatballs/conversations/views.py index 5ea2f6f..df50e72 100644 --- a/apps/backend/chatballs/conversations/views.py +++ b/apps/backend/chatballs/conversations/views.py @@ -93,7 +93,11 @@ class ConversationListView(ConversationViewBase): from django.db.models import Exists, OuterRef # Имена — по подстроке; тексты сообщений — полнотекстовым поиском - # (russian-конфиг, GIN-индекс conv_message_text_fts). + # (russian-конфиг, GIN-индекс conv_message_text_fts). Конфигурация + # одна на оба языка и от языка организации не зависит: в «russian» + # латиница отдана english_stem, а кириллица — russian_stem, так что + # английская переписка стеммится английским стеммером. Менять её на + # «english» нельзя — та оставит без основы русские слова. search = SearchQuery(query, config="russian", search_type="websearch") message_match = ( Message.objects.filter(conversation=OuterRef("pk")) diff --git a/apps/backend/chatballs/i18n/__init__.py b/apps/backend/chatballs/i18n/__init__.py index 366f93f..395dac9 100644 --- a/apps/backend/chatballs/i18n/__init__.py +++ b/apps/backend/chatballs/i18n/__init__.py @@ -1,5 +1,6 @@ """Язык интерфейса: выбор языка запроса и каталог сообщений.""" +from chatballs.i18n.audience import customer_language from chatballs.i18n.catalog import current_language, t, tn from chatballs.i18n.languages import ( DEFAULT_LANGUAGE, @@ -22,6 +23,7 @@ __all__ = [ "LANGUAGE_CODES", "RUSSIAN", "current_language", + "customer_language", "first_chosen", "normalize_language", "parse_accept_language", diff --git a/apps/backend/chatballs/i18n/audience.py b/apps/backend/chatballs/i18n/audience.py new file mode 100644 index 0000000..eb39051 --- /dev/null +++ b/apps/backend/chatballs/i18n/audience.py @@ -0,0 +1,24 @@ +"""Язык текста, который уходит наружу — клиенту, а не сотруднику. + +Язык запроса здесь не годится. Приглашение на звонок, просьба поделиться +контактом, тема исходящего письма — это речь организации, обращённая к её +клиенту, и она не должна менять язык от того, кто из операторов нажал кнопку. +Часть такого текста вообще рождается в воркере, где запроса нет. + +Поэтому берём язык организации, а если она его не задавала — язык установки. +Язык самого клиента нам неизвестен: в Telegram, MAX и почте его сообщить +нечему, а в веб-виджете он выбирается на стороне страницы. +""" + +from __future__ import annotations + +from chatballs.i18n.languages import INHERIT, first_chosen, normalize_language + + +def customer_language(organization: object) -> str: + """Язык обращения организации к клиенту. Пусто — язык по умолчанию.""" + + from chatballs.identity.instance_settings import default_language + + organization_language = normalize_language(getattr(organization, "language", INHERIT)) + return first_chosen(organization_language, default_language()) diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index 3996ae9..c4cdd3e 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -21,10 +21,10 @@ MESSAGES: dict[str, object] = { "ai.provider_not_configured": "No AI provider is configured: a call without a channel cannot pick an integration", "api.window_needs_sort_key": "a window needs at least one sort key", "calls.invite_invalid": "The invitation is not valid or has expired", - "calls.kind_audio": "audio call", + "calls.kind_audio": "an audio call", "calls.kind_invalid": "Unsupported call type", "calls.kind_required": "Choose the call type", - "calls.kind_video": "video call", + "calls.kind_video": "a video call", "calls.not_found": "Call not found", "calls.token_invalid": "The call access token is not valid or has expired", "channels.binding_needs_manage": "Binding a connection requires integrations.manage", @@ -301,4 +301,234 @@ MESSAGES: dict[str, object] = { "calls.unfinished_call_exists": "The conversation already has an unfinished call", "channels.no_active_agent": "The channel has no active AI agent", "conversations.unavailable": "The conversation is unavailable", + "admin.action_not_allowed_for_employee": "You cannot perform this action on this employee", + "admin.email_required": "Email is required", + "admin.email_taken": "Email is already used", + "admin.full_name_required": "Full name is required", + "admin.invalid_role": "Invalid role", + "admin.not_allowed": "Not allowed", + "admin.previous_owner_role": "Previous owner role must be ADMIN or EMPLOYEE", + "admin.target_must_be_active": "Target must be an active employee", + "admin.temporary_passwords_unsupported": "Temporary passwords are not supported", + "admin.unknown_password_mode": "Unknown password mode", + "ai.attachment_not_found": "Attachment not found", + "ai.category_not_found": "Category not found", + "ai.documents_non_empty": "documents must be a non-empty list", + "ai.file_required": "file is required (multipart/form-data)", + "ai.group_id_integer_or_null": "groupId must be an integer or null", + "ai.group_id_or_none": "group must be an id or none", + "ai.history_must_be_list": "history must be a list", + "ai.integration_id_integer": "integrationId must be an integer", + "ai.knowledge_not_found": "Knowledge not found", + "ai.title_required": "Title is required", + "conversations.archived_boolean": "archived must be a boolean", + "conversations.label_ids_list": "labelIds must be a list of ids", + "identity.invalid_credentials": "Invalid credentials", + "identity.invalid_totp_code": "Invalid TOTP code", + "identity.invitation_email_mismatch": "Invitation email does not match the account", + "identity.invitation_invalid": "Invitation is invalid or has expired", + "identity.role_conflict": "User already has a different role in this organization", + "identity.token_required": "token is required", + "identity.totp_challenge_inactive": "TOTP challenge is not active", + "notifications.ids_list_or_all": "ids must be a list or use all=true", + "notifications.push_types_list": "pushTypes must be a list", + "platform.idempotency_key_required": "Idempotency-Key header is required", + "portals.articles_non_empty": "articles must be a non-empty list", + "profile.current_password_invalid": "Current password is invalid", + "profile.totp_enabled_already": "TOTP is already enabled", + "profile.totp_required_by_policy": "TOTP is required by an organization policy", + "ai.provider_error": "Provider error: {error}", + "conversations.activity_closed": "Conversation closed · {channel}", + "conversations.activity_started": "Conversation · {channel}", + "conversations.field_too_long": "Field {field}: no longer than {limit} characters", + "ai.provider_no_transcription": "The {provider} provider does not support audio transcription", + "ai.transcription_failed": "Transcription failed: {error}", + "ai.transcription_failed_http": "Transcription failed: HTTP {code} {detail}", + "api.expected_integer": "{name}: an integer is expected", + "api.expected_positive": "{name}: a number greater than zero is expected", + "api.expected_record_id": "{name}: a record identifier is expected", + "settings.storage_unavailable": "The storage is unavailable: {error}", + "admin.actor_system": "System", + "admin.image_formats": "PNG, JPEG and WebP are supported", + "admin.logo_too_large": "The logo must not exceed 2 MB", + "calls.calls_off_entry_point": "Calls are switched off for this entry point", + "calls.failure_code_required": "FAILED needs a normalized failure_code", + "calls.video_off_entry_point": "Video calls are switched off for this entry point", + "channels.archived_channel_binding": "A connection cannot be bound to an archived channel", + "channels.connection_not_bound": "The connection is not bound to a channel", + "channels.connection_not_found": "Connection not found", + "channels.llm_is_not_a_connection": "An LLM provider is not a channel connection", + "channels.name_required": "The channel name cannot be empty", + "channels.name_too_long": "The channel name is longer than 255 characters", + "portals.a_record_mismatch": "The domain's A record does not point at the Chatballs server", + "portals.a_record_missing": "The domain's A record has not appeared yet", + "portals.anonymous_widget_missing": "No active anonymous support web widget was found", + "portals.archived_article_publish": "An archived article cannot be published", + "portals.archived_article_readonly": "An archived article cannot be edited", + "portals.domain_empty_check": "An empty domain cannot be verified", + "portals.domain_is_internal": "The internal application domain cannot be used", + "portals.domain_required_first": "Set the domain first", + "portals.domain_same_as_installation": "A custom domain must differ from the Chatballs address", + "portals.domain_taken": "This domain is already used by another portal", + "portals.file_name_required": "The file name is required", + "portals.file_too_large": "The file is over 25 MB", + "portals.invalid_theme_id": "Invalid theme identifier", + "portals.invalid_theme_scheme": "Unknown theme colour scheme", + "portals.invalid_theme_setting_name": "Invalid theme setting name", + "portals.move_articles_first": "Move the articles out of this section first", + "portals.move_nested_sections_first": "Move or delete the nested sections first", + "portals.other_organization": "The portal belongs to another organization", + "portals.restore_to_edit_content": "Restore the portal to change its content", + "portals.restore_to_edit_domain": "Restore the portal to change its domain", + "portals.restore_to_edit_settings": "Restore the portal to change its settings", + "portals.restore_to_verify_domain": "Restore the portal to verify its domain", + "portals.revision_not_found": "Article revision not found", + "portals.slug_taken": "This address is already used by another portal", + "portals.theme_settings_json": "Theme settings must be a JSON object", + "portals.theme_settings_object": "Theme settings must be an object", + "portals.theme_settings_too_large": "The theme settings are too large", + "portals.unknown_status": "Unknown portal status", + "portals.widget_channel_missing": "The web widget channel was not found", + "profile.photo_too_large": "The photo must not exceed 2 MB", + "settings.local_or_s3": "LOCAL or S3", + "settings.object_written_not_readable": "The object was written but cannot be read — check the read permissions", + "settings.seconds_number": "A number of seconds", + "admin.group_name_required": "Group name is required", + "ai.agent_other_organization": "Agent belongs to another organization", + "ai.archived_agent_state": "Archived AI agent cannot change state", + "ai.at_least_one_knowledge_id": "At least one knowledge ID is required", + "ai.attachment_other_organization": "Attachment belongs to another organization", + "ai.category_cycle": "Category cycle is not allowed", + "ai.category_name_required": "Category name is required", + "ai.category_names_strings": "Category names must be non-empty strings", + "ai.category_other_organization": "Category belongs to another organization", + "ai.category_path_not_found": "Category path not found", + "ai.category_path_required": "Non-empty category path required", + "ai.category_with_children": "Category with children cannot be deleted", + "ai.category_with_knowledge": "Category with knowledge cannot be deleted", + "ai.channel_required": "Channel is required", + "ai.content_required": "Content string is required", + "ai.description_string": "Description must be a string", + "ai.document_object_required": "Document must be an object", + "ai.expected_attach_or_detach": "Expected attach or detach", + "ai.file_name_required": "File name is required", + "ai.file_too_large_25": "File is too large (max 25 MB)", + "ai.integration_model_required": "Integration default model is required", + "ai.knowledge_ids_integers": "Knowledge IDs must be integers", + "ai.knowledge_other_organization": "Knowledge belongs to another organization", + "ai.knowledge_unknown_or_unavailable": "Unknown or unavailable knowledge item", + "ai.list_of_ids": "List of IDs required", + "ai.list_of_knowledge_ids": "List of knowledge IDs required", + "ai.parent_category_not_found": "Parent category not found", + "ai.parent_category_other_organization": "Parent category belongs to another organization", + "ai.system_category_immutable": "System category is immutable", + "ai.system_category_name_immutable": "System category name is immutable", + "ai.system_category_undeletable": "System category cannot be deleted", + "ai.unknown_knowledge_item": "Unknown knowledge item", + "ai.unknown_portal_article": "Unknown portal article", + "ai.unknown_provider_integration": "Unknown OpenRouter or Custom integration", + "api.at_least_one_id": "At least one ID is required", + "api.boolean_required": "Boolean required", + "api.integer_id_required": "Integer id required", + "api.integer_required": "Integer required", + "api.list_of_ids_required": "List of ids required", + "api.object_required": "Object required", + "api.positive_id_required": "Positive integer ID required", + "channels.agent_exists": "Channel already has an AI agent", + "channels.not_found": "Channel not found", + "channels.unknown_group": "Unknown group", + "conversations.file_download_unsupported": "File download is not supported for this provider", + "conversations.telegram_getfile_failed": "Telegram getFile failed", + "conversations.voice_download_unsupported": "Voice download is not supported for this provider", + "portals.article_object_required": "Article must be an object", + "portals.locale_string": "Locale must be a string", + "portals.summary_string": "Summary must be a string", + "portals.widget_channel_mismatch": "Widget and legacy channel must match", + "portals.widget_needs_anonymous": "Portal widget must allow anonymous web sessions", + "portals.widget_needs_channel": "Portal widget must be bound to a channel", + "settings.allowed_origins_list": "allowedOrigins must be a list of strings", + "settings.api_key_required": "Custom API key is required", + "settings.custom_base_url_required": "Custom Base URL is required", + "settings.custom_model_required": "Custom model is required", + "settings.email_hosts_required": "Email address, IMAP host and SMTP host are required", + "settings.email_not_a_bot": "Email cannot be a notifications bot", + "settings.inactive_channel": "Inactive channel cannot accept connections", + "settings.integration_other_organization": "Integration belongs to another organization", + "settings.mailbox_password_required": "Mailbox password is required", + "settings.name_required": "Name required", + "settings.ports_numbers": "Ports must be numbers", + "settings.quick_replies_list": "quickReplies must be a list of strings", + "settings.unknown_provider": "Unknown provider", + "ai.field_required": "The field “{field}” is required", + "ai.unexpected_provider_response": "Unexpected provider response: {error}", + "settings.base_url_rejected": "Base URL: {error}", + "settings.proxy_url_rejected": "Proxy: {error}", + "notifications.type_dialog_waiting": "A dialog is waiting for an operator", + "notifications.type_dialog_new_message": "A new message in a dialog", + "notifications.type_release_published": "An agent release was published", + "notifications.type_integration_error": "Integration error", + "integrations.check_no_connection": "No connection: {error}", + "integrations.check_api_key_missing": "The API key is not set", + "integrations.check_base_url_missing": "Base URL is not set", + "integrations.check_bot_token_missing": "The bot token is not set", + "integrations.check_mailbox_password_missing": "The mailbox password is not set", + "integrations.check_mail_hosts_missing": "The address, IMAP host or SMTP host is missing", + "integrations.check_provider_answered": "{provider} answered {status}", + "integrations.check_endpoint_answered": "The endpoint answered {status}", + "integrations.check_key_accepted": "the key was accepted", + "integrations.check_bot_connected": "the bot is connected", + "integrations.check_demo": "Demo provider: answers from the agent's knowledge, with no external calls or keys", + "integrations.check_unsupported": "Checking is not supported for this connection type", + "integrations.check_web_not_bound": "The connection is not bound to a channel — the widget is inactive", + "integrations.check_web_no_config": "The web widget configuration has not been created", + "integrations.check_web_no_origins": "No allowed domains are set — the widget will be unavailable on the site", + "integrations.check_web_active": "The web widget is active · channel “{channel}”", + "integrations.check_endpoint_models": {"one": "The endpoint answers: {count} model", "other": "The endpoint answers: {count} models"}, + "identity.password_help": "The password must contain a letter, a digit and a special character.", + "conversations.attachment_too_large": "The file is over 20 MB", + "conversations.attachment_empty": "The file is empty", + "conversations.attachment_type_blocked": "This file type cannot be sent", + "settings.s3_bucket_required": "Enter the bucket name", + "settings.s3_endpoint_scheme": "The address must start with http:// or https://", + "settings.s3_access_key_required": "Enter the Access Key", + "settings.s3_secret_key_required": "Enter the Secret Key", + "channels.binding_conflict": "The connection is already bound to another channel: moving it requires force", + "channels.operation_needs_manage": "{operation} requires channels.manage", + "channels.operation_status_change": "Changing the channel status", + "channels.operation_policy_change": "Changing the channel policy", + "conversations.dialog_status_closed": "Closed", + "conversations.dialog_status_operator": "Operator", + "conversations.dialog_status_wait": "Waiting for an operator", + "setup.your_name_required": "Enter your name", + "setup.your_name_too_long": "The name is longer than 255 characters", + "setup.email_invalid": "Enter a valid e-mail address", + "setup.check_fields": "Check the fields you filled in", + "setup.owner_position": "Owner", + "ai.agent_without_llm_integration": "The agent is not bound to a BYOK LLM integration; choose a provider in the agent settings", + "ai.integration_is_not_llm": "The “{provider}” integration is not a BYOK LLM provider", + "settings.url_scheme_required": "The address must start with one of these schemes: {schemes}", + "settings.download_http_only": "Downloads are only allowed over http/https", + "settings.url_points_inside": "The address points into the internal network", + "settings.scheme_forbidden": "The {scheme} scheme is not allowed in outgoing requests", + "conversations.contact_request": "Please share your contact — tap the button below.", + "conversations.contact_request_web": "Please share your phone number.", + "calls.invite_text": "You are invited to {kind}. Tap the button to join the call.", + "conversations.email_default_subject": "Your request", + "conversations.voice_message": "Voice message", + "conversations.file_caption": "File: {name}", + "webchat.default_greeting": "Hello! Happy to help and answer your questions. What can we do for you?", + "webchat.default_consent": "By continuing you agree that your messages are processed to answer your request.", + "webchat.guest_name": "Guest · {code}", + "channels.operation_agent_create": "Creating an agent", + "channels.operation_agent_delete": "Deleting an agent", + "identity.browser_unknown": "Browser", + "settings.mail_test_subject": "Chatballs mail check", + "settings.mail_test_body": "This is a test message from Settings. If you are reading it, the installation's mail works.", + "notifications.binding_done": "Done! Chatballs notifications are connected. You can turn them off in your profile.", + "notifications.binding_hint": "This is the Chatballs notification bot. To connect notifications, open your profile in Chatballs and press “Connect”.", + "conversations.button_share_contact": "Share contact", + "conversations.button_join_call": "Join the call", + "ai.demo_handover": "I'll check this with my colleagues and hand the dialog to a member of staff — they will answer during working hours.", + "ai.demo_handover_suffix": " I'm handing the dialog to a member of staff — they will help from here.", + "ai.unknown_answer_language": "Unknown agent answer language", } diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index a5789d8..8e66b11 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -305,4 +305,234 @@ MESSAGES: dict[str, object] = { "calls.unfinished_call_exists": "В диалоге уже есть незавершённый звонок", "channels.no_active_agent": "У канала нет активного AI-агента", "conversations.unavailable": "Диалог недоступен", + "admin.action_not_allowed_for_employee": "Это действие над этим сотрудником недоступно", + "admin.email_required": "Укажите email", + "admin.email_taken": "Этот email уже используется", + "admin.full_name_required": "Укажите имя и фамилию", + "admin.invalid_role": "Неизвестная роль", + "admin.not_allowed": "Недостаточно прав", + "admin.previous_owner_role": "Прежний владелец становится администратором или сотрудником", + "admin.target_must_be_active": "Владение передаётся действующему сотруднику", + "admin.temporary_passwords_unsupported": "Временные пароли не поддерживаются", + "admin.unknown_password_mode": "Неизвестный режим выдачи пароля", + "ai.attachment_not_found": "Вложение не найдено", + "ai.category_not_found": "Категория не найдена", + "ai.documents_non_empty": "documents — непустой список", + "ai.file_required": "Нужен файл в поле file (multipart/form-data)", + "ai.group_id_integer_or_null": "groupId — целое число или null", + "ai.group_id_or_none": "group — идентификатор или none", + "ai.history_must_be_list": "history — список", + "ai.integration_id_integer": "integrationId — целое число", + "ai.knowledge_not_found": "Знание не найдено", + "ai.title_required": "Укажите заголовок", + "conversations.archived_boolean": "archived — булево значение", + "conversations.label_ids_list": "labelIds — список идентификаторов", + "identity.invalid_credentials": "Неверный email или пароль", + "identity.invalid_totp_code": "Неверный код", + "identity.invitation_email_mismatch": "Приглашение выписано на другой адрес", + "identity.invitation_invalid": "Приглашение недействительно или истекло", + "identity.role_conflict": "У пользователя уже другая роль в этой организации", + "identity.token_required": "Нужен токен", + "identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново", + "notifications.ids_list_or_all": "ids — список, либо all=true", + "notifications.push_types_list": "pushTypes — список", + "platform.idempotency_key_required": "Нужен заголовок Idempotency-Key", + "portals.articles_non_empty": "articles — непустой список", + "profile.current_password_invalid": "Текущий пароль неверен", + "profile.totp_enabled_already": "Двухфакторная аутентификация уже включена", + "profile.totp_required_by_policy": "Организация требует двухфакторную аутентификацию", + "ai.provider_error": "Ошибка провайдера: {error}", + "conversations.activity_closed": "Диалог закрыт · {channel}", + "conversations.activity_started": "Диалог · {channel}", + "conversations.field_too_long": "Поле {field}: не длиннее {limit} символов", + "ai.provider_no_transcription": "Провайдер {provider} не поддерживает расшифровку аудио", + "ai.transcription_failed": "Расшифровка не удалась: {error}", + "ai.transcription_failed_http": "Расшифровка не удалась: HTTP {code} {detail}", + "api.expected_integer": "{name}: ожидается целое число", + "api.expected_positive": "{name}: ожидается число больше нуля", + "api.expected_record_id": "{name}: ожидается идентификатор записи", + "settings.storage_unavailable": "Хранилище недоступно: {error}", + "admin.actor_system": "Система", + "admin.image_formats": "Поддерживаются PNG, JPEG и WebP", + "admin.logo_too_large": "Размер логотипа не должен превышать 2 МБ", + "calls.calls_off_entry_point": "Звонки отключены для этой точки входа", + "calls.failure_code_required": "Для FAILED требуется нормализованный failure_code", + "calls.video_off_entry_point": "Видеозвонки отключены для этой точки входа", + "channels.archived_channel_binding": "Нельзя привязать подключение к архивному каналу", + "channels.connection_not_bound": "Подключение не привязано к каналу", + "channels.connection_not_found": "Подключение не найдено", + "channels.llm_is_not_a_connection": "LLM-провайдер не является подключением канала", + "channels.name_required": "Название канала не может быть пустым", + "channels.name_too_long": "Название канала длиннее 255 символов", + "portals.a_record_mismatch": "A-запись домена указывает не на сервер Chatballs", + "portals.a_record_missing": "A-запись домена пока не найдена", + "portals.anonymous_widget_missing": "Активный анонимный Web-виджет поддержки не найден", + "portals.archived_article_publish": "Архивную статью нельзя публиковать", + "portals.archived_article_readonly": "Архивную статью нельзя изменять", + "portals.domain_empty_check": "Нельзя подтвердить пустой домен", + "portals.domain_is_internal": "Домен внутреннего приложения использовать нельзя", + "portals.domain_required_first": "Сначала укажите домен", + "portals.domain_same_as_installation": "Свой домен должен отличаться от адреса Chatballs", + "portals.domain_taken": "Этот домен уже используется другим порталом", + "portals.file_name_required": "Имя файла обязательно", + "portals.file_too_large": "Файл больше 25 МБ", + "portals.invalid_theme_id": "Некорректный идентификатор темы", + "portals.invalid_theme_scheme": "Неизвестная цветовая схема темы", + "portals.invalid_theme_setting_name": "Некорректное имя параметра темы", + "portals.move_articles_first": "Сначала переместите статьи из этого раздела", + "portals.move_nested_sections_first": "Сначала переместите или удалите вложенные разделы", + "portals.other_organization": "Портал принадлежит другой организации", + "portals.restore_to_edit_content": "Восстановите портал, чтобы изменить его содержимое", + "portals.restore_to_edit_domain": "Восстановите портал, чтобы изменить его домен", + "portals.restore_to_edit_settings": "Восстановите портал, чтобы изменить настройки", + "portals.restore_to_verify_domain": "Восстановите портал, чтобы подтвердить его домен", + "portals.revision_not_found": "Версия статьи не найдена", + "portals.slug_taken": "Этот адрес уже используется другим порталом", + "portals.theme_settings_json": "Параметры темы должны быть JSON-объектом", + "portals.theme_settings_object": "Параметры темы должны быть объектом", + "portals.theme_settings_too_large": "Параметры темы слишком велики", + "portals.unknown_status": "Неизвестный статус портала", + "portals.widget_channel_missing": "Канал веб-виджета не найден", + "profile.photo_too_large": "Размер фото не должен превышать 2 МБ", + "settings.local_or_s3": "LOCAL или S3", + "settings.object_written_not_readable": "Объект записан, но не читается — проверьте права на чтение", + "settings.seconds_number": "Число секунд", + "admin.group_name_required": "Укажите название группы", + "ai.agent_other_organization": "Агент принадлежит другой организации", + "ai.archived_agent_state": "Состояние архивного агента не меняется", + "ai.at_least_one_knowledge_id": "Нужен хотя бы один идентификатор знания", + "ai.attachment_other_organization": "Вложение принадлежит другой организации", + "ai.category_cycle": "Категорию нельзя перенести внутрь себя", + "ai.category_name_required": "Укажите название категории", + "ai.category_names_strings": "Названия категорий — непустые строки", + "ai.category_other_organization": "Категория принадлежит другой организации", + "ai.category_path_not_found": "Путь категории не найден", + "ai.category_path_required": "«categoryPath» — непустой список", + "ai.category_with_children": "Сначала перенесите вложенные категории", + "ai.category_with_knowledge": "Сначала перенесите знания из категории", + "ai.channel_required": "Укажите канал", + "ai.content_required": "Поле «content» обязательно", + "ai.description_string": "Поле «description» — строка", + "ai.document_object_required": "Документ должен быть объектом", + "ai.expected_attach_or_detach": "Ожидается attach или detach", + "ai.file_name_required": "Имя файла обязательно", + "ai.file_too_large_25": "Файл больше 25 МБ", + "ai.integration_model_required": "У интеграции не задана модель по умолчанию", + "ai.knowledge_ids_integers": "Идентификаторы знаний — целые числа", + "ai.knowledge_other_organization": "Знание принадлежит другой организации", + "ai.knowledge_unknown_or_unavailable": "Знание неизвестно или недоступно", + "ai.list_of_ids": "Нужен список идентификаторов", + "ai.list_of_knowledge_ids": "Нужен список идентификаторов знаний", + "ai.parent_category_not_found": "Родительская категория не найдена", + "ai.parent_category_other_organization": "Родительская категория принадлежит другой организации", + "ai.system_category_immutable": "Системная категория не изменяется", + "ai.system_category_name_immutable": "Название системной категории не меняется", + "ai.system_category_undeletable": "Системную категорию нельзя удалить", + "ai.unknown_knowledge_item": "Неизвестное знание", + "ai.unknown_portal_article": "Неизвестная статья портала", + "ai.unknown_provider_integration": "Неизвестная интеграция OpenRouter или Custom", + "api.at_least_one_id": "Нужен хотя бы один идентификатор", + "api.boolean_required": "Нужно булево значение", + "api.integer_id_required": "Нужен целочисленный идентификатор", + "api.integer_required": "Нужно целое число", + "api.list_of_ids_required": "Нужен список идентификаторов", + "api.object_required": "Нужен объект", + "api.positive_id_required": "Нужен положительный целочисленный идентификатор", + "channels.agent_exists": "У канала уже есть AI-агент", + "channels.not_found": "Канал не найден", + "channels.unknown_group": "Неизвестная группа", + "conversations.file_download_unsupported": "Этот канал не поддерживает скачивание файлов", + "conversations.telegram_getfile_failed": "Telegram не отдал файл", + "conversations.voice_download_unsupported": "Этот канал не поддерживает скачивание голосовых", + "portals.article_object_required": "Статья должна быть объектом", + "portals.locale_string": "Поле «locale» — строка", + "portals.summary_string": "Поле «summary» — строка", + "portals.widget_channel_mismatch": "Виджет и канал не совпадают", + "portals.widget_needs_anonymous": "Виджет портала должен допускать анонимные сессии", + "portals.widget_needs_channel": "Виджет портала должен быть привязан к каналу", + "settings.allowed_origins_list": "allowedOrigins — список строк", + "settings.api_key_required": "Укажите API-ключ", + "settings.custom_base_url_required": "Укажите Base URL", + "settings.custom_model_required": "Укажите модель", + "settings.email_hosts_required": "Укажите адрес, IMAP- и SMTP-сервер", + "settings.email_not_a_bot": "Почта не может быть ботом уведомлений", + "settings.inactive_channel": "Неактивный канал не принимает подключения", + "settings.integration_other_organization": "Интеграция принадлежит другой организации", + "settings.mailbox_password_required": "Укажите пароль ящика", + "settings.name_required": "Укажите название", + "settings.ports_numbers": "Порты — числа", + "settings.quick_replies_list": "quickReplies — список строк", + "settings.unknown_provider": "Неизвестный провайдер", + "ai.field_required": "Поле «{field}» обязательно", + "ai.unexpected_provider_response": "Неожиданный ответ провайдера: {error}", + "settings.base_url_rejected": "Base URL: {error}", + "settings.proxy_url_rejected": "Прокси: {error}", + "notifications.type_dialog_waiting": "Диалог ждёт оператора", + "notifications.type_dialog_new_message": "Новое сообщение в диалоге", + "notifications.type_release_published": "Опубликован релиз агента", + "notifications.type_integration_error": "Ошибка интеграции", + "integrations.check_no_connection": "Нет связи: {error}", + "integrations.check_api_key_missing": "Не указан API-ключ", + "integrations.check_base_url_missing": "Не указан Base URL", + "integrations.check_bot_token_missing": "Не указан токен бота", + "integrations.check_mailbox_password_missing": "Не указан пароль ящика", + "integrations.check_mail_hosts_missing": "Не заполнены адрес, IMAP- или SMTP-хост", + "integrations.check_provider_answered": "{provider} ответил {status}", + "integrations.check_endpoint_answered": "Эндпоинт ответил {status}", + "integrations.check_key_accepted": "ключ принят", + "integrations.check_bot_connected": "бот подключён", + "integrations.check_demo": "Демо-провайдер: отвечает по знаниям агента, без внешних запросов и ключей", + "integrations.check_unsupported": "Проверка для этого типа подключения не поддерживается", + "integrations.check_web_not_bound": "Подключение не привязано к каналу — виджет не активен", + "integrations.check_web_no_config": "Конфигурация Web-виджета не создана", + "integrations.check_web_no_origins": "Не заданы разрешённые домены — виджет будет недоступен на сайте", + "integrations.check_web_active": "Web-виджет активен · канал «{channel}»", + "integrations.check_endpoint_models": {"one": "Эндпоинт отвечает: {count} модель", "few": "Эндпоинт отвечает: {count} модели", "many": "Эндпоинт отвечает: {count} моделей", "other": "Эндпоинт отвечает: {count} модели"}, + "identity.password_help": "Пароль должен содержать букву, цифру и спецсимвол.", + "conversations.attachment_too_large": "Файл больше 20 МБ", + "conversations.attachment_empty": "Пустой файл", + "conversations.attachment_type_blocked": "Такой тип файла отправить нельзя", + "settings.s3_bucket_required": "Укажите имя бакета", + "settings.s3_endpoint_scheme": "Адрес должен начинаться с http:// или https://", + "settings.s3_access_key_required": "Укажите Access Key", + "settings.s3_secret_key_required": "Укажите Secret Key", + "channels.binding_conflict": "Подключение уже привязано к другому каналу: перенос требует force", + "channels.operation_needs_manage": "{operation} требует channels.manage", + "channels.operation_status_change": "Изменение статуса канала", + "channels.operation_policy_change": "Изменение политики канала", + "conversations.dialog_status_closed": "Закрыт", + "conversations.dialog_status_operator": "Оператор", + "conversations.dialog_status_wait": "Ждёт оператора", + "setup.your_name_required": "Укажите ваше имя", + "setup.your_name_too_long": "Имя длиннее 255 символов", + "setup.email_invalid": "Укажите корректный e-mail", + "setup.check_fields": "Проверьте заполненные поля", + "setup.owner_position": "Владелец", + "ai.agent_without_llm_integration": "Агент не привязан к LLM-интеграции BYOK; выберите провайдера в настройках агента", + "ai.integration_is_not_llm": "Интеграция «{provider}» не является LLM-провайдером BYOK", + "settings.url_scheme_required": "Адрес должен начинаться с одной из схем: {schemes}", + "settings.download_http_only": "Скачивать можно только по http/https", + "settings.url_points_inside": "Адрес ведёт во внутреннюю сеть", + "settings.scheme_forbidden": "Схема {scheme} в исходящих запросах запрещена", + "conversations.contact_request": "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже.", + "conversations.contact_request_web": "Поделитесь, пожалуйста, номером телефона.", + "calls.invite_text": "Приглашаем вас на {kind}. Нажмите кнопку, чтобы перейти к звонку.", + "conversations.email_default_subject": "Ваше обращение", + "conversations.voice_message": "Голосовое сообщение", + "conversations.file_caption": "Файл: {name}", + "webchat.default_greeting": "Здравствуйте! Готов помочь и ответить на вопросы. Чем можем помочь?", + "webchat.default_consent": "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение.", + "webchat.guest_name": "Гость · {code}", + "channels.operation_agent_create": "Создание агента", + "channels.operation_agent_delete": "Удаление агента", + "identity.browser_unknown": "Браузер", + "settings.mail_test_subject": "Проверка почты Chatballs", + "settings.mail_test_body": "Это тестовое письмо из «Настроек». Если вы его читаете, почта установки работает.", + "notifications.binding_done": "Готово! Уведомления Chatballs подключены. Отключить можно в профиле.", + "notifications.binding_hint": "Это сервисный бот уведомлений Chatballs. Чтобы подключить уведомления, откройте профиль в Chatballs и нажмите «Подключить».", + "conversations.button_share_contact": "Поделиться контактом", + "conversations.button_join_call": "Перейти к звонку", + "ai.demo_handover": "Уточню этот вопрос у коллег и передам диалог сотруднику — он ответит в рабочее время.", + "ai.demo_handover_suffix": " Передаю диалог сотруднику — он поможет дальше.", + "ai.unknown_answer_language": "Неизвестный язык ответов агента", } diff --git a/apps/backend/chatballs/i18n/tests.py b/apps/backend/chatballs/i18n/tests.py index a7c2880..cd01963 100644 --- a/apps/backend/chatballs/i18n/tests.py +++ b/apps/backend/chatballs/i18n/tests.py @@ -49,6 +49,40 @@ class CatalogTests(SimpleTestCase): names(russian), names(table[key]), f"{key}: расходятся параметры в {code}" ) + def test_every_key_used_in_code_exists(self) -> None: + """Вызов t() без ключа в каталоге молча печатает сам ключ. + + Так уже случалось: строки заменили на вызовы, а ключи в каталог + дописать забыли — и вместо текста в ответе поехало «channels. + connection_not_found». Ошибка не падает и в логах не видна, поэтому + ищется здесь, обходом исходников. + """ + + import ast + + root = pathlib.Path(__file__).resolve().parents[1] + known = set(CATALOG[DEFAULT_LANGUAGE]) + unknown: list[str] = [] + for source in root.rglob("*.py"): + # Каталог — это сами словари; тесты намеренно зовут несуществующий + # ключ, проверяя фолбэк. + if "__pycache__" in source.parts or source.parts[-2:-1] == ("messages",): + continue + if "test" in source.name: + continue + for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))): + if not isinstance(node, ast.Call): + continue + if getattr(node.func, "id", "") not in ("t", "tn") or not node.args: + continue + first = node.args[0] + # Ключ, собранный из выражения, проверить статически нельзя — + # такие места отвечают за себя сами (см. _system_text). + if isinstance(first, ast.Constant) and isinstance(first.value, str): + if first.value not in known: + unknown.append(f"{source.name}:{node.lineno} {first.value}") + self.assertEqual(unknown, [], "ключи вызываются, но в каталоге их нет") + def test_unknown_key_returns_itself(self) -> None: self.assertEqual(t("нет.такого.ключа"), "нет.такого.ключа") diff --git a/apps/backend/chatballs/identity/administration_payloads.py b/apps/backend/chatballs/identity/administration_payloads.py index 0ffee50..4787dd4 100644 --- a/apps/backend/chatballs/identity/administration_payloads.py +++ b/apps/backend/chatballs/identity/administration_payloads.py @@ -4,6 +4,7 @@ from zoneinfo import available_timezones from django.urls import reverse +from chatballs.i18n import t from chatballs.i18n.languages import LANGUAGES from chatballs.identity.audit_catalog import ( audit_action_label, @@ -83,7 +84,7 @@ def audit_event_payload(event: AuditEvent) -> dict[str, object]: "id": event.id, "createdAt": event.created_at.isoformat(), "actorId": actor.id if actor is not None else None, - "actor": ((actor.full_name or actor.email) if actor is not None else "Система"), + "actor": ((actor.full_name or actor.email) if actor is not None else t("admin.actor_system")), "actorEmail": (actor.email if actor is not None else ""), "action": event.action, "actionLabel": audit_action_label(event.action), diff --git a/apps/backend/chatballs/identity/administration_services.py b/apps/backend/chatballs/identity/administration_services.py index c99264c..8a00c16 100644 --- a/apps/backend/chatballs/identity/administration_services.py +++ b/apps/backend/chatballs/identity/administration_services.py @@ -98,10 +98,10 @@ def replace_organization_logo( if not data: raise ValidationError({"file": t("admin.choose_logo_file")}) if len(data) > MAX_LOGO_BYTES: - raise ValidationError({"file": "Размер логотипа не должен превышать 2 МБ"}) + raise ValidationError({"file": t("admin.logo_too_large")}) detected = _image_type(data) if detected is None: - raise ValidationError({"file": "Поддерживаются PNG, JPEG и WebP"}) + raise ValidationError({"file": t("admin.image_formats")}) content_type, suffix = detected organization = Organization.objects.select_for_update().get( pk=context.organization_id diff --git a/apps/backend/chatballs/identity/administration_views.py b/apps/backend/chatballs/identity/administration_views.py index d3dfc8b..0adc68f 100644 --- a/apps/backend/chatballs/identity/administration_views.py +++ b/apps/backend/chatballs/identity/administration_views.py @@ -42,7 +42,7 @@ def _validation_response(error: ValidationError) -> Response: key: messages[0] if isinstance(messages, list) else str(messages) for key, messages in error.message_dict.items() } - detail = next(iter(payload.values()), "Проверьте заполненные поля") + detail = next(iter(payload.values()), t("setup.check_fields")) return Response({"detail": detail, "errors": payload}, status=400) return Response({"detail": "; ".join(error.messages)}, status=400) @@ -282,7 +282,7 @@ def _audit_actors(base) -> list[dict[str, object]]: ] actors.sort(key=lambda item: str(item["label"]).lower()) if base.filter(actor__isnull=True).exists(): - actors.append({"value": "system", "label": "Система"}) + actors.append({"value": "system", "label": t("admin.actor_system")}) return actors diff --git a/apps/backend/chatballs/identity/auth/invitations.py b/apps/backend/chatballs/identity/auth/invitations.py index fde7aba..c9d36c2 100644 --- a/apps/backend/chatballs/identity/auth/invitations.py +++ b/apps/backend/chatballs/identity/auth/invitations.py @@ -5,6 +5,7 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from chatballs.i18n import t from chatballs.identity.auth.common import _user_payload from chatballs.identity.invitation_service import InvitationError, accept_invitation @@ -23,7 +24,7 @@ class InvitationAcceptView(APIView): def post(self, request: Request) -> Response: token = str(request.data.get("token", "")).strip() if not token: - return Response({"detail": "token is required"}, status=400) + return Response({"detail": t("identity.token_required")}, status=400) try: accept_invitation(token=token, user=request.user) except InvitationError as error: diff --git a/apps/backend/chatballs/identity/auth/profile.py b/apps/backend/chatballs/identity/auth/profile.py index 6361b9c..0fb8143 100644 --- a/apps/backend/chatballs/identity/auth/profile.py +++ b/apps/backend/chatballs/identity/auth/profile.py @@ -28,11 +28,11 @@ class ProfileUpdateView(APIView): full_name = str(body.get("fullName", "")).strip() email = HumanUser.objects.normalize_email(str(body.get("email", "")).strip()) if not full_name: - return Response({"detail": "Full name is required"}, status=400) + return Response({"detail": t("admin.full_name_required")}, status=400) if not email: - return Response({"detail": "Email is required"}, status=400) + return Response({"detail": t("admin.email_required")}, status=400) if HumanUser.objects.exclude(id=request.user.id).filter(email=email).exists(): - return Response({"detail": "Email is already used"}, status=400) + return Response({"detail": t("admin.email_taken")}, status=400) request.user.full_name = full_name request.user.email = email @@ -97,7 +97,7 @@ class ProfilePasswordView(APIView): current_password = str(body.get("currentPassword", "")) new_password = str(body.get("newPassword", "")) if not request.user.check_password(current_password): - return Response({"detail": "Current password is invalid"}, status=400) + return Response({"detail": t("profile.current_password_invalid")}, status=400) try: validate_password(new_password, user=request.user) except DjangoValidationError as error: @@ -150,10 +150,10 @@ class ProfileTotpDisableView(APIView): def post(self, request: Request) -> Response: current_password = str(request.data.get("currentPassword", "")) if not request.user.check_password(current_password): - return Response({"detail": "Current password is invalid"}, status=400) + return Response({"detail": t("profile.current_password_invalid")}, status=400) if user_requires_totp(request.user.id): - return Response({"detail": "TOTP is required by an organization policy"}, status=409) + return Response({"detail": t("profile.totp_required_by_policy")}, status=409) request.user.totp_enabled = False request.user.totp_secret = "" request.user.save(update_fields=["totp_enabled", "totp_secret"]) @@ -199,7 +199,7 @@ class ChangeTemporaryPasswordView(APIView): current_password = str(body.get("currentPassword", "")) new_password = str(body.get("newPassword", "")) if not request.user.must_change_password and not request.user.check_password(current_password): - return Response({"detail": "Current password is invalid"}, status=400) + return Response({"detail": t("profile.current_password_invalid")}, status=400) try: validate_password(new_password, user=request.user) except DjangoValidationError as error: diff --git a/apps/backend/chatballs/identity/auth/sessions.py b/apps/backend/chatballs/identity/auth/sessions.py index b91fc40..9a49dc8 100644 --- a/apps/backend/chatballs/identity/auth/sessions.py +++ b/apps/backend/chatballs/identity/auth/sessions.py @@ -9,7 +9,7 @@ from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView -from chatballs.i18n import current_language +from chatballs.i18n import current_language, t from chatballs.identity.audit import record_audit_event from chatballs.identity.auth.common import _challenge_payload, _user_payload from chatballs.identity.auth.totp_utils import TOTP_SESSION_KEY, TOTP_STARTED_KEY @@ -48,7 +48,7 @@ class LoginView(APIView): user = authenticate(request, username=email, password=password) if user is None: record_audit_event(action="identity.login_failed", result=AuditResult.DENIED, request=request) - return Response({"detail": "Invalid credentials"}, status=401) + return Response({"detail": t("identity.invalid_credentials")}, status=401) if user.totp_enabled: request.session[TOTP_SESSION_KEY] = user.id # Шаг с кодом ждёт не вечно: см. TOTP_CHALLENGE_TTL_SECONDS. diff --git a/apps/backend/chatballs/identity/auth/totp.py b/apps/backend/chatballs/identity/auth/totp.py index 578e148..5beb58e 100644 --- a/apps/backend/chatballs/identity/auth/totp.py +++ b/apps/backend/chatballs/identity/auth/totp.py @@ -10,6 +10,7 @@ from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView +from chatballs.i18n import t from chatballs.identity.audit import record_audit_event from chatballs.identity.auth.common import _user_payload from chatballs.identity.auth.totp_utils import ( @@ -43,7 +44,7 @@ class TotpSetupView(APIView): def get(self, request: Request) -> Response: if request.user.totp_enabled: - return Response({"detail": "TOTP is already enabled"}, status=400) + return Response({"detail": t("profile.totp_enabled_already")}, status=400) secret = _ensure_totp_secret(request.user) account_name = request.user.email @@ -76,7 +77,7 @@ class TotpConfirmView(APIView): result=AuditResult.DENIED, request=request, ) - return Response({"detail": "Invalid TOTP code"}, status=400) + return Response({"detail": t("identity.invalid_totp_code")}, status=400) request.user.totp_enabled = True request.user.save(update_fields=["totp_enabled"]) @@ -99,7 +100,7 @@ class TotpVerifyView(APIView): pending_user_id = request.session.get(TOTP_SESSION_KEY) if not pending_user_id or _challenge_expired(request): _drop_challenge(request) - return Response({"detail": "TOTP challenge is not active"}, status=401) + return Response({"detail": t("identity.totp_challenge_inactive")}, status=401) try: # is_active обязателен: пароль приняли раньше, и без этой проверки @@ -109,7 +110,7 @@ class TotpVerifyView(APIView): ) except HumanUser.DoesNotExist: _drop_challenge(request) - return Response({"detail": "TOTP challenge is not active"}, status=401) + return Response({"detail": t("identity.totp_challenge_inactive")}, status=401) if not user.totp_enabled or not accept_totp_code( user, str(request.data.get("code", "")) @@ -120,7 +121,7 @@ class TotpVerifyView(APIView): result=AuditResult.DENIED, request=request, ) - return Response({"detail": "Invalid TOTP code"}, status=400) + return Response({"detail": t("identity.invalid_totp_code")}, status=400) # Отметку «последний код принят …» для карточки 2FA (кадр P1) уже # проставил accept_totp_code вместе с номером интервала. diff --git a/apps/backend/chatballs/identity/avatars.py b/apps/backend/chatballs/identity/avatars.py index 883506f..737a742 100644 --- a/apps/backend/chatballs/identity/avatars.py +++ b/apps/backend/chatballs/identity/avatars.py @@ -68,10 +68,10 @@ def replace_user_avatar(user: HumanUser, upload: UploadedFile) -> HumanUser: if not data: raise ValidationError({"file": t("profile.choose_photo_file")}) if len(data) > MAX_AVATAR_BYTES: - raise ValidationError({"file": "Размер фото не должен превышать 2 МБ"}) + raise ValidationError({"file": t("profile.photo_too_large")}) detected = image_type(data) if detected is None: - raise ValidationError({"file": "Поддерживаются PNG, JPEG и WebP"}) + raise ValidationError({"file": t("admin.image_formats")}) content_type, suffix = detected return set_user_avatar(user, data, content_type, suffix) diff --git a/apps/backend/chatballs/identity/demo_seed/data/en/channels_ai.json b/apps/backend/chatballs/identity/demo_seed/data/en/channels_ai.json new file mode 100644 index 0000000..c0a3f97 --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/data/en/channels_ai.json @@ -0,0 +1,384 @@ +{ + "schemaVersion": 1, + "_comment": "English twin of data/ru/channels_ai.json. Agents = cards over Channel+AIAgent (ADR-HUB-0041 §4). Connections are dialog entry points; the LLM provider is shared across the organization (BYOK). There are two web widgets: the anonymous one on the site (agent «Consultant») and the authenticated one in the customer account (agent «Site support», mandatory-identity policy — SPEC-HUB-0027 P4/P5). The demo LLM provider is the built-in «Demo provider» (no key): agents answer from the knowledge base straight after installation.", + "llmProvider": { + "key": "llm", + "provider": "DEMO", + "name": "Demo provider", + "secret": "", + "config": {}, + "status": "OK" + }, + "agents": [ + { + "key": "consultant", + "code": "consultant", + "name": "Consultant", + "group": "operators", + "aiStatus": "ACTIVE", + "persona": "You are a consultant at Nord Atelier. The atelier makes curtains, voile and home textiles to measure and delivers across the country.", + "tone": "Friendly, brief, no officialese. Use the client's name if they have given it.", + "instructions": "Answer only from the atelier's knowledge. Quote lead times, prices and order status only from the knowledge base; if there is no answer, say you will check with colleagues and hand the dialog to a member of staff. Do not promise discounts. If the client asks for a human, hand the dialog over straight away.", + "knowledge": [ + "pricing", + "fabrics", + "measure", + "delivery", + "care" + ], + "portalArticles": [ + "how-to-measure", + "lead-times" + ], + "limits": { + "dailyCostUsd": 300 + }, + "createdDaysAgo": 41 + }, + { + "key": "support", + "code": "site-support", + "name": "Site support", + "group": "support", + "aiStatus": "ACTIVE", + "persona": "You are the support assistant for the Nord Atelier website: accounts, orders, payment, delivery, returns.", + "tone": "Calm and to the point.", + "instructions": "Check the knowledge about accounts and returns first. For questions about a specific order with no order number, ask for the number. Complaints about quality go to a member of staff with a note.", + "knowledge": [ + "delivery", + "returns", + "account" + ], + "portalArticles": [ + "returns-and-exchanges", + "your-account" + ], + "limits": {}, + "createdDaysAgo": 30, + "policy": { + "allow_anonymous_sessions": true, + "allow_self_reported_contact": true + } + }, + { + "key": "portal", + "code": "portal", + "name": "Customer account", + "group": "support", + "aiStatus": "ACTIVE", + "persona": "You are the assistant for the Nord Atelier customer account: orders, payment, statuses, delivery, returns.", + "tone": "Calm and to the point.", + "instructions": "Rely on the knowledge about accounts and orders. Ask for the order number if it has not been given. Complaints about payment go to a member of staff.", + "knowledge": [ + "account", + "delivery", + "returns" + ], + "portalArticles": [ + "your-account", + "returns-and-exchanges" + ], + "limits": {}, + "createdDaysAgo": 28, + "policy": { + "allow_anonymous_sessions": true, + "allow_self_reported_contact": true + } + }, + { + "key": "reception", + "code": "reception", + "name": "Front desk", + "group": null, + "aiStatus": "DRAFT", + "persona": "", + "tone": "", + "instructions": "", + "knowledge": [], + "portalArticles": [], + "limits": {}, + "createdDaysAgo": 3 + }, + { + "key": "legacy", + "code": "old-line", + "name": "Old line (Instagram)", + "group": "vip", + "isActive": false, + "aiStatus": "DISABLED", + "persona": "Atelier consultant.", + "tone": "", + "instructions": "This channel was closed in the spring of 2026.", + "knowledge": [ + "pricing" + ], + "portalArticles": [], + "limits": {}, + "createdDaysAgo": 180 + } + ], + "connections": [ + { + "key": "tg", + "provider": "TELEGRAM", + "name": "Telegram · @NordAtelierBot", + "agent": "consultant", + "status": "OK", + "secret": "0000000000:DEMO-token-not-real", + "config": { + "username": "NordAtelierBot" + }, + "createdDaysAgo": 40 + }, + { + "key": "max", + "provider": "MAX", + "name": "MAX · Nord Atelier", + "agent": "consultant", + "status": "OK", + "secret": "demo-max-token", + "config": {}, + "createdDaysAgo": 25 + }, + { + "key": "email", + "provider": "EMAIL", + "name": "Mail · info@nord-atelier.com", + "agent": "support", + "status": "OK", + "secret": "demo-imap-password", + "config": { + "email": "info@nord-atelier.com", + "imapHost": "imap.nord-atelier.com", + "smtpHost": "smtp.nord-atelier.com" + }, + "createdDaysAgo": 29 + }, + { + "key": "web", + "provider": "WEB", + "name": "Web Chat · nord-atelier.com", + "agent": "consultant", + "status": "OK", + "config": { + "title": "Nord Atelier", + "greeting": "Hello! We can help with fabrics, lead times and orders.", + "quick_replies": [ + "Lead times", + "Order status", + "Returns" + ], + "allowed_origins": [ + "https://nord-atelier.com" + ] + }, + "createdDaysAgo": 28 + }, + { + "key": "web_help", + "provider": "WEB", + "name": "Web Chat · Help section", + "agent": "support", + "status": "OK", + "config": { + "title": "Nord Atelier", + "greeting": "Hello! We can help with fabrics, lead times and orders.", + "quick_replies": [ + "Lead times", + "Order status", + "Returns" + ], + "allowed_origins": [ + "https://nord-atelier.com" + ] + }, + "createdDaysAgo": 28 + }, + { + "key": "web_account", + "provider": "WEB", + "name": "Web Chat · customer account", + "agent": "portal", + "status": "OK", + "config": { + "title": "Support · Nord Atelier", + "greeting": "Hello! We can see your order — how can we help?", + "quick_replies": [ + "Order status", + "Change the address", + "Returns" + ], + "allowed_origins": [ + "https://nord-atelier.com", + "https://account.nord-atelier.com" + ] + }, + "createdDaysAgo": 27 + }, + { + "key": "tg_delivery", + "provider": "TELEGRAM", + "name": "Telegram · delivery bot", + "agent": "reception", + "status": "ERROR", + "lastError": "401 Unauthorized: bot token was revoked in @BotFather", + "secret": "1111111111:DEMO-revoked", + "config": { + "username": "NordAtelierDeliveryBot" + }, + "createdDaysAgo": 3 + } + ], + "knowledgeCategories": [ + { + "key": "sewing", + "name": "Making and fabrics", + "children": [ + { + "key": "measure", + "name": "Measuring and quotes" + }, + { + "key": "fabrics", + "name": "Fabrics" + } + ] + }, + { + "key": "orders", + "name": "Orders and delivery", + "children": [ + { + "key": "delivery", + "name": "Delivery" + }, + { + "key": "returns", + "name": "Returns and exchanges" + } + ] + }, + { + "key": "site", + "name": "Website and customer account" + } + ], + "knowledge": [ + { + "key": "pricing", + "category": "measure", + "title": "Quoting for made-to-measure curtains", + "description": "The pricing formula, fullness ratios, minimum order", + "content": "Price = fabric (metres × price per metre) + making (by the finished width) + hardware.\n\nFullness ratio: 1.5 for heavy curtains, 2.0–2.5 for voile. Fabric width including fullness = track width × ratio.\n\nMaking: £18 per linear metre of finished width for curtains, £14 for voile. The bottom hem is included. Eyelets — £3.50 each, heading tape — £4.80 per metre.\n\nMinimum order — £120. The consultant quotes from the window width and drop; a measuring visit within London is free on orders over £600.", + "attachments": [ + "price-estimate.md" + ], + "createdDaysAgo": 40 + }, + { + "key": "fabrics", + "category": "fabrics", + "title": "Fabric catalogue 2026", + "description": "Curtain and voile fabrics in stock, prices per metre", + "content": "Curtain fabrics: Nord blackout (100% blackout, £59/m, 14 colours), linen blend (£35/m, 9 colours), Verona velvet (£87/m, 6 colours), canvas (£27/m, 12 colours).\n\nVoile: sheer voile (£15/m), Greek net (£20/m), linen voile (£29/m).\n\nEvery fabric is 280–300 cm wide. We post samples free of charge — up to 5 of them.", + "attachments": [ + { + "file": "fabric-catalogue-2026.pdf", + "text": "fabric-catalogue-2026.txt", + "contentType": "application/pdf" + } + ], + "createdDaysAgo": 38 + }, + { + "key": "measure", + "category": "measure", + "title": "How to measure your windows yourself", + "description": "Client instructions: track, drop, allowances", + "content": "1. Measure the length of the track from end to end (not the window!).\n2. Measure the drop from the track hook (or the top of the pole) to the length you want: to the sill (minus 1 cm), to the floor (minus 1–2 cm) or puddled on the floor (+3–5 cm).\n3. For eyelet curtains add 3 cm at the top.\n4. Photograph the whole window — a consultant will check the figures.", + "attachments": [], + "createdDaysAgo": 36 + }, + { + "key": "delivery", + "category": "delivery", + "title": "Making times and delivery", + "description": "Making 5–7 working days, delivery by DPD and courier", + "content": "Making takes 5–7 working days once the quote is agreed and paid. Express making (3 working days) costs +30% of the making price and only if the fabric is in stock.\n\nDelivery: within London by courier £15 (free over £400), slots 10:00–13:00, 13:00–17:00, 17:00–20:00; the courier calls an hour ahead. Across the country — DPD to a pickup point or to your door, 3–5 working days from dispatch, and the tracking number arrives by e-mail on the day of dispatch. Manchester, Birmingham and Leeds are usually 3 days.\n\nMoving a delivery slot is free of charge, no later than 18:00 the day before.", + "attachments": [ + "delivery-and-payment.txt" + ], + "createdDaysAgo": 35 + }, + { + "key": "returns", + "category": "returns", + "title": "Returns and exchanges", + "description": "14 days, and the terms for made-to-measure items", + "content": "Ready-made items (cushions, throws from the catalogue) — returnable within 14 days with no reason given, provided they have not been used.\n\nItems made to your own measurements cannot be returned (bespoke goods are exempt from the cancellation right under the Consumer Contracts Regulations), except where they are faulty or do not match the agreed order: wrong colour or size, defects in the making. In that case we remake them free of charge or exchange them for another colour in stock, in 5–7 working days; if the client prefers, we refund the money within 10 days.\n\nA claim needs the order number and photographs of the item. The claim is raised by a member of staff, not by the AI.", + "attachments": [], + "createdDaysAgo": 33 + }, + { + "key": "care", + "category": "fabrics", + "title": "Caring for your curtains", + "description": "Washing, ironing and cleaning by fabric type", + "content": "Blackout: delicate wash at 30°, no spin, iron on the reverse at the lowest temperature. Linen: wash at 40°, shrinkage of up to 3% is allowed for in the measurements. Velvet: dry clean only. Voile: wash at 30° in a mesh bag, dry it on the track.", + "attachments": [], + "createdDaysAgo": 20 + }, + { + "key": "account", + "category": "site", + "title": "The customer account on the website", + "description": "Registration, password recovery, order statuses", + "content": "You sign in with your e-mail. The password reset message comes from no-reply@nord-atelier.com within 5 minutes; if it has not arrived, check your spam folder and that the address is right. You can ask for another one after 10 minutes.\n\nOrder statuses in the account: “Awaiting payment” → “In production” → “Ready” → “Handed to the courier” → “Delivered”. The delivery address can be changed up to the “Handed to the courier” status — through a support agent.", + "attachments": [], + "createdDaysAgo": 28 + }, + { + "key": "old_prices", + "category": "measure", + "title": "2024 price list (out of date)", + "description": "Old prices — switched off, kept for the record", + "content": "Making £15/m, voile £11/m. Do not use.", + "attachments": [], + "isEnabled": false, + "createdDaysAgo": 400 + } + ], + "usage": { + "_comment": "Thirty days of LLM call history for the «AI usage» section: generated from a template.", + "days": 30, + "chatPerDay": { + "consultant": [ + 6, + 14 + ], + "support": [ + 3, + 9 + ], + "portal": [ + 1, + 4 + ] + }, + "embeddingPerDay": [ + 0, + 2 + ], + "model": "openai/gpt-4o-mini", + "promptTokens": [ + 600, + 1800 + ], + "completionTokens": [ + 80, + 260 + ], + "costMicrosPerToken": 0.6, + "failuresTotal": 3 + } +} diff --git a/apps/backend/chatballs/identity/demo_seed/data/en/conversations.json b/apps/backend/chatballs/identity/demo_seed/data/en/conversations.json new file mode 100644 index 0000000..afc9b07 --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/data/en/conversations.json @@ -0,0 +1,954 @@ +{ + "schemaVersion": 1, + "_comment": "English twin of data/ru/conversations.json: the same people, the same dialogs, the same frames — only the prose differs. Seven visible dialogs mirror frames A–H and A1 one for one; Maria's contact history is frame F. The remaining features (the VIP group, HTML mail, the portal and the customer-account snapshot, spam) are covered by archived and spam dialogs that are not in the main list. Time is relative: «now» = the moment of installation.", + "contacts": [ + { + "key": "maria", + "name": "Maria Sokolova", + "phone": "+44 7700 900140", + "avatar": "maria-sokolova.jpg", + "description": "Order 4471 — a set of curtains. A regular since August, prefers Telegram.", + "createdDaysAgo": 34 + }, + { + "key": "maria-duplicate", + "name": "Maria S.", + "phone": "+44 7700 900140", + "description": "Duplicate: came in from the website, not yet recognised by phone number.", + "createdDaysAgo": 12 + }, + { + "key": "irina", + "name": "Irina Levina", + "phone": "", + "avatar": "irina-levina.jpg", + "description": "Textile buyer for a hotel. Asked us to write by e-mail rather than call.", + "company": "The Riverside Hotel", + "createdDaysAgo": 60 + }, + { + "key": "artem", + "name": "Artem Gusev", + "phone": "", + "createdDaysAgo": 3 + }, + { + "key": "olga", + "name": "Olga Melnik", + "phone": "+44 7700 900731", + "avatar": "olga-melnik.jpg", + "createdDaysAgo": 12 + }, + { + "key": "sergey", + "name": "Sergey Krylov", + "phone": "+44 7700 900912", + "avatar": "sergey-krylov.jpg", + "city": "Manchester", + "createdDaysAgo": 9 + }, + { + "key": "viktor", + "name": "Viktor Panov", + "phone": "+44 20 7946 0210", + "company": "House of Textiles Ltd", + "city": "London", + "description": "Wholesale customer: a 46-room hotel. Handled by Kirill Volkov.", + "createdDaysAgo": 90 + }, + { + "key": "polina", + "name": "Polina Rakitina", + "phone": "", + "createdDaysAgo": 1 + }, + { + "key": "spammer", + "name": "", + "phone": "", + "createdDaysAgo": 0 + } + ], + "connectionIdentities": [ + { + "key": "maria_tg", + "contact": "maria", + "connection": "tg", + "externalUserId": "482913001", + "displayName": "Maria Sokolova", + "username": "maria_s" + }, + { + "key": "irina_email", + "contact": "irina", + "connection": "email", + "externalUserId": "irina@housetextiles.example", + "displayName": "Irina Levina" + }, + { + "key": "artem_max", + "contact": "artem", + "connection": "max", + "externalUserId": "58102277", + "displayName": "Artem Gusev" + }, + { + "key": "olga_tg", + "contact": "olga", + "connection": "tg", + "externalUserId": "482913004", + "displayName": "Olga Melnik", + "username": "olga_s" + }, + { + "key": "sergey_tg", + "contact": "sergey", + "connection": "tg", + "externalUserId": "482913007", + "displayName": "Sergey Krylov", + "username": "sergey_s" + }, + { + "key": "sergey_web", + "contact": "sergey", + "connection": "web_account", + "externalUserId": "web-sess-4402a", + "displayName": "Sergey Krylov" + }, + { + "key": "viktor_tg", + "contact": "viktor", + "connection": "tg", + "externalUserId": "482913006", + "displayName": "Viktor Panov", + "username": "v_panov" + }, + { + "key": "polina_email", + "contact": "polina", + "connection": "email", + "externalUserId": "polina.rakitina@example.com", + "displayName": "Polina Rakitina" + }, + { + "key": "spam_tg", + "contact": "spammer", + "connection": "tg", + "externalUserId": "900000001", + "displayName": "Instant Credit", + "username": "credit_fast_24" + } + ], + "labels": [ + { + "key": "delivery", + "name": "delivery", + "color": "#1677ff" + }, + { + "key": "return", + "name": "returns", + "color": "#eb2f96" + }, + { + "key": "account", + "name": "account", + "color": "#13c2c2" + }, + { + "key": "address", + "name": "address", + "color": "#fa8c16" + }, + { + "key": "mail", + "name": "mail", + "color": "#722ed1" + }, + { + "key": "order4390", + "name": "order 4390", + "color": "#52c41a" + }, + { + "key": "urgent", + "name": "urgent", + "color": "#f5222d" + }, + { + "key": "vip", + "name": "vip", + "color": "#b8860b" + }, + { + "key": "wholesale", + "name": "wholesale", + "color": "#0f9d58" + } + ], + "replyTemplates": [ + { + "title": "Greeting", + "text": "Hello! My name is {name}, I am a consultant at Nord Atelier. Could you tell me the track width and the window drop — I will work out a quote." + }, + { + "title": "Lead times", + "text": "Making takes 5–7 working days after payment. An express option in 3 days is possible if the fabric is in stock, at +30% of the making price." + }, + { + "title": "DPD delivery", + "text": "We send by DPD to a pickup point or to your door, 3–5 working days. The tracking number arrives by e-mail on the day of dispatch, by 19:00." + }, + { + "title": "Moving a delivery", + "text": "We have moved your delivery to {date}, slot {slot}. The courier will call an hour ahead. Please confirm that the slot works for you." + }, + { + "title": "Returns — asking for photos", + "text": "I am sorry this happened. Please send the order number and photographs of the item in daylight — I will raise a claim for a remake or an exchange." + }, + { + "title": "Fabric samples", + "text": "We will post up to 5 samples free of charge. Tell us which fabrics and colours you would like, and the address with the postcode." + }, + { + "title": "Closing a dialog", + "text": "Glad to have helped! If anything else comes up, write to us — I answer during working hours, 10:00 to 20:00." + } + ], + "conversations": [ + { + "key": "maria_delivery", + "agent": "consultant", + "connection": "tg", + "contact": "maria", + "identity": "maria_tg", + "externalChatId": "482913001", + "lifecycle": "OPEN", + "controlMode": "AI", + "expectedResponder": "CUSTOMER", + "group": "operators", + "priority": "HIGH", + "labels": [ + "delivery", + "urgent" + ], + "note": "", + "createdMinutesAgo": 12, + "lastActivityMinutesAgo": 4, + "readBy": [], + "previousConversation": "maria_measure", + "messages": [ + { + "author": "CONTACT", + "text": "Hello! I ordered a set of curtains from you, order number 4471. How long does delivery to Manchester take?", + "minutesAgo": 12 + }, + { + "author": "AI", + "text": "Hello, Maria. Delivery to Manchester takes 3–5 working days from dispatch. Order 4471 is being made at the moment — we expect to send it on Thursday.", + "minutesAgo": 12 + }, + { + "author": "CONTACT", + "text": "Can it be sped up? I need it by Saturday.", + "minutesAgo": 5 + }, + { + "author": "AI", + "text": "I understand. Express delivery is possible, but the terms are best checked with a specialist — I am passing the question to my colleagues.", + "minutesAgo": 5 + }, + { + "author": "CONTACT", + "text": "All right, I will wait", + "minutesAgo": 4 + } + ] + }, + { + "key": "dmitry_custom", + "agent": "consultant", + "connection": "web", + "webGuest": true, + "guestName": "Dmitry Orlov", + "guestAvatar": "dmitry-orlov.png", + "lifecycle": "OPEN", + "controlMode": "PAUSED", + "expectedResponder": "OPERATOR", + "group": "operators", + "priority": "MEDIUM", + "labels": [], + "note": "", + "createdMinutesAgo": 12, + "lastActivityMinutesAgo": 6, + "readBy": [], + "messages": [ + { + "author": "CONTACT", + "text": "Good afternoon. Can I order curtains made to my own measurements?", + "minutesAgo": 12 + }, + { + "author": "AI", + "text": "Good afternoon! Yes, we make to measure. Tell me the window width and drop and I will work out the price.", + "minutesAgo": 11.7 + }, + { + "author": "CONTACT", + "text": "I would like to talk to a human", + "minutesAgo": 6 + }, + { + "author": "SYSTEM", + "text": "The AI handed the dialog over: the client asked for a member of staff", + "minutesAgo": 6 + } + ] + }, + { + "key": "irina_status", + "agent": "support", + "connection": "email", + "contact": "irina", + "identity": "irina_email", + "externalChatId": "irina@housetextiles.example", + "lifecycle": "OPEN", + "controlMode": "HUMAN", + "expectedResponder": "CUSTOMER", + "assignedOperator": "elena", + "group": "support", + "priority": "HIGH", + "labels": [ + "order4390", + "mail" + ], + "note": "A regular, order 4390. Asked us to write by e-mail rather than call.", + "createdMinutesAgo": 54, + "lastActivityMinutesAgo": 26, + "readBy": [ + "elena" + ], + "transportMeta": { + "subject": "Order 4390 — the status is not changing" + }, + "messages": [ + { + "author": "CONTACT", + "text": "Hello. Order 4390 was paid for a week ago and the status is not changing. What is going on?", + "minutesAgo": 54, + "contentHtml": "

Hello.

Order 4390 was paid for a week ago and the status is not changing. What is going on?

— Irina Levina, purchasing

" + }, + { + "author": "AI", + "text": "Hello, Irina. I can see order 4390 with the status “In production”. I will check the timing with my colleagues — passing the question on.", + "minutesAgo": 54, + "contentHtml": "

Hello, Irina.

I can see order 4390 with the status “In production”. I will check the timing with my colleagues — passing the question on.

" + }, + { + "author": "SYSTEM", + "text": "Elena Kuznetsova took the dialog", + "minutesAgo": 43 + }, + { + "author": "OPERATOR", + "text": "Good afternoon, Irina. I have checked: the order has already gone to the courier, and the tracking number will arrive by e-mail today before 19:00.", + "minutesAgo": 26, + "authorUser": "elena", + "contentHtml": "

Good afternoon, Irina.

I have checked: the order has already gone to the courier, and the tracking number will arrive by e-mail today before 19:00.

" + } + ] + }, + { + "key": "artem_prepay", + "agent": "consultant", + "connection": "max", + "contact": "artem", + "identity": "artem_max", + "externalChatId": "58102277", + "lifecycle": "OPEN", + "controlMode": "AI", + "expectedResponder": "CUSTOMER", + "priority": "NONE", + "labels": [], + "note": "", + "createdMinutesAgo": 52, + "lastActivityMinutesAgo": 43, + "readBy": [ + "elena", + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Hello! I would like to order Roman blinds for the kitchen, two windows. How does payment work?", + "minutesAgo": 52 + }, + { + "author": "CONTACT", + "kind": "voice", + "voice": { + "file": "artem-prepayment.ogg", + "contentType": "audio/ogg", + "durationSeconds": 4, + "transcriptStatus": "READY", + "transcript": "What deposit do you take? Ideally give it to me as a percentage." + }, + "minutesAgo": 50 + }, + { + "author": "AI", + "text": "Hello, Artem! The deposit is 50% when you place the order, and the rest after the fitting, before delivery. You can pay by card or by bank transfer.", + "minutesAgo": 49 + }, + { + "author": "CONTACT", + "text": "Thanks, got it", + "minutesAgo": 43 + } + ] + }, + { + "key": "guest_password", + "agent": "support", + "connection": "web_help", + "webGuest": true, + "lifecycle": "OPEN", + "controlMode": "HUMAN", + "expectedResponder": "CUSTOMER", + "assignedOperator": "anna", + "group": "support", + "priority": "LOW", + "labels": [ + "account" + ], + "note": "", + "createdMinutesAgo": 94, + "lastActivityMinutesAgo": 77, + "readBy": [ + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "The password reset e-mail for my account never arrives", + "minutesAgo": 94 + }, + { + "author": "AI", + "text": "Please check your spam folder. If it is not there, I will pass the request to a member of staff.", + "minutesAgo": 94 + }, + { + "author": "CONTACT", + "text": "There is nothing there", + "minutesAgo": 82 + }, + { + "author": "SYSTEM", + "text": "Anna Kim took the dialog", + "minutesAgo": 79 + }, + { + "author": "OPERATOR", + "text": "I will check with my colleagues which address is on the account.", + "minutesAgo": 77, + "authorUser": "anna" + } + ] + }, + { + "key": "olga_return", + "agent": "consultant", + "connection": "tg", + "contact": "olga", + "identity": "olga_tg", + "externalChatId": "482913004", + "lifecycle": "OPEN", + "controlMode": "HUMAN", + "expectedResponder": "CUSTOMER", + "assignedOperator": "elena", + "group": "operators", + "priority": "NONE", + "labels": [ + "return" + ], + "note": "", + "createdMinutesAgo": 122, + "lastActivityMinutesAgo": 105, + "readBy": [ + "elena" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Hello! I have received the curtains, but the width is not what I ordered.", + "minutesAgo": 122 + }, + { + "author": "AI", + "text": "Hello, Olga. I am sorry about that. Exchanges and returns are possible within 14 days — I am passing the question to a member of staff.", + "minutesAgo": 122 + }, + { + "author": "CONTACT", + "kind": "voice", + "voice": { + "file": "olga-eighty-cm.ogg", + "contentType": "audio/ogg", + "durationSeconds": 3, + "transcriptStatus": "READY", + "transcript": "Can you see? Exactly eighty centimetres!" + }, + "minutesAgo": 114 + }, + { + "author": "SYSTEM", + "text": "Elena Kuznetsova took the dialog", + "minutesAgo": 110 + }, + { + "author": "OPERATOR", + "kind": "voice", + "voice": { + "file": "elena-start-with-numbers.ogg", + "contentType": "audio/ogg", + "durationSeconds": 7, + "transcriptStatus": "NONE" + }, + "minutesAgo": 108, + "authorUser": "elena" + }, + { + "author": "CONTACT", + "kind": "voice", + "voice": { + "file": "olga-saturday-post.ogg", + "contentType": "audio/ogg", + "durationSeconds": 3, + "transcriptStatus": "READY", + "transcript": "Does the post office deliver parcels on Saturdays?" + }, + "minutesAgo": 105 + } + ] + }, + { + "key": "sergey_address", + "agent": "consultant", + "connection": "tg", + "contact": "sergey", + "identity": "sergey_tg", + "externalChatId": "482913007", + "lifecycle": "CLOSED", + "controlMode": "HUMAN", + "expectedResponder": "NOBODY", + "assignedOperator": "elena", + "group": "operators", + "priority": "NONE", + "labels": [ + "address" + ], + "note": "", + "createdHoursAgo": 30, + "createdMinutesAgo": 9, + "lastActivityHoursAgo": 30, + "lastActivityMinutesAgo": 2, + "readBy": [ + "elena" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Hello, I would like to change the delivery address on order 4402", + "hoursAgo": 30, + "minutesAgo": 9 + }, + { + "author": "SYSTEM", + "text": "Elena Kuznetsova took the dialog", + "hoursAgo": 30, + "minutesAgo": 8 + }, + { + "author": "OPERATOR", + "authorUser": "elena", + "text": "Hello, Sergey. Send me the new address and I will change it.", + "hoursAgo": 30, + "minutesAgo": 7 + }, + { + "author": "CONTACT", + "text": "12 Deansgate, Flat 40, Manchester", + "hoursAgo": 30, + "minutesAgo": 5 + }, + { + "author": "OPERATOR", + "authorUser": "elena", + "text": "Done, the address is updated. Have a good day!", + "hoursAgo": 30, + "minutesAgo": 3 + }, + { + "author": "SYSTEM", + "text": "Dialog closed", + "hoursAgo": 30, + "minutesAgo": 2 + } + ] + }, + { + "key": "maria_measure", + "agent": "consultant", + "connection": "tg", + "contact": "maria", + "identity": "maria_tg", + "externalChatId": "482913001", + "lifecycle": "CLOSED", + "controlMode": "HUMAN", + "expectedResponder": "NOBODY", + "assignedOperator": "anna", + "group": "operators", + "priority": "NONE", + "labels": [], + "note": "", + "archivedDaysAgo": 20, + "createdDaysAgo": 24, + "lastActivityDaysAgo": 24, + "readBy": [ + "anna" + ], + "previousConversation": "maria_first", + "messages": [ + { + "author": "CONTACT", + "text": "Hello! Three windows, 2.4 by 2.7 each. How much would a set of curtains with voile cost?", + "daysAgo": 24, + "hoursAgo": 3 + }, + { + "author": "SYSTEM", + "text": "Anna Kim took the dialog", + "daysAgo": 24, + "hoursAgo": 3 + }, + { + "author": "OPERATOR", + "authorUser": "anna", + "text": "Hello, Maria! Anna Kim here. For three windows at 2.4 × 2.7: Nord blackout curtains and sheer voile — £1,940 including the making. I am sending the quote as a file.", + "daysAgo": 24, + "hoursAgo": 2 + }, + { + "author": "CONTACT", + "text": "That works! Go ahead, I will pay today.", + "daysAgo": 24, + "hoursAgo": 1 + }, + { + "author": "OPERATOR", + "authorUser": "anna", + "text": "I have placed order 4471. The payment link will come to this chat. Thank you!", + "daysAgo": 24, + "hoursAgo": 1 + }, + { + "author": "SYSTEM", + "text": "Dialog closed · handled by Anna Kim", + "daysAgo": 24 + } + ] + }, + { + "key": "maria_first", + "agent": "consultant", + "connection": "tg", + "contact": "maria", + "identity": "maria_tg", + "externalChatId": "482913001", + "lifecycle": "CLOSED", + "controlMode": "AI", + "expectedResponder": "NOBODY", + "group": "operators", + "priority": "NONE", + "labels": [], + "note": "", + "archivedDaysAgo": 30, + "createdDaysAgo": 33, + "lastActivityDaysAgo": 33, + "readBy": [ + "anna" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Hello, which fabrics do you have for a living room and how long does the making take?", + "daysAgo": 33, + "hoursAgo": 5 + }, + { + "author": "AI", + "text": "Hello, Maria! For a living room people usually take Nord blackout (14 colours) and Scandia linen. Making takes 5–7 working days after payment. I am sending the fabric catalogue.", + "daysAgo": 33, + "hoursAgo": 5 + }, + { + "author": "CONTACT", + "text": "Thank you, I will have a look!", + "daysAgo": 33, + "hoursAgo": 4 + }, + { + "author": "SYSTEM", + "text": "Dialog closed · AI", + "daysAgo": 33 + } + ] + }, + { + "key": "viktor_wholesale", + "agent": "consultant", + "connection": "tg", + "contact": "viktor", + "identity": "viktor_tg", + "externalChatId": "482913006", + "lifecycle": "OPEN", + "controlMode": "HUMAN", + "expectedResponder": "CUSTOMER", + "assignedOperator": "kirill", + "group": "vip", + "priority": "LOW", + "labels": [ + "vip", + "wholesale" + ], + "note": "Textile buyer for a hotel. Asked us to write by e-mail rather than call.", + "createdMinutesAgo": 4300, + "lastActivityMinutesAgo": 2900, + "readBy": [ + "kirill", + "elena" + ], + "messages": [ + { + "author": "CONTACT", + "text": "Good afternoon. House of Textiles, a 46-room hotel. We need blackout and voile for every window, all one size, 1.8 × 2.5. Do you have wholesale terms?", + "daysAgo": 3 + }, + { + "author": "AI", + "text": "Good afternoon, Viktor! Wholesale terms are handled by our VIP client manager — I am passing the dialog on.", + "daysAgo": 3 + }, + { + "author": "SYSTEM", + "text": "Kirill Volkov took the dialog", + "daysAgo": 3 + }, + { + "author": "OPERATOR", + "authorUser": "kirill", + "text": "Hello, Viktor! Kirill Volkov here. On 46 sets we give 12% off the fabric and the making, with a three-week lead time. I will send the quotation by e-mail today.", + "daysAgo": 3 + }, + { + "author": "CONTACT", + "text": "Excellent, I will wait for the quotation. My e-mail is on the card.", + "daysAgo": 2 + }, + { + "author": "CONTACT", + "kind": "contact", + "text": "+44 20 7946 0210", + "daysAgo": 2 + } + ], + "archivedDaysAgo": 1, + "createdDaysAgo": 3, + "lastActivityDaysAgo": 2 + }, + { + "key": "polina_password", + "agent": "support", + "connection": "email", + "contact": "polina", + "identity": "polina_email", + "externalChatId": "polina.rakitina@example.com", + "lifecycle": "CLOSED", + "controlMode": "HUMAN", + "expectedResponder": "NOBODY", + "group": "support", + "priority": "MEDIUM", + "labels": [], + "note": "", + "createdMinutesAgo": 240, + "lastActivityMinutesAgo": 200, + "readBy": [ + "igor" + ], + "transportMeta": { + "subject": "The password reset e-mail never arrives" + }, + "messages": [ + { + "author": "CONTACT", + "text": "The password reset e-mail for my account never arrives", + "contentHtml": "

Hello!

I am trying to reset the password for my account on nord-atelier.com — the e-mail has not arrived for an hour. The address is right, I have checked.

Kind regards,
Polina Rakitina

", + "minutesAgo": 240, + "daysAgo": 2 + }, + { + "author": "AI", + "text": "Please check your spam folder. If it is not there, I will pass the request to a member of staff.", + "contentHtml": "

Hello, Polina!

The message comes from no-reply@nord-atelier.com within 5 minutes. Please check your spam folder. If it is not there, I will pass the request to a member of staff.

", + "minutesAgo": 238, + "daysAgo": 2 + }, + { + "author": "CONTACT", + "text": "It is not in spam either. Please help.", + "contentHtml": "

It is not in spam either. Please help.

", + "minutesAgo": 200, + "daysAgo": 2 + }, + { + "author": "SYSTEM", + "text": "The AI handed the dialog over: the client is asking for a member of staff", + "minutesAgo": 200, + "daysAgo": 2 + }, + { + "author": "OPERATOR", + "authorUser": "igor", + "text": "Hello, Polina! Igor Saveliev here. The messages were going to an address with a typo — I have fixed it and sent the reset link again.", + "daysAgo": 2, + "contentHtml": "

Hello, Polina!

The messages were going to an address with a typo — I have fixed it and sent the reset link again.

Igor Saveliev, support

" + }, + { + "author": "SYSTEM", + "text": "Dialog closed · handled by Igor Saveliev", + "daysAgo": 2 + }, + { + "author": "OPERATOR", + "authorUser": "igor", + "text": "Hello, Polina! Igor Saveliev here. The messages were going to an address with a typo — I have fixed it and sent the reset link again.", + "daysAgo": 2, + "contentHtml": "

Hello, Polina!

The messages were going to an address with a typo — I have fixed it and sent the reset link again.

Igor Saveliev, support

" + }, + { + "author": "SYSTEM", + "text": "Dialog closed · handled by Igor Saveliev", + "daysAgo": 2 + }, + { + "author": "OPERATOR", + "authorUser": "igor", + "text": "Hello, Polina! Igor Saveliev here. The messages were going to an address with a typo — I have fixed it and sent the reset link again.", + "daysAgo": 2, + "contentHtml": "

Hello, Polina!

The messages were going to an address with a typo — I have fixed it and sent the reset link again.

Igor Saveliev, support

" + }, + { + "author": "SYSTEM", + "text": "Dialog closed · handled by Igor Saveliev", + "daysAgo": 2 + } + ], + "archivedDaysAgo": 1, + "createdDaysAgo": 2, + "lastActivityDaysAgo": 2, + "assignedOperator": "igor" + }, + { + "key": "portal_sergey", + "agent": "portal", + "connection": "web_account", + "contact": "sergey", + "identity": "sergey_web", + "externalChatId": "portal-sergey-1", + "lifecycle": "CLOSED", + "controlMode": "HUMAN", + "expectedResponder": "NOBODY", + "assignedOperator": "igor", + "group": "support", + "priority": "NONE", + "labels": [], + "note": "An authenticated request from the customer account (support portal).", + "createdDaysAgo": 6, + "lastActivityDaysAgo": 6, + "readBy": [ + "artem" + ], + "messages": [ + { + "author": "CONTACT", + "text": "I paid for order 4402 by bank transfer, but the status still says “Awaiting payment”. I am attaching the receipt.", + "daysAgo": 6 + }, + { + "author": "AI", + "text": "Hello, Sergey. A bank transfer can take up to 30 minutes to show. If the status does not change, I will pass this to a member of staff.", + "daysAgo": 6 + }, + { + "author": "SYSTEM", + "text": "Igor Saveliev took the dialog", + "daysAgo": 6 + }, + { + "author": "OPERATOR", + "authorUser": "igor", + "text": "Sergey, I found the payment and applied it by hand — order 4402 is now “In production”. Thank you for your patience!", + "daysAgo": 6 + }, + { + "author": "SYSTEM", + "text": "Dialog closed · handled by Igor Saveliev", + "daysAgo": 6 + } + ], + "archivedDaysAgo": 4 + }, + { + "key": "spam_credit", + "agent": "consultant", + "connection": "tg", + "contact": "spammer", + "identity": "spam_tg", + "externalChatId": "900000001", + "lifecycle": "SPAM", + "controlMode": "PAUSED", + "expectedResponder": "NOBODY", + "group": "operators", + "priority": "NONE", + "labels": [], + "note": "", + "createdMinutesAgo": 600, + "lastActivityMinutesAgo": 600, + "readBy": [ + "elena" + ], + "messages": [ + { + "author": "CONTACT", + "text": "GREAT BUSINESS LOANS up to £200,000 with no paperwork! Click: credit-fast24.example", + "minutesAgo": 600 + }, + { + "author": "SYSTEM", + "text": "Elena Kuznetsova marked the dialog as spam", + "minutesAgo": 590 + } + ] + } + ], + "contactMerges": [ + { + "target": "maria", + "source": "maria-duplicate", + "reason": "The same person: the phone number matched and it was confirmed in the dialog", + "actor": "elena" + } + ] +} diff --git a/apps/backend/chatballs/identity/demo_seed/data/en/operations.json b/apps/backend/chatballs/identity/demo_seed/data/en/operations.json new file mode 100644 index 0000000..a26fc4f --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/data/en/operations.json @@ -0,0 +1,161 @@ +{ + "schemaVersion": 1, + "calls": [ + { + "key": "call_maria", + "conversation": "maria_measure", + "initiatedBy": "anna", + "contactIdentity": "maria_tg", + "deliveryConnection": "tg", + "kind": "AUDIO", + "status": "ENDED", + "endedBy": "STAFF", + "daysAgo": 24, + "durationSeconds": 262, + "metrics": { + "connectionType": "RELAY", + "localCandidateType": "relay", + "remoteCandidateType": "relay", + "roundTripMs": 64 + } + }, + { + "key": "call_olga_video", + "conversation": "olga_return", + "initiatedBy": "elena", + "contactIdentity": "olga_tg", + "deliveryConnection": "tg", + "kind": "VIDEO", + "status": "ENDED", + "endedBy": "CUSTOMER", + "minutesAgo": 107, + "durationSeconds": 95, + "metrics": { + "connectionType": "DIRECT", + "localCandidateType": "srflx", + "remoteCandidateType": "srflx", + "roundTripMs": 31 + } + }, + { + "key": "call_sergey_missed", + "conversation": "sergey_address", + "initiatedBy": "elena", + "contactIdentity": "sergey_tg", + "deliveryConnection": "tg", + "kind": "AUDIO", + "status": "MISSED", + "hoursAgo": 30, + "minutesAgo": 6 + }, + { + "key": "call_viktor_declined", + "conversation": "viktor_wholesale", + "initiatedBy": "kirill", + "contactIdentity": "viktor_tg", + "deliveryConnection": "tg", + "kind": "AUDIO", + "status": "DECLINED", + "endedBy": "CUSTOMER", + "daysAgo": 3 + }, + { + "key": "call_sergey_expired", + "conversation": "portal_sergey", + "initiatedBy": "igor", + "contactIdentity": "sergey_web", + "deliveryConnection": "web_account", + "kind": "AUDIO", + "status": "EXPIRED", + "daysAgo": 6, + "invite": { + "deliveryStatus": "DELIVERED", + "expiredMinutesAfter": 2 + } + } + ], + "notifications": [ + { + "type": "DIALOG_WAITING", + "level": "WARNING", + "title": "Dmitry Orlov is waiting for an operator", + "body": "Web Chat: the client asked for a member of staff. Waiting 6 min.", + "targetRoute": "chat", + "targetConversation": "dmitry_custom", + "audience": "ALL", + "minutesAgo": 10 + }, + { + "type": "DIALOG_NEW_MESSAGE", + "level": "INFO", + "title": "New voice message from Olga Melnik", + "body": "“Does the post office deliver parcels on Saturdays?”", + "targetRoute": "chat", + "targetConversation": "olga_return", + "audience": "USER", + "recipientUser": "elena", + "minutesAgo": 105 + }, + { + "type": "DIALOG_NEW_MESSAGE", + "level": "INFO", + "title": "New message from Maria Sokolova", + "body": "“All right, I'll wait”", + "targetRoute": "chat", + "targetConversation": "maria_delivery", + "audience": "ALL", + "minutesAgo": 4 + }, + { + "type": "INTEGRATION_ERROR", + "level": "CRITICAL", + "title": "Telegram · delivery bot: connection error", + "body": "401 Unauthorized: bot token was revoked in @BotFather. Check the token in the connection settings.", + "targetRoute": "settings", + "audience": "OWNER", + "minutesAgo": 4200 + }, + { + "type": "RELEASE_PUBLISHED", + "level": "SUCCESS", + "title": "Chatballs update: voice messages", + "body": "Operators can send voice messages to Telegram and MAX, and incoming ones are transcribed with a single button.", + "targetRoute": "chat", + "audience": "ALL", + "daysAgo": 2, + "readBy": [ + "elena", + "anna", + "svetlana" + ] + } + ], + "messengerBindings": [ + { + "user": "elena", + "connection": "tg", + "externalChatId": "482900010", + "pushTypes": [ + "DIALOG_WAITING", + "DIALOG_NEW_MESSAGE", + "INTEGRATION_ERROR" + ] + }, + { + "user": "svetlana", + "connection": "tg", + "externalChatId": "482900011", + "pushTypes": [ + "DIALOG_WAITING" + ] + } + ], + "messengerBindingCodes": [ + { + "user": "igor", + "connection": "tg", + "code": "DEMO-7K2P", + "expiresInMinutes": 25 + } + ] +} diff --git a/apps/backend/chatballs/identity/demo_seed/data/en/organization.json b/apps/backend/chatballs/identity/demo_seed/data/en/organization.json new file mode 100644 index 0000000..7c264e4 --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/data/en/organization.json @@ -0,0 +1,134 @@ +{ + "schemaVersion": 1, + "_comment": "English demo set for «Nord Atelier». Same cast and keys as data/ru: avatars, voice recordings and cross-file references are shared, only the prose differs. Installed into the installer's own organization — neither the organization nor its owner is created here. All demo employees share one password, see demoPassword.", + "demoPassword": "Chatballs-Demo-2026", + "showcaseAccounts": [ + "elena", + "svetlana", + "igor" + ], + "groups": [ + { + "key": "operators", + "name": "Operators", + "color": "#1677ff" + }, + { + "key": "support", + "name": "Support", + "color": "#2aa876" + }, + { + "key": "vip", + "name": "VIP clients", + "color": "#eb2f96" + } + ], + "accounts": [ + { + "key": "elena", + "email": "e.kuznetsova@nord-atelier.com", + "fullName": "Elena Kuznetsova", + "role": "ADMIN", + "positionTitle": "Administrator", + "phone": "+44 20 7946 0116", + "groups": [ + "operators", + "support" + ], + "avatar": "elena-kuznetsova.png", + "uiTheme": "LIGHT", + "uiAccent": "#1677ff", + "createdDaysAgo": 120 + }, + { + "key": "anna", + "email": "a.kim@nord-atelier.com", + "fullName": "Anna Kim", + "role": "ADMIN", + "positionTitle": "Administrator", + "phone": "+44 20 7946 0117", + "groups": [ + "operators", + "support" + ], + "avatar": "anna-kim.png", + "uiTheme": "LIGHT", + "uiAccent": "#1677ff", + "createdDaysAgo": 110 + }, + { + "key": "svetlana", + "email": "s.petrova@nord-atelier.com", + "fullName": "Svetlana Petrova", + "role": "EMPLOYEE", + "positionTitle": "Operator", + "phone": "+44 20 7946 0120", + "groups": [ + "operators" + ], + "avatar": "svetlana-petrova.jpg", + "uiTheme": "DARK", + "uiAccent": "#7c3aed", + "createdDaysAgo": 80 + }, + { + "key": "igor", + "email": "i.saveliev@nord-atelier.com", + "fullName": "Igor Saveliev", + "role": "EMPLOYEE", + "positionTitle": "Support specialist", + "phone": "+44 20 7946 0119", + "groups": [ + "support" + ], + "uiTheme": "SYSTEM", + "uiAccent": "#0f9d58", + "createdDaysAgo": 60 + }, + { + "key": "kirill", + "email": "k.volkov@nord-atelier.com", + "fullName": "Kirill Volkov", + "role": "EMPLOYEE", + "positionTitle": "VIP client manager", + "phone": "", + "groups": [ + "vip" + ], + "totpEnabled": true, + "uiTheme": "LIGHT", + "uiAccent": "#d6336c", + "createdDaysAgo": 40 + }, + { + "key": "natalia", + "email": "n.frolova@nord-atelier.com", + "fullName": "Natalia Frolova", + "role": "EMPLOYEE", + "positionTitle": "Operator (left the company)", + "phone": "", + "groups": [ + "operators" + ], + "blocked": true, + "uiTheme": "SYSTEM", + "uiAccent": "#1677ff", + "createdDaysAgo": 200 + } + ], + "invitations": [ + { + "email": "v.kotova@nord-atelier.com", + "role": "EMPLOYEE", + "createdBy": "elena", + "expiresInDays": 6 + }, + { + "email": "old.invite@nord-atelier.com", + "role": "EMPLOYEE", + "createdBy": "elena", + "expiresInDays": -2 + } + ] +} diff --git a/apps/backend/chatballs/identity/demo_seed/data/en/support.json b/apps/backend/chatballs/identity/demo_seed/data/en/support.json new file mode 100644 index 0000000..df426d6 --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/data/en/support.json @@ -0,0 +1,122 @@ +{ + "schemaVersion": 1, + "portal": { + "slug": "nord-atelier", + "name": "Help · Nord Atelier", + "locale": "en", + "status": "PUBLISHED", + "widgetConnection": "web_help", + "categories": [ + { + "slug": "orders", + "name": "Orders and payment", + "description": "Placing an order, payment, statuses", + "sortOrder": 1 + }, + { + "slug": "delivery", + "name": "Delivery", + "description": "Lead times, courier, nationwide shipping", + "sortOrder": 2 + }, + { + "slug": "returns", + "name": "Returns and exchanges", + "description": "Return conditions for our products", + "sortOrder": 3 + }, + { + "slug": "care", + "name": "Caring for your curtains", + "description": "Washing and ironing by fabric type", + "sortOrder": 4 + } + ], + "articles": [ + { + "slug": "how-to-measure", + "category": "orders", + "title": "How to measure for curtains", + "summary": "Track, drop, allowances — in five minutes", + "content": "## Track length\n\nMeasure the track from end to end — not the window. Curtains should cover the window with 15–20 cm to spare on each side.\n\n## Drop\n\nFrom the track hook (or the top of the pole) to the length you want: to the sill minus 1 cm, to the floor minus 1–2 cm, puddled on the floor plus 3–5 cm.\n\n## Eyelets\n\nFor eyelet curtains add 3 cm at the top.\n\nPhotograph the whole window and send it to a consultant — we will check your figures free of charge.", + "status": "PUBLISHED", + "helpfulVotes": 14, + "unhelpfulVotes": 1, + "publishedDaysAgo": 27, + "files": [ + "katalog-tkanej-2026.pdf" + ] + }, + { + "slug": "lead-times", + "category": "delivery", + "title": "Making and delivery times", + "summary": "5–7 working days to make, 3–5 to deliver nationwide", + "content": "Making takes **5–7 working days** once the quote is agreed and paid.\n\n### Within London\n\nCourier — £15, free on orders over £400. Slots 10:00–13:00, 13:00–17:00, 17:00–20:00. The courier calls an hour ahead.\n\n### Across the country\n\nDPD to a pickup point or to your door, 3–5 working days. The tracking number arrives by e-mail on the day of dispatch.", + "status": "PUBLISHED", + "helpfulVotes": 22, + "unhelpfulVotes": 3, + "publishedDaysAgo": 26, + "secondRevision": { + "summary": "5–7 working days to make, 3–5 to deliver nationwide. Moving your slot is free", + "contentAppend": "\n\n### Moving your delivery\n\nYou can move the slot free of charge until 18:00 the day before — write to us in the chat or at info@nord-atelier.com.", + "daysAgo": 9 + } + }, + { + "slug": "returns-and-exchanges", + "category": "returns", + "title": "Returns and exchanges", + "summary": "Ready-made items — 14 days; made to measure — remade if faulty", + "content": "Ready-made items from the catalogue can be returned within **14 days** if they have not been used.\n\nItems made to your own measurements cannot be returned (bespoke goods are exempt from the cancellation right under the Consumer Contracts Regulations), except where they are faulty or do not match the order: wrong colour or size, defects in the making. In that case we remake or exchange them free of charge, in 5–7 working days.\n\nTo make a claim, send us your order number and photographs of the item in the chat.", + "status": "PUBLISHED", + "helpfulVotes": 9, + "unhelpfulVotes": 4, + "publishedDaysAgo": 25 + }, + { + "slug": "your-account", + "category": "orders", + "title": "Your account: signing in and resetting a password", + "summary": "The reset e-mail arrives within 5 minutes", + "content": "You sign in with your e-mail. The password reset message comes from no-reply@nord-atelier.com within 5 minutes. If it has not arrived, check your spam folder. You can ask for another one after 10 minutes.\n\nOrder statuses: “Awaiting payment” → “In production” → “Ready” → “Handed to the courier” → “Delivered”.", + "status": "PUBLISHED", + "helpfulVotes": 6, + "unhelpfulVotes": 0, + "publishedDaysAgo": 20 + }, + { + "slug": "curtain-care", + "category": "care", + "title": "How to care for your curtains", + "summary": "Washing and ironing by fabric type", + "content": "**Blackout** — delicate wash at 30°, no spin, iron on the reverse at the lowest setting.\n\n**Linen** — wash at 40°; shrinkage of up to 3% is already allowed for in the measurements.\n\n**Velvet** — dry clean only.\n\n**Voile** — wash at 30° in a mesh bag, dry it hanging on the track.", + "status": "PUBLISHED", + "helpfulVotes": 11, + "unhelpfulVotes": 0, + "publishedDaysAgo": 15 + }, + { + "slug": "paying-in-instalments", + "category": "orders", + "title": "Paying in instalments", + "summary": "Draft: 50/50 terms for orders over £1,200", + "content": "For orders over £1,200 you can pay 50% when you order and 50% before dispatch. Draft — to be agreed with the finance team.", + "status": "DRAFT", + "helpfulVotes": 0, + "unhelpfulVotes": 0 + }, + { + "slug": "summer-sale-2026", + "category": "orders", + "title": "Summer sale 2026", + "summary": "10% off voile until 31 August", + "content": "This offer has ended.", + "status": "ARCHIVED", + "helpfulVotes": 3, + "unhelpfulVotes": 0, + "publishedDaysAgo": 70 + } + ] + } +} diff --git a/apps/backend/chatballs/identity/demo_seed/data/media/delivery-and-payment.txt b/apps/backend/chatballs/identity/demo_seed/data/media/delivery-and-payment.txt new file mode 100644 index 0000000..0636118 --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/data/media/delivery-and-payment.txt @@ -0,0 +1,13 @@ +DELIVERY AND PAYMENT — consultant's crib sheet + +Making: 5–7 working days after payment. Express (3 days): +30% on the making, fabric in stock only. + +London, courier: £15, free over £400. +Slots: 10:00–13:00, 13:00–17:00, 17:00–20:00. The courier calls an hour ahead. +Moving a slot is free until 18:00 the day before. + +Nationwide, DPD: to a pickup point or to your door, 3–5 working days from dispatch. +The tracking number goes to the client's e-mail on the day of dispatch, by 19:00. +Manchester, Birmingham, Leeds — usually 3 days. + +Payment: 100% on ordering (card, bank transfer) or 50/50 for orders over £1,200. diff --git a/apps/backend/chatballs/identity/demo_seed/data/media/fabric-catalogue-2026.pdf b/apps/backend/chatballs/identity/demo_seed/data/media/fabric-catalogue-2026.pdf new file mode 100644 index 0000000..36b895e --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/data/media/fabric-catalogue-2026.pdf @@ -0,0 +1,32 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> +endobj +4 0 obj +<< /Length 496 >> +stream +BT /F1 12 Tf 50 780 Td 16 TL (Nord Atelier - Fabric catalogue 2026) Tj T* () Tj T* (Curtain fabrics, width 280-300 cm) Tj T* (Nord blackout - 100% blackout - GBP 59/m - 14 colours) Tj T* (Linen blend - GBP 35/m - 9 colours) Tj T* (Verona velvet - GBP 87/m - 6 colours) Tj T* (Canvas - GBP 27/m - 12 colours) Tj T* () Tj T* (Voile) Tj T* (Sheer voile - GBP 15/m) Tj T* (Greek net - GBP 20/m) Tj T* (Linen voile - GBP 29/m) Tj T* () Tj T* (Samples: up to 5 of them, posted free of charge.) Tj T* ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000788 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +858 +%%EOF diff --git a/apps/backend/chatballs/identity/demo_seed/data/media/fabric-catalogue-2026.txt b/apps/backend/chatballs/identity/demo_seed/data/media/fabric-catalogue-2026.txt new file mode 100644 index 0000000..68b31ca --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/data/media/fabric-catalogue-2026.txt @@ -0,0 +1,14 @@ +Fabric catalogue 2026 — Nord Atelier + +Curtain fabrics (280–300 cm wide) +Nord blackout — 100% blackout — £59/m — 14 colours +Linen blend — £35/m — 9 colours +Verona velvet — £87/m — 6 colours +Canvas — £27/m — 12 colours + +Voile +Sheer voile — £15/m +Greek net — £20/m +Linen voile — £29/m + +Samples: up to 5 of them, posted free of charge. diff --git a/apps/backend/chatballs/identity/demo_seed/data/media/price-estimate.md b/apps/backend/chatballs/identity/demo_seed/data/media/price-estimate.md new file mode 100644 index 0000000..a2e2d5d --- /dev/null +++ b/apps/backend/chatballs/identity/demo_seed/data/media/price-estimate.md @@ -0,0 +1,32 @@ +# Quoting for made-to-measure curtains + +## The formula + +**Price = fabric + making + hardware** + +| Component | How we count it | +| --- | --- | +| Fabric | metres × price per metre (see the fabric catalogue) | +| Making, curtains | £18 per linear metre of finished width | +| Making, voile | £14 per linear metre of finished width | +| Eyelets | £3.50 each (18 cm apart) | +| Heading tape | £4.80 per metre | + +## Fullness ratio + +- Heavy curtains — 1.5 +- Linen, canvas — 1.8 +- Voile, sheers — 2.0–2.5 + +Fabric width = track width × ratio. Fabric drop = finished drop + 15 cm for the hem. + +## Worked example + +A 3 m track, 2.6 m drop, Nord blackout at £59/m, two curtains. + +- Fabric: 3 × 1.5 = 4.5 m × £59 = £265.50 +- Making: 4.5 × £18 = £81 +- Tape: 4.5 × £4.80 = £21.60 +- **Total: £368.10** + +Minimum order — £120. Measuring within London is free on orders over £600. diff --git a/apps/backend/chatballs/identity/demo_seed/data/channels_ai.json b/apps/backend/chatballs/identity/demo_seed/data/ru/channels_ai.json similarity index 100% rename from apps/backend/chatballs/identity/demo_seed/data/channels_ai.json rename to apps/backend/chatballs/identity/demo_seed/data/ru/channels_ai.json diff --git a/apps/backend/chatballs/identity/demo_seed/data/conversations.json b/apps/backend/chatballs/identity/demo_seed/data/ru/conversations.json similarity index 100% rename from apps/backend/chatballs/identity/demo_seed/data/conversations.json rename to apps/backend/chatballs/identity/demo_seed/data/ru/conversations.json diff --git a/apps/backend/chatballs/identity/demo_seed/data/operations.json b/apps/backend/chatballs/identity/demo_seed/data/ru/operations.json similarity index 100% rename from apps/backend/chatballs/identity/demo_seed/data/operations.json rename to apps/backend/chatballs/identity/demo_seed/data/ru/operations.json diff --git a/apps/backend/chatballs/identity/demo_seed/data/organization.json b/apps/backend/chatballs/identity/demo_seed/data/ru/organization.json similarity index 100% rename from apps/backend/chatballs/identity/demo_seed/data/organization.json rename to apps/backend/chatballs/identity/demo_seed/data/ru/organization.json diff --git a/apps/backend/chatballs/identity/demo_seed/data/support.json b/apps/backend/chatballs/identity/demo_seed/data/ru/support.json similarity index 100% rename from apps/backend/chatballs/identity/demo_seed/data/support.json rename to apps/backend/chatballs/identity/demo_seed/data/ru/support.json diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py index 1b0c077..4462515 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/channels_ai.py @@ -28,7 +28,7 @@ from chatballs.webchat.widgets import ensure_widget def load(context: TenantContext, refs: DemoRefs) -> None: - data = manifest.load("channels_ai") + data = manifest.load("channels_ai", refs.language) current = now() llm = _ensure_llm_provider(context, refs, data["llmProvider"], current) diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py index 7f853a8..67fae90 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/conversations.py @@ -32,7 +32,7 @@ AVATAR_URL = "/api/v1/demo-media/avatars/{name}" def load(context: TenantContext, refs: DemoRefs) -> None: - data = manifest.load("conversations") + data = manifest.load("conversations", refs.language) organization = refs.organization current = now() diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/foundation.py b/apps/backend/chatballs/identity/demo_seed/loaders/foundation.py index 99b7dce..6b95fa3 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/foundation.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/foundation.py @@ -22,7 +22,7 @@ from chatballs.tenancy.context import TenantContext def load(context: TenantContext, refs: DemoRefs) -> None: - data = manifest.load("organization") + data = manifest.load("organization", refs.language) organization = refs.organization current = now() diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/operations.py b/apps/backend/chatballs/identity/demo_seed/loaders/operations.py index 3caad0a..5d70d00 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/operations.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/operations.py @@ -42,7 +42,7 @@ FINISHED = { def load(context: TenantContext, refs: DemoRefs) -> None: - data = manifest.load("operations") + data = manifest.load("operations", refs.language) current = now() for item in data.get("calls", []): _ensure_call(refs, item, current) diff --git a/apps/backend/chatballs/identity/demo_seed/loaders/support.py b/apps/backend/chatballs/identity/demo_seed/loaders/support.py index 29e44d3..25b81a8 100644 --- a/apps/backend/chatballs/identity/demo_seed/loaders/support.py +++ b/apps/backend/chatballs/identity/demo_seed/loaders/support.py @@ -34,7 +34,7 @@ from chatballs.tenancy.storage import adjust_storage_usage def load(context: TenantContext, refs: DemoRefs) -> None: - data = manifest.load("support") + data = manifest.load("support", refs.language) current = now() _ensure_portal(context, refs, data.get("portal"), current) _link_agent_articles(refs) diff --git a/apps/backend/chatballs/identity/demo_seed/manifest.py b/apps/backend/chatballs/identity/demo_seed/manifest.py index 4c61467..2429c69 100644 --- a/apps/backend/chatballs/identity/demo_seed/manifest.py +++ b/apps/backend/chatballs/identity/demo_seed/manifest.py @@ -16,6 +16,12 @@ from django.core.exceptions import ImproperlyConfigured DATA_DIR = Path(__file__).resolve().parent / "data" MEDIA_DIR = DATA_DIR / "media" +# Язык демо-данных: у каждого свой каталог манифестов. Демо — это витрина, и +# на английской установке она обязана быть английской целиком: имена, переписка, +# знания. Общими остаются только бинарные вложения в ``media`` (аватары, +# голосовые): их из текста не сделать. +DEFAULT_DATA_LANGUAGE = "ru" + SCHEMA_VERSION = 1 #: Файлы манифестов по доменам (имя файла без расширения). @@ -33,13 +39,16 @@ class ManifestError(ImproperlyConfigured): @cache -def load(name: str) -> dict | list: +def load(name: str, language: str = DEFAULT_DATA_LANGUAGE) -> dict | list: """Возвращает разобранный JSON-манифест ``name`` с проверкой схемы. - Кэшируется на процесс: повторные вызовы во время одного прогона сида - не перечитывают файл. + Кэшируется на процесс и на язык: повторные вызовы во время одного прогона + сида не перечитывают файл. Язык, для которого набора нет, откатывается к + языку по умолчанию — демо лучше показать на другом языке, чем не показать. """ - path = DATA_DIR / f"{name}.json" + path = DATA_DIR / language / f"{name}.json" + if not path.is_file(): + path = DATA_DIR / DEFAULT_DATA_LANGUAGE / f"{name}.json" if not path.is_file(): raise ManifestError(f"Demo manifest not found: {path}") try: diff --git a/apps/backend/chatballs/identity/demo_seed/orchestrator.py b/apps/backend/chatballs/identity/demo_seed/orchestrator.py index 71a8185..958cb4e 100644 --- a/apps/backend/chatballs/identity/demo_seed/orchestrator.py +++ b/apps/backend/chatballs/identity/demo_seed/orchestrator.py @@ -23,6 +23,7 @@ from chatballs.identity.demo_seed.loaders import ( from chatballs.identity.demo_seed.loaders import ( support as support_loader, ) +from chatballs.i18n import customer_language from chatballs.identity.demo_seed.refs import DemoRefs from chatballs.tenancy.context import TenantContext @@ -42,6 +43,9 @@ def run_demo_seed( refs = refs or DemoRefs() refs.organization = context.organization refs.recorder = recorder + # Демо ставится на языке организации: витрина обязана совпадать с тем, + # что человек увидит в остальном интерфейсе. + refs.language = customer_language(context.organization) foundation_loader.load(context, refs) channels_ai_loader.load(context, refs) diff --git a/apps/backend/chatballs/identity/demo_seed/refs.py b/apps/backend/chatballs/identity/demo_seed/refs.py index f190e3d..979f18d 100644 --- a/apps/backend/chatballs/identity/demo_seed/refs.py +++ b/apps/backend/chatballs/identity/demo_seed/refs.py @@ -19,6 +19,8 @@ class DemoRefs: organization: object | None = None recorder: object | None = None + # Язык набора демо-данных; loaders читают манифесты только на нём. + language: str = "ru" groups: dict[str, object] = field(default_factory=dict) memberships: dict[str, object] = field(default_factory=dict) users: dict[str, object] = field(default_factory=dict) diff --git a/apps/backend/chatballs/identity/demo_seed/service.py b/apps/backend/chatballs/identity/demo_seed/service.py index dae7404..0c0c90a 100644 --- a/apps/backend/chatballs/identity/demo_seed/service.py +++ b/apps/backend/chatballs/identity/demo_seed/service.py @@ -38,9 +38,11 @@ class DemoBusy(ValidationError): def showcase_accounts(organization_id: int) -> list[dict[str, object]]: """Учётки для входа «посмотреть глазами сотрудника»: админ и по одному сотруднику из разных групп. Пароль общий и намеренно публичный — это демо.""" + from chatballs.i18n import customer_language from chatballs.identity.demo_seed import manifest - data = manifest.load("organization") + organization = Organization.objects.filter(id=organization_id).first() + data = manifest.load("organization", customer_language(organization)) by_key = {item["key"]: item for item in data["accounts"]} groups = {item["key"]: item["name"] for item in data.get("groups", [])} keys = data.get("showcaseAccounts", []) diff --git a/apps/backend/chatballs/identity/employee_security_views.py b/apps/backend/chatballs/identity/employee_security_views.py index 3278f3b..5af6d1d 100644 --- a/apps/backend/chatballs/identity/employee_security_views.py +++ b/apps/backend/chatballs/identity/employee_security_views.py @@ -27,12 +27,12 @@ class EmployeeResetPasswordView(APIView): actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: - return Response({"detail": "Employee not found"}, status=404) + return Response({"detail": t("admin.employee_not_found")}, status=404) if not can_manage_employee(actor, profile, EmployeeAction.RESET_PASSWORD): return deny_employee_action(request, profile, EmployeeAction.RESET_PASSWORD) mode = clean_password_mode(request.data.get("mode")) if mode is None: - return Response({"detail": "Unknown password mode"}, status=400) + return Response({"detail": t("admin.unknown_password_mode")}, status=400) # Пароль живёт на пользователе, а не на членстве: сбрасывать его из одной # организации, когда человек работает и в другой, нельзя. if profile.user.memberships.count() > 1: @@ -58,7 +58,7 @@ class EmployeeRevokeSessionsView(APIView): actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: - return Response({"detail": "Employee not found"}, status=404) + return Response({"detail": t("admin.employee_not_found")}, status=404) if not can_manage_employee(actor, profile, EmployeeAction.TERMINATE_SESSIONS): return deny_employee_action(request, profile, EmployeeAction.TERMINATE_SESSIONS) revoked = revoke_user_sessions(profile.user_id) @@ -82,7 +82,7 @@ class EmployeeBlockView(APIView): actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: - return Response({"detail": "Employee not found"}, status=404) + return Response({"detail": t("admin.employee_not_found")}, status=404) if not can_manage_employee(actor, profile, EmployeeAction.BLOCK): return deny_employee_action(request, profile, EmployeeAction.BLOCK) profile.block() @@ -105,7 +105,7 @@ class EmployeeUnblockView(APIView): actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: - return Response({"detail": "Employee not found"}, status=404) + return Response({"detail": t("admin.employee_not_found")}, status=404) if not can_manage_employee(actor, profile, EmployeeAction.UNBLOCK): return deny_employee_action(request, profile, EmployeeAction.UNBLOCK) profile.unblock() diff --git a/apps/backend/chatballs/identity/employee_validation.py b/apps/backend/chatballs/identity/employee_validation.py index ee32c16..1868ba4 100644 --- a/apps/backend/chatballs/identity/employee_validation.py +++ b/apps/backend/chatballs/identity/employee_validation.py @@ -1,6 +1,7 @@ from rest_framework.request import Request from rest_framework.response import Response +from chatballs.i18n import t from chatballs.identity.audit import record_audit_event from chatballs.identity.models import ( POSITION_TITLE_MAX_LENGTH, @@ -31,7 +32,7 @@ def resolve_groups(organization, raw: object) -> tuple[list[EmployeeGroup] | Non requested = list(dict.fromkeys(raw)) groups = list(EmployeeGroup.objects.filter(organization=organization, id__in=requested)) if len(groups) != len(requested): - return None, "Group not found" + return None, t("admin.group_not_found") return groups, None @@ -49,4 +50,4 @@ def deny_employee_action( payload={"action": action, "targetRole": target.role if target else None}, request=request, ) - return Response({"detail": "You cannot perform this action on this employee"}, status=403) + return Response({"detail": t("admin.action_not_allowed_for_employee")}, status=403) diff --git a/apps/backend/chatballs/identity/employee_views.py b/apps/backend/chatballs/identity/employee_views.py index f84c7c1..b40fa6e 100644 --- a/apps/backend/chatballs/identity/employee_views.py +++ b/apps/backend/chatballs/identity/employee_views.py @@ -67,7 +67,7 @@ class EmployeeListView(APIView): def get(self, request: Request) -> Response: actor = request.tenant_context.membership if not has_capability_any_scope(actor, "employees.view"): - return Response({"detail": "Not allowed"}, status=403) + return Response({"detail": t("admin.not_allowed")}, status=403) page = paginate( employees_for(actor.organization_id, request.query_params), request.query_params, @@ -92,23 +92,23 @@ class EmployeeCreateView(APIView): requested_role = str(body.get("role", EmployeeRole.EMPLOYEE)) if requested_role not in ASSIGNABLE_ROLES: - return Response({"detail": "Invalid role"}, status=400) + return Response({"detail": t("admin.invalid_role")}, status=400) if not can_create_role(actor, requested_role): return deny_employee_action( request, None, f"{EmployeeAction.CREATE}:{requested_role}" ) if not email: - return Response({"detail": "Email is required"}, status=400) + return Response({"detail": t("admin.email_required")}, status=400) if not full_name: - return Response({"detail": "Full name is required"}, status=400) + return Response({"detail": t("admin.full_name_required")}, status=400) if position_error: return Response({"detail": position_error}, status=400) if provided_password: - return Response({"detail": "Temporary passwords are not supported"}, status=400) + return Response({"detail": t("admin.temporary_passwords_unsupported")}, status=400) if password_mode is None: - return Response({"detail": "Unknown password mode"}, status=400) + return Response({"detail": t("admin.unknown_password_mode")}, status=400) if HumanUser.objects.filter(email=email).exists(): - return Response({"detail": "Email is already used"}, status=400) + return Response({"detail": t("admin.email_taken")}, status=400) groups, groups_error = resolve_groups(actor.organization, body.get("groupIds")) if groups_error: @@ -164,9 +164,9 @@ class EmployeeDetailView(APIView): actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: - return Response({"detail": "Employee not found"}, status=404) + return Response({"detail": t("admin.employee_not_found")}, status=404) if not has_capability_any_scope(actor, "employees.view"): - return Response({"detail": "Employee not found"}, status=404) + return Response({"detail": t("admin.employee_not_found")}, status=404) return Response({"employee": employee_payload(profile, actor, include_detail=True)}) @@ -178,7 +178,7 @@ class EmployeeUpdateView(APIView): actor = request.tenant_context.membership profile = get_owned_profile(request, user_id) if profile is None: - return Response({"detail": "Employee not found"}, status=404) + return Response({"detail": t("admin.employee_not_found")}, status=404) if not can_manage_employee(actor, profile, EmployeeAction.UPDATE_PROFILE): return deny_employee_action(request, profile, EmployeeAction.UPDATE_PROFILE) @@ -192,18 +192,18 @@ class EmployeeUpdateView(APIView): requested_role = str(body.get("role", profile.role)) if not full_name: - return Response({"detail": "Full name is required"}, status=400) + return Response({"detail": t("admin.full_name_required")}, status=400) if not email: - return Response({"detail": "Email is required"}, status=400) + return Response({"detail": t("admin.email_required")}, status=400) if HumanUser.objects.exclude(id=profile.user_id).filter(email=email).exists(): - return Response({"detail": "Email is already used"}, status=400) + return Response({"detail": t("admin.email_taken")}, status=400) if position_error: return Response({"detail": position_error}, status=400) role_changing = requested_role != profile.role if role_changing: if requested_role not in ASSIGNABLE_ROLES: - return Response({"detail": "Invalid role"}, status=400) + return Response({"detail": t("admin.invalid_role")}, status=400) if not can_manage_employee(actor, profile, EmployeeAction.CHANGE_ROLE): return deny_employee_action(request, profile, EmployeeAction.CHANGE_ROLE) diff --git a/apps/backend/chatballs/identity/group_models.py b/apps/backend/chatballs/identity/group_models.py index c04814d..9e9bcb3 100644 --- a/apps/backend/chatballs/identity/group_models.py +++ b/apps/backend/chatballs/identity/group_models.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.db.models.functions import Lower +from chatballs.i18n import t from chatballs.identity.models import Organization, OrganizationMembership from chatballs.tenancy.models import TenantRelationModel @@ -39,7 +40,7 @@ class EmployeeGroup(models.Model): def clean(self) -> None: self.name = self.name.strip() if not self.name: - raise ValidationError({"name": "Group name is required"}) + raise ValidationError({"name": t("admin.group_name_required")}) class EmployeeGroupMember(TenantRelationModel): diff --git a/apps/backend/chatballs/identity/group_views.py b/apps/backend/chatballs/identity/group_views.py index cb87bed..77c9b39 100644 --- a/apps/backend/chatballs/identity/group_views.py +++ b/apps/backend/chatballs/identity/group_views.py @@ -55,7 +55,7 @@ def _resolve_members(organization, raw) -> tuple[list[OrganizationMembership] | ) ) if len(members) != len(requested): - return None, "Employee not found" + return None, t("admin.employee_not_found") return members, None @@ -121,7 +121,7 @@ class GroupDetailView(APIView): def patch(self, request: Request, group_id: int) -> Response: group = self._group(request, group_id) if group is None: - return Response({"detail": "Group not found"}, status=404) + return Response({"detail": t("admin.group_not_found")}, status=404) if "color" in request.data: color = _clean_color(request.data.get("color")) if color is None: @@ -163,7 +163,7 @@ class GroupDetailView(APIView): def delete(self, request: Request, group_id: int) -> Response: group = self._group(request, group_id) if group is None: - return Response({"detail": "Group not found"}, status=404) + return Response({"detail": t("admin.group_not_found")}, status=404) # SET_NULL на каналах и диалогах: их диалоги становятся общими. group_payload = _group_payload(group) group.delete() diff --git a/apps/backend/chatballs/identity/instance_settings.py b/apps/backend/chatballs/identity/instance_settings.py index b4430b5..0186635 100644 --- a/apps/backend/chatballs/identity/instance_settings.py +++ b/apps/backend/chatballs/identity/instance_settings.py @@ -136,6 +136,20 @@ def default_language() -> str: return normalize_language(row.default_language) if row is not None else "" +def remember_default_language(language: str) -> None: + """Записать язык установки. Пустое значение ничего не меняет.""" + + from chatballs.i18n.languages import normalize_language + + code = normalize_language(language) + if not code: + return + row = InstanceSettings.load() + if row.default_language != code: + row.default_language = code + row.save(update_fields=["default_language", "updated_at"]) + + def remember_public_host(raw_host: str, scheme: str = "http") -> None: """Запомнить адрес, на котором прошли мастер, если он ещё не задан.""" diff --git a/apps/backend/chatballs/identity/instance_views.py b/apps/backend/chatballs/identity/instance_views.py index 36f88b4..19554c3 100644 --- a/apps/backend/chatballs/identity/instance_views.py +++ b/apps/backend/chatballs/identity/instance_views.py @@ -149,7 +149,7 @@ class InstanceAddressView(APIView): except (TypeError, ValueError): return Response( {"detail": t("settings.ttl_is_seconds"), - "errors": {"turnTtlSeconds": "Число секунд"}}, + "errors": {"turnTtlSeconds": t("settings.seconds_number")}}, status=400, ) fields += ["turn_urls", "turn_ttl_seconds"] @@ -185,11 +185,8 @@ class InstanceEmailCheckView(APIView): recipient = str(request.data.get("email", "")).strip() or request.user.email try: send_mail( - subject="Проверка почты Chatballs", - message=( - "Это тестовое письмо из «Настроек». " - "Если вы его читаете, почта установки работает." - ), + subject=t("settings.mail_test_subject"), + message=t("settings.mail_test_body"), from_email=email_from_address(), recipient_list=[recipient], connection=connection, diff --git a/apps/backend/chatballs/identity/invitation_service.py b/apps/backend/chatballs/identity/invitation_service.py index b57c889..0de2593 100644 --- a/apps/backend/chatballs/identity/invitation_service.py +++ b/apps/backend/chatballs/identity/invitation_service.py @@ -8,6 +8,7 @@ from datetime import datetime from django.db import transaction from django.utils import timezone +from chatballs.i18n import t from chatballs.identity.audit import record_audit_event from chatballs.identity.models import ( EmployeeRole, @@ -99,12 +100,12 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation: if already is not None: return already raise InvitationError( - "Invitation is invalid or has expired", code="invitation_invalid" + t("identity.invitation_invalid"), code="invitation_invalid" ) normalized_email = user.email.strip().lower() if invitation.email.strip().lower() != normalized_email: raise InvitationError( - "Invitation email does not match the account", code="email_mismatch" + t("identity.invitation_email_mismatch"), code="email_mismatch" ) organization = invitation.organization @@ -143,7 +144,7 @@ def _ensure_owner_membership( # An existing non-OWNER membership for this user should not be silently # promoted by an invitation; surface as a conflict instead. raise InvitationError( - "User already has a different role in this organization", + t("identity.role_conflict"), code="role_conflict", ) return membership diff --git a/apps/backend/chatballs/identity/ownership_views.py b/apps/backend/chatballs/identity/ownership_views.py index ffcbce8..e1d9201 100644 --- a/apps/backend/chatballs/identity/ownership_views.py +++ b/apps/backend/chatballs/identity/ownership_views.py @@ -4,6 +4,7 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from chatballs.i18n import t from chatballs.identity.audit import record_audit_event from chatballs.identity.employee_support import employee_payload from chatballs.identity.employee_validation import ( @@ -31,16 +32,16 @@ class OwnershipTransferView(APIView): .get(user_id=user_id, organization=actor.organization) ) except OrganizationMembership.DoesNotExist: - return Response({"detail": "Employee not found"}, status=404) + return Response({"detail": t("admin.employee_not_found")}, status=404) if not can_manage_employee(actor, target, EmployeeAction.TRANSFER_OWNERSHIP): return deny_employee_action(request, target, EmployeeAction.TRANSFER_OWNERSHIP) if target.is_blocked or not target.user.is_active: - return Response({"detail": "Target must be an active employee"}, status=409) + return Response({"detail": t("admin.target_must_be_active")}, status=409) previous_owner_role = str(request.data.get("previousOwnerRole", EmployeeRole.ADMIN)) if previous_owner_role not in ASSIGNABLE_ROLES: - return Response({"detail": "Previous owner role must be ADMIN or EMPLOYEE"}, status=400) + return Response({"detail": t("admin.previous_owner_role")}, status=400) actor.role = previous_owner_role actor.save(update_fields=["role"]) diff --git a/apps/backend/chatballs/identity/password_validation.py b/apps/backend/chatballs/identity/password_validation.py index a7c43cb..d12a6a2 100644 --- a/apps/backend/chatballs/identity/password_validation.py +++ b/apps/backend/chatballs/identity/password_validation.py @@ -22,4 +22,4 @@ class PasswordComplexityValidator: raise ValidationError(t("identity.password_needs_special"), code="password_no_special") def get_help_text(self) -> str: - return "Пароль должен содержать букву, цифру и спецсимвол." + return t("identity.password_help") diff --git a/apps/backend/chatballs/identity/sessions.py b/apps/backend/chatballs/identity/sessions.py index 52c156e..eb31d7c 100644 --- a/apps/backend/chatballs/identity/sessions.py +++ b/apps/backend/chatballs/identity/sessions.py @@ -13,6 +13,8 @@ import re from django.contrib.sessions.models import Session from django.utils import timezone +from chatballs.i18n import t + SESSION_AGENT_KEY = "device_agent" SESSION_IP_KEY = "device_ip" SESSION_STARTED_KEY = "device_started" @@ -44,7 +46,7 @@ def describe_agent(user_agent: str) -> str: platform = next((label for token, label in _PLATFORMS if token in user_agent), "") if browser and platform: return f"{browser} · {platform}" - return browser or platform or "Браузер" + return browser or platform or t("identity.browser_unknown") def device_kind(user_agent: str) -> str: diff --git a/apps/backend/chatballs/identity/setup.py b/apps/backend/chatballs/identity/setup.py index bcc54ea..62a73e1 100644 --- a/apps/backend/chatballs/identity/setup.py +++ b/apps/backend/chatballs/identity/setup.py @@ -19,12 +19,13 @@ from django.contrib.auth.password_validation import validate_password from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.db import connections, transaction +from django.utils import translation from django.utils.text import slugify from chatballs.ai.knowledge_categories import ensure_uncategorized_category -from chatballs.i18n import t +from chatballs.i18n import INHERIT, current_language, normalize_language, t from chatballs.identity.audit import record_audit_event -from chatballs.identity.instance_settings import remember_public_host +from chatballs.identity.instance_settings import remember_default_language, remember_public_host from chatballs.identity.models import ( EmployeeRole, HumanUser, @@ -55,6 +56,9 @@ class SetupInput: email: str password: str install_demo: bool = False + # Язык установки, выбранный в мастере. Пусто — язык браузера, на котором + # мастер и открылся: человек его не трогал, значит он верен. + language: str = INHERIT @dataclass(frozen=True) @@ -79,17 +83,17 @@ def _clean(data: SetupInput) -> SetupInput: if not organization_name: errors["organizationName"] = t("admin.organization_name_required") elif len(organization_name) > ORGANIZATION_NAME_MAX_LENGTH: - errors["organizationName"] = "Название длиннее 255 символов" + errors["organizationName"] = t("admin.name_too_long") full_name = " ".join(data.full_name.split()) if not full_name: - errors["fullName"] = "Укажите ваше имя" + errors["fullName"] = t("setup.your_name_required") elif len(full_name) > FULL_NAME_MAX_LENGTH: - errors["fullName"] = "Имя длиннее 255 символов" + errors["fullName"] = t("setup.your_name_too_long") email = data.email.strip() try: validate_email(email) except ValidationError: - errors["email"] = "Укажите корректный e-mail" + errors["email"] = t("setup.email_invalid") if errors: raise ValidationError(errors) return SetupInput( @@ -98,6 +102,7 @@ def _clean(data: SetupInput) -> SetupInput: email=HumanUser.objects.normalize_email(email).lower(), password=data.password, install_demo=data.install_demo, + language=normalize_language(data.language), ) @@ -113,6 +118,24 @@ def _unique_slug(name: str) -> str: def complete_setup( data: SetupInput, public_host: str = "", public_scheme: str = "http" +) -> SetupResult: + """Создать установку целиком: язык, организация, владелец, демо-данные. + + Язык выбирается первым и держится активным до конца: на нём написана + должность владельца, на нём приедут демо-данные и на нём вернутся ошибки + формы. Иначе установка на английском получила бы русскую запись «Владелец» + в карточке сотрудника — с первой же секунды и навсегда. + """ + + # Пусто — берём язык, который middleware уже вывела из Accept-Language: + # именно на нём человек видел форму, пока её заполнял. + language = normalize_language(data.language) or current_language() + with translation.override(language): + return _complete_setup(data, language, public_host, public_scheme) + + +def _complete_setup( + data: SetupInput, language: str, public_host: str, public_scheme: str ) -> SetupResult: clean = _clean(data) with use_database(INSTANCE_DB_ALIAS), transaction.atomic(using=INSTANCE_DB_ALIAS): @@ -120,6 +143,7 @@ def complete_setup( # установки: другого источника у коробки нет. if public_host: remember_public_host(public_host, public_scheme) + remember_default_language(language) # Блокировка от гонки двух вкладок: второй запрос дождётся первого и # увидит созданную организацию. with connections[INSTANCE_DB_ALIAS].cursor() as cursor: @@ -150,7 +174,7 @@ def complete_setup( user=owner, organization=organization, role=EmployeeRole.OWNER, - position_title="Владелец", + position_title=t("setup.owner_position"), totp_required=False, ) record_audit_event( diff --git a/apps/backend/chatballs/identity/setup_views.py b/apps/backend/chatballs/identity/setup_views.py index 8b81fbf..8048a5e 100644 --- a/apps/backend/chatballs/identity/setup_views.py +++ b/apps/backend/chatballs/identity/setup_views.py @@ -33,7 +33,7 @@ def _validation_response(error: ValidationError) -> Response: if "__all__" in errors: errors["password"] = " ".join(error.message_dict["__all__"]) del errors["__all__"] - detail = next(iter(errors.values()), "Проверьте заполненные поля") + detail = next(iter(errors.values()), t("setup.check_fields")) return Response({"detail": detail, "errors": errors}, status=400) message = " ".join(error.messages) return Response({"detail": message, "errors": {"password": message}}, status=400) @@ -67,6 +67,7 @@ class SetupView(APIView): email=str(body.get("email", "")), password=str(body.get("password", "")), install_demo=bool(body.get("installDemo", False)), + language=str(body.get("language", "")), ), public_host=request.get_host(), public_scheme=request.scheme, diff --git a/apps/backend/chatballs/identity/test_seed_demo.py b/apps/backend/chatballs/identity/test_seed_demo.py index 963c331..b755a72 100644 --- a/apps/backend/chatballs/identity/test_seed_demo.py +++ b/apps/backend/chatballs/identity/test_seed_demo.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re import tempfile from django.apps import apps @@ -22,6 +23,7 @@ from chatballs.events.models import OutboxEvent from chatballs.identity.demo_models import DemoDataset, DemoDatasetStatus, DemoRecord from chatballs.identity.demo_seed import service from chatballs.identity.group_models import EmployeeGroup +from chatballs.identity.instance_settings import default_language from chatballs.identity.models import HumanUser, Organization, OrganizationMembership from chatballs.identity.setup import SetupInput, complete_setup from chatballs.tenancy.context import TenantActorKind, TenantContext @@ -243,3 +245,43 @@ class SetupWizardDemoTests(TestCase): dataset = DemoDataset.objects.get() self.assertEqual(dataset.status, DemoDatasetStatus.INSTALLING) self.assertTrue(OutboxEvent.objects.filter(event_type="demo.install_requested").exists()) + + +class EnglishDemoDatasetTests(TestCase): + """Демо на английской установке: набор ставится английский, целиком. + + Витрина показывает продукт целиком, и русская переписка в английском + интерфейсе обесценивает её ровно так же, как непереведённая кнопка. + """ + + @override_settings(MEDIA_ROOT=_MEDIA_ROOT) + def test_english_organization_gets_the_english_dataset(self) -> None: + result = complete_setup(SetupInput(**OWNER, language="en")) + organization = result.organization + context = TenantContext.for_resource( + organization, actor_kind=TenantActorKind.SYSTEM, actor_user=result.owner + ) + with tenant_atomic(context): + dataset = DemoDataset.objects.create( + organization=organization, status=DemoDatasetStatus.INSTALLING + ) + dataset = service.install(context=context, dataset=dataset) + self.assertEqual(dataset.status, DemoDatasetStatus.INSTALLED, dataset.error) + + # Язык установки пришёл из мастера и достался организации по наследству. + self.assertEqual(default_language(), "en") + + self.assertTrue( + Contact.objects.filter(organization=organization, name="Maria Sokolova").exists() + ) + self.assertTrue( + AIAgent.objects.filter(organization=organization, name="Consultant").exists() + ) + # Ни одной кириллической буквы во всём, что увидит человек на экране. + texts = [ + *Message.objects.filter(organization=organization).values_list("text", flat=True), + *Knowledge.objects.filter(organization=organization).values_list("content", flat=True), + *Contact.objects.filter(organization=organization).values_list("description", flat=True), + ] + cyrillic = [text for text in texts if re.search(r"[А-Яа-яЁё]", text or "")] + self.assertEqual(cyrillic, [], f"английское демо содержит русский текст: {cyrillic[:3]}") diff --git a/apps/backend/chatballs/integrations/checks.py b/apps/backend/chatballs/integrations/checks.py index 86eae51..d57c1f7 100644 --- a/apps/backend/chatballs/integrations/checks.py +++ b/apps/backend/chatballs/integrations/checks.py @@ -22,6 +22,7 @@ import urllib.request from django.conf import settings +from chatballs.i18n import t, tn from chatballs.integrations.proxy import build_opener DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" @@ -51,19 +52,19 @@ def _safe(fn) -> CheckResult: except urllib.error.HTTPError as error: return False, f"HTTP {error.code}: {error.reason}", {} except (urllib.error.URLError, TimeoutError, OSError) as error: - return False, f"Нет связи: {error}", {} + return False, t("integrations.check_no_connection", error=error), {} def check_openrouter(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult: if not secret: - return False, "Не указан API-ключ", {} + return False, t("integrations.check_api_key_missing"), {} base = (base_url or DEFAULT_OPENROUTER_BASE_URL).rstrip("/") def run() -> CheckResult: status, data = _get(f"{base}/key", headers={"Authorization": f"Bearer {secret}"}, proxy_url=proxy_url) if status != 200: - return False, f"OpenRouter ответил {status}", {} - label = (data.get("data") or {}).get("label") or "ключ принят" + return False, t("integrations.check_provider_answered", provider="OpenRouter", status=status), {} + label = (data.get("data") or {}).get("label") or t("integrations.check_key_accepted") return True, f"OpenRouter: {label}", {} return _safe(run) @@ -77,38 +78,38 @@ def check_custom(*, secret: str, base_url: str, proxy_url: str = "") -> CheckRes shape by listing models. GET /models with Authorization: Bearer . """ if not secret: - return False, "Не указан API-ключ", {} + return False, t("integrations.check_api_key_missing"), {} if not base_url: - return False, "Не указан Base URL", {} + return False, t("integrations.check_base_url_missing"), {} base = base_url.rstrip("/") def run() -> CheckResult: status, data = _get(f"{base}/models", headers={"Authorization": f"Bearer {secret}"}, proxy_url=proxy_url) if status != 200: - return False, f"Эндпоинт ответил {status}", {} + return False, t("integrations.check_endpoint_answered", status=status), {} # OpenAI shape: {"data": [{"id": "..."}, ...]}. Каталог не является # разрешительным списком (ADR-CHATBALLS-0020:89), ответственность за model # identifier лежит на владельце (ADR-CHATBALLS-0034 §4). count = len(data.get("data") or []) - return True, f"Эндпоинт отвечает: {count} моделей", {} + return True, tn("integrations.check_endpoint_models", count), {} return _safe(run) def check_max(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult: if not secret: - return False, "Не указан токен бота", {} + return False, t("integrations.check_bot_token_missing"), {} base = (base_url or DEFAULT_MAX_BASE_URL).rstrip("/") def run() -> CheckResult: # MAX: токен в заголовке Authorization (без Bearer), метод GET /me. status, data = _get(f"{base}/me", headers={"Authorization": secret}, proxy_url=proxy_url) if status != 200: - return False, f"MAX ответил {status}", {} + return False, t("integrations.check_provider_answered", provider="MAX", status=status), {} bot_id = data.get("user_id") username = data.get("username") or "" - name = data.get("name") or username or "бот подключён" + name = data.get("name") or username or t("integrations.check_bot_connected") meta = {"bot_id": str(bot_id) if bot_id else "", "bot_username": username, "bot_name": name} return True, f"MAX: {name}", meta @@ -129,9 +130,9 @@ def check_email(*, secret: str, config: dict) -> CheckResult: imap_host = str(config.get("imap_host", "")).strip() smtp_host = str(config.get("smtp_host", "")).strip() if not secret: - return False, "Не указан пароль ящика", {} + return False, t("integrations.check_mailbox_password_missing"), {} if not address or not imap_host or not smtp_host: - return False, "Не заполнены адрес, IMAP- или SMTP-хост", {} + return False, t("integrations.check_mail_hosts_missing"), {} timeout = settings.CHATBALLS_AI_REQUEST_TIMEOUT try: @@ -169,20 +170,20 @@ def check_email(*, secret: str, config: dict) -> CheckResult: def check_telegram(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult: if not secret: - return False, "Не указан токен бота", {} + return False, t("integrations.check_bot_token_missing"), {} base = (base_url or DEFAULT_TELEGRAM_BASE_URL).rstrip("/") def run() -> CheckResult: # Telegram: токен в пути /bot/getMe. status, data = _get(f"{base}/bot{secret}/getMe", proxy_url=proxy_url) if status != 200 or not data.get("ok"): - return False, f"Telegram ответил {status}", {} + return False, t("integrations.check_provider_answered", provider="Telegram", status=status), {} result = data.get("result") or {} bot_id = result.get("id") username = result.get("username") or "" name = result.get("first_name") or username or "бот подключён" meta = {"bot_id": str(bot_id) if bot_id else "", "bot_username": username, "bot_name": name} - detail = f"Telegram: @{username}" if username else "Telegram: бот подключён" + detail = f"Telegram: @{username}" if username else f'Telegram: {t("integrations.check_bot_connected")}' return True, detail, meta return _safe(run) @@ -190,4 +191,4 @@ def check_telegram(*, secret: str, base_url: str, proxy_url: str = "") -> CheckR def check_demo(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult: """Демо-провайдер не ходит в сеть — всегда готов.""" - return True, "Демо-провайдер: отвечает по знаниям агента, без внешних запросов и ключей", {} + return True, t("integrations.check_demo"), {} diff --git a/apps/backend/chatballs/integrations/outbound.py b/apps/backend/chatballs/integrations/outbound.py index 0814d74..5c535fc 100644 --- a/apps/backend/chatballs/integrations/outbound.py +++ b/apps/backend/chatballs/integrations/outbound.py @@ -18,6 +18,8 @@ OpenAI-совместимый сервер или собственный Bot API from __future__ import annotations +from chatballs.i18n import t + import ipaddress import socket from urllib.parse import urlsplit @@ -44,7 +46,7 @@ def clean_config_url(value: str, *, schemes: frozenset[str]) -> str: parsed = urlsplit(candidate) if parsed.scheme.lower() not in schemes or not parsed.hostname: allowed = ", ".join(f"{scheme}://" for scheme in sorted(schemes)) - raise OutboundUrlRejected(f"Адрес должен начинаться с одной из схем: {allowed}") + raise OutboundUrlRejected(t("settings.url_scheme_required", schemes=allowed)) return candidate @@ -61,13 +63,13 @@ def ensure_downloadable(url: str, *, allowed_host: str = "", via_proxy: bool = F parsed = urlsplit(str(url or "").strip()) scheme = parsed.scheme.lower() if scheme not in HTTP_SCHEMES or not parsed.hostname: - raise OutboundUrlRejected("Скачивать можно только по http/https") + raise OutboundUrlRejected(t("settings.download_http_only")) host = parsed.hostname.lower() if via_proxy or (allowed_host and host == allowed_host.lower()): return port = parsed.port or (443 if scheme == "https" else 80) if _resolves_to_private(host, port): - raise OutboundUrlRejected("Адрес ведёт во внутреннюю сеть") + raise OutboundUrlRejected(t("settings.url_points_inside")) def _resolves_to_private(host: str, port: int) -> bool: diff --git a/apps/backend/chatballs/integrations/proxy.py b/apps/backend/chatballs/integrations/proxy.py index 4b4130b..4d0b5e8 100644 --- a/apps/backend/chatballs/integrations/proxy.py +++ b/apps/backend/chatballs/integrations/proxy.py @@ -26,17 +26,17 @@ SOCKS_SCHEMES = ("socks5", "socks5h") class _RefusedFileHandler(urllib.request.FileHandler): def file_open(self, req): - raise OutboundUrlRejected("Схема file:// в исходящих запросах запрещена") + raise OutboundUrlRejected(t("settings.scheme_forbidden", scheme="file://")) class _RefusedFTPHandler(urllib.request.FTPHandler): def ftp_open(self, req): - raise OutboundUrlRejected("Схема ftp:// в исходящих запросах запрещена") + raise OutboundUrlRejected(t("settings.scheme_forbidden", scheme="ftp://")) class _RefusedDataHandler(urllib.request.DataHandler): def data_open(self, req): - raise OutboundUrlRejected("Схема data: в исходящих запросах запрещена") + raise OutboundUrlRejected(t("settings.scheme_forbidden", scheme="data:")) class _GuardedRedirectHandler(urllib.request.HTTPRedirectHandler): diff --git a/apps/backend/chatballs/integrations/services.py b/apps/backend/chatballs/integrations/services.py index 0222d4c..8af6e61 100644 --- a/apps/backend/chatballs/integrations/services.py +++ b/apps/backend/chatballs/integrations/services.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.db import transaction from django.utils import timezone +from chatballs.i18n import t from chatballs.identity.models import Organization from chatballs.integrations import checks from chatballs.integrations.models import ( @@ -44,9 +45,9 @@ def _resolve_channel( try: channel = Channel.objects.get(organization=organization, id=channel_id) except Channel.DoesNotExist as error: - raise ValidationError({"channel": "Channel not found"}) from error + raise ValidationError({"channel": t("channels.not_found")}) from error if not channel.is_active and channel.id != current_channel_id: - raise ValidationError({"channel": "Inactive channel cannot accept connections"}) + raise ValidationError({"channel": t("settings.inactive_channel")}) return channel @@ -54,17 +55,17 @@ def _email_config(config: dict) -> dict: """Email-подключение (ADR-CHATBALLS-0035): адрес и хосты IMAP/SMTP обязательны, порты/SSL имеют значения по умолчанию, purpose не поддерживается.""" if str(config.get("purpose", "")).strip(): - raise ValidationError({"config": "Email cannot be a notifications bot"}) + raise ValidationError({"config": t("settings.email_not_a_bot")}) address = str(config.get("email", "")).strip().lower() imap_host = str(config.get("imapHost", config.get("imap_host", ""))).strip() smtp_host = str(config.get("smtpHost", config.get("smtp_host", ""))).strip() if not address or not imap_host or not smtp_host: - raise ValidationError({"config": "Email address, IMAP host and SMTP host are required"}) + raise ValidationError({"config": t("settings.email_hosts_required")}) try: imap_port = int(config.get("imapPort", config.get("imap_port")) or 993) smtp_port = int(config.get("smtpPort", config.get("smtp_port")) or 465) except (TypeError, ValueError) as error: - raise ValidationError({"config": "Ports must be numbers"}) from error + raise ValidationError({"config": t("settings.ports_numbers")}) from error return { "email": address, "imap_host": imap_host, @@ -78,18 +79,18 @@ def _email_config(config: dict) -> dict: def _normalized_config(provider: str, config: dict) -> dict: if not isinstance(config, dict): - raise ValidationError({"config": "Object required"}) + raise ValidationError({"config": t("api.object_required")}) if provider == IntegrationProvider.EMAIL: return _email_config(config) if provider == IntegrationProvider.WEB: allowed = config.get("allowedOrigins", config.get("allowed_domains", [])) if not isinstance(allowed, list) or not all(isinstance(item, str) for item in allowed): - raise ValidationError({"config": "allowedOrigins must be a list of strings"}) + raise ValidationError({"config": t("settings.allowed_origins_list")}) quick_replies = config.get("quickReplies", config.get("quick_replies", [])) if not isinstance(quick_replies, list) or not all( isinstance(item, str) for item in quick_replies ): - raise ValidationError({"config": "quickReplies must be a list of strings"}) + raise ValidationError({"config": t("settings.quick_replies_list")}) return { "allowed_domains": [item.strip() for item in allowed if item.strip()], "title": str(config.get("title", "")).strip(), @@ -109,13 +110,13 @@ def _normalized_config(provider: str, config: dict) -> dict: try: result["base_url"] = clean_config_url(base_url, schemes=HTTP_SCHEMES) except OutboundUrlRejected as error: - raise ValidationError({"config": f"Base URL: {error}"}) from error + raise ValidationError({"config": t("settings.base_url_rejected", error=error)}) from error proxy_url = str(config.get("proxyUrl", config.get("proxy_url", ""))).strip() if proxy_url: try: result["proxy_url"] = clean_config_url(proxy_url, schemes=PROXY_SCHEMES) except OutboundUrlRejected as error: - raise ValidationError({"config": f"Proxy URL: {error}"}) from error + raise ValidationError({"config": t("settings.proxy_url_rejected", error=error)}) from error # LLM-провайдеры (OpenRouter, Custom) хранят модель по умолчанию свободным текстом. # Для OpenRouter поле исторически декоративно (SPEC-HUB-0005:388); для Custom оно # читается в рантайме (ADR-CHATBALLS-0034 §4). Версионирование модели — дорожка ADR-0034. @@ -140,9 +141,9 @@ def _normalized_config(provider: str, config: dict) -> dict: result["purpose"] = purpose if provider == IntegrationProvider.CUSTOM: if not base_url: - raise ValidationError({"config": "Custom Base URL is required"}) + raise ValidationError({"config": t("settings.custom_base_url_required")}) if "default_model" not in result: - raise ValidationError({"config": "Custom model is required"}) + raise ValidationError({"config": t("settings.custom_model_required")}) return result @@ -161,7 +162,7 @@ def _publish_web_widget(*, context: TenantContext, integration: Integration) -> def _validate_provider(provider: str) -> str: if provider not in IntegrationProvider.values: - raise ValidationError({"provider": "Unknown provider"}) + raise ValidationError({"provider": t("settings.unknown_provider")}) return provider @@ -171,11 +172,11 @@ def create_integration(*, context: TenantContext, data: IntegrationInput) -> Int provider = _validate_provider(data.provider) name = data.name.strip() if not name: - raise ValidationError({"name": "Name required"}) + raise ValidationError({"name": t("settings.name_required")}) if provider == IntegrationProvider.CUSTOM and not (data.secret or "").strip(): - raise ValidationError({"secret": "Custom API key is required"}) + raise ValidationError({"secret": t("settings.api_key_required")}) if provider == IntegrationProvider.EMAIL and not (data.secret or "").strip(): - raise ValidationError({"secret": "Mailbox password is required"}) + raise ValidationError({"secret": t("settings.mailbox_password_required")}) integration = Integration( organization=organization, kind=PROVIDER_KIND[provider], @@ -200,7 +201,7 @@ def update_integration( *, context: TenantContext, integration: Integration, data: IntegrationInput ) -> Integration: if integration.organization_id != context.organization_id: - raise ValidationError({"integration": "Integration belongs to another organization"}) + raise ValidationError({"integration": t("settings.integration_other_organization")}) integration.name = data.name.strip() or integration.name integration.config = _normalized_config(integration.provider, data.config) integration.channel = _resolve_channel( @@ -225,7 +226,7 @@ def update_integration( def delete_integration(*, context: TenantContext, integration: Integration) -> None: if integration.organization_id != context.organization_id: - raise ValidationError({"integration": "Integration belongs to another organization"}) + raise ValidationError({"integration": t("settings.integration_other_organization")}) integration.delete() @@ -242,7 +243,7 @@ def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, """Web-виджет обслуживается нашим же backend'ом — внешнего API нет. Проверяем конфигурацию конкретного widget entry point.""" if integration.channel_id is None: - return False, "Подключение не привязано к каналу — виджет не активен", {} + return False, t("integrations.check_web_not_bound"), {} from chatballs.webchat.widgets import ensure_widget try: @@ -250,18 +251,18 @@ def _check_web(context: TenantContext, integration: Integration) -> tuple[bool, except ValidationError as error: return False, "; ".join(error.messages), {} if widget is None: - return False, "Конфигурация Web-виджета не создана", {} + return False, t("integrations.check_web_no_config"), {} # Пустой allowed_origins в проде запрещает вообще все домены (webchat.services. # origin_allowed), и на сайте виджет молча показывает «Чат временно недоступен». # Проверка обязана падать здесь, а не оставлять зелёный статус при мёртвом чате. if not widget.allowed_origins: - return False, "Не заданы разрешённые домены — виджет будет недоступен на сайте", {} - return True, f"Web-виджет активен · канал «{integration.channel.name}»", {} + return False, t("integrations.check_web_no_origins"), {} + return True, t("integrations.check_web_active", channel=integration.channel.name), {} def test_integration(*, context: TenantContext, integration: Integration) -> Integration: if integration.organization_id != context.organization_id: - raise ValidationError({"integration": "Integration belongs to another organization"}) + raise ValidationError({"integration": t("settings.integration_other_organization")}) if integration.provider == IntegrationProvider.WEB: ok, detail, meta = _check_web(context, integration) elif integration.provider == IntegrationProvider.EMAIL: @@ -270,10 +271,12 @@ def test_integration(*, context: TenantContext, integration: Integration) -> Int else: check = _CHECKS.get(integration.provider) if check is None: - ok, detail, meta = False, "Проверка для этого типа подключения не поддерживается", {} + ok, detail, meta = False, t("integrations.check_unsupported"), {} else: ok, detail, meta = check(secret=integration.secret, base_url=str(integration.config.get("base_url", "")), proxy_url=str(integration.config.get("proxy_url", ""))) integration.status = IntegrationStatus.OK if ok else IntegrationStatus.ERROR + # Диагностика сохраняется на языке того, кто нажал «Проверить»: она живёт до + # следующей проверки, и хранить её кодом, как историю диалога, нечего. integration.last_error = "" if ok else detail integration.last_checked_at = timezone.now() update_fields = ["status", "last_error", "last_checked_at", "updated_at"] diff --git a/apps/backend/chatballs/integrations/test_outbound.py b/apps/backend/chatballs/integrations/test_outbound.py index 52688da..63e6a9c 100644 --- a/apps/backend/chatballs/integrations/test_outbound.py +++ b/apps/backend/chatballs/integrations/test_outbound.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.test import TestCase from chatballs.conversations.transports.base import download_bytes +from chatballs.i18n import t from chatballs.identity.bootstrap import bootstrap_owner from chatballs.identity.models import Organization from chatballs.integrations.models import IntegrationProvider @@ -111,7 +112,7 @@ class IntegrationConfigTests(TestCase): config={"baseUrl": "file:///etc/passwd", "defaultModel": "m"}, ), ) - self.assertIn("Base URL", str(raised.exception)) + self.assertIn(t("settings.base_url_rejected", error="").rstrip(), str(raised.exception)) def test_proxy_url_scheme_is_validated(self) -> None: with self.assertRaises(ValidationError) as raised: @@ -128,4 +129,6 @@ class IntegrationConfigTests(TestCase): }, ), ) - self.assertIn("Proxy URL", str(raised.exception)) + # Сообщение называет именно прокси, а не Base URL. Префикс берём из + # каталога: иначе проверка ломается от языка, на котором идёт прогон. + self.assertIn(t("settings.proxy_url_rejected", error="").rstrip(), str(raised.exception)) diff --git a/apps/backend/chatballs/integrations/tests.py b/apps/backend/chatballs/integrations/tests.py index 6f494c3..5f9739e 100644 --- a/apps/backend/chatballs/integrations/tests.py +++ b/apps/backend/chatballs/integrations/tests.py @@ -7,6 +7,7 @@ from django.test import TestCase from chatballs.identity.bootstrap import bootstrap_owner from chatballs.identity.models import Organization +from chatballs.i18n import tn from chatballs.integrations import checks from chatballs.integrations.models import ( Integration, @@ -647,7 +648,7 @@ class CustomIntegrationTests(TestCase): self.assertTrue(ok) - self.assertIn("1 моделей", detail) + self.assertEqual(detail, tn("integrations.check_endpoint_models", 1)) diff --git a/apps/backend/chatballs/notifications/binding.py b/apps/backend/chatballs/notifications/binding.py index 9849406..012d3cf 100644 --- a/apps/backend/chatballs/notifications/binding.py +++ b/apps/backend/chatballs/notifications/binding.py @@ -17,6 +17,7 @@ from django.utils import timezone from chatballs.conversations import transports from chatballs.conversations.transports.base import InboundMessage from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider +from chatballs.i18n import customer_language, first_chosen, normalize_language, t from chatballs.notifications.models import MessengerBinding, MessengerBindingCode logger = logging.getLogger(__name__) @@ -24,8 +25,6 @@ logger = logging.getLogger(__name__) CODE_TTL = timedelta(minutes=10) NOTIFIER_PURPOSE = "notifications" -CONFIRMATION_TEXT = "Готово! Уведомления Chatballs подключены. Отключить можно в профиле." -HINT_TEXT = "Это сервисный бот уведомлений Chatballs. Чтобы подключить уведомления, откройте профиль в Chatballs и нажмите «Подключить»." # База deep-link по провайдеру: и TG, и MAX поддерживают ?start=<код>. _DEEP_LINK_BASE = { @@ -92,7 +91,8 @@ def handle_notifier_inbound(integration: Integration, inbound: InboundMessage) - else None ) if binding_code is None: - transports.send_reply(integration, chat_id=inbound.chat_id, user_id=inbound.user_id, text=HINT_TEXT) + hint = t("notifications.binding_hint", language=customer_language(integration.organization)) + transports.send_reply(integration, chat_id=inbound.chat_id, user_id=inbound.user_id, text=hint) return with transaction.atomic(): # Уведомления идут ровно в один мессенджер: новая привязка заменяет прежние. @@ -106,7 +106,15 @@ def handle_notifier_inbound(integration: Integration, inbound: InboundMessage) - defaults={"external_chat_id": inbound.chat_id or inbound.user_id}, ) binding_code.delete() - transports.send_reply(integration, chat_id=inbound.chat_id, user_id=inbound.user_id, text=CONFIRMATION_TEXT) + # Подтверждение читает конкретный сотрудник — язык берём из его профиля. + done = t( + "notifications.binding_done", + language=first_chosen( + normalize_language(binding_code.user.ui_language), + customer_language(integration.organization), + ), + ) + transports.send_reply(integration, chat_id=inbound.chat_id, user_id=inbound.user_id, text=done) def poll_notifier_bots(context) -> int: diff --git a/apps/backend/chatballs/notifications/services.py b/apps/backend/chatballs/notifications/services.py index 41194b6..9da099f 100644 --- a/apps/backend/chatballs/notifications/services.py +++ b/apps/backend/chatballs/notifications/services.py @@ -16,11 +16,30 @@ from chatballs.notifications.selectors import unread_for # Реестр типов: дефолтный уровень и маршрут диплинка. Новый тип события — # одна запись здесь + вызов notify(...) из доменного сервиса. +# «label» — ключ каталога, а не текст: список типов уходит в профиль, где +# каждый сотрудник читает его на своём языке. Ярлык TextChoices остаётся +# английским/русским значением модели и до человека не доходит. TYPE_META: dict[str, dict] = { - NotificationType.DIALOG_WAITING: {"level": NotificationLevel.WARNING, "route": "chat"}, - NotificationType.DIALOG_NEW_MESSAGE: {"level": NotificationLevel.INFO, "route": "chat"}, - NotificationType.RELEASE_PUBLISHED: {"level": NotificationLevel.SUCCESS, "route": "agents"}, - NotificationType.INTEGRATION_ERROR: {"level": NotificationLevel.CRITICAL, "route": "integrations"}, + NotificationType.DIALOG_WAITING: { + "level": NotificationLevel.WARNING, + "route": "chat", + "label": "notifications.type_dialog_waiting", + }, + NotificationType.DIALOG_NEW_MESSAGE: { + "level": NotificationLevel.INFO, + "route": "chat", + "label": "notifications.type_dialog_new_message", + }, + NotificationType.RELEASE_PUBLISHED: { + "level": NotificationLevel.SUCCESS, + "route": "agents", + "label": "notifications.type_release_published", + }, + NotificationType.INTEGRATION_ERROR: { + "level": NotificationLevel.CRITICAL, + "route": "integrations", + "label": "notifications.type_integration_error", + }, } diff --git a/apps/backend/chatballs/notifications/views.py b/apps/backend/chatballs/notifications/views.py index 57c03a2..c821a76 100644 --- a/apps/backend/chatballs/notifications/views.py +++ b/apps/backend/chatballs/notifications/views.py @@ -56,7 +56,7 @@ class MessengerBindingListView(APIView): } ) # Реестр типов для чекбоксов в профиле (порядок — как в TYPE_META). - available = [{"code": code, "label": NotificationType(code).label} for code in TYPE_META] + available = [{"code": code, "label": t(meta["label"])} for code, meta in TYPE_META.items()] return Response({"items": items, "availableTypes": available}) @@ -90,8 +90,8 @@ class MessengerBindingDetailView(APIView): return Response({"detail": t("profile.link_not_found")}, status=404) types = request.data.get("pushTypes") if not isinstance(types, list): - return Response({"detail": "pushTypes must be a list"}, status=400) - binding.push_types = [t for t in types if t in NotificationType.values] + return Response({"detail": t("notifications.push_types_list")}, status=400) + binding.push_types = [code for code in types if code in NotificationType.values] binding.save(update_fields=["push_types"]) return Response({"pushTypes": binding.push_types}) @@ -109,6 +109,6 @@ class NotificationReadView(APIView): else: ids = request.data.get("ids") if not isinstance(ids, list): - return Response({"detail": "ids must be a list or use all=true"}, status=400) + return Response({"detail": t("notifications.ids_list_or_all")}, status=400) mark_read(context=request.tenant_context, ids=[i for i in ids if isinstance(i, int)]) return Response({"unreadCount": unread_for(request.tenant_context).count()}) diff --git a/apps/backend/chatballs/platform/views.py b/apps/backend/chatballs/platform/views.py index 34505fb..0bd5adf 100644 --- a/apps/backend/chatballs/platform/views.py +++ b/apps/backend/chatballs/platform/views.py @@ -4,6 +4,7 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from chatballs.i18n import t from chatballs.identity.models import EmployeeRole, OrganizationMembership from chatballs.platform.authentication import PlatformTokenAuthentication from chatballs.platform.errors import ProvisioningError @@ -31,7 +32,7 @@ class OrganizationProvisionView(APIView): def post(self, request: Request) -> Response: idempotency_key = request.headers.get(_IDEMPOTENCY_HEADER, "").strip() if not idempotency_key: - return Response({"detail": "Idempotency-Key header is required"}, status=400) + return Response({"detail": t("platform.idempotency_key_required")}, status=400) command, error = parse_provisioning_body( request.data, idempotency_key=idempotency_key, diff --git a/apps/backend/chatballs/support_portals/addressing.py b/apps/backend/chatballs/support_portals/addressing.py index f1c4729..4dcb414 100644 --- a/apps/backend/chatballs/support_portals/addressing.py +++ b/apps/backend/chatballs/support_portals/addressing.py @@ -60,11 +60,11 @@ def clean_portal_domains(portal) -> None: } if portal.custom_domain in application_hosts: raise ValidationError( - {"custom_domain": "Домен внутреннего приложения использовать нельзя"} + {"custom_domain": t("portals.domain_is_internal")} ) if portal.custom_domain == portal.hosted_domain: raise ValidationError( - {"custom_domain": "Свой домен должен отличаться от адреса Chatballs"} + {"custom_domain": t("portals.domain_same_as_installation")} ) collision = portal.__class__.objects.exclude(pk=portal.pk).filter( models.Q(hosted_domain=portal.custom_domain) @@ -73,16 +73,16 @@ def clean_portal_domains(portal) -> None: ) if collision.exists(): raise ValidationError( - {"custom_domain": "Этот домен уже используется другим порталом"} + {"custom_domain": t("portals.domain_taken")} ) elif portal.custom_domain_verified_at is not None: raise ValidationError( - {"custom_domain_verified_at": "Нельзя подтвердить пустой домен"} + {"custom_domain_verified_at": t("portals.domain_empty_check")} ) elif portal.__class__.objects.exclude(pk=portal.pk).filter( custom_domain=portal.hosted_domain ).exists(): - raise ValidationError({"slug": "Этот адрес уже используется другим порталом"}) + raise ValidationError({"slug": t("portals.slug_taken")}) def portal_public_url(*, hosted: str, custom: str = "", custom_verified: bool = False) -> str: diff --git a/apps/backend/chatballs/support_portals/article_import.py b/apps/backend/chatballs/support_portals/article_import.py index 5208757..87cfa7d 100644 --- a/apps/backend/chatballs/support_portals/article_import.py +++ b/apps/backend/chatballs/support_portals/article_import.py @@ -3,6 +3,7 @@ from dataclasses import dataclass, field from django.core.exceptions import ValidationError from django.db import transaction +from chatballs.i18n import t from chatballs.support_portals.content_services import ( add_revision, create_article, @@ -32,14 +33,14 @@ class ArticleImportResult: def _required(document: dict[str, object], key: str) -> str: value = document.get(key) if not isinstance(value, str) or not value.strip(): - raise ValidationError({key: f"{key} is required"}) + raise ValidationError({key: t("ai.field_required", field=key)}) return value.strip() def _content(document: dict[str, object]) -> str: value = document.get("content") if not isinstance(value, str): - raise ValidationError({"content": "Content string is required"}) + raise ValidationError({"content": t("ai.content_required")}) return value @@ -48,7 +49,7 @@ def _locale(document: dict[str, object], *, default: str) -> str: return default value = document["locale"] if not isinstance(value, str): - raise ValidationError({"locale": "Locale must be a string"}) + raise ValidationError({"locale": t("portals.locale_string")}) return value.strip().lower() @@ -57,18 +58,18 @@ def _summary(document: dict[str, object], *, default: str) -> str: return default value = document["summary"] if not isinstance(value, str): - raise ValidationError({"summary": "Summary must be a string"}) + raise ValidationError({"summary": t("portals.summary_string")}) return value.strip() def _category_for_path(*, portal, raw_path: object) -> PortalCategory: if not isinstance(raw_path, list) or not raw_path: - raise ValidationError({"categoryPath": "Non-empty category path required"}) + raise ValidationError({"categoryPath": t("ai.category_path_required")}) parent_id = None category = None for raw_name in raw_path: if not isinstance(raw_name, str) or not raw_name.strip(): - raise ValidationError({"categoryPath": "Category names must be non-empty strings"}) + raise ValidationError({"categoryPath": t("ai.category_names_strings")}) name = raw_name.strip() try: category = portal.categories.get(parent_id=parent_id, name=name) @@ -76,7 +77,7 @@ def _category_for_path(*, portal, raw_path: object) -> PortalCategory: PortalCategory.DoesNotExist, PortalCategory.MultipleObjectsReturned, ) as error: - raise ValidationError({"categoryPath": "Category path not found"}) from error + raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error parent_id = category.id assert category is not None return category @@ -157,7 +158,7 @@ def import_articles( slug = raw_slug.strip() if isinstance(raw_slug, str) else "" try: if not isinstance(raw_document, dict): - raise ValidationError({"article": "Article must be an object"}) + raise ValidationError({"article": t("portals.article_object_required")}) outcome = _import_document( context=context, portal=portal, document=raw_document ) diff --git a/apps/backend/chatballs/support_portals/article_import_views.py b/apps/backend/chatballs/support_portals/article_import_views.py index 98583a4..b9882e6 100644 --- a/apps/backend/chatballs/support_portals/article_import_views.py +++ b/apps/backend/chatballs/support_portals/article_import_views.py @@ -14,7 +14,7 @@ class ArticleImportView(PortalBaseView): return Response({"detail": t("portals.not_found")}, status=404) articles = request.data.get("articles") if not isinstance(articles, list) or not articles: - return Response({"detail": "articles must be a non-empty list"}, status=400) + return Response({"detail": t("portals.articles_non_empty")}, status=400) result = import_articles( context=request.tenant_context, portal=portal, diff --git a/apps/backend/chatballs/support_portals/content_services.py b/apps/backend/chatballs/support_portals/content_services.py index 73c05aa..39b9120 100644 --- a/apps/backend/chatballs/support_portals/content_services.py +++ b/apps/backend/chatballs/support_portals/content_services.py @@ -83,11 +83,11 @@ def delete_category(*, portal: SupportPortal, category: PortalCategory) -> None: ensure_portal_editable(portal) if category.children.exists(): raise ValidationError( - {"category": "Сначала переместите или удалите вложенные разделы"} + {"category": t("portals.move_nested_sections_first")} ) if category.articles.exists(): raise ValidationError( - {"category": "Сначала переместите статьи из этого раздела"} + {"category": t("portals.move_articles_first")} ) category.delete() @@ -116,7 +116,7 @@ def add_revision( ) -> PortalArticleRevision: ensure_portal_editable(article.portal) if article.status == ArticleStatus.ARCHIVED: - raise ValidationError({"article": "Архивную статью нельзя изменять"}) + raise ValidationError({"article": t("portals.archived_article_readonly")}) last = article.revisions.aggregate(value=Max("revision"))["value"] or 0 revision = PortalArticleRevision( organization=context.organization, @@ -137,7 +137,7 @@ def update_article( ) -> PortalArticle: ensure_portal_editable(article.portal) if article.status == ArticleStatus.ARCHIVED: - raise ValidationError({"article": "Архивную статью нельзя изменять"}) + raise ValidationError({"article": t("portals.archived_article_readonly")}) article.category = _category( article.portal, data.get("categoryId", article.category_id) ) @@ -154,11 +154,11 @@ def publish_revision( ) -> PortalArticle: ensure_portal_editable(article.portal) if article.status == ArticleStatus.ARCHIVED: - raise ValidationError({"article": "Архивную статью нельзя публиковать"}) + raise ValidationError({"article": t("portals.archived_article_publish")}) try: revision = article.revisions.get(id=revision_id) except PortalArticleRevision.DoesNotExist as error: - raise ValidationError({"revisionId": "Версия статьи не найдена"}) from error + raise ValidationError({"revisionId": t("portals.revision_not_found")}) from error revision.published_at = timezone.now() revision.save(update_fields=["published_at"]) article.published_revision = revision @@ -200,7 +200,7 @@ def _category(portal: SupportPortal, category_id) -> PortalCategory: def ensure_portal_editable(portal: SupportPortal) -> None: if portal.status == PortalStatus.ARCHIVED: raise ValidationError( - {"portal": "Восстановите портал, чтобы изменить его содержимое"} + {"portal": t("portals.restore_to_edit_content")} ) @@ -243,12 +243,12 @@ def add_article_file( ensure_portal_editable(article.portal) if article.status == ArticleStatus.ARCHIVED: - raise ValidationError({"article": "Архивную статью нельзя изменять"}) + raise ValidationError({"article": t("portals.archived_article_readonly")}) original_name = safe_filename((upload.name or "").strip(), "") if not original_name: - raise ValidationError({"file": "Имя файла обязательно"}) + raise ValidationError({"file": t("portals.file_name_required")}) if upload.size and upload.size > MAX_ARTICLE_FILE_BYTES: - raise ValidationError({"file": "Файл больше 25 МБ"}) + raise ValidationError({"file": t("portals.file_too_large")}) existing = article.files.filter(original_name=original_name).first() existing_size = existing.size if existing is not None else 0 data = upload.read() diff --git a/apps/backend/chatballs/support_portals/domain_services.py b/apps/backend/chatballs/support_portals/domain_services.py index c71c562..8bf85e1 100644 --- a/apps/backend/chatballs/support_portals/domain_services.py +++ b/apps/backend/chatballs/support_portals/domain_services.py @@ -5,6 +5,7 @@ import dns.resolver from django.core.exceptions import ValidationError from django.utils import timezone +from chatballs.i18n import t from chatballs.support_portals.addressing import normalize_domain, validate_domain from chatballs.support_portals.models import SupportPortal from chatballs.support_portals.public_address import help_public_ipv4 @@ -14,7 +15,7 @@ from chatballs.support_portals.statuses import PortalStatus def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal: if portal.status == PortalStatus.ARCHIVED: raise ValidationError( - {"portal": "Восстановите портал, чтобы изменить его домен"} + {"portal": t("portals.restore_to_edit_domain")} ) normalized = normalize_domain(value) portal.custom_domain = validate_domain(normalized) if normalized else "" @@ -29,10 +30,10 @@ def set_custom_domain(portal: SupportPortal, value: str) -> SupportPortal: def verify_custom_domain(portal: SupportPortal) -> SupportPortal: if portal.status == PortalStatus.ARCHIVED: raise ValidationError( - {"portal": "Восстановите портал, чтобы подтвердить его домен"} + {"portal": t("portals.restore_to_verify_domain")} ) if not portal.custom_domain: - raise ValidationError({"customDomain": "Сначала укажите домен"}) + raise ValidationError({"customDomain": t("portals.domain_required_first")}) # Подтверждать владение доменом нечем и незачем: продукт self-hosted, домен # и установка принадлежат одному владельцу (README дизайн-базлайна, решение # 6). Остаётся техническая проверка «ведёт ли домен на этот сервер» — она @@ -52,11 +53,11 @@ def verify_custom_domain(portal: SupportPortal) -> SupportPortal: dns.exception.Timeout, ) as error: raise ValidationError( - {"customDomain": "A-запись домена пока не найдена"} + {"customDomain": t("portals.a_record_missing")} ) from error if server_ipv4 not in addresses: raise ValidationError( - {"customDomain": "A-запись домена указывает не на сервер Chatballs"} + {"customDomain": t("portals.a_record_mismatch")} ) portal.custom_domain_verified_at = timezone.now() diff --git a/apps/backend/chatballs/support_portals/portal_services.py b/apps/backend/chatballs/support_portals/portal_services.py index 3baecee..ed3d71c 100644 --- a/apps/backend/chatballs/support_portals/portal_services.py +++ b/apps/backend/chatballs/support_portals/portal_services.py @@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError from django.db import transaction from django.utils import timezone +from chatballs.i18n import t from chatballs.integrations.models import IntegrationProvider, IntegrationStatus from chatballs.support_portals.addressing import hosted_domain from chatballs.support_portals.models import SupportPortal @@ -63,7 +64,7 @@ def update_portal( ) -> SupportPortal: _check_tenant(context, portal) if portal.status == PortalStatus.ARCHIVED: - raise ValidationError({"portal": "Восстановите портал, чтобы изменить настройки"}) + raise ValidationError({"portal": t("portals.restore_to_edit_settings")}) portal.slug = data.slug.strip().lower() portal.hosted_domain = hosted_domain(portal.slug) portal.name = data.name.strip() @@ -108,7 +109,7 @@ def _widget( widget = matches[0] if len(matches) == 1 else None if widget is None: raise ValidationError( - {"widgetId": "Активный анонимный Web-виджет поддержки не найден"} + {"widgetId": t("portals.anonymous_widget_missing")} ) return widget @@ -119,7 +120,7 @@ def set_portal_status( ) -> SupportPortal: _check_tenant(context, portal) if status not in PortalStatus.values: - raise ValidationError({"status": "Неизвестный статус портала"}) + raise ValidationError({"status": t("portals.unknown_status")}) if status == portal.status: return portal portal.transition_version += 1 @@ -138,4 +139,4 @@ def set_portal_status( def _check_tenant(context: TenantContext, portal: SupportPortal) -> None: if portal.organization_id != context.organization_id: - raise ValidationError({"portal": "Портал принадлежит другой организации"}) + raise ValidationError({"portal": t("portals.other_organization")}) diff --git a/apps/backend/chatballs/support_portals/portal_views.py b/apps/backend/chatballs/support_portals/portal_views.py index 1d244e5..12f2c1b 100644 --- a/apps/backend/chatballs/support_portals/portal_views.py +++ b/apps/backend/chatballs/support_portals/portal_views.py @@ -44,7 +44,7 @@ def _optional_id(value) -> int | None: return int(value) except (TypeError, ValueError) as error: raise ValidationError( - {"widgetChannelId": "Канал веб-виджета не найден"} + {"widgetChannelId": t("portals.widget_channel_missing")} ) from error diff --git a/apps/backend/chatballs/support_portals/themes.py b/apps/backend/chatballs/support_portals/themes.py index e33c661..a3ed175 100644 --- a/apps/backend/chatballs/support_portals/themes.py +++ b/apps/backend/chatballs/support_portals/themes.py @@ -15,6 +15,8 @@ import re from django.core.exceptions import ValidationError from django.db import models +from chatballs.i18n import t + DEFAULT_PORTAL_THEME = "classic" PORTAL_THEME_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") PORTAL_THEME_SETTINGS_MAX_BYTES = 4096 @@ -38,28 +40,28 @@ def normalize_theme_scheme(value: str | None) -> str: def validate_theme(value: str) -> None: if not PORTAL_THEME_ID_PATTERN.fullmatch(value or ""): - raise ValidationError({"theme": "Некорректный идентификатор темы"}) + raise ValidationError({"theme": t("portals.invalid_theme_id")}) def validate_theme_scheme(value: str) -> None: if value not in PortalThemeScheme.values: - raise ValidationError({"themeScheme": "Неизвестная цветовая схема темы"}) + raise ValidationError({"themeScheme": t("portals.invalid_theme_scheme")}) def validate_theme_settings(value) -> dict: if value in (None, ""): return {} if not isinstance(value, dict): - raise ValidationError({"themeSettings": "Параметры темы должны быть объектом"}) + raise ValidationError({"themeSettings": t("portals.theme_settings_object")}) for key in value: if not isinstance(key, str) or not PORTAL_THEME_ID_PATTERN.fullmatch(key): - raise ValidationError({"themeSettings": "Некорректное имя параметра темы"}) + raise ValidationError({"themeSettings": t("portals.invalid_theme_setting_name")}) try: encoded = json.dumps(value, ensure_ascii=False) except (TypeError, ValueError) as error: raise ValidationError( - {"themeSettings": "Параметры темы должны быть JSON-объектом"} + {"themeSettings": t("portals.theme_settings_json")} ) from error if len(encoded.encode("utf-8")) > PORTAL_THEME_SETTINGS_MAX_BYTES: - raise ValidationError({"themeSettings": "Параметры темы слишком велики"}) + raise ValidationError({"themeSettings": t("portals.theme_settings_too_large")}) return value diff --git a/apps/backend/chatballs/support_portals/widget_validation.py b/apps/backend/chatballs/support_portals/widget_validation.py index df63fdb..2b231d2 100644 --- a/apps/backend/chatballs/support_portals/widget_validation.py +++ b/apps/backend/chatballs/support_portals/widget_validation.py @@ -1,17 +1,19 @@ from django.core.exceptions import ValidationError +from chatballs.i18n import t + def validate_portal_widget(portal) -> None: if portal.widget_channel_id is not None: if not portal.widget_channel.allow_anonymous_sessions: raise ValidationError( - {"widget_channel": "Portal widget must allow anonymous web sessions"} + {"widget_channel": t("portals.widget_needs_anonymous")} ) if portal.widget_id is None: return widget = portal.widget channel = widget.integration.channel if channel is None: - raise ValidationError({"widget": "Portal widget must be bound to a channel"}) + raise ValidationError({"widget": t("portals.widget_needs_channel")}) if portal.widget_channel_id is not None and channel.id != portal.widget_channel_id: - raise ValidationError({"widget": "Widget and legacy channel must match"}) + raise ValidationError({"widget": t("portals.widget_channel_mismatch")}) diff --git a/apps/backend/chatballs/tenancy/storage_settings.py b/apps/backend/chatballs/tenancy/storage_settings.py index 6d082c2..3b7bcd7 100644 --- a/apps/backend/chatballs/tenancy/storage_settings.py +++ b/apps/backend/chatballs/tenancy/storage_settings.py @@ -183,13 +183,13 @@ def build_s3_storage(config: StorageConfig, *, probe: bool = False): def validate_s3_fields(*, bucket: str, endpoint_url: str, region: str, access_key: str, secret_key: str, addressing_style: str) -> None: errors: dict[str, str] = {} if not bucket.strip(): - errors["s3Bucket"] = "Укажите имя бакета" + errors["s3Bucket"] = t("settings.s3_bucket_required") if endpoint_url and not endpoint_url.startswith(("http://", "https://")): - errors["s3EndpointUrl"] = "Адрес должен начинаться с http:// или https://" + errors["s3EndpointUrl"] = t("settings.s3_endpoint_scheme") if not access_key: - errors["s3AccessKey"] = "Укажите Access Key" + errors["s3AccessKey"] = t("settings.s3_access_key_required") if not secret_key: - errors["s3SecretKey"] = "Укажите Secret Key" + errors["s3SecretKey"] = t("settings.s3_secret_key_required") if addressing_style not in {"path", "virtual"}: errors["s3AddressingStyle"] = "path или virtual" if errors: @@ -205,12 +205,12 @@ def probe_s3(config: StorageConfig) -> None: storage = build_s3_storage(config, probe=True) stored = storage.save(key, ContentFile(b"chatballs storage probe")) if not storage.exists(stored): - raise ValidationError({"s3Bucket": "Объект записан, но не читается — проверьте права на чтение"}) + raise ValidationError({"s3Bucket": t("settings.object_written_not_readable")}) storage.delete(stored) except ValidationError: raise except Exception as error: # boto/botocore ошибки разнообразны — показываем текст - raise ValidationError({"s3Bucket": f"Хранилище недоступно: {_short(error)}"}) from error + raise ValidationError({"s3Bucket": t("settings.storage_unavailable", error=_short(error))}) from error def _short(error: Exception) -> str: diff --git a/apps/backend/chatballs/tenancy/storage_views.py b/apps/backend/chatballs/tenancy/storage_views.py index 0eea217..47fe86f 100644 --- a/apps/backend/chatballs/tenancy/storage_views.py +++ b/apps/backend/chatballs/tenancy/storage_views.py @@ -58,7 +58,7 @@ def storage_payload(row: ss.StorageSettings) -> dict[str, object]: def _errors_response(error: ValidationError) -> Response: if hasattr(error, "message_dict"): payload = {k: (v[0] if isinstance(v, list) else str(v)) for k, v in error.message_dict.items()} - detail = next(iter(payload.values()), "Проверьте заполненные поля") + detail = next(iter(payload.values()), t("setup.check_fields")) return Response({"detail": detail, "errors": payload}, status=400) return Response({"detail": "; ".join(error.messages)}, status=400) @@ -92,7 +92,7 @@ class StorageSettingsView(APIView): body = request.data backend = str(body.get("backend") or row.backend).upper() if backend not in ss.StorageBackend.values: - return Response({"detail": t("settings.unknown_storage_kind"), "errors": {"backend": "LOCAL или S3"}}, status=400) + return Response({"detail": t("settings.unknown_storage_kind"), "errors": {"backend": t("settings.local_or_s3")}}, status=400) config = _apply_fields(row, body) if backend == ss.StorageBackend.S3: try: diff --git a/apps/backend/chatballs/webchat/services.py b/apps/backend/chatballs/webchat/services.py index 1cc1915..d0e5aad 100644 --- a/apps/backend/chatballs/webchat/services.py +++ b/apps/backend/chatballs/webchat/services.py @@ -18,7 +18,7 @@ from chatballs.conversations.models import ( MessageKind, ) from chatballs.conversations.transports.base import InboundMessage -from chatballs.i18n import t +from chatballs.i18n import customer_language, t from chatballs.i18n.languages import resolve_language from chatballs.identity.instance_settings import default_language from chatballs.integrations.features import features_payload @@ -26,8 +26,6 @@ from chatballs.integrations.models import Integration, IntegrationProvider from chatballs.tenancy.context import TenantContext from chatballs.webchat.models import WebChatWidget, WebSession -DEFAULT_GREETING = "Здравствуйте! Готов помочь и ответить на вопросы. Чем можем помочь?" -DEFAULT_CONSENT = "Продолжая, вы соглашаетесь на обработку сообщений для ответа на обращение." DEFAULT_ACCENT = "#1677ff" _STATE = {ControlMode.AI: "ai", ControlMode.HUMAN: "operator", ControlMode.PAUSED: "waiting"} @@ -84,10 +82,17 @@ def origin_allowed(widget: WebChatWidget, origin: str) -> bool: def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str) -> dict: integration = widget.integration + # Язык отдаётся и тогда, когда виджет открыть нельзя: «Чат временно + # недоступен» — это организация говорит со своим клиентом, и говорить она + # должна на своём языке, а не на языке браузера посетителя. + language = resolve_language( + organization_language=context.organization.language, + instance_language=default_language(), + ) if integration.channel_id is None: - return {"available": False} + return {"available": False, "language": language} if not origin_allowed(widget, origin): - return {"available": False, "reason": "domain"} + return {"available": False, "reason": "domain", "language": language} cfg = widget.presentation_config consent = widget.consent_config channel = integration.channel @@ -103,19 +108,15 @@ def public_config(*, context: TenantContext, widget: WebChatWidget, origin: str) "widgetKey": widget.public_key, # Язык обвязки виджета — язык организации: на нём отвечают и агент, и # оператор, и английская кнопка «Send» вокруг русских ответов выглядела - # бы ошибкой. Если организация языка не выбрала, берётся язык - # установки; пустое значение оставляет решение браузеру посетителя. - "language": resolve_language( - organization_language=context.organization.language, - instance_language=default_language(), - ), + # бы ошибкой. + "language": language, # Что разрешено в этой точке входа: виджет прячет микрофон при запрете. "features": features_payload(integration), "title": cfg.get("title") or channel.name, "accent": cfg.get("accent") or DEFAULT_ACCENT, - "greeting": cfg.get("greeting") or DEFAULT_GREETING, + "greeting": cfg.get("greeting") or t("webchat.default_greeting", language=language), "consent": { - "text": consent.get("consent_text") or DEFAULT_CONSENT, + "text": consent.get("consent_text") or t("webchat.default_consent", language=language), "version": consent.get("consent_version") or "v1", }, "quickReplies": cfg.get("quick_replies") or [], @@ -129,7 +130,11 @@ def issue_session(*, context: TenantContext, widget: WebChatWidget) -> dict | No if integration is None or integration.channel_id is None: return None session_id = uuid.uuid4().hex - guest_name = f"Гость · {session_id[:6]}" + guest_name = t( + "webchat.guest_name", + code=session_id[:6], + language=customer_language(integration.channel.organization), + ) contact = Contact.objects.create(organization=integration.channel.organization, name=guest_name) identity = ConnectionIdentity.objects.create( organization=context.organization, diff --git a/apps/internal-ui/src/features/administration/AuditTable.tsx b/apps/internal-ui/src/features/administration/AuditTable.tsx index c9af316..113b875 100644 --- a/apps/internal-ui/src/features/administration/AuditTable.tsx +++ b/apps/internal-ui/src/features/administration/AuditTable.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import { Icon } from "../../shared/icons"; import { shortDate, shortDateYear } from "../../shared/utils"; import type { AuditEvent } from "./model"; -import { t } from "../../i18n"; +import { fmt, t } from "../../i18n"; // Журнал действий. Три вещи, без которых он был нечитаем: // * день отбивается заголовком — иначе сотни строк идут сплошняком; @@ -12,7 +12,7 @@ import { t } from "../../i18n"; // были в базе, но наружу не отдавались, и разобраться в событии было нечем. function formatTime(value: string): string { - return new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" }).format(new Date(value)); + return fmt.time(value); } function dayKey(value: string): string { diff --git a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx index e2560ba..db8100a 100644 --- a/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx +++ b/apps/internal-ui/src/features/administration/OrganizationSettingsForm.tsx @@ -71,19 +71,6 @@ export function OrganizationSettingsForm({ onChange={(timezone) => onChange({ ...organization, timezone })} options={timezones.map((timezone) => [timezone, timezoneLabel(timezone)])} /> - onChange({ ...organization, language })} - // Первый пункт — не язык, а отказ от выбора: организация без своего - // языка следует за установкой, и владелец видит это словами. - options={[ - ["", t("settings.language_as_installation")], - ...languages.map((item): [string, string] => [item.code, item.label]), - ]} - hint={t("settings.language_org_hint")} - /> + {/* Язык — сегментом, как «Тема» в профиле и схема в «Платформе»: вариантов + три, и выбор лучше видеть целиком, чем разворачивать список. В сетку + полей он не встаёт — она выравнивает поля по нижнему краю, и строка с + подписью ломала бы ряд. */} +
+ {t("settings.language")} +
+ + {languages.map((item) => ( + + ))} +
+
+

{t("settings.language_org_hint")}

{error &&
{error}
} {canManage && (
diff --git a/apps/internal-ui/src/features/agents/AgentDetailPage.tsx b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx index 9b5e34b..8105a70 100644 --- a/apps/internal-ui/src/features/agents/AgentDetailPage.tsx +++ b/apps/internal-ui/src/features/agents/AgentDetailPage.tsx @@ -1,6 +1,8 @@ import { Dropdown, Modal } from "antd"; import { useCallback, useEffect, useState } from "react"; +import { LANGUAGES } from "@chatballs/shared"; + import { api, ApiError } from "../../api/client"; import { ChannelGlyph } from "../../shared/badges"; import { Icon } from "../../shared/icons"; @@ -333,17 +335,36 @@ const INSTRUCTION_FIELDS = [ { key: "instructions", label: t("ai.working_rules"), hint: t("ai.instructions"), rows: 10, placeholder: t("ai.example_do_not_quote_prices") }, ] as const; +// Язык ответов клиенту (кадр G3). «Как у клиента» стоит первым и выбран по +// умолчанию: язык обращения — сигнал точнее любой настройки, он лежит прямо в +// сообщении. Явный язык нужен тем, кто отвечает ровно на одном независимо от +// того, на каком спросили. Подписи языков не переводятся — свой язык человек +// узнаёт по его собственному имени. +const ANSWER_LANGUAGES: ReadonlyArray<{ value: string; label: string; lang?: string }> = [ + { value: "MIRROR", label: t("ai.answer_language_mirror") }, + { value: "ORGANIZATION", label: t("ai.answer_language_organization") }, + ...LANGUAGES.map((item) => ({ value: item.code, label: item.label, lang: item.code })), +]; + +function answerLanguageLabel(value: string): string { + return ANSWER_LANGUAGES.find((option) => option.value === value)?.label ?? value; +} + function InstructionsCard({ card, canManage, busy, apply }: { card: AgentCard; canManage: boolean; busy: boolean; apply: (patch: AgentPatch) => Promise; }) { - const [draft, setDraft] = useState({ persona: card.persona, tone: card.tone, instructions: card.instructions }); + const [draft, setDraft] = useState({ persona: card.persona, tone: card.tone, instructions: card.instructions, answerLanguage: card.answerLanguage }); useEffect(() => { - setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions }); - }, [card.persona, card.tone, card.instructions]); - const dirty = draft.persona !== card.persona || draft.tone !== card.tone || draft.instructions !== card.instructions; + setDraft({ persona: card.persona, tone: card.tone, instructions: card.instructions, answerLanguage: card.answerLanguage }); + }, [card.persona, card.tone, card.instructions, card.answerLanguage]); + const dirty = + draft.persona !== card.persona || + draft.tone !== card.tone || + draft.instructions !== card.instructions || + draft.answerLanguage !== card.answerLanguage; return (
@@ -351,6 +372,26 @@ function InstructionsCard({ card, canManage, busy, apply }: {

{t("ai.instructions_2")}

{t("ai.system_prompt_assembled_from_three")}
+
+ {t("ai.answer_language")}{t("ai.answer_language_hint")} + {canManage ? ( +
+ {ANSWER_LANGUAGES.map((option) => ( + + ))} +
+ ) : ( + {answerLanguageLabel(card.answerLanguage)} + )} +
{INSTRUCTION_FIELDS.map((field) => (