Files
portal/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php
T
Дмитрий 6e970d7231 fix(автоподбор): канал В получает чистый стоп-лист — дубли-филиалы схлопнуты
Стоп-лист известных для ИИ канала В дедуплицировался только в проверке
видели/не видели, а в промпт уходил с повторами (Корунд ×27, КрасЛомбард ×19).
Теперь чистим и сам список по ключу имени — ИИ видит каждое имя один раз:
короче, дешевле по токенам, точнее. TDD, 137/137 unit зелёные.

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

104 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
use App\Services\Autopodbor\Agent\ChannelB\ResearcherClient;
use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser;
/**
* Канал В, ФИНАЛ владельца: ИИ — генератор ИМЁН федералов/онлайн. На вход ИИ даём СПИСОК ИЗ КАНАЛА А
* (уже найденные в справочниках фирмы) + примеры клиента как «уже известных — не повторять». ОДНА модель
* × 2 прохода, растущий стоп-лист. Выход — только новые ИМЕНА (+ тип). Якоря добывает потом Firecrawl.
* Чистая логика — модель за границей ResearcherClient.
*/
final class FakeResearcher implements ResearcherClient
{
/** @var list<string> */
public array $userPrompts = [];
/** @param list<string> $answers */
public function __construct(private array $answers) {}
public function research(string $system, string $user): string
{
$this->userPrompts[] = $user;
return $this->answers[count($this->userPrompts) - 1] ?? '[]';
}
}
it('2 прохода: копит новые имена без дублей, наращивает стоп-лист, исключает список из А', function () {
$pass1 = '[{"name":"CarMoney","type":"федеральная"},{"name":"Финансовый дом","type":"федеральная"}]';
$pass2 = '[{"name":"Webbankir","type":"федеральная"},{"name":"CarMoney","type":"федеральная"}]'; // дубль
$fake = new FakeResearcher([$pass1, $pass2]);
$search = new ChannelBSearch($fake, new ResearcherParser);
// knownFromA = имена, уже найденные каналом А (справочники)
$out = $search->harvest(
profile: 'займы под залог авто',
region: 'Красноярский край',
clientSite: 'lkomega.ru',
known: ['КрасЛомбард', 'Голд Авто Инвест', 'Ваш инвестор'],
passes: 2,
);
// 3 новых уникальных (CarMoney из прохода 2 — дубль, не добавлен)
expect($out)->toHaveCount(3)
->and(array_column($out, 'name'))->toContain('CarMoney', 'Финансовый дом', 'Webbankir');
expect($fake->userPrompts)->toHaveCount(2);
// Проход 1: стоп-лист несёт список из А
expect($fake->userPrompts[0])->toContain('КрасЛомбард')
->and($fake->userPrompts[0])->toContain('Голд Авто Инвест');
// Проход 2: стоп-лист вырос — несёт найденное в проходе 1
expect($fake->userPrompts[1])->toContain('CarMoney')
->and($fake->userPrompts[1])->toContain('Финансовый дом');
});
it('ИИ не должен повторять имена из канала А (дедуп с known)', function () {
// ИИ вернул фирму, уже найденную каналом А → не добавляем
$fake = new FakeResearcher(['[{"name":"КрасЛомбард","type":"региональная"},{"name":"Cashmotor","type":"федеральная"}]']);
$search = new ChannelBSearch($fake, new ResearcherParser);
$out = $search->harvest('займы', 'Красноярский край', 'lkomega.ru', ['КрасЛомбард'], 1);
expect($out)->toHaveCount(1)->and($out[0]['name'])->toBe('Cashmotor');
});
it('пустой ответ прохода не падает и не добавляет', function () {
$fake = new FakeResearcher(['[{"name":"Cashmotor","type":"федеральная"}]', '[]']);
$search = new ChannelBSearch($fake, new ResearcherParser);
$out = $search->harvest('займы', 'Красноярский край', 'lkomega.ru', [], 2);
expect($out)->toHaveCount(1)->and($out[0]['name'])->toBe('Cashmotor');
});
it('чистый стоп-лист для ИИ: дубли-филиалы схлопнуты — каждое имя один раз', function () {
// Канал А отдал филиалы: «Корунд» ×3 (в т.ч. другой регистр), «КрасЛомбард» ×2.
// В промпт ИИ должно уйти по одному — иначе «- Корунд» повторится 27 раз (реальный случай).
$fake = new FakeResearcher(['[]']);
$search = new ChannelBSearch($fake, new ResearcherParser);
$search->harvest(
profile: 'займы под залог авто',
region: 'Красноярский край',
clientSite: 'lkomega.ru',
known: ['Корунд', 'Корунд', 'корунд', 'КрасЛомбард', 'КрасЛомбард'],
passes: 1,
);
$prompt = $fake->userPrompts[0];
expect(substr_count($prompt, '- Корунд'))->toBe(1)
->and(substr_count($prompt, '- КрасЛомбард'))->toBe(1);
});
it('системный промт просит ТОЛЬКО названия (§11.3)', function () {
expect(ChannelBSearch::SYSTEM_PROMPT)->toContain('только НАЗВАНИЯ')
->and(ChannelBSearch::SYSTEM_PROMPT)->toContain('строго JSON');
});