b900874a72
EmbeddingRelevance: профиль клиента (примеры имя+описание) → центроид; кандидат (имя+описание) → косинус → relevance_pct [0..100]; сортировка по убыванию (§12.5). Векторы — за границей Embedder (живой AITUNNEL text-embedding-3-small подключается на блоке H/живом прогоне, ключ по §12.9). Вся логика ранжирования протестирована офлайн на детерминированном эмбеддере. Тесты: similarity 3/3; модуль Автоподбора unit 84/84; Pint чисто. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
75 lines
3.2 KiB
PHP
75 lines
3.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Services\Autopodbor\Agent\Similarity\Embedder;
|
||
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
|
||
|
||
/** Детерминированный эмбеддер для офлайн-теста: текст → заранее заданный вектор (по подстроке). */
|
||
function fakeEmbedder(array $byNeedle): Embedder
|
||
{
|
||
return new class($byNeedle) implements Embedder
|
||
{
|
||
/** @param array<string,array<int,float>> $map */
|
||
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);
|
||
}
|
||
};
|
||
}
|
||
|
||
it('ранжирует кандидатов по близости к профилю клиента (косинус)', function () {
|
||
// профиль клиента — «автоломбард / займ под залог авто»
|
||
$embedder = fakeEmbedder([
|
||
'залог авто' => [1.0, 0.0, 0.0], // клиентский профиль и близкие к нему
|
||
'автозайм' => [0.9, 0.1, 0.0],
|
||
'пластиковые окна' => [0.0, 1.0, 0.0], // совсем другое
|
||
]);
|
||
$rel = new EmbeddingRelevance($embedder);
|
||
|
||
$ranked = $rel->rank(
|
||
clientExamples: ['Автоломбард Клиента залог авто'],
|
||
candidates: [
|
||
['name' => 'Окна Комфорт', 'description' => 'пластиковые окна и балконы'],
|
||
['name' => 'АвтоДеньги', 'description' => 'автозайм под ПТС'],
|
||
['name' => 'ЗалогЦентр', 'description' => 'займ под залог авто'],
|
||
],
|
||
);
|
||
|
||
// ближайший — «займ под залог авто» (точное совпадение профиля), затем «автозайм», окна — последними
|
||
expect(array_column($ranked, 'name'))->toBe(['ЗалогЦентр', 'АвтоДеньги', 'Окна Комфорт']);
|
||
expect($ranked[0]['relevance_pct'])->toBe(100);
|
||
expect($ranked[2]['relevance_pct'])->toBe(0);
|
||
foreach ($ranked as $c) {
|
||
expect($c['relevance_pct'])->toBeGreaterThanOrEqual(0)->toBeLessThanOrEqual(100);
|
||
}
|
||
});
|
||
|
||
it('без профиля клиента похожесть 0, порядок сохраняется', function () {
|
||
$rel = new EmbeddingRelevance(fakeEmbedder([]));
|
||
|
||
$ranked = $rel->rank(clientExamples: [], candidates: [
|
||
['name' => 'А', 'description' => 'x'],
|
||
['name' => 'Б', 'description' => 'y'],
|
||
]);
|
||
|
||
expect($ranked)->toHaveCount(2);
|
||
expect($ranked[0]['relevance_pct'])->toBe(0);
|
||
expect($ranked[1]['relevance_pct'])->toBe(0);
|
||
});
|
||
|
||
it('пустой список кандидатов → пустой результат', function () {
|
||
$rel = new EmbeddingRelevance(fakeEmbedder([]));
|
||
expect($rel->rank(['что-то'], []))->toBe([]);
|
||
});
|