Files
portal/app/tests/Unit/Autopodbor/ChannelB/ChannelBSearchTest.php
T
Дмитрий 5a65165114 feat(автоподбор): движок шага 1 пересобран под финал v4 (каналы А+В, EXA)
Замена вырожденного «одна фраза → одна страница» на §12/§11.3 финал:
- Шаг АНАЛИЗ (ChannelA\AitunnelQueryAnalyzer): описание → запросы-рубрики (мелкая модель).
- Канал А (ChannelA\CategoryScraper): скрейп категории 2ГИС с пагинацией → резолв карточек.
- Канал В (ChannelB\*): ОДНА модель sonar-reasoning-pro × 2 прохода → ТОЛЬКО имена
  федералов; стоп-лист = имена из А + примеры; сайт федерала через EXA (ExaSiteFinder),
  т.к. у федерала нет карточки в 2ГИС/Яндексе на регион.
- Оркестратор LiveFindCompetitors переписан: АНАЛИЗ→А→В→слияние→отсев→дедуп→похожесть→DTO.
- Провайдер перепрошит; config services.php +research_model/exa.
Похожесть — эмбеддер-модель (математически), резолвер/дедуп — без изменений.
Всё за тонкими границами, офлайн-тесты на фикстурах: модуль 130 unit + 74 feature зелёные.
Провайдер за флагом autopodbor.real_find; на проде не меняется.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 21:06:10 +03:00

85 lines
4.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('системный промт просит ТОЛЬКО названия (§11.3)', function () {
expect(ChannelBSearch::SYSTEM_PROMPT)->toContain('только НАЗВАНИЯ')
->and(ChannelBSearch::SYSTEM_PROMPT)->toContain('строго JSON');
});