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

78 lines
3.9 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\KnowledgeChunk;
use App\Services\Bot\BotAnswerService;
use App\Services\Bot\ChatTextCleaner;
use App\Services\Bot\KnowledgeSearch;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
/**
* Слепое сравнение моделей 12.07.2026: Gemini выиграл у Яндекса 62:36, но судьи нашли
* пять болезней — враньё про платный повтор сбора, цены по памяти, «приглашу специалиста»,
* гарантии и markdown-разметка в чате.
*/
beforeEach(function () {
config()->set('services.yandexgpt', [
'api_key' => 'k', 'folder_id' => 'f', 'model' => 'yandexgpt-lite/latest',
'endpoint' => 'https://llm.api.cloud.yandex.net/foundationModels/v1/completion',
'timeout_seconds' => 8,
]);
KnowledgeChunk::query()->delete();
Artisan::call('help:rebuild-knowledge');
});
it('в чат не уходит markdown-разметка — Jivo её не рисует', function () {
$cleaner = app(ChatTextCleaner::class);
expect($cleaner->clean('**Важно:** пишите на `support@liderra.ru`'))
->toBe('Важно: пишите на support@liderra.ru')
->and($cleaner->clean("Причины:\n* первая\n* вторая"))
->toBe("Причины:\n— первая\n— вторая")
->and($cleaner->clean('### Заголовок'))->toBe('Заголовок');
});
it('ответ бота приходит клиенту без звёздочек и кавычек', function () {
Http::fake(['*' => Http::response([
'result' => ['alternatives' => [['message' => ['text' => '**Списания** видно в разделе `Биллинг`.']]]],
], 200)]);
$answer = app(BotAnswerService::class)->answer('где посмотреть списания');
expect($answer->text)->not->toContain('**')
->and($answer->text)->not->toContain('`')
->and($answer->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('даже если ничего не нашлось');
});
it('правила для модели запрещают гарантии, цены по памяти и «приглашу специалиста»', function () {
// Правила живут в системном промпте — проверяем, что они там есть и доезжают до модели.
Http::fake(['*' => Http::response([
'result' => ['alternatives' => [['message' => ['text' => 'Ответ.']]]],
], 200)]);
app(BotAnswerService::class)->answer('сколько стоит заявка');
Http::assertSent(function ($request) {
$system = (string) ($request->data()['messages'][0]['text'] ?? '');
return str_contains($system, 'Никогда не называй цены по памяти')
&& str_contains($system, 'Не обещай «приглашу специалиста»')
&& str_contains($system, 'Не давай гарантий')
&& str_contains($system, 'Без markdown');
});
});