<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://www.bendangelo.me/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.bendangelo.me/" rel="alternate" type="text/html" /><updated>2026-10-07T23:10:05-04:00</updated><id>https://www.bendangelo.me/feed.xml</id><title type="html">Bendangelo Blog</title><subtitle>I build apps using Rust. Also am fluent in Mandarin Chinese.</subtitle><author><name>Ben</name></author><entry><title type="html">Avoiding SQLite Database Locks in Production</title><link href="https://www.bendangelo.me/2026/07/04/avoiding-sqlite-database-locks-in-production/" rel="alternate" type="text/html" title="Avoiding SQLite Database Locks in Production" /><published>2026-07-04T13:05:00-04:00</published><updated>2026-07-04T13:05:00-04:00</updated><id>https://www.bendangelo.me/2026/07/04/avoiding-sqlite-database-locks-in-production</id><content type="html" xml:base="https://www.bendangelo.me/2026/07/04/avoiding-sqlite-database-locks-in-production/"><![CDATA[<p>While working on <a href="https://wenmarpro.com">Wenmar Pro</a>, a POS for auto shops, I’ve encountered quite a few SQLite3::BusyException exceptions and it’s taken me months to figure out the cause. This happens because sqlite is single writer, when one process holds the lock, any other process must wait for it to be released, but often it’s not in time and thus throws this exception. The cause can be hard to track down, so I’ll share what I have learned so far.</p>

<p>It comes down to:</p>

<ul>
  <li>Better error tracking (I personally use AppSignal for it’s good Rails integration)</li>
  <li>SQLite configuration</li>
  <li>Background job recurring jobs</li>
  <li>Optimize SQLite batch jobs</li>
</ul>

<h2 id="this-is-a-rails-stack-using-solid-queue-and-sqlite">This is a Rails stack, using Solid Queue, and SQLite</h2>

<p>For some context, I started this app 3 or so months ago and I felt I had nothing to lose by trying a new db. I’ve heard many good things about SQLite and it’s advancements in performance, esp for Rails. So I decided to give it a shot. Otherwise I would use Postgresql. A big factor was not having to manage an other process and because this software has a low amount of users, I felt this could be a good fit. As well as, worst case a migration from sqlite to postgres is fairly simple. So I took a chance.</p>

<p>So far it’s been great but it’s become clear you need to spend a little more time optimizing for sqlite. It’s single writer locking needs to be taken into account, and a lot of AI agents do not. I should note, I am keeping my eyes on <a href="https://github.com/tursodatabase/turso">Turso</a>, it’s compatible with sqlite but offers multiple writers, which is awesome but it has no rails driver atm.</p>

<h2 id="my-sqlite-configuration">My SQLite configuration</h2>

<p>I’m using rails and I’ll just share my configuration. One important thing is, the <strong>busy_timeout</strong> and <strong>timeout</strong> values should be the same. If you’re getting a lot of random lock exceptions trying making them the same value.</p>

<p>Other notes:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">wal</code> should be on.</li>
  <li>the other custom values were suggested additions, cannot confirm if they help.</li>
</ul>

<p>For testing:</p>

<ul>
  <li>busy_timeout is higher to help with <code class="language-plaintext highlighter-rouge">rails test</code> parallel testing</li>
  <li>other values are for quicker db writing, which does help</li>
</ul>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">default_pragmas</span><span class="pi">:</span> <span class="nl">&amp;default_pragmas</span>
  <span class="na">journal_mode</span><span class="pi">:</span> <span class="s">wal</span>
  <span class="na">synchronous</span><span class="pi">:</span> <span class="s">normal</span>
  <span class="na">temp_store</span><span class="pi">:</span> <span class="s">memory</span>
  <span class="na">mmap_size</span><span class="pi">:</span> <span class="m">134217728</span>
  <span class="na">cache_size</span><span class="pi">:</span> <span class="s">-20000</span>
  <span class="na">busy_timeout</span><span class="pi">:</span> <span class="m">5000</span>
  <span class="na">wal_autocheckpoint</span><span class="pi">:</span> <span class="m">10000</span>

<span class="na">default</span><span class="pi">:</span> <span class="nl">&amp;default</span>
  <span class="na">adapter</span><span class="pi">:</span> <span class="s">sqlite3</span>
  <span class="na">max_connections</span><span class="pi">:</span> <span class="s">&lt;%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %&gt;</span>
  <span class="na">timeout</span><span class="pi">:</span> <span class="m">5000</span>
  <span class="na">pragmas</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default_pragmas</span>

<span class="na">development</span><span class="pi">:</span>
  <span class="na">primary</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">storage/development.sqlite3</span>
  <span class="na">queue</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">storage/development_queue.sqlite3</span>
    <span class="na">migrations_paths</span><span class="pi">:</span> <span class="s">db/queue_migrate</span>
  <span class="na">cable</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">storage/development_cable.sqlite3</span>
    <span class="na">migrations_paths</span><span class="pi">:</span> <span class="s">db/cable_migrate</span>

<span class="c1"># Warning: The database defined as "test" will be erased and</span>
<span class="c1"># re-generated from your development database when you run "rake".</span>
<span class="c1"># Do not set this db to the same as development or production.</span>
<span class="na">test</span><span class="pi">:</span>
  <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
  <span class="na">database</span><span class="pi">:</span> <span class="s">storage/test&lt;%= ENV['TEST_ENV_NUMBER'] || ENV['TEST_WORKER_ID'] || '' %&gt;.sqlite3</span>
  <span class="na">timeout</span><span class="pi">:</span> <span class="m">20000</span>
  <span class="na">pragmas</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default_pragmas</span>
    <span class="na">mmap_size</span><span class="pi">:</span> <span class="m">268435456</span>
    <span class="na">synchronous</span><span class="pi">:</span> <span class="s2">"</span><span class="s">OFF"</span>
    <span class="na">busy_timeout</span><span class="pi">:</span> <span class="m">20000</span>
    <span class="na">wal_autocheckpoint</span><span class="pi">:</span> <span class="m">0</span>

<span class="c1"># Store production database in the storage/ directory, which by default</span>
<span class="c1"># is mounted as a persistent Docker volume in config/deploy.yml.</span>
<span class="na">production</span><span class="pi">:</span>
  <span class="na">primary</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">storage/production.sqlite3</span>
  <span class="na">cache</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">storage/production_cache.sqlite3</span>
    <span class="na">migrations_paths</span><span class="pi">:</span> <span class="s">db/cache_migrate</span>
  <span class="na">queue</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">storage/production_queue.sqlite3</span>
    <span class="na">migrations_paths</span><span class="pi">:</span> <span class="s">db/queue_migrate</span>
  <span class="na">cable</span><span class="pi">:</span>
    <span class="na">&lt;&lt;</span><span class="pi">:</span> <span class="nv">*default</span>
    <span class="na">database</span><span class="pi">:</span> <span class="s">storage/production_cable.sqlite3</span>
    <span class="na">migrations_paths</span><span class="pi">:</span> <span class="s">db/cable_migrate</span>
</code></pre></div></div>

<h2 id="sqlite-misconfiguration-in-deployyml">SQLite misconfiguration in deploy.yml</h2>

<p>I’m using Kamal for deployments, which is very simple and I recommend it. But recently I found out one reason why db locks kept happening in my background jobs. I accidently added an env var for <code class="language-plaintext highlighter-rouge">SOLID_QUEUE_IN_PUMA</code>. This caused solid queue to run <em>twice</em>, once in my web container and once in my job container.</p>

<p>deploy.yml:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">env</span><span class="pi">:</span>
  <span class="na">secret</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">RAILS_MASTER_KEY</span>
    <span class="pi">-</span> <span class="s">LITESTREAM_ACCESS_KEY_ID</span>
    <span class="pi">-</span> <span class="s">LITESTREAM_SECRET_ACCESS_KEY</span>
  <span class="na">clear</span><span class="pi">:</span>
    <span class="na">RAILS_ENV</span><span class="pi">:</span> <span class="s">production</span>
    <span class="na">RAILS_LOG_LEVEL</span><span class="pi">:</span> <span class="s">info</span>
    <span class="na">SOLID_QUEUE_IN_PUMA</span><span class="pi">:</span> <span class="kc">false</span> <span class="c1"># this actually activates it</span>
</code></pre></div></div>

<p>After months I figured out what happened.</p>

<p>puma.rb:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Run the Solid Queue supervisor inside of Puma for single-server deployments.</span>
<span class="n">plugin</span> <span class="ss">:solid_queue</span> <span class="k">if</span> <span class="no">ENV</span><span class="p">[</span><span class="s2">"SOLID_QUEUE_IN_PUMA"</span><span class="p">]</span>
</code></pre></div></div>

<p>See the issue? Any value in <code class="language-plaintext highlighter-rouge">SOLID_QUEUE_IN_PUMA</code> would then activate this plugin. I can’t blame myself, this was done at initial project setup and I just never noticed.</p>

<h2 id="recurring-jobs">Recurring Jobs</h2>

<p>I’m using solid queue and it’s built in <code class="language-plaintext highlighter-rouge">recurring.yml</code>, I make sure all recurring jobs will never run at the same time. This is a great method for finding which job caused the lock. So if a new lock exception gets logged, the first thing I check is the time it was logged at. From there I can track down which job caused it. Otherwise I would have no idea and would be guessing. This app has over 30 jobs, so it’s no easy debugging feat.</p>

<h2 id="avoid-batching--find-each-in-jobs">Avoid Batching / Find Each in Jobs</h2>

<p>The next cause of locks was the way my AI agent wrote these background jobs. It would do a <code class="language-plaintext highlighter-rouge">.find_each</code> and then inside the look send emails etc. This would block other jobs / web from running because the find_each call holds the write lock until it’s released. So the fix is to pluck the ids of wanted records, and then run any long process.</p>

<p>Example:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># BAD — read cursor stays open while writing</span>
<span class="no">Vehicle</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">stale: </span><span class="kp">true</span><span class="p">).</span><span class="nf">find_each</span> <span class="p">{</span> <span class="o">|</span><span class="n">v</span><span class="o">|</span> <span class="n">v</span><span class="p">.</span><span class="nf">update!</span><span class="p">(</span><span class="ss">checked_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span><span class="p">)</span> <span class="p">}</span>

