mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ab26a1bcd | ||
|
|
09fb0177b1 | ||
|
|
b76871caf0 | ||
|
|
8580b480e7 | ||
|
|
eff6d7ac0c | ||
|
|
1cff9e2183 | ||
|
|
d8420e4379 | ||
|
|
21b8100598 | ||
|
|
1da84c9734 | ||
|
|
82805f7b1a | ||
|
|
eca0e52667 | ||
|
|
54c35e281a | ||
|
|
5396dda8ad | ||
|
|
29164db094 | ||
|
|
c5a53ccec5 | ||
|
|
049217505a | ||
|
|
132c684630 | ||
|
|
bfe90e9abd | ||
|
|
5b49904d6a | ||
|
|
cd50cce5a8 | ||
|
|
a4ca7e9373 | ||
|
|
145c44a17d | ||
|
|
ac824e192c | ||
|
|
3e790af6e5 | ||
|
|
1390ccc426 | ||
|
|
2e52bdaea0 | ||
|
|
5bf3d9b572 | ||
|
|
6d94aa1f9f | ||
|
|
0e6a3db9a6 |
No files matched your search
@@ -23,9 +23,9 @@ jobs:
|
||||
name: ruff
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -46,9 +46,9 @@ jobs:
|
||||
name: deployment CLI
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -39,12 +39,13 @@ env:
|
||||
GATEWAY_BASE_IMAGE: caddy:2.8.4
|
||||
REDIS_IMAGE: redis:7-alpine
|
||||
COTURN_IMAGE: coturn/coturn:4.6
|
||||
UPDATER_BASE_IMAGE: docker:27-cli
|
||||
|
||||
jobs:
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Версия релиза
|
||||
id: version
|
||||
@@ -57,9 +58,9 @@ jobs:
|
||||
echo "value=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -78,13 +79,16 @@ jobs:
|
||||
echo "gateway=${GATEWAY_BASE_IMAGE}@$(digest_of "$GATEWAY_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "redis=${REDIS_IMAGE}@$(digest_of "$REDIS_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "coturn=${COTURN_IMAGE}@$(digest_of "$COTURN_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
echo "updater=${UPDATER_BASE_IMAGE}@$(digest_of "$UPDATER_BASE_IMAGE")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Backend
|
||||
id: backend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/apps/backend/Dockerfile.production
|
||||
build-args: |
|
||||
CHATBALLS_VERSION=${{ steps.version.outputs.value }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/backend:${{ steps.version.outputs.value }}
|
||||
@@ -94,7 +98,7 @@ jobs:
|
||||
|
||||
- name: Frontend
|
||||
id: frontend
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/frontend.Dockerfile
|
||||
@@ -109,7 +113,7 @@ jobs:
|
||||
# а не монтируются с хоста. Ради этого установка и стала одним файлом.
|
||||
- name: Gateway
|
||||
id: gateway
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/gateway.Dockerfile
|
||||
@@ -124,7 +128,7 @@ jobs:
|
||||
|
||||
- name: Postgres
|
||||
id: postgres
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/postgres.Dockerfile
|
||||
@@ -137,6 +141,23 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Сервис обновления по кнопке (ADR-CHATBALLS-0049): docker CLI с compose
|
||||
# и два сценария внутри; единственный, кому монтируется docker.sock.
|
||||
- name: Updater
|
||||
id: updater
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: ${{ env.APP_DIR }}
|
||||
file: ${{ env.APP_DIR }}/deploy/docker/updater.Dockerfile
|
||||
build-args: |
|
||||
CHATBALLS_UPDATER_BASE_IMAGE=${{ steps.bases.outputs.updater }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:${{ steps.version.outputs.value }}
|
||||
${{ env.REGISTRY }}/${{ steps.version.outputs.repo }}/updater:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Релизный compose.yaml и release.env
|
||||
id: artifacts
|
||||
run: |
|
||||
@@ -148,6 +169,7 @@ jobs:
|
||||
frontend="$prefix/frontend:$version@${{ steps.frontend.outputs.digest }}"
|
||||
gateway="$prefix/gateway:$version@${{ steps.gateway.outputs.digest }}"
|
||||
postgres="$prefix/postgres:$version@${{ steps.postgres.outputs.digest }}"
|
||||
updater="$prefix/updater:$version@${{ steps.updater.outputs.digest }}"
|
||||
redis="${{ steps.bases.outputs.redis }}"
|
||||
coturn="${{ steps.bases.outputs.coturn }}"
|
||||
|
||||
@@ -160,7 +182,8 @@ jobs:
|
||||
--pin "CHATBALLS_GATEWAY_IMAGE=$gateway" \
|
||||
--pin "CHATBALLS_POSTGRES_IMAGE=$postgres" \
|
||||
--pin "CHATBALLS_REDIS_IMAGE=$redis" \
|
||||
--pin "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
--pin "CHATBALLS_COTURN_IMAGE=$coturn" \
|
||||
--pin "CHATBALLS_UPDATER_IMAGE=$updater"
|
||||
|
||||
{
|
||||
echo "# release.env — digest-пины релиза $version для \`chatballs deploy\`."
|
||||
@@ -172,6 +195,7 @@ jobs:
|
||||
echo "CHATBALLS_POSTGRES_IMAGE=$postgres"
|
||||
echo "CHATBALLS_REDIS_IMAGE=$redis"
|
||||
echo "CHATBALLS_COTURN_IMAGE=$coturn"
|
||||
echo "CHATBALLS_UPDATER_IMAGE=$updater"
|
||||
} > dist/release.env
|
||||
|
||||
# Файл, который скачает человек, обязан быть валидным сам по себе —
|
||||
@@ -180,7 +204,7 @@ jobs:
|
||||
|
||||
cat dist/compose.yaml
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: release-compose
|
||||
path: |
|
||||
@@ -189,7 +213,7 @@ jobs:
|
||||
|
||||
- name: Приложить к релизу
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: |
|
||||
dist/compose.yaml
|
||||
|
||||
+4
-1
@@ -20,7 +20,10 @@ test-results/
|
||||
.DS_Store
|
||||
|
||||
# Перенесено из корневого .gitignore при переносе корня репозитория в code/chatballs
|
||||
data/
|
||||
# Тома docker-стенда (postgres, redis, caddy, media) — только в корне репозитория.
|
||||
# Без якоря правило ловило и demo_seed/data: русские манифесты в индексе уже
|
||||
# лежали, а новые файлы туда молча не попадали.
|
||||
/data/
|
||||
.env.bak-*
|
||||
**/staticfiles/
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Production deployment / миграция:
|
||||
- Перед любым изменяющим действием в production сначала представить владельцу точный план миграции и получить его явное согласование. Разрешение на диагностику или общая просьба «исправить» не являются разрешением самостоятельно выбирать архитектуру миграции.
|
||||
|
||||
UI / дизайн:
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утвержденной документации или design-system.
|
||||
- Никакой отсебятины в UI: не добавлять экраны, блоки, карточки, иконки, тексты, анимации, цвета, layout-решения и состояния, которых нет в утверждённом дизайн-макете (`design/baseline/<фича>/*.dc.html`). Макет — источник истины, README рядом с ним лишь пересказ.
|
||||
- Если UI-этап еще не наступил, UI не считается реализованным и не должен маскироваться под готовый продуктовый интерфейс.
|
||||
- Для построения UI использовать существующие компоненты и их стили, если они уже реализованы; если подходящего компонента нет, создавать переиспользуемый компонент в рамках существующей системы.
|
||||
- Не упрощать UI, анимации, иконки, состояния или поведение по своему усмотрению. Любое отклонение от baseline требует явного согласования до правок.
|
||||
@@ -51,6 +51,9 @@ Engineering rules / обязательные практики:
|
||||
- Что записано в базу, переводится по коду, а не текстом: системные события диалога, уведомления и подписи журнала аудита хранят код, а фразу собирает бэкенд на языке читателя. Писать в историю готовую русскую фразу нельзя — её потом не перевести.
|
||||
- Язык запроса выбирается цепочкой: профиль сотрудника → организация → установка → браузер. Письмо получает язык адресата, а не отправителя.
|
||||
- На бэкенде `t(...)` не вызывается на уровне модуля: язык там свой на каждый запрос, а константа посчиталась бы один раз при импорте.
|
||||
- Текст, уходящий наружу — клиенту в мессенджер, письмо, кнопку виджета, — берёт язык не запроса, а организации: `t(..., language=customer_language(organization))`. Язык оператора, нажавшего кнопку, к речи компании с её клиентом отношения не имеет, и половина такого текста вообще рождается в воркере, где запроса нет.
|
||||
- Системный промпт агента не переводится: его читает модель. Язык ответа задаёт отдельная директива по полю `AIAgent.answer_language` — по умолчанию «как у клиента».
|
||||
- Демо-набор ставится на языке организации: манифесты лежат в `demo_seed/data/<язык>/`, наборы ключей в них совпадают. Бинарные вложения (аватары, голосовые) общие, текстовые документы — свои на каждый язык.
|
||||
|
||||
Definition of Done:
|
||||
- Запускать только тесты, относящиеся к изменениям текущей итерации. Полный прогон всех тестов выполнять только по явному указанию владельца.
|
||||
|
||||
@@ -132,7 +132,9 @@ By default files are stored in a Docker volume. In **Settings → Storage** the
|
||||
|
||||
### Updating
|
||||
|
||||
Download the new release's `compose.yaml` over the old one and restart:
|
||||
When a new release is out, the installation administrator sees a banner in the interface and updates with one button; the same lives in **Settings → Platform → Updates**. The installation updates itself on the server: it downloads the release `compose.yaml`, pulls the images and restarts the services, with about a minute of downtime.
|
||||
|
||||
Manually, from the server console: download the new release's `compose.yaml` over the old one and restart:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
@@ -311,13 +313,13 @@ All services should be `healthy` or `running`. Application readiness is availabl
|
||||
<details>
|
||||
<summary><strong>Do not delete the secrets volume</strong></summary>
|
||||
|
||||
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. Include this volume in backups together with the database and files.
|
||||
The `chatballs-secrets` volume holds the encryption key. Without it, integration tokens, SMTP passwords, S3 keys and two-factor secrets become unreadable. The `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes hold the database role passwords: without them the stack cannot connect to its own database. Include all three volumes in backups together with the database and files.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Backup</strong></summary>
|
||||
|
||||
Copy the `chatballs-postgres`, `chatballs-media` and `chatballs-secrets` volumes. A database dump can be taken as well:
|
||||
Copy the `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` and `chatballs-secrets-schema` volumes. A database dump can be taken as well:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
|
||||
|
||||
+5
-3
@@ -132,7 +132,9 @@ Relay слушает выделенный IP, чтобы порт 443 не ко
|
||||
|
||||
### Обновление
|
||||
|
||||
Скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
Когда выходит новый релиз, администратор установки видит баннер в интерфейсе и обновляется одной кнопкой; то же есть в **Настройки → Платформа → Обновления**. Установка идёт на сервере сама: скачивается `compose.yaml` релиза, загружаются образы, сервисы перезапускаются, приложение недоступно около минуты.
|
||||
|
||||
Вручную, из консоли сервера: скачайте `compose.yaml` нового релиза поверх старого и повторите запуск:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d --wait
|
||||
@@ -311,13 +313,13 @@ docker compose ps
|
||||
<details>
|
||||
<summary><strong>Не удаляйте том с секретами</strong></summary>
|
||||
|
||||
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. Включайте этот том в резервные копии вместе с базой и файлами.
|
||||
В томе `chatballs-secrets` лежит ключ шифрования. Без него станут нечитаемы токены интеграций, пароли SMTP, ключи S3 и секреты двухфакторной защиты. В томах `chatballs-secrets-platform` и `chatballs-secrets-schema` лежат пароли ролей базы: без них стек не подключится к собственной базе. Включайте все три тома в резервные копии вместе с базой и файлами.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Резервная копия</strong></summary>
|
||||
|
||||
Копируйте тома `chatballs-postgres`, `chatballs-media` и `chatballs-secrets`. Для базы можно снять дамп:
|
||||
Копируйте тома `chatballs-postgres`, `chatballs-media`, `chatballs-secrets`, `chatballs-secrets-platform` и `chatballs-secrets-schema`. Для базы можно снять дамп:
|
||||
|
||||
```bash
|
||||
docker compose exec -T postgres pg_dump -U chatballs_bootstrap chatballs > backup.sql
|
||||
|
||||
@@ -6,6 +6,11 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Версия релиза попадает в образ при сборке: по ней приложение решает,
|
||||
# есть ли обновление. Сборка из исходников остаётся «dev».
|
||||
ARG CHATBALLS_VERSION=dev
|
||||
ENV CHATBALLS_VERSION=${CHATBALLS_VERSION}
|
||||
|
||||
RUN addgroup --system hub && adduser --system --ingroup hub hub
|
||||
|
||||
COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ def agent_card_payload(channel: Channel, *, knowledge_total: int | None = None)
|
||||
"model": agent.model,
|
||||
"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 +201,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(
|
||||
@@ -231,10 +231,10 @@ def update_agent_card(
|
||||
ai_fields = {
|
||||
"providerIntegrationId",
|
||||
"modelParams",
|
||||
"limits",
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
"answerLanguage",
|
||||
"knowledgeIds",
|
||||
}
|
||||
if ai_fields & set(body):
|
||||
@@ -245,20 +245,17 @@ 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"})
|
||||
if not isinstance(limits, dict):
|
||||
raise ValidationError({"limits": "Object required"})
|
||||
raise ValidationError({"modelParams": 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,
|
||||
@@ -268,10 +265,12 @@ def update_agent_card(
|
||||
provider_integration_id=provider_integration_id,
|
||||
model_params=model_params,
|
||||
allowed_tools=agent.allowed_tools,
|
||||
limits=limits,
|
||||
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 +280,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 +307,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,10 +4,10 @@ from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.ai.models import KnowledgeAttachment
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import attachment_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
class AttachmentDownloadView(APIView):
|
||||
@@ -19,10 +19,9 @@ class AttachmentDownloadView(APIView):
|
||||
route = attachment_route(str(public_id))
|
||||
if route is None:
|
||||
raise Http404
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist as error:
|
||||
raise Http404 from error
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise Http404
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
attachment = KnowledgeAttachment.objects.filter(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,7 +2,6 @@ import time
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from chatballs.ai import limits, pricing
|
||||
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
|
||||
from chatballs.ai.pii import redact
|
||||
from chatballs.ai.provider import routing
|
||||
@@ -19,18 +18,6 @@ from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilienc
|
||||
_breaker = CircuitBreaker()
|
||||
|
||||
|
||||
def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> None:
|
||||
LlmInvocation.objects.create(
|
||||
organization=channel.organization,
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
operation="chat",
|
||||
model=model,
|
||||
status=LlmInvocationStatus.BLOCKED,
|
||||
error=str(error),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]:
|
||||
# BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции
|
||||
# организации с fallback на модель агента. Без интеграции модель остаётся
|
||||
@@ -52,18 +39,7 @@ def invoke_chat(
|
||||
params: dict | None = None,
|
||||
used_fragment_ids: list | None = None,
|
||||
) -> ChatResult:
|
||||
fallback_model = model or channel.ai_agent.model
|
||||
try:
|
||||
provider, model = _prepare_invocation(channel=channel, requested_model=model)
|
||||
limits.assert_within_limits(channel, channel.ai_agent)
|
||||
except limits.LimitExceeded as error:
|
||||
_record_blocked(
|
||||
channel=channel,
|
||||
purpose=purpose,
|
||||
model=fallback_model,
|
||||
error=error,
|
||||
)
|
||||
raise
|
||||
provider, model = _prepare_invocation(channel=channel, requested_model=model)
|
||||
|
||||
safe_messages = [ChatMessage(role=item.role, content=redact(item.content)) for item in messages]
|
||||
started = time.monotonic()
|
||||
@@ -96,8 +72,6 @@ def invoke_chat(
|
||||
prompt_tokens=result.prompt_tokens,
|
||||
completion_tokens=result.completion_tokens,
|
||||
total_tokens=result.total_tokens,
|
||||
cost_micros=result.cost_micros
|
||||
or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens),
|
||||
latency_ms=int((time.monotonic() - started) * 1000),
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
used_fragment_ids=used_fragment_ids or [],
|
||||
@@ -128,7 +102,6 @@ def embed_texts(
|
||||
model=model,
|
||||
prompt_tokens=tokens,
|
||||
total_tokens=tokens,
|
||||
cost_micros=pricing.cost_micros(model, tokens, 0),
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
)
|
||||
return results
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
|
||||
|
||||
|
||||
class LimitExceeded(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _day_start():
|
||||
now = timezone.localtime()
|
||||
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def daily_cost_micros(channel=None) -> int:
|
||||
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)
|
||||
if channel is not None:
|
||||
queryset = queryset.filter(channel=channel)
|
||||
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0
|
||||
|
||||
|
||||
def assert_within_limits(channel, agent) -> None:
|
||||
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS
|
||||
if global_limit and daily_cost_micros() >= global_limit:
|
||||
raise LimitExceeded("Global daily AI cost limit reached")
|
||||
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается
|
||||
# в micro-USD. 1 цент = 10 000 micro-USD.
|
||||
channel_limit = (agent.limits or {}).get("dailyCostUsd")
|
||||
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:
|
||||
raise LimitExceeded("Channel daily AI cost limit reached")
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Дневной бюджет агента снят вместе с полем `limits`: расход считался по
|
||||
# прайс-таблице из двух моделей, а для всех остальных оставался нулевым — лимит
|
||||
# не срабатывал никогда. Единственный оставшийся предохранитель — общий лимит
|
||||
# установки из переменной окружения (CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS).
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("ai", "0017_agent_answer_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="aiagent",
|
||||
name="limits",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Учёт стоимости вызовов удалён вместе с лимитами. Считать было нечем: цена
|
||||
# бралась из ответа провайдера, а его присылает только OpenRouter; на остальных
|
||||
# оставалась прайс-таблица из двух моделей и ноль для всех прочих. Ни одна
|
||||
# цифра расхода в продукте не показывалась.
|
||||
#
|
||||
# Статус BLOCKED уходит вместе с лимитами — блокировать вызовы больше нечему.
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def drop_blocked_rows(apps, schema_editor):
|
||||
"""Строк со снятым статусом в журнале остаться не должно."""
|
||||
|
||||
apps.get_model("ai", "LlmInvocation").objects.filter(status="BLOCKED").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("ai", "0018_remove_agent_limits"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(drop_blocked_rows, migrations.RunPython.noop),
|
||||
migrations.RemoveField(model_name="llminvocation", name="cost_micros"),
|
||||
migrations.RemoveField(model_name="llminvocation", name="currency"),
|
||||
migrations.AlterField(
|
||||
model_name="llminvocation",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[("SUCCESS", "Успех"), ("ERROR", "Ошибка")],
|
||||
default="SUCCESS",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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) # кто он и что он
|
||||
@@ -397,10 +417,6 @@ class AIAgent(TenantRelationModel):
|
||||
|
||||
allowed_tools = models.JSONField(default=list, blank=True)
|
||||
|
||||
# Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
|
||||
|
||||
limits = models.JSONField(default=dict, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -443,8 +459,6 @@ class LlmInvocationStatus(models.TextChoices):
|
||||
|
||||
ERROR = "ERROR", "Ошибка"
|
||||
|
||||
BLOCKED = "BLOCKED", "Заблокировано лимитом"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -469,10 +483,6 @@ class LlmInvocation(TenantRelationModel):
|
||||
|
||||
total_tokens = models.PositiveIntegerField(default=0)
|
||||
|
||||
cost_micros = models.PositiveBigIntegerField(default=0)
|
||||
|
||||
currency = models.CharField(max_length=3, default="USD")
|
||||
|
||||
latency_ms = models.PositiveIntegerField(default=0)
|
||||
|
||||
status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from django.conf import settings
|
||||
|
||||
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
|
||||
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
|
||||
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
|
||||
DEFAULT_PRICING = {
|
||||
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
|
||||
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
|
||||
}
|
||||
|
||||
|
||||
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
|
||||
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
|
||||
price = table.get(model)
|
||||
if not price:
|
||||
return 0
|
||||
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
|
||||
from chatballs.i18n import t
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChatMessage:
|
||||
@@ -16,9 +18,6 @@ class ChatResult:
|
||||
model: str
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
# Фактическая стоимость, сообщённая провайдером (micro-USD). 0 — провайдер не
|
||||
# вернул цену, тогда считаем по прайс-таблице (ai/pricing.py).
|
||||
cost_micros: int = 0
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
@@ -48,4 +47,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))
|
||||
@@ -27,27 +27,63 @@ from chatballs.i18n import t
|
||||
EMBEDDING_DIM = 16
|
||||
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
|
||||
HANDOFF_TOKEN = "<<HANDOFF>>"
|
||||
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):
|
||||
|
||||
@@ -12,7 +12,7 @@ speak the same Chat Completions shape:
|
||||
|
||||
- POST /chat/completions with {model, messages, ...}; response has
|
||||
|
||||
choices[0].message.content and usage (optionally usage.cost in USD).
|
||||
choices[0].message.content and usage (prompt/completion tokens).
|
||||
|
||||
- POST /embeddings with {model, input}; response has data[].embedding and usage.
|
||||
|
||||
@@ -24,7 +24,7 @@ This module owns the HTTP transport and response parsing so the three adapters
|
||||
|
||||
do not duplicate it. Adapters stay responsible for their own product semantics
|
||||
|
||||
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
|
||||
(name, catalog). Stdlib only — no third-party HTTP client.
|
||||
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -115,21 +116,9 @@ def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_ur
|
||||
|
||||
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
|
||||
|
||||
timeout: float, proxy_url: str = "", params: dict | None = None,
|
||||
timeout: float, proxy_url: str = "", params: dict | None = None) -> ChatResult:
|
||||
|
||||
include_cost: bool = False) -> ChatResult:
|
||||
|
||||
"""POST /chat/completions and parse the OpenAI-shaped response.
|
||||
|
||||
|
||||
|
||||
`include_cost=True` requests the OpenRouter-style usage.include flag and reads
|
||||
|
||||
usage.cost (USD, converted to micros). Providers that do not report cost
|
||||
|
||||
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
|
||||
|
||||
"""
|
||||
"""POST /chat/completions and parse the OpenAI-shaped response."""
|
||||
|
||||
payload: dict = {
|
||||
|
||||
@@ -141,10 +130,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
}
|
||||
|
||||
if include_cost:
|
||||
|
||||
payload["usage"] = {"include": True}
|
||||
|
||||
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
|
||||
|
||||
payload=payload, timeout=timeout, proxy_url=proxy_url)
|
||||
@@ -155,12 +140,10 @@ 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 {}
|
||||
|
||||
cost = usage.get("cost")
|
||||
|
||||
return ChatResult(
|
||||
|
||||
text=text,
|
||||
@@ -171,8 +154,6 @@ def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage]
|
||||
|
||||
completion_tokens=int(usage.get("completion_tokens", 0)),
|
||||
|
||||
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
|
||||
|
||||
)
|
||||
|
||||
|
||||
@@ -195,7 +176,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 {}
|
||||
|
||||
|
||||
@@ -22,10 +22,9 @@ class OpenRouterProvider(LLMProvider):
|
||||
self.proxy_url = proxy_url or ""
|
||||
|
||||
def chat(self, *, messages: list[ChatMessage], model: str, params: dict | None = None) -> ChatResult:
|
||||
# usage.include=true — OpenRouter возвращает фактическую стоимость в usage.cost (USD).
|
||||
return openai_http.chat_completions(
|
||||
base_url=self.base_url, api_key=self.api_key, messages=messages, model=model,
|
||||
timeout=self.timeout, proxy_url=self.proxy_url, params=params, include_cost=True,
|
||||
timeout=self.timeout, proxy_url=self.proxy_url, params=params,
|
||||
)
|
||||
|
||||
def embed(self, *, texts: list[str], model: str) -> list[EmbeddingResult]:
|
||||
@@ -66,9 +65,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"))
|
||||
|
||||
@@ -25,6 +25,7 @@ from chatballs.ai.provider.base import LLMProvider, ProviderError
|
||||
from chatballs.ai.provider.custom import CustomProvider
|
||||
from chatballs.ai.provider.demo import DemoProvider
|
||||
from chatballs.ai.provider.openrouter import OpenRouterProvider
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import Integration, IntegrationProvider
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -21,10 +21,10 @@ class AgentInput:
|
||||
provider_integration_id: int | None
|
||||
model_params: dict
|
||||
allowed_tools: list
|
||||
limits: dict
|
||||
persona: str
|
||||
tone: str
|
||||
instructions: str
|
||||
answer_language: str
|
||||
knowledge_ids: list[int] | None # None -> выбор знаний не меняется
|
||||
|
||||
|
||||
@@ -38,20 +38,6 @@ class AgentCreateInput:
|
||||
knowledge_ids: list[int]
|
||||
|
||||
|
||||
# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD
|
||||
# (dailyCostUsd). Прочие исторические ключи (dailyCostMicros, dailyBudgetRub,
|
||||
# dailyDialogs, maxMessagesPerDialog) бэкендом не используются и отбрасываются.
|
||||
def _normalize_limits(raw: dict | None) -> dict:
|
||||
if not isinstance(raw, dict):
|
||||
return {}
|
||||
value = raw.get("dailyCostUsd")
|
||||
try:
|
||||
cents = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return {"dailyCostUsd": cents} if cents > 0 else {}
|
||||
|
||||
|
||||
def knowledge_for_agent_ids(
|
||||
*, context: TenantContext, channel: Channel, knowledge_ids: list[int]
|
||||
) -> list[Knowledge]:
|
||||
@@ -72,7 +58,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 +66,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 +103,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,
|
||||
@@ -145,10 +131,10 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
locked.provider_integration = selection.integration
|
||||
locked.model_params = data.model_params
|
||||
locked.allowed_tools = data.allowed_tools
|
||||
locked.limits = _normalize_limits(data.limits)
|
||||
locked.persona = data.persona
|
||||
locked.tone = data.tone
|
||||
locked.instructions = data.instructions
|
||||
locked.answer_language = data.answer_language
|
||||
locked.save(
|
||||
update_fields=[
|
||||
"name",
|
||||
@@ -156,10 +142,10 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) ->
|
||||
"provider_integration",
|
||||
"model_params",
|
||||
"allowed_tools",
|
||||
"limits",
|
||||
"persona",
|
||||
"tone",
|
||||
"instructions",
|
||||
"answer_language",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
@@ -173,7 +159,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 +167,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")}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -343,15 +343,6 @@ class ChatInvocationTests(TestCase):
|
||||
invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat")
|
||||
self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS)
|
||||
self.assertGreater(invocation.total_tokens, 0)
|
||||
# Технический учёт стоимости (ADR-CHATBALLS-0042 §2): считается по прайсу модели.
|
||||
from chatballs.ai import pricing
|
||||
|
||||
self.assertEqual(
|
||||
invocation.cost_micros,
|
||||
pricing.cost_micros(
|
||||
invocation.model, invocation.prompt_tokens, invocation.completion_tokens
|
||||
),
|
||||
)
|
||||
|
||||
def test_pii_is_redacted_before_reaching_provider(self) -> None:
|
||||
from unittest import mock
|
||||
@@ -378,24 +369,6 @@ class ChatInvocationTests(TestCase):
|
||||
|
||||
self.assertNotIn("a@b.com", captured["messages"][0].content)
|
||||
|
||||
def test_limit_blocks_and_records(self) -> None:
|
||||
from chatballs.ai import limits as ai_limits
|
||||
from chatballs.ai.invocation import invoke_chat
|
||||
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
|
||||
from chatballs.ai.provider.base import ChatMessage
|
||||
|
||||
self.agent.limits = {"dailyCostUsd": 1}
|
||||
self.agent.save(update_fields=["limits"])
|
||||
# 1 цент = 10 000 micro-USD; лимит превышен расходом в 10_001 micros.
|
||||
LlmInvocation.objects.create(
|
||||
channel=self.channel, purpose="seed", operation="chat", model="x", cost_micros=10_001,
|
||||
status=LlmInvocationStatus.SUCCESS,
|
||||
)
|
||||
|
||||
with self.assertRaises(ai_limits.LimitExceeded):
|
||||
invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat")
|
||||
self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists())
|
||||
|
||||
def test_invocation_records_used_fragment_ids(self) -> None:
|
||||
from chatballs.ai.invocation import invoke_chat
|
||||
from chatballs.ai.models import LlmInvocation
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from chatballs.calls.services import CALL_INVITE_SEND
|
||||
from chatballs.calls.tokens import issue_invite_token
|
||||
from chatballs.conversations import transports
|
||||
from chatballs.events.handlers import register
|
||||
from chatballs.i18n import customer_language, t
|
||||
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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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", "Таймаут"
|
||||
|
||||
|
||||
|
||||
@@ -27,10 +27,11 @@ from chatballs.calls.tokens import (
|
||||
verify_call_access_token,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import call_invite_route, call_session_route
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -45,10 +46,9 @@ def resolve_invite(*, token: str) -> ResolvedInvite:
|
||||
route = call_invite_route(token_hash)
|
||||
if route is None:
|
||||
raise CallTokenError(message)
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
raise CallTokenError(message) from None
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise CallTokenError(message)
|
||||
context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(context):
|
||||
invite = (
|
||||
@@ -92,10 +92,9 @@ def _authorize_call_access(
|
||||
route = call_session_route(str(claims.call_session_id))
|
||||
if route is None:
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
try:
|
||||
organization = Organization.objects.get(pk=route.organization_id)
|
||||
except Organization.DoesNotExist:
|
||||
raise CallTokenError(t("calls.token_invalid")) from None
|
||||
organization = load_organization(route.organization_id)
|
||||
if organization is None:
|
||||
raise CallTokenError(t("calls.token_invalid"))
|
||||
resource_context = TenantContext.for_resource(organization)
|
||||
with tenant_atomic(resource_context):
|
||||
try:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -22,8 +22,9 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.conversations.realtime import conversation_group, inbox_group
|
||||
from chatballs.conversations.selectors import conversation_is_visible
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import organization_by_public_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,8 +83,10 @@ class ConversationEventsConsumer(AsyncJsonWebsocketConsumer):
|
||||
@database_sync_to_async
|
||||
def _membership(self, user_id: int, raw_public_id: str) -> tuple[int, int] | None:
|
||||
try:
|
||||
organization = Organization.objects.get(public_id=uuid.UUID(str(raw_public_id)))
|
||||
except (ValueError, Organization.DoesNotExist):
|
||||
organization = organization_by_public_id(uuid.UUID(str(raw_public_id)))
|
||||
except ValueError:
|
||||
return None
|
||||
if organization is None:
|
||||
return None
|
||||
with tenant_atomic(organization.pk):
|
||||
membership = (
|
||||
|
||||
@@ -13,7 +13,6 @@ from dataclasses import dataclass
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.limits import LimitExceeded
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.ai.runtime import HANDOFF_TOKEN
|
||||
from chatballs.channels.runtime import run_channel_turn
|
||||
@@ -382,10 +381,9 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
|
||||
|
||||
try:
|
||||
result = run_channel_turn(channel=channel, message=ai_input, history=_history(conversation))
|
||||
except (ProviderError, LimitExceeded) as error:
|
||||
# Сбой AI (провайдер недоступен) или срабатывание лимита стоимости не должны
|
||||
# «терять» сообщение: переводим диалог в очередь к оператору, уведомляем и
|
||||
# отвечаем клиенту понятным fallback.
|
||||
except ProviderError as error:
|
||||
# Сбой AI не должен «терять» сообщение: переводим диалог в очередь к
|
||||
# оператору, уведомляем и отвечаем клиенту понятным fallback.
|
||||
logger.warning("AI turn failed for conversation %s: %s", conversation.id, error)
|
||||
conversation.control_mode = ControlMode.PAUSED
|
||||
conversation.expected_responder = ExpectedResponder.OPERATOR
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -22,6 +22,8 @@ def poll_all_messengers(context) -> int:
|
||||
channel__is_active=True,
|
||||
).exclude(secret="")
|
||||
if integration.config.get("purpose") != "notifications"
|
||||
# Демо-подключения из демо-набора: токены ненастоящие, опрашивать нечего.
|
||||
and not integration.config.get("demoSeed")
|
||||
]
|
||||
total = 0
|
||||
for integration in integrations:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,18 +13,11 @@ 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 = "Поделитесь, пожалуйста, номером телефона."
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ClaimError(Exception):
|
||||
|
||||
@@ -116,6 +109,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 +267,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(
|
||||
|
||||
|
||||
@@ -8,11 +8,10 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.db.models import Count, Sum
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import TruncDate, TruncHour
|
||||
from django.utils import timezone
|
||||
|
||||
from chatballs.ai.models import LlmInvocation
|
||||
from chatballs.channels.selectors import channels_in_organization
|
||||
from chatballs.conversations.models import (
|
||||
ControlMode,
|
||||
@@ -21,6 +20,7 @@ from chatballs.conversations.models import (
|
||||
LifecycleState,
|
||||
Message,
|
||||
)
|
||||
from chatballs.i18n import t
|
||||
|
||||
_ACTIVE_WINDOW = timedelta(minutes=15)
|
||||
_WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
@@ -63,17 +63,6 @@ def _chart(
|
||||
return {"values": values, "labels": labels}
|
||||
|
||||
|
||||
def _ai_cost(
|
||||
org_id: int,
|
||||
start: datetime,
|
||||
end: datetime | None = None,
|
||||
) -> int:
|
||||
qs = LlmInvocation.objects.filter(channel__organization_id=org_id, created_at__gte=start)
|
||||
if end is not None:
|
||||
qs = qs.filter(created_at__lt=end)
|
||||
return qs.aggregate(total=Sum("cost_micros"))["total"] or 0
|
||||
|
||||
|
||||
def sales_overview_stats(context, period: str) -> dict:
|
||||
organization_id = context.organization_id
|
||||
now = timezone.now()
|
||||
@@ -104,8 +93,6 @@ def sales_overview_stats(context, period: str) -> dict:
|
||||
conversation__organization_id=organization_id,
|
||||
created_at__gte=start,
|
||||
).count(),
|
||||
"aiCostMicros": _ai_cost(organization_id, start),
|
||||
"aiCostPrevMicros": _ai_cost(organization_id, prev_start, start),
|
||||
}
|
||||
|
||||
open_by_channel = dict(open_qs.values_list("channel_id").annotate(c=Count("id")))
|
||||
@@ -126,7 +113,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,
|
||||
|
||||
@@ -340,30 +340,34 @@ class ReplyTemplateTests(ChatExtrasTestCase):
|
||||
self.assertFalse(ReplyTemplate.objects.exists())
|
||||
|
||||
|
||||
class LaunchChecklistTests(TestCase):
|
||||
class OnboardingTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.organization = Organization.objects.create(name="New", slug="launch-org")
|
||||
owner = HumanUser.objects.create_user(
|
||||
self.owner = HumanUser.objects.create_user(
|
||||
email="owner@launch.test", password="Password-123"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
user=owner,
|
||||
self.membership = OrganizationMembership.objects.create(
|
||||
user=self.owner,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.OWNER,
|
||||
position_title="Owner",
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(owner)
|
||||
self.client.force_authenticate(self.owner)
|
||||
|
||||
def test_checklist_marks_steps_by_fact(self) -> None:
|
||||
initial = self.client.get("/api/v1/company/launch-checklist/").json()
|
||||
def test_steps_are_marked_by_fact(self) -> None:
|
||||
initial = self.client.get("/api/v1/company/onboarding/").json()
|
||||
self.assertEqual(
|
||||
initial,
|
||||
initial["steps"],
|
||||
{
|
||||
"agentCreated": False,
|
||||
"providerConnected": False,
|
||||
"agentActive": False,
|
||||
"knowledgeFilled": False,
|
||||
"connectionBound": False,
|
||||
"widgetPublished": False,
|
||||
"employeeInvited": False,
|
||||
"done": False,
|
||||
"platformConfigured": False,
|
||||
"firstConversation": False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -393,5 +397,56 @@ class LaunchChecklistTests(TestCase):
|
||||
position_title="Operator",
|
||||
)
|
||||
|
||||
final = self.client.get("/api/v1/company/launch-checklist/").json()
|
||||
self.assertTrue(final["done"])
|
||||
final = self.client.get("/api/v1/company/onboarding/").json()
|
||||
self.assertTrue(final["steps"]["connectionBound"])
|
||||
self.assertTrue(final["steps"]["employeeInvited"])
|
||||
|
||||
def test_existing_member_has_not_dismissed_onboarding(self) -> None:
|
||||
"""Признак пустой у всех, кто заведён до появления онбординга."""
|
||||
|
||||
payload = self.client.get("/api/v1/company/onboarding/").json()
|
||||
self.assertIsNone(payload["dismissedAt"])
|
||||
self.assertIsNone(payload["completedAt"])
|
||||
|
||||
def test_dismiss_and_complete_and_restart(self) -> None:
|
||||
dismissed = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
|
||||
).json()
|
||||
self.assertIsNotNone(dismissed["dismissedAt"])
|
||||
self.assertIsNone(dismissed["completedAt"])
|
||||
|
||||
completed = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "complete"}, format="json"
|
||||
).json()
|
||||
self.assertIsNotNone(completed["completedAt"])
|
||||
|
||||
restarted = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "restart"}, format="json"
|
||||
).json()
|
||||
self.assertIsNone(restarted["dismissedAt"])
|
||||
self.assertIsNone(restarted["completedAt"])
|
||||
|
||||
def test_dismissal_is_personal(self) -> None:
|
||||
"""Закрытие одним администратором не прячет визард у второго."""
|
||||
|
||||
self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "dismiss"}, format="json"
|
||||
)
|
||||
colleague = HumanUser.objects.create_user(
|
||||
email="admin@launch.test", password="Password-123"
|
||||
)
|
||||
OrganizationMembership.objects.create(
|
||||
user=colleague,
|
||||
organization=self.organization,
|
||||
role=EmployeeRole.ADMIN,
|
||||
position_title="Admin",
|
||||
)
|
||||
other = APIClient()
|
||||
other.force_authenticate(colleague)
|
||||
self.assertIsNone(other.get("/api/v1/company/onboarding/").json()["dismissedAt"])
|
||||
|
||||
def test_unknown_action_is_rejected(self) -> None:
|
||||
response = self.client.post(
|
||||
"/api/v1/company/onboarding/", {"action": "nope"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -15,6 +15,7 @@ from chatballs.conversations.models import Contact, Conversation, MessageAuthor
|
||||
from chatballs.conversations.selectors import conversation_messages
|
||||
from chatballs.conversations.serializers import conversation_payload, message_payload
|
||||
from chatballs.conversations.transports import email as email_transport
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.identity.bootstrap import bootstrap_owner
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
@@ -359,11 +360,11 @@ class EmailPollTests(TestCase):
|
||||
|
||||
with mock.patch.object(email_transport, "_imap_connect", side_effect=OSError("refused")):
|
||||
|
||||
messages, marker = email_transport.poll_updates(integration)
|
||||
with self.assertRaises(PollFailed):
|
||||
email_transport.poll_updates(integration)
|
||||
|
||||
self.assertEqual(messages, [])
|
||||
|
||||
self.assertEqual(marker, "7:99")
|
||||
# Курсор не сдвинулся: следующий удачный опрос продолжит с того же места.
|
||||
self.assertEqual(integration.poll_marker, "7:99")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Сбой опроса подключения: пауза с удвоением и тишина в журнале.
|
||||
|
||||
Раньше подключение с ненастоящим токеном писало предупреждение каждые три
|
||||
секунды. Теперь после сбоя оно пропускается с растущей паузой, журнал видит
|
||||
первый сбой, выход на максимальную паузу и восстановление, а курсор при
|
||||
сбое не двигается. Демо-подключения не опрашиваются вовсе.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.conversations import poller
|
||||
from chatballs.conversations.transports import backoff, poll
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
|
||||
|
||||
class PollBackoffTests(TestCase):
|
||||
def setUp(self) -> None:
|
||||
backoff.reset()
|
||||
self.addCleanup(backoff.reset)
|
||||
self.organization = Organization.objects.create(name="Poll", slug="poll-org")
|
||||
with tenant_atomic(self.organization.id):
|
||||
self.channel = Channel.objects.create(organization=self.organization, name="Main", code="main")
|
||||
self.integration = Integration.objects.create(
|
||||
organization=self.organization,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
provider=IntegrationProvider.TELEGRAM,
|
||||
name="Bot",
|
||||
secret="0000:not-a-token",
|
||||
channel=self.channel,
|
||||
poll_marker="41",
|
||||
)
|
||||
|
||||
def _fail(self, *_args, **_kwargs):
|
||||
raise PollFailed("HTTP Error 401: Unauthorized")
|
||||
|
||||
def test_failure_is_logged_once_and_then_skipped(self) -> None:
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
|
||||
with self.assertLogs("chatballs.conversations.transports.backoff", level="WARNING") as logs:
|
||||
self.assertEqual(poll(self.integration), ([], "41"))
|
||||
self.assertEqual(len(logs.output), 1)
|
||||
self.assertIn("401", logs.output[0])
|
||||
# Пока пауза не вышла, транспорт не вызывается и журнал молчит.
|
||||
with mock.patch.object(backoff, "_now", return_value=backoff._now()):
|
||||
with self.assertNoLogs("chatballs.conversations.transports.backoff", level="WARNING"):
|
||||
self.assertEqual(poll(self.integration), ([], "41"))
|
||||
self.assertTrue(backoff.should_skip(self.integration.id))
|
||||
|
||||
def test_delay_doubles_up_to_the_cap_and_recovery_is_logged(self) -> None:
|
||||
clock = [1000.0]
|
||||
with mock.patch.object(backoff, "_now", side_effect=lambda: clock[0]):
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: self._fail}):
|
||||
delays = []
|
||||
for _ in range(10):
|
||||
poll(self.integration)
|
||||
state = backoff._failures[self.integration.id]
|
||||
delays.append(state.delay)
|
||||
clock[0] = state.next_attempt_at # ждём ровно до следующей попытки
|
||||
self.assertEqual(delays[:3], [6.0, 12.0, 24.0])
|
||||
self.assertEqual(delays[-1], backoff.MAX_DELAY_SECONDS)
|
||||
self.assertTrue(all(delay <= backoff.MAX_DELAY_SECONDS for delay in delays))
|
||||
|
||||
with mock.patch.dict(poller.transports._POLL, {IntegrationProvider.TELEGRAM: lambda _i: ([], "42")}):
|
||||
with self.assertLogs("chatballs.conversations.transports.backoff", level="INFO") as logs:
|
||||
self.assertEqual(poll(self.integration), ([], "42"))
|
||||
self.assertIn("recovered", logs.output[0])
|
||||
self.assertNotIn(self.integration.id, backoff._failures)
|
||||
|
||||
def test_demo_seed_connections_are_not_polled(self) -> None:
|
||||
with tenant_atomic(self.organization.id):
|
||||
Integration.objects.filter(pk=self.integration.pk).update(config={"demoSeed": True})
|
||||
context = TenantContext.for_resource(self.organization)
|
||||
with mock.patch("chatballs.conversations.poller.transports.poll") as polled:
|
||||
poller.poll_all_messengers(context)
|
||||
polled.assert_not_called()
|
||||
@@ -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» уже покрывает латиницу",
|
||||
)
|
||||
@@ -3,7 +3,6 @@ from unittest import mock
|
||||
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from chatballs.ai.limits import LimitExceeded
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus
|
||||
from chatballs.ai.provider.base import ProviderError
|
||||
from chatballs.channels.models import Channel
|
||||
@@ -42,10 +41,9 @@ def _messenger_connection(channel):
|
||||
)
|
||||
|
||||
|
||||
class IngestLimitHandlingTests(TestCase):
|
||||
"""При срабатывании дневного лимита стоимости (LimitExceeded) диалог не должен
|
||||
«зависать»: его передают оператору с fallback-ответом клиенту (как при сбое
|
||||
провайдера). См. ingest.ingest_inbound.
|
||||
class IngestProviderFailureTests(TestCase):
|
||||
"""При сбое провайдера диалог не должен «зависать»: его передают оператору с
|
||||
fallback-ответом клиенту. См. ingest.ingest_inbound.
|
||||
"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
@@ -63,13 +61,13 @@ class IngestLimitHandlingTests(TestCase):
|
||||
external_id="ext-1", user_id="user-1", chat_id="chat-1", text="Здравствуйте", display_name="Гость"
|
||||
)
|
||||
|
||||
def test_limit_exceeded_hands_off_to_operator(self) -> None:
|
||||
def test_provider_failure_hands_off_to_operator(self) -> None:
|
||||
from chatballs.conversations.ingest import ingest_inbound
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"chatballs.conversations.ingest.run_channel_turn",
|
||||
side_effect=LimitExceeded("Channel daily AI cost limit reached"),
|
||||
side_effect=ProviderError("provider is down"),
|
||||
),
|
||||
mock.patch("chatballs.conversations.ingest.transports.send_reply", return_value=True) as send,
|
||||
):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from chatballs.conversations.transports import backoff
|
||||
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.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import t
|
||||
from chatballs.integrations.models import IntegrationProvider
|
||||
|
||||
_POLL = {
|
||||
@@ -50,7 +53,22 @@ SUPPORTED_PROVIDERS = tuple(_POLL.keys())
|
||||
|
||||
|
||||
def poll(integration):
|
||||
return _POLL[integration.provider](integration)
|
||||
"""Опрос подключения с паузой после сбоя (transports.backoff).
|
||||
|
||||
Сбой транспорта не роняет цикл и не пишется в журнал на каждой попытке:
|
||||
подключение пропускается с растущей паузой, а журнал видит только смену
|
||||
состояния. Курсор при сбое не двигается.
|
||||
"""
|
||||
|
||||
if backoff.should_skip(integration.id):
|
||||
return [], integration.poll_marker
|
||||
try:
|
||||
result = _POLL[integration.provider](integration)
|
||||
except PollFailed as error:
|
||||
backoff.record_failure(integration, error)
|
||||
return [], integration.poll_marker
|
||||
backoff.record_success(integration)
|
||||
return result
|
||||
|
||||
|
||||
def send_reply(integration, *, chat_id: str, user_id: str, text: str) -> bool:
|
||||
@@ -114,7 +132,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 +161,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:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Пауза между попытками опроса подключения после сбоя.
|
||||
|
||||
Воркер опрашивает мессенджеры каждые три секунды. Подключение с отозванным
|
||||
токеном или недоступным сервером отвечало ошибкой на каждый цикл и писало
|
||||
её в журнал двадцать раз в минуту — журнал переставал быть читаемым, а
|
||||
чужой сервер получал бессмысленный поток запросов. Теперь после сбоя
|
||||
подключение пропускается с растущей паузой, а в журнал попадают только
|
||||
изменения состояния: первый сбой, выход на максимальную паузу и
|
||||
восстановление.
|
||||
|
||||
Состояние живёт в памяти процесса: воркер один, а после перезапуска первая
|
||||
попытка всё равно нужна.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Первая пауза — два цикла опроса, дальше удвоение до четверти часа.
|
||||
FIRST_DELAY_SECONDS = 6.0
|
||||
MAX_DELAY_SECONDS = 900.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Failure:
|
||||
failures: int
|
||||
next_attempt_at: float
|
||||
delay: float
|
||||
|
||||
|
||||
_failures: dict[int, _Failure] = {}
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def should_skip(integration_id: int) -> bool:
|
||||
state = _failures.get(integration_id)
|
||||
return state is not None and _now() < state.next_attempt_at
|
||||
|
||||
|
||||
def record_failure(integration, error: object) -> None:
|
||||
previous = _failures.get(integration.id)
|
||||
failures = (previous.failures if previous else 0) + 1
|
||||
delay = min(FIRST_DELAY_SECONDS * 2 ** (failures - 1), MAX_DELAY_SECONDS)
|
||||
_failures[integration.id] = _Failure(failures=failures, next_attempt_at=_now() + delay, delay=delay)
|
||||
if failures == 1:
|
||||
logger.warning(
|
||||
"%s poll failed for integration %s: %s (next attempt in %.0fs)",
|
||||
integration.provider, integration.id, error, delay,
|
||||
)
|
||||
elif delay >= MAX_DELAY_SECONDS and (previous is None or previous.delay < MAX_DELAY_SECONDS):
|
||||
logger.warning(
|
||||
"%s poll keeps failing for integration %s: %s (retrying every %.0f min)",
|
||||
integration.provider, integration.id, error, MAX_DELAY_SECONDS / 60,
|
||||
)
|
||||
|
||||
|
||||
def record_success(integration) -> None:
|
||||
state = _failures.pop(integration.id, None)
|
||||
if state is not None:
|
||||
logger.info(
|
||||
"%s poll recovered for integration %s after %s failure(s)",
|
||||
integration.provider, integration.id, state.failures,
|
||||
)
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Для тестов: забыть все сбои."""
|
||||
|
||||
_failures.clear()
|
||||
@@ -26,6 +26,8 @@ from chatballs.conversations.transports.base import (
|
||||
guess_content_type,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -134,8 +136,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
client = _imap_connect(integration)
|
||||
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
|
||||
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
try:
|
||||
client.select("INBOX", readonly=True)
|
||||
validity = _status_value(client, "UIDVALIDITY")
|
||||
@@ -161,8 +162,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
new_marker = f"{validity}:{uids[-1]}" if uids else integration.poll_marker
|
||||
return messages, new_marker
|
||||
except (imaplib.IMAP4.error, OSError, TimeoutError) as error:
|
||||
logger.warning("Email IMAP poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
@@ -194,7 +194,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 +224,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)])
|
||||
@@ -0,0 +1,6 @@
|
||||
class PollFailed(Exception):
|
||||
"""Опрос подключения не удался: сеть, авторизация, ответ не разобран.
|
||||
|
||||
Транспорт не пишет об этом в журнал сам — решение, когда повторить и что
|
||||
записать, принимает слой пауз между попытками (``transports.backoff``).
|
||||
"""
|
||||
@@ -27,6 +27,8 @@ from chatballs.conversations.transports.base import (
|
||||
request_json_multipart,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.checks import DEFAULT_MAX_BASE_URL
|
||||
from chatballs.integrations.outbound import host_of
|
||||
|
||||
@@ -241,8 +243,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
data = request_json(url, headers={"Authorization": token, "Content-Type": "application/json"}, proxy_url=_proxy(integration))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
|
||||
logger.warning("MAX poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
updates = data.get("updates") or []
|
||||
messages = [m for m in (_normalize(u) for u in updates) if m is not None]
|
||||
new_marker = data.get("marker")
|
||||
@@ -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/<token>.
|
||||
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]})
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ from chatballs.conversations.transports.base import (
|
||||
request_json_multipart,
|
||||
safe_filename,
|
||||
)
|
||||
from chatballs.conversations.transports.errors import PollFailed
|
||||
from chatballs.i18n import customer_language, t
|
||||
from chatballs.integrations.checks import DEFAULT_TELEGRAM_BASE_URL
|
||||
from chatballs.integrations.outbound import host_of
|
||||
|
||||
@@ -123,8 +125,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
|
||||
try:
|
||||
data = request_json(url, proxy_url=_proxy(integration))
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
|
||||
logger.warning("Telegram poll failed for integration %s: %s", integration.id, error)
|
||||
return [], integration.poll_marker
|
||||
raise PollFailed(str(error)) from error
|
||||
if not data.get("ok"):
|
||||
return [], integration.poll_marker
|
||||
updates = data.get("result") or []
|
||||
@@ -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/<token>.
|
||||
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),
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -11,10 +11,11 @@ from chatballs.conversations.poller import poll_all_messengers
|
||||
from chatballs.events.handlers import dispatch
|
||||
from chatballs.events.models import OutboxStatus
|
||||
from chatballs.events.services import claim_next_outbox_event, mark_retry
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.notifications.binding import poll_notifier_bots
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.lookup import iter_organizations
|
||||
from chatballs.updates.services import check_for_updates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,7 +29,7 @@ class Command(BaseCommand):
|
||||
|
||||
@staticmethod
|
||||
def _tenant_contexts():
|
||||
for organization in Organization.objects.order_by("id").iterator():
|
||||
for organization in iter_organizations():
|
||||
yield TenantContext.for_resource(
|
||||
organization, actor_kind=TenantActorKind.SYSTEM
|
||||
)
|
||||
@@ -92,6 +93,12 @@ class Command(BaseCommand):
|
||||
logger.exception("Call sweep cycle failed")
|
||||
if now - last_maintenance >= MAINTENANCE_INTERVAL:
|
||||
last_maintenance = now
|
||||
# Канал релизов спрашивается не чаще раза в несколько часов:
|
||||
# интервал держит сама проверка по времени последнего ответа.
|
||||
try:
|
||||
check_for_updates()
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("Update check cycle failed")
|
||||
try:
|
||||
for context in self._tenant_contexts():
|
||||
with tenant_atomic(context):
|
||||
|
||||
@@ -7,8 +7,9 @@ from django.utils import timezone
|
||||
|
||||
from chatballs.events.context import get_correlation_id
|
||||
from chatballs.events.models import EventOwnership, OutboxEvent, OutboxStatus
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.tenancy.context import TenantActorKind, TenantContext
|
||||
from chatballs.tenancy.lookup import load_organization
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,7 +44,9 @@ def tenant_context_for_event(event: OutboxEvent) -> TenantContext | None:
|
||||
return None
|
||||
if event.organization_id is None:
|
||||
raise ValueError("Tenant event has no organization")
|
||||
organization = Organization.objects.get(pk=event.organization_id)
|
||||
organization = load_organization(event.organization_id)
|
||||
if organization is None:
|
||||
raise ValueError("Tenant event organization does not exist")
|
||||
try:
|
||||
actor_kind = TenantActorKind(event.actor_kind)
|
||||
except ValueError as error:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
MESSAGES: dict[str, object] = {
|
||||
"admin.choose_logo_file": "Choose a logo file",
|
||||
"admin.currency_rub_only": "Only the Russian rouble (RUB) is supported",
|
||||
"admin.employee_not_found": "Operator not found",
|
||||
"admin.group_name_taken": "A group with this name already exists",
|
||||
"admin.group_not_found": "Group not found",
|
||||
@@ -21,10 +20,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",
|
||||
@@ -82,6 +81,8 @@ MESSAGES: dict[str, object] = {
|
||||
"emails.initial_access_subject": "Your Chatballs access",
|
||||
"emails.password_reset_body": "Hello {name},\n\nYou asked to reset your Chatballs password. To set a new one, follow this link:\n{url}\n\nThe link is valid for 30 minutes. If you did not ask for a reset, simply ignore this email.",
|
||||
"emails.password_reset_subject": "Chatballs access recovery",
|
||||
"emails.membership_invitation_subject": "Invitation to {organization}",
|
||||
"emails.membership_invitation_body": "Hello, {name}.\n\nYou are invited to join the organization \u201c{organization}\u201d in Chatballs. To accept the invitation, follow this link:\n{url}\n\nThe link is valid for 7 days. If you did not expect this e-mail, simply ignore it.",
|
||||
"identity.link_invalid_or_expired": "The link is not valid or has expired",
|
||||
"identity.password_needs_digit": "The password must contain a digit.",
|
||||
"identity.password_needs_letter": "The password must contain a letter.",
|
||||
@@ -96,6 +97,8 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.new_message": "New message · {contact}",
|
||||
"notifications.operator_needed": "An operator is needed · {contact}",
|
||||
"notifications.voice_without_transcript": "A voice message without a transcript",
|
||||
"onboarding.membership_required": "Onboarding is available to organization members",
|
||||
"onboarding.unknown_action": "Unknown onboarding action",
|
||||
"portals.article_not_found": "Article not found",
|
||||
"portals.file_field_required": "A file is required in the file field (multipart/form-data)",
|
||||
"portals.file_not_found": "File not found",
|
||||
@@ -109,7 +112,7 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_section": "Invalid section",
|
||||
"portals.not_found": "Portal not found",
|
||||
"portals.section_not_found": "Section not found",
|
||||
"profile.accent_hex": "The accent is a HEX colour like #1677ff",
|
||||
"profile.accent_hex": "The accent is a HEX colour like #0f9b8e",
|
||||
"profile.choose_photo_file": "Choose a photo file",
|
||||
"profile.link_not_found": "Link not found",
|
||||
"profile.notification_bot_not_found": "Notification bot not found",
|
||||
@@ -208,6 +211,10 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_identity_avatar_updated": "Profile photo changed",
|
||||
"audit.action_identity_employee_blocked": "Operator blocked",
|
||||
"audit.action_identity_employee_created": "Operator added",
|
||||
"audit.action_identity_employee_invited": "Operator invited",
|
||||
"audit.action_identity_invitation_accepted": "Organization invitation accepted",
|
||||
"audit.action_identity_invitation_revoked": "Operator invitation revoked",
|
||||
"audit.action_updates_install_requested": "Installation update started",
|
||||
"audit.action_identity_employee_groups_changed": "Operator groups changed",
|
||||
"audit.action_identity_employee_password_reset": "Operator password reset",
|
||||
"audit.action_identity_employee_privileged_action_denied": "Privileged action denied",
|
||||
@@ -241,6 +248,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_integrations_integration_created": "Integration added",
|
||||
"audit.action_integrations_integration_deleted": "Integration deleted",
|
||||
"audit.action_integrations_integration_updated": "Integration changed",
|
||||
"audit.action_organization_created": "Organization created from the interface",
|
||||
"audit.action_organization_owner_activated": "Organization owner activated",
|
||||
"audit.action_organization_owner_invitation_requested": "Owner invitation sent",
|
||||
"audit.action_organization_provisioned": "Organization created",
|
||||
@@ -301,4 +309,240 @@ 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.organization_create_forbidden": "Only the installation administrator and organization owners or administrators can create organizations",
|
||||
"identity.invitation_invalid": "Invitation is invalid or has expired",
|
||||
"identity.invitation_account_exists": "An account with this address already exists. Sign in with it",
|
||||
"updates.nothing_to_install": "The latest version is already installed",
|
||||
"updates.updater_offline": "The update service is not running, so updating from the interface is unavailable",
|
||||
"updates.install_in_progress": "An update is already in progress",
|
||||
"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, WebP and SVG are supported",
|
||||
"admin.svg_logo_unsafe": "SVG must not contain scripts, event handlers or external links",
|
||||
"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",
|
||||
}
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
|
||||
MESSAGES: dict[str, object] = {
|
||||
"admin.choose_logo_file": "Выберите файл логотипа",
|
||||
"admin.currency_rub_only": "Поддерживается только российский рубль (RUB)",
|
||||
"admin.employee_not_found": "Сотрудник не найден",
|
||||
"admin.group_name_taken": "Группа с таким именем уже есть",
|
||||
"admin.group_not_found": "Группа не найдена",
|
||||
@@ -86,6 +85,8 @@ MESSAGES: dict[str, object] = {
|
||||
"emails.initial_access_subject": "Первичный доступ к Chatballs",
|
||||
"emails.password_reset_body": "Здравствуйте, {name}.\n\nВы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n{url}\n\nСсылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо.",
|
||||
"emails.password_reset_subject": "Восстановление доступа к Chatballs",
|
||||
"emails.membership_invitation_subject": "Приглашение в организацию {organization}",
|
||||
"emails.membership_invitation_body": "Здравствуйте, {name}.\n\nВас приглашают в организацию «{organization}» в Chatballs. Чтобы принять приглашение, перейдите по ссылке:\n{url}\n\nСсылка действует 7 дней. Если вы не ожидали это письмо, просто проигнорируйте его.",
|
||||
"identity.link_invalid_or_expired": "Ссылка недействительна или истекла",
|
||||
"identity.password_needs_digit": "Пароль должен содержать цифру.",
|
||||
"identity.password_needs_letter": "Пароль должен содержать букву.",
|
||||
@@ -100,6 +101,8 @@ MESSAGES: dict[str, object] = {
|
||||
"notifications.new_message": "Новое сообщение · {contact}",
|
||||
"notifications.operator_needed": "Нужен оператор · {contact}",
|
||||
"notifications.voice_without_transcript": "Голосовое без расшифровки",
|
||||
"onboarding.membership_required": "Онбординг доступен участнику организации",
|
||||
"onboarding.unknown_action": "Неизвестное действие онбординга",
|
||||
"portals.article_not_found": "Статья не найдена",
|
||||
"portals.file_field_required": "Нужен файл в поле file (multipart/form-data)",
|
||||
"portals.file_not_found": "Файл не найден",
|
||||
@@ -113,7 +116,7 @@ MESSAGES: dict[str, object] = {
|
||||
"portals.invalid_section": "Некорректный раздел",
|
||||
"portals.not_found": "Портал не найден",
|
||||
"portals.section_not_found": "Раздел не найден",
|
||||
"profile.accent_hex": "Акцент — HEX-цвет вида #1677ff",
|
||||
"profile.accent_hex": "Акцент — HEX-цвет вида #0f9b8e",
|
||||
"profile.choose_photo_file": "Выберите файл фото",
|
||||
"profile.link_not_found": "Привязка не найдена",
|
||||
"profile.notification_bot_not_found": "Бот уведомлений не найден",
|
||||
@@ -212,6 +215,10 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_identity_avatar_updated": "Изменено фото профиля",
|
||||
"audit.action_identity_employee_blocked": "Сотрудник заблокирован",
|
||||
"audit.action_identity_employee_created": "Добавлен сотрудник",
|
||||
"audit.action_identity_employee_invited": "Отправлено приглашение сотруднику",
|
||||
"audit.action_identity_invitation_accepted": "Принято приглашение в организацию",
|
||||
"audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника",
|
||||
"audit.action_updates_install_requested": "Запущено обновление установки",
|
||||
"audit.action_identity_employee_groups_changed": "Изменены группы сотрудника",
|
||||
"audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника",
|
||||
"audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии",
|
||||
@@ -245,6 +252,7 @@ MESSAGES: dict[str, object] = {
|
||||
"audit.action_integrations_integration_created": "Добавлена интеграция",
|
||||
"audit.action_integrations_integration_deleted": "Удалена интеграция",
|
||||
"audit.action_integrations_integration_updated": "Изменена интеграция",
|
||||
"audit.action_organization_created": "Создана организация из интерфейса",
|
||||
"audit.action_organization_owner_activated": "Активирован владелец организации",
|
||||
"audit.action_organization_owner_invitation_requested": "Отправлено приглашение владельцу",
|
||||
"audit.action_organization_provisioned": "Создана организация",
|
||||
@@ -305,4 +313,240 @@ 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.organization_create_forbidden": "Создавать организации могут администратор установки и владельцы или администраторы организаций",
|
||||
"identity.invitation_invalid": "Приглашение недействительно или истекло",
|
||||
"identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней",
|
||||
"updates.nothing_to_install": "Установлена последняя версия",
|
||||
"updates.updater_offline": "Сервис обновления не запущен — обновление из интерфейса недоступно",
|
||||
"updates.install_in_progress": "Обновление уже идёт",
|
||||
"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 и SVG",
|
||||
"admin.svg_logo_unsafe": "В SVG не должно быть скриптов, обработчиков событий и внешних ссылок",
|
||||
"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": "Неизвестный язык ответов агента",
|
||||
}
|
||||
@@ -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("нет.такого.ключа"), "нет.такого.ключа")
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ from chatballs.identity.models import (
|
||||
@admin.register(HumanUser)
|
||||
class HumanUserAdmin(UserAdmin):
|
||||
ordering = ["email"]
|
||||
list_display = ["email", "full_name", "is_active", "is_staff", "last_login"]
|
||||
list_display = ["email", "full_name", "is_active", "is_staff", "is_instance_admin", "last_login"]
|
||||
search_fields = ["email", "full_name"]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("email", "password")}),
|
||||
("Personal info", {"fields": ("full_name", "first_name", "last_name")}),
|
||||
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
|
||||
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "is_instance_admin", "groups", "user_permissions")}),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
@@ -35,7 +35,7 @@ class HumanUserAdmin(UserAdmin):
|
||||
|
||||
@admin.register(Organization)
|
||||
class OrganizationAdmin(admin.ModelAdmin):
|
||||
list_display = ["slug", "name", "currency", "timezone"]
|
||||
list_display = ["slug", "name", "timezone"]
|
||||
search_fields = ["slug", "name"]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -42,7 +43,6 @@ def organization_settings_payload(organization: Organization) -> dict[str, objec
|
||||
return {
|
||||
"name": organization.name,
|
||||
"timezone": organization.timezone,
|
||||
"currency": organization.currency,
|
||||
# Пустая строка доезжает до интерфейса как есть: там это отдельный
|
||||
# пункт «Как в установке», а не отсутствие значения.
|
||||
"language": organization.language,
|
||||
@@ -83,7 +83,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),
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.db import transaction
|
||||
|
||||
from chatballs.i18n import t
|
||||
from chatballs.i18n.languages import normalize_language
|
||||
from chatballs.identity.logo_svg import SVG_CONTENT_TYPE, looks_like_svg, svg_is_safe
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.context import TenantContext
|
||||
from chatballs.tenancy.storage import adjust_storage_usage
|
||||
@@ -27,17 +28,18 @@ MAX_LOGO_BYTES = 2 * 1024 * 1024
|
||||
class OrganizationSettingsInput:
|
||||
name: str
|
||||
timezone: str
|
||||
currency: str
|
||||
# Пустая строка — «как в установке»: организация не обязана выбирать язык,
|
||||
# и владелец, который его не трогал, не должен получить жёсткий русский
|
||||
# после того, как язык установки сменили.
|
||||
language: str = ""
|
||||
|
||||
|
||||
def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
def validate_organization_settings(data: OrganizationSettingsInput) -> OrganizationSettingsInput:
|
||||
"""Имя, часовой пояс и язык организации — одни правила для «Настроек» и
|
||||
для страницы создания организации."""
|
||||
|
||||
name = data.name.strip()
|
||||
timezone = data.timezone.strip()
|
||||
currency = data.currency.strip().upper()
|
||||
language = normalize_language(data.language)
|
||||
if data.language.strip() and not language:
|
||||
raise ValidationError({"language": t("settings.language_unsupported")})
|
||||
@@ -51,13 +53,7 @@ def _validate_input(data: OrganizationSettingsInput) -> OrganizationSettingsInpu
|
||||
raise ValidationError(
|
||||
{"timezone": t("admin.invalid_timezone")}
|
||||
) from error
|
||||
if currency != "RUB":
|
||||
raise ValidationError(
|
||||
{"currency": t("admin.currency_rub_only")}
|
||||
)
|
||||
return OrganizationSettingsInput(
|
||||
name=name, timezone=timezone, currency=currency, language=language
|
||||
)
|
||||
return OrganizationSettingsInput(name=name, timezone=timezone, language=language)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -66,15 +62,14 @@ def update_organization_settings(
|
||||
context: TenantContext,
|
||||
data: OrganizationSettingsInput,
|
||||
) -> Organization:
|
||||
clean = _validate_input(data)
|
||||
clean = validate_organization_settings(data)
|
||||
organization = Organization.objects.select_for_update().get(
|
||||
pk=context.organization_id
|
||||
)
|
||||
organization.name = clean.name
|
||||
organization.timezone = clean.timezone
|
||||
organization.currency = clean.currency
|
||||
organization.language = clean.language
|
||||
organization.save(update_fields=["name", "timezone", "currency", "language"])
|
||||
organization.save(update_fields=["name", "timezone", "language"])
|
||||
return organization
|
||||
|
||||
|
||||
@@ -85,6 +80,8 @@ def _image_type(data: bytes) -> tuple[str, str] | None:
|
||||
return "image/jpeg", ".jpg"
|
||||
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
return "image/webp", ".webp"
|
||||
if looks_like_svg(data):
|
||||
return SVG_CONTENT_TYPE, ".svg"
|
||||
return None
|
||||
|
||||
|
||||
@@ -98,10 +95,14 @@ 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")})
|
||||
if detected[0] == SVG_CONTENT_TYPE and not svg_is_safe(data):
|
||||
# Скрипты, внешние ссылки и обработчики событий в логотипе не нужны:
|
||||
# файл отклоняется целиком, а не переписывается молча.
|
||||
raise ValidationError({"file": t("admin.svg_logo_unsafe")})
|
||||
content_type, suffix = detected
|
||||
organization = Organization.objects.select_for_update().get(
|
||||
pk=context.organization_id
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -74,7 +74,6 @@ class OrganizationSettingsView(APIView):
|
||||
data=OrganizationSettingsInput(
|
||||
name=str(body.get("name", organization.name)),
|
||||
timezone=str(body.get("timezone", organization.timezone)),
|
||||
currency=str(body.get("currency", organization.currency)),
|
||||
language=str(body.get("language", organization.language)),
|
||||
),
|
||||
)
|
||||
@@ -107,11 +106,18 @@ class OrganizationLogoView(APIView):
|
||||
organization = request.tenant_context.organization
|
||||
if not organization.logo:
|
||||
return Response({"detail": t("admin.logo_not_uploaded")}, status=404)
|
||||
return FileResponse(
|
||||
response = FileResponse(
|
||||
organization.logo.open("rb"),
|
||||
content_type=organization.logo_content_type or "application/octet-stream",
|
||||
filename="organization-logo",
|
||||
)
|
||||
# Логотип показывается через <img>, но адрес можно открыть и напрямую.
|
||||
# SVG проверен при загрузке; заголовки — второй рубеж: в контексте
|
||||
# документа ему нельзя ни исполнять скрипты, ни ходить наружу, а
|
||||
# браузеру нельзя угадывать тип.
|
||||
response["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox"
|
||||
response["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
upload = request.FILES.get("file")
|
||||
@@ -282,41 +288,104 @@ 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
|
||||
|
||||
|
||||
class LaunchChecklistView(APIView):
|
||||
"""Чек-лист «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2): три шага с
|
||||
автоотметкой по факту. Скрытие блока — предпочтение клиента (localStorage)."""
|
||||
class OnboardingView(APIView):
|
||||
"""Состояние онбординга «Начало работы» для текущего человека.
|
||||
|
||||
Шаги отмечаются по факту, а не по нажатию «Далее»: визард — проводник, а
|
||||
не чек-лист с галочками вручную. Признаки «закрыл» и «прошёл» — на членстве
|
||||
в организации: у каждого человека свои, и один администратор не прячет
|
||||
визард команде.
|
||||
"""
|
||||
|
||||
permission_classes = [HasCapability]
|
||||
required_capability = "settings.view"
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
from chatballs.channels.models import Channel
|
||||
from chatballs.identity.models import OrganizationMembership
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
membership = request.tenant_context.membership
|
||||
return Response(
|
||||
{
|
||||
"steps": onboarding_steps(request.tenant_context.organization_id),
|
||||
"dismissedAt": _isoformat(membership.onboarding_dismissed_at if membership else None),
|
||||
"completedAt": _isoformat(membership.onboarding_completed_at if membership else None),
|
||||
}
|
||||
)
|
||||
|
||||
organization_id = request.tenant_context.organization_id
|
||||
agent_created = Channel.objects.filter(organization_id=organization_id).exists()
|
||||
connection_bound = Integration.objects.filter(
|
||||
def post(self, request: Request) -> Response:
|
||||
"""Закрыть визард или отметить его пройденным.
|
||||
|
||||
Тело ``{"action": "dismiss" | "complete" | "restart"}``. «Заново»
|
||||
снимает оба признака: визард снова открывается по ссылке и пилюле.
|
||||
"""
|
||||
|
||||
membership = request.tenant_context.membership
|
||||
if membership is None:
|
||||
return Response({"detail": t("onboarding.membership_required")}, status=403)
|
||||
action = str(request.data.get("action") or "dismiss")
|
||||
now = django_timezone.now()
|
||||
if action == "dismiss":
|
||||
membership.onboarding_dismissed_at = now
|
||||
elif action == "complete":
|
||||
membership.onboarding_dismissed_at = now
|
||||
membership.onboarding_completed_at = now
|
||||
elif action == "restart":
|
||||
membership.onboarding_dismissed_at = None
|
||||
membership.onboarding_completed_at = None
|
||||
else:
|
||||
return Response({"detail": t("onboarding.unknown_action")}, status=400)
|
||||
membership.save(update_fields=["onboarding_dismissed_at", "onboarding_completed_at"])
|
||||
return Response(
|
||||
{
|
||||
"steps": onboarding_steps(request.tenant_context.organization_id),
|
||||
"dismissedAt": _isoformat(membership.onboarding_dismissed_at),
|
||||
"completedAt": _isoformat(membership.onboarding_completed_at),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _isoformat(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
def onboarding_steps(organization_id: int) -> dict[str, bool]:
|
||||
"""Восемь фактов настройки установки, каждый — один запрос на существование."""
|
||||
|
||||
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge
|
||||
from chatballs.conversations.models import Conversation
|
||||
from chatballs.identity import instance_settings
|
||||
from chatballs.identity.models import Organization, OrganizationMembership
|
||||
from chatballs.integrations.models import Integration, IntegrationKind
|
||||
from chatballs.webchat.models import WebChatWidget, WebChatWidgetStatus
|
||||
|
||||
organization = Organization.objects.filter(pk=organization_id).first()
|
||||
return {
|
||||
"providerConnected": Integration.objects.filter(
|
||||
organization_id=organization_id,
|
||||
kind=IntegrationKind.LLM_PROVIDER,
|
||||
).exists(),
|
||||
"agentActive": AIAgent.objects.filter(
|
||||
organization_id=organization_id,
|
||||
status=AIAgentStatus.ACTIVE,
|
||||
).exists(),
|
||||
"knowledgeFilled": Knowledge.objects.filter(organization_id=organization_id).exists(),
|
||||
"connectionBound": Integration.objects.filter(
|
||||
organization_id=organization_id,
|
||||
kind=IntegrationKind.MESSENGER,
|
||||
channel__isnull=False,
|
||||
).exists()
|
||||
employee_invited = (
|
||||
OrganizationMembership.objects.filter(
|
||||
organization_id=organization_id
|
||||
).count()
|
||||
> 1
|
||||
or request.tenant_context.organization.invitations.exists()
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"agentCreated": agent_created,
|
||||
"connectionBound": connection_bound,
|
||||
"employeeInvited": employee_invited,
|
||||
"done": agent_created and connection_bound and employee_invited,
|
||||
}
|
||||
)
|
||||
).exists(),
|
||||
"widgetPublished": WebChatWidget.objects.filter(
|
||||
organization_id=organization_id,
|
||||
status=WebChatWidgetStatus.PUBLISHED,
|
||||
).exists(),
|
||||
"employeeInvited": (
|
||||
OrganizationMembership.objects.filter(organization_id=organization_id).count() > 1
|
||||
or (organization is not None and organization.invitations.exists())
|
||||
),
|
||||
# Шаг про саму установку, а не про организацию: домен и исходящая почта
|
||||
# общие для всех организаций на сервере.
|
||||
"platformConfigured": bool(instance_settings.public_host()) and instance_settings.email_is_configured(),
|
||||
"firstConversation": Conversation.objects.filter(organization_id=organization_id).exists(),
|
||||
}
|
||||
@@ -78,6 +78,10 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
|
||||
"identity.avatar_updated": "audit.action_identity_avatar_updated",
|
||||
"identity.avatar_deleted": "audit.action_identity_avatar_deleted",
|
||||
"identity.employee_created": "audit.action_identity_employee_created",
|
||||
"identity.employee_invited": "audit.action_identity_employee_invited",
|
||||
"identity.invitation_accepted": "audit.action_identity_invitation_accepted",
|
||||
"identity.invitation_revoked": "audit.action_identity_invitation_revoked",
|
||||
"updates.install_requested": "audit.action_updates_install_requested",
|
||||
"identity.employee_updated": "audit.action_identity_employee_updated",
|
||||
"identity.employee_blocked": "audit.action_identity_employee_blocked",
|
||||
"identity.employee_unblocked": "audit.action_identity_employee_unblocked",
|
||||
@@ -107,6 +111,7 @@ AUDIT_ACTION_LABELS: dict[str, str] = {
|
||||
"administration.instance_updated": "audit.action_administration_instance_updated",
|
||||
# --- Организация ---
|
||||
"organization.provisioned": "audit.action_organization_provisioned",
|
||||
"organization.created": "audit.action_organization_created",
|
||||
"organization.owner_activated": "audit.action_organization_owner_activated",
|
||||
"organization.owner_invitation_requested": "audit.action_organization_owner_invitation_requested",
|
||||
# --- Интеграции и каналы ---
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from chatballs.identity.auth.invitations import InvitationAcceptView
|
||||
from chatballs.identity.auth.invitations import (
|
||||
InvitationAcceptView,
|
||||
InvitationPreviewView,
|
||||
InvitationRegisterView,
|
||||
)
|
||||
from chatballs.identity.auth.password_reset import (
|
||||
PasswordResetConfirmView,
|
||||
PasswordResetRequestView,
|
||||
@@ -40,4 +44,6 @@ __all__ = [
|
||||
"TotpConfirmView",
|
||||
"TotpVerifyView",
|
||||
"InvitationAcceptView",
|
||||
"InvitationPreviewView",
|
||||
"InvitationRegisterView",
|
||||
]
|
||||
@@ -1,32 +1,48 @@
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from chatballs.i18n import current_language
|
||||
from chatballs.i18n import current_language, t
|
||||
from chatballs.identity.avatars import own_avatar_url
|
||||
from chatballs.identity.models import HumanUser, Organization, OrganizationMembership
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.policy import get_effective_access
|
||||
from chatballs.identity.sessions import revoke_user_sessions
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user
|
||||
|
||||
|
||||
def validation_response(error: ValidationError) -> Response:
|
||||
"""Ошибки формы полями: мастер первого запуска и регистрация по приглашению."""
|
||||
|
||||
if hasattr(error, "message_dict"):
|
||||
errors = {
|
||||
key: messages[0] if isinstance(messages, list) else str(messages)
|
||||
for key, messages in error.message_dict.items()
|
||||
}
|
||||
# validate_password кладёт сообщения без ключа поля.
|
||||
if "__all__" in errors:
|
||||
errors["password"] = " ".join(error.message_dict["__all__"])
|
||||
del errors["__all__"]
|
||||
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)
|
||||
|
||||
|
||||
def _user_payload(user: HumanUser) -> dict[str, object]:
|
||||
memberships = []
|
||||
routes = membership_routes_for_user(user.id)
|
||||
organizations = Organization.objects.in_bulk(
|
||||
[route.organization_id for route in routes]
|
||||
)
|
||||
for route in routes:
|
||||
organization = organizations.get(route.organization_id)
|
||||
if organization is None:
|
||||
continue
|
||||
with tenant_atomic(organization.id):
|
||||
# Организация читается вместе с членством внутри её контекста: роль
|
||||
# app не видит чужие строки организаций (tenancy/0033).
|
||||
with tenant_atomic(route.organization_id):
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(
|
||||
id=route.resource_id,
|
||||
user=user,
|
||||
organization=organization,
|
||||
organization_id=route.organization_id,
|
||||
blocked_at__isnull=True,
|
||||
)
|
||||
.first()
|
||||
@@ -70,6 +86,9 @@ def _user_payload(user: HumanUser) -> dict[str, object]:
|
||||
"uiLanguage": user.ui_language,
|
||||
"language": current_language(),
|
||||
"avatarUrl": own_avatar_url(user),
|
||||
# Администратор установки видит раздел «Платформа» и хранилище
|
||||
# файлов: это свойства инсталляции, а не организации.
|
||||
"isInstanceAdmin": user.is_instance_admin,
|
||||
"memberships": memberships,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.contrib.auth import login
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from chatballs.identity.auth.common import _user_payload
|
||||
from chatballs.identity.invitation_service import InvitationError, accept_invitation
|
||||
from chatballs.i18n import t
|
||||
from chatballs.identity.auth.common import _user_payload, validation_response
|
||||
from chatballs.identity.invitation_service import (
|
||||
InvitationError,
|
||||
accept_invitation,
|
||||
invitation_preview,
|
||||
register_and_accept,
|
||||
)
|
||||
from chatballs.identity.sessions import remember_device
|
||||
|
||||
|
||||
@method_decorator(ensure_csrf_cookie, name="dispatch")
|
||||
class InvitationPreviewView(APIView):
|
||||
"""Что стоит за ссылкой /join до входа: организация, адрес, есть ли учётная запись.
|
||||
|
||||
Ответ нужен экрану, чтобы решить, показать вход или форму создания пароля.
|
||||
Токен — секрет из письма, поэтому подробности отдаются только по нему.
|
||||
"""
|
||||
|
||||
authentication_classes: list = []
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "login"
|
||||
|
||||
def get(self, request: Request) -> Response:
|
||||
token = str(request.query_params.get("token", "")).strip()
|
||||
preview = invitation_preview(token) if token else None
|
||||
if preview is None:
|
||||
return Response({"valid": False})
|
||||
return Response({"valid": True, **preview})
|
||||
|
||||
|
||||
@method_decorator(csrf_protect, name="dispatch")
|
||||
class InvitationRegisterView(APIView):
|
||||
"""Создать учётную запись по приглашению, принять его и войти."""
|
||||
|
||||
authentication_classes: list = []
|
||||
permission_classes = [AllowAny]
|
||||
throttle_classes = [ScopedRateThrottle]
|
||||
throttle_scope = "login"
|
||||
|
||||
def post(self, request: Request) -> Response:
|
||||
body = request.data if isinstance(request.data, dict) else {}
|
||||
token = str(body.get("token", "")).strip()
|
||||
if not token:
|
||||
return Response({"detail": t("identity.token_required")}, status=400)
|
||||
try:
|
||||
accepted = register_and_accept(
|
||||
token=token,
|
||||
full_name=str(body.get("fullName", "")),
|
||||
password=str(body.get("password", "")),
|
||||
)
|
||||
except InvitationError as error:
|
||||
return Response({"detail": str(error), "code": error.code}, status=400)
|
||||
except ValidationError as error:
|
||||
return validation_response(error)
|
||||
user = accepted.membership.user
|
||||
user.backend = "django.contrib.auth.backends.ModelBackend"
|
||||
login(request, user)
|
||||
remember_device(request)
|
||||
return Response(
|
||||
{
|
||||
"authenticated": True,
|
||||
"user": _user_payload(user),
|
||||
"organizationPublicId": str(accepted.organization.public_id),
|
||||
},
|
||||
status=201,
|
||||
)
|
||||
|
||||
|
||||
class InvitationAcceptView(APIView):
|
||||
"""Accept an OWNER invitation (SPEC-HUB-0021 §8.2).
|
||||
"""Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee.
|
||||
|
||||
Authenticated endpoint: the caller must already have a HumanUser account
|
||||
(created through sign-up / password setup). The token is read from the body;
|
||||
@@ -23,9 +94,15 @@ 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)
|
||||
accepted = accept_invitation(token=token, user=request.user)
|
||||
except InvitationError as error:
|
||||
return Response({"detail": str(error)}, status=400)
|
||||
return Response({"user": _user_payload(request.user)})
|
||||
return Response(
|
||||
{
|
||||
"user": _user_payload(request.user),
|
||||
# Куда открыть приложение после принятия.
|
||||
"organizationPublicId": str(accepted.organization.public_id),
|
||||
}
|
||||
)
|
||||
@@ -17,7 +17,8 @@ from chatballs.identity.avatars import delete_user_avatar, replace_user_avatar
|
||||
from chatballs.identity.instance_settings import default_language
|
||||
from chatballs.identity.models import HumanUser, OrganizationMembership
|
||||
from chatballs.identity.sessions import list_user_sessions
|
||||
from chatballs.tenancy.ingress import user_requires_totp
|
||||
from chatballs.tenancy.database import tenant_atomic
|
||||
from chatballs.tenancy.ingress import membership_routes_for_user, user_requires_totp
|
||||
|
||||
|
||||
class ProfileUpdateView(APIView):
|
||||
@@ -28,11 +29,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 +98,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 +151,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 +200,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:
|
||||
@@ -289,10 +290,20 @@ def _request_organization_language(request: Request) -> str:
|
||||
context = getattr(request, "tenant_context", None)
|
||||
if context is not None:
|
||||
return context.organization.language or ""
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(user=request.user, blocked_at__isnull=True)
|
||||
.order_by("created_at", "id")
|
||||
.first()
|
||||
)
|
||||
return membership.organization.language if membership is not None else ""
|
||||
# Членства роли app без контекста не видны: сначала каталог входа, затем
|
||||
# каждое членство читается в контексте своей организации — как в
|
||||
# identity.auth.common._user_payload.
|
||||
oldest: tuple[object, int, str] | None = None
|
||||
for route in membership_routes_for_user(request.user.id):
|
||||
with tenant_atomic(route.organization_id):
|
||||
membership = (
|
||||
OrganizationMembership.objects.select_related("organization")
|
||||
.filter(id=route.resource_id, user=request.user, blocked_at__isnull=True)
|
||||
.first()
|
||||
)
|
||||
if membership is None:
|
||||
continue
|
||||
key = (membership.created_at, membership.id, membership.organization.language or "")
|
||||
if oldest is None or key[:2] < oldest[:2]:
|
||||
oldest = key
|
||||
return oldest[2] if oldest is not None else ""
|
||||
@@ -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.
|
||||
|
||||
@@ -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 вместе с номером интервала.
|
||||
|
||||
@@ -23,4 +23,6 @@ urlpatterns = [
|
||||
path("totp/confirm/", auth.TotpConfirmView.as_view(), name="auth-totp-confirm"),
|
||||
path("totp/verify/", auth.TotpVerifyView.as_view(), name="auth-totp-verify"),
|
||||
path("invitations/accept/", auth.InvitationAcceptView.as_view(), name="auth-invitation-accept"),
|
||||
path("invitations/preview/", auth.InvitationPreviewView.as_view(), name="auth-invitation-preview"),
|
||||
path("invitations/register/", auth.InvitationRegisterView.as_view(), name="auth-invitation-register"),
|
||||
]
|
||||
@@ -38,9 +38,12 @@ def user_avatar_url(user: HumanUser | None, organization_public_id) -> str | Non
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def organization_public_id(organization_id: int) -> str:
|
||||
"""public_id организации по id — неизменяем, поэтому кэшируется."""
|
||||
from chatballs.identity.models import Organization
|
||||
from chatballs.tenancy.ingress import organization_public_id_of
|
||||
|
||||
return str(Organization.objects.values_list("public_id", flat=True).get(pk=organization_id))
|
||||
public_id = organization_public_id_of(organization_id)
|
||||
if public_id is None:
|
||||
raise LookupError(f"Organization {organization_id} does not exist")
|
||||
return public_id
|
||||
|
||||
|
||||
def user_avatar_url_in(user: HumanUser | None, organization_id: int) -> str | None:
|
||||
@@ -68,10 +71,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)
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
|
||||
defaults={
|
||||
"name": "Demo",
|
||||
"timezone": "Europe/Moscow",
|
||||
"currency": "RUB",
|
||||
},
|
||||
)
|
||||
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
|
||||
@@ -50,15 +49,17 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
|
||||
"full_name": full_name,
|
||||
"is_staff": True,
|
||||
"is_superuser": True,
|
||||
"is_instance_admin": True,
|
||||
},
|
||||
)
|
||||
if created_owner:
|
||||
owner.set_password(password)
|
||||
owner.save(update_fields=["password"])
|
||||
elif not owner.is_staff or not owner.is_superuser:
|
||||
elif not owner.is_staff or not owner.is_superuser or not owner.is_instance_admin:
|
||||
owner.is_staff = True
|
||||
owner.is_superuser = True
|
||||
owner.save(update_fields=["is_staff", "is_superuser"])
|
||||
owner.is_instance_admin = True
|
||||
owner.save(update_fields=["is_staff", "is_superuser", "is_instance_admin"])
|
||||
|
||||
OrganizationMembership.objects.get_or_create(
|
||||
user=owner,
|
||||
|
||||
@@ -4,10 +4,8 @@ from chatballs.identity import (
|
||||
administration_views,
|
||||
demo_views,
|
||||
group_views,
|
||||
instance_views,
|
||||
)
|
||||
from chatballs.integrations import feature_views
|
||||
from chatballs.tenancy import storage_views
|
||||
|
||||
urlpatterns = [
|
||||
path("groups/", group_views.GroupListView.as_view(), name="group-list"),
|
||||
@@ -17,31 +15,20 @@ urlpatterns = [
|
||||
administration_views.OrganizationSettingsView.as_view(),
|
||||
name="organization-settings",
|
||||
),
|
||||
path(
|
||||
"administration/instance/",
|
||||
instance_views.InstanceAddressView.as_view(),
|
||||
name="instance-address",
|
||||
),
|
||||
path(
|
||||
"administration/instance/email-check/",
|
||||
instance_views.InstanceEmailCheckView.as_view(),
|
||||
name="instance-email-check",
|
||||
),
|
||||
# Настройки установки (адрес, почта, TURN, хранилище) — не свойства
|
||||
# организации: они живут на /api/v1/instance/ (identity.instance_urls).
|
||||
path(
|
||||
"administration/logo/",
|
||||
administration_views.OrganizationLogoView.as_view(),
|
||||
name="organization-logo",
|
||||
),
|
||||
path(
|
||||
"launch-checklist/",
|
||||
administration_views.LaunchChecklistView.as_view(),
|
||||
name="launch-checklist",
|
||||
"onboarding/",
|
||||
administration_views.OnboardingView.as_view(),
|
||||
name="onboarding",
|
||||
),
|
||||
path("demo/", demo_views.DemoDataView.as_view(), name="organization-demo-data"),
|
||||
path("administration/communication/", feature_views.CommunicationSettingsView.as_view(), name="communication-settings"),
|
||||
path("administration/storage/", storage_views.StorageSettingsView.as_view(), name="storage-settings"),
|
||||
path("administration/storage/check/", storage_views.StorageCheckView.as_view(), name="storage-check"),
|
||||
path("administration/storage/migrate/", storage_views.StorageMigrateView.as_view(), name="storage-migrate"),
|
||||
path(
|
||||
"administration/audit/",
|
||||
administration_views.AuditListView.as_view(),
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"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"
|
||||
],
|
||||
"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": [],
|
||||
"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": [],
|
||||
"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
|
||||
],
|
||||
"failuresTotal": 3
|
||||
}
|
||||
}
|
||||
@@ -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": "<p>Hello.</p><p>Order <b>4390</b> was paid for a week ago and the status is not changing. What is going on?</p><p>— Irina Levina, purchasing</p>"
|
||||
},
|
||||
{
|
||||
"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": "<p>Hello, Irina.</p><p>I can see order 4390 with the status “In production”. I will check the timing with my colleagues — passing the question on.</p>"
|
||||
},
|
||||
{
|
||||
"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": "<p>Good afternoon, Irina.</p><p>I have checked: the order has already gone to the courier, and the tracking number will arrive by e-mail today before 19:00.</p>"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "<p>Hello!</p><p>I am trying to reset the password for my account on <b>nord-atelier.com</b> — the e-mail has not arrived for an hour. The address is right, I have checked.</p><p>Kind regards,<br>Polina Rakitina</p>",
|
||||
"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": "<p>Hello, Polina!</p><p>The message comes from <b>no-reply@nord-atelier.com</b> within 5 minutes. Please check your spam folder. If it is not there, I will pass the request to a member of staff.</p>",
|
||||
"minutesAgo": 238,
|
||||
"daysAgo": 2
|
||||
},
|
||||
{
|
||||
"author": "CONTACT",
|
||||
"text": "It is not in spam either. Please help.",
|
||||
"contentHtml": "<p>It is not in spam either. Please help.</p>",
|
||||
"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": "<p>Hello, Polina!</p><p>The messages were going to an address with a typo — I have fixed it and sent the reset link again.</p><p>Igor Saveliev, support</p>"
|
||||
},
|
||||
{
|
||||
"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": "<p>Hello, Polina!</p><p>The messages were going to an address with a typo — I have fixed it and sent the reset link again.</p><p>Igor Saveliev, support</p>"
|
||||
},
|
||||
{
|
||||
"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": "<p>Hello, Polina!</p><p>The messages were going to an address with a typo — I have fixed it and sent the reset link again.</p><p>Igor Saveliev, support</p>"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user