<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[TapTax Engineering]]></title><description><![CDATA[TapTax Engineering]]></description><link>https://taptax.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>TapTax Engineering</title><link>https://taptax.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 08 Sep 2026 06:37:37 GMT</lastBuildDate><atom:link href="https://taptax.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Receipt OCR plus an LLM categoriser that knows what it does not know]]></title><description><![CDATA[By Solomon Amos, Founder, TapTax
A miscategorised transaction is not a cosmetic bug. On a tax return it is a misfiled number that flows into a calculation a government agency will act on. So when I bu]]></description><link>https://taptax.hashnode.dev/receipt-ocr-plus-an-llm-categoriser-that-knows-what-it-does-not-know</link><guid isPermaLink="true">https://taptax.hashnode.dev/receipt-ocr-plus-an-llm-categoriser-that-knows-what-it-does-not-know</guid><category><![CDATA[AI]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[fintech]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Solomon Amos]]></dc:creator><pubDate>Fri, 03 Jul 2026 03:17:12 GMT</pubDate><content:encoded><![CDATA[<p><em>By Solomon Amos, Founder, TapTax</em></p>
<p>A miscategorised transaction is not a cosmetic bug. On a tax return it is a misfiled number that flows into a calculation a government agency will act on. So when I built the receipt and transaction pipeline for TapTax, a Making Tax Digital app for UK sole traders, the hard part was never getting a model to read a receipt or guess a category. Models do that well on a good day. The hard part was building a system that is honest about the bad days: one that can tell the difference between "I am sure this is travel" and "I have no idea, a human should look." This post is about that difference, and the small set of design decisions that make an OCR-plus-LLM pipeline safe enough to point at someone's tax.</p>
<p>The shape of the problem is specific. A sole trader uploads a photo of a receipt or a CSV export from their bank. We have to turn that into structured line items, assign each one an HMRC category (the exact field names HMRC expects on a quarterly submission, like <code>travelCosts</code>, <code>premisesRunningCosts</code>, or <code>costOfGoods</code>), and do it at a volume where asking the user to check every single row defeats the point of the product. The temptation is to let the model decide everything and ship it. For a Your-Money-or-Your-Life domain like tax, that is the one thing you must not do.</p>
<h2>Split reading from deciding: two stages, two models</h2>
<p>The first decision is structural. OCR and categorisation are different jobs with different failure modes, so they are different stages with different models.</p>
<p>Stage one is extraction. A receipt image goes to a vision model that returns structured fields: vendor name, total amount, currency, date, and line items. In the repo this is a Claude vision call that asks for JSON only:</p>
<pre><code class="language-ts">// receiptService.ts - extract structured fields from a receipt image
const response = await anthropic.messages.create({
  model: 'claude-haiku-4-5-20251001',
  max_tokens: 1024,
  messages: [{ role: 'user', content: [
    { type: 'image', source: { type: 'base64', media_type: mimeType, data: imageBuffer.toString('base64') } },
    { type: 'text', text: 'Extract the following from this receipt image as JSON: vendor_name, total_amount, currency, date, line_items (array). Return only valid JSON, no other text.' },
  ]}],
});
</code></pre>
<p>Stage two is categorisation. The extracted (or bank-fed) transaction goes to a separate text model whose only job is to map a description and amount onto the HMRC vocabulary. Keeping them apart matters because a misread total and a misjudged category need different recovery. A blurry photo is a data-capture problem; an ambiguous coffee-shop charge is a judgement problem. Conflating them hides which one actually went wrong.</p>
<h2>Make failure cheap, not loud</h2>
<p>The most important line in the OCR stage is not the prompt. It is what happens when the model returns something useless. The extractor never throws. If the vision call fails or returns non-JSON, it logs and hands back an empty result, and the receipt row is still saved so the user keeps their upload and can fill in the gaps:</p>
<pre><code class="language-ts">try {
  ocrData = JSON.parse(stripJsonFence(text));
} catch {
  logger.warn({ text }, 'non-JSON OCR response - using empty ocrData');
}
</code></pre>
<p>That <code>stripJsonFence</code> call is a small, real lesson: even when you say "return only valid JSON," these models routinely wrap the object in a markdown code fence. Parse the raw string and you fall into your error branch on perfectly good output. We hit this in both the OCR and the categorisation paths, and stripping the fence before parsing was the fix. The broader principle: degrade to a known-empty state, never to a confident-looking wrong one.</p>
<h2>Constrain the output, then score it</h2>
<p>The categoriser is where confidence enters. The prompt is deliberately closed-world: it is handed the exact list of valid HMRC categories for the transaction type and asked to return a category plus a confidence between 0.0 and 1.0.</p>
<pre><code class="language-ts">// categorisationService.ts
const prompt = `You are a UK tax categorisation assistant for HMRC Making Tax Digital.
Given a transaction, return the best HMRC MTD category as JSON.
Response format: {"category": "&lt;category_name&gt;", "confidence": &lt;0.0-1.0&gt;}
Valid categories for ${type}: ${categories.join(', ')}
Transaction: type=${type}, amount=£${amount}, description="${description}"`;
</code></pre>
<p>Two guards sit behind that. First, the returned category is validated against the allowlist; a category the model invented is rejected. Second, the confidence is clamped to <code>[0, 1]</code> so a stray <code>1.7</code> or negative cannot poison downstream logic. And critically, every failure mode collapses to the same safe value:</p>
<pre><code class="language-ts">if (!parsed.category || !validCategories.includes(parsed.category)) {
  return { category: 'other', confidence: 0 };   // invalid category
}
// ...and on any API error or JSON parse failure: { category: 'other', confidence: 0 }
</code></pre>
<p>Confidence <code>0</code> is not a magic number here. It is the system saying "I do not know," and as you will see, zero is exactly the value that guarantees a human gets involved. A self-reported confidence from a language model is not a calibrated probability, and you should not treat it as one. What it is good for is <em>ranking</em>: it lets you separate the rows the model is comfortable with from the rows it is hedging on, and route them differently. That ranking is enough to build the safety mechanism that matters.</p>
<h2>The auto-apply threshold and the review queue</h2>
<p>Here is the core pattern. Every categorised transaction is written with two distinct pieces of state: the <code>category</code> field that will actually be filed, and an <code>ai_category</code> plus <code>ai_confidence</code> record of what the model suggested and how sure it was. The filing field and the audit trail are kept separate on purpose, so a later human correction can overwrite what gets filed without erasing what the model originally thought.</p>
<p>Confidence then decides routing against a single threshold. Rows at or above it are treated as auto-applied; rows below it surface in a human review queue:</p>
<pre><code class="language-ts">// summary path - low-confidence rows are counted for the review queue
if (typeof r.ai_confidence === 'number' &amp;&amp; r.ai_confidence &lt; 0.6) {
  lowConfidenceCount += 1;
}
</code></pre>
<p>In TapTax the review threshold is <code>0.6</code>. Below it, the row is flagged for the user to confirm or correct; at or above it, the suggestion stands unless the user touches it. Every row keeps a <code>review_status</code> of <code>unreviewed</code> until a person acts, so "the model auto-applied it" and "a human signed it off" are never confused. The number itself is a product decision, not a law of nature: tune it against your own labelled corpus and your tolerance for false confidence. The architecture is the point. There is a line, below which the machine is required to ask.</p>
<h2>A cheap heuristic, and why it gets exactly 0.7</h2>
<p>Not every row deserves an LLM call. A keyword pre-pass catches the obvious cases (an Uber or a train fare is <code>travelCosts</code>, a hosting bill is <code>adminCosts</code>) before any model runs, which keeps cost down on a free tier. But the interesting detail is the confidence we assign those heuristic hits: <code>0.7</code>. That is a deliberate placement. It sits above the <code>0.6</code> review threshold, so a confident keyword match is auto-applied, but it stays well clear of <code>1.0</code> because a regex is a guess, not a guarantee. Confidence here is a budget you spend carefully, and the gap between <code>0.7</code> and the threshold is the margin of safety.</p>
<h2>The ambiguous long tail is the whole game</h2>
<p>Clear cases are easy and rare in aggregate; the long tail is where a categoriser earns its keep. A payment to a marketplace could be stock or equipment. A "consulting" line could be professional fees or staff costs. The correct behaviour for these is not a cleverer prompt, it is humility: a middling confidence, a trip to the review queue, and a human making the call. When the user corrects a row, that correction becomes the filed value and a labelled example you can learn from later. The review queue is not an admission of failure. It is the mechanism that lets you ship an imperfect model into a domain that does not tolerate silent errors, because the errors are caught at a checkpoint instead of inside a submission to HMRC.</p>
<p>This is what "knowing what it does not know" buys you in practice. The model is allowed to be wrong; it is not allowed to be wrong <em>quietly</em>. Every uncertain decision is visible, attributable, and reversible before it reaches the return. For anyone building AI into a regulated or high-stakes workflow, that property matters more than raw accuracy. A categoriser that is right 95% of the time and silent about the other 5% is dangerous. One that is right 90% of the time and flags the rest is something you can actually trust a tax filing to. We took the same approach across the OCR, bank-feed, and CSV paths in <a href="https://taptax.co.uk/making-tax-digital?utm_source=hashnode&amp;utm_medium=editorial&amp;utm_campaign=ebas_hashnode_ocr">TapTax's Making Tax Digital pipeline</a>: one threshold, one queue, one rule that uncertainty is never hidden.</p>
<h2>Takeaways</h2>
<ul>
<li><p>Split reading (OCR) from deciding (categorisation) so their failure modes recover separately.</p>
</li>
<li><p>Constrain the model's output to a known vocabulary and validate it; reject anything off-list.</p>
</li>
<li><p>Collapse every failure (bad JSON, invalid category, API error) to a single low-confidence value so the unknown always routes to a human.</p>
</li>
<li><p>Pick an auto-apply threshold, send everything below it to a review queue, and keep the filed field separate from the model's suggestion.</p>
</li>
<li><p>Treat self-reported confidence as a ranking signal, not a calibrated probability, and tune the threshold on your own data.</p>
</li>
</ul>
<p><em>Solomon Amos is the founder of TapTax, a Making Tax Digital app for UK sole traders. He built TapTax's HMRC integration (open banking, AI transaction categorisation, OCR), spent two years embedded at HMRC's digital programmes, and holds a PhD in machine learning. <a href="https://www.linkedin.com/in/solomonudoh/">https://www.linkedin.com/in/solomonudoh/</a></em></p>
]]></content:encoded></item></channel></rss>