Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 135 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,12 @@ runs fully without ever signing in — community features are the only auth-gate
surface.

Two ways to classify:
- **AI Config mode** (no local model active): send the cropped image to an
OpenAI-compatible HTTP server (`/v1/chat/completions`).
- **Over HTTP** (`/v1/chat/completions` on an OpenAI-compatible server) — two
spellings of the same backend: **AI Config mode** (no active model, one
app-level config) and an active **openai-mode model** (`model_mode =
"openai"`), which carries its *own* `AIModelConfig` and headstamp list so
several such models can coexist, exactly as the Windows app's "OpenAI API"
Training Mode does.
- **Local model mode**: run a PyTorch **ConvNeXt** model locally. The model can
be one the user trained on the Train page, a pretrained model downloaded from
the community, or one imported from a ZIP — running locally does **not** require
Expand Down Expand Up @@ -234,7 +238,13 @@ sanctioned way for worker threads to update the UI.
`ensure_initialized()` creates the DB, runs a one-shot import from legacy
`data/config.json` (renaming it `.bak`), or seeds a default cartridge+model.
Tables: `cartridges`, `models`, `headstamp_parents`, `headstamps`,
`slot_templates`, `settings`.
`slot_templates`, `settings`. One structural fix lives *outside* the
ladder: `_widen_model_mode_check` rebuilds `models` when its mode CHECK
predates `'openai'` — a CHECK can't be ALTERed, and the rebuild needs
`PRAGMA foreign_keys` toggled, which is a silent no-op inside the
transaction every ladder step runs in (with FKs on, `DROP TABLE models`
would cascade-delete every headstamp). Guarded structurally off
`sqlite_master`, like the DDL pass.
- **`repository.py`** — `CartridgeRepo`, `ModelRepo`, `HeadstampRepo`,
`HeadstampParentRepo`, `SlotTemplateRepo`, `SettingsRepo`. All SQL is
**parameterized**. `SettingsRepo` is a typed key/value store (JSON-encoded
Expand All @@ -248,12 +258,99 @@ sanctioned way for worker threads to update the UI.
and the sorting-template API (see below).
- **`models.py`** — dataclasses: `Model`, `Headstamp`, `Cartridge`, `SlotTemplate`,
`TrainingConfig`, `AIModelConfig`, `ImageProcessingConfig`, plus normalizers
(`normalize_upload_mode`, `SUPPORTED_MODEL_MODES`, `SLOT_TEMPLATE_MODES`).
(`normalize_upload_mode`, `SLOT_TEMPLATE_MODES`) and the mode/ownership
vocabulary: `SUPPORTED_MODEL_MODES` (the trainable ConvNeXt backbones —
`train_page` assigns a mode straight into `training_config.model_name`, so
`"openai"` must never join this tuple), `OPENAI_MODEL_MODE` and
`MODEL_MODES` (what `ModelRepo` accepts), `is_openai_model`, and
`model_mode_label` — the user-facing spellings ("ConvNeXt-Tiny",
"OpenAI", the Windows app's Training Mode names) that every UI surface
prints while storage keeps the snake_case identifiers; the editor combo
carries the identifier as item *data*, and `_normalize_model_mode`
accepts the labels back so one leaking into a manifest still round-trips.
- **`model_io.py`** (`sorter/data/model_io.py` — grouped with the rest of
persistence, not a separate layer: it's a model persisted to a ZIP instead
of SQLite) — model **ZIP** import/export; see the *Training & evaluation*
entry below for what it does, kept there to stay next to the training
workflow it feeds.
- **`winforms_import.py`** — one-shot import of an existing **WinForms ("AI
Brass Sorter") installation**, so a user moving off the Windows app doesn't
rebuild their setup by hand (#98). The legacy app keeps everything in its
*install directory* — `Data/ConfigDB.sjdb.json` (the whole database as one
JSON document, BOM-prefixed), `Data/Settings.json`, `training/images/<id>/`
and `training/models/<id>.zip` — and **nothing in the registry**:
`HKCU\Software\AICaseSorter` exists but is empty, the only value under
`HKCU\Software\SJSeth\...` is an MSI-authored `DesktopFolder`, and the
uninstall entry's `InstallLocation` is blank, so a custom install is found
by asking the user, not by reading a key.
`survey()` reports what a root offers without importing anything (it is what
populates the dialog's counts and what keeps the first-run offer silent);
`import_installation()` does the work, per ticked item.
- **The selection is per model, not per category.** `ImportOptions.per_model`
maps a legacy model id to a `ModelSelection` (images / headstamps /
checkpoint), and `selection_for()` is the single place that resolves "what
do I bring for this model" — a real install holds years of models the user
doesn't want (sjseth on #125). Three states, and the difference is
load-bearing: **`None`** means no per-model choice was made, so every
surveyed model comes with the app-level flags (what every pre-tree caller
and every default `ImportOptions()` gets); **`{}`** means no models at all;
a populated map is the answer. The model's **row** is deliberately not a
selectable part — the images land in its folder, the headstamps hang off it,
the checkpoint is recorded on it — which is the inheritance the dialog's
tree makes structural instead of a rule (§5).
- **Model ids never collide, names can.** `ModelRepo.create` allocates the
rowid, so the legacy id survives only as a lookup key (`training/images/<id>`
and the `winforms_imported_models` map) and an id conflict with a local
model is impossible by construction. The *name* is the one real conflict, so
a newly-created row goes through `model_io.unique_model_name` — the same
resolution the ZIP path has always applied, made public for this caller.
An update (UID or remembered pairing) keeps the local name, so re-running
the import stays idempotent rather than growing `(2)`s.
- **`survey(root, db=...)`** additionally resolves, per model, whether
importing it would create a row or refresh one (`LegacyModel.updates`),
via the same `_find_existing` the import uses. Asked early purely so the
dialog can say so before the user commits; without a `db` it stays None.
Two things it leans on and one it must not:
- Legacy `Models` rows are **the same PascalCase shape** as an export ZIP's
`ModelInfo`, so they go straight to `model_io.model_from_export_dict` —
`ModelType`/`ModelMode` int mapping and all — rather than being re-parsed.
`ModelType` 1/2 therefore lands as `ReadOnly`/`CommunityManaged`, i.e. a
community model stays non-trainable exactly as a download here would.
**This module is the only caller of `model_from_export_dict` with no clamp
of its own**, so every value in `_WINFORMS_MODELMODE_INT_TO_STR` has to be
a mode `ModelRepo` accepts — `test_model_io.py` pins that. `ModelMode` 2
(OpenAI) maps to `"openai"`, a first-class mode here too, so the row
imports faithfully — its own `AIModelConfig` and headstamps included —
and needs no warning. The AI Config item still seeds the app-level
config, preferring the OpenAI-mode model's blob: the legacy app writes
one on every model and most are blank, so "first non-empty" picked the
wrong one.
- **`training/models/<id>.zip` is a `torch.save` archive, not a ZIP of
anything** — it copies to `<id>.pth` verbatim. The legacy **ML.NET**
pipeline writes its models beside it under the same extension, so
`_checkpoint_kind` looks inside (`*/data.pkl` = torch,
`TransformerChain/` = ML.NET) before copying. An ML.NET-only model is
imported as a **shell** — metadata, headstamps and images, no checkpoint —
because the images are the expensive part and the `NoLocalCheckpointError`
path already explains a model that can't classify yet. That warning lives
on `LegacyModel.warning`, not in an install-wide list
(`LegacySurvey.warnings` derives from the models), so the *survey* reports
on the whole install — which is what informs the pick — while the *import*
reports only on the models the user actually took.
- **Never destructive to the source.** Files are copied; nothing in the
install directory is written, moved or removed. Re-running is idempotent:
a community UID match or the per-root `winforms_imported_models` settings
map updates the row in place, so slot assignments and templates survive
and images already copied are skipped.
- **One bad row costs that row.** Each model imports inside its own nested
`db.transaction()` (a SAVEPOINT), counted into a scratch `ImportResult`
merged only on success, so a legacy row this app refuses is skipped with a
warning instead of rolling back an install's worth of images.
Slot assignments are **inverted on the way in** — the legacy DB stores a slot
listing its headstamps (`SlotConfigs[].Config`), ours stores a slot on the
headstamp row. `Defaults.IP_*` maps to `image_proc.linescan` **only**: the
legacy pipeline has no Hough stage, so writing its numbers into ours would
silently detune a working crop.

### Filesystem (`sorter/paths.py` — top level, not under `data/`)
- **`paths.py`** — single source of truth for the on-disk layout (see §6) and
Expand All @@ -279,10 +376,12 @@ sanctioned way for worker threads to update the UI.

### Active-model concept
"Active model" = `settings.default_model_id`. When **absent**, the app is in
**AI Config mode** (cloud HTTP classification, headstamps in a settings key).
When **set**, that local model is active (Train live, local inference used,
headstamps in the `headstamps` table). Activating a model posts
`mode/changed`, which is what re-evaluates the mode pair (§5).
**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
Expand Down Expand Up @@ -382,16 +481,21 @@ between them from the Sort page's template dropdown.

### Classification (`sorter/ml/`)
- **`classifier.py`** — `classify_active`: **the active model alone picks the
backend.** A model is active → local inference; AI Config mode (no active
model) → HTTP. Passes the trained `image_size` through. A local model whose
backend.** A ConvNeXt model is active → local inference; an openai-mode
model is active → HTTP with **that model's own** `ai_model_config` (the
passed app-level `api_cfg` is deliberately ignored there); AI Config mode
(no active model) → HTTP with the app-level config. Passes the trained
`image_size` through. A local model whose
checkpoint is missing raises `NoLocalCheckpointError` — it does **not**
degrade to HTTP. That fallback existed and was a trap: a renamed data folder
or an images-only community share left `model_path` unusable and the app
quietly POSTed case images to whatever AI Config last pointed at,
surfacing only as a connection error naming a host the user wasn't knowingly
using. Switching backends is the user's call, on the Models page. `active_model`
/ `uses_local_inference` / `has_local_checkpoint` / `checkpoint_problem`
expose the decision alone, so the UI can ask "does this need PyTorch?" and
expose the decision alone (`uses_local_inference` and `checkpoint_problem`
are both False/None for an openai model — no PyTorch, no checkpoint to
miss), so the UI can ask "does this need PyTorch?" and
"can this model actually classify?" before starting a run — keep them in
lock-step with `classify_active` or the install gate (§5) drifts from reality.
`checkpoint_problem` also asks `torch_floor_problem`: a model records the
Expand Down Expand Up @@ -589,9 +693,12 @@ 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 exactly one is *live*:
Train ⟺ `models.is_trainable(active model)`, AI Config ⟺ no active model —
so a community model leaves neither live. The other gets
**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
Expand Down Expand Up @@ -669,7 +776,7 @@ modal), and never gate on `is_available()`.
| **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 | Camera, Serial, Image Processing, Theme — listed in `SETTINGS_SECTIONS`, reached by name. |
| **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`.
Expand Down Expand Up @@ -724,6 +831,19 @@ Themes panel in `app.py`. Dialogs are `dialog_*.py`.
shipped and then reverted: JL lived with them and chose the bar. Don't
reintroduce item widgets in these tables — `_pin_ai_row` and every sort
destroy them, which is machinery the bar simply doesn't need.
- **One checkable tree, and it is a picker, not a table.**
`dialog_winforms_import.py`'s `QTreeWidget#importTree` is the only tree in the
app whose items carry check state, which is why `theme.py` needs an
`::indicator` block keyed on it (the `QCheckBox::indicator` rules can't reach
an item view's own indicator). It is not an exception to the bar convention
above: check state is item *data*, not an embedded widget, so nothing is
destroyed by a rebuild. The hierarchy carries meaning — a model's images,
headstamps and checkpoint are its children because they cannot exist without
it — and check propagation is manual (`_set_branch` down, `_refresh_ancestors`
up, both under `blockSignals`) rather than `ItemIsAutoTristate`, so exactly
one place decides what a half-ticked parent means. Rows with nothing behind
them are **omitted**, not disabled, so propagation never has to reason about
a child the user can't reach.
- **The notify/confirm seam.** Anything that would open a native modal —
`win.notify`, a page's `confirm` / `ask_text` / `ask_open_path` /
`ask_save_path` / `ask_import_choice` — is an **instance attribute**, not a
Expand Down
Loading