369 lines
16 KiB
PHP
369 lines
16 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
require_once WD . '/modules/openAI/openAI_c.php';
|
|
|
|
use Exception;
|
|
use interfaces\openai_i;
|
|
use openAI\openAI_c;
|
|
|
|
class openai_request_exception extends Exception
|
|
{
|
|
public function __construct(string $message, public readonly bool $retryable = false, public readonly ?int $httpStatus = null)
|
|
{
|
|
parent::__construct($message);
|
|
}
|
|
}
|
|
|
|
class openai implements openai_i
|
|
{
|
|
/**
|
|
* The configuration of the module
|
|
* @var openAI_c
|
|
*/
|
|
public openAI_c $config;
|
|
/**
|
|
* API URL
|
|
* @var string
|
|
*/
|
|
private string $api_url = 'https://api.openai.com/v1/responses';
|
|
protected string $model = 'gpt-4.1-mini';
|
|
protected string $temperature = '0.7';
|
|
protected string $max_tokens = '1000';
|
|
public string $prompt_LPR = 'You are an AI assistant specialized in reading license plates. Your task is to extract the license plate number from the provided image. The license plate number can be in various formats, including letters and numbers. Please provide the license plate number in a clear and concise format.';
|
|
|
|
public function __construct()
|
|
{
|
|
$this->config = new openAI_c();
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
*/
|
|
public function requireModuleEnabled(): void
|
|
{
|
|
if (!$this->config->enabled->isTrue()) {
|
|
throw new Exception('OpenAI module is not enabled.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a structured JSON text task to the OpenAI Responses API.
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function jsonTask(
|
|
string $schemaName,
|
|
string $prompt,
|
|
array $payload,
|
|
array $schema,
|
|
float $temperature = 0.1,
|
|
?string $model = null
|
|
): array
|
|
{
|
|
$this->requireModuleEnabled();
|
|
|
|
$data = [
|
|
'model' => $model ?? $this->model,
|
|
// The caller owns the durable audit record. Do not retain application state at OpenAI.
|
|
'store' => false,
|
|
'input' => [
|
|
[
|
|
'role' => 'developer',
|
|
'content' => [[
|
|
'type' => 'input_text',
|
|
'text' => $prompt,
|
|
]],
|
|
],
|
|
[
|
|
'role' => 'user',
|
|
'content' => [
|
|
[
|
|
'type' => 'input_text',
|
|
'text' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
],
|
|
],
|
|
],
|
|
],
|
|
'text' => [
|
|
'format' => [
|
|
'type' => 'json_schema',
|
|
'name' => $schemaName,
|
|
'schema' => $schema,
|
|
'strict' => true,
|
|
],
|
|
],
|
|
'temperature' => $temperature,
|
|
];
|
|
|
|
$response = $this->sendRequest($data);
|
|
return self::parseJsonTaskResponse($response);
|
|
}
|
|
|
|
public static function parseJsonTaskResponse(array $response): array
|
|
{
|
|
$status = (string)($response['status'] ?? '');
|
|
if ($status === 'incomplete') {
|
|
$reason = preg_replace('/[^a-z0-9_.-]/i', '', (string)($response['incomplete_details']['reason'] ?? 'unknown')) ?: 'unknown';
|
|
throw new openai_request_exception('OpenAI response was incomplete: ' . $reason, true);
|
|
}
|
|
if ($status !== 'completed') {
|
|
throw new openai_request_exception('OpenAI response did not complete.', in_array($status, ['queued', 'in_progress'], true));
|
|
}
|
|
|
|
$outputText = null;
|
|
foreach ((array)($response['output'] ?? []) as $output) {
|
|
foreach ((array)($output['content'] ?? []) as $content) {
|
|
if (($content['type'] ?? null) === 'refusal') {
|
|
throw new openai_request_exception('OpenAI refused the structured task.', false);
|
|
}
|
|
if (($content['type'] ?? null) === 'output_text' && is_string($content['text'] ?? null)) {
|
|
$outputText = (string)$content['text'];
|
|
}
|
|
}
|
|
}
|
|
if ($outputText === null || $outputText === '') {
|
|
throw new openai_request_exception('OpenAI completed without structured output text.', false);
|
|
}
|
|
$decoded = json_decode($outputText, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
|
throw new openai_request_exception('OpenAI returned invalid structured JSON.', false);
|
|
}
|
|
$resolvedModel = trim((string)($response['model'] ?? ''));
|
|
if ($resolvedModel === '') {
|
|
throw new openai_request_exception('OpenAI response omitted the resolved model.', false);
|
|
}
|
|
$usage = (array)($response['usage'] ?? []);
|
|
$inputTokens = max(0, (int)($usage['input_tokens'] ?? 0));
|
|
$outputTokens = max(0, (int)($usage['output_tokens'] ?? 0));
|
|
$totalTokens = max(0, (int)($usage['total_tokens'] ?? ($inputTokens + $outputTokens)));
|
|
return [
|
|
...$decoded,
|
|
'_openai_response_model' => $resolvedModel,
|
|
'_openai_usage' => [
|
|
'input_tokens' => $inputTokens,
|
|
'output_tokens' => $outputTokens,
|
|
'total_tokens' => $totalTokens,
|
|
'service_tier' => (string)($response['service_tier'] ?? ''),
|
|
],
|
|
];
|
|
}
|
|
|
|
protected function getLPRSchema(): array
|
|
{
|
|
return [
|
|
/**
|
|
* "format": {
|
|
* "type": "json_schema",
|
|
* "name": "math_reasoning",
|
|
* "schema": {
|
|
* "type": "object",
|
|
* "properties": {
|
|
* "steps": {
|
|
* "type": "array",
|
|
* "items": {
|
|
* "type": "object",
|
|
* "properties": {
|
|
* "explanation": { "type": "string" },
|
|
* "output": { "type": "string" }
|
|
* },
|
|
* "required": ["explanation", "output"],
|
|
* "additionalProperties": false
|
|
* }
|
|
* },
|
|
* "final_answer": { "type": "string" }
|
|
* },
|
|
* "required": ["steps", "final_answer"],
|
|
* "additionalProperties": false
|
|
* },
|
|
* "strict": true
|
|
* }
|
|
*/
|
|
'format' => [
|
|
'type' => 'json_schema',
|
|
'name' => 'lpr',
|
|
'schema' => [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'success' => [
|
|
'type' => 'boolean',
|
|
'description' => 'Indicates whether the license plate number was successfully extracted.'
|
|
],
|
|
'license_plate_number' => [
|
|
'type' => 'string',
|
|
'description' => 'The extracted license plate number from the image.'
|
|
],
|
|
],
|
|
'required' => ['license_plate_number', 'success'],
|
|
'additionalProperties' => false
|
|
],
|
|
'strict' => true
|
|
]
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @inheritDoc
|
|
* @return array The extracted license plate number (If there is a valid response)
|
|
* @throws Exception If the API response cannot be parsed
|
|
* @throws Exception If the image base64 is not valid.
|
|
* @throws Exception If the module is not enabled or if there is an error in the API request
|
|
*/
|
|
public function lpr(string $base64_image): array
|
|
{
|
|
$this->requireModuleEnabled();
|
|
if (empty($base64_image)) {
|
|
throw new Exception('Image URL cannot be empty.');
|
|
}
|
|
|
|
$prompt = $this->prompt_LPR;
|
|
$data = [
|
|
'model' => $this->model,
|
|
'input' => [
|
|
[
|
|
'role' => 'user',
|
|
'content' => [
|
|
['type' => 'input_text', 'text' => $prompt],
|
|
[
|
|
'type' => 'input_image',
|
|
'image_url' => $this->generateTemporaryDirectDownloadLink($base64_image) // Generate a temporary direct download link for the base64 image
|
|
]
|
|
]
|
|
]
|
|
],
|
|
'text' => [
|
|
...self::getLPRSchema(), // Apply the LPR schema { success, license_plate_number }
|
|
],
|
|
'temperature' => (float)$this->temperature,
|
|
];
|
|
// Send the request to the OpenAI API
|
|
$response = $this->sendRequest($data);
|
|
// Check if the response contains the expected format
|
|
return $this->parseLPRResponse($response);
|
|
}
|
|
|
|
/**
|
|
* Generates a temporary direct download link for the image.
|
|
*
|
|
* @param string $image The image object key name
|
|
* @return string The temporary direct download link for the image.
|
|
*/
|
|
private function generateTemporaryDirectDownloadLink(string $image): string
|
|
{
|
|
$uploads = new upload_store();
|
|
// Store the image temporarily and return the direct download link
|
|
return $uploads->generateDirectDownloadUrl($image);
|
|
}
|
|
|
|
/**
|
|
* Parses the response from the OpenAI API for the LPR task.
|
|
*
|
|
* @param mixed $response The response object from the OpenAI API.
|
|
* @return array The extracted license plate number if successful, otherwise an empty string.
|
|
* @throws Exception If the response does not contain the expected format or if parsing fails.
|
|
*/
|
|
private function parseLPRResponse(mixed $response): array
|
|
{
|
|
/**
|
|
* {"success":true,"data":{"id":"resp_68a2e2d5eb608191a546ef0540797d3b0148dcae28786ad6","object":"response","created_at":1755505366,"status":"completed","background":false,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[{"id":"msg_68a2e2d91050819183cc2c1d9be252950148dcae28786ad6","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"{\"success\":true,\"license_plate_number\":\"CU 61 297\"}"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":0.7,"text":{"format":{"type":"json_schema","description":null,"name":"lpr","schema":{"type":"object","properties":{"success":{"type":"boolean","description":"Indicates whether the license plate number was successfully extracted."},"license_plate_number":{"type":"string","description":"The extracted license plate number from the image."}},"required":["license_plate_number","success"],"additionalProperties":false},"strict":true},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1,"truncation":"disabled","usage":{"input_tokens":2470,"input_tokens_details":{"cached_tokens":0},"output_tokens":16,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2486},"user":null,"metadata":[]},"meta":[],"includes":[]}
|
|
*/
|
|
// Check if the response contains the expected format
|
|
if (!isset($response['output'][0]['content'][0]['text'])) {
|
|
throw new Exception('Invalid response format from OpenAI API. (Missing text field)');
|
|
}
|
|
$output = $response['output'][0]['content'][0]['text'];
|
|
// Decode the JSON response
|
|
$decodedOutput = json_decode($output, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new Exception('Error parsing JSON response: ' . json_last_error_msg());
|
|
}
|
|
// Check if the response contains the expected fields
|
|
if (!isset($decodedOutput['success']) || !isset($decodedOutput['license_plate_number'])) {
|
|
throw new Exception('Invalid response format from OpenAI API. (Missing success or license_plate_number field)');
|
|
}
|
|
// Return the license plate number if successful
|
|
if ($decodedOutput['success']) {
|
|
// Remove any spaces or unwanted characters from the license plate number
|
|
return [
|
|
'success' => $decodedOutput['success'],
|
|
'license_plate_number' => $this->cleanLicensePlate($decodedOutput['license_plate_number']),
|
|
];
|
|
// return $decodedOutput['license_plate_number']);
|
|
} else {
|
|
throw new Exception('License plate extraction failed.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleans the license plate number by removing unwanted characters and spaces.
|
|
*
|
|
* @param string $license_plate The license plate number to clean.
|
|
* @return string The cleaned license plate number.
|
|
*/
|
|
private function cleanLicensePlate(string $license_plate): string
|
|
{
|
|
// Remove any unwanted characters (e.g., spaces, special characters)
|
|
$cleaned = preg_replace('/[^A-Za-z0-9]/', '', $license_plate);
|
|
// Return the cleaned license plate number
|
|
return strtoupper(trim($cleaned)); // Convert to uppercase and trim any whitespace
|
|
}
|
|
|
|
/**
|
|
* Sends a request to the OpenAI API and returns the response.
|
|
*
|
|
* @param array $data The data to send in the request.
|
|
* @return array The response from the API, typically an associative array containing the response data.
|
|
* @throws Exception If the API request fails or if the response cannot be parsed.
|
|
*/
|
|
private function sendRequest(array $data): array
|
|
{
|
|
$this->requireModuleEnabled();
|
|
/**
|
|
* curl https://api.openai.com/v1/responses \
|
|
* -H "Content-Type: application/json" \
|
|
* -H "Authorization: Bearer $OPENAI_API_KEY" \
|
|
* -d '{
|
|
* "model": "gpt-4.1-mini",
|
|
* "input": [
|
|
* {
|
|
* "role": "user",
|
|
* "content": [
|
|
* {"type": "input_text", "text": "what is in this image?"},
|
|
* {
|
|
* "type": "input_image",
|
|
* "image_url": "https://upload.wikimedia.org/wikipedia/commons/thumb/d/dd/Gfp-wisconsin-madison-the-nature-boardwalk.jpg/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"
|
|
* }
|
|
* ]
|
|
* }
|
|
* ]
|
|
* }'
|
|
*/
|
|
$curl = curl_init($this->api_url);
|
|
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($curl, CURLOPT_POST, true);
|
|
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
|
|
curl_setopt($curl, CURLOPT_TIMEOUT, 45);
|
|
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $this->config->api_key->getVariableValue()
|
|
]);
|
|
curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
|
|
$response = curl_exec($curl);
|
|
if (curl_errno($curl)) {
|
|
$curlCode = curl_errno($curl);
|
|
curl_close($curl);
|
|
throw new openai_request_exception('OpenAI transport failed.', in_array($curlCode, [CURLE_OPERATION_TIMEDOUT, CURLE_COULDNT_CONNECT, CURLE_COULDNT_RESOLVE_HOST], true));
|
|
}
|
|
$httpStatus = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
|
curl_close($curl);
|
|
$responseData = json_decode($response, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
throw new openai_request_exception('OpenAI returned a non-JSON response.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
|
}
|
|
if ($httpStatus < 200 || $httpStatus >= 300 || !is_array($responseData)) {
|
|
throw new openai_request_exception('OpenAI request failed with HTTP status ' . $httpStatus . '.', $httpStatus === 429 || $httpStatus >= 500, $httpStatus);
|
|
}
|
|
return $responseData;
|
|
}
|
|
}
|