Files
portal/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php
T
Дмитрий df83fa836c feat(автоподбор): Конкурентное поле — финальный проход склейки, вскрыть всё, группы дублей, телефоны, отсев рекламных номеров 2ГИС
Шаг 1 «Конкурентное поле» — чекпоинт:
- Финальный проход «Найти и объединить дубли» на поле и предложениях; клиент решает по каждой группе, ничего не склеиваем молча.
- Тихая склейка только по сайту и коду справочника; телефон и людный номер у более чем 4 фирм — на решение клиента.
- Колонка phones jsonb; телефон-дубли видны уже на «Предложениях».
- Sonar даёт только имя и тип, сайт всегда через EXA; вскрываем все карточки без гейта «есть сайт».
- Яндекс-карточки через локальный Playwright параллельно; рубрика из заголовка идёт в описание.
- Фикс рекламных номеров: 2ГИС-парсер разбирает телефоны пообъектно и выкидывает рекламные кнопки с platforms/caption чужих фирм.

Тесты: автоподбор 224/224 unit, 105/105 feature; фронт-спеки зелёные. Времянки app/scripts/diag-*/probe-* в гит не входят.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:34:03 +03:00

110 lines
6.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
use App\Services\Autopodbor\Agent\ChannelB\ResearcherClient;
use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser;
/**
* Канал В, ФИНАЛ владельца: ИИ — генератор ИМЁН федералов/онлайн. На вход ИИ даём СПИСОК ИЗ КАНАЛА А
* (уже найденные в справочниках фирмы) + примеры клиента как «уже известных — не повторять». ОДНА модель
* × 2 прохода, растущий стоп-лист. Выход — только новые ИМЕНА (+ тип). Якоря добывает потом Firecrawl.
* Чистая логика — модель за границей ResearcherClient.
*/
final class FakeResearcher implements ResearcherClient
{
/** @var list<string> */
public array $userPrompts = [];
/** @param list<string> $answers */
public function __construct(private array $answers) {}
public function research(string $system, string $user): string
{
$this->userPrompts[] = $user;
return $this->answers[count($this->userPrompts) - 1] ?? '[]';
}
}
it('2 прохода: копит новые имена без дублей, наращивает стоп-лист, исключает список из А', function () {
$pass1 = '[{"name":"CarMoney","type":"федеральная"},{"name":"Финансовый дом","type":"федеральная"}]';
$pass2 = '[{"name":"Webbankir","type":"федеральная"},{"name":"CarMoney","type":"федеральная"}]'; // дубль
$fake = new FakeResearcher([$pass1, $pass2]);
$search = new ChannelBSearch($fake, new ResearcherParser);
// knownFromA = имена, уже найденные каналом А (справочники)
$out = $search->harvest(
profile: 'займы под залог авто',
region: 'Красноярский край',
clientSite: 'lkomega.ru',
known: ['КрасЛомбард', 'Голд Авто Инвест', 'Ваш инвестор'],
passes: 2,
);
// 3 новых уникальных (CarMoney из прохода 2 — дубль, не добавлен)
expect($out)->toHaveCount(3)
->and(array_column($out, 'name'))->toContain('CarMoney', 'Финансовый дом', 'Webbankir');
expect($fake->userPrompts)->toHaveCount(2);
// Проход 1: стоп-лист несёт список из А
expect($fake->userPrompts[0])->toContain('КрасЛомбард')
->and($fake->userPrompts[0])->toContain('Голд Авто Инвест');
// Проход 2: стоп-лист вырос — несёт найденное в проходе 1
expect($fake->userPrompts[1])->toContain('CarMoney')
->and($fake->userPrompts[1])->toContain('Финансовый дом');
});
it('ИИ не должен повторять имена из канала А (дедуп с known)', function () {
// ИИ вернул фирму, уже найденную каналом А → не добавляем
$fake = new FakeResearcher(['[{"name":"КрасЛомбард","type":"региональная"},{"name":"Cashmotor","type":"федеральная"}]']);
$search = new ChannelBSearch($fake, new ResearcherParser);
$out = $search->harvest('займы', 'Красноярский край', 'lkomega.ru', ['КрасЛомбард'], 1);
expect($out)->toHaveCount(1)->and($out[0]['name'])->toBe('Cashmotor');
});
it('пустой ответ прохода не падает и не добавляет', function () {
$fake = new FakeResearcher(['[{"name":"Cashmotor","type":"федеральная"}]', '[]']);
$search = new ChannelBSearch($fake, new ResearcherParser);
$out = $search->harvest('займы', 'Красноярский край', 'lkomega.ru', [], 2);
expect($out)->toHaveCount(1)->and($out[0]['name'])->toBe('Cashmotor');
});
it('чистый стоп-лист для ИИ: дубли-филиалы схлопнуты — каждое имя один раз', function () {
// Канал А отдал филиалы: «Корунд» ×3 (в т.ч. другой регистр), «КрасЛомбард» ×2.
// В промпт ИИ должно уйти по одному — иначе «- Корунд» повторится 27 раз (реальный случай).
$fake = new FakeResearcher(['[]']);
$search = new ChannelBSearch($fake, new ResearcherParser);
$search->harvest(
profile: 'займы под залог авто',
region: 'Красноярский край',
clientSite: 'lkomega.ru',
known: ['Корунд', 'Корунд', 'корунд', 'КрасЛомбард', 'КрасЛомбард'],
passes: 1,
);
$prompt = $fake->userPrompts[0];
expect(substr_count($prompt, '- Корунд'))->toBe(1)
->and(substr_count($prompt, '- КрасЛомбард'))->toBe(1);
});
it('системный промт: ТОЛЬКО названия+тип федералов, без сайта/карточки, строгий JSON', function () {
expect(ChannelBSearch::SYSTEM_PROMPT)->toContain('ведущих свою деятельность в указанном регионе')
->and(ChannelBSearch::SYSTEM_PROMPT)->toContain('федеральных игроков')
->and(ChannelBSearch::SYSTEM_PROMPT)->toContain('строго JSON')
// форма — только имя+тип; сайт/карточку НЕ просим (их найдёт EXA)
->and(ChannelBSearch::SYSTEM_PROMPT)->toContain('[{"name":"Название","type":"федеральная"}]')
->and(ChannelBSearch::SYSTEM_PROMPT)->not->toContain('"site"')
->and(ChannelBSearch::SYSTEM_PROMPT)->not->toContain('"card"')
->and(ChannelBSearch::SYSTEM_PROMPT)->not->toContain('карточку в 2ГИС');
});