feat(vision): add VisionClient Node adapter (advisory, fail-soft, opt-in)

Node-side adapter to the apps/vision ANPR microservice (localhost HTTP: POST /analyze
with snapshot bytes, GET /health), returning a normalised VisionResult or null. Enforces
"advisory, never sole authority" at the boundary: opt-in (VISION_ENABLED, default off),
fail-soft (any error/timeout/unreachable → null, never throws into the lane → ticket
fallback), and re-applies the confidence floor (VISION_MIN_CONFIDENCE) on top of the
service's own low_confidence flag. Per-request AbortController timeout so a slow call
can't hang the barrier. Constructed in server.ts.

Verified: fail-soft (disabled/unreachable → null, no throw) and live end-to-end (Node
client → running fast_alpr service → AA558EE 0.999, region=Albania). NOT yet wired into
the read bus — the opt-in snapshot→DeviceReadEvent{kind:"plate"} trigger is the next
step. Build + lint green. Updates opencv-anpr-service (adapter gap marked done).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 15:58:43 +02:00
parent 17fdf3d482
commit 236cbfecab
4 changed files with 220 additions and 9 deletions
+13 -9
View File
@@ -165,15 +165,19 @@ the read is given** — and the answer splits by role:
is NOT built**. So plate-as-identity is convenience + evidence, never the lone reason a paid barrier
opens. Consistent with "advisory, never sole authority" above.
**Gaps before it's actually consumed (capable ≠ wired):** (1) the Node→service **`VisionClient`**
adapter (localhost HTTP, behind the [[device-adapter-pattern]] interface) — the real integration work;
(2) **trigger wiring** — snapshots today fire *after* a barrier opens (evidence); plate-as-identity
needs a snapshot *before* the decision, on a **per-camera opt-in** lane (open item below); (3)
**field-accuracy** unknown — re-benchmark/tune the threshold on real on-site captures
(angle/night/dirt); (4) the **weight-provenance** check (open). **Bottom line: consume it as a
gated advisory identity source feeding the existing `kind:"plate"` path — not as sole authority — and
Job 2 is still required for the anti-spoofing value.** Next concrete step is the `VisionClient` adapter
+ the opt-in trigger, not more model work.
**Gaps before it's actually consumed (capable ≠ wired):** (1) ✅ **DONE — the Node→service
`VisionClient`** adapter (`apps/server/src/vision-client.ts`, localhost HTTP to `/analyze` + `/health`)
now exists: **opt-in** (`VISION_ENABLED`, default off), **fail-soft** (any error/timeout/unreachable →
`null`, never throws into the lane → ticket-path fallback), and **re-applies the confidence floor**
(`VISION_MIN_CONFIDENCE`) so a low read is flagged advisory. Constructed in `server.ts`; verified
end-to-end against the live service (Node → `AA558EE` 0.999, `region=Albania`). Still NOT wired into the
read bus. (2) **trigger wiring** — snapshots today fire *after* a barrier opens (evidence);
plate-as-identity needs a snapshot *before* the decision, on a **per-camera opt-in** lane → emit
`DeviceReadEvent{kind:"plate"}` (open item below). (3) **field-accuracy** unknown — re-benchmark/tune
the threshold on real on-site captures (angle/night/dirt). (4) the **weight-provenance** check (open).
**Bottom line: consume it as a gated advisory identity source feeding the existing `kind:"plate"` path
— not as sole authority — and Job 2 is still required for the anti-spoofing value.** With the adapter
done, the next concrete step is the **opt-in snapshot→read trigger**, not more model work.
## Open