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, $allowedEntityTypes); } try { $raw = $this->sendRequest($payload, $apiKey); $parsed = $this->parseResponse($raw); $parsed['source'] = 'openai'; system_search_cache::setIntent($cacheHash, $parsed, 3600); return $this->normalizeResult($parsed, $allowedEntityTypes); } 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('/\b[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}\b/i', '[uuid]', $query) ?? $query; $query = preg_replace('/\+?\d[\d\s\-]{6,}\d/', '[phone]', $query) ?? $query; $query = preg_replace('/\b(order|invoice|booking|customer|kunde|faktura)\s*[#:\-]?\s*\d{4,}\b/iu', '$1 [id]', $query) ?? $query; $query = preg_replace('/\b(reg(?:istration)?|plate|license plate|nummerplade)\s*[#:\-]?\s*[a-z0-9\-]{4,10}\b/iu', '$1 [plate]', $query) ?? $query; $query = preg_replace('/\b[a-z]{2}\s?\d{5}\b/iu', '[plate]', $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" . "- Infer what the user is trying to find, not just literal words.\n" . "- aliases should contain user-friendly and backend-friendly equivalent terms.\n" . "- Include cross-language/domain synonyms when likely (example: Danish 'rabat' -> 'discount').\n" . "- If user references a customer/company by name, include hints that help find related invoices/orders/discounts.\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', 'maxLength' => $this->maxNormalizedQueryLength, ], 'aliases' => [ 'type' => 'array', 'maxItems' => $this->maxAliases, 'items' => [ 'type' => 'string', 'maxLength' => $this->maxAliasLength, ], ], 'entity_hints' => [ 'type' => 'array', 'maxItems' => $this->maxEntityHints, 'items' => [ 'type' => 'string', 'maxLength' => $this->maxHintLength, ], ], 'confidence' => [ 'type' => 'number', 'minimum' => 0, 'maximum' => 1, ], 'association_hint' => ['type' => 'boolean'], 'fallback_reason' => [ 'anyOf' => [ ['type' => 'string', 'maxLength' => $this->maxFallbackReasonLength], ['type' => '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 $allowedEntityTypes = []): array { $normalizedQuery = trim((string)($result['normalized_query'] ?? '')); if (mb_strlen($normalizedQuery) > $this->maxNormalizedQueryLength) { $normalizedQuery = mb_substr($normalizedQuery, 0, $this->maxNormalizedQueryLength); } $aliases = $this->sanitizeStringList((array)($result['aliases'] ?? []), $this->maxAliases, $this->maxAliasLength); $entityHints = $this->sanitizeStringList((array)($result['entity_hints'] ?? []), $this->maxEntityHints, $this->maxHintLength); if (!empty($allowedEntityTypes)) { $entityHints = array_values(array_intersect($allowedEntityTypes, $entityHints)); } $fallbackReason = null; if (isset($result['fallback_reason']) && $result['fallback_reason'] !== null) { $fallbackReason = trim((string)$result['fallback_reason']); if (mb_strlen($fallbackReason) > $this->maxFallbackReasonLength) { $fallbackReason = mb_substr($fallbackReason, 0, $this->maxFallbackReasonLength); } } return [ 'success' => (bool)($result['success'] ?? false), 'normalized_query' => $normalizedQuery, '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' => $fallbackReason, 'source' => (string)($result['source'] ?? 'openai'), ]; } private function sanitizeStringList(array $values, int $maxItems, int $maxLength): array { $result = []; foreach ($values as $value) { if (!is_string($value)) { continue; } $item = trim(mb_strtolower($value)); if ($item === '') { continue; } if (mb_strlen($item) > $maxLength) { $item = mb_substr($item, 0, $maxLength); } if (!in_array($item, $result, true)) { $result[] = $item; } if (count($result) >= $maxItems) { break; } } return $result; } 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, ]; } }