Commit Graph

56 Commits

Author SHA1 Message Date
bvandeusen 0db76c70e8 feat(series): SeriesService list/add(move)/remove/reorder/cover with guards
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:34:17 -04:00
bvandeusen b83baa7d7f feat(series): SeriesPage model + 0005 migration (unique image, ordered pages)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:33:12 -04:00
bvandeusen f946918cd2 feat(bulk): SuggestionService.for_selection consensus + POST /api/suggestions/bulk
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:03:16 -04:00
bvandeusen e1c8400c87 feat(bulk): BulkTagService (common_tags/bulk_add/bulk_remove) + 3 image bulk-tag routes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:02:01 -04:00
bvandeusen 93b6fc21c8 fix(tags): invalid 'copyright' kind 500 — KINDS uses real enum 'fandom'; directory 400s on bad kind
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:51:27 -04:00
bvandeusen 305687dc33 feat(api): rich 409 merge hint and POST tags merge endpoint with backfill enqueue
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:40:37 -04:00
bvandeusen 8fbeabe9ba feat(tags): protective alias creation per observed tagger category with kind fallback
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:39:16 -04:00
bvandeusen 33efba8bf5 feat(tags): merge repoints allowlist, embedding, aliases, fandom children
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:38:27 -04:00
bvandeusen 003181ed41 feat(tags): merge repoints image_tag and tag_suggestion_rejection with dedup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:36:13 -04:00
bvandeusen e0a455466d feat(tags): TagService.merge guards + MergeResult + repoint stubs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:35:26 -04:00
bvandeusen 96e6003b66 feat(tags): TagMergeConflict + provenance helper, enrich rename collision
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:34:25 -04:00
bvandeusen 13be9085b5 fix(integration): asyncio auto-mode, per-test truncation, artist images IN-subquery 2026-05-15 22:23:32 -04:00
bvandeusen e43c2a0dd0 feat(fc2c-i): artist overview + paged images endpoints 2026-05-15 15:52:02 -04:00
bvandeusen 8484cb9aaa feat(fc2c-i): tag directory service and /api/tags/directory 2026-05-15 15:50:41 -04:00
bvandeusen a74b313596 feat(fc2c-i): showcase random-sample service and endpoint 2026-05-15 15:49:25 -04:00
bvandeusen f7f75fcac6 refactor(fc2c-i): sweep blueprints + ml tasks onto shared get_session (covers tasks/ml.py, a survey gap) 2026-05-15 15:48:02 -04:00
bvandeusen fc33c23dd5 refactor(fc2c-i): single shared async engine with lifecycle dispose 2026-05-15 13:53:42 -04:00
bvandeusen 0a4eb0bdc0 fix(fc2b): bare CHECK constraint names (naming-convention double-prefix)
Base.metadata's convention applies ck_%(table_name)s_%(constraint_name)s.
ml_settings and tag_allowlist passed already-prefixed names
(ck_ml_settings_singleton / ck_tag_allowlist_confidence_range), so the
ORM-side names came out doubled (ck_ml_settings_ck_ml_settings_singleton
etc.) and the migration-0003 smoke tests failed.

Same class of bug fixed in FC-2a for ImportSettings — should have applied
that lesson here. Bare names ('singleton', 'confidence_range') let the
convention produce the final names that match migration 0003's literal
DDL. Migration unchanged; only the model __table_args__.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:05:41 -04:00
bvandeusen 4ebe779b7c fix(fc2b): lazy-import onnxruntime in tagger (CI collection failure)
onnxruntime is in requirements-ml.txt only (deliberately kept out of the
lean web image and CI). The top-level `import onnxruntime` broke pytest
collection of test_ml_tagger / test_ml_suggestions / test_tasks_ml even
though those are pure-logic/integration-marked, because collection
imports the module.

