<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nikolai | ExamReady</title>
    <description>The latest articles on DEV Community by Nikolai | ExamReady (@examready).</description>
    <link>https://dev.to/examready</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4172777%2F244400a5-e06d-440e-9a3b-4e752ab19f75.png</url>
      <title>DEV Community: Nikolai | ExamReady</title>
      <link>https://dev.to/examready</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/examready"/>
    <language>en</language>
    <item>
      <title>Generating realistic exam listening tests with TTS: voices, pauses and timestamps</title>
      <dc:creator>Nikolai | ExamReady</dc:creator>
      <pubDate>Sat, 17 Oct 2026 20:59:51 +0000</pubDate>
      <link>https://dev.to/examready/generating-realistic-exam-listening-tests-with-tts-voices-pauses-and-timestamps-32ob</link>
      <guid>https://dev.to/examready/generating-realistic-exam-listening-tests-with-tts-voices-pauses-and-timestamps-32ob</guid>
      <description>&lt;p&gt;Listening is the part of a language exam where you can't slow down. The recording plays, you answer, and the next one starts. If you're building practice material for it, a plain audio file isn't enough: it has to behave like the real thing, including the instructions, the pauses to read the questions, and the second play.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://examready.at" rel="noopener noreferrer"&gt;ExamReady&lt;/a&gt;, a platform for preparing for German B1/B2 exams, and listening practice is one of the parts I had to figure out from scratch. This post is about how I generate the audio with text-to-speech, assemble it with ffmpeg, and get timestamps out of the build so the app can show transcripts and highlight text while the audio plays.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ExamReady is an independent project and is not endorsed by ÖSD, ÖIF or the Goethe-Institut.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;I needed listening practice that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;has several speakers who sound different from each other&lt;/li&gt;
&lt;li&gt;includes the exam framing: instructions, time to read the questions, and a second play&lt;/li&gt;
&lt;li&gt;lets the learner see a transcript afterwards, and ideally see which line is being spoken right now&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I didn't want to hand-edit audio in a DAW for every recording. Every change to a script would mean re-exporting by hand, and I'd lose track of what was where. I wanted the audio to be a build artifact, produced from a text script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: describe the recording as segments
&lt;/h2&gt;

&lt;p&gt;Each recording is a script made of segments. A segment is either speech (a voice and some text), a silence, or a chapter marker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Segment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speech&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;silence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chapter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recording&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Segment&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chapter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Instructions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speech&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;narrator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;You will hear a short conversation twice.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;silence&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c1"&gt;// example value&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chapter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Conversation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speech&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speakerA&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speech&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;speakerB&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important idea is that everything the learner hears is in this list, including the silences. Silence is not something I leave to chance or to the TTS engine. If the exam gives you time to read the questions, that time is an explicit segment in the script.&lt;/p&gt;

&lt;p&gt;Voices are just names mapped to a text-to-speech voice configuration, so a conversation between two people uses two different voices, and the narrator uses a third.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: generate speech per segment
&lt;/h2&gt;

&lt;p&gt;For speech I use ElevenLabs text-to-speech. Each speech segment becomes its own audio clip. Here is the shape of the build step, in pseudo-code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;clips&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;speech&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;clips&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;VOICES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;voice&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;silence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;clips&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;make_silence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;# chapters produce no audio, only a marker
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generating clip by clip, instead of one long request for the whole conversation, has a practical benefit: I control exactly where each voice starts and stops, and I can insert silence between any two lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: assemble with ffmpeg and record the timestamps
&lt;/h2&gt;

&lt;p&gt;Once I have the clips, ffmpeg concatenates them into a single file. The part that turned out to matter most is that the build also prints the start time of every segment.&lt;/p&gt;

&lt;p&gt;To do that, the build measures the duration of each clip and keeps a running total:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
&lt;span class="n"&gt;timeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clip&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clips&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;segment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;duration_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# measured from the real audio, not estimated
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key detail is "measured from the real audio, not estimated". If you guess durations from the text length, the timestamps drift and the highlighting looks wrong after the first few lines. Reading the actual duration of each generated clip keeps the timeline honest.&lt;/p&gt;

