Stacey on Software

Agile

Leveraging Iteration and Decoupling

July 18, 2023

You gotta love the “AI” newsfeed these days, fast and furious.

So, along the path of writing this 6th instalment, a number of things emerged. First, OpenAI released the -0613 models for GPT-4 and GPT-3.5, which include the ability to generate JSON that can be accurately transformed into calls to tool APIs of your design. Also, with GPT-3.5 they launched the 16KB context window capability.

Needless to say, it tossed me into a number of divergent experiments, while I re-assessed my approach in general.

I was already reaching the extent of the 8KB context window in GPT-4, as I expanded the requirements to describe enough of the domain and code style I wanted, trying to leave room for it to generate more interrelated code for me.

Through this, my mind went to the notion of coupling and cohesion. How, in well designed systems, you want tightly cohesive components that are loosely coupled from each other. The trick is, where do you draw the boundaries.

I still enjoy the Use-Case oriented boundaries, but the underlying model kept becoming tightly coupled. To the point where I separated my prompts to have GPT generate the model independently of the use-cases, and fed the finished model in to each use-case prompt. And then I didn’t like how the model began getting too speculative, anticipating future use-cases.

Here’s what that looked like when generating typed python code with pydantic:

Prompt

You are an expert software craftsman that applies SOLID principles and the Four Rules of Simple Design to create clean, well-organized, and easy-to-read code.

Language and style: Use python 3 with pydantic. Tests: Use pytest for testing, generate example data for each scenario Data structures:

class LineItem(BaseModel):
description: str = Field(..., description="Invoice line item description")
amount: Decimal = Field(..., description="Invoice line item amount")
contractor_code: str = Field(..., description="Contractor code to whom the line item applies")
taxable: bool = Field(..., description="Whether the line item is taxable")
class HoursLineItem(LineItem):
hours: int = Field(..., description="Number of hours being billed")
period_start: date = Field(..., description="Start date of the period being billed")
period_end: date = Field(..., description="End date of the period being billed")
tag: Literal["Hours"] = "Hours" # Used to distinguish between Hours and Expense types during deserialization
class ExpenseLineItem(LineItem):
tag: Literal["Expense"] = "Expense" # Used to distinguish between Hours and Expense types during deserialization
class Invoice(BaseModel):
number: str = Field(..., description="Invoice number")
paid: bool = Field(default=False, description="Whether the invoice has been paid")
issue_date: date = Field(..., description="Date the invoice was issued")
line_items: List[Union[HoursLineItem, ExpenseLineItem]] = Field(default=[], description="Line items on the invoice")
class Consultancy(BaseModel):
code: str = Field(..., description="Internal code for the consultancy")
name: str = Field(..., description="Name of the consultancy")
contract: str = Field(..., description="Contract number")
contact_name: str = Field(..., description="Name of the primary contact")
contact_phone: str = Field(..., description="Phone number of the primary contact")
contact_email: str = Field(..., description="Email address of the primary contact")
invoices: list[Invoice] = Field(default=[], description="Invoices issued to us by the consultancy")
class CoachingPracticeFinance(BaseModel):
consultancies: List[Consultancy] = Field(default=[], description="Consultancies we work with")

Use-case classes: These are public-facing, take the root of the object graph as an argument constructor, and have an execute method that should only take basic data types. Report-based Use-Case classes: These should join the data from the object structures into a dataframe, calculates the data for the report in the dataframe, and then uses a generic class for displaying the dataframe that leverages the tabulate library.

Application domain: I run a coaching practice consisting of full-time (FTE) and contract coaches. I have to recover the costs of the coaches across several funding sources within our organization that are tied to initiatives or value streams.

Requirements: Let’s call this software project CoachingPracticeFinanceTracker.

Implement the following use case:

  • add a new consultancy through which contractors will work

GPT-4-0613 Response

Let’s create a use-case class called AddConsultancyUseCase for adding a new consultancy.

from pydantic import BaseModel, Field
from typing import List
from datetime import date
from decimal import Decimal
# ... previous data structures ...
class AddConsultancyUseCase:
def __init__(self, finance_tracker: CoachingPracticeFinance):
self.finance_tracker = finance_tracker
def execute(self, consultancy_data: dict):
new_consultancy = Consultancy(**consultancy_data)
self.finance_tracker.consultancies.append(new_consultancy)