<span class="c1"># GOOD — cursor closes instantly, writes happen in short transactions</span>
<span class="no">Vehicle</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">stale: </span><span class="kp">true</span><span class="p">).</span><span class="nf">pluck</span><span class="p">(</span><span class="ss">:id</span><span class="p">).</span><span class="nf">each_slice</span><span class="p">(</span><span class="mi">50</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">batch_ids</span><span class="o">|</span>
  <span class="no">Vehicle</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">id: </span><span class="n">batch_ids</span><span class="p">).</span><span class="nf">each</span> <span class="p">{</span> <span class="o">|</span><span class="n">v</span><span class="o">|</span> <span class="n">v</span><span class="p">.</span><span class="nf">update!</span><span class="p">(</span><span class="ss">checked_at: </span><span class="no">Time</span><span class="p">.</span><span class="nf">current</span><span class="p">)</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="opencode-skill-to-fix-this">Opencode skill to fix this</h3>

<p>I wrote an opencode skill to make sure all future ai agents know to avoid this. So I wrote a skill (with the help of an llm). Feel free to use it.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>---
name: sqlite-lock-prevention
description: &gt;
  Use when writing loops, batch updates, or ActiveJob code that iterates over
  a collection and writes to the same table. Prevents SQLite3::BusyException
  from holding a read cursor open during writes.
---

# SQLite Lock Prevention

## 1. Don't write while a read cursor is open
SQLite can't safely write to a table while a read cursor (`find_each`, `each`)
is still open against it on the same connection.

**Fix:** pluck IDs to close the cursor immediately, then write in small,
isolated batches.

```ruby
# BAD — read cursor stays open while writing
Vehicle.where(stale: true).find_each { |v| v.update!(checked_at: Time.current) }

# GOOD — cursor closes instantly, writes happen in short transactions
Vehicle.where(stale: true).pluck(:id).each_slice(50) do |batch_ids|
  Vehicle.where(id: batch_ids).each { |v| v.update!(checked_at: Time.current) }
end
```

## 2. Batch writes in one transaction
Wrap multi-row writes in a single transaction instead of one commit per row —
shortens the total time the write lock is held.

```ruby
Model.transaction { records.each(&amp;:save!) }
```
</code></pre></div></div>

<h2 id="post-your-sqlite-tips">Post your SQLite tips</h2>

<p>Please share your tips for sqlite if you have any.</p>]]></content><author><name>Ben</name></author><category term="sqlite" /><category term="ruby" /><category term="rails" /><summary type="html"><![CDATA[While working on Wenmar Pro, a POS for auto shops, I’ve encountered quite a few SQLite3::BusyException exceptions and it’s taken me months to figure out the cause. This happens because sqlite is single writer, when one process holds the lock, any other process must wait for it to be released, but often it’s not in time and thus throws this exception. The cause can be hard to track down, so I’ll share what I have learned so far.]]></summary></entry><entry><title type="html">Ruby on Rails Died Today</title><link href="https://www.bendangelo.me/2026/07/04/ruby-on-rails-died-today/" rel="alternate" type="text/html" title="Ruby on Rails Died Today" /><published>2026-07-04T13:05:00-04:00</published><updated>2026-07-04T13:05:00-04:00</updated><id>https://www.bendangelo.me/2026/07/04/ruby-on-rails-died-today</id><content type="html" xml:base="https://www.bendangelo.me/2026/07/04/ruby-on-rails-died-today/"><![CDATA[<p>Technologies always come and go, this is to be expected if you are in the tech industry. It’s all about adapting the new and pushing the limits of what you can do. I first started doing php back in 2005, when working on Flash games for <a href="https://throwtheproject.com">Throw the Project</a>, and then at my first developer job I worked in Ruby on Rails and instantly fell in love with it. I’ve used it ever since.</p>

<p>I never thought I would move off of Rails, but since last year in November 2025, things have changed. Coding agents have gotten so powerful that I – and almost everyone – no longer code. The language it’s written in no longer matters. Many people have already seen this coming and already jumped ship. But for me, I thought as long as the large companies like Shopify, Basecamp, etc are on Rails, then I can piggyback off of their success. Their libraries, videos etc can support everyone. But the Rails World talk yesterday sent shock waves throughout the rails community.</p>

<p>DHH, the creator of Rails, rewrote Hey in Rust, and the frontend is now native. Shopify also moved away from React Native, and are now building native apps. They are also increasing their usage of Rust. In tech, you gotta go where the wind blows. Everyone is optimizing their stack for coding agents, and I already know compiled languages made the most sense but I didn’t see this coming so quickly.</p>

<p>According to Google Trends, Rust overtook Rails in 2021. From there Rails has only lost popularity and Rust has grown. So the trend is clear.</p>

<p>Being a developer is no longer a career, but it’s been downgraded to a hobby. Note: I’m talking about writing code, not developing products. We are now <em>makers</em>, we write in plain English – or speak it – and our AI agents write out code for us. The developer is gone. You might not believe it now, because it makes some dumb mistakes but think long term, right now is the dumbest AI will ever be.</p>

<p>And so, I’m leaving Ruby on Rails and moving to Rust. I’ve already done some Rust before like for my Tantivy.cr Crystal shard, or whichlang. But it still is a tough pill to swallow. The amount of hours I’ve spent optimizing my neovim config, learning the internals, reading about best practices. It all just doesn’t matter now, the machine can just do it, quicker and better.</p>

<p>Developers are the first casualties, next it’ll go down to any action done on the computer. If you’re a remote worker, what stops AI from just learning your workflow? Now the new role is one person as the product manager / prompter to build out entire systems.</p>

<p>To finish this, it reminds me of a famous article from wired. <a href="https://www.wired.com/2000/04/joy-2/">Why the Future Doesn’t Need Us</a>.</p>

<h2 id="my-ruby-projects-are-no-longer-maintained">My Ruby Projects Are No Longer Maintained</h2>

<p>I will not be maintaining any Ruby projects.</p>]]></content><author><name>Ben</name></author><category term="ruby" /><category term="rails" /><category term="rust" /><summary type="html"><![CDATA[Technologies always come and go, this is to be expected if you are in the tech industry. It’s all about adapting the new and pushing the limits of what you can do. I first started doing php back in 2005, when working on Flash games for Throw the Project, and then at my first developer job I worked in Ruby on Rails and instantly fell in love with it. I’ve used it ever since.]]></summary></entry><entry><title type="html">Minitest is better than RSpec for AI Agents</title><link href="https://www.bendangelo.me/2026/06/21/minitest-is-better-than-rspec-for-ai-agents/" rel="alternate" type="text/html" title="Minitest is better than RSpec for AI Agents" /><published>2026-06-21T14:20:00-04:00</published><updated>2026-06-21T14:20:00-04:00</updated><id>https://www.bendangelo.me/2026/06/21/minitest-is-better-than-rspec-for-ai-agents</id><content type="html" xml:base="https://www.bendangelo.me/2026/06/21/minitest-is-better-than-rspec-for-ai-agents/"><![CDATA[<p>I been using RSpec ever since I started Ruby on Rails development back in 2012. It was basically the default for writing tests in every Rails project, and it made sense because it was more human readable than the default test suite, minitest. But AI agents have changed everything, humans are no longer writing tests. Tests are being written faster, in parallel and – the biggest issue – being ran in parallel.</p>

<h2 id="rspec-has-bad-test-parallel-support">RSpec has bad test parallel support</h2>

<p>I often have 2-3 AI agents running at one time and they almost always finish their task by running the test suite. Because of the way <a href="https://github.com/grosser/parallel_tests">RSpec parallel</a> works, it often breaks when two agents try to run the test suite at the same time. It didn’t made it if the agent was trying to run the whole test suite or even a single file. The supported <code class="language-plaintext highlighter-rouge">rails test</code> doesn’t have this problem. It can be ran multiple times and never chocked up an ai agent. This became a real blocker for me, because now I had to baby sit ai agents to make sure they never ran tests at the same time.</p>

<h2 id="rspec-parallel-is-too-slow">RSpec parallel is too slow</h2>

<p>It just isn’t as fast as Minitest, maybe this has been known for a long time but because my test suite is over 4500 tests. I need all the speed I can get. This suite takes about 50 mins to complete. I have made plenty of optimizations but it’s just not a priority, if I can get more speed for free then why not. This same test suite on RSpec took well over an hour. An other good reason to move to minitest.</p>

<h2 id="rspecs-syntax-is-confusing-for-llms">RSpecs syntax is confusing for llms</h2>

<p>RSpec was designed for humans writing code, now it’s ai doing it. So it makes more sense for the test style to represent this.</p>

<h2 id="moving-to-minittest-was-totally-worth-it">Moving to Minittest was totally worth it</h2>

<p>After about a month on minitest, I have no regrets. It has first class support in Ruby on Rails, native parallel testing, and the tests are easier to read.</p>

<h3 id="include-mocha-for-mocking">Include mocha for mocking</h3>

<p>The only issue I encountered was lack of stubbing, I ended up including mocha for this.</p>

<p>This is my test group in bundle:</p>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">group</span> <span class="ss">:test</span> <span class="k">do</span>
  <span class="c1"># Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]</span>
  <span class="n">gem</span> <span class="s2">"capybara"</span>
  <span class="n">gem</span> <span class="s2">"minitest-reporters"</span>
  <span class="n">gem</span> <span class="s2">"cuprite"</span>
  <span class="n">gem</span> <span class="s2">"capybara-lockstep"</span>

  <span class="n">gem</span> <span class="s1">'vcr'</span>
  <span class="n">gem</span> <span class="s1">'webmock'</span>

  <span class="c1"># Code coverage with markdown output for LLM consumption</span>
  <span class="n">gem</span> <span class="s1">'simplecov'</span><span class="p">,</span> <span class="ss">require: </span><span class="kp">false</span>
  <span class="n">gem</span> <span class="s1">'simplecov-markdown'</span><span class="p">,</span> <span class="ss">require: </span><span class="kp">false</span>
  <span class="n">gem</span> <span class="s1">'mocha'</span><span class="p">,</span> <span class="s1">'~&gt; 2.7'</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="minittest-test-helper-file">MinitTest test helper file</h2>

<p>Here is my test helper, it should help someone get started:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Minitest</span><span class="o">::</span><span class="no">Reporters</span><span class="p">.</span><span class="nf">use!</span> <span class="p">[</span>
  <span class="no">SingleLineReporter</span><span class="p">.</span><span class="nf">new</span>
<span class="p">]</span>