&lt;p&gt;Then the concatenation itself is a single ffmpeg call over a list of the clips in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-f&lt;/span&gt; concat &lt;span class="nt"&gt;-safe&lt;/span&gt; 0 &lt;span class="nt"&gt;-i&lt;/span&gt; clips.txt &lt;span class="nt"&gt;-c&lt;/span&gt; copy listening.mp3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using stream copy only works if all clips share the same format and parameters, so it's worth making the silence clips match the speech clips. If they don't, re-encoding is the safer fallback.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the timestamps are used for
&lt;/h2&gt;

&lt;p&gt;The printed start times become data attached to the recording. The app uses them in two ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Transcripts.&lt;/strong&gt; After the exam-style run, a learner can read the transcript, line by line, with each line linked to its position in the audio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Highlighting.&lt;/strong&gt; While the audio plays, the app compares the current playback time to the start times and highlights the matching text.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the timeline comes from the same script that produced the audio, the transcript and the audio can't drift apart. If I edit a line and rebuild, everything is regenerated together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Baking in the exam framing
&lt;/h2&gt;

&lt;p&gt;I put the exam framing into the file itself: the instructions, the reading pauses and the second play. That was a deliberate choice. A learner doing practice should experience the same rhythm as in the exam, not a clean recording with the pacing left to them.&lt;/p&gt;

&lt;p&gt;The tradeoff is that the file is less flexible. If I wanted a version with no pauses, I'd have to build a second file. For exam practice I think that's the right tradeoff, and since it's all generated from the script, a second build isn't much work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd watch out for
&lt;/h2&gt;

&lt;p&gt;A few cautions rather than hard-won certainties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTS voices are not real exam audio.&lt;/strong&gt; They're clear and consistent, which is good for practice, but real recordings can have different accents, speeds and background noise. I wouldn't claim generated audio is identical to what a learner will hear on exam day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check pronunciation.&lt;/strong&gt; Names, numbers, dates and times matter a lot in listening tasks, and TTS can read them in a way you didn't expect. It's worth listening to the output instead of trusting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the script as the source of truth.&lt;/strong&gt; Don't patch the audio by hand. If something is wrong, fix the script and rebuild, otherwise the timestamps stop matching.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Describe the recording as data: segments with a voice and text, silences and chapters.&lt;/li&gt;
&lt;li&gt;Make silences explicit. They are part of the exam.&lt;/li&gt;
&lt;li&gt;Measure clip durations from the generated audio, never from the text.&lt;/li&gt;
&lt;li&gt;Print the timeline from the build. Transcripts and highlighting come almost for free.&lt;/li&gt;
&lt;li&gt;Listen to the output. A pipeline that runs without errors can still read a number wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you've built something similar, for exams or for any audio that needs to stay in sync with text, I'd be curious how you handled the timeline.&lt;/p&gt;

&lt;p&gt;I'm building this at &lt;a href="https://examready.at" rel="noopener noreferrer"&gt;examready.at&lt;/a&gt;, feedback welcome.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>audio</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Don't let the LLM do the maths: grading writing with AI but scoring in code</title>
      <dc:creator>Nikolai | ExamReady</dc:creator>
      <pubDate>Sun, 11 Oct 2026 22:03:24 +0000</pubDate>
      <link>https://dev.to/examready/dont-let-the-llm-do-the-maths-grading-writing-with-ai-but-scoring-in-code-7j</link>
      <guid>https://dev.to/examready/dont-let-the-llm-do-the-maths-grading-writing-with-ai-but-scoring-in-code-7j</guid>
      <description>&lt;p&gt;If you ask a language model to grade a text and also to count the words, add up the points and apply the scoring rules, it will usually give you a confident answer. Sometimes the answer will be wrong, and you won't notice, because the output looks reasonable.&lt;/p&gt;

