<?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: Great Sage</title>
    <description>The latest articles on DEV Community by Great Sage (@greatsage_sh).</description>
    <link>https://dev.to/greatsage_sh</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%2F4025220%2F908c5121-b9c3-4918-9093-1f647f2ab1dd.jpg</url>
      <title>DEV Community: Great Sage</title>
      <link>https://dev.to/greatsage_sh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/greatsage_sh"/>
    <language>en</language>
    <item>
      <title>Self-hosting an OpenAI-compatible TTS API with Kokoro-82M (no GPU needed)</title>
      <dc:creator>Great Sage</dc:creator>
      <pubDate>Mon, 20 Jul 2026 00:03:47 +0000</pubDate>
      <link>https://dev.to/greatsage_sh/self-hosting-an-openai-compatible-tts-api-with-kokoro-82m-no-gpu-needed-9d3</link>
      <guid>https://dev.to/greatsage_sh/self-hosting-an-openai-compatible-tts-api-with-kokoro-82m-no-gpu-needed-9d3</guid>
      <description>&lt;p&gt;Most self-hosted TTS is a tradeoff: espeak/piper are fast and free but sound robotic, and anything that sounds actually good usually means a GPU box or an ElevenLabs bill. Kokoro-82M is the first open model I've used that dodges both problems — 82M parameters, runs fine on CPU, and the output is close enough to ElevenLabs-tier for narration and voiceover work that I stopped reaching for paid APIs.&lt;/p&gt;

&lt;p&gt;The part that made it actually usable for me is &lt;a href="https://github.com/remsky/Kokoro-FastAPI" rel="noopener noreferrer"&gt;Kokoro-FastAPI&lt;/a&gt; — a wrapper that exposes Kokoro behind an &lt;strong&gt;OpenAI-compatible &lt;code&gt;/v1/audio/speech&lt;/code&gt; endpoint&lt;/strong&gt;. If you already have code that talks to OpenAI's TTS API, you don't rewrite anything, you just swap the base URL:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://your-app.up.railway.app/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-api-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;speech&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kokoro&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;af_bella&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello world!&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream_to_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output.mp3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get out of the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenAI-compatible &lt;code&gt;POST /v1/audio/speech&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;multi-language: English, Japanese, Chinese&lt;/li&gt;
&lt;li&gt;output formats: MP3, WAV, Opus, FLAC, M4A, PCM&lt;/li&gt;
&lt;li&gt;streaming with configurable chunk sizes&lt;/li&gt;
&lt;li&gt;weighted voice combinations, e.g. &lt;code&gt;af_bella(2)+af_sky(1)&lt;/code&gt; for a 67/33 blend&lt;/li&gt;
&lt;li&gt;word-level timestamped captions via &lt;code&gt;POST /dev/captioned_speech&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a built-in web UI (&lt;code&gt;/web&lt;/code&gt;) and API docs (&lt;code&gt;/docs&lt;/code&gt;) so you can poke at it without writing a client&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Tradeoff worth knowing up front:&lt;/strong&gt; the image I run is the CPU build, so it's not going to touch a GPU pipeline on raw throughput or handle heavy concurrent load. For narration, scripts, voiceover, IVR prompts — anything not real-time-at-scale — it's been solid.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploying it
&lt;/h3&gt;

&lt;p&gt;I maintain a one-click Railway template for this — it's just the stock &lt;code&gt;ghcr.io/remsky/kokoro-fastapi-cpu&lt;/code&gt; image, nothing patched. Full disclosure: I get a kickback if you deploy through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy:&lt;/strong&gt; &lt;a href="https://railway.com/deploy/kokoro-tts-api?referralCode=Z1xivh&amp;amp;utm_medium=integration&amp;amp;utm_source=template&amp;amp;utm_campaign=generic" rel="noopener noreferrer"&gt;https://railway.com/deploy/kokoro-tts-api?referralCode=Z1xivh&amp;amp;utm_medium=integration&amp;amp;utm_source=template&amp;amp;utm_campaign=generic&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you'd rather not go through a referral link, the same image runs anywhere Docker does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8880:8880 ghcr.io/remsky/kokoro-fastapi-cpu:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upstream repo (all credit to the actual authors): &lt;a href="https://github.com/remsky/Kokoro-FastAPI" rel="noopener noreferrer"&gt;https://github.com/remsky/Kokoro-FastAPI&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions on memory sizing or voice quality in the comments — it's been running steady for me without needing much tuning.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Grading free-form Japanese answers with an LLM: Django + pydantic-ai</title>
      <dc:creator>Great Sage</dc:creator>
      <pubDate>Sun, 19 Jul 2026 01:03:49 +0000</pubDate>
      <link>https://dev.to/greatsage_sh/grading-free-form-japanese-answers-with-an-llm-django-pydantic-ai-5g5h</link>
      <guid>https://dev.to/greatsage_sh/grading-free-form-japanese-answers-with-an-llm-django-pydantic-ai-5g5h</guid>
      <description>&lt;p&gt;I'm studying for the JLPT (aiming for N2 this cycle), and the thing that kept bugging me about every prep app I tried is that they're all multiple choice. You tap A/B/C/D, you get a green checkmark, and you feel like you're learning. But multiple choice trains &lt;em&gt;recognition&lt;/em&gt;, not &lt;em&gt;recall&lt;/em&gt;. You can pattern-match your way to a passing-looking score without being able to actually produce a single sentence.&lt;/p&gt;

