AI-Case-Sorter-Py

CLAUDE.md

Guidance for AI assistants (and humans) working in this repository. It maps the architecture, the moving parts, and the conventions so a new contributor can be productive without reverse-engineering the whole tree. Keep this file current: when you add a page, change the data model, or alter a subsystem boundary, update the relevant section here in the same change.

Docs are part of every functionality change, visible or not. A change that alters behavior ships with its documentation in the same change: this file’s relevant section, the module docstring it invalidates, docs/guide/GUIDE.md for anything an operator can see or do (its headings are load-bearing — the F1 help maps to them; tests/unit/ui/test_help.py pins the anchors), and docs/ui-modernization.md for UI design decisions. Stale docs are bugs: they were the direct cause of a full retroactive documentation sweep on 2026-08-14, and the guide misdirecting an operator is a user-facing defect.

The published site is user-facing only. mkdocs.yml’s nav is the whole of it — home, install.md, getting-started.md, guide/GUIDE.md, troubleshooting.md — and mkdocs-pdf.yml renders the same pages as one PDF per release. Contributor documents stay in docs/ and go in exclude_docs (ui-modernization.md is the only one so far); a decision record reaching an operator as a chapter of the manual is what that list exists to stop.


1. What this project is

The AI Case Sorter is a cross-platform (Windows + Linux/Ubuntu + macOS) desktop application that drives a physical machine which sorts spent brass cartridge casings by headstamp (the stamp on the base of the case). A camera photographs each case, an image classifier predicts the headstamp, and a serial-connected sorting machine drops the case into the correct bin.

It is the full-parity Python/Qt version of the existing Windows-only WinForms application and is intended to eventually replace it. Much of the code deliberately mirrors the WinForms behavior.

The “community” features (model sharing, downloads, feedback loop) authenticate against a hosted backend at reloadingrecipes.com via Azure AD B2C. The app runs fully without ever signing in — community features are the only auth-gated surface.

Two ways to classify:


2. Running, testing, layout

Entry point: src/sorter/__main__.py → initializes paths, opens the SQLite DB (migrating from a legacy data/config.json if present), loads Config, and launches sorter.ui.app.run_app. Launched as python -m sorter with PYTHONPATH=src, which bootstrap.py sets on the child process — the package is deliberately never installed into the venv (uv sync --no-install-project), so the environment is what makes -m resolve. No module in src/sorter/ may rewrite sys.path (tests/unit/test_entry_point.py enforces it): the launcher owns the path, and a hand-rolled shim that inserts src/sorter instead of src/ puts every subpackage on the path as a top-level name, where ui, data and update shadow same-named third-party packages.

One exception, and it is never run from this tree: src/sorter/_legacy_entry.py is copied into the sdist as a root main.py by pyproject.toml’s [tool.hatch.build.targets.sdist.force-include]. An in-app update is applied by the copy already installed, and every release up to 1.1.0 ends that launch with python main.py from a bootstrap.py already in memory when the new tree lands — so the archive needs one even though the source tree doesn’t. Keeping it out of the repo root is what lets the source layout be final: retiring the shim is one deleted line in pyproject.toml. tests/integration/test_cross_version_update.py runs 1.1.0’s real updater against a real built sdist to keep all of this honest.

Launch (handles the Python runtime, system deps, and dependency sync automatically):