&lt;p&gt;I ran into this while building the writing and speaking practice in &lt;a href="https://examready.at" rel="noopener noreferrer"&gt;ExamReady&lt;/a&gt;, a platform for preparing for German B1/B2 exams. The setup I ended up with is simple to state: the LLM judges, and my code does everything that can be computed. This post explains where that line is and why I drew it there.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ExamReady is an independent project and is not endorsed by ÖSD, ÖIF or the Goethe-Institut.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;In a writing exam, a learner gets a task, writes a text, and is graded on several official criteria. There are also hard rules around the result. One example: if the text has less than 50% of the required number of words, it scores 0 points.&lt;/p&gt;

&lt;p&gt;That rule has nothing to do with language quality. It's arithmetic. A text can be well written and still score 0 because it's too short.&lt;/p&gt;

&lt;p&gt;The tempting approach is to put the whole grading guide into one prompt and let the model produce a final score. That's one API call and one result, which is attractive when you're a solo developer. But it mixes two very different jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What models are good at, and what they aren't
&lt;/h2&gt;

&lt;p&gt;A model is good at judging things that need language understanding. Does this text address the task? Is the register appropriate? Is the grammar mostly correct, and where does it go wrong? Those are the questions I want an LLM for.&lt;/p&gt;

&lt;p&gt;A model is unreliable at counting and at arithmetic. Counting the words of a text, deciding whether that is above or below half the required number, and summing criterion scores into a total are all things that a few lines of code do exactly, every time. Asking a model to do them gives you a result that is usually right, and occasionally wrong in a way that is hard to detect and impossible to reproduce.&lt;/p&gt;

&lt;p&gt;So I split the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The split
&lt;/h2&gt;

&lt;p&gt;The LLM (Gemini) gets the task and the learner's text, and returns grades per official criterion, plus written feedback. It does not return a total and it does not decide whether the text was long enough.&lt;/p&gt;

&lt;p&gt;The code then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;counts the words in the learner's text&lt;/li&gt;
&lt;li&gt;applies the official rules, for example 0 points if the text is under 50% of the required words&lt;/li&gt;
&lt;li&gt;combines the criterion grades into the final result&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the shape of the model output I ask for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CriterionGrade&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;criterion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// one of the official criteria&lt;/span&gt;
  &lt;span class="nl"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// within that criterion's allowed range&lt;/span&gt;
  &lt;span class="nl"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ModelGrading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;grades&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CriterionGrade&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;corrections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;original&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;suggestion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the part that belongs to the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;countWords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;finalScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WritingTask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ModelGrading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;countWords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// rule applied in code, not by the model&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredWords&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;too_short&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;grades&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two small details matter here. First, the word count is a function I can unit-test. I can feed it texts and check the result, which I can't do with a prompt. Second, the model's points are clamped to the allowed range for each criterion, because a model can return a value outside what the scale permits. I treat model output as input that needs validation, not as a verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying corrections against the learner's text
&lt;/h2&gt;

&lt;p&gt;The second place where I stopped trusting the model blindly is corrections. A useful feedback feature is showing the learner a specific mistake in their own sentence and a better version. The failure mode is that the model quotes something the learner never wrote, or slightly rewrites their sentence before "correcting" it.&lt;/p&gt;

&lt;p&gt;So every correction is verified against the learner's own text before it is shown. In essence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifiedCorrections&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ModelGrading&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;corrections&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;original&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the quoted passage isn't actually in the text, the correction is dropped. A missing correction is better than a wrong one pointing at a sentence the learner didn't write. The real check can be a bit more tolerant about whitespace, but the principle is the same: the code checks the model's claims against the source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not do everything in the prompt?
&lt;/h2&gt;

