docs(chat): замысел личных ответов вошедшему клиенту
Карточка фактов (баланс/ступень/проекты/заявки) считается кодом и всегда кладётся боту, когда клиент вошёл. Сторож режет числа не из карточки. Переписка вошедшего закрывается от посторонних. Данные — строго под RLS тенанта. Бот только рассказывает, ничего не меняет. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-07-13T06:50:36.154Z
|
||||
Last updated: 2026-07-13T07:15:44.493Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -112,9 +112,9 @@ Episodes since last run: 542 / threshold: 10
|
||||
|
||||
| PID | Имя | CPU-время | Возраст |
|
||||
|---|---|---|---|
|
||||
| 3384 | MsMpEng | 5.94ч | 0.0ч |
|
||||
| 9376 | Code | 3.52ч | 0.0ч |
|
||||
| 4 | System | 1.15ч | 0.0ч |
|
||||
| 3384 | MsMpEng | 6.06ч | 0.0ч |
|
||||
| 9376 | Code | 3.62ч | 0.0ч |
|
||||
| 4 | System | 1.17ч | NaNч |
|
||||
|
||||
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Замысел: личные ответы вошедшему клиенту в чате (13.07.2026)
|
||||
|
||||
> Продолжение стройки «свой чат вместо Jivo»
|
||||
> ([замысел](2026-07-13-own-chat-widget-design.md), [снимок состояния](../2026-07-13-HANDOFF-own-chat-STATE.md)).
|
||||
> Ветка `worktree-jivo-bot-core`. 🔴 **На прод не катим** — только по прямой команде владельца.
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
Личные ответы — та самая причина, по которой мы ушли от JivoSite: в чужом виджете бот
|
||||
физически не знал, кто с ним говорит. Теперь знает: реплика приходит на наш адрес, вошедший
|
||||
клиент опознан, `bot_dialogs` уже пишет `user_id` и `source`.
|
||||
|
||||
Сегодня бот **специально** отбивает любой личный вопрос к живому специалисту
|
||||
(`BotAnswerService::PERSONAL_PATTERN` → «оставьте телефон»). Это дорого (человек перезванивает
|
||||
ради цифры, которая есть в базе) и медленно (клиент ждёт, вместо того чтобы получить ответ
|
||||
за две секунды).
|
||||
|
||||
**Решение владельца 13.07.2026:** бот отвечает вошедшему клиенту по его данным — деньги,
|
||||
ступень, проекты, заявки. Только **рассказывает и ведёт за руку**; ничего не меняет
|
||||
(пауза, пополнение, лимиты — руками клиента в портале).
|
||||
|
||||
## 2. Границы (что можно, чего нельзя)
|
||||
|
||||
**Можно** — четыре группы фактов (выбор владельца):
|
||||
|
||||
| Группа | Что бот знает |
|
||||
|---|---|
|
||||
| Деньги | баланс в рублях; на сколько заявок его хватит; на сколько дней при текущем заказе; списано за месяц; последнее пополнение (сумма и дата) |
|
||||
| Ступень | номер текущей ступени, цена заявки на ней, получено в месяце, сколько заявок до следующей ступени |
|
||||
| Проекты | сколько; по каждому — название, состояние (работает / на паузе / остановлен из-за денег / выключен), дневной лимит, доставлено сегодня |
|
||||
| Заявки | сколько пришло сегодня, вчера, за 7 дней, за месяц |
|
||||
|
||||
**Нельзя, ни при каких формулировках вопроса:**
|
||||
|
||||
- **Телефоны, имена, любые ПДн лидов.** В чат уходят только числа. Хочет посмотреть заявки —
|
||||
ведём в раздел «Сделки». Это правило и в карточке фактов (их там просто нет), и в промпте.
|
||||
- **Коммерческая тайна** — поставщик, каналы, механика поставки. Стоп-темы остаются в силе.
|
||||
- **Действия за клиента** — бот не ставит паузу, не меняет лимиты, не пополняет баланс.
|
||||
- **Прогнозы и обещания** — «завтра придёт 20 заявок», «на 1000 ₽ выйдет 10 заявок».
|
||||
Считать за клиента будущее бот не должен (запрет уже стоит, остаётся).
|
||||
- **Гостю с лендинга — никаких личных цифр.** Он не опознан.
|
||||
|
||||
## 3. Как устроено
|
||||
|
||||
### 3.1. Карточка фактов — цифры считает КОД, не модель
|
||||
|
||||
Новый сервис `App\Services\Bot\ClientFacts`:
|
||||
|
||||
- вход — `int $userId`;
|
||||
- находит `User` → `tenant_id`;
|
||||
- под RLS-контекстом этого тенанта (`DB::transaction` + `SET LOCAL app.current_tenant_id`,
|
||||
как в `ImportLeadsJob`) собирает четыре группы фактов;
|
||||
- отдаёт готовый **текстовый блок** («карточку»), который кладётся модели рядом
|
||||
со статьями инструкции.
|
||||
|
||||
Пример карточки:
|
||||
|
||||
```
|
||||
ДАННЫЕ ЭТОГО КЛИЕНТА (посчитаны только что, на 13.07.2026 14:20 МСК):
|
||||
Баланс: 3 400 ₽ ≈ 40 заявок, при текущем заказе хватит примерно на 5 дней.
|
||||
Ступень: 2-я, 85 ₽ за заявку. Получено в этом месяце: 180 заявок, до следующей ступени — 120.
|
||||
Проекты (3): «Стоматология МСК» — работает, лимит 20/день, сегодня 12;
|
||||
«Клиника СПб» — на паузе с 12.07 (пауза поставлена клиентом);
|
||||
«Тест» — остановлен из-за нехватки денег.
|
||||
Заявки: сегодня 12, вчера 18, за 7 дней 96, за месяц 340.
|
||||
Списано за месяц: 28 900 ₽. Последнее пополнение: 5 000 ₽ 08.07.2026.
|
||||
```
|
||||
|
||||
Откуда берутся числа (существующий код, ничего нового не изобретаем):
|
||||
|
||||
| Факт | Источник |
|
||||
|---|---|
|
||||
| Баланс | `tenants.balance_rub` |
|
||||
| Баланс → заявки | `BalanceToLeadsConverter::convert(balance_rub, delivered_in_month, активные ступени)` |
|
||||
| Хватит на дней | `RunwayCalculator::daysLeft(tenantId, affordableLeads)`; `null` → «активных проектов нет, тратить нечего» |
|
||||
| Ступень и цена | `PricingTier::current()` + `PricingTierResolver::resolveForCount($tiers, delivered_in_month + 1)` |
|
||||
| До следующей ступени | считаем в `ClientFacts` по накопительным порогам `leads_in_tier` (у резолвера готового метода нет); на последней ступени — «выше ступеней нет» |
|
||||
| Проекты | `projects`: `is_active`, `paused_at`, `preflight_blocked_at`, `daily_limit_target`, `delivered_today` |
|
||||
| Заморозка по балансу | `tenants.frozen_by_balance_at` |
|
||||
| Заявки за период | `deals` по `tenant_id` + `received_at` (это ключ партиционирования — фильтруем по нему, не по `created_at`) |
|
||||
| Списано за месяц | `lead_charges`: `SUM(price_per_lead_kopecks) / 100` по `charged_at` |
|
||||
| Последнее пополнение | `balance_transactions`: последняя запись с `type = 'topup'` (`BalanceTransaction::TYPE_TOPUP`) по `created_at` — сумма `amount_rub` и дата |
|
||||
|
||||
Состояние проекта складывается из полей (колонки `status` у проектов нет):
|
||||
|
||||
- `preflight_blocked_at` ≠ NULL → «остановлен из-за нехватки денег»;
|
||||
- `paused_at` ≠ NULL → «на паузе, поставлена клиентом»;
|
||||
- `is_active` = false → «выключен»;
|
||||
- иначе → «работает».
|
||||
|
||||
Если у тенанта заморозка (`frozen_by_balance_at`), в карточке отдельная строка:
|
||||
«Заказ заморожен из-за нулевого баланса — пополните, и проекты поедут снова».
|
||||
|
||||
### 3.2. Карточка кладётся ВСЕГДА, когда клиент вошёл
|
||||
|
||||
Не «когда бот распознал личный вопрос». Распознавание намерения регулярками мы уже проходили —
|
||||
оно **проигрывает гонку формулировок** (уроки 12–13.07.2026: цена/ниша возвращалась пятью
|
||||
формулировками, выдуманный срок связи проскочил мимо списка глаголов). Если бот **всегда** знает,
|
||||
с кем говорит, отдельное «распознавание» не нужно вовсе: он сам сообразит и на «а хватит
|
||||
до пятницы?», и на «почему второй проект молчит?», и на «сколько я потратил».
|
||||
|
||||
Цена вопроса — четыре лёгких запроса к базе на реплику (≈300 знаков в контекст). Приемлемо.
|
||||
|
||||
### 3.3. Что меняется в мозге
|
||||
|
||||
`BotAnswerService::answer(string $question, array $history, bool $fromPortal, ?string $facts = null)`:
|
||||
|
||||
- `PERSONAL_PATTERN` → эскалация **отключается**, когда карточка есть: личный вопрос теперь
|
||||
отвечается, а не отфутболивается;
|
||||
- у гостя (`$facts === null`) личный вопрос → **не эскалация к человеку**, а честный ответ:
|
||||
«Ваши цифры видны в кабинете — войдите, и я подскажу по балансу, ступени и проектам»
|
||||
(новая константа `LOGIN_TEXT`, `escalate: false`). Звать живого человека ради этого дорого и глупо;
|
||||
- карточка добавляется в `$context` **первым блоком** (перед статьями) и попадает в промпт;
|
||||
- новые правила промпта:
|
||||
- «Цифры про клиента бери ТОЛЬКО из карточки. Нет факта в карточке — скажи, где посмотреть
|
||||
в портале, но не придумывай»;
|
||||
- «Никаких телефонов и имён из заявок: их нет и не будет — веди в раздел “Сделки”»;
|
||||
- «Не прогнозируй будущее (“завтра придёт столько-то”)»;
|
||||
- «Ничего не меняй за клиента: паузу, лимиты и пополнение он делает сам — покажи, где».
|
||||
|
||||
### 3.4. Сторож
|
||||
|
||||
`AnswerGuard::clean($text, $context)` уже режет предложение, если в нём **сумма в рублях**,
|
||||
которой нет в выданных материалах. Карточка фактов становится частью `$context` — значит
|
||||
разрешены ровно те цифры, что посчитал код, и ни одной больше.
|
||||
|
||||
Расширяем правило `hasInventedPrice` → `hasInventedNumber`: то же самое для чисел рядом
|
||||
со словами **«заявк…», «проект…», «дн…/день/дней», «₽/руб…»**. Иначе бот назовёт «придёт
|
||||
20 заявок» — числа рядом с заявками сегодня никем не проверяются.
|
||||
|
||||
Ложные срабатывания («например, 2–3 заявки») нас устраивают: считать за клиента бот и так
|
||||
не должен — такие фразы под запретом.
|
||||
|
||||
### 3.5. Переписка становится личной — закрываем её
|
||||
|
||||
Сегодня `GET /api/chat/{chat}/messages` открыт всем, кто знает ключ разговора (32 знака
|
||||
в `localStorage`). Пока бот говорил общее — неважно. Как только он назовёт баланс, переписка
|
||||
становится личной, и её надо закрыть.
|
||||
|
||||
**Владелец разговора.** Разговор считается принадлежащим клиенту, если в нём есть хоть одна
|
||||
реплика с `user_id`. Правила:
|
||||
|
||||
| Кто | `send` (написать) | `messages` (прочитать) |
|
||||
|---|---|---|
|
||||
| Владелец (тот же `user_id`) | можно | можно |
|
||||
| Другой вошедший | 404 «нет такого разговора» | 404 |
|
||||
| Гость | 404 | 404 |
|
||||
| Разговор без владельца (гостевой) | можно всем (как сейчас) | можно всем (как сейчас) |
|
||||
|
||||
404, а не 403: мы не подтверждаем чужому, что такой разговор существует.
|
||||
|
||||
**Ключ разговора в браузере.** В кабинете ключ свой у каждого входа: `liderra_chat_id_u<id>`;
|
||||
на лендинге — `liderra_chat_id_guest`. Вышел из кабинета — окошко берёт гостевой ключ и
|
||||
чужих цифр не показывает. Компонент `ChatWidget.vue` передаёт окошку id вошедшего
|
||||
(`window.__liderraChatUser`), окошко выбирает ключ.
|
||||
|
||||
**RLS.** `ProcessChatMessageJob` сегодня не выставляет контекст тенанта. Пока он читал только
|
||||
`bot_dialogs` — сходило. Для данных клиента контекст обязателен: `ClientFacts` сам открывает
|
||||
транзакцию и делает `SET LOCAL app.current_tenant_id` (рецепт `ImportLeadsJob`). Иначе на проде
|
||||
джоба либо не увидит ничего, либо (при роли с BYPASSRLS) увидит **чужое** — это худший исход.
|
||||
|
||||
## 4. Что НЕ делаем в этой стройке (YAGNI)
|
||||
|
||||
- Действия из чата (пауза, пополнение, лимиты) — отдельная стройка, если владелец захочет.
|
||||
- Ролики для гостей на лендинге — третья стройка.
|
||||
- Пересказ конкретных заявок и лидов — никогда (ПДн).
|
||||
- Кэш карточки фактов — не нужен: четыре лёгких запроса дешевле любой возни с протуханием.
|
||||
|
||||
## 5. Как проверяем
|
||||
|
||||
1. **Тесты на карточку** (`ClientFacts`): баланс и «хватит на N дней»; ступень и цена;
|
||||
до следующей ступени; последняя ступень («выше нет»); четыре состояния проекта;
|
||||
заявки за периоды (граница «вчера/сегодня» по МСК); заморозка по балансу;
|
||||
пустой клиент (0 проектов, 0 заявок, 0 ₽) — карточка не падает.
|
||||
2. **Тесты на закрытие переписки**: чужой вошедший и гость получают 404 и на чтение,
|
||||
и на запись; владелец — 200; гостевой разговор доступен как раньше.
|
||||
3. **Тесты на сторожа**: выдуманное число рядом с «заявк/проект/дн/₽» режется; число
|
||||
из карточки проходит.
|
||||
4. **Тест на гостя**: личный вопрос с лендинга → ответ «войдите в кабинет», без эскалации.
|
||||
5. **Живой прогон диалогами** на тестовом клиенте с настоящими данными (30–40 реплик):
|
||||
спрашиваем баланс разными словами, «почему проект стоит», «сколько потратил»,
|
||||
«когда пополнял», «а хватит до пятницы». Проверка — **судьями**, не счётчиками
|
||||
по своим же регуляркам (урок 12.07.2026: счётчик показал «0 нарушений», а враньё осталось).
|
||||
6. **Проверка на утечку**: чужой `chat_id` из другого браузера — ничего не отдаёт.
|
||||
|
||||
## 6. Риски
|
||||
|
||||
| Риск | Что делаем |
|
||||
|---|---|
|
||||
| Бот назовёт число, которого нет в карточке | сторож режет предложение; карточка — единственный источник цифр |
|
||||
| Личные цифры утекут через ключ разговора | владелец разговора; ключ свой на каждый вход; 404 чужому |
|
||||
| Джоба увидит чужого тенанта | RLS-контекст в `ClientFacts`, тест на изоляцию |
|
||||
| Бот сболтнёт ПДн лида | в карточке их нет физически + запрет в промпте |
|
||||
| Расход на модель вырастет | карточка ≈300 знаков; счётчики от накрутки счёта остаются как есть |
|
||||
Reference in New Issue
Block a user