Using GPT-3 as a Programming Assistant
Well, I’m back from a hiatus of over a year, hopefully to add some more useful content to this blog.
Lately I’ve been having a lot of fun with GPT-3.
I’ve heard a lot of claims that it will one day be able to replace programmers, so I wanted to see how far I could take it.
Simple Usage
I find it does particularly well with simple requests. The other day I wanted to see how to programmatically get the meta description from a WordPress site that uses the Yoast plugin. Too lazy to Google it, I thought I’d see what GPT-3 could offer, so I asked:
How can I export Yoast SEO meta descriptions from WordPress posts using PHP?
Here was its response:
You can use the WordPress function get_post_meta() to get the SEO meta descriptions from a post. The code might look something like this:
$seo_descr = get_post_meta(get_the_ID(), '_yoast_wpseo_metadesc', true);
Now I admit, I’m useless when it comes to WordPress programming, mostly because I hate WordPress so much as a programming environment. so, wanting to know what the true
was about, i asked:
What does the third argument of the WordPress
get_post_meta()
function do?
And GPT-3 answered:
The third argument of the WordPress
get_post_meta()
function is a boolean value indicating whether to return an array or a single value. If this argument is set totrue
, an array of the post meta values will be returned; if it is set tofalse
, only the first value matching the given meta key will be returned.
I’m pretty impressed by that answer. And yes, it included the markdown formatting in the response, since I had included it in my question.
Anyway, the task I really wanted was to output a list of all posts, ordered chronologically, in the format title: meta description
. So I asked it this:
In PHP, output a list of all blog post titles ordered chronologically. Also include the value of the
_yoast_wpseo_metadesc
meta key.For example:
Title 1: description 1
Title 2: description 2
Title 3: description 3
Here was the answer:
<?php $args = array('post_type'=> 'post', 'posts_per_page' => -1, 'orderby'=> 'date', 'order'=> 'DESC'); $blogPosts = get_posts( $args ); foreach( $blogPosts as $blogPost ): $title = get_the_title($blogPost->ID); $description = get_post_meta( $blogPost->ID, '_yoast_wpseo_metadesc', true); echo $title . ': ' . $description . '<br/>'; endforeach; ?>
OK, pretty good overall.
I’m a bit annoyed at the foreach
/endforeach
format, but it seems to assume this is placed in a template, seeing as it ended each line in <br/>
.
I also think the code style leaves a bit to be desired, and wish it would have used short array syntax, but all that is easily fixed.
It’s funny to see the assumption it made though, that I wanted it ordered chronologically from most recent to oldest. To be fair, I hadn’t specified that in my request.
But overall, that’s a pretty good job, and I verified the code did indeed work.
Building a Symfony Console Application with GPT-3
But the question is, how would it handle something with greater complexity?
I’ve been working on building a console application in Symfony that helps solve the NYTimes Letter Boxed game.
If you’re not familiar with Letter Boxed, it’s a word game where you create words from letters in a square grid. Words must be at least 3 letters in length, letters can be reused, and consecutive letters must not come from the same side. The last letter of a word becomes the first letter of the next word. The aim is to use all letters to solve the puzzle in as few words as possible.
The console application would accept a list of one or more words, validate that they follow the rules of the game, then return any letters that haven’t yet been used by the given words.
For example, the January 19, 2023 puzzle had these letters:
- XBM
- INA
- OYL
- TEC
If we feed the word “exclamation” into the application, it should return the letters “BY”, since those are the only letters left that haven’t been used in the provided word.
Let’s see how far we can get with only GPT-3.
Create the Entry Point
Request:
Create a console script in PHP which will be the entry point into a Symphony console application. It must include
vendor/autoload.php
first. It should create a newApplication
object and run it.
<?php
require 'vendor/autoload.php';
use Symfony\Component\Console\Application;
$application = new Application();
$application->run();
Pretty good, but I want this to be an executable script, and I also want to enable strict types. While we’re at it, we’ll set the title of the application to Letter Boxed Solver
.
Create a console script in PHP which will be the entry point into a Symphony console application. First enable strict types. It must include
vendor/autoload.php
first. It should create a newApplication
object and run it.This is an executable script so it should begin with a call to the PHP executable.
The title of the application is
Letter Boxed Solver
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\Console\Application;
$app = new Application('Letter Boxed Solver');
$app->run();
Yep that’s pretty good. And it works, too, albeit with no current functionality.
Creating the Command
Create a Symphony command called
Devbanana\LetterBoxed\Command\RemainingCommand
.
- It should declare strict types.
- It should use the
AsCommand
PHP attribute to declare it is a command. The command’s name isremaining
and the description isDetermine remaining letters after word(s)
.- The help text is,
Determine which letters are left after the given words
.- It should accept an argument called
letters
, which is required and is an array. The description of the argument is:The letters of the puzzle (each side separated by a space)
- It should accept an option called
word
(short formw
) which requires a value and is an array. The description of the option is:The word(s) in the puzzle so far
.- For outputting text, it should use
SymfonyStyle
assigned to$io
.- With
$io
, it should output the titleRemaining
.- It should check that the
letters
argument is an array with 4 elements. If not, it should output the error,There must be exactly 4 sets of letters.
. This should be written tostderr
using thegetErrorStyle()
method of$io
. It should returnCommand::INVALID
.- Otherwise, it should return
Command::SUCCESS
.
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* @AsCommand(name="remaining", description="Determine remaining letters after word(s)")
*/
class RemainingCommand extends Command
{
protected function configure()
{
$this
->setDescription('Determine which letters are left after the given words')
->addArgument('letters', InputArgument::REQUIRED, 'The letters of the puzzle (each side separated by a space)')
->addOption('word', 'w', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The word(s) in the puzzle so far');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
// create a style helper for outputting text
$io = new SymfonyStyle($input, $output);
// output the title
$io->title('Remaining');
// check that the argument is an array with 4 elements
$letters = $input->getArgument('letters');
if (!is_array($letters) || count($letters) !== 4) {
$io->getErrorStyle()->error('There must be exactly 4 sets of letters.');
return Command::INVALID;
}
// TODO: add logic
return Command::SUCCESS;
}
}
Funnily enough, it actually output this code in markdown format, too.
Anyway, this is pretty good overall.
Unfortunately it seems to not know about PHP attributes. I tried several times to get it to output a proper #[AsCommand()]
attribute but it wouldn’t do it.
I had to be very specific about using SymfonyStyle::getErrorStyle()
, but it got the point in the end.
It didn’t actually define my letters
argument as an array for some reason.
And finally, it didn’t declare the return types of the methods.
But again, pretty good overall — it just requires some manual oversight.
Here’s my modified version after some styling using my preferred code style:
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'remaining', description: 'Determine remaining letters after word(s)')]
final class RemainingCommand extends Command
{
protected function configure(): void
{
$this
->setHelp('Determine which letters are left after the given words')
->addArgument('letters', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'The letters of the puzzle (each side separated by a space)')
->addOption('word', 'w', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The word(s) in the puzzle so far')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Remaining');
$letters = $input->getArgument('letters');
if (!\is_array($letters) || \count($letters) !== 4) {
$io->getErrorStyle()->error('There must be exactly 4 sets of letters.');
return Command::INVALID;
}
// TODO: add logic
return Command::SUCCESS;
}
}
I’m also realizing there’s no validation that at least one word is passed. A value is required if the option is given, but we should validate a word is actually given.
Given the following command, ensure the
word
option is an array that is not empty. If it is empty, output the errorAt least 1 word is required.
<script excluded for brevity>
<?php
// ...
$words = $input->getOption('word');
if (!\is_array($words) || empty($words)) {
$io->getErrorStyle()->error('At least 1 word is required.');
return Command::INVALID;
}
// ...
OK that’s cool: no joke, it only output the part of the script that had changed. It even included the // ...
comments. And I no longer have to be so specific about how to output errors, because it sees how it was already done before.
And now let’s add this command to the entry point:
Given the following PHP script, add the
Devbanana\LetterBoxed\Command\RemainingCommand
command.
<script excluded for brevity>
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\Console\Application;
use Devbanana\LetterBoxed\Command\RemainingCommand;
$app = new Application('Letter Boxed Solver');
$app->add(new RemainingCommand());
$app->run();
It did a great job adding the command to the script. Of course it’d likely be faster if I just added it myself, but the point in this post is to have the AI do as much as possible.
Validating Input
OK, so we want to take the given words and validate that they are valid according to the rules of Letter Boxed.
These are complicated rules but let’s see what we can do.
Create a PHP class
Devbanana\LetterBoxed\Validator\WordValidator
. The class should be final and have one method calledvalidate()
.The
validate()
method accepts an array of words, which cannot be empty, and a second array of sides, which are strings of 3 letters each.$sides
is in uppercase and should be compared case insensitively. There must be exactly 4 sides.Each word must pass the following rules:
- Each word must be at least 3 letters in length.
- The first letter of each word must equal the last letter of the previous word, unless it is the first word.
- Two consecutive letters of the word must come from different elements of
$sides
. For example, the first letter could come from$sides[1]
, and the second letter from$sides[3]
but cannot come from$sides[1]
. There are always 4 elements of$sides
.Here is the stub class;
<?php declare(strict_types=1); namespace Devbanana\LetterBoxed\Validator; final class WordValidator { /** * @param string[] $words * @param string[] $sides */ public function validate(array $words, array $sides): bool { return true; } }
Modified class:
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Validator;
final class WordValidator
{
/**
* @param string[] $words
* @param string[] $sides
*/
public function validate(array $words, array $sides): bool
{
// Ensure there are 4 sides
if (count($sides) !== 4) {
return false;
}
// Ensure there are words
if (empty($words)) {
return false;
}
// Go through each word and ensure that it meets the rules
$lastLetter = null;
foreach ($words as $word) {
// Word must be at least 3 letters
if (strlen($word) < 3) {
return false;
}
// First letter must match last letter of previous word
if ($lastLetter !== null && $word[0] !== $lastLetter) {
return false;
}
// Two consecutive letters must come from different sides
for ($i = 0; $i < strlen($word) - 1; $i++) {
if (strtoupper($word[$i]) === strtoupper($word[$i + 1])) {
$side1 = array_search(strtoupper($word[$i]), array_map('strtoupper', $sides));
$side2 = array_search(strtoupper($word[$i + 1]), array_map('strtoupper', $sides));
if ($side1 === false || $side1 === $side2) {
return false;
}
}
}
// Update last letter
$lastLetter = $word[strlen($word) - 1];
}
// All tests have passed
return true;
}
}
This time I included an outline for the class so GPT-3 would get a better feel for my stylistic preferences, which seems to have helped.
I can already see from the start that this code won’t work how I want it to.
It got most of the rules right, like the first letter of one word needing to match the last letter of the previous word.
But it totally botched the requirement that consecutive letters have to come from different sides, probably because it’s so hard to describe it clearly and concisely.
But just to check, I wrote some unit tests that this needs to pass. I’m linking to the tests to save space here.
$ vendor/bin/phpunit tests/Validator/WordValidatorTest.php
PHPUnit 9.6-dev by Sebastian Bergmann and contributors.
.F... 5 / 5 (100%)
Time: 00:00.007, Memory: 4.00 MB
There was 1 failure:
1) Devbanana\LetterBoxed\Tests\Validator\WordValidatorTest::testConsecutiveLettersFromSameSideAreInvalid
Failed asserting that true is false.
/path/to/letter-boxed/tests/Validator/WordValidatorTest.php:35
FAILURES!
Tests: 5, Assertions: 5, Failures: 1.
Failing just 1 out of 5 isn’t horrible, but it still definitely misunderstood that rule.
I tried different ways of explaining it, but none resulted in much better code.
So I’m wondering if breaking down the process would yield better results. Let’s try it.
Find the Side by Letter
Add a private method to the following class called
findSideByLetter
.It accepts a string,
$letter
, and an array of 4 sides,$sides
. Each element of$sides
is a string with three letters.The method should return the index of
$sides
that contains$letter
, with a case insensitive search, or null if it cannot be found.Stub class:
<code excluded for brevity>
Modified class:
<?php
namespace Devbanana\LetterBoxed\Validator;
final class WordValidator
{
/**
* @param string[] $words
* @param string[] $sides
*/
public function validate(array $words, array $sides): bool
{
return true;
}
/**
* @param string $letter
* @param string[] $sides
* @return int|null
*/
private function findSideByLetter(string $letter, array $sides): ?int
{
$letter = strtolower($letter);
foreach ($sides as $index => $side) {
if (strpos(strtolower($side), $letter) !== false) {
return $index;
}
}
return null;
}
}
This is good. I would have personally used stripos()
instead of making both strings lowercase, but technically there’s nothing wrong with it. Still, I’ll modify the method to use stripos()
instead.
Also, I’m happy it’s learned and is now using return type declarations.
I’m also removing some of the redundant phpdoc information.
Validate Each Word
Now let’s create a method to validate each individual word.
Add a private method to the following class called
validateWord
.It accepts a string
$word
, and an array of 4 sides.For each letter in
$word
, it should callfindSideByLetter()
to get the index of the side that letter is in.If two consecutive letters have the same side, it is invalid and should return false.
Otherwise it is valid and should return true.
Current class:
<code excluded for brevity>
Updated class:
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Validator;
final class WordValidator
{
/**
* Validate that the given words are valid according to the provided sides.
*
* @param string[] $words The words to validate
* @param string[] $sides The sides containing the 4 sets of letters
*/
public function validate(array $words, array $sides): bool
{
return true;
}
/**
* Validates that the given word is valid according to the provided sides.
*
* @param string $word The word to validate
* @param string[] $sides The 4 sides of the puzzle
*/
private function validateWord(string $word, array $sides): bool
{
$previousLetterSide = null;
foreach (str_split($word) as $letter) {
$letterSide = $this->findSideByLetter($letter, $sides);
if ($previousLetterSide === $letterSide) {
return false;
}
$previousLetterSide = $letterSide;
}
return true;
}
// ...
}
Can I just say how impressed I am that it generated accurate, properly aligned comments for the validateWord()
method? The comments you see for validate()
were added by me, but GPT-3 copied the style of my comments and applied the same style for validateWord()
.
This code looks pretty accurate, but I can’t test it until we complete the class.
Validate the Words Match
Now let’s create a method to validate the first letter of one word is equal to the last letter of the previous word.
Create a private method called
validateWordsMatch()
to the following class.It accepts two words,
$word1
and$word2
.It should validate that the first letter of
$word2
is equal to the last letter of$word1
, with a case insensitive match. If not, it should return false.Current class:
<code excluded for brevity>
Modified class:
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Validator;
final class WordValidator
{
/**
* Validate that the given words are valid according to the provided sides.
*
* @param string[] $words The words to validate
* @param string[] $sides The sides containing the 4 sets of letters
*/
public function validate(array $words, array $sides): bool
{
if (\count($words) < 1) {
return false;
}
foreach ($words as $word) {
if (\strlen($word) < 3) {
return false;
}
}
if (\count($sides) !== 4) {
return false;
}
for ($i = 0; $i < count($words) - 1; $i += 1) {
if (!$this->validateWordsMatch($words[$i], $words[$i+1])) {
return false;
}
}
foreach ($words as $i => $word) {
if (!$this->validateWord($word, $sides)) {
return false;
}
}
return true;
}
/**
* Validates that the given two words are valid according to the provided matches.
*
* @param string $word1 The first word to validate
* @param string $word2 The second word to validate
*/
private function validateWordsMatch(string $word1, string $word2): bool
{
$lastLetter = strtolower($word1[strlen($word1) - 1]);
$firstLetter = strtolower($word2[0]);
return $lastLetter === $firstLetter;
}
/**
* Validates that the given word is valid according to the provided sides.
*
* @param string $word The word to validate
* @param string[] $sides The 4 sides of the puzzle
*/
private function validateWord(string $word, array $sides): bool
{
$previousLetterSide = null;
foreach (str_split($word) as $letter) {
$letterSide = $this->findSideByLetter($letter, $sides);
if ($previousLetterSide === $letterSide) {
return false;
}
$previousLetterSide = $letterSide;
}
return true;
}
/**
* Find the side that contains the given letter.
*
* @param string $letter The letter to search for
* @param string[] $sides The 4 sides of the puzzle
*/
private function findSideByLetter(string $letter, array $sides): ?int
{
foreach ($sides as $i => $side) {
if (stripos($side, $letter) !== false) {
return $i;
}
}
return null;
}
}
Straightforward enough.
Try to Modify validate()
Again
Now let’s put all these steps together.
Modify the
validate()
method of this class so it does the following:
- Validates
$words
contains at least one word, or returns false otherwise- Validates each element of
$words
contains at least 3 letters, and returns false otherwise- Validates
$sides
contains exactly 4 elements, or returns false otherwise- Uses
validateWordsMatch()
to validate the first letter of each word matches the last letter of the prior word- Uses
validateWord()
to validate that each word is validCurrent class:
<code excluded for brevity>
Modified class:
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Validator;
final class WordValidator
{
/**
* Validate that the given words are valid according to the provided sides.
*
* @param string[] $words The words to validate
* @param string[] $sides The sides containing the 4 sets of letters
*/
public function validate(array $words, array $sides): bool
{
// Validate that $words contains at least 1 word
if (\count($words) < 1) {
return false;
}
// Validate that each element of $words contains at least 3 letters
foreach ($words as $word) {
if (\strlen($word) < 3) {
return false;
}
}
// Validate that $sides contains exactly 4 elements
if (\count($sides) !== 4) {
return false;
}
// Validate that the first letter of each word matches the last letter of the prior word
for ($i = 0; $i < count($words) - 1; $i += 1) {
if (!$this->validateWordsMatch($words[$i], $words[$i+1])) {
return false;
}
}
// Validate that each word is valid
foreach ($words as $i => $word) {
if (!$this->validateWord($word, $sides)) {
return false;
}
}
return true;
}
// ...
}
WOW is that inefficient! It loops through the words several times, when it could clearly be optimized.
But, I am impressed it is learning my code style and sticking to it. That’s very cool!
But does it work? Let’s see:
$ vendor/bin/phpunit tests/Validator/WordValidatorTest.php
PHPUnit 9.6-dev by Sebastian Bergmann and contributors.
..... 5 / 5 (100%)
Time: 00:00.005, Memory: 4.00 MB
OK (5 tests, 6 assertions)
Nice! Everything works as it should!
Optimize the Method
Now let’s see if we can clean it up.
Rewrite the
validate()
method so$words
is only looped through once.Current method:
<code excluded for brevity>
Modified method:
/**
* Validate that the given words are valid according to the provided sides.
*
* @param string[] $words The words to validate
* @param string[] $sides The sides containing the 4 sets of letters
*/
public function validate(array $words, array $sides): bool
{
// Validate that $words contains at least 1 word
if (\count($words) < 1) {
return false;
}
// Validate that $sides contains exactly 4 elements
if (\count($sides) !== 4) {
return false;
}
// Validate that each element of $words contains at least 3 letters and is valid
foreach ($words as $i => $word) {
if (\strlen($word) < 3 || !$this->validateWord($word, $sides)) {
return false;
}
// Validate that the first letter of each word matches the last letter of the prior word
if ($i > 0 && !$this->validateWordsMatch($words[$i-1], $words[$i])) {
return false;
}
}
return true;
}
Much better, and it still passes the tests.
One caveat though. You’ll notice I wrote “current method” and “modified method” instead of including the entire class.
This is because if I included the entire class, it deleted some of my private helper methods and moved them into validate()
, no matter how clearly I said not to touch any private methods, or only to modify the validate()
method.
But when I just included the method I wanted modified, that’s of course all it optimized.
But otherwise, the result is pretty much exactly what I’d want. I’m realizing I should have moved the check for the word being 3 or more letters into the validateWord()
method, but I can do that myself.
So I think the takeaway from this is that if you have a complex task, it’s better to break it down into manageable pieces, and the AI will handle it a lot better.
Additional Validations
I’m realizing I forgot a criterion though: we need to make sure every letter in $words
is contained in the puzzle.
Let’s write a test to see if it passes:
public function testEveryLetterMustBeFoundInSides(): void
{
self::assertFalse($this->wordValidator->validate(['atoms'], $this->sides));
}
Running this test:
$ vendor/bin/phpunit tests/Validator/WordValidatorTest.php
PHPUnit 9.6-dev by Sebastian Bergmann and contributors.
...F.. 6 / 6 (100%)
Time: 00:00.005, Memory: 4.00 MB
There was 1 failure:
1) Devbanana\LetterBoxed\Tests\Validator\WordValidatorTest::testEveryLetterMustBeFoundInSides
Failed asserting that true is false.
/path/to/letter-boxed/tests/Validator/WordValidatorTest.php:46
FAILURES!
Tests: 6, Assertions: 7, Failures: 1.
Nope, it doesn’t pass, so let’s modify the validateWord()
method.
Modify this method so that if
findSideByLetter()
returns null for any letter, it is invalid and returns false.Current method:
<code excluded for brevity>
Modified method:
/**
* Validates that the given word is valid according to the provided sides.
*
* @param string $word The word to validate
* @param string[] $sides The 4 sides of the puzzle
*/
private function validateWord(string $word, array $sides): bool
{
if (\strlen($word) < 3) {
return false;
}
$previousLetterSide = null;
$letterSide = null;
foreach (str_split($word) as $letter) {
$letterSide = $this->findSideByLetter($letter, $sides);
if ($previousLetterSide === $letterSide || $letterSide === null) {
return false;
}
$previousLetterSide = $letterSide;
}
return true;
}
Do the tests pass?
$ vendor/bin/phpunit tests/Validator/WordValidatorTest.php
PHPUnit 9.6-dev by Sebastian Bergmann and contributors.
...... 6 / 6 (100%)
Time: 00:00.006, Memory: 4.00 MB
OK (6 tests, 7 assertions)
Yep!
Why it initializes $letterSide
I have no idea. It’s definitely not needed.
I want to add one more validation, to not accept any non-alpha characters in $sides
.
First the test:
public function testNonAlphaCharactersInSidesAreInvalid(): void
{
self::assertFalse($this->wordValidator->validate(['atom'], ['XBM', 'INA', 'OYL', 'TE-']));
}
Does it fail?
$ vendor/bin/phpunit tests/Validator/WordValidatorTest.php
PHPUnit 9.6-dev by Sebastian Bergmann and contributors.
.F..... 7 / 7 (100%)
Time: 00:00.006, Memory: 4.00 MB
There was 1 failure:
1) Devbanana\LetterBoxed\Tests\Validator\WordValidatorTest::testNonAlphaCharactersInSidesAreInvalid
Failed asserting that true is false.
/path/to/letter-boxed/tests/Validator/WordValidatorTest.php:35
FAILURES!
Tests: 7, Assertions: 8, Failures: 1.
And now let’s request the modification:
Modify this method to disallow any non-alpha characters in
$sides
. Usearray_filter()
to perform the validation.Current method:
<code excluded for brevity>
Modified method:
/**
* Validate that the given words are valid according to the provided sides.
*
* @param string[] $words The words to validate
* @param string[] $sides The sides containing the 4 sets of letters
*/
public function validate(array $words, array $sides): bool
{
// Validate that $words contains at least 1 word
if (\count($words) < 1) {
return false;
}
// Validate that $sides contains exactly 4 elements that are all alphabetic
if (\count($sides) !== 4 || array_filter($sides, "ctype_alpha") !== $sides) {
return false;
}
// Validate that each element of $words contains at least 3 letters and is valid
foreach ($words as $i => $word) {
if (!$this->validateWord($word, $sides)) {
return false;
}
// Validate that the first letter of each word matches the last letter of the prior word
if ($i > 0 && !$this->validateWordsMatch($words[$i-1], $words[$i])) {
return false;
}
}
return true;
}
Does it pass?
$ vendor/bin/phpunit tests/Validator/WordValidatorTest.php
PHPUnit 9.6-dev by Sebastian Bergmann and contributors.
....... 7 / 7 (100%)
Time: 00:00.004, Memory: 4.00 MB
OK (7 tests, 8 assertions)
Great!
You’ll notice I was pretty specific, telling it to use array_filter()
for the validation. This is because otherwise it was looping through the sides and I just thought there was a nicer way of doing things.
Also I ran this twice, because the first time it used preg_match()
which I thought was far less efficient than type_alpha()
.
You can see the final version of WordValidator
, which I did modify a bit to be more to my liking.
Add the Validator to the Command
Let’s add WordValidator
to RemainingCommand
.
Modify this class to add
Devbanana\LetterBoxed\Validator\WordValidator
as a dependency. In ``execute(), it should pass
$wordsand
$lettersto theWordValidator::validate()
method. If it is false, it should output the error,The provided words are invalid.
Current class:
<code excluded for brevity>
Modified class:
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Command;
use Devbanana\LetterBoxed\Validator\WordValidator;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'remaining', description: 'Determine remaining letters after word(s)')]
final class RemainingCommand extends Command
{
private WordValidator $wordValidator;
public function __construct(WordValidator $wordValidator)
{
$this->wordValidator = $wordValidator;
parent::__construct();
}
// ...
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Remaining');
$letters = $input->getArgument('letters');
if (!\is_array($letters) || \count($letters) !== 4) {
$io->getErrorStyle()->error('There must be exactly 4 sets of letters.');
return Command::INVALID;
}
$words = $input->getOption('word');
if (!\is_array($words) || empty($words)) {
$io->getErrorStyle()->error('At least 1 word is required.');
return Command::INVALID;
}
if (!$this->wordValidator->validate($words, $letters)) {
$io->getErrorStyle()->error('The provided words are invalid.');
return Command::INVALID;
}
// TODO: add logic
return Command::SUCCESS;
}
}
Nice!
All we have to do is to pass WordValidator
to RemainingCommand
in our entry point script. We could ask the AI to do this but it’d take longer to form the request and copy the result as to just do it ourselves.
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Devbanana\LetterBoxed\Command\RemainingCommand;
use Devbanana\LetterBoxed\Validator\WordValidator;
use Symfony\Component\Console\Application;
$app = new Application('Letter Boxed Solver');
$app->add(new RemainingCommand(new WordValidator()));
$app->run();
Now let’s try passing an invalid word:
$ ./letterboxed remaining XBM INA OYL TEC -w tea
Remaining
=========
[ERROR] The provided words are invalid.
Strip Words From Letters
Now we want to create a utility that strips the letters of the provided words from the letters in the puzzle, and only returns the letters that are left.
Just as before, let’s write some unit tests for this.
And now let’s see if we can get it in one shot.
Modify this class to strip the letters from
$words
from$sides
and return the remaining letters as an alphabetically-ordered string.Current class:
<?php declare(strict_types=1); namespace Devbanana\LetterBoxed\Util; final class StripWords { /** * Strip the letters of the given words from the letters in the given sides. * * @param string[] $words The words to strip * @param string[] $sides The sides of the puzzle * * @return string The remaining letters */ public function strip(array $words, array $sides): string { return ''; } }
Modified class:
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Util;
final class StripWords
{
/**
* Strip the letters of the given words from the letters in the given sides.
*
* @param string[] $words The words to strip
* @param string[] $sides The sides of the puzzle
*
* @return string The remaining letters
*/
public function strip(array $words, array $sides): string
{
$remainingLetters = [];
foreach ($sides as $side) {
foreach (str_split($side) as $letter) {
$remainingLetters[$letter] = 1;
}
}
foreach ($words as $word) {
foreach (str_split($word) as $letter) {
unset($remainingLetters[$letter]);
}
}
ksort($remainingLetters);
return implode('', array_keys($remainingLetters));
}
}
Cool. Does it pass?
$ vendor/bin/phpunit tests/Util/StripWordsTest.php
PHPUnit 9.6-dev by Sebastian Bergmann and contributors.
.FFFF 5 / 5 (100%)
Time: 00:00.007, Memory: 6.00 MB
There were 4 failures:
1) Devbanana\LetterBoxed\Tests\Util\StripWordsTest::testOnlyStripPresentLetters
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'BCEILNXY'
+'ABCEILMNOTXY'
/path/to/letter-boxed/tests/Util/StripWordsTest.php:35
2) Devbanana\LetterBoxed\Tests\Util\StripWordsTest::testStripMultipleWords
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'CILNX'
+'ABCEILMNOTXY'
/path/to/letter-boxed/tests/Util/StripWordsTest.php:40
3) Devbanana\LetterBoxed\Tests\Util\StripWordsTest::testReturnEmptyStringIfAllLettersStripped
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-''
+'ABCEILMNOTXY'
/path/to/letter-boxed/tests/Util/StripWordsTest.php:45
4) Devbanana\LetterBoxed\Tests\Util\StripWordsTest::testDuplicateLettersShouldBeRemoved
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'BEILNXY'
+'ABEILMNOTXY'
/path/to/letter-boxed/tests/Util/StripWordsTest.php:52
FAILURES!
Tests: 5, Assertions: 5, Failures: 4.
Not even a little.
Looking at the code, I see that it’s doing a case sensitive search, because I neglected to mention that. So let’s see if we can modify it to be case insensitive.
Modify the following class to make the match case insensitive but still return the string in uppercase.
Current class:
<code excluded for brevity>
Modified class:
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Util;
final class StripWords
{
/**
* Strip the letters of the given words from the letters in the given sides.
*
* @param string[] $words The words to strip
* @param string[] $sides The sides of the puzzle
*
* @return string The remaining letters
*/
public function strip(array $words, array $sides): string
{
$remainingLetters = [];
foreach ($sides as $side) {
foreach (str_split(strtoupper($side)) as $letter) {
$remainingLetters[$letter] = 1;
}
}
foreach ($words as $word) {
foreach (str_split(strtoupper($word)) as $letter) {
unset($remainingLetters[$letter]);
}
}
ksort($remainingLetters);
return implode('', array_keys($remainingLetters));
}
}
Does it pass?
$ vendor/bin/phpunit tests/Util/StripWordsTest.php
PHPUnit 9.6-dev by Sebastian Bergmann and contributors.
..... 5 / 5 (100%)
Time: 00:00.003, Memory: 6.00 MB
OK (5 tests, 5 assertions)
Perfect!
Note how I said it still had to be returned as an uppercase string. That’s because when I ran this without that qualifier, it made all the strings lowercase, and so the tests failed.
I like this implementation. I’m not sure it’s exactly what I would have chosen, but it’s creative and it works. It might actually be more compact than what I would have chosen.
Add it to the Command
Modify the following class to add a dependency to the constructor for
Devbanana\LetterBoxed\Util\StripWords
. After all the validation, it should pass$words
and$letters
toStripWords::strip()
in order to strip the words from the puzzle, and output the result like this:If the string is not empty, then output:
The remaining letters are: {$remaining}
If the string is empty, then output the following:
<info>You've solved the puzzle! All the letters were used.</info>
used.`Current class:
<code excluded for brevity>
Modified class:
<?php
declare(strict_types=1);
namespace Devbanana\LetterBoxed\Command;
use Devbanana\LetterBoxed\Util\StripWords;
use Devbanana\LetterBoxed\Validator\WordValidator;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'remaining', description: 'Determine remaining letters after word(s)')]
final class RemainingCommand extends Command
{
private WordValidator $wordValidator;
private StripWords $stripWords;
public function __construct(WordValidator $wordValidator, StripWords $stripWords)
{
$this->wordValidator = $wordValidator;
$this->stripWords = $stripWords;
parent::__construct();
}
protected function configure(): void
{
$this
->setHelp('Determine which letters are left after the given words')
->addArgument('letters', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'The letters of the puzzle (each side separated by a space)')
->addOption('word', 'w', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The word(s) in the puzzle so far')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Remaining');
$letters = $input->getArgument('letters');
if (!\is_array($letters) || \count($letters) !== 4) {
$io->getErrorStyle()->error('There must be exactly 4 sets of letters.');
return Command::INVALID;
}
$words = $input->getOption('word');
if (!\is_array($words) || empty($words)) {
$io->getErrorStyle()->error('At least 1 word is required.');
return Command::INVALID;
}
if (!$this->wordValidator->validate($words, $letters)) {
$io->getErrorStyle()->error('The provided words are invalid.');
return Command::INVALID;
}
$remaining = $this->stripWords->strip($words, $letters);
if (!empty($remaining)) {
$io->writeln("The remaining letters are:");
$io->writeln($remaining);
} else {
$io->writeln("<info>You've solved the puzzle! All the letters were used.</info>");
}
return Command::SUCCESS;
}
}
And let’s add it to the entry point:
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Devbanana\LetterBoxed\Command\RemainingCommand;
use Devbanana\LetterBoxed\Util\StripWords;
use Devbanana\LetterBoxed\Validator\WordValidator;
use Symfony\Component\Console\Application;
$app = new Application('Letter Boxed Solver');
$app->add(new RemainingCommand(new WordValidator(), new StripWords()));
$app->run();
Now let’s see if it works:
$ ./letterboxed remaining XBM INA OYL TEC -w exclamation
Remaining
=========
The remaining letters are:
BY
And let’s try solving the puzzle:
$ ./letterboxed remaining XBM INA OYL TEC -w bye -w exclamation
Remaining
=========
You've solved the puzzle! All the letters were used.
I can’t show the green text but it does come out green if your terminal supports color.
Final Thoughts
You can view the final state of this application as of this writing by visiting the repository.
As you can see, GPT-3 has some really powerful capabilities when it comes to programming.
However, in my opinion you have to know how to guide it. You either have to know how to explain it clearly enough, or to break it down into small enough steps that it doesn’t get confused.
It seemed to do better when I gave it a stub to operate from, and it did a great job copying my code style.
I also think having unit tests to guide the process really helped, since I had a clear idea of success.
Do I think it can replace programmers? Not really. Could it perhaps in the distant future? Maybe, but I don’t think that’s its strength.
Someone with no programming knowledge clearly couldn’t sit down and generate this console application purely with GPT-3. It required plenty of technical oversight and correction along the way.
But still, it is extremely helpful for the parts it is good at.