&lt;p&gt;I can see the argument for it. Modern models are good, and often they'll count correctly. But "often" isn't the standard I want for something that decides whether a learner thinks they would pass. A few reasons I prefer the split:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reproducibility.&lt;/strong&gt; The same text always gets the same word count and the same rule application. Only the judgement part varies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testability.&lt;/strong&gt; Rules in code can have tests. Rules in prompts have vibes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debuggability.&lt;/strong&gt; When a score looks wrong, I can tell whether the rule or the model's judgement caused it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule changes.&lt;/strong&gt; If a rule changes, I change a function, not a prompt I then have to re-validate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost is more code and a more structured output from the model. I think that's cheap for what it buys.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on speaking
&lt;/h2&gt;

&lt;p&gt;Speaking practice uses a live AI conversation partner built on Gemini Live. It's a different shape of problem, since it's a conversation rather than a document, and it costs me roughly ten cents per speaking task. I'm still learning what the right boundary is there, so I'll keep this post about writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Let the LLM judge language, and let code count, compare and add up.&lt;/li&gt;
&lt;li&gt;Treat model output as untrusted input: validate ranges and shapes.&lt;/li&gt;
&lt;li&gt;Apply official rules in code so they can be tested and reproduced.&lt;/li&gt;
&lt;li&gt;Verify any quote or correction against the learner's own text before showing it.&lt;/li&gt;
&lt;li&gt;Prefer a missing piece of feedback over a wrong one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is clever. It's mostly about not asking a language model to do what a function does better.&lt;/p&gt;

&lt;p&gt;I'm building this at &lt;a href="https://examready.at" rel="noopener noreferrer"&gt;examready.at&lt;/a&gt;, feedback welcome.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built an exam prep platform for German B1/B2 exams as a solo founder: what I learned</title>
      <dc:creator>Nikolai | ExamReady</dc:creator>
      <pubDate>Fri, 09 Oct 2026 07:44:42 +0000</pubDate>
      <link>https://dev.to/examready/i-built-an-exam-prep-platform-for-german-b1b2-exams-as-a-solo-founder-what-i-learned-n71</link>
      <guid>https://dev.to/examready/i-built-an-exam-prep-platform-for-german-b1b2-exams-as-a-solo-founder-what-i-learned-n71</guid>
      <description>&lt;p&gt;I'm a solo developer, and for a while now I've been building &lt;a href="https://examready.at" rel="noopener noreferrer"&gt;ExamReady&lt;/a&gt;, a platform for preparing for official German exams. Most of the people it's meant for are immigrants who need to pass a B1 or B2 exam.&lt;/p&gt;

&lt;p&gt;This is a build-in-public summary: what the product is, how it's put together, and the things I got wrong along the way. It's not a success story. It's a list of decisions, and a few of them I'd now make differently.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;ExamReady is an independent project. It is not official and is not endorsed by ÖSD, ÖIF or the Goethe-Institut.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem I wanted to solve
&lt;/h2&gt;

&lt;p&gt;The exams I cover are the ÖIF Integration Exam B1, ÖSD B1 (ZDÖ), ÖSD B2 and the Goethe-Zertifikat B1. Each has its own format, task types and scoring rules. Preparing means learning German and learning the exam format at the same time.&lt;/p&gt;

&lt;p&gt;My idea was simple: keep the exam material in German, because that's what the real exam looks like, but explain everything around it in the learner's own language. The explanation of a task type should not itself be a reading test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What exists today
&lt;/h2&gt;

&lt;p&gt;There are 4 courses with 104 short interactive lessons, 20 full model tests, and explanations in 7 languages: German, English, Russian, Ukrainian, Turkish, Serbian and Romanian. It's free to start (2 keys a day), and Pro is a one-time payment rather than a subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;I picked it to be boring on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js frontend&lt;/li&gt;
&lt;li&gt;NestJS backend&lt;/li&gt;
&lt;li&gt;MongoDB&lt;/li&gt;
&lt;li&gt;Docker Compose behind Caddy on one small VPS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm the only person who deploys and debugs it, so I optimised for being able to hold the whole system in my head. One server, one compose file, one reverse proxy. I'm not claiming this setup is the right one for everyone, only that it fits one person maintaining a product.&lt;/p&gt;