You can use this class like this:

finance_tracker = CoachingPracticeFinance()
add_consultancy_use_case = AddConsultancyUseCase(finance_tracker)
add_consultancy_use_case.execute({
'code': 'CNSLT001',
'name': 'XYZ Consultancy',
'contract': 'CONTR001',
'contact_name': 'John Doe',
'contact_phone': '1234567890',
'contact_email': 'john.doe@xyz.com'
})
print(finance_tracker.dict())

This will add a new consultancy to the finance_tracker instance. The .dict() method is used to print the current state of the finance_tracker object in a dictionary format.

For testing this use case, you can use pytest:

def test_add_consultancy_use_case():
finance_tracker = CoachingPracticeFinance()
add_consultancy_use_case = AddConsultancyUseCase(finance_tracker)
add_consultancy_use_case.execute({
'code': 'CNSLT001',
'name': 'XYZ Consultancy',
'contract': 'CONTR001',
'contact_name': 'John Doe',
'contact_phone': '1234567890',
'contact_email': 'john.doe@xyz.com'
})
assert finance_tracker.consultancies[0].code == 'CNSLT001'
assert finance_tracker.consultancies[0].name == 'XYZ Consultancy'
assert finance_tracker.consultancies[0].contract == 'CONTR001'
assert finance_tracker.consultancies[0].contact_name == 'John Doe'
assert finance_tracker.consultancies[0].contact_phone == '1234567890'
assert finance_tracker.consultancies[0].contact_email == 'john.doe@xyz.com'

This test verifies that the AddConsultancyUseCase correctly adds a new consultancy to the finance_tracker object.

GPT-3.5-turbo-16k-0613 Response

To implement the use case of adding a new consultancy, you can create a class called AddConsultancyUseCase. This class should have a constructor that takes the root of the object graph (CoachingPracticeFinance instance) as an argument.

Here’s an example implementation:

from typing import Optional
from pydantic import BaseModel, Field
from datetime import date
from coaching_practice_finance import CoachingPracticeFinance, Consultancy
class AddConsultancyInput(BaseModel):
code: str = Field(..., description="Internal code for the consultancy")
name: str = Field(..., description="Name of the consultancy")
contract: str = Field(..., description="Contract number")
contact_name: str = Field(..., description="Name of the primary contact")
contact_phone: str = Field(..., description="Phone number of the primary contact")
contact_email: str = Field(..., description="Email address of the primary contact")
class AddConsultancyUseCase:
def __init__(self, finance_tracker: CoachingPracticeFinance):
self.finance_tracker = finance_tracker
def execute(self, input_data: AddConsultancyInput) -> Optional[Consultancy]:
consultancy = Consultancy(
code=input_data.code,
name=input_data.name,
contract=input_data.contract,
contact_name=input_data.contact_name,
contact_phone=input_data.contact_phone,
contact_email=input_data.contact_email
)
self.finance_tracker.consultancies.append(consultancy)
return consultancy

In this implementation, the AddConsultancyUseCase class takes the root of the object graph (CoachingPracticeFinance instance) in its constructor. The execute method takes an AddConsultancyInput object as an argument and creates a new Consultancy object with the provided data. It then adds the new Consultancy to the list of consultancies in the CoachingPracticeFinance instance.

You can use this class in your application code like this:

finance_tracker = CoachingPracticeFinance()
add_consultancy_use_case = AddConsultancyUseCase(finance_tracker)
input_data = AddConsultancyInput(
code="ABC",
name="ABC Consultancy",
contract="12345",
contact_name="John Doe",
contact_phone="1234567890",
contact_email="john.doe@example.com"
)
new_consultancy = add_consultancy_use_case.execute(input_data)
print(f"New consultancy added: {new_consultancy}")

Note: Replace the ... in the code with appropriate descriptions and field validations as per your requirements.

Analysis

Notice how the 3.5-turbo model wanted to build a whole new class to hold structured input for the use case, but the 4.0 model generated more concise python code that captured the input data as an untyped dictionary. Part of my experimentation over the past couple months has been assessing 3.5 vs 4.0’s tendencies and biases.

