From 472b1a806113c78b3606ba2d3f5744316fc71d32 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Tue, 14 Mar 2023 12:01:36 +0100 Subject: [PATCH] Automated the generation of form templates --- .env.example | 2 + app/Console/Commands/GenerateTemplate.php | 236 +++++++++++++++ app/Models/Template.php | 15 +- app/Service/OpenAi/GptCompleter.php | 126 ++++++++ app/Service/OpenAi/Utils/JsonFixer.php | 272 ++++++++++++++++++ app/Service/OpenAi/Utils/PadsJson.php | 113 ++++++++ composer.json | 1 + composer.lock | 83 +++++- config/services.php | 9 + ...change_templates_image_url_to_nullable.php | 33 +++ .../js/components/open/forms/OpenForm.vue | 4 +- resources/js/pages/templates/show.vue | 6 +- 12 files changed, 893 insertions(+), 7 deletions(-) create mode 100644 app/Console/Commands/GenerateTemplate.php create mode 100644 app/Service/OpenAi/GptCompleter.php create mode 100644 app/Service/OpenAi/Utils/JsonFixer.php create mode 100644 app/Service/OpenAi/Utils/PadsJson.php create mode 100644 database/migrations/2023_03_14_094623_change_templates_image_url_to_nullable.php diff --git a/.env.example b/.env.example index cb01683..46c268d 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,5 @@ STRIPE_SECRET= MUX_WORKSPACE_ID= MUX_API_TOKEN= + +OPEN_AI_API_KEY= diff --git a/app/Console/Commands/GenerateTemplate.php b/app/Console/Commands/GenerateTemplate.php new file mode 100644 index 0000000..dfa3d02 --- /dev/null +++ b/app/Console/Commands/GenerateTemplate.php @@ -0,0 +1,236 @@ +Looking for a real person to speak to?

We're here for you! Just drop in your queries below and we'll connect with you as soon as we can.

", + "re_fillable": false, + "use_captcha": false, + "redirect_url": null, + "submitted_text": "

Great, we've received your message. We'll get back to you as soon as we can :)