&lt;h2&gt;
  
  
  A content model that doesn't fight me
&lt;/h2&gt;

&lt;p&gt;The first real design decision was how to store exam content. I ended up with a normalized model: Exam &amp;gt; model test &amp;gt; module &amp;gt; task. Every task has a type, and the answer key lives separately from the task.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Exam&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;tests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ModelTest&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ModelTest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;modules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Module&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TaskType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// stored separately from the task&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AnswerKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping the key apart has two benefits. The client never has to receive answers it doesn't need to display, and I can change or regenerate a key without touching the content.&lt;/p&gt;

&lt;p&gt;Scoring goes through a registry: one scorer per task type, looked up by the task's type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scorers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;TaskType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Score&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// multipleChoice: ..., trueFalse: ..., and so on&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AnswerKey&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Score&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;scorers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a new task type means adding a type and a scorer. That's the whole contract. It kept me from scattering ad hoc scoring logic across the codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons as decks
&lt;/h2&gt;

&lt;p&gt;Lessons are full-screen decks: one idea per slide, in the style of Brilliant. About 60% of a lesson is teaching, there are 3-4 questions, and a realistic exam task comes at the end. The one-idea-per-slide constraint forces me to cut. If an explanation doesn't fit on one slide, it's usually two ideas, or I haven't understood it well enough yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I got things wrong
&lt;/h2&gt;

&lt;p&gt;A few honest ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The answer keys were guessable.&lt;/strong&gt; I noticed that answer patterns repeated between tests, and that true/false keys alternated too regularly. A learner could guess the pattern without understanding anything. Now key sequences are generated with constraints (different from other tests, no strict alternation) and a script checks them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual testing wasn't enough.&lt;/strong&gt; Clicking through lessons yourself mostly covers the path you already know. I now run AI agents acting as personas through every lesson, plus an automated "impatient user" sweep. They found real bugs: a second example that never showed up, a writing draft lost when going back, and links losing the language prefix. I'll write about that setup in a separate post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Translations needed real review.&lt;/strong&gt; Machine-like translations had to be reviewed and rewritten. And when I removed em dashes from the text, some Russian and Ukrainian sentences broke, because those languages use the dash in place of the verb "to be".&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I use AI, and where I don't
&lt;/h2&gt;

&lt;p&gt;Writing and speaking practice use AI. For writing, the LLM (Gemini) gives grades per official criterion and written feedback. My code counts the words and applies the official rules, for example that writing under 50% of the required words scores 0 points. Models are unreliable at counting and arithmetic, so I don't ask them to do it. Corrections are also verified against the learner's own text before they're shown.&lt;/p&gt;

&lt;p&gt;For speaking, there is a live AI conversation partner built on Gemini Live. It costs me roughly ten cents per speaking task, which is a real number I have to keep in mind when I think about pricing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building with AI coding agents
&lt;/h2&gt;

&lt;p&gt;I built all of this as a solo developer with heavy use of AI coding agents (Claude Code). It let me cover a lot of surface area: content tooling, frontend, backend and scripts. It didn't remove the need to decide what to build, to read the diffs, or to test like a real user. The amount of code I can produce went up. The cost of judgement didn't go down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Keep the stack boring when you're the only maintainer.&lt;/li&gt;
&lt;li&gt;Model your content properly early. Separating answer keys from tasks paid off.&lt;/li&gt;
&lt;li&gt;Don't let the LLM do arithmetic or apply rules. Let it judge, let code count.&lt;/li&gt;
&lt;li&gt;Test as an impatient, confused user, not as the author.&lt;/li&gt;
&lt;li&gt;Treat translations as content that needs review, not as a switch you flip.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you've built exam prep or other structured-content products, I'd like to hear what you'd do differently.&lt;/p&gt;

&lt;p&gt;I'm building this at &lt;a href="https://examready.at" rel="noopener noreferrer"&gt;examready.at&lt;/a&gt;, feedback welcome.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>startup</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