This plunged me into further prompting to control use-case construction. I tried different ways of describing what I wanted in plain english, but ultimately couldn’t avoid providing an example use case class in order to seed the code style I wanted.

This of course mandates that I know how to write a clean implementation, because I must provide the clean example.

A New Approach

I’ve not given up on the specification prompting approach, but when thinking about context limits, I’m starting to think about some other ways to tackle this.

As a developer, I manage my cognitive load via iteration. Can I do the same with the LLM?

Watch this interesting progression… I’m using the Chat model, and taking advantage of sending several segments in to the model for context.

Step 1

System

You are an expert software craftsman that applies SOLID principles and the Four Rules of Simple Design to create clean, well-organized, and easy-to-read code.

User Prompt

Language and style: Use python 3 with pydantic. Data structures: Use classes that extend pydantic’s BaseModel. You must fully annotate the model’s fields using pydantic’s Field class, and include a description of each field. The data used by the application should be rooted by a single model so that the whole object graph can be loaded and saved from a single json data file.

Application domain: I run a coaching practice that consists of both full-time (FTE) and contract coaches. I have to recover the costs of the coaches across a number of funding sources within our organization that are tied to initiatives or value-streams.

Requirements: Let’s call this software project CoachingPracticeFinanceTracker, and the root data object type CoachingPracticeFinance.

Generate a data model that can track consultancies, contractors who work through consultancies, and invoices from the consultancies for the contractors’ time and expenses.

LLM Response

Here is an example of a pydantic data model that fulfills your requirements:

from typing import List, Optional
from pydantic import BaseModel, Field
class Contractor(BaseModel):
id: str = Field(..., description="The unique identifier of the contractor")
name: str = Field(..., description="The contractor's name")
consultancy_id: str = Field(..., description="The id of the consultancy the contractor works through")
class Consultancy(BaseModel):
id: str = Field(..., description="The unique identifier of the consultancy")
name: str = Field(..., description="The consultancy's name")
contractors: List[Contractor] = Field(..., description="The list of contractors that work through this consultancy")
class Invoice(BaseModel):
id: str = Field(..., description="The unique identifier of the invoice")
consultancy_id: str = Field(..., description="The id of the consultancy that sent the invoice")
contractor_id: str = Field(..., description="The id of the contractor whom the invoice is for")
amount_due: float = Field(..., description="The amount due on the invoice")
expenses: float = Field(0.0, description="The amount of expenses on the invoice")
class CoachingPracticeFinance(BaseModel):
consultancies: List[Consultancy] = Field(..., description="The list of consultancies")
invoices: List[Invoice] = Field(..., description="The list of invoices")

This model assumes that each consultancy will have a list of contractors that work through them. Each invoice is associated with a consultancy and a contractor, and includes an amount due and optional expenses. The root CoachingPracticeFinance object contains lists of all consultancies and invoices.

Step 2

System

You are an expert software craftsman that applies SOLID principles and the Four Rules of Simple Design to create clean, well-organized, and easy-to-read code.

Code (as User message)

file: “entities.py”

from typing import List, Optional
from pydantic import BaseModel, Field
class Contractor(BaseModel):
id: str = Field(..., description="The unique identifier of the contractor")
name: str = Field(..., description="The contractor's name")
consultancy_id: str = Field(..., description="The id of the consultancy the contractor works through")
class Consultancy(BaseModel):
id: str = Field(..., description="The unique identifier of the consultancy")
name: str = Field(..., description="The consultancy's name")
contractors: List[Contractor] = Field(..., description="The list of contractors that work through this consultancy")
class Invoice(BaseModel):
id: str = Field(..., description="The unique identifier of the invoice")
consultancy_id: str = Field(..., description="The id of the consultancy that sent the invoice")
contractor_id: str = Field(..., description="The id of the contractor whom the invoice is for")
amount_due: float = Field(..., description="The amount due on the invoice")
expenses: float = Field(0.0, description="The amount of expenses on the invoice")
class CoachingPracticeFinance(BaseModel):
consultancies: List[Consultancy] = Field(..., description="The list of consultancies")
invoices: List[Invoice] = Field(..., description="The list of invoices")

