Automated the generation of form templates

This commit is contained in:
Julien Nahum 2023-03-14 12:01:36 +01:00
parent 5df4488c25
commit 472b1a8061
12 changed files with 893 additions and 7 deletions

View File

@ -58,3 +58,5 @@ STRIPE_SECRET=
MUX_WORKSPACE_ID=
MUX_API_TOKEN=
OPEN_AI_API_KEY=

View File

@ -0,0 +1,236 @@
<?php
namespace App\Console\Commands;
use App\Models\Template;
use App\Service\OpenAi\GptCompleter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
class GenerateTemplate extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ai:make-form-template {prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generates a new form template from a prompt';
const FORM_STRUCTURE_PROMPT = <<<EOD
I created a form builder. Forms are represented as Json objects. Here's an example form:
```json
{
"title": "Contact Form",
"properties": [
{
"help": null,
"name": "What's your name?",
"type": "text",
"hidden": false,
"prefill": null,
"required": true,
"placeholder": null
},
{
"help": null,
"name": "Email",
"type": "email",
"hidden": false,
"prefill": null,
"required": true,
"placeholder": null
},
{
"help": null,
"name": "How would you rate your overall experience?",
"type": "select",
"hidden": false,
"select": {
"options": [
{
"id": "Below Average",
"name": "Below Average"
},
{
"id": "Average",
"name": "Average"
},
{
"id": "Above Average",
"name": "Above Average"
}
]
},
"prefill": null,
"required": true,
"placeholder": null,
},
{
"help": null,
"name": "Subject",
"type": "text",
"hidden": false,
"prefill": null,
"required": true,
"placeholder": null
},
{
"help": null,
"name": "How can we help?",
"type": "text",
"hidden": false,
"prefill": null,
"required": true,
"multi_lines": true,
"placeholder": null,
"generates_uuid": false,
"max_char_limit": "2000",
"hide_field_name": false,
"show_char_limit": false,
"generates_auto_increment_id": false
},
{
"help": null,
"name": "Have any attachments?",
"type": "files",
"hidden": false,
"prefill": null,
"placeholder": null
}
],
"description": "<p>Looking for a real person to speak to?</p><p>We're here for you! Just drop in your queries below and we'll connect with you as soon as we can.</p>",
"re_fillable": false,
"use_captcha": false,
"redirect_url": null,
"submitted_text": "<p>Great, we've received your message. We'll get back to you as soon as we can :)</p>",
"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 = <<<EOD
I own a form builder online named OpnForm. It's free to use. Give me a description for a template page for the following form: [REPLACE]. Explain what the form is about, and that it takes seconds to duplicate the template to create your own version it and to start getting some submissions.
EOD;
const FORM_QAS_PROMPT = <<<EOD
Now give me 3 to 5 question and answers to put on the form template page. The questions should be about the reasons for this template (when to use, why, target audience, goal etc.) and OpnForm's usage. Reply only with a valid JSON, being an array of object containing the keys "question" and "answer".
EOD;
const FORM_TITLE_PROMPT = <<<EOD
Finally give me a title for the template. It should be short and to the point, without any quotes.
EOD;
const FORM_IMG_KEYWORDS_PROMPT = <<<EOD
I want to add an image to illustrate this form template page. Give me a releveant search query for unsplash. Reply only with a valid JSON like this:
```json
{
"search_query": ""
}
```
EOD;
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
// Get form structture
$completer = new GptCompleter(config('services.openai.api_key'));
$completer->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,
]);
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,126 @@
<?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
{
const AI_MODEL = 'gpt-3.5-turbo';
protected Client $openAi;
protected mixed $result;
protected array $completionInput;
protected ?string $systemMessage;
protected int $tokenUsed = 0;
public function __construct(string $apiKey, protected int $retries = 2)
{
$this->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;
}
}

View File

@ -0,0 +1,272 @@
<?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 App\Exceptions\Coursework\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)
{
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)
);
}
}

View 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;
}
}

View File

@ -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",

83
composer.lock generated
View File

@ -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",

View File

@ -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'),

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('templates', function (Blueprint $table) {
// Make image_url nullable
$table->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();
});
}
};

View File

@ -22,7 +22,7 @@
class="nf-code w-full px-2 mb-3"
v-html="field.content"
/>
<div v-if="field.type === 'nf-divider'" :id="field.id" :key="field.id"
<div v-if="field.type === 'nf-divider'" :id="field.id" :key="field.id"
class="border-b my-4 w-full mx-2"
/>
<div v-if="field.type === 'nf-image' && (field.image_block || !isPublicFormPage)" :id="field.id"
@ -162,7 +162,7 @@ export default {
},
/**
* Returns true if we're on the last page
* @returns {boolean}
* @returns {boolean}xs
*/
isLastPage() {
return this.currentFieldGroupIndex === (this.fieldGroups.length - 1)

View File

@ -31,14 +31,14 @@
<h3 class="text-center text-gray-500 mt-8 mb-2">Template Preview</h3>
<open-complete-form ref="open-complete-form" :form="form" :creating="true"
class="mb-4 p-4 bg-gray-50 rounded-lg overflow-hidden"/>
class="mb-4 p-4 bg-gray-50 rounded-lg"/>
<div v-if="template.questions.length > 0" id="questions">
<h3 class="text-xl font-semibold mt-8">Frequently asked questions</h3>
<div class="pt-2">
<div v-for="(ques,ques_key) in template.questions" :key="ques_key" class="my-3 border rounded-lg">
<h5 class="border-b p-2">{{ ques.question }}</h5>
<div class="p-2" v-html="ques.answer"></div>
<h5 class="border-b p-2 text-gray-700 font-semibold">{{ ques.question }}</h5>
<p class="p-2 text-gray-600" v-html="ques.answer"></p>
</div>
</div>
</div>