Files
api/services/nginx/app/classes/system_search_openai_intent_parser.php
T

248 lines
9.1 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;
/**
* @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,
];
}
}