Files
portal/app/tests/Feature/Bot/JudgeFindingsTest.php
T
Дмитрий c5125e981e feat(bot): выбор модели + три замка безопасности + инструкция 51 статья
Прогнал 250 вопросов через настоящий вебхук шесть раз и сравнил четыре модели
вслепую силами судей-агентов. Итоги — в docs findings.

Модель. Появился переключатель services.jivo_bot.llm: yandex (данные не покидают
РФ) либо aitunnel (зарубежные модели через российский шлюз). По слепому сравнению
Claude Haiku 4.5 выиграл финал у Gemini 157:24 и у обеих моделей Яндекса: он
полнее, точнее по кнопкам и цифрам и говорит как человек, а не справочник.

Три замка, потому что живая модель = самоуверенная модель:
- PersonalDataMask — телефоны и почта вырезаются ДО отправки в любую модель
  (клиент оставляет номер в чате; за границу ПДн уходить не должны).
- ChatTextCleaner — markdown и смайлики (Jivo их не рисует): было 191 ответ
  с разметкой, стало 0.
- AnswerGuard — режет юридические гарантии («вас не оштрафуют», «мы гарантируем»,
  «не нарушаем закон») и ЛЮБУЮ цену, которой нет в выданных статьях. Пусто после
  чистки → зовём живого специалиста.

Поиск по инструкции переписан: выбрасываются вопросительные слова (из-за них
«какие у вас тарифы» находило выгрузку в Excel), ранжирование сперва по заголовку
и синонимам, выдача — три РАЗНЫЕ статьи и каждая целиком, плюс починка опечаток
(«каг сазадь праэкт») и учёт прошлой реплики в коротких «а это платно?».

Инструкция: 40 → 51 статья. Новые — по фактам владельца: бесплатный тест и
подарок 1000 ₽, «мы канал, а не реклама», СМС → через час звонок, средние
показатели (8–12 из 100 разговаривают, ещё 5–7 по СМС), номер не вернётся
30 дней, замена брака, вывод остатка, поддержка 24/7, удаление данных, ниши.
Вычищено враньё: минус баланса, «списали без заявки», пауза вечером, выходные,
«Вся РФ», статус «Отказ» (его нет — есть «Не реализовано»), amoCRM, платный
повторный сбор, нарастающий итог тарифных ступеней.

Тесты: 106 из 106. Каждая найденная тупость закреплена тестом.

НЕ НА ПРОДЕ. Выкат — только по прямой команде владельца.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 20:44:48 +03:00