Mirrors the embedder's lazy-torch pattern: onnxruntime is imported inside
Tagger.load(), placed AFTER the file-existence checks so
test_load_raises_when_model_missing still gets RuntimeError (not
ModuleNotFoundError) in onnxruntime-less environments. self._session
annotation dropped to a comment to avoid an eval-time ort reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:15:24 -04:00
bvandeusen aa4cc7c629 feat(fc2b): add tag rename (PATCH /api/tags/<id>) + /api/ml admin
Rename returns 409 on collision (frontend shows FC-2c merge hint), 400
on empty. ml_admin: GET/PATCH settings (thresholds + min_reference;
model versions read-only here), POST backfill / recompute-centroids
returning 202 + celery task id. Tests integration-marked, eager Celery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:54:12 -04:00
bvandeusen 46338a1f2e feat(fc2b): add /api/allowlist and /api/aliases endpoints
Allowlist: list-all, get-one (404 if not listed), PATCH threshold
(range-validated), DELETE. Aliases: list-all (with canonical name),
create (idempotent, 201), DELETE by (string, category). Tests
integration-marked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:46:38 -04:00
bvandeusen 07961dfe04 feat(fc2b): add /api/images/<id>/suggestions endpoints
GET (ranked, category-grouped), accept (→ apply + allowlist; enqueues
retro-apply when newly added), alias (create alias + accept canonical),
dismiss (per-image rejection). Thin blueprint over SuggestionService +
AllowlistService. Tests marked integration, eager Celery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:45:55 -04:00
bvandeusen b68a382b60 feat(fc2b): importer enqueues tag_and_embed + ml-worker model self-heal
import_media_file now enqueues tag_and_embed alongside generate_thumbnail
after a successful import. scripts/download_models.py snapshots Camie +
SigLIP into /models, idempotent (skips when present). The ml-worker
entrypoint runs it before starting the Celery worker so a fresh /models
volume self-heals on first boot. Downloader tests are pure-logic (no
network in CI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:45:09 -04:00
bvandeusen bb48845268 fix(fc2b): add ML beat schedule entries (missed in Task 9 commit)
The celery_app.py beat-schedule edit failed with a stale-read error in
the Task 9 commit, so the ML daily jobs weren't registered. Adds
ml-backfill-daily, recompute-centroids-daily, apply-allowlist-sweep-daily.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:44:12 -04:00
bvandeusen 3e6cc8fffa feat(fc2b): add allowlist-apply + centroid recompute tasks + beat
apply_allowlist_tags: 4 modes (tag-only / image-only / both / full sweep),
matches a tag to a prediction either by direct name or via alias
(name, category) resolution, gates on per-tag min_confidence, skips
applied/rejected, applies source='ml_auto'. recompute_centroid /
recompute_centroids: async-bridged calls into CentroidService, delta-gated.
Beat: daily backfill, daily centroid recompute, daily allowlist sweep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:43:39 -04:00
bvandeusen ac7e0d13bc feat(fc2b): add tag_and_embed + backfill Celery tasks
tag_and_embed: Camie + SigLIP on one image (video → 10-frame sample,
max-pool tags, mean-pool embeddings), stores predictions/embedding with
model versions, then enqueues per-image allowlist apply. backfill:
keyset-paginated discovery of images missing predictions/embeddings for
the current model versions (restart-safe). apply_allowlist_tags stub
included so .delay() resolves between commits (filled in Task 9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:40:27 -04:00
bvandeusen ed92548c0f feat(fc2b): add AllowlistService + TagService.rename
AllowlistService: accept (apply ml_accepted + add to allowlist + clear
rejection; returns whether newly-added so API can kick retro-apply),
add_alias_and_accept, dismiss, reject_applied_tag (remove + record
rejection so the allowlist won't re-apply), threshold update, remove,
list_all.

TagService.rename: refuses on (name, kind, fandom_id) collision with a
message pointing at FC-2c merge. Tests marked integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:39:33 -04:00
bvandeusen 7860b86a13 feat(fc2b): add SuggestionService — alias-resolved, threshold-filtered, ranked
The read path: load tagger_predictions, drop unsurfaced categories
(rating/meta/year), apply per-category thresholds, batch-resolve aliases,
skip applied + rejected, augment with centroid hits above the similarity
threshold, merge duplicate signals (take max score, mark source 'both'),
group by category, sort by score DESC. Tests marked integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:38:33 -04:00
bvandeusen dfa67d6437 feat(fc2b): add CentroidService — per-tag SigLIP centroids + similarity
recompute_for_tag (mean of member embeddings, eligible-kind + min-refs
gated, upsert), list_drifted (the delta-gate: member-count mismatch OR
missing OR wrong model version), find_similar_tags (pgvector cosine
distance, similarity = 1 - distance). Tests marked integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:37:53 -04:00
bvandeusen 03c6a61673 feat(fc2b): add AliasService — (name, category) -> canonical tag
resolve() / resolve_many() (batch, used by the suggestion read path),
idempotent create, remove, list_all. Category-scoped so 'naruto' as
character vs copyright map to different canonicals. Tests marked
integration (real DB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:37:13 -04:00
bvandeusen 696c17fe29 feat(fc2b): add SigLIP embedder wrapper
Direct port of ImageRepo's siglip.py. Lazy torch/transformers import so
the web container can import the module (for enqueue logic) without the
torch cost. EMBED_DIM=1152 asserted against the schema's Vector(1152)
columns. Real inference runs in the local integration suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:36:17 -04:00
bvandeusen 41fa26ed95 feat(fc2b): add Camie tagger ONNX wrapper
CPU-only, lazy-loaded, process-singleton ONNX session. Parses Camie's
string-category selected_tags.csv (vs WD14's integer Danbooru ids).
STORE_FLOOR (0.05) keeps the stored predictions JSON compact;
SURFACED_CATEGORIES gates which categories the suggestion UI shows
(meta/rating/year stored but never surfaced).

Inference itself isn't unit-tested (1GB model not in CI); the missing-
model error path and pure-logic surface are. Full inference runs in the
local integration suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:35:58 -04:00
bvandeusen 906804140c feat(fc2b): schema migration 0003 — ML pipeline tables
Renames image_record.wd14_* -> tagger_* (we're on Camie now, not WD14).
Adds tag_allowlist (auto-apply opt-in, per-tag confidence),
tag_suggestion_rejection (per-image dismissals), tag_alias (composite
(string, category) -> canonical tag, resolved at read time),
tag_reference_embedding (per-tag SigLIP centroids), and the ml_settings
singleton (per-category + centroid thresholds, model version pins).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:35:22 -04:00
bvandeusen 23d2fb24ac fix(fc2a): three unit-test failures — async fixture decorator, name convention, lazy celery includes
test_health.py:
  @pytest.fixture on an `async def` function is rejected by pytest-asyncio
  1.x strict mode. Switched to @pytest_asyncio.fixture.

backend/app/models/import_settings.py:
  My constraint name was 'ck_import_settings_singleton' and Base.metadata's
  naming convention applies 'ck_<table>_<name>' on top, so the final ORM
  name was 'ck_import_settings_ck_import_settings_singleton' (double prefix).
  The migration creates the DB constraint as 'ck_import_settings_singleton'
  via raw alembic, so they didn't match. Fix: bare name 'singleton' in the
  model → convention produces 'ck_import_settings_singleton', matching the
  migration's literal name.

tests/test_tasks_register.py:
  Celery's include=[...] parameter on the constructor is lazy — task
  modules aren't imported until a worker boots. The test only imported
  the Celery instance, so the @celery.task decorators in scan.py /
  import_file.py / thumbnail.py never ran. Fix: explicit `import` of
  those modules for side-effect at the top of the test file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:40:47 -04:00
bvandeusen 80a5690740 fix(fc2a): apply ruff autofix + skip vue-tsc check (no tsconfig)
Ruff:
The remaining I001 errors came from ruff treating `alembic` as a first-
party module (because the alembic/ directory exists in the repo root)
rather than third-party. Ran `ruff check --fix` locally — auto-sorted
import groupings to put alembic/sqlalchemy alongside backend.* as first-
party, and trimmed redundant blank lines after a few import blocks.

Frontend:
`npm run check` (vue-tsc --noEmit) was failing because vue-tsc has no
tsconfig.json to read against, and the frontend is pure JS without
JSDoc annotations — vue-tsc had nothing to do. Skipping the step until
we add a tsconfig + convert to TS or add JSDoc annotations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:24:25 -04:00
bvandeusen b4e0d680f1 fix(fc2a): satisfy ruff 0.15.13 lint — UP017, UP042, I001
Ruff lint surfaced 23 violations across three rules; all addressed:

UP017 (Use datetime.UTC alias):
  Replaced 13 sites of datetime.now(timezone.utc) with datetime.now(UTC),
  also adjusted from-imports accordingly. UTC is a Python 3.11+ alias for
  timezone.utc that ruff's pyupgrade rules prefer.

UP042 (StrEnum):
  Replaced `class TagKind(str, Enum)` and `class SkipReason(str, Enum)`
  with `class Foo(StrEnum)`. StrEnum was added in Python 3.11 stdlib and
  is the modern idiom. Behavior is equivalent for our usage (the .value
  attribute, str(member) semantics).

I001 (Import sorting):
  Added `known-first-party = ["backend"]` to ruff.toml's [lint.isort] so
  ruff groups `backend.*` imports correctly. Without it, ruff treated
  them as third-party and demanded a different grouping. The existing
  import order is stdlib → third-party → first-party → local relative,
  which ruff now accepts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:11:35 -04:00
bvandeusen e597a9300e feat(fc2a): add /api/import admin endpoints
trigger enqueues a Celery scan_directory task and returns 202 with the
celery task id (the actual scan happens asynchronously; status surfaces
via /api/import/status and /api/system/stats). list_tasks supports
status filter and cursor pagination. retry-failed resets failed tasks
to queued and re-enqueues. clear-completed deletes finished tasks
older than the supplied age (used by the Settings UI's cleanup control).

mode='deep' is explicitly rejected with 400 until FC-2d ships it; the
frontend's UI in FC-2a only sends 'quick'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:11:49 -04:00
bvandeusen 62eac7cffd fix(fc2a): register settings_bp in all_blueprints
Missed in the Task 11 commit because of a stale-cache Edit failure.
Routes /api/settings/import and /api/system/stats are now actually mounted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:11:10 -04:00
bvandeusen ff28f29dbb feat(fc2a): add /api/settings/import and /api/system/stats endpoints
Settings GET returns the single-row ImportSettings DB record; PATCH does
selective field updates (only known editable fields are applied; unknown
keys silently ignored). System stats returns image/tag/storage counts,
task status histogram, and the active batch summary in one shot for the
Settings overview tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:10:57 -04:00
bvandeusen 0e66368010 feat(fc2a): add /api/tags endpoints (autocomplete, create, image association)
Thin async blueprint delegating to TagService. Returns 400 with the
TagValidationError message on bad kind/fandom combos so the frontend can
surface the reason. List/add/remove endpoints scoped under
/api/images/<id>/tags follow REST conventions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:10:22 -04:00
bvandeusen fd8cf83003 feat(fc2a): add /api/gallery endpoints (scroll, timeline, jump, image detail)
Thin Quart blueprint that delegates to GalleryService. Cursor-based scroll
returns images + a date_groups summary so the SPA can render year-month
headers without re-grouping client-side. Timeline returns year-month
buckets for the sidebar jump nav. Jump returns a cursor positioned at a
specific year-month so the gallery can scroll to historical periods.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:09:44 -04:00
bvandeusen 509c19ce86 feat(fc2a): add maintenance tasks (recovery + cleanup) with beat schedule
recover_interrupted_tasks runs every 5 minutes, finds ImportTask rows
stuck in 'processing' for >30 minutes (well above any legitimate import
duration), and re-queues them. cleanup_old_tasks runs daily and deletes
finished tasks older than 7 days so the task table stays an operational
view rather than an archive.

Both thresholds match ImageRepo's precedent. The 30-min stuck threshold
is documented inline so a future reader can adjust it intentionally
rather than mistaking it for a 'magic number'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:09:09 -04:00
bvandeusen d3bb8c509a feat(fc2a): add Celery tasks — scan_directory, import_media_file, generate_thumbnail
scan_directory walks ImportSettings.import_scan_path, creates an
ImportBatch, enumerates supported files into ImportTasks, and enqueues
import_media_file per task. import_media_file moves the task through
its state machine (pending → queued → processing → complete/skipped/failed),
updates ImportBatch counters atomically (UPDATE ... SET col = col + 1),
enqueues a thumbnail task on success, and marks the batch complete when
the last task drains.

generate_thumbnail runs on its own queue (thumbnail) so big imports
don't starve thumbnail throughput; failure here is logged and does not
fail the import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:08:37 -04:00
bvandeusen f38a1d48c5 feat(fc2a): add GalleryService — cursor scroll, timeline, image detail with neighbors
Cursor format: base64(iso8601_created_at|image_id). Pagination key is
(created_at DESC, id DESC) so we don't drift when new imports land between
page loads. Timeline groups by date_part(year, month) so the sidebar can
render year-month jump buckets. get_image_with_tags returns full image
detail plus prev/next ids so the modal viewer can navigate without an
extra round-trip per arrow press.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:07:54 -04:00
bvandeusen bb9c183ff7 feat(fc2a): add Importer service
Single-file pipeline: validate as image, apply filter rules (min dimensions,
transparency), SHA256 hash dedup, atomic copy to /images/{subdir}/, create
ImageRecord with origin='imported_filesystem' and integrity_status='unknown',
auto-derive top-level folder name as Artist + artist tag.

Importer is intentionally sync (consumed by a Celery worker process); the
async Quart side uses the same ORM through its own async session. The
db_sync fixture in conftest.py was added in Task 3 to support these tests.

Thumbnail generation is NOT inlined; the calling Celery task enqueues a
separate thumbnail task so the import queue keeps moving on big batches.
pHash dedup is FC-2d, not here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:07:01 -04:00
bvandeusen 1a2a120da1 feat(fc2a): add Thumbnailer service
Generates 400px-max thumbnails: PNG with alpha preserved when source has
transparency, JPEG (q=85, progressive) otherwise. Storage layout is
/images/thumbs/{sha[:3]}/{sha}.{jpg|png} so each prefix bucket holds at
most ~4k files (16^3 buckets across all possible SHAs).

Video path uses ffmpeg's first-frame snapshot at ~5% offset; we have a
1-second floor for short clips. ffmpeg call has a 60s timeout. Video
tests are deferred to FC-2e's integration suite where transcoding is
also wired up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:05:19 -04:00
bvandeusen f6c7117231 feat(fc2a): add TagService — find-or-create, autocomplete, image association
Validates kind/fandom rules at the service layer: fandom_id only allowed
for kind='character', and must reference an existing kind='fandom' tag.
Autocomplete ranking: exact match > prefix match > substring, tie-broken
by image_count descending. Image-tag association uses INSERT ON CONFLICT
DO NOTHING for idempotent re-tagging.

conftest.py adds a transactional AsyncSession fixture; each test rolls
back so they don't pollute each other. Also includes a sync Session
fixture (db_sync) for the Importer tests in Task 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:04:48 -04:00
bvandeusen 4cb319b393 feat(fc2a): add slug and paths utilities
slugify() produces ASCII-only lowercase slugs from arbitrary text (used for
artist slugs and tag slugs). paths.derive_subdir/derive_top_level_artist
extract the destination layout and folder→artist convention from an import
source path. hash_suffixed_name builds IR-style 'stem__hash10.ext' filenames.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:04:05 -04:00
bvandeusen 9eb1614fbf feat(fc2a): schema migration 0002 — tag kinds + fandom hierarchy + import lifecycle
Adds Tag.kind enum (artist/character/fandom/general/series/archive/post/meta/rating),
Tag.fandom_id FK with CHECK constraint (only valid for kind='character'), and a
kind-aware uniqueness index so the same name can exist across kinds and the same
character name can exist in different fandoms.

Adds ImportBatch + ImportTask state-machine tables for scan tracking, plus a
single-row ImportSettings table (CHECK id=1) holding the importer's filter knobs.

Adds image_record.integrity_status column (defaults to 'unknown'); FC-2e
populates this via the integrity verifier.

Drops the unused tag.namespace column from FC-1 — superseded by kind.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:03:41 -04:00
bvandeusen 8e61dc4df4 feat: serve the built Vue SPA from Quart with history-mode fallback
The frontend blueprint is registered after the api blueprint so /api/*
routes are matched first; everything else falls through to the SPA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:36:16 -04:00