<span class="no">FactoryBot</span><span class="p">.</span><span class="nf">definition_file_paths</span> <span class="o">=</span> <span class="p">[</span><span class="no">Rails</span><span class="p">.</span><span class="nf">root</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"test"</span><span class="p">,</span> <span class="s2">"factories"</span><span class="p">)]</span>

<span class="k">module</span> <span class="nn">ActiveSupport</span>
  <span class="k">class</span> <span class="nc">TestCase</span>
    <span class="kp">include</span> <span class="no">FixtureScoping</span>
    <span class="kp">include</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Testing</span><span class="o">::</span><span class="no">TimeHelpers</span>
    <span class="kp">include</span> <span class="no">FactoryBot</span><span class="o">::</span><span class="no">Syntax</span><span class="o">::</span><span class="no">Methods</span>
    <span class="kp">include</span> <span class="no">AuthenticationHelpers</span><span class="o">::</span><span class="no">Minitest</span>
    <span class="kp">include</span> <span class="no">MeilisearchHelpers</span>
    <span class="kp">include</span> <span class="no">TenantHelper</span>
    <span class="kp">include</span> <span class="no">BulkInsertHelper</span>

    <span class="n">parallelize</span><span class="p">(</span><span class="ss">workers: :number_of_processors</span><span class="p">)</span>
    <span class="nb">self</span><span class="p">.</span><span class="nf">test_order</span> <span class="o">=</span> <span class="ss">:random</span>

    <span class="n">setup</span> <span class="k">do</span>
      <span class="no">ActsAsTenant</span><span class="p">.</span><span class="nf">current_tenant</span> <span class="o">=</span> <span class="kp">nil</span>
      <span class="no">Current</span><span class="p">.</span><span class="nf">reset</span>
      <span class="o">::</span><span class="no">MeiliSearch</span><span class="o">::</span><span class="no">Rails</span><span class="p">.</span><span class="nf">deactivate!</span>
    <span class="k">end</span>

    <span class="n">teardown</span> <span class="k">do</span>
      <span class="no">ActsAsTenant</span><span class="p">.</span><span class="nf">current_tenant</span> <span class="o">=</span> <span class="kp">nil</span>
      <span class="no">Current</span><span class="p">.</span><span class="nf">reset</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>I created a custom reporter for llms, it just cuts out the noise and lets the llm only see important info.</p>

<h2 id="bonus-how-to-run-the-full-ci-for-llms">Bonus: How to run the full ci for llms</h2>

<p>Cause the test suite takes over 50mins to complete. In my AGENTS.md I wrote never run the full test suite, because this will timeout the opencode bash command. So instead I run this ci script and it will log everything to file, then when I’m ready I pipe it into a new opencode session and it starts making it’s fixes. Here is the script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># bin/ci-log</span>
<span class="c">#!/bin/bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="c"># Ensure we are in the repo root</span>
<span class="nv">SCRIPT_DIR</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">cd</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="k">${</span><span class="nv">BASH_SOURCE</span><span class="p">[0]</span><span class="k">}</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span> <span class="o">&amp;&amp;</span> <span class="nb">pwd</span><span class="si">)</span><span class="s2">"</span>
<span class="nb">cd</span> <span class="s2">"</span><span class="nv">$SCRIPT_DIR</span><span class="s2">/.."</span>

<span class="c"># Setup logging destination</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> tmp
<span class="nv">LOG_FILE</span><span class="o">=</span><span class="s2">"tmp/ci-output.log"</span>

<span class="nb">echo</span> <span class="s2">"🚀 Starting CI via bin/ci..."</span>

bin/setup <span class="nt">--skip-server</span>

<span class="c"># Run bin/ci, pipe stdout/stderr to terminal AND save to file</span>
<span class="nb">set</span> +e
bin/ci 2&gt;&amp;1 | <span class="nb">tee</span> <span class="s2">"</span><span class="nv">$LOG_FILE</span><span class="s2">"</span>
<span class="nv">CI_EXIT_CODE</span><span class="o">=</span><span class="k">${</span><span class="nv">PIPESTATUS</span><span class="p">[0]</span><span class="k">}</span>
<span class="nb">set</span> <span class="nt">-e</span>

<span class="nb">echo</span> <span class="s2">"----------------------------------------"</span>
<span class="nb">echo</span> <span class="s2">"Log saved to: </span><span class="nv">$LOG_FILE</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="s2">"----------------------------------------"</span>

<span class="c"># Trigger LLM Agent if the Ruby CI script failed</span>
<span class="k">if</span> <span class="o">[</span> <span class="nv">$CI_EXIT_CODE</span> <span class="nt">-ne</span> 0 <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"💡 Errors to fix."</span>
<span class="k">else
  </span><span class="nb">echo</span> <span class="s2">"✅ CI Passed perfectly."</span>
<span class="k">fi</span>
</code></pre></div></div>]]></content><author><name>Ben</name></author><category term="ruby" /><category term="testing" /><summary type="html"><![CDATA[I been using RSpec ever since I started Ruby on Rails development back in 2012. It was basically the default for writing tests in every Rails project, and it made sense because it was more human readable than the default test suite, minitest. But AI agents have changed everything, humans are no longer writing tests. Tests are being written faster, in parallel and – the biggest issue – being ran in parallel.]]></summary></entry><entry><title type="html">LLM astroturfing is killing Reddit</title><link href="https://www.bendangelo.me/2026/02/02/llm-astroturfing-is-killing-reddit/" rel="alternate" type="text/html" title="LLM astroturfing is killing Reddit" /><published>2026-02-02T11:07:00-05:00</published><updated>2026-02-02T11:07:00-05:00</updated><id>https://www.bendangelo.me/2026/02/02/llm-astroturfing-is-killing-reddit</id><content type="html" xml:base="https://www.bendangelo.me/2026/02/02/llm-astroturfing-is-killing-reddit/"><![CDATA[<p><img src="/images/posts/llm-astroturfing-is-killing-reddit/reddit-llm.webp" alt="Example of a llm astroturfing tool" />
<strong>Example of a llm astroturfing tool</strong></p>

<p>Do you want to know why reading Reddit feels so infuriating to read recently? So many posts always use this same lifeless tone, with bullet pointed lists and lack any sort of authenticity. They are so generic and I feel like I’m slowly mentally degenerating by just reading it day after day.</p>

<p>This is the result of Google, and other companies using Reddit comments as input into their AI systems.</p>

<p><strong>TLDR</strong>:</p>

<p>ChatGPT uses Google to search for answers. Reddit threads often appear as top search results from Google. Reddit threads and posts are being created by AI to mention their products / services. The hope is to get their product more mentions by other AIs via this method.</p>

<h2 id="ai-finds-reddit-threads">AI finds Reddit threads</h2>

<p>Basically:</p>

<ul>
  <li>These marketing companies created an AI to find which Reddit threads are going viral and/or predict which ones will</li>
  <li>Then they use an LLM to give a <em>long human-like reply</em></li>
  <li>Then near the end mention all the services / products they should use</li>
  <li>Linking to them isn’t even needed because the goal is to get them mentioned by ChatGPT and other AIs</li>
</ul>

<p>The ironic part of this scheme is that these comments are not even <em>meant</em> to be read by humans, they are information dense and optimized to be read by <em>AI</em>. Then in the next AI training or whatever this product mention will be picked up. It’s simply an overcomplicated way of automating advertising.</p>

<h2 id="its-like-a-snake-eating-its-own-tail">It’s like a snake eating it’s own tail</h2>

<p>AIs using Reddit to mention their product and then it gets sucked into the next training data set.</p>

<h2 id="ai-generates-fake-questions-in-threads">AI generates fake questions in threads</h2>

<p>I’ve even seen some AIs generating fake generic questions and then using an other account to answer it.</p>

<p><a href="https://www.reddit.com/r/canadasmallbusiness/comments/1qmapti/comment/o1oumzd/?context=1">Example LLM generated thread</a></p>

<p>For those who don’t want to click the link:</p>

<p><strong>r/canadasmallbusiness</strong></p>
<h3 id="anyone-else-struggling-to-keep-their-small-business-afloat-lately">Anyone else struggling to keep their small business afloat lately?</h3>
<blockquote>
  <p>Running a small business in Canada feels tougher than ever right now. Rising costs, low reach, and constantly changing platforms make it hard just to stay visible.
….</p>
</blockquote>

<p>Generic problem, and a generic explanation. Seriously does anyone actually talk like this? How can anyone give advice to such a open-ended question? But of course this is the perfect question for an AI to solve, they love this generic topic, it’s just full of juicy keywords.</p>

<h2 id="then-ai-answers-itself">Then AI answers itself</h2>

<p>The original AI generated response was removed. But it basically gave a long winded answer about having the same feeling, and then near the end mentioned various internet services it can use. And helpfully layed out which ones are good for what. Everything said in a way for the next LLM to slurp up.</p>

<h2 id="this-was-bound-to-happen">This was bound to happen</h2>

<p>Reddit astroturfing isn’t new but now the end result is people will not see the <em>source</em> of the answer on sites like Reddit, they’ll only get an answer from their AI. AI is very good at convincing people and most won’t bother to check the source for the claims. For example, Doctors are using LLMs to ask for which medicine to prescribe. Check this comment out:</p>

<blockquote>
  <p>I worked at a company that sold to doctors and the marketing team was spending most of their effort on this kind of thing. They said that nowadays when doctors want to know “what should I buy to solve X?” or “which is better A or B?” they ask ChatGPT and take its answer as factual. They said that they were very successful in generating blog articles for OpenAI to train on so that our product would be the preferred answer.</p>
</blockquote>

<p><a href="https://lemmy.ca/post/59718256">Source thread</a></p>

<p>People will assume because the <em>AI</em> crunched the numbers and thought really hard, its answer must be the most optimal, but in actuality, it’s just regurgitating what was said on Reddit and/or blog posts.</p>

<h2 id="i-want-to-talk-to-real-people">I want to talk to real people</h2>

