Separated laravel app to its own folder (#540)
This commit is contained in:
205
api/app/Service/OpenAi/GptCompleter.php
Normal file
205
api/app/Service/OpenAi/GptCompleter.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\OpenAi;
|
||||
|
||||
use App\Service\OpenAi\Utils\JsonFixer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenAI\Client;
|
||||
use OpenAI\Exceptions\ErrorException;
|
||||
|
||||
/**
|
||||
* Handles a GPT completion prompt with or without insert tag.
|
||||
* Also parses output.
|
||||
*/
|
||||
class GptCompleter
|
||||
{
|
||||
public const AI_MODEL = 'gpt-4o';
|
||||
|
||||
protected Client $openAi;
|
||||
|
||||
protected mixed $result;
|
||||
|
||||
protected array $completionInput;
|
||||
|
||||
protected ?string $systemMessage;
|
||||
|
||||
protected bool $expectsJson = false;
|
||||
|
||||
protected int $tokenUsed = 0;
|
||||
|
||||
protected bool $useStreaming = false;
|
||||
|
||||
public function __construct(string $apiKey, protected int $retries = 2, protected string $model = self::AI_MODEL)
|
||||
{
|
||||
$this->openAi = \OpenAI::client($apiKey);
|
||||
}
|
||||
|
||||
public function setAiModel(string $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSystemMessage(string $systemMessage): self
|
||||
{
|
||||
$this->systemMessage = $systemMessage;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function useStreaming(): self
|
||||
{
|
||||
$this->useStreaming = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function expectsJson(): self
|
||||
{
|
||||
$this->expectsJson = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function doesNotExpectJson(): self
|
||||
{
|
||||
$this->expectsJson = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function completeChat(array $messages, int $maxTokens = 4096, float $temperature = 0.81, ?bool $exceptJson = null): self
|
||||
{
|
||||
if (! is_null($exceptJson)) {
|
||||
$this->expectsJson = $exceptJson;
|
||||
}
|
||||
$this->computeChatCompletion($messages, $maxTokens, $temperature)
|
||||
->queryCompletion();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBool(): bool
|
||||
{
|
||||
switch (strtolower($this->result)) {
|
||||
case 'true':
|
||||
return true;
|
||||
case 'false':
|
||||
return false;
|
||||
default:
|
||||
throw new \InvalidArgumentException("Expected a boolean value, got {$this->result}");
|
||||
}
|
||||
}
|
||||
|
||||
public function getArray(): array
|
||||
{
|
||||
for ($i = 0; $i < $this->retries; $i++) {
|
||||
$payload = Str::of($this->result)->trim();
|
||||
if ($payload->contains('```json')) {
|
||||
$payload = $payload->after('```json')->before('```');
|
||||
} elseif ($payload->contains('```')) {
|
||||
$payload = $payload->after('```')->before('```');
|
||||
}
|
||||
$payload = $payload->toString();
|
||||
$exception = null;
|
||||
|
||||
try {
|
||||
$newPayload = (new JsonFixer())->fix($payload);
|
||||
|
||||
return json_decode($newPayload, true);
|
||||
} catch (\Aws\Exception\InvalidJsonException $e) {
|
||||
$exception = $e;
|
||||
Log::warning('Invalid JSON, retrying:');
|
||||
Log::warning($payload);
|
||||
$this->queryCompletion();
|
||||
}
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
public function getHtml(): string
|
||||
{
|
||||
$payload = Str::of($this->result)->trim();
|
||||
if ($payload->contains('```html')) {
|
||||
$payload = $payload->after('```html')->before('```');
|
||||
} elseif ($payload->contains('```')) {
|
||||
$payload = $payload->after('```')->before('```');
|
||||
}
|
||||
|
||||
return $payload->toString();
|
||||
}
|
||||
|
||||
public function getString(): string
|
||||
{
|
||||
return trim($this->result);
|
||||
}
|
||||
|
||||
public function getTokenUsed(): int
|
||||
{
|
||||
return $this->tokenUsed;
|
||||
}
|
||||
|
||||
protected function computeChatCompletion(array $messages, int $maxTokens = 4096, float $temperature = 0.81): self
|
||||
{
|
||||
if (isset($this->systemMessage) && $messages[0]['role'] !== 'system') {
|
||||
$messages = array_merge([[
|
||||
'role' => 'system',
|
||||
'content' => $this->systemMessage,
|
||||
]], $messages);
|
||||
}
|
||||
|
||||
$completionInput = [
|
||||
'model' => $this->model,
|
||||
'messages' => $messages,
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature,
|
||||
];
|
||||
|
||||
if ($this->expectsJson) {
|
||||
$completionInput['response_format'] = [
|
||||
'type' => 'json_object',
|
||||
];
|
||||
}
|
||||
|
||||
$this->completionInput = $completionInput;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function queryCompletion(): self
|
||||
{
|
||||
if ($this->useStreaming) {
|
||||
return $this->queryStreamedCompletion();
|
||||
}
|
||||
|
||||
try {
|
||||
Log::debug('Open AI query: '.json_encode($this->completionInput));
|
||||
$response = $this->openAi->chat()->create($this->completionInput);
|
||||
} catch (ErrorException $errorException) {
|
||||
// Retry once
|
||||
Log::warning("Open AI error, retrying: {$errorException->getMessage()}");
|
||||
$response = $this->openAi->chat()->create($this->completionInput);
|
||||
}
|
||||
$this->tokenUsed += $response->usage->totalTokens;
|
||||
$this->result = $response->choices[0]->message->content;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function queryStreamedCompletion(): self
|
||||
{
|
||||
Log::debug('Open AI query: '.json_encode($this->completionInput));
|
||||
$this->result = '';
|
||||
$response = $this->openAi->chat()->createStreamed($this->completionInput);
|
||||
foreach ($response as $chunk) {
|
||||
$choice = $chunk->choices[0];
|
||||
if (is_null($choice->delta->role)) {
|
||||
$this->result .= $choice->delta->content;
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
274
api/app/Service/OpenAi/Utils/JsonFixer.php
Normal file
274
api/app/Service/OpenAi/Utils/JsonFixer.php
Normal file
@@ -0,0 +1,274 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\OpenAi\Utils;
|
||||
|
||||
/*
|
||||
* This file is part of the PHP-JSON-FIXER package.
|
||||
*
|
||||
* (c) Jitendra Adhikari <jiten.adhikary@gmail.com>
|
||||
* <https://github.com/adhocore>
|
||||
*
|
||||
* Licensed under MIT license.
|
||||
*/
|
||||
|
||||
use Aws\Exception\InvalidJsonException;
|
||||
|
||||
/**
|
||||
* Attempts to fix truncated JSON by padding contextual counterparts at the end.
|
||||
*
|
||||
* @author Jitendra Adhikari <jiten.adhikary@gmail.com>
|
||||
* @license MIT
|
||||
*
|
||||
* @link https://github.com/adhocore/php-json-fixer
|
||||
*/
|
||||
class JsonFixer
|
||||
{
|
||||
use PadsJson;
|
||||
|
||||
/** @var array Current token stack indexed by position */
|
||||
protected $stack = [];
|
||||
|
||||
/** @var bool If current char is within a string */
|
||||
protected $inStr = false;
|
||||
|
||||
/** @var bool Whether to throw Exception on failure */
|
||||
protected $silent = false;
|
||||
|
||||
/** @var array The complementary pairs */
|
||||
protected $pairs = [
|
||||
'{' => '}',
|
||||
'[' => ']',
|
||||
'"' => '"',
|
||||
];
|
||||
|
||||
/** @var int The last seen object `{` type position */
|
||||
protected $objectPos = -1;
|
||||
|
||||
/** @var int The last seen array `[` type position */
|
||||
protected $arrayPos = -1;
|
||||
|
||||
/** @var string Missing value. (Options: true, false, null) */
|
||||
protected $missingValue = 'null';
|
||||
|
||||
/**
|
||||
* Set/unset silent mode.
|
||||
*
|
||||
* @param bool $silent
|
||||
* @return $this
|
||||
*/
|
||||
public function silent($silent = true)
|
||||
{
|
||||
$this->silent = (bool) $silent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set missing value.
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return $this
|
||||
*/
|
||||
public function missingValue($value)
|
||||
{
|
||||
if ($value === null) {
|
||||
$value = 'null';
|
||||
} elseif (\is_bool($value)) {
|
||||
$value = $value ? 'true' : 'false';
|
||||
}
|
||||
|
||||
$this->missingValue = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix the truncated JSON.
|
||||
*
|
||||
* @param string $json The JSON string to fix.
|
||||
* @return string Fixed JSON. If failed with silent then original JSON.
|
||||
*
|
||||
* @throws InvalidJsonException When fixing fails.
|
||||
*/
|
||||
public function fix($json)
|
||||
{
|
||||
$json = preg_replace('/(?<!\\\\)(?:\\\\{2})*\p{C}+/u', '', $json);
|
||||
[$head, $json, $tail] = $this->trim($json);
|
||||
|
||||
if (empty($json) || $this->isValid($json)) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
if (null !== $tmpJson = $this->quickFix($json)) {
|
||||
return $tmpJson;
|
||||
}
|
||||
|
||||
$this->reset();
|
||||
|
||||
return $head.$this->doFix($json).$tail;
|
||||
}
|
||||
|
||||
protected function trim($json)
|
||||
{
|
||||
\preg_match('/^(\s*)([^\s]+)(\s*)$/', $json, $match);
|
||||
|
||||
$match += ['', '', '', ''];
|
||||
$match[2] = \trim($json);
|
||||
|
||||
\array_shift($match);
|
||||
|
||||
return $match;
|
||||
}
|
||||
|
||||
protected function isValid($json)
|
||||
{
|
||||
\json_decode($json, true, 512, JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
|
||||
return \json_last_error() === \JSON_ERROR_NONE;
|
||||
}
|
||||
|
||||
protected function quickFix($json)
|
||||
{
|
||||
if (\strlen($json) === 1 && isset($this->pairs[$json])) {
|
||||
return $json.$this->pairs[$json];
|
||||
}
|
||||
|
||||
if ($json[0] !== '"') {
|
||||
return $this->maybeLiteral($json);
|
||||
}
|
||||
|
||||
return $this->padString($json);
|
||||
}
|
||||
|
||||
protected function reset()
|
||||
{
|
||||
$this->stack = [];
|
||||
$this->inStr = false;
|
||||
$this->objectPos = -1;
|
||||
$this->arrayPos = -1;
|
||||
}
|
||||
|
||||
protected function maybeLiteral($json)
|
||||
{
|
||||
if (! \in_array($json[0], ['t', 'f', 'n'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (['true', 'false', 'null'] as $literal) {
|
||||
if (\strpos($literal, $json) === 0) {
|
||||
return $literal;
|
||||
}
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
return null;
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
protected function doFix($json)
|
||||
{
|
||||
[$index, $char] = [-1, ''];
|
||||
|
||||
while (isset($json[++$index])) {
|
||||
[$prev, $char] = [$char, $json[$index]];
|
||||
|
||||
$next = isset($json[$index + 1]) ? $json[$index + 1] : '';
|
||||
|
||||
if (! \in_array($char, [' ', "\n", "\r"])) {
|
||||
$this->stack($prev, $char, $index, $next);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->fixOrFail($json);
|
||||
}
|
||||
|
||||
protected function stack($prev, $char, $index, $next)
|
||||
{
|
||||
if ($this->maybeStr($prev, $char, $index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$last = $this->lastToken();
|
||||
|
||||
if (\in_array($last, [',', ':', '"']) && \preg_match('/\"|\d|\{|\[|t|f|n/', $char)) {
|
||||
$this->popToken();
|
||||
}
|
||||
|
||||
if (\in_array($char, [',', ':', '[', '{'])) {
|
||||
$this->stack[$index] = $char;
|
||||
}
|
||||
|
||||
$this->updatePos($char, $index);
|
||||
}
|
||||
|
||||
protected function lastToken()
|
||||
{
|
||||
return \end($this->stack);
|
||||
}
|
||||
|
||||
protected function popToken($token = null)
|
||||
{
|
||||
// Last one
|
||||
if ($token === null) {
|
||||
return \array_pop($this->stack);
|
||||
}
|
||||
|
||||
$keys = \array_reverse(\array_keys($this->stack));
|
||||
foreach ($keys as $key) {
|
||||
if ($this->stack[$key] === $token) {
|
||||
unset($this->stack[$key]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function maybeStr($prev, $char, $index)
|
||||
{
|
||||
if ($prev !== '\\' && $char === '"') {
|
||||
$this->inStr = ! $this->inStr;
|
||||
}
|
||||
|
||||
if ($this->inStr && $this->lastToken() !== '"') {
|
||||
$this->stack[$index] = '"';
|
||||
}
|
||||
|
||||
return $this->inStr;
|
||||
}
|
||||
|
||||
protected function updatePos($char, $index)
|
||||
{
|
||||
if ($char === '{') {
|
||||
$this->objectPos = $index;
|
||||
} elseif ($char === '}') {
|
||||
$this->popToken('{');
|
||||
$this->objectPos = -1;
|
||||
} elseif ($char === '[') {
|
||||
$this->arrayPos = $index;
|
||||
} elseif ($char === ']') {
|
||||
$this->popToken('[');
|
||||
$this->arrayPos = -1;
|
||||
}
|
||||
}
|
||||
|
||||
protected function fixOrFail($json)
|
||||
{
|
||||
$length = \strlen($json);
|
||||
$tmpJson = $this->pad($json);
|
||||
|
||||
if ($this->isValid($tmpJson)) {
|
||||
return $tmpJson;
|
||||
}
|
||||
|
||||
if ($this->silent) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
\Log::debug('Broken json received: ', [
|
||||
'json' => $json,
|
||||
]);
|
||||
|
||||
throw new InvalidJsonException(
|
||||
\sprintf('Could not fix JSON (tried padding `%s`)', \substr($tmpJson, $length), $json)
|
||||
);
|
||||
}
|
||||
}
|
||||
113
api/app/Service/OpenAi/Utils/PadsJson.php
Normal file
113
api/app/Service/OpenAi/Utils/PadsJson.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service\OpenAi\Utils;
|
||||
|
||||
/**
|
||||
* Attempts to fix truncated JSON by padding contextual counterparts at the end.
|
||||
*
|
||||
* @author Jitendra Adhikari <jiten.adhikary@gmail.com>
|
||||
* @license MIT
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @link https://github.com/adhocore/php-json-fixer
|
||||
*/
|
||||
trait PadsJson
|
||||
{
|
||||
public function pad($tmpJson)
|
||||
{
|
||||
if (! $this->inStr) {
|
||||
$tmpJson = \rtrim($tmpJson, ',');
|
||||
while ($this->lastToken() === ',') {
|
||||
$this->popToken();
|
||||
}
|
||||
}
|
||||
|
||||
$tmpJson = $this->padLiteral($tmpJson);
|
||||
$tmpJson = $this->padObject($tmpJson);
|
||||
|
||||
return $this->padStack($tmpJson);
|
||||
}
|
||||
|
||||
protected function padLiteral($tmpJson)
|
||||
{
|
||||
if ($this->inStr) {
|
||||
return $tmpJson;
|
||||
}
|
||||
|
||||
$match = \preg_match('/(tr?u?e?|fa?l?s?e?|nu?l?l?)$/', $tmpJson, $matches);
|
||||
|
||||
if (! $match || null === $literal = $this->maybeLiteral($matches[1])) {
|
||||
return $tmpJson;
|
||||
}
|
||||
|
||||
return \substr($tmpJson, 0, 0 - \strlen($matches[1])).$literal;
|
||||
}
|
||||
|
||||
protected function padStack($tmpJson)
|
||||
{
|
||||
foreach (\array_reverse($this->stack, true) as $token) {
|
||||
if (isset($this->pairs[$token])) {
|
||||
$tmpJson .= $this->pairs[$token];
|
||||
}
|
||||
}
|
||||
|
||||
return $tmpJson;
|
||||
}
|
||||
|
||||
protected function padObject($tmpJson)
|
||||
{
|
||||
if (! $this->objectNeedsPadding($tmpJson)) {
|
||||
return $tmpJson;
|
||||
}
|
||||
|
||||
$part = \substr($tmpJson, $this->objectPos + 1);
|
||||
if (\preg_match('/(\s*\"[^"]+\"\s*:\s*[^,]+,?)+$/', $part, $matches)) {
|
||||
return $tmpJson;
|
||||
}
|
||||
|
||||
if ($this->inStr) {
|
||||
$tmpJson .= '"';
|
||||
}
|
||||
|
||||
$tmpJson = $this->padIf($tmpJson, ':');
|
||||
$tmpJson = $tmpJson.$this->missingValue;
|
||||
|
||||
if ($this->lastToken() === '"') {
|
||||
$this->popToken();
|
||||
}
|
||||
|
||||
return $tmpJson;
|
||||
}
|
||||
|
||||
protected function objectNeedsPadding($tmpJson)
|
||||
{
|
||||
$last = \substr($tmpJson, -1);
|
||||
$empty = $last === '{' && ! $this->inStr;
|
||||
|
||||
return ! $empty && $this->arrayPos < $this->objectPos;
|
||||
}
|
||||
|
||||
protected function padString($string)
|
||||
{
|
||||
$last = \substr($string, -1);
|
||||
$last2 = \substr($string, -2);
|
||||
|
||||
if ($last2 === '\"' || $last !== '"') {
|
||||
return $string.'"';
|
||||
}
|
||||
|
||||
// @codeCoverageIgnoreStart
|
||||
return null;
|
||||
// @codeCoverageIgnoreEnd
|
||||
}
|
||||
|
||||
protected function padIf($string, $substr)
|
||||
{
|
||||
if (\substr($string, 0 - \strlen($substr)) !== $substr) {
|
||||
return $string.$substr;
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user