Introduce a centralized search service with OpenAI-powered intent parsing and caching
This commit is contained in:
+425
@@ -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
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user