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
+425
View File
@@ -38,6 +38,8 @@ tags:
description: User and employee authentication endpoints
- name: Users
description: User management and customer operations
- name: Search
description: System-wide search endpoints
- name: Orders
description: Order creation, management, and retrieval
- name: Order Items
@@ -7744,6 +7746,152 @@ paths:
application/json:
schema: {}
/search/system:
get:
tags:
- Search
summary: System-wide search
description: Search across all supported entities with permission-aware filtering and optional intent parsing debug metadata.
operationId: systemWideSearchGet
parameters:
- in: query
name: query
required: true
schema:
type: string
description: Free-text query to search for.
- in: query
name: include_types
required: false
schema:
type: array
items:
$ref: '#/components/schemas/SystemSearchEntityType'
style: form
explode: false
description: Comma-separated list of entity types to include. Defaults to all allowed types.
- in: query
name: exclude_types
required: false
schema:
type: array
items:
$ref: '#/components/schemas/SystemSearchEntityType'
style: form
explode: false
description: Comma-separated list of entity types to exclude.
- in: query
name: include_associations
required: false
schema:
type: boolean
default: true
description: Include associated objects when matching a primary entity such as a customer.
- in: query
name: debug_intent
required: false
schema:
type: boolean
default: false
description: Include intent parser diagnostics in `meta.intent_parser`.
- in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 50
- in: query
name: offset
required: false
schema:
type: integer
minimum: 0
default: 0
responses:
'200':
description: Search results returned successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SystemSearchResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
post:
tags:
- Search
summary: System-wide search
description: Search across all supported entities using JSON request payload.
operationId: systemWideSearchPost
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SystemSearchRequest'
responses:
'200':
description: Search results returned successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SystemSearchResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/superuser/search/system/cache:
delete:
tags:
- Search
summary: Clear system search caches
description: Clears both query-result cache and intent-parser cache namespaces for system-wide search.
operationId: clearSystemSearchCache
responses:
'200':
description: Cache cleared successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SystemSearchCacheClearResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
/superuser/search/system/cache/rebuild:
post:
tags:
- Search
summary: Queue system search cache rebuild
description: Queues a cache rebuild request and clears active query/intent cache namespaces immediately.
operationId: rebuildSystemSearchCache
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/SystemSearchCacheRebuildRequest'
responses:
'200':
description: Cache rebuild queued successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SystemSearchCacheRebuildResponse'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
# Configuration Endpoints
/economic/config:
get:
@@ -9006,6 +9154,283 @@ components:
type: integer
description: HTTP status code
SystemSearchEntityType:
type: string
enum:
- objects
- module_config
- orders
- order_items
- customers
- employees
- subusers
- customer_discounts
- customer_fixed_prices
- departments
- permissions
- roles
- invoices
- vehicles
SystemSearchRequest:
type: object
required:
- query
properties:
query:
type: string
description: Free-text query to search for.
include_types:
type: array
items:
$ref: '#/components/schemas/SystemSearchEntityType'
description: Limit search to these entity types.
exclude_types:
type: array
items:
$ref: '#/components/schemas/SystemSearchEntityType'
description: Exclude these entity types from search.
include_associations:
type: boolean
default: true
description: Include associated records for matched core entities.
debug_intent:
type: boolean
default: false
description: Include parser diagnostics in `meta.intent_parser`.
limit:
type: integer
minimum: 1
maximum: 200
default: 50
offset:
type: integer
minimum: 0
default: 0
SystemSearchResult:
type: object
properties:
entity_type:
$ref: '#/components/schemas/SystemSearchEntityType'
entity_id:
type: string
title:
type: string
description:
type: string
customer_number:
type: integer
nullable: true
department_id:
type: integer
nullable: true
score:
type: integer
association_reason:
type: string
nullable: true
payload:
type: object
additionalProperties: true
required:
- entity_type
- entity_id
- title
- score
SystemSearchIntentParserMeta:
type: object
properties:
invoked:
type: boolean
source:
type: string
enum: [cache, openai, none]
status:
type: string
confidence:
type: number
minimum: 0
maximum: 1
expanded_terms:
type: array
items:
type: string
entity_hints:
type: array
items:
$ref: '#/components/schemas/SystemSearchEntityType'
fallback_reason:
type: string
nullable: true
required:
- invoked
- source
- status
- confidence
- expanded_terms
- entity_hints
SystemSearchMeta:
type: object
properties:
query:
type: string
limit:
type: integer
offset:
type: integer
total:
type: integer
allowed_types:
type: array
items:
$ref: '#/components/schemas/SystemSearchEntityType'
cache:
type: object
properties:
hit:
type: boolean
required: [hit]
intent_parser:
$ref: '#/components/schemas/SystemSearchIntentParserMeta'
required:
- query
- limit
- offset
- total
- allowed_types
- cache
SystemSearchPayload:
type: object
properties:
results:
type: array
items:
$ref: '#/components/schemas/SystemSearchResult'
grouped_results:
type: object
additionalProperties:
type: array
items:
$ref: '#/components/schemas/SystemSearchResult'
meta:
$ref: '#/components/schemas/SystemSearchMeta'
required:
- results
- grouped_results
- meta
SystemSearchResponse:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/SystemSearchPayload'
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
SystemSearchCacheClearResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
message:
type: string
query_cache_cleared:
type: boolean
intent_cache_cleared:
type: boolean
required:
- message
- query_cache_cleared
- intent_cache_cleared
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
SystemSearchCacheRebuildRequest:
type: object
properties:
scope:
type: string
enum: [all, types, dirty]
default: all
types:
type: array
items:
$ref: '#/components/schemas/SystemSearchEntityType'
SystemSearchCacheRebuildResponse:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
message:
type: string
request:
type: object
properties:
scope:
type: string
enum: [all, types, dirty]
types:
type: array
items:
$ref: '#/components/schemas/SystemSearchEntityType'
requested_at:
type: integer
required:
- scope
- types
- requested_at
query_cache_cleared:
type: boolean
intent_cache_cleared:
type: boolean
required:
- message
- request
- query_cache_cleared
- intent_cache_cleared
meta:
type: object
additionalProperties: true
includes:
type: object
additionalProperties: true
required:
- success
- data
- meta
- includes
ModuleConfigValue:
oneOf:
- type: string
@@ -155,6 +155,10 @@ class object_property
if (defined('redis')) {
redis->delete($this->getCacheKey());
}
try {
system_search_cache::markDirtyTable($this->table);
} catch (\Throwable) {
}
}
/**
@@ -184,5 +188,9 @@ class object_property
if (defined('redis')) {
redis->delete($this->getCacheKey());
}
try {
system_search_cache::markDirtyTable($this->table);
} catch (\Throwable) {
}
}
}
}
@@ -0,0 +1,234 @@
<?php
namespace classes;
use Throwable;
class system_search_cache
{
public const PREFIX = 'system_search:v1:';
public const QUERY_PREFIX = self::PREFIX . 'query:';
public const INTENT_PREFIX = self::PREFIX . 'intent:';
public const DIRTY_TABLES_KEY = self::PREFIX . 'dirty_tables';
public const REBUILD_REQUEST_KEY = self::PREFIX . 'rebuild_request';
/**
* Optional runtime adapter for tests.
*/
private static ?object $adapter = null;
public static function setAdapterForTests(?object $adapter): void
{
self::$adapter = $adapter;
}
public static function getQuery(string $hash): ?array
{
$raw = self::redisGet(self::QUERY_PREFIX . $hash);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
public static function setQuery(string $hash, array $payload, int $ttlSeconds = 120): void
{
self::redisSetEx(self::QUERY_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function getIntent(string $hash): ?array
{
$raw = self::redisGet(self::INTENT_PREFIX . $hash);
if ($raw === null) {
return null;
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
return null;
}
return $decoded;
}
public static function setIntent(string $hash, array $payload, int $ttlSeconds = 3600): void
{
self::redisSetEx(self::INTENT_PREFIX . $hash, json_encode($payload, JSON_UNESCAPED_UNICODE), $ttlSeconds);
}
public static function clearAll(): void
{
self::clearPattern(self::PREFIX . '*');
}
public static function clearQueryCaches(): void
{
self::clearPattern(self::QUERY_PREFIX . '*');
}
public static function clearIntentCaches(): void
{
self::clearPattern(self::INTENT_PREFIX . '*');
}
public static function markDirtyTable(string $table): void
{
$table = trim($table, " `\t\n\r\0\x0B");
if ($table === '') {
return;
}
$tables = self::redisGetArray(self::DIRTY_TABLES_KEY);
if (!in_array($table, $tables, true)) {
$tables[] = $table;
self::redisSetArray(self::DIRTY_TABLES_KEY, $tables);
// Keep dirty markers briefly in case cron is delayed.
self::redisExpire(self::DIRTY_TABLES_KEY, 3600);
}
// Query cache depends on mutable data and must be invalidated immediately.
self::clearQueryCaches();
}
public static function consumeDirtyTables(): array
{
$tables = self::redisGetArray(self::DIRTY_TABLES_KEY);
self::redisDelete(self::DIRTY_TABLES_KEY);
return $tables;
}
public static function enqueueRebuild(string $scope = 'all', array $types = []): array
{
$payload = [
'scope' => in_array($scope, ['all', 'types', 'dirty'], true) ? $scope : 'all',
'types' => array_values(array_unique(array_filter(array_map('strval', $types)))),
'requested_at' => time(),
];
self::redisSet(self::REBUILD_REQUEST_KEY, json_encode($payload, JSON_UNESCAPED_UNICODE));
self::redisExpire(self::REBUILD_REQUEST_KEY, 86400);
return $payload;
}
public static function consumeRebuildRequest(): ?array
{
$raw = self::redisGet(self::REBUILD_REQUEST_KEY);
if ($raw === null) {
return null;
}
self::redisDelete(self::REBUILD_REQUEST_KEY);
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : null;
}
private static function clearPattern(string $pattern): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->clear_keys($pattern);
} catch (Throwable) {
// Cache clear must never break request flow.
}
}
private static function redisSetEx(string $key, string $value, int $ttl): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->setEx($key, $value, $ttl);
} catch (Throwable) {
}
}
private static function redisSet(string $key, string $value): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->set($key, $value);
} catch (Throwable) {
}
}
private static function redisGet(string $key): ?string
{
try {
$client = self::redisClient();
if ($client === null) {
return null;
}
$value = $client->get($key);
return is_string($value) ? $value : null;
} catch (Throwable) {
return null;
}
}
private static function redisDelete(string $key): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->delete($key);
} catch (Throwable) {
}
}
private static function redisSetArray(string $key, array $value): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->set_array($key, $value);
} catch (Throwable) {
}
}
private static function redisGetArray(string $key): array
{
try {
$client = self::redisClient();
if ($client === null) {
return [];
}
$value = $client->get_array($key);
return is_array($value) ? $value : [];
} catch (Throwable) {
return [];
}
}
private static function redisExpire(string $key, int $ttl): void
{
try {
$client = self::redisClient();
if ($client === null) {
return;
}
$client->expire($key, $ttl);
} catch (Throwable) {
}
}
private static function redisClient(): ?object
{
if (self::$adapter !== null) {
return self::$adapter;
}
if (!defined('redis')) {
return null;
}
$client = constant('redis');
return is_object($client) ? $client : null;
}
}
@@ -0,0 +1,247 @@
<?php
namespace classes;
use Exception;
use interfaces\system_search_intent_parser_i;
use Throwable;
class system_search_openai_intent_parser implements system_search_intent_parser_i
{
private string $apiUrl = 'https://api.openai.com/v1/responses';
private string $model = 'gpt-4.1-mini';
private float $temperature = 0.1;
private int $timeoutSeconds = 10;
/**
* @var callable|null
*/
private $transport;
private ?bool $forcedEnabled;
private ?string $forcedApiKey;
public function __construct(?callable $transport = null, ?bool $forcedEnabled = null, ?string $forcedApiKey = null)
{
$this->transport = $transport;
$this->forcedEnabled = $forcedEnabled;
$this->forcedApiKey = $forcedApiKey;
}
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array
{
$query = trim($query);
if ($query === '') {
return $this->failed('empty_query', 'none');
}
[$enabled, $apiKey] = $this->resolveOpenAISettings();
if (!$enabled) {
return $this->failed('openai_disabled', 'none');
}
if (empty($apiKey)) {
return $this->failed('openai_missing_key', 'none');
}
$redactedQuery = self::redactSensitiveQuery($query);
$payload = $this->buildPayload($redactedQuery, $allowedEntityTypes, $taxonomy);
$cacheHash = md5(json_encode([
'q' => $redactedQuery,
'types' => $allowedEntityTypes,
'taxonomy' => $taxonomy,
'v' => 1,
], JSON_UNESCAPED_UNICODE));
$cached = system_search_cache::getIntent($cacheHash);
if (is_array($cached) && isset($cached['success'])) {
$cached['source'] = 'cache';
return $this->normalizeResult($cached);
}
try {
$raw = $this->sendRequest($payload, $apiKey);
$parsed = $this->parseResponse($raw);
$parsed['source'] = 'openai';
system_search_cache::setIntent($cacheHash, $parsed, 3600);
return $this->normalizeResult($parsed);
} catch (Throwable $e) {
return $this->failed($e->getMessage(), 'openai');
}
}
public static function redactSensitiveQuery(string $query): string
{
$query = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email]', $query) ?? $query;
$query = preg_replace('/\b\d{8}\b/', '[cvr]', $query) ?? $query;
$query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query;
return $query;
}
private function resolveOpenAISettings(): array
{
if ($this->forcedEnabled !== null) {
return [(bool)$this->forcedEnabled, (string)($this->forcedApiKey ?? '')];
}
try {
$openai = new openai();
$enabled = (bool)$openai->config->enabled->getVariableValue();
$apiKey = (string)$openai->config->api_key->getVariableValue();
return [$enabled, $apiKey];
} catch (Throwable) {
return [false, ''];
}
}
private function buildPayload(string $query, array $allowedEntityTypes, array $taxonomy): array
{
$taxonomyText = json_encode([
'allowed_entity_types' => array_values($allowedEntityTypes),
'taxonomy' => $taxonomy,
], JSON_UNESCAPED_UNICODE);
$prompt = "You parse user search intent into strict JSON.\n"
. "Rules:\n"
. "- Keep output concise and valid JSON only.\n"
. "- Do not invent entity types not listed in allowed_entity_types.\n"
. "- aliases should contain user-friendly alternative terms.\n"
. "- confidence must be between 0 and 1.\n"
. "- association_hint should be true if related records likely needed.\n\n"
. "Context:\n"
. $taxonomyText . "\n\n"
. "User query:\n"
. $query;
return [
'model' => $this->model,
'temperature' => $this->temperature,
'input' => [
[
'role' => 'user',
'content' => [
['type' => 'input_text', 'text' => $prompt],
],
],
],
'text' => [
'format' => [
'type' => 'json_schema',
'name' => 'system_search_intent',
'schema' => [
'type' => 'object',
'properties' => [
'success' => ['type' => 'boolean'],
'normalized_query' => ['type' => 'string'],
'aliases' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
'entity_hints' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
'confidence' => [
'type' => 'number',
'minimum' => 0,
'maximum' => 1,
],
'association_hint' => ['type' => 'boolean'],
'fallback_reason' => ['type' => ['string', 'null']],
],
'required' => [
'success',
'normalized_query',
'aliases',
'entity_hints',
'confidence',
'association_hint',
'fallback_reason',
],
'additionalProperties' => false,
],
'strict' => true,
],
],
];
}
private function sendRequest(array $payload, string $apiKey): array
{
if ($this->transport !== null) {
$result = call_user_func($this->transport, $payload, $apiKey);
if (!is_array($result)) {
throw new Exception('Transport returned invalid payload');
}
return $result;
}
$curl = curl_init($this->apiUrl);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
]);
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
$raw = curl_exec($curl);
if ($raw === false) {
$error = curl_error($curl);
curl_close($curl);
throw new Exception('cURL error: ' . $error);
}
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new Exception('Invalid JSON from OpenAI');
}
if ($status >= 400) {
$message = $decoded['error']['message'] ?? ('OpenAI HTTP ' . $status);
throw new Exception($message);
}
return $decoded;
}
private function parseResponse(array $response): array
{
$text = $response['output'][0]['content'][0]['text'] ?? null;
if (!is_string($text) || $text === '') {
throw new Exception('Invalid response format (missing output text)');
}
$decoded = json_decode($text, true);
if (!is_array($decoded)) {
throw new Exception('Invalid intent JSON');
}
return $decoded;
}
private function normalizeResult(array $result): array
{
$entityHints = array_values(array_unique(array_filter(array_map('strval', (array)($result['entity_hints'] ?? [])))));
$aliases = array_values(array_unique(array_filter(array_map('strval', (array)($result['aliases'] ?? [])))));
return [
'success' => (bool)($result['success'] ?? false),
'normalized_query' => (string)($result['normalized_query'] ?? ''),
'aliases' => $aliases,
'entity_hints' => $entityHints,
'confidence' => max(0.0, min(1.0, (float)($result['confidence'] ?? 0.0))),
'association_hint' => (bool)($result['association_hint'] ?? false),
'fallback_reason' => isset($result['fallback_reason']) ? (string)$result['fallback_reason'] : null,
'source' => (string)($result['source'] ?? 'openai'),
];
}
private function failed(string $reason, string $source): array
{
return [
'success' => false,
'normalized_query' => '',
'aliases' => [],
'entity_hints' => [],
'confidence' => 0.0,
'association_hint' => false,
'fallback_reason' => $reason,
'source' => $source,
];
}
}
File diff suppressed because it is too large Load Diff
+30 -1
View File
@@ -3,6 +3,7 @@
use classes\backup_store;
use classes\economic;
use classes\system_search_cache;
use classes\xlvask;
use classes\slack as Slack;
use classes\email as Email;
@@ -80,6 +81,12 @@ $cron_tasks = [
'next_run' => 0,
'function' => 'SyncXLVaskModuleCron',
],
'SystemSearchCacheMaintenanceCron' => [
'interval' => 300, // 5 minutes
'last_run' => 0,
'next_run' => 0,
'function' => 'SystemSearchCacheMaintenanceCron',
],
'GoalsProgressAlertsCron' => [
'interval' => 60, // check every minute
'last_run' => 0,
@@ -162,6 +169,28 @@ function SyncXLVaskModuleCron(): void
}
}
function SystemSearchCacheMaintenanceCron(): void
{
try {
$rebuildRequest = system_search_cache::consumeRebuildRequest();
$dirtyTables = system_search_cache::consumeDirtyTables();
if ($rebuildRequest !== null) {
system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
echo "[" . date('Y-m-d H:i:s') . "][CRON] System search cache rebuild handled. Scope: " . ($rebuildRequest['scope'] ?? 'all') . "\n";
return;
}
if (!empty($dirtyTables)) {
system_search_cache::clearQueryCaches();
echo "[" . date('Y-m-d H:i:s') . "][CRON] System search query cache invalidated for dirty tables: " . implode(', ', $dirtyTables) . "\n";
}
} catch (Throwable $e) {
warn('SystemSearchCacheMaintenanceCron failed: ' . $e->getMessage());
}
}
/**
* GoalsProgressAlertsCron
*
@@ -417,4 +446,4 @@ foreach ( $cron_tasks as $task => $data ) {
} else {
$response_cron[] = $task . ' is not due to run yet, next run is at ' . date('Y-m-d H:i:s', $nextRun) . ' (' . ($nextRun - time()) . ' seconds)';
}
}
}
@@ -0,0 +1,26 @@
<?php
namespace interfaces;
interface system_search_intent_parser_i
{
/**
* Parse a natural-language search query into structured search hints.
*
* @param string $query The raw user query.
* @param array $allowedEntityTypes Entity types the caller is allowed to search.
* @param array $taxonomy Public taxonomy/aliases to improve intent parsing.
* @return array{
* success: bool,
* normalized_query: string,
* aliases: array<int, string>,
* entity_hints: array<int, string>,
* confidence: float,
* association_hint: bool,
* fallback_reason: string|null,
* source: string
* }
*/
public function parse(string $query, array $allowedEntityTypes, array $taxonomy = []): array;
}
@@ -0,0 +1,416 @@
<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\router;
use classes\system_search_cache;
use classes\system_search_service;
use Throwable;
use traits\route_t;
class systemSearchRoute
{
use route_t;
public function run(): void
{
$this->get('/search/system', function () {
$this->handleSearchRequest();
});
$this->post('/search/system', function () {
$this->handleSearchRequest();
});
$this->delete('/superuser/search/system/cache', function () {
global /** @var response $response */
$response;
$this->requirePermission('superuser_search_system_cache_clear');
system_search_cache::clearAll();
$response->success([
'message' => 'System search cache cleared',
'query_cache_cleared' => true,
'intent_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_clear' => 'Clear system-wide search query and intent caches',
]);
$this->post('/superuser/search/system/cache/rebuild', function () {
global /** @var response $response */
$response;
$this->requirePermission('superuser_search_system_cache_rebuild');
$params = $this->getRequestPayload();
$scope = strtolower(trim((string)($params['scope'] ?? 'all')));
$types = $this->parseTypeList($params['types'] ?? []);
$request = system_search_cache::enqueueRebuild($scope, $types);
// Rebuild endpoint also clears parser namespace immediately.
system_search_cache::clearQueryCaches();
system_search_cache::clearIntentCaches();
$response->success([
'message' => 'System search cache rebuild queued',
'request' => $request,
'query_cache_cleared' => true,
'intent_cache_cleared' => true,
]);
}, [
'superuser_search_system_cache_rebuild' => 'Queue and trigger a system-wide search cache rebuild',
]);
}
private function handleSearchRequest(): void
{
global /** @var response $response */
/** @var router $router */
$response, $router;
$auth = new authentication();
$user = $auth->get_user();
$subuser = $auth->get_subuser();
if ($user === false && $subuser === false) {
$response->error('Invalid session', 401);
}
$params = $this->getRequestPayload();
$query = trim((string)($params['q'] ?? $params['query'] ?? $params['search'] ?? ''));
if ($query === '') {
$response->error('Missing required parameter: query', 400);
}
$includeTypes = $this->parseTypeList($params['include_types'] ?? []);
$excludeTypes = $this->parseTypeList($params['exclude_types'] ?? []);
$includeAssociations = $this->toBool($params['include_associations'] ?? true, true);
$debugIntent = $this->toBool($params['debug_intent'] ?? false, false);
$limit = $this->clampInt((int)($params['limit'] ?? 50), 1, 200, 50);
$offset = max(0, (int)($params['offset'] ?? 0));
[$allowedTypes, $ownOnlyTypes] = $this->resolveAllowedTypes();
if (empty($allowedTypes)) {
$response->error('Permission denied. No searchable entity types available for this user.', 403);
}
$permissionsCatalogAll = $this->flattenPermissionCatalog((array)$router->getPermissions());
$permissionsCatalogOwn = [];
if ($user !== false) {
try {
$permissionsCatalogOwn = array_values(array_unique(array_map('strval', (array)$user->getGroup()->getPermissions())));
} catch (Throwable) {
$permissionsCatalogOwn = [];
}
}
$service = new system_search_service();
$result = $service->search([
'query' => $query,
'include_types' => $includeTypes,
'exclude_types' => $excludeTypes,
'allowed_types' => $allowedTypes,
'own_only_types' => $ownOnlyTypes,
'own_customer_number' => $this->resolveEffectiveCustomerNumber(),
'permissions_catalog_all' => $permissionsCatalogAll,
'permissions_catalog_own' => $permissionsCatalogOwn,
'module_config_visibility' => $this->buildModuleConfigVisibility(),
'include_associations' => $includeAssociations,
'debug_intent' => $debugIntent,
'limit' => $limit,
'offset' => $offset,
]);
$response->success($result);
}
private function getRequestPayload(): array
{
$payload = $this->getParametersAsArray();
return is_array($payload) ? $payload : [];
}
private function resolveAllowedTypes(): array
{
$allowed = [];
$ownOnly = [];
foreach ($this->entityPermissionMap() as $type => $permissionSets) {
$hasAll = $this->hasAnyPermission((array)($permissionSets['all'] ?? []));
$hasOwn = $this->hasAnyPermission((array)($permissionSets['own'] ?? []));
if (!$hasAll && !$hasOwn) {
continue;
}
$allowed[] = $type;
if (!$hasAll && $hasOwn) {
$ownOnly[] = $type;
}
}
return [
array_values(array_intersect($this->allEntityTypes(), array_values(array_unique($allowed)))),
array_values(array_intersect($this->allEntityTypes(), array_values(array_unique($ownOnly)))),
];
}
private function entityPermissionMap(): array
{
return [
'objects' => [
'all' => ['list_order_attachments', 'download_order_attachments', 'list_department_selfserve_task_attachments'],
'own' => ['list_own_order_attachments', 'download_order_attachments_own'],
],
'module_config' => [
'all' => $this->moduleConfigPermissions(),
'own' => [],
],
'orders' => [
'all' => ['list_orders', 'fetch_order'],
'own' => ['list_own_orders', 'fetch_own_order'],
],
'order_items' => [
'all' => ['list_order_items'],
'own' => ['list_own_order_items'],
],
'customers' => [
'all' => ['search_customers', 'list_users', 'get_user_from_customer_number'],
'own' => ['user'],
],
'employees' => [
'all' => ['list_users'],
'own' => [],
],
'subusers' => [
'all' => ['list_subuser_grants', 'manage_subuser_grants'],
'own' => ['list_own_subusers', 'list_own_subuser_grants'],
],
'customer_discounts' => [
'all' => ['get_custom_prices_other', 'set_custom_price'],
'own' => [],
],
'customer_fixed_prices' => [
'all' => ['get_customer_fixed_pricing'],
'own' => [],
],
'departments' => [
'all' => ['list_departments'],
'own' => [],
],
'permissions' => [
'all' => ['permissions_list'],
'own' => ['permissions_list_own'],
],
'roles' => [
'all' => ['list_roles'],
'own' => [],
],
'invoices' => [
'all' => ['list_collected_invoices', 'list_collected_invoices_economic_overview'],
'own' => ['user_invoices'],
],
'vehicles' => [
'all' => ['list_vehicles_other', 'list_unknown_customer_vehicles'],
'own' => ['list_own_vehicles'],
],
];
}
private function moduleConfigPermissions(): array
{
return [
'economic_config',
'recaptcha_config',
'email_config',
'backups_config',
'modules_bird_config',
'motorapi_config',
'stripe_config',
'fxratesapi_config',
'weatherapi_config',
'gatewayapi_config',
'xlvask_config',
'entra_config',
'modules_limble_config',
'modules_ocrspace_config',
'modules_openai_config',
'modules_licenseplaterecognizer_config',
'modules_virkdata_config',
'modules_shelly_config',
'modules_selfserve_config',
];
}
private function buildModuleConfigVisibility(): array
{
global $db;
$modulePermissions = [
'economic' => ['economic_config'],
'reCAPTCHA' => ['recaptcha_config'],
'Email' => ['email_config'],
'Backups' => ['backups_config'],
'bird' => ['modules_bird_config'],
'motorapi' => ['motorapi_config'],
'Stripe' => ['stripe_config'],
'fxratesapi' => ['fxratesapi_config'],
'weatherapi' => ['weatherapi_config'],
'GatewayAPI' => ['gatewayapi_config'],
'xlvask' => ['xlvask_config'],
'Entra' => ['entra_config'],
'limble' => ['modules_limble_config'],
'ocrSpace' => ['modules_ocrspace_config'],
'openAI' => ['modules_openai_config'],
'licenseplaterecognizer' => ['modules_licenseplaterecognizer_config'],
'virkdata' => ['modules_virkdata_config'],
'shelly' => ['modules_shelly_config'],
'selfserve' => ['modules_selfserve_config'],
];
$visibility = [];
try {
$result = $db->query('SELECT DISTINCT module FROM module_config');
if ($result instanceof \mysqli_result) {
$rows = $db->fetch_all($result);
foreach ($rows as $row) {
$module = (string)($row['module'] ?? '');
if ($module === '') {
continue;
}
$candidates = $modulePermissions[$module] ?? [];
if (empty($candidates)) {
$slug = strtolower(preg_replace('/[^a-z0-9]+/i', '', $module) ?? '');
if ($slug !== '') {
$candidates[] = $slug . '_config';
$candidates[] = 'modules_' . $slug . '_config';
}
}
$visibility[$module] = $this->hasAnyPermission($candidates);
}
}
} catch (Throwable) {
// Fail open for compatibility if module map cannot be loaded.
}
return $visibility;
}
private function hasAnyPermission(array $permissions): bool
{
foreach ($permissions as $permission) {
if (!is_string($permission) || $permission === '') {
continue;
}
if ($this->hasPermission($permission)) {
return true;
}
}
return false;
}
private function flattenPermissionCatalog(array $permissions): array
{
$flat = [];
$walker = function (mixed $node) use (&$flat, &$walker): void {
if (!is_array($node)) {
return;
}
foreach ($node as $key => $value) {
if (is_string($key) && is_string($value)) {
$flat[$key] = $value;
continue;
}
if (is_array($value)) {
$walker($value);
}
}
};
$walker($permissions);
return $flat;
}
private function parseTypeList(mixed $value): array
{
$result = [];
$raw = [];
if (is_array($value)) {
$raw = $value;
} elseif (is_string($value)) {
$trimmed = trim($value);
if ($trimmed === '') {
return [];
}
if (str_starts_with($trimmed, '[')) {
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
$raw = $decoded;
} else {
$raw = explode(',', $trimmed);
}
} else {
$raw = explode(',', $trimmed);
}
} elseif ($value !== null) {
$raw = [$value];
}
foreach ($raw as $item) {
if (!is_string($item)) {
continue;
}
$normalized = strtolower(trim($item));
if ($normalized === '') {
continue;
}
$result[] = $normalized;
}
return array_values(array_unique($result));
}
private function toBool(mixed $value, bool $default): bool
{
if (is_bool($value)) {
return $value;
}
if (is_int($value) || is_float($value)) {
return (bool)$value;
}
if (is_string($value)) {
$parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
return $parsed ?? $default;
}
return $default;
}
private function clampInt(int $value, int $min, int $max, int $default): int
{
if ($value === 0) {
$value = $default;
}
if ($value < $min) {
return $min;
}
if ($value > $max) {
return $max;
}
return $value;
}
private function allEntityTypes(): array
{
return [
'objects',
'module_config',
'orders',
'order_items',
'customers',
'employees',
'subusers',
'customer_discounts',
'customer_fixed_prices',
'departments',
'permissions',
'roles',
'invoices',
'vehicles',
];
}
}
@@ -0,0 +1,157 @@
<?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 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 this test adapter.
}
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('reuses parser cache entries for repeated natural-language intent requests', 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 unpaid invoices',
'aliases' => ['acme'],
'entity_hints' => ['invoices'],
'confidence' => 0.91,
'association_hint' => true,
], JSON_UNESCAPED_UNICODE),
],
],
],
],
];
},
true,
'test-key'
);
$first = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']);
$second = $parser->parse('find unpaid invoices for acme', ['invoices', 'customers']);
expect($first['source'])->toBe('openai');
expect($second['source'])->toBe('cache');
expect($calls)->toBe(1);
});
it('clears parser cache namespace via clearAll to force a fresh parse', 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'
);
$parser->parse('acme invoices', ['invoices']);
system_search_cache::clearAll();
$result = $parser->parse('acme invoices', ['invoices']);
expect($result['source'])->toBe('openai');
expect($calls)->toBe(2);
});
it('invalidates query cache immediately when dirty-table marker is registered', function (): void {
$hash = md5('test-query');
system_search_cache::setQuery($hash, ['results' => [], 'grouped_results' => [], 'meta' => []], 120);
expect(system_search_cache::getQuery($hash))->not->toBeNull();
system_search_cache::markDirtyTable('orders');
$dirty = system_search_cache::consumeDirtyTables();
expect(system_search_cache::getQuery($hash))->toBeNull();
expect($dirty)->toContain('orders');
});
@@ -0,0 +1,32 @@
<?php
it('marks system search cache dirty from generic db object mutation flows', function (): void {
$content = file_get_contents(app_path('traits/db_object_t.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('markSystemSearchDirtyTable');
expect($content)->toContain('system_search_cache::markDirtyTable');
});
it('marks system search cache dirty from object property mutations', function (): void {
$content = file_get_contents(app_path('classes/object_property.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain("system_search_cache::markDirtyTable(\$this->table)");
});
it('marks system search cache dirty when module config values change', function (): void {
$content = file_get_contents(app_path('traits/module_config_variable_t.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain("system_search_cache::markDirtyTable('module_config')");
});
it('registers cron maintenance task for system search cache', function (): void {
$content = file_get_contents(app_path('cron/Cron.php'));
expect($content)->not->toBeFalse();
expect($content)->toContain('SystemSearchCacheMaintenanceCron');
expect($content)->toContain("system_search_cache::consumeRebuildRequest()");
expect($content)->toContain("system_search_cache::consumeDirtyTables()");
});
@@ -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);
});
@@ -0,0 +1,38 @@
<?php
function system_search_openapi_content_or_skip(): string
{
$candidates = [
dirname(__DIR__, 4) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'openapi.yaml',
dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'openapi.yaml',
];
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
$content = file_get_contents($candidate);
if ($content !== false) {
return $content;
}
}
}
test()->markTestSkipped('openapi.yaml is not available in this runtime environment.');
}
it('documents system-wide search endpoints in openapi', function (): void {
$content = system_search_openapi_content_or_skip();
expect($content)->toContain('/search/system:');
expect($content)->toContain('/superuser/search/system/cache:');
expect($content)->toContain('/superuser/search/system/cache/rebuild:');
});
it('documents debug_intent and parser metadata schema in openapi', function (): void {
$content = system_search_openapi_content_or_skip();
expect($content)->toContain('debug_intent:');
expect($content)->toContain('SystemSearchIntentParserMeta:');
expect($content)->toContain('intent_parser:');
expect($content)->toContain('SystemSearchResponse:');
});
@@ -0,0 +1,37 @@
<?php
it('registers system-wide search GET and POST endpoints with intent debug support', function (): void {
$routeFile = app_path('routes/systemSearchRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/search/system');
expect($content)->toContain("'debug_intent'");
expect($content)->toContain("'include_types'");
expect($content)->toContain("'exclude_types'");
expect($content)->toContain('new system_search_service()');
});
it('registers superuser cache clear and rebuild endpoints for system search', function (): void {
$routeFile = app_path('routes/systemSearchRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain('/superuser/search/system/cache');
expect($content)->toContain('/superuser/search/system/cache/rebuild');
expect($content)->toContain("requirePermission('superuser_search_system_cache_clear')");
expect($content)->toContain("requirePermission('superuser_search_system_cache_rebuild')");
expect($content)->toContain('system_search_cache::clearIntentCaches()');
});
it('passes permission and own-scope context into system search service', function (): void {
$routeFile = app_path('routes/systemSearchRoute.php');
$content = file_get_contents($routeFile);
expect($content)->not->toBeFalse();
expect($content)->toContain("'allowed_types'");
expect($content)->toContain("'own_only_types'");
expect($content)->toContain("'own_customer_number'");
expect($content)->toContain("'permissions_catalog_all'");
expect($content)->toContain("'permissions_catalog_own'");
});
@@ -0,0 +1,224 @@
<?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');
});
+31 -2
View File
@@ -8,6 +8,7 @@ use classes\attachments;
use classes\db;
use classes\object_property;
use classes\response;
use classes\system_search_cache;
use Exception;
use mysqli_result;
use objects\bookings_new_o;
@@ -47,6 +48,7 @@ use objects\tokens_o;
use objects\user_key_value_pairs_o;
use objects\user_price_overrides_o;
use objects\users_o;
use Throwable;
trait db_object_t
{
@@ -67,6 +69,20 @@ trait db_object_t
*/
private string $additionalWhereClause = ''; // Additional where clause to add to the pagination query, this is used to add custom where clauses to the pagination query
private function markSystemSearchDirtyTable(?string $table = null): void
{
try {
$target = $table ?? $this->table;
$target = trim((string)$target, " `\t\n\r\0\x0B");
if ($target === '') {
return;
}
system_search_cache::markDirtyTable($target);
} catch (Throwable) {
// Search invalidation must never block write operations.
}
}
public function __construct()
{
$this->structure();
@@ -289,7 +305,11 @@ trait db_object_t
// Execute the update query
$sql = "UPDATE $table SET $set WHERE $where";
return $db->query($sql);
$result = $db->query($sql);
if ($result !== false) {
$this->markSystemSearchDirtyTable($table);
}
return $result;
}
/**
@@ -1116,6 +1136,7 @@ trait db_object_t
// Delete the object from the database
self::delete_object($this->table, $this->id);
}
$this->markSystemSearchDirtyTable();
// Trigger the object changed event
self::objectChanged();
}
@@ -1192,6 +1213,7 @@ trait db_object_t
if (empty($set)) {
return;
}
$this->markSystemSearchDirtyTable();
// Trigger the object changed event
self::objectChanged();
}
@@ -1226,6 +1248,10 @@ trait db_object_t
global $db;
$sql = "DELETE FROM $table WHERE id = $id";
$db->query($sql);
try {
system_search_cache::markDirtyTable(trim((string)$table, " `\t\n\r\0\x0B"));
} catch (Throwable) {
}
}
/**
@@ -1237,6 +1263,7 @@ trait db_object_t
self::requireSelected();
// Delete the object from the database
self::delete_object($this->table, $this->id);
$this->markSystemSearchDirtyTable();
// Trigger the object changed event
self::objectChanged();
}
@@ -1329,6 +1356,7 @@ trait db_object_t
$set = implode(', ', $set);
$sql = "INSERT INTO $this->table SET $set";
$db->query($sql);
$this->markSystemSearchDirtyTable();
return $db->insert_id();
} catch (Exception $e) {
throw new Exception($e->getMessage());
@@ -1368,6 +1396,7 @@ trait db_object_t
$id = $this->id;
$sql = "UPDATE $this->table SET deleted_at = NULL WHERE id = $id";
$db->query($sql);
$this->markSystemSearchDirtyTable();
// Trigger the object changed event
self::objectChanged();
}
@@ -1426,4 +1455,4 @@ trait db_object_t
$attachment = new attachments();
return $attachment->get($attachmentId);
}
}
}
@@ -2,6 +2,7 @@
namespace traits;
use classes\system_search_cache;
use Exception;
trait module_config_variable
@@ -87,6 +88,7 @@ trait module_config_variable
global $db;
$sql = "INSERT INTO module_config (module, variable, value, type) VALUES ('$module', '$variable', '$value', '" . self::getVariableType() . "')";
$db->query($sql);
system_search_cache::markDirtyTable('module_config');
}
/**
@@ -227,6 +229,7 @@ trait module_config_variable
global $db;
$sql = "UPDATE module_config SET value = '$value' WHERE module = '$module' AND variable = '$variable'";
$db->query($sql);
system_search_cache::markDirtyTable('module_config');
}
/**
@@ -300,4 +303,4 @@ trait module_config_variable
{
return $this->config_variable_required;
}
}
}