Build Your Own AIED Special Sauce ✨ App!

I’m throwing down a challenge to edtech developers everywhere. After spending over 15 years in education wading through products and services that really don’t shift the dial on teaching and learning, I want you to build something that works.

I’ve fired a few shots across edtech bows recently, but honestly I know lots of amazing former and current educators who work in the industry, and many of the products are built with the best of intentions. But I know there are just as many sharks in the water, and at the moment the sharkiest thing to do is to pour the AIED Special Sauce over a platform, and hope that it will convince enough teachers, schools, or districts to sign up.

So I’m calling it: If I can build what you’re offering with my rudimentary coding skills and a handful of open source applications, then you need to try harder.

In this article I’m going to expand on a series of LinkedIn posts I wrote whilst in transit between Perth and home. The morning of the trip, Google released a new open source model, Gemma-2 2B, boasting performance comparable to GPT-3.5 Turbo but in a ludicrously small (1.6 GB) package. I’ve played around with open source language models before, so I figured I’d have a go and making something with this one.

I’m going to break it down into a step by step process to show you how I got from this:

To this:

Set up the tools

Before getting into the whole process, you’ll need to set up the necessary tools: mainly the offline Gemma 2 2B model. The key component for this project is Google’s new lightweight language model, which I run locally using Ollama. Here’s how to get started:

  1. Download Ollama:
    • Go to the official Ollama website (https://ollama.ai/).
    • Download and install the Ollama application for your operating system.
  2. Run the Ollama application:
    • After installation, launch the Ollama application.
    • This will set up the necessary environment for running language models locally.
  3. Download the Gemma model:
    • Open a terminal or command prompt.
    • Run the following command to download and set up the Gemma 2B model: ollama run gemma2:2b
    • This command will download the Gemma 2B model, which is approximately 1.6 GB in size.
    • Wait for the download and initialisation to complete.

Once you’ve completed these steps, you’ll have Ollama set up with the Gemma 2B model ready to use in your project.

Later, we’ll also need to install a few python dependencies. Even if you’re not a very technical person, don’t worry: this is very straightforward.

Step 1: Starting with Existing Code

From what I understand, a lot of coding involves repurposing chunks of code from elsewhere, and this project is no different. I began with some working code for a file Question and Answer chatbot I previously built using Streamlit, a Python application which can be used to make browser-based interfaces, including for chatbots. This was based on code from the Streamlit example pages.

That original code used Meta’s latest model, Llama 3.1, to create an offline chatbot that can take a file upload as part of the prompt.

I uploaded this working file, as well as some more example code from the Streamlit website to create the initial code using Claude.

That first code gave me a minimalist chatbot, somewhat like OpenAI’s first version of ChatGPT, but using the Gemma 2 billion parameter model under the hood. It’s ludicrously simple, taking only 40-something lines of code (much of which is comments and whitespace).

This can then be run straight from terminal on a Mac or the command line on Windows with the command streamlit run <filename>.py.

Step 2: Adding Features

The next step was to add some features. At this stage, I just wanted a handful of features to extend that initial chatbot functionality.

Again, I wanted Claude to do most of the hard work. As we were still parked on the tarmac waiting for takeoff, I prompted Claude to recommend a handful of conditions, and I selected a few of those, such as translation features, as well as the capability to export to a Word document.

Chatbot prompt: OK that works. Based on your knowledge of streamlit, what are some cool functions we could add to the basic interface that would make it particularly useful for an educator? I'm thinking things like adding the file QA function back in for a start, but then what about something which allows it to output files e.g, as word docs (i'm not sure how exactly - maybe something which can be clicked to run a python docx script using the markdown that's output from the Gemma model?). What other features might be useful?

Most of these features worked immediately after downloading the new code and running a new instance of the Streamlit app.

The updated code contains a few extra functions, including one to generate the Word document for export
This version adds translation buttons and the ‘Export to Word’ function

Step 3: Iteration and Troubleshooting

Still sitting on the runway, and therefore still connected to the internet, I decided to use Claude to add a few more basic features and do some troubleshooting.

Gemma and many other language models often output text using a formatting language called Markdown. This allows the chatbot to use classic text formatting features like bold, italics, underline, heading styles and to create simple visuals like tables. Unfortunately, when you try to copy text from Claude or ChatGPT into a Word document, it often doesn’t translate, and you end up with text which is filled with asterisks, hashtags, and other formatting code.

This was a relatively simple fix with a one-line prompt to Claude, which resulted in an upgrade to the ‘Export to Word’ function. It used another Python package called Beautiful Soup to remove the Markdown formatting.

At this point, it’s worth making a note of the various packages needed for this code to run successfully, since they will need to be installed with an internet connection to be able to use this chatbot. I’m skipping ahead here, but by the time this code was finished, it used the following imports:

import streamlit as st
from streamlit_option_menu import option_menu
import ollama
from docx import Document
from docx.shared import Pt
from docx.enum.style import WD_STYLE_TYPE
from io import BytesIO
import markdown
from bs4 import BeautifulSoup
from pptx import Presentation
from pptx.util import Inches, Pt as PptxPt

This is where that little bit of Python understanding comes in handy, although it’s by no means difficult to do a quick search online and learn how to install the required packages.

On a MacBook, for example, I just need to open up terminal and use the following command to install everything at once:

pip install streamlit streamlit-option-menu ollama python-docx markdown beautifulsoup4 python-pptx

Having been playing around with this kind of code for a while now, most of these were already installed, which is good, because at this point the fasten seatbelt sign turned on, my tray table had to be stowed in the upright position, and my laptop closed and secured for takeoff.

Step 4: Offline Iterations

Once up in the air, I resisted the temptation to connect to Virgin Australia’s pay-to-play in-flight WiFi, and decided to do everything else offline. This meant ditching Claude and going through the next few iterations of the code the old-fashioned way. I opened up the most recent version from Visual Studio and started to tinker around with the individual settings.

Time for some offline work on flight VA712

One thing I noticed was the awkwardness of the columns, which meant that whenever a button was pressed, the new text would be generated inside a narrow column, rather than in a new chat window.

The columns with buttons, while functional, weren’t particularly useful

I couldn’t figure this out myself, and I couldn’t access Claude, so I opened up a new terminal window and started a chat with a slightly more powerful local LLM Llama 3.1 8b. Copying and pasting in chunks of code, I was able to troubleshoot many of the functions that weren’t working, and eventually move away from the bottom columns and to a more simplistic drop-down menu.

I screen recorded everything I did in-flight and edited what I’ve done so far into this first short video. The voiceover was recorded in a deserted Rex lounge at Adelaide airport.

Step 5: Refinements on Solid Ground

Back on solid ground, I took version one of the application back to Claude for some further refinements. For a start, the boring, monochrome colour scheme had to go: it’s not really an AIED special sauce platform if it doesn’t have some kind of pink, blue, yellow, green gradient and lots of sparkle emojis.

Being a browser-based application, Streamlit uses CSS for the styling. While Claude kept trying to put the CSS into a separate file, which I’m sure is best practice, I insisted that it try again with inline CSS so that I could package up this whole final thing as one single file.

Much more special sauce

I also prompted Claude for some better user experience features, which at times included uploading screenshots of the interface.

Adding sparkle

Finally, I added PowerPoint export as an additional feature on top of the Word document export. This needed an additional step, since while the Word document export was just taking the entire content of the last message, stripping the Markdown and putting it into a Word document, the PowerPoint export would need to take the contents of the message, convert it into titles and bullet points for slides, and then use that to generate the PowerPoint.

Initially, it hit the same Markdown issue as the Word document export, but because I’d fixed that once, I was able to fix it again. I ended up with a little function which uses Gemma to convert the last message received into a PowerPoint template, and then uses the Python-pptx module to create the slides.

The improved PowerPoint function, which uses Gemma to convert the last message into slides. You can view the code for the function here: https://claude.site/artifacts/602af040-7f44-41f2-8220-6bd9f838d8d4
OMG Volcanoes! PowerPoint generated by the above function. The code produces a basic template PowerPoint and Microsoft’s AI-assisted ‘Designer’ feature handles the images and style.
Exporting a Word doc after hammering the add sparkle button until it breaks

Step 6: Final Run-Through and Reflections

I want to stress again that I don’t think this kind of platform is actually good for teachers or students. I don’t think that most educators are happy to click a button and have AI generate lesson plans for them. Some of the functions like exporting to Word documents might be genuinely useful, but frankly, the world could probably use fewer PowerPoints.

The point of this exercise is to show that much of what is being advertised to teachers at the moment as “revolutionary AI-powered technology “is so simple that even an English teacher with no background in coding can make it in a couple of hundred lines of AI-generated code.

Here’s the final platform, and what it can do:

Features:

  • Text file upload
  • Word doc export
  • PowerPoint generation and export
  • Translation
  • Add sparkle ✨
  • Generate lesson plan
  • Blue gradient colour scheme

All of this happens offline, on device, with less than a couple of GB’s worth of downloads. Most of it was built by Claude 3.5 Sonnet in under an hour, with the features added whilst offline mid-flight.

Build it Yourself

Here is the complete code so you can build it yourself. You’ll need to download all of the dependencies, which won’t take long since the largest file here is the Gemma 2B language model itself, which is only 1.6 GB.

You should then be able to run it with no issues by copying this code into a blank .py file. Most of the readers on this blog probably couldn’t care less about cloning this from GitHub, so I’m not going to bother putting it there. But if you really want to, you’re welcome to clone, replicate, and butcher this code any way that you choose. It’s just under 400 lines of code, much of which is the CSS styling which could be shoved elsewhere or left out without really impacting the functionality.

I’m not going to pretend that this code is elegant, follows any kind of best practice, or that it replaces the kinds of apps I’m talking about. It’s a proof of concept, and something I’d encourage you to play around with yourself.

import streamlit as st
from streamlit_option_menu import option_menu
import ollama
from docx import Document
from docx.shared import Pt
from docx.enum.style import WD_STYLE_TYPE
from io import BytesIO
import markdown
from bs4 import BeautifulSoup
from pptx import Presentation
from pptx.util import Inches, Pt as PptxPt

# Set page config for a wider layout
st.set_page_config(layout="wide", page_title="Educational Assistant")

def generate_response(prompt, context=None):
    if context:
        messages = [{'role': 'user', 'content': f"Based on the following content, respond to this prompt: {prompt}\n\nContent: {context}"}]
    else:
        messages = [{'role': 'user', 'content': prompt}]
    
    response = ollama.chat(model='gemma2:2b', messages=messages)
    return response['message']['content']



def export_to_word(content):
    # Convert markdown to HTML
    html = markdown.markdown(content)
    
    # Parse HTML
    soup = BeautifulSoup(html, 'html.parser')
    
    # Create a new Document
    doc = Document()
    
    # Define styles
    styles = doc.styles
    style_normal = styles['Normal']
    style_heading = styles.add_style('Heading', WD_STYLE_TYPE.PARAGRAPH)
    style_bold = styles.add_style('Bold', WD_STYLE_TYPE.CHARACTER)
    style_italic = styles.add_style('Italic', WD_STYLE_TYPE.CHARACTER)
    
    style_heading.font.size = Pt(16)
    style_heading.font.bold = True
    style_bold.font.bold = True
    style_italic.font.italic = True
    
    # Function to add formatted text
    def add_formatted_text(element, paragraph):
        for child in element.children:
            if child.name == 'strong':
                paragraph.add_run(child.text).bold = True
            elif child.name == 'em':
                paragraph.add_run(child.text).italic = True
            elif child.name is None:  # This is a text node
                paragraph.add_run(child.string)
    
    # Process HTML elements
    for element in soup.find_all(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol']):
        if element.name.startswith('h'):
            paragraph = doc.add_paragraph(style=style_heading)
            add_formatted_text(element, paragraph)
        elif element.name == 'p':
            paragraph = doc.add_paragraph(style=style_normal)
            add_formatted_text(element, paragraph)
        elif element.name in ['ul', 'ol']:
            for i, li in enumerate(element.find_all('li'), start=1):
                paragraph = doc.add_paragraph(style=style_normal)
                paragraph.paragraph_format.left_indent = Pt(20)
                run = paragraph.add_run('• ' if element.name == 'ul' else f"{i}. ")
                add_formatted_text(li, paragraph)
    
    bio = BytesIO()
    doc.save(bio)
    return bio.getvalue()

def create_powerpoint(content):
    # Generate slide content using Gemma
    slide_content = generate_response(f"Create a 5-slide PowerPoint presentation based on this content. For each slide, provide a title prefixed with 'title:' and 3-5 bullet points prefixed with 'bullet points:'. Use markdown formatting. Separate each slide with '---': {content}")
    
    # Create a new PowerPoint presentation
    prs = Presentation()
    
    # Parse the generated content
    slides = slide_content.split('---')
    
    for slide_content in slides:
        if not slide_content.strip():
            continue  # Skip empty slides
        
        # Split the content into title and bullet points
        parts = slide_content.split('bullet points:', 1)
        if len(parts) != 2:
            continue  # Skip slides with incorrect format
        
        title_part = parts[0].strip()
        bullet_points_part = parts[1].strip()
        
        # Extract title and convert from markdown
        title_html = markdown.markdown(title_part.replace('title:', '').strip())
        title_soup = BeautifulSoup(title_html, 'html.parser')
        title = title_soup.get_text().strip()
        
        # Convert bullet points from markdown to HTML, then to plain text
        bullet_points_html = markdown.markdown(bullet_points_part)
        bullet_soup = BeautifulSoup(bullet_points_html, 'html.parser')
        bullet_points = [li.get_text().strip() for li in bullet_soup.find_all('li')]
        
        # Add a slide
        slide_layout = prs.slide_layouts[1]  # Using the bullet slide layout
        slide = prs.slides.add_slide(slide_layout)
        
        # Set the title
        title_shape = slide.shapes.title
        title_shape.text = title
        
        # Add bullet points
        body_shape = slide.shapes.placeholders[1]
        tf = body_shape.text_frame
        
        if bullet_points:
            tf.text = bullet_points[0]
            
            for point in bullet_points[1:]:
                p = tf.add_paragraph()
                p.text = point
                p.level = 0
        else:
            tf.text = "No bullet points provided."
    
    # Ensure at least one slide is created
    if not prs.slides:
        slide = prs.slides.add_slide(prs.slide_layouts[0])
        title = slide.shapes.title
        title.text = "Presentation"
        subtitle = slide.placeholders[1]
        subtitle.text = "No content was generated for this presentation."
    
    # Save the presentation
    pptx_stream = BytesIO()
    prs.save(pptx_stream)
    return pptx_stream.getvalue()

# Custom CSS for better aesthetics with improved readability
st.markdown("""
<style>
    @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap');

    :root {
        --primary-color: #6a11cb;
        --secondary-color: #2575fc;
        --accent-color: #ffd166;
        --text-color: #ffffff;
        --background-color: rgba(255, 255, 255, 0.1);
    }

    .stApp {
        background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
        font-family: 'Poppins', sans-serif;
    }

    .main {
        max-width: 800px;
        margin: 0 auto;
        padding: 2rem;
        background-color: var(--background-color);
        backdrop-filter: blur(10px);
        border-radius: 10px;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    }

    /* Sidebar styling */
    [data-testid="stSidebar"] {
        background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
        padding: 2rem 1rem;
    }

    [data-testid="stSidebar"] .stSelectbox {
        margin-bottom: 1rem;
    }

    /* Style for the menu items in sidebar */
    .css-1544g2n {
        padding: 1rem;
        background-color: var(--background-color);
        border-radius: 10px;
        margin-bottom: 1rem;
    }

    /* Chat prompt box styling */
    .stTextInput > div > div > input {
        background-color: var(--background-color);
        color: var(--text-color);
        border: 1px solid rgba(255, 255, 255, 0.2);
        border-radius: 5px;
        padding: 0.5rem 1rem;
    }

    /* File uploader styling */
    .stFileUploader {
        background-color: var(--background-color);
        border: 1px solid rgba(255, 255, 255, 0.2);
        border-radius: 10px;
        padding: 1rem;
    }

    h1, h2, h3 {
        color: var(--accent-color);
        font-weight: 600;
        margin-bottom: 1rem;
    }

    p {
        color: var(--text-color);
        line-height: 1.6;
        margin-bottom: 1rem;
    }

    .stButton > button {
        background-color: var(--accent-color);
        color: var(--primary-color);
        font-weight: 600;
        border: none;
        border-radius: 5px;
        padding: 0.5rem 1rem;
        transition: all 0.3s ease;
    }

    .stButton > button:hover {
        background-color: var(--primary-color);
        color: var(--accent-color);
    }

    .chat-message {
        padding: 1.5rem;
        border-radius: 0.5rem;
        margin-bottom: 1rem;
        display: flex;
        box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        background-color: var(--background-color);
    }

    .chat-message .message {
       width: 100%;
       color: var(--text-color);
    }

    /* Custom scrollbar */
    ::-webkit-scrollbar {
        width: 10px;
    }

    ::-webkit-scrollbar-track {
        background: rgba(255, 255, 255, 0.1);
    }

    ::-webkit-scrollbar-thumb {
        background: var(--accent-color);
        border-radius: 5px;
    }

    ::-webkit-scrollbar-thumb:hover {
        background: var(--primary-color);
    }
</style>
""", unsafe_allow_html=True)

st.title("✨ AIED Special Sauce Mega Platform V2.0 ✨")

# Sidebar
with st.sidebar:
    selected = option_menu("Menu", ["Chat", "Actions", "Export"], 
        icons=['chat', 'gear', 'file-earmark-arrow-down'], menu_icon="cast", default_index=0,
        styles={
            "container": {"padding": "1rem", "background-color": "rgba(255, 255, 255, 0.1)"},
            "icon": {"color": "var(--accent-color)", "font-size": "25px"}, 
            "nav-link": {"color": "var(--text-color)", "font-size": "16px", "text-align": "left", "margin":"0px", "--hover-color": "rgba(255, 255, 255, 0.2)"},
            "nav-link-selected": {"background-color": "var(--accent-color)", "color": "var(--primary-color)"},
        }
    )

# Initialize chat history
if "messages" not in st.session_state:
    st.session_state.messages = []

if selected == "Chat":
    # File uploader for Q&A
    uploaded_file = st.file_uploader("📄 Upload a document for context (optional)", type=["txt"])
    if uploaded_file:
        file_content = uploaded_file.getvalue().decode("utf-8")
        st.session_state.file_content = file_content
        st.success("✅ File uploaded successfully!")

    # Display chat messages from history on app rerun
    for message in st.session_state.messages:
        with st.chat_message(message["role"]):
            st.markdown(message["content"])

    # Accept user input
    if prompt := st.chat_input("💬 What would you like to do today?"):
        # Add user message to chat history
        st.session_state.messages.append({"role": "user", "content": prompt})
        # Display user message in chat message container
        with st.chat_message("user"):
            st.markdown(prompt)

        # Generate response
        context = st.session_state.file_content if "file_content" in st.session_state else None
        response = generate_response(prompt, context)

        # Display assistant response in chat message container
        with st.chat_message("assistant"):
            st.markdown(response)
        
        # Add assistant response to chat history
        st.session_state.messages.append({"role": "assistant", "content": response})

        # Force a rerun to update the display immediately
        st.experimental_rerun()

elif selected == "Actions":
    if st.session_state.messages:
        latest_message = st.session_state.messages[-1]["content"]

        action = st.selectbox("🔧 Choose an action:", [
            "Select an action",
            "Translate to French",
            "Create a lesson plan",
            "Generate a vocabulary list",
            "Add sparkle to the text",
            "Create a PowerPoint presentation"
        ])

        if st.button("▶️ Perform Action"):
            if action == "Translate to French":
                prompt = f"Translate the following text to French: {latest_message}"
            elif action == "Create a lesson plan":
                prompt = f"Produce a clear, single 50 minute lesson plan based on the following content. Provide 3 learning objectives and success criteria: {latest_message}"
            elif action == "Generate a vocabulary list":
                prompt = f"Generate a vocabulary list based on the following content: {latest_message}"
            elif action == "Add sparkle to the text":
                prompt = f"Take this, keep the text the same, but cover it in relevant emojis: {latest_message}"
            elif action == "Create a PowerPoint presentation":
                pptx_data = create_powerpoint(latest_message)
                st.download_button(
                    label="📥 Download PowerPoint",
                    data=pptx_data,
                    file_name="presentation.pptx",
                    mime="application/vnd.openxmlformats-officedocument.presentationml.presentation"
                )
                st.success("✅ PowerPoint presentation created successfully!")
                st.stop()
            else:
                st.warning("⚠️ Please select an action.")
                st.stop()

            response = generate_response(prompt)
            
            # Display the result
            with st.chat_message("assistant"):
                st.markdown(response)
            
            # Add the result to chat history
            st.session_state.messages.append({"role": "assistant", "content": response})

    else:
        st.warning("⚠️ No chat history. Please start a conversation first.")

elif selected == "Export":
    if st.session_state.messages:
        latest_message = st.session_state.messages[-1]["content"]
        
        export_type = st.radio("📁 Choose export format:", ["Word", "PowerPoint"])
        
        if export_type == "Word":
            if st.button("📄 Export to Word"):
                word_doc = export_to_word(latest_message)
                st.download_button(
                    label="📥 Download Word Document",
                    data=word_doc,
                    file_name="exported_content.docx",
                    mime="application/vnd.openxmlformats-officedocument.wordprocessingml.document"
                )
        else:  # PowerPoint
            if st.button("📊 Export to PowerPoint"):
                pptx_data = create_powerpoint(latest_message)
                st.download_button(
                    label="📥 Download PowerPoint",
                    data=pptx_data,
                    file_name="presentation.pptx",
                    mime="application/vnd.openxmlformats-officedocument.presentationml.presentation"
                )
    else:
        st.warning("⚠️ No content to export. Please generate some content first.")

All in all, you get a simple AI chatbot, a little like ChatGPT 3.5, with text file uploads for additional context, translation buttons, and other actions, including translation and the all-important sparkle feature. It has the export to PowerPoint function, and of course, the special sauce platform blue-purple gradient colour sceme.

If I wanted to, it would be very straightforward to add a dozen or 100 more features simply by adjusting the code for the drop-down actions. At one point during the design, I also had a chat history which took the entire contents of the thread and exported it as a JSON file. I cut that code from the end product in the interest of maintaining my sanity.

If you wanted to add features to this code yourself, you would just edit a few more options to the dropdown and the if/else statements. This will work for every feature that uses the AI to generate a variation on the content. For example, you could add a ‘generate quiz’ feature using something like:

if action == "Generate Quiz":
                prompt = f"Generate a 10 questions and answers based on: {latest_message}"

Or, another popular feature of “AI-powered” platforms, simplify and modify text with an option like:

if action == "Simplify":
                prompt = f"Write a simplified version of the following using a brief paragraph summary and bullet points: {latest_message}"

If I wanted to (I don’t), it wouldn’t be too difficult to add features like image generation (maybe with an offline-capable model like the new Flux image generator), export to Google Docs via the API, or even add voice to text features with a model like OpenAI’s Whisper. Again, I’m not going to do that because I’m not building a product here, I’m just pointing out that I can: and therefore, any educator with a reasonable grasp of the technology can do the same and more.

So again, here’s my challenge to the edtech companies: by all means, pour the AI special sauce over your platform, but don’t try to market it to teachers as something magical, complex, or particularly sophisticated. Don’t hide what is, in effect, a few dozen lines of code behind shiny websites and expect teachers or venture capitalists to throw millions of dollars at you.

Instead, build something that works, and something that teachers and students actually want and need.

Chatbots which create lesson plans at the click of a button are not only ludicrously easy to build, they don’t shift the dial on teaching and learning. Get into classrooms and listen. Even better, don’t treat teachers as free market-research: pay them for their time, and value what they have to say.

And most importantly, if an English teacher can build most of your product at 40,000 feet with a handful of open source tools, then maybe you should focus your time and energy on something else.

Want to learn more about GenAI professional development and advisory services, or just have questions or comments? Get in touch:

← Back

Thank you for your response. ✨

2 responses to “Build Your Own AIED Special Sauce ✨ App!”

  1. Interesting project!

  2. […] Over the two and a half years writing about AI, I’ve brought attention to a lot of different strands of emerging technologies, including audio generation, video generation, and using AI to code including making quick apps, turning sketches into websites, and building my own slightly cheeky AI-powered edtech platform. […]

Leave a Reply