From 09f0ea44df9ff965b29697f5bc7b30b8d8bfa580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 13 Jul 2026 10:55:32 +0300 Subject: [PATCH] =?UTF-8?q?docs(chat):=20=D0=B7=D0=B0=D0=BC=D1=8B=D1=81?= =?UTF-8?q?=D0=B5=D0=BB=20=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D1=85=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=82=D0=BE=D0=B2=20=D0=B2=D0=BE=D1=88=D0=B5?= =?UTF-8?q?=D0=B4=D1=88=D0=B5=D0=BC=D1=83=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка фактов (баланс/ступень/проекты/заявки) считается кодом и всегда кладётся боту, когда клиент вошёл. Сторож режет числа не из карточки. Переписка вошедшего закрывается от посторонних. Данные — строго под RLS тенанта. Бот только рассказывает, ничего не меняет. Co-Authored-By: Claude Opus 4.8 --- docs/observer/STATUS.md | 8 +- .../2026-07-13-personal-answers-design.md | 194 ++++++++++++++++++ 2 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-13-personal-answers-design.md diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 9defa6d9..3f45580d 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -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-сессий. diff --git a/docs/superpowers/specs/2026-07-13-personal-answers-design.md b/docs/superpowers/specs/2026-07-13-personal-answers-design.md new file mode 100644 index 00000000..6e4416da --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-personal-answers-design.md @@ -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`; +на лендинге — `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 знаков; счётчики от накрутки счёта остаются как есть |