User Prompt

Split the code so that the invoice contains line items, where each line item can be either for a contractor’s hourly time, or an expense.

LLM Response

file: “entities.py”

from typing import List, Optional
from pydantic import BaseModel, Field
class Contractor(BaseModel):
id: str = Field(..., description="The unique identifier of the contractor")
name: str = Field(..., description="The contractor's name")
consultancy_id: str = Field(..., description="The id of the consultancy the contractor works through")
class Consultancy(BaseModel):
id: str = Field(..., description="The unique identifier of the consultancy")
name: str = Field(..., description="The consultancy's name")
contractors: List[Contractor] = Field(..., description="The list of contractors that work through this consultancy")
class LineItem(BaseModel):
id: str = Field(..., description="The unique identifier of the line item")
description: str = Field(..., description="The description of the line item included in the invoice")
amount: float = Field(..., description="The amount of the line item")
class Invoice(BaseModel):
id: str = Field(..., description="The unique identifier of the invoice")
consultancy_id: str = Field(..., description="The id of the consultancy that sent the invoice")
contractor_id: str = Field(..., description="The id of the contractor whom the invoice is for")
line_items: List[LineItem] = Field(..., description="The line items included in the invoice")
class CoachingPracticeFinance(BaseModel):
consultancies: List[Consultancy] = Field(..., description="The list of consultancies")
invoices: List[Invoice] = Field(..., description="The list of invoices")

This refactoring has broken down the Invoice entity’s amount_due and expenses fields into multiple LineItem objects, each with its own amount. This design adheres to the Single Responsibility Principle, as each LineItem object is now responsible for maintaining its own amount. Additionally, it adheres to the Open-Closed Principle as new types of line items can be added without modifying the existing Invoice or LineItem classes.

Step 3

System

You are an expert software craftsman that applies SOLID principles and the Four Rules of Simple Design to create clean, well-organized, and easy-to-read code.

Code (as User message)

file: “entities.py”

from typing import List, Optional
from pydantic import BaseModel, Field
class Contractor(BaseModel):
id: str = Field(..., description="The unique identifier of the contractor")
name: str = Field(..., description="The contractor's name")
consultancy_id: str = Field(..., description="The id of the consultancy the contractor works through")
class Consultancy(BaseModel):
id: str = Field(..., description="The unique identifier of the consultancy")
name: str = Field(..., description="The consultancy's name")
contractors: List[Contractor] = Field(..., description="The list of contractors that work through this consultancy")
class LineItem(BaseModel):
id: str = Field(..., description="The unique identifier of the line item")
description: str = Field(..., description="The description of the line item included in the invoice")
amount: float = Field(..., description="The amount of the line item")
class Invoice(BaseModel):
id: str = Field(..., description="The unique identifier of the invoice")
consultancy_id: str = Field(..., description="The id of the consultancy that sent the invoice")
contractor_id: str = Field(..., description="The id of the contractor whom the invoice is for")
line_items: List[LineItem] = Field(..., description="The line items included in the invoice")
class CoachingPracticeFinance(BaseModel):
consultancies: List[Consultancy] = Field(..., description="The list of consultancies")
invoices: List[Invoice] = Field(..., description="The list of invoices")

User Prompt

Use-case classes: These are public facing, take the root of the object graph as an argument constructor, and have an execute method that should take individual basic data type arguments. Each use-case class should be in a file called “use_cases/use_case_class_name.py”.

Generate a use case file that allows someone to add a new consultancy to the system.

LLM Response

file: “use_cases/add_consultancy.py”

from entities import Consultancy, CoachingPracticeFinance
class AddConsultancy:
def __init__(self, coaching_practice_finance: CoachingPracticeFinance):
self.coaching_practice_finance = coaching_practice_finance
def execute(self, id: str, name: str):
new_consultancy = Consultancy(id=id, name=name, contractors=[])
self.coaching_practice_finance.consultancies.append(new_consultancy)

