2026-06-30 18:03:25 +03:00
< ? php
declare ( strict_types = 1 );
use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier ;
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter ;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler ;
use App\Services\Autopodbor\Agent\Similarity\Embedder ;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance ;
use App\Services\Autopodbor\AutopodborDedup ;
use App\Services\Autopodbor\AutopodborNormalizer ;
function assembler ( array $aggByName , array $vecByNeedle ) : FindCompetitorsAssembler
{
$classifier = new class ( $aggByName ) implements AggregatorClassifier
{
public function __construct ( private array $map ) {}
public function isAggregator ( string $name , ? string $siteUrl , ? string $description ) : ? bool
{
return $this -> map [ $name ] ? ? null ;
}
};
$embedder = new class ( $vecByNeedle ) 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 );
}
};
return new FindCompetitorsAssembler (
new AggregatorFilter ( $classifier ),
new AutopodborDedup ( new AutopodborNormalizer ),
new EmbeddingRelevance ( $embedder ),
);
}
function sampleCandidates () : array
{
return [
[ 'name' => 'Авито' , 'site_url' => 'avito.ru' , 'description' => 'объявления' ], // агрегатор
[ 'name' => 'АвтоДеньги' , 'site_url' => 'avtodengi.ru' , 'description' => 'займ под залог авто' , 'directory_urls' => [ 'https://2gis.ru/firm/1' ], 'is_federal' => false ],
[ 'name' => 'АвтоДеньги' , 'site_url' => 'https://avtodengi.ru/' , 'description' => 'автозайм' , 'directory_urls' => [ 'https://yandex.ru/maps/org/x/2' ], 'is_federal' => false ], // дубль по домену
[ 'name' => 'Окна Комфорт' , 'site_url' => 'okna.ru' , 'description' => 'пластиковые окна' , 'is_federal' => false ], // нерелевантный
[ 'name' => 'Мой Бизнес' , 'site_url' => 'moy-biznes.ru' , 'description' => 'это клиент' ], // сам клиент
[ 'name' => 'CarMoney' , 'site_url' => 'carmoney.ru' , 'description' => 'залог авто онлайн' , 'is_federal' => true ], // федерал
];
}
it ( 'собирает ядро: отсев агрегаторов → слияние/вычет клиента → похожесть → DTO §7.2' , function () {
$a = assembler (
aggByName : [ 'Авито' => true ],
vecByNeedle : [ 'залог авто' => [ 1.0 , 0.0 , 0.0 ], 'пластиковые окна' => [ 0.0 , 1.0 , 0.0 ]],
);
$res = $a -> assemble (
candidates : sampleCandidates (),
clientExamples : [ 'Автоломбард Клиента залог авто' ],
clientKeys : [ 'moy-biznes.ru' ],
includeFederal : false ,
maxCompetitors : 10 ,
);
$names = array_column ( $res -> competitors , 'name' );
expect ( $names ) -> not -> toContain ( 'Авито' ) // агрегатор отсеян
2026-07-01 15:45:11 +03:00
-> toContain ( 'Мой Бизнес' ) // «вычет себя» убран (спека §7) — клиент остаётся
2026-06-30 18:03:25 +03:00
-> not -> toContain ( 'CarMoney' ); // федерал исключён (includeFederal=false)
2026-07-01 15:45:11 +03:00
expect ( $names ) -> toBe ([ 'АвтоДеньги' , 'Окна Комфорт' , 'Мой Бизнес' ]); // клиент внизу (похожесть 0)
2026-06-30 18:03:25 +03:00
$top = $res -> competitors [ 0 ];
expect ( $top [ 'name' ]) -> toBe ( 'АвтоДеньги' );
expect ( $top [ 'directory_urls' ]) -> toContain ( 'https://2gis.ru/firm/1' ) -> toContain ( 'https://yandex.ru/maps/org/x/2' ); // дубль слит
expect ( $top [ 'relevance_pct' ]) -> toBe ( 100 );
expect ( $top [ 'relevance_pct' ]) -> toBeGreaterThan ( $res -> competitors [ 1 ][ 'relevance_pct' ]);
// форма §7.2
expect ( $top ) -> toHaveKeys ([ 'name' , 'description' , 'is_federal' , 'relevance_pct' , 'site_url' , 'directory_urls' , 'provenance' ]);
});
2026-07-01 09:27:26 +03:00
it ( 'дедуп ДО отсева агрегаторов — классификатор видит уникальные фирмы, а не дубли-филиалы' , function () {
// sampleCandidates() содержит «АвтоДеньги» дважды (дубль по домену). Отсев агрегаторов должен
// идти ПОСЛЕ склейки — иначе gpt-4o-mini классифицирует один и тот же дубль по многу раз (реальный
// случай: агрегатору залетело 227 позиций с кучей повторов).
$recorder = new class implements AggregatorClassifier
{
/** @var list<string> */
public array $asked = [];
public function isAggregator ( string $name , ? string $siteUrl , ? string $description ) : ? bool
{
$this -> asked [] = $name ;
return $name === 'Авито' ;
}
};
$zero = new class implements Embedder
{
public function embed ( array $texts ) : array
{
return array_map ( fn () => [ 0.0 ], $texts );
}
};
$a = new FindCompetitorsAssembler (
new AggregatorFilter ( $recorder ),
new AutopodborDedup ( new AutopodborNormalizer ),
new EmbeddingRelevance ( $zero ),
);
$a -> assemble ( sampleCandidates (), [ 'залог авто' ], [ 'moy-biznes.ru' ], true , 10 );
// «АвтоДеньги» (дубль) — спрошен РОВНО один раз (склейка схлопнула до отсева).
expect ( array_count_values ( $recorder -> asked )[ 'АвтоДеньги' ] ? ? 0 ) -> toBe ( 1 );
});
2026-06-30 18:03:25 +03:00
it ( 'includeFederal=true оставляет федерала; maxCompetitors режет хвост' , function () {
$a = assembler (
aggByName : [ 'Авито' => true ],
vecByNeedle : [ 'залог авто' => [ 1.0 , 0.0 , 0.0 ]],
);
$res = $a -> assemble (
candidates : sampleCandidates (),
clientExamples : [ 'залог авто' ],
clientKeys : [ 'moy-biznes.ru' ],
includeFederal : true ,
maxCompetitors : 1 ,
);
expect ( $res -> competitors ) -> toHaveCount ( 1 ); // срез до 1
// CarMoney (залог авто, федерал) теперь допустим как кандидат верхнего уровня
$a2 = assembler ([ 'Авито' => true ], [ 'залог авто' => [ 1.0 , 0.0 , 0.0 ]]);
$full = $a2 -> assemble ( sampleCandidates (), [ 'залог авто' ], [ 'moy-biznes.ru' ], true , 10 );
expect ( array_column ( $full -> competitors , 'name' )) -> toContain ( 'CarMoney' );
});