<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ edtech - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ edtech - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 21 Jul 2026 14:37:43 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/edtech/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Teach a Small LLM to Suggest K12 Creative Project Ideas ]]>
                </title>
                <description>
                    <![CDATA[ Recently, I wrote a post about an educational app I'd developed using AI tools, and the design decisions I made along the way. When I showed the prototype of my activity-based learning app to a few ed ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-teach-a-small-llm-to-suggest-k12-creative-project-ideas/</link>
                <guid isPermaLink="false">6a3ab6628d22211aa0282f4f</guid>
                
                    <category>
                        <![CDATA[ edtech ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Srishti Sethi ]]>
                </dc:creator>
                <pubDate>Tue, 23 Jun 2026 16:37:54 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/381c2b8d-ed7d-4f88-b0d4-f4ba90878758.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Recently, I <a href="https://www.freecodecamp.org/news/technical-design-decisions-educational-app-llms/">wrote a post about an educational app</a> I'd developed using AI tools, and the design decisions I made along the way.</p>
<p>When I showed the prototype of my activity-based learning app to a few educators, one suggestion came up repeatedly that was drawn from their own experience hunting for creative ideas on platforms like Pinterest and TikTok. They wanted a feature that could pull project ideas from across the internet based on practical search criteria: the materials they have access to, and what they'd like the end product to look like.</p>
<p>The app already has a basic search that returns results from its own activity data, but that data is still limited at this stage. Generating results from outside the app felt like something LLMs are well suited to handle.</p>
<p>I was also curious to learn how you actually teach a K12 LLM – not the kind that needs enormous datasets and compute (which I don't have access to), but the mechanics of it, for learning's sake. And, like in my previous post, I wanted to think through the design choices that go into it:</p>
<ul>
<li><p>What are the technicalities behind teaching a small LLM to handle a K12 use case?</p>
</li>
<li><p>How, and on what data, do you train such a model?</p>
</li>
<li><p>How do you ensure the model is child friendly?</p>
</li>
<li><p>What does it take to integrate the model into your app?</p>
</li>
</ul>
<p>In this post, I'll document everything I learned about training such a model and integrating it as a feature in my educational prototype.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-dataset-preparation">Dataset Preparation</a></p>
</li>
<li><p><a href="#heading-filtering-the-corpus">Filtering the Corpus</a></p>
</li>
<li><p><a href="#heading-generating-training-pairs">Generating Training Pairs</a></p>
</li>
<li><p><a href="#heading-fine-tuning">Fine Tuning</a></p>
</li>
<li><p><a href="#heading-evaluating-the-fine-tuned-model">Evaluating the Fine-tuned Model</a></p>
</li>
<li><p><a href="#heading-building-the-index-amp-rag-retrieval">Building the Index &amp; RAG Retrieval</a></p>
</li>
<li><p><a href="#heading-integrate-the-model-with-the-feature">Integrate the Model with the Feature</a></p>
</li>
<li><p><a href="#heading-making-content-safe">Making Content Safe</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>This is a hands-on tutorial, so here's what will help you follow along or train the model yourself.</p>
<p><strong>Skills you'll want</strong></p>
<ul>
<li><p>Using Claude on the command line.</p>
</li>
<li><p>Basic Python: reading code, installing and using packages, calling APIs, and making sense of output like log files.</p>
</li>
<li><p>Reading a bit of TypeScript, since that's what the app's frontend is built in.</p>
</li>
<li><p>Most importantly, being comfortable following Claude's reasoning, weighing the options it lays out, and deciding what to do next. That back-and-forth, not any single command, is really the core skill this kind of project asks for.</p>
</li>
</ul>
<p>You don't need a background in machine learning. The post tries to explain the ML concepts as it goes, in plain language.</p>
<p><strong>Setup you'll need</strong></p>
<ul>
<li><p>An Apple Silicon Mac (M1/M2/M3 or newer). The fine-tuning step uses MLX, Apple's framework, which only runs on Apple Silicon.</p>
</li>
<li><p>Python 3 with a virtual environment <code>python3 -m venv</code>).</p>
</li>
<li><p>Ollama installed, with the Qwen 2.5 7B model pulled <code>ollama pull qwen2.5:7b</code>), for generating the training data locally. You'll want enough RAM to run a 7B model.</p>
</li>
<li><p>Claude on the command line, for working through the build.</p>
</li>
</ul>
<h2 id="heading-dataset-preparation"><strong>Dataset Preparation</strong></h2>
<p>For this experiment, I wanted the activity data to be grounded in local cultures from around the world. This would help the model suggest creative project ideas that inspire the facilitation of cultural activities in educational settings.</p>
<p>I'd come across a lot of Wikipedia articles on local arts and traditions over the years. Wikipedia is my favorite resource for information: it's human-first, its content is updated frequently, and as an open source project its APIs are free to use. So I decided to use Wikipedia data to teach my model.</p>
<p>The genuinely hands-on part of this stage was seeding the right categories. In a Python script, I defined ~40 seed categories and grouped them under 9 STEAM labels with suggestions from Claude on which categories to scrape and how to avoid noise in the fetched data.</p>
<p>For extracting text from the sections of each article, Claude suggested a Python wrapper for the Wikipedia API. This let me fetch each article as a section-structured record. To keep noise down, I limited the crawl to one sub-category level deep and only kept articles above a certain content size.</p>
<pre><code class="language-python"># Seed categories grouped by STEAM domain.
SEED_CATEGORIES = {
    "Crafts &amp; making": [
        "Category:Crafts",
        "Category:Origami",
        "Category:Pottery",
        "Category:Kites",
    ],
    "Arts": [
        "Category:Folk art",
        "Category:Textile arts",
        "Category:Indigenous art",
        "Category:Masks",
    ],
    "Science": [
        "Category:Ethnobotany",
        "Category:Food preservation",
        "Category:Gardening",
    ],                                                            
# ... Media arts, Engineering, Mathematics, Music &amp; sky, Play &amp; learning
}

MAX_DEPTH = 1             # descend only one sub-category level
MIN_CONTENT_CHARS = 800   # skip stubs (summary + sections)
</code></pre>
<h2 id="heading-filtering-the-corpus">Filtering the Corpus</h2>
<p>The previous step wrote ~19,000 articles during scraping. This step makes sure the content stays relevant to STEAM topics. Relevance filtering itself runs in two stages: removing obvious noise, then semantic filtering.</p>
<p>The first stage drops obvious non-activity content like music, films, TV, biographies, plant/animal species using category, title, and section-heading patterns.</p>
<p>The second, semantic stage converts each article's title and summary into a vector using a small sentence-transformer model (all-MiniLM-L6-v2). It then compares it against two sets of example sentences: positive and negative anchors.</p>
<p>The positive anchors describe sentences relevant to STEAM activities and the negative anchors describe less relevant ones. Each article gets a score based on how close it sits to the positive examples versus the negative ones, and we keep every article that leans positive. We do this with the sentence-transformers library.</p>
<p>Writing these anchor sentences is the most human step in the process. With this filtering, I brought the corpus down to ~6,600 articles.</p>
<pre><code class="language-python"># Filtering the raw scrape to articles useful for STEAM activity suggestions.

POSITIVE_ANCHORS = [
    "a hands-on craft that children can make using simple materials and a technique",
    "a traditional cultural art or making technique such as weaving, carving, pottery or paper folding",
]
NEGATIVE_ANCHORS = [
    "a species of plant, animal or fungus",
    "a biography of a person",
    "a city, region, building or geographic place",
]

    # Embed article + anchors, then keep whatever leans positive.
    pos_sim = util.cos_sim(emb, pos).max(dim=1).values # closest positive anchor 
    neg_sim = util.cos_sim(emb, neg).max(dim=1).values # closest negative anchor
    scores = (pos_sim - neg_sim).tolist()
</code></pre>
<h2 id="heading-generating-training-pairs"><strong>Generating Training Pairs</strong></h2>
<p>The next step is to generate input → output training pairs from the filtered corpus. We do this by distilling it through a pretrained, local open-source model (Qwen 2.5 7B, running via Ollama).</p>
<p>For each article, you send the model the title, summary, cultural context, and a few content sections. You also send it a system prompt that explains the task, specifies the output format (valid JSON, in this case), and includes one example training pair to anchor the format.</p>
<p>Constructing this prompt well is where human intervention matters most: the schema, the rules, and that single worked example are what determine the quality of every pair the model generates.</p>
<p>After generation, we cleaned and prepared the pairs for fine-tuning. The local model tended to invent its own category labels ("Ceramics," "Crafts &amp; Making," "Circuits (metaphorical)"…). So this step maps every category onto the app's fixed set of 10 canonical categories (Art, Science, Coding, Circuits, Engineering, Storytelling, Drama, Film, Music, Nature), clamps each activity's age range into the K12 band, converts the pairs into chat format, and finally splits the data into three sets: train, validate, and test.</p>
<pre><code class="language-json"># The schema every generated training pair must match (valid JSON only).
  {
    "input": {
      "materials": ["3-6 realistic classroom materials"],
      "age_range": [min_int, max_int],
      "theme": "optional string or null"
    },
    "output": {
      "ideas": [{
        "title": "catchy, max 60 chars",
        "description": "2-3 sentences",
        "category": "one of: Art, Science, Coding, Circuits, Engineering, ...",
        "cultural_origin": "specific region or culture",
        "materials_used": ["subset of input materials"],
        "materials_missing": ["anything else needed"],
        "estimated_minutes": integer,
        "steps": ["3-6 short steps, one sentence each"],
        "learning_objectives": ["2-4 objectives"],
        "safety_note": "string or null"
      }]
    }
  }
</code></pre>
<h2 id="heading-fine-tuning"><strong>Fine-Tuning</strong></h2>
<p>This is the step where the model learns how to behave and generate a desired response in the appropriate format. It involves fine-tuning a pretrained model (Qwen2.5-1.5B-Instruct-4bit in this case) via MLX on my dataset using the LoRA technique.</p>
<p>Fine-tuning with LoRA is a cheap and lightweight approach: it doesn't retrain the whole model, but instead adds a tiny correction layer that adjusts the final behavior while the original model stays frozen.</p>
<p>Given the constraints of this project, working on a personal laptop with a small dataset of ~400 pairs, full fine-tuning would have needed significantly more memory and compute, which would be overkill here. So LoRA was the right choice.</p>
<h3 id="heading-the-lora-fine-tuning-cycle">The LoRA Fine-tuning Cycle:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6a172a9fbadcd8afcb11f314/bb1b995b-ff56-4364-8246-c885449c7399.png" alt="Flowchart showing the LoRA fine-tuning cycle" style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p>Training runs many iterations over the training pairs, and each iteration is the same short cycle. For each input, the model produces a prediction by assigning a probability score to every possible next word, based on the input and the model's current weights. During training it is then graded on how much probability it gave the actual correct next word from the training data.</p>
<p>(Note: in a neural network, <a href="https://www.youtube.com/watch?v=nEt5_8V_wpY">weights and biases</a> are the numbers that determine how the model processes an input, makes a prediction, and generates a response.).</p>
<p>From that comparison it calculates the train loss. It then updates the weights accordingly, specifically the small LoRA adapter weights, while the frozen base model stays untouched, so that next time the guess is a little closer. The lower the loss, the better the model is fitting the data.</p>
<p>Then it moves on to the next iteration, and the cycle repeats. At the end, the trained adapter weights are saved out to a safetensors file.</p>
<p>For example, here is how the validation loss moved over my run: 2.532 → 0.842 → 0.823 → 0.814 → 0.820 → 0.831 → 0.845. It dropped sharply at first (the model was genuinely learning), bottomed out at 0.814 around iteration 300, then ticked back up to 0.845 by the end. This was early sign that the model was starting to overfit, that is memorize the training data rather than continue improving.</p>
<p>So the sweet spot was the middle of the run, not the very end. This is where human review mattered most: I saved checkpoints at iterations 200, 400, and 600, and chose the 400 checkpoint, the one with the lowest validation loss among them, to evaluate and serve.</p>
<pre><code class="language-yaml"># Base model — small, instruction-tuned, 4-bit (runs on a laptop)
  model: "mlx-community/Qwen2.5-1.5B-Instruct-4bit"

  train: true
  data: "data/mlx"            # training data: train.jsonl + valid.jsonl
  adapter_path: "adapters"    # &lt;- the trained LoRA weights get saved here

  fine_tune_type: lora
  num_layers: 8               # apply LoRA to the last 8 transformer layers only
  lora_parameters:
    rank: 8                   # adapter size — bigger = more capacity, more overfit risk

  # Training loop
  batch_size: 4               # 400 train examples / 4 = 100 iterations per epoch
  iters: 600                  # ~6 passes over the training set
  learning_rate: 1e-5

  # Watch validation loss to catch overfitting
  steps_per_eval: 100         # check validation loss every 100 steps
  save_every: 200             # checkpoint adapters at 200 / 400 / 600
</code></pre>
<p>Above is the configuration file. It shows the model used, the adapter path, the fine-tuning and LoRA settings, the training loop, and the validation pass.</p>
<p>Below is the command, run with MLX (Apple's machine learning framework), that kicks off the fine-tuning process:</p>
<pre><code class="language-shell">mlx_lm.lora --config lora_config.yaml
</code></pre>
<p>The output below shows the result: the trained weights land in the adapters/ folder, with a checkpoint saved every 200 iterations at 200, 400, and 600.</p>
<pre><code class="language-shell">  adapters/
  ├── 0000200_adapters.safetensors
  ├── 0000400_adapters.safetensors   &lt;- the one you serve (lowest val loss of the three)
  ├── 0000600_adapters.safetensors
  └── adapters.safetensors           &lt;- copy of the final (600) weights
</code></pre>
<h2 id="heading-evaluating-the-fine-tuned-model"><strong>Evaluating the Fine-tuned Model</strong></h2>
<p>Once fine-tuning was done, the model needed to be evaluated on the held-out test set, the 50 examples set aside during the training-pair generation step and never seen during training.</p>
<p>In this step, the user message is fed to the model, the model generates its own JSON answer, and that answer is compared against the gold (correct/reference) answer already stored in the file.</p>
<p>The evaluation checks and reports whether the JSON is valid, whether it has the expected keys, how much the predicted materials overlap with the gold answer, how often the prediction names a specific cultural origin, and so on.</p>
<p>It runs this for every example in the test set, printing a short per-example line and a summary at the end. It saves the full results, including each predicted idea alongside the actual (gold) idea, so you can read them side by side.</p>
<pre><code class="language-json"># Fine-tuned model on 50 held-out test examples:
  {
    "json_valid_rate":       1.00,   # always valid JSON
    "schema_match_rate":     1.00,   # always the right keys
    "avg_n_steps":           4.74,   # ~5 steps per idea
    "avg_materials_jaccard": 0.653,  # decent overlap with gold materials
    "pred_culture_specific_rate": 0.52,   # names a specific culture about half the time
    "culture_loose_match_rate":   0.108,  # but it's usually the WRONG one  &lt;-- the gap RAG tries to close
  }
</code></pre>
<h2 id="heading-building-the-index-amp-rag-retrieval"><strong>Building the Index &amp; RAG Retrieval</strong></h2>
<p>In the previous step we found that <code>culture_loose_match_rate_when_gold_specific</code> was low: the model is bad at recalling the right cultural origin for a suggested activity.</p>
<p>In this step, we'll try to address that weakness with RAG (retrieval-augmented generation). Instead of hoping that the model has memorized that Raku is Japanese, we'll look up the real Wikipedia article at query time, hand it to the model, and then test whether retrieval actually helps.</p>
<p>This happens in two parts. First, we'll build a retrieval index, turning the Wikipedia corpus we collected earlier into a searchable "meaning database." For each article we compute an embedding by passing its title and summary through a small embedding model, all-MiniLM-L6-v2. An embedding is a numeric fingerprint of meaning, a row of 384 numbers, and articles with similar meaning end up with similar numbers. These are computed once, offline, and saved to disk.</p>
<p>Second comes the retrieval itself. At query time, we turn the query into the same kind of vector, score every article by how similar it is, and return the few with the highest scores (that is, the articles whose meaning is closest to what the user asked for). We then run the same evaluation as the previous phase, but with these retrieved articles pasted into the prompt, to answer the core question: when the model is handed the right Wikipedia article, does it do better?</p>
<p>In a nutshell, this phase is: retrieve the relevant articles, augment the prompt with them, and let the model generate.</p>
<pre><code class="language-python">def retrieve(query, embedder, embeddings, meta, k):
      # 1. turn the query into the same kind of 384-number vector
      q = embedder.encode([query], normalize_embeddings=True,
                          convert_to_numpy=True)[0]
      # 2. score every article by similarity (dot product of unit vectors = cosine)
      sims = embeddings @ q
      # 3. take the k closest, return them with their scores
      top = np.argsort(-sims)[:k]
      return [(meta[i], float(sims[i])) for i in top]
</code></pre>
<p>So with RAG, the materials overlap improved and the model named a specific culture more often – but the exact cultural match barely moved. This is something I would like to improve in future versions of the app.</p>
<pre><code class="language-plaintext">Metric                        Plain     + RAG     Change
materials_jaccard             0.653     0.752     better
pred_culture_specific_rate    0.52      0.64      better
culture_loose_match_rate      0.108     0.135     barely
</code></pre>
<h2 id="heading-integrate-the-model-with-the-feature"><strong>Integrate the Model with the Feature</strong></h2>
<p>Now it's time to integrate the fine-tuned model into the app and see what cultural activities it can generate to inspire educators.</p>
<p>The end-to-end flow starts on a "Suggest" screen, where an educator enters the materials they have on hand and, optionally, a theme for the activity. From there, the suggestion happens in two phases: retrieval, then generation.</p>
<p>First, the app does a vector search over the Wikipedia index and populates a grid of culturally-specific articles that match the educator's input. No model is involved, so the grid appears instantly.</p>
<p>Then, when you tap a card, you land on a detail screen where the fine-tuned model generates a full STEAM activity grounded in that single tradition: a title, description, materials, step-by-step instructions, learning objectives, and a safety note. Everything needed to guide the activity in the classroom.</p>
<pre><code class="language-typescript"> // Step 1 — RETRIEVAL: educator's materials -&gt; grid of cultural articles.
  // Pure vector search on the server, no model, so the grid appears instantly.
  export async function fetchInspiration(materials: string[], theme?: string) {
    const res = await fetch(`${BASE_URL}/suggest`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ materials, theme: theme ?? null }),
    });
    return res.json();   // { results: [...articles] }
  }

  // Step 2 — GENERATION: runs only when the educator taps ONE card.
  // The fine-tuned model generates a full activity grounded in that article.
  export async function fetchActivity(
    articleId: number,
    materials: string[],
    ageRange: [number, number],
  ) {
    const res = await fetch(`${BASE_URL}/activity`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ article_id: articleId, materials, age_range: ageRange }),
    });
    return res.json();   // { activity: {...}, article: {...} }
  }