Tests: pytest from the repo root (tests/conftest.py puts src/ on sys.path; it lives at tests/ top-level so it applies to both subdirectories below it). tests/unit/ mirrors src/sorter/’s subpackages one-for-one (tests/unit/hardware/, tests/unit/data/, …), plus a handful of modules that test something at the package’s own top level (test_paths.py, test_bootstrap.py, test_version.py) or outside it entirely — the shipped scripts and CI plumbing (test_installer_scripts.py, test_next_prerelease.py, test_workflow_permissions.py) — and stay directly under tests/unit/. Everything in tests/unit/ uses synthetic fixtures only; tests/integration/ stays flat — the files that exercise a real external tool or service (uv build, git-cliff, the PyTorch wheel index) instead, each self-skipping if that tool — or, for the wheel index, the network — is missing; pytest -m "not integration" skips them outright. tests/unit/ui/ mirrors sorter/ui/ like the rest, and runs the whole UI headless on QT_QPA_PLATFORM=offscreen (its conftest.py sets it) — no Xvfb, no display (§5, §8). CI (.github/workflows/build.yml) runs the full matrix on every push/PR — run pytest locally before pushing regardless, since CI turnaround is slower than your own machine. The suite is threading-fragile by design (see tests/conftest.py); don’t parallelize it.

Python: 3.12+ floor (pyproject.toml); .python-version pins the actual version uv provisions for the app itself, independent of that floor. Core deps: pyserial, opencv-python, numpy, Pillow, requests, msal, platformdirs, sqlite-utils, PySide6-Essentials + pyside6-qtads (+ pygrabber on Windows). Optional ML deps: torch, torchvision.

AI-Case-Sorter-Py/
├── bootstrap.py              # cross-platform launcher logic (Python+uv+deps+update)
├── start.sh / start.bat     # thin per-OS shims that just call bootstrap.py
├── pyproject.toml           # package metadata; [ml] extra = torch/torchvision
├── uv.lock                  # committed, exact dependency resolution
├── .python-version           # Python version uv provisions for the app
├── src/
│   └── sorter/               # all application code (never installed — see above)
│       ├── __main__.py         # entry point (+ `--apply-update` pre-launch hook)
│       ├── _legacy_entry.py    # shipped as the archive's root main.py; see §2
│       ├── paths.py            # on-disk layout; stdlib-only, imported before uv sync
│       ├── logging_setup.py    # one-shot logging config (§8)
│       ├── control/            # event bus + the sort loop
│       ├── hardware/           # serial, camera, image processing
│       ├── data/                # SQLite persistence + model ZIP import/export
│       ├── ml/                  # classification, local inference, evaluation
│       ├── community/           # auth, community backend client, feedback loop
│       ├── update/              # self-update: check/stage + pre-launch apply
│       ├── training/            # out-of-process ConvNeXt trainer
│       └── ui/                  # PySide6 UI — the only UI (§5)
├── installer/               # Windows bootstrapper (see §7)
├── tools/                   # developer utilities, not shipped or imported
└── tests/                   # pytest suite, mirrors src/sorter/'s subpackages

The data root lives outside the repo by default — see §6.


3. Architecture at a glance

The app separates hardware I/O, control logic, persistence, and UI into independent, testable layers, glued by a thread-safe event bus. Since #58’s src/ layout, that sentence is literally the top level of src/sorter/: hardware/ ↔ hardware I/O, control/ ↔ control logic (the event bus and the sort loop), data/ ↔ persistence, ui/ ↔ UI — plus ml/ (classification/inference/evaluation), community/ (auth + the community backend client + the feedback loop), update/ (self-update), and training/ (the out-of-process trainer), each its own subpackage.

flowchart TB
    UI["UI — PySide6, main thread<br/>ui.QtMainWindow · sidebar pages · docks · dialogs · theme"]
    Bus["control.events.EventBus<br/>Queue-backed pub/sub"]

    UI -- "subscribes (drained on main thread)" --> Bus
    Bus -- "run_worker(fn) spawns" --> UI

    Bus --> RC["control.run_controller<br/>sort loop, daemon thread"]
    Bus --> SB["hardware.serial_broker<br/>UART protocol, reader+ping threads"]
    Bus --> CAM["hardware.camera<br/>cv2 grab thread"]
    Bus --> TM["training.manager<br/>subprocess + stdout JSON markers"]

    RC --> CLF["ml.classifier"]
    CLF --> LI["ml.local_inference (torch)"]
    CLF --> API["ml.api_client (HTTP)"]

    CAM --> IP["hardware.image_proc<br/>Hough crop"]

    TM --> TC["train_convnext.py<br/>ConvNeXt, separate process"]

