242 lines
8.4 KiB
PHP
242 lines
8.4 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');
|
|
|
|
use classes\system_search_cache;
|
|
use classes\system_search_openai_intent_parser;
|
|
|
|
if (!class_exists('SystemSearchTestRedisAdapter')) {
|
|
class SystemSearchTestRedisAdapter
|
|
{
|
|
private array $store = [];
|
|
|
|
public function reset(): void
|
|
{
|
|
$this->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]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
beforeEach(function (): void {
|
|
system_search_cache::setAdapterForTests(new SystemSearchTestRedisAdapter());
|
|
});
|
|
|
|
afterEach(function (): void {
|
|
system_search_cache::setAdapterForTests(null);
|
|
});
|
|
|
|
it('redacts obvious sensitive fragments before sending query to intent parser', function (): void {
|
|
$query = 'Contact alice@example.com at +45 12 34 56 78, cvr 12345678, order 987654, reg AB12345, id 550e8400-e29b-41d4-a716-446655440000';
|
|
$redacted = system_search_openai_intent_parser::redactSensitiveQuery($query);
|
|
|
|
expect($redacted)->toContain('[email]');
|
|
expect($redacted)->toContain('[phone]');
|
|
expect($redacted)->toContain('[cvr]');
|
|
expect($redacted)->toContain('order [id]');
|
|
expect($redacted)->toContain('[plate]');
|
|
expect($redacted)->toContain('[uuid]');
|
|
expect($redacted)->not->toContain('alice@example.com');
|
|
expect($redacted)->not->toContain('12345678');
|
|
expect($redacted)->not->toContain('987654');
|
|
expect($redacted)->not->toContain('AB12345');
|
|
});
|
|
|
|
it('builds payload with redacted query and parses strict JSON output', function (): void {
|
|
$capturedPrompt = '';
|
|
$parser = new system_search_openai_intent_parser(
|
|
function (array $payload, string $apiKey) use (&$capturedPrompt): array {
|
|
$capturedPrompt = (string)($payload['input'][0]['content'][0]['text'] ?? '');
|
|
return [
|
|
'output' => [
|
|
[
|
|
'content' => [
|
|
[
|
|
'text' => json_encode([
|
|
'success' => true,
|
|
'normalized_query' => 'acme unpaid invoices',
|
|
'aliases' => ['acme', 'invoice overdue'],
|
|
'entity_hints' => ['customers', 'invoices'],
|
|
'confidence' => 0.93,
|
|
'association_hint' => true,
|
|
], JSON_UNESCAPED_UNICODE),
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
},
|
|
true,
|
|
'test-key'
|
|
);
|
|
|
|
$result = $parser->parse(
|
|
'find unpaid invoices for alice@example.com',
|
|
['customers', 'invoices'],
|
|
['customers' => ['account'], 'invoices' => ['billing']]
|
|
);
|
|
|
|
expect($capturedPrompt)->toContain('[email]');
|
|
expect($capturedPrompt)->not->toContain('alice@example.com');
|
|
expect($result['success'])->toBeTrue();
|
|
expect($result['source'])->toBe('openai');
|
|
expect($result['confidence'])->toBe(0.93);
|
|
expect($result['entity_hints'])->toBe(['customers', 'invoices']);
|
|
});
|
|
|
|
it('falls back safely when OpenAI is disabled', function (): void {
|
|
$parser = new system_search_openai_intent_parser(null, false, null);
|
|
$result = $parser->parse('find acme invoices', ['customers', 'invoices']);
|
|
|
|
expect($result['success'])->toBeFalse();
|
|
expect($result['source'])->toBe('none');
|
|
expect($result['fallback_reason'])->toBe('openai_disabled');
|
|
});
|
|
|
|
it('handles malformed OpenAI response payloads without throwing', function (): void {
|
|
$parser = new system_search_openai_intent_parser(
|
|
function (array $payload, string $apiKey): array {
|
|
return ['output' => []];
|
|
},
|
|
true,
|
|
'test-key'
|
|
);
|
|
|
|
$result = $parser->parse('find acme invoices', ['invoices']);
|
|
|
|
expect($result['success'])->toBeFalse();
|
|
expect($result['source'])->toBe('openai');
|
|
expect((string)$result['fallback_reason'])->toContain('Invalid response format');
|
|
});
|
|
|
|
it('uses parser cache for identical query and allowed type combinations', function (): void {
|
|
$calls = 0;
|
|
$parser = new system_search_openai_intent_parser(
|
|
function (array $payload, string $apiKey) use (&$calls): array {
|
|
$calls++;
|
|
return [
|
|
'output' => [
|
|
[
|
|
'content' => [
|
|
[
|
|
'text' => json_encode([
|
|
'success' => true,
|
|
'normalized_query' => 'acme invoices',
|
|
'aliases' => ['acme'],
|
|
'entity_hints' => ['invoices'],
|
|
'confidence' => 0.8,
|
|
'association_hint' => false,
|
|
], JSON_UNESCAPED_UNICODE),
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
},
|
|
true,
|
|
'test-key'
|
|
);
|
|
|
|
$first = $parser->parse('acme invoices', ['invoices']);
|
|
$second = $parser->parse('acme invoices', ['invoices']);
|
|
|
|
expect($first['source'])->toBe('openai');
|
|
expect($second['source'])->toBe('cache');
|
|
expect($calls)->toBe(1);
|
|
});
|
|
|
|
it('caps alias and hint payloads from OpenAI and filters hints to allowed types', function (): void {
|
|
$manyAliases = [];
|
|
for ($i = 0; $i < 40; $i++) {
|
|
$manyAliases[] = 'ALIAS_' . $i . '_' . str_repeat('x', 90);
|
|
}
|
|
|
|
$parser = new system_search_openai_intent_parser(
|
|
function (array $payload, string $apiKey) use ($manyAliases): array {
|
|
return [
|
|
'output' => [
|
|
[
|
|
'content' => [
|
|
[
|
|
'text' => json_encode([
|
|
'success' => true,
|
|
'normalized_query' => str_repeat('q', 500),
|
|
'aliases' => $manyAliases,
|
|
'entity_hints' => ['orders', 'invoices', 'made_up_type'],
|
|
'confidence' => 0.7,
|
|
'association_hint' => false,
|
|
'fallback_reason' => null,
|
|
], JSON_UNESCAPED_UNICODE),
|
|
],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
},
|
|
true,
|
|
'test-key'
|
|
);
|
|
|
|
$result = $parser->parse('acme', ['orders', 'invoices']);
|
|
|
|
expect(count($result['aliases']))->toBeLessThanOrEqual(12);
|
|
$longestAlias = 0;
|
|
foreach ($result['aliases'] as $alias) {
|
|
$longestAlias = max($longestAlias, mb_strlen((string)$alias));
|
|
}
|
|
expect($longestAlias)->toBeLessThanOrEqual(64);
|
|
expect(mb_strlen((string)$result['normalized_query']))->toBeLessThanOrEqual(256);
|
|
expect($result['entity_hints'])->toBe(['orders', 'invoices']);
|
|
});
|