68341e5576
Отсев агрегаторов шёл по СЫРЬЮ до склейки — gpt-4o-mini залетало 227 позиций с дублями (Ломбардико ×6). Порядок в FindCompetitorsAssembler: сначала склейка/дедуп, потом отсев — классифицируем ~90 уникальных, а не 227. Плюс файл состояния сессии (что сделано/осталось) + промт восстановления контекста для компакта. TDD, 234/234 зелёные. НЕ прод. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
6.7 KiB
PHP
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');
|
|
});
|