diff --git a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php index a2ef0804..1504a447 100644 --- a/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php +++ b/app/app/Services/Autopodbor/Agent/LiveFindCompetitors.php @@ -86,9 +86,9 @@ final class LiveFindCompetitors $candidates = array_merge($aCards, $bCards); $clientKeys = $this->stringList($r->aboutSelf); - $examples = $this->stringList($r->examples); + $profileTexts = $this->clientProfileTexts($r); - return $this->assembler->assemble($candidates, $examples, $clientKeys, $r->includeFederal, $r->maxCompetitors); + return $this->assembler->assemble($candidates, $profileTexts, $clientKeys, $r->includeFederal, $r->maxCompetitors); } /** @@ -144,7 +144,7 @@ final class LiveFindCompetitors { foreach ($aboutSelf as $v) { $v = trim((string) $v); - if ($v !== '' && ! str_contains($v, ' ') && str_contains($v, '.')) { + if ($v !== '' && $this->looksLikeDomain($v)) { return $v; } } @@ -152,6 +152,29 @@ final class LiveFindCompetitors return ''; } + /** + * Тексты клиента для ЦЕНТРА похожести (§12.5): описание ниши из «о себе» + примеры-конкуренты. + * Собственный домен клиента (напр. lkomega.ru) в центр НЕ тащим — он строку-имя, а не смысл ниши, + * и смещал бы центр (рычаг чистоты №1: раньше центр строился ТОЛЬКО из доменов-примеров и был «сбит»). + * + * @return list + */ + private function clientProfileTexts(FindCompetitorsRequest $r): array + { + $niche = array_values(array_filter( + $this->stringList($r->aboutSelf), + fn (string $v): bool => ! $this->looksLikeDomain($v), + )); + + return array_values(array_unique(array_merge($niche, $this->stringList($r->examples)))); + } + + /** Похоже на домен: есть точка, нет пробела (например «lkomega.ru», но не «займы под залог»). */ + private function looksLikeDomain(string $v): bool + { + return ! str_contains($v, ' ') && str_contains($v, '.'); + } + /** @return list */ private function stringList(array $items): array { diff --git a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php index 86678d38..33eab5c8 100644 --- a/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php +++ b/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.php @@ -103,6 +103,58 @@ function leanEngine($pages, ResearcherClient $researcher, HttpFactory $http, arr ); } +/** Сборщик с НАСТОЯЩИМ (не нулевым) эмбеддером — чтобы проверить, ЧТО уходит в центр похожести. */ +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); @@ -197,3 +249,44 @@ it('пусто везде → пустой список, без падения', 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); // домен клиента не сместил центр +});