&lt;p&gt;So I started building &lt;a href="https://betterlingo.io" rel="noopener noreferrer"&gt;BetterLingo&lt;/a&gt; — a JLPT study app whose daily practice is free-form. No multiple choice. You write a real answer, and an LLM grades it. This post is about the part that was actually interesting to build: &lt;strong&gt;grading a free-form Japanese answer reliably enough that a learner trusts the score.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "just ask the model to grade it"
&lt;/h2&gt;

&lt;p&gt;The naive version is one prompt: "Here's the question, here's the student's answer, give it a score out of 100." It works in a demo and falls apart in real use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scores are unstable — same answer, different run, ±20 points.&lt;/li&gt;
&lt;li&gt;The model hallucinates a rubric that doesn't match the question type.&lt;/li&gt;
&lt;li&gt;You get back prose you have to regex, and sometimes it just... doesn't give a number.&lt;/li&gt;
&lt;li&gt;No structured breakdown, so the learner can't see &lt;em&gt;why&lt;/em&gt; they lost points.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a study tool that has to be near-zero cost per grade, run reliably, and give feedback a learner believes, none of that flies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Structured output with pydantic-ai
&lt;/h2&gt;

&lt;p&gt;The single biggest reliability win was forcing the model into a typed schema instead of free text. I use &lt;a href="https://ai.pydantic.dev/" rel="noopener noreferrer"&gt;pydantic-ai&lt;/a&gt;, so the grade &lt;em&gt;is&lt;/em&gt; a Pydantic model:&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GradeBreakdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;grammar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;vocabulary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;naturalness&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;meaning&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Grade&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Overall score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;breakdown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;GradeBreakdown&lt;/span&gt;
    &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Two or three sentences, specific&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;example_answer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A model answer in Japanese&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;grader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:qwen3-next-80b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# via an OpenAI-compatible endpoint
&lt;/span&gt;    &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Grade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;GRADER_SYSTEM_PROMPT&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;Because the output is a validated model, I never parse prose. If the model returns something off-schema, pydantic-ai catches it and retries automatically. The &lt;code&gt;ge&lt;/code&gt;/&lt;code&gt;le&lt;/code&gt; bounds mean a score is always a real 0–100 int, never &lt;code&gt;"about 85"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four question types, four rubrics
&lt;/h2&gt;

&lt;p&gt;"Free-form" isn't one task. The daily set mixes four types, and each needs a different grading emphasis:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Answer&lt;/strong&gt; — respond to a prompt in Japanese. Graded on meaning + naturalness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fill in the blank&lt;/strong&gt; — one right-ish token, graded strictly on grammar/collocation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translate EN→JP&lt;/strong&gt; — meaning fidelity first, then naturalness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translate JP→EN&lt;/strong&gt; — comprehension check; grammar of the English matters less.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trick that stabilized scores was &lt;strong&gt;not&lt;/strong&gt; having one universal rubric. Each type gets its own system prompt and its own weighting of the breakdown fields. A perfectly natural answer that misses the point of a translation task shouldn't score the same as one that nails the meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping grading cost near zero
&lt;/h2&gt;

&lt;p&gt;This runs on my own study sessions daily, and I want it to scale without a bill that scales with it. So the two LLM jobs are split by cost profile:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grading&lt;/strong&gt; (high volume, runs every practice item) uses Qwen3-Next-80B on NVIDIA's free inference tier. Structured output means even a smaller/cheaper model stays on-rails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tutor + hints&lt;/strong&gt; (lower volume, needs to be sharp) use Claude Sonnet via OpenRouter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tutor — I call her Rami — is the other half of the free-form bet. She sees your &lt;em&gt;actual&lt;/em&gt; attempt: the question, what you wrote, the score, the per-field breakdown, the feedback. So when you ask "why is my answer wrong," she's responding to your specific mistake, not reciting a generic grammar FAQ. That context handoff is only possible &lt;em&gt;because&lt;/em&gt; the grade is structured data, not a paragraph.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring stack (on purpose)
&lt;/h2&gt;

&lt;p&gt;The rest is deliberately unexciting so I spend my time on the learning loop, not plumbing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Django 6&lt;/strong&gt; + HTMX + Tailwind — no JS build step, server-rendered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres&lt;/strong&gt; for everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;granian&lt;/strong&gt; for ASGI.&lt;/li&gt;
&lt;li&gt;A traditional SM-2 &lt;strong&gt;SRS&lt;/strong&gt; review mode and kanji study with level gates, for when free-form recall is too heavy and you just want to drill.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I've learned so far
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured output is the reliability lever.&lt;/strong&gt; Switching from "parse the prose" to "the grade is a typed object" removed almost all the flakiness in one move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One rubric per task type&lt;/strong&gt; beats one clever universal prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cheap model with tight constraints&lt;/strong&gt; often beats an expensive model with a loose prompt, especially for high-volume grading.&lt;/li&gt;
&lt;li&gt;Passing structured feedback into the tutor makes the tutor feel like it's actually reading your work — because it is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's still rough — I'm the primary user, fixing things as I hit them in my own N2 prep. If you're building anything that grades or evaluates free-form user input with an LLM, I'm happy to compare notes on schema design and rubric splitting. And if you're prepping for the JLPT yourself, &lt;a href="https://betterlingo.io" rel="noopener noreferrer"&gt;betterlingo.io&lt;/a&gt; is the thing I'm building — I'd genuinely like the feedback.&lt;/p&gt;

</description>
      <category>japanese</category>
      <category>python</category>
      <category>django</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
