Files
api/services/nginx/app/tests/Unit/Search/SystemSearchServiceIntentFlowTest.php
T

351 lines
12 KiB
PHP

<?php
app_require('interfaces/system_search_intent_parser_i.php');
app_require('classes/system_search_cache.php');
app_require('classes/system_search_openai_intent_parser.php');
app_require('classes/system_search_service.php');
use classes\system_search_cache;
use classes\system_search_service;
use interfaces\system_search_intent_parser_i;
if (!class_exists('SystemSearchTestRedisAdapter')) {
class SystemSearchTestRedisAdapter
{
private array $store = [];
public function get(string $key): mixed
{
return $this->store[$key] ?? null;
}
public function set(string $key, string $value): void
{
$this->store[$key] = $value;
}
public function setEx(string $key, string $value, int $ttl): void
{
$this->store[$key] = $value;
}
public function delete(string $key): void
{
unset($this->store[$key]);
}
public function expire(string $key, int $ttl): void
{
// TTL is not simulated in unit tests.
}
public function set_array(string $key, array $value): void
{
$this->store[$key] = json_encode($value, JSON_UNESCAPED_UNICODE);
}
public function get_array(string $key): ?array
{
$value = $this->store[$key] ?? null;
if (!is_string($value)) {
return null;
}
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : null;
}
public function clear_keys(string $pattern): void
{
$regex = '/^' . str_replace('\*', '.*', preg_quote($pattern, '/')) . '$/';
foreach (array_keys($this->store) as $key) {
if (preg_match($regex, $key)) {
unset($this->store[$key]);
}
}
}
}
}
if (!class_exists('FakeSystemSearchIntentParser')) {
class FakeSystemSearchIntentParser implements system_search_intent_parser_i
{
public int $calls = 0;
/**
* @var array<int, array<string, mixed>>
*/
public array $responses = [];
public function __construct(array $responses = [])
{
$this->responses = $responses;
}
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
{
$this->calls++;
if (empty($this->responses)) {
return [
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => 'no_response',
'source' => 'none',
];
}
return array_shift($this->responses);
}
}
}
if (!class_exists('TestableSystemSearchService')) {
class TestableSystemSearchService extends system_search_service
{
/**
* @var array<int, array<string, mixed>>
*/
public array $lexicalCalls = [];
/**
* @var array<int, array<int, array<string, mixed>>>
*/
private array $queuedLexicalResults;
public function __construct(system_search_intent_parser_i $intentParser, array $queuedLexicalResults)
{
$this->queuedLexicalResults = $queuedLexicalResults;
parent::__construct($intentParser);
}
protected function executeLexicalSearch(
array $activeTypes,
array $terms,
array $entityBoost,
array $ownOnlyTypes,
?int $ownCustomerNumber,
array $permissionsCatalogAll,
array $permissionsCatalogOwn,
array $moduleConfigVisibility,
array $forcedCustomerNumbers = []
): array {
$this->lexicalCalls[] = [
'activeTypes' => $activeTypes,
'terms' => $terms,
'entityBoost' => $entityBoost,
'ownOnlyTypes' => $ownOnlyTypes,
'ownCustomerNumber' => $ownCustomerNumber,
'forcedCustomerNumbers' => $forcedCustomerNumbers,
];
if (empty($this->queuedLexicalResults)) {
return [];
}
return array_shift($this->queuedLexicalResults);
}
}
}
beforeEach(function (): void {
system_search_cache::setAdapterForTests(null);
});
it('does not invoke intent parser when lexical confidence is already high', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => 'ignored',
'aliases' => ['ignored'],
'entity_hints' => ['orders'],
'confidence' => 0.99,
'association_hint' => false,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [[
['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'Order #1', 'score' => 95],
['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'Order #2', 'score' => 90],
['entity_type' => 'orders', 'entity_id' => '3', 'title' => 'Order #3', 'score' => 88],
['entity_type' => 'orders', 'entity_id' => '4', 'title' => 'Order #4', 'score' => 84],
['entity_type' => 'orders', 'entity_id' => '5', 'title' => 'Order #5', 'score' => 80],
]]);
$result = $service->search([
'query' => 'order 1',
'allowed_types' => ['orders'],
'include_associations' => false,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(0);
expect($result['meta']['intent_parser']['status'])->toBe('skipped');
expect(count($service->lexicalCalls))->toBe(1);
});
it('invokes parser on low-confidence lexical results and applies hints-only boosts', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => 'acme invoices',
'aliases' => ['acme corp', 'invoice overdue'],
'entity_hints' => ['orders'],
'confidence' => 0.87,
'association_hint' => true,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 20]],
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'strong', 'score' => 85]],
]);
$result = $service->search([
'query' => 'acm inv',
'allowed_types' => ['orders', 'customers'],
'include_associations' => false,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(1);
expect(count($service->lexicalCalls))->toBe(2);
expect($service->lexicalCalls[1]['entityBoost']['orders'] ?? 0)->toBe(25);
expect(implode(' ', $service->lexicalCalls[1]['terms']))->toContain('acme');
expect($result['meta']['intent_parser']['status'])->toBe('ok');
expect($result['meta']['intent_parser']['source'])->toBe('openai');
});
it('never lets parser entity hints override explicit include filters', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => 'customer acme',
'aliases' => ['acme'],
'entity_hints' => ['orders'],
'confidence' => 0.7,
'association_hint' => true,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'customers', 'entity_id' => '55', 'title' => 'Acme', 'score' => 10]],
[['entity_type' => 'customers', 'entity_id' => '55', 'title' => 'Acme', 'score' => 90]],
]);
$result = $service->search([
'query' => 'acm',
'include_types' => ['customers'],
'allowed_types' => ['customers', 'orders'],
'include_associations' => false,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(1);
expect($service->lexicalCalls[0]['activeTypes'])->toBe(['customers']);
expect($service->lexicalCalls[1]['activeTypes'])->toBe(['customers']);
expect($result['results'][0]['entity_type'])->toBe('customers');
});
it('returns fallback parser metadata when parser fails gracefully', function (): void {
$parser = new FakeSystemSearchIntentParser([
[
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => 'openai_disabled',
'source' => 'none',
],
]);
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 5]],
]);
$result = $service->search([
'query' => 'unknown phrase',
'allowed_types' => ['orders'],
'include_associations' => false,
'debug_intent' => true,
]);
expect($parser->calls)->toBe(1);
expect($result['meta']['intent_parser']['status'])->toBe('fallback');
expect($result['meta']['intent_parser']['fallback_reason'])->toBe('openai_disabled');
});
it('scopes query cache by permission context to avoid cross-user cache leakage', function (): void {
system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter());
$parser = new FakeSystemSearchIntentParser();
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'first', 'score' => 95]],
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'second', 'score' => 96]],
]);
$first = $service->search([
'query' => 'acme',
'allowed_types' => ['orders'],
'include_associations' => false,
'permissions_catalog_own' => ['list_orders'],
'module_config_visibility' => ['openAI' => true],
]);
$second = $service->search([
'query' => 'acme',
'allowed_types' => ['orders'],
'include_associations' => false,
'permissions_catalog_own' => ['list_own_orders'],
'module_config_visibility' => ['openAI' => false],
]);
expect(count($service->lexicalCalls))->toBe(2);
expect($first['results'][0]['entity_id'])->toBe('1');
expect($second['results'][0]['entity_id'])->toBe('2');
});
it('caps AI-driven expanded terms to prevent query amplification', function (): void {
$aliases = [];
for ($i = 0; $i < 80; $i++) {
$aliases[] = 'alias_' . $i . '_' . str_repeat('x', 90);
}
$parser = new FakeSystemSearchIntentParser([
[
'success' => true,
'normalized_query' => str_repeat('n', 500),
'aliases' => $aliases,
'entity_hints' => ['orders'],
'confidence' => 0.8,
'association_hint' => false,
'fallback_reason' => null,
'source' => 'openai',
],
]);
$service = new TestableSystemSearchService($parser, [
[['entity_type' => 'orders', 'entity_id' => '1', 'title' => 'weak', 'score' => 5]],
[['entity_type' => 'orders', 'entity_id' => '2', 'title' => 'strong', 'score' => 80]],
]);
$service->search([
'query' => 'a b',
'allowed_types' => ['orders'],
'include_associations' => false,
'debug_intent' => true,
]);
$expanded = $service->lexicalCalls[1]['terms'] ?? [];
expect(count($expanded))->toBeLessThanOrEqual(24);
$maxLen = 0;
foreach ($expanded as $term) {
$maxLen = max($maxLen, mb_strlen((string)$term));
}
expect($maxLen)->toBeLessThanOrEqual(64);
});