<p>Reading Reddit is getting harder by the week. The remedy seems to be that the AI systems will implement a sort of “adblocking” system to stop this from happening. Only then will they stop using this method.</p>]]></content><author><name>Ben</name></author><category term="reddit" /><category term="llm" /><summary type="html"><![CDATA[Example of a llm astroturfing tool]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.bendangelo.me/images/posts/llm-astroturfing-is-killing-reddit/reddit-llm.webp" /><media:content medium="image" url="https://www.bendangelo.me/images/posts/llm-astroturfing-is-killing-reddit/reddit-llm.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Optimizing your site for LLMs</title><link href="https://www.bendangelo.me/2026/01/21/optimizing-your-site-for-llms/" rel="alternate" type="text/html" title="Optimizing your site for LLMs" /><published>2026-01-21T22:45:00-05:00</published><updated>2026-01-21T22:45:00-05:00</updated><id>https://www.bendangelo.me/2026/01/21/optimizing-your-site-for-llms</id><content type="html" xml:base="https://www.bendangelo.me/2026/01/21/optimizing-your-site-for-llms/"><![CDATA[<p><img src="/images/posts/optimizing-your-site-for-llms/llms.png" alt="llms.txt" />
<strong>My llms.txt</strong></p>

<p>LLM optimization is becoming more and more needed recently. If you want your site to be suggested more often by ChatGPT, Gemini etc then you’ll need to make some adjustments. I suspect in 2026, this will start to become even more important.</p>

<p><strong>Note:</strong> <em>None</em> of these are standard yet and may be removed in the future.</p>

<p>Let’s share what I have learned so far:</p>

<ul>
  <li>Create a <a href="/llms.txt">/llms.txt</a> file</li>
  <li>Create a “LLM-readable” structured page (often called <a href="/for-llms">/for-llms</a>)</li>
  <li>Add Schema.org data tags to parts of your html. For example <a href="/projects">/projects</a></li>
  <li>Being consistent with naming and phrases like your website name or like writing “Ruby on Rails” instead of “RoR”</li>
</ul>

<p>I’ve included links to help show you how I’ve done it, and you’re welcome to copy me.</p>

<h2 id="create-a-llmstxt-file">Create a llms.txt file</h2>

<p>From <a href="https://llmstxt.org/">llms-txt</a>:</p>

<blockquote>
  <p>A proposal to standardise on using an /llms.txt file to provide information to help LLMs use a website at inference time.</p>
</blockquote>

<p>This is basically a way for LLMs to quickly figure out what your site is about and see what useful links it has to offer. For my website part of the core pages section is auto-generated:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gu">## Core Pages</span>
<span class="p">-</span> <span class="p">[</span><span class="nv">About Ben (For LLMs)</span><span class="p">](</span><span class="sx">https://www.bendangelo.me/for-llms/</span><span class="p">)</span>
{% for page in site.header_pages %}
<span class="p">-</span> <span class="p">[</span><span class="nv">{{ page.title }}</span><span class="p">](</span><span class="sx">{{</span> site.url }}/{{ page.url }}/){% endfor %}

<span class="gu">## Recent Posts</span>
{% for post in site.posts limit:10 %}
<span class="p">-</span> <span class="p">[</span><span class="nv">{{ post.title }}</span><span class="p">](</span><span class="sx">{{</span> site.url }}{{ post.url }}): {{ post.excerpt | strip_html | truncate: 120 }}{% endfor %}

<span class="gu">## Optional</span>

<span class="gu">### Posts by Tag</span>
{% for tag in site.jekyll-archives.enabled %}
<span class="p">-</span> <span class="p">[</span><span class="nv">{{ tag | capitalize }} Posts</span><span class="p">](</span><span class="sx">{{</span> site.url }}/tag/{{ tag }}/){% endfor %}
<span class="p">
-</span> <span class="p">[</span><span class="nv">Throw the Project</span><span class="p">](</span><span class="sx">https://throwtheproject.com</span><span class="p">)</span>: A Flash-game studio active circa 2007.
<span class="p">-</span> <span class="p">[</span><span class="nv">Archive</span><span class="p">](</span><span class="sx">{{</span> site.url }}/page2/): Older blog entries.
</code></pre></div></div>

<h2 id="create-a-llm-readable-page">Create a LLM-readable page</h2>

<p>This is a new way of giving llms more information about your site. I’ve not entirely sure if it will become standard yet but you can view my page at <a href="/for-llms">/for-llms</a>.</p>

<p>This was made by me pasting in all my information into a LLM, Gemini, and asking it to write out a llm-friendly structured page. I figured a LLM would be able to write this page better than I could.</p>

<p>Short snippet:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gh"># Ben (Bendangelo)</span>

Ben, widely known online as <span class="gs">**bendangelo**</span>, is a software engineer, open-source contributor, and polyglot developer based in Canada. He specializes in <span class="gs">**Ruby on Rails**</span>, the <span class="gs">**Crystal programming language**</span>, <span class="gs">**AI integration**</span>, and <span class="gs">**Mandarin Chinese**</span>.

<span class="gs">**Official website:**</span> <span class="p">[</span><span class="nv">https://www.bendangelo.me</span><span class="p">](</span><span class="sx">https://www.bendangelo.me</span><span class="p">)</span>

<span class="gu">## What I Do</span>
</code></pre></div></div>

<h2 id="make-use-of-schema-org">Make use of Schema org</h2>

<p>Following <a href="https://schema.org/">the schema guide</a>, this is about adding more context to text within your html pages. For example, I added new tags to my projects page to give LLMs more context on the kind of projects I maintain and what I work on. I even asked the LLM how to improve the schema of my page and it suggeted to add tags, license, and other metadata.</p>

<p>Example of <code class="language-plaintext highlighter-rouge">projects.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">title</span><span class="pi">:</span> <span class="s">free_dictionary_api</span>
  <span class="na">description</span><span class="pi">:</span> <span class="s">Ruby client for the Free Dictionary API</span>
  <span class="na">url</span><span class="pi">:</span> <span class="s">https://codeberg.org/bendangelo/free_dictionary_api</span>
  <span class="na">language</span><span class="pi">:</span> <span class="s">Ruby</span>
  <span class="na">license</span><span class="pi">:</span> <span class="s">MIT</span>
  <span class="na">category</span><span class="pi">:</span> <span class="s">API Clients</span>
  <span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">ruby</span><span class="pi">,</span> <span class="nv">dictionary</span><span class="pi">,</span> <span class="nv">api</span><span class="pi">]</span>
</code></pre></div></div>

<p>Then on the <a href="/projects">projects</a> page, these tags are then outputted:</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{% if project.language %}
<span class="nt">&lt;meta</span> <span class="na">itemprop=</span><span class="s">"programmingLanguage"</span> <span class="na">content=</span><span class="s">"{{ project.language }}"</span><span class="nt">&gt;</span>
{% endif %}

{% if project.license %}
<span class="nt">&lt;link</span> <span class="na">itemprop=</span><span class="s">"license"</span> <span class="na">href=</span><span class="s">"https://opensource.org/licenses/{{ project.license }}"</span><span class="nt">&gt;</span>
{% endif %}

<span class="nt">&lt;p</span> <span class="na">itemprop=</span><span class="s">"description"</span> <span class="na">class=</span><span class="s">"text-base text-gray-700 dark:text-gray-300 mt-2"</span><span class="nt">&gt;</span>
    {{ project.description }}
<span class="nt">&lt;/p&gt;</span>
<span class="nt">&lt;meta</span> <span class="na">itemprop=</span><span class="s">"author"</span> <span class="na">content=</span><span class="s">"Ben"</span><span class="nt">&gt;</span>
</code></pre></div></div>

<h2 id="consistent-naming">Consistent naming</h2>

<p>This should be self-evident. Make sure you use consistent naming for yourself, your projects and anything about yourself. This will help the AI know you’re referring to the same thing.</p>]]></content><author><name>Ben</name></author><category term="llm" /><category term="advice" /><summary type="html"><![CDATA[My llms.txt]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.bendangelo.me/images/posts/optimizing-your-site-for-llms/llms.png" /><media:content medium="image" url="https://www.bendangelo.me/images/posts/optimizing-your-site-for-llms/llms.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Self-Host any Docker image using Kamal</title><link href="https://www.bendangelo.me/2026/01/12/self-host-any-docker-image-using-kamal/" rel="alternate" type="text/html" title="Self-Host any Docker image using Kamal" /><published>2026-01-12T12:17:00-05:00</published><updated>2026-01-12T12:17:00-05:00</updated><id>https://www.bendangelo.me/2026/01/12/self-host-any-docker-image-using-kamal</id><content type="html" xml:base="https://www.bendangelo.me/2026/01/12/self-host-any-docker-image-using-kamal/"><![CDATA[<p>I been using <a href="https://kamal-deploy.org/">Kamal</a> for the past year in my own Ruby on Rails projects. And it’s fantastic:</p>

<ul>
  <li>One yaml file for config</li>
  <li>Light-weight</li>
  <li>Fast deployments</li>
  <li>Command line tool</li>
  <li>It’s open source</li>
  <li>Can build / pull any docker image</li>
  <li>Automatic SSL certs</li>
  <li>Manages env vars</li>
  <li>Can deploy multiple docker services</li>
  <li><strong>New</strong> does not require a repository for custom images</li>
</ul>

<p>So naturally I wanted to also use this for my own self-hosting. But this tool was not developed for deploying premade docker images, so I had to make a work around. It basically involves creating a wrapper docker file that just pulls the original image. This isn’t ideal, but it works with no issues.</p>

<p>I haven’t seen this documented anywhere, so I decided to share this.</p>

<h2 id="setup">Setup</h2>

<p>I’ll show you how to install Vikunja on your own server. If you don’t know, this is an open-source todo app, and it’s fairly lightweight.</p>

<ul>
  <li>Install Ruby</li>
</ul>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gem <span class="nb">install </span>kamal
<span class="nb">mkdir </span>vikunja
<span class="nb">mkdir </span>vikunja/config
<span class="nb">touch </span>vikunja/config/deploy.yml
<span class="nb">touch </span>vikunja/Dockerfile
</code></pre></div></div>

<h3 id="creating-the-deployyml">Creating the deploy.yml</h3>

<p>The basic deploy.yml looks like this, replace it with your server:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">service</span><span class="pi">:</span> <span class="s">vikunja</span>
<span class="na">image</span><span class="pi">:</span> <span class="s">myname/vikunja</span>

<span class="na">servers</span><span class="pi">:</span>
  <span class="na">web</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">myip</span>

<span class="na">proxy</span><span class="pi">:</span>
  <span class="na">ssl</span><span class="pi">:</span> <span class="kc">true</span>
  <span class="na">host</span><span class="pi">:</span> <span class="s">myhost</span>
  <span class="na">app_port</span><span class="pi">:</span> <span class="m">3456</span>

<span class="na">registry</span><span class="pi">:</span>
  <span class="c1"># Points to your local Kamal registry tunnel</span>
  <span class="na">server</span><span class="pi">:</span> <span class="s">localhost:5555</span>

<span class="na">ssh</span><span class="pi">:</span>
  <span class="na">user</span><span class="pi">:</span> <span class="s">app</span>

<span class="na">builder</span><span class="pi">:</span>
  <span class="na">arch</span><span class="pi">:</span> <span class="s">arm64</span> <span class="c1"># change this to make the arch</span>
  <span class="na">context</span><span class="pi">:</span> <span class="s">.</span>

<span class="na">env</span><span class="pi">:</span>
  <span class="na">clear</span><span class="pi">:</span>
    <span class="na">VIKUNJA_SERVICE_PUBLICURL</span><span class="pi">:</span> <span class="s">https://myhost</span>
    <span class="na">VIKUNJA_SERVICE_ENABLEREGISTRATION</span><span class="pi">:</span> <span class="s2">"</span><span class="s">false"</span>
    <span class="na">VIKUNJA_DATABASE_PATH</span><span class="pi">:</span> <span class="s">/db/vikunja.db</span>
  <span class="na">secret</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="s">VIKUNJA_SERVICE_JWTSECRET</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s2">"</span><span class="s">/data/vikunja-files:/app/vikunja/files"</span>
  <span class="pi">-</span> <span class="s2">"</span><span class="s">/data/vikunja-db:/db"</span>
  <span class="pi">-</span> <span class="s2">"</span><span class="s">/data/vikunja-cache:/.cache"</span>
</code></pre></div></div>

<h3 id="creating-secrets">Creating Secrets</h3>

<p>If you don’t want to commit your secrets, like <code class="language-plaintext highlighter-rouge">VIKUNJA_SERVICE_JWTSECRET</code>, you can create a new directory:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">mkdir</span> .kamal
vim .kamal/secrets
</code></pre></div></div>

<p>And in this file paste in your secrets:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>VIKUNJA_SERVICE_JWTSECRET=mysecretcode12344
</code></pre></div></div>

<h3 id="creating-the-dockerfile">Creating the Dockerfile</h3>

<p>This file just wraps the original image.</p>

<div class="language-Dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Inherit directly from the official Vikunja image</span>
<span class="k">FROM</span><span class="s"> vikunja/vikunja:latest</span>

<span class="c"># No changes needed, but you can add custom labels if you want</span>
<span class="k">LABEL</span><span class="s"> description="Vikunja wrapped for Kamal deployment"</span>
</code></pre></div></div>

<h2 id="deploying">Deploying</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>vikunja
kamal setup
kamal repository setup
kamal deploy
</code></pre></div></div>

<h2 id="final-tree">Final Tree</h2>

<p>Your dir structure should look something like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>.
|____README.md
|____.gitignore
|____Gemfile
|____.kamal
| |____secrets
|____config
| |____deploy.yml
|____Gemfile.lock
|____Dockerfile
</code></pre></div></div>

<p>I used a Gemfile to lock the version of Kamal, but this isn’t necessary.</p>

<p>Good luck!</p>]]></content><author><name>Ben</name></author><category term="self-host" /><category term="kamal" /><summary type="html"><![CDATA[I been using Kamal for the past year in my own Ruby on Rails projects. And it’s fantastic:]]></summary></entry><entry><title type="html">On Writing the TOCFL B Band speaking and writing test</title><link href="https://www.bendangelo.me/2026/01/01/on-writing-the-tocfl-b-band-speaking-and-writing-test/" rel="alternate" type="text/html" title="On Writing the TOCFL B Band speaking and writing test" /><published>2026-01-01T14:52:00-05:00</published><updated>2026-01-01T14:52:00-05:00</updated><id>https://www.bendangelo.me/2026/01/01/on-writing-the-tocfl-b-band-speaking-and-writing-test</id><content type="html" xml:base="https://www.bendangelo.me/2026/01/01/on-writing-the-tocfl-b-band-speaking-and-writing-test/"><![CDATA[<p>Not much information is online about taking the <a href="https://tocfl.edu.tw/tocfl/">TOCFL B Band test</a>, so I wanted to share my experience and hopefully help others better prepare for it. One important thing, I only took the <a href="https://tocfl.edu.tw/tocfl/index.php/test/speaking/list/1">speaking</a> and <a href="https://tocfl.edu.tw/tocfl/index.php/test/writing/list/1">writing</a> tests, because the reading and listening tests had no seats left. At the time of this writing, the speaking and writing can only be done once a year in Taiwan, so I jumped on it as soon as I could.</p>

<h2 id="results-speaking-b1-and-writing-b2">Results: Speaking B1 and Writing B2</h2>

<p>So after waiting a month, the results are in. I only had a month of study before the test because it was a last minute decision. Although stressful, I believe it was worth the effort to have verification of my Chinese ability. I can now say I’m fluent in writing Chinese (B2 is the widely accepted level for being considered fluent), and almost fluent in speaking.</p>

<p><img src="/images/posts/on-writing-the-tocfl-b-band-speaking-and-writing-test/tocfl-speaking.jpg" alt="Speaking score" />
<strong>I should have gotten B2</strong></p>

<p><img src="/images/posts/on-writing-the-tocfl-b-band-speaking-and-writing-test/tocfl-speaking-stars.jpg" alt="Speaking stars" />
<strong>One more star and would have gotten B2</strong></p>

<p><img src="/images/posts/on-writing-the-tocfl-b-band-speaking-and-writing-test/tocfl-writing-results.jpg" alt="Writing score" />
<strong>Had to wait a month for this</strong></p>

<p><img src="/images/posts/on-writing-the-tocfl-b-band-speaking-and-writing-test/tocfl-writing-stars.jpg" alt="Writing stars" />
<strong>You get a review of your answer</strong></p>

<h2 id="been-learning-chinese-for-12-years">Been learning Chinese for 12 years</h2>

<p>So.. yeah.. I expected more stars because I speak and write Chinese everyday, while living in Taiwan. I did not expect to just barely get B2.</p>

<p>Some background, I’m a white Canadian living in Taiwan. It’s been a few years now, going back and fourth. Some people have said, I must have some Chinese ancestry because my Chinese is decent, but I do not and I don’t consider my Chinese to be <em>amazing</em>.</p>

<p>I started learning Chinese when I was 23. Because of the Chinese isn’t easy, in the past I wanted to give up many, many times, but after meeting other westerners at a very fluent level. This totally shifted my perspective from I’ll <em>always</em> have broken Chinese, to it <em>is</em> possible to be fluent. This made a massive impact on my language learning and this is why I’m writing this. Just remember make small daily improvements maybe 1% or 2% a day and it’ll snowball.</p>

<h3 id="quick-tips">Quick tips</h3>

<p><img src="/images/posts/on-writing-the-tocfl-b-band-speaking-and-writing-test/pleco-chinese.webp" alt="Pleco chinese definition" />
<strong>Chinese definitions are much more clearer</strong></p>

<ul>
  <li>For beginners <a href="https://duchinese.net/">Du Chinese</a>, reading and listening at the same time is the best method.</li>
  <li>Buy the Chinese dictionary for <a href="https://www.pleco.com/">Pleco</a>, it’s worth it.</li>
  <li>Read <a href="https://www.dcard.tw/f">Dcard</a> everyday to learn how locals talk.</li>
  <li>Download the <a href="https://www.cna.com.tw/">CNA</a> app, it’s Taiwanese news and it can read it out to you.</li>
  <li>Reread books you read in English and read it again in Chinese with audio. I reread <a href="https://www.books.com.tw/products/0010949113?loc=P_br_60nq68yhb_D_2aabdc_B_1">How to win friends and influence people</a>.</li>
</ul>

<h3 id="why-not-hsk">Why not HSK?</h3>

<p>I’m currently in Taiwan and it’s not offered. And from my research, TOCFL is a better test on fluency, it’s not possible to memorize answers or look for keywords. In terms of the speaking test, they don’t care how many Chinese idioms you use, or your vocabulary, it’s all about expressing yourself. So I felt it’s more accurate.</p>

<h2 id="i-did-this-for-my-own-personal-goal">I did this for my own personal goal</h2>

<p>This is a test for non-native Chinese speakers to test their language abilities. I finally wanted the ability to say I’m fluent in Chinese, because a lot of people claim it but that statement is hard to backup. So, at least I can say I can write fluent Chinese?</p>

<p>Also in the future, this could be good for my career and doing business. In my mind, it’s always good knowing a language. In fact, in the many years of learning Chinese, I have actually improved my communication skills in English and understand my culture a lot better. Mostly because when speaking your native language you usually don’t put much thought into the words you use but after learning Chinese I was focused to re-evaluate which words I use and what I’m actually trying to convey.</p>

<h2 id="bands-and-grading">Bands and grading</h2>

<p><img src="/images/posts/on-writing-the-tocfl-b-band-speaking-and-writing-test/tocfl.webp" alt="TOCFL" /></p>

<p>So TOCFL has three bands, A, B, C. A is beginner, less than a year. B is the mid-tier and it’s <em>recommended</em> be taken before the C band (because most people overestimate their abilities). C band is the highest. Within these bands you have two possible levels, 1 (lower), 2 (higher). So I took the B band, and there are only three possible results:</p>

<ul>
  <li>Fail</li>
  <li>B1</li>
  <li>B2 – this was my target</li>
</ul>

<p>About C, this is the highest level and from my experience, few people can achieve a C1 level in speaking and writing. And even fewer can attain the legendary C2 level, not because it’s impossible but because most people realize the amount of effort required, makes it just not worth it. A B2 in writing and speaking is well suitable for daily life, reading papers, articles etc. I personally know someone who has lived in Taiwan for 15 years, speaks perfect Chinese, literally knew every random Chinese idiom I could say – only one she didn’t know, but could infer the meaning –, and she only got a C1 in reading and C1 in listening. The juice isn’t worth the squeeze.</p>

<p>I’ve also seen a few people online that claim they got a C2 in reading, or listening or whatever, while studying outside the country. I have a very hard time believing that. Even TOCFL suggests living in a Mandarin speaking country will double your learning speed. So living in America and learning fluent Chinese in 3 years? I want to see your test score.</p>

<h2 id="reviewed-example-answers">Reviewed example answers</h2>

<p>In preparation for the test, first, I reviewed these answers to get an idea of what to expect.</p>

<ul>
  <li><a href="https://tocfl.edu.tw/tocfl/index.php/test/speaking/list/5">Speaking Answers</a></li>
  <li><a href="https://tocfl.edu.tw/tocfl/index.php/test/writing/list/6">Writing Answers</a></li>
</ul>

<h2 id="created-a-daily-practice-prompt">Created a daily practice prompt</h2>

<p>From looking at the test requirements, like the topics covered, example questions. I ended up using AI to help generate a prompt which I used in my daily studies. It’s not perfect, because I asked the AI to grade me based on the <a href="https://tocfl.edu.tw/tocfl/index.php/test/speaking/list/3">TOCFL grading chart</a> and it almost always gave me B2. At one point it even upgraded me to C1, but of course in reality I only got B1. So don’t let the AI fool you, whatever level you think you are, it’s not enough.</p>

<p>At the end I will share the prompts I used.</p>

<h3 id="writing-practice-method">Writing practice method</h3>

<p>I practiced writing the most, by writing one or two essays a day. Typically, I would ask AI to write out an example answer based on the grading rubric. From that I would memorize the essay by rewriting or rewording it. Trying to improve my style and expression ability.</p>

<p>After that I would set a time and write an essay from whatever the AI gave me.</p>

<p>After writing, I’ll ask the AI to correct and grade. From there I would then rewrite the essay to learn from my mistakes.</p>

<p>This whole process would take at least 2 or 3 hours a day. Sometimes if I just couldn’t express a certain topic well, I would practice it all day.</p>

<h4 id="example-answer">Example answer</h4>

<p>Here is one of my essays from one of my practice sessions. Yes I know Social Media is misspelt, I’m on Linux and the keyboard gives the alternative character.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>📝 題目：

「有人認為社群媒體讓人更接近，也有人認為它讓人更疏離。你同意哪一種看法？」
</code></pre></div></div>

<blockquote>
  <p>近年來，社羣媒體越來越普遍，不管在哪裏能讓人保持聯絡。雖然帶來一些好處，但我還認爲社羣媒體對大多數人造成負面影響。</p>
</blockquote>

<blockquote>
  <p>首先，社羣媒體確實讓人更接近，透過手機隨時隨地能與家庭朋友保持聯絡，但是網友之間的交流與面對面的不同。線上的人往往只會分享他們的最完美的一個面，忽略他們的沒那麼好的時間，這樣會讓他們的朋友們感到很疏離。因爲實際上的生活差很多，因此讓許多人沒那麼接近。</p>
</blockquote>

<blockquote>
  <p>其次，社羣媒體鼓勵人線上聯絡，輕視面對面交流。因爲這個社羣媒體的平臺公司目的是賺錢，他們的功能都鼓勵人留在線上，但是最好的交流方式是面對面。這個方式比較自然，也並沒有時差或誤會機會。大家都知道面對你的朋友是有許多好處，譬如看到別人的表情會幫你理解別人。</p>
</blockquote>

<blockquote>
  <p>總而言之，雖然社羣媒體帶來許多便利功能，但還導致讓人更疏離。最理想的方式是人多面對面之間交流，互相幫忙，才能做更好的平臺。</p>
</blockquote>

<p>Since I got B2 in writing, I’ll say my practice sessions worked well. No changes to be made here.</p>

<h3 id="speaking-practice-method">Speaking practice method</h3>

<p>I would wake up every morning, with my big mic and ask the AI to give me a new list of questions based on the tests rubric. Here is an example:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>太好了！你設定的學習計畫非常完整，我會完全依照**華語文口語測驗（華測口試）B2級（高階級）**的官方格式，幫你模擬每日測驗、評分與回饋。
那我們現在開始 Day 1 模擬測驗（B2級） 🎯

🗣️ 華語文口語測驗 B2 — Day 1 模擬題
Part 1：熱身題 Warm-Up（約30–45秒）

👉 題目：
「你平常喜歡怎麼安排週末的時間？你通常會做些什麼活動？」

Part 2：經驗描述 Experience Description（約1.5分鐘）

👉 題目：
「請描述一次你參加特別活動或比賽的經驗。那次活動是什麼？你當時的感受如何？你從中學到了什麼？」

Part 3：圖片描述 Picture Description（約1.5分鐘）

👉 圖片說明（想像場景）：
「圖片上有三位大學生坐在咖啡廳裡，他們一邊喝飲料，一邊用筆電討論專題報告。
請描述他們的情況，並談談你對‘團隊合作’的看法。」

Part 4：陳述意見 Opinion Statement（約2分鐘）

👉 題目：
「現在很多人覺得『網路讓人們之間的關係更疏遠』，也有人認為『網路讓人們更容易保持聯繫』。
你同意哪一種說法？請說明你的理由。」

📋 請依照口試形式，用自然的口語中文回答這四題。
你可以輸入成文字（口語化即可），或用語音錄音回答。

當你回答後，我會依照官方三大評分面向：

🧩 內容組織（Content &amp; Organization）

💬 表達能力（Expressive Ability）

🀄 語言運用（Language Use）

給出每題的等級評分（A1–C1）、具體優缺點分析、範例高分回答，
並提供今日總結＋明日練習建議＋30秒口語訓練題。

請開始你的 Day 1 模擬測驗：
🎙️ 請從 Part 1：熱身題 開始回答：「你平常喜歡怎麼安排週末的時間？」
</code></pre></div></div>

<p>I would then go through all the questions, with a timer and using the built in ChatGPT recording feature. But I wish it had a built-in timer.</p>

<p>Afterwards, it’ll correct me and give a score. The score wasn’t too helpful because it praised me too much. However, it’s feedback was always useful and lead me to the next steps.</p>

<p>After the correction I would repeat it a few times, and maybe again the next day.</p>

<p>I feel like my practice sessions kinda helped, but it would have been better to pick one topic and expand on that for longer periods of time. I have a hard time going deeper into a topic and really expressing my point of view well. I feel this is what led me to only getting a B1 score.</p>

<h3 id="example-answer-1">Example answer</h3>

<p>To give people an idea of my speaking level. Here is an example answer I gave to ChatGPT during one of my practice sessions. I said all of this within two minutes. Please note, some of the transcriptions are not correct but it’s about 99% accurate.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>🎯 今日題目（陳述意見題）

「有些人覺得早起對身體和生活比較好，
也有人覺得晚睡晚起比較符合現代人的生活方式。
你覺得早起還是晚起比較好？請說明你的看法並提供至少兩個理由。」
</code></pre></div></div>

<blockquote>
  <p>我覺得早起對身體和生活比較好。 首先,早起床的人是比較健康的習慣, 因為起床後你就可以去運動,還是吃早餐,然後上班。 這個習慣對身體比較健康。 其次,我覺得很早起床,如果你很晚起床,那可能會超過早餐營業的時間。 這樣比較難去你想吃的餐廳,因為你睡太久。 當然晚起床有一些優點,譬如說晚上比較安靜的環境,你可以專注你想要做的重要的事情。 但總的來說,我覺得早起是比較好的生活習慣,因為比較健康。 也可以早起床可以做你想要做的事情。 喝完水比較不好的習慣。</p>
</blockquote>

<p>Speaking fluently in this kind of setting is quite difficult. You basically have to juggle multiple issues at once:</p>

<ul>
  <li>Time cannot be wasted thinking, so you’ll need a backlog of useful phrases. For example: 我給你一個例子 or 不一定會</li>
  <li>Decide your two points before speaking, and then on the fly, expressing it.</li>
  <li>From each point, give examples from your personal experience.</li>
  <li>Trying to give a logical, thoughtful answer, while the timer is counting down.</li>
  <li>You cannot spend <strong>any</strong> time thinking about Chinese grammar, words or language.</li>
</ul>

<p>In the future, I would recommend discussing this kind of topic with a partner for at least 10 minutes. Digging into all areas you can. Yeah it’ll be boring, but your speaking ability needs to be so well rounded, you can focus all your energy on your answer.</p>

<h2 id="the-actual-test-experience">The actual test experience</h2>

<h3 id="speaking-test">Speaking test</h3>

<p>So on the day of, I was nervous. My thought process was to practice answering these types of questions everyday for a whole month for about an hour. This would create a habit and so my brain would just be able to spit out answers without thinking about it too much. This mostly worked, but my ultimate downfall was not speaking in greater lengths of time, and practicing answering hypothetical questions. You’ll see why.</p>

<p>I should note, you have a minute to prepare for the harder questions. And a pen and paper are provided for taking notes. I mostly wrote down the points I wanted to make.</p>

<h4 id="two-warm-up-questions">Two warm up questions</h4>

<p>So the test starts with two warm up questions, which don’t count for anything. It’s basic stuff, like introduce your family etc.</p>

<h4 id="why-did-you-learn-chinese">Why did you learn Chinese?</h4>

<p>This was a walk in the park. I answer this question everyday.</p>

<h4 id="ideal-job">Ideal job?</h4>

<p>I don’t totally remember this question, but I believe it was what kind of job would you like and why? Is money important etc? This is a common thing for me to talk about, so no issues.</p>

<h4 id="describe-the-picture">Describe the picture</h4>

<p>So for this part, a picture is shown and you need to describe the situation and if you’ve experienced it before. I got a picture of an old woman trying to give a package to a post office worker, but according to the sign behind her, she packaged it wrong.</p>

<p>This was one of the harder questions, because in real life, you almost never describe a situation from a picture. I kinda fumbled and forgot a lot of specific post office words.</p>

<h4 id="which-character-would-you-be">Which character would you be?</h4>

<p>This was the hardest question. I had to pick a character from a movie or a book. I chose Spiderman, because I knew how to say this characters name and mostly how to describe everything about him. But then I realized, I didn’t know how to say shoot spiderwebs or swing from buildings. This is a case of lack of experience, no one asks me these types of questions ever.</p>

<h4 id="would-you-rather-travel-young-or-old">Would you rather travel young or old?</h4>

<p>Now this is the argumentative section. One minute to prepare and you must pick a side. For this I chose I would rather travel when I’m younger. Because I have more energy, time, and it suits my travel style better. This sounds easy, but you have to talk for two minutes straight and can’t repeat yourself.</p>

<h4 id="there-is-an-argument-at-a-restaurant-how-to-resolve">There is an argument at a restaurant, how to resolve?</h4>

<p>This one was strange but I felt I explained myself well. Basically, there was an argument where one person was unhappy with their meal and the owner will not refund or offer any concessions. You had to share your thoughts and who you agreed with.</p>

<h3 id="writing-test">Writing test</h3>

<p>You have the option of writing this in pinyin or in zhujin. And this is not written on paper, it’s on the computer.</p>

<h4 id="question-1-write-a-letter">Question 1: Write a letter</h4>

<p>This was about your experience studying abroad. I used a Chinese idiom, but that didn’t help me in getting 4 stars.</p>

<h4 id="question-2-write-an-argumentative-essay">Question 2: Write an argumentative essay</h4>

<p>The question was, a construction company was to build a new shopping mall in a small village. Pick a side and explain if this is good for the community or bad. I ended up getting 4 stars for this. I wrote a pretty formal, strong essay and pulled out every trick I could. I started out explaining the pros and cons, and then ultimately saying this is good for the city because it’ll invite more investment and improve every ones quality of life.</p>

<h2 id="practice-chinese-via-charades">Practice Chinese via Charades</h2>

<p>So I created a free mobile app to help people learn languages with friends and in meet ups. If you can take a quick look and download it, that’ll help out out. Getting your app visible on the app store is not easy anymore. Thanks!</p>

<p><img src="/images/posts/on-writing-the-tocfl-b-band-speaking-and-writing-test/tour_guess.png" alt="Word head game" /></p>

<p>For now it’s only on <a href="https://apps.apple.com/app/id6753888185">ios</a> It’s called Word Head.</p>

<h3 id="writing-prompt">Writing prompt</h3>

<p>This is what I used for my daily writing exercise. I would set a timer and write out whatever example topic was given. <a href="https://tocfl.edu.tw/tocfl/index.php/test/writing/list/1">Source of requirements</a></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>📘 Role &amp; Goal:
You are my Chinese writing coach and examiner. I am preparing for the 華語文寫作測驗 and want to reach B2 (高階級) level. Please evaluate my writing strictly according to the official rubric.

🧭 Step 1 — Topic Selection:
Each day, provide one B2-level writing task (450–550 words). The task should be either:

書信寫作 (formal/semi-formal letter):

Include proper Chinese writing format: 收件人、寄件人、日期、稱呼、正文.

Convey emotions and highlight the personal significance of events.

Comment on messages or viewpoints of the recipient if appropriate.

Include all three required content points in the task.

Be clear, detailed, and logically structured.

Use proper paragraphing; each paragraph should have 3–6 sentences.

Use Chinese punctuation.

Strictly 450–550 words.

觀點論述 (argumentative essay on a social issue):

Express only one clear position (支持 or 反對) throughout.

Provide reasons and concrete examples supporting the viewpoint.

Examples must be relevant, persuasive, and logically connected.

Organize ideas clearly; use cohesive transitions.

Use proper Chinese punctuation.

Strictly 450–550 words.

Additional rules:

The topic must be realistic and culturally relevant, similar to the actual test.

Always specify whether it is 書信寫作 or 觀點論述.

Include a short scenario describing the context of the writing task.

Example (觀點論述):

有些父母讓念小學的孩子帶手機去學校，但他們也擔心這樣做可能對孩子有不好的影響。請你針對 “小學生帶手機去學校” 這個議題表達你的看法，內容必須明確表達你的支持或反對立場，並提出充分理由支持你的觀點。

📝 Step 2 — My Writing Submission:
I will write my essay (450–550 words) according to the topic you provide.

🧮 Step 3 — Evaluation:
After I submit my writing, please:

Evaluate using the official criteria:

情境任務的符合度與充實度 (task relevance and completeness)

結構完整性與句法正確度 (organization and grammar accuracy)

詞語適切度與豐富性 (vocabulary appropriateness and richness)

Give a score estimate (A1–C1) and indicate whether it meets B2 standard.

Provide a corrected version with improved grammar, vocabulary, cohesion, and structure.

Offer 2–3 specific improvement tips.

🎯 Step 4 — Daily Improvement Focus:
Provide a micro-goal for my next writing session, e.g.:

Practice using 因為…所以… / 不僅…而且…

Focus on cohesive paragraph linking

Enhance vocabulary richness

✅ Instructions for Daily Use:
When I start, say:
🧑‍🎓 “Let’s begin today’s B2-level writing practice. Please give me a writing task.”
</code></pre></div></div>

<h3 id="speaking-prompt">Speaking prompt</h3>

<p>Below is the prompt I used in my daily speaking exercise. I would paste this into ChatGPT and then use the microphone feature to record my voice and then submit it. I also timed myself as to not go over / under the minimum time. For this test you must keep talking until the time runs out. <a href="https://tocfl.edu.tw/tocfl/index.php/test/speaking/list/1">Source of requirements</a></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>🎯 Goal:
I’m preparing for the 華語文口語測驗 (Chinese Oral Proficiency Test) and aiming to achieve B2 (高階級) level within one month.
Please act as my official oral test coach and evaluator, using the official rubric for structure, scoring, and feedback.

🧭 Step 1 — Complete B2 Daily Test Simulation

Each day, please give me 4 questions, covering all core task types from the 進階高階級 (B2 Band) of the official test:

Part 1: 熱身題 (Warm-Up)
→ Ask 1 short personal question about daily life or interests to help me start speaking naturally.
Example: 「你平常喜歡怎麼安排週末的時間？」

Part 2: 經驗描述 (Experience Description)
→ Ask me to describe a personal experience, past event, or situation.
Example: 「請描述一次讓你印象深刻的旅行經驗。」

Part 3: 圖片描述 (Picture Description)
→ Describe a given image or imagined scene (you can describe it in text form).
Example: 「圖片上有幾位年輕人在公園裡撿垃圾，請描述他們的活動，並談談你對環保的看法。」

Part 4: 陳述意見 (Opinion Statement)
→ Give me a general or social topic. I must present a clear opinion, support it with at least 2 reasons, and use examples.
Example: 「有人認為學生不應該打工，也有人覺得打工有幫助。你怎麼看？」

🎙️ Step 2 — My Speaking Responses

I will respond in spoken Chinese (voice recording or written “spoken-style” text).
Each answer should last about:

Part 1: 30–45 seconds

Part 2: 1.5 minutes

Part 3: 1.5 minutes

Part 4: 2 minutes

🧮 Step 3 — Official Evaluation Criteria

After I respond, please evaluate each part based on the 華語文口語測驗 B2 標準:

1. 內容組織 (Content &amp; Organization)

Is the message clear, complete, and logically organized?

Did I provide examples or reasons?

2. 表達能力 (Expressive Ability)

Is my speaking fluent, natural, and easy to understand?

Did I connect ideas smoothly using cohesive phrases (例如：首先、另外、因此、總之)?

Did I express emotions or opinions appropriately?

3. 語言運用 (Language Use)

Are my sentences grammatically correct?

Did I use appropriate, rich, and accurate vocabulary?

Is my pronunciation/tone natural and accurate?

🧾 Step 4 — Feedback and Scoring

For each question, please:

Give me a score (A1–C1) and note if it meets B2 criteria.

Highlight specific strengths and weaknesses (for example: fluency, vocabulary, tone accuracy, idea development).

Provide an improved version or model response (natural, test-style Chinese).

Summarize overall feedback with an estimated overall band level (e.g., “Your current performance: B1+, approaching B2”).

💡 Step 5 — Targeted Improvement Plan

After the evaluation, please give me:

2–3 focused improvement tips for the next day (for example:
“Work on expressing comparisons using 比較…” or
“Practice explaining reasons clearly using 因為…所以…”).

A short daily speaking drill (30–60 seconds), such as:
“Describe your day using only complex connectors (雖然…但是…, 不但…而且…).”

🧩 Example Use

👩‍🎓 “Let’s begin today’s speaking practice. Please give me all 4 questions (熱身題、經驗描述、圖片描述、陳述意見) following the official B2 test format.”
</code></pre></div></div>]]></content><author><name>Ben</name></author><category term="chinese" /><category term="tocfl" /><category term="language" /><summary type="html"><![CDATA[A data-driven look at passing the TOCFL B1 Speaking and B2 Writing test in Taiwan with one month of prep.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.bendangelo.me/images/posts/on-writing-the-tocfl-b-band-speaking-and-writing-test/tocfl-writing-results.jpg" /><media:content medium="image" url="https://www.bendangelo.me/images/posts/on-writing-the-tocfl-b-band-speaking-and-writing-test/tocfl-writing-results.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Made A Language Learning Charades Game</title><link href="https://www.bendangelo.me/2025/10/27/made-a-language-learning-charades-game/" rel="alternate" type="text/html" title="Made A Language Learning Charades Game" /><published>2025-10-27T11:39:00-04:00</published><updated>2025-10-27T11:39:00-04:00</updated><id>https://www.bendangelo.me/2025/10/27/made-a-language-learning-charades-game</id><content type="html" xml:base="https://www.bendangelo.me/2025/10/27/made-a-language-learning-charades-game/"><![CDATA[<p><img src="/images/posts/made-a-language-learning-charades-game/tour_guess.png" alt="Word head game" /></p>

<p>Hi everyone. I’ve joined many in-person language exchanges and always found it hard to get the whole group engaged or get something out of it. Maybe someone is a beginner or they can’t really teach their own language well. The idea with this game is anyone from any level can practice both their target language and help others practice theirs.
It’s free, no ads and currently only on ios. The game is called word head.
It’s focused mostly for beginners and B1. The idea is, in a group, one person has to guess their target language word from a set list of words in a category. Everyone else has to give hints (in your target language or your native). For example, if they choose an easy difficulty and the category is Food, they have to guess from a list of 20 foods. Afterwards you can swap the language and the other players can practice from the English words.</p>

<p>Languages:</p>
<ul>
  <li>English</li>
  <li>Español</li>
  <li>Français</li>
  <li>Deutsch</li>
  <li>Italiano</li>
  <li>Português</li>
  <li>Arabic</li>
  <li>中文繁體</li>
  <li>中文简体</li>
  <li>日本語</li>
  <li>한국어</li>
</ul>

<p><img src="/images/posts/made-a-language-learning-charades-game/app.PNG" alt="Word head game" />
<strong>Can choose from many categories</strong></p>

<p>For now it’s only on ios: https://apps.apple.com/app/id6753888185 It’s called Word Head.</p>

<p>I’m also looking for feedback:</p>

<ul>
  <li>I’m considering removing the hard difficulty level and combining it with Easy / Medium + 10 harder words.</li>
  <li>I’m also considering adding a hint function to give a person a hint in their target language how to give hints for the word. For example, the word is “hot”, the hint to explain the word might be “not cold”</li>
</ul>]]></content><author><name>Ben</name></author><category term="language" /><category term="mobile" /><summary type="html"><![CDATA[]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.bendangelo.me/images/posts/made-a-language-learning-charades-game/tour_guess.png" /><media:content medium="image" url="https://www.bendangelo.me/images/posts/made-a-language-learning-charades-game/tour_guess.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Using whichlang.cr for language detection</title><link href="https://www.bendangelo.me/2025/04/13/using-whichlang-cr-for-language-detection/" rel="alternate" type="text/html" title="Using whichlang.cr for language detection" /><published>2025-04-13T22:16:00-04:00</published><updated>2025-04-13T22:16:00-04:00</updated><id>https://www.bendangelo.me/2025/04/13/using-whichlang-cr-for-language-detection</id><content type="html" xml:base="https://www.bendangelo.me/2025/04/13/using-whichlang-cr-for-language-detection/"><![CDATA[<p><code class="language-plaintext highlighter-rouge">whichlang.cr</code> provides Crystal bindings for Rust’s <a href="https://github.com/quickwit-oss/whichlang/"><code class="language-plaintext highlighter-rouge">whichlang</code></a> language detection library. It detects the language of a given text.</p>

<h2 id="supported-languages">Supported languages</h2>

<ul>
  <li>Arabic (Ara)</li>
  <li>Chinese (Cmn)</li>
  <li>German (Deu)</li>
  <li>English (Eng)</li>
  <li>French (Fra)</li>
  <li>Hindi (Hin)</li>
  <li>Italian (Ita)</li>
  <li>Japanese (Jpn)</li>
  <li>Korean (Kor)</li>
  <li>Dutch (Nld)</li>
  <li>Portuguese (Por)</li>
  <li>Russian (Rus)</li>
  <li>Spanish (Spa)</li>
  <li>Swedish (Swe)</li>
  <li>Turkish (Tur)</li>
  <li>Vietnamese (Vie)</li>
  <li>Unknown (for undetected text)</li>
</ul>

<h2 id="requirements">Requirements</h2>

<ul>
  <li>Linux or Mac</li>
  <li>Rust 1.56 or later</li>
</ul>

<h2 id="installation">Installation</h2>

<p>Add this to your <code class="language-plaintext highlighter-rouge">shard.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">dependencies</span><span class="pi">:</span>  
  <span class="na">whichlang</span><span class="pi">:</span>  
    <span class="na">github</span><span class="pi">:</span> <span class="s">bendangelo/whichlang.cr</span>  
</code></pre></div></div>

<p>Then run:</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>shards <span class="nb">install</span>  
</code></pre></div></div>

<h2 id="usage">Usage</h2>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">require</span> <span class="s2">"whichlang"</span>  

<span class="n">input</span> <span class="o">=</span> <span class="s2">"this is the best day ever"</span>  
<span class="no">Whichlang</span><span class="p">.</span><span class="nf">detect</span><span class="p">(</span><span class="n">input</span><span class="p">)</span> <span class="c1"># =&gt; Whichlang::Lang::Eng  </span>

<span class="c1"># Returns nil for empty or undetectable text  </span>
<span class="n">input</span> <span class="o">=</span> <span class="s2">" "</span>  
<span class="no">Whichlang</span><span class="p">.</span><span class="nf">detect?</span><span class="p">(</span><span class="n">input</span><span class="p">)</span> <span class="c1"># =&gt; nil  </span>
</code></pre></div></div>

<p>That’s it. You now have language detection in your Crystal app.</p>]]></content><author><name>Ben</name></author><category term="crystal" /><summary type="html"><![CDATA[whichlang.cr provides Crystal bindings for Rust’s whichlang language detection library. It detects the language of a given text.]]></summary></entry><entry><title type="html">Using marisa.cr for Efficient String Storage in Crystal</title><link href="https://www.bendangelo.me/2025/04/11/using-marisa-cr-for-efficient-string-storage-in-crystal/" rel="alternate" type="text/html" title="Using marisa.cr for Efficient String Storage in Crystal" /><published>2025-04-11T23:27:00-04:00</published><updated>2025-04-11T23:27:00-04:00</updated><id>https://www.bendangelo.me/2025/04/11/using-marisa-cr-for-efficient-string-storage-in-crystal</id><content type="html" xml:base="https://www.bendangelo.me/2025/04/11/using-marisa-cr-for-efficient-string-storage-in-crystal/"><![CDATA[<p>The <code class="language-plaintext highlighter-rouge">marisa.cr</code> <a href="https://codeberg.org/bendangelo/marisa.cr">Crystal shard</a> gives you access to the powerful <a href="https://github.com/s-yata/marisa-trie">Marisa Trie</a> data structure, perfect for storing and searching strings efficiently. Let’s look at how to use it.</p>

