docs(chat): замысел личных ответов вошедшему клиенту

Карточка фактов (баланс/ступень/проекты/заявки) считается кодом и всегда
кладётся боту, когда клиент вошёл. Сторож режет числа не из карточки.
Переписка вошедшего закрывается от посторонних. Данные — строго под RLS
тенанта. Бот только рассказывает, ничего не меняет.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-13 10:55:32 +03:00
parent 2b13980af8
commit 09f0ea44df
2 changed files with 198 additions and 4 deletions
+4 -4
View File
@@ -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. Карточка кладётся ВСЕГДА, когда клиент вошёл
Не «когда бот распознал личный вопрос». Распознавание намерения регулярками мы уже проходили —
оно **проигрывает гонку формулировок** (уроки 1213.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 знаков; счётчики от накрутки счёта остаются как есть |