</code></pre>
<p>Splitting browsing from generation this way is both a cost and a quality choice: retrieval is essentially free, so the model runs just once on the tradition the educator actually commits to, rather than once for every card on the grid.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a172a9fbadcd8afcb11f314/b56489af-0450-48eb-b8f6-04c7a1a15781.png" alt="Screenshots showing steps to generate cultural STEAM activities using the app" style="display:block;margin:0 auto" width="5930" height="2532" loading="lazy">

<h2 id="heading-making-content-safe">Making Content Safe</h2>
<p>I wanted to talk about this topic explicitly at the end, even though many phases of the pipeline already involve steps to keep the model's content safe.</p>
<p>Even though the direct users of the app are educators, anything this feature produces can end up in front of kids. So we never want to surface or generate steps for intoxicants, drugs, tobacco, weapons, explosives, or poisons – basically any content that isn't age-appropriate.</p>
<p>This is something the model won't automatically handle on its own. The fine-tuned model was trained only on cultural-craft examples, so it has no built-in instinct to refuse an unsafe request, and the general knowledge of things like alcohol and weapons still lives in the base model's weights underneath.</p>
<p>As a builder, you have to put the necessary guards and checkpoints in place, and remind the model how to behave. We do this in two phases:</p>
<ul>
<li><p>Pre-filter the data to reduce risk at the source, the same way we dropped unrelated categories earlier. Screening the corpus (and the generated training pairs) means we never teach the model unsafe content in the first place. This matters especially if you ever plan to publish your model or dataset somewhere like Hugging Face, where it should already be filtered. This step removed ~850 unsafe articles from the ~19,000 scraped.</p>
</li>
<li><p>Keep runtime guardrails in the ZubHub app as the actual guarantee. Because data filtering reduces risk but can't erase what the base model already knows, the live app screens every input before retrieval and every generated output before display. This means that nothing built around unsafe terms is ever retrieved or shown.</p>
</li>
</ul>
<pre><code class="language-python"># safety.py — one shared list of what we never surface to kids...
  UNSAFE_TERMS = { 
      # ...
  }

  # ...matched whole-word, so "twine" != "wine" and "gunny sack" != "gun".
  def screen_text(text):
      """Return the first unsafe category found, or None if the text is clear."""
      for category, pattern in _PATTERNS.items():   # _PATTERNS built from UNSAFE_TERMS
          if pattern.search(text):
              return category
      return None

  # Phase 1, data: drop unsafe articles before they ever reach training.
  for article in corpus:
      if screen_text(article["title"] + article["summary"]):
          continue                      # never taught to the model

  # Phase 2, runtime: screen the educator's input AND the model's output.
  if screen_text(user_input):           # before retrieval
      return BLOCK_MESSAGE
  answer = model.generate(...)
  if screen_text(answer):               # before anything is shown
      return BLOCK_MESSAGE
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In a nutshell, this article walked through how you teach a small LLM to suggest creative, hands-on projects for an educational app.</p>
<p>We started from a pretrained model, Qwen2.5-1.5B-Instruct, and taught it on a dataset we built from Wikipedia's STEAM and cultural articles.</p>
<p>The goal was to get it to take a simple input (the materials an educator has, the children's age range, and an optional theme) and respond with a structured JSON activity: a title, description, step-by-step instructions, learning objectives, and a safety note.</p>
<p>Along the way, we worked through the technicalities of adapting a small LLM for a K12 use case end to end: building the dataset with the Wikipedia API, filtering out irrelevant categories and unsafe content, generating training pairs, fine-tuning the model with LoRA, evaluating its quality, building a retrieval index and adding RAG to make the suggestions more grounded and specific, and finally integrating the model into the app.</p>
<p>Most importantly, building it this way as a hands-on project is what made the core ideas of the ML/LLM space click for me, rather than staying abstract. I hope it does the same for you!</p>
<h2 id="heading-resources"><strong>Resources</strong></h2>
<ul>
<li>Check out the source code in this <a href="https://github.com/unstructuredstudio/zubhub-mobile/commit/296729c6bf981b0aa4ed6418f7c771a667170e77">specific PR</a>.</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Key Technical Design Decisions for Building an Educational App with LLMs  ]]>
                </title>
                <description>
                    <![CDATA[ Recently, I spent time prototyping an educational app using Claude Code. The project is an open-source mobile app for educators to share, discover, and facilitate low-cost creative learning activities ]]>
                </description>
                <link>https://www.freecodecamp.org/news/technical-design-decisions-educational-app-llms/</link>
                <guid isPermaLink="false">6a20a338e4cc400d0c4149bb</guid>
                
                    <category>
                        <![CDATA[ edtech ]]>
                    </category>
                
                    <category>
                        <![CDATA[ creativity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ learning design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ product development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai experiments ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Srishti Sethi ]]>
                </dc:creator>
                <pubDate>Wed, 03 Jun 2026 21:57:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/004bd995-d90e-4589-be82-e66e0be110f5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Recently, I spent time prototyping an educational app using <a href="https://www.anthropic.com/claude-code">Claude Code</a>. The project is an open-source mobile app for educators to share, discover, and facilitate low-cost creative learning activities.</p>
