e67f0ccef0
The operator's category choice is a hypothesis, not truth (user, 2026-09-06): each wash order with a vehicle read queues a package for a trusted reviewer over the private overlay (Netbird); the verdict becomes the phase-B training label and the per-operator error rate. wiki/concepts/vision-review-outbox.md. - Boxes: the vision service returns the vehicle bbox; snapshot.ts stores the vehicle and plate boxes on the read as FRACTIONS of the analysed frame (the stored snapshot is a downscaled copy); vehicleForIdentity() returns them. - carwash_review_outbox (migration 0031) + review-outbox.ts: crop = detector box + 8 % margin, ≤ 640 px, plate blurred in place from the plate box; payload carries a pseudonymous booth id and a keyed operator hash — no site name, no plate, no OSD, no bystanders; multipart POST with a per-booth bearer; 2xx → sent (image dropped); 400/404/413/415/422 → abandoned; anything else → backoff 1 min·2^n capped 6 h; voided orders and items older than 14 days abandoned unsent. Nothing queued while unconfigured. - Enqueue is fire-and-forget off the intake path in createOrder; the loop runs every CARWASH_REVIEW_INTERVAL_SEC (60) and stops on close. - GET /api/carwash/review/status (site:read) + a "Remote review" line in Setup → Car wash. - Env CARWASH_REVIEW_URL / _TOKEN / _BOOTH_ID (all three or off) documented in .env.example and forwarded by compose. - Tests: review-outbox.test.ts (crop + blur on a synthetic frame, config/pseudonyms, queue/drain/backoff/abandon, through the app). Wiki: new concept page, index, venue-modules As built, log. The collector is not built. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
119 lines
4.8 KiB
TypeScript
119 lines
4.8 KiB
TypeScript
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
|
||
import { isNormBox, isVehicleClass, type VehicleRead } from "@parking/shared";
|
||
|
||
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
|
||
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
|
||
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
|
||
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
|
||
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
|
||
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
|
||
//
|
||
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
|
||
// session); within a direction, the newest read wins. Returns the plate text only —
|
||
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
|
||
|
||
/** The best advisory plate observed for a session, for display. */
|
||
export interface PlateView {
|
||
readonly plate: string;
|
||
readonly confidence: number | null;
|
||
readonly direction: "entry" | "exit" | null;
|
||
}
|
||
|
||
interface ReadDetail {
|
||
identity?: string;
|
||
plate?: string;
|
||
confidence?: number;
|
||
direction?: string;
|
||
bodyType?: string;
|
||
bodyConfidence?: number;
|
||
snapshotId?: string;
|
||
vehicleBox?: unknown;
|
||
plateBox?: unknown;
|
||
}
|
||
|
||
/** The advisory VEHICLE read (body type) for a session — the same stream and the same
|
||
* preference as the plate (entry over exit, newest first). Null when vision never
|
||
* classified the vehicle. See venue-modules.md §Vehicle category from vision. */
|
||
export function vehicleForIdentity(db: Db, identity: string): VehicleRead | null {
|
||
const rows = db
|
||
.select({ detail: deviceEvents.detail })
|
||
.from(deviceEvents)
|
||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||
.orderBy(desc(deviceEvents.occurredAt))
|
||
.all();
|
||
let fallback: VehicleRead | null = null;
|
||
for (const r of rows) {
|
||
const d = (r.detail ?? {}) as ReadDetail;
|
||
if (d.identity !== identity || !isVehicleClass(d.bodyType) || typeof d.bodyConfidence !== "number") continue;
|
||
const v: VehicleRead = {
|
||
bodyType: d.bodyType,
|
||
confidence: d.bodyConfidence,
|
||
snapshotId: d.snapshotId ?? null,
|
||
box: isNormBox(d.vehicleBox) ? d.vehicleBox : null,
|
||
plateBox: isNormBox(d.plateBox) ? d.plateBox : null,
|
||
};
|
||
if (d.direction === "entry") return v;
|
||
if (!fallback) fallback = v;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
|
||
export function plateForIdentity(db: Db, identity: string): PlateView | null {
|
||
const rows = db
|
||
.select({ detail: deviceEvents.detail })
|
||
.from(deviceEvents)
|
||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||
.orderBy(desc(deviceEvents.occurredAt))
|
||
.all();
|
||
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
|
||
}
|
||
|
||
/** Resolve plates for MANY identities in one device_events scan (used by the active-
|
||
* sessions list and the feed page, which each carry tens–hundreds of rows). */
|
||
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
|
||
const want = new Set(identities);
|
||
const out = new Map<string, PlateView>();
|
||
if (want.size === 0) return out;
|
||
// Newest first so the first acceptable read per (identity,direction) is the freshest.
|
||
const rows = db
|
||
.select({ detail: deviceEvents.detail })
|
||
.from(deviceEvents)
|
||
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
|
||
.orderBy(desc(deviceEvents.occurredAt))
|
||
.all();
|
||
const byId = new Map<string, ReadDetail[]>();
|
||
for (const r of rows) {
|
||
const d = (r.detail ?? {}) as ReadDetail;
|
||
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
|
||
let list = byId.get(d.identity);
|
||
if (!list) byId.set(d.identity, (list = []));
|
||
list.push(d);
|
||
}
|
||
for (const [id, reads] of byId) {
|
||
const best = pickBest(reads, id);
|
||
if (best) out.set(id, best);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
|
||
* exit read; otherwise the first (newest) acceptable read wins. */
|
||
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
|
||
let fallback: ReadDetail | null = null;
|
||
for (const d of reads) {
|
||
if (d.identity !== identity || !d.plate) continue;
|
||
if (d.direction === "entry") return toView(d);
|
||
if (!fallback) fallback = d;
|
||
}
|
||
return fallback ? toView(fallback) : null;
|
||
}
|
||
|
||
function toView(d: ReadDetail): PlateView {
|
||
return {
|
||
plate: d.plate!.trim().toUpperCase(),
|
||
confidence: typeof d.confidence === "number" ? d.confidence : null,
|
||
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
|
||
};
|
||
}
|