Files
portal/app/tests/Unit/Autopodbor/FindCompetitorsAssemblerTest.php
T
Дмитрий 68341e5576 fix(автоподбор): дедуп ДО отсева агрегаторов + файл состояния/промт продолжения
Отсев агрегаторов шёл по СЫРЬЮ до склейки — gpt-4o-mini залетало 227 позиций
с дублями (Ломбардико ×6). Порядок в FindCompetitorsAssembler: сначала
склейка/дедуп, потом отсев — классифицируем ~90 уникальных, а не 227.

Плюс файл состояния сессии (что сделано/осталось) + промт восстановления
контекста для компакта. TDD, 234/234 зелёные. НЕ прод.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 09:27:26 +03:00

146 lines
6.7 KiB
PHP

<?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('Авито') // агрегатор отсеян
->not->toContain('Мой Бизнес') // клиент вычтен
->not->toContain('CarMoney'); // федерал исключён (includeFederal=false)
expect($names)->toBe(['АвтоДеньги', 'Окна Комфорт']); // отсортированы по похожести
$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']);
});
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);
});
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');
});