<p>One of the core features of the app is AI-assisted activity creation. Activity creation has always been a key aspect of the project, and in the earlier desktop version, this was handled through manual long-form activity submission forms.</p>
<p>Given the current AI landscape, it felt important to explore alternative ways to simplify and streamline activity creation using AI, while reducing the amount of manual form-filling required from users.</p>
<p>I started with a blank slate, letting Claude guide me on the technologies to use for the app. The app was eventually built with React Native (Expo) and Firebase, and builds on a web version that is currently in beta.</p>
<p>What stood out to me most during prototyping was the speed: the mobile app went from ideation and mockups to a working prototype in about a month, compared to nearly a year for the original web version.</p>
<p>I haven't been coding heavily in recent years, and most of my professional work today is centered around technical community management. But since I do have a technical background and prior experience working in software development, I found it surprisingly accessible to quickly build a functional app using Claude alongside its reference guides and documentation.</p>
<p>I do think that experience helped me reason through tradeoffs, evaluate architectural decisions, and critically analyze the generated code rather than relying on the LLM blindly.</p>
<p>In this article, I’ll share some of the technical design decisions I made along the way.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/20cd893a-b01e-4430-bfb0-61c9e57be914.png" alt="Example app" style="display:block;margin:0 auto" width="3590" height="2572" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-which-model-to-choose">Which Model to Choose</a></p>
</li>
<li><p><a href="#heading-choosing-for-geography-and-cost">Choosing For Geography and Cost</a></p>
</li>
<li><p><a href="#heading-choosing-the-programming-framework-and-backend-architecture">Choosing the Programming Framework and Backend Architecture</a></p>
</li>
<li><p><a href="#heading-machine-translation-and-multilingualism">Machine Translation and Multilingualism</a></p>
</li>
<li><p><a href="#heading-create-with-ai-with-humans-in-the-loop">“Create with AI” with Humans in the Loop</a></p>
</li>
<li><p><a href="#heading-optimizing-for-low-bandwidth">Optimizing for Low Bandwidth</a></p>
</li>
<li><p><a href="#heading-producing-a-demo-video">Producing a Demo Video</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>The key technical decisions I discuss here are reflections that come from my hands-on experimentation. They're intended for others working at the intersection of education and technology, especially developers, community practitioners, or technically curious people looking to prototype and build quickly using AI tools.</p>
<p>You'll need to have some basic familiarity with the React Native framework, how databases and Firebase work, as well as how to use Claude tools, command-line tools, and API integrations.</p>
<p>It also helps to be comfortable making decisions along the way around tradeoffs, such as choosing one infrastructure over another based on cost, geography, multilingual support, scalability, or ease of use.</p>
<h2 id="heading-which-model-to-choose">Which Model to Choose</h2>
<p>When it comes to choosing the model to build the app itself, it was a straightforward choice. I picked <a href="https://www.anthropic.com/news/claude-opus-4-7">Opus 4.7</a> for its advanced capabilities because I needed the model to help architect the app from scratch.</p>
<p>But when it came to choosing the model inside the app, the decision required more consideration.</p>
<p>Before diving into the reasons for picking a model, let’s first understand the context. Some of the features in the app include lesson plan creation and structuring with AI, machine translation of the content into 10 languages, a facilitation mode that guides educators through AI-generated tips for each activity step, educator profiles, and more.</p>
<p>If we break these features down, the model needs to support a few key capabilities: structured JSON generation that follows strict schemas, pedagogical reasoning for activity design, multilingual content generation, and the ability to infer constraints such as time, materials, and age-appropriateness. It also needs to reliably map user inputs into predefined activity categories while maintaining consistency in output structure.</p>
<p>The activity generation workflow is the key AI feature in the app. Since it's an asynchronous, one-shot generation feature, I picked Sonnet among the available Claude models because of the quality and non-generic educational content it was able to generate.</p>
<h2 id="heading-choosing-for-geography-and-cost">Choosing For Geography and Cost</h2>
<p>Latency and network reliability were also important considerations. The app is designed to support educators working in underserved contexts and slower network environments. Although Claude’s <a href="https://www.anthropic.com/claude/haiku">Haiku</a> model would have offered lower latency, it might not be as reliable on slower networks compared to other models.</p>
<p>At the same time, I plan to keep the app free and open source, and I'm not currently planning to market it aggressively. Using Opus for end-user generation would therefore have been expensive, even though it may have produced richer outputs.</p>
<p>For a structured generation task like this, Sonnet felt like the right balance between quality, cost, and response time. Longer generation times with Opus could have negatively impacted user experience.</p>
<p>When it comes to configuring <code>maxTokens</code> in the API setup, I also made decisions keeping cost and generation length in mind.</p>
<p>A typical activity generated for the platform should ideally not exceed roughly 1,500–2,000 words, which translates to around 2,500 output tokens and approximately 30–45 seconds of generation time.</p>
<p>Based on this, I kept the <code>maxTokens</code> value around that range to help control token costs while still allowing enough space for meaningful structured educational content generation.</p>
<pre><code class="language-typescript">/**
 * Claude AI configuration.
 *
 * NOTE: For production, move the API key to a server-side proxy to avoid
 * exposing it in the client bundle. The `baseUrl` can be swapped to your
 * own backend endpoint that forwards requests to Anthropic.
 */