In this use case, we have a class AddConsultancy. This class takes an instance of CoachingPracticeFinance in its constructor, which represents the existing state of the finances of the coaching practice. The execute method then takes the details required to add a new consultancy and creates a new instance of Consultancy. This new consultancy is then appended to the list of consultancies in the CoachingPracticeFinance object.

Step 4

System

You are an expert software craftsman that applies SOLID principles and the Four Rules of Simple Design to create clean, well-organized, and easy-to-read code.

Code (as User message)

file: “entities.py”

from typing import List, Optional
from pydantic import BaseModel, Field
class Contractor(BaseModel):
id: str = Field(..., description="The unique identifier of the contractor")
name: str = Field(..., description="The contractor's name")
consultancy_id: str = Field(..., description="The id of the consultancy the contractor works through")
class Consultancy(BaseModel):
id: str = Field(..., description="The unique identifier of the consultancy")
name: str = Field(..., description="The consultancy's name")
contractors: List[Contractor] = Field(..., description="The list of contractors that work through this consultancy")
class LineItem(BaseModel):
id: str = Field(..., description="The unique identifier of the line item")
description: str = Field(..., description="The description of the line item included in the invoice")
amount: float = Field(..., description="The amount of the line item")
class Invoice(BaseModel):
id: str = Field(..., description="The unique identifier of the invoice")
consultancy_id: str = Field(..., description="The id of the consultancy that sent the invoice")
contractor_id: str = Field(..., description="The id of the contractor whom the invoice is for")
line_items: List[LineItem] = Field(..., description="The line items included in the invoice")
class CoachingPracticeFinance(BaseModel):
consultancies: List[Consultancy] = Field(..., description="The list of consultancies")
invoices: List[Invoice] = Field(..., description="The list of invoices")

file: “use_cases/add_consultancy.py”

from entities import Consultancy, CoachingPracticeFinance
class AddConsultancy:
def __init__(self, coaching_practice_finance: CoachingPracticeFinance):
self.coaching_practice_finance = coaching_practice_finance
def execute(self, id: str, name: str):
new_consultancy = Consultancy(id=id, name=name, contractors=[])
self.coaching_practice_finance.consultancies.append(new_consultancy)

User Prompt

Generate a use case that allows someone to add a new contractor to a consultancy.

LLM Response

file: “use_cases/add_contractor.py”

from entities import Contractor, Consultancy, CoachingPracticeFinance
class AddContractor:
def __init__(self, coaching_practice_finance: CoachingPracticeFinance):
self.coaching_practice_finance = coaching_practice_finance
def execute(self, id: str, name: str, consultancy_id: str):
new_contractor = Contractor(id=id, name=name, consultancy_id=consultancy_id)
# Find the consultancy to which the contractor belongs
for consultancy in self.coaching_practice_finance.consultancies:
if consultancy.id == consultancy_id:
consultancy.contractors.append(new_contractor)
break
else:
raise ValueError(f"No consultancy found with id: {consultancy_id}")

This use case class AddContractor allows adding a new contractor to a consultancy. The execute method creates a new contractor and adds it to the consultancy with the id provided. If no consultancy with the provided id is found, it raises a ValueError. The SOLID principles and the Four Rules of Simple Design are applied here, making the code clean, well-organized, and easy-to-read.

Step 5

System

You are an expert software craftsman that applies SOLID principles and the Four Rules of Simple Design to create clean, well-organized, and easy-to-read code.

Code (as User message)

file: “entities.py”

from typing import List, Optional
from pydantic import BaseModel, Field
class Contractor(BaseModel):
id: str = Field(..., description="The unique identifier of the contractor")
name: str = Field(..., description="The contractor's name")
consultancy_id: str = Field(..., description="The id of the consultancy the contractor works through")
class Consultancy(BaseModel):
id: str = Field(..., description="The unique identifier of the consultancy")
name: str = Field(..., description="The consultancy's name")
contractors: List[Contractor] = Field(..., description="The list of contractors that work through this consultancy")
class LineItem(BaseModel):
id: str = Field(..., description="The unique identifier of the line item")
description: str = Field(..., description="The description of the line item included in the invoice")
amount: float = Field(..., description="The amount of the line item")
class Invoice(BaseModel):
id: str = Field(..., description="The unique identifier of the invoice")
consultancy_id: str = Field(..., description="The id of the consultancy that sent the invoice")
contractor_id: str = Field(..., description="The id of the contractor whom the invoice is for")
line_items: List[LineItem] = Field(..., description="The line items included in the invoice")
class CoachingPracticeFinance(BaseModel):
consultancies: List[Consultancy] = Field(..., description="The list of consultancies")
invoices: List[Invoice] = Field(..., description="The list of invoices")

