feat(автоподбор): центр похожести из описания ниши, а не только доменов-примеров
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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); // домен клиента не сместил центр
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user