", + "uppercase_labels": false, + "submit_button_text": "Submit", + "re_fill_button_text": "Fill Again", + "color": "#3B82F6" + } + ``` + The form properties can have one of the following types: 'text', 'number', 'select', 'multi_select', 'date', 'files', 'checkbox', 'url', 'email', 'phone_number'. + All form properties objects need to have the keys 'help', 'name', 'type', 'hidden', 'placeholder', 'prefill'. + + For the type "select" and "multi_select", the input object must have a key "select" (or "multi_select") that's mapped to an object like this one: + ```json + { + "options": [ + {"id":"Option 1","name":"Option 1"}, + {"id":"Pption 2","name":"Option 2"} + ] + } + ``` + + Give me the JSON code only, for the following form: "[REPLACE]" + Do not ask me for more information about required properties or types, suggest me a form structure instead. + EOD; + + const FORM_DESCRIPTION_PROMPT = <<completeChat([ + ["role" => "system", "content" => "You are a robot helping to generate forms."], + ["role" => "user", "content" => Str::of(self::FORM_STRUCTURE_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString()] + ],3000); + $formData = $completer->getArray(); + + // Now get description and QAs + $formDescriptionPrompt = Str::of(self::FORM_DESCRIPTION_PROMPT)->replace('[REPLACE]', $this->argument('prompt'))->toString(); + $formDescription = $completer->completeChat([ + ["role" => "system", "content" => "You are a robot helping to generate forms."], + ["role" => "user", "content" => $formDescriptionPrompt] + ])->getString(); + $formQAs = $completer->completeChat([ + ["role" => "system", "content" => "You are a robot helping to generate forms."], + ["role" => "user", "content" => $formDescriptionPrompt], + ["role" => "assistant", "content" => $formDescription], + ["role" => "user", "content" => self::FORM_QAS_PROMPT] + ])->getArray(); + $formTitle = $completer->completeChat([ + ["role" => "system", "content" => "You are a robot helping to generate forms."], + ["role" => "user", "content" => $formDescriptionPrompt], + ["role" => "assistant", "content" => $formDescription], + ["role" => "user", "content" => self::FORM_TITLE_PROMPT] + ])->getString(); + + // Finally get keyworks for image cover + $formCoverKeyworks = $completer->completeChat([ + ["role" => "system", "content" => "You are a robot helping to generate forms."], + ["role" => "user", "content" => $formDescriptionPrompt], + ["role" => "assistant", "content" => $formDescription], + ["role" => "user", "content" => self::FORM_IMG_KEYWORDS_PROMPT] + ])->getArray(); + $imageUrl = $this->getImageCoverUrl($formCoverKeyworks['search_query']); + + $template = $this->createFormTemplate($formData, $formTitle, $formDescription, $formQAs, $imageUrl); + $this->info('/templates/' . $template->slug); + + return Command::SUCCESS; + } + + /** + * Get an image cover URL for the template using unsplash API + */ + private function getImageCoverUrl($searchQuery): ?string + { + $url = 'https://api.unsplash.com/search/photos?query=' . urlencode($searchQuery) . '&client_id=' . config('services.unslash.access_key'); + $response = Http::get($url)->json(); + ray($response, $url); + if (isset($response['results'][0]['urls']['regular'])) { + return $response['results'][0]['urls']['regular']; + } + return null; + } + + private function createFormTemplate(array $formData, string $formTitle, string $formDescription, array $formQAs, ?string $imageUrl) + { + // Add property uuids + foreach ($formData['properties'] as &$property) { + $property['id'] = Str::uuid()->toString(); + } + + return Template::create([ + 'name' => $formTitle, + 'description' => $formDescription, + 'questions' => $formQAs, + 'structure' => $formData, + 'image_url' => $imageUrl, + ]); + } +} diff --git a/app/Models/Template.php b/app/Models/Template.php index cbf541b..db3d652 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -4,11 +4,13 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Spatie\Sluggable\HasSlug; +use Spatie\Sluggable\SlugOptions; use Stevebauman\Purify\Facades\Purify; class Template extends Model { - use HasFactory; + use HasFactory, HasSlug; protected $fillable = [ 'name', @@ -29,4 +31,15 @@ class Template extends Model // Strip out unwanted html $this->attributes['description'] = Purify::clean($value); } + + /** + * Config/options + */ + public function getSlugOptions(): SlugOptions + { + return SlugOptions::create() + ->doNotGenerateSlugsOnUpdate() + ->generateSlugsFrom('name') + ->saveSlugsTo('slug'); + } } diff --git a/app/Service/OpenAi/GptCompleter.php b/app/Service/OpenAi/GptCompleter.php new file mode 100644 index 0000000..c5e5b5a --- /dev/null +++ b/app/Service/OpenAi/GptCompleter.php @@ -0,0 +1,126 @@ +openAi = \OpenAI::client($apiKey); + } + + public function setSystemMessage(string $systemMessage): self + { + $this->systemMessage = $systemMessage; + return $this; + } + + public function completeChat(array $messages, int $maxTokens = 512, float $temperature = 0.81): self + { + $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 + { + $payload = Str::of($this->result)->trim(); + if ($payload->contains('```json')) { + $payload = $payload->after('```json')->before('```'); + } else if ($payload->contains('```')) { + $payload = $payload->after('```')->before('```'); + } + $payload = $payload->toString(); + $exception = null; + + for ($i = 0; $i < $this->retries; $i++) { + try { + $payload = (new JsonFixer)->fix($payload); + return json_decode($payload, true); + } catch (\Aws\Exception\InvalidJsonException $e) { + $exception = $e; + Log::warning("Invalid JSON, retrying:"); + Log::warning($payload); + Log::warning(json_encode($this->completionInput)); + $this->queryCompletion(); + } + } + throw $exception; + } + + public function getString(): string + { + return trim($this->result); + } + + public function getTokenUsed(): int + { + return $this->tokenUsed; + } + + protected function computeChatCompletion(array $messages, int $maxTokens = 512, float $temperature = 0.81): self + { + if (isset($this->systemMessage)) { + $messages = array_merge([ + 'role' => 'system', + 'content' => $this->systemMessage + ], $messages); + } + + $completionInput = [ + 'model' => self::AI_MODEL, + 'messages' => $messages, + 'max_tokens' => $maxTokens, + 'temperature' => $temperature + ]; + + $this->completionInput = $completionInput; + return $this; + } + + protected function queryCompletion(): self { + 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; + } +} diff --git a/app/Service/OpenAi/Utils/JsonFixer.php b/app/Service/OpenAi/Utils/JsonFixer.php new file mode 100644 index 0000000..f65cfa8 --- /dev/null +++ b/app/Service/OpenAi/Utils/JsonFixer.php @@ -0,0 +1,272 @@ + + * + * + * Licensed under MIT license. + */ + +use App\Exceptions\Coursework\InvalidJsonException; + +/** + * Attempts to fix truncated JSON by padding contextual counterparts at the end. + * + * @author Jitendra Adhikari + * @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) + { + list($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); + + return \JSON_ERROR_NONE === \json_last_error(); + } + + 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) + { + list($index, $char) = [-1, '']; + + while (isset($json[++$index])) { + list($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 (null === $token) { + 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; + } + + throw new InvalidJsonException( + \sprintf('Could not fix JSON (tried padding `%s`)', \substr($tmpJson, $length), $json) + ); + } +} diff --git a/app/Service/OpenAi/Utils/PadsJson.php b/app/Service/OpenAi/Utils/PadsJson.php new file mode 100644 index 0000000..6d17c42 --- /dev/null +++ b/app/Service/OpenAi/Utils/PadsJson.php @@ -0,0 +1,113 @@ + + * @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; + } +} diff --git a/composer.json b/composer.json index 28ac9a2..9d4cd64 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "laravel/vapor-ui": "^1.5", "league/flysystem-aws-s3-v3": "^3.0", "maatwebsite/excel": "^3.1", + "openai-php/client": "^0.3.5", "sentry/sentry-laravel": "^2.11.0", "spatie/laravel-sitemap": "^6.0", "spatie/laravel-sluggable": "^3.0", diff --git a/composer.lock b/composer.lock index 8858df1..ff1f0c4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "417c01c6f73f7e870992a7d71e75f363", + "content-hash": "54e1e706f48dc10e931a4411e310be5c", "packages": [ { "name": "asm89/stack-cors", @@ -4972,6 +4972,87 @@ ], "time": "2022-06-22T07:13:36+00:00" }, + { + "name": "openai-php/client", + "version": "v0.3.5", + "source": { + "type": "git", + "url": "https://github.com/openai-php/client.git", + "reference": "0bb01e93fbd1f155af1c5a70caa53252dd13478f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openai-php/client/zipball/0bb01e93fbd1f155af1c5a70caa53252dd13478f", + "reference": "0bb01e93fbd1f155af1c5a70caa53252dd13478f", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.5.0", + "php": "^8.1.0" + }, + "require-dev": { + "laravel/pint": "^1.6.0", + "nunomaduro/collision": "^7.0.5", + "pestphp/pest": "^2.0.0", + "pestphp/pest-plugin-arch": "^2.0.0", + "pestphp/pest-plugin-mock": "^2.0.0", + "phpstan/phpstan": "^1.10.3", + "rector/rector": "^0.14.8", + "symfony/var-dumper": "^6.2.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/OpenAI.php" + ], + "psr-4": { + "OpenAI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API", + "keywords": [ + "GPT-3", + "api", + "client", + "codex", + "dall-e", + "language", + "natural", + "openai", + "php", + "processing", + "sdk" + ], + "support": { + "issues": "https://github.com/openai-php/client/issues", + "source": "https://github.com/openai-php/client/tree/v0.3.5" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2023-03-08T06:39:38+00:00" + }, { "name": "phenx/php-font-lib", "version": "0.5.4", diff --git a/config/services.php b/config/services.php index 403502f..4120b72 100644 --- a/config/services.php +++ b/config/services.php @@ -48,6 +48,15 @@ return [ 'worker' => env('NOTION_WORKER','https://notion-forms-worker.notionforms.workers.dev/v1') ], + 'openai' => [ + 'api_key' => env('OPEN_AI_API_KEY'), + ], + + 'unslash' => [ + 'access_key' => env('UNSPLASH_ACCESS_KEY'), + 'secret_key' => env('UNSPLASH_SECRET_KEY'), + ], + 'google_analytics_code' => env('GOOGLE_ANALYTICS_CODE'), 'amplitude_code' => env('AMPLITUDE_CODE'), 'crisp_website_id' => env('CRISP_WEBSITE_ID'), diff --git a/database/migrations/2023_03_14_094623_change_templates_image_url_to_nullable.php b/database/migrations/2023_03_14_094623_change_templates_image_url_to_nullable.php new file mode 100644 index 0000000..8dbe017 --- /dev/null +++ b/database/migrations/2023_03_14_094623_change_templates_image_url_to_nullable.php @@ -0,0 +1,33 @@ +string('image_url')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('templates', function (Blueprint $table) { + $table->string('image_url')->nullable(false)->change(); + }); + } +}; diff --git a/resources/js/components/open/forms/OpenForm.vue b/resources/js/components/open/forms/OpenForm.vue index 7aee2b8..6d0bf8f 100644 --- a/resources/js/components/open/forms/OpenForm.vue +++ b/resources/js/components/open/forms/OpenForm.vue @@ -22,7 +22,7 @@ class="nf-code w-full px-2 mb-3" v-html="field.content" /> -
Template Preview + class="mb-4 p-4 bg-gray-50 rounded-lg"/>

Frequently asked questions

-
{{ ques.question }}
-
+
{{ ques.question }}
+