225 lines
7.8 KiB
PHP
225 lines
7.8 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('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');
|
|
});
|