Files
portal/app/tests/Feature/Bot/KnowledgeSearchTest.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

161 lines
9.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\KnowledgeChunk;
use App\Services\Bot\KnowledgeSearch;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
KnowledgeChunk::create([
'source_path' => 'help/p.md', 'title' => 'Что такое проект', 'tour' => 'create-project',
'topics' => 'создать проект, заявка на лиды', 'chunk_index' => 0,
'content' => 'Проект — это заявка на поток клиентов с выбранного источника.',
]);
KnowledgeChunk::create([
'source_path' => 'help/b.md', 'title' => 'Как пополнить баланс', 'tour' => 'top-up-balance',
'topics' => 'пополнить, закинуть деньги, оплата', 'chunk_index' => 0,
'content' => 'Пополнить баланс: раздел Биллинг, кнопка Пополнить.',
]);
});
it('находит релевантный чанк и ранжирует его первым', function () {
$hits = app(KnowledgeSearch::class)->search('а что такое проект?', 3);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->toBe('Что такое проект');
});
it('находит по синонимам из topics («закинуть деньги»)', function () {
$hits = app(KnowledgeSearch::class)->search('как закинуть деньги', 3);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->toBe('Как пополнить баланс');
});
it('находит статью, даже если часть слов вопроса в текстах отсутствует', function () {
// websearch_to_tsquery связывает слова через И: слово «нужен» в статье не встречается,
// строгий запрос даёт пусто — нужен мягкий запасной поиск через ИЛИ (живой баг 12.07.2026).
$hits = app(KnowledgeSearch::class)->search('Что такое проект и зачем он нужен?', 3);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->toBe('Что такое проект');
});
it('на РЕАЛЬНОЙ базе знаний «зачем нужна Лидерра, чем отличается» ведёт к статье о сервисе', function () {
// Живой урок 12.07.2026: бот принял «конкурентов» буквально и рассказал про раздел
// «Конкурентное поле» вместо ценности сервиса. Проверяем на настоящих статьях
// resources/help — в базе знаний обязана быть статья про смысл и выгоду сервиса.
KnowledgeChunk::query()->delete();
$this->artisan('help:rebuild-knowledge')->assertExitCode(0);
$hits = app(KnowledgeSearch::class)->search(
'а зачем лидерра вообще нужна и чем она отличается от конкурентов', 3
);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->not->toContain('Конкурентное поле')
->and(mb_strtolower($hits[0]->title))->toContain('сервис');
});
it('«где вы берёте эти номера» → статья о происхождении данных и согласии абонента', function () {
// Живой урок 12.07.2026 (формулировка владельца): номера передают операторы связи,
// и только по тем абонентам, кто дал согласие на передачу данных третьим лицам
// и на рекламные предложения. Раньше бот эскалировал этот частый вопрос.
KnowledgeChunk::query()->delete();
$this->artisan('help:rebuild-knowledge')->assertExitCode(0);
foreach (['а где вы берёте эти номера?', 'откуда у вас данные людей', 'это законно вообще?'] as $q) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty()
->and(collect($hits)->pluck('content')->implode(' '))->toContain('оператор');
}
});
it('вопросы про дубли ведут в статью о дублях — и она знает про ОБА места', function () {
// Живой урок 12.07.2026: на «а дубли в карточке конкурента?» бот выдумал несуществующую
// чистку внутри карточки. На самом деле кнопка «Найти и объединить дубли» есть в ДВУХ
// местах — в «Поле» и в «Предложениях» — плюс окно «такой источник уже есть».
KnowledgeChunk::query()->delete();
$this->artisan('help:rebuild-knowledge')->assertExitCode(0);
foreach ([
'зачем чистка дублей в конкурентном поле?',
'что такое почистить дубли',
'а дубли в карточке конкурента?',
'как объединить одинаковых конкурентов',
] as $q) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect($hits[0]->title)->toContain('Дубли конкурентов');
}
// Статья режется на куски — смотрим её целиком: в ней обязаны быть ОБА места чистки.
$article = KnowledgeChunk::query()
->where('source_path', 'help/dubli-konkurentov.md')
->orderBy('chunk_index')
->pluck('content')
->implode(' ');
expect($article)->toContain('В «Поле»') // первое место чистки
->and($article)->toContain('В «Предложениях»') // второе место чистки
->and($article)->toContain('уже есть'); // окно «такой источник уже есть»
});
it('«проекты или конкурентное поле — что выбрать» находит ответ, а не «не знаю»', function () {
// Живой урок 12.07.2026: владелец спросил «чем лучше пользоваться, проектами или
// конкурентным полем? по мне одно и то же» — бот честно сдался. Ответ обязан быть.
KnowledgeChunk::query()->delete();
$this->artisan('help:rebuild-knowledge')->assertExitCode(0);
foreach (['что выбрать проекты или конкурентное поле', 'чем лучше пользоваться проектами или полем'] as $q) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect(collect($hits)->pluck('content')->implode(' '))->toContain('Что выбрать');
}
});
it('вопросительные слова не перебивают суть вопроса', function () {
// Массовый прогон 250 вопросов 12.07.2026: «какие у вас тарифы» превращалось в
// «как ИЛИ тариф», и статья, где чаще встречается слово «как» (выгрузка в Excel),
// побеждала статью «Тарифы». Русский словарь не считает «какие/что/это/когда»
// стоп-словами — выбрасываем их сами.
KnowledgeChunk::query()->delete();
Artisan::call('help:rebuild-knowledge');
$cases = [
'какие у вас тарифы' => 'Тарифы',
'когда работает поддержка' => 'Поддержка',
'как импортировать базу клиентов' => 'Импорт данных',
'а что такое конкурентное поле' => 'Конкурентное поле',
'это как баланс ушёл в минус' => 'Баланс',
];
foreach ($cases as $q => $expected) {
$hits = app(KnowledgeSearch::class)->search($q, 3);
expect($hits)->not->toBeEmpty();
expect($hits[0]->title)->toContain($expected);
}
});
it('переживает опечатки в вопросе', function () {
// Массовый прогон 12.07.2026: «каг сазадь праэкт» — поиск пуст, бот сдался.
KnowledgeChunk::query()->delete();
Artisan::call('help:rebuild-knowledge');
$hits = app(KnowledgeSearch::class)->search('каг сазадь праэкт', 3);
expect($hits)->not->toBeEmpty()
->and($hits[0]->title)->toContain('Создать проект');
});
it('на вопрос не по теме возвращает пусто', function () {
expect(app(KnowledgeSearch::class)->search('какая погода в москве', 3))->toBeEmpty();
});
it('не падает на спецсимволах в вопросе', function () {
expect(app(KnowledgeSearch::class)->search('проект & | ! ( )', 3))->toBeArray();
});