6933406ae3
Skeleton of the host-side vision service per the packaging decision: a Python/FastAPI app at apps/vision/, uv-managed, wired into the Turbo graph via a thin package.json shim (dev/lint/test/build → uv/uvicorn/ruff/pytest). A per-package turbo.json sets build outputs [] so the no-op build is warning-free. Endpoints: GET /health (readiness + model version) and POST /analyze (raw octet-stream body, so Node POSTs Snapshot.bytes directly; empty→400, oversize→413, recognizer-not-ready→503). The recognizer is a Protocol with a StubRecognizer (no models, boots/tests offline — the dev/CI default) and a FastAlprRecognizer (the real MIT YOLOv9+CCT/ONNX stack, lazily imported; missing models ⇒ ready=False, not a crash) — the device-adapter pattern applied to the model. fast-alpr + onnxruntime are an optional `alpr` extra, so `uv sync` needs no model download. Verified: turbo run lint|test|build includes @parking/vision and stays green; uv run mypy strict-clean; uvicorn boots and serves /health + /analyze live; pnpm workspace 6→7. Not built yet: the Node VisionClient adapter, a Dockerfile + model fetch, and Job 2 (vehicle verification). Updates the packaging decision (As-scaffolded) + log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
113 lines
6.9 KiB
Markdown
113 lines
6.9 KiB
Markdown
---
|
|
type: decision
|
|
tags: [parking, decisions, vision, anpr, monorepo, packaging]
|
|
sources: []
|
|
updated: 2026-06-19
|
|
status: settled
|
|
---
|
|
|
|
# Decision: the vision service lives in this monorepo (apps/vision/), wired into Turbo via a shim
|
|
|
|
Taken 2026-06-19, when planning how to *implement* the host-side [[opencv-anpr-service|vision
|
|
service]] decided in [[vision-service]]. That decision settled WHAT (a separate localhost Python
|
|
process) and the recognizer baseline ([[opencv-anpr-service|fast-alpr]]); this one settles WHERE the
|
|
source lives and how it joins the build.
|
|
|
|
## Decision
|
|
|
|
1. **In THIS monorepo, at `apps/vision/`** — a Python/FastAPI service co-located with the Node
|
|
backend, **not** a separate repository. One git history, atomic cross-cutting commits (the
|
|
`/analyze` contract + the Node-side adapter change together), one wiki.
|
|
2. **Still a separate OS process** — co-location is source-level only. It runs as its own process
|
|
(`uvicorn`), called over **localhost HTTP** by the Node backend, with its own failure domain.
|
|
Nothing about putting it in `apps/vision/` weakens the runtime isolation [[vision-service]]
|
|
requires.
|
|
3. **Wired into the Turbo task graph via a thin `package.json` shim.** `pnpm-workspace.yaml` already
|
|
globs `apps/*`, so an `apps/vision/package.json` auto-joins the workspace. Its `scripts` shell out
|
|
to Python tooling, so the existing `turbo run` tasks cover it:
|
|
- `dev` → `uv run uvicorn app:app --reload` (matches `turbo.json` `dev`: persistent, uncached)
|
|
- `lint` → `ruff check` · `test` → `pytest` · `typecheck` → `ruff`/`mypy`
|
|
- `build` → **no-op or model-fetch** (Python has no `dist/**`; the `build` task's `outputs:
|
|
["dist/**"]` simply won't match — fine). If models are fetched/cached at build, point outputs at
|
|
the model dir.
|
|
Python **dependencies** stay managed by `uv` + `pyproject.toml` (NOT pnpm) — the shim only exposes
|
|
*tasks*, not deps.
|
|
4. **Node talks to it through an interface** (`VisionClient` behind a port, the
|
|
[[device-adapter-pattern]] style) so the recognizer/service is swappable without touching business
|
|
logic — as [[opencv-anpr-service]] already specifies.
|
|
|
|
## Why co-located beats a separate repo
|
|
|
|
- **Atomic changes.** The service contract (`POST /analyze` shape) and its Node consumer evolve
|
|
together; one repo = one PR, no two-repo version skew.
|
|
- **`uv` makes Python-in-monorepo painless** — fast, lockfile-based, offline-friendly (fits
|
|
[[offline-first]]); the appliance build pulls a pinned env.
|
|
- **Turbo still orchestrates it.** The shim makes `turbo run lint`/`test` include the Python service
|
|
as a first-class node — one command lints front, back, AND vision — even though Turbo can't *build*
|
|
Python. Turbo orchestrates **tasks**, and a task can be a Python command.
|
|
- **One knowledge base.** The wiki + CLAUDE.md already describe the whole system; a split repo
|
|
fragments that.
|
|
|
|
## Why this still honors the isolation decision
|
|
|
|
The "[[vision-service|separate process]]" decision is about **runtime isolation** (own process +
|
|
failure domain) and **license isolation** (AGPL obligations don't reach the Node/React code because
|
|
it is **not linked** — it's a separate program over HTTP). **Neither depends on a separate
|
|
repository.** AGPL's reach is a linking/distribution-boundary question between *programs*, not a
|
|
which-folder question. A Python service in `apps/vision/` that Node calls over localhost is exactly as
|
|
isolated, license-wise, as one in its own repo.
|
|
|
|
- With the **[[opencv-anpr-service|fast-alpr]] MIT-end-to-end baseline**, the AGPL pressure to split
|
|
the repo out **largely evaporates** (pending the weight-provenance caveat). Co-location is the
|
|
low-friction default.
|
|
- If a true-AGPL model (Ultralytics YOLO) is later adopted, its weights live under `apps/vision/` —
|
|
still fine (separate process), and that dir is the natural place to document the license boundary +
|
|
the `[[standing-decisions|scoped exception]]`.
|
|
|
|
## Rejected
|
|
|
|
- **Separate repo** — strongest separation, but loses atomic contract changes and adds coordination
|
|
overhead; justified only if a different team owns it or the AGPL concern becomes acute. Kept as the
|
|
fallback if either happens.
|
|
- **Embed Python in the Node process** (opencv4nodejs / a child-process module) — already rejected by
|
|
[[vision-service]] (native-build pain, no process isolation, shares the app's failure + license
|
|
surface). Unchanged.
|
|
- **A Python package under `packages/`** — `packages/` is for shared *JS* libraries imported by other
|
|
workspaces; the vision service is a deployable app, so `apps/vision/` is the right bucket.
|
|
|
|
## As-scaffolded (2026-06-19)
|
|
|
|
The skeleton is **built and wired** (no recognizer models yet):
|
|
|
|
- `apps/vision/` — `pyproject.toml` (+ `uv.lock`, uv-managed), the thin `package.json` shim, a
|
|
per-package `turbo.json` (`extends: ["//"]`, `build` outputs `[]` so the no-op build is warning-
|
|
free), `.gitignore` (venv/caches/`*.onnx`/`models/` out), `README`.
|
|
- `vision_service/`: `app.py` (FastAPI `GET /health` + `POST /analyze`, raw octet-stream body so Node
|
|
POSTs `Snapshot.bytes` directly; oversize→413, empty→400, recognizer-not-ready→503), `settings.py`
|
|
(env `VISION_*`), `schemas.py` (the `/analyze` contract incl. a not-yet-populated `vehicle` field
|
|
for Job 2), `recognizer.py` (a `Recognizer` **Protocol** + `StubRecognizer` and `FastAlprRecognizer`
|
|
— the [[device-adapter-pattern]] applied to the model).
|
|
- **Light-core, heavy-optional:** core deps boot in **stub mode** (no model download) so `uv sync` +
|
|
tests work offline; the real stack is the `alpr` extra (`uv sync --extra alpr` →
|
|
fast-alpr + onnxruntime). `VISION_RECOGNIZER=fast_alpr` switches it on.
|
|
- **Verified:** `turbo run lint|test|build` includes `@parking/vision` (ruff/pytest/no-op via the
|
|
shim) and stays green; `uv run mypy` strict-clean; uvicorn boots and serves `/health` (`ready`,
|
|
stub-0) + `/analyze` (contract shape) live. pnpm workspace count 6→7.
|
|
|
|
## Still to build (next, when vision work proceeds)
|
|
|
|
- The Node-side **`VisionClient`** adapter (localhost HTTP) + per-camera **opt-in** wiring (the open
|
|
item in [[opencv-anpr-service]]).
|
|
- A **`Dockerfile`**/process unit for the appliance (its own image/process); model-weight fetch at
|
|
deploy (the `alpr` extra), kept out of git ([[opencv-anpr-service|weight-provenance]] check first).
|
|
- **Job 2** (vehicle attributes / fingerprint) — the `vehicle` field is scaffolded but unpopulated;
|
|
fast-alpr is plate-only. Built later on the same ONNX runtime.
|
|
|
|
## Open
|
|
|
|
- `uv` vs. `pip-tools`/`poetry` for the Python env (leaning `uv` — speed + lockfile + offline).
|
|
- Whether `build` should fetch/cache model weights (and set Turbo `outputs` to the model dir) or keep
|
|
weights out of the build entirely (baked into the Docker image instead).
|
|
- Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail,
|
|
defer to the install/hardening pass.
|