queries; } }; } function fakeResearcher(array $answers): ResearcherClient { return new class($answers) implements ResearcherClient { public function __construct(private array $answers) {} public function research(string $system, string $user): string { static $i = 0; return $this->answers[$i++] ?? '[]'; } }; } function noAiAssembler(): FindCompetitorsAssembler { $nullClassifier = new class implements AggregatorClassifier { public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool { return null; } }; $zeroEmbedder = new class implements Embedder { public function embed(array $texts): array { return array_map(fn () => [0.0], $texts); } }; return new FindCompetitorsAssembler( new AggregatorFilter($nullClassifier), new AutopodborDedup(new AutopodborNormalizer), new EmbeddingRelevance($zeroEmbedder), ); } function fakeYandex(array $rows): YandexDirectory { return new class($rows) implements YandexDirectory { public function __construct(private array $rows) {} public function collect(string $city, array $queries): array { return $this->rows; } }; } function leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, array $queries, array $yandexRows = []): LiveFindCompetitors { return new LiveFindCompetitors( fakeAnalyzer($queries), new CategoryScraper($pages, new CategoryListingParser, maxPages: 2), fakeYandex($yandexRows), new ChannelBSearch($researcher, new ResearcherParser), new ExaSiteFinder($http), noAiAssembler(), ); } /** Сборщик с НАСТОЯЩИМ (не нулевым) эмбеддером — чтобы проверить, ЧТО уходит в центр похожести. */ function embedAssembler(Embedder $embedder): FindCompetitorsAssembler { $nullClassifier = new class implements AggregatorClassifier { public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool { return null; } }; return new FindCompetitorsAssembler( new AggregatorFilter($nullClassifier), new AutopodborDedup(new AutopodborNormalizer), new EmbeddingRelevance($embedder), ); } /** Детерминированный эмбеддер по подстроке (нет совпадения → нулевой вектор). */ function needleEmbedder(array $byNeedle): Embedder { return new class($byNeedle) implements Embedder { public function __construct(private array $map) {} public function embed(array $texts): array { return array_map(function (string $t): array { foreach ($this->map as $needle => $vec) { if (str_contains($t, $needle)) { return $vec; } } return [0.0, 0.0, 0.0]; }, $texts); } }; } function leanEngineEmbed($pages, ResearcherClient $researcher, HttpFactory $http, array $queries, array $yandexRows, Embedder $embedder): LiveFindCompetitors { return new LiveFindCompetitors( fakeAnalyzer($queries), new CategoryScraper($pages, new CategoryListingParser, maxPages: 2), fakeYandex($yandexRows), new ChannelBSearch($researcher, new ResearcherParser), new ExaSiteFinder($http), embedAssembler($embedder), ); } it('сквозной: канал А (2ГИС-список: имя+карточка+сайт) + канал В (федерал с сайтом EXA)', function () { Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30]); $http = app(HttpFactory::class); $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]); $pages = stubPages([ '2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']]), ]); $res = leanEngine($pages, fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]', '[]']), $http, ['ломбард']) ->find(new FindCompetitorsRequest( regionCode: 29, examples: ['automoney-krsk.ru'], aboutSelf: ['займы под залог авто', 'lkomega.ru'], includeFederal: true, maxCompetitors: 20, )); $kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард'); expect($kl)->not->toBeNull() ->and($kl['site_url'])->toBe('kraslombard24.ru') ->and($kl['is_federal'])->toBeFalse() ->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, '/firm/985690700540003')))->toBeTrue(); $cm = collect($res->competitors)->firstWhere('name', 'CarMoney'); expect($cm)->not->toBeNull() ->and($cm['is_federal'])->toBeTrue() ->and($cm['site_url'])->toBe('carmoney.ru'); }); it('слияние 2ГИС+Яндекс: один конкурент в обоих справочниках → одна карточка с двумя ссылками', function () { Config::set('services.exa.key', ''); $http = app(HttpFactory::class); $pages = stubPages([ '2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']]), ]); // Яндекс дал того же КрасЛомбарда (без сайта) + новую фирму, которой нет в 2ГИС $yandex = [ ['name' => 'КрасЛомбард', 'card_url' => 'https://yandex.ru/maps/org/kraslombard/175852236692'], ['name' => 'ЯрКомиссионка', 'card_url' => 'https://yandex.ru/maps/org/yarkomissionka/226908207223'], ]; $res = leanEngine($pages, fakeResearcher(['[]']), $http, ['ломбард'], $yandex) ->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20)); $names = array_column($res->competitors, 'name'); // КрасЛомбард — один раз (2ГИС+Яндекс слиты), плюс новая из Яндекса expect(array_count_values($names)['КрасЛомбард'])->toBe(1) ->and($names)->toContain('ЯрКомиссионка'); // у слитого КрасЛомбарда — обе ссылки-справочники и сайт из 2ГИС $kl = collect($res->competitors)->firstWhere('name', 'КрасЛомбард'); expect($kl['site_url'])->toBe('kraslombard24.ru') ->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, '2gis.ru')))->toBeTrue() ->and(collect($kl['directory_urls'])->contains(fn ($u) => str_contains($u, 'yandex.ru')))->toBeTrue(); }); it('канал В: имя без сайта в EXA выкидывается (нет якоря)', function () { Config::set('services.exa.key', 'k'); $http = app(HttpFactory::class); // EXA не нашла подходящего сайта (только агрегатор) → site=null → имя выкинуто $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://zoon.ru/x']]], 200)]); $pages = stubPages(['2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']])]); $res = leanEngine($pages, fakeResearcher(['[{"name":"Призрак","type":"федеральная"}]', '[]']), $http, ['ломбард']) ->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], true, 20)); $names = array_column($res->competitors, 'name'); expect($names)->toContain('КрасЛомбард')->and($names)->not->toContain('Призрак'); }); it('includeFederal=false → канал В выключен, остаётся местная А', function () { Config::set('services.exa.key', 'k'); $http = app(HttpFactory::class); $http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]); $pages = stubPages(['2gis.ru/krasnoyarsk/search/' => listingHtml([[985690700540003, 'КрасЛомбард', 'kraslombard24.ru']])]); $res = leanEngine($pages, fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]']), $http, ['ломбард']) ->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20)); $names = array_column($res->competitors, 'name'); expect($names)->toContain('КрасЛомбард')->and($names)->not->toContain('CarMoney'); }); it('пусто везде → пустой список, без падения', function () { Config::set('services.exa.key', ''); $res = leanEngine(stubPages([]), fakeResearcher(['[]', '[]']), app(HttpFactory::class), ['ничего']) ->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], true, 10)); expect($res->competitors)->toBe([]); }); it('центр похожести берёт описание ниши (а не только домены-примеры)', function () { Config::set('services.exa.key', ''); $http = app(HttpFactory::class); // Два конкурента из Яндекса, с рубрикой-описанием. Один — по нише клиента, другой — нет. $yandex = [ ['name' => 'ЗалогЦентр', 'card_url' => 'https://yandex.ru/maps/org/zalogcentr/1', 'description' => 'займ под залог авто'], ['name' => 'Окна Комфорт', 'card_url' => 'https://yandex.ru/maps/org/okna/2', 'description' => 'пластиковые окна'], ]; // Ниша «займы под залог авто» ≡ вектор [1,0,0]; окна — ортогональны. $embedder = needleEmbedder(['залог авто' => [1.0, 0.0, 0.0], 'пластиковые окна' => [0.0, 1.0, 0.0]]); // examples ПУСТ — на старом коде центр был бы пуст → все 0%. Ниша в «о себе» должна спасти. $res = leanEngineEmbed(stubPages([]), fakeResearcher(['[]']), $http, ['ломбард'], $yandex, $embedder) ->find(new FindCompetitorsRequest(29, [], ['займы под залог авто', 'lkomega.ru'], false, 20)); expect(array_column($res->competitors, 'name'))->toBe(['ЗалогЦентр', 'Окна Комфорт']); $zc = collect($res->competitors)->firstWhere('name', 'ЗалогЦентр'); $ok = collect($res->competitors)->firstWhere('name', 'Окна Комфорт'); expect($zc['relevance_pct'])->toBe(100) // по нише — попадание ->and($ok['relevance_pct'])->toBe(0); // не по нише — мимо }); it('свой домен клиента в «о себе» НЕ тащится в центр (иначе центр смещён)', function () { Config::set('services.exa.key', ''); $http = app(HttpFactory::class); $yandex = [ ['name' => 'ЗалогЦентр', 'card_url' => 'https://yandex.ru/maps/org/zalogcentr/1', 'description' => 'займ под залог авто'], ]; // Ниша ≡ [1,0,0]; домен клиента lkomega.ru ≡ [0,1,0] (ортогонален). Если домен попадёт в центр — // центр станет [0.5,0.5,0], косинус к кандидату [1,0,0] упадёт до ~71%. Значит домен вычитаем. $embedder = needleEmbedder(['залог авто' => [1.0, 0.0, 0.0], 'lkomega.ru' => [0.0, 1.0, 0.0]]); $res = leanEngineEmbed(stubPages([]), fakeResearcher(['[]']), $http, ['ломбард'], $yandex, $embedder) ->find(new FindCompetitorsRequest(29, [], ['займы под залог авто', 'lkomega.ru'], false, 20)); $zc = collect($res->competitors)->firstWhere('name', 'ЗалогЦентр'); expect($zc['relevance_pct'])->toBe(100); // домен клиента не сместил центр });