<p>First, install the shard by adding it to your <code class="language-plaintext highlighter-rouge">shard.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">dependencies</span><span class="pi">:</span>
  <span class="na">marisa</span><span class="pi">:</span>
    <span class="na">git</span><span class="pi">:</span> <span class="s">https://codeberg.org/bendangelo/marisa.cr.git</span>
</code></pre></div></div>

<h2 id="basic-usage">Basic Usage</h2>

<p>Create a trie and add some strings:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">require</span> <span class="s2">"marisa"</span>

<span class="n">trie</span> <span class="o">=</span> <span class="no">Marisa</span><span class="o">::</span><span class="no">Trie</span><span class="p">.</span><span class="nf">new</span>
<span class="n">trie</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="s2">"snow"</span><span class="p">)</span>
<span class="n">trie</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="s2">"snow cone"</span><span class="p">)</span>
<span class="n">trie</span> <span class="o">&lt;&lt;</span> <span class="s2">"ice cream"</span> <span class="c1"># same as add</span>
</code></pre></div></div>

<p>You can search for strings:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">trie</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="s2">"ice"</span><span class="p">)</span>
<span class="n">trie</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="s2">"ice"</span><span class="p">).</span><span class="nf">keys</span>
<span class="c1"># =&gt; ["ice", "ice cream"]</span>
</code></pre></div></div>

