Files
portal/app/tests/Unit/Autopodbor/LiveFindCompetitorsTest.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

161 lines
6.8 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Services\Autopodbor\Agent\Aggregator\AggregatorClassifier;
use App\Services\Autopodbor\Agent\Aggregator\AggregatorFilter;
use App\Services\Autopodbor\Agent\ChannelA\CategoryScraper;
use App\Services\Autopodbor\Agent\ChannelA\QueryAnalyzer;
use App\Services\Autopodbor\Agent\ChannelB\ChannelBSearch;
use App\Services\Autopodbor\Agent\ChannelB\ExaSiteFinder;
use App\Services\Autopodbor\Agent\ChannelB\ResearcherClient;
use App\Services\Autopodbor\Agent\ChannelB\ResearcherParser;
use App\Services\Autopodbor\Agent\Dto\FindCompetitorsRequest;
use App\Services\Autopodbor\Agent\FindCompetitorsAssembler;
use App\Services\Autopodbor\Agent\LiveFindCompetitors;
use App\Services\Autopodbor\Agent\Resolve\CompetitorResolver;
use App\Services\Autopodbor\Agent\Search\SearchResultsParser;
use App\Services\Autopodbor\Agent\Similarity\Embedder;
use App\Services\Autopodbor\Agent\Similarity\EmbeddingRelevance;
use App\Services\Autopodbor\AutopodborDedup;
use App\Services\Autopodbor\AutopodborNormalizer;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\Facades\Config;
// Сквозной офлайн-тест ФИНАЛЬНОГО оркестратора (ZAFIKSIROVANO §0-БИС): АНАЛИЗ → канал А (скрейп
// категории 2ГИС + резолв карточек) → канал В (имена федералов → САЙТ через EXA → резолв) → сборка.
// ИИ-границы фейкаем, страницы — stubPages на реальных фикстурах, exa — Http fake.
// stubPages()/autopodborFixture() — глобальные хелперы из tests/Pest.php.
function fakeAnalyzer(array $queries): QueryAnalyzer
{
return new class($queries) implements QueryAnalyzer
{
public function __construct(private array $queries) {}
public function analyze(string $description, string $region): array
{
return $this->queries;
}
};
}
function fakeResearcher(array $answers): ResearcherClient
{
return new class($answers) implements ResearcherClient
{
public function __construct(private array $answers) {}
public function research(string $system, string $user): string
{
static $i = 0;
return $this->answers[$i++] ?? '[]';
}
};
}
function noAiAssembler(): FindCompetitorsAssembler
{
$nullClassifier = new class implements AggregatorClassifier
{
public function isAggregator(string $name, ?string $siteUrl, ?string $description): ?bool
{
return null; // без ИИ — никого не выкидываем
}
};
$zeroEmbedder = new class implements Embedder
{
public function embed(array $texts): array
{
return array_map(fn () => [0.0], $texts);
}
};
return new FindCompetitorsAssembler(
new AggregatorFilter($nullClassifier),
new AutopodborDedup(new AutopodborNormalizer),
new EmbeddingRelevance($zeroEmbedder),
);
}
it('сквозной: канал А (2ГИС-карточка) + канал В (федерал с сайтом из EXA) попадают в результат', function () {
Config::set('services.exa', ['key' => 'k', 'base_url' => 'https://api.exa.ai', 'timeout_sec' => 30]);
$http = app(HttpFactory::class);
$http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]);
$pages = stubPages([
'2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'),
'/firm/' => autopodborFixture('2gis-firm-kraslombard.html'),
// Яндекс по «CarMoney …» вернёт КрасЛомбарда — резолвер отвергнет по несовпадению имени → федерал.
'maps/?text=' => autopodborFixture('yandex-search-kraslombard.html'),
'/maps/org/' => autopodborFixture('yandex-org-kraslombard.html'),
]);
$find = new LiveFindCompetitors(
fakeAnalyzer(['ломбард']),
new CategoryScraper($pages, new SearchResultsParser, maxPages: 2),
new CompetitorResolver($pages),
new ChannelBSearch(fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]', '[]']), new ResearcherParser),
new ExaSiteFinder($http),
noAiAssembler(),
);
$res = $find->find(new FindCompetitorsRequest(
regionCode: 29,
examples: ['automoney-krsk.ru'],
aboutSelf: ['займы под залог авто', 'lkomega.ru'],
includeFederal: true,
maxCompetitors: 20,
));
$names = array_column($res->competitors, 'name');
expect($names)->toContain('КрасЛомбард'); // канал А
$carmoney = collect($res->competitors)->firstWhere('name', 'CarMoney');
expect($carmoney)->not->toBeNull()
->and($carmoney['is_federal'])->toBeTrue()
->and($carmoney['site_url'])->toBe('carmoney.ru'); // сайт из EXA
});
it('includeFederal=false → канал В выключен, остаётся только местная А', function () {
Config::set('services.exa.key', 'k');
$http = app(HttpFactory::class);
$http->fake(['api.exa.ai/*' => $http->response(['results' => [['url' => 'https://carmoney.ru/']]], 200)]);
$pages = stubPages([
'2gis.ru/krasnoyarsk/search/' => autopodborFixture('2gis-search-kraslombard.html'),
'/firm/' => autopodborFixture('2gis-firm-kraslombard.html'),
]);
$find = new LiveFindCompetitors(
fakeAnalyzer(['ломбард']),
new CategoryScraper($pages, new SearchResultsParser, maxPages: 2),
new CompetitorResolver($pages),
new ChannelBSearch(fakeResearcher(['[{"name":"CarMoney","type":"федеральная"}]']), new ResearcherParser),
new ExaSiteFinder($http),
noAiAssembler(),
);
$res = $find->find(new FindCompetitorsRequest(29, [], ['ломбард', 'lkomega.ru'], false, 20));
$names = array_column($res->competitors, 'name');
expect($names)->toContain('КрасЛомбард')
->and($names)->not->toContain('CarMoney');
});
it('пусто везде → пустой список, без падения', function () {
Config::set('services.exa.key', '');
$find = new LiveFindCompetitors(
fakeAnalyzer(['ничего']),
new CategoryScraper(stubPages([]), new SearchResultsParser, maxPages: 2),
new CompetitorResolver(stubPages([])),
new ChannelBSearch(fakeResearcher(['[]', '[]']), new ResearcherParser),
new ExaSiteFinder(app(HttpFactory::class)),
noAiAssembler(),
);
$res = $find->find(new FindCompetitorsRequest(29, [], ['ничего', 'self.ru'], true, 10));
expect($res->competitors)->toBe([]);
});