<?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: Devadath H K</title>
    <description>The latest articles on DEV Community by Devadath H K (@devadath_hk).</description>
    <link>https://dev.to/devadath_hk</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%2F4033110%2Ffe2297ee-08eb-4df0-81d2-50d8af124626.png</url>
      <title>DEV Community: Devadath H K</title>
      <link>https://dev.to/devadath_hk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devadath_hk"/>
    <language>en</language>
    <item>
      <title>We built one AI tutor for five completely different subjects — here's the routing layer that keeps it from falling over</title>
      <dc:creator>Devadath H K</dc:creator>
      <pubDate>Mon, 20 Jul 2026 20:30:00 +0000</pubDate>
      <link>https://dev.to/devadath_hk/we-built-one-ai-tutor-for-five-completely-different-subjects-heres-the-routing-layer-that-keeps-15f4</link>
      <guid>https://dev.to/devadath_hk/we-built-one-ai-tutor-for-five-completely-different-subjects-heres-the-routing-layer-that-keeps-15f4</guid>
      <description>&lt;p&gt;Most "AI tutor" apps are a chat window bolted onto a system prompt. We wanted something that actually holds state — knows what you've mastered, adapts difficulty, remembers you tomorrow — across five very different domains: a general subject tutor, exam prep for India's JEE/NEET/UPSC (each with its own official syllabus and negative-marking rules), a language-learning track that goes CEFR A0 through C1, an AI career coach with mock interviews, and a competitive "arena" mode.&lt;/p&gt;

&lt;p&gt;That range turned out to be the actual engineering problem. Not "can an LLM explain photosynthesis" — every model can do that — but: what happens when you have 60+ distinct AI-backed features, each with different cost/quality tradeoffs, and one of them starts failing at 2am?&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that actually mattered: a feature registry, not a prompt library
&lt;/h2&gt;

&lt;p&gt;Early on, model calls were scattered — each feature picked its own model, its own retry logic (often none), its own idea of what "thinking budget" meant. A cost audit found thinking tokens (billed at the output rate) were the single largest leak in the whole system. That's what forced the redesign.&lt;/p&gt;

&lt;p&gt;Now every AI-backed feature is one entry in a single registry file:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;\&lt;/code&gt;&lt;code&gt;js&lt;br&gt;
export const FEATURES = {&lt;br&gt;
  chat_message:     { sparks: 2, chain: LITE_FIRST, stream: true },&lt;br&gt;
  generate_quiz:     { sparks: 1, chain: LITE_FIRST, cacheTtlMs: 30 * 60_000 },&lt;br&gt;
  km_build_concepts: { sparks: 8, chain: FLASH_FIRST },&lt;br&gt;
  // ...60+ more, one line each&lt;br&gt;
};&lt;br&gt;
\&lt;/code&gt;&lt;code&gt;\&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Each entry declares its price, its model &lt;em&gt;chain&lt;/em&gt; (primary + fallbacks), and its generation config. The router walks the chain on failure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lite-primary features fall back &lt;strong&gt;up&lt;/strong&gt; to Flash at the same price — availability beats margin during a Lite incident.&lt;/li&gt;
&lt;li&gt;Flash-primary features fall back &lt;strong&gt;down&lt;/strong&gt; to Lite at the same price — a degraded answer beats an error page, and the value delta isn't worth the ledger churn.&lt;/li&gt;
&lt;li&gt;Anything that sends images/PDF/video gets &lt;strong&gt;no&lt;/strong&gt; fallback at all — nothing in the app has ever sent media to the smaller model, so its multimodal quality is unverified. Failing loud (with an automatic refund) beats silently returning garbage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If a cheaper step in the chain ends up answering, the router refunds the price difference automatically. Response caching is an &lt;em&gt;allowlist&lt;/em&gt;, not a heuristic — only deterministic-ish generators (quizzes, reviews, curricula) get a TTL; personalized chat is explicitly never cached. And the client-side paywall keeps its own copy of the prices — a test asserts the two stay byte-identical, so drift is a CI failure instead of a support ticket.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's the boring, unglamorous plumbing that every AI product eventually needs and almost none ship on day one — because day one is about the prompt, not the failure mode of the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that taught us the most: mastery stuck at 0%
&lt;/h2&gt;

&lt;p&gt;Here's the one that stung. We had a whole "mastery" system — spaced repetition, progress bars, the works — and users' mastery scores just... never moved. Not broken-looking, just flat.&lt;/p&gt;

&lt;p&gt;The cause: the &lt;em&gt;only&lt;/em&gt; thing that triggered a quiz (and therefore a mastery write) was the user typing a hidden keyword like "quiz" or "check" into chat. Practice mode and Review mode — the two surfaces actually designed for this — had no write-back path at all. The UI was doing real work and throwing the result away.&lt;/p&gt;

&lt;p&gt;The fix wasn't a clever algorithm, it was making the invisible contract visible: a shared &lt;code&gt;masteryRules\&lt;/code&gt; + &lt;code&gt;pathProgress\&lt;/code&gt; engine, and an actual &lt;strong&gt;"Check my understanding"&lt;/strong&gt; button instead of a magic word nobody was told about. The lesson generalizes past this one bug — if a core feedback loop depends on the user knowing an undocumented trigger, it doesn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being honest about failure instead of hiding it
&lt;/h2&gt;

&lt;p&gt;The other pattern worth mentioning: for a while, every AI call that errored was caught and swallowed — &lt;code&gt;console.error(e)\&lt;/code&gt; and render &lt;code&gt;null\&lt;/code&gt;. From the user's side, that's just a blank screen with no explanation, indistinguishable from the app being broken. We replaced every one of those call sites with a shared error-state component that actually tells the user what happened and gives them a retry path. Small change, but it's the difference between "this app is broken" and "this app hit a snag" in the user's head — and those get very different reactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;693 tests across 63 files, running on every change&lt;/li&gt;
&lt;li&gt;React 19 + Firebase/Firestore + Cloud Run backend, Gemini 2.5 Flash/Pro/Lite behind the router above&lt;/li&gt;
&lt;li&gt;Five learning modes sharing one credit system, one mastery engine, one sync layer across devices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's live at &lt;a href="https://vararuchi.com" rel="noopener noreferrer"&gt;vararuchi.com&lt;/a&gt; if you want to poke at &lt;/p&gt;

&lt;p&gt;I'd genuinely rather have three people tell me what's broken than a hundred silent signups, so — try to break something, and tell me what you find. &lt;/p&gt;

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