diff --git a/.skaro/adr/0001-sostoyaniya-i-zhiznennyy-tsikl-dialoga.md b/.skaro/adr/0001-sostoyaniya-i-zhiznennyy-tsikl-dialoga.md new file mode 100644 index 0000000..9a2558b --- /dev/null +++ b/.skaro/adr/0001-sostoyaniya-i-zhiznennyy-tsikl-dialoga.md @@ -0,0 +1,36 @@ +--- +id: "0001" +title: Состояния и жизненный цикл диалога +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Один комбинированный статус диалога даёт комбинаторный рост состояний: «открыт и отвечает AI», «открыт и ждёт оператора», «закрыт», «спам» и их сочетания. Повторное открытие давнего диалога смешивает разные обращения одного человека в одну ленту и искажает статистику. + +## Решение + +Состояние диалога разделяется на три независимые оси: + +- **LifecycleState**: `OPEN`, `CLOSED`, `SPAM`; +- **ControlMode** — кто ведёт: `AI`, `HUMAN`, `PAUSED`; +- **ExpectedResponder** — от кого ждут реплики: `CUSTOMER`, `AI`, `OPERATOR`, `NOBODY`. + +Правила: + +- новый диалог создаётся в режиме `AI`; +- закрытие панели веб-чата, переход на другую страницу и закрытие браузера не меняют жизненный цикл; +- семь дней без активности переводят `OPEN` в `CLOSED`; +- новое сообщение после `CLOSED` создаёт новый `Conversation` того же контакта с `previous_conversation_id`; +- `SPAM` не переоткрывается и не создаёт новый диалог автоматически; +- для оперативной статистики активным считается открытый диалог с активностью за последние 15 минут. + +Отклонены: один общий enum (комбинаторный рост); повторное открытие закрытого диалога (смешивает обращения, ломает статистику). + +## Последствия + +- Каждое обращение учитывается отдельно; оси не дублируют друг друга; история клиента связна через цепочку диалогов. +- Переходы валидируются доменным сервисом, а не свободной записью поля. +- Нужна фоновая задача автоматического закрытия. +- Транспорт обязан направить новое сообщение в новый диалог, а не дописать его в закрытый. \ No newline at end of file diff --git a/.skaro/adr/0002-ai-first-obrabotka-i-ruchnoy-perehvat.md b/.skaro/adr/0002-ai-first-obrabotka-i-ruchnoy-perehvat.md new file mode 100644 index 0000000..6bcd094 --- /dev/null +++ b/.skaro/adr/0002-ai-first-obrabotka-i-ruchnoy-perehvat.md @@ -0,0 +1,45 @@ +--- +id: "0002" +title: AI-first обработка и ручной перехват +status: accepted +date: 2026-09-28 +--- + +## Контекст + +AI должен обрабатывать основную массу обращений, но сотрудник обязан мгновенно получить исключительный контроль над диалогом. Модель должна работать и с одним сотрудником, и с несколькими — без гонок и двойных ответов клиенту. + +## Решение + +Новый диалог ведёт агент, в подключение которого пришло обращение. + +**AI может:** отвечать по инструкциям и прикреплённым знаниям, уточнять потребность, передавать клиенту ссылки на вложения знаний и статьи портала, запрашивать сотрудника и формировать резюме передачи. **AI не может:** обещать сведения, отсутствующие в знаниях, и совершать действия, оставленные человеку. + +### Детерминированная передача человеку + +Передача выполняется, если клиент попросил человека; в знаниях нет основания для ответа; запрос относится к операции только для человека; возник конфликт или жалоба; достигнут лимит агента; есть подозрение на злоупотребление. Самооценка модели «уверен / не уверен» основанием не является. + +### Общая очередь и атомарный claim + +- запросы человека попадают в общую очередь диалогов, ждущих ответа; +- сотрудник выполняет атомарный `claim`, забрать диалог может только один; +- в режиме `HUMAN` писать клиенту может назначенный сотрудник; +- владелец и администратор могут перехватить или переназначить диалог; +- сотрудник может вернуть диалог в очередь; блокировка учётной записи возвращает её открытые диалоги в очередь; +- закрытие браузера не снимает назначение; +- возврат к AI — только явным действием человека. + +### Атомарный перехват у AI + +1. блокировка диалога; 2. блокировка отправки новых AI-ответов; 3. отмена незавершённой генерации; 4. режим `HUMAN`; 5. назначение сотрудника; 6. системное событие; 7. резюме сотруднику. + +Запрос онлайн-звонка — человеческое действие: из режима `AI` команда звонка сначала выполняет этот перехват и только после успеха создаёт `CallSession`; при конфликте claim приглашение клиенту не отправляется. + +Отклонены: обязательная проверка человеком каждого AI-ответа (узкое место); автоматический возврат к AI по таймеру (неожиданный ответ робота); round-robin распределение (избыточно для масштаба). + +## Последствия + +- Двойной ответ клиенту исключён; очередь работает с любым числом сотрудников; передача проверяема. +- Нужны транзакционная блокировка и проверка режима перед каждой отправкой. +- Автоматического распределения диалогов по сотрудникам нет. +- Качество резюме зависит от конфигурации агента. \ No newline at end of file diff --git a/.skaro/adr/0003-identifikatsiya-klienta-i-obedinenie-kon.md b/.skaro/adr/0003-identifikatsiya-klienta-i-obedinenie-kon.md new file mode 100644 index 0000000..317c4ac --- /dev/null +++ b/.skaro/adr/0003-identifikatsiya-klienta-i-obedinenie-kon.md @@ -0,0 +1,31 @@ +--- +id: "0003" +title: Идентификация клиента и объединение контактов +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Один человек может написать через Telegram, MAX, ВКонтакте, почту и веб-виджет; внешние идентификаторы разные. Автоматическое объединение по слабым признакам (имя, похожий адрес) смешивает данные разных людей, и ошибку потом нельзя обнаружить изнутри системы. + +## Решение + +- `Contact` — человек в организации; +- `ConnectionIdentity` — устойчивая идентичность внутри конкретного подключения: Telegram user ID, MAX user ID, ID пользователя ВКонтакте, адрес отправителя письма, для веб-виджета — идентификатор сессии и высокоэнтропийный credential (на сервере только hash, клиенту — короткоживущий токен). + +Сессия веб-виджета хранится браузером в контексте конкретного виджета и восстанавливает анонимную историю, но **не является подтверждённой личностью** и сама по себе не объединяет контакт с другими подключениями. Очистка browser storage создаёт новую анонимную идентичность. + +Каждое подключение принадлежит одному агенту; агент диалога определяется по подключению до начала разговора. + +Автоматическое объединение идентичностей разных подключений запрещено, кроме случая, когда само подключение даёт подтверждённый номер телефона и совпадение однозначно. Объединение по имени запрещено. + +Ручное объединение и разъединение доступны владельцу и администратору: сотрудник может предложить объединение; инициатор видит сравнение; операция требует причины; переносятся идентичности и диалоги; операция полностью аудируется и обратима. + +Отклонены: автообъединение по email или введённому телефону (клиент может ввести чужой адрес); одно общее подключение на все транспорты (риск неправильного агента и знаний). + +## Последствия + +- Риск смешения персональных данных минимален; история сохраняется; ошибочное объединение откатывается. +- Один человек может временно существовать как несколько контактов. +- Требуется интерфейс предложения, объединения и разъединения. \ No newline at end of file diff --git a/.skaro/adr/0004-soglasie-klienta-i-minimizatsiya-dannyh.md b/.skaro/adr/0004-soglasie-klienta-i-minimizatsiya-dannyh.md new file mode 100644 index 0000000..faacf8f --- /dev/null +++ b/.skaro/adr/0004-soglasie-klienta-i-minimizatsiya-dannyh.md @@ -0,0 +1,32 @@ +--- +id: "0004" +title: Согласие клиента и минимизация данных для LLM +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Платформа обрабатывает сообщения и контактные данные клиентов. Внешняя модель не должна получать идентификаторы и персональные сведения, не нужные для ответа. Клиент публичного веб-виджета должен видеть, на что соглашается, до того как сообщение уйдёт в модель. + +## Решение + +### Согласие принадлежит точке входа + +Текст согласия и его версия хранятся в конфигурации веб-виджета. Виджет показывает приветствие и согласие до начала разговора. До принятия: сообщение не передаётся в LLM, диалог с AI не начинается, сохраняется только минимальный технический контекст (показ согласия, сессия, защита от злоупотреблений). Версия меняется вместе с текстом. Для мессенджеров и почты отдельного экрана согласия нет — клиент сам инициирует переписку. + +### Минимизация данных + +Перед отправкой в модель текст проходит redaction; по умолчанию не передаются адреса e-mail, телефоны, длинные числовые идентификаторы (карты, платежи, документы), токены, секреты и внутренние идентификаторы. Runtime формирует отдельное очищенное представление — исходный текст в истории не меняется. Персональные данные попадают в модель только через явно разрешённый инструмент. + +### Удаление и обезличивание + +Владелец может обезличить коммуникационные данные: связь с контактом заменяется техническим идентификатором, история остаётся как факт коммуникации. Сроки хранения задаёт организация. + +Отклонены: передача модели полной карточки клиента; хранение только флага «согласие получено» (не доказать, какой текст был показан). + +## Последствия + +- Объём персональных данных у провайдера минимален; текст согласия версионирован; удаление данных клиента не разрушает историю. +- Нужен отдельный слой redaction и payload для модели. +- Redaction по шаблонам не ловит произвольные упоминания ПДн в свободном тексте — осознанный компромисс. \ No newline at end of file diff --git a/.skaro/adr/0005-struktura-repozitoriya-i-granitsy-razver.md b/.skaro/adr/0005-struktura-repozitoriya-i-granitsy-razver.md new file mode 100644 index 0000000..8b73f77 --- /dev/null +++ b/.skaro/adr/0005-struktura-repozitoriya-i-granitsy-razver.md @@ -0,0 +1,24 @@ +--- +id: "0005" +title: Структура репозитория и границы развёртывания +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Код, дизайн и служебные материалы должны иметь один канонический источник внутри репозитория. Публикуемый open-source репозиторий не должен содержать второго дерева с копиями макетов, второго production-манифеста или приватных материалов владельца. + +## Решение + +- Каноническая структура: `apps/backend` (Django: API, ASGI, домены, воркеры), `apps/internal-ui` (интерфейс организации и публичные порталы, выбор по HTTP Host), `apps/web-chat` (виджет, клиентская страница звонка, лоадер), `packages/ui`, `packages/contracts`, `packages/shared`, `design/baseline`, `deploy`, `scripts`, `tests`, `compose.yaml`, `compose.dev.yaml`, `Caddyfile`. Новые приложения и пакеты вне структуры — только отдельным решением; параллельные каталоги дизайна и дублирование макетов запрещены. +- `apps/backend` — единственный источник доменной логики; фоновые обработчики — процессы из того же кода. +- Публичный адрес — свойство установки, а не константа репозитория; домены в код, конфигурацию и документацию не зашиваются. Публичные пути: `/` (интерфейс), `/api/`, `/ws/`, `/chat/`, `/calls/`, `/chat-widget.js`. +- Один `compose.yaml` для production и коробки; разработка добавляет только `compose.dev.yaml`. Секреты и реквизиты внешних сервисов в репозитории не хранятся. +- Проектная документация владельца приватна и в публикацию не входит; публично поставляется `README.md` репозитория. + +## Последствия + +- Код, дизайн и документация живут в одном контуре; дублирование макетов и манифестов исключено; локальная разработка не зависит от серверного развёртывания. +- Любое новое приложение требует отдельного решения. +- Публичная документация пишется отдельно и не появляется сама из проектных документов. \ No newline at end of file diff --git a/.skaro/adr/0006-kanaly-uvedomleniy-sotrudnikov.md b/.skaro/adr/0006-kanaly-uvedomleniy-sotrudnikov.md new file mode 100644 index 0000000..dd91613 --- /dev/null +++ b/.skaro/adr/0006-kanaly-uvedomleniy-sotrudnikov.md @@ -0,0 +1,27 @@ +--- +id: "0006" +title: Каналы уведомлений сотрудников +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Контакт-центр — рабочая система: если клиент ждёт человека, а сотрудник закрыл вкладку, событие не должно потеряться. Клиентские боты организации не должны использоваться как служебный канал для сотрудников — разные аудитории, токены и права. + +## Решение + +**Способы доставки:** центр уведомлений в интерфейсе; звуковой сигнал в чате; browser notifications; письмо через почту установки; служебный Telegram- или MAX-бот. + +**Разделение ботов.** Служебный бот — отдельная интеграция с назначением «уведомления»: не привязывается к агенту и не принимает клиентские диалоги; токены, аудит и права клиентского и служебного контуров разделены. Сотрудник привязывает себя сам: в профиле получает одноразовый код с коротким TTL и открывает бота по deep-link; привязка хранит внешний ID чата и набор типов уведомлений для мессенджера. + +**Типы событий** (базовые): `DIALOG_WAITING` — диалог ждёт человека; `DIALOG_NEW_MESSAGE` — новое сообщение в диалоге под управлением человека; `INTEGRATION_ERROR` — подключение или провайдер недоступны. Уведомление адресуется всем, владельцу, работающим с диалогами сотрудникам или конкретному пользователю и содержит диплинк на объект. Новый тип — запись в реестре типов, а не новый механизм доставки. + +**Настройки.** Сотрудник настраивает доступные способы и типы в пределах ограничений роли. + +## Последствия + +- Рабочие события не теряются при закрытом браузере; Telegram и MAX равноправны; служебное не смешивается с клиентским; новые способы доставки добавляются без изменения доменных событий. +- Служебный бот требует отдельной регистрации; каждая доставка идемпотентна; недоступность одного способа не блокирует остальные. +- Пока почта установки не настроена, приглашения и сброс пароля уходят в лог. +- Поведение прочтения уведомлений — SPEC-0017. \ No newline at end of file diff --git a/.skaro/adr/0007-tehnologiya-retrieval-znaniy-pgvector-i.md b/.skaro/adr/0007-tehnologiya-retrieval-znaniy-pgvector-i.md new file mode 100644 index 0000000..b9091d9 --- /dev/null +++ b/.skaro/adr/0007-tehnologiya-retrieval-znaniy-pgvector-i.md @@ -0,0 +1,26 @@ +--- +id: "0007" +title: "Технология retrieval знаний: pgvector и FTS PostgreSQL" +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Агенту нужен поиск по прикреплённым материалам, находящий и точные формулировки, и близкие по смыслу. Требования: работать в уже используемой инфраструктуре, не вводить отдельный сервис хранения, поддерживать лексический и семантический поиск и фиксировать, какие фрагменты попали в ответ. + +## Решение + +Retrieval реализуется в PostgreSQL: семантический поиск — `pgvector` (образ `pgvector/pgvector:pg16`, расширение включается миграцией); полнотекстовый — встроенный FTS. + +- Содержимое источника разбивается на фрагменты (`KnowledgeFragment`): текст и эмбеддинг. Размерность колонки не фиксируется: эмбеддинги считает адаптер провайдера (демо — детерминированный малой размерности, иначе провайдер организации). +- Источников фрагмента два — знание библиотеки и статья портала (ADR-0017); «ровно один источник» закреплён CHECK-ограничением. +- Retrieval объединяет лексических и семантических кандидатов среди материалов, доступных агенту, ранжирует их; идентификаторы использованных фрагментов сохраняются в журнале вызова модели. +- На текущем объёме допускается последовательное сканирование без ANN-индекса. При росте объёма добавляется HNSW/IVFFlat с фиксированной размерностью выбранной embedding-модели — отдельной миграцией, без изменения интерфейса retrieval. + +Отклонены: внешнее векторное хранилище (лишний сервис); только FTS (нет семантики); фиксированная размерность сразу (ломает совместимость провайдеров). + +## Последствия + +- Один компонент инфраструктуры для лексики и семантики; интерфейс retrieval скрывает реализацию. +- Без ANN-индекса поиск линейный; ANN потребует фиксации размерности и переиндексации при смене модели. \ No newline at end of file diff --git a/.skaro/adr/0008-integratsii-provaydery-i-podklyucheniya.md b/.skaro/adr/0008-integratsii-provaydery-i-podklyucheniya.md new file mode 100644 index 0000000..2e2d7b1 --- /dev/null +++ b/.skaro/adr/0008-integratsii-provaydery-i-podklyucheniya.md @@ -0,0 +1,36 @@ +--- +id: "0008" +title: "Интеграции: провайдеры и подключения" +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Подключение к внешним системам было неявным: ключ провайдера задавался переменной окружения, транспорт предполагался как «один бот на всё». Владелец должен заводить и проверять подключения в интерфейсе, экземпляров одного типа может быть несколько, секреты должны храниться зашифрованными, а не в окружении. + +## Решение + +**Интеграция — единица внешнего подключения**, заводится и проверяется в «Настройки → Интеграции». Два рода: + +- **LLM-провайдер** — OpenRouter, произвольный OpenAI-совместимый endpoint (ADR-0014), демо-провайдер без ключей; +- **Подключение (транспорт)** — Telegram-бот, MAX-бот, сообщество ВКонтакте, веб-виджет, почтовый ящик (ADR-0015). Экземпляров много; каждое подключение принадлежит **одному** агенту. + +Платформенных credentials и managed-режима нет (ADR-0020). + +**Маршрутизация провайдера** — только через явно выбранную интеграцию агента; «первая попавшаяся интеграция организации» и глобальная подмена выбора владельца запрещены. + +**Каталог моделей** адаптер отдаёт через backend; allowlist не ведётся. Модель выбирается поисковым списком; backend проверяет её наличие в каталоге интеграции; исчезнувшая модель не подменяется автоматически, конфигурация помечается как требующая выбора. Исключение — Custom: каталога нет, модель вводится текстом. Браузер никогда не получает credentials. + +**Хранение и проверка:** секреты шифруются в БД; у интеграции есть статус, время и текст последней ошибки; «Проверить» выполняет реальную проверку (провайдер — health и capability, бот — `getMe`, ВКонтакте — ключ и настройки Long Poll сообщества, почта — IMAP и SMTP); `is_active` отделён от статуса проверки. Ошибка проверки объясняется словами, а не кодом; полный ответ провайдера — только в журнал. Исходящие запросы представляются User-Agent `Chatballs/<версия>`. + +**Приём и отправка:** входящие — идемпотентно через inbox (уникальность по источнику и внешнему id), нормализация в доменное сообщение; исходящие — через outbox с ретраями; поллинг в воркере или вебхук. После сбоя опроса подключение пропускается с растущей паузой, курсор не двигается, в журнал — только первый сбой, выход на максимальную паузу и восстановление. + +**Границы:** интеграция — чистый транспорт или доступ к провайдеру; не хранит знания, инструкции, диалоги и конфигурацию агента. Что разрешено в точке входа (голосовые, звонки) — свойство интеграции. + +Отклонены: ключ провайдера только в окружении; один бот на всё. + +## Последствия + +- Провайдеры и боты управляются и проверяются в интерфейсе; модель выбирается из актуального каталога; несколько ботов одного типа из коробки; секреты зашифрованы; приём и отправка идемпотентны. +- Нужен жизненный цикл и ротация секретов; для вебхуков нужен публичный адрес, по умолчанию — поллинг; ошибки внешних API требуют внимания владельца. \ No newline at end of file diff --git a/.skaro/adr/0009-model-agenta-i-znaniy-bez-versiy-i-reliz.md b/.skaro/adr/0009-model-agenta-i-znaniy-bez-versiy-i-reliz.md new file mode 100644 index 0000000..f49ac3c --- /dev/null +++ b/.skaro/adr/0009-model-agenta-i-znaniy-bez-versiy-i-reliz.md @@ -0,0 +1,26 @@ +--- +id: "0009" +title: Модель агента и знаний без версий и релизов +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Первая итерация строила AI-конфигурацию на трёх уровнях: библиотека версионируемых документов, агент и неизменяемый релиз. Чтобы поправить одну фразу, владелец проходил четыре шага (черновик версии → публикация версии → сборка релиза → публикация релиза), а в интерфейсе жили три источника истины. + +## Решение + +**Знание — плоская сущность с вложениями.** `Knowledge`: заголовок, краткое описание, текст (Markdown), признак «включено»; версий нет. Вложения: оригинальное имя сохраняется и уникально в рамках знания (текст ссылается по имени); из md, txt, pdf, docx извлекается текст для индексации; у вложения публичная ссылка с непредсказуемым UUID — агент передаёт её клиенту в любом подключении. Защита ссылки — только непредсказуемость UUID (осознанный компромисс). Фрагменты и эмбеддинги перестраиваются при каждом изменении содержимого или вложений. + +**Агент — одна сущность без релизов:** модель и параметры генерации; инструкции из трёх частей — Персонализация, Тон общения, Инструкции; прикреплённые знания и статьи портала; дневной лимит стоимости; статус. Системный промпт: Персонализация → Тон → Инструкции → каталог прикреплённых материалов (заголовок, описание, ссылки вложений); содержимое подтягивается retrieval'ом. + +Изменения агента и знаний применяются в runtime **сразу**. + +Отклонены: сохранить релизы, упростив документы (главная сложность — многошаговая публикация); версионируемые вложения (файл заменяется загрузкой с тем же именем). + +## Последствия + +- Правка в один шаг, одна точка настройки, вложения можно передать клиенту. +- Нет атомарной публикации и отката; нет истории версий; расследование ответа опирается на журнал использованных фрагментов. +- Публичные ссылки вложений доступны любому, у кого есть URL. \ No newline at end of file diff --git a/.skaro/adr/0010-p2p-onlayn-zvonki-v-dialogah.md b/.skaro/adr/0010-p2p-onlayn-zvonki-v-dialogah.md new file mode 100644 index 0000000..279a0ff --- /dev/null +++ b/.skaro/adr/0010-p2p-onlayn-zvonki-v-dialogah.md @@ -0,0 +1,29 @@ +--- +id: "0010" +title: P2P-онлайн-звонки в диалогах +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Часть обращений быстрее закрыть голосом. Переход из переписки в звонок не должен создавать внешнюю конференцию, терять связь с диалогом или требовать от клиента установки чего-либо. Интеграции с готовыми платформами видеосвязи не принимаются. Запись звонков сознательно отложена. + +## Решение + +- **Звонок — часть диалога.** `CallSession` связан с существующим диалогом; в ленту попадают только системные события: запрос, принятие, отклонение, начало, завершение, пропуск, ошибка. +- **Участники:** один сотрудник с доступом к диалогу и один клиент; один незавершённый звонок на диалог; аудио и видео, разговор продолжается при выключенной камере. AI не участвует. Групповые звонки, второй сотрудник, screen sharing не входят. +- **Медиамаршрут:** WebRTC напрямую; при невозможности — собственный Coturn как relay, который ничего не записывает. +- **Собственный signaling:** backend создаёт сессию и приглашение, проверяет доступ, ведёт состояния, передаёт SDP и ICE между разрешёнными участниками, таймауты, аудит. SDP, ICE и медиа не сохраняются ни в БД, ни в логах. +- **Доставка приглашения:** в веб-чате — realtime-событием в сессию; в Telegram, MAX и ВКонтакте — кнопкой на защищённую страницу `/calls/<токен>`. Токен высокоэнтропийный, привязан к приглашению, идентичности и организации, ограничен по сроку, не даёт доступа к истории и не работает после завершения. +- **Состояния:** `REQUESTED · RINGING · ACCEPTED · CONNECTING · ACTIVE · DECLINED · CANCELLED · MISSED · ENDED · FAILED · EXPIRED`. +- **Развёртывание:** Coturn — отдельный Docker-сервис, который **поднимается вместе со стеком** (с релиза 1.8.0; ранее включался профилем `calls`): host network, порт 3478 TCP/UDP, relay-диапазон 49160–49999/udp, TURN-over-TLS по умолчанию выключен. Порты публикуются напрямую, HTTP-шлюз TURN не проксирует. Адреса relay и STUN вычисляются от адреса установки, вписанные вручную побеждают. Backend выдаёт только краткоживущие credentials. +- **Запись отложена:** ни аудио, ни видео не записываются. Будущая запись — только аудио, отдельным сервисом, отдельным решением о хранении, доступе и сроках. + +Отклонены: готовый Video SDK (LiveKit, Jitsi, Zoom); серверный медиасервер/SFU; запись через браузер сотрудника; TURN как механизм записи. + +## Последствия + +- Звонок остаётся частью диалога; нет зависимости от внешней платформы; при прямом соединении медиа не проходит через backend. +- Качество зависит от сети и браузеров; при relay сервер несёт медиатрафик; на файрволе нужно открыть `3478/udp`, `3478/tcp` и `49160–49999/udp`. +- Нужно отдельное тестирование мобильных браузеров, NAT и восстановления соединения. \ No newline at end of file diff --git a/.skaro/adr/0011-edinyy-kontur-razvertyvaniya-i-ustanovka.md b/.skaro/adr/0011-edinyy-kontur-razvertyvaniya-i-ustanovka.md new file mode 100644 index 0000000..5d2fc28 --- /dev/null +++ b/.skaro/adr/0011-edinyy-kontur-razvertyvaniya-i-ustanovka.md @@ -0,0 +1,31 @@ +--- +id: "0011" +title: Единый контур развёртывания и установка без конфигурации +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Пока локальная разработка, production владельца и установка у пользователя описывались разными манифестами и командами, они расходились: порядок миграций, health checks, шлюз и bootstrap жили своей жизнью. Для open-source поставки пользователь должен получить ровно тот путь установки, который владелец ежедневно проверяет. + +## Решение + +- **Один production-контур** для первичной установки, обновления, production владельца, staging и восстановления. +- **Один канонический `compose.yaml`**: топология, зависимости, health checks, тома, образы. `compose.dev.yaml` может подменять образ сборкой, команды — dev-серверами и монтировать исходники, но не описывает вторую топологию. +- **Установка без конфигурации.** Файла `.env` нет. Секреты инстанса (ключ подписи, ключ шифрования полей, пароли ролей БД, секрет TURN) генерирует одноразовый сервис `secrets` в тома `chatballs-secrets`, `chatballs-secrets-platform`, `chatballs-secrets-schema` (ADR-0026); скрипт идемпотентен и не меняет пароли работающей базы. Адрес, почта, хранилище, TURN, интеграции и AI-провайдер — в интерфейсе. Переменная окружения выше файла секрета — для оркестраторов. +- **`release.env`** — только версия и неизменяемые ссылки на образы по digest; без него стек собирается из исходников. `latest` источником версии не является. +- **One-shot `init`** — только миграции; не создаёт организацию, владельца, демо-данные; безопасен при повторе. +- **Мастер первого запуска** вместо bootstrap-переменных: пока организаций нет, установка показывает мастер; после создания владельца он закрывается навсегда. Адрес, на котором прошли мастер (включая нестандартный порт), запоминается как публичный адрес установки. +- **Deployment CLI** `./chatballs` без Python и Node на хосте: реализованы `doctor`, `deploy`, `status`, `logs`; `install`, `update`, `backup`, `restore`, `rollback` — следующий этап. CI вызывает тот же entrypoint. +- **Шлюз внутри Docker (Caddy)**: без зашитых хостов; http отвечает на любом адресе и порту; сертификат on-demand для хостов, одобренных backend; принудительного редиректа на https нет. За прокси панели шлюз принимает `X-Forwarded-Proto`. +- **Runtime-конфигурация frontend**: домен не компилируется в образ, используются same-origin относительные URL. +- **Разделение bootstrap-данных**: миграции и справочники — при каждом deploy; организация и владелец — один раз через мастер; демо-данные — опционально, со своим жизненным циклом, не профиль Compose и не переменная. + +Отклонены: отдельный box-installer поверх CI-деплоя (два источника истины); конфигурация экземпляра в `.env`; демо-данные как профиль или переменная. + +## Последствия + +- Установка пользователя и production владельца идут одним кодовым путём; образ frontend не зависит от домена; не нужны host nginx и переменные. +- Caddy — часть поддерживаемой инфраструктуры; откат ограничен совместимостью миграций. +- Часть команд CLI не реализована — их роль выполняют ручные операции и `deploy`. \ No newline at end of file diff --git a/.skaro/adr/0012-multitenantnost-membership-i-izolyatsiya.md b/.skaro/adr/0012-multitenantnost-membership-i-izolyatsiya.md new file mode 100644 index 0000000..8b4f6a3 --- /dev/null +++ b/.skaro/adr/0012-multitenantnost-membership-i-izolyatsiya.md @@ -0,0 +1,29 @@ +--- +id: "0012" +title: Мультитенантность, membership и изоляция данных +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Одна установка должна обслуживать несколько изолированных организаций. Прежняя модель связывала пользователя ровно с одной организацией и хранила в одной записи роль, должность, блокировку и MFA; блокировка деактивировала глобального пользователя. Один пропущенный фильтр по `organization_id` открывает чужие данные — нужна защита в несколько слоёв. + +## Решение + +1. **Organization — граница владения**, авторизации, аудита и удаления. Неизменяемый `public_id` (UUID) для API и маршрутов; внутренний ключ — для связей и RLS. Каждая сущность — platform-owned, tenant-owned или публичные данные точки входа; неопределённая принадлежность запрещена. +2. **Identity и membership.** `HumanUser` — глобальная учётная запись; `OrganizationMembership` — роль, должность, статус, блокировка; `unique(user, organization)`. Блокировка membership закрывает доступ только к своей организации. Владелец и администратор не меняют чужой глобальный пароль, TOTP и сессии. В организации ровно один активный владелец; передача владения — атомарная аудируемая операция. TOTP принадлежит пользователю; требование MFA — политика организации. Приглашение — отдельная сущность (ADR-0025). +3. **Tenant context** передаётся явно: `/api/v1/organizations/{organization_public_id}/...`. Порядок: аутентификация → разрешение организации → транзакция и контекст → активная membership → роль и видимость → данные. Последняя выбранная организация — предпочтение интерфейса, не авторизация (разные организации в разных вкладках). +4. **Изоляция в приложении:** каждый endpoint, selector, сервис и репозиторий принимает проверенный контекст; объект загружается вместе с организацией; массовые операции, management-команды и импорт соблюдают те же правила. +5. **Row-Level Security:** `ENABLE`/`FORCE`, `USING` и `WITH CHECK`, runtime-роль не superuser, без `BYPASSRLS`, не владеет таблицами; роль миграций отдельна. Контекст — `SET LOCAL`; session-level `SET` запрещён. Отсутствующий контекст строк не открывает. Platform-операции — своя ограниченная граница и аудит. +6. **Ключи и связи:** корневые tenant-сущности и таблицы с независимым доступом, воркерами, своей RLS-политикой или экспортом имеют прямой `organization_id`. Cross-organization связи запрещены на всех слоях, включая constraint triggers. +7. **Точки входа:** HTTP — организация в маршруте, единообразный отказ; WebSocket — проверка membership и принадлежности до подписки; фоновые задачи несут организацию и актора, воркер перепроверяет; публичные точки входа выводят организацию server-side; ключи хранилища начинаются с идентификатора организации. +8. **Platform administration:** администратор установки — глобальный признак (`HumanUser.is_instance_admin`, ADR-0024), не membership во всех организациях; Django superuser — только break-glass. Доступ к данным организации — через явную support-сессию с организацией, причиной, сроком, полномочиями и аудитом. +9. **Аудит субъекта** различает глобального пользователя, membership, platform-оператора, AI-агента и машинный credential; попытки cross-tenant доступа сохраняются отдельно. + +Отклонены: одна организация на пользователя; активная организация только в сессии; только фильтры в приложении; только RLS; схема или база на организацию; отключение RLS в одиночной установке; platform-администратор как владелец каждой организации. + +## Последствия + +- Один пользователь безопасно работает в нескольких организациях; ошибка в коде перестаёт быть единственным барьером. +- RLS требует транзакционного контекста во всех контурах; аналитике по установке нужна отдельная граница; пропущенный tenant-ключ обнаруживается только тестом изоляции. \ No newline at end of file diff --git a/.skaro/adr/0013-poverhnosti-prilozheniya-adresa-i-admini.md b/.skaro/adr/0013-poverhnosti-prilozheniya-adresa-i-admini.md new file mode 100644 index 0000000..0b6da02 --- /dev/null +++ b/.skaro/adr/0013-poverhnosti-prilozheniya-adresa-i-admini.md @@ -0,0 +1,26 @@ +--- +id: "0013" +title: Поверхности приложения, адреса и административный доступ +status: accepted +date: 2026-09-28 +--- + +## Контекст + +В одной установке живут рабочее приложение организаций, control plane установки и публичные порталы. Объединение их на одном host и в одной сборке повышает риск раскрытия платформенных функций и смешения cookie, маршрутов и авторизации. Django admin — break-glass инструмент и не должен публиковаться в интернете. + +## Решение + +1. **Адрес — свойство установки.** Зашитых доменов нет; свежая установка открывается по IP или выбранному домену; адрес запоминается на хосте, где прошли мастер. Порталы — на собственных хостах (ADR-0016). Платформенная поверхность включается отдельным доменом; пока он не задан, её матчер ни с чем не совпадает. +2. **Разделение поверхностей:** приложение организации (рабочее пространство, tenant API, публичные точки входа); platform control plane (отдельный домен); Django admin (только loopback). Публичная поверхность определяется по HTTP Host **до** разбора маршрута; неизвестный путь публичного хоста — публичный `404`, а не форма входа. +3. **Раздельные точки входа backend:** `backend-app`, `backend-platform`, `backend-admin` — один образ, свои URLConf и минимальные наборы маршрутов. Учётные данные БД разделены по процессам (ADR-0026). +4. **Django admin только локально** — loopback, доступ через SSH-туннель; публичный reverse proxy для admin запрещён без нового решения. Ежедневное управление — через интерфейс. +5. **Сессии и cookie:** раздельные host-only сессии приложения и платформы; общая cookie на домен запрещена; без `Domain`, `HttpOnly`, подходящий `SameSite`. По https — префикс `__Host-`, `Secure` и HSTS; по http — обычные имена. +6. **Границы API:** tenant API — UUID организации, membership, роль, видимость, RLS; платформенное API — отдельное пространство имён и аудит, к данным организации — только через support-сессию. Настройки установки — `/api/v1/instance/` (ADR-0024). Видимость маршрута во frontend авторизацией не является. + +Отклонены: одна общая поверхность; admin по публичному адресу; защита admin только правилом шлюза; общая cookie на домен; отдельный домен для API; домены, зашитые в образ. + +## Последствия + +- Управление установкой отделено от рабочего приложения; admin вне поверхности атаки; установка переезжает с IP на домен без пересборки. +- Три процесса backend; раздельные cookie, hosts и CSRF origins. \ No newline at end of file diff --git a/.skaro/adr/0014-proizvolnyy-openai-sovmestimyy-provayder.md b/.skaro/adr/0014-proizvolnyy-openai-sovmestimyy-provayder.md new file mode 100644 index 0000000..2bbb22c --- /dev/null +++ b/.skaro/adr/0014-proizvolnyy-openai-sovmestimyy-provayder.md @@ -0,0 +1,27 @@ +--- +id: "0014" +title: Произвольный OpenAI-совместимый провайдер +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Правило «адаптер под каждого провайдера и запрет свободного ввода модели» (ADR-0008) означало закрытый список провайдеров: свой inference-endpoint или менее известного провайдера нельзя было подключить без правки кода. Для self-hosted продукта выбор провайдера — дело пользователя. + +## Решение + +Организация подключает произвольный OpenAI-совместимый endpoint тремя значениями: **endpoint** (базовый URL), **API key** (шифруется в контуре организации), **модель** (свободный текст). + +Исключения из общих правил интеграций для этого режима: модель вводится текстом; любой OpenAI-совместимый endpoint принимается без адаптера; каталог не требуется. Остальное сохраняется: маршрутизация через интеграцию агента, запрет подмены выбора, шифрование, статус проверки, границы организации, серверные credentials. + +Контракт: OpenAI Chat Completions — `POST /chat/completions`, `Authorization: Bearer `, ответ с `choices[0].message.content` и `usage`; переиспользуется HTTP-слой OpenRouter. Endpoint другой формы требует отдельного адаптера. + +Поле модели читается в runtime; валидации против провайдера нет — ответственность на владельце. + +Отклонены: OpenRouter, поглощающий generic-режим (теряется каталог); сохранить запрет свободного ввода; требовать адаптер для любого провайдера. + +## Последствия + +- Любой OpenAI-совместимый провайдер без правки кода; OpenRouter остаётся готовой интеграцией с каталогом. +- Модель не проверяется до первого вызова; ошибка идентификатора проявляется как ошибка провайдера в статусе интеграции. \ No newline at end of file diff --git a/.skaro/adr/0015-email-podklyuchenie-imap-smtp.md b/.skaro/adr/0015-email-podklyuchenie-imap-smtp.md new file mode 100644 index 0000000..6d35af3 --- /dev/null +++ b/.skaro/adr/0015-email-podklyuchenie-imap-smtp.md @@ -0,0 +1,26 @@ +--- +id: "0015" +title: Email-подключение (IMAP/SMTP) +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Часть клиентов пишет на почту поддержки, и эта переписка жила вне системы: без агента, передачи человеку и истории по контакту. Почта самой установки (приглашения, сброс пароля) — транзакционная и диалоговым транспортом не является. + +## Решение + +- Почтовый ящик — подключение рода «мессенджер» наравне с ботами: ящиков несколько, каждый привязан к одному агенту, заводится и проверяется в «Интеграциях». +- Секрет — пароль ящика (обычно пароль приложения), один для IMAP и SMTP. Несекретно: адрес (он же логин), хосты, порты, флаги SSL. OAuth2 в первой итерации не поддерживается. +- **Приём:** поллинг IMAP в воркере; курсор `uidvalidity:last_uid`, смена `UIDVALIDITY` сбрасывает курсор; при первом подключении курсор ставится на текущий максимум — история ящика не импортируется; идемпотентность по `Message-ID`; идентичность — адрес отправителя в нижнем регистре; `text/plain` всегда, для HTML — дополнительно санитизированный HTML со структурным allowlist; собственные письма ящика пропускаются; вложения в первой итерации не принимаются, в текст добавляется пометка. +- **Отправка:** через outbox по SMTP, `Re: <тема>`, `In-Reply-To` и `References` на последнее входящее; диалог хранит транспортную мету. Исходящие диалоги, начатые сотрудником, не создаются. +- **Проверка** проходит только при успехе обеих сторон: IMAP (соединение, вход, папка) и SMTP (соединение, `EHLO`, вход). +- **Границы:** не заменяет почту установки, не используется для писем аутентификации, не канал уведомлений сотрудников, не для рассылок. + +Отклонены: OAuth2 в первой итерации; IMAP IDLE вместо поллинга; отдельный род интеграции «почта». + +## Последствия + +- Почта становится полноценными диалогами; переиспользуются поллер, inbox/outbox, шифрование и проверка. +- Задержка в пределах интервала воркера; вложения входящих теряются (с пометкой); ящик, который параллельно читают люди, может давать двойные ответы — рекомендуется выделенный ящик. \ No newline at end of file diff --git a/.skaro/adr/0016-publichnye-portaly-podderzhki.md b/.skaro/adr/0016-publichnye-portaly-podderzhki.md new file mode 100644 index 0000000..feb591f --- /dev/null +++ b/.skaro/adr/0016-publichnye-portaly-podderzhki.md @@ -0,0 +1,26 @@ +--- +id: "0016" +title: Публичные порталы поддержки +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Организации нужен публичный Help Center: база статей, которую читает клиент, с возможностью тут же написать в поддержку. Порталов может понадобиться несколько — для разных брендов или направлений. + +## Решение + +- **Портал — объект организации.** Список, создание, настройка, контент, публикация и архив — в одном разделе; отдельных прав портала нет, действуют права роли. +- **Адрес — отдельный публичный хост**: `https://.<базовый help-домен>/` и `/articles/`. Можно подключить свой домен: он нормализуется и проверяется (не совпадает с доменом приложения и адресом портала); подтверждения владения нет, только техническая проверка, что домен ведёт на сервер. До активации работает адрес по умолчанию. Поверхность определяется по Host до разбора маршрута; неизвестный путь — публичный `404`. Шлюз выпускает сертификаты для доменов порталов по одобрению backend. +- **Без ограничений:** лимитов и тарифных условий нет; черновик не блокирует создание следующего портала. +- **Контент:** категории, статьи и неизменяемые редакции; публикация выбирает одну редакцию. Портал и библиотека знаний не объединяются; агент может ссылаться на статью (ADR-0017). +- **Общий визуальный язык:** управление материалами портала — доменная адаптация библиотеки знаний, а не второй интерфейс. Компоненты дерева, таблицы, панели действий, категорий, редактора и подтверждений лежат в общем модуле и импортируются обеими функциями; копирование разметки под другим префиксом переиспользованием не считается. +- **Веб-чат портала** — конкретный опубликованный виджет (ADR-0018), разрешающий origin портала и ведущий в активного агента. Только штатный лоадер; свой launcher, iframe или вторая реализация чата запрещены. + +## Последствия + +- Портал не появляется в глобальной навигации установки; у каждого — свой адрес и тема (ADR-0022). +- Публичный Help Center не загружает внутренний интерфейс как fallback. +- Архивный портал — только чтение и восстановление. +- Изменение адресного контракта требует отдельного решения. \ No newline at end of file diff --git a/.skaro/adr/0017-stati-portala-kak-istochnik-znaniy-agent.md b/.skaro/adr/0017-stati-portala-kak-istochnik-znaniy-agent.md new file mode 100644 index 0000000..65a30f4 --- /dev/null +++ b/.skaro/adr/0017-stati-portala-kak-istochnik-znaniy-agent.md @@ -0,0 +1,25 @@ +--- +id: "0017" +title: Статьи портала как источник знаний агента +status: accepted +date: 2026-09-28 +--- + +## Контекст + +У агента поддержки и публичного Help Center один предмет. Раньше статью писали в портал, а для агента повторяли знанием — копия расходится с оригиналом при первой правке. Фрагмент индекса обязательно принадлежал знанию, и retrieval физически не видел статью. + +## Решение + +1. **Агент ссылается на статью, а не на копию.** Вводится связь агента со статьями; зеркалирование отвергнуто (второй источник правды, синхронизация, нередактируемые записи). Правка статьи сразу меняет ответы агента. +2. **Один индекс фрагментов на два источника**: знание либо статья, «ровно один источник» — CHECK-ограничение. Retrieval и учёт фрагментов — один запрос и одно пространство идентификаторов. +3. **Индексируется только опубликованная редакция**: фрагменты строятся при публикации и снимаются при архивации; черновик не индексируется никогда. Связь агента со статьёй при архивации сохраняется. +4. **Доступность — в границах организации**; в runtime проверяется, что статья опубликована, а портал не архивирован. Массовая операция над смешанной выборкой не падает: недоступные элементы пропускаются и возвращаются списком. +5. **Состав знаний агента меняется только из библиотек** — массовыми действиями в разделах знаний и порталов. Карточка агента показывает нередактируемый список. Мастер создания агента знания не выбирает. + +## Последствия + +- Нет двойного ввода; статья — единственный источник правды; агент может дать клиенту ссылку на статью Help Center. +- Код, читающий источник фрагмента, обязан учитывать оба варианта. +- Архивация и снятие публикации немедленно убирают статью из ответов. +- Карточка агента перестаёт быть местом изменения состава знаний. \ No newline at end of file diff --git a/.skaro/adr/0018-veb-vidzhet-kak-samostoyatelnaya-tochka.md b/.skaro/adr/0018-veb-vidzhet-kak-samostoyatelnaya-tochka.md new file mode 100644 index 0000000..2fa3f8a --- /dev/null +++ b/.skaro/adr/0018-veb-vidzhet-kak-samostoyatelnaya-tochka.md @@ -0,0 +1,28 @@ +--- +id: "0018" +title: Веб-виджет как самостоятельная точка входа +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Веб-чат используется в нескольких независимых местах: на портале, на сайте организации, на разных доменах и страницах. Раньше визуальный экземпляр виджета, веб-подключение и агент отождествлялись, а лоадер адресовал чат кодом агента. Нельзя было выбрать один из нескольких виджетов агента, держать разные списки доменов и оформление, достоверно записать источник обращения. + +## Решение + +1. **`WebChatWidget` — самостоятельный объект**: `integration` (1:1 с WEB-подключением), `code` (неизменяем в организации), `public_key` (неизменяем, глобально уникален, не секрет), `name`, `status` (`DRAFT | PUBLISHED | DISABLED`), `allowed_origins`, `presentation_config`, `consent_config`, `anti_abuse_config`. `public_key` можно передавать в HTML; он не credential и не заменяет проверку origin и сессионный credential. +2. **Разные ответственности:** агент — обработка (инструкции, знания, модель, группа); виджет — только вход (место установки, адресация, origins, оформление, согласие, anti-abuse). `Агент 1 — N WEB-подключение 1 — 1 WebChatWidget`. Выбор агента внутри виджета не поддерживается. +3. **Вход только анонимный**; «режима» у виджета нет (ADR-0023). Агент виджета должен разрешать анонимные сессии. +4. **Публичная маршрутизация — по ключу виджета**; клиент не выбирает и не подменяет агента. +5. **Анонимная сессия привязана к виджету**: credential одного виджета не принимается другим; очистка хранилища или другое устройство — новая анонимная идентичность без автообъединения. +6. **Источник обращения сохраняется**: диалог фиксирует виджет, из которого начался. +7. **Конфигурация и origin принадлежат виджету**; origin — обязательная дополнительная проверка, а не единственная. + +Отклонены: один код агента = один виджет; несколько веб-подключений с поиском по коду агента; один виджет с выбором агента. + +## Последствия + +- Несколько виджетов одного агента — штатный сценарий; портал и сайт используют один контракт входа. +- Виджет с историей не удаляется, а отключается; преемственность анонимной истории между виджетами намеренно отсутствует. +- Одновременный показ нескольких launcher'ов на странице требует отдельного дизайн-решения. \ No newline at end of file diff --git a/.skaro/adr/0019-kontakt-tsentr-udalenie-prodazh-i-otdelo.md b/.skaro/adr/0019-kontakt-tsentr-udalenie-prodazh-i-otdelo.md new file mode 100644 index 0000000..be66c3a --- /dev/null +++ b/.skaro/adr/0019-kontakt-tsentr-udalenie-prodazh-i-otdelo.md @@ -0,0 +1,34 @@ +--- +id: "0019" +title: "Контакт-центр: удаление продаж и отделов, объединение агента и канала" +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Chatballs развивался как операционная CRM: отделы продаж и поддержки, учёт внешних продаж через Product Sales API, атрибуция, коммерческий каталог, метрики выручки, capability/scope-авторизация с отделами и department-видимостью знаний. + +Практика показала два расхождения. **Продажи не являются ценностью продукта**: организации нужен контакт-центр, а не учёт чужих коммерческих фактов. **Тройка «подключение → канал → агент» и отделы непосильны для администратора**: новая организация должна освоиться за минуты, а не изучать внутреннюю архитектуру. + +## Решение + +Это основание текущей продуктовой модели. + +1. **Chatballs — платформа клиентских коммуникаций (контакт-центр).** Продажи, сделки, воронки, заказы, счета, атрибуция и выручка не входят ни в каком виде. +2. **Домен продаж удалён полностью** без сохранения данных: приложения `sales` и `orders`, коммерческий слой каталога (`Offer`, `Price`, `MarketplacePublication`), метрики выручки и конверсии, налоговый профиль организации, разделы «Продажи» и «Продукты», capabilities `sales.*` и `products.*`. +3. **Отделы (`Department`) упразднены.** Структура групп впоследствии заменена настраиваемыми группами (ADR-0021). +4. **Агент и канал — одна сущность для пользователя.** Агент содержит имя, инструкции, модель и провайдера, знания, подключения и признак активности. Подключение — транспорт (ADR-0008), привязано к агенту и управляется только из карточки агента; разделов «Каналы» и «Подключения» в интерфейсе нет. В схеме БД сохраняется пара `Channel` — `AIAgent` 1:1. +5. **Модель доступа** — роли вместо capability registry, access profiles и department scopes; deny-by-default в backend сохраняется. Действующие правила ролей — SPEC-0004. +6. **Один рабочий экран сотрудника — чат**; командный центр удалён (сводка может вернуться отдельным решением). +7. **`Product`** был скрыт из интерфейса; впоследствии удалён полностью (ADR-0023). +8. **Знания — общая библиотека организации**, области видимости упразднены; агент использует только явно выбранные и включённые знания. +9. **P2P-звонки сохраняются** (ADR-0010). +10. **Онбординг за минуты**: создать агента → подключить точку входа → пригласить сотрудников → работать в чате. Любое решение, удлиняющее путь обязательными шагами, требует обоснования. + +Тарифная привязка «один агент = одно оплаченное место» и сохранение коммерческого контура отменены (ADR-0020); фиксированные группы «Операторы»/«Поддержка», обязательная группа агента и её правила видимости заменены (ADR-0021). + +## Последствия + +- Продукт проще: меньше сущностей, разделов и понятий. +- Демо-датасет и seed очищены от коммерческих данных. \ No newline at end of file diff --git a/.skaro/adr/0020-open-source-rasprostranenie-i-udalenie-t.md b/.skaro/adr/0020-open-source-rasprostranenie-i-udalenie-t.md new file mode 100644 index 0000000..fd4f360 --- /dev/null +++ b/.skaro/adr/0020-open-source-rasprostranenie-i-udalenie-t.md @@ -0,0 +1,25 @@ +--- +id: "0020" +title: Open-source распространение и удаление тарифного контура +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Переход в контакт-центр (ADR-0019) сохранил коммерческий контур платформы: тарифы, подписки, entitlements, quotas, managed AI-кредиты и оплату через Точку. Владелец принял стратегию: продукт публикуется как open source для набора аудитории и известности, монетизация — отдельный облачный сервис voice-to-voice звонков. Тарифная модель в этой стратегии не нужна и удорожает продукт и порог входа. + +## Решение + +1. **Open source — основной способ распространения** (self-hosted, docker compose). Мультитенантность сохраняется. Managed-облако как сервис не входит. +2. **Тарифный контур удалён полностью**: планы, подписки, entitlements, quotas, usage-биллинг, managed AI-кредиты, интеграция с Точкой, все тарифные ограничения (лимиты диалогов, подключений, «места» агентов). Остаются только rate limits, защита от злоупотреблений и технический учёт AI-потребления для статистики. Количество агентов не ограничено. +3. **AI — только через провайдера организации** (ADR-0008, ADR-0014); managed AI и платформенные credentials удалены. +4. **Монетизация — облачный сервис voice-to-voice звонков с ИИ**: закрытый, со своим названием и поминутными тарифами, открыт для любых клиентов. Телефония, медиа, голосовые модели, тарифы и учёт минут живут только в нём. Chatballs подключает его как интеграцию по API-ключу; в открытом репозитории — только клиентская часть (ключ, привязка номеров к агентам, приём событий звонка: контекст агента, инструменты, транскрипты, передача оператору). Без оплаченного ключа интеграция ничего не делает. В открытой части остаются P2P-звонки и голосовые сообщения. +5. **Имя продукта — Chatballs**; прежние имена не используются ни в интерфейсе, ни в текстах, ни в идентификаторах. Зашитых доменов нет. +6. **Проектная документация — приватный контур** владельца, исключена из репозитория; в open-source входит отдельная публичная документация. + +## Последствия + +- Снесены `subscriptions`, managed AI и связанные platform-разделы; tenant provisioning создаёт организацию без подписки и usage period. +- Демо-датасет и seed очищены от тарифных данных. +- Интеграция с облачным сервисом voice-to-voice пока не реализована (T-006). \ No newline at end of file diff --git a/.skaro/adr/0021-nastraivaemye-gruppy-organizatsii-i-mars.md b/.skaro/adr/0021-nastraivaemye-gruppy-organizatsii-i-mars.md new file mode 100644 index 0000000..1e78226 --- /dev/null +++ b/.skaro/adr/0021-nastraivaemye-gruppy-organizatsii-i-mars.md @@ -0,0 +1,24 @@ +--- +id: "0021" +title: Настраиваемые группы организации и маршрутизация диалогов +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Отделы были заменены двумя фиксированными группами «Операторы» и «Поддержка», что предполагало универсальность деления на «новых» и «существующих» клиентов. Решение владельца: у каждой организации свои отделы, платформа не навязывает структуру. При этом группы не должны быть обязательным шагом запуска. + +## Решение + +1. **Группы задаются организацией**: tenant-сущность `EmployeeGroup` (имя, цвет, состав). Создаёт, переименовывает и удаляет администратор или владелец; количество не ограничено; групп может не быть. Группа — только граница видимости диалогов: без прав, знаний, навигации, должностей и иерархии. Сотрудник может состоять в нескольких группах. +2. **Группа агента необязательна**: агент с группой — его новые диалоги попадают в неё; без группы — диалоги видны всем. +3. **Диалог маршрутизируется независимо**: наследует группу агента, но его можно перенести в другую группу или убрать из группы; у диалога есть необязательный ответственный. Перенос и смена ответственного доступны администратору, владельцу и сотрудникам, видящим диалог. +4. **Видимость:** владелец и администратор — все диалоги; сотрудник — диалоги своих групп + без группы + где он ответственный (независимо от группы). +5. **Онбординг без групп** — полноценный сценарий: все видят всё; группы включаются, когда организация дорастает. + +## Последствия + +- Модель: `EmployeeGroup` (organization, name), M2M «сотрудник—группа», nullable группа агента, nullable `Conversation.group` (наследуется, переопределяется), nullable `Conversation.assignee`. +- Проверка видимости — deny-by-default во всех HTTP, WebSocket и фоновых операциях. +- UI: «Группы» в настройках; выбор группы в карточке агента; перенос и ответственный в диалоге; фильтр списка диалогов. \ No newline at end of file diff --git a/.skaro/adr/0022-vizualnye-temy-portalov-podderzhki.md b/.skaro/adr/0022-vizualnye-temy-portalov-podderzhki.md new file mode 100644 index 0000000..c8ea516 --- /dev/null +++ b/.skaro/adr/0022-vizualnye-temy-portalov-podderzhki.md @@ -0,0 +1,26 @@ +--- +id: "0022" +title: Визуальные темы порталов поддержки +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Публичный Help Center собран в `apps/internal-ui` и рисовался токенами дизайн-системы рабочего пространства (`--n-*`, `--surface-*`). Организации нужен другой облик публичной страницы, разработчикам — возможность добавлять оформления, не трогая бэкенд и чужие файлы. Требования: добавление темы без изменений БД, API и реестров; тема не может сломать вёрстку; отсутствие темы в сборке не ломает сохранённые порталы. + +## Решение + +1. **Тема — папка** `apps/internal-ui/src/features/help-center/themes//` с `manifest.ts` (имя, описание, схемы, цвета превью) и `theme.css`. Реестр — автообнаружением (`import.meta.glob`); id совпадает с именем папки (проверяет тест). +2. **Контракт токенов** `themes/contract.css` объявляет все `--help-*` со значениями по умолчанию. Вёрстка Help Center читает **только** их; `--n-*`, `--surface-*` и литеральные цвета в help-CSS запрещены. Свобода темы: переопределение токенов; собственные правила под `html[data-portal-theme=""]`; подмена компонентов и JS не поддерживаются. Тест каталога проверяет скоуп, префикс `@keyframes` и то, что объявлены только токены контракта. +3. **Бэкенд не знает каталог тем**: `SupportPortal.theme` (идентификатор), `theme_scheme` (`LIGHT | DARK | SYSTEM`), `theme_settings` (JSON, зарезервирован). Проверяется только формат id. Неизвестная тема деградирует до `classic` на публичной странице и показывается как недоступная в настройках — без молчаливой подмены выбора. +4. **Цветовая схема** — атрибут `data-theme` на ``, тот же механизм, что у интерфейса; `SYSTEM` следует `prefers-color-scheme`; неподдерживаемая темой схема не предлагается, сохранённая деградирует до основной. +5. **Загрузка:** манифесты статичны, CSS темы ленив; до применения темы портал держит boot-загрузчик — без вспышки оформления. + +Тема по умолчанию `classic` повторяет базовое оформление и ничего не переопределяет. + +## Последствия + +- Публичный манифест `/api/v1/help/` отдаёт `theme`, `themeScheme`, `themeSettings`. +- Вёрстка Help Center переведена на `--help-*` без визуальных изменений. +- Сами темы не разрабатываются этим решением; визуальные решения за владельцем. Инструкция разработчику — документ «Разработка визуальной темы портала» («portal-theme-development.md»). \ No newline at end of file diff --git a/.skaro/adr/0023-udalenie-suschnosti-product-i-avtorizova.md b/.skaro/adr/0023-udalenie-suschnosti-product-i-avtorizova.md new file mode 100644 index 0000000..625de6e --- /dev/null +++ b/.skaro/adr/0023-udalenie-suschnosti-product-i-avtorizova.md @@ -0,0 +1,24 @@ +--- +id: "0023" +title: Удаление сущности Product и авторизованного in-product чата +status: accepted +date: 2026-09-28 +--- + +## Контекст + +После перехода в контакт-центр `Product` остался в схеме как скрытая техническая привязка, но скрытым не стал: в настройках портала жил раздел «Продукты и поддержка», в списке порталов — колонка «Продукты». На `Product` держался целый контур: контракты идентификации клиента поддержки, снимки личности, Product Support Token, режим виджета `AUTHENTICATED_PRODUCT`, признак канала `requires_authenticated_product_identity`, XOR-инвариант identity диалога, `mode=support` лоадера и отдельное приложение виджета. Контакт-центр не знает о продуктах клиента — у контура нет владельца в продуктовой модели. + +## Решение + +1. **`Product` удалён полностью** (приложение `products`, таблица `identity_product`); ссылки сняты с канала, учёта AI и портала. +2. **Авторизованный in-product чат удалён**: приложение `support`, режим `support` веб-чата, `ChatballsChat.init` с token provider, поле `WebChatWidget.mode` и ключ `mode` в ответах API. Внешний продукт приводит клиента в поддержку обычным анонимным виджетом. +3. **У диалога один источник identity — контакт**; диалоги из снимков личности получили контакты. +4. **Политика канала** — только `allow_anonymous_sessions` и `allow_self_reported_contact`; коммерческие флаги и инварианты политики удалены (`channels/0008`); удалены остатки налогового профиля организации (`identity/0032`). +5. **Портал не знает о продуктах**: раздел, колонка, связь и endpoint продуктов удалены; выбор виджета — `GET /portals/{id}/widgets/`, только анонимные виджеты. +6. **Данные не экспортируются** — решение владельца; таблицы сняты миграциями, следы удалённых приложений вычищены из миграций уцелевших. + +## Последствия + +- У виджета нет понятия «режим»; возврат авторизованного входа потребует нового проектирования без сущности «продукт». +- Дизайн-baseline «Порталы» приведён в соответствие. \ No newline at end of file diff --git a/.skaro/adr/0024-administrator-ustanovki-i-nastroyki-inst.md b/.skaro/adr/0024-administrator-ustanovki-i-nastroyki-inst.md new file mode 100644 index 0000000..ff39e22 --- /dev/null +++ b/.skaro/adr/0024-administrator-ustanovki-i-nastroyki-inst.md @@ -0,0 +1,26 @@ +--- +id: "0024" +title: Администратор установки и настройки инсталляции +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Настройки установки — публичный адрес, почта, TURN, хранилище, язык по умолчанию — общие для всех организаций, но жили под путём организации и редактировались любым с `company.manage` хотя бы в одной организации: владелец одной организации переключал SMTP, домен и бакет всем остальным. Роль platform administrator была заявлена, но не существовала; мастер выдавал владельцу `is_superuser` — break-glass доступ к Django admin, а не продуктовую роль. + +## Решение + +1. **`HumanUser.is_instance_admin`** — глобальный булев признак; не выводится из членств и не даёт прав внутри организаций. Первым получает владелец из мастера; существующим установкам выдан миграцией по `is_superuser`. Передача — вне интерфейса: `set_instance_admin --email … --grant|--revoke` и Django admin; последнего активного администратора отозвать нельзя. Экрана управления администраторами нет (нет в baseline). +2. **Настройки установки — путь без организации** `/api/v1/instance/`: `settings/`, `settings/email-check/`, `storage/`, `storage/check/`, `storage/migrate/`. Без tenant middleware. Чтение — менеджеру любой организации (для карточки TURN), изменение — только администратору установки. Прежние пути отвечают 404. +3. **Аудит и события уровня установки** — с `organization_id IS NULL`, как platform-события. +4. **Интерфейс:** «Платформа» и «Хранилище файлов» в «Настройках» видны только администратору установки; сессия отдаёт `isInstanceAdmin`; остальным менеджерам relay только на чтение. +5. **Создание организаций из интерфейса** (с 1.6.0): кнопка «Добавить организацию» в переключателе видна администратору установки и тем, кто где-либо владелец или администратор; создавший становится владельцем. После входа пользователь нескольких организаций выбирает, с какой начать. + +Отклонены: переиспользовать `is_superuser` (открывает admin и обход проверок); оставить настройки под организацией с проверкой признака. + +## Последствия + +- `is_superuser` — только break-glass Django admin. +- SMTP, TURN и хранилище общие на установку — по решению. +- Экран управления организациями у администратора установки отсутствует (T-005). \ No newline at end of file diff --git a/.skaro/adr/0025-priglasheniya-v-organizatsiyu.md b/.skaro/adr/0025-priglasheniya-v-organizatsiyu.md new file mode 100644 index 0000000..0e43f70 --- /dev/null +++ b/.skaro/adr/0025-priglasheniya-v-organizatsiyu.md @@ -0,0 +1,26 @@ +--- +id: "0025" +title: Приглашения в организацию +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Учётная запись глобальна, членство — на организацию, но попасть во вторую организацию было нельзя: форма создания сотрудника отвечала «e-mail занят» и заодно раскрывала чужим организациям, какие адреса зарегистрированы. Приглашение владельца из провижининга существовало, но письмо никто не отправлял, а регистрации по ссылке не было. + +## Решение + +1. **Членство — только с согласия человека.** Существующая учётная запись никогда не присоединяется молча; администратор лишь выписывает приглашение. +2. **Форма создания сотрудника не меняется.** Новый адрес — учётная запись с паролем первичного доступа. Адрес активной учётной записи из другой организации — приглашение с той же ролью, должностью, телефоном и группами и событие на отправку письма. В своей организации (и для деактивированной учётной записи) адрес «занят». Членство собирается из полей приглашения при принятии; повторное приглашение заменяет прежнее. +3. **Письмо отправляет воркер, токен выпускается при отправке** — открытый токен нигде не хранится. Ссылка `/join?token=…`; язык — получателя, иначе организации. Тот же обработчик шлёт приглашение владельца из провижининга. +4. **Ссылка `/join`:** предпросмотр по токену; есть учётная запись — вход и автоматическое принятие; нет — форма имени и пароля из элементов мастера (`register`, для существующего адреса — `account_exists`). Для владельца принятие активирует организацию (`PENDING_OWNER → ACTIVE`). Приглашение до входа находится через каталог входа (исправлено в 1.6.0). +5. **Ожидающие приглашения в списке сотрудников** — строки «Приглашён» из baseline; фильтры действуют; из меню — «отправить ещё раз» (срок продлевается) и «отозвать». Требуют `employees.manage` и права выдать роль. + +Отклонены: присоединять существующую учётную запись сразу; хранить открытый токен; отдельный экран приглашений. + +## Последствия + +- Аудит: `identity.employee_invited`, `identity.invitation_accepted`, `identity.invitation_revoked`. +- Ответ создания сотрудника может содержать `employee: null`, `invited: true`. +- Остаточная различимость: администратор видит, что ушло приглашение, то есть узнаёт о существовании учётной записи. Полное закрытие требует перевода и новых сотрудников на приглашения, что меняет кадры baseline и требует отдельного решения. \ No newline at end of file diff --git a/.skaro/adr/0026-minimalnye-privilegii-runtime-protsessov.md b/.skaro/adr/0026-minimalnye-privilegii-runtime-protsessov.md new file mode 100644 index 0000000..c377e77 --- /dev/null +++ b/.skaro/adr/0026-minimalnye-privilegii-runtime-protsessov.md @@ -0,0 +1,27 @@ +--- +id: "0026" +title: Минимальные привилегии runtime-процессов +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Аудит поддержки нескольких организаций показал, что граница изоляции прочна в базе, но размыта на уровне процессов: платформенный API создания организаций не работал в поставке (роль platform не имела прав на свои таблицы, тесты ходили владельцем кластера); публичный `backend-app` держал platform-соединение, а в его файловой системе лежали пароли всех ролей, включая роль миграций; роль app читала таблицу организаций без ограничений. + +## Решение + +1. **Гранты платформенной роли** на таблицы оператора, токена, провижининга (без DELETE) и приглашений под tenant-политикой. Провижининг проверяется тестом под реальной runtime-ролью. +2. **`backend-app` работает только ролью app.** Каталоги входа (`chatballs.*_directory`) — security-barrier вьюхи с SELECT для app. Организацию роль app вставляет только в её собственном контексте (`id = chatballs.current_organization_id()`): id резервируется из последовательности заранее (`tenancy.lookup.reserve_organization_id`) — так работают и мастер первого запуска, и «Добавить организацию». Алиас `platform` есть только у `backend-platform` и воркеров (`CHATBALLS_DB_PLATFORM_ALIAS=1`); в остальных процессах обращение к нему падает сразу. +3. **Три тома секретов:** `chatballs-secrets` (ключ подписи, ключ шифрования, пароль app, секрет TURN — все сервисы); `chatballs-secrets-platform` (пароль platform — secrets, postgres, backend-platform, воркеры); `chatballs-secrets-schema` (пароли migration и владельца кластера — secrets, postgres, init, backend-admin). Генератор переносит файлы существующих установок (копия, сверка, удаление), пароли не меняет. `doctor` проверяет, что `backend-app` не видит паролей platform и migration. Вариант `volume.subpath` отклонён (требует Docker 26 и Compose 2.24). +4. **Строка организации видна app только в её контексте**; входы без контекста находят id через `chatballs.organization_directory` и читают строку внутри `tenant_atomic`. +5. **nginx разрешает upstream на каждом запросе** через `resolver` на DNS Docker — иначе после пересоздания контейнера WebSocket уходил в чужой сервис. + +Отклонены: платформенные учётные данные в `backend-app` «на всякий случай»; INSERT организаций для app без ограничения; расширение каталога организаций именем и логотипом. + +## Последствия + +- Резервная копия включает три тома секретов. +- Откат на релиз до разделения томов требует вернуть файлы в общий том (команда — в истории релизов). +- Тесты бэкенда в dev-стеке — из контейнера `backend-admin`. +- `backend-admin` по-прежнему обходит RLS ролью миграций; его защищает привязка к loopback. \ No newline at end of file diff --git a/.skaro/adr/0027-obnovlenie-ustanovki-iz-interfeysa.md b/.skaro/adr/0027-obnovlenie-ustanovki-iz-interfeysa.md new file mode 100644 index 0000000..a29bae3 --- /dev/null +++ b/.skaro/adr/0027-obnovlenie-ustanovki-iz-interfeysa.md @@ -0,0 +1,24 @@ +--- +id: "0027" +title: Обновление установки из интерфейса +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Установка обновлялась только из консоли сервера; администратор не узнавал о новых версиях иначе как со страницы релизов, самообслуживание без SSH было невозможно. `backend-app` работает с минимальными правами (ADR-0026), и давать ему управление Docker нельзя. + +## Решение + +1. **Канал релизов — GitHub Releases** репозитория поставки (`CHATBALLS_UPDATE_REPO`, по умолчанию `dartdavros/chatballs`). Версия зашивается в образ бэкенда (`CHATBALLS_VERSION`; у dev-сборки `dev` обновлений нет). Релиз принимается только с ассетом `compose.yaml` по адресу `…/releases/download/v<версия>/compose.yaml`; версии сравниваются семантически. Проверка — из воркера раз в `CHATBALLS_UPDATE_CHECK_INTERVAL_SECONDS` (15 минут) и по кнопке; результат — в одной строке `UpdateState` вне организаций; ошибка канала ничего не роняет. +2. **Отдельный сервис `updater`** (образ на `docker:cli` с двумя скриптами) — единственный с `/var/run/docker.sock`. Обмен с `backend-app` — файлами в томе `chatballs-updates`: `heartbeat` (каждые 5 с; старше 90 с — «обновление недоступно»), `request.json`, `status.json` (`running`/`done`/`failed` со стадиями), `apply.log`. Updater принимает только цифровую версию, адрес со страницы релизов своего репозитория и `compose.yaml`, где все образы пришпилены `@sha256` из репозитория поставки; проект, каталог и том узнаёт из меток своего контейнера. +3. **Установка в отдельном контейнере** `chatballs-updater-apply` (тот же образ, `docker run -d --rm`, собственная входная точка установки): `config -q` → `pull` → `up -d --wait`. После успеха новый `compose.yaml` копируется в каталог установки. Второй запрос во время установки отклоняется; установка, оборвавшаяся без результата, помечается неудачной. +4. **Интерфейс:** баннер администратору установки «Доступна версия X» со ссылкой «Что нового», кнопкой «Обновить» и подтверждением; баннер переживает перезапуск бэкенда и показывает стадию, итог или причину сбоя. То же и кнопка «Проверить» — в карточке «Обновления» раздела «Платформа». Аудит `updates.install_requested`. Читать — владелец любой организации, проверять и устанавливать — администратор установки. + +Отклонены: Docker-сокет у `backend-app`; Watchtower и аналоги; проверка версии из браузера напрямую в GitHub. + +## Последствия + +- `docker.sock` у `updater` равен root на хосте; поверхность сведена к минимуму. Без сервиса интерфейс показывает «обновление недоступно», CLI-путь остаётся. +- Откат — вручную (`compose.yaml` предыдущего релиза); автоматического отката нет — миграции не возвращаются. \ No newline at end of file diff --git a/.skaro/adr/0028-otdelenie-hoda-ai-ot-priema-soobscheniy.md b/.skaro/adr/0028-otdelenie-hoda-ai-ot-priema-soobscheniy.md new file mode 100644 index 0000000..8936f1d --- /dev/null +++ b/.skaro/adr/0028-otdelenie-hoda-ai-ot-priema-soobscheniy.md @@ -0,0 +1,24 @@ +--- +id: "0028" +title: "Отделение хода AI от приёма сообщений: сервис worker-events" +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Агент с несколькими подключениями (сайт и мессенджер) отвечал с задержками или молчал. Ответ считался прямо в приёме сообщения: пока модель думала над одним вопросом, ни одно другое входящее по всей установке не забиралось; один ход мог занять до трёх минут. Отозванный ключ одной организации мог погасить AI у остальных. + +## Решение + +- Приём (`worker --role=poller`) только записывает сообщение; ход AI, расшифровка голосовых, доставка уведомлений и приглашений на звонки — событиями outbox, которые обрабатывает отдельный сервис `worker-events` (`run_worker --role=events`). +- `worker` (опрос) — строго один экземпляр: курсоры и паузы после сбоя живут в памяти процесса. +- `worker-events` масштабируется репликами (`CHATBALLS_EVENT_WORKERS`, по умолчанию 2); события разбираются с `skip_locked`; ходы одного диалога идут строго по очереди (`claim_next_outbox_event`); ход упавшего процесса возвращается в очередь. Реплик держать немного — они ходят к провайдеру одним ключом организации. +- У сообщения есть состояние хода AI; ответ ограничен сроком: не успел или пролежал в очереди слишком долго — диалог уходит оператору, клиент получает понятный текст. +- Отказ провайдера по существу запроса (неверный ключ, несуществующая модель, слишком длинный запрос) не повторяется; сбои провайдера считаются по каждой организации отдельно. +- Подключение с сорвавшимся опросом возвращается в работу через минуту. + +## Последствия + +- Каналы не ждут друг друга; разносить подключения по агентам ради обхода залипания не нужно. +- В установке появился сервис `worker-events`; миграция `conversations/0026`. \ No newline at end of file diff --git a/.skaro/adr/0029-modeli-otvetov-i-rasshifrovki-golosovyh.md b/.skaro/adr/0029-modeli-otvetov-i-rasshifrovki-golosovyh.md new file mode 100644 index 0000000..bf3779a --- /dev/null +++ b/.skaro/adr/0029-modeli-otvetov-i-rasshifrovki-golosovyh.md @@ -0,0 +1,22 @@ +--- +id: "0029" +title: Модели ответов и расшифровки голосовых принадлежат агенту +status: accepted +date: 2026-09-28 +--- + +## Контекст + +Речь в текст умеет не всякая модель ответов: у части провайдеров (например, Anthropic, Yandex Foundation Models) эндпоинта расшифровки нет вовсе. Кроме того, на одном ключе провайдера живут разные агенты, и модель им нужна разная, а модель задавалась на интеграции. + +## Решение + +- На карточке агента две независимые пары «провайдер + модель»: для ответов и для расшифровки голосовых. Расшифровку можно направить в любую другую интеграцию организации (OpenAI, Groq, собственный Whisper), ответы при этом пишет основной провайдер. +- Модель принадлежит агенту, а не ключу. Пустое поле означает «как в интеграции»; подсказка показывает значение по умолчанию. +- Модель расшифровки: своя на агенте → из интеграции расшифровки → `whisper-1`. Пустой выбор интеграции расшифровки — «как у ответов». +- Ошибка расшифровки не показывает оператору ответ чужого API: в ленте — фраза о том, что произошло и где чинить; ответ провайдера — в журнал. + +## Последствия + +- Миграции `ai/0020`, `ai/0021`; `ai/0022` освободила поле модели у агентов, чья модель совпадала с моделью интеграции, — они следуют за настройкой ключа. +- Маршрутизация по-прежнему только через явно выбранные интеграции агента (ADR-0008). \ No newline at end of file diff --git a/.skaro/adr/0030-dannye-s-sayta-nedoverennye-polya-kontak.md b/.skaro/adr/0030-dannye-s-sayta-nedoverennye-polya-kontak.md new file mode 100644 index 0000000..0fb5066 --- /dev/null +++ b/.skaro/adr/0030-dannye-s-sayta-nedoverennye-polya-kontak.md @@ -0,0 +1,30 @@ +--- +id: "0030" +title: Данные с сайта — недоверенные поля контакта в контексте агента +status: accepted +date: 2026-09-29 +--- + +## Контекст + +Организациям нужно, чтобы оператор и AI-агент видели в веб-чате то, что сайт уже знает о посетителе: ID клиента, номер и статус заказа, признак активного заказа. Значения меняются по ходу диалога. + +Данные приходят из браузера посетителя через публичный виджет: их может подменить любой, кто откроет консоль. ADR-0004 требует минимизировать данные, передаваемые LLM. ADR-0003 определяет идентификацию клиента и объединение контактов. Встроенные поля контакта (имя, почта, телефон) оператор правит вручную, и эти правки нельзя затирать. + +Варианты: (1) писать всё в поля Contact или в JSON на контакте; (2) отдельное хранилище значений со схемой в настройках подключения; (3) подписанный токен от сервера сайта (JWT) — надёжно, но требует бэкенда у сайта и ломает запуск за минуты. + +## Решение + +1. Схема своих полей — в конфигурации WEB-подключения (`integration.config.fields`): ключ, подпись, тип (string, number, boolean, datetime, enum, email, phone, url), значения списка с цветом, признак «Видит AI», порядок. Не больше 30 полей. Ключи `name`, `email`, `phone` зарезервированы. +2. Значения — в отдельной таблице `contact_field_values (organization, contact, integration, key, value jsonb, updated_at)`: одно последнее значение на ключ, с RLS как у остальных tenant-таблиц. Историю изменений хранит лента диалога (системные события), а не эта таблица. +3. `name`, `email`, `phone` пишутся в контакт, только если поле в контакте пустое или прошлое значение пришло с того же подключения. Ручные правки оператора не затираются. +4. Данные с сайта недоверенные. Они не участвуют в авторизации, идентификации и объединении контактов (ADR-0003). В UI помечены «с сайта» и доступны только для чтения. Неизвестные ключи и значения неверного типа отбрасываются молча для сайта, с предупреждением в журнал. +5. В системный промпт агента попадают только поля с включённым «Видит AI». Подаются отдельным блоком как сведения от сайта, не как инструкции. Это явное согласие администратора в духе ADR-0004. +6. Удаление поля из схемы значения не удаляет, но перестаёт их показывать и передавать AI. +7. Подписанная передача (JWT от сервера сайта) — возможное развитие, в этот объём не входит. + +## Последствия + +Плюсы: оператор и AI видят контекст сайта без интеграций на стороне бэкенда сайта: достаточно одного вызова JS. Схема живёт рядом с виджетом и не требует миграций на каждое новое поле. Карточка контакта не засоряется, ручные правки защищены. + +Минусы и риски: посетитель может подменить значения, поэтому AI может ответить по ложному статусу. Администратор должен понимать это, включая «Видит AI». Значения одного контакта из разных подключений хранятся раздельно по `integration`. Поиск и фильтрация контактов по этим полям в объём не входят. Появляется ещё одна tenant-таблица, для неё нужны тесты изоляции. diff --git a/.skaro/architecture.md b/.skaro/architecture.md new file mode 100644 index 0000000..1c96783 --- /dev/null +++ b/.skaro/architecture.md @@ -0,0 +1,215 @@ +# Архитектура Chatballs + +## 1. Принципы + +- **Одна кодовая база, одна правда.** От одиночной установки до многоорганизационной работают один backend, один frontend, один `compose.yaml` и один порядок обновления. Отдельных редакций нет. +- **Модульный монолит.** Домены — логические модули одного Django-приложения. Микросервисов и второй базы нет. Фоновые обработчики — отдельные процессы из того же кода. +- **Organization — граница изоляции.** Все клиентские данные принадлежат организации прямо или через однозначную цепочку владения. `organization_id`, переданный клиентом, никогда не основание доступа (ADR-0012). +- **Один пользователь — несколько организаций.** Учётная запись глобальна, связь с организацией — через membership. +- **Простота для администратора.** Понятий минимум: агент, подключение внутри агента, сотрудник, группа, контакт, диалог, знание, портал. +- **Никакой коммерции.** Ни продаж, ни заказов, ни платежей, ни собственного биллинга (ADR-0020). +- **Ничего не спрашивать до запуска.** Установка не требует переменных окружения; всё, что зависит от площадки, задаётся в интерфейсе и хранится в БД (ADR-0011). + +## 2. Верхнеуровневая модель + +```text +Platform +├── Platform layer +│ ├── HumanUser, Organization, OrganizationMembership +│ ├── InstanceSettings — свойства самой установки +│ ├── tenant provisioning +│ └── platform administration +└── Organization workspace + ├── сотрудники, роли и группы + ├── агенты и их подключения + ├── контакты, диалоги, сообщения, шаблоны ответов + ├── звонки + ├── знания и retrieval + ├── порталы поддержки + └── уведомления и аудит +``` + +## 3. Структура репозитория + +```text +code/chatballs/ +├── apps/ +│ ├── backend/ Django: API, ASGI, домены, фоновые воркеры +│ ├── internal-ui/ рабочее пространство организации и публичные порталы (по HTTP Host) +│ └── web-chat/ публичный веб-виджет, клиентская страница звонка, лоадер +├── packages/ +│ ├── ui/ общие компоненты и токены +│ ├── contracts/ общие типы и контракты API +│ └── shared/ общая TypeScript-логика +├── design/baseline/ утверждённые макеты +├── deploy/ cli, docker, nginx, postgres, secrets +├── scripts/ запуск и проверки +├── tests/ сквозные тесты и тесты CLI +├── compose.yaml единственный production-манифест +├── compose.dev.yaml override для разработки +└── Caddyfile +``` + +Решение — ADR-0005. + +## 4. Модули backend + +| Модуль | Ответственность | +|---|---| +| `identity` | пользователи, организации, membership, роли, группы, приглашения, мастер первого запуска, настройки установки, аудит, демо-датасет | +| `tenancy` | tenant-контекст, транзакции с RLS, маршрутизация ролей БД, каталоги входа, учёт объёма хранилища | +| `channels` | якорь обработки: группа диалогов агента, ссылка на провайдер-интеграцию | +| `ai` | агент, знания, категории, вложения, фрагменты и retrieval, учёт вызовов LLM, расшифровка голосовых | +| `integrations` | LLM-провайдеры (OpenRouter, Custom, Demo) и подключения-транспорты (Telegram, MAX, VK, WEB, EMAIL), секреты, проверки | +| `conversations` | контакты, идентичности подключений, диалоги, сообщения, метки, шаблоны ответов, транспорты приёма и отправки | +| `calls` | P2P-звонки: сессии, приглашения, участники, метрики | +| `webchat` | веб-виджет: конфигурация, публичный лоадер, анонимные сессии | +| `support_portals` | порталы, категории, статьи и редакции, публичная выдача, обратная связь | +| `notifications` | уведомления, прочтения, привязки служебных ботов | +| `platform` | platform-операторы, токены, provisioning организаций | +| `events` | inbox/outbox, идемпотентная доставка, очередь хода AI | +| `updates` | состояние обновлений установки | +| `health`, `http`, `api` | health-эндпоинты и общая инфраструктура HTTP | + +### Агент и канал + +Пользователь видит один объект — **Агента**. В схеме это пара `Channel` — `AIAgent` один к одному: канал держит группу видимости и ссылку на провайдер-интеграцию, агент — имя, инструкции, модель, знания и статьи. Подключения привязаны к каналу. Это осознанный остаток объединения (ADR-0019): слияние выполнено в продуктовой модели и UI, но не в схеме БД. В API и UI «канал» как самостоятельный объект не появляется. + +## 5. Пользователи и доступ + +`HumanUser` — глобальная identity (email, аутентификация, MFA, глобальный статус, признак `is_instance_admin`). `OrganizationMembership` — участие в организации: роль, должность, статус, блокировка; `unique(user, organization)`. + +```text +OWNER — все права; не может быть удалён или заблокирован +ADMIN — те же права, включая управление администраторами +EMPLOYEE — только чат; видимость диалогов по группам и назначению +``` + +Видимость диалога для сотрудника: диалоги его групп + без группы + где он ответственный (ADR-0021). Проверки — deny-by-default в backend во всех HTTP-, WebSocket- и фоновых операциях. + +Администратор установки — глобальный признак, не выражаемый через membership и не дающий прав внутри организаций (ADR-0024). Доступ оператора платформы к данным организации — только через аудируемую support-сессию. + +## 6. Tenant context и изоляция + +```text +аутентифицированный HumanUser + активная membership + выбранная организация = проверенный tenant context +``` + +Канонический маршрут tenant API: `/api/v1/organizations/{organization_public_id}/...`. Настройки установки — вне организаций: `/api/v1/instance/...`. + +Слои изоляции: проверка membership, роли и видимости в сервисах → валидация принадлежности в моделях → ограничения БД (включая constraint triggers) → PostgreSQL RLS → регрессионные тесты изоляции. + +RLS: `ENABLE` и `FORCE ROW LEVEL SECURITY`, политики с `USING` и `WITH CHECK`, runtime-роль без `BYPASSRLS` и без владения таблицами; контекст — только `SET LOCAL` в транзакции. Строка организации видна роли app только в её контексте; входы без контекста идут через security-barrier каталоги (ADR-0026). + +Ключи объектов в хранилище: `organizations/{organization_public_id}/...`. + +## 7. Коммуникации + +Диалог — основная единица; состояние разделено на оси: жизненный цикл, режим управления, ожидаемый отвечающий (ADR-0001). AI ведёт первым, человек перехватывает атомарно (ADR-0002). Диалог наследует группу агента, но маршрутизируется независимо. + +Приём и отправка — через идемпотентные inbox/outbox. Приём только записывает сообщение; ход AI считается отдельно процессами `worker-events`, по очереди внутри диалога (ADR-0028). + +P2P-звонок — дочерняя сессия диалога; медиа идёт WebRTC напрямую либо через Coturn и не сохраняется (ADR-0010). + +## 8. AI + +Текстовый режим — только через провайдера организации: OpenRouter или любой OpenAI-совместимый endpoint; плюс демо-провайдер без ключей и сети. На агенте две независимые пары «провайдер + модель»: для ответов и для расшифровки голосовых (ADR-0029). + +Знания — общая библиотека организации с деревом категорий, без версий и релизов (ADR-0009). Retrieval — в PostgreSQL: `pgvector` + встроенный FTS (ADR-0007). Статья портала — второй источник фрагментов (ADR-0017). Изменения агента и знаний применяются сразу. + +## 9. Поверхности приложения + +```text +backend-app — tenant API, публичные API, realtime +backend-platform — platform control plane (отдельный домен) +backend-admin — Django admin, только на loopback +``` + +У каждой поверхности свой URLConf, свои cookie и host-политика (ADR-0013). Публичные порталы определяются по HTTP Host до разбора маршрута. + +## 10. Развёртывание + +```text +secrets · postgres · redis · init · backend-app · backend-platform · backend-admin +worker · worker-events · updater · frontend · gateway · coturn +``` + +- `secrets` — одноразовая генерация секретов в три тома (`chatballs-secrets`, `-platform`, `-schema`); +- `init` — одноразовые миграции; +- `worker` — опрос подключений и периодические работы, строго один экземпляр; +- `worker-events` — ход AI, доставка уведомлений и приглашений, масштабируется репликами (`CHATBALLS_EVENT_WORKERS`, по умолчанию 2); +- `updater` — единственный сервис с `docker.sock`, применяет обновления из интерфейса; +- `frontend` — nginx со статикой internal-ui и веб-чата; +- `gateway` (Caddy) — единственная публичная HTTP/HTTPS-граница, без зашитых хостов, сертификаты on-demand для одобренных backend хостов; +- `coturn` — relay для звонков, поднимается вместе со стеком (host network, порт 3478, UDP 49160–49999, TURN-over-TLS по умолчанию выключен); +- PostgreSQL (`pgvector/pgvector:pg16`) и Redis портов на хост не публикуют. + +Подробности — SPEC-0005. + +## 11. Источники истины + +| Область | Источник | +|---|---| +| Пользователи, организации, membership | platform layer | +| Адрес установки, почта, TURN, хранилище, язык по умолчанию | `InstanceSettings` | +| Секреты интеграций | зашифрованные поля БД организации | +| Контакты, диалоги, агенты, знания, порталы | organization workspace | +| Файлы и вложения | локальный диск установки или S3-совместимое хранилище | + +Часовой пояс и локаль — настройки организации. + +## 12. Надёжность и безопасность + +Обязательны: transactional outbox/inbox и идемпотентные обработчики, retry с backoff, сохранение необработанных событий, резервное копирование и проверяемое восстановление, регрессионные тесты изоляции, шифрование и ротация секретов, минимизация payload для LLM (ADR-0004), краткоживущие call/TURN credentials. + +## 13. Границы + +Входит: open-source self-hosted поставка; несколько организаций в установке; контакты и история; AI-first диалоги; агенты с подключениями Telegram, MAX, ВКонтакте, веб-виджет и email; группы, перенос диалогов и ответственный; голосовые сообщения; P2P-звонки; знания и retrieval; порталы с темами; шаблоны ответов; уведомления; аудит; раздельные app/platform поверхности и local-only admin; обновление из интерфейса. + +Не входит без отдельного решения: тарифы, подписки, квоты, биллинг; managed AI и AI-кредиты; продажи, заказы, каталог; лиды, сделки, воронки, канбан; должности и capability-модель как наборы прав; командный центр; managed-облако; групповые звонки, SFU, screen sharing и запись медиа. + +## Правила и ограничения + +### Изоляция и безопасность +1. Каждый endpoint, selector, сервис и репозиторий принимает проверенный tenant-контекст; объект загружается по идентификатору **вместе** с организацией. Отсутствие контекста — отказ, а не запрос по всей базе. +2. `organization_id` из тела, query string или клиентских метаданных — не доказательство доступа. Публичные точки входа выводят организацию server-side из credential или ключа виджета. +3. Session-level `SET` запрещён; tenant-контекст только через `SET LOCAL` в транзакции (`tenant_atomic`). Для WebSocket, воркеров и обработчиков событий контекст ставится на каждую операцию. +4. `backend-app` работает только ролью app; алиас `platform` — только у `backend-platform` и воркеров. Docker socket в контейнеры приложения не монтируется (исключение — `updater`). +5. Cross-organization связи запрещены и защищаются всеми слоями; пропущенный tenant-ключ ловится тестом изоляции под реальной runtime-ролью. +6. В прикладных логах и БД запрещено хранить raw audio, SDP, ICE candidates, секреты провайдеров, токены сессий и приглашений. Секретные параметры URL не попадают ни в журнал, ни в поле последней ошибки подключения. +7. Провайдер AI разрешается только через явно выбранную интеграцию агента; «первая попавшаяся интеграция» и глобальная подмена выбора владельца запрещены. Браузер никогда не получает credentials провайдера. +8. Django admin — только loopback, без публичного проксирования. Cookie host-only, без `Domain`, `HttpOnly`; жёсткость (`__Host-`, `Secure`, HSTS) — по факту TLS. +9. Домены в код, конфигурацию и образы не зашиваются; frontend использует same-origin относительные URL. + +### Поставка +10. Один `compose.yaml` для production и коробки; разработка добавляет только `compose.dev.yaml`. Второй production-манифест запрещён. Новые приложения и пакеты вне структуры репозитория — только отдельным решением. +11. Файла `.env` у продукта нет; новые настройки площадки задаются в интерфейсе и хранятся в БД. `release.env` содержит только версию и ссылки на образы по digest. +12. `init` выполняет только миграции: не создаёт организацию, владельца и демо-данные. + +### UI (из AGENTS.md проекта) +13. **Макет — закон.** Экраны, блоки, тексты, порядок и состояния — ровно как в `design/baseline/<фича>/*.dc.html`. Отклонение — только по прямому указанию владельца. +14. **Один стандарт на элемент.** Ссылки — класс `.link` из `shared/links.css` с модификаторами `.is-strong`, `.is-neutral`, `.is-muted`, `.is-mono`, `.has-icon`; действие — `