file: “use_cases/add_consultancy.py”

from entities import Consultancy, CoachingPracticeFinance
class AddConsultancy:
def __init__(self, coaching_practice_finance: CoachingPracticeFinance):
self.coaching_practice_finance = coaching_practice_finance
def execute(self, id: str, name: str):
new_consultancy = Consultancy(id=id, name=name, contractors=[])
self.coaching_practice_finance.consultancies.append(new_consultancy)

file: “use_cases/add_contractor.py”

from entities import Contractor, Consultancy, CoachingPracticeFinance
class AddContractor:
def __init__(self, coaching_practice_finance: CoachingPracticeFinance):
self.coaching_practice_finance = coaching_practice_finance
def execute(self, id: str, name: str, consultancy_id: str):
new_contractor = Contractor(id=id, name=name, consultancy_id=consultancy_id)
# Find the consultancy to which the contractor belongs
for consultancy in self.coaching_practice_finance.consultancies:
if consultancy.id == consultancy_id:
consultancy.contractors.append(new_contractor)
break
else:
raise ValueError(f"No consultancy found with id: {consultancy_id}")

User Prompt

Generate a pyqt6 front-end UI that allows the user to run any of the use cases. The main window of the UI should list the consultancies and contractors and present a menu of available use cases to run. It should use a form to collect the data needed for the use case the user selects to execute, and then execute the use-case, returning to the main window afterwards.

LLM Response

Sure. Below is the PyQt6 code. Note that it’s a basic UI and doesn’t have any error handling or data validation.

file: “main.py”

from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget, QFormLayout, QLineEdit, QLabel, QComboBox, QListWidget
from PyQt6.QtCore import Qt
from entities import CoachingPracticeFinance
from use_cases.add_consultancy import AddConsultancy
from use_cases.add_contractor import AddContractor
class MainWindow(QMainWindow):
def __init__(self, coaching_practice_finance):
super().__init__()
self.coaching_practice_finance = coaching_practice_finance
self.setWindowTitle("Coaching Practice Finance")
self.main_layout = QVBoxLayout()
self.consultancies_list = QListWidget()
self.contractors_list = QListWidget()
self.main_layout.addWidget(QLabel("Consultancies:"))
self.main_layout.addWidget(self.consultancies_list)
self.main_layout.addWidget(QLabel("Contractors:"))
self.main_layout.addWidget(self.contractors_list)
self.add_consultancy_button = QPushButton("Add Consultancy")
self.add_consultancy_button.clicked.connect(self.on_add_consultancy)
self.add_contractor_button = QPushButton("Add Contractor")
self.add_contractor_button.clicked.connect(self.on_add_contractor)
self.main_layout.addWidget(self.add_consultancy_button)
self.main_layout.addWidget(self.add_contractor_button)
main_widget = QWidget()
main_widget.setLayout(self.main_layout)
self.setCentralWidget(main_widget)
def on_add_consultancy(self):
self.form_layout = QFormLayout()
self.id_input = QLineEdit()
self.name_input = QLineEdit()
self.form_layout.addRow('ID:', self.id_input)
self.form_layout.addRow('Name:', self.name_input)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.submit_consultancy)
self.form_layout.addWidget(self.submit_button)
form_widget = QWidget()
form_widget.setLayout(self.form_layout)
self.setCentralWidget(form_widget)
def submit_consultancy(self):
id_text = self.id_input.text()
name_text = self.name_input.text()
add_consultancy_use_case = AddConsultancy(self.coaching_practice_finance)
add_consultancy_use_case.execute(id_text, name_text)
self.setCentralWidget(QWidget())
def on_add_contractor(self):
self.form_layout = QFormLayout()
self.id_input = QLineEdit()
self.name_input = QLineEdit()
self.consultancy_id_input = QLineEdit()
self.form_layout.addRow('ID:', self.id_input)
self.form_layout.addRow('Name:', self.name_input)
self.form_layout.addRow('Consultancy ID:', self.consultancy_id_input)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.submit_contractor)
self.form_layout.addWidget(self.submit_button)
form_widget = QWidget()
form_widget.setLayout(self.form_layout)
self.setCentralWidget(form_widget)
def submit_contractor(self):
id_text = self.id_input.text()
name_text = self.name_input.text()
consultancy_id_text = self.consultancy_id_input.text()
add_contractor_use_case = AddContractor(self.coaching_practice_finance)
add_contractor_use_case.execute(id_text, name_text, consultancy_id_text)
self.setCentralWidget(QWidget())
if __name__ == "__main__":
app = QApplication([])
coaching_practice_finance = CoachingPracticeFinance(consultancies=[], invoices=[])
main_win = MainWindow(coaching_practice_finance)
main_win.show()
app.exec()