export const aiConfig = {
  apiKey: process.env.EXPO_PUBLIC_CLAUDE_API_KEY ?? '',
  model: 'claude-sonnet-4-6',
  maxTokens: 2500,
  baseUrl: 'https://api.anthropic.com/v1/messages',
  anthropicVersion: '2023-06-01',
};
</code></pre>
<h2 id="heading-choosing-the-programming-framework-and-backend-architecture">Choosing the Programming Framework and Backend Architecture</h2>
<p>I wanted to build an app using a framework that could work seamlessly on both Android and iOS devices. React Native seemed like an obvious choice here, both because it directly fit this requirement and because of its simplicity, ease of use, and overall popularity in the ecosystem.</p>
<p>For the database and backend, I wanted to pick a system that felt credible from both a data privacy and security perspective.</p>
<p>I had a slightly unexpected moment during development while discussing architecture choices with Claude Code. It suggested commonly used developer platforms, such as <a href="https://supabase.com/">Supabase</a>. At first, this felt like a reasonable default choice.</p>
<p>But the key here was to not just go with what's commonly suggested, and instead do a quick but thorough check on how reliably these services are accessible in the target user regions. While looking deeper, I came across reports that <a href="https://techcrunch.com/2026/02/27/india-disrupts-access-to-popular-developer-platform-supabase-with-blocking-order/">Supabase access had been restricted in India</a>, likely related to cybersecurity concerns.</p>
<p>That immediately changed my decision. Even though Claude had initially scaffolded the backend setup assuming Supabase, I later switched the architecture to <a href="https://firebase.google.com/">Firebase</a> by creating a project directly in the Firebase Console.</p>
<p>That was one of those small but important reminders that it's not enough to accept AI suggestions at face value. It's useful to actively check for the latest context, especially when it comes to infrastructure and platform availability.</p>
<p>The Firebase setup itself looked fairly straightforward:</p>
<ul>
<li><p>Create a project at <a href="https://console.firebase.google.com/">Firebase Console</a></p>
</li>
<li><p>Enable Authentication (Email/Password)</p>
</li>
<li><p>Create a Firestore database</p>
</li>
<li><p>Enable Storage</p>
</li>
<li><p>Add a Web app and copy the config values</p>
</li>
</ul>
<p>Another pattern I noticed was that AI is very quick to suggest interesting or “modern” infrastructure choices along the way: for example, for video uploads or media handling.</p>
<p>But in practice, thoughtful tradeoff decisions matter much more. Especially at an early stage, when I'm still validating the app idea with a small group of educators, I don't actually need a full-scale video infrastructure. This keeps the system lightweight, reduces implementation complexity, and helps avoid overengineering before the product direction and user needs are fully validated.</p>
<p>The prompt I used reflected this thinking:</p>
<blockquote>
<p>I am in the early validation stage for this app, focusing on feedback from a small group of educators. Therefore, we do not require a scalable and robust video infrastructure yet. Let’s design for easier alternatives, such as users uploading their videos to YouTube and simply copying the URL into a field for embedding on the activity page.</p>
</blockquote>
<h2 id="heading-machine-translation-and-multilingualism">Machine Translation and Multilingualism</h2>
<p>Given that the primary audience of the earlier version of the app was multilingual, with the platform targeting users from different Indian language communities, it was really difficult as a small project to get translation coverage for content across all languages. But with AI, machine translation is possible at least for popular languages for which training datasets are available.</p>
<p>For the prototyping phase, I'm providing 5 of the world’s most popular languages and 5 popular Indian languages in the language selection. At least for these languages, the machine translation quality is pretty good and AI is reasonably reliable.</p>
<p>Without this, it would have been a cumbersome maintenance effort in the early stages of the app, both to keep translations updated and to recruit contributors to translate content manually.</p>
<p>There are two translation layers in the project: a static layer for interface messages kept in a <code>src/i18n</code> folder, and a dynamic layer for activity content.</p>
<p>For the dynamic part, AI generates translations for activity content using the Google Translate API. This is the same public web endpoint that the Google Translate web widget uses. It's free and no API key is needed.</p>
<p>But the API is unofficial and rate limits aren't guaranteed. For production use, we'll eventually switch to something more commercial and reliable such as the <a href="https://cloud.google.com/translate">Cloud Translation API</a>.</p>
<h2 id="heading-create-with-ai-with-humans-in-the-loop">“Create with AI” with Humans in the Loop</h2>
<p>The core idea behind the app is to help educators document and share their creative projects. So making documentation easier through AI while still keeping educators in the loop to maintain ownership over the final published content felt like an essential design choice.</p>
<p>Initially, I experimented with using Claude as a conversational chat partner for activity creation. The idea was that the AI would guide educators through a back-and-forth interaction and gradually build the activity plan through follow-up questions.</p>
<p>But during prototyping, I realized that this often introduced too much friction into the experience. It started to feel like users were being asked too many questions, and the final outputs frequently deviated from the intended structure or became inconsistent across activities.</p>
<p>To make the experience as quick and lightweight as possible, the app now primarily works from a single input. Users can briefly describe their activity idea in natural language and optionally upload media files, after which the AI generates a complete structured activity plan.</p>
<p>Instead of relying on open-ended conversational outputs, the app uses prompts with specific guidelines and schema requirements. The generated output is strictly valid JSON following a predefined structure (for example: 3-6 activity steps, 4-5 facilitation steps depending on complexity, automatic selection of a featured image based on visual relevance, and so on). This allows the generated content to be directly consumed by the app without requiring an additional parsing or mapping layer.</p>
<p>If a user uploads multiple photos, the AI also identifies which images belong to which activity steps. The experience works somewhat similarly to Facebook’s “<a href="https://about.fb.com/news/2026/03/facebook-marketplace-new-meta-ai-tools-make-selling-faster-and-easier/">Create your listing with Meta AI</a>” feature. Users can upload different types of media files, after which the AI generates titles, materials, objectives, activity steps, and facilitation tips.</p>
<p>Importantly, everything remains editable before publishing, so educators can review, refine, and personalize the final content before sharing it with the community.</p>
<pre><code class="language-json">Return ONLY valid JSON (no markdown, no backticks) matching this exact schema:
{
  "title": "string (catchy, max 60 chars)",
  "description": "string (2-3 sentences, educator-facing)",
  "duration_minutes": number,
  "min_age": number,
  "max_age": number or null,
  "category": one of "Art" | "Science" | "Coding" | "Circuits" | "Engineering" | "Storytelling" | "Drama" | "Film" | "Music" | "Nature",
  "materials": [{ "name": "string", "buy_hint": "string (where to find it, e.g. craft store, hardware store, recycled)" }],
  "objectives": ["string (learning objective)"],
  "steps": [{
    "number": 1,
    "title": "string",
    "description": "string (2-3 sentences, detailed instructions for the educator)",
    "duration_minutes": number,
    "tip": "string (practical facilitation tip for educators running this step for the first time)",
    "assignedPhotoIndex": number or null
  }],
  "featured_image_index": number or null,
  "tips": ["string (general facilitation tip)"]
}
</code></pre>
<h2 id="heading-optimizing-for-low-bandwidth">Optimizing for Low Bandwidth</h2>
<p>Keeping in mind that the app is intended for users on low-bandwidth networks during the initial development phase, I made sure to provide these constraints to Claude and ensure that the prototype included the bare minimum needed to support users on slower connections.</p>
<p>The app loads 10 activities at a time, and uses the Expo module <code>expo-image-manipulator</code> for lightweight image processing tasks such as resizing photos to 1200 px and re-encoding them as JPEGs before upload. As a result, a typical 3–5 MB image can be reduced to ~200 KB.</p>
<p>The AI calls are also kept text-only. While images are uploaded and stored in Firebase, they're never sent to the model itself, which helps keep requests lightweight and responsive even on slower internet connections.</p>
<h2 id="heading-producing-a-demo-video">Producing a Demo Video</h2>
<p>Finally, this was probably the most fun part of the process. Before my first demo meeting with an educator, I managed to generate a ~1 minute demo video out of a 4 minute screen recording. I used Claude to identify and cut the most relevant segments, and the <code>ffmpeg</code> command line tool to convert the final output into the appropriate format.</p>
<p>After trying out numerous AI video generation tools that would exhaust my tokens pretty quickly, I eventually found myself coming back to Claude for this workflow, and it ended up working surprisingly well. 🙂</p>
<h2 id="heading-summary"><strong>Summary</strong></h2>
<p>A little over a year ago, I had started implementing a similar version of this app, but never reached a functional prototype. The tooling was still evolving, and I often found myself stuck in loops of agentic errors, spending more time debugging the AI workflow itself than actually building the product. With the recent advancements in AI-assisted development tools, it has genuinely felt empowering to shape and prototype ideas much more quickly.</p>
<p>At the same time, one of the biggest lessons from this experience was that you can't blindly build applications using AI tools. You can't simply ask an agent to do all the work and make decisions while you go on a hike –&nbsp;though perhaps you can do the dishes between prompts.</p>
<p>Each step still needs careful evaluation. The reasoning, suggestions, and discussions generated by the agent need to be read, understood, and refined through follow-up prompts and human input.</p>
<p>A large part of the work involves making thoughtful decisions along the way: what model to choose and why, what tradeoffs matter most for your use case (cost, geography, latency, reasoning capability, multilingual support), what infrastructure choices make sense for hosting and scalability, what API integrations are appropriate, and what decisions should be optimized for the early stages of the app versus long-term growth.</p>
<p>Similarly, design decisions should be grounded in the actual needs and contexts of users rather than simply following what AI tools suggest by default.</p>
<p>That's ultimately what I have tried to document through this article: not just how the educational app was built, but also the reasoning and tradeoffs behind the technical and design decisions made throughout the process.</p>
<p>Hopefully, these reflections are useful to others experimenting with AI-assisted development, especially in educational or community-centered contexts.</p>
<p>And if you have ideas for evolving the app further, feel free to contribute or comment on GitHub :)</p>
<h3 id="heading-resources"><strong>Resources</strong></h3>
<ul>
<li><p><a href="https://github.com/unstructuredstudio/zubhub-mobile">Checkout the source code repository</a></p>
</li>
<li><p><a href="https://drive.google.com/file/d/1W0rr9O6dBw9_9yCcgufVdCf8nbydHAo1/view?usp=sharing">Watch demo of the final workflow</a></p>
</li>
</ul>
<div class="embed-wrapper"><iframe width="100%" height="500" title="Embedded content" loading="lazy">
</iframe></div> ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
