第7a阶段——发表意见:基于规则的自动分类(以及稍后AI的接合)

2026年8月9日1 次浏览来源:Dev.to阅读原文

正文保留英文原文(机翻易破坏代码与排版),标题/摘要已提供中文

My expense app finally has a point of view on what I'm spending money on.

No AI yet — just honest keyword rules, a nullable column, and one interface that means I can bolt an LLM on later without ripping anything out.

Here's the build, three "empty value" bugs that bit me, and the habits that kept it clean.

Index Where we left off The plan: rules first, AI behind the same door Step 1 — A nullable column (and why nullable matters) Step 2 — The migration: generate → review → apply Step 3 — A dumb-but-working Step 4 — Wiring it into create (with override precedence) Step 5 — The seam: extracting behind a interface Step 6 — The UI loop: show, add, edit 🐛 The war story: three ways "empty" lied to me Thinking like an attacker Learning shortcut vs. production Key habits to keep Next up: Phase 7b Where we left off Phase 6 gave me the receipts — date-range reports and CSV export.

I ended that post with a promise: Next up: Phase 7, where categories finally enter the schema and the app starts to get opinionated about what I'm spending on.

This is that.

But it turned into a bigger beast than one post, so I'm splitting it: Phase 7a (this post): the schema, a rules-based categorizer, the interface seam, and the full UI loop.

Phase 7b (next): the actual LLM — an that slots in behind the same interface, with caching and a rules fallback.

Doing rules first isn't a cop-out.

It's the whole strategy.

The plan: rules first, AI behind the same door The temptation with "AI categorization" is to reach straight for the API key.

I didn't.

Here's the order I actually built in, and why: Step What Why this order 1 Nullable column The app needs somewhere to store a category before it can fill one 2 Rules A working, free, offline fallback — and a baseline to test against 3 Extract behind an interface So the LLM can slot in later without touching call sites 4 UI loop (show / add / edit) Give the human final say, no matter how smart the auto-fill gets 5 (Phase 7b) LLM implementation The risky external dependency goes in last, behind the seam The principle: build the boundary before you plug in the flaky thing.

Rules are boring and reliable.

The LLM will be clever and occasionally down, slow, or wrong.

If both live behind the same contract, swapping between them — or falling back — is a one-line change.

Step 1 — A nullable column (and why nullable matters) One new line on the model: The interesting bit is the / .

Every expense already in my database was created before this column existed.

If I made it , the migration would try to force a value into all those existing rows and fail — or demand a default I don't actually want.

Nullable is honest: old rows are genuinely uncategorized. "No category" is a real state, not an error.

And it maps perfectly onto the rules engine returning "I don't know" — more on that below.

New syntax I picked up here: Syntax Meaning A required string `Mapped[str \ None]` Length-bounded, like my at 255 Step 2 — The migration: generate → review → apply Three deliberate moves, not one: Autogenerate is good, not infallible.

It occasionally invents spurious type tweaks, and SQLite has its quirks with certain operations.

So I read the file every single time before applying — confirming both that does only what I asked and that cleanly reverses it.

Backup habit: SQLite is a single file, so a backup is a : Verified the column actually landed: (Aside: my had but not .

Named the backup so the existing rule caught it, then added anyway as housekeeping.) Step 3 — A dumb-but-working No AI.

Just keywords: The concepts I actually learned writing this: Concept What it does Case-insensitive matching — , , all hit Substring test — is Returns on the first match, then stops No match = no guess — maps onto the nullable column That last line is the design decision I'm proudest of.

When the rules don't recognize something, they don't guess wildly — they return , and the expense stays honestly uncategorized.

Tested in isolation before wiring anything: Step 4 — Wiring it into create (with override precedence) The rule: if the user explicitly sends a category, respect it.

Only auto-fill when they don't. returns if the key is missing or sent as null — my "did the user leave it blank?" test.

Blank → auto-categorize.

Provided → keep theirs.

This is also where the first landmine was waiting (see the war story).

But conceptually: the client now has three honest options — omit it (auto), send a value (override), or send (explicitly uncategorized).

Step 5 — The seam: extracting behind a interface This is the step that makes Phase 7b painless.

Right now lives inside a web route — core domain logic welded to the HTTP layer.

That's a smell, and it blocks the LLM work.

So I pulled it into its own module behind a contract: Three ideas clicked here: A is a contract. "Anything called a must have ." A class satisfies it just by having that method — no inheritance (structural typing).

My future will satisfy the same contract for free.

A class carries state.

A bare function can't remember things.

The LLM version will need to — an API client, and eventually a per-merchant cache.

So a class, with on the instance.

One entry point.

Everything imports and calls , blissfully ignorant of how it works.

Swapping rules → LLM becomes a one-line change in one place.

Then just does: Refactor discipline: I shipped the extraction as a pure, behavior-neutral commit — same inputs, same outputs — and only then, in a separate commit, widened the Food keywords.

Never mix a refactor with a behavior change in the same commit.

When something breaks later, you want to land on one or the other, not a tangle of both.

Step 6 — The UI loop: show, add, edit Auto-categorization is useless if the human can't see or override it.

Three small React slices: Show — a category pill per row, with a loud fallback for the ones the rules missed: I made the "Uncategorized" pill red on purpose — it's a gentle nudge that says "this one still needs you." Add — an optional category input on the create form, plus defaulting the date to today.

Edit — the same override on the inline edit form, so those red flags are actionable: click, type a category (or clear it), save.

Both forms share one trick that turned out to be load-bearing — which brings me to the bugs. 🐛 The war story: three ways "empty" lied to me Phase 6 had one signature bug (a CSV that only wrote its last row, traced to an indentation slip).

Phase 7a had a theme: every single bug this session was about the difference between null, empty string, omitted, and the wrong day. "Nothing" is not one thing.

It's at least four, and they don't behave the same.

Gotcha 1 — Nullable ≠ optional (the silent 422) I declared the create field like this and thought I was done: Omit in the request body → 422 Field required.

In Pydantic v2, only says "null is an allowed value." What makes a field skippable is a default. = "null is allowed." = "you can omit it." You need both for "optional and nullable." Two different questions; I'd only answered one.

Gotcha 2 — Empty string sneaks past the categorizer My backend auto-fills only when category is : But an HTML text input never gives you .

Empty it, and it hands you .

So the naive form body... ...sends .

And → .

So the backend treats an empty box as a deliberate override, skips entirely, and every blank-form expense silently lands uncategorized.

My shiny new auto-categorization looked completely broken — because of a frontend empty string. is falsy → kicks in → the backend sees → auto-categorizes. also stops someone "overriding" with three spaces.

I used the exact same line on the edit form, where clearing the box is how you send a category back to .

Gotcha 3 — "Today" was yesterday (a UTC off-by-one) I wanted the add form to default the date to today.

The one-liner everyone reaches for: returns UTC.

I'm in IST (UTC+5:30).

So between midnight and ~5:30 AM my time, UTC is still on yesterday's date, and the form would confidently pre-fill the wrong day.

分享