This PyQt6 application has an initial window where it lists consultancies and contractors. It has two buttons for adding a consultancy and adding a contractor. Clicking on one of these buttons will open a form where the user can type the relevant information. Clicking the submit button on the form will add the new consultancy or contractor and return to the main window.

Analysis, Iterative Approach

In each iterative pass, the sample code reinforces the code style generated by the LLM. This reduces the amount of context we need to expend providing english-language description for the code style we want.

At the end, we are really stretching the 8KB context limit of GPT-4. Yeah, it’s not the best UI and it’s monolithic, but I gave it no guidance on UI app design. It’s fun letting it run unleashed sometimes.

Now think about the couplings in this code.

The use cases are coupled to the model.

It’s harder to see, but if you think about the qt6 front-end as two pieces - the “main partition” and the UI itself, it is only the “main partition” that is tightly coupled to the model through the CoachingPracticeFinance class.

Think like this:

file: “main.py” (coupled to the UI and to the entity model)

from ui import MainWindow
from entities import CoachingPracticeFinance
app = QApplication([])
coaching_practice_finance = CoachingPracticeFinance(consultancies=[], invoices=[])
main_win = MainWindow(coaching_practice_finance)
main_win.show()
app.exec()

file: “ui.py” (only coupled to the use cases)

from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget, QFormLayout, QLineEdit, QLabel, QComboBox, QListWidget
from PyQt6.QtCore import Qt
from use_cases.add_consultancy import AddConsultancy
from use_cases.add_contractor import AddContractor
class MainWindow(QMainWindow):
def __init__(self, coaching_practice_finance):
super().__init__()
self.coaching_practice_finance = coaching_practice_finance
self.setWindowTitle("Coaching Practice Finance")
self.main_layout = QVBoxLayout()
self.consultancies_list = QListWidget()
self.contractors_list = QListWidget()
self.main_layout.addWidget(QLabel("Consultancies:"))
self.main_layout.addWidget(self.consultancies_list)
self.main_layout.addWidget(QLabel("Contractors:"))
self.main_layout.addWidget(self.contractors_list)
self.add_consultancy_button = QPushButton("Add Consultancy")
self.add_consultancy_button.clicked.connect(self.on_add_consultancy)
self.add_contractor_button = QPushButton("Add Contractor")
self.add_contractor_button.clicked.connect(self.on_add_contractor)
self.main_layout.addWidget(self.add_consultancy_button)
self.main_layout.addWidget(self.add_contractor_button)
main_widget = QWidget()
main_widget.setLayout(self.main_layout)
self.setCentralWidget(main_widget)
def on_add_consultancy(self):
self.form_layout = QFormLayout()
self.id_input = QLineEdit()
self.name_input = QLineEdit()
self.form_layout.addRow('ID:', self.id_input)
self.form_layout.addRow('Name:', self.name_input)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.submit_consultancy)
self.form_layout.addWidget(self.submit_button)
form_widget = QWidget()
form_widget.setLayout(self.form_layout)
self.setCentralWidget(form_widget)
def submit_consultancy(self):
id_text = self.id_input.text()
name_text = self.name_input.text()
add_consultancy_use_case = AddConsultancy(self.coaching_practice_finance)
add_consultancy_use_case.execute(id_text, name_text)
self.setCentralWidget(QWidget())
def on_add_contractor(self):
self.form_layout = QFormLayout()
self.id_input = QLineEdit()
self.name_input = QLineEdit()
self.consultancy_id_input = QLineEdit()
self.form_layout.addRow('ID:', self.id_input)
self.form_layout.addRow('Name:', self.name_input)
self.form_layout.addRow('Consultancy ID:', self.consultancy_id_input)
self.submit_button = QPushButton("Submit")
self.submit_button.clicked.connect(self.submit_contractor)
self.form_layout.addWidget(self.submit_button)
form_widget = QWidget()
form_widget.setLayout(self.form_layout)
self.setCentralWidget(form_widget)
def submit_contractor(self):
id_text = self.id_input.text()
name_text = self.name_input.text()
consultancy_id_text = self.consultancy_id_input.text()
add_contractor_use_case = AddContractor(self.coaching_practice_finance)
add_contractor_use_case.execute(id_text, name_text, consultancy_id_text)
self.setCentralWidget(QWidget())

