318 lines
12 KiB
PHP
318 lines
12 KiB
PHP
<?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;
|
|
private int $maxAliases = 12;
|
|
private int $maxEntityHints = 8;
|
|
private int $maxAliasLength = 64;
|
|
private int $maxHintLength = 32;
|
|
private int $maxNormalizedQueryLength = 256;
|
|
private int $maxFallbackReasonLength = 160;
|
|
|
|
/**
|
|
* @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, $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,
|
|
];
|
|
}
|
|
}
|