<p>Check if a string exists:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">trie</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="s2">"snow"</span><span class="p">)</span> <span class="c1"># =&gt; true</span>
</code></pre></div></div>

<h2 id="working-with-weights">Working with Weights</h2>

<p>Add strings with weights (useful for prioritization):</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">trie</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="s2">"ice"</span><span class="p">,</span> <span class="mf">1_f32</span><span class="p">)</span>
<span class="n">trie</span><span class="p">.</span><span class="nf">get_weight</span><span class="p">(</span><span class="s2">"ice"</span><span class="p">)</span> <span class="c1"># =&gt; 1.0e-45_f32</span>
</code></pre></div></div>

<h2 id="bulk-operations">Bulk Operations</h2>

<p>Add multiple strings at once:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">trie</span><span class="p">.</span><span class="nf">add_many</span><span class="p">([</span><span class="s2">"icicle"</span><span class="p">,</span> <span class="s2">"snowball"</span><span class="p">])</span>
</code></pre></div></div>

<p>Iterate through all keys:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">trie</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">key</span><span class="o">|</span>
  <span class="nb">puts</span> <span class="n">key</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="saving-and-loading">Saving and Loading</h2>

<p>Save your trie to disk:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">trie</span><span class="p">.</span><span class="nf">save</span><span class="p">(</span><span class="s2">"winter.trie"</span><span class="p">)</span>
</code></pre></div></div>

