The power of automation: How I built the Ship30 Endless Idea Generator into Notion using AI and Go to generate article ideas directly within Notion
One fine Saturday evening, I was generating article ideas using the ChatGPT prompt provided by Ship30 and thought to myself “This would be so much easier to do directly in Notion”
At that moment I went down a rabbit hole figuring out how I could do this. I’ve built plenty of ancillary tools into my Notion setup so I was comfortable with that end, but I’d never used the OpenAI API. Thirty minutes and a proof of concept later, I realized I could totally pull this off!
Here are the steps I took to build the Ship30 EIG into my Notion workspace.
Create the OpenAI client in Go
The first thing I did was create an API key at https://platform.openai.com/account/api-keys.
This key is used within the code to authenticate with my OpenAI account. I used the github.com/sashabaranov/go-openai
package that was set up for exactly what I needed. Finally, using the Endless Idea Generator ChatGPT (stored as a .txt file) prompt with the ChatMessageRoleSystem
role, I can train the bot to accept a generic prompt.
This is the function that puts that whole thing together:
func GenerateIdeas(prompt string) (*string, *string, error) {
f, err := os.ReadFile("./eig.txt")
if err != nil {
return nil, nil, err
}
eigPrompt := string(f)
client := openai.NewClient(key)
resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT3Dot5Turbo,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: eigPrompt,
},
{
Role: openai.ChatMessageRoleAssistant,
Content: prompt,
},
},
},
)
if err != nil {
return nil, nil, err
}
return &resp.ID, &resp.Choices[0].Message.Content, nil
}
Notion configuration
I have two databases in Notion for this project: EIGRequests and EIGIdeas.
EIGRequests is where I can enter a prompt for ideas I want to generate. The title
field is the actual prompt, although I can manually specify the topic, audience, and outcome if needed. The prompt is actually modified to generate a JSON payload instead of text so I can easily parse it.
This is what the database looks like:
EIGIdeas holds the generated ideas.
Once the list of ideas is generated in that JSON structure, I can parse them into this table. The table is broken down by idea, idea type, and idea subtype. I also have a relation field linking back to the original prompt so I know where it came from.
Here is the EIGIdeas database:
Running it on my server
I want this thing to work without having to manually run it, so I decided to use the github.com/go-co-op/gocron
package to do so.
This package allows me to easily specify an interval to run any function in Go. I run it on my home server with Kubernetes, but you could easily set this up in an EC2 instance. When the function runs every 3 min, it does the following:
Check the EIGRequest database for open requests.
Flip the status of the request to Pending.
Parse the response and send it to OpenAI using the function shown above.
Take that response and create a record in the EIGIdeas database for each one.
Put the raw JSON into the specific page with the request.
The end result is that I can add a prompt to Notion, and this system will generate 29 article ideas for me.
Here is how the go-cron package is configured to run the code every 3 minutes:
s := gocron.NewScheduler(time.UTC)
s.Every(3).Minute().Do(CheckForRequests)
Here is the function called by go-cron:
func CheckForRequests() {
// Call the Notion API to get any EIGRequest records with "Ready" status
openRequests, err := GetOpenRequests()
if err != nil {
log.Fatal(err)
}
for _, el := range openRequests.Results {
props := el.Properties.(notion.DatabasePageProperties)
prompt := props["Prompt"].Title[0].Text.Content
// Call the Notion API to change the status to Pending so it doesnt get called again
err = SetStatusToPending(el.ID)
if err != nil {
log.Fatal(err)
}
var id *string
var response *string
// Some retry logic in case something doesnt go right
ok := false
i := 0
maxTries := 5
for i < maxTries {
i++
id, response, err = GenerateIdeas(prompt)
if err != nil {
log.Fatal(err)
}
// Response is probably good since its JSON (Starts with '{')
if strings.HasPrefix(*response, "{") {
i = maxTries + 1
ok = true
}
}
if !ok {
// If I don't get a good response, set the EIGRequest to Error status for later
err = CompleteEigRequest(el.ID, *id, *response, "Error", nil)
if err != nil {
log.Fatal(err)
}
return
}
var pr ParsedResponse
err = json.Unmarshal([]byte(*response), &pr)
if err != nil {
log.Fatal(err)
}
// Loop over the response from OpenAI and create an EIGIdea for each
for ideaType, subTypeStruct := range pr.Ideas {
for subtype, idea := range subTypeStruct {
err = CreateEigIdea(el.ID, ideaType, subtype, idea)
if err != nil {
log.Fatal(err)
}
time.Sleep(2 * time.Second)
}
}
// Change EIGRequest to Done, add response to body
err = CompleteEigRequest(el.ID, *id, *response, "Done", &pr)
if err != nil {
log.Fatal(err)
}
}
}
Finally just to give an idea, here are what many of the functions to call Notion look like:
var c *notion.Client
func SetStatusToPending(pageId string) error {
_, err := c.UpdatePage(context.Background(), pageId, notion.UpdatePageParams{
DatabasePageProperties: notion.DatabasePageProperties{
"Status": notion.DatabasePageProperty{
Status: ¬ion.SelectOptions{
Name: "Pending",
},
},
},
})
return err
}