Introduce a centralized search service with OpenAI-powered intent parsing and caching

This commit is contained in:
Jeppe Bundgaard
2026-03-12 20:14:06 +01:00
parent f651309d6a
commit 6e887b315f
16 changed files with 3164 additions and 5 deletions
@@ -0,0 +1,192 @@
<?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';
$redacted = system_search_openai_intent_parser::redactSensitiveQuery($query);
expect($redacted)->toContain('[email]');
expect($redacted)->toContain('[phone]');
expect($redacted)->toContain('[cvr]');
expect($redacted)->not->toContain('alice@example.com');
expect($redacted)->not->toContain('12345678');
});
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);
});