<p>Load it later:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">trie</span> <span class="o">=</span> <span class="no">Marisa</span><span class="o">::</span><span class="no">Trie</span><span class="p">.</span><span class="nf">new</span>
<span class="n">trie</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="s2">"winter.trie"</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="specialized-tries">Specialized Tries</h2>

<p>For binary data:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bytes_trie</span> <span class="o">=</span> <span class="no">Marisa</span><span class="o">::</span><span class="no">BytesTrie</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="s2">"one"</span> <span class="o">=&gt;</span> <span class="s2">"1"</span><span class="p">,</span> <span class="s2">"two"</span> <span class="o">=&gt;</span> <span class="s2">"2"</span><span class="p">)</span>
<span class="n">bytes_trie</span><span class="p">[</span><span class="s2">"one"</span><span class="p">]</span> <span class="c1"># =&gt; "1"</span>
</code></pre></div></div>

<p>For integer values:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">int_trie</span> <span class="o">=</span> <span class="no">Marisa</span><span class="o">::</span><span class="no">IntTrie</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="s2">"one"</span> <span class="o">=&gt;</span> <span class="mi">1</span><span class="p">,</span> <span class="s2">"two"</span> <span class="o">=&gt;</span> <span class="mi">2</span><span class="p">)</span>
<span class="n">int_trie</span><span class="p">[</span><span class="s2">"one"</span><span class="p">]</span> <span class="c1"># =&gt; 1</span>
<span class="n">int_trie</span><span class="p">.</span><span class="nf">sum</span><span class="p">(</span><span class="s2">"one"</span><span class="p">)</span> <span class="c1"># =&gt; 4 (sums all matching entries)</span>
</code></pre></div></div>

<h2 id="advanced-options">Advanced Options</h2>

<p>Customize your trie:</p>

<div class="language-crystal highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">trie</span> <span class="o">=</span> <span class="no">Marisa</span><span class="o">::</span><span class="no">Trie</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
  <span class="p">[</span><span class="s2">"test"</span><span class="p">],</span>
  <span class="p">[</span><span class="mf">1.0_f32</span><span class="p">],</span>
  <span class="ss">binary: </span><span class="kp">true</span><span class="p">,</span>
  <span class="ss">num_tries: </span><span class="mi">10</span><span class="p">,</span>
  <span class="ss">cache_size: :large</span><span class="p">,</span>
  <span class="ss">order: :weight</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The marisa.cr shard is a great choice when you need compact, efficient string storage with fast lookup capabilities. Give it a try for your next autocomplete or search feature!</p>]]></content><author><name>Ben</name></author><category term="crystal" /><summary type="html"><![CDATA[The marisa.cr Crystal shard gives you access to the powerful Marisa Trie data structure, perfect for storing and searching strings efficiently. Let’s look at how to use it.]]></summary></entry></feed>