130 lines
7.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\KnowledgeChunk;
use App\Services\Bot\KnowledgeSearch;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Разбор пятого прогона четырьмя проверяющими (12.07.2026): поиск берёт соседнюю
* статью, бот выдумывает статусы и даёт юридические гарантии.
*/
beforeEach(function () {
KnowledgeChunk::query()->delete();
Artisan::call('help:rebuild-knowledge');
});
function topArticle(string $question): string
{
$hits = app(KnowledgeSearch::class)->search($question, 3);
expect($hits)->not->toBeEmpty();
return $hits[0]->title;
}
it('поиск берёт статью ПО ТЕМЕ, а не соседнюю с теми же словами', function () {
$cases = [
'как создать проект' => 'Создать проект — пошагово',
'какие поля надо заполнить при создании проекта' => 'Создать проект — пошагово',
'как запустить проект обратно' => 'Пауза и запуск проекта',
'что такое сделка' => 'Сделки',
'можно скачать заявки в csv' => 'Выгрузить сделки',
'есть ли абонентская плата' => 'Тарифы',
'в какое время дня приходят лиды' => 'Выгрузка по проекту',
'у меня один и тот же конкурент два раза' => 'Дубли конкурентов',
'нашел источник, что дальше делать' => 'Проект из найденного источника',
'а бесплатно можно?' => 'Бесплатный тест',
'дубликаты номеров бывают?' => 'Повторы и дубли номеров',
'насколько свежие заявки, сколько времени прошло' => 'Что вы получаете',
];
foreach ($cases as $q => $expected) {
expect(topArticle($q))->toContain($expected);
}
});
it('в инструкции нет выдуманных статусов «Отказ» и «Успешная»', function () {
// Судья 12.07.2026: бот советовал ставить статус «Отказ» — его нет. Пять статусов:
// Новая сделка / Просмотрено / В работе / Сделка / Не реализовано.
$all = KnowledgeChunk::query()->pluck('content')->implode(' ');
expect($all)->not->toContain('статус «Отказ»')
->and($all)->not->toContain('статус «Успешная»');
$deals = KnowledgeChunk::query()
->where('source_path', 'help/sdelki.md')
->orderBy('chunk_index')
->pluck('content')
->implode(' ');
expect($deals)->toContain('«Не реализовано»');
});
it('значение источника менять можно — бот не имеет права говорить «нельзя»', function () {
// Судья 12.07.2026: на «могу ли я поменять сайт в проекте» бот ответил «нельзя,
// заводите новый». Неправда: не меняется только ТИП источника, а сам сайт — меняется.
$hits = app(KnowledgeSearch::class)->search('могу ли я поменять сайт в уже созданном проекте', 3);
$text = collect($hits)->pluck('content')->implode(' ');
expect($hits[0]->title)->toContain('Сменить источник')
->and($text)->toContain('Менять можно значение');
});
it('за повтор номера деньги не берутся, а списание идёт за каждую заявку', function () {
// Судья 12.07.2026: на «списываются ли деньги за дубли» бот ответил, что деньги
// списываются ТОЛЬКО за услуги Конкурентного поля. Это враньё про деньги.
$hits = app(KnowledgeSearch::class)->search('списывается ли деньги за дубли', 3);
expect($hits[0]->title)->toContain('Повторы и дубли номеров');
$article = KnowledgeChunk::query()
->where('source_path', 'help/dubli-nomerov.md')
->orderBy('chunk_index')
->pluck('content')
->implode(' ');
expect($article)->toContain('30 дней')
->and($article)->toContain('дважды за него не заплатите');
});
it('про штрафы бот не даёт гарантий — ответственность на клиенте', function () {
// Судья 12.07.2026: бот категорично обещал «Нет, вас не оштрафуют». Гарантий не даём.
$hits = app(KnowledgeSearch::class)->search('меня оштрафуют если я буду звонить по вашим номерам', 3);
$text = collect($hits)->pluck('content')->implode(' ');
expect($text)->toContain('гарантий по вашей рекламе мы не даём');
});
it('зависшее изучение — это поддержка, а не «не хватило баланса»', function () {
// Судья 12.07.2026: бот выдумал причину «наверное, не хватает денег». При нехватке
// баланса портал вообще не запускает задание и прямо об этом говорит.
$hits = app(KnowledgeSearch::class)->search('изучение висит в очереди уже час', 3);
$text = collect($hits)->pluck('content')->implode(' ');
expect($text)->toContain('висит дольше')
->and($text)->toContain('support@liderra.ru');
});
it('на старте не требует пополнения — подарок уже на балансе', function () {
foreach (['с чего начать?', 'я только зарегистрировался что дальше'] as $q) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
$text = collect($hits)->pluck('content')->implode(' ');
expect($text)->toContain('1000 ₽');
}
});
it('«верните деньги за заявку» — статья про замену находится', function () {
$hits = app(KnowledgeSearch::class)->search('верните деньги за плохую заявку', 3);
expect(collect($hits)->pluck('title')->implode(' | '))->toContain('заменим');
});
it('ночью и после лимита — отвечает по статье, без выдумок', function () {
$hits = app(KnowledgeSearch::class)->search('ночью заявки приходят?', 3);
$text = collect($hits)->pluck('content')->implode(' ');
expect($text)->toContain('Ночью заявки не приходят');
});