feat(автоподбор): центр похожести из описания ниши, а не только доменов-примеров

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-01 15:44:43 +03:00
parent 68341e5576
commit fc4e4bacbf
2 changed files with 119 additions and 3 deletions
@@ -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<string>
*/
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<string> */
private function stringList(array $items): array
{
@@ -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); // домен клиента не сместил центр
});