5a65165114
Замена вырожденного «одна фраза → одна страница» на §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>
161 lines
6.8 KiB
PHP
161 lines
6.8 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\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([]);
|
||
});
|