The event bus (sorter/control/events.py)

A single EventBus with a thread-safe Queue. Workers call bus.post(topic, payload) from any thread; the Qt main loop calls bus.drain() on a 50 ms QTimer to dispatch queued events to subscribers on the main thread, so handlers can safely touch widgets. Handler exceptions are logged with their topic and then swallowed — one broken subscriber must not stop the drain, but it no longer fails silently either (#32). Topics are slash-namespaced strings: run/*, test/*, serial/*, training/*, mode/changed, feedback/*, community/*. This is the only sanctioned way for worker threads to update the UI.


4. Module reference (sorter/)

Persistence & configuration (sorter/data/)

Filesystem (sorter/paths.py — top level, not under data/)

Community backend config (sorter/community/appenv.py)

Active-model concept

“Active model” = settings.default_model_id. When absent, the app is in AI Config mode (HTTP classification via the app-level config.api, headstamps in a settings key). When set, that model is active with its headstamps in the headstamps table — a ConvNeXt model classifies locally (Train live); an openai-mode model classifies over HTTP using its own ai_model_config (AI Config live, editing that model’s settings). Activating a model posts mode/changed, which is what re-evaluates the mode pair (§5).

Sorting templates

A sorting template is a named snapshot of the Sort page’s slot assignments, so one model can carry several bin layouts (“Range brass”, “Match prep”) and switch between them from the Sort page’s template dropdown.

Hardware control (sorter/hardware/)

The sort loop (sorter/control/run_controller.py)

Classification (sorter/ml/)

Training & evaluation (sorter/training/, plus evaluation in sorter/ml/ and ZIP import/export in sorter/data/model_io.py)

Self-update (sorter/update/; see §7 for the full flow)

Community / cloud (sorter/community/)


5. The UI (sorter/ui/)

The PySide6 UI is the UI — python -m sorter lands in ui/app.py, and PySide6 is an ordinary core dependency. This package was a second, opt-in UI called sorter/qtui/ until 2026-08-14, beside a Tkinter one that held the ui name; the Tk UI is gone and this took its place, name included. docs/ui-modernization.md is the decision record for the port, the retirement and the rename.

QtMainWindow (app.py) is the shell: an activity sidebar in three groups — the always-live surfaces (ACTIVITIES: Sort, Models, Community), the mode pair (MODE_ACTIVITIES: Train, AI Config), then Settings — split by two hairlines (sidebar_separator and sidebar_settings_separator, both objectName sidebarSeparator, coloured from the palette’s border role by ui/theme.py alone, so a theme switch needs no hook). Every entry is in the flow, with the stretch last: Settings used to be pinned below the stretch and went off-screen on a short window — driving a QStackedWidget of pages, plus four docks — serial monitor (bottom), classification history, the user guide and the theme picker (right, all three closed until asked for) — a status bar (camera/serial indicators, an inference-device indicator — refresh_device_indicator, fed by local_inference.device_description(), warmed off-thread at startup by _warm_device_indicator and hidden in AI Config mode — update affordance, identity + sign-in) and File/View/Help menus. It owns the EventBus, Camera, SerialBroker, RunController and AuthManager, auto-connects serial/camera on startup, and runs the bus drain loop. run_worker(fn, on_done, on_error) is the standard helper for offloading blocking work to a thread and marshaling the result back through the bus.

Neither of the mode pair is ever hidden, and at most one is live: Train ⟺ models.is_trainable(active model) (False for community and for openai-mode models), AI Config ⟺ no active model or an active openai-mode model (both classify over HTTP; the page’s server fields bind to whichever config is in effect via AiSection.retarget(), so an openai model’s settings are edited on its own row) — a community model leaves neither live. The other gets _set_activity_unavailable, which sets the dynamic property unavailable on the button — restyled text_subtle by ui/theme.py and re-inked by _paint_sidebar_icon, since a stylesheet can’t reach a QIcon — and leaves it enabled: the click must still work, because the explainer behind it is what answers it. Both halves are the same stacked panel: train_page’s, and ai_page’s — which replaces the server form with a panel naming the model that classifies instead and a jump to Models. A tooltip on both entries states liveness in one line. Hiding an activity was how JL came not to know Train existed.

Model ownership

A model installed from the Community page is stamped model_type = "CommunityManaged" by import_model(..., community_download=True), and models.is_trainable() is False for it: the local checkpoint is the publisher’s, retraining forks it from the version they keep updating, and the archive usually ships without the images it was built from. ReadOnly (the legacy app’s marker) is treated the same. _merge_onto_installed never lets an update downgrade ownership.

community_model_uid is not an ownership signal — sharing your own model stamps a UID onto your local copy, so a UID means “exists in the community”, not “isn’t yours”. Ownership is decided by how the model reached this machine, which is why the flag is a parameter of import_model rather than something read out of the manifest (a publisher’s own copy is Standard, and that’s what they export). A plain ZIP import stays owned — it’s just as likely to be a user restoring their own model onto a new machine.

The PyTorch install gate

PyTorch is the optional [ml] extra, so a fresh install has none. The rule: torch is installed the first time something actually needs it, and never before — an AI Config user must never be prompted. ui/torch_gate.py is the single entry point: TorchGate is bound once as win.ensure_torch (__call__ = hard gate, offer = once-per-session soft gate), opens dialog_install_torch and re-enters the caller on success:

if not self.ensure_torch(self._start, reason="Sorting needs PyTorch"):
    return

A second rule sits on top of presence, and it is a security floor. local_inference.MIN_TORCH_VERSION (2.10.0) is the oldest torch allowed to load a checkpoint this app did not produce: CVE-2026-24747 lets a crafted .pth defeat the weights_only=True unpickler that _load relies on, and the Community page and ZIP import are exactly that delivery path. So ensure_torch takes the model the action will load and picks the policy from models.is_foreign_model: a foreign model blocks until the user upgrades; the user’s own model gets an offer that proceeds on decline, remembered for the session (their own checkpoint isn’t an attack, and a multi-GB download shouldn’t stand between them and sorting). Omitting model takes the blocking branch — unknown provenance is treated as foreign. The floor is deliberately not derived from the [ml] pin: the pin is “what a fresh install gets” and moves on every routine bump, while this is “below here the safety property is gone”, moves only when an advisory says so, and records which one. It also bounds any future opt-into-an-older-build override (#67), and since it sits above the 2.3.0 floor where torch.amp.GradScaler first appears, honouring it cannot regress train_convnext.py onto the AttributeError older wheels produce. meets_min_version() fails open on unreadable metadata — a source build is likelier than an exploit, and bricking those installs would trade a real breakage for a speculative one.

Gated: Sort’s Start + Manual feed (only when classifier.uses_local_inference is True), the evaluator, and training. Train’s Feed offers rather than gates — capturing and labelling images is exactly the workflow that doesn’t need torch, so declining costs only the predicted-label convenience and is remembered for the session. Call it on the main thread only (it opens a modal), and never gate on is_available().

Surfaces

| Activity | File | Purpose | |—–|——|———| | Sort | app.py (+ slot_grid.py, dialog_slot_assign.py) | Production sorting: the crop the classifier saw, the slot cards with live counts, sorting templates, Start/Stop/Manual feed, package-mode counters. | | Models | models_page.py | Model library: browse/filter/sort, create, edit, activate, import/export, delete. Synthetic “Use AI Config” row. | | Train | train_page.py | Feed→capture→classify→label→save loop; “Sort While Training”; launches training. | | AI Config | ai_page.py | HTTP server config (endpoint/key/model/prompt/encoding), headstamp manager, single-shot test. | | Community | community_page.py | Browse/search/download community models; share entry point. Auth-gated. | | Settings | settings_{camera,serial,imageproc}.py + app.py’s Theme section + dialog_winforms_import.py | Camera, Serial, Image Processing, Theme, Import from Windows — listed in SETTINGS_SECTIONS, reached by name. |

Docks: serial_monitor.py, history_view.py, help_viewer.py, and the Themes panel in app.py. Dialogs are dialog_*.py.

Conventions, each one load-bearing


6. Data & on-disk layout

Everything the app writes lives under a single data root, resolved once by paths.app_data_dir():

  1. CASESORTER_DATA_DIR — explicit override, wins over everything.
  2. A portable.txt marker next to bootstrap.py<app>/data (USB-stick installs).
  3. Otherwise the per-user OS location: %LOCALAPPDATA%\CaseSorter on Windows, $XDG_DATA_HOME/CaseSorter (default ~/.local/share/CaseSorter) elsewhere.

The data root is outside the app folder by default, and that is load-bearing: the in-app updater replaces the app folder wholesale (§7). Keeping user data out of it makes the updater safe by construction rather than by maintaining an exclusion list. paths.migrate_legacy_data_dir() moves a pre-0.2 <app>/data up on first run, so upgrades are invisible. <app>/data is still gitignored and must never be committed.

<data root>/
├── config/
│   ├── casesorter.db      # SQLite (all settings, models, headstamps)
│   └── msal_cache.bin     # MSAL token cache (chmod 0600 on POSIX)
├── models/
│   └── <model_id>/
│       ├── images/          # raw training images   {label}__{ticks}.jpg
│       ├── run_images/      # opt-in run captures
│       ├── feedback_images/ # below-threshold feedback queue (folder == queue)
│       ├── reports/         # evaluator HTML reports
│       └── trainedmodel/    # <model_id>.pth checkpoint
├── logs/                  # app + launcher + installer + training logs (§7, §8)
│   ├── casesorter.log       # the app's own; DEBUG, rotating 1 MB x 3
│   ├── launch.log           # this launch; previous kept as launch.prev.log
│   ├── install-<stamp>.log  # one per install-windows.ps1 run
│   └── training-<stamp>.log # one per training run; last few kept
└── updates/               # staged app updates (§7)
    ├── pending/             # extracted tree awaiting the next launch
    ├── pending.json         # its metadata — a SIBLING, never inside pending/
    ├── backup/              # previous version, kept for rollback
    └── last_applied.json

Filename convention (WinForms-compatible): training images are {label}__{ticks}.jpg; feedback images are {label}__{confidence}__{ticks}.jpg, where ticks is the .NET DateTime.Ticks value.


7. Updates & Windows install

Non-developers get the app without git, and keep it current from inside the app. There is no git dependency anywhere in this path: a release tarball over HTTPS has the same trust anchor as git pull over HTTPS, and the source tree is ~1 MB, so delta transfer buys nothing.

Version: derived from the git tag at build time (pyproject.toml’s [tool.hatch.version] source = "vcs", via hatch-vcs), not hand-bumped — removes the old “forgot to bump __version__ in the release commit” footgun entirely; the manual step just doesn’t exist anymore. hatch-vcs’s build hook writes src/sorter/_version.py (gitignored, generated), which src/sorter/__init__.py imports as its first choice, falling back to importlib.metadata (an actual pip/uv install from a wheel) and finally a literal placeholder if neither is available — see that file’s comments for why each tier exists.

Two things this makes load-bearing that weren’t before:

The flow is stage now, apply at next launch:

flowchart TD
    A["updater.check_for_update()<br/>GET /releases/latest, compare tags — needs requests"]
    B["updater.stage_update()<br/>download → verify → &lt;data&gt;/updates/pending/<br/>(the app folder is NOT touched)"]
    C(["restart"])
    D["apply_update<br/>run by bootstrap.py BEFORE uv sync — stdlib ONLY"]
    E["sorter.update.apply_update<br/>backup → copy over app dir → prune → clear pending"]

    A --> B --> C --> D --> E

8. Conventions & gotchas