By breaking the UI apart into different classes, say one per use case, we can further reduce the couplings.

Now, when constructing areas of the application that are isolated to satisfying a single use case, we can shed all the irrelevant context - the entities, the main partition, other use cases.

We can selectively include context to provide code style hints, if we feel it’s losing that thread, but this can reduce our needed context headroom to make space to generate larger systems a decoupled component at a time.

Phases, Futures

Notice in this example how we used parts of my original prompting, the english description of code style, to bootstrap us into generating code that becomes style templating for larger decoupled systems.

Each decoupled component still must fit in the context of the LLM prompting leaving enough headroom for generated responses, but this nicely mirrors the way we would want to code in order to manage cognitive load on human developers.

Systems like gpt-engineer leap into agent-style automated iteration, but leave a great deal to the LLM to guide style and structure through generative and critical iteration.

I’m still using Zero-Shot techniques here. When coding, I’m a firm believer of managing cognitive load by only doing one thing at a time, and I’m applying that principle here, focusing on Zero-Shot generative coding before we step into critical analysis.

I’m fairly happy with the generated code, and over this series of posts you’ve seen some of the language exploration I’ve done. The more popular languages generally work out best, GPT-4 isn’t much of a Rust coder, nor a Ruby coder, but it does quite well in Java, Python, and Javascript.

I swap out language-specific prompt components for each language I generate, leaving the remainder of the prompting the same, making this approach somewhat modular as well.

eg

Typescript prompt component:

Language and style: Use typescript with parameter property constructors where possible. Tests: Use Jest for testing, use describe and it blocks, and generate example data for each scenario. Nest describe scenarios when scenarios share a common context. Data structures: Composite data types should be expressed as interfaces. The data used by the application should be rooted by a single javascript object so that the whole object graph can be loaded and saved from a single json data file. Use-case classes: These are public facing, take the root of the object graph as an argument constructor, and have an execute method that should only take basic data types. Report-based Use-Case classes: These should join the data from the object structures into simple nested array structures for easy output of tabular data.

Python prompt component:

Language and style: Use python 3 with pydantic. Tests: Use pytest for testing, generate example data for each scenario Data structures: Use classes that extend pydantic’s BaseModel. You must fully annotate the model’s fields using pydantic’s Field class. The data used by the application should be rooted by a single model so that the whole object graph can be loaded and saved from a single json data file. Use-case classes: These are public facing, take the root of the object graph as an argument constructor, and have an execute method that should only take basic data types. Report-based Use-Case classes: These should join the data from the object structures into a dataframe, calculates the data for the report in the dataframe, and then uses a generic class for displaying the dataframe that leverages the tabulate library.

I’m not sure I’m ready to start digging into the critical analysis part of all of this because a lot of my thoughts are still forming, but my direction currently has shifted towards reframing my code style language towards the post-generation critical analysis stage like an Agent might do. I’m not fully bought into the current Agent style approach though, as I try and keep the future of all this open.


Welcome to my personal blog. Writing that I've done is collected here, some is technical, some is business oriented, some is trans related.