c5125e981e
Прогнал 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>
161 lines
9.3 KiB
PHP
161 lines
9.3 KiB
